diff --git a/HEIF_IMPLEMENTATION_PLAN.md b/HEIF_IMPLEMENTATION_PLAN.md
index a4e570b0da..7ceea809e0 100644
--- a/HEIF_IMPLEMENTATION_PLAN.md
+++ b/HEIF_IMPLEMENTATION_PLAN.md
@@ -824,7 +824,7 @@ Encoder verification contract:
- [~] Encoder mode information now uses a frame-owned reference grid over its contiguous allocation, matching current libaom's `mi_grid_base` and `mi_alloc` ownership without per-block tail copies. Signed relative neighbor lookup, 4x4-unit addressing, and mutable selected skip syntax have focused contracts; complete mode decision still remains.
- [~] Final block decisions now use contiguous value storage with palette and prediction syntax inline. Macroblock edge and neighbor state is reused by the entropy-coding operation instead of allocating one managed object per final block; directional deltas retain their signed range, while complete mode decision still remains.
- [~] Finalized transform coefficients and packed EOB/type state now use raster-ordered, per-superblock plane segments matching current libaom's coefficient-pool geometry. One ImageSharp allocator owner replaces libaom's separate coefficient, EOB, and entropy-context allocations while preserving the full 1024 luma and 256-per-chroma 4x4 state capacity of a 128x128 4:2:0 superblock; the forward transform and mode-decision stages still need to populate this owner.
-- [~] Tile partition writing now follows current libaom's recursive `write_modes_sb` preorder traversal and `update_ext_partition_context` edge updates directly. The obsolete SVT-derived global geometry catalog and its unimplemented lookup are removed; transform geometry is derived in libaom's bounded 64x64 residual order from the selected block transform size, frame-edge and segmentation syntax use mode-information units, and 128x128 CDEF units use libaom's 0-to-3 indexing and first-block strength ownership. Partition analysis and selected-transform syntax still need to populate and encode these retained decisions.
+- [~] Tile partition writing now follows current libaom's recursive `write_modes_sb` preorder traversal and `update_ext_partition_context` edge updates directly. The obsolete SVT-derived global geometry catalog and its unimplemented lookup are removed; transform geometry is derived in libaom's bounded 64x64 residual order, fixed intra transform-size symbols use the reference depth and neighbor contexts, frame-edge and segmentation syntax use mode-information units, and 128x128 CDEF units use libaom's 0-to-3 indexing and first-block strength ownership. Partition and mode analysis still need to populate these retained decisions; variable inter-transform syntax remains part of later inter-frame support.
- [ ] Implement legal deblocking, CDEF, restoration, super-resolution, and film-grain signaling decisions.
- [~] The coefficient symbol encoder now reuses tile-lifetime level and context workspaces instead of allocating per transform. Every remaining encoder fragment must be audited before it becomes active.
- [~] The planar conversion, forward transform, and forward quantizer use descending SIMD dispatch: Vector512, Vector256, Vector128, then scalar. Apply the same rule to every later hot-path family.
diff --git a/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolEncoder.cs b/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolEncoder.cs
index d9f96129c1..30ba8621a0 100644
--- a/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolEncoder.cs
+++ b/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolEncoder.cs
@@ -101,6 +101,11 @@ internal class Av1SymbolEncoder : IDisposable
///
private readonly Av1Distribution[][][] intraExtendedTransform;
+ ///
+ /// The tile-adaptive fixed transform-size distributions.
+ ///
+ private readonly Av1Distribution[][] transformSize;
+
///
/// The tile-adaptive spatial segment-identifier distributions.
///
@@ -175,6 +180,7 @@ internal class Av1SymbolEncoder : IDisposable
this.filterIntraMode = Av1DefaultDistributions.FilterIntraMode;
this.deltaQuantizerAbsolute = Av1DefaultDistributions.DeltaQuantizerAbsolute;
this.intraExtendedTransform = Av1DefaultDistributions.IntraExtendedTransform;
+ this.transformSize = Av1DefaultDistributions.TransformSize;
this.segmentId = Av1DefaultDistributions.SegmentId;
this.angleDelta = Av1DefaultDistributions.AngleDelta;
this.skip = Av1DefaultDistributions.Skip;
@@ -425,6 +431,36 @@ internal class Av1SymbolEncoder : IDisposable
w.WriteSymbol(skip, this.transformBlockSkip[(int)transformSizeContext][skipContext]);
}
+ ///
+ /// Writes the selected transform size as its subdivision depth from the block maximum.
+ ///
+ /// The block size defining the maximum transform.
+ /// The selected transform size.
+ /// The neighboring transform-size context.
+ public void WriteTransformSize(Av1BlockSize blockSize, Av1TransformSize transformSize, int context)
+ {
+ Av1TransformSize maximumTransformSize = blockSize.GetMaximumTransformSize();
+ Av1TransformSize currentTransformSize = maximumTransformSize;
+ int categoryDepth = 0;
+ while (currentTransformSize != Av1TransformSize.Size4x4)
+ {
+ categoryDepth++;
+ currentTransformSize = currentTransformSize.GetSubSize();
+ }
+
+ int selectedDepth = 0;
+ currentTransformSize = maximumTransformSize;
+ while (currentTransformSize != transformSize && selectedDepth < Av1Constants.MaxVarTransform)
+ {
+ selectedDepth++;
+ currentTransformSize = currentTransformSize.GetSubSize();
+ }
+
+ DebugGuard.IsTrue(currentTransformSize == transformSize, nameof(transformSize));
+ ref Av1SymbolWriter w = ref this.writer;
+ w.WriteSymbol(selectedDepth, this.transformSize[categoryDepth - 1][context]);
+ }
+
///
/// Finalizes the range-coded tile payload and transfers ownership of its memory.
///
diff --git a/src/ImageSharp/Formats/Heif/Av1/Tiling/Av1TileWriter.cs b/src/ImageSharp/Formats/Heif/Av1/Tiling/Av1TileWriter.cs
index 941c5c4c77..a1024cbfa6 100644
--- a/src/ImageSharp/Formats/Heif/Av1/Tiling/Av1TileWriter.cs
+++ b/src/ImageSharp/Formats/Heif/Av1/Tiling/Av1TileWriter.cs
@@ -748,6 +748,15 @@ internal partial class Av1TileWriter
}
}
+ WriteTransformSize(
+ pcs,
+ writer,
+ macroBlockModeInfo,
+ macroBlock,
+ blockSize,
+ blockOrigin,
+ tile_idx);
+
if (!skipWritingCoefficients)
{
EncodeCoefficients1d(
@@ -770,6 +779,66 @@ internal partial class Av1TileWriter
UpdateNeighbors(pcs, entropyCodingContext, blockOrigin, ref blk_ptr, tile_idx, blockSize);
}
+ ///
+ /// Writes or derives the block transform size and publishes its edge contexts.
+ ///
+ /// The picture coding state.
+ /// The tile symbol encoder.
+ /// The selected block modes.
+ /// The reusable macroblock edge and neighbor state.
+ /// The block size.
+ /// The block origin in samples.
+ /// The zero-based tile index.
+ internal static void WriteTransformSize(
+ Av1PictureControlSet pcs,
+ Av1SymbolEncoder writer,
+ Av1MacroBlockModeInfo macroBlockModeInfo,
+ Av1MacroBlockD macroBlock,
+ Av1BlockSize blockSize,
+ Point blockOrigin,
+ int tileIndex)
+ {
+ ObuFrameHeader frameHeader = pcs.Parent.FrameHeader;
+ bool isLossless = frameHeader.LosslessArray[macroBlockModeInfo.Block.SegmentId];
+ bool writesTransformSize = !isLossless &&
+ frameHeader.TransformMode == Av1TransformMode.Select &&
+ blockSize > Av1BlockSize.Block4x4;
+ Av1TransformSize transformSize = isLossless
+ ? Av1TransformSize.Size4x4
+ : writesTransformSize
+ ? macroBlockModeInfo.Block.TransformSize
+ : blockSize.GetMaximumTransformSize();
+
+ macroBlockModeInfo.Block.TransformSize = transformSize;
+ Av1NeighborArrayUnit transformContexts = pcs.TransformFunctionContexts[tileIndex];
+ if (writesTransformSize)
+ {
+ Av1TransformSize maximumTransformSize = blockSize.GetMaximumTransformSize();
+ int above = transformContexts.Top[transformContexts.GetTopIndex(blockOrigin)] >= maximumTransformSize.GetWidth() ? 1 : 0;
+ int left = transformContexts.Left[transformContexts.GetLeftIndex(blockOrigin)] >= maximumTransformSize.GetHeight() ? 1 : 0;
+ int context = macroBlock.IsUpAvailable
+ ? macroBlock.IsLeftAvailable ? above + left : above
+ : macroBlock.IsLeftAvailable ? left : 0;
+
+ writer.WriteTransformSize(blockSize, transformSize, context);
+ }
+
+ Size blockDimensions = new(blockSize.GetWidth(), blockSize.GetHeight());
+
+ // Above entries retain transform widths and left entries retain heights, including rectangular selections.
+ transformContexts.UnitModeWrite(
+ (byte)transformSize.GetWidth(),
+ blockOrigin,
+ blockDimensions,
+ Av1NeighborArrayUnit.UnitMask.Top);
+
+ transformContexts.UnitModeWrite(
+ (byte)transformSize.GetHeight(),
+ blockOrigin,
+ blockDimensions,
+ Av1NeighborArrayUnit.UnitMask.Left);
+ }
+
///
/// Writes the chroma intra mode, chroma-from-luma alpha values, and directional angle adjustment for a block.
///
diff --git a/tests/ImageSharp.Tests/Formats/Heif/Av1/Av1CoefficientsEntropyTests.cs b/tests/ImageSharp.Tests/Formats/Heif/Av1/Av1CoefficientsEntropyTests.cs
index ee2e035547..89635e9f47 100644
--- a/tests/ImageSharp.Tests/Formats/Heif/Av1/Av1CoefficientsEntropyTests.cs
+++ b/tests/ImageSharp.Tests/Formats/Heif/Av1/Av1CoefficientsEntropyTests.cs
@@ -346,6 +346,70 @@ public class Av1CoefficientsEntropyTests
}
}
+ [Fact]
+ public void SelectedTransformSizeRoundTripsAndPublishesRectangularEdgeContexts()
+ {
+ Av1PictureControlSet picture = CreateEncoderPicture(16, 16);
+ picture.Parent.FrameHeader.TransformMode = Av1TransformMode.Select;
+ Av1MacroBlockModeInfo modeInfo = picture.ModeInfoAllocation[0].MacroBlockModeInfo;
+ modeInfo.Block.BlockSize = Av1BlockSize.Block16x32;
+ modeInfo.Block.TransformSize = Av1TransformSize.Size8x8;
+ modeInfo.Block.SegmentId = 0;
+ Point blockOrigin = new(16, 16);
+ Av1MacroBlockD macroBlock = new()
+ {
+ Tile = new Av1TileInfo(0, 0, picture.Parent.FrameHeader),
+ IsUpAvailable = true,
+ IsLeftAvailable = true
+ };
+
+ using Av1NeighborArrayUnit transforms = new(
+ Configuration.Default,
+ leftSize: 64,
+ topSize: 64,
+ topLeftSize: 128)
+ {
+ GranularityNormalLog2 = Av1Constants.ModeInfoSizeLog2,
+ GranularityTopLeftLog2 = Av1Constants.ModeInfoSizeLog2
+ };
+
+ int topIndex = transforms.GetTopIndex(blockOrigin);
+ int leftIndex = transforms.GetLeftIndex(blockOrigin);
+ transforms.Top[topIndex] = 16;
+ transforms.Left[leftIndex] = 16;
+ picture.TransformFunctionContexts = [transforms];
+
+ Av1SymbolEncoder writer = new(Configuration.Default, 64, BaseQIndex);
+ Av1TileWriter.WriteTransformSize(
+ picture,
+ writer,
+ modeInfo,
+ macroBlock,
+ modeInfo.Block.BlockSize,
+ blockOrigin,
+ tileIndex: 0);
+
+ using IMemoryOwner encoded = writer.Exit();
+ writer.Dispose();
+
+ Av1SymbolDecoder reader = new(Configuration.Default, encoded.GetSpan(), BaseQIndex);
+ Assert.Equal(
+ Av1TransformSize.Size8x8,
+ reader.ReadTransformSize(Av1BlockSize.Block16x32, context: 1));
+
+ for (int index = 0; index < transforms.Top.Length; index++)
+ {
+ byte expected = index >= topIndex && index < topIndex + 4 ? (byte)8 : (byte)0;
+ Assert.Equal(expected, transforms.Top[index]);
+ }
+
+ for (int index = 0; index < transforms.Left.Length; index++)
+ {
+ byte expected = index >= leftIndex && index < leftIndex + 8 ? (byte)8 : (byte)0;
+ Assert.Equal(expected, transforms.Left[index]);
+ }
+ }
+
[Theory]
[InlineData((int)Av1PartitionType.None, 24, 24)]
[InlineData((int)Av1PartitionType.Horizontal, 24, 28)]
@@ -510,10 +574,21 @@ public class Av1CoefficientsEntropyTests
GranularityTopLeftLog2 = 2
};
+ using Av1NeighborArrayUnit transforms = new(
+ Configuration.Default,
+ leftSize: 16,
+ topSize: 32,
+ topLeftSize: 48)
+ {
+ GranularityNormalLog2 = 2,
+ GranularityTopLeftLog2 = 2
+ };
+
picture.PartitionContexts = [partitions];
picture.LuminanceDcSignLevelCoefficientNeighbors = [luma];
picture.CrDcSignLevelCoefficientNeighbors = [red];
picture.CbDcSignLevelCoefficientNeighbors = [blue];
+ picture.TransformFunctionContexts = [transforms];
Av1TileInfo tile = new(0, 0, picture.Parent.FrameHeader);
Point[] blockPositions = [new(16, 0), new(24, 0), new(16, 8), new(24, 8)];
Av1EncoderBlockStruct[] blocks = new Av1EncoderBlockStruct[blockPositions.Length];
@@ -579,6 +654,16 @@ public class Av1CoefficientsEntropyTests
{
Assert.Equal(24, partitions.Left[index].Left);
}
+
+ for (int index = 0; index < transforms.Top.Length; index++)
+ {
+ Assert.Equal(index < 16 ? 0 : 32, transforms.Top[index]);
+ }
+
+ for (int index = 0; index < transforms.Left.Length; index++)
+ {
+ Assert.Equal(32, transforms.Left[index]);
+ }
}
[Fact]