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).
- Dando dibujo / Pintura / Ahorro de múltiples mapas de bits
- Reducir tamaño de mapa de bits a algunos píxeles especificados en Android
- Obtener imagen uri de picasso?
- Convertir un lienzo en imagen de mapa de bits en android
- Configuración de varios elementos personalizados en MultiAutoCompleteTextView: Android
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]: 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): IrfanView 100% [728k] (visualmente idéntico al original): IrfanView La diferencia del 100% con el original (casi nada) Android 100% [942k]: La diferencia de Android 100% con la original (tinción, bandas, manchas)
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.
- Cómo convertir un dibujable a un mapa de bits?
- Cómo obtener cuando un ImageView está completamente cargado en Android
- CORRUPCIÓN DE MEMORIA HEAP EN los errores dlmalloc o dlfree y SIGSEGV en Android ICS 4.0
- Deslizar para eliminar con rotación
- Cómo crear relieve alrededor de un mapa de bits?
- Escritura Bitmap al almacenamiento externo (sdcard)
- API básica de Android v2 MapActivity outOfMemory con 10 marcadores
- Bordes negros alrededor de la captura de pantalla tomada
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); }
- MediaCodec con entrada de superficie: Grabación en segundo plano
- Adición de imágenes / iconos adicionales a CardFragment (Android Wear)