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