Browse Source

Implement AV1 lossless still-image coding

pull/2633/head
James Jackson-South 4 weeks ago
parent
commit
930acdc82c
  1. 8
      HEIF_IMPLEMENTATION_PLAN.md
  2. 19
      src/ImageSharp/Formats/Heif/Av1/OpenBitstreamUnit/ObuColorConfig.cs
  3. 4
      src/ImageSharp/Formats/Heif/Av1/Pipeline/Av1EncoderModeDecisionWorkspace.cs
  4. 18
      src/ImageSharp/Formats/Heif/Av1/Pipeline/Av1FrameEncoder.cs
  5. 20
      src/ImageSharp/Formats/Heif/Av1/Pipeline/Av1IntraSuperblockEncoder.ChromaModeDecision.cs
  6. 52
      src/ImageSharp/Formats/Heif/Av1/Pipeline/Av1IntraSuperblockEncoder.ModeDecision.cs
  7. 34
      src/ImageSharp/Formats/Heif/Av1/Pipeline/Av1TransformBlockEncoder.cs
  8. 170
      src/ImageSharp/Formats/Heif/Av1/Pipeline/Quantizers/Av1ForwardQuantizer.Operator.cs
  9. 49
      src/ImageSharp/Formats/Heif/Av1/Pipeline/Quantizers/Av1ForwardQuantizer.cs
  10. 127
      src/ImageSharp/Formats/Heif/Av1/Transform/Av1ForwardTransformer.cs
  11. 15
      src/ImageSharp/Formats/Heif/HeifEncoderCore.cs
  12. 89
      tests/ImageSharp.Tests/Formats/Heif/Av1/Av1EncoderFrameTests.cs
  13. 35
      tests/ImageSharp.Tests/Formats/Heif/Av1/Av1ForwardQuantizerTests.cs
  14. 91
      tests/ImageSharp.Tests/Formats/Heif/Av1/Av1ForwardTransformTests.cs
  15. 1
      tests/ImageSharp.Tests/Formats/Heif/Av1/Av1TransformBlockEncoderTests.cs
  16. 62
      tests/ImageSharp.Tests/Formats/Heif/HeifEncoderTests.cs

8
HEIF_IMPLEMENTATION_PLAN.md

File diff suppressed because one or more lines are too long

19
src/ImageSharp/Formats/Heif/Av1/OpenBitstreamUnit/ObuColorConfig.cs

