Merge: mask a whole category, not just one instance

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

# Conflicts:
#	apps/darkroom-desktop/Cargo.toml
#	docs/traceability.md
This commit is contained in:
2026-08-30 16:37:31 +02:00
12 changed files with 604 additions and 15 deletions
+21 -7
View File
@@ -579,13 +579,21 @@ impl MaskPass {
// fields are built per layer, in this same order, because two
// layers over one subject can carry different morphology.
let subject = match &layer.source {
MaskSource::Subject { .. } => match subjects.filter(|s| slot < s.len()) {
Some(s) => (s, slot),
None => {
log::warn!("mask layer {} has no distance field; skipping", layer.id);
continue;
// Category alongside Subject: both are model coverage turned
// into a distance field, both are built per layer in this same
// order, and leaving a category out of here is precisely the
// failure the comment above warns about — it binds the 1x1
// placeholder, so the mask covers everything and the
// adjustment silently goes global.
MaskSource::Subject { .. } | MaskSource::Category { .. } => {
match subjects.filter(|s| slot < s.len()) {
Some(s) => (s, slot),
None => {
log::warn!("mask layer {} has no distance field; skipping", layer.id);
continue;
}
}
},
}
_ => (&self.empty_subject, 0),
};
@@ -654,7 +662,13 @@ impl MaskPass {
// edge; the field is in proxy pixels. Converting here keeps the
// stored edit resolution-independent while the shader works in the
// units its texture is actually measured in.
MaskSource::Subject { .. } => {
// A category shares the subject's mode, and that is not a
// shortcut: both arrive as a soft coverage buffer at proxy
// resolution and both are turned into a distance field before they
// reach here. The shader has no way to tell them apart and no
// reason to want one — what differs is only which model produced
// the coverage.
MaskSource::Subject { .. } | MaskSource::Category { .. } => {
let short = field_short_edge(width, height);
MaskParams {
mode: MODE_SUBJECT,
+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
+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,
+61
View File
@@ -616,3 +616,64 @@ fn show_a_sidecar() {
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");
}
+122
View File
@@ -57,6 +57,8 @@ use std::sync::Arc;
use ndarray::ArrayView3;
#[cfg(test)]
use crate::semantic::INPUT_EDGE;
use crate::semantic::{install_backend, Letterbox, Window};
use crate::SegmentError;
@@ -353,6 +355,34 @@ impl Scene {
}
}
impl Scene {
/// Build a scene from known weights, for tests.
///
/// The geometry in [`Scene::rasterise`] is a letterbox inverse, and a
/// letterbox inverse is exactly the kind of code that produces confident,
/// plausible, wrong answers. Testing it needs weights whose correct
/// destination is known in advance, which a real inference can never give.
#[cfg(test)]
fn from_weights(names: Vec<Arc<str>>, weight: Vec<f32>, gw: usize, gh: usize) -> Self {
// A square source, so the letterbox is the identity and any offset
// this finds is the mapping's own rather than the padding's.
let window = Window {
x: 0.0,
y: 0.0,
w: INPUT_EDGE as f32,
h: INPUT_EDGE as f32,
};
Self {
names,
weight,
grid_width: gw,
grid_height: gh,
letterbox: Letterbox::fit(window.w, window.h),
window,
}
}
}
/// Read `models/scene/categories.txt`, resolving class names to indices.
///
/// Hand-written rather than generated, unlike the `.classes.json` beside it,
@@ -477,6 +507,98 @@ mod tests {
assert!(cats.iter().any(|c| &*c.name == "vegetation"));
}
/// Weight put in the top half of the grid must come back in the top half
/// of the image.
///
/// The one property that makes a category mask worth anything: if the
/// model says "sky up here" and `rasterise` puts it down there, every
/// grade lands on the wrong half of the photograph and nothing about the
/// numbers looks wrong. A vertical split is the cheapest arrangement that
/// catches a flipped axis, and a flipped axis is the mistake this code is
/// actually prone to.
#[test]
fn rasterise_keeps_weight_on_the_side_it_came_from() {
let (gw, gh) = (8usize, 8usize);
let mut weight = vec![0.0f32; gw * gh];
for y in 0..gh / 2 {
for x in 0..gw {
weight[y * gw + x] = 1.0;
}
}
let scene = Scene::from_weights(vec![Arc::from("sky")], weight, gw, gh);
let (w, h) = (64usize, 64usize);
let mask = scene.rasterise(0, w, h).expect("category 0 exists");
let mean = |y0: usize, y1: usize| {
let band: f32 = (y0..y1)
.flat_map(|y| (0..w).map(move |x| (y, x)))
.map(|(y, x)| mask[y * w + x])
.sum();
band / ((y1 - y0) * w) as f32
};
let top = mean(0, h / 4);
let bottom = mean(3 * h / 4, h);
assert!(
top > 0.9,
"the half that had the weight should keep it: {top}"
);
assert!(
bottom < 0.1,
"the half that had none should stay empty: {bottom}"
);
}
/// A horizontal split too, because a transpose passes the vertical test.
///
/// Swapping x and y maps a top band onto a left band, and the check above
/// would still see the top band full. Two axes is what makes the pair
/// meaningful; either alone is not.
#[test]
fn rasterise_does_not_transpose() {
let (gw, gh) = (8usize, 8usize);
let mut weight = vec![0.0f32; gw * gh];
for y in 0..gh {
for x in 0..gw / 2 {
weight[y * gw + x] = 1.0;
}
}
let scene = Scene::from_weights(vec![Arc::from("left")], weight, gw, gh);
let (w, h) = (64usize, 64usize);
let mask = scene.rasterise(0, w, h).expect("category 0 exists");
let mean = |x0: usize, x1: usize| {
let band: f32 = (0..h)
.flat_map(|y| (x0..x1).map(move |x| (y, x)))
.map(|(y, x)| mask[y * w + x])
.sum();
band / (h * (x1 - x0)) as f32
};
assert!(mean(0, w / 4) > 0.9, "left stays left");
assert!(mean(3 * w / 4, w) < 0.1, "right stays empty");
}
/// Coverage is the fraction of the frame, not a count or a sum.
///
/// The scene tab hides a category below half a percent, so a coverage that
/// is off by a factor of the grid size would either hide everything or
/// hide nothing, and both look like the model failing rather than the
/// arithmetic.
#[test]
fn coverage_is_a_fraction_of_the_frame() {
let (gw, gh) = (10usize, 10usize);
let mut weight = vec![0.0f32; gw * gh];
for cell in weight.iter_mut().take(25) {
*cell = 1.0;
}
let scene = Scene::from_weights(vec![Arc::from("quarter")], weight, gw, gh);
let c = scene.coverage(0);
assert!(
(c - 0.25).abs() < 1e-6,
"a quarter of the cells is 0.25, got {c}"
);
}
/// Softmax then group: the reported weights must never exceed one, and
/// must equal one exactly when the categories name every class.
#[test]