Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 58df125b authored by Deepanshu Gupta's avatar Deepanshu Gupta
Browse files

Add image comparison to LayoutLib tests.

The LayoutLib rendering tests now compares the rendered image with a
golden file from a previous execution. This will help catch any
unexpected changes in the final rendering.

Change-Id: I80251a778e89df7d7ec13863a8f299967f3d747d
parent 4022272a
Loading
Loading
Loading
Loading
+3.2 KiB
Loading image diff...
+336 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2014 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.layoutlib.bridge.intensive;

import com.android.annotations.NonNull;

import java.awt.AlphaComposite;
import java.awt.Color;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;

import javax.imageio.ImageIO;

import static java.awt.RenderingHints.*;
import static java.awt.image.BufferedImage.TYPE_INT_ARGB;
import static java.io.File.separatorChar;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;


// Adapted by taking the relevant pieces of code from the following classes:
//
// com.android.tools.idea.rendering.ImageUtils,
// com.android.tools.idea.tests.gui.framework.fixture.layout.ImageFixture and
// com.android.tools.idea.rendering.RenderTestBase
/**
 * Utilities related to image processing.
 */
public class ImageUtils {
    /**
     * Normally, this test will fail when there is a missing thumbnail. However, when
     * you create creating a new test, it's useful to be able to turn this off such that
     * you can generate all the missing thumbnails in one go, rather than having to run
     * the test repeatedly to get to each new render assertion generating its thumbnail.
     */
    private static final boolean FAIL_ON_MISSING_THUMBNAIL = true;

    private static final int THUMBNAIL_SIZE = 250;

    private static final double MAX_PERCENT_DIFFERENCE = 0.1;

    public static void requireSimilar(@NonNull String relativePath, @NonNull BufferedImage image)
            throws IOException {
        int maxDimension = Math.max(image.getWidth(), image.getHeight());
        double scale = THUMBNAIL_SIZE / (double)maxDimension;
        BufferedImage thumbnail = scale(image, scale, scale);

        InputStream is = ImageUtils.class.getResourceAsStream(relativePath);
        if (is == null) {
            String message = "Unable to load golden thumbnail: " + relativePath + "\n";
            message = saveImageAndAppendMessage(thumbnail, message, relativePath);
            if (FAIL_ON_MISSING_THUMBNAIL) {
                fail(message);
            } else {
                System.out.println(message);
            }
        }
        else {
            BufferedImage goldenImage = ImageIO.read(is);
            assertImageSimilar(relativePath, goldenImage, thumbnail, MAX_PERCENT_DIFFERENCE);
        }
    }

