Files
lightcraft/crates/engine/examples/render_bench.rs
T
d729c80a5a Detail: Remove Hot Pixels, on for newly imported raws (#1345) (#1350)
* WIP: suppress isolated hot photosites in the raw mosaic (#820)

Noise model fitted to the image, candidates 5 sigma above every same-colour neighbour, kept when two or
more direct neighbours are lit (real light); adjacent hot pairs are replaced too. LIGHTCRAFT_HOT_PIXELS=0
turns it off, LIGHTCRAFT_HP_DEBUG=1 prints statistics. Renders are cached apart from older ones.

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

* WIP: hot pixel suppression off by default (LIGHTCRAFT_HOT_PIXELS=1 turns it on)

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

* #1345: mosaic outlier filter: cache key no longer names the #858 comparison switch (merged upstream)

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

* #1345: mosaic outlier filter: fixed thresholds (no LIGHTCRAFT_HP_K / _SPOT knobs), statistics under LIGHTCRAFT_PROFILE

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

* #1345: Remove Hot Pixels, a box in Detail ▸ Noise Reduction, on for newly imported raws

`Detail::nr_outliers` replaces single outlier photosites in the raw mosaic before demosaicing. It is
true in `DevelopSettings::for_raw` and false by default, so edits saved before the box existed keep
their look. The loaders take the flag (no more `LIGHTCRAFT_HOT_PIXELS`), a decoded source records
it, and a cached raw source decoded with the other setting is decoded again. The box shows for raws
only (`check:detail.nrOutliers`).

* #1345: Remove Hot Pixels off keeps old settings' JSON; smart previews fit either setting

- `detail.nr_outliers` is left out of the stored JSON when off, so settings saved
  before it keep their hash and cached thumbnails. Copy / paste, presets (also
  at an amount), preset updates and Auto Sync work on
  `DevelopSettings::to_json_for_merge`, which states it, so turning the box off
  still carries over.
- `DecodedSource::fits_outliers` accepts a smart preview (`from_proxy`): it has
  no mosaic, so an offline raw with the box on keeps reusing the decoded proxy.

(review of #1350)

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

* #1345: the Detail eye and Reset Detail switch Remove Hot Pixels off

`DevelopSettings::removes_hot_pixels` (the box and the Detail section's
eye) decides the raw decode in the app, the web worker and the thumbnail
bench, so a Detail section that is off contributes nothing here either.
Reset Detail resets the box to the general default (off), like the
sliders it resets.

(review of #1350)

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

* #1345: the hot pixel limit counts only replaced photosites, and can be set

The 0.2 % limit was checked against the candidates before real light was
filtered out, so a dense star field reached it with its stars alone and
kept its hot pixels too. It now counts the photosites that would be
replaced, as the module docs say.

The limit is now a parameter (RawImage::suppress_hot_pixels_with, default
HOT_PIXEL_MAX_SHARE) and a setting: Settings > Performance > Hot pixel
limit, applied with the new command app.hotPixelLimit. Changing it forgets
the decoded sources and changes the render cache keys of photos that
remove hot pixels; at the default every key stays as it was.

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

* #1345: the app's default hot pixel limit is the default

The setting is an f32: 0.2 % read back as 0.2000000029… % was a new limit,
so every start forgot the decoded sources and changed the render keys of
photos that remove hot pixels. A value within a millionth of the default
now is the default.

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

* Format the imports

* Web loader test states what it decodes with

* Format the media exports

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-05 09:26:47 +01:00

440 lines
21 KiB
Rust
Raw 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.
//! End-to-end render benchmark (min of N runs, robust to a loaded machine):
//! `cargo run --release -p lightcraft-engine --example render_bench -- corpus/raw/arw-sony-a7m3-compressed.arw`
//! (`N=9` runs per scenario, `RAYON_NUM_THREADS=1` for algorithmic comparisons,
//! `LIGHTCRAFT_PROFILE=1` for per-stage timings).
//!
//! Without a file argument a procedural 6000×4000 source is used. Scenarios: a ~2.5 MP loupe render
//! of the 2560 px preview (cold, and with a warm stage cache while a slider is dragged), a draft,
//! and a full-size render + JPEG encode (export); `ONLY=batch` with several files times a full-size
//! export of each (decode + render + encode). Prints minimum wall-clock and minimum process CPU
//! time: on a shared machine the CPU time shows the work done, wall-clock also the wait for cores.
//! Each scenario runs on the CPU pipeline and, when a GPU adapter exists, on `lightcraft-gpu`
//! (second column; `LIGHTCRAFT_GPU=0` for the CPU only, `GPU_ONLY=1` for the GPU only).
//!
//! `ONLY=window`: a 1:1 zoom window cut from the full-size original (the loupe's `Slot::Region`):
//! the first window of a photo, a pan, and exposure / draft / NR changes with a warm stage cache;
//! `DUMP=file` writes the GPU window's RGBA bytes, for pixel A/B comparisons between builds.
//! Opening a photo, per file: `ONLY=source` (decode steps), `ONLY=load` (what the app's loaders
//! cost: file read, decode, binning, embedded stand-ins, `load_bytes(2560)`, the fs loader),
//! `ONLY=load1` (decode checksum + `load_bytes(2560)`; `LOADONLY=1`: only that, for a profiler),
//! `ONLY=jpeg` (a JPEG decoded to fit 256–2560 px boxes).
use std::sync::Arc;
use std::time::Instant;
use lightcraft_develop::DevelopSettings;
use lightcraft_engine::export::{ExportOptions, encode_image};
use lightcraft_engine::media::develop;
use lightcraft_pipeline::{Quality, RenderRequest, SourceInfo, StageCache};
use lightcraft_raster::Rgb32f;
use lightcraft_raster::resample::{Filter, fit};
/// Process CPU time in ms (all threads). The only `unsafe` is this libc clock read, in a dev-only example.
#[allow(unsafe_code)]
fn cpu_ms() -> f64 {
#[cfg(unix)]
{
let mut ts = libc::timespec { tv_sec: 0, tv_nsec: 0 };
// SAFETY: `clock_gettime` only writes into the timespec we pass.
unsafe { libc::clock_gettime(libc::CLOCK_PROCESS_CPUTIME_ID, &mut ts) };
ts.tv_sec as f64 * 1e3 + ts.tv_nsec as f64 / 1e6
}
#[cfg(not(unix))]
{
0.0
}
}
/// Minimum wall-clock and minimum CPU time (ms) over the runs.
struct T(f64, f64);
impl std::fmt::Display for T {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:.1} ms wall, {:.1} ms cpu", self.0, self.1)
}
}
fn best(n: usize, mut f: impl FnMut()) -> T {
let mut b = T(f64::MAX, f64::MAX);
for _ in 0..n {
let (t, c) = (Instant::now(), cpu_ms());
f();
b.1 = b.1.min(cpu_ms() - c);
b.0 = b.0.min(t.elapsed().as_secs_f64() * 1e3);
}
b
}
fn typical() -> DevelopSettings {
let mut s = DevelopSettings::default();
s.light.exposure = 0.3;
s.light.highlights = -40.0;
s.light.shadows = 30.0;
s.effects.clarity = 15.0;
s.effects.texture = 10.0;
s.effects.dehaze = 10.0;
s.detail.nr_luminance = 30.0;
s.detail.nr_color = 25.0;
s.detail.sharpen_amount = 40.0;
s
}
/// Max and mean |Δ| (8-bit, RGB) between two renders.
fn diff(a: &lightcraft_raster::Rgba8, b: &lightcraft_raster::Rgba8) -> (u8, f64) {
let (mut max, mut sum) = (0u8, 0u64);
for (p, q) in a.data.iter().zip(&b.data) {
for c in 0..3 {
let d = p[c].abs_diff(q[c]);
max = max.max(d);
sum += d as u64;
}
}
(max, sum as f64 / (a.data.len() * 3).max(1) as f64)
}
fn benchmark_jpeg(args: &[String], n: usize) {
// a JPEG (e.g. an embedded preview pulled out with exiftool) decoded to fit a few boxes
for path in args {
let bytes = std::fs::read(path).expect("read");
for edge in [256u32, 512, 1600, 2560] {
let o = lightcraft_codecs::DecodeOptions::fit(edge, edge);
let d = lightcraft_codecs::decode(&bytes, o).expect("decode");
println!(
" {path} fit {edge}: {}×{} {}",
d.image.width,
d.image.height,
best(n, || drop(lightcraft_codecs::decode(&bytes, o).expect("decode")))
);
}
}
}
fn benchmark_load(args: &[String], n: usize, only: &str) {
// opening a photo in the loupe, step by step: what the app's loaders cost (per file)
for path in args {
let bytes = std::fs::read(path).expect("read");
println!("{path}");
if std::env::var("LOADONLY").is_ok() {
// only the preview load (for a profiler)
println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load"))));
continue;
}
println!(" {:<36} {}", "file read:", best(n, || drop(std::fs::read(path).expect("read"))));
println!(" {:<36} {}", "raw decode:", best(n, || drop(lightcraft_raw::decode(&bytes).expect("decode"))));
let mut raw = lightcraft_raw::decode(&bytes).expect("decode");
if let lightcraft_raw::RawData::U16(d) = &raw.data {
let sum = d.iter().enumerate().fold(0u64, |a, (i, &v)| a.wrapping_mul(31).wrapping_add(v as u64 ^ i as u64));
println!(" decode checksum: {sum:016x}");
}
if only == "load1" {
println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load"))));
continue;
}
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
if let Some(k) = lightcraft_engine::files::bin_factor(&raw, 2560) {
println!(" {:<36} {}", format!("binned ×{k} masked:"), best(n, || drop(raw.develop_binned_masked(k, 0.99))));
}
for edge in [2560usize, 1600] {
println!(
" {:<36} {}",
format!("embedded srgb, whole file ({edge}):"),
best(n, || drop(lightcraft_engine::files::embedded_preview_srgb(&bytes, edge)))
);
let quick = lightcraft_engine::files::fs_preview_loader();
println!(" {:<36} {}", format!("fs_preview_loader ({edge}):"), best(n, || drop(quick(path, edge))));
}
println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load"))));
let (loader, _) = lightcraft_engine::files::fs_hooks();
println!(" {:<36} {}", "fs loader (2560, read+load):", best(n, || drop(loader(path, 2560, Default::default()).expect("load"))));
}
}
fn benchmark_source(args: &[String], n: usize) {
// opening a photo: where the time goes before the first sharp loupe (per file)
for path in args {
let bytes = std::fs::read(path).expect("read");
println!("{path}");
let mut raw = lightcraft_raw::decode(&bytes).expect("decode");
// as the loader does: lens corrections are applied by the pipeline, not in the raw develop
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
println!(" {}×{} cpp {} cfa {:?}", raw.width, raw.height, raw.cpp, raw.cfa.as_ref().map(|c| c.name()));
println!(" {:<36} {}", "raw decode:", best(n, || drop(lightcraft_raw::decode(&bytes).expect("decode"))));
println!(" {:<36} {}", "normalize:", best(n, || drop(raw.normalized().expect("norm"))));
let norm = raw.normalized().expect("norm");
println!(" {:<36} {}", "demosaic AHD:", best(n, || drop(lightcraft_raw::demosaic(&norm, lightcraft_raw::Method::Ahd))));
if let Some(k) = lightcraft_engine::files::bin_factor(&raw, 2560) {
println!(" {:<36} {}", format!("binned ×{k} (2560 preview):"), best(n, || drop(raw.develop_binned(k, 0.99))));
let img = raw.develop_binned(k, 0.99).expect("bin").expect("binnable");
let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
println!(
" {:<36} {}",
"highlight reconstruct (binned):",
best(n, || {
let mut i = img.clone();
lightcraft_raw::highlight::reconstruct(&mut i, t.wb, 0.99);
})
);
println!(" {:<36} {}", "clone (binned):", best(n, || drop(img.clone())));
println!(" {:<36} {}", "fit 2560 (binned):", best(n, || drop(fit(&img, 2560, 2560, Filter::Box))));
}
println!(" {:<36} {}", "embedded preview (2560):", best(n, || drop(lightcraft_engine::files::load_embedded_preview(&bytes, 2560))));
for edge in [512usize, 2560, usize::MAX] {
println!(
" {:<36} {}",
format!("load_bytes({edge}):"),
best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, edge).expect("load")))
);
}
}
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let n: usize = std::env::var("N").ok().and_then(|v| v.parse().ok()).unwrap_or(5);
let only = std::env::var("ONLY").unwrap_or_default();
if only == "jpeg" {
benchmark_jpeg(&args, n);
return;
}
if only == "load" || only == "load1" {
benchmark_load(&args, n, &only);
return;
}
if only == "source" {
benchmark_source(&args, n);
return;
}
let run = |name: &str| only.is_empty() || only.split(',').any(|o| name.contains(o));
let (full, info) = match args.first() {
Some(path) => {
let bytes = std::fs::read(path).expect("read");
let t = Instant::now();
let r = lightcraft_engine::files::load_bytes(&bytes, usize::MAX).expect("decode");
println!("decode full: {:.0} ms", t.elapsed().as_secs_f64() * 1e3);
r
}
None => (
Rgb32f::from_fn(6000, 4000, |x, y| {
let v = ((x as f32 * 0.011).sin() * (y as f32 * 0.007).cos() * 0.5 + 0.5) * 0.4 + ((x * 31 + y * 17) % 13) as f32 * 0.002;
[v, v * 0.9, v * 0.7]
}),
SourceInfo { raw: true, ..Default::default() },
),
};
let full = Arc::new(full);
println!("source {}×{} ({:.1} MP), {n} runs each", full.width, full.height, (full.width * full.height) as f64 / 1e6);
// GPU column: `LIGHTCRAFT_GPU=0` (or no adapter) prints only the CPU column.
let t = Instant::now();
let gpu = lightcraft_engine::gpu::available();
match lightcraft_engine::gpu::adapter_name().filter(|_| gpu) {
Some(a) => println!("gpu: {a} (device + kernels: {:.0} ms)", t.elapsed().as_secs_f64() * 1e3),
None => println!("gpu: none"),
}
// `GPU_ONLY=1`: skip the CPU column (profiling the GPU path)
let backends: &[bool] = match (gpu, std::env::var_os("GPU_ONLY").is_some()) {
(true, true) => &[true],
(true, false) => &[false, true],
_ => &[false],
};
let row = |name: &str, f: &mut dyn FnMut(bool)| {
let cols: Vec<String> = backends.iter().map(|&g| format!("{}", best(n, || f(g)))).collect();
let first = if backends[0] { "gpu" } else { "cpu" };
println!("{name:<38} {first}: {}{}", cols[0], cols.get(1).map(|g| format!(" | gpu: {g}")).unwrap_or_default());
};
let preview = Arc::new(fit(&full, 2560, 2560, Filter::Box));
let s = typical();
let view = RenderRequest::fit(1920, 1280);
let draft = RenderRequest { quality: Quality::Draft, ..RenderRequest::fit(1152, 768) };
if gpu {
let c = develop(&preview, &info, &s, &view, None, false).image;
let g = develop(&preview, &info, &s, &view, None, true).image;
let (max, mean) = diff(&c, &g);
println!("gpu vs cpu, loupe 1920×1280 typical: max {max} LSB, mean {mean:.4} LSB");
}
if run("cold") {
// a new photo: no cached stages (the GPU also uploads the preview)
row("loupe 1920×1280 cold:", &mut |g| drop(develop(&preview, &info, &s, &view, None, g)));
row("loupe draft 1152×768 cold:", &mut |g| drop(develop(&preview, &info, &s, &draft, None, g)));
}
let caches = [StageCache::default(), StageCache::default()];
let mut k = 0.0;
if run("drag") {
for g in backends {
drop(develop(&preview, &info, &s, &view, Some(&caches[*g as usize]), *g));
}
row("loupe 1920×1280 exposure drag (warm):", &mut |g| {
let mut t = s.clone();
k += 1.0;
t.light.contrast = k % 50.0;
t.light.exposure = 0.3 + k * 0.01;
drop(develop(&preview, &info, &t, &view, Some(&caches[g as usize]), g));
});
for g in backends {
drop(develop(&preview, &info, &s, &draft, Some(&caches[*g as usize]), *g));
}
row("loupe draft highlights drag (warm):", &mut |g| {
let mut t = s.clone();
k += 1.0;
t.light.highlights = -40.0 + k % 50.0;
drop(develop(&preview, &info, &t, &draft, Some(&caches[g as usize]), g));
});
row("loupe draft clarity drag (warm):", &mut |g| {
let mut t = s.clone();
k += 1.0;
t.effects.clarity = 15.0 + k % 50.0;
drop(develop(&preview, &info, &t, &draft, Some(&caches[g as usize]), g));
});
row("loupe draft NR drag (warm):", &mut |g| {
let mut t = s.clone();
k += 1.0;
t.detail.nr_luminance = 30.0 + k % 50.0;
drop(develop(&preview, &info, &t, &draft, Some(&caches[g as usize]), g));
});
row("loupe 1920×1280 NR drag (warm):", &mut |g| {
let mut t = s.clone();
k += 1.0;
t.detail.nr_luminance = 30.0 + k % 50.0;
drop(develop(&preview, &info, &t, &view, Some(&caches[g as usize]), g));
});
}
if run("window") {
// a 1:1 zoom window (the loupe's `Slot::Region`): visible 1920×1200 + margin, snapped to the
// 256 px grid, cut from the full-resolution original (`ui-egui` `region::window_for`)
use lightcraft_pipeline::PixelWindow;
let (fw, fh) = (full.width, full.height);
let win = |cx: usize, cy: usize| {
let snap = |v: usize| v / 256 * 256;
let (x, y) = (snap(cx.saturating_sub(960 + 512)), snap(cy.saturating_sub(600 + 512)));
PixelWindow { x, y, w: 3072.min(fw - x), h: 2304.min(fh - y) }
};
let at = |w: PixelWindow, q: Quality| RenderRequest { window: Some(w), quality: q, ..RenderRequest::fit(fw, fh) };
let w0 = win(fw / 2, fh / 2);
println!("window {}×{} at ({}, {}) of {fw}×{fh}", w0.w, w0.h, w0.x, w0.y);
// first window of a photo: nothing cached (the GPU uploads the original: `shared_source`)
row("window first (upload original):", &mut |g| {
lightcraft_engine::gpu::trim_pool(0);
drop(develop(&full, &info, &s, &at(w0, Quality::Full), Some(&StageCache::default()), g))
});
let wc = [StageCache::default(), StageCache::default()];
for g in backends {
drop(develop(&full, &info, &s, &at(w0, Quality::Full), Some(&wc[*g as usize]), *g));
}
let mut step = 0usize;
row("window pan (new window, warm src):", &mut |g| {
step += 1;
let w = win(fw / 2 + 512 * (step % 4 + 1), fh / 2);
drop(develop(&full, &info, &s, &at(w, Quality::Full), Some(&wc[g as usize]), g))
});
for g in backends {
drop(develop(&full, &info, &s, &at(w0, Quality::Full), Some(&wc[*g as usize]), *g));
}
row("window exposure change (warm):", &mut |g| {
let mut t = s.clone();
k += 1.0;
t.light.exposure = 0.3 + k * 0.01;
drop(develop(&full, &info, &t, &at(w0, Quality::Full), Some(&wc[g as usize]), g));
});
row("window draft exposure drag (warm):", &mut |g| {
let mut t = s.clone();
k += 1.0;
t.light.exposure = 0.3 + k * 0.01;
drop(develop(&full, &info, &t, &at(w0, Quality::Draft), Some(&wc[g as usize]), g));
});
row("window NR change (warm):", &mut |g| {
let mut t = s.clone();
k += 1.0;
t.detail.nr_luminance = 30.0 + k % 50.0;
drop(develop(&full, &info, &t, &at(w0, Quality::Full), Some(&wc[g as usize]), g));
});
// `DUMP=file`: the GPU window's raw RGBA bytes (A/B pixel comparisons between builds)
if let (true, Some(path)) = (gpu, std::env::var_os("DUMP")) {
let g = develop(&full, &info, &s, &at(w0, Quality::Full), Some(&StageCache::default()), true).image;
let bytes: Vec<u8> = g.data.iter().flatten().copied().collect();
std::fs::write(path, bytes).expect("dump");
}
if gpu && std::env::var_os("GPU_ONLY").is_none() {
let c = develop(&full, &info, &s, &at(w0, Quality::Full), None, false).image;
let g = develop(&full, &info, &s, &at(w0, Quality::Full), None, true).image;
let (max, mean) = diff(&c, &g);
println!("gpu vs cpu, window: max {max} LSB, mean {mean:.4} LSB");
}
}
if run("finish") {
// per-pixel stage cost by feature (warm cache: exposure changes only)
let variants: [(&str, fn(&mut DevelopSettings)); 6] = [
("typical", |_| {}),
("- dehaze", |t| t.effects.dehaze = 0.0),
("- clarity", |t| t.effects.clarity = 0.0),
("- texture/sharpen", |t| {
t.effects.texture = 0.0;
t.detail.sharpen_amount = 0.0;
}),
("- highlights/shadows", |t| {
t.light.highlights = 0.0;
t.light.shadows = 0.0;
}),
("+ vibrance/saturation", |t| {
t.color.vibrance = 20.0;
t.color.saturation = 10.0;
}),
];
for (name, f) in variants {
let mut t = s.clone();
f(&mut t);
for g in backends {
drop(develop(&preview, &info, &t, &view, Some(&caches[*g as usize]), *g));
}
row(&format!("finish 1920×1280 {name}"), &mut |g| {
k += 1.0;
t.light.exposure = 0.3 + k * 0.001;
drop(develop(&preview, &info, &t, &view, Some(&caches[g as usize]), g));
});
}
}
if run("batch") && args.len() > 1 {
// full-size JPEG export of all the files given (decode + render + encode), as the Export
// dialog runs it: several photos side by side (`export_parallelism`), files discarded
use lightcraft_engine::export::{Destination, export_batch};
let mut session = lightcraft_engine::Session::new().with_fs();
let r = session.execute("library.import", &serde_json::json!({"paths": args})).expect("import");
let ids: Vec<_> = r["imported"].as_array().expect("ids").iter().filter_map(|v| v.as_u64()).map(lightcraft_engine::catalog::PhotoId).collect();
let o = ExportOptions::default();
let lanes = lightcraft_engine::export::export_parallelism(ids.len());
row(&format!("batch export of {} files, {lanes} at a time (decode+render+JPEG):", ids.len()), &mut |g| {
lightcraft_engine::gpu::set_enabled(g);
let files = export_batch(&mut session, &ids, &o, &Destination::default(), &mut |_, _| Ok(()), &|_| false).expect("export");
assert_eq!(files.len(), ids.len());
for &id in &ids {
session.media.forget(id); // every original is decoded once per run
}
});
lightcraft_engine::gpu::set_enabled(true);
return;
}
if run("export") {
let big = RenderRequest::fit(full.width, full.height);
let ne = n.min(3);
let mut img = None;
let cols: Vec<String> = backends
.iter()
.map(|&g| {
let ms = best(ne, || img = Some(develop(&full, &info, &s, &big, None, g).image));
format!("{ms}")
})
.collect();
println!(
"{:<38} cpu: {}{}",
format!("export render {}×{}:", full.width, full.height),
cols[0],
cols.get(1).map(|g| format!(" | gpu: {g}")).unwrap_or_default()
);
if gpu {
let c = develop(&full, &info, &s, &big, None, false).image;
let (max, mean) = diff(&c, img.as_ref().expect("rendered"));
println!("gpu vs cpu, export {}×{} typical: max {max} LSB, mean {mean:.4} LSB", full.width, full.height);
}
let img = img.expect("rendered");
println!("export JPEG encode: {}", best(ne, || drop(encode_image(&img, &ExportOptions::default()))));
}
}