Store what the model found, so a reopened photograph keeps its masks
A subject or category layer was written to the sidecar as identity alone —
which run, which instance, which category — on the reasoning that the pixels
are reproducible by running the same model over the same image. They are, but
only by *running the model*, and nothing runs one except a photographer
pressing "find subjects". So on every path that did not already have a run in
memory the layer resolved to no coverage, `MaskPass::render` logged "has no
distance field; skipping", and the adjustment was silently absent:
- reopening an edited photograph rendered it without its local adjustments,
and then saved that state back on the way out;
- a batch export from the grid could not have them at any point, because
`render_from_library` opens a session, applies a version and renders, and
there is no model anywhere on that path. Three hundred files written
without the edits their photographer made, over a log warning.
Neither failure announced itself. The generated shader still emits the layer's
block and the empty placeholder multiplies it by zero, so the result is a
well-formed frame that is simply missing an edit — `mask_is_stale` already
named the state and called it "not stale, just unrenderable".
The coverage now travels in the file, as one `coverage = w h levels payload`
line at the end of the layer's block.
Two levels, and that is not a compromise. The model hands out a byte per pixel
but `Shaped::build` measures its distance field from `coverage >= 128` and
throws the shoulder away on the first line; everything soft about the rendered
edge comes afterwards from the layer's feather and falloff, which are read off
the distance. So one bit per pixel is not an approximation of what the model
said — it is exactly the part of it that reaches a pixel, and the stored mask
renders the identical frame. Storing all 256 levels would have stored 1.7 MB
of bilinear interpolation to reconstruct a predicate, and would not even have
compressed: a model mask is a bilinear upsample of a coarse grid, so almost no
two adjacent bytes are alike. Measured on a simulated sky and a simulated
figure at 1600x1067, against 1.71 MB raw: 4.0 kB and 6.5 kB at two levels,
46 kB and 76 kB at sixteen, 835 kB and 1.43 MB at all 256. The level count is
still written into the line, so a later build that finds a use for the
shoulder can write sixteen and this one will read them rather than misreading
a stream of lengths as pairs.
The coder is hand-rolled — run-length pairs in a base-64 varint — because
`dr-pipeline` links nothing, which is the property that lets the descriptor
and codegen logic be tested without a device. `flate2` would have been fewer
lines and a dependency in the one crate that has none.
Where it lives matters more than how it is coded. The raster sits on
`MaskLayer` beside the source, not inside `MaskSource::Subject`: the source is
*identity*, which is what makes it diff as a handful of numbers and merge per
field under FR-NC-9, and a raster in there would have given the merge a binary
blob to arbitrate. It takes no part in `MaskLayer`'s equality for the same
reason — a device that has run the model and one that has not hold the same
edit, and counting the difference would raise a conflict over a cache and let
`remote_wins` answer it by discarding the only copy of the pixels.
Encoding happens in `masks_for_storage`, on the save path, rather than in
`ensure_subject_fields` where every coverage already funnels through.
`ensure_subject_fields` runs on a drag — dilating a mask with a compound
morphology rebuilds the field every frame — and encoding a megapixel raster
per frame is the kind of work NFR-P5 exists to keep off a gesture. Saving
happens once, when the photograph stops being the open one, and already costs
a network round trip.
Version skew holds both ways. A file with no `coverage` line reads exactly as
it did before, which is a layer that needs the model run; an unreadable one
costs the pixels and not the layer, because the layer is the edit and the
raster is a cache of it. An old build reading a new file drops the key it does
not understand, which costs a model run and no work. And a payload that will
not compress is refused rather than truncated: a checkerboard would encode to
twice the raster it came from, so past 64 kB nothing is stored and the
behaviour falls back to what it was — half a mask would render as a mask that
is confidently wrong, which is the failure that tells nobody.
This commit is contained in:
@@ -778,3 +778,349 @@ fn a_category_from_another_run_is_stale() {
|
||||
assert!(layer.is_stale(2), "a different run must invalidate it");
|
||||
assert!(!layer.is_stale(1), "the run it was built against must not");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stored coverage (dr_pipeline::coverage)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// A subject or a category is stored as *identity* — which run, which instance,
|
||||
// which category — and identity alone is only enough while the run is still in
|
||||
// memory. These pin down the raster that goes beside it, which is what lets a
|
||||
// reopened photograph and a batch export render the layer without a model.
|
||||
|
||||
use dr_pipeline::coverage::{Coverage, RENDERED_LEVELS};
|
||||
use std::sync::Arc;
|
||||
|
||||
const PROXY: (usize, usize) = (96, 64);
|
||||
|
||||
fn subject(signature: u64, index: u32) -> MaskSource {
|
||||
MaskSource::Subject {
|
||||
signature,
|
||||
index,
|
||||
class: "dog".into(),
|
||||
score: 0.94,
|
||||
}
|
||||
}
|
||||
|
||||
/// A soft-edged disc, which is the shape a model actually hands out: a coarse
|
||||
/// sigmoid with a shoulder several pixels wide.
|
||||
fn a_disc() -> Vec<u8> {
|
||||
let (w, h) = PROXY;
|
||||
let mut values = vec![0u8; w * h];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let dx = (x as f32 - 40.0) / 20.0;
|
||||
let dy = (y as f32 - 30.0) / 20.0;
|
||||
let r = (dx * dx + dy * dy).sqrt();
|
||||
values[y * w + x] = ((1.0 / (1.0 + ((r - 1.0) * 4.0).exp())) * 255.0) as u8;
|
||||
}
|
||||
}
|
||||
values
|
||||
}
|
||||
|
||||
/// What the renderer would make of a coverage: the distance transform reads
|
||||
/// `>= 128` and nothing else, so this is the whole of the information a stored
|
||||
/// mask has to preserve.
|
||||
fn inside(values: &[u8]) -> Vec<bool> {
|
||||
values.iter().map(|&v| v >= 128).collect()
|
||||
}
|
||||
|
||||
fn with_coverage(id: &str, source: MaskSource, values: &[u8]) -> MaskLayer {
|
||||
let mut layer = MaskLayer::new(id, source);
|
||||
layer.set_param("exposure", ParamId("exposure"), 0.75);
|
||||
layer.coverage = Some(Arc::new(
|
||||
Coverage::encode(values, PROXY.0, PROXY.1, RENDERED_LEVELS).expect("a disc should encode"),
|
||||
));
|
||||
layer
|
||||
}
|
||||
|
||||
/// The bug this whole thing exists for: without the raster, reopening the
|
||||
/// photograph gave the layer nothing to be, and the local adjustment was
|
||||
/// silently absent until somebody pressed "find subjects".
|
||||
#[test]
|
||||
fn a_subjects_coverage_survives_a_round_trip() {
|
||||
let values = a_disc();
|
||||
let mut graph = EditGraph::default_chain();
|
||||
graph
|
||||
.masks_mut()
|
||||
.push(with_coverage("m1", subject(0x1234, 2), &values));
|
||||
|
||||
let restored = round_trip(&graph);
|
||||
let layer = &restored.masks().layers()[0];
|
||||
|
||||
assert_eq!(
|
||||
layer.source,
|
||||
subject(0x1234, 2),
|
||||
"the identity is still there"
|
||||
);
|
||||
let coverage = layer.coverage.as_ref().expect("the pixels came back too");
|
||||
assert_eq!((coverage.width(), coverage.height()), PROXY);
|
||||
assert_eq!(
|
||||
inside(&coverage.decode()),
|
||||
inside(&values),
|
||||
"every pixel must fall on the same side of the threshold"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_categorys_coverage_survives_a_round_trip() {
|
||||
let values = a_disc();
|
||||
let source = MaskSource::Category {
|
||||
signature: 0x5678,
|
||||
name: "sky".into(),
|
||||
};
|
||||
let mut graph = EditGraph::default_chain();
|
||||
graph
|
||||
.masks_mut()
|
||||
.push(with_coverage("m1", source.clone(), &values));
|
||||
|
||||
let restored = round_trip(&graph);
|
||||
let layer = &restored.masks().layers()[0];
|
||||
assert_eq!(layer.source, source);
|
||||
assert_eq!(
|
||||
inside(&layer.coverage.as_ref().expect("coverage").decode()),
|
||||
inside(&values)
|
||||
);
|
||||
}
|
||||
|
||||
/// The property that lets a caller skip an upload by comparing content — now
|
||||
/// with several kilobytes of run-length payload in the file. An encoder that
|
||||
/// re-coded the same raster differently on the way out would put a spurious
|
||||
/// upload on every save.
|
||||
#[test]
|
||||
fn a_stored_coverage_writes_the_same_bytes_every_time() {
|
||||
let mut graph = EditGraph::default_chain();
|
||||
graph
|
||||
.masks_mut()
|
||||
.push(with_coverage("m1", subject(1, 0), &a_disc()));
|
||||
|
||||
let mut sidecar = Sidecar::new();
|
||||
sidecar.put(Version::from_graph("default", "Default", &graph));
|
||||
let once = sidecar.to_text();
|
||||
let twice = Sidecar::parse(&once).expect("reparse").to_text();
|
||||
assert_eq!(once, twice);
|
||||
}
|
||||
|
||||
/// The line goes last in its block, and that is a claim about reading the file
|
||||
/// by hand: it is thousands of characters against a dozen everywhere else, and
|
||||
/// a sidecar is what somebody opens when an edit has gone wrong (ARCH §6.12).
|
||||
#[test]
|
||||
fn the_coverage_line_comes_after_everything_a_human_is_looking_for() {
|
||||
let mut graph = EditGraph::default_chain();
|
||||
graph
|
||||
.masks_mut()
|
||||
.push(with_coverage("m1", subject(1, 0), &a_disc()));
|
||||
let mut sidecar = Sidecar::new();
|
||||
sidecar.put(Version::from_graph("default", "Default", &graph));
|
||||
let text = sidecar.to_text();
|
||||
|
||||
let block = text.split("[mask ").nth(1).expect("a mask block");
|
||||
let keys: Vec<&str> = block
|
||||
.lines()
|
||||
.filter_map(|l| l.split_once(" = "))
|
||||
.map(|(k, _)| k)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
keys.last(),
|
||||
Some(&"coverage"),
|
||||
"coverage should be the last key in the block: {keys:?}"
|
||||
);
|
||||
assert!(
|
||||
keys.contains(&"class") && keys.contains(&"exposure.exposure"),
|
||||
"and everything else should still be above it: {keys:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
block.lines().filter(|l| l.starts_with("coverage")).count(),
|
||||
1,
|
||||
"one line, because a node is a line and the merge is key-wise"
|
||||
);
|
||||
}
|
||||
|
||||
/// Version skew, backwards: a file written before any of this existed.
|
||||
///
|
||||
/// The layer must load and behave exactly as it did then — which is to say it
|
||||
/// needs the model run — rather than reading as a mask with no pixels.
|
||||
#[test]
|
||||
fn a_sidecar_written_before_coverage_existed_still_loads() {
|
||||
let text = "\
|
||||
drsc 1
|
||||
|
||||
[version default]
|
||||
name = Default
|
||||
default = 1
|
||||
revision = 3
|
||||
|
||||
[mask default m1]
|
||||
name = Dog
|
||||
source = subject
|
||||
signature = 4660
|
||||
index = 2
|
||||
class = dog
|
||||
score = 0.94
|
||||
exposure.exposure = 0.75
|
||||
";
|
||||
let sidecar = Sidecar::parse(text).expect("an old file must still parse");
|
||||
let mut graph = EditGraph::default_chain();
|
||||
sidecar
|
||||
.versions
|
||||
.get("default")
|
||||
.expect("version")
|
||||
.apply(&mut graph)
|
||||
.expect_no_film();
|
||||
|
||||
let layer = &graph.masks().layers()[0];
|
||||
assert_eq!(layer.source, subject(4660, 2));
|
||||
assert_eq!(layer.name, "Dog");
|
||||
assert!(
|
||||
layer.coverage.is_none(),
|
||||
"absent means absent, not an empty mask"
|
||||
);
|
||||
}
|
||||
|
||||
/// Version skew, forwards: a coverage this build cannot make sense of.
|
||||
///
|
||||
/// The stand-in for a payload written by a build that means something else by
|
||||
/// the key. It costs the pixels — a model run — and must not cost the layer,
|
||||
/// because the layer is the edit and the raster is a cache of it.
|
||||
#[test]
|
||||
fn an_unreadable_coverage_costs_the_pixels_and_not_the_layer() {
|
||||
let text = "\
|
||||
drsc 1
|
||||
|
||||
[version default]
|
||||
name = Default
|
||||
default = 1
|
||||
|
||||
[mask default m1]
|
||||
name = Dog
|
||||
source = subject
|
||||
signature = 4660
|
||||
index = 2
|
||||
class = dog
|
||||
score = 0.94
|
||||
exposure.exposure = 0.75
|
||||
coverage = 96 64 999 not-a-payload
|
||||
";
|
||||
let sidecar = Sidecar::parse(text).expect("parse");
|
||||
let mut graph = EditGraph::default_chain();
|
||||
sidecar
|
||||
.versions
|
||||
.get("default")
|
||||
.expect("version")
|
||||
.apply(&mut graph)
|
||||
.expect_no_film();
|
||||
|
||||
let layer = &graph.masks().layers()[0];
|
||||
assert_eq!(layer.source, subject(4660, 2));
|
||||
assert_eq!(layer.name, "Dog");
|
||||
assert!(layer.coverage.is_none());
|
||||
assert_eq!(
|
||||
layer
|
||||
.ops
|
||||
.iter()
|
||||
.find(|o| o.descriptor().id.0 == "exposure")
|
||||
.map(|o| o.param(ParamId("exposure"))),
|
||||
Some(0.75),
|
||||
"the adjustment is the thing that must not be lost"
|
||||
);
|
||||
}
|
||||
|
||||
/// A raster only means anything against a source a model produced. A gradient
|
||||
/// carrying one is a hand-edited or mis-written file, and honouring it would
|
||||
/// upload a buffer nothing samples.
|
||||
#[test]
|
||||
fn coverage_is_not_loaded_onto_a_source_with_no_model_behind_it() {
|
||||
let payload = Coverage::encode(&a_disc(), PROXY.0, PROXY.1, RENDERED_LEVELS)
|
||||
.expect("encode")
|
||||
.to_text();
|
||||
let text = format!(
|
||||
"drsc 1\n\n[version default]\nname = Default\ndefault = 1\n\n\
|
||||
[mask default m1]\nsource = radial\ncentre = 0.5 0.5\nradii = 0.25 0.25\n\
|
||||
coverage = {payload}\n"
|
||||
);
|
||||
let sidecar = Sidecar::parse(&text).expect("parse");
|
||||
let mut graph = EditGraph::default_chain();
|
||||
sidecar
|
||||
.versions
|
||||
.get("default")
|
||||
.expect("version")
|
||||
.apply(&mut graph)
|
||||
.expect_no_film();
|
||||
|
||||
let layer = &graph.masks().layers()[0];
|
||||
assert!(matches!(layer.source, MaskSource::Radial { .. }));
|
||||
assert!(layer.coverage.is_none());
|
||||
}
|
||||
|
||||
/// TRACES: FR-NC-9
|
||||
/// One device has run the model and the other has not. That is the same edit.
|
||||
///
|
||||
/// The merge decides "did this device change the layer" by comparing layers,
|
||||
/// so a cached raster taking part would make a photograph opened on the phone
|
||||
/// conflict with itself on the desktop — and, with `remote_wins`, resolve the
|
||||
/// conflict by discarding the only copy of the pixels.
|
||||
#[test]
|
||||
fn a_coverage_one_device_has_and_the_other_lacks_is_not_a_conflict() {
|
||||
let layer = |with: bool| {
|
||||
let mut l = lit("m1", &[1], 1.0);
|
||||
l.source = subject(7, 0);
|
||||
if with {
|
||||
l.coverage = Some(Arc::new(
|
||||
Coverage::encode(&a_disc(), PROXY.0, PROXY.1, RENDERED_LEVELS).expect("encode"),
|
||||
));
|
||||
}
|
||||
l
|
||||
};
|
||||
|
||||
let base = version_with("default", 1, |g| {
|
||||
g.masks_mut().push(layer(false));
|
||||
});
|
||||
let mut ours = version_with("default", 2, |g| {
|
||||
g.masks_mut().push(layer(true));
|
||||
});
|
||||
let theirs = version_with("default", 9, |g| {
|
||||
g.masks_mut().push(layer(false));
|
||||
});
|
||||
|
||||
let conflicts = ours.merge(&theirs, Some(&base));
|
||||
assert!(
|
||||
conflicts.is_empty(),
|
||||
"running the model is not an edit: {conflicts:?}"
|
||||
);
|
||||
assert!(
|
||||
ours.masks.get("m1").expect("layer").coverage.is_some(),
|
||||
"and the side that has the pixels keeps them"
|
||||
);
|
||||
}
|
||||
|
||||
/// The other half of the same claim: a real edit is still a conflict when both
|
||||
/// sides also happen to hold coverage.
|
||||
#[test]
|
||||
fn a_real_edit_is_still_a_conflict_with_coverage_present() {
|
||||
let stored = || {
|
||||
Some(Arc::new(
|
||||
Coverage::encode(&a_disc(), PROXY.0, PROXY.1, RENDERED_LEVELS).expect("encode"),
|
||||
))
|
||||
};
|
||||
let layer = |ev: f32| {
|
||||
let mut l = lit("m1", &[1], ev);
|
||||
l.source = subject(7, 0);
|
||||
l.coverage = stored();
|
||||
l
|
||||
};
|
||||
|
||||
let base = version_with("default", 1, |g| {
|
||||
g.masks_mut().push(layer(0.5));
|
||||
});
|
||||
let mut ours = version_with("default", 2, |g| {
|
||||
g.masks_mut().push(layer(1.0));
|
||||
});
|
||||
let theirs = version_with("default", 9, |g| {
|
||||
g.masks_mut().push(layer(-1.0));
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
ours.merge(&theirs, Some(&base)),
|
||||
vec![("mask".to_string(), "m1".to_string())]
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user