diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index b3df8e0..bddda69 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -149,9 +149,10 @@ jobs: # its tests compare all of them against libpng (Magick.NET), so they run on every tier too. - name: Test PNG run: env ${{ matrix.isa }} dotnet test tests/Sdcb.SimdCodecs.Tests -c Release --no-build --filter "FullyQualifiedName~Png" - # BMP's row kernels (byte shuffles, 5-6-5 widening) have scalar twins too, and ICO decodes through them. - - name: Test BMP and ICO - run: env ${{ matrix.isa }} dotnet test tests/Sdcb.SimdCodecs.Tests -c Release --no-build --filter "FullyQualifiedName~Bmp|FullyQualifiedName~Ico|FullyQualifiedName~Qoi|FullyQualifiedName~Pnm" + # BMP's row kernels (byte shuffles, 5-6-5 widening) have scalar twins too, and ICO decodes through them; + # so has the box filter behind scaled decodes (BoxDownscaler, in Abstractions). + - name: Test BMP, ICO and the box filter + run: env ${{ matrix.isa }} dotnet test tests/Sdcb.SimdCodecs.Tests -c Release --no-build --filter "FullyQualifiedName~Bmp|FullyQualifiedName~Ico|FullyQualifiedName~Qoi|FullyQualifiedName~Pnm|FullyQualifiedName~BoxDownscale" # The HEVC / HEIF kernels have scalar twins too. HeifEquivalenceTests compares the tiers on random # blocks and needs nothing; the golden tests need the fixtures (fetched before the build). No # REQUIRE_ORACLES here, so the live ffmpeg comparison skips while the goldens are still compared. diff --git a/AGENTS.md b/AGENTS.md index b6d01ca..69abdb4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,8 +33,8 @@ Tuned against that bar so far: JPEG encode and decode, WebP decode, PNG encode a ```bash dotnet build SimdCodecs.slnx -c Release REQUIRE_ORACLES=1 dotnet test SimdCodecs.slnx -c Release # what CI runs -DOTNET_EnableAVX2=0 dotnet test tests/Sdcb.SimdCodecs.Tests -c Release --filter "FullyQualifiedName~Jpeg|FullyQualifiedName~Png|FullyQualifiedName~Bmp|FullyQualifiedName~Ico|FullyQualifiedName~Qoi|FullyQualifiedName~Pnm" -DOTNET_EnableHWIntrinsic=0 dotnet test tests/Sdcb.SimdCodecs.Tests -c Release --filter "FullyQualifiedName~Jpeg|FullyQualifiedName~Png|FullyQualifiedName~Bmp|FullyQualifiedName~Ico|FullyQualifiedName~Qoi|FullyQualifiedName~Pnm" +DOTNET_EnableAVX2=0 dotnet test tests/Sdcb.SimdCodecs.Tests -c Release --filter "FullyQualifiedName~Jpeg|FullyQualifiedName~Png|FullyQualifiedName~Bmp|FullyQualifiedName~Ico|FullyQualifiedName~Qoi|FullyQualifiedName~Pnm|FullyQualifiedName~BoxDownscale" +DOTNET_EnableHWIntrinsic=0 dotnet test tests/Sdcb.SimdCodecs.Tests -c Release --filter "FullyQualifiedName~Jpeg|FullyQualifiedName~Png|FullyQualifiedName~Bmp|FullyQualifiedName~Ico|FullyQualifiedName~Qoi|FullyQualifiedName~Pnm|FullyQualifiedName~BoxDownscale" bash tests/Sdcb.SimdCodecs.AotSmoke/run.sh # NativeAOT output == JIT output bash tests/Sdcb.SimdCodecs.WasmSmoke/run.sh # Mono AOT (browser-wasm) output == JIT output ``` diff --git a/README.md b/README.md index b5ca449..f6ba382 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ File.WriteAllBytes("photo.jpg", jpeg); The rest of the facade: - `SimdCodecs.TryDecodeInto(file, buffer, stride, format)` — any `PixelFormat` straight into your buffer, at any row stride: BGRA (Windows / Skia), `Bgra32Premultiplied` (WPF, Direct2D, compositors), RGB, BGR (OpenCV), grey (OCR), and their 16-bit twins (`Rgba64`, `Gray16`, …) without losing a 16-bit PNG's or a 10-bit HEIC's precision. JPEG and PNG write the 8-bit layouts natively, with no RGBA frame in between; a colour JPEG's grey is its Y plane. -- `SimdCodecs.TryDecode(file, out image, options, cancellationToken)` — the native layout: channel count, 8/16-bit or float samples, real bit depth, ICC, Exif / XMP (`image.Metadata`). `DecodeOptions` asks for a region, a scaled decode (JPEG 1/2–1/8 in the DCT domain, JPEG 2000 by resolution level; `TryGetClosestSize` says what a codec does natively) or pooled memory (dispose the image to return it). Every entry point takes a `CancellationToken`. +- `SimdCodecs.TryDecode(file, out image, options, cancellationToken)` — the native layout: channel count, 8/16-bit or float samples, real bit depth, ICC, Exif / XMP (`image.Metadata`). `DecodeOptions` asks for a region, a scaled decode (any power of two for every codec: JPEG 1/2–1/8 in the DCT domain and JPEG 2000 by resolution level natively, the rest by a box filter after a full decode; `TryGetClosestSize` says what a codec does natively) or pooled memory (dispose the image to return it). Every entry point takes a `CancellationToken`. - `SimdCodecs.TryReadInfo(file, out info)` — from the headers alone: format, stored bit depth, colour encoding (is it PQ / HLG HDR?), resolution (DPI), EXIF orientation (reported, never applied), frame or page count, thumbnail. - `SimdCodecs.TryReadMetadata(file, out metadata)` / `TryDecodeThumbnail(file, out thumb)` — the Exif, XMP and ICC blocks for a transcoder to pass on, and the embedded preview (Exif IFD1, HEIF `thmb`). - `ImageDecoder.Open(stream | path | memory)` — a WIC-style session: `Format`, `FrameCount`, then `GetFrame(i).Decode(options)` or `.CopyPixels(region, format, buffer, stride)`; `OpenAsync` for a non-seekable stream. diff --git a/README.zh-CN.md b/README.zh-CN.md index 143cfc3..4be2718 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -36,7 +36,7 @@ File.WriteAllBytes("photo.jpg", jpeg); 门面的其他入口: - `SimdCodecs.TryDecodeInto(file, buffer, stride, format)`:按任意行跨度,把任意 `PixelFormat` 直接写进你的缓冲区: BGRA(Windows / Skia)、`Bgra32Premultiplied`(WPF、Direct2D、合成器)、RGB、BGR(OpenCV)、灰度(OCR),以及 它们的 16 位版本(`Rgba64`、`Gray16` 等),16 位 PNG、10 位 HEIC 的精度不会丢失。JPEG 和 PNG 原生输出 8 位布局, 不经过 RGBA 整帧;彩色 JPEG 的灰度就是它的 Y 平面。 -- `SimdCodecs.TryDecode(file, out image, options, cancellationToken)`:保留原始布局:通道数、8/16 位或浮点采样、 真实位深、ICC、Exif / XMP(`image.Metadata`)。`DecodeOptions` 可以只解一个区域、缩放解码(JPEG 在 DCT 域 1/2 到 1/8,JPEG 2000 按分辨率级别;`TryGetClosestSize` 告诉你 codec 原生能做到什么)或使用池化内存(释放图像即归还)。所有入口都接受 `CancellationToken`。 +- `SimdCodecs.TryDecode(file, out image, options, cancellationToken)`:保留原始布局:通道数、8/16 位或浮点采样、 真实位深、ICC、Exif / XMP(`image.Metadata`)。`DecodeOptions` 可以只解一个区域、缩放解码(每个 codec 都接受任意 2 的幂:JPEG 在 DCT 域 1/2 到 1/8、JPEG 2000 按分辨率级别是原生的,其余整幅解码后用 box 滤波缩小;`TryGetClosestSize` 告诉你 codec 原生能做到什么)或使用池化内存(释放图像即归还)。所有入口都接受 `CancellationToken`。 - `SimdCodecs.TryReadInfo(file, out info)`:只读文件头:格式、存储位深、颜色编码(是不是 PQ / HLG HDR)、分辨率 (DPI)、EXIF 方向(只报告,不旋转像素)、帧数或页数、有没有缩略图。 - `SimdCodecs.TryReadMetadata(file, out metadata)` / `TryDecodeThumbnail(file, out thumb)`:取出 Exif、XMP、ICC 原始块供转码透传,以及内嵌的预览图(Exif IFD1、HEIF `thmb`)。 - `ImageDecoder.Open(stream | path | memory)`:WIC 式的会话:`Format`、`FrameCount`,再 `GetFrame(i).Decode(options)` 或 `.CopyPixels(region, format, buffer, stride)`;不可寻址的流用 `OpenAsync`。 diff --git a/benchmarks/Sdcb.SimdCodecs.Heif.Benchmarks/PeakMemory.cs b/benchmarks/Sdcb.SimdCodecs.Heif.Benchmarks/PeakMemory.cs index 61730f4..d150647 100644 --- a/benchmarks/Sdcb.SimdCodecs.Heif.Benchmarks/PeakMemory.cs +++ b/benchmarks/Sdcb.SimdCodecs.Heif.Benchmarks/PeakMemory.cs @@ -1,5 +1,7 @@ using System.Diagnostics; using System.Runtime.InteropServices; +using Sdcb.SimdCodecs.Abstractions; +using Sdcb.SimdCodecs.Abstractions.CodecSupport; using Sdcb.SimdCodecs.Heif; namespace Sdcb.SimdCodecs.Heif.Benchmarks; @@ -18,18 +20,28 @@ public sealed record Sample(string Fixture, string Contestant, long BaselineByte public long RiseBytes => PeakBytes - BaselineBytes; } - public static readonly string[] Contestants = ["ours", "libheif", "magick"]; + /// + /// Full-size RGBA: ours, libheif, magick. Scaled by d through the adapter + /// (HeifImageDecoder.TryDecode with ScaleDenominator, Rgb24 / Rgb48): scaled-dN is the native + /// path (tiles boxed as they are written), boxed-dN the fallback it replaces (a full decode, then + /// ), the same bytes. + /// + public static readonly string[] Contestants = + ["ours", "libheif", "magick", "scaled-d2", "boxed-d2", "scaled-d4", "boxed-d4", "scaled-d8", "boxed-d8"]; /// The parent: one fresh child process per (contestant, fixture), median of . - public static string Run(string[] fixtures, int repeats) + public static string Run(string[] fixtures, int repeats, string? contestantPattern = null) { + var contestants = contestantPattern is null + ? Contestants + : Contestants.Where(c => System.Text.RegularExpressions.Regex.IsMatch(c, contestantPattern)).ToArray(); var lines = new List { $"{"fixture",-42} {"contestant",-10} {"ms",8} {"peak MB",9} {"rise MB",9} {"output MB",10}", }; foreach (var fixture in fixtures) { - foreach (var contestant in Contestants) + foreach (var contestant in contestants) { var samples = new List(); for (var r = 0; r < repeats; r++) samples.Add(SpawnChild(contestant, fixture)); @@ -67,14 +79,13 @@ private static Sample SpawnChild(string contestant, string fixture) public static int Child(string contestant, string fixture) { var data = HeifInputs.Load(fixture); - long outputBytes = contestant == "ours" - ? HeifDecoder.ReadInfo(data).Width * (long)HeifDecoder.ReadInfo(data).Height * 4 - : long.Parse(fixture.Contains("8192x6144") ? "201326592" : fixture.Contains("3072x4096") ? "50331648" : "0"); - if (outputBytes == 0) - { - var info = HeifDecoder.ReadInfo(data); - outputBytes = info.Width * (long)info.Height * 4; - } + var info = HeifDecoder.ReadInfo(data); + // The decoded image's size: full-size RGBA, or the scaled Rgb24 / Rgb48 image. + var at = contestant.IndexOf("-d", StringComparison.Ordinal); + var factor = at < 0 ? 1 : int.Parse(contestant[(at + 2)..]); + long outputBytes = factor == 1 + ? info.Width * (long)info.Height * 4 + : (long)((info.Width + factor - 1) / factor) * ((info.Height + factor - 1) / factor) * 3 * (info.BitDepth > 8 ? 2 : 1); var process = Process.GetCurrentProcess(); GC.Collect(); @@ -117,6 +128,22 @@ public static int Child(string contestant, string fixture) Touch(pixels.ToByteArray(ImageMagick.PixelMapping.RGBA)); break; } + case not null when contestant.StartsWith("scaled-d", StringComparison.Ordinal): + { + if (!HeifImageDecoder.TryDecode(data, out var image, new DecodeOptions { ScaleDenominator = factor })) + throw new InvalidOperationException("The scaled decode failed."); + using (image) Touch(image.Pixels); + break; + } + case not null when contestant.StartsWith("boxed-d", StringComparison.Ordinal): + { + if (!HeifImageDecoder.TryDecode(data, out var full)) + throw new InvalidOperationException("The decode failed."); + using (full) + using (var image = BoxDownscaler.Downscale(full, factor)) + Touch(image.Pixels); + break; + } default: throw new ArgumentException($"Unknown contestant '{contestant}'.", nameof(contestant)); } diff --git a/benchmarks/Sdcb.SimdCodecs.Heif.Benchmarks/Program.cs b/benchmarks/Sdcb.SimdCodecs.Heif.Benchmarks/Program.cs index b671869..f905a3e 100644 --- a/benchmarks/Sdcb.SimdCodecs.Heif.Benchmarks/Program.cs +++ b/benchmarks/Sdcb.SimdCodecs.Heif.Benchmarks/Program.cs @@ -6,16 +6,18 @@ verify every contestant against us, before any timing; exit 1 on a difference equivalence serial and parallel tile decode produce the identical RGBA buffer - quick [--reps N] [--fixtures RE] - ours only, one CPU (HEIF_BENCH_CPU, default 2): HEVC stage and RGBA, for A/B rounds + quick [--reps N] [--fixtures RE] [--scaled] + ours only, one CPU (HEIF_BENCH_CPU, default 2): HEVC stage and RGBA, for A/B rounds; + --scaled adds ScaleDenominator 2 / 4 / 8, native vs full decode + box filter multi [--reps N] [--fixtures RE] multi-core decode speed and allocation, ours vs libheif vs Magick single [--reps N] [--fixtures RE] one logical CPU for the whole process, tile parallelism off on every side ablate [--reps N] [--fixtures RE] single-core time with one decode stage switched off at a time - peakmem [--reps N] [--fixtures RE] - peak working set per decode, one fresh process each (ours, libheif, magick) + peakmem [--reps N] [--fixtures RE] [--contestants RE] + peak working set per decode, one fresh process each (ours, libheif, magick; + scaled-dN native scaled decode, boxed-dN full decode + box filter) [--filter ...] [BDN args] BenchmarkDotNet (HeifDecodeBenchmarks) Every contestant decodes in this process on the same bytes. Native competitors: @@ -48,7 +50,7 @@ string[] Select(bool defaultAll) Console.WriteLine(SingleCore.Run(Select(defaultAll: true), Reps(), verify: true)); return 0; case "quick": - Console.WriteLine(Quick.Run(Select(defaultAll: true), Reps())); + Console.WriteLine(Quick.Run(Select(defaultAll: true), Reps(), args.Contains("--scaled"))); return 0; case "multi": Console.WriteLine(MultiCore.Run(Select(defaultAll: true), Reps())); @@ -57,7 +59,7 @@ string[] Select(bool defaultAll) Console.WriteLine(Ablation.Run(Select(defaultAll: true), Reps())); return 0; case "peakmem": - Console.WriteLine(PeakMemory.Run(Select(defaultAll: true), Reps())); + Console.WriteLine(PeakMemory.Run(Select(defaultAll: true), Reps(), Opt("--contestants"))); return 0; case "peakmem-child": return PeakMemory.Child(args[1], args[2]); diff --git a/benchmarks/Sdcb.SimdCodecs.Heif.Benchmarks/Quick.cs b/benchmarks/Sdcb.SimdCodecs.Heif.Benchmarks/Quick.cs index 5bcbea9..e3e6179 100644 --- a/benchmarks/Sdcb.SimdCodecs.Heif.Benchmarks/Quick.cs +++ b/benchmarks/Sdcb.SimdCodecs.Heif.Benchmarks/Quick.cs @@ -1,4 +1,6 @@ using System.Diagnostics; +using Sdcb.SimdCodecs.Abstractions; +using Sdcb.SimdCodecs.Abstractions.CodecSupport; using Sdcb.SimdCodecs.Heif; namespace Sdcb.SimdCodecs.Heif.Benchmarks; @@ -7,20 +9,34 @@ namespace Sdcb.SimdCodecs.Heif.Benchmarks; /// Our decoder alone, single core: the HEVC stage (per tile, no container, no colour) and the full decode to /// RGBA, median of reps. Meant for A/B rounds between two builds run back to back (see the M3 record): /// no competitors, so a round takes seconds. The CPU is HEIF_BENCH_CPU (default 2; CPU 0 takes most of -/// the system's interrupts on Windows). +/// the system's interrupts on Windows); HEIF_BENCH_CPU=all runs on every core with the default thread pool. +/// +/// --scaled adds the adapter's scaled decode (ScaleDenominator 2, 4, 8, to Rgb24 / Rgb48): native (each +/// tile boxed as it is written) next to the path it replaces in the same run (the full TryDecode, then +/// ), after checking the two give the same bytes. +/// /// internal static class Quick { - public static string Run(string[] fixtures, int reps) + private static readonly int[] Factors = [2, 4, 8]; + + public static string Run(string[] fixtures, int reps, bool scaled = false) { - var cpu = int.TryParse(Environment.GetEnvironmentVariable("HEIF_BENCH_CPU"), out var c) ? c : 2; + var setting = Environment.GetEnvironmentVariable("HEIF_BENCH_CPU"); + var all = setting == "all"; + var cpu = int.TryParse(setting, out var c) ? c : 2; var process = Process.GetCurrentProcess(); - if (OperatingSystem.IsWindows() || OperatingSystem.IsLinux()) process.ProcessorAffinity = (nint)(1L << cpu); + if (!all && (OperatingSystem.IsWindows() || OperatingSystem.IsLinux())) process.ProcessorAffinity = (nint)(1L << cpu); ThreadPool.GetMinThreads(out int minWorker, out int minIo); ThreadPool.GetMaxThreads(out int maxWorker, out int maxIo); - ThreadPool.SetMinThreads(1, minIo); - ThreadPool.SetMaxThreads(1, maxIo); - var lines = new List { $"{"fixture",-42} {"stage ms",9} {"RGBA ms",9} (CPU {cpu})" }; + if (!all) + { + ThreadPool.SetMinThreads(1, minIo); + ThreadPool.SetMaxThreads(1, maxIo); + } + var where = all ? $"{Environment.ProcessorCount} logical CPUs" : $"CPU {cpu}"; + var lines = new List { $"{"fixture",-42} {"stage ms",9} {"RGBA ms",9} ({where})" }; + var scaledLines = new List { "", $"{"fixture",-42} {"d",3} {"native ms",10} {"boxed ms",9} {"native/boxed",13} ({where})" }; try { foreach (var fixture in fixtures) @@ -35,6 +51,22 @@ public static string Run(string[] fixtures, int reps) }); var rgba = Median(reps, () => { using var image = HeifDecoder.Decode(data); return image.Rgba[0]; }); lines.Add($"{fixture,-42} {stage,9:F1} {rgba,9:F1}"); + if (!scaled) continue; + + foreach (var d in Factors) + { + var options = new DecodeOptions { ScaleDenominator = d }; + // Correctness before timing: the native scaled image is the box filter of the full decode. + using (var native = ScaledNative(data, options)) + using (var boxed = ScaledBoxed(data, d)) + { + if (!native.Pixels.SequenceEqual(boxed.Pixels)) + throw new InvalidOperationException($"{fixture} d{d}: the native scaled decode differs from the box filter."); + } + var a = Median(reps, () => { using var image = ScaledNative(data, options); return image.Pixels[0]; }); + var b = Median(reps, () => { using var image = ScaledBoxed(data, d); return image.Pixels[0]; }); + scaledLines.Add($"{fixture,-42} {d,3} {a,10:F1} {b,9:F1} {a / b,13:F3}"); + } } } finally @@ -42,9 +74,20 @@ public static string Run(string[] fixtures, int reps) ThreadPool.SetMinThreads(minWorker, minIo); ThreadPool.SetMaxThreads(maxWorker, maxIo); } + if (scaled) lines.AddRange(scaledLines); return string.Join(Environment.NewLine, lines); } + private static IDecodedImage ScaledNative(byte[] data, DecodeOptions options) => + HeifImageDecoder.TryDecode(data, out var image, options) ? image : throw new InvalidOperationException("The scaled decode failed."); + + private static RasterImage ScaledBoxed(byte[] data, int factor) + { + if (!HeifImageDecoder.TryDecode(data, out var full)) throw new InvalidOperationException("The decode failed."); + using (full) + return BoxDownscaler.Downscale(full, factor); + } + private static double Median(int reps, Func action) { action(); diff --git a/benchmarks/Sdcb.SimdCodecs.Heif.Benchmarks/README.md b/benchmarks/Sdcb.SimdCodecs.Heif.Benchmarks/README.md index 1e21846..b7482f6 100644 --- a/benchmarks/Sdcb.SimdCodecs.Heif.Benchmarks/README.md +++ b/benchmarks/Sdcb.SimdCodecs.Heif.Benchmarks/README.md @@ -35,4 +35,6 @@ Or point `HEIF_INPUTS` at any directory that contains the three files. dotnet run -c Release --project benchmarks/Sdcb.SimdCodecs.Heif.Benchmarks -- --filter "*" ``` +Scaled decode (`ScaleDenominator` 2, 4, 8): `quick --scaled` times the native scaled decode next to the full decode followed by `BoxDownscaler.Downscale` (after checking they give the same bytes), and `peakmem --contestants "^(ours|scaled|boxed)"` measures `scaled-dN` (native) and `boxed-dN` (full decode, then the box filter). `HEIF_BENCH_CPU=all` runs `quick` on every core. See [`results/2026-11-08-amd-ryzen-7-5800x-scaled.md`](results/2026-11-08-amd-ryzen-7-5800x-scaled.md). + Record the machine with every result (CPU, OS, .NET version). Never compare numbers across machines or against a result from an earlier session. diff --git a/benchmarks/Sdcb.SimdCodecs.Heif.Benchmarks/results/2026-11-08-amd-ryzen-7-5800x-scaled.md b/benchmarks/Sdcb.SimdCodecs.Heif.Benchmarks/results/2026-11-08-amd-ryzen-7-5800x-scaled.md new file mode 100644 index 0000000..8e7dcc8 --- /dev/null +++ b/benchmarks/Sdcb.SimdCodecs.Heif.Benchmarks/results/2026-11-08-amd-ryzen-7-5800x-scaled.md @@ -0,0 +1,71 @@ +# HEIF scaled decode — 2026-11-08 — AMD Ryzen 7 5800X + +**Purpose:** compare the native scaled decode (`DecodeOptions.ScaleDenominator` 2 / 4 / 8: each tile boxes the pixels it converts, written straight into the scaled image) with the path it replaces (decode the full Rgb24 / Rgb48 image, then `BoxDownscaler.Downscale`). The two give the same bytes; `quick --scaled` checks that before it times anything. + +## Machine + +- CPU: AMD Ryzen 7 5800X (8 cores / 16 logical) +- OS: Windows 11 Pro for Workstations 10.0.26200 +- .NET: SDK 10.0.400, runtime 10.0.11 (win-x64) +- Vector128 / Vector256 hardware-accelerated, Vector512 not +- Other work was running on the machine at the same time, so single runs are noisy; the time table gives medians over three alternating rounds. + +## How + +A is `origin/scaled-decode` (7874b76) with this branch's harness files (`Quick.cs`, `PeakMemory.cs`, `Program.cs`) copied in; they use only public API, so A's "native" column is the old adapter path. B is this branch. The two builds ran alternately, A then B, in the same session: + +```bash +HEIF_BENCH_CPU=all dotnet /…/Sdcb.SimdCodecs.Heif.Benchmarks.dll quick --scaled --reps 9 # 3 rounds + dotnet /…/Sdcb.SimdCodecs.Heif.Benchmarks.dll peakmem --reps 3 --contestants "^(ours|scaled)" +``` + +`HEIF_BENCH_CPU=all` uses every core with the default thread pool; the adapter decodes the tiles in parallel. Every fixture takes the native path: the grids have 512-pixel tiles and a size that is a multiple of 8, and the single items have no seams. + +## Time (all cores, ms, median of 3 rounds × 9 reps) + +| fixture | d | A (current) | B (native) | B / A | +|---|---:|---:|---:|---:| +| 3072×4096 Main grid | 2 | 111.3 | 76.1 | 0.68 | +| | 4 | 106.0 | 69.5 | 0.66 | +| | 8 | 85.7 | 98.8 | 1.15 | +| 3072×4096 Main 10 grid | 2 | 141.8 | 89.9 | 0.63 | +| | 4 | 113.5 | 94.3 | 0.83 | +| | 8 | 104.1 | 87.6 | 0.84 | +| 8192×6144 Main grid | 2 | 249.3 | 187.0 | 0.75 | +| | 4 | 242.5 | 225.0 | 0.93 | +| | 8 | 220.8 | 182.5 | 0.83 | +| fuji 1600×900 (1 item) | 2 | 13.7 | 12.2 | 0.89 | +| | 4 | 13.3 | 11.9 | 0.90 | +| | 8 | 12.3 | 11.8 | 0.96 | +| mangrove 1599×1199 (1 item, clap) | 2 | 15.7 | 13.5 | 0.86 | +| | 4 | 14.5 | 13.3 | 0.92 | +| | 8 | 14.2 | 13.3 | 0.94 | +| testsrc2 1280×720 (1 item) | 2 | 7.0 | 6.2 | 0.89 | +| | 4 | 6.8 | 6.4 | 0.94 | +| | 8 | 6.6 | 6.3 | 0.96 | + +The HEVC decode is most of the time and is unchanged; the saving is the full-size image (allocating, writing and reading it again). The 3072×4096 d8 row is the one where B was slower; its three rounds were 82–112 ms against A's 84–94 ms, within the noise of the loaded machine. On one CPU (`quick --scaled`, 2 rounds, not tabulated) the two paths were within ±10% of each other on every fixture, as expected when the decode dominates. + +## Peak working set (one fresh process per decode, median of 3, MB) + +`rise` is the peak over the working set before the decode; `output` is the decoded image (full-size RGBA for `ours`, the scaled Rgb24 / Rgb48 image for `scaled-dN`). + +| fixture | contestant | A rise | B rise | output | +|---|---|---:|---:|---:| +| 3072×4096 Main grid | full RGBA | 74 | 74 | 48 | +| | d2 | 72 | 38 | 9 | +| | d4 | 64 | 32 | 2 | +| | d8 | 61 | 30 | 1 | +| 3072×4096 Main 10 grid | full RGBA | 78 | 78 | 48 | +| | d2 | 115 | 52 | 18 | +| | d4 | 103 | 37 | 4 | +| | d8 | 100 | 32 | 1 | +| 8192×6144 Main grid | full RGBA | 229 | 229 | 192 | +| | d2 | 218 | 82 | 36 | +| | d4 | 192 | 55 | 9 | +| | d8 | 183 | 47 | 2 | +| fuji 1600×900 | d2 / d4 / d8 | 22 / 21 / 20 | 17 / 16 / 16 | 1 / 0 / 0 | +| mangrove 1599×1199 | d2 / d4 / d8 | 24 / 23 / 24 | 19 / 18 / 19 | 1 / 0 / 0 | +| testsrc2 1280×720 | d2 / d4 / d8 | 16 / 15 / 15 | 13 / 13 / 13 | 1 / 0 / 0 | + +A scaled grid decode now rises by its output plus the working set of the tiles in flight (about 30 MB here: JIT, sixteen thread-pool threads, one tile's planes each), not by the full-size image: a 50 MP photo at d8 needs 47 MB instead of 183 MB. diff --git a/benchmarks/Sdcb.SimdCodecs.Png.Benchmarks/Baseline.cs b/benchmarks/Sdcb.SimdCodecs.Png.Benchmarks/Baseline.cs index 0843f18..a34cc66 100644 --- a/benchmarks/Sdcb.SimdCodecs.Png.Benchmarks/Baseline.cs +++ b/benchmarks/Sdcb.SimdCodecs.Png.Benchmarks/Baseline.cs @@ -67,8 +67,44 @@ private sealed class Bound public IntPtr Decode; public readonly Dictionary Encoders = []; public Type? OptionsType; + public IntPtr TryDecode; + public Type? DecodeOptionsType; + public Type? PixelRectType; } + /// + /// A DecodeOptions of the baseline's own Abstractions, built by reflection from and + /// , for ; null when the baseline has no options overload. + /// + public static object? CreateDecodeOptions(int scale, (int X, int Y, int Width, int Height)? region) + { + Bound b = Loaded.Value; + if (b.DecodeOptionsType is null) + return null; + object options = Activator.CreateInstance(b.DecodeOptionsType)!; + b.DecodeOptionsType.GetProperty("ScaleDenominator")!.SetValue(options, scale); + if (region is { } r) + b.DecodeOptionsType.GetProperty("Region")!.SetValue(options, Activator.CreateInstance(b.PixelRectType!, r.X, r.Y, r.Width, r.Height)); + return options; + } + + public static bool CanDecodeWithOptions => Loaded.Value.TryDecode != IntPtr.Zero && Loaded.Value.DecodeOptionsType is not null; + + /// The baseline's PngImageDecoder.TryDecode; is its own IDecodedImage. + public static bool TryDecode(ReadOnlySpan png, out object? image, object? options) => + ((delegate*, out object?, object?, CancellationToken, bool>)Loaded.Value.TryDecode)( + png, out image, options, CancellationToken.None); + + /// The samples of a baseline image (its RasterImage.Pixels), copied out. + public static byte[] PixelsOf(object image) + { + // A delegate bound to the instance getter: ReadOnlySpan is the BCL's type in both load contexts. + var getter = (SpanGetter)Delegate.CreateDelegate(typeof(SpanGetter), image, image.GetType().GetProperty("Pixels")!.GetMethod!); + return getter().ToArray(); + } + + private delegate ReadOnlySpan SpanGetter(); + /// The baseline's PngImageDecoder.TryDecodeIntoRgba8. public static bool TryDecodeIntoRgba8(ReadOnlySpan png, Span rgba) => ((delegate*, Span, bool>)Loaded.Value.TryDecodeIntoRgba8)(png, rgba); @@ -112,6 +148,14 @@ private static Bound Load() .MethodHandle.GetFunctionPointer(); Type reader = asm.GetType("Sdcb.SimdCodecs.Png.PngReader", throwOnError: true)!; bound.Decode = reader.GetMethod("Decode", BindingFlags.Public | BindingFlags.Static)!.MethodHandle.GetFunctionPointer(); + // TryDecode(ReadOnlySpan, out IDecodedImage, DecodeOptions?, CancellationToken): the options overload. + if (decoder.GetMethods(BindingFlags.Public | BindingFlags.Static).SingleOrDefault(m => + m.Name == "TryDecode" && m.GetParameters() is { Length: 4 } p && p[2].ParameterType.Name == "DecodeOptions") is { } tryDecode) + { + bound.TryDecode = tryDecode.MethodHandle.GetFunctionPointer(); + bound.DecodeOptionsType = tryDecode.GetParameters()[2].ParameterType; + bound.PixelRectType = Nullable.GetUnderlyingType(bound.DecodeOptionsType.GetProperty("Region")!.PropertyType); + } bound.OptionsType = asm.GetType("Sdcb.SimdCodecs.Png.PngWriteOptions"); Type writer = asm.GetType("Sdcb.SimdCodecs.Png.PngWriter", throwOnError: true)!; diff --git a/benchmarks/Sdcb.SimdCodecs.Png.Benchmarks/PeakMemory.cs b/benchmarks/Sdcb.SimdCodecs.Png.Benchmarks/PeakMemory.cs index f7a518b..ef85938 100644 --- a/benchmarks/Sdcb.SimdCodecs.Png.Benchmarks/PeakMemory.cs +++ b/benchmarks/Sdcb.SimdCodecs.Png.Benchmarks/PeakMemory.cs @@ -117,13 +117,13 @@ private static Contestant Single(bool decode, string caseName, string contestant return list.Count == 1 ? list[0] : throw new InvalidOperationException($"{contestant} cannot run {caseName}"); } - private static void TouchManagedHeap() + internal static void TouchManagedHeap() { GC.Collect(2, GCCollectionMode.Forced, blocking: true, compacting: true); GC.WaitForPendingFinalizers(); } - private static long Measure(Action operation) + internal static long Measure(Action operation) { long baseline = CurrentWorkingSet(); // The OS peak counter covers the process lifetime, so a transient spike before the baseline diff --git a/benchmarks/Sdcb.SimdCodecs.Png.Benchmarks/Program.cs b/benchmarks/Sdcb.SimdCodecs.Png.Benchmarks/Program.cs index 11b4959..03073b2 100644 --- a/benchmarks/Sdcb.SimdCodecs.Png.Benchmarks/Program.cs +++ b/benchmarks/Sdcb.SimdCodecs.Png.Benchmarks/Program.cs @@ -24,6 +24,9 @@ writes .cache/work/matrix.md formats decode into BGRA / RGB / BGR / grey: the native path vs decode-to-RGBA + convert, and libpng in the same layout (default cases: the common colour types; writes .cache/work/formats.md) peakmem decode|encode peak working set per operation, one fresh process per run (native memory included) + scaled TryDecode with a region / scale: native window decode vs the baseline's, and the full + decode (default cases: the common colour types; writes .cache/work/scaled.md) + scaled-peakmem the same, peak working set, one fresh process per run (writes .cache/work/scaled-peakmem.md) inputs [--force] (re)generate the decode inputs into .cache/inputs (done on demand otherwise) baseline [git-ref] build git-ref's (default HEAD) Sdcb.SimdCodecs.Png as the A/B baseline @@ -111,6 +114,24 @@ writes .cache/work/matrix.md Console.Write(MachineInfo.Describe()); return Write("formats.md", MachineInfo.Describe() + Environment.NewLine + Formats.Run(rounds)); } + case "scaled" or "scaled-peakmem": + { + if (Opt("--cases") is null) + { + Environment.SetEnvironmentVariable(Settings.CasesVariable, Scaled.DefaultCases); + } + + Console.Write(MachineInfo.Describe()); + if (command == "scaled-peakmem") + { + return Write("scaled-peakmem.md", MachineInfo.Describe() + Environment.NewLine + Scaled.RunPeakMemory()); + } + + int rounds = int.TryParse(Opt("--rounds"), out int r) ? r : 7; + return Write("scaled.md", MachineInfo.Describe() + Environment.NewLine + Scaled.Run(rounds)); + } + case "scaled-child": + return Scaled.Child(args[1], int.Parse(args[2], System.Globalization.CultureInfo.InvariantCulture), args[3]); case "peakmem" when args.Length > 1 && args[1] is "decode" or "encode": { string report = MachineInfo.Describe() + Environment.NewLine + PeakMemory.Run(args[1] == "decode"); diff --git a/benchmarks/Sdcb.SimdCodecs.Png.Benchmarks/README.md b/benchmarks/Sdcb.SimdCodecs.Png.Benchmarks/README.md index c6e9a71..644dbe7 100644 --- a/benchmarks/Sdcb.SimdCodecs.Png.Benchmarks/README.md +++ b/benchmarks/Sdcb.SimdCodecs.Png.Benchmarks/README.md @@ -81,6 +81,15 @@ dotnet run -c Release --project benchmarks/Sdcb.SimdCodecs.Png.Benchmarks -- for times `PngImageDecoder.TryDecodeInto` in BGRA, RGB, BGR and grey against the two-step path an RGBA-only API leaves a caller (decode to RGBA, then `PixelConverter` over the frame), with RGBA alone and libpng's simplified API in the same layout for scale. The two of ours must give identical bytes before anything is timed. Default cases: grey, RGB, palette, RGBA and 16-bit RGB, plain and Adam7. Report: `.cache/work/formats.md`. +### Region and scale + +```bash +dotnet run -c Release --project benchmarks/Sdcb.SimdCodecs.Png.Benchmarks -- scaled [--cases RE] [--rounds N] +dotnet run -c Release --project benchmarks/Sdcb.SimdCodecs.Png.Benchmarks -- scaled-peakmem [--cases RE] +``` + +times `PngImageDecoder.TryDecode` with `DecodeOptions` (512² regions at the top, centre and bottom, 1/2, 1/4 and 1/8, and a region of 1/4): the current build against the `baseline` build's, with the full decode for scale. Both must give identical pixels before anything is timed. Built from a commit before native region decoding, the baseline is the generic path: full decode, then `BoxDownscaler` and a crop. `scaled-peakmem` measures the working-set rise of each in a fresh process (median of three, after a warm-up on a 64×64 image of the same case and mode). Default cases: grey, RGB, palette, RGBA and 16-bit RGB, non-interlaced (Adam7 keeps the generic path). Reports: `.cache/work/scaled.md` / `scaled-peakmem.md`. Recorded: [`results/scaled-decode-2026-11-08-amd-ryzen-7-5800x.md`](results/scaled-decode-2026-11-08-amd-ryzen-7-5800x.md). + ### Same-process A/B ```bash diff --git a/benchmarks/Sdcb.SimdCodecs.Png.Benchmarks/Scaled.cs b/benchmarks/Sdcb.SimdCodecs.Png.Benchmarks/Scaled.cs new file mode 100644 index 0000000..89581e3 --- /dev/null +++ b/benchmarks/Sdcb.SimdCodecs.Png.Benchmarks/Scaled.cs @@ -0,0 +1,213 @@ +using System.Buffers.Binary; +using System.Diagnostics; +using System.Globalization; +using System.Text; +using Sdcb.SimdCodecs.Abstractions; + +namespace Sdcb.SimdCodecs.Png.Benchmarks; + +/// +/// and : for each case and each mode, the +/// current with the options (native: only the window's rows, box-filtered as they +/// are decoded, stopping after the last row needed) against the baseline's (baseline [git-ref]; on +/// scaled-decode that is the generic path: decode the whole image, then box-filter and crop), with the full decode +/// for scale. The two must give identical pixels, checked before timing. scaled-peakmem measures the working set +/// of each, one fresh process per run. +/// +internal static class Scaled +{ + /// Without --cases: the common colour types, non-interlaced (Adam7 keeps the generic path). + public const string DefaultCases = "^(photo|screen)-(g8|rgb8|p8|rgba8|rgb16)$"; + + public const string Full = "full decode"; + public const string Native = "native"; + public const string Fallback = "baseline"; + + /// A scale and a region of the scaled image, the region clipped to fit a small image (the warm-up's). + public sealed record Mode(string Name, int Scale, Func Region); + + public static readonly Mode[] Modes = + [ + new("region 512² top-left", 1, (w, h) => Clip(0, 0, 512, 512, w, h)), + new("region 512² centre", 1, (w, h) => Clip(w / 2 - 256, h / 2 - 256, 512, 512, w, h)), + new("region 512² bottom-right", 1, (w, h) => Clip(w - 512, h - 512, 512, 512, w, h)), + new("scale 1/2", 2, (_, _) => null), + new("scale 1/4", 4, (_, _) => null), + new("scale 1/8", 8, (_, _) => null), + new("1/4 + region 128² centre", 4, (w, h) => Clip(w / 2 - 64, h / 2 - 64, 128, 128, w, h)), + ]; + + private static PixelRect Clip(int x, int y, int width, int height, int w, int h) + { + width = Math.Min(width, w); + height = Math.Min(height, h); + return new PixelRect(Math.Clamp(x, 0, w - width), Math.Clamp(y, 0, h - height), width, height); + } + + private static (int Width, int Height) SizeOf(byte[] png) => + (BinaryPrimitives.ReadInt32BigEndian(png.AsSpan(16)), BinaryPrimitives.ReadInt32BigEndian(png.AsSpan(20))); + + private static DecodeOptions OptionsFor(Mode mode, byte[] png, out PixelRect? region) + { + var (w, h) = SizeOf(png); + region = mode.Region((w + mode.Scale - 1) / mode.Scale, (h + mode.Scale - 1) / mode.Scale); + return new DecodeOptions { ScaleDenominator = mode.Scale, Region = region }; + } + + /// The contestants of one case and mode, each checked against the full decode's box + crop first. + public static List Contestants(byte[] png, Mode mode, string? only = null) + { + DecodeOptions options = OptionsFor(mode, png, out PixelRect? region); + var list = new List(); + bool Want(string name) => only is null || only == name; + + if (Want(Full)) + list.Add(new(Full, () => { Check(PngImageDecoder.TryDecode(png, out var image)); image!.Dispose(); })); + + byte[]? reference = null; + if (Want(Native)) + { + Check(PngImageDecoder.TryDecode(png, out var image, options)); + reference = image!.Pixels.ToArray(); + image.Dispose(); + list.Add(new(Native, () => { Check(PngImageDecoder.TryDecode(png, out var i, options)); i!.Dispose(); })); + } + + if (Want(Fallback) && BaselinePng.IsBuilt && BaselinePng.CanDecodeWithOptions) + { + object baselineOptions = BaselinePng.CreateDecodeOptions(mode.Scale, + region is { } r ? (r.X, r.Y, r.Width, r.Height) : null)!; + Check(BaselinePng.TryDecode(png, out object? image, baselineOptions)); + byte[] pixels = BaselinePng.PixelsOf(image!); + ((IDisposable)image!).Dispose(); + if (reference is not null && !pixels.AsSpan().SequenceEqual(reference)) + throw new InvalidOperationException($"{mode.Name}: native and baseline pixels differ"); + list.Add(new(Fallback, () => + { + Check(BaselinePng.TryDecode(png, out object? i, baselineOptions)); + ((IDisposable)i!).Dispose(); + })); + } + + return list; + } + + public static string Run(int rounds) + { + var sb = new StringBuilder(); + sb.AppendLine($"### Region and scale decode (`TryDecode` with `DecodeOptions`; in-process medians of {rounds} interleaved rounds, ms)"); + sb.AppendLine(); + sb.AppendLine($"`{Native}` = the current `TryDecode(options)`; `{Fallback}` = the baseline's (`{BaselinePng.Description ?? "none built"}`), " + + $"which decodes the whole image, then box-filters and crops; `{Full}` = `TryDecode` without options, for scale. " + + "Both give identical pixels (checked first). Allocated = managed bytes of one warm operation."); + sb.AppendLine(); + sb.AppendLine($"| Case | Mode | {Full} | {Fallback} | {Native} | {Native} ÷ {Fallback} | {Native} ÷ {Full} | alloc {Fallback} | alloc {Native} |"); + sb.AppendLine("|---|---|---:|---:|---:|---:|---:|---:|---:|"); + foreach (DecodeCase c in DecodeCase.Selected) + { + byte[] png = c.Read(); + foreach (Mode mode in Modes) + { + List contestants = Contestants(png, mode); + var allocated = new Dictionary(); + Dictionary ms = Matrix.Time(contestants, rounds, allocated); + Console.WriteLine($"[scaled] {c.Name,-14} {mode.Name,-26} " + string.Join(" ", ms.Select(kv => $"{kv.Key} {kv.Value:F3}"))); + string Ms(string n) => ms.TryGetValue(n, out double v) ? F(v) : "—"; + string Ratio(string a, string b) => ms.TryGetValue(a, out double x) && ms.TryGetValue(b, out double y) + ? (x / y).ToString("F2", CultureInfo.InvariantCulture) : "—"; + string Kb(string n) => allocated.TryGetValue(n, out long v) ? $"{v / 1024.0:F0} KB" : "—"; + sb.AppendLine($"| `{c.Name}` | {mode.Name} | {Ms(Full)} | {Ms(Fallback)} | {Ms(Native)} | {Ratio(Native, Fallback)} | " + + $"{Ratio(Native, Full)} | {Kb(Fallback)} | {Kb(Native)} |"); + } + } + + return sb.ToString(); + } + + // ---- Peak working set ------------------------------------------------------------------------------------------ + + public static string RunPeakMemory(int repeats = 3) + { + string self = typeof(Scaled).Assembly.Location; + var size = Settings.Size; + var sb = new StringBuilder(); + sb.AppendLine($"### Peak working set per region / scale decode, MiB (one fresh process per run, median of {repeats})"); + sb.AppendLine(); + sb.AppendLine("\"first\" is the working-set rise during the first full-size operation, after a warm-up on a 64×64 image of " + + "the same case and mode (JIT excluded). It includes the decoded output, which for the region modes is " + + $"{512 * 512 * 4 / 1048576.0:F2} MiB at most (512² RGBA); the {size.Width}×{size.Height} full decode's samples are " + + $"{(long)size.Width * size.Height * 4 / 1048576.0:F2} MiB for RGBA8."); + sb.AppendLine(); + string[] names = [Full, Fallback, Native]; + sb.AppendLine("| Case | Mode | " + string.Join(" | ", names.Select(n => n + " first")) + " |"); + sb.AppendLine("|---|---|" + string.Concat(names.Select(_ => "---:|"))); + foreach (DecodeCase c in DecodeCase.Selected) + { + for (int m = 0; m < Modes.Length; m++) + { + var cells = new List(); + foreach (string name in names) + { + if (name == Full && m > 0) + { + cells.Add("″"); + continue; + } + + var runs = new List(); + for (int r = 0; r < repeats; r++) + { + var psi = new ProcessStartInfo(Environment.ProcessPath!) { RedirectStandardOutput = true, UseShellExecute = false }; + if (!Path.GetFileName(Environment.ProcessPath!).StartsWith("Sdcb.", StringComparison.Ordinal)) + psi.ArgumentList.Add(self); + foreach (string arg in new[] { "scaled-child", c.Name, m.ToString(CultureInfo.InvariantCulture), name }) + psi.ArgumentList.Add(arg); + psi.Environment[BenchPaths.RootVariable] = BenchPaths.Root; + using Process p = Process.Start(psi)!; + string line = p.StandardOutput.ReadToEnd().Trim().Split('\n').Last(); + p.WaitForExit(); + if (p.ExitCode != 0) + throw new InvalidOperationException($"scaled child failed for {name} {c.Name} {Modes[m].Name}: {line}"); + runs.Add(long.Parse(line, CultureInfo.InvariantCulture)); + } + + long median = runs.Order().ElementAt(runs.Count / 2); + cells.Add(Mib(median)); + Console.WriteLine($"[scaled-peakmem] {c.Name,-14} {Modes[m].Name,-26} {name,-12} {Mib(median),8} MiB"); + } + + sb.AppendLine($"| `{c.Name}` | {Modes[m].Name} | " + string.Join(" | ", cells) + " |"); + } + } + + return sb.ToString(); + } + + /// Child-process body: prints the working-set rise of one operation in bytes. + public static int Child(string caseName, int mode, string contestant) + { + DecodeCase c = DecodeCase.Find(caseName); + string? size = Environment.GetEnvironmentVariable(Settings.SizeVariable); + Environment.SetEnvironmentVariable(Settings.SizeVariable, "64x64"); + Single(c.Read(), Modes[mode], contestant).Run(); + Environment.SetEnvironmentVariable(Settings.SizeVariable, size); + + Contestant op = Single(c.Read(), Modes[mode], contestant); + PeakMemory.TouchManagedHeap(); + Console.WriteLine(PeakMemory.Measure(op.Run).ToString(CultureInfo.InvariantCulture)); + return 0; + } + + private static Contestant Single(byte[] png, Mode mode, string contestant) => + Contestants(png, mode, contestant) is [var only] ? only : throw new InvalidOperationException($"{contestant} cannot run {mode.Name}"); + + private static void Check(bool ok) + { + if (!ok) + throw new InvalidOperationException("decode failed"); + } + + private static string F(double ms) => ms.ToString(ms < 10 ? "F3" : "F2", CultureInfo.InvariantCulture); + + private static string Mib(long bytes) => (bytes / 1048576.0).ToString("F2", CultureInfo.InvariantCulture); +} diff --git a/benchmarks/Sdcb.SimdCodecs.Png.Benchmarks/results/scaled-decode-2026-11-08-amd-ryzen-7-5800x.md b/benchmarks/Sdcb.SimdCodecs.Png.Benchmarks/results/scaled-decode-2026-11-08-amd-ryzen-7-5800x.md new file mode 100644 index 0000000..de77f9d --- /dev/null +++ b/benchmarks/Sdcb.SimdCodecs.Png.Benchmarks/results/scaled-decode-2026-11-08-amd-ryzen-7-5800x.md @@ -0,0 +1,198 @@ +# Region and scale decode (non-interlaced): native window decode vs the generic path + +Branch `scaled-png` against a baseline built from `origin/scaled-decode` (88a489d) with `baseline origin/scaled-decode`, loaded in the same process. On the baseline every region or scale decode takes the generic path: the whole image is decoded, then `BoxDownscaler` and a crop. The native path decodes only the rows down to the last one the output needs, unfilters only as far right as its last column, and box-filters rows as they arrive. Both give identical pixels: the benchmark checks before timing, and the test suite checks every colour type, depth, tRNS, scale, region and pixel format. + +Other jobs ran on the machine at the same time, so absolute numbers move by 10-30% between runs. Ratios within one run are what count. + +Commands: `dotnet run -c Release --project benchmarks/Sdcb.SimdCodecs.Png.Benchmarks -- scaled`, `scaled --rounds 21 --cases '^screen-(g8|rgba8)$'`, `scaled-peakmem --cases '^(photo-rgba8|photo-rgb16|screen-p8)$'`, `matrix decode --rounds 11 --cases '^(photo|screen)-(g4|g8|rgb8|p8|rgba8|rgb16)$'`. .NET SDK 10.0.400. + +How to read the numbers: + +- A region that ends high in the image costs a fraction of the full decode (512² top-left: 0.11-0.49× of the generic path). +- A region at the bottom still inflates every row above it, so it costs about as much as the full decode. Inflating is most of the time; see the README's lessons. +- Scale-only output costs about what the generic path costs (0.75-1.1×). Box-filtering every source row is the same work either way, and on the screen images it costs more than the decode itself. For 16-bit (`rgb16`) the native path is 0.5-0.9×, because it skips the generic path's full-size 16-bit copies. +- The gain for scale-only output is memory. The generic path holds the full-size image; the native one holds the output and a few rows (see the peak working set below). + +- CPU: AMD Ryzen 7 5800X 8-Core Processor +- OS: Microsoft Windows 10.0.26200 (X64) +- .NET: .NET 10.0.11 (win-x64), process X64 +- Logical processors: 16 +- Vector128.IsHardwareAccelerated: True, Vector256.IsHardwareAccelerated: True, Vector512.IsHardwareAccelerated: False +- AVX2: True, AVX-512F: False +- Image size: 1920×1080 +- `photo` source: synthetic (no JPEG corpus master found; run the JPEG benchmark's scripts/fetch-corpus.sh for a real photo) +- `screen` source: synthetic +- ImageSharp: 3.1.12 +- libpng: not found (tried libpng16.dll, libpng.dll; set LIBPNG_PATH) +- Baseline: 88a489d47a39 Merge scaled-decode-l1: every codec accepts any power-of-two ScaleDenominator (native, then a frozen box downscale) (#6) + +### Region and scale decode (`TryDecode` with `DecodeOptions`; in-process medians of 7 interleaved rounds, ms) + +`native` = the current `TryDecode(options)`; `baseline` = the baseline's (`88a489d47a39 Merge scaled-decode-l1: every codec accepts any power-of-two ScaleDenominator (native, then a frozen box downscale) (#6)`), which decodes the whole image, then box-filters and crops; `full decode` = `TryDecode` without options, for scale. Both give identical pixels (checked first). Allocated = managed bytes of one warm operation. + +| Case | Mode | full decode | baseline | native | native ÷ baseline | native ÷ full decode | alloc baseline | alloc native | +|---|---|---:|---:|---:|---:|---:|---:|---:| +| `photo-g8` | region 512² top-left | 14.13 | 14.35 | 5.019 | 0.35 | 0.36 | 2284 KB | 257 KB | +| `photo-g8` | region 512² centre | 18.38 | 18.42 | 12.41 | 0.67 | 0.67 | 2284 KB | 257 KB | +| `photo-g8` | region 512² bottom-right | 18.33 | 18.44 | 18.30 | 0.99 | 1.00 | 2284 KB | 257 KB | +| `photo-g8` | scale 1/2 | 18.38 | 23.94 | 23.94 | 1.00 | 1.30 | 2542 KB | 515 KB | +| `photo-g8` | scale 1/4 | 17.18 | 20.29 | 20.24 | 1.00 | 1.18 | 2163 KB | 135 KB | +| `photo-g8` | scale 1/8 | 18.36 | 19.14 | 19.13 | 1.00 | 1.04 | 2068 KB | 41 KB | +| `photo-g8` | 1/4 + region 128² centre | 18.54 | 18.88 | 12.59 | 0.67 | 0.68 | 2047 KB | 19 KB | +| `photo-rgb8` | region 512² top-left | 34.50 | 34.27 | 16.05 | 0.47 | 0.47 | 6851 KB | 770 KB | +| `photo-rgb8` | region 512² centre | 34.53 | 34.96 | 26.04 | 0.74 | 0.75 | 6851 KB | 770 KB | +| `photo-rgb8` | region 512² bottom-right | 33.38 | 33.04 | 32.42 | 0.98 | 0.97 | 6851 KB | 770 KB | +| `photo-rgb8` | scale 1/2 | 32.58 | 42.55 | 42.00 | 0.99 | 1.29 | 7624 KB | 1543 KB | +| `photo-rgb8` | scale 1/4 | 32.04 | 35.04 | 35.16 | 1.00 | 1.10 | 6485 KB | 404 KB | +| `photo-rgb8` | scale 1/8 | 34.49 | 36.38 | 36.31 | 1.00 | 1.05 | 6200 KB | 119 KB | +| `photo-rgb8` | 1/4 + region 128² centre | 33.71 | 34.15 | 26.20 | 0.77 | 0.78 | 6137 KB | 56 KB | +| `photo-rgb16` | region 512² top-left | 60.33 | 61.24 | 24.56 | 0.40 | 0.41 | 38002 KB | 1541 KB | +| `photo-rgb16` | region 512² centre | 62.18 | 61.54 | 37.59 | 0.61 | 0.60 | 38002 KB | 1541 KB | +| `photo-rgb16` | region 512² bottom-right | 62.52 | 69.66 | 51.32 | 0.74 | 0.82 | 38002 KB | 1541 KB | +| `photo-rgb16` | scale 1/2 | 61.45 | 71.26 | 61.03 | 0.86 | 0.99 | 39527 KB | 3065 KB | +| `photo-rgb16` | scale 1/4 | 54.32 | 57.49 | 50.66 | 0.88 | 0.93 | 37237 KB | 776 KB | +| `photo-rgb16` | scale 1/8 | 58.84 | 65.03 | 56.82 | 0.87 | 0.97 | 36662 KB | 200 KB | +| `photo-rgb16` | 1/4 + region 128² centre | 61.08 | 60.45 | 37.42 | 0.62 | 0.61 | 36566 KB | 104 KB | +| `photo-p8` | region 512² top-left | 10.18 | 10.78 | 4.641 | 0.43 | 0.46 | 11154 KB | 1027 KB | +| `photo-p8` | region 512² centre | 13.45 | 13.51 | 9.188 | 0.68 | 0.68 | 11154 KB | 1027 KB | +| `photo-p8` | region 512² bottom-right | 13.15 | 14.04 | 12.01 | 0.86 | 0.91 | 11154 KB | 1027 KB | +| `photo-p8` | scale 1/2 | 10.29 | 17.39 | 16.13 | 0.93 | 1.57 | 12185 KB | 2058 KB | +| `photo-p8` | scale 1/4 | 13.35 | 18.17 | 17.58 | 0.97 | 1.32 | 10667 KB | 540 KB | +| `photo-p8` | scale 1/8 | 13.21 | 16.28 | 15.86 | 0.97 | 1.20 | 10287 KB | 160 KB | +| `photo-p8` | 1/4 + region 128² centre | 12.17 | 13.99 | 9.607 | 0.69 | 0.79 | 10202 KB | 75 KB | +| `photo-rgba8` | region 512² top-left | 32.70 | 33.10 | 16.08 | 0.49 | 0.49 | 9134 KB | 1026 KB | +| `photo-rgba8` | region 512² centre | 36.71 | 35.70 | 26.06 | 0.73 | 0.71 | 9134 KB | 1026 KB | +| `photo-rgba8` | region 512² bottom-right | 37.58 | 36.14 | 36.14 | 1.00 | 0.96 | 9134 KB | 1026 KB | +| `photo-rgba8` | scale 1/2 | 37.23 | 45.64 | 47.29 | 1.04 | 1.27 | 10165 KB | 2057 KB | +| `photo-rgba8` | scale 1/4 | 33.77 | 40.95 | 38.35 | 0.94 | 1.14 | 8646 KB | 538 KB | +| `photo-rgba8` | scale 1/8 | 39.17 | 40.62 | 41.21 | 1.01 | 1.05 | 8266 KB | 159 KB | +| `photo-rgba8` | 1/4 + region 128² centre | 38.65 | 39.33 | 29.67 | 0.75 | 0.77 | 8182 KB | 74 KB | +| `screen-g8` | region 512² top-left | 3.490 | 2.384 | 0.718 | 0.30 | 0.21 | 2284 KB | 257 KB | +| `screen-g8` | region 512² centre | 3.142 | 3.715 | 1.535 | 0.41 | 0.49 | 2284 KB | 257 KB | +| `screen-g8` | region 512² bottom-right | 3.938 | 2.371 | 2.694 | 1.14 | 0.68 | 2284 KB | 257 KB | +| `screen-g8` | scale 1/2 | 3.840 | 9.506 | 9.465 | 1.00 | 2.46 | 2542 KB | 515 KB | +| `screen-g8` | scale 1/4 | 3.935 | 4.334 | 4.711 | 1.09 | 1.20 | 2162 KB | 135 KB | +| `screen-g8` | scale 1/8 | 3.053 | 3.149 | 4.686 | 1.49 | 1.53 | 2067 KB | 40 KB | +| `screen-g8` | 1/4 + region 128² centre | 4.005 | 4.228 | 2.791 | 0.66 | 0.70 | 2046 KB | 19 KB | +| `screen-rgb8` | region 512² top-left | 3.445 | 3.795 | 1.010 | 0.27 | 0.29 | 6850 KB | 769 KB | +| `screen-rgb8` | region 512² centre | 3.426 | 3.832 | 2.148 | 0.56 | 0.63 | 6850 KB | 769 KB | +| `screen-rgb8` | region 512² bottom-right | 3.464 | 3.769 | 3.127 | 0.83 | 0.90 | 6850 KB | 769 KB | +| `screen-rgb8` | scale 1/2 | 3.065 | 11.93 | 11.64 | 0.98 | 3.80 | 7623 KB | 1542 KB | +| `screen-rgb8` | scale 1/4 | 3.410 | 6.886 | 6.058 | 0.88 | 1.78 | 6484 KB | 403 KB | +| `screen-rgb8` | scale 1/8 | 3.150 | 5.644 | 4.236 | 0.75 | 1.34 | 6199 KB | 118 KB | +| `screen-rgb8` | 1/4 + region 128² centre | 3.423 | 4.508 | 2.517 | 0.56 | 0.74 | 6136 KB | 55 KB | +| `screen-rgb16` | region 512² top-left | 14.16 | 14.33 | 1.636 | 0.11 | 0.12 | 37998 KB | 1537 KB | +| `screen-rgb16` | region 512² centre | 15.46 | 15.16 | 3.102 | 0.20 | 0.20 | 37998 KB | 1537 KB | +| `screen-rgb16` | region 512² bottom-right | 15.01 | 15.31 | 4.436 | 0.29 | 0.30 | 37998 KB | 1537 KB | +| `screen-rgb16` | scale 1/2 | 13.46 | 20.20 | 12.95 | 0.64 | 0.96 | 39523 KB | 3061 KB | +| `screen-rgb16` | scale 1/4 | 12.88 | 18.03 | 9.908 | 0.55 | 0.77 | 37233 KB | 772 KB | +| `screen-rgb16` | scale 1/8 | 10.80 | 15.21 | 7.656 | 0.50 | 0.71 | 36658 KB | 196 KB | +| `screen-rgb16` | 1/4 + region 128² centre | 15.21 | 15.64 | 3.686 | 0.24 | 0.24 | 36562 KB | 100 KB | +| `screen-p8` | region 512² top-left | 3.421 | 3.328 | 0.916 | 0.28 | 0.27 | 11154 KB | 1027 KB | +| `screen-p8` | region 512² centre | 2.590 | 2.714 | 0.943 | 0.35 | 0.36 | 11154 KB | 1027 KB | +| `screen-p8` | region 512² bottom-right | 2.371 | 2.734 | 0.975 | 0.36 | 0.41 | 11154 KB | 1027 KB | +| `screen-p8` | scale 1/2 | 3.424 | 13.91 | 13.16 | 0.95 | 3.85 | 12185 KB | 2058 KB | +| `screen-p8` | scale 1/4 | 3.591 | 8.352 | 7.549 | 0.90 | 2.10 | 10666 KB | 539 KB | +| `screen-p8` | scale 1/8 | 2.401 | 5.596 | 3.897 | 0.70 | 1.62 | 10287 KB | 159 KB | +| `screen-p8` | 1/4 + region 128² centre | 2.958 | 2.954 | 1.255 | 0.42 | 0.42 | 10202 KB | 75 KB | +| `screen-rgba8` | region 512² top-left | 4.050 | 3.763 | 1.090 | 0.29 | 0.27 | 9133 KB | 1025 KB | +| `screen-rgba8` | region 512² centre | 3.136 | 3.787 | 2.316 | 0.61 | 0.74 | 9133 KB | 1025 KB | +| `screen-rgba8` | region 512² bottom-right | 3.941 | 4.196 | 3.151 | 0.75 | 0.80 | 9133 KB | 1025 KB | +| `screen-rgba8` | scale 1/2 | 2.984 | 10.04 | 8.670 | 0.86 | 2.91 | 10164 KB | 2056 KB | +| `screen-rgba8` | scale 1/4 | 3.900 | 9.079 | 7.170 | 0.79 | 1.84 | 8645 KB | 537 KB | +| `screen-rgba8` | scale 1/8 | 3.790 | 5.337 | 6.016 | 1.13 | 1.59 | 8265 KB | 157 KB | +| `screen-rgba8` | 1/4 + region 128² centre | 4.252 | 4.612 | 2.763 | 0.60 | 0.65 | 8181 KB | 73 KB | + +The noisiest rows above (`screen-g8` scale 1/8 at 1.49×, `screen-rgba8` scale 1/8 at 1.13×) re-run with 21 rounds: + +| Case | Mode | full decode | baseline | native | native ÷ baseline | native ÷ full decode | alloc baseline | alloc native | +|---|---|---:|---:|---:|---:|---:|---:|---:| +| `screen-g8` | region 512² top-left | 2.116 | 2.149 | 0.534 | 0.25 | 0.25 | 2284 KB | 257 KB | +| `screen-g8` | region 512² centre | 4.005 | 3.985 | 2.608 | 0.65 | 0.65 | 2284 KB | 257 KB | +| `screen-g8` | region 512² bottom-right | 3.972 | 3.565 | 3.896 | 1.09 | 0.98 | 2284 KB | 257 KB | +| `screen-g8` | scale 1/2 | 3.972 | 8.816 | 9.487 | 1.08 | 2.39 | 2542 KB | 515 KB | +| `screen-g8` | scale 1/4 | 3.925 | 5.737 | 5.744 | 1.00 | 1.46 | 2162 KB | 135 KB | +| `screen-g8` | scale 1/8 | 4.071 | 4.811 | 4.587 | 0.95 | 1.13 | 2067 KB | 40 KB | +| `screen-g8` | 1/4 + region 128² centre | 3.990 | 3.977 | 2.808 | 0.71 | 0.70 | 2046 KB | 19 KB | +| `screen-rgba8` | region 512² top-left | 3.800 | 3.723 | 1.132 | 0.30 | 0.30 | 9133 KB | 1025 KB | +| `screen-rgba8` | region 512² centre | 3.680 | 4.140 | 2.343 | 0.57 | 0.64 | 9133 KB | 1025 KB | +| `screen-rgba8` | region 512² bottom-right | 3.774 | 3.889 | 3.440 | 0.88 | 0.91 | 9133 KB | 1025 KB | +| `screen-rgba8` | scale 1/2 | 3.965 | 15.10 | 14.83 | 0.98 | 3.74 | 10164 KB | 2056 KB | +| `screen-rgba8` | scale 1/4 | 3.807 | 8.697 | 8.326 | 0.96 | 2.19 | 8645 KB | 537 KB | +| `screen-rgba8` | scale 1/8 | 3.640 | 6.402 | 5.212 | 0.81 | 1.43 | 8265 KB | 157 KB | +| `screen-rgba8` | 1/4 + region 128² centre | 2.836 | 3.153 | 1.835 | 0.58 | 0.65 | 8181 KB | 73 KB | + +### Peak working set per region / scale decode, MiB (one fresh process per run, median of 3) + +"first" is the working-set rise during the first full-size operation, after a warm-up on a 64×64 image of the same case and mode (JIT excluded). It includes the decoded output, which for the region modes is 1.00 MiB at most (512² RGBA); the 1920×1080 full decode's samples are 7.91 MiB for RGBA8. + +| Case | Mode | full decode first | baseline first | native first | +|---|---|---:|---:|---:| +| `photo-rgb16` | region 512² top-left | 35.70 | 13.50 | 1.43 | +| `photo-rgb16` | region 512² centre | ″ | 13.50 | 1.43 | +| `photo-rgb16` | region 512² bottom-right | ″ | 13.50 | 1.43 | +| `photo-rgb16` | scale 1/2 | ″ | 14.99 | 2.89 | +| `photo-rgb16` | scale 1/4 | ″ | 12.76 | 0.67 | +| `photo-rgb16` | scale 1/8 | ″ | 12.19 | 0.05 | +| `photo-rgb16` | 1/4 + region 128² centre | ″ | 12.10 | 0.05 | +| `photo-rgba8` | region 512² top-left | 8.50 | 8.99 | 0.93 | +| `photo-rgba8` | region 512² centre | ″ | 9.00 | 0.93 | +| `photo-rgba8` | region 512² bottom-right | ″ | 9.00 | 0.93 | +| `photo-rgba8` | scale 1/2 | ″ | 9.96 | 1.91 | +| `photo-rgba8` | scale 1/4 | ″ | 8.48 | 0.42 | +| `photo-rgba8` | scale 1/8 | ″ | 8.09 | 0.05 | +| `photo-rgba8` | 1/4 + region 128² centre | ″ | 7.97 | 0.05 | +| `screen-p8` | region 512² top-left | 10.07 | 10.81 | 0.92 | +| `screen-p8` | region 512² centre | ″ | 10.81 | 0.92 | +| `screen-p8` | region 512² bottom-right | ″ | 10.82 | 0.92 | +| `screen-p8` | scale 1/2 | ″ | 11.80 | 1.91 | +| `screen-p8` | scale 1/4 | ″ | 10.32 | 0.43 | +| `screen-p8` | scale 1/8 | ″ | 9.95 | 0.06 | +| `screen-p8` | 1/4 + region 128² centre | ″ | 9.82 | 0.05 | + +## Full-image decode: no regression + +The full-image paths run the same code as before: `TryDecodeInto` without options only gained a branch it does not take. `current` against `baseline` below is `TryDecodeIntoRgba8` on both builds; the geometric mean is 1.01× on both content sets. Single cases deviate by up to 1.15× in either direction. That is noise: an A/A run with the baseline built from this branch's own commit showed the same spread (`screen-g4`: current 4.22 / baseline 3.83 ms in one run, 2.71 / 2.69 ms in the next). + +### Decode to 8-bit RGBA (in-process medians of 11 interleaved rounds, ms) + +`current` = `PngImageDecoder.TryDecodeIntoRgba8` into a caller buffer; `native` = `PngReader.Decode` (samples as stored, no RGBA expansion); ImageSharp = `Image.Load` + `CopyPixelDataTo`, one thread; libpng = simplified API `png_image_finish_read` to RGBA. Ratios below 1 mean we are faster. Inputs are encoded by ImageSharp (adaptive filters, zlib level 6). + +| Case | KB | current | native | baseline | ImageSharp | libpng | current MP/s | ÷ ImageSharp | ÷ libpng | ÷ baseline | +|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| `photo-g4` | 427 | 5.817 | 5.793 | 5.563 | 8.419 | — | 356 | 0.69× | — | 1.05× | +| `photo-g8` | 1297 | 17.7 | 16.6 | 18.8 | 19.4 | — | 117 | 0.91× | — | 0.94× | +| `photo-rgb8` | 3309 | 25.5 | 26.4 | 26.5 | 30.7 | — | 81 | 0.83× | — | 0.96× | +| `photo-rgb16` | 10762 | 46.5 | 44.9 | 47.2 | 81.9 | — | 45 | 0.57× | — | 0.99× | +| `photo-p8` | 1406 | 9.266 | 8.467 | 9.232 | 14.8 | — | 224 | 0.63× | — | 1.00× | +| `photo-rgba8` | 3809 | 39.0 | 39.6 | 34.1 | 35.1 | — | 53 | 1.11× | — | 1.15× | +| `screen-g4` | 45 | 2.035 | 1.703 | 1.974 | 6.087 | — | 1019 | 0.33× | — | 1.03× | +| `screen-g8` | 59 | 4.236 | 3.982 | 4.302 | 5.052 | — | 489 | 0.84× | — | 0.98× | +| `screen-rgb8` | 81 | 3.991 | 3.870 | 4.368 | 7.242 | — | 520 | 0.55× | — | 0.91× | +| `screen-rgb16` | 97 | 6.080 | 5.684 | 5.279 | 25.6 | — | 341 | 0.24× | — | 1.15× | +| `screen-p8` | 84 | 2.508 | 1.301 | 2.460 | 10.5 | — | 827 | 0.24× | — | 1.02× | +| `screen-rgba8` | 85 | 2.750 | 3.062 | 2.885 | 3.299 | — | 754 | 0.83× | — | 0.95× | + +Geometric means of current ÷ competitor over the cases both decode: + +- vs ImageSharp, photo: 0.77× (6 cases) +- vs ImageSharp, screen: 0.44× (6 cases) +- vs baseline, photo: 1.01× (6 cases) +- vs baseline, screen: 1.01× (6 cases) + +Managed bytes allocated per operation, KB (one warm operation on the timing thread). ImageSharp keeps pixel buffers in its own unmanaged pool and libpng allocates natively, so neither shows here: compare working sets with `peakmem`. + +| Case | current | native | baseline | ImageSharp | libpng | +|---|---:|---:|---:|---:|---:| +| `photo-g4` | 1.8 | 2027.8 | 1.8 | 105.5 | — | +| `photo-g8` | 1.2 | 2028.2 | 1.2 | 5.4 | — | +| `photo-rgb8` | 1.7 | 6082.5 | 1.7 | 8.3 | — | +| `photo-rgb16` | 4.8 | 12166.2 | 4.8 | 19.3 | — | +| `photo-p8` | 3.0 | 2029.0 | 3.0 | 10.4 | — | +| `photo-rgba8` | 9.2 | 8109.4 | 9.2 | 9.1 | — | +| `screen-g4` | 1.7 | 2027.7 | 1.7 | 104.8 | — | +| `screen-g8` | 0.7 | 2027.7 | 0.7 | 3.5 | — | +| `screen-rgb8` | 0.7 | 6081.5 | 0.7 | 3.6 | — | +| `screen-rgb16` | 0.7 | 12162.1 | 0.7 | 3.6 | — | +| `screen-p8` | 2.5 | 2028.5 | 2.5 | 8.5 | — | +| `screen-rgba8` | 8.2 | 8108.3 | 8.2 | 3.6 | — | diff --git a/benchmarks/Sdcb.SimdCodecs.Webp.Benchmarks/AbBenchmarks.cs b/benchmarks/Sdcb.SimdCodecs.Webp.Benchmarks/AbBenchmarks.cs index 732de86..55a4a61 100644 --- a/benchmarks/Sdcb.SimdCodecs.Webp.Benchmarks/AbBenchmarks.cs +++ b/benchmarks/Sdcb.SimdCodecs.Webp.Benchmarks/AbBenchmarks.cs @@ -226,7 +226,7 @@ internal static class QuickAb /// Contestant name of the opt-in multithreaded decode (quick --mt). public const string Multithreaded = "Multithreaded"; - public static void Run(IReadOnlyList inputs, bool ablate, int rounds, bool multithreaded = false) + public static void Run(IReadOnlyList inputs, bool ablate, int rounds, bool multithreaded = false, bool scaled = false) { Console.Write(MachineInfo.Describe()); bool hasBaseline = BaselineDecoder.Description is not null; @@ -246,6 +246,25 @@ public static void Run(IReadOnlyList inputs, bool ablate, int rounds, bo contestants.Add((nameof(AbBenchmarks.Baseline), () => BaselineDecoder.DecodeTo(source, rgba))); } + // quick --scaled: region and scale decodes (ScaledCases), current (native for lossy stills) against the + // baseline's generic path, each checked equal before timing. + if (scaled && info.Format == WebpFormat.Lossy) + { + if (!hasBaseline) + { + throw new InvalidOperationException("quick --scaled compares against the baseline: build one with the baseline command first."); + } + + foreach (ScaledCases.Case c in ScaledCases.All) + { + ScaledCases.Check(source, c); + var (w, h) = ScaledCases.OutputSize(c, info); + byte[] output = new byte[w * h * 4]; + contestants.Add(("Current " + c.Name, ScaledCases.Current(source, output, c))); + contestants.Add(("Baseline " + c.Name, ScaledCases.Baseline(source, output, c))); + } + } + if (multithreaded) { contestants.Add((Multithreaded, () => WebpDiagnostics.DecodeToMultithreaded(source, rgba))); @@ -307,6 +326,35 @@ public static void Run(IReadOnlyList inputs, bool ablate, int rounds, bo Console.WriteLine(AbBenchmarks.RenderTable(medians, BaselineDecoder.Description ?? "(none built)", $"in-process medians of {rounds} interleaved rounds × 10 decodes")); + if (scaled) + { + Console.WriteLine(RenderScaled(medians)); + } + } + + private static string RenderScaled(Dictionary<(string Input, string Method), double> medians) + { + static string Ms(double v) => v.ToString("F3", CultureInfo.InvariantCulture); + var sb = new StringBuilder(); + sb.AppendLine("Region / scale decodes (`WebpImageDecoder.TryDecodeInto`, RGBA; regions 640×270 at full scale), ms. " + + "Current = native lossy path; baseline = generic path (whole decode, box filter, crop)."); + sb.AppendLine(); + sb.AppendLine("| Input | case | current | baseline | current / baseline |"); + sb.AppendLine("|---|---|---:|---:|---:|"); + foreach (string input in medians.Keys.Select(k => k.Input).Distinct()) + { + foreach (ScaledCases.Case c in ScaledCases.All) + { + if (medians.TryGetValue((input, "Current " + c.Name), out double current) && + medians.TryGetValue((input, "Baseline " + c.Name), out double baseline)) + { + sb.AppendLine($"| `{Path.GetFileName(input)}` | {c.Name} | {Ms(current)} | {Ms(baseline)} | " + + $"{(current / baseline).ToString("F2", CultureInfo.InvariantCulture)}× |"); + } + } + } + + return sb.ToString(); } } diff --git a/benchmarks/Sdcb.SimdCodecs.Webp.Benchmarks/Baseline.cs b/benchmarks/Sdcb.SimdCodecs.Webp.Benchmarks/Baseline.cs index 1657eba..1371ba8 100644 --- a/benchmarks/Sdcb.SimdCodecs.Webp.Benchmarks/Baseline.cs +++ b/benchmarks/Sdcb.SimdCodecs.Webp.Benchmarks/Baseline.cs @@ -59,11 +59,11 @@ internal static unsafe class BaselineDecoder public static string Label => Loaded.Value.Label; - /// The baseline's WebpDecoder.DecodeTo(data, rgba, stride: 0). + /// The baseline's WebpDecoder.DecodeTo(data, rgba, stride: 0, cancellationToken). public static void DecodeTo(ReadOnlySpan webp, Span rgba) { - var fn = (delegate*, Span, int, void>)Loaded.Value.Fn; - fn(webp, rgba, 0); + var fn = (delegate*, Span, int, CancellationToken, void>)Loaded.Value.Fn; + fn(webp, rgba, 0, CancellationToken.None); } private static (IntPtr, string) Load() @@ -71,7 +71,7 @@ private static (IntPtr, string) Load() (Assembly asm, string label) = Assembly.Value; Type type = asm.GetType("Sdcb.SimdCodecs.Webp.WebpDecoder", throwOnError: true)!; MethodInfo method = type.GetMethods(BindingFlags.Public | BindingFlags.Static) - .Single(m => m.Name == "DecodeTo" && m.GetParameters().Length == 3); + .Single(m => m.Name == "DecodeTo" && m.GetParameters() is { Length: 4 } p && p[3].ParameterType == typeof(CancellationToken)); return (method.MethodHandle.GetFunctionPointer(), label); } diff --git a/benchmarks/Sdcb.SimdCodecs.Webp.Benchmarks/PeakMemory.cs b/benchmarks/Sdcb.SimdCodecs.Webp.Benchmarks/PeakMemory.cs index f3c96c8..a4c2523 100644 --- a/benchmarks/Sdcb.SimdCodecs.Webp.Benchmarks/PeakMemory.cs +++ b/benchmarks/Sdcb.SimdCodecs.Webp.Benchmarks/PeakMemory.cs @@ -23,16 +23,24 @@ public sealed record Sample(string Input, string Contestant, long FirstRiseBytes public long RiseBytes => PeakBytes - BaselineBytes; } - public static string Run(IReadOnlyList inputs, int repeats = 3) + public static string Run(IReadOnlyList inputs, int repeats = 3, bool scaled = false) { string self = typeof(PeakMemory).Assembly.Location; var samples = new List(); bool hasBaseline = BaselineDecoder.Description is not null; foreach (string input in inputs) { - foreach (string contestant in Contestants) + // --scaled: region / scale decodes ("Current:1/4", "Baseline:top", ...) of the lossy stills, after the + // whole-image decodes. + IEnumerable contestants = Contestants; + if (scaled && WebpDecoder.ReadInfo(WebpBenchmarkInputs.Read(input)).Format == WebpFormat.Lossy) { - if (contestant == "Baseline" && !hasBaseline) + contestants = contestants.Concat(ScaledCases.All.SelectMany(c => new[] { "Current:" + c.Name, "Baseline:" + c.Name })); + } + + foreach (string contestant in contestants) + { + if (contestant.StartsWith("Baseline", StringComparison.Ordinal) && !hasBaseline) { continue; } @@ -71,7 +79,7 @@ public static string Run(IReadOnlyList inputs, int repeats = 3) Sample median = runs.OrderBy(s => s.RiseBytes).ElementAt(runs.Count / 2); samples.Add(median); - Console.WriteLine($"[peakmem] {input,-26} {contestant,-9} first-decode rise {Mib(median.FirstRiseBytes),7} MiB, " + + Console.WriteLine($"[peakmem] {input,-26} {contestant,-15} first-decode rise {Mib(median.FirstRiseBytes),7} MiB, " + $"steady peak {Mib(median.PeakBytes),7} MiB, rise {Mib(median.RiseBytes),7} MiB"); } } @@ -81,7 +89,8 @@ public static string Run(IReadOnlyList inputs, int repeats = 3) "first full-size decode after a warm-up on a tiny file of the same kind (JIT excluded): the decode's " + "working memory; \"steady\" is a second decode of the same " + "file with pooled buffers already resident. The 1920×1080 RGBA destination (7.9 MiB) is allocated and " + - "touched before either baseline, so it is not part of any rise."); + "touched before either baseline, so it is not part of any rise; a region / scale decode " + + "(\"Current:1/4\", \"Baseline:top\", ...) allocates and touches only its output's RGBA."); sb.AppendLine(); sb.AppendLine("| Input | contestant | first-decode rise | steady peak | steady rise |"); sb.AppendLine("|---|---|---:|---:|---:|"); @@ -130,6 +139,11 @@ public static int Child(string contestant, string input) { byte[] source = WebpBenchmarkInputs.Read(input); WebpInfo info = WebpDecoder.ReadInfo(source); + if (contestant.Split(':') is [var who, var caseName]) + { + return ScaledChild(who, ScaledCases.Find(caseName), source, info); + } + byte[] rgba = new byte[info.Width * info.Height * 4]; Action decode = contestant switch { @@ -169,6 +183,37 @@ public static int Child(string contestant, string input) return 0; } + private static int ScaledChild(string who, ScaledCases.Case c, byte[] source, WebpInfo info) + { + var (w, h) = ScaledCases.OutputSize(c, info); + byte[] rgba = new byte[w * h * 4]; + Action decode = who == "Current" ? ScaledCases.Current(source, rgba, c) : ScaledCases.Baseline(source, rgba, c); + + // JIT on a tiny lossy still through the same path: a 1×1 region at the same scale. + byte[] tiny = File.ReadAllBytes(Path.Combine(BenchPaths.Root, "..", "..", "tests", "Sdcb.SimdCodecs.Tests", + "Fixtures", "webp", info.HasAlpha ? "lossy-alpha-q50.webp" : "gradient-16x16.webp")); + byte[] tinyRgba = new byte[4]; + var region = new Sdcb.SimdCodecs.Abstractions.PixelRect(0, 0, 1, 1); + bool warmed = who == "Current" + ? WebpImageDecoder.TryDecodeInto(tiny, tinyRgba, 0, Sdcb.SimdCodecs.Abstractions.PixelFormat.Rgba32, + new Sdcb.SimdCodecs.Abstractions.DecodeOptions { ScaleDenominator = c.Scale, Region = region }) + : BaselineImageDecoder.TryDecodeInto(tiny, tinyRgba, BaselineImageDecoder.CreateOptions(c.Scale, region)); + if (!warmed) + { + throw new InvalidOperationException("The warm-up decode failed."); + } + + rgba.AsSpan().Fill(0xFF); + GC.Collect(2, GCCollectionMode.Forced, blocking: true, compacting: true); + GC.WaitForPendingFinalizers(); + long firstRise = Measure(decode, out _); + GC.Collect(2, GCCollectionMode.Forced, blocking: true, compacting: true); + GC.WaitForPendingFinalizers(); + Measure(decode, out (long Baseline, long Peak) steady); + Console.WriteLine($"{firstRise}\t{steady.Baseline}\t{steady.Peak}"); + return 0; + } + internal static long Measure(Action decode, out (long Baseline, long Peak) result) { long baseline = CurrentWorkingSet(); diff --git a/benchmarks/Sdcb.SimdCodecs.Webp.Benchmarks/Program.cs b/benchmarks/Sdcb.SimdCodecs.Webp.Benchmarks/Program.cs index 76e00a5..ae0e926 100644 --- a/benchmarks/Sdcb.SimdCodecs.Webp.Benchmarks/Program.cs +++ b/benchmarks/Sdcb.SimdCodecs.Webp.Benchmarks/Program.cs @@ -20,9 +20,10 @@ Sdcb.SimdCodecs WebP decode and encode benchmark. Run from the repository root w --profile-webp-lossy 12-second single-thread lossy decode loop (for attaching a profiler) baseline [git-ref] build git-ref's (default HEAD) decoder as the A/B baseline ab [--ablate] [--inputs RE] BenchmarkDotNet A/B: current vs baseline vs libwebp; --ablate adds stage costs - quick [--ablate] [--inputs RE|--files PATTERN] [--rounds N] [--mt] + quick [--ablate] [--inputs RE|--files PATTERN] [--rounds N] [--mt] [--scaled] in-process interleaved A/B (seconds); for the inner loop, not for commits - peakmem [--inputs RE] peak working set per decode, one fresh process each (current, baseline, libwebp) + peakmem [--inputs RE] [--scaled] peak working set per decode, one fresh process each (current, baseline, libwebp); + --scaled adds region / scale decodes, current vs baseline corpus [path-to-cwebp] encode the synthetic verification corpus into .cache/corpus verify decode the corpus and benchmark inputs with this decoder and libwebp; compare bytes encode [--mode lossless|lossy|lossy-alpha|anim|all] [--reps N] [--max-dim N] [--images TEXT] [--cwebp PATH] [--no-cwebp] [--efforts 0,6] [--gate] @@ -63,13 +64,14 @@ List SelectInputs() => Opt("--inputs") is { } re ? Directory.GetFiles(Corpus.Dir, pattern).Order(StringComparer.Ordinal).ToList() : SelectInputs(); CheckCorrectness(inputs, requireBaseline: false); - QuickAb.Run(inputs, args.Contains("--ablate"), int.TryParse(Opt("--rounds"), out int r) ? r : 15, args.Contains("--mt")); + QuickAb.Run(inputs, args.Contains("--ablate"), int.TryParse(Opt("--rounds"), out int r) ? r : 15, args.Contains("--mt"), + args.Contains("--scaled")); return 0; } case "ab": return Ab(SelectInputs(), args.Contains("--ablate")); case "peakmem": - Console.WriteLine(PeakMemory.Run(SelectInputs())); + Console.WriteLine(PeakMemory.Run(SelectInputs(), scaled: args.Contains("--scaled"))); return 0; case "peakmem-child": return PeakMemory.Child(args[1], args[2]); diff --git a/benchmarks/Sdcb.SimdCodecs.Webp.Benchmarks/README.md b/benchmarks/Sdcb.SimdCodecs.Webp.Benchmarks/README.md index 02ecbf2..eb0e835 100644 --- a/benchmarks/Sdcb.SimdCodecs.Webp.Benchmarks/README.md +++ b/benchmarks/Sdcb.SimdCodecs.Webp.Benchmarks/README.md @@ -36,20 +36,20 @@ The second command is a 12-second single-threaded loop over the lossy 1080p inpu ```bash dotnet run -c Release --project benchmarks/Sdcb.SimdCodecs.Webp.Benchmarks -- baseline [git-ref] # default HEAD dotnet run -c Release --project benchmarks/Sdcb.SimdCodecs.Webp.Benchmarks -- ab [--ablate] [--inputs RE] -dotnet run -c Release --project benchmarks/Sdcb.SimdCodecs.Webp.Benchmarks -- quick [--ablate] [--inputs RE|--files PATTERN] [--rounds N] [--mt] +dotnet run -c Release --project benchmarks/Sdcb.SimdCodecs.Webp.Benchmarks -- quick [--ablate] [--inputs RE|--files PATTERN] [--rounds N] [--mt] [--scaled] ``` -`baseline` builds a commit's decoder into `.cache/baseline/` (through a temporary git worktree) and loads it into its own `AssemblyLoadContext`, so `ab` times baseline, current and libwebp in one BenchmarkDotNet run, interleaved per input — never against a number from an earlier session. Both `ab` and `quick` first check that the current decoder's output is byte-identical to libwebp and refuse to time it otherwise. `--ablate` adds runs with one stage switched off (internal `WebpAblation` flags via `WebpDiagnostics`): a stage's cost is the time that disappears (the set of ablations is chosen from the file's content: VP8 stages, ALPH, or VP8L transforms and the predictor transform alone). `quick` is an in-process interleaved loop that answers in seconds; use it while iterating and `ab` for the numbers that go into a commit. It warms every contestant for at least 300 ms first, so small files are not timed as tier-0 code. `--files PATTERN` times files of the verification corpus (below) instead of the three inputs — the inputs are flat `testsrc2` frames, and the corpus' noisy content is where entropy decoding dominates. `--mt` adds the opt-in multithreaded decode (`WebpDecodeOptions.Multithreaded`) as a separate table; it is never part of the single-thread comparison. +`baseline` builds a commit's decoder into `.cache/baseline/` (through a temporary git worktree) and loads it into its own `AssemblyLoadContext`, so `ab` times baseline, current and libwebp in one BenchmarkDotNet run, interleaved per input — never against a number from an earlier session. Both `ab` and `quick` first check that the current decoder's output is byte-identical to libwebp and refuse to time it otherwise. `--ablate` adds runs with one stage switched off (internal `WebpAblation` flags via `WebpDiagnostics`): a stage's cost is the time that disappears (the set of ablations is chosen from the file's content: VP8 stages, ALPH, or VP8L transforms and the predictor transform alone). `quick` is an in-process interleaved loop that answers in seconds; use it while iterating and `ab` for the numbers that go into a commit. It warms every contestant for at least 300 ms first, so small files are not timed as tier-0 code. `--files PATTERN` times files of the verification corpus (below) instead of the three inputs — the inputs are flat `testsrc2` frames, and the corpus' noisy content is where entropy decoding dominates. `--mt` adds the opt-in multithreaded decode (`WebpDecodeOptions.Multithreaded`) as a separate table; it is never part of the single-thread comparison. `--scaled` adds region and scale decodes of the lossy stills through `WebpImageDecoder.TryDecodeInto` (1/2, 1/4, 1/8, and a 640×270 region at the top, middle and bottom at full scale) as a separate table: the current build's native path against the baseline's, each checked byte-identical to the baseline's output before timing (it needs a baseline). ### Memory `ab` runs with BenchmarkDotNet's `MemoryDiagnoser` and reports managed bytes allocated per decode. libwebp allocates natively, which that cannot see, so ```bash -dotnet run -c Release --project benchmarks/Sdcb.SimdCodecs.Webp.Benchmarks -- peakmem [--inputs RE] +dotnet run -c Release --project benchmarks/Sdcb.SimdCodecs.Webp.Benchmarks -- peakmem [--inputs RE] [--scaled] ``` -measures the peak working set of each decode in a fresh process (current, baseline, libwebp): after a warm-up decode of a tiny file of the same kind (JIT excluded) and with the RGBA destination already touched, the rise during the first full-size decode is the decoder's working memory; a second decode shows the steady state with pooled buffers resident. +measures the peak working set of each decode in a fresh process (current, baseline, libwebp): after a warm-up decode of a tiny file of the same kind (JIT excluded) and with the RGBA destination already touched, the rise during the first full-size decode is the decoder's working memory; a second decode shows the steady state with pooled buffers resident. `--scaled` adds the same region and scale decodes as `quick --scaled`, current and baseline, each with only its output's RGBA destination touched. ### Verification corpus diff --git a/benchmarks/Sdcb.SimdCodecs.Webp.Benchmarks/ScaledCases.cs b/benchmarks/Sdcb.SimdCodecs.Webp.Benchmarks/ScaledCases.cs new file mode 100644 index 0000000..c3425fa --- /dev/null +++ b/benchmarks/Sdcb.SimdCodecs.Webp.Benchmarks/ScaledCases.cs @@ -0,0 +1,108 @@ +using System.Reflection; +using Sdcb.SimdCodecs.Abstractions; + +namespace Sdcb.SimdCodecs.Webp.Benchmarks; + +/// +/// Region and scale decodes through the facade adapter (, RGBA): 1/2, 1/4, +/// 1/8 of the whole image, and a 640×270 window at the top, middle and bottom of a 1920×1080 frame at full scale. The +/// current build decodes lossy stills natively; the baseline (a build from before that) takes the generic path, a whole +/// decode, box filter and crop. +/// +internal static class ScaledCases +{ + public sealed record Case(string Name, int Scale, string? Where); + + public static readonly Case[] All = + [ + new("1/2", 2, null), new("1/4", 4, null), new("1/8", 8, null), + new("top", 1, "top"), new("middle", 1, "middle"), new("bottom", 1, "bottom"), + ]; + + public static Case Find(string name) => All.Single(c => c.Name == name); + + /// The region of in the scaled image, or null for the whole scaled image. + public static PixelRect? Region(Case c, int width, int height) + { + if (c.Where is null) + return null; + int w = Math.Min(640, width), h = Math.Min(270, height); + int x = (width - w) / 2; + int y = c.Where switch { "top" => 0, "middle" => (height - h) / 2, _ => height - h }; + return new PixelRect(x, y, w, h); + } + + public static (int Width, int Height) OutputSize(Case c, WebpInfo info) => + new DecodeOptions { ScaleDenominator = c.Scale, Region = Region(c, info.Width, info.Height) }.GetOutputSize(info.Width, info.Height); + + public static Action Current(byte[] source, byte[] rgba, Case c) + { + WebpInfo info = WebpDecoder.ReadInfo(source); + var options = new DecodeOptions { ScaleDenominator = c.Scale, Region = Region(c, info.Width, info.Height) }; + return () => + { + if (!WebpImageDecoder.TryDecodeInto(source, rgba, 0, PixelFormat.Rgba32, options)) + throw new InvalidOperationException($"The current decoder refused {c.Name}."); + }; + } + + public static Action Baseline(byte[] source, byte[] rgba, Case c) + { + WebpInfo info = WebpDecoder.ReadInfo(source); + object options = BaselineImageDecoder.CreateOptions(c.Scale, Region(c, info.Width, info.Height)); + return () => + { + if (!BaselineImageDecoder.TryDecodeInto(source, rgba, options)) + throw new InvalidOperationException($"The baseline decoder refused {c.Name}."); + }; + } + + /// Correctness before timing: the native output must equal the baseline's generic one byte for byte. + public static void Check(byte[] source, Case c) + { + WebpInfo info = WebpDecoder.ReadInfo(source); + var (w, h) = OutputSize(c, info); + byte[] current = new byte[w * h * 4], baseline = new byte[w * h * 4]; + Current(source, current, c)(); + Baseline(source, baseline, c)(); + if (!current.AsSpan().SequenceEqual(baseline)) + throw new InvalidOperationException($"Region/scale case {c.Name}: the current output differs from the baseline's."); + } +} + +/// The baseline's WebpImageDecoder.TryDecodeInto with its own DecodeOptions, found by name. +internal static unsafe class BaselineImageDecoder +{ + private static readonly Lazy<(IntPtr Fn, Assembly Abstractions)> Loaded = new(() => + { + Assembly asm = BaselineDecoder.Assembly.Value.Assembly; + Type type = asm.GetType("Sdcb.SimdCodecs.Webp.WebpImageDecoder", throwOnError: true)!; + MethodInfo method = type.GetMethods(BindingFlags.Public | BindingFlags.Static) + .Single(m => m.Name == "TryDecodeInto" && m.GetParameters().Length == 6); + Assembly abstractions = method.GetParameters()[4].ParameterType.Assembly; + return (method.MethodHandle.GetFunctionPointer(), abstractions); + }); + + /// A DecodeOptions of the baseline's own Abstractions. + public static object CreateOptions(int scale, PixelRect? region) + { + Assembly abstractions = Loaded.Value.Abstractions; + Type optionsType = abstractions.GetType("Sdcb.SimdCodecs.Abstractions.DecodeOptions", throwOnError: true)!; + Type rectType = abstractions.GetType("Sdcb.SimdCodecs.Abstractions.PixelRect", throwOnError: true)!; + object options = Activator.CreateInstance(optionsType)!; + optionsType.GetProperty("ScaleDenominator")!.SetValue(options, scale); + if (region is { } r) + optionsType.GetProperty("Region")!.SetValue(options, Activator.CreateInstance(rectType, r.X, r.Y, r.Width, r.Height)); + return options; + } + + /// + /// TryDecodeInto to tight RGBA. The baseline's PixelFormat is the same struct (same source, same layout) as the + /// current one, so the current is passed as is. + /// + public static bool TryDecodeInto(ReadOnlySpan data, Span rgba, object options) + { + var fn = (delegate*, Span, int, PixelFormat, object?, CancellationToken, bool>)Loaded.Value.Fn; + return fn(data, rgba, 0, PixelFormat.Rgba32, options, CancellationToken.None); + } +} diff --git a/benchmarks/Sdcb.SimdCodecs.Webp.Benchmarks/optimization/scaled-region-2026-11-08-amd-ryzen-7-5800x.md b/benchmarks/Sdcb.SimdCodecs.Webp.Benchmarks/optimization/scaled-region-2026-11-08-amd-ryzen-7-5800x.md new file mode 100644 index 0000000..df95120 --- /dev/null +++ b/benchmarks/Sdcb.SimdCodecs.Webp.Benchmarks/optimization/scaled-region-2026-11-08-amd-ryzen-7-5800x.md @@ -0,0 +1,82 @@ +# Native region and scale decode of lossy stills (2026-11-08) + +Lossy (VP8) stills decode `DecodeOptions.Region` / `ScaleDenominator` natively: rows stream from the macroblock-row decoder through the box filter (`BoxDownscaler`), only the window's columns are converted, and decoding stops after the macroblock row that emits the last needed source row. The baseline is `origin/scaled-decode` (88a489d), which takes the generic path (whole decode, box filter, crop). Every case is checked byte-identical to the baseline's output before it is timed. The 8-bit RGB / RGBA emit of `BoxDownscaler` gained an exact 32-bit path (shift for power-of-two boxes) in the same change; without it the native 1/2 decode was no faster than the generic one (12.3 vs 11.8 ms), since the emit's 64-bit divisions dominated. + +## Machine + +- CPU: AMD Ryzen 7 5800X (16 logical processors) +- OS: Microsoft Windows 10.0.26200 (X64) +- .NET: SDK 10.0.400, runtime .NET 10.0.11 (win-x64) +- Vector128.IsHardwareAccelerated: True, Vector256.IsHardwareAccelerated: True, Vector512.IsHardwareAccelerated: False +- AVX2: True, AVX-512F: False, BMI2: True +- libwebp: 1.6.0 SIMD build (`.cache/libwebp.dll`) + +## Time + +`quick --scaled --rounds 15`, in-process medians of 15 interleaved rounds × 10 decodes, ms. + +Whole-image decode (no regression; the lossless row does not use the changed code): + +| Input | libwebp | baseline | current | current ÷ baseline | current ÷ libwebp | +|---|---:|---:|---:|---:|---:| +| `lossy-opaque-1080p.webp` | 7.357 | 6.121 | 6.264 | 1.02× | 0.85× | +| `lossy-alpha-1080p.webp` | 13.644 | 7.087 | 6.886 | 0.97× | 0.50× | +| `lossless-1080p.webp` | 8.800 | 6.204 | 5.526 | 0.89× | 0.63× | + +Region / scale decodes (`WebpImageDecoder.TryDecodeInto`, RGBA; regions 640×270 at full scale): + +| Input | case | current | baseline | current / baseline | +|---|---|---:|---:|---:| +| `lossy-opaque-1080p.webp` | 1/2 | 9.059 | 13.387 | 0.68× | +| `lossy-opaque-1080p.webp` | 1/4 | 7.336 | 9.620 | 0.76× | +| `lossy-opaque-1080p.webp` | 1/8 | 8.177 | 8.825 | 0.93× | +| `lossy-opaque-1080p.webp` | top | 1.300 | 7.028 | 0.18× | +| `lossy-opaque-1080p.webp` | middle | 2.591 | 7.600 | 0.34× | +| `lossy-opaque-1080p.webp` | bottom | 5.277 | 7.283 | 0.72× | +| `lossy-alpha-1080p.webp` | 1/2 | 11.446 | 15.332 | 0.75× | +| `lossy-alpha-1080p.webp` | 1/4 | 9.143 | 10.887 | 0.84× | +| `lossy-alpha-1080p.webp` | 1/8 | 7.129 | 9.393 | 0.76× | +| `lossy-alpha-1080p.webp` | top | 1.342 | 7.888 | 0.17× | +| `lossy-alpha-1080p.webp` | middle | 3.292 | 8.103 | 0.41× | +| `lossy-alpha-1080p.webp` | bottom | 6.994 | 8.457 | 0.83× | + +The whole-image rows moved within run-to-run noise (an earlier run of the same build gave 0.97× / 0.99× / 1.00×). + +## Peak memory + +`peakmem --scaled --inputs lossy`, one fresh process per decode, median of 3, MiB. A region / scale decode allocates and touches only its output's RGBA before the measurement. + +| Input | contestant | first-decode rise | steady peak | steady rise | +|---|---|---:|---:|---:| +| `lossy-opaque-1080p.webp` | LibWebp | 0.25 | 33.17 | 0.19 | +| `lossy-opaque-1080p.webp` | Baseline | 0.28 | 39.52 | 0.02 | +| `lossy-opaque-1080p.webp` | Current | 0.27 | 37.00 | 0.02 | +| `lossy-opaque-1080p.webp` | Current:1/2 | 0.29 | 31.45 | 0.02 | +| `lossy-opaque-1080p.webp` | Baseline:1/2 | 15.62 | 49.62 | 1.38 | +| `lossy-opaque-1080p.webp` | Current:1/4 | 0.36 | 30.02 | 0.02 | +| `lossy-opaque-1080p.webp` | Baseline:1/4 | 14.58 | 46.99 | 0.02 | +| `lossy-opaque-1080p.webp` | Current:1/8 | 0.36 | 29.61 | 0.02 | +| `lossy-opaque-1080p.webp` | Baseline:1/8 | 14.29 | 46.36 | 0.02 | +| `lossy-opaque-1080p.webp` | Current:top | 0.27 | 30.12 | 0.02 | +| `lossy-opaque-1080p.webp` | Baseline:top | 14.64 | 47.24 | 0.39 | +| `lossy-opaque-1080p.webp` | Current:middle | 0.29 | 30.06 | 0.02 | +| `lossy-opaque-1080p.webp` | Baseline:middle | 14.64 | 47.29 | 0.39 | +| `lossy-opaque-1080p.webp` | Current:bottom | 0.30 | 30.24 | 0.02 | +| `lossy-opaque-1080p.webp` | Baseline:bottom | 14.64 | 47.19 | 0.39 | +| `lossy-alpha-1080p.webp` | LibWebp | 2.47 | 35.45 | 2.43 | +| `lossy-alpha-1080p.webp` | Baseline | 3.06 | 42.66 | 0.02 | +| `lossy-alpha-1080p.webp` | Current | 3.07 | 40.16 | 0.02 | +| `lossy-alpha-1080p.webp` | Current:1/2 | 3.09 | 34.59 | 0.02 | +| `lossy-alpha-1080p.webp` | Baseline:1/2 | 13.07 | 47.28 | 1.87 | +| `lossy-alpha-1080p.webp` | Current:1/4 | 3.13 | 33.12 | 0.02 | +| `lossy-alpha-1080p.webp` | Baseline:1/4 | 11.61 | 44.28 | 0.39 | +| `lossy-alpha-1080p.webp` | Current:1/8 | 3.13 | 32.75 | 0.02 | +| `lossy-alpha-1080p.webp` | Baseline:1/8 | 11.24 | 43.47 | 0.02 | +| `lossy-alpha-1080p.webp` | Current:top | 3.08 | 33.11 | 0.02 | +| `lossy-alpha-1080p.webp` | Baseline:top | 11.69 | 44.54 | 0.55 | +| `lossy-alpha-1080p.webp` | Current:middle | 3.08 | 33.10 | 0.02 | +| `lossy-alpha-1080p.webp` | Baseline:middle | 11.69 | 44.52 | 0.55 | +| `lossy-alpha-1080p.webp` | Current:bottom | 3.08 | 33.12 | 0.02 | +| `lossy-alpha-1080p.webp` | Baseline:bottom | 11.69 | 44.50 | 0.55 | + +The native path's working memory is the row caches (about 0.3 MiB); with alpha, the whole ALPH plane (1920 × 1080 bytes ≈ 2 MiB) is still decoded first. The generic path holds the full-size RGBA frame and the RGB raster. diff --git a/docs/api-design.md b/docs/api-design.md index 2aea774..4ae945f 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -6,7 +6,7 @@ - **静态抽象成员 + 显式注册。** codec 不靠反射发现,NativeAOT 和 Mono AOT 都安全。每个 codec 用对象初始化器、以自己 具体类型的方法组构造 `Registration`(解码器的 `ImageDecoderRegistration`、编码器的 `ImageEncoderRegistration`),门面 只引用这些属性,从不在泛型方法里绑定委托(Mono AOT 会在那里陷入)。wasm 冒烟测试覆盖所有门面入口。 - **`ReadOnlySpan` 一次性 API 是热路径。** 会话 API(`ImageDecoder`)建在它之上。 -- **错误约定。** 不可信输入:`Try*` 返回 `false`(抛出型 API 抛 `InvalidDataException`),不挂起,不无界分配。取消 抛 `OperationCanceledException`;调用方给的选项做不到(不支持的缩放、越界的区域)抛 `NotSupportedException` / `ArgumentException`,不静默回退。 +- **错误约定。** 不可信输入:`Try*` 返回 `false`(抛出型 API 抛 `InvalidDataException`),不挂起,不无界分配。取消 抛 `OperationCanceledException`;调用方给的选项做不到(越界的区域)抛 `ArgumentException`,不静默回退。 - **`IDecodedImage` 是保真层**:原始通道数和样本类型、主机字节序、非预乘 alpha。 ## 像素格式:`PixelFormat` @@ -33,10 +33,11 @@ `IImageDecoder`:`CanDecode`、`TryReadInfo`、`TryGetClosestSize`、`TryDecode`、`TryDecodeInto`、`TryReadMetadata`、 `TryDecodeThumbnail`。 - **`TryDecodeInto` 的契约**:结果等于 `TryDecode` 的图像(同样的选项)的 `Pixels` 经 `PixelConverter` 转换。原生 快速路径必须逐字节一致。例外两种,都需在 codec 文档里写明:存储亮度的 codec 输出灰度时给存储的亮度(YCbCr JPEG 的 Y 平面);从比 8 位更深的编码样本直接转换到 8 位格式的 codec(HEIF Main 10),只舍入一次,与收窄 16 位图像最多差 1。 -- **`DecodeOptions`**:`ScaleDenominator`(任意 2 的幂,1 到 2^30;每个 codec 只接受自己能原生做到的分母,例如 JPEG 最多 8,其他抛 `NotSupportedException`)、 `Region`(存储坐标;做不到原生区域解码的 codec 整幅解码再裁剪)、`Pool`(像素从池中租用,释放图像即归还)。 调用方先用 `TryGetClosestSize` 问 codec 原生能做到的最接近尺寸(WIC 的协商方式)。 +- **`DecodeOptions`**:`ScaleDenominator`(任意 2 的幂,1 到 2^30,每个 codec 都接受;原生能做到的分母便宜,例如 JPEG 最多 8、JPEG 2000 最多 2 的分解级数次方,其余分母先按最大的原生分母 n 解码,再用冻结的 box 滤波缩小 d / n 倍)、 `Region`(缩放后图像的存储坐标;做不到原生区域解码的 codec 整幅解码再裁剪)、`Pool`(像素从池中租用,释放图像即归还)。 调用方先用 `TryGetClosestSize` 问 codec 原生能做到的最接近尺寸(WIC 的协商方式);它只报告原生尺寸,其他分母也能用,但要付出一次原生尺寸的解码。 +- **冻结的 box 滤波**(`CodecSupport.BoxDownscaler`):输出尺寸 ⌈w/k⌉ × ⌈h/k⌉(k 为 box 边长)。输出像素 (bx, by) 取存储像素 x ∈ [bx·k, min(bx·k + k, w))、y 同理的平均,右边和下边的 box 不完整,n 为裁剪后 box 的像素数。在存储的码值上计算(gamma 空间,不线性化)。无 alpha:每个通道 (Σc + n/2) / n,整数运算。有 alpha(GrayAlpha、RGBA):A = Σa,alpha = (A + n/2) / n,颜色 = A 为 0 时取 0,否则 (Σ(c·a) + A/2) / A,即按 alpha 加权、逐像素不舍入。8 / 16 位精确求和(64 位累加,box 最多 2^32 像素,不会溢出),所以每个 SIMD 层输出相同的字节。Float32 用 double 按行、box 内从左到右累加:Σc / n;有 alpha 时 alpha = A / n,颜色 = A 为 0 时取 0,否则 Σ(c·a) / A;没有向量路径。位深、颜色编码和元数据与 `RasterImage.Crop` 一样保留。它是流式的行累加器:解码器逐行推入源行,每 k 行(以及最后一行)取出一行输出,因此可以边解码边缩小;从 box 边界开始的列窗口(`GetSourceRect`)只缩小区域需要的列。 - **取消**:检查点放在粗粒度位置(JPEG 每个 MCU 行、HEIF 每个 tile 和每 64 行输出、PNG / QOI 等每 64 行、WebP 每个 宏块行),不进内层循环。 - **`ImageInfo`** 在解码前给出 `ColorEncoding`(判断是不是 PQ / HLG)、`Resolution`(单位按文件声明)、`HasThumbnail`。 -- **`ImageDecoderFallback`** 给不具备原生能力的 codec 一行实现:解码再转换、原尺寸、无元数据、无缩略图。它和 `DecodeOptions` 的 `Validate` / `Apply`(扩展成员)、`BufferedEncoderSession` 一样放在 `Sdcb.SimdCodecs.Abstractions.CodecSupport`:只有写 codec 的人需要,用户的 IntelliSense 里看不到。 +- **`ImageDecoderFallback`** 给不具备原生能力的 codec 一行实现:解码再转换、原尺寸、无元数据、无缩略图。它和 `DecodeOptions` 的 `Validate` / `GetNativeScale` / `Apply`(扩展成员)、`BoxDownscaler`、`BufferedEncoderSession` 一样放在 `Sdcb.SimdCodecs.Abstractions.CodecSupport`:只有写 codec 的人需要,用户的 IntelliSense 里看不到。 ## 元数据与缩略图 diff --git a/docs/known-gaps.md b/docs/known-gaps.md index c9ea6a0..4dbe41e 100644 --- a/docs/known-gaps.md +++ b/docs/known-gaps.md @@ -10,15 +10,15 @@ ## 能力一览 -✅ 支持,⚠️ 部分支持(限定条件见各格式页),空白为不支持或不适用。“原生缩放”指不先解出全尺寸图;“区域按需”指只读或只解与区域相交的数据,其余格式的区域解码是整幅解码再裁剪;“直写缓冲区”指 `TryDecodeInto` 不经整帧中间图、直接写进调用方缓冲区。 +✅ 支持,⚠️ 部分支持(限定条件见各格式页),空白为不支持或不适用。“原生缩放”指不先解出全尺寸图(JPEG、JPEG 2000 在变换域少算;PNG、WebP、HEIF 边解码边做 box 缩小,不持有整帧),所有格式都接受任意 2 的幂,空白的格式是整幅解码后再 box 缩小;“区域按需”指只读或只解与区域相交的数据,其余格式的区域解码是整幅解码再裁剪;“直写缓冲区”指 `TryDecodeInto` 不经整帧中间图、直接写进调用方缓冲区。 | 格式 | 解码 | 编码 | 动画 / 多页 | alpha | 16 位 | 浮点 / HDR | 原生缩放 | 区域按需 | 直写缓冲区 | 已调优 | |---|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:| | [JPEG](known-gaps/jpeg.md) | ✅ | ✅ | | | | | ✅ | ⚠️ | ✅ | ✅ | | [Ultra HDR / 增益图](known-gaps/jpeg-gainmap.md) | ✅ | ✅ | | | | ✅ | | | | | -| [PNG](known-gaps/png.md) | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ | | | ✅ | ✅ | +| [PNG](known-gaps/png.md) | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ⚠️ | ✅ | ✅ | | [TIFF](known-gaps/tiff.md) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅ | ⚠️ | | -| [WebP](known-gaps/webp.md) | ✅ | ✅ | ✅ | ✅ | | | | | ✅ | ✅ | +| [WebP](known-gaps/webp.md) | ✅ | ✅ | ✅ | ✅ | | | ⚠️ | ⚠️ | ✅ | ✅ | | [GIF](known-gaps/gif.md) | ✅ | ✅ | ✅ | ⚠️ | | | | | | | | [BMP](known-gaps/bmp.md) | ✅ | ✅ | | ✅ | | | | | ✅ | ✅ | | [ICO / CUR](known-gaps/ico.md) | ✅ | ✅ | ✅ | ✅ | | | | | | ✅ | @@ -26,7 +26,7 @@ | [Netpbm](known-gaps/netpbm.md) | ✅ | ✅ | | ✅ | ✅ | ✅ | | | ✅ | ✅ | | [JPEG 2000](known-gaps/jpeg2000.md) | ✅ | | | ✅ | ✅ | | ✅ | | | ⚠️ | | [JBIG2](known-gaps/jbig2.md) | ✅ | | ⚠️ | | | | | | | | -| [HEIF / HEIC](known-gaps/heif.md) | ✅ | | | | ✅ | ⚠️ | | ✅ | ✅ | ✅ | +| [HEIF / HEIC](known-gaps/heif.md) | ✅ | | | | ✅ | ⚠️ | ⚠️ | ✅ | ✅ | ✅ | | [EXIF](known-gaps/exif.md) | ✅ | ✅ | | | | | | | | | 几点补充:PNG 和 HEIF 的“浮点 / HDR”指能读出 PQ / HLG 标记和 16 位原始样本,但不投影到 8 位;GIF 的 alpha 只有 1 位;ICO 的“多页”是目录里的各个尺寸;JBIG2 的多页只能通过包 API 取,门面只给第 1 页;JPEG 2000 有基准项目,单核仍慢于 libopenjp2;EXIF 一行的解码 / 编码指读取和生成 Exif 块。 @@ -47,8 +47,8 @@ HEIF 解码也已调优(M3),基准项目是 `Sdcb.SimdCodecs.Heif.Benchmar - **HEIF 要显式注册。** HEVC 有专利负担,`Sdcb.SimdCodecs.Heif` 不在元包里,门面也不引用它;安装后调用一次 `SimdCodecs.Register(HeifImageDecoder.Registration)`,门面的全部入口才会处理 HEIF。注册前 `DetectFormat` 仍报 `ImageFormat.Heif`,但 `IsDecodable` 为 `false`、`Try*` 返回 `false`。注册是进程级的:幂等、线程安全、 不能撤销;注册的编解码器排在内置的之后嗅探。没有反注册、优先级、按名字查找,这是有意保持最小。 - **编码器抽象(2.0 起)的流式程度不一。** `IImageEncoder` 的会话可以逐行推送像素,但目前每个编码器都在会话里 缓冲整幅图,`Commit` 时一次编码(`BufferedEncoderSession`):契约不变,只是没有内存收益。PNG、BMP、QOI、PNM 天然逐行,以后可以改成真正流式。 -- **缩放解码只有 JPEG 和 JPEG 2000 是原生的。** JPEG 支持 `DecodeOptions.ScaleDenominator` 2、4、8(DCT 域缩放);JPEG 2000 支持不超过 2 的分解级数次方的任意 2 的幂(略去高分辨率级别,与 `opj_decompress -r` 一致),但 SIZ 原点不为 0 的图只能按原尺寸解码。其他 codec 抛出 `NotSupportedException`,`TryGetClosestSize` 报告原尺寸。 -- **区域解码大多是“整幅解码再裁剪”。** `DecodeOptions.Region` 对所有 codec 都可用,但除了 HEIF(只解码与区域相交 的网格 tile,只转换区域内的像素)、TIFF(只读与区域相交的条带或分块)和 JPEG 以外都是整幅解码后裁剪,语义一致,没有性能收益。JPEG 只持有 MCU 行条带和 区域本身,只对区域内的行做上采样和色彩转换,所以超过 2 GB 的大图也能取区域;但每个 MCU 仍要熵解码和 IDCT(baseline 码流没有可跳转的索引),耗时接近整幅解码。按 restart 间隔跳过熵解码留给 2.x。 +- **所有 codec 都接受任意 2 的幂作为 `DecodeOptions.ScaleDenominator`,但只有部分是原生的。** JPEG 原生支持 2、4、8(DCT 域缩放);JPEG 2000 原生支持不超过 2 的分解级数次方的任意 2 的幂(略去高分辨率级别,与 `opj_decompress -r` 一致),但 SIZ 原点不为 0 的图没有原生缩减。超出原生范围的分母先按最大的原生分母解码,再用冻结的 box 滤波缩小(见 [API 设计](api-design.md))。PNG(非隔行)、WebP 有损静态图和 HEIF(tile 边界与 box 对齐时)边解码边缩小,不持有整帧,峰值内存约为输出加几行,但每个像素仍要解码,耗时与整幅解码相近。其他 codec 整幅解码后再缩小,结果相同,没有加速。`TryGetClosestSize` 只报告原生尺寸(JPEG、JPEG 2000 之外报告原尺寸)。 +- **区域解码只在部分格式上按需。** `DecodeOptions.Region` 对所有 codec 都可用,按需读取的有:HEIF(只解码与区域相交的网格 tile,只转换区域内的像素)、TIFF(只读与区域相交的条带或分块)、JPEG、PNG(非隔行)和 WebP 有损静态图;其他格式是整幅解码后裁剪,语义一致,没有性能收益。JPEG 只持有 MCU 行条带和区域本身,只对区域内的行做上采样和色彩转换,所以超过 2 GB 的大图也能取区域;但每个 MCU 仍要熵解码和 IDCT(baseline 码流没有可跳转的索引),耗时接近整幅解码,按 restart 间隔跳过熵解码留给 2.x。PNG 和 WebP 解到区域需要的最后一行就停:区域上方的行仍要解压,区域下方的数据不读也不校验,所以一个文件取区域成功、整幅解码却可能失败。 - **整幅输出受单个数组 2 GB 上限约束。** 输出光栅超过 `Array.MaxLength` 字节时,`TryDecode` 返回 false, `ImageDecoder` 的 `Decode` 抛出 `InvalidDataException` 并提示改用缩放或区域解码;`RasterImage` 的工厂方法抛出 `ArgumentOutOfRangeException`。JPEG progressive 或分多次扫描的帧要整帧保存系数,总样本数上限为 `int.MaxValue`, 缩放解码也不例外。 - **`Stream` 输入会先读进内存。** `ImageDecoder.Open(Stream)` 对可寻址的流按需读取的 `IByteSource` 已经就位,但 2.0 里所有 codec 仍在整份文件上解码:可寻址的流一次读入,不可寻址的流先缓冲,文件路径用内存映射(不复制)。 逐 codec 换成真正的增量输入是 2.x 的性能项。 - **`ColorEncoding` 只从 ICC 取原色。** 格式自己的色彩标记优先(PNG 的 cICP,表示线性的 gAMA 1.0,HEIF 的 nclx `colr` 盒;HEIF 没有 `colr` 盒时用 HEVC VUI)。格式没有自己的标记、但带有 ICC 配置文件,并且该文件声明的原色是 BT.709、Display P3 或 BT.2020 时,`ImageInfo.ColorEncoding` 和 `IDecodedImage.ColorEncoding` 报 sRGB 加上这组原色(例如 Display P3 照片报 `Primaries = DisplayP3`);传递函数和范围不从 ICC 推导,仍是 sRGB 和全范围。ICC 声明的是其他原色(Adobe RGB、ProPhoto、相机或显示器自己的)、是 LUT 配置文件、或者格式损坏时,行为不变,报默认的 sRGB。识别方法与 `IccProfiles.TryGetPrimaries` 相同。覆盖 JPEG、PNG(含帧读取器)、WebP(含帧读取器)、TIFF、HEIF、JPEG 2000(JP2 的受限 ICC)和 BMP V5;像素不受影响。 diff --git a/docs/known-gaps/bmp.md b/docs/known-gaps/bmp.md index a266c85..4c4c6a8 100644 --- a/docs/known-gaps/bmp.md +++ b/docs/known-gaps/bmp.md @@ -46,7 +46,7 @@ | 功能 | 状态 | 说明 | |---|:-:|---| | `TryDecodeInto` 直写调用方缓冲区 | ✅ | SIMD 按行转换,零分配 | -| 原生缩放解码 | ❌ | | +| 原生缩放解码 | ❌ | 可用,整幅解码后 box 缩小 | | 区域解码 | ⚠️ | 整幅解码再裁剪 | | 已调优(解码和编码) | ✅ | 1920×1080 解码约为 WIC 的 0.20× | diff --git a/docs/known-gaps/gif.md b/docs/known-gaps/gif.md index 6177a8b..742e3c4 100644 --- a/docs/known-gaps/gif.md +++ b/docs/known-gaps/gif.md @@ -32,7 +32,7 @@ | 功能 | 状态 | 说明 | |---|:-:|---| -| 原生缩放解码 | ❌ | | +| 原生缩放解码 | ❌ | 可用,整幅解码后 box 缩小 | | 区域解码 | ⚠️ | 整幅解码再裁剪 | | `TryDecodeInto` 直写调用方缓冲区 | ⚠️ | 先解码整帧再转换 | | 已调优 | ❌ | | diff --git a/docs/known-gaps/heif.md b/docs/known-gaps/heif.md index 4adb737..119aba3 100644 --- a/docs/known-gaps/heif.md +++ b/docs/known-gaps/heif.md @@ -36,7 +36,7 @@ | 需显式注册 | — | HEVC 专利原因,不在元包里 | | 区域解码只解相交的 tile | ✅ | | | `TryDecodeInto` 直写调用方缓冲区 | ✅ | 所有 `PixelFormat`,每个 tile 解完立即写入 | -| 原生缩放解码 | ❌ | | +| 原生缩放解码 | ⚠️ | 每个 tile 边转换边做 box 缩小,不产生全尺寸图;网格的 tile 接缝须落在 box 边界上,否则整幅解码后缩小(字节相同) | | 多核并行(tile 并行、WPP wavefront) | ✅ | | | 已调优 | ✅ | 8 核到 RGBA 约为 libheif 的 0.20–0.32× | @@ -51,4 +51,5 @@ - **PQ / HLG 不给 8 位投影。** nclx(或 VUI)的传递函数是 PQ(16)或 HLG(18)时,`TryDecodeInto` 返回 `false`,`TryDecode` 照常解码并在 `ColorEncoding` 里如实报告,与 PNG 的 cICP 处理一致。 - **只有 ICC、没有 nclx 时,`ColorEncoding` 只取 ICC 的原色**:`colr` 盒优先于 VUI(手机照片常是 Display P3 ICC 配 BT.601 的 VUI),所以此时不看 VUI 的原色;ICC 声明的原色是 BT.709、Display P3 或 BT.2020 时,报 sRGB 加上这组原色(手机照片报 `DisplayP3`),其他情况报默认的 sRGB。YCbCr 矩阵仍取自 VUI,像素不变。有 nclx 盒时 nclx 优先于 ICC。 - **已调优(M3)。** 单核 HEVC 阶段是 libde265 的 0.68–0.87×;8 核端到端到 RGBA 是 libheif 的 0.20–0.32×(网格 手机照片和单 item 图都是:单 item 的 WPP 图按 CTB 行做 wavefront 并行),Magick.NET 的 0.09–0.14×。每个 tile 解完立即按目标像素格式和 stride 写进调用方缓冲区(所有 `PixelFormat`,含 `clap` / `irot` / `imir`),没有 整帧中间缓冲。冷进程首次解码的峰值工作集增量:12.6 MP 网格 75 MB(输出 48 MB,libheif 80 MB),未达 ≤ 64 MB 的目标,余量是 JIT 和 16 个并行 tile 各自的工作集;50 MP 网格 230 MB(libheif 373 MB)。见 [HEIF M3 记录](../../benchmarks/Sdcb.SimdCodecs.Heif.Benchmarks/results/2026-11-04-amd-ryzen-7-5800x-m3.md)。 +- **缩放解码在 tile 内完成,但仍要解码每个像素。** HEVC 没有降分辨率解码,所以 `ScaleDenominator` > 1 时每个 tile 照常解码,转换成 Rgb24(Main 10 为 Rgb48)后立即按冻结的 box 滤波缩小,写进缩小后的图(`TryDecodeInto` 再用 `PixelConverter` 转成目标格式),没有全尺寸中间图,与“整幅解码再 box 缩小”逐字节相同。条件是每个与输出相交的 tile 在显示坐标下的矩形都从 box 边界开始、在 box 边界或图像边缘结束:单 item 图总是满足;网格要求 tile 接缝经 `clap` / `irot` / `imir` 映射后落在缩放因子的整数倍上(手机照片的 512 像素 tile、尺寸是因子的倍数、无变换或只有旋转 / 镜像时满足,因子最大到 512)。不满足时(例如 `clap` 从奇数列开始裁剪)退回整幅解码后缩小。`TryGetClosestSize` 仍报告原尺寸。50 MP 网格冷进程峰值增量:d2 82 MB、d8 47 MB(原来 218 MB、183 MB);12.6 MP 网格 d2 38 MB、d8 30 MB(原来 72、61 MB);8 核耗时是原来的 0.63–0.96×。见 [缩放记录](../../benchmarks/Sdcb.SimdCodecs.Heif.Benchmarks/results/2026-11-08-amd-ryzen-7-5800x-scaled.md)。 - **专利**:HEVC 有专利负担,本包是 MIT,不授予任何专利许可。 diff --git a/docs/known-gaps/jpeg.md b/docs/known-gaps/jpeg.md index 8d5964b..bcf75ac 100644 --- a/docs/known-gaps/jpeg.md +++ b/docs/known-gaps/jpeg.md @@ -47,7 +47,7 @@ | 功能 | 状态 | 说明 | |---|:-:|---| -| 原生缩放解码 1/2、1/4、1/8 | ✅ | DCT 域缩放,不生成全分辨率平面;1/4 的缩减 IDCT 还是标量 | +| 原生缩放解码 1/2、1/4、1/8 | ✅ | DCT 域缩放,不生成全分辨率平面;1/4 的缩减 IDCT 还是标量;1/16 及更小先按 1/8 解码,再 box 缩小 | | 区域解码 | ⚠️ | 只保留 MCU 行条带和区域本身,但每个 MCU 仍要熵解码和 IDCT | | `TryDecodeInto` 直写调用方缓冲区 | ✅ | | | 已调优 | ✅ | 446 个文件解码到 RGBA 约为 libjpeg-turbo 的 0.82×;编码有质量门槛 `encode --gate` | diff --git a/docs/known-gaps/jpeg2000.md b/docs/known-gaps/jpeg2000.md index 754a10b..c898a30 100644 --- a/docs/known-gaps/jpeg2000.md +++ b/docs/known-gaps/jpeg2000.md @@ -38,7 +38,7 @@ | 功能 | 状态 | 说明 | |---|:-:|---| -| 原生缩放(按分辨率级别缩减) | ✅ | 省略的级别不做熵解码;SIZ 偏移非零的图只支持 1:1 | +| 原生缩放(按分辨率级别缩减) | ✅ | 省略的级别不做熵解码;超出级数的分母先按最大缩减解码,再 box 缩小;SIZ 偏移非零的图没有原生缩减(整幅解码后 box 缩小) | | 区域解码 | ⚠️ | 整幅解码再裁剪 | | `TryDecodeInto` 直写调用方缓冲区 | ⚠️ | 先解码整幅再转换 | | tile 内多核并行 | ✅ | 结果与串行逐位相同 | @@ -57,6 +57,6 @@ - **分量位深最多 16 位,且所有分量位深必须相同**;经调色板映射后的各通道位深也必须相同。 - **JP2 通道定义盒(`cdef`)已支持**:颜色通道按色彩空间顺序(Asoc 1、2、3……)重排,不透明通道放在最后,`Jpeg2000Image.Channels` / `Jpeg2000Info.Channels` 报告每个通道的类型;Typ 2(预乘不透明度)由门面用整数公式还原,没有 `cdef` 的 4 分量图(可能是 CMYK)仍然拒绝。 - **门面只呈现灰度(1)、灰度 + alpha(2)、RGB(3)、RGB + alpha(4)、且不是 sYCC 的图像**;其他情形门面返回 `false`,`Jpeg2000Decoder.Decode` 仍会返回全部通道:没有 `cdef` 的 2 或 4 分量、sRGB / 灰度以外的枚举色彩空间(CMYK、CIELab 等)、只作用于某一颜色的不透明通道、多个不透明通道、用途未指定(Typ 65535)的通道、色彩空间与颜色通道数不符。 -- **门面的缩放(`ScaleDenominator`)就是分辨率缩减**:支持不超过 2^分解层数 的 2 的幂,与 `opj_decompress -r` 一致,被省略的层不做熵解码;SIZ 图像偏移非零的图只支持 1:1(参考网格上的取整可能与 ⌈w/d⌉ 差一个像素),此时仍可直接调用 `Jpeg2000Decoder.Decode(data, reduce)`。 +- **门面的缩放(`ScaleDenominator`)在原生范围内就是分辨率缩减**:不超过 2^分解层数 的 2 的幂与 `opj_decompress -r` 一致,被省略的层不做熵解码;更大的分母先按最大缩减解码,再用 box 滤波缩小。SIZ 图像偏移非零的图没有原生缩减(参考网格上的取整可能与 ⌈w/d⌉ 差一个像素),每个分母都是整幅解码后 box 缩小;需要参考网格上的缩减时仍可直接调用 `Jpeg2000Decoder.Decode(data, reduce)`。 - **真实文件回归**:issue #1 附带的 16 个 JPEG 2000 文件(来源和许可不明,不进仓库)全部与 `opj_decompress` 一致(5/3 逐样本相同,9/7 最多差 1)。改动解码器后按 `Issue1Jpeg2000CorpusTests` 的说明下载并跑一遍。 - **已有基准测试,单线程性能仍待调优**:基准项目 `Sdcb.SimdCodecs.Jpeg2000.Benchmarks` 在 `results/` 记录了基线与多线程结果。单线程纯托管解码耗时约为 libopenjp2 纯 C 核心的 1.9×~2.2×;解码默认并行(同一 tile 内的 code-block、小波的行与列条带、分量变换、电平移位按逻辑处理器数分配线程,结果与串行逐位相同),16 线程时约为 libopenjp2(同样 16 线程)的 1.15×~1.75×。EBCOT 条带位掩码快速跳过与系数重构缓冲区复用尚未实现;tile 之间仍串行解码(内存上限是一个 tile 的工作集)。 diff --git a/docs/known-gaps/netpbm.md b/docs/known-gaps/netpbm.md index d399e6e..6eb09d1 100644 --- a/docs/known-gaps/netpbm.md +++ b/docs/known-gaps/netpbm.md @@ -29,7 +29,7 @@ | 功能 | 状态 | 说明 | |---|:-:|---| -| 原生缩放解码 | ❌ | | +| 原生缩放解码 | ❌ | 可用,整幅解码后 box 缩小 | | 区域解码 | ⚠️ | 整幅解码再裁剪 | | 已调优(解码和编码) | ✅ | 只和 ImageSharp 比过:解码约 0.23×、编码约 0.54× | diff --git a/docs/known-gaps/png.md b/docs/known-gaps/png.md index 9a526b4..cc54f19 100644 --- a/docs/known-gaps/png.md +++ b/docs/known-gaps/png.md @@ -46,8 +46,8 @@ | 功能 | 状态 | 说明 | |---|:-:|---| -| 原生缩放解码 | ❌ | | -| 区域解码 | ⚠️ | 整幅解码再裁剪 | +| 原生缩放 | ⚠️ | 非隔行图:边解码边按行 box 缩小,不生成全尺寸图,内存约为输出加几行。Adam7 隔行图仍整幅解码后缩小 | +| 区域按需 | ⚠️ | 非隔行图:只解到区域(或缩放 box)需要的最后一行就停,只反滤波到区域最右一列,只展开区域内的列。区域上方的行仍要解压和反滤波(PNG 的行彼此依赖)。Adam7 隔行图仍整幅解码后裁剪 | | 已调优(编码和解码) | ✅ | 见下方基准链接 | ## 已知缺口与局限 @@ -60,5 +60,7 @@ - 选择矩形 / dispose / blend 用的是对每个候选做一次快速 deflate 来估算大小,不是穷举最优。 - **编码器不能写 Adam7 隔行图**(解码支持)。 - **不读取 `bKGD`、`sBIT`、`tIME` 等辅助块。** 目前读取的有:`PLTE`、`tRNS`、`gAMA`、`cHRM`、`sRGB`、`iCCP`、 `cICP`、`mDCV`、`cLLI`、`pHYs`、`eXIf`、`tEXt`、`zTXt`、`iTXt`。 +- **区域 / 缩放解码在最后需要的一行之后就停止,不校验其下的图像数据。** 非隔行图带 `Region` 或 `ScaleDenominator` 解码时,下方损坏或截断的 zlib 数据不会被读到,解码照常成功;整幅解码则会返回 `false`。各块的 CRC 仍在读块头时全部校验;区域一直到最后一行时,也和整幅解码一样检查数据流的结尾。区域外各列的调色板索引同样不检查。输出与“整幅解码 → box 缩小 → 裁剪 → 转换像素格式”逐字节相同(`PngRegionDecodeTests`)。 +- **`TryGetClosestSize` 仍只报存储尺寸**:任何 2 的幂缩放都能用,非隔行图也不生成全尺寸图,但缩放并不比整幅解码更快(box 缩小要处理每一行源数据),所以不把缩放尺寸当作“便宜的原生尺寸”报出。 - **cICP 标为 PQ / HLG 的 HDR 图像,`TryDecodeInto` 返回 `false`**:它们没有正确的 8 位投影,请用 `TryDecode` 拿 16 位原始数据。 - 性能数据和与 libpng / ImageSharp 的逐项对比见 [PNG 基准结果](../../benchmarks/Sdcb.SimdCodecs.Png.Benchmarks/results/)。 diff --git a/docs/known-gaps/qoi.md b/docs/known-gaps/qoi.md index f770250..2610b00 100644 --- a/docs/known-gaps/qoi.md +++ b/docs/known-gaps/qoi.md @@ -10,7 +10,7 @@ | 色彩空间字节(sRGB / 线性) | ✅ | 报告给 `ColorEncoding`,不改变像素 | | `TryDecodeInto` 直写调用方缓冲区 | ✅ | 任意 8 位布局,零分配 | | 编码(RGBA、BGRA、RGB、BGR、灰度输入,任意行跨度) | ✅ | 到数组或流 | -| 原生缩放解码 | ❌ | | +| 原生缩放解码 | ❌ | 可用,整幅解码后 box 缩小 | | 区域解码 | ⚠️ | 整幅解码再裁剪 | | 已调优(解码和编码) | ✅ | 只和 ImageSharp 比过:解码约 0.29×、编码约 0.49× | diff --git a/docs/known-gaps/tiff.md b/docs/known-gaps/tiff.md index b264e33..ccc92e9 100644 --- a/docs/known-gaps/tiff.md +++ b/docs/known-gaps/tiff.md @@ -63,7 +63,7 @@ |---|:-:|---| | 区域解码只读相交的条带 / 分块 | ✅ | | | `TryDecodeInto` 直写调用方缓冲区 | ⚠️ | 整数页面按条带写入;浮点页面不投影到 8 位 | -| 原生缩放解码 | ❌ | | +| 原生缩放解码 | ❌ | 可用,整幅解码后 box 缩小 | | 已调优 | ❌ | | ## 已知缺口与局限 diff --git a/docs/known-gaps/webp.md b/docs/known-gaps/webp.md index 63e80d1..f3cff6a 100644 --- a/docs/known-gaps/webp.md +++ b/docs/known-gaps/webp.md @@ -37,8 +37,8 @@ | 功能 | 状态 | 说明 | |---|:-:|---| -| 原生缩放解码 | ❌ | | -| 区域解码 | ⚠️ | 整幅解码再裁剪 | +| 原生缩放解码 | ⚠️ | 仅有损(VP8)静态图:解码时逐行做 box 缩小,不生成全尺寸图;无损、动画仍是整幅解码后 box 缩小 | +| 区域按需解码 | ⚠️ | 仅有损静态图:只转换区域覆盖的行和列,区域最后一行所在的宏块行之后提前停止;ALPH 平面仍整幅解码。无损、动画仍是整幅解码再裁剪 | | 解码已调优 | ✅ | 照片类不透明有损与 libwebp 持平(0.87–1.13×),其他类别更快 | | 编码已调优 | ✅ | 默认设置下无损和有损的体积都小于 libwebp,速度相当;个别带透明的图仍慢约 10% | @@ -49,5 +49,6 @@ - **有损编码**:AMD Ryzen 7 5800X 上 12 张不透明基准图,默认力度(4)对 libwebp 默认(method 4)的 BD-rate 为 PSNR-Y −6.2%、SSIM-Y −4.8%,每张图两项都为负;q75 速度约为它的 1.07×(18.6 vs 17.4 MP/s)。高码率一端 (q ≥ 70)也是 −5.4% / −5.5%。还没有 trellis 量化。 - **有损 + 透明**:3 张带透明的基准图,可见像素 PSNR-RGB 的 BD-rate 为 −4.5%(alpha 逐字节一致),但 sticker 一张 是 +0.8%(ALPH 比 libwebp 大约 3%)。q75 时三张合计耗时约为 libwebp 的 0.96×;photo-alpha 和 screen-alpha 更快, sticker 仍慢约 10%(它的 alpha 平面要靠预测器,直接编码那一路的代价解析最贵)。 - **无损编码**:AMD Ryzen 7 5800X 上 14 张基准图合计,默认力度(6)比 libwebp 默认小 1.4%,速度约为它的 1.14× (2.92 vs 2.56 MP/s);力度 9 小 1.7%,但只有它速度的 0.37×。`cwebp -z 9` 仍比我们的力度 9 小 1.1%(它慢约 7 倍)。 +- **有损静态图的区域 / 缩放解码提前停止,不校验其余码流。** 设了 `Region` 或 `ScaleDenominator` 时,有损(VP8)静态图逐个宏块行解码,色度上采样仍按整行做(fancy 上采样要读相邻列),YUV → RGB 只转换区域的列,再按 `TryDecode` 的布局(不透明 RGB,带透明 RGBA)送进 box 滤波器,最后转换成目标像素格式,所以输出与整幅解码、box 缩小、裁剪、转换逐字节一致。区域下方的宏块行不做熵解码,那里损坏或截断的码流不会报错,整幅解码则能解出(截断部分读作 0 比特)。帧头、分区表等区域之上的错误照常返回 false。ALPH 平面仍整幅解码,带透明时多占宽 × 高字节。AMD Ryzen 7 5800X 上 1920×1080:顶部 640×270 区域约为整幅解码再裁剪的 0.18×,底部约 0.72–0.83×,1/2–1/8 缩放约 0.68–0.93×;工作内存从约 15 MiB 降到 0.3 MiB(带透明 3.1 MiB,即 alpha 平面),见 [基准结果](../../benchmarks/Sdcb.SimdCodecs.Webp.Benchmarks/optimization/scaled-region-2026-11-08-amd-ryzen-7-5800x.md)。`TryGetClosestSize` 仍返回存储尺寸。 - **保留的 VP8 色彩空间标志不支持**(规范保留值)。 - **性能**:在接近照片内容、不带 alpha 的有损文件上与 libwebp 持平(0.87–1.13×),没有全面领先;其他类别更快, 见 [WebP 基准结果](../../benchmarks/Sdcb.SimdCodecs.Webp.Benchmarks/optimization/m4-final-2026-10-25-amd-ryzen-7-5800x.md)。 diff --git a/docs/migrating-to-2.0.md b/docs/migrating-to-2.0.md index 9b42bbe..f93ef6c 100644 --- a/docs/migrating-to-2.0.md +++ b/docs/migrating-to-2.0.md @@ -27,7 +27,7 @@ Code that `switch`ed over `ImagePixelFormat` switches over `format.Layout` (`Cha ## New in 2.0 - 16-bit and premultiplied output: `PixelFormat.Rgba64`, `Gray16`, `Bgra32Premultiplied`, … in every `TryDecodeInto`. -- `DecodeOptions`: region, scaled decode (JPEG 1/2–1/8), pooled memory; `TryGetClosestSize` negotiation. +- `DecodeOptions`: region, scaled decode (any power of two for every codec: JPEG 1/2–1/8 and JPEG 2000 resolution levels natively, the rest by a frozen box filter after the native decode), pooled memory; `TryGetClosestSize` negotiation (native sizes only). - Metadata: `TryReadMetadata` (Exif, XMP, ICC as raw blocks), `TryDecodeThumbnail`, `IDecodedImage.Metadata`. - `ImageDecoder` / `ImageFrame`: a session over a `Stream`, a file (memory-mapped) or memory, with `OpenAsync`. - `IImageEncoder` and `SimdCodecs.Encode` / `CreateEncoder`: every encoder through one API, every `PixelFormat` in. diff --git a/src/Sdcb.SimdCodecs.Abstractions/CodecSupport/BoxDownscaler.cs b/src/Sdcb.SimdCodecs.Abstractions/CodecSupport/BoxDownscaler.cs new file mode 100644 index 0000000..2a22708 --- /dev/null +++ b/src/Sdcb.SimdCodecs.Abstractions/CodecSupport/BoxDownscaler.cs @@ -0,0 +1,732 @@ +using System; +using System.Buffers; +using System.Numerics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Runtime.Intrinsics; + +namespace Sdcb.SimdCodecs.Abstractions.CodecSupport; + +/// +/// The frozen box filter behind beyond what a codec scales to natively, as a +/// streaming row accumulator: push the source rows top to bottom (); after every +/// rows, and after the last one, an output row is ready to take (). A decoder +/// can so downscale while it decodes, holding one output row of sums instead of the full-size image. +/// +/// The filter: output pixel (bx, by) averages the source pixels x ∈ [bx·d, min(bx·d + d, width)) of the rows +/// y ∈ [by·d, min(by·d + d, height)), d = ; boxes at the right and bottom edges are partial, and n is +/// the number of pixels in the clipped box. Without alpha (1 or 3 channels) each channel is (Σc + n/2) / n. With +/// alpha (2 or 4 channels, alpha last), A = Σa; alpha is (A + n/2) / n and each colour channel +/// (Σ(c·a) + A/2) / A, or 0 where A is 0: weighted by alpha, without rounding per pixel. Integer samples are +/// summed exactly, so the result does not depend on the order of the sums; it is computed on the stored code values +/// (no linearisation). sums in , row by row and left to right in +/// each box, and divides without rounding: Σc / n; with alpha A / n and Σ(c·a) / A (0 where A is 0). +/// +/// +/// A window of a larger image: push only columns x0 .. x0 + width − 1 of rows y0 .. y0 + height − 1, +/// where x0 and y0 are multiples of the factor and the window ends at a multiple of the factor or at the image's edge +/// ( computes it from a region of the scaled image). The output is then exactly that part +/// of the whole image's downscale. +/// +/// +public sealed class BoxDownscaler +{ + internal const int Scalar = 0; + internal const int Vector128Tier = 1; + internal const int Vector256Tier = 2; + + /// The fastest tier on this machine (the vector loops assume little-endian lane order). + internal static int BestTier => + !BitConverter.IsLittleEndian ? Scalar + : Vector256.IsHardwareAccelerated ? Vector256Tier + : Vector128.IsHardwareAccelerated ? Vector128Tier + : Scalar; + + // The largest box, in pixels. It keeps every sum inside 64 bits: Σ(c·a) ≤ 65535² · 2^32 = 2^64 − 2^49 + 2^32, and + // adding the rounding term A/2 ≤ 65535 · 2^31 < 2^47 still stays below 2^64. + private const long MaxBoxPixels = 1L << 32; + + // The column sums of the 8-bit path are 32-bit; it is taken while a box has at most this many rows: + // 255 · 255 · 65536 = 4 261 478 400 < 2^32 bounds a column of products c·a, and 255 · 65536 a column of samples. + private const int MaxRowsForColumnSums32 = 65536; + + private readonly int _tier; + private readonly int _samples; // samples per source row: Width × Channels + private readonly int _boxRows; // rows of a full box: min(Factor, Height) + private readonly bool _alpha; + private readonly uint[]? _columns32; // 8-bit path: per source sample, the sum over the box's rows so far + private readonly ulong[]? _boxes64; // other integer paths: per output sample, the box's sum so far + private readonly double[]? _boxesF; // Float32: per output sample, the box's sum so far + private int _rowsInBox; + private bool _ready; + + /// + /// Creates an accumulator for source rows of pixels each. + /// + /// Pixels per pushed row (> 0): the image width, or a column window's. + /// Rows to push (> 0). + /// Samples per pixel, 1..4; 2 and 4 carry alpha last. + /// The sample type, in host byte order as in . + /// The box side d (> 0); is a power of two. + /// An argument is out of range, a source row holds more than + /// bytes, or a box would hold more than 2^32 pixels. + public BoxDownscaler(int width, int height, int channels, SampleFormat sampleFormat, int factor) + : this(width, height, channels, sampleFormat, factor, BestTier) + { + } + + /// The same, capped at (, , + /// ), so the tests can hold each tier against the scalar one. + internal BoxDownscaler(int width, int height, int channels, SampleFormat sampleFormat, int factor, int tier) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(width); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(height); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(factor); + if (channels is < 1 or > 4) + throw new ArgumentOutOfRangeException(nameof(channels), channels, "Channels must be 1..4."); + if (sampleFormat is not (SampleFormat.UInt8 or SampleFormat.UInt16 or SampleFormat.Float32)) + throw new ArgumentOutOfRangeException(nameof(sampleFormat), sampleFormat, "Not a defined sample format."); + if ((long)width * channels * RasterImage.BytesPerSample(sampleFormat) > Array.MaxLength) + throw new ArgumentOutOfRangeException(nameof(width), width, "A source row holds more than Array.MaxLength bytes."); + if ((long)Math.Min(factor, width) * Math.Min(factor, height) > MaxBoxPixels) + throw new ArgumentOutOfRangeException(nameof(factor), factor, "A box would hold more than 2^32 pixels."); + + Width = width; + Height = height; + Channels = channels; + SampleFormat = sampleFormat; + Factor = factor; + OutputWidth = (int)(((long)width + factor - 1) / factor); + OutputHeight = (int)(((long)height + factor - 1) / factor); + var bytesPerSample = RasterImage.BytesPerSample(sampleFormat); + SourceRowBytes = width * channels * bytesPerSample; // ≤ Array.MaxLength, checked above + OutputRowBytes = OutputWidth * channels * bytesPerSample; // ≤ SourceRowBytes + _tier = tier; + _samples = width * channels; + _boxRows = Math.Min(factor, height); + _alpha = channels is 2 or 4; + if (sampleFormat == SampleFormat.Float32) + _boxesF = new double[OutputWidth * channels]; + else if (sampleFormat == SampleFormat.UInt8 && _boxRows <= MaxRowsForColumnSums32) + _columns32 = new uint[_samples]; + else + _boxes64 = new ulong[OutputWidth * channels]; + } + + /// Pixels per pushed row. + public int Width { get; } + + /// Rows to push. + public int Height { get; } + + /// Samples per pixel; 2 and 4 carry alpha last. + public int Channels { get; } + + /// The sample type. + public SampleFormat SampleFormat { get; } + + /// The box side d. + public int Factor { get; } + + /// Pixels per output row: ⌈Width / Factor⌉. + public int OutputWidth { get; } + + /// Output rows: ⌈Height / Factor⌉. + public int OutputHeight { get; } + + /// Bytes of a source row that reads: Width × Channels × bytesPerSample. + public int SourceRowBytes { get; } + + /// Bytes of an output row that writes: OutputWidth × Channels × bytesPerSample. + public int OutputRowBytes { get; } + + /// Source rows pushed so far. + public int RowsPushed { get; private set; } + + /// Output rows taken so far; the next writes output row RowsTaken. + public int RowsTaken { get; private set; } + + /// True once every output row has been taken. + public bool IsComplete => RowsTaken == OutputHeight; + + /// + /// Adds the next source row. Returns true when it completes an output row (every rows, and at the + /// last row): take it with before pushing again. + /// + /// The row's samples; only the first bytes are read. + /// is shorter than . + /// All rows have been pushed, or an output row is + /// waiting to be taken. + public bool PushRow(ReadOnlySpan row) + { + if (_ready) + throw new InvalidOperationException("An output row is waiting: call TakeRow before pushing the next source row."); + if (RowsPushed == Height) + throw new InvalidOperationException("Every source row has been pushed."); + if (row.Length < SourceRowBytes) + throw new ArgumentException($"The row holds {row.Length} bytes, fewer than the {SourceRowBytes} of a source row.", nameof(row)); + row = row[..SourceRowBytes]; + + if (_columns32 is not null) + AccumulateColumns8(row, _columns32, Channels, _tier); + else if (_boxesF is not null) + AccumulateFloat(MemoryMarshal.Cast(row)); + else if (SampleFormat == SampleFormat.UInt8) + AccumulateBoxes(row); + else + AccumulateBoxes(MemoryMarshal.Cast(row)); + + RowsPushed++; + _rowsInBox++; + _ready = _rowsInBox == _boxRows || RowsPushed == Height; + return _ready; + } + + /// + /// Writes the output row the last completed into (its first + /// bytes; the rest is untouched) and starts the next one. + /// + /// is shorter than . + /// No output row is ready. + public void TakeRow(Span destination) + { + if (!_ready) + throw new InvalidOperationException("No output row is ready: PushRow has not completed one."); + if (destination.Length < OutputRowBytes) + throw new ArgumentException($"The destination holds {destination.Length} bytes, fewer than the {OutputRowBytes} of an output row.", nameof(destination)); + destination = destination[..OutputRowBytes]; + + if (_columns32 is not null) + EmitColumns8(destination); + else if (_boxesF is not null) + EmitFloat(MemoryMarshal.Cast(destination)); + else if (SampleFormat == SampleFormat.UInt8) + EmitBoxes(destination); + else + EmitBoxes(MemoryMarshal.Cast(destination)); + + RowsTaken++; + _rowsInBox = 0; + _ready = false; + } + + /// + /// The source pixels the region of the image scaled by + /// averages: columns X·d .. min((X + Width)·d, width) − 1, rows likewise. Pushing that window gives exactly + /// the region. + /// + /// A region of the ⌈width / d⌉ × ⌈height / d⌉ scaled image. + /// The source width. + /// The source height. + /// The box side d (> 0). + /// does not lie inside the scaled image. + public static PixelRect GetSourceRect(PixelRect scaledRegion, int width, int height, int factor) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(factor); + var scaledWidth = (int)(((long)width + factor - 1) / factor); + var scaledHeight = (int)(((long)height + factor - 1) / factor); + if (!scaledRegion.FitsIn(scaledWidth, scaledHeight)) + throw new ArgumentException($"The region {scaledRegion} does not lie inside the {scaledWidth}x{scaledHeight} image.", nameof(scaledRegion)); + // X < ⌈width / d⌉ gives X·d < width, so x0 fits an int; x1 ≤ width. + var x0 = (int)((long)scaledRegion.X * factor); + var y0 = (int)((long)scaledRegion.Y * factor); + var x1 = (int)Math.Min(((long)scaledRegion.X + scaledRegion.Width) * factor, width); + var y1 = (int)Math.Min(((long)scaledRegion.Y + scaledRegion.Height) * factor, height); + return new PixelRect(x0, y0, x1 - x0, y1 - y0); + } + + /// + /// Downscales a whole image by with the box filter, or only the part of it that + /// (in the scaled image's coordinates) covers, into a new image (rented from + /// when it is not null) with the source's channels, sample format, bit depth, colour + /// signalling and metadata. The source is not disposed. + /// + /// does not lie inside the scaled image. + /// is not positive. + public static RasterImage Downscale(IDecodedImage source, int factor, PixelRect? scaledRegion = null, ArrayPool? pool = null) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(factor); + var region = scaledRegion ?? new PixelRect(0, 0, + (int)(((long)source.Width + factor - 1) / factor), (int)(((long)source.Height + factor - 1) / factor)); + return Downscale(source, factor, region, pool, BestTier); + } + + internal static RasterImage Downscale(IDecodedImage source, int factor, PixelRect scaledRegion, ArrayPool? pool, int tier) + { + var rect = GetSourceRect(scaledRegion, source.Width, source.Height, factor); + var box = new BoxDownscaler(rect.Width, rect.Height, source.Channels, source.SampleFormat, factor, tier); + var image = RasterImage.Create(scaledRegion.Width, scaledRegion.Height, source.Channels, source.SampleFormat, pool, + source.ColorEncoding, source.BitDepth == RasterImage.BytesPerSample(source.SampleFormat) * 8 ? 0 : source.BitDepth, + source.Metadata); + try + { + var bpp = source.Channels * RasterImage.BytesPerSample(source.SampleFormat); + var inRow = (long)source.Width * bpp; + var pixels = source.Pixels; + var output = image.WritablePixels; + for (var y = 0; y < rect.Height; y++) + { + if (box.PushRow(pixels.Slice((int)((rect.Y + y) * inRow + (long)rect.X * bpp), box.SourceRowBytes))) + box.TakeRow(output.Slice(box.RowsTaken * box.OutputRowBytes, box.OutputRowBytes)); + } + return image; + } + catch + { + image.Dispose(); + throw; + } + } + + // ---- 8-bit path: 32-bit column sums -------------------------------------------------------------------------- + + /// + /// Adds a row of 8-bit samples to the column sums: columns[i] += row[i], except that a colour sample of a + /// pixel with alpha (2 or 4 channels) adds c·a. A 256-bit loop, then a 128-bit one, then the scalar twin for + /// the rest (all of it at ); integer sums, so every tier gives the same sums. + /// + [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.AggressiveOptimization)] + internal static void AccumulateColumns8(ReadOnlySpan row, Span columns, int channels, int tier) + { + if (columns.Length < row.Length) + throw new ArgumentException("The column sums are shorter than the row.", nameof(columns)); + var n = row.Length; + ref var s = ref MemoryMarshal.GetReference(row); + ref var c = ref MemoryMarshal.GetReference(columns); + var i = 0; + + // In range: a 256-bit step at i reads row[i, i + 32) and reads and writes columns[i, i + 32), with i + 32 ≤ n ≤ + // columns.Length; a 128-bit step likewise with 16. Each step starts at a multiple of 16, which is a pixel + // boundary for 2 and 4 channels, so the alpha of every pixel in the step is in the step. + // Arithmetic: a product c·a ≤ 255² = 65025 fits a 16-bit lane; the column sum is bounded by the caller (at most + // MaxRowsForColumnSums32 rows, ≤ 65025 · 65536 < 2^32), and uint addition is exact below that. + if (tier >= Vector256Tier && Vector256.IsHardwareAccelerated && BitConverter.IsLittleEndian) + { + var (mask, one) = WeightConstants256(channels); + for (; i + 32 <= n; i += 32) + { + var (lo, hi) = Vector256.Widen(Vector256.LoadUnsafe(ref s, (nuint)i)); + if (channels is 2 or 4) + { + lo *= Weights256(lo, channels, mask, one); + hi *= Weights256(hi, channels, mask, one); + } + var (a, b) = Vector256.Widen(lo); + var (e, f) = Vector256.Widen(hi); + (Vector256.LoadUnsafe(ref c, (nuint)i) + a).StoreUnsafe(ref c, (nuint)i); + (Vector256.LoadUnsafe(ref c, (nuint)(i + 8)) + b).StoreUnsafe(ref c, (nuint)(i + 8)); + (Vector256.LoadUnsafe(ref c, (nuint)(i + 16)) + e).StoreUnsafe(ref c, (nuint)(i + 16)); + (Vector256.LoadUnsafe(ref c, (nuint)(i + 24)) + f).StoreUnsafe(ref c, (nuint)(i + 24)); + } + } + if (tier >= Vector128Tier && Vector128.IsHardwareAccelerated && BitConverter.IsLittleEndian) + { + var (mask, one) = WeightConstants128(channels); + for (; i + 16 <= n; i += 16) + { + var (lo, hi) = Vector128.Widen(Vector128.LoadUnsafe(ref s, (nuint)i)); + if (channels is 2 or 4) + { + lo *= Weights128(lo, channels, mask, one); + hi *= Weights128(hi, channels, mask, one); + } + var (a, b) = Vector128.Widen(lo); + var (e, f) = Vector128.Widen(hi); + (Vector128.LoadUnsafe(ref c, (nuint)i) + a).StoreUnsafe(ref c, (nuint)i); + (Vector128.LoadUnsafe(ref c, (nuint)(i + 4)) + b).StoreUnsafe(ref c, (nuint)(i + 4)); + (Vector128.LoadUnsafe(ref c, (nuint)(i + 8)) + e).StoreUnsafe(ref c, (nuint)(i + 8)); + (Vector128.LoadUnsafe(ref c, (nuint)(i + 12)) + f).StoreUnsafe(ref c, (nuint)(i + 12)); + } + } + AccumulateColumns8Scalar(row, columns, channels, i); + } + + /// The scalar twin of , from sample (a pixel boundary). + private static void AccumulateColumns8Scalar(ReadOnlySpan row, Span columns, int channels, int from) + { + columns = columns[..row.Length]; + if (channels is 1 or 3) + { + for (var i = from; i < row.Length; i++) + columns[i] += row[i]; + return; + } + for (var i = from; i < row.Length; i += channels) + { + uint a = row[i + channels - 1]; + for (var k = 0; k < channels - 1; k++) + columns[i + k] += row[i + k] * a; + columns[i + channels - 1] += a; + } + } + + // The per-lane multipliers of a widened row: each colour lane gets its pixel's alpha, each alpha lane 1. With lanes + // read as 64-bit (4 channels: one pixel per 64 bits) or 32-bit (2 channels) words, alpha is the top 16 bits: + // shifting it down and copying it up fills the word with it, and the mask puts 1 in the alpha lane. + private static (Vector128 Mask, Vector128 One) WeightConstants128(int channels) => channels == 4 + ? (Vector128.Create(0x0000_FFFF_FFFF_FFFFUL), Vector128.Create(0x0001_0000_0000_0000UL)) + : (Vector128.Create(0x0000_FFFF_0000_FFFFUL), Vector128.Create(0x0001_0000_0001_0000UL)); + + private static (Vector256 Mask, Vector256 One) WeightConstants256(int channels) => channels == 4 + ? (Vector256.Create(0x0000_FFFF_FFFF_FFFFUL), Vector256.Create(0x0001_0000_0000_0000UL)) + : (Vector256.Create(0x0000_FFFF_0000_FFFFUL), Vector256.Create(0x0001_0000_0001_0000UL)); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Vector128 Weights128(Vector128 v, int channels, Vector128 mask, Vector128 one) + { + Vector128 t; + if (channels == 4) + { + t = v.AsUInt64() >>> 48; + t |= t << 16; + t |= t << 32; + } + else + { + var u = v.AsUInt32() >>> 16; + t = (u | (u << 16)).AsUInt64(); + } + return ((t & mask) | one).AsUInt16(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Vector256 Weights256(Vector256 v, int channels, Vector256 mask, Vector256 one) + { + Vector256 t; + if (channels == 4) + { + t = v.AsUInt64() >>> 48; + t |= t << 16; + t |= t << 32; + } + else + { + var u = v.AsUInt32() >>> 16; + t = (u | (u << 16)).AsUInt64(); + } + return ((t & mask) | one).AsUInt16(); + } + + private void EmitColumns8(Span destination) + { + var columns = _columns32!.AsSpan(); + var ch = Channels; + if (ch is 3 or 4 && (long)Math.Min(Factor, Width) * _rowsInBox <= MaxBoxPixelsForSums32) + { + if (ch == 3) + EmitColumns8Rgb(columns, destination); + else + EmitColumns8Rgba(columns, destination); + columns.Clear(); + return; + } + var rows = (ulong)_rowsInBox; + Span sums = stackalloc ulong[4]; + for (var bx = 0; bx < OutputWidth; bx++) + { + // Box columns x0 .. x1 − 1: x0 = bx·d < Width, so it fits an int. + var x0 = (int)((long)bx * Factor); + var x1 = (int)Math.Min((long)x0 + Factor, Width); + sums.Clear(); + for (var i = x0 * ch; i < x1 * ch; i += ch) + { + for (var k = 0; k < ch; k++) + sums[k] += columns[i + k]; + } + var n = (ulong)(x1 - x0) * rows; + Finish(sums, ch, n, destination.Slice(bx * ch, ch)); + } + columns.Clear(); + } + + // EmitColumns8 for RGB and RGBA boxes of at most this many pixels, in 32 bits: a box sums at most 255 · 2^16 samples + // or, with alpha, 65025 · 2^16 = 4 261 478 400 products c·a, and with the rounding term A/2 ≤ 255 · 2^15 that is + // still below 2^32. The sums, roundings and quotients are then exactly Finish's 64-bit ones. + private const int MaxBoxPixelsForSums32 = 1 << 16; + + /// + /// for 3 channels and boxes of at most pixels: the same + /// (Σc + n/2) / n in 32 bits, as a shift for the full boxes when n is a power of two (d × d, or d × the rows of + /// the last box row). + /// + private void EmitColumns8Rgb(ReadOnlySpan columns, Span destination) + { + var rows = (uint)_rowsInBox; + var d = Factor; + var fullBoxes = Width / d; + var i = 0; + var o = 0; + if (fullBoxes > 0) + { + var n = (uint)d * rows; // ≤ MaxBoxPixelsForSums32: d ≤ Width here, so d = min(Factor, Width) + var half = n / 2; + var shift = BitOperations.IsPow2(n) ? BitOperations.Log2(n) : -1; + for (var bx = 0; bx < fullBoxes; bx++, o += 3) + { + uint r = 0, g = 0, b = 0; + for (var k = 0; k < d; k++, i += 3) + { + r += columns[i]; + g += columns[i + 1]; + b += columns[i + 2]; + } + if (shift >= 0) + { + destination[o] = (byte)((r + half) >> shift); + destination[o + 1] = (byte)((g + half) >> shift); + destination[o + 2] = (byte)((b + half) >> shift); + } + else + { + destination[o] = (byte)((r + half) / n); + destination[o + 1] = (byte)((g + half) / n); + destination[o + 2] = (byte)((b + half) / n); + } + } + } + if (fullBoxes < OutputWidth) + { + // The partial box at the right edge: Width − fullBoxes·d columns. + uint r = 0, g = 0, b = 0; + for (; i < Width * 3; i += 3) + { + r += columns[i]; + g += columns[i + 1]; + b += columns[i + 2]; + } + var n = (uint)(Width - fullBoxes * d) * rows; + destination[o] = (byte)((r + n / 2) / n); + destination[o + 1] = (byte)((g + n / 2) / n); + destination[o + 2] = (byte)((b + n / 2) / n); + } + } + + /// + /// for 4 channels and boxes of at most pixels: alpha + /// (A + n/2) / n (a shift for full power-of-two boxes) and each colour (Σ(c·a) + A/2) / A, 0 where A is + /// 0, in 32 bits. + /// + private void EmitColumns8Rgba(ReadOnlySpan columns, Span destination) + { + var rows = (uint)_rowsInBox; + var d = Factor; + var fullBoxes = Width / d; + var i = 0; + var o = 0; + if (fullBoxes > 0) + { + var n = (uint)d * rows; // ≤ MaxBoxPixelsForSums32, as in EmitColumns8Rgb + var half = n / 2; + var shift = BitOperations.IsPow2(n) ? BitOperations.Log2(n) : -1; + for (var bx = 0; bx < fullBoxes; bx++, o += 4) + { + uint r = 0, g = 0, b = 0, a = 0; + for (var k = 0; k < d; k++, i += 4) + { + r += columns[i]; + g += columns[i + 1]; + b += columns[i + 2]; + a += columns[i + 3]; + } + destination[o + 3] = shift >= 0 ? (byte)((a + half) >> shift) : (byte)((a + half) / n); + WriteWeightedColour(destination.Slice(o, 3), r, g, b, a); + } + } + if (fullBoxes < OutputWidth) + { + uint r = 0, g = 0, b = 0, a = 0; + for (; i < Width * 4; i += 4) + { + r += columns[i]; + g += columns[i + 1]; + b += columns[i + 2]; + a += columns[i + 3]; + } + var n = (uint)(Width - fullBoxes * d) * rows; + destination[o + 3] = (byte)((a + n / 2) / n); + WriteWeightedColour(destination.Slice(o, 3), r, g, b, a); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void WriteWeightedColour(Span rgb, uint r, uint g, uint b, uint a) + { + if (a == 0) + { + rgb[0] = rgb[1] = rgb[2] = 0; + return; + } + var half = a / 2; + rgb[0] = (byte)((r + half) / a); + rgb[1] = (byte)((g + half) / a); + rgb[2] = (byte)((b + half) / a); + } + + // ---- Other integer paths: 64-bit box sums --------------------------------------------------------------------- + + private void AccumulateBoxes(ReadOnlySpan row) + { + var boxes = _boxes64!.AsSpan(); + var ch = Channels; + for (var bx = 0; bx < OutputWidth; bx++) + { + var x0 = (int)((long)bx * Factor); + var x1 = (int)Math.Min((long)x0 + Factor, Width); + var box = boxes.Slice(bx * ch, ch); + for (var i = x0 * ch; i < x1 * ch; i += ch) + { + if (_alpha) + { + ulong a = row[i + ch - 1]; + for (var k = 0; k < ch - 1; k++) + box[k] += row[i + k] * a; + box[ch - 1] += a; + } + else + { + for (var k = 0; k < ch; k++) + box[k] += row[i + k]; + } + } + } + } + + private void AccumulateBoxes(ReadOnlySpan row) + { + var boxes = _boxes64!.AsSpan(); + var ch = Channels; + for (var bx = 0; bx < OutputWidth; bx++) + { + var x0 = (int)((long)bx * Factor); + var x1 = (int)Math.Min((long)x0 + Factor, Width); + var box = boxes.Slice(bx * ch, ch); + for (var i = x0 * ch; i < x1 * ch; i += ch) + { + if (_alpha) + { + // c·a ≤ 65535² < 2^32, and a box sums at most 2^32 of them (MaxBoxPixels). + ulong a = row[i + ch - 1]; + for (var k = 0; k < ch - 1; k++) + box[k] += row[i + k] * a; + box[ch - 1] += a; + } + else + { + for (var k = 0; k < ch; k++) + box[k] += row[i + k]; + } + } + } + } + + private void EmitBoxes(Span destination) + { + var boxes = _boxes64!.AsSpan(); + var ch = Channels; + for (var bx = 0; bx < OutputWidth; bx++) + { + var x0 = (long)bx * Factor; + var n = (ulong)(Math.Min(x0 + Factor, Width) - x0) * (ulong)_rowsInBox; + Finish(boxes.Slice(bx * ch, ch), ch, n, destination.Slice(bx * ch, ch)); + } + boxes.Clear(); + } + + private void EmitBoxes(Span destination) + { + var boxes = _boxes64!.AsSpan(); + var ch = Channels; + for (var bx = 0; bx < OutputWidth; bx++) + { + var x0 = (long)bx * Factor; + var n = (ulong)(Math.Min(x0 + Factor, Width) - x0) * (ulong)_rowsInBox; + Finish(boxes.Slice(bx * ch, ch), ch, n, destination.Slice(bx * ch, ch)); + } + boxes.Clear(); + } + + // The rounded averages of one box from its sums (n pixels). Without alpha (Σc + n/2) / n; with alpha (last sum A = Σa) + // (A + n/2) / n, and (Σ(c·a) + A/2) / A for each colour, 0 when A is 0. No sum overflows (MaxBoxPixels), and each + // quotient is at most the sample maximum, since Σc ≤ max·n and Σ(c·a) ≤ max·A. + private static void Finish(ReadOnlySpan sums, int channels, ulong n, Span output) + { + if (channels is 1 or 3) + { + for (var k = 0; k < channels; k++) + output[k] = (byte)((sums[k] + n / 2) / n); + return; + } + var a = sums[channels - 1]; + output[channels - 1] = (byte)((a + n / 2) / n); + for (var k = 0; k < channels - 1; k++) + output[k] = a == 0 ? (byte)0 : (byte)((sums[k] + a / 2) / a); + } + + private static void Finish(ReadOnlySpan sums, int channels, ulong n, Span output) + { + if (channels is 1 or 3) + { + for (var k = 0; k < channels; k++) + output[k] = (ushort)((sums[k] + n / 2) / n); + return; + } + var a = sums[channels - 1]; + output[channels - 1] = (ushort)((a + n / 2) / n); + for (var k = 0; k < channels - 1; k++) + output[k] = a == 0 ? (ushort)0 : (ushort)((sums[k] + a / 2) / a); + } + + // ---- Float32: double box sums, row-major ----------------------------------------------------------------------- + + private void AccumulateFloat(ReadOnlySpan row) + { + var boxes = _boxesF!.AsSpan(); + var ch = Channels; + for (var bx = 0; bx < OutputWidth; bx++) + { + var x0 = (int)((long)bx * Factor); + var x1 = (int)Math.Min((long)x0 + Factor, Width); + var box = boxes.Slice(bx * ch, ch); + for (var i = x0 * ch; i < x1 * ch; i += ch) + { + if (_alpha) + { + double a = row[i + ch - 1]; + for (var k = 0; k < ch - 1; k++) + box[k] += row[i + k] * a; + box[ch - 1] += a; + } + else + { + for (var k = 0; k < ch; k++) + box[k] += row[i + k]; + } + } + } + } + + private void EmitFloat(Span destination) + { + var boxes = _boxesF!.AsSpan(); + var ch = Channels; + for (var bx = 0; bx < OutputWidth; bx++) + { + var x0 = (long)bx * Factor; + var n = (double)((Math.Min(x0 + Factor, Width) - x0) * _rowsInBox); + var sums = boxes.Slice(bx * ch, ch); + var output = destination.Slice(bx * ch, ch); + if (!_alpha) + { + for (var k = 0; k < ch; k++) + output[k] = (float)(sums[k] / n); + continue; + } + var a = sums[ch - 1]; + output[ch - 1] = (float)(a / n); + for (var k = 0; k < ch - 1; k++) + output[k] = a == 0 ? 0f : (float)(sums[k] / a); + } + boxes.Clear(); + } +} diff --git a/src/Sdcb.SimdCodecs.Abstractions/CodecSupport/DecodeOptionsChecks.cs b/src/Sdcb.SimdCodecs.Abstractions/CodecSupport/DecodeOptionsChecks.cs index 77a06c3..853d53d 100644 --- a/src/Sdcb.SimdCodecs.Abstractions/CodecSupport/DecodeOptionsChecks.cs +++ b/src/Sdcb.SimdCodecs.Abstractions/CodecSupport/DecodeOptionsChecks.cs @@ -1,41 +1,27 @@ using System; +using System.Numerics; namespace Sdcb.SimdCodecs.Abstractions.CodecSupport; /// -/// What a codec does with the it is given: check them against the image, and apply a -/// region it cannot decode natively. Extension members, so a codec writes options?.Validate(…) and -/// DecodeOptions.Apply(…); they stay out of a consumer's view unless this namespace is imported. +/// What a codec does with the it is given: check them against the image, pick the scale it +/// decodes at natively, and apply the rest of the scale and a region it cannot decode natively. Extension members, so a +/// codec writes options?.Validate(…) and DecodeOptions.Apply(…); they stay out of a consumer's view unless +/// this namespace is imported. /// public static class DecodeOptionsChecks { extension(DecodeOptions options) { /// - /// Checks the options against a stored × image, for a - /// decoder that scales natively by 2, 4 and 8 () or only at 1. + /// Checks the options against a stored × image: the + /// must lie inside the image at . + /// Every scale is accepted (what a codec does not produce natively, DecodeOptions.Apply(image, options, nativeScale) + /// box-filters). /// - /// The decoder cannot produce . /// does not lie inside the scaled image. - public void Validate(int width, int height, bool supportsScale) => - options.Validate(width, height, supportsScale ? 8 : 1); - - /// - /// Checks the options against a stored × image, for a - /// decoder that scales natively by every power of two up to (1: it - /// does not scale). - /// - /// The decoder cannot produce . - /// does not lie inside the scaled image. - public void Validate(int width, int height, int maxScaleDenominator) + public void Validate(int width, int height) { - var scale = options.ScaleDenominator; - if (scale > maxScaleDenominator) - { - throw new NotSupportedException(maxScaleDenominator <= 1 - ? $"This decoder does not scale natively; it cannot decode at 1/{scale}. Ask TryGetClosestSize, then scale the result." - : $"This decoder scales natively by up to 1/{maxScaleDenominator}; it cannot decode at 1/{scale}. Ask TryGetClosestSize, then scale the result."); - } if (options.Region is { } r) { var (w, h) = options.GetScaledSize(width, height); @@ -43,27 +29,61 @@ public void Validate(int width, int height, int maxScaleDenominator) throw new ArgumentException($"The region {r} does not lie inside the {w}x{h} image.", nameof(options.Region)); } } + + /// + /// The scale to decode at natively, for a decoder that scales natively by every power of two up to + /// (1: it does not scale): the largest power of two that is at most + /// both that and . Pass the decode at it to + /// DecodeOptions.Apply(image, options, nativeScale), which box-filters the rest. + /// + /// is not positive. + public int GetNativeScale(int maxNativeScaleDenominator) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxNativeScaleDenominator); + return Math.Min(options.ScaleDenominator, 1 << BitOperations.Log2((uint)maxNativeScaleDenominator)); + } } extension(DecodeOptions) { /// - /// The generic region path for a decoder without native region decoding: checks the options (at scale 1 - /// only), then crops — the whole decoded image — to , - /// disposing it. Without a region it returns itself. + /// The generic path for a decoder without native scaling or region decoding: DecodeOptions.Apply(image, options, nativeScale) + /// for , the whole image at full size. + /// + /// does not lie inside the scaled image. + public static IDecodedImage Apply(IDecodedImage image, DecodeOptions? options) => Apply(image, options, 1); + + /// + /// The generic scale and region path: is the whole image decoded at + /// 1/ (see GetNativeScale). Box-filters it by the rest of + /// (), then crops it to + /// (in the scaled image's coordinates), disposing it. Without either it returns + /// itself. /// - /// is not 1. - /// does not lie inside the image. - public static IDecodedImage Apply(IDecodedImage image, DecodeOptions? options) + /// is not a power of two that divides + /// . + /// does not lie inside the scaled image. + public static IDecodedImage Apply(IDecodedImage image, DecodeOptions? options, int nativeScale) { ArgumentNullException.ThrowIfNull(image); if (options is null) return image; - options.Validate(image.Width, image.Height, supportsScale: false); - if (options.Region is not { } r) - return image; + if (nativeScale <= 0 || (nativeScale & (nativeScale - 1)) != 0 || nativeScale > options.ScaleDenominator) + throw new ArgumentOutOfRangeException(nameof(nativeScale), nativeScale, "The native scale is a power of two no larger than ScaleDenominator."); + var factor = options.ScaleDenominator / nativeScale; + var w = (int)(((long)image.Width + factor - 1) / factor); + var h = (int)(((long)image.Height + factor - 1) / factor); + if (options.Region is { } r && !r.FitsIn(w, h)) + throw new ArgumentException($"The region {r} does not lie inside the {w}x{h} image.", nameof(options.Region)); + if (factor == 1) + { + if (options.Region is not { } region) + return image; + using (image) + return RasterImage.Crop(image, region, options.Pool); + } using (image) - return RasterImage.Crop(image, r, options.Pool); + return BoxDownscaler.Downscale(image, factor, options.Region, options.Pool); } } } diff --git a/src/Sdcb.SimdCodecs.Abstractions/CodecSupport/ImageDecoderFallback.cs b/src/Sdcb.SimdCodecs.Abstractions/CodecSupport/ImageDecoderFallback.cs index 6475249..4d57c39 100644 --- a/src/Sdcb.SimdCodecs.Abstractions/CodecSupport/ImageDecoderFallback.cs +++ b/src/Sdcb.SimdCodecs.Abstractions/CodecSupport/ImageDecoderFallback.cs @@ -52,7 +52,7 @@ public static bool TryDecodeFrameInto(IImageFrameReader reader, int index, Span< /// /// for a codec that does not scale natively: the stored size, at - /// scale 1, whatever was requested. + /// scale 1, whatever was requested (other scales are accepted, but cost a full decode and the box filter). /// /// The codec's . /// The encoded image. diff --git a/src/Sdcb.SimdCodecs.Abstractions/DecodeOptions.cs b/src/Sdcb.SimdCodecs.Abstractions/DecodeOptions.cs index ac9fed1..b66ae6e 100644 --- a/src/Sdcb.SimdCodecs.Abstractions/DecodeOptions.cs +++ b/src/Sdcb.SimdCodecs.Abstractions/DecodeOptions.cs @@ -19,10 +19,11 @@ public bool FitsIn(int width, int height) => /// What a decode should produce besides the default — the whole image at full size, in freshly allocated memory. /// /// Size negotiation works as in WIC: ask the decoder what it can do natively -/// (), then request exactly that. A decoder never falls back silently: -/// a it cannot produce is refused with , so the -/// caller knows to scale the result itself. A works with every decoder; one without native region -/// decoding decodes the whole image and crops it, with the same result and no speed-up. +/// (), then request exactly that for the cheapest decode. Every decoder +/// accepts every , though: one beyond its native scales decodes at the largest native +/// scale and applies the frozen box filter described there for the rest. A works with every +/// decoder; one without native region decoding decodes the whole image and crops it, with the same result and no +/// speed-up. /// /// public sealed record DecodeOptions @@ -34,9 +35,21 @@ public sealed record DecodeOptions /// /// Decode at 1 / ScaleDenominator of the stored size in each direction: a power of two, 1 to 2^30. The - /// scaled size is ⌈width / d⌉ × ⌈height / d⌉. Each codec accepts the denominators it produces natively and - /// throws for the others (JPEG: up to 8, in the DCT domain; codecs that do - /// not scale: 1 only); ask which one to use. + /// scaled size is ⌈width / d⌉ × ⌈height / d⌉. Every codec accepts every denominator. Those it produces + /// natively are cheap (JPEG: up to 8, in the DCT domain; JPEG 2000: up to 2 to its decomposition levels; other + /// codecs: 1 only), and reports them. A larger one decodes at the + /// largest native scale n, then reduces that image by the box filter of + /// with box side d / n. + /// + /// The box filter is frozen. Output pixel (bx, by) averages the input pixels x ∈ [bx·k, min(bx·k + k, w)) of the + /// rows y ∈ [by·k, min(by·k + k, h)), k the box side; boxes at the right and bottom edges are partial, n is the + /// number of pixels in the clipped box. It works on the stored code values (gamma space, no linearisation). Without + /// alpha each channel is (Σc + n/2) / n in integers. With alpha, A = Σa: alpha is (A + n/2) / n and + /// each colour channel (Σ(c·a) + A/2) / A, or 0 where A is 0 (weighted by alpha, without rounding per + /// pixel). 8- and 16-bit sums are exact, so every SIMD tier gives the same bytes. Float32 samples sum in double, + /// row by row and left to right in each box: Σc / n, with alpha A / n and Σ(c·a) / A (0 where A + /// is 0). The bit depth, colour encoding and metadata are kept. A applies after the scale. + /// /// /// Set to a value that is not a power of two from 1 to 2^30. public int ScaleDenominator diff --git a/src/Sdcb.SimdCodecs.Abstractions/IImageDecoder.cs b/src/Sdcb.SimdCodecs.Abstractions/IImageDecoder.cs index 7ab2554..1d2f37c 100644 --- a/src/Sdcb.SimdCodecs.Abstractions/IImageDecoder.cs +++ b/src/Sdcb.SimdCodecs.Abstractions/IImageDecoder.cs @@ -48,7 +48,10 @@ public interface IImageDecoder /// The size closest to × that this codec can /// decode to natively — never smaller than requested in either direction unless the full size is — and the /// that produces it. A codec that does not scale natively reports the - /// stored size and 1, so the caller knows to scale the result itself. False when the header is unreadable. + /// stored size and 1. Only native sizes are reported, because only they are cheap: every codec accepts every power + /// of two as , but one beyond its native scales costs a decode at the + /// largest native scale and the box filter of . False when the header is + /// unreadable. /// static abstract bool TryGetClosestSize(ReadOnlySpan data, int requestedWidth, int requestedHeight, out int width, out int height, out int scaleDenominator); diff --git a/src/Sdcb.SimdCodecs.Abstractions/PublicAPI.Unshipped.txt b/src/Sdcb.SimdCodecs.Abstractions/PublicAPI.Unshipped.txt index 2878f8a..58cc2b0 100644 --- a/src/Sdcb.SimdCodecs.Abstractions/PublicAPI.Unshipped.txt +++ b/src/Sdcb.SimdCodecs.Abstractions/PublicAPI.Unshipped.txt @@ -1,3 +1,27 @@ #nullable enable -Sdcb.SimdCodecs.Abstractions.CodecSupport.DecodeOptionsChecks.extension(Sdcb.SimdCodecs.Abstractions.DecodeOptions!).Validate(int width, int height, int maxScaleDenominator) -> void -static Sdcb.SimdCodecs.Abstractions.CodecSupport.DecodeOptionsChecks.Validate(this Sdcb.SimdCodecs.Abstractions.DecodeOptions! options, int width, int height, int maxScaleDenominator) -> void +Sdcb.SimdCodecs.Abstractions.CodecSupport.BoxDownscaler +Sdcb.SimdCodecs.Abstractions.CodecSupport.BoxDownscaler.BoxDownscaler(int width, int height, int channels, Sdcb.SimdCodecs.Abstractions.SampleFormat sampleFormat, int factor) -> void +Sdcb.SimdCodecs.Abstractions.CodecSupport.BoxDownscaler.Channels.get -> int +Sdcb.SimdCodecs.Abstractions.CodecSupport.BoxDownscaler.Factor.get -> int +Sdcb.SimdCodecs.Abstractions.CodecSupport.BoxDownscaler.Height.get -> int +Sdcb.SimdCodecs.Abstractions.CodecSupport.BoxDownscaler.IsComplete.get -> bool +Sdcb.SimdCodecs.Abstractions.CodecSupport.BoxDownscaler.OutputHeight.get -> int +Sdcb.SimdCodecs.Abstractions.CodecSupport.BoxDownscaler.OutputRowBytes.get -> int +Sdcb.SimdCodecs.Abstractions.CodecSupport.BoxDownscaler.OutputWidth.get -> int +Sdcb.SimdCodecs.Abstractions.CodecSupport.BoxDownscaler.PushRow(System.ReadOnlySpan row) -> bool +Sdcb.SimdCodecs.Abstractions.CodecSupport.BoxDownscaler.RowsPushed.get -> int +Sdcb.SimdCodecs.Abstractions.CodecSupport.BoxDownscaler.RowsTaken.get -> int +Sdcb.SimdCodecs.Abstractions.CodecSupport.BoxDownscaler.SampleFormat.get -> Sdcb.SimdCodecs.Abstractions.SampleFormat +Sdcb.SimdCodecs.Abstractions.CodecSupport.BoxDownscaler.SourceRowBytes.get -> int +Sdcb.SimdCodecs.Abstractions.CodecSupport.BoxDownscaler.TakeRow(System.Span destination) -> void +Sdcb.SimdCodecs.Abstractions.CodecSupport.BoxDownscaler.Width.get -> int +Sdcb.SimdCodecs.Abstractions.CodecSupport.DecodeOptionsChecks.extension(Sdcb.SimdCodecs.Abstractions.DecodeOptions!).GetNativeScale(int maxNativeScaleDenominator) -> int +Sdcb.SimdCodecs.Abstractions.CodecSupport.DecodeOptionsChecks.extension(Sdcb.SimdCodecs.Abstractions.DecodeOptions!).Validate(int width, int height) -> void +static Sdcb.SimdCodecs.Abstractions.CodecSupport.BoxDownscaler.Downscale(Sdcb.SimdCodecs.Abstractions.IDecodedImage! source, int factor, Sdcb.SimdCodecs.Abstractions.PixelRect? scaledRegion = null, System.Buffers.ArrayPool? pool = null) -> Sdcb.SimdCodecs.Abstractions.RasterImage! +static Sdcb.SimdCodecs.Abstractions.CodecSupport.BoxDownscaler.GetSourceRect(Sdcb.SimdCodecs.Abstractions.PixelRect scaledRegion, int width, int height, int factor) -> Sdcb.SimdCodecs.Abstractions.PixelRect +static Sdcb.SimdCodecs.Abstractions.CodecSupport.DecodeOptionsChecks.Apply(Sdcb.SimdCodecs.Abstractions.IDecodedImage! image, Sdcb.SimdCodecs.Abstractions.DecodeOptions? options, int nativeScale) -> Sdcb.SimdCodecs.Abstractions.IDecodedImage! +static Sdcb.SimdCodecs.Abstractions.CodecSupport.DecodeOptionsChecks.GetNativeScale(this Sdcb.SimdCodecs.Abstractions.DecodeOptions! options, int maxNativeScaleDenominator) -> int +static Sdcb.SimdCodecs.Abstractions.CodecSupport.DecodeOptionsChecks.Validate(this Sdcb.SimdCodecs.Abstractions.DecodeOptions! options, int width, int height) -> void +static Sdcb.SimdCodecs.Abstractions.CodecSupport.DecodeOptionsChecks.extension(Sdcb.SimdCodecs.Abstractions.DecodeOptions!).Apply(Sdcb.SimdCodecs.Abstractions.IDecodedImage! image, Sdcb.SimdCodecs.Abstractions.DecodeOptions? options, int nativeScale) -> Sdcb.SimdCodecs.Abstractions.IDecodedImage! +*REMOVED*Sdcb.SimdCodecs.Abstractions.CodecSupport.DecodeOptionsChecks.extension(Sdcb.SimdCodecs.Abstractions.DecodeOptions!).Validate(int width, int height, bool supportsScale) -> void +*REMOVED*static Sdcb.SimdCodecs.Abstractions.CodecSupport.DecodeOptionsChecks.Validate(this Sdcb.SimdCodecs.Abstractions.DecodeOptions! options, int width, int height, bool supportsScale) -> void diff --git a/src/Sdcb.SimdCodecs.Abstractions/RasterSize.cs b/src/Sdcb.SimdCodecs.Abstractions/RasterSize.cs index 2b6e59b..7c814aa 100644 --- a/src/Sdcb.SimdCodecs.Abstractions/RasterSize.cs +++ b/src/Sdcb.SimdCodecs.Abstractions/RasterSize.cs @@ -28,8 +28,8 @@ public static int Elements(int width, int height, int elementsPerPixel, int byte throw new ArgumentOutOfRangeException(nameof(width), $"The requested image raster ({width}x{height}, {(long)elementsPerPixel * bytesPerElement} bytes per pixel, " + $"{elements * bytesPerElement} bytes) exceeds the maximum single-buffer size of {Array.MaxLength} elements (2 GB). " + - "Decode at a reduced scale (DecodeOptions.ScaleDenominator = 2, 4 or 8, for a codec that scales natively, " + - "such as JPEG in the DCT domain) or decode a region of the image (DecodeOptions.Region)."); + "Decode at a reduced scale (DecodeOptions.ScaleDenominator, a power of two; one the codec scales to natively, " + + "such as JPEG up to 8 in the DCT domain, never holds the full size) or decode a region of the image (DecodeOptions.Region)."); } return (int)(pixels * elementsPerPixel); diff --git a/src/Sdcb.SimdCodecs.Bmp/BmpImageDecoder.cs b/src/Sdcb.SimdCodecs.Bmp/BmpImageDecoder.cs index fd96c25..67df47b 100644 --- a/src/Sdcb.SimdCodecs.Bmp/BmpImageDecoder.cs +++ b/src/Sdcb.SimdCodecs.Bmp/BmpImageDecoder.cs @@ -84,7 +84,7 @@ public static bool TryDecode(ReadOnlySpan data, [NotNullWhen(true)] out ID return false; } - options?.Validate(bmp.Width, bmp.Height, supportsScale: false); + options?.Validate(bmp.Width, bmp.Height); var raster = RasterImage.Create(bmp.Width, bmp.Height, bmp.Channels, SampleFormat.UInt8, options?.Pool, colorEncoding: ColorOf(data, header), metadata: BmpDecoder.Icc(data, header) is { } icc ? new ImageMetadata { Icc = icc } : null); diff --git a/src/Sdcb.SimdCodecs.Gif/GifFrameReader.cs b/src/Sdcb.SimdCodecs.Gif/GifFrameReader.cs index 3c8e031..de3e80c 100644 --- a/src/Sdcb.SimdCodecs.Gif/GifFrameReader.cs +++ b/src/Sdcb.SimdCodecs.Gif/GifFrameReader.cs @@ -43,7 +43,7 @@ public bool TryDecodeFrame(int index, [NotNullWhen(true)] out IDecodedImage? ima Check(index); cancellationToken.ThrowIfCancellationRequested(); image = null; - options?.Validate(canvas.Width, canvas.Height, supportsScale: false); + options?.Validate(canvas.Width, canvas.Height); if (!TryComposite(index, cancellationToken)) return false; var raster = ToImage(canvas, frameInfo.Channels, options?.Pool); image = DecodeOptions.Apply(raster, options); diff --git a/src/Sdcb.SimdCodecs.Gif/GifImageDecoder.cs b/src/Sdcb.SimdCodecs.Gif/GifImageDecoder.cs index 8cea082..0ca3552 100644 --- a/src/Sdcb.SimdCodecs.Gif/GifImageDecoder.cs +++ b/src/Sdcb.SimdCodecs.Gif/GifImageDecoder.cs @@ -60,7 +60,7 @@ public static bool TryDecode(ReadOnlySpan data, [NotNullWhen(true)] out ID image = null; cancellationToken.ThrowIfCancellationRequested(); if (!TryFirstFrame(data, out var canvas, out var info, cancellationToken)) return false; - options?.Validate(info.Width, info.Height, supportsScale: false); + options?.Validate(info.Width, info.Height); var raster = GifFrameReader.ToImage(canvas, info.Channels, options?.Pool); image = DecodeOptions.Apply(raster, options); return true; diff --git a/src/Sdcb.SimdCodecs.Heif/HeifCanvas.cs b/src/Sdcb.SimdCodecs.Heif/HeifCanvas.cs index 0ea49b0..b423935 100644 --- a/src/Sdcb.SimdCodecs.Heif/HeifCanvas.cs +++ b/src/Sdcb.SimdCodecs.Heif/HeifCanvas.cs @@ -1,4 +1,5 @@ using Sdcb.SimdCodecs.Abstractions; +using Sdcb.SimdCodecs.Abstractions.CodecSupport; using Sdcb.SimdCodecs.Heif.Hevc; namespace Sdcb.SimdCodecs.Heif; @@ -28,6 +29,14 @@ namespace Sdcb.SimdCodecs.Heif; /// (), RGBA64 written directly, the others converted from its row. Those /// are exactly the bytes of decoding to RGBA (8- or 16-bit) and converting. /// +/// +/// Scaling. With a factor d > 1 an output pixel is a box of d × d displayed pixels (). +/// Its preimage on the canvas is a d × d square too, so when every tile's displayed rectangle starts on a box edge and +/// ends on one or at the image's edge (), a tile holds whole boxes: it converts its +/// pixels to Rgb24 / Rgb48, pushes them in display order (rows, reversed rows, or columns after a quarter turn) through a +/// of its own and converts each finished row of boxes into the output. The integer box sums +/// do not depend on the order of the pixels, so the boxes are those of downscaling the whole image. +/// /// internal sealed unsafe class HeifCanvas { @@ -42,11 +51,18 @@ internal sealed unsafe class HeifCanvas private readonly bool _wide; // 16-bit output: _k is the 16-bit coefficient set private readonly CancellationToken _cancellationToken; + // Scaled output (_factor > 1): _format is the layout the boxes average (Rgb24, or Rgb48 above 8 bits: TryDecode's), + // _outFormat the destination's, converted from each finished output row. + private readonly int _factor; + private readonly PixelFormat _outFormat; + private readonly int _outBpp; + // output = (a·x + b·y + c, d·x + e·y + f) for canvas (x, y) inside the region. private readonly int _a, _b, _c, _d, _e, _f; private readonly int _regionX0, _regionY0, _regionX1, _regionY1; - /// The displayed size: the output's width and height. + /// The displayed size: the output's width and height. When scaled, the size of the displayed pixels the + /// output's boxes average (), not the output's. internal int Width { get; } /// @@ -55,17 +71,30 @@ internal sealed unsafe class HeifCanvas /// /// Sets up the output for 's canvas. must stay pinned /// while tiles are written; it must hold the display size at bytes per row. + /// + /// With > 1 the output is the display image box-filtered by that factor + /// (), or its (then in scaled coordinates): each tile boxes the + /// pixels it converts and writes the finished boxes, converted to + /// ; holds the scaled size. That needs every tile's pixels + /// to make whole boxes (). + /// /// internal HeifCanvas(HeifContainer container, byte* destination, long length, int stride, PixelFormat format, - in HeifColor.Coefficients coefficients, CancellationToken cancellationToken = default, PixelRect? region = null) + in HeifColor.Coefficients coefficients, CancellationToken cancellationToken = default, PixelRect? region = null, + int factor = 1, PixelFormat boxFormat = default) { + if (factor > 1 && boxFormat != PixelFormat.Rgb24 && boxFormat != PixelFormat.Rgb48) + throw new ArgumentException("A scaled output boxes Rgb24 or Rgb48 pixels.", nameof(boxFormat)); _destination = destination; _length = length; _stride = stride; - _format = format; - _bpp = format.BytesPerPixel; + _factor = Math.Max(factor, 1); + _outFormat = format; + _outBpp = format.BytesPerPixel; + _format = _factor > 1 ? boxFormat : format; + _bpp = _format.BytesPerPixel; _k = coefficients; - _wide = format.Sample == SampleFormat.UInt16; + _wide = _format.Sample == SampleFormat.UInt16; _cancellationToken = cancellationToken; int canvasW = container.CanvasWidth, canvasH = container.CanvasHeight; @@ -110,6 +139,23 @@ internal HeifCanvas(HeifContainer container, byte* destination, long length, int // A region of the displayed image is one more translation: output' = output − (X, Y), on a Width × Height // rectangle. The preimage below then shrinks to the canvas pixels that land in it, so tiles outside it are // skipped (Intersects) and only its pixels are converted. + // Scaled, the translation is to the displayed pixels the scaled region's boxes average (all of them without a + // region): output box (bx, by) then averages output pixels [bx·d, bx·d + d) × [by·d, by·d + d), clipped. + int outW, outH; + if (_factor > 1) + { + var scaledW = (int)(((long)width + _factor - 1) / _factor); + var scaledH = (int)(((long)height + _factor - 1) / _factor); + var scaled = region ?? new PixelRect(0, 0, scaledW, scaledH); + if (!scaled.FitsIn(scaledW, scaledH)) + throw new ArgumentException($"The region {scaled} does not lie inside the {scaledW}x{scaledH} image."); + region = BoxDownscaler.GetSourceRect(scaled, width, height, _factor); + (outW, outH) = (scaled.Width, scaled.Height); + } + else + { + (outW, outH) = region is { } q ? (q.Width, q.Height) : (width, height); + } if (region is { } r) { if (!r.FitsIn(width, height)) @@ -136,12 +182,79 @@ internal HeifCanvas(HeifContainer container, byte* destination, long length, int } (_regionX0, _regionX1, _regionY0, _regionY1) = (xLo, Math.Max(xLo, xHi), yLo, Math.Max(yLo, yHi)); - if (width <= 0 || height <= 0 || stride < (long)width * _bpp || length < (long)(height - 1) * stride + (long)width * _bpp) + // The output rectangle the destination must hold: the region (scaled, when scaled) in the output format. + if (width <= 0 || height <= 0 || outW <= 0 || outH <= 0 || stride < (long)outW * _outBpp + || length < (long)(outH - 1) * stride + (long)outW * _outBpp) { throw new ArgumentException("The destination does not fit the image."); } } + /// + /// The output rectangle (before scaling) that canvas pixels [x0, x1) × [y0, y1) map onto: a signed permutation and a + /// translation send a rectangle to a rectangle. + /// + private (int U0, int U1, int V0, int V1) ToOutput(int x0, int x1, int y0, int y1) + { + if (_a != 0) + { + var (u0, u1) = MapRange(_a, _c, x0, x1); + var (v0, v1) = MapRange(_e, _f, y0, y1); + return (u0, u1, v0, v1); + } + else + { + var (u0, u1) = MapRange(_b, _c, y0, y1); + var (v0, v1) = MapRange(_d, _f, x0, x1); + return (u0, u1, v0, v1); + } + } + + /// The canvas rectangle that output pixels [u0, u1) × [v0, v1) come from: inverted. + private (int X0, int X1, int Y0, int Y1) ToCanvas(int u0, int u1, int v0, int v1) + { + // v = s·x + o with s = ±1 inverts to x = s·v − s·o: the same form, with offset −s·o. + if (_a != 0) + { + var (x0, x1) = MapRange(_a, -_a * _c, u0, u1); + var (y0, y1) = MapRange(_e, -_e * _f, v0, v1); + return (x0, x1, y0, y1); + } + else + { + var (y0, y1) = MapRange(_b, -_b * _c, u0, u1); + var (x0, x1) = MapRange(_d, -_d * _f, v0, v1); + return (x0, x1, y0, y1); + } + } + + /// The image of [lo, hi) under v ↦ s·v + offset (s = ±1). + private static (int Lo, int Hi) MapRange(int s, int offset, int lo, int hi) => + s > 0 ? (lo + offset, hi + offset) : (offset - hi + 1, offset - lo + 1); + + /// + /// True when every tile's visible pixels make whole boxes of the scaled output, so each tile can box its own pixels + /// (): the output rectangle of every tile that reaches the output starts on a multiple of the + /// factor and ends on one or at the output's edge, on both axes. Always true for one coded image (its rectangle is + /// the whole output), and for a grid when the tile seams, carried through clap / irot / imir to display coordinates, + /// fall on multiples of the factor (a region does not change that: its boxes start on multiples of the factor). + /// + internal bool BoxesAlignWithTiles(int tileCount, int columns, int tileW, int tileH) + { + if (_factor == 1) return true; + for (var i = 0; i < tileCount; i++) + { + int x = i % columns * tileW, y = i / columns * tileH; + if (!Intersects(x, y, tileW, tileH)) continue; + var (u0, u1, v0, v1) = ToOutput(Math.Max(x, _regionX0), Math.Min(x + tileW, _regionX1), + Math.Max(y, _regionY0), Math.Min(y + tileH, _regionY1)); + if (!Aligned(u0, u1, Width) || !Aligned(v0, v1, Height)) return false; + } + return true; + } + + private bool Aligned(int lo, int hi, int size) => lo % _factor == 0 && (hi % _factor == 0 || hi == size); + /// True when canvas pixels [x, x + w) × [y, y + h) reach the output: a tile outside the region is skipped. internal bool Intersects(int x, int y, int w, int h) => x < _regionX1 && x + w > _regionX0 && y < _regionY1 && y + h > _regionY0; @@ -172,6 +285,11 @@ private static void Clip(int s, int offset, int size, ref int lo, ref int hi) internal void WriteTile(HevcPicture picture, int canvasX, int canvasY, int tileW, int tileH, bool parallel = false) { _cancellationToken.ThrowIfCancellationRequested(); + if (_factor > 1) + { + WriteTileScaled(picture, canvasX, canvasY, tileW, tileH, parallel); + return; + } var ys = Math.Max(canvasY, _regionY0); var ye = Math.Min(canvasY + tileH, _regionY1); const int Band = 64; @@ -219,6 +337,125 @@ private void WriteTileRows(HevcPicture picture, int canvasX, int canvasY, int ti } } + // Output rows (or, rotated, columns) a band of a scaled tile spans before the factor rounds it up: a band boxes its + // pixels on its own, so it bounds the per-band buffers (one row; rotated, this many canvas rows). + private const int ScaledBand = 64; + + /// + /// The scaled : the tile's visible pixels, which make whole boxes (), + /// are converted to the box layout, boxed and written into the output boxes they make, in bands of whole boxes + /// across the output axis canvas rows run along (in parallel for a coded image decoded alone). Bands, like tiles, + /// write disjoint output pixels. + /// + private void WriteTileScaled(HevcPicture picture, int canvasX, int canvasY, int tileW, int tileH, bool parallel) + { + var xs = Math.Max(canvasX, _regionX0); + var xe = Math.Min(canvasX + tileW, _regionX1); + var ys = Math.Max(canvasY, _regionY0); + var ye = Math.Min(canvasY + tileH, _regionY1); + if (xs >= xe || ys >= ye) return; + var (u0, u1, v0, v1) = ToOutput(xs, xe, ys, ye); + if (!Aligned(u0, u1, Width) || !Aligned(v0, v1, Height)) + throw new InvalidOperationException("A tile's pixels do not make whole boxes: check BoxesAlignWithTiles first."); + + var band = Math.Max(ScaledBand, _factor); // both powers of two: a multiple of the factor + var rows = _a != 0; // canvas rows land on output rows; otherwise (a quarter turn) on output columns + int lo = rows ? v0 : u0, hi = rows ? v1 : u1; + var count = (int)(((long)hi - lo + band - 1) / band); + void Band(int k) + { + var s = lo + k * band; + var t = (int)Math.Min((long)s + band, hi); + if (rows) BoxBand(picture, canvasX, canvasY, u0, u1, s, t); + else BoxBand(picture, canvasX, canvasY, s, t, v0, v1); + } + if (!parallel || count == 1 || Environment.ProcessorCount < 2) + { + for (var k = 0; k < count; k++) Band(k); + return; + } + Parallel.For(0, count, new ParallelOptions { CancellationToken = _cancellationToken }, Band); + } + + /// + /// Boxes output pixels [u0, u1) × [v0, v1) (before scaling; whole boxes, from multiples of the factor) of the picture + /// and writes the boxes, converted to the output format. Output row v is pushed as its own pixels in order: canvas + /// row e·(v − f) read left to right (mirrored: right to left), or after a quarter turn canvas column d·(v − f), read + /// from a scratch of the band's converted canvas rows. + /// + private void BoxBand(HevcPicture picture, int canvasX, int canvasY, int u0, int u1, int v0, int v1) + { + _cancellationToken.ThrowIfCancellationRequested(); + var (x0, x1, y0, y1) = ToCanvas(u0, u1, v0, v1); + int width = u1 - u0, bpp = _bpp, rowBytes = width * bpp; + var box = new BoxDownscaler(width, v1 - v0, 3, _format.Sample, _factor); + var pool = System.Buffers.ArrayPool.Shared; + var taken = _outFormat != _format ? pool.Rent(box.OutputRowBytes) : null; + var row = pool.Rent(rowBytes); + byte[]? other = null; + try + { + int ox = u0 / _factor, oy = v0 / _factor; + if (_a != 0) + { + // Output row v is canvas row y = e·(v − f), columns x0 .. x1 − 1 (= u0 .. u1 − 1, reversed when a = −1). + if (_a < 0) other = pool.Rent(rowBytes); + for (var v = v0; v < v1; v++) + { + ConvertRow(picture, _e * (v - _f) - canvasY, x0 - canvasX, width, row); + var pushed = row; + if (other is not null) + { + for (int i = 0, j = rowBytes - bpp; i < rowBytes; i += bpp, j -= bpp) + row.AsSpan(j, bpp).CopyTo(other.AsSpan(i, bpp)); + pushed = other; + } + if (box.PushRow(pushed)) Emit(box, ox, oy, taken); + } + return; + } + + // A quarter turn: output row v is canvas column x = d·(v − f), and output pixel u canvas row b·(u − c). The + // band's canvas rows [y0, y1) are converted first (columns x0 .. x1 − 1), then each output row gathered. + var canvasRowBytes = (x1 - x0) * bpp; + other = pool.Rent(canvasRowBytes * (y1 - y0)); + for (var y = y0; y < y1; y++) + ConvertRow(picture, y - canvasY, x0 - canvasX, x1 - x0, other.AsSpan((y - y0) * canvasRowBytes, canvasRowBytes)); + var step = _b * canvasRowBytes; + for (var v = v0; v < v1; v++) + { + var source = (_b * (u0 - _c) - y0) * canvasRowBytes + (_d * (v - _f) - x0) * bpp; + for (var i = 0; i < rowBytes; i += bpp, source += step) + { + for (var k = 0; k < bpp; k++) + row[i + k] = other[source + k]; + } + if (box.PushRow(row)) Emit(box, ox, oy, taken); + } + } + finally + { + pool.Return(row); + if (other is not null) pool.Return(other); + if (taken is not null) pool.Return(taken); + } + } + + /// Takes the output row finished into the output at box column , + /// row + the rows taken before it, through when the formats differ. + private void Emit(BoxDownscaler box, int ox, int oy, byte[]? taken) + { + var offset = (long)(oy + box.RowsTaken) * _stride + (long)ox * _outBpp; + if (taken is null) + { + box.TakeRow(Output(offset, box.OutputRowBytes)); + return; + } + box.TakeRow(taken); + PixelConverter.ConvertRow(taken.AsSpan(0, box.OutputRowBytes), _format, Output(offset, box.OutputWidth * _outBpp), + _outFormat, box.OutputWidth); + } + /// /// The output bytes at . Every pixel the region maps to lies inside the width × height /// rectangle the constructor checked against the buffer; the check here keeps a mistake in that reasoning from diff --git a/src/Sdcb.SimdCodecs.Heif/HeifDecoder.cs b/src/Sdcb.SimdCodecs.Heif/HeifDecoder.cs index 8f5c1d1..cfeac12 100644 --- a/src/Sdcb.SimdCodecs.Heif/HeifDecoder.cs +++ b/src/Sdcb.SimdCodecs.Heif/HeifDecoder.cs @@ -168,6 +168,38 @@ private static (int Width, int Height) ValidateTiles(HeifContainer container, He internal static int MatrixCoefficients(HeifContainer container) => container.NclxColour is { } nclx ? nclx.MatrixCoefficients : container.TileConfigs[0].ParseSps().MatrixCoefficients; + /// The size of a coded image's output (its conformance window), checked to cover the canvas with the grid. + private static (int Width, int Height) TileSize(HeifContainer container) + { + var sps = container.TileConfigs[0].ParseSps(); + if (sps.BitDepthY != sps.BitDepthC) throw new InvalidDataException("Different luma and chroma bit depths are not supported."); + int tileW = sps.Width - sps.ConfLeft - sps.ConfRight, tileH = sps.Height - sps.ConfTop - sps.ConfBottom; + if ((long)tileW * container.GridColumns < container.CanvasWidth || (long)tileH * container.GridRows < container.CanvasHeight) + { + throw new InvalidDataException("The grid tiles do not cover the output image."); + } + return (tileW, tileH); + } + + /// + /// True when can box by while it writes the tiles: every tile that + /// reaches the output (or the scaled ) covers whole boxes, so the tiles' seams, carried + /// through clap / irot / imir to display coordinates, fall on multiples of the factor + /// (). Always true for a single coded image and for a factor of 1. + /// + /// The container's tiles are malformed. + /// does not lie inside the scaled image. + internal static unsafe bool ScalesNatively(HeifContainer container, int factor, PixelRect? region) + { + if (factor <= 1) return true; + var (tileW, tileH) = TileSize(container); + // Geometry only: a canvas over no buffer, through which nothing is written. + HeifColor.Coefficients none = default; + var canvas = new HeifCanvas(container, null, long.MaxValue, int.MaxValue, PixelFormat.Rgb24, none, default, region, + factor, PixelFormat.Rgb24); + return canvas.BoxesAlignWithTiles(container.Tiles.Count, container.GridColumns, tileW, tileH); + } + /// /// Decodes the primary image, display-oriented, into in /// with bytes per row; returns the coded bit depth. Each tile is converted into the @@ -175,30 +207,36 @@ internal static int MatrixCoefficients(HeifContainer container) => /// image-sized buffer is the destination. A 16-bit takes the 16-bit conversion /// (); an 8-bit one the frozen 8-bit conversion, straight from the coded samples. /// Cancellation is checked per tile and per band of output rows. + /// + /// With > 1 the output is the box filter () + /// of the Rgb24 image (Rgb48 above 8 bits) by that factor, or its in scaled coordinates, + /// converted to : each tile boxes the pixels it converts, so no full-size image exists. It + /// requires . + /// /// internal static unsafe int DecodeInto(ReadOnlySpan data, HeifContainer container, Span destination, int stride, - PixelFormat format, CancellationToken cancellationToken = default, PixelRect? region = null) + PixelFormat format, CancellationToken cancellationToken = default, PixelRect? region = null, int factor = 1) { var payloads = ResolvePayloads(data, container); var configs = container.TileConfigs; var sps = configs[0].ParseSps(); - if (sps.BitDepthY != sps.BitDepthC) throw new InvalidDataException("Different luma and chroma bit depths are not supported."); - int tileW = sps.Width - sps.ConfLeft - sps.ConfRight, tileH = sps.Height - sps.ConfTop - sps.ConfBottom; - if ((long)tileW * container.GridColumns < container.CanvasWidth || (long)tileH * container.GridRows < container.CanvasHeight) - { - throw new InvalidDataException("The grid tiles do not cover the output image."); - } + var (tileW, tileH) = TileSize(container); var (matrix, fullRange) = container.NclxColour is { } nclx ? (nclx.MatrixCoefficients, nclx.FullRange) : (sps.MatrixCoefficients, sps.VideoFullRange); - var coefficients = format.Sample == SampleFormat.UInt16 + // Scaled, the boxes average TryDecode's layout, so the conversion is the one for it. + var boxFormat = sps.BitDepthY > 8 ? PixelFormat.Rgb48 : PixelFormat.Rgb24; + var coefficients = (factor > 1 ? boxFormat : format).Sample == SampleFormat.UInt16 ? HeifColor.For16(matrix, fullRange, sps.BitDepthY) : HeifColor.For(matrix, fullRange, sps.BitDepthY); fixed (byte* pointer = destination) { - var canvas = new HeifCanvas(container, pointer, destination.Length, stride, format, coefficients, cancellationToken, region); + var canvas = new HeifCanvas(container, pointer, destination.Length, stride, format, coefficients, cancellationToken, region, + factor, boxFormat); var columns = container.GridColumns; + if (!canvas.BoxesAlignWithTiles(payloads.Length, columns, tileW, tileH)) + throw new InvalidOperationException("The boxes do not line up with the tiles: check ScalesNatively first."); ForEachTile(payloads.Length, i => { // A tile none of whose pixels reach the output (a region of a grid) is not decoded at all. diff --git a/src/Sdcb.SimdCodecs.Heif/HeifImageDecoder.cs b/src/Sdcb.SimdCodecs.Heif/HeifImageDecoder.cs index a2bd557..96d0d87 100644 --- a/src/Sdcb.SimdCodecs.Heif/HeifImageDecoder.cs +++ b/src/Sdcb.SimdCodecs.Heif/HeifImageDecoder.cs @@ -121,7 +121,8 @@ public static bool TryReadInfo(ReadOnlySpan data, out ImageInfo info) } /// - /// HEIF does not scale natively: the display size, at scale 1. + /// The display size, at scale 1: HEVC has no reduced-resolution decode. Other scales are accepted; every + /// pixel is still decoded, but boxed as each tile is converted (see ). public static bool TryGetClosestSize(ReadOnlySpan data, int requestedWidth, int requestedHeight, out int width, out int height, out int scaleDenominator) => ImageDecoderFallback.TryGetStoredSize(TryReadInfo, data, out width, out height, out scaleDenominator); @@ -129,7 +130,12 @@ public static bool TryGetClosestSize(ReadOnlySpan data, int requestedWidth /// /// RGB, 3 channels, at the display size: 8-bit samples for an 8-bit image, 16-bit (bit depth 10) for a /// Main 10 one. A region (in display coordinates) is decoded natively: only the grid tiles it touches are decoded, - /// and only its pixels converted. + /// and only its pixels converted. A above 1 is the frozen box filter of + /// that image; it is computed while the tiles are written (each tile boxes the pixels it converts, so no full-size + /// image exists) when every tile's pixels make whole boxes: always for a single coded image, and for a grid whose + /// tile seams, carried through clap / irot / imir to display coordinates, fall on multiples of the factor (a grid + /// of 512-pixel tiles with a display size that is a multiple of the factor, untransformed or turned or mirrored). + /// Otherwise the pixels the boxes cover are decoded first, then box-filtered; the bytes are the same. public static bool TryDecode(ReadOnlySpan data, [NotNullWhen(true)] out IDecodedImage? image, DecodeOptions? options = null, CancellationToken cancellationToken = default) { @@ -146,14 +152,51 @@ public static bool TryDecode(ReadOnlySpan data, [NotNullWhen(true)] out ID { return false; } - options?.Validate(size.Width, size.Height, supportsScale: false); + options?.Validate(size.Width, size.Height); + var factor = options?.ScaleDenominator ?? 1; var region = options?.Region; + var wide = depth > 8; + if (factor > 1) + { + bool native; + try + { + native = HeifDecoder.ScalesNatively(container, factor, region); + } + catch (InvalidDataException) + { + return false; + } + if (native) + { + // Each tile boxes the pixels it converts straight into the scaled image (HeifCanvas). + var (w, h) = options!.GetOutputSize(size.Width, size.Height); + var scaled = RasterImage.Create(w, h, 3, wide ? SampleFormat.UInt16 : SampleFormat.UInt8, options.Pool, + ColorEncodingOf(container), bitDepth: wide && depth < 16 ? depth : 0, metadata: HeifMetadata.Read(data, container)); + try + { + HeifDecoder.DecodeInto(data, container, scaled.WritablePixels, w * 3 * (wide ? 2 : 1), + wide ? PixelFormat.Rgb48 : PixelFormat.Rgb24, cancellationToken, region, factor); + } + catch (Exception e) + { + scaled.Dispose(); + if (e is InvalidDataException or ArgumentException or OverflowException) + return false; + throw; + } + image = scaled; + return true; + } + // Grid tiles whose seams do not fall on box edges: the stored pixels the region's boxes cover (all of them + // without a region) are decoded, then box-filtered. + region = region is { } r0 ? BoxDownscaler.GetSourceRect(r0, size.Width, size.Height, factor) : null; + } if (region is { } r) size = (r.Width, r.Height); - var wide = depth > 8; var raster = RasterImage.Create(size.Width, size.Height, 3, wide ? SampleFormat.UInt16 : SampleFormat.UInt8, - options?.Pool, ColorEncodingOf(container), bitDepth: wide && depth < 16 ? depth : 0, + factor == 1 ? options?.Pool : null, ColorEncodingOf(container), bitDepth: wide && depth < 16 ? depth : 0, metadata: HeifMetadata.Read(data, container)); try { @@ -168,7 +211,13 @@ public static bool TryDecode(ReadOnlySpan data, [NotNullWhen(true)] out ID throw; } // The region is decoded natively: only the grid tiles it touches are decoded, only its pixels converted. - image = raster; + if (factor == 1) + { + image = raster; + return true; + } + using (raster) + image = BoxDownscaler.Downscale(raster, factor, pool: options!.Pool); return true; } @@ -179,8 +228,11 @@ public static bool TryDecode(ReadOnlySpan data, [NotNullWhen(true)] out ID /// 16-bit conversion, both from the coded samples. is the stored luma, /// range-mapped to 8 bits (see on codecs that store luma; the formula is /// frozen in HeifColor), except for an identity-matrix (GBR) image, which takes the luma of the RGB. A - /// region decodes only the tiles it touches. False, without throwing, for malformed or unsupported data, a - /// destination that does not fit, and a PQ or HLG transfer function. + /// region decodes only the tiles it touches. A scale above 1 is 's scaled image (Rgb24, or + /// Rgb48 for Main 10) converted with , so is then the luma + /// of the RGB and 8-bit formats of a Main 10 image narrow its 16-bit samples; when the tiles allow it (see + /// ) the boxes are converted straight into . False, without + /// throwing, for malformed or unsupported data, a destination that does not fit, and a PQ or HLG transfer function. /// public static bool TryDecodeInto(ReadOnlySpan data, Span destination, int stride, PixelFormat format, DecodeOptions? options = null, CancellationToken cancellationToken = default) @@ -195,7 +247,27 @@ public static bool TryDecodeInto(ReadOnlySpan data, Span destination { return false; } - options?.Validate(size.Width, size.Height, supportsScale: false); + options?.Validate(size.Width, size.Height); + if (options is { ScaleDenominator: > 1 }) + { + // A scale is the box filter of TryDecode's image, converted: natively, each tile boxes its Rgb24 / Rgb48 pixels + // and converts the finished boxes into the destination; otherwise through TryDecode. + if (ColorEncodingOf(container).Transfer is TransferFunction.Pq or TransferFunction.Hlg) return false; + try + { + if (!HeifDecoder.ScalesNatively(container, options.ScaleDenominator, options.Region)) + return ImageDecoderFallback.TryDecodeInto(TryDecode, data, destination, stride, format, options, cancellationToken); + var (w, h) = options.GetOutputSize(size.Width, size.Height); + if (!PixelConverter.TryGetLayout(w, h, format, stride, destination.Length, out var scaledStride)) return false; + HeifDecoder.DecodeInto(data, container, destination, scaledStride, format, cancellationToken, options.Region, + options.ScaleDenominator); + return true; + } + catch (Exception e) when (e is InvalidDataException or ArgumentException or OverflowException) + { + return false; + } + } var region = options?.Region; if (region is { } r) size = (r.Width, r.Height); diff --git a/src/Sdcb.SimdCodecs.Ico/IcoImageDecoder.cs b/src/Sdcb.SimdCodecs.Ico/IcoImageDecoder.cs index 335f48c..4c9e8cf 100644 --- a/src/Sdcb.SimdCodecs.Ico/IcoImageDecoder.cs +++ b/src/Sdcb.SimdCodecs.Ico/IcoImageDecoder.cs @@ -169,7 +169,7 @@ internal static IDecodedImage DecodeRaster(ReadOnlySpan data, IcoContainer { cancellationToken.ThrowIfCancellationRequested(); var entry = container.Entries[index]; - options?.Validate(entry.Width, entry.Height, supportsScale: false); + options?.Validate(entry.Width, entry.Height); var raster = RasterImage.Create(entry.Width, entry.Height, 4, SampleFormat.UInt8, options?.Pool); try { diff --git a/src/Sdcb.SimdCodecs.Jbig2/Jbig2ImageDecoder.cs b/src/Sdcb.SimdCodecs.Jbig2/Jbig2ImageDecoder.cs index 52535d7..ef1de03 100644 --- a/src/Sdcb.SimdCodecs.Jbig2/Jbig2ImageDecoder.cs +++ b/src/Sdcb.SimdCodecs.Jbig2/Jbig2ImageDecoder.cs @@ -79,7 +79,7 @@ public static bool TryDecode(ReadOnlySpan data, [NotNullWhen(true)] out ID if (!CanDecode(data)) return false; if (!Jbig2Decoder.TryReadFileInfo(data, out var w, out var h)) return false; - options?.Validate(w, h, supportsScale: false); + options?.Validate(w, h); Jbig2Image page; try @@ -114,7 +114,7 @@ public static bool TryDecodeInto(ReadOnlySpan data, Span destination if (!CanDecode(data)) return false; if (!Jbig2Decoder.TryReadFileInfo(data, out var width, out var height)) return false; - options?.Validate(width, height, supportsScale: false); + options?.Validate(width, height); try { diff --git a/src/Sdcb.SimdCodecs.Jpeg.GainMap/GainMapImageDecoder.cs b/src/Sdcb.SimdCodecs.Jpeg.GainMap/GainMapImageDecoder.cs index d19fd3b..0bc1e67 100644 --- a/src/Sdcb.SimdCodecs.Jpeg.GainMap/GainMapImageDecoder.cs +++ b/src/Sdcb.SimdCodecs.Jpeg.GainMap/GainMapImageDecoder.cs @@ -69,7 +69,8 @@ public static bool TryReadInfo(ReadOnlySpan data, out ImageInfo info) /// /// The reconstructed HDR is not scaled natively; the SDR base is a plain JPEG, which is (see - /// JpegImageDecoder.TryGetClosestSize). A file whose fidelity tier is the HDR reports its stored size. + /// JpegImageDecoder.TryGetClosestSize). A file whose fidelity tier is the HDR reports its stored size; other + /// scales are accepted, and box-filter the full-size HDR. public static bool TryGetClosestSize(ReadOnlySpan data, int requestedWidth, int requestedHeight, out int width, out int height, out int scaleDenominator) { @@ -90,7 +91,7 @@ public static bool TryDecode(ReadOnlySpan data, [NotNullWhen(true)] out ID { if (!JpegImageDecoder.TryReadInfo(data, out var info)) return false; - options?.Validate(info.Width, info.Height, supportsScale: false); + options?.Validate(info.Width, info.Height); cancellationToken.ThrowIfCancellationRequested(); if (!JpegGainMap.TryRead(data, out var gainMap)) return false; diff --git a/src/Sdcb.SimdCodecs.Jpeg/JpegDecoder.cs b/src/Sdcb.SimdCodecs.Jpeg/JpegDecoder.cs index 4aa62d4..53eec8d 100644 --- a/src/Sdcb.SimdCodecs.Jpeg/JpegDecoder.cs +++ b/src/Sdcb.SimdCodecs.Jpeg/JpegDecoder.cs @@ -355,7 +355,7 @@ internal static string TooLargeMessage(int width, int height, int bytesPerPixel, $"The requested image raster ({width}x{height}, {bytesPerPixel} bytes per pixel, {bytes} bytes) exceeds the maximum " + $"single-buffer size of {Array.MaxLength} bytes (2 GB). Decode a gigapixel JPEG at a reduced scale " + "(DecodeOptions.ScaleDenominator = 2, 4 or 8, or JpegScale.Half / Quarter / Eighth: scaled in the DCT domain, " + - "never at full size) or decode a region of it (DecodeOptions.Region)."; + "never at full size; larger powers of two box-filter the 1/8 decode) or decode a region of it (DecodeOptions.Region)."; private static int BytesPerPixel(JpegPixelFormat format) => format switch { diff --git a/src/Sdcb.SimdCodecs.Jpeg/JpegImageDecoder.cs b/src/Sdcb.SimdCodecs.Jpeg/JpegImageDecoder.cs index 6b7b162..b613c73 100644 --- a/src/Sdcb.SimdCodecs.Jpeg/JpegImageDecoder.cs +++ b/src/Sdcb.SimdCodecs.Jpeg/JpegImageDecoder.cs @@ -19,7 +19,8 @@ namespace Sdcb.SimdCodecs.Jpeg; /// ; the other /// formats (16-bit, premultiplied, grey + alpha) are the 8-bit decode widened by . /// JPEG scales natively in the DCT domain: 2, 4 and 8 decode a -/// smaller image at a fraction of the cost (). +/// smaller image at a fraction of the cost (); 16 and above decode at 1/8, then apply +/// the box filter of for the rest. /// /// public sealed class JpegImageDecoder : IImageDecoder @@ -58,7 +59,8 @@ public static bool TryReadInfo(ReadOnlySpan data, out ImageInfo info) /// /// The smallest of 1/1, 1/2, 1/4 and 1/8 of the stored size that is at least the requested size in both - /// directions (the stored size when the request is larger). + /// directions (the stored size when the request is larger). Smaller scales are accepted too, but cost the 1/8 + /// decode and a box filter. public static bool TryGetClosestSize(ReadOnlySpan data, int requestedWidth, int requestedHeight, out int width, out int height, out int scaleDenominator) { @@ -86,24 +88,31 @@ public static bool TryDecode(ReadOnlySpan data, [NotNullWhen(true)] out ID image = null; if (!JpegDecoder.TryReadInfo(data, out var info)) return false; - options?.Validate(info.Width, info.Height, maxScaleDenominator: 8); - var scale = (JpegScale)(options?.ScaleDenominator ?? 1); + options?.Validate(info.Width, info.Height); + // Scales up to 1/8 come from the DCT domain; beyond that the 1/8 decode is box-filtered by the rest. + var native = options?.GetNativeScale(8) ?? 1; + var factor = (options?.ScaleDenominator ?? 1) / native; + var scale = (JpegScale)native; var (width, height) = info.ScaledSize(scale); // A one-component frame keeps its samples as stored (Gray8 output is exactly its Y); // every other frame is colour, written as RGB since JPEG carries no alpha. var (channels, format) = info.Components == 1 ? (1, JpegPixelFormat.Gray8) : (3, JpegPixelFormat.Rgb24); - // A region decodes natively (only the region's pixels are ever held), so the raster is the output's size. One - // that does not fit a single array (a gigapixel JPEG at 1/1) is refused before anything is allocated. - var region = options?.Region ?? new PixelRect(0, 0, width, height); + // A region decodes natively (only the region's pixels are ever held), so the raster is the output's size (at a + // box-filtered scale, the native pixels the region's boxes cover). One that does not fit a single array (a + // gigapixel JPEG at 1/1) is refused before anything is allocated. + PixelRect? nativeRegion = options?.Region is { } scaled + ? factor == 1 ? scaled : BoxDownscaler.GetSourceRect(scaled, width, height, factor) + : null; + var region = nativeRegion ?? new PixelRect(0, 0, width, height); if ((long)region.Width * region.Height * channels > Array.MaxLength) return false; var metadata = ReadMetadata(data); - var raster = RasterImage.Create(region.Width, region.Height, channels, SampleFormat.UInt8, options?.Pool, + var raster = RasterImage.Create(region.Width, region.Height, channels, SampleFormat.UInt8, factor == 1 ? options?.Pool : null, colorEncoding: IccPrimaries.Encoding(metadata.Icc.Span), metadata: metadata); try { - if (options?.Region is not null) + if (nativeRegion is not null) { JpegDecoder.DecodeRegionTo(data, raster.WritablePixels, region.Width * channels, format, scale, region.X, region.Y, region.Width, region.Height, cancellationToken); @@ -121,7 +130,13 @@ public static bool TryDecode(ReadOnlySpan data, [NotNullWhen(true)] out ID throw; } - image = raster; + if (factor == 1) + { + image = raster; + return true; + } + using (raster) + image = BoxDownscaler.Downscale(raster, factor, pool: options!.Pool); return true; } @@ -129,11 +144,11 @@ public static bool TryDecode(ReadOnlySpan data, [NotNullWhen(true)] out ID public static bool TryDecodeInto(ReadOnlySpan data, Span destination, int stride, PixelFormat format, DecodeOptions? options = null, CancellationToken cancellationToken = default) { - if (!format.IsBasic8) + if (!format.IsBasic8 || options is { ScaleDenominator: > 8 }) return ImageDecoderFallback.TryDecodeInto(TryDecode, data, destination, stride, format, options, cancellationToken); if (!JpegDecoder.TryReadInfo(data, out var info)) return false; - options?.Validate(info.Width, info.Height, maxScaleDenominator: 8); + options?.Validate(info.Width, info.Height); var scale = (JpegScale)(options?.ScaleDenominator ?? 1); var (width, height) = info.ScaledSize(scale); if (options?.Region is { } region) diff --git a/src/Sdcb.SimdCodecs.Jpeg2000/Jpeg2000ImageDecoder.cs b/src/Sdcb.SimdCodecs.Jpeg2000/Jpeg2000ImageDecoder.cs index 2268aba..f737dd3 100644 --- a/src/Sdcb.SimdCodecs.Jpeg2000/Jpeg2000ImageDecoder.cs +++ b/src/Sdcb.SimdCodecs.Jpeg2000/Jpeg2000ImageDecoder.cs @@ -43,11 +43,13 @@ namespace Sdcb.SimdCodecs.Jpeg2000; /// /// Scaled decode. is a resolution reduction: any /// power of two up to 2 to the codestream's decomposition levels, decoded by leaving the finer levels -/// out (they are never entropy-decoded), with the same samples as opj_decompress -r. A -/// is in the scaled image's coordinates and is cropped from it. An -/// image whose SIZ image offset is not zero is decoded at scale 1 only: on the reference grid a -/// halving rounds relative to the origin, which can differ by a pixel from the facade's -/// ⌈w / d⌉ (the decoder's own still reduces it). +/// out (they are never entropy-decoded), with the same samples as opj_decompress -r. A larger +/// one decodes at that largest reduction, then applies the box filter of +/// for the rest. A is in the scaled image's coordinates and is +/// cropped from it. An image whose SIZ image offset is not zero has no native reduction (every scale +/// is the box filter of the full-size decode): on the reference grid a halving rounds relative to the +/// origin, which can differ by a pixel from the facade's ⌈w / d⌉ (the decoder's own +/// still reduces it). /// /// /// Samples up to 8 bits come back as , deeper ones as @@ -114,7 +116,7 @@ private static bool TryReadInfo(ReadOnlySpan data, out ImageInfo info, [No /// The largest scale denominator this image decodes at natively: 2 to the number of resolution /// levels that can be left out. A reduction halves each side on the reference grid (T.800 B-14), /// which is the facade's ⌈w / d⌉ only when the image region starts at the origin; an image - /// whose SIZ offset is not zero can differ by a pixel at some scales, so it is decoded at 1 only + /// whose SIZ offset is not zero can differ by a pixel at some scales, so it is decoded at 1 natively /// ( still reduces it). /// private static int MaxScaleDenominator(Jpeg2000Info j2k) => @@ -123,7 +125,8 @@ private static int MaxScaleDenominator(Jpeg2000Info j2k) => /// /// The smallest image, a power-of-two reduction of the stored one, that is at least the requested /// size in both directions (the stored size when the request is larger). A reduction costs only the - /// levels it keeps: the finer ones are never entropy-decoded. + /// levels it keeps: the finer ones are never entropy-decoded. Smaller scales are accepted too, but cost the + /// largest reduction's decode and a box filter. public static bool TryGetClosestSize(ReadOnlySpan data, int requestedWidth, int requestedHeight, out int width, out int height, out int scaleDenominator) { @@ -154,8 +157,10 @@ public static bool TryDecode(ReadOnlySpan data, [NotNullWhen(true)] out ID if (!CanDecode(data)) return false; if (!TryReadInfo(data, out var info, out var read)) return false; - options?.Validate(info.Width, info.Height, MaxScaleDenominator(read)); - var reduce = options is null ? 0 : BitOperations.Log2((uint)options.ScaleDenominator); + options?.Validate(info.Width, info.Height); + // Resolution levels give the scales up to MaxScaleDenominator; the box filter does the rest. + var native = options?.GetNativeScale(MaxScaleDenominator(read)) ?? 1; + var reduce = BitOperations.Log2((uint)native); RasterImage? raster; try @@ -171,8 +176,8 @@ public static bool TryDecode(ReadOnlySpan data, [NotNullWhen(true)] out ID try { - // The region is in the scaled image's coordinates, and the raster already is that image. - image = DecodeOptions.Apply(raster, options is { ScaleDenominator: > 1 } ? options with { ScaleDenominator = 1 } : options); + // The raster is the image at 1/native; the region is in the scaled image's coordinates. + image = DecodeOptions.Apply(raster, options, native); return true; } catch diff --git a/src/Sdcb.SimdCodecs.Png/PngFrameReader.cs b/src/Sdcb.SimdCodecs.Png/PngFrameReader.cs index da19a24..ef58a9b 100644 --- a/src/Sdcb.SimdCodecs.Png/PngFrameReader.cs +++ b/src/Sdcb.SimdCodecs.Png/PngFrameReader.cs @@ -78,7 +78,7 @@ public bool TryDecodeFrame(int index, [NotNullWhen(true)] out IDecodedImage? ima return PngImageDecoder.TryDecode(_data.Span, out image, options, cancellationToken); image = null; - options?.Validate(_frameInfo.Width, _frameInfo.Height, supportsScale: false); + options?.Validate(_frameInfo.Width, _frameInfo.Height); if (!TryComposite(animation, index, cancellationToken)) return false; int width = _frameInfo.Width, height = _frameInfo.Height; @@ -117,7 +117,7 @@ public bool TryDecodeFrameInto(int index, Span destination, int stride, Pi if (options is { IsFullImage: false }) return ImageDecoderFallback.TryDecodeFrameInto(this, index, destination, stride, format, options, cancellationToken); - options?.Validate(_frameInfo.Width, _frameInfo.Height, supportsScale: false); + options?.Validate(_frameInfo.Width, _frameInfo.Height); // As for a still: 8-bit samples are a display projection, which an HDR-encoded file has none of. if (format.Sample == SampleFormat.UInt8 && !_displayReferred) return false; diff --git a/src/Sdcb.SimdCodecs.Png/PngImageDecoder.cs b/src/Sdcb.SimdCodecs.Png/PngImageDecoder.cs index f60df76..9139a68 100644 --- a/src/Sdcb.SimdCodecs.Png/PngImageDecoder.cs +++ b/src/Sdcb.SimdCodecs.Png/PngImageDecoder.cs @@ -151,22 +151,69 @@ public static bool TryDecode(ReadOnlySpan data, [NotNullWhen(true)] out ID { // Outside any catch: a scale or a region that does not fit throws on purpose. if (!TryReadInfo(data, out var info)) return false; - options.Validate(info.Width, info.Height, supportsScale: false); + options.Validate(info.Width, info.Height); } - if (!PngReader.TryDecode(data, out var png, cancellationToken)) return false; RasterImage raster; try { + var header = PngReader.ReadHeader(data, out var idat); + if (options is { IsFullImage: false } && !header.Interlaced) + { + image = DecodeWindow(data, header, idat, options, cancellationToken); + return true; + } + var png = header with + { + Pixels = header.Interlaced + ? PngReader.InflateAdam7(data, idat, header.Width, header.Height, header.SourceBitDepth, header.ColorType, cancellationToken) + : PngReader.InflateAndUnfilter(data, idat, header.Width, header.Height, header.SourceBitDepth, header.ColorType, cancellationToken), + }; raster = ToDecoded(png); } catch (Exception e) when (e is InvalidDataException or ArgumentException or NotSupportedException) { return false; } + // Adam7 scatters every pass over the whole image, so an interlaced file is decoded whole, then scaled and cropped. image = DecodeOptions.Apply(raster, options); return true; } + // TryDecode's region / scale path for a non-interlaced image: the window's rows only, box-filtered as they are + // decoded (PngReader.DecodeWindow), into an image of the output's size. The same pixels, colour signalling and + // metadata as the full decode in ToDecoded's layout followed by DecodeOptions.Apply, but the decode stops after the + // last row the output needs. + private static RasterImage DecodeWindow(ReadOnlySpan data, PngImage header, List<(int Offset, int Length)> idat, + DecodeOptions options, CancellationToken cancellationToken) + { + var region = Window(header, options, out var sourceRect); + var format = PngReader.FidelityFormat(header); + // 1-, 2- and 4-bit greyscale comes out as 8-bit samples; the file's precision is what bitDepth records (ToDecoded). + var depth = header.ColorType == 0 && header.SourceBitDepth < 8 ? header.SourceBitDepth : 0; + var raster = RasterImage.Create(region.Width, region.Height, format.Channels, format.Sample, options.Pool, + MapColorEncoding(header.Cicp, header.Gamma, header.IccProfile), depth, MetadataOf(header)); + try + { + PngReader.DecodeWindow(data, header, idat, sourceRect, options.ScaleDenominator, raster.WritablePixels, 0, format, + cancellationToken); + return raster; + } + catch + { + raster.Dispose(); + throw; + } + } + + // The output region (the options' region, or the whole scaled image) and the source pixels it averages. + private static PixelRect Window(PngImage header, DecodeOptions options, out PixelRect sourceRect) + { + var (scaledWidth, scaledHeight) = options.GetScaledSize(header.Width, header.Height); + var region = options.Region ?? new PixelRect(0, 0, scaledWidth, scaledHeight); + sourceRect = BoxDownscaler.GetSourceRect(region, header.Width, header.Height, options.ScaleDenominator); + return region; + } + /// /// False for cICP PQ/HLG-tagged files: their integer code values are /// encoded for an HDR EOTF, so the code-value expansion would render them @@ -179,7 +226,9 @@ public static bool TryDecode(ReadOnlySpan data, [NotNullWhen(true)] out ID public static bool TryDecodeInto(ReadOnlySpan data, Span destination, int stride, PixelFormat format, DecodeOptions? options = null, CancellationToken cancellationToken = default) { - if (options is { IsFullImage: false } || !format.IsBasic8) + if (options is { IsFullImage: false }) + return TryDecodeWindowInto(data, destination, stride, format, options, cancellationToken); + if (!format.IsBasic8) { // 8-bit samples are display projections, so the HDR refusal applies to them whichever the layout; // 16-bit formats keep the file's code values and go through the generic path as they are. @@ -199,6 +248,41 @@ public static bool TryDecodeInto(ReadOnlySpan data, Span destination } } + // TryDecodeInto with a region or a scale. A non-interlaced image is decoded a window at a time + // (PngReader.DecodeWindow): boxed in TryDecode's layout, then converted to the format, the generic path's order, so + // exactly its pixels. An interlaced one takes the generic path. + private static bool TryDecodeWindowInto(ReadOnlySpan data, Span destination, int stride, PixelFormat format, + DecodeOptions options, CancellationToken cancellationToken) + { + // 8-bit samples are display projections, so the HDR refusal applies to them whichever the layout; + // 16-bit formats keep the file's code values. + PngImage? header = null; + List<(int Offset, int Length)>? idat = null; + if (format.Sample == SampleFormat.UInt8 && !TryReadDisplayReferred(data, out header, out idat)) return false; + // Outside any catch: a region that does not fit throws on purpose, as TryDecode does. + if (!TryReadInfo(data, out var info)) return false; + options.Validate(info.Width, info.Height); + try + { + if (header is null) + header = PngReader.ReadHeader(data, out idat); + if (!header.Interlaced) + { + var region = Window(header, options, out var sourceRect); + if (!PixelConverter.TryGetLayout(region.Width, region.Height, format, stride, destination.Length, out var rowStride)) + return false; + PngReader.DecodeWindow(data, header, idat!, sourceRect, options.ScaleDenominator, destination, rowStride, format, + cancellationToken); + return true; + } + } + catch (Exception e) when (e is InvalidDataException or ArgumentException or NotSupportedException) + { + return false; + } + return ImageDecoderFallback.TryDecodeInto(TryDecode, data, destination, stride, format, options, cancellationToken); + } + /// /// to tightly packed 8-bit RGBA: must be /// at least Width * Height * 4 bytes. diff --git a/src/Sdcb.SimdCodecs.Png/PngReader.Window.cs b/src/Sdcb.SimdCodecs.Png/PngReader.Window.cs new file mode 100644 index 0000000..786a9d5 --- /dev/null +++ b/src/Sdcb.SimdCodecs.Png/PngReader.Window.cs @@ -0,0 +1,203 @@ +using System.Buffers; +using System.Buffers.Binary; +using System.Runtime.InteropServices; +using System.Threading; +using Sdcb.SimdCodecs.Abstractions; +using Sdcb.SimdCodecs.Abstractions.CodecSupport; + +namespace Sdcb.SimdCodecs.Png; + +public static partial class PngReader +{ + /// + /// The layout gives a non-interlaced image (its fidelity layout): indexed + /// as 8-bit RGBA, everything else in its own channels and depth (16-bit in host byte order), plus an alpha channel + /// for a tRNS transparent colour. + /// + internal static PixelFormat FidelityFormat(PngImage header) + { + if (header.ColorType == 3) + return PixelFormat.ForChannels(4, SampleFormat.UInt8); + var channels = SamplesPerPixel(header.ColorType) + (header.TransparentColor is null ? 0 : 1); + return PixelFormat.ForChannels(channels, header.SourceBitDepth == 16 ? SampleFormat.UInt16 : SampleFormat.UInt8); + } + + /// + /// Decodes a window of a non-interlaced image, optionally box-downscaled, straight into + /// in , rows bytes apart: + /// exactly what the full decode in the fidelity layout (), then + /// by over , then + /// to gives, without a buffer the size of the image. + /// Rows are inflated and unfiltered only down to the last row of , and only as far + /// right as its last column (a pixel's unfiltering needs only the pixels left of and above it); the decode then + /// stops, so image data below the window is never inflated and never checked. When the window reaches the bottom + /// row, the end of the stream is checked as the full decode checks it. Rows above the window are unfiltered but + /// not expanded, and only the window's columns are expanded. + /// + /// The source pixels to decode: for > 1 a window aligned to + /// box boundaries (). + /// The image data is corrupt within the rows decoded, or an indexed pixel in + /// the window lies past the palette's end. + /// The image is interlaced, the window does not lie inside the image, or the + /// destination does not fit. + internal static unsafe void DecodeWindow(ReadOnlySpan data, PngImage header, List<(int Offset, int Length)> idat, + PixelRect sourceRect, int factor, Span destination, int dstStride, PixelFormat format, + CancellationToken cancellationToken = default) + { + if (header.Interlaced) + throw new ArgumentException("DecodeWindow decodes non-interlaced images only.", nameof(header)); + int width = header.Width, height = header.Height, bitDepth = header.SourceBitDepth, colorType = header.ColorType; + if (sourceRect.Width <= 0 || sourceRect.Height <= 0 || !sourceRect.FitsIn(width, height)) + throw new ArgumentException($"The window {sourceRect} does not lie inside the {width}x{height} image.", nameof(sourceRect)); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(factor); + + int x0 = sourceRect.X, w = sourceRect.Width, x1 = x0 + w, y0 = sourceRect.Y, y1 = y0 + sourceRect.Height; + var outWidth = (int)(((long)w + factor - 1) / factor); + var outHeight = (int)(((long)sourceRect.Height + factor - 1) / factor); + if (!PixelConverter.TryGetLayout(outWidth, outHeight, format, dstStride, destination.Length, out var stride)) + throw new ArgumentException($"The destination does not fit a {outWidth}x{outHeight} {format} image at stride {dstStride}.", nameof(destination)); + var outRowBytes = outWidth * format.BytesPerPixel; + + var fidelity = FidelityFormat(header); + var spp = SamplesPerPixel(colorType); + var key = header.UnpackedTransparentColor(); + var filterBpp = bitDepth < 8 ? 1 : spp * (bitDepth / 8); + var rowBytes = checked((int)(((long)width * spp * bitDepth + 7) / 8)); + // The bytes of a row that hold columns 0 .. x1 − 1: all its unfiltering needs. + var prefix = (int)(((long)x1 * spp * bitDepth + 7) / 8); + // A row in the fidelity layout is built in scratch unless it is the samples as they are (8-bit, no key). + var needsBuild = colorType == 3 || key is not null || bitDepth == 16; + var fidelityRowBytes = needsBuild ? w * fidelity.BytesPerPixel : 0; + var box = factor > 1 ? new BoxDownscaler(w, sourceRect.Height, fidelity.Channels, fidelity.Sample, factor) : null; + var boxRowBytes = box is not null && format != fidelity ? box.OutputRowBytes : 0; + var unpackedBytes = bitDepth < 8 ? x1 : 0; + // Index expansion: the 8-bit indices of the row (unpacked first when sub-byte) through the palette, as + // PngImage.ToRgba8 maps them. + var palette = colorType == 3 ? new PngRgba8Expander(3, 8, header.Palette, header.PaletteAlpha) : null; + + var scratch = ArrayPool.Shared.Rent(checked(2 * rowBytes + unpackedBytes + fidelityRowBytes + boxRowBytes)); + try + { + var current = scratch.AsSpan(0, rowBytes); + var above = scratch.AsSpan(rowBytes, rowBytes); + var unpacked = scratch.AsSpan(2 * rowBytes, unpackedBytes); + var built = scratch.AsSpan(2 * rowBytes + unpackedBytes, fidelityRowBytes); + var boxRow = scratch.AsSpan(2 * rowBytes + unpackedBytes + fidelityRowBytes, boxRowBytes); + above[..prefix].Clear(); + + fixed (byte* pinned = data) + { + using var rows = new RowInflater(pinned, idat, ((long)rowBytes + 1) * height, width, height, filterBpp); + for (var y = 0; y < y1; y++) + { + if ((y & 63) == 0) + cancellationToken.ThrowIfCancellationRequested(); + var filterType = rows.Read(current); + PngPredictor.UnfilterRow(filterType, current[..prefix], above[..prefix], filterBpp); + if (y >= y0) + { + var row = FidelityRow(current, x0, w, bitDepth, colorType, spp, key, palette, unpacked, built); + if (box is null) + { + PixelConverter.ConvertRow(row, fidelity, destination.Slice((y - y0) * stride, outRowBytes), format, w); + } + else if (box.PushRow(row)) + { + var target = destination.Slice(box.RowsTaken * stride, outRowBytes); + if (boxRowBytes == 0) + { + box.TakeRow(target); + } + else + { + box.TakeRow(boxRow); + PixelConverter.ConvertRow(boxRow, fidelity, target, format, outWidth); + } + } + } + var swap = above; + above = current; + current = swap; + } + // Stopping early leaves the rest of the stream unread and unchecked; a window down to the last row + // checks the stream's end as the full decode does. + if (y1 == height) + rows.Finish(); + } + } + finally + { + ArrayPool.Shared.Return(scratch); + } + } + + /// + /// Columns .. + − 1 of an unfiltered row in the + /// fidelity layout, the samples PngImageDecoder.ToDecoded gives them: the row itself for 8-bit samples without + /// a transparent colour, otherwise built in . + /// + private static ReadOnlySpan FidelityRow(ReadOnlySpan row, int x0, int w, int bitDepth, int colorType, + int spp, ushort[]? key, PngRgba8Expander? palette, Span unpacked, Span built) + { + if (bitDepth < 8) + { + // Greyscale or indexed, one sample per pixel: unpacked to a byte each up to the window's right edge. + PngRgba8Expander.UnpackSubByte(row, unpacked, unpacked.Length, bitDepth, scaleGray: colorType == 0); + row = unpacked; + bitDepth = 8; + } + + if (palette is not null) + { + palette.Expand(row[x0..], built, w); + return built; + } + + if (bitDepth == 8) + { + var samples = row.Slice(x0 * spp, w * spp); + if (key is null) + return samples; + // tRNS: alpha 0 on exactly the pixels of the key colour, 255 elsewhere (the key is masked to the depth). + var o = 0; + for (var i = 0; i < samples.Length; i += spp) + { + var match = true; + for (var c = 0; c < spp; c++) + { + var v = samples[i + c]; + built[o++] = v; + match &= v == key[c]; + } + built[o++] = match ? (byte)0 : byte.MaxValue; + } + return built; + } + + // 16-bit: big-endian in the file, host order in the fidelity layout; a key compares at full precision. + var source = row.Slice(x0 * spp * 2, w * spp * 2); + var output = MemoryMarshal.Cast(built); + if (key is null) + { + var input = MemoryMarshal.Cast(source); + if (BitConverter.IsLittleEndian) + BinaryPrimitives.ReverseEndianness(input, output); + else + input.CopyTo(output); + return built; + } + var k = 0; + for (var i = 0; i < source.Length; i += 2 * spp) + { + var match = true; + for (var c = 0; c < spp; c++) + { + var v = BinaryPrimitives.ReadUInt16BigEndian(source.Slice(i + 2 * c, 2)); + output[k++] = v; + match &= v == key[c]; + } + output[k++] = match ? (ushort)0 : ushort.MaxValue; + } + return built; + } +} diff --git a/src/Sdcb.SimdCodecs.Png/PngReader.cs b/src/Sdcb.SimdCodecs.Png/PngReader.cs index 05059a3..e132b95 100644 --- a/src/Sdcb.SimdCodecs.Png/PngReader.cs +++ b/src/Sdcb.SimdCodecs.Png/PngReader.cs @@ -30,7 +30,7 @@ namespace Sdcb.SimdCodecs.Png; /// /// APNG (animated PNG) layers on top of this decoder: see . /// -public static class PngReader +public static partial class PngReader { private static readonly byte[] Signature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; diff --git a/src/Sdcb.SimdCodecs.Pnm/PnmImageDecoder.cs b/src/Sdcb.SimdCodecs.Pnm/PnmImageDecoder.cs index 4a0e307..7f79e7e 100644 --- a/src/Sdcb.SimdCodecs.Pnm/PnmImageDecoder.cs +++ b/src/Sdcb.SimdCodecs.Pnm/PnmImageDecoder.cs @@ -174,7 +174,7 @@ public static bool TryDecode(ReadOnlySpan data, bool pfm, [NotNullWhen(tru return false; } - options?.Validate(info.Width, info.Height, supportsScale: false); + options?.Validate(info.Width, info.Height); int bitDepth = pfm ? 32 : (32 - BitOperations.LeadingZeroCount((uint)info.MaxValue)); var raster = RasterImage.Create(info.Width, info.Height, info.Channels, info.SampleFormat, options?.Pool, colorEncoding: pfm ? LinearFloat : null, bitDepth: bitDepth); diff --git a/src/Sdcb.SimdCodecs.Qoi/QoiImageDecoder.cs b/src/Sdcb.SimdCodecs.Qoi/QoiImageDecoder.cs index e753718..fc21c85 100644 --- a/src/Sdcb.SimdCodecs.Qoi/QoiImageDecoder.cs +++ b/src/Sdcb.SimdCodecs.Qoi/QoiImageDecoder.cs @@ -76,7 +76,7 @@ public static bool TryDecode(ReadOnlySpan data, [NotNullWhen(true)] out ID return false; } - options?.Validate(info.Width, info.Height, supportsScale: false); + options?.Validate(info.Width, info.Height); var raster = RasterImage.Create(info.Width, info.Height, info.Channels, SampleFormat.UInt8, options?.Pool, colorEncoding: ColorOf(info.ColorSpace), bitDepth: 8); try diff --git a/src/Sdcb.SimdCodecs.Tiff/TiffImageDecoder.cs b/src/Sdcb.SimdCodecs.Tiff/TiffImageDecoder.cs index bad3857..fdb0f9b 100644 --- a/src/Sdcb.SimdCodecs.Tiff/TiffImageDecoder.cs +++ b/src/Sdcb.SimdCodecs.Tiff/TiffImageDecoder.cs @@ -33,9 +33,10 @@ namespace Sdcb.SimdCodecs.Tiff; /// /// /// 1/2/4-bit grey is expanded to fill 8 bits (the maximum code becomes 255) and reports -/// 1/2/4; 16-bit float reports 16. Scaling is not supported (a -/// other than 1 throws ); a -/// reads only the strips or tiles it intersects. Cancellation is checked per strip, +/// 1/2/4; 16-bit float reports 16. TIFF does not scale natively: a +/// above 1 decodes at full size, then applies the box filter of +/// . A reads only the strips or tiles it (at a scale, +/// the stored pixels its boxes cover) intersects. Cancellation is checked per strip, /// per row of tiles and per page. /// /// @@ -157,10 +158,11 @@ internal static bool TryDecodePageInto(ReadOnlySpan data, int pageIndex, S } if (!TiffColor.TryCreate(layout, out var plan)) return false; - options?.Validate(layout.Width, layout.Height, supportsScale: false); - if (plan.OutFormat == SampleFormat.Float32) + options?.Validate(layout.Width, layout.Height); + if (plan.OutFormat == SampleFormat.Float32 || options is { ScaleDenominator: > 1 }) { - // PixelConverter defines float output through the decoded image; keep exactly that. + // PixelConverter defines float output through the decoded image, and a scale is the box filter of the + // decoded image; keep exactly that. if (!TryDecodePage(data, pageIndex, options is null ? null : options with { Pool = null }, cancellationToken, out var image)) return false; using (image) @@ -314,7 +316,7 @@ internal static bool TryDecodePage(ReadOnlySpan data, int pageIndex, Decod if (!TiffColor.TryCreate(layout, out plan)) return false; // Check the options against the page before any strip is decoded (outside the try: they throw). - options?.Validate(layout.Width, layout.Height, supportsScale: false); + options?.Validate(layout.Width, layout.Height); ImageMetadata? metadata; try @@ -328,12 +330,15 @@ internal static bool TryDecodePage(ReadOnlySpan data, int pageIndex, Decod if (metadata is { IsEmpty: true }) metadata = null; - if (options?.Region is { } region) + if (options?.Region is { } scaledRegion) { // Only the strips or tiles the region intersects are read, decoded and converted, straight into the - // result: the same pixels as decoding the page whole and cropping it. - var result = RasterImage.Create(region.Width, region.Height, plan.OutChannels, plan.OutFormat, options.Pool, - ColorOf(layout), BitDepthOf(layout, plan), metadata); + // result: the same pixels as decoding the page whole and cropping it. At a scale, that is the stored + // pixels the region's boxes cover, box-filtered afterwards. + var factor = options.ScaleDenominator; + var region = factor == 1 ? scaledRegion : BoxDownscaler.GetSourceRect(scaledRegion, layout.Width, layout.Height, factor); + var result = RasterImage.Create(region.Width, region.Height, plan.OutChannels, plan.OutFormat, + factor == 1 ? options.Pool : null, ColorOf(layout), BitDepthOf(layout, plan), metadata); try { var target = result.WritablePixels; @@ -358,7 +363,13 @@ internal static bool TryDecodePage(ReadOnlySpan data, int pageIndex, Decod result.Dispose(); throw; } - image = result; + if (factor == 1) + { + image = result; + return true; + } + using (result) + image = BoxDownscaler.Downscale(result, factor, pool: options.Pool); return true; } diff --git a/src/Sdcb.SimdCodecs.Webp/Vp8Decoder.cs b/src/Sdcb.SimdCodecs.Webp/Vp8Decoder.cs index 1b0019a..6860d99 100644 --- a/src/Sdcb.SimdCodecs.Webp/Vp8Decoder.cs +++ b/src/Sdcb.SimdCodecs.Webp/Vp8Decoder.cs @@ -108,6 +108,11 @@ public void Emit(ref byte yCache, int yStride, int yRow0, ref byte uCache, ref b /// small row cache, the previous rows' loop filtering completes as this row is filtered, and /// finished rows go to while they are still in cache. Working memory is /// a few macroblock rows, not a frame. + /// + /// With below the decode stops after the macroblock row whose + /// output reaches it: rows [0, rowLimit) are emitted (and possibly a few more), and the macroblock rows below are + /// neither entropy-decoded nor validated. A stream damaged only below them decodes without error. + /// /// internal static void DecodeFrame( ReadOnlySpan frame, @@ -115,7 +120,8 @@ internal static void DecodeFrame( int height, WebpAblation ablation, ref TSink sink, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + int rowLimit = int.MaxValue) where TSink : IVp8RowSink, allows ref struct { bool applyLoopFilter = (ablation & WebpAblation.SkipLoopFilter) == 0; @@ -400,6 +406,10 @@ ref Unsafe.Add(ref MemoryMarshal.GetArrayDataReference(vCache), CacheLeft), } emitted = end; + if (emitted >= rowLimit) + { + break; + } // Slide the cache down by one macroblock row, keeping the rows the next row's loop // filter and output still need. diff --git a/src/Sdcb.SimdCodecs.Webp/WebpDecoder.Region.cs b/src/Sdcb.SimdCodecs.Webp/WebpDecoder.Region.cs new file mode 100644 index 0000000..6c091d3 --- /dev/null +++ b/src/Sdcb.SimdCodecs.Webp/WebpDecoder.Region.cs @@ -0,0 +1,254 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Runtime.Intrinsics; +using Sdcb.SimdCodecs.Abstractions; +using Sdcb.SimdCodecs.Abstractions.CodecSupport; + +namespace Sdcb.SimdCodecs.Webp; + +public static partial class WebpDecoder +{ + /// + /// Decodes of a lossy still scaled by (the + /// filter, applied while decoding) and writes it to in + /// , with exactly the pixels a whole decode, box-filtered, cropped and converted + /// from the decoder's layout (RGB, or RGBA with 4), would give. + /// + /// Only the source rows the region's boxes cover are converted, a column window of each; macroblock rows below the + /// last of them are not decoded at all, so damage there goes unnoticed. The ALPH plane, when there is one, is still + /// decoded whole. + /// + /// Why the window is exact: chroma is upsampled across the full width (fancy upsampling reads neighbouring chroma + /// columns), and YUV → RGBA is a function of each pixel's own Y / U / V / A alone on every tier, so converting + /// columns [x0, x0 + w) of the upsampled row writes exactly those columns of the full conversion. + /// + internal static void DecodeLossyRegion( + ReadOnlySpan bitstream, + ReadOnlySpan alphaData, + bool hasAlphaChunk, + int width, + int height, + int channels, + int factor, + PixelRect scaledRegion, + Span destination, + int destinationStride, + PixelFormat destinationFormat, + bool fancy, + CancellationToken cancellationToken = default) + { + Debug.Assert(channels is 3 or 4); + PixelRect source = factor == 1 ? scaledRegion : BoxDownscaler.GetSourceRect(scaledRegion, width, height, factor); + if (factor == 1 && !scaledRegion.FitsIn(width, height)) + { + throw new ArgumentException($"The region {scaledRegion} does not lie inside the {width}x{height} image.", nameof(scaledRegion)); + } + + PixelFormat layout = channels == 4 ? PixelFormat.Rgba32 : PixelFormat.Rgb24; + int outputRowBytes = checked(scaledRegion.Width * destinationFormat.BytesPerPixel); + if (destinationStride < outputRowBytes || + destination.Length < (long)destinationStride * (scaledRegion.Height - 1) + outputRowBytes) + { + throw new ArgumentException("The destination is too small for the region.", nameof(destination)); + } + + BoxDownscaler? box = factor == 1 ? null : new BoxDownscaler(source.Width, source.Height, channels, SampleFormat.UInt8, factor); + byte[]? alpha = hasAlphaChunk ? DecodeAndUnfilterAlpha(alphaData, width, height) : null; + int chromaWidth = (width + 1) >> 1; + ushort[]? tRows = null; + byte[]? chromaRows = null; + byte[]? rgbaRow = null; + byte[]? layoutRow = null; + byte[]? boxRow = null; + try + { + tRows = WebpBufferPool.Rent(checked(chromaWidth * 2)); + chromaRows = WebpBufferPool.Rent(checked(width * 2)); + rgbaRow = WebpBufferPool.Rent(checked(source.Width * 4)); + layoutRow = channels == 3 ? WebpBufferPool.Rent(checked(source.Width * 3)) : null; + boxRow = box is null ? null : WebpBufferPool.Rent(box.OutputRowBytes); + ReadOnlySpan alphaSpan = alpha is null ? ReadOnlySpan.Empty : alpha.AsSpan(0, checked(width * height)); + var sink = new RegionSink(width, height, fancy, alphaSpan, tRows, chromaRows, source, layout, rgbaRow, layoutRow, + box, boxRow, destination, destinationStride, destinationFormat, scaledRegion.Width); + Vp8Decoder.DecodeFrame(bitstream, width, height, WebpAblation.None, ref sink, cancellationToken, + rowLimit: source.Y + source.Height); + if (sink.RowsWritten != scaledRegion.Height) + { + throw new InvalidDataException("The VP8 frame ended before the region."); + } + } + finally + { + if (alpha is not null) WebpBufferPool.Return(alpha); + if (tRows is not null) WebpBufferPool.Return(tRows); + if (chromaRows is not null) WebpBufferPool.Return(chromaRows); + if (rgbaRow is not null) WebpBufferPool.Return(rgbaRow); + if (layoutRow is not null) WebpBufferPool.Return(layoutRow); + if (boxRow is not null) WebpBufferPool.Return(boxRow); + } + } + + /// + /// Upsamples chroma (fancy or nearest) of the rows of source exactly as does, + /// converts the window's columns to RGBA, puts each row into the decoder's layout, box-filters it when there is a + /// box, and converts each finished output row into the destination. + /// + private ref struct RegionSink : IVp8RowSink + { + private readonly int _width; + private readonly int _height; + private readonly bool _fancy; + private readonly ReadOnlySpan _alpha; + private readonly ushort[] _tRows; + private readonly byte[] _chromaRows; + private readonly PixelRect _source; + private readonly PixelFormat _layout; + private readonly byte[] _rgbaRow; + private readonly byte[]? _layoutRow; + private readonly BoxDownscaler? _box; + private readonly byte[]? _boxRow; + private readonly Span _destination; + private readonly int _destinationStride; + private readonly PixelFormat _destinationFormat; + private readonly int _outputWidth; + + public RegionSink(int width, int height, bool fancy, ReadOnlySpan alpha, ushort[] tRows, byte[] chromaRows, + PixelRect source, PixelFormat layout, byte[] rgbaRow, byte[]? layoutRow, BoxDownscaler? box, byte[]? boxRow, + Span destination, int destinationStride, PixelFormat destinationFormat, int outputWidth) + { + _width = width; + _height = height; + _fancy = fancy; + _alpha = alpha; + _tRows = tRows; + _chromaRows = chromaRows; + _source = source; + _layout = layout; + _rgbaRow = rgbaRow; + _layoutRow = layoutRow; + _box = box; + _boxRow = boxRow; + _destination = destination; + _destinationStride = destinationStride; + _destinationFormat = destinationFormat; + _outputWidth = outputWidth; + } + + /// Output rows written to the destination so far. + public int RowsWritten { get; private set; } + + public void Emit(ref byte y, int yStride, int yRow0, ref byte u, ref byte v, int uvStride, int uvRow0, + int first, int end) + { + int from = Math.Max(first, _source.Y); + int to = Math.Min(end, _source.Y + _source.Height); + if (from >= to) + { + return; + } + + int width = _width; + int chromaWidth = (width + 1) >> 1; + int chromaHeight = (_height + 1) >> 1; + int x0 = _source.X; + int windowWidth = _source.Width; + bool hasAlpha = !_alpha.IsEmpty; + // x0 + windowWidth ≤ width (the window lies inside the image), and the scratch rows hold width bytes per + // plane, the alpha plane width per row and rgbaRow windowWidth × 4: every column the converter touches, + // x0 + [0, windowWidth), is inside the luma cache row, both upsampled rows, the alpha row and rgbaRow. + ref byte uScratch = ref Unsafe.Add(ref MemoryMarshal.GetArrayDataReference(_chromaRows), x0); + ref byte vScratch = ref Unsafe.Add(ref MemoryMarshal.GetArrayDataReference(_chromaRows), width + x0); + ref byte rgba = ref MemoryMarshal.GetArrayDataReference(_rgbaRow); + for (int row = from; row < to; row++) + { + int chromaRow = row >> 1; + ref byte uRow = ref Unsafe.Add(ref u, (chromaRow - uvRow0) * uvStride); + ref byte vRow = ref Unsafe.Add(ref v, (chromaRow - uvRow0) * uvStride); + ref byte yRow = ref Unsafe.Add(ref y, (row - yRow0) * yStride + x0); + ref byte alphaRow = ref Unsafe.NullRef(); + if (hasAlpha) + { + alphaRow = ref Unsafe.Add(ref MemoryMarshal.GetReference(_alpha), row * width + x0); + } + + if (_fancy) + { + int adjacentRow = row % 2 == 0 + ? (chromaRow == 0 ? 0 : chromaRow - 1) + : (chromaRow == chromaHeight - 1 ? chromaRow : chromaRow + 1); + ref byte adjacentU = ref Unsafe.Add(ref u, (adjacentRow - uvRow0) * uvStride); + ref byte adjacentV = ref Unsafe.Add(ref v, (adjacentRow - uvRow0) * uvStride); + Span tU = _tRows.AsSpan(0, chromaWidth); + Span tV = _tRows.AsSpan(chromaWidth, chromaWidth); + Span uUpsampled = _chromaRows.AsSpan(0, width); + Span vUpsampled = _chromaRows.AsSpan(width, width); + ReadOnlySpan currentU = MemoryMarshal.CreateReadOnlySpan(ref uRow, chromaWidth); + ReadOnlySpan currentV = MemoryMarshal.CreateReadOnlySpan(ref vRow, chromaWidth); + ReadOnlySpan adjacentUSpan = MemoryMarshal.CreateReadOnlySpan(ref adjacentU, chromaWidth); + ReadOnlySpan adjacentVSpan = MemoryMarshal.CreateReadOnlySpan(ref adjacentV, chromaWidth); + if (Vector128.IsHardwareAccelerated) + { + UpsampleFancyVector128(currentU, adjacentUSpan, tU, uUpsampled); + UpsampleFancyVector128(currentV, adjacentVSpan, tV, vUpsampled); + } + else + { + UpsampleFancyScalar(currentU, adjacentUSpan, tU, uUpsampled); + UpsampleFancyScalar(currentV, adjacentVSpan, tV, vUpsampled); + } + } + else + { + // Nearest: column c takes chroma column c >> 1; only the window's columns are needed. + ref byte uOut = ref MemoryMarshal.GetArrayDataReference(_chromaRows); + ref byte vOut = ref Unsafe.Add(ref uOut, width); + for (int column = x0; column < x0 + windowWidth; column++) + { + Unsafe.Add(ref uOut, column) = Unsafe.Add(ref uRow, column >> 1); + Unsafe.Add(ref vOut, column) = Unsafe.Add(ref vRow, column >> 1); + } + } + + if (Vector256.IsHardwareAccelerated) + { + ConvertRowVector256Core(ref yRow, ref uScratch, ref vScratch, ref alphaRow, hasAlpha, ref rgba, windowWidth); + } + else if (Vector128.IsHardwareAccelerated) + { + ConvertRowVector128Core(ref yRow, ref uScratch, ref vScratch, ref alphaRow, hasAlpha, ref rgba, windowWidth); + } + else + { + ConvertRowScalarCore(ref yRow, ref uScratch, ref vScratch, ref alphaRow, hasAlpha, ref rgba, windowWidth); + } + + ReadOnlySpan layoutRow = _rgbaRow.AsSpan(0, windowWidth * 4); + if (_layoutRow is not null) + { + // Opaque: the decoder's layout is RGB, as the whole decode stores it. + PixelConverter.ConvertRow(layoutRow, PixelFormat.Rgba32, _layoutRow, PixelFormat.Rgb24, windowWidth); + layoutRow = _layoutRow.AsSpan(0, windowWidth * 3); + } + + if (_box is null) + { + WriteOutputRow(layoutRow); + } + else if (_box.PushRow(layoutRow)) + { + _box.TakeRow(_boxRow!); + WriteOutputRow(_boxRow!.AsSpan(0, _box.OutputRowBytes)); + } + } + } + + private void WriteOutputRow(ReadOnlySpan row) + { + PixelConverter.ConvertRow(row, _layout, + _destination.Slice(RowsWritten * _destinationStride, _outputWidth * _destinationFormat.BytesPerPixel), + _destinationFormat, _outputWidth); + RowsWritten++; + } + } +} diff --git a/src/Sdcb.SimdCodecs.Webp/WebpDecoder.cs b/src/Sdcb.SimdCodecs.Webp/WebpDecoder.cs index 8185147..388298f 100644 --- a/src/Sdcb.SimdCodecs.Webp/WebpDecoder.cs +++ b/src/Sdcb.SimdCodecs.Webp/WebpDecoder.cs @@ -15,7 +15,7 @@ namespace Sdcb.SimdCodecs.Webp; /// canvases above 67,108,864 pixels are rejected as malformed before any raster is allocated. /// /// -public static class WebpDecoder +public static partial class WebpDecoder { private static readonly Vector128 ChromaPairShuffleIndices = Vector128.Create( (byte)0, 8, 1, 9, 2, 10, 3, 11, 4, 12, 5, 13, 6, 14, 7, 15); diff --git a/src/Sdcb.SimdCodecs.Webp/WebpFrameReader.cs b/src/Sdcb.SimdCodecs.Webp/WebpFrameReader.cs index bead384..d9c1fe8 100644 --- a/src/Sdcb.SimdCodecs.Webp/WebpFrameReader.cs +++ b/src/Sdcb.SimdCodecs.Webp/WebpFrameReader.cs @@ -61,7 +61,7 @@ public bool TryDecodeFrame(int index, [NotNullWhen(true)] out IDecodedImage? ima return WebpImageDecoder.TryDecode(_data.Span, out image, options, cancellationToken); image = null; - options?.Validate(_frameInfo.Width, _frameInfo.Height, supportsScale: false); + options?.Validate(_frameInfo.Width, _frameInfo.Height); if (!TryComposite(animation, index, cancellationToken)) return false; var canvas = animation.Canvas; @@ -106,7 +106,7 @@ public bool TryDecodeFrameInto(int index, Span destination, int stride, Pi if (options is { IsFullImage: false }) return ImageDecoderFallback.TryDecodeFrameInto(this, index, destination, stride, format, options, cancellationToken); - options?.Validate(_frameInfo.Width, _frameInfo.Height, supportsScale: false); + options?.Validate(_frameInfo.Width, _frameInfo.Height); int width = _frameInfo.Width, height = _frameInfo.Height; if (!PixelConverter.TryGetLayout(width, height, format, stride, destination.Length, out var rowStride)) diff --git a/src/Sdcb.SimdCodecs.Webp/WebpImageDecoder.cs b/src/Sdcb.SimdCodecs.Webp/WebpImageDecoder.cs index 7f7c53c..d48d352 100644 --- a/src/Sdcb.SimdCodecs.Webp/WebpImageDecoder.cs +++ b/src/Sdcb.SimdCodecs.Webp/WebpImageDecoder.cs @@ -14,6 +14,12 @@ namespace Sdcb.SimdCodecs.Webp; /// ICCP profile is passed through, and the colour encoding is reported as assumed sRGB. Malformed or /// unsupported data makes the Try methods return false rather than throw. /// +/// +/// and of a lossy still decode natively: +/// only the rows the region's boxes cover are converted and box-filtered as they are decoded, and decoding stops after +/// the macroblock row holding the last of them, so the rest of the bitstream is not read or validated. The output is +/// exactly the generic path's (whole decode, box filter, crop). Lossless and animated input take the generic path. +/// /// public sealed class WebpImageDecoder : IMultiFrameImageDecoder { @@ -92,7 +98,7 @@ public static bool TryDecode(ReadOnlySpan data, [NotNullWhen(true)] out ID } var webpInfo = container.Info; - options?.Validate(webpInfo.Width, webpInfo.Height, supportsScale: false); + options?.Validate(webpInfo.Width, webpInfo.Height); int channels = webpInfo.HasAlpha ? 4 : 3; ImageMetadata? metadata = (container.HasExif || container.HasXmp || container.HasIccProfile) @@ -104,8 +110,35 @@ public static bool TryDecode(ReadOnlySpan data, [NotNullWhen(true)] out ID } : null; + var colorEncoding = EncodingOf(container.HasIccProfile ? container.IccProfile : default); + if (options is { IsFullImage: false } && webpInfo.Format == WebpFormat.Lossy) + { + // A lossy still decodes the region and scale natively: only the rows its boxes cover, streamed through + // the box filter, into a raster of the output's size. + var (outputWidth, outputHeight) = options.GetOutputSize(webpInfo.Width, webpInfo.Height); + var region = options.Region ?? new PixelRect(0, 0, outputWidth, outputHeight); + var output = RasterImage.Create(outputWidth, outputHeight, channels, SampleFormat.UInt8, options.Pool, + colorEncoding: colorEncoding, bitDepth: 8, metadata: metadata); + try + { + WebpDecoder.DecodeLossyRegion(container.Vp8Bitstream, container.AlphaData, container.HasAlphaChunk, + webpInfo.Width, webpInfo.Height, channels, options.ScaleDenominator, region, output.WritablePixels, + outputWidth * channels, channels == 4 ? PixelFormat.Rgba32 : PixelFormat.Rgb24, fancy: true, cancellationToken); + } + catch (Exception e) + { + output.Dispose(); + if (e is InvalidDataException or ArgumentException or NotSupportedException) + return false; + throw; + } + + image = output; + return true; + } + var raster = RasterImage.Create(webpInfo.Width, webpInfo.Height, channels, SampleFormat.UInt8, options?.Pool, - colorEncoding: EncodingOf(container.HasIccProfile ? container.IccProfile : default), bitDepth: 8, metadata: metadata); + colorEncoding: colorEncoding, bitDepth: 8, metadata: metadata); try { @@ -152,14 +185,14 @@ public static bool TryDecodeInto(ReadOnlySpan data, Span destination DecodeOptions? options = null, CancellationToken cancellationToken = default) { if (options is { IsFullImage: false }) - return ImageDecoderFallback.TryDecodeInto(TryDecode, data, destination, stride, format, options, cancellationToken); + return TryDecodeRegionInto(data, destination, stride, format, options, cancellationToken); if (!WebpDecoder.TryReadInfo(data, out WebpInfo info)) { return false; } - options?.Validate(info.Width, info.Height, supportsScale: false); + options?.Validate(info.Width, info.Height); if (!PixelConverter.TryGetLayout(info.Width, info.Height, format, stride, destination.Length, out int rowStride)) { @@ -211,6 +244,46 @@ public static bool TryDecodeInto(ReadOnlySpan data, Span destination } } + // A region or scale: native for a lossy still, the generic path (whole decode, box filter, crop) otherwise. The + // native rows are box-filtered in TryDecode's layout (RGB, or RGBA with alpha) and only then converted to the format, + // as the generic path does. + private static bool TryDecodeRegionInto(ReadOnlySpan data, Span destination, int stride, PixelFormat format, + DecodeOptions options, CancellationToken cancellationToken) + { + WebpContainer container; + try + { + container = WebpContainer.Read(data); + } + catch (Exception e) when (e is InvalidDataException or ArgumentException or NotSupportedException) + { + return false; + } + + WebpInfo info = container.Info; + if (info.Format != WebpFormat.Lossy) + return ImageDecoderFallback.TryDecodeInto(TryDecode, data, destination, stride, format, options, cancellationToken); + + options.Validate(info.Width, info.Height); + var (outputWidth, outputHeight) = options.GetOutputSize(info.Width, info.Height); + if (!PixelConverter.TryGetLayout(outputWidth, outputHeight, format, stride, destination.Length, out int rowStride)) + return false; + + int channels = info.HasAlpha ? 4 : 3; + try + { + WebpDecoder.DecodeLossyRegion(container.Vp8Bitstream, container.AlphaData, container.HasAlphaChunk, + info.Width, info.Height, channels, options.ScaleDenominator, + options.Region ?? new PixelRect(0, 0, outputWidth, outputHeight), destination, rowStride, format, + fancy: true, cancellationToken); + return true; + } + catch (Exception e) when (e is InvalidDataException or ArgumentException or NotSupportedException or OverflowException) + { + return false; + } + } + /// public static bool TryReadMetadata(ReadOnlySpan data, [NotNullWhen(true)] out ImageMetadata? metadata) { diff --git a/src/Sdcb.SimdCodecs/ImageDecoder.cs b/src/Sdcb.SimdCodecs/ImageDecoder.cs index f60300f..f112305 100644 --- a/src/Sdcb.SimdCodecs/ImageDecoder.cs +++ b/src/Sdcb.SimdCodecs/ImageDecoder.cs @@ -209,7 +209,6 @@ public bool TryGetClosestSize(int width, int height, out int closestWidth, out i /// Decodes the frame at its full fidelity (see ). Dispose the result. /// The frame is undecodable. - /// The codec cannot produce . /// does not lie inside the frame. /// was cancelled. public IDecodedImage Decode(DecodeOptions? options = null, CancellationToken cancellationToken = default) @@ -224,8 +223,8 @@ public IDecodedImage Decode(DecodeOptions? options = null, CancellationToken can throw new InvalidDataException( $"Frame {_index} could not be decoded: its raster ({w}x{h}, {Info.Channels} channels, {bytes} bytes) exceeds " + $"the maximum single-buffer size of {Array.MaxLength} bytes (2 GB). Decode it at a reduced scale " + - "(DecodeOptions.ScaleDenominator = 2, 4 or 8 where the codec scales natively, as JPEG does; see " + - "TryGetClosestSize) or decode a region of it (DecodeOptions.Region)."); + "(DecodeOptions.ScaleDenominator, a power of two; one the codec scales to natively, as JPEG does up to 8, " + + "never holds the full size, see TryGetClosestSize) or decode a region of it (DecodeOptions.Region)."); } throw new InvalidDataException($"Frame {_index} could not be decoded."); } @@ -240,7 +239,6 @@ public IDecodedImage Decode(DecodeOptions? options = null, CancellationToken can /// The destination does not fit, or the region does not lie inside the frame. /// The frame is undecodable, or has no integer projection (float samples, /// or an HDR transfer a codec refuses to project). - /// The codec cannot produce . /// was cancelled. public void CopyPixels(PixelRect? region, PixelFormat format, Span destination, int stride, DecodeOptions? options = null, CancellationToken cancellationToken = default) diff --git a/src/Sdcb.SimdCodecs/SimdCodecs.cs b/src/Sdcb.SimdCodecs/SimdCodecs.cs index cf9e59e..d2b8a9d 100644 --- a/src/Sdcb.SimdCodecs/SimdCodecs.cs +++ b/src/Sdcb.SimdCodecs/SimdCodecs.cs @@ -262,7 +262,6 @@ private void Check(int index) /// The decoded image. /// Scale, region and pool; null for the whole image at full size. /// Checked between coarse units of work. - /// The codec cannot produce . /// does not lie inside the image. /// was cancelled. public static bool TryDecode(ReadOnlySpan data, [NotNullWhen(true)] out IDecodedImage? image, @@ -290,7 +289,8 @@ public static bool TryReadInfo(ReadOnlySpan data, out ImageInfo info) /// /// The size closest to × that the codec decodes /// to natively, and the that gives it (see - /// ): the stored size and 1 for a codec that does not scale. False when + /// ): the stored size and 1 for a codec that does not scale. Every codec + /// accepts every other power of two too, at the cost of a decode at its native size and a box filter. False when /// no codec recognises the stream or the header is malformed. /// public static bool TryGetClosestSize(ReadOnlySpan data, int requestedWidth, int requestedHeight, @@ -314,7 +314,6 @@ public static bool TryGetClosestSize(ReadOnlySpan data, int requestedWidth /// pixels are. Returns false when no codec recognises the stream, the destination does not fit, or the payload is /// undecodable. /// - /// The codec cannot produce . /// does not lie inside the image. /// was cancelled. public static bool TryDecodeInto(ReadOnlySpan data, Span destination, int stride, PixelFormat format, diff --git a/tests/Sdcb.SimdCodecs.AotSmoke/FacadeSmoke.cs b/tests/Sdcb.SimdCodecs.AotSmoke/FacadeSmoke.cs index 2a01020..4f26f85 100644 --- a/tests/Sdcb.SimdCodecs.AotSmoke/FacadeSmoke.cs +++ b/tests/Sdcb.SimdCodecs.AotSmoke/FacadeSmoke.cs @@ -105,14 +105,20 @@ private static void Exercise(string label, byte[] data) var rgba = new byte[info.Width * info.Height * 4]; // Float samples (PFM) have no 8-bit layout without a tone policy: TryDecodeInto says no. Check(SimdCodecs.TryDecodeIntoRgba8(data, rgba) == (info.SampleFormat != SampleFormat.Float32), label, "TryDecodeIntoRgba8"); + // A scale past every codec's native ones: the box filter (BoxDownscaler's vector kernels), whole and a region. + var sixteenth = new DecodeOptions { ScaleDenominator = 16 }; + Check(SimdCodecs.TryDecode(data, out var small, sixteenth), label, "TryDecode 1/16"); + Check(SimdCodecs.TryDecode(data, out var smallRegion, sixteenth with { Region = new PixelRect(small.Width / 2, 0, small.Width - small.Width / 2, small.Height) }), + label, "TryDecode 1/16 region"); Check(SimdCodecs.TryOpenFrames(data, out var reader), label, "TryOpenFrames"); using (reader) { var last = reader.FrameCount - 1; Check(reader.TryDecodeFrame(last, out var frame), label, "TryDecodeFrame"); + Check(reader.TryDecodeFrame(last, out var smallFrame, new DecodeOptions { ScaleDenominator = 2 }), label, "TryDecodeFrame 1/2"); Console.WriteLine( $"facade {label} {SimdCodecs.DetectFormat(data)}/{info.Format} {info.Width}x{info.Height}x{info.Channels} {info.SampleFormat} frames={reader.FrameCount}" + - $"\t{Hash(Pixels(image))} {Hash(rgba)} {Hash(Pixels(frame))}"); + $"\t{Hash(Pixels(image))} {Hash(rgba)} {Hash(Pixels(frame))} {Hash(Pixels(small))} {Hash(Pixels(smallRegion))} {Hash(Pixels(smallFrame))}"); } } @@ -149,6 +155,12 @@ private static int Heif() Check(SimdCodecs.TryDecodeInto(heic, pixels, stride, format), "heif", $"TryDecodeInto {format}"); formats.Add($"{format}={Hash(pixels)}"); } + // Scaled into a padded destination: each tile boxes its pixels and converts the boxes straight into it. + var quarter = new DecodeOptions { ScaleDenominator = 4 }; + var (qw, qh) = quarter.GetScaledSize(info.Width, info.Height); + var boxed = new byte[(qw * 4 + 3) * qh]; + Check(SimdCodecs.TryDecodeInto(heic, boxed, qw * 4 + 3, PixelFormat.Bgra32, quarter), "heif", "TryDecodeInto Bgra32 1/4"); + formats.Add($"Bgra32/4={Hash(boxed)}"); Console.WriteLine($"facade heif-layouts\t{string.Join(' ', formats)}"); diff --git a/tests/Sdcb.SimdCodecs.Tests/BoxDownscaleTests.cs b/tests/Sdcb.SimdCodecs.Tests/BoxDownscaleTests.cs new file mode 100644 index 0000000..27017ba --- /dev/null +++ b/tests/Sdcb.SimdCodecs.Tests/BoxDownscaleTests.cs @@ -0,0 +1,355 @@ +using System.Runtime.InteropServices; +using ImageMagick; +using Sdcb.SimdCodecs.Abstractions; +using Sdcb.SimdCodecs.Abstractions.CodecSupport; +using Shouldly; + +namespace Sdcb.SimdCodecs.Tests; + +/// +/// , the frozen box filter behind beyond a codec's +/// native scales, against , a per-box transcription of the filter's documented definition, on every +/// forced tier (scalar, Vector128, Vector256): widths past every vector step and tail, partial edge boxes, 8-, 16-bit and +/// float samples, 1 to 4 channels, alpha all 0, all opaque and mixed; column windows; rows pushed one at a time; nothing +/// written past an output row. +/// +public sealed class BoxDownscaleTests +{ + public static readonly int[] Tiers = [BoxDownscaler.Scalar, BoxDownscaler.Vector128Tier, BoxDownscaler.Vector256Tier]; + + private static int Bps(SampleFormat f) => RasterImage.BytesPerSample(f); + + /// The box filter as DecodeOptions.ScaleDenominator documents it, one output pixel at a time. + internal static byte[] Reference(ReadOnlySpan pixels, int width, int height, int channels, SampleFormat format, int d) + { + var ow = (int)(((long)width + d - 1) / d); + var oh = (int)(((long)height + d - 1) / d); + var bps = Bps(format); + var result = new byte[ow * oh * channels * bps]; + var alpha = channels is 2 or 4; + double Sample(ReadOnlySpan p, int index) => format switch + { + SampleFormat.UInt8 => p[index], + SampleFormat.UInt16 => MemoryMarshal.Read(p[(index * 2)..]), + _ => MemoryMarshal.Read(p[(index * 4)..]), + }; + for (var by = 0; by < oh; by++) + for (var bx = 0; bx < ow; bx++) + { + var x0 = (long)bx * d; + var y0 = (long)by * d; + var x1 = Math.Min(x0 + d, width); + var y1 = Math.Min(y0 + d, height); + var n = (ulong)((x1 - x0) * (y1 - y0)); + var outIndex = (by * ow + bx) * channels; + if (format == SampleFormat.Float32) + { + var sums = new double[channels]; + for (var y = y0; y < y1; y++) + for (var x = x0; x < x1; x++) + { + var i = (int)((y * width + x) * channels); + if (alpha) + { + var a = Sample(pixels, i + channels - 1); + for (var k = 0; k < channels - 1; k++) + sums[k] += Sample(pixels, i + k) * a; + sums[channels - 1] += a; + } + else + { + for (var k = 0; k < channels; k++) + sums[k] += Sample(pixels, i + k); + } + } + for (var k = 0; k < channels; k++) + { + float v; + if (!alpha) v = (float)(sums[k] / n); + else if (k == channels - 1) v = (float)(sums[k] / n); + else v = sums[channels - 1] == 0 ? 0f : (float)(sums[k] / sums[channels - 1]); + MemoryMarshal.Write(result.AsSpan((outIndex + k) * 4), v); + } + continue; + } + + var isums = new ulong[channels]; + for (var y = y0; y < y1; y++) + for (var x = x0; x < x1; x++) + { + var i = (int)((y * width + x) * channels); + if (alpha) + { + var a = (ulong)Sample(pixels, i + channels - 1); + for (var k = 0; k < channels - 1; k++) + isums[k] += (ulong)Sample(pixels, i + k) * a; + isums[channels - 1] += a; + } + else + { + for (var k = 0; k < channels; k++) + isums[k] += (ulong)Sample(pixels, i + k); + } + } + for (var k = 0; k < channels; k++) + { + ulong v; + var asum = isums[channels - 1]; + if (!alpha || k == channels - 1) v = (isums[k] + n / 2) / n; + else v = asum == 0 ? 0 : (isums[k] + asum / 2) / asum; + if (format == SampleFormat.UInt8) result[outIndex + k] = (byte)v; + else MemoryMarshal.Write(result.AsSpan((outIndex + k) * 2), (ushort)v); + } + } + return result; + } + + /// Random samples; 0: alpha all 0, 1: all max, 2: random, 3: mixed (0, max or random). + internal static byte[] Image(int width, int height, int channels, SampleFormat format, int alphaKind, int seed) + { + var random = new Random(seed); + var count = width * height * channels; + var bytes = new byte[count * Bps(format)]; + for (var i = 0; i < count; i++) + { + var isAlpha = channels is 2 or 4 && i % channels == channels - 1; + var pick = !isAlpha ? 2 : alphaKind == 3 ? random.Next(3) : alphaKind; + switch (format) + { + case SampleFormat.UInt8: + bytes[i] = pick switch { 0 => 0, 1 => 255, _ => (byte)random.Next(256) }; + break; + case SampleFormat.UInt16: + MemoryMarshal.Write(bytes.AsSpan(i * 2), pick switch { 0 => (ushort)0, 1 => ushort.MaxValue, _ => (ushort)random.Next(65536) }); + break; + default: + MemoryMarshal.Write(bytes.AsSpan(i * 4), pick switch { 0 => 0f, 1 => 1f, _ => (float)(random.NextDouble() * 1.5 - 0.25) }); + break; + } + } + return bytes; + } + + private static readonly int[] Widths = [1, 2, 3, 4, 5, 7, 8, 9, 15, 16, 17, 31, 32, 33, 47, 63, 64, 65, 67]; + private static readonly int[] Factors = [2, 4, 8, 16, 1024]; + + public static TheoryData Cases() + { + var data = new TheoryData(); + foreach (var format in new[] { SampleFormat.UInt8, SampleFormat.UInt16, SampleFormat.Float32 }) + foreach (var channels in new[] { 1, 2, 3, 4 }) + foreach (var tier in Tiers) + data.Add(format, channels, tier); + return data; + } + + // Pushes the rows one at a time, each in a span longer than the row (the tail must not be read), and takes each output + // row into a span longer than the output row, prefilled with a sentinel (the tail must not be written). + private static byte[] Stream(byte[] pixels, int width, int height, int channels, SampleFormat format, int d, int tier) + { + var box = new BoxDownscaler(width, height, channels, format, d, tier); + var rowBytes = width * channels * Bps(format); + box.SourceRowBytes.ShouldBe(rowBytes); + var result = new List(); + var row = new byte[rowBytes + 7]; + var output = new byte[box.OutputRowBytes + 9]; + for (var y = 0; y < height; y++) + { + pixels.AsSpan(y * rowBytes, rowBytes).CopyTo(row); + row.AsSpan(rowBytes).Fill(0xC3); + var ready = box.PushRow(row); + ready.ShouldBe((y + 1) % d == 0 || y == height - 1); + if (!ready) + continue; + output.AsSpan().Fill(0xA5); + box.TakeRow(output); + output.AsSpan(box.OutputRowBytes).ToArray().ShouldAllBe(b => b == 0xA5); + result.AddRange(output.AsSpan(0, box.OutputRowBytes).ToArray()); + } + box.IsComplete.ShouldBeTrue(); + (box.RowsPushed, box.RowsTaken).ShouldBe((height, box.OutputHeight)); + return [.. result]; + } + + [Theory] + [MemberData(nameof(Cases))] + public void TheFilterIsTheReferenceOnEveryTier(SampleFormat format, int channels, int tier) + { + var seed = 0; + foreach (var d in Factors) + foreach (var width in Widths) + foreach (var height in new[] { 1, 3, d - 1, d + 1, 2 * d + 3 }.Where(h => h is > 0 and <= 40).Distinct()) + foreach (var alphaKind in channels is 2 or 4 ? new[] { 0, 1, 2, 3 } : [2]) + { + var pixels = Image(width, height, channels, format, alphaKind, ++seed); + var expected = Reference(pixels, width, height, channels, format, d); + Stream(pixels, width, height, channels, format, d, tier) + .ShouldBe(expected, $"{format} ch{channels} tier{tier} d{d} {width}x{height} alpha{alphaKind}"); + } + } + + [Theory] + [MemberData(nameof(Cases))] + public void TheOneShotHelperAndAWindowAreTheStreamedRows(SampleFormat format, int channels, int tier) + { + const int width = 67, height = 45; + var pixels = Image(width, height, channels, format, 3, channels * 31 + (int)format); + foreach (var d in new[] { 2, 4, 8, 16 }) + { + var expected = Reference(pixels, width, height, channels, format, d); + using var source = RasterImage.Wrap(width, height, channels, format, pixels, bitDepth: format == SampleFormat.UInt16 ? 12 : 0, + metadata: new ImageMetadata { Icc = new byte[] { 1, 2, 3 } }); + var (ow, oh) = new DecodeOptions { ScaleDenominator = d }.GetScaledSize(width, height); + using (var whole = BoxDownscaler.Downscale(source, d, new PixelRect(0, 0, ow, oh), null, tier)) + { + (whole.Width, whole.Height, whole.Channels, whole.SampleFormat).ShouldBe((ow, oh, channels, format)); + whole.BitDepth.ShouldBe(source.BitDepth); + whole.Metadata.Icc.ToArray().ShouldBe(new byte[] { 1, 2, 3 }); + whole.Pixels.ToArray().ShouldBe(expected); + } + + // A region of the scaled image: the window GetSourceRect gives, pushed as column slices of the full rows, + // is that crop of the whole downscale. + var bpp = channels * Bps(format); + foreach (var region in new PixelRect[] { new(0, 0, 1, 1), new(1, 1, ow - 1, oh - 1), new(ow - 1, oh - 1, 1, 1), new(ow / 3, 0, Math.Max(1, ow / 2), oh) }) + { + var crop = new byte[region.Width * region.Height * bpp]; + for (var y = 0; y < region.Height; y++) + expected.AsSpan(((region.Y + y) * ow + region.X) * bpp, region.Width * bpp).CopyTo(crop.AsSpan(y * region.Width * bpp)); + + using (var part = BoxDownscaler.Downscale(source, d, region, null, tier)) + part.Pixels.ToArray().ShouldBe(crop, $"d{d} {region}"); + + var rect = BoxDownscaler.GetSourceRect(region, width, height, d); + (rect.X % d, rect.Y % d).ShouldBe((0, 0)); + var box = new BoxDownscaler(rect.Width, rect.Height, channels, format, d, tier); + (box.OutputWidth, box.OutputHeight).ShouldBe((region.Width, region.Height)); + var streamed = new byte[crop.Length]; + for (var y = 0; y < rect.Height; y++) + { + var row = pixels.AsSpan(((rect.Y + y) * width + rect.X) * bpp); // the rest of the image follows: not read + if (box.PushRow(row)) + box.TakeRow(streamed.AsSpan(box.RowsTaken * box.OutputRowBytes)); + } + streamed.ShouldBe(crop, $"window d{d} {region}"); + } + } + } + + [Theory] + [InlineData(65536)] // the last height the 8-bit path sums in 32-bit columns: 255 · 255 · 65536 < 2^32 + [InlineData(65537)] // the first it sums in 64 bits + public void EightBitSumsDoNotOverflowAtTheColumnSumLimit(int height) + { + // One column of opaque white and one of a colour, every row: the largest possible column sums. + const int width = 2; + var pixels = new byte[width * height * 4]; + for (var i = 0; i < pixels.Length; i += 8) + { + pixels.AsSpan(i, 4).Fill(255); + pixels[i + 4] = 200; pixels[i + 5] = 100; pixels[i + 6] = 1; pixels[i + 7] = 255; + } + foreach (var tier in Tiers) + { + Stream(pixels, width, height, 4, SampleFormat.UInt8, 1 << 17, tier) + .ShouldBe(new byte[] { 228, 178, 128, 255 }, $"tier {tier}"); // (255 + 200 + 1)/2, (255 + 100 + 1)/2, (255 + 1 + 1)/2 + } + } + + /// + /// The 8-bit RGB / RGBA emit sums in 32 bits up to 2^16-pixel boxes (shifting for power-of-two ones) and in 64 bits + /// beyond: at and around that limit, with the largest sums (white, and a colour, at full alpha) and with noise, every + /// size gives the reference. + /// + [Theory] + [InlineData(256, 256, 256)] // 2^16 pixels, a power of two: the shift + [InlineData(256, 255, 256)] // 256 · 255: the 32-bit division + [InlineData(512, 128, 512)] // 2^16 again, from a wider box + [InlineData(512, 129, 512)] // 512 · 129 > 2^16: the 64-bit path + [InlineData(300, 256, 256)] // a full box and a partial one at the right edge + public void EightBitBoxesAtTheThirtyTwoBitLimitAreTheReference(int width, int height, int d) + { + foreach (var channels in new[] { 3, 4 }) + { + var extreme = new byte[width * height * channels]; + for (var i = 0; i < extreme.Length; i += 2 * channels) + { + extreme.AsSpan(i, channels).Fill(255); + if (i + channels < extreme.Length) + { + extreme[i + channels] = 200; extreme[i + channels + 1] = 100; extreme[i + channels + 2] = 1; + if (channels == 4) + extreme[i + 7] = 255; + } + } + foreach (var pixels in new[] { extreme, Image(width, height, channels, SampleFormat.UInt8, 3, width + height) }) + { + var expected = Reference(pixels, width, height, channels, SampleFormat.UInt8, d); + foreach (var tier in Tiers) + Stream(pixels, width, height, channels, SampleFormat.UInt8, d, tier).ShouldBe(expected, $"ch{channels} tier{tier}"); + } + } + } + + [Fact] + public void AHugeFactorAveragesTheWholeImage() + { + const int width = 37, height = 11; + var pixels = Image(width, height, 4, SampleFormat.UInt16, 3, 5); + using var source = RasterImage.Wrap(width, height, 4, SampleFormat.UInt16, pixels); + using var one = BoxDownscaler.Downscale(source, 1 << 30); + (one.Width, one.Height).ShouldBe((1, 1)); + one.Pixels.ToArray().ShouldBe(Reference(pixels, width, height, 4, SampleFormat.UInt16, 1 << 30)); + } + + [Fact] + public void FactorOneKeepsOpaquePixelsAndZeroesTransparentColour() + { + var pixels = new byte[] { 10, 20, 30, 255, 40, 50, 60, 0, 70, 80, 90, 128 }; + foreach (var tier in Tiers) + Stream(pixels, 3, 1, 4, SampleFormat.UInt8, 1, tier).ShouldBe(new byte[] { 10, 20, 30, 255, 0, 0, 0, 0, 70, 80, 90, 128 }); + } + + [Fact] + public void TheCallSequenceIsChecked() + { + var box = new BoxDownscaler(4, 3, 1, SampleFormat.UInt8, 2); + Should.Throw(() => box.TakeRow(new byte[2])); + Should.Throw(() => box.PushRow(new byte[3])); + box.PushRow(new byte[4]).ShouldBeFalse(); + box.PushRow(new byte[4]).ShouldBeTrue(); + Should.Throw(() => box.PushRow(new byte[4])); + Should.Throw(() => box.TakeRow(new byte[1])); + box.TakeRow(new byte[2]); + box.PushRow(new byte[4]).ShouldBeTrue(); // the last row completes the partial bottom box + box.TakeRow(new byte[2]); + box.IsComplete.ShouldBeTrue(); + Should.Throw(() => box.PushRow(new byte[4])); + + Should.Throw(() => new BoxDownscaler(0, 1, 1, SampleFormat.UInt8, 2)); + Should.Throw(() => new BoxDownscaler(1, 1, 5, SampleFormat.UInt8, 2)); + Should.Throw(() => new BoxDownscaler(1, 1, 1, SampleFormat.UInt8, 0)); + Should.Throw(() => new BoxDownscaler(1 << 17, 1 << 16, 1, SampleFormat.UInt8, 1 << 17)); // a 2^33-pixel box + Should.Throw(() => BoxDownscaler.GetSourceRect(new PixelRect(5, 0, 1, 1), 9, 9, 2)); + } + + /// + /// A foreign check: ImageMagick's -scale to an exact fraction averages each box of pixels too. On an opaque 8-bit + /// image whose sides the factor divides, it gives exactly our bytes, so neither side linearises or weights differently. + /// + [Theory] + [InlineData(2)] + [InlineData(4)] + [InlineData(8)] + public void MagickScaleOfAnOpaqueImageIsTheBoxFilter(int d) + { + const int width = 64, height = 48; + var pixels = Image(width, height, 3, SampleFormat.UInt8, 2, d); + using var image = new MagickImage(); + image.ReadPixels(pixels, new PixelReadSettings((uint)width, (uint)height, StorageType.Char, "RGB")); + image.Scale((uint)(width / d), (uint)(height / d)); + using var magick = image.GetPixels(); + var theirs = magick.ToByteArray(PixelMapping.RGB)!; + theirs.ShouldBe(Reference(pixels, width, height, 3, SampleFormat.UInt8, d)); + } +} diff --git a/tests/Sdcb.SimdCodecs.Tests/DecodeIntoTests.cs b/tests/Sdcb.SimdCodecs.Tests/DecodeIntoTests.cs index 092ebe7..df301c3 100644 --- a/tests/Sdcb.SimdCodecs.Tests/DecodeIntoTests.cs +++ b/tests/Sdcb.SimdCodecs.Tests/DecodeIntoTests.cs @@ -45,7 +45,7 @@ public sealed class DecodeIntoTests private static readonly Lazy> CorpusCache = new(BuildCorpus); - private static Dictionary Corpus() => CorpusCache.Value; + internal static Dictionary Corpus() => CorpusCache.Value; private static Dictionary BuildCorpus() { diff --git a/tests/Sdcb.SimdCodecs.Tests/DecodeOptionsTests.cs b/tests/Sdcb.SimdCodecs.Tests/DecodeOptionsTests.cs index fc5fcbd..319f7dd 100644 --- a/tests/Sdcb.SimdCodecs.Tests/DecodeOptionsTests.cs +++ b/tests/Sdcb.SimdCodecs.Tests/DecodeOptionsTests.cs @@ -157,15 +157,27 @@ public void TryGetClosestSizeRefusesWhatIsNotAnImage() } [Fact] - public void AnUnscalingCodecRefusesAScaleAndReportsItsStoredSize() + public void AnUnscalingCodecBoxFiltersAScaleAndReportsItsStoredSize() { var png = Stills["png"]; - foreach (var d in new[] { 2, 4, 8, 16 }) + SimdCodecs.TryDecode(png, out var full, cancellationToken: TestContext.Current.CancellationToken).ShouldBeTrue(); + using (full) { - var options = new DecodeOptions { ScaleDenominator = d }; - Should.Throw(() => SimdCodecs.TryDecode(png, out _, options, TestContext.Current.CancellationToken)); - Should.Throw(() => - SimdCodecs.TryDecodeInto(png, new byte[W * H * 4], 0, PixelFormat.Rgba32, options, TestContext.Current.CancellationToken)); + foreach (var d in new[] { 2, 4, 8, 16 }) + { + var options = new DecodeOptions { ScaleDenominator = d }; + var expected = BoxDownscaleTests.Reference(full.Pixels, W, H, 4, SampleFormat.UInt8, d); + var (w, h) = options.GetScaledSize(W, H); + SimdCodecs.TryDecode(png, out var scaled, options, TestContext.Current.CancellationToken).ShouldBeTrue(); + using (scaled) + { + (scaled.Width, scaled.Height).ShouldBe((w, h)); + scaled.Pixels.ToArray().ShouldBe(expected); + } + var into = new byte[w * h * 4]; + SimdCodecs.TryDecodeInto(png, into, 0, PixelFormat.Rgba32, options, TestContext.Current.CancellationToken).ShouldBeTrue(); + into.ShouldBe(expected); + } } foreach (var (wantedWidth, wantedHeight) in new[] { (1, 1), (W / 2, H / 2), (W, H), (W * 3, H * 3) }) @@ -397,19 +409,29 @@ public void AScaleThatIsNotAPowerOfTwoIsRefused(int denominator) => [Theory] [InlineData(16)] [InlineData(1 << 30)] - public void AnyPowerOfTwoIsAnOptionAndEachCodecSaysWhichItProduces(int denominator) + public void AnyPowerOfTwoIsAnOptionAndEveryCodecProducesIt(int denominator) { - // The options carry any power of two (JPEG 2000 resolution levels, say, go past 8); a codec refuses what it - // cannot produce natively. JPEG's DCT scaling stops at 1/8. + // The options carry any power of two (JPEG 2000 resolution levels, say, go past 8). JPEG's DCT scaling stops at + // 1/8: past it, the 1/8 decode is box-filtered by the rest. var options = new DecodeOptions { ScaleDenominator = denominator }; options.ScaleDenominator.ShouldBe(denominator); - options.GetScaledSize(W, H).ShouldBe(((W + denominator - 1) / denominator, (H + denominator - 1) / denominator)); + var (w, h) = ((W + denominator - 1) / denominator, (H + denominator - 1) / denominator); + options.GetScaledSize(W, H).ShouldBe((w, h)); var jpeg = ColourJpeg(); - Should.Throw(() => SimdCodecs.TryDecode(jpeg, out _, options, TestContext.Current.CancellationToken)) - .Message.ShouldContain("1/8"); - Should.Throw(() => - SimdCodecs.TryDecodeInto(jpeg, new byte[W * H * 4], 0, PixelFormat.Rgba32, options, TestContext.Current.CancellationToken)); - Should.Throw(() => SimdCodecs.TryDecode(Stills["png"], out _, options, TestContext.Current.CancellationToken)); + var eighth = JpegDecoder.Decode(jpeg, JpegScale.Eighth); + var expected = BoxDownscaleTests.Reference(eighth.Pixels, eighth.Width, eighth.Height, 4, SampleFormat.UInt8, denominator / 8); + SimdCodecs.TryDecode(jpeg, out var image, options, TestContext.Current.CancellationToken).ShouldBeTrue(); + using (image) + { + (image.Width, image.Height).ShouldBe((w, h)); + image.ToRgba8().ShouldBe(expected); + } + var into = new byte[w * h * 4]; + SimdCodecs.TryDecodeInto(jpeg, into, 0, PixelFormat.Rgba32, options, TestContext.Current.CancellationToken).ShouldBeTrue(); + into.ShouldBe(expected); + SimdCodecs.TryDecode(Stills["png"], out var png, options, TestContext.Current.CancellationToken).ShouldBeTrue(); + using (png) + (png.Width, png.Height).ShouldBe((w, h)); } [Fact] diff --git a/tests/Sdcb.SimdCodecs.Tests/HeifFuzzTests.cs b/tests/Sdcb.SimdCodecs.Tests/HeifFuzzTests.cs index 961dd36..e7a2df0 100644 --- a/tests/Sdcb.SimdCodecs.Tests/HeifFuzzTests.cs +++ b/tests/Sdcb.SimdCodecs.Tests/HeifFuzzTests.cs @@ -174,6 +174,14 @@ internal static void DecodeOne(byte[] data, string context, int layout) if (SimdCodecs.TryDecodeInto(data, destination, 0, format) && SimdCodecs.TryDecode(data, out var decoded)) { (decoded.Width, decoded.Height).ShouldBe((info.Width, info.Height), context); + // The scaled path (tiles boxed as they are written, or the fallback), into the same format. + var scale = new DecodeOptions { ScaleDenominator = 2 << layout % 3 }; + var (sw, sh) = scale.GetScaledSize(info.Width, info.Height); + if (SimdCodecs.TryDecodeInto(data, destination, 0, format, scale) && SimdCodecs.TryDecode(data, out var scaled, scale)) + { + using (scaled) + (scaled.Width, scaled.Height).ShouldBe((sw, sh), context); + } } } else diff --git a/tests/Sdcb.SimdCodecs.Tests/HeifScaledTests.cs b/tests/Sdcb.SimdCodecs.Tests/HeifScaledTests.cs new file mode 100644 index 0000000..29f6bf3 --- /dev/null +++ b/tests/Sdcb.SimdCodecs.Tests/HeifScaledTests.cs @@ -0,0 +1,192 @@ +using Sdcb.SimdCodecs.Abstractions; +using Sdcb.SimdCodecs.Heif; +using Shouldly; + +namespace Sdcb.SimdCodecs.Tests; + +/// +/// HEIF's native scaled decode ( > 1): each tile boxes the pixels it +/// converts. The output must be exactly what the box-filter fallback gives — the box filter (an independent reference, +/// ) of the whole Rgb24 / Rgb48 decode, cropped to the region, converted to the +/// format with — for every format, through clap / irot / imir chains, single images and +/// grids, Main and Main 10. Bytes between the destination's rows stay untouched. +/// +public sealed class HeifScaledTests +{ + private static readonly int[] Factors = [2, 4, 8, 16]; + + public static TheoryData Fixtures() => new() + { + "synth-screen-testsrc2-1280x720-main8.heic", + "synth-photo-mangrove-1599x1199-main8.heic", + "hevc-main-3072x4096-grid.heic", + "hevc-main10-3072x4096-grid.heic", + }; + + [Theory] + [MemberData(nameof(Fixtures))] + public void Native_scaled_decode_is_the_box_filter_of_the_whole_decode(string fixture) + { + var data = HeifTests.ReadFixture(fixture); + var ct = TestContext.Current.CancellationToken; + var rng = new Random(fixture.Length * 6007); + var grid = HeifContainer.Parse(data).Tiles.Count > 1; + var native = 0; + var fallback = 0; + for (var iteration = 0; iteration < 5; iteration++) + { + var container = HeifContainer.Parse(data); + container.Transforms.Clear(); + if (iteration > 0) + AddTransforms(container, rng, aligned: iteration < 4); + var (w, h) = container.DisplaySize(); + var depth = container.TileConfigs[0].ParseSps().BitDepthY; + var layout = depth > 8 ? PixelFormat.Rgb48 : PixelFormat.Rgb24; + var whole = new byte[w * h * layout.BytesPerPixel]; + HeifDecoder.DecodeInto(data, container, whole, w * layout.BytesPerPixel, layout, ct); + + foreach (var d in Factors) + { + var boxed = BoxDownscaleTests.Reference(whole, w, h, 3, layout.Sample, d); + int sw = (w + d - 1) / d, sh = (h + d - 1) / d; + PixelRect?[] regions = + [ + null, + new PixelRect(rng.Next(0, sw / 2), rng.Next(0, sh / 2), rng.Next(1, sw / 2 + 1), rng.Next(1, sh / 2 + 1)), + // A small one at the far corner: partial boxes at the right and bottom edges. + new PixelRect(sw - Math.Min(sw, 7), sh - Math.Min(sh, 5), Math.Min(sw, 7), Math.Min(sh, 5)), + ]; + if (!HeifDecoder.ScalesNatively(container, d, null)) + { + // No tile is boxed natively when the seams miss the box edges; the adapter then takes the fallback. + iteration.ShouldBe(4, $"{fixture} d{d}: only the misaligned chain may miss the box edges"); + grid.ShouldBeTrue($"{fixture} d{d}: a single coded image always scales natively"); + fallback++; + continue; + } + foreach (var region in regions) + { + HeifDecoder.ScalesNatively(container, d, region).ShouldBeTrue(); + var r = region ?? new PixelRect(0, 0, sw, sh); + var crop = Crop(boxed, sw, layout.BytesPerPixel, r); + // Every format for a single image or a small region; a rotating pair for a whole grid. + var all = PixelFormat.All.ToArray(); + var formats = grid && region is null ? [all[(iteration + d) % all.Length], all[(iteration + d + 7) % all.Length]] : all; + foreach (var format in formats) + { + var bpp = format.BytesPerPixel; + var stride = r.Width * bpp + 3; + var expected = new byte[(r.Height - 1) * stride + r.Width * bpp]; + var actual = new byte[expected.Length]; + Array.Fill(expected, (byte)0xA5); + Array.Fill(actual, (byte)0xA5); + PixelConverter.Convert(crop, 0, layout, expected, stride, format, r.Width, r.Height); + HeifDecoder.DecodeInto(data, container, actual, stride, format, ct, region, d); + if (!actual.AsSpan().SequenceEqual(expected)) + Assert.Fail($"{fixture} iteration {iteration} d{d} {format} region {region}: {FirstDifference(actual, expected, stride, bpp)}"); + native++; + } + } + } + } + native.ShouldBeGreaterThan(0); + if (grid) fallback.ShouldBeGreaterThan(0, $"{fixture}: the misaligned chain should take the fallback"); + } + + /// The adapter end to end, at every format: native (no transform, aligned grid seams) equals the box filter + /// of the full , converted. + [Theory] + [MemberData(nameof(Fixtures))] + public void Adapter_scaled_decode_equals_the_converted_box_filter(string fixture) + { + var data = HeifTests.ReadFixture(fixture); + var ct = TestContext.Current.CancellationToken; + HeifImageDecoder.TryDecode(data, out var full, cancellationToken: ct).ShouldBeTrue(); + using (full) + { + var layout = PixelFormat.ForChannels(full.Channels, full.SampleFormat); + foreach (var d in Factors) + { + HeifDecoder.ScalesNatively(HeifContainer.Parse(data), d, null).ShouldBeTrue($"{fixture} d{d}"); + var boxed = BoxDownscaleTests.Reference(full.Pixels, full.Width, full.Height, 3, full.SampleFormat, d); + var (sw, sh) = new DecodeOptions { ScaleDenominator = d }.GetScaledSize(full.Width, full.Height); + foreach (var region in new PixelRect?[] { null, new PixelRect(sw / 3, sh / 5, sw / 2, sh / 3) }) + { + var r = region ?? new PixelRect(0, 0, sw, sh); + var crop = Crop(boxed, sw, layout.BytesPerPixel, r); + var options = new DecodeOptions { ScaleDenominator = d, Region = region }; + HeifImageDecoder.TryDecode(data, out var image, options, ct).ShouldBeTrue(); + using (image) + { + (image.Width, image.Height, image.BitDepth, image.SampleFormat).ShouldBe((r.Width, r.Height, full.BitDepth, full.SampleFormat)); + image.Pixels.SequenceEqual(crop).ShouldBeTrue($"{fixture} d{d} {region}"); + } + foreach (var format in PixelFormat.All) + { + var expected = new byte[r.Width * r.Height * format.BytesPerPixel]; + PixelConverter.Convert(crop, 0, layout, expected, 0, format, r.Width, r.Height); + var into = new byte[expected.Length]; + HeifImageDecoder.TryDecodeInto(data, into, 0, format, options, ct).ShouldBeTrue(); + into.AsSpan().SequenceEqual(expected).ShouldBeTrue($"{fixture} d{d} {region} {format}"); + HeifImageDecoder.TryDecodeInto(data, into.AsSpan(0, expected.Length - 1), 0, format, options, ct) + .ShouldBeFalse($"{fixture} d{d} {region} {format} short destination"); + } + } + } + } + } + + [Fact] + public void Misaligned_grid_seams_take_the_fallback() + { + var data = HeifTests.ReadFixture("hevc-main-3072x4096-grid.heic"); + var container = HeifContainer.Parse(data); + container.Transforms.Clear(); + // A crop starting one pixel in: every vertical seam lands one pixel off a box edge. + container.Transforms.Add(new HeifContainer.Transform(HeifContainer.PropClap, 0, 0, + [container.CanvasWidth - 2, 1, container.CanvasHeight, 1, 0, 1, 0, 1])); + HeifDecoder.ScalesNatively(container, 2, null).ShouldBeFalse(); + Should.Throw(() => + HeifDecoder.DecodeInto(data, container, new byte[1536 * 2048 * 3], 1535 * 3, PixelFormat.Rgb24, factor: 2)); + // The grid's own 512-pixel seams line up with every factor up to 512, and with none beyond it. + container.Transforms.Clear(); + HeifDecoder.ScalesNatively(container, 512, null).ShouldBeTrue(); + HeifDecoder.ScalesNatively(container, 1024, null).ShouldBeFalse(); + } + + // Random clap / irot / imir chains. Aligned: quarter turns and mirrors only, whose seams stay on multiples of 16 for + // these fixtures (sizes that are multiples of 16, or a single image); misaligned: a crop one pixel in from the left. + private static void AddTransforms(HeifContainer container, Random rng, bool aligned) + { + int w = container.CanvasWidth, h = container.CanvasHeight; + if (!aligned) + { + var cw = w - 2 - rng.Next(0, 9); + var ch = h - rng.Next(0, 9); + // Centre offset (cw − w + 2) / 2 ... places the left edge at x = 1. + container.Transforms.Add(new HeifContainer.Transform(HeifContainer.PropClap, 0, 0, + [cw, 1, ch, 1, 2 + cw - w, 2, ch - h, 2])); + } + for (var t = rng.Next(1, 4); t > 0; t--) + { + if (rng.Next(2) == 0) + container.Transforms.Add(new HeifContainer.Transform(HeifContainer.PropIrot, rng.Next(1, 4), 0, null)); + else + container.Transforms.Add(new HeifContainer.Transform(HeifContainer.PropImir, rng.Next(2), 0, null)); + } + } + + private static byte[] Crop(byte[] pixels, int width, int bpp, PixelRect r) + { + var result = new byte[r.Width * r.Height * bpp]; + for (var y = 0; y < r.Height; y++) + pixels.AsSpan(((r.Y + y) * width + r.X) * bpp, r.Width * bpp).CopyTo(result.AsSpan(y * r.Width * bpp)); + return result; + } + + private static string FirstDifference(byte[] actual, byte[] expected, int stride, int bpp) + { + var i = actual.AsSpan().CommonPrefixLength(expected); + return $"first difference at row {i / stride}, pixel {i % stride / bpp} (byte {i}): {actual[i]} != {expected[i]}"; + } +} diff --git a/tests/Sdcb.SimdCodecs.Tests/Jpeg2000FacadeTests.cs b/tests/Sdcb.SimdCodecs.Tests/Jpeg2000FacadeTests.cs index c95ef65..605e3f9 100644 --- a/tests/Sdcb.SimdCodecs.Tests/Jpeg2000FacadeTests.cs +++ b/tests/Sdcb.SimdCodecs.Tests/Jpeg2000FacadeTests.cs @@ -224,7 +224,8 @@ public void TryGetClosestSize_PicksTheLargestReductionThatCoversTheRequest( (image.Width, image.Height).ShouldBe((width, height)); } - /// Odd sizes round up (⌈w / d⌉), and a codestream is never scaled past its levels. + /// Odd sizes round up (⌈w / d⌉), and a codestream is never scaled natively past its levels: beyond them the + /// decode at the largest reduction is box-filtered by the rest. [Fact] public void ClosestSize_RoundsUpAndNeverExceedsTheLevels() { @@ -234,10 +235,23 @@ public void ClosestSize_RoundsUpAndNeverExceedsTheLevels() SimdCodecs.TryGetClosestSize(File("nodwt-struct32.j2k"), 1, 1, out w, out h, out d).ShouldBeTrue(); (w, h, d).ShouldBe((32, 32, 1)); - Should.Throw(() => SimdCodecs.TryDecode(File("odd37x23.j2k"), out _, - new DecodeOptions { ScaleDenominator = 8 }, TestContext.Current.CancellationToken)); - Should.Throw(() => SimdCodecs.TryDecode(File("nodwt-struct32.j2k"), out _, - new DecodeOptions { ScaleDenominator = 2 }, TestContext.Current.CancellationToken)); + foreach (var (file, k, native) in new[] { ("odd37x23.j2k", 8, 4), ("odd37x23.j2k", 32, 4), ("nodwt-struct32.j2k", 2, 1) }) + { + var ct = TestContext.Current.CancellationToken; + SimdCodecs.TryDecode(File(file), out var reduced, new DecodeOptions { ScaleDenominator = native }, ct).ShouldBeTrue(); + using (reduced) + { + var expected = BoxDownscaleTests.Reference(reduced.Pixels, reduced.Width, reduced.Height, reduced.Channels, + reduced.SampleFormat, k / native); + SimdCodecs.TryDecode(File(file), out var scaled, new DecodeOptions { ScaleDenominator = k }, ct).ShouldBeTrue(); + using (scaled) + { + SimdCodecs.TryReadInfo(File(file), out var info).ShouldBeTrue(); + (scaled.Width, scaled.Height).ShouldBe(new DecodeOptions { ScaleDenominator = k }.GetScaledSize(info.Width, info.Height)); + scaled.Pixels.ToArray().ShouldBe(expected, $"{file} d{k}"); + } + } + } } /// A region is in the scaled image's coordinates and is exactly that crop of the scaled decode. diff --git a/tests/Sdcb.SimdCodecs.Tests/PngRegionDecodeTests.cs b/tests/Sdcb.SimdCodecs.Tests/PngRegionDecodeTests.cs new file mode 100644 index 0000000..cd24376 --- /dev/null +++ b/tests/Sdcb.SimdCodecs.Tests/PngRegionDecodeTests.cs @@ -0,0 +1,429 @@ +using System.Buffers.Binary; +using System.Reflection; +using Sdcb.SimdCodecs.Abstractions; +using Sdcb.SimdCodecs.Abstractions.CodecSupport; +using Sdcb.SimdCodecs.Png; +using Shouldly; + +namespace Sdcb.SimdCodecs.Tests; + +/// +/// and on a non-interlaced PNG decode a +/// window of rows (PngReader.DecodeWindow), box-filtering as they go and stopping after the last row needed. The +/// contract is exact: the result equals the generic path, the whole image decoded in 's +/// layout, then (or a crop), then for +/// , for every colour type, depth, tRNS, scale, region and pixel format. +/// +public sealed class PngRegionDecodeTests +{ + private const int W = 37; + private const int H = 29; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private static readonly PixelFormat[] AllFormats = + [ + .. typeof(PixelFormat).GetProperties(BindingFlags.Public | BindingFlags.Static) + .Where(p => p.PropertyType == typeof(PixelFormat)) + .Select(p => (PixelFormat)p.GetValue(null)!), + ]; + + public static TheoryData Files() => [.. Corpus().Keys]; + + private static Dictionary? _corpus; + + private static Dictionary Corpus() + { + if (_corpus is not null) return _corpus; + var files = new Dictionary(); + // ImageMagick (libpng) writes each colour type and depth: foreign bytes, its own filter choices. + (int ColorType, int Depth)[] layouts = + [(0, 1), (0, 2), (0, 4), (0, 8), (0, 16), (2, 8), (2, 16), (3, 1), (3, 2), (3, 4), (3, 8), (4, 8), (4, 16), (6, 8), (6, 16)]; + foreach (var (colorType, depth) in layouts) + files[$"magick ct{colorType} d{depth}"] = PngRgba8ExpansionTests.Encode(colorType, depth, false, W, H); + files["magick ct3 d8 palette alpha"] = PngRgba8ExpansionTests.Encode(3, 8, true, W, H); + files["magick ct0 d8 1x1"] = PngRgba8ExpansionTests.Encode(0, 8, false, 1, 1); + files["magick ct6 d8 1x40"] = PngRgba8ExpansionTests.Encode(6, 8, false, 1, 40); + files["magick ct2 d16 40x1"] = PngRgba8ExpansionTests.Encode(2, 16, false, 40, 1); + // Adam7 takes the generic path, which must still give the same result. + files["magick ct6 d8 interlaced"] = PngRgba8ExpansionTests.Encode(6, 8, false, W, H, interlace: true); + + // A tRNS transparent colour, the first pixel's, so some pixels match it. + foreach (var (colorType, depth) in new[] { (0, 1), (0, 2), (0, 4), (0, 8), (0, 16), (2, 8), (2, 16) }) + { + var plain = PngRgba8ExpansionTests.Encode(colorType, depth, false, W, H); + var image = PngReader.Decode(plain, Ct); + var channels = colorType == 0 ? 1 : 3; + var bytesPerSample = image.BitDepth / 8; + var key = new byte[2 * channels]; + for (var c = 0; c < channels; c++) + { + int v = bytesPerSample == 2 + ? BinaryPrimitives.ReadUInt16BigEndian(image.Pixels.AsSpan(2 * c)) + : image.Pixels[c]; + if (depth < 8) + v = v * ((1 << depth) - 1) / 255; // the unpacked level back to the file's depth + BinaryPrimitives.WriteUInt16BigEndian(key.AsSpan(2 * c), (ushort)v); + } + files[$"magick ct{colorType} d{depth} tRNS"] = PngTransparentColorTests.WithChunk(plain, "tRNS"u8, key); + } + + // Our own encoder, with its filter choices. + var random = new Random(5); + var rgba = new byte[W * H * 4]; + random.NextBytes(rgba); + for (var i = 0; i < W * H; i++) + rgba[4 * i + 3] = (byte)(i % 5 == 0 ? 0 : rgba[4 * i + 3]); + files["ours rgba8"] = PngWriter.Encode(rgba, W, H); + var rgb = new byte[W * H * 3]; + for (var i = 0; i < rgb.Length; i++) rgb[i] = (byte)((i * 7) ^ (i / 97)); + files["ours rgb8"] = PngWriter.EncodeRgb8(rgb, W, H); + var gray16 = new ushort[W * H]; + for (var i = 0; i < gray16.Length; i++) gray16[i] = (ushort)(i * 977); + files["ours gray16"] = PngWriter.EncodeGray16(gray16, W, H); + return _corpus = files; + } + + // Regions of the scaled image: the whole of it, corners, the last row and column, a middle block. + private static IEnumerable Regions(int width, int height) + { + yield return null; + yield return new PixelRect(0, 0, width, height); + yield return new PixelRect(0, 0, 1, 1); + yield return new PixelRect(width - 1, height - 1, 1, 1); + yield return new PixelRect(0, height - 1, width, 1); + yield return new PixelRect(width - 1, 0, 1, height); + yield return new PixelRect(width / 3, height / 3, Math.Max(1, width / 2), Math.Max(1, height / 3)); + } + + // The generic path's result: the whole image, box-filtered and cropped. + private static RasterImage Reference(IDecodedImage full, int factor, PixelRect? region) + { + if (factor > 1) + return BoxDownscaler.Downscale(full, factor, region); + return RasterImage.Crop(full, region ?? new PixelRect(0, 0, full.Width, full.Height)); + } + + [Theory] + [MemberData(nameof(Files))] + public void TryDecode_EqualsTheGenericPath(string name) + { + var png = Corpus()[name]; + PngImageDecoder.TryDecode(png, out var full, cancellationToken: Ct).ShouldBeTrue(); + using var _ = full; + foreach (var factor in new[] { 1, 2, 4, 8, 16 }) + { + var (sw, sh) = ((full!.Width + factor - 1) / factor, (full.Height + factor - 1) / factor); + foreach (var region in Regions(sw, sh)) + { + if (factor == 1 && region is null) continue; + using var expected = Reference(full, factor, region); + var options = new DecodeOptions { ScaleDenominator = factor, Region = region }; + PngImageDecoder.TryDecode(png, out var actual, options, Ct).ShouldBeTrue(); + using (actual) + { + var what = $"{name} 1/{factor} {region}"; + (actual!.Width, actual.Height, actual.Channels, actual.SampleFormat, actual.BitDepth) + .ShouldBe((expected.Width, expected.Height, expected.Channels, expected.SampleFormat, expected.BitDepth), what); + actual.ColorEncoding.ShouldBe(expected.ColorEncoding, what); + actual.Pixels.ToArray().ShouldBe(expected.Pixels.ToArray(), what); + } + } + } + } + + [Theory] + [MemberData(nameof(Files))] + public void TryDecodeInto_EqualsTheGenericPath_ForEveryFormat(string name) + { + var png = Corpus()[name]; + PngImageDecoder.TryDecode(png, out var full, cancellationToken: Ct).ShouldBeTrue(); + using var _ = full; + foreach (var factor in new[] { 1, 2, 4, 8, 16 }) + { + var (sw, sh) = ((full!.Width + factor - 1) / factor, (full.Height + factor - 1) / factor); + foreach (var region in Regions(sw, sh)) + { + if (factor == 1 && region is null) continue; + using var reference = Reference(full, factor, region); + var options = new DecodeOptions { ScaleDenominator = factor, Region = region }; + foreach (var format in AllFormats) + { + // Padded rows: the bytes between them stay untouched. + var stride = reference.Width * format.BytesPerPixel + 3; + var size = stride * reference.Height; + var expected = new byte[size]; + var actual = new byte[size]; + expected.AsSpan().Fill(0xCD); + actual.AsSpan().Fill(0xCD); + PixelConverter.TryConvert(reference, expected, stride, format).ShouldBeTrue(); + var what = $"{name} 1/{factor} {region} {format}"; + PngImageDecoder.TryDecodeInto(png, actual, stride, format, options, Ct).ShouldBeTrue(what); + actual.ShouldBe(expected, what); + } + } + } + } + + [Fact] + public void TryDecodeInto_TooSmallDestination_IsFalse() + { + var png = Corpus()["magick ct6 d8"]; + var options = new DecodeOptions { ScaleDenominator = 4, Region = new PixelRect(1, 1, 3, 2) }; + PngImageDecoder.TryDecodeInto(png, new byte[3 * 2 * 4 - 1], 0, PixelFormat.Rgba32, options, Ct).ShouldBeFalse(); + PngImageDecoder.TryDecodeInto(png, new byte[3 * 2 * 4], 0, PixelFormat.Rgba32, options, Ct).ShouldBeTrue(); + } + + [Fact] + public void ARegionOutsideTheScaledImage_Throws() + { + var png = Corpus()["magick ct2 d8"]; + var options = new DecodeOptions { ScaleDenominator = 4, Region = new PixelRect(9, 0, 2, 1) }; // 10 columns at 1/4 + Should.Throw(() => PngImageDecoder.TryDecode(png, out _, options, Ct)); + Should.Throw(() => PngImageDecoder.TryDecodeInto(png, new byte[64], 0, PixelFormat.Rgba32, options, Ct)); + } + + [Fact] + public void TryDecode_RentsFromThePool() + { + var png = Corpus()["magick ct6 d8"]; + PngImageDecoder.TryDecode(png, out var full, cancellationToken: Ct).ShouldBeTrue(); + using var _ = full; + using var expected = BoxDownscaler.Downscale(full!, 2); + var pool = new CountingPool(); + PngImageDecoder.TryDecode(png, out var actual, new DecodeOptions { ScaleDenominator = 2, Pool = pool }, Ct).ShouldBeTrue(); + pool.Rented.ShouldBe(1); + actual!.Pixels.ToArray().ShouldBe(expected.Pixels.ToArray()); + actual.Dispose(); + pool.Returned.ShouldBe(1); + } + + // ---- Early stop ------------------------------------------------------------------------------------------------- + + private const int StopW = 16; + private const int StopH = 12; + private const int CorruptRow = 7; + + // 8-bit greyscale, filter None. One stored deflate block holds the rows above CorruptRow and the first bytes of it; + // the next block has the reserved block type (11), so inflating row CorruptRow fails while every row above it is + // intact. (The good block ends inside a row: zlib reads a block header as soon as the previous block is done, so a + // block boundary at the end of the last row read would already meet the bad header.) + private static byte[] StoredPngWithCorruptRow(out byte[] gray) + { + gray = new byte[StopW * StopH]; + for (var i = 0; i < gray.Length; i++) gray[i] = (byte)(i * 13 + 1); + var raw = new List(); + for (var y = 0; y < StopH; y++) + { + raw.Add(0); + raw.AddRange(gray.AsSpan(y * StopW, StopW).ToArray()); + } + var good = CorruptRow * (StopW + 1) + 5; + var z = new List { 0x78, 0x01, 0x00, (byte)good, (byte)(good >> 8), (byte)~good, (byte)(~good >> 8) }; + z.AddRange(raw.Take(good)); + z.Add(0x07); // BFINAL 1, BTYPE 11: reserved + z.AddRange(raw.Skip(good)); // never inflated + z.AddRange([0, 0, 0, 0]); // a stand-in Adler-32, never reached + + using var ms = new MemoryStream(); + ms.Write([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); + var ihdr = new byte[13]; + BinaryPrimitives.WriteInt32BigEndian(ihdr, StopW); + BinaryPrimitives.WriteInt32BigEndian(ihdr.AsSpan(4), StopH); + ihdr[8] = 8; // depth 8, colour type 0, no interlace + Chunk(ms, "IHDR"u8, ihdr); + Chunk(ms, "IDAT"u8, [.. z]); + Chunk(ms, "IEND"u8, []); + return ms.ToArray(); + + static void Chunk(Stream s, ReadOnlySpan type, ReadOnlySpan data) + { + Span u32 = stackalloc byte[4]; + BinaryPrimitives.WriteInt32BigEndian(u32, data.Length); + s.Write(u32); + s.Write(type); + s.Write(data); + BinaryPrimitives.WriteUInt32BigEndian(u32, PngCrc32.Compute(type, data)); + s.Write(u32); + } + } + + [Fact] + public void CorruptImageDataBelowTheRegion_IsNeverRead() + { + var png = StoredPngWithCorruptRow(out var gray); + // The whole image does not decode: the stream is corrupt. + PngImageDecoder.TryDecode(png, out _, cancellationToken: Ct).ShouldBeFalse(); + + // A region that ends above the corrupt row decodes; the decode stops after the region's last row. + var region = new PixelRect(3, 2, 9, CorruptRow - 2); + PngImageDecoder.TryDecode(png, out var image, new DecodeOptions { Region = region }, Ct).ShouldBeTrue(); + using (image) + { + var expected = new byte[region.Width * region.Height]; + for (var y = 0; y < region.Height; y++) + gray.AsSpan((region.Y + y) * StopW + region.X, region.Width).CopyTo(expected.AsSpan(y * region.Width)); + image!.Pixels.ToArray().ShouldBe(expected); + } + var rgba = new byte[region.Width * region.Height * 4]; + PngImageDecoder.TryDecodeInto(png, rgba, 0, PixelFormat.Rgba32, new DecodeOptions { Region = region }, Ct).ShouldBeTrue(); + rgba[4 * (region.Width * region.Height - 1)].ShouldBe(gray[(region.Y + region.Height - 1) * StopW + region.X + region.Width - 1]); + + // At 1/4 the first output row averages rows 0..3, above the corrupt row; at 1/8 rows 0..7, which reach it. + PngImageDecoder.TryDecode(png, out var scaled, new DecodeOptions { ScaleDenominator = 4, Region = new PixelRect(0, 0, 4, 1) }, Ct) + .ShouldBeTrue(); + scaled!.Dispose(); + PngImageDecoder.TryDecode(png, out _, new DecodeOptions { ScaleDenominator = 8, Region = new PixelRect(0, 0, 1, 1) }, Ct) + .ShouldBeFalse(); + } + + [Fact] + public void CorruptImageDataInsideOrAboveTheRegion_IsInvalidData() + { + var png = StoredPngWithCorruptRow(out _); + var header = PngReader.ReadHeader(png, out var idat); + var format = PngReader.FidelityFormat(header); + foreach (var rect in new[] { new PixelRect(0, CorruptRow, 4, 1), new PixelRect(0, CorruptRow + 2, StopW, 3) }) + { + Should.Throw(() => + PngReader.DecodeWindow(png, header, idat, rect, 1, new byte[rect.Width * rect.Height], 0, format, Ct)); + PngImageDecoder.TryDecode(png, out _, new DecodeOptions { Region = rect }, Ct).ShouldBeFalse(); + PngImageDecoder.TryDecodeInto(png, new byte[rect.Width * rect.Height], 0, PixelFormat.Gray8, + new DecodeOptions { Region = rect }, Ct).ShouldBeFalse(); + } + } + + [Fact] + public void AStreamThatRunsOnPastTheLastRow_FailsWhenTheRegionReachesTheBottom() + { + // A valid file with a byte too many in its image data: the full decode refuses it; so does a window down to the + // last row, which checks the stream's end; one that stops above it does not see it. + var good = PngRgba8ExpansionTests.Encode(0, 8, false, W, H); + var header = PngReader.ReadHeader(good, out var idat); + var raw = PngReader.InflateAndUnfilter(good, idat, W, H, 8, 0, Ct); + var filtered = new byte[(W + 1) * H + 1]; + for (var y = 0; y < H; y++) + raw.AsSpan(y * W, W).CopyTo(filtered.AsSpan(y * (W + 1) + 1)); + using var compressed = new MemoryStream(); + using (var zlib = new System.IO.Compression.ZLibStream(compressed, System.IO.Compression.CompressionLevel.Optimal, leaveOpen: true)) + zlib.Write(filtered); + var png = ReplaceIdat(good, compressed.ToArray()); + + PngImageDecoder.TryDecode(png, out _, cancellationToken: Ct).ShouldBeFalse(); + PngImageDecoder.TryDecode(png, out _, new DecodeOptions { ScaleDenominator = 2 }, Ct).ShouldBeFalse(); + PngImageDecoder.TryDecode(png, out _, new DecodeOptions { Region = new PixelRect(0, H - 1, 1, 1) }, Ct).ShouldBeFalse(); + PngImageDecoder.TryDecode(png, out var top, new DecodeOptions { Region = new PixelRect(0, 0, W, H - 1) }, Ct).ShouldBeTrue(); + using (top) + top!.Pixels.ToArray().ShouldBe(raw.AsSpan(0, W * (H - 1)).ToArray()); + header.Interlaced.ShouldBeFalse(); + } + + private static byte[] ReplaceIdat(byte[] png, byte[] zlib) + { + using var ms = new MemoryStream(); + ms.Write(png.AsSpan(0, 8)); + var pos = 8; + var wrote = false; + while (pos < png.Length) + { + var length = BinaryPrimitives.ReadInt32BigEndian(png.AsSpan(pos)); + var type = png.AsSpan(pos + 4, 4); + if (type.SequenceEqual("IDAT"u8)) + { + if (!wrote) + { + var u32 = new byte[4]; + BinaryPrimitives.WriteInt32BigEndian(u32, zlib.Length); + ms.Write(u32); + ms.Write("IDAT"u8); + ms.Write(zlib); + BinaryPrimitives.WriteUInt32BigEndian(u32, PngCrc32.Compute("IDAT"u8, zlib)); + ms.Write(u32); + wrote = true; + } + } + else + { + ms.Write(png.AsSpan(pos, 12 + length)); + } + pos += 12 + length; + } + return ms.ToArray(); + } + + // ---- Robustness ------------------------------------------------------------------------------------------------- + + [Theory] + [InlineData("magick ct6 d8")] + [InlineData("magick ct3 d4")] + [InlineData("magick ct0 d16 tRNS")] + [InlineData("magick ct2 d8 tRNS")] + public void MutatedImageData_FailsCleanlyOrDecodes(string name) + { + // Bytes of the image data flipped, or cut short, with the CRC fixed so the window decode itself meets them: + // every call returns true or false, never throws. + var png = Corpus()[name]; + var header = PngReader.ReadHeader(png, out var idat); + var (offset, length) = idat[0]; + var random = new Random(name.Length); + var formats = new[] { PixelFormat.Rgba32, PixelFormat.Gray16, PixelFormat.Bgra32Premultiplied }; + var options = new[] + { + new DecodeOptions { Region = new PixelRect(3, 4, 10, 9) }, + new DecodeOptions { ScaleDenominator = 4 }, + new DecodeOptions { ScaleDenominator = 2, Region = new PixelRect(W / 2 - 3, H / 2 - 3, 3, 3) }, + }; + var buffer = new byte[W * H * 8]; + for (var iteration = 0; iteration < 300; iteration++) + { + byte[] mutated; + if (iteration % 5 == 4) + { + mutated = ReplaceIdat(png, png.AsSpan(offset, random.Next(1, length)).ToArray()); + } + else + { + var payload = png.AsSpan(offset, length).ToArray(); + for (var k = random.Next(1, 4); k > 0; k--) + payload[random.Next(payload.Length)] ^= (byte)random.Next(1, 256); + mutated = ReplaceIdat(png, payload); + } + foreach (var option in options) + { + if (PngImageDecoder.TryDecode(mutated, out var image, option, Ct)) + image!.Dispose(); + foreach (var format in formats) + PngImageDecoder.TryDecodeInto(mutated, buffer, 0, format, option, Ct); + } + } + header.Interlaced.ShouldBeFalse(); + } + + // ---- Memory ----------------------------------------------------------------------------------------------------- + + [Fact] + public void ARegionOfALargeImage_AllocatesRowsNotTheImage() + { + const int side = 1024; + var rgba = new byte[side * side * 4]; + new Random(3).NextBytes(rgba); + var png = PngWriter.Encode(rgba, side, side); + var options = new DecodeOptions { ScaleDenominator = 8, Region = new PixelRect(10, 10, 20, 20) }; + PngImageDecoder.TryDecode(png, out var warm, options, Ct).ShouldBeTrue(); // JIT and pools first + warm!.Dispose(); + + var before = GC.GetAllocatedBytesForCurrentThread(); + PngImageDecoder.TryDecode(png, out var image, options, Ct).ShouldBeTrue(); + var allocated = GC.GetAllocatedBytesForCurrentThread() - before; + image!.Dispose(); + // The full decode holds 4 MiB of samples; this holds the 20×20 output and a few rows. + allocated.ShouldBeLessThan(256 * 1024); + } + + private sealed class CountingPool : System.Buffers.ArrayPool + { + public int Rented, Returned; + public override byte[] Rent(int minimumLength) { Rented++; return new byte[minimumLength]; } + public override void Return(byte[] array, bool clearArray = false) => Returned++; + } +} diff --git a/tests/Sdcb.SimdCodecs.Tests/ScaledDecodeFacadeTests.cs b/tests/Sdcb.SimdCodecs.Tests/ScaledDecodeFacadeTests.cs new file mode 100644 index 0000000..d464f57 --- /dev/null +++ b/tests/Sdcb.SimdCodecs.Tests/ScaledDecodeFacadeTests.cs @@ -0,0 +1,208 @@ +using System.Runtime.InteropServices; +using Sdcb.SimdCodecs.Abstractions; +using Shouldly; + +namespace Sdcb.SimdCodecs.Tests; + +/// +/// Every codec accepts every power-of-two : the decode at the codec's largest +/// native scale n that does not exceed d ( reports the largest), box-filtered by +/// d / n (), then cropped to the region; and TryDecodeInto is that image +/// converted (). One or more fixtures of every format. +/// +public sealed class ScaledDecodeFacadeTests +{ + private static readonly Lazy> CorpusCache = new(BuildCorpus); + + private static Dictionary BuildCorpus() + { + var all = DecodeIntoTests.Corpus(); + var corpus = new Dictionary(); + foreach (var name in new[] + { + "png ct0 d8 70x9", "png ct2 d16 70x9 adam7", "png ct4 d16 37x5", "png ct6 d8 70x9", "png ct6 d16 70x9", "png ct3 d8 trns 37x5", + "jpeg grey 70x33", "jpeg 4:2:0 70x33", "jpeg 4:4:4 progressive 37x19", "jpeg gain map", + "tiff grey16 23x7", "tiff rgb16 23x7", "tiff rgba8 31x6", "jbig2", + "gif still transparent 23x11", "gif anim mixed", "bmp rgb24 37x19", "bmp argb 31x6", "ico 3 images", "cur 1 image", + "qoi rgba 31x9", "qoi rgb 19x7", "pnm ppm 37x19", "pnm pam rgba 31x6", "pnm pgm16 19x7", "pnm pbm plain 19x7", + "webp testsrc2-320x240-q75.webp", "webp odd-33x19-libvpx.webp", "webp lossy-alpha-q50.webp", "webp lossless-exact-alpha.webp", + "webp lossless-3x7.webp", "webp anim-img2webp-mixed.webp", + }) + { + corpus[name] = all[name]; + } + foreach (var name in all.Keys.Where(k => k.StartsWith("j2k ", StringComparison.Ordinal))) + corpus[name] = all[name]; + + var random = new Random(11); + corpus["tiff float32 grey 23x7"] = HdrTiff.Float32Gray(23, 7, [.. Enumerable.Range(0, 23 * 7).Select(_ => (float)(random.NextDouble() * 4 - 1))]); + corpus["tiff half rgb 13x9"] = HdrTiff.HalfRgb(13, 9, [.. Enumerable.Range(0, 13 * 9 * 3).Select(_ => (Half)random.NextDouble())]); + float[] pfm = [.. Enumerable.Range(0, 19 * 7 * 3).Select(_ => (float)(random.NextDouble() * 2))]; + corpus["pfm rgb 19x7"] = Sdcb.SimdCodecs.Pnm.PnmEncoder.EncodePfm(pfm, 19, 7, 3); + return corpus; + } + + public static TheoryData Inputs() => [.. CorpusCache.Value.Keys]; + + private static readonly int[] Scales = [2, 4, 16, 64]; + + private static IEnumerable Regions(int w, int h) => + [ + null, new PixelRect(0, 0, 1, 1), new PixelRect(w - 1, h - 1, 1, 1), new PixelRect(w / 3, h / 4, Math.Max(1, w / 2), Math.Max(1, h / 2)), + ]; + + private static byte[] Crop(byte[] pixels, int width, int bpp, PixelRect r) + { + var result = new byte[r.Width * r.Height * bpp]; + for (var y = 0; y < r.Height; y++) + pixels.AsSpan(((r.Y + y) * width + r.X) * bpp, r.Width * bpp).CopyTo(result.AsSpan(y * r.Width * bpp)); + return result; + } + + // The decode at native scale n, box-filtered by d / n: the whole scaled image. + private static (byte[] Pixels, int Width, int Height, IDecodedImage Native) Expected(ReadOnlySpan data, int d, + Func decode, int maxNative = 0) + { + if (maxNative == 0) + SimdCodecs.TryGetClosestSize(data, 1, 1, out _, out _, out maxNative).ShouldBeTrue(); + var n = Math.Min(d, maxNative); + var native = decode(new DecodeOptions { ScaleDenominator = n }); + var factor = d / n; + var pixels = factor == 1 + ? native.Pixels.ToArray() + : BoxDownscaleTests.Reference(native.Pixels, native.Width, native.Height, native.Channels, native.SampleFormat, factor); + return (pixels, (native.Width + factor - 1) / factor, (native.Height + factor - 1) / factor, native); + } + + [Theory] + [MemberData(nameof(Inputs))] + public void AScaledDecodeIsTheBoxFilterOfTheNativeDecode(string name) + { + var data = CorpusCache.Value[name]; + var ct = TestContext.Current.CancellationToken; + SimdCodecs.TryReadInfo(data, out var info).ShouldBeTrue(name); + foreach (var d in Scales) + { + var (expected, w, h, native) = Expected(data, d, options => + { + SimdCodecs.TryDecode(data, out var image, options, ct).ShouldBeTrue($"{name} native"); + return image; + }); + using (native) + { + new DecodeOptions { ScaleDenominator = d }.GetScaledSize(info.Width, info.Height).ShouldBe((w, h), $"{name} d{d}"); + var bpp = native.Channels * RasterImage.BytesPerSample(native.SampleFormat); + foreach (var region in Regions(w, h)) + { + var label = $"{name} d{d} {region}"; + var options = new DecodeOptions { ScaleDenominator = d, Region = region }; + var r = region ?? new PixelRect(0, 0, w, h); + var wanted = region is null ? expected : Crop(expected, w, bpp, r); + SimdCodecs.TryDecode(data, out var image, options, ct).ShouldBeTrue(label); + using (image) + { + (image.Width, image.Height, image.Channels, image.SampleFormat, image.BitDepth) + .ShouldBe((r.Width, r.Height, native.Channels, native.SampleFormat, native.BitDepth), label); + image.ColorEncoding.ShouldBe(native.ColorEncoding, label); + image.Pixels.ToArray().ShouldBe(wanted, label); + } + + // TryDecodeInto is that image converted. A float image has no integer projection, except a gain-map + // JPEG's, whose display tier is its SDR base: that base's scaled decode. + var (display, displayFormat) = (wanted, PixelFormat.ForChannels(native.Channels, native.SampleFormat == SampleFormat.Float32 ? SampleFormat.UInt8 : native.SampleFormat)); + if (native.SampleFormat == SampleFormat.Float32 && name == "jpeg gain map") + { + var (sdr, _, _, sdrNative) = Expected(data, d, o => + { + Jpeg.JpegImageDecoder.TryDecode(data, out var image, o, ct).ShouldBeTrue(); + return image; + }, maxNative: 8); + using (sdrNative) + (display, displayFormat) = (region is null ? sdr : Crop(sdr, w, sdrNative.Channels, r), PixelFormat.ForChannels(sdrNative.Channels, SampleFormat.UInt8)); + } + foreach (var format in new[] { PixelFormat.Rgba32, PixelFormat.Bgra64Premultiplied }) + { + var into = new byte[r.Width * r.Height * format.BytesPerPixel]; + var decoded = SimdCodecs.TryDecodeInto(data, into, 0, format, options, ct); + if (native.SampleFormat == SampleFormat.Float32 && name != "jpeg gain map") + { + decoded.ShouldBeFalse(label); + continue; + } + decoded.ShouldBeTrue($"{label} into {format}"); + into.ShouldBe(PixelConverterTests.ReferenceConvert(display, displayFormat, format, r.Width * r.Height), $"{label} into {format}"); + } + } + } + } + } + + public static TheoryData Animations() => + [.. CorpusCache.Value.Keys.Where(k => k.Contains("anim", StringComparison.Ordinal))]; + + [Theory] + [MemberData(nameof(Animations))] + public void AScaledFrameIsTheBoxFilterOfTheFrame(string name) + { + var data = CorpusCache.Value[name]; + var ct = TestContext.Current.CancellationToken; + SimdCodecs.TryOpenFrames(data, out var reader).ShouldBeTrue(name); + using (reader) + { + reader.FrameCount.ShouldBeGreaterThan(1); + for (var index = reader.FrameCount - 1; index >= 0; index--) + { + reader.TryDecodeFrame(index, out var full, cancellationToken: ct).ShouldBeTrue(); + using (full) + { + foreach (var d in new[] { 2, 8 }) + { + var expected = BoxDownscaleTests.Reference(full.Pixels, full.Width, full.Height, full.Channels, full.SampleFormat, d); + var (w, h) = new DecodeOptions { ScaleDenominator = d }.GetScaledSize(full.Width, full.Height); + reader.TryDecodeFrame(index, out var scaled, new DecodeOptions { ScaleDenominator = d }, ct).ShouldBeTrue(); + using (scaled) + scaled.Pixels.ToArray().ShouldBe(expected, $"{name} frame {index} d{d}"); + var into = new byte[w * h * 4]; + reader.TryDecodeFrameInto(index, into, 0, PixelFormat.Rgba32, new DecodeOptions { ScaleDenominator = d }, ct).ShouldBeTrue(); + into.ShouldBe(PixelConverterTests.ReferenceConvert(expected, PixelFormat.ForChannels(full.Channels, full.SampleFormat), PixelFormat.Rgba32, w * h)); + } + } + } + } + } + + [Theory] + [InlineData("synth-screen-testsrc2-1280x720-main8.heic")] + [InlineData("synth-photo-mangrove-1599x1199-main8.heic")] + public void AScaledHeifIsTheBoxFilterOfTheFullDecode(string fixture) + { + var data = HeifTests.ReadFixture(fixture); + var ct = TestContext.Current.CancellationToken; + // Through the codec itself: the facade does not register HEIF unless asked to (see HeifFacadeTests). + Heif.HeifImageDecoder.TryDecode(data, out var full, cancellationToken: ct).ShouldBeTrue(); + using (full) + { + var bpp = full.Channels * RasterImage.BytesPerSample(full.SampleFormat); + foreach (var d in new[] { 4, 64 }) + { + var expected = BoxDownscaleTests.Reference(full.Pixels, full.Width, full.Height, full.Channels, full.SampleFormat, d); + var (w, h) = new DecodeOptions { ScaleDenominator = d }.GetScaledSize(full.Width, full.Height); + foreach (var region in Regions(w, h)) + { + var r = region ?? new PixelRect(0, 0, w, h); + var options = new DecodeOptions { ScaleDenominator = d, Region = region }; + var wanted = region is null ? expected : Crop(expected, w, bpp, r); + Heif.HeifImageDecoder.TryDecode(data, out var image, options, ct).ShouldBeTrue(); + using (image) + { + (image.Width, image.Height, image.BitDepth).ShouldBe((r.Width, r.Height, full.BitDepth)); + image.Pixels.ToArray().ShouldBe(wanted, $"{fixture} d{d} {region}"); + } + var into = new byte[r.Width * r.Height * 4]; + Heif.HeifImageDecoder.TryDecodeInto(data, into, 0, PixelFormat.Bgra32, options, ct).ShouldBeTrue(); + into.ShouldBe(PixelConverterTests.ReferenceConvert(wanted, PixelFormat.ForChannels(full.Channels, full.SampleFormat), PixelFormat.Bgra32, r.Width * r.Height)); + } + } + } + } +} diff --git a/tests/Sdcb.SimdCodecs.Tests/WebpRegionDecodeTests.cs b/tests/Sdcb.SimdCodecs.Tests/WebpRegionDecodeTests.cs new file mode 100644 index 0000000..b177e0d --- /dev/null +++ b/tests/Sdcb.SimdCodecs.Tests/WebpRegionDecodeTests.cs @@ -0,0 +1,302 @@ +using System.Buffers.Binary; +using System.Runtime.CompilerServices; +using ImageMagick; +using Sdcb.SimdCodecs.Abstractions; +using Sdcb.SimdCodecs.Abstractions.CodecSupport; +using Sdcb.SimdCodecs.Webp; +using Shouldly; + +namespace Sdcb.SimdCodecs.Tests; + +/// +/// WebP's native region and scale decode for lossy stills (, +/// ): every output must be exactly what the generic path gives — the whole +/// decode (which the libwebp goldens pin), box-filtered, cropped, then converted to the requested format — for every +/// pixel format, region-only, scale-only and both, with and without alpha. +/// +public sealed class WebpRegionDecodeTests +{ + private static readonly string FixtureDirectory = Path.Combine(AppContext.BaseDirectory, "Fixtures", "webp"); + + public static TheoryData LossyFixtures() + { + var data = new TheoryData(); + foreach (var path in Directory.GetFiles(FixtureDirectory, "*.webp").Order()) + { + if (WebpDecoder.TryReadInfo(File.ReadAllBytes(path), out var info) && info.Format == WebpFormat.Lossy) + data.Add(Path.GetFileName(path)); + } + data.Add("magick:rgb"); + data.Add("magick:rgba"); + data.Add("magick:tall"); + return data; + } + + private static byte[] Load(string name) => name switch + { + "magick:rgb" => MagickLossy(197, 133, alpha: false), + "magick:rgba" => MagickLossy(171, 99, alpha: true), + "magick:tall" => MagickLossy(37, 530, alpha: false), + _ => File.ReadAllBytes(Path.Combine(FixtureDirectory, name)), + }; + + // Foreign bytes: a lossy WebP written by ImageMagick (libwebp's encoder inside) from a gradient with noise. + private static byte[] MagickLossy(int width, int height, bool alpha) + { + var channels = alpha ? 4 : 3; + var pixels = new byte[width * height * channels]; + var rng = new Random(width * 7919 + height); + for (var y = 0; y < height; y++) + { + for (var x = 0; x < width; x++) + { + var i = (y * width + x) * channels; + pixels[i] = (byte)(x * 255 / width ^ rng.Next(0, 24)); + pixels[i + 1] = (byte)(y * 255 / height + rng.Next(0, 32)); + pixels[i + 2] = (byte)((x + y) * 3 + rng.Next(0, 16)); + if (alpha) + pixels[i + 3] = (byte)(x < width / 3 ? 255 : (x * y) % 256); + } + } + using var image = new MagickImage(); + image.ReadPixels(pixels, new PixelReadSettings((uint)width, (uint)height, StorageType.Char, alpha ? "RGBA" : "RGB")); + image.Quality = 70; + image.Settings.SetDefine(MagickFormat.WebP, "lossless", false); + var webp = image.ToByteArray(MagickFormat.WebP); + WebpDecoder.ReadInfo(webp).Format.ShouldBe(WebpFormat.Lossy); + WebpDecoder.ReadInfo(webp).HasAlpha.ShouldBe(alpha); + return webp; + } + + // Regions of the w × h scaled image: whole, corners, single pixels, the last row and column, and rows around the + // macroblock boundaries (16 source rows: 16 / d scaled rows), plus a few random ones. + private static List Regions(int w, int h, int d, Random rng) + { + var list = new List + { + new(0, 0, w, h), new(0, 0, 1, 1), new(w - 1, h - 1, 1, 1), new(0, h - 1, w, 1), new(w - 1, 0, 1, h), + new(w / 2, h / 2, w - w / 2, h - h / 2), new(0, 0, w, (h + 1) / 2), + }; + var mb = Math.Max(1, 16 / d); + for (var boundary = mb; boundary < h; boundary += mb * 3) + { + foreach (var y in new[] { boundary - 1, boundary, boundary + 1 }) + { + if (y >= 0 && y < h) + list.Add(new PixelRect(0, y, w, Math.Min(h - y, 2))); + } + } + for (var i = 0; i < 4; i++) + { + int x = rng.Next(0, w), y = rng.Next(0, h); + list.Add(new PixelRect(x, y, rng.Next(1, w - x + 1), rng.Next(1, h - y + 1))); + } + return list; + } + + private static IDecodedImage Generic(IDecodedImage whole, DecodeOptions options) + { + // The generic path, on a copy so that the whole decode can be reused. + var copy = RasterImage.Crop(whole, new PixelRect(0, 0, whole.Width, whole.Height)); + return DecodeOptions.Apply(copy, options); + } + + [Theory] + [MemberData(nameof(LossyFixtures))] + public void Native_region_and_scale_equal_the_generic_path(string fixture) + { + var data = Load(fixture); + var ct = TestContext.Current.CancellationToken; + WebpImageDecoder.TryDecode(data, out var whole, cancellationToken: ct).ShouldBeTrue(); + using (whole) + { + var rng = new Random(fixture.Length * 104729); + foreach (var d in new[] { 1, 2, 4, 8, 16 }) + { + var (w, h) = new DecodeOptions { ScaleDenominator = d }.GetScaledSize(whole.Width, whole.Height); + var options = new List(); + if (d > 1) + options.Add(new DecodeOptions { ScaleDenominator = d }); + options.AddRange(Regions(w, h, d, rng).Select(r => new DecodeOptions { ScaleDenominator = d, Region = r })); + foreach (var option in options) + { + using var expected = Generic(whole, option); + WebpImageDecoder.TryDecode(data, out var native, option, ct).ShouldBeTrue(); + using (native) + { + (native.Width, native.Height, native.Channels).ShouldBe((expected.Width, expected.Height, expected.Channels)); + if (!native.Pixels.SequenceEqual(expected.Pixels)) + Assert.Fail($"{fixture} d{d} {option.Region}: TryDecode differs from the generic path."); + } + } + + // TryDecodeInto, every pixel format, on a subset of the options (with a padded stride). + foreach (var option in options.Where((_, i) => i % 3 == 0)) + { + using var expected = Generic(whole, option); + foreach (var format in PixelFormat.All) + { + var stride = expected.Width * format.BytesPerPixel + 3; + var want = new byte[stride * expected.Height]; + var got = new byte[stride * expected.Height]; + PixelConverter.TryConvert(expected, want, stride, format).ShouldBeTrue(); + WebpImageDecoder.TryDecodeInto(data, got, stride, format, option, ct).ShouldBeTrue(); + if (!got.AsSpan().SequenceEqual(want)) + Assert.Fail($"{fixture} d{d} {option.Region} {format}: TryDecodeInto differs from the generic path."); + } + } + } + } + } + + /// Nearest-neighbour chroma (the internal no-fancy path) through the region sink equals its whole decode, box-filtered. + [Theory] + [InlineData("testsrc2-320x240-q75.webp")] + [InlineData("odd-33x19-libvpx.webp")] + [InlineData("lossy-alpha-q50.webp")] + public void Nearest_upsampling_region_equals_the_whole_nearest_decode(string fixture) + { + var data = Load(fixture); + var info = WebpDecoder.ReadInfo(data); + var rgba = new byte[info.Width * info.Height * 4]; + WebpDecoder.DecodeToNoFancy(data, rgba); + var channels = info.HasAlpha ? 4 : 3; + using var whole = RasterImage.Create(info.Width, info.Height, channels, SampleFormat.UInt8, null); + PixelConverter.Convert(rgba, 0, PixelFormat.Rgba32, whole.WritablePixels, 0, + channels == 4 ? PixelFormat.Rgba32 : PixelFormat.Rgb24, info.Width, info.Height); + var rng = new Random(17); + foreach (var d in new[] { 1, 2, 4 }) + { + var (w, h) = new DecodeOptions { ScaleDenominator = d }.GetScaledSize(info.Width, info.Height); + foreach (var region in Regions(w, h, d, rng)) + { + using var expected = Generic(whole, new DecodeOptions { ScaleDenominator = d, Region = region }); + var got = new byte[region.Width * region.Height * 4]; + DecodeRegion(data, channels, d, region, got, fancy: false); + var want = new byte[got.Length]; + PixelConverter.TryConvert(expected, want, 0, PixelFormat.Rgba32).ShouldBeTrue(); + got.ShouldBe(want, $"d{d} {region}"); + } + } + } + + private static void DecodeRegion(byte[] data, int channels, int d, PixelRect region, byte[] rgba, bool fancy) + { + // The VP8 / ALPH payloads, found the way a reader of the container would (RFC 9649 chunk layout). + var (vp8, alph) = Chunks(data); + var info = WebpDecoder.ReadInfo(data); + WebpDecoder.DecodeLossyRegion(vp8, alph, alph.Length > 0, info.Width, info.Height, channels, d, region, rgba, + region.Width * 4, PixelFormat.Rgba32, fancy); + } + + private static (byte[] Vp8, byte[] Alph) Chunks(byte[] data) + { + byte[] vp8 = [], alph = []; + for (var at = 12; at + 8 <= data.Length;) + { + var size = (int)BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(at + 4)); + var tag = System.Text.Encoding.ASCII.GetString(data, at, 4); + if (tag == "VP8 ") vp8 = data.AsSpan(at + 8, size).ToArray(); + if (tag == "ALPH") alph = data.AsSpan(at + 8, size).ToArray(); + at += 8 + size + (size & 1); + } + return (vp8, alph); + } + + // Records the furthest row a decode emitted. + private struct CountingSink : IVp8RowSink + { + public int End; + + public void Emit(ref byte y, int yStride, int yRow0, ref byte u, ref byte v, int uvStride, int uvRow0, int first, int end) => + End = Math.Max(End, end); + } + + [Fact] + public void A_row_limit_stops_after_the_macroblock_row_that_reaches_it() + { + var (vp8, _) = Chunks(Load("testsrc2-320x240-q75.webp")); + var ct = TestContext.Current.CancellationToken; + // Output lags by 8 rows: macroblock row k emits up to 16k + 8 (and the last one to the bottom). + foreach (var (limit, end) in new[] { (1, 8), (8, 8), (9, 24), (24, 24), (25, 40), (233, 240), (240, 240), (int.MaxValue, 240) }) + { + var sink = new CountingSink(); + Vp8Decoder.DecodeFrame(vp8, 320, 240, WebpAblation.None, ref sink, ct, limit); + sink.End.ShouldBe(end, $"limit {limit}"); + } + } + + // Rewrites the VP8 chunk (and the RIFF size) of a simple lossy file, keeping everything else. + private static byte[] WithVp8(byte[] data, Func change) + { + var (vp8, alph) = Chunks(data); + alph.Length.ShouldBe(0); + WebpDecoder.ReadInfo(data).Format.ShouldBe(WebpFormat.Lossy); + var payload = change(vp8); + var output = new List(); + output.AddRange("RIFF"u8.ToArray()); + output.AddRange(new byte[4]); + output.AddRange("WEBPVP8 "u8.ToArray()); + var size = new byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(size, (uint)payload.Length); + output.AddRange(size); + output.AddRange(payload); + if ((payload.Length & 1) != 0) + output.Add(0); + var file = output.ToArray(); + BinaryPrimitives.WriteUInt32LittleEndian(file.AsSpan(4), (uint)(file.Length - 8)); + return file; + } + + [Fact] + public void Damage_below_the_region_is_not_decoded() + { + var data = MagickLossy(64, 512, alpha: false); + var ct = TestContext.Current.CancellationToken; + WebpImageDecoder.TryDecode(data, out var whole, cancellationToken: ct).ShouldBeTrue(); + using (whole) + { + // The frame truncated to three quarters (the token partition loses its end): the bottom macroblock rows read zero bits, so the whole + // decode changes there, but a region from the top half stops before them and is exactly the clean region. + var truncated = WithVp8(data, static v => v.AsSpan(0, v.Length * 3 / 4).ToArray()); + var top = new DecodeOptions { Region = new PixelRect(0, 0, 64, 64) }; + WebpImageDecoder.TryDecode(truncated, out var damagedWhole, cancellationToken: ct).ShouldBeTrue(); + using (damagedWhole) + damagedWhole.Pixels.SequenceEqual(whole.Pixels).ShouldBeFalse("the truncation must change the bottom"); + foreach (var option in new[] { top, new DecodeOptions { ScaleDenominator = 4, Region = new PixelRect(0, 0, 16, 16) } }) + { + using var expected = Generic(whole, option); + WebpImageDecoder.TryDecode(truncated, out var part, option, ct).ShouldBeTrue(); + using (part) + part.Pixels.ToArray().ShouldBe(expected.Pixels.ToArray()); + } + + // Damage above every row: a broken start code, a first partition longer than the frame. + var badStartCode = WithVp8(data, static v => { var c = v.ToArray(); c[3] = 0; return c; }); + var badPartition = WithVp8(data, static v => + { + var c = v.ToArray(); + var tag = (uint)(c[0] | c[1] << 8 | c[2] << 16) | (0x7ffffu << 5); + (c[0], c[1], c[2]) = ((byte)tag, (byte)(tag >> 8), (byte)(tag >> 16)); + return c; + }); + foreach (var bad in new[] { badStartCode, badPartition }) + { + WebpImageDecoder.TryDecode(bad, out _, top, ct).ShouldBeFalse(); + WebpImageDecoder.TryDecodeInto(bad, new byte[64 * 64 * 4], 0, PixelFormat.Rgba32, top, ct).ShouldBeFalse(); + Should.Throw(() => DecodeRegion(bad, 3, 1, top.Region!.Value, new byte[64 * 64 * 4], true)); + } + } + } + + [Fact] + public void A_region_outside_the_image_and_a_short_destination_are_refused() + { + var data = Load("testsrc2-320x240-q75.webp"); + var ct = TestContext.Current.CancellationToken; + Should.Throw(() => + WebpImageDecoder.TryDecode(data, out _, new DecodeOptions { ScaleDenominator = 2, Region = new PixelRect(150, 0, 20, 10) }, ct)); + WebpImageDecoder.TryDecodeInto(data, new byte[20 * 10 * 4 - 1], 0, PixelFormat.Rgba32, + new DecodeOptions { Region = new PixelRect(0, 0, 20, 10) }, ct).ShouldBeFalse(); + } +}