Skip to content

Scaled and region decode for every codec - #10

Merged
sdcb merged 20 commits into
mainfrom
scaled-decode
Nov 8, 2026
Merged

sdcb merged 20 commits into
mainfrom
scaled-decode

Conversation

@sdcb

@sdcb sdcb commented Nov 8, 2026

Copy link
Copy Markdown
Owner

Benchmarks (Ryzen 7 5800X, same-run baselines) are in each codec's results folder: e.g. WebP 1080p top region 0.18× the time, PNG region peak memory 8–15 MiB → under 3 MiB, HEIF 12.6 MP grid at 1/4 peak 64 → 32 MB.

🤖 Generated with Claude Code

sdcb and others added 20 commits November 8, 2026 21:24
… a frozen box filter

DecodeOptions.ScaleDenominator is no longer refused with NotSupportedException.
A codec decodes at its largest native scale n <= d (JPEG up to 8, JPEG 2000 up
to 2^levels, others 1), then reduces by d / n with the frozen box filter, then
crops the Region (in scaled coordinates). TryGetClosestSize still reports only
native sizes.

- CodecSupport.BoxDownscaler (public): a streaming row accumulator (PushRow /
  TakeRow, column windows via GetSourceRect) plus a one-shot Downscale. Exact
  integer sums for 8/16-bit (u32 column sums with Vector256/Vector128 kernels
  and a scalar twin for 8-bit, u64 otherwise; box <= 2^32 pixels), double
  row-major sums for Float32; alpha-weighted without per-pixel rounding.
- DecodeOptionsChecks: Validate(width, height), GetNativeScale(max),
  Apply(image, options, nativeScale); Validate(w, h, bool) is obsolete; the
  unshipped Validate(w, h, int) is removed.
- Adapters: JPEG (1/8 then box, native region of the boxes' source rect),
  JPEG 2000 (largest reduction then box), TIFF and HEIF (native region of the
  source rect, then box; TryDecodeInto via the fallback), the rest via Apply.
- Tests: BoxDownscaleTests (reference on every tier, windows, streaming,
  overflow limits, Magick.NET -scale matches exactly), ScaledDecodeFacadeTests
  (every format, frames, HEIF); NotSupportedException tests rewritten. CI and
  AGENTS.md tier filters include BoxDownscale; the AOT / wasm smoke decodes
  every facade image at 1/16 (whole and region) and its last frame at 1/2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lter)

api-design.md defines the frozen box filter; known-gaps.md and each format page
say which scales are native and that the rest decode, then box-filter;
migrating-to-2.0.md and both READMEs say the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…te it

DecodeOptions has not shipped yet (v1.4.0 has none), so the overload goes outright, as ImagePixelFormat did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ominator (native, then a frozen box downscale) (#6)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TryDecode and TryDecodeInto with a Region or a ScaleDenominator now decode a
window of rows (PngReader.DecodeWindow): rows are inflated and unfiltered only
down to the window's last row and only as far right as its last column, the
window's columns are expanded to TryDecode's layout, box-filtered as they arrive
(BoxDownscaler) and, for TryDecodeInto, converted afterwards. The output equals
the generic path (full decode, box, crop, convert) byte for byte. The decode stops
after the last needed row, so image data below it is neither inflated nor
checked; a window that reaches the bottom row still checks the stream's end.
Adam7 keeps the generic path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…6 pixels

The 64-bit division per output sample dominated streamed downscales at small factors
(1920x1080 RGB, d=2: 36 ms total, 3 ms of it accumulation). Boxes of at most 2^16 pixels
now sum in 32 bits (all sums and rounding terms stay below 2^32) and shift for full
power-of-two boxes; the results are the same integers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Region / ScaleDenominator of a VP8 still no longer decode the whole frame: rows go from
the macroblock-row decoder through full-width chroma upsampling, YUV->RGBA of the window's
columns only, the decoder's layout (RGB or RGBA) and the streaming BoxDownscaler, then into
the requested pixel format. Decoding stops after the macroblock row that emits the last
needed row, so the bitstream below is neither decoded nor validated. Output is byte-identical
to the generic path for every PixelFormat. Lossless and animations keep the generic path;
the ALPH plane is still decoded whole.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
quick --scaled and peakmem --scaled time the native path against the baseline's generic
one (checked equal first). Also fixes the baseline loader, which looked for the 3-parameter
DecodeTo that now takes a CancellationToken. Results on a Ryzen 7 5800X: top region 0.18x,
1/2-1/8 0.68-0.93x, working memory ~0.3 MiB instead of ~15 MiB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tills (#7)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`scaled` times TryDecode with a region or a scale against the baseline build's
(the generic path on scaled-decode: full decode, box, crop) in the same process,
checking identical pixels first; `scaled-peakmem` measures the working set of
each in a fresh process. Results on a Ryzen 7 5800X: regions high in the image
0.11-0.49x of the generic path, scale-only 0.75-1.1x (the box filter is the same
work), peak working set 8-15 MiB -> 0.05-2.9 MiB; the full-image decode matrix
is unchanged (geomean 1.01x vs the baseline).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When every tile's displayed rectangle starts on a box edge and ends on one or at the image's
edge (always for a single coded image; for a grid whose seams, through clap / irot / imir, fall
on multiples of the factor), each tile converts its pixels to Rgb24 / Rgb48, pushes them in
display order through its own BoxDownscaler and converts each finished row of boxes into the
destination. No full-size image. Otherwise the existing path (full decode, then the box
filter). Byte-identical to it for every PixelFormat, region and transform.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d PNG (#8)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ondition, time and peak memory

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…le (#9)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sy and HEIF

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sdcb
sdcb merged commit 6448ba5 into main Nov 8, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant