Files
craft-libs/crates/jpeg/README.md
97c83dc9e8 craft-jpeg: decode sequential 8-bit JPEG a band of rows at a time (#743)
* craft-jpeg: decode sequential 8-bit JPEG a band of rows at a time

Bands reads the headers of a sequential Huffman-coded (SOF0, SOF1) 8-bit file whose first scan
holds every component, and a Reader decodes it one line of MCUs at a time: Huffman decoding,
dequantization, inverse transform, upsampling and colour conversion, holding at most three MCU
lines of samples. Nothing the size of the image is allocated, so a frame up to the format's
65,535 px sides can be decoded straight into the caller's storage.

The samples equal decode()'s for the same rows: both go through the same block transform
(idct_block, factored out of reconstruct), the same upsampling maps and the same colour
conversion (to_color, to_u8). A component subsampled down reads the MCU line above and below
its band, as the whole-image upsampling does.

A band that begins a restart interval is a seek point once find_seek_points has found every
restart marker, in sequence; readers on other threads can decode from there. Damaged data
leaves the rest of its restart interval at zero and decoding resumes at the next marker, so the
result is the same however the bands were read. Bits::restart now also clears the exhausted
flag: decode() never restarts after running out of data, the band decoder does.

parse_frame is split into parse_header (validation and geometry) and the whole-image
allocation; parse_scan takes the component identifiers. decode() is unchanged.

Tests: bands against decode() sample for sample over jpeg-encoder streams (gray, YCbCr, CMYK,
YCCK; 4:4:4, 4:2:2, 4:2:0, 4:4:0; four restart intervals) and a test-only encoder's interleaved
layouts with factors of 3 and 4, in order and by seeking; zune-jpeg within the existing
tolerance; damage with and without restarts; limits; unsupported files; the libjpeg test
images; the mutation run with a seek-equals-in-order check; and a `bands` fuzz target.

Co-Authored-By: Νύξ (Nyx, AI) 🌑 <nyx@despotak.is>

* craft-jpeg: faster inverse transform, rounding, Huffman codes and band rows

Each change keeps every sample as it was: decode() and the bands give bit-identical output
before and after on the test images, 12-bit and arithmetic-coded files, 48 generated streams
and a 48,000 × 34,200 file decoded in bands.

- idct_block works on rows of eight: each sum keeps its order (over u, then over v), eight
  values of x at a time, and rows of zero coefficients are skipped (a zero term changes only the
  sign of a zero, which the level shift removes). The transposed basis is built once.
- to_u8 rounds half away from zero as truncation plus one from .5 up, exact on 0..=255, instead
  of the `round` library call targets without SSE4.1 make.
- Huffman::decode resolves codes of up to 9 bits from a table of the next 9 bits, built by
  running the bit-by-bit procedure on each, so both give the same symbol.
- The band rows are colour converted in loops written out for gray, YCbCr and RGB; YCbCr to
  RGB is one helper shared with the whole-image decode.

On the 48,000 × 34,200 4:4:4 file with a restart interval per line of MCUs (Ryzen 7 7800X3D,
idle), one thread decodes a band of 8 rows in about 3.0 ms (5.8 ms before), and 16 threads
decode the whole image in 2.05 s (3.8 s before).

Co-Authored-By: Νύξ (Nyx, AI) 🌑 <nyx@despotak.is>

* craft-jpeg: bands report damage, never fail on it, and see data left before a restart

Two cases a mutation run through PhotoCraft's import turned up.

- Data that fails in the first MCU made a band read fail, as decode() refuses a stream with no
  image data. The baseline decoder PhotoCraft uses for other files opens such files (grey, with
  restarts the rest of the image), so a band read now marks it damaged and goes on: with
  restart intervals everything after the first decodes. Refusing a file with nothing
  decodable is the caller's, from the size of its scan data.
- An interval that decodes without an invalid code but leaves whole bytes before its restart
  marker was decoded from cut, shifted or added data. It now counts as damage (libjpeg warns
  "extraneous bytes before marker"). The check runs after an interval's last MCU, so readers
  that seek and readers that read in order agree.

Over 1,074 sequential 8-bit JPEGs on one machine the damage flag agrees with djpeg's warnings on
every file, reading from seek points equals reading in order on all 151 with restart intervals,
and the samples are within mean 0.57, max 8 of zune-jpeg's.

Co-Authored-By: Νύξ (Nyx, AI) 🌑 <nyx@despotak.is>

* craft-jpeg: seek points follow the restart markers in the data, as reading in order does

find_seek_points gave up on a file whose markers were not all there in sequence, so a damaged
or cut-short file with a restart interval at every line of MCUs decoded in order on one core
(about 13 s for an undamaged 48,000 × 34,200 file on an idle 8-core machine, against 2 s on
16 threads). With these seek points the same file with one marker renumbered, cut at 60%, or
its first interval overwritten decodes in 2.8 to 4.4 s on 16 threads. A reader that reads in
order pairs the k-th restart marker in the data with interval k, whatever its number, and
resumes after each, so seek points built the same way give the same samples for any data:
missing, misnumbered or added markers only change which bands are seek points.

Tested: misnumbered, missing (file cut), added and removed markers read in order and by
seeking; the mutation run's seek-equals-in-order check (more of its cases now seek); 600,000
runs of the bands fuzz target, which asserts the same; 169 local files with restarts.

Co-Authored-By: Νύξ (Nyx, AI) 🌑 <nyx@despotak.is>

* craft-jpeg: Bands::halo, the MCU lines a reader decodes around its bands

A reader that seeks decodes the MCU line above and below its bands when a component is
subsampled down. A caller splitting the work across threads needs to know it: reading one band
from each seek point of a 4:2:0 file decodes three lines per band (4.97 s for a 48,000 × 34,200
file on 16 threads, against 3.09 s reading four bands from each), while for 4:4:4 the run length
makes no difference. Tested against the sampling factors of every interleaved layout.

Co-Authored-By: Νύξ (Nyx, AI) 🌑 <nyx@despotak.is>

* craft-jpeg: band decoder review fixes

- Data left over after the last MCU, before EOI, is damage, as it already
  was before a restart marker.
- A scan that names a component twice is Malformed (T.81 B.2.3), not
  Unsupported.
- The upsampling maps are sized from the header and checked against
  max_alloc before they are made.
- The bands fuzz target and the mutation test fail on a read or seek error
  once Bands::new has accepted a stream; damage never surfaces as an error.
- Known-sample CMYK and YCCK fixtures check the ink polarity directly; the
  bands-equal-decode tests cannot, since both share the colour conversion.
- Docs: a single-component frame's bands are 8 rows; Reader::damaged and
  the scan's size cannot tell a failure at the first MCU from later damage;
  with a halo a seek decodes from the previous seek point; each fuzz target
  writes to its own corpus; doc comments on the remaining new functions.

Co-Authored-By: Νύξ (Nyx, AI) 🌑 <nyx@despotak.is>

* craft-jpeg: test YCCK chroma and trailing-data samples

- The ink fixture gains a YCCK case with Cb and Cr away from neutral, with
  the T.871 values worked out by hand: a conversion that drops or swaps
  the chroma now fails it.
- Bytes added before EOI leave the samples as the clean stream's; the test
  now checks that, not only that reading in order and seeking agree.

Co-Authored-By: Νύξ (Nyx, AI) 🌑 <nyx@despotak.is>

---------

Co-authored-by: Νύξ (Nyx, AI) 🌑 <nyx@despotak.is>
Co-authored-by: Dylan M. Taylor <dylan@dylanmtaylor.com>
2026-11-11 07:37:06 -05:00

201 lines
9.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# craft-jpeg
A clean-room JPEG decoder for the Craft apps, written from ITU-T T.81 (ISO/IEC 10918-1). It
covers the processes that common Rust JPEG decoders skip: 12-bit samples and arithmetic coding.
It also decodes the common sequential 8-bit files a band of rows at a time ([`Bands`](#decoding-in-bands)),
for images too large to decode into one buffer.
No `unsafe`, no panics on malformed input, and it builds for `wasm32-unknown-unknown`. The only
dependency is `thiserror`.
```toml
craft-jpeg = { git = "https://github.com/storytold/craft-libs", rev = "<commit>" }
```
## What it decodes
| Frame | Process | Precision |
|---|---|---|
| SOF0, SOF1 | sequential DCT, Huffman coded | 8 or 12-bit |
| SOF9 | extended sequential DCT, arithmetic coded | 8 or 12-bit |
| SOF10 | progressive DCT, arithmetic coded | 8 or 12-bit |
Every other frame returns `Error::Unsupported`: progressive Huffman (SOF2), lossless (SOF3,
SOF11), hierarchical (SOF5 to SOF7, SOF13 to SOF15), other precisions, more than one frame, and
a height given only in a DNL marker. Images have 1, 3 or 4 components with any sampling factors
and restart intervals.
## Use
```rust
use craft_jpeg::{decode, Limits, Samples};
let img = decode(&bytes, &Limits::default(), adobe_transform)?;
match img.samples {
Samples::U8(px) => { /* 8-bit frames */ }
Samples::U16(px) => { /* 12-bit frames, scaled to 16 bits */ }
}
if img.damaged { /* warn: part of the image is missing */ }
```
- `decode(bytes, &Limits, adobe_transform: Option<u8>) -> Result<Decoded, Error>`.
`adobe_transform` is the transform byte of the APP14 `Adobe` segment, when the app found one.
- `frame(bytes) -> Option<FrameKind>` reads the headers up to the first SOFn marker and returns
that marker and the declared sample precision, without decoding.
- `Error` is `Malformed`, `Unsupported` or `LimitExceeded`, each with a message.
## Output
`Decoded` has `width`, `height`, `components`, `samples` and `damaged`.
- `samples` is interleaved by pixel, rows top to bottom: `Samples::U8` for 8-bit frames,
`Samples::U16` for 12-bit frames, scaled to the full 16-bit range
(`round(v × 65535 / 4095)`).
- `components` is 1 (gray), 3 (RGB) or 4 (CMYK).
- 3 components are converted from YCbCr to RGB (JFIF, T.871), unless the Adobe transform is
0 or the component IDs are `R`, `G`, `B`.
- 4 components are ink amounts (0 = no ink). The stored values are taken to follow Adobe's
inverted convention (maximum = no ink) and are inverted back. With Adobe transform 2 (YCCK),
C, M and Y are converted from YCbCr, which yields ink amounts directly, and K is inverted.
- `damaged` is set when entropy-coded data was cut short or corrupt after at least one MCU had
decoded, or its scans would decode more than 64 passes over the frame's blocks. The rest of
that scan, and every scan past the budget, is left out; those blocks keep what earlier scans
decoded. A stream with no decodable image data is an error. Arithmetic-coded data that stops
early at a marker reads as the zero bytes encoders drop at the end of a scan (libjpeg-turbo
treats it the same way), so for arithmetic scans only data that runs to the end of the file
and a missing restart marker count as damage.
## Limits
`Limits` (fields `max_pixels`, `max_alloc`) is checked against the frame header before anything
is allocated: `width × height` against `max_pixels`, and the output buffer plus the decoder's
own coefficient and sample buffers, together, against `max_alloc`. The defaults are 2³⁰ pixels
and 8 GiB on 64-bit targets, 1 GiB on 32-bit ones (wasm32). The struct is `#[non_exhaustive]`:
start from the defaults and set the fields you change:
```rust
let mut limits = craft_jpeg::Limits::default();
limits.max_alloc = 512 << 20;
```
A file of under a hundred bytes can declare a frame that fills the default limits, and
decoding it still renders every sample. Callers that decode for a UI or for previews should
pass a `max_alloc` well below the default.
## Routing
An app that decodes baseline JPEG with another decoder keeps its own rule for which files come
here, built on `frame`. PhotoCraft sends arithmetic-coded files (SOF9 to SOF11) and SOF0/SOF1
files whose precision is not 8 bits:
```rust
fn use_craft_jpeg(bytes: &[u8]) -> bool {
match craft_jpeg::frame(bytes) {
Some(f) if (0xC9..=0xCB).contains(&f.marker) => true,
Some(f) if matches!(f.marker, 0xC0 | 0xC1) => f.precision != 8,
_ => false,
}
}
```
SOF11 (lossless) falls in that range and returns `Unsupported`.
## Decoding in bands
`Bands` decodes a sequential Huffman-coded (SOF0, SOF1) 8-bit file whose first scan holds every
component, one line of MCUs at a time: a band is 8 rows times the largest vertical sampling
factor (8 rows for a single-component frame, whatever its sampling factors), and the last band
may be shorter. Its samples equal `decode`'s for the same rows (the same inverse transform, upsampling
and colour conversion); only a few MCU lines are held at once. Other files return
`Error::Unsupported`: decode them whole.
```rust
use craft_jpeg::{Bands, Limits};
let mut limits = Limits::default();
limits.max_pixels = 65_535 * 65_535; // the image is never held whole
let bands = Bands::new(&bytes, &limits, adobe_transform)?;
let mut reader = bands.reader();
while let Some(band) = reader.next_band()? {
// band.y, band.rows, band.samples: interleaved like `Decoded::samples`
}
if reader.damaged() { /* warn: part of the image is missing */ }
```
- `limits.max_pixels` bounds the image; `limits.max_alloc` bounds one reader's buffers
(`Bands::reader_bytes`) and the upsampling maps, not the image.
- **In parallel.** A band that starts a restart interval is a seek point once
`Bands::find_seek_points` has found the restart marker before it (one pass over the scan
data). The k-th marker in the data begins interval k, as a reader that reads in order pairs
them, so missing, misnumbered or added markers in a damaged file change nothing but which
bands are seek points. Readers on separate threads can `seek` to seek points and decode the
bands up to the next one. Files with a restart interval at every line of MCUs, common in files
Photoshop saved, decode on every core.
- **Damaged data** leaves the rest of its restart interval at zero coefficients (mid grey) and
decoding resumes at the next restart marker; without restart intervals the rest of the scan
stays at zero, as in `decode`. An interval, or the scan, that leaves more than its padding
unread before the next marker counts as damaged too. `Reader::damaged` is set either way, and
the samples don't depend on where reading started. Damage never makes a read fail, also in the
first MCU (where `decode` reports that there is no image data). `Reader::damaged` does not
tell that case from later damage, and neither does the size of the scan: data that fails at
its first code can be any length.
## Boundaries
- In scope: decoding the frames above into samples.
- Out of scope: metadata. The decoder skips every APPn and COM segment. ICC profiles, EXIF,
XMP, Photoshop resources, resolution and the APP14 `Adobe` segment are the app's to read.
Color management and encoding also stay in the apps.
## Testing
`cargo test -p craft-jpeg` runs:
- unit tests in `src/tests.rs`: arithmetic-coded streams from a test-only encoder must decode to
the coefficients that went in, and Huffman streams are compared against `zune-jpeg`.
- `tests/conformance.rs`, on libjpeg-turbo's test images in `tests/data` (provenance and how
the derived files were made: `tests/data/SOURCE.md`):
- `testimgari.jpg` (SOF9) decodes to exactly the samples of its jpegtran Huffman transcode.
- jpegtran's arithmetic and progressive arithmetic transcodes of `testorig.jpg` (one with
restarts) and `testorig12.jpg` decode to exactly the samples of their sources.
- `whitetail-ari.jpg` (SOF9 from cjpeg, a white lower part that decodes from the zeros
supplied at EOI) decodes undamaged and equal to its Huffman transcode.
- `testorig.jpg` matches `zune-jpeg`, and `testorig12.jpg` matches libjpeg-turbo's 12-bit
decode, within the float-against-integer IDCT difference (mean under 0.75, max 4).
- `src/bands/tests.rs`: `Bands` against `decode`, sample for sample, over gray, YCbCr, CMYK and
YCCK streams from `jpeg-encoder` (4:4:4, 4:2:2, 4:2:0, 4:4:0; restart intervals of 1 and 7
MCUs and of one and two MCU lines) and over interleaved layouts from a test-only encoder
(factors of 3 and 4 across and down, four components), read in order and by seeking; against
`zune-jpeg`; on damaged data (with and without restarts, in the first interval, and bytes
added before a restart marker), limits, and the files it refuses.
- `tests/conformance.rs` also decodes the Huffman-coded 8-bit test images in bands.
- `tests/mutation.rs`: a seeded mutation run (truncations, bit flips, byte and frame-header
overwrites, deleted and repeated spans) over the test images and the fuzz seeds. Decoding must
not panic and must stay within small `Limits`. The band decoder gets the same mutations, and
wherever seek points survive one, reading from them must give the samples reading in order
does.
`.github/workflows/jpeg.yml` runs fmt, clippy, the tests and a `wasm32-unknown-unknown` check on
Linux.
### Fuzzing
`fuzz/` is a cargo-fuzz crate outside the workspace, with two targets: `decode`, and `bands`,
which also checks that reading from the seek points gives the samples reading in order does.
The seeds in `fuzz/corpus/decode` are the test images plus small SOF9 (with restarts), 12-bit
SOF9, SOF10 and 12-bit SOF1 streams; `bands` starts from them too. With nightly and
`cargo install cargo-fuzz`:
```sh
cd crates/jpeg/fuzz
cargo +nightly fuzz run decode
cargo +nightly fuzz run bands corpus/bands corpus/decode
```
Each target adds the inputs it finds to the first corpus directory it reads: `decode` to
`fuzz/corpus/decode`, `bands` to `fuzz/corpus/bands`. Commit only seeds that cover something
new.
## Credits
The decoder was written by Michael Higgins (Higgs32584) for PhotoCraft.