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
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:
+70
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user