11 minute read

android.graphics.Matrix는 Android에서 2D 그래픽을 이동시키거나, 회전하거나, 크기를 바꾸는 등 좌표를 변환하기 위해 활용되는 클래스입니다. 해당 클래스는 단순한 수학적 행렬이 아닌 그래픽 좌표 변환을 위한 행렬로 2D 좌표 공간에서 다양한 변환을 통해 특수한 요구사항에 따라 이미지 처리를 도와주는 역할을 합니다.


구조

Matrix의 구조에 대해 이해한다면, Matrix의 기능을 적극적으로 활용하는 데 많은 도움이 될 수 있습니다. 먼저 Matrixandroid.graphics 패키지 내에 존재하며, Matrix 클래스의 일부를 아래에서 확인하실 수 있습니다. ( 더욱 자세한 정보는 [해당 소스]를 참고하시길 바랍니다. )

public class Matrix {

    ...

    private final long native_instance;
    
    
    ...
    
    
    public boolean preSkew(float kx, float ky, float px, float py) {
        nPreSkew(native_instance, kx, ky, px, py);
        return true;
    }

    /**
     * Preconcats the matrix with the specified skew. M' = M * K(kx, ky)
     */
    public boolean preSkew(float kx, float ky) {
        nPreSkew(native_instance, kx, ky);
        return true;
    }

    /**
     * Preconcats the matrix with the specified matrix. M' = M * other
     */
    public boolean preConcat(Matrix other) {
        nPreConcat(native_instance, other.native_instance);
        return true;
    }

    /**
     * Postconcats the matrix with the specified translation. M' = T(dx, dy) * M
     */
    public boolean postTranslate(float dx, float dy) {
        nPostTranslate(native_instance, dx, dy);
        return true;
    }

    /**
     * Postconcats the matrix with the specified scale. M' = S(sx, sy, px, py) * M
     */
    public boolean postScale(float sx, float sy, float px, float py) {
        nPostScale(native_instance, sx, sy, px, py);
        return true;
    }

    /**
     * Postconcats the matrix with the specified scale. M' = S(sx, sy) * M
     */
    public boolean postScale(float sx, float sy) {
        nPostScale(native_instance, sx, sy);
        return true;
    }

    
    ...

    @CriticalNative
    private static native void nPreSkew(long nObject, float kx, float ky, float px, float py);
    @CriticalNative
    private static native void nPreSkew(long nObject, float kx, float ky);
    @CriticalNative
    private static native void nPreConcat(long nObject, long nOther_matrix);
    @CriticalNative
    private static native void nPostTranslate(long nObject, float dx, float dy);
    @CriticalNative
    private static native void nPostScale(long nObject, float sx, float sy, float px, float py);
    @CriticalNative
    private static native void nPostScale(long nObject, float sx, float sy);
    
    ...
    
 }  

Matrix 클래스의 구조를 보면 Bitmap 구조와 마찬가지로 일반 자바 메서드와 JNI가 공존하며, 자바 메서드의 내부는 JNI를 래핑하여 구현된 것을 확인하실 수 있습니다. 이는 Matrix 클래스의 실제 로직은 네이티브 수준에서 처리된다는 것과 Java/Kotlin에 존재하는 메서드는 단지, 개발자가 편리하게 사용할 수 있도록 구현된 인터페이스임을 의미합니다. 실제 android.graphics.Matrix 에 존재하는 JNI 들은 네이티브 영역에 존재하는 Skia 엔진의 SkMatrix와 연결되게 됩니다. 이러한 구조는 Java에서 발생하는 연산비용을 최소화하면서 네이티브의 강력한 연산 성능을 활용할 수 있게 도와줍니다.

추가적으로 코드 상단을 확인해보면, native_instance 라는 필드가 존재함을 확인할 수 있습니다. 이는 네이티브 수준에 있는 Skia 라이브러리의 SkMatrix 객체를 가리키는 포인터이며, 해당 필드를 통해 자바에서 네이티브 코드를 직접 연산을 위임할 수 있습니다.

SkMatrix

SKMatrix는 네이티브에 존재하는 Skia 엔진의 일부분으로 android.graphics.Matrix와 연결되는 클래스입니다. 해당 클래스를 자세히 다루기보단 대략적인 요소만 확인하여 Matrix를 더욱 잘 이해할 수 있는 방향으로 글을 이어 나가겠습니다.

