mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
LR-EDIT-COLOR-MIXER-BW + LR-FILT-TYPE: Auto B&W mix, HDR / panorama type filters
- develop.autoBwMix (B&W Mixer → Auto): each hue band's colourful pixels are pushed away from
the image's mean lightness (light bands brighter, dark ones darker; bands with almost no
colour untouched), clamped to ±60; switches to B&W; one undo step.
- library.filter {merged: hdr | panorama | hdrPanorama | any}: Photo Merge results recognised
by the "-HDR" / "-Pano" / "-HDR-Pano" names merges write; filter bar type picker lists HDR,
Panoramas, HDR Panoramas.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
478f328cc0
commit
9e8d75300f
@@ -44,6 +44,8 @@ pub struct Filter {
|
||||
pub imported: Option<String>,
|
||||
/// Imported at or after this time (ISO; Recently Added).
|
||||
pub imported_from: Option<String>,
|
||||
/// Photo Merge results: `hdr`, `panorama`, `hdrPanorama` or `any` (see [`merged_kind`]).
|
||||
pub merged: Option<String>,
|
||||
/// A folder on disk: its files only (browsed ones too); `subfolders` includes everything below.
|
||||
pub folder: Option<String>,
|
||||
pub subfolders: bool,
|
||||
@@ -188,6 +190,12 @@ impl Filter {
|
||||
if self.edited.is_some_and(|e| e != p.is_edited()) {
|
||||
return false;
|
||||
}
|
||||
if let Some(want) = &self.merged {
|
||||
match merged_kind(&p.file_name) {
|
||||
Some(k) if want == "any" || want == k => {}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
if let Some(a) = self.album
|
||||
&& !cat.album_contains(a, p)
|
||||
{
|
||||
@@ -311,3 +319,20 @@ pub fn in_folder(path: &str, dir: &str, deep: bool) -> bool {
|
||||
let Some(rest) = rest.strip_prefix(['/', '\\']) else { return false };
|
||||
!rest.is_empty() && (deep || !rest.contains(['/', '\\']))
|
||||
}
|
||||
|
||||
/// The kind of Photo Merge result a file is, from the name merges give it (`IMG_1-HDR.dng`,
|
||||
/// `IMG_1-Pano.dng`, `IMG_1-HDR-Pano.dng` — also the names other raw editors use):
|
||||
/// `hdr`, `panorama` or `hdrPanorama`.
|
||||
pub fn merged_kind(file_name: &str) -> Option<&'static str> {
|
||||
let stem = file_name.rsplit_once('.').map_or(file_name, |(s, _)| s).to_ascii_lowercase();
|
||||
let stem = stem.trim_end_matches(|c: char| c.is_ascii_digit() || c == '-' || c == ' ');
|
||||
if stem.ends_with("-hdr-pano") {
|
||||
Some("hdrPanorama")
|
||||
} else if stem.ends_with("-hdr") {
|
||||
Some("hdr")
|
||||
} else if stem.ends_with("-pano") {
|
||||
Some("panorama")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,3 +216,14 @@ fn folder_paths() {
|
||||
assert!(!in_folder("/a/bc/d.jpg", "/a/b", true), "a sibling with the same prefix");
|
||||
assert!(in_folder("C:\\Pics\\a.jpg", "C:\\Pics", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_results_are_recognised() {
|
||||
use crate::query::merged_kind;
|
||||
assert_eq!(merged_kind("IMG_1-HDR.dng"), Some("hdr"));
|
||||
assert_eq!(merged_kind("IMG_1-Pano.dng"), Some("panorama"));
|
||||
assert_eq!(merged_kind("IMG_1-HDR-Pano.dng"), Some("hdrPanorama"));
|
||||
assert_eq!(merged_kind("IMG_1-HDR-2.dng"), Some("hdr"), "a second merge of the same photo");
|
||||
assert_eq!(merged_kind("hdr.jpg"), None);
|
||||
assert_eq!(merged_kind("panorama.jpg"), None);
|
||||
}
|
||||
|
||||
@@ -193,6 +193,28 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(())
|
||||
})
|
||||
}),
|
||||
cmd!(
|
||||
"develop.autoBwMix",
|
||||
"Auto B&W Mix",
|
||||
[],
|
||||
None,
|
||||
"{} — sets the black & white mix from the photo's colours (and switches to B&W)",
|
||||
has_active,
|
||||
|s, _| {
|
||||
let id = active(s, "develop.autoBwMix")?;
|
||||
let src = s.source_now(id, SourceLevel::Thumb).map_err(|e| bad("develop.autoBwMix", e))?;
|
||||
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
let m = lightcraft_pipeline::auto::auto_bw_mix(&src, &info, &d);
|
||||
edit(s, "develop.autoBwMix", "Auto B&W Mix", |d| {
|
||||
d.treatment = Treatment::Bw;
|
||||
let b = &mut d.bw_mix;
|
||||
[b.red, b.orange, b.yellow, b.green, b.aqua, b.blue, b.purple, b.magenta] = m;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"mix": m}))
|
||||
}
|
||||
),
|
||||
cmd!("develop.auto", "Auto Settings", ["Photo"], Some("Shift+A"), "{}", has_active, |s, _| {
|
||||
let id = active(s, "develop.auto")?;
|
||||
let src = s.source_now(id, SourceLevel::Thumb).map_err(|e| bad("develop.auto", e))?;
|
||||
|
||||
@@ -164,7 +164,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Filter",
|
||||
[],
|
||||
None,
|
||||
"partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, edited?, date?, keyword?, camera?}",
|
||||
"partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, merged?: hdr|panorama|hdrPanorama|any, edited?, date?, keyword?, camera?}",
|
||||
always,
|
||||
|s, p| {
|
||||
let mut v = serde_json::to_value(&s.filter).unwrap_or_default();
|
||||
|
||||
@@ -421,3 +421,16 @@ fn filter_presets_save_and_apply() {
|
||||
s.execute("filter.deletePreset", &json!({"name": "Best"})).unwrap();
|
||||
assert!(s.filter_presets.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_bw_mix_separates_colours() {
|
||||
let mut s = demo();
|
||||
s.execute("develop.autoBwMix", &json!({})).unwrap();
|
||||
let d = active_dev(&s);
|
||||
assert_eq!(d.treatment, lightcraft_develop::Treatment::Bw);
|
||||
let m = d.bw_mix.bands();
|
||||
assert!(m.iter().any(|v| *v != 0.0), "some band moved: {m:?}");
|
||||
assert!(m.iter().all(|v| v.abs() <= 60.0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_ne!(active_dev(&s).treatment, lightcraft_develop::Treatment::Bw, "one undo step");
|
||||
}
|
||||
|
||||
@@ -60,6 +60,10 @@ fn hdr_merge_command_creates_and_imports_a_dng() {
|
||||
assert!(r2["path"].as_str().unwrap().ends_with("IMG_0-HDR-2.dng"));
|
||||
let id2 = lightcraft_catalog::PhotoId(r2["id"].as_u64().unwrap());
|
||||
assert!(s.catalog.stack_of(id2).is_none(), "no stack without the option");
|
||||
// the type filter finds merge results (and only them)
|
||||
s.execute("library.filter", &json!({"merged": "hdr"})).unwrap();
|
||||
let found: std::collections::HashSet<_> = s.catalog.query(&s.filter, &Default::default()).into_iter().collect();
|
||||
assert_eq!(found, [id, id2].into_iter().collect());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,46 @@ pub fn auto_tone(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings) -> AutoTo
|
||||
}
|
||||
}
|
||||
|
||||
/// An automatic black & white mix (slider values, red … magenta) for `src` under `s`'s white
|
||||
/// balance and tone: each hue band's colourful pixels are pushed away from the image's mean
|
||||
/// lightness — bands brighter than average get brighter, darker ones darker — so areas that
|
||||
/// differ only in colour stay apart in grey. Bands with almost no colourful pixels stay at 0.
|
||||
pub fn auto_bw_mix(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings) -> [f64; 8] {
|
||||
use lightcraft_color::perceptual::{lab_to_lch, oklab_from_2020};
|
||||
let mut img = lightcraft_raster::resample::fit(src, 512, 512, lightcraft_raster::resample::Filter::Box);
|
||||
let base = DevelopSettings { wb: s.wb, light: s.light, ..DevelopSettings::default() };
|
||||
crate::local::scene_linear_pre(&mut img, info, &base);
|
||||
let gain = 2f32.powf(base.light.exposure as f32);
|
||||
let (mut mass, mut sum_l) = ([0f64; 8], [0f64; 8]);
|
||||
let (mut all_l, mut n) = (0f64, 0usize);
|
||||
for p in &img.data {
|
||||
let lch = lab_to_lch(oklab_from_2020(p.map(|v| (v * gain).max(0.0))));
|
||||
all_l += lch[0] as f64;
|
||||
n += 1;
|
||||
let k = (lch[1] / 0.2).min(1.0) as f64; // as in the B&W conversion
|
||||
if k < 0.05 {
|
||||
continue;
|
||||
}
|
||||
let w = crate::colorops::band_weights(lch[2]);
|
||||
for i in 0..8 {
|
||||
mass[i] += w[i] as f64 * k;
|
||||
sum_l[i] += w[i] as f64 * k * lch[0] as f64;
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
return [0.0; 8];
|
||||
}
|
||||
let mean = all_l / n as f64;
|
||||
let total: f64 = mass.iter().sum();
|
||||
std::array::from_fn(|i| {
|
||||
if total <= 0.0 || mass[i] / total < 0.01 {
|
||||
return 0.0;
|
||||
}
|
||||
let sep = sum_l[i] / mass[i] - mean;
|
||||
(sep * 400.0).clamp(-60.0, 60.0).round()
|
||||
})
|
||||
}
|
||||
|
||||
/// Grey-world white balance weighted towards mid-tone, low-chroma pixels. Returns (temp, tint).
|
||||
pub fn auto_wb(src: &Rgb32f, info: &SourceInfo) -> (f64, f64) {
|
||||
let img = lightcraft_raster::resample::fit(src, 256, 256, lightcraft_raster::resample::Filter::Box);
|
||||
@@ -134,3 +174,20 @@ mod tests {
|
||||
assert!((c[0] - c[2]).abs() < 0.02, "{c:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod bw_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn auto_bw_mix_pushes_light_and_dark_hues_apart() {
|
||||
// left: a light yellow, right: a dark blue (scene-linear Rec. 2020)
|
||||
let src = Rgb32f::from_fn(64, 32, |x, _| if x < 32 { [0.55, 0.5, 0.05] } else { [0.01, 0.02, 0.12] });
|
||||
let m = auto_bw_mix(&src, &SourceInfo::default(), &DevelopSettings::default());
|
||||
let (yellow, blue) = (m[2], m[5]);
|
||||
assert!(yellow > 0.0 && blue < 0.0, "{m:?}");
|
||||
// an image without colour leaves the mix alone
|
||||
let grey = Rgb32f::from_fn(16, 16, |x, _| [x as f32 / 16.0; 3]);
|
||||
assert_eq!(auto_bw_mix(&grey, &SourceInfo::default(), &DevelopSettings::default()), [0.0; 8]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -730,6 +730,12 @@ fn mixer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) {
|
||||
});
|
||||
let b = bands[sel];
|
||||
if crate::is_bw(d) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(24.0);
|
||||
if crate::widgets::text_button(ui, "bwAuto", "Auto", false).on_hover_text("Set the mix from the photo's colours").clicked() {
|
||||
let _ = app.run("develop.autoBwMix", json!({}));
|
||||
}
|
||||
});
|
||||
control(app, ui, d, &format!("bw.{b}"), true);
|
||||
} else {
|
||||
for k in ["hue", "sat", "lum"] {
|
||||
|
||||
@@ -188,12 +188,19 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
Some(MediaKind::Raw) => "Raw",
|
||||
Some(MediaKind::Video) => "Videos",
|
||||
};
|
||||
let kind_label = match f.merged.as_deref() {
|
||||
Some("hdr") => "HDR",
|
||||
Some("panorama") => "Panoramas",
|
||||
Some("hdrPanorama") => "HDR Panoramas",
|
||||
Some(_) => "Merged",
|
||||
None => kind_label,
|
||||
};
|
||||
picker(
|
||||
app,
|
||||
ui,
|
||||
"kind",
|
||||
kind_label,
|
||||
f.kind.is_some(),
|
||||
f.kind.is_some() || f.merged.is_some(),
|
||||
[
|
||||
("All types", Value::Null, None),
|
||||
("Photos", json!("image"), Some(MediaKind::Image)),
|
||||
@@ -201,7 +208,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
("Videos", json!("video"), Some(MediaKind::Video)),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(l, v, k)| (l.to_string(), json!({"kind": v}), f.kind == k))
|
||||
.map(|(l, v, k)| (l.to_string(), json!({"kind": v, "merged": null}), f.kind == k && f.merged.is_none()))
|
||||
.chain(
|
||||
[("HDR", "hdr"), ("Panoramas", "panorama"), ("HDR Panoramas", "hdrPanorama")]
|
||||
.into_iter()
|
||||
.map(|(l, m)| (l.to_string(), json!({"kind": null, "merged": m}), f.merged.as_deref() == Some(m))),
|
||||
)
|
||||
.collect(),
|
||||
);
|
||||
let edited_label = match f.edited {
|
||||
|
||||
+6
-6
@@ -24,9 +24,9 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
|
||||
| A. Import (IMP) | 7 | 2 | 4 | 1 | 3/4 (75%) | 3/4 (75%) |
|
||||
| B. Library management (LIB) | 19 | 0 | 6 | 2 | 9/9 (100%) | 9/9 (100%) |
|
||||
| C. Views & navigation (VIEW) | 14 | 0 | 3 | 0 | 9/9 (100%) | 4/4 (100%) |
|
||||
| D. Search & filter (FILT) | 8 | 2 | 3 | 0 | 4/4 (100%) | 3/4 (75%) |
|
||||
| D. Search & filter (FILT) | 9 | 1 | 3 | 0 | 4/4 (100%) | 4/4 (100%) |
|
||||
| E. Metadata (META) | 4 | 2 | 0 | 0 | 2/2 (100%) | 1/2 (50%) |
|
||||
| F. Edit panel — global adjustments (EDIT) | 40 | 2 | 6 | 1 | 28/28 (100%) | 12/14 (86%) |
|
||||
| F. Edit panel — global adjustments (EDIT) | 41 | 1 | 6 | 1 | 28/28 (100%) | 13/14 (93%) |
|
||||
| G. Profiles (PROF) | 6 | 0 | 4 | 0 | 3/3 (100%) | 2/3 (67%) |
|
||||
| H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) |
|
||||
| I. Remove / healing (REM) | 6 | 1 | 3 | 2 | 4/4 (100%) | 2/3 (67%) |
|
||||
@@ -48,9 +48,9 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
|
||||
| Y. Menus | 70 | 7 | 8 | 8 | 46/47 (98%) | 19/23 (83%) |
|
||||
| Z. Keyboard shortcuts (desktop) | 66 | 9 | 5 | 1 | 49/52 (94%) | 15/23 (65%) |
|
||||
| Lightroom Classic extras | 12 | 17 | 50 | 9 | — | 11/21 (52%) |
|
||||
| **Total** | 323 | 51 | 126 | 37 | 193/198 (97%) | 110/144 (76%) |
|
||||
| **Total** | 325 | 49 | 126 | 37 | 193/198 (97%) | 112/144 (78%) |
|
||||
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **69.7%** of 500 in-scope rows — P0 98.7% of 198 · P1 86.1% of 144 · P2 18.4% of 158.
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **69.9%** of 500 in-scope rows — P0 98.7% of 198 · P1 86.8% of 144 · P2 18.4% of 158.
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## Top gaps
|
||||
@@ -176,7 +176,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| LR-FILT-RATING | Rating filter | P0 | ✅ | `cmd:library.filter` (`rating`, `ratingOp`), `crates/ui-egui/src/panels/filterbar.rs` | ≥ / = / ≤ stars in the filter bar (`cmd:view.filterBar`) |
|
||||
| LR-FILT-FLAG | Flag filter | P0 | ✅ | `cmd:library.filter` (`flag`), `crates/ui-egui/src/panels/filterbar.rs` | picked / rejected / unflagged (one at a time) |
|
||||
| LR-FILT-LABEL | Colour-label filter | P1 | ✅ | `cmd:library.filter` (`label`), `crates/ui-egui/src/panels/filterbar.rs` | one label at a time; no “no label” choice |
|
||||
| LR-FILT-TYPE | Type / edited filter | P1 | 🟡 | `cmd:library.filter` (`kind`, `edited`), `crates/ui-egui/src/panels/filterbar.rs` | photos / raw / videos, edited / unedited; no HDR/panorama/depth kinds |
|
||||
| LR-FILT-TYPE | Type / edited filter | P1 | ✅ | `cmd:library.filter` (`kind`, `merged`, `edited`), `crates/ui-egui/src/panels/filterbar.rs`, `crates/catalog/src/query.rs` (`merged_kind`) | photos / raw / videos / HDR / panoramas / HDR panoramas (merge results by name), edited / unedited; no depth kind |
|
||||
| LR-FILT-KEYWORD | Keyword filter | P1 | ✅ | `cmd:library.filter` (`keyword`), `crates/ui-egui/src/panels/filterbar.rs` | keyword picker |
|
||||
| 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`) | free-text match on the location field only |
|
||||
@@ -221,7 +221,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| LR-EDIT-COLOR-VIBRANCE | Vibrance | P0 | ✅ | `ctl:color.vibrance` | |
|
||||
| LR-EDIT-COLOR-SATURATION | Saturation | P0 | ✅ | `ctl:color.saturation` | |
|
||||
| LR-EDIT-COLOR-MIXER-HSL | 8-band colour mixer | P0 | ✅ | `ctl:mixer.*` | no targeted (drag-on-image) mode |
|
||||
| LR-EDIT-COLOR-MIXER-BW | B&W mix | P1 | 🟡 | `ctl:bw.*` | no auto mix |
|
||||
| LR-EDIT-COLOR-MIXER-BW | B&W mix | P1 | ✅ | `ctl:bw.*`, `cmd:develop.autoBwMix` | eight bands; Auto pushes each hue band's colourful pixels away from the mean lightness (own rule) |
|
||||
| LR-EDIT-COLOR-POINTCOLOR | Point colour | P1 | ✅ | `cmd:pointColor.pick`, `cmd:pointColor.delete` | |
|
||||
| LR-EDIT-COLOR-GRADING | Colour grading wheels | P0 | ✅ | `ctl:grading.*` | |
|
||||
| LR-EDIT-EFFECTS-TEXTURE | Texture | P0 | ✅ | `ctl:effects.texture` | |
|
||||
|
||||
Reference in New Issue
Block a user