ImageCompare.cs
// 
// このコードは、DioDocs for Imaging のサンプルの一部として提供されています。
// © 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
{
    // このサンプルでは、ピクセル単位での画像比較を実装しています。
    //
    // 色の比較には CIE L*a*b* 色空間を使用し、
    // RGB と Lab 間の色変換には XYZ 色空間を使用しています。
    // 「差分」画像は、1枚目の画像を低コントラストのグレースケールで表現したものに、
    // 異なるピクセルをマゼンタ色で重ねて表示します。
    // マゼンタの濃さは、元の2つのピクセル間の差の大きさに比例します。
    // すべての出力形式に対応していますが、生成される TIFF の
    // 2ページ目および3ページ目に元画像2枚が表示されるため、
    // TIFF 形式の使用を推奨します。
    //
    // @色計算は EasyRGB の情報を基にしています。
    // @色差の計算式は Identifying Color Differences Using L*a*b* または L*C*H* 座標による色差判定 を参考にしています。
    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];
            // 比較の曖昧さ(0からMaxDeltaまで)を設定します。
            int fuzz = sampleParams == null ? 12 : int.Parse(sampleParams[5]);
            // 異なるピクセルはこの色でハイライトされます。
            uint highlight = (uint)Color.Magenta.ToArgb();
            // どの画像にも占有されていない空の領域の塗りつぶし色です。
            uint fill = (uint)Color.White.ToArgb();
            // Qは差異の強度を高めるために使用されます。
            // fuzzに反比例させて行います(fuzzが0の場合、わずかな差異でも際立たせます)。
            int Q = (int)(255d / (fuzz + 1));
            // 参照画像の透明度です。
            const uint alphaBack = 55 << 24;

            var bmp1 = new GcBitmap(path1);
            var bmp2 = new GcBitmap(path2);

            // 画像が大きすぎる場合、ターゲットの幅と高さに合わせてリサイズします。
            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);

            // 画像を同じ(最小の)幅にリサイズします。
            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;
            }

            // 色の差異に関する計算は以下のURLに基づいています。
            // https://sensing.konicaminolta.us/us/blog/identifying-color-differences-using-l-a-b-or-l-c-h-coordinates/

            // RGB空間において、L*a*b*の範囲はL*が0〜100、a*およびb*が-128〜+127であると仮定します。詳細は以下を参照してください。
            // http://www.colourphil.co.uk/lab_lch_colour_space.shtml
            // そのため、RGB空間における任意の2色間の最大可能差異は以下の通りとなります。
            // 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)))
            {
                // 見つかった異なるピクセルの総数です。
                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];

                        // 差異を計算するために、RGBをCIE L*a*b*空間に変換します。
                        var (L1, a1, b1) = ColorXYZ.FromRGB((int)px1).ToCIELab();
                        if (px1 == px2)
                        {
                            // 処理を高速化するため、同一ピクセルの場合は2つ目の色の計算をスキップします。
                            // 同一ピクセルは、差異の参照用として半透明のグレーで描画されます。
                            uint gray = (uint)Math.Round((L1 * 255d) / 100d);
                            bmp[i, j] = alphaBack | (gray << 16) | (gray << 8) | gray;
                        }
                        else
                        {
                            // それ以外の場合は、2色間の距離を計算します。
                            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
                            {
                                // 上記の同一ピクセルのコメントを参照してください。
                                uint gray = (uint)Math.Round((L1 * 255d) / 100d);
                                bmp[i, j] = alphaBack | (gray << 16) | (gray << 8) | gray;
                            }
                        }
                    }
                }

                // 一貫性のため、差異を不透明に変換します。
                bmp.ConvertToOpaque(Color.White);

                // 情報テキスト用のテキストレイアウトです。
                var tl = new TextLayout(g.Resolution) { TextAlignment = TextAlignment.Trailing };
                tl.DefaultFormat.Font = GCTEXT.Font.FromFile(Path.Combine("Resources", "Fonts", "NotoSansJP-Regular.ttf"));
                tl.DefaultFormat.FontSize = 12;
                tl.DefaultFormat.ForeColor = Color.Blue;
                tl.MaxWidth = g.Width;
                tl.MarginAll = 4;

                // ターゲット形式に保存します。
                var ms = new MemoryStream();
                if (targetMime == Common.Util.MimeTypes.TIFF)
                {
                    // TIFFの場合、差異画像と2つのソース画像を別々のページにレンダリングします。
                    tl.Append($"{differences} 個の異なるピクセル (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
                {
                    // その他の形式の場合、差異画像とソース画像をタイル状に並べます。
                    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($"{differences} 個の異なるピクセル (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($"エンコーディング {targetMime} はサポートされていません。");
                    }
                }
                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("比較結果");
                    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; // サイズ変更の必要がないことを示します。
        }

        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[]>()
            {
                // 文字列の内容は、名前、説明、情報です。それ以外は任意の文字列であり、このサンプルでは以下の通りです。
                // - 比較する1つ目のファイル
                // - 比較する2つ目のファイル
                // - 比較の曖昧さ(整数)
                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" },
            };
        }

        // XYZ形式のカラー型です。以下のURLにある色の計算式に基づいています。
        // https://www.easyrgb.com/en/math.php
        public class ColorXYZ : IEquatable<ColorXYZ>
        {
            private readonly double _x, _y, _z;

            // D65 CIE 1964 参照値(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)
                );
            }
        }
    }
}