Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
41 changes: 32 additions & 9 deletions TEST_FIXTURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,19 @@ tarball via GitHub Releases. This repo pins specific device versions in

Standard-format fixtures (JPEG, PNG, GIF, TIFF, WebP, SVG, AVIF, JXL) are
generated locally by `examples/generate_test_fixtures.rs` with full EXIF, ICC,
and XMP metadata embedded.
and XMP metadata embedded. The HEIC fixture is generated with external tools
(`heif-enc`, see `tests/heic_hw_decode.rs` module docs) because rawshift is
deliberately HEIC decode-only.

> **Post-gamut-migration note:** the standard-format encoders are now gamut
> backends (gamut-jpeg, gamut-png, gamut-avif, gamut-jxl — replacing
> zune-jpeg/jpegli, the `png`/zune-png stack, ravif/libaom, and the libjxl
> glue; HEIC dropped libheif for gamut-heic + rawshift-hwdec). Encoded bytes
> differ from pre-migration outputs, so locally generated fixtures from
> before the migration are stale — re-run
> `cargo run -p rawshift-image --example generate_test_fixtures --features full`
> (or `just generate-fixtures`) to refresh them. `expected.json` ground truth
> is byte-format-independent and remains valid.

---

Expand Down Expand Up @@ -60,7 +72,8 @@ test_data/
│ ├── webp/test_8x8.webp
│ ├── svg/test_8x8.svg
│ ├── avif/test_8x8.avif # (with avif-encode feature)
│ └── jxl/test_8x8.jxl # (with jxl-encode feature)
│ ├── jxl/test_8x8.jxl # (with jxl-encode feature)
│ └── heic/test_64x64.heic # (external: heif-enc, see tests/heic_hw_decode.rs)
└── .device-versions/ # Per-device version stamps (written by fetch script)
├── sony-ilce-6700 # contains "1"
└── apple-iphone-17-pro-max # contains "1"
Expand Down Expand Up @@ -172,13 +185,22 @@ just test-fixtures
# Individual test files
cargo test --features=experimental --test raw_decode_fixtures
cargo test --test standard_decode_fixtures
cargo test --features=tiff-parser --test tiff_parser_tests
cargo test --features=tiff-parser --test dng_check
cargo test --features=arw --test ifd_decoder_tests
cargo test --features=dng --test dng_check
cargo test --features=heic --test heic_aux

# Hardware decode (compiled with `hw`; skips gracefully without a GPU)
cargo test --features=full --test heic_hw_decode --test avif_hw_decode

