From e1ef376d800633c73146c9ff5bf3d26eb141e920 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Thu, 17 Sep 2026 22:36:31 +1000 Subject: [PATCH] Fix EXR encoder to write scaled values The EXR decoder maps file values to scaled values, but the encoder read native values. Pixel formats whose native range is not [0, 1], such as HalfVector4, wrote wrong color and alpha values to the file. --- src/ImageSharp/Formats/Exr/ExrEncoderCore.cs | 14 +++++++---- .../Formats/Exr/ExrEncoderTests.cs | 25 +++++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/ImageSharp/Formats/Exr/ExrEncoderCore.cs b/src/ImageSharp/Formats/Exr/ExrEncoderCore.cs index 5f91d1bd39..c556973b9f 100644 --- a/src/ImageSharp/Formats/Exr/ExrEncoderCore.cs +++ b/src/ImageSharp/Formats/Exr/ExrEncoderCore.cs @@ -206,9 +206,11 @@ private ulong[] EncodeFloatingPointPixelData( Span pixelRowSpan = pixels.DangerousGetRowSpan((int)rowIndex); for (int x = 0; x < width; x++) { - // OpenEXR stores RGB associated with alpha. Use the native vector domain so floating-point and HDR component - // ranges are preserved instead of being clamped through the scaled [0, 1] representation. - Vector4 vector4 = pixelRowSpan[x].ToAssociatedVector4(); + // OpenEXR stores RGB associated with alpha, and the decoder maps an EXR value of 1 to a scaled value of 1. + // Read the scaled vector so that encoding and decoding agree for every pixel format. The native vector + // is wrong here because its range belongs to the pixel format, not to OpenEXR. For example, HalfVector4 + // stores opaque alpha as the native value 65504, which would be written to the file unchanged. + Vector4 vector4 = pixelRowSpan[x].ToAssociatedScaledVector4(); redBuffer[x] = vector4.X; greenBuffer[x] = vector4.Y; blueBuffer[x] = vector4.Z; @@ -305,8 +307,10 @@ private ulong[] EncodeUnsignedIntPixelData( Span pixelRowSpan = pixels.DangerousGetRowSpan((int)rowIndex); for (int x = 0; x < width; x++) { - // OpenEXR channels use associated alpha; the native vector conversion also preserves the integer channel range. - Vector4 vector4 = pixelRowSpan[x].ToAssociatedVector4(); + // OpenEXR channels use associated alpha. Rgba128.FromVector4 expects components in [0, 1], which is the + // scaled range. The native range of the source pixel format can differ, so read the scaled vector, + // the same domain that the decoder writes. + Vector4 vector4 = pixelRowSpan[x].ToAssociatedScaledVector4(); rgb = Rgba128.FromVector4(vector4); redBuffer[x] = rgb.R; diff --git a/tests/ImageSharp.Tests/Formats/Exr/ExrEncoderTests.cs b/tests/ImageSharp.Tests/Formats/Exr/ExrEncoderTests.cs index 2f507ea51e..9e7cfc6a10 100644 --- a/tests/ImageSharp.Tests/Formats/Exr/ExrEncoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Exr/ExrEncoderTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. +using System.Numerics; using SixLabors.ImageSharp.Formats; using SixLabors.ImageSharp.Formats.Exr; using SixLabors.ImageSharp.Formats.Exr.Constants; @@ -38,6 +39,30 @@ public void EncoderOptions_SetPixelType_Works(ExrPixelType? pixelType, ExrPixelT Assert.Equal(expectedPixelType, exrMetaData.PixelType); } + [Theory] + [InlineData(ExrPixelType.Half)] + [InlineData(ExrPixelType.Float)] + [InlineData(ExrPixelType.UnsignedInt)] + public void Encode_PixelFormatWithNonUnitNativeRange_WritesScaledValues(ExrPixelType pixelType) + { + // arrange + // HalfVector4 stores the scaled range [0, 1] as the native range [-65504, 65504], so its native and scaled + // vectors differ. The alpha of 0.5 makes the test sensitive to the alpha channel too: a wrong alpha value + // larger than 1 would otherwise be hidden by the clamp in the decoder. + Vector4 expected = new(0.25F, 0.5F, 0.75F, 0.5F); + ExrEncoder exrEncoder = new() { PixelType = pixelType }; + using Image input = new(2, 2, HalfVector4.FromScaledVector4(expected)); + using MemoryStream memStream = new(); + + // act + input.Save(memStream, exrEncoder); + + // assert + memStream.Position = 0; + using Image output = Image.Load(memStream); + Assert.Equal(expected, output[0, 0].ToScaledVector4(), new ApproximateFloatComparer(1e-4F)); + } + [Theory] [WithFile(TestImages.Exr.Uncompressed, PixelTypes.Rgba32)] public void ExrEncoder_WithNoCompression_Works(TestImageProvider provider)