This commit is contained in:
bflatastic 2026-10-13 12:28:32 +07:00
commit 3eb3722e2c
8 changed files with 672 additions and 73 deletions

View File

@ -70,6 +70,37 @@ fn typical() -> DevelopSettings {
s
}
/// [`typical`] plus three masks: a radial gradient, a brush (three strokes) and either a linear
/// gradient with Refine Edges (`refine`; such edits render on the CPU) or a Subject mask (the
/// heuristic, which the GPU renderer evaluates on the CPU).
fn with_masks(refine: bool) -> DevelopSettings {
use lightcraft_develop::{BrushStroke, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape};
use lightcraft_geom::Point;
let mut s = typical();
let mask = |id: u32, shape: MaskShape, exposure: f64, refine: f64| Mask {
id,
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }],
adjust: LocalAdjustments { exposure, clarity: 10.0, ..Default::default() },
refine,
..Default::default()
};
let radial = MaskShape::Radial { center: Point::new(0.45, 0.5), rx: 0.3, ry: 0.2, angle: 15.0, feather: 60.0, invert: false };
let strokes = (0..3)
.map(|i| BrushStroke {
points: (0..40).map(|k| Point::new(0.1 + k as f64 * 0.02, 0.2 + i as f64 * 0.25 + (k as f64 * 0.3).sin() * 0.05)).collect(),
size: 0.04,
..Default::default()
})
.collect();
let third = if refine {
mask(3, MaskShape::Linear { start: Point::new(0.5, 0.0), end: Point::new(0.5, 0.6) }, -0.5, 60.0)
} else {
mask(3, MaskShape::Subject, 0.3, 0.0)
};
s.masks = vec![mask(1, radial, 0.5, 0.0), mask(2, MaskShape::Brush { strokes }, -0.3, 0.0), third];
s
}
/// Max and mean |Δ| (8-bit, RGB) between two renders.
fn diff(a: &lightcraft_raster::Rgba8, b: &lightcraft_raster::Rgba8) -> (u8, f64) {
let (mut max, mut sum) = (0u8, 0u64);
@ -214,6 +245,32 @@ fn main() {
drop(develop(&preview, &info, &t, &view, Some(&caches[g as usize]), g));
});
}
if run("masks") {
// three masks (one with Refine Edges, or one Subject mask): a global exposure drag and a
// mask's own exposure drag, both of which leave the masks' alphas as they were
for refine in [true, false] {
let m = with_masks(refine);
let what = if refine { "masks+refine" } else { "masks+subject" };
let caches = [StageCache::default(), StageCache::default()];
for (req, size) in [(&view, "loupe 1920×1280"), (&draft, "draft 1152×768")] {
for g in backends {
drop(develop(&preview, &info, &m, req, Some(&caches[*g as usize]), *g));
}
row(&format!("{size} exposure drag, {what}:"), &mut |g| {
let mut t = m.clone();
k += 1.0;
t.light.exposure = 0.3 + (k % 50.0) * 0.01;
drop(develop(&preview, &info, &t, req, Some(&caches[g as usize]), g));
});
row(&format!("{size} mask exposure drag, {what}:"), &mut |g| {
let mut t = m.clone();
k += 1.0;
t.masks[0].adjust.exposure = 0.5 + (k % 50.0) * 0.01;
drop(develop(&preview, &info, &t, req, Some(&caches[g as usize]), g));
});
}
}
}
if run("finish") {
// per-pixel stage cost by feature (warm cache: exposure changes only)
let variants: [(&str, fn(&mut DevelopSettings)); 6] = [

View File

@ -9,6 +9,7 @@ use lightcraft_geom::Orientation;
use lightcraft_pipeline::finish::{FinishParams, MASK_TERMS, mask_terms};
use lightcraft_pipeline::geometry::SampleMode;
use lightcraft_pipeline::local::plane_at;
use lightcraft_pipeline::masks::{AlphaCache, AlphaKeys, alpha_key};
use lightcraft_pipeline::{Plan, RenderRequest, Rendered, SourceInfo, local};
use lightcraft_raster::resample::Filter;
use lightcraft_raster::{Histogram, Plane, Rgb32f, Rgba8};
@ -34,6 +35,9 @@ struct Entry {
sampled: Arc<Buf>,
lin: Option<(u64, Arc<Buf>)>,
planes: Planes,
/// Alphas of the last render's masks, as [`lightcraft_pipeline::masks::AlphaCache`] keeps them
/// on the CPU (same keys).
masks: AlphaCache<Buf>,
}
/// Spatial planes of one linear image (`key` = its `lin_key`), each tagged with its radius.
@ -106,6 +110,8 @@ impl GpuStages {
p.clarity.iter().for_each(|(_, b)| add(b));
p.texture.iter().for_each(|(_, b)| add(b));
p.dark.iter().for_each(|(_, b, _)| add(b));
p.chroma.iter().for_each(|(_, b)| add(b));
e.masks.planes().for_each(&mut add);
}
if let Some((_, b)) = &*self.source.lock().unwrap_or_else(|e| e.into_inner()) {
add(b);
@ -644,6 +650,7 @@ pub fn render(
lap("wb/nr", &mut t, &mut cx);
// 3. spatial planes
let mut alphas = cached.as_ref().map(|e| e.masks.clone()).unwrap_or_default();
let mut planes = match cached.map(|e| e.planes) {
Some(p) if p.key == plan.lin_key => p,
_ => Planes { key: plan.lin_key, ..Default::default() },
@ -654,13 +661,13 @@ pub fn render(
prep.air = a;
}
lap("planes", &mut t, &mut cx);
if let Some(c) = stages {
c.put(Entry { src: src.clone(), geo: plan.geo, sampled, lin: Some((plan.lin_key, lin.clone())), planes });
}
// 4. masks
let (masks, terms) = masks(&mut cx, &lin, &prep, &plan, &mut host);
let (masks, terms) = masks(&mut cx, &lin, &prep, &plan, &mut host, &mut alphas);
lap("masks", &mut t, &mut cx);
if let Some(c) = stages {
c.put(Entry { src: src.clone(), geo: plan.geo, sampled, lin: Some((plan.lin_key, lin.clone())), planes, masks: alphas });
}
// 5. per-pixel stage
let mut fp = FinishParams::new(s, &plan.frame, info, w, h, plan.px_per_long, prep.air, req.space);
@ -924,14 +931,25 @@ fn guided_cross_max(cx: &mut Cx<'_>, guide: &Buf, p: &Buf, w: usize, h: usize, s
/// Evaluate the masks (`lightcraft_pipeline::masks::evaluate`): their alpha planes (concatenated,
/// followed by the blurred chromaticity when local Moiré / Noise need it) and their adjustment
/// terms. Shapes without a kernel (Sky, Subject, …) run on the CPU.
fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Host) -> (Option<Buf>, Vec<[f32; MASK_TERMS]>) {
/// terms. Shapes without a kernel (Sky, Subject, …) run on the CPU. A mask whose alpha `alphas`
/// holds (same [`alpha_key`]) is copied from there; `alphas` keeps this render's.
fn masks(
cx: &mut Cx<'_>,
lin: &Buf,
prep: &Prep,
plan: &Plan<'_>,
host: &mut Host,
alphas: &mut AlphaCache<Buf>,
) -> (Option<Buf>, Vec<[f32; MASK_TERMS]>) {
use lightcraft_develop::{MaskOp, MaskShape};
let s = &*plan.settings;
let list: Vec<_> = s.masks.iter().filter(|m| m.visible && !m.components.is_empty()).collect();
let old = alphas.take(plan.mattes.as_ref());
if list.is_empty() {
return (None, Vec::new());
}
let keys = AlphaKeys { geo: plan.geo, image: plan.lin_key };
let mut used = Vec::with_capacity(list.len());
let (w, h) = (plan.w, plan.h);
let n = w * h;
let frame = &plan.frame;
@ -946,6 +964,12 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho
}
let comp_plane = cx.gpu.buffer(n);
for (mi, m) in list.iter().enumerate() {
let key = alpha_key(m, keys, ev);
if let Some((_, b)) = old.iter().find(|(k, _)| *k == key) {
cx.copy_into(b, &alpha, mi * n);
used.push((key, b.clone()));
continue;
}
let mut first = true;
for comp in &m.components {
let op = match comp.op {
@ -1055,7 +1079,9 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho
&[None, None, None, Some(&comp_plane), Some(&alpha)],
groups1(n),
);
used.push((key, Arc::new(cx.slice(&alpha, mi * n, n))));
}
alphas.keep(plan.mattes.as_ref(), used);
let terms = list.iter().map(|m| mask_terms(&m.adjust)).collect();
(Some(alpha), terms)
}

View File

@ -553,6 +553,67 @@ fn cached_renders_match_uncached() {
}
}
/// Mask alphas kept on the device across a slider drag: a warm render equals a fresh one whatever
/// the edit (one each mask reads, and ones none of them read), the CPU's result within the bounds,
/// and the alphas count in the stages' device bytes.
#[test]
fn cached_renders_with_masks_match_uncached() {
if !gpu() {
return;
}
let src = scene(1, 900, 600);
let info = SourceInfo { raw: true, ..Default::default() };
let cache = StageCache::default();
let req = RenderRequest::fit(640, 640);
let comp = |shape: MaskShape| MaskComponent { name: None, op: MaskOp::Add, invert: false, shape };
let mask = |id: u32, shape: MaskShape| Mask { id, components: vec![comp(shape)], ..Default::default() };
let mut s = DevelopSettings::default();
typical(&mut s);
let stroke = BrushStroke { points: (0..10).map(|k| Point::new(0.1 + 0.07 * k as f64, 0.6)).collect(), size: 0.04, ..Default::default() };
s.masks = vec![
mask(1, MaskShape::Radial { center: Point::new(0.4, 0.5), rx: 0.3, ry: 0.2, angle: 0.0, feather: 50.0, invert: false }),
mask(2, MaskShape::Brush { strokes: vec![stroke] }),
mask(3, MaskShape::LuminanceRange { lo: 0.3, hi: 0.7, lo_feather: 0.1, hi_feather: 0.1 }),
// no kernel: evaluated on the CPU and uploaded
mask(4, MaskShape::Subject),
];
for (i, m) in s.masks.iter_mut().enumerate() {
m.adjust.exposure = 0.3 * i as f64 - 0.4;
m.adjust.clarity = 20.0;
}
let steps: Vec<Box<dyn Fn(&mut DevelopSettings)>> = vec![
Box::new(|_| {}),
Box::new(|s| s.light.exposure = 0.8),
Box::new(|s| s.masks[0].adjust.exposure = 1.0),
Box::new(|s| s.masks[3].adjust.amount = 50.0),
Box::new(|s| {
s.masks[0].components[0].shape =
MaskShape::Radial { center: Point::new(0.6, 0.4), rx: 0.2, ry: 0.2, angle: 0.0, feather: 30.0, invert: false }
}),
Box::new(|s| s.masks.swap(1, 2)),
Box::new(|s| {
s.wb.mode = WbMode::Custom;
s.wb.temp = 4300.0;
}),
Box::new(|s| s.masks[1].visible = false),
];
for (i, step) in steps.iter().enumerate() {
step(&mut s);
let warm = lightcraft_gpu::render(&src, &info, &s, &req, Some(&cache)).expect("gpu");
let fresh = lightcraft_gpu::render(&src, &info, &s, &req, None).expect("gpu");
assert_eq!(warm.image, fresh.image, "step {i}");
}
check("masks after a cached drag", &src, &info, &s, &req);
// three visible masks of 4 B/px held on the device besides the stages
let out = lightcraft_gpu::render(&src, &info, &s, &req, Some(&cache)).expect("gpu").image;
let n = out.width * out.height;
let with = lightcraft_gpu::stage_bytes(&cache);
s.masks.clear();
let fresh = StageCache::default();
lightcraft_gpu::render(&src, &info, &s, &req, Some(&fresh)).expect("gpu");
assert!(with >= lightcraft_gpu::stage_bytes(&fresh) + 3 * 4 * n, "{with}");
}
/// Issue #632: the Highlights / Shadows base of each process (V1: the edge-aware filter of log
/// luminance; V2: of its fine median, a radius that grows with the render) matches the CPU's, and
/// a warm cache never hands one process the other's base.

View File

@ -236,9 +236,10 @@ pub fn frame_for(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, apply_cro
///
/// Each stage is keyed by exactly the inputs it depends on: the resampled source by the source
/// buffer, framing and output size; the white-balanced, retouched, denoised image by those plus
/// white balance, spots and noise reduction; each spatial plane by that plus its own radius. So a
/// tone or colour slider drag (or exposure) re-runs only the per-pixel stage, a clarity drag skips
/// resampling and noise reduction, and so on. Holds the last few output sizes (a draft-size and a
/// white balance, spots and noise reduction; each spatial plane by that plus its own radius; each
/// mask's alpha by its definition, the framing and, for masks that read them, that image and the
/// exposure ([`masks::alpha_key`]). So a tone or colour slider drag (or exposure) re-runs only the
/// per-pixel stage, a clarity drag skips resampling and noise reduction, and so on. Holds the last few output sizes (a draft-size and a
/// full-size render of the same view both stay warm).
pub struct StageCache {
entries: Mutex<Vec<CacheEntry>>,
@ -300,9 +301,12 @@ impl StageCache {
}
let pl = &e.planes;
let planes = pl.log_l.iter().chain(pl.base.iter().map(|x| &x.1)).chain(pl.clarity.iter().map(|x| &x.1));
for p in planes.chain(pl.texture.iter().map(|x| &x.1)).chain(pl.dark.iter().map(|x| &x.1)) {
for p in planes.chain(pl.texture.iter().map(|x| &x.1)).chain(pl.dark.iter().map(|x| &x.1)).chain(e.masks.planes()) {
add(Arc::as_ptr(p) as usize, size(p));
}
if let Some((_, c)) = &pl.chroma {
add(Arc::as_ptr(c) as usize, size(c));
}
}
total
}
@ -341,6 +345,9 @@ struct CacheEntry {
sampled: Arc<Rgb32f>,
lin: Option<(u64, Arc<Rgb32f>)>,
planes: local::Planes,
/// Alphas of the last render's masks (kept across changes to the image: those of masks that
/// don't read it stay valid).
masks: masks::AlphaCache,
}
fn hash_of(parts: impl std::hash::Hash) -> u64 {
@ -563,6 +570,7 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende
}
};
lap("wb/spots/nr", &mut t);
let mut alphas = cached.as_ref().map(|e| e.masks.clone()).unwrap_or_default();
let mut planes = match cached.map(|e| e.planes) {
Some(p) if p.key == lin_key => p,
// A draft while noise reduction changes (a slider drag): the wide, edge-aware planes of the
@ -572,13 +580,14 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende
Some(p) if req.quality == Quality::Draft && p.pre_nr == plan.pre_nr_key => p.for_other_nr(),
_ => local::Planes { key: lin_key, pre_nr: plan.pre_nr_key, ..Default::default() },
};
let mut prep = local::prepare(lin.clone(), s, frame, px_per_long, req.quality, &mut planes, plan.mattes.as_deref());
let keys = masks::AlphaKeys { geo, image: lin_key };
let mut prep = local::prepare(lin.clone(), s, frame, px_per_long, req.quality, &mut planes, plan.mattes.as_ref(), (&mut alphas, keys));
if let Some(a) = plan.fixed_air {
prep.air = a;
}
lap("prepare", &mut t);
if let Some((a, c)) = shared {
c.put(CacheEntry { src: a.clone(), geo, sampled, lin: Some((lin_key, lin)), planes });
c.put(CacheEntry { src: a.clone(), geo, sampled, lin: Some((lin_key, lin)), planes, masks: alphas });
}
if req.depth != OutputDepth::U8 {
let deep = finish::finish_deep(&prep, s, frame, info, req.space, req.depth, req.proof);
@ -646,7 +655,7 @@ pub fn color_range_sample(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings,
fn overlay_alpha(o: Overlay, plan: &Plan<'_>, prep: &Prepared) -> Option<Plane> {
let m = o.mask(&plan.settings)?;
if let Some(e) = prep.masks.iter().find(|e| e.id == m.id) {
return Some(e.alpha.clone());
return Some((*e.alpha).clone());
}
let ev = plan.settings.light.exposure as f32;
Some(masks::evaluate_one(m, &plan.frame, plan.w, plan.h, &prep.img, &prep.log_l, ev, plan.mattes.as_deref()))

View File

@ -479,8 +479,8 @@ pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneS
PlaneSigmas { base, base_detail, clarity, texture, dark, chroma }
}
/// The spatial planes the per-pixel stage needs for `img` (white-balanced, before exposure),
/// reusing whatever `planes` already holds for it. Missing planes are computed side by side (each
/// The spatial planes and mask alphas the per-pixel stage needs for `img` (white-balanced, before
/// exposure), reusing whatever `planes` and `alphas` already hold for it. Missing planes are computed side by side (each
/// one alone scales poorly: the guided filters work on small subsampled grids).
#[allow(clippy::too_many_arguments)]
pub(crate) fn prepare(
@ -490,7 +490,8 @@ pub(crate) fn prepare(
px_per_long: f64,
q: Quality,
planes: &mut Planes,
mattes: Option<&masks::Mattes>,
mattes: Option<&Arc<masks::Mattes>>,
alphas: (&mut masks::AlphaCache, masks::AlphaKeys),
) -> Prepared {
let log_l = planes.log_l.get_or_insert_with(|| Arc::new(timed("log_l", || img.map(log_lum)))).clone();
let PlaneSigmas { base: base_sigma, base_detail, clarity: clarity_sigma, texture: texture_sigma, dark: dark_sigma, chroma: chroma_sigma } =
@ -536,7 +537,8 @@ pub(crate) fn prepare(
None => (None, 1.0),
};
let ev = s.light.exposure as f32;
let masks = timed("masks", || masks::evaluate(&s.masks, frame, img.width, img.height, &img, &log_l, ev, mattes));
let (cache, keys) = alphas;
let masks = timed("masks", || masks::evaluate_cached(&s.masks, frame, img.width, img.height, &img, &log_l, ev, mattes, keys, cache));
Prepared { img, log_l, base, clarity_blur, texture_blur, dark, chroma_blur, air, masks, px_per_long }
}

View File

@ -9,6 +9,7 @@
use lightcraft_develop::{BrushStroke, LocalAdjustments, Mask, MaskOp, MaskShape, SegMask};
use lightcraft_geom::{Orientation, Point};
use lightcraft_raster::{Image, Plane, Rgb32f};
use std::sync::Arc;
use crate::for_rows;
use crate::geometry::Frame;
@ -95,7 +96,8 @@ impl Mattes {
pub struct Evaluated {
/// The mask's id ([`Mask::id`]).
pub id: u32,
pub alpha: Plane,
/// Shared with the [`AlphaCache`] that keeps it for the next render.
pub alpha: Arc<Plane>,
pub adjust: LocalAdjustments,
}
@ -111,10 +113,176 @@ pub fn evaluate(masks: &[Mask], frame: &Frame, w: usize, h: usize, img: &Rgb32f,
masks
.iter()
.filter(|m| m.visible && !m.components.is_empty())
.map(|m| Evaluated { id: m.id, alpha: evaluate_one(m, frame, w, h, img, log_l, ev, mattes), adjust: m.adjust })
.map(|m| Evaluated { id: m.id, alpha: Arc::new(evaluate_one(m, frame, w, h, img, log_l, ev, mattes)), adjust: m.adjust })
.collect()
}
/// What a render's masks are evaluated against besides their own definitions: [`alpha_key`] keys
/// each alpha by the parts of it the mask reads.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct AlphaKeys {
/// The output frame and size (`Plan::geo`): every mask depends on it.
pub geo: u64,
/// The image the masks see, before exposure (`Plan::lin_key`): range masks, the Sky / Subject
/// / Background heuristics, Auto Mask brushes and Refine Edges read it.
pub image: u64,
}
/// Alpha planes (`P`: a CPU [`Plane`], or a renderer's device buffer) of the last render's visible
/// masks, keyed by [`alpha_key`]. A slider drag that changes nothing a mask reads (Exposure for
/// gradients and brushes, Clarity, the mask's own sliders except Amount) reuses its alpha instead
/// of evaluating it again. Holds only the masks of the last render (one plane per visible mask),
/// tied to the segmentation mattes they were evaluated with.
pub struct AlphaCache<P = Plane> {
mattes: Option<Arc<Mattes>>,
slots: Vec<(u64, Arc<P>)>,
}
impl<P> Clone for AlphaCache<P> {
fn clone(&self) -> Self {
AlphaCache { mattes: self.mattes.clone(), slots: self.slots.clone() }
}
}
impl<P> Default for AlphaCache<P> {
fn default() -> Self {
AlphaCache { mattes: None, slots: Vec::new() }
}
}
impl<P> AlphaCache<P> {
/// The alphas held for a render with `mattes` (none when the mattes changed), taken out of
/// the cache: hand back the ones the render used with [`Self::keep`].
pub fn take(&mut self, mattes: Option<&Arc<Mattes>>) -> Vec<(u64, Arc<P>)> {
let same = match (&self.mattes, mattes) {
(None, None) => true,
(Some(a), Some(b)) => Arc::ptr_eq(a, b),
_ => false,
};
let slots = std::mem::take(&mut self.slots);
if same { slots } else { Vec::new() }
}
/// Hold `slots` (the alphas a render with `mattes` used), dropping everything else.
pub fn keep(&mut self, mattes: Option<&Arc<Mattes>>, slots: Vec<(u64, Arc<P>)>) {
self.mattes = mattes.cloned();
self.slots = slots;
}
/// The alphas held.
pub fn planes(&self) -> impl Iterator<Item = &Arc<P>> {
self.slots.iter().map(|(_, p)| p)
}
pub fn len(&self) -> usize {
self.slots.len()
}
pub fn is_empty(&self) -> bool {
self.slots.is_empty()
}
}
/// Whether the alpha of mask `m` reads the image (`.0`) and the global exposure (`.1`), beyond
/// its own definition and the frame. Errs towards yes: a mask said to read something it doesn't
/// is only evaluated more often than it needs to be.
pub fn reads(m: &Mask) -> (bool, bool) {
let (mut image, mut exposure) = (m.refine > 0.0, false);
for c in &m.components {
let (i, e) = match &c.shape {
MaskShape::Linear { .. } | MaskShape::Radial { .. } | MaskShape::DepthRange { .. } | MaskShape::Landscape { .. } => (false, false),
MaskShape::Prompt { .. } => (false, false),
// a stored segmentation (or no clicks) reads nothing; clicks without one take the heuristic
MaskShape::Object { seg, detail, .. } => (seg.is_none() && detail.is_empty(), false),
MaskShape::Brush { strokes } => (strokes.iter().any(|s| s.auto_mask), false),
// (with a source matte these read neither: counted as if they did)
MaskShape::LuminanceRange { .. } | MaskShape::ColorRange { .. } | MaskShape::Sky => (true, true),
MaskShape::Subject | MaskShape::Background | MaskShape::People { .. } => (true, false),
};
image |= i;
exposure |= e;
}
(image, exposure)
}
/// Feeds `Debug` output to a hasher without building the string.
struct HashWrite<'a, H: std::hash::Hasher>(&'a mut H);
impl<H: std::hash::Hasher> std::fmt::Write for HashWrite<'_, H> {
fn write_str(&mut self, s: &str) -> std::fmt::Result {
self.0.write(s.as_bytes());
Ok(())
}
}
/// The key of mask `m`'s alpha in a render with `keys` and global exposure `ev`: everything
/// [`evaluate_one`] reads — its components, Refine Edges, Invert and Amount, the frame, and the
/// image and exposure for the masks that read them ([`reads`]). Not its other sliders, name or id.
pub fn alpha_key(m: &Mask, keys: AlphaKeys, ev: f32) -> u64 {
use std::fmt::Write;
use std::hash::{BuildHasher, BuildHasherDefault, DefaultHasher, Hash, Hasher};
let (image, exposure) = reads(m);
let mut h = BuildHasherDefault::<DefaultHasher>::default().build_hasher();
keys.geo.hash(&mut h);
image.then_some(keys.image).hash(&mut h);
exposure.then_some(ev.to_bits()).hash(&mut h);
(m.refine.to_bits(), m.invert, m.adjust.amount.to_bits()).hash(&mut h);
// (`Debug` of an f64 round-trips: distinct definitions write distinct text)
let _ = write!(HashWrite(&mut h), "{:?}", m.components);
h.finish()
}
/// [`evaluate`], reusing the alphas `cache` holds for masks whose inputs are unchanged and keeping
/// this render's there. The result is identical to [`evaluate`]'s.
#[allow(clippy::too_many_arguments)]
pub fn evaluate_cached(
masks: &[Mask],
frame: &Frame,
w: usize,
h: usize,
img: &Rgb32f,
log_l: &Plane,
ev: f32,
mattes: Option<&Arc<Mattes>>,
keys: AlphaKeys,
cache: &mut AlphaCache,
) -> Vec<Evaluated> {
let old = cache.take(mattes);
let mut used = Vec::new();
let out = masks
.iter()
.filter(|m| m.visible && !m.components.is_empty())
.map(|m| {
let key = alpha_key(m, keys, ev);
let alpha = match old.iter().find(|(k, _)| *k == key) {
Some((_, a)) => a.clone(),
None => Arc::new(evaluate_one(m, frame, w, h, img, log_l, ev, mattes.map(|a| &**a))),
};
used.push((key, alpha.clone()));
Evaluated { id: m.id, alpha, adjust: m.adjust }
})
.collect();
cache.keep(mattes, used);
out
}
/// `f(a, b)` for each value `a` of `p` and the value `b` at the same index of `q`, row-parallel.
fn zip_rows(p: &mut Plane, q: &Plane, f: impl Fn(&mut f32, f32) + Sync + Send) {
let w = p.width;
for_rows(&mut p.data, w, |y, row| {
let Some(src) = q.data.get(y * w..y * w + row.len()) else { return };
for (a, b) in row.iter_mut().zip(src) {
f(a, *b);
}
});
}
/// `f` on each value of `p`, row-parallel.
fn map_rows(p: &mut Plane, f: impl Fn(&mut f32) + Sync + Send) {
let w = p.width;
for_rows(&mut p.data, w, |_, row| row.iter_mut().for_each(&f));
}
/// The alpha plane of mask `m` (whether visible or not): its components combined, inverted and
/// scaled by its amount.
#[allow(clippy::too_many_arguments)]
@ -124,19 +292,17 @@ pub fn evaluate_one(m: &Mask, frame: &Frame, w: usize, h: usize, img: &Rgb32f, l
for comp in &m.components {
let mut c = shape_alpha(&comp.shape, frame, w, h, img, log_l, ev, mattes);
if comp.invert {
c.data.iter_mut().for_each(|v| *v = 1.0 - *v);
map_rows(&mut c, |v| *v = 1.0 - *v);
}
if first && comp.op != MaskOp::Intersect {
alpha = c;
first = false;
continue;
}
for (a, c) in alpha.data.iter_mut().zip(&c.data) {
*a = match comp.op {
MaskOp::Add => a.max(*c),
MaskOp::Subtract => *a * (1.0 - c),
MaskOp::Intersect => *a * c,
};
match comp.op {
MaskOp::Add => zip_rows(&mut alpha, &c, |a, c| *a = a.max(c)),
MaskOp::Subtract => zip_rows(&mut alpha, &c, |a, c| *a *= 1.0 - c),
MaskOp::Intersect => zip_rows(&mut alpha, &c, |a, c| *a *= c),
}
first = false;
}
@ -146,16 +312,15 @@ pub fn evaluate_one(m: &Mask, frame: &Frame, w: usize, h: usize, img: &Rgb32f, l
let k = (m.refine / 100.0).clamp(0.0, 1.0) as f32;
let sigma = (0.04 * frame.output_long(w, h) as f32 * k).max(1.0);
let refined = guided_cross(log_l, &alpha, sigma, 0.02);
for (a, r) in alpha.data.iter_mut().zip(&refined.data) {
*a += (r - *a) * k.sqrt();
}
let t = k.sqrt();
zip_rows(&mut alpha, &refined, |a, r| *a += (r - *a) * t);
}
if m.invert {
alpha.data.iter_mut().for_each(|v| *v = 1.0 - *v);
map_rows(&mut alpha, |v| *v = 1.0 - *v);
}
let amt = (m.adjust.amount / 100.0) as f32;
if (amt - 1.0).abs() > 1e-6 {
alpha.data.iter_mut().for_each(|v| *v *= amt);
map_rows(&mut alpha, |v| *v *= amt);
}
alpha
}
@ -210,37 +375,42 @@ pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &R
MaskShape::Brush { strokes } => rasterize_brush(strokes, frame, w, h, img, log_l, &mut out),
MaskShape::LuminanceRange { lo, hi, lo_feather, hi_feather } => {
let (lo, hi, lf, hf) = (*lo as f32, *hi as f32, (*lo_feather as f32).max(1e-3), (*hi_feather as f32).max(1e-3));
for (v, l) in out.data.iter_mut().zip(&log_l.data) {
zip_rows(&mut out, log_l, |v, l| {
// map log luminance to a 0..1 perceptual scale (−8..+4 EV)
let y = ((l + ev + 8.0) / 12.0).clamp(0.0, 1.0);
*v = smooth(lo - lf, lo, y) * (1.0 - smooth(hi, hi + hf, y));
}
});
}
MaskShape::ColorRange { samples, refine } => {
let tol = color_range_tolerance(*refine);
let samples: Vec<[f32; 3]> = samples.iter().map(|s| [s[0] as f32, s[1] as f32, s[2] as f32]).collect();
for (v, c) in out.data.iter_mut().zip(&img.data) {
let lab = lightcraft_color::perceptual::oklab_from_2020(tonemap_for_select(c.map(|v| v * gain)));
let d = samples
.iter()
.map(|s| ((lab[1] - s[1]).powi(2) + (lab[2] - s[2]).powi(2) + 0.25 * (lab[0] - s[0]).powi(2)).sqrt())
.fold(f32::MAX, f32::min);
*v = 1.0 - smooth(tol * 0.5, tol, d);
}
for_rows(&mut out.data, w, |y, row| {
let Some(src) = img.data.get(y * w..y * w + row.len()) else { return };
for (v, c) in row.iter_mut().zip(src) {
let lab = lightcraft_color::perceptual::oklab_from_2020(tonemap_for_select(c.map(|v| v * gain)));
let d = samples
.iter()
.map(|s| ((lab[1] - s[1]).powi(2) + (lab[2] - s[2]).powi(2) + 0.25 * (lab[0] - s[0]).powi(2)).sqrt())
.fold(f32::MAX, f32::min);
*v = 1.0 - smooth(tol * 0.5, tol, d);
}
});
}
MaskShape::Sky => {
// Classical sky heuristic until the segmenter lands (M12): bright, smooth, blue-ish or
// unsaturated, and connected to the top of the frame.
let m = frame.out_to_norm(w, h);
for (i, v) in out.data.iter_mut().enumerate() {
let (x, y) = (i % w, i / w);
let n = m.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5));
let c = img.data[i].map(|v| v * gain);
let l = log_l.data[i] + ev;
let blue = (c[2] - c[0]).max(0.0) / (c[2] + 1e-4);
let top = 1.0 - smooth(0.25, 0.7, n.y as f32);
*v = top * smooth(-3.5, -1.0, l) * (0.4 + 0.6 * smooth(0.0, 0.3, blue).max(smooth(0.0, 1.5, l)));
}
for_rows(&mut out.data, w, |y, row| {
let (Some(cs), Some(ls)) = (img.data.get(y * w..y * w + row.len()), log_l.data.get(y * w..y * w + row.len())) else { return };
for (x, (v, (c, l))) in row.iter_mut().zip(cs.iter().zip(ls)).enumerate() {
let n = m.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5));
let c = c.map(|v| v * gain);
let l = l + ev;
let blue = (c[2] - c[0]).max(0.0) / (c[2] + 1e-4);
let top = 1.0 - smooth(0.25, 0.7, n.y as f32);
*v = top * smooth(-3.5, -1.0, l) * (0.4 + 0.6 * smooth(0.0, 0.3, blue).max(smooth(0.0, 1.5, l)));
}
});
smooth_plane(&mut out, 0.01 * frame_px(frame, w));
}
MaskShape::Object { seg, detail, edge, .. } | MaskShape::Prompt { seg, detail, edge, .. } if seg.is_some() || !detail.is_empty() => {
@ -260,18 +430,20 @@ pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &R
// Saliency heuristic: centre-weighted local contrast (replaced by the segmenter in M12).
let m = frame.out_to_norm(w, h);
let blur = lightcraft_raster::blur::gaussian(log_l, 0.03 * frame_px(frame, w));
for (i, v) in out.data.iter_mut().enumerate() {
let (x, y) = (i % w, i / w);
let n = m.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5));
let d = (((n.x - 0.5) / 0.35).powi(2) + ((n.y - 0.55) / 0.4).powi(2)).sqrt() as f32;
let contrast = (log_l.data[i] - blur.data[i]).abs();
*v = (1.0 - smooth(0.6, 1.0, d)) * (0.5 + 0.5 * smooth(0.05, 0.6, contrast));
}
for_rows(&mut out.data, w, |y, row| {
let (Some(ls), Some(bs)) = (log_l.data.get(y * w..y * w + row.len()), blur.data.get(y * w..y * w + row.len())) else { return };
for (x, (v, (l, b))) in row.iter_mut().zip(ls.iter().zip(bs)).enumerate() {
let n = m.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5));
let d = (((n.x - 0.5) / 0.35).powi(2) + ((n.y - 0.55) / 0.4).powi(2)).sqrt() as f32;
let contrast = (l - b).abs();
*v = (1.0 - smooth(0.6, 1.0, d)) * (0.5 + 0.5 * smooth(0.05, 0.6, contrast));
}
});
smooth_plane(&mut out, 0.015 * frame_px(frame, w));
}
MaskShape::Background => {
let mut s = shape_alpha(&MaskShape::Subject, frame, w, h, img, log_l, ev, mattes);
s.data.iter_mut().for_each(|v| *v = 1.0 - *v);
map_rows(&mut s, |v| *v = 1.0 - *v);
out = s;
}
MaskShape::DepthRange { .. } | MaskShape::Landscape { .. } => {}
@ -570,19 +742,37 @@ pub fn guided_cross(guide: &Plane, p: &Plane, sigma: f32, eps: f32) -> Plane {
let mp = gaussian(p, sigma);
let cip = gaussian(&guide.zip_map(p, |a, b| a * b), sigma);
let cii = gaussian(&guide.map(|a| a * a), sigma);
let w = p.width;
let rows = |y: usize, len: usize| y * w..y * w + len;
// a = cov(I, p) / (var(I) + eps), b = mean(p) − a · mean(I)
let mut a = Plane::new(p.width, p.height);
let mut b = Plane::new(p.width, p.height);
for i in 0..p.data.len() {
let var = (cii.data[i] - mi.data[i] * mi.data[i]).max(0.0);
let k = (cip.data[i] - mi.data[i] * mp.data[i]) / (var + eps);
a.data[i] = k;
b.data[i] = mp.data[i] - k * mi.data[i];
}
let (ma, mb) = (gaussian(&a, sigma), gaussian(&b, sigma));
let mut q = Plane::new(p.width, p.height);
for i in 0..q.data.len() {
q.data[i] = (ma.data[i] * guide.data[i] + mb.data[i]).clamp(0.0, 1.0);
}
for_rows(&mut a.data, w, |y, row| {
let r = rows(y, row.len());
let (Some(cii), Some(mi), Some(cip), Some(mp)) = (cii.data.get(r.clone()), mi.data.get(r.clone()), cip.data.get(r.clone()), mp.data.get(r))
else {
return;
};
for (k, (((cii, mi), cip), mp)) in row.iter_mut().zip(cii.iter().zip(mi).zip(cip).zip(mp)) {
let var = (cii - mi * mi).max(0.0);
*k = (cip - mi * mp) / (var + eps);
}
});
let mut b = mp;
for_rows(&mut b.data, w, |y, row| {
let r = rows(y, row.len());
let (Some(ks), Some(mi)) = (a.data.get(r.clone()), mi.data.get(r)) else { return };
for (b, (k, mi)) in row.iter_mut().zip(ks.iter().zip(mi)) {
*b -= k * mi;
}
});
let (ma, mut q) = (gaussian(&a, sigma), gaussian(&b, sigma));
for_rows(&mut q.data, w, |y, row| {
let r = rows(y, row.len());
let (Some(ma), Some(g)) = (ma.data.get(r.clone()), guide.data.get(r)) else { return };
for (q, (a, g)) in row.iter_mut().zip(ma.iter().zip(g)) {
*q = (a * g + *q).clamp(0.0, 1.0);
}
});
q
}
@ -626,9 +816,7 @@ fn rasterize_brush(strokes: &[BrushStroke], frame: &Frame, w: usize, h: usize, i
// the refinement fills gaps along edges; it never takes paint away
stroke_alpha = stroke_alpha.zip_map(&q, f32::max);
}
for (o, a) in out.data.iter_mut().zip(&stroke_alpha.data) {
*o = if erase { *o * (1.0 - a) } else { o.max(*a) };
}
zip_rows(out, &stroke_alpha, |o, a| *o = if erase { *o * (1.0 - a) } else { o.max(a) });
}
}
@ -1015,4 +1203,70 @@ mod tests {
let lips = MaskShape::People { person: 0, parts: vec!["Lips".into()] };
assert_eq!(eval(&lips), shape_alpha(&lips, &f, w, h, &img, &l, 0.0, None));
}
/// The alpha cache's key changes with everything an alpha is evaluated from (a stale key would
/// put a stale mask on screen), and not with the mask's other sliders.
#[test]
fn alpha_keys_change_with_every_input_of_the_alpha() {
let keys = AlphaKeys { geo: 1, image: 2 };
let comp = |shape| MaskComponent { name: None, op: MaskOp::Add, invert: false, shape };
let one = |shape| Mask { components: vec![comp(shape)], ..Default::default() };
let radial = one(MaskShape::Radial { center: Point::new(0.5, 0.5), rx: 0.3, ry: 0.2, angle: 0.0, feather: 50.0, invert: false });
let lum = one(MaskShape::LuminanceRange { lo: 0.3, hi: 0.7, lo_feather: 0.1, hi_feather: 0.1 });
let key = |m: &Mask| alpha_key(m, keys, 0.0);
// a stored segmentation of the same size with other logits
let object = |v: f32| {
let seg = Some(SegMask::from_logits(4, &[v; 16]));
one(MaskShape::Object { hint: vec![Point::new(0.5, 0.5)], exclude: vec![], seg, detail: vec![], edge: 0.0 })
};
assert_ne!(key(&object(4.0)), key(&object(-4.0)));
let edits: [fn(&mut Mask); 7] = [
|m| m.refine = 10.0,
|m| m.invert = true,
|m| m.adjust.amount = 50.0,
|m| m.components[0].invert = true,
|m| m.components[0].op = MaskOp::Intersect,
|m| {
if let MaskShape::Radial { feather, .. } = &mut m.components[0].shape {
*feather = 50.5;
}
},
|m| {
let shape = MaskShape::Linear { start: Point::new(0.0, 0.0), end: Point::new(0.0, 1.0) };
m.components.push(MaskComponent { name: None, op: MaskOp::Subtract, invert: false, shape });
},
];
for (i, edit) in edits.iter().enumerate() {
let mut m = radial.clone();
edit(&mut m);
assert_ne!(key(&m), key(&radial), "edit {i}");
}
let mut m = radial.clone();
(m.id, m.name, m.adjust.exposure, m.adjust.clarity) = (9, "Renamed".into(), 1.0, 30.0);
assert_eq!(key(&m), key(&radial));
// the frame for every mask; the image and the exposure for the masks that read them
let (other_geo, other_image) = (AlphaKeys { geo: 3, ..keys }, AlphaKeys { image: 4, ..keys });
let refined = Mask { refine: 40.0, ..radial.clone() };
for m in [&radial, &lum, &refined] {
assert_ne!(alpha_key(m, other_geo, 0.0), key(m));
}
assert_eq!((alpha_key(&radial, other_image, 0.0), alpha_key(&radial, keys, 1.0)), (key(&radial), key(&radial)));
assert_ne!(alpha_key(&lum, other_image, 0.0), key(&lum));
assert_ne!(alpha_key(&lum, keys, 1.0), key(&lum));
assert_ne!(alpha_key(&refined, other_image, 0.0), key(&refined));
assert_eq!(alpha_key(&refined, keys, 1.0), key(&refined));
}
/// Alphas are only handed back to a render with the segmentation mattes they were evaluated
/// with (another photo's mattes, or none, start empty).
#[test]
fn alpha_cache_is_tied_to_the_mattes() {
let (a, b) = (Arc::new(Mattes::default()), Arc::new(Mattes::default()));
let mut c = AlphaCache::<Plane>::default();
c.keep(Some(&a), vec![(1, Arc::new(Plane::new(2, 2)))]);
assert!(c.clone().take(Some(&b)).is_empty());
assert!(c.clone().take(None).is_empty());
assert_eq!(c.take(Some(&a)).len(), 1);
assert!(c.is_empty());
}
}