SkMatrix는 Android의 그래픽 엔진인 Skia에서 2D 변환을 표현하고 처리하는데, 사용되는 클래스로 android.graphics.Matrix가 래핑한 실제 기능은 SkMatrix에서 처리됩니다. 아래 코드의 일부를 보면 TypeMaskfMat[9] 가 존재함을 확인하실 수 있습니다.

class SK_API SkMatrix {
public:
		
		...

    enum TypeMask {
        kIdentity_Mask = 0,
        kTranslate_Mask = 0x01,
        kScale_Mask = 0x02,
        kAffine_Mask = 0x04,
        kPerspective_Mask = 0x08
    };
    
    ...
    
    SkScalar fMat[9];
    
    ...
};

(더욱 자세한 소스는 [해당 링크]를 통해 확인하실 수 있습니다.)

fMat

SkMatrix는 내부적으로 9개의 SkScalar값을 저장하는 배열 fMat[9]을 가지고 있습니다. 해당 배열은 내부적으로 아래와 같은 3x3 동차 행렬 형태로 구성되어 있습니다.

\[\begin{pmatrix}fMat[0]&fMat[1]&fMat[2]\\fMat[3]&fMat[4]&fMat[5]\\fMat[6]&fMat[7]&fMat[8]\end{pmatrix}\]

이 행렬은 아핀 변환(Affine Transformation)을 위한 형식이며, 각 요소의 값을 변경하는 방식으로 이미지를 변환할 수 있습니다. 행렬의 fMat[0], fMat[4] 요소는 스케일, fMat[1], fMat[3]은 회전 및 기울이기, 그리고 fMat[2], fMat[5]는 이동을 제어할 때 사용합니다. 마지막 행은 항상 0 0 1로 2D 변환을 고정하기 위해 사용합니다.

행렬의 구조를 설명하는 과정에서 동차 좌표계, 아핀 변환과 같은 새로운 용어가 등장하였는데, 잠시 해당 용어들을 간략하게 정리하고 내용을 이어가도록 하겠습니다.


동차 좌표계

동차 좌표계(Homogeneous Coordinates)는 N차원 공간의 점을 N+1차원 공간의 점으로 표현하는 방식입니다. 2D 포인트 (x,y)를 동차 좌표에서 표현할 때 (x,y,1)로 표현되며, 마지막 1은 동차 요소(Homogeneous Component)라고 불립니다. 좌표 하나를 추가하는 이유는 기본적으로 동일한 차원에 행렬에서는 모든 변환을 행렬 곱셈으로 수행할 수 없기 때문입니다. 따라서 차원을 하나 추가하는 방식으로 변환 연산을 가능하게 하는것이 동차 좌표계를 사용하는 이유입니다.

아핀 변환

아핀 변환은 점, 직선, 도형 등을 이동, 회전, 확대, 축소, 대칭 등의 방식으로 변형시키는 변환으로 원래 도형의 모양이 크게 바뀌지는 않지만 위치, 크기, 기울기 등을 바꿀 수 있는 연산입니다. (아핀 변환에 대한 더욱 자세한 내용은 [해당 문서]를 통해 확인하실 수 있습니다.)

먼저 아핀 변환을 사용하기 위해서는 아래와 같이 픽셀의 위치를 동차 좌표계를 통해 확장해야 합니다. 기존 픽셀은 2D 포인트로 구성되어 있기 때문에 차원을 높혀 (x,y,1)로 설정합니다.

\[\begin{pmatrix}x\\y\\1\end{pmatrix}\]

어떠한 x,y 위치에 있는 픽셀에 대해 아핀변환을 수행한다면 아래와 같은 공식을 통해 결괏값인 x, y에 위치시킬 수 있게 됩니다. 아래 공식은 fMat 행렬과 픽셀 동차좌표계에 대한 행렬 곱셈의 결과이며, a, b, c, d, e, f 는 앞서 언급된 fMat 행렬의 각 요소를 단순하게 기호로 표시한 것 입니다. (아래 공식은 행렬 곱셈 연산에 대한 이해가 필요합니다.)

x' = ax + cy + e
y' = bx + dy + f

