Bitmap画像の一致を確認するHamcrestのMatcher

投稿日:  更新日:

Bitmap画像の一致を確認するHamcrestのMatcherを作成したので紹介します。

このMatcherは画像をピクセル単位で比較して一致を確認します。

アプリのスナップショットの確認用に作成しました。プログラム中で確認できるので、テストの自動化に役立つと思います。

スポンサーリンク

画像の一致を確認する方法

Bitmap画像をピクセル単位で比較する方法を2つ上げます。

Bitmap#sameAs()(完全一致、既存のNativeコード)

Bitmap#sameAs()で比較する方法はNativeなので実行が高速です。

Androidシステムのメモリ上に置かれたデータを、メモリー領域の比較(C/C++のmemcmp)で比較しています。

次のように記述出来ます。

        ...
        val _match = _actual.sameAs(_expect) // 完全一致確認
		...

注意点として、≧API26でsameAsの比較方法が変更になっています。

下記はシステムのソースコードから比較を行っている部分を抜粋したものです。

static jboolean Bitmap_sameAs(JNIEnv* env, jobject, jlong bm0Handle, jlong bm1Handle) {
    SkBitmap bm0;
    SkBitmap bm1;
    ...
    if (bm0.width() != bm1.width() ||
        bm0.height() != bm1.height() ||
        bm0.colorType() != bm1.colorType()) {
        return JNI_FALSE;
    }
	...
	return JNI_TRUE;
}
static jboolean Bitmap_sameAs(JNIEnv* env, jobject, jlong bm0Handle, jlong bm1Handle) {
    SkBitmap bm0;
    SkBitmap bm1;
	...
	if (bm0.width() != bm1.width()
            || bm0.height() != bm1.height()
            || bm0.colorType() != bm1.colorType()
            || bm0.alphaType() != bm1.alphaType()
            || !SkColorSpace::Equals(bm0.colorSpace(), bm1.colorSpace())) {
        return JNI_FALSE;
    }
	...
	return JNI_TRUE;
}

Bitmap属性のalphaTypeとcolorSpaceの比較が追加になっています。

つまり、≧API26は画像のピクセル値が一致しているとしても、alphaType(hasAlphaのこと)やcolorSpaceが異なれば不一致と判定されます。

不一致した時の理由を明確にするため、Fail情報に上記の属性値を出力することをお勧めします。

Bitmap#aboutSameAs()(ほぼ一致、新規Nativeコード)

Bitmap画像をピクセル単位でサーチしながら比較を行います。

ピクセル値の違いにマージンを持たせています。なので、「完全一致」ではなく、「ほぼ一致」を目的にした比較を行います。

高速化を狙ってNativeコードで記述してみました。

#include <jni.h>
#include <string>
#include <android/log.h>
#include <android/bitmap.h>

typedef struct {
    uint8_t red;
    uint8_t green;
    uint8_t blue;
    uint8_t alpha;
} rgba;

extern "C" JNIEXPORT jboolean JNICALL
Java_PACKAGE_BitmapNativeFunc_00024Companion_compare(
        JNIEnv* env,
        jobject obj,
        jobject jbitmapA,
        jobject jbitmapB,
        jint margin) {

    int result;

    AndroidBitmapInfo infoA, infoB;
    result = AndroidBitmap_getInfo(env, jbitmapA, &infoA);
    if(result < 0) return false;
    result = AndroidBitmap_getInfo(env, jbitmapB, &infoB);
    if(result < 0) return false;

    if(infoA.format != ANDROID_BITMAP_FORMAT_RGBA_8888) return false;
    if(infoB.format != ANDROID_BITMAP_FORMAT_RGBA_8888) return false;
    if(infoA.width != infoB.width || infoA.height != infoB.height) return false;

    void *pixelsA, *pixelsB;
    result = AndroidBitmap_lockPixels(env, jbitmapA, &pixelsA);
    if(result < 0) return false;
    result = AndroidBitmap_lockPixels(env, jbitmapB, &pixelsB);
    if(result < 0) return false;

    bool match = true;
    for(int y = 0; y < infoA.height; y++) {
        rgba *lineA = (rgba *)((uint8_t *)pixelsA + infoA.stride * y);
        rgba *lineB = (rgba *)((uint8_t *)pixelsB + infoB.stride * y);
        for(int x = 0; x < infoA.width; x++) {
            bool match_red   = abs(lineA[x].red   - lineB[x].red)   <= margin;
            bool match_green = abs(lineA[x].green - lineB[x].green) <= margin;
            bool match_blue  = abs(lineA[x].blue  - lineB[x].blue)  <= margin;
            bool match_alpha = abs(lineA[x].alpha - lineB[x].alpha) <= margin;
            match = match_red && match_green && match_blue && match_alpha && match;
        }
    }

    AndroidBitmap_unlockPixels(env, jbitmapA);
    AndroidBitmap_unlockPixels(env, jbitmapB);

    return match;
}

