All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.github.romankh3.image.comparison.ImageComparison Maven / Gradle / Ivy

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;
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy