How to compress Bitmap as JPEG with least quality loss on Android?

36,495

After some investigation I found the culprit: Skia's YCbCr conversion. Repro, code for investigation and solutions can be found at TWiStErRob/AndroidJPEG.

Discovery

After not getting a positive response on this question (neither from http://b.android.com/206128) I started digging deeper. I found numerous half-informed SO answers which helped me tremendously in discovering bits and pieces. One such answer was https://stackoverflow.com/a/13055615/253468 which made me aware of YuvImage which converts an YUV NV21 byte array into a JPEG compressed byte array:

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

There's a lot of freedom going into creating the YUV data, with varying constants and precision. From my question it's clear that Android uses an incorrect algorithm. While playing around with the algorithms and constants I found online I always got a bad image: either the brightness changed or had the same banding issues as in the question.

Digging deeper

YuvImage is actually not used when calling Bitmap.compress, here's the stack for Bitmap.compress:

and the stack for using YuvImage

By using the constants in rgb2yuv_32 from the Bitmap.compress flow I was able to recreate the same banding effect using YuvImage, not an achievement, just a confirmation that it's indeed the YUV conversion that is messed up. I double-checked that the problem is not during YuvImage calling libjpeg: by converting the Bitmap's ARGB to YUV and back to RGB then dumping the resulting pixel blob as a raw image, the banding was already there.

While doing this I realized that the NV21/YUV420SP layout is lossy as it samples the color information every 4th pixel, but it keeps the value (brightness) of each pixel which means that some color info is lost, but most of the info for people's eyes are in the brightness anyway. Take a look at the example on wikipedia, the Cb and Cr channel makes barely recognisable images, so lossy sampling on it doesn't matter much.

Solution

So, at this point I knew that libjpeg does the right conversion when it is passed the right raw data. This is when I set up the NDK and integrated the latest LibJPEG from http://www.ijg.org. I was able to confirm that indeed passing the RGB data from the Bitmap's pixels array yields the expected result. I like to avoid using native components when not absolutely necessary, so aside of going for a native library that encodes a Bitmap I found a neat workaround. I've essentially taken the rgb_ycc_convert function from jcolor.c and rewrote it in Java using the skeleton from https://stackoverflow.com/a/13055615/253468. The below is not optimized for speed, but readability, some constants were removed for brevity, you can find them in libjpeg code or my example project.

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();
}

The magic key seems to be ONE_HALF - 1 the rest looks an awful lot like the math in Skia. That's a good direction for future investigation, but for me the above is sufficiently simple to be a good solution for working around Android's builtin weirdness, albeit slower. Note that this solution uses the NV21 layout which loses 3/4 of the color info (from Cr/Cb), but this loss is much less than the errors created by Skia's math. Also note that YuvImage doesn't support odd-sized images, for more info see NV21 format and odd image dimensions.

Share:
36,495

Related videos on Youtube

TWiStErRob
Author by

TWiStErRob

I'm a principal-level senior software engineer with a masters degree from Hungary working in London, UK. I have experience in multiple fields and technologies: modern web-, mobile- and desktop applications, and their backend services, databases. To name a few: Android, Java, HTML/CSS/JS, WPF.NET, C#, node.js, PHP, SQL, neo4j, C++ in no particular order. I'm a pragmatic perfectionist: efficient, fast, and I pay excessive attention to details when it comes to my work. Check out my homepage for more information about me and my work... All original text, images and source snippets I post on Stack Exchange sites are dedicated to the public domain. Do with them as you see fit. You're also free to apply WTFPL, CC0, Unlicence, or any other similarly permissive licence if necessary.

Updated on March 21, 2020

