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