    public static void assertImageSimilar(String relativePath, BufferedImage goldenImage,
            BufferedImage image, double maxPercentDifferent) throws IOException {
        assertEquals("Only TYPE_INT_ARGB image types are supported",  TYPE_INT_ARGB, image.getType());

        if (goldenImage.getType() != TYPE_INT_ARGB) {
            BufferedImage temp = new BufferedImage(goldenImage.getWidth(), goldenImage.getHeight(),
                    TYPE_INT_ARGB);
            temp.getGraphics().drawImage(goldenImage, 0, 0, null);
            goldenImage = temp;
        }
        assertEquals(TYPE_INT_ARGB, goldenImage.getType());

        int imageWidth = Math.min(goldenImage.getWidth(), image.getWidth());
        int imageHeight = Math.min(goldenImage.getHeight(), image.getHeight());

        // Blur the images to account for the scenarios where there are pixel
        // differences
        // in where a sharp edge occurs
        // goldenImage = blur(goldenImage, 6);
        // image = blur(image, 6);

        int width = 3 * imageWidth;
        @SuppressWarnings("UnnecessaryLocalVariable")
        int height = imageHeight; // makes code more readable
        BufferedImage deltaImage = new BufferedImage(width, height, TYPE_INT_ARGB);
        Graphics g = deltaImage.getGraphics();

        // Compute delta map
        long delta = 0;
        for (int y = 0; y < imageHeight; y++) {
            for (int x = 0; x < imageWidth; x++) {
                int goldenRgb = goldenImage.getRGB(x, y);
                int rgb = image.getRGB(x, y);
                if (goldenRgb == rgb) {
                    deltaImage.setRGB(imageWidth + x, y, 0x00808080);
                    continue;
                }

                // If the pixels have no opacity, don't delta colors at all
                if (((goldenRgb & 0xFF000000) == 0) && (rgb & 0xFF000000) == 0) {
                    deltaImage.setRGB(imageWidth + x, y, 0x00808080);
                    continue;
                }

                int deltaR = ((rgb & 0xFF0000) >>> 16) - ((goldenRgb & 0xFF0000) >>> 16);
                int newR = 128 + deltaR & 0xFF;
                int deltaG = ((rgb & 0x00FF00) >>> 8) - ((goldenRgb & 0x00FF00) >>> 8);
                int newG = 128 + deltaG & 0xFF;
                int deltaB = (rgb & 0x0000FF) - (goldenRgb & 0x0000FF);
                int newB = 128 + deltaB & 0xFF;

                int avgAlpha = ((((goldenRgb & 0xFF000000) >>> 24)
                        + ((rgb & 0xFF000000) >>> 24)) / 2) << 24;

                int newRGB = avgAlpha | newR << 16 | newG << 8 | newB;
                deltaImage.setRGB(imageWidth + x, y, newRGB);

                delta += Math.abs(deltaR);
                delta += Math.abs(deltaG);
                delta += Math.abs(deltaB);
            }
        }

        // 3 different colors, 256 color levels
        long total = imageHeight * imageWidth * 3L * 256L;
        float percentDifference = (float) (delta * 100 / (double) total);

        String error = null;
        String imageName = getName(relativePath);
        if (percentDifference > maxPercentDifferent) {
            error = String.format("Images differ (by %.1f%%)", percentDifference);
        } else if (Math.abs(goldenImage.getWidth() - image.getWidth()) >= 2) {
            error = "Widths differ too much for " + imageName + ": " +
                    goldenImage.getWidth() + "x" + goldenImage.getHeight() +
                    "vs" + image.getWidth() + "x" + image.getHeight();
        } else if (Math.abs(goldenImage.getHeight() - image.getHeight()) >= 2) {
            error = "Heights differ too much for " + imageName + ": " +
                    goldenImage.getWidth() + "x" + goldenImage.getHeight() +
                    "vs" + image.getWidth() + "x" + image.getHeight();
        }

        assertEquals(TYPE_INT_ARGB, image.getType());
        if (error != null) {
            // Expected on the left
            // Golden on the right
            g.drawImage(goldenImage, 0, 0, null);
            g.drawImage(image, 2 * imageWidth, 0, null);

            // Labels
            if (imageWidth > 80) {
                g.setColor(Color.RED);
                g.drawString("Expected", 10, 20);
                g.drawString("Actual", 2 * imageWidth + 10, 20);
            }

            File output = new File(getTempDir(), "delta-" + imageName);
            if (output.exists()) {
                boolean deleted = output.delete();
                assertTrue(deleted);
            }
            ImageIO.write(deltaImage, "PNG", output);
            error += " - see details in " + output.getPath() + "\n";
            error = saveImageAndAppendMessage(image, error, relativePath);
            System.out.println(error);
            fail(error);
        }

        g.dispose();
    }