@ -8,11 +8,6 @@ namespace SixLabors.ImageSharp.Formats.Heif.Av1.OpenBitstreamUnit;
/// </summary>
internal sealed class ObuColorConfig
{
/// <summary>
/// Stores whether the sequence uses a single monochrome plane.
/// </summary>
private bool isMonochrome;
/// <summary>
/// Gets or sets a value indicating whether color-description syntax is present.
/// </summary>
@ -21,23 +16,13 @@ internal sealed class ObuColorConfig
/// <summary>
/// Gets the number of color channels in this image. Can have the value 1 or 3.
/// </summary>
public int PlaneCount { get; private set; }
public int PlaneCount => this.IsMonochrome ? 1 : Av1Constants.MaxPlanes;
/// <summary>
/// Gets or sets a value indicating whether the image has a single greyscale plane, will have
/// <see cref="Av1Constants.MaxPlanes"/> color planes otherwise.
/// </summary>
public bool IsMonochrome
{
get => this.isMonochrome;
set
{
// Plane count is derived from the monochrome flag throughout the decoder, so update
// both values atomically rather than allowing the two pieces of state to diverge.
this.PlaneCount = value ? 1 : Av1Constants.MaxPlanes;
this.isMonochrome = value;
}
}
public bool IsMonochrome { get; set; }
/// <summary>
/// Gets or sets the color-primary chromaticities.

4
src/ImageSharp/Formats/Heif/Av1/Pipeline/Av1EncoderModeDecisionWorkspace.cs

@ -39,7 +39,8 @@ internal readonly ref struct Av1EncoderModeDecisionWorkspace<TSample>
/// <summary>
/// The maximum number of transform states needed while evaluating both chroma planes of one 128x128 block.
/// </summary>
public const int MaximumCandidateTransformBlockCount = 32;
public const int MaximumCandidateTransformBlockCount =
2 * MaximumSampleCount / MinimumTransformSampleCount;
/// <summary>
/// The required workspace length in signed-integer storage elements.
@ -48,6 +49,7 @@ internal readonly ref struct Av1EncoderModeDecisionWorkspace<TSample>
private const int ReferenceBufferLength = (2 * Av1Constants.MaxTransformSize) + 1;
private const int ReferenceBufferCount = 4;
private const int MinimumTransformSampleCount = 1 << (2 * Av1Constants.ModeInfoSizeLog2);
private const int ReferenceStorageLength = ReferenceBufferCount * ReferenceBufferLength * sizeof(ushort) / sizeof(int);
private const int CandidateSampleStorageOffset = ReferenceStorageLength;
private const int CandidateSampleStorageLength = 2 * MaximumSampleCount * sizeof(ushort) / sizeof(int);

18
src/ImageSharp/Formats/Heif/Av1/Pipeline/Av1FrameEncoder.cs

@ -119,7 +119,9 @@ internal static class Av1FrameEncoder
ErrorResilientMode = true,
RefreshFrameFlags = byte.MaxValue,
DisableFrameEndUpdateCdf = true,
TransformMode = effort >= 6 ? Av1TransformMode.Select : Av1TransformMode.Largest,
TransformMode = qIndex == 0
? Av1TransformMode.Only4x4
: effort >= 6 ? Av1TransformMode.Select : Av1TransformMode.Largest,
ModeInfoColumnCount = modeInfoColumnCount,
ModeInfoRowCount = modeInfoRowCount,
TilesInfo = tiles,
@ -294,14 +296,17 @@ internal static class Av1FrameEncoder
}
frameHeader.AllowScreenContentTools = allowScreenContentTools;
frameHeader.AllowIntraBlockCopy = allowIntraBlockCopy;
// The current IBC search is specialized for one 8x8 transform. Coded lossless requires reversible 4x4
// transforms, so retain palette search but omit this candidate until it has a matching tiled implementation.
frameHeader.AllowIntraBlockCopy = !frameHeader.CodedLossless && allowIntraBlockCopy;
using Av1EncoderPictureBuffer picture = new(
configuration,
sequenceHeader,
frameHeader,
image.Width,
image.Height,
disallow4x4AllFrames: effort < 9);
disallow4x4AllFrames: !frameHeader.CodedLossless && effort < 9);
using Av1EncoderCoefficientBuffer coefficients = new(
configuration,
@ -358,14 +363,17 @@ internal static class Av1FrameEncoder
}
frameHeader.AllowScreenContentTools = allowScreenContentTools;
frameHeader.AllowIntraBlockCopy = allowIntraBlockCopy;
// The current IBC search is specialized for one 8x8 transform. Coded lossless requires reversible 4x4
// transforms, so retain palette search but omit this candidate until it has a matching tiled implementation.
frameHeader.AllowIntraBlockCopy = !frameHeader.CodedLossless && allowIntraBlockCopy;
using Av1EncoderPictureBuffer picture = new(
configuration,
sequenceHeader,
frameHeader,
image.Width,
image.Height,
disallow4x4AllFrames: effort < 9);
disallow4x4AllFrames: !frameHeader.CodedLossless && effort < 9);
using Av1EncoderCoefficientBuffer coefficients = new(
configuration,

20
src/ImageSharp/Formats/Heif/Av1/Pipeline/Av1IntraSuperblockEncoder.ChromaModeDecision.cs

@ -805,10 +805,12 @@ internal static partial class Av1IntraSuperblockEncoder
int transformHeight4x4 = transformSize.Get4x4HighCount();
int maximumUnitWidth = Math.Min(maximumUnitBlockSize.GetWidth(), blockWidth);
int maximumUnitHeight = Math.Min(maximumUnitBlockSize.GetHeight(), blockHeight);
Av1TransformType transformType = Av1SymbolContextHelper.GetDefaultIntraTransformType(
predictionMode,
transformSize,
this.picture.Parent.FrameHeader.UseReducedTransformSet);
Av1TransformType transformType = this.picture.Parent.FrameHeader.CodedLossless
? Av1TransformType.DctDct
: Av1SymbolContextHelper.GetDefaultIntraTransformType(
predictionMode,
transformSize,
this.picture.Parent.FrameHeader.UseReducedTransformSet);
Av1ComponentType componentType = plane == Av1Plane.Y
? Av1ComponentType.Luminance
@ -1046,10 +1048,12 @@ internal static partial class Av1IntraSuperblockEncoder
// Intra chroma derives one transform type from the shared UV prediction mode. The type is not
// signaled independently for either chroma plane, so U and V must use the same legal fallback.
Av1TransformType transformType = Av1SymbolContextHelper.GetDefaultIntraTransformType(
predictionMode,
transformSize,
this.picture.Parent.FrameHeader.UseReducedTransformSet);
Av1TransformType transformType = this.picture.Parent.FrameHeader.CodedLossless
? Av1TransformType.DctDct
: Av1SymbolContextHelper.GetDefaultIntraTransformType(
predictionMode,
transformSize,
this.picture.Parent.FrameHeader.UseReducedTransformSet);
long distortion = TOperator.EncodeCandidate(
this.blockWorkspace,

52
src/ImageSharp/Formats/Heif/Av1/Pipeline/Av1IntraSuperblockEncoder.ModeDecision.cs

@ -544,7 +544,10 @@ internal static partial class Av1IntraSuperblockEncoder
{
Av1BlockSize blockSize = modeInfo.Block.BlockSize;
Av1PartitionType partitionType = modeInfo.Block.PartitionType;
Av1TransformSize maximumLumaTransformSize = blockSize.GetMaximumTransformSize();
Av1TransformSize maximumLumaTransformSize = this.picture.Parent.FrameHeader.CodedLossless
? Av1TransformSize.Size4x4
: blockSize.GetMaximumTransformSize();
int qIndex = this.quantization.QIndex[0];
modeInfo.Block = new Av1EncoderBlockModeInfo
{
@ -619,8 +622,9 @@ internal static partial class Av1IntraSuperblockEncoder
block.FilterIntraMode)
: 0;
modeInfo.Block.Skip = lumaTransformEmpty &&
Av1TileWriter.ShouldSkipCoefficients(
modeInfo.Block.Skip =
!this.picture.Parent.FrameHeader.CodedLossless &&
lumaTransformEmpty && Av1TileWriter.ShouldSkipCoefficients(
writer,
Av1TileWriter.GetSkipContext(macroBlock),
emptyTransformRate);
@ -660,9 +664,11 @@ internal static partial class Av1IntraSuperblockEncoder
subsamplingX,
subsamplingY);
Av1TransformSize chromaTransformSize = blockSize.GetMaxUvTransformSize(
colorConfig.SubSamplingX,
colorConfig.SubSamplingY);
Av1TransformSize chromaTransformSize = this.picture.Parent.FrameHeader.CodedLossless
? Av1TransformSize.Size4x4
: blockSize.GetMaxUvTransformSize(
colorConfig.SubSamplingX,
colorConfig.SubSamplingY);
Av1BlockSize chromaBlockSize = blockSize.GetSubsampled(
colorConfig.SubSamplingX,
@ -763,7 +769,8 @@ internal static partial class Av1IntraSuperblockEncoder
Av1FilterIntraMode.AllFilterIntraModes);
}
modeInfo.Block.Skip = Av1TileWriter.ShouldSkipCoefficients(
modeInfo.Block.Skip = !this.picture.Parent.FrameHeader.CodedLossless &&
Av1TileWriter.ShouldSkipCoefficients(
writer,
Av1TileWriter.GetSkipContext(macroBlock),
regularEmptyTransformRate);
@ -1336,7 +1343,11 @@ internal static partial class Av1IntraSuperblockEncoder
out Av1TransformSize selectedTransformSize,
out long selectedCost)
{
Av1TransformSize transformSize = blockSize.GetMaximumTransformSize();
bool codedLossless = this.picture.Parent.FrameHeader.CodedLossless;
Av1TransformSize transformSize = codedLossless
? Av1TransformSize.Size4x4
: blockSize.GetMaximumTransformSize();
int blockWidth = blockSize.GetWidth();
int blockHeight = blockSize.GetHeight();
Av1EncoderModeDecisionWorkspace<TSample> workspace =
@ -1530,8 +1541,9 @@ internal static partial class Av1IntraSuperblockEncoder
// Transform type and transform size are separate search axes. Splitting their effort thresholds
// gives callers a useful intermediate tier without changing the fast default path.
bool searchEveryTransformType = this.effort >= 7;
bool searchEveryTransformSize = this.effort >= 8 &&
bool searchEveryTransformType = !codedLossless && this.effort >= 7;
bool searchEveryTransformSize = !codedLossless &&
this.effort >= 8 &&
transformSize == Av1TransformSize.Size8x8;
// Zero-angle modes precede groups of six nonzero adjustments for each directional mode.
@ -1571,12 +1583,14 @@ internal static partial class Av1IntraSuperblockEncoder
// Transform types are visited in AV1 enumeration order. A strict cost comparison below keeps
// the first legal type on ties, while lower efforts visit only the mode-derived default.
Av1TransformType firstTransformType = searchEveryTransformType
Av1TransformType firstTransformType = codedLossless
? Av1TransformType.DctDct
: Av1SymbolContextHelper.GetDefaultIntraTransformType(
mode,
transformSize,
useReducedTransformSet);
: searchEveryTransformType
? Av1TransformType.DctDct
: Av1SymbolContextHelper.GetDefaultIntraTransformType(
mode,
transformSize,
useReducedTransformSet);
Av1TransformType transformTypeLimit = searchEveryTransformType
? Av1TransformType.AllTransformTypes
@ -1679,7 +1693,7 @@ internal static partial class Av1IntraSuperblockEncoder
// Midrange effort refines the preliminary mode only. Higher effort already searched every
// mode-transform pair above, so repeating the winning mode would add no candidates.
long bestTransformCost = bestCost;
if (this.effort >= 3 && !searchEveryTransformType)
if (!codedLossless && this.effort >= 3 && !searchEveryTransformType)
{
// The shared spans now contain the last mode visited above, so rebuild the preliminary
// winner once before refining its transform types.
@ -1767,8 +1781,12 @@ internal static partial class Av1IntraSuperblockEncoder
transformSize,
this.bitDepth);
Av1TransformType filterTransformTypeLimit = codedLossless
? (Av1TransformType)((int)Av1TransformType.DctDct + 1)
: Av1TransformType.AllTransformTypes;
for (Av1TransformType transformType = Av1TransformType.DctDct;
transformType < Av1TransformType.AllTransformTypes;
transformType < filterTransformTypeLimit;
transformType++)
{
if (!transformType.IsExtendedSetUsed(transformSetType))

34
src/ImageSharp/Formats/Heif/Av1/Pipeline/Av1TransformBlockEncoder.cs

@ -223,10 +223,10 @@ internal static class Av1TransformBlockEncoder
reconstruction,
reconstructionStride,
transformSize,
transformType,
state.TransformType,
(int)plane,
state.EndOfBlock,
false,
qIndex == 0,
workspace.TransformWorkspace);
}
@ -310,10 +310,10 @@ internal static class Av1TransformBlockEncoder
reconstruction,
width,
transformSize,
Av1TransformType.DctDct,
state.TransformType,
(int)plane,
state.EndOfBlock,
false,
qIndex == 0,
workspace.TransformWorkspace);
}
@ -549,10 +549,10 @@ internal static class Av1TransformBlockEncoder
MemoryMarshal.Cast<ushort, short>(reconstruction),
reconstructionStride,
transformSize,
transformType,
state.TransformType,
(int)plane,
state.EndOfBlock,
false,
qIndex == 0,
bitDepth,
workspace.TransformWorkspace);
}
@ -652,10 +652,10 @@ internal static class Av1TransformBlockEncoder
signedReconstruction,
width,
transformSize,
Av1TransformType.DctDct,
state.TransformType,
(int)plane,
state.EndOfBlock,
false,
qIndex == 0,
bitDepth,
workspace.TransformWorkspace);
}
@ -913,10 +913,10 @@ internal static class Av1TransformBlockEncoder
reconstruction,
reconstructionStride,
transformSize,
transformType,
state.TransformType,
(int)plane,
state.EndOfBlock,
false,
qIndex == 0,
workspace.TransformWorkspace);
}
}
@ -1001,10 +1001,10 @@ internal static class Av1TransformBlockEncoder
MemoryMarshal.Cast<ushort, short>(reconstruction),
reconstructionStride,
transformSize,
transformType,
state.TransformType,
(int)plane,
state.EndOfBlock,
false,
qIndex == 0,
bitDepth,
workspace.TransformWorkspace);
}
@ -1061,6 +1061,16 @@ internal static class Av1TransformBlockEncoder
Span<int> quantized = quantizedCoefficients[..coefficientCount];
Span<int> dequantized = workspace.DequantizedCoefficients[..coefficientCount];
if (qIndex == 0)
{
// A coded-lossless frame fixes every transform block at 4x4 and uses the reversible transform. Its
// quantizer removes only the transform's fixed scale, leaving reconstruction coefficients unchanged.
Av1ForwardTransformer.TransformLossless4x4(residual, transformed, (uint)transformSize.GetWidth());
state.EndOfBlock = Av1ForwardQuantizer.QuantizeLossless(transformed, quantized, dequantized, bitDepth);
state.TransformType = Av1TransformType.DctDct;
return;
}
// The forward transform reads the prepared residual without changing it, so every type candidate can
// reuse one source-minus-prediction block. Separate coefficient spans preserve each later representation.
Av1ForwardTransformer.Transform2d(

170
src/ImageSharp/Formats/Heif/Av1/Pipeline/Quantizers/Av1ForwardQuantizer.Operator.cs

@ -202,4 +202,174 @@ internal static partial class Av1ForwardQuantizer
return quantized;
}
}
/// <summary>
/// Implements fast no-matrix quantization without truncating high-bit-depth transform magnitudes.
/// </summary>
/// <remarks>
/// The reciprocal product is widened to 64 bits because 10-bit and 12-bit transforms can exceed the signed
/// 16-bit range. Each SIMD overload preserves the scalar fixed-point operation order in every lane.
/// </remarks>
internal readonly struct HighBitDepthFastQuantizationOperator : IForwardQuantizationOperator
{
/// <inheritdoc/>
public static Vector128<int> Quantize(
Vector128<int> coefficients,
Vector128<int> rounding,
Vector128<int> quantizer,
Vector128<int> dequantizer,
int logScale,
out Vector128<int> dequantizedCoefficients)
{
Vector128<int> coefficientSign = Vector128.ShiftRightArithmetic(coefficients, 31);
Vector128<int> magnitude = Vector128.Abs(coefficients);
Vector128<int> thresholdMask = ~Vector128.GreaterThan(dequantizer, magnitude << (1 + logScale));
Vector128<int> rounded = magnitude + rounding;
// Widen before multiplying so transform magnitudes above 32,767 retain their full precision.
Vector128<long> lowerProduct = Vector128.WidenLower(rounded) * Vector128.WidenLower(quantizer);
Vector128<long> upperProduct = Vector128.WidenUpper(rounded) * Vector128.WidenUpper(quantizer);
Vector128<int> quantizedMagnitude =
Vector128.Narrow(lowerProduct >> (16 - logScale), upperProduct >> (16 - logScale)) & thresholdMask;
Vector128<int> quantized = (quantizedMagnitude ^ coefficientSign) - coefficientSign;
Vector128<int> dequantizedMagnitude = (quantizedMagnitude * dequantizer) >> logScale;
dequantizedCoefficients = (dequantizedMagnitude ^ coefficientSign) - coefficientSign;
return quantized;
}
/// <inheritdoc/>
public static Vector256<int> Quantize(
Vector256<int> coefficients,
Vector256<int> rounding,
Vector256<int> quantizer,
Vector256<int> dequantizer,
int logScale,
out Vector256<int> dequantizedCoefficients)
{
Vector256<int> coefficientSign = Vector256.ShiftRightArithmetic(coefficients, 31);
Vector256<int> magnitude = Vector256.Abs(coefficients);
Vector256<int> thresholdMask = ~Vector256.GreaterThan(dequantizer, magnitude << (1 + logScale));
Vector256<int> rounded = magnitude + rounding;
// Widen before multiplying so transform magnitudes above 32,767 retain their full precision.
Vector256<long> lowerProduct = Vector256.WidenLower(rounded) * Vector256.WidenLower(quantizer);
Vector256<long> upperProduct = Vector256.WidenUpper(rounded) * Vector256.WidenUpper(quantizer);
Vector256<int> quantizedMagnitude =
Vector256.Narrow(lowerProduct >> (16 - logScale), upperProduct >> (16 - logScale)) & thresholdMask;
Vector256<int> quantized = (quantizedMagnitude ^ coefficientSign) - coefficientSign;
Vector256<int> dequantizedMagnitude = (quantizedMagnitude * dequantizer) >> logScale;
dequantizedCoefficients = (dequantizedMagnitude ^ coefficientSign) - coefficientSign;
return quantized;
}
/// <inheritdoc/>
public static Vector512<int> Quantize(
Vector512<int> coefficients,
Vector512<int> rounding,
Vector512<int> quantizer,
Vector512<int> dequantizer,
int logScale,
out Vector512<int> dequantizedCoefficients)
{
Vector512<int> coefficientSign = Vector512.ShiftRightArithmetic(coefficients, 31);
Vector512<int> magnitude = Vector512.Abs(coefficients);
Vector512<int> thresholdMask = ~Vector512.GreaterThan(dequantizer, magnitude << (1 + logScale));
Vector512<int> rounded = magnitude + rounding;
// Widen before multiplying so transform magnitudes above 32,767 retain their full precision.
Vector512<long> lowerProduct = Vector512.WidenLower(rounded) * Vector512.WidenLower(quantizer);
Vector512<long> upperProduct = Vector512.WidenUpper(rounded) * Vector512.WidenUpper(quantizer);
Vector512<int> quantizedMagnitude =
Vector512.Narrow(lowerProduct >> (16 - logScale), upperProduct >> (16 - logScale)) & thresholdMask;
Vector512<int> quantized = (quantizedMagnitude ^ coefficientSign) - coefficientSign;
Vector512<int> dequantizedMagnitude = (quantizedMagnitude * dequantizer) >> logScale;
dequantizedCoefficients = (dequantizedMagnitude ^ coefficientSign) - coefficientSign;
return quantized;
}
/// <inheritdoc/>
public static int Quantize(
int coefficient,
int rounding,
int quantizer,
int dequantizer,
int logScale,
out int dequantizedCoefficient)
{
int coefficientSign = coefficient >> 31;
int magnitude = (coefficient ^ coefficientSign) - coefficientSign;
int quantizedMagnitude = 0;
if (((long)magnitude << (1 + logScale)) >= dequantizer)
{
quantizedMagnitude = (int)((((long)magnitude + rounding) * quantizer) >> (16 - logScale));
}
int quantized = (quantizedMagnitude ^ coefficientSign) - coefficientSign;
int dequantizedMagnitude = (quantizedMagnitude * dequantizer) >> logScale;
dequantizedCoefficient = (dequantizedMagnitude ^ coefficientSign) - coefficientSign;
return quantized;
}
}
/// <summary>
/// Removes the reversible transform's fixed scale for entropy coding while retaining exact reconstruction coefficients.
/// </summary>
internal readonly struct LosslessQuantizationOperator : IForwardQuantizationOperator
{
/// <inheritdoc/>
public static Vector128<int> Quantize(
Vector128<int> coefficients,
Vector128<int> rounding,
Vector128<int> quantizer,
Vector128<int> dequantizer,
int logScale,
out Vector128<int> dequantizedCoefficients)
{
dequantizedCoefficients = coefficients;
return coefficients >> 2;
}
/// <inheritdoc/>
public static Vector256<int> Quantize(
Vector256<int> coefficients,
Vector256<int> rounding,
Vector256<int> quantizer,
Vector256<int> dequantizer,
int logScale,
out Vector256<int> dequantizedCoefficients)
{
dequantizedCoefficients = coefficients;
return coefficients >> 2;
}
/// <inheritdoc/>
public static Vector512<int> Quantize(
Vector512<int> coefficients,
Vector512<int> rounding,
Vector512<int> quantizer,
Vector512<int> dequantizer,
int logScale,
out Vector512<int> dequantizedCoefficients)
{
dequantizedCoefficients = coefficients;
return coefficients >> 2;
}
/// <inheritdoc/>
public static int Quantize(
int coefficient,
int rounding,
int quantizer,
int dequantizer,
int logScale,
out int dequantizedCoefficient)
{
dequantizedCoefficient = coefficient;
return coefficient >> 2;
}
}
}

