From 901e976d7eba0567802c61469e199d5ed9c68a04 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Wed, 26 Aug 2026 14:39:49 +1000 Subject: [PATCH] Document AV1 film grain synthesis --- .../Pipeline/FilmGrain/Av1FilmGrainDecoder.cs | 113 +++++++++++++++++- 1 file changed, 111 insertions(+), 2 deletions(-) diff --git a/src/ImageSharp/Formats/Heif/Av1/Pipeline/FilmGrain/Av1FilmGrainDecoder.cs b/src/ImageSharp/Formats/Heif/Av1/Pipeline/FilmGrain/Av1FilmGrainDecoder.cs index c34100171..69ae59ae2 100644 --- a/src/ImageSharp/Formats/Heif/Av1/Pipeline/FilmGrain/Av1FilmGrainDecoder.cs +++ b/src/ImageSharp/Formats/Heif/Av1/Pipeline/FilmGrain/Av1FilmGrainDecoder.cs @@ -10,8 +10,11 @@ using SixLabors.ImageSharp.Memory; namespace SixLabors.ImageSharp.Formats.Heif.Av1.Pipeline.FilmGrain; /// -/// Synthesizes the film-grain signal carried by one independently decodable AV1 still-image frame. +/// Synthesizes the film-grain signal carried by one displayed AV1 image frame. /// +/// +/// Grain is applied after loop restoration to presentation samples and is not part of reference reconstruction. +/// internal sealed class Av1FilmGrainDecoder { /// @@ -73,7 +76,7 @@ internal sealed class Av1FilmGrainDecoder /// Initializes a new instance of the class. /// /// The sequence header defining bit depth and chroma sampling. - /// The frame header containing the complete still-frame grain parameters. + /// The frame header containing the complete grain parameters. /// The restored frame samples to which grain is added. public Av1FilmGrainDecoder( ObuSequenceHeader sequenceHeader, @@ -91,6 +94,9 @@ internal sealed class Av1FilmGrainDecoder public void DecodeFrame() { ObuFilmGrainParameters parameters = this.frameHeader.FilmGrainParameters; + + // Film grain is a presentation process. A frame which does not signal it must retain the restored samples + // byte-for-byte, so the decoder does not allocate templates or touch the padded frame planes in this case. if (!parameters.ApplyGrain) { return; @@ -102,15 +108,23 @@ internal sealed class Av1FilmGrainDecoder int subsamplingY = !isMonochrome && colorConfig.SubSamplingY ? 1 : 0; int visibleWidth = this.frameBuffer.Width; int visibleHeight = this.frameBuffer.Height; + + // Grain blocks are traversed in half-resolution luma coordinates and expanded in 2x2 sample groups. + // Replicating an odd final row or column makes that traversal complete without changing the visible extent. int alignedWidth = Av1Math.AlignPowerOf2(visibleWidth, 1); int alignedHeight = Av1Math.AlignPowerOf2(visibleHeight, 1); Buffer2D lumaBuffer = this.frameBuffer.BufferY!; + + // Frame planes are allocated as bytes even for high-bit-depth pictures. Convert their byte strides to + // native sample strides once so every later offset is expressed consistently in samples. int lumaStride = lumaBuffer.Width / this.frameBuffer.BytesPerSample; int chromaStride = isMonochrome ? 0 : this.frameBuffer.BufferCb!.Width / this.frameBuffer.BytesPerSample; + // Closing ApplyGrain over byte or ushort keeps synthesis in the frame buffer's native representation. + // This avoids an intermediate converted image while allowing the JIT to remove the sample-type branches. if (this.frameBuffer.BytesPerSample == 2) { Span luma = GetPlaneSamples( @@ -216,6 +230,9 @@ internal sealed class Av1FilmGrainDecoder { Span samples = MemoryMarshal.Cast(buffer.DangerousGetSingleSpan()); int stride = buffer.Width / Unsafe.SizeOf(); + + // The returned span intentionally retains the allocation beyond the visible rectangle. Film-grain overlap + // and odd-dimension extension use the frame buffer's existing right and bottom padding through this stride. return samples[((originY * stride) + originX)..]; } @@ -240,6 +257,7 @@ internal sealed class Av1FilmGrainDecoder { if (visibleWidth != alignedWidth) { + // The synthetic column is consumed only as the partner of the final visible sample in a 2x2 group. for (int row = 0; row < visibleHeight; row++) { int rowOffset = row * stride; @@ -284,6 +302,8 @@ internal sealed class Av1FilmGrainDecoder bool isMonochrome) where TSample : unmanaged { + // A template contains a selectable 64x64 luma region, the maximum three-sample autoregressive history, + // and the fixed margins required by the block-offset process. Chroma dimensions contract with sampling. int chromaSubblockHeight = LumaSubblockSize >> subsamplingY; int chromaSubblockWidth = LumaSubblockSize >> subsamplingX; int lumaBlockHeight = TemplatePadding + (2 * AutoregressivePadding) + (2 * LumaSubblockSize); @@ -299,6 +319,9 @@ internal sealed class Av1FilmGrainDecoder int lumaGrainLength = lumaBlockHeight * lumaBlockWidth; int chromaGrainLength = isMonochrome ? 0 : chromaBlockHeight * chromaBlockWidth; int scalingLength = isMonochrome ? 256 : 768; + + // Overlap keeps the outgoing two luma rows/columns, or their subsampled chroma equivalents, until the + // adjacent block is selected. Frames without overlap do not reserve these line and column workspaces. int lumaLineLength = parameters.OverlapFlag ? lumaStride * 2 : 0; int chromaLineLength = parameters.OverlapFlag && !isMonochrome ? chromaStride * (2 >> subsamplingY) @@ -312,6 +335,8 @@ internal sealed class Av1FilmGrainDecoder int scratchLength = scalingLength + lumaGrainLength + (2 * chromaGrainLength) + lumaLineLength + (2 * chromaLineLength) + lumaColumnLength + (2 * chromaColumnLength); + // All frame-lifetime film-grain state shares one allocator-backed owner. The slices below are disjoint, + // and their logical ordering mirrors lookup tables, templates, horizontal boundaries, then vertical boundaries. using IMemoryOwner scratchOwner = this.frameBuffer.MemoryAllocator.Allocate(scratchLength); Span scratch = scratchOwner.GetSpan()[..scratchLength]; int scratchOffset = 0; @@ -362,6 +387,9 @@ internal sealed class Av1FilmGrainDecoder int bitDepth = this.sequenceHeader.ColorConfig.BitDepth.GetBitCount(); ushort randomRegister = (ushort)parameters.GrainSeed; + + // Luma consumes the seed's initial pseudo-random sequence. Chroma generation subsequently reinitializes + // the same register with plane-specific row identities so its two templates remain deterministic and distinct. GenerateLumaGrain( parameters, ref randomRegister, @@ -388,6 +416,8 @@ internal sealed class Av1FilmGrainDecoder bitDepth); } + // Signaled points describe piecewise-linear functions over the eight-bit domain. High-bit-depth samples + // interpolate between these 256 entries later, rather than allocating larger per-depth lookup tables. InitializeScalingFunction(parameters.PointYValue, parameters.PointYScaling, (int)parameters.NumYPoints, scalingY); if (!isMonochrome) { @@ -415,12 +445,19 @@ internal sealed class Av1FilmGrainDecoder int grainMinimum = -(1 << (bitDepth - 1)); int grainMaximum = (1 << (bitDepth - 1)) - 1; bool isIdentityMatrix = this.sequenceHeader.ColorConfig.MatrixCoefficients == ObuMatrixCoefficients.Identity; + + // Coordinates are halved because each iteration owns one 32x32 luma block but all frame offsets are even. + // Keeping the loop in this domain also makes one-unit boundary adjustments represent two luma samples. for (int halfY = 0; halfY < height / 2; halfY += LumaSubblockSize >> 1) { + // Block rows restart from a seed mixed with their luma row number. This makes a block's selection + // independent of decoder traversal outside its row while remaining reproducible from the bitstream. InitializeRandomGenerator(ref randomRegister, halfY << 1, (ushort)parameters.GrainSeed); for (int halfX = 0; halfX < width / 2; halfX += LumaSubblockSize >> 1) { + // The high and low nibbles choose an even luma offset inside the reusable 64x64 template region. + // Chroma offsets use the corresponding subsampled position so all planes share the same selection. int randomOffset = GetRandomNumber(ref randomRegister, 8); int offsetX = (randomOffset >> 4) & 15; int offsetY = randomOffset & 15; @@ -434,6 +471,8 @@ internal sealed class Av1FilmGrainDecoder if (parameters.OverlapFlag && halfX != 0) { + // Blend the incoming template columns with the outgoing columns saved by the block on the left. + // Writing back to the column buffers produces the exact grain region applied at this boundary. VerticalOverlap( yColumnBuffer, 2, @@ -479,6 +518,9 @@ internal sealed class Av1FilmGrainDecoder } int rowAdjustment = halfY != 0 ? 1 : 0; + + // The top overlap row, when present, is owned by the horizontal-boundary pass below. Skip it here + // so the corner and vertical boundary are each added to the decoded samples exactly once. int destinationLumaOffset = (((halfY + rowAdjustment) << 1) * lumaStride) + (halfX << 1); int destinationChromaOffset = (((halfY + rowAdjustment) << (1 - subsamplingY)) * chromaStride) + (halfX << (1 - subsamplingX)); @@ -528,6 +570,8 @@ internal sealed class Av1FilmGrainDecoder { if (halfX != 0) { + // At an interior corner, first combine the saved top boundary with the already blended left + // boundary. The resulting corner is then part of the horizontal boundary applied below. HorizontalOverlap( yLineBuffer[(halfX << 1)..], lumaStride, @@ -573,6 +617,9 @@ internal sealed class Av1FilmGrainDecoder int overlappedColumn = halfX != 0 ? halfX + 1 : 0; int templateColumnAdjustment = halfX != 0 ? 2 : 0; + + // The horizontal boundary excludes the two luma columns already emitted by vertical overlap. + // The same adjustment contracts to one column for horizontally subsampled chroma. int horizontalWidth = Math.Min( LumaSubblockSize - templateColumnAdjustment, width - (overlappedColumn << 1)); @@ -642,6 +689,8 @@ internal sealed class Av1FilmGrainDecoder ? Span.Empty : crLineBuffer[(halfX << (1 - subsamplingX))..]; + // Apply the completed top boundary as a one-unit half-height strip, which is two luma rows and + // one or two chroma rows depending on vertical subsampling. AddNoiseToBlock( parameters, scalingY, @@ -668,6 +717,9 @@ internal sealed class Av1FilmGrainDecoder int interiorRowAdjustment = parameters.OverlapFlag && halfY != 0 ? 1 : 0; int interiorColumnAdjustment = parameters.OverlapFlag && halfX != 0 ? 1 : 0; + + // Move both the destination and template origins past boundary strips already applied above. This + // leaves a disjoint interior rectangle, including clipped partial blocks at the right and bottom edges. int lumaGrainOffset = ((lumaOffsetY + (interiorRowAdjustment << 1)) * lumaBlockWidth) + lumaOffsetX + (interiorColumnAdjustment << 1); @@ -713,6 +765,8 @@ internal sealed class Av1FilmGrainDecoder { if (halfX != 0) { + // Preserve the completed corner in the line buffers before the column buffers are overwritten. + // It becomes the top input for the block at this column position on the next block row. CopyArea( yColumnBuffer[(LumaSubblockSize << 1)..], 2, @@ -747,6 +801,9 @@ internal sealed class Av1FilmGrainDecoder int lineDestinationColumn = halfX != 0 ? halfX + 1 : 0; int lineTemplateAdjustment = halfX != 0 ? 2 : 0; + + // Save the template's bottom boundary for the block directly below. Columns already represented + // by the corner are skipped so the line buffer remains one contiguous frame-width boundary. int lineWidth = Math.Min(LumaSubblockSize, width - (halfX << 1)) - lineTemplateAdjustment; CopyArea( lumaGrain[(((lumaOffsetY + LumaSubblockSize) * lumaBlockWidth) + @@ -784,6 +841,8 @@ internal sealed class Av1FilmGrainDecoder 2 >> subsamplingY); } + // Finally retain the template's right boundary for the next block in this row. The extra two rows + // extend beyond the nominal block so a later corner blend has both horizontal overlap rows available. CopyArea( lumaGrain[((lumaOffsetY * lumaBlockWidth) + lumaOffsetX + LumaSubblockSize)..], lumaBlockWidth, @@ -843,10 +902,14 @@ internal sealed class Av1FilmGrainDecoder { if (parameters.NumYPoints == 0) { + // Without a luma scaling function no luma grain is ever applied. A zero template is still required when + // chroma autoregression is present because its optional luma predictor must then contribute zero. grain.Clear(); return; } + // The fixed Gaussian table has 12-bit amplitude. GrainScaleShift and the decoded bit depth reduce it to + // the signed working range before the causal autoregressive filter changes its spatial correlation. int gaussianShift = 12 - bitDepth + (int)parameters.GrainScaleShift; int gaussianRounding = (1 << gaussianShift) >> 1; ReadOnlySpan gaussian = Av1FilmGrainGaussianSequence.Samples; @@ -865,6 +928,9 @@ internal sealed class Av1FilmGrainDecoder int grainMinimum = -(1 << (bitDepth - 1)); int grainMaximum = (1 << (bitDepth - 1)) - 1; uint[] coefficients = parameters.ArCoeffsYPlus128!; + + // TemplatePadding leaves every lag-one through lag-three predecessor addressable without a boundary branch. + // Raster order guarantees that all rows above and all samples to the left have already been filtered. for (int row = TemplatePadding; row < height; row++) { for (int column = TemplatePadding; column < width - TemplatePadding; column++) @@ -934,6 +1000,8 @@ internal sealed class Av1FilmGrainDecoder ReadOnlySpan gaussian = Av1FilmGrainGaussianSequence.Samples; if (applyCb) { + // The fixed luma-line identities seven and eleven decorrelate the two chroma pseudo-random sequences + // from each other and from the luma template while retaining deterministic generation from GrainSeed. InitializeRandomGenerator(ref randomRegister, 7 << 5, (ushort)parameters.GrainSeed); FillGaussianGrain(ref randomRegister, cbGrain, height, width, stride, gaussian, gaussianShift, gaussianRounding); } @@ -959,6 +1027,9 @@ internal sealed class Av1FilmGrainDecoder int grainMaximum = (1 << (bitDepth - 1)) - 1; uint[]? cbCoefficients = parameters.ArCoeffsCbPlus128; uint[]? crCoefficients = parameters.ArCoeffsCrPlus128; + + // Cb and Cr share the same causal predecessor walk, so both accumulators advance one coefficient index + // together. A disabled plane stays zero but does not alter the coefficient ordering of the enabled plane. for (int row = TemplatePadding; row < height; row++) { for (int column = TemplatePadding; column < width - TemplatePadding; column++) @@ -1003,6 +1074,8 @@ internal sealed class Av1FilmGrainDecoder if (parameters.NumYPoints != 0) { + // Chroma has one additional autoregressive predictor when luma grain exists. Average the luma + // template footprint represented by this chroma sample before applying that final coefficient. int lumaRow = ((row - TemplatePadding) << subsamplingY) + TemplatePadding; int lumaColumn = ((column - TemplatePadding) << subsamplingX) + TemplatePadding; int averageLuma = 0; @@ -1069,6 +1142,7 @@ internal sealed class Av1FilmGrainDecoder int gaussianShift, int gaussianRounding) { + // Eleven pseudo-random bits address all 2,048 Gaussian entries with no modulo operation or distribution skew. for (int row = 0; row < height; row++) { for (int column = 0; column < width; column++) @@ -1099,11 +1173,16 @@ internal sealed class Av1FilmGrainDecoder uint[] values = pointValues!; uint[] scalings = pointScalings!; + + // Values outside the first and last control points extend their nearest endpoint rather than extrapolating. lookup[..(int)values[0]].Fill((int)scalings[0]); for (int point = 0; point < pointCount - 1; point++) { int deltaY = (int)scalings[point + 1] - (int)scalings[point]; int deltaX = (int)values[point + 1] - (int)values[point]; + + // A rounded Q16 reciprocal performs the piecewise-linear interpolation using integer arithmetic. The + // 32768 bias below rounds each reconstructed scaling value when it returns to integer precision. long delta = deltaY * ((65536 + (deltaX >> 1)) / deltaX); for (int x = 0; x < deltaX; x++) { @@ -1164,6 +1243,8 @@ internal sealed class Av1FilmGrainDecoder bool isIdentityMatrix) where TSample : unmanaged { + // GrainScalingMinus8 stores a shift in the range eight through eleven. The half-unit bias makes the + // signed scaled-grain contribution round before it is added to the restored sample. int scalingShift = (int)parameters.GrainScalingMinus8 + 8; int roundingOffset = 1 << (scalingShift - 1); int depthScale = 1 << (bitDepth - 8); @@ -1174,6 +1255,8 @@ internal sealed class Av1FilmGrainDecoder int chromaMaximum = sampleMaximum; if (parameters.ClipToRestrictedRange) { + // Legal-range constants are specified at eight-bit precision and scale exactly for 10- and 12-bit data. + // Identity matrices carry RGB-like planes, so every plane uses the luma legal range rather than YUV chroma. lumaMinimum = RestrictedLumaMinimum * depthScale; lumaMaximum = RestrictedLumaMaximum * depthScale; chromaMinimum = (isIdentityMatrix ? RestrictedLumaMinimum : RestrictedChromaMinimum) * depthScale; @@ -1182,6 +1265,8 @@ internal sealed class Av1FilmGrainDecoder if (!isMonochrome) { + // Chroma multipliers are biased by 128 in the bitstream, and offsets are biased by 256 after conversion + // to the active bit depth. Restoring those signed values keeps the scaling-index equation entirely integral. int cbMultiplier = (int)parameters.CbMult - 128; int cbLumaMultiplier = (int)parameters.CbLumaMult - 128; int cbOffset = ((int)parameters.CbOffset * depthScale) - (256 * depthScale); @@ -1190,6 +1275,8 @@ internal sealed class Av1FilmGrainDecoder int crOffset = ((int)parameters.CrOffset * depthScale) - (256 * depthScale); if (parameters.ChromaScalingFromLuma) { + // A luma-derived chroma function selects the luma coordinate directly: 64 is unity in the Q6 + // multiplier domain, while the chroma sample multiplier and both offsets are forced to zero. cbMultiplier = 0; cbLumaMultiplier = 64; cbOffset = 0; @@ -1202,6 +1289,9 @@ internal sealed class Av1FilmGrainDecoder bool applyCr = parameters.NumCrPoints != 0 || parameters.ChromaScalingFromLuma; int chromaHeight = halfLumaHeight << (1 - subsamplingY); int chromaWidth = halfLumaWidth << (1 - subsamplingX); + + // Chroma is processed before luma so its scaling coordinate observes restored luma, not luma after grain. + // This ordering also makes Cb and Cr independent of whether a luma scaling function is present. for (int row = 0; row < chromaHeight; row++) { for (int column = 0; column < chromaWidth; column++) @@ -1210,6 +1300,8 @@ internal sealed class Av1FilmGrainDecoder int averageLuma = GetSample(luma, lumaIndex); if (subsamplingX != 0) { + // Horizontally subsampled chroma is centered over two luma columns. Vertical subsampling + // changes which luma row is selected but does not introduce a second-row average here. averageLuma = (averageLuma + GetSample(luma, lumaIndex + 1) + 1) >> 1; } @@ -1256,6 +1348,8 @@ internal sealed class Av1FilmGrainDecoder if (parameters.NumYPoints != 0) { + // The half-dimension contract expands back to the exact luma rectangle owned by this boundary or interior + // pass. Each sample uses its restored value as the scaling coordinate before grain is added in place. int lumaHeight = halfLumaHeight << 1; int lumaWidth = halfLumaWidth << 1; for (int row = 0; row < lumaHeight; row++) @@ -1292,9 +1386,12 @@ internal sealed class Av1FilmGrainDecoder int lookupIndex = index >> depthShift; if (depthShift == 0 || lookupIndex == 255) { + // Eight-bit coordinates address the table directly. The last high-bit-depth interval has no following + // entry, so endpoint extension returns entry 255 without attempting interpolation. return lookup[lookupIndex]; } + // The low depthShift bits are the fractional position between adjacent eight-bit lookup coordinates. int fraction = index & ((1 << depthShift) - 1); return lookup[lookupIndex] + ((((lookup[lookupIndex + 1] - lookup[lookupIndex]) * fraction) + (1 << (depthShift - 1))) >> depthShift); @@ -1332,6 +1429,7 @@ internal sealed class Av1FilmGrainDecoder int destinationOffset = row * destinationStride; if (width == 1) { + // A subsampled one-column boundary uses the dedicated 23:22 overlap weights. destination[destinationOffset] = Av1Math.Clamp( ((left[leftOffset] * 23) + (right[rightOffset] * 22) + 16) >> 5, minimum, @@ -1339,6 +1437,8 @@ internal sealed class Av1FilmGrainDecoder } else { + // The two-column kernel biases the outer samples toward their originating block and crosses the + // 27:17 weights for the inner samples. These fixed weights are part of AV1 grain synthesis. destination[destinationOffset] = Av1Math.Clamp( ((left[leftOffset] * 27) + (right[rightOffset] * 17) + 16) >> 5, minimum, @@ -1381,6 +1481,7 @@ internal sealed class Av1FilmGrainDecoder { if (height == 1) { + // Vertically subsampled chroma collapses the overlap to the single-row 23:22 kernel. destination[column] = Av1Math.Clamp( ((top[column] * 23) + (bottom[column] * 22) + 16) >> 5, minimum, @@ -1388,6 +1489,7 @@ internal sealed class Av1FilmGrainDecoder } else { + // Luma and full-height chroma use the crossed two-row 27:17 overlap kernel. destination[column] = Av1Math.Clamp( ((top[column] * 27) + (bottom[column] * 17) + 16) >> 5, minimum, @@ -1436,6 +1538,8 @@ internal sealed class Av1FilmGrainDecoder { randomRegister = seed; int lumaBlock = lumaLine >> 5; + + // The two affine mixes inject the block-row identity into both bytes of the 16-bit LFSR state. randomRegister ^= (ushort)((((lumaBlock * 37) + 178) & 255) << 8); randomRegister ^= (ushort)(((lumaBlock * 173) + 105) & 255); } @@ -1449,6 +1553,8 @@ internal sealed class Av1FilmGrainDecoder [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int GetRandomNumber(ref ushort randomRegister, int bitCount) { + // AV1 uses taps 0, 1, 3, and 12 of the current register. Shifting right exposes the requested high bits + // after feedback has entered bit 15, matching both Gaussian indexing and block-offset selection. int feedback = (randomRegister ^ (randomRegister >> 1) ^ (randomRegister >> 3) ^ (randomRegister >> 12)) & 1; @@ -1468,6 +1574,8 @@ internal sealed class Av1FilmGrainDecoder where TSample : unmanaged { ref TSample sample = ref samples[index]; + + // Callers close TSample over byte or ushort, so the JIT removes this branch and emits a native unsigned load. return typeof(TSample) == typeof(byte) ? Unsafe.As(ref sample) : Unsafe.As(ref sample); @@ -1484,6 +1592,7 @@ internal sealed class Av1FilmGrainDecoder private static void SetSample(Span samples, int index, int value) where TSample : unmanaged { + // As in GetSample, the closed generic type leaves only the matching native store in generated code. if (typeof(TSample) == typeof(byte)) { byte byteValue = (byte)value;