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

com.intuit.karate.ImageComparison Maven / Gradle / Ivy

There is a newer version: 1.4.1
Show newest version
/*
 * The MIT License
 *
 * Copyright 2022 Karate Labs Inc.
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 */
package com.intuit.karate;

import io.github.t12y.resemble.Resemble;
import io.github.t12y.ssim.models.MSSIMMatrix;
import io.github.t12y.ssim.models.Matrix;
import io.github.t12y.ssim.models.Options;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.stream.ImageInputStream;
import java.awt.image.BufferedImage;
import java.awt.Image;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.util.*;

import static io.github.t12y.ssim.SSIM.ssim;

public class ImageComparison {

    public static final String RESEMBLE = "resemble";
    public static final String SSIM = "ssim";

    private static final String[] IGNORED_BOX_KEYS = new String[]{"left", "right", "top", "bottom"};
    private static final String[] IGNORED_COLOR_KEYS = new String[]{"r", "g", "b"};

    static final Logger logger = LoggerFactory.getLogger(ImageComparison.class);

    private double[] baselinePixels;
    private double[] latestPixels;
    private int height;
    private int width;
    private double stopWhenMismatchIsLessThan;
    private double failureThreshold;
    private boolean baselineMissing;
    private boolean scaleMismatch;
    private String[] engines;
    private Map options;
    private Map result;

    private ImageComparison(byte[] baselineImg, byte[] latestImg, Map options, boolean allowScaling) {
        this.baselineMissing = baselineImg == null || baselineImg.length == 0;

        BufferedImage baselineImage;
        BufferedImage latestImage;

        try {
            latestImage = ImageIO.read(new ByteArrayInputStream(latestImg));
            baselineImage = baselineMissing ? latestImage : ImageIO.read(new ByteArrayInputStream(baselineImg));
        } catch (IOException e) {
            logger.error("image comparison failed while reading images: {}", e.getMessage());
            return;
        }

        this.height = baselineImage.getHeight();
        this.width = baselineImage.getWidth();
        this.options = options;

        int latestHeight = latestImage.getHeight();
        int latestWidth = latestImage.getWidth();

        if (width != latestWidth || height != latestHeight) {
            if (allowScaling) {
                Image scaledImage = latestImage.getScaledInstance(width, height, Image.SCALE_SMOOTH);
                latestImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
                latestImage.getGraphics().drawImage(scaledImage, 0, 0, null);
                latestHeight = height;
                latestWidth = width;
            } else {
                scaleMismatch = true;
            }
        }

        this.baselinePixels = unpackPixels(baselineImage.getRGB(0, 0, width, height, null, 0, width));
        this.latestPixels = unpackPixels(latestImage.getRGB(0, 0, latestWidth, latestHeight, null, 0, latestWidth));

        String latestDataUrl = getDataUrl(latestImg);
        String baselineDataUrl = baselineMissing ? latestDataUrl : getDataUrl(baselineImg);

        this.result = new HashMap<>();
        result.put("baseline", baselineDataUrl);
        result.put("latest", latestDataUrl);
    }

    private static String getDataUrl(byte[] img) {
        String format = "png";

        try {
            ImageInputStream input = ImageIO.createImageInputStream(new ByteArrayInputStream(img));
            Iterator readers = ImageIO.getImageReaders(input);

            if (readers.hasNext()) {
                ImageReader reader = readers.next();
                reader.setInput(input);
                format = reader.getFormatName();
            }
        } catch (Exception e) {
            logger.error("image comparison failed to detect image type: {}", e.getMessage());
        }

        return "data:image/" + format + ";base64," + Base64.getEncoder().encodeToString(img);
    }

    private void configure(Map defaultOptions) {
        String defaultEngine = asString(defaultOptions.get("engine"));
        if (defaultEngine == null) {
            defaultEngine = RESEMBLE;
        }
        result.put("defaultEngine", defaultEngine);

        double defaultFailureThreshold = toDouble(defaultOptions.get("failureThreshold"), 0.0);
        result.put("defaultFailureThreshold", defaultFailureThreshold);

        failureThreshold = getDouble("failureThreshold", defaultFailureThreshold);
        result.put("failureThreshold", failureThreshold);

        String engineConfig = getString("engine", defaultEngine).toLowerCase().replaceAll("[^a-z,|]", "");
        result.put("engine", engineConfig);

        if (engineConfig.contains("|")) {
            stopWhenMismatchIsLessThan = failureThreshold;
            engines = engineConfig.split("\\|");
        } else {
            stopWhenMismatchIsLessThan = -1.0; // don't stop
            engines = engineConfig.split(",");
        }
    }

