From 06cd5cd7d0c111bc7b0bb4ece155f4782de7c7f0 Mon Sep 17 00:00:00 2001 From: Jordan Atwood Date: Sun, 29 Jul 2018 19:37:37 -0700 Subject: [PATCH] Add image utility class --- .../client/plugins/config/ConfigPanel.java | 4 +- .../client/plugins/config/PluginListItem.java | 4 +- .../kourendlibrary/KourendLibraryPanel.java | 4 +- .../puzzlesolver/PuzzleSolverOverlay.java | 17 +- .../screenmarkers/ui/ScreenMarkerPanel.java | 20 +- .../net/runelite/client/util/ImageUtil.java | 112 +++++++++ .../net/runelite/client/util/SwingUtil.java | 58 +---- .../runelite/client/util/ImageUtilTest.java | 236 ++++++++++++++++++ 8 files changed, 369 insertions(+), 86 deletions(-) create mode 100644 runelite-client/src/main/java/net/runelite/client/util/ImageUtil.java create mode 100644 runelite-client/src/test/java/net/runelite/client/util/ImageUtilTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/config/ConfigPanel.java b/runelite-client/src/main/java/net/runelite/client/plugins/config/ConfigPanel.java index 632b365611..e5636314f9 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/config/ConfigPanel.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/config/ConfigPanel.java @@ -89,7 +89,7 @@ import net.runelite.client.ui.PluginPanel; import net.runelite.client.ui.components.ComboBoxListRenderer; import net.runelite.client.ui.components.IconButton; import net.runelite.client.ui.components.IconTextField; -import net.runelite.client.util.SwingUtil; +import net.runelite.client.util.ImageUtil; @Slf4j public class ConfigPanel extends PluginPanel @@ -130,7 +130,7 @@ public class ConfigPanel extends PluginPanel { BufferedImage backIcon = ImageIO.read(ConfigPanel.class.getResourceAsStream("config_back_icon.png")); BACK_ICON = new ImageIcon(backIcon); - BACK_ICON_HOVER = new ImageIcon(SwingUtil.grayscaleOffset(backIcon, -100)); + BACK_ICON_HOVER = new ImageIcon(ImageUtil.grayscaleOffset(backIcon, -100)); SEARCH = new ImageIcon(ImageIO.read(IconTextField.class.getResourceAsStream("search.png"))); } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/config/PluginListItem.java b/runelite-client/src/main/java/net/runelite/client/plugins/config/PluginListItem.java index 0514795de1..27b96d2c27 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/config/PluginListItem.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/config/PluginListItem.java @@ -45,7 +45,7 @@ import net.runelite.client.plugins.Plugin; import net.runelite.client.plugins.PluginDescriptor; import net.runelite.client.ui.PluginPanel; import net.runelite.client.ui.components.IconButton; -import net.runelite.client.util.SwingUtil; +import net.runelite.client.util.ImageUtil; import org.apache.commons.text.similarity.JaroWinklerDistance; class PluginListItem extends JPanel @@ -98,7 +98,7 @@ class PluginListItem extends JPanel OFF_STAR = new ImageIcon(ImageIO.read(ConfigPanel.class.getResourceAsStream("stars/off.png"))); } - CONFIG_ICON_HOVER = new ImageIcon(SwingUtil.grayscaleOffset(configIcon, -100)); + CONFIG_ICON_HOVER = new ImageIcon(ImageUtil.grayscaleOffset(configIcon, -100)); } catch (IOException e) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/kourendlibrary/KourendLibraryPanel.java b/runelite-client/src/main/java/net/runelite/client/plugins/kourendlibrary/KourendLibraryPanel.java index 33b7edba41..cd66a57229 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/kourendlibrary/KourendLibraryPanel.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/kourendlibrary/KourendLibraryPanel.java @@ -52,7 +52,7 @@ import javax.swing.border.CompoundBorder; import javax.swing.border.EmptyBorder; import net.runelite.client.ui.ColorScheme; import net.runelite.client.ui.PluginPanel; -import net.runelite.client.util.SwingUtil; +import net.runelite.client.util.ImageUtil; @Singleton class KourendLibraryPanel extends PluginPanel @@ -73,7 +73,7 @@ class KourendLibraryPanel extends PluginPanel { BufferedImage resetIcon = ImageIO.read(KourendLibraryPanel.class.getResourceAsStream("reset.png")); RESET_ICON = new ImageIcon(resetIcon); - RESET_CLICK_ICON = new ImageIcon(SwingUtil.grayscaleOffset(resetIcon, -100)); + RESET_CLICK_ICON = new ImageIcon(ImageUtil.grayscaleOffset(resetIcon, -100)); } } catch (IOException e) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/puzzlesolver/PuzzleSolverOverlay.java b/runelite-client/src/main/java/net/runelite/client/plugins/puzzlesolver/PuzzleSolverOverlay.java index 33b8aff485..cc82f165cf 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/puzzlesolver/PuzzleSolverOverlay.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/puzzlesolver/PuzzleSolverOverlay.java @@ -32,8 +32,6 @@ import java.awt.FontMetrics; import java.awt.Graphics2D; import java.awt.Point; import java.awt.Rectangle; -import java.awt.geom.AffineTransform; -import java.awt.image.AffineTransformOp; import java.awt.image.BufferedImage; import java.io.IOException; import java.util.Arrays; @@ -60,6 +58,7 @@ import net.runelite.client.ui.overlay.OverlayPriority; import net.runelite.client.ui.overlay.OverlayUtil; import net.runelite.client.ui.overlay.components.BackgroundComponent; import net.runelite.client.ui.overlay.components.TextComponent; +import net.runelite.client.util.ImageUtil; @Slf4j public class PuzzleSolverOverlay extends Overlay @@ -448,7 +447,7 @@ public class PuzzleSolverOverlay extends Overlay { if (upArrow == null) { - upArrow = getRotatedImage(getDownArrow(), Math.PI); + upArrow = ImageUtil.rotateImage(getDownArrow(), Math.PI); } return upArrow; } @@ -457,7 +456,7 @@ public class PuzzleSolverOverlay extends Overlay { if (leftArrow == null) { - leftArrow = getRotatedImage(getDownArrow(), Math.PI / 2); + leftArrow = ImageUtil.rotateImage(getDownArrow(), Math.PI / 2); } return leftArrow; } @@ -466,16 +465,8 @@ public class PuzzleSolverOverlay extends Overlay { if (rightArrow == null) { - rightArrow = getRotatedImage(getDownArrow(), 3 * Math.PI / 2); + rightArrow = ImageUtil.rotateImage(getDownArrow(), 3 * Math.PI / 2); } return rightArrow; } - - private BufferedImage getRotatedImage(BufferedImage image, double theta) - { - AffineTransform transform = new AffineTransform(); - transform.rotate(theta, image.getWidth() / 2, image.getHeight() / 2); - AffineTransformOp transformOp = new AffineTransformOp(transform, AffineTransformOp.TYPE_BILINEAR); - return transformOp.filter(image, null); - } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/screenmarkers/ui/ScreenMarkerPanel.java b/runelite-client/src/main/java/net/runelite/client/plugins/screenmarkers/ui/ScreenMarkerPanel.java index e7b4d71044..def79500dd 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/screenmarkers/ui/ScreenMarkerPanel.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/screenmarkers/ui/ScreenMarkerPanel.java @@ -55,7 +55,7 @@ import net.runelite.client.plugins.screenmarkers.ScreenMarkerPlugin; import net.runelite.client.ui.ColorScheme; import net.runelite.client.ui.FontManager; import net.runelite.client.ui.components.FlatTextField; -import net.runelite.client.util.SwingUtil; +import net.runelite.client.util.ImageUtil; class ScreenMarkerPanel extends JPanel { @@ -114,36 +114,36 @@ class ScreenMarkerPanel extends JPanel { BufferedImage borderImg = ImageIO.read(ScreenMarkerPlugin.class.getResourceAsStream("border_color_icon.png")); BORDER_COLOR_ICON = new ImageIcon(borderImg); - BORDER_COLOR_HOVER_ICON = new ImageIcon(SwingUtil.grayscaleOffset(borderImg, -100)); + BORDER_COLOR_HOVER_ICON = new ImageIcon(ImageUtil.grayscaleOffset(borderImg, -100)); BufferedImage noBorderImg = ImageIO.read(ScreenMarkerPlugin.class.getResourceAsStream("no_border_color_icon.png")); NO_BORDER_COLOR_ICON = new ImageIcon(noBorderImg); - NO_BORDER_COLOR_HOVER_ICON = new ImageIcon(SwingUtil.grayscaleOffset(noBorderImg, -100)); + NO_BORDER_COLOR_HOVER_ICON = new ImageIcon(ImageUtil.grayscaleOffset(noBorderImg, -100)); BufferedImage fillImg = ImageIO.read(ScreenMarkerPlugin.class.getResourceAsStream("fill_color_icon.png")); FILL_COLOR_ICON = new ImageIcon(fillImg); - FILL_COLOR_HOVER_ICON = new ImageIcon(SwingUtil.grayscaleOffset(fillImg, -100)); + FILL_COLOR_HOVER_ICON = new ImageIcon(ImageUtil.grayscaleOffset(fillImg, -100)); BufferedImage noFillImg = ImageIO.read(ScreenMarkerPlugin.class.getResourceAsStream("no_fill_color_icon.png")); NO_FILL_COLOR_ICON = new ImageIcon(noFillImg); - NO_FILL_COLOR_HOVER_ICON = new ImageIcon(SwingUtil.grayscaleOffset(noFillImg, -100)); + NO_FILL_COLOR_HOVER_ICON = new ImageIcon(ImageUtil.grayscaleOffset(noFillImg, -100)); BufferedImage opacityImg = ImageIO.read(ScreenMarkerPlugin.class.getResourceAsStream("opacity_icon.png")); FULL_OPACITY_ICON = new ImageIcon(opacityImg); - OPACITY_HOVER_ICON = new ImageIcon(SwingUtil.grayscaleOffset(opacityImg, -100)); - NO_OPACITY_ICON = new ImageIcon(SwingUtil.grayscaleOffset(opacityImg, -150)); + OPACITY_HOVER_ICON = new ImageIcon(ImageUtil.grayscaleOffset(opacityImg, -100)); + NO_OPACITY_ICON = new ImageIcon(ImageUtil.grayscaleOffset(opacityImg, -150)); BufferedImage visibleImg = ImageIO.read(ScreenMarkerPlugin.class.getResourceAsStream("visible_icon.png")); VISIBLE_ICON = new ImageIcon(visibleImg); - VISIBLE_HOVER_ICON = new ImageIcon(SwingUtil.grayscaleOffset(visibleImg, -100)); + VISIBLE_HOVER_ICON = new ImageIcon(ImageUtil.grayscaleOffset(visibleImg, -100)); BufferedImage invisibleImg = ImageIO.read(ScreenMarkerPlugin.class.getResourceAsStream("invisible_icon.png")); INVISIBLE_ICON = new ImageIcon(invisibleImg); - INVISIBLE_HOVER_ICON = new ImageIcon(SwingUtil.grayscaleOffset(invisibleImg, -100)); + INVISIBLE_HOVER_ICON = new ImageIcon(ImageUtil.grayscaleOffset(invisibleImg, -100)); BufferedImage deleteImg = ImageIO.read(ScreenMarkerPlugin.class.getResourceAsStream("delete_icon.png")); DELETE_ICON = new ImageIcon(deleteImg); - DELETE_HOVER_ICON = new ImageIcon(SwingUtil.grayscaleOffset(deleteImg, -100)); + DELETE_HOVER_ICON = new ImageIcon(ImageUtil.grayscaleOffset(deleteImg, -100)); } } catch (IOException e) diff --git a/runelite-client/src/main/java/net/runelite/client/util/ImageUtil.java b/runelite-client/src/main/java/net/runelite/client/util/ImageUtil.java new file mode 100644 index 0000000000..c2aa18f999 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/util/ImageUtil.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2018, Jordan Atwood + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package net.runelite.client.util; + +import java.awt.Graphics2D; +import java.awt.Image; +import java.awt.geom.AffineTransform; +import java.awt.image.AffineTransformOp; +import java.awt.image.BufferedImage; +import java.awt.image.RescaleOp; +import java.util.Arrays; + +/** + * Various Image/BufferedImage utilities. + */ +public class ImageUtil +{ + /** + * Offsets an image in the grayscale (darkens/brightens) by a given offset. + * + * @param image The image to be darkened or brightened. + * @param offset A signed 8-bit integer value to brighten or darken the image with. + * Values above 0 will brighten, and values below 0 will darken. + * @return The given image with its brightness adjusted by the given offset. + */ + public static BufferedImage grayscaleOffset(final BufferedImage image, final int offset) + { + final float offsetFloat = (float) offset; + final int numComponents = image.getColorModel().getNumComponents(); + final float[] scales = new float[numComponents]; + final float[] offsets = new float[numComponents]; + + Arrays.fill(scales, 1f); + for (int i = 0; i < numComponents; i++) + { + offsets[i] = offsetFloat; + } + // Set alpha to not offset + offsets[numComponents - 1] = 0f; + + return offset(image, scales, offsets); + } + + /** + * Re-size a BufferedImage to the given dimensions. + * + * @param image the BufferedImage. + * @param newWidth The width to set the BufferedImage to. + * @param newHeight The height to set the BufferedImage to. + * @return The BufferedImage with the specified dimensions + */ + public static BufferedImage resizeImage(final BufferedImage image, final int newWidth, final int newHeight) + { + final Image tmp = image.getScaledInstance(newWidth, newHeight, Image.SCALE_SMOOTH); + final BufferedImage dimg = new BufferedImage(newWidth, newHeight, BufferedImage.TYPE_INT_ARGB); + + final Graphics2D g2d = dimg.createGraphics(); + g2d.drawImage(tmp, 0, 0, null); + g2d.dispose(); + return dimg; + } + + /** + * Rotates an image around its center by a given number of radians. + * + * @param image The image to be rotated. + * @param theta The number of radians to rotate the image. + * @return The given image, rotated by the given theta. + */ + public static BufferedImage rotateImage(final BufferedImage image, final double theta) + { + AffineTransform transform = new AffineTransform(); + transform.rotate(theta, image.getWidth() / 2.0, image.getHeight() / 2.0); + AffineTransformOp transformOp = new AffineTransformOp(transform, AffineTransformOp.TYPE_BILINEAR); + return transformOp.filter(image, null); + } + + /** + * Performs a rescale operation on the image's color components. + * + * @param image The image to be adjusted. + * @param scales An array of scale operations to be performed on the image's color components. + * @param offsets An array of offset operations to be performed on the image's color components. + * @return The modified image after applying the given adjustments. + */ + private static BufferedImage offset(final BufferedImage image, final float[] scales, final float[] offsets) + { + return new RescaleOp(scales, offsets, null).filter(image, null); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/util/SwingUtil.java b/runelite-client/src/main/java/net/runelite/client/util/SwingUtil.java index 47bc06adb6..e951393709 100644 --- a/runelite-client/src/main/java/net/runelite/client/util/SwingUtil.java +++ b/runelite-client/src/main/java/net/runelite/client/util/SwingUtil.java @@ -29,7 +29,6 @@ import java.awt.Color; import java.awt.Dimension; import java.awt.Font; import java.awt.Frame; -import java.awt.Graphics2D; import java.awt.Image; import java.awt.Rectangle; import java.awt.SystemTray; @@ -41,8 +40,6 @@ import java.awt.event.MouseEvent; import java.awt.event.WindowAdapter; import java.awt.event.WindowEvent; import java.awt.image.BufferedImage; -import java.awt.image.RescaleOp; -import java.util.Arrays; import java.util.Enumeration; import java.util.concurrent.Callable; import java.util.function.BiConsumer; @@ -105,27 +102,6 @@ public class SwingUtil System.setProperty("sun.awt.noerasebackground", "true"); } - /** - * Offsets an image in the grayscale (darkens/brightens) by an offset - */ - public static BufferedImage grayscaleOffset(BufferedImage image, int offset) - { - final float offsetFloat = (float) offset; - final int numComponents = image.getColorModel().getNumComponents(); - final float[] scales = new float[numComponents]; - final float[] offsets = new float[numComponents]; - - Arrays.fill(scales, 1f); - for (int i = 0; i < numComponents; i++) - { - offsets[i] = offsetFloat; - } - // Set alpha to not offset - offsets[numComponents - 1] = 0f; - - return offset(image, scales, offsets); - } - /** * Safely sets Swing theme * @@ -266,25 +242,6 @@ public class SwingUtil }); } - /** - * Re-size a BufferedImage to the given dimensions. - * - * @param image the BufferedImage. - * @param newWidth The width to set the BufferedImage to. - * @param newHeight The height to set the BufferedImage to. - * @return The BufferedImage with the specified dimensions - */ - private static BufferedImage resizeImage(BufferedImage image, int newWidth, int newHeight) - { - final Image tmp = image.getScaledInstance(newWidth, newHeight, Image.SCALE_SMOOTH); - final BufferedImage dimg = new BufferedImage(newWidth, newHeight, BufferedImage.TYPE_INT_ARGB); - - final Graphics2D g2d = dimg.createGraphics(); - g2d.drawImage(tmp, 0, 0, null); - g2d.dispose(); - return dimg; - } - /** * Create swing button from navigation button. * @@ -300,7 +257,7 @@ public class SwingUtil { final BufferedImage scaledImage = iconSize > 0 - ? resizeImage(navigationButton.getIcon(), iconSize, iconSize) + ? ImageUtil.resizeImage(navigationButton.getIcon(), iconSize, iconSize) : navigationButton.getIcon(); final JButton button = new JButton(); @@ -350,17 +307,4 @@ public class SwingUtil { return SubstanceCoreUtilities.getTitlePaneComponent(frame) != null; } - - /** - * Performs a rescale operation on the image's color components. - * - * @param image The image to be adjusted. - * @param scales An array of scale operations to be performed on the image's color components. - * @param offsets An array of offset operations to be performed on the image's color components. - * @return The modified image after applying the given adjustments. - */ - private static BufferedImage offset(final BufferedImage image, final float[] scales, final float[] offsets) - { - return new RescaleOp(scales, offsets, null).filter(image, null); - } } diff --git a/runelite-client/src/test/java/net/runelite/client/util/ImageUtilTest.java b/runelite-client/src/test/java/net/runelite/client/util/ImageUtilTest.java new file mode 100644 index 0000000000..a1caa7f1ae --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/util/ImageUtilTest.java @@ -0,0 +1,236 @@ +/* + * Copyright (c) 2018, Jordan Atwood + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package net.runelite.client.util; + +import java.awt.Color; +import static java.awt.Color.BLACK; +import static java.awt.Color.BLUE; +import static java.awt.Color.GRAY; +import static java.awt.Color.GREEN; +import static java.awt.Color.RED; +import static java.awt.Color.WHITE; +import java.awt.image.BufferedImage; +import java.awt.image.DataBuffer; +import java.awt.image.DataBufferInt; +import java.util.Arrays; +import javax.annotation.Nonnull; +import static org.junit.Assert.assertEquals; +import org.junit.Test; + +public class ImageUtilTest +{ + private static final Color BLACK_HALF_TRANSPARENT = new Color(0, 0, 0, 128); + private static final Color BLACK_TRANSPARENT = new Color(0, true); + private static final int CORNER_SIZE = 2; + private static final int CENTERED_SIZE = 3; + + private static final BufferedImage BLACK_PIXEL_TOP_LEFT; + private static final BufferedImage BLACK_PIXEL_TOP_RIGHT; + private static final BufferedImage BLACK_PIXEL_BOTTOM_LEFT; + private static final BufferedImage BLACK_PIXEL_BOTTOM_RIGHT; + + static + { + BLACK_PIXEL_TOP_LEFT = new BufferedImage(CORNER_SIZE, CORNER_SIZE, BufferedImage.TYPE_INT_ARGB); + BLACK_PIXEL_TOP_LEFT.setRGB(0, 0, BLACK.getRGB()); + + BLACK_PIXEL_TOP_RIGHT = new BufferedImage(CORNER_SIZE, CORNER_SIZE, BufferedImage.TYPE_INT_ARGB); + BLACK_PIXEL_TOP_RIGHT.setRGB(1, 0, BLACK.getRGB()); + + BLACK_PIXEL_BOTTOM_LEFT = new BufferedImage(CORNER_SIZE, CORNER_SIZE, BufferedImage.TYPE_INT_ARGB); + BLACK_PIXEL_BOTTOM_LEFT.setRGB(0, 1, BLACK.getRGB()); + + BLACK_PIXEL_BOTTOM_RIGHT = new BufferedImage(CORNER_SIZE, CORNER_SIZE, BufferedImage.TYPE_INT_ARGB); + BLACK_PIXEL_BOTTOM_RIGHT.setRGB(1, 1, BLACK.getRGB()); + } + + @Test + public void grayscaleOffset() + { + assert(bufferedImagesEqual(oneByOne(BLACK), ImageUtil.grayscaleOffset(oneByOne(BLACK), -255))); + assert(bufferedImagesEqual(oneByOne(new Color(50, 50, 50)), ImageUtil.grayscaleOffset(oneByOne(BLACK), 50))); + assert(bufferedImagesEqual(oneByOne(GRAY), ImageUtil.grayscaleOffset(oneByOne(BLACK), 128))); + assert(bufferedImagesEqual(oneByOne(BLACK), ImageUtil.grayscaleOffset(oneByOne(GRAY), -255))); + assert(bufferedImagesEqual(oneByOne(WHITE), ImageUtil.grayscaleOffset(oneByOne(BLACK), 255))); + assert(bufferedImagesEqual(oneByOne(new Color(200, 200, 200)), ImageUtil.grayscaleOffset(oneByOne(WHITE), -55))); + assert(bufferedImagesEqual(oneByOne(WHITE), ImageUtil.grayscaleOffset(oneByOne(WHITE), 55))); + } + + @Test + public void resizeImage() + { + // TODO: test image contents after changing size + + final BufferedImage larger = ImageUtil.resizeImage(oneByOne(BLACK), 46, 46); + final BufferedImage smaller = ImageUtil.resizeImage(centeredPixel(WHITE), 1, 1); + final BufferedImage stretched = ImageUtil.resizeImage(solidColor(30, 30, RED), 12, 34); + + assertEquals(46, larger.getWidth()); + assertEquals(46, larger.getHeight()); + assertEquals(1, smaller.getWidth()); + assertEquals(1, smaller.getHeight()); + assertEquals(12, stretched.getWidth()); + assertEquals(34, stretched.getHeight()); + + final BufferedImage[] assertSameAfterResize = new BufferedImage[] { + oneByOne(WHITE), + oneByOne(GRAY), + oneByOne(BLACK), + oneByOne(RED), + oneByOne(GREEN), + oneByOne(BLUE), + oneByOne(BLACK_HALF_TRANSPARENT), + oneByOne(BLACK_TRANSPARENT), + centeredPixel(WHITE), + centeredPixel(GRAY), + centeredPixel(BLACK), + BLACK_PIXEL_TOP_LEFT, + BLACK_PIXEL_TOP_RIGHT, + BLACK_PIXEL_BOTTOM_LEFT, + BLACK_PIXEL_BOTTOM_RIGHT, + }; + for (BufferedImage image : assertSameAfterResize) + { + assert(bufferedImagesEqual(image, ImageUtil.resizeImage(image, image.getWidth(), image.getHeight()))); + } + } + + @Test + public void rotateImage() + { + // TODO: Test more than 90° rotations + + // Evenly-sized images (2x2) + assert(bufferedImagesEqual(BLACK_PIXEL_TOP_RIGHT, ImageUtil.rotateImage(BLACK_PIXEL_TOP_LEFT, Math.PI / 2))); + assert(bufferedImagesEqual(BLACK_PIXEL_BOTTOM_RIGHT, ImageUtil.rotateImage(BLACK_PIXEL_TOP_LEFT, Math.PI))); + assert(bufferedImagesEqual(BLACK_PIXEL_BOTTOM_LEFT, ImageUtil.rotateImage(BLACK_PIXEL_TOP_LEFT, Math.PI * 3 / 2))); + assert(bufferedImagesEqual(BLACK_PIXEL_TOP_LEFT, ImageUtil.rotateImage(BLACK_PIXEL_TOP_LEFT, Math.PI * 2))); + + // Unevenly-sized images (2x1); when rotated 90° become (2x2) images + final BufferedImage twoByOneLeft = new BufferedImage(2, 1, BufferedImage.TYPE_INT_ARGB); + twoByOneLeft.setRGB(0, 0, BLACK.getRGB()); + final BufferedImage twoByTwoRight = new BufferedImage(2, 1, BufferedImage.TYPE_INT_ARGB); + twoByTwoRight.setRGB(1, 0, BLACK.getRGB()); + final BufferedImage oneByTwoTop = new BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB); + oneByTwoTop.setRGB(1, 0, new Color(0, 0, 0, 127).getRGB()); + final BufferedImage oneByTwoBottom = new BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB); + oneByTwoBottom.setRGB(0, 0, new Color(0, 0, 0, 127).getRGB()); + oneByTwoBottom.setRGB(0, 1, BLACK.getRGB()); + + assert(bufferedImagesEqual(oneByTwoTop, ImageUtil.rotateImage(twoByOneLeft, Math.PI / 2))); + assert(bufferedImagesEqual(twoByTwoRight, ImageUtil.rotateImage(twoByOneLeft, Math.PI))); + assert(bufferedImagesEqual(oneByTwoBottom, ImageUtil.rotateImage(twoByOneLeft, Math.PI * 3 / 2))); + assert(bufferedImagesEqual(twoByOneLeft, ImageUtil.rotateImage(twoByOneLeft, Math.PI * 2))); + } + + /** + * Compares whether two {@link BufferedImage}s are equal in data. + * + * @param expected The first {@link BufferedImage} to be compared. + * @param actual The second {@link BufferedImage} to be compared. + * @return A boolean indicating whether the given {@link BufferedImage}s are of the same image data. + */ + private boolean bufferedImagesEqual(final @Nonnull BufferedImage expected, final @Nonnull BufferedImage actual) + { + if (expected.getWidth() != actual.getWidth()) + { + return false; + } + + if (!expected.getColorModel().equals(actual.getColorModel())) + { + return false; + } + + final DataBuffer aBuffer = expected.getRaster().getDataBuffer(); + final DataBuffer bBuffer = actual.getRaster().getDataBuffer(); + final DataBufferInt aBufferInt = (DataBufferInt) aBuffer; + final DataBufferInt bBufferInt = (DataBufferInt) bBuffer; + + if (aBufferInt.getNumBanks() != bBufferInt.getNumBanks()) + { + return false; + } + + for (int i = 0; i < aBufferInt.getNumBanks(); i++) + { + final int[] aDataBank = aBufferInt.getData(i); + final int[] bDataBank = bBufferInt.getData(i); + if (!Arrays.equals(aDataBank, bDataBank)) + { + return false; + } + } + + return true; + } + + /** + * Creates a {@link BufferedImage} of a 1-by-1px image of the given color. + * + * @param color The color to use for the image's single pixel. + * @return A {@link BufferedImage} containing a single pixel of the given color. + */ + private BufferedImage oneByOne(final @Nonnull Color color) + { + return solidColor(1, 1, color); + } + + /** + * Creates a {@link BufferedImage} of a single pixel of the given color centered in a 3-by-3px + * image. + * + * @param color The color to use for the centered pixel. + * @return A {@link BufferedImage} with completely transparent pixels and one pixel of the + * given color in the center. + */ + private BufferedImage centeredPixel(final @Nonnull Color color) + { + final BufferedImage out = new BufferedImage(CENTERED_SIZE, CENTERED_SIZE, BufferedImage.TYPE_INT_ARGB); + out.setRGB(1, 1, color.getRGB()); + return out; + } + + /** + * Creates a {@link BufferedImage} of a solid color of given width and height. + * + * @param width The desired width of the color image. + * @param height The desired height of the color image. + * @param color The desired color of the image. + * @return A {@link BufferedImage} of given dimensions filled with the given color. + */ + private BufferedImage solidColor(final int width, final int height, final @Nonnull Color color) + { + final BufferedImage out = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + for (int x = 0; x < width; x++) + { + for (int y = 0; y < height; y++) + { + out.setRGB(x, y, color.getRGB()); + } + } + return out; + } +}