diff --git a/HEIF_IMPLEMENTATION_PLAN.md b/HEIF_IMPLEMENTATION_PLAN.md index 553c96f52e..6aee22365f 100644 --- a/HEIF_IMPLEMENTATION_PLAN.md +++ b/HEIF_IMPLEMENTATION_PLAN.md @@ -298,6 +298,51 @@ Intra-edge integration after checkpoint `2424ff9f9`, verified on 2026-09-05: ### Required completion gates +Frame-context investigation after checkpoint `1639550a0`: + +- `Av1SymbolEncoder.cs:355-369` resets tile CDFs to quantizer-band defaults. `Av1TileEncoder.cs:281-337` + does that between tiles and retains only their output offsets and lengths. Reference + `av1/encoder/encodeframe.c:1456` initializes each tile from the unchanged frame context, then + `av1/encoder/bitstream.c:4074-4079` signals the largest encoded tile as the update source. + `av1/encoder/encoder.c:4489-4495` copies that tile's CDFs, resets observation counters, and stores them + with the reconstructed reference frame. Encoder publication is missing in the managed path. +- Primary-reference selection is coupled to the reference-role and frame-layer controller + (`av1/encoder/encode_strategy.c:168-230`), not simply the last encoded frame. The managed encoder retains + only the preceding reconstruction and always writes global-motion models relative to identity + (`ObuWriter.cs:1206-1226`). Enabling primary-reference reuse requires reconciling those inherited models, + loop-filter deltas, segmentation, refresh slots, and entropy state together. No encoder frame-context flag + or reference policy was changed during this investigation. +- Existing `Av1FrameEntropyContexts.BeginFrame`, `Av1FrameEntropyContext.CopyFrom`, and `SnapshotTo` + already implement decoder base/working/published state and counter reset. They are existing reusable + contracts to consider when implementing encoder publication; a new ownership framework is not justified. + +Independent loop-filter delta entropy correction, verified on 2026-09-05: + +- The context-family comparison found a numerical decoder defect. Before correction, + `Av1SymbolDecoder.cs:921-924` always read `DeltaLoopFilterAbsolute`, including the per-channel loop at + `Av1TileReader.cs:3065-3067`. Native `av1/decoder/decodemv.c:749-765` instead selects independent + `delta_lf_multi_cdf[lf_id]` distributions for multi-delta syntax and the shared CDF otherwise. + Defaults are in `av1/common/entropymode.c:844-851`; counter reset is in `av1/common/entropy.c:166-169`. +- A new regression encodes independent channel histories using explicit reference defaults and signed magnitude + syntax from `av1/encoder/bitstream.c:323-353`. The original decoder failed on the third symbol, returning + **-1 instead of -2** (`delta-lf-before.trx`, stopped on the first failure). +- Four independent distributions now participate in prototype construction, deep copying, default restoration, + and frame snapshot counter reset. The existing tile loop passes its parsed multi-delta flag and channel index + to the symbol reader. No per-symbol owner, allocation, new guard, or rejection policy was introduced. +- Tests cover four color channels, two monochrome channels, shared-delta syntax, disabled CDF adaptation, + signed escape magnitudes, independent copies, counter reset, and unchanged shared-delta defaults. + Final Release net11.0 build: zero errors and 1,009 existing warnings; Roslynk: zero compiler errors. + Serialized Visual Studio VSTest passed **2,095/2,095** in `delta-lf-final.trx` (1.8771 minutes), covering + entropy tests, frame-context lifecycle tests, and the AV1 reconstruction conformance suite. +- Current libaom normal encoding sets `DEFAULT_DELTA_LF_MULTI` to zero (`av1/common/enums.h:73`, + `av1/encoder/encodeframe.c:2357`). Existing native output must not be assumed to exercise multi-delta syntax. + A complete independently authored multi-delta bitstream remains a verification gap. This correction does not + establish encoder parity or complete decoder correctness. No benchmark was run. +- Following the decoded values into deblocking found a further clipping-order discrepancy: + `Av1LoopFilterDecoder.cs:373-390` clips the reference adjustment before adding the mode adjustment; + native `av1/common/av1_loopfilter.c:95-101,195-201` clips their combined result once. A production-frame + regression and correction are still pending for that separately identified numerical defect. + Motion-controller investigation continued after correction checkpoint `578ec34d9`: - Managed `Av1IntraSuperblockEncoder.ReferenceModeDecision.cs:1278-1493` uses the same normalized squared-error diff --git a/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1DefaultDistributions.cs b/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1DefaultDistributions.cs index 277d77449d..f70c722fcd 100644 --- a/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1DefaultDistributions.cs +++ b/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1DefaultDistributions.cs @@ -495,6 +495,17 @@ internal static class Av1DefaultDistributions /// public static Av1Distribution DeltaLoopFilterAbsolute => new(28160, 32120, 32677); + /// + /// Gets independent loop-filter delta distributions for vertical luma, horizontal luma, U, and V. + /// + public static Av1Distribution[] DeltaLoopFilterMultiAbsolute => + [ + new(28160, 32120, 32677), + new(28160, 32120, 32677), + new(28160, 32120, 32677), + new(28160, 32120, 32677) + ]; + /// /// Gets the distribution for an absolute quantizer delta magnitude. /// diff --git a/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1FrameEntropyContext.cs b/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1FrameEntropyContext.cs index b7e5cb17d8..c1b0d82e6c 100644 --- a/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1FrameEntropyContext.cs +++ b/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1FrameEntropyContext.cs @@ -108,6 +108,7 @@ internal sealed class Av1FrameEntropyContext this.Skip = Av1DefaultDistributions.Skip; this.SkipMode = Av1DefaultDistributions.SkipMode; this.DeltaLoopFilterAbsolute = Av1DefaultDistributions.DeltaLoopFilterAbsolute; + this.DeltaLoopFilterMultiAbsolute = Av1DefaultDistributions.DeltaLoopFilterMultiAbsolute; this.DeltaQuantizerAbsolute = Av1DefaultDistributions.DeltaQuantizerAbsolute; this.SegmentId = Av1DefaultDistributions.SegmentId; this.SegmentIdPredicted = Av1DefaultDistributions.SegmentIdPredicted; @@ -183,6 +184,7 @@ internal sealed class Av1FrameEntropyContext this.Skip = Av1Distribution.CreateCopy(source.Skip); this.SkipMode = Av1Distribution.CreateCopy(source.SkipMode); this.DeltaLoopFilterAbsolute = source.DeltaLoopFilterAbsolute.CreateCopy(); + this.DeltaLoopFilterMultiAbsolute = Av1Distribution.CreateCopy(source.DeltaLoopFilterMultiAbsolute); this.DeltaQuantizerAbsolute = source.DeltaQuantizerAbsolute.CreateCopy(); this.SegmentId = Av1Distribution.CreateCopy(source.SegmentId); this.SegmentIdPredicted = Av1Distribution.CreateCopy(source.SegmentIdPredicted); @@ -409,6 +411,11 @@ internal sealed class Av1FrameEntropyContext /// public Av1Distribution DeltaLoopFilterAbsolute { get; } + /// + /// Gets independent delta distributions for vertical luma, horizontal luma, U, and V loop filters. + /// + public Av1Distribution[] DeltaLoopFilterMultiAbsolute { get; } + /// /// Gets the absolute quantizer delta distribution. /// @@ -578,6 +585,7 @@ internal sealed class Av1FrameEntropyContext CopyState(source.Skip, this.Skip); CopyState(source.SkipMode, this.SkipMode); this.DeltaLoopFilterAbsolute.CopyFrom(source.DeltaLoopFilterAbsolute); + CopyState(source.DeltaLoopFilterMultiAbsolute, this.DeltaLoopFilterMultiAbsolute); this.DeltaQuantizerAbsolute.CopyFrom(source.DeltaQuantizerAbsolute); CopyState(source.SegmentId, this.SegmentId); CopyState(source.SegmentIdPredicted, this.SegmentIdPredicted); @@ -659,6 +667,7 @@ internal sealed class Av1FrameEntropyContext ResetUpdateCounts(this.Skip); ResetUpdateCounts(this.SkipMode); this.DeltaLoopFilterAbsolute.ResetUpdateCount(); + ResetUpdateCounts(this.DeltaLoopFilterMultiAbsolute); this.DeltaQuantizerAbsolute.ResetUpdateCount(); ResetUpdateCounts(this.SegmentId); ResetUpdateCounts(this.SegmentIdPredicted); diff --git a/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolDecoder.cs b/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolDecoder.cs index 5134a1467e..ef5423167d 100644 --- a/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolDecoder.cs +++ b/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolDecoder.cs @@ -917,11 +917,17 @@ internal ref struct Av1SymbolDecoder /// /// Reads a signed loop-filter delta value. /// + /// Whether the frame signals independent deltas for each loop-filter channel. + /// The loop-filter channel, used only when is true. /// The decoded loop-filter delta. - public int ReadDeltaLoopFilter() + public int ReadDeltaLoopFilter(bool isMulti, int channel) { ref Av1SymbolReader r = ref this.reader; - int deltaLoopFilterAbsolute = r.ReadSymbol(this.context.DeltaLoopFilterAbsolute); + + // Multi-delta syntax adapts one CDF per filter channel. Sharing the scalar-delta CDF would let + // an earlier channel change the range intervals used to decode the next channel in the same block. + Av1Distribution distribution = isMulti ? this.context.DeltaLoopFilterMultiAbsolute[channel] : this.context.DeltaLoopFilterAbsolute; + int deltaLoopFilterAbsolute = r.ReadSymbol(distribution); if (deltaLoopFilterAbsolute == Av1Constants.DeltaLoopFilterSmall) { int deltaLoopFilterRemainingBits = r.ReadLiteral(3) + 1; diff --git a/src/ImageSharp/Formats/Heif/Av1/Tiling/Av1TileReader.cs b/src/ImageSharp/Formats/Heif/Av1/Tiling/Av1TileReader.cs index 124c66c527..8fdb67afa5 100644 --- a/src/ImageSharp/Formats/Heif/Av1/Tiling/Av1TileReader.cs +++ b/src/ImageSharp/Formats/Heif/Av1/Tiling/Av1TileReader.cs @@ -3064,7 +3064,7 @@ internal sealed class Av1TileReader : IAv1TileReader, IDisposable for (int i = 0; i < frameLoopFilterCount; i++) { - int reducedDeltaLoopFilterLevel = reader.ReadDeltaLoopFilter(); + int reducedDeltaLoopFilterLevel = reader.ReadDeltaLoopFilter(this.FrameHeader.DeltaLoopFilterParameters.IsMulti, i); int deltaLoopFilterResolution = this.FrameHeader.DeltaLoopFilterParameters.Resolution; this.currentDeltaLoopFilter[i] = Av1Math.Clip3( -Av1Constants.MaxLoopFilter, diff --git a/tests/ImageSharp.Tests/Formats/Heif/Av1/Av1EntropyTests.cs b/tests/ImageSharp.Tests/Formats/Heif/Av1/Av1EntropyTests.cs index c27fa959c3..9aa31ce5cb 100644 --- a/tests/ImageSharp.Tests/Formats/Heif/Av1/Av1EntropyTests.cs +++ b/tests/ImageSharp.Tests/Formats/Heif/Av1/Av1EntropyTests.cs @@ -24,6 +24,100 @@ public class Av1EntropyTests // Short syntax round trips encode only their small in-method symbol vectors. private const int ShortSyntaxBufferLength = 64; + [Theory] + [InlineData(4, true)] + [InlineData(2, true)] + [InlineData(1, true)] + [InlineData(4, false)] + public void DeltaLoopFilterChannelsAdaptIndependently(int channelCount, bool updateCdf) + { + ReadOnlySpan deltas = [0, 1, -2, 3, -1, 0, 17, -3, 2, -9, 0, 1]; + Av1Distribution[] channels = new Av1Distribution[channelCount]; + for (int channel = 0; channel < channelCount; channel++) + { + // libaom entropymode.c gives each multi-delta channel this independent initial CDF. + channels[channel] = new(28160, 32120, 32677); + } + + using Av1SymbolWriter writer = new(Configuration.Default, 512, updateCdf); + for (int index = 0; index < 96; index++) + { + int delta = deltas[index % deltas.Length]; + int magnitude = Math.Abs(delta); + writer.WriteSymbol(Math.Min(magnitude, 3), channels[index % channelCount]); + if (magnitude >= 3) + { + // The escape magnitude is 2^bits + 1 plus the transmitted remainder, followed by its sign. + int bits = BitOperations.Log2((uint)(magnitude - 1)); + writer.WriteLiteral((uint)(bits - 1), 3); + writer.WriteLiteral((uint)(magnitude - (1 << bits) - 1), bits); + } + + if (magnitude != 0) + { + writer.WriteLiteral(delta < 0 ? 1U : 0U, 1); + } + } + + using IMemoryOwner payload = writer.Exit(); + Av1SymbolDecoder reader = new(Configuration.Default, payload.Memory.Span, BaseQIndex, updateCdf); + for (int index = 0; index < 96; index++) + { + Assert.Equal(deltas[index % deltas.Length], reader.ReadDeltaLoopFilter(channelCount > 1, index % channelCount)); + } + } + + [Fact] + public void FrameEntropyLifecyclePreservesIndependentDeltaLoopFilterChannels() + { + Av1FrameEntropyContext source = new(BaseQIndex); + Av1FrameEntropyContext copy = new(BaseQIndex); + Av1FrameEntropyContext snapshot = new(BaseQIndex); + Av1Distribution defaults = new(28160, 32120, 32677); + for (int channel = 0; channel < 4; channel++) + { + for (int observation = 0; observation < 20; observation++) + { + source.DeltaLoopFilterMultiAbsolute[channel].Update((channel % 3) + 1); + } + } + + copy.CopyFrom(source); + source.SnapshotTo(snapshot); + for (int channel = 0; channel < 4; channel++) + { + Av1Distribution original = source.DeltaLoopFilterMultiAbsolute[channel]; + Av1Distribution copied = copy.DeltaLoopFilterMultiAbsolute[channel]; + Av1Distribution published = snapshot.DeltaLoopFilterMultiAbsolute[channel]; + Assert.NotSame(original, copied); + Assert.NotSame(original, published); + for (int symbol = 0; symbol < 4; symbol++) + { + Assert.Equal(original[symbol], copied[symbol]); + Assert.Equal(original[symbol], published[symbol]); + Assert.Equal(defaults[symbol], source.DeltaLoopFilterAbsolute[symbol]); + } + + // A published CDF preserves probabilities but restarts its observation history. The next + // identical symbol must therefore move its threshold further than in the twenty-count source. + original.Update(0); + published.Update(0); + Assert.NotEqual(original[0], published[0]); + Assert.NotEqual(original[0], copied[0]); + } + + copy.ResetToDefaults(255); + Av1FrameEntropyContext fresh = new(BaseQIndex); + for (int channel = 0; channel < 4; channel++) + { + for (int symbol = 0; symbol < 4; symbol++) + { + Assert.Equal(defaults[symbol], copy.DeltaLoopFilterMultiAbsolute[channel][symbol]); + Assert.Equal(defaults[symbol], fresh.DeltaLoopFilterMultiAbsolute[channel][symbol]); + } + } + } + [Fact] public void ProbabilityCostTableMatchesDefinition() {