web-dev-qa-db-ja.com

Androidで品質の低下を最小限に抑えてビットマップをJPEGとして圧縮する方法

これは簡単な問題ではないので、読んでください!

JPEGファイルを操作して、再度JPEGとして保存したい。問題は、操作しなくても重大な(目に見える)品質の損失があることです。 質問:品質を低下させることなくJPEGを再圧縮できるようにするために不足しているオプションまたはAPI以下で説明するのは、特にquality = 100の場合、許容されるレベルのアーティファクトではありません。

コントロール

ファイルからBitmapとしてロードします:

_BitmapFactory.Options options = new BitmapFactory.Options();
// explicitly state everything so the configuration is clear
options.inPreferredConfig = Config.ARGB_8888;
options.inDither = false; // shouldn't be used anyway since 8888 can store HQ pixels
options.inScaled = false;
options.inPremultiplied = false; // no alpha, but disable explicitly
options.inSampleSize = 1; // make sure pixels are 1:1
options.inPreferQualityOverSpeed = true; // doesn't make a difference
// I'm loading the highest possible quality without any scaling/sizing/manipulation
Bitmap bitmap = BitmapFactory.decodeFile("/sdcard/image.jpg", options);
_

次に、比較するコントロールイメージを得るために、プレーンビットマップバイトをPNGとして保存しましょう。

_bitmap.compress(PNG, 100/*ignored*/, new FileOutputStream("/sdcard/image.png"));
_

これをコンピューター上の元のJPEG画像と比較したところ、視覚的な違いはありませんでした。

また、getPixelsから生の_int[]_を保存し、コンピューターに生のARGBファイルとしてロードしました。元のJPEGやビットマップから保存されたPNGには視覚的な違いはありません。

ビットマップのサイズと構成を確認しました。ソースイメージと入力オプションに一致しています。期待どおり_ARGB_8888_としてデコードされています。

チェックを制御する上記は、メモリ内ビットマップ内のピクセルが正しいことを証明しています。

問題

結果としてJPEGファイルを取得したいので、上記のPNGおよびRAWのアプローチは機能しません。最初にJPEG 100%として保存してみましょう。

_// 100% still expected lossy, but not this amount of artifacts
bitmap.compress(JPEG, 100, new FileOutputStream("/sdcard/image.jpg"));
_

その測定値がパーセントかどうかはわかりませんが、読みやすく議論しやすいので、使用します。

私は、100%の品質のJPEGがまだ損失があることを知っていますが、視覚的にそれほど損失が大きくないため、遠くから目立つことはありません。次に、同じソースの2つの100%圧縮の比較を示します。

それらを別々のタブで開き、前後にクリックして、意味を確認します。差異画像は、Gimpを使用して作成されました。下層としてオリジナル、「Grain extract」モードで再圧縮された中間層、「Value」モードで上層を完全に白くして、悪さを高めます。

以下の画像はImgurにアップロードされ、ファイルも圧縮されますが、すべての画像が同じように圧縮されるため、元の不要なアーティファクトは元のファイルを開いたときと同じように表示されたままになります

オリジナル[560k]: Original picture オリジナルとのImgurの違い(問題に関係なく、画像のアップロード時に余分なアーティファクトを引き起こさないことを示すためだけに): imgur's distortion IrfanView 100%[728k](視覚的には元のものと同じ): 100% with IrfanView IrfanView 100%のオリジナルとの違い(ほとんど何もありません) 100% with IrfanView diff Android 100%[942k]: 100% with Android Android 100%のオリジナルとの違い(色合い、バンディング、スミアリング) 100% with Android diff

IrfanViewでは、50%[50k]を下回って、リモートで同様の効果を確認する必要があります。 IrfanViewの70%[100k]では、目立った違いはありませんが、サイズはAndroidの9倍です。

バックグラウンド

Camera APIから写真を撮るアプリを作成しました。その画像は_byte[]_であり、エンコードされたJPEG blobです。このファイルをOutputStream.write(byte[])メソッドで保存しました。これは元のソースファイルです。 decodeByteArray(data, 0, data.length, options)は、ファイルからの読み取りと同じピクセルをデコードし、_Bitmap.sameAs_でテストされているため、問題とは無関係です。

私はSamsung Galaxy S4でAndroid 4.4.2を使用してテストしました。編集:さらに調査しながら、Android 6.0およびNプレビューエミュレーターとそれらは同じ問題を再現します。

17
TWiStErRob

いくつかの調査の後、犯人を見つけました:SkiaのYCbCr変換。再現、調査用のコード、およびソリューションは TWiStErRob/AndroidJPEG にあります。

発見

この質問に対して肯定的な応答が得られなかった後( http://b.Android.com/206128 からでもない)、私はより深く掘り始めました。半分の情報に基づいた多数のSO回答を見つけたため、断片を発見するのに非常に役立ちました。そのような答えの1つは https://stackoverflow.com/a/13055615/253468 で、YUV NV21バイト配列をJPEG圧縮バイト配列に変換するYuvImageを認識しました。

_YuvImage yuv = new YuvImage(yuvData, ImageFormat.NV21, width, height, null);
yuv.compressToJpeg(new Rect(0, 0, width, height), 100, jpeg);
_

YUVデータの作成には、さまざまな定数と精度で多くの自由があります。私の質問から、Androidが間違ったアルゴリズムを使用していることは明らかです。オンラインで見つけたアルゴリズムと定数で遊んでいると、いつも悪い画像が表示されました。明るさが変わったか、質問と同じバンディングの問題がありました。

より深く掘る

YuvImageは_Bitmap.compress_を呼び出すときに実際には使用されません。これは_Bitmap.compress_のスタックです

およびYuvImageを使用するためのスタック

_rgb2yuv_32_フローから_Bitmap.compress_の定数を使用することで、実績ではなくYuvImageを使用して同じバン​​ディング効果を再作成することができました。めちゃめちゃ。 YuvImagelibjpegを呼び出している間ではないことを再確認しました:ビットマップのARGBをYUVに変換してRGBに戻し、結果のピクセルblobを生画像としてダンプすることにより、バンディングが既に行われましたそこ。

これを行っている間、NV21/YUV420SPレイアウトは4番目のピクセルごとに色情報をサンプリングするため、損失が多いことに気付きましたが、各ピクセルの値(明るさ)を保持するため、色情報は失われますが、人々の目に関する情報のほとんどはとにかく明るさにあります。 wikipediaexample を見てください。CbおよびCrチャンネルはほとんど認識できない画像なので、その上での損失の多いサンプリングはあまり重要ではありません。

溶液

したがって、この時点で、libjpegが適切な生データを渡されたときに適切な変換を行うことがわかりました。これは、NDKをセットアップし、 http://www.ijg.org から最新のLibJPEGを統合したときです。実際にビットマップのピクセル配列からRGBデータを渡すと、期待どおりの結果が得られることを確認できました。絶対に必要でない場合はネイティブコンポーネントを使用しないようにしたいので、ビットマップをエンコードするネイティブライブラリを別にして、適切な回避策を見つけました。基本的に_rgb_ycc_convert_関数を_jcolor.c_から取得し、 https://stackoverflow.com/a/13055615/253468のスケルトンを使用してJavaに書き換えました。 。以下は速度のために最適化されていませんが、読みやすさ、簡潔さのためにいくつかの定数が削除されました。それらはlibjpegコードまたは私のサンプルプロジェクトで見つけることができます。

_private static final int JSAMPLE_SIZE = 255 + 1;
private static final int CENTERJSAMPLE = 128;
private static final int SCALEBITS = 16;
private static final int CBCR_OFFSET = CENTERJSAMPLE << SCALEBITS;
private static final int ONE_HALF = 1 << (SCALEBITS - 1);

private static final int[] rgb_ycc_tab = new int[TABLE_SIZE];
static { // rgb_ycc_start
    for (int i = 0; i <= JSAMPLE_SIZE; i++) {
        rgb_ycc_tab[R_Y_OFFSET + i] = FIX(0.299) * i;
        rgb_ycc_tab[G_Y_OFFSET + i] = FIX(0.587) * i;
        rgb_ycc_tab[B_Y_OFFSET + i] = FIX(0.114) * i + ONE_HALF;
        rgb_ycc_tab[R_CB_OFFSET + i] = -FIX(0.168735892) * i;
        rgb_ycc_tab[G_CB_OFFSET + i] = -FIX(0.331264108) * i;
        rgb_ycc_tab[B_CB_OFFSET + i] = FIX(0.5) * i + CBCR_OFFSET + ONE_HALF - 1;
        rgb_ycc_tab[R_CR_OFFSET + i] = FIX(0.5) * i + CBCR_OFFSET + ONE_HALF - 1;
        rgb_ycc_tab[G_CR_OFFSET + i] = -FIX(0.418687589) * i;
        rgb_ycc_tab[B_CR_OFFSET + i] = -FIX(0.081312411) * i;
    }
}

static void rgb_ycc_convert(int[] argb, int width, int height, byte[] ycc) {
    int[] tab = LibJPEG.rgb_ycc_tab;
    final int frameSize = width * height;

    int yIndex = 0;
    int uvIndex = frameSize;
    int index = 0;
    for (int y = 0; y < height; y++) {
        for (int x = 0; x < width; x++) {
            int r = (argb[index] & 0x00ff0000) >> 16;
            int g = (argb[index] & 0x0000ff00) >> 8;
            int b = (argb[index] & 0x000000ff) >> 0;

            byte Y = (byte)((tab[r + R_Y_OFFSET] + tab[g + G_Y_OFFSET] + tab[b + B_Y_OFFSET]) >> SCALEBITS);
            byte Cb = (byte)((tab[r + R_CB_OFFSET] + tab[g + G_CB_OFFSET] + tab[b + B_CB_OFFSET]) >> SCALEBITS);
            byte Cr = (byte)((tab[r + R_CR_OFFSET] + tab[g + G_CR_OFFSET] + tab[b + B_CR_OFFSET]) >> SCALEBITS);

            ycc[yIndex++] = Y;
            if (y % 2 == 0 && index % 2 == 0) {
                ycc[uvIndex++] = Cr;
                ycc[uvIndex++] = Cb;
            }
            index++;
        }
    }
}

static byte[] compress(Bitmap bitmap) {
    int w = bitmap.getWidth();
    int h = bitmap.getHeight();
    int[] argb = new int[w * h];
    bitmap.getPixels(argb, 0, w, 0, 0, w, h);
    byte[] ycc = new byte[w * h * 3 / 2];
    rgb_ycc_convert(argb, w, h, ycc);
    argb = null; // let GC do its job
    ByteArrayOutputStream jpeg = new ByteArrayOutputStream();
    YuvImage yuvImage = new YuvImage(ycc, ImageFormat.NV21, w, h, null);
    yuvImage.compressToJpeg(new Rect(0, 0, w, h), quality, jpeg);
    return jpeg.toByteArray();
}
_

魔法の鍵は_ONE_HALF - 1_のようで、残りはSkiaの数学に酷似しています。これは今後の調査のための良い方向ですが、私にとっては上記はAndroidのビルトインの奇妙さを回避するための良い解決策となるには十分に簡単ですが、遅くなります。 このソリューションでは、色情報の3/4(Cr/Cbから)を失うNV21レイアウトを使用していますが、この損失はSkiaの数学によって作成されたエラーよりもはるかに少ないことに注意してくださいまた、YuvImageは奇数サイズの画像をサポートしていません。詳細については、 NV21形式と奇数画像の寸法 を参照してください。

21
TWiStErRob