diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..06b72da --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,34 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/dotnet +{ + "name": "C# (.NET)", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "image": "mcr.microsoft.com/devcontainers/dotnet:2-10.0-noble", + "customizations": { + "vscode": { + "extensions": [ + "ms-dotnettools.csdevkit" + ] + } + } + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [5000, 5001], + // "portsAttributes": { + // "5001": { + // "protocol": "https" + // } + // } + + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "dotnet restore", + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.editorconfig b/.editorconfig index 71c3975..3ca6b4f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,13 +1,22 @@ -[*.cs] +[*] +spelling_exclusion_path = .\exclusion.dic + +[*.cs] # CA1303: Do not pass literals as localized parameters dotnet_diagnostic.CA1303.severity = none +# CA2000: Dispose objects before losing scope +dotnet_diagnostic.CA2000.severity = suggestion + # var preferences csharp_style_var_elsewhere = true:warning csharp_style_var_for_built_in_types = true:warning csharp_style_var_when_type_is_apparent = true:warning +# CA1822: Mark members as static +dotnet_diagnostic.CA1822.severity = suggestion + [*.{cs,vb}] tab_width=4 indent_size=4 diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..36ace77 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,15 @@ +# These are supported funding model platforms + +github: [Codeuctivity] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +polar: # Replace with a single Polar username +buy_me_a_coffee: # Replace with a single Buy Me a Coffee username +thanks_dev: # Replace with a single thanks.dev username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index c38e82b..3152501 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -11,17 +11,17 @@ jobs: steps: - name: "CLA Assistant" if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target' - uses: cla-assistant/github-action@v2.2.1 + uses: cla-assistant/github-action@v2.6.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # the below token should have repo scope and must be manually added by you in the repository's secret PERSONAL_ACCESS_TOKEN : ${{ secrets.PERSONAL_ACCESS_TOKEN }} with: path-to-signatures: 'signatures/version1/cla.json' - path-to-document: 'https://github.com/Codeuctivity/ImageSharp.Compare/blob/main/cla.md' # e.g. a CLA or a DCO document + path-to-document: 'https://github.com/Codeuctivity/SkiaSharp.Compare/blob/main/cla.md' # e.g. a CLA or a DCO document # branch should not be protected branch: 'cla' - allowlist: dependabot[bot],stesee + allowlist: dependabot[bot],stesee,github-actions[bot],github-copilot[bot],copilot[bot],copilot-swe-agent[bot],Copilot #below are the optional inputs - If the optional inputs are not given, then default values will be taken #remote-organization-name: enter the remote organization name where the signatures should be stored (Default is storing the signatures in the same repository) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 95422b1..ca8b1c2 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -1,11 +1,15 @@ name: .NET build and test env: - CURRENT_VERSION: 0.0.${{ github.run_number }} + CURRENT_VERSION: 4.0.${{ github.run_number }} LAST_COMMIT_MESSAGE: ${{ github.event.head_commit.message }} on: push: - pull_request: + paths-ignore: + - '**.md' + +permissions: + contents: write jobs: build: @@ -14,79 +18,104 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@v6 with: dotnet-version: | - 6.0.x + 10.0.x + - name: Check formatting + run: dotnet format --verify-no-changes - name: Restore dependencies run: dotnet restore - name: Build run: dotnet build --configuration Release --no-restore + + - name: Publish SkiaSharpCompare.Cli (Linux) + if: runner.os == 'Linux' + run: | + dotnet publish SkiaSharpCompare.Cli/SkiaSharpCompare.Cli.csproj -c Release -r linux-x64 --self-contained true -p:PublishSingleFile=true -o ./artifacts/cli/linux-x64 + + - name: Publish SkiaSharpCompare.Cli (macOS) + if: runner.os == 'macOS' + run: | + dotnet publish SkiaSharpCompare.Cli/SkiaSharpCompare.Cli.csproj -c Release -r osx-x64 --self-contained true -p:PublishSingleFile=true -o ./artifacts/cli/osx-x64 + + - name: Publish SkiaSharpCompare.Cli (Windows) + if: runner.os == 'Windows' + shell: powershell + run: | + dotnet publish SkiaSharpCompare.Cli/SkiaSharpCompare.Cli.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -o ./artifacts/cli/win-x64 + dotnet publish SkiaSharpCompare.Cli/SkiaSharpCompare.Cli.csproj -c Release -r win-arm64 --self-contained true -p:PublishSingleFile=true -o ./artifacts/cli/win-arm64 + + - name: Upload CLI artifacts + uses: actions/upload-artifact@v7 + with: + name: cli-artifacts-${{ matrix.os }} + path: ./artifacts/cli + retention-days: 7 + - name: Test run: dotnet test --no-build --verbosity normal --configuration Release deployRelease: - if: github.ref == 'refs/heads/release' + if: startsWith(github.ref, 'refs/heads/release') runs-on: ubuntu-latest - needs: build + needs: build steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@v6 with: dotnet-version: | - 6.0.x + 10.0.x - name: Restore dependencies run: dotnet restore - name: Build run: dotnet build --configuration Release --no-restore + - name: Download CLI artifacts + uses: actions/download-artifact@v8 + with: + path: ./artifacts_download - name: NugetPush env: NUGET_TOKEN_EXISTS: ${{ secrets.NUGET_TOKEN }} - if: env.NUGET_TOKEN_EXISTS != '' + if: env.NUGET_TOKEN_EXISTS != '' run: | - dotnet nuget push ./SkiaSharpCompare/bin/Release/*.nupkg --skip-duplicate --api-key ${{secrets.NUGET_TOKEN}} --source https://api.nuget.org/v3/index.json - - uses: "marvinpinto/action-automatic-releases@latest" - with: - repo_token: "${{ secrets.GITHUB_TOKEN }}" - automatic_release_tag: ${{ env.CURRENT_VERSION }} - prerelease: false - title: "Release Build" - files: | - ./SkiaSharpCompare/bin/Release/*.nupkg - ./SkiaSharpCompare/bin/Release/*.snupkg + dotnet nuget push ./SkiaSharpCompare/bin/Release/*.nupkg --skip-duplicate --api-key ${{secrets.NUGET_TOKEN}} --source https://api.nuget.org/v3/index.json + - name: Github release + shell: bash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + if: env.GITHUB_TOKEN != '' + run: | + # Attach all downloaded CLI artifacts regardless of OS + zip -r cli-artifacts.zip ./artifacts_download + gh release create ${{env.CURRENT_VERSION}} ./SkiaSharpCompare/bin/Release/*.*nupkg cli-artifacts.zip --generate-notes deployTest: - if: github.ref != 'refs/heads/release' + if: ${{ github.ref == 'refs/heads/main' }} runs-on: ubuntu-latest - needs: build + needs: build steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@v6 with: dotnet-version: | - 6.0.x + 10.0.x - name: Restore dependencies run: dotnet restore - name: Build run: dotnet build --configuration Release --no-restore + - name: Download CLI artifacts + uses: actions/download-artifact@v8 + with: + path: ./artifacts_download - name: NugetPush env: NUGET_TOKEN_EXISTS: ${{ secrets.NUGET_TEST_TOKEN }} - if: env.NUGET_TOKEN_EXISTS != '' + if: env.NUGET_TOKEN_EXISTS != '' run: | ls ./SkiaSharpCompare/bin/Release - dotnet nuget push ./SkiaSharpCompare/bin/Release/*.nupkg --skip-duplicate --api-key ${{secrets.NUGET_TEST_TOKEN}} --source https://apiint.nugettest.org/v3/index.json - - uses: "marvinpinto/action-automatic-releases@latest" - if: github.ref == 'refs/heads/main' - with: - repo_token: "${{ secrets.GITHUB_TOKEN }}" - automatic_release_tag: "latest-prerelease" - prerelease: true - title: "Prerelease Build" - files: | - ./SkiaSharpCompare/bin/Release/*.nupkg - ./SkiaSharpCompare/bin/Release/*.snupkg + dotnet nuget push ./SkiaSharpCompare/bin/Release/*.nupkg --skip-duplicate --api-key ${{secrets.NUGET_TEST_TOKEN}} --source https://apiint.nugettest.org/v3/index.json \ No newline at end of file diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 655d659..9ca7111 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -18,7 +18,7 @@ jobs: pull-requests: write steps: - - uses: actions/stale@v6 + - uses: actions/stale@v11 with: repo-token: ${{ secrets.GITHUB_TOKEN }} stale-issue-message: 'Stale issue message' diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 3704b2e..f803ec5 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -2,7 +2,6 @@ "recommendations": [ "mhutchie.git-graph", "streetsidesoftware.code-spell-checker", - "timonwong.shellcheck", "redhat.vscode-xml", "redhat.vscode-yaml" ] diff --git a/.vscode/launch.json b/.vscode/launch.json index da20669..0b302ee 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,9 +10,9 @@ "request": "launch", "preLaunchTask": "build", // If you have changed target frameworks, make sure to update the program path. - "program": "${workspaceFolder}/ImageSharpCompareTestNunit/bin/Debug/net6.0/ImageSharpCompareTestNunit.dll", + "program": "${workspaceFolder}/SkiaSharpCompareTestNunit/bin/Debug/net6.0/SkiaSharpCompareTestNunit.dll", "args": [], - "cwd": "${workspaceFolder}/ImageSharpCompareTestNunit", + "cwd": "${workspaceFolder}/SkiaSharpCompareTestNunit", // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console "console": "internalConsole", "stopAtEntry": false diff --git a/.vscode/settings.json b/.vscode/settings.json index ae6673f..201e0e3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,11 +1,14 @@ { - "cSpell.words": [ - "Codacy", - "Codeuctivity", - "Nuget", - "Nunit", - "Rgba", - "nupkg", - "snupkg" - ] -} \ No newline at end of file + "cSpell.words": [ + "Codacy", + "Codeuctivity", + "Diffmask", + "Dont", + "Nuget", + "Nunit", + "nupkg", + "Rgba", + "Skia", + "snupkg" + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json index d0235d3..c22253a 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -7,7 +7,7 @@ "type": "process", "args": [ "build", - "${workspaceFolder}/ImageSharpCompareTestNunit/ImageSharpCompareTestNunit.csproj", + "${workspaceFolder}/SkiaSharpCompareTestNunit/SkiaSharpCompareTestNunit.csproj", "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], @@ -19,7 +19,7 @@ "type": "process", "args": [ "publish", - "${workspaceFolder}/ImageSharpCompareTestNunit/ImageSharpCompareTestNunit.csproj", + "${workspaceFolder}/SkiaSharpCompareTestNunit/SkiaSharpCompareTestNunit.csproj", "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], @@ -32,7 +32,7 @@ "args": [ "watch", "run", - "${workspaceFolder}/ImageSharpCompareTestNunit/ImageSharpCompareTestNunit.csproj", + "${workspaceFolder}/SkiaSharpCompareTestNunit/SkiaSharpCompareTestNunit.csproj", "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], @@ -43,6 +43,18 @@ "type": "shell", "command": "'./.vscode/updateNuget.sh'", "problemMatcher": [] + }, + { + "label": "update nuget packages (windows)", + "type": "shell", + "command": "powershell", + "args": [ + "-ExecutionPolicy", + "Bypass", + "-File", + "./.vscode/updateNuget.ps1" + ], + "problemMatcher": [] } ] } \ No newline at end of file diff --git a/.vscode/updateNuget.ps1 b/.vscode/updateNuget.ps1 new file mode 100644 index 0000000..8977003 --- /dev/null +++ b/.vscode/updateNuget.ps1 @@ -0,0 +1,23 @@ +# PowerShell script to update NuGet packages in all project files +# This script finds all .csproj and .fsproj files and updates stable (non-prerelease) packages + +$regex = 'PackageReference Include="([^"]*)" Version="([^"]*)"' + +Get-ChildItem -Path . -Recurse -Include "*.csproj", "*.fsproj" | ForEach-Object { + $projFile = $_.FullName + Write-Host "Processing project: $projFile" + $content = Get-Content $projFile + foreach ($line in $content) { + if ($line -match $regex) { + $packageName = $matches[1] + $version = $matches[2] + Write-Host "Found package: $packageName, version: $version" + + # Only update stable versions (not prerelease versions containing -) + if ($version -notmatch '-') { + Write-Host "Updating package: $packageName" + dotnet add "$projFile" package "$packageName" + } + } + } +} \ No newline at end of file diff --git a/README.md b/README.md index b5dccc9..408a378 100644 --- a/README.md +++ b/README.md @@ -8,15 +8,26 @@ Inspired by the image compare feature "Visual verification API" of [TestApi](htt SkiaSharpCompare focus on os agnostic support and therefore depends on [SkiaSharp](https://github.com/mono/SkiaSharp). -**NOTE**: For now the **A**lpha-channel is ignored. - ## Example simple show cases +### Compares each RGB value of each pixel to determine the equality + ```csharp -bool imagesAreEqual = SkiaSharpCompare.ImagesAreEqual("actual.png", "expected.png"); +bool isEqual = Compare.ImagesAreEqual("actual.png", "expected.png"); +``` -// calcs MeanError, AbsoluteError, PixelErrorCount and PixelErrorPercentage -ICompareResult calcDiff = SkiaSharpCompare.CalcDiff("actual.png", "expected.png"); +### [Calculates diff](https://dotnetfiddle.net/tTnq2j) + +```csharp +var calcDiff = Compare.CalcDiff("2x2PixelBlack.png", "2x2PixelWhite.png"); +Console.WriteLine($"PixelErrorCount: {calcDiff.PixelErrorCount}"); +Console.WriteLine($"PixelErrorPercentage: {calcDiff.PixelErrorPercentage}"); +Console.WriteLine($"AbsoluteError: {calcDiff.AbsoluteError}"); +Console.WriteLine($"MeanError: {calcDiff.MeanError}"); +// PixelErrorCount: 4 +// PixelErrorPercentage: 100 +// AbsoluteError: 3060 +// MeanError: 765 ``` ## Example show case allowing some tolerated diff @@ -33,7 +44,7 @@ Imagine two images you want to compare, and want to accept the found difference ### Tolerance mask image -using "compare.CalcDiff" you can calc a diff mask from actual and reference image +Using **CalcDiffMaskImage** you can calc a diff mask from actual and reference image Example - Create difference image @@ -52,3 +63,29 @@ Example - Compare two images using the created difference image. Add white pixel var maskedDiff = SkiaSharpCompare.CalcDiff(pathPic1, pathPic2, "differenceMask.png"); Assert.That(maskedDiff.AbsoluteError, Is.EqualTo(0)); ``` + +### [Configure transparency, enable metadata compare, ...](https://dotnetfiddle.net/lygaRU) + +```csharp +var comparer = new ImageCompare(ResizeOption.Resize, TransparencyOptions.CompareAlphaChannel, pixelColorShiftTolerance: 5, compareMetadata: true); +var calcDiff = comparer.CalcDiff("pngPartialTransparent4x4Pixel.png", "2x2PixelWhite.png"); +// Displaying the differences +Console.WriteLine($"PixelErrorCount: {calcDiff.PixelErrorCount}"); +Console.WriteLine($"PixelErrorPercentage: {calcDiff.PixelErrorPercentage}"); +Console.WriteLine($"AbsoluteError: {calcDiff.AbsoluteError}"); +Console.WriteLine($"MeanError: {calcDiff.MeanError}"); +foreach (var metadataDifference in calcDiff.MetadataDifferences) + Console.WriteLine($"Metadata Difference: {metadataDifference}"); + +// PixelErrorCount: 16 +// PixelErrorPercentage: 100 +// AbsoluteError: 14272 +// MeanError: 892 +// Metadata Difference: [File:File Name, (pngPartialTransparent4x4Pixel.png, 2x2PixelWhite.png)] +// Metadata Difference: [File:File Size, (688 bytes, 556 bytes)] +// Metadata Difference: [PNG-IHDR:Color Type, (True Color with Alpha, True Color)] +// Metadata Difference: [PNG-IHDR:Image Height, (4, 2)] +// Metadata Difference: [PNG-IHDR:Image Width, (4, 2)] +// Metadata Difference: [PNG-tEXt:Textual Data, (Software: Paint.NET 5.1.2, Comment: Created with GIMP)] +// ... +``` diff --git a/SkiaSharpCompare.Cli.Tests/SkiaSharpCompare.Cli.Tests.csproj b/SkiaSharpCompare.Cli.Tests/SkiaSharpCompare.Cli.Tests.csproj new file mode 100644 index 0000000..199fde0 --- /dev/null +++ b/SkiaSharpCompare.Cli.Tests/SkiaSharpCompare.Cli.Tests.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + false + 12 + enable + + + + + + + + + + + + + \ No newline at end of file diff --git a/SkiaSharpCompare.Cli.Tests/SkiaSharpCompareCliIntegrationTests.cs b/SkiaSharpCompare.Cli.Tests/SkiaSharpCompareCliIntegrationTests.cs new file mode 100644 index 0000000..c78cf6c --- /dev/null +++ b/SkiaSharpCompare.Cli.Tests/SkiaSharpCompareCliIntegrationTests.cs @@ -0,0 +1,64 @@ +using NUnit.Framework; +using System; +using System.Diagnostics; +using System.IO; +using System.Threading.Tasks; + +namespace SkiaSharpCompare.Cli.Tests +{ + public class SkiaSharpCompareCliIntegrationTests + { + private const int ProcessTimeoutMs = 30_000; + + [Test] + public async Task SkiaSharpCompareCli_ShouldExitSuccessfully() + { + // Static precompiled CLI project directory (relative to test assembly output). + var cliProjectDir = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "SkiaSharpCompare.Cli")); + Assert.That(Directory.Exists(cliProjectDir), Is.True, $"Could not locate CLI project directory at '{cliProjectDir}'. Ensure the solution layout is unchanged and the path is valid."); + + // Find a JPG file in the repository to pass to the CLI. + var jpgFile = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "SkiaSharpCompareTestNunit", "TestData", "imageWithGpsMetadata.jpg")); + Assert.That(jpgFile, Is.Not.Null, "Could not locate any .jpg file in the repository to use for the integration test."); + + var startInfo = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"run -- \"{jpgFile}\" --meta", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = false, + WorkingDirectory = cliProjectDir + }; + + using var process = new Process { StartInfo = startInfo }; + + process.Start(); + + var stdOutTask = process.StandardOutput.ReadToEndAsync(); + var stdErrTask = process.StandardError.ReadToEndAsync(); + + var exited = await Task.Run(() => process.WaitForExit(ProcessTimeoutMs)); + if (!exited) + { + try + { + process.Kill(entireProcessTree: true); + } + catch + { + // Best effort + } + + Assert.Fail($"Process did not exit within {ProcessTimeoutMs} ms. StdOut:{Environment.NewLine}{await stdOutTask}{Environment.NewLine}StdErr:{Environment.NewLine}{await stdErrTask}"); + } + + var stdout = await stdOutTask; + var stderr = await stdErrTask; + + Assert.That(process.ExitCode, Is.EqualTo(0), $"Process exited with non-zero exit code {process.ExitCode}.{Environment.NewLine}StdOut:{Environment.NewLine}{stdout}{Environment.NewLine}StdErr:{Environment.NewLine}{stderr}"); + Assert.That(stdout, Does.Contain("GPS:GPS Altitude: 201")); + } + } +} \ No newline at end of file diff --git a/SkiaSharpCompare.Cli.Tests/SkiaSharpCompareCliTests.cs b/SkiaSharpCompare.Cli.Tests/SkiaSharpCompareCliTests.cs new file mode 100644 index 0000000..84f03c1 --- /dev/null +++ b/SkiaSharpCompare.Cli.Tests/SkiaSharpCompareCliTests.cs @@ -0,0 +1,74 @@ +using NUnit.Framework; +using System; +using System.IO; + +namespace SkiaSharpCompare.Cli.Tests +{ + [TestFixture] + public class SkiaSharpCompareCliTests + { + [Test] + public void CompareFiles_NonImageFiles_AreReportedUnsupported() + { + var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + + try + { + var a = Path.Combine(dir, "a.txt"); + var b = Path.Combine(dir, "b.txt"); + + File.WriteAllText(a, "not an image"); + File.WriteAllText(b, "not an image"); + + var info = CliRunner.CompareFiles(a, b); + Assert.That(info.Unsupported, Is.True); + Assert.That(info.Result, Is.Null); + Assert.That(info.ErrorMessage, Is.Not.Null.And.Not.Empty); + } + finally + { + Directory.Delete(dir, true); + } + } + + [Test] + public void CompareDirectories_MatchesMissingAndUnsupported() + { + var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + var dirA = Path.Combine(root, "A"); + var dirB = Path.Combine(root, "B"); + Directory.CreateDirectory(dirA); + Directory.CreateDirectory(dirB); + + try + { + // file only in A + var onlyA = Path.Combine(dirA, "onlyA.txt"); + File.WriteAllText(onlyA, "not image"); + + // file only in B + var onlyB = Path.Combine(dirB, "onlyB.txt"); + File.WriteAllText(onlyB, "not image"); + + // common file (but not a valid image) + var commonA = Path.Combine(dirA, "common.jpg"); + var commonB = Path.Combine(dirB, "common.jpg"); + File.WriteAllText(commonA, "not an image"); + File.WriteAllText(commonB, "not an image"); + + var summary = CliRunner.CompareDirectories(dirA, dirB); + + Assert.That(summary.OnlyInA, Has.Member("onlyA.txt")); + Assert.That(summary.OnlyInB, Has.Member("onlyB.txt")); + Assert.That(summary.MatchedResults.Keys, Has.Member("common.jpg")); + Assert.That(summary.MatchedResults["common.jpg"], Is.Null); + Assert.That(summary.UnsupportedFiles, Has.Member("common.jpg")); + } + finally + { + Directory.Delete(root, true); + } + } + } +} \ No newline at end of file diff --git a/SkiaSharpCompare.Cli/CliRunner.cs b/SkiaSharpCompare.Cli/CliRunner.cs new file mode 100644 index 0000000..716e15b --- /dev/null +++ b/SkiaSharpCompare.Cli/CliRunner.cs @@ -0,0 +1,249 @@ +using Codeuctivity.SkiaSharpCompare; + +namespace SkiaSharpCompare.Cli +{ + /// + /// Helper that exposes programmatic entry points for the console and tests. + /// + public static class CliRunner + { + /// + /// Result wrapper for a single file comparison. + /// + public sealed class FileCompareInfo + { + public ICompareResult? Result { get; init; } + public bool Unsupported { get; init; } + public string? ErrorMessage { get; init; } + } + + /// + /// Compare two image files. If an image cannot be loaded or compared, + /// will be true and will be null. + /// + public static FileCompareInfo CompareFiles(string pathA, string pathB, bool compareMetadata = true) + { + if (string.IsNullOrWhiteSpace(pathA)) + { + throw new ArgumentException("PathA is required", nameof(pathA)); + } + + if (string.IsNullOrWhiteSpace(pathB)) + { + throw new ArgumentException("PathB is required", nameof(pathB)); + } + + var absoluteA = Path.GetFullPath(pathA); + var absoluteB = Path.GetFullPath(pathB); + + var comparer = new ImageCompare(compareMetadata: compareMetadata); + + try + { + var result = comparer.CalcDiff(absoluteA, absoluteB); + return new FileCompareInfo + { + Result = result, + Unsupported = false, + ErrorMessage = null + }; + } + catch (Exception ex) + { + return new FileCompareInfo + { + Result = null, + Unsupported = true, + ErrorMessage = ex.Message + }; + } + } + + /// + /// Compare two directories. Matches files by filename. Returns compare results for matched names, + /// lists of files existing only in one directory and a list of files which failed to be compared (unsupported). + /// + public static DirectoryCompareSummary CompareDirectories(string directoryA, string directoryB, bool compareMetadata = true) + { + if (string.IsNullOrWhiteSpace(directoryA)) + { + throw new ArgumentException("directoryA is required", nameof(directoryA)); + } + + if (string.IsNullOrWhiteSpace(directoryB)) + { + throw new ArgumentException("directoryB is required", nameof(directoryB)); + } + + var dirA = Path.GetFullPath(directoryA); + var dirB = Path.GetFullPath(directoryB); + + if (!Directory.Exists(dirA)) + { + throw new DirectoryNotFoundException(dirA); + } + + if (!Directory.Exists(dirB)) + { + throw new DirectoryNotFoundException(dirB); + } + + var filesA = Directory.GetFiles(dirA).Select(Path.GetFileName).Where(n => n != null).Cast().ToHashSet(StringComparer.OrdinalIgnoreCase); + var filesB = Directory.GetFiles(dirB).Select(Path.GetFileName).Where(n => n != null).Cast().ToHashSet(StringComparer.OrdinalIgnoreCase); + + var onlyInA = filesA.Except(filesB, StringComparer.OrdinalIgnoreCase).OrderBy(n => n).ToList(); + var onlyInB = filesB.Except(filesA, StringComparer.OrdinalIgnoreCase).OrderBy(n => n).ToList(); + var matched = filesA.Intersect(filesB, StringComparer.OrdinalIgnoreCase).OrderBy(n => n).ToList(); + + var matchedResults = new Dictionary(StringComparer.OrdinalIgnoreCase); + var unsupported = new List(); + + foreach (var fileName in matched) + { + var pathA = Path.Combine(dirA, fileName); + var pathB = Path.Combine(dirB, fileName); + + var info = CompareFiles(pathA, pathB, compareMetadata: compareMetadata); + if (info.Unsupported) + { + matchedResults[fileName] = null; + unsupported.Add(fileName); + } + else + { + matchedResults[fileName] = info.Result; + } + } + + return new DirectoryCompareSummary + { + MatchedResults = matchedResults, + OnlyInA = onlyInA, + OnlyInB = onlyInB, + UnsupportedFiles = unsupported + }; + } + + /// + /// Print a file comparison result to the provided text writer. + /// + public static void PrintCompareResult(FileCompareInfo info, TextWriter writer, string? nameA = null, string? nameB = null) + { + if (writer is null) + { + throw new ArgumentNullException(nameof(writer)); + } + + nameA ??= "A"; + nameB ??= "B"; + + writer.WriteLine($"Comparing: {nameA} <> {nameB}"); + + if (info.Unsupported) + { + writer.WriteLine($" Unsupported or failed to compare: {info.ErrorMessage}"); + return; + } + + var result = info.Result!; + writer.WriteLine($" PixelErrorCount: {result.PixelErrorCount}"); + writer.WriteLine($" PixelErrorPercentage: {result.PixelErrorPercentage:F4}"); + writer.WriteLine($" AbsoluteError: {result.AbsoluteError}"); + writer.WriteLine($" MeanError: {result.MeanError:F4}"); + + if (result.MetadataDifferences is null) + { + writer.WriteLine(" Metadata comparison disabled (null)."); + } + else if (result.MetadataDifferences.Count == 0) + { + writer.WriteLine(" Metadata: no differences."); + } + else + { + writer.WriteLine(" Metadata differences:"); + foreach (var kvp in result.MetadataDifferences) + { + writer.WriteLine($" {kvp.Key}: (A: {kvp.Value.ValueA ?? string.Empty}, B: {kvp.Value.ValueB ?? string.Empty})"); + } + } + } + + /// + /// Print directory compare summary to the writer. + /// + public static void PrintDirectorySummary(DirectoryCompareSummary summary, TextWriter writer) + { + if (writer is null) + { + throw new ArgumentNullException(nameof(writer)); + } + + writer.WriteLine("Directory comparison summary:"); + writer.WriteLine(); + + writer.WriteLine("Matched files:"); + if (summary.MatchedResults.Count == 0) + { + writer.WriteLine(" (none)"); + } + else + { + foreach (var kvp in summary.MatchedResults.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase)) + { + writer.WriteLine($" {kvp.Key}:"); + if (kvp.Value is null) + { + writer.WriteLine(" Unsupported or failed to compare"); + } + else + { + writer.WriteLine($" PixelErrorCount: {kvp.Value.PixelErrorCount}, PixelErrorPercentage: {kvp.Value.PixelErrorPercentage:F4}"); + } + } + } + + writer.WriteLine(); + writer.WriteLine("Only in A:"); + if (summary.OnlyInA.Count == 0) + { + writer.WriteLine(" (none)"); + } + else + { + foreach (var n in summary.OnlyInA) + { + writer.WriteLine($" {n}"); + } + } + + writer.WriteLine(); + writer.WriteLine("Only in B:"); + if (summary.OnlyInB.Count == 0) + { + writer.WriteLine(" (none)"); + } + else + { + foreach (var n in summary.OnlyInB) + { + writer.WriteLine($" {n}"); + } + } + + writer.WriteLine(); + writer.WriteLine("Unsupported files:"); + if (summary.UnsupportedFiles.Count == 0) + { + writer.WriteLine(" (none)"); + } + else + { + foreach (var n in summary.UnsupportedFiles) + { + writer.WriteLine($" {n}"); + } + } + } + } +} \ No newline at end of file diff --git a/SkiaSharpCompare.Cli/DirectoryCompareSummary.cs b/SkiaSharpCompare.Cli/DirectoryCompareSummary.cs new file mode 100644 index 0000000..4cd2fd5 --- /dev/null +++ b/SkiaSharpCompare.Cli/DirectoryCompareSummary.cs @@ -0,0 +1,15 @@ +using Codeuctivity.SkiaSharpCompare; + +namespace SkiaSharpCompare.Cli +{ + /// + /// Summary of comparing two directories. + /// + public sealed class DirectoryCompareSummary + { + public Dictionary MatchedResults { get; init; } = new(); + public List OnlyInA { get; init; } = new(); + public List OnlyInB { get; init; } = new(); + public List UnsupportedFiles { get; init; } = new(); + } +} \ No newline at end of file diff --git a/SkiaSharpCompare.Cli/Program.cs b/SkiaSharpCompare.Cli/Program.cs new file mode 100644 index 0000000..ffad1d8 --- /dev/null +++ b/SkiaSharpCompare.Cli/Program.cs @@ -0,0 +1,81 @@ +using Codeuctivity.SkiaSharpCompare; + +namespace SkiaSharpCompare.Cli +{ + internal static class Program + { + private static int Main(string[] args) + { + if (args is null || args.Length < 1) + { + Console.WriteLine("Usage:"); + Console.WriteLine(" dotnet run -- "); + Console.WriteLine(" dotnet run -- --dir"); + Console.WriteLine(" dotnet run -- --meta # show metadata for a file"); + return 2; + } + + try + { + // If user requested metadata display + if (Array.IndexOf(args, "--meta") >= 0) + { + var fileArg = args[0]; + var absolute = Path.GetFullPath(fileArg); + if (!File.Exists(absolute)) + { + Console.Error.WriteLine($"File not found: {absolute}"); + return 4; + } + + try + { + var meta = MetadataExtractorAdapter.Extract(absolute); + Console.WriteLine($"Metadata for: {absolute}"); + if (meta.Count == 0) + { + Console.WriteLine(" (no metadata found)"); + } + else + { + foreach (var kv in meta.OrderBy(k => k.Key)) + { + Console.WriteLine($" {kv.Key}: {kv.Value}"); + } + } + + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Failed to extract metadata: {ex.Message}"); + return 5; + } + } + + if (args.Length >= 3 && args[2] == "--dir") + { + var summary = CliRunner.CompareDirectories(args[0], args[1]); + CliRunner.PrintDirectorySummary(summary, Console.Out); + } + else if (args.Length >= 2) + { + var resultInfo = CliRunner.CompareFiles(args[0], args[1], compareMetadata: true); + CliRunner.PrintCompareResult(resultInfo, Console.Out, Path.GetFileName(args[0]), Path.GetFileName(args[1])); + } + else + { + Console.WriteLine("Insufficient arguments. See usage."); + return 2; + } + + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Fatal error: {ex.Message}"); + return 3; + } + } + } +} \ No newline at end of file diff --git a/SkiaSharpCompare.Cli/SkiaSharpCompare.Cli.csproj b/SkiaSharpCompare.Cli/SkiaSharpCompare.Cli.csproj new file mode 100644 index 0000000..70af7c5 --- /dev/null +++ b/SkiaSharpCompare.Cli/SkiaSharpCompare.Cli.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + enable + enable + 12 + + + + + + + \ No newline at end of file diff --git a/SkiaSharpCompare.sln b/SkiaSharpCompare.sln deleted file mode 100644 index 1524ac7..0000000 --- a/SkiaSharpCompare.sln +++ /dev/null @@ -1,41 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{A1B6F6C3-ECBE-471C-AE18-199DEF5982C3}" - ProjectSection(SolutionItems) = preProject - .editorconfig = .editorconfig - .github\workflows\cla.yml = .github\workflows\cla.yml - .github\dependabot.yml = .github\dependabot.yml - .github\workflows\dotnet.yml = .github\workflows\dotnet.yml - README.md = README.md - .github\workflows\stale.yml = .github\workflows\stale.yml - EndProjectSection -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SkiaSharpCompare", "SkiaSharpCompare\SkiaSharpCompare.csproj", "{556DA894-7456-4DE2-9E02-3815FE159D7F}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SkiaSharpCompareTestNunit", "SkiaSharpCompareTestNunit\SkiaSharpCompareTestNunit.csproj", "{9CFA9A6F-92FC-40E3-BB85-4C8F95EFC8C7}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {556DA894-7456-4DE2-9E02-3815FE159D7F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {556DA894-7456-4DE2-9E02-3815FE159D7F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {556DA894-7456-4DE2-9E02-3815FE159D7F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {556DA894-7456-4DE2-9E02-3815FE159D7F}.Release|Any CPU.Build.0 = Release|Any CPU - {9CFA9A6F-92FC-40E3-BB85-4C8F95EFC8C7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {9CFA9A6F-92FC-40E3-BB85-4C8F95EFC8C7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {9CFA9A6F-92FC-40E3-BB85-4C8F95EFC8C7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {9CFA9A6F-92FC-40E3-BB85-4C8F95EFC8C7}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {AC72E02E-48E4-4DDE-B1FF-CEF86C9DFCAB} - EndGlobalSection -EndGlobal diff --git a/SkiaSharpCompare.slnx b/SkiaSharpCompare.slnx new file mode 100644 index 0000000..d19966e --- /dev/null +++ b/SkiaSharpCompare.slnx @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/SkiaSharpCompare/CompareResult.cs b/SkiaSharpCompare/CompareResult.cs index 30aac45..bed79df 100644 --- a/SkiaSharpCompare/CompareResult.cs +++ b/SkiaSharpCompare/CompareResult.cs @@ -1,45 +1,48 @@ +using System.Collections.Generic; + namespace Codeuctivity.SkiaSharpCompare { /// /// Dto - outcome of compared images /// - public class CompareResult : ICompareResult + /// + /// ctor for CompareResult + /// + /// Mean error + /// Absolute error + /// Number of pixels that differ between images + /// Percentage of pixels that differ between images + /// Metadata that differ between images + public class CompareResult(int absoluteError, double meanError, int pixelErrorCount, double pixelErrorPercentage, Dictionary? metadataDifference = null) : ICompareResult { /// /// Mean pixel error of absolute pixel error /// /// 0-765 - public double MeanError { get; } + public double MeanError { get; } = meanError; /// /// Absolute pixel error, counts each color channel on every pixel the delta /// - public int AbsoluteError { get; } + public int AbsoluteError { get; } = absoluteError; /// /// Number of pixels that differ between images /// - public int PixelErrorCount { get; } + public int PixelErrorCount { get; } = pixelErrorCount; /// /// Percentage of pixels that differ between images /// /// 0-100.0 - public double PixelErrorPercentage { get; } + public double PixelErrorPercentage { get; } = pixelErrorPercentage; /// - /// ctor for CompareResult + /// Gets a collection of metadata keys and their differing values between two sources. /// - /// Mean error - /// Absolute error - /// Number of pixels that differ between images - /// Percentage of pixels that differ between images - public CompareResult(int absoluteError, double meanError, int pixelErrorCount, double pixelErrorPercentage) - { - MeanError = meanError; - AbsoluteError = absoluteError; - PixelErrorCount = pixelErrorCount; - PixelErrorPercentage = pixelErrorPercentage; - } + /// Each entry in the dictionary represents a metadata key for which the values differ. + /// The tuple contains the value from the first source and the value from the second source, respectively. If a + /// value is null, the key may be missing from that source. + public Dictionary? MetadataDifferences { get; } = metadataDifference; } } \ No newline at end of file diff --git a/SkiaSharpCompare/CompareResultWithMetadata.cs b/SkiaSharpCompare/CompareResultWithMetadata.cs new file mode 100644 index 0000000..abb087e --- /dev/null +++ b/SkiaSharpCompare/CompareResultWithMetadata.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; + +namespace Codeuctivity.SkiaSharpCompare +{ + internal class CompareResultWithMetadata(ICompareResult compareResult, Dictionary? metadataDiff) : ICompareResult + { + public double MeanError { get; } = compareResult.MeanError; + public int AbsoluteError { get; } = compareResult.AbsoluteError; + public int PixelErrorCount { get; } = compareResult.PixelErrorCount; + public double PixelErrorPercentage { get; } = compareResult.PixelErrorPercentage; + public Dictionary? MetadataDifferences { get; } = metadataDiff; + } +} \ No newline at end of file diff --git a/SkiaSharpCompare/ICompareResult.cs b/SkiaSharpCompare/ICompareResult.cs index 9d737ca..8099b25 100644 --- a/SkiaSharpCompare/ICompareResult.cs +++ b/SkiaSharpCompare/ICompareResult.cs @@ -1,4 +1,6 @@ -namespace Codeuctivity.SkiaSharpCompare +using System.Collections.Generic; + +namespace Codeuctivity.SkiaSharpCompare { /// /// Dto - of compared images @@ -24,5 +26,13 @@ public interface ICompareResult /// Percentage of pixels that differ between images /// double PixelErrorPercentage { get; } + + /// + /// Gets a collection of metadata keys and their differing values between two sources. + /// + /// Each entry in the dictionary represents a metadata key for which the values differ + /// between the two compared sources. The tuple contains the value from the first source and the value from the + /// second source. If there are no differences or metadata is unavailable, the property returns null. + Dictionary? MetadataDifferences { get; } } } \ No newline at end of file diff --git a/SkiaSharpCompare/ImageCompare.cs b/SkiaSharpCompare/ImageCompare.cs new file mode 100644 index 0000000..646a703 --- /dev/null +++ b/SkiaSharpCompare/ImageCompare.cs @@ -0,0 +1,336 @@ +using SkiaSharp; +using System; +using System.IO; + +namespace Codeuctivity.SkiaSharpCompare +{ + /// + /// Provides functionality for comparing images and calculating differences between them. + /// + /// The class allows users to compare images using various options, + /// such as resizing, transparency handling, and pixel color shift tolerance. It supports multiple input types, + /// including file paths, streams, and in-memory bitmaps. The class also provides methods to generate difference + /// masks and check for image equality or size equality. + /// + /// Initializes a new instance of the class with the specified options for resizing, + /// transparency handling, and color shift tolerance. + /// + /// Specifies how images should be resized before comparison. The default is . + /// Specifies how transparency should be handled during comparison. The default is . + /// Specifies the tolerance for color shifts in pixel values during comparison. A value of 0 means no + /// tolerance, and higher values allow for greater differences. The default is 0. + /// If true, compares image metadata (EXIF, etc.) in addition to pixel data. + public class ImageCompare(ResizeOption resizeOption = ResizeOption.DontResize, TransparencyOptions transparencyOptions = TransparencyOptions.CompareAlphaChannel, int pixelColorShiftTolerance = 0, bool compareMetadata = false) + { + /// + /// Gets the resize option that determines how an image should be resized. + /// + public ResizeOption ResizeOption { get; } = resizeOption; + + /// + /// Gets the transparency options that determine how transparency should be handled during image comparison. + /// + public TransparencyOptions TransparencyOptions { get; } = transparencyOptions; + + /// + /// Gets the tolerance level for pixel color shifts in image processing operations. + /// + /// This property is typically used to determine whether two pixels are considered + /// similar in color during image comparison or analysis tasks. + public int PixelColorShiftTolerance { get; } = pixelColorShiftTolerance; + + /// + /// Gets a value indicating whether metadata should be included in the comparison operation. + /// + public bool CompareMetadata { get; } = compareMetadata; + + /// + /// Calculates the difference between two images located at the specified file paths. + /// + /// The comparison process may involve resizing the images or applying tolerances for + /// pixel color shifts and transparency, depending on the configured options. + /// The file path to the first image. This cannot be null or empty. + /// The file path to the second image. This cannot be null or empty. + /// An object representing the differences between the two images. + public ICompareResult CalcDiff(string pathImage1, string pathImage2) + { + var compareResult = Compare.CalcDiff(pathImage1, pathImage2, ResizeOption, PixelColorShiftTolerance, TransparencyOptions); + + if (CompareMetadata) + { + var metadataDiff = MetadataComparer.CompareMetadata(pathImage1, pathImage2); + return new CompareResultWithMetadata(compareResult, metadataDiff); + } + + return compareResult; + } + + /// + /// Calculates the difference between two images located at the specified file paths, using a provided difference mask. + /// + /// + /// + /// + /// + public ICompareResult CalcDiff(string pahtImage1, string pathImage2, string differenceMask) + { + var compareResult = Compare.CalcDiff(pahtImage1, pathImage2, differenceMask, ResizeOption, PixelColorShiftTolerance, TransparencyOptions); + + if (CompareMetadata) + { + var metadataDiff = MetadataComparer.CompareMetadata(pahtImage1, pathImage2); + return new CompareResultWithMetadata(compareResult, metadataDiff); + } + + return compareResult; + } + + /// + /// Calculates the difference between two in-memory images represented as objects. + /// + /// + /// + /// + public ICompareResult CalcDiff(SKBitmap image1, SKBitmap image2) + { + ArgumentNullException.ThrowIfNull(image1); + ArgumentNullException.ThrowIfNull(image2); + + if (CompareMetadata) + { + throw new NotSupportedException("Metadata comparison is not implemented for SKBitmap inputs. https://github.com/mono/SkiaSharp/issues/1139 Use the overload with streams or filepath to get support for metadata comparison."); + } + + return Compare.CalcDiffInternal(image1, image2, null, ResizeOption, PixelColorShiftTolerance, TransparencyOptions); + } + + /// + /// Calculates the difference between two in-memory images represented as objects. + /// + /// + /// + /// + /// + public ICompareResult CalcDiff(SKBitmap image1, SKBitmap image2, SKBitmap differenceMaskPic) + { + if (CompareMetadata) + { + throw new NotSupportedException("Metadata comparison is not implemented for SKBitmap inputs. https://github.com/mono/SkiaSharp/issues/1139 Use the overload with streams or filepath to get support for metadata comparison."); + } + + return Compare.CalcDiff(image1, image2, differenceMaskPic, ResizeOption, PixelColorShiftTolerance, TransparencyOptions); + } + + /// + /// Calculates the difference between two in-memory images represented as objects. + /// + /// + /// + /// + public ICompareResult CalcDiff(Stream image1, Stream image2) + { + var compareResult = Compare.CalcDiff(image1, image2, ResizeOption, PixelColorShiftTolerance, TransparencyOptions); + + if (CompareMetadata) + { + if (image1.CanSeek) + { + image1.Position = 0; + } + + if (image2.CanSeek) + { + image2.Position = 0; + } + + var metadataDiff = MetadataComparer.CompareMetadata(image1, image2); + + if (image1.CanSeek) + { + image1.Position = 0; + } + + if (image2.CanSeek) + { + image2.Position = 0; + } + + return new CompareResultWithMetadata(compareResult, metadataDiff); + } + + return compareResult; + } + + /// + /// Calculates the difference between two images provided as file streams, using a specified mask image to focus the comparison. + /// + /// + /// + /// + /// + public ICompareResult CalcDiff(Stream image1, Stream image2, SKBitmap maskImage) + { + var compareResult = Compare.CalcDiff(image1, image2, maskImage, ResizeOption, PixelColorShiftTolerance, TransparencyOptions); + + if (CompareMetadata) + { + var metadataDiff = MetadataComparer.CompareMetadata(image1, image2); + return new CompareResultWithMetadata(compareResult, metadataDiff); + } + + return compareResult; + } + + /// + /// Calculates a difference mask image that highlights the differences between two images located at the specified file paths. + /// + /// + /// + /// + public SKBitmap CalcDiffMaskImage(string pathImage1, string pathImage2) + { + return Compare.CalcDiffMaskImage(pathImage1, pathImage2, ResizeOption, PixelColorShiftTolerance, TransparencyOptions); + } + + /// + /// Calculates a difference mask image that highlights the differences between two in-memory images represented as objects. + /// + /// + /// + /// + public SKBitmap CalcDiffMaskImage(SKBitmap image1, SKBitmap image2) + { + return Compare.CalcDiffMaskImage(image1, image2, ResizeOption, PixelColorShiftTolerance, TransparencyOptions); + } + + /// + /// Calculates a difference mask image that highlights the differences between two images located at the specified file paths, + /// + /// + /// + /// + /// + public SKBitmap CalcDiffMaskImage(string pathImage1, string pathImage2, string pathDiffMask) + { + return Compare.CalcDiffMaskImage(pathImage1, pathImage2, pathDiffMask, ResizeOption, PixelColorShiftTolerance, TransparencyOptions); + } + + /// + /// Calculates a difference mask image that highlights the differences between two images provided as file streams. + /// + /// + /// + /// + public SKBitmap CalcDiffMaskImage(Stream image1, Stream image2) + { + return Compare.CalcDiffMaskImage(image1, image2, ResizeOption, PixelColorShiftTolerance, TransparencyOptions); + } + + /// + /// Calculates a difference mask image that highlights the differences between two images provided as file streams, + /// + /// + /// + /// + /// + public SKBitmap CalcDiffMaskImage(Stream image1, Stream image2, Stream diffMask) + { + return Compare.CalcDiffMaskImage(image1, image2, diffMask, ResizeOption, PixelColorShiftTolerance, TransparencyOptions); + } + + /// + /// Calculates a difference mask image that highlights the differences between two in-memory images represented as objects, + /// + /// + /// + /// + /// + public SKBitmap CalcDiffMaskImage(SKBitmap image1, SKBitmap image2, SKBitmap diffMask) + { + return Compare.CalcDiffMaskImage(image1, image2, diffMask, ResizeOption, PixelColorShiftTolerance, TransparencyOptions); + } + + /// + /// Checks if two images located at the specified file paths are identical, considering the configured options for resizing, + /// + /// + /// + /// + public bool ImagesAreEqual(string pathImage1, string pathImage2) + { + return Compare.ImagesAreEqual(pathImage1, pathImage2, ResizeOption, PixelColorShiftTolerance, TransparencyOptions, CompareMetadata); + } + + /// + /// Checks if two images provided as file streams are identical, considering the configured options for resizing, + /// + /// + /// + /// + public bool ImagesAreEqual(Stream image1, Stream image2) + { + return Compare.ImagesAreEqual(image1, image2, ResizeOption, PixelColorShiftTolerance, TransparencyOptions, CompareMetadata); + } + + /// + /// Checks if two in-memory images represented as objects are identical, considering the configured options for resizing, + /// + /// + /// + /// + public bool ImagesAreEqual(SKBitmap image1, SKBitmap image2) + { + return Compare.ImagesAreEqual(image1, image2, ResizeOption, PixelColorShiftTolerance, TransparencyOptions, CompareMetadata); + } + + /// + /// Checks if two images have the same dimensions (width and height). + /// + /// + /// + /// + public static bool ImagesHaveEqualSize(string pathImage1, string pathImage2) + { + return Compare.ImagesHaveEqualSize(pathImage1, pathImage2); + } + + /// + /// Create a tolerance mask by expanding non-black regions in a diff mask. + /// Non-black pixels in diffMask will be grown by padding into broader white areas + /// to tolerate local differences (e.g., filenames, timestamps). + /// + public SKBitmap CreateToleranceMaskFromDiff(SKBitmap diffMask, int padding = 10) + { + ArgumentNullException.ThrowIfNull(diffMask); + var w = diffMask.Width; + var h = diffMask.Height; + var mask = new SKBitmap(w, h, SKColorType.Rgba8888, SKAlphaType.Unpremul); + using var canvas = new SKCanvas(mask); + canvas.Clear(SKColors.Black); + + using var paint = new SKPaint { Color = SKColors.White, IsAntialias = false }; + + for (var y = 0; y < h; y++) + { + for (var x = 0; x < w; x++) + { + var col = diffMask.GetPixel(x, y); + if (col.Alpha > 16 && (col.Red > 16 || col.Green > 16 || col.Blue > 16)) + { + var left = Math.Max(0, x - padding); + var top = Math.Max(0, y - padding); + var right = Math.Min(w, x + padding + 1); + var bottom = Math.Min(h, y + padding + 1); + var rect = new SKRectI(left, top, right, bottom); + canvas.DrawRect(rect, paint); + } + } + } + + canvas.Flush(); + return mask; + } + } +} \ No newline at end of file diff --git a/SkiaSharpCompare/MetadataComparer.cs b/SkiaSharpCompare/MetadataComparer.cs new file mode 100644 index 0000000..5869b5b --- /dev/null +++ b/SkiaSharpCompare/MetadataComparer.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace Codeuctivity.SkiaSharpCompare +{ + internal static class MetadataComparer + { + internal static Dictionary? CompareMetadata(string pathImageActual, string pathImageExpected) + { + var metaA = MetadataExtractorAdapter.Extract(pathImageActual); + var metaB = MetadataExtractorAdapter.Extract(pathImageExpected); + + return GetMetadataDifferences(metaA, metaB); + } + + internal static Dictionary? CompareMetadata(Stream imageActual, Stream imageExpected) + { + var metaA = MetadataExtractorAdapter.Extract(imageActual); + var metaB = MetadataExtractorAdapter.Extract(imageExpected); + + return GetMetadataDifferences(metaA, metaB); + } + + private static Dictionary? GetMetadataDifferences(IReadOnlyDictionary a, IReadOnlyDictionary b) + { + var diffs = new List<(string, string?, string?)>(); + var allKeys = new HashSet(a.Keys, StringComparer.OrdinalIgnoreCase); + allKeys.UnionWith(b.Keys); + + foreach (var key in allKeys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase)) + { + a.TryGetValue(key, out var valA); + b.TryGetValue(key, out var valB); + + // Normalize whitespace for comparison + var normA = valA?.Trim() ?? string.Empty; + var normB = valB?.Trim() ?? string.Empty; + + if (!string.Equals(normA, normB, StringComparison.Ordinal)) + { + diffs.Add((key, valA, valB)); + } + } + + return diffs.ToDictionary(diff => diff.Item1, diff => (diff.Item2, diff.Item3) + ); + } + } +} \ No newline at end of file diff --git a/SkiaSharpCompare/MetadataExtractorAdapter.cs b/SkiaSharpCompare/MetadataExtractorAdapter.cs new file mode 100644 index 0000000..6a9fb81 --- /dev/null +++ b/SkiaSharpCompare/MetadataExtractorAdapter.cs @@ -0,0 +1,76 @@ +using MetadataExtractor; +using System.Collections.Generic; +using System.IO; + +namespace Codeuctivity.SkiaSharpCompare +{ + /// + /// Provides static methods for extracting image metadata and returning it as a normalized dictionary of tag names + /// and values. + /// + /// This class serves as an adapter for reading metadata from image files or streams using the + /// underlying MetadataExtractor library. All returned tag keys are normalized in the format "Directory:TagName" for + /// consistency. The class is thread-safe as it contains only stateless static methods. + public static class MetadataExtractorAdapter + { + /// + /// Extracts metadata tags from the specified image stream and returns them as a normalized, case-insensitive + /// dictionary. + /// + /// The caller is responsible for ensuring that the provided stream is positioned at the + /// start of the image data and remains open for the duration of the operation. The returned dictionary uses + /// case-insensitive keys. If the stream is not seekable, consider passing a seekable copy to avoid + /// errors. + /// A seekable stream containing image data from which to read metadata. The stream must support reading and + /// seeking. + /// A read-only dictionary containing metadata tag names and their corresponding values extracted from the + /// image. The dictionary is empty if no metadata is found. + public static IReadOnlyDictionary Extract(Stream imageStream) + { + var map = new Dictionary(System.StringComparer.OrdinalIgnoreCase); + + // MetadataExtractor reads from stream + var directories = ImageMetadataReader.ReadMetadata(imageStream); + return CreateResult(map, directories); + } + + /// + /// Extracts metadata from the specified image file and returns it as a read-only dictionary of tag names and + /// values. + /// + /// The path to the image file from which to extract metadata. Cannot be null or empty. + /// A read-only dictionary containing metadata tag names and their corresponding values. The dictionary is empty + /// if no metadata is found. + public static IReadOnlyDictionary Extract(string imagePath) + { + var map = new Dictionary(System.StringComparer.OrdinalIgnoreCase); + + // MetadataExtractor reads from stream + var directories = ImageMetadataReader.ReadMetadata(imagePath); + return CreateResult(map, directories); + } + + private static Dictionary CreateResult(Dictionary map, IReadOnlyList directories) + { + foreach (var directory in directories) + { + foreach (var tag in directory.Tags) + { + // Normalize key as "Directory:TagName" + var key = $"{directory.Name}:{tag.Name}"; + if (map.TryGetValue(key, out var existingValue)) + { + // If duplicate keys occur, append with a separator + map[key] = existingValue + "; " + (tag.Description ?? string.Empty); + } + else + { + map[key] = tag.Description ?? string.Empty; + } + } + } + + return map; + } + } +} \ No newline at end of file diff --git a/SkiaSharpCompare/ResizeOption.cs b/SkiaSharpCompare/ResizeOption.cs new file mode 100644 index 0000000..ed31140 --- /dev/null +++ b/SkiaSharpCompare/ResizeOption.cs @@ -0,0 +1,18 @@ +namespace Codeuctivity.SkiaSharpCompare +{ + /// + /// Options that are applied if images do have different image dimension + /// + public enum ResizeOption + { + /// + /// Dont resize images with different size. + /// + DontResize, + + /// + /// Images with different size will get resized before pixel based compare is used to determine equality. + /// + Resize + } +} \ No newline at end of file diff --git a/SkiaSharpCompare/SkiaSharpCompare.cs b/SkiaSharpCompare/SkiaSharpCompare.cs index 62749c0..cc16dd8 100644 --- a/SkiaSharpCompare/SkiaSharpCompare.cs +++ b/SkiaSharpCompare/SkiaSharpCompare.cs @@ -1,5 +1,6 @@ using SkiaSharp; using System; +using System.Collections.Generic; using System.IO; namespace Codeuctivity.SkiaSharpCompare @@ -7,238 +8,398 @@ namespace Codeuctivity.SkiaSharpCompare /// /// SkiaSharpCompare, compares images. An alpha channel is ignored. /// - public static class SkiaSharpCompare + public static class Compare { private const string sizeDiffersExceptionMessage = "Size of images differ."; /// /// Is true if width and height of both images are equal /// - /// - /// + /// + /// /// - public static bool ImagesHaveEqualSize(string pathImageActual, string pathImageExpected) + public static bool ImagesHaveEqualSize(string pathImage1, string pathImage2) { - using var actualImage = SKBitmap.Decode(pathImageActual); - using var expectedImage = SKBitmap.Decode(pathImageExpected); - return ImagesHaveEqualSize(actualImage, expectedImage); + using var image1 = SKBitmap.Decode(pathImage1); + using var image2 = SKBitmap.Decode(pathImage2); + return ImagesHaveEqualSize(image1, image2); } /// /// Is true if width and height of both images are equal /// - /// - /// + /// + /// /// - public static bool ImagesHaveEqualSize(Stream actual, Stream expected) + public static bool ImagesHaveEqualSize(Stream image1, Stream image2) { - using var actualImage = SKBitmap.Decode(actual); - using var expectedImage = SKBitmap.Decode(expected); + ArgumentNullException.ThrowIfNull(image1); + ArgumentNullException.ThrowIfNull(image2); + using var actualImage = DecodeStream(image1); + using var expectedImage = DecodeStream(image2); return ImagesHaveEqualSize(actualImage, expectedImage); } /// /// Is true if width and height of both images are equal /// - /// - /// + /// + /// /// - public static bool ImagesHaveEqualSize(SKBitmap actualImage, SKBitmap expectedImage) + public static bool ImagesHaveEqualSize(SKBitmap image1, SKBitmap image2) { - return ImagesHaveSameDimension(actualImage, expectedImage); + return ImagesHaveSameDimension(image1, image2); } /// /// Compares two images for equivalence /// - /// - /// + /// + /// + /// + /// + /// + /// If true, compares image metadata (EXIF, etc.) in addition to pixel data. /// True if every pixel of actual is equal to expected - public static bool ImagesAreEqual(string pathImageActual, string pathImageExpected) + public static bool ImagesAreEqual(string pathImage1, string pathImage2, ResizeOption resizeOption = ResizeOption.DontResize, int pixelColorShiftTolerance = 0, TransparencyOptions transparencyOptions = TransparencyOptions.CompareAlphaChannel, bool compareMetadata = false) { - using var actualImage = SKBitmap.Decode(pathImageActual); - using var expectedImage = SKBitmap.Decode(pathImageExpected); - return ImagesAreEqual(actualImage, expectedImage); + if (compareMetadata) + { + var hasMetadataDiff = MetadataComparer.CompareMetadata(pathImage1, pathImage2)?.Count != 0; + + if (hasMetadataDiff) + { + return false; + } + } + + using var image1 = SKBitmap.Decode(pathImage1); + using var image2 = SKBitmap.Decode(pathImage2); + return ImagesAreEqual(image1, image2, resizeOption, pixelColorShiftTolerance, transparencyOptions); } /// /// Compares two images for equivalence /// - /// - /// + /// + /// + /// + /// + /// + /// If true, compares image metadata (EXIF, etc.) in addition to pixel data. /// True if every pixel of actual is equal to expected - public static bool ImagesAreEqual(Stream actual, Stream expected) + public static bool ImagesAreEqual(Stream image1, Stream image2, ResizeOption resizeOption = ResizeOption.DontResize, int pixelColorShiftTolerance = 0, TransparencyOptions transparencyOptions = TransparencyOptions.CompareAlphaChannel, bool compareMetadata = false) { - using var actualImage = SKBitmap.Decode(actual); - using var expectedImage = SKBitmap.Decode(expected); - return ImagesAreEqual(actualImage, expectedImage); + if (compareMetadata) + { + var hasMetadataDiff = MetadataComparer.CompareMetadata(image1, image2)?.Count != 0; + + if (hasMetadataDiff) + { + return false; + } + } + + ArgumentNullException.ThrowIfNull(image1); + ArgumentNullException.ThrowIfNull(image2); + + using var decodedImage1 = DecodeStream(image1); + using var decodedImage2 = DecodeStream(image2); + return ImagesAreEqual(decodedImage1, decodedImage2, resizeOption, pixelColorShiftTolerance, transparencyOptions); } /// /// Compares two images for equivalence /// - /// - /// + /// + /// + /// + /// + /// + /// If true, compares image metadata (EXIF, etc.) in addition to pixel data. /// True if every pixel of actual is equal to expected - public static bool ImagesAreEqual(SKBitmap actual, SKBitmap expected) + public static bool ImagesAreEqual(SKBitmap image1, SKBitmap image2, ResizeOption resizeOption = ResizeOption.DontResize, int pixelColorShiftTolerance = 0, TransparencyOptions transparencyOptions = TransparencyOptions.CompareAlphaChannel, bool compareMetadata = false) { - if (!ImagesHaveSameDimension(actual, expected)) + if (compareMetadata) + { + throw new NotSupportedException("Metadata comparison is not implemented for SKBitmap inputs. https://github.com/mono/SkiaSharp/issues/1139 Use the overload with streams or filepath to get support for metadata comparison."); + } + + ArgumentNullException.ThrowIfNull(image1); + ArgumentNullException.ThrowIfNull(image2); + + if (resizeOption == ResizeOption.DontResize && !ImagesHaveSameDimension(image1, image2)) { return false; } - for (var x = 0; x < actual.Width; x++) + if (resizeOption == ResizeOption.DontResize || ImagesHaveSameDimension(image1, image2)) { - for (var y = 0; y < actual.Height; y++) + for (var x = 0; x < image1.Width; x++) { - if (!actual.GetPixel(x, y).Equals(expected.GetPixel(x, y))) + for (var y = 0; y < image1.Height; y++) { - return false; + if (transparencyOptions == TransparencyOptions.CompareAlphaChannel && pixelColorShiftTolerance == 0 && !image1.GetPixel(x, y).Equals(image2.GetPixel(x, y))) + { + return false; + } + else + { + var actualPixel = image1.GetPixel(x, y); + var expectedPixel = image2.GetPixel(x, y); + var a = 0; + + if (transparencyOptions == TransparencyOptions.CompareAlphaChannel) + { + a = Math.Abs(expectedPixel.Alpha - actualPixel.Alpha); + } + + var r = Math.Abs(expectedPixel.Red - actualPixel.Red); + var g = Math.Abs(expectedPixel.Green - actualPixel.Green); + var b = Math.Abs(expectedPixel.Blue - actualPixel.Blue); + var sum = r + g + b + a; + + if (sum > pixelColorShiftTolerance) + { + return false; + } + } } } + + return true; + } + + var grown = GrowToSameDimension(image1, image2); + try + { + return ImagesAreEqual(grown.Item1, grown.Item2, ResizeOption.DontResize); + } + finally + { + grown.Item1?.Dispose(); + grown.Item2?.Dispose(); } + } - return true; + /// + /// Calculates ICompareResult expressing the amount of difference of both images + /// + /// + /// + /// + /// + /// + /// Mean and absolute pixel error + public static ICompareResult CalcDiff(string pathImage1, string pathImage2, ResizeOption resizeOption = ResizeOption.DontResize, int pixelColorShiftTolerance = 0, TransparencyOptions transparencyOptions = TransparencyOptions.IgnoreAlphaChannel) + { + using var actual = SKBitmap.Decode(pathImage1); + using var expected = SKBitmap.Decode(pathImage2); + return CalcDiffInternal(actual, expected, null, resizeOption, pixelColorShiftTolerance, transparencyOptions); } /// /// Calculates ICompareResult expressing the amount of difference of both images /// + /// + /// + /// + /// + /// + /// Mean and absolute pixel error + public static ICompareResult CalcDiff(Stream image1, Stream image2, ResizeOption resizeOption = ResizeOption.DontResize, int pixelColorShiftTolerance = 0, TransparencyOptions transparencyOptions = TransparencyOptions.IgnoreAlphaChannel) + { + ArgumentNullException.ThrowIfNull(image1); + ArgumentNullException.ThrowIfNull(image2); + + using var actual = DecodeStream(image1); + using var expected = DecodeStream(image2); + return CalcDiffInternal(actual, expected, null, resizeOption, pixelColorShiftTolerance, transparencyOptions); + } + + /// + /// Calculates ICompareResult expressing the amount of difference of both images using a mask image for tolerated difference between the two images + /// /// /// + /// + /// + /// + /// /// Mean and absolute pixel error - public static ICompareResult CalcDiff(string pathActualImage, string pathExpectedImage) + public static ICompareResult CalcDiff(string pathActualImage, string pathExpectedImage, string pathMaskImage, ResizeOption resizeOption = ResizeOption.DontResize, int pixelColorShiftTolerance = 0, TransparencyOptions transparencyOptions = TransparencyOptions.CompareAlphaChannel) { - using var actual = SKBitmap.Decode(pathActualImage); - using var expected = SKBitmap.Decode(pathExpectedImage); - return CalcDiff(actual, expected); + using var image1 = SKBitmap.Decode(pathActualImage); + using var image2 = SKBitmap.Decode(pathExpectedImage); + using var mask = SKBitmap.Decode(pathMaskImage); + return CalcDiff(image1, image2, mask, resizeOption, pixelColorShiftTolerance, transparencyOptions); } /// /// Calculates ICompareResult expressing the amount of difference of both images /// - /// - /// - /// Mean and absolute pixel error - public static ICompareResult CalcDiff(Stream actualImage, Stream expectedImage) + /// + /// + /// + /// + /// + /// + /// + public static ICompareResult CalcDiff(Stream image1, Stream image2, SKBitmap maskImage, ResizeOption resizeOption = ResizeOption.DontResize, int pixelColorShiftTolerance = 0, TransparencyOptions transparencyOptions = TransparencyOptions.CompareAlphaChannel) { - using var actual = SKBitmap.Decode(actualImage); - using var expected = SKBitmap.Decode(expectedImage); - return CalcDiff(actual, expected); + ArgumentNullException.ThrowIfNull(image1); + ArgumentNullException.ThrowIfNull(image2); + ArgumentNullException.ThrowIfNull(maskImage); + + using var actual = DecodeStream(image1); + using var expected = DecodeStream(image2); + return CalcDiff(actual, expected, maskImage, resizeOption, pixelColorShiftTolerance, transparencyOptions); + } + + /// + /// Compares two images for equivalence + /// + /// + /// + /// + /// + /// + /// + /// + public static ICompareResult CalcDiff(SKBitmap image1, SKBitmap image2, SKBitmap maskImage, ResizeOption resizeOption = ResizeOption.DontResize, int pixelColorShiftTolerance = 0, TransparencyOptions transparencyOptions = TransparencyOptions.CompareAlphaChannel) + { + ArgumentNullException.ThrowIfNull(image1); + ArgumentNullException.ThrowIfNull(image2); + ArgumentNullException.ThrowIfNull(maskImage); + + var metadataDifference = new Dictionary(); + return CalcDiff(image1, image2, maskImage, metadataDifference, resizeOption, pixelColorShiftTolerance, transparencyOptions); } /// /// Calculates ICompareResult expressing the amount of difference of both images /// - /// - /// + /// + /// + /// + /// + /// + /// /// Mean and absolute pixel error - public static ICompareResult CalcDiff(SKBitmap actual, SKBitmap expected) + internal static ICompareResult CalcDiffInternal(SKBitmap image1, SKBitmap image2, Dictionary? metadataDifference, ResizeOption resizeOption = ResizeOption.DontResize, int pixelColorShiftTolerance = 0, TransparencyOptions transparencyOptions = TransparencyOptions.IgnoreAlphaChannel) { - if (!ImagesHaveSameDimension(actual, expected)) + var imagesHaveSameDimension = ImagesHaveSameDimension(image1, image2); + + if (resizeOption == ResizeOption.Resize && !imagesHaveSameDimension) + { + var grown = GrowToSameDimension(image1, image2); + try + { + return CalcDiffInternal(grown.Item1, grown.Item2, metadataDifference, ResizeOption.DontResize, pixelColorShiftTolerance, transparencyOptions); + } + finally + { + grown.Item1?.Dispose(); + grown.Item2?.Dispose(); + } + } + + if (!ImagesHaveSameDimension(image1, image2)) { throw new SkiaSharpCompareException(sizeDiffersExceptionMessage); } - var quantity = actual.Width * actual.Height; + var quantity = image1.Width * image1.Height; var absoluteError = 0; var pixelErrorCount = 0; - for (var x = 0; x < actual.Width; x++) + for (var x = 0; x < image1.Width; x++) { - for (var y = 0; y < actual.Height; y++) + for (var y = 0; y < image1.Height; y++) { - var actualPixel = actual.GetPixel(x, y); - var expectedPixel = expected.GetPixel(x, y); + var actualPixel = image1.GetPixel(x, y); + var expectedPixel = image2.GetPixel(x, y); var r = Math.Abs(expectedPixel.Red - actualPixel.Red); var g = Math.Abs(expectedPixel.Green - actualPixel.Green); var b = Math.Abs(expectedPixel.Blue - actualPixel.Blue); - absoluteError = absoluteError + r + g + b; - - pixelErrorCount += r + g + b > 0 ? 1 : 0; + var sum = r + g + b; + if (transparencyOptions == TransparencyOptions.CompareAlphaChannel) + { + var a = Math.Abs(expectedPixel.Alpha - actualPixel.Alpha); + sum += a; + } + absoluteError += (sum > pixelColorShiftTolerance ? sum : 0); + pixelErrorCount += (sum > pixelColorShiftTolerance) ? 1 : 0; } } var meanError = (double)absoluteError / quantity; var pixelErrorPercentage = (double)pixelErrorCount / quantity * 100; - return new CompareResult(absoluteError, meanError, pixelErrorCount, pixelErrorPercentage); + return new CompareResult(absoluteError, meanError, pixelErrorCount, pixelErrorPercentage, metadataDifference); } - /// - /// Calculates ICompareResult expressing the amount of difference of both images using a mask image for tolerated difference between the two images - /// - /// - /// - /// - /// Mean and absolute pixel error - public static ICompareResult CalcDiff(string pathActualImage, string pathExpectedImage, string pathMaskImage) + private static SKBitmap DecodeStream(Stream stream) { - using var actual = SKBitmap.Decode(pathActualImage); - using var expected = SKBitmap.Decode(pathExpectedImage); - using var mask = SKBitmap.Decode(pathMaskImage); - return CalcDiff(actual, expected, mask); - } + // Addressing https://github.com/mono/SkiaSharp/issues/2263 - /// - /// Calculates ICompareResult expressing the amount of difference of both images - /// - /// - /// - /// - /// - public static ICompareResult CalcDiff(Stream actualImage, Stream expectedImage, SKBitmap maskImage) - { - using var actual = SKBitmap.Decode(actualImage); - using var expected = SKBitmap.Decode(expectedImage); - return CalcDiff(actual, expected, maskImage); - } + using var copy = new MemoryStream(); - private static bool ImagesHaveSameDimension(SKBitmap actual, SKBitmap expected) - { - if (actual == null) + if (stream.CanSeek) { - throw new ArgumentNullException(nameof(actual)); + stream.Position = 0; } - if (expected == null) + stream.CopyTo(copy); + + if (stream.CanSeek) { - throw new ArgumentNullException(nameof(expected)); + stream.Position = 0; } - return actual.Height == expected.Height && actual.Width == expected.Width; + var bytes = copy.ToArray(); + using var skData = SKData.CreateCopy(bytes); + + return SKBitmap.Decode(skData); } - /// - /// Calculates ICompareResult expressing the amount of difference of both images using a image mask for tolerated difference between the two images - /// - /// - /// - /// - /// Mean and absolute pixel error - public static ICompareResult CalcDiff(SKBitmap actual, SKBitmap expected, SKBitmap maskImage) + internal static ICompareResult CalcDiff(SKBitmap image1, SKBitmap image2, SKBitmap maskImage, Dictionary metadataDifference, ResizeOption resizeOption = ResizeOption.DontResize, int pixelColorShiftTolerance = 0, TransparencyOptions transparencyOptions = TransparencyOptions.CompareAlphaChannel) { - if (!ImagesHaveSameDimension(actual, expected)) + ArgumentNullException.ThrowIfNull(maskImage); + + var imagesHaveSameDimension = ImagesHaveSameDimension(image1, image2) && ImagesHaveSameDimension(image1, maskImage); + + if (resizeOption == ResizeOption.Resize && !imagesHaveSameDimension) { - throw new SkiaSharpCompareException(sizeDiffersExceptionMessage); + var grown = GrowToSameDimension(image1, image2, maskImage); + try + { + return CalcDiff(grown.Item1, grown.Item2, grown.Item3, ResizeOption.DontResize, pixelColorShiftTolerance, transparencyOptions); + } + finally + { + grown.Item1?.Dispose(); + grown.Item2?.Dispose(); + grown.Item3?.Dispose(); + } } - if (maskImage == null) + if (!imagesHaveSameDimension) { - throw new ArgumentNullException(nameof(maskImage)); + throw new SkiaSharpCompareException(sizeDiffersExceptionMessage); } - var quantity = actual.Width * actual.Height; + var quantity = image1.Width * image1.Height; var absoluteError = 0; var pixelErrorCount = 0; - for (var x = 0; x < actual.Width; x++) + for (var x = 0; x < image1.Width; x++) { - for (var y = 0; y < actual.Height; y++) + for (var y = 0; y < image1.Height; y++) { var maskImagePixel = maskImage.GetPixel(x, y); - var actualPixel = actual.GetPixel(x, y); - var expectedPixel = expected.GetPixel(x, y); + var actualPixel = image1.GetPixel(x, y); + var expectedPixel = image2.GetPixel(x, y); var r = Math.Abs(expectedPixel.Red - actualPixel.Red); var g = Math.Abs(expectedPixel.Green - actualPixel.Green); @@ -261,87 +422,349 @@ public static ICompareResult CalcDiff(SKBitmap actual, SKBitmap expected, SKBitm error += b; } - absoluteError += error; - pixelErrorCount += error > 0 ? 1 : 0; + if (transparencyOptions == TransparencyOptions.CompareAlphaChannel) + { + var a = Math.Abs(expectedPixel.Alpha - actualPixel.Alpha); + if (a > maskImagePixel.Alpha) + { + error += a; + } + } + + absoluteError += (error > pixelColorShiftTolerance ? error : 0); + pixelErrorCount += error > pixelColorShiftTolerance ? 1 : 0; } } var meanError = (double)absoluteError / quantity; var pixelErrorPercentage = (double)pixelErrorCount / quantity * 100; - return new CompareResult(absoluteError, meanError, pixelErrorCount, pixelErrorPercentage); + return new CompareResult(absoluteError, meanError, pixelErrorCount, pixelErrorPercentage, metadataDifference); + } + + private static bool ImagesHaveSameDimension(SKBitmap actual, SKBitmap expected) + { + ArgumentNullException.ThrowIfNull(actual); + ArgumentNullException.ThrowIfNull(expected); + + return actual.Height == expected.Height && actual.Width == expected.Width; } /// /// Creates a diff mask image of two images /// - /// - /// + /// + /// + /// + /// + /// + /// Image representing diff, black means no diff between actual image and expected image, white means max diff + public static SKBitmap CalcDiffMaskImage(string pathImage1, string pathImage2, ResizeOption resizeOption = ResizeOption.DontResize, int pixelColorShiftTolerance = 0, TransparencyOptions transparencyOptions = TransparencyOptions.CompareAlphaChannel) + { + using var actual = SKBitmap.Decode(pathImage1); + using var expected = SKBitmap.Decode(pathImage2); + return CalcDiffMaskImage(actual, expected, resizeOption, pixelColorShiftTolerance, transparencyOptions); + } + + /// + /// Creates a diff mask image of two images + /// + /// + /// + /// + /// + /// + /// + /// Image representing diff, black means no diff between actual image and expected image, white means max diff + public static SKBitmap CalcDiffMaskImage(string pathImage1, string pathImage2, string pathMaskImage, ResizeOption resizeOption = ResizeOption.DontResize, int pixelColorShiftTolerance = 0, TransparencyOptions transparencyOptions = TransparencyOptions.CompareAlphaChannel) + { + using var actual = SKBitmap.Decode(pathImage1); + using var expected = SKBitmap.Decode(pathImage2); + using var mask = SKBitmap.Decode(pathMaskImage); + return CalcDiffMaskImage(actual, expected, mask, resizeOption, pixelColorShiftTolerance, transparencyOptions); + } + + /// + /// Creates a diff mask image of two images + /// + /// + /// + /// + /// + /// + /// Image representing diff, black means no diff between actual image and expected image, white means max diff + public static SKBitmap CalcDiffMaskImage(Stream image1, Stream image2, ResizeOption resizeOption = ResizeOption.DontResize, int pixelColorShiftTolerance = 0, TransparencyOptions transparencyOptions = TransparencyOptions.CompareAlphaChannel) + { + ArgumentNullException.ThrowIfNull(image1); + ArgumentNullException.ThrowIfNull(image2); + + using var actual = DecodeStream(image1); + using var expected = DecodeStream(image2); + + return CalcDiffMaskImage(actual, expected, resizeOption, pixelColorShiftTolerance, transparencyOptions); + } + + /// + /// Creates a diff mask image of two images + /// + /// + /// + /// + /// + /// + /// /// Image representing diff, black means no diff between actual image and expected image, white means max diff - public static SKBitmap CalcDiffMaskImage(string pathActualImage, string pathExpectedImage) + public static SKBitmap CalcDiffMaskImage(Stream image1, Stream image2, Stream maskImage, ResizeOption resizeOption = ResizeOption.DontResize, int pixelColorShiftTolerance = 0, TransparencyOptions transparencyOptions = TransparencyOptions.CompareAlphaChannel) { - using var actual = SKBitmap.Decode(pathActualImage); - using var expected = SKBitmap.Decode(pathExpectedImage); - return CalcDiffMaskImage(actual, expected); + ArgumentNullException.ThrowIfNull(image1); + ArgumentNullException.ThrowIfNull(image2); + ArgumentNullException.ThrowIfNull(maskImage); + + using var actual = DecodeStream(image1); + using var expected = DecodeStream(image2); + using var mask = DecodeStream(maskImage); + + return CalcDiffMaskImage(actual, expected, mask, resizeOption, pixelColorShiftTolerance, transparencyOptions); } /// /// Creates a diff mask image of two images /// - /// - /// + /// + /// + /// + /// + /// /// Image representing diff, black means no diff between actual image and expected image, white means max diff - public static SKBitmap CalcDiffMaskImage(Stream actualImage, Stream expectedImage) + public static SKBitmap CalcDiffMaskImage(SKBitmap image1, SKBitmap image2, ResizeOption resizeOption = ResizeOption.DontResize, int pixelColorShiftTolerance = 0, TransparencyOptions transparencyOptions = TransparencyOptions.CompareAlphaChannel) { - if (actualImage.CanSeek) + var imagesHAveSameDimension = ImagesHaveSameDimension(image1, image2); + + if (resizeOption == ResizeOption.DontResize && !imagesHAveSameDimension) { - actualImage.Position = 0; + throw new SkiaSharpCompareException(sizeDiffersExceptionMessage); } - if (expectedImage.CanSeek) + + if (imagesHAveSameDimension) { - expectedImage.Position = 0; + var maskImage = new SKBitmap(image1.Width, image1.Height); + + for (var x = 0; x < image1.Width; x++) + { + for (var y = 0; y < image1.Height; y++) + { + var actualPixel = image1.GetPixel(x, y); + var expectedPixel = image2.GetPixel(x, y); + + var red = (byte)Math.Abs(actualPixel.Red - expectedPixel.Red); + var green = (byte)Math.Abs(actualPixel.Green - expectedPixel.Green); + var blue = (byte)Math.Abs(actualPixel.Blue - expectedPixel.Blue); + + if (pixelColorShiftTolerance == 0) + { + if (transparencyOptions == TransparencyOptions.CompareAlphaChannel) + { + var alpha = (byte)Math.Abs(actualPixel.Alpha - expectedPixel.Alpha); + // Ensure mask pixel has full opacity if there's any color difference + var effectiveAlpha = (red > 0 || green > 0 || blue > 0) ? (byte)255 : alpha; + var pixel = new SKColor(red, green, blue, effectiveAlpha); + maskImage.SetPixel(x, y, pixel); + } + else + { + var pixel = new SKColor(red, green, blue); + maskImage.SetPixel(x, y, pixel); + } + } + else + { + if (transparencyOptions == TransparencyOptions.CompareAlphaChannel) + { + var alpha = (byte)Math.Abs(actualPixel.Alpha - expectedPixel.Alpha); + var pixel = new SKColor(red, green, blue, alpha); + var r = Math.Abs(expectedPixel.Red - actualPixel.Red); + var g = Math.Abs(expectedPixel.Green - actualPixel.Green); + var b = Math.Abs(expectedPixel.Blue - actualPixel.Blue); + var a = Math.Abs(expectedPixel.Alpha - actualPixel.Alpha); + var sum = r + g + b + a; + + if (sum > pixelColorShiftTolerance) + { + maskImage.SetPixel(x, y, pixel); + } + else + { + maskImage.SetPixel(x, y, 0); + } + } + else + { + var pixel = new SKColor(red, green, blue); + var r = Math.Abs(expectedPixel.Red - actualPixel.Red); + var g = Math.Abs(expectedPixel.Green - actualPixel.Green); + var b = Math.Abs(expectedPixel.Blue - actualPixel.Blue); + var sum = r + g + b; + + if (sum > pixelColorShiftTolerance) + { + maskImage.SetPixel(x, y, pixel); + } + else + { + maskImage.SetPixel(x, y, 0); + } + } + } + } + } + return maskImage; } - using var actualImageCopy = new MemoryStream(); - using var expectedImageCopy = new MemoryStream(); - actualImage.CopyTo(actualImageCopy); - expectedImage.CopyTo(expectedImageCopy); - actualImageCopy.Position = 0; - expectedImageCopy.Position = 0; - using var actual = SKBitmap.Decode(actualImageCopy); - using var expected = SKBitmap.Decode(expectedImageCopy); - return CalcDiffMaskImage(actual, expected); + var grown = GrowToSameDimension(image1, image2); + try + { + return CalcDiffMaskImage(grown.Item1, grown.Item2, ResizeOption.DontResize, pixelColorShiftTolerance, transparencyOptions); + } + finally + { + grown.Item1?.Dispose(); + grown.Item2?.Dispose(); + } } /// /// Creates a diff mask image of two images /// - /// - /// + /// + /// + /// + /// + /// + /// /// Image representing diff, black means no diff between actual image and expected image, white means max diff - public static SKBitmap CalcDiffMaskImage(SKBitmap actual, SKBitmap expected) + public static SKBitmap CalcDiffMaskImage(SKBitmap image1, SKBitmap image2, SKBitmap mask, ResizeOption resizeOption = ResizeOption.DontResize, int pixelColorShiftTolerance = 0, TransparencyOptions transparencyOptions = TransparencyOptions.CompareAlphaChannel) { - if (!ImagesHaveSameDimension(actual, expected)) + ArgumentNullException.ThrowIfNull(mask); + + var imagesHaveSameDimension = ImagesHaveSameDimension(image1, image2) && ImagesHaveSameDimension(image1, mask); + + if (resizeOption == ResizeOption.DontResize && !imagesHaveSameDimension) { throw new SkiaSharpCompareException(sizeDiffersExceptionMessage); } - var maskImage = new SKBitmap(actual.Width, actual.Height); - - for (var x = 0; x < actual.Width; x++) + if (imagesHaveSameDimension) { - for (var y = 0; y < actual.Height; y++) - { - var actualPixel = actual.GetPixel(x, y); - var expectedPixel = expected.GetPixel(x, y); - - var red = (byte)Math.Abs(actualPixel.Red - expectedPixel.Red); - var green = (byte)Math.Abs(actualPixel.Green - expectedPixel.Green); - var blue = (byte)Math.Abs(actualPixel.Blue - expectedPixel.Blue); - var pixel = new SKColor(red, green, blue); + var maskImage = new SKBitmap(image1.Width, image1.Height); - maskImage.SetPixel(x, y, pixel); + for (var x = 0; x < image1.Width; x++) + { + for (var y = 0; y < image1.Height; y++) + { + var actualPixel = image1.GetPixel(x, y); + var expectedPixel = image2.GetPixel(x, y); + var maskPixel = mask.GetPixel(x, y); + + var redDiff = Math.Abs(actualPixel.Red - expectedPixel.Red); + var greenDiff = Math.Abs(actualPixel.Green - expectedPixel.Green); + var blueDiff = Math.Abs(actualPixel.Blue - expectedPixel.Blue); + + var red = (byte)Math.Max(0, redDiff - maskPixel.Red); + var green = (byte)Math.Max(0, greenDiff - maskPixel.Green); + var blue = (byte)Math.Max(0, blueDiff - maskPixel.Blue); + + if (transparencyOptions == TransparencyOptions.CompareAlphaChannel) + { + var alphaDiff = Math.Abs(actualPixel.Alpha - expectedPixel.Alpha); + var alpha = (byte)Math.Max(0, alphaDiff - maskPixel.Alpha); + var pixel = new SKColor(red, green, blue, alpha); + + if (pixelColorShiftTolerance == 0) + { + maskImage.SetPixel(x, y, pixel); + } + else + { + var r = Math.Abs(expectedPixel.Red - actualPixel.Red); + var g = Math.Abs(expectedPixel.Green - actualPixel.Green); + var b = Math.Abs(expectedPixel.Blue - actualPixel.Blue); + var a = Math.Abs(expectedPixel.Alpha - actualPixel.Alpha); + var sum = r + g + b + a; + + if (sum > pixelColorShiftTolerance) + { + maskImage.SetPixel(x, y, pixel); + } + else + { + maskImage.SetPixel(x, y, 0); + } + } + } + else + { + var pixel = new SKColor(red, green, blue); + + if (pixelColorShiftTolerance == 0) + { + maskImage.SetPixel(x, y, pixel); + } + else + { + var r = Math.Abs(expectedPixel.Red - actualPixel.Red); + var g = Math.Abs(expectedPixel.Green - actualPixel.Green); + var b = Math.Abs(expectedPixel.Blue - actualPixel.Blue); + var sum = r + g + b; + + if (sum > pixelColorShiftTolerance) + { + maskImage.SetPixel(x, y, pixel); + } + else + { + maskImage.SetPixel(x, y, 0); + } + } + } + } } + return maskImage; } - return maskImage; + + var grown = GrowToSameDimension(image1, image2, mask); + try + { + return CalcDiffMaskImage(grown.Item1, grown.Item2, grown.Item3, ResizeOption.DontResize, pixelColorShiftTolerance, transparencyOptions); + } + finally + { + grown.Item1?.Dispose(); + grown.Item2?.Dispose(); + grown.Item3?.Dispose(); + } + } + + private static (SKBitmap, SKBitmap) GrowToSameDimension(SKBitmap image1, SKBitmap image2) + { + var biggestWidth = image1.Width > image2.Width ? image1.Width : image2.Width; + var biggestHeight = image1.Height > image2.Height ? image1.Height : image2.Height; + var skSize = new SKSizeI(biggestWidth, biggestHeight); + var grownExpected = image2.Resize(skSize, SKSamplingOptions.Default); + var grownActual = image1.Resize(skSize, SKSamplingOptions.Default); + + return (grownActual, grownExpected); + } + + private static (SKBitmap, SKBitmap, SKBitmap) GrowToSameDimension(SKBitmap image1, SKBitmap image2, SKBitmap mask) + { + var biggestWidth = image1.Width > image2.Width ? image1.Width : image2.Width; + biggestWidth = biggestWidth > mask.Width ? biggestWidth : mask.Width; + var biggestHeight = image1.Height > image2.Height ? image1.Height : image2.Height; + biggestHeight = biggestHeight > mask.Height ? biggestHeight : mask.Height; + var skSize = new SKSizeI(biggestWidth, biggestHeight); + var grownExpected = image2.Resize(skSize, SKSamplingOptions.Default); + var grownActual = image1.Resize(skSize, SKSamplingOptions.Default); + var grownMask = mask.Resize(skSize, SKSamplingOptions.Default); + + return (grownActual, grownExpected, grownMask); } } } \ No newline at end of file diff --git a/SkiaSharpCompare/SkiaSharpCompare.csproj b/SkiaSharpCompare/SkiaSharpCompare.csproj index f4d7061..8b863a8 100644 --- a/SkiaSharpCompare/SkiaSharpCompare.csproj +++ b/SkiaSharpCompare/SkiaSharpCompare.csproj @@ -1,62 +1,60 @@ - - - netstandard2.0;net6.0 - true - true - https://github.com/Codeuctivity/SkiaSharp.Compare - Compare Image JPG PNG BMP Linux Windows MacOs - Stefan Seeland - Codeuctivity - $(CURRENT_VERSION) - 0.0.1 - $(Version) - $(Version) - $(Version)-prerelease - $(LAST_COMMIT_MESSAGE) - NugetIcon.png - https://github.com/Codeuctivity/SkiaSharp.Compare - Compares Images and calculates mean error, absolute error and diff image. Supports optional tolerance mask/images to ignore areas of an image. Use this for automated visual comparing in your unit tests. - Apache-2.0 - false - SkiaSharpCompare.snk - True - true - snupkg - true - true - enable - 8.0 - true - Codeuctivity.SkiaSharpCompare - nugetReadme.md - Codeuctivity.SkiaSharpCompare - Codeuctivity.SkiaSharpCompare - Codeuctivity.SkiaSharpCompare - true - - - True - - - True - + + + net10.0 + true + true + https://github.com/Codeuctivity/SkiaSharp.Compare + Compare Image JPG PNG BMP + Stefan Seeland + Codeuctivity + $(CURRENT_VERSION) + 0.0.1 + $(Version) + $(Version) + $(Version) + $(LAST_COMMIT_MESSAGE) + NugetIcon.png + https://github.com/Codeuctivity/SkiaSharp.Compare + + Compares Images and calculates mean error, absolute error and diff image. + Supports optional tolerance mask/images to ignore areas of an image. Use this for + automated visual comparing in your unit tests. + + Apache-2.0 + false + SkiaSharpCompare.snk + True + true + snupkg + true + true + enable + true + AllEnabledByDefault + latest + Codeuctivity.SkiaSharpCompare + nugetReadme.md + Codeuctivity.SkiaSharpCompare + Codeuctivity.SkiaSharpCompare + Codeuctivity.SkiaSharpCompare + true + + + + - - - + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - + + + - - - - - \ No newline at end of file + diff --git a/SkiaSharpCompare/SkiaSharpCompareException.cs b/SkiaSharpCompare/SkiaSharpCompareException.cs index d528cb2..d0198a6 100644 --- a/SkiaSharpCompare/SkiaSharpCompareException.cs +++ b/SkiaSharpCompare/SkiaSharpCompareException.cs @@ -1,12 +1,10 @@ using System; -using System.Runtime.Serialization; namespace Codeuctivity.SkiaSharpCompare { /// /// SkiaSharpCompareException gets thrown if comparing of images fails /// - [Serializable] public class SkiaSharpCompareException : Exception { /// @@ -34,15 +32,5 @@ public SkiaSharpCompareException(string message) : base(message) public SkiaSharpCompareException(string message, Exception innerException) : base(message, innerException) { } - - /// - /// SkiaSharpCompareException gets thrown if comparing of images fails - /// - /// - /// - /// - protected SkiaSharpCompareException(SerializationInfo info, StreamingContext context) : base(info, context) - { - } } } \ No newline at end of file diff --git a/SkiaSharpCompare/TransparencyOptions.cs b/SkiaSharpCompare/TransparencyOptions.cs new file mode 100644 index 0000000..3da27dd --- /dev/null +++ b/SkiaSharpCompare/TransparencyOptions.cs @@ -0,0 +1,22 @@ +namespace Codeuctivity.SkiaSharpCompare +{ + /// + /// Specifies options for handling transparency when comparing images. + /// + /// These options determine whether the alpha channel (transparency) is considered during image + /// comparison. + public enum TransparencyOptions + { + /// + /// Gets or sets a value indicating whether the alpha channel should be ignored during processing. + /// + IgnoreAlphaChannel, + + /// + /// Compares the alpha channel values of two colors. + /// + /// This method is useful for sorting or comparing colors based on their transparency + /// levels. + CompareAlphaChannel + } +} \ No newline at end of file diff --git a/SkiaSharpCompare/docs/nugetReadme.md b/SkiaSharpCompare/docs/nugetReadme.md index d16f7a9..96f410b 100644 --- a/SkiaSharpCompare/docs/nugetReadme.md +++ b/SkiaSharpCompare/docs/nugetReadme.md @@ -1,13 +1,47 @@ +# Compares images -Compares images -Supports comparing images by using a tolerance mask image. -Linux, Windows, MacOs supported. - -Basic example: +## Compares each pixel to determine the equality ```csharp -bool isEqual = SkiaSharpCompare.ImagesAreEqual("actual.png", "expected.png"); +bool isEqual = Compare.ImagesAreEqual("actual.png", "expected.png"); +``` -// calcs MeanError, AbsoluteError, PixelErrorCount and PixelErrorPercentage -ICompareResult calcDiff = SkiaSharpCompare.CalcDiff("actual.png", "expected.png"); +## [Calculates diff](https://dotnetfiddle.net/tTnq2j) + +```csharp +var calcDiff = Compare.CalcDiff("2x2PixelBlack.png", "2x2PixelWhite.png"); +Console.WriteLine($"PixelErrorCount: {calcDiff.PixelErrorCount}"); +Console.WriteLine($"PixelErrorPercentage: {calcDiff.PixelErrorPercentage}"); +Console.WriteLine($"AbsoluteError: {calcDiff.AbsoluteError}"); +Console.WriteLine($"MeanError: {calcDiff.MeanError}"); +// PixelErrorCount: 4 +// PixelErrorPercentage: 100 +// AbsoluteError: 3060 +// MeanError: 765 ``` + +## [Configure transparency, enable metadata compare, ...](https://dotnetfiddle.net/lygaRU) + +```csharp +var comparer = new ImageCompare(ResizeOption.Resize, TransparencyOptions.CompareAlphaChannel, pixelColorShiftTolerance: 5, compareMetadata: true); +var calcDiff = comparer.CalcDiff("pngPartialTransparent4x4Pixel.png", "2x2PixelWhite.png"); +// Displaying the differences +Console.WriteLine($"PixelErrorCount: {calcDiff.PixelErrorCount}"); +Console.WriteLine($"PixelErrorPercentage: {calcDiff.PixelErrorPercentage}"); +Console.WriteLine($"AbsoluteError: {calcDiff.AbsoluteError}"); +Console.WriteLine($"MeanError: {calcDiff.MeanError}"); +foreach (var metadataDifference in calcDiff.MetadataDifferences) + Console.WriteLine($"Metadata Difference: {metadataDifference}"); + +// PixelErrorCount: 16 +// PixelErrorPercentage: 100 +// AbsoluteError: 14272 +// MeanError: 892 +// Metadata Difference: [File:File Name, (pngPartialTransparent4x4Pixel.png, 2x2PixelWhite.png)] +// Metadata Difference: [File:File Size, (688 bytes, 556 bytes)] +// Metadata Difference: [PNG-IHDR:Color Type, (True Color with Alpha, True Color)] +// Metadata Difference: [PNG-IHDR:Image Height, (4, 2)] +// Metadata Difference: [PNG-IHDR:Image Width, (4, 2)] +// Metadata Difference: [PNG-tEXt:Textual Data, (Software: Paint.NET 5.1.2, Comment: Created with GIMP)] +// ... +``` \ No newline at end of file diff --git a/SkiaSharpCompareTestNunit/ImageExtensions.cs b/SkiaSharpCompareTestNunit/ImageExtensions.cs new file mode 100644 index 0000000..cbc9050 --- /dev/null +++ b/SkiaSharpCompareTestNunit/ImageExtensions.cs @@ -0,0 +1,35 @@ +using SkiaSharp; +using System.IO; + +namespace SkiaSharpCompareTestNunit +{ + internal class ImageExtensions + { + protected ImageExtensions() + { + } + + internal static void SaveAsPng(SKBitmap diffMask1Image, FileStream diffMask1Stream) + { + var encodedData = diffMask1Image.Encode(SKEncodedImageFormat.Png, 100); + encodedData.SaveTo(diffMask1Stream); + } + + public static bool IsImageEntirelyBlack(SKBitmap image, Codeuctivity.SkiaSharpCompare.TransparencyOptions transparencyOptions) + { + for (var x = 0; x < image.Width; x++) + { + for (var y = 0; y < image.Height; y++) + { + var sKColor = image.GetPixel(x, y); + if (sKColor.Red != 0 || sKColor.Green != 0 || sKColor.Blue != 0 || (transparencyOptions == Codeuctivity.SkiaSharpCompare.TransparencyOptions.CompareAlphaChannel && sKColor.Alpha != 0)) + { + return false; + } + } + } + + return true; + } + } +} \ No newline at end of file diff --git a/SkiaSharpCompareTestNunit/ImageSharpCompareTest.cs b/SkiaSharpCompareTestNunit/ImageSharpCompareTest.cs deleted file mode 100644 index e944a08..0000000 --- a/SkiaSharpCompareTestNunit/ImageSharpCompareTest.cs +++ /dev/null @@ -1,279 +0,0 @@ -using Codeuctivity.SkiaSharpCompare; -using NUnit.Framework; -using SkiaSharp; -using System; -using System.IO; -using System.Reflection; - -namespace SkiaSharpCompareTestNunit -{ - public class IntegrationTest - { - private const string jpg0Rgb24 = "../../../TestData/Calc0.jpg"; - private const string jpg1Rgb24 = "../../../TestData/Calc1.jpg"; - private const string png0Rgba32 = "../../../TestData/Calc0.png"; - private const string png1Rgba32 = "../../../TestData/Calc1.png"; - private const string pngBlack = "../../../TestData/Black.png"; - private const string pngWhite = "../../../TestData/White.png"; - - [Test] - [TestCase(jpg0Rgb24, jpg0Rgb24, true)] - [TestCase(png0Rgba32, png0Rgba32, true)] - [TestCase(png0Rgba32, jpg0Rgb24, true)] - [TestCase(png0Rgba32, jpg1Rgb24, true)] - [TestCase(png0Rgba32, pngBlack, false)] - public void ShouldVerifyThatImagesFromFilepathSizeAreEqual(string pathActual, string pathExpected, bool expectedOutcome) - { - var absolutePathActual = Path.Combine(AppContext.BaseDirectory, pathActual); - var absolutePathExpected = Path.Combine(AppContext.BaseDirectory, pathExpected); - - Assert.That(SkiaSharpCompare.ImagesHaveEqualSize(absolutePathActual, absolutePathExpected), Is.EqualTo(expectedOutcome)); - } - - [Test] - [TestCase(jpg0Rgb24, jpg0Rgb24, true)] - [TestCase(png0Rgba32, png0Rgba32, true)] - [TestCase(png0Rgba32, jpg0Rgb24, true)] - [TestCase(png0Rgba32, jpg1Rgb24, true)] - [TestCase(png0Rgba32, pngBlack, false)] - public void ShouldVerifyThatImagesSizeAreEqual(string pathActual, string pathExpected, bool expectedOutcome) - { - var absolutePathActual = Path.Combine(AppContext.BaseDirectory, pathActual); - var absolutePathExpected = Path.Combine(AppContext.BaseDirectory, pathExpected); - - using var actual = SKBitmap.Decode(absolutePathActual); - using var expected = SKBitmap.Decode(absolutePathExpected); - - Assert.That(SkiaSharpCompare.ImagesHaveEqualSize(absolutePathActual, absolutePathExpected), Is.EqualTo(expectedOutcome)); - } - - [Test] - [TestCase(jpg0Rgb24, jpg0Rgb24, true)] - [TestCase(png0Rgba32, png0Rgba32, true)] - [TestCase(png0Rgba32, jpg0Rgb24, true)] - [TestCase(png0Rgba32, jpg1Rgb24, true)] - [TestCase(png0Rgba32, pngBlack, false)] - public void ShouldVerifyThatImageStreamsSizeAreEqual(string pathActual, string pathExpected, bool expectedOutcome) - { - var absolutePathActual = Path.Combine(AppContext.BaseDirectory, pathActual); - var absolutePathExpected = Path.Combine(AppContext.BaseDirectory, pathExpected); - - using var actual = new FileStream(absolutePathActual, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - using var expected = new FileStream(absolutePathExpected, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - - Assert.That(SkiaSharpCompare.ImagesHaveEqualSize(absolutePathActual, absolutePathExpected), Is.EqualTo(expectedOutcome)); - } - - [Test] - [TestCase(jpg0Rgb24, jpg0Rgb24)] - [TestCase(png0Rgba32, png0Rgba32)] - public void ShouldVerifyThatImagesAreEqual(string pathActual, string pathExpected) - { - var absolutePathActual = Path.Combine(AppContext.BaseDirectory, pathActual); - var absolutePathExpected = Path.Combine(AppContext.BaseDirectory, pathExpected); - - Assert.That(SkiaSharpCompare.ImagesAreEqual(absolutePathActual, absolutePathExpected), Is.True); - } - - [Test] - [TestCase(jpg0Rgb24, jpg0Rgb24)] - [TestCase(png0Rgba32, png0Rgba32)] - public void ShouldVerifyThatImageStreamsAreEqual(string pathActual, string pathExpected) - { - var absolutePathActual = Path.Combine(AppContext.BaseDirectory, pathActual); - var absolutePathExpected = Path.Combine(AppContext.BaseDirectory, pathExpected); - - using var actual = new FileStream(absolutePathActual, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - using var expected = new FileStream(absolutePathExpected, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - - Assert.That(SkiaSharpCompare.ImagesAreEqual(actual, expected), Is.True); - } - - [Test] - [TestCase(jpg0Rgb24, jpg0Rgb24)] - [TestCase(png0Rgba32, png0Rgba32)] - public void ShouldVerifyThatImageSharpImagesAreEqual(string pathActual, string pathExpected) - { - var absolutePathActual = Path.Combine(AppContext.BaseDirectory, pathActual); - var absolutePathExpected = Path.Combine(AppContext.BaseDirectory, pathExpected); - - using var actual = SKBitmap.Decode(absolutePathActual); - using var expected = SKBitmap.Decode(absolutePathExpected); - - Assert.That(SkiaSharpCompare.ImagesAreEqual(actual, expected), Is.True); - } - - [Test] - [TestCase(jpg0Rgb24, png0Rgba32, 461891, 2.8574583652965777d, 158087, 97.799485288659028d)] - [TestCase(jpg1Rgb24, png1Rgba32, 460034, 2.8459701566405187d, 158121, 97.820519165573728d)] - [TestCase(png1Rgba32, png1Rgba32, 0, 0, 0, 0)] - [TestCase(jpg1Rgb24, jpg1Rgb24, 0, 0, 0, 0)] - [TestCase(jpg0Rgb24, jpg1Rgb24, 208890, 1.2922842790329365d, 2101, 1.2997698646408156d)] - [TestCase(png0Rgba32, png1Rgba32, 203027, 1.25601321422385d, 681, 0.42129618173269651d)] - [TestCase(pngBlack, pngWhite, 3060, 765, 4, 100.0d)] - public void ShouldVerifyThatImagesAreSemiEqual(string pathPic1, string pathPic2, int expectedAbsoluteError, double expectedMeanError, int expectedPixelErrorCount, double expectedPixelErrorPercentage) - { - var absolutePathPic1 = Path.Combine(AppContext.BaseDirectory, pathPic1); - var absolutePathPic2 = Path.Combine(AppContext.BaseDirectory, pathPic2); - - var diff = SkiaSharpCompare.CalcDiff(absolutePathPic1, absolutePathPic2); - Assert.That(diff.AbsoluteError, Is.EqualTo(expectedAbsoluteError), "AbsoluteError"); - Assert.That(diff.MeanError, Is.EqualTo(expectedMeanError), "MeanError"); - Assert.That(diff.PixelErrorCount, Is.EqualTo(expectedPixelErrorCount), "PixelErrorCount"); - Assert.That(diff.PixelErrorPercentage, Is.EqualTo(expectedPixelErrorPercentage), "PixelErrorPercentage"); - } - - [Test] - [TestCase(jpg0Rgb24, png0Rgba32, 461891, 2.8574583652965777d, 158087, 97.799485288659028d)] - [TestCase(jpg1Rgb24, png1Rgba32, 460034, 2.8459701566405187d, 158121, 97.820519165573728d)] - [TestCase(png1Rgba32, png1Rgba32, 0, 0, 0, 0)] - [TestCase(jpg1Rgb24, jpg1Rgb24, 0, 0, 0, 0)] - [TestCase(jpg0Rgb24, jpg1Rgb24, 208890, 1.2922842790329365d, 2101, 1.2997698646408156d)] - [TestCase(png0Rgba32, png1Rgba32, 203027, 1.25601321422385d, 681, 0.42129618173269651d)] - [TestCase(pngBlack, pngWhite, 3060, 765, 4, 100.0d)] - public void ShouldVerifyThatImageStreamsAreSemiEqual(string pathPic1, string pathPic2, int expectedAbsoluteError, double expectedMeanError, int expectedPixelErrorCount, double expectedPixelErrorPercentage) - { - var absolutePathPic1 = Path.Combine(AppContext.BaseDirectory, pathPic1); - var absolutePathPic2 = Path.Combine(AppContext.BaseDirectory, pathPic2); - - using var pic1 = new FileStream(absolutePathPic1, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - using var pic2 = new FileStream(absolutePathPic2, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - - var diff = SkiaSharpCompare.CalcDiff(pic1, pic2); - Assert.That(diff.AbsoluteError, Is.EqualTo(expectedAbsoluteError), "AbsoluteError"); - Assert.That(diff.MeanError, Is.EqualTo(expectedMeanError), "MeanError"); - Assert.That(diff.PixelErrorCount, Is.EqualTo(expectedPixelErrorCount), "PixelErrorCount"); - Assert.That(diff.PixelErrorPercentage, Is.EqualTo(expectedPixelErrorPercentage), "PixelErrorPercentage"); - } - - [TestCase(png0Rgba32, png1Rgba32, 0, 0, 0, 0)] - public void ShoulCalcDiffmask(string pathPic1, string pathPic2, int expectedMeanError, int expectedAbsoluteError, int expectedPixelErrorCount, double expectedPixelErrorPercentage) - { - var absolutePathPic1 = Path.Combine(AppContext.BaseDirectory, pathPic1); - var absolutePathPic2 = Path.Combine(AppContext.BaseDirectory, pathPic2); - var differenceMask = Path.GetTempFileName() + "differenceMask.png"; - - using (var fileStreamDifferenceMask = File.Create(differenceMask)) - using (var maskImage = SkiaSharpCompare.CalcDiffMaskImage(absolutePathPic1, absolutePathPic2)) - { - SaveAsPng(maskImage, fileStreamDifferenceMask); - } - - var maskedDiff = SkiaSharpCompare.CalcDiff(absolutePathPic1, absolutePathPic2, differenceMask); - File.Delete(differenceMask); - - Assert.That(maskedDiff.AbsoluteError, Is.EqualTo(expectedAbsoluteError), "AbsoluteError"); - Assert.That(maskedDiff.MeanError, Is.EqualTo(expectedMeanError), "MeanError"); - Assert.That(maskedDiff.PixelErrorCount, Is.EqualTo(expectedPixelErrorCount), "PixelErrorCount"); - Assert.That(maskedDiff.PixelErrorPercentage, Is.EqualTo(expectedPixelErrorPercentage), "PixelErrorPercentage"); - } - - private void SaveAsPng(SKBitmap maskImage, FileStream fileStreamDifferenceMask) - { - var encodedData = maskImage.Encode(SKEncodedImageFormat.Png, 100); - encodedData.SaveTo(fileStreamDifferenceMask); - } - - [TestCase(png0Rgba32, png1Rgba32, 0, 0, 0, 0)] - [TestCase(jpg0Rgb24, jpg1Rgb24, 0, 0, 0, 0)] - public void ShoulCalcDiffmaskImageSharp(string pathPic1, string pathPic2, int expectedMeanError, int expectedAbsoluteError, int expectedPixelErrorCount, double expectedPixelErrorPercentage) - { - var absolutePathPic1 = Path.Combine(AppContext.BaseDirectory, pathPic1); - var absolutePathPic2 = Path.Combine(AppContext.BaseDirectory, pathPic2); - var differenceMaskPicPath = Path.GetTempFileName() + "differenceMask.png"; - - using var absolutePic1 = SKBitmap.Decode(absolutePathPic1); - using var absolutePic2 = SKBitmap.Decode(absolutePathPic2); - - using (var fileStreamDifferenceMask = File.Create(differenceMaskPicPath)) - using (var maskImage = SkiaSharpCompare.CalcDiffMaskImage(absolutePic1, absolutePic2)) - { - SaveAsPng(maskImage, fileStreamDifferenceMask); - } - - using var differenceMaskPic = SKBitmap.Decode(differenceMaskPicPath); - var maskedDiff = SkiaSharpCompare.CalcDiff(absolutePic1, absolutePic2, differenceMaskPic); - File.Delete(differenceMaskPicPath); - - Assert.That(maskedDiff.AbsoluteError, Is.EqualTo(expectedAbsoluteError), "AbsoluteError"); - Assert.That(maskedDiff.MeanError, Is.EqualTo(expectedMeanError), "MeanError"); - Assert.That(maskedDiff.PixelErrorCount, Is.EqualTo(expectedPixelErrorCount), "PixelErrorCount"); - Assert.That(maskedDiff.PixelErrorPercentage, Is.EqualTo(expectedPixelErrorPercentage), "PixelErrorPercentage"); - } - - private static object? GetInstanceField(T instance, string fieldName) - { - var bindFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static; - var field = typeof(T).GetField(fieldName, bindFlags); - if (field == null) - { - throw new ArgumentNullException(fieldName); - } - - return field.GetValue(instance); - } - - [TestCase(png0Rgba32, png1Rgba32, 0, 0, 0, 0)] - public void DiffmaskSteams(string pathPic1, string pathPic2, int expectedMeanError, int expectedAbsoluteError, int expectedPixelErrorCount, double expectedPixelErrorPercentage) - { - var absolutePathPic1 = Path.Combine(AppContext.BaseDirectory, pathPic1); - var absolutePathPic2 = Path.Combine(AppContext.BaseDirectory, pathPic2); - - using var pic1 = new FileStream(absolutePathPic1, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - using var pic2 = new FileStream(absolutePathPic2, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - - using var maskImage = SkiaSharpCompare.CalcDiffMaskImage(pic1, pic2); - - pic1.Position = 0; - pic2.Position = 0; - - var maskedDiff = SkiaSharpCompare.CalcDiff(pic1, pic2, maskImage); - Assert.That(maskedDiff.AbsoluteError, Is.EqualTo(expectedAbsoluteError), "AbsoluteError"); - Assert.That(maskedDiff.MeanError, Is.EqualTo(expectedMeanError), "MeanError"); - Assert.That(maskedDiff.PixelErrorCount, Is.EqualTo(expectedPixelErrorCount), "PixelErrorCount"); - Assert.That(maskedDiff.PixelErrorPercentage, Is.EqualTo(expectedPixelErrorPercentage), "PixelErrorPercentage"); - } - - [Test] - [TestCase(jpg0Rgb24, jpg1Rgb24)] - [TestCase(png0Rgba32, png1Rgba32)] - [TestCase(jpg0Rgb24, png1Rgba32)] - [TestCase(jpg0Rgb24, png0Rgba32)] - [TestCase(jpg1Rgb24, png1Rgba32)] - public void ShouldVerifyThatImagesAreNotEqual(string pathActual, string pathExpected) - { - var absolutePathActual = Path.Combine(AppContext.BaseDirectory, pathActual); - var absolutePathExpected = Path.Combine(AppContext.BaseDirectory, pathExpected); - - Assert.That(SkiaSharpCompare.ImagesAreEqual(absolutePathActual, absolutePathExpected), Is.False); - } - - [Test] - [TestCase(jpg0Rgb24, jpg1Rgb24)] - [TestCase(png0Rgba32, png1Rgba32)] - [TestCase(jpg0Rgb24, png1Rgba32)] - [TestCase(jpg0Rgb24, png0Rgba32)] - [TestCase(jpg1Rgb24, png1Rgba32)] - public void ShouldVerifyThatImageStreamAreNotEqual(string pathActual, string pathExpected) - { - var absolutePathActual = Path.Combine(AppContext.BaseDirectory, pathActual); - var absolutePathExpected = Path.Combine(AppContext.BaseDirectory, pathExpected); - - using var actual = new FileStream(absolutePathActual, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - using var expected = new FileStream(absolutePathExpected, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - - Assert.That(SkiaSharpCompare.ImagesAreEqual(actual, expected), Is.False); - } - - [TestCase(png0Rgba32, pngBlack)] - public void ShouldVerifyThatImageWithDifferentSizeThrows(string pathPic1, string pathPic2) - { - var absolutePathPic1 = Path.Combine(AppContext.BaseDirectory, pathPic1); - var absolutePathPic2 = Path.Combine(AppContext.BaseDirectory, pathPic2); - - var exception = Assert.Throws(() => SkiaSharpCompare.CalcDiff(absolutePathPic1, absolutePathPic2)); - - Assert.That(exception?.Message, Is.EqualTo("Size of images differ.")); - } - } -} \ No newline at end of file diff --git a/SkiaSharpCompareTestNunit/SkiaSharpCompareMetaData_PathTests.cs b/SkiaSharpCompareTestNunit/SkiaSharpCompareMetaData_PathTests.cs new file mode 100644 index 0000000..ffeb658 --- /dev/null +++ b/SkiaSharpCompareTestNunit/SkiaSharpCompareMetaData_PathTests.cs @@ -0,0 +1,112 @@ +using Codeuctivity.SkiaSharpCompare; +using NUnit.Framework; +using SkiaSharp; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace SkiaSharpCompareTestNunit +{ + internal class SkiaSharpCompareMetaData_PathTests + { + [TestCase(TestFiles.png0Rgba32, TestFiles.png0Rgba32, true, true)] + [TestCase(TestFiles.png0Rgba32, TestFiles.png0Rgba32, false, true)] + [TestCase(TestFiles.imageWithoutGpsMetadata, TestFiles.imageWithGpsMetadata, true, false)] + [TestCase(TestFiles.imageWithoutGpsMetadata, TestFiles.imageWithGpsMetadata, false, true)] + public void ImagesAreEqual_SamePixelComparedByMetadataShouldReturnResult(string pic1Path, string pic2Path, bool shouldCompareMetadata, bool expectOutcomeComparisonOfImage) + { + var pic1 = Path.Combine(AppContext.BaseDirectory, pic1Path); + var pic2 = Path.Combine(AppContext.BaseDirectory, pic2Path); + + var sut = new ImageCompare(compareMetadata: shouldCompareMetadata); + Assert.That(sut.ImagesAreEqual(pic1, pic2), Is.EqualTo(expectOutcomeComparisonOfImage)); + } + + [Test] + public void CalcDiff_SamePixelComparedByMetadataShouldReturnResult_Null() + { + var pic1 = Path.Combine(AppContext.BaseDirectory, TestFiles.imageWithGpsMetadata); + var pic2 = Path.Combine(AppContext.BaseDirectory, TestFiles.imageWithoutGpsMetadata); + + var sut = new ImageCompare(compareMetadata: false); + var actual = sut.CalcDiff(pic1, pic2); + + Assert.That(actual.MetadataDifferences, Is.Null); + Assert.That(actual.PixelErrorCount, Is.Zero); + } + + [Test] + public void CalcDiff_SamePixelComparedByMetadataShouldReturnEmptyResult() + { + var pic1 = Path.Combine(AppContext.BaseDirectory, TestFiles.imageWithoutGpsMetadata); + var pic2 = Path.Combine(AppContext.BaseDirectory, TestFiles.imageWithoutGpsMetadata); + + var sut = new ImageCompare(compareMetadata: true); + var actual = sut.CalcDiff(pic1, pic2); + + Assert.That(actual.MetadataDifferences, Is.Empty); + Assert.That(actual.PixelErrorCount, Is.Zero); + } + + [Test] + [SetCulture("en-US")] + public void CalcDiff_SamePixelComparedByMetadataShouldReturnCollectionOfMetadataThatDiffers() + { + var pic1 = Path.Combine(AppContext.BaseDirectory, TestFiles.imageWithoutGpsMetadata); + var pic2 = Path.Combine(AppContext.BaseDirectory, TestFiles.imageWithGpsMetadata); + + var sut = new ImageCompare(compareMetadata: true); + var actual = sut.CalcDiff(pic1, pic2); + + // Output actual and expected to test run logs for easier diagnosis + TestContext.WriteLine($"Actual image: {pic1}"); + TestContext.WriteLine($"Expected image: {pic2}"); + TestContext.WriteLine($"Actual MetadataDifferences: {FormatMetadata(actual.MetadataDifferences)}"); + + var expected = new Dictionary + { + { "File:File Name", (Path.GetFileName(pic1), Path.GetFileName(pic2)) }, + { "GPS:GPS Altitude", ("0 metres", "201.62 metres") }, + { "GPS:GPS Date Stamp", ("", "2025:01:03") }, + { "GPS:GPS Latitude", ("0° 0' 0\"", "48° 12' 7.17\"") }, + { "GPS:GPS Latitude Ref", ("", "N") }, + { "GPS:GPS Longitude", ("0° 0' 0\"", "16° 24' 7.53\"") }, + { "GPS:GPS Longitude Ref", ("", "E") }, + { "GPS:GPS Processing Method", ("", "GPS") }, + { "GPS:GPS Time-Stamp", ("00:00:00.000 UTC", "14:41:20.000 UTC") } + }; + + TestContext.WriteLine($"Expected MetadataDifferences: {FormatMetadata(expected)}"); + + Assert.That(actual.MetadataDifferences, Is.EqualTo(expected)); + Assert.That(actual.PixelErrorCount, Is.Zero); + } + + [Test] + public void CreateToleranceMaskFromDiff_CreatesWhiteRectangle() + { + var pic1 = Path.Combine(AppContext.BaseDirectory, TestFiles.png0Rgba32); + var pic2 = Path.Combine(AppContext.BaseDirectory, TestFiles.png1Rgba32); + var sut = new ImageCompare(); + + using var diffMask = sut.CalcDiffMaskImage(pic1, pic2); + using var tolFromBitmap = sut.CreateToleranceMaskFromDiff(diffMask); + + var expectedImage = SKBitmap.Decode(TestFiles.createToleranceMaskFromDiff); + + Assert.That(sut.ImagesAreEqual(tolFromBitmap, expectedImage), Is.True); + Assert.That(sut.CalcDiff(pic1, pic1, TestFiles.createToleranceMaskFromDiff).PixelErrorCount, Is.Zero); + } + + private static string FormatMetadata(Dictionary? metadata) + { + if (metadata is null) + { + return "null"; + } + + return string.Join("; ", metadata.Select(kvp => $"{kvp.Key}=[{kvp.Value.ValueA ?? ""} | {kvp.Value.ValueB ?? ""}]")); + } + } +} \ No newline at end of file diff --git a/SkiaSharpCompareTestNunit/SkiaSharpCompareMetaData_SkBitmap.cs b/SkiaSharpCompareTestNunit/SkiaSharpCompareMetaData_SkBitmap.cs new file mode 100644 index 0000000..920f66e --- /dev/null +++ b/SkiaSharpCompareTestNunit/SkiaSharpCompareMetaData_SkBitmap.cs @@ -0,0 +1,78 @@ +using Codeuctivity.SkiaSharpCompare; +using NUnit.Framework; +using SkiaSharp; +using System; +using System.IO; + +namespace SkiaSharpCompareTestNunit +{ + internal class SkiaSharpCompareMetaData_SkBitmap + { + [TestCase(TestFiles.png0Rgba32, TestFiles.png0Rgba32)] + [TestCase(TestFiles.imageWithoutGpsMetadata, TestFiles.imageWithGpsMetadata)] + public void ImagesAreEqual_SamePixelComparedByMetadataShouldReturnResult(string pic1Path, string pic2Path) + { + var pic1 = SKBitmap.Decode(Path.Combine(AppContext.BaseDirectory, pic1Path)); + var pic2 = SKBitmap.Decode(Path.Combine(AppContext.BaseDirectory, pic2Path)); + + var sut = new ImageCompare(compareMetadata: false); + Assert.That(sut.ImagesAreEqual(pic1, pic2)); + } + + [TestCase(TestFiles.png0Rgba32, TestFiles.png0Rgba32)] + [TestCase(TestFiles.imageWithoutGpsMetadata, TestFiles.imageWithGpsMetadata)] + public void ImagesAreEqual_SamePixelComparedByMetadataShouldThrows(string pic1Path, string pic2Path) + { + var pic1 = SKBitmap.Decode(Path.Combine(AppContext.BaseDirectory, pic1Path)); + var pic2 = SKBitmap.Decode(Path.Combine(AppContext.BaseDirectory, pic2Path)); + var sut = new ImageCompare(compareMetadata: true); + Action act = () => sut.ImagesAreEqual(pic1, pic2); + + var ex = Assert.Throws(act); + + Assert.That(ex?.Message, Is.EqualTo("Metadata comparison is not implemented for SKBitmap inputs. https://github.com/mono/SkiaSharp/issues/1139 Use the overload with streams or filepath to get support for metadata comparison.")); + } + + [Test] + public void CalcDiff_SamePixelComparedByMetadataShouldReturnResult_Null() + { + var pic1 = SKBitmap.Decode(Path.Combine(AppContext.BaseDirectory, TestFiles.imageWithGpsMetadata)); + var pic2 = SKBitmap.Decode(Path.Combine(AppContext.BaseDirectory, TestFiles.imageWithoutGpsMetadata)); + + var sut = new ImageCompare(compareMetadata: false); + var actual = sut.CalcDiff(pic1, pic2); + + Assert.That(actual.MetadataDifferences, Is.Null); + Assert.That(actual.PixelErrorCount, Is.Zero); + } + + [Test] + public void CalcDiff_SamePixelComparedByMetadataShouldReturnEmptyResult() + { + var pic1 = SKBitmap.Decode(Path.Combine(AppContext.BaseDirectory, TestFiles.imageWithoutGpsMetadata)); + var pic2 = SKBitmap.Decode(Path.Combine(AppContext.BaseDirectory, TestFiles.imageWithoutGpsMetadata)); + + var sut = new ImageCompare(compareMetadata: true); + Action act = () => sut.ImagesAreEqual(pic1, pic2); + + var ex = Assert.Throws(act); + + Assert.That(ex?.Message, Is.EqualTo("Metadata comparison is not implemented for SKBitmap inputs. https://github.com/mono/SkiaSharp/issues/1139 Use the overload with streams or filepath to get support for metadata comparison.")); + } + + [Test] + [SetCulture("en-US")] + public void CalcDiff_SamePixelComparedByMetadataShouldReturnCollectionOfMetadataThatDiffers() + { + var pic1 = SKBitmap.Decode(Path.Combine(AppContext.BaseDirectory, TestFiles.imageWithoutGpsMetadata)); + var pic2 = SKBitmap.Decode(Path.Combine(AppContext.BaseDirectory, TestFiles.imageWithGpsMetadata)); + + var sut = new ImageCompare(compareMetadata: true); + Action act = () => sut.ImagesAreEqual(pic1, pic2); + + var ex = Assert.Throws(act); + + Assert.That(ex?.Message, Is.EqualTo("Metadata comparison is not implemented for SKBitmap inputs. https://github.com/mono/SkiaSharp/issues/1139 Use the overload with streams or filepath to get support for metadata comparison.")); + } + } +} \ No newline at end of file diff --git a/SkiaSharpCompareTestNunit/SkiaSharpCompareMetaData_StreamTests.cs b/SkiaSharpCompareTestNunit/SkiaSharpCompareMetaData_StreamTests.cs new file mode 100644 index 0000000..5490fc2 --- /dev/null +++ b/SkiaSharpCompareTestNunit/SkiaSharpCompareMetaData_StreamTests.cs @@ -0,0 +1,92 @@ +using Codeuctivity.SkiaSharpCompare; +using NUnit.Framework; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace SkiaSharpCompareTestNunit +{ + internal class SkiaSharpCompareMetaData_StreamTests + { + [TestCase(TestFiles.png0Rgba32, TestFiles.png0Rgba32, true, true)] + [TestCase(TestFiles.png0Rgba32, TestFiles.png0Rgba32, false, true)] + [TestCase(TestFiles.imageWithoutGpsMetadata, TestFiles.imageWithGpsMetadata, true, false)] + [TestCase(TestFiles.imageWithoutGpsMetadata, TestFiles.imageWithGpsMetadata, false, true)] + public void ImagesAreEqual_SamePixelComparedByMetadataShouldReturnResult(string pic1Path, string pic2Path, bool shouldCompareMetadata, bool expectOutcomeComparisonOfImage) + { + using var pic1 = File.OpenRead(pic1Path); + using var pic2 = File.OpenRead(pic2Path); + + var sut = new ImageCompare(compareMetadata: shouldCompareMetadata); + Assert.That(sut.ImagesAreEqual(pic1, pic2), Is.EqualTo(expectOutcomeComparisonOfImage)); + } + + [Test] + public void CalcDiff_SamePixelComparedByMetadataShouldReturnResult_Null() + { + using var pic1 = File.OpenRead(TestFiles.imageWithoutGpsMetadata); + using var pic2 = File.OpenRead(TestFiles.imageWithoutGpsMetadata); + + var sut = new ImageCompare(compareMetadata: false); + var actual = sut.CalcDiff(pic1, pic2); + + Assert.That(actual.MetadataDifferences, Is.Null); + Assert.That(actual.PixelErrorCount, Is.Zero); + } + + [Test] + public void CalcDiff_SamePixelComparedByMetadataShouldReturnEmptyResult() + { + using var pic1 = File.OpenRead(TestFiles.imageWithoutGpsMetadata); + using var pic2 = File.OpenRead(TestFiles.imageWithoutGpsMetadata); + + var sut = new ImageCompare(compareMetadata: true); + var actual = sut.CalcDiff(pic1, pic2); + + Assert.That(actual.MetadataDifferences, Is.Empty); + Assert.That(actual.PixelErrorCount, Is.Zero); + } + + [Test] + [SetCulture("en-US")] + public void CalcDiff_SamePixelComparedByMetadataShouldReturnCollectionOfMetadataThatDiffers() + { + using var pic1 = File.OpenRead(TestFiles.imageWithoutGpsMetadata); + using var pic2 = File.OpenRead(TestFiles.imageWithGpsMetadata); + var sut = new ImageCompare(compareMetadata: true); + var actual = sut.CalcDiff(pic1, pic2); + + // Output actual and expected to test run logs for easier diagnosis + TestContext.WriteLine($"Actual image: {pic1}"); + TestContext.WriteLine($"Expected image: {pic2}"); + TestContext.WriteLine($"Actual MetadataDifferences: {FormatMetadata(actual.MetadataDifferences)}"); + + var expected = new Dictionary + { + { "GPS:GPS Altitude", ("0 metres", "201.62 metres") }, + { "GPS:GPS Date Stamp", ("", "2025:01:03") }, + { "GPS:GPS Latitude", ("0° 0' 0\"", "48° 12' 7.17\"") }, + { "GPS:GPS Latitude Ref", ("", "N") }, + { "GPS:GPS Longitude", ("0° 0' 0\"", "16° 24' 7.53\"") }, + { "GPS:GPS Longitude Ref", ("", "E") }, + { "GPS:GPS Processing Method", ("", "GPS") }, + { "GPS:GPS Time-Stamp", ("00:00:00.000 UTC", "14:41:20.000 UTC") } + }; + + TestContext.WriteLine($"Expected MetadataDifferences: {FormatMetadata(expected)}"); + + Assert.That(actual.MetadataDifferences, Is.EqualTo(expected)); + Assert.That(actual.PixelErrorCount, Is.Zero); + } + + private static string FormatMetadata(Dictionary? metadata) + { + if (metadata is null) + { + return "null"; + } + + return string.Join("; ", metadata.Select(kvp => $"{kvp.Key}=[{kvp.Value.ValueA ?? ""} | {kvp.Value.ValueB ?? ""}]")); + } + } +} \ No newline at end of file diff --git a/SkiaSharpCompareTestNunit/SkiaSharpCompareTestNunit.csproj b/SkiaSharpCompareTestNunit/SkiaSharpCompareTestNunit.csproj index 50929f4..dad41e9 100644 --- a/SkiaSharpCompareTestNunit/SkiaSharpCompareTestNunit.csproj +++ b/SkiaSharpCompareTestNunit/SkiaSharpCompareTestNunit.csproj @@ -1,37 +1,31 @@  - - net48;net6.0 - net6.0 - false - 8.0 - enable - true - + + net10.0 + false + enable + true + - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + - - - - - - - + + + diff --git a/SkiaSharpCompareTestNunit/SkiaSharpCompareTests.cs b/SkiaSharpCompareTestNunit/SkiaSharpCompareTests.cs new file mode 100644 index 0000000..1c5c1ef --- /dev/null +++ b/SkiaSharpCompareTestNunit/SkiaSharpCompareTests.cs @@ -0,0 +1,507 @@ +using Codeuctivity.SkiaSharpCompare; +using NUnit.Framework; +using SkiaSharp; +using System; +using System.IO; + +namespace SkiaSharpCompareTestNunit +{ + public class SkiaSharpCompareTests + { + [Test] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.jpg0Rgb24, true)] + [TestCase(TestFiles.png0Rgba32, TestFiles.png0Rgba32, true)] + [TestCase(TestFiles.png0Rgba32, TestFiles.jpg0Rgb24, true)] + [TestCase(TestFiles.png0Rgba32, TestFiles.jpg1Rgb24, true)] + [TestCase(TestFiles.png0Rgba32, TestFiles.pngBlack2x2px, false)] + public void ShouldVerifyThatImagesFromFilePathSizeAreEqual(string pathActual, string pathExpected, bool expectedOutcome) + { + var absolutePathActual = Path.Combine(AppContext.BaseDirectory, pathActual); + var absolutePathExpected = Path.Combine(AppContext.BaseDirectory, pathExpected); + + Assert.That(ImageCompare.ImagesHaveEqualSize(absolutePathActual, absolutePathExpected), Is.EqualTo(expectedOutcome)); + } + + [Test] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.jpg0Rgb24, true)] + [TestCase(TestFiles.png0Rgba32, TestFiles.png0Rgba32, true)] + [TestCase(TestFiles.png0Rgba32, TestFiles.jpg0Rgb24, true)] + [TestCase(TestFiles.png0Rgba32, TestFiles.jpg1Rgb24, true)] + [TestCase(TestFiles.png0Rgba32, TestFiles.pngBlack2x2px, false)] + public void ShouldVerifyThatImagesSizeAreEqual(string pathActual, string pathExpected, bool expectedOutcome) + { + var absolutePathActual = Path.Combine(AppContext.BaseDirectory, pathActual); + var absolutePathExpected = Path.Combine(AppContext.BaseDirectory, pathExpected); + + using var actual = SKBitmap.Decode(absolutePathActual); + using var expected = SKBitmap.Decode(absolutePathExpected); + + Assert.That(ImageCompare.ImagesHaveEqualSize(absolutePathActual, absolutePathExpected), Is.EqualTo(expectedOutcome)); + } + + [Test] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.jpg0Rgb24, true)] + [TestCase(TestFiles.png0Rgba32, TestFiles.png0Rgba32, true)] + [TestCase(TestFiles.png0Rgba32, TestFiles.jpg0Rgb24, true)] + [TestCase(TestFiles.png0Rgba32, TestFiles.jpg1Rgb24, true)] + [TestCase(TestFiles.png0Rgba32, TestFiles.pngBlack2x2px, false)] + public void ShouldVerifyThatImageStreamsSizeAreEqual(string pathActual, string pathExpected, bool expectedOutcome) + { + var absolutePathActual = Path.Combine(AppContext.BaseDirectory, pathActual); + var absolutePathExpected = Path.Combine(AppContext.BaseDirectory, pathExpected); + + using var actual = new FileStream(absolutePathActual, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var expected = new FileStream(absolutePathExpected, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + + Assert.That(ImageCompare.ImagesHaveEqualSize(absolutePathActual, absolutePathExpected), Is.EqualTo(expectedOutcome)); + } + + [Test] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.jpg0Rgb24, ResizeOption.Resize, TransparencyOptions.CompareAlphaChannel)] + [TestCase(TestFiles.png0Rgba32, TestFiles.png0Rgba32, ResizeOption.Resize, TransparencyOptions.CompareAlphaChannel)] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.jpg0Rgb24, ResizeOption.DontResize, TransparencyOptions.IgnoreAlphaChannel)] + [TestCase(TestFiles.png0Rgba32, TestFiles.png0Rgba32, ResizeOption.DontResize, TransparencyOptions.IgnoreAlphaChannel)] + [TestCase(TestFiles.pngTransparent4x4px, TestFiles.pngPartialTransparent4x4px, ResizeOption.DontResize, TransparencyOptions.IgnoreAlphaChannel)] + public void ShouldVerifyThatImagesAreEqual(string pathActual, string pathExpected, ResizeOption resizeOption, TransparencyOptions transparencyOptions) + { + var sut = new ImageCompare(resizeOption, transparencyOptions); + var absolutePathActual = Path.Combine(AppContext.BaseDirectory, pathActual); + var absolutePathExpected = Path.Combine(AppContext.BaseDirectory, pathExpected); + + Assert.That(sut.ImagesAreEqual(absolutePathActual, absolutePathExpected), Is.True); + } + + [Test] + [TestCase(TestFiles.pngBlack2x2px, TestFiles.pngBlack2x2px, ResizeOption.Resize, true)] + [TestCase(TestFiles.pngBlack2x2px, TestFiles.pngBlack4x4px, ResizeOption.Resize, true)] + [TestCase(TestFiles.pngBlack2x2px, TestFiles.pngBlack4x4px, ResizeOption.DontResize, false)] + [TestCase(TestFiles.colorShift1, TestFiles.colorShift2, ResizeOption.DontResize, false)] + public void ShouldVerifyThatImagesWithDifferentSizeAreEqual(string pathActual, string pathExpected, ResizeOption resizeOption, bool expectedResult) + { + var sut = new ImageCompare(resizeOption); + var absolutePathActual = Path.Combine(AppContext.BaseDirectory, pathActual); + var absolutePathExpected = Path.Combine(AppContext.BaseDirectory, pathExpected); + + Assert.That(sut.ImagesAreEqual(absolutePathActual, absolutePathExpected), Is.EqualTo(expectedResult)); + } + + [Test] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.jpg0Rgb24)] + [TestCase(TestFiles.png0Rgba32, TestFiles.png0Rgba32)] + public void ShouldVerifyThatImageStreamsAreEqual(string pathActual, string pathExpected) + { + var sut = new ImageCompare(); + var absolutePathActual = Path.Combine(AppContext.BaseDirectory, pathActual); + var absolutePathExpected = Path.Combine(AppContext.BaseDirectory, pathExpected); + + using var actual = new FileStream(absolutePathActual, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var expected = new FileStream(absolutePathExpected, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + + Assert.That(sut.ImagesAreEqual(actual, expected), Is.True); + } + + [Test] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.jpg0Rgb24)] + [TestCase(TestFiles.png0Rgba32, TestFiles.png0Rgba32)] + public void ShouldVerifyThatSkiaSharpImagesAreEqual(string pathActual, string pathExpected) + { + var sut = new ImageCompare(); + var absolutePathActual = Path.Combine(AppContext.BaseDirectory, pathActual); + var absolutePathExpected = Path.Combine(AppContext.BaseDirectory, pathExpected); + + using var actual = SKBitmap.Decode(absolutePathActual); + using var expected = SKBitmap.Decode(absolutePathExpected); + + Assert.That(sut.ImagesAreEqual(actual, expected), Is.True); + } + + [Test] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.png0Rgba32, 461891, 2.8574583652965777d, 158087, 97.799485288659028d, null, 0, TransparencyOptions.CompareAlphaChannel)] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.png0Rgba32, 461891, 2.8574583652965777d, 158087, 97.799485288659028d, ResizeOption.DontResize, 0, TransparencyOptions.CompareAlphaChannel)] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.png0Rgba32, 461891, 2.8574583652965777d, 158087, 97.799485288659028d, ResizeOption.Resize, 0, TransparencyOptions.CompareAlphaChannel)] + [TestCase(TestFiles.jpg1Rgb24, TestFiles.png1Rgba32, 460034, 2.8459701566405187d, 158121, 97.820519165573728d, ResizeOption.DontResize, 0, TransparencyOptions.CompareAlphaChannel)] + [TestCase(TestFiles.jpg1Rgb24, TestFiles.png1Rgba32, 460034, 2.8459701566405187d, 158121, 97.820519165573728d, ResizeOption.DontResize, 0, TransparencyOptions.CompareAlphaChannel)] + [TestCase(TestFiles.png1Rgba32, TestFiles.png1Rgba32, 0, 0, 0, 0, ResizeOption.DontResize, 0, TransparencyOptions.CompareAlphaChannel)] + [TestCase(TestFiles.jpg1Rgb24, TestFiles.jpg1Rgb24, 0, 0, 0, 0, ResizeOption.DontResize, 0, TransparencyOptions.CompareAlphaChannel)] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.jpg1Rgb24, 208890, 1.2922842790329365d, 2101, 1.2997698646408156d, ResizeOption.DontResize, 0, TransparencyOptions.CompareAlphaChannel)] + [TestCase(TestFiles.png0Rgba32, TestFiles.png1Rgba32, 203027, 1.25601321422385d, 681, 0.42129618173269651d, ResizeOption.DontResize, 0, TransparencyOptions.CompareAlphaChannel)] + [TestCase(TestFiles.pngBlack2x2px, TestFiles.pngBlack4x4px, 0, 0, 0, 0, ResizeOption.Resize, 0, TransparencyOptions.CompareAlphaChannel)] + [TestCase(TestFiles.pngBlack4x4px, TestFiles.pngWhite2x2px, 12240, 765, 16, 100.0d, ResizeOption.Resize, 0, TransparencyOptions.CompareAlphaChannel)] + [TestCase(TestFiles.renderedForm1, TestFiles.renderedForm2, 49267623, 60.794204096742348d, 174178, 21.49284304047384d, ResizeOption.Resize, 0, TransparencyOptions.CompareAlphaChannel)] + [TestCase(TestFiles.renderedForm2, TestFiles.renderedForm1, 49267623, 60.794204096742348d, 174178, 21.49284304047384d, ResizeOption.Resize, 0, TransparencyOptions.CompareAlphaChannel)] + [TestCase(TestFiles.colorShift1, TestFiles.colorShift2, 117896, 3.437201166180758d, 30398, 88.623906705539355d, ResizeOption.DontResize, 0, TransparencyOptions.CompareAlphaChannel)] + [TestCase(TestFiles.colorShift1, TestFiles.colorShift2, 0, 0, 0, 0, ResizeOption.DontResize, 15, TransparencyOptions.CompareAlphaChannel)] + [TestCase(TestFiles.pngPartialTransparent4x4px, TestFiles.pngTransparent4x4px, 2048, 128.0d, 16, 100, ResizeOption.DontResize, 0, TransparencyOptions.CompareAlphaChannel)] + [TestCase(TestFiles.pngPartialTransparent4x4px, TestFiles.pngTransparent4x4px, 0, 0, 0, 0, ResizeOption.DontResize, 0, TransparencyOptions.IgnoreAlphaChannel)] + public void ShouldVerifyThatImagesAreSemiEqual(string pathPic1, string pathPic2, int expectedAbsoluteError, double expectedMeanError, int expectedPixelErrorCount, double expectedPixelErrorPercentage, ResizeOption resizeOption, int colorShiftTolerance, TransparencyOptions transparencyOptions) + { + var sut = new ImageCompare(resizeOption, transparencyOptions, colorShiftTolerance); + var absolutePathPic1 = Path.Combine(AppContext.BaseDirectory, pathPic1); + var absolutePathPic2 = Path.Combine(AppContext.BaseDirectory, pathPic2); + var diff = sut.CalcDiff(absolutePathPic1, absolutePathPic2); + + Console.WriteLine($"PixelErrorCount: {diff.PixelErrorCount}"); + Console.WriteLine($"PixelErrorPercentage: {diff.PixelErrorPercentage}"); + Console.WriteLine($"AbsoluteError: {diff.AbsoluteError}"); + Console.WriteLine($"MeanError: {diff.MeanError}"); + + Assert.That(diff.AbsoluteError, Is.EqualTo(expectedAbsoluteError), "AbsoluteError"); + Assert.That(diff.MeanError, Is.EqualTo(expectedMeanError), "MeanError"); + Assert.That(diff.PixelErrorCount, Is.EqualTo(expectedPixelErrorCount), "PixelErrorCount"); + Assert.That(diff.PixelErrorPercentage, Is.EqualTo(expectedPixelErrorPercentage), "PixelErrorPercentage"); + } + + [TestCase(TestFiles.pngBlack2x2px, TestFiles.pngBlack4x4px)] + [TestCase(TestFiles.pngBlack4x4px, TestFiles.pngWhite2x2px)] + public void ShouldVerifyThatCalcDiffThrowsOnDifferentImageSizes(string pathPic1, string pathPic2) + { + var sut = new ImageCompare(); + var absolutePathPic1 = Path.Combine(AppContext.BaseDirectory, pathPic1); + var absolutePathPic2 = Path.Combine(AppContext.BaseDirectory, pathPic2); + Action action = () => sut.CalcDiff(absolutePathPic1, absolutePathPic2); + + var exception = Assert.Throws(action); + + Assert.That(exception?.Message, Is.EqualTo("Size of images differ.")); + } + + [Test] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.png0Rgba32, 461891, 2.8574583652965777d, 158087, 97.799485288659028d, null)] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.png0Rgba32, 461891, 2.8574583652965777d, 158087, 97.799485288659028d, ResizeOption.DontResize)] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.png0Rgba32, 461891, 2.8574583652965777d, 158087, 97.799485288659028d, ResizeOption.Resize)] + [TestCase(TestFiles.jpg1Rgb24, TestFiles.png1Rgba32, 460034, 2.8459701566405187d, 158121, 97.820519165573728d, ResizeOption.DontResize)] + [TestCase(TestFiles.png1Rgba32, TestFiles.png1Rgba32, 0, 0, 0, 0, ResizeOption.DontResize)] + [TestCase(TestFiles.jpg1Rgb24, TestFiles.jpg1Rgb24, 0, 0, 0, 0, ResizeOption.DontResize)] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.jpg1Rgb24, 208890, 1.2922842790329365d, 2101, 1.2997698646408156d, ResizeOption.DontResize)] + [TestCase(TestFiles.png0Rgba32, TestFiles.png1Rgba32, 203027, 1.25601321422385d, 681, 0.42129618173269651d, ResizeOption.DontResize)] + [TestCase(TestFiles.pngBlack2x2px, TestFiles.pngWhite2x2px, 3060, 765, 4, 100.0d, ResizeOption.DontResize)] + [TestCase(TestFiles.pngBlack2x2px, TestFiles.pngBlack4x4px, 0, 0, 0, 0, ResizeOption.Resize)] + [TestCase(TestFiles.pngBlack4x4px, TestFiles.pngWhite2x2px, 12240, 765, 16, 100.0d, ResizeOption.Resize)] + public void ShouldVerifyThatImageStreamsAreSemiEqual(string pathPic1, string pathPic2, int expectedAbsoluteError, double expectedMeanError, int expectedPixelErrorCount, double expectedPixelErrorPercentage, ResizeOption resizeOption) + { + var sut = new ImageCompare(resizeOption); + var absolutePathPic1 = Path.Combine(AppContext.BaseDirectory, pathPic1); + var absolutePathPic2 = Path.Combine(AppContext.BaseDirectory, pathPic2); + + using var pic1 = new FileStream(absolutePathPic1, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var pic2 = new FileStream(absolutePathPic2, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + var diff = sut.CalcDiff(pic1, pic2); + Assert.That(diff.AbsoluteError, Is.EqualTo(expectedAbsoluteError), "AbsoluteError"); + Assert.That(diff.MeanError, Is.EqualTo(expectedMeanError), "MeanError"); + Assert.That(diff.PixelErrorCount, Is.EqualTo(expectedPixelErrorCount), "PixelErrorCount"); + Assert.That(diff.PixelErrorPercentage, Is.EqualTo(expectedPixelErrorPercentage), "PixelErrorPercentage"); + } + + [TestCase(TestFiles.pngBlack2x2px, TestFiles.pngBlack2x2px, ResizeOption.Resize, true)] + [TestCase(TestFiles.pngBlack2x2px, TestFiles.pngBlack2x2px, ResizeOption.DontResize, true)] + [TestCase(TestFiles.pngBlack2x2px, TestFiles.pngBlack4x4px, ResizeOption.Resize, true)] + [TestCase(TestFiles.pngBlack2x2px, TestFiles.pngWhite2x2px, ResizeOption.DontResize, false)] + public void ShouldCalcDiffMaskSKBitmap(string pathPic1, string pathPic2, ResizeOption resizeOption, bool expectedOutcome) + { + var sut = new ImageCompare(resizeOption); + var absolutePathPic1 = Path.Combine(AppContext.BaseDirectory, pathPic1); + var absolutePathPic2 = Path.Combine(AppContext.BaseDirectory, pathPic2); + + using var absolutePic1 = SKBitmap.Decode(absolutePathPic1); + using var absolutePic2 = SKBitmap.Decode(absolutePathPic2); + using var maskImage = sut.CalcDiffMaskImage(absolutePic1, absolutePic2); + Assert.That(ImageExtensions.IsImageEntirelyBlack(maskImage, TransparencyOptions.IgnoreAlphaChannel), Is.EqualTo(expectedOutcome)); + } + + [TestCase(TestFiles.png0Rgba32, TestFiles.png1Rgba32, null, TransparencyOptions.IgnoreAlphaChannel)] + [TestCase(TestFiles.pngWhite2x2px, TestFiles.pngBlack2x2px, ResizeOption.Resize, TransparencyOptions.CompareAlphaChannel)] + [TestCase(TestFiles.pngTransparent4x4px, TestFiles.pngPartialTransparent4x4px, ResizeOption.Resize, TransparencyOptions.CompareAlphaChannel)] + [TestCase(TestFiles.png0Rgba32, TestFiles.png1Rgba32, ResizeOption.DontResize, TransparencyOptions.IgnoreAlphaChannel)] + [TestCase(TestFiles.png0Rgba32, TestFiles.png1Rgba32, ResizeOption.Resize, TransparencyOptions.IgnoreAlphaChannel)] + [TestCase(TestFiles.pngWhite2x2px, TestFiles.pngBlack4x4px, ResizeOption.Resize, TransparencyOptions.IgnoreAlphaChannel)] + [TestCase(TestFiles.pngBlack4x4px, TestFiles.pngWhite2x2px, ResizeOption.Resize, TransparencyOptions.IgnoreAlphaChannel)] + [TestCase(TestFiles.renderedForm1, TestFiles.renderedForm2, ResizeOption.Resize, TransparencyOptions.IgnoreAlphaChannel)] + [TestCase(TestFiles.renderedForm2, TestFiles.renderedForm1, ResizeOption.Resize, TransparencyOptions.IgnoreAlphaChannel)] + public void CalcDiff(string pathPic1, string pathPic2, ResizeOption resizeOption, TransparencyOptions transparencyOptions) + { + var sut = new ImageCompare(resizeOption, transparencyOptions); + var absolutePathPic1 = Path.Combine(AppContext.BaseDirectory, pathPic1); + var absolutePathPic2 = Path.Combine(AppContext.BaseDirectory, pathPic2); + + var maskImage1 = sut.CalcDiffMaskImage(absolutePathPic1, absolutePathPic2); + Assert.That(ImageExtensions.IsImageEntirelyBlack(maskImage1, transparencyOptions), Is.False); + + using var absolutePic1 = SKBitmap.Decode(absolutePathPic1); + using var absolutePic2 = SKBitmap.Decode(absolutePathPic2); + var differenceMask = Path.GetTempFileName() + "differenceMask.png"; + + using (var fileStreamDifferenceMask = File.Create(differenceMask)) + using (var maskImage = sut.CalcDiffMaskImage(absolutePic1, absolutePic2)) + { + Assert.That(ImageExtensions.IsImageEntirelyBlack(maskImage, transparencyOptions), Is.False); + SaveAsPng(maskImage, fileStreamDifferenceMask); + } + + var maskedDiff = Compare.CalcDiff(absolutePathPic1, absolutePathPic2, differenceMask, resizeOption, 0, transparencyOptions); + File.Delete(differenceMask); + + Assert.That(maskedDiff.AbsoluteError, Is.EqualTo(0), "AbsoluteError"); + Assert.That(maskedDiff.MeanError, Is.EqualTo(0), "MeanError"); + Assert.That(maskedDiff.PixelErrorCount, Is.EqualTo(0), "PixelErrorCount"); + Assert.That(maskedDiff.PixelErrorPercentage, Is.EqualTo(0), "PixelErrorPercentage"); + } + + private static void SaveAsPng(SKBitmap maskImage, FileStream fileStreamDifferenceMask) + { + var encodedData = maskImage.Encode(SKEncodedImageFormat.Png, 100); + encodedData.SaveTo(fileStreamDifferenceMask); + } + + [TestCase(TestFiles.png0Rgba32, TestFiles.png1Rgba32, 0, 0, 0, 0, ResizeOption.DontResize, TransparencyOptions.CompareAlphaChannel)] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.jpg1Rgb24, 0, 0, 0, 0, ResizeOption.DontResize, TransparencyOptions.CompareAlphaChannel)] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.jpg1Rgb24, 0, 0, 0, 0, ResizeOption.Resize, TransparencyOptions.CompareAlphaChannel)] + [TestCase(TestFiles.pngBlack2x2px, TestFiles.pngBlack4x4px, 0, 0, 0, 0, ResizeOption.Resize, TransparencyOptions.CompareAlphaChannel)] + [TestCase(TestFiles.pngBlack2x2px, TestFiles.pngWhite2x2px, 0, 0, 0, 0, ResizeOption.Resize, TransparencyOptions.CompareAlphaChannel)] + [TestCase(TestFiles.pngTransparent4x4px, TestFiles.pngWhite2x2px, 0, 0, 0, 0, ResizeOption.Resize, TransparencyOptions.CompareAlphaChannel)] + [TestCase(TestFiles.pngTransparent4x4px, TestFiles.pngBlack4x4px, 0, 0, 0, 0, ResizeOption.Resize, TransparencyOptions.CompareAlphaChannel)] + [TestCase(TestFiles.png0Rgba32, TestFiles.png1Rgba32, 0, 0, 0, 0, ResizeOption.DontResize, TransparencyOptions.IgnoreAlphaChannel)] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.jpg1Rgb24, 0, 0, 0, 0, ResizeOption.DontResize, TransparencyOptions.IgnoreAlphaChannel)] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.jpg1Rgb24, 0, 0, 0, 0, ResizeOption.Resize, TransparencyOptions.IgnoreAlphaChannel)] + [TestCase(TestFiles.pngBlack2x2px, TestFiles.pngBlack4x4px, 0, 0, 0, 0, ResizeOption.Resize, TransparencyOptions.IgnoreAlphaChannel)] + [TestCase(TestFiles.pngBlack2x2px, TestFiles.pngWhite2x2px, 0, 0, 0, 0, ResizeOption.Resize, TransparencyOptions.IgnoreAlphaChannel)] + [TestCase(TestFiles.pngTransparent4x4px, TestFiles.pngWhite2x2px, 0, 0, 0, 0, ResizeOption.Resize, TransparencyOptions.IgnoreAlphaChannel)] + [TestCase(TestFiles.pngTransparent4x4px, TestFiles.pngBlack4x4px, 0, 0, 0, 0, ResizeOption.Resize, TransparencyOptions.IgnoreAlphaChannel)] + public void ShouldCalcDiffMaskSKBitmapAndUseOutcome(string pathPic1, string pathPic2, int expectedMeanError, int expectedAbsoluteError, int expectedPixelErrorCount, double expectedPixelErrorPercentage, ResizeOption resizeOption, TransparencyOptions transparencyOptions) + { + var sut = new ImageCompare(resizeOption, transparencyOptions); + var absolutePathPic1 = Path.Combine(AppContext.BaseDirectory, pathPic1); + var absolutePathPic2 = Path.Combine(AppContext.BaseDirectory, pathPic2); + var differenceMaskPicPath = Path.GetTempFileName() + "differenceMask.png"; + + using var absolutePic1 = SKBitmap.Decode(absolutePathPic1); + using var absolutePic2 = SKBitmap.Decode(absolutePathPic2); + + using (var fileStreamDifferenceMask = File.Create(differenceMaskPicPath)) + using (var maskImage = sut.CalcDiffMaskImage(absolutePic1, absolutePic2)) + { + SaveAsPng(maskImage, fileStreamDifferenceMask); + } + + using var differenceMaskPic = SKBitmap.Decode(differenceMaskPicPath); + var maskedDiff = sut.CalcDiff(absolutePic1, absolutePic2, differenceMaskPic); + File.Delete(differenceMaskPicPath); + + Assert.That(maskedDiff.AbsoluteError, Is.EqualTo(expectedAbsoluteError), "AbsoluteError"); + Assert.That(maskedDiff.MeanError, Is.EqualTo(expectedMeanError), "MeanError"); + Assert.That(maskedDiff.PixelErrorCount, Is.EqualTo(expectedPixelErrorCount), "PixelErrorCount"); + Assert.That(maskedDiff.PixelErrorPercentage, Is.EqualTo(expectedPixelErrorPercentage), "PixelErrorPercentage"); + } + + [TestCase(TestFiles.pngWhite2x2px, TestFiles.pngBlack2x2px, TestFiles.pngTransparent4x4px, 765, 12240, 16, 100d, ResizeOption.Resize, 0)] + [TestCase(TestFiles.pngWhite2x2px, TestFiles.pngBlack2x2px, TestFiles.pngBlack4x4px, 765, 12240, 16, 100d, ResizeOption.Resize, 0)] + [TestCase(TestFiles.pngBlack2x2px, TestFiles.pngBlack2x2px, TestFiles.pngBlack4x4px, 0, 0, 0, 0, ResizeOption.Resize, 0)] + [TestCase(TestFiles.pngBlack2x2px, TestFiles.pngBlack4x4px, TestFiles.pngBlack2x2px, 0, 0, 0, 0, ResizeOption.Resize, 0)] + [TestCase(TestFiles.pngBlack4x4px, TestFiles.pngBlack2x2px, TestFiles.pngBlack2x2px, 0, 0, 0, 0, ResizeOption.Resize, 0)] + [TestCase(TestFiles.colorShift1, TestFiles.colorShift1, TestFiles.pngBlack2x2px, 0, 0, 0, 0, ResizeOption.Resize, 0)] + [TestCase(TestFiles.colorShift1, TestFiles.colorShift2, TestFiles.pngBlack2x2px, 0, 0, 0, 0, ResizeOption.Resize, 20)] + public void ShouldUseDiffMask(string pathPic1, string pathPic2, string pathPic3, double expectedMeanError, int expectedAbsoluteError, int expectedPixelErrorCount, double expectedPixelErrorPercentage, ResizeOption resizeOption, int colorShiftTolerance) + { + var sut = new ImageCompare(resizeOption, TransparencyOptions.CompareAlphaChannel, colorShiftTolerance); + var absolutePathPic1 = Path.Combine(AppContext.BaseDirectory, pathPic1); + var absolutePathPic2 = Path.Combine(AppContext.BaseDirectory, pathPic2); + var differenceMaskPic = Path.Combine(AppContext.BaseDirectory, pathPic3); + using var pic1 = SKBitmap.Decode(absolutePathPic1); + using var pic2 = SKBitmap.Decode(absolutePathPic2); + using var maskPic = SKBitmap.Decode(differenceMaskPic); + + var maskedDiff = sut.CalcDiff(pic1, pic2, maskPic); + + Assert.That(maskedDiff.MeanError, Is.EqualTo(expectedMeanError), "MeanError"); + Assert.That(maskedDiff.AbsoluteError, Is.EqualTo(expectedAbsoluteError), "AbsoluteError"); + Assert.That(maskedDiff.PixelErrorCount, Is.EqualTo(expectedPixelErrorCount), "PixelErrorCount"); + Assert.That(maskedDiff.PixelErrorPercentage, Is.EqualTo(expectedPixelErrorPercentage), "PixelErrorPercentage"); + } + + [TestCase(TestFiles.pngBlack2x2px, TestFiles.pngBlack2x2px, TestFiles.pngBlack4x4px)] + [TestCase(TestFiles.pngBlack2x2px, TestFiles.pngBlack4x4px, TestFiles.pngBlack2x2px)] + [TestCase(TestFiles.pngBlack4x4px, TestFiles.pngBlack2x2px, TestFiles.pngBlack2x2px)] + public void ShouldThrowUsingInvalidImageDimensionsDiffMask(string pathPic1, string pathPic2, string pathPic3) + { + var sut = new ImageCompare(); + var absolutePathPic1 = Path.Combine(AppContext.BaseDirectory, pathPic1); + var absolutePathPic2 = Path.Combine(AppContext.BaseDirectory, pathPic2); + var differenceMaskPic = Path.Combine(AppContext.BaseDirectory, pathPic3); + using var pic1 = SKBitmap.Decode(absolutePathPic1); + using var pic2 = SKBitmap.Decode(absolutePathPic2); + using var maskPic = SKBitmap.Decode(differenceMaskPic); + Action action = () => sut.CalcDiff(pic1, pic2, maskPic); + + var exception = Assert.Throws(action); + + Assert.That(exception?.Message, Is.EqualTo("Size of images differ.")); + } + + [TestCase(TestFiles.png0Rgba32, TestFiles.png1Rgba32, 0, 0, 0, 0)] + public void CalcDiffStreams(string pathPic1, string pathPic2, int expectedMeanError, int expectedAbsoluteError, int expectedPixelErrorCount, double expectedPixelErrorPercentage) + { + var sut = new ImageCompare(transparencyOptions: TransparencyOptions.IgnoreAlphaChannel); + var absolutePathPic1 = Path.Combine(AppContext.BaseDirectory, pathPic1); + var absolutePathPic2 = Path.Combine(AppContext.BaseDirectory, pathPic2); + + using var pic1 = new FileStream(absolutePathPic1, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var pic2 = new FileStream(absolutePathPic2, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + + using var maskImage = sut.CalcDiffMaskImage(pic1, pic2); + + pic1.Position = 0; + pic2.Position = 0; + + var maskedDiff = sut.CalcDiff(pic1, pic2, maskImage); + Assert.That(maskedDiff.AbsoluteError, Is.EqualTo(expectedAbsoluteError), "AbsoluteError"); + Assert.That(maskedDiff.MeanError, Is.EqualTo(expectedMeanError), "MeanError"); + Assert.That(maskedDiff.PixelErrorCount, Is.EqualTo(expectedPixelErrorCount), "PixelErrorCount"); + Assert.That(maskedDiff.PixelErrorPercentage, Is.EqualTo(expectedPixelErrorPercentage), "PixelErrorPercentage"); + } + + [TestCase(TestFiles.png0Rgba32, TestFiles.png1Rgba32, TransparencyOptions.CompareAlphaChannel, 0)] + [TestCase(TestFiles.colorShift1, TestFiles.colorShift2, TransparencyOptions.CompareAlphaChannel, 20)] + [TestCase(TestFiles.png0Rgba32, TestFiles.png1Rgba32, TransparencyOptions.IgnoreAlphaChannel, 0)] + [TestCase(TestFiles.colorShift1, TestFiles.colorShift2, TransparencyOptions.IgnoreAlphaChannel, 20)] + [TestCase(TestFiles.pngTransparent4x4px, TestFiles.pngPartialTransparent4x4px, TransparencyOptions.IgnoreAlphaChannel, 0)] + [TestCase(TestFiles.pngTransparent4x4px, TestFiles.pngPartialTransparent4x4px, TransparencyOptions.CompareAlphaChannel, 0)] + public void CalcDiffMaskImage_WhenSupplyingDiffMaskOfTwoImagesByFilePath_NoDifferences(string image1RelativePath, string image2RelativePath, TransparencyOptions transparencyOptions, int colorShiftTolerance) + { + var sut = new ImageCompare(ResizeOption.DontResize, transparencyOptions, colorShiftTolerance); + var image1Path = Path.Combine(AppContext.BaseDirectory, image1RelativePath); + var image2Path = Path.Combine(AppContext.BaseDirectory, image2RelativePath); + var diffMask1Path = Path.GetTempFileName() + "differenceMask.png"; + + using (var diffMask1Stream = File.Create(diffMask1Path)) + { + using var diffMask1Image = sut.CalcDiffMaskImage(image1Path, image2Path); + ImageExtensions.SaveAsPng(diffMask1Image, diffMask1Stream); + } + + using var diffMask2Image = sut.CalcDiffMaskImage(image1Path, image2Path, diffMask1Path); + + using (var diffMask2Stream = File.Create(diffMask1Path)) + { + ImageExtensions.SaveAsPng(diffMask2Image, diffMask2Stream); + } + + Assert.That(ImageExtensions.IsImageEntirelyBlack(diffMask2Image, transparencyOptions), Is.True); + + File.Delete(diffMask1Path); + } + + [TestCase(TestFiles.png0Rgba32, TestFiles.png1Rgba32)] + public void CalcDiffMaskImage_WhenSupplyingDiffMaskOfTwoImagesByStream_NoDifferences(string image1RelativePath, string image2RelativePath) + { + var sut = new ImageCompare(); + var image1Path = Path.Combine(AppContext.BaseDirectory, image1RelativePath); + var image2Path = Path.Combine(AppContext.BaseDirectory, image2RelativePath); + var diffMask1Path = Path.GetTempFileName() + "differenceMask.png"; + + using var image1Stream = new FileStream(image1Path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var image2Stream = new FileStream(image2Path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + + using (var diffMask1Stream = File.Create(diffMask1Path)) + { + using var diffMask1Image = sut.CalcDiffMaskImage(image1Stream, image2Stream); + ImageExtensions.SaveAsPng(diffMask1Image, diffMask1Stream); + } + + image1Stream.Position = 0; + image2Stream.Position = 0; + + using (var diffMask1Stream = new FileStream(diffMask1Path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + { + diffMask1Stream.Position = 0; + using var diffMask2Image = sut.CalcDiffMaskImage(image1Stream, image2Stream, diffMask1Stream); + Assert.That(ImageExtensions.IsImageEntirelyBlack(diffMask2Image, TransparencyOptions.IgnoreAlphaChannel), Is.True); + } + + File.Delete(diffMask1Path); + } + + [TestCase(TestFiles.png0Rgba32, TestFiles.png1Rgba32)] + public void CalcDiffMaskImage_WhenSupplyingDiffMaskOfTwoImagesByImage_NoDifferences(string image1RelativePath, string image2RelativePath) + { + var sut = new ImageCompare(); + var image1Path = Path.Combine(AppContext.BaseDirectory, image1RelativePath); + var image2Path = Path.Combine(AppContext.BaseDirectory, image2RelativePath); + + using var image1 = SKBitmap.Decode(image1Path); + using var image2 = SKBitmap.Decode(image2Path); + + using var diffMask1Image = sut.CalcDiffMaskImage(image1, image2); + using var diffMask2Image = sut.CalcDiffMaskImage(image1, image2, diffMask1Image); + + Assert.That(ImageExtensions.IsImageEntirelyBlack(diffMask2Image, TransparencyOptions.IgnoreAlphaChannel), Is.True); + } + + [Test] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.jpg1Rgb24)] + [TestCase(TestFiles.png0Rgba32, TestFiles.png1Rgba32)] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.png1Rgba32)] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.png0Rgba32)] + [TestCase(TestFiles.jpg1Rgb24, TestFiles.png1Rgba32)] + [TestCase(TestFiles.colorShift1, TestFiles.colorShift2)] + [TestCase(TestFiles.pngTransparent4x4px, TestFiles.pngPartialTransparent4x4px)] + public void ShouldVerifyThatImagesAreNotEqual(string pathActual, string pathExpected) + { + var sut = new ImageCompare(); + var absolutePathActual = Path.Combine(AppContext.BaseDirectory, pathActual); + var absolutePathExpected = Path.Combine(AppContext.BaseDirectory, pathExpected); + + Assert.That(sut.ImagesAreEqual(absolutePathActual, absolutePathExpected), Is.False); + } + + [Test] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.jpg1Rgb24)] + [TestCase(TestFiles.png0Rgba32, TestFiles.png1Rgba32)] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.png1Rgba32)] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.png0Rgba32)] + [TestCase(TestFiles.jpg1Rgb24, TestFiles.png1Rgba32)] + [TestCase(TestFiles.colorShift1, TestFiles.colorShift2)] + [TestCase(TestFiles.pngTransparent4x4px, TestFiles.pngPartialTransparent4x4px)] + public void ShouldVerifyThatImageStreamAreNotEqual(string pathActual, string pathExpected) + { + var sut = new ImageCompare(); + var absolutePathActual = Path.Combine(AppContext.BaseDirectory, pathActual); + var absolutePathExpected = Path.Combine(AppContext.BaseDirectory, pathExpected); + + using var actual = new FileStream(absolutePathActual, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var expected = new FileStream(absolutePathExpected, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + + Assert.That(sut.ImagesAreEqual(actual, expected), Is.False); + } + + [TestCase(TestFiles.png0Rgba32, TestFiles.pngBlack2x2px, TransparencyOptions.IgnoreAlphaChannel)] + [TestCase(TestFiles.png0Rgba32, TestFiles.pngBlack2x2px, TransparencyOptions.CompareAlphaChannel)] + public void ShouldVerifyThatImageWithDifferentSizeThrows(string pathPic1, string pathPic2, TransparencyOptions transparencyOptions) + { + var sut = new ImageCompare(ResizeOption.DontResize, transparencyOptions); + var absolutePathPic1 = Path.Combine(AppContext.BaseDirectory, pathPic1); + var absolutePathPic2 = Path.Combine(AppContext.BaseDirectory, pathPic2); + Action action = () => sut.CalcDiff(absolutePathPic1, absolutePathPic2); + + var exception = Assert.Throws(action); + + Assert.That(exception?.Message, Is.EqualTo("Size of images differ.")); + } + + [TestCase(TestFiles.png0Rgba32, TestFiles.png0Rgba32, TestFiles.pngBlack2x2px)] + [TestCase(TestFiles.png0Rgba32, TestFiles.pngBlack2x2px, TestFiles.png0Rgba32)] + [TestCase(TestFiles.pngBlack2x2px, TestFiles.png0Rgba32, TestFiles.png0Rgba32)] + [TestCase(TestFiles.pngPartialTransparent4x4px, TestFiles.png0Rgba32, TestFiles.png0Rgba32)] + [TestCase(TestFiles.png0Rgba32, TestFiles.png0Rgba32, TestFiles.pngPartialTransparent4x4px)] + public void ShouldVerifyThatImageWithDifferentSizeThrows(string pathPic1, string pathPic2, string pathPic3) + { + var sut = new ImageCompare(); + var absolutePathPic1 = Path.Combine(AppContext.BaseDirectory, pathPic1); + var absolutePathPic2 = Path.Combine(AppContext.BaseDirectory, pathPic2); + var absolutePathPic3 = Path.Combine(AppContext.BaseDirectory, pathPic3); + Action action = () => sut.CalcDiff(absolutePathPic1, absolutePathPic2, absolutePathPic3); + + var exception = Assert.Throws(action); + + Assert.That(exception?.Message, Is.EqualTo("Size of images differ.")); + } + } +} \ No newline at end of file diff --git a/SkiaSharpCompareTestNunit/SkiaSharpStaticCompareTests.cs b/SkiaSharpCompareTestNunit/SkiaSharpStaticCompareTests.cs new file mode 100644 index 0000000..94955c5 --- /dev/null +++ b/SkiaSharpCompareTestNunit/SkiaSharpStaticCompareTests.cs @@ -0,0 +1,477 @@ +using Codeuctivity.SkiaSharpCompare; +using NUnit.Framework; +using SkiaSharp; +using System; +using System.IO; + +namespace SkiaSharpCompareTestNunit +{ + public class SkiaSharpStaticCompareTests + { + [Test] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.jpg0Rgb24, true)] + [TestCase(TestFiles.png0Rgba32, TestFiles.png0Rgba32, true)] + [TestCase(TestFiles.png0Rgba32, TestFiles.jpg0Rgb24, true)] + [TestCase(TestFiles.png0Rgba32, TestFiles.jpg1Rgb24, true)] + [TestCase(TestFiles.png0Rgba32, TestFiles.pngBlack2x2px, false)] + public void ShouldVerifyThatImagesFromFilePathSizeAreEqual(string pathActual, string pathExpected, bool expectedOutcome) + { + var absolutePathActual = Path.Combine(AppContext.BaseDirectory, pathActual); + var absolutePathExpected = Path.Combine(AppContext.BaseDirectory, pathExpected); + + Assert.That(Compare.ImagesHaveEqualSize(absolutePathActual, absolutePathExpected), Is.EqualTo(expectedOutcome)); + } + + [Test] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.jpg0Rgb24, true)] + [TestCase(TestFiles.png0Rgba32, TestFiles.png0Rgba32, true)] + [TestCase(TestFiles.png0Rgba32, TestFiles.jpg0Rgb24, true)] + [TestCase(TestFiles.png0Rgba32, TestFiles.jpg1Rgb24, true)] + [TestCase(TestFiles.png0Rgba32, TestFiles.pngBlack2x2px, false)] + public void ShouldVerifyThatImagesSizeAreEqual(string pathActual, string pathExpected, bool expectedOutcome) + { + var absolutePathActual = Path.Combine(AppContext.BaseDirectory, pathActual); + var absolutePathExpected = Path.Combine(AppContext.BaseDirectory, pathExpected); + + using var actual = SKBitmap.Decode(absolutePathActual); + using var expected = SKBitmap.Decode(absolutePathExpected); + + Assert.That(Compare.ImagesHaveEqualSize(absolutePathActual, absolutePathExpected), Is.EqualTo(expectedOutcome)); + } + + [Test] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.jpg0Rgb24, true)] + [TestCase(TestFiles.png0Rgba32, TestFiles.png0Rgba32, true)] + [TestCase(TestFiles.png0Rgba32, TestFiles.jpg0Rgb24, true)] + [TestCase(TestFiles.png0Rgba32, TestFiles.jpg1Rgb24, true)] + [TestCase(TestFiles.png0Rgba32, TestFiles.pngBlack2x2px, false)] + public void ShouldVerifyThatImageStreamsSizeAreEqual(string pathActual, string pathExpected, bool expectedOutcome) + { + var absolutePathActual = Path.Combine(AppContext.BaseDirectory, pathActual); + var absolutePathExpected = Path.Combine(AppContext.BaseDirectory, pathExpected); + + using var actual = new FileStream(absolutePathActual, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var expected = new FileStream(absolutePathExpected, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + + Assert.That(Compare.ImagesHaveEqualSize(absolutePathActual, absolutePathExpected), Is.EqualTo(expectedOutcome)); + } + + [Test] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.jpg0Rgb24, 0)] + [TestCase(TestFiles.png0Rgba32, TestFiles.png0Rgba32, 0)] + [TestCase(TestFiles.colorShift1, TestFiles.colorShift2, 15)] + public void ShouldVerifyThatImagesAreEqual(string pathActual, string pathExpected, int colorShiftTolerance) + { + var absolutePathActual = Path.Combine(AppContext.BaseDirectory, pathActual); + var absolutePathExpected = Path.Combine(AppContext.BaseDirectory, pathExpected); + + Assert.That(Compare.ImagesAreEqual(absolutePathActual, absolutePathExpected, pixelColorShiftTolerance: colorShiftTolerance), Is.True); + } + + [Test] + [TestCase(TestFiles.pngBlack2x2px, TestFiles.pngBlack2x2px, ResizeOption.Resize, true)] + [TestCase(TestFiles.pngBlack2x2px, TestFiles.pngBlack4x4px, ResizeOption.Resize, true)] + [TestCase(TestFiles.pngBlack2x2px, TestFiles.pngBlack4x4px, ResizeOption.DontResize, false)] + [TestCase(TestFiles.colorShift1, TestFiles.colorShift2, ResizeOption.DontResize, false)] + public void ShouldVerifyThatImagesWithDifferentSizeAreEqual(string pathActual, string pathExpected, ResizeOption resizeOption, bool expectedResult) + { + var absolutePathActual = Path.Combine(AppContext.BaseDirectory, pathActual); + var absolutePathExpected = Path.Combine(AppContext.BaseDirectory, pathExpected); + + Assert.That(Compare.ImagesAreEqual(absolutePathActual, absolutePathExpected, resizeOption), Is.EqualTo(expectedResult)); + } + + [Test] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.jpg0Rgb24)] + [TestCase(TestFiles.png0Rgba32, TestFiles.png0Rgba32)] + public void ShouldVerifyThatImageStreamsAreEqual(string pathActual, string pathExpected) + { + var absolutePathActual = Path.Combine(AppContext.BaseDirectory, pathActual); + var absolutePathExpected = Path.Combine(AppContext.BaseDirectory, pathExpected); + + using var actual = new FileStream(absolutePathActual, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var expected = new FileStream(absolutePathExpected, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + + Assert.That(Compare.ImagesAreEqual(actual, expected), Is.True); + } + + [Test] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.jpg0Rgb24)] + [TestCase(TestFiles.png0Rgba32, TestFiles.png0Rgba32)] + public void ShouldVerifyThatSkiaSharpImagesAreEqual(string pathActual, string pathExpected) + { + var absolutePathActual = Path.Combine(AppContext.BaseDirectory, pathActual); + var absolutePathExpected = Path.Combine(AppContext.BaseDirectory, pathExpected); + + using var actual = SKBitmap.Decode(absolutePathActual); + using var expected = SKBitmap.Decode(absolutePathExpected); + + Assert.That(Compare.ImagesAreEqual(actual, expected), Is.True); + } + + [Test] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.png0Rgba32, 461891, 2.8574583652965777d, 158087, 97.799485288659028d, null, 0)] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.png0Rgba32, 461891, 2.8574583652965777d, 158087, 97.799485288659028d, ResizeOption.DontResize, 0)] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.png0Rgba32, 461891, 2.8574583652965777d, 158087, 97.799485288659028d, ResizeOption.Resize, 0)] + [TestCase(TestFiles.jpg1Rgb24, TestFiles.png1Rgba32, 460034, 2.8459701566405187d, 158121, 97.820519165573728d, ResizeOption.DontResize, 0)] + [TestCase(TestFiles.jpg1Rgb24, TestFiles.png1Rgba32, 460034, 2.8459701566405187d, 158121, 97.820519165573728d, ResizeOption.DontResize, 0)] + [TestCase(TestFiles.png1Rgba32, TestFiles.png1Rgba32, 0, 0, 0, 0, ResizeOption.DontResize, 0)] + [TestCase(TestFiles.jpg1Rgb24, TestFiles.jpg1Rgb24, 0, 0, 0, 0, ResizeOption.DontResize, 0)] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.jpg1Rgb24, 208890, 1.2922842790329365d, 2101, 1.2997698646408156d, ResizeOption.DontResize, 0)] + [TestCase(TestFiles.png0Rgba32, TestFiles.png1Rgba32, 203027, 1.25601321422385d, 681, 0.42129618173269651d, ResizeOption.DontResize, 0)] + [TestCase(TestFiles.pngBlack2x2px, TestFiles.pngBlack4x4px, 0, 0, 0, 0, ResizeOption.Resize, 0)] + [TestCase(TestFiles.pngBlack4x4px, TestFiles.pngWhite2x2px, 12240, 765, 16, 100.0d, ResizeOption.Resize, 0)] + [TestCase(TestFiles.renderedForm1, TestFiles.renderedForm2, 49267623, 60.794204096742348d, 174178, 21.49284304047384d, ResizeOption.Resize, 0)] + [TestCase(TestFiles.renderedForm2, TestFiles.renderedForm1, 49267623, 60.794204096742348d, 174178, 21.49284304047384d, ResizeOption.Resize, 0)] + [TestCase(TestFiles.colorShift1, TestFiles.colorShift2, 117896, 3.437201166180758d, 30398, 88.623906705539355d, ResizeOption.DontResize, 0)] + [TestCase(TestFiles.colorShift1, TestFiles.colorShift2, 0, 0, 0, 0, ResizeOption.DontResize, 15)] + public void ShouldVerifyThatImagesAreSemiEqual(string pathPic1, string pathPic2, int expectedAbsoluteError, double expectedMeanError, int expectedPixelErrorCount, double expectedPixelErrorPercentage, ResizeOption resizeOption, int colorShiftTolerance) + { + var absolutePathPic1 = Path.Combine(AppContext.BaseDirectory, pathPic1); + var absolutePathPic2 = Path.Combine(AppContext.BaseDirectory, pathPic2); + var diff = Compare.CalcDiff(absolutePathPic1, absolutePathPic2, resizeOption, colorShiftTolerance); + + Console.WriteLine($"PixelErrorCount: {diff.PixelErrorCount}"); + Console.WriteLine($"PixelErrorPercentage: {diff.PixelErrorPercentage}"); + Console.WriteLine($"AbsoluteError: {diff.AbsoluteError}"); + Console.WriteLine($"MeanError: {diff.MeanError}"); + + Assert.That(diff.AbsoluteError, Is.EqualTo(expectedAbsoluteError), "AbsoluteError"); + Assert.That(diff.MeanError, Is.EqualTo(expectedMeanError), "MeanError"); + Assert.That(diff.PixelErrorCount, Is.EqualTo(expectedPixelErrorCount), "PixelErrorCount"); + Assert.That(diff.PixelErrorPercentage, Is.EqualTo(expectedPixelErrorPercentage), "PixelErrorPercentage"); + } + + [TestCase(TestFiles.pngBlack2x2px, TestFiles.pngBlack4x4px)] + [TestCase(TestFiles.pngBlack4x4px, TestFiles.pngWhite2x2px)] + public void ShouldVerifyThatCalcDiffThrowsOnDifferentImageSizes(string pathPic1, string pathPic2) + { + var absolutePathPic1 = Path.Combine(AppContext.BaseDirectory, pathPic1); + var absolutePathPic2 = Path.Combine(AppContext.BaseDirectory, pathPic2); + Action action = () => Compare.CalcDiff(absolutePathPic1, absolutePathPic2, ResizeOption.DontResize); + + var exception = Assert.Throws(action); + + Assert.That(exception?.Message, Is.EqualTo("Size of images differ.")); + } + + [Test] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.png0Rgba32, 461891, 2.8574583652965777d, 158087, 97.799485288659028d, null)] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.png0Rgba32, 461891, 2.8574583652965777d, 158087, 97.799485288659028d, ResizeOption.DontResize)] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.png0Rgba32, 461891, 2.8574583652965777d, 158087, 97.799485288659028d, ResizeOption.Resize)] + [TestCase(TestFiles.jpg1Rgb24, TestFiles.png1Rgba32, 460034, 2.8459701566405187d, 158121, 97.820519165573728d, ResizeOption.DontResize)] + [TestCase(TestFiles.png1Rgba32, TestFiles.png1Rgba32, 0, 0, 0, 0, ResizeOption.DontResize)] + [TestCase(TestFiles.jpg1Rgb24, TestFiles.jpg1Rgb24, 0, 0, 0, 0, ResizeOption.DontResize)] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.jpg1Rgb24, 208890, 1.2922842790329365d, 2101, 1.2997698646408156d, ResizeOption.DontResize)] + [TestCase(TestFiles.png0Rgba32, TestFiles.png1Rgba32, 203027, 1.25601321422385d, 681, 0.42129618173269651d, ResizeOption.DontResize)] + [TestCase(TestFiles.pngBlack2x2px, TestFiles.pngWhite2x2px, 3060, 765, 4, 100.0d, ResizeOption.DontResize)] + [TestCase(TestFiles.pngBlack2x2px, TestFiles.pngBlack4x4px, 0, 0, 0, 0, ResizeOption.Resize)] + [TestCase(TestFiles.pngBlack4x4px, TestFiles.pngWhite2x2px, 12240, 765, 16, 100.0d, ResizeOption.Resize)] + public void ShouldVerifyThatImageStreamsAreSemiEqual(string pathPic1, string pathPic2, int expectedAbsoluteError, double expectedMeanError, int expectedPixelErrorCount, double expectedPixelErrorPercentage, ResizeOption resizeOption) + { + var absolutePathPic1 = Path.Combine(AppContext.BaseDirectory, pathPic1); + var absolutePathPic2 = Path.Combine(AppContext.BaseDirectory, pathPic2); + + using var pic1 = new FileStream(absolutePathPic1, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var pic2 = new FileStream(absolutePathPic2, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + var diff = Compare.CalcDiff(pic1, pic2, resizeOption); + Assert.That(diff.AbsoluteError, Is.EqualTo(expectedAbsoluteError), "AbsoluteError"); + Assert.That(diff.MeanError, Is.EqualTo(expectedMeanError), "MeanError"); + Assert.That(diff.PixelErrorCount, Is.EqualTo(expectedPixelErrorCount), "PixelErrorCount"); + Assert.That(diff.PixelErrorPercentage, Is.EqualTo(expectedPixelErrorPercentage), "PixelErrorPercentage"); + } + + [TestCase(TestFiles.png0Rgba32, TestFiles.png1Rgba32, 0, 0, 0, 0, null)] + [TestCase(TestFiles.png0Rgba32, TestFiles.png1Rgba32, 0, 0, 0, 0, ResizeOption.DontResize)] + [TestCase(TestFiles.png0Rgba32, TestFiles.png1Rgba32, 0, 0, 0, 0, ResizeOption.Resize)] + [TestCase(TestFiles.pngWhite2x2px, TestFiles.pngBlack4x4px, 0, 0, 0, 0, ResizeOption.Resize)] + [TestCase(TestFiles.pngBlack4x4px, TestFiles.pngWhite2x2px, 0, 0, 0, 0, ResizeOption.Resize)] + [TestCase(TestFiles.renderedForm1, TestFiles.renderedForm2, 0, 0, 0, 0, ResizeOption.Resize)] + [TestCase(TestFiles.renderedForm2, TestFiles.renderedForm1, 0, 0, 0, 0, ResizeOption.Resize)] + public void DiffMask(string pathPic1, string pathPic2, int expectedMeanError, int expectedAbsoluteError, int expectedPixelErrorCount, double expectedPixelErrorPercentage, ResizeOption resizeOption) + { + var absolutePathPic1 = Path.Combine(AppContext.BaseDirectory, pathPic1); + var absolutePathPic2 = Path.Combine(AppContext.BaseDirectory, pathPic2); + var differenceMask = Path.GetTempFileName() + "differenceMask.png"; + + using (var fileStreamDifferenceMask = File.Create(differenceMask)) + using (var maskImage = Compare.CalcDiffMaskImage(absolutePathPic1, absolutePathPic2, resizeOption)) + { + SaveAsPng(maskImage, fileStreamDifferenceMask); + } + + var maskedDiff = Compare.CalcDiff(absolutePathPic1, absolutePathPic2, differenceMask, resizeOption); + File.Delete(differenceMask); + + Assert.That(maskedDiff.AbsoluteError, Is.EqualTo(expectedAbsoluteError), "AbsoluteError"); + Assert.That(maskedDiff.MeanError, Is.EqualTo(expectedMeanError), "MeanError"); + Assert.That(maskedDiff.PixelErrorCount, Is.EqualTo(expectedPixelErrorCount), "PixelErrorCount"); + Assert.That(maskedDiff.PixelErrorPercentage, Is.EqualTo(expectedPixelErrorPercentage), "PixelErrorPercentage"); + } + + private static void SaveAsPng(SKBitmap maskImage, FileStream fileStreamDifferenceMask) + { + var encodedData = maskImage.Encode(SKEncodedImageFormat.Png, 100); + encodedData.SaveTo(fileStreamDifferenceMask); + } + + [TestCase(TestFiles.pngBlack2x2px, TestFiles.pngBlack2x2px, ResizeOption.Resize)] + [TestCase(TestFiles.pngBlack2x2px, TestFiles.pngBlack2x2px, ResizeOption.DontResize)] + [TestCase(TestFiles.pngBlack2x2px, TestFiles.pngBlack4x4px, ResizeOption.Resize)] + public void ShouldCalcDiffMaskSKBitmap(string pathPic1, string pathPic2, ResizeOption resizeOption) + { + var absolutePathPic1 = Path.Combine(AppContext.BaseDirectory, pathPic1); + var absolutePathPic2 = Path.Combine(AppContext.BaseDirectory, pathPic2); + + using var absolutePic1 = SKBitmap.Decode(absolutePathPic1); + using var absolutePic2 = SKBitmap.Decode(absolutePathPic2); + using var maskImage = Compare.CalcDiffMaskImage(absolutePic1, absolutePic2, resizeOption); + Assert.That(ImageExtensions.IsImageEntirelyBlack(maskImage, TransparencyOptions.IgnoreAlphaChannel)); + } + + [TestCase(TestFiles.png0Rgba32, TestFiles.png1Rgba32, 0, 0, 0, 0, ResizeOption.DontResize)] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.jpg1Rgb24, 0, 0, 0, 0, ResizeOption.DontResize)] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.jpg1Rgb24, 0, 0, 0, 0, ResizeOption.Resize)] + [TestCase(TestFiles.pngBlack2x2px, TestFiles.pngBlack4x4px, 0, 0, 0, 0, ResizeOption.Resize)] + public void ShouldCalcDiffMaskSKBitmapAndUseOutcome(string pathPic1, string pathPic2, int expectedMeanError, int expectedAbsoluteError, int expectedPixelErrorCount, double expectedPixelErrorPercentage, ResizeOption resizeOption) + { + var absolutePathPic1 = Path.Combine(AppContext.BaseDirectory, pathPic1); + var absolutePathPic2 = Path.Combine(AppContext.BaseDirectory, pathPic2); + var differenceMaskPicPath = Path.GetTempFileName() + "differenceMask.png"; + + using var absolutePic1 = SKBitmap.Decode(absolutePathPic1); + using var absolutePic2 = SKBitmap.Decode(absolutePathPic2); + + using (var fileStreamDifferenceMask = File.Create(differenceMaskPicPath)) + using (var maskImage = Compare.CalcDiffMaskImage(absolutePic1, absolutePic2, resizeOption)) + { + SaveAsPng(maskImage, fileStreamDifferenceMask); + } + + using var differenceMaskPic = SKBitmap.Decode(differenceMaskPicPath); + var maskedDiff = Compare.CalcDiff(absolutePic1, absolutePic2, differenceMaskPic, resizeOption); + File.Delete(differenceMaskPicPath); + + Assert.That(maskedDiff.AbsoluteError, Is.EqualTo(expectedAbsoluteError), "AbsoluteError"); + Assert.That(maskedDiff.MeanError, Is.EqualTo(expectedMeanError), "MeanError"); + Assert.That(maskedDiff.PixelErrorCount, Is.EqualTo(expectedPixelErrorCount), "PixelErrorCount"); + Assert.That(maskedDiff.PixelErrorPercentage, Is.EqualTo(expectedPixelErrorPercentage), "PixelErrorPercentage"); + } + + [TestCase(TestFiles.pngWhite2x2px, TestFiles.pngBlack2x2px, TestFiles.pngTransparent4x4px, 765, 12240, 16, 100d, ResizeOption.Resize, 0)] + [TestCase(TestFiles.pngWhite2x2px, TestFiles.pngBlack2x2px, TestFiles.pngBlack4x4px, 765, 12240, 16, 100d, ResizeOption.Resize, 0)] + [TestCase(TestFiles.pngBlack2x2px, TestFiles.pngBlack2x2px, TestFiles.pngBlack4x4px, 0, 0, 0, 0, ResizeOption.Resize, 0)] + [TestCase(TestFiles.pngBlack2x2px, TestFiles.pngBlack4x4px, TestFiles.pngBlack2x2px, 0, 0, 0, 0, ResizeOption.Resize, 0)] + [TestCase(TestFiles.pngBlack4x4px, TestFiles.pngBlack2x2px, TestFiles.pngBlack2x2px, 0, 0, 0, 0, ResizeOption.Resize, 0)] + [TestCase(TestFiles.colorShift1, TestFiles.colorShift1, TestFiles.pngBlack2x2px, 0, 0, 0, 0, ResizeOption.Resize, 0)] + [TestCase(TestFiles.colorShift1, TestFiles.colorShift2, TestFiles.pngBlack2x2px, 0, 0, 0, 0, ResizeOption.Resize, 20)] + public void ShouldUseDiffMask(string pathPic1, string pathPic2, string pathPic3, double expectedMeanError, int expectedAbsoluteError, int expectedPixelErrorCount, double expectedPixelErrorPercentage, ResizeOption resizeOption, int colorShiftTolerance) + { + var absolutePathPic1 = Path.Combine(AppContext.BaseDirectory, pathPic1); + var absolutePathPic2 = Path.Combine(AppContext.BaseDirectory, pathPic2); + var differenceMaskPic = Path.Combine(AppContext.BaseDirectory, pathPic3); + using var pic1 = SKBitmap.Decode(absolutePathPic1); + using var pic2 = SKBitmap.Decode(absolutePathPic2); + using var maskPic = SKBitmap.Decode(differenceMaskPic); + + var maskedDiff = Compare.CalcDiff(pic1, pic2, maskPic, resizeOption, colorShiftTolerance); + + Assert.That(maskedDiff.MeanError, Is.EqualTo(expectedMeanError), "MeanError"); + Assert.That(maskedDiff.AbsoluteError, Is.EqualTo(expectedAbsoluteError), "AbsoluteError"); + Assert.That(maskedDiff.PixelErrorCount, Is.EqualTo(expectedPixelErrorCount), "PixelErrorCount"); + Assert.That(maskedDiff.PixelErrorPercentage, Is.EqualTo(expectedPixelErrorPercentage), "PixelErrorPercentage"); + } + + [TestCase(TestFiles.pngBlack2x2px, TestFiles.pngBlack2x2px, TestFiles.pngBlack4x4px)] + [TestCase(TestFiles.pngBlack2x2px, TestFiles.pngBlack4x4px, TestFiles.pngBlack2x2px)] + [TestCase(TestFiles.pngBlack4x4px, TestFiles.pngBlack2x2px, TestFiles.pngBlack2x2px)] + public void ShouldThrowUsingInvalidImageDimensionsDiffMask(string pathPic1, string pathPic2, string pathPic3) + { + var absolutePathPic1 = Path.Combine(AppContext.BaseDirectory, pathPic1); + var absolutePathPic2 = Path.Combine(AppContext.BaseDirectory, pathPic2); + var differenceMaskPic = Path.Combine(AppContext.BaseDirectory, pathPic3); + using var pic1 = SKBitmap.Decode(absolutePathPic1); + using var pic2 = SKBitmap.Decode(absolutePathPic2); + using var maskPic = SKBitmap.Decode(differenceMaskPic); + Action action = () => Compare.CalcDiff(pic1, pic2, maskPic, ResizeOption.DontResize); + + var exception = Assert.Throws(action); + + Assert.That(exception?.Message, Is.EqualTo("Size of images differ.")); + } + + [TestCase(TestFiles.png0Rgba32, TestFiles.png1Rgba32, 0, 0, 0, 0)] + public void DiffMaskStreams(string pathPic1, string pathPic2, int expectedMeanError, int expectedAbsoluteError, int expectedPixelErrorCount, double expectedPixelErrorPercentage) + { + var absolutePathPic1 = Path.Combine(AppContext.BaseDirectory, pathPic1); + var absolutePathPic2 = Path.Combine(AppContext.BaseDirectory, pathPic2); + + using var pic1 = new FileStream(absolutePathPic1, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var pic2 = new FileStream(absolutePathPic2, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + + using var maskImage = Compare.CalcDiffMaskImage(pic1, pic2); + + pic1.Position = 0; + pic2.Position = 0; + + var maskedDiff = Compare.CalcDiff(pic1, pic2, maskImage); + Assert.That(maskedDiff.AbsoluteError, Is.EqualTo(expectedAbsoluteError), "AbsoluteError"); + Assert.That(maskedDiff.MeanError, Is.EqualTo(expectedMeanError), "MeanError"); + Assert.That(maskedDiff.PixelErrorCount, Is.EqualTo(expectedPixelErrorCount), "PixelErrorCount"); + Assert.That(maskedDiff.PixelErrorPercentage, Is.EqualTo(expectedPixelErrorPercentage), "PixelErrorPercentage"); + } + + [TestCase(TestFiles.png0Rgba32, TestFiles.png1Rgba32, 0, false)] + [TestCase(TestFiles.colorShift1, TestFiles.colorShift2, 20, true)] + [TestCase(TestFiles.colorShift1, TestFiles.colorShift2, 0, false)] + public void CalcDiffMaskImage_WhenSupplyingDiffMaskOfTwoImagesByFilePath_NoDifferences(string image1RelativePath, string image2RelativePath, int colorShiftTolerance, bool expectIsImageEntirelyBlack) + { + var image1Path = Path.Combine(AppContext.BaseDirectory, image1RelativePath); + var image2Path = Path.Combine(AppContext.BaseDirectory, image2RelativePath); + var diffMask1Path = Path.GetTempFileName() + "differenceMask.png"; + + using (var diffMask1Stream = File.Create(diffMask1Path)) + { + using var diffMask1Image = Compare.CalcDiffMaskImage(image1Path, image2Path, ResizeOption.DontResize, colorShiftTolerance); + ImageExtensions.SaveAsPng(diffMask1Image, diffMask1Stream); + Assert.That(ImageExtensions.IsImageEntirelyBlack(diffMask1Image, TransparencyOptions.IgnoreAlphaChannel), Is.EqualTo(expectIsImageEntirelyBlack)); + } + + using var diffMask2Image = Compare.CalcDiffMaskImage(image1Path, image2Path, diffMask1Path); + + using (var diffMask2Stream = File.Create(diffMask1Path)) + { + ImageExtensions.SaveAsPng(diffMask2Image, diffMask2Stream); + } + + Assert.That(ImageExtensions.IsImageEntirelyBlack(diffMask2Image, TransparencyOptions.IgnoreAlphaChannel)); + + File.Delete(diffMask1Path); + } + + [TestCase(TestFiles.png0Rgba32, TestFiles.png1Rgba32)] + public void CalcDiffMaskImage_WhenSupplyingDiffMaskOfTwoImagesByStream_NoDifferences(string image1RelativePath, string image2RelativePath) + { + var image1Path = Path.Combine(AppContext.BaseDirectory, image1RelativePath); + var image2Path = Path.Combine(AppContext.BaseDirectory, image2RelativePath); + var diffMask1Path = Path.GetTempFileName() + "differenceMask.png"; + + using var image1Stream = new FileStream(image1Path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var image2Stream = new FileStream(image2Path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + + using (var diffMask1Stream = File.Create(diffMask1Path)) + { + using var diffMask1Image = Compare.CalcDiffMaskImage(image1Stream, image2Stream); + ImageExtensions.SaveAsPng(diffMask1Image, diffMask1Stream); + } + + image1Stream.Position = 0; + image2Stream.Position = 0; + + using (var diffMask1Stream = new FileStream(diffMask1Path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + { + diffMask1Stream.Position = 0; + using var diffMask2Image = Compare.CalcDiffMaskImage(image1Stream, image2Stream, diffMask1Stream); + Assert.That(ImageExtensions.IsImageEntirelyBlack(diffMask2Image, TransparencyOptions.IgnoreAlphaChannel), Is.True); + } + + File.Delete(diffMask1Path); + } + + [TestCase(TestFiles.png0Rgba32, TestFiles.png1Rgba32, TestFiles.png1Rgba32, 0, false)] + [TestCase(TestFiles.colorShift1, TestFiles.colorShift1, TestFiles.colorShift2, 15, true)] + public void CalcDiffMaskImage_WhenSupplyingDiffMaskOfTwoImagesByImage_NoDifferences(string image1RelativePath, string image2RelativePath, string image3RelativePath, int colorShiftTolerance, bool expectToleranceMaskToEntirelyBlack) + { + var image1Path = Path.Combine(AppContext.BaseDirectory, image1RelativePath); + var image2Path = Path.Combine(AppContext.BaseDirectory, image2RelativePath); + var image3Path = Path.Combine(AppContext.BaseDirectory, image3RelativePath); + + using var image1 = SKBitmap.Decode(image1Path); + using var image2 = SKBitmap.Decode(image2Path); + using var image3 = SKBitmap.Decode(image3Path); + + using var diffMask1Image = Compare.CalcDiffMaskImage(image1, image2); + + using var diffMask2Image = Compare.CalcDiffMaskImage(image1, image3, diffMask1Image, pixelColorShiftTolerance: colorShiftTolerance); + + Assert.That(ImageExtensions.IsImageEntirelyBlack(diffMask1Image, TransparencyOptions.IgnoreAlphaChannel), Is.EqualTo(expectToleranceMaskToEntirelyBlack)); + Assert.That(ImageExtensions.IsImageEntirelyBlack(diffMask2Image, TransparencyOptions.IgnoreAlphaChannel), Is.True); + } + + [TestCase(TestFiles.png0Rgba32, TestFiles.png0Rgba32, 0)] + [TestCase(TestFiles.png0Rgba32, TestFiles.png0Rgba32, 15)] + [TestCase(TestFiles.colorShift1, TestFiles.colorShift2, 15)] + public void CalcDiffMaskImage_ToleranceColorShift_NoDifferences(string image1RelativePath, string image2RelativePath, int colorShiftTolerance) + { + var image1Path = Path.Combine(AppContext.BaseDirectory, image1RelativePath); + var image2Path = Path.Combine(AppContext.BaseDirectory, image2RelativePath); + + using var image1 = SKBitmap.Decode(image1Path); + using var image2 = SKBitmap.Decode(image2Path); + + using var diffMask1Image = Compare.CalcDiffMaskImage(image1, image2, pixelColorShiftTolerance: colorShiftTolerance); + + Assert.That(ImageExtensions.IsImageEntirelyBlack(diffMask1Image, TransparencyOptions.IgnoreAlphaChannel), Is.True); + } + + [Test] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.jpg1Rgb24)] + [TestCase(TestFiles.png0Rgba32, TestFiles.png1Rgba32)] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.png1Rgba32)] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.png0Rgba32)] + [TestCase(TestFiles.jpg1Rgb24, TestFiles.png1Rgba32)] + [TestCase(TestFiles.colorShift1, TestFiles.colorShift2)] + public void ShouldVerifyThatImagesAreNotEqual(string pathActual, string pathExpected) + { + var absolutePathActual = Path.Combine(AppContext.BaseDirectory, pathActual); + var absolutePathExpected = Path.Combine(AppContext.BaseDirectory, pathExpected); + + Assert.That(Compare.ImagesAreEqual(absolutePathActual, absolutePathExpected), Is.False); + } + + [Test] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.jpg1Rgb24)] + [TestCase(TestFiles.png0Rgba32, TestFiles.png1Rgba32)] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.png1Rgba32)] + [TestCase(TestFiles.jpg0Rgb24, TestFiles.png0Rgba32)] + [TestCase(TestFiles.jpg1Rgb24, TestFiles.png1Rgba32)] + [TestCase(TestFiles.colorShift1, TestFiles.colorShift2)] + public void ShouldVerifyThatImageStreamAreNotEqual(string pathActual, string pathExpected) + { + var absolutePathActual = Path.Combine(AppContext.BaseDirectory, pathActual); + var absolutePathExpected = Path.Combine(AppContext.BaseDirectory, pathExpected); + + using var actual = new FileStream(absolutePathActual, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var expected = new FileStream(absolutePathExpected, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + + Assert.That(Compare.ImagesAreEqual(actual, expected), Is.False); + } + + [TestCase(TestFiles.png0Rgba32, TestFiles.pngBlack2x2px)] + public void ShouldVerifyThatImageWithDifferentSizeThrows(string pathPic1, string pathPic2) + { + var absolutePathPic1 = Path.Combine(AppContext.BaseDirectory, pathPic1); + var absolutePathPic2 = Path.Combine(AppContext.BaseDirectory, pathPic2); + Action action = () => Compare.CalcDiff(absolutePathPic1, absolutePathPic2); + + var exception = Assert.Throws(action); + + Assert.That(exception?.Message, Is.EqualTo("Size of images differ.")); + } + + [TestCase(TestFiles.png0Rgba32, TestFiles.png0Rgba32, TestFiles.pngBlack2x2px)] + [TestCase(TestFiles.png0Rgba32, TestFiles.pngBlack2x2px, TestFiles.png0Rgba32)] + [TestCase(TestFiles.pngBlack2x2px, TestFiles.png0Rgba32, TestFiles.png0Rgba32)] + public void ShouldVerifyThatImageWithDifferentSizeThrows(string pathPic1, string pathPic2, string pathPic3) + { + var absolutePathPic1 = Path.Combine(AppContext.BaseDirectory, pathPic1); + var absolutePathPic2 = Path.Combine(AppContext.BaseDirectory, pathPic2); + var absolutePathPic3 = Path.Combine(AppContext.BaseDirectory, pathPic3); + Action action = () => Compare.CalcDiff(absolutePathPic1, absolutePathPic2, absolutePathPic3); + + var exception = Assert.Throws(action); + + Assert.That(exception?.Message, Is.EqualTo("Size of images differ.")); + } + } +} \ No newline at end of file diff --git a/SkiaSharpCompareTestNunit/TestData/BlackDoubleSize.png b/SkiaSharpCompareTestNunit/TestData/BlackDoubleSize.png new file mode 100644 index 0000000..9a5886b Binary files /dev/null and b/SkiaSharpCompareTestNunit/TestData/BlackDoubleSize.png differ diff --git a/SkiaSharpCompareTestNunit/TestData/ColorShift1.png b/SkiaSharpCompareTestNunit/TestData/ColorShift1.png new file mode 100644 index 0000000..fa349b8 Binary files /dev/null and b/SkiaSharpCompareTestNunit/TestData/ColorShift1.png differ diff --git a/SkiaSharpCompareTestNunit/TestData/ColorShift2.png b/SkiaSharpCompareTestNunit/TestData/ColorShift2.png new file mode 100644 index 0000000..7cfe42a Binary files /dev/null and b/SkiaSharpCompareTestNunit/TestData/ColorShift2.png differ diff --git a/SkiaSharpCompareTestNunit/TestData/HC007-Test-02-3-OxPt.html1.png b/SkiaSharpCompareTestNunit/TestData/HC007-Test-02-3-OxPt.html1.png new file mode 100644 index 0000000..b860ccd Binary files /dev/null and b/SkiaSharpCompareTestNunit/TestData/HC007-Test-02-3-OxPt.html1.png differ diff --git a/SkiaSharpCompareTestNunit/TestData/HC007-Test-02-3-OxPt.html2.png b/SkiaSharpCompareTestNunit/TestData/HC007-Test-02-3-OxPt.html2.png new file mode 100644 index 0000000..98ac29e Binary files /dev/null and b/SkiaSharpCompareTestNunit/TestData/HC007-Test-02-3-OxPt.html2.png differ diff --git a/SkiaSharpCompareTestNunit/TestData/createToleranceMaskFromDiff.png b/SkiaSharpCompareTestNunit/TestData/createToleranceMaskFromDiff.png new file mode 100644 index 0000000..24ab8f4 Binary files /dev/null and b/SkiaSharpCompareTestNunit/TestData/createToleranceMaskFromDiff.png differ diff --git a/SkiaSharpCompareTestNunit/TestData/imageWithGpsMetadata.jpg b/SkiaSharpCompareTestNunit/TestData/imageWithGpsMetadata.jpg new file mode 100644 index 0000000..4e4c0ff Binary files /dev/null and b/SkiaSharpCompareTestNunit/TestData/imageWithGpsMetadata.jpg differ diff --git a/SkiaSharpCompareTestNunit/TestData/imageWithoutGpsMetadata.jpg b/SkiaSharpCompareTestNunit/TestData/imageWithoutGpsMetadata.jpg new file mode 100644 index 0000000..1313728 Binary files /dev/null and b/SkiaSharpCompareTestNunit/TestData/imageWithoutGpsMetadata.jpg differ diff --git a/SkiaSharpCompareTestNunit/TestData/pngPartialTransparent4x4px.png b/SkiaSharpCompareTestNunit/TestData/pngPartialTransparent4x4px.png new file mode 100644 index 0000000..fe044ba Binary files /dev/null and b/SkiaSharpCompareTestNunit/TestData/pngPartialTransparent4x4px.png differ diff --git a/SkiaSharpCompareTestNunit/TestData/pngTransparent4x4px.png b/SkiaSharpCompareTestNunit/TestData/pngTransparent4x4px.png new file mode 100644 index 0000000..c83cef5 Binary files /dev/null and b/SkiaSharpCompareTestNunit/TestData/pngTransparent4x4px.png differ diff --git a/SkiaSharpCompareTestNunit/TestFiles.cs b/SkiaSharpCompareTestNunit/TestFiles.cs new file mode 100644 index 0000000..b6b05dc --- /dev/null +++ b/SkiaSharpCompareTestNunit/TestFiles.cs @@ -0,0 +1,25 @@ +namespace SkiaSharpCompareTestNunit +{ + internal static class TestFiles + { + public const string jpg0Rgb24 = "./../../../TestData/Calc0.jpg"; + public const string jpg1Rgb24 = "./../../../TestData/Calc1.jpg"; + public const string png0Rgba32 = "./../../../TestData/Calc0.png"; + public const string png1Rgba32 = "./../../../TestData/Calc1.png"; + public const string pngBlack2x2px = "./../../../TestData/Black.png"; + public const string pngBlack4x4px = "./../../../TestData/BlackDoubleSize.png"; + public const string pngWhite2x2px = "./../../../TestData/White.png"; + public const string pngTransparent4x4px = "./../../../TestData/pngTransparent4x4px.png"; + public const string pngPartialTransparent4x4px = "./../../../TestData/pngPartialTransparent4x4px.png"; + public const string renderedForm1 = "./../../../TestData/HC007-Test-02-3-OxPt.html1.png"; + public const string renderedForm2 = "./../../../TestData/HC007-Test-02-3-OxPt.html2.png"; + public const string colorShift1 = "./../../../TestData/ColorShift1.png"; + public const string colorShift2 = "./../../../TestData/ColorShift2.png"; + + // These changes are related to https://github.com/nextcloud/android/issues/6248#issuecomment-1207315271 + public const string imageWithoutGpsMetadata = "./../../../TestData/imageWithoutGpsMetadata.jpg"; + + public const string imageWithGpsMetadata = "./../../../TestData/imageWithGpsMetadata.jpg"; + public const string createToleranceMaskFromDiff = "./../../../TestData/createToleranceMaskFromDiff.png"; + } +} \ No newline at end of file diff --git a/exclusion.dic b/exclusion.dic new file mode 100644 index 0000000..b510bd0 --- /dev/null +++ b/exclusion.dic @@ -0,0 +1,3 @@ +Skia +Codeuctivity +Calc