WallStudio

技術ブログや創作ブログに届かない雑記です

【かみしば】画像が正しく表示されないバグに関する調査(Android Camera2 API)

これだからハードウェア依存のアプリケーションは難しいのです…

経緯と症状

夏コミのタイミングでAndroid向けに「かみしば」というアプリをリリースしました。かみしば対応同人誌にカメラをかざすとナレーションが再生されるアプリです。

…とAndroidに関しては無事リリースしたかに思われたのですが、カメラが映らないよというお問い合わせをいただきました。

バグを直すためには「再現」が必須です。再現性のないバグは直すことはまずできません。しかし、エミュレータでは正常に動作してしまいました。もう諦めるしかないのか…と思ったのですがしゃんずさんが「開発や改善に御協力できればと思います」と仰って下さったので、お言葉に甘えて遠隔デバッグが始まりました。

余談ですが、しゃんずさんは以前からよくうちのブースに足を運んでくださるお方で、しかも毎回大変、大変嬉しいお言葉をかけて頂いて、はい、神様です。疲れてうまくいかなくてもう面倒くさいし辞めようかなと思う時もしゃんずさんの言葉を思い出してモチベーションを保っています。ゆかりさんの尊いMMD劇場動画も制作されているので視ると幸せになれますよ。

www.nicovideo.jp

分析

本題に戻りますが、問い合わせの画像にある様にカメラの映像が乱れてしまっている問題です。このままでは原因が分からないのでログを収集するプログラムを仕込んだバージョンを動かしていただきました。

# 件の端末
BMP_W=864
BMP_H=480
IMG_W=864
IMG_H=480
IMG_PLANES_COUNT=3
IMG_ROW_STRIDE_Y=896
IMG_ROW_STRIDE_V=896
IMG_ROW_STRIDE_U=896
IMG_PX_STRIDE_Y=1
IMG_PX_STRIDE_V=2
IMG_PX_STRIDE_U=2
# 開発機
BMP_W=640
BMP_H=480
IMG_W=640
IMG_H=480
IMG_PLANES_COUNT=3
IMG_ROW_STRIDE_Y=640
IMG_ROW_STRIDE_V=320
IMG_ROW_STRIDE_U=320
IMG_PX_STRIDE_Y=1
IMG_PX_STRIDE_V=1
IMG_PX_STRIDE_U=1

注目すべきは、IMG_WIMG_ROW_STRIDE_Y です。

Strideとは、二次元座標上で表現される画像を一次元座標(アドレス)上のメモリに格納するために「何Byteで次の行に折り返すのか」という値です。普通に考えれば1byteで1pixelですので、IMG_W=IMG_ROW_STRIDE_Yとなるはずです。しかし、件の端末ではそうはなっていません。どうしてこういうことになってしまうかというと、メモリの読み込みは「64byteいっぺんに読み込まれるから」です。64というはたまたまで32かもしれないし128かもしれませんが、高速化の為にそういう仕組みになっているので、その恩恵を最大で受けられるようにStrideは64の整数倍になっています。(差分の32byteは無駄になりますが背に腹です)

この問題は以前、Windows向けの動画編集ソフトを開発していた時に経験していました、なのでもっと早くに疑っても良かったのですが…

ついでに IMG_PX_STRIDE_V の値が2、つまり1ピクセル当たり2byte??!!そんなこともあるのか…しかも中身は0~255で全然8byteに収まるはずです。恐らくですが、「RGB2YUV変換の際に負の領域を使うから、初めから余分に負の分も付けとこ~」みたいなノリなんじゃないかと思われます。 インターリーブ、つまりUVが交互に1Byteずつ入っているようです。(でもそれだとPlane2枚でいいのでは???摩訶不思議)Googleのリファレンスによれば、データパディングやインターリーブに使われますとのことなので少なくとも16bit深度のピクセルが出てくるわけではなさそうです。ともかく偶数番目をデータパディングだと思って捨てればきれいにデコードできました。

プログラムの修正

兎にも角にも原因は突き止めたのでソースコードに反映させるのみです。愚直にすべてwidthで計算していたのがいけません。適宜Strideで計算させます。

# pythonで取り敢えずデコードしてみる

