如何在Android上将Bitmap压缩为JPEG质量损失最小?

TWi*_*Rob 17 android jpeg image bitmap image-compression

这不是一个简单的问题,请仔细阅读!

我想操作JPEG文件并将其再次保存为JPEG.问题是,即使没有操纵,也会出现明显(可见)的质量损失. 问题:我缺少什么选项或API,能够在没有质量损失的情况下重新压缩JPEG(我知道这不完全可能,但我认为我在下面描述的不是可接受的工件级别,特别是质量= 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);
Run Code Online (Sandbox Code Playgroud)

现在,为了比较一个控制图像,让我们将普通的Bitmap字节保存为PNG:

bitmap.compress(PNG, 100/*ignored*/, new FileOutputStream("/sdcard/image.png"));
Run Code Online (Sandbox Code Playgroud)

我把它与我电脑上的原始JPEG图像进行了比较,没有视觉差异.

我仍然保存着原始int[]getPixels并加载它作为我的电脑上的原始ARGB文件:没有视觉差于原来的JPEG,也不是从位图保存PNG.

我检查了Bitmap的尺寸和配置,它们匹配源图像和输入选项:它ARGB_8888按预期解码.

以上控制检查证明内存中位图中的像素是正确的.

问题

我希望有JPEG文件,所以上面的PNG和RAW方法不起作用,让我们先尝试以100%格式保存:

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

我不确定它的百分比是多少,但它更容易阅读和讨论,所以我会用它.

我知道质量为100%的JPEG仍然是有损的,但它不应该在视觉上有损,以至于远远看来它是显而易见的.这是相同来源的两次100%压缩的比较.

在单独的选项卡中打开它们,然后在它们之间来回点击以查看我的意思.使用Gimp制作差异图像:原始作为底层,使用"谷物提取"模式重新压缩中间层,使用"值"模式的顶层全白色以增强不良性.

下面的图像上传到Imgur,它也压缩文件,但由于所有图像都被压缩相同,原始的不需要的工件仍然可见,就像我打开原始文件时看到的那样.

原[560k]: 原始图片 Imgur与原始版本的区别(与问题无关,只是为了表明在上传图像时不会造成任何额外的工件): imgur的失真 IrfanView 100%[728k](视觉上与原始相同): 100%使用IrfanView Irfan查看与原版100%的差异(几乎没有) 100%使用IrfanView diff Android 100%[942k]: 100%使用Android Android 100%与原版的区别(着色,条带,拖尾) 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因此与问题无关.

我正在使用我的三星Galaxy S4与Android 4.4.2进行测试.编辑:在进一步调查时我也尝试了Android 6.0和N预览模拟器,他们重现了同样的问题.

TWi*_*Rob 21

经过一番调查后,我找到了罪魁祸首:Skia的YCbCr转换.Repro,调查和解决方案的代码可以在TWiStErRob/AndroidJPEG找到.

发现

在没有得到关于这个问题的积极回应之后(来自http://b.android.com/206128)我开始深入挖掘.我找到了许多半知情的答案,这些答案帮助我发现了点点滴滴.一个这样的答案是/sf/answers/913893081/,它让我知道YuvImage哪个YUV NV21字节数组转换为JPEG压缩字节数组:

YuvImage yuv = new YuvImage(yuvData, ImageFormat.NV21, width, height, null);
yuv.compressToJpeg(new Rect(0, 0, width, height), 100, jpeg);
Run Code Online (Sandbox Code Playgroud)

创建YUV数据有很多自由,具有不同的常量和精度.从我的问题来看,很明显Android使用了不正确的算法.在玩网上找到的算法和常量时,我​​总是得到一个糟糕的图像:要么亮度发生变化,要么出现与问题相同的条带问题.

深层发掘

YuvImage实际上在调用时不使用Bitmap.compress,这里的堆栈是Bitmap.compress:

以及使用的堆栈 YuvImage

通过使用常量rgb2yuv_32Bitmap.compress流我能够重新使用同一频带效应YuvImage,而不是成就,只是一个确认,它的的确是搞砸YUV转换.我在YuvImage调用期间仔细检查了问题libjpeg:通过将Bitmap的ARGB转换为YUV并返回RGB,然后将生成的像素blob转换为原始图像,条带已经存在.

虽然这样做我意识到NV21/YUV420SP布局是有损的,因为它每隔4个像素采样颜色信息,它保持每个像素的值(亮度),这意味着一些颜色信息丢失,但大多数人的信息无论如何,眼睛都处于亮度.看看的例子维基百科中,Cb和Cr通道使得几乎认不出图像,从而有损采样上没有多大关系.

所以,在这一点上,我知道libjpeg在传递正确的原始数据时会进行正确的转换.这是我设置NDK并从http://www.ijg.org集成最新的LibJPEG的时候.我能够确认确实从Bitmap的像素阵列传递RGB数据会产生预期的结果.我不想在不是绝对必要时避免使用本机组件,所以除了去编写Bitmap的本地库之外,我找到了一个简洁的解决方法.我基本上从/sf/answers/913893081/使用骨架从Java中重新编写了该rgb_ycc_convert函数.下面没有针对速度进行优化,但是可读性,为了简洁起见,删除了一些常量,您可以在libjpeg代码或我的示例项目中找到它们.jcolor.c

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();
}
Run Code Online (Sandbox Code Playgroud)

神奇的关键似乎是ONE_HALF - 1其余的看起来非常像Skia中的数学.这对未来的调查来说是一个很好的方向,但对我而言,上述内容非常简单,可以成为解决Android内置怪异问题的好方法,虽然速度较慢.请注意,此解决方案使用的NV21布局会丢失3/4的颜色信息(来自Cr/Cb),但这种损失远小于Skia数学创建的错误.另请注意,YuvImage不支持奇数尺寸的图像,有关更多信息,请参阅NV21格式和奇数图像尺寸.