Как сжать Bitmap как JPEG с наименьшей потерей качества на Android?

Это непростая проблема, прочтите!

Я хочу изменить файл 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);

Теперь, чтобы иметь контрольное изображение для сравнения, давайте сохраним простые байты Bitmap как PNG:

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

Я сравнил это с исходным изображением в формате JPEG на моем компьютере, и визуальной разницы нет.

Я также сохранил необработанный int[] из getPixels и загрузил его как необработанный файл ARGB на свой компьютер: нет визуальных отличий от исходного JPEG или PNG, сохраненного из Bitmap.

Я проверил размеры и конфигурацию растрового изображения, они соответствуют исходному изображению и параметрам ввода: он декодируется как 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"));

Я не уверен, что он измеряется в процентах, но его легче читать и обсуждать, поэтому я воспользуюсь им.

Я знаю, что JPEG с качеством 100% по-прежнему с потерями, но он не должен быть настолько визуально потерянным, чтобы это было заметно издалека. Вот сравнение двух 100% сжатий одного и того же источника.

Открывайте их на отдельных вкладках и щелкайте между ними, чтобы понять, что я имею в виду. Различия изображений были сделаны с использованием GIMP: оригинал в качестве нижнего слоя, повторно сжатый средний слой в режиме «Зернистость», верхний слой полностью белый в режиме «Значение» для улучшения плохих качеств.

Приведенные ниже изображения загружаются в Imgur, который также сжимает файлы, но поскольку все изображения сжимаются одинаково, исходные нежелательные артефакты остаются видимыми так же, как я вижу их при открытии исходных файлов.

Исходный [560k]:  Исходное изображение Отличие Imgur от оригинала (не имеет отношения к проблеме, просто чтобы показать, что это не вызывает каких-либо дополнительных артефактов при загрузке изображений): искажение imgur  IrfanView 100% [728k] (визуально идентичен оригиналу): 100% с IrfanView IrfanView 100% отличия от оригинала (почти ничего)  100% с IrfanView diff Android 100% [942k]:  100% с Android 100% отличия Android от оригинала (тонировка, полосы, смазывание) 100% с Android diff

В IrfanView мне нужно опуститься ниже 50% [50k], чтобы увидеть отдаленно похожие эффекты. При 70% [100k] в IrfanView заметной разницы нет, но размер составляет 9-ю часть от размера Android.

Задний план

Я создал приложение, которое делает снимок из Camera API, это изображение имеет вид byte[] и представляет собой закодированный BLOB-объект в формате JPEG. Я сохранил этот файл методом OutputStream.write(byte[]), это был мой исходный исходный файл. decodeByteArray(data, 0, data.length, options) декодирует те же пиксели, что и чтение из файла, протестировано с Bitmap.sameAs, поэтому не имеет отношения к проблеме.

Я использовал свой Samsung Galaxy S4 с Android 4.4.2 для проверки. Изменить: во время дальнейшего исследования я также попробовал эмуляторы предварительного просмотра Android 6.0 и N, и они воспроизводят ту же проблему.


person TWiStErRob    schedule 07.04.2016    source источник
comment
Если вам не нравится кодек JPEG для Android, вы можете принести свой собственный. Я не думаю, что какое-либо принуждение заставит Android делать то, что вы хотите, так, как вы это измеряете.   -  person Doug Stevenson    schedule 08.04.2016
comment
Согласованный. AFAIK, это то, что здесь. В зависимости от манипуляций, которые вы хотите выполнить, вы все равно можете не делать этого на Java из-за ограничений памяти, вместо этого прибегая к NDK. В таком случае поведение Bitmap.compress() будет спорным. Но эпический анализ! :-)   -  person CommonsWare    schedule 08.04.2016
comment
@CommonsWare манипуляции, которые я хочу сделать, так же просты, как обрезка части изображения, но это означает, что мне нужно перекодировать; и это должен быть JPEG из-за нехватки места на диске. Есть ли у вас какие-либо предыстории о том, почему Android следует использовать некачественную реализацию JPEG?   -  person TWiStErRob    schedule 08.04.2016
comment
@DougStevenson, что вы предлагаете? (даже в форме ответа;) Я действительно не хочу реализовывать свой собственный кодировщик, но было бы неплохо иметь альтернативу. Мои измерения являются чисто визуальными и эмпирическими: у меня есть много изображений, на которых это действительно видно, приведенное выше может быть не лучшим примером, но я думаю, что этого достаточно, чтобы донести мысль.   -  person TWiStErRob    schedule 08.04.2016
comment
@TWiStErRob SO на самом деле не используется для обозначения решений других продуктов. Поиск в Google или вопрос на Quora могут быть более подходящими.   -  person Doug Stevenson    schedule 08.04.2016
comment
Bitmap.compress() изначально был создан для одноядерных процессоров с тактовой частотой ~ 500 МГц и 192 МБ ОЗУ устройства. Это примерно на уровне компьютеров 15-летней давности. Следовательно, он был оптимизирован для низкого уровня ЦП и оперативной памяти, а не для максимального качества. Я понятия не имею, улучшали ли они его с течением времени или нет, учитывая, что требования к устройствам растут. У меня есть много изображений, на которых это действительно видно - имейте в виду, что вы можете быть более чувствительны к проблеме, чем другие. Я заведомо плохо разбираюсь в этом, и если я смотрю на фотографии, я обнаруживаю мельчайшие различия, но это все.   -  person CommonsWare    schedule 08.04.2016


