Merge branch 'worktree-agent-a22a049c461818dbe' into integration
# Conflicts: # core/dr-pipeline/tests/mask_sidecar.rs
This commit is contained in:
+206
-12
@@ -1020,26 +1020,63 @@ impl DevelopSession {
|
||||
//
|
||||
// It also means the array does not reallocate when the window
|
||||
// resizes, and does not need redrawing when the view moves.
|
||||
let Some(seg) = self.segmentation.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
let (pw, ph) = seg.proxy_size();
|
||||
let (pw, ph) = self.mask_raster_size();
|
||||
let subjects = self.subjects.as_ref();
|
||||
|
||||
// **Built here, not in `segment`.** A gradient needs no segmentation —
|
||||
// a graduated filter over a sky never had to know what a sky is — but
|
||||
// the rasteriser was only ever constructed on the way out of one, so
|
||||
// adding a gradient to a photograph nobody had segmented produced an
|
||||
// array that was never rasterised and a layer that drew nothing at
|
||||
// all. Silently: the generated shader still emits the layer's block
|
||||
// and the empty placeholder multiplies it by zero, which is the same
|
||||
// failure exports and thumbnails had.
|
||||
//
|
||||
// The shader compile this costs is paid once, on the first frame after
|
||||
// the first mask is added — a button press, not a frame anyone is
|
||||
// dragging through. `is_neutral` above is what keeps it off the path
|
||||
// of every photograph that has no local adjustment at all.
|
||||
if self.masks.is_none() {
|
||||
let ctx = self.ctx.clone();
|
||||
self.masks = dr_gpu::MaskPass::new(&ctx)
|
||||
.inspect_err(|e| log::warn!("no mask rasteriser on this device: {e}"))
|
||||
.ok();
|
||||
}
|
||||
let Some(pass) = self.masks.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
// No label field: region masks were the watershed's, and nothing
|
||||
// produces one any more. A stored layer that still names regions is
|
||||
// skipped by the rasteriser rather than drawn wrong.
|
||||
pass.render(
|
||||
self.graph.masks(),
|
||||
None,
|
||||
subjects,
|
||||
pw as u32,
|
||||
ph as u32,
|
||||
pass.render(self.graph.masks(), None, subjects, pw, ph)
|
||||
.inspect_err(|e| log::warn!("mask rasterisation failed: {e}"))
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// The size the mask array is rasterised at, in source space.
|
||||
///
|
||||
/// **A property of the photograph, not of the segmentation.** The two come
|
||||
/// out the same because both are the source scaled to `SEGMENT_PROXY_EDGE`,
|
||||
/// and they have to: a subject layer's distance field is built at the
|
||||
/// segmentation's proxy and sampled against this array, so the two sizes
|
||||
/// agreeing is a requirement rather than a coincidence. Reading the size
|
||||
/// *off* the segmentation is what made it look like a dependency, and made
|
||||
/// a gradient — which indexes nothing — wait for a model to run.
|
||||
///
|
||||
/// The aspect must be the source's either way. A gradient's geometry is
|
||||
/// measured against the frame's own proportions, so a square array over a
|
||||
/// 3:2 photograph would stretch every circle it drew.
|
||||
fn mask_raster_size(&self) -> (u32, u32) {
|
||||
if let Some(seg) = self.segmentation.as_ref() {
|
||||
let (pw, ph) = seg.proxy_size();
|
||||
return (pw as u32, ph as u32);
|
||||
}
|
||||
let (sw, sh) = self.demosaiced.size();
|
||||
let scale = (SEGMENT_PROXY_EDGE as f32 / sw.max(sh).max(1) as f32).min(1.0);
|
||||
(
|
||||
((sw as f32 * scale) as u32).max(1),
|
||||
((sh as f32 * scale) as u32).max(1),
|
||||
)
|
||||
.inspect_err(|e| log::warn!("mask rasterisation failed: {e}"))
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
@@ -1245,6 +1282,71 @@ impl DevelopSession {
|
||||
self.active_mask.as_deref()
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3 | FR-UI-3
|
||||
/// The selected gradient's handles, in fractions of the shown image.
|
||||
///
|
||||
/// Empty unless a gradient layer is selected, which is what makes this the
|
||||
/// panel's whole test for "is there anything to draw on the canvas".
|
||||
///
|
||||
/// Recomputed on every redraw rather than cached, because the answer
|
||||
/// changes with the *view* and not only with the mask: a pan moves every
|
||||
/// handle and touches no geometry. Four handles through an affine map is
|
||||
/// not work worth caching, and a cache keyed on the wrong thing is how a
|
||||
/// handle comes to sit where the mask used to be.
|
||||
pub fn gradient_handles(&self) -> Vec<crate::GradientHandle> {
|
||||
let Some(layer) = self.active_layer() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let (sw, sh) = self.demosaiced.size();
|
||||
crate::gradient::handles(&layer.source, self.graph.framing(), (sw, sh))
|
||||
}
|
||||
|
||||
/// Drag one handle of the selected gradient, from `press` to `now`, both
|
||||
/// in fractions of the shown image.
|
||||
///
|
||||
/// `origin` is the geometry the gesture started from — see
|
||||
/// [`crate::gradient::drag`] for why a drag is applied to that rather than
|
||||
/// accumulated. Returns it, so the caller can hold it for the rest of the
|
||||
/// gesture; `None` when there is no gradient selected to drag.
|
||||
pub fn drag_gradient_handle(
|
||||
&mut self,
|
||||
role: crate::HandleRole,
|
||||
origin: Option<&MaskSource>,
|
||||
press: (f32, f32),
|
||||
now: (f32, f32),
|
||||
) -> Option<MaskSource> {
|
||||
let (sw, sh) = self.demosaiced.size();
|
||||
let framing = *self.graph.framing();
|
||||
let id = self.active_mask.clone()?;
|
||||
let start = match origin {
|
||||
Some(s) => s.clone(),
|
||||
None => self.graph.masks().get(&id)?.source.clone(),
|
||||
};
|
||||
|
||||
let moved = crate::gradient::drag(&start, role, press, now, &framing, (sw, sh));
|
||||
self.graph.masks_mut().get_mut(&id)?.source = moved;
|
||||
// **Nothing recorded here.** A drag delivers a pointer event a frame,
|
||||
// and a history step per frame would make undo walk a gesture back
|
||||
// pixel by pixel. `Edit` coalesces by operation id and a mask's shape
|
||||
// is not an operation, so there is no key to coalesce under — the
|
||||
// honest answer is to record once, on release.
|
||||
Some(start)
|
||||
}
|
||||
|
||||
/// A handle drag finished: one history step for the whole gesture.
|
||||
///
|
||||
/// Called on the pointer's release rather than on each move, which is what
|
||||
/// makes a drag one decision in the undo stack however many frames it took.
|
||||
pub fn commit_gradient_drag(&mut self) {
|
||||
self.history.record(&self.graph, Edit::Discrete);
|
||||
}
|
||||
|
||||
/// The selected layer's mask rule, for a caller that has to remember what
|
||||
/// a gesture started from.
|
||||
pub fn active_mask_source(&self) -> Option<MaskSource> {
|
||||
self.active_layer().map(|l| l.source.clone())
|
||||
}
|
||||
|
||||
/// Select a layer for editing, or `None` to return the panel to the
|
||||
/// global chain.
|
||||
pub fn set_active_mask(&mut self, id: Option<&str>) {
|
||||
@@ -2283,6 +2385,98 @@ mod tests {
|
||||
pollster::block_on(dr_gpu::GpuContext::new_headless()).ok()
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// A gradient needs no segmentation, and until now it silently got no mask.
|
||||
///
|
||||
/// The rasteriser was built on the way out of `segment`, so a gradient
|
||||
/// added to a photograph nobody had segmented had nothing to draw it — and
|
||||
/// the failure was invisible from every side. The generated shader still
|
||||
/// emits the layer's block, the empty placeholder multiplies it by zero,
|
||||
/// and the result is a well-formed frame with the local adjustment simply
|
||||
/// absent. No error, no warning, and nothing on screen to tell it apart
|
||||
/// from a mask the user had placed badly.
|
||||
///
|
||||
/// A graduated filter over a sky never had to know what a sky is, so the
|
||||
/// dependency was wrong as well as silent.
|
||||
#[test]
|
||||
fn a_gradient_renders_on_a_photograph_nobody_has_segmented() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
|
||||
// Mid grey, so a brightening layer is unambiguous either way.
|
||||
let rgba: Vec<u8> = (0..64 * 64).flat_map(|_| [128u8, 128, 128, 255]).collect();
|
||||
let mut session =
|
||||
DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL)
|
||||
.expect("session");
|
||||
assert!(
|
||||
!session.has_segmentation(),
|
||||
"the point of the test is that there is none"
|
||||
);
|
||||
|
||||
let before = read_back(&ctx, &session.render(64, 64).expect("render"));
|
||||
|
||||
// A radial over the middle, brightened hard. Addressed by index, so
|
||||
// this names no operation (FR-DEV-3a).
|
||||
session.add_gradient_mask(true).expect("a radial");
|
||||
let row = session.rows()[0].clone();
|
||||
session.set_param(row.op_index, row.param_index, row.maximum);
|
||||
|
||||
let after = read_back(&ctx, &session.render(64, 64).expect("render"));
|
||||
|
||||
let centre = |px: &[u8]| px[((32 * 64 + 32) * 4) as usize];
|
||||
assert!(
|
||||
centre(&after) > centre(&before) + 20,
|
||||
"the middle of the frame must brighten: {} against {}",
|
||||
centre(&after),
|
||||
centre(&before)
|
||||
);
|
||||
// And only the middle: a mask that failed to rasterise the other way —
|
||||
// covering everything — would pass the assertion above.
|
||||
let corner = |px: &[u8]| px[0];
|
||||
assert_eq!(
|
||||
corner(&after),
|
||||
corner(&before),
|
||||
"the corner is outside the radial and must not move"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// The mask array and the segmentation proxy are the same size on purpose.
|
||||
///
|
||||
/// They have to be: a subject layer's distance field is built at the
|
||||
/// segmentation's proxy resolution and sampled against the array, so if the
|
||||
/// two ever diverged a subject mask would be drawn at the wrong scale —
|
||||
/// a mask that is confidently in the wrong place, which is worse than none.
|
||||
///
|
||||
/// It used to hold because the array's size was *read off* the
|
||||
/// segmentation, which also made a gradient wait for a model it does not
|
||||
/// use. Deriving both from the photograph keeps the agreement and drops the
|
||||
/// dependency, and this is what stops the agreement being an accident.
|
||||
#[test]
|
||||
fn the_mask_array_is_the_size_the_segmentation_will_use() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
|
||||
let rgba: Vec<u8> = (0..100 * 100)
|
||||
.flat_map(|_| [128u8, 128, 128, 255])
|
||||
.collect();
|
||||
let mut session =
|
||||
DevelopSession::open_rgb(&ctx, &rgba, 100, 100, dr_types::Orientation::NORMAL)
|
||||
.expect("session");
|
||||
|
||||
let before = session.mask_raster_size();
|
||||
if session
|
||||
.segment(&ctx, &crate::segmentation::Options::default())
|
||||
.is_err()
|
||||
{
|
||||
eprintln!("no model; skipping");
|
||||
return;
|
||||
}
|
||||
assert_eq!(
|
||||
before,
|
||||
session.mask_raster_size(),
|
||||
"a mask rasterised before the model ran must not move when it does"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unzoomed_overlay_shows_the_whole_frame() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
|
||||
Reference in New Issue
Block a user