diff --git a/core/dr-pipeline/src/mask.rs b/core/dr-pipeline/src/mask.rs index 0f9b561..b9ce4a3 100644 --- a/core/dr-pipeline/src/mask.rs +++ b/core/dr-pipeline/src/mask.rs @@ -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(), } } diff --git a/core/dr-pipeline/src/sidecar.rs b/core/dr-pipeline/src/sidecar.rs index d0857da..b13cdff 100644 --- a/core/dr-pipeline/src/sidecar.rs +++ b/core/dr-pipeline/src/sidecar.rs @@ -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, 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::().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::() + .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 diff --git a/core/dr-pipeline/tests/mask_sidecar.rs b/core/dr-pipeline/tests/mask_sidecar.rs index c995bc9..07d08bc 100644 --- a/core/dr-pipeline/tests/mask_sidecar.rs +++ b/core/dr-pipeline/tests/mask_sidecar.rs @@ -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 diff --git a/ui/dr-ui/src/develop.rs b/ui/dr-ui/src/develop.rs index e4d3a52..199655a 100644 --- a/ui/dr-ui/src/develop.rs +++ b/ui/dr-ui/src/develop.rs @@ -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 diff --git a/ui/dr-ui/src/labels.rs b/ui/dr-ui/src/labels.rs index d71a876..49929e8 100644 --- a/ui/dr-ui/src/labels.rs +++ b/ui/dr-ui/src/labels.rs @@ -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", diff --git a/ui/dr-ui/src/masks_ui.rs b/ui/dr-ui/src/masks_ui.rs index 152b822..d1979d7 100644 --- a/ui/dr-ui/src/masks_ui.rs +++ b/ui/dr-ui/src/masks_ui.rs @@ -188,6 +188,8 @@ pub(crate) fn sync(window: &AppWindow, session: &Rc, + /// 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, } /// 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> { + 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, 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, }); } diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index 11da7ec..0e23ca2 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -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 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); } diff --git a/ui/dr-ui/ui/masks.slint b/ui/dr-ui/ui/masks.slint index 6c97449..7f775d5 100644 --- a/ui/dr-ui/ui/masks.slint +++ b/ui/dr-ui/ui/masks.slint @@ -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); }