    /**
     * Resize the given image
     *
     * @param source the image to be scaled
     * @param xScale x scale
     * @param yScale y scale
     * @return the scaled image
     */
    @NonNull
    public static BufferedImage scale(@NonNull BufferedImage source, double xScale, double yScale) {

        int sourceWidth = source.getWidth();
        int sourceHeight = source.getHeight();
        int destWidth = Math.max(1, (int) (xScale * sourceWidth));
        int destHeight = Math.max(1, (int) (yScale * sourceHeight));
        int imageType = source.getType();
        if (imageType == BufferedImage.TYPE_CUSTOM) {
            imageType = BufferedImage.TYPE_INT_ARGB;
        }
        if (xScale > 0.5 && yScale > 0.5) {
            BufferedImage scaled =
                    new BufferedImage(destWidth, destHeight, imageType);
            Graphics2D g2 = scaled.createGraphics();
            g2.setComposite(AlphaComposite.Src);
            g2.setColor(new Color(0, true));
            g2.fillRect(0, 0, destWidth, destHeight);
            if (xScale == 1 && yScale == 1) {
                g2.drawImage(source, 0, 0, null);
            } else {
                setRenderingHints(g2);
                g2.drawImage(source, 0, 0, destWidth, destHeight, 0, 0, sourceWidth, sourceHeight,
                        null);
            }
            g2.dispose();
            return scaled;
        } else {
            // When creating a thumbnail, using the above code doesn't work very well;
            // you get some visible artifacts, especially for text. Instead use the
            // technique of repeatedly scaling the image into half; this will cause
            // proper averaging of neighboring pixels, and will typically (for the kinds
            // of screen sizes used by this utility method in the layout editor) take
            // about 3-4 iterations to get the result since we are logarithmically reducing
            // the size. Besides, each successive pass in operating on much fewer pixels
            // (a reduction of 4 in each pass).
            //
            // However, we may not be resizing to a size that can be reached exactly by
            // successively diving in half. Therefore, once we're within a factor of 2 of
            // the final size, we can do a resize to the exact target size.
            // However, we can get even better results if we perform this final resize
            // up front. Let's say we're going from width 1000 to a destination width of 85.
            // The first approach would cause a resize from 1000 to 500 to 250 to 125, and
            // then a resize from 125 to 85. That last resize can distort/blur a lot.
            // Instead, we can start with the destination width, 85, and double it
            // successfully until we're close to the initial size: 85, then 170,
            // then 340, and finally 680. (The next one, 1360, is larger than 1000).
            // So, now we *start* the thumbnail operation by resizing from width 1000 to
            // width 680, which will preserve a lot of visual details such as text.
            // Then we can successively resize the image in half, 680 to 340 to 170 to 85.
            // We end up with the expected final size, but we've been doing an exact
            // divide-in-half resizing operation at the end so there is less distortion.

            int iterations = 0; // Number of halving operations to perform after the initial resize
            int nearestWidth = destWidth; // Width closest to source width that = 2^x, x is integer
            int nearestHeight = destHeight;
            while (nearestWidth < sourceWidth / 2) {
                nearestWidth *= 2;
                nearestHeight *= 2;
                iterations++;
            }

            BufferedImage scaled = new BufferedImage(nearestWidth, nearestHeight, imageType);

            Graphics2D g2 = scaled.createGraphics();
            setRenderingHints(g2);
            g2.drawImage(source, 0, 0, nearestWidth, nearestHeight, 0, 0, sourceWidth, sourceHeight,
                    null);
            g2.dispose();

            sourceWidth = nearestWidth;
            sourceHeight = nearestHeight;
            source = scaled;

            for (int iteration = iterations - 1; iteration >= 0; iteration--) {
                int halfWidth = sourceWidth / 2;
                int halfHeight = sourceHeight / 2;
                scaled = new BufferedImage(halfWidth, halfHeight, imageType);
                g2 = scaled.createGraphics();
                setRenderingHints(g2);
                g2.drawImage(source, 0, 0, halfWidth, halfHeight, 0, 0, sourceWidth, sourceHeight,
                        null);
                g2.dispose();

                sourceWidth = halfWidth;
                sourceHeight = halfHeight;
                source = scaled;
                iterations--;
            }
            return scaled;
        }
    }

    private static void setRenderingHints(@NonNull Graphics2D g2) {
        g2.setRenderingHint(KEY_INTERPOLATION,VALUE_INTERPOLATION_BILINEAR);
        g2.setRenderingHint(KEY_RENDERING, VALUE_RENDER_QUALITY);
        g2.setRenderingHint(KEY_ANTIALIASING, VALUE_ANTIALIAS_ON);
    }

    /**
     * Temp directory where to write the thumbnails and deltas.
     */
    @NonNull
    private static File getTempDir() {
        if (System.getProperty("os.name").equals("Mac OS X")) {
            return new File("/tmp"); //$NON-NLS-1$
        }

        return new File(System.getProperty("java.io.tmpdir")); //$NON-NLS-1$
    }

    /**
     * Saves the generated thumbnail image and appends the info message to an initial message
     */
    @NonNull
    private static String saveImageAndAppendMessage(@NonNull BufferedImage image,
            @NonNull String initialMessage, @NonNull String relativePath) throws IOException {
        File output = new File(getTempDir(), getName(relativePath));
        if (output.exists()) {
            boolean deleted = output.delete();
            assertTrue(deleted);
        }
        ImageIO.write(image, "PNG", output);
        initialMessage += "Thumbnail for current rendering stored at " + output.getPath();
//        initialMessage += "\nRun the following command to accept the changes:\n";
//        initialMessage += String.format("mv %1$s %2$s", output.getPath(),
//                ImageUtils.class.getResource(relativePath).getPath());
        // The above has been commented out, since the destination path returned is in out dir
        // and it makes the tests pass without the code being actually checked in.
        return initialMessage;
    }

    private static String getName(@NonNull String relativePath) {
        return relativePath.substring(relativePath.lastIndexOf(separatorChar) + 1);
    }
}
+7 −0
Original line number Diff line number Diff line
@@ -39,6 +39,7 @@ import org.junit.Test;

import java.io.File;
import java.io.FileFilter;
import java.io.IOException;
import java.net.URL;
import java.util.Arrays;
import java.util.Comparator;
@@ -280,6 +281,12 @@ public class Main {
            getLogger().error(session.getResult().getException(),
                    session.getResult().getErrorMessage());
        }
        String goldenDataDir = APP_TEST_RES + "/../../../golden/";
        try {
            ImageUtils.requireSimilar(goldenDataDir + "activity.png", session.getImage());
        } catch (IOException e) {
            getLogger().error(e, e.getMessage());
        }
    }

    /**