diff --git a/HEIF_IMPLEMENTATION_PLAN.md b/HEIF_IMPLEMENTATION_PLAN.md index 126aec4550..322d99c6b4 100644 --- a/HEIF_IMPLEMENTATION_PLAN.md +++ b/HEIF_IMPLEMENTATION_PLAN.md @@ -283,6 +283,32 @@ Frame/block RD and decoder filter follow-up after `aa2ecf690`: superblock modifier from the range of subblock variances (`partition_search.c:5721-5734`). `Av1IntraSuperblockEncoder.ModeDecision.cs:172-176` uses only the segment-zero base quantizer and intra flag. These missing policies remain architectural deviations; no isolated multiplier adjustment was introduced. +- The default `disable_trellis_quant = 3` (`av1/av1_cx_iface.c:291`) means + `NO_ESTIMATE_YRD_TRELLIS_OPT`, not final-pass-only optimization (`speed_features.c:2493-2513`). + `encodemb.h:157-162` and `tx_search.c:2084-2085,2216-2229` still allow optimization during transform + candidate evaluation under that default. Control value 2 selects final-pass-only behavior. A winner-only + optimizer would therefore leave the default production path incomplete. +- The complete optimizer traversal at `av1/encoder/txb_rdopt.c:18-560` and its cost/dequantization helpers + (`txb_rdopt_utils.h:39-204`) were followed through level lowering, EOB replacement, empty-transform selection, + signed DC handling, plane/precision/tuning scaling, and joint rate/EOB/context publication. It retains only + three nonzero positions for the EOB phase, then changes traversal after that phase's bound is exceeded. + That bound belongs to this complete traversal; it is not a general candidate-pruning threshold. + Managed `Av1SymbolEncoder.cs:1183-1334` evaluates an unchanged coefficient vector and + `Av1TransformBlockEncoder.cs:1175-1225` stops at fast quantization. Existing scratch can support parts of the + arithmetic, but cost-state policy, evaluation stages, mutation, and reconstruction must be integrated together. +- The caller policy also changes quantization, not only the decision to invoke trellis: + `tx_search.c:1964-2000,2147-2156,2216-2229` derives the MSE/SATD gates from the active evaluation stage, + switches between fast and regular quantization, then optimizes before distortion/reconstruction. + Threshold tables and default/mode/winner selection live in `speed_features.c:76-100` and `rd.h:353-381`. + Managed candidate reconstruction currently happens before `GetCoefficientCost` + (`Av1TransformBlockEncoder.cs:187-251`, `Av1IntraSuperblockEncoder.ModeDecision.cs:2494-2550`). + Adding optimization to that later cost call would reconstruct twice or leave candidate pixels stale. +- The optional residual border policy is selected in `encoder.c:4559-4568`: GOOD mode, objective delta-Q, + TPL enabled, no AQ/segmentation/ROI/QP sweep/ducky path, and sharpness other than three. + `encodemb.c:80-173` fills outside-visible residuals using a whole-block mean, per-axis mean, or zero, + depending on transform type. Thus border residual reuse across transform candidates is conditional. + This policy is not an unconditional replacement for coded-edge replication, and is not enabled for ALLINTRA. + No isolated border-padding rule was added; its configuration and candidate integration remain open. - Transform pixel-error normalization and the modeled-rate skip comparison were also traced through `Av1TransformBlockEncoder.cs:577-590`, `Av1RateDistortion.cs:259-307`, native `av1/encoder/model_rd.h:70-106,162-199`, and `av1/encoder/tx_search.c:979-1051`. @@ -294,6 +320,32 @@ Frame/block RD and decoder filter follow-up after `aa2ecf690`: retains bottom lines for its worker-capable traversal. This inspection establishes no decoder-wide or SIMD completeness claim. Decoder CDEF storage remains an operation-scoped owner, not native reusable worker state. +Range-writer output-capacity correction, verified after `93aba785f`: + +- `Av1SymbolWriter.cs:213-214,339` before correction sliced a fixed initial allocation for finalization + and eight-byte flushes. Reference `aom_dsp/entenc.c:78-91,270-285` grows capacity when either needs + more room. The packet estimate in `Av1FrameEncoder.cs:544-563` does not replace that range-coder policy. + This is a demonstrated writer-capacity deviation; no production frame overflowing that estimate was established. +- The existing owner now grows at those two boundaries. Word flushes double the current tile capacity and add + eight bytes; finalization reserves its exact terminating length. Reallocation preserves finalized preceding + tiles and the current completed prefix, including bytes that can receive a backward carry. Pending bits stay + in the range state. The old owner is returned only after successful allocation/copy, and reset reuses capacity. + Existing frame aggregation remains; this does not complete native deferred-packing or worker ownership parity. +- Before correction, the zero-capacity consecutive-tile regression failed in `Normalize` with + `ArgumentOutOfRangeException` (`writer-growth-red.trx`); VSTest stopped on that first failure. + Five small initial capacities now preserve three consecutive tiles byte-for-byte against sufficient-capacity + encoding, with CDF adaptation enabled/disabled. Existing native carry assertions cover two additional + finalization-growth capacities. Allocation limits independently exercise failure at both growth boundaries, + and allocation identities verify every successful owner is returned exactly once. +- Final Release .NET 11 incremental build: zero errors and warnings; preceding test compilation: + 1,009 existing warnings. Roslynk reports zero compiler errors. Serialized Visual Studio VSTest passes + 2,292/2,292 entropy, intra-superblock, encoder-frame, and HEIF encoder cases in 26.9726 seconds + (`writer-growth-final.trx`), including the twelve focused writer cases. +- Current optimized libaom decoding of the regenerated 23 palette, eight partition, and twelve color-sequence + streams matches all 38,973 samples exactly: maximum error 0, zero samples exceeding one. + These are bounded same-bitstream checks, not separate-encoder parity or a timing/quality improvement. + No benchmark was run. All temporary native comparison output and test reports remain excluded from commits. + Restoration processing-unit correction: - Before correction, `Av1LoopRestorationDecoder.cs:147-166,309-358` sized its bordered source, Wiener diff --git a/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolEncoder.cs b/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolEncoder.cs index f706004430..1720c5898e 100644 --- a/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolEncoder.cs +++ b/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolEncoder.cs @@ -222,7 +222,7 @@ internal sealed class Av1SymbolEncoder : IDisposable /// Initializes a new instance of the class with reusable tile state. /// /// The configuration providing output and temporary memory. - /// The complete fixed output allocation length in bytes. + /// The initial output capacity in bytes. /// The frame base quantizer index. /// A value indicating whether encoded symbols adapt their tile distributions. public Av1SymbolEncoder(Configuration configuration, int bufferLength, int qIndex, bool updateCdf) @@ -1527,7 +1527,7 @@ internal sealed class Av1SymbolEncoder : IDisposable /// Exposes a prefix containing every consecutively encoded tile without copying their bytes. /// /// The number of bytes in the prefix. - /// The encoded prefix, valid until this encoder is reset to offset zero or disposed. + /// The encoded prefix, valid until this encoder is reset or disposed. public ReadOnlyMemory GetOutput(int length) => this.writer.GetOutput(length); diff --git a/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolWriter.cs b/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolWriter.cs index a8110cd755..6b26d84d2e 100644 --- a/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolWriter.cs +++ b/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolWriter.cs @@ -46,14 +46,14 @@ internal sealed class Av1SymbolWriter : IDisposable private readonly Configuration configuration; /// - /// The owner of the fixed output buffer shared by consecutively encoded tiles. + /// The owner of the output buffer shared by consecutively encoded tiles. /// - private readonly IMemoryOwner bufferOwner; + private IMemoryOwner bufferOwner; /// /// The complete requested output allocation, including every consecutively encoded tile. /// - private readonly Memory outputBuffer; + private Memory outputBuffer; /// /// The requested output range, excluding any excess capacity returned by a pooling allocator. @@ -71,10 +71,10 @@ internal sealed class Av1SymbolWriter : IDisposable private int position; /// - /// Initializes a new instance of the class with a bounded output size. + /// Initializes a new instance of the class. /// /// The configuration that supplies output allocation. - /// The complete fixed output allocation length in bytes. + /// The initial output capacity in bytes. /// A value indicating whether encoded symbols adapt their distributions. public Av1SymbolWriter(Configuration configuration, int bufferLength, bool updateCdf) { @@ -86,7 +86,7 @@ internal sealed class Av1SymbolWriter : IDisposable } /// - /// Restores the initial range-coder state while retaining the bounded output allocation. + /// Restores the initial range-coder state and begins a new output sequence. /// public void Reset() => this.Reset(0); @@ -192,7 +192,7 @@ internal sealed class Av1SymbolWriter : IDisposable /// Exposes a prefix containing consecutively encoded tiles without copying their bytes. /// /// The number of bytes in the prefix. - /// The encoded prefix, valid until this writer is reset to offset zero or disposed. + /// The encoded prefix, valid until this writer is reset or disposed. public ReadOnlyMemory GetOutput(int length) => this.outputBuffer[..length]; /// @@ -211,6 +211,12 @@ internal sealed class Av1SymbolWriter : IDisposable ulong e = ((l + m) & ~m) | (m + 1); s += c; int pendingByteCount = Math.Max((s + 7) >> 3, 0); + if (pos + pendingByteCount > this.buffer.Length) + { + // Finalization needs only the terminating bytes; ordinary word flushes reserve their own headroom. + this.ResizeBuffer(pos + pendingByteCount); + } + Span buffer = this.buffer.Span[..(pos + pendingByteCount)]; if (s > 0) { @@ -336,6 +342,13 @@ internal sealed class Av1SymbolWriter : IDisposable // bytes together while preserving one carry bit. if (s >= 40) { + if (this.position + sizeof(ulong) > this.buffer.Length) + { + // A word store touches eight bytes even when fewer become logical output. Double the current + // tile capacity and add one word, matching the range coder's amortized growth from an empty buffer. + this.ResizeBuffer(checked((2 * this.buffer.Length) + sizeof(ulong))); + } + Span buffer = this.buffer.Span[..(this.position + sizeof(ulong))]; int readyByteCount = (s >> 3) + 1; c += 24 - (readyByteCount << 3); @@ -365,6 +378,27 @@ internal sealed class Av1SymbolWriter : IDisposable this.cnt = s; } + /// + /// Replaces the output owner while retaining finalized tiles and the current tile's completed bytes. + /// + /// The required capacity starting at the current tile's output offset. + private void ResizeBuffer(int tileCapacity) + { + int outputOffset = this.outputBuffer.Length - this.buffer.Length; + int capacity = checked(outputOffset + tileCapacity); + IMemoryOwner replacement = this.configuration.MemoryAllocator.Allocate(capacity); + Memory replacementBuffer = replacement.Memory[..capacity]; + + // Previous tile bytes remain part of the frame payload. The current tile's completed prefix also carries + // backward into earlier bytes, so preserve that prefix before returning the old owner. Pending bits stay + // in low/cnt and need no copy. If allocation fails, the original owner remains available for disposal. + this.outputBuffer.Span[..(outputOffset + this.position)].CopyTo(replacementBuffer.Span); + this.bufferOwner.Dispose(); + this.bufferOwner = replacement; + this.outputBuffer = replacementBuffer; + this.buffer = replacementBuffer[outputOffset..]; + } + /// /// Adds a carry to the completed output prefix. /// diff --git a/src/ImageSharp/Formats/Heif/Av1/Pipeline/Av1FrameEncoder.cs b/src/ImageSharp/Formats/Heif/Av1/Pipeline/Av1FrameEncoder.cs index 0dd214715f..69ef8c6f70 100644 --- a/src/ImageSharp/Formats/Heif/Av1/Pipeline/Av1FrameEncoder.cs +++ b/src/ImageSharp/Formats/Heif/Av1/Pipeline/Av1FrameEncoder.cs @@ -545,6 +545,7 @@ internal static class Av1FrameEncoder { // Libaom reserves 2.5 times the 32-sample-aligned native input for an all-intra output packet. // Counting the active planes directly retains that headroom without charging monochrome for unused chroma. + // This is an initial estimate: the range writer grows if encoded syntax exceeds its remaining capacity. int alignedWidth = Av1Math.AlignPowerOf2(width, OutputAlignmentLog2); int alignedHeight = Av1Math.AlignPowerOf2(height, OutputAlignmentLog2); int subsamplingX = colorConfig.SubSamplingX ? 1 : 0; diff --git a/tests/ImageSharp.Tests/Formats/Heif/Av1/Av1EntropyTests.cs b/tests/ImageSharp.Tests/Formats/Heif/Av1/Av1EntropyTests.cs index ce9a817545..bbaddfb107 100644 --- a/tests/ImageSharp.Tests/Formats/Heif/Av1/Av1EntropyTests.cs +++ b/tests/ImageSharp.Tests/Formats/Heif/Av1/Av1EntropyTests.cs @@ -931,10 +931,13 @@ public class Av1EntropyTests int expected) => Assert.Equal(expected, Av1RateDistortion.GetInterFrameRateMultiplier(qIndex, (Av1BitDepth)bitDepth)); - [Fact] - public void SymbolWriterMatchesCurrentLibaomCarryRegression() + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(ShortSyntaxBufferLength)] + public void SymbolWriterMatchesCurrentLibaomCarryRegression(int initialCapacity) { - using Av1SymbolWriter writer = new(Configuration.Default, ShortSyntaxBufferLength, updateCdf: false); + using Av1SymbolWriter writer = new(Configuration.Default, initialCapacity, updateCdf: false); writer.WriteBoolean(false, 16_384); writer.WriteBoolean(false, 16_384); writer.WriteBoolean(true, 512); @@ -968,6 +971,105 @@ public class Av1EntropyTests Assert.Equal(allocation.HashCodeOfBuffer, returned.HashCodeOfBuffer); } + [Theory] + [InlineData(0, false)] + [InlineData(1, true)] + [InlineData(7, false)] + [InlineData(8, true)] + [InlineData(17, true)] + public void SymbolWriterGrowthPreservesConsecutiveTiles(int initialCapacity, bool updateCdf) + { + TestMemoryAllocator allocator = new(); + allocator.EnableNonThreadSafeLogging(); + Configuration configuration = Configuration.Default.Clone(); + configuration.MemoryAllocator = allocator; + + using (Av1SymbolWriter writer = new(configuration, initialCapacity, updateCdf)) + using (Av1SymbolWriter expected = new(Configuration.Default, 8192, updateCdf)) + { + int outputLength = 0; + for (int tile = 0; tile < 3; tile++) + { + writer.Reset(outputLength); + expected.Reset(outputLength); + Av1Distribution distribution = new(100, 16000, 32000); + Av1Distribution expectedDistribution = new(100, 16000, 32000); + for (int index = 0; index < 257; index++) + { + // Small intervals provoke carries while literals cross repeated word-flush boundaries. + int symbol = (index + tile) & 3; + uint literal = (uint)((index * 73) + tile); + writer.WriteSymbol(symbol, distribution); + writer.WriteLiteral(literal, 8); + expected.WriteSymbol(symbol, expectedDistribution); + expected.WriteLiteral(literal, 8); + } + + ReadOnlyMemory actualTile = writer.Exit(out int length); + ReadOnlyMemory expectedTile = expected.Exit(out int expectedLength); + Assert.Equal(expectedLength, length); + Assert.True(expectedTile.Span.SequenceEqual(actualTile.Span)); + outputLength += length; + + // Growth in a later tile must preserve all earlier finalized tile bytes too. + Assert.True(expected.GetOutput(outputLength).Span.SequenceEqual(writer.GetOutput(outputLength).Span)); + } + + Assert.True(allocator.AllocationLog.Count > 1); + Assert.Equal(allocator.AllocationLog.Count - 1, allocator.ReturnLog.Count); + int allocations = allocator.AllocationLog.Count; + writer.Reset(); + writer.WriteLiteral(false); + _ = writer.Exit(out _); + Assert.Equal(allocations, allocator.AllocationLog.Count); + } + + Assert.Equal( + allocator.AllocationLog.Select(x => x.AllocationId).Order(), + allocator.ReturnLog.Select(x => x.AllocationId).Order()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void SymbolWriterGrowthFailureRetainsItsOwner(bool failDuringFinalization) + { + int initialCapacity = failDuringFinalization ? 1 : 8; + OutputLimitedAllocator allocator = new(initialCapacity); + allocator.EnableNonThreadSafeLogging(); + Configuration configuration = Configuration.Default.Clone(); + configuration.MemoryAllocator = allocator; + + using (Av1SymbolWriter writer = new(configuration, initialCapacity, updateCdf: false)) + { + Assert.Throws(() => + { + if (failDuringFinalization) + { + writer.WriteBoolean(false, 16_384); + writer.WriteBoolean(false, 16_384); + writer.WriteBoolean(true, 512); + writer.WriteBoolean(false, 8_192); + _ = writer.Exit(out _); + } + else + { + for (int index = 0; index < 32; index++) + { + writer.WriteLiteral((uint)index, 8); + } + } + }); + + Assert.Single(allocator.AllocationLog); + Assert.Empty(allocator.ReturnLog); + } + + Assert.Equal( + Assert.Single(allocator.AllocationLog).AllocationId, + Assert.Single(allocator.ReturnLog).AllocationId); + } + [Fact] public void SymbolWriterResetReusesExistingOutputAllocation() { @@ -2268,6 +2370,12 @@ public class Av1EntropyTests return result; } + // Exercise the allocator's actual contiguous-buffer boundary while retaining the existing owner log. + private sealed class OutputLimitedAllocator : TestMemoryAllocator + { + public OutputLimitedAllocator(int limit) => this.SingleBufferAllocationLimitBytes = limit; + } + public static TheoryData GetInterTransformTypeData() { TheoryData result = [];