diff --git a/HEIF_IMPLEMENTATION_PLAN.md b/HEIF_IMPLEMENTATION_PLAN.md index 894951df93..54f6ddf140 100644 --- a/HEIF_IMPLEMENTATION_PLAN.md +++ b/HEIF_IMPLEMENTATION_PLAN.md @@ -504,6 +504,29 @@ Explicit grid-sampling conversion correction, verified after `5f7bad3a6` on 2026 and warnings; the preceding test compilation reports 1,009 existing warnings. Roslynk reports zero compiler errors. No benchmark or separate-encoder measurement was run. Decoder rejection tests for invalid input grids remain unchanged. +Identity-matrix option correction after `fff06700d`: + +- Unsupported restriction: `HeifEncoderCore.Sequence.cs:129-156` required BT.709/sRGB metadata for every + identity-matrix 4:4:4 encode and otherwise substituted BT.601. Reference + `av1/encoder/bitstream.c:2457-2494` permits other primaries/transfer descriptions with an explicit range + bit. The special BT.709/sRGB branch alone infers full range. Reference validation requires unsubsampled + identity planes (`av1/av1_cx_iface.c:921-928,1005-1013`), not that special color description. +- The production writer already has the correct syntax branches (`ObuWriter.cs:397-446`), and the shared + semantic identity operator already maps G/B/R with the luma range on every plane + (`HeifColorConverter.IdentityOperator.cs:17-139`). Option resolution now preserves valid identity + descriptions and their explicit range; only BT.709/sRGB identity normalizes limited to full range. + Existing conversion for incompatible sampling remains. No converter, buffer, or public API was added. +- Before correction the first public regression failed with expected Identity versus emitted BT.601 + (`identity-profile-red.trx`); VSTest stopped on that failure. After the final edit, Release .NET 11 + built with zero errors/warnings and Roslynk reported zero compiler errors. Serialized Visual Studio + VSTest passed 81/81 HEIF encoder cases in 6.4887 seconds (`identity-profile-final.trx`). +- Fourteen new cases cover BT.2020/PQ full/limited range at 8/10/12 bits, stills/sequences, and special + sRGB range inference. They inspect emitted syntax, container metadata, preserved source metadata, + and every decoded RGB component with a one-unit limit for range conversion. Optimized libaom decoding + matches all 4,032 independently calculated GBR samples exactly: maximum error 0, zero samples exceeding + one (`identity-comparison.json`, temporary and outside the repository). This is bounded conversion and + same-bitstream evidence, not separate-encoder parity or performance evidence. No benchmark ran. + Color-conversion boundary correction after checkpoint `f7bd907d6`, verified on 2026-09-05: - `HeifEncoderCore.Sequence.cs:74-194` resolved output sampling and preserved reversible YCgCo matrix metadata, diff --git a/src/ImageSharp/Formats/Heif/HeifEncoderCore.Sequence.cs b/src/ImageSharp/Formats/Heif/HeifEncoderCore.Sequence.cs index a8a7cbf2e8..22d3ff710b 100644 --- a/src/ImageSharp/Formats/Heif/HeifEncoderCore.Sequence.cs +++ b/src/ImageSharp/Formats/Heif/HeifEncoderCore.Sequence.cs @@ -125,9 +125,7 @@ internal sealed partial class HeifEncoderCore { bool identityMatrix = sourceColorProfile.MatrixCoefficients == CicpMatrixCoefficients.Identity; bool legalIdentityMatrix = !isMonochrome - && chromaSubsampling == HeifChromaSubsampling.Yuv444 - && sourceColorProfile.ColorPrimaries == CicpColorPrimaries.ItuRBt709_6 - && sourceColorProfile.TransferCharacteristics == CicpTransferCharacteristics.Iec61966_2_1; + && chromaSubsampling == HeifChromaSubsampling.Yuv444; bool reversibleMatrix = sourceColorProfile.MatrixCoefficients is CicpMatrixCoefficients.YCgCoRe or CicpMatrixCoefficients.YCgCoRo; if (sourceColorProfile.MatrixCoefficients == CicpMatrixCoefficients.Unspecified @@ -142,8 +140,13 @@ internal sealed partial class HeifEncoderCore (byte)CicpMatrixCoefficients.ItuRBt601_7_525, sourceColorProfile.FullRange); } - else if (identityMatrix && !sourceColorProfile.FullRange) + else if (identityMatrix + && sourceColorProfile.ColorPrimaries == CicpColorPrimaries.ItuRBt709_6 + && sourceColorProfile.TransferCharacteristics == CicpTransferCharacteristics.Iec61966_2_1 + && !sourceColorProfile.FullRange) { + // Only BT.709/sRGB identity omits the range bit and infers full range. Other identity + // descriptions carry that bit explicitly and can preserve limited-range sample conversion. colorProfile = new CicpProfile( (byte)sourceColorProfile.ColorPrimaries, (byte)sourceColorProfile.TransferCharacteristics, diff --git a/tests/ImageSharp.Tests/Formats/Heif/HeifEncoderTests.cs b/tests/ImageSharp.Tests/Formats/Heif/HeifEncoderTests.cs index 0d31452599..2fec37aaf6 100644 --- a/tests/ImageSharp.Tests/Formats/Heif/HeifEncoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Heif/HeifEncoderTests.cs @@ -1008,6 +1008,138 @@ public class HeifEncoderTests Assert.Equal(Av1BitDepth.TenBit, sequenceHeader.ColorConfig.BitDepth); } + [Theory] + [InlineData(HeifBitDepth.Bit8, false, false, false)] + [InlineData(HeifBitDepth.Bit8, true, false, false)] + [InlineData(HeifBitDepth.Bit10, false, false, false)] + [InlineData(HeifBitDepth.Bit10, true, false, false)] + [InlineData(HeifBitDepth.Bit12, false, false, false)] + [InlineData(HeifBitDepth.Bit12, true, false, false)] + [InlineData(HeifBitDepth.Bit8, false, true, false)] + [InlineData(HeifBitDepth.Bit8, true, true, false)] + [InlineData(HeifBitDepth.Bit10, false, true, false)] + [InlineData(HeifBitDepth.Bit10, true, true, false)] + [InlineData(HeifBitDepth.Bit12, false, true, false)] + [InlineData(HeifBitDepth.Bit12, true, true, false)] + [InlineData(HeifBitDepth.Bit8, false, false, true)] + [InlineData(HeifBitDepth.Bit12, false, true, true)] + public void Av1PreservesIdentityMatrixColorDescription( + HeifBitDepth bitDepth, + bool fullRange, + bool sequence, + bool srgb) + { + const int Width = 8; + const int Height = 8; + using Image image = new(Width, Height); + if (sequence) + { + image.Frames.AddFrame(image.Frames.RootFrame); + } + + for (int frameIndex = 0; frameIndex < image.Frames.Count; frameIndex++) + { + ImageFrame frame = image.Frames[frameIndex]; + frame.Metadata.GetHeifMetadata().FrameDelay = new Rational(1, 25); + for (int y = 0; y < Height; y++) + { + Span row = frame.PixelBuffer.DangerousGetRowSpan(y); + for (int x = 0; x < Width; x++) + { + row[x] = new Rgb24( + (byte)((x * 31) + y + frameIndex), + (byte)((y * 29) + x + frameIndex), + (byte)((x * 17) + (y * 11) + frameIndex)); + } + } + } + + // BT.2020/PQ identity uses explicit range syntax. Only the BT.709/sRGB identity combination + // infers full range, so its limited-range metadata must be normalized before pixel conversion. + CicpProfile profile = srgb ? new(1, 13, 0, fullRange) : new(9, 16, 0, fullRange); + image.Metadata.CicpProfile = profile; + bool expectedFullRange = fullRange || srgb; + using MemoryStream stream = new(); + image.Save(stream, new HeifEncoder + { + CompressionMethod = HeifCompressionMethod.Av1, + BitDepth = bitDepth, + ChromaSubsampling = HeifChromaSubsampling.Yuv444, + Lossless = true, + Effort = 0 + }); + + Assert.Same(profile, image.Metadata.CicpProfile); + Assert.Equal(fullRange, profile.FullRange); + byte[] file = stream.ToArray(); + using Av1Decoder sampleDecoder = new(Configuration.Default); + using Image sample = sampleDecoder.Decode(GetItemPayload(file, 1)); + ObuSequenceHeader header = Assert.IsType(sampleDecoder.SequenceHeader); + Assert.Equal(ObuMatrixCoefficients.Identity, header.ColorConfig.MatrixCoefficients); + Assert.Equal((byte)profile.ColorPrimaries, (byte)header.ColorConfig.ColorPrimaries); + Assert.Equal((byte)profile.TransferCharacteristics, (byte)header.ColorConfig.TransferCharacteristics); + Assert.Equal(expectedFullRange, header.ColorConfig.ColorRange); + Assert.Equal(Av1ColorFormat.Yuv444, header.ColorConfig.GetColorFormat()); + + stream.Position = 0; + DecoderOptions options = new() { ColorProfileHandling = ColorProfileHandling.Preserve }; + using Image decoded = Image.Load(options, stream); + CicpProfile decodedProfile = Assert.IsType(decoded.Metadata.CicpProfile); + Assert.Equal(profile.ColorPrimaries, decodedProfile.ColorPrimaries); + Assert.Equal(profile.TransferCharacteristics, decodedProfile.TransferCharacteristics); + Assert.Equal(CicpMatrixCoefficients.Identity, decodedProfile.MatrixCoefficients); + Assert.Equal(expectedFullRange, decodedProfile.FullRange); + Assert.Equal(image.Frames.Count, decoded.Frames.Count); + for (int frameIndex = 0; frameIndex < image.Frames.Count; frameIndex++) + { + for (int y = 0; y < Height; y++) + { + ReadOnlySpan expectedRow = image.Frames[frameIndex].PixelBuffer.DangerousGetRowSpan(y); + ReadOnlySpan actualRow = decoded.Frames[frameIndex].PixelBuffer.DangerousGetRowSpan(y); + for (int x = 0; x < Width; x++) + { + // Limited-range conversion rounds onto 219 codes before the lossless codec stage. + Assert.InRange((int)actualRow[x].R - expectedRow[x].R, -1, 1); + Assert.InRange((int)actualRow[x].G - expectedRow[x].G, -1, 1); + Assert.InRange((int)actualRow[x].B - expectedRow[x].B, -1, 1); + } + } + } + + string directory = TestEnvironment.CreateOutputDirectory("Heif", "Av1", nameof(this.Av1PreservesIdentityMatrixColorDescription)); + string name = $"{(int)bitDepth}-{fullRange}-{sequence}-{srgb}"; + File.WriteAllBytes(Path.Combine(directory, name + ".obu"), GetTopLevelBox(file, Heif4CharCode.Mdat)[8..].ToArray()); + using BinaryWriter expectedSamples = new(File.Create(Path.Combine(directory, name + ".expected.yuv"))); + int depthScale = 1 << ((int)bitDepth - 8); + int bias = expectedFullRange ? 0 : 16 * depthScale; + int range = expectedFullRange ? (1 << (int)bitDepth) - 1 : 219 * depthScale; + for (int frameIndex = 0; frameIndex < image.Frames.Count; frameIndex++) + { + for (int plane = 0; plane < 3; plane++) + { + for (int y = 0; y < Height; y++) + { + ReadOnlySpan row = image.Frames[frameIndex].PixelBuffer.DangerousGetRowSpan(y); + for (int x = 0; x < Width; x++) + { + // The independent reference is G, B, R with the luma range on every plane. + // These integer sample expectations do not call the production color converter. + int channel = plane == 0 ? row[x].G : plane == 1 ? row[x].B : row[x].R; + int value = bias + (((channel * range) + 127) / 255); + if (bitDepth == HeifBitDepth.Bit8) + { + expectedSamples.Write((byte)value); + } + else + { + expectedSamples.Write((ushort)value); + } + } + } + } + } + } + [Theory] [InlineData(CicpMatrixCoefficients.Identity, HeifChromaSubsampling.Yuv420)] [InlineData(CicpMatrixCoefficients.YCgCoRe, null)]