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