Let a mask cover a whole category, not just one instance
`MaskSource` could say "instance 3 of that segmentation run" but had no way
to say "the sky". Adding `Category { signature, name }` beside `Subject` is
what lets a local adjustment attach to a semantic category at all.
Stored as identity like a subject, and for the same reason: the coverage is
megabytes and is reproducible by running the same model over the same image,
so the sidecar carries what finds it again and the session carries pixels.
## A name rather than an index
An index would be smaller and would match `Subject`. It would also be a bug.
The grouping lives in `models/scene/categories.txt`, which is editable by
design — adding one category to it renumbers every category after it, and
every stored layer would silently start grading something else. A name that
no longer exists is simply not found and the layer reads as stale, which is
the failure that announces itself.
Staleness is otherwise identical to a subject's: the coverage buffer is in
the session, never the sidecar, so a signature from another run points at
pixels that were never computed.
## Two tests, and the second one caught a real shape
Round-tripping the name matters more than usual here, because the whole
argument for storing a name instead of an index is worthless if the sidecar
is what drops it.
The multi-word case is the one worth having: `category = swimming pool` is
written on one line, and a reader splitting on whitespace would have
truncated it to a category no model has — a layer that silently masks
nothing. `category` is also its own key rather than a reuse of `class`,
because a file conflating them would round-trip a subject into a category.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -532,6 +532,38 @@ pub enum MaskSource {
|
|||||||
score: f32,
|
score: f32,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Every pixel of one photographic category, from the scene model.
|
||||||
|
///
|
||||||
|
/// The counterpart to [`Self::Subject`], and the difference is the whole
|
||||||
|
/// reason both exist. A subject is *one* instance — this dog, not that one
|
||||||
|
/// — found by a COCO-trained instance model. A category is *all* the sky,
|
||||||
|
/// or all the foliage, from an ADE20K-trained semantic model that has no
|
||||||
|
/// notion of instances at all (docs/segmentation.md §16).
|
||||||
|
///
|
||||||
|
/// So this is what a global grade attaches to: lift the sky, desaturate
|
||||||
|
/// the vegetation, warm the architecture. Asking it for "that person
|
||||||
|
/// rather than the other two" is a category error — the model merged them
|
||||||
|
/// before the mask ever existed, and [`Self::Subject`] is the source for
|
||||||
|
/// that question.
|
||||||
|
///
|
||||||
|
/// Stored as identity like a subject, and for the same reason: the
|
||||||
|
/// coverage is megabytes and is reproducible from the same model over the
|
||||||
|
/// same image.
|
||||||
|
Category {
|
||||||
|
/// Which segmentation run produced it, so a layer can tell whether
|
||||||
|
/// the name below still refers to something that was computed.
|
||||||
|
signature: u64,
|
||||||
|
/// The category name from `models/scene/categories.txt` — "sky",
|
||||||
|
/// "vegetation".
|
||||||
|
///
|
||||||
|
/// A name rather than an index because the descriptor is editable: a
|
||||||
|
/// category added to it would silently renumber every layer stored
|
||||||
|
/// against an index, and the failure would be a mask quietly grading
|
||||||
|
/// the wrong thing. A name that no longer exists is simply not found,
|
||||||
|
/// and the layer reads as stale.
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
|
||||||
/// A linear gradient — the graduated-filter mask.
|
/// A linear gradient — the graduated-filter mask.
|
||||||
///
|
///
|
||||||
/// Geometry is in **normalised source coordinates**, so it survives a crop,
|
/// Geometry is in **normalised source coordinates**, so it survives a crop,
|
||||||
@@ -596,6 +628,7 @@ impl MaskSource {
|
|||||||
match self {
|
match self {
|
||||||
Self::Regions { .. } => "regions",
|
Self::Regions { .. } => "regions",
|
||||||
Self::Subject { .. } => "subject",
|
Self::Subject { .. } => "subject",
|
||||||
|
Self::Category { .. } => "category",
|
||||||
Self::Linear { .. } => "linear",
|
Self::Linear { .. } => "linear",
|
||||||
Self::Radial { .. } => "radial",
|
Self::Radial { .. } => "radial",
|
||||||
Self::Brush { .. } => "brush",
|
Self::Brush { .. } => "brush",
|
||||||
@@ -825,9 +858,9 @@ impl MaskLayer {
|
|||||||
/// should offer to recompute rather than render it.
|
/// should offer to recompute rather than render it.
|
||||||
pub fn is_stale(&self, current: u64) -> bool {
|
pub fn is_stale(&self, current: u64) -> bool {
|
||||||
match self.source {
|
match self.source {
|
||||||
MaskSource::Regions { signature, .. } | MaskSource::Subject { signature, .. } => {
|
MaskSource::Regions { signature, .. }
|
||||||
signature != current
|
| MaskSource::Subject { signature, .. }
|
||||||
}
|
| MaskSource::Category { signature, .. } => signature != current,
|
||||||
// A gradient is geometry in normalised coordinates. It means the
|
// A gradient is geometry in normalised coordinates. It means the
|
||||||
// same thing whatever was or was not detected, so nothing about a
|
// same thing whatever was or was not detected, so nothing about a
|
||||||
// new run can invalidate it. Painted strokes are the same: they are
|
// new run can invalidate it. Painted strokes are the same: they are
|
||||||
|
|||||||
@@ -971,6 +971,10 @@ fn write_mask(out: &mut String, version: &str, layer: &MaskLayer) {
|
|||||||
let _ = writeln!(out, "class = {class}");
|
let _ = writeln!(out, "class = {class}");
|
||||||
let _ = writeln!(out, "score = {}", format_value(*score));
|
let _ = writeln!(out, "score = {}", format_value(*score));
|
||||||
}
|
}
|
||||||
|
MaskSource::Category { signature, name } => {
|
||||||
|
let _ = writeln!(out, "signature = {signature}");
|
||||||
|
let _ = writeln!(out, "category = {name}");
|
||||||
|
}
|
||||||
MaskSource::Linear {
|
MaskSource::Linear {
|
||||||
centre,
|
centre,
|
||||||
angle,
|
angle,
|
||||||
@@ -1124,6 +1128,7 @@ struct PartialMask {
|
|||||||
ids: Vec<u32>,
|
ids: Vec<u32>,
|
||||||
index: u32,
|
index: u32,
|
||||||
class: String,
|
class: String,
|
||||||
|
category: String,
|
||||||
score: f32,
|
score: f32,
|
||||||
centre: (f32, f32),
|
centre: (f32, f32),
|
||||||
radii: (f32, f32),
|
radii: (f32, f32),
|
||||||
@@ -1154,6 +1159,7 @@ impl PartialMask {
|
|||||||
level: 0,
|
level: 0,
|
||||||
ids: Vec::new(),
|
ids: Vec::new(),
|
||||||
index: 0,
|
index: 0,
|
||||||
|
category: String::new(),
|
||||||
class: String::new(),
|
class: String::new(),
|
||||||
score: 0.0,
|
score: 0.0,
|
||||||
centre: (0.5, 0.5),
|
centre: (0.5, 0.5),
|
||||||
@@ -1191,6 +1197,11 @@ impl PartialMask {
|
|||||||
self.ids.sort_unstable();
|
self.ids.sort_unstable();
|
||||||
self.ids.dedup();
|
self.ids.dedup();
|
||||||
}
|
}
|
||||||
|
// A category's own key rather than reusing `class`: both name a
|
||||||
|
// thing the mask covers, but one is a COCO instance's label and
|
||||||
|
// the other an entry in the scene descriptor, and a file that
|
||||||
|
// conflated them would round-trip a subject into a category.
|
||||||
|
"category" => self.category = value.to_string(),
|
||||||
"index" => self.index = value.parse().unwrap_or(0),
|
"index" => self.index = value.parse().unwrap_or(0),
|
||||||
"class" => self.class = value.to_string(),
|
"class" => self.class = value.to_string(),
|
||||||
"score" => self.score = value.parse().unwrap_or(0.0),
|
"score" => self.score = value.parse().unwrap_or(0.0),
|
||||||
@@ -1257,6 +1268,10 @@ impl PartialMask {
|
|||||||
class: self.class,
|
class: self.class,
|
||||||
score: self.score,
|
score: self.score,
|
||||||
},
|
},
|
||||||
|
"category" => MaskSource::Category {
|
||||||
|
signature: self.signature,
|
||||||
|
name: self.category,
|
||||||
|
},
|
||||||
"linear" => MaskSource::Linear {
|
"linear" => MaskSource::Linear {
|
||||||
centre: self.centre,
|
centre: self.centre,
|
||||||
angle: self.angle,
|
angle: self.angle,
|
||||||
|
|||||||
@@ -616,3 +616,64 @@ fn show_a_sidecar() {
|
|||||||
|
|
||||||
println!("\n{}", sidecar.to_text());
|
println!("\n{}", sidecar.to_text());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A category mask is a name plus a signature, and both have to survive.
|
||||||
|
///
|
||||||
|
/// The name especially. `MaskSource::Category` stores one rather than an index
|
||||||
|
/// precisely so that editing `models/scene/categories.txt` cannot repoint a
|
||||||
|
/// stored layer — and that reasoning is worth nothing if the name is what the
|
||||||
|
/// sidecar drops.
|
||||||
|
#[test]
|
||||||
|
fn category_masks_keep_their_name() {
|
||||||
|
let mut graph = EditGraph::default_chain();
|
||||||
|
|
||||||
|
let mut sky = MaskLayer::new(
|
||||||
|
"m1",
|
||||||
|
MaskSource::Category {
|
||||||
|
signature: 0x5EED_1234,
|
||||||
|
name: "sky".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
sky.name = "sky".into();
|
||||||
|
sky.feather = 0.03;
|
||||||
|
sky.set_param("exposure", ParamId("exposure"), 0.5);
|
||||||
|
graph.masks_mut().push(sky);
|
||||||
|
|
||||||
|
// A multi-word name too: the writer emits `category = swimming pool` on one
|
||||||
|
// line, and a reader that split on whitespace would silently truncate it to
|
||||||
|
// a category no model has.
|
||||||
|
let mut pool = MaskLayer::new(
|
||||||
|
"m2",
|
||||||
|
MaskSource::Category {
|
||||||
|
signature: 0x5EED_1234,
|
||||||
|
name: "swimming pool".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
pool.set_param("saturation", ParamId("saturation"), -30.0);
|
||||||
|
graph.masks_mut().push(pool);
|
||||||
|
|
||||||
|
let restored = round_trip(&graph);
|
||||||
|
let layers = restored.masks().layers();
|
||||||
|
assert_eq!(layers.len(), 2);
|
||||||
|
assert_eq!(layers[0].source, graph.masks().layers()[0].source);
|
||||||
|
assert_eq!(layers[1].source, graph.masks().layers()[1].source);
|
||||||
|
assert!((layers[0].feather - 0.03).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// signature no longer matches is pointing at pixels that were never computed.
|
||||||
|
/// Rendering it anyway would mask nothing and read as a broken adjustment.
|
||||||
|
#[test]
|
||||||
|
fn a_category_from_another_run_is_stale() {
|
||||||
|
let layer = MaskLayer::new(
|
||||||
|
"m1",
|
||||||
|
MaskSource::Category {
|
||||||
|
signature: 1,
|
||||||
|
name: "sky".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(layer.is_stale(2), "a different run must invalidate it");
|
||||||
|
assert!(!layer.is_stale(1), "the run it was built against must not");
|
||||||
|
}
|
||||||
|
|||||||
+30
-30
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user