49
src/ImageSharp/Formats/Heif/Av1/Pipeline/Quantizers/Av1ForwardQuantizer.cs

@ -36,21 +36,56 @@ internal static partial class Av1ForwardQuantizer
int dcDeltaQ,
int acDeltaQ,
Av1BitDepth bitDepth)
=> QuantizeLossy<FastQuantizationOperator>(
=> bitDepth == Av1BitDepth.EightBit
? Quantize<FastQuantizationOperator>(
coefficients,
quantizedCoefficients,
dequantizedCoefficients,
transformSize,
transformType,
qIndex,
dcDeltaQ,
acDeltaQ,
bitDepth)
: Quantize<HighBitDepthFastQuantizationOperator>(
coefficients,
quantizedCoefficients,
dequantizedCoefficients,
transformSize,
transformType,
qIndex,
dcDeltaQ,
acDeltaQ,
bitDepth);
/// <summary>
/// Quantizes one reversible four-by-four transform without changing its reconstruction coefficients.
/// </summary>
/// <param name="coefficients">The raster-order reversible-transform coefficients.</param>
/// <param name="quantizedCoefficients">The raster-order entropy-coding coefficients.</param>
/// <param name="dequantizedCoefficients">The raster-order reconstruction coefficients.</param>
/// <param name="bitDepth">The coded sample bit depth.</param>
/// <returns>The one-based end position in coefficient scan order.</returns>
public static ushort QuantizeLossless(
ReadOnlySpan<int> coefficients,
Span<int> quantizedCoefficients,
Span<int> dequantizedCoefficients,
Av1BitDepth bitDepth)
=> Quantize<LosslessQuantizationOperator>(
coefficients,
quantizedCoefficients,
dequantizedCoefficients,
transformSize,
transformType,
qIndex,
dcDeltaQ,
acDeltaQ,
Av1TransformSize.Size4x4,
Av1TransformType.DctDct,
0,
0,
0,
bitDepth);
/// <summary>
/// Applies a closed generic quantization operator across the widest available hardware widths.
/// </summary>
internal static ushort QuantizeLossy<TOperator>(
private static ushort Quantize<TOperator>(
ReadOnlySpan<int> coefficients,
Span<int> quantizedCoefficients,
Span<int> dequantizedCoefficients,

127
src/ImageSharp/Formats/Heif/Av1/Transform/Av1ForwardTransformer.cs

@ -45,6 +45,133 @@ internal static partial class Av1ForwardTransformer
DispatchColumn(input, coefficients, stride, bitDepth, ref config, workspace);
}
/// <summary>
/// Applies the reversible four-by-four transform required by coded-lossless AV1 blocks.
/// </summary>
/// <param name="input">The spatial residual samples.</param>
/// <param name="coefficients">The destination transform coefficients.</param>
/// <param name="stride">The number of input samples between rows.</param>
public static void TransformLossless4x4(ReadOnlySpan<short> input, Span<int> coefficients, uint stride)
{
int inputStride = (int)stride;
if (Vector128.IsHardwareAccelerated)
{
Vector128<int> row0 = Vector128.Create((int)input[0], input[1], input[2], input[3]);
Vector128<int> row1 = Vector128.Create(
input[inputStride],
input[inputStride + 1],
input[inputStride + 2],
input[inputStride + 3]);
Vector128<int> row2 = Vector128.Create(
input[2 * inputStride],
input[(2 * inputStride) + 1],
input[(2 * inputStride) + 2],
input[(2 * inputStride) + 3]);
Vector128<int> row3 = Vector128.Create(
input[3 * inputStride],
input[(3 * inputStride) + 1],
input[(3 * inputStride) + 2],
input[(3 * inputStride) + 3]);
// Each lane initially holds one column. The first stage transforms those columns in parallel, then the
// transpose makes each transformed column a row so the same lane-wise network can process the other axis.
TransformLosslessStage(ref row0, ref row1, ref row2, ref row3);
Av1Transform2dOperations.Transpose(ref row0, ref row1, ref row2, ref row3);
TransformLosslessStage(ref row0, ref row1, ref row2, ref row3);
// The entropy pipeline stores transform positions in row-major order, while the reversible reference
// walk leaves the two frequency axes exchanged. Normalize that boundary before scan-order traversal.
Av1Transform2dOperations.Transpose(ref row0, ref row1, ref row2, ref row3);
ref int coefficientBase = ref MemoryMarshal.GetReference(coefficients);
(row0 * 4).StoreUnsafe(ref coefficientBase);
(row1 * 4).StoreUnsafe(ref coefficientBase, 4);
(row2 * 4).StoreUnsafe(ref coefficientBase, 8);
(row3 * 4).StoreUnsafe(ref coefficientBase, 12);
return;
}
// The first pass writes transposed columns into the destination, matching the layout consumed in-place by
// the second pass. This keeps the scalar fallback allocation-free without a temporary matrix.
for (int column = 0; column < 4; column++)
{
int a = input[column];
int b = input[inputStride + column];
int c = input[(2 * inputStride) + column];
int d = input[(3 * inputStride) + column];
a += b;
d -= c;
int e = (a - d) >> 1;
b = e - b;
c = e - c;
a -= c;
d += b;
int offset = column * 4;
coefficients[offset] = a;
coefficients[offset + 1] = c;
coefficients[offset + 2] = d;
coefficients[offset + 3] = b;
}
for (int column = 0; column < 4; column++)
{
int a = coefficients[column];
int b = coefficients[4 + column];
int c = coefficients[8 + column];
int d = coefficients[12 + column];
a += b;
d -= c;
int e = (a - d) >> 1;
b = e - b;
c = e - c;
a -= c;
d += b;
coefficients[column] = a * 4;
coefficients[4 + column] = c * 4;
coefficients[8 + column] = d * 4;
coefficients[12 + column] = b * 4;
}
// Normalize the scalar reference walk to the row-major coefficient contract used by entropy coding.
(coefficients[1], coefficients[4]) = (coefficients[4], coefficients[1]);
(coefficients[2], coefficients[8]) = (coefficients[8], coefficients[2]);
(coefficients[3], coefficients[12]) = (coefficients[12], coefficients[3]);
(coefficients[6], coefficients[9]) = (coefficients[9], coefficients[6]);
(coefficients[7], coefficients[13]) = (coefficients[13], coefficients[7]);
(coefficients[11], coefficients[14]) = (coefficients[14], coefficients[11]);
}
/// <summary>
/// Applies one axis of the reversible four-point transform to four independent SIMD lanes.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void TransformLosslessStage(
ref Vector128<int> row0,
ref Vector128<int> row1,
ref Vector128<int> row2,
ref Vector128<int> row3)
{
Vector128<int> a = row0 + row1;
Vector128<int> d = row3 - row2;
Vector128<int> e = (a - d) >> 1;
Vector128<int> b = e - row1;
Vector128<int> c = e - row2;
a -= c;
d += b;
row0 = a;
row1 = c;
row2 = d;
row3 = b;
}
/// <summary>
/// Selects the concrete column operator for a transform block.
/// </summary>

15
src/ImageSharp/Formats/Heif/HeifEncoderCore.cs

@ -786,11 +786,6 @@ internal sealed class HeifEncoderCore
CancellationToken cancellationToken)
where TPixel : unmanaged, IPixel<TPixel>
{
if (this.encoder.Lossless)
{
throw new NotSupportedException("Lossless AV1 encoding is not implemented.");
}
if (image.Frames.Count != 1)
{
throw new NotSupportedException("AV1 image-sequence encoding is not implemented.");
@ -806,8 +801,12 @@ internal sealed class HeifEncoderCore
_ => throw new NotSupportedException($"HEIF bit depth '{bitDepth}' is not supported.")
};
HeifChromaSubsampling defaultChromaSubsampling = this.encoder.Lossless
? HeifChromaSubsampling.Yuv444
: HeifChromaSubsampling.Yuv420;
HeifChromaSubsampling chromaSubsampling = this.encoder.ChromaSubsampling ??
(metadata.IsMonochrome ? HeifChromaSubsampling.Monochrome : HeifChromaSubsampling.Yuv420);
(metadata.IsMonochrome ? HeifChromaSubsampling.Monochrome : defaultChromaSubsampling);
(bool isMonochrome, bool subsamplingX, bool subsamplingY) = chromaSubsampling switch
{
@ -871,7 +870,7 @@ internal sealed class HeifEncoderCore
};
int quality = this.encoder.Quality ?? 75;
int qIndex = GetAv1QuantizerIndex(quality);
int qIndex = this.encoder.Lossless ? 0 : GetAv1QuantizerIndex(quality);
cancellationToken.ThrowIfCancellationRequested();
ObuSequenceHeader colorHeader = Av1FrameEncoder.Encode(
this.configuration,
@ -910,7 +909,7 @@ internal sealed class HeifEncoderCore
};
int alphaQuality = this.encoder.AlphaQuality ?? quality;
int alphaQIndex = GetAv1QuantizerIndex(alphaQuality);
int alphaQIndex = this.encoder.Lossless ? 0 : GetAv1QuantizerIndex(alphaQuality);
cancellationToken.ThrowIfCancellationRequested();
long alphaOffset = stream.Length;
ObuSequenceHeader alphaHeader = Av1FrameEncoder.EncodeAlpha(

89
tests/ImageSharp.Tests/Formats/Heif/Av1/Av1EncoderFrameTests.cs

@ -153,6 +153,95 @@ public class Av1EncoderFrameTests
}
}
[Theory]
[InlineData(TenBit)]
[InlineData(TwelveBit)]
public void LosslessHighBitDepthEncodingPreservesNativePlanes(int bitDepthValue)
{
const int width = 8;
const int height = 8;
Av1BitDepth bitDepth = (Av1BitDepth)bitDepthValue;
using Image<Rgb48> source = new(width, height);
for (int row = 0; row < height; row++)
{
Span<Rgb48> pixels = source.Frames.RootFrame.PixelBuffer.DangerousGetRowSpan(row);
for (int column = 0; column < width; column++)
{
pixels[column] = new Rgb48(
(ushort)((column * 7001) + (row * 997)),
(ushort)((row * 6007) + (column * 1231)),
(ushort)((column * 4001) + (row * 3001)));
}
}
ObuColorConfig colorConfig = new()
{
IsColorDescriptionPresent = true,
ColorPrimaries = ObuColorPrimaries.Bt709,
TransferCharacteristics = ObuTransferCharacteristics.Srgb,
MatrixCoefficients = ObuMatrixCoefficients.Identity,
ColorRange = true,
BitDepth = bitDepth
};
using Av1EncoderFrameBuffer<ushort> expected = new(
Configuration.Default,
width,
height,
bitDepth.GetBitCount(),
Av1ColorFormat.Yuv444,
1,
1);
Av1FrameEncoder.PrepareSource(
Configuration.Default,
source.Frames.RootFrame,
expected.Frame,
colorConfig);
using MemoryStream stream = new();
Av1FrameEncoder.Encode(
Configuration.Default,
source.Frames.RootFrame,
stream,
colorConfig,
qIndex: 0,
effort: 0);
byte[] payload = stream.ToArray();
string outputDirectory = Path.Combine(
TestEnvironment.ActualOutputDirectoryFullPath,
"Formats",
"Heif",
"Av1");
Directory.CreateDirectory(outputDirectory);
File.WriteAllBytes(
Path.Combine(outputDirectory, $"encoder-frame-8x8-{bitDepth.GetBitCount()}b-444-lossless.obu"),
payload);
using Av1Decoder decoder = new(Configuration.Default);
using Av1FrameBuffer<byte> actual = decoder.DecodeFrameBuffer(payload, null, null, out _);
ObuFrameHeader frameHeader = Assert.IsType<ObuFrameHeader>(decoder.FrameHeader);
Assert.Equal(width, actual.Width);
Assert.Equal(height, actual.Height);
Assert.True(frameHeader.CodedLossless);
Assert.True(frameHeader.AllLossless);
Assert.Equal(Av1TransformMode.Only4x4, frameHeader.TransformMode);
foreach (Av1Plane plane in new[] { Av1Plane.Y, Av1Plane.U, Av1Plane.V })
{
Buffer2DRegion<ushort> expectedPlane = expected.Frame.View.GetPlane(plane);
for (int row = 0; row < height; row++)
{
Assert.Equal(
expectedPlane.DangerousGetRowSpan(row)[..width].ToArray(),
actual.GetHighBitDepthRowSpan(plane, row, 0, 0).ToArray());
}
}
}
[Fact]
public void EncodeEffortNineSelectsSubEightPartition()
{

35
tests/ImageSharp.Tests/Formats/Heif/Av1/Av1ForwardQuantizerTests.cs

@ -68,6 +68,34 @@ public class Av1ForwardQuantizerTests
Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - before);
}
/// <summary>
/// Verifies that lossless quantization removes only the reversible transform scale.
/// </summary>
[Fact]
public void LosslessQuantizerRetainsExactReconstructionCoefficients()
{
int[] coefficients =
[
4, -8, 12, -16,
20, -24, 28, -32,
36, -40, 44, -48,
52, -56, 60, -64
];
int[] quantized = new int[coefficients.Length];
int[] dequantized = new int[coefficients.Length];
ushort endOfBlock = Av1ForwardQuantizer.QuantizeLossless(
coefficients,
quantized,
dequantized,
Av1BitDepth.TwelveBit);
Assert.Equal((ushort)16, endOfBlock);
Assert.Equal(coefficients.Select(x => x / 4), quantized);
Assert.Equal(coefficients, dequantized);
}
/// <summary>
/// Exercises each transform-scale category, coded 64-point layout, quantizer range, and sample precision.
/// </summary>
@ -188,7 +216,12 @@ public class Av1ForwardQuantizerTests
if ((magnitude << (1 + logScale)) >= dequantizer)
{
magnitude = Math.Clamp(magnitude + rounding, short.MinValue, short.MaxValue);
magnitude += rounding;
if (bitDepth == Av1BitDepth.EightBit)
{
magnitude = Math.Min(magnitude, short.MaxValue);
}
quantizedMagnitude = (int)((magnitude * quantizer) >> (16 - logScale));
}

