Measure the distance to the edge, and get four controls for one transform
Feathering, growing, shrinking, closing and opening are the same number read differently. With the signed distance from the boundary in hand, dilation is the set where d >= -r, erosion where d >= +r, and a feather of any shape is a function of d. So the field is computed once and the controls are arithmetic on it. The **field** is what reaches the GPU, not a finished alpha, and that is the point: growing a mask or changing its falloff then costs a uniform upload and no recomputation, which is what makes them live controls rather than ones that stall on every drag. Only closing and opening rebuild, because after the first threshold the shape has changed and the old distances describe the old one. Exact Euclidean, via Felzenszwalb's separable transform — not a chamfer approximation, which leaves a mask visibly octagonal once grown more than a few pixels. A test asserts the diagonal is √2 rather than 1 or 2. It runs on the CPU, which ARCH §5.4 forbids for masks. The rule is about brush lag — a stroke rasterised per frame — and this is a different operation: once per mask edit, on input the model already produced here, producing a field the GPU then samples for free. What it buys is exact determinism, which matters because masks reach the sidecar as indices and a field that varied by vendor would mean a mask meaning one thing on the desktop and another on the phone. The half-pixel in `signed_distance` is not a detail, and a test caught it. Measuring to the nearest opposite pixel *centre* puts the smallest magnitude at 1 either side, so the boundary is nowhere and **eroding by less than a pixel removes nothing**. A control whose first notch does nothing is a broken control. Half a pixel off each side puts the boundary where it physically is, and eroding by 1 takes exactly the outermost ring. Every falloff curve is 0.5 at the boundary by construction, asserted for all five: changing the curve should change how the transition looks and never where it sits.
This commit is contained in:
+135
-13
@@ -27,6 +27,12 @@ use crate::ParamRow;
|
||||
|
||||
/// A loaded image plus its edit state.
|
||||
pub struct DevelopSession {
|
||||
/// Kept so the session can build GPU resources after construction.
|
||||
///
|
||||
/// The distance fields behind a subject mask are made when a layer is
|
||||
/// *shaped*, not when the image opens, and cloning a `GpuContext` is two
|
||||
/// `Arc` bumps.
|
||||
ctx: GpuContext,
|
||||
graph: EditGraph,
|
||||
/// TRACES: FR-DEV-5
|
||||
/// Undo, kept beside the graph rather than in the window.
|
||||
@@ -55,8 +61,16 @@ pub struct DevelopSession {
|
||||
segmentation: Option<Segmentation>,
|
||||
/// Rasterises the mask layers. Built lazily for the same reason.
|
||||
masks: Option<MaskPass>,
|
||||
/// The recognised objects' coverage, on the GPU.
|
||||
/// One signed distance field per active subject layer, on the GPU.
|
||||
subjects: Option<dr_gpu::SubjectMasks>,
|
||||
/// What `subjects` was built from.
|
||||
///
|
||||
/// The fields are expensive — an exact distance transform over the proxy
|
||||
/// for each layer — and almost nothing changes them. Feather, falloff and
|
||||
/// simple growing are arithmetic the shader does on the field it already
|
||||
/// has, so this deliberately does *not* include them: dragging those
|
||||
/// sliders must not rebuild anything.
|
||||
subject_key: u64,
|
||||
/// Which layer the develop panel is editing, if any.
|
||||
///
|
||||
/// This is what lets one panel serve both scopes: with a layer selected,
|
||||
@@ -115,6 +129,7 @@ impl DevelopSession {
|
||||
graph.set_orientation(orientation);
|
||||
let history = History::new(&graph);
|
||||
Self {
|
||||
ctx: ctx.clone(),
|
||||
graph,
|
||||
history,
|
||||
demosaiced,
|
||||
@@ -125,6 +140,7 @@ impl DevelopSession {
|
||||
segmentation: None,
|
||||
masks: None,
|
||||
subjects: None,
|
||||
subject_key: 0,
|
||||
active_mask: None,
|
||||
show_overlay: false,
|
||||
}
|
||||
@@ -604,6 +620,96 @@ impl DevelopSession {
|
||||
/// `self.adjust` mutably while holding `self.masks` immutably. Those are
|
||||
/// disjoint fields and the borrow checker will allow it — but only when
|
||||
/// each is reached directly rather than through a method taking `self`.
|
||||
/// What the uploaded distance fields depend on.
|
||||
///
|
||||
/// The instance each layer names, and the morphology that *rebuilds* a
|
||||
/// field rather than offsetting it. Nothing else: see `subject_key`.
|
||||
fn subject_signature(&self) -> u64 {
|
||||
use dr_pipeline::mask::{MaskSource, Morphology};
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
let mut mix = |v: u64| {
|
||||
for byte in v.to_le_bytes() {
|
||||
h ^= byte as u64;
|
||||
h = h.wrapping_mul(0x1000_0000_01b3);
|
||||
}
|
||||
};
|
||||
|
||||
for layer in self.graph.masks().active() {
|
||||
match &layer.source {
|
||||
MaskSource::Subject { index, .. } => {
|
||||
mix(1);
|
||||
mix(*index as u64);
|
||||
// Only the compound operations change the field itself.
|
||||
if layer.morphology.is_compound() {
|
||||
mix(match layer.morphology {
|
||||
Morphology::Close => 2,
|
||||
Morphology::Open => 3,
|
||||
_ => 0,
|
||||
});
|
||||
mix(layer.morph_radius.to_bits() as u64);
|
||||
}
|
||||
}
|
||||
_ => mix(0),
|
||||
}
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// Rebuild the distance fields if anything they depend on moved.
|
||||
fn ensure_subject_fields(&mut self, ctx: &GpuContext) {
|
||||
use dr_pipeline::mask::MaskSource;
|
||||
|
||||
let key = self.subject_signature();
|
||||
if key == self.subject_key && self.subjects.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(seg) = self.segmentation.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let (pw, ph) = seg.proxy_size();
|
||||
|
||||
// In `active()` order, because that is the order the rasteriser walks
|
||||
// and the order it indexes these by.
|
||||
let mut fields: Vec<Vec<f32>> = Vec::new();
|
||||
for layer in self.graph.masks().active() {
|
||||
let field = match &layer.source {
|
||||
MaskSource::Subject { index, .. } => seg
|
||||
.instance_mask(*index as usize)
|
||||
.map(|coverage| {
|
||||
dr_segment::Shaped::build(
|
||||
coverage,
|
||||
pw,
|
||||
ph,
|
||||
128,
|
||||
morphology_for(layer.morphology),
|
||||
// Radii are fractions of the shorter edge; the
|
||||
// field is in proxy pixels.
|
||||
layer.morph_radius * pw.min(ph) as f32,
|
||||
)
|
||||
.distance
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
// A placeholder of the right size, so the slot indices line up
|
||||
// with `active()` whatever mix of sources the stack holds.
|
||||
_ => vec![-1.0; pw * ph],
|
||||
};
|
||||
fields.push(field);
|
||||
}
|
||||
|
||||
if fields.is_empty() {
|
||||
self.subjects = None;
|
||||
self.subject_key = key;
|
||||
return;
|
||||
}
|
||||
|
||||
let refs: Vec<&[f32]> = fields.iter().map(|f| f.as_slice()).collect();
|
||||
self.subjects = dr_gpu::SubjectMasks::upload(ctx, &refs, pw as u32, ph as u32)
|
||||
.inspect_err(|e| log::warn!("could not upload the subject fields: {e}"))
|
||||
.ok();
|
||||
self.subject_key = key;
|
||||
}
|
||||
|
||||
fn rasterise_masks(&mut self) -> bool {
|
||||
if self.graph.masks().is_neutral() {
|
||||
return false;
|
||||
@@ -661,19 +767,12 @@ impl DevelopSession {
|
||||
.ok();
|
||||
}
|
||||
|
||||
let (pw, ph) = seg.proxy_size();
|
||||
let alphas: Vec<&[u8]> = (0..seg.instances().len())
|
||||
.filter_map(|i| seg.instance_mask(i))
|
||||
.collect();
|
||||
self.subjects = if alphas.is_empty() {
|
||||
None
|
||||
} else {
|
||||
dr_gpu::SubjectMasks::upload(ctx, &alphas, pw as u32, ph as u32)
|
||||
.inspect_err(|e| log::warn!("could not upload the subject masks: {e}"))
|
||||
.ok()
|
||||
};
|
||||
|
||||
// The fields themselves are built per *layer*, on demand — there are
|
||||
// none yet, and building one per detected object would transform
|
||||
// several megapixels for masks the user may never make.
|
||||
self.segmentation = Some(seg);
|
||||
self.subjects = None;
|
||||
self.subject_key = 0;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1082,6 +1181,11 @@ impl DevelopSession {
|
||||
|
||||
// Rasterise the masks first: the shader addresses array slices by
|
||||
// index, so the array has to describe *this* stack before it is bound.
|
||||
// Any layer whose *shape* changed needs its field rebuilt before the
|
||||
// rasteriser reads it. Keyed, so a feather drag reaches neither.
|
||||
let ctx = self.ctx.clone();
|
||||
self.ensure_subject_fields(&ctx);
|
||||
|
||||
let masks = self
|
||||
.rasterise_masks()
|
||||
.then(|| self.masks.as_ref().and_then(|p| p.array()))
|
||||
@@ -2789,3 +2893,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `dr_pipeline`'s morphology, as `dr_segment` names it.
|
||||
///
|
||||
/// Two enums for one idea, and deliberately: `dr-pipeline` describes the
|
||||
/// *edit* and `dr-segment` implements the *transform*, and neither depends on
|
||||
/// the other. The crossing is this function, which the compiler makes
|
||||
/// exhaustive on both sides.
|
||||
fn morphology_for(m: dr_pipeline::mask::Morphology) -> dr_segment::Morphology {
|
||||
use dr_pipeline::mask::Morphology as Edit;
|
||||
use dr_segment::Morphology as Transform;
|
||||
match m {
|
||||
Edit::None => Transform::None,
|
||||
Edit::Dilate => Transform::Dilate,
|
||||
Edit::Erode => Transform::Erode,
|
||||
Edit::Close => Transform::Close,
|
||||
Edit::Open => Transform::Open,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,9 +84,13 @@ pub struct InstanceSummary {
|
||||
pub score: f32,
|
||||
/// The regions this instance covers, snapped to watershed boundaries.
|
||||
///
|
||||
/// Empty without a watershed, which is the default. Kept because it is
|
||||
/// the mechanism that would give a model outline the image's own edge, if
|
||||
/// the hierarchy underneath it is ever made to work.
|
||||
/// Empty without a watershed, which is the default, and unread by anything
|
||||
/// today. Kept — with the lint silenced rather than the field deleted —
|
||||
/// because it is the mechanism that would give a model's outline the
|
||||
/// image's own edge, and that is the point of the whole exercise if the
|
||||
/// hierarchy underneath it is ever made to work. Deleting it would mean
|
||||
/// rediscovering `regions_for_instance` from the git history.
|
||||
#[allow(dead_code)]
|
||||
pub regions: Vec<u32>,
|
||||
/// Coverage at proxy resolution, quantised to a byte.
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user