# With specific features
cargo test --features=full
```

The pre-migration `tiff_parser_tests` suite died with the in-repo binrw TIFF
parser (rawshift#21) — IFD structure walking is now gamut-ifd, exercised by
`ifd_decoder_tests` and `dng_check`; its malformed-input corpus was
contributed upstream (justin13888/gamut#262, #264).

Tests skip gracefully when fixture files are missing — `cargo test` always
passes even without test data.

Expand All @@ -194,12 +216,13 @@ passes even without test data.
### Test data gaps
- [ ] RAW formats: CR2, CR3, CRW, NEF, RAF — need sample images sourced and
added to rawshift-test-fixtures repo
- [ ] JXL fixtures — generator implemented but gated behind `jxl-encode` feature;
needs `expected.json` and decode test
- [ ] JXL fixture decode test — generator (gated behind `jxl-encode`) writes
`test_8x8.jxl` and its `expected.json`, but `standard_decode_fixtures`
has no JXL case yet (decode itself is covered by the
`export_format_tests` round-trips)
- [ ] TIFF metadata — library reads EXIF from TIFF but no metadata is embedded
in the TIFF test fixture (tiff crate encoder doesn't use encode_rgb_image path)
- [ ] AVIF fixtures — generator implemented but gated behind `avif-encode` feature;
needs `expected.json` and decode test
in the TIFF test fixture (tiff crate encoder doesn't use encode_rgb_image
path; TIFF is a blocked migration — gamut#299/#300, rawshift#22)

### Metadata coverage gaps
- [ ] IPTC metadata — not implemented in rawshift at all
Expand Down
217 changes: 215 additions & 2 deletions crates/rawshift-image/benches/decode.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
//! Benchmarks for RAW image data structure creation and basic operations.
//! Benchmarks for image decode paths.
//!
//! Covers three layers:
//! - RAW data-structure primitives (creation, pixel access),
//! - the gamut-backed standard codecs (JPEG/PNG encode + decode round-trips
//! on a synthetic image — the going-forward per-format regression baseline
//! after the gamut migration),
//! - hardware HEIC/AVIF still decode (`hw` feature). The hardware benches
//! generate their fixtures locally with `heif-enc` / `avifenc` and skip
//! cleanly when the tool is not installed or no hardware decoder is usable
//! at runtime, so `cargo bench` stays green on GPU-less machines and CI.

use criterion::{Criterion, criterion_group, criterion_main};
use rawshift_image::core::image::{CfaPattern, Dimensions, Point, RawImage, Rect};
Expand Down Expand Up @@ -45,5 +55,208 @@ fn bench_pixel_access(c: &mut Criterion) {
});
}

criterion_group!(benches, bench_raw_image_creation, bench_pixel_access);
// ── Standard-codec round-trips (gamut backends) ──────────────────────────────

/// A synthetic 16-bit RGB gradient with per-pixel variation, so codec work is
/// representative (not a flat plane the entropy coder shortcuts).
#[cfg(any(
all(feature = "jpeg-decode", feature = "jpeg-encode"),
all(feature = "png-decode", feature = "png-encode"),
))]
fn synthetic_rgb(width: u32, height: u32) -> rawshift_image::core::RgbImage {
let mut data = Vec::with_capacity((width * height * 3) as usize);
for y in 0..height {
for x in 0..width {
let r = (x * 65535 / width.max(1)) as u16;
let g = (y * 65535 / height.max(1)) as u16;
let b = (((x ^ y) & 0xFF) as u16) * 257;
data.extend_from_slice(&[r, g, b]);
}
}
rawshift_image::core::RgbImage::new(width, height, data).expect("valid RGB buffer")
}

#[cfg(all(feature = "jpeg-decode", feature = "jpeg-encode"))]
fn bench_jpeg_codec(c: &mut Criterion) {
use rawshift_image::core::metadata::ImageMetadata;
use rawshift_image::formats::export::EncodeOptions;
use rawshift_image::formats::{StandardFormat, decode_standard_image, encode_rgb_image_to_vec};

let image = synthetic_rgb(512, 512);
let metadata = ImageMetadata::default();
let opts = EncodeOptions::jpeg();

c.bench_function("jpeg_encode_512x512", |b| {
b.iter(|| encode_rgb_image_to_vec(&image, &metadata, &opts).expect("encode JPEG"));
});

let bytes = encode_rgb_image_to_vec(&image, &metadata, &opts).expect("encode JPEG");
c.bench_function("jpeg_decode_512x512", |b| {
b.iter(|| decode_standard_image(&bytes, StandardFormat::Jpeg).expect("decode JPEG"));
});
}

#[cfg(not(all(feature = "jpeg-decode", feature = "jpeg-encode")))]
fn bench_jpeg_codec(_c: &mut Criterion) {}

#[cfg(all(feature = "png-decode", feature = "png-encode"))]
fn bench_png_codec(c: &mut Criterion) {
use rawshift_image::core::metadata::ImageMetadata;
use rawshift_image::formats::export::EncodeOptions;
use rawshift_image::formats::{StandardFormat, decode_standard_image, encode_rgb_image_to_vec};

let image = synthetic_rgb(512, 512);
let metadata = ImageMetadata::default();
let opts = EncodeOptions::png();

c.bench_function("png_encode_512x512", |b| {
b.iter(|| encode_rgb_image_to_vec(&image, &metadata, &opts).expect("encode PNG"));
});

let bytes = encode_rgb_image_to_vec(&image, &metadata, &opts).expect("encode PNG");
c.bench_function("png_decode_512x512", |b| {
b.iter(|| decode_standard_image(&bytes, StandardFormat::Png).expect("decode PNG"));
});
}

#[cfg(not(all(feature = "png-decode", feature = "png-encode")))]
fn bench_png_codec(_c: &mut Criterion) {}

// ── Hardware HEIC/AVIF decode (`hw` feature) ─────────────────────────────────

/// Local fixture generation for the hardware benches: a synthetic Y4M pushed
/// through an external encoder CLI (`heif-enc` / `avifenc`). Everything lands
/// in a per-process temp dir; nothing is committed and nothing needs human
/// sourcing.
#[cfg(all(feature = "hw", any(feature = "heic-decode", feature = "avif-decode")))]
mod hw_fixtures {
use std::path::Path;
use std::process::Command;

/// Write an 8-bit 4:2:0 Y4M whose luma varies per pixel, so the encoded
/// stream carries real content for the decoder to work on.
fn write_y4m_420(path: &Path, w: usize, h: usize) {
let mut out = format!("YUV4MPEG2 W{w} H{h} F25:1 Ip A1:1 C420jpeg\nFRAME\n").into_bytes();
for y in 0..h {
for x in 0..w {
out.push(((x + y) & 0xFF) as u8);
}
}
out.extend(std::iter::repeat_n(96u8, (w / 2) * (h / 2)));
out.extend(std::iter::repeat_n(160u8, (w / 2) * (h / 2)));
std::fs::write(path, out).expect("write y4m");
}

/// Encode a synthetic Y4M with `tool`, returning the container bytes, or
/// `None` (after eprintln-ing why) when the tool is missing or fails —
/// the caller skips its benchmark in that case.
pub fn encode_synthetic(
tool: &str,
extra: &[&str],
ext: &str,
w: usize,
h: usize,
) -> Option<Vec<u8>> {
let dir = std::env::temp_dir().join(format!("rawshift_hw_bench_{}", std::process::id()));
std::fs::create_dir_all(&dir).ok()?;
let y4m = dir.join(format!("bench_{w}x{h}.y4m"));
write_y4m_420(&y4m, w, h);
let out_path = dir.join(format!("bench_{w}x{h}.{ext}"));
match Command::new(tool)
.args(extra)
.arg("-o")
.arg(&out_path)
.arg(&y4m)
.output()
{
Ok(out) if out.status.success() => std::fs::read(&out_path).ok(),
Ok(out) => {
eprintln!(
"Skipping {ext} hardware decode bench: {tool} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
None
}
Err(_) => {
eprintln!("Skipping {ext} hardware decode bench: {tool} not installed");
None
}
}
}
}

#[cfg(all(feature = "hw", feature = "heic-decode"))]
fn bench_heic_hw_decode(c: &mut Criterion) {
use rawshift_image::formats::{HeicFile, heic_hw_decode_available};

if !heic_hw_decode_available() {
eprintln!(
"Skipping HEIC hardware decode bench: no hardware HEVC decoder usable at runtime"
);
return;
}
let Some(data) = hw_fixtures::encode_synthetic("heif-enc", &[], "heic", 512, 512) else {
return;
};
let file = match HeicFile::open(data) {
Ok(file) => file,
Err(err) => {
eprintln!("Skipping HEIC hardware decode bench: container rejected: {err}");
return;
}
};
// One probe decode: drivers can advertise support yet reject the stream —
// skip instead of panicking mid-benchmark.
if let Err(err) = file.decode_primary() {
eprintln!("Skipping HEIC hardware decode bench: probe decode failed: {err}");
return;
}
c.bench_function("heic_hw_decode_primary_512x512", |b| {
b.iter(|| file.decode_primary().expect("hardware decode HEIC primary"));
});
}

#[cfg(not(all(feature = "hw", feature = "heic-decode")))]
fn bench_heic_hw_decode(_c: &mut Criterion) {}

#[cfg(all(feature = "hw", feature = "avif-decode"))]
fn bench_avif_hw_decode(c: &mut Criterion) {
use rawshift_image::formats::{AvifFile, avif_hw_decode_available};

if !avif_hw_decode_available() {
eprintln!("Skipping AVIF hardware decode bench: no hardware AV1 decoder usable at runtime");
return;
}
let Some(data) = hw_fixtures::encode_synthetic("avifenc", &["-s", "8"], "avif", 512, 512)
else {
return;
};
let file = match AvifFile::open(data) {
Ok(file) => file,
Err(err) => {
eprintln!("Skipping AVIF hardware decode bench: container rejected: {err}");
return;
}
};
if let Err(err) = file.decode_primary() {
eprintln!("Skipping AVIF hardware decode bench: probe decode failed: {err}");
return;
}
c.bench_function("avif_hw_decode_primary_512x512", |b| {
b.iter(|| file.decode_primary().expect("hardware decode AVIF primary"));
});
}

#[cfg(not(all(feature = "hw", feature = "avif-decode")))]
fn bench_avif_hw_decode(_c: &mut Criterion) {}

criterion_group!(
benches,
bench_raw_image_creation,
bench_pixel_access,
bench_jpeg_codec,
bench_png_codec,
bench_heic_hw_decode,
bench_avif_hw_decode
);
criterion_main!(benches);
81 changes: 81 additions & 0 deletions docs/BENCHMARKS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Benchmark Baselines

Criterion benches live in `crates/rawshift-image/benches/` (`decode`,
`demosaic`, `pipeline`). Run them with:

```bash
# All three criterion benches (add --features full for hw HEIC/AVIF benches)
cargo bench -p rawshift-image --features full --bench decode --bench demosaic --bench pipeline

