From 0afaa85770144f248d77f96d8bd2e5cee6ac7b40 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Wed, 2 Sep 2026 21:36:14 +1000 Subject: [PATCH] Expose live AV1 syntax costs --- HEIF_IMPLEMENTATION_PLAN.md | 2 +- .../Heif/Av1/Entropy/Av1SymbolEncoder.cs | 75 +++++++++++++++++++ .../Formats/Heif/Av1/Av1EntropyTests.cs | 70 +++++++++++++++++ 3 files changed, 146 insertions(+), 1 deletion(-) diff --git a/HEIF_IMPLEMENTATION_PLAN.md b/HEIF_IMPLEMENTATION_PLAN.md index 9c799b97d9..1a3beb5872 100644 --- a/HEIF_IMPLEMENTATION_PLAN.md +++ b/HEIF_IMPLEMENTATION_PLAN.md @@ -828,7 +828,7 @@ Encoder verification contract: - [ ] Implement inter mode search for bounded sequences, including reference selection and the decoder-supported inter tools. - [~] Current-libaom `av1_quantize_fp_no_qmatrix` arithmetic is implemented as a closed generic forward-quantizer family with Vector512, Vector256, Vector128, and scalar paths, raster-order output, coded 64-point coefficient limits, and scan-order EOB selection. Transform search, coefficient optimization, and lossless behavior remain. - [ ] Implement real rate-distortion selection and make quality and effort change work, size, and output quality. -- [~] Encoder rate accounting now converts the entropy writer's live inverse cumulative distributions into current-libaom fixed-point symbol costs without allocating or duplicating probability state. The complete normalized probability table, Q15 boundaries, multi-symbol interval arithmetic, literal-bit precision, and zero-allocation hot path pass 15 of 15 focused direct net11 Release VSTest cases; all 1,897 `Av1EntropyTests` cases pass. Mode, transform, skip, and coefficient candidate accounting still need to compose these costs into real rate-distortion selection. +- [~] Encoder rate accounting now converts the entropy writer's live inverse cumulative distributions into current-libaom fixed-point symbol costs without allocating or duplicating probability state. Read-only luma-mode, directional-delta, filter-intra, chroma-mode, block-skip, and transform-block-skip queries share the exact distributions mutated by the subsequent entropy write, and a regression proves the reported luma cost follows that adaptation. The complete normalized probability table, Q15 boundaries, multi-symbol interval arithmetic, literal-bit precision, zero-allocation hot path, syntax-query parity, and live-update behavior pass 17 of 17 focused direct net11 Release VSTest cases; all 1,899 `Av1EntropyTests` cases pass. Transform-size, coefficient, and complete candidate accounting still need to compose these costs into real rate-distortion selection. - [~] The tile writer now publishes one packed coefficient context per covered 4x4 edge unit and derives luma/chroma skip plus DC-sign contexts from the complete transform edges using current-libaom units. Partition, transform, and coefficient neighbor state retains only the above and left context regions used by current libaom; the unused third top-left region, its granularity state, and its unused sentinel are removed. One picture owner now packs segmentation plus every tile's partition, luma, chroma, and transform edges into one clean byte allocation with typed non-owning views; together with the separately typed packed mode-information owner, the complete picture state uses two allocator rents rather than seven. Exact aligned lengths, clean initialization, and balanced exactly-once returns are covered in Release. Multi-tile payload ownership and verified CDF update behavior remain. - [~] Encoder mode information now uses a frame-owned integer alias grid over a packed 8-byte value allocation, matching current libaom's `mi_grid_base` and `mi_alloc` relationship without a managed object or reference per 4x4 entry. The visible dimensions are aligned to eight luma samples, the grid stride and allocated row count are aligned to 32 mode-information units, and optional 8x8 allocation granularity reduces the value store in both dimensions exactly as current libaom does. One clean ImageSharp byte owner contains both independently typed regions, reducing libaom's two allocation lifetimes to one without a copy. At 4K, the 4x4 layout occupies about 6.0 MiB in total; the 8x8 layout occupies about 3.0 MiB. Exact geometry, clean allocation, typed lengths, aligned mapping, untouched row padding, and exactly-once return pass 4 of 4 direct net11 VSTest cases in Release. Every coded 4x4 cell covered by square, rectangular, or clipped edge blocks maps to its owning allocation entry before context-dependent symbols are written. Packed syntax, relative neighbor lookup, full block mapping, writer traversal, entropy, and OBU coverage pass 1,947 of 1,947 direct net11 VSTest cases in Release; complete mode decision still remains. - [~] The final-block decision workspace uses one reusable 10.3 KiB ImageSharp allocator owner. It contains 1,024 explicitly packed 10-byte final-block entries and the 341 preorder partition bytes required by a complete 128x128-through-8x8 quadtree, replacing separate managed arrays. Construction and the explicit per-superblock reset initialize every syntax field, including the nonzero sentinel that disables filter-intra prediction; pooled palette, quantizer, prediction, and partition bytes cannot leak into the next decision pass. Exact allocation, size, initialization, reset, return, repeated-run, writer, entropy, and OBU coverage pass 1,957 of 1,957 direct net11 VSTest cases in Release; complete mode decision still remains. diff --git a/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolEncoder.cs b/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolEncoder.cs index 6da345b907..ede81daba3 100644 --- a/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolEncoder.cs +++ b/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolEncoder.cs @@ -426,6 +426,18 @@ internal class Av1SymbolEncoder : IDisposable } } + /// + /// Gets the current fixed-point cost of the transform-block skip flag. + /// + /// Indicates whether the transform block is empty. + /// The square transform-size probability context. + /// The context derived from neighboring coefficient blocks. + /// The rate cost in 1/512-bit units. + public int GetTransformBlockSkipCost(bool skip, Av1TransformSize transformSizeContext, int skipContext) + => Av1ProbabilityCost.GetSymbolCost( + this.transformBlockSkip[(int)transformSizeContext][skipContext], + skip ? 1 : 0); + /// /// Writes whether a transform block has no coded coefficients. /// @@ -600,6 +612,15 @@ internal class Av1SymbolEncoder : IDisposable w.WriteSymbol(segmentId, this.segmentId[context]); } + /// + /// Gets the current fixed-point cost of the transform-skip flag. + /// + /// Indicates whether the block contains no coded transform coefficients. + /// The neighboring skip context. + /// The rate cost in 1/512-bit units. + public int GetSkipCost(bool skip, int context) + => Av1ProbabilityCost.GetSymbolCost(this.skip[context], skip ? 1 : 0); + /// /// Writes the transform-skip flag from a neighboring skip context. /// @@ -622,6 +643,24 @@ internal class Av1SymbolEncoder : IDisposable w.WriteSymbol(skip, this.skipMode[context]); } + /// + /// Gets the current fixed-point cost of the filter-intra enable flag and selected mode. + /// + /// The selected filter-intra mode, or the disabled sentinel. + /// The block size selecting the enable distribution. + /// The rate cost in 1/512-bit units. + public int GetFilterIntraModeCost(Av1FilterIntraMode filterIntraMode, Av1BlockSize blockSize) + { + bool useFilter = filterIntraMode != Av1FilterIntraMode.AllFilterIntraModes; + int cost = Av1ProbabilityCost.GetSymbolCost(this.filterIntra[(int)blockSize], useFilter ? 1 : 0); + if (useFilter) + { + cost += Av1ProbabilityCost.GetSymbolCost(this.filterIntraMode, (int)filterIntraMode); + } + + return cost; + } + /// /// Writes the filter-intra enable flag and, when enabled, its prediction mode. /// @@ -666,6 +705,18 @@ internal class Av1SymbolEncoder : IDisposable } } + /// + /// Gets the current fixed-point cost of a key-frame luma prediction mode. + /// + /// The luma prediction mode. + /// The reduced above-mode context. + /// The reduced left-mode context. + /// The rate cost in 1/512-bit units. + public int GetLumaModeCost(Av1PredictionMode lumaMode, byte topContext, byte leftContext) + => Av1ProbabilityCost.GetSymbolCost( + this.keyFrameYMode[topContext][leftContext], + (int)lumaMode); + /// /// Writes a key-frame luma prediction mode using the above and left mode contexts. /// @@ -678,6 +729,17 @@ internal class Av1SymbolEncoder : IDisposable w.WriteSymbol((int)lumaMode, this.keyFrameYMode[topContext][leftContext]); } + /// + /// Gets the current fixed-point cost of a directional angle-delta symbol. + /// + /// The signed angle delta offset by . + /// The directional prediction mode selecting the distribution. + /// The rate cost in 1/512-bit units. + public int GetAngleDeltaCost(int angleDelta, Av1PredictionMode context) + => Av1ProbabilityCost.GetSymbolCost( + this.angleDelta[context - Av1PredictionMode.Vertical], + angleDelta); + /// /// Writes an unsigned directional angle-delta symbol. /// @@ -700,6 +762,19 @@ internal class Av1SymbolEncoder : IDisposable w.WriteLiteral((uint)cdefStrength, bitCount); } + /// + /// Gets the current fixed-point cost of a chroma intra prediction mode. + /// + /// The chroma prediction mode. + /// Indicates whether chroma-from-luma is valid for the block. + /// The block's luma prediction mode. + /// The rate cost in 1/512-bit units. + public int GetChromaModeCost(Av1ChromaPredictionMode chromaMode, bool isChromaFromLumaAllowed, Av1PredictionMode lumaMode) + { + int cflAllowed = isChromaFromLumaAllowed ? 1 : 0; + return Av1ProbabilityCost.GetSymbolCost(this.uvMode[cflAllowed][(int)lumaMode], (int)chromaMode); + } + /// /// Writes a chroma intra prediction mode conditioned on the luma mode and chroma-from-luma availability. /// diff --git a/tests/ImageSharp.Tests/Formats/Heif/Av1/Av1EntropyTests.cs b/tests/ImageSharp.Tests/Formats/Heif/Av1/Av1EntropyTests.cs index ccb51f700e..e02905849e 100644 --- a/tests/ImageSharp.Tests/Formats/Heif/Av1/Av1EntropyTests.cs +++ b/tests/ImageSharp.Tests/Formats/Heif/Av1/Av1EntropyTests.cs @@ -79,6 +79,76 @@ public class Av1EntropyTests Assert.Equal(before, after); } + [Fact] + public void SymbolEncoderSyntaxCostsMatchCurrentDistributions() + { + const byte TopContext = 0; + const byte LeftContext = 0; + const int SkipContext = 0; + const Av1BlockSize BlockSize = Av1BlockSize.Block8x8; + const Av1TransformSize TransformSize = Av1TransformSize.Size8x8; + const Av1PredictionMode LumaMode = Av1PredictionMode.DC; + const Av1ChromaPredictionMode ChromaMode = Av1ChromaPredictionMode.DC; + const Av1FilterIntraMode FilterMode = Av1FilterIntraMode.DC; + + using Av1SymbolEncoder encoder = new(Configuration.Default, 64, BaseQIndex, updateCdf: false); + + Av1Distribution luma = Av1DefaultDistributions.KeyFrameYMode[TopContext][LeftContext]; + Assert.Equal( + Av1ProbabilityCost.GetSymbolCost(luma, (int)LumaMode), + encoder.GetLumaModeCost(LumaMode, TopContext, LeftContext)); + + Av1Distribution angle = Av1DefaultDistributions.AngleDelta[0]; + Assert.Equal( + Av1ProbabilityCost.GetSymbolCost(angle, Av1Constants.MaxAngleDelta), + encoder.GetAngleDeltaCost(Av1Constants.MaxAngleDelta, Av1PredictionMode.Vertical)); + + Av1Distribution chroma = Av1DefaultDistributions.UvMode[0][(int)LumaMode]; + Assert.Equal( + Av1ProbabilityCost.GetSymbolCost(chroma, (int)ChromaMode), + encoder.GetChromaModeCost(ChromaMode, false, LumaMode)); + + Av1Distribution filterEnable = Av1DefaultDistributions.FilterIntra[(int)BlockSize]; + Av1Distribution filterMode = Av1DefaultDistributions.FilterIntraMode; + int expectedFilterCost = Av1ProbabilityCost.GetSymbolCost(filterEnable, 1) + + Av1ProbabilityCost.GetSymbolCost(filterMode, (int)FilterMode); + + Assert.Equal(expectedFilterCost, encoder.GetFilterIntraModeCost(FilterMode, BlockSize)); + Assert.Equal( + Av1ProbabilityCost.GetSymbolCost(filterEnable, 0), + encoder.GetFilterIntraModeCost(Av1FilterIntraMode.AllFilterIntraModes, BlockSize)); + + Av1Distribution skip = Av1DefaultDistributions.Skip[SkipContext]; + Assert.Equal( + Av1ProbabilityCost.GetSymbolCost(skip, 1), + encoder.GetSkipCost(true, SkipContext)); + + Av1Distribution transformSkip = Av1DefaultDistributions + .GetTransformBlockSkip(BaseQIndex)[(int)TransformSize][SkipContext]; + + Assert.Equal( + Av1ProbabilityCost.GetSymbolCost(transformSkip, 1), + encoder.GetTransformBlockSkipCost(true, TransformSize, SkipContext)); + } + + [Fact] + public void SymbolEncoderCostTracksWrittenLumaMode() + { + using Av1SymbolEncoder encoder = new(Configuration.Default, 64, BaseQIndex); + Av1Distribution expected = Av1DefaultDistributions.KeyFrameYMode[0][0]; + + Assert.Equal( + Av1ProbabilityCost.GetSymbolCost(expected, (int)Av1PredictionMode.DC), + encoder.GetLumaModeCost(Av1PredictionMode.DC, 0, 0)); + + encoder.WriteLumaMode(Av1PredictionMode.DC, 0, 0); + expected.Update((int)Av1PredictionMode.DC); + + Assert.Equal( + Av1ProbabilityCost.GetSymbolCost(expected, (int)Av1PredictionMode.DC), + encoder.GetLumaModeCost(Av1PredictionMode.DC, 0, 0)); + } + [Fact] public void SymbolWriterMatchesCurrentLibaomCarryRegression() {