From 0520ab33b99bc77bdbd115dc085dc48f50edc5ca Mon Sep 17 00:00:00 2001
From: winscripter <142818255+winscripter@users.noreply.github.com>
Date: Tue, 8 Sep 2026 23:59:20 +0400
Subject: [PATCH] Add encoder image comparators
---
.../Comparator/ButteraugliJxlComparator.cs | 83 +++++++++
.../Encoder/Comparator/JxlComparator.cs | 27 +++
.../JxlComparatorScoreComputation.cs | 175 ++++++++++++++++++
.../Encoder/Comparator/NullComparator.cs | 23 +++
4 files changed, 308 insertions(+)
create mode 100644 src/ImageSharp/Formats/Jxl/Processing/Encoder/Comparator/ButteraugliJxlComparator.cs
create mode 100644 src/ImageSharp/Formats/Jxl/Processing/Encoder/Comparator/JxlComparator.cs
create mode 100644 src/ImageSharp/Formats/Jxl/Processing/Encoder/Comparator/JxlComparatorScoreComputation.cs
create mode 100644 src/ImageSharp/Formats/Jxl/Processing/Encoder/Comparator/NullComparator.cs
diff --git a/src/ImageSharp/Formats/Jxl/Processing/Encoder/Comparator/ButteraugliJxlComparator.cs b/src/ImageSharp/Formats/Jxl/Processing/Encoder/Comparator/ButteraugliJxlComparator.cs
new file mode 100644
index 0000000000..919446bfca
--- /dev/null
+++ b/src/ImageSharp/Formats/Jxl/Processing/Encoder/Comparator/ButteraugliJxlComparator.cs
@@ -0,0 +1,83 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Numerics.Tensors;
+using SixLabors.ImageSharp.Formats.Jxl.Cms;
+using SixLabors.ImageSharp.Formats.Jxl.IO.Metadata;
+using SixLabors.ImageSharp.Formats.Jxl.Memory.ImageTypes;
+using SixLabors.ImageSharp.Formats.Jxl.Processing.Butteraugli;
+using SixLabors.ImageSharp.Formats.Jxl.Processing.Image;
+
+namespace SixLabors.ImageSharp.Formats.Jxl.Processing.Encoder.Comparator;
+
+///
+/// Compares images using Google Butteraugli.
+///
+internal sealed class ButteraugliJxlComparator(ButteraugliParameters parameters, JxlCmsInterface cms)
+ : JxlComparator
+{
+ private int width;
+ private int height;
+ private float intensityTarget;
+ private ButteraugliComparator? comparator;
+
+ public override float GoodQualityScore => Butteraugli.Butteraugli.ButteraugliFuzzyInverse(1.5f);
+
+ public override float BadQualityScore => Butteraugli.Butteraugli.ButteraugliFuzzyInverse(0.5f);
+
+ public override void CompareWith(Configuration configuration, JxlImageBundle actual, JxlImageF? diffMap, out float score)
+ {
+ DebugGuard.NotNull(this.comparator, nameof(this.comparator));
+
+ if (this.width != actual.XSize || this.height != actual.YSize)
+ {
+ throw new InvalidOperationException("Image must have same size");
+ }
+
+ JxlImageMetadata metadata = actual.Metadata!;
+ JxlImageBundle store = new(metadata);
+
+ JxlTransformsEncoder.TransformIfNeeded(configuration, actual, JxlColorEncoding.LinearSRgb(actual.IsGray), cms, store, out JxlImageBundle actualLinearSRgb);
+
+ JxlImageF tempDiffmap = new(configuration, this.width, this.height);
+ JxlImage3F scaledActualLinearSRgb = actualLinearSRgb.Color!;
+
+ if (this.intensityTarget != 0 && actual.Metadata!.IntensityTarget != this.intensityTarget)
+ {
+ JxlImage3F scaledActualLinearSRgbStore = new(configuration, this.width, this.height);
+ scaledActualLinearSRgb = scaledActualLinearSRgbStore;
+
+ float scale = actual.Metadata!.IntensityTarget / this.intensityTarget;
+ for (int c = 0; c < 3; ++c)
+ {
+ for (int y = 0; y < this.height; ++y)
+ {
+ Span souceRow = actualLinearSRgb.Color!.PlaneRow(c, y);
+ Span scaledRow = scaledActualLinearSRgb.PlaneRow(c, y);
+ TensorPrimitives.Multiply(souceRow, scale, scaledRow);
+ }
+ }
+ }
+
+ if (!this.comparator.Diffmap(configuration, scaledActualLinearSRgb, tempDiffmap))
+ {
+ throw new InvalidOperationException("Diffmap failed");
+ }
+
+ score = Butteraugli.Butteraugli.ButteraugliScoreFromDiffmap(tempDiffmap, parameters);
+ diffMap?.Swap(tempDiffmap);
+ }
+
+ public override void SetReferenceImage(Configuration configuration, JxlImageBundle reference)
+ {
+ JxlImageMetadata metadata = reference.Metadata!;
+ JxlImageBundle store = new(metadata);
+
+ JxlTransformsEncoder.TransformIfNeeded(configuration, reference, JxlColorEncoding.LinearSRgb(reference.IsGray), cms, store, out JxlImageBundle refLinearSRgb);
+ this.comparator = ButteraugliComparator.Make(configuration, refLinearSRgb.Color!, parameters);
+
+ this.width = reference.XSize;
+ this.height = reference.YSize;
+ this.intensityTarget = reference.Metadata!.IntensityTarget;
+ }
+}
diff --git a/src/ImageSharp/Formats/Jxl/Processing/Encoder/Comparator/JxlComparator.cs b/src/ImageSharp/Formats/Jxl/Processing/Encoder/Comparator/JxlComparator.cs
new file mode 100644
index 0000000000..920e929099
--- /dev/null
+++ b/src/ImageSharp/Formats/Jxl/Processing/Encoder/Comparator/JxlComparator.cs
@@ -0,0 +1,27 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using SixLabors.ImageSharp.Formats.Jxl.Memory.ImageTypes;
+using SixLabors.ImageSharp.Formats.Jxl.Processing.Image;
+
+namespace SixLabors.ImageSharp.Formats.Jxl.Processing.Encoder.Comparator;
+
+///
+/// Compares images.
+///
+internal abstract class JxlComparator
+{
+ public abstract float GoodQualityScore { get; }
+
+ public abstract float BadQualityScore { get; }
+
+ // Sets the reference image, the first to compare.
+ public abstract void SetReferenceImage(Configuration configuration, JxlImageBundle reference);
+
+ // Sets the actual image, the second to compare.
+ public abstract void CompareWith(
+ Configuration configuration,
+ JxlImageBundle actual,
+ JxlImageF? diffMap,
+ out float score);
+}
diff --git a/src/ImageSharp/Formats/Jxl/Processing/Encoder/Comparator/JxlComparatorScoreComputation.cs b/src/ImageSharp/Formats/Jxl/Processing/Encoder/Comparator/JxlComparatorScoreComputation.cs
new file mode 100644
index 0000000000..8dec22e152
--- /dev/null
+++ b/src/ImageSharp/Formats/Jxl/Processing/Encoder/Comparator/JxlComparatorScoreComputation.cs
@@ -0,0 +1,175 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Numerics.Tensors;
+using SixLabors.ImageSharp.Formats.Jxl.Cms;
+using SixLabors.ImageSharp.Formats.Jxl.IO.Metadata;
+using SixLabors.ImageSharp.Formats.Jxl.Memory.ImageTypes;
+using SixLabors.ImageSharp.Formats.Jxl.Processing.Image;
+
+namespace SixLabors.ImageSharp.Formats.Jxl.Processing.Encoder.Comparator;
+
+internal static class JxlComparatorScoreComputation
+{
+ private static void AlphaBlend(JxlImage3F input, int c, float backgroundLinear, JxlImageF alpha, JxlImage3F output)
+ {
+ float background = (float)JxlGammaCorrect.LinearToSRgb8Direct(backgroundLinear);
+
+ for (int y = 0; y < output.YSize; y++)
+ {
+ ReadOnlySpan rowA = alpha.GetRow(y);
+ ReadOnlySpan rowI = input.PlaneRow(c, y);
+ Span rowO = output.PlaneRow(c, y);
+
+ for (int x = 0; x < output.XSize; x++)
+ {
+ float a = rowA[x];
+
+ if (a <= 0.0f)
+ {
+ rowO[x] = backgroundLinear;
+ }
+ else if (a >= 1.0f)
+ {
+ rowO[x] = rowI[x];
+ }
+ else
+ {
+ float wFg = a;
+ float wBg = 1.0f - wFg;
+ float fg = wFg * (float)JxlGammaCorrect.LinearToSRgb8Direct(rowI[x]);
+ float bg = wBg * background;
+
+ rowO[x] = (float)JxlGammaCorrect.SRgb8ToLinearDirect(fg + bg);
+ }
+ }
+ }
+ }
+
+ private static void AlphaBlend(float backgroundLinear, JxlImageBundle image)
+ {
+ if (!image.ContainsAlpha)
+ {
+ return;
+ }
+
+ for (int c = 0; c < 3; c++)
+ {
+ AlphaBlend(image.Color!, c, backgroundLinear, image.Alpha!, image.Color!);
+ }
+ }
+
+ private static void ComputeScoreImpl(Configuration configuration, JxlImageBundle rgb0, JxlImageBundle rgb1, JxlComparator comparator, JxlImageF? diffMap, out float score)
+ {
+ comparator.SetReferenceImage(rgb0);
+ comparator.CompareWith(configuration, rgb1, diffMap, out score);
+ }
+
+ public static void ComputeScore(
+ Configuration configuration,
+ JxlImageBundle rgb0,
+ JxlImageBundle rgb1,
+ JxlComparator comparator,
+ JxlCmsInterface cms,
+ out float score,
+ JxlImageF? diffMap = null,
+ bool ignoreAlpha = false)
+ {
+ JxlImageMetadata metadata0 = rgb0.Metadata.Copy();
+
+ JxlImageBundle store0 = new(metadata0);
+
+ JxlTransformsEncoder.TransformIfNeeded(
+ rgb0,
+ JxlColorEncoding.LinearSRgb(rgb0.IsGray),
+ cms,
+ pool,
+ store0,
+ out JxlImageBundle linearSrgb0);
+
+ JxlImageMetadata metadata1 = rgb1.Metadata.Copy();
+
+ JxlImageBundle store1 = new(metadata1);
+
+ JxlTransformsEncoder.TransformIfNeeded(
+ rgb1,
+ JxlColorEncoding.LinearSRgb(rgb1.IsGray),
+ cms,
+ pool,
+ store1,
+ out JxlImageBundle linearSrgb1);
+
+ if (ignoreAlpha || (!rgb0.ContainsAlpha && !rgb1.ContainsAlpha))
+ {
+ ComputeScoreImpl(
+ configuration,
+ linearSrgb0,
+ linearSrgb1,
+ comparator,
+ diffMap,
+ out score);
+ }
+
+ JxlImageF diffMapBlack = new();
+ float distBlack;
+ {
+ const float black = 0.0f;
+
+ JxlImageBundle blendedBlack0 = linearSrgb0.Copy(configuration);
+ JxlImageBundle blendedBlack1 = linearSrgb1.Copy(configuration);
+
+ AlphaBlend(black, blendedBlack0);
+ AlphaBlend(black, blendedBlack1);
+
+ ComputeScoreImpl(
+ configuration,
+ blendedBlack0,
+ blendedBlack1,
+ comparator,
+ diffMapBlack,
+ out distBlack);
+ }
+
+ JxlImageF diffMapWhite = new();
+ float distWhite;
+ {
+ const float white = 1.0f;
+
+ JxlImageBundle blendedWhite0 = linearSrgb0.Copy(configuration);
+ JxlImageBundle blendedWhite1 = linearSrgb1.Copy(configuration);
+
+ AlphaBlend(white, blendedWhite0);
+ AlphaBlend(white, blendedWhite1);
+
+ ComputeScoreImpl(
+ configuration,
+ blendedWhite0,
+ blendedWhite1,
+ comparator,
+ diffMapWhite,
+ out distWhite);
+ }
+
+ if (diffMap is not null)
+ {
+ int xSize = rgb0.XSize;
+ int ySize = rgb0.YSize;
+
+ JxlImageF outputDiffMap = new(
+ configuration,
+ xSize,
+ ySize);
+
+ for (int y = 0; y < ySize; y++)
+ {
+ ReadOnlySpan rowBlack = diffMapBlack.GetRow(y);
+ ReadOnlySpan rowWhite = diffMapWhite.GetRow(y);
+ Span rowOut = diffMap.GetRow(y);
+
+ TensorPrimitives.Max(rowBlack, rowWhite, rowOut);
+ }
+ }
+
+ score = MathF.Max(distBlack, distWhite);
+ }
+}
diff --git a/src/ImageSharp/Formats/Jxl/Processing/Encoder/Comparator/NullComparator.cs b/src/ImageSharp/Formats/Jxl/Processing/Encoder/Comparator/NullComparator.cs
new file mode 100644
index 0000000000..810c4c59a0
--- /dev/null
+++ b/src/ImageSharp/Formats/Jxl/Processing/Encoder/Comparator/NullComparator.cs
@@ -0,0 +1,23 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using SixLabors.ImageSharp.Formats.Jxl.Memory.ImageTypes;
+using SixLabors.ImageSharp.Formats.Jxl.Processing.Image;
+
+namespace SixLabors.ImageSharp.Formats.Jxl.Processing.Encoder.Comparator;
+
+///
+/// Performs no image comparison at all.
+///
+internal sealed class NullComparator : JxlComparator
+{
+ public override float GoodQualityScore => 0;
+
+ public override float BadQualityScore => 0;
+
+ public override void CompareWith(Configuration configuration, JxlImageBundle actual, JxlImageF? diffMap, out float score) => score = 0;
+
+ public override void SetReferenceImage(Configuration configuration, JxlImageBundle reference)
+ {
+ }
+}