이제 대표적으로 이동(translation) 변환에 대한 예시를 들어보겠습니다. 이동에 대한 행렬은 아래와 같은 구조로 구성됩니다.

\[\begin{pmatrix}1&0&dx\\0&1&dy\\0&0&1\end{pmatrix}\]

해당 행렬을 앞서 언급한 fMat 행렬에 대입하여 연산을 수행할 경우 아래와 같은 공식으로 정리됩니다. 즉, 해당 행렬을 사용하여 연산을 수행할 경우 x, y 위치에 있는 픽셀이 dx,dy만큼 멀어진 x, y 위치로 변환됩니다.

x' = (1 * x) + (0 * y) + dx
y' = (0 * x) + (1 * y) + dy
w  = (0 * x) + (0 * y) + (1 * 1) //(동차 요소)

x' = x + dx
y' = y + dy

지금까지 이동에 대한 변환 예시를 들었는데, 실제로 동차좌표를 통해 확장하지 않으면, dx, dy 만큼 포인트가 이동된 결과와 같은 공식을 얻어낼 수는 없습니다. fMat의 각 요소들의 값을 잘 설정한다면, 이동 외에도 확대/축소, 기울이기, 회전과 같은 다양한 2D 변환에 대한 결과를 도출해 낼 수 있습니다.

지금까지는 하나의 픽셀에 대한 2D 변환에 관해 얘기하였습니다. 하지만 이미지는 여러 개의 픽셀로 구성된 하나의 집합입니다. 하나의 픽셀을 변환하는 방법을 안다면 나머지도 마찬가지로 똑같은 방식으로 수행하면 됩니다. 결론적으로 행렬을 통한 이미지 변환의 방법은 각 픽셀의 위치(x,y)에 대하여 개별적으로 행렬 변환 연산을 수행한 후 연산 결과인 (x,y)에 해당 픽셀을 그려 넣는 방식으로 진행됩니다.

TypeMask

TypeMask는 Skia에서 Matrix가 어떤 종류의 변환을 포함하고 있는지를 빠르게 판단하기 위한 플래그입니다. 해당 플래그를 통해 필요한 계산을 최소화할 수 있고, 불필요한 연산을 건너뛰어 렌더링 성능을 크게 개선할 수 있습니다. 이때 가장 비용이 적은 연산부터 시작해 복잡한 연산 순으로 판단하여 복잡한 계산은 조기에 건너뛰도록 구성되어 있습니다.

kIdentity_Mask (0x00)

kIdentity_Mask은 SkMatrix가 단위행렬 상태임을 나타내는 플래그로 행렬이 아무런 변환도 포함하고 있지 않다는 것을 의미합니다. 다시 말해, 해당 행렬을 어떤 포인트에 곱하더라도 그 점의 위치, 크기, 회전 등은 전혀 변하지 않고 원본 그대로 유지됩니다. SkMatrix는 자신의 fTypeMask 의 플래그가 kIdentity_Mask로 되어있는 것을 확인할 경우, GPU나 CPU가 변환과 관련된 어떠한 계산도 할 필요가 없다는 것을 즉시 알아차립니다.

\[\begin{pmatrix}1&0&0\\0&1&0\\0&0&1\end{pmatrix}\]

kTranslate_Mask (0x01)

kTranslate_Mask는 SkMatrix**현재 평행 이동 **변환만을 포함하고 있음을 나타내는 플래그로 행렬이 단순히 x축과 y축 방향으로 옮긴다는 것을 의미합니다. 아래의 행렬에서 tx, ty는 각각 x축과 y축으로 이동할 거리를 의미합니다.

\[\begin{pmatrix}1&0&tx\\0&1&ty\\0&0&1\end{pmatrix}\]

kScale_Mask (0x02)

kScale_Mask는 SkMatrix가 현재 오직 확대/축소(Scale) 변환만을 포함하고 있음을 나타내는 플래그로 행렬이 대상을 더 크게 또는 더 작게 만듭니다. 여기서 sx와 sy는 각각 x축과 y축의 확대, 축소비율을 의미합니다.

\[\begin{pmatrix}sx&0&0\\0&sy&0\\0&0&1\end{pmatrix}\]

kAffine_Mask (0x04)

