From 105d76954cb6a0356cbe57d210b7bfb049e6a69f Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Mon, 7 Sep 2026 15:58:12 +1000 Subject: [PATCH] Add coordinated AV1 motion-search controller Compose weighted spatial and temporal starts, retained search geometry and motion rates, full-pixel and fractional search, DRL pruning, and transform-RD winner selection through closed sample operators. Borrow worker-owned storage and preserve reference search ordering, limits, precision, and empty cost-list policy. Release .NET 11 verification: 467 focused VSTest cases passed. Temporary local comparison with optimized libaom d565eec6 matched all 2,646 coordinated decisions, 486 full-pixel searches, and 1,944 fractional searches exactly. Native tooling and generated comparison data are excluded. This checkpoints the search backend. Production caller/configuration/history integration, scaled references, and full encoder parity remain unfinished. --- .../Heif/Av1/Entropy/Av1MotionVectorCosts.cs | 205 ++++++ .../Heif/Av1/Entropy/Av1SymbolEncoder.cs | 6 + .../Av1MotionSearchBase.ByteOperator.cs | 89 +++ .../Motion/Av1MotionSearchBase.Fractional.cs | 340 +++++++++ .../Motion/Av1MotionSearchBase.Operator.cs | 118 +++ .../Av1MotionSearchBase.SingleReference.cs | 651 +++++++++++++++++ .../Av1MotionSearchBase.UInt16Operator.cs | 90 +++ .../Heif/Av1/Motion/Av1MotionSearchBase.cs | 674 ++++++++++++++++++ .../Av1/Motion/Av1MotionSearchSettings.cs | 457 ++++++++++++ .../Heif/Av1/Motion/Av1MotionSearchSites.cs | 189 +++++ .../Heif/Av1/Motion/Av1MotionVector.cs | 62 ++ .../Av1/Pipeline/Av1EncoderBlockWorkspace.cs | 61 +- .../Av1TranslationalInterPredictor.Search.cs | 240 +++++++ .../Formats/Heif/HeifEncodingSpeed.cs | 61 ++ .../Heif/Av1/Av1MotionSearchSettingsTests.cs | 117 +++ .../Formats/Heif/Av1/Av1MotionSearchTests.cs | 579 +++++++++++++++ .../Heif/Av1/Av1MotionVectorCostsTests.cs | 87 +++ .../Formats/Heif/Av1/Av1MotionVectorTests.cs | 89 +++ 18 files changed, 4113 insertions(+), 2 deletions(-) create mode 100644 src/ImageSharp/Formats/Heif/Av1/Entropy/Av1MotionVectorCosts.cs create mode 100644 src/ImageSharp/Formats/Heif/Av1/Motion/Av1MotionSearchBase.ByteOperator.cs create mode 100644 src/ImageSharp/Formats/Heif/Av1/Motion/Av1MotionSearchBase.Fractional.cs create mode 100644 src/ImageSharp/Formats/Heif/Av1/Motion/Av1MotionSearchBase.Operator.cs create mode 100644 src/ImageSharp/Formats/Heif/Av1/Motion/Av1MotionSearchBase.SingleReference.cs create mode 100644 src/ImageSharp/Formats/Heif/Av1/Motion/Av1MotionSearchBase.UInt16Operator.cs create mode 100644 src/ImageSharp/Formats/Heif/Av1/Motion/Av1MotionSearchBase.cs create mode 100644 src/ImageSharp/Formats/Heif/Av1/Motion/Av1MotionSearchSettings.cs create mode 100644 src/ImageSharp/Formats/Heif/Av1/Motion/Av1MotionSearchSites.cs create mode 100644 src/ImageSharp/Formats/Heif/Av1/Prediction/Inter/Av1TranslationalInterPredictor.Search.cs create mode 100644 src/ImageSharp/Formats/Heif/HeifEncodingSpeed.cs create mode 100644 tests/ImageSharp.Tests/Formats/Heif/Av1/Av1MotionSearchSettingsTests.cs create mode 100644 tests/ImageSharp.Tests/Formats/Heif/Av1/Av1MotionSearchTests.cs create mode 100644 tests/ImageSharp.Tests/Formats/Heif/Av1/Av1MotionVectorCostsTests.cs diff --git a/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1MotionVectorCosts.cs b/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1MotionVectorCosts.cs new file mode 100644 index 0000000000..e89e0a39b2 --- /dev/null +++ b/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1MotionVectorCosts.cs @@ -0,0 +1,205 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using SixLabors.ImageSharp.Formats.Heif.Av1.Motion; + +namespace SixLabors.ImageSharp.Formats.Heif.Av1.Entropy; + +/// +/// Borrows the motion-vector rate tables retained by one encoder worker. +/// +internal readonly ref struct Av1MotionVectorCosts +{ + /// + /// The largest representable signed component difference, in eighth-sample units. + /// + public const int MaximumComponent = (1 << 14) - 1; + + private const int ComponentCount = (2 * MaximumComponent) + 1; + + /// + /// Storage for the joint symbols and two component pairs, one for each fractional precision. + /// + public const int StorageLength = 4 + (4 * ComponentCount); + + private readonly Span joint; + private readonly Span row; + private readonly Span column; + private readonly Av1MotionVectorPrecision precision; + + /// + /// Initializes a new instance of the struct. + /// + /// Worker-lifetime storage containing both precision tables. + /// The precision used by the current frame. + public Av1MotionVectorCosts(Span storage, Av1MotionVectorPrecision precision) + { + // Integer and quarter-sample frames share one pair. The eighth-sample pair remains separate so changing + // frame precision does not change the worker's allocation or the layout of its other scratch regions. + int offset = 4 + (precision == Av1MotionVectorPrecision.EighthSample ? 2 * ComponentCount : 0); + this.joint = storage[..4]; + this.row = storage.Slice(offset, ComponentCount); + this.column = storage.Slice(offset + ComponentCount, ComponentCount); + this.precision = precision; + } + + /// + /// Captures component rates from the current tile distributions without adapting them. + /// + /// The tile's current motion-vector distributions. + public void Fill(Av1MotionVectorContext context) + { + for (int i = 0; i < 4; i++) + { + this.joint[i] = Av1ProbabilityCost.GetSymbolCost(context.Joint, i); + } + + FillComponent(this.row, context.Vertical, this.precision); + FillComponent(this.column, context.Horizontal, this.precision); + } + + /// + /// Measures a candidate against its differential reference using the captured distributions. + /// + /// The candidate vector, in eighth-sample units. + /// The differential reference, in eighth-sample units. + /// The rate in 1/512-bit units. + public int GetCost(Av1MotionVector value, Av1MotionVector reference) + { + int rowDifference = value.Row - reference.Row; + int columnDifference = value.Column - reference.Column; + int jointType = (rowDifference != 0 ? 2 : 0) | (columnDifference != 0 ? 1 : 0); + return this.joint[jointType] + this.row[MaximumComponent + rowDifference] + this.column[MaximumComponent + columnDifference]; + } + + /// + /// Builds a signed component table by reusing the costs of shorter binary magnitudes. + /// + /// The complete signed component table, centered on zero. + /// The distributions for this axis. + /// The fractional symbols present in the frame. + private static void FillComponent(Span destination, Av1MotionVectorContext.Component component, Av1MotionVectorPrecision precision) + { + Span classCosts = stackalloc int[11]; + Span bitCosts = stackalloc int[20]; + Span fractionalCosts = stackalloc int[4]; + Span highPrecisionCosts = stackalloc int[2]; + Span costOffsets = stackalloc int[10]; + int positiveSignCost = Av1ProbabilityCost.GetSymbolCost(component.Sign, 0); + int negativeSignCost = Av1ProbabilityCost.GetSymbolCost(component.Sign, 1); + int signDifference = negativeSignCost - positiveSignCost; + + for (int i = 0; i < classCosts.Length; i++) + { + classCosts[i] = Av1ProbabilityCost.GetSymbolCost(component.MagnitudeClass, i); + } + + for (int i = 0; i < costOffsets.Length; i++) + { + bitCosts[2 * i] = Av1ProbabilityCost.GetSymbolCost(component.OffsetBits[i], 0); + bitCosts[(2 * i) + 1] = Av1ProbabilityCost.GetSymbolCost(component.OffsetBits[i], 1); + } + + // Omitting fractional symbols gives them zero rate. All entries are assigned explicitly because stack + // storage is uninitialized, including when integer motion disables both fractional syntax stages. + for (int i = 0; i < fractionalCosts.Length; i++) + { + fractionalCosts[i] = precision == Av1MotionVectorPrecision.Integer ? 0 : Av1ProbabilityCost.GetSymbolCost(component.Fractional, i); + } + + for (int i = 0; i < highPrecisionCosts.Length; i++) + { + highPrecisionCosts[i] = precision == Av1MotionVectorPrecision.EighthSample + ? Av1ProbabilityCost.GetSymbolCost(component.HighPrecision, i) + : 0; + } + + costOffsets[0] = 0; + for (int i = 1; i < costOffsets.Length; i++) + { + // A shorter magnitude's leading one becomes an offset bit in a larger magnitude. Remove its + // former class rate and insert that bit's rate before adding the new magnitude class below. + costOffsets[i] = bitCosts[(2 * (i - 1)) + 1] - (i > 1 ? classCosts[i - 1] : 0); + } + + destination[MaximumComponent] = 0; + for (int fractional = 0; fractional < 4; fractional++) + { + for (int highPrecision = 0; highPrecision < 2; highPrecision++) + { + int magnitude = (2 * fractional) + highPrecision + 1; + destination[MaximumComponent + magnitude] = fractionalCosts[fractional] + highPrecisionCosts[highPrecision] + positiveSignCost; + } + } + + // Magnitudes encode value minus one. Each exponent doubles the integer offset range, reusing the + // previously completed lower half. The first eight entries temporarily carry fractional and sign rates + // alone; class-zero syntax is installed only after all larger magnitudes have consumed those seeds. + for (int exponentIndex = 0; exponentIndex < 10; exponentIndex++) + { + int exponent = 8 << exponentIndex; + int classCost = exponentIndex >= 1 ? classCosts[exponentIndex] : 0; + int mantissa = 0; + for (int bit = 0; bit <= exponentIndex; bit++) + { + for (; mantissa < (8 << bit); mantissa++) + { + int cost = destination[MaximumComponent + mantissa + 1] + classCost + costOffsets[bit]; + int magnitude = exponent + mantissa + 1; + destination[MaximumComponent + magnitude] = cost; + destination[MaximumComponent - magnitude] = cost + signDifference; + } + + // The next exponent introduces one more leading zero in this mantissa group. + costOffsets[bit] += bitCosts[2 * exponentIndex]; + } + } + + // The final exponent ends at 16383, one entry before the next power of two. Treat its upper mantissa + // separately to avoid producing the unrepresentable magnitude 16384 or reading an eleventh offset bit. + int finalMantissa = 0; + for (int bit = 0; bit < 10; bit++) + { + for (; finalMantissa < (8 << bit); finalMantissa++) + { + int cost = destination[MaximumComponent + finalMantissa + 1] + classCosts[10] + costOffsets[bit]; + int magnitude = 8192 + finalMantissa + 1; + destination[MaximumComponent + magnitude] = cost; + destination[MaximumComponent - magnitude] = cost + signDifference; + } + } + + int finalOffset = bitCosts[19] - classCosts[9]; + for (; finalMantissa < 8191; finalMantissa++) + { + int cost = destination[MaximumComponent + finalMantissa + 1] + classCosts[10] + finalOffset; + int magnitude = 8192 + finalMantissa + 1; + destination[MaximumComponent + magnitude] = cost; + destination[MaximumComponent - magnitude] = cost + signDifference; + } + + for (int integerOffset = 0; integerOffset < 2; integerOffset++) + { + int classZeroCost = classCosts[0] + Av1ProbabilityCost.GetSymbolCost(component.ClassZero, integerOffset); + for (int fractional = 0; fractional < 4; fractional++) + { + int cost = classZeroCost; + if (precision != Av1MotionVectorPrecision.Integer) + { + cost += Av1ProbabilityCost.GetSymbolCost(component.ClassZeroFractional[integerOffset], fractional); + } + + for (int highPrecision = 0; highPrecision < 2; highPrecision++) + { + int magnitude = (8 * integerOffset) + (2 * fractional) + highPrecision + 1; + int fractionalCost = precision == Av1MotionVectorPrecision.EighthSample + ? Av1ProbabilityCost.GetSymbolCost(component.ClassZeroHighPrecision, highPrecision) + : 0; + + destination[MaximumComponent + magnitude] = cost + fractionalCost + positiveSignCost; + destination[MaximumComponent - magnitude] = cost + fractionalCost + negativeSignCost; + } + } + } + } +} diff --git a/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolEncoder.cs b/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolEncoder.cs index 1720c5898e..1d6fb6b805 100644 --- a/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolEncoder.cs +++ b/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolEncoder.cs @@ -900,6 +900,12 @@ internal sealed class Av1SymbolEncoder : IDisposable w.WriteSymbol(advance, this.dynamicReferenceList[context]); } + /// + /// Captures the current motion-vector distributions for a subsequent motion-search interval. + /// + /// The worker-owned rate tables to refresh. + public void FillMotionVectorCosts(Av1MotionVectorCosts costs) => costs.Fill(this.motionVector); + /// /// Measures an inter motion vector relative to its selected stack reference. /// diff --git a/src/ImageSharp/Formats/Heif/Av1/Motion/Av1MotionSearchBase.ByteOperator.cs b/src/ImageSharp/Formats/Heif/Av1/Motion/Av1MotionSearchBase.ByteOperator.cs new file mode 100644 index 0000000000..0bbcf15271 --- /dev/null +++ b/src/ImageSharp/Formats/Heif/Av1/Motion/Av1MotionSearchBase.ByteOperator.cs @@ -0,0 +1,89 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using SixLabors.ImageSharp.Formats.Heif.Av1.Pipeline; +using SixLabors.ImageSharp.Formats.Heif.Av1.Prediction.Inter; + +namespace SixLabors.ImageSharp.Formats.Heif.Av1.Motion; + +internal static partial class Av1MotionSearchBase +{ + /// + /// Measures eight-bit sample errors with the shared vector-width residual traversal. + /// + public readonly struct ByteOperator : IMotionSearchOperator + { + /// + public static void PreparePrediction( + ReadOnlySpan source, + int sourceStride, + ReadOnlySpan reference, + int referenceStride, + int referenceOrigin, + Span prediction, + Span residual, + Span scratch, + int width, + int height, + Av1InterpolationFilter horizontalFilter, + Av1InterpolationFilter verticalFilter, + int horizontalPhase, + int verticalPhase, + int bitDepth) + { + Av1TranslationalInterPredictor.Predict( + reference, + referenceStride, + referenceOrigin, + prediction, + width, + width, + height, + horizontalFilter, + verticalFilter, + horizontalPhase, + verticalPhase, + scratch); + + Av1ResidualBuilder.Subtract(source, sourceStride, prediction, width, residual, width, width, height); + } + + /// + public static void Predict( + ReadOnlySpan reference, + int referenceStride, + int referenceOrigin, + Span buffer, + int width, + int height, + int horizontalPhase, + int verticalPhase, + int taps, + int bitDepth) + => Av1TranslationalInterPredictor.PredictForSearch( + reference, referenceStride, referenceOrigin, buffer, width, height, horizontalPhase, verticalPhase, taps); + + /// + public static int SumAbsoluteDifferences( + ReadOnlySpan source, + int sourceStride, + ReadOnlySpan prediction, + int predictionStride, + int width, + int height, + int rowStep) + => Av1ResidualBuilder.SumAbsoluteDifferences(source, sourceStride, prediction, predictionStride, width, height, rowStep); + + /// + public static void GetMoments( + ReadOnlySpan source, + int sourceStride, + ReadOnlySpan prediction, + int predictionStride, + int width, + int height, + out int sum, + out long squares) + => Av1ResidualBuilder.GetMoments(source, sourceStride, prediction, predictionStride, width, height, out sum, out squares); + } +} diff --git a/src/ImageSharp/Formats/Heif/Av1/Motion/Av1MotionSearchBase.Fractional.cs b/src/ImageSharp/Formats/Heif/Av1/Motion/Av1MotionSearchBase.Fractional.cs new file mode 100644 index 0000000000..c2cf4e2e01 --- /dev/null +++ b/src/ImageSharp/Formats/Heif/Av1/Motion/Av1MotionSearchBase.Fractional.cs @@ -0,0 +1,340 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using SixLabors.ImageSharp.Formats.Heif.Av1.Entropy; +using static SixLabors.ImageSharp.Formats.Heif.Av1.Motion.Av1MotionSearchSettings; + +namespace SixLabors.ImageSharp.Formats.Heif.Av1.Motion; + +internal static partial class Av1MotionSearchBase +{ + /// + /// Retains the fractional winner and the error statistics used to select it. + /// + public readonly struct FractionalResult + { + /// + /// Initializes a new instance of the struct. + /// + /// The displacement in eighth-sample units. + /// The normalized residual variance. + /// The normalized squared residual sum. + /// The variance-domain rate cost. + public FractionalResult(Av1MotionVector vector, int variance, int squaredError, int motionCost) + { + this.Vector = vector; + this.Variance = variance; + this.SquaredError = squaredError; + this.MotionCost = motionCost; + } + + /// + /// Gets the displacement in eighth-sample units. + /// + public Av1MotionVector Vector { get; } + + /// + /// Gets the normalized residual variance. + /// + public int Variance { get; } + + /// + /// Gets the normalized squared residual sum. + /// + public int SquaredError { get; } + + /// + /// Gets the variance-domain rate cost. + /// + public int MotionCost { get; } + + /// + /// Gets the combined selection cost. + /// + public int Cost => this.Variance + this.MotionCost; + } + + /// + /// Refines an unscaled reference prediction while borrowing the worker's sample and entropy storage. + /// + /// The unsigned sample storage type. + /// The closed prediction and error operator. + public readonly ref struct FractionalSearch + where TSample : unmanaged + where TOperator : struct, IMotionSearchOperator + { + private readonly ReadOnlySpan source; + private readonly ReadOnlySpan reference; + private readonly Span prediction; + private readonly int sourceStride; + private readonly int referenceStride; + private readonly int referenceOrigin; + private readonly Size blockSize; + private readonly Rectangle bounds; + private readonly Av1MotionVector referenceVector; + private readonly Av1MotionVectorCosts costs; + private readonly int bitDepth; + private readonly int rateMultiplier; + + /// + /// Initializes a new instance of the struct. + /// + /// Source samples beginning at the block origin. + /// The source row stride in samples. + /// The complete bordered reference plane. + /// The reference row stride in samples. + /// The reference index corresponding to the current block origin. + /// The worker's reusable fractional prediction buffer. + /// The prediction dimensions. + /// The permitted eighth-sample displacements, with exclusive upper edges. + /// The spatial entropy reference in eighth-sample units. + /// The retained motion-rate tables. + /// The coded component precision. + /// The block rate multiplier. + public FractionalSearch( + ReadOnlySpan source, + int sourceStride, + ReadOnlySpan reference, + int referenceStride, + int referenceOrigin, + Span prediction, + Size blockSize, + Rectangle bounds, + Av1MotionVector referenceVector, + Av1MotionVectorCosts costs, + Av1BitDepth bitDepth, + int rateMultiplier) + { + this.source = source; + this.sourceStride = sourceStride; + this.reference = reference; + this.referenceStride = referenceStride; + this.referenceOrigin = referenceOrigin; + this.prediction = prediction; + this.blockSize = blockSize; + this.bounds = bounds; + this.referenceVector = referenceVector; + this.costs = costs; + this.bitDepth = bitDepth.GetBitCount(); + this.rateMultiplier = rateMultiplier; + } + + /// + /// Runs the selected fractional tree and publishes its retained winner. + /// + /// The initial eighth-sample displacement. + /// Integer-search statistics when that starting prediction was already measured. + /// The fractional decision policy. + /// The finest permitted search step. + /// Whether eighth-sample candidates are enabled by the frame. + /// The number of refinement levels at each precision. + /// The full tree's interpolation tap count. + /// The integer center, left, down, right, and up costs, or an empty span when unavailable. + /// Three retained precision centers, or an empty span when duplicate pruning is disabled. + /// The selected vector and its error statistics. + /// The selected cost, or when a previously searched center terminates the path. + public int Search( + Av1MotionVector start, + FullPixelResult? startStatistics, + FractionalSearchMethod method, + SearchPrecision precision, + bool allowHighPrecision, + int iterationsPerStep, + int taps, + ReadOnlySpan costList, + Span previousCenters, + out FractionalResult result) + { + // Integer search has already paid for these moments. Retain that exact error domain, including + // its signed high-depth rounding, until a fractional candidate strictly improves the total cost. + if (startStatistics.HasValue) + { + FullPixelResult statistics = startStatistics.Value; + result = new FractionalResult(start, statistics.Variance, statistics.SquaredError, statistics.MotionCost); + } + else + { + result = this.Measure(start, method == FractionalSearchMethod.TwoLevelTree ? taps : 2); + } + + int rounds = Math.Min(3 - (int)precision, allowHighPrecision ? 3 : 2); + for (int iteration = 0, step = 4; iteration < rounds; iteration++, step >>= 1) + { + Av1MotionVector center = result.Vector; + if (!previousCenters.IsEmpty) + { + // Each slot belongs to one precision. Another starting candidate reaching the same + // center has the same remaining tree, so the caller can discard this duplicate path. + if (previousCenters[iteration] == center) + { + return int.MaxValue; + } + + previousCenters[iteration] = center; + } + + bool finiteNeighborhood = costList.Length == 5 + && costList[0] != int.MaxValue + && costList[1] != int.MaxValue + && costList[2] != int.MaxValue + && costList[3] != int.MaxValue + && costList[4] != int.MaxValue; + + if (iteration == 0 && method == FractionalSearchMethod.PrunedTree && finiteNeighborhood) + { + // Half-sample pruning chooses one quadrant from the integer cost surface. Ties select + // right and up here; the measured-cardinal tree below instead breaks ties left and up. + int column = costList[1] < costList[3] ? -step : step; + int row = costList[2] < costList[4] ? step : -step; + this.Check(new Av1MotionVector(center.Row, center.Column + column), 2, ref result); + this.Check(new Av1MotionVector(center.Row + row, center.Column), 2, ref result); + this.Check(new Av1MotionVector(center.Row + row, center.Column + column), 2, ref result); + continue; + } + + if (iteration == 0 && method == FractionalSearchMethod.MorePrunedTree && finiteNeighborhood + && costList[0] < costList[1] && costList[0] < costList[2] + && costList[0] < costList[3] && costList[0] < costList[4]) + { + // A strictly lower center gives positive curvature on both axes. The minimum of each + // fitted parabola is (negative-side cost - positive-side cost) / (2 * curvature). + // Multiplying that location by two gives half-sample units; signed division rounds + // the displacement to the nearest such unit before converting it to eighth samples. + int columnNumerator = costList[1] - costList[3]; + int columnDenominator = costList[1] - (2 * costList[0]) + costList[3]; + int rowNumerator = costList[4] - costList[2]; + int rowDenominator = costList[4] - (2 * costList[0]) + costList[2]; + int column = (columnNumerator + (columnNumerator < 0 ? -columnDenominator / 2 : columnDenominator / 2)) + / columnDenominator; + int row = (rowNumerator + (rowNumerator < 0 ? -rowDenominator / 2 : rowDenominator / 2)) / rowDenominator; + + if ((row | column) != 0) + { + this.Check(new Av1MotionVector(center.Row + (row * step), center.Column + (column * step)), 2, ref result); + } + + continue; + } + + int selectedTaps = method == FractionalSearchMethod.TwoLevelTree ? taps : 2; + int left = this.Check(new Av1MotionVector(center.Row, center.Column - step), selectedTaps, ref result); + int right = this.Check(new Av1MotionVector(center.Row, center.Column + step), selectedTaps, ref result); + int up = this.Check(new Av1MotionVector(center.Row - step, center.Column), selectedTaps, ref result); + int down = this.Check(new Av1MotionVector(center.Row + step, center.Column), selectedTaps, ref result); + int diagonalRow = up <= down ? -step : step; + int diagonalColumn = left <= right ? -step : step; + this.Check(new Av1MotionVector(center.Row + diagonalRow, center.Column + diagonalColumn), selectedTaps, ref result); + + if (iterationsPerStep > 1 && result.Vector != center) + { + // All second-level sites are anchored to the first-level winner. Updating that winner + // while measuring these sites must not move the remaining sites of the same level. + Av1MotionVector winner = result.Vector; + if (method == FractionalSearchMethod.TwoLevelTree) + { + if (winner.Row == center.Row) + { + diagonalRow = -diagonalRow; + } + else if (winner.Column == center.Column) + { + diagonalColumn = -diagonalColumn; + } + + int previousCost = result.Cost; + this.Check(new Av1MotionVector(winner.Row + diagonalRow, winner.Column), selectedTaps, ref result); + this.Check(new Av1MotionVector(winner.Row, winner.Column + diagonalColumn), selectedTaps, ref result); + + // Extend to the outward diagonal only when an outward cardinal site improved. + if (result.Cost < previousCost) + { + this.Check(new Av1MotionVector(winner.Row + diagonalRow, winner.Column + diagonalColumn), selectedTaps, ref result); + } + } + else if (winner.Row != center.Row && winner.Column != center.Column) + { + this.Check(new Av1MotionVector(winner.Row, winner.Column + diagonalColumn), 2, ref result); + this.Check(new Av1MotionVector(winner.Row + diagonalRow, winner.Column), 2, ref result); + } + else if (winner.Row == center.Row) + { + this.Check(new Av1MotionVector(winner.Row + step, winner.Column + diagonalColumn), 2, ref result); + this.Check(new Av1MotionVector(winner.Row - step, winner.Column + diagonalColumn), 2, ref result); + this.Check(new Av1MotionVector(winner.Row - diagonalRow, winner.Column), 2, ref result); + } + else + { + this.Check(new Av1MotionVector(winner.Row + diagonalRow, winner.Column + step), 2, ref result); + this.Check(new Av1MotionVector(winner.Row + diagonalRow, winner.Column - step), 2, ref result); + this.Check(new Av1MotionVector(winner.Row, winner.Column - diagonalColumn), 2, ref result); + } + } + } + + return result.Cost; + } + + /// + /// Measures an in-range candidate and replaces the retained winner only for a strictly smaller cost. + /// + private int Check(Av1MotionVector vector, int taps, ref FractionalResult best) + { + if (!this.bounds.Contains(vector.Column, vector.Row)) + { + return int.MaxValue; + } + + FractionalResult candidate = this.Measure(vector, taps); + if (candidate.Cost < best.Cost) + { + best = candidate; + } + + return candidate.Cost; + } + + /// + /// Filters the borrowed reference and measures prediction-minus-source moments in the search error domain. + /// + private FractionalResult Measure(Av1MotionVector vector, int taps) + { + int referenceIndex = this.referenceOrigin + ((vector.Row >> 3) * this.referenceStride) + (vector.Column >> 3); + TOperator.Predict( + this.reference, + this.referenceStride, + referenceIndex, + this.prediction, + this.blockSize.Width, + this.blockSize.Height, + vector.Column & 7, + vector.Row & 7, + taps, + this.bitDepth); + + TOperator.GetMoments( + this.prediction, + this.blockSize.Width, + this.source, + this.sourceStride, + this.blockSize.Width, + this.blockSize.Height, + out int sum, + out long squares); + + int precisionShift = this.bitDepth - 8; + if (precisionShift != 0) + { + // Prediction is the first operand: signed rounding is asymmetric for negative residual + // sums. Normalize that sum and its squares independently before subtracting the mean. + sum = (sum + (1 << (precisionShift - 1))) >> precisionShift; + int squaredShift = precisionShift * 2; + squares = (squares + (1L << (squaredShift - 1))) >> squaredShift; + } + + int variance = (int)Math.Max(squares - (((long)sum * sum) / (this.blockSize.Width * this.blockSize.Height)), 0); + int rate = this.costs.GetCost(vector, this.referenceVector); + int motionCost = Av1RateDistortion.GetMotionSearchCost(this.rateMultiplier, rate, 0); + return new FractionalResult(vector, variance, (int)squares, motionCost); + } + } +} diff --git a/src/ImageSharp/Formats/Heif/Av1/Motion/Av1MotionSearchBase.Operator.cs b/src/ImageSharp/Formats/Heif/Av1/Motion/Av1MotionSearchBase.Operator.cs new file mode 100644 index 0000000000..94741cdd7f --- /dev/null +++ b/src/ImageSharp/Formats/Heif/Av1/Motion/Av1MotionSearchBase.Operator.cs @@ -0,0 +1,118 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using SixLabors.ImageSharp.Formats.Heif.Av1.Prediction.Inter; + +namespace SixLabors.ImageSharp.Formats.Heif.Av1.Motion; + +internal static partial class Av1MotionSearchBase +{ + /// + /// Measures unsigned sample planes without changing the motion controller's error domains. + /// + /// The unsigned component storage type. + public interface IMotionSearchOperator + where TSample : unmanaged + { + /// + /// Builds the final inter predictor and its residual for transform-based winner selection. + /// + /// The source samples at the block origin. + /// The source row stride. + /// The complete bordered reference plane. + /// The reference row stride. + /// The displaced integer reference origin. + /// The packed prediction destination. + /// The packed residual destination. + /// The signed intermediate convolution storage. + /// The prediction width. + /// The prediction height. + /// The final horizontal interpolation family. + /// The final vertical interpolation family. + /// The horizontal phase in one-sixteenth-sample units. + /// The vertical phase in one-sixteenth-sample units. + /// The coded sample precision. + public static abstract void PreparePrediction( + ReadOnlySpan source, + int sourceStride, + ReadOnlySpan reference, + int referenceStride, + int referenceOrigin, + Span prediction, + Span residual, + Span scratch, + int width, + int height, + Av1InterpolationFilter horizontalFilter, + Av1InterpolationFilter verticalFilter, + int horizontalPhase, + int verticalPhase, + int bitDepth); + + /// + /// Produces a packed fractional prediction with each filter pass rounded to sample precision. + /// + /// The bordered reference plane. + /// The reference row stride. + /// The integer prediction origin. + /// The borrowed prediction and intermediate buffer. + /// The block width. + /// The block height. + /// The horizontal eighth-sample phase. + /// The vertical eighth-sample phase. + /// The search filter's tap count. + /// The coded precision. + static abstract void Predict( + ReadOnlySpan reference, + int referenceStride, + int referenceOrigin, + Span buffer, + int width, + int height, + int horizontalPhase, + int verticalPhase, + int taps, + int bitDepth); + + /// + /// Measures raw absolute differences, doubling alternate-row results before precision normalization. + /// + /// The source block samples. + /// The source row stride. + /// The prediction block samples. + /// The prediction row stride. + /// The block width. + /// The block height. + /// One for all rows or two for alternate rows. + /// The raw absolute-difference sum. + static abstract int SumAbsoluteDifferences( + ReadOnlySpan source, + int sourceStride, + ReadOnlySpan prediction, + int predictionStride, + int width, + int height, + int rowStep); + + /// + /// Measures raw signed and squared residual sums without materializing a residual plane. + /// + /// The source block samples. + /// The source row stride. + /// The prediction block samples. + /// The prediction row stride. + /// The block width. + /// The block height. + /// The raw signed residual sum. + /// The raw squared residual sum. + static abstract void GetMoments( + ReadOnlySpan source, + int sourceStride, + ReadOnlySpan prediction, + int predictionStride, + int width, + int height, + out int sum, + out long squares); + } +} diff --git a/src/ImageSharp/Formats/Heif/Av1/Motion/Av1MotionSearchBase.SingleReference.cs b/src/ImageSharp/Formats/Heif/Av1/Motion/Av1MotionSearchBase.SingleReference.cs new file mode 100644 index 0000000000..0b0fe44f47 --- /dev/null +++ b/src/ImageSharp/Formats/Heif/Av1/Motion/Av1MotionSearchBase.SingleReference.cs @@ -0,0 +1,651 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using SixLabors.ImageSharp.Formats.Heif.Av1.Entropy; +using SixLabors.ImageSharp.Formats.Heif.Av1.Pipeline; +using SixLabors.ImageSharp.Formats.Heif.Av1.Prediction.Inter; +using SixLabors.ImageSharp.Formats.Heif.Av1.Transform; +using static SixLabors.ImageSharp.Formats.Heif.Av1.Motion.Av1MotionSearchSettings; + +namespace SixLabors.ImageSharp.Formats.Heif.Av1.Motion; + +internal static partial class Av1MotionSearchBase +{ + /// + /// Converts a full-sample motion extent to the initial number of excluded search stages. + /// + /// The frame dimension or retained spatial motion magnitude. + /// The initial search-step parameter. + public static int GetInitialStepParameter(int size) + { + size = Math.Max(size, 16); + int step = 0; + while ((size << step) < 1023) + { + step++; + } + + return Math.Min(step, 9); + } + + /// + /// Collects weighted temporal starting vectors for one prediction block. + /// + /// The rounded spatial reference displacement in full samples. + /// The temporal analysis vectors at the block's analysis-grid origin. + /// The analysis row stride in vectors. + /// The number of analysis columns and rows covered by the block. + /// Storage for the spatial start and every covered analysis block. + /// The represented weight, or zero when analysis is incomplete. + /// The number of collected starting candidates. + public static int CollectStartingCandidates( + Point spatialStart, + ReadOnlySpan temporalVectors, + int temporalStride, + Size analysisSize, + Span candidates, + out int totalWeight) + { + candidates[0] = new StartingCandidate(spatialStart, 0); + totalWeight = 0; + int count = 1; + int analysisCount = analysisSize.Width * analysisSize.Height; + if (analysisCount != 0) + { + // The spatial start receives one vote per analysis block before temporal votes are added. + // It therefore remains among the first starts even when the temporal field is fragmented. + candidates[0] = new StartingCandidate(spatialStart, analysisCount); + for (int y = 0; y < analysisSize.Height; y++) + { + for (int x = 0; x < analysisSize.Width; x++) + { + Av1MotionVector vector = temporalVectors[(y * temporalStride) + x]; + if (vector.Row == short.MinValue && vector.Column == short.MinValue) + { + // Analysis may end partway through a block. Retain the collected prefix, but do not + // apply completed-field weighting or reorder it as if all temporal votes were available. + return count; + } + + Point position = new(vector.Column >> 3, vector.Row >> 3); + int rowGroup = (position.Y + 3 + (position.Y >= 0 ? 1 : 0)) >> 3; + int columnGroup = (position.X + 3 + (position.X >= 0 ? 1 : 0)) >> 3; + int index = 0; + for (; index < count; index++) + { + Point existing = candidates[index].Vector; + + // Temporal starts are grouped into rounded eight-sample cells after conversion to + // full samples. Keep the first representative position while accumulating its votes. + if (((existing.Y + 3 + (existing.Y >= 0 ? 1 : 0)) >> 3) == rowGroup + && ((existing.X + 3 + (existing.X >= 0 ? 1 : 0)) >> 3) == columnGroup) + { + candidates[index] = new StartingCandidate(existing, candidates[index].Weight + 1); + break; + } + } + + if (index == count) + { + candidates[count++] = new StartingCandidate(position, 1); + } + } + } + + totalWeight = 2 * analysisCount; + if (count > 2) + { + candidates[..count].Sort(default(StartingCandidateWeightComparer)); + } + } + + return count; + } + + /// + /// Holds one weighted full-sample starting position from spatial or temporal analysis. + /// + public readonly struct StartingCandidate + { + /// + /// Initializes a new instance of the struct. + /// + /// The starting displacement in full samples. + /// The number of represented analysis blocks. + public StartingCandidate(Point vector, int weight) + { + this.Vector = vector; + this.Weight = weight; + } + + /// + /// Gets the full-sample displacement. + /// + public Point Vector { get; } + + /// + /// Gets the number of represented analysis blocks. + /// + public int Weight { get; } + } + + /// + /// Retains motion-search results and mode decisions for one differential-reference choice. + /// + public struct ReferenceSearchResult + { + public Av1MotionVector ReferenceVector; + public Av1MotionVector FullVector; + public Av1MotionVector Vector; + public int FullRate; + public int FullCost; + public int Rate; + public int DrlRate; + public bool HasFullResult; + public bool IsValid; + public bool Skip; + } + + /// + /// Retains the six possible starts and three reference results across one block's new-motion modes. + /// + public struct SingleReferenceState + { + public InlineArray6 Starts; + public InlineArray6 StartReferenceIndices; + public InlineArray3 References; + public int StartCount; + } + + /// + /// Coordinates single-reference starting candidates, full-pixel search, fractional refinement, and winner estimation. + /// + /// The unsigned sample storage type. + /// The sample-specific prediction and error operations. + public readonly ref struct SingleReferenceSearch + where TSample : unmanaged + where TOperator : struct, IMotionSearchOperator + { + private readonly ReadOnlySpan source; + private readonly int sourceStride; + private readonly ReadOnlySpan reference; + private readonly int referenceStride; + private readonly int referenceOrigin; + private readonly Av1BlockSize blockSize; + private readonly Rectangle frameBounds; + private readonly Av1EncoderBlockWorkspace workspace; + private readonly Span prediction; + private readonly Span residual; + private readonly Span convolutionScratch; + private readonly Span quantized; + private readonly Av1SymbolEncoder writer; + private readonly ReadOnlySpan aboveContexts; + private readonly ReadOnlySpan leftContexts; + private readonly Av1BitDepth bitDepth; + private readonly int qIndex; + private readonly int dcDeltaQ; + private readonly int sharpness; + private readonly bool lossless; + private readonly int rateMultiplier; + private readonly int transformSizeRate; + private readonly int noSkipRate; + private readonly int skipRate; + private readonly Av1InterpolationFilter horizontalFilter; + private readonly Av1InterpolationFilter verticalFilter; + private readonly Av1MotionVectorCosts motionCosts; + + /// + /// Initializes a new instance of the struct. + /// + /// The source samples at the prediction-block origin. + /// The source row stride. + /// The complete bordered reference plane. + /// The reference row stride. + /// The reference origin corresponding to zero displacement. + /// The containing prediction block size. + /// The full-sample frame search limits before differential-vector limits. + /// The worker transform and search-site storage. + /// The worker search prediction buffer, also reused for final predictions. + /// The packed block residual destination. + /// The signed intermediate storage for final prediction. + /// The scratch quantized coefficients for one transform. + /// The current tile probability state. + /// The incoming top coefficient contexts. + /// The incoming left coefficient contexts. + /// The coded sample precision. + /// The effective segment quantizer index. + /// The luma DC quantizer adjustment. + /// The quantization sharpness setting. + /// Whether the segment is coded losslessly. + /// The block rate-distortion multiplier. + /// The transform partition rate used by winner estimation. + /// The rate of a non-skipped prediction block. + /// The rate of a skipped prediction block. + /// The final horizontal interpolation family. + /// The final vertical interpolation family. + /// The retained differential motion-rate table. + public SingleReferenceSearch( + ReadOnlySpan source, + int sourceStride, + ReadOnlySpan reference, + int referenceStride, + int referenceOrigin, + Av1BlockSize blockSize, + Rectangle frameBounds, + Av1EncoderBlockWorkspace workspace, + Span prediction, + Span residual, + Span convolutionScratch, + Span quantized, + Av1SymbolEncoder writer, + ReadOnlySpan aboveContexts, + ReadOnlySpan leftContexts, + Av1BitDepth bitDepth, + int qIndex, + int dcDeltaQ, + int sharpness, + bool lossless, + int rateMultiplier, + int transformSizeRate, + int noSkipRate, + int skipRate, + Av1InterpolationFilter horizontalFilter, + Av1InterpolationFilter verticalFilter, + Av1MotionVectorCosts motionCosts) + { + this.source = source; + this.sourceStride = sourceStride; + this.reference = reference; + this.referenceStride = referenceStride; + this.referenceOrigin = referenceOrigin; + this.blockSize = blockSize; + this.frameBounds = frameBounds; + this.workspace = workspace; + this.prediction = prediction; + this.residual = residual; + this.convolutionScratch = convolutionScratch; + this.quantized = quantized; + this.writer = writer; + this.aboveContexts = aboveContexts; + this.leftContexts = leftContexts; + this.bitDepth = bitDepth; + this.qIndex = qIndex; + this.dcDeltaQ = dcDeltaQ; + this.sharpness = sharpness; + this.lossless = lossless; + this.rateMultiplier = rateMultiplier; + this.transformSizeRate = transformSizeRate; + this.noSkipRate = noSkipRate; + this.skipRate = skipRate; + this.horizontalFilter = horizontalFilter; + this.verticalFilter = verticalFilter; + this.motionCosts = motionCosts; + } + + /// + /// Searches one differential-reference choice while retaining state for subsequent choices. + /// + /// The resolved frame search policy. + /// The frame's initial number of excluded outer search stages. + /// The largest full-sample magnitude in this reference's spatial context. + /// Whether the current frame is presented. + /// The optional range reduction, or the maximum integer for no reduction. + /// Whether the frame prohibits fractional motion vectors. + /// Whether eighth-sample vectors are permitted. + /// Whether content classification caps the first mesh interval. + /// The current dynamic-reference index. + /// The differential coding reference in eighth-sample units. + /// The syntax rate selecting this differential reference. + /// Weighted starting positions in decreasing weight order. + /// The total represented weight before selecting the first two starts. + /// The block's retained results; initialize once before its first new-motion mode. + /// The selected displacement and prediction-error statistics. + /// Whether the search produced a valid candidate. + public bool Search( + Av1MotionSearchSettings settings, + int frameStepParameter, + int spatialMagnitude, + bool showFrame, + int searchRange, + bool forceInteger, + bool allowHighPrecision, + bool fineMeshInterval, + int referenceIndex, + Av1MotionVector referenceVector, + int drlRate, + ReadOnlySpan starts, + int totalWeight, + ref SingleReferenceState state, + out FractionalResult result) + { + ref ReferenceSearchResult current = ref state.References[referenceIndex]; + current.ReferenceVector = referenceVector; + current.DrlRate = drlRate; + int stepParameter = frameStepParameter; + if (settings.AutomaticStepSizeLevel != 0 && showFrame) + { + stepParameter = (GetInitialStepParameter(spatialMagnitude) + frameStepParameter) / 2; + } + + // The frame may supply many temporal starts, but only its first two ranked candidates enter + // this search. Record both before searching: the weight cutoff does not undo start history. + int candidateCount = Math.Min(2, starts.Length); + Span rejected = stackalloc bool[2]; + rejected.Clear(); + Point fullReference = new( + (referenceVector.Column + 3 + (referenceVector.Column >= 0 ? 1 : 0)) >> 3, + (referenceVector.Row + 3 + (referenceVector.Row >= 0 ? 1 : 0)) >> 3); + + if (settings.StartCandidatePruningLevel != 0) + { + for (int candidateIndex = 0; candidateIndex < candidateCount; candidateIndex++) + { + Point start = starts[candidateIndex].Vector; + for (int historyIndex = 0; historyIndex < state.StartCount; historyIndex++) + { + int previousIndex = state.StartReferenceIndices[historyIndex]; + ref ReferenceSearchResult previous = ref state.References[previousIndex]; + if (!previous.IsValid && previousIndex != referenceIndex) + { + continue; + } + + Point previousStart = state.Starts[historyIndex]; + Av1MotionVector previousReference = previous.ReferenceVector; + int previousColumn = (previousReference.Column + 3 + (previousReference.Column >= 0 ? 1 : 0)) >> 3; + int previousRow = (previousReference.Row + 3 + (previousReference.Row >= 0 ? 1 : 0)) >> 3; + int startX = Math.Abs(start.X - previousStart.X); + int startY = Math.Abs(start.Y - previousStart.Y); + int referenceX = Math.Abs(fullReference.X - previousColumn); + int referenceY = Math.Abs(fullReference.Y - previousRow); + bool duplicates = settings.StartCandidatePruningLevel >= 2 + ? startX <= 1 && startY <= 1 && referenceX <= 1 && referenceY <= 1 + : startX + startY <= 1 && referenceX + referenceY <= 1; + + if (duplicates) + { + rejected[candidateIndex] = true; + break; + } + } + + if (!rejected[candidateIndex]) + { + state.Starts[state.StartCount] = start; + state.StartReferenceIndices[state.StartCount++] = (byte)referenceIndex; + } + } + } + + FullPixelSearchMethod method = settings.GetFullPixelMethod(this.blockSize); + Av1MotionSearchSites sites = this.workspace.GetMotionSearchSites(method, this.referenceStride); + if (searchRange < int.MaxValue) + { + if (searchRange < 1) + { + stepParameter = sites.StageCount; + } + else + { + while (sites.StageCount - stepParameter - 1 > 0 + && sites.GetRadius(sites.StageCount - stepParameter - 1) > (searchRange << 1)) + { + stepParameter++; + } + } + } + + Size size = new(this.blockSize.GetWidth(), this.blockSize.GetHeight()); + FullPixelSearch fullSearch = new( + this.source, + this.sourceStride, + this.reference, + this.referenceStride, + this.referenceOrigin, + size, + referenceVector.GetFullPixelSearchBounds(this.frameBounds), + referenceVector, + this.motionCosts, + this.bitDepth, + Av1RateDistortion.GetMotionSearchSadPerBit(this.qIndex, this.bitDepth), + this.rateMultiplier); + + FullPixelResult best = default; + Point? second = null; + bool hasBest = false; + int sumWeight = 0; + for (int candidateIndex = 0; candidateIndex < candidateCount; candidateIndex++) + { + if (rejected[candidateIndex]) + { + continue; + } + + // Non-realtime motion policy disables neighborhood publication. Fractional pruning therefore + // measures its own candidates instead of fitting the optional five-cost integer surface. + FullPixelResult candidate = fullSearch.Search( + starts[candidateIndex].Vector, + stepParameter, + method, + sites, + settings, + keyFrame: false, + fineMeshInterval, + Span.Empty, + out Point? candidateSecond); + + if (candidate.Cost < (hasBest ? best.Cost : int.MaxValue)) + { + best = candidate; + second = candidateSecond; + hasBest = true; + } + + sumWeight += starts[candidateIndex].Weight; + if (4 * sumWeight > 3 * totalWeight) + { + break; + } + } + + result = default; + if (!hasBest) + { + return false; + } + + Av1MotionVector integerVector = new(best.Vector.Y * 8, best.Vector.X * 8); + int integerRate = ((this.motionCosts.GetCost(integerVector, referenceVector) * 108) + 64) >> 7; + current.FullVector = integerVector; + current.FullCost = best.Cost; + current.FullRate = integerRate; + current.HasFullResult = true; + int pruningLevel = settings.ReferenceCandidatePruningLevel; + if (pruningLevel >= 2) + { + for (int previousIndex = 0; previousIndex < referenceIndex; previousIndex++) + { + ref ReferenceSearchResult previous = ref state.References[previousIndex]; + if (!previous.HasFullResult) + { + continue; + } + + if (previous.FullVector == integerVector && previous.FullRate + previous.DrlRate <= integerRate + drlRate) + { + return false; + } + + // Level three permits a quarter more search error; level four compares the original + // error directly. This only prunes when the earlier reference also has cheaper selection syntax. + int threshold = pruningLevel == 3 ? previous.FullCost + (previous.FullCost >> 2) : previous.FullCost; + if (pruningLevel >= 3 && best.Cost > threshold && previous.DrlRate < drlRate) + { + return false; + } + } + } + + result = new FractionalResult(integerVector, best.Variance, best.SquaredError, best.MotionCost); + if (!forceInteger && best.Cost < int.MaxValue) + { + Rectangle fractionalBounds = referenceVector.GetSubpixelSearchBounds(this.frameBounds); + FractionalSearch fractionalSearch = new( + this.source, + this.sourceStride, + this.reference, + this.referenceStride, + this.referenceOrigin, + this.prediction, + size, + fractionalBounds, + referenceVector, + this.motionCosts, + this.bitDepth, + this.rateMultiplier); + + Span centers = stackalloc Av1MotionVector[3]; + centers.Fill(new Av1MotionVector(short.MinValue, short.MinValue)); + int firstCost = fractionalSearch.Search( + integerVector, + best, + settings.FractionalMethod, + SearchPrecision.EighthSample, + allowHighPrecision, + settings.FractionalIterationsPerStep, + settings.FractionalInterpolationTaps, + ReadOnlySpan.Empty, + centers, + out result); + + if (second.HasValue && second.Value != best.Vector && settings.SecondCandidateSelection <= CandidateSelection.Variance) + { + Point secondPoint = second.Value; + Av1MotionVector secondStart = new(secondPoint.Y * 8, secondPoint.X * 8); + if (fractionalBounds.Contains(secondStart.Column, secondStart.Row)) + { + int secondCost = fractionalSearch.Search( + secondStart, + null, + settings.FractionalMethod, + SearchPrecision.EighthSample, + allowHighPrecision, + settings.FractionalIterationsPerStep, + settings.FractionalInterpolationTaps, + ReadOnlySpan.Empty, + centers, + out FractionalResult secondResult); + + if (settings.SecondCandidateSelection == CandidateSelection.RateDistortion && secondCost != int.MaxValue) + { + long firstRateDistortion = this.EstimateCandidate(result.Vector, referenceVector); + long secondRateDistortion = this.EstimateCandidate(secondResult.Vector, referenceVector); + if (secondRateDistortion < firstRateDistortion) + { + result = secondResult; + } + } + else if (secondCost < firstCost) + { + result = secondResult; + } + } + } + + if (pruningLevel >= 1) + { + int fractionalRate = ((this.motionCosts.GetCost(result.Vector, referenceVector) * 108) + 64) >> 7; + for (int previousIndex = 0; previousIndex < referenceIndex; previousIndex++) + { + ref ReferenceSearchResult previous = ref state.References[previousIndex]; + if (!previous.IsValid || previous.Vector != result.Vector) + { + continue; + } + + // A previously skipped matching mode remains skipped regardless of rate. Otherwise, + // preserve the earlier mode whenever its motion-plus-reference syntax is no more expensive. + if (previous.Skip || previous.Rate + previous.DrlRate <= fractionalRate + drlRate) + { + current.Skip = true; + break; + } + } + } + } + + // Weight only the motion-vector syntax. The transform and differential-reference rates retain + // their own 1/512-bit units; applying this factor to their sum would change the mode decision. + current.Rate = ((this.motionCosts.GetCost(result.Vector, referenceVector) * 108) + 64) >> 7; + current.Vector = result.Vector; + current.IsValid = true; + return true; + } + + /// + /// Compares a refined vector using final prediction, transform rate, and differential motion rate. + /// + /// The refined candidate vector. + /// The differential coding reference. + /// The rate-distortion estimate excluding the block skip-header cost. + private long EstimateCandidate(Av1MotionVector vector, Av1MotionVector referenceVector) + { + int width = this.blockSize.GetWidth(); + int height = this.blockSize.GetHeight(); + int origin = this.referenceOrigin + ((vector.Row >> 3) * this.referenceStride) + (vector.Column >> 3); + TOperator.PreparePrediction( + this.source, + this.sourceStride, + this.reference, + this.referenceStride, + origin, + this.prediction, + this.residual, + this.convolutionScratch, + width, + height, + this.horizontalFilter, + this.verticalFilter, + (vector.Column & 7) << 1, + (vector.Row & 7) << 1, + this.bitDepth.GetBitCount()); + + Av1TransformBlockEncoder.EstimateInterTransform( + this.workspace, + this.residual, + width, + this.quantized, + this.writer, + this.aboveContexts, + this.leftContexts, + this.blockSize, + new Size(width, height), + this.blockSize.GetMaximumTransformSize(), + this.qIndex, + this.dcDeltaQ, + this.bitDepth, + this.sharpness, + this.lossless, + this.rateMultiplier, + this.transformSizeRate, + this.noSkipRate, + this.skipRate, + long.MaxValue, + out Av1RateDistortionStatistics statistics, + out _, + out _); + + int motionRate = ((this.motionCosts.GetCost(vector, referenceVector) * 108) + 64) >> 7; + return Av1RateDistortion.GetCost(this.rateMultiplier, statistics.Rate + motionRate, statistics.Distortion); + } + } + + /// + /// Orders temporal starts by descending represented analysis weight. + /// + private readonly struct StartingCandidateWeightComparer : IComparer + { + /// + public int Compare(StartingCandidate x, StartingCandidate y) => y.Weight.CompareTo(x.Weight); + } +} diff --git a/src/ImageSharp/Formats/Heif/Av1/Motion/Av1MotionSearchBase.UInt16Operator.cs b/src/ImageSharp/Formats/Heif/Av1/Motion/Av1MotionSearchBase.UInt16Operator.cs new file mode 100644 index 0000000000..5eb0c06d8d --- /dev/null +++ b/src/ImageSharp/Formats/Heif/Av1/Motion/Av1MotionSearchBase.UInt16Operator.cs @@ -0,0 +1,90 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using SixLabors.ImageSharp.Formats.Heif.Av1.Pipeline; +using SixLabors.ImageSharp.Formats.Heif.Av1.Prediction.Inter; + +namespace SixLabors.ImageSharp.Formats.Heif.Av1.Motion; + +internal static partial class Av1MotionSearchBase +{ + /// + /// Measures high-bit-depth sample errors with the shared vector-width residual traversal. + /// + public readonly struct UInt16Operator : IMotionSearchOperator + { + /// + public static void PreparePrediction( + ReadOnlySpan source, + int sourceStride, + ReadOnlySpan reference, + int referenceStride, + int referenceOrigin, + Span prediction, + Span residual, + Span scratch, + int width, + int height, + Av1InterpolationFilter horizontalFilter, + Av1InterpolationFilter verticalFilter, + int horizontalPhase, + int verticalPhase, + int bitDepth) + { + Av1TranslationalInterPredictor.Predict( + reference, + referenceStride, + referenceOrigin, + prediction, + width, + width, + height, + horizontalFilter, + verticalFilter, + horizontalPhase, + verticalPhase, + bitDepth, + scratch); + + Av1ResidualBuilder.Subtract(source, sourceStride, prediction, width, residual, width, width, height); + } + + /// + public static void Predict( + ReadOnlySpan reference, + int referenceStride, + int referenceOrigin, + Span buffer, + int width, + int height, + int horizontalPhase, + int verticalPhase, + int taps, + int bitDepth) + => Av1TranslationalInterPredictor.PredictForSearch( + reference, referenceStride, referenceOrigin, buffer, width, height, horizontalPhase, verticalPhase, taps, bitDepth); + + /// + public static int SumAbsoluteDifferences( + ReadOnlySpan source, + int sourceStride, + ReadOnlySpan prediction, + int predictionStride, + int width, + int height, + int rowStep) + => Av1ResidualBuilder.SumAbsoluteDifferences(source, sourceStride, prediction, predictionStride, width, height, rowStep); + + /// + public static void GetMoments( + ReadOnlySpan source, + int sourceStride, + ReadOnlySpan prediction, + int predictionStride, + int width, + int height, + out int sum, + out long squares) + => Av1ResidualBuilder.GetMoments(source, sourceStride, prediction, predictionStride, width, height, out sum, out squares); + } +} diff --git a/src/ImageSharp/Formats/Heif/Av1/Motion/Av1MotionSearchBase.cs b/src/ImageSharp/Formats/Heif/Av1/Motion/Av1MotionSearchBase.cs new file mode 100644 index 0000000000..f5db64cceb --- /dev/null +++ b/src/ImageSharp/Formats/Heif/Av1/Motion/Av1MotionSearchBase.cs @@ -0,0 +1,674 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Numerics; +using SixLabors.ImageSharp.Formats.Heif.Av1.Entropy; +using static SixLabors.ImageSharp.Formats.Heif.Av1.Motion.Av1MotionSearchSettings; + +namespace SixLabors.ImageSharp.Formats.Heif.Av1.Motion; + +/// +/// Owns motion-search traversal while closed sample operators measure prediction errors. +/// +internal static partial class Av1MotionSearchBase +{ + /// + /// Retains the integer winner's distortion and rate separately for fractional refinement. + /// + public readonly struct FullPixelResult + { + /// + /// Initializes a new instance of the struct. + /// + /// The displacement in full samples. + /// The variance in the eight-bit error domain. + /// The squared residual sum in the eight-bit error domain. + /// The variance-domain motion-rate cost. + public FullPixelResult(Point vector, int variance, int squaredError, int motionCost) + { + this.Vector = vector; + this.Variance = variance; + this.SquaredError = squaredError; + this.MotionCost = motionCost; + } + + /// + /// Gets the displacement in full samples. + /// + public Point Vector { get; } + + /// + /// Gets the normalized residual variance. + /// + public int Variance { get; } + + /// + /// Gets the normalized squared residual sum. + /// + public int SquaredError { get; } + + /// + /// Gets the variance-domain motion-rate cost. + /// + public int MotionCost { get; } + + /// + /// Gets the total cost used to compare completed search paths. + /// + public int Cost => this.Variance + this.MotionCost; + } + + /// + /// Borrows source, reference, and rate state for all integer candidates of a prediction block. + /// + /// The unsigned component storage type. + /// The closed sample-error operator. + public readonly ref struct FullPixelSearch + where TSample : unmanaged + where TOperator : struct, IMotionSearchOperator + { + private readonly ReadOnlySpan source; + private readonly ReadOnlySpan reference; + private readonly int sourceStride; + private readonly int referenceStride; + private readonly int referenceOrigin; + private readonly Size blockSize; + private readonly Rectangle bounds; + private readonly Av1MotionVector referenceVector; + private readonly Av1MotionVector integerReferenceVector; + private readonly Av1MotionVectorCosts costs; + private readonly int precisionShift; + private readonly int sadPerBit; + private readonly int rateMultiplier; + + /// + /// Initializes a new instance of the struct. + /// + /// Source samples beginning at the block origin. + /// The source row stride in samples. + /// The complete retained reference storage including its border. + /// The reference row stride in samples. + /// The reference index corresponding to the current block origin. + /// The prediction dimensions. + /// The permitted displacement rectangle, with exclusive upper edges. + /// The spatial reference in eighth-sample units. + /// The retained motion-rate tables. + /// The coded component precision. + /// The quantizer-derived rate scale for absolute differences. + /// The block rate multiplier for variance costs. + public FullPixelSearch( + ReadOnlySpan source, + int sourceStride, + ReadOnlySpan reference, + int referenceStride, + int referenceOrigin, + Size blockSize, + Rectangle bounds, + Av1MotionVector referenceVector, + Av1MotionVectorCosts costs, + Av1BitDepth bitDepth, + int sadPerBit, + int rateMultiplier) + { + this.source = source; + this.sourceStride = sourceStride; + this.reference = reference; + this.referenceStride = referenceStride; + this.referenceOrigin = referenceOrigin; + this.blockSize = blockSize; + this.bounds = bounds; + this.referenceVector = referenceVector; + this.costs = costs; + this.precisionShift = bitDepth.GetBitCount() - 8; + this.sadPerBit = sadPerBit; + this.rateMultiplier = rateMultiplier; + + // Nearest full-sample rounding breaks half-sample ties away from zero. SAD compares integer + // differences from that rounded reference; variance retains the original subpixel difference. + int row = (referenceVector.Row + 3 + (referenceVector.Row >= 0 ? 1 : 0)) >> 3; + int column = (referenceVector.Column + 3 + (referenceVector.Column >= 0 ? 1 : 0)) >> 3; + this.integerReferenceVector = new Av1MotionVector(row * 8, column * 8); + } + + /// + /// Runs the selected full-pixel search, restarts, mesh decision, and neighboring cost publication. + /// + /// The initial displacement in full samples. + /// The number of outer search stages already excluded by frame and block policy. + /// The block-selected search method. + /// The retained geometry configured for this method and reference stride. + /// The resolved frame motion policy. + /// Whether key-frame policy prevents adaptive alternate-row SAD. + /// Whether content classification caps the initial mesh interval at four. + /// Five costs: center, left, down, right, and up; empty when neighborhood publication is disabled. + /// The preceding integer winner, when the selected traversal supplies one. + /// The integer winner with its retained variance, squared error, and motion cost. + public FullPixelResult Search( + Point start, + int stepParameter, + FullPixelSearchMethod method, + Av1MotionSearchSites sites, + Av1MotionSearchSettings settings, + bool keyFrame, + bool fineMeshInterval, + Span costList, + out Point? secondBest) + { + Point clampedStart = this.Clamp(start); + int rowStep = 1; + if (this.blockSize.Height >= 16) + { + if (settings.DownsampledSadLevel == 2) + { + rowStep = 2; + } + else if (settings.DownsampledSadLevel == 1 && !keyFrame) + { + int evenSad = this.GetSad(clampedStart, 2, 0); + int oddSad = this.GetSad(clampedStart, 2, 1); + if ((Math.Abs(evenSad - oddSad) * 4) < evenSad) + { + rowStep = 2; + } + } + } + + // An alternate-row search may alias vertical texture. If its final candidate exposes that aliasing, + // repeat the same complete search with full SAD; candidate state and cost-list state both restart. + while (true) + { + secondBest = null; + FullPixelResult best; + bool centerCostOnly = false; + if (method <= FullPixelSearchMethod.ClampedDiamond) + { + best = this.SearchDiamond(clampedStart, stepParameter, sites, rowStep, ref secondBest); + } + else + { + best = this.SearchPattern(clampedStart, stepParameter, method, sites, rowStep, out centerCostOnly); + } + + if (centerCostOnly && !costList.IsEmpty) + { + // An initial finest-scale winner skips the four-point refinement stage. Its neighbors + // have not been published, so fractional pruning must see them as unavailable. + costList.Fill(int.MaxValue); + costList[0] = this.GetSadCost(best.Vector, rowStep); + } + else if (!costList.IsEmpty) + { + this.FillCostList(best.Vector, rowStep, costList); + } + + int areaLog2 = BitOperations.Log2((uint)(this.blockSize.Width * this.blockSize.Height)); + bool runMesh = method is FullPixelSearchMethod.NStep or FullPixelSearchMethod.EightPointNStep + && best.Cost > (settings.MeshErrorThreshold >> (14 - areaLog2)); + + // Distance is measured from the caller's original start, before range clamping. + if (settings.MeshPruningLevel == 2 && + Math.Max(Math.Abs(start.X - best.Vector.X), Math.Abs(start.Y - best.Vector.Y)) <= 4) + { + runMesh = false; + } + + if (rowStep == 2) + { + int fullSad = this.GetSad(best.Vector, 1, 0); + int skippedSad = this.GetSad(best.Vector, 2, 0); + int threshold = (this.blockSize.Width * this.blockSize.Height) >> 4; + if (fullSad > threshold && Math.Abs(skippedSad - fullSad) * 10 >= Math.Max(fullSad, 1) * 9) + { + rowStep = 1; + continue; + } + } + + if (runMesh) + { + FullPixelResult mesh = this.SearchMesh( + best.Vector, settings.GetMeshPattern(intraBlockCopy: false), fineMeshInterval, rowStep, ref secondBest); + + // The mesh publishes its neighborhood and preceding winner before its final variance comparison. + // Keep that publication order so later fractional selection sees the same retained search state. + if (!costList.IsEmpty) + { + this.FillCostList(mesh.Vector, rowStep, costList); + } + + if (mesh.Cost < best.Cost) + { + best = mesh; + } + } + + return best; + } + } + + /// + /// Runs decreasing-radius searches from the same start and compares their winners using variance. + /// + private FullPixelResult SearchDiamond( + Point start, + int stepParameter, + Av1MotionSearchSites sites, + int rowStep, + ref Point? secondBest) + { + int startCost = this.GetSadCost(start, rowStep); + Point winner = this.SearchDiamondSteps(start, startCost, stepParameter, sites, rowStep, ref secondBest, out int centeredSteps); + FullPixelResult best = this.GetVarianceResult(winner); + int furtherSteps = sites.StageCount - 1 - stepParameter; + while (centeredSteps < furtherSteps) + { + centeredSteps++; + winner = this.SearchDiamondSteps( + start, startCost, stepParameter + centeredSteps, sites, rowStep, ref secondBest, out int skippedSteps); + + FullPixelResult candidate = this.GetVarianceResult(winner); + if (candidate.Cost < best.Cost) + { + best = candidate; + } + + centeredSteps += skippedSteps; + } + + return best; + } + + /// + /// Visits ordered sites once per radius, retaining initial center stays for later restart pruning. + /// + private Point SearchDiamondSteps( + Point start, + int startCost, + int stepParameter, + Av1MotionSearchSites sites, + int rowStep, + ref Point? secondBest, + out int centeredSteps) + { + Point best = start; + int bestCost = startCost; + bool movedFromStart = false; + centeredSteps = 0; + for (int stage = sites.StageCount - stepParameter - 1; stage >= 0; stage--) + { + ReadOnlySpan stageSites = sites.GetSites(stage); + int centerIndex = this.referenceOrigin + (best.Y * this.referenceStride) + best.X; + int bestSite = 0; + for (int index = 1; index <= sites.GetCandidateCount(stage); index++) + { + Av1MotionSearchSites.Site site = stageSites[index]; + Point candidate = new(best.X + site.Column, best.Y + site.Row); + if (this.bounds.Contains(candidate) && this.TryImproveSad(candidate, centerIndex + site.Offset, rowStep, ref bestCost)) + { + bestSite = index; + } + } + + if (bestSite != 0) + { + secondBest = best; + Av1MotionSearchSites.Site site = stageSites[bestSite]; + best = new Point(best.X + site.Column, best.Y + site.Row); + movedFromStart = true; + } + + if (!movedFromStart) + { + centeredSteps++; + } + + // Repeated outer radii can be skipped after a center stay; after a move they must remain eligible. + if (bestSite == 0 && stage > 2) + { + while (stage > 2 && sites.GetRadius(stage - 1) == sites.GetRadius(stage)) + { + centeredSteps++; + stage--; + } + } + } + + return best; + } + + /// + /// Selects an initial scale, then walks adjacent sites around each winning direction before reducing scale. + /// + private FullPixelResult SearchPattern( + Point start, + int stepParameter, + FullPixelSearchMethod method, + Av1MotionSearchSites sites, + int rowStep, + out bool centerCostOnly) + { + bool initialSearch = method is FullPixelSearchMethod.Hexagon or FullPixelSearchMethod.BigDiamond; + centerCostOnly = false; + int minimumStep = method switch + { + FullPixelSearchMethod.FastBigDiamond => 8, + FullPixelSearchMethod.FastDiamond => 9, + FullPixelSearchMethod.VeryFastDiamond => 10, + _ => 0 + }; + + int initialScale = 10 - Math.Min(Math.Max(stepParameter, minimumStep), 10); + int bestCost = this.GetSadCost(start, rowStep); + Point best = start; + int direction = -1; + if (initialSearch) + { + int maximumScale = initialScale; + initialScale = -1; + for (int scale = 0; scale <= maximumScale; scale++) + { + int candidateIndex = this.FindBestSite(start, scale, sites, rowStep, ref bestCost); + if (candidateIndex >= 0) + { + initialScale = scale; + direction = candidateIndex; + } + } + + if (initialScale >= 0) + { + Av1MotionSearchSites.Site site = sites.GetSites(initialScale)[direction]; + best = new Point(start.X + site.Column, start.Y + site.Row); + } + } + + if (initialScale >= 0) + { + bool fourPointFinalStage = sites.GetCandidateCount(0) == 4; + centerCostOnly = fourPointFinalStage && initialSearch && initialScale == 0; + int lastScale = fourPointFinalStage ? 1 : 0; + for (int scale = initialScale; scale >= lastScale; scale--) + { + ReadOnlySpan stageSites = sites.GetSites(scale); + if (!initialSearch || scale != initialScale) + { + int candidateIndex = this.FindBestSite(best, scale, sites, rowStep, ref bestCost); + if (candidateIndex < 0) + { + continue; + } + + direction = candidateIndex; + Av1MotionSearchSites.Site site = stageSites[direction]; + best = new Point(best.X + site.Column, best.Y + site.Row); + } + + best = this.FollowPatternDirection(best, scale, direction, sites, rowStep, ref bestCost); + } + + // Four-point patterns retain a separate final-stage entry decision. When the initial scale + // is already zero, its initial winner is published without another directional walk. + if (fourPointFinalStage && (!initialSearch || initialScale != 0)) + { + int candidateIndex = this.FindBestSite(best, 0, sites, rowStep, ref bestCost); + if (candidateIndex >= 0) + { + Av1MotionSearchSites.Site site = sites.GetSites(0)[candidateIndex]; + best = new Point(best.X + site.Column, best.Y + site.Row); + best = this.FollowPatternDirection(best, 0, candidateIndex, sites, rowStep, ref bestCost); + } + } + } + + return this.GetVarianceResult(best); + } + + /// + /// Tests the complete stage around a fixed center, keeping the first candidate on equal cost. + /// + private int FindBestSite(Point center, int stage, Av1MotionSearchSites sites, int rowStep, ref int bestCost) + { + ReadOnlySpan stageSites = sites.GetSites(stage); + int centerIndex = this.referenceOrigin + (center.Y * this.referenceStride) + center.X; + int count = sites.GetCandidateCount(stage); + int radius = sites.GetRadius(stage); + if (center.X - radius >= this.bounds.Left && center.X + radius < this.bounds.Right && + center.Y - radius >= this.bounds.Top && center.Y + radius < this.bounds.Bottom) + { + // Interior pattern stages visit complete four-site groups. For a six-site hexagon the final + // two sites are visited only by the boundary path, so range classification affects selection. + count &= ~3; + } + + int bestIndex = -1; + for (int index = 0; index < count; index++) + { + Av1MotionSearchSites.Site site = stageSites[index]; + Point candidate = new(center.X + site.Column, center.Y + site.Row); + if (this.bounds.Contains(candidate) && this.TryImproveSad(candidate, centerIndex + site.Offset, rowStep, ref bestCost)) + { + bestIndex = index; + } + } + + return bestIndex; + } + + /// + /// Walks the previous, same, and next directions around the ring until none improves the current center. + /// + private Point FollowPatternDirection( + Point center, + int stage, + int direction, + Av1MotionSearchSites sites, + int rowStep, + ref int bestCost) + { + int count = sites.GetCandidateCount(stage); + ReadOnlySpan stageSites = sites.GetSites(stage); + while (true) + { + int centerIndex = this.referenceOrigin + (center.Y * this.referenceStride) + center.X; + int bestIndex = -1; + for (int relative = -1; relative <= 1; relative++) + { + int index = (direction + relative + count) % count; + Av1MotionSearchSites.Site site = stageSites[index]; + Point candidate = new(center.X + site.Column, center.Y + site.Row); + if (this.bounds.Contains(candidate) && this.TryImproveSad(candidate, centerIndex + site.Offset, rowStep, ref bestCost)) + { + bestIndex = index; + } + } + + if (bestIndex < 0) + { + return center; + } + + direction = bestIndex; + Av1MotionSearchSites.Site winningSite = stageSites[direction]; + center = new Point(center.X + winningSite.Column, center.Y + winningSite.Row); + } + } + + /// + /// Runs content-selected mesh passes, adjusting the initial range to the current displacement magnitude. + /// + private FullPixelResult SearchMesh(Point start, ReadOnlySpan pattern, bool fineInterval, int rowStep, ref Point? secondBest) + { + int originalRange = pattern[0]; + int interval = pattern[1]; + int range = Math.Min(Math.Max(originalRange, (5 * Math.Max(Math.Abs(start.X), Math.Abs(start.Y))) / 4), 256); + interval = Math.Max(interval, range / (originalRange / interval)); + if (fineInterval) + { + interval = Math.Min(interval, 4); + } + + Point best = this.SearchMeshPass(start, range, interval, rowStep, ref secondBest); + if (interval > 1 && range > 7) + { + for (int pass = 1; pass < 4; pass++) + { + best = this.SearchMeshPass(best, pattern[pass * 2], pattern[(pass * 2) + 1], rowStep, ref secondBest); + if (pattern[(pass * 2) + 1] == 1) + { + break; + } + } + } + + return this.GetVarianceResult(best); + } + + /// + /// Scans mesh rows from a fixed center; each strict replacement retains the previous winner. + /// + private Point SearchMeshPass(Point start, int range, int interval, int rowStep, ref Point? secondBest) + { + start = this.Clamp(start); + Point best = start; + int bestCost = this.GetSadCost(start, rowStep); + int minimumRow = Math.Max(-range, this.bounds.Top - start.Y); + int maximumRow = Math.Min(range, this.bounds.Bottom - 1 - start.Y); + int minimumColumn = Math.Max(-range, this.bounds.Left - start.X); + int maximumColumn = Math.Min(range, this.bounds.Right - 1 - start.X); + int columnStep = interval > 1 ? interval : 4; + for (int row = minimumRow; row <= maximumRow; row += interval) + { + for (int column = minimumColumn; column <= maximumColumn; column += columnStep) + { + // A complete unit-step group visits four adjacent columns in order. The partial terminal + // group has an exclusive end; preserve that edge rule rather than widening the searched set. + int count = interval > 1 ? 1 : column + 3 <= maximumColumn ? 4 : maximumColumn - column; + for (int index = 0; index < count; index++) + { + Point candidate = new(start.X + column + index, start.Y + row); + if (this.TryImproveSad(candidate, rowStep, ref bestCost)) + { + secondBest = best; + best = candidate; + } + } + } + } + + return best; + } + + /// + /// Publishes SAD-plus-rate values at the center and its four axial neighbors for fractional pruning. + /// + private void FillCostList(Point best, int rowStep, Span costList) + { + costList[0] = this.GetSadCost(best, rowStep); + ReadOnlySpan offsets = [0, -1, 1, 0, 0, 1, -1, 0]; + for (int index = 0; index < 4; index++) + { + Point candidate = new(best.X + offsets[(index * 2) + 1], best.Y + offsets[index * 2]); + costList[index + 1] = this.bounds.Contains(candidate) ? this.GetSadCost(candidate, rowStep) : int.MaxValue; + } + } + + /// + /// Clamps a starting displacement to the prediction-distinct full-pixel range. + /// + private Point Clamp(Point vector) + => new(Math.Clamp(vector.X, this.bounds.Left, this.bounds.Right - 1), Math.Clamp(vector.Y, this.bounds.Top, this.bounds.Bottom - 1)); + + /// + /// Rejects candidates whose prediction error alone already reaches the best combined cost. + /// + private bool TryImproveSad(Point vector, int rowStep, ref int bestCost) + => this.TryImproveSad(vector, this.referenceOrigin + (vector.Y * this.referenceStride) + vector.X, rowStep, ref bestCost); + + /// + /// Measures a candidate using the retained site's offset, avoiding repeated stride multiplication. + /// + private bool TryImproveSad(Point vector, int referenceIndex, int rowStep, ref int bestCost) + { + int sad = this.GetSad(referenceIndex, rowStep, 0); + if (sad >= bestCost) + { + return false; + } + + int rate = this.costs.GetCost(new Av1MotionVector(vector.Y * 8, vector.X * 8), this.integerReferenceVector); + int cost = Av1RateDistortion.GetMotionSearchSadCost(this.sadPerBit, rate, sad); + if (cost >= bestCost) + { + return false; + } + + bestCost = cost; + return true; + } + + /// + /// Measures the complete absolute-difference cost in the eight-bit error domain. + /// + private int GetSadCost(Point vector, int rowStep) + { + int sad = this.GetSad(vector, rowStep, 0); + int rate = this.costs.GetCost(new Av1MotionVector(vector.Y * 8, vector.X * 8), this.integerReferenceVector); + return Av1RateDistortion.GetMotionSearchSadCost(this.sadPerBit, rate, sad); + } + + /// + /// Measures raw sample differences and truncates only after alternate-row scaling. + /// + private int GetSad(Point vector, int rowStep, int firstRow) + => this.GetSad(this.referenceOrigin + (vector.Y * this.referenceStride) + vector.X, rowStep, firstRow); + + /// + /// Measures the requested row parity at a retained reference offset. + /// + private int GetSad(int referenceIndex, int rowStep, int firstRow) + { + referenceIndex += firstRow * this.referenceStride; + int sad = TOperator.SumAbsoluteDifferences( + this.source[(firstRow * this.sourceStride)..], + this.sourceStride, + this.reference[referenceIndex..], + this.referenceStride, + this.blockSize.Width, + this.blockSize.Height - firstRow, + rowStep); + + return sad >> this.precisionShift; + } + + /// + /// Retains normalized moments and subpixel-reference motion rate for a completed integer winner. + /// + private FullPixelResult GetVarianceResult(Point vector) + { + int referenceIndex = this.referenceOrigin + (vector.Y * this.referenceStride) + vector.X; + TOperator.GetMoments( + this.source, + this.sourceStride, + this.reference[referenceIndex..], + this.referenceStride, + this.blockSize.Width, + this.blockSize.Height, + out int sum, + out long squares); + + if (this.precisionShift != 0) + { + // Signed sums and squared sums have different scales. Round each before removing the mean; + // cancellation may make the rounded variance negative, so clamp the final variance to zero. + sum = (sum + (1 << (this.precisionShift - 1))) >> this.precisionShift; + int squaredShift = this.precisionShift * 2; + squares = (squares + (1L << (squaredShift - 1))) >> squaredShift; + } + + int variance = (int)Math.Max(squares - (((long)sum * sum) / (this.blockSize.Width * this.blockSize.Height)), 0); + int rate = this.costs.GetCost(new Av1MotionVector(vector.Y * 8, vector.X * 8), this.referenceVector); + int motionCost = Av1RateDistortion.GetMotionSearchCost(this.rateMultiplier, rate, 0); + return new FullPixelResult(vector, variance, (int)squares, motionCost); + } + } +} diff --git a/src/ImageSharp/Formats/Heif/Av1/Motion/Av1MotionSearchSettings.cs b/src/ImageSharp/Formats/Heif/Av1/Motion/Av1MotionSearchSettings.cs new file mode 100644 index 0000000000..9827bcf680 --- /dev/null +++ b/src/ImageSharp/Formats/Heif/Av1/Motion/Av1MotionSearchSettings.cs @@ -0,0 +1,457 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +namespace SixLabors.ImageSharp.Formats.Heif.Av1.Motion; + +/// +/// Resolves frame-dependent motion-search policies before block traversal. +/// +internal readonly struct Av1MotionSearchSettings +{ + private readonly HeifEncodingSpeed speed; + private readonly FullPixelSearchMethod fullPixelMethod; + private readonly int fasterSearchMinimumDimension; + + /// + /// Initializes a new instance of the struct. + /// + /// The encoding speed. + /// Whether every frame is coded independently. + /// The visible frame dimensions. + /// The base quantizer index. + /// Whether this is a key, golden, or alternate-reference frame with boosted quality. + /// Whether the content classification identifies graphics or screen content. + public Av1MotionSearchSettings( + HeifEncodingSpeed speed, + bool intraOnly, + Size frameSize, + int qIndex, + bool boostedFrame, + bool screenContent) + { + this.speed = speed; + this.fullPixelMethod = FullPixelSearchMethod.NStep; + this.FractionalMethod = FractionalSearchMethod.TwoLevelTree; + this.FractionalIterationsPerStep = 2; + this.FractionalInterpolationTaps = 8; + this.SimpleMotionPrecision = SearchPrecision.EighthSample; + this.SecondCandidateSelection = CandidateSelection.RateDistortion; + this.AllowIntraBlockCopy = true; + this.MeshErrorThreshold = 1 << (screenContent ? 20 : 25); + + // Apply coding-mode choices before resolution and quantizer overrides. Reversing that order can + // incorrectly suppress a second motion candidate or replace a quantizer-selected search pattern. + if (speed >= HeifEncodingSpeed.Level1) + { + this.MeshErrorThreshold <<= 1; + } + + if (intraOnly) + { + this.PruneIntraBlockCopyHashCandidates = speed >= HeifEncodingSpeed.Level1; + this.AutomaticStepSizeLevel = speed >= HeifEncodingSpeed.Level2 ? 1 : 0; + this.LimitFullPixelStartingCandidates = speed >= HeifEncodingSpeed.Level3; + if (speed >= HeifEncodingSpeed.Level3) + { + this.fullPixelMethod = FullPixelSearchMethod.Diamond; + } + + if (speed >= HeifEncodingSpeed.Level4) + { + this.FractionalMethod = FractionalSearchMethod.MorePrunedTree; + this.SimpleMotionPrecision = SearchPrecision.HalfSample; + this.ReduceSearchRange = true; + this.LimitIntraBlockCopyHashBlockSize = true; + } + + this.MeshPruningLevel = speed >= HeifEncodingSpeed.Level5 ? 2 : 0; + if (speed >= HeifEncodingSpeed.Level6) + { + this.fasterSearchMinimumDimension = 32; + this.UseFastIntraBlockCopySearch = true; + } + } + else + { + this.DisableExtensiveJointSearch = true; + if (speed >= HeifEncodingSpeed.Level1) + { + this.UseRefiningObmcSearch = true; + this.FractionalInterpolationTaps = 4; + } + + if (speed >= HeifEncodingSpeed.Level2) + { + this.SimpleMotionPrecision = SearchPrecision.QuarterSample; + this.FractionalIterationsPerStep = 1; + this.ReduceSearchRange = true; + } + + if (speed >= HeifEncodingSpeed.Level3) + { + this.FractionalMethod = FractionalSearchMethod.PrunedTree; + this.fullPixelMethod = FullPixelSearchMethod.Diamond; + this.SecondCandidateSelection = CandidateSelection.FirstOnly; + this.MeshPruningLevel = 1; + this.AllowIntraBlockCopy = false; + } + + if (speed >= HeifEncodingSpeed.Level4) + { + this.FractionalMethod = FractionalSearchMethod.MorePrunedTree; + this.SimpleMotionPrecision = SearchPrecision.HalfSample; + this.MeshPruningLevel = 2; + } + + this.UseDiamondWarpSearch = speed >= HeifEncodingSpeed.Level5; + if (speed >= HeifEncodingSpeed.Level6) + { + this.SimpleMotionPrecision = SearchPrecision.Integer; + } + } + + // Resolution classes use the shorter dimension, so rotating a frame does not change its class. + int minimumDimension = Math.Min(frameSize.Width, frameSize.Height); + bool is720pOrLarger = minimumDimension >= 720; + this.DownsampledSadLevel = is720pOrLarger ? 2 : 0; + if (!intraOnly) + { + this.ReferenceCandidatePruningLevel = speed >= HeifEncodingSpeed.Level5 ? 4 + : speed >= HeifEncodingSpeed.Level4 && minimumDimension <= 480 ? 3 + : speed >= HeifEncodingSpeed.Level3 ? 2 + : speed >= HeifEncodingSpeed.Level1 ? 1 : 0; + + if (speed >= HeifEncodingSpeed.Level2) + { + this.AutomaticStepSizeLevel = is720pOrLarger ? 1 : 2; + this.SecondCandidateSelection = !is720pOrLarger + ? CandidateSelection.Variance + : boostedFrame ? CandidateSelection.RateDistortion : CandidateSelection.FirstOnly; + } + + if (speed >= HeifEncodingSpeed.Level4 && minimumDimension < 480) + { + this.StartCandidatePruningLevel = boostedFrame ? 0 : 1; + } + + if (speed >= HeifEncodingSpeed.Level5) + { + this.StartCandidatePruningLevel = boostedFrame ? 0 : 1; + if (!is720pOrLarger) + { + this.DownsampledSadLevel = 1; + } + } + + if (speed >= HeifEncodingSpeed.Level6) + { + this.StartCandidatePruningLevel = boostedFrame ? 0 : 2; + this.fasterSearchMinimumDimension = is720pOrLarger ? 128 : 64; + } + } + + // Coarse quantization selects a less expensive full-pixel pattern even at the slower speed levels. + // These thresholds apply to the coding pass; first-pass statistics use a separate configuration. + if (speed <= HeifEncodingSpeed.Level2) + { + int coarseThreshold; + int intermediateThreshold; + if (is720pOrLarger) + { + coarseThreshold = speed == HeifEncodingSpeed.Level2 ? 200 : 255; + intermediateThreshold = speed == HeifEncodingSpeed.Level0 ? 200 : -1; + } + else + { + coarseThreshold = speed == HeifEncodingSpeed.Level0 ? 200 : 170; + intermediateThreshold = speed switch + { + HeifEncodingSpeed.Level0 => 70, + HeifEncodingSpeed.Level1 => 50, + _ => 40 + }; + } + + if (qIndex > coarseThreshold) + { + this.fullPixelMethod = is720pOrLarger + ? FullPixelSearchMethod.Diamond + : FullPixelSearchMethod.ClampedDiamond; + } + else if (qIndex > intermediateThreshold) + { + this.fullPixelMethod = FullPixelSearchMethod.EightPointNStep; + } + } + } + + /// + /// The full-pixel search pattern. + /// + public enum FullPixelSearchMethod + { + /// + /// Repeated shrinking diamond searches. + /// + Diamond, + + /// + /// Searches with eight or twelve sites at progressively smaller radii. + /// + NStep, + + /// + /// Searches with eight sites at every radius. + /// + EightPointNStep, + + /// + /// Diamond search with repeated, bounded initial radii. + /// + ClampedDiamond, + + /// + /// Hexagonal search followed by local refinement. + /// + Hexagon, + + /// + /// Large diamond search followed by local refinement. + /// + BigDiamond, + + /// + /// Diamond search beginning at a reduced scale. + /// + FastDiamond, + + /// + /// Large diamond search beginning at a reduced scale. + /// + FastBigDiamond, + + /// + /// Diamond search with only the smallest scales. + /// + VeryFastDiamond + } + + /// + /// The fractional-pixel search traversal. + /// + public enum FractionalSearchMethod + { + /// + /// Cardinal and selected diagonal searches with a second refinement level. + /// + TwoLevelTree, + + /// + /// Pruned tree search using the integer cost neighborhood when available. + /// + PrunedTree, + + /// + /// Pruned tree search with additional quadratic cost-surface prediction. + /// + MorePrunedTree + } + + /// + /// The finest displacement examined by a fractional search. + /// + public enum SearchPrecision + { + /// + /// One eighth of a luma sample. + /// + EighthSample, + + /// + /// One quarter of a luma sample. + /// + QuarterSample, + + /// + /// One half of a luma sample. + /// + HalfSample, + + /// + /// Whole luma samples. + /// + Integer + } + + /// + /// The comparison used after refining a second motion candidate. + /// + public enum CandidateSelection + { + /// + /// Compare estimated transform rate and distortion. + /// + RateDistortion, + + /// + /// Compare prediction variance and motion-vector rate. + /// + Variance, + + /// + /// Refine only the first full-pixel winner. + /// + FirstOnly + } + + /// + /// Gets the adaptation level for the initial full-pixel step. + /// + public int AutomaticStepSizeLevel { get; } + + /// + /// Gets the fractional search traversal. + /// + public FractionalSearchMethod FractionalMethod { get; } + + /// + /// Gets the refinement iterations at each fractional precision. + /// + public int FractionalIterationsPerStep { get; } + + /// + /// Gets the interpolation tap count used during fractional search. + /// + public int FractionalInterpolationTaps { get; } + + /// + /// Gets the finest precision used by preliminary simple-motion analysis. + /// + public SearchPrecision SimpleMotionPrecision { get; } + + /// + /// Gets the variance threshold for following a stepped search with a mesh search. + /// + public int MeshErrorThreshold { get; } + + /// + /// Gets a value indicating whether earlier reference-index results restrict subsequent search ranges. + /// + public bool ReduceSearchRange { get; } + + /// + /// Gets the level used to prune mesh search based on motion displacement. + /// + public int MeshPruningLevel { get; } + + /// + /// Gets a value indicating whether overlapped prediction uses local full-pixel refinement. + /// + public bool UseRefiningObmcSearch { get; } + + /// + /// Gets a value indicating whether full-pixel search omits additional temporal-analysis starting candidates. + /// + public bool LimitFullPixelStartingCandidates { get; } + + /// + /// Gets a value indicating whether intra-block-copy motion search is enabled. + /// + public bool AllowIntraBlockCopy { get; } + + /// + /// Gets a value indicating whether block-copy hash search stops after the first 64 candidates. + /// + public bool PruneIntraBlockCopyHashCandidates { get; } + + /// + /// Gets a value indicating whether block copy restricts geometry and uses pixel search only after hash search fails. + /// + public bool UseFastIntraBlockCopySearch { get; } + + /// + /// Gets a value indicating whether block-copy hashing is restricted to 4x4 and 8x8 blocks. + /// + public bool LimitIntraBlockCopyHashBlockSize { get; } + + /// + /// Gets the row-subsampling policy: zero disables it, one checks the starting SAD, and two checks only the final SAD. + /// + public int DownsampledSadLevel { get; } + + /// + /// Gets a value indicating whether compound motion omits the extensive joint refinement search. + /// + public bool DisableExtensiveJointSearch { get; } + + /// + /// Gets how two fractional motion candidates are compared. + /// + public CandidateSelection SecondCandidateSelection { get; } + + /// + /// Gets a value indicating whether zero, four, or eight neighboring start/reference positions can reuse an earlier search. + /// + public int StartCandidatePruningLevel { get; } + + /// + /// Gets the pruning level applied across dynamic reference-vector choices after motion search. + /// + public int ReferenceCandidatePruningLevel { get; } + + /// + /// Gets a value indicating whether warped-motion refinement uses a diamond instead of a square. + /// + public bool UseDiamondWarpSearch { get; } + + /// + /// Gets the full-pixel method for the current block geometry. + /// + /// The prediction block size. + /// The frame-selected method after its block-size override. + public FullPixelSearchMethod GetFullPixelMethod(Av1BlockSize blockSize) + { + int minimumDimension = Math.Min(blockSize.GetWidth(), blockSize.GetHeight()); + if (this.fasterSearchMinimumDimension == 0 || minimumDimension < this.fasterSearchMinimumDimension) + { + return this.fullPixelMethod; + } + + return this.fullPixelMethod switch + { + FullPixelSearchMethod.NStep or FullPixelSearchMethod.EightPointNStep => FullPixelSearchMethod.Diamond, + FullPixelSearchMethod.Diamond or FullPixelSearchMethod.ClampedDiamond => FullPixelSearchMethod.BigDiamond, + FullPixelSearchMethod.BigDiamond => FullPixelSearchMethod.Hexagon, + FullPixelSearchMethod.Hexagon => FullPixelSearchMethod.FastDiamond, + FullPixelSearchMethod.FastDiamond => FullPixelSearchMethod.VeryFastDiamond, + _ => this.fullPixelMethod + }; + } + + /// + /// Gets the successive mesh ranges and sampling intervals in full luma samples. + /// + /// Whether the search references the current reconstruction. + /// Four range/interval pairs. Traversal ends after the first interval of one. + public ReadOnlySpan GetMeshPattern(bool intraBlockCopy) + { + // The alternating range/interval layout is immutable static storage. A frame or candidate does not + // allocate a pattern, and an interval of one terminates refinement before unused trailing entries. + if (intraBlockCopy) + { + return this.speed switch + { + <= HeifEncodingSpeed.Level1 => [256, 1, 256, 1, 0, 0, 0, 0], + <= HeifEncodingSpeed.Level3 => [64, 1, 64, 1, 0, 0, 0, 0], + _ => [64, 4, 16, 1, 0, 0, 0, 0] + }; + } + + return this.speed switch + { + <= HeifEncodingSpeed.Level1 => [64, 8, 28, 4, 15, 1, 7, 1], + HeifEncodingSpeed.Level2 => [64, 8, 14, 2, 7, 1, 7, 1], + _ => [64, 16, 24, 8, 12, 4, 7, 1] + }; + } +} diff --git a/src/ImageSharp/Formats/Heif/Av1/Motion/Av1MotionSearchSites.cs b/src/ImageSharp/Formats/Heif/Av1/Motion/Av1MotionSearchSites.cs new file mode 100644 index 0000000000..51111cb46a --- /dev/null +++ b/src/ImageSharp/Formats/Heif/Av1/Motion/Av1MotionSearchSites.cs @@ -0,0 +1,189 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Runtime.InteropServices; +using static SixLabors.ImageSharp.Formats.Heif.Av1.Motion.Av1MotionSearchSettings; + +namespace SixLabors.ImageSharp.Formats.Heif.Av1.Motion; + +/// +/// Borrows one retained search-site configuration, including stride-relative sample offsets. +/// +internal readonly ref struct Av1MotionSearchSites +{ + /// + /// The number of integer storage elements for sites, stage metadata, and the configured stride. + /// + public const int StorageLength = (22 * 17 * 2) + (22 * 2) + 2; + + private const int StageCapacity = 22; + private const int SitesPerStage = 17; + private const int SiteStorageLength = StageCapacity * SitesPerStage * 2; + private const int StageCountOffset = SiteStorageLength + (StageCapacity * 2); + private readonly Span storage; + + /// + /// Initializes a new instance of the struct. + /// + /// The retained configuration storage, whose stride slot is initialized by its owner. + public Av1MotionSearchSites(Span storage) => this.storage = storage; + + /// + /// Gets the number of populated search stages. + /// + public int StageCount => this.storage[StageCountOffset]; + + /// + /// Gets the number of non-center candidates at the given stage. + /// + /// The stage, ordered from the smallest search radius. + /// The candidate count. + public int GetCandidateCount(int stage) => this.storage[SiteStorageLength + stage]; + + /// + /// Gets the radius of the given stage in full samples. + /// + /// The stage, ordered from the smallest search radius. + /// The search radius. + public int GetRadius(int stage) => this.storage[SiteStorageLength + StageCapacity + stage]; + + /// + /// Gets the ordered candidate sites for a stage. + /// + /// The stage, ordered from the smallest search radius. + /// The fixed stage slot; only the configured candidate entries are populated. + public ReadOnlySpan GetSites(int stage) + => MemoryMarshal.Cast(this.storage[..SiteStorageLength]).Slice(stage * SitesPerStage, SitesPerStage); + + /// + /// Initializes or refreshes offsets when the retained reference plane's stride changes. + /// + /// The distinct search shape owned by this configuration. + /// The reference plane stride in samples. + public void Configure(FullPixelSearchMethod method, int stride) + { + if (this.storage[StageCountOffset + 1] == stride) + { + return; + } + + Span sites = MemoryMarshal.Cast(this.storage[..SiteStorageLength]); + Span counts = this.storage.Slice(SiteStorageLength, StageCapacity); + Span radii = this.storage.Slice(SiteStorageLength + StageCapacity, StageCapacity); + bool nStep = method is FullPixelSearchMethod.NStep or FullPixelSearchMethod.EightPointNStep; + bool diamond = method is FullPixelSearchMethod.Diamond or FullPixelSearchMethod.ClampedDiamond; + int stageCount = nStep ? (method == FullPixelSearchMethod.NStep ? 15 : 16) : 11; + int radius = 1; + for (int stage = 0; stage < stageCount; stage++) + { + Span stageSites = sites.Slice(stage * SitesPerStage, SitesPerStage); + if (diamond) + { + // The clamped shape repeats its three outer stages at radius 256. Retaining those stages + // matters because a move at one stage permits another move at the same radius. + radius = 1 << Math.Min(stage, method == FullPixelSearchMethod.ClampedDiamond ? 8 : 10); + } + else if (!nStep) + { + radius = 1 << stage; + } + + radii[stage] = radius; + if (nStep || diamond) + { + bool twelveSites = nStep && radius > 5 && method != FullPixelSearchMethod.EightPointNStep; + int tangent = twelveSites ? Math.Max((int)(0.41 * radius), 1) : radius; + counts[stage] = twelveSites ? 12 : 8; + stageSites[0] = new Site(0, 0, stride); + stageSites[1] = new Site(-radius, 0, stride); + stageSites[2] = new Site(radius, 0, stride); + stageSites[3] = new Site(0, -radius, stride); + stageSites[4] = new Site(0, radius, stride); + stageSites[5] = new Site(-radius, -tangent, stride); + stageSites[6] = new Site(radius, tangent, stride); + stageSites[7] = new Site(-tangent, radius, stride); + stageSites[8] = new Site(tangent, -radius, stride); + if (twelveSites) + { + stageSites[9] = new Site(-radius, tangent, stride); + stageSites[10] = new Site(radius, -tangent, stride); + stageSites[11] = new Site(tangent, radius, stride); + stageSites[12] = new Site(-tangent, -radius, stride); + } + + // N-step radii grow by rounded halves through stage twelve, then retain the outer radius. + if (nStep && stage < 12) + { + radius = Math.Max(((3 * radius) + 1) / 2, radius + 1); + } + } + else + { + // Pattern sites omit the center. Pairs are row then column, in traversal order. + // Beyond scale zero, multiply the half-radius by these integer coordinates. + ReadOnlySpan coordinates; + int scale; + if (method == FullPixelSearchMethod.Hexagon) + { + coordinates = stage == 0 + ? [-1, -1, 0, -1, 1, -1, 1, 0, 1, 1, 0, 1, -1, 1, -1, 0] + : [-1, -2, 1, -2, 2, 0, 1, 2, -1, 2, -2, 0]; + + scale = stage == 0 ? 1 : radius / 2; + } + else + { + coordinates = stage == 0 + ? [0, -1, 1, 0, 0, 1, -1, 0] + : [-1, -1, 0, -2, 1, -1, 2, 0, 1, 1, 0, 2, -1, 1, -2, 0]; + + scale = stage == 0 ? 1 : radius / 2; + } + + counts[stage] = coordinates.Length / 2; + for (int index = 0; index < counts[stage]; index++) + { + stageSites[index] = new Site(coordinates[index * 2] * scale, coordinates[(index * 2) + 1] * scale, stride); + } + } + } + + this.storage[StageCountOffset] = stageCount; + this.storage[StageCountOffset + 1] = stride; + } + + /// + /// Stores one full-sample displacement and its reference-plane offset in eight bytes. + /// + [StructLayout(LayoutKind.Sequential)] + public readonly struct Site + { + /// + /// Initializes a new instance of the struct. + /// + /// The vertical full-sample displacement. + /// The horizontal full-sample displacement. + /// The reference row stride in samples. + public Site(int row, int column, int stride) + { + this.Row = (short)row; + this.Column = (short)column; + this.Offset = (row * stride) + column; + } + + /// + /// Gets the vertical full-sample displacement. + /// + public short Row { get; } + + /// + /// Gets the horizontal full-sample displacement. + /// + public short Column { get; } + + /// + /// Gets the signed displacement in reference-plane samples. + /// + public int Offset { get; } + } +} diff --git a/src/ImageSharp/Formats/Heif/Av1/Motion/Av1MotionVector.cs b/src/ImageSharp/Formats/Heif/Av1/Motion/Av1MotionVector.cs index 2854d1562f..59c741e7c3 100644 --- a/src/ImageSharp/Formats/Heif/Av1/Motion/Av1MotionVector.cs +++ b/src/ImageSharp/Formats/Heif/Av1/Motion/Av1MotionVector.cs @@ -105,6 +105,68 @@ internal readonly struct Av1MotionVector : IEquatable /// when either component differs; otherwise, . public static bool operator !=(Av1MotionVector left, Av1MotionVector right) => !left.Equals(right); + /// + /// Computes the full-pixel displacement region that can produce distinct, padded block predictions. + /// + /// The block's luma rectangle in the coded frame. + /// The frame dimensions rounded to mode-information units. + /// The allocated luma border on every side. + /// The displacement region with exclusive right and bottom edges. + public static Rectangle GetFrameSearchBounds(Rectangle block, Size frameSize, int border) + { + // Reserve eight samples for interpolation support and restrict candidates beyond a replicated edge + // once moving farther cannot change the prediction. Bounds describe displacement from this block, + // so interior blocks can move across the frame rather than being restricted to the border width. + int minimumColumn = Math.Max(-(block.X + border - 8), -(block.Right + 8)); + int minimumRow = Math.Max(-(block.Y + border - 8), -(block.Bottom + 8)); + int maximumColumn = Math.Min(frameSize.Width - block.Right + border - 8, frameSize.Width - block.X + 8); + int maximumRow = Math.Min(frameSize.Height - block.Bottom + border - 8, frameSize.Height - block.Y + 8); + return Rectangle.FromLTRB(minimumColumn, minimumRow, maximumColumn + 1, maximumRow + 1); + } + + /// + /// Restricts a frame displacement region to representable full-pixel candidates around this reference. + /// + /// The full-pixel region from . + /// The full-pixel search region with exclusive right and bottom edges. + public Rectangle GetFullPixelSearchBounds(Rectangle frameBounds) + { + const int MaximumDisplacement = 1023; + + // Both endpoints must fit inside a 1023-pixel displacement from the fractional reference. Round the + // lower endpoint toward positive infinity and the upper toward negative infinity, including for + // negative references. Keep the reserved vector-domain endpoints out of the search as well. + int minimumColumn = Math.Max(frameBounds.Left, Math.Max(((this.Column + 7) >> 3) - MaximumDisplacement, (LowerBound >> 3) + 1)); + int minimumRow = Math.Max(frameBounds.Top, Math.Max(((this.Row + 7) >> 3) - MaximumDisplacement, (LowerBound >> 3) + 1)); + int maximumColumn = Math.Min(frameBounds.Right - 1, Math.Min((this.Column >> 3) + MaximumDisplacement, (UpperBound >> 3) - 1)); + int maximumRow = Math.Min(frameBounds.Bottom - 1, Math.Min((this.Row >> 3) + MaximumDisplacement, (UpperBound >> 3) - 1)); + maximumColumn = Math.Max(minimumColumn, maximumColumn); + maximumRow = Math.Max(minimumRow, maximumRow); + return Rectangle.FromLTRB(minimumColumn, minimumRow, maximumColumn + 1, maximumRow + 1); + } + + /// + /// Restricts a frame displacement region to representable fractional candidates around this reference. + /// + /// The full-pixel region from . + /// The eighth-sample search region with exclusive right and bottom edges. + public Rectangle GetSubpixelSearchBounds(Rectangle frameBounds) + { + const int MaximumDisplacement = 1023 * SubpixelScale; + + // Refine against the original frame region, not the rounded full-pixel intersection. Otherwise the + // fractional portion between an integer endpoint and the reference-centered limit would be lost. + int minimumColumn = Math.Max(frameBounds.Left * SubpixelScale, this.Column - MaximumDisplacement); + int minimumRow = Math.Max(frameBounds.Top * SubpixelScale, this.Row - MaximumDisplacement); + int maximumColumn = Math.Min((frameBounds.Right - 1) * SubpixelScale, this.Column + MaximumDisplacement); + int maximumRow = Math.Min((frameBounds.Bottom - 1) * SubpixelScale, this.Row + MaximumDisplacement); + maximumColumn = Math.Min(UpperBound - 1, Math.Max(minimumColumn, maximumColumn)); + maximumRow = Math.Min(UpperBound - 1, Math.Max(minimumRow, maximumRow)); + minimumColumn = Math.Max(LowerBound + 1, minimumColumn); + minimumRow = Math.Max(LowerBound + 1, minimumRow); + return Rectangle.FromLTRB(minimumColumn, minimumRow, maximumColumn + 1, maximumRow + 1); + } + /// /// Reduces this vector to the motion-vector precision selected by the current frame. /// diff --git a/src/ImageSharp/Formats/Heif/Av1/Pipeline/Av1EncoderBlockWorkspace.cs b/src/ImageSharp/Formats/Heif/Av1/Pipeline/Av1EncoderBlockWorkspace.cs index 62e982c752..efd96ea31f 100644 --- a/src/ImageSharp/Formats/Heif/Av1/Pipeline/Av1EncoderBlockWorkspace.cs +++ b/src/ImageSharp/Formats/Heif/Av1/Pipeline/Av1EncoderBlockWorkspace.cs @@ -3,6 +3,7 @@ using System.Buffers; using System.Runtime.InteropServices; +using SixLabors.ImageSharp.Formats.Heif.Av1.Entropy; using SixLabors.ImageSharp.Formats.Heif.Av1.Motion; using SixLabors.ImageSharp.Formats.Heif.Av1.Tiling; using SixLabors.ImageSharp.Formats.Heif.Av1.Transform; @@ -26,7 +27,7 @@ internal sealed class Av1EncoderBlockWorkspace : IDisposable public const int MaximumCoefficientCount = (Av1Constants.MaxTransformSize / 2) * (Av1Constants.MaxTransformSize / 2); /// - /// The complete workspace length in signed-integer storage elements. + /// The base workspace length in signed-integer storage elements, excluding inter-motion state. /// public const int StorageLength = ResidualStorageLength + @@ -37,6 +38,8 @@ internal sealed class Av1EncoderBlockWorkspace : IDisposable PartitionContextStorageLength; private const int ResidualStorageLength = MaximumResidualCount / 2; + private const int MotionSearchSiteCount = 6; + private const int MotionSearchSiteStorageOffset = StorageLength + Av1MotionVectorCosts.StorageLength; private const int TransformCoefficientOffset = ResidualStorageLength; private const int DequantizedCoefficientOffset = TransformCoefficientOffset + MaximumCoefficientCount; private const int TransformWorkspaceOffset = DequantizedCoefficientOffset + MaximumCoefficientCount; @@ -117,7 +120,34 @@ internal sealed class Av1EncoderBlockWorkspace : IDisposable /// /// The configuration providing the encoder allocator. public Av1EncoderBlockWorkspace(Configuration configuration) - => this.owner = configuration.MemoryAllocator.Allocate(StorageLength); + : this(configuration, allocateInterMotionCosts: false) + { + } + + /// + /// Initializes a new instance of the class for a fixed encoding mode. + /// + /// The configuration providing the encoder allocator. + /// Whether the worker will encode inter frames. + public Av1EncoderBlockWorkspace(Configuration configuration, bool allocateInterMotionCosts) + { + // Motion rates belong to the worker, not a block candidate or frame. Keep both precision pairs after + // the existing scratch regions so sequence frames can change precision while retaining one owner. + int length = StorageLength + + (allocateInterMotionCosts ? Av1MotionVectorCosts.StorageLength + (MotionSearchSiteCount * Av1MotionSearchSites.StorageLength) : 0); + + this.owner = configuration.MemoryAllocator.Allocate(length); + if (allocateInterMotionCosts) + { + // Each shape retains its offsets across frames. A zero stride marks its first use; every populated + // site and stage is subsequently overwritten when the reference stride changes. + Span storage = this.owner.Memory.Span; + for (int index = 0; index < MotionSearchSiteCount; index++) + { + storage[MotionSearchSiteStorageOffset + ((index + 1) * Av1MotionSearchSites.StorageLength) - 1] = 0; + } + } + } /// /// Gets the maximum-size spatial residual workspace as a compact 16-bit view of the aligned owner. @@ -148,6 +178,33 @@ internal sealed class Av1EncoderBlockWorkspace : IDisposable /// public ref Av1ReferenceMotionVectors ReferenceMotionVectors => ref this.referenceMotionVectors; + /// + /// Borrows the inter-motion rate tables for the current frame's precision. + /// + /// The fractional precision selected by the frame. + /// The worker's reusable motion-rate view. + public Av1MotionVectorCosts GetMotionVectorCosts(Av1MotionVectorPrecision precision) + => new(this.owner.Memory.Span.Slice(StorageLength, Av1MotionVectorCosts.StorageLength), precision); + + /// + /// Gets the retained full-pixel search geometry for the reference plane's current stride. + /// + /// The block-selected search method. + /// The reference row stride in samples. + /// The configured non-owning search-site view. + public Av1MotionSearchSites GetMotionSearchSites(Av1MotionSearchSettings.FullPixelSearchMethod method, int stride) + { + // Fast diamond variants differ in stage selection, so they share the big-diamond geometry slot. + Av1MotionSearchSettings.FullPixelSearchMethod shape = method > Av1MotionSearchSettings.FullPixelSearchMethod.BigDiamond + ? Av1MotionSearchSettings.FullPixelSearchMethod.BigDiamond + : method; + + int offset = MotionSearchSiteStorageOffset + ((int)shape * Av1MotionSearchSites.StorageLength); + Av1MotionSearchSites sites = new(this.owner.Memory.Span.Slice(offset, Av1MotionSearchSites.StorageLength)); + sites.Configure(shape, stride); + return sites; + } + /// /// Gets the disjoint edge snapshot used to restore one square partition-search level. /// diff --git a/src/ImageSharp/Formats/Heif/Av1/Prediction/Inter/Av1TranslationalInterPredictor.Search.cs b/src/ImageSharp/Formats/Heif/Av1/Prediction/Inter/Av1TranslationalInterPredictor.Search.cs new file mode 100644 index 0000000000..c4edbfcc4d --- /dev/null +++ b/src/ImageSharp/Formats/Heif/Av1/Prediction/Inter/Av1TranslationalInterPredictor.Search.cs @@ -0,0 +1,240 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +namespace SixLabors.ImageSharp.Formats.Heif.Av1.Prediction.Inter; + +internal static partial class Av1TranslationalInterPredictor +{ + /// + /// The sample capacity for a 128-column search prediction and its eight-tap vertical support. + /// + public const int SearchPredictionBufferLength = 136 * 128; + + /// + /// Produces a search prediction, rounding and clipping each separable pass to the component precision. + /// + /// The bordered reference plane. + /// The reference row stride in samples. + /// The integer prediction origin. + /// The borrowed search buffer; the packed result occupies its first width times height samples. + /// The prediction width. + /// The prediction height. + /// The horizontal fraction in eighth-sample units. + /// The vertical fraction in eighth-sample units. + /// The selected two-, four-, or eight-tap search filter. + public static void PredictForSearch( + ReadOnlySpan source, + int sourceStride, + int sourceOrigin, + Span buffer, + int width, + int height, + int horizontalPhase, + int verticalPhase, + int taps) + { + if ((horizontalPhase | verticalPhase) == 0) + { + Copy(source, sourceStride, sourceOrigin, buffer, width, width, height); + return; + } + + ReadOnlySpan table = taps == 2 ? Bilinear : taps == 4 ? RegularFourTap : RegularEightTap; + ReadOnlySpan horizontal = GetPhase(table, horizontalPhase * 2); + ReadOnlySpan vertical = GetPhase(table, verticalPhase * 2); + GetEffectiveKernel(horizontal, out int horizontalFirst, out int horizontalCount); + GetEffectiveKernel(vertical, out int verticalFirst, out int verticalCount); + + // Search interpolation applies a single Q7 rounding and clips after each pass. Keeping both + // passes in sample storage preserves those clipped values when they feed the vertical filter. + if (verticalPhase == 0) + { + FilterDirect( + source, + sourceStride, + sourceOrigin, + buffer, + width, + width, + height, + horizontal[horizontalFirst..], + horizontalCount, + horizontalFirst - 3, + 1, + FilterBits, + 0); + + return; + } + + if (horizontalPhase == 0) + { + FilterDirect( + source, + sourceStride, + sourceOrigin, + buffer, + width, + width, + height, + vertical[verticalFirst..], + verticalCount, + (verticalFirst - 3) * sourceStride, + sourceStride, + FilterBits, + 0); + + return; + } + + // The vertical support occupies seven additional rows at a fixed 128-sample stride. The final + // top-to-bottom pass packs its output over consumed rows of the same buffer: width never exceeds + // that stride, and a written column cannot affect another column's vertical convolution. + FilterDirect( + source, + sourceStride, + sourceOrigin - (3 * sourceStride), + buffer, + 128, + width, + height + 7, + horizontal[horizontalFirst..], + horizontalCount, + horizontalFirst - 3, + 1, + FilterBits, + 0); + + FilterDirect( + buffer, + 128, + 3 * 128, + buffer, + width, + width, + height, + vertical[verticalFirst..], + verticalCount, + (verticalFirst - 3) * 128, + 128, + FilterBits, + 0); + } + + /// + /// Produces a search prediction, rounding and clipping each separable pass to the component precision. + /// + /// The bordered reference plane. + /// The reference row stride in samples. + /// The integer prediction origin. + /// The borrowed search buffer; the packed result occupies its first width times height samples. + /// The prediction width. + /// The prediction height. + /// The horizontal fraction in eighth-sample units. + /// The vertical fraction in eighth-sample units. + /// The selected two-, four-, or eight-tap search filter. + /// The coded component precision. + public static void PredictForSearch( + ReadOnlySpan source, + int sourceStride, + int sourceOrigin, + Span buffer, + int width, + int height, + int horizontalPhase, + int verticalPhase, + int taps, + int bitDepth) + { + if ((horizontalPhase | verticalPhase) == 0) + { + Copy(source, sourceStride, sourceOrigin, buffer, width, width, height); + return; + } + + ReadOnlySpan table = taps == 2 ? Bilinear : taps == 4 ? RegularFourTap : RegularEightTap; + ReadOnlySpan horizontal = GetPhase(table, horizontalPhase * 2); + ReadOnlySpan vertical = GetPhase(table, verticalPhase * 2); + GetEffectiveKernel(horizontal, out int horizontalFirst, out int horizontalCount); + GetEffectiveKernel(vertical, out int verticalFirst, out int verticalCount); + + // Search interpolation applies a single Q7 rounding and clips after each pass. Keeping both + // passes in sample storage preserves those clipped values when they feed the vertical filter. + if (verticalPhase == 0) + { + FilterDirect( + source, + sourceStride, + sourceOrigin, + buffer, + width, + width, + height, + horizontal[horizontalFirst..], + horizontalCount, + horizontalFirst - 3, + 1, + FilterBits, + 0, + bitDepth); + + return; + } + + if (horizontalPhase == 0) + { + FilterDirect( + source, + sourceStride, + sourceOrigin, + buffer, + width, + width, + height, + vertical[verticalFirst..], + verticalCount, + (verticalFirst - 3) * sourceStride, + sourceStride, + FilterBits, + 0, + bitDepth); + + return; + } + + // The vertical support occupies seven additional rows at a fixed 128-sample stride. The final + // top-to-bottom pass packs its output over consumed rows of the same buffer: width never exceeds + // that stride, and a written column cannot affect another column's vertical convolution. + FilterDirect( + source, + sourceStride, + sourceOrigin - (3 * sourceStride), + buffer, + 128, + width, + height + 7, + horizontal[horizontalFirst..], + horizontalCount, + horizontalFirst - 3, + 1, + FilterBits, + 0, + bitDepth); + + FilterDirect( + buffer, + 128, + 3 * 128, + buffer, + width, + width, + height, + vertical[verticalFirst..], + verticalCount, + (verticalFirst - 3) * 128, + 128, + FilterBits, + 0, + bitDepth); + } +} diff --git a/src/ImageSharp/Formats/Heif/HeifEncodingSpeed.cs b/src/ImageSharp/Formats/Heif/HeifEncodingSpeed.cs new file mode 100644 index 0000000000..6d5ff249ad --- /dev/null +++ b/src/ImageSharp/Formats/Heif/HeifEncodingSpeed.cs @@ -0,0 +1,61 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +namespace SixLabors.ImageSharp.Formats.Heif; + +/// +/// Specifies the balance between encoding speed and compression efficiency for AV1 images. +/// Higher levels prioritize encoding speed over compression efficiency. +/// +public enum HeifEncodingSpeed +{ + /// + /// The slowest encoding level and the default setting. + /// + Level0 = 0, + + /// + /// Encoding speed level 1. + /// + Level1 = 1, + + /// + /// Encoding speed level 2. + /// + Level2 = 2, + + /// + /// Encoding speed level 3. + /// + Level3 = 3, + + /// + /// Encoding speed level 4. + /// + Level4 = 4, + + /// + /// Encoding speed level 5. + /// + Level5 = 5, + + /// + /// Encoding speed level 6. + /// + Level6 = 6, + + /// + /// Encoding speed level 7. + /// + Level7 = 7, + + /// + /// Encoding speed level 8. + /// + Level8 = 8, + + /// + /// The fastest encoding level. + /// + Level9 = 9 +} diff --git a/tests/ImageSharp.Tests/Formats/Heif/Av1/Av1MotionSearchSettingsTests.cs b/tests/ImageSharp.Tests/Formats/Heif/Av1/Av1MotionSearchSettingsTests.cs new file mode 100644 index 0000000000..d27379f0e8 --- /dev/null +++ b/tests/ImageSharp.Tests/Formats/Heif/Av1/Av1MotionSearchSettingsTests.cs @@ -0,0 +1,117 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using SixLabors.ImageSharp.Formats.Heif; +using SixLabors.ImageSharp.Formats.Heif.Av1; +using SixLabors.ImageSharp.Formats.Heif.Av1.Motion; +using static SixLabors.ImageSharp.Formats.Heif.Av1.Motion.Av1MotionSearchSettings; + +namespace SixLabors.ImageSharp.Tests.Formats.Heif.Av1; + +public class Av1MotionSearchSettingsTests +{ + [Theory] + [InlineData(HeifEncodingSpeed.Level0, false, 719, 70, Av1BlockSize.Block8x8, FullPixelSearchMethod.NStep)] + [InlineData(HeifEncodingSpeed.Level0, false, 719, 71, Av1BlockSize.Block8x8, FullPixelSearchMethod.EightPointNStep)] + [InlineData(HeifEncodingSpeed.Level0, false, 719, 200, Av1BlockSize.Block8x8, FullPixelSearchMethod.EightPointNStep)] + [InlineData(HeifEncodingSpeed.Level0, false, 719, 201, Av1BlockSize.Block8x8, FullPixelSearchMethod.ClampedDiamond)] + [InlineData(HeifEncodingSpeed.Level0, false, 720, 200, Av1BlockSize.Block8x8, FullPixelSearchMethod.NStep)] + [InlineData(HeifEncodingSpeed.Level0, false, 720, 201, Av1BlockSize.Block8x8, FullPixelSearchMethod.EightPointNStep)] + [InlineData(HeifEncodingSpeed.Level1, false, 719, 50, Av1BlockSize.Block8x8, FullPixelSearchMethod.NStep)] + [InlineData(HeifEncodingSpeed.Level1, false, 719, 51, Av1BlockSize.Block8x8, FullPixelSearchMethod.EightPointNStep)] + [InlineData(HeifEncodingSpeed.Level1, false, 720, 0, Av1BlockSize.Block8x8, FullPixelSearchMethod.EightPointNStep)] + [InlineData(HeifEncodingSpeed.Level2, false, 719, 40, Av1BlockSize.Block8x8, FullPixelSearchMethod.NStep)] + [InlineData(HeifEncodingSpeed.Level2, false, 719, 41, Av1BlockSize.Block8x8, FullPixelSearchMethod.EightPointNStep)] + [InlineData(HeifEncodingSpeed.Level2, false, 719, 170, Av1BlockSize.Block8x8, FullPixelSearchMethod.EightPointNStep)] + [InlineData(HeifEncodingSpeed.Level2, false, 719, 171, Av1BlockSize.Block8x8, FullPixelSearchMethod.ClampedDiamond)] + [InlineData(HeifEncodingSpeed.Level2, false, 720, 200, Av1BlockSize.Block8x8, FullPixelSearchMethod.EightPointNStep)] + [InlineData(HeifEncodingSpeed.Level2, false, 720, 201, Av1BlockSize.Block8x8, FullPixelSearchMethod.Diamond)] + [InlineData(HeifEncodingSpeed.Level6, false, 719, 120, Av1BlockSize.Block32x64, FullPixelSearchMethod.Diamond)] + [InlineData(HeifEncodingSpeed.Level6, false, 719, 120, Av1BlockSize.Block64x64, FullPixelSearchMethod.BigDiamond)] + [InlineData(HeifEncodingSpeed.Level6, false, 720, 120, Av1BlockSize.Block64x128, FullPixelSearchMethod.Diamond)] + [InlineData(HeifEncodingSpeed.Level6, false, 720, 120, Av1BlockSize.Block128x128, FullPixelSearchMethod.BigDiamond)] + [InlineData(HeifEncodingSpeed.Level2, true, 719, 171, Av1BlockSize.Block8x8, FullPixelSearchMethod.ClampedDiamond)] + [InlineData(HeifEncodingSpeed.Level6, true, 719, 120, Av1BlockSize.Block16x32, FullPixelSearchMethod.Diamond)] + [InlineData(HeifEncodingSpeed.Level6, true, 719, 120, Av1BlockSize.Block32x32, FullPixelSearchMethod.BigDiamond)] + public void FullPixelPatternUsesQuantizerResolutionAndBlockGeometry( + HeifEncodingSpeed speed, + bool intraOnly, + int minimumDimension, + int qIndex, + int blockSize, + int expected) + { + Av1MotionSearchSettings landscape = new(speed, intraOnly, new Size(1920, minimumDimension), qIndex, false, false); + Av1MotionSearchSettings portrait = new(speed, intraOnly, new Size(minimumDimension, 1920), qIndex, false, false); + + Assert.Equal((FullPixelSearchMethod)expected, landscape.GetFullPixelMethod((Av1BlockSize)blockSize)); + Assert.Equal((FullPixelSearchMethod)expected, portrait.GetFullPixelMethod((Av1BlockSize)blockSize)); + } + + [Theory] + [InlineData(HeifEncodingSpeed.Level1, 719, false, CandidateSelection.RateDistortion, 0)] + [InlineData(HeifEncodingSpeed.Level2, 719, false, CandidateSelection.Variance, 2)] + [InlineData(HeifEncodingSpeed.Level3, 719, false, CandidateSelection.Variance, 2)] + [InlineData(HeifEncodingSpeed.Level9, 719, true, CandidateSelection.Variance, 2)] + [InlineData(HeifEncodingSpeed.Level2, 720, false, CandidateSelection.FirstOnly, 1)] + [InlineData(HeifEncodingSpeed.Level3, 720, true, CandidateSelection.RateDistortion, 1)] + [InlineData(HeifEncodingSpeed.Level9, 720, true, CandidateSelection.RateDistortion, 1)] + public void ResolutionAndFrameRoleOverrideInitialSecondCandidatePolicy( + HeifEncodingSpeed speed, + int minimumDimension, + bool boostedFrame, + int expectedSelection, + int expectedAutomaticStepSize) + { + Av1MotionSearchSettings settings = new(speed, false, new Size(1920, minimumDimension), 120, boostedFrame, false); + + Assert.Equal((CandidateSelection)expectedSelection, settings.SecondCandidateSelection); + Assert.Equal(expectedAutomaticStepSize, settings.AutomaticStepSizeLevel); + } + + [Fact] + public void IndependentFrameAndSequenceModesRetainDifferentMotionPolicies() + { + Av1MotionSearchSettings intraOnly = new(HeifEncodingSpeed.Level9, true, new Size(256, 256), 120, true, true); + Av1MotionSearchSettings sequence = new(HeifEncodingSpeed.Level9, false, new Size(256, 256), 120, true, true); + + Assert.True(intraOnly.AllowIntraBlockCopy); + Assert.True(intraOnly.PruneIntraBlockCopyHashCandidates); + Assert.True(intraOnly.UseFastIntraBlockCopySearch); + Assert.True(intraOnly.LimitIntraBlockCopyHashBlockSize); + Assert.True(intraOnly.LimitFullPixelStartingCandidates); + Assert.False(sequence.AllowIntraBlockCopy); + Assert.False(sequence.LimitFullPixelStartingCandidates); + Assert.Equal(8, intraOnly.FractionalInterpolationTaps); + Assert.Equal(4, sequence.FractionalInterpolationTaps); + Assert.Equal(2, intraOnly.FractionalIterationsPerStep); + Assert.Equal(1, sequence.FractionalIterationsPerStep); + Assert.Equal(SearchPrecision.HalfSample, intraOnly.SimpleMotionPrecision); + Assert.Equal(SearchPrecision.Integer, sequence.SimpleMotionPrecision); + Assert.Equal(2_097_152, intraOnly.MeshErrorThreshold); + Assert.Equal(2_097_152, sequence.MeshErrorThreshold); + } + + [Theory] + [InlineData(HeifEncodingSpeed.Level0, 0, 0, 2, FractionalSearchMethod.TwoLevelTree)] + [InlineData(HeifEncodingSpeed.Level1, 0, 0, 2, FractionalSearchMethod.TwoLevelTree)] + [InlineData(HeifEncodingSpeed.Level2, 0, 0, 1, FractionalSearchMethod.TwoLevelTree)] + [InlineData(HeifEncodingSpeed.Level3, 1, 0, 1, FractionalSearchMethod.PrunedTree)] + [InlineData(HeifEncodingSpeed.Level4, 2, 1, 1, FractionalSearchMethod.MorePrunedTree)] + [InlineData(HeifEncodingSpeed.Level5, 2, 1, 1, FractionalSearchMethod.MorePrunedTree)] + [InlineData(HeifEncodingSpeed.Level6, 2, 2, 1, FractionalSearchMethod.MorePrunedTree)] + public void SequenceSearchStagesUseTheSelectedSpeedPolicy( + HeifEncodingSpeed speed, + int expectedMeshPruning, + int expectedStartPruning, + int expectedIterations, + int expectedFractionalMethod) + { + Av1MotionSearchSettings settings = new(speed, false, new Size(256, 256), 120, false, false); + + Assert.Equal(expectedMeshPruning, settings.MeshPruningLevel); + Assert.Equal(expectedStartPruning, settings.StartCandidatePruningLevel); + Assert.Equal(expectedIterations, settings.FractionalIterationsPerStep); + Assert.Equal((FractionalSearchMethod)expectedFractionalMethod, settings.FractionalMethod); + } +} diff --git a/tests/ImageSharp.Tests/Formats/Heif/Av1/Av1MotionSearchTests.cs b/tests/ImageSharp.Tests/Formats/Heif/Av1/Av1MotionSearchTests.cs new file mode 100644 index 0000000000..f0cf9ffa82 --- /dev/null +++ b/tests/ImageSharp.Tests/Formats/Heif/Av1/Av1MotionSearchTests.cs @@ -0,0 +1,579 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Numerics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using SixLabors.ImageSharp.Formats.Heif; +using SixLabors.ImageSharp.Formats.Heif.Av1; +using SixLabors.ImageSharp.Formats.Heif.Av1.Entropy; +using SixLabors.ImageSharp.Formats.Heif.Av1.Motion; +using SixLabors.ImageSharp.Formats.Heif.Av1.Pipeline; +using SixLabors.ImageSharp.Formats.Heif.Av1.Prediction.Inter; +using Xunit; +using static SixLabors.ImageSharp.Formats.Heif.Av1.Motion.Av1MotionSearchSettings; + +namespace SixLabors.ImageSharp.Tests.Formats.Heif.Av1; + +/// +/// Verifies retained search geometry and the statistics published by complete integer search paths. +/// +public class Av1MotionSearchTests +{ + /// + /// Exercises the coordinated search across three differential references and exports its retained state. + /// + /// The coded component precision. + [Theory] + [InlineData(8)] + [InlineData(10)] + [InlineData(12)] + public void SingleReferenceSearchRetainsCoordinatedDecisions(int bits) + { + if (bits == 8) + { + VerifySingleReferenceSearches(Av1BitDepth.EightBit, bits); + } + else + { + VerifySingleReferenceSearches( + bits == 10 ? Av1BitDepth.TenBit : Av1BitDepth.TwelveBit, + bits); + } + } + + /// + /// Checks exact integer matches and publishes textured, fractional, and repeated-reference decisions. + /// + private static void VerifySingleReferenceSearches(Av1BitDepth bitDepth, int bits) + where TSample : unmanaged, IBinaryInteger + where TOperator : struct, Av1MotionSearchBase.IMotionSearchOperator + { + const int QIndex = 90; + const int ReferenceStride = 192; + const int ReferenceOrigin = (64 * ReferenceStride) + 64; + string directory = Path.Combine(TestEnvironment.ActualOutputDirectoryFullPath, "Heif", "Av1", "SingleReferenceSearch"); + Directory.CreateDirectory(directory); + using Av1EncoderBlockWorkspace workspace = new(Configuration.Default, allocateInterMotionCosts: true); + using Av1SymbolEncoder writer = new(Configuration.Default, 64, QIndex, updateCdf: true); + Av1MotionVectorCosts costs = workspace.GetMotionVectorCosts(Av1MotionVectorPrecision.EighthSample); + writer.FillMotionVectorCosts(costs); + int multiplier = Av1RateDistortion.GetKeyFrameRateMultiplier(QIndex, bitDepth); + TSample[] prediction = new TSample[136 * 128]; + short[] residual = new short[128 * 128]; + short[] scratch = new short[136 * 128]; + int[] quantized = new int[64 * 64]; + byte[] contexts = new byte[32]; + int maximum = (1 << bits) - 1; + foreach (Av1BlockSize blockSize in new[] { Av1BlockSize.Block8x8, Av1BlockSize.Block16x16, Av1BlockSize.Block64x64 }) + { + int width = blockSize.GetWidth(); + int height = blockSize.GetHeight(); + int sourceStride = width + 3; + TSample[] source = new TSample[sourceStride * height]; + TSample[] reference = new TSample[ReferenceStride * ReferenceStride]; + for (int pattern = 0; pattern < 7; pattern++) + { + uint random = (uint)(173 + pattern); + for (int index = 0; index < reference.Length; index++) + { + random = unchecked((random * 1664525) + 1013904223); + reference[index] = TSample.CreateChecked((random >> 16) & (uint)maximum); + } + + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + int offset = ReferenceOrigin + ((y + 3) * ReferenceStride) + x - 2; + int sample = int.CreateChecked(reference[offset]); + if (pattern == 1) + { + sample = (sample + int.CreateChecked(reference[offset + 1]) + 1) >> 1; + } + else if (pattern >= 2) + { + sample = (sample + ((x * 13) ^ (y * 37)) + pattern) & maximum; + } + + source[(y * sourceStride) + x] = TSample.CreateChecked(sample); + } + } + + TSample[] originalSource = source.ToArray(); + TSample[] originalReference = reference.ToArray(); + foreach (int speed in new[] { 0, 1, 2, 3, 4, 5, 8 }) + { + Size frameSize = pattern == 3 ? new Size(1280, 720) : new Size(640, 480); + Av1MotionSearchSettings settings = new((HeifEncodingSpeed)speed, false, frameSize, QIndex, false, false); + foreach (bool forceInteger in new[] { false, true }) + { + int frameStep = 5; + int spatialMagnitude = pattern == 2 ? 128 : 16; + int searchRange = pattern == 3 ? 4 : int.MaxValue; + Rectangle bounds = Rectangle.FromLTRB(-24, -24, 25, 25); + Av1MotionSearchBase.SingleReferenceSearch search = new( + source, + sourceStride, + reference, + ReferenceStride, + ReferenceOrigin, + blockSize, + bounds, + workspace, + prediction, + residual, + scratch, + quantized, + writer, + contexts, + contexts, + bitDepth, + QIndex, + 0, + 0, + false, + multiplier, + 512, + 1024, + 256, + Av1InterpolationFilter.Regular, + Av1InterpolationFilter.Regular, + costs); + + Av1MotionSearchBase.SingleReferenceState state = default; + using FileStream stream = File.Create(Path.Combine(directory, $"{bits}-{width}-{pattern}-{speed}-{forceInteger}.bin")); + using BinaryWriter output = new(stream); + int[] header = + [ + 3, bits, width, height, sourceStride, ReferenceStride, ReferenceOrigin, + bounds.Left, bounds.Top, bounds.Right, bounds.Bottom, QIndex, speed, + (int)settings.GetFullPixelMethod(blockSize), frameStep, spatialMagnitude, 1, searchRange, + forceInteger ? 1 : 0, 1, 0, multiplier, Av1RateDistortion.GetMotionSearchSadPerBit(QIndex, bitDepth), + settings.AutomaticStepSizeLevel, settings.StartCandidatePruningLevel, settings.ReferenceCandidatePruningLevel, + (int)settings.FractionalMethod, settings.FractionalIterationsPerStep, settings.FractionalInterpolationTaps, + (int)settings.SecondCandidateSelection, settings.MeshErrorThreshold, settings.MeshPruningLevel, + settings.DownsampledSadLevel, frameSize.Width, frameSize.Height, 0, 0, 512, 1024, 256, 0, 0, 3, + source.Length, reference.Length, pattern + ]; + + output.Write(MemoryMarshal.AsBytes(header.AsSpan())); + output.Write(MemoryMarshal.AsBytes(source.AsSpan())); + output.Write(MemoryMarshal.AsBytes(reference.AsSpan())); + for (int referenceIndex = 0; referenceIndex < 3; referenceIndex++) + { + Av1MotionVector referenceVector = referenceIndex == 1 + ? new Av1MotionVector(21, -13) : new Av1MotionVector(24, -16); + + Point start = new( + (referenceVector.Column + 3 + (referenceVector.Column >= 0 ? 1 : 0)) >> 3, + (referenceVector.Row + 3 + (referenceVector.Row >= 0 ? 1 : 0)) >> 3); + + int analysisWidth = pattern >= 4 ? width / 16 : 0; + Av1MotionVector[] temporal = new Av1MotionVector[analysisWidth * analysisWidth]; + for (int index = 0; index < temporal.Length; index++) + { + // Repeated cells accumulate votes; the final pattern interrupts analysis after a prefix. + temporal[index] = pattern == 6 && index == temporal.Length / 2 + ? new Av1MotionVector(short.MinValue, short.MinValue) + : new Av1MotionVector(8 * (index % 3 == 0 ? 16 : -16), 8 * (index % 2 == 0 ? 16 : -16)); + } + + Av1MotionSearchBase.StartingCandidate[] starts = new Av1MotionSearchBase.StartingCandidate[temporal.Length + 1]; + int startCount = Av1MotionSearchBase.CollectStartingCandidates( + start, temporal, analysisWidth, new Size(analysisWidth, analysisWidth), starts, out int totalWeight); + + int drlRate = referenceIndex * 256; + bool valid = search.Search( + settings, + frameStep, + spatialMagnitude, + true, + searchRange, + forceInteger, + true, + false, + referenceIndex, + referenceVector, + drlRate, + starts.AsSpan(0, startCount), + totalWeight, + ref state, + out Av1MotionSearchBase.FractionalResult result); + + ref Av1MotionSearchBase.ReferenceSearchResult retained = ref state.References[referenceIndex]; + Assert.Equal(valid, retained.IsValid); + if (referenceIndex == 0) + { + Assert.True(valid); + if (pattern == 0) + { + Assert.Equal(new Av1MotionVector(24, -16), result.Vector); + Assert.Equal(0, result.SquaredError); + } + } + + if (valid && forceInteger) + { + Assert.Equal(0, result.Vector.Row & 7); + Assert.Equal(0, result.Vector.Column & 7); + } + + int[] decision = + [ + referenceVector.Row, referenceVector.Column, drlRate, valid ? 1 : 0, + valid ? result.Vector.Row : 0, valid ? result.Vector.Column : 0, + valid ? retained.Rate : 0, retained.Skip ? 1 : 0, state.StartCount, + retained.HasFullResult ? 1 : 0, retained.FullVector.Row, retained.FullVector.Column, + retained.FullRate, retained.FullCost + ]; + + output.Write(MemoryMarshal.AsBytes(decision.AsSpan())); + } + + Assert.Equal(originalSource, source); + Assert.Equal(originalReference, reference); + Assert.All(contexts, value => Assert.Equal(0, value)); + } + } + } + } + } + + /// + /// Checks all search methods at each sample precision and exports their inputs for independent reference verification. + /// + /// The coded component precision. + [Theory] + [InlineData(8)] + [InlineData(10)] + [InlineData(12)] + public void FullPixelSearchPublishesScalarVerifiedStatistics(int bits) + { + if (bits == 8) + { + VerifySearches(Av1BitDepth.EightBit, bits); + } + else + { + VerifySearches(bits == 10 ? Av1BitDepth.TenBit : Av1BitDepth.TwelveBit, bits); + } + } + + /// + /// Checks all retained coordinate offsets after initial configuration and a stride change. + /// + [Fact] + public void SearchSitesPreserveShapeAcrossStrideChanges() + { + using Av1EncoderBlockWorkspace workspace = new(Configuration.Default, allocateInterMotionCosts: true); + for (int methodIndex = 0; methodIndex <= (int)FullPixelSearchMethod.VeryFastDiamond; methodIndex++) + { + FullPixelSearchMethod method = (FullPixelSearchMethod)methodIndex; + foreach (int stride in new[] { 192, 224 }) + { + Av1MotionSearchSites sites = workspace.GetMotionSearchSites(method, stride); + Assert.Equal(method == FullPixelSearchMethod.NStep ? 15 : method == FullPixelSearchMethod.EightPointNStep ? 16 : 11, sites.StageCount); + for (int stage = 0; stage < sites.StageCount; stage++) + { + ReadOnlySpan entries = sites.GetSites(stage); + int first = method <= FullPixelSearchMethod.ClampedDiamond ? 1 : 0; + for (int index = first; index < first + sites.GetCandidateCount(stage); index++) + { + Assert.Equal((entries[index].Row * stride) + entries[index].Column, entries[index].Offset); + } + } + + Assert.Equal(1, sites.GetRadius(0)); + int outerRadius = method is FullPixelSearchMethod.NStep or FullPixelSearchMethod.EightPointNStep + ? 210 : method == FullPixelSearchMethod.ClampedDiamond ? 256 : 1024; + + Assert.Equal(outerRadius, sites.GetRadius(sites.StageCount - 1)); + } + } + + Assert.Equal(8, Unsafe.SizeOf()); + Assert.Equal(794, Av1MotionSearchSites.StorageLength); + } + + /// + /// Exercises exact matches, textured residuals, alternate-row policies, and fractional spatial references. + /// + private static void VerifySearches(Av1BitDepth bitDepth, int bits) + where TSample : unmanaged, IBinaryInteger + where TOperator : struct, Av1MotionSearchBase.IMotionSearchOperator + { + const int QIndex = 90; + const int ReferenceStride = 192; + const int ReferenceOrigin = (64 * ReferenceStride) + 64; + int maximum = (1 << bits) - 1; + string directory = Path.Combine(TestEnvironment.ActualOutputDirectoryFullPath, "Heif", "Av1", "FullPixelSearch"); + Directory.CreateDirectory(directory); + using Av1EncoderBlockWorkspace workspace = new(Configuration.Default, allocateInterMotionCosts: true); + using Av1SymbolEncoder writer = new(Configuration.Default, 64, QIndex, updateCdf: true); + Av1MotionVectorCosts costs = workspace.GetMotionVectorCosts(Av1MotionVectorPrecision.EighthSample); + writer.FillMotionVectorCosts(costs); + int multiplier = Av1RateDistortion.GetKeyFrameRateMultiplier(QIndex, bitDepth); + int sadPerBit = Av1RateDistortion.GetMotionSearchSadPerBit(QIndex, bitDepth); + int[] costList = new int[5]; + foreach (int width in new[] { 8, 16, 64 }) + { + int height = width; + int sourceStride = width + 3; + TSample[] source = new TSample[sourceStride * height]; + TSample[] reference = new TSample[ReferenceStride * ReferenceStride]; + for (int pattern = 0; pattern < 6; pattern++) + { + HeifEncodingSpeed speed = pattern is 0 or 3 or 4 + ? HeifEncodingSpeed.Level0 : pattern == 1 ? HeifEncodingSpeed.Level5 : HeifEncodingSpeed.Level8; + + Size frameSize = pattern is 2 or 5 ? new Size(1280, 720) : new Size(320, 240); + bool screenContent = pattern == 4; + Av1MotionSearchSettings settings = new(speed, false, frameSize, QIndex, false, screenContent); + uint state = (uint)(173 + pattern); + for (int index = 0; index < reference.Length; index++) + { + state = unchecked((state * 1664525) + 1013904223); + reference[index] = TSample.CreateChecked((state >> 16) & (uint)maximum); + } + + Point start = pattern == 4 ? new(40, -40) : new(-2, 3); + Point predictionOffset = pattern == 3 ? new(-1, 3) : pattern == 4 ? new(-24, 24) : start; + Av1MotionVector referenceVector = pattern == 0 ? new Av1MotionVector(24, -16) : new Av1MotionVector(21, -13); + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + int sample = int.CreateChecked(reference[ReferenceOrigin + ((y + predictionOffset.Y) * ReferenceStride) + x + predictionOffset.X]); + if (pattern is 1 or 2) + { + // Independent signed perturbations retain nonzero prediction variance and high-depth rounding residues. + sample = (sample + ((x * 13) ^ (y * 37)) + pattern) & maximum; + } + else if (pattern == 5 && (y & 1) != 0) + { + // Even rows match exactly while odd rows disagree, forcing the alternate-row reliability check. + sample = maximum - sample; + } + + source[(y * sourceStride) + x] = TSample.CreateChecked(sample); + } + } + + Rectangle frameBounds = Rectangle.FromLTRB(-24, -24, 25, 25); + Rectangle bounds = referenceVector.GetFullPixelSearchBounds(frameBounds); + for (int methodIndex = 0; methodIndex <= (int)FullPixelSearchMethod.VeryFastDiamond; methodIndex++) + { + FullPixelSearchMethod method = (FullPixelSearchMethod)methodIndex; + Av1MotionSearchSites sites = workspace.GetMotionSearchSites(method, ReferenceStride); + Av1MotionSearchBase.FullPixelSearch search = new( + source, + sourceStride, + reference, + ReferenceStride, + ReferenceOrigin, + new Size(width, height), + bounds, + referenceVector, + costs, + bitDepth, + sadPerBit, + multiplier); + + Av1MotionSearchBase.FullPixelResult result = search.Search( + start, 5, method, sites, settings, false, false, costList, out Point? secondBest); + + Assert.True(bounds.Contains(result.Vector)); + if (secondBest.HasValue) + { + Assert.True(bounds.Contains(secondBest.Value)); + } + + long sum = 0; + long squares = 0; + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + int prediction = int.CreateChecked( + reference[ReferenceOrigin + ((y + result.Vector.Y) * ReferenceStride) + x + result.Vector.X]); + + long residual = int.CreateChecked(source[(y * sourceStride) + x]) - prediction; + sum += residual; + squares += residual * residual; + } + } + + if (bits != 8) + { + sum = (sum + (1L << (bits - 9))) >> (bits - 8); + squares = (squares + (1L << (((bits - 8) * 2) - 1))) >> ((bits - 8) * 2); + } + + int expectedVariance = (int)Math.Max(squares - ((sum * sum) / (width * height)), 0); + Av1MotionVector resultVector = new(result.Vector.Y * 8, result.Vector.X * 8); + int syntaxRate = writer.GetMotionVectorCost(resultVector, referenceVector, Av1MotionVectorPrecision.EighthSample); + int expectedMotionCost = (int)((((long)syntaxRate * Math.Max(multiplier >> 6, 1)) + 8192) >> 14); + Assert.Equal(expectedVariance, result.Variance); + Assert.Equal((int)squares, result.SquaredError); + Assert.Equal(expectedMotionCost, result.MotionCost); + if (pattern == 0) + { + Assert.Equal(start, result.Vector); + Assert.Equal(0, result.SquaredError); + } + + // This export records both source inputs and the published state. A native comparison can + // establish controller agreement without calling native code from the managed test process. + using BinaryWriter output = new(File.Create(Path.Combine(directory, $"{bits}-{width}-{pattern}-{methodIndex}.bin"))); + foreach (int value in new[] + { + 1, bits, width, height, sourceStride, ReferenceStride, ReferenceOrigin, + frameBounds.Left, frameBounds.Top, frameBounds.Right, frameBounds.Bottom, + referenceVector.Row, referenceVector.Column, start.X, start.Y, 5, methodIndex, (int)speed, + frameSize.Width, frameSize.Height, QIndex, 0, screenContent ? 1 : 0, 0, 0, source.Length, reference.Length, + result.Vector.X, result.Vector.Y, result.Variance, result.SquaredError, result.MotionCost, + secondBest.HasValue ? 1 : 0, secondBest.GetValueOrDefault().X, secondBest.GetValueOrDefault().Y, multiplier, sadPerBit + }) + { + output.Write(value); + } + + foreach (int cost in costList) + { + output.Write(cost); + } + + output.Write(MemoryMarshal.AsBytes(source.AsSpan())); + output.Write(MemoryMarshal.AsBytes(reference.AsSpan())); + if (method == FullPixelSearchMethod.NStep) + { + VerifyFractionalSearches( + bitDepth, + bits, + pattern, + width, + source, + sourceStride, + reference, + ReferenceStride, + ReferenceOrigin, + frameBounds, + referenceVector, + costs, + multiplier, + result, + costList, + writer, + directory, + methodIndex); + } + } + } + } + } + + /// + /// Exercises fractional policy, retained statistics, precision stops, and repeated-center termination. + /// + private static void VerifyFractionalSearches( + Av1BitDepth bitDepth, + int bits, + int pattern, + int width, + TSample[] source, + int sourceStride, + TSample[] reference, + int referenceStride, + int referenceOrigin, + Rectangle frameBounds, + Av1MotionVector referenceVector, + Av1MotionVectorCosts costs, + int multiplier, + Av1MotionSearchBase.FullPixelResult integerResult, + int[] costList, + Av1SymbolEncoder writer, + string directory, + int fullPixelMethod) + where TSample : unmanaged + where TOperator : struct, Av1MotionSearchBase.IMotionSearchOperator + { + TSample[] prediction = new TSample[Av1TranslationalInterPredictor.SearchPredictionBufferLength]; + Av1MotionVector[] centers = new Av1MotionVector[3]; + Av1MotionVector start = new(integerResult.Vector.Y * 8, integerResult.Vector.X * 8); + Rectangle bounds = referenceVector.GetSubpixelSearchBounds(frameBounds); + Av1MotionSearchBase.FractionalSearch search = new( + source, + sourceStride, + reference, + referenceStride, + referenceOrigin, + prediction, + new Size(width, width), + bounds, + referenceVector, + costs, + bitDepth, + multiplier); + + using BinaryWriter output = new(File.Create(Path.Combine(directory, $"{bits}-{width}-{pattern}-{fullPixelMethod}.fractional"))); + foreach (FractionalSearchMethod method in Enum.GetValues()) + { + foreach (int taps in new[] { 2, 4, 8 }) + { + for (int variant = 0; variant < 4; variant++) + { + SearchPrecision precision = (SearchPrecision)variant; + bool allowHighPrecision = (pattern & 1) == 0; + int iterations = (pattern & 1) + 1; + bool retainStatistics = pattern % 3 != 0; + bool retainCosts = pattern % 3 != 1; + Array.Fill(centers, new Av1MotionVector(short.MinValue, short.MinValue)); + int cost = search.Search( + start, + retainStatistics ? integerResult : null, + method, + precision, + allowHighPrecision, + iterations, + taps, + retainCosts ? costList : ReadOnlySpan.Empty, + centers, + out Av1MotionSearchBase.FractionalResult result); + + Assert.True(bounds.Contains(result.Vector.Column, result.Vector.Row)); + int syntaxRate = writer.GetMotionVectorCost(result.Vector, referenceVector, Av1MotionVectorPrecision.EighthSample); + int expectedMotionCost = (int)((((long)syntaxRate * Math.Max(multiplier >> 6, 1)) + 8192) >> 14); + Assert.Equal(expectedMotionCost, result.MotionCost); + Assert.Equal(result.Cost, cost); + + // Repeating the same start must stop at the retained first precision center. Integer-only + // searches never visit that center and therefore still publish their ordinary total cost. + int repeatedCost = search.Search( + start, + retainStatistics ? integerResult : null, + method, + precision, + allowHighPrecision, + iterations, + taps, + retainCosts ? costList : ReadOnlySpan.Empty, + centers, + out Av1MotionSearchBase.FractionalResult repeated); + + Assert.Equal(precision == SearchPrecision.Integer ? cost : int.MaxValue, repeatedCost); + foreach (int value in new[] + { + (int)method, taps, variant, allowHighPrecision ? 1 : 0, iterations, retainStatistics ? 1 : 0, retainCosts ? 1 : 0, + cost, result.Vector.Column, result.Vector.Row, result.Variance, result.SquaredError, result.MotionCost, + centers[0].Row, centers[0].Column, centers[1].Row, centers[1].Column, centers[2].Row, centers[2].Column, + repeatedCost, repeated.Vector.Column, repeated.Vector.Row, repeated.Variance, repeated.SquaredError, repeated.MotionCost + }) + { + output.Write(value); + } + } + } + } + } +} diff --git a/tests/ImageSharp.Tests/Formats/Heif/Av1/Av1MotionVectorCostsTests.cs b/tests/ImageSharp.Tests/Formats/Heif/Av1/Av1MotionVectorCostsTests.cs new file mode 100644 index 0000000000..1675235365 --- /dev/null +++ b/tests/ImageSharp.Tests/Formats/Heif/Av1/Av1MotionVectorCostsTests.cs @@ -0,0 +1,87 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using SixLabors.ImageSharp.Formats.Heif.Av1.Entropy; +using SixLabors.ImageSharp.Formats.Heif.Av1.Motion; + +namespace SixLabors.ImageSharp.Tests.Formats.Heif.Av1; + +public class Av1MotionVectorCostsTests +{ + /// + /// Checks the table recurrence against symbol-by-symbol rate accumulation over every legal signed magnitude. + /// + /// The frame motion precision. + /// The corresponding increment between representable components. + /// Whether to adapt the entropy distributions before building the table. + [Theory] + [InlineData(Av1MotionVectorPrecision.Integer, 8, false)] + [InlineData(Av1MotionVectorPrecision.QuarterSample, 2, false)] + [InlineData(Av1MotionVectorPrecision.EighthSample, 1, false)] + [InlineData(Av1MotionVectorPrecision.Integer, 8, true)] + [InlineData(Av1MotionVectorPrecision.QuarterSample, 2, true)] + [InlineData(Av1MotionVectorPrecision.EighthSample, 1, true)] + public void EveryLegalComponentMatchesIndependentSymbolTraversal(int precisionValue, int step, bool adapt) + { + Av1MotionVectorPrecision precision = (Av1MotionVectorPrecision)precisionValue; + using Av1SymbolEncoder writer = new(Configuration.Default, 65536, 128, updateCdf: true); + if (adapt) + { + // Exercise asymmetric axes and signs, class-zero offsets, fractional symbols, and larger classes. + // The oracle below walks the syntax bits; it does not reuse the table's magnitude recurrence. + for (int i = 0; i < 1000; i++) + { + int row = ((i * 37) % 1024) * step; + int column = -((i * 71) % 2048) * step; + writer.WriteMotionVector(new(row, column), default, precision); + } + } + + int[] storage = new int[Av1MotionVectorCosts.StorageLength + 2]; + storage.AsSpan().Fill(-1234567); + Av1MotionVectorCosts costs = new(storage.AsSpan(1, Av1MotionVectorCosts.StorageLength), precision); + writer.FillMotionVectorCosts(costs); + Av1MotionVector reference = new(16, -24); + int maximum = (Av1MotionVectorCosts.MaximumComponent / step) * step; + for (int difference = -maximum; difference <= maximum; difference += step) + { + Av1MotionVector vertical = new(reference.Row + difference, reference.Column); + Av1MotionVector horizontal = new(reference.Row, reference.Column + difference); + Av1MotionVector both = new(reference.Row + difference, reference.Column - difference); + Assert.Equal(writer.GetMotionVectorCost(vertical, reference, precision), costs.GetCost(vertical, reference)); + Assert.Equal(writer.GetMotionVectorCost(horizontal, reference, precision), costs.GetCost(horizontal, reference)); + Assert.Equal(writer.GetMotionVectorCost(both, reference, precision), costs.GetCost(both, reference)); + } + + Assert.Equal(-1234567, storage[0]); + Assert.Equal(-1234567, storage[^1]); + } + + /// + /// Verifies that table construction preserves the other precision pair and that adaptation requires an explicit refresh. + /// + [Fact] + public void SnapshotRetainsRatesUntilExplicitRefreshAndPrecisionPairsDoNotOverlap() + { + using Av1SymbolEncoder writer = new(Configuration.Default, 65536, 128, updateCdf: true); + int[] storage = new int[Av1MotionVectorCosts.StorageLength]; + Av1MotionVectorCosts quarter = new(storage, Av1MotionVectorPrecision.QuarterSample); + Av1MotionVectorCosts eighth = new(storage, Av1MotionVectorPrecision.EighthSample); + writer.FillMotionVectorCosts(quarter); + Av1MotionVector value = new(24, -48); + int original = quarter.GetCost(value, default); + writer.FillMotionVectorCosts(eighth); + Assert.Equal(original, quarter.GetCost(value, default)); + + for (int i = 0; i < 100; i++) + { + writer.WriteMotionVector(value, default, Av1MotionVectorPrecision.QuarterSample); + } + + Assert.Equal(original, quarter.GetCost(value, default)); + int adapted = writer.GetMotionVectorCost(value, default, Av1MotionVectorPrecision.QuarterSample); + Assert.NotEqual(original, adapted); + writer.FillMotionVectorCosts(quarter); + Assert.Equal(adapted, quarter.GetCost(value, default)); + } +} diff --git a/tests/ImageSharp.Tests/Formats/Heif/Av1/Av1MotionVectorTests.cs b/tests/ImageSharp.Tests/Formats/Heif/Av1/Av1MotionVectorTests.cs index 7aaaae5381..79191e4638 100644 --- a/tests/ImageSharp.Tests/Formats/Heif/Av1/Av1MotionVectorTests.cs +++ b/tests/ImageSharp.Tests/Formats/Heif/Av1/Av1MotionVectorTests.cs @@ -11,6 +11,95 @@ namespace SixLabors.ImageSharp.Tests.Formats.Heif.Av1; [Trait("Format", "Avif")] public class Av1MotionVectorTests { + /// + /// Checks frame-relative displacement limits and their inclusive last candidate. + /// + /// The block column. + /// The block row. + /// The block width. + /// The block height. + /// The allocated luma border. + /// The first legal horizontal displacement. + /// The first legal vertical displacement. + /// The last legal horizontal displacement. + /// The last legal vertical displacement. + [Theory] + [InlineData(0, 0, 8, 8, 96, -16, -16, 264, 264)] + [InlineData(128, 64, 8, 8, 96, -144, -80, 136, 200)] + [InlineData(252, 252, 8, 8, 96, -268, -268, 12, 12)] + [InlineData(0, 0, 128, 128, 96, -88, -88, 216, 216)] + [InlineData(0, 0, 128, 128, 160, -136, -136, 264, 264)] + public void FrameSearchBoundsIncludePositionBlockExtentAndInterpolation( + int x, + int y, + int width, + int height, + int border, + int minimumColumn, + int minimumRow, + int maximumColumn, + int maximumRow) + { + Rectangle bounds = Av1MotionVector.GetFrameSearchBounds(new Rectangle(x, y, width, height), new Size(256, 256), border); + Assert.Equal(Rectangle.FromLTRB(minimumColumn, minimumRow, maximumColumn + 1, maximumRow + 1), bounds); + Assert.True(bounds.Contains(minimumColumn, minimumRow)); + Assert.True(bounds.Contains(maximumColumn, maximumRow)); + Assert.False(bounds.Contains(maximumColumn + 1, maximumRow)); + Assert.False(bounds.Contains(maximumColumn, maximumRow + 1)); + } + + /// + /// Checks inward rounding around fractional references and exclusion of reserved vector endpoints. + /// + /// The reference row in eighth-sample units. + /// The reference column in eighth-sample units. + /// The first full-pixel column. + /// The first full-pixel row. + /// The last full-pixel column. + /// The last full-pixel row. + /// The first eighth-sample column. + /// The first eighth-sample row. + /// The last eighth-sample column. + /// The last eighth-sample row. + [Theory] + [InlineData(1, -1, -1023, -1022, 1022, 1023, -8185, -8183, 8183, 8185)] + [InlineData(-1, 1, -1022, -1023, 1023, 1022, -8183, -8185, 8185, 8183)] + [InlineData(16376, -16376, -2047, 1024, -1024, 2047, -16383, 8192, -8192, 16383)] + [InlineData(-16376, 16376, 1024, -2047, 2047, -1024, 8192, -16383, 16383, -8192)] + public void SearchBoundsRoundInwardAndExcludeReservedVectorEndpoints( + int row, + int column, + int fullMinimumColumn, + int fullMinimumRow, + int fullMaximumColumn, + int fullMaximumRow, + int fractionalMinimumColumn, + int fractionalMinimumRow, + int fractionalMaximumColumn, + int fractionalMaximumRow) + { + Av1MotionVector reference = new(row, column); + Rectangle frameBounds = Rectangle.FromLTRB(-4000, -4000, 4001, 4001); + Rectangle full = reference.GetFullPixelSearchBounds(frameBounds); + Rectangle fractional = reference.GetSubpixelSearchBounds(frameBounds); + Assert.Equal(Rectangle.FromLTRB(fullMinimumColumn, fullMinimumRow, fullMaximumColumn + 1, fullMaximumRow + 1), full); + Assert.Equal(Rectangle.FromLTRB(fractionalMinimumColumn, fractionalMinimumRow, fractionalMaximumColumn + 1, fractionalMaximumRow + 1), fractional); + Assert.True(fractional.Contains(full.Left * 8, full.Top * 8)); + Assert.True(fractional.Contains((full.Right - 1) * 8, (full.Bottom - 1) * 8)); + } + + /// + /// Verifies that reference-centered limits cannot widen a tighter padded-frame region. + /// + [Fact] + public void FullAndFractionalSearchRetainTighterFrameBounds() + { + Rectangle frameBounds = Rectangle.FromLTRB(-17, -29, 32, 44); + Av1MotionVector reference = new(1, -1); + Assert.Equal(frameBounds, reference.GetFullPixelSearchBounds(frameBounds)); + Assert.Equal(Rectangle.FromLTRB(-136, -232, 249, 345), reference.GetSubpixelSearchBounds(frameBounds)); + } + /// /// Verifies that high-precision vectors retain their one-eighth-sample components unchanged. ///