1 /* 2 * Copyright (C) 2022 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.layoutlib.bridge.intensive.util; 18 19 import android.annotation.NonNull; 20 21 import java.awt.Color; 22 import java.awt.Graphics; 23 import java.awt.image.BufferedImage; 24 import java.io.File; 25 import java.io.IOException; 26 import java.io.InputStream; 27 28 import javax.imageio.ImageIO; 29 30 import static java.awt.image.BufferedImage.TYPE_INT_ARGB; 31 import static java.io.File.separatorChar; 32 import static org.junit.Assert.assertEquals; 33 import static org.junit.Assert.assertTrue; 34 import static org.junit.Assert.fail; 35 36 37 // Adapted by taking the relevant pieces of code from the following classes: 38 // 39 // com.android.tools.idea.rendering.ImageUtils, 40 // com.android.tools.idea.tests.gui.framework.fixture.layout.ImageFixture and 41 // com.android.tools.idea.rendering.RenderTestBase 42 /** 43 * Utilities related to image processing. 44 */ 45 public class ImageUtils { 46 /** 47 * Normally, this test will fail when there is a missing golden image. However, when 48 * you create creating a new test, it's useful to be able to turn this off such that 49 * you can generate all the missing golden images in one go, rather than having to run 50 * the test repeatedly to get to each new render assertion generating its golden images. 51 */ 52 private static final boolean FAIL_ON_MISSING_GOLDEN = true; 53 54 private static final double MAX_PERCENT_DIFFERENCE = 0.1; 55 requireSimilar(@onNull String relativePath, @NonNull BufferedImage image)56 public static void requireSimilar(@NonNull String relativePath, @NonNull BufferedImage image) 57 throws IOException { 58 InputStream is = ImageUtils.class.getClassLoader().getResourceAsStream(relativePath); 59 if (is == null) { 60 String message = "Unable to load golden image: " + relativePath + "\n"; 61 message = saveImageAndAppendMessage(image, message, relativePath); 62 if (FAIL_ON_MISSING_GOLDEN) { 63 fail(message); 64 } else { 65 System.out.println(message); 66 } 67 } 68 else { 69 try { 70 BufferedImage goldenImage = ImageIO.read(is); 71 assertImageSimilar(relativePath, goldenImage, image, MAX_PERCENT_DIFFERENCE); 72 } finally { 73 is.close(); 74 } 75 } 76 } 77 assertImageSimilar(String relativePath, BufferedImage goldenImage, BufferedImage image, double maxPercentDifferent)78 public static void assertImageSimilar(String relativePath, BufferedImage goldenImage, 79 BufferedImage image, double maxPercentDifferent) throws IOException { 80 if (goldenImage.getType() != TYPE_INT_ARGB) { 81 BufferedImage temp = new BufferedImage(goldenImage.getWidth(), goldenImage.getHeight(), 82 TYPE_INT_ARGB); 83 temp.getGraphics().drawImage(goldenImage, 0, 0, null); 84 goldenImage = temp; 85 } 86 assertEquals(TYPE_INT_ARGB, goldenImage.getType()); 87 88 int imageWidth = Math.min(goldenImage.getWidth(), image.getWidth()); 89 int imageHeight = Math.min(goldenImage.getHeight(), image.getHeight()); 90 91 // Blur the images to account for the scenarios where there are pixel 92 // differences 93 // in where a sharp edge occurs 94 // goldenImage = blur(goldenImage, 6); 95 // image = blur(image, 6); 96 97 int width = 3 * imageWidth; 98 @SuppressWarnings("UnnecessaryLocalVariable") 99 int height = imageHeight; // makes code more readable 100 BufferedImage deltaImage = new BufferedImage(width, height, TYPE_INT_ARGB); 101 Graphics g = deltaImage.getGraphics(); 102 103 // Compute delta map 104 long delta = 0; 105 for (int y = 0; y < imageHeight; y++) { 106 for (int x = 0; x < imageWidth; x++) { 107 int goldenRgb = goldenImage.getRGB(x, y); 108 int rgb = image.getRGB(x, y); 109 if (goldenRgb == rgb) { 110 deltaImage.setRGB(imageWidth + x, y, 0x00808080); 111 continue; 112 } 113 114 // If the pixels have no opacity, don't delta colors at all 115 if (((goldenRgb & 0xFF000000) == 0) && (rgb & 0xFF000000) == 0) { 116 deltaImage.setRGB(imageWidth + x, y, 0x00808080); 117 continue; 118 } 119 120 int deltaR = ((rgb & 0xFF0000) >>> 16) - ((goldenRgb & 0xFF0000) >>> 16); 121 int newR = 128 + deltaR & 0xFF; 122 int deltaG = ((rgb & 0x00FF00) >>> 8) - ((goldenRgb & 0x00FF00) >>> 8); 123 int newG = 128 + deltaG & 0xFF; 124 int deltaB = (rgb & 0x0000FF) - (goldenRgb & 0x0000FF); 125 int newB = 128 + deltaB & 0xFF; 126 127 int avgAlpha = ((((goldenRgb & 0xFF000000) >>> 24) 128 + ((rgb & 0xFF000000) >>> 24)) / 2) << 24; 129 130 int newRGB = avgAlpha | newR << 16 | newG << 8 | newB; 131 deltaImage.setRGB(imageWidth + x, y, newRGB); 132 133 delta += Math.abs(deltaR); 134 delta += Math.abs(deltaG); 135 delta += Math.abs(deltaB); 136 } 137 } 138 139 // 3 different colors, 256 color levels 140 long total = imageHeight * imageWidth * 3L * 256L; 141 float percentDifference = (float) (delta * 100 / (double) total); 142 143 String error = null; 144 String imageName = getName(relativePath); 145 if (percentDifference > maxPercentDifferent) { 146 error = String.format("Images differ (by %.1f%%)", percentDifference); 147 } else if (Math.abs(goldenImage.getWidth() - image.getWidth()) >= 2) { 148 error = "Widths differ too much for " + imageName + ": " + 149 goldenImage.getWidth() + "x" + goldenImage.getHeight() + 150 "vs" + image.getWidth() + "x" + image.getHeight(); 151 } else if (Math.abs(goldenImage.getHeight() - image.getHeight()) >= 2) { 152 error = "Heights differ too much for " + imageName + ": " + 153 goldenImage.getWidth() + "x" + goldenImage.getHeight() + 154 "vs" + image.getWidth() + "x" + image.getHeight(); 155 } 156 157 if (error != null) { 158 // Expected on the left 159 // Golden on the right 160 g.drawImage(goldenImage, 0, 0, null); 161 g.drawImage(image, 2 * imageWidth, 0, null); 162 163 // Labels 164 if (imageWidth > 80) { 165 g.setColor(Color.RED); 166 g.drawString("Expected", 10, 20); 167 g.drawString("Actual", 2 * imageWidth + 10, 20); 168 } 169 170 File output = new File(getFailureDir(), "delta-" + imageName); 171 if (output.exists()) { 172 boolean deleted = output.delete(); 173 assertTrue(deleted); 174 } 175 ImageIO.write(deltaImage, "PNG", output); 176 error += " - see details in file://" + output.getPath() + "\n"; 177 error = saveImageAndAppendMessage(image, error, relativePath); 178 System.out.println(error); 179 fail(error); 180 } 181 182 g.dispose(); 183 } 184 185 /** 186 * Directory where to write the generated image and deltas. 187 */ 188 @NonNull getFailureDir()189 private static File getFailureDir() { 190 File failureDir; 191 String failureDirString = System.getProperty("test_failure.dir"); 192 if (failureDirString != null) { 193 failureDir = new File(failureDirString); 194 } else { 195 String workingDirString = System.getProperty("user.dir"); 196 failureDir = new File(workingDirString, "out/failures"); 197 } 198 199 //noinspection ResultOfMethodCallIgnored 200 failureDir.mkdirs(); 201 return failureDir; //$NON-NLS-1$ 202 } 203 204 /** 205 * Saves the generated golden image and appends the info message to an initial message 206 */ 207 @NonNull saveImageAndAppendMessage(@onNull BufferedImage image, @NonNull String initialMessage, @NonNull String relativePath)208 private static String saveImageAndAppendMessage(@NonNull BufferedImage image, 209 @NonNull String initialMessage, @NonNull String relativePath) throws IOException { 210 File output = new File(getFailureDir(), getName(relativePath)); 211 if (output.exists()) { 212 boolean deleted = output.delete(); 213 assertTrue(deleted); 214 } 215 ImageIO.write(image, "PNG", output); 216 initialMessage += "Golden image for current rendering stored at " + output.getPath(); 217 // initialMessage += "\nRun the following command to accept the changes:\n"; 218 // initialMessage += String.format("mv %1$s %2$s", output.getPath(), 219 // ImageUtils.class.getResource(relativePath).getPath()); 220 // The above has been commented out, since the destination path returned is in out dir 221 // and it makes the tests pass without the code being actually checked in. 222 return initialMessage; 223 } 224 getName(@onNull String relativePath)225 private static String getName(@NonNull String relativePath) { 226 return relativePath.substring(relativePath.lastIndexOf(separatorChar) + 1); 227 } 228 } 229