ImageCompare.vb
''
'' This code is part of GrapeCity Documents for Imaging samples.
'' Copyright (c) GrapeCity, 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

'' 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
    Function GenerateImageStream(
                ByVal targetEncoding As ImageEncoding,
                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)
        '' Comparison fuzziness (from 0 to MaxDelta):
        Dim fuzz As Integer
        If sampleParams Is Nothing Then
            fuzz = 12
        Else
            fuzz = Integer.Parse(sampleParams(5))
        End If
        '' Different pixels will be this color:
        Dim highlight = Color.Magenta.ToArgb()
        '' Fill for empty areas not occupied by any image:
        Dim fill = 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):
        Dim Q = (255D / (fuzz + 1))
        '' Reference image transparency:
        Const alphaBack = 55 << 24

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

        '' If images are too large, resize to fit target width/height:
        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

        '' Resize images to same (minimum) widths:
        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

        '' 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
        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))
                '' Total number of different pixels found:
                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)

                        '' Convert RGB to CIE L*a*b* space to calc the difference:
                        Dim lab1 = ColorXYZ.FromRGB(px1).ToCIELab()
                        If px1 = px2 Then
                            '' 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:
                            Dim gray = Math.Round((lab1.L * 255D) / 100D)
                            bmp(i, j) = alphaBack Or (gray << 16) Or (gray << 8) Or gray
                        Else
                            '' Otherwise, calc the distance between the two colors:
                            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
                                '' See equal pixels comment above:
                                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

                '' For consistency, convert the difference to opaque:
                bmp.ConvertToOpaque(Color.White)

                '' Text layout for info texts:
                Dim tl = New TextLayout(g.Resolution) With {.TextAlignment = TextAlignment.Trailing}
                tl.DefaultFormat.Font = 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:
                Dim ms = New MemoryStream()
                If targetEncoding = ImageEncoding.Tiff Then
                    '' 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 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
                    '' For other formats, tile the diff and sources:
                    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($"Found {differences} different pixels (fuzz {fuzz}).")
                            tg.DrawTextLayout(tl, PointF.Empty)
                        End Using
                        Select Case targetEncoding
                            Case ImageEncoding.Jpeg
                                tbmp.SaveAsJpeg(ms)
                            Case ImageEncoding.Png
                                tbmp.SaveAsPng(ms)
                            Case ImageEncoding.Bmp
                                tbmp.SaveAsBmp(ms)
                            Case ImageEncoding.Gif
                                tbmp.SaveAsGif(ms)
                            Case Else
                                Throw New Exception($"Encoding {targetEncoding} 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("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))
            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 '' indicates that no resizing is needed
        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 DefaultEncoding() As ImageEncoding
        Get
            Return ImageEncoding.Tiff
        End Get
    End Property

    Public Shared Function GetSampleParamsList() As List(Of 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):
        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

    '' An XYZ color type, based on color math from
    '' 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 ref values (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