Comments

  • TWiStErRob
    TWiStErRob about 4 years

    This is not a straightforward problem, please read through!

    I want to manipulate a JPEG file and save it again as JPEG. The problem is that even without manipulation there's significant (visible) quality loss. Question: what option or API am I missing to be able to re-compress JPEG without quality loss (I know it's not exactly possible, but I think what I describe below is not an acceptable level of artifacts, especially with quality=100).

    Control

    I load it as a Bitmap from the file:

    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);
    

    Now, to have a control image to compare to, let's save the plain Bitmap bytes as PNG:

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

    I compared this to the original JPEG image on my computer and there's no visual difference.

    I also saved the raw int[] from getPixels and loaded it as a raw ARGB file on my computer: there's no visual difference to the original JPEG, nor the PNG saved from Bitmap.

    I checked the Bitmap's dimensions and config, they match the source image and the input options: it's decoded as ARGB_8888 as expected.

    The above to control checks prove that the pixels in the in-memory Bitmap are correct.

    Problem

    I want to have JPEG files as a result, so the above PNG and RAW approaches wouldn't work, let's try to save as JPEG 100% first:

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

    I'm not sure its measure is percent, but it's easier to read and discuss, so I'm gonna use it.

    I'm aware that JPEG with the quality of 100% is still lossy, but it shouldn't be so visually lossy that it's noticeable from afar. Here's a comparison of two 100% compressions of the same source.

    Open them in separate tabs and click back and forth between to see what I mean. The difference images were made using Gimp: original as bottom layer, re-compressed middle layer with "Grain extract" mode, top layer full white with "Value" mode to enhance badness.

    The below images are uploaded to Imgur which also compresses the files, but since all of the images are compressed the same, the original unwanted artifacts remain visible the same way I see it when opening my original files.

    Original [560k]: Original picture Imgur's difference to original (not relevant to problem, just to show that it's not causing any extra artifacts when uploading the images): imgur's distortion IrfanView 100% [728k] (visually identical to original): 100% with IrfanView IrfanView 100%'s difference to original (barely anything) 100% with IrfanView diff Android 100% [942k]: 100% with Android Android 100%'s difference to original (tinting, banding, smearing) 100% with Android diff

    In IrfanView I have to go below 50% [50k] to see remotely similar effects. At 70% [100k] in IrfanView there's no noticable difference, but the size is 9th of Android's.

    Background

    I created an app that takes a picture from Camera API, that image comes as a byte[] and is an encoded JPEG blob. I saved this file via OutputStream.write(byte[]) method, that was my original source file. decodeByteArray(data, 0, data.length, options) decodes the same pixels as reading from a File, tested with Bitmap.sameAs so it's irrelevant to the issue.

    I was using my Samsung Galaxy S4 with Android 4.4.2 to test things out. Edit: while investigating further I also tried Android 6.0 and N preview emulators and they reproduce the same issue.

    • Doug Stevenson
      Doug Stevenson about 8 years
      If you don't like Android's JPEG codec, you're free to bring your own. I don't think any amount of coercing will make Android do what you want, the way you are measuring it.
    • CommonsWare
      CommonsWare about 8 years
      Agreed. AFAIK, "it is what it is" here. Depending on the manipulations that you want to do, you might not be doing it in Java anyway, due to memory limitations, resorting instead to the NDK. In that case, the behavior of Bitmap.compress() will be moot. Epic analysis, though! :-)
    • TWiStErRob
      TWiStErRob about 8 years
      @CommonsWare the "manipulations" I want do is as simple as cropping part of the image, but that means that I have to re-encode; and that needs to be JPEG due to disk space concerns. Do you have any background on why Android would go with a sub-par implementation of JPEG?
    • TWiStErRob
      TWiStErRob about 8 years
      @DougStevenson what's your suggestion? (even in form of an answer ;) I don't really want to implement my own encoder, but it would be nice to have an alternative. My measurement is purely visual and empiric: I have a lot of images where this is really visible, the above may not be the best example, but I think it's sufficient to bring the point through.
    • Doug Stevenson
      Doug Stevenson about 8 years
      @TWiStErRob SO isn't really used to refer to other products' solutions. A Google search or a Quora question might be more appropriate.
    • CommonsWare
      CommonsWare about 8 years
      Bitmap.compress() would have been originally created for single-core, ~500MHz CPUs and 192MB device RAM. That's roughly on par with 15-year-old PCs. Hence, it was optimized for low CPU and low RAM, not max quality. I have no idea if they improved it over time or not, given that device requirements have been climbing. "I have a lot of images where this is really visible" -- bear in mind that you may be more sensitive to the issue than others. I'm notoriously lousy at it, and if I stare at the photos, I'll detect minute differences, but that's it.
  • TWiStErRob
    TWiStErRob about 8 years
    Ehm, that's a downscaled PNG, not JPG. Obviously a lossless format will work, but not with bandwidth and storage. Making the image smaller just to save space is pointless, that's quality loss as well. Note that quality=90 will be ignored for PNG.