diff --git a/diylc/diylc-core/src/org/diylc/presenter/update.xml b/diylc/diylc-core/src/org/diylc/presenter/update.xml index 0209c3c0..aee882c5 100644 --- a/diylc/diylc-core/src/org/diylc/presenter/update.xml +++ b/diylc/diylc-core/src/org/diylc/presenter/update.xml @@ -3731,7 +3731,7 @@ 22 0 - 2022-02-27 00:00:00.000 CET + 2022-03-02 00:00:00.000 CET @@ -3744,12 +3744,16 @@ IMPROVEMENT - Separate WIRING layer (containing Hookup Wire and Twisted Wire at the moment) from TRACE layer and render it above the COMPONENT layer by default + Separate WIRING layer (containing 'Hookup Wire' and 'Twisted Wire' at the moment) from TRACE layer and render it above the COMPONENT layer by default IMPROVEMENT Upgrade OSX application package built-in Java to JRE 1.8.0_321-b07 + + NEW_FEATURE + Added regression test suite that validates each release candidate version against a barrage of different DIY files + https://github.com/bancika/diy-layout-creator/releases diff --git a/diylc/diylc-library/lib/diylc-core.jar b/diylc/diylc-library/lib/diylc-core.jar index 574299bd..9332174d 100644 Binary files a/diylc/diylc-library/lib/diylc-core.jar and b/diylc/diylc-library/lib/diylc-core.jar differ diff --git a/diylc/diylc-swing/RegressionTestRunner.launch b/diylc/diylc-swing/RegressionTestRunner.launch new file mode 100644 index 00000000..49fd3296 --- /dev/null +++ b/diylc/diylc-swing/RegressionTestRunner.launch @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/diylc/diylc-swing/lib/diylc-core.jar b/diylc/diylc-swing/lib/diylc-core.jar index 574299bd..9332174d 100644 Binary files a/diylc/diylc-swing/lib/diylc-core.jar and b/diylc/diylc-swing/lib/diylc-core.jar differ diff --git a/diylc/diylc-swing/library/main.jar b/diylc/diylc-swing/library/main.jar index f0dc52b8..22f5f014 100644 Binary files a/diylc/diylc-swing/library/main.jar and b/diylc/diylc-swing/library/main.jar differ diff --git a/diylc/diylc-swing/test/com/github/romankh3/image/comparison/ImageComparison.java b/diylc/diylc-swing/test/com/github/romankh3/image/comparison/ImageComparison.java new file mode 100644 index 00000000..728c6ef6 --- /dev/null +++ b/diylc/diylc-swing/test/com/github/romankh3/image/comparison/ImageComparison.java @@ -0,0 +1,708 @@ +package com.github.romankh3.image.comparison; + +import com.github.romankh3.image.comparison.model.ExcludedAreas; +import com.github.romankh3.image.comparison.model.ImageComparisonResult; +import com.github.romankh3.image.comparison.model.ImageComparisonState; +import com.github.romankh3.image.comparison.model.Rectangle; + +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.File; +import java.util.List; +import java.util.*; +import java.util.stream.Collectors; + +import static com.github.romankh3.image.comparison.ImageComparisonUtil.getDifferencePercent; +import static java.util.Collections.emptyList; + +/** + * Main class for comparison images. + */ +public class ImageComparison { + + /** + * The threshold which means the max distance between non-equal pixels. + * Could be changed according to the size and requirements of the image. + */ + private int threshold = 5; + + /** + * Expected image for comparison + */ + private final BufferedImage expected; + + /** + * Actual image for comparison + */ + private final BufferedImage actual; + + /** + * Width of the line that is drawn the rectangle + */ + private int rectangleLineWidth = 1; + + /** + * {@link File} of the result destination. + */ + private /* @Nullable */ File destination; + + /** + * The number which marks how many rectangles. Beginning from 2. + */ + private int counter = 2; + + /** + * The number of the marking specific rectangle. + */ + private int regionCount = counter; + + /** + * The number of the minimal rectangle size. Count as (width x height). + */ + private Integer minimalRectangleSize = 1; + + /** + * Maximal count of the {@link Rectangle}s. + * It means that would get the first x biggest rectangles. + * Default value is -1, that means that all the rectangles would be drawn. + */ + private Integer maximalRectangleCount = -1; + + /** + * Level of the pixel tolerance. By default, it's 0.1 -> 10% difference. + * The value can be set from 0.0 to 0.99. + */ + private double pixelToleranceLevel = 0.1; + + /** + * Constant using for counting the level of the difference. + */ + private double differenceConstant; + + /** + * Matrix YxX => int[y][x]. + * E.g.: + * | X - width ---- + * | ..................................... + * Y . (0, 0) . + * | . . + * | . . + * h . . + * e . . + * i . . + * g . . + * h . . + * t . (X, Y). + * | ..................................... + */ + private int[][] matrix; + + /** + * ExcludedAreas contains a List of {@link Rectangle}s to be ignored when comparing images + */ + private ExcludedAreas excludedAreas = new ExcludedAreas(); + + /** + * Flag which says draw excluded rectangles or not. + */ + private boolean drawExcludedRectangles = false; + + /** + * The difference in percent between two images. + */ + private float differencePercent; + + /** + * Flag for filling comparison difference rectangles. + */ + private boolean fillDifferenceRectangles = false; + + /** + * Sets the opacity percentage of the fill of comparison difference rectangles. 0.0 means completely transparent and 100.0 means completely opaque. + */ + private double percentOpacityDifferenceRectangles = 20.0; + + /** + * Flag for filling excluded rectangles. + */ + private boolean fillExcludedRectangles = false; + + /** + * Sets the opacity percentage of the fill of excluded rectangles. 0.0 means completely transparent and 100.0 means completely opaque. + */ + private double percentOpacityExcludedRectangles = 20.0; + + /** + * The percent of the allowing pixels to be different to stay {@link ImageComparisonState#MATCH} for comparison. + * E.g. percent of the pixels, which would ignore in comparison. + */ + private double allowingPercentOfDifferentPixels = 0.0; + + /** + * Sets rectangle color of image difference. By default, it's red. + */ + private Color differenceRectangleColor = Color.RED; + + /** + * Sets rectangle color of excluded part. By default, it's green. + */ + private Color excludedRectangleColor = Color.GREEN; + + /** + * Create a new instance of {@link ImageComparison} that can compare the given images. + * + * @param expected expected image to be compared + * @param actual actual image to be compared + */ + public ImageComparison(String expected, String actual) { + this(ImageComparisonUtil.readImageFromResources(expected), + ImageComparisonUtil.readImageFromResources(actual), + null); + } + + /** + * Create a new instance of {@link ImageComparison} that can compare the given images. + * + * @param expected expected image to be compared + * @param actual actual image to be compared + * @param destination destination to save the result. If null, the result is shown in the UI. + */ + public ImageComparison(BufferedImage expected, BufferedImage actual, File destination) { + this.expected = expected; + this.actual = actual; + this.destination = destination; + differenceConstant = calculateDifferenceConstant(); + } + + /** + * Create a new instance of {@link ImageComparison} that can compare the given images. + * + * @param expected expected image to be compared + * @param actual actual image to be compared + */ + public ImageComparison(BufferedImage expected, BufferedImage actual) { + this(expected, actual, null); + } + + /** + * Draw rectangles which cover the regions of the difference pixels. + * + * @return the result of the drawing. + */ + public ImageComparisonResult compareImages() { + + // check that the images have the same size + if (isImageSizesNotEqual(expected, actual)) { + BufferedImage actualResized = ImageComparisonUtil.resize(actual, expected.getWidth(), expected.getHeight()); + return ImageComparisonResult.defaultSizeMisMatchResult(expected, actual, getDifferencePercent(actualResized, expected)); + } + + List rectangles = populateRectangles(); + + if (rectangles.isEmpty()) { + ImageComparisonResult matchResult = ImageComparisonResult.defaultMatchResult(expected, actual); + if (drawExcludedRectangles) { + matchResult.setResult(drawRectangles(rectangles)); + saveImageForDestination(matchResult.getResult()); + } + return matchResult; + } + + BufferedImage resultImage = drawRectangles(rectangles); + saveImageForDestination(resultImage); + return ImageComparisonResult.defaultMisMatchResult(expected, actual, getDifferencePercent(actual, expected)) + .setResult(resultImage) + .setRectangles(rectangles); + } + + /** + * Check images for equals their widths and heights. + * + * @param expected {@link BufferedImage} object of the expected image. + * @param actual {@link BufferedImage} object of the actual image. + * @return true if image size are not equal, false otherwise. + */ + private boolean isImageSizesNotEqual(BufferedImage expected, BufferedImage actual) { + return expected.getHeight() != actual.getHeight() || expected.getWidth() != actual.getWidth(); + } + + /** + * Populate binary matrix with "0" and "1". If the pixels are different set it as "1", otherwise "0". + * + * @return the count of different pixels + */ + private long populateTheMatrixOfTheDifferences() { + long countOfDifferentPixels = 0; + matrix = new int[expected.getHeight()][expected.getWidth()]; + for (int y = 0; y < expected.getHeight(); y++) { + for (int x = 0; x < expected.getWidth(); x++) { + if (!excludedAreas.contains(new Point(x, y))) { + if (isDifferentPixels(expected.getRGB(x, y), actual.getRGB(x, y))) { + matrix[y][x] = 1; + countOfDifferentPixels++; + } + } + } + } + return countOfDifferentPixels; + } + + /** + * Say if the two pixels equal or not. The rule is the difference between two pixels + * need to be more than {@link #pixelToleranceLevel}. + * + * @param expectedRgb the RGB value of the Pixel of the Expected image. + * @param actualRgb the RGB value of the Pixel of the Actual image. + * @return {@code true} if they' are difference, {@code false} otherwise. + */ + private boolean isDifferentPixels(int expectedRgb, int actualRgb) { + if (expectedRgb == actualRgb) { + return false; + } else if (pixelToleranceLevel == 0.0) { + return true; + } + + int red1 = (expectedRgb >> 16) & 0xff; + int green1 = (expectedRgb >> 8) & 0xff; + int blue1 = (expectedRgb) & 0xff; + int red2 = (actualRgb >> 16) & 0xff; + int green2 = (actualRgb >> 8) & 0xff; + int blue2 = (actualRgb) & 0xff; + + return (Math.pow(red2 - red1, 2) + Math.pow(green2 - green1, 2) + Math.pow(blue2 - blue1, 2)) + > differenceConstant; + } + + /** + * Populate rectangles of the differences + * + * @return the collection of the populated {@link Rectangle} objects. + */ + private List populateRectangles() { + long countOfDifferentPixels = populateTheMatrixOfTheDifferences(); + + if (countOfDifferentPixels == 0) { + return emptyList(); + } + + if (isAllowedPercentOfDifferentPixels(countOfDifferentPixels)) { + return emptyList(); + } + groupRegions(); + List rectangles = new ArrayList<>(); + while (counter <= regionCount) { + Rectangle rectangle = createRectangle(); + if (!rectangle.equals(Rectangle.createDefault()) && rectangle.size() >= minimalRectangleSize) { + rectangles.add(rectangle); + } + counter++; + } + + return mergeRectangles(mergeRectangles(rectangles)); + } + + /** + * Say if provided {@param countOfDifferentPixels} is allowed for {@link ImageComparisonState#MATCH} state. + * + * @param countOfDifferentPixels the count of the different pixels in comparison. + * @return true, if percent of different pixels lower or equal {@link ImageComparison#allowingPercentOfDifferentPixels}, + * false - otherwise. + */ + private boolean isAllowedPercentOfDifferentPixels(long countOfDifferentPixels) { + long totalPixelCount = matrix.length * matrix[0].length; + double actualPercentOfDifferentPixels = ((double) countOfDifferentPixels / (double) totalPixelCount) * 100; + return actualPercentOfDifferentPixels <= allowingPercentOfDifferentPixels; + } + + /** + * Create a {@link Rectangle} object. + * + * @return the {@link Rectangle} object. + */ + private Rectangle createRectangle() { + Rectangle rectangle = Rectangle.createDefault(); + for (int y = 0; y < matrix.length; y++) { + for (int x = 0; x < matrix[0].length; x++) { + if (matrix[y][x] == counter) { + updateRectangleCreation(rectangle, x, y); + } + } + } + return rectangle; + } + + /** + * Update {@link Point} of the rectangle based on x and y coordinates. + */ + private void updateRectangleCreation(Rectangle rectangle, int x, int y) { + if (x < rectangle.getMinPoint().getX()) { + rectangle.getMinPoint().x = x; + } + if (x > rectangle.getMaxPoint().getX()) { + rectangle.getMaxPoint().x = x; + } + + if (y < rectangle.getMinPoint().getY()) { + rectangle.getMinPoint().y = y; + } + if (y > rectangle.getMaxPoint().getY()) { + rectangle.getMaxPoint().y = y; + } + } + + /** + * Find overlapping rectangles and merge them. + */ + private List mergeRectangles(List rectangles) { + int position = 0; + while (position < rectangles.size()) { + if (rectangles.get(position).equals(Rectangle.createZero())) { + position++; + } + for (int i = 1 + position; i < rectangles.size(); i++) { + Rectangle r1 = rectangles.get(position); + Rectangle r2 = rectangles.get(i); + if (r2.equals(Rectangle.createZero())) { + continue; + } + if (r1.isOverlapping(r2)) { + rectangles.set(position, r1.merge(r2)); + r2.makeZeroRectangle(); + if (position != 0) { + position--; + } + } + } + position++; + } + + return rectangles.stream().filter(it -> !it.equals(Rectangle.createZero())).collect(Collectors.toList()); + } + + /** + * Draw the rectangles based on collection of the rectangles and result image. + * + * @param rectangles the collection of the {@link Rectangle} objects. + * @return result {@link BufferedImage} with drawn rectangles. + */ + private BufferedImage drawRectangles(List rectangles) { + BufferedImage resultImage = ImageComparisonUtil.deepCopy(actual); + Graphics2D graphics = preparedGraphics2D(resultImage); + + drawExcludedRectangles(graphics); + drawRectanglesOfDifferences(rectangles, graphics); + + return resultImage; + } + + /** + * Draw excluded rectangles. + * + * @param graphics prepared {@link Graphics2D}object. + */ + private void drawExcludedRectangles(Graphics2D graphics) { + if (drawExcludedRectangles) { + graphics.setColor(this.excludedRectangleColor); + draw(graphics, excludedAreas.getExcluded()); + + if (fillExcludedRectangles) { + fillRectangles(graphics, excludedAreas.getExcluded(), percentOpacityExcludedRectangles); + } + } + } + + /** + * Draw rectangles with the differences. + * + * @param rectangles the collection of the {@link Rectangle} of differences. + * @param graphics prepared {@link Graphics2D}object. + */ + private void drawRectanglesOfDifferences(List rectangles, Graphics2D graphics) { + List rectanglesForDraw; + graphics.setColor(this.differenceRectangleColor); + + if (maximalRectangleCount > 0 && maximalRectangleCount < rectangles.size()) { + rectanglesForDraw = rectangles.stream() + .sorted(Comparator.comparing(Rectangle::size)) + .skip(rectangles.size() - maximalRectangleCount) + .collect(Collectors.toList()); + } else { + rectanglesForDraw = new ArrayList<>(rectangles); + } + + draw(graphics, rectanglesForDraw); + + if (fillDifferenceRectangles) { + fillRectangles(graphics, rectanglesForDraw, percentOpacityDifferenceRectangles); + } + } + + /** + * Prepare {@link Graphics2D} based on resultImage and rectangleLineWidth + * + * @param image image based on created {@link Graphics2D}. + * @return prepared {@link Graphics2D} object. + */ + private Graphics2D preparedGraphics2D(BufferedImage image) { + Graphics2D graphics = image.createGraphics(); + graphics.setStroke(new BasicStroke(rectangleLineWidth)); + return graphics; + } + + /** + * Save image to destination object if exists. + * + * @param image {@link BufferedImage} to be saved. + */ + private void saveImageForDestination(BufferedImage image) { + if (Objects.nonNull(destination)) { + ImageComparisonUtil.saveImage(destination, image); + } + } + + /** + * Draw rectangles based on collection of the {@link Rectangle} and {@link Graphics2D}. + * getWidth/getHeight return real width/height, + * so need to draw rectangle on one px smaller because minpoint + width/height is point on excluded pixel. + * + * @param graphics the {@link Graphics2D} object for drawing. + * @param rectangles the collection of the {@link Rectangle}. + */ + private void draw(Graphics2D graphics, List rectangles) { + rectangles.forEach(rectangle -> graphics.drawRect( + rectangle.getMinPoint().x, + rectangle.getMinPoint().y, + rectangle.getWidth() - 1, + rectangle.getHeight() - 1) + ); + } + + /** + * Fill rectangles based on collection of the {@link Rectangle} and {@link Graphics2D}. + * getWidth/getHeight return real width/height, + * so need to draw rectangle fill two px smaller to fit inside rectangle borders. + * + * @param graphics the {@link Graphics2D} object for drawing. + * @param rectangles rectangles the collection of the {@link Rectangle}. + * @param percentOpacity the opacity of the fill. + */ + private void fillRectangles(Graphics2D graphics, List rectangles, double percentOpacity) { + + graphics.setColor(new Color(graphics.getColor().getRed(), + graphics.getColor().getGreen(), + graphics.getColor().getBlue(), + (int) (percentOpacity / 100 * 255) + )); + rectangles.forEach(rectangle -> graphics.fillRect( + rectangle.getMinPoint().x - 1, + rectangle.getMinPoint().y - 1, + rectangle.getWidth() - 2, + rectangle.getHeight() - 2) + ); + } + + + /** + * Group rectangle regions in matrix. + */ + private void groupRegions() { + for (int y = 0; y < matrix.length; y++) { + for (int x = 0; x < matrix[y].length; x++) { + if (matrix[y][x] == 1) { + joinToRegion(x, y); + regionCount++; + } + } + } + } + + /** + * The recursive method which go to all directions and finds difference + * in binary matrix using {@code threshold} for setting max distance between values which equal "1". + * and set the {@code groupCount} to matrix. + * + * @param x the value of the X-coordinate. + * @param y the value of the Y-coordinate. + */ + private void joinToRegion(int x, int y) { + if (isJumpRejected(x, y)) { + return; + } + + matrix[y][x] = regionCount; + + for (int i = 0; i < threshold; i++) { + joinToRegion(x + 1 + i, y); + joinToRegion(x, y + 1 + i); + + joinToRegion(x + 1 + i, y - 1 - i); + joinToRegion(x - 1 - i, y + 1 + i); + joinToRegion(x + 1 + i, y + 1 + i); + } + } + + /** + * Returns the list of rectangles that would be drawn as a diff image. + * If you submit two images that are the same barring the parts you want to excludedAreas you get a list of + * rectangles that can be used as said excludedAreas + * + * @return List of {@link Rectangle} + */ + public List createMask() { + return populateRectangles(); + } + + /** + * Check next step valid or not. + * + * @param x X-coordinate of the image. + * @param y Y-coordinate of the image + * @return true if jump rejected, otherwise false. + */ + private boolean isJumpRejected(int x, int y) { + return y < 0 || y >= matrix.length || x < 0 || x >= matrix[y].length || matrix[y][x] != 1; + } + + public double getPixelToleranceLevel() { + return pixelToleranceLevel; + } + + public ImageComparison setPixelToleranceLevel(double pixelToleranceLevel) { + if (0.0 <= pixelToleranceLevel && pixelToleranceLevel < 1) { + this.pixelToleranceLevel = pixelToleranceLevel; + differenceConstant = calculateDifferenceConstant(); + } + return this; + } + + private double calculateDifferenceConstant() { + return Math.pow(pixelToleranceLevel * Math.sqrt(Math.pow(255, 2) * 3), 2); + } + + public boolean isDrawExcludedRectangles() { + return drawExcludedRectangles; + } + + public ImageComparison setDrawExcludedRectangles(boolean drawExcludedRectangles) { + this.drawExcludedRectangles = drawExcludedRectangles; + return this; + } + + public int getThreshold() { + return threshold; + } + + public ImageComparison setThreshold(int threshold) { + this.threshold = threshold; + return this; + } + + public Optional getDestination() { + return Optional.ofNullable(destination); + } + + public ImageComparison setDestination(File destination) { + this.destination = destination; + return this; + } + + public BufferedImage getExpected() { + return expected; + } + + public BufferedImage getActual() { + return actual; + } + + public int getRectangleLineWidth() { + return rectangleLineWidth; + } + + public ImageComparison setRectangleLineWidth(int rectangleLineWidth) { + this.rectangleLineWidth = rectangleLineWidth; + return this; + } + + public Integer getMinimalRectangleSize() { + return minimalRectangleSize; + } + + public ImageComparison setMinimalRectangleSize(Integer minimalRectangleSize) { + this.minimalRectangleSize = minimalRectangleSize; + return this; + } + + public Integer getMaximalRectangleCount() { + return maximalRectangleCount; + } + + public ImageComparison setMaximalRectangleCount(Integer maximalRectangleCount) { + this.maximalRectangleCount = maximalRectangleCount; + return this; + } + + public ImageComparison setExcludedAreas(List excludedAreas) { + this.excludedAreas = new ExcludedAreas(excludedAreas); + return this; + } + + public boolean isFillDifferenceRectangles() { + return this.fillDifferenceRectangles; + } + + public double getPercentOpacityDifferenceRectangles() { + return this.percentOpacityDifferenceRectangles; + } + + public ImageComparison setDifferenceRectangleFilling(boolean fillRectangles, double percentOpacity) { + this.fillDifferenceRectangles = fillRectangles; + this.percentOpacityDifferenceRectangles = percentOpacity; + return this; + } + + public boolean isFillExcludedRectangles() { + return this.fillExcludedRectangles; + } + + public double getPercentOpacityExcludedRectangles() { + return this.percentOpacityExcludedRectangles; + } + + public ImageComparison setExcludedRectangleFilling(boolean fillRectangles, double percentOpacity) { + this.fillExcludedRectangles = fillRectangles; + this.percentOpacityExcludedRectangles = percentOpacity; + return this; + } + + public double getAllowingPercentOfDifferentPixels() { + return allowingPercentOfDifferentPixels; + } + + public ImageComparison setAllowingPercentOfDifferentPixels(double allowingPercentOfDifferentPixels) { + if (0.0 <= allowingPercentOfDifferentPixels && allowingPercentOfDifferentPixels <= 100) { + this.allowingPercentOfDifferentPixels = allowingPercentOfDifferentPixels; + } else { + //todo add warning here + } + + return this; + } + + public Color getDifferenceRectangleColor() { + return this.differenceRectangleColor; + } + + public ImageComparison setDifferenceRectangleColor(Color differenceRectangleColor) { + this.differenceRectangleColor = differenceRectangleColor; + return this; + } + + public Color getExcludedRectangleColor() { + return this.excludedRectangleColor; + } + + public ImageComparison setExcludedRectangleColor(Color excludedRectangleColor) { + this.excludedRectangleColor = excludedRectangleColor; + return this; + } +} diff --git a/diylc/diylc-swing/test/com/github/romankh3/image/comparison/ImageComparisonUtil.java b/diylc/diylc-swing/test/com/github/romankh3/image/comparison/ImageComparisonUtil.java new file mode 100644 index 00000000..4d1f5f15 --- /dev/null +++ b/diylc/diylc-swing/test/com/github/romankh3/image/comparison/ImageComparisonUtil.java @@ -0,0 +1,169 @@ +package com.github.romankh3.image.comparison; + +import com.github.romankh3.image.comparison.exception.ImageComparisonException; +import com.github.romankh3.image.comparison.exception.ImageNotFoundException; +import java.awt.Color; +import java.awt.Graphics; +import java.awt.Image; +import java.awt.image.BufferedImage; +import java.awt.image.ColorModel; +import java.awt.image.ConvolveOp; +import java.awt.image.Kernel; +import java.awt.image.WritableRaster; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import javax.imageio.ImageIO; +import javax.swing.ImageIcon; + +/** + * Tools for the {@link ImageComparison} object. + */ +public final class ImageComparisonUtil { + + /** + * Make a copy of the {@link BufferedImage} object. + * + * @param image the provided image. + * @return copy of the provided image. + */ + static BufferedImage deepCopy(BufferedImage image) { + ColorModel cm = image.getColorModel(); + boolean isAlphaPremultiplied = cm.isAlphaPremultiplied(); + WritableRaster raster = image.copyData(image.getRaster().createCompatibleWritableRaster()); + return new BufferedImage(cm, raster, isAlphaPremultiplied, null); + } + + /** + * Read image from the provided path. + * + * @param path the path where contains image. + * @return the {@link BufferedImage} object of this specific image. + * @throws ImageComparisonException due to read the image from resources. + */ + public static BufferedImage readImageFromResources(String path) throws ImageComparisonException { + File imageFile = new File(path); + if (imageFile.exists()) { + try { + return ImageIO.read(imageFile); + } catch (IOException e) { + throw new ImageComparisonException(String.format("Cannot read image from the file, path=%s", path), e); + } + } else { + InputStream inputStream = ImageComparisonUtil.class.getClassLoader().getResourceAsStream(path); + if (inputStream != null) { + try { + return ImageIO.read(inputStream); + } catch (IOException e) { + throw new ImageComparisonException(String.format("Cannot read image from the file, path=%s", path), e); + } + } else { + throw new ImageNotFoundException(String.format("Image with path = %s not found", path)); + } + } + } + + /** + * Save image to the provided path. + * + * @param pathFile the path to the saving image. + * @param image the {@link BufferedImage} object of this specific image. + * @throws ImageComparisonException due to save image. + */ + public static void saveImage(File pathFile, BufferedImage image) throws ImageComparisonException { + File dir = pathFile.getParentFile(); + // make dir if it's not using from Gradle. + boolean dirExists = dir == null || dir.isDirectory() || dir.mkdirs(); + if (!dirExists) { + throw new ImageComparisonException("Unable to create directory " + dir); + } + try { + ImageIO.write(image, "png", pathFile); + } catch (IOException e) { + throw new ImageComparisonException( + String.format("Cannot save image to path=%s", pathFile.getAbsolutePath()), e); + } + } + + /** + * Resize image to new dimensions and return new image. + * + * @param img the object of the image to be resized. + * @param newW the new width. + * @param newH the new height. + * @return resized {@link BufferedImage} object. + */ + public static BufferedImage resize(BufferedImage img, int newW, int newH) { + return toBufferedImage(img.getScaledInstance(newW, newH, Image.SCALE_SMOOTH)); + } + + /** + * Convert image to buffered image. + * + * @param img the object of the image to be converted to buffered image. + * @return the converted buffered image. + */ + public static BufferedImage toBufferedImage(Image img) { + if (img instanceof BufferedImage) { + return (BufferedImage) img; + } + + float softenFactor = 0.05f; + final Image temp = new ImageIcon(img).getImage(); + final BufferedImage bufferedImage = new BufferedImage( + temp.getWidth(null), + temp.getHeight(null), + BufferedImage.TYPE_INT_RGB); + final Graphics g = bufferedImage.createGraphics(); + g.setColor(Color.white); + g.fillRect(0, 0, temp.getWidth(null), temp.getHeight(null)); + g.drawImage(temp, 0, 0, null); + g.dispose(); + + final float[] softenArray = {0, softenFactor, 0, softenFactor, 1 - (softenFactor * 4), softenFactor, 0, + softenFactor, 0}; + final Kernel kernel = new Kernel(3, 3, softenArray); + final ConvolveOp cOp = new ConvolveOp(kernel, ConvolveOp.EDGE_NO_OP, null); + + return cOp.filter(bufferedImage, null); + } + + /** + * Return the difference in percent between two buffered images. + * + * @param img1 the first image. + * @param img2 the second image. + * @return difference percent. + */ + public static float getDifferencePercent(BufferedImage img1, BufferedImage img2) { + int width = img1.getWidth(); + int height = img1.getHeight(); + + long diff = 0; + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + diff += pixelDiff(img1.getRGB(x, y), img2.getRGB(x, y)); + } + } + long maxDiff = 3L * 255 * width * height; + + return (float) (100.0 * diff / maxDiff); + } + + /** + * Compare two pixels + * + * @param rgb1 the first rgb + * @param rgb2 the second rgn + * @return the difference. + */ + public static int pixelDiff(int rgb1, int rgb2) { + int r1 = (rgb1 >> 16) & 0xff; + int g1 = (rgb1 >> 8) & 0xff; + int b1 = rgb1 & 0xff; + int r2 = (rgb2 >> 16) & 0xff; + int g2 = (rgb2 >> 8) & 0xff; + int b2 = rgb2 & 0xff; + return Math.abs(r1 - r2) + Math.abs(g1 - g2) + Math.abs(b1 - b2); + } +} diff --git a/diylc/diylc-swing/test/com/github/romankh3/image/comparison/exception/ImageComparisonException.java b/diylc/diylc-swing/test/com/github/romankh3/image/comparison/exception/ImageComparisonException.java new file mode 100644 index 00000000..b53006a7 --- /dev/null +++ b/diylc/diylc-swing/test/com/github/romankh3/image/comparison/exception/ImageComparisonException.java @@ -0,0 +1,32 @@ +package com.github.romankh3.image.comparison.exception; + +/** + * A {@link RuntimeException} that is thrown in case of an image comparison failures. + */ +public final class ImageComparisonException extends RuntimeException { + + /** + * Constructs a new {@link ImageComparisonException} with the specified detail message. + * The cause is not initialized, and may subsequently be initialized by a call to + * Throwable.initCause(java.lang.Throwable). + * + * @param message the detail message. The detail message is saved for later retrieval by the Throwable.getMessage() + * method. + */ + public ImageComparisonException(String message) { + super(message); + } + + /** + * Constructs a new {@link ImageComparisonException} with the specified detail message and cause. + * Note that the detail message associated with cause is not automatically incorporated in this {@link + * ImageComparisonException}'s detail message. + * + * @param message the detail message (which is saved for later retrieval by the Throwable.getMessage() method). + * @param throwable the cause (which is saved for later retrieval by the Throwable.getCause() method). + * (A null value is permitted, and indicates that the cause is nonexistent or unknown.) + */ + public ImageComparisonException(String message, Throwable throwable) { + super(message, throwable); + } +} diff --git a/diylc/diylc-swing/test/com/github/romankh3/image/comparison/exception/ImageNotFoundException.java b/diylc/diylc-swing/test/com/github/romankh3/image/comparison/exception/ImageNotFoundException.java new file mode 100644 index 00000000..9c418a74 --- /dev/null +++ b/diylc/diylc-swing/test/com/github/romankh3/image/comparison/exception/ImageNotFoundException.java @@ -0,0 +1,19 @@ +package com.github.romankh3.image.comparison.exception; + +/** + * {@link RuntimeException} that is thrown in case of an image getting failures. + */ +public class ImageNotFoundException extends RuntimeException { + + /** + * Constructs a new {@link ImageNotFoundException} with the specified detail message. + * The cause is not initialized, and may subsequently be initialized by a call to + * Throwable.initCause(java.lang.Throwable). + * + * @param message the detail message. The detail message is saved for later retrieval by the Throwable.getMessage() + * method. + */ + public ImageNotFoundException(String message) { + super(message); + } +} diff --git a/diylc/diylc-swing/test/com/github/romankh3/image/comparison/model/ExcludedAreas.java b/diylc/diylc-swing/test/com/github/romankh3/image/comparison/model/ExcludedAreas.java new file mode 100644 index 00000000..b71a95a6 --- /dev/null +++ b/diylc/diylc-swing/test/com/github/romankh3/image/comparison/model/ExcludedAreas.java @@ -0,0 +1,48 @@ +package com.github.romankh3.image.comparison.model; + +import java.awt.Point; +import java.util.ArrayList; +import java.util.List; + +/** + * The area that will be excluded, masked, in the image. + */ +public class ExcludedAreas { + + /** + * The collection of the areas which would be excluded from the comparison. + */ + private final List excluded; + + /** + * Create empty instance of the {@link ExcludedAreas}. + */ + public ExcludedAreas() { + excluded = new ArrayList<>(); + } + + /** + * Create instance of the {@link ExcludedAreas} with provided {@link Rectangle} areas. + * + * @param excluded provided collection of the {@link Rectangle} objects. + */ + public ExcludedAreas(List excluded) { + this.excluded = excluded; + } + + /** + * Check if this {@link Point} contains in the {@link ExcludedAreas#excluded} + * collection of the {@link Rectangle}. + * + * @param point the {@link Point} object to be checked. + * + * @return {@code true} if this {@link Point} contains in areas from {@link ExcludedAreas#excluded}. + */ + public boolean contains(Point point) { + return excluded.stream().anyMatch(rectangle -> rectangle.containsPoint(point)); + } + + public List getExcluded() { + return excluded; + } +} diff --git a/diylc/diylc-swing/test/com/github/romankh3/image/comparison/model/ImageComparisonResult.java b/diylc/diylc-swing/test/com/github/romankh3/image/comparison/model/ImageComparisonResult.java new file mode 100644 index 00000000..00136408 --- /dev/null +++ b/diylc/diylc-swing/test/com/github/romankh3/image/comparison/model/ImageComparisonResult.java @@ -0,0 +1,158 @@ +package com.github.romankh3.image.comparison.model; + +import com.github.romankh3.image.comparison.ImageComparisonUtil; + +import java.awt.image.BufferedImage; +import java.io.File; +import java.util.List; + +/** + * Data transfer objects which contains all the needed data for result of the comparison. + */ +public class ImageComparisonResult { + + /** + * {@link BufferedImage} object of the expected. + */ + private BufferedImage expected; + + /** + * {@link BufferedImage} object of the actual. + */ + private BufferedImage actual; + + /** + * {@link BufferedImage} object of the comparison result. + */ + private BufferedImage result; + + /** + * State of the comparison. + */ + private ImageComparisonState imageComparisonState; + + /** + * The difference percentage between two images. + */ + private float differencePercent; + + /** + * Rectangles of the differences + */ + private List rectangles; + + /** + * Create default instance of the {@link ImageComparisonResult} with {@link ImageComparisonState#SIZE_MISMATCH}. + * + * @param expected expected {@link BufferedImage} object. + * @param actual actual {@link BufferedImage} object. + * @param differencePercent the percent of the differences between images. + * @return instance of the {@link ImageComparisonResult} object. + */ + public static ImageComparisonResult defaultSizeMisMatchResult(BufferedImage expected, BufferedImage actual, + float differencePercent) { + return new ImageComparisonResult() + .setImageComparisonState(ImageComparisonState.SIZE_MISMATCH) + .setDifferencePercent(differencePercent) + .setExpected(expected) + .setActual(actual) + .setResult(actual); + } + + /** + * Create default instance of the {@link ImageComparisonResult} with {@link ImageComparisonState#MISMATCH}. + * + * @param expected expected {@link BufferedImage} object. + * @param actual actual {@link BufferedImage} object. + * @param differencePercent the persent of the differences between images. + * @return instance of the {@link ImageComparisonResult} object. + */ + public static ImageComparisonResult defaultMisMatchResult(BufferedImage expected, BufferedImage actual, float differencePercent) { + return new ImageComparisonResult() + .setImageComparisonState(ImageComparisonState.MISMATCH) + .setDifferencePercent(differencePercent) + .setExpected(expected) + .setActual(actual) + .setResult(actual); + } + + /** + * Create default instance of the {@link ImageComparisonResult} with {@link ImageComparisonState#MATCH}. + * + * @param expected expected {@link BufferedImage} object. + * @param actual actual {@link BufferedImage} object. + * @return instance of the {@link ImageComparisonResult} object. + */ + public static ImageComparisonResult defaultMatchResult(BufferedImage expected, BufferedImage actual) { + return new ImageComparisonResult() + .setImageComparisonState(ImageComparisonState.MATCH) + .setExpected(expected) + .setActual(actual) + .setResult(actual); + } + + /** + * Save the image to the provided {@link File} object. + * + * @param file the provided {@link File} object. + * @return this {@link ImageComparisonResult} object. + */ + public ImageComparisonResult writeResultTo(File file) { + ImageComparisonUtil.saveImage(file, result); + return this; + } + + public BufferedImage getExpected() { + return expected; + } + + public ImageComparisonResult setExpected(BufferedImage expected) { + this.expected = expected; + return this; + } + + public BufferedImage getActual() { + return actual; + } + + public ImageComparisonResult setActual(BufferedImage actual) { + this.actual = actual; + return this; + } + + public BufferedImage getResult() { + return result; + } + + public ImageComparisonResult setResult(BufferedImage result) { + this.result = result; + return this; + } + + public ImageComparisonState getImageComparisonState() { + return imageComparisonState; + } + + public ImageComparisonResult setImageComparisonState(ImageComparisonState imageComparisonState) { + this.imageComparisonState = imageComparisonState; + return this; + } + + public float getDifferencePercent() { + return differencePercent; + } + + ImageComparisonResult setDifferencePercent(float differencePercent) { + this.differencePercent = differencePercent; + return this; + } + + public List getRectangles() { + return rectangles; + } + + public ImageComparisonResult setRectangles(List rectangles) { + this.rectangles = rectangles; + return this; + } +} diff --git a/diylc/diylc-swing/test/com/github/romankh3/image/comparison/model/ImageComparisonState.java b/diylc/diylc-swing/test/com/github/romankh3/image/comparison/model/ImageComparisonState.java new file mode 100644 index 00000000..968c5bb7 --- /dev/null +++ b/diylc/diylc-swing/test/com/github/romankh3/image/comparison/model/ImageComparisonState.java @@ -0,0 +1,23 @@ +package com.github.romankh3.image.comparison.model; + +/** + * Enum for telling the result of the Comparison. + * For example {@link ImageComparisonState#MATCH} - means that the images are equal. + */ +public enum ImageComparisonState { + + /** + * Result state of the comparison, where mismatch of the image sizes. + */ + SIZE_MISMATCH, + + /** + * Result state of the comparison, where mismatch of the images. + */ + MISMATCH, + + /** + * Result state of the images, where images are equal, e.g. match. + */ + MATCH +} diff --git a/diylc/diylc-swing/test/com/github/romankh3/image/comparison/model/Rectangle.java b/diylc/diylc-swing/test/com/github/romankh3/image/comparison/model/Rectangle.java new file mode 100644 index 00000000..d6673627 --- /dev/null +++ b/diylc/diylc-swing/test/com/github/romankh3/image/comparison/model/Rectangle.java @@ -0,0 +1,195 @@ +package com.github.romankh3.image.comparison.model; + +import static java.lang.Integer.max; +import static java.lang.Integer.min; + +import java.awt.Point; +import java.util.Objects; + +/** + * Object contained data for a rectangle. + */ +public class Rectangle { + + /** + * Left Top {@link Point} on the {@link Rectangle}. + */ + private Point minPoint; + + /** + * Right bottom {@link Point} on the {@link Rectangle}. + */ + private Point maxPoint; + + /** + * Create empty instance of the {@link Rectangle}. + */ + private Rectangle() { + minPoint = new Point(); + maxPoint = new Point(); + } + + /** + * Create clone object based on the provided {@link Rectangle}. + * + * @param rectangle provided {@link Rectangle} object. + */ + public Rectangle(Rectangle rectangle) { + this.minPoint = new Point(rectangle.getMinPoint().x, rectangle.getMinPoint().y); + this.maxPoint = new Point(rectangle.getMaxPoint().x, rectangle.getMaxPoint().y); + } + + /** + * Create instance of the {@link Rectangle} based on the provided coordinates. + * + * @param minX minimal X-coordinate. + * @param minY minimal Y-coordinate. + * @param maxX maximal X-coordinate. + * @param maxY maximal Y-coordinate. + */ + public Rectangle(int minX, int minY, int maxX, int maxY) { + this.minPoint = new Point(minX, minY); + this.maxPoint = new Point(maxX, maxY); + } + + /** + * Create default {@link Rectangle} object. + * + * @return default rectangle {@link Rectangle}. + */ + public static Rectangle createDefault() { + Rectangle defaultRectangle = new Rectangle(); + + defaultRectangle.setDefaultValues(); + + return defaultRectangle; + } + + /** + * Create instance with zero points. + * + * @return created {@link Rectangle} instance. + */ + public static Rectangle createZero() { + Rectangle rectangle = new Rectangle(); + rectangle.makeZeroRectangle(); + return rectangle; + } + + /** + * Create new {@link Rectangle} via merging this and that. + * + * @param that {@link Rectangle} for merging with this. + * @return new merged {@link Rectangle}. + */ + public Rectangle merge(Rectangle that) { + return new Rectangle(min(this.getMinPoint().x, that.getMinPoint().x), + min(this.getMinPoint().y, that.getMinPoint().y), + max(this.getMaxPoint().x, that.getMaxPoint().x), + max(this.getMaxPoint().y, that.getMaxPoint().y)); + } + + /** + * Check is that rectangle overlapp this. + * + * @param that {@link Rectangle} which checks with this. + * @return true if this over lapp that, false otherwise. + */ + public boolean isOverlapping(Rectangle that) { + if (this.getMaxPoint().getY() < that.getMinPoint().getY() || + that.getMaxPoint().getY() < this.getMinPoint().getY()) { + return false; + } + return this.getMaxPoint().getX() >= that.getMinPoint().getX() && + that.getMaxPoint().getX() >= this.getMinPoint().getX(); + } + + /** + * Set default values for rectangle. + */ + public void setDefaultValues() { + this.maxPoint = new Point(Integer.MIN_VALUE, Integer.MIN_VALUE); + this.minPoint = new Point(Integer.MAX_VALUE, Integer.MAX_VALUE); + } + + /** + * Make zero rectagle. + */ + public void makeZeroRectangle() { + this.minPoint = new Point(); + this.maxPoint = new Point(); + } + + /** + * Size of the {@link Rectangle}, counted as width x height. + * + * @return the size of the {@link Rectangle}. + */ + public Integer size() { + return getWidth() * getHeight(); + } + + /** + * Count the width of the {@link Rectangle}. + * Min and max point are included, so real width is +1px + * + * @return rectangle width. + */ + public int getWidth() { + return maxPoint.x - minPoint.x + 1; + } + + /** + * Count the height of the {@link Rectangle}. + * Min and max point are included, so real width is +1px. + * + * @return rectangle height. + */ + public int getHeight() { + return maxPoint.y - minPoint.y + 1; + } + + /** + * Check in the provided {@link Point} contains in the {@link Rectangle}. + * + * @param point provided {@link Point}. + * @return {@code true} if provided {@link Point} contains, {@code false} - otherwise. + */ + boolean containsPoint(Point point) { + return point.x >= minPoint.x && point.x<= maxPoint.x && point.y >= minPoint.y && point.y <= maxPoint.y; + } + + public Point getMinPoint() { + return minPoint; + } + + public void setMinPoint(Point minPoint) { + this.minPoint = minPoint; + } + + public Point getMaxPoint() { + return maxPoint; + } + + public void setMaxPoint(Point maxPoint) { + this.maxPoint = maxPoint; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Rectangle rectangle = (Rectangle) o; + return minPoint.equals(rectangle.minPoint) && + maxPoint.equals(rectangle.maxPoint); + } + + @Override + public int hashCode() { + return Objects.hash(minPoint, maxPoint); + } +} diff --git a/diylc/diylc-swing/test/org/diylc/RegressionTestRunner.java b/diylc/diylc-swing/test/org/diylc/RegressionTestRunner.java new file mode 100644 index 00000000..796974df --- /dev/null +++ b/diylc/diylc-swing/test/org/diylc/RegressionTestRunner.java @@ -0,0 +1,100 @@ +package org.diylc; + +import java.awt.image.BufferedImage; +import java.io.File; +import org.diylc.appframework.miscutils.ConfigurationManager; +import org.diylc.appframework.miscutils.InMemoryConfigurationManager; +import org.diylc.core.IView; +import org.diylc.presenter.Presenter; +import org.diylc.swing.plugins.file.ProjectDrawingProvider; +import org.diylc.swingframework.export.DrawingExporter; +import com.github.romankh3.image.comparison.ImageComparison; +import com.github.romankh3.image.comparison.ImageComparisonUtil; +import com.github.romankh3.image.comparison.model.ImageComparisonResult; +import com.github.romankh3.image.comparison.model.ImageComparisonState; + +public class RegressionTestRunner { + + public static void main(String[] args) { + if (args.length < 2) { + System.out.println("No path and command provided"); + return; + } + + ConfigurationManager.getInstance().initialize("diylc-test"); + + String basePath = args[0]; + String command = args[1]; + System.out.println("Running command " + command + " in: " + basePath); + File inputsDir = new File(basePath + File.separator + "input" + File.separator + "diy"); + if (!inputsDir.exists()) { + System.out.println("Could not find the input directory: " + inputsDir.getAbsolutePath()); + return; + } + File[] files = inputsDir.listFiles(); + int okCount = 0; + int failCount = 0; + for (File file : files) { + boolean fileOk = processFile(file, basePath, command); + if (fileOk) { + okCount++; + } else { + failCount++; + } + } + System.out.println("Finished! OK files: " + okCount + "; Failed files: " + failCount); + } + + private static boolean processFile(File file, String basePath, String command) { + System.out.print("Processing file: " + file.getName() + "..."); + try { + IView view = new MockView(); + Presenter presenter = new Presenter(view, InMemoryConfigurationManager.getInstance()); + ProjectDrawingProvider drawingProvider = new ProjectDrawingProvider(presenter, false, true, false); + presenter.loadProjectFromFile(file.getAbsolutePath()); + if ("PREPARE".equalsIgnoreCase(command)) { + // generate PNG input image for comparison + File pngFile = new File(basePath + File.separator + "input" + File.separator + "png" + File.separator + (file.getName().replace(".diy", ".png"))); + if (!pngFile.exists()) { + DrawingExporter.getInstance().exportPNG(drawingProvider, pngFile); + } + } else if ("TEST".equalsIgnoreCase(command)) { + // test save + File outputFile = new File(basePath + File.separator + "output" + File.separator + "diy" + File.separator + file.getName()); + if (outputFile.exists()) { + outputFile.delete(); + } + presenter.saveProjectToFile(outputFile.getAbsolutePath(), false); + + // test export png + File pngOutputFile = new File(basePath + File.separator + "output" + File.separator + "png" + File.separator + (file.getName().replace(".diy", ".png"))); + if (pngOutputFile.exists()) { + pngOutputFile.delete(); + } + DrawingExporter.getInstance().exportPNG(drawingProvider, pngOutputFile); + File pngInputFile = new File(basePath + File.separator + "input" + File.separator + "png" + File.separator + (file.getName().replace(".diy", ".png"))); + if (!pngInputFile.exists()) { + System.out.println("Input image does not exist!"); + return false; + } + + BufferedImage expectedImage = ImageComparisonUtil.readImageFromResources(pngInputFile.getAbsolutePath()); + BufferedImage actualImage = ImageComparisonUtil.readImageFromResources(pngOutputFile.getAbsolutePath()); + + //Create ImageComparison object and compare the images. + ImageComparisonResult imageComparisonResult = new ImageComparison(expectedImage, actualImage).compareImages(); + + //Check the result + if (ImageComparisonState.MATCH != imageComparisonResult.getImageComparisonState()) { + System.out.println("Images do not match!"); + return false; + } + } + System.out.println("OK"); + return true; + } catch (Exception e) { + System.out.println("ERROR: " + e.getMessage()); + return false; + } + } +} diff --git a/diylc/diylc-swing/test/org/diylc/cloud-inputs.zip b/diylc/diylc-swing/test/org/diylc/cloud-inputs.zip new file mode 100644 index 00000000..a33ce434 Binary files /dev/null and b/diylc/diylc-swing/test/org/diylc/cloud-inputs.zip differ