ImageCompare.cs
//
// This code is part of Document Solutions for Imaging demos.
// Copyright (c) MESCIUS inc. All rights reserved.
//
using System;
using System.IO;
using System.Drawing;
using System.Collections.Generic;
using System.Linq;
using GrapeCity.Documents.Drawing;
using GrapeCity.Documents.Text;
using GrapeCity.Documents.Imaging;
using GCTEXT = GrapeCity.Documents.Text;
using GCDRAW = GrapeCity.Documents.Drawing;
namespace DsImagingWeb.Demos
{
// This sample implements pixel by pixel image comparison.
//
// CIE L*a*b* color space is used for color comparison,
// XYZ color space is used to convert colors between RGB and Lab.
// The 'difference' image is a low-contrast grayscale representation of the first image,
// overlaid with magenta colored pixels that are different, magenta intensity proportional
// to the difference between the two original pixels.
// While all output formats are supported by this sample, TIFF is preferable
// as the 2nd and 3rd pages of the generated TIFF show the two original images.
//
// Color math taken from EasyRGB.
// Color comparison formula from Identifying Color Differences Using L*a*b* or L*C*H* Coordinates.
public class ImageCompare
{
public Stream GenerateImageStream(string targetMime, Size pixelSize, float dpi, bool opaque, string[] sampleParams = null)
{
sampleParams ??= GetSampleParamsList()[0];
var path1 = sampleParams[3];
var path2 = sampleParams[4];
// Comparison fuzziness (from 0 to MaxDelta):
int fuzz = sampleParams == null ? 12 : int.Parse(sampleParams[5]);
// Different pixels will be this color:
uint highlight = (uint)Color.Magenta.ToArgb();
// Fill for empty areas not occupied by any image:
uint fill = (uint)Color.White.ToArgb();
// Q is used to enhance the intensity of the differences,
// we do it in inverse proportion to the fuzz (0 fuzz will make
// even the smallest differences pop out):
int Q = (int)(255d / (fuzz + 1));
// Reference image transparency:
const uint alphaBack = 55 << 24;
var bmp1 = new GcBitmap(path1);
var bmp2 = new GcBitmap(path2);
// If images are too large, resize to fit target width/height:
double z1 = 1, z2 = 1;
if (bmp1.PixelWidth > pixelSize.Width)
z1 = (double)pixelSize.Width / bmp1.PixelWidth;
if (bmp1.PixelHeight > pixelSize.Height)
z1 = Math.Min(z1, (double)pixelSize.Height / bmp1.PixelHeight);
if (bmp2.PixelWidth > pixelSize.Width)
z2 = (double)pixelSize.Width / bmp2.PixelWidth;
if (bmp2.PixelHeight > pixelSize.Height)
z2 = Math.Min(z2, (double)pixelSize.Height / bmp2.PixelHeight);
// Resize images to same (minimum) widths:
if (bmp1.PixelWidth * z1 > bmp2.PixelWidth * z2)
z1 = (double)bmp2.PixelWidth / bmp1.PixelWidth;
else if (bmp2.PixelWidth * z2 > bmp1.PixelWidth * z1)
z2 = (double)bmp1.PixelWidth / bmp2.PixelWidth;
if (z1 < 1)
{
var t = bmp1.Resize((int)Math.Round(bmp1.PixelWidth * z1), (int)Math.Round(bmp1.PixelHeight * z1),
z1 < 0.5 ? InterpolationMode.Downscale : InterpolationMode.Cubic);
bmp1.Dispose();
bmp1 = t;
}
if (z2 < 1)
{
var t = bmp2.Resize((int)Math.Round(bmp2.PixelWidth * z2), (int)Math.Round(bmp2.PixelHeight * z2),
z2 < 0.5 ? InterpolationMode.Downscale : InterpolationMode.Cubic);
bmp2.Dispose();
bmp2 = t;
}
// Color difference math from
// https://sensing.konicaminolta.us/us/blog/identifying-color-differences-using-l-a-b-or-l-c-h-coordinates/
// We assume that for RGB space, L*a*b* ranges are
// L* from 0 to 100, a* and b* from -128 to + 127, see
// http://www.colourphil.co.uk/lab_lch_colour_space.shtml
// So max possible difference between any two colors in RGB space would be:
// MaxDelta = 374.23254802328461
var MaxDelta = LabDistance((0, -128, -128), (100, 127, 127));
using (var bmp = new GcBitmap(pixelSize.Width, pixelSize.Height, false, dpi, dpi))
using (var g = bmp.CreateGraphics(Color.FromArgb((int)fill)))
{
// Total number of different pixels found:
int differences = 0;
var w = bmp1.PixelWidth;
var h = Math.Min(bmp1.PixelHeight, bmp2.PixelHeight);
for (int i = 0; i < w; ++i)
{
for (int j = 0; j < h; ++j)
{
var px1 = bmp1[i, j];
var px2 = bmp2[i, j];
// Convert RGB to CIE L*a*b* space to calc the difference:
var (L1, a1, b1) = ColorXYZ.FromRGB((int)px1).ToCIELab();
if (px1 == px2)
{
// To speed things up, we skip 2nd color calculations for identical pixels:
// Equal pixels are drawn in semi-transparent gray to give a reference to the differences:
uint gray = (uint)Math.Round((L1 * 255d) / 100d);
bmp[i, j] = alphaBack | (gray << 16) | (gray << 8) | gray;
}
else
{
// Otherwise, calc the distance between the two colors:
var (L2, a2, b2) = ColorXYZ.FromRGB((int)px2).ToCIELab();
var delta = LabDistance((L1, a1, b1), (L2, a2, b2));
if (delta > fuzz)
{
uint alpha = (uint)Math.Min(255, ((255 * delta) / MaxDelta) * Q);
bmp[i, j] = (alpha << 24) | highlight;
++differences;
}
else
{
// See equal pixels comment above:
uint gray = (uint)Math.Round((L1 * 255d) / 100d);
bmp[i, j] = alphaBack | (gray << 16) | (gray << 8) | gray;
}
}
}
}
// For consistency, convert the difference to opaque:
bmp.ConvertToOpaque(Color.White);
// Text layout for info texts:
var tl = new TextLayout(g.Resolution) { TextAlignment = TextAlignment.Trailing };
tl.DefaultFormat.Font = GCTEXT.Font.FromFile(Path.Combine("Resources", "Fonts", "arial.ttf"));
tl.DefaultFormat.FontSize = 12;
tl.DefaultFormat.ForeColor = Color.Blue;
tl.MaxWidth = g.Width;
tl.MarginAll = 4;
// Save to target format:
var ms = new MemoryStream();
if (targetMime == Common.Util.MimeTypes.TIFF)
{
// For TIFF, render 3 images (diff and 2 sources) on separate pages:
tl.Append($"Found {differences} different pixels (fuzz {fuzz}).");
g.DrawTextLayout(tl, PointF.Empty);
using (var tw = new GcTiffWriter(ms))
{
tw.AppendFrame(bmp);
bmp1.ConvertToOpaque(Color.White);
using (var g1 = bmp1.CreateGraphics())
{
tl.Clear();
tl.MaxWidth = g1.Width;
tl.DefaultFormat.BackColor = Color.LightYellow;
tl.Append(Path.GetFileName(path1));
g1.DrawTextLayout(tl, PointF.Empty);
}
tw.AppendFrame(bmp1);
bmp2.ConvertToOpaque(Color.White);
using (var g2 = bmp2.CreateGraphics())
{
tl.Clear();
tl.MaxWidth = g2.Width;
tl.Append(Path.GetFileName(path2));
g2.DrawTextLayout(tl, PointF.Empty);
}
tw.AppendFrame(bmp2);
}
bmp.SaveAsTiff(ms);
}
else
{
// For other formats, tile the diff and sources:
using var tbmp = TileImages(pixelSize, bmp, new Size(w, h), bmp1, bmp2, dpi, tl, Path.GetFileName(path1), Path.GetFileName(path2));
using (var tg = tbmp.CreateGraphics())
{
tl.TextAlignment = TextAlignment.Trailing;
tl.MaxWidth = tg.Width;
tl.Clear();
tl.Append($"Found {differences} different pixels (fuzz {fuzz}).");
tg.DrawTextLayout(tl, PointF.Empty);
}
switch (targetMime)
{
case Common.Util.MimeTypes.JPEG:
tbmp.SaveAsJpeg(ms);
break;
case Common.Util.MimeTypes.PNG:
tbmp.SaveAsPng(ms);
break;
case Common.Util.MimeTypes.BMP:
tbmp.SaveAsBmp(ms);
break;
case Common.Util.MimeTypes.GIF:
tbmp.SaveAsGif(ms);
break;
case Common.Util.MimeTypes.WEBP:
bmp.SaveAsWebp(ms);
break;
default:
throw new Exception($"Encoding {targetMime} is not supported.");
}
}
bmp1.Dispose();
bmp2.Dispose();
ms.Seek(0, SeekOrigin.Begin);
return ms;
}
}
private static GcBitmap TileImages(Size targetSize, GcBitmap diff, Size diffSize, GcBitmap bmp1, GcBitmap bmp2, float dpi, TextLayout tl, string name1, string name2)
{
Size tSize = new Size(targetSize.Width / 2 - 1, targetSize.Width / 2 - 1);
var bmp = new GcBitmap(targetSize.Width, targetSize.Height, true, dpi, dpi);
using (var diffClip = diff.Clip(new Rectangle(Point.Empty, diffSize)))
{
bmp1.ConvertToOpaque(Color.White);
bmp2.ConvertToOpaque(Color.White);
var ts0 = FitSize(diffClip, tSize);
var ts1 = FitSize(bmp1, tSize);
var ts2 = FitSize(bmp2, tSize);
using (var g = bmp.CreateGraphics(Color.White))
{
g.DrawLine(0, ts0.Height, g.Width, ts0.Height, Color.Yellow);
g.DrawLine(ts1.Width + 1, diffClip.Height, ts1.Width + 1, ts0.Height + ts1.Height, Color.Yellow);
}
int x;
if (ts0.IsEmpty)
{
x = ts1.Width - diffClip.PixelWidth / 2;
bmp.BitBlt(diffClip, x, 0);
}
else
{
x = ts1.Width - ts0.Width / 2;
using (var tbmp = diffClip.Resize(ts0.Width, ts0.Height, InterpolationMode.Cubic))
bmp.BitBlt(tbmp, x, 0);
}
if (ts1.IsEmpty)
bmp.BitBlt(bmp1, 0, ts0.Height + 1);
else
using (var tbmp = bmp1.Resize(ts1.Width, ts1.Height, InterpolationMode.Cubic))
bmp.BitBlt(tbmp, 0, ts0.Height + 1);
if (ts2.IsEmpty)
bmp.BitBlt(bmp2, ts1.Width + 1, ts0.Height + 1);
else
using (var tbmp = bmp2.Resize(ts2.Width, ts2.Height, InterpolationMode.Cubic))
bmp.BitBlt(tbmp, ts1.Width + 1, ts0.Height + 1);
using (var g = bmp.CreateGraphics())
{
tl.TextAlignment = TextAlignment.Leading;
tl.DefaultFormat.BackColor = Color.LightYellow;
tl.Clear();
tl.MaxWidth = ts0.Width;
tl.Append("Difference");
g.DrawTextLayout(tl, new PointF(x, 0));
tl.Clear();
tl.MaxWidth = ts1.Width;
tl.Append(name1);
g.DrawTextLayout(tl, new PointF(0, ts0.Height + 1));
tl.Clear();
tl.MaxWidth = ts2.Width;
tl.Append(name2);
g.DrawTextLayout(tl, new PointF(ts1.Width + 1, ts0.Height + 1));
}
}
return bmp;
}
private static Size FitSize(GcBitmap bmp, Size size)
{
double z = 1;
if (bmp.PixelWidth > size.Width)
z = (double)size.Width / bmp.PixelWidth;
if (bmp.PixelHeight > size.Height)
z = Math.Min(z, (double)size.Height / bmp.PixelHeight);
if (z < 1)
return new Size((int)Math.Round(bmp.PixelWidth * z), (int)Math.Round(bmp.PixelHeight * z));
else
return Size.Empty; // indicates that no resizing is needed
}
public static double LabDistance((double L, double a, double b) lab1, (double L, double a, double b) lab2)
{
var dL = lab1.L - lab2.L;
var da = lab1.a - lab2.a;
var db = lab1.b - lab2.b;
return Math.Sqrt(dL * dL + da * da + db * db);
}
public string DefaultMime { get => Common.Util.MimeTypes.TIFF; }
public static List<string[]> GetSampleParamsList()
{
return new List<string[]>()
{
// Strings are name, description, info. Rest are arbitrary strings, in this sample these are:
// - first file to compare;
// - second file to compare;
// - compare fuzz (integer):
new string[] { "Find Differences", "Compare two similar images with few minor differences (fuzz 12)", null,
Path.Combine("Resources", "Images", "newfoundland.jpg"), Path.Combine("Resources", "ImageCompare", "newfoundland-mod.jpg"), "12" },
new string[] { "Invisible Text", "Compare an image with same image that has a semi-transparent text overlay (fuzz 0)", null,
Path.Combine("Resources", "ImageCompare", "seville.png"), Path.Combine("Resources", "ImageCompare", "seville-text.png"), "0" },
new string[] { "PNG vs JPEG", "Compare a PNG image with the same image saved as a 75% quality JPEG (fuzz 6)", null,
Path.Combine("Resources", "ImageCompare", "toronto-lights.png"), Path.Combine("Resources", "ImageCompare", "toronto-lights-75.jpg"), "6" },
new string[] { "Font Hinting", "Compare text rendered with TrueType font hinting on and off (fuzz 1)", null,
Path.Combine("Resources", "ImageCompare", "TrueTypeHinting-on.png"), Path.Combine("Resources", "ImageCompare", "TrueTypeHinting-off.png"), "1" },
};
}
// An XYZ color type, based on color math from
// https://www.easyrgb.com/en/math.php
public class ColorXYZ : IEquatable<ColorXYZ>
{
private readonly double _x, _y, _z;
// D65 CIE 1964 ref values (sRGB, AdobeRGB):
const double ReferenceX = 94.811;
const double ReferenceY = 100.000;
const double ReferenceZ = 107.304;
public double X => _x;
public double Y => _y;
public double Z => _z;
private ColorXYZ(double x, double y, double z)
{
_x = x;
_y = y;
_z = z;
}
public bool Equals(ColorXYZ other)
{
if ((object)other == null)
return false;
return _x == other._x && _y == other._y && _z == other._z;
/*
return Same(_x, other._x) && Same(_y, other._y) && Same(_z, other._z);
bool Same(double a, double b)
{
return Math.Abs(a - b) <= a / 10000;
}
*/
}
public override bool Equals(object obj)
{
return Equals(obj as ColorXYZ);
}
public static bool operator ==(ColorXYZ obj1, ColorXYZ obj2)
{
return (object)obj1 != null && obj1.Equals(obj2);
}
public static bool operator !=(ColorXYZ obj1, ColorXYZ obj2)
{
return (object)obj1 == null || !obj1.Equals(obj2);
}
public override int GetHashCode()
{
return _x.GetHashCode() ^ _y.GetHashCode() ^ _z.GetHashCode();
}
public static ColorXYZ FromXYZ(double x, double y, double z)
{
return new ColorXYZ(x, y, z);
}
public static ColorXYZ FromRGB(Color rgb)
{
return FromRGB(rgb.R, rgb.G, rgb.B);
}
public static ColorXYZ FromRGB(int rgb)
{
return FromRGB((rgb & 0x00FF0000) >> 16, (rgb & 0x0000FF00) >> 8, rgb & 0x000000FF);
}
public static ColorXYZ FromRGB(int r, int g, int b)
{
double var_R = (r / 255d);
double var_G = (g / 255d);
double var_B = (b / 255d);
if (var_R > 0.04045)
var_R = Math.Pow((var_R + 0.055) / 1.055, 2.4);
else
var_R = var_R / 12.92;
if (var_G > 0.04045)
var_G = Math.Pow((var_G + 0.055) / 1.055, 2.4);
else
var_G /= 12.92;
if (var_B > 0.04045)
var_B = Math.Pow((var_B + 0.055) / 1.055, 2.4);
else
var_B /= 12.92;
var_R *= 100;
var_G *= 100;
var_B *= 100;
return new ColorXYZ(
var_R * 0.4124 + var_G * 0.3576 + var_B * 0.1805,
var_R * 0.2126 + var_G * 0.7152 + var_B * 0.0722,
var_R * 0.0193 + var_G * 0.1192 + var_B * 0.9505
);
}
public static ColorXYZ FromCIELab((double L, double a, double b) lab)
{
return FromCIELab(lab.L, lab.a, lab.b);
}
public static ColorXYZ FromCIELab(double L, double a, double b)
{
double var_Y = (L + 16) / 116d;
double var_X = a / 500d + var_Y;
double var_Z = var_Y - b / 200d;
if (Math.Pow(var_Y, 3) > 0.008856)
var_Y = Math.Pow(var_Y, 3);
else
var_Y = (var_Y - 16d / 116d) / 7.787;
if (Math.Pow(var_X, 3) > 0.008856)
var_X = Math.Pow(var_X, 3);
else
var_X = (var_X - 16d / 116d) / 7.787;
if (Math.Pow(var_Z, 3) > 0.008856)
var_Z = Math.Pow(var_Z, 3);
else
var_Z = (var_Z - 16d / 116d) / 7.787;
return new ColorXYZ(
var_X * ReferenceX,
var_Y * ReferenceY,
var_Z * ReferenceZ
);
}
public Color ToRGB()
{
double var_X = _x / 100;
double var_Y = _y / 100;
double var_Z = _z / 100;
double var_R = var_X * 3.2406 + var_Y * -1.5372 + var_Z * -0.4986;
double var_G = var_X * -0.9689 + var_Y * 1.8758 + var_Z * 0.0415;
double var_B = var_X * 0.0557 + var_Y * -0.2040 + var_Z * 1.0570;
if (var_R > 0.0031308)
var_R = 1.055 * Math.Pow(var_R, (1 / 2.4)) - 0.055;
else
var_R = 12.92 * var_R;
if (var_G > 0.0031308)
var_G = 1.055 * Math.Pow(var_G, (1 / 2.4)) - 0.055;
else
var_G = 12.92 * var_G;
if (var_B > 0.0031308)
var_B = 1.055 * Math.Pow(var_B, (1 / 2.4)) - 0.055;
else
var_B = 12.92 * var_B;
return Color.FromArgb(
(int)Math.Round(var_R * 255),
(int)Math.Round(var_G * 255),
(int)Math.Round(var_B * 255)
);
}
public (double L, double a, double b) ToCIELab()
{
double var_X = _x / ReferenceX;
double var_Y = _y / ReferenceY;
double var_Z = _z / ReferenceZ;
if (var_X > 0.008856)
var_X = Math.Pow(var_X, 1 / 3d);
else
var_X = (7.787 * var_X) + (16d / 116d);
if (var_Y > 0.008856)
var_Y = Math.Pow(var_Y, 1 / 3d);
else
var_Y = (7.787 * var_Y) + (16d / 116d);
if (var_Z > 0.008856)
var_Z = Math.Pow(var_Z, 1 / 3d);
else
var_Z = (7.787 * var_Z) + (16d / 116d);
return (
(116 * var_Y) - 16,
500 * (var_X - var_Y),
200 * (var_Y - var_Z)
);
}
}
}
}