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