# ファイル読み込み
with open(yfile, mode="rb") as yplane :
    with open(ufile, mode="rb") as uplane :
        with open(vfile, mode="rb") as vplane :
            ya = yplane.read()
            ua = uplane.read()
            va = vplane.read()

            uua = []
            vva = []
            for i in range(0, int(len(ua) / 2)) :
                # 1pxcel = 2byteなので2byteずつまとめる
                uua.append(ua[i*2])
                vva.append(va[i*2])

            for j in range(0, height) :
                for i in range(0, stride) :
                    if i < 864 and j < height : # 何故かStride*height分のでータが無い(尻切れてる)
                        ii = int(i // 2 + (j // 2) * (stride // 2))
                        y = ya[i + j * stride]
                        u = uua[ii] if len(uua) > ii else 0[f:id:yukawallstudio:20181010034900p:plain]
                        v = vva[ii] if len(vva) > ii else 0
                        # YUV2RGB変換して画像行列にポイ

デコード結果のSSでも貼りたいところですが、あくまで私が撮影したものではないので…でもちゃんと目視で正常な風にデコードできました、信じて。

そもそもの過ち

マジックナンバーもりもり手抜き工事

マジックナンバー、すなわちプログラマーが「きっとこの値だろう」と決めつけて書いてしまうことです。今回なら「width=Strideだろう」、「1pixel=1byteに決まってる」と端から決めてかかっていたのが根源です。一方でマジックナンバーを排除するには「正しいパラメータを決定する機能」が必要になるのでその分労力がかかります。

当時の心境を思い起こすと、「兎に角コミケまでに完成させなきゃ」という一心だけでした。思えば始めの頃は、このYUVデコードの部分も後で書き直そうと思ってた仮実装が切羽詰まってきて「動いてるからもういいや」に変わっていました。

最終的なプログラム

inline void  ThrowJavaException(JNIEnv *env, std::string message){
    jclass  exception = env->FindClass("java/lang/RuntimeException");
    env->ThrowNew(exception, message.c_str());
}

inline uint32_t Yuv2Rgb(const float y, const float u, const float v);

extern "C"
JNIEXPORT void JNICALL
Java_wallstudio_work_kamishiba_Jni_yuvByteArrayToBmp(JNIEnv *env, jclass type,
                                                     jobject bufferY, jobject bufferU, jobject  bufferV,
                                                     jint bufferYLength, jint bufferULength, jint bufferVLength,
                                                     jobject destBitmap,
                                                     jint bitmapWidth, jint bitmapHeight,
                                                     jint imageWidth, jint  imageHeight,
                                                     jint imagePlanesCount,
                                                     jint imageRowStrideY, jint imageRowStrideU, jint imageRowStrideV,
                                                     jint imagePixelStrideY, jint imagePixelStrideU, jint imagePixelStrideV) {

    try {

        if (imagePlanesCount != 3) {
            ThrowJavaException(env, "Plane count need 3. (now " + std::to_string(imagePlanesCount) + ")");
            return;
        }
        // 入力Planeの準備
        uint8_t *planeY = reinterpret_cast<uint8_t *>(env->GetDirectBufferAddress(bufferY));
        uint8_t *planeU = reinterpret_cast<uint8_t *>(env->GetDirectBufferAddress(bufferU));
        uint8_t *planeV = reinterpret_cast<uint8_t *>(env->GetDirectBufferAddress(bufferV));
        // ref. https://developer.android.com/reference/android/graphics/ImageFormat.html#YUV_420_888
        if (!((imagePixelStrideY == 1 || imagePixelStrideY == 2 || imagePixelStrideY == 4) &&
            (imagePixelStrideU == 1 || imagePixelStrideU == 2 || imagePixelStrideU == 4) &&
            (imagePixelStrideV == 1 || imagePixelStrideV == 2 || imagePixelStrideV == 4) &&
            imagePixelStrideU == imagePixelStrideV)) {
            ThrowJavaException(env, "Pixel strides need 1,2,4. (now " +
                                    std::to_string(imagePixelStrideY) + "-" +
                                    std::to_string(imagePixelStrideU) + "-" +
                                    std::to_string(imagePixelStrideV) + ")");
            return;
        }
        if (imageRowStrideU != imageRowStrideV){
            ThrowJavaException(env, "Row stride U,V need same (now " +
                    std::to_string(imageRowStrideU) + "-" +
                    std::to_string(imageRowStrideV) + ")");
            return;
        }

        // 出力先の準備
        AndroidBitmapInfo info;
        if (AndroidBitmap_getInfo(env, destBitmap, &info) < 0) {
            ThrowJavaException(env, std::string("Failed AndroidBitmap_getInfo()"));
            return;
        }
        if (info.format != ANDROID_BITMAP_FORMAT_RGBA_8888) {
            ThrowJavaException(env, "Bitmap format need RGBA. (now " + std::to_string(info.format) + ")");
            return;
        }
        int bitmapStride = info.stride;
        void *bitmapRawPtr;
        if (AndroidBitmap_lockPixels(env, destBitmap, &bitmapRawPtr) < 0) {
            ThrowJavaException(env, std::string("Failed AndroidBitmap_lockPixels()"));
            return;
        }
        uint32_t *destArray = reinterpret_cast<uint32_t *>(bitmapRawPtr);

        // 走査
        int i = 0, j = 0;
        for (int c = 0; c < bufferYLength; c++) {
            i = c % imageRowStrideY;
            j = c / imageRowStrideY;
            int idxY = i + j * imageRowStrideY;
            int idxU = (i / 2 * imagePixelStrideU) + (j / 2) * imageRowStrideU;
            int idxV = (i / 2 * imagePixelStrideV) + (j / 2) * imageRowStrideU;
            float y = idxY < bufferYLength ? planeY[idxY] : 0;
            float u = idxU < bufferULength ? planeU[idxU] : 0;
            float v = idxV < bufferVLength ? planeV[idxV] : 0;
            if(i < bitmapStride / sizeof(uint32_t) && j < bitmapHeight)
                destArray[i + j * bitmapStride / sizeof(uint32_t)] = Yuv2Rgb(y, u, v);
        }
        AndroidBitmap_unlockPixels(env, destBitmap);

    }catch (std::exception e){
        ThrowJavaException(env, std::string(e.what()));
    }
}

inline uint32_t Yuv2Rgb(const float y, const float u, const float v){
    double r = y + (1.4065 * (u - 128));
    double g = y - (0.3455 * (u - 128)) - (0.7169 * (v - 128));
    double b = y + (1.7790 * (v - 128));

    if (r < 0) r = 0;
    else if (r > 255) r = 255;
    if (g < 0) g = 0;
    else if (g > 255) g = 255;
    if (b < 0) b = 0;
    else if (b > 255) b = 255;

    return ((uint32_t) 0xFFU << 24) + ((uint32_t) r << 16) + ((uint32_t) g << 8) + (uint32_t) b;
}

今後のかみしば

  • 今週末までに、この問題を修正した新バージョンをリリースします

    しました。 現在オープンβで公開中です。

  • iOS対応に関してですが、所謂クラウド型で開発を模索中です。

    データ量、計算量ともに重めなので、端末側での処理が理想ですが、私の開発環境では不足過ぎて正直いって無茶です。せめてiPhone 6S以上があれば…(今あるのがiPod touch 5G;RAM512MBww)

  • スタンドモードの削除

    分かり辛さを助長している割に、精度がおよろしくないので。ハンズフリーにはなるけど、案外利用者が気を使わないといけないというのも小手先で何とかなる話ではありませんし。

  • かみしばのサイトの方は近々非公開にします。

    気を付けていても潜在的なセキュリティリスクは存在します。ましてや私なんぞがたった一人突貫で作ったシステムです。利用者もいないので現状リスクしかないという状況です。利用者を増やす(知り合いの作家に打診して回る)というのもあまり気が進まないです。(画期性が低いという話)

  • アルゴリズムがオリジナルな部分があるのでその部分については全く別のところで動いています。

まとめ

改めて、何度も何度もデバッグにお付き合いいただきましてしゃんずさんには感謝してもし切れません。誠にありがとうございました。また、実はるなまーくさんにも嘗てバグを指摘いただいていて(そっちは簡単に直せたので今回のように泥沼に巻き込むことはありませんでしたが)ありがとうございました。