mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-02 22:49:48 -06:00
Merge main into LRC-LIB-SMARTCOLL (keep both sides of catalog tests and What's new)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -33,7 +33,15 @@ pub(crate) const PROFILE_PROXY: usize = 192;
|
||||
pub(crate) fn file_local_look(format: RawFormat) -> bool {
|
||||
matches!(
|
||||
format,
|
||||
RawFormat::Arw | RawFormat::Nef | RawFormat::Nrw | RawFormat::Rw2 | RawFormat::Raf | RawFormat::Cr3 | RawFormat::Cr2 | RawFormat::Pef
|
||||
RawFormat::Arw
|
||||
| RawFormat::Nef
|
||||
| RawFormat::Nrw
|
||||
| RawFormat::Rw2
|
||||
| RawFormat::Raf
|
||||
| RawFormat::Cr3
|
||||
| RawFormat::Cr2
|
||||
| RawFormat::Pef
|
||||
| RawFormat::Srw
|
||||
)
|
||||
}
|
||||
|
||||
@@ -402,19 +410,38 @@ const EDGE_CONTRAST: f32 = 3.0;
|
||||
/// it is tried once more on the pixels away from edges (same gates): there colour pairs stay
|
||||
/// valid when the camera JPEG's geometry differs slightly from the raw's.
|
||||
fn fit_pairs_with(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>) -> Option<CameraLook> {
|
||||
fit_pairs_ordered(sensor, reference, colour, &[ToneFit::Quantile, ToneFit::ConditionalMedian])
|
||||
}
|
||||
|
||||
/// [`fit_pairs_with`] with the tone fits to try, in order: the first whose look passes the
|
||||
/// acceptance gates is used. Quantile matching comes first because it cannot produce flat steps;
|
||||
/// the conditional-median fit follows because on a few photos (compacts, early Micro Four Thirds)
|
||||
/// its slightly lower held-out error is what clears the gates, and a photo must not lose its look
|
||||
/// to the change of fit. The whole search (all pixels, then away from edges, with and without the
|
||||
/// camera profile's colour) runs with one tone fit before the next is tried, so the fallback
|
||||
/// accepts exactly what the previous releases accepted.
|
||||
fn fit_pairs_ordered(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>, order: &[ToneFit]) -> Option<CameraLook> {
|
||||
let has_profile = colour.is_some();
|
||||
// A different picture style can make a camera profile fail the gates. Preserve the
|
||||
// photo's own colour fit before resorting to the neutral fallback.
|
||||
fit_candidate(sensor, reference, colour).or_else(|| has_profile.then(|| fit_candidate(sensor, reference, None)).flatten())
|
||||
order.iter().find_map(|&tone| {
|
||||
// A different picture style can make a camera profile fail the gates. Preserve the
|
||||
// photo's own colour fit before resorting to the neutral fallback.
|
||||
fit_candidate(sensor, reference, colour.clone(), tone).or_else(|| has_profile.then(|| fit_candidate(sensor, reference, None, tone)).flatten())
|
||||
})
|
||||
}
|
||||
|
||||
/// One candidate colour model, fitted on all pixels and, when that fails the gates, once more on
|
||||
/// the pixels away from edges (issue #232).
|
||||
fn fit_candidate(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>) -> Option<CameraLook> {
|
||||
fit_pairs_on(sensor, reference, colour.clone(), None).or_else(|| fit_pairs_on(sensor, reference, colour, Some(EDGE_CONTRAST)))
|
||||
fn fit_candidate(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>, tone: ToneFit) -> Option<CameraLook> {
|
||||
fit_pairs_on(sensor, reference, colour.clone(), None, tone).or_else(|| fit_pairs_on(sensor, reference, colour, Some(EDGE_CONTRAST), tone))
|
||||
}
|
||||
|
||||
fn fit_pairs_on(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>, edge_limit: Option<f32>) -> Option<CameraLook> {
|
||||
fn fit_pairs_on(
|
||||
sensor: &Rgb32f,
|
||||
reference: &Rgb32f,
|
||||
colour: Option<(Mat3, Option<HsvTable>)>,
|
||||
edge_limit: Option<f32>,
|
||||
tone_fit: ToneFit,
|
||||
) -> Option<CameraLook> {
|
||||
// A known colour model needs enough signal for tone fitting, not a scene rich enough to
|
||||
// learn a new colour matrix. Still reject monochrome references.
|
||||
let (pairs, bright) = collect_pairs(sensor, reference, if colour.is_some() { 0.005 } else { 0.05 }, edge_limit)?;
|
||||
@@ -437,7 +464,7 @@ fn fit_pairs_on(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Optio
|
||||
};
|
||||
let tone_pairs: Vec<_> =
|
||||
pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(colour(*x).map(|v| v.max(0.0))), luma(*y))).collect();
|
||||
let Some(curve) = fit_tone(tone_pairs) else { continue };
|
||||
let Some(curve) = tone_fit.fit(tone_pairs) else { continue };
|
||||
let mut look = CameraLook { matrix, tone: curve, hue_sat: hue_sat.clone() };
|
||||
if let Some(tone) = fit_chroma(&bright, &look) {
|
||||
look.tone = tone;
|
||||
@@ -468,10 +495,11 @@ fn fit_pairs_on(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Optio
|
||||
.sum();
|
||||
if lightcraft_pipeline::profiling() {
|
||||
eprintln!(
|
||||
"[profile] camera look holdout RMS {:.5} -> {:.5} ({samples} channels{})",
|
||||
"[profile] camera look holdout RMS {:.5} -> {:.5} ({samples} channels{}{})",
|
||||
(before / samples as f64).sqrt(),
|
||||
(after / samples as f64).sqrt(),
|
||||
if edge_limit.is_some() { ", away from edges" } else { "" }
|
||||
if edge_limit.is_some() { ", away from edges" } else { "" },
|
||||
if tone_fit == ToneFit::ConditionalMedian { ", conditional-median tone" } else { "" }
|
||||
);
|
||||
}
|
||||
if samples == 0 || after >= before * MIN_IMPROVEMENT || after / samples as f64 > MAX_HOLDOUT_RMS.powi(2) {
|
||||
@@ -681,12 +709,68 @@ fn fit_chroma(pairs: &[([f64; 3], [f64; 3])], look: &CameraLook) -> Option<Camer
|
||||
(after.is_finite() && after < before).then_some(fitted)
|
||||
}
|
||||
|
||||
/// How the per-photo tone curve is fitted ([`fit_pairs_ordered`] tries them in order).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum ToneFit {
|
||||
/// [`fit_tone`]: no flat steps, no pixel-exact registration needed.
|
||||
Quantile,
|
||||
/// [`fit_tone_conditional`]: the earlier fit, kept as the fallback.
|
||||
ConditionalMedian,
|
||||
}
|
||||
|
||||
impl ToneFit {
|
||||
fn fit(self, pairs: Vec<(f64, f64)>) -> Option<CameraTone> {
|
||||
match self {
|
||||
ToneFit::Quantile => fit_tone(pairs),
|
||||
ToneFit::ConditionalMedian => fit_tone_conditional(pairs),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The camera's tone curve from scene/JPEG luminance pairs, by quantile matching: knot `i` pairs
|
||||
/// the median of the `i`-th 1/32 of the sorted scene luminances with the median of the `i`-th
|
||||
/// 1/32 of the sorted JPEG luminances. A tone curve is monotone, so it maps each quantile of the
|
||||
/// scene to the same quantile of the JPEG; matching the two distributions needs no pixel-exact
|
||||
/// registration and is increasing by construction.
|
||||
///
|
||||
/// Binning the pairs by scene luminance and taking the JPEG's median per bin instead (followed by
|
||||
/// isotonic pooling) fails on real files: camera JPEGs are lens-corrected and sharpened, so on a
|
||||
/// 96 px proxy of a busy scene neighbouring pairs disagree by more than the gap between bins.
|
||||
/// The conditional medians then zig-zag, pooling turns them into flat steps (13 of 31 segments
|
||||
/// on a Z 6II forest, issue #475) that render as posterised patches, and regression toward the
|
||||
/// mean flattens both ends of the curve (lifted blacks, dull highlights).
|
||||
fn fit_tone(pairs: Vec<(f64, f64)>) -> Option<CameraTone> {
|
||||
if pairs.len() < 128 || !pairs.iter().all(|(x, y)| x.is_finite() && *x > 0.0 && y.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let mut xs: Vec<f64> = pairs.iter().map(|p| p.0).collect();
|
||||
let mut ys: Vec<f64> = pairs.iter().map(|p| p.1).collect();
|
||||
xs.sort_by(f64::total_cmp);
|
||||
ys.sort_by(f64::total_cmp);
|
||||
let n = xs.len();
|
||||
let mut knots = [[0.0; 2]; 32];
|
||||
for (i, knot) in knots.iter_mut().enumerate() {
|
||||
let (a, b) = (i * n / 32, (i + 1) * n / 32);
|
||||
let mid = a + (b - a) / 2;
|
||||
*knot = [*xs.get(mid)? as f32, *ys.get(mid)? as f32];
|
||||
}
|
||||
if knots[31][0] < knots[0][0] * 1.5 {
|
||||
return None;
|
||||
}
|
||||
CameraTone::new(knots)
|
||||
}
|
||||
|
||||
fn median(values: &mut [f64]) -> Option<f64> {
|
||||
values.sort_by(f64::total_cmp);
|
||||
values.get(values.len() / 2).copied()
|
||||
}
|
||||
|
||||
fn fit_tone(mut pairs: Vec<(f64, f64)>) -> Option<CameraTone> {
|
||||
/// The earlier tone fit, kept as the fallback of [`ToneFit`]: the JPEG's median per equal-population
|
||||
/// bin of scene luminance, then pooled-adjacent-violators isotonic regression. It follows the
|
||||
/// per-pixel pairs closely when the two images are registered, which is why it can clear the
|
||||
/// acceptance gates where [`fit_tone`] just misses them, but on lens-corrected JPEGs of busy scenes
|
||||
/// the pooling leaves flat steps (issue #475).
|
||||
fn fit_tone_conditional(mut pairs: Vec<(f64, f64)>) -> Option<CameraTone> {
|
||||
if pairs.len() < 128 || !pairs.iter().all(|(x, y)| x.is_finite() && *x > 0.0 && y.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
@@ -1021,7 +1105,17 @@ mod tests {
|
||||
#[test]
|
||||
fn supported_raws_get_a_file_local_look() {
|
||||
// every decoded raw without a colour matrix of its own (issue #310: CR2 rendered flat with the fallback)
|
||||
let local = [RawFormat::Arw, RawFormat::Nef, RawFormat::Nrw, RawFormat::Rw2, RawFormat::Raf, RawFormat::Cr3, RawFormat::Cr2, RawFormat::Pef];
|
||||
let local = [
|
||||
RawFormat::Arw,
|
||||
RawFormat::Nef,
|
||||
RawFormat::Nrw,
|
||||
RawFormat::Rw2,
|
||||
RawFormat::Raf,
|
||||
RawFormat::Cr3,
|
||||
RawFormat::Cr2,
|
||||
RawFormat::Pef,
|
||||
RawFormat::Srw,
|
||||
];
|
||||
assert!(local.into_iter().all(file_local_look));
|
||||
// DNG carries its own colour model
|
||||
assert!(!file_local_look(RawFormat::Dng));
|
||||
@@ -1228,4 +1322,77 @@ mod tests {
|
||||
reference.data.truncate(8);
|
||||
assert!(fit_pairs(&sensor, &reference).is_none());
|
||||
}
|
||||
|
||||
/// A busy scene whose JPEG is the camera curve of the scene two proxy pixels to the right (a
|
||||
/// lens-corrected preview): the fitted curve follows the camera's and has no flat steps.
|
||||
#[test]
|
||||
fn tone_fit_has_no_steps_on_a_misregistered_jpeg() {
|
||||
let camera = |x: f64| 0.6 * x.powf(0.55) / (1.0 + 0.2 * x);
|
||||
let (w, h) = (96usize, 64usize);
|
||||
let mut seed = 0x2545_f491_u32;
|
||||
let mut scene = Vec::with_capacity(w * h);
|
||||
for _ in 0..w * h {
|
||||
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||
scene.push(0.01 * (5.0 * f64::from(seed >> 8) / f64::from(1u32 << 24)).exp());
|
||||
}
|
||||
let pairs: Vec<(f64, f64)> =
|
||||
(0..h).flat_map(|y| (0..w - 2).map(move |x| (y * w + x, y * w + x + 2))).map(|(i, j)| (scene[i], camera(scene[j]))).collect();
|
||||
let curve = fit_tone(pairs).unwrap();
|
||||
let mut sorted = scene.clone();
|
||||
sorted.sort_by(f64::total_cmp);
|
||||
let (lo, hi) = (sorted[sorted.len() / 50], sorted[sorted.len() * 49 / 50]);
|
||||
let mut x = lo;
|
||||
while x < hi {
|
||||
let (a, b) = (curve.apply(x as f32), curve.apply((x * 1.02) as f32));
|
||||
assert!(b > a, "flat step at {x}: {a} -> {b}");
|
||||
assert!((f64::from(a) / camera(x) - 1.0).abs() < 0.05, "at {x}: {a} vs {}", camera(x));
|
||||
x *= 1.02;
|
||||
}
|
||||
}
|
||||
|
||||
/// A synthetic photo whose JPEG is the camera look of the scene scaled per pixel by noise of
|
||||
/// `spread` (a pseudo-random, registration-free stand-in for local tone mapping): the per-bin
|
||||
/// median recovers the curve, while the JPEG's broadened distribution stretches the quantile fit.
|
||||
fn noisy_look(spread: f32) -> (Rgb32f, Rgb32f) {
|
||||
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
|
||||
let (w, h) = (96usize, 64usize);
|
||||
let mut sensor = Rgb32f::new(w, h);
|
||||
let mut reference = sensor.clone();
|
||||
let mut seed = 0x9e37_79b9_u32;
|
||||
let mut next = move || {
|
||||
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||
f32::from((seed >> 16) as u16) / 32768.0 - 1.0
|
||||
};
|
||||
for (src, dst) in sensor.data.iter_mut().zip(&mut reference.data) {
|
||||
let ev = 0.05 + (next() + 1.0) * 0.2;
|
||||
*src = [ev * (0.8 + (next() + 1.0) * 0.1), ev, ev * (0.8 + (next() + 1.0) * 0.1)];
|
||||
let p = known.apply_f32(*src);
|
||||
let y = luminance_2020(p);
|
||||
let scale = (1.0 - (-2.5 * y).exp()) / y * (1.0 + spread * next());
|
||||
*dst = p.map(|v| v * scale);
|
||||
}
|
||||
(sensor, reference)
|
||||
}
|
||||
|
||||
/// Quantile matching is tried first; the conditional-median fit is the fallback and is used
|
||||
/// only where the quantile look misses the acceptance gates, so no photo loses its look.
|
||||
#[test]
|
||||
fn tone_fit_order_quantile_first_conditional_median_as_fallback() {
|
||||
// Clean data: both fits pass, the default order uses the quantile curve (increasing).
|
||||
let (sensor, reference) = noisy_look(0.0);
|
||||
let default = fit_pairs(&sensor, &reference).expect("clean look");
|
||||
let quantile = fit_pairs_ordered(&sensor, &reference, None, &[ToneFit::Quantile]).expect("quantile passes");
|
||||
assert_eq!(default.tone, quantile.tone);
|
||||
// Noisy data: the quantile fit misses the gates, the conditional median still passes.
|
||||
let (sensor, reference) = noisy_look(0.33);
|
||||
assert!(fit_pairs_ordered(&sensor, &reference, None, &[ToneFit::Quantile]).is_none(), "quantile alone misses the gates");
|
||||
let conditional = fit_pairs_ordered(&sensor, &reference, None, &[ToneFit::ConditionalMedian]).expect("conditional median passes");
|
||||
let default = fit_pairs(&sensor, &reference).expect("the fallback keeps the look");
|
||||
assert_eq!(default.tone, conditional.tone);
|
||||
// The order matters only through the first pass; with the fits swapped the conditional one wins even on clean data.
|
||||
let (sensor, reference) = noisy_look(0.0);
|
||||
let swapped = fit_pairs_ordered(&sensor, &reference, None, &[ToneFit::ConditionalMedian, ToneFit::Quantile]).unwrap();
|
||||
let conditional = fit_pairs_ordered(&sensor, &reference, None, &[ToneFit::ConditionalMedian]).unwrap();
|
||||
assert_eq!(swapped.tone, conditional.tone);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -671,9 +671,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
// ---- delete / restore
|
||||
cmd!("photo.delete", "Delete Photo", ["Photo"], Some("Delete"), "{ids?} — moves to Recently Deleted", has_selection, |s, p| {
|
||||
let before = s.visible_cloned();
|
||||
let at = before.iter().position(|id| Some(*id) == s.selection.active);
|
||||
let v = for_targets(s, p, "Delete", |id| Some(Op::SetDeleted { id, deleted: true }))?;
|
||||
let vis = s.visible_cloned();
|
||||
s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default();
|
||||
let remaining: std::collections::HashSet<_> = s.visible_cloned().into_iter().collect();
|
||||
s.selection.ids.retain(|id| remaining.contains(id));
|
||||
s.selection.active = s.selection.active.filter(|id| remaining.contains(id)).or_else(|| s.selection.ids.first().copied());
|
||||
if s.selection.active.is_none()
|
||||
&& let Some(at) = at
|
||||
{
|
||||
// Keep culling near the deleted photo: next visible survivor, or the previous one at the end.
|
||||
let neighbour = before.iter().skip(at.saturating_add(1)).chain(before.iter().take(at).rev()).find(|id| remaining.contains(id));
|
||||
s.selection = neighbour.map(|id| Selection::single(*id)).unwrap_or_default();
|
||||
}
|
||||
Ok(v)
|
||||
}),
|
||||
cmd!("photo.restore", "Restore", ["Photo"], None, "{ids?}", has_selection, |s, p| for_targets(s, p, "Restore", |id| Some(Op::SetDeleted {
|
||||
|
||||
@@ -358,11 +358,22 @@ fn load_bytes_now(
|
||||
Arc::new(lightcraft_pipeline::CameraColor { tags, developed_for: xy })
|
||||
});
|
||||
let camera_tone = camera_look.as_ref().map(|p| p.tone).or_else(|| raw.color.profile.tone_curve.as_ref().and_then(dng_tone_curve));
|
||||
let local_tone = local_tone(&raw);
|
||||
let (temp, tint) = if relative { (6500.0, 0.0) } else { (temp.round(), tint.round()) };
|
||||
return Ok((
|
||||
img,
|
||||
twin,
|
||||
SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens, relative_wb: relative, camera_color, camera_tone, mattes },
|
||||
SourceInfo {
|
||||
raw: true,
|
||||
as_shot_temp: temp,
|
||||
as_shot_tint: tint,
|
||||
lens,
|
||||
relative_wb: relative,
|
||||
camera_color,
|
||||
camera_tone,
|
||||
mattes,
|
||||
local_tone,
|
||||
},
|
||||
));
|
||||
}
|
||||
let d = lightcraft_codecs::decode(&bytes, fit_box(max_edge)).map_err(|e| e.to_string())?;
|
||||
@@ -372,6 +383,18 @@ fn load_bytes_now(
|
||||
Ok((img.into_oriented(Orientation::from_exif(d.orientation)), None, SourceInfo::default()))
|
||||
}
|
||||
|
||||
/// The raw's gain table map with where its developed picture (the default crop, oriented) sits in
|
||||
/// the active area: rendered when the photo's "Camera local tone mapping" option is on.
|
||||
fn local_tone(raw: &lightcraft_raw::RawImage) -> Option<Arc<lightcraft_pipeline::local_tone::LocalTone>> {
|
||||
let map = raw.color.profile.gain_table_map.clone()?;
|
||||
let a = raw.active_area;
|
||||
let (aw, ah) = (a.width.max(1) as f64, a.height.max(1) as f64);
|
||||
let c = raw.develop_crop(a.width, a.height);
|
||||
let rect = [c.x as f64 / aw, c.y as f64 / ah, c.width as f64 / aw, c.height as f64 / ah];
|
||||
let placement = lightcraft_raw::gaintable::SourcePlacement { rect, orientation: raw.orientation };
|
||||
Some(Arc::new(lightcraft_pipeline::local_tone::LocalTone { map, placement }))
|
||||
}
|
||||
|
||||
/// The semantic masks of a DNG that AI masks understand, over the developed image (default crop,
|
||||
/// oriented like it). Person mattes without a confidently selected pixel are left out (the
|
||||
/// Subject mask then falls back to its heuristic); a sky matte counts even when empty (no sky).
|
||||
@@ -958,6 +981,59 @@ mod tests {
|
||||
assert_eq!(before.data, after.data);
|
||||
}
|
||||
|
||||
/// The Profile option "Camera local tone mapping" renders the map by position (in the raw's
|
||||
/// active area, whatever the photo's orientation), and only when it's on.
|
||||
#[test]
|
||||
fn dng_gain_table_map_renders_when_asked() {
|
||||
use lightcraft_pipeline::{RenderRequest, render};
|
||||
use lightcraft_raw::gaintable::GainTableMap;
|
||||
let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default());
|
||||
let mut raw = lightcraft_raw::decode(&plain).unwrap();
|
||||
// ×1 at the active area's left edge rising to ×4 at its right edge, whatever the colour
|
||||
raw.color.profile.gain_table_map = Some(GainTableMap {
|
||||
points_v: 1,
|
||||
points_h: 2,
|
||||
points_n: 1,
|
||||
spacing_v: 1.0,
|
||||
spacing_h: 1.0,
|
||||
origin_v: 0.0,
|
||||
origin_h: 0.0,
|
||||
weights: [1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0, 0.0, 0.0],
|
||||
gamma: 1.0,
|
||||
gains: vec![1.0, 4.0],
|
||||
});
|
||||
let with_map = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap();
|
||||
let (img, info) = load_bytes(&with_map, 64).unwrap();
|
||||
let (_, plain_info) = load_bytes(&plain, 64).unwrap();
|
||||
assert!(info.local_tone.is_some() && plain_info.local_tone.is_none());
|
||||
let req = RenderRequest::fit(64, 64);
|
||||
let mut s = lightcraft_develop::DevelopSettings::default();
|
||||
// (darker, so that ×4 never reaches white)
|
||||
s.light.exposure = -3.0;
|
||||
let off = render(&img, &info, &s, &req).image;
|
||||
assert_eq!(off.data, render(&img, &plain_info, &s, &req).image.data, "off: the map changes nothing");
|
||||
s.profile.camera_local_tone = true;
|
||||
assert!(s.to_json().to_string().contains("camera_local_tone"), "the option is part of the settings (and their hash)");
|
||||
let on = render(&img, &info, &s, &req).image;
|
||||
// brightness gained, per column: none at the left edge, most at the right
|
||||
let column = |im: &lightcraft_raster::Rgba8, x: usize| (0..im.height).map(|y| im.data[y * im.width + x][1] as f32).sum::<f32>();
|
||||
let gain = |x: usize| column(&on, x) / column(&off, x).max(1.0);
|
||||
let last = on.width - 1;
|
||||
assert!((gain(0) - 1.0).abs() < 0.08, "left ×{}", gain(0));
|
||||
assert!(gain(last) > 1.4 && gain(last) > gain(last / 2) && gain(last / 2) > gain(0), "{} {} {}", gain(0), gain(last / 2), gain(last));
|
||||
// the photo turned upside down: the map follows the raw, so the brightened side swaps
|
||||
s.orientation = lightcraft_geom::Orientation::Rotate180;
|
||||
s.profile.camera_local_tone = false;
|
||||
let off = render(&img, &info, &s, &req).image;
|
||||
s.profile.camera_local_tone = true;
|
||||
let on = render(&img, &info, &s, &req).image;
|
||||
let gain = |x: usize| column(&on, x) / column(&off, x).max(1.0);
|
||||
assert!(gain(0) > 1.4 && (gain(last) - 1.0).abs() < 0.08, "rotated: {} … {}", gain(0), gain(last));
|
||||
// a rendered (non-raw) source never gets it
|
||||
let jpeg = SourceInfo { raw: false, ..info.clone() };
|
||||
assert!(!lightcraft_pipeline::local_tone::enabled(&jpeg, &s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_raw_falls_back_to_embedded_preview() {
|
||||
let b = cr3_with_preview(48, 32);
|
||||
|
||||
@@ -32,9 +32,9 @@ use crate::media::ProbeInfo;
|
||||
|
||||
/// File extensions LightCraft imports (lower case).
|
||||
pub const EXTENSIONS: &[&str] = &[
|
||||
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "psd", "jxl",
|
||||
"gif", "bmp", "heic", "avif", // containers LightCraft cannot decode but imports as preview only (their embedded JPEG)
|
||||
"iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf",
|
||||
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "srw", "psd",
|
||||
"jxl", "gif", "bmp", "heic", "avif", // containers LightCraft cannot decode but imports as preview only (their embedded JPEG)
|
||||
"iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", "3fr", "fff",
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
|
||||
@@ -62,7 +62,7 @@ impl SettingsHashes {
|
||||
}
|
||||
|
||||
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
|
||||
pub const RENDER_CACHE_VERSION: u64 = 21;
|
||||
pub const RENDER_CACHE_VERSION: u64 = 22;
|
||||
|
||||
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
|
||||
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];
|
||||
@@ -209,6 +209,7 @@ impl DecodedSource {
|
||||
/// Cache cost: the picture, its segmentation mattes, its denoised twin and room for the lazy mix.
|
||||
pub fn bytes(&self) -> usize {
|
||||
let mattes = self.info.as_ref().and_then(|i| i.mattes.as_ref()).map_or(0, |m| m.bytes());
|
||||
let mattes = mattes + self.info.as_ref().and_then(|i| i.local_tone.as_ref()).map_or(0, |t| t.map.gains.len() * 4);
|
||||
self.image.data.len() * 12 + 64 + std::mem::size_of::<SourceInfo>() + mattes + self.denoised.as_ref().map_or(0, |t| t.bytes())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Session, command_specs};
|
||||
use crate::{Selection, Session, command_specs};
|
||||
|
||||
fn demo() -> Session {
|
||||
Session::with_demo()
|
||||
@@ -157,6 +157,47 @@ fn filters_and_delete_restore() {
|
||||
assert_eq!(s.visible().len(), 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_photos_selects_a_neighbour_in_the_current_view() {
|
||||
for params in [json!({}), json!({"key": "fileName", "ascending": false}), json!({"key": "random", "seed": 123})] {
|
||||
for at_end in [false, true] {
|
||||
let mut s = demo();
|
||||
s.execute("library.sort", ¶ms).unwrap();
|
||||
s.execute("library.filter", &json!({"rating": 2})).unwrap();
|
||||
let visible = s.visible_cloned();
|
||||
assert!(visible.len() > 5);
|
||||
let at = if at_end { visible.len() - 2 } else { visible.len() / 2 };
|
||||
let deleted = &visible[at..at + 2];
|
||||
s.execute("library.select", &json!({"ids": deleted, "active": deleted[0]})).unwrap();
|
||||
s.execute("photo.delete", &json!({})).unwrap();
|
||||
let expected = if at_end { visible[at - 1] } else { visible[at + 2] };
|
||||
assert_eq!(s.selection, Selection::single(expected), "sort {params}, end {at_end}");
|
||||
assert!(deleted.iter().all(|id| s.catalog.photo(*id).unwrap().deleted));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(deleted.iter().all(|id| !s.catalog.photo(*id).unwrap().deleted));
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!(deleted.iter().all(|id| s.catalog.photo(*id).unwrap().deleted));
|
||||
}
|
||||
}
|
||||
let mut s = demo();
|
||||
s.execute("library.selectAll", &json!({})).unwrap();
|
||||
s.execute("photo.delete", &json!({})).unwrap();
|
||||
assert!(s.visible().is_empty());
|
||||
assert_eq!(s.selection, Selection::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_explicit_targets_keeps_the_surviving_selection() {
|
||||
let mut s = demo();
|
||||
let visible = s.visible_cloned();
|
||||
s.execute("library.select", &json!({"ids": [visible[8], visible[9]], "active": visible[9]})).unwrap();
|
||||
let before = s.selection.clone();
|
||||
s.execute("photo.delete", &json!({"ids": [visible[3]]})).unwrap();
|
||||
assert_eq!(s.selection, before, "deleting an unselected photo doesn't change selection");
|
||||
s.execute("photo.delete", &json!({"ids": [visible[9]]})).unwrap();
|
||||
assert_eq!(s.selection, Selection::single(visible[8]), "partial deletion retains a selected survivor");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_crop_and_render() {
|
||||
let mut s = demo();
|
||||
|
||||
Reference in New Issue
Block a user