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