# Fast pass (what produced the table below)
cargo bench -p rawshift-image --features full --bench decode --bench demosaic --bench pipeline -- --quick
```

The hardware HEIC/AVIF benches (`decode` bench, `hw` feature) generate their
fixtures locally with `heif-enc` / `avifenc` and skip cleanly when either tool
is missing or no hardware decoder is usable at runtime.

## Baseline provenance (gamut migration, issue #35)

Pre-migration baselines were **not captured** before the gamut migration epic
(#38) started, so no repo-wide before/after comparison exists. Per-codec
before/after numbers were recorded in the individual migration PRs where they
were measured (PNG: #47, JPEG: #52). The table below is the **post-migration
baseline** — the reference point for future regressions — captured on
2026-07-18 at commit `217c596` (post `chore(features,ci)`), `--quick` mode.

Machine: AMD Ryzen 7 7800X3D (16 threads), Radeon RX 7900 (VAAPI/radeonsi),
Linux (Fedora 44). `--quick` numbers are indicative, not tightly converged —
re-measure with a full `cargo bench` run before acting on small deltas.

## Post-migration baseline (2026-07-18)

### decode (`--features full`)

| Benchmark | Time (mid estimate) |
|---|---|
| raw_image_creation/1000x1000 | 16.6 µs |
| raw_image_creation/4000x3000 | 192.8 µs |
| raw_image_creation/8000x6000 | 5.7 µs¹ |
| pixel_get_4000x3000 | 47.3 µs |
| jpeg_encode_512x512 (gamut-jpeg) | 6.48 ms |
| jpeg_decode_512x512 (gamut-jpeg) | 6.04 ms |
| png_encode_512x512 (gamut-png) | 23.0 ms |
| png_decode_512x512 (gamut-png) | 4.72 ms |
| heic_hw_decode_primary_512x512 (gamut-heic + VAAPI HEVC) | 9.20 ms |
| avif_hw_decode_primary_512x512 (gamut-avif + VAAPI AV1) | 9.48 ms |

¹ Lazy zero-page allocation artifact at this size; not a real throughput
number.

### demosaic

| Benchmark | Time (mid estimate) |
|---|---|
| demosaic_bilinear/100x100 | 22.3 µs |
| demosaic_bilinear/500x500 | 261.9 µs |
| demosaic_bilinear/1000x1000 | 917.6 µs |
| demosaic_bilinear/2000x2000 | 3.45 ms |
| demosaic_amaze/100x100 | 393.4 µs |
| demosaic_amaze/500x500 | 9.06 ms |
| demosaic_amaze/1000x1000 | 36.7 ms |
| demosaic_amaze/2000x2000 | 199.5 ms |

### pipeline

| Benchmark | Time (mid estimate) |
|---|---|
| black_level/1000x1000 | 1.28 ms |
| black_level/4000x3000 | 15.8 ms |
| white_balance/1000x1000 | 2.77 ms |
| white_balance/4000x3000 | 34.0 ms |
| color_matrix/1000x1000 | 3.06 ms |
| color_matrix/4000x3000 | 35.3 ms |
| tone_mapping/1000x1000 | 1.15 ms |
| tone_mapping/4000x3000 | 9.39 ms |

## Updating this baseline

Gamut pin bumps require a full test + benchmark run (AGENTS.md). When a run
shows a deliberate, explained shift (new backend, algorithm change), refresh
the affected rows here in the same PR and note the change in CHANGELOG.md.
2 changes: 1 addition & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,4 @@ coverage-report:

# Run all fixture-based integration tests (fetches fixtures first)
test-fixtures: setup-test-data
cargo test -p rawshift-image --features=full --test raw_decode_fixtures --test standard_decode_fixtures --test dng_check
cargo test -p rawshift-image --features=full --test raw_decode_fixtures --test standard_decode_fixtures --test ifd_decoder_tests --test dng_check