拡張関数化して既存のBitmapt#sameAs()と同等な使い方を実現しています。

class BitmapNativeFunc {

    companion object {
        init {
            System.loadLibrary("bitmap-lib")
        }
        external fun compare(bitmapA: Bitmap, bitmapB: Bitmap, margin: Int): Boolean
    }
}

fun Bitmap.aboutSameAs(bitmap: Bitmap, margin: Int = 4): Boolean {
    if(this.hasAlpha() != bitmap.hasAlpha())
        return false
    if(Build.VERSION.SDK_INT >= 26 && this.colorSpace != bitmap.colorSpace)
        return false
    return BitmapNativeFunc.compare(this, bitmap, margin)
}
        ...
        val _match = _actual.aboutSameAs(_expect) // ほぼ一致確認
		...
スポンサーリンク

Matcherの作成

TypeSafeMatcher(タイプチェック有)を継承して作成します。Bitmap固有のsameAsメソッドを使って判定を行うためです。

Matcherの作成方法は「HamcrestのMatcherの作り方」を参照してください。

Matcher本体(IsSameAs:完全一致)

class IsSameAs(private var expect: Bitmap) : TypeSafeMatcher<Bitmap>() {

    private val info: MutableList<String> = ArrayList()

    override fun matchesSafely(actual: Bitmap): Boolean {
        val _match = actual.sameAs(expect)

        if (! _match) {
		    // ↓ infoへ不一致ピクセルの情報を蓄積
            // ↓ resultへ不一致ピクセルのハイライト画像を出力
            // ↓ ※今回は省略
            // val _result = createResult(actual, expect, 0, info)
            // saveDebugData(actual, expect, _result)
        }

        return _match
    }

    override fun describeMismatchSafely(
            actual: Bitmap, mismatchDescription: Description) {
//        super.describeMismatchSafely(actual, mismatchDescription)

        mismatchDescription.appendText("<%s>\n".format(getSummary(actual)))
        if(info.size > 0) {
            mismatchDescription.appendText("\n\n")
            mismatchDescription.appendText("debug info:\n")
            for (_L in info) {
                mismatchDescription.appendText("%s\n".format(_L))
            }
        }
    }

    override fun describeTo(description: Description) {
        description.appendText("<%s>".format(getSummary(expect)))
    }
}

Matcher本体(IsAboutSameAs:ほぼ一致)

