diff --git a/HEIF_IMPLEMENTATION_PLAN.md b/HEIF_IMPLEMENTATION_PLAN.md
index 9596d46aac..423aea37e8 100644
--- a/HEIF_IMPLEMENTATION_PLAN.md
+++ b/HEIF_IMPLEMENTATION_PLAN.md
@@ -851,6 +851,7 @@ Encoder verification contract:
- [x] Filter-intra mode decision now runs after ordinary luma modes in current-libaom order, evaluates all five recursive predictors, and refines each predictor across every legal 8x8 transform in transform-enum order. Strictly-better replacement preserves ordinary-mode and filter-mode tie order. Each filter prediction and its source residual are prepared once and reused across transform candidates, avoiding repeated recursive prediction while retaining SIMD-first predictor and subtraction operators. The stack cost is 192 bytes for eight-bit samples or 256 bytes for high-bit-depth samples; no allocator owner or managed buffer was added. Fifteen production cases force every filter mode at 8, 10, and 12 bits and prove retained filter syntax, zero-residual reconstruction, and the DCT-DCT equal-cost transform tie. The decoded-frame MD5 values selected by this checkpoint are `d7d68803763b95827483f14515281d3a` for the 8x8 10-bit gradient, `3f7e34d44c65d7797ad26b5cd4c35bf4` for the 8x8 12-bit gradient, and `9985f05790d2c9f5f28723ef86d5b89b`, `2ba2f1d0fcfef60394a5175553c7cb8b`, and `6aa7a2ed0dbf76ad2ec0c222585272d0` for the odd 4:2:0, 4:2:2, and 4:4:4 gradients. The exact net11 Release rebuild remains at 1,005 warnings and zero errors, 18 focused filter-intra, predictor-reference, syntax-cost, and allocation cases pass, all 8,974 HEIF/AV1 tests pass, and current-main `aomdec` at `a40ed1ea9e4ecc3df58a5bccb76623f2c94ae727` accepts all 29 regenerated payloads.
- [x] Empty-transform block skip now compares the complete live rate of the two decoder-identical syntax choices after luma and every coded chroma plane have been selected. Current libaom forces all-intra blocks to non-skip; this encoder retains that behavior for every non-empty block and for equal-cost empty blocks, but emits block skip when its adapted context cost is strictly lower than non-skip plus all empty-transform coefficient costs. Costing and writing share the same above-and-left skip-context calculation, and the coefficient estimator returns after the transform-block-skip symbol without reading coefficient storage. This adds no allocation, copy, or persistent state. A focused adapted-CDF regression proves both outcomes through the production decision helper, the two production all-zero fixtures still prove the default real block path, the exact net11 Release rebuild remains at 1,005 warnings and zero errors, all 8,975 HEIF/AV1 tests pass, and current-main `aomdec` at `a40ed1ea9e4ecc3df58a5bccb76623f2c94ae727` accepts all 29 regenerated payloads.
- [~] Palette entropy coding now mirrors current libaom's adaptive luma-mode, chroma-mode, palette-size, and spatial color-index distributions, together with its truncated-binary uniform code used by palette colors. The complete mutable palette probability graph is created once on first palette search or write, so the current palette-disabled frame path retains zero palette allocations. Three focused regressions cover every legal 2-through-8 color alphabet and every defined mode, size, and color-index context; all 1,928 entropy cases and all 8,978 HEIF/AV1 cases pass direct net11 Release VSTest. The exact Release rebuild remains at 1,005 warnings and zero errors. This checkpoint adds the exact entropy foundation only: palette candidate generation, retained color and index storage, mode decision, map tokenization, and production syntax remain incomplete, and no generated payload changed.
+- [~] Luma and chroma palette-color coding now matches current libaom's neighbor-cache flags, sorted delta representation, wrapped V-plane deltas, strict delta-versus-raw V selection, and fixed-point color-rate model at 8, 10, and 12 bits. Encoder costing and emission use only fixed stack spans, including explicitly initialized cache-membership state, and steady-state color costing allocates zero managed bytes. The decoder consumes the same bounded color-syntax primitive after the tile reader derives its neighbor cache, removing duplicated color parsing without changing retained palette ownership. Nine focused syntax, exact palette decode, constrained-allocation, truncation, presentation, and allocation cases pass; all 1,933 entropy cases and all 8,983 HEIF/AV1 cases pass direct net11 Release VSTest. The exact Release rebuild remains at 1,005 warnings and zero errors. Retained encoder palette colors, neighbor caches, color-index maps, candidate generation, and production palette selection remain incomplete, and the compact 8-byte frame mode entries were not enlarged.
- [x] The expanded checkpoint exposed a pre-existing transform-block test that asserted uninitialized pooled padding was zero. The test now initializes the complete physical luma plane with a sentinel and proves the block operation leaves both adjacent padding samples unchanged. The exact net11 Release rebuild remains at 1,005 baseline warnings and zero errors, the focused allocator-order set passes 30 of 30 cases, and the complete HEIF/AV1 namespace passes 8,859 of 8,859 direct VSTest cases with zero failures or skips.
- [x] Combined-frame OBU output now counts the byte-aligned frame and tile-group headers, non-final tile-size fields, and owned tile payloads before emitting the OBU size. It retains only the small allocator-owned header scratch and writes each entropy-coded tile span directly from its detached owner, removing the second file-sized allocator rent and complete-payload copy. A 64 KiB regression proves exactly one sub-payload-sized byte rent with a balanced return and verifies the exact streamed tile tail; the existing two-tile round trip proves size-prefix and ordering parity. The focused writer and production-frame set passes 32 of 32 direct net11 VSTest cases, current-main `aomdec` accepts all 29 generated native-format payloads, and the complete HEIF/AV1 namespace passes 8,860 of 8,860 cases with zero failures or skips.
- [x] Finalized fixed-block decisions now set the block-level transform-skip flag only when every retained luma and coded chroma transform has zero EOB, matching current libaom's conjunction of per-plane skip state. The previous always-false flag produced legal but redundant non-skip and zero-coefficient syntax. Monochrome and 4:2:0 regressions prove both branches from actual coefficient state; the focused decision and production-frame set passes 32 of 32 direct net11 VSTest cases. Current-main `aomdec` accepts all 29 regenerated payloads, the recorded decoded-frame MD5s are unchanged, and affected 16x16 constant 8-bit and 10-bit payloads are one byte smaller. The complete HEIF/AV1 namespace passes 8,862 of 8,862 cases with zero failures or skips.
diff --git a/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolDecoder.cs b/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolDecoder.cs
index 2179d6788e..eeaceaab54 100644
--- a/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolDecoder.cs
+++ b/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolDecoder.cs
@@ -110,6 +110,141 @@ internal ref struct Av1SymbolDecoder
return (value << 1) - threshold + r.ReadLiteral(1);
}
+ ///
+ /// Reads sorted luma palette colors using selections from the neighboring color cache.
+ ///
+ /// The sorted unique colors inherited from eligible neighbors.
+ /// The number of palette colors.
+ /// The number of bits in each color sample.
+ /// The destination palette colors in prediction-index order.
+ public void ReadPaletteYColors(
+ scoped ReadOnlySpan colorCache,
+ int paletteSize,
+ int bitDepth,
+ scoped Span colors)
+ {
+ Span cachedColors = stackalloc ushort[Av1Constants.PaletteMaxSize];
+ int colorIndex = 0;
+ for (int i = 0; i < colorCache.Length && colorIndex < paletteSize; i++)
+ {
+ if (this.ReadLiteral(1) != 0)
+ {
+ cachedColors[colorIndex++] = colorCache[i];
+ }
+ }
+
+ if (colorIndex == paletteSize)
+ {
+ cachedColors[..paletteSize].CopyTo(colors);
+ return;
+ }
+
+ int cachedColorCount = colorIndex;
+ colors[colorIndex++] = (ushort)this.ReadLiteral(bitDepth);
+ if (colorIndex < paletteSize)
+ {
+ int bits = bitDepth - 3 + this.ReadLiteral(2);
+ int maximumColor = (1 << bitDepth) - 1;
+ int range = maximumColor - colors[colorIndex - 1];
+ for (; colorIndex < paletteSize; colorIndex++)
+ {
+ int delta = this.ReadLiteral(bits) + 1;
+ colors[colorIndex] = (ushort)Av1Math.Clip3(0, maximumColor, colors[colorIndex - 1] + delta);
+ range -= colors[colorIndex] - colors[colorIndex - 1];
+ bits = Math.Min(bits, (int)Av1Math.CeilLog2((uint)range));
+ }
+ }
+
+ MergePaletteColors(colors, cachedColors, paletteSize, cachedColorCount);
+ }
+
+ ///
+ /// Reads shared chroma palette colors using cached U values and raw or delta-coded V values.
+ ///
+ /// The sorted unique U colors inherited from eligible neighbors.
+ /// The number of palette colors.
+ /// The number of bits in each color sample.
+ /// The destination U palette colors.
+ /// The destination V palette colors paired with .
+ public void ReadPaletteUvColors(
+ scoped ReadOnlySpan colorCache,
+ int paletteSize,
+ int bitDepth,
+ scoped Span uColors,
+ scoped Span vColors)
+ {
+ Span cachedColors = stackalloc ushort[Av1Constants.PaletteMaxSize];
+ int colorIndex = 0;
+ for (int i = 0; i < colorCache.Length && colorIndex < paletteSize; i++)
+ {
+ if (this.ReadLiteral(1) != 0)
+ {
+ cachedColors[colorIndex++] = colorCache[i];
+ }
+ }
+
+ if (colorIndex < paletteSize)
+ {
+ int cachedColorCount = colorIndex;
+ uColors[colorIndex++] = (ushort)this.ReadLiteral(bitDepth);
+ if (colorIndex < paletteSize)
+ {
+ int bits = bitDepth - 3 + this.ReadLiteral(2);
+ int maximumColor = (1 << bitDepth) - 1;
+ int range = (1 << bitDepth) - uColors[colorIndex - 1];
+ for (; colorIndex < paletteSize; colorIndex++)
+ {
+ int delta = this.ReadLiteral(bits);
+ uColors[colorIndex] = (ushort)Av1Math.Clip3(0, maximumColor, uColors[colorIndex - 1] + delta);
+ range -= uColors[colorIndex] - uColors[colorIndex - 1];
+ bits = Math.Min(bits, (int)Av1Math.CeilLog2((uint)range));
+ }
+ }
+
+ MergePaletteColors(uColors, cachedColors, paletteSize, cachedColorCount);
+ }
+ else
+ {
+ cachedColors[..paletteSize].CopyTo(uColors);
+ }
+
+ if (this.ReadLiteral(1) != 0)
+ {
+ // V deltas wrap in the unsigned sample domain so complementary chroma colors remain compact.
+ int bits = bitDepth - 4 + this.ReadLiteral(2);
+ int sampleRange = 1 << bitDepth;
+ vColors[0] = (ushort)this.ReadLiteral(bitDepth);
+ for (int i = 1; i < paletteSize; i++)
+ {
+ int delta = this.ReadLiteral(bits);
+ if (delta != 0 && this.ReadLiteral(1) != 0)
+ {
+ delta = -delta;
+ }
+
+ int value = vColors[i - 1] + delta;
+ if (value < 0)
+ {
+ value += sampleRange;
+ }
+
+ if (value >= sampleRange)
+ {
+ value -= sampleRange;
+ }
+
+ vColors[i] = (ushort)value;
+ }
+ }
+ else
+ {
+ for (int i = 0; i < paletteSize; i++)
+ {
+ vColors[i] = (ushort)this.ReadLiteral(bitDepth);
+ }
+ }
+ }
+
///
/// Reads a finite subexponential value recentered around a preceding value.
///
@@ -158,6 +293,40 @@ internal ref struct Av1SymbolDecoder
return r.ReadSymbol(this.context.SgrProjectionRestoration) != 0;
}
+ ///
+ /// Merges selected cached colors with the sorted transmitted colors.
+ ///
+ /// The transmitted colors beginning at and the merged output.
+ /// The selected cached colors in ascending order.
+ /// The total palette size.
+ /// The number of selected cached colors.
+ private static void MergePaletteColors(
+ Span colors,
+ ReadOnlySpan cachedColors,
+ int paletteSize,
+ int cachedColorCount)
+ {
+ if (cachedColorCount == 0)
+ {
+ return;
+ }
+
+ int cacheIndex = 0;
+ int transmittedIndex = cachedColorCount;
+ for (int i = 0; i < paletteSize; i++)
+ {
+ if (cacheIndex < cachedColorCount &&
+ (transmittedIndex >= paletteSize || cachedColors[cacheIndex] <= colors[transmittedIndex]))
+ {
+ colors[i] = cachedColors[cacheIndex++];
+ }
+ else
+ {
+ colors[i] = colors[transmittedIndex++];
+ }
+ }
+ }
+
///
/// Reads a finite subexponential code from the tile entropy stream.
///
diff --git a/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolEncoder.cs b/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolEncoder.cs
index 0e0e2c2939..11d231e2bb 100644
--- a/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolEncoder.cs
+++ b/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolEncoder.cs
@@ -418,6 +418,167 @@ internal class Av1SymbolEncoder : IDisposable
w.WriteSymbol(colorOrderIndex, distribution);
}
+ ///
+ /// Gets the fixed-point rate of the luma palette colors.
+ ///
+ /// The sorted unique colors inherited from eligible neighbors.
+ /// The sorted luma palette colors.
+ /// The number of bits in each color sample.
+ /// The rate cost in 1/512-bit units.
+ public static int GetPaletteYColorCost(
+ ReadOnlySpan colorCache,
+ ReadOnlySpan colors,
+ int bitDepth)
+ {
+ Span cacheColorFound = stackalloc byte[Av1Constants.PaletteMaxSize * 2];
+ Span uncachedColors = stackalloc ushort[Av1Constants.PaletteMaxSize];
+ int uncachedColorCount = IndexColorCache(
+ colorCache,
+ colors,
+ cacheColorFound,
+ uncachedColors);
+
+ // Palette RD modeling charges every available cache flag even though emission can stop once all colors match.
+ int bitCount = colorCache.Length +
+ GetDeltaEncodedColorBitCount(uncachedColors[..uncachedColorCount], bitDepth, minimumDelta: 1);
+
+ return Av1ProbabilityCost.GetLiteralCost(bitCount);
+ }
+
+ ///
+ /// Writes the luma palette colors using neighboring cache selections followed by sorted deltas.
+ ///
+ /// The sorted unique colors inherited from eligible neighbors.
+ /// The sorted luma palette colors.
+ /// The number of bits in each color sample.
+ public void WritePaletteYColors(
+ ReadOnlySpan colorCache,
+ ReadOnlySpan colors,
+ int bitDepth)
+ {
+ Span cacheColorFound = stackalloc byte[Av1Constants.PaletteMaxSize * 2];
+ Span uncachedColors = stackalloc ushort[Av1Constants.PaletteMaxSize];
+ int uncachedColorCount = IndexColorCache(
+ colorCache,
+ colors,
+ cacheColorFound,
+ uncachedColors);
+
+ int cachedColorCount = 0;
+ for (int i = 0; i < colorCache.Length && cachedColorCount < colors.Length; i++)
+ {
+ byte found = cacheColorFound[i];
+ this.WriteLiteral(found, 1);
+ cachedColorCount += found;
+ }
+
+ this.WriteDeltaEncodedColors(uncachedColors[..uncachedColorCount], bitDepth, minimumDelta: 1);
+ }
+
+ ///
+ /// Gets the fixed-point rate of the shared chroma palette colors.
+ ///
+ /// The sorted unique U colors inherited from eligible neighbors.
+ /// The sorted U palette colors.
+ /// The V palette colors paired with .
+ /// The number of bits in each color sample.
+ /// The rate cost in 1/512-bit units.
+ public static int GetPaletteUvColorCost(
+ ReadOnlySpan colorCache,
+ ReadOnlySpan uColors,
+ ReadOnlySpan vColors,
+ int bitDepth)
+ {
+ Span cacheColorFound = stackalloc byte[Av1Constants.PaletteMaxSize * 2];
+ Span uncachedColors = stackalloc ushort[Av1Constants.PaletteMaxSize];
+ int uncachedColorCount = IndexColorCache(
+ colorCache,
+ uColors,
+ cacheColorFound,
+ uncachedColors);
+
+ // Palette RD modeling charges every available cache flag even though emission can stop once all colors match.
+ int bitCount = colorCache.Length +
+ GetDeltaEncodedColorBitCount(uncachedColors[..uncachedColorCount], bitDepth, minimumDelta: 0);
+
+ int deltaBits = GetPaletteVDeltaBitCount(vColors, bitDepth, out int zeroCount, out int minimumBits);
+ int deltaBitCount = 2 + bitDepth + ((deltaBits + 1) * (vColors.Length - 1)) - zeroCount;
+ int rawBitCount = bitDepth * vColors.Length;
+ bitCount += 1 + Math.Min(deltaBitCount, rawBitCount);
+ return Av1ProbabilityCost.GetLiteralCost(bitCount);
+ }
+
+ ///
+ /// Writes the shared chroma palette colors using cached U values and the cheaper V representation.
+ ///
+ /// The sorted unique U colors inherited from eligible neighbors.
+ /// The sorted U palette colors.
+ /// The V palette colors paired with .
+ /// The number of bits in each color sample.
+ public void WritePaletteUvColors(
+ ReadOnlySpan colorCache,
+ ReadOnlySpan uColors,
+ ReadOnlySpan vColors,
+ int bitDepth)
+ {
+ Span cacheColorFound = stackalloc byte[Av1Constants.PaletteMaxSize * 2];
+ Span uncachedColors = stackalloc ushort[Av1Constants.PaletteMaxSize];
+ int uncachedColorCount = IndexColorCache(
+ colorCache,
+ uColors,
+ cacheColorFound,
+ uncachedColors);
+
+ int cachedColorCount = 0;
+ for (int i = 0; i < colorCache.Length && cachedColorCount < uColors.Length; i++)
+ {
+ byte found = cacheColorFound[i];
+ this.WriteLiteral(found, 1);
+ cachedColorCount += found;
+ }
+
+ this.WriteDeltaEncodedColors(uncachedColors[..uncachedColorCount], bitDepth, minimumDelta: 0);
+
+ int deltaBits = GetPaletteVDeltaBitCount(vColors, bitDepth, out int zeroCount, out int minimumBits);
+ int deltaBitCount = 2 + bitDepth + ((deltaBits + 1) * (vColors.Length - 1)) - zeroCount;
+ int rawBitCount = bitDepth * vColors.Length;
+ bool useDelta = deltaBitCount < rawBitCount;
+ this.WriteLiteral(useDelta ? 1u : 0u, 1);
+ if (!useDelta)
+ {
+ for (int i = 0; i < vColors.Length; i++)
+ {
+ this.WriteLiteral(vColors[i], bitDepth);
+ }
+
+ return;
+ }
+
+ this.WriteLiteral((uint)(deltaBits - minimumBits), 2);
+ this.WriteLiteral(vColors[0], bitDepth);
+ int sampleRange = 1 << bitDepth;
+ for (int i = 1; i < vColors.Length; i++)
+ {
+ int signedDelta = vColors[i] - vColors[i - 1];
+ int delta = Math.Abs(signedDelta);
+
+ // Chroma wraps in its unsigned sample domain, so signal whichever circular direction has less magnitude.
+ if (delta <= sampleRange - delta)
+ {
+ this.WriteLiteral((uint)delta, deltaBits);
+ if (delta != 0)
+ {
+ this.WriteLiteral(signedDelta < 0 ? 1u : 0u, 1);
+ }
+ }
+ else
+ {
+ this.WriteLiteral((uint)(sampleRange - delta), deltaBits);
+ this.WriteLiteral(signedDelta < 0 ? 0u : 1u, 1);
+ }
+ }
+ }
+
///
/// Writes the frame-local intra-block-copy flag.
///
@@ -1366,6 +1527,175 @@ internal class Av1SymbolEncoder : IDisposable
}
}
+ ///
+ /// Separates palette colors selected from the neighbor cache from colors that require literal coding.
+ ///
+ /// The sorted unique neighbor colors.
+ /// The sorted palette colors.
+ /// The cache-selection flags.
+ /// The destination for colors absent from the cache.
+ /// The number of uncached colors.
+ private static int IndexColorCache(
+ ReadOnlySpan colorCache,
+ ReadOnlySpan colors,
+ Span cacheColorFound,
+ Span uncachedColors)
+ {
+ cacheColorFound[..colorCache.Length].Clear();
+ Span inCache = stackalloc byte[Av1Constants.PaletteMaxSize];
+ inCache.Clear();
+
+ // Cache-order flags drive the bitstream while palette-order flags preserve the sorted uncached output.
+ int cachedColorCount = 0;
+ for (int cacheIndex = 0; cacheIndex < colorCache.Length && cachedColorCount < colors.Length; cacheIndex++)
+ {
+ for (int colorIndex = 0; colorIndex < colors.Length; colorIndex++)
+ {
+ if (colors[colorIndex] == colorCache[cacheIndex])
+ {
+ inCache[colorIndex] = 1;
+ cacheColorFound[cacheIndex] = 1;
+ cachedColorCount++;
+ break;
+ }
+ }
+ }
+
+ int uncachedColorCount = 0;
+ for (int colorIndex = 0; colorIndex < colors.Length; colorIndex++)
+ {
+ if (inCache[colorIndex] == 0)
+ {
+ uncachedColors[uncachedColorCount++] = colors[colorIndex];
+ }
+ }
+
+ return uncachedColorCount;
+ }
+
+ ///
+ /// Gets the literal length of an ascending palette-color sequence.
+ ///
+ /// The sorted colors.
+ /// The number of bits in each color sample.
+ /// The minimum representable difference between adjacent colors.
+ /// The literal length in bits.
+ private static int GetDeltaEncodedColorBitCount(
+ ReadOnlySpan colors,
+ int bitDepth,
+ int minimumDelta)
+ {
+ if (colors.IsEmpty)
+ {
+ return 0;
+ }
+
+ int bitCount = bitDepth;
+ if (colors.Length == 1)
+ {
+ return bitCount;
+ }
+
+ int maximumDelta = 0;
+ for (int i = 1; i < colors.Length; i++)
+ {
+ maximumDelta = Math.Max(maximumDelta, colors[i] - colors[i - 1]);
+ }
+
+ int minimumBits = bitDepth - 3;
+ int bits = Math.Max(
+ (int)Av1Math.CeilLog2((uint)(maximumDelta + 1 - minimumDelta)),
+ minimumBits);
+
+ int range = (1 << bitDepth) - colors[0] - minimumDelta;
+ bitCount += 2;
+ for (int i = 1; i < colors.Length; i++)
+ {
+ int delta = colors[i] - colors[i - 1];
+ bitCount += bits;
+ range -= delta;
+ bits = Math.Min(bits, (int)Av1Math.CeilLog2((uint)range));
+ }
+
+ return bitCount;
+ }
+
+ ///
+ /// Writes an ascending palette-color sequence as one literal followed by bounded deltas.
+ ///
+ /// The sorted colors.
+ /// The number of bits in each color sample.
+ /// The minimum representable difference between adjacent colors.
+ private void WriteDeltaEncodedColors(
+ ReadOnlySpan colors,
+ int bitDepth,
+ int minimumDelta)
+ {
+ if (colors.IsEmpty)
+ {
+ return;
+ }
+
+ this.WriteLiteral(colors[0], bitDepth);
+ if (colors.Length == 1)
+ {
+ return;
+ }
+
+ int maximumDelta = 0;
+ for (int i = 1; i < colors.Length; i++)
+ {
+ maximumDelta = Math.Max(maximumDelta, colors[i] - colors[i - 1]);
+ }
+
+ int minimumBits = bitDepth - 3;
+ int bits = Math.Max(
+ (int)Av1Math.CeilLog2((uint)(maximumDelta + 1 - minimumDelta)),
+ minimumBits);
+
+ this.WriteLiteral((uint)(bits - minimumBits), 2);
+ int range = (1 << bitDepth) - colors[0] - minimumDelta;
+ for (int i = 1; i < colors.Length; i++)
+ {
+ int delta = colors[i] - colors[i - 1];
+ this.WriteLiteral((uint)(delta - minimumDelta), bits);
+ range -= delta;
+ bits = Math.Min(bits, (int)Av1Math.CeilLog2((uint)range));
+ }
+ }
+
+ ///
+ /// Gets the bit width required by wrapped V-plane palette deltas.
+ ///
+ /// The V-plane colors in U-palette order.
+ /// The number of bits in each color sample.
+ /// The number of deltas that omit a sign bit.
+ /// The minimum permitted delta width.
+ /// The delta width in bits.
+ private static int GetPaletteVDeltaBitCount(
+ ReadOnlySpan colors,
+ int bitDepth,
+ out int zeroCount,
+ out int minimumBits)
+ {
+ int sampleRange = 1 << bitDepth;
+ int maximumDelta = 0;
+ zeroCount = 0;
+ minimumBits = bitDepth - 4;
+ for (int i = 1; i < colors.Length; i++)
+ {
+ int delta = Math.Abs(colors[i] - colors[i - 1]);
+ int wrappedDelta = Math.Min(delta, sampleRange - delta);
+ maximumDelta = Math.Max(maximumDelta, wrappedDelta);
+ if (wrappedDelta == 0)
+ {
+ zeroCount++;
+ }
+ }
+
+ return Math.Max((int)Av1Math.CeilLog2((uint)(maximumDelta + 1)), minimumBits);
+ }
+
///
/// Emits coefficient syntax and reports no estimated rate.
///
diff --git a/src/ImageSharp/Formats/Heif/Av1/Tiling/Av1TileReader.cs b/src/ImageSharp/Formats/Heif/Av1/Tiling/Av1TileReader.cs
index 4df5cfb8f6..2209404293 100644
--- a/src/ImageSharp/Formats/Heif/Av1/Tiling/Av1TileReader.cs
+++ b/src/ImageSharp/Formats/Heif/Av1/Tiling/Av1TileReader.cs
@@ -2659,40 +2659,8 @@ internal sealed class Av1TileReader : IAv1TileReader, IDisposable
scoped Span colors)
{
Span colorCache = stackalloc ushort[Av1Constants.PaletteMaxSize * 2];
- Span cachedColors = stackalloc ushort[Av1Constants.PaletteMaxSize];
int cacheSize = GetPaletteCache(ref partitionInfo, Av1Plane.Y, colorCache);
- int colorIndex = 0;
- for (int i = 0; i < cacheSize && colorIndex < paletteSize; i++)
- {
- if (reader.ReadLiteral(1) != 0)
- {
- cachedColors[colorIndex++] = colorCache[i];
- }
- }
-
- if (colorIndex == paletteSize)
- {
- cachedColors[..paletteSize].CopyTo(colors);
- return;
- }
-
- int cachedColorCount = colorIndex;
- colors[colorIndex++] = (ushort)reader.ReadLiteral(bitDepth);
- if (colorIndex < paletteSize)
- {
- int bits = bitDepth - 3 + reader.ReadLiteral(2);
- int maximumColor = (1 << bitDepth) - 1;
- int range = maximumColor - colors[colorIndex - 1];
- for (; colorIndex < paletteSize; colorIndex++)
- {
- int delta = reader.ReadLiteral(bits) + 1;
- colors[colorIndex] = (ushort)Av1Math.Clip3(0, maximumColor, colors[colorIndex - 1] + delta);
- range -= colors[colorIndex] - colors[colorIndex - 1];
- bits = Math.Min(bits, (int)Av1Math.CeilLog2((uint)range));
- }
- }
-
- MergePaletteColors(colors, cachedColors, paletteSize, cachedColorCount);
+ reader.ReadPaletteYColors(colorCache[..cacheSize], paletteSize, bitDepth, colors);
}
///
@@ -2713,77 +2681,8 @@ internal sealed class Av1TileReader : IAv1TileReader, IDisposable
scoped Span vColors)
{
Span colorCache = stackalloc ushort[Av1Constants.PaletteMaxSize * 2];
- Span cachedColors = stackalloc ushort[Av1Constants.PaletteMaxSize];
int cacheSize = GetPaletteCache(ref partitionInfo, Av1Plane.U, colorCache);
- int colorIndex = 0;
- for (int i = 0; i < cacheSize && colorIndex < paletteSize; i++)
- {
- if (reader.ReadLiteral(1) != 0)
- {
- cachedColors[colorIndex++] = colorCache[i];
- }
- }
-
- if (colorIndex < paletteSize)
- {
- int cachedColorCount = colorIndex;
- uColors[colorIndex++] = (ushort)reader.ReadLiteral(bitDepth);
- if (colorIndex < paletteSize)
- {
- int bits = bitDepth - 3 + reader.ReadLiteral(2);
- int maximumColor = (1 << bitDepth) - 1;
- int range = (1 << bitDepth) - uColors[colorIndex - 1];
- for (; colorIndex < paletteSize; colorIndex++)
- {
- int delta = reader.ReadLiteral(bits);
- uColors[colorIndex] = (ushort)Av1Math.Clip3(0, maximumColor, uColors[colorIndex - 1] + delta);
- range -= uColors[colorIndex] - uColors[colorIndex - 1];
- bits = Math.Min(bits, (int)Av1Math.CeilLog2((uint)range));
- }
- }
-
- MergePaletteColors(uColors, cachedColors, paletteSize, cachedColorCount);
- }
- else
- {
- cachedColors[..paletteSize].CopyTo(uColors);
- }
-
- if (reader.ReadLiteral(1) != 0)
- {
- // V deltas wrap in the unsigned sample domain so complementary chroma colors remain compact.
- int bits = bitDepth - 4 + reader.ReadLiteral(2);
- int maximumColorPlusOne = 1 << bitDepth;
- vColors[0] = (ushort)reader.ReadLiteral(bitDepth);
- for (int i = 1; i < paletteSize; i++)
- {
- int delta = reader.ReadLiteral(bits);
- if (delta != 0 && reader.ReadLiteral(1) != 0)
- {
- delta = -delta;
- }
-
- int value = vColors[i - 1] + delta;
- if (value < 0)
- {
- value += maximumColorPlusOne;
- }
-
- if (value >= maximumColorPlusOne)
- {
- value -= maximumColorPlusOne;
- }
-
- vColors[i] = (ushort)value;
- }
- }
- else
- {
- for (int i = 0; i < paletteSize; i++)
- {
- vColors[i] = (ushort)reader.ReadLiteral(bitDepth);
- }
- }
+ reader.ReadPaletteUvColors(colorCache[..cacheSize], paletteSize, bitDepth, uColors, vColors);
}
///
@@ -2858,36 +2757,6 @@ internal sealed class Av1TileReader : IAv1TileReader, IDisposable
}
}
- ///
- /// Merges selected cached colors with the sorted transmitted colors in one prediction-order palette.
- ///
- /// The transmitted colors beginning at and the merged output.
- /// The selected cached colors in ascending order.
- /// The total palette size.
- /// The number of selected cached colors.
- private static void MergePaletteColors(Span colors, ReadOnlySpan cachedColors, int paletteSize, int cachedColorCount)
- {
- if (cachedColorCount == 0)
- {
- return;
- }
-
- int cacheIndex = 0;
- int transmittedIndex = cachedColorCount;
- for (int i = 0; i < paletteSize; i++)
- {
- if (cacheIndex < cachedColorCount &&
- (transmittedIndex >= paletteSize || cachedColors[cacheIndex] <= colors[transmittedIndex]))
- {
- colors[i] = cachedColors[cacheIndex++];
- }
- else
- {
- colors[i] = colors[transmittedIndex++];
- }
- }
- }
-
///
/// Computes the padded plane dimensions and the portion that lies inside the coded image.
///
diff --git a/tests/ImageSharp.Tests/Formats/Heif/Av1/Av1EntropyTests.cs b/tests/ImageSharp.Tests/Formats/Heif/Av1/Av1EntropyTests.cs
index 2c2df5df8e..65c8eeb587 100644
--- a/tests/ImageSharp.Tests/Formats/Heif/Av1/Av1EntropyTests.cs
+++ b/tests/ImageSharp.Tests/Formats/Heif/Av1/Av1EntropyTests.cs
@@ -1145,6 +1145,94 @@ public class Av1EntropyTests
Assert.Equal(Av1ProbabilityCost.GetLiteralCost(3), Av1SymbolEncoder.GetUniformCost(5, 3));
}
+ [Theory]
+ [InlineData(8)]
+ [InlineData(10)]
+ [InlineData(12)]
+ public void RoundTripPaletteColors(int bitDepth)
+ {
+ ushort[] colorCache = [4, 17, 23, 51];
+ ushort[] yColors = [4, 23, 90];
+ ushort[] uColors = [17, 51, 100];
+ ushort[] deltaVColors = [1, 2, 1];
+ ushort[] rawVColors = [0, (ushort)(1 << (bitDepth - 1)), 0];
+ using Av1SymbolEncoder encoder = new(Configuration.Default, 128, BaseQIndex);
+ encoder.WritePaletteYColors(colorCache, yColors, bitDepth);
+ encoder.WritePaletteUvColors(colorCache, uColors, deltaVColors, bitDepth);
+ encoder.WritePaletteUvColors(colorCache, uColors, rawVColors, bitDepth);
+
+ using IMemoryOwner encoded = encoder.Exit();
+ Av1SymbolDecoder decoder = new(Configuration.Default, encoded.GetSpan(), BaseQIndex);
+ ushort[] decodedYColors = new ushort[yColors.Length];
+ ushort[] decodedUColors = new ushort[uColors.Length];
+ ushort[] decodedDeltaVColors = new ushort[deltaVColors.Length];
+ ushort[] decodedRawVColors = new ushort[rawVColors.Length];
+ decoder.ReadPaletteYColors(colorCache, yColors.Length, bitDepth, decodedYColors);
+ decoder.ReadPaletteUvColors(
+ colorCache,
+ uColors.Length,
+ bitDepth,
+ decodedUColors,
+ decodedDeltaVColors);
+
+ Assert.Equal(yColors, decodedYColors);
+ Assert.Equal(uColors, decodedUColors);
+ Assert.Equal(deltaVColors, decodedDeltaVColors);
+
+ decodedUColors.AsSpan().Clear();
+ decoder.ReadPaletteUvColors(
+ colorCache,
+ uColors.Length,
+ bitDepth,
+ decodedUColors,
+ decodedRawVColors);
+
+ Assert.Equal(uColors, decodedUColors);
+ Assert.Equal(rawVColors, decodedRawVColors);
+ }
+
+ [Fact]
+ public void PaletteColorCostsMatchCurrentLibaomBitCounts()
+ {
+ ushort[] colorCache = [5, 10, 20];
+
+ Assert.Equal(
+ Av1ProbabilityCost.GetLiteralCost(20),
+ Av1SymbolEncoder.GetPaletteYColorCost([], [10, 20, 21], 8));
+
+ Assert.Equal(
+ Av1ProbabilityCost.GetLiteralCost(11),
+ Av1SymbolEncoder.GetPaletteYColorCost(colorCache, [5, 20, 30], 8));
+
+ Assert.Equal(
+ Av1ProbabilityCost.GetLiteralCost(32),
+ Av1SymbolEncoder.GetPaletteUvColorCost(colorCache, [5, 20, 30], [20, 21, 20], 8));
+
+ Assert.Equal(
+ Av1ProbabilityCost.GetLiteralCost(36),
+ Av1SymbolEncoder.GetPaletteUvColorCost(colorCache, [5, 20, 30], [0, 128, 0], 8));
+ }
+
+ [Fact]
+ public void PaletteColorCostDoesNotAllocate()
+ {
+ ushort[] colorCache = [5, 10, 20];
+ ushort[] yColors = [5, 20, 30];
+ ushort[] uColors = [5, 20, 30];
+ ushort[] vColors = [20, 21, 20];
+ _ = Av1SymbolEncoder.GetPaletteYColorCost(colorCache, yColors, 8);
+ long before = GC.GetAllocatedBytesForCurrentThread();
+
+ for (int i = 0; i < 1000; i++)
+ {
+ _ = Av1SymbolEncoder.GetPaletteYColorCost(colorCache, yColors, 8);
+ _ = Av1SymbolEncoder.GetPaletteUvColorCost(colorCache, uColors, vColors, 8);
+ }
+
+ long after = GC.GetAllocatedBytesForCurrentThread();
+ Assert.Equal(before, after);
+ }
+
[Theory]
[MemberData(nameof(GetRangeData), 20)]
public void RoundTripPartitionType(int context)