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単位で違いが発生する場合があるようです。