    public static Map compare(byte[] baselineImg, byte[] latestImg, Map options,
            Map defaultOptions) throws MismatchException {

        boolean allowScaling = toBool(defaultOptions.get("allowScaling"));
        ImageComparison imageComparison = new ImageComparison(baselineImg, latestImg, options, allowScaling);
        imageComparison.configure(defaultOptions);

        if (imageComparison.baselineMissing) {
            imageComparison.result.put("isBaselineMissing", true);
            throw new MismatchException("baseline image was empty or not found", imageComparison.result);
        }

        if (imageComparison.scaleMismatch) {
            imageComparison.result.put("isScaleMismatch", true);
            throw new MismatchException("latest image dimensions != baseline image dimensions", imageComparison.result);
        }

        double mismatchPercentage = 100.0;

        for (String engine : imageComparison.engines) {
            double currentMismatchPercentage;
            switch (engine) {
                case RESEMBLE:
                    currentMismatchPercentage = imageComparison.execResemble();
                    break;
                case SSIM:
                    currentMismatchPercentage = imageComparison.execSSIM();
                    break;
                default:
                    logger.error("skipping unsupported image comparison engine: {}", engine);
                    continue;
            }

            if (currentMismatchPercentage <= mismatchPercentage) {
                mismatchPercentage = currentMismatchPercentage;
            }

            if (mismatchPercentage < imageComparison.stopWhenMismatchIsLessThan) {
                break;
            }
        }

        return imageComparison.checkMismatch(mismatchPercentage);
    }

    private Map checkMismatch(double mismatchPercentage) {
        result.put("mismatchPercentage", mismatchPercentage);

        if (mismatchPercentage <= 0.0 || mismatchPercentage < failureThreshold) {
            return result;
        }

        String msg = "latest image differed from baseline more than allowable threshold: "
                + mismatchPercentage + "% >= " + failureThreshold + "%";

        result.put("isMismatch", true);

        throw new MismatchException(msg, result);
    }

    private double execResemble() {
        double mismatchPercentage = Resemble.analyzeImages(baselinePixels, latestPixels, resembleOptions());
        result.put("resembleMismatchPercentage", mismatchPercentage);
        return mismatchPercentage;
    }

    private double execSSIM() {
        MSSIMMatrix ssimResult = ssim(
                new Matrix(height, width, baselinePixels),
                new Matrix(height, width, latestPixels),
                ssimOptions());

        double mismatchPercentage = (1.0 - ssimResult.mssim) * 100.0;
        result.put("ssimMismatchPercentage", mismatchPercentage);

        return mismatchPercentage;
    }

    private io.github.t12y.resemble.Options resembleOptions() {
        io.github.t12y.resemble.Options opts;

        String ignoreOption = getString("ignore", "less");
        switch (ignoreOption.toLowerCase().replaceAll("[^a-z]", "")) {
            case "nothing":
                opts = io.github.t12y.resemble.Options.ignoreNothing();
                break;
            case "less":
                opts = io.github.t12y.resemble.Options.ignoreLess();
                break;
            case "antialiasing":
                opts = io.github.t12y.resemble.Options.ignoreAntialiasing();
                break;
            case "colors":
                opts = io.github.t12y.resemble.Options.ignoreColors();
                break;
            case "alpha":
                opts = io.github.t12y.resemble.Options.ignoreAlpha();
                break;
            default:
                logger.error("invalid 'ignore' option for resemble engine: {}", ignoreOption);
                opts = io.github.t12y.resemble.Options.ignoreNothing();
        }

        opts.width = width;
        opts.height = height;

        opts.ignoredBoxes = getIgnoredBoxes();
        opts.ignoreAreasColoredWith = getIntArray(options.get("ignoreAreasColoredWith"), IGNORED_COLOR_KEYS);

        opts.ignoreColors = getBool("ignoreColors", opts.ignoreColors);
        opts.ignoreAntialiasing = getBool("ignoreAntialiasing", opts.ignoreAntialiasing);

        Object tolerancesObj = options.get("tolerances");
        if (tolerancesObj instanceof Map) {
            Map tolerances = (Map) tolerancesObj;
            opts.redTolerance = toDouble(tolerances.get("red"), opts.redTolerance);
            opts.greenTolerance = toDouble(tolerances.get("green"), opts.greenTolerance);
            opts.blueTolerance = toDouble(tolerances.get("blue"), opts.blueTolerance);
            opts.alphaTolerance = toDouble(tolerances.get("alpha"), opts.alphaTolerance);
            opts.minBrightness = toDouble(tolerances.get("minBrightness"), opts.minBrightness);
            opts.maxBrightness = toDouble(tolerances.get("maxBrightness"), opts.maxBrightness);
        }

        return opts;
    }

