diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index 617aa071c..1c2b7bf87 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -10,6 +10,7 @@ //! lightcraft-cli commands [--json] //! lightcraft-cli controls [--json] //! ``` +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod alloc_release; @@ -283,14 +284,18 @@ fn synth_merge(args: &[String]) -> Result<(), String> { let mut written = Vec::new(); match kind { "hdr" => { - for (k, b) in lightcraft_merge::synth::bracket_dngs(1800, 1200, &[-2.0, 0.0, 2.0]).into_iter().enumerate() { + for (k, b) in lightcraft_merge::synth::bracket_dngs(1800, 1200, &[-2.0, 0.0, 2.0]).map_err(|e| e.to_string())?.into_iter().enumerate() { let p = Path::new(&dir).join(format!("bracket-{k}.dng")); std::fs::write(&p, b).map_err(|e| e.to_string())?; written.push(p); } } "panorama" | "pano" => { - for (k, v) in lightcraft_merge::synth::pano_views(1200, 900, 1000.0, &[-50.0, -25.0, 0.0, 25.0, 50.0]).into_iter().enumerate() { + for (k, v) in lightcraft_merge::synth::pano_views(1200, 900, 1000.0, &[-50.0, -25.0, 0.0, 25.0, 50.0]) + .map_err(|e| e.to_string())? + .into_iter() + .enumerate() + { let p = Path::new(&dir).join(format!("view-{k}.png")); let img = v.to_srgb8(); let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()) diff --git a/crates/engine/src/tests_merge.rs b/crates/engine/src/tests_merge.rs index 44a904a6c..a0784dd15 100644 --- a/crates/engine/src/tests_merge.rs +++ b/crates/engine/src/tests_merge.rs @@ -21,7 +21,7 @@ fn import(s: &mut Session, paths: Vec) -> Vec { fn hdr_merge_command_creates_and_imports_a_dng() { let dir = temp_dir("hdr"); let mut paths = Vec::new(); - for (i, b) in lightcraft_merge::synth::bracket_dngs(480, 320, &[-2.0, 0.0, 2.0]).into_iter().enumerate() { + for (i, b) in lightcraft_merge::synth::bracket_dngs(480, 320, &[-2.0, 0.0, 2.0]).unwrap().into_iter().enumerate() { let p = dir.join(format!("IMG_{i}.dng")); std::fs::write(&p, b).unwrap(); paths.push(p.to_string_lossy().to_string()); @@ -71,7 +71,7 @@ fn hdr_merge_command_creates_and_imports_a_dng() { fn panorama_command_with_auto_crop() { let dir = temp_dir("pano"); let mut paths = Vec::new(); - for (i, v) in lightcraft_merge::synth::pano_views(480, 360, 420.0, &[-25.0, 0.0, 25.0]).into_iter().enumerate() { + for (i, v) in lightcraft_merge::synth::pano_views(480, 360, 420.0, &[-25.0, 0.0, 25.0]).unwrap().into_iter().enumerate() { let img = v.to_srgb8(); let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); let p = dir.join(format!("P_{i}.png")); diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index ad40d6801..1424d6a76 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -86,6 +86,9 @@ pub struct Buf { impl Buf { pub fn raw(&self) -> &wgpu::Buffer { + // `buf` is `Some` from construction (`Gpu::buffer`/`upload`) until `Drop` takes it back + // for the pool, so a live `Buf` always has it. + #[allow(clippy::expect_used)] &self.buf.as_ref().expect("live buffer").0 } } @@ -442,7 +445,12 @@ impl Gpu { /// Record kernel `name` with parameters `p` over buffers `bufs` (None = unused binding). pub fn run(&self, enc: &mut wgpu::CommandEncoder, name: &str, p: &[u32], bufs: &[Option<&Buf>], groups: [u32; 3]) { - let k = self.kernels.get(name).unwrap_or_else(|| panic!("unknown kernel {name}")); + let Some(k) = self.kernels.get(name) else { + // a kernel name typo: fail this render over to the CPU instead of crashing + log::error!("gpu: unknown kernel {name}"); + crate::mark_broken(); + return; + }; assert_eq!(bufs.len(), k.nbuf, "{name}: binding count"); let params = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: None, @@ -479,11 +487,21 @@ impl Gpu { let _ = tx.send(r); }); let _ = self.device.poll(wgpu::PollType::wait_indefinitely()); + // A failed readback (device lost, out of memory) marks the GPU broken: the caller + // (`crate::render`) then discards this render's zeroed result and renders on the CPU. + let failed = |why: String| { + log::error!("gpu readback failed: {why}"); + crate::mark_broken(); + vec![::zeroed(); len] + }; match rx.recv() { Ok(Ok(())) => {} - other => panic!("gpu readback failed: {other:?}"), + other => return failed(format!("{other:?}")), } - let data = slice.get_mapped_range().expect("mapped"); + let data = match slice.get_mapped_range() { + Ok(d) => d, + Err(e) => return failed(format!("{e:?}")), + }; let out: Vec = bytemuck::cast_slice(&data).to_vec(); drop(data); staging.unmap(); diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 3342a0f80..fb08a9137 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -11,6 +11,7 @@ //! The browser build has no GPU path yet (WebGPU device creation is asynchronous): everything here //! compiles to the CPU fallback on wasm32. #![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; diff --git a/crates/gpu/src/params.rs b/crates/gpu/src/params.rs index e35c6d8df..d592ca9b2 100644 --- a/crates/gpu/src/params.rs +++ b/crates/gpu/src/params.rs @@ -86,15 +86,17 @@ pub fn finish_fields() -> Vec<(&'static str, usize)> { .collect() } -fn index(name: &str) -> usize { +/// Offset of field `name`; `None` (and a debug assertion) for a name not in [`FIELDS`]. +fn index(name: &str) -> Option { let mut i = 0; for (n, len) in FIELDS { if *n == name { - return i; + return Some(i); } i += len; } - panic!("unknown finish field {name}") + debug_assert!(false, "unknown finish field {name}"); + None } /// A parameter block being filled by name. @@ -105,20 +107,20 @@ impl Block { Block(vec![0; FIELDS.iter().map(|f| f.1).sum()]) } fn f(&mut self, name: &str, v: f32) { - let i = index(name); - self.0[i] = v.to_bits(); + self.u(name, v.to_bits()); } fn u(&mut self, name: &str, v: u32) { - let i = index(name); - self.0[i] = v; + if let Some(slot) = index(name).and_then(|i| self.0.get_mut(i)) { + *slot = v; + } } fn b(&mut self, name: &str, v: bool) { self.u(name, v as u32); } fn fs(&mut self, name: &str, v: &[f32]) { - let i = index(name); - for (k, x) in v.iter().enumerate() { - self.0[i + k] = x.to_bits(); + let Some(i) = index(name) else { return }; + for (slot, x) in self.0.iter_mut().skip(i).zip(v) { + *slot = x.to_bits(); } } } diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index ff983a0b7..1a80af13e 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -402,17 +402,19 @@ fn sample(cx: &mut Cx<'_>, src: &Arc, src_buf: Arc, plan: &Plan<'_> Some(d) => (Arc::new(resize(cx, &oriented, (sp.ow, sp.oh), d, 3, Filter::Mitchell)), d), None => (oriented, (sp.ow, sp.oh)), }; - match &sp.mode { - SampleMode::Copy => base, - SampleMode::Affine(xf) => { - let out = cx.gpu.buffer(w * h * 3); - let mut p = vec![bw as u32, bh as u32, w as u32, h as u32]; - p.extend(affine_bits(xf)); - cx.run("sample_affine", &p, &[Some(&base), Some(&out)], groups2(w, h, [16, 16])); - Arc::new(out) - } - SampleMode::Warp(o2t) => { - let wp = fr.warp.as_ref().expect("warp mode has a warp"); + let mut affine = |xf: &lightcraft_geom::Affine| { + let out = cx.gpu.buffer(w * h * 3); + let mut p = vec![bw as u32, bh as u32, w as u32, h as u32]; + p.extend(affine_bits(xf)); + cx.run("sample_affine", &p, &[Some(&base), Some(&out)], groups2(w, h, [16, 16])); + Arc::new(out) + }; + match (&sp.mode, fr.warp.as_ref()) { + (SampleMode::Copy, _) => base, + (SampleMode::Affine(xf), _) => affine(xf), + // `sample_plan` plans a warp only when there is one; without it the same mapping is affine + (SampleMode::Warp(o2t), None) => affine(&(lightcraft_geom::Affine::scale(sp.sx, sp.sy) * *o2t)), + (SampleMode::Warp(o2t), Some(wp)) => { let out = cx.gpu.buffer(w * h * 3); let p = warp_params(wp, (bw, bh), (w, h), o2t, sp.sx, sp.sy); cx.run("sample_warp", &p, &[Some(&base), Some(&out)], groups2(w, h, [16, 16])); diff --git a/crates/merge/src/align.rs b/crates/merge/src/align.rs index 4298f8100..c977c8b53 100644 --- a/crates/merge/src/align.rs +++ b/crates/merge/src/align.rs @@ -97,7 +97,8 @@ pub fn align_pair(ref_feat: &Features, feat: &Features, scale: f64, full_w: usiz } } let Some(most) = fits.iter().map(|(_, f)| f.inliers.len()).max() else { return Alignment::identity() }; - let (name, f) = fits.into_iter().find(|(_, f)| f.inliers.len() as f64 >= most as f64 * 0.97).expect("the best fit qualifies"); + // the fit with `most` inliers always qualifies + let Some((name, f)) = fits.into_iter().find(|(_, f)| f.inliers.len() as f64 >= most as f64 * 0.97) else { return Alignment::identity() }; Alignment { h: scale_homography(&f.h, scale), model: name, inliers: f.inliers.len(), rms: f.rms / scale } } diff --git a/crates/merge/src/features.rs b/crates/merge/src/features.rs index 353bed8f7..9bf770a69 100644 --- a/crates/merge/src/features.rs +++ b/crates/merge/src/features.rs @@ -54,7 +54,7 @@ impl Features { pub fn detect(img: &Plane, max: usize) -> Features { let mut levels = vec![img.clone()]; while levels.len() < 4 { - let l = levels.last().expect("non-empty"); + let Some(l) = levels.last() else { break }; if l.width.min(l.height) < 96 { break; } diff --git a/crates/merge/src/lib.rs b/crates/merge/src/lib.rs index 65bd86c45..edfd19348 100644 --- a/crates/merge/src/lib.rs +++ b/crates/merge/src/lib.rs @@ -11,6 +11,7 @@ //! Every algorithm here is our own implementation from published papers (cited in each module); //! no code from GPL projects was consulted. #![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod align; pub mod features; diff --git a/crates/merge/src/pano/blend.rs b/crates/merge/src/pano/blend.rs index e3147da17..dacd5b68b 100644 --- a/crates/merge/src/pano/blend.rs +++ b/crates/merge/src/pano/blend.rs @@ -106,14 +106,14 @@ pub fn push_pull(vals: &mut Buf, wgt: &[f32]) { } pm.data[i * (c + 1) + c] = a; } - let mut levels = vec![pm]; - while levels.last().is_some_and(|l| l.w > 1 || l.h > 1) { - let d = down(levels.last().expect("level")); - levels.push(d); + // the finer levels, and the coarsest (1 × 1) one + let (mut levels, mut l) = (Vec::new(), pm); + while l.w > 1 || l.h > 1 { + let d = down(&l); + levels.push(std::mem::replace(&mut l, d)); } // normalise the coarsest, then fill each finer level's holes from the coarser one let mut filled = { - let l = levels.last().expect("level"); let mut f = Buf::new(l.w, l.h, c); for i in 0..l.w * l.h { let a = l.data[i * (c + 1) + c]; @@ -123,8 +123,7 @@ pub fn push_pull(vals: &mut Buf, wgt: &[f32]) { } f }; - for li in (0..levels.len() - 1).rev() { - let l = &levels[li]; + for l in levels.iter().rev() { let coarse = up(&filled, l.w, l.h); let mut f = Buf::new(l.w, l.h, c); for i in 0..l.w * l.h { @@ -235,7 +234,8 @@ impl Blender { } res = Some(b); } - res.expect("level 0") + // `0..=levels` always yields level 0 + res.unwrap_or_else(|| Buf::new(self.w, self.h, 3)) } } diff --git a/crates/merge/src/pano/camera.rs b/crates/merge/src/pano/camera.rs index 1257b9529..7625170c3 100644 --- a/crates/merge/src/pano/camera.rs +++ b/crates/merge/src/pano/camera.rs @@ -191,7 +191,7 @@ pub fn initial_cameras(ids: &[usize], sizes: &[(usize, usize)], pairs: &[PairMat let s = d.signum() * d.abs().cbrt(); let rel = rel.map(|r| r.map(|v| v / s)); let rel = linalg::orthonormalize(&rel); - let rk = cams[known].expect("known").r; + let Some(rk) = cams[known].map(|c| c.r) else { continue }; cams[new] = Some(Camera { f: f0, r: linalg::mul3(&rel, &rk), cx: c(new).0, cy: c(new).1 }); grew = true; } @@ -277,7 +277,9 @@ pub fn bundle_adjust(cams: &[Option], pairs: &[PairMatch], anchor: usize let cost = |cs: &[Option]| -> (f64, f64) { let (mut c, mut sq) = (0.0, 0.0); for o in &obs { - let r = residual(cs[o.i].as_ref().expect("cam"), cs[o.j].as_ref().expect("cam"), o.a, o.b); + // every observation joins two posed cameras (see `obs` above) + let (Some(ci), Some(cj)) = (cs[o.i].as_ref(), cs[o.j].as_ref()) else { continue }; + let r = residual(ci, cj, o.a, o.b); for v in r { let a = v.abs(); sq += v * v; @@ -298,11 +300,12 @@ pub fn bundle_adjust(cams: &[Option], pairs: &[PairMatch], anchor: usize .fold( || (vec![0.0f64; np * np], vec![0.0f64; np]), |(mut jtj, mut jtr), o| { - let (ci, cj) = (cur[o.i].as_ref().expect("cam"), cur[o.j].as_ref().expect("cam")); + let (Some(ci), Some(cj)) = (cur[o.i].as_ref(), cur[o.j].as_ref()) else { return (jtj, jtr) }; let r = residual(ci, cj, o.a, o.b); let mut cols: Vec<(usize, [f64; 4])> = Vec::with_capacity(8); for (cam, which) in [(o.i, 0), (o.j, 1)] { - let base = 4 * idx[cam].expect("idx"); + let Some(i) = idx[cam] else { continue }; + let base = 4 * i; for k in 0..4 { if k < 3 && cam == anchor { continue; diff --git a/crates/merge/src/pano/mod.rs b/crates/merge/src/pano/mod.rs index d81abda9f..309505614 100644 --- a/crates/merge/src/pano/mod.rs +++ b/crates/merge/src/pano/mod.rs @@ -223,7 +223,7 @@ pub fn stitch(frames: Vec, opts: &PanoOptions, progress: &Progress) -> Re Projection::Auto => choose_projection(&frames, &cams), p => p, }; - let fmed = median(used.iter().map(|&i| cams[i].expect("used").f).collect()); + let fmed = median(used.iter().filter_map(|&i| cams[i].map(|c| c.f)).collect()); let Some((bx0, by0, bx1, by1)) = bounds(&frames, &cams, projection, fmed) else { return Err(MergeError::NoOverlap(format!("the field of view ({:.0}°) is too wide for a perspective projection", fov.0))); }; diff --git a/crates/merge/src/synth.rs b/crates/merge/src/synth.rs index 1d8fd8aa2..d15646ecb 100644 --- a/crates/merge/src/synth.rs +++ b/crates/merge/src/synth.rs @@ -8,9 +8,13 @@ use lightcraft_raster::Rgb32f; use crate::frame::FrameColor; use crate::linalg::{self, M3}; use crate::output::{DngSamples, write_linear_dng}; +use crate::{MergeError, Result}; -fn scene(kind: lightcraft_scenes::Kind) -> lightcraft_scenes::Scene { - lightcraft_scenes::demo_library().into_iter().find(|s| s.kind == kind).expect("scene kind in the demo library") +fn scene(kind: lightcraft_scenes::Kind) -> Result { + lightcraft_scenes::demo_library() + .into_iter() + .find(|s| s.kind == kind) + .ok_or_else(|| MergeError::Mismatch(format!("no {kind:?} scene in the demo library"))) } fn srgb_color() -> FrameColor { @@ -19,12 +23,12 @@ fn srgb_color() -> FrameColor { /// Exposure brackets (`evs`, e.g. `[-2, 0, 2]`) of a sunset scene as 16-bit linear DNGs, each /// shifted by a few pixels (handheld) with EXIF exposure times; `w × h` pixels. -pub fn bracket_dngs(w: usize, h: usize, evs: &[f64]) -> Vec> { +pub fn bracket_dngs(w: usize, h: usize, evs: &[f64]) -> Result>> { let m = 12usize; - let truth = scene(lightcraft_scenes::Kind::OceanSunset).render(w + 2 * m, h + 2 * m); + let truth = scene(lightcraft_scenes::Kind::OceanSunset)?.render(w + 2 * m, h + 2 * m); let mut l: Vec = truth.data.iter().map(|p| p[1]).collect(); l.sort_by(|a, b| a.total_cmp(b)); - let s = 0.18 / l[l.len() / 2].max(1e-6); + let s = 0.18 / l.get(l.len() / 2).copied().unwrap_or(0.0).max(1e-6); let to_srgb = lightcraft_color::REC2020.to_space(&lightcraft_color::SRGB).to_f32(); evs.iter() .enumerate() @@ -44,20 +48,21 @@ pub fn bracket_dngs(w: usize, h: usize, evs: &[f64]) -> Vec> { iso: Some(100), ..Default::default() }; - write_linear_dng(&img, &srgb_color(), Orientation::Normal, &meta, 0.0, DngSamples::U16).expect("encode") + write_linear_dng(&img, &srgb_color(), Orientation::Normal, &meta, 0.0, DngSamples::U16) }) .collect() } /// `yaws.len()` overlapping views (degrees of yaw, small pitch/roll wobble) of a canyon scene used /// as a 200° × 70° spherical environment, `w × h` pixels with focal length `f`, as linear sRGB. -pub fn pano_views(w: usize, h: usize, f: f64, yaws: &[f64]) -> Vec { - let env = scene(lightcraft_scenes::Kind::Canyon).render(2400, 840); +pub fn pano_views(w: usize, h: usize, f: f64, yaws: &[f64]) -> Result> { + let env = scene(lightcraft_scenes::Kind::Canyon)?.render(2400, 840); let mut l: Vec = env.data.iter().map(|p| p[1]).collect(); l.sort_by(|a, b| a.total_cmp(b)); - let s = 0.25 / l[l.len() / 2].max(1e-6); + let s = 0.25 / l.get(l.len() / 2).copied().unwrap_or(0.0).max(1e-6); let (theta, phi) = (200.0f64, 70.0f64); - yaws.iter() + Ok(yaws + .iter() .enumerate() .map(|(i, yaw)| { let wob = (i as f64 * 1.3).sin(); @@ -77,5 +82,5 @@ pub fn pano_views(w: usize, h: usize, f: f64, yaws: &[f64]) -> Vec { env.sample_bilinear(px as f32, py as f32).map(|v| (v * s).min(1.0)) }) }) - .collect() + .collect()) }