web-dev-qa-db-fra.com

Comment compresser Bitmap en JPEG avec la moindre perte de qualité sur Android?

Ce n'est pas un problème simple, veuillez lire !

Je souhaite manipuler un fichier JPEG et l'enregistrer à nouveau au format JPEG. Le problème est que même sans manipulation, il y a une perte de qualité importante (visible). Question : quelle option ou API me manque pour pouvoir recompresser JPEG sans perte de qualité (je sais que ce n'est pas exactement possible, mais je pense que Je décris ci-dessous n'est pas un niveau acceptable d'artefacts, en particulier avec une qualité = 100).

Contrôle

Je le charge en tant que Bitmap à partir du fichier:

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

Maintenant, pour avoir une image de contrôle à comparer, enregistrons les octets Bitmap simples au format PNG:

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

J'ai comparé cela à l'image JPEG d'origine sur mon ordinateur et il n'y a aucune différence visuelle.

J'ai également enregistré le int[] Brut de getPixels et l'ai chargé en tant que fichier ARGB brut sur mon ordinateur: il n'y a aucune différence visuelle par rapport au JPEG d'origine, ni au PNG enregistré à partir de Bitmap.

J'ai vérifié les dimensions et la configuration du bitmap, elles correspondent à l'image source et aux options d'entrée: il est décodé comme ARGB_8888 Comme prévu.

Les contrôles ci-dessus prouvent que les pixels du bitmap en mémoire sont corrects.

Problème

Je veux avoir des fichiers JPEG en conséquence, donc les approches PNG et RAW ci-dessus ne fonctionneraient pas, essayons d'abord d'enregistrer au format JPEG à 100%:

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

Je ne suis pas sûr que sa mesure soit en pourcentage, mais c'est plus facile à lire et à discuter, donc je vais l'utiliser.

Je suis conscient que le JPEG avec une qualité de 100% est toujours avec perte, mais il ne devrait pas être si visuellement perdu qu'il est perceptible de loin. Voici une comparaison de deux compressions à 100% de la même source.

Ouvrez-les dans des onglets séparés et cliquez d'avant en arrière pour voir ce que je veux dire. Les images de différence ont été faites en utilisant Gimp: l'original comme couche inférieure, la couche intermédiaire recompressée avec le mode "Grain extract", la couche supérieure entièrement blanche avec le mode "Value" pour améliorer la qualité.

Les images ci-dessous sont téléchargées sur Imgur qui compresse également les fichiers, mais comme toutes les images sont compressées de la même manière, les artefacts indésirables d'origine restent visibles de la même manière que je les vois lors de l'ouverture de mes fichiers d'origine. =

Original [560k]: Original picture Différence entre Imgur et l'original (sans rapport avec le problème, juste pour montrer qu'il ne cause aucun artefact extra lors du téléchargement des images): imgur's distortion IrfanView 100% [728k] (visuellement identique à l'original): 100% with IrfanView IrfanView 100% de différence par rapport à l'original (presque rien) 100% with IrfanView diff Android 100% [942k]: 100% with Android Android 100% de différence par rapport à l'original (teinture, bande, maculage) 100% with Android diff

Dans IrfanView, je dois descendre en dessous de 50% [50k] pour voir des effets similaires à distance. À 70% [100k] dans IrfanView, il n'y a pas de différence notable, mais la taille est 9e d'Android.

Contexte

J'ai créé une application qui prend une photo de l'API Appareil photo, cette image se présente sous la forme d'un byte[] Et est un blob JPEG encodé. J'ai enregistré ce fichier via la méthode OutputStream.write(byte[]), qui était mon fichier source d'origine. decodeByteArray(data, 0, data.length, options) décode les mêmes pixels que la lecture d'un fichier, testé avec Bitmap.sameAs donc c'est sans rapport avec le problème.

J'utilisais mon Samsung Galaxy S4 avec Android 4.4.2 pour tester les choses. Edit: tout en approfondissant mes recherches, j'ai également essayé Android 6.0 et N émulateurs d'aperçu et N et ils reproduisent le même problème.

