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)
        {
            if (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)
                );
            }
        }
    }
}