Cómo comprimir Bitmap como JPEG con la pérdida de calidad al menos en Android?

Este no es un problema sencillo, por favor lea!

Quiero manipular un archivo JPEG y guardarlo de nuevo como JPEG. El problema es que incluso sin manipulación hay una pérdida significativa (visible) de calidad. Pregunta : qué opción o API me falta para poder comprimir JPEG sin pérdida de calidad (sé que no es exactamente posible, pero creo que lo que describo a continuación no es un nivel aceptable de artefactos, especialmente con calidad = 100).

Controlar

Lo carga como un Bitmap de Bitmap del archivo:

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

Ahora, para tener una imagen de control para comparar, vamos a guardar los bytes de mapa de bits simples como PNG:

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

He comparado esto con la imagen JPEG original en mi computadora y no hay diferencia visual.

También guardé el raw int[] de getPixels y lo cargué como un archivo ARGB en bruto en mi computadora: no hay diferencia visual con el JPEG original, ni el PNG guardado de Bitmap.

Comprobé las dimensiones y configuración de Bitmap, que coinciden con la imagen de origen y las opciones de entrada: se decodifica como ARGB_8888 como se esperaba.

Lo anterior para controlar las comprobaciones demuestran que los píxeles del mapa de bits en memoria son correctos.

Problema

Quiero tener archivos JPEG como resultado, por lo que los anteriores PNG y RAW enfoques no funcionaría, vamos a tratar de guardar como JPEG 100% primero:

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

No estoy seguro de que su medida sea por ciento, pero es más fácil de leer y discutir, así que voy a usarla.

Soy consciente de que JPEG con la calidad del 100% sigue siendo lossy, pero no debe ser tan visualmente lossy que es notable desde lejos. He aquí una comparación de dos compresiones del 100% de la misma fuente.

Ábralos en pestañas separadas y haz clic para ver lo que quiero decir. Las imágenes de la diferencia se hicieron usando Gimp: original como capa inferior, re-comprimido capa media con el "extracto de grano" modo, la capa superior llena de blanco con el "valor" modo de mejorar la maldad.

Las siguientes imágenes se cargan en Imgur, que también comprime los archivos, pero como todas las imágenes se comprimen igual, los artefactos no deseados originales permanecen visibles de la misma manera que veo al abrir mis archivos originales.

Original [560k]: Imagen original La diferencia de Imgur con el original (no es relevante para el problema, solo para mostrar que no está causando ningún artefacto extra al cargar las imágenes): La distorsión de imgur IrfanView 100% [728k] (visualmente idéntico al original): 100% con IrfanView IrfanView La diferencia del 100% con el original (casi nada) 100% con IrfanView diff Android 100% [942k]: 100% con Android La diferencia de Android 100% con la original (tinción, bandas, manchas) 100% con Android diff

En IrfanView tengo que ir por debajo del 50% [50k] para ver efectos remotamente similares. En 70% [100k] en IrfanView no hay diferencia notable, pero el tamaño es el noveno de Android.

Fondo

He creado una aplicación que toma una foto de Camera API, esa imagen viene como un byte[] y es un codificado JPEG blob. He guardado este archivo vía el método de OutputStream.write(byte[]) , que era mi archivo original de la fuente. decodeByteArray(data, 0, data.length, options) decodifica los mismos píxeles que la lectura de un archivo, probado con Bitmap.sameAs por lo que es irrelevante para el problema.

Yo estaba usando mi Samsung Galaxy S4 con Android 4.4.2 para probar las cosas. Edit: al investigar más, también probé Android 6.0 y N emuladores de vista previa y reproducir el mismo problema.

Después de alguna investigación encontré al culpable: la conversión de YCbCr de Skia. Repro, código de investigación y soluciones se pueden encontrar en TWiStErRob / AndroidJPEG .

Descubrimiento