    private io.github.t12y.ssim.models.Options ssimOptions() {
        io.github.t12y.ssim.models.Options opts = io.github.t12y.ssim.models.Options.Defaults();

        opts.ssim = Options.SSIMImpl.valueOf(getString("ssim", asString(opts.ssim)));
        opts.rgb2grayVersion = Options.RGB2Gray.valueOf(getString("rgb2grayVersion", asString(opts.rgb2grayVersion)));

        opts.k1 = getDouble("k1", opts.k1);
        opts.k2 = getDouble("k2", opts.k2);

        opts.windowSize = getInt("windowSize", opts.windowSize);
        opts.bitDepth = getInt("bitDepth", opts.bitDepth);
        opts.maxSize = getInt("maxSize", opts.maxSize);

        opts.ignoredBoxes = getIgnoredBoxes();

        return opts;
    }

    private boolean getBool(String name, boolean defaultValue) {
        if (!options.containsKey(name)) {
            return defaultValue;
        }
        return toBool(options.get(name));
    }

    private static boolean toBool(Object obj) {
        if (obj == null) {
            return false;
        }
        return Boolean.parseBoolean(asString(obj));
    }

    private double getDouble(String name, double defaultValue) {
        return toDouble(options.get(name), defaultValue);
    }

    private static double toDouble(Object obj, double defaultValue) {
        if (!(obj instanceof Number)) {
            return defaultValue;
        }
        return ((Number) obj).doubleValue();
    }

    private int getInt(String name, int defaultValue) {
        Object val = options.get(name);
        if (!(val instanceof Number)) {
            return defaultValue;
        }
        return ((Number) val).intValue();
    }

    private String getString(String name, String defaultValue) {
        if (!options.containsKey(name)) {
            return defaultValue;
        }
        return asString(options.get(name));
    }

    private static String asString(Object obj) {
        if (obj == null) {
            return null;
        }
        return obj.toString();
    }

    private int[][] getIgnoredBoxes() {
        Object boxes = options.get("ignoredBoxes");
        if (!(boxes instanceof Collection)) {
            return null;
        }

        List ignoredBoxes = new ArrayList<>();
        for (Object boxObj : (Collection) boxes) {
            int[] ignoredBox = getIntArray(boxObj, IGNORED_BOX_KEYS);
            if (ignoredBox != null) {
                ignoredBoxes.add(ignoredBox);
            }
        }

        return ignoredBoxes.toArray(new int[0][]);
    }

    private static int[] getIntArray(Object obj, String[] keys) {
        if (!(obj instanceof Map)) {
            return null;
        }

        int[] vals = new int[keys.length];
        Map m = (Map) obj;

        for (int i = 0; i < keys.length; i++) {
            Object val = m.get(keys[i]);
            if (val instanceof Number) {
                vals[i] = ((Number) val).intValue();
            }
        }

        return vals;
    }

    // BufferedImage returns pixels packed into int[] in ARGB order -- we need *unpacked* RGBA to for Resemble / SSIM
    // see: https://developer.mozilla.org/en-US/docs/Web/API/ImageData
    private static double[] unpackPixels(int[] packed) {
        int packedLength = packed.length;
        double[] unpacked = new double[packedLength * 4];
        int unpackedIndex;
        int packedPixel;

        for (int i = 0; i < packedLength; i++) {
            packedPixel = packed[i];
            unpackedIndex = i * 4;

            unpacked[unpackedIndex] = (0xff & (packedPixel >> 16));
            unpacked[unpackedIndex + 1] = (0xff & (packedPixel >> 8));
            unpacked[unpackedIndex + 2] = (0xff & packedPixel);
            unpacked[unpackedIndex + 3] = (0xff & (packedPixel >>> 24));
        }

        return unpacked;
    }

    public static class MismatchException extends RuntimeException {

        public Map data;

        public MismatchException(String msg, Map data) {
            super(msg);
            data.put("error", getMessage());
            this.data = data;
        }
    }
    
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy