Put the refinement on a slider, per layer
Benchmarks / CPU and I/O (per commit) (push) Successful in 3m27s
Benchmarks / Frame budget (on demand) (push) Skipped
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Desktop (Linux) (push) Failing after 1h14m30s
Build and test / Layer separation (push) Successful in 43s
Traceability / Requirement traces (push) Failing after 46s
Build and test / Android (aarch64) (push) Successful in 1h8m56s

The evidence was being gathered and spent immediately at one strictness
nobody could see or change. This makes it a control.

`MaskLayer::refine` is shaping, like the feather, and it lives on the layer
for the layer's reason: two layers may sit on the same category and want
different amounts of it, and the model ran once for both.

## What the segmentation now stores

`CategorySummary` keeps the **coarse** mask and the `Refinement` beside it,
rather than a refined mask. That is what gives the control an off position
that is bit-for-bit the model's own weighting, and what stops a strictness
change from needing the model again.

`category_mask_at` borrows at zero and wherever no refinement could be
fitted, so a layer nobody has touched costs nothing over the old path.

The fit moved to the far side of the orientation permutation. The verdict is
a per-pixel field over the same grid as the mask it gates, so fitting it
upright would mean permuting a proxy-sized buffer afterwards to match — a
second rotation, and a second chance to get one wrong. One logit cell is the
same number of pixels either way: the letterbox scales by the longer edge and
a permutation does not change which edge that is.

The two frames became a `Frames` struct rather than six parameters. This
function reads the picture twice for opposite purposes — the model needs it
upright or it recognises far less, the refinement needs the sensor's grid —
and a transposed pair produces a plausible mask over slightly the wrong
pixels, which is the failure this module is most prone to.

## It rebuilds the field, and the signature says so

`refine` is mixed into `subject_signature`. Unlike a feather, which is read
off a field that is already correct, this changes which pixels are in the
mask at all — so it changes the coverage the field is measured from. Omitting
it is the bug where the slider moves and nothing happens until some unrelated
control invalidates the cache.

That puts it in the same cost class as a close or an open, which is why the
row takes `SliderRow::changed` — already once-per-gesture, since that row
takes `SliderTrack`'s `committed` internally — rather than a live stream.

The slider is offered only where there is something to move: a category
source, *and* a refinement the frame actually gave enough to fit. A control
that moves and does nothing is worse than an absent one.

## Two defaults that are deliberately different

A layer added from the panel starts at 4.0, because a category's edges are
twenty proxy pixels wide before anything is done to them and a photographer
adding a sky mask wants the sky rather than the sky plus every chimney in it.

A layer read from a sidecar with no `refine` key starts at **zero**. A file
written before this control existed has to render as it did then, and a
default of 4 on absence would quietly re-grade every stored category mask in
the catalogue. `a_categorys_refine_strictness_survives_and_defaults_off`
holds both halves, and `an_out_of_range_refine_is_clamped` holds the file to
the scale — past the top of it every colour fails and the mask deletes
itself, which reads as lost work rather than as a bad file.

`MAX_REFINE` is dr-pipeline's own constant mirroring
`dr_segment::STRICTNESS_MAX`, following `Falloff` and `Morphology`: this
crate holds the description of an edit and must not depend on the crate that
runs a model. dr-ui is where the two meet, and the only place that converts.

Verified: fmt clean, clippy --workspace -D warnings clean, 488 dr-pipeline
and 60 dr-segment tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 18:30:40 +02:00
co-authored by Claude Opus 5
parent f87bf6ebc0
commit 4efab496c2
9 changed files with 413 additions and 47 deletions
+36
View File
@@ -49,6 +49,19 @@ use crate::ops;
/// be a literal in one.
pub const DEFAULT_FEATHER: f32 = 0.004;
/// The most a layer's [`MaskLayer::refine`] may be, in nats.
///
/// Mirrors `dr_segment::STRICTNESS_MAX`, and is a separate constant for the
/// same reason [`Falloff`] and [`Morphology`] are separate types from that
/// crate's: this one holds the *description* of an edit and must not depend on
/// the crate that runs a model. `dr-ui` is where the two meet, and it is the
/// only place that converts between them.
///
/// Used here to bound what a sidecar is allowed to claim. A file is not
/// trusted, and a strictness off the end of the scale would render as a
/// category that had silently deleted itself.
pub const MAX_REFINE: f32 = 8.0;
/// The most points one stroke keeps before a gesture continues as a new one.
///
/// This is a *cost* bound, not a storage one. Each stroke is drawn over its own
@@ -685,6 +698,23 @@ pub struct MaskLayer {
pub morphology: Morphology,
/// How far, in the same units as [`Self::feather`].
pub morph_radius: f32,
/// How strictly a category source is cut back to the pixels whose colour
/// agrees with it, in nats. Zero leaves the model's own weighting alone.
///
/// Only [`MaskSource::Category`] has anything to apply it to — the
/// evidence is fitted per category during segmentation — so it sits at
/// zero and out of the panel for every other source.
///
/// A *quantity*, not a 0-to-100: it is how much more plausible than the
/// alternatives a colour must be before its weight is kept, which is why
/// `dr_segment::refine` can state where a flag falls on it and where a
/// cloud does. Bounded by [`MAX_REFINE`].
///
/// This is shaping, like [`Self::feather`], and it lives on the layer for
/// the same reason: two layers may sit on the same category and want
/// different amounts of it, and the model ran once for both.
pub refine: f32,
/// This layer's adjustments.
///
/// A full chain, the same one [`crate::EditGraph`] holds. That is the
@@ -741,6 +771,7 @@ impl Clone for MaskLayer {
falloff: self.falloff,
morphology: self.morphology,
morph_radius: self.morph_radius,
refine: self.refine,
ops,
}
}
@@ -797,6 +828,11 @@ impl MaskLayer {
falloff: Falloff::default(),
morphology: Morphology::default(),
morph_radius: 0.0,
// Off, so a layer built here is the model's own weighting. The
// useful starting point for a *category* is not zero, but it is
// `dr_segment`'s number to state and this crate does not depend on
// it — `Session::add_category_mask` sets it on the way in.
refine: 0.0,
ops: layer_chain(),
}
}
+17
View File
@@ -1035,6 +1035,11 @@ fn write_mask(out: &mut String, version: &str, layer: &MaskLayer) {
let _ = writeln!(out, "morphology = {}", layer.morphology.name());
let _ = writeln!(out, "morph-radius = {}", format_value(layer.morph_radius));
}
// Absent means zero, which is the model's own weighting — so a file
// written before this control existed reads back looking exactly as it did.
if layer.refine != 0.0 {
let _ = writeln!(out, "refine = {}", format_value(layer.refine));
}
for (op, param, value) in layer.params() {
let _ = writeln!(out, "{op}.{param} = {}", format_value(value));
}
@@ -1144,6 +1149,7 @@ struct PartialMask {
falloff: Falloff,
morphology: Morphology,
morph_radius: f32,
refine: f32,
strokes: Vec<Stroke>,
params: Vec<(String, String, f32)>,
}
@@ -1174,6 +1180,7 @@ impl PartialMask {
falloff: Falloff::default(),
morphology: Morphology::default(),
morph_radius: 0.0,
refine: 0.0,
strokes: Vec::new(),
params: Vec::new(),
}
@@ -1227,6 +1234,15 @@ impl PartialMask {
"morph-radius" => {
self.morph_radius = value.parse::<f32>().unwrap_or(0.0).clamp(0.0, 1.0)
}
// Clamped for the same reason the feather is: a strictness off the
// end of the scale is a category that has deleted itself, and a
// file is not trusted to ask for that.
"refine" => {
self.refine = value
.parse::<f32>()
.unwrap_or(0.0)
.clamp(0.0, crate::mask::MAX_REFINE)
}
// An unrecognised name falls back to the default rather than
// dropping the layer. A newer build's falloff curve is a cosmetic
// difference in the edge; losing the selection under it would not
@@ -1304,6 +1320,7 @@ impl PartialMask {
layer.falloff = self.falloff;
layer.morphology = self.morphology;
layer.morph_radius = self.morph_radius;
layer.refine = self.refine;
for (op, param, value) in &self.params {
// `ParamId` holds a `&'static str` and this one came off disk, so
// it is matched against the descriptors and the *static* id is
+101
View File
@@ -660,6 +660,107 @@ fn category_masks_keep_their_name() {
assert!((layers[0].feather - 0.03).abs() < 1e-6);
}
/// The refine strictness survives, and its absence means zero.
///
/// Both halves matter and they are different claims. A dropped strictness
/// would reopen a sky the photographer had closed, silently, on the next
/// launch — the mask would still be there and would simply have got the
/// chimneys back.
///
/// And a *missing* key has to read as zero rather than as the default the
/// panel starts a new layer at, because that is what makes a file written
/// before this control existed render exactly as it did then. A default of 4
/// on absence would quietly re-grade every stored category mask in the
/// catalogue.
#[test]
fn a_categorys_refine_strictness_survives_and_defaults_off() {
let mut graph = EditGraph::default_chain();
let mut sky = MaskLayer::new(
"m1",
MaskSource::Category {
signature: 0x5EED_1234,
name: "sky".into(),
},
);
sky.refine = 3.7;
graph.masks_mut().push(sky);
// Left at zero, so the writer omits the key entirely — which is the case
// the second half of this test is really about.
let plain = MaskLayer::new(
"m2",
MaskSource::Category {
signature: 0x5EED_1234,
name: "water".into(),
},
);
graph.masks_mut().push(plain);
let text = {
let mut sidecar = Sidecar::new();
sidecar.put(Version::from_graph("default", "Default", &graph));
sidecar.to_text()
};
assert_eq!(
text.matches("refine =").count(),
1,
"only the layer that set one should write the key:\n{text}"
);
let layers = round_trip(&graph);
let layers = layers.masks().layers();
assert!(
(layers[0].refine - 3.7).abs() < 1e-6,
"got {}",
layers[0].refine
);
assert_eq!(
layers[1].refine, 0.0,
"an absent key is the model's own mask"
);
}
/// A file is not trusted to ask for a strictness off the end of the scale.
///
/// Past the top of the range every colour fails the test, so the mask deletes
/// itself — which reads as a lost edit rather than as a bad file.
#[test]
fn an_out_of_range_refine_is_clamped() {
let mut graph = EditGraph::default_chain();
let mut sky = MaskLayer::new(
"m1",
MaskSource::Category {
signature: 0x5EED_1234,
name: "sky".into(),
},
);
sky.refine = 3.0;
graph.masks_mut().push(sky);
// Edited in the written text rather than assembled by hand, so this test
// cannot drift out of step with the format the writer actually emits.
let mut sidecar = Sidecar::new();
sidecar.put(Version::from_graph("default", "Default", &graph));
let text = sidecar.to_text().replace("refine = 3", "refine = 900");
assert!(text.contains("refine = 900"), "the edit must have applied");
let parsed = Sidecar::parse(&text).expect("reparse");
let mut restored = EditGraph::default_chain();
parsed
.versions
.get("default")
.expect("version survived")
.apply(&mut restored)
.expect_no_film();
let refine = restored.masks().layers()[0].refine;
assert!(
refine <= dr_pipeline::mask::MAX_REFINE,
"a file asked for 900 and got {refine}"
);
}
/// A category is stale when its run is, exactly as a subject is.
///
/// The coverage buffer lives in the session, not the sidecar, so a layer whose
+70 -2
View File
@@ -1821,6 +1821,13 @@ impl DevelopSession {
});
mix(layer.morph_radius.to_bits() as u64);
}
// The refine control changes which pixels are in the mask
// at all, so it changes the coverage the field is measured
// from — unlike a feather, which is read off a field that
// is already correct. Omitting it here is the bug where
// the slider moves and nothing happens until some other
// control happens to invalidate the cache.
mix(layer.refine.to_bits() as u64);
}
_ => mix(0),
}
@@ -1848,10 +1855,10 @@ impl DevelopSession {
for layer in self.graph.masks().active() {
let field = match &layer.source {
MaskSource::Category { name, .. } => seg
.category_mask(name)
.category_mask_at(name, layer.refine)
.map(|coverage| {
dr_segment::Shaped::build(
coverage,
&coverage,
pw,
ph,
128,
@@ -2659,6 +2666,24 @@ impl DevelopSession {
},
);
layer.name = name.to_string();
// Refined from the start, where there is anything to refine with.
//
// A category's edges are twenty proxy pixels wide before this runs, so
// the unrefined mask is the wrong default for the common case — a
// photographer adding a sky mask wants the sky, not the sky plus every
// chimney in it. Zero is still one drag away, and it is exactly the
// model's own weighting when they get there.
//
// Set here rather than in `MaskLayer::new` because the number belongs
// to `dr_segment` and `dr-pipeline` does not depend on it — see
// `dr_pipeline::mask::MAX_REFINE`.
if self
.segmentation
.as_ref()
.is_some_and(|seg| seg.category_is_refinable(name))
{
layer.refine = dr_segment::STRICTNESS_DEFAULT;
}
if !self.graph.masks_mut().push(layer) {
return None;
}
@@ -2774,6 +2799,22 @@ impl DevelopSession {
}
}
/// How strictly this layer's category is cut back to the pixels whose
/// colour agrees with it.
///
/// Unlike the feather, this changes the mask's *shape*, so the distance
/// field has to be rebuilt — the same class of cost as a close or an open
/// (`dr_segment::Morphology::needs_recompute`), and the reason it is in
/// `subject_signature`. It still runs no model: the evidence was fitted
/// during segmentation and this is a smoothstep over it.
pub fn set_mask_refine(&mut self, id: &str, refine: f32) {
if let Some(layer) = self.graph.masks_mut().get_mut(id) {
layer.refine = refine.clamp(0.0, dr_pipeline::mask::MAX_REFINE);
self.history
.record(&self.graph, Edit::Control(labels::step::MASK_REFINE));
}
}
pub fn set_mask_falloff(&mut self, id: &str, index: usize) {
use dr_pipeline::mask::Falloff;
let Some(&falloff) = Falloff::ALL.get(index) else {
@@ -2840,6 +2881,33 @@ impl DevelopSession {
self.graph.masks().get(id).map_or(0.0, |l| l.morph_radius)
}
pub fn mask_refine(&self, id: &str) -> f32 {
self.graph.masks().get(id).map_or(0.0, |l| l.refine)
}
/// Whether this layer has a refinement to act on.
///
/// Two conditions, and both are needed. The source must be a category —
/// nothing else has a colour model fitted for it — and the segmentation
/// must actually have fitted one, which it cannot for a category that is
/// everywhere thinner than the model's own resolution or that fills the
/// whole frame.
///
/// Asked by the panel before it draws the slider, because a control that
/// moves and does nothing is worse than an absent one.
pub fn mask_is_refinable(&self, id: &str) -> bool {
use dr_pipeline::mask::MaskSource;
let Some(layer) = self.graph.masks().get(id) else {
return false;
};
let MaskSource::Category { name, .. } = &layer.source else {
return false;
};
self.segmentation
.as_ref()
.is_some_and(|seg| seg.category_is_refinable(name))
}
/// Whether the edge controls apply to this layer.
///
/// Only sources that go through the distance field. A gradient carries its
+3
View File
@@ -48,6 +48,7 @@ pub mod step {
pub const MASK_OPACITY: LocalizedKey = LocalizedKey("history.mask_opacity");
pub const MASK_FALLOFF: LocalizedKey = LocalizedKey("history.mask_falloff");
pub const MASK_MORPHOLOGY: LocalizedKey = LocalizedKey("history.mask_morphology");
pub const MASK_REFINE: LocalizedKey = LocalizedKey("history.mask_refine");
/// TRACES: FR-DEV-8
/// A repair's own settings changed — its radius, its source, its opacity.
@@ -90,6 +91,7 @@ pub mod step {
MASK_OPACITY,
MASK_FALLOFF,
MASK_MORPHOLOGY,
MASK_REFINE,
SPOT,
SPOT_PLACED,
SPOT_MOVED,
@@ -251,6 +253,7 @@ fn catalogued(key: &str) -> Option<&'static str> {
"history.mask_opacity" => "Mask Opacity",
"history.mask_falloff" => "Mask Falloff",
"history.mask_morphology" => "Mask Grow/Shrink",
"history.mask_refine" => "Mask Refine",
"history.spot" => "Adjust Repair",
"history.spot_placed" => "Add Repair",
"history.spot_moved" => "Move Repair",
+22
View File
@@ -188,6 +188,8 @@ pub(crate) fn sync(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSessio
falloff: s.mask_falloff(&id) as i32,
morphology: s.mask_morphology(&id) as i32,
morph_radius: s.mask_morph_radius(&id),
refine: s.mask_refine(&id),
refinable: s.mask_is_refinable(&id),
shapeable: s.mask_is_shapeable(&id),
id: id.into(),
label: label.into(),
@@ -609,6 +611,26 @@ pub(crate) fn wire(
redraw(&w);
});
}
// The refine control. Same shape as the feather's handler, and a separate
// one for the same reason every other mask control has its own: the
// callback carries the layer id, so there is nothing to share.
//
// `SliderRow::changed` fires once per completed gesture rather than once
// per movement, which is what this control needs — it rebuilds the
// layer's distance field, where a feather only re-reads one.
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_mask_refine_changed(move |id, value| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_mask_refine(&id, value);
}
sync(&w, &session);
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
+128 -45
View File
@@ -30,6 +30,7 @@
//! develop session, and [`crate::develop::SegmentationJob`] is the piece that
//! carries the proxy render across with it.
use std::borrow::Cow;
use std::sync::Arc;
use dr_gpu::GpuContext;
@@ -50,7 +51,21 @@ pub struct CategorySummary {
pub coverage: f32,
/// Coverage at proxy resolution, quantised to a byte — the same
/// representation, and for the same reasons, as `InstanceSummary::mask`.
///
/// **The model's own weighting, before any refinement.** Storing the
/// coarse mask rather than a refined one is what lets the refine control
/// have an off position that is exactly the old behaviour, and what stops
/// a strictness change from needing the model run again.
pub mask: Vec<u8>,
/// The evidence for cutting this category back to the pixels whose colour
/// agrees with it, when there was enough of the frame to gather any.
///
/// `None` is ordinary: a category thinner everywhere than the model's own
/// resolution, or one filling the whole frame, gives nothing to contrast
/// against — `dr_segment::refine` says which. A layer on such a category
/// simply has no refine control, which is the honest presentation of
/// "there was nothing to judge it with".
pub refinement: Option<dr_segment::Refinement>,
}
/// One recognised object.
@@ -130,6 +145,37 @@ impl Segmentation {
.map(|c| c.mask.as_slice())
}
/// One category's coverage, cut back at the given strictness.
///
/// What the rasteriser reads. Borrowed at
/// [`dr_segment::STRICTNESS_OFF`] and wherever no refinement could be
/// fitted, so the common case — a layer whose control has not been moved —
/// costs nothing at all over [`Self::category_mask`].
///
/// The strictness is the layer's, not the segmentation's: two layers may
/// sit on the same category and want different amounts of it, and the
/// model ran once for both.
pub fn category_mask_at(&self, name: &str, strictness: f32) -> Option<Cow<'_, [u8]>> {
let category = self.categories.iter().find(|c| &*c.name == name)?;
match &category.refinement {
Some(refinement) if strictness > dr_segment::STRICTNESS_OFF => Some(Cow::Owned(
refinement.apply_coverage(&category.mask, strictness),
)),
_ => Some(Cow::Borrowed(category.mask.as_slice())),
}
}
/// Whether this category has evidence a refine control could act on.
///
/// The panel asks before offering the slider: a control that moves and
/// does nothing is worse than an absent one.
pub fn category_is_refinable(&self, name: &str) -> bool {
self.categories
.iter()
.find(|c| &*c.name == name)
.is_some_and(|c| c.refinement.is_some())
}
/// Replace one instance in place, keeping every other index and the
/// signature unchanged.
///
@@ -273,19 +319,23 @@ pub struct Options {
pub fine: bool,
/// TRACES: FR-DEV-3
/// Cut each scene category back to the pixels whose colour agrees with it.
/// Gather the evidence for cutting each scene category back to the pixels
/// whose colour agrees with it.
///
/// The scene model's counterpart to [`Options::fine`], and it exists
/// because tiling is not available here: a category has no bounding box to
/// tile over — sky is wherever the sky is — so the only route to a sharper
/// category edge is the photograph itself. See [`dr_segment::refine`].
///
/// This buys the *possibility* of refinement rather than any of it. What
/// it produces is a `dr_segment::Refinement` per category, which changes
/// no mask until a layer's refine control is moved off zero — so turning
/// this off costs the control, not the appearance of anything.
///
/// On by default where `fine` is off, because the two have opposite costs.
/// Tiling is six inferences and a five-second wait; this is a distance
/// transform and a k-means over a subsample, tens of milliseconds against
/// a precompute already measured in hundreds. And it is subtractive, so
/// the worst it can do is take too much of a category rather than invent
/// one.
/// transform and a k-means over a subsample per category, tens of
/// milliseconds each against a precompute already measured in hundreds.
pub refine: bool,
}
@@ -370,7 +420,16 @@ pub fn compute(
// way. Failures here are logged and dropped rather than propagated: no
// scene model is an ordinary state, and a photograph that can be masked by
// subject should not become unopenable because the categories are absent.
let categories = match scene_categories(&stood_up, uw, uh, orientation, options.refine) {
let categories = match scene_categories(
&Frames {
upright: &stood_up,
upright_size: (uw, uh),
sensor: rgb,
sensor_size: (width, height),
},
orientation,
options.refine,
) {
Ok(c) => c,
Err(e) => {
log::info!("no scene categories for this frame: {e}");
@@ -545,6 +604,27 @@ fn detect(
.map_err(|e| e.to_string())
}
/// The one photograph, in the two frames this pass needs it in.
///
/// Both, and not one plus a permutation applied where needed, because this
/// function reads the picture *twice* for different purposes and the two want
/// opposite frames. The model must be shown an upright photograph or it
/// recognises far less (see [`upright`]); the refinement must read the sensor's
/// own grid, because the field it produces has to line up pixel for pixel with
/// a mask that has already been laid back down.
///
/// Carried as a struct rather than six parameters so that a caller cannot
/// quietly transpose the pair — which would produce a plausible mask over
/// slightly the wrong pixels, the failure this whole module is most prone to.
struct Frames<'a> {
/// As the photographer sees it. What the model reads.
upright: &'a [f32],
upright_size: (usize, usize),
/// As the sensor wrote it. The space every stored mask lives in.
sensor: &'a [f32],
sensor_size: (usize, usize),
}
/// Weigh the photographic categories, if this build can find a scene model.
///
/// Separate from [`detect`] rather than folded into it because the two are
@@ -552,15 +632,16 @@ fn detect(
/// frame with no recognisable subject still has sky. Neither failure should
/// take the other down.
fn scene_categories(
rgb: &[f32],
width: usize,
height: usize,
frames: &Frames<'_>,
orientation: Orientation,
refine: bool,
) -> Result<Vec<CategorySummary>, String> {
let (upright, (width, height)) = (frames.upright, frames.upright_size);
let (sensor, (sensor_width, sensor_height)) = (frames.sensor, frames.sensor_size);
let mut model = load_scene_model()?;
let scene = model
.analyse(rgb, width, height)
.analyse(upright, width, height)
.map_err(|e| e.to_string())?;
let mut out = Vec::new();
@@ -577,41 +658,6 @@ fn scene_categories(
continue;
};
// Cut the category back to the pixels whose colour agrees with it.
//
// Here rather than after `lay_down` because this reads the photograph,
// and the photograph is upright at this point — refining on the far
// side of the permutation would mean carrying a second, rotated copy
// of the proxy across for it to read.
let weights = if refine {
let (refined, what) = dr_segment::refine_category(
&weights,
rgb,
width,
height,
scene.cell_pixels(),
&dr_segment::RefineOptions::default(),
);
match what {
dr_segment::Refined::Applied { removed } => {
log::debug!(
"refined '{name}': cut {:.1}% of its weight",
removed * 100.0
);
}
// An ordinary state, not a failure — see `dr_segment::refine`.
// Logged all the same, because a refinement that silently did
// nothing is indistinguishable from the feature being off, and
// "the sky looks the way it did before" is what both look like.
dr_segment::Refined::Skipped(why) => {
log::debug!("left '{name}' coarse: {why:?}");
}
}
refined
} else {
weights
};
// Back into sensor space, exactly as an instance mask is: the grid a
// stored layer indexes into has to be the sensor's whatever the model
// was shown. `lay_down` wants a box too, so it gets the whole frame —
@@ -623,10 +669,47 @@ fn scene_categories(
height,
orientation,
);
// Gather the evidence for cutting this category back, on the far side
// of the permutation.
//
// Sensor space rather than the model's, and that is the whole reason
// this happens here: the verdict is a per-pixel field over the same
// grid as the mask it gates, so fitting it upright would mean
// permuting it afterwards to match — a second rotation of a
// proxy-sized buffer, and a second chance to get one wrong.
//
// One logit cell is the same number of pixels in either frame: the
// letterbox scales by the *longer* edge, and a permutation does not
// change which edge that is.
let refinement = refine
.then(|| {
dr_segment::Refinement::compute(
&mask,
sensor,
sensor_width,
sensor_height,
scene.cell_pixels(),
&dr_segment::RefineOptions::default(),
)
})
.and_then(|result| match result {
Ok(refinement) => Some(refinement),
// An ordinary state, not a failure — see `dr_segment::refine`.
// Logged all the same, because the layer will simply have no
// refine control and "there is no slider" should be traceable
// to a reason rather than looking like a missing feature.
Err(why) => {
log::debug!("no refinement for '{name}': {why:?}");
None
}
});
out.push(CategorySummary {
name: name.clone(),
coverage,
mask: quantise(&mask),
refinement,
});
}
+4
View File
@@ -1064,6 +1064,7 @@ export component AppWindow inherits Window {
callback mask-invert-toggled(string, bool);
callback mask-opacity-changed(string, float);
callback mask-feather-changed(string, float);
callback mask-refine-changed(string, float);
callback mask-falloff-picked(string, int);
callback mask-morphology-picked(string, int);
callback mask-morph-radius-changed(string, float);
@@ -2532,6 +2533,9 @@ in property <bool> panel-visible: true;
mask-opacity-changed(id, v) => {
root.mask-opacity-changed(id, v);
}
mask-refine-changed(id, v) => {
root.mask-refine-changed(id, v);
}
mask-feather-changed(id, v) => {
root.mask-feather-changed(id, v);
}
+32
View File
@@ -29,6 +29,12 @@ export struct MaskRow {
/// Index into `Morphology::ALL`.
morphology: int,
morph-radius: float,
/// How strictly a category is cut back to the pixels whose colour agrees
/// with it, in nats. Zero is the model's own weighting.
refine: float,
/// Whether this layer has a colour model fitted to refine against. Only a
/// category has one, and only where the frame gave enough to fit it.
refinable: bool,
/// Whether the edge controls apply. A gradient has its own geometry and
/// does not go through the distance field, so showing them would offer
/// controls that do nothing.
@@ -97,6 +103,7 @@ component MaskEntry inherits Rectangle {
callback invert-toggled(bool);
callback opacity-changed(float);
callback feather-changed(float);
callback refine-changed(float);
callback falloff-picked(int);
callback morphology-picked(int);
callback morph-radius-changed(float);
@@ -235,6 +242,29 @@ component MaskEntry inherits Rectangle {
clicked => { root.refined(); }
}
// Which pixels are in the mask at all. Above the edge controls
// because it decides the outline they then treat, and unlike them
// it rebuilds the distance field — the same cost as a close or an
// open, and the reason it commits on release rather than on every
// sample of a drag.
if root.data.refinable: SliderRow {
label: "Refine";
hint: "Drop pixels whose colour does not match the rest of "
+ "the category — a flag in the sky, a chimney, a bare "
+ "branch. Zero is the model's own outline.";
value: root.data.refine;
default-value: 4;
minimum: 0;
maximum: 8;
// Tenths, not whole nats. The interval between a flag going
// and a cloud going is under three nats wide, so whole steps
// would give the control about three useful positions inside
// the range it exists to explore.
precision: 1;
changed(v) => { root.refine-changed(v); }
reset => { root.refine-changed(4); }
}
// The edge. All of it reads one signed distance field, so these
// are free to move — none of them recomputes anything except a
// compound morphology.
@@ -309,6 +339,7 @@ export component MaskPanel inherits Rectangle {
callback mask-invert-toggled(string, bool);
callback mask-opacity-changed(string, float);
callback mask-feather-changed(string, float);
callback mask-refine-changed(string, float);
callback mask-falloff-picked(string, int);
callback mask-morphology-picked(string, int);
callback mask-morph-radius-changed(string, float);
@@ -561,6 +592,7 @@ export component MaskPanel inherits Rectangle {
invert-toggled(on) => { root.mask-invert-toggled(mask.id, on); }
opacity-changed(v) => { root.mask-opacity-changed(mask.id, v); }
feather-changed(v) => { root.mask-feather-changed(mask.id, v); }
refine-changed(v) => { root.mask-refine-changed(mask.id, v); }
falloff-picked(i) => { root.mask-falloff-picked(mask.id, i); }
morphology-picked(i) => { root.mask-morphology-picked(mask.id, i); }
morph-radius-changed(v) => { root.mask-morph-radius-changed(mask.id, v); }