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
+36 -3
View File
@@ -532,6 +532,38 @@ pub enum MaskSource {
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.
///
/// Geometry is in **normalised source coordinates**, so it survives a crop,
@@ -596,6 +628,7 @@ impl MaskSource {
match self {
Self::Regions { .. } => "regions",
Self::Subject { .. } => "subject",
Self::Category { .. } => "category",
Self::Linear { .. } => "linear",
Self::Radial { .. } => "radial",
Self::Brush { .. } => "brush",
@@ -825,9 +858,9 @@ impl MaskLayer {
/// should offer to recompute rather than render it.
pub fn is_stale(&self, current: u64) -> bool {
match self.source {
MaskSource::Regions { signature, .. } | MaskSource::Subject { signature, .. } => {
signature != current
}
MaskSource::Regions { signature, .. }
| MaskSource::Subject { signature, .. }
| MaskSource::Category { signature, .. } => signature != current,
// A gradient is geometry in normalised coordinates. It means the
// same thing whatever was or was not detected, so nothing about a
// new run can invalidate it. Painted strokes are the same: they are