Browse Source

Preserve valid AV1 identity color descriptions and range

pull/2633/head
James Jackson-South 4 weeks ago
parent
commit
330d4c4eab
  1. 23
      HEIF_IMPLEMENTATION_PLAN.md
  2. 11
      src/ImageSharp/Formats/Heif/HeifEncoderCore.Sequence.cs
  3. 132
      tests/ImageSharp.Tests/Formats/Heif/HeifEncoderTests.cs

23
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. 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. 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: 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, - `HeifEncoderCore.Sequence.cs:74-194` resolved output sampling and preserved reversible YCgCo matrix metadata,

11
src/ImageSharp/Formats/Heif/HeifEncoderCore.Sequence.cs

@ -125,9 +125,7 @@ internal sealed partial class HeifEncoderCore
{ {
bool identityMatrix = sourceColorProfile.MatrixCoefficients == CicpMatrixCoefficients.Identity; bool identityMatrix = sourceColorProfile.MatrixCoefficients == CicpMatrixCoefficients.Identity;
bool legalIdentityMatrix = !isMonochrome bool legalIdentityMatrix = !isMonochrome
&& chromaSubsampling == HeifChromaSubsampling.Yuv444 && chromaSubsampling == HeifChromaSubsampling.Yuv444;
&& sourceColorProfile.ColorPrimaries == CicpColorPrimaries.ItuRBt709_6
&& sourceColorProfile.TransferCharacteristics == CicpTransferCharacteristics.Iec61966_2_1;
bool reversibleMatrix = sourceColorProfile.MatrixCoefficients is CicpMatrixCoefficients.YCgCoRe or CicpMatrixCoefficients.YCgCoRo; bool reversibleMatrix = sourceColorProfile.MatrixCoefficients is CicpMatrixCoefficients.YCgCoRe or CicpMatrixCoefficients.YCgCoRo;
if (sourceColorProfile.MatrixCoefficients == CicpMatrixCoefficients.Unspecified if (sourceColorProfile.MatrixCoefficients == CicpMatrixCoefficients.Unspecified
@ -142,8 +140,13 @@ internal sealed partial class HeifEncoderCore
(byte)CicpMatrixCoefficients.ItuRBt601_7_525, (byte)CicpMatrixCoefficients.ItuRBt601_7_525,
sourceColorProfile.FullRange); 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( colorProfile = new CicpProfile(
(byte)sourceColorProfile.ColorPrimaries, (byte)sourceColorProfile.ColorPrimaries,
(byte)sourceColorProfile.TransferCharacteristics, (byte)sourceColorProfile.TransferCharacteristics,

132
tests/ImageSharp.Tests/Formats/Heif/HeifEncoderTests.cs

@ -1008,6 +1008,138 @@ public class HeifEncoderTests
Assert.Equal(Av1BitDepth.TenBit, sequenceHeader.ColorConfig.BitDepth); 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<Rgb24> image = new(Width, Height);
if (sequence)
{
image.Frames.AddFrame(image.Frames.RootFrame);
}
for (int frameIndex = 0; frameIndex < image.Frames.Count; frameIndex++)
{
ImageFrame<Rgb24> frame = image.Frames[frameIndex];
frame.Metadata.GetHeifMetadata().FrameDelay = new Rational(1, 25);
for (int y = 0; y < Height; y++)
{
Span<Rgb24> 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<Rgb24> sample = sampleDecoder.Decode<Rgb24>(GetItemPayload(file, 1));
ObuSequenceHeader header = Assert.IsType<ObuSequenceHeader>(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<Rgb24> decoded = Image.Load<Rgb24>(options, stream);
CicpProfile decodedProfile = Assert.IsType<CicpProfile>(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<Rgb24> expectedRow = image.Frames[frameIndex].PixelBuffer.DangerousGetRowSpan(y);
ReadOnlySpan<Rgb24> 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<Rgb24> 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] [Theory]
[InlineData(CicpMatrixCoefficients.Identity, HeifChromaSubsampling.Yuv420)] [InlineData(CicpMatrixCoefficients.Identity, HeifChromaSubsampling.Yuv420)]
[InlineData(CicpMatrixCoefficients.YCgCoRe, null)] [InlineData(CicpMatrixCoefficients.YCgCoRe, null)]

Loading…
Cancel
Save