Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
67dd757
Every codec accepts every power-of-two ScaleDenominator: native, then…
sdcb Nov 8, 2026
acb8f95
Docs: every codec scales by any power of two (native, then the box fi…
sdcb Nov 8, 2026
5da19b8
Abstractions: remove Validate(width, height, bool) rather than obsole…
sdcb Nov 8, 2026
88a489d
Merge scaled-decode-l1: every codec accepts any power-of-two ScaleDen…
sdcb Nov 8, 2026
133e665
PNG: native region and scale decode for non-interlaced images
sdcb Nov 8, 2026
2825469
BoxDownscaler: exact 32-bit emit for 8-bit RGB / RGBA boxes up to 2^1…
sdcb Nov 8, 2026
f52cc35
WebP: native region and scale decode for lossy stills
sdcb Nov 8, 2026
eff76f3
WebP benchmarks and docs: region / scale contestants, recorded results
sdcb Nov 8, 2026
7874b76
Merge scaled-webp: native streaming region and scale for WebP lossy s…
sdcb Nov 8, 2026
9acef8e
PNG benchmark: scaled / scaled-peakmem commands, recorded results
sdcb Nov 8, 2026
36670d4
Docs: PNG native region and scale decode for non-interlaced images
sdcb Nov 8, 2026
b725684
HEIF: native scaled decode, each tile boxes the pixels it converts
sdcb Nov 8, 2026
521fce0
HEIF benchmark: scaled contestants in quick and peakmem
sdcb Nov 8, 2026
5c2c9d6
PNG region tests: mutated and truncated image data fail cleanly
sdcb Nov 8, 2026
5e0db89
Merge scaled-png: native streaming region and scale for non-interlace…
sdcb Nov 8, 2026
6965548
HEIF fuzz: a scaled decode in every case
sdcb Nov 8, 2026
473b361
HEIF docs and benchmark record: native scaled decode, its alignment c…
sdcb Nov 8, 2026
f94be37
AOT / wasm smoke: a scaled HEIF TryDecodeInto into a padded destination
sdcb Nov 8, 2026
5a74718
Merge scaled-heif: native scaled decode for HEIF, box-filtered per ti…
sdcb Nov 8, 2026
bbf6c76
known-gaps: native scale and on-demand region now cover PNG, WebP los…
sdcb Nov 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`。
Expand Down
49 changes: 38 additions & 11 deletions benchmarks/Sdcb.SimdCodecs.Heif.Benchmarks/PeakMemory.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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"];
/// <summary>
/// Full-size RGBA: <c>ours</c>, <c>libheif</c>, <c>magick</c>. Scaled by d through the adapter
/// (<c>HeifImageDecoder.TryDecode</c> with <c>ScaleDenominator</c>, Rgb24 / Rgb48): <c>scaled-dN</c> is the native
/// path (tiles boxed as they are written), <c>boxed-dN</c> the fallback it replaces (a full decode, then
/// <see cref="BoxDownscaler.Downscale"/>), the same bytes.
/// </summary>
public static readonly string[] Contestants =
["ours", "libheif", "magick", "scaled-d2", "boxed-d2", "scaled-d4", "boxed-d4", "scaled-d8", "boxed-d8"];

/// <summary>The parent: one fresh child process per (contestant, fixture), median of <paramref name="repeats"/>.</summary>
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<string>
{
$"{"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<Sample>();
for (var r = 0; r < repeats; r++) samples.Add(SpawnChild(contestant, fixture));
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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));
}
Expand Down
14 changes: 8 additions & 6 deletions benchmarks/Sdcb.SimdCodecs.Heif.Benchmarks/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()));
Expand All @@ -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]);
Expand Down
57 changes: 50 additions & 7 deletions benchmarks/Sdcb.SimdCodecs.Heif.Benchmarks/Quick.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 <c>reps</c>. 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 <c>HEIF_BENCH_CPU</c> (default 2; CPU 0 takes most of
/// the system's interrupts on Windows).
/// the system's interrupts on Windows); <c>HEIF_BENCH_CPU=all</c> runs on every core with the default thread pool.
/// <para>
/// <c>--scaled</c> adds the adapter's scaled decode (<c>ScaleDenominator</c> 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 <c>TryDecode</c>, then
/// <see cref="BoxDownscaler.Downscale"/>), after checking the two give the same bytes.
/// </para>
/// </summary>
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<string> { $"{"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<string> { $"{"fixture",-42} {"stage ms",9} {"RGBA ms",9} ({where})" };
var scaledLines = new List<string> { "", $"{"fixture",-42} {"d",3} {"native ms",10} {"boxed ms",9} {"native/boxed",13} ({where})" };
try
{
foreach (var fixture in fixtures)
Expand All @@ -35,16 +51,43 @@ 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
{
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<int> action)
{
action();
Expand Down
2 changes: 2 additions & 0 deletions benchmarks/Sdcb.SimdCodecs.Heif.Benchmarks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading
Loading