これは簡単な問題ではないので、読んでください!
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]: オリジナルとのImgurの違い(問題に関係なく、画像のアップロード時に余分なアーティファクトを引き起こさないことを示すためだけに): IrfanView 100%[728k](視覚的には元のものと同じ): IrfanView 100%のオリジナルとの違い(ほとんど何もありません) Android 100%[942k]: Android 100%のオリジナルとの違い(色合い、バンディング、スミアリング)
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プレビューエミュレーターとそれらは同じ問題を再現します。
いくつかの調査の後、犯人を見つけました: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
_のスタックです
jpeg_write_scanlines
_( jcapistd.c:77 )rgb2yuv_32
_( SkImageDecoder_libjpeg.cpp:91 )writer(=Write_32_YUV).write
( SkImageDecoder_libjpeg.cpp:961 )WE_CONVERT_TO_YUV
_ は無条件に定義されます]SkJPEGImageEncoder::onEncode
_( SkImageDecoder_libjpeg.cpp:1046 )SkImageEncoder::encodeStream
_( SkImageEncoder.cpp:15 )Bitmap_compress
_( Bitmap.cpp:38 )Bitmap.nativeCompress
_( Bitmap.Java:157 )Bitmap.compress
_( Bitmap.Java:984 )app.saveBitmapAsJPEG
_()およびYuvImage
を使用するためのスタック
jpeg_write_raw_data
_( jcapistd.c:12 )YuvToJpegEncoder::compress
_( YuvToJpegEncoder.cpp:71 )YuvToJpegEncoder::encode
_( YuvToJpegEncoder.cpp:24 )YuvImage_compressToJpeg
_( YuvToJpegEncoder.cpp:219 )YuvImage.nativeCompressToJpeg
_( YuvImage.Java:141 )YuvImage.compressToJpeg
_( YuvImage.Java:12 )app.saveNV21AsJPEG
_()_rgb2yuv_32
_フローから_Bitmap.compress
_の定数を使用することで、実績ではなくYuvImage
を使用して同じバンディング効果を再作成することができました。めちゃめちゃ。 YuvImage
がlibjpeg
を呼び出している間ではないことを再確認しました:ビットマップのARGBをYUVに変換してRGBに戻し、結果のピクセルblobを生画像としてダンプすることにより、バンディングが既に行われましたそこ。
これを行っている間、NV21/YUV420SPレイアウトは4番目のピクセルごとに色情報をサンプリングするため、損失が多いことに気付きましたが、各ピクセルの値(明るさ)を保持するため、色情報は失われますが、人々の目に関する情報のほとんどはとにかく明るさにあります。 wikipedia の example を見てください。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形式と奇数画像の寸法 を参照してください。