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 +```