am 565f2245: am a6671b70: am 6461e054: Merge "Add image comparison to LayoutLib tests." into lmp-dev
* commit '565f2245402f4fe6038942b03b9a35cfc0d48d86': Add image comparison to LayoutLib tests.
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,336 @@
|
|||||||
|
/*
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ import org.junit.Test;
|
|||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.FileFilter;
|
import java.io.FileFilter;
|
||||||
|
import java.io.IOException;
|
||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
@@ -280,6 +281,12 @@ public class Main {
|
|||||||
getLogger().error(session.getResult().getException(),
|
getLogger().error(session.getResult().getException(),
|
||||||
session.getResult().getErrorMessage());
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user