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

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

Я проверил размеры и конфигурацию 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 Android 100% отличается от оригинала (тонирование, обвязка, размазывание) 100% с Android diff

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

Задний план

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

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

После некоторого расследования я нашел виновника: конверсия YCbCr Skia. Repro, код для исследования и решения можно найти на 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 :

  • jpeg_write_scanlines / jpeg_write_scanlines ( jcapistd.c: 77 )
  • rgb2yuv_32 / rgb2yuv_32 ( SkImageDecoder_libjpeg.cpp: 913 )
  • Skia / 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: 383 )
  • Bitmap.nativeCompress ( Bitmap.java:1573 )
  • Bitmap.compress ( Bitmap.java:984 )
  • app.saveBitmapAsJPEG ()

И стек для использования YuvImage

  • jpeg_write_raw_data / jpeg_write_raw_data ( jcapistd.c: 120 )
  • YuvToJpegEncoder::compress ( YuvToJpegEncoder.cpp: 71 )
  • YuvToJpegEncoder::encode ( YuvToJpegEncoder.cpp: 24 )
  • YuvImage_compressToJpeg ( YuvToJpegEncoder.cpp: 219 )
  • YuvImage.nativeCompressToJpeg ( YuvImage.java:141 )
  • YuvImage.compressToJpeg ( YuvImage.java:123 )
  • app.saveNV21AsJPEG ()

Используя константы в rgb2yuv_32 из потока Bitmap.compress я смог воссоздать тот же эффект полос, используя YuvImage , а не достижение, просто подтверждение того, что это действительно преобразование YUV, которое испортилось. Я дважды проверял, что проблема не во время YuvImage вызывающего libjpeg : путем преобразования ARGB битмапа в YUV и обратно в RGB, а затем сбрасывая результирующий пиксельный блок в качестве необработанного изображения, полоса была уже там.

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

Решение

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

Используйте следующий метод:

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

Ниже мой код:

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