spacr.curation¶
B12 C7 — correcting a mask and a track by hand, on the record.
Two jobs that had no answer at all, and one rule that binds them.
The masks are wrong in specific, obvious places. Cellpose merges two touching cells, or clips a lobe, and until now the only remedies were to re-run segmentation with different parameters and hope, or to throw the field away. Painting the fix takes four seconds and there was no brush.
btrack output is never perfect. A track breaks when a cell divides or briefly leaves focus, and two tracks get swapped when cells touch. Timelapse analysis downstream is only as good as the tracks, and there was no way to join, split or delete one. That, more than the brush, is what has made timelapse unusable: a velocity computed over a track that is really two cells is not a noisy number, it is a wrong one.
A corrected dataset must be distinguishable from a raw one. This is the
rule, and it is why this module exists rather than a couple of mutating
helpers. A hand-edited mask that looks exactly like a segmented one is a
reproducibility hole: six months later nobody can say which fields were
touched, by whom, or what they looked like before — and a reviewer asking “did
you edit the data?” gets an answer based on memory. So every correction here
goes through CurationLog: an append-only ledger, written beside the
artefact it describes, recording what changed, when, and to what. Every
correction made through the supported edit methods leaves an entry. The public
layer and table objects remain accessible to views and advanced callers;
mutating those objects directly bypasses this provenance guarantee.
What the ledger is, and is not¶
It is a provenance record, not an undo file. MaskCuration.undo() works
off an in-memory history of exact pixel values and is bounded; the ledger
keeps one small JSON entry per action forever. Making one serve both would
mean either a ledger big enough to hold every painted voxel or an undo stack
that silently forgot.
It is deliberately a sidecar rather than a table in measurements.db. Mask
and track curation happen on a folder of TIFFs long before (and often
without) a measurement database, and a provenance record that only exists once
you have measured is a record that misses the edits most worth having.
Exceptions¶
A correction that cannot mean what it was asked to mean. |
Classes¶
One recorded correction. |
|
An append-only ledger of corrections to one artefact. |
|
Exactly which elements a stroke changed, and what they were. |
|
A brush over a |
|
Join, split and delete tracks by hand, on the record. |
Functions¶
|
Whether |
|
Where the ledger for |
Module Contents¶
- exception spacr.curation.CurationError[source]¶
Bases:
ValueErrorA correction that cannot mean what it was asked to mean.
Raised rather than silently doing nothing. A “join” button that quietly declines when the two tracks overlap in time leaves the user believing the join happened, and the ledger — which is the whole point — would then disagree with the data.
Initialize self. See help(type(self)) for accurate signature.
- class spacr.curation.CurationEdit[source]¶
One recorded correction.
- Parameters:
kind – correction verb, normally
"paint","undo","join","split", or"delete"; the ledger groups and displays this value verbatim.target – corrected object in that operation’s terms, such as a painted label, track identifier, or pair of track identifiers.
when – UTC ISO-8601 timestamp; direct construction defaults to
_now().who – operating-system user recorded for human provenance, not a cryptographic identity.
n_changed – number of voxels painted or rows reassigned; zero records that nothing moved.
detail – additional operation-specific provenance such as brush radius, split frame, or overwritten labels.
- classmethod from_dict(data: Mapping[str, Any]) CurationEdit[source]¶
Rebuild one edit from its
to_dict()form.- Parameters:
data – a single entry out of a ledger’s
editslist. Every key is optional, so an entry written by an older version — or one hand-trimmed in the JSON — loads instead of taking the whole ledger down with it. A missing key becomes an empty value whatever the field declares:""forkind,whenandwho,Nonefortarget,0forn_changed,{}fordetail. A missingwhenis therefore""and not the current time, so a reconstructed edit never claims a timestamp it does not have. Keys this class does not know are dropped; put anything you want kept underdetail.
- class spacr.curation.CurationLog(artifact: Any = '', *, source: str = 'spacr')[source]¶
An append-only ledger of corrections to one artefact.
- Parameters:
artifact – what the edits were made to — a mask file, a tracks CSV. Recorded in the ledger so a file that gets renamed still says what it was when it was edited.
source – what made the edits (“spacr-qt curation”).
Append-only in the API as well as in spirit: there is no
removeand no way to rewrite an entry. An undone paint appends anundoedit rather than deleting thepaint— the fact that something was painted and then taken back is itself part of what happened, and a ledger you can quietly tidy is not evidence of anything.Initialize an empty ledger for one artifact and editing source.
- Parameters:
artifact – artifact identity recorded in future serialized ledgers; a falsey value becomes
"".source – description of the editing application, coerced to text.
- append(kind: str, target: Any, *, n_changed: int = 0, **detail: Any) CurationEdit[source]¶
Record one correction and return it.
- Parameters:
kind – the verb. The curation classes write
"paint","undo","join","split"and"delete"; nothing here restricts it, but it is the keycounts()groups by and the worddescribe()prints, so a second spelling of an existing action reads as a second kind of edit.target – what was corrected, in that kind’s own terms — a label for a paint, a track id for a split, a pair of ids for a join. Stored as handed over, and the writer falls back to
strfor anythingjsonwill not take: a numpy integer comes back out of the ledger as the string"7"and no longer matches the id it came from, which is why the track operations pass ids through_plain()first.n_changed – how much actually moved — voxels painted, rows re-assigned. Left at its default of 0 the entry cannot be told from an action that did nothing, which is most of what this number is for.
detail – any further keys worth keeping with the entry: the brush radius, the frame a split happened at, the labels that were overwritten. They land in
CurationEdit.detailverbatim and are serialised with the rest of the ledger, so the samestrfallback applies to their values.
- classmethod read(path: Any) CurationLog[source]¶
Read a ledger back. A missing file is an empty ledger.
- Parameters:
path – the ledger itself, not the artefact — use
read_beside()when you have the artefact’s name. A path that does not exist gives an empty ledger, which is how a first session starts and why this is safe to call unguarded; a file that exists but is not JSON lets the decode error out rather than reporting the data as never edited.
- classmethod read_beside(artifact: Any) CurationLog[source]¶
Read
<artifact>.curation.json.- Parameters:
artifact – the artefact whose sidecar to open; the suffix is appended here. No sidecar means an empty ledger whose
artifactis""rather than this name, so a session that intends to write should construct its own log with the artefact rather than editing the one this returns.
- write(path: Any) str[source]¶
Write the ledger to
path, atomically. Returns the path.Atomic because the ledger is written after every action, including while a long session is running: a half-written JSON file left by a crash would make the whole history unreadable, and the history is the one thing that cannot be reconstructed from the data.
- Parameters:
path – the ledger file to write, extension and all — no suffix is added, so pass the full
<artifact>.curation.jsonname or usewrite_beside()to build it. Missing parent directories are created. The bytes go to a hidden.<name>.tmpsibling and are then renamed over the target, so the destination directory must allow creating a file and not merely overwriting one, and any ledger already atpathis replaced whole rather than appended to.
- write_beside(artifact: Any) str[source]¶
Write to
<artifact>.curation.json.- Parameters:
artifact – the artefact to sit beside. It need not be the one this ledger names:
artifactis set when the log is created and is not updated here, so a ledger written next to a copy still records which file the edits were actually made to.
- property edits: Tuple[CurationEdit, ...][source]¶
Everything recorded, oldest first.
- class spacr.curation.LabelEdit[source]¶
Exactly which elements a stroke changed, and what they were.
The undo record. Holding the previous values rather than a whole copy of the array is what makes an unbounded-looking history affordable: a brush stroke touches a few thousand voxels of a field that is tens of millions, so a hundred strokes cost less than one copy of the mask.
- Parameters:
index – one integer coordinate array per labels-data axis, restricted to positions this dab actually changed and aligned element-for-element with
before.before – previous label value at each coordinate in
index; undo groups these values and writes each one back to its original positions.after – integer label written at every indexed position; stroke summaries record it as the value painted.
radius – brush radius used for this dab in world units. It is retained as provenance even if the session radius changes later; defaults to
0.0for manually constructed records.
- revert(layer) int[source]¶
Put the previous labels back. Returns how many elements moved.
Element by element rather than one assignment, because a stroke that crossed three objects has three previous labels and restoring “the” previous label would flatten them into one — which is a new editing mistake introduced by the undo.
- Parameters:
layer – the labels layer to write back into — the same layer the dab was taken from, still the same shape.
indexholds raw element indices, not world coordinates, so reverting against a re-loaded, re-cropped or differently oriented array silently restores the old labels in the wrong places instead of failing. Onlyset_labels_atis used, so the layer’s subscribers hear one notification per distinct label restored, not one per element.
- class spacr.curation.MaskCuration(layer, *, artifact: Any = '', history: int = 64, log: CurationLog | None = None)[source]¶
A brush over a
spacr.layers.LabelsLayer, with an undo history and a ledger.- Parameters:
layer – the labels layer to edit.
artifact – what the mask is stored as, for the ledger. Defaults to the layer’s name.
history – how many strokes
undo()can walk back. Bounded, so a long session cannot grow without limit; the ledger is unbounded and is what a reviewer reads.log – the
CurationLogevery edit is recorded in. Defaults to a fresh one for this artifact; pass an existing log to record a mask and its tracks into a single ledger.
Strokes, not points. A drag is dozens of
paint()calls and one thing the user did, sobegin_stroke()/end_stroke()group them and undo takes back the whole stroke. Painting without opening a stroke is still legal — one dab is one stroke — because a click is a legitimate edit and should not need ceremony.save_mask()is how a session ends: it writes the corrected labels and the ledger together.save_log()writes only the ledger, and is for a caller that has already written the pixels itself.Attach a labels layer, bounded undo history, and provenance log.
- Parameters:
layer – labels layer whose data the brush edits.
artifact – artifact identity for persistence; a falsey value falls back to the layer name and then
"mask".history – maximum completed strokes retained for undo, clamped to at least one.
log – existing ledger to share, or
Nonefor a new curation ledger.
- delete_object(label: int) CurationEdit | None[source]¶
Remove one object whole: every element holding
labelbecomes 0.One click, one undoable stroke, and one
deleteentry in the ledger naming the label and how many elements it covered. Rubbing an object out with the eraser does the same to the pixels, but the ledger then reads as a run of paints of 0 and nobody can tell a deleted object from a trimmed one.- Parameters:
label – the object’s id. 0 (background) and an id the mask does not hold change nothing and record nothing.
- Returns:
the
deleteedit, orNonewhen nothing changed.
- end_stroke() CurationEdit | None[source]¶
Close the stroke and record it.
Noneif nothing changed.A stroke that changed nothing — the user pressed and released without moving, over pixels that already held the brush label — is not recorded. A ledger padded with no-op entries is one nobody reads.
- erase(world: Mapping[str, float], radius: float | None = None) int[source]¶
Paint background. The same act; named for what it is.
- Parameters:
world – the brush centre in world units, exactly as for
paint().radius – world-space radius;
Nonemeansradius, the same default the brush paints with — the eraser has no size of its own, so widening the brush widens this too.
- Returns:
how many elements changed.
- paint(world: Mapping[str, float], label: int | None = None, radius: float | None = None) int[source]¶
Paint one dab and remember exactly what it changed.
- Parameters:
world – the brush centre as
{axis: coordinate}in WORLD units, keyed by the layer’s axis names. Axes the mapping leaves out are taken as 0, which is what a 2-D click on a 3-D stack means once the viewer has filled in the slice it is showing. A centre that puts the whole ball off the grid paints nothing and returns 0 rather than raising.label – the value to write;
Nonemeanslabel. 0 is background, so painting 0 is an erase — seeerase(). Elements that already hold this value are not counted, not recorded, and cannot be undone, because nothing happened to them.radius – brush radius in world units — µm on a calibrated stack, pixels on an uncalibrated one;
Nonemeansradius. The brush is a ball in world space, so on an anisotropic stack it reaches fewer z-slices than y-rows. 0 is not a one-element brush: it covers only an element whose centre the point lands on exactly, so a click at a fractional coordinate changes nothing.
- Returns:
how many elements changed.
- save_log(artifact: Any | None = None) str[source]¶
Write the ledger beside the artefact. Returns the path.
- Parameters:
artifact – what to write beside; the ledger goes to
<artifact>.curation.json. Anything falsy — including the defaultNone— meansartifact, which when the session was built without one is only the layer’s name, so the ledger lands in the process’s working directory rather than next to the image. Pass the mask’s real path here, or at construction, if that is not what you want. Writing to a different place does not change the artefact name recorded inside the ledger.
- save_mask(artifact: Any | None = None) str[source]¶
Write the corrected labels to disk, with the ledger beside them.
The pixels and the record are requested by one call, because either one alone is a lie. They are two sequential filesystem writes, not an atomic transaction; if a process stops between them, call this method again to bring the ledger back in step. A ledger written on its own asserts corrections to a file whose pixels are untouched, and
is_curated()then reports that untouched file as hand-edited; labels written on their own are a curated mask that is byte-indistinguishable from a segmented one, which is the hole this module exists to close.- Parameters:
artifact – where the labels go; anything falsy — including the default
None— meansartifact. The extension chooses the format:.npywrites NumPy, anything else writes a compressed uint16 TIFF, so the resolved path is what comes back and need not be what went in.- Returns:
the path the labels were written to. The ledger sits at that path plus
LOG_SUFFIX, so the two can never name different files.- Raises:
CurationError – when there is no path to write to.
A session that painted nothing writes the labels and no ledger, for the same reason
save_log()records nothing: a sidecar beside every mask ever opened answers no question.
- subscribe(fn) None[source]¶
Call
fn(edit)whenever a correction is recorded.The seam a panel refreshes off. Distinct from subscribing to the layer, which fires once per dab: a stroke is many dabs and one ledger entry, and a view that wants to show the entry has to hear about the entry.
- Parameters:
fn – called as
fn(edit)with theCurationEditjust appended, synchronously, on whichever thread made the edit. Pass a bound method: registration is de-duplicated by equality, and a bound method looked up fresh compares equal to the one already held, sosubscribetwice is a no-op andunsubscribe()works — where each fresh lambda is a new object that stacks up and cannot be removed, besides keeping a closed panel alive as a receiver. An exception raised insidefnis swallowed: the correction has already happened to the data, and one view’s failed redraw must not be reported as a failed edit.
- undo() CurationEdit | None[source]¶
Take back the last stroke.
Nonewhen there is nothing to undo.Appends an
undoentry rather than removing thepaintone. That something was painted and then taken back is part of what happened, and a ledger that can be quietly tidied is not evidence of anything.
- unsubscribe(fn) None[source]¶
Stop listening. Safe for something that never subscribed.
- Parameters:
fn – matched by equality against what
subscribe()was given, so the usual teardown — handing back the same bound method — removes it, while a lambda can only be removed by passing the very object that was subscribed. Anything not currently registered is ignored rather than raising, so a panel’s close handler need not know whether it ever connected.
- class spacr.curation.TrackCuration(tracks: pandas.DataFrame, *, artifact: Any = '', log: CurationLog | None = None)[source]¶
Join, split and delete tracks by hand, on the record.
- Parameters:
tracks – a track table —
spacr.zstack.BASE_TRACK_COLUMNS, i.e.frame,track_id,original_labeland the centroid. Copied, so the caller’s frame is never edited underneath them.artifact – the tracks CSV, for the ledger.
log – the
CurationLogevery edit is recorded in. Defaults to a fresh one for this artifact; pass the log aMaskCurationis using to keep both halves of one curation session on one record.
Every operation preserves a consistent input table, and consistency here has a definition worth stating because it is what the checks enforce:
one row per
(track_id, frame)— a track is one object’s path, so a track that is in two places at one time is not a track;every track’s frames are the frames it actually has, and a join may not produce a track that overlaps itself in time.
Construction validates only the two key columns.
check()returns pre-existing violations rather than raising, so a table that arrived broken can be shown to be broken instead of making every operation on it fail with the same message.Validate key columns and attach a copied table and provenance log.
- Parameters:
tracks – source track table; it must contain
frameandtrack_idand is copied before any operation.artifact – persisted track artifact identity; a falsey value becomes
"tracks".log – existing ledger to share, or
Nonefor a new curation ledger.
- Raises:
CurationError – if either required key column is absent.
- check() List[str][source]¶
Everything wrong with the table right now, as sentences.
Empty for a consistent table. Returned rather than raised so a broken table can be shown to the user; the operations raise, because an operation that would create a violation must not happen.
- delete(track_id: Any) CurationEdit[source]¶
Remove a track entirely — debris, or a tracker artefact.
The rows go, but the ledger keeps what went: how many rows and which frames, so a count that changed between two analyses can be explained rather than argued about.
- Parameters:
track_id – the track to drop. Every row carrying it goes, in every frame — there is no partial delete, so
split()the track first if only one end of it is debris. An id that is not in the table raisesCurationErrorinstead of removing nothing and recording a delete that did not happen.
- frames_of(track_id: Any) List[Any][source]¶
The frames
track_idappears in, sorted.- Parameters:
track_id – matched with
==against thetrack_idcolumn, so it has to be the same kind of value the table holds — 3 finds nothing in a table of strings. An id that is not there gives an empty list rather than raising: this is a reader, and the operations do their own existence check. The result is sorted on the frame values themselves, so a frame column of strings sorts lexicographically and"10"lands before"2".
- join(first: Any, second: Any) CurationEdit[source]¶
Make
seconda continuation offirst.The commonest correction there is: a track breaks when a cell briefly leaves focus, and the same cell comes back with a new id, so one cell becomes two half-length tracks and every velocity computed from them is wrong at the join.
Refused when the two overlap in time. Two tracks present in the same frame are two objects, and joining them would put one track in two places at once — which is exactly the state
check()exists to forbid, and producing it silently would corrupt the table on the way to fixing it.- Parameters:
first – the track that survives. Its id is what every joined row ends up carrying and what downstream analysis will see, so pass the one you want to keep — usually the earlier half, though nothing here requires it.
second – the track absorbed. Its rows are re-labelled in place, never moved or re-timed, and its id then no longer exists in the table. Nothing checks that the two halves are adjacent or even near each other in time — only that they do not overlap — so this will happily join tracks fifty frames apart if you ask it to.
- Returns:
the ledger entry.
- Raises:
CurationError – on an unknown track, joining a track to itself, or a time overlap.
- save(path: Any) str[source]¶
Write the curated table AND its ledger. Returns the CSV path.
One call, deliberately. A curated table written without its ledger is exactly the reproducibility hole this module exists to close, and leaving the second write to the caller is how that happens. The CSV and ledger remain sequential filesystem writes rather than one atomic transaction; retry this method if a process stops between them.
- Parameters:
path – where the CSV goes; missing parent directories are created. Two files are written, not one — the ledger lands at
<path>.curation.jsonbeside it, so copying the CSV onward without that sidecar drops the record of every correction. The artefact name insidelogis repointed at this path first, so the saved ledger names the file it was saved with rather than whatever the session was opened on. The table is written sorted by track then frame, and any file already atpathis overwritten.
- span(track_id: Any) Tuple[Any, Any] | None[source]¶
(first frame, last frame)of a track, orNoneif absent.- Parameters:
track_id – the track to measure, matched as in
frames_of(). Unknown givesNone, which is how to ask “is this track here at all” in one call. Both ends are inclusive, so a track living in one frame answers with that frame twice rather than an empty or half-open range, and a track with gaps answers with its outer bounds — the span is not the frame count.
- split(track_id: Any, at_frame: Any) CurationEdit[source]¶
Break
track_idin two:at_framestarts the new track.The other half of the commonest pair of errors: two cells touch, the tracker swaps them, and one id follows cell A then cell B. Splitting at the frame where it changed hands turns one wrong track into two right ones.
- Parameters:
track_id – the track to break. It keeps the frames before
at_frameand keeps its id, so references to the head stay valid; the tail is what gets renamed.at_frame – the first frame of the NEW track — rows at this frame and after are re-assigned, rows before it are left alone. Compared with
<and>=, so it need not be a frame the track actually has; any value with rows on both sides of it works, and one that would leave a side empty is refused rather than quietly doing nothing.
- Returns:
the ledger entry, whose
detail['new_track']is the id the tail was given.- Raises:
CurationError – for an unknown track, or a frame that would leave one side empty — a “split” that moves nothing is a no-op the user will read as having worked.
- to_frame() pandas.DataFrame[source]¶
The curated table, sorted by track then frame.
- spacr.curation.is_curated(artifact: Any) bool[source]¶
Whether
artifacthas been edited by hand — the question the rule exists to answer.Trueonly when a ledger exists and holds at least one edit. An empty ledger left by a session that opened the brush and painted nothing is not a curated dataset, and reporting it as one would make the flag useless by making it always true.- Parameters:
artifact – the artefact itself — the mask or tracks file, not its ledger; the
.curation.jsonsuffix is appended here. Only the sidecar is opened, so the artefact may be absent or unreadable without changing the answer. A sidecar that exists but will not parse answersTrue: a damaged provenance record is a reason to be suspicious, not grounds for certifying the data as raw.
- spacr.curation.log_path_for(artifact: Any) str[source]¶
Where the ledger for
artifactlives:<artifact>.curation.json.Keyed on the full name including its extension, so
masks.tifandmasks.npyin one folder get their own ledgers instead of sharing one and interleaving two histories.- Parameters:
artifact – the artefact’s own path — anything
os.fspath()accepts, so apathlib.Pathas readily as a string. Nothing is opened or checked and the file need not exist, which is what lets a ledger be opened for a mask that is about to be written.