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