mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
LR-LIB-CULL: assisted culling — focus scores, bursts, best-of-burst
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1b4f623e55
commit
26b00014a8
@@ -149,6 +149,11 @@ pub enum Op {
|
||||
id: PhotoId,
|
||||
captured: Option<String>,
|
||||
},
|
||||
/// Assisted culling scores.
|
||||
SetAnalysis {
|
||||
id: PhotoId,
|
||||
analysis: Option<crate::Analysis>,
|
||||
},
|
||||
/// 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()
|
||||
|
||||
@@ -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<Arc<DevelopSettings>>,
|
||||
/// Assisted culling scores (`None` until analysed).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub analysis: Option<Analysis>,
|
||||
}
|
||||
|
||||
/// 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<u32>,
|
||||
/// 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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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<i64> {
|
||||
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<Value> {
|
||||
let ids: Vec<PhotoId> = 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<i64>, 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<Option<u32>> = 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<u32, (usize, f32)> = 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<CommandSpec> {
|
||||
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
|
||||
)]
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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<u64> = 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);
|
||||
}
|
||||
|
||||
@@ -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<f32>, 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::<f32>() / 64.0;
|
||||
s.iter_mut().for_each(|v| *v -= mean);
|
||||
let norm = s.iter().map(|v| v * v).sum::<f32>().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::<f32>().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);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@
|
||||
|
||||
pub mod auto;
|
||||
pub mod colorops;
|
||||
pub mod cull;
|
||||
pub mod finish;
|
||||
pub mod geometry;
|
||||
pub mod local;
|
||||
|
||||
@@ -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<Re
|
||||
app.toast(&ctx, format!("{name} opened for editing; it is stacked with the original"));
|
||||
Ok(r)
|
||||
}
|
||||
"dialog.cull" => {
|
||||
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}))
|
||||
|
||||
@@ -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<serde_jso
|
||||
Dialog::MergeKeywords { from, into } => 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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)>,
|
||||
|
||||
+8
-8
@@ -21,10 +21,10 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
|
||||
<!-- parity:summary -->
|
||||
| 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.
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## 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 |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user