91
tests/ImageSharp.Tests/Formats/Heif/Av1/Av1ForwardTransformTests.cs

@ -61,6 +61,13 @@ public class Av1ForwardTransformTests
public void TwoDimensionalPipelineMatchesReference()
=> FeatureTestRunner.RunWithHwIntrinsicsFeature(AssertTwoDimensionalPipeline, TransformConfigurations);
/// <summary>
/// Verifies the reversible transform against the independent scalar operation order at every hardware tier.
/// </summary>
[Fact]
public void LosslessTransformMatchesLibaomReferenceAcrossHardwareWidths()
=> FeatureTestRunner.RunWithHwIntrinsicsFeature(AssertLosslessTransform, TransformConfigurations);
/// <summary>
/// Verifies that the complete transform dispatcher reuses caller-owned workspace.
/// </summary>
@ -83,6 +90,90 @@ public class Av1ForwardTransformTests
Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - before);
}
private static void AssertLosslessTransform()
{
const int stride = 7;
short[] input = new short[stride * 4];
for (int row = 0; row < 4; row++)
{
for (int column = 0; column < 4; column++)
{
input[(row * stride) + column] = (short)((row * 1003) - (column * 499) + (row * column * 71) - 1024);
}
}
int[] expected = new int[16];
int[] actual = new int[16];
TransformLosslessReference(input, expected, stride);
Av1ForwardTransformer.TransformLossless4x4(input, actual, stride);
Assert.Equal(expected, actual);
long before = GC.GetAllocatedBytesForCurrentThread();
for (int iteration = 0; iteration < 32; iteration++)
{
Av1ForwardTransformer.TransformLossless4x4(input, actual, stride);
}
Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - before);
}
private static void TransformLosslessReference(ReadOnlySpan<short> input, Span<int> output, int stride)
{
// The first pass traverses columns and writes their four transformed values contiguously. The second
// pass consumes that transposed layout in place, matching the normative reversible operation order.
for (int column = 0; column < 4; column++)
{
int a = input[column];
int b = input[stride + column];
int c = input[(2 * stride) + column];
int d = input[(3 * stride) + column];
a += b;
d -= c;
int e = (a - d) >> 1;
b = e - b;
c = e - c;
a -= c;
d += b;
int offset = column * 4;
output[offset] = a;
output[offset + 1] = c;
output[offset + 2] = d;
output[offset + 3] = b;
}
for (int column = 0; column < 4; column++)
{
int a = output[column];
int b = output[4 + column];
int c = output[8 + column];
int d = output[12 + column];
a += b;
d -= c;
int e = (a - d) >> 1;
b = e - b;
c = e - c;
a -= c;
d += b;
output[column] = a * 4;
output[4 + column] = c * 4;
output[8 + column] = d * 4;
output[12 + column] = b * 4;
}
// Encoder coefficient storage is row-major, so exchange the reference walk's final frequency axes.
(output[1], output[4]) = (output[4], output[1]);
(output[2], output[8]) = (output[8], output[2]);
(output[3], output[12]) = (output[12], output[3]);
(output[6], output[9]) = (output[9], output[6]);
(output[7], output[13]) = (output[13], output[7]);
(output[11], output[14]) = (output[14], output[11]);
}
/// <summary>
/// Exercises every DCT, ADST, and identity stage network using both Int16 and Int32 lane arithmetic.
/// </summary>