kAffine_Mask는 SkMatrix가 아핀 변환을 포함하고 있음을 나타내는 플래그로 이동과 확대, 축소뿐만 아니라 회전이나 왜곡과 같은 좀 더 복합적인 변환까지 모두 포함하는 연산임을 나타냅니다.

\[\begin{pmatrix}a&b&c\\d&e&f\\0&0&1\end{pmatrix}\]

예를 들어, 점 (x, y)에 아래와 같은 아핀변환 행렬을 곱한다면 아래와 같은 조합으로 계산됩니다. 물론 아핀 변환은 단순한 이동이나 스케일보다는 복잡도가 더 높고 연산량도 증가하지만, 하지만 원근 변환처럼 나눗셈 연산이 필요로 하지 않기 때문에, 여전히 효율적으로 처리될 수 있습니다.

x' = (a * x) + (b * y) + c
y' = (d * x) + (e * y) + f

kPerspective_Mask (0x08)

kPerspective_Mask는 SkMatrix가 원근 투영을 포함하고 있음을 나타내는 플래그로 단순히 2D 평면에서 움직이거나 크기를 바꾸는 것을 넘어 원근감 표현하는 연산임을 의미합니다.

해당 연산은 다른 연산들과는 다르게 마지막 행의 값은 더 이상 0, 0, 1로 고정되지 않습니다. 이 연산은 3D 공간에서의 원근감을 흉내 내는 데 사용되며, 앞 연산들 보다 연산량이 많이 증가합니다. Skia는 해당 TypeMask가 있을 경우 별도의 고급 경로로 처리합니다. 이때 최종적으로 구해진 x’, y’ 값을 w′ 로 나누는 정규화 과정이 필요하므로, 나눗셈 연산이 요구됩니다.

\[\begin{pmatrix}a&c&e\\b&d&f\\g&h&i\end{pmatrix}\]

처리과정

앞서 언급되었듯이, android.graphics.Matrix는 Java/Kotlin 코드에서 직접 사용할 수 있는 2D 변환 행렬 클래스입니다. 하지만, 이 클래스는 실제 복잡한 행렬 계산을 직접 수행하지 않으며 네이티브 영역에서 동작하는 Skia 엔진의 SkMatrix 객체를 통해 모든 연산이 처리됩니다.

예를 들어, 이미지를 회전시키기 위해 아래와 같은 코드를 작성했다고 가정해봅시다.

Matrix matrix = new Matrix();
matrix.setRotate(45);

Matrix.setRotate() 메서드 내부에서는 JNI를 통해 네이티브의 rotate() 함수를 호출합니다. 이때, Java/Kotlin 영역의 Matrix 객체가 보유하고 있던 native_instance 값과 함께 45 라는 각도 값을 네이티브 함수로 전달합니다. JNI를 통해 호출된 네이티브 함수는 전달받은 포인터를 사용하여 해당 SkMatrix 객체에 직접 접근합니다. 그 후 SkMatrix의 rotate 함수를 호출하여 실제 회전 연산을 수행합니다. 이 연산의 결과로 결정된 SkScalar 값들은 SkMatrix **객체 내부에 저장됩니다.

Java/Kotlin 영역의 Matrix 객체는 연산 결과가 담긴 네이티브 SkMatrix 객체의 메모리 주소만 알고 있을 뿐, 그 내부 데이터를 직접 들여다보거나 조작할 수 없습니다. 따라서 Java/Kotlin에서 Matrix의 현재 상태를 확인하고 싶을 경우에는 getValues()와 같은 메서드를 호출해야 합니다.

기능

Matrix 는 앞서 이동,회전, 확대/축소 등과 같은 다양한 2D 변환을 제공한다고 언급하였습니다. 여기에 추가로 Matrix는 이러한 2D 변환을 중첩하여 사용할 수 있는 특징이 존재합니다. 하지만 해당 중첩 기능은 변환이 적용되는 순서에 따라 다른 결과를 생성할 수 있다는 유의점이 존재합니다. 해당 순서를 명확하게 정리해서 구현하여야 요구사항에 맞는 이미지 결과를 생성할 수 있게 됩니다.

