Bitmap画像の一致を確認するTruthのSubject

投稿日:  更新日:

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

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

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

スポンサーリンク

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

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) // ほぼ一致確認
		...
スポンサーリンク

BitmapSubjectの作成

Subjectを継承して作成します。

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

Subject本体

class BitmapSubject
    private constructor(metadata: FailureMetadata, private val actual: Bitmap)
    : Subject(metadata, actual) {

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

    // ----- テスト
     fun isSameAs(expect: Bitmap) {
        val _match = actual.sameAs(expect)
        if (! _match) {
		    // ↓ infoへ不一致ピクセルの情報を蓄積
			// ↓ resultへ不一致ピクセルのハイライト画像を出力
			// ↓ ※今回は省略
            // val _result = createResult(actual, expect, 0, info)
            // saveDebugData(actual, expect, _result)
            failWithActual("is same as", getSummary(expect))
        }
    }
    fun isAboutSameAs(expect: Bitmap, margin: Int = 4) {
        val _match = actual.aboutSameAs(expect, margin)
        if (! _match) {
 		    // ↓ infoへ不一致ピクセルの情報を蓄積
			// ↓ resultへ不一致ピクセルのハイライト画像を出力
			// ↓ ※今回は省略
			// val _result = createResult(actual, expect, margin, info)
            // saveDebugData(actual, expect, _result)
            failWithActual("is about same as", getSummary(expect))
        }
    }

    override fun actualCustomStringRepresentation(): String {
        val _buffer = StringBuffer()
        _buffer.append(getSummary(actual))
        if (info.size > 0) {
            _buffer.append("\n\n")
            _buffer.append("debug info:\n")
            for (_L in info) {
                _buffer.append(String.format("%s\n", _L))
            }
        }
        return _buffer.toString()
    }

    // ----- ファクトリー
    companion object {
        fun assertThat(bitmap: Bitmap): BitmapSubject {
            return Truth.assertAbout(bitmaps()).that(bitmap)
        }

        fun bitmaps(): Factory<BitmapSubject, Bitmap> {
            return Factory {
                metadata, actual -> BitmapSubject(metadata, actual)
            }
        }
    }
}

※com.google.truth:truth:1.0対応

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())
    }
}
スポンサーリンク

BitmapSubjectの使い方

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

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

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

is same as:
android.graphics.Bitmap@4e059f3 320x480 ARGB_8888 sRGB IEC61966-2.1 (id=0, model=RGB)
but was:
android.graphics.Bitmap@3e086b0 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単位で違いが発生する場合があるようです。

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