Подтвердить что ты не робот

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

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

Я хочу манипулировать файлом JPEG и сохранять его снова как JPEG. Проблема в том, что даже без манипуляции наблюдается значительная (видимая) потеря качества. Вопрос: какой вариант или API мне не хватает, чтобы иметь возможность повторно сжимать JPEG без потери качества (я знаю, что это не совсем возможно, но я думаю, что то, что я описываю ниже, не является приемлемым уровнем артефактов, особенно с качеством = 100).

Control

Я загружаю его как 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 на своем компьютере, и нет визуальной разницы.

Я также сохранил raw 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's distortion IrfanView 100% [728k] (визуально идентичный оригиналу): 100% с IrfanView IrfanView 100% разница с оригиналом (почти ничего) 100% с IrfanView diff Android 100% [942k]: 100% с Android Android 100% разница с оригиналом (тонирование, обвязка, размазывание) 100% с Android diff

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

Фон

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

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

4b9b3361

Ответ 1

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

Открытие

Не получив положительного ответа на этот вопрос (ни от http://b.android.com/206128), я начал копать глубже. Я нашел множество полуинформационных SO-ответов, которые очень помогли мне в обнаружении бит-кусков. Одним из таких ответов был fooobar.com/questions/234085/..., из-за которого я знал о 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, а затем сброса результирующей пиксельной капли в качестве необработанного изображения, полоса была уже там.

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

Решение

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

Ответ 2

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

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

Ответ 3

Ниже мой код:

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