🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Build and test / Desktop (Linux) (push) Failing after 9m6s
Build and test / Layer separation (push) Successful in 26s
Traceability / Requirement traces (push) Failing after 23s
Build and test / Android (aarch64) (push) Failing after 22m38s
Nothing here is film simulation. These are lints that fail master today,
under the -D warnings CI runs with, mostly from a toolchain that learned
new ones rather than from anybody's code -- `is_multiple_of` and the
derivable `Default` did not exist as lints when this was written.
They are fixed rather than allowed, and by hand rather than by trusting
`cargo clippy --fix` wholesale: its automatic pass split a derive in two
and left a stray blank line, which is the sort of thing that is correct
and still wrong to commit.
The four that needed a decision rather than a rewrite:
- The distance transform's inner loop writes through its iterator now.
`q` stays, because it is the position the parabola is evaluated at as
well as the index it is written to -- the lint is about the write.
- `to_source` and `to_proto` take `self` by value. Their receiver is
`Copy`, so this is the same machine code and the honest signature.
- The export path's return type is five levels deep and now has a name,
plus a line saying why the `Option` wraps the `Result`: `None` is
cancellation, which is not a failure and has no error to report.
- A test fills a range instead of looping over one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
438 lines
16 KiB
Rust
438 lines
16 KiB
Rust
//! Arm C — semantic instances as a prior over the watershed merge order.
|
|
//!
|
|
//! docs/segmentation.md §5. The spec calls this the expected winner and it is
|
|
//! what ships, for a reason that survives the model turning out to be narrower
|
|
//! than §4 assumed: the two arms fail in *opposite* directions, so each one
|
|
//! covers the other's failure.
|
|
//!
|
|
//! - The watershed knows where every edge is and nothing about what it
|
|
//! separates. Its coarse levels are geometric accidents — level 7 is *a*
|
|
//! coarser partition, not *the* object.
|
|
//! - The model knows a dog is a dog and puts the dog's outline roughly where
|
|
//! the dog is, at quarter resolution, with a soft edge.
|
|
//!
|
|
//! Weighting the merge by semantic agreement takes the outline from the
|
|
//! watershed and the grouping from the model. A region pair the model believes
|
|
//! belongs to one object merges early; a pair straddling an object's edge
|
|
//! merges late. **No boundary moves** — only the order in which boundaries
|
|
//! dissolve — which is why the result is pixel-accurate at every level while
|
|
//! its coarse levels are named things.
|
|
//!
|
|
//! # Why this is not "just use the model's mask"
|
|
//!
|
|
//! Because a mask edge is judged at 100% zoom, and the model's edge is a
|
|
//! quarter-resolution sigmoid. Using the instance mask directly gives a
|
|
//! selection that is semantically right and visibly soft — acceptable for
|
|
//! biasing, not acceptable as the mask itself. Snapping to watershed regions
|
|
//! ([`regions_for_instance`]) gives the same selection with the sensor's own
|
|
//! edges.
|
|
|
|
use std::collections::HashMap;
|
|
|
|
use crate::hierarchy::{Edge, RegionField};
|
|
|
|
/// How strongly the model is allowed to reorder the merge.
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub struct PriorOptions {
|
|
/// How far a confident semantic judgement may scale a saddle, `0.0..1.0`.
|
|
///
|
|
/// At `0.0` this is arm A exactly. At `0.9` a pair the model is certain
|
|
/// shares an object merges at a tenth of its true boundary strength.
|
|
///
|
|
/// Not `1.0`, and the ceiling is the point: at `1.0` an agreeing pair gets
|
|
/// a saddle of zero and merges *before* genuinely identical neighbours,
|
|
/// which lets a confident-but-wrong detection flatten real structure it
|
|
/// happens to cover. Leaving headroom keeps the image's own evidence able
|
|
/// to outvote the model.
|
|
pub strength: f32,
|
|
|
|
/// Coverage above which a region counts as belonging to an instance.
|
|
///
|
|
/// Applied to the *mean* of the instance's soft mask over the region, so
|
|
/// this is "most of this region is inside the dog", not "some pixel is".
|
|
pub membership: f32,
|
|
|
|
/// Instances scoring below this contribute no prior at all.
|
|
///
|
|
/// Higher than the detection threshold on purpose. A weak detection is
|
|
/// still worth *offering* in a list a person picks from, where the cost of
|
|
/// being wrong is an ignored entry — but not worth silently reshaping the
|
|
/// hierarchy every other interaction depends on.
|
|
pub confidence: f32,
|
|
}
|
|
|
|
impl Default for PriorOptions {
|
|
fn default() -> Self {
|
|
Self {
|
|
strength: 0.75,
|
|
membership: 0.5,
|
|
confidence: 0.4,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Which instance, if any, each region belongs to.
|
|
///
|
|
/// One dominant instance per region rather than a vector of memberships:
|
|
/// regions are small — a proxy watershed makes thousands of them — and a
|
|
/// region spanning two objects means the watershed already failed there, which
|
|
/// is a case to leave to the image gradient rather than to average over.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct Membership {
|
|
/// Per region: the instance index it mostly belongs to, and how much.
|
|
pub of: Vec<Option<(usize, f32)>>,
|
|
}
|
|
|
|
impl Membership {
|
|
/// Compute per-region membership from soft instance masks.
|
|
///
|
|
/// `masks` is one full-image coverage buffer per instance, each
|
|
/// `width * height` — [`crate::semantic::Instance::mask`] is exactly this,
|
|
/// passed as slices so that this function needs no `semantic` feature and
|
|
/// stays testable with hand-written masks.
|
|
pub fn compute(
|
|
field: &RegionField,
|
|
masks: &[&[f32]],
|
|
options: &PriorOptions,
|
|
) -> Result<Self, MembershipError> {
|
|
let pixels = field.width * field.height;
|
|
for (i, mask) in masks.iter().enumerate() {
|
|
if mask.len() != pixels {
|
|
return Err(MembershipError::MaskSize {
|
|
instance: i,
|
|
expected: pixels,
|
|
got: mask.len(),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Sum coverage per (region, instance), then divide by region size.
|
|
let mut totals = vec![0.0f32; field.region_count * masks.len()];
|
|
let mut sizes = vec![0u32; field.region_count];
|
|
|
|
for (p, &label) in field.labels.iter().enumerate() {
|
|
let r = label as usize;
|
|
sizes[r] += 1;
|
|
for (i, mask) in masks.iter().enumerate() {
|
|
totals[r * masks.len() + i] += mask[p];
|
|
}
|
|
}
|
|
|
|
let of = (0..field.region_count)
|
|
.map(|r| {
|
|
let size = sizes[r].max(1) as f32;
|
|
let row = &totals[r * masks.len()..(r + 1) * masks.len()];
|
|
|
|
// `total_cmp` and an index tiebreak: two instances covering a
|
|
// region equally must resolve the same way on every machine,
|
|
// because the merge order below is derived from this and an
|
|
// unstable merge order is an unstable label field (M5).
|
|
let best = row
|
|
.iter()
|
|
.enumerate()
|
|
.max_by(|(ia, a), (ib, b)| a.total_cmp(b).then(ib.cmp(ia)))?;
|
|
|
|
let coverage = best.1 / size;
|
|
(coverage >= options.membership).then_some((best.0, coverage))
|
|
})
|
|
.collect();
|
|
|
|
Ok(Self { of })
|
|
}
|
|
|
|
/// The instance a region belongs to, if any.
|
|
pub fn instance_of(&self, region: u32) -> Option<usize> {
|
|
self.of
|
|
.get(region as usize)
|
|
.copied()
|
|
.flatten()
|
|
.map(|(i, _)| i)
|
|
}
|
|
|
|
/// How two regions relate semantically, in `-1.0..=1.0`.
|
|
///
|
|
/// `+c` when both sit in the same instance with confidence `c`, `-c` when
|
|
/// they sit in different ones or one is inside an object and the other is
|
|
/// background, and `0.0` when neither belongs to anything — two patches of
|
|
/// hillside get no opinion from a model that has no word for hillside, and
|
|
/// fall back to arm A untouched.
|
|
pub fn affinity(&self, a: u32, b: u32) -> f32 {
|
|
let (a, b) = (
|
|
self.of.get(a as usize).copied().flatten(),
|
|
self.of.get(b as usize).copied().flatten(),
|
|
);
|
|
match (a, b) {
|
|
(Some((ia, ca)), Some((ib, cb))) if ia == ib => ca.min(cb),
|
|
(Some((_, ca)), Some((_, cb))) => -ca.min(cb),
|
|
(Some((_, c)), None) | (None, Some((_, c))) => -c,
|
|
(None, None) => 0.0,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error, PartialEq)]
|
|
pub enum MembershipError {
|
|
#[error("instance {instance} mask is {got} pixels, expected {expected}")]
|
|
MaskSize {
|
|
instance: usize,
|
|
expected: usize,
|
|
got: usize,
|
|
},
|
|
}
|
|
|
|
/// Re-weight a region field's boundaries by semantic agreement.
|
|
///
|
|
/// Returns a field whose labels are untouched and whose adjacency saddles have
|
|
/// been scaled — so [`crate::MergeTree::build`] over the result yields a
|
|
/// hierarchy that climbs toward objects instead of toward whatever happened to
|
|
/// be smooth.
|
|
///
|
|
/// The scaling is `saddle * (1 - strength * affinity)`, which has the three
|
|
/// properties that matter: agreement shrinks a saddle toward zero without ever
|
|
/// reaching it, disagreement grows one without bound, and an affinity of zero
|
|
/// is exactly arm A. A pair the model has no opinion about is left alone
|
|
/// rather than nudged.
|
|
pub fn apply_semantic_prior(
|
|
field: &RegionField,
|
|
membership: &Membership,
|
|
options: &PriorOptions,
|
|
) -> RegionField {
|
|
let strength = options.strength.clamp(0.0, 0.99);
|
|
|
|
let mut adjacency: Vec<Edge> = field
|
|
.adjacency
|
|
.iter()
|
|
.map(|e| Edge {
|
|
a: e.a,
|
|
b: e.b,
|
|
saddle: e.saddle * (1.0 - strength * membership.affinity(e.a, e.b)),
|
|
})
|
|
.collect();
|
|
|
|
// Re-sorted because `MergeTree::build` consumes this in order and trusts
|
|
// it to be sorted; the same total order as `RegionField::from_roots` uses,
|
|
// for the same determinism reason.
|
|
adjacency.sort_by(|x, y| {
|
|
x.saddle
|
|
.total_cmp(&y.saddle)
|
|
.then(x.a.cmp(&y.a))
|
|
.then(x.b.cmp(&y.b))
|
|
});
|
|
|
|
RegionField {
|
|
width: field.width,
|
|
height: field.height,
|
|
labels: field.labels.clone(),
|
|
region_count: field.region_count,
|
|
adjacency,
|
|
}
|
|
}
|
|
|
|
/// The regions making up one instance — click-to-select, snapped to edges.
|
|
///
|
|
/// This is the interaction the whole spike exists to enable, and the reason it
|
|
/// returns *region ids* rather than a raster: a mask that is a set of integers
|
|
/// is diffable, mergeable at node level under FR-NC-9, and cheap in a sidecar
|
|
/// (docs/segmentation.md §1). A raster is none of those.
|
|
///
|
|
/// The returned ids are sorted, so the same click always produces the same
|
|
/// mask — which is what lets it be a cache key.
|
|
pub fn regions_for_instance(field: &RegionField, mask: &[f32], options: &PriorOptions) -> Vec<u32> {
|
|
let mut coverage = vec![0.0f32; field.region_count];
|
|
let mut sizes = vec![0u32; field.region_count];
|
|
|
|
for (p, &label) in field.labels.iter().enumerate() {
|
|
coverage[label as usize] += mask.get(p).copied().unwrap_or(0.0);
|
|
sizes[label as usize] += 1;
|
|
}
|
|
|
|
(0..field.region_count as u32)
|
|
.filter(|&r| coverage[r as usize] / sizes[r as usize].max(1) as f32 >= options.membership)
|
|
.collect()
|
|
}
|
|
|
|
/// Every pixel covered by a set of region ids, as a binary mask.
|
|
///
|
|
/// The other direction: region ids are what gets *stored*, and a rasteriser
|
|
/// needs pixels. On the shipping path this happens in a shader (ARCH §5.4);
|
|
/// this exists for export, for tests, and for the example.
|
|
pub fn rasterise(field: &RegionField, regions: &[u32]) -> Vec<f32> {
|
|
let selected: HashMap<u32, ()> = regions.iter().map(|&r| (r, ())).collect();
|
|
field
|
|
.labels
|
|
.iter()
|
|
.map(|l| if selected.contains_key(l) { 1.0 } else { 0.0 })
|
|
.collect()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::hierarchy::MergeTree;
|
|
|
|
/// A 4x2 field: regions 0 and 1 on the left, 2 and 3 on the right.
|
|
fn field() -> RegionField {
|
|
RegionField {
|
|
width: 4,
|
|
height: 2,
|
|
labels: vec![0, 0, 2, 2, 1, 1, 3, 3],
|
|
region_count: 4,
|
|
adjacency: vec![
|
|
Edge {
|
|
a: 0,
|
|
b: 1,
|
|
saddle: 1.0,
|
|
},
|
|
Edge {
|
|
a: 0,
|
|
b: 2,
|
|
saddle: 1.0,
|
|
},
|
|
Edge {
|
|
a: 1,
|
|
b: 3,
|
|
saddle: 1.0,
|
|
},
|
|
Edge {
|
|
a: 2,
|
|
b: 3,
|
|
saddle: 1.0,
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
/// An instance covering the left half — regions 0 and 1.
|
|
fn left_half() -> Vec<f32> {
|
|
vec![1.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0]
|
|
}
|
|
|
|
#[test]
|
|
fn membership_finds_the_covered_regions() {
|
|
let f = field();
|
|
let m = Membership::compute(&f, &[&left_half()], &PriorOptions::default()).unwrap();
|
|
|
|
assert_eq!(m.instance_of(0), Some(0));
|
|
assert_eq!(m.instance_of(1), Some(0));
|
|
assert_eq!(m.instance_of(2), None, "right half is outside the instance");
|
|
assert_eq!(m.instance_of(3), None);
|
|
}
|
|
|
|
#[test]
|
|
fn affinity_is_signed_by_agreement() {
|
|
let f = field();
|
|
let m = Membership::compute(&f, &[&left_half()], &PriorOptions::default()).unwrap();
|
|
|
|
assert!(m.affinity(0, 1) > 0.0, "both inside the instance");
|
|
assert!(m.affinity(0, 2) < 0.0, "across the instance boundary");
|
|
assert_eq!(m.affinity(2, 3), 0.0, "model has no opinion on either");
|
|
}
|
|
|
|
/// The property arm C exists for: with equal image evidence everywhere,
|
|
/// the semantic pair merges first.
|
|
#[test]
|
|
fn the_prior_reorders_the_merge_toward_the_object() {
|
|
let f = field();
|
|
let m = Membership::compute(&f, &[&left_half()], &PriorOptions::default()).unwrap();
|
|
|
|
// Arm A alone: every saddle is 1.0, so the merge order is arbitrary
|
|
// and 0-1 has no reason to come first.
|
|
let plain = MergeTree::build(&f);
|
|
assert_eq!(plain.merges.len(), 3);
|
|
|
|
let biased = MergeTree::build(&apply_semantic_prior(&f, &m, &PriorOptions::default()));
|
|
let first = biased.merges[0];
|
|
assert_eq!(
|
|
(first.a, first.b),
|
|
(0, 1),
|
|
"the two regions inside the instance should merge first"
|
|
);
|
|
assert!(
|
|
first.saddle < 1.0,
|
|
"agreement should lower the saddle, got {}",
|
|
first.saddle
|
|
);
|
|
|
|
// And the boundary the model believes in should now be the last to go.
|
|
let last = biased.merges.last().unwrap();
|
|
assert!(
|
|
last.saddle > 1.0,
|
|
"a semantic boundary should outlast the others, got {}",
|
|
last.saddle
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_cut_at_two_groups_splits_along_the_instance() {
|
|
let f = field();
|
|
let m = Membership::compute(&f, &[&left_half()], &PriorOptions::default()).unwrap();
|
|
let tree = MergeTree::build(&apply_semantic_prior(&f, &m, &PriorOptions::default()));
|
|
|
|
let grouping = tree.cut_to(2);
|
|
assert_eq!(grouping[0], grouping[1], "instance regions share a group");
|
|
assert_eq!(grouping[2], grouping[3], "background regions share a group");
|
|
assert_ne!(grouping[0], grouping[2], "and the two groups differ");
|
|
}
|
|
|
|
#[test]
|
|
fn zero_strength_is_arm_a_exactly() {
|
|
let f = field();
|
|
let opts = PriorOptions {
|
|
strength: 0.0,
|
|
..PriorOptions::default()
|
|
};
|
|
let m = Membership::compute(&f, &[&left_half()], &opts).unwrap();
|
|
assert_eq!(apply_semantic_prior(&f, &m, &opts).adjacency, f.adjacency);
|
|
}
|
|
|
|
#[test]
|
|
fn selection_snaps_to_whole_regions() {
|
|
let f = field();
|
|
|
|
// A mask that is ragged at the pixel level — as a quarter-resolution
|
|
// sigmoid would be — still selects clean whole regions.
|
|
let ragged = vec![1.0, 0.9, 0.1, 0.0, 0.8, 1.0, 0.0, 0.2];
|
|
let regions = regions_for_instance(&f, &ragged, &PriorOptions::default());
|
|
assert_eq!(regions, vec![0, 1]);
|
|
|
|
let pixels = rasterise(&f, ®ions);
|
|
assert_eq!(pixels, vec![1.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0]);
|
|
}
|
|
|
|
#[test]
|
|
fn regions_are_sorted_so_a_click_is_a_cache_key() {
|
|
let f = field();
|
|
let all = vec![1.0; 8];
|
|
let regions = regions_for_instance(&f, &all, &PriorOptions::default());
|
|
assert!(regions.windows(2).all(|w| w[0] < w[1]));
|
|
}
|
|
|
|
#[test]
|
|
fn a_wrong_sized_mask_is_an_error_not_a_panic() {
|
|
let f = field();
|
|
let err = Membership::compute(&f, &[&[0.0; 3]], &PriorOptions::default()).unwrap_err();
|
|
assert_eq!(
|
|
err,
|
|
MembershipError::MaskSize {
|
|
instance: 0,
|
|
expected: 8,
|
|
got: 3
|
|
}
|
|
);
|
|
}
|
|
|
|
/// Two instances, so the "different objects repel" branch is covered.
|
|
#[test]
|
|
fn different_instances_repel() {
|
|
let f = field();
|
|
let right_half = vec![0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 1.0];
|
|
let m = Membership::compute(&f, &[&left_half(), &right_half], &PriorOptions::default())
|
|
.unwrap();
|
|
|
|
assert_eq!(m.instance_of(0), Some(0));
|
|
assert_eq!(m.instance_of(2), Some(1));
|
|
assert!(m.affinity(0, 2) < 0.0, "two different objects should repel");
|
|
assert!(m.affinity(2, 3) > 0.0, "same object should attract");
|
|
}
|
|
}
|