ImageCompare.vb
'' 
'' このコードは、DioDocs for Imaging のサンプルの一部として提供されています。
'' © MESCIUS inc. All rights reserved.
'' 
Imports System.IO
Imports System.Drawing
Imports System.Collections.Generic
Imports System.Linq
Imports GrapeCity.Documents.Drawing
Imports GrapeCity.Documents.Text
Imports GrapeCity.Documents.Imaging
Imports GCTEXT = GrapeCity.Documents.Text
Imports GCDRAW = GrapeCity.Documents.Drawing

'' このサンプルでは、ピクセル単位での画像比較を実装しています。
'' 
'' 色の比較には 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
    Function GenerateImageStream(
                ByVal targetMime As String,
                ByVal pixelSize As Size,
                ByVal dpi As Single,
                ByVal opaque As Boolean,
                Optional sampleParams As String() = Nothing) As Stream

        If sampleParams Is Nothing Then
            sampleParams = GetSampleParamsList()(0)
        End If

        Dim path1 = sampleParams(3)
        Dim path2 = sampleParams(4)
        '' 比較の曖昧さ(0からMaxDeltaまで)を設定します。
        Dim fuzz As Integer
        If sampleParams Is Nothing Then
            fuzz = 12
        Else
            fuzz = Integer.Parse(sampleParams(5))
        End If
        '' 異なるピクセルはこの色でハイライトされます。
        Dim highlight = Color.Magenta.ToArgb()
        '' どの画像にも占有されていない空の領域の塗りつぶし色です。
        Dim fill = Color.White.ToArgb()
        '' Qは差異の強度を高めるために使用されます。
        '' fuzzに反比例させて行います(fuzzが0の場合、わずかな差異でも際立たせます)。
        Dim Q = (255D / (fuzz + 1))
        '' 参照画像の透明度です。
        Const alphaBack = 55 << 24

        Dim bmp1 = New GcBitmap(path1)
        Dim bmp2 = New GcBitmap(path2)

        '' 画像が大きすぎる場合、ターゲットの幅と高さに合わせてリサイズします。
        Dim z1 = 1, z2 = 1
        If bmp1.PixelWidth > pixelSize.Width Then
            z1 = pixelSize.Width / bmp1.PixelWidth
        End If
        If bmp1.PixelHeight > pixelSize.Height Then
            z1 = Math.Min(z1, pixelSize.Height / bmp1.PixelHeight)
        End If
        If (bmp2.PixelWidth > pixelSize.Width) Then
            z2 = pixelSize.Width / bmp2.PixelWidth
        End If
        If (bmp2.PixelHeight > pixelSize.Height) Then
            z2 = Math.Min(z2, pixelSize.Height / bmp2.PixelHeight)
        End If

        '' 画像を同じ(最小の)幅にリサイズします。
        If (bmp1.PixelWidth * z1 > bmp2.PixelWidth * z2) Then
            z1 = bmp2.PixelWidth / bmp1.PixelWidth
        ElseIf (bmp2.PixelWidth * z2 > bmp1.PixelWidth * z1) Then
            z2 = bmp1.PixelWidth / bmp2.PixelWidth
        End If

        If z1 < 1 Then
            Dim t = bmp1.Resize(Math.Round(bmp1.PixelWidth * z1), Math.Round(bmp1.PixelHeight * z1),
                                If(z1 < 0.5, InterpolationMode.Downscale, InterpolationMode.Cubic))
            bmp1.Dispose()
            bmp1 = t
        End If
        If z2 < 1 Then
            Dim t = bmp2.Resize(Math.Round(bmp2.PixelWidth * z2), Math.Round(bmp2.PixelHeight * z2),
                    If(z2 < 0.5, InterpolationMode.Downscale, InterpolationMode.Cubic))
            bmp2.Dispose()
            bmp2 = t
        End If

        '' 色の差異に関する計算は以下の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
        Dim MaxDelta = LabDistance((0, -128, -128), (100, 127, 127))

        Using bmp = New GcBitmap(pixelSize.Width, pixelSize.Height, False, dpi, dpi)
            Using g = bmp.CreateGraphics(Color.FromArgb(fill))
                '' 見つかった異なるピクセルの総数です。
                Dim differences = 0

                Dim w = bmp1.PixelWidth
                Dim h = Math.Min(bmp1.PixelHeight, bmp2.PixelHeight)

                For i = 0 To w - 1
                    For j = 0 To h - 1
                        Dim px1 As UInteger = bmp1(i, j)
                        Dim px2 As UInteger = bmp2(i, j)

                        '' 差異を計算するために、RGBをCIE L*a*b*空間に変換します。
                        Dim lab1 = ColorXYZ.FromRGB(px1).ToCIELab()
                        If px1 = px2 Then
                            '' 処理を高速化するため、同一ピクセルの場合は2つ目の色の計算をスキップします。
                            '' 同一ピクセルは、差異の参照用として半透明のグレーで描画されます。
                            Dim gray = Math.Round((lab1.L * 255D) / 100D)
                            bmp(i, j) = alphaBack Or (gray << 16) Or (gray << 8) Or gray
                        Else
                            '' それ以外の場合は、2色間の距離を計算します。
                            Dim lab2 = ColorXYZ.FromRGB(px2).ToCIELab()
                            Dim delta = LabDistance((lab1.L, lab1.a, lab1.b), (lab2.L, lab2.a, lab2.b))
                            If delta > fuzz Then
                                Dim alpha = Math.Min(255, ((255 * delta) / MaxDelta) * Q)
                                bmp(i, j) = (alpha << 24) Or highlight
                                differences += 1
                            Else
                                '' 上記の同一ピクセルのコメントを参照してください。
                                Dim gray = Math.Round((lab1.L * 255D) / 100D)
                                bmp(i, j) = alphaBack Or (gray << 16) Or (gray << 8) Or gray
                            End If
                        End If
                    Next
                Next

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

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

                '' ターゲット形式に保存します。
                Dim ms = New MemoryStream()
                If targetMime = MimeTypes.TIFF Then
                    '' TIFFの場合、差異画像と2つのソース画像を別々のページにレンダリングします。
                    tl.Append($"{differences} 個の異なるピクセル (fuzz {fuzz}) が見つかりました。")
                    g.DrawTextLayout(tl, PointF.Empty)
                    Using tw = New GcTiffWriter(ms)
                        tw.AppendFrame(bmp)
                        bmp1.ConvertToOpaque(Color.White)
                        Using g1 = bmp1.CreateGraphics()
                            tl.Clear()
                            tl.MaxWidth = g1.Width
                            tl.DefaultFormat.BackColor = Color.LightYellow
                            tl.Append(Path.GetFileName(path1))
                            g1.DrawTextLayout(tl, PointF.Empty)
                        End Using
                        tw.AppendFrame(bmp1)
                        bmp2.ConvertToOpaque(Color.White)
                        Using g2 = bmp2.CreateGraphics()
                            tl.Clear()
                            tl.MaxWidth = g2.Width
                            tl.Append(Path.GetFileName(path2))
                            g2.DrawTextLayout(tl, PointF.Empty)
                        End Using
                        tw.AppendFrame(bmp2)
                    End Using
                    bmp.SaveAsTiff(ms)
                Else
                    '' その他の形式の場合、差異画像とソース画像をタイル状に並べます。
                    Using tbmp = TileImages(pixelSize, bmp, New Size(w, h), bmp1, bmp2, dpi, tl, Path.GetFileName(path1), Path.GetFileName(path2))
                        Using tg = tbmp.CreateGraphics()
                            tl.TextAlignment = TextAlignment.Trailing
                            tl.MaxWidth = tg.Width
                            tl.Clear()
                            tl.Append($"{differences} 個の異なるピクセル (fuzz {fuzz}) が見つかりました。")
                            tg.DrawTextLayout(tl, PointF.Empty)
                        End Using
                        Select Case targetMime
                            Case MimeTypes.JPEG
                                tbmp.SaveAsJpeg(ms)
                            Case MimeTypes.PNG
                                tbmp.SaveAsPng(ms)
                            Case MimeTypes.BMP
                                tbmp.SaveAsBmp(ms)
                            Case MimeTypes.GIF
                                tbmp.SaveAsGif(ms)
                            Case Else
                                Throw New Exception($"Encoding {targetMime} is not supported.")
                        End Select
                    End Using
                End If
                bmp1.Dispose()
                bmp2.Dispose()

                ms.Seek(0, SeekOrigin.Begin)
                Return ms
            End Using
        End Using
    End Function

    Private Shared Function TileImages(ByVal targetSize As Size,
                                       ByVal diff As GcBitmap,
                                       ByVal diffSize As Size,
                                       ByVal bmp1 As GcBitmap,
                                       ByVal bmp2 As GcBitmap,
                                       ByVal dpi As Single,
                                       ByVal tl As TextLayout,
                                       ByVal name1 As String,
                                       ByVal name2 As String) As GcBitmap

        Dim tSize = New Size(targetSize.Width / 2 - 1, targetSize.Width / 2 - 1)

        Dim bmp = New GcBitmap(targetSize.Width, targetSize.Height, True, dpi, dpi)
        Using diffClip = diff.Clip(New Rectangle(Point.Empty, diffSize))
            bmp1.ConvertToOpaque(Color.White)
            bmp2.ConvertToOpaque(Color.White)

            Dim ts0 = FitSize(diffClip, tSize)
            Dim ts1 = FitSize(bmp1, tSize)
            Dim ts2 = FitSize(bmp2, tSize)
            Using 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)
            End Using

            Dim x As Integer
            If (ts0.IsEmpty) Then
                x = ts1.Width - diffClip.PixelWidth / 2
                bmp.BitBlt(diffClip, x, 0)
            Else
                x = ts1.Width - ts0.Width / 2
                Using tbmp = diffClip.Resize(ts0.Width, ts0.Height, InterpolationMode.Cubic)
                    bmp.BitBlt(tbmp, x, 0)
                End Using
            End If
            If (ts1.IsEmpty) Then
                bmp.BitBlt(bmp1, 0, ts0.Height + 1)
            Else
                Using tbmp = bmp1.Resize(ts1.Width, ts1.Height, InterpolationMode.Cubic)
                    bmp.BitBlt(tbmp, 0, ts0.Height + 1)
                End Using
            End If
            If (ts2.IsEmpty) Then
                bmp.BitBlt(bmp2, ts1.Width + 1, ts0.Height + 1)
            Else
                Using tbmp = bmp2.Resize(ts2.Width, ts2.Height, InterpolationMode.Cubic)
                    bmp.BitBlt(tbmp, ts1.Width + 1, ts0.Height + 1)
                End Using
            End If

            Using 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))
            End Using
        End Using
        Return bmp
    End Function

    Private Shared Function FitSize(ByVal bmp As GcBitmap, ByVal size As Size) As Size
        Dim z As Double = 1
        If bmp.PixelWidth > size.Width Then
            z = size.Width / bmp.PixelWidth
        End If
        If bmp.PixelHeight > size.Height Then
            z = Math.Min(z, size.Height / bmp.PixelHeight)
        End If
        If z < 1 Then
            Return New Size(Math.Round(bmp.PixelWidth * z), Math.Round(bmp.PixelHeight * z))
        Else
            Return Size.Empty '' サイズ変更の必要がないことを示します。
        End If
    End Function

    Public Shared Function LabDistance(lab1 As (L As Double, a As Double, b As Double), lab2 As (L As Double, a As Double, b As Double)) As Double
        Dim dL = lab1.L - lab2.L
        Dim da = lab1.a - lab2.a
        Dim db = lab1.b - lab2.b
        Return Math.Sqrt(dL * dL + da * da + db * db)
    End Function

    Public ReadOnly Property DefaultMime() As String
        Get
            Return MimeTypes.TIFF
        End Get
    End Property

    Public Shared Function GetSampleParamsList() As List(Of String())
        '' 文字列の内容は、名前、説明、情報です。それ以外は任意の文字列であり、このサンプルでは以下の通りです。
        '' - 比較する1つ目のファイル
        '' - 比較する2つ目のファイル
        '' - 比較の曖昧さ(整数)
        Return New List(Of String()) From
            {
                New String() {"Find Differences", "Compare two similar images with few minor differences (fuzz 12)", Nothing,
                    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)", Nothing,
                    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)", Nothing,
                    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)", Nothing,
                    Path.Combine("Resources", "ImageCompare", "TrueTypeHinting-on.png"), Path.Combine("Resources", "ImageCompare", "TrueTypeHinting-off.png"), "1"}
            }
    End Function

    '' XYZ形式のカラー型です。以下のURLにある色の計算式に基づいています。
    '' https://www.easyrgb.com/en/math.php
    Public Class ColorXYZ
        Implements IEquatable(Of ColorXYZ)

        Private ReadOnly _x As Double, _y As Double, _z As Double

        '' D65 CIE 1964 参照値(sRGB、AdobeRGB)
        Const ReferenceX = 94.811
        Const ReferenceY = 100.0
        Const ReferenceZ = 107.304

        Public ReadOnly Property X As Double
            Get
                Return _x
            End Get
        End Property
        Public ReadOnly Property Y As Double
            Get
                Return _y
            End Get
        End Property
        Public ReadOnly Property Z As Double
            Get
                Return _z
            End Get
        End Property

        Private Sub New(x As Double, y As Double, z As Double)
            _x = x
            _y = y
            _z = z
        End Sub

        Public Overloads Function Equals(other As ColorXYZ) As Boolean Implements IEquatable(Of ColorXYZ).Equals
            If CType(other, Object) Is Nothing Then
                Return False
            End If
            Return _x = other._x AndAlso _y = other._y AndAlso _z = other._z
        End Function

        Public Overrides Function Equals(obj As Object) As Boolean
            Return Equals(CType(obj, ColorXYZ))
        End Function

        Public Shared Operator =(obj1 As ColorXYZ, obj2 As ColorXYZ) As Boolean
            Return CType(obj1, Object) IsNot Nothing AndAlso obj1.Equals(obj2)
        End Operator

        Public Shared Operator <>(obj1 As ColorXYZ, obj2 As ColorXYZ) As Boolean
            Return obj1 Is Nothing OrElse Not obj1.Equals(obj2)
        End Operator

        Public Overrides Function GetHashCode() As Integer
            Return _x.GetHashCode() Xor _y.GetHashCode() Xor _z.GetHashCode()
        End Function

        Public Shared Function FromXYZ(x As Double, y As Double, z As Double) As ColorXYZ
            Return New ColorXYZ(x, y, z)
        End Function

        Public Shared Function FromRGB(rgb As Color) As ColorXYZ
            Return FromRGB(rgb.R, rgb.G, rgb.B)
        End Function

        Public Shared Function FromRGB(rgb As UInteger) As ColorXYZ
            Return FromRGB((rgb And &HFF0000UI) >> 16, (rgb And &HFF00UI) >> 8, rgb And &HFFUI)
        End Function

        Public Shared Function FromRGB(r As Integer, g As Integer, b As Integer) As ColorXYZ
            Dim var_R = (r / 255D)
            Dim var_G = (g / 255D)
            Dim var_B = (b / 255D)

            If var_R > 0.04045 Then
                var_R = Math.Pow((var_R + 0.055) / 1.055, 2.4)
            Else
                var_R = var_R / 12.92
            End If
            If var_G > 0.04045 Then
                var_G = Math.Pow((var_G + 0.055) / 1.055, 2.4)
            Else
                var_G /= 12.92
            End If
            If var_B > 0.04045 Then
                var_B = Math.Pow((var_B + 0.055) / 1.055, 2.4)
            Else
                var_B /= 12.92
            End If

            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
            )
        End Function

        Public Shared Function FromCIELab(lab As (L As Double, a As Double, b As Double)) As ColorXYZ
            Return FromCIELab(lab.L, lab.a, lab.b)
        End Function

        Public Shared Function FromCIELab(L As Double, a As Double, b As Double) As ColorXYZ
            Dim var_Y = (L + 16) / 116D
            Dim var_X = a / 500D + var_Y
            Dim var_Z = var_Y - b / 200D

            If Math.Pow(var_Y, 3) > 0.008856 Then
                var_Y = Math.Pow(var_Y, 3)
            Else
                var_Y = (var_Y - 16D / 116D) / 7.787
            End If
            If Math.Pow(var_X, 3) > 0.008856 Then
                var_X = Math.Pow(var_X, 3)
            Else
                var_X = (var_X - 16D / 116D) / 7.787
            End If
            If Math.Pow(var_Z, 3) > 0.008856 Then
                var_Z = Math.Pow(var_Z, 3)
            Else
                var_Z = (var_Z - 16D / 116D) / 7.787
            End If

            Return New ColorXYZ(
                var_X * ReferenceX,
                var_Y * ReferenceY,
                var_Z * ReferenceZ
            )
        End Function

        Public Function ToRGB() As Color
            Dim var_X = _x / 100
            Dim var_Y = _y / 100
            Dim var_Z = _z / 100

            Dim var_R = var_X * 3.2406 + var_Y * -1.5372 + var_Z * -0.4986
            Dim var_G = var_X * -0.9689 + var_Y * 1.8758 + var_Z * 0.0415
            Dim var_B = var_X * 0.0557 + var_Y * -0.204 + var_Z * 1.057

            If var_R > 0.0031308 Then
                var_R = 1.055 * Math.Pow(var_R, (1 / 2.4)) - 0.055
            Else
                var_R = 12.92 * var_R
            End If
            If var_G > 0.0031308 Then
                var_G = 1.055 * Math.Pow(var_G, (1 / 2.4)) - 0.055
            Else
                var_G = 12.92 * var_G
            End If
            If var_B > 0.0031308 Then
                var_B = 1.055 * Math.Pow(var_B, (1 / 2.4)) - 0.055
            Else
                var_B = 12.92 * var_B
            End If

            Return Color.FromArgb(
                Math.Round(var_R * 255),
                Math.Round(var_G * 255),
                Math.Round(var_B * 255)
            )
        End Function

        Public Function ToCIELab() As (L As Double, a As Double, b As Double)
            Dim var_X = _x / ReferenceX
            Dim var_Y = _y / ReferenceY
            Dim var_Z = _z / ReferenceZ

            If (var_X > 0.008856) Then
                var_X = Math.Pow(var_X, 1 / 3D)
            Else
                var_X = (7.787 * var_X) + (16D / 116D)
            End If
            If (var_Y > 0.008856) Then
                var_Y = Math.Pow(var_Y, 1 / 3D)
            Else
                var_Y = (7.787 * var_Y) + (16D / 116D)
            End If
            If (var_Z > 0.008856) Then
                var_Z = Math.Pow(var_Z, 1 / 3D)
            Else
                var_Z = (7.787 * var_Z) + (16D / 116D)
            End If

            Return (
                (116 * var_Y) - 16,
                500 * (var_X - var_Y),
                200 * (var_Y - var_Z)
            )
        End Function
    End Class
End Class