17
TWiStErRob

Après une enquête, j'ai trouvé le coupable: la conversion YCbCr de Skia. Repro, code d'investigation et solutions peuvent être trouvés sur TWiStErRob/AndroidJPEG .

Découverte

Après n'avoir pas reçu de réponse positive à cette question (ni de http://b.Android.com/206128 ) j'ai commencé à creuser plus profondément. J'ai trouvé de nombreuses réponses à moitié informées SO qui m'ont énormément aidé à découvrir des morceaux. Une de ces réponses était https://stackoverflow.com/a/13055615/253468 = qui m'a fait connaître YuvImage qui convertit un tableau d'octets YUV NV21 en un tableau d'octets compressé JPEG:

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

Il y a beaucoup de liberté dans la création des données YUV, avec des constantes et une précision variables. D'après ma question, il est clair que Android utilise un algorithme incorrect. En jouant avec les algorithmes et les constantes que j'ai trouvés en ligne, j'ai toujours une mauvaise image: soit la luminosité a changé, soit les mêmes problèmes de bandes que dans la question.

Creuser plus profond

YuvImage n'est en fait pas utilisé lors de l'appel de Bitmap.compress, voici la pile pour Bitmap.compress:

et la pile pour utiliser YuvImage

En utilisant les constantes dans rgb2yuv_32 Du flux Bitmap.compress, J'ai pu recréer le même effet de bande en utilisant YuvImage, pas une réussite, juste une confirmation qu'il s'agit bien de la conversion YUV qui est foiré. J'ai revérifié que le problème ne se produit pas pendant YuvImage appelant libjpeg: en convertissant l'ARGB du bitmap en YUV et de nouveau en RVB puis en vidant le pixel blob résultant en tant qu'image brute, le regroupement était déjà Là.

En faisant cela, j'ai réalisé que la disposition NV21/YUV420SP est avec perte car elle échantillonne les informations de couleur tous les 4 pixels, mais elle conserve la valeur (luminosité) de chaque pixel, ce qui signifie que certaines informations de couleur sont perdues, mais la plupart des informations pour les yeux des gens sont de toute façon dans la luminosité. Jetez un oeil à exemple sur wikipedia , le canal Cb et Cr fait des images à peine reconnaissables, donc l'échantillonnage avec perte sur celui-ci n'a pas beaucoup d'importance.

Solution

Donc, à ce stade, je savais que libjpeg fait la bonne conversion lorsqu'il reçoit les bonnes données brutes. C'est à ce moment que j'ai installé le NDK et intégré le dernier LibJPEG de http://www.ijg.org . J'ai pu confirmer qu'en effet, le passage des données RVB du tableau de pixels du bitmap donne le résultat attendu. J'aime éviter d'utiliser des composants natifs lorsqu'ils ne sont pas absolument nécessaires, donc à part opter pour une bibliothèque native qui code pour un bitmap, j'ai trouvé une solution de contournement intéressante. J'ai essentiellement pris la fonction rgb_ycc_convert De jcolor.c Et l'ai réécrite en Java en utilisant le squelette de https://stackoverflow.com/ a/13055615/253468 . Ce qui suit n'est pas optimisé pour la vitesse, mais la lisibilité, certaines constantes ont été supprimées par souci de concision, vous pouvez les trouver dans le code libjpeg ou mon exemple de projet.

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 clé magique semble être ONE_HALF - 1 Le reste ressemble énormément aux mathématiques de Skia. C'est une bonne direction pour une enquête future, mais pour moi, ce qui précède est suffisamment simple pour être une bonne solution pour contourner l'étrangeté intégrée d'Android, bien que plus lentement. Notez que cette solution utilise la disposition NV21 qui perd 3/4 des informations de couleur (de Cr/Cb), mais cette perte est beaucoup moins importante que les erreurs créées par les calculs de Skia. Notez également que YuvImage ne prend pas en charge les images de tailles impaires, pour plus d'informations, voir format NV21 et dimensions des images impaires .

21
TWiStErRob