From fac7c305afe8c4ce76dfe3ebc918c58ca471f9ec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 02:38:34 +0000 Subject: [PATCH 01/11] Upgrade project to .NET 10 Co-authored-by: wforney <79032+wforney@users.noreply.github.com> --- ImageComparator/ImageComparator.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ImageComparator/ImageComparator.csproj b/ImageComparator/ImageComparator.csproj index 836b760..7e16ea1 100644 --- a/ImageComparator/ImageComparator.csproj +++ b/ImageComparator/ImageComparator.csproj @@ -2,7 +2,7 @@ Exe - net8.0 + net10.0 enable enable true @@ -20,7 +20,7 @@ - + From 621502e776f83fb8c6fb2e522d96b977cef40b01 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 03:03:03 +0000 Subject: [PATCH 02/11] Add strategy modes, auto-selection, benchmarks, and tests Co-authored-by: wforney <79032+wforney@users.noreply.github.com> --- .github/workflows/ci.yml | 5 +- .../ImageComparator.Tests.csproj | 14 ++ ImageComparator.Tests/Program.cs | 121 +++++++++++++ ImageComparator.sln | 28 ++- ImageComparator/BitmapCompare.cs | 160 ++++++++++++++++-- ImageComparator/ComparisonBenchmark.cs | 77 +++++++++ ImageComparator/ComparisonStrategy.cs | 18 ++ ImageComparator/ComparisonStrategySelector.cs | 30 ++++ ImageComparator/Program.cs | 155 +++++++++++++++-- README.md | 25 +++ 10 files changed, 601 insertions(+), 32 deletions(-) create mode 100644 ImageComparator.Tests/ImageComparator.Tests.csproj create mode 100644 ImageComparator.Tests/Program.cs create mode 100644 ImageComparator/ComparisonBenchmark.cs create mode 100644 ImageComparator/ComparisonStrategy.cs create mode 100644 ImageComparator/ComparisonStrategySelector.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7260216..2c1cea5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: "8.0.x" + dotnet-version: "10.0.x" - name: Restore dependencies run: dotnet restore @@ -27,5 +27,8 @@ jobs: - name: Build run: dotnet build --no-restore --configuration Release + - name: Run tests + run: dotnet run --no-build --configuration Release --project ImageComparator.Tests/ImageComparator.Tests.csproj + - name: Publish (self-contained) run: dotnet publish --no-build --configuration Release --output publish diff --git a/ImageComparator.Tests/ImageComparator.Tests.csproj b/ImageComparator.Tests/ImageComparator.Tests.csproj new file mode 100644 index 0000000..ede3bd0 --- /dev/null +++ b/ImageComparator.Tests/ImageComparator.Tests.csproj @@ -0,0 +1,14 @@ + + + + + + + + Exe + net10.0 + enable + enable + + + diff --git a/ImageComparator.Tests/Program.cs b/ImageComparator.Tests/Program.cs new file mode 100644 index 0000000..5773050 --- /dev/null +++ b/ImageComparator.Tests/Program.cs @@ -0,0 +1,121 @@ +namespace ImageComparator.Tests; + +using System.Drawing; +using System.Runtime.Versioning; +using ImageComparator; + +[SupportedOSPlatform("windows")] +internal static class Program +{ + private static int Main() + { + if (!OperatingSystem.IsWindows()) + { + Console.WriteLine("Tests skipped: System.Drawing comparisons are Windows-only."); + return 0; + } + + var tests = new (string Name, Action Execute)[] + { + ("Strategies rank identical above different", StrategiesRankIdenticalAboveDifferent), + ("Auto strategy picks dHash for very large images", AutoStrategyPicksDifferenceHashForLargeImages), + ("Benchmark returns concrete strategies", BenchmarkReturnsConcreteStrategies), + }; + + var failures = new List(); + + foreach (var (name, execute) in tests) + { + try + { + execute(); + Console.WriteLine($"PASS: {name}"); + } + catch (Exception ex) + { + failures.Add($"{name}: {ex.Message}"); + Console.WriteLine($"FAIL: {name}"); + } + } + + if (failures.Count == 0) + { + return 0; + } + + Console.WriteLine(); + Console.WriteLine("Failures:"); + foreach (var failure in failures) + { + Console.WriteLine($"- {failure}"); + } + + return 1; + } + + private static void StrategiesRankIdenticalAboveDifferent() + { + using var imageA = CreateSolidBitmap(Color.Red); + using var imageB = CreateSolidBitmap(Color.Red); + using var imageC = CreateSolidBitmap(Color.Blue); + + var strategies = new[] + { + ComparisonStrategy.LegacyDominantChannel, + ComparisonStrategy.MeanAbsoluteDifference, + ComparisonStrategy.DifferenceHash, + }; + + foreach (var strategy in strategies) + { + var comparer = new BitmapCompare(strategy); + var sameSimilarity = comparer.GetSimilarity(imageA, imageB); + var differentSimilarity = comparer.GetSimilarity(imageA, imageC); + + AssertTrue( + sameSimilarity > differentSimilarity, + $"{strategy} expected sameSimilarity > differentSimilarity but was {sameSimilarity:F4} <= {differentSimilarity:F4}"); + } + } + + private static void AutoStrategyPicksDifferenceHashForLargeImages() + { + using var imageA = CreateSolidBitmap(Color.Green, width: 2400, height: 1600); + using var imageB = CreateSolidBitmap(Color.Green, width: 2400, height: 1600); + var comparer = new BitmapCompare(ComparisonStrategy.Auto); + + _ = comparer.GetSimilarity(imageA, imageB); + + AssertTrue( + comparer.LastStrategyUsed == ComparisonStrategy.DifferenceHash, + $"Expected {ComparisonStrategy.DifferenceHash} but got {comparer.LastStrategyUsed}"); + } + + private static void BenchmarkReturnsConcreteStrategies() + { + using var imageA = CreateSolidBitmap(Color.White); + using var imageB = CreateSolidBitmap(Color.Black); + var results = ComparisonBenchmark.Run(imageA, imageB, iterations: 3); + + AssertTrue(results.Count == 3, $"Expected 3 benchmark rows, got {results.Count}"); + AssertTrue( + results.All(result => result.Strategy != ComparisonStrategy.Auto), + "Benchmark should return only concrete strategies."); + } + + private static Bitmap CreateSolidBitmap(Color color, int width = 64, int height = 64) + { + var bitmap = new Bitmap(width, height); + using var graphics = Graphics.FromImage(bitmap); + graphics.Clear(color); + return bitmap; + } + + private static void AssertTrue(bool condition, string message) + { + if (!condition) + { + throw new InvalidOperationException(message); + } + } +} diff --git a/ImageComparator.sln b/ImageComparator.sln index 16051ed..2e082de 100644 --- a/ImageComparator.sln +++ b/ImageComparator.sln @@ -1,20 +1,46 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ImageComparator", "ImageComparator\ImageComparator.csproj", "{04C06855-4D15-4E39-B460-257E4CC0B65A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ImageComparator.Tests", "ImageComparator.Tests\ImageComparator.Tests.csproj", "{81EF9DFF-D736-4530-B67E-F942E7DBB5C9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {04C06855-4D15-4E39-B460-257E4CC0B65A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {04C06855-4D15-4E39-B460-257E4CC0B65A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {04C06855-4D15-4E39-B460-257E4CC0B65A}.Debug|x64.ActiveCfg = Debug|Any CPU + {04C06855-4D15-4E39-B460-257E4CC0B65A}.Debug|x64.Build.0 = Debug|Any CPU + {04C06855-4D15-4E39-B460-257E4CC0B65A}.Debug|x86.ActiveCfg = Debug|Any CPU + {04C06855-4D15-4E39-B460-257E4CC0B65A}.Debug|x86.Build.0 = Debug|Any CPU {04C06855-4D15-4E39-B460-257E4CC0B65A}.Release|Any CPU.ActiveCfg = Release|Any CPU {04C06855-4D15-4E39-B460-257E4CC0B65A}.Release|Any CPU.Build.0 = Release|Any CPU + {04C06855-4D15-4E39-B460-257E4CC0B65A}.Release|x64.ActiveCfg = Release|Any CPU + {04C06855-4D15-4E39-B460-257E4CC0B65A}.Release|x64.Build.0 = Release|Any CPU + {04C06855-4D15-4E39-B460-257E4CC0B65A}.Release|x86.ActiveCfg = Release|Any CPU + {04C06855-4D15-4E39-B460-257E4CC0B65A}.Release|x86.Build.0 = Release|Any CPU + {81EF9DFF-D736-4530-B67E-F942E7DBB5C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {81EF9DFF-D736-4530-B67E-F942E7DBB5C9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {81EF9DFF-D736-4530-B67E-F942E7DBB5C9}.Debug|x64.ActiveCfg = Debug|Any CPU + {81EF9DFF-D736-4530-B67E-F942E7DBB5C9}.Debug|x64.Build.0 = Debug|Any CPU + {81EF9DFF-D736-4530-B67E-F942E7DBB5C9}.Debug|x86.ActiveCfg = Debug|Any CPU + {81EF9DFF-D736-4530-B67E-F942E7DBB5C9}.Debug|x86.Build.0 = Debug|Any CPU + {81EF9DFF-D736-4530-B67E-F942E7DBB5C9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {81EF9DFF-D736-4530-B67E-F942E7DBB5C9}.Release|Any CPU.Build.0 = Release|Any CPU + {81EF9DFF-D736-4530-B67E-F942E7DBB5C9}.Release|x64.ActiveCfg = Release|Any CPU + {81EF9DFF-D736-4530-B67E-F942E7DBB5C9}.Release|x64.Build.0 = Release|Any CPU + {81EF9DFF-D736-4530-B67E-F942E7DBB5C9}.Release|x86.ActiveCfg = Release|Any CPU + {81EF9DFF-D736-4530-B67E-F942E7DBB5C9}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/ImageComparator/BitmapCompare.cs b/ImageComparator/BitmapCompare.cs index ec09cb8..f158f9d 100644 --- a/ImageComparator/BitmapCompare.cs +++ b/ImageComparator/BitmapCompare.cs @@ -2,26 +2,86 @@ namespace ImageComparator; using System.Drawing; using System.Drawing.Imaging; +using System.Numerics; using System.Runtime.Versioning; /// Compares two bitmaps by their dominant colour channel average. [SupportedOSPlatform("windows")] public class BitmapCompare : IBitmapCompare { + /// Initializes a comparer with automatic strategy selection. + public BitmapCompare() + : this(ComparisonStrategy.Auto) + { + } + + /// Initializes a comparer with a fixed strategy. + public BitmapCompare(ComparisonStrategy strategy) + { + Strategy = strategy; + } + + /// Gets the configured comparison strategy. + public ComparisonStrategy Strategy { get; } + + /// Gets the strategy used in the most recent comparison. + public ComparisonStrategy LastStrategyUsed { get; private set; } + /// public double GetSimilarity(Bitmap a, Bitmap b) { - var dataA = ProcessBitmap(a); - var dataB = ProcessBitmap(b); + ArgumentNullException.ThrowIfNull(a); + ArgumentNullException.ThrowIfNull(b); + + var chosenStrategy = Strategy == ComparisonStrategy.Auto + ? ComparisonStrategySelector.SelectBest(a, b) + : Strategy; - var maxA = (a.Width * 3) * a.Height; - var maxB = (b.Width * 3) * b.Height; + LastStrategyUsed = chosenStrategy; + + return chosenStrategy switch + { + ComparisonStrategy.LegacyDominantChannel => GetLegacySimilarity(a, b), + ComparisonStrategy.MeanAbsoluteDifference => GetMeanAbsoluteDifferenceSimilarity(a, b), + ComparisonStrategy.DifferenceHash => GetDifferenceHashSimilarity(a, b), + _ => throw new InvalidOperationException($"Unsupported strategy '{chosenStrategy}'."), + }; + } + + /// Returns whether a pair is similar using strategy-specific default thresholds. + public bool IsSimilar(Bitmap a, Bitmap b, out double similarity, double? threshold = null) + { + similarity = GetSimilarity(a, b); + var strategyThreshold = threshold ?? GetDefaultThreshold(LastStrategyUsed); + return similarity > strategyThreshold; + } + + /// Gets default threshold by strategy. + public static double GetDefaultThreshold(ComparisonStrategy strategy) => strategy switch + { + ComparisonStrategy.LegacyDominantChannel => 0.75, + ComparisonStrategy.MeanAbsoluteDifference => 0.90, + ComparisonStrategy.DifferenceHash => 0.82, + ComparisonStrategy.Auto => 0.90, + _ => 0.90, + }; + + private static double GetLegacySimilarity(Bitmap a, Bitmap b) + { + using var normalizedA = Ensure24Bpp(a, a.Width, a.Height); + using var normalizedB = Ensure24Bpp(b, b.Width, b.Height); + + var dataA = ProcessBitmap(normalizedA); + var dataB = ProcessBitmap(normalizedB); + + var maxA = (normalizedA.Width * 3) * normalizedA.Height; + var maxB = (normalizedB.Width * 3) * normalizedB.Height; double result = dataA.GetLargest() switch { - 1 => (double)(Math.Abs(dataA.R / maxA) - Math.Abs(dataB.R / maxB)) / 2, - 2 => (double)(Math.Abs(dataA.G / maxA) - Math.Abs(dataB.G / maxB)) / 2, - _ => (double)(Math.Abs(dataA.B / maxA) - Math.Abs(dataB.B / maxB)) / 2, + 1 => (Math.Abs((double)dataA.R / maxA) - Math.Abs((double)dataB.R / maxB)) / 2, + 2 => (Math.Abs((double)dataA.G / maxA) - Math.Abs((double)dataB.G / maxB)) / 2, + _ => (Math.Abs((double)dataA.B / maxA) - Math.Abs((double)dataB.B / maxB)) / 2, }; result = Math.Abs((result + 100) / 100); @@ -34,10 +94,84 @@ public double GetSimilarity(Bitmap a, Bitmap b) return result; } - private static RGBData ProcessBitmap(Bitmap a) + private static double GetMeanAbsoluteDifferenceSimilarity(Bitmap a, Bitmap b) + { + const int targetWidth = 64; + const int targetHeight = 64; + + using var normalizedA = Ensure24Bpp(a, targetWidth, targetHeight); + using var normalizedB = Ensure24Bpp(b, targetWidth, targetHeight); + + double totalDiff = 0; + const double maxDiffPerPixel = 255 * 3; + + for (var y = 0; y < targetHeight; y++) + { + for (var x = 0; x < targetWidth; x++) + { + var colorA = normalizedA.GetPixel(x, y); + var colorB = normalizedB.GetPixel(x, y); + totalDiff += Math.Abs(colorA.R - colorB.R); + totalDiff += Math.Abs(colorA.G - colorB.G); + totalDiff += Math.Abs(colorA.B - colorB.B); + } + } + + var averageDiff = totalDiff / (targetWidth * targetHeight); + return Math.Clamp(1.0 - (averageDiff / maxDiffPerPixel), 0.0, 1.0); + } + + private static double GetDifferenceHashSimilarity(Bitmap a, Bitmap b) + { + var hashA = ComputeDifferenceHash(a); + var hashB = ComputeDifferenceHash(b); + var bitDistance = BitOperations.PopCount(hashA ^ hashB); + return 1.0 - (bitDistance / 64.0); + } + + private static ulong ComputeDifferenceHash(Bitmap source) + { + const int width = 9; + const int height = 8; + + using var normalized = Ensure24Bpp(source, width, height); + ulong hash = 0; + var bitIndex = 0; + + for (var y = 0; y < height; y++) + { + for (var x = 0; x < width - 1; x++) + { + var left = normalized.GetPixel(x, y); + var right = normalized.GetPixel(x + 1, y); + + var leftLuma = (left.R * 299) + (left.G * 587) + (left.B * 114); + var rightLuma = (right.R * 299) + (right.G * 587) + (right.B * 114); + + if (leftLuma > rightLuma) + { + hash |= 1UL << bitIndex; + } + + bitIndex++; + } + } + + return hash; + } + + private static Bitmap Ensure24Bpp(Bitmap source, int width, int height) + { + var normalized = new Bitmap(width, height, PixelFormat.Format24bppRgb); + using var graphics = Graphics.FromImage(normalized); + graphics.DrawImage(source, new Rectangle(0, 0, width, height)); + return normalized; + } + + private static RGBData ProcessBitmap(Bitmap source) { - var bmpData = a.LockBits( - new Rectangle(0, 0, a.Width, a.Height), + var bmpData = source.LockBits( + new Rectangle(0, 0, source.Width, source.Height), ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb); @@ -47,10 +181,10 @@ private static RGBData ProcessBitmap(Bitmap a) unsafe { var p = (byte*)(void*)ptr; - var width = a.Width * 3; + var width = source.Width * 3; var offset = bmpData.Stride - width; - for (var y = 0; y < a.Height; ++y) + for (var y = 0; y < source.Height; ++y) { for (var x = 0; x < width; ++x) { @@ -64,7 +198,7 @@ private static RGBData ProcessBitmap(Bitmap a) } } - a.UnlockBits(bmpData); + source.UnlockBits(bmpData); return data; } diff --git a/ImageComparator/ComparisonBenchmark.cs b/ImageComparator/ComparisonBenchmark.cs new file mode 100644 index 0000000..2a66a27 --- /dev/null +++ b/ImageComparator/ComparisonBenchmark.cs @@ -0,0 +1,77 @@ +namespace ImageComparator; + +using System.Diagnostics; +using System.Drawing; +using System.Runtime.Versioning; + +/// Benchmark helpers for image comparison strategies. +[SupportedOSPlatform("windows")] +public static class ComparisonBenchmark +{ + /// Benchmarks all concrete strategies for a file pair. + public static IReadOnlyList RunForFiles( + string firstImagePath, + string secondImagePath, + int iterations = 25) + { + ArgumentException.ThrowIfNullOrWhiteSpace(firstImagePath); + ArgumentException.ThrowIfNullOrWhiteSpace(secondImagePath); + + using var a = new Bitmap(firstImagePath); + using var b = new Bitmap(secondImagePath); + return Run(a, b, iterations); + } + + /// Benchmarks all concrete strategies for a bitmap pair. + public static IReadOnlyList Run(Bitmap a, Bitmap b, int iterations = 25) + { + ArgumentNullException.ThrowIfNull(a); + ArgumentNullException.ThrowIfNull(b); + + if (iterations <= 0) + { + throw new ArgumentOutOfRangeException(nameof(iterations), "Iterations must be greater than zero."); + } + + var strategies = new[] + { + ComparisonStrategy.LegacyDominantChannel, + ComparisonStrategy.MeanAbsoluteDifference, + ComparisonStrategy.DifferenceHash, + }; + + var results = new List(strategies.Length); + + foreach (var strategy in strategies) + { + var comparer = new BitmapCompare(strategy); + double similarityTotal = 0; + + var sw = Stopwatch.StartNew(); + for (var i = 0; i < iterations; i++) + { + similarityTotal += comparer.GetSimilarity(a, b); + } + + sw.Stop(); + results.Add(new ComparisonBenchmarkResult( + strategy, + similarityTotal / iterations, + sw.Elapsed.TotalMilliseconds / iterations)); + } + + return results + .OrderBy(result => result.AverageMillisecondsPerComparison) + .ToList(); + } +} + +/// Strategy benchmark output. +/// The benchmarked strategy. +/// Average similarity produced during the benchmark. +/// Average elapsed milliseconds per comparison. +public sealed record ComparisonBenchmarkResult( + ComparisonStrategy Strategy, + double AverageSimilarity, + double AverageMillisecondsPerComparison); + diff --git a/ImageComparator/ComparisonStrategy.cs b/ImageComparator/ComparisonStrategy.cs new file mode 100644 index 0000000..0af5f2b --- /dev/null +++ b/ImageComparator/ComparisonStrategy.cs @@ -0,0 +1,18 @@ +namespace ImageComparator; + +/// Supported image comparison strategies. +public enum ComparisonStrategy +{ + /// Legacy dominant-channel algorithm. + LegacyDominantChannel, + + /// Mean absolute per-channel pixel difference on normalized images. + MeanAbsoluteDifference, + + /// Difference hash (dHash) based perceptual comparison. + DifferenceHash, + + /// Automatically choose a strategy based on image characteristics. + Auto, +} + diff --git a/ImageComparator/ComparisonStrategySelector.cs b/ImageComparator/ComparisonStrategySelector.cs new file mode 100644 index 0000000..c8798bf --- /dev/null +++ b/ImageComparator/ComparisonStrategySelector.cs @@ -0,0 +1,30 @@ +namespace ImageComparator; + +using System.Drawing; +using System.Runtime.Versioning; + +/// Selects an appropriate comparison strategy for a pair of images. +[SupportedOSPlatform("windows")] +public static class ComparisonStrategySelector +{ + /// Returns the strategy best suited for the provided images. + public static ComparisonStrategy SelectBest(Bitmap a, Bitmap b) + { + var maxPixels = Math.Max(a.Width * a.Height, b.Width * b.Height); + if (maxPixels >= 1920 * 1080) + { + return ComparisonStrategy.DifferenceHash; + } + + var ratioA = a.Width / (double)a.Height; + var ratioB = b.Width / (double)b.Height; + var ratioDelta = Math.Abs(ratioA - ratioB); + if (ratioDelta > 0.20) + { + return ComparisonStrategy.DifferenceHash; + } + + return ComparisonStrategy.MeanAbsoluteDifference; + } +} + diff --git a/ImageComparator/Program.cs b/ImageComparator/Program.cs index 4bfd840..c44e8e9 100644 --- a/ImageComparator/Program.cs +++ b/ImageComparator/Program.cs @@ -9,24 +9,18 @@ internal static class Program private static void Main(string[] args) { var endDate = new DateTime(2010, 4, 2, 20, 30, 0); + var options = ParseArguments(args); - string? goodDirectory; - string? badDirectory; - string fileType; + string? goodDirectory = options.GoodDirectory; + string? badDirectory = options.BadDirectory; + var fileType = options.FileType; - if (args.Length == 4) - { - goodDirectory = string.IsNullOrEmpty(args[1]) ? string.Empty : args[1]; - badDirectory = string.IsNullOrEmpty(args[2]) ? string.Empty : args[2]; - fileType = string.IsNullOrEmpty(args[3]) ? "jpg" : args[3]; - } - else + if (goodDirectory is null || badDirectory is null) { Console.WriteLine("Please input the source image directory (good images):"); goodDirectory = Console.ReadLine(); Console.WriteLine("Please input the destination image directory (incorrect images):"); badDirectory = Console.ReadLine(); - fileType = "jpg"; } if (goodDirectory is null || badDirectory is null) @@ -40,7 +34,9 @@ private static void Main(string[] args) return; } + var comparer = new BitmapCompare(options.Strategy); var processed = new List(); + var benchmarkPrinted = false; foreach (var goodFile in GetFiles(goodDirectory, fileType) ?? []) { @@ -64,7 +60,20 @@ private static void Main(string[] args) continue; } - if (!ImageCompare(Path.Combine(goodDirectory, goodFile), Path.Combine(badDirectory, badFile))) + if (options.Benchmark && !benchmarkPrinted) + { + PrintBenchmark( + Path.Combine(goodDirectory, goodFile), + Path.Combine(badDirectory, badFile), + options.BenchmarkIterations); + benchmarkPrinted = true; + } + + if (!ImageCompare( + Path.Combine(goodDirectory, goodFile), + Path.Combine(badDirectory, badFile), + comparer, + out var similarity)) { continue; } @@ -73,7 +82,7 @@ private static void Main(string[] args) { File.Copy(Path.Combine(badDirectory, badFile), Path.Combine(badDirectory, goodFile), true); processed.Add(badFile); - Console.WriteLine("\r\n{0} --> {1}", badFile, goodFile); + Console.WriteLine("\r\n{0} --> {1} ({2:F3} via {3})", badFile, goodFile, similarity, comparer.LastStrategyUsed); break; } catch (IOException) @@ -110,6 +119,108 @@ private static void Main(string[] args) Console.ReadLine(); } + private static CommandLineOptions ParseArguments(string[] args) + { + var nonOptionArguments = new List(); + var strategy = ComparisonStrategy.Auto; + var benchmark = false; + var benchmarkIterations = 25; + + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + + if (arg.StartsWith("--strategy=", StringComparison.OrdinalIgnoreCase)) + { + strategy = ParseStrategy(arg.Split('=', 2)[1]); + continue; + } + + if (arg.Equals("--strategy", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length) + { + strategy = ParseStrategy(args[++i]); + continue; + } + + if (arg.Equals("--benchmark", StringComparison.OrdinalIgnoreCase)) + { + benchmark = true; + continue; + } + + if (arg.StartsWith("--benchmark-iterations=", StringComparison.OrdinalIgnoreCase)) + { + benchmarkIterations = int.TryParse(arg.Split('=', 2)[1], out var parsed) && parsed > 0 + ? parsed + : benchmarkIterations; + continue; + } + + nonOptionArguments.Add(arg); + } + + string? goodDirectory = null; + string? badDirectory = null; + var fileType = "jpg"; + + if (nonOptionArguments.Count == 4) + { + // Preserve compatibility with the original argument style. + goodDirectory = nonOptionArguments[1]; + badDirectory = nonOptionArguments[2]; + fileType = string.IsNullOrWhiteSpace(nonOptionArguments[3]) ? "jpg" : nonOptionArguments[3]; + } + else if (nonOptionArguments.Count >= 2) + { + goodDirectory = nonOptionArguments[0]; + badDirectory = nonOptionArguments[1]; + fileType = nonOptionArguments.Count >= 3 && !string.IsNullOrWhiteSpace(nonOptionArguments[2]) + ? nonOptionArguments[2] + : "jpg"; + } + + return new CommandLineOptions(goodDirectory, badDirectory, fileType, strategy, benchmark, benchmarkIterations); + } + + private static ComparisonStrategy ParseStrategy(string value) => value.ToLowerInvariant() switch + { + "legacy" => ComparisonStrategy.LegacyDominantChannel, + "mad" => ComparisonStrategy.MeanAbsoluteDifference, + "mean" => ComparisonStrategy.MeanAbsoluteDifference, + "dhash" => ComparisonStrategy.DifferenceHash, + "auto" => ComparisonStrategy.Auto, + _ => ComparisonStrategy.Auto, + }; + + private static void PrintBenchmark(string firstImagePath, string secondImagePath, int iterations) + { + try + { + var results = ComparisonBenchmark.RunForFiles(firstImagePath, secondImagePath, iterations); + Console.WriteLine(); + Console.WriteLine("Benchmark results ({0} iterations):", iterations); + foreach (var result in results) + { + Console.WriteLine( + "- {0}: {1:F4} ms/comparison, avg similarity {2:F4}", + result.Strategy, + result.AverageMillisecondsPerComparison, + result.AverageSimilarity); + } + + if (results.Count > 0) + { + Console.WriteLine("Fastest strategy: {0}", results[0].Strategy); + } + + Console.WriteLine(); + } + catch (Exception ex) when (ex is IOException or ArgumentException) + { + Console.WriteLine("Benchmark skipped: {0}", ex.Message); + } + } + private static List? GetFiles(string directory, string filetype) { var di = new DirectoryInfo(directory); @@ -124,14 +235,16 @@ private static void Main(string[] args) } } - private static bool ImageCompare(string firstImagePath, string secondImagePath) + private static bool ImageCompare( + string firstImagePath, + string secondImagePath, + BitmapCompare comparer, + out double similarity) { - var comparer = new BitmapCompare(); using var comImage = new Bitmap(firstImagePath); using var fileBitmap = new Bitmap( ThumbnailGenerator.GetThumbnailFromFile(secondImagePath, comImage.Width, comImage.Height, true, true)); - var sim = comparer.GetSimilarity(comImage, fileBitmap); - return Math.Round(sim, 3) > 0.75; + return comparer.IsSimilar(comImage, fileBitmap, out similarity); } private static bool DateCompare(string firstImagePath, string secondImagePath) @@ -142,4 +255,12 @@ private static bool DateCompare(string firstImagePath, string secondImagePath) var creationDate2Max = creationDate2.AddMinutes(5); return creationDate1 >= creationDate2Min && creationDate1 <= creationDate2Max; } + + private sealed record CommandLineOptions( + string? GoodDirectory, + string? BadDirectory, + string FileType, + ComparisonStrategy Strategy, + bool Benchmark, + int BenchmarkIterations); } diff --git a/README.md b/README.md index 93ad855..f80f346 100644 --- a/README.md +++ b/README.md @@ -2,3 +2,28 @@ An image comparing console application we built a few years ago. It also has a thumbnailer. + +## Comparison strategies + +The comparer now supports multiple strategies: + +- `legacy` (original dominant-channel logic) +- `mad` (mean absolute pixel difference on normalized images) +- `dhash` (perceptual difference hash) +- `auto` (default; chooses a strategy based on image characteristics) + +## Usage + +```bash +dotnet run --project /home/runner/work/ImageComparator/ImageComparator/ImageComparator/ImageComparator.csproj -- [fileType] [--strategy=auto|legacy|mad|dhash] [--benchmark] [--benchmark-iterations=25] +``` + +When `--benchmark` is enabled, the app prints per-strategy timing and similarity output for the first compared image pair. + +## Tests + +Run: + +```bash +dotnet run --project /home/runner/work/ImageComparator/ImageComparator/ImageComparator.Tests/ImageComparator.Tests.csproj +``` From 130e723e84022dc3b139770efbc0ad7eeeece09e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 03:08:34 +0000 Subject: [PATCH 03/11] Finalize strategy implementation with benchmarks and tests Co-authored-by: wforney <79032+wforney@users.noreply.github.com> --- ImageComparator/BitmapCompare.cs | 76 +++++++++++++++++++------- ImageComparator/ComparisonBenchmark.cs | 2 +- ImageComparator/Program.cs | 28 ++++++---- README.md | 4 +- 4 files changed, 74 insertions(+), 36 deletions(-) diff --git a/ImageComparator/BitmapCompare.cs b/ImageComparator/BitmapCompare.cs index f158f9d..b87fd00 100644 --- a/ImageComparator/BitmapCompare.cs +++ b/ImageComparator/BitmapCompare.cs @@ -105,17 +105,45 @@ private static double GetMeanAbsoluteDifferenceSimilarity(Bitmap a, Bitmap b) double totalDiff = 0; const double maxDiffPerPixel = 255 * 3; - for (var y = 0; y < targetHeight; y++) + var dataA = normalizedA.LockBits( + new Rectangle(0, 0, targetWidth, targetHeight), + ImageLockMode.ReadOnly, + PixelFormat.Format24bppRgb); + + var dataB = normalizedB.LockBits( + new Rectangle(0, 0, targetWidth, targetHeight), + ImageLockMode.ReadOnly, + PixelFormat.Format24bppRgb); + + try { - for (var x = 0; x < targetWidth; x++) + unsafe { - var colorA = normalizedA.GetPixel(x, y); - var colorB = normalizedB.GetPixel(x, y); - totalDiff += Math.Abs(colorA.R - colorB.R); - totalDiff += Math.Abs(colorA.G - colorB.G); - totalDiff += Math.Abs(colorA.B - colorB.B); + var ptrA = (byte*)(void*)dataA.Scan0; + var ptrB = (byte*)(void*)dataB.Scan0; + var rowLength = targetWidth * 3; + var offsetA = dataA.Stride - rowLength; + var offsetB = dataB.Stride - rowLength; + + for (var y = 0; y < targetHeight; y++) + { + for (var x = 0; x < rowLength; x++) + { + totalDiff += Math.Abs(ptrA[0] - ptrB[0]); + ptrA++; + ptrB++; + } + + ptrA += offsetA; + ptrB += offsetB; + } } } + finally + { + normalizedA.UnlockBits(dataA); + normalizedB.UnlockBits(dataB); + } var averageDiff = totalDiff / (targetWidth * targetHeight); return Math.Clamp(1.0 - (averageDiff / maxDiffPerPixel), 0.0, 1.0); @@ -178,27 +206,33 @@ private static RGBData ProcessBitmap(Bitmap source) var ptr = bmpData.Scan0; var data = new RGBData(); - unsafe + try { - var p = (byte*)(void*)ptr; - var width = source.Width * 3; - var offset = bmpData.Stride - width; - - for (var y = 0; y < source.Height; ++y) + unsafe { - for (var x = 0; x < width; ++x) + var p = (byte*)(void*)ptr; + var width = source.Width * 3; + var offset = bmpData.Stride - width; + + for (var y = 0; y < source.Height; ++y) { - data.R += p[0]; - data.G += p[1]; - data.B += p[2]; - ++p; + for (var x = 0; x < width; ++x) + { + data.R += p[0]; + data.G += p[1]; + data.B += p[2]; + ++p; + } + + p += offset; } - - p += offset; } } + finally + { + source.UnlockBits(bmpData); + } - source.UnlockBits(bmpData); return data; } diff --git a/ImageComparator/ComparisonBenchmark.cs b/ImageComparator/ComparisonBenchmark.cs index 2a66a27..34af140 100644 --- a/ImageComparator/ComparisonBenchmark.cs +++ b/ImageComparator/ComparisonBenchmark.cs @@ -45,6 +45,7 @@ public static IReadOnlyList Run(Bitmap a, Bitmap b, i foreach (var strategy in strategies) { var comparer = new BitmapCompare(strategy); + _ = comparer.GetSimilarity(a, b); double similarityTotal = 0; var sw = Stopwatch.StartNew(); @@ -74,4 +75,3 @@ public sealed record ComparisonBenchmarkResult( ComparisonStrategy Strategy, double AverageSimilarity, double AverageMillisecondsPerComparison); - diff --git a/ImageComparator/Program.cs b/ImageComparator/Program.cs index c44e8e9..8e5ccf6 100644 --- a/ImageComparator/Program.cs +++ b/ImageComparator/Program.cs @@ -150,9 +150,17 @@ private static CommandLineOptions ParseArguments(string[] args) if (arg.StartsWith("--benchmark-iterations=", StringComparison.OrdinalIgnoreCase)) { - benchmarkIterations = int.TryParse(arg.Split('=', 2)[1], out var parsed) && parsed > 0 - ? parsed - : benchmarkIterations; + if (int.TryParse(arg.Split('=', 2)[1], out var parsed) && parsed > 0) + { + benchmarkIterations = parsed; + } + else + { + Console.WriteLine( + "Invalid --benchmark-iterations value; using default of {0}.", + benchmarkIterations); + } + continue; } @@ -163,14 +171,7 @@ private static CommandLineOptions ParseArguments(string[] args) string? badDirectory = null; var fileType = "jpg"; - if (nonOptionArguments.Count == 4) - { - // Preserve compatibility with the original argument style. - goodDirectory = nonOptionArguments[1]; - badDirectory = nonOptionArguments[2]; - fileType = string.IsNullOrWhiteSpace(nonOptionArguments[3]) ? "jpg" : nonOptionArguments[3]; - } - else if (nonOptionArguments.Count >= 2) + if (nonOptionArguments.Count >= 2) { goodDirectory = nonOptionArguments[0]; badDirectory = nonOptionArguments[1]; @@ -178,6 +179,10 @@ private static CommandLineOptions ParseArguments(string[] args) ? nonOptionArguments[2] : "jpg"; } + else if (nonOptionArguments.Count == 1) + { + goodDirectory = nonOptionArguments[0]; + } return new CommandLineOptions(goodDirectory, badDirectory, fileType, strategy, benchmark, benchmarkIterations); } @@ -186,7 +191,6 @@ private static CommandLineOptions ParseArguments(string[] args) { "legacy" => ComparisonStrategy.LegacyDominantChannel, "mad" => ComparisonStrategy.MeanAbsoluteDifference, - "mean" => ComparisonStrategy.MeanAbsoluteDifference, "dhash" => ComparisonStrategy.DifferenceHash, "auto" => ComparisonStrategy.Auto, _ => ComparisonStrategy.Auto, diff --git a/README.md b/README.md index f80f346..6907443 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ The comparer now supports multiple strategies: ## Usage ```bash -dotnet run --project /home/runner/work/ImageComparator/ImageComparator/ImageComparator/ImageComparator.csproj -- [fileType] [--strategy=auto|legacy|mad|dhash] [--benchmark] [--benchmark-iterations=25] +dotnet run --project ImageComparator/ImageComparator.csproj -- [fileType] [--strategy=auto|legacy|mad|dhash] [--benchmark] [--benchmark-iterations=25] ``` When `--benchmark` is enabled, the app prints per-strategy timing and similarity output for the first compared image pair. @@ -25,5 +25,5 @@ When `--benchmark` is enabled, the app prints per-strategy timing and similarity Run: ```bash -dotnet run --project /home/runner/work/ImageComparator/ImageComparator/ImageComparator.Tests/ImageComparator.Tests.csproj +dotnet run --project ImageComparator.Tests/ImageComparator.Tests.csproj ``` From dfb36edf62b4ca48a16d5656f12c691691647d4f Mon Sep 17 00:00:00 2001 From: Bill Forney Date: Wed, 12 Aug 2026 20:23:42 -0700 Subject: [PATCH 04/11] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- ImageComparator/ComparisonStrategySelector.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ImageComparator/ComparisonStrategySelector.cs b/ImageComparator/ComparisonStrategySelector.cs index c8798bf..fd323bc 100644 --- a/ImageComparator/ComparisonStrategySelector.cs +++ b/ImageComparator/ComparisonStrategySelector.cs @@ -10,8 +10,8 @@ public static class ComparisonStrategySelector /// Returns the strategy best suited for the provided images. public static ComparisonStrategy SelectBest(Bitmap a, Bitmap b) { - var maxPixels = Math.Max(a.Width * a.Height, b.Width * b.Height); - if (maxPixels >= 1920 * 1080) + var maxPixels = Math.Max((long)a.Width * a.Height, (long)b.Width * b.Height); + if (maxPixels >= 1920L * 1080) { return ComparisonStrategy.DifferenceHash; } From 6c306faffc458617d4a3c1d06227ad3e3f7ab654 Mon Sep 17 00:00:00 2001 From: Bill Forney Date: Wed, 12 Aug 2026 20:24:00 -0700 Subject: [PATCH 05/11] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- ImageComparator/Program.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ImageComparator/Program.cs b/ImageComparator/Program.cs index 8e5ccf6..741397a 100644 --- a/ImageComparator/Program.cs +++ b/ImageComparator/Program.cs @@ -136,8 +136,14 @@ private static CommandLineOptions ParseArguments(string[] args) continue; } - if (arg.Equals("--strategy", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length) + if (arg.Equals("--strategy", StringComparison.OrdinalIgnoreCase)) { + if (i + 1 >= args.Length) + { + Console.WriteLine("Missing value for --strategy; using default of auto."); + continue; + } + strategy = ParseStrategy(args[++i]); continue; } From 74185c061d671e6b7b505f27b32b7cf1c4b257df Mon Sep 17 00:00:00 2001 From: Bill Forney Date: Wed, 12 Aug 2026 20:24:17 -0700 Subject: [PATCH 06/11] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- ImageComparator/BitmapCompare.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ImageComparator/BitmapCompare.cs b/ImageComparator/BitmapCompare.cs index b87fd00..ab68536 100644 --- a/ImageComparator/BitmapCompare.cs +++ b/ImageComparator/BitmapCompare.cs @@ -5,7 +5,7 @@ namespace ImageComparator; using System.Numerics; using System.Runtime.Versioning; -/// Compares two bitmaps by their dominant colour channel average. +/// Compares two bitmaps using a configurable image-comparison strategy. [SupportedOSPlatform("windows")] public class BitmapCompare : IBitmapCompare { From 5eff614625e7b3ade0222da1b5dd5b7aad7fe2fd Mon Sep 17 00:00:00 2001 From: Bill Forney Date: Wed, 12 Aug 2026 20:24:37 -0700 Subject: [PATCH 07/11] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- ImageComparator/Program.cs | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/ImageComparator/Program.cs b/ImageComparator/Program.cs index 741397a..2073820 100644 --- a/ImageComparator/Program.cs +++ b/ImageComparator/Program.cs @@ -193,14 +193,24 @@ private static CommandLineOptions ParseArguments(string[] args) return new CommandLineOptions(goodDirectory, badDirectory, fileType, strategy, benchmark, benchmarkIterations); } - private static ComparisonStrategy ParseStrategy(string value) => value.ToLowerInvariant() switch + private static ComparisonStrategy ParseStrategy(string value) { - "legacy" => ComparisonStrategy.LegacyDominantChannel, - "mad" => ComparisonStrategy.MeanAbsoluteDifference, - "dhash" => ComparisonStrategy.DifferenceHash, - "auto" => ComparisonStrategy.Auto, - _ => ComparisonStrategy.Auto, - }; + var normalized = value.Trim().ToLowerInvariant(); + return normalized switch + { + "legacy" => ComparisonStrategy.LegacyDominantChannel, + "mad" => ComparisonStrategy.MeanAbsoluteDifference, + "dhash" => ComparisonStrategy.DifferenceHash, + "auto" => ComparisonStrategy.Auto, + _ => Invalid(), + }; + + ComparisonStrategy Invalid() + { + Console.WriteLine("Invalid --strategy value '{0}'; using auto.", value); + return ComparisonStrategy.Auto; + } + } private static void PrintBenchmark(string firstImagePath, string secondImagePath, int iterations) { From 914a1f0dd3062d66bb08720225af4a534884ed52 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 03:27:50 +0000 Subject: [PATCH 08/11] fix: prevent legacy RGB accumulator overflow on large images Co-authored-by: wforney <79032+wforney@users.noreply.github.com> --- ImageComparator/BitmapCompare.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ImageComparator/BitmapCompare.cs b/ImageComparator/BitmapCompare.cs index ab68536..c435b57 100644 --- a/ImageComparator/BitmapCompare.cs +++ b/ImageComparator/BitmapCompare.cs @@ -74,8 +74,8 @@ private static double GetLegacySimilarity(Bitmap a, Bitmap b) var dataA = ProcessBitmap(normalizedA); var dataB = ProcessBitmap(normalizedB); - var maxA = (normalizedA.Width * 3) * normalizedA.Height; - var maxB = (normalizedB.Width * 3) * normalizedB.Height; + var maxA = (long)normalizedA.Width * 3 * normalizedA.Height; + var maxB = (long)normalizedB.Width * 3 * normalizedB.Height; double result = dataA.GetLargest() switch { @@ -240,13 +240,13 @@ private static RGBData ProcessBitmap(Bitmap source) public struct RGBData : IEquatable { /// Gets or sets the summed red channel value. - public int R { get; set; } + public long R { get; set; } /// Gets or sets the summed green channel value. - public int G { get; set; } + public long G { get; set; } /// Gets or sets the summed blue channel value. - public int B { get; set; } + public long B { get; set; } /// Returns which channel (1=R, 2=G, 3=B) has the largest sum. public readonly int GetLargest() => From ce034d1ea72b66ff05918918e56806475699f488 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 03:37:18 +0000 Subject: [PATCH 09/11] test: migrate to TUnit and add code-review skill Co-authored-by: wforney <79032+wforney@users.noreply.github.com> --- .github/skills/code-review/SKILL.md | 17 ++++ .../ImageComparator.Tests.csproj | 4 + ImageComparator.Tests/Program.cs | 97 ++++++------------- 3 files changed, 53 insertions(+), 65 deletions(-) create mode 100644 .github/skills/code-review/SKILL.md diff --git a/.github/skills/code-review/SKILL.md b/.github/skills/code-review/SKILL.md new file mode 100644 index 0000000..246d9bc --- /dev/null +++ b/.github/skills/code-review/SKILL.md @@ -0,0 +1,17 @@ +# ImageComparator code review skill + +Use this skill to review pull requests in this repository with project-specific context. + +## Focus areas + +- Validate image-comparison correctness across strategies (`legacy`, `mad`, `dhash`, `auto`). +- Check for numeric overflow risks in pixel and channel accumulation logic. +- Verify `System.Drawing` pixel-format assumptions (especially 24bpp BGR byte order and stride handling). +- Review CLI argument parsing edge cases (`--strategy`, `--benchmark`, `--benchmark-iterations`). +- Ensure Windows-only behavior is explicit for `System.Drawing` paths and tests. +- Confirm benchmark and test changes remain deterministic and minimal. + +## Review output expectations + +- Report only high-confidence correctness, security, or reliability issues. +- Include exact file/line references and a concrete fix suggestion for each issue. diff --git a/ImageComparator.Tests/ImageComparator.Tests.csproj b/ImageComparator.Tests/ImageComparator.Tests.csproj index ede3bd0..a86be87 100644 --- a/ImageComparator.Tests/ImageComparator.Tests.csproj +++ b/ImageComparator.Tests/ImageComparator.Tests.csproj @@ -4,6 +4,10 @@ + + + + Exe net10.0 diff --git a/ImageComparator.Tests/Program.cs b/ImageComparator.Tests/Program.cs index 5773050..bb24cc3 100644 --- a/ImageComparator.Tests/Program.cs +++ b/ImageComparator.Tests/Program.cs @@ -3,58 +3,20 @@ namespace ImageComparator.Tests; using System.Drawing; using System.Runtime.Versioning; using ImageComparator; +using TUnit.Core; -[SupportedOSPlatform("windows")] -internal static class Program +public class BitmapCompareTests { - private static int Main() + [Test] + [SupportedOSPlatform("windows")] + public async Task StrategiesRankIdenticalAboveDifferent() { - if (!OperatingSystem.IsWindows()) + if (!IsWindows()) { - Console.WriteLine("Tests skipped: System.Drawing comparisons are Windows-only."); - return 0; + Skip.Test("System.Drawing comparisons are Windows-only."); + return; } - var tests = new (string Name, Action Execute)[] - { - ("Strategies rank identical above different", StrategiesRankIdenticalAboveDifferent), - ("Auto strategy picks dHash for very large images", AutoStrategyPicksDifferenceHashForLargeImages), - ("Benchmark returns concrete strategies", BenchmarkReturnsConcreteStrategies), - }; - - var failures = new List(); - - foreach (var (name, execute) in tests) - { - try - { - execute(); - Console.WriteLine($"PASS: {name}"); - } - catch (Exception ex) - { - failures.Add($"{name}: {ex.Message}"); - Console.WriteLine($"FAIL: {name}"); - } - } - - if (failures.Count == 0) - { - return 0; - } - - Console.WriteLine(); - Console.WriteLine("Failures:"); - foreach (var failure in failures) - { - Console.WriteLine($"- {failure}"); - } - - return 1; - } - - private static void StrategiesRankIdenticalAboveDifferent() - { using var imageA = CreateSolidBitmap(Color.Red); using var imageB = CreateSolidBitmap(Color.Red); using var imageC = CreateSolidBitmap(Color.Blue); @@ -72,37 +34,47 @@ private static void StrategiesRankIdenticalAboveDifferent() var sameSimilarity = comparer.GetSimilarity(imageA, imageB); var differentSimilarity = comparer.GetSimilarity(imageA, imageC); - AssertTrue( - sameSimilarity > differentSimilarity, - $"{strategy} expected sameSimilarity > differentSimilarity but was {sameSimilarity:F4} <= {differentSimilarity:F4}"); + await Assert.That(sameSimilarity > differentSimilarity).IsTrue(); } } - private static void AutoStrategyPicksDifferenceHashForLargeImages() + [Test] + [SupportedOSPlatform("windows")] + public async Task AutoStrategyPicksDifferenceHashForLargeImages() { + if (!IsWindows()) + { + Skip.Test("System.Drawing comparisons are Windows-only."); + return; + } using var imageA = CreateSolidBitmap(Color.Green, width: 2400, height: 1600); using var imageB = CreateSolidBitmap(Color.Green, width: 2400, height: 1600); var comparer = new BitmapCompare(ComparisonStrategy.Auto); _ = comparer.GetSimilarity(imageA, imageB); - AssertTrue( - comparer.LastStrategyUsed == ComparisonStrategy.DifferenceHash, - $"Expected {ComparisonStrategy.DifferenceHash} but got {comparer.LastStrategyUsed}"); + await Assert.That(comparer.LastStrategyUsed).IsEqualTo(ComparisonStrategy.DifferenceHash); } - private static void BenchmarkReturnsConcreteStrategies() + [Test] + [SupportedOSPlatform("windows")] + public async Task BenchmarkReturnsConcreteStrategies() { + if (!IsWindows()) + { + Skip.Test("System.Drawing comparisons are Windows-only."); + return; + } + using var imageA = CreateSolidBitmap(Color.White); using var imageB = CreateSolidBitmap(Color.Black); var results = ComparisonBenchmark.Run(imageA, imageB, iterations: 3); - AssertTrue(results.Count == 3, $"Expected 3 benchmark rows, got {results.Count}"); - AssertTrue( - results.All(result => result.Strategy != ComparisonStrategy.Auto), - "Benchmark should return only concrete strategies."); + await Assert.That(results.Count).IsEqualTo(3); + await Assert.That(results.All(result => result.Strategy != ComparisonStrategy.Auto)).IsTrue(); } + [SupportedOSPlatform("windows")] private static Bitmap CreateSolidBitmap(Color color, int width = 64, int height = 64) { var bitmap = new Bitmap(width, height); @@ -111,11 +83,6 @@ private static Bitmap CreateSolidBitmap(Color color, int width = 64, int height return bitmap; } - private static void AssertTrue(bool condition, string message) - { - if (!condition) - { - throw new InvalidOperationException(message); - } - } + [SupportedOSPlatformGuard("windows")] + private static bool IsWindows() => OperatingSystem.IsWindows(); } From 1d31cdecb21400ddb2966d4974f7b92daab569fd Mon Sep 17 00:00:00 2001 From: Bill Forney Date: Wed, 12 Aug 2026 22:36:53 -0700 Subject: [PATCH 10/11] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- ImageComparator/BitmapCompare.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ImageComparator/BitmapCompare.cs b/ImageComparator/BitmapCompare.cs index c435b57..974f1b1 100644 --- a/ImageComparator/BitmapCompare.cs +++ b/ImageComparator/BitmapCompare.cs @@ -211,17 +211,17 @@ private static RGBData ProcessBitmap(Bitmap source) unsafe { var p = (byte*)(void*)ptr; - var width = source.Width * 3; - var offset = bmpData.Stride - width; + var rowLength = source.Width * 3; + var offset = bmpData.Stride - rowLength; for (var y = 0; y < source.Height; ++y) { - for (var x = 0; x < width; ++x) + for (var x = 0; x < source.Width; ++x) { - data.R += p[0]; + data.B += p[0]; data.G += p[1]; - data.B += p[2]; - ++p; + data.R += p[2]; + p += 3; } p += offset; From cf5adcbab0d28ff1b866512ef697be9d2613a033 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 05:42:18 +0000 Subject: [PATCH 11/11] test: stabilize dHash ranking fixture Co-authored-by: wforney <79032+wforney@users.noreply.github.com> --- ImageComparator.Tests/Program.cs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/ImageComparator.Tests/Program.cs b/ImageComparator.Tests/Program.cs index bb24cc3..b9e0327 100644 --- a/ImageComparator.Tests/Program.cs +++ b/ImageComparator.Tests/Program.cs @@ -17,8 +17,8 @@ public async Task StrategiesRankIdenticalAboveDifferent() return; } - using var imageA = CreateSolidBitmap(Color.Red); - using var imageB = CreateSolidBitmap(Color.Red); + using var imageA = CreateSplitBitmap(Color.White, Color.Black); + using var imageB = CreateSplitBitmap(Color.White, Color.Black); using var imageC = CreateSolidBitmap(Color.Blue); var strategies = new[] @@ -83,6 +83,17 @@ private static Bitmap CreateSolidBitmap(Color color, int width = 64, int height return bitmap; } + [SupportedOSPlatform("windows")] + private static Bitmap CreateSplitBitmap(Color leftColor, Color rightColor, int width = 64, int height = 64) + { + var bitmap = new Bitmap(width, height); + using var graphics = Graphics.FromImage(bitmap); + graphics.Clear(rightColor); + using var brush = new SolidBrush(leftColor); + graphics.FillRectangle(brush, 0, 0, width / 2, height); + return bitmap; + } + [SupportedOSPlatformGuard("windows")] private static bool IsWindows() => OperatingSystem.IsWindows(); }