Files
7935999d94 LR-EDIT-DETAIL-DENOISE: non-destructive AI RAW denoise with pure-Rust CPU/GPU
Recover the unpublished denoise work against current upstream. Preserve later opt-in model setup, shared Rust HTTPS downloads, pending-photo resume and an Amount-independent switch. Keep original RAWs unchanged and share the current color finish between ordinary and denoised sources.

Bound public tiling inputs, refuse corrupt-cache exports, and retain synthetic/real-model coverage. Include two Windows path spelling fixes in existing tests. No model weights, face code, media or Tract runtime are included.

Validation: cargo xtask ci (all seven gates); installed RawNIND CPU/GPU agreement 1.38e-6; native CPU preview/export on a public 24 MP Sony RAW, unchanged original SHA-256.

Co-authored-by: Codex <[email protected]>

Co-authored-by: Claude Sonnet 5.5 <[email protected]>

Generated with Codex.
2026-10-08 22:07:20 -04:00

102 lines
4.8 KiB
Rust

//! Opt-in same-machine denoise model-stage benchmark on one public Bayer raw.
//! LC_DENOISE_MODEL=<onnx> LC_DENOISE_RAW=<CC0 corpus/raw> cargo test --release
//! -p lightcraft-engine --features denoise --test denoise_cpu -- --ignored --nocapture
//! LC_DENOISE_ONLY selects a file (default arw-sony-a7m3-compressed.arw).
//! The whole model stage includes packing, scale/clip protection, blending and normalization;
//! file decode, model loading and GPU setup are outside the timer. It uses the application's tile pool convention.
#![cfg(feature = "denoise")]
use lightcraft_denoise::{
TileRunner,
bayer::Layout,
run::{Control, Params, denoise_bayer},
runtime::{CpuRunner, check_tile},
};
use lightcraft_raster::Rgb32f;
use std::{path::Path, time::Instant};
fn agree(a: &[f32], b: &[f32]) -> f32 {
assert_eq!(a.len(), b.len());
assert!(a.iter().chain(b).all(|v| v.is_finite()));
let scale = b.iter().fold(1e-6f32, |m, v| m.max(v.abs()));
let error = a.iter().zip(b).fold(0f32, |m, (a, b)| m.max((a - b).abs())) / scale;
assert!(error <= 1e-3, "maximum relative error {error:e}");
error
}
fn tile_time(name: &str, r: &dyn TileRunner, x: &[f32]) {
r.run(x).unwrap();
let mut times: Vec<_> = (0..5)
.map(|_| {
let t = Instant::now();
r.run(x).unwrap();
t.elapsed().as_secs_f64() * 1000.0
})
.collect();
times.sort_by(f64::total_cmp);
println!("{name}, one 512-cell tile, one thread: {:.1} ms median ({:.1}..{:.1})", times[2], times[0], times[4]);
}
fn photo_time(name: &str, r: &dyn TileRunner, raw: &lightcraft_raw::Normalized, layout: Layout, p: &Params) -> Rgb32f {
let pool = rayon::ThreadPoolBuilder::new().num_threads(p.parallel).build().unwrap();
let run = || pool.install(|| denoise_bayer(&raw.data, raw.width, raw.height, layout, r, p, &Control::default())).unwrap();
drop(run()); // warm the workers and their workspace cache
let mut times = Vec::new();
let mut answer = Rgb32f { width: 0, height: 0, data: Vec::new() };
for i in 0..3 {
let t = Instant::now();
answer = run();
let secs = t.elapsed().as_secs_f64();
times.push(secs);
println!("{name}, whole model stage, {} workers, run {i}: {secs:.3} s", p.parallel);
}
times.sort_by(f64::total_cmp);
println!("{name}, whole model stage median {:.3} s", times[1]);
answer
}
#[test]
#[ignore = "needs LC_DENOISE_MODEL and LC_DENOISE_RAW; release timing and real-model accuracy"]
fn denoise_stage_compares_cpu_and_gpu() {
let model = std::env::var_os("LC_DENOISE_MODEL").expect("set LC_DENOISE_MODEL");
let folder = std::env::var_os("LC_DENOISE_RAW").expect("set LC_DENOISE_RAW to public CC0 corpus/raw");
let name = std::env::var("LC_DENOISE_ONLY").unwrap_or_else(|_| "arw-sony-a7m3-compressed.arw".into());
let bytes = std::fs::read(Path::new(&folder).join(&name)).unwrap();
let raw = lightcraft_raw::decode(&bytes).unwrap().normalized().unwrap();
let cfa = raw.cfa.as_ref().unwrap();
assert!(cfa.is_bayer());
let layout = Layout::from_cell([cfa.color_at(0, 0), cfa.color_at(1, 0), cfa.color_at(0, 1), cfa.color_at(1, 1)]).unwrap();
let m = lightcraft_denoise::known::find("rawnind-bayer").unwrap().manifest;
let cpu = CpuRunner::load(Path::new(&model), &m).unwrap();
let threads = std::env::var("LC_DENOISE_PARALLEL")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or_else(|| std::thread::available_parallelism().map_or(1, |n| n.get()).clamp(1, 16));
let p = Params { tile: 512, overlap: 64, gain: m.gain, clip: Some(1.0), parallel: threads };
println!(
"{name}: {}x{}, {:.2} MP, {} tiles, {} CPU workers, {:.1} MB/workspace, {:.1} GMAC/tile",
raw.width,
raw.height,
raw.width as f64 * raw.height as f64 / 1e6,
lightcraft_denoise::run::tile_count(raw.width, raw.height, layout, 512, 64),
threads,
cpu.bytes_per_tile() as f64 / 1e6,
cpu.net().macs(512) as f64 / 1e9
);
let x = check_tile(512);
let want = cpu.run(&x).unwrap();
tile_time("pure Rust", &cpu, &x);
let done = photo_time("pure Rust", &cpu, &raw, layout, &p);
match lightcraft_gpu::nn::runner(cpu.net(), 512) {
Ok(gpu) => {
println!("GPU: {}", gpu.adapter());
println!("CPU/check tile vs GPU: {:e}", agree(&want, &gpu.run(&x).unwrap()));
tile_time("GPU", &gpu, &x);
let gp = Params { parallel: 4, ..p };
let answer = photo_time("GPU", &gpu, &raw, layout, &gp);
let a: Vec<_> = done.data.iter().flatten().copied().collect();
let b: Vec<_> = answer.data.iter().flatten().copied().collect();
println!("CPU/whole photo vs GPU: {:e}", agree(&a, &b));
}
Err(e) => println!("GPU unavailable: {e}"),
}
}