1 /* 2 * Copyright (C) 2016 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.AlphaComposite; 22 import java.awt.Color; 23 import java.awt.Graphics; 24 import java.awt.Graphics2D; 25 import java.awt.image.BufferedImage; 26 import java.io.File; 27 import java.io.IOException; 28 import java.io.InputStream; 29 30 import javax.imageio.ImageIO; 31 32 import static java.awt.RenderingHints.*; 33 import static java.awt.image.BufferedImage.TYPE_INT_ARGB; 34 import static java.io.File.separatorChar; 35 import static org.junit.Assert.assertEquals; 36 import static org.junit.Assert.assertTrue; 37 import static org.junit.Assert.fail; 38 39 40 // Adapted by taking the relevant pieces of code from the following classes: 41 // 42 // com.android.tools.idea.rendering.ImageUtils, 43 // com.android.tools.idea.tests.gui.framework.fixture.layout.ImageFixture and 44 // com.android.tools.idea.rendering.RenderTestBase 45 /** 46 * Utilities related to image processing. 47 */ 48 public class ImageUtils { 49 /** 50 * Normally, this test will fail when there is a missing thumbnail. However, when 51 * you create creating a new test, it's useful to be able to turn this off such that 52 * you can generate all the missing thumbnails in one go, rather than having to run 53 * the test repeatedly to get to each new render assertion generating its thumbnail. 54 */ 55 private static final boolean FAIL_ON_MISSING_THUMBNAIL = true; 56 57 private static final int THUMBNAIL_SIZE = 1000; 58 59 private static final double MAX_PERCENT_DIFFERENCE = 0.1; 60 requireSimilar(@onNull String relativePath, @NonNull BufferedImage image)61 public static void requireSimilar(@NonNull String relativePath, @NonNull BufferedImage image) 62 throws IOException { 63 int maxDimension = Math.max(image.getWidth(), image.getHeight()); 64 double scale = THUMBNAIL_SIZE / (double)maxDimension; 65 BufferedImage thumbnail = scale(image, scale, scale); 66 67 InputStream is = ImageUtils.class.getClassLoader().getResourceAsStream(relativePath); 68 if (is == null) { 69 String message = "Unable to load golden thumbnail: " + relativePath + "\n"; 70 message = saveImageAndAppendMessage(thumbnail, message, relativePath); 71 if (FAIL_ON_MISSING_THUMBNAIL) { 72 fail(message); 73 } else { 74 System.out.println(message); 75 } 76 } 77 else { 78 try { 79 BufferedImage goldenImage = ImageIO.read(is); 80 assertImageSimilar(relativePath, goldenImage, thumbnail, MAX_PERCENT_DIFFERENCE); 81 } finally { 82 is.close(); 83 } 84 } 85 } 86 assertImageSimilar(String relativePath, BufferedImage goldenImage, BufferedImage image, double maxPercentDifferent)87 public static void assertImageSimilar(String relativePath, BufferedImage goldenImage, 88 BufferedImage image, double maxPercentDifferent) throws IOException { 89 if (goldenImage.getType() != TYPE_INT_ARGB) { 90 BufferedImage temp = new BufferedImage(goldenImage.getWidth(), goldenImage.getHeight(), 91 TYPE_INT_ARGB); 92 temp.getGraphics().drawImage(goldenImage, 0, 0, null); 93 goldenImage = temp; 94 } 95 assertEquals(TYPE_INT_ARGB, goldenImage.getType()); 96 97 int imageWidth = Math.min(goldenImage.getWidth(), image.getWidth()); 98 int imageHeight = Math.min(goldenImage.getHeight(), image.getHeight()); 99 100 // Blur the images to account for the scenarios where there are pixel 101 // differences 102 // in where a sharp edge occurs 103 // goldenImage = blur(goldenImage, 6); 104 // image = blur(image, 6); 105 106 int width = 3 * imageWidth; 107 @SuppressWarnings("UnnecessaryLocalVariable") 108 int height = imageHeight; // makes code more readable 109 BufferedImage deltaImage = new BufferedImage(width, height, TYPE_INT_ARGB); 110 Graphics g = deltaImage.getGraphics(); 111 112 // Compute delta map 113 long delta = 0; 114 for (int y = 0; y < imageHeight; y++) { 115 for (int x = 0; x < imageWidth; x++) { 116 int goldenRgb = goldenImage.getRGB(x, y); 117 int rgb = image.getRGB(x, y); 118 if (goldenRgb == rgb) { 119 deltaImage.setRGB(imageWidth + x, y, 0x00808080); 120 continue; 121 } 122 123 // If the pixels have no opacity, don't delta colors at all 124 if (((goldenRgb & 0xFF000000) == 0) && (rgb & 0xFF000000) == 0) { 125 deltaImage.setRGB(imageWidth + x, y, 0x00808080); 126 continue; 127 } 128 129 int deltaR = ((rgb & 0xFF0000) >>> 16) - ((goldenRgb & 0xFF0000) >>> 16); 130 int newR = 128 + deltaR & 0xFF; 131 int deltaG = ((rgb & 0x00FF00) >>> 8) - ((goldenRgb & 0x00FF00) >>> 8); 132 int newG = 128 + deltaG & 0xFF; 133 int deltaB = (rgb & 0x0000FF) - (goldenRgb & 0x0000FF); 134 int newB = 128 + deltaB & 0xFF; 135 136 int avgAlpha = ((((goldenRgb & 0xFF000000) >>> 24) 137 + ((rgb & 0xFF000000) >>> 24)) / 2) << 24; 138 139 int newRGB = avgAlpha | newR << 16 | newG << 8 | newB; 140 deltaImage.setRGB(imageWidth + x, y, newRGB); 141 142 delta += Math.abs(deltaR); 143 delta += Math.abs(deltaG); 144 delta += Math.abs(deltaB); 145 } 146 } 147 148 // 3 different colors, 256 color levels 149 long total = imageHeight * imageWidth * 3L * 256L; 150 float percentDifference = (float) (delta * 100 / (double) total); 151 152 String error = null; 153 String imageName = getName(relativePath); 154 if (percentDifference > maxPercentDifferent) { 155 error = String.format("Images differ (by %.1f%%)", percentDifference); 156 } else if (Math.abs(goldenImage.getWidth() - image.getWidth()) >= 2) { 157 error = "Widths differ too much for " + imageName + ": " + 158 goldenImage.getWidth() + "x" + goldenImage.getHeight() + 159 "vs" + image.getWidth() + "x" + image.getHeight(); 160 } else if (Math.abs(goldenImage.getHeight() - image.getHeight()) >= 2) { 161 error = "Heights differ too much for " + imageName + ": " + 162 goldenImage.getWidth() + "x" + goldenImage.getHeight() + 163 "vs" + image.getWidth() + "x" + image.getHeight(); 164 } 165 166 if (error != null) { 167 // Expected on the left 168 // Golden on the right 169 g.drawImage(goldenImage, 0, 0, null); 170 g.drawImage(image, 2 * imageWidth, 0, null); 171 172 // Labels 173 if (imageWidth > 80) { 174 g.setColor(Color.RED); 175 g.drawString("Expected", 10, 20); 176 g.drawString("Actual", 2 * imageWidth + 10, 20); 177 } 178 179 File output = new File(getFailureDir(), "delta-" + imageName); 180 if (output.exists()) { 181 boolean deleted = output.delete(); 182 assertTrue(deleted); 183 } 184 ImageIO.write(deltaImage, "PNG", output); 185 error += " - see details in file://" + output.getPath() + "\n"; 186 error = saveImageAndAppendMessage(image, error, relativePath); 187 System.out.println(error); 188 fail(error); 189 } 190 191 g.dispose(); 192 } 193 194 /** 195 * Resize the given image 196 * 197 * @param source the image to be scaled 198 * @param xScale x scale 199 * @param yScale y scale 200 * @return the scaled image 201 */ 202 @NonNull scale(@onNull BufferedImage source, double xScale, double yScale)203 public static BufferedImage scale(@NonNull BufferedImage source, double xScale, double yScale) { 204 205 int sourceWidth = source.getWidth(); 206 int sourceHeight = source.getHeight(); 207 int destWidth = Math.max(1, (int) (xScale * sourceWidth)); 208 int destHeight = Math.max(1, (int) (yScale * sourceHeight)); 209 int imageType = source.getType(); 210 if (imageType == BufferedImage.TYPE_CUSTOM) { 211 imageType = BufferedImage.TYPE_INT_ARGB; 212 } 213 if (xScale > 0.5 && yScale > 0.5) { 214 BufferedImage scaled = 215 new BufferedImage(destWidth, destHeight, imageType); 216 Graphics2D g2 = scaled.createGraphics(); 217 g2.setComposite(AlphaComposite.Src); 218 g2.setColor(new Color(0, true)); 219 g2.fillRect(0, 0, destWidth, destHeight); 220 if (xScale == 1 && yScale == 1) { 221 g2.drawImage(source, 0, 0, null); 222 } else { 223 setRenderingHints(g2); 224 g2.drawImage(source, 0, 0, destWidth, destHeight, 0, 0, sourceWidth, sourceHeight, 225 null); 226 } 227 g2.dispose(); 228 return scaled; 229 } else { 230 // When creating a thumbnail, using the above code doesn't work very well; 231 // you get some visible artifacts, especially for text. Instead use the 232 // technique of repeatedly scaling the image into half; this will cause 233 // proper averaging of neighboring pixels, and will typically (for the kinds 234 // of screen sizes used by this utility method in the layout editor) take 235 // about 3-4 iterations to get the result since we are logarithmically reducing 236 // the size. Besides, each successive pass in operating on much fewer pixels 237 // (a reduction of 4 in each pass). 238 // 239 // However, we may not be resizing to a size that can be reached exactly by 240 // successively diving in half. Therefore, once we're within a factor of 2 of 241 // the final size, we can do a resize to the exact target size. 242 // However, we can get even better results if we perform this final resize 243 // up front. Let's say we're going from width 1000 to a destination width of 85. 244 // The first approach would cause a resize from 1000 to 500 to 250 to 125, and 245 // then a resize from 125 to 85. That last resize can distort/blur a lot. 246 // Instead, we can start with the destination width, 85, and double it 247 // successfully until we're close to the initial size: 85, then 170, 248 // then 340, and finally 680. (The next one, 1360, is larger than 1000). 249 // So, now we *start* the thumbnail operation by resizing from width 1000 to 250 // width 680, which will preserve a lot of visual details such as text. 251 // Then we can successively resize the image in half, 680 to 340 to 170 to 85. 252 // We end up with the expected final size, but we've been doing an exact 253 // divide-in-half resizing operation at the end so there is less distortion. 254 255 int iterations = 0; // Number of halving operations to perform after the initial resize 256 int nearestWidth = destWidth; // Width closest to source width that = 2^x, x is integer 257 int nearestHeight = destHeight; 258 while (nearestWidth < sourceWidth / 2) { 259 nearestWidth *= 2; 260 nearestHeight *= 2; 261 iterations++; 262 } 263 264 BufferedImage scaled = new BufferedImage(nearestWidth, nearestHeight, imageType); 265 266 Graphics2D g2 = scaled.createGraphics(); 267 setRenderingHints(g2); 268 g2.drawImage(source, 0, 0, nearestWidth, nearestHeight, 0, 0, sourceWidth, sourceHeight, 269 null); 270 g2.dispose(); 271 272 sourceWidth = nearestWidth; 273 sourceHeight = nearestHeight; 274 source = scaled; 275 276 for (int iteration = iterations - 1; iteration >= 0; iteration--) { 277 int halfWidth = sourceWidth / 2; 278 int halfHeight = sourceHeight / 2; 279 scaled = new BufferedImage(halfWidth, halfHeight, imageType); 280 g2 = scaled.createGraphics(); 281 setRenderingHints(g2); 282 g2.drawImage(source, 0, 0, halfWidth, halfHeight, 0, 0, sourceWidth, sourceHeight, 283 null); 284 g2.dispose(); 285 286 sourceWidth = halfWidth; 287 sourceHeight = halfHeight; 288 source = scaled; 289 iterations--; 290 } 291 return scaled; 292 } 293 } 294 setRenderingHints(@onNull Graphics2D g2)295 private static void setRenderingHints(@NonNull Graphics2D g2) { 296 g2.setRenderingHint(KEY_INTERPOLATION,VALUE_INTERPOLATION_BILINEAR); 297 g2.setRenderingHint(KEY_RENDERING, VALUE_RENDER_QUALITY); 298 g2.setRenderingHint(KEY_ANTIALIASING, VALUE_ANTIALIAS_ON); 299 } 300 301 /** 302 * Directory where to write the thumbnails and deltas. 303 */ 304 @NonNull getFailureDir()305 private static File getFailureDir() { 306 File failureDir; 307 String failureDirString = System.getProperty("test_failure.dir"); 308 if (failureDirString != null) { 309 failureDir = new File(failureDirString); 310 } else { 311 String workingDirString = System.getProperty("user.dir"); 312 failureDir = new File(workingDirString, "out/failures"); 313 } 314 315 //noinspection ResultOfMethodCallIgnored 316 failureDir.mkdirs(); 317 return failureDir; //$NON-NLS-1$ 318 } 319 320 /** 321 * Saves the generated thumbnail image and appends the info message to an initial message 322 */ 323 @NonNull saveImageAndAppendMessage(@onNull BufferedImage image, @NonNull String initialMessage, @NonNull String relativePath)324 private static String saveImageAndAppendMessage(@NonNull BufferedImage image, 325 @NonNull String initialMessage, @NonNull String relativePath) throws IOException { 326 File output = new File(getFailureDir(), getName(relativePath)); 327 if (output.exists()) { 328 boolean deleted = output.delete(); 329 assertTrue(deleted); 330 } 331 ImageIO.write(image, "PNG", output); 332 initialMessage += "Thumbnail for current rendering stored at " + output.getPath(); 333 // initialMessage += "\nRun the following command to accept the changes:\n"; 334 // initialMessage += String.format("mv %1$s %2$s", output.getPath(), 335 // ImageUtils.class.getResource(relativePath).getPath()); 336 // The above has been commented out, since the destination path returned is in out dir 337 // and it makes the tests pass without the code being actually checked in. 338 return initialMessage; 339 } 340 getName(@onNull String relativePath)341 private static String getName(@NonNull String relativePath) { 342 return relativePath.substring(relativePath.lastIndexOf(separatorChar) + 1); 343 } 344 } 345