Matrix의 중첩된 2D 변환은 이러한 사항을 제어할 수 있도록 선행(pre)과 후행(post)에 대한 연산이 존재합니다. pre 접두사가 붙은 메서드들은 현재 행렬에 앞서서 새로운 변환을 적용합니다. 이는 변환 순서에서 새로운 변환이 가장 먼저 처리되는 것을 의미합니다. 만약 Matrix가 특정 회전 변환을 가지고 있다 가정하였을 때, preTranslate(300,0)이라는 메서드를 호출할 경우 객체는 회전되기 전에 x축으로 300만큼 이동하고 그 후 회전효과를 적용하게 됩니다. post 접두사가 붙은 메서드들은 반대로 현재 행렬 뒤에 새로운 변환을 적용하는 것입니다. 동일한 예로 Matrix가 특정 회전 변환을 가지고 있는 상태에서 postTranslate(300,0)을 호출한다면, 객체는 먼저 회전이 완료된 후, 그 회전된 상태 그대로 x축으로 300만큼 이동하게 됩니다.

실제로 아래 사진을 보면, 연산 순서에 따라 결과가 다르게 표시되는 것을 알 수 있습니다. 왼쪽 사진은 postRotate(45f)postTranslate(300f, 0f) 연산을 사용한 것이며, 오른쪽은postRotate(45f)preTranslate(300f, 0f) 을 사용한 것입니다.

실제 Matrix 객체에서 사용할 수 있는 메서드들은 앞서 언급된 네이티브 연산 기반 하에서 동작됩니다. 따라서 사용할 수 있는 메서드들 또한 마찬가지로 2D 변환에 기반합니다. 추가로 앞서 pre…() 및 post…()에 관한 메서드를 언급하였는데, matrix 설정을 지정해 버리는 set…() 관련 메서드가 존재합니다. 이러한 pre…(),post…(),set…() 메서드를 자유롭게 사용하여 요구사항에 맞는 2D 변환 작업물을 얻어낼 수 있습니다.

이동 (Translation)

이미지의 좌표를 수평, 수직 방향으로 이동시키는 기능입니다.

val matrix = Matrix()
matrix.setTranslate(100f, 50f)

확대/축소 (Scale)

이미지의 크기를 변경할 때 사용되며, 중심점을 변경할 수 있습니다. 중심점 관련 파라미터를 사용하지 않을 경우, 원점(0,0)을 기준으로 확대/축소 변환이 일어납니다.

val matrix = Matrix()
matrix.setScale(2f, 2f)
matrix.setScale(2f, 2f, px, py)

회전 (Rotate)

이미지를 회전시킵니다. 기본적으로는 중심점 기준으로 회전하며, 이미지가 화면 밖으로 나갈 수 있습니다. 마찬가지로 중심점 관련 파라미터를 사용하지 않을 경우, 원점(0,0)을 기준으로 변환이 일어납니다.

val matrix = Matrix()
matrix.setRotate(45f)
matrix.setRotate(45f, px, py)

기울이기 (Skew)

이미지를 기울입니다. 이때 기울인다는 의미는 직사각형을 평행사변형처럼 변형시키는 것과 유사합니다. 해당 메서드 또한 마찬가지로 중심점 관련 파라미터를 사용하지 않을경우, 원점(0,0)을 기준으로 변환이 일어납니다.

val matrix = Matrix()
matrix.setSkew(0.5f, 0f)
matrix.setSkew(0.5f, 0f, px, py)

기타

Matrix에서 2D 변환 외에도 객체의 상태나 값을 불러오는 추가적인 메서드들이 존재합니다. 아래에서는 간략하게 메서드의 일부를 소개하며, 기능에 관한 내용을 마치겠습니다. 그 외 추가적인 메서드나 정보들은 [공식 문서]를 통해 확인하실 수 있씁니다.

  • reset() : 현재 Matrix를 단위행렬 상태로 되돌리는 메서드입니다. 해당 메서드를 사용할 경우 모든 변환이 초기화됩니다.
  • isIdentity() : 현재 Matrix가 단위행렬인지 확인하는 플래그입니다.
  • getValues(float[] values) : **현재 Matrix 내부 9개의 스칼라값을 특정 배열에 복사합니다. 현재 Matrix가 어떤 상태일지 확인할 때 사용되며, 사전에 정보를 담은 배열을 선언해야 합니다.