Después de no obtener una respuesta positiva sobre esta cuestión (ni de http://b.android.com/206128 ) Comencé a cavar más profundo. Encontré numerosas respuestas informáticas de SO que me ayudaron enormemente a descubrir trozos. Una de esas respuestas fue https://stackoverflow.com/a/13055615/253468 que me hizo consciente de YuvImage que convierte una matriz de byte YUV NV21 en una matriz de bytes comprimidos JPEG:

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

Hay mucha libertad en crear los datos YUV, con constantes y precisión variables. De mi pregunta está claro que Android utiliza un algoritmo incorrecto. Mientras jugaba con los algoritmos y constantes que encontré en línea siempre tenía una mala imagen: o el brillo cambiaba o tenía los mismos problemas de bandas como en la pregunta.

Cavar más profundo

YuvImage no se utiliza realmente cuando se llama a Bitmap.compress , aquí está la pila de 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 se define incondicionalmente]
  • 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 ()

Y la pila para usar 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 ()

Usando las constantes en rgb2yuv_32 del flujo de Bitmap.compress podía reconstruir el mismo efecto de la venda usando YuvImage , no un logro, apenas una confirmación que es de hecho la conversión de YUV que está desordenada. Comprobé de nuevo que el problema no era durante YuvImage llamando a libjpeg : convirtiendo el ARGB del mapa de bits en YUV y volviendo a RGB y luego descartando la gota de píxel resultante como una imagen sin procesar, la banda ya estaba allí.

Al hacer esto, me di cuenta de que el diseño NV21 / YUV420SP tiene pérdidas, ya que muestra la información de color cada 4 píxeles, pero mantiene el valor (brillo) de cada píxel, lo que significa que se pierde alguna información de color, pero la mayor parte de la información de las personas Los ojos están en el brillo de todos modos. Echa un vistazo al ejemplo de wikipedia , el canal Cb y Cr hace que las imágenes apenas reconocibles, por lo que el muestreo con pérdidas en él no importa mucho.

Solución

Por lo tanto, en este momento supe que libjpeg hace la conversión correcta cuando se pasa el derecho de datos sin procesar. Esto es cuando instalé el NDK e integré el último LibJPEG de http://www.ijg.org . Pude confirmar que pasar los datos RGB de la matriz de píxeles del mapa de bits produce el resultado esperado. Me gusta evitar el uso de componentes nativos cuando no es absolutamente necesario, por lo que aparte de ir a una biblioteca nativa que codifica un mapa de bits encontré una buena solución. He tomado esencialmente la función rgb_ycc_convert de jcolor.c y la reescribí en Java usando el esqueleto de https://stackoverflow.com/a/13055615/253468 . Lo siguiente no está optimizado para la velocidad, pero la legibilidad, algunas constantes se eliminaron por brevedad, se pueden encontrar en el código de libjpeg o mi proyecto de ejemplo.

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

La llave mágica parece ser ONE_HALF - 1 el resto se parece mucho a las matemáticas en Skia. Esa es una buena dirección para la investigación futura, pero para mí lo anterior es lo suficientemente simple como para ser una buena solución para trabajar alrededor de la rareza de Android, aunque sea más lenta. Tenga en cuenta que esta solución utiliza la disposición NV21 que pierde 3/4 de la información de color (de Cr / Cb), pero esta pérdida es mucho menor que los errores creados por la matemática de Skia. También tenga en cuenta que YuvImage no admite imágenes de tamaño extraño, para obtener más información, consulte el formato NV21 y las dimensiones de imagen impar .

Utilice el siguiente método:

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

Abajo está mi código:

 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); } 
  • Bitmap comprimido con calidad = 100 tamaño de archivo mayor que original
  • Redimensionar bitmap de Android manteniendo relación de aspecto
  • Android guardar y cargar las diferencias PNG bitmaps
  • Android Paint PorterDuff.Mode.CLEAR
  • Convertir mapa de bits a Mat después de capturar la imagen con la cámara de Android
  • Borde sobre un mapa de bits con esquinas redondeadas en Android
  • Tome la imagen y convertir a Base64
  • Repetición de mapa de bits + esquinas redondeadas
  • Guardar bitmap en la función de archivo
  • Android fuera de la prevención de la memoria
  • Android Opengl-es carga un poder de 2 no-textura
  • FlipAndroid es un fan de Google para Android, Todo sobre Android Phones, Android Wear, Android Dev y Aplicaciones para Android Aplicaciones.