class IsAboutSameAs(private var expect: Bitmap, private val margin: Int = 4)
    : TypeSafeMatcher<Bitmap>() {

    private val info: MutableList<String> = ArrayList()

    override fun matchesSafely(actual: Bitmap): Boolean {
        val _match = actual.aboutSameAs(expect, margin)

        if (! _match) {
			// ↓ infoへ不一致ピクセルの情報を蓄積
            // ↓ resultへ不一致ピクセルのハイライト画像を出力
            // ↓ ※今回は省略
            // val _result = createResult(actual, expect, margin, info)
            // saveDebugData(actual, expect, _result)
        }

        return _match
    }

    override fun describeMismatchSafely(
            actual: Bitmap, mismatchDescription: Description) {
//        super.describeMismatchSafely(actual, mismatchDescription)

        mismatchDescription.appendText("<%s>\n".format(getSummary(actual)))
        if(info.size > 0) {
            mismatchDescription.appendText("\n\n")
            mismatchDescription.appendText("debug info:\n")
            for (_L in info) {
                mismatchDescription.appendText("%s\n".format(_L))
            }
        }
    }

    override fun describeTo(description: Description) {
        description.appendText("<%s>".format(getSummary(expect)))
    }
}

Fail情報(属性の出力)

private fun getSummary(bitmap: Bitmap): String {
    return if (Build.VERSION.SDK_INT >= 26) {
        String.format("%s %sx%s %s hasAlpha:%s %s",
            bitmap.toString(),
            bitmap.width, bitmap.height,
            bitmap.config.toString(),
            bitmap.hasAlpha(),
            bitmap.colorSpace.toString())
    } else {
        String.format("%s %sx%s %s hasAlpha:%s",
            bitmap.toString(),
            bitmap.width, bitmap.height,
            bitmap.config.toString(),
            bitmap.hasAlpha())
    }
}
スポンサーリンク

Matcherの使い方

記述例です。サンプルの画像の一致比較を行っています。

    @Test
    fun isSameAs_test() {
        assertThat(sample320x480, IsSameAs(test320x480))
		assertThat(sample320x480, IsAboutSameAs(test320x480)) // marginはデフォルト
		assertThat(sample320x480, IsAboutSameAs(test320x480, margin = 8))
    }

比較対象の両サンプル画像は一部が異なるため不一致になります。以下は、その時のFail情報です。

java.lang.AssertionError:
Expected: <android.graphics.Bitmap@3e086b0 320x480 ARGB_8888 sRGB IEC61966-2.1 (id=0, model=RGB)>
but: <android.graphics.Bitmap@bc32929 320x480 ARGB_8888 sRGB IEC61966-2.1 (id=0, model=RGB)>

debug info:
Point(307,6) A(0xFF126358) != E(0xFF1B695E)
Point(308,6) A(0xFF0F6156) != E(0xFF0D6054)
Point(305,7) A(0xFF33796F) != E(0xFF5E958D)
Point(306,7) A(0xFFC8DBD8) != E(0xFFDFEAE8)
Point(307,7) A(0xFFFBFCFC) != E(0xFFFFFFFF)
スポンサーリンク

不一致になったら確認すること

画像のフォーマット

jpgもpngも不可逆圧縮の画像フォーマットです。

アルゴリズムが違うので復元後のBitmapの画素値は異なります。

例えば元画像が同じでも、jpgから復元した画像とpngから復元した画像は異なります。

画像の所在

Androidは画面を鮮明に表示するため、スクリーンの解像度ごとに画像リソースのサイズを、動的に切り替えています。

drawableがhdpとxhdpから作られたBitmapは、元データが違うため両者の画素値は異なります。

作成(変換)経路

jpgもpngも不可逆圧縮の画像フォーマットです。

変換bitmap⇒png⇒bitmapを1回と変換bitmap⇒png⇒bitmapを2回行った画像を比べた場合、圧縮・復元を繰り返した方が画像は劣化します。劣化の度合いにより両者の画素値は異なります。

また、同じ画像フォーマットでも圧縮率によって復元された画素値は変わります。

その他、変換(移動、回転、スケーリング)の順番により異なる場合もあります。

Open GL ESの違い

Androidシステムは画面を高速に描画するためにOpen GL ESを使っています。

エミュレータ(AVD)はOpen GL ESの実行をHardwareまたはSoftwareのどちらかで行います。ユーザにより選択が可能です。

この両者には演算誤差があって、LSB 1bit単位で違いが発生する場合があるようです。

スポンサーリンク
スポンサーリンク