From 26b00014a89f36c9c42613c109ab11965e2dfeab Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 02:58:52 -0400 Subject: [PATCH] =?UTF-8?q?LR-LIB-CULL:=20assisted=20culling=20=E2=80=94?= =?UTF-8?q?=20focus=20scores,=20bursts,=20best-of-burst?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lightcraft-pipeline cull.rs: focus (Laplacian energy of the sharpest 16-px blocks at 512 px, 0–100, calibrated on the CC0 corpus), clipping share and an 8×8 look-alike signature. photo.analyze scores the selection (else the view), groups look-alike shots taken within 10 s into bursts and marks each burst's sharpest; optionally rejects photos below a focus score and picks each burst's best (one undo step). Scores live on the photo (Analysis, Op::SetAnalysis): rule fields Focus and Best of Similar Shots, Info panel line. Photo ▸ Assisted Culling…. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/lib.rs | 9 ++ crates/catalog/src/model.rs | 17 ++++ crates/catalog/src/rules.rs | 4 + crates/engine/src/cmd/cull.rs | 96 +++++++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/tests_import.rs | 73 ++++++++++++++ crates/pipeline/src/cull.rs | 138 +++++++++++++++++++++++++++ crates/pipeline/src/lib.rs | 1 + crates/ui-egui/src/menus.rs | 5 + crates/ui-egui/src/panels/dialogs.rs | 20 ++++ crates/ui-egui/src/panels/right.rs | 11 +++ crates/ui-egui/src/state.rs | 6 ++ docs/parity.md | 16 ++-- 13 files changed, 390 insertions(+), 8 deletions(-) create mode 100644 crates/engine/src/cmd/cull.rs create mode 100644 crates/pipeline/src/cull.rs diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index 3501de29c..837b3ccf4 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -149,6 +149,11 @@ pub enum Op { id: PhotoId, captured: Option, }, + /// Assisted culling scores. + SetAnalysis { + id: PhotoId, + analysis: Option, + }, /// Rename a photo: its file name and the source it points to. Applying the op never touches /// the disk — the engine moves the file before it commits (and on undo/redo). SetFile { @@ -490,6 +495,10 @@ impl Catalog { s.collapsed = collapsed; old } + Op::SetAnalysis { id, analysis } => { + let p = self.photo_mut(id)?; + Op::SetAnalysis { id, analysis: std::mem::replace(&mut p.analysis, analysis) } + } Op::SetCaptured { id, captured } => { if let Some(c) = &captured && stacks::iso_seconds(c).is_none() diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index e1c87bf1a..f485e2579 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -197,6 +197,22 @@ pub struct Photo { /// unedited, and Reset returns to them. #[serde(default, skip_serializing_if = "Option::is_none")] pub import_look: Option>, + /// Assisted culling scores (`None` until analysed). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub analysis: Option, +} + +/// What assisted culling measured on a photo. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Analysis { + /// Focus: 0 (blurred) .. 100 (crisp), from the detail in the photo's sharpest area. + pub sharpness: f32, + /// Exposure: share of clipped pixels, 0..1 (shadows + highlights). + pub clipped: f32, + /// Similar-shot group (burst), when the photo has neighbours that look alike. + pub group: Option, + /// The best photo of its group. + pub best: bool, } impl Photo { @@ -229,6 +245,7 @@ impl Photo { copy_of: None, copy_name: None, import_look: None, + analysis: None, } } /// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index fe3099568..b59257812 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -80,6 +80,8 @@ pub const FIELDS: &[(&str, &str, Kind)] = &[ ("hasGps", "Has GPS", Kind::Bool), ("virtualCopy", "Virtual Copy", Kind::Bool), ("album", "Album", Kind::Number), + ("sharpness", "Focus (assisted culling)", Kind::Number), + ("bestOfGroup", "Best of Similar Shots", Kind::Bool), ]; /// The operators for a field kind: (id, label). @@ -316,6 +318,8 @@ impl Rule { "aperture" => num_op(op, m.aperture.map(|v| v as f64), value), "focalLength" => num_op(op, m.focal_mm.map(|v| v as f64), value), "megapixels" => num_op(op, Some(p.width as f64 * p.height as f64 / 1e6), value), + "sharpness" => num_op(op, p.analysis.map(|a| a.sharpness as f64), value), + "bestOfGroup" => p.analysis.is_some_and(|a| a.best || a.group.is_none()) == value.as_bool().unwrap_or(true), "album" => { let id = number(value).map(|v| crate::AlbumId(v as u64)); id.is_some_and(|a| cat.album(a).is_some_and(|al| !al.is_smart()) && cat.album_contains(a, p)) == (op == "is") diff --git a/crates/engine/src/cmd/cull.rs b/crates/engine/src/cmd/cull.rs new file mode 100644 index 000000000..928e4cd39 --- /dev/null +++ b/crates/engine/src/cmd/cull.rs @@ -0,0 +1,96 @@ +//! Assisted culling: score photos for focus and clipping, group similar shots taken close +//! together (bursts) and mark the sharpest of each group; optionally reject blurry ones and pick +//! each group's best. Scores are kept on the photos (rules / smart albums: `sharpness`, +//! `bestOfGroup`). + +use lightcraft_catalog::{Analysis, Flag, Op, PhotoId}; +use serde_json::{Value, json}; + +use super::{CommandSpec, always, cmd}; +use crate::{Result, Session}; + +/// Seconds between shots of the same burst at most. +const BURST_GAP: i64 = 10; +/// Signature similarity for "the same scene". +const SAME: f32 = 0.93; + +fn secs(iso: &str) -> Option { + let d = lightcraft_meta::DateTime::parse_iso(iso)?; + Some(((d.year as i64 * 372 + d.month as i64 * 31 + d.day as i64) * 24 + d.hour as i64) * 3600 + d.minute as i64 * 60 + d.second as i64) +} + +fn analyze(s: &mut Session, p: &Value) -> Result { + let ids: Vec = if p.get("ids").is_some() || s.selection.ids.len() > 1 { s.targets(p) } else { s.visible_cloned() }; + let reject_below = p.get("rejectBelow").and_then(Value::as_f64).map(|v| v as f32); + let pick_best = p.get("pickBest").and_then(Value::as_bool).unwrap_or(false); + // measure (thumbnail-level sources: fast, and enough for focus at 512 px) + let mut rows: Vec<(PhotoId, Option, f32, f32, [f32; 64])> = Vec::new(); + let mut failed = Vec::new(); + for id in ids { + match s.source_now(id, crate::media::SourceLevel::Thumb) { + Ok(src) => { + let t = s.catalog.photo(id).and_then(|p| p.captured.as_deref().and_then(secs)); + use lightcraft_pipeline::cull; + rows.push((id, t, cull::sharpness(&src), cull::clipped(&src), cull::signature(&src))); + } + Err(e) => failed.push(json!([id.0, e])), + } + } + // bursts: consecutive (by capture time) look-alike shots + rows.sort_by_key(|r| (r.1.unwrap_or(i64::MAX), r.0)); + let mut group_of: Vec> = vec![None; rows.len()]; + let mut next = 1u32; + for i in 1..rows.len() { + let (a, b) = (&rows[i - 1], &rows[i]); + let close = matches!((a.1, b.1), (Some(x), Some(y)) if (y - x).abs() <= BURST_GAP); + if close && lightcraft_pipeline::cull::similarity(&a.4, &b.4) >= SAME { + let g = *group_of[i - 1].get_or_insert_with(|| { + next += 1; + next - 1 + }); + group_of[i] = Some(g); + } + } + let mut best: std::collections::HashMap = Default::default(); + for (i, g) in group_of.iter().enumerate() { + if let Some(g) = g { + let e = best.entry(*g).or_insert((i, -1.0)); + if rows[i].2 > e.1 { + *e = (i, rows[i].2); + } + } + } + let mut ops = Vec::new(); + let (mut rejected, mut picked) = (0, 0); + let mut out = Vec::new(); + for (i, (id, _, sharp, clip, _)) in rows.iter().enumerate() { + let g = group_of[i]; + let is_best = g.is_some_and(|g| best.get(&g).is_some_and(|b| b.0 == i)); + ops.push(Op::SetAnalysis { id: *id, analysis: Some(Analysis { sharpness: *sharp, clipped: *clip, group: g, best: is_best }) }); + let flag = s.catalog.photo(*id).map(|p| p.flag).unwrap_or_default(); + if reject_below.is_some_and(|t| *sharp < t) && flag != Flag::Reject { + ops.push(Op::SetFlag { id: *id, flag: Flag::Reject }); + rejected += 1; + } else if pick_best && is_best && flag == Flag::None { + ops.push(Op::SetFlag { id: *id, flag: Flag::Pick }); + picked += 1; + } + out.push(json!({"id": id.0, "sharpness": (sharp * 10.0).round() / 10.0, "clipped": clip, "group": g, "best": is_best})); + } + if !ops.is_empty() { + s.commit("Assisted Culling", Op::Batch { ops })?; + } + Ok(json!({"photos": out, "groups": best.len(), "rejected": rejected, "picked": picked, "failed": failed})) +} + +pub fn specs() -> Vec { + vec![cmd!( + "photo.analyze", + "Assisted Culling", + [], + None, + "{ids?, rejectBelow?: sharpness 0..100, pickBest?: bool} — score the selected photos (else all in view) for focus (0..100) and clipping, group look-alike shots taken within 10 s and mark the sharpest of each; optionally reject blurry photos and pick each group's best; one undo step → {photos: [{id, sharpness, clipped, group, best}], groups, rejected, picked}", + always, + analyze + )] +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 7ff286e83..9c462423f 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -8,6 +8,7 @@ mod before; mod browse; mod color; pub(crate) mod convert; +mod cull; mod develop; mod edit; mod export; @@ -123,6 +124,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(manage::specs()); v.extend(previews::specs()); v.extend(lut_profiles::specs()); + v.extend(cull::specs()); v.extend(convert::specs()); v.extend(convert::edit_specs()); v.extend(merge::specs()); diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index 458f6d502..0d8ee7e7c 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -409,3 +409,76 @@ fn folders_rename_and_move_with_their_photos() { assert_eq!(path(&s), root.join("Archive/Italy 2026/one.png").to_string_lossy()); let _ = std::fs::remove_dir_all(&root); } + +/// Assisted culling: a burst (same scene, seconds apart) of a sharp and two soft shots, plus an +/// unrelated photo: the burst is grouped, the sharp one is its best, blurry shots can be rejected. +#[test] +fn assisted_culling_groups_bursts_and_scores_focus() { + let dir = temp_dir("cull"); + let scene = lightcraft_scenes::demo_library()[0].render(480, 320); + let soften = |img: &lightcraft_raster::Rgb32f, r: usize| { + let (w, h) = (img.width, img.height); + let mut out = img.clone(); + for y in 0..h { + for x in 0..w { + let (mut acc, mut n) = ([0f32; 3], 0.0); + for yy in y.saturating_sub(r)..(y + r + 1).min(h) { + for xx in x.saturating_sub(r)..(x + r + 1).min(w) { + let c = img.data[yy * w + xx]; + acc = [acc[0] + c[0], acc[1] + c[1], acc[2] + c[2]]; + n += 1.0; + } + } + out.data[y * w + x] = acc.map(|v| v / n); + } + } + out + }; + let save = |img: &lightcraft_raster::Rgb32f, name: &str| { + let data: Vec<[u8; 4]> = img + .data + .iter() + .map(|c| { + let e = |v: f32| (lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0)) * 255.0).round() as u8; + [e(c[0]), e(c[1]), e(c[2]), 255] + }) + .collect(); + let png = lightcraft_codecs::encode_png( + &lightcraft_codecs::EncodeImage::rgba8(&lightcraft_raster::Rgba8 { width: img.width, height: img.height, data }), + &Default::default(), + ) + .unwrap(); + std::fs::write(dir.join(name), png).unwrap(); + }; + save(&soften(&scene, 3), "a_soft.png"); + save(&scene, "b_sharp.png"); + save(&soften(&scene, 6), "c_softer.png"); + save(&lightcraft_scenes::demo_library()[7].render(480, 320), "d_other.png"); + let mut s = Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [dir.to_string_lossy()]})).unwrap(); + let ids: Vec = r["imported"].as_array().unwrap().iter().filter_map(Value::as_u64).collect(); + let by_name = |s: &Session, n: &str| s.catalog.photos().find(|p| p.file_name == n).unwrap().id; + for (i, n) in ["a_soft.png", "b_sharp.png", "c_softer.png"].iter().enumerate() { + let id = by_name(&s, n); + s.commit("t", lightcraft_catalog::Op::SetCaptured { id, captured: Some(format!("2026-05-01T10:00:0{i}")) }).unwrap(); + } + let other = by_name(&s, "d_other.png"); + s.commit("t", lightcraft_catalog::Op::SetCaptured { id: other, captured: Some("2026-05-01T10:00:04".into()) }).unwrap(); + let r = s.execute("photo.analyze", &json!({"ids": ids, "rejectBelow": 0.0, "pickBest": true})).unwrap(); + assert_eq!(r["groups"], 1, "{r}"); + let a = |s: &Session, n: &str| s.catalog.photo(by_name(s, n)).unwrap().analysis.unwrap(); + let (soft, sharp, softer, odd) = (a(&s, "a_soft.png"), a(&s, "b_sharp.png"), a(&s, "c_softer.png"), a(&s, "d_other.png")); + eprintln!("sharpness: sharp {} soft {} softer {} other {}", sharp.sharpness, soft.sharpness, softer.sharpness, odd.sharpness); + assert!(sharp.sharpness > soft.sharpness && soft.sharpness > softer.sharpness); + assert!(sharp.best && !soft.best && sharp.group.is_some() && sharp.group == softer.group); + assert!(odd.group.is_none(), "an unrelated photo isn't in the burst"); + assert_eq!(s.catalog.photo(by_name(&s, "b_sharp.png")).unwrap().flag, lightcraft_catalog::Flag::Pick); + // reject the blurry ones (by score), as a rule too + let cut = (soft.sharpness + sharp.sharpness) / 2.0; + s.execute("photo.analyze", &json!({"ids": ids, "rejectBelow": cut})).unwrap(); + assert_eq!(s.catalog.photo(by_name(&s, "c_softer.png")).unwrap().flag, lightcraft_catalog::Flag::Reject); + s.execute("library.filter", &json!({"ruleSet": {"rules": [{"field": "bestOfGroup", "op": "is", "value": true}]}})).unwrap(); + let vis = s.visible_cloned(); + assert!(vis.contains(&by_name(&s, "b_sharp.png")) && !vis.contains(&by_name(&s, "a_soft.png")) && vis.contains(&other)); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/pipeline/src/cull.rs b/crates/pipeline/src/cull.rs new file mode 100644 index 000000000..f266dbbb7 --- /dev/null +++ b/crates/pipeline/src/cull.rs @@ -0,0 +1,138 @@ +//! Assisted culling measurements on a decoded source (any size; measured on a fixed-size copy so +//! scores compare across photos): focus, clipping, and a tiny signature for finding similar +//! shots (bursts). Classical image statistics — no learned models. + +use lightcraft_raster::Rgb32f; + +/// Long edge the measurements run at. +const EDGE: usize = 512; + +/// Display-ish luminance (sRGB-encoded) of `img` resampled to fit `EDGE` (box filter). +fn luma(img: &Rgb32f) -> (Vec, usize, usize) { + let (w, h) = (img.width.max(1), img.height.max(1)); + let s = (EDGE as f64 / w.max(h) as f64).min(1.0); + let (ow, oh) = (((w as f64 * s).round() as usize).max(8), ((h as f64 * s).round() as usize).max(8)); + let mut out = vec![0f32; ow * oh]; + for (y, row) in out.chunks_mut(ow).enumerate() { + let (y0, y1) = (y * h / oh, ((y + 1) * h / oh).max(y * h / oh + 1).min(h)); + for (x, v) in row.iter_mut().enumerate() { + let (x0, x1) = (x * w / ow, ((x + 1) * w / ow).max(x * w / ow + 1).min(w)); + let mut acc = 0.0f32; + for yy in y0..y1 { + for xx in x0..x1 { + let c = img.data[yy * w + xx]; + acc += 0.2627 * c[0] + 0.678 * c[1] + 0.0593 * c[2]; + } + } + let l = acc / ((y1 - y0) * (x1 - x0)) as f32; + *v = lightcraft_color::transfer::linear_to_srgb(l.clamp(0.0, 1.0)); + } + } + (out, ow, oh) +} + +/// Focus 0..100: detail energy (Laplacian) of the sharpest areas — the 90th percentile of +/// 16 × 16-pixel blocks, so a sharp subject on a soft background still reads as sharp. +pub fn sharpness(img: &Rgb32f) -> f32 { + let (l, w, h) = luma(img); + let b = 16; + let mut blocks = Vec::new(); + for by in (1..h.saturating_sub(1)).step_by(b) { + for bx in (1..w.saturating_sub(1)).step_by(b) { + let mut e = 0.0f32; + let mut n = 0; + for y in by..(by + b).min(h - 1) { + for x in bx..(bx + b).min(w - 1) { + let c = l[y * w + x]; + let lap = 4.0 * c - l[y * w + x - 1] - l[y * w + x + 1] - l[(y - 1) * w + x] - l[(y + 1) * w + x]; + e += lap * lap; + n += 1; + } + } + if n > 0 { + blocks.push(e / n as f32); + } + } + } + if blocks.is_empty() { + return 0.0; + } + let k = (blocks.len() as f32 * 0.9) as usize; + let k = k.min(blocks.len() - 1); + let (_, v, _) = blocks.select_nth_unstable_by(k, f32::total_cmp); + // map energy to 0..100: a crisp, ordinary photo at 512 px reads ≈ 50–80 (calibrated on the + // CC0 corpus), soft ones well below + 100.0 * (1.0 - (-*v / 0.012).exp()) +} + +/// Share of clipped pixels (crushed blacks + blown highlights), 0..1. +pub fn clipped(img: &Rgb32f) -> f32 { + let n = img.data.len().max(1); + let c = img.data.iter().filter(|p| p.iter().any(|v| *v >= 0.985) || p.iter().all(|v| *v <= 0.002)).count(); + c as f32 / n as f32 +} + +/// A tiny look-alike signature: 8 × 8 luminance, normalised to zero mean and unit norm. +pub fn signature(img: &Rgb32f) -> [f32; 64] { + let (l, w, h) = luma(img); + let mut s = [0f32; 64]; + for (i, v) in s.iter_mut().enumerate() { + let (cx, cy) = (i % 8, i / 8); + let (x0, x1) = (cx * w / 8, (cx + 1) * w / 8); + let (y0, y1) = (cy * h / 8, (cy + 1) * h / 8); + let mut acc = 0.0; + for y in y0..y1 { + for x in x0..x1 { + acc += l[y * w + x]; + } + } + *v = acc / ((x1 - x0) * (y1 - y0)).max(1) as f32; + } + let mean = s.iter().sum::() / 64.0; + s.iter_mut().for_each(|v| *v -= mean); + let norm = s.iter().map(|v| v * v).sum::().sqrt().max(1e-6); + s.iter_mut().for_each(|v| *v /= norm); + s +} + +/// Similarity of two signatures: 1 = the same picture, 0 = unrelated. +pub fn similarity(a: &[f32; 64], b: &[f32; 64]) -> f32 { + a.iter().zip(b).map(|(x, y)| x * y).sum::().clamp(-1.0, 1.0) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn blur(img: &Rgb32f, r: usize) -> Rgb32f { + let (w, h) = (img.width, img.height); + let mut out = img.clone(); + for y in 0..h { + for x in 0..w { + let mut acc = [0f32; 3]; + let mut n = 0.0; + for yy in y.saturating_sub(r)..(y + r + 1).min(h) { + for xx in x.saturating_sub(r)..(x + r + 1).min(w) { + let c = img.data[yy * w + xx]; + acc = [acc[0] + c[0], acc[1] + c[1], acc[2] + c[2]]; + n += 1.0; + } + } + out.data[y * w + x] = acc.map(|v| v / n); + } + } + out + } + + #[test] + fn blur_lowers_focus_and_signatures_match() { + let img = lightcraft_scenes::demo_library()[0].render(480, 320); + let soft = blur(&img, 4); + let (a, b) = (sharpness(&img), sharpness(&soft)); + assert!(a > b + 10.0, "sharp {a} vs blurred {b}"); + assert!(similarity(&signature(&img), &signature(&soft)) > 0.95, "the same picture"); + let other = lightcraft_scenes::demo_library()[5].render(480, 320); + assert!(similarity(&signature(&img), &signature(&other)) < 0.9); + assert!(clipped(&img) < 0.5); + } +} diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 0122359f6..8c1289b19 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -24,6 +24,7 @@ pub mod auto; pub mod colorops; +pub mod cull; pub mod finish; pub mod geometry; pub mod local; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 726cb4481..80a866f65 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -115,6 +115,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("app.about", "About LightCraft", None, "Help"), ("app.systemInfo", "System Info…", None, "Help"), ("app.whatsNew", "What's New", None, "Help"), + ("dialog.cull", "Assisted Culling…", None, "Photo"), ("app.help", "LightCraft Help", Some("F1"), "Help"), ("app.discord", "Join the ArtCraft Discord…", None, "Help"), ("app.feedback", "Send Feedback…", None, "Help"), @@ -731,6 +732,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + app.ui.dialog = Some(Dialog::Cull { reject_below: 0.0, pick_best: true }); + Ok(Value::Null) + } "app.whatsNew" => { app.ui.dialog = Some(Dialog::WhatsNew); Ok(json!({"text": crate::panels::dialogs::WHATS_NEW})) diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 20ad7d527..2a7843824 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -36,6 +36,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::AllMetadata { .. } => "All Metadata", Dialog::SystemInfo { .. } => "System Info", Dialog::WhatsNew => "What's New", + Dialog::Cull { .. } => "Assisted Culling", Dialog::SmartRules { id: None, .. } => "New Smart Album", Dialog::SmartRules { .. } => "Edit Smart Album", Dialog::AutoStack { .. } => "Auto-Stack by Capture Time", @@ -80,6 +81,18 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { egui::RichText::new(format!("Creates {} stacks from {} of {scope}", preview["stacks"], preview["photos"])).color(t.text_dim), ); } + Dialog::Cull { reject_below, pick_best } => { + let n = app.session.selection.ids.len(); + let scope = if n > 1 { format!("the {n} selected photos") } else { format!("the {} photos in view", app.session.visible_cloned().len()) }; + ui.label(egui::RichText::new(format!("Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).")).color(t.text_label)); + ui.add_space(6.0); + let r = ui.add(egui::Slider::new(reject_below, 0.0..=80.0).text("Reject below focus").step_by(1.0)); + crate::widgets::register(ui.ctx(), "field:cullReject", r.rect); + ui.label(egui::RichText::new(if *reject_below > 0.0 { "Blurry photos below the score are flagged as rejects." } else { "0: nothing is rejected." }).color(t.text_dim)); + let r = ui.checkbox(pick_best, "Pick the sharpest photo of each burst"); + crate::widgets::register(ui.ctx(), "check:cullPick", r.rect); + ui.label(egui::RichText::new("Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.").color(t.text_dim)); + } Dialog::WhatsNew => { egui::ScrollArea::vertical().max_height(460.0).auto_shrink([false, true]).show(ui, |ui| { for line in WHATS_NEW.lines() { @@ -767,6 +780,13 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result app.run("keyword.merge", json!({"from": from, "into": into})), Dialog::AutoStack { gap } => app.run("stack.auto", json!({"gap": gap})), Dialog::AllMetadata { .. } | Dialog::SystemInfo { .. } | Dialog::WhatsNew => Ok(serde_json::Value::Null), + Dialog::Cull { reject_below, pick_best } => { + let mut p = json!({"pickBest": pick_best}); + if *reject_below > 0.0 { + p["rejectBelow"] = json!(reject_below); + } + app.run("photo.analyze", p) + } Dialog::SmartRules { id, name, rules } => { let name = if name.trim().is_empty() { "Smart Album".to_string() } else { name.trim().to_string() }; match id { diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index f2ad1ac0a..99deb9a8b 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -493,6 +493,17 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } }); } + if let Some(a) = p.analysis { + let mut line = format!("Focus {:.0}", a.sharpness); + if a.clipped > 0.02 { + line.push_str(&format!(" · {:.0}% clipped", a.clipped * 100.0)); + } + if a.group.is_some() { + line.push_str(if a.best { " · best of its burst" } else { " · in a burst" }); + } + ui.add_space(4.0); + ui.label(egui::RichText::new(line).color(t.text_dim)); + } // offline originals / smart previews if let lightcraft_catalog::Source::File { path } = &p.source { let online = std::path::Path::new(path).exists(); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index daaa07432..c4d946e33 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -384,6 +384,12 @@ pub enum Dialog { }, /// Help ▸ What's New. WhatsNew, + /// Photo ▸ Assisted Culling: reject photos below this focus score (0 = none), pick the best + /// of each burst. + Cull { + reject_below: f32, + pick_best: bool, + }, /// Help ▸ System Info: (label, value) rows. SystemInfo { rows: Vec<(String, String)>, diff --git a/docs/parity.md b/docs/parity.md index b7ea41cba..abaa1a571 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -21,10 +21,10 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | Section | ✅ | 🟡 | ⬜ | 🚫 | P0 done | P1 done | |---|---:|---:|---:|---:|---:|---:| -| A. Import (IMP) | 10 | 1 | 2 | 1 | 3/4 (75%) | 4/4 (100%) | -| B. Library management (LIB) | 22 | 0 | 3 | 2 | 9/9 (100%) | 9/9 (100%) | +| A. Import (IMP) | 10 | 2 | 1 | 1 | 3/4 (75%) | 4/4 (100%) | +| B. Library management (LIB) | 22 | 1 | 2 | 2 | 9/9 (100%) | 9/9 (100%) | | C. Views & navigation (VIEW) | 16 | 0 | 1 | 0 | 9/9 (100%) | 4/4 (100%) | -| D. Search & filter (FILT) | 10 | 0 | 3 | 0 | 4/4 (100%) | 4/4 (100%) | +| D. Search & filter (FILT) | 11 | 0 | 2 | 0 | 4/4 (100%) | 4/4 (100%) | | E. Metadata (META) | 5 | 1 | 0 | 0 | 2/2 (100%) | 2/2 (100%) | | F. Edit panel — global adjustments (EDIT) | 42 | 1 | 5 | 1 | 28/28 (100%) | 13/14 (93%) | | G. Profiles (PROF) | 6 | 1 | 3 | 0 | 3/3 (100%) | 2/3 (67%) | @@ -48,9 +48,9 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | Y. Menus | 80 | 1 | 4 | 8 | 46/47 (98%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) | | Lightroom Classic extras | 24 | 8 | 47 | 9 | — | 20/21 (95%) | -| **Total** | 374 | 23 | 103 | 37 | 193/198 (97%) | 136/144 (94%) | +| **Total** | 375 | 25 | 100 | 37 | 193/198 (97%) | 136/144 (94%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **77.1%** of 500 in-scope rows — P0 98.7% of 198 · P1 96.2% of 144 · P2 32.6% of 158. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **77.5%** of 500 in-scope rows — P0 98.7% of 198 · P1 96.2% of 144 · P2 33.9% of 158. ## Top gaps @@ -110,7 +110,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; edits go to XMP sidecars; importing promotes them | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | | LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF, WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2, ARW, NEF, RAF, RW2, PEF, ORF. Missing: CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only), HEIC/AVIF decode | -| LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | ⬜ | | | +| LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | | LR-IMP-DNG-CONVERT | Convert to DNG on import [Classic] | P2 | ✅ | `cmd:library.import` (`dng`), `crates/ui-egui/src/import.rs` | copy imports: Raw files ▸ Copy as DNG (lossless; the card is untouched) | ## B. Library management (LIB) @@ -140,7 +140,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-LIB-CAPTURETIME | Edit capture time | P1 | ✅ | `cmd:photo.setCaptureTime`, `cmd:dialog.captureTime`, `crates/catalog/src/dates.rs` | set (the other selected photos shift by the same amount, or `each`), shift by days/hours/minutes, time-zone shift; one undo step, journaled; Info panel button; no “revert to original capture time” | | LR-LIB-SHOWFINDER | Reveal original in file manager | P0 | ✅ | `cmd:app.showInFinder` | ⌘R (see MENU-FILE-SHOWFINDER) | | LR-LIB-COVER | Album cover | P2 | ✅ | `cmd:album.setCover` | | -| LR-LIB-CULL | Assisted culling | P2 | ⬜ | | | +| LR-LIB-CULL | Assisted culling | P2 | 🟡 | `cmd:photo.analyze`, `cmd:dialog.cull`, `crates/pipeline/src/cull.rs` | Photo ▸ Assisted Culling…: focus score (0–100), clipping, burst grouping (look-alike shots within 10 s) with the sharpest marked; reject below a focus score, pick each burst's best; classical measures — no eyes-closed / expression detection | | LR-LIB-ACTIVITY | Comments & likes | OOS | 🚫 | | | | LR-LIB-QUICKCOLL | Quick collection [Classic] | P2 | ✅ | `cmd:album.toggleTarget`, `cmd:album.clearQuick` | B in the grids | | LR-LIB-VIRTUALCOPY | Virtual copies [Classic] | P1 | ✅ | `cmd:photo.virtualCopy`, `crates/engine/src/cmd/organize.rs` | “Copy N” badge, stacked with the original, same albums, no XMP writes; no “Set Copy as Master” | @@ -181,7 +181,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-FILT-CAMERA | Camera / lens filter | P1 | ✅ | `cmd:library.filter` (`camera`, `lens`), `crates/ui-egui/src/panels/filterbar.rs` | camera and lens pickers | | LR-FILT-LOCATION | Location filter | P2 | ✅ | `cmd:library.filter` (`text`, `ruleSet` field `location`) | free text, or a rule on location / city / state / country (smart albums, `library.filter`) | | LR-FILT-PEOPLE | People filter | P2 | ⬜ | | | -| LR-FILT-CULL | Culling-score filters | P2 | ⬜ | | | +| LR-FILT-CULL | Culling-score filters | P2 | ✅ | `cmd:library.filter` (`ruleSet` fields `sharpness`, `bestOfGroup`) | Focus and Best of Similar Shots in the rule editor / smart albums | | LR-FILT-SORT | Sort | P0 | ✅ | `cmd:library.sort`, `crates/ui-egui/src/panels/bottombar.rs` | no colour-label or custom (manual) order | | LR-FILT-SAVED | Filter presets [Classic] | P2 | ✅ | `cmd:filter.savePreset`, `cmd:filter.applyPreset`, `cmd:filter.presets`, `cmd:filter.deletePreset`, `crates/ui-egui/src/panels/filterbar.rs` | filter bar → Presets: apply, save current filter, delete (right-click); saved with the library |