다른 클래스와의 관계

앞서 살펴본 Matrix는 그래픽 변환을 정밀하게 제어할 수 있는 도구로 Bitmap에서 활용할 수 있습니다. MatrixBitmap의 위치, 크기, 방향 등을 결정짓는 핵심 역할을 합니다.

Bitmap.createBitmap()에는 Matrix를 파라미터로 받아 변환된 Bitmap을 생성하는 파라미터가 존재합니다. 이 방법은 원본 이미지를 변경하지 않고, 변환된 새 이미지로 렌더링하고자 할 때 유용합니다. (예: 썸네일 미리보기, 회전된 이미지 저장 등)

이는 아래와 같이 호출할 수 있습니다.

val transformedBitmap = Bitmap.createBitmap(
    bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true
)

위 코드에서처럼 마지막 인자인 filter를 true로 설정할 경우 단순한 픽셀 복사가 아닌 이중선형 필터링(Bilinear Filtering)을 적용하여 이미지가 더 부드럽게 리사이즈되거나 회전됩니다.

이중선형 필터링(Bilinear Filtering)은 실제 크기보다 크거나 작게 표시되는 텍스처를 보간하는데, 사용되는 방식입니다. 예를 들어 이미지를 확대/축소하거나 회전할 때 원본 픽셀의 변환된 위치가 정확하게 일치하지 않을 경우(소수점 좌표로 떨어지는 케이스)에서 색상 결정의 어려움이 존재합니다. 물론 가장 가까운 픽셀의 색상을 가져다 쓰면 되지만, 계단처럼 끊겨저 보이는 현상이 나타날 수 있습니다. 이러한 문제를 해결하기 위해 주변 4방향의 픽셀값을 바탕으로 색상을 결정하여 부드럽게 보이게 하는 것이 이중선형 필터링 기법입니다. 해당 방식은 계산량은 많아질 수 있어도 더 높은 품질의 결과를 제공합니다. 자세한 정보는 [해당 문서]를 참고 하시길 바랍니다.

또 다른 방법으로는 Canvas를 통해 Matrix를 설정하는 방법입니다. 우선 Canavs는 내부적으로 현재 상태에 대한 Matrix를 가지고 있지만, Matrix 객체를 덮어씌울 수 있는 setMatrix(Matrix matrix) 메서드가 존재합니다. 이를 통해 사전에 정의한 MatrixCanvas 내에 적용할 수 있게 됩니다. 아래는 setMatrix()의 내부 로직입니다.

public void setMatrix(@Nullable Matrix matrix) {
    nSetMatrix(mNativeCanvasWrapper, matrix == null ? 0 : matrix.ni());
}

추가로 CanvasBitmap을 그림과 동시에 Matrix 적용하는 방식도 가능합니다. 먼저 Bitmap.createBitmap() 등을 통해 메모리상에 픽셀 데이터를 갖는 Bitmap을 생성한다고 하였을 때, 이 시점에서 Bitmap은 단순히 픽셀 데이터의 집합일 뿐입니다. 이를 실제 화면에서 그리기 위해선 CanvasdrawBitmap()을 호출하여야 하는데 이때 Matrix를 파라미터를 추가한다면 지정된 Matrix 기반으로 이미지의 위치, 회전, 스케일등이 적용된 이미지를 Canvas 내에 생성할 수 있게 됩니다. 아래는 drawBitmap()의 내부 로직입니다.

public void drawBitmap(@NonNull Bitmap bitmap, @NonNull Matrix matrix, @Nullable Paint paint) {
    super.drawBitmap(bitmap, matrix, paint);
}

android.graphics.Matrix 클래스는 다양한 이미지 2D 변환과 관련한 요구사항을 효과적으로 구현해 낼 수 있는 도구입니다. 하지만, 내부 구조와 pre()post() 같은 변환을 제어하는 방식을 이해하지 않는 기반에서 사용할 경우에는 의도하지 않은 결과물을 낼 수 있습니다. Matrix에 대한 전반적인 구조를 해당 포스팅에서 다루었지만, 가장 좋은 방식은 소스코드와 공식 문서를 찾아가며 직접 이해하고 적용하는 것입니다. 참고하기 좋은 레퍼런스들을 남기며 글을 마무리합니다.