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/.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..a86be87
--- /dev/null
+++ b/ImageComparator.Tests/ImageComparator.Tests.csproj
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
diff --git a/ImageComparator.Tests/Program.cs b/ImageComparator.Tests/Program.cs
new file mode 100644
index 0000000..b9e0327
--- /dev/null
+++ b/ImageComparator.Tests/Program.cs
@@ -0,0 +1,99 @@
+namespace ImageComparator.Tests;
+
+using System.Drawing;
+using System.Runtime.Versioning;
+using ImageComparator;
+using TUnit.Core;
+
+public class BitmapCompareTests
+{
+ [Test]
+ [SupportedOSPlatform("windows")]
+ public async Task StrategiesRankIdenticalAboveDifferent()
+ {
+ if (!IsWindows())
+ {
+ Skip.Test("System.Drawing comparisons are Windows-only.");
+ return;
+ }
+
+ 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[]
+ {
+ 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);
+
+ await Assert.That(sameSimilarity > differentSimilarity).IsTrue();
+ }
+ }
+
+ [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);
+
+ await Assert.That(comparer.LastStrategyUsed).IsEqualTo(ComparisonStrategy.DifferenceHash);
+ }
+
+ [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);
+
+ 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);
+ using var graphics = Graphics.FromImage(bitmap);
+ graphics.Clear(color);
+ 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();
+}
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..974f1b1 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.
+/// Compares two bitmaps using a configurable image-comparison strategy.
[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;
+
+ 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 maxA = (a.Width * 3) * a.Height;
- var maxB = (b.Width * 3) * b.Height;
+ var dataA = ProcessBitmap(normalizedA);
+ var dataB = ProcessBitmap(normalizedB);
+
+ var maxA = (long)normalizedA.Width * 3 * normalizedA.Height;
+ var maxB = (long)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,37 +94,145 @@ public double GetSimilarity(Bitmap a, Bitmap b)
return result;
}
- private static RGBData ProcessBitmap(Bitmap a)
+ private static double GetMeanAbsoluteDifferenceSimilarity(Bitmap a, Bitmap b)
{
- var bmpData = a.LockBits(
- new Rectangle(0, 0, a.Width, a.Height),
+ 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;
+
+ var dataA = normalizedA.LockBits(
+ new Rectangle(0, 0, targetWidth, targetHeight),
ImageLockMode.ReadOnly,
PixelFormat.Format24bppRgb);
- var ptr = bmpData.Scan0;
- var data = new RGBData();
+ var dataB = normalizedB.LockBits(
+ new Rectangle(0, 0, targetWidth, targetHeight),
+ ImageLockMode.ReadOnly,
+ PixelFormat.Format24bppRgb);
+
+ try
+ {
+ unsafe
+ {
+ 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;
- unsafe
+ 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
{
- var p = (byte*)(void*)ptr;
- var width = a.Width * 3;
- var offset = bmpData.Stride - width;
+ normalizedA.UnlockBits(dataA);
+ normalizedB.UnlockBits(dataB);
+ }
+
+ 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;
- for (var y = 0; y < a.Height; ++y)
+ 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++)
{
- for (var x = 0; x < width; ++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)
{
- data.R += p[0];
- data.G += p[1];
- data.B += p[2];
- ++p;
+ hash |= 1UL << bitIndex;
}
- p += offset;
+ 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 = source.LockBits(
+ new Rectangle(0, 0, source.Width, source.Height),
+ ImageLockMode.ReadOnly,
+ PixelFormat.Format24bppRgb);
+
+ var ptr = bmpData.Scan0;
+ var data = new RGBData();
+
+ try
+ {
+ unsafe
+ {
+ var p = (byte*)(void*)ptr;
+ var rowLength = source.Width * 3;
+ var offset = bmpData.Stride - rowLength;
+
+ for (var y = 0; y < source.Height; ++y)
+ {
+ for (var x = 0; x < source.Width; ++x)
+ {
+ data.B += p[0];
+ data.G += p[1];
+ data.R += p[2];
+ p += 3;
+ }
+
+ p += offset;
+ }
}
}
+ finally
+ {
+ source.UnlockBits(bmpData);
+ }
- a.UnlockBits(bmpData);
return data;
}
@@ -72,13 +240,13 @@ private static RGBData ProcessBitmap(Bitmap a)
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() =>
diff --git a/ImageComparator/ComparisonBenchmark.cs b/ImageComparator/ComparisonBenchmark.cs
new file mode 100644
index 0000000..34af140
--- /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);
+ _ = comparer.GetSimilarity(a, b);
+ 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..fd323bc
--- /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((long)a.Width * a.Height, (long)b.Width * b.Height);
+ if (maxPixels >= 1920L * 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/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 @@
-
+
diff --git a/ImageComparator/Program.cs b/ImageComparator/Program.cs
index 4bfd840..2073820 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,128 @@ 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))
+ {
+ if (i + 1 >= args.Length)
+ {
+ Console.WriteLine("Missing value for --strategy; using default of auto.");
+ continue;
+ }
+
+ strategy = ParseStrategy(args[++i]);
+ continue;
+ }
+
+ if (arg.Equals("--benchmark", StringComparison.OrdinalIgnoreCase))
+ {
+ benchmark = true;
+ continue;
+ }
+
+ if (arg.StartsWith("--benchmark-iterations=", StringComparison.OrdinalIgnoreCase))
+ {
+ 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;
+ }
+
+ nonOptionArguments.Add(arg);
+ }
+
+ string? goodDirectory = null;
+ string? badDirectory = null;
+ var fileType = "jpg";
+
+ if (nonOptionArguments.Count >= 2)
+ {
+ goodDirectory = nonOptionArguments[0];
+ badDirectory = nonOptionArguments[1];
+ fileType = nonOptionArguments.Count >= 3 && !string.IsNullOrWhiteSpace(nonOptionArguments[2])
+ ? nonOptionArguments[2]
+ : "jpg";
+ }
+ else if (nonOptionArguments.Count == 1)
+ {
+ goodDirectory = nonOptionArguments[0];
+ }
+
+ return new CommandLineOptions(goodDirectory, badDirectory, fileType, strategy, benchmark, benchmarkIterations);
+ }
+
+ private static ComparisonStrategy ParseStrategy(string value)
+ {
+ 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)
+ {
+ 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 +255,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 +275,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..6907443 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 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 ImageComparator.Tests/ImageComparator.Tests.csproj
+```