/*
* GeoTools - The Open Source Java GIS Toolkit
* http://geotools.org
*
* (C) 2014, Open Source Geospatial Foundation (OSGeo)
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation;
* version 2.1 of the License.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*/
package org.geotools.image.test;
import java.awt.image.RenderedImage;
import javax.media.jai.PlanarImage;
import javax.media.jai.iterator.RandomIter;
import javax.media.jai.iterator.RandomIterFactory;
import org.geotools.image.ImageWorker;
/**
* Utility to compare two images and verify if the are equal to the human eye, or not. See the
* {@link Mode} enumeration for comparison modes. The image comparison logic has been ported to Java
* from Resemble.js, https://github.com/Huddle/Resemble.js
*
* @author Andrea Aime - GeoSolutions
*
*/
public class ImageComparator {
public enum Mode {
/**
* Checks if the images are equal taking into account the full color and all pixels. Some
* light difference between the two images is still being tolerated
*/
IgnoreNothing,
/**
* Same as above, but if a pixel is found to be anti-aliased, only brightness will be
* compared, instead of the full color component
*/
IgnoreAntialiasing,
/**
* Ignores the colors and compares only the brightness
*/
IgnoreColors
};
final class Pixel {
int r;
int g;
int b;
int a;
private int brightness;
private double hue;
public Pixel() {
}
public void init(int[] px) {
if (bands < 2) {
r = g = b = px[0];
if (bands == 2) {
a = px[1];
} else {
a = 255;
}
} else {
r = px[0];
g = px[1];
b = px[2];
if (bands == 4) {
a = px[3];
} else {
a = 255;
}
}
brightness = Integer.MIN_VALUE;
hue = Double.NaN;
}
int getBrightness() {
if (brightness == Integer.MIN_VALUE) {
brightness = (int) Math.round(0.3 * r + 0.59 * g + 0.11 * b);
}
return brightness;
}
double getHue() {
if (hue == Double.NaN) {
double r = this.r / 255d;
double g = this.g / 255d;
double b = this.b / 255d;
double max = Math.max(r, Math.max(g, b));
double min = Math.min(r, Math.max(g, b));
if (max == min) {
hue = 0; // achromatic
} else {
double d = max - min;
if (max == r) {
hue = (g - b) / d + (g < b ? 6 : 0);
} else if (max == g) {
hue = (b - r) / d + 2;
} else {
hue = (r - g) / d + 4;
}
hue /= 6;
}
}
return hue;
}
public boolean isRGBSame(Pixel other) {
if (a != other.a) {
return false;
}
if (b != other.b)
return false;
if (g != other.g)
return false;
if (r != other.r)
return false;
return true;
}
public boolean isSimilar(Pixel other) {
return isColorSimilar(r, other.r, RED) && //
isColorSimilar(g, other.g, GREEN) && //
isColorSimilar(b, other.b, BLUE) && //
isColorSimilar(a, other.a, ALPHA);
}
public boolean isConstrasting(Pixel other) {
return Math.abs(getBrightness() - other.getBrightness()) > tolerance[MAX_BRIGHTNESS];
}
private boolean isColorSimilar(int a, int b, int color) {
final int diff = Math.abs(a - b);
return diff == 0 || diff < tolerance[color];
}
public boolean isBrightnessSimilar(Pixel other) {
return isColorSimilar(a, other.a, ALPHA)
&& isColorSimilar(getBrightness(), other.getBrightness(), MIN_BRIGHTNESS);
}
public boolean hasDifferentHue(Pixel cursor) {
return Math.abs(getHue() - cursor.getHue()) > 0.3;
}
@Override
public String toString() {
return "Pixel [r=" + r + ", g=" + g + ", b=" + b + ", a=" + a + "]";
}
}
static final int RED = 0;
static final int GREEN = 1;
static final int BLUE = 2;
static final int ALPHA = 3;
static final int MIN_BRIGHTNESS = 4;
static final int MAX_BRIGHTNESS = 5;
int[] tolerance = new int[MAX_BRIGHTNESS + 1];
Mode mode;
long mismatchCount = 0;
double mismatchPercent;
int bands;
public ImageComparator(Mode mode, RenderedImage image1, RenderedImage image2) {
int height = image1.getHeight();
int width = image1.getWidth();
if (width != image2.getWidth() || height != image2.getHeight()) {
mismatchCount = Integer.MAX_VALUE;
mismatchPercent = 1d;
return;
}
// switch to rbg/rgba/gray/gray-alpha
image1 = normalizeImage(image1);
image2 = normalizeImage(image2);
this.bands = image1.getSampleModel().getNumBands();
final boolean hasAlpha = image1.getColorModel().hasAlpha();
if (bands > 4 || (bands == 2 && !hasAlpha) || (bands == 3 && hasAlpha)) {
throw new IllegalArgumentException(
"Images have the wrong type, this code only supports gray, gray/alpha, "
+ "RGB, RGBA images, or images that can be transformed in those models");
}
this.mode = mode;
switch (mode) {
case IgnoreNothing:
tolerance[RED] = 16;
tolerance[GREEN] = 16;
tolerance[BLUE] = 16;
tolerance[ALPHA] = 16;
tolerance[MIN_BRIGHTNESS] = 16;
tolerance[MAX_BRIGHTNESS] = 240;
break;
case IgnoreAntialiasing:
tolerance[RED] = 32;
tolerance[GREEN] = 32;
tolerance[BLUE] = 32;
tolerance[ALPHA] = 128;
tolerance[MIN_BRIGHTNESS] = 64;
tolerance[MAX_BRIGHTNESS] = 98;
break;
case IgnoreColors:
tolerance[ALPHA] = 16;
tolerance[MIN_BRIGHTNESS] = 16;
tolerance[MAX_BRIGHTNESS] = 240;
break;
}
computeDifference(image1, image2);
mismatchPercent = mismatchCount * 1d / (width * image2.getHeight());
}
/**
* Forces the image to start in the origin and have a rgb/rbga/gray/gray+alpha structure
*
* @param image1
* @return
*/
private RenderedImage normalizeImage(RenderedImage image1) {
image1 = new ImageWorker(image1).forceColorSpaceRGB().forceComponentColorModel()
.getRenderedImage();
if (image1.getMinX() != 0 || image1.getMinY() != 0) {
image1 = PlanarImage.wrapRenderedImage(image1).getAsBufferedImage();
}
return image1;
}
public double getMismatchPercent() {
return mismatchPercent;
}
public long getMismatchCount() {
return mismatchCount;
}
void computeDifference(RenderedImage image1, RenderedImage image2) {
int[] components = new int[bands];
Pixel px1 = new Pixel();
Pixel px2 = new Pixel();
final int width = image1.getWidth();
final int height = image1.getHeight();
RandomIter it1 = RandomIterFactory.create(image1, null);
RandomIter it2 = RandomIterFactory.create(image2, null);
Pixel cursor = new Pixel();
try {
for (int r = 0; r < height; r++) {
for (int c = 0; c < width; c++) {
it1.getPixel(c, r, components);
px1.init(components);
it2.getPixel(c, r, components);
px2.init(components);
if (mode == Mode.IgnoreColors) {
if (!px1.isBrightnessSimilar(px2)) {
mismatchCount++;
}
} else if (!px1.isSimilar(px2)) {
if (mode == Mode.IgnoreAntialiasing) {
if (isAntialised(px1, it1, r, c, width, height, components, cursor)
|| isAntialised(px2, it2, r, c, width, height, components,
cursor)) {
if (!px1.isBrightnessSimilar(px2)) {
mismatchCount++;
}
} else {
mismatchCount++;
}
} else {
mismatchCount++;
}
}
}
}
} finally {
it1.done();
it2.done();
}
}
private boolean isAntialised(Pixel source, RandomIter it, int row, int col, int width,
int height, int[] pixel, Pixel cursor) {
final int DISTANCE = 1;
int highContrastSibling = 0;
int siblingWithDifferentHue = 0;
int equivalentSibling = 0;
final int rowMin = Math.max(row - DISTANCE, 0);
final int rowMax = Math.min(row + DISTANCE, width);
final int colMin = Math.max(col - DISTANCE, 0);
final int colMax = Math.min(col + DISTANCE, height);
for (int c = colMin; c < colMax; c++) {
for (int r = rowMin; r < rowMax; r++) {
if (c == col && r == row) {
// ignore source pixel
continue;
} else {
it.getPixel(c, r, pixel);
cursor.init(pixel);
if (source.isRGBSame(cursor)) {
equivalentSibling++;
} else if (source.isConstrasting(cursor)) {
highContrastSibling++;
}
if (source.hasDifferentHue(cursor)) {
siblingWithDifferentHue++;
}
if (siblingWithDifferentHue > 1 || highContrastSibling > 1) {
return true;
}
}
}
}
if (equivalentSibling < 2) {
return true;
}
return false;
}
}