1
tests/ImageSharp.Tests/Formats/Heif/Av1/Av1TransformBlockEncoderTests.cs

@ -626,6 +626,7 @@ public class Av1TransformBlockEncoderTests
Assert.Equal(Av1EncoderModeDecisionWorkspace<ushort>.MaximumTransformSampleCount, modeWorkspace.Prediction.Length);
Assert.Equal(Av1EncoderModeDecisionWorkspace<ushort>.MaximumTransformSampleCount, modeWorkspace.Residual.Length);
Assert.Equal(Av1EncoderModeDecisionWorkspace<ushort>.MaximumCandidateTransformBlockCount, modeWorkspace.CandidateTransformBlocks.Length);
Assert.Equal(2048, modeWorkspace.CandidateTransformBlocks.Length);
// CfL is unavailable above 32x32, so its scratch remains fixed while larger partitions are enabled.
Assert.Equal(Av1ChromaFromLumaContext.BufferLength, modeWorkspace.ChromaFromLumaSamples.Length);

62
tests/ImageSharp.Tests/Formats/Heif/HeifEncoderTests.cs

@ -8,6 +8,7 @@ using SixLabors.ImageSharp.Formats;
using SixLabors.ImageSharp.Formats.Heif;
using SixLabors.ImageSharp.Formats.Heif.Av1;
using SixLabors.ImageSharp.Formats.Heif.Av1.OpenBitstreamUnit;
using SixLabors.ImageSharp.Formats.Heif.Av1.Transform;
using SixLabors.ImageSharp.Memory;
using SixLabors.ImageSharp.Metadata;
using SixLabors.ImageSharp.Metadata.Profiles.Cicp;
@ -181,18 +182,69 @@ public class HeifEncoderTests
}
[Fact]
public void Av1RejectsLosslessEncodingBeforeWritingOutput()
public void Av1LosslessRoundTripPreservesColorAndAlpha()
{
using Image<Rgba32> image = new(1, 1);
const int width = 8;
const int height = 8;
using Image<Rgba32> image = new(width, height);
for (int row = 0; row < height; row++)
{
Span<Rgba32> pixels = image.Frames.RootFrame.PixelBuffer.DangerousGetRowSpan(row);
for (int column = 0; column < width; column++)
{
pixels[column] = new Rgba32(
(byte)((column * 31) + row),
(byte)((row * 29) + column),
(byte)((column * 17) + (row * 11)),
(byte)((column * 23) + (row * 7)));
}
}
// Identity-matrix 4:4:4 maps the packed RGB channels directly onto AV1 planes, so codec losslessness
// can be asserted against the original pixels without a separate color-conversion tolerance.
image.Metadata.CicpProfile = new CicpProfile(1, 13, 0, true);
using MemoryStream stream = new();
HeifEncoder encoder = new()
{
CompressionMethod = HeifCompressionMethod.Av1,
Lossless = true
Lossless = true,
Effort = 0
};
Assert.Throws<NotSupportedException>(() => image.Save(stream, encoder));
Assert.Equal(0, stream.Length);
image.Save(stream, encoder);
byte[] file = stream.ToArray();
Span<byte> colorPayload = GetItemPayload(file, 1);
using Av1Decoder colorDecoder = new(Configuration.Default);
using Image<Rgba32> colorImage = colorDecoder.Decode<Rgba32>(colorPayload);
ObuSequenceHeader colorSequenceHeader = Assert.IsType<ObuSequenceHeader>(colorDecoder.SequenceHeader);
ObuFrameHeader colorFrameHeader = Assert.IsType<ObuFrameHeader>(colorDecoder.FrameHeader);
Assert.Equal(Av1ColorFormat.Yuv444, colorSequenceHeader.ColorConfig.GetColorFormat());
Assert.Equal(0, colorFrameHeader.QuantizationParameters.BaseQIndex);
Assert.True(colorFrameHeader.CodedLossless);
Assert.True(colorFrameHeader.AllLossless);
Assert.Equal(Av1TransformMode.Only4x4, colorFrameHeader.TransformMode);
Span<byte> alphaPayload = GetItemPayload(file, 2);
using Av1Decoder alphaDecoder = new(Configuration.Default);
using Image<L8> alphaImage = alphaDecoder.Decode<L8>(alphaPayload);
ObuFrameHeader alphaFrameHeader = Assert.IsType<ObuFrameHeader>(alphaDecoder.FrameHeader);
Assert.Equal(0, alphaFrameHeader.QuantizationParameters.BaseQIndex);
Assert.True(alphaFrameHeader.CodedLossless);
stream.Position = 0;
using Image<Rgba32> decoded = Image.Load<Rgba32>(stream);
Assert.Empty(ImageComparer.Exact.CompareImages(image, decoded));
string outputDirectory = Path.Combine(
TestEnvironment.ActualOutputDirectoryFullPath,
"Formats",
"Heif",
"Av1");
Directory.CreateDirectory(outputDirectory);
File.WriteAllBytes(Path.Combine(outputDirectory, "encoder-public-lossless-color.obu"), colorPayload.ToArray());
File.WriteAllBytes(Path.Combine(outputDirectory, "encoder-public-lossless-alpha.obu"), alphaPayload.ToArray());
}
[Fact]

Loading…
Cancel
Save