View File

@ -255,6 +255,188 @@ fn drafts_keep_wide_planes_across_a_noise_reduction_change_and_full_renders_do_n
}
}
/// [`typical_edits`] plus masks of every kind the alpha cache must tell apart: ones that read
/// only the frame (gradients, a brush), the image (Auto Mask, Subject, Refine Edges) or the image
/// and the exposure (luminance and colour range, Sky).
fn masked_edits() -> DevelopSettings {
use lightcraft_develop::{BrushStroke, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape};
use lightcraft_geom::Point;
let comp = |op: MaskOp, shape: MaskShape| MaskComponent { name: None, op, invert: false, shape };
let mask = |id: u32, components: Vec<MaskComponent>, refine: f64| Mask {
id,
components,
adjust: LocalAdjustments { exposure: 0.4 * (id as f64 - 3.0), clarity: 20.0, noise: 30.0, ..Default::default() },
refine,
..Default::default()
};
let stroke = |y: f64, auto_mask: bool| BrushStroke {
points: (0..12).map(|k| Point::new(0.1 + k as f64 * 0.06, y)).collect(),
size: 0.05,
auto_mask,
..Default::default()
};
let mut s = typical_edits();
s.masks = vec![
mask(
1,
vec![
comp(MaskOp::Add, MaskShape::Radial { center: Point::new(0.4, 0.5), rx: 0.3, ry: 0.2, angle: 10.0, feather: 50.0, invert: false }),
comp(MaskOp::Subtract, MaskShape::Linear { start: Point::new(0.0, 0.0), end: Point::new(0.0, 0.4) }),
],
0.0,
),
mask(2, vec![comp(MaskOp::Add, MaskShape::Linear { start: Point::new(0.5, 1.0), end: Point::new(0.5, 0.3) })], 60.0),
mask(3, vec![comp(MaskOp::Add, MaskShape::LuminanceRange { lo: 0.3, hi: 0.7, lo_feather: 0.1, hi_feather: 0.1 })], 0.0),
mask(4, vec![comp(MaskOp::Add, MaskShape::ColorRange { samples: vec![[0.6, -0.05, -0.08]], refine: 50.0 })], 0.0),
mask(5, vec![comp(MaskOp::Add, MaskShape::Brush { strokes: vec![stroke(0.3, false), stroke(0.7, true)] })], 0.0),
mask(6, vec![comp(MaskOp::Add, MaskShape::Sky), comp(MaskOp::Intersect, MaskShape::Background)], 0.0),
];
s
}
/// A cached render with masks is the same image as an uncached one through any edit, global or
/// to a mask, at either size.
#[test]
fn stage_cache_with_masks_matches_uncached_render_through_a_slider_session() {
use crate::{Quality, StageCache, render_cached};
use lightcraft_develop::{BrushStroke, MaskShape};
use lightcraft_geom::Point;
use std::sync::Arc;
let src = Arc::new(scene());
let info = SourceInfo { raw: true, ..Default::default() };
let cache = StageCache::default();
let full = RenderRequest::fit(200, 200);
let draft = RenderRequest { quality: Quality::Draft, ..RenderRequest::fit(120, 120) };
let mut s = masked_edits();
let steps: Vec<Box<dyn Fn(&mut DevelopSettings)>> = vec![
Box::new(|_| {}),
Box::new(|s| s.light.exposure = -0.6),
Box::new(|s| s.light.contrast = 30.0),
Box::new(|s| s.masks[0].adjust.exposure = 1.2),
Box::new(|s| s.masks[1].refine = 30.0),
Box::new(|s| s.masks[2].components[0].shape = MaskShape::LuminanceRange { lo: 0.2, hi: 0.6, lo_feather: 0.1, hi_feather: 0.2 }),
Box::new(|s| s.masks[3].adjust.amount = 40.0),
Box::new(|s| s.masks[4].invert = true),
Box::new(|s| {
if let MaskShape::Brush { strokes } = &mut s.masks[4].components[0].shape {
strokes.push(BrushStroke { points: vec![Point::new(0.5, 0.5), Point::new(0.6, 0.55)], erase: true, ..Default::default() });
}
}),
Box::new(|s| s.masks[5].visible = false),
Box::new(|s| s.masks.swap(0, 1)),
Box::new(|s| {
s.wb.mode = lightcraft_develop::WbMode::Custom;
s.wb.temp = 4200.0;
}),
Box::new(|s| s.effects.clarity = -20.0),
Box::new(|s| s.masks[5].visible = true),
Box::new(|s| s.crop.geometry.rect = lightcraft_geom::Rect::new(0.05, 0.1, 0.9, 0.85)),
Box::new(|s| s.light.exposure = 0.9),
// (last: a draft then keeps the wide planes of the image before noise reduction)
Box::new(|s| s.detail.nr_luminance = 40.0),
];
let nr_step = steps.len() - 1;
for (i, step) in steps.iter().enumerate() {
step(&mut s);
for req in [&full, &draft] {
let a = render_cached(&src, &info, &s, req, &cache).image;
let b = render(&src, &info, &s, req).image;
if i != nr_step || req.quality != Quality::Draft {
assert_eq!(a, b, "step {i} {:?}", req.quality);
}
// and the alphas held are those a cold cache evaluates
let fresh = StageCache::default();
render_cached(&src, &info, &s, req, &fresh);
let (warm, cold) = (cached_alphas(&cache), cached_alphas(&fresh));
assert_eq!(warm.len(), cold.len(), "step {i} {:?}", req.quality);
for (k, (a, b)) in warm.iter().zip(&cold).enumerate() {
assert!(**a == **b, "step {i} {:?}: mask {k} is stale", req.quality);
}
}
}
}
/// The alphas held by the stage cache's most recent entry, in mask order.
fn cached_alphas(cache: &crate::StageCache) -> Vec<std::sync::Arc<lightcraft_raster::Plane>> {
cache.entries.lock().unwrap().last().unwrap().masks.planes().cloned().collect()
}
#[test]
fn mask_alphas_are_reused_until_what_they_read_changes() {
use crate::{StageCache, render_cached};
use lightcraft_develop::MaskShape;
use lightcraft_geom::Point;
use std::sync::Arc;
let src = Arc::new(scene());
let info = SourceInfo { raw: true, ..Default::default() };
let cache = StageCache::default();
let req = RenderRequest::fit(200, 200);
// radial − linear (reads the frame only), linear with Refine Edges (the image), luminance
// range (the image and the exposure)
let mut s = masked_edits();
s.masks.truncate(3);
render_cached(&src, &info, &s, &req, &cache);
let step = |s: &DevelopSettings| -> Vec<bool> {
let before = cached_alphas(&cache);
render_cached(&src, &info, s, &req, &cache);
let after = cached_alphas(&cache);
assert_eq!(after.len(), before.len());
before.iter().zip(&after).map(|(a, b)| Arc::ptr_eq(a, b)).collect()
};
// exposure: only the range mask reads it
s.light.exposure += 0.5;
assert_eq!(step(&s), [true, true, false]);
// a mask's own sliders (except Amount) and global sliders no mask reads
s.masks[0].adjust.exposure = -1.0;
s.masks[2].adjust.clarity = 50.0;
s.effects.clarity = 40.0;
s.light.highlights = 10.0;
assert_eq!(step(&s), [true, true, true]);
// Amount scales the alpha
s.masks[1].adjust.amount = 50.0;
assert_eq!(step(&s), [true, false, true]);
// the image changes (white balance): the masks that read it
s.wb.mode = lightcraft_develop::WbMode::Custom;
s.wb.temp = 4000.0;
assert_eq!(step(&s), [true, false, false]);
// a shape moves: that mask only
s.masks[0].components[0].shape = MaskShape::Radial { center: Point::new(0.6, 0.5), rx: 0.3, ry: 0.2, angle: 10.0, feather: 50.0, invert: false };
assert_eq!(step(&s), [false, true, true]);
// the frame changes (a flip: same size): all of them
s.crop.flip_h = true;
assert_eq!(step(&s), [false, false, false]);
// a hidden mask's alpha is let go: only the visible masks' are held
s.masks[2].visible = false;
render_cached(&src, &info, &s, &req, &cache);
assert_eq!(cached_alphas(&cache).len(), 2);
}
/// The memory a stage cache reports covers every buffer it holds, the mask alphas and the blurred
/// chromaticity (local Noise / Moiré) included.
#[test]
fn stage_cache_bytes_count_mask_alphas_and_the_chroma_plane() {
use crate::{StageCache, render_cached};
use lightcraft_develop::{LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape};
use lightcraft_geom::Point;
use std::sync::Arc;
let src = Arc::new(scene());
let info = SourceInfo { raw: true, ..Default::default() };
let cache = StageCache::default();
let mut s = DevelopSettings::default();
let shape = MaskShape::Radial { center: Point::new(0.5, 0.5), rx: 0.3, ry: 0.3, angle: 0.0, feather: 50.0, invert: false };
s.masks.push(Mask {
id: 1,
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }],
adjust: LocalAdjustments { noise: 40.0, ..Default::default() },
..Default::default()
});
let img = render_cached(&src, &info, &s, &RenderRequest::fit(200, 200), &cache).image;
let n = img.width * img.height;
// the sampled and linear images (12 B/px each), log luminance and the texture band that local
// Noise reads (4 B/px each), the blurred chromaticity (12 B/px) and the mask's alpha (4 B/px)
assert_eq!(cache.bytes(), (12 + 12 + 4 + 4 + 12 + 4) * n);
}
#[test]
fn exposure_after_spatial_filters_equals_exposing_the_source() {
// The pipeline filters the image before exposure and applies exposure per pixel; filters on

View File

@ -150,6 +150,14 @@ walks the drive.
- Stage cache: `GpuStages` mirrors `StageCache` (same keys: source identity + `geo`, `lin_key`,
per-plane radii) and lives in it as an extension, so the UI needs no change. The uploaded source is
kept per view. A tone / colour / exposure drag re-runs one dispatch of `finish` plus the readback.
Mask alphas are cached too, on both sides (`masks::AlphaCache`, one plane per visible mask of the
last render), each keyed by `masks::alpha_key`: the mask's components, Refine Edges, Invert and
Amount, `geo`, plus `lin_key` when it reads the image (range masks, Sky / Subject / Background
heuristics, Auto Mask brushes, Refine Edges) and the exposure when it reads that (luminance and
colour range, Sky). An Exposure drag over gradient and brush masks, or a drag of a mask's own
sliders, evaluates no mask; a cached alpha is the buffer a fresh evaluation produced, so warm and
fresh renders stay bit-identical. Both caches' `bytes()` count the alphas and the blurred
chromaticity.
- One command encoder per render; the only mid-render readback is the airlight samples (dehaze, when
the dark channel is recomputed). Dropped buffers are recycled (exact size, ≤ 2 GB pool) once the
thread that dropped them has submitted — allocating and zero-filling fresh 100–300 MB buffers