Ответы (3)


После некоторого расследования я нашел виновника: преобразование Skia в YCbCr. Репро, код для расследования и решения можно найти на странице TWiStErRob / AndroidJPEG.

Открытие

Не получив положительного ответа на этот вопрос (ни от http://b.android.com/206128 ) Начал копать глубже. Я нашел множество полуосведомленных ответов SO, которые очень помогли мне в обнаружении кусочков и кусочков. Одним из таких ответов был https://stackoverflow.com/a/13055615/253468, который заставил меня узнать о YuvImage, который преобразует YUV NV21 байтовый массив в сжатый байтовый массив JPEG:

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, а не достижение, а просто подтверждение того, что это действительно преобразование YUV, которое испортилось. Я дважды проверил, что проблема не во время YuvImage вызова libjpeg: преобразовав ARGB растрового изображения в YUV и обратно в RGB, а затем сбросив полученный пиксельный blob как необработанное изображение, полосы уже были.

Делая это, я понял, что макет NV21 / YUV420SP работает с потерями, поскольку он отбирает информацию о цвете через каждый 4-й пиксель, но он сохраняет значение (яркость) каждого пикселя, что означает, что некоторая информация о цвете теряется, но большая часть информации для глаз людей в любом случае находится в яркости. Взгляните на пример на wikipedia, канал Cb и Cr создает едва узнаваемые изображения, поэтому выборка с потерями на нем не имеет большого значения.

Решение

Итак, на этом этапе я знал, что libjpeg выполняет правильное преобразование, когда ему передаются правильные необработанные данные. Это когда я установил NDK и интегрировал последнюю версию LibJPEG с http://www.ijg.org. Я смог подтвердить, что действительно передача данных RGB из массива пикселей Bitmap дает ожидаемый результат. Мне нравится избегать использования собственных компонентов, когда это не является абсолютно необходимым, поэтому, помимо использования собственной библиотеки, кодирующей Bitmap, я нашел изящный обходной путь. По сути, я взял функцию rgb_ycc_convert из jcolor.c и переписал ее на Java, используя скелет из https://stackoverflow.com/a/13055615/253468. Приведенное ниже не оптимизировано для скорости, но для удобочитаемости, некоторые константы были удалены для краткости, вы можете найти их в коде 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 странностей, хотя и медленнее. Обратите внимание, что в этом решении используется макет NV21, который теряет 3/4 информации о цвете (из Cr / Cb), но эта потеря намного меньше, чем ошибки, созданные математикой Skia. Также обратите внимание, что YuvImage не делает не поддерживает изображения нестандартного размера. Для получения дополнительной информации см. формат NV21 и размеры нечетных изображений.

person TWiStErRob    schedule 11.04.2016

Пожалуйста, используйте следующий метод:

public String convertBitmaptoSmallerSizetoString(String image){
    File imageFile = new File(image);
    Bitmap bitmap = BitmapFactory.decodeFile(imageFile.getAbsolutePath());
    int nh = (int) (bitmap.getHeight() * (512.0 / bitmap.getWidth()));
    Bitmap scaled = Bitmap.createScaledBitmap(bitmap, 512, nh, true);
    ByteArrayOutputStream stream = new ByteArrayOutputStream();
    scaled.compress(Bitmap.CompressFormat.PNG, 90, stream);
    byte[] imageByte = stream.toByteArray();
    String img_str = Base64.encodeToString(imageByte, Base64.NO_WRAP);
    return img_str;
}
person Ismaran Duwadi    schedule 12.04.2016
comment
Эм, это уменьшенный PNG, а не JPG. Очевидно, формат без потерь будет работать, но не с пропускной способностью и хранилищем. Уменьшать изображение просто для экономии места бессмысленно, это тоже потеря качества. Обратите внимание, что качество = 90 будет проигнорировано для PNG. - person TWiStErRob; 12.04.2016

Ниже мой код:

public static String compressImage(Context context, String imagePath)
{

    final float maxHeight = 1024.0f;
    final float maxWidth = 1024.0f;
    Bitmap scaledBitmap = null;
    BitmapFactory.Options options = new BitmapFactory.Options();
    options.inJustDecodeBounds = true;
    Bitmap bmp = BitmapFactory.decodeFile(imagePath, options);
    int actualHeight = options.outHeight;
    int actualWidth = options.outWidth;
    float imgRatio = (float) actualWidth / (float) actualHeight;
    float maxRatio = maxWidth / maxHeight;
    if (actualHeight > maxHeight || actualWidth > maxWidth) {
        if (imgRatio < maxRatio) {
            imgRatio = maxHeight / actualHeight;
            actualWidth = (int) (imgRatio * actualWidth);
            actualHeight = (int) maxHeight;
        } else if (imgRatio > maxRatio) {
            imgRatio = maxWidth / actualWidth;
            actualHeight = (int) (imgRatio * actualHeight);
            actualWidth = (int) maxWidth;
        } else {
            actualHeight = (int) maxHeight;
            actualWidth = (int) maxWidth;
        }
    }
    options.inSampleSize = calculateInSampleSize(options, actualWidth, actualHeight);
    options.inJustDecodeBounds = false;
    options.inDither = false;
    options.inPurgeable = true;
    options.inInputShareable = true;
    options.inTempStorage = new byte[16 * 1024];
    try {
        bmp = BitmapFactory.decodeFile(imagePath, options);
    } catch (OutOfMemoryError exception) {
        exception.printStackTrace();
    }
    try {
        scaledBitmap = Bitmap.createBitmap(actualWidth, actualHeight, Bitmap.Config.RGB_565);
    } catch (OutOfMemoryError exception) {
        exception.printStackTrace();
    }
    float ratioX = actualWidth / (float) options.outWidth;
    float ratioY = actualHeight / (float) options.outHeight;
    float middleX = actualWidth / 2.0f;
    float middleY = actualHeight / 2.0f;
    Matrix scaleMatrix = new Matrix();
    scaleMatrix.setScale(ratioX, ratioY, middleX, middleY);
    assert scaledBitmap != null;
    Canvas canvas = new Canvas(scaledBitmap);
    canvas.setMatrix(scaleMatrix);
    canvas.drawBitmap(bmp, middleX - bmp.getWidth() / 2, middleY - bmp.getHeight() / 2, new Paint(Paint.FILTER_BITMAP_FLAG));
    if (bmp != null) {
        bmp.recycle();
    }
    ExifInterface exif;
    try {
        exif = new ExifInterface(imagePath);
        int orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, 0);
        Matrix matrix = new Matrix();
        if (orientation == 6) {
            matrix.postRotate(90);
        } else if (orientation == 3) {
            matrix.postRotate(180);
        } else if (orientation == 8) {
            matrix.postRotate(270);
        }
        scaledBitmap = Bitmap.createBitmap(scaledBitmap, 0, 0, scaledBitmap.getWidth(), scaledBitmap.getHeight(), matrix, true);
    } catch (IOException e) {
        e.printStackTrace();
    }
    FileOutputStream out = null;
    String filepath = getFilename(context);
    try {
        out = new FileOutputStream(filepath);
        scaledBitmap.compress(Bitmap.CompressFormat.JPEG, 80, out);
    } catch (FileNotFoundException e) {
        e.printStackTrace();
    }
    return filepath;
}

public static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) {
    final int height = options.outHeight;
    final int width = options.outWidth;
    int inSampleSize = 1;
    if (height > reqHeight || width > reqWidth) {
        final int heightRatio = Math.round((float) height / (float) reqHeight);
        final int widthRatio = Math.round((float) width / (float) reqWidth);
        inSampleSize = heightRatio < widthRatio ? heightRatio : widthRatio;
    }
    final float totalPixels = width * height;
    final float totalReqPixelsCap = reqWidth * reqHeight * 2;
    while (totalPixels / (inSampleSize * inSampleSize) > totalReqPixelsCap) {
        inSampleSize++;
    }
    return inSampleSize;
}


public static String getFilename(Context context) {
    File mediaStorageDir = new File(Environment.getExternalStorageDirectory()
            + "/Android/data/"
            + context.getApplicationContext().getPackageName()
            + "/Files/Compressed");

    if (!mediaStorageDir.exists()) {
        mediaStorageDir.mkdirs();
    }
    String mImageName = "IMG_" + String.valueOf(System.currentTimeMillis()) + ".jpg";
    return (mediaStorageDir.getAbsolutePath() + "/" + mImageName);
}
person Praful Parmar    schedule 03.03.2017