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:
2026-08-30 14:02:08 +02:00
co-authored by Claude Opus 5
parent 458025c607
commit 485297f0c6
4 changed files with 142 additions and 33 deletions
+15
View File
@@ -971,6 +971,10 @@ fn write_mask(out: &mut String, version: &str, layer: &MaskLayer) {
let _ = writeln!(out, "class = {class}");
let _ = writeln!(out, "score = {}", format_value(*score));
}
MaskSource::Category { signature, name } => {
let _ = writeln!(out, "signature = {signature}");
let _ = writeln!(out, "category = {name}");
}
MaskSource::Linear {
centre,
angle,
@@ -1124,6 +1128,7 @@ struct PartialMask {
ids: Vec<u32>,
index: u32,
class: String,
category: String,
score: f32,
centre: (f32, f32),
radii: (f32, f32),
@@ -1154,6 +1159,7 @@ impl PartialMask {
level: 0,
ids: Vec::new(),
index: 0,
category: String::new(),
class: String::new(),
score: 0.0,
centre: (0.5, 0.5),
@@ -1191,6 +1197,11 @@ impl PartialMask {
self.ids.sort_unstable();
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),
"class" => self.class = value.to_string(),
"score" => self.score = value.parse().unwrap_or(0.0),
@@ -1257,6 +1268,10 @@ impl PartialMask {
class: self.class,
score: self.score,
},
"category" => MaskSource::Category {
signature: self.signature,
name: self.category,
},
"linear" => MaskSource::Linear {
centre: self.centre,
angle: self.angle,