Merge PR #1147
This commit is contained in:
commit
3eb3722e2c
|
|
@ -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] = [
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue