spacr.seg_qc¶
Score segmentation masks the moment they exist, before Measure runs.
Why this exists¶
measure_crop is the expensive step in spaCR: it opens every mask, cuts
every object out of every channel, computes hundreds of features per object
and writes them to SQLite. On a full plate that is hours. A plate whose
segmentation collapsed — a channel out of focus, a diameter two-fold wrong, a
confluent monolayer that fused into slabs — costs exactly the same hours and
then produces a measurement table that has to be thrown away. The failure is
obvious from the masks alone; nobody looks, because looking means opening a
thousand .npy files by hand. Mask statistics can highlight suspicious
fields, but cannot establish their cause or validate biological interpretation.
So this module looks. It reads the label masks that
spacr.object has just written, scores every field against a handful of
robust statistics, and prints a scorecard naming fields that exceed configured
thresholds. It changes nothing: seg_qc='report' (the default) surfaces the
flags, it does not silently drop fields. Deciding which fields to keep is the
user’s call and it needs the evidence, not a filter.
Design constraint: this runs right after segmentation on every field of the
plate, so it must be cheap. Everything below is np.bincount on a label
image — microseconds per field. The one expensive check (the distance-transform
cross-check for fusion) runs only on fields dense enough for fusion to be the
explanation, so a healthy plate never pays for it. Like spacr.diameter,
this module imports no torch and no cellpose — that is a tested property
(see tests/test_seg_qc.py), not an aspiration.
What is measured, and why that number¶
Every threshold below is a keyword argument with a default in
QC_DEFAULTS, and every one of them is exposed as a seg_qc_* setting
(see SETTING_KEYS) so a user with 3 px organelles or a deliberately
confluent assay can move it.
object countand its deviation from the plate mediancount_ratioisn_objects / plate_median; outside[seg_qc_count_ratio, 1/seg_qc_count_ratio](0.25 to 4-fold) the field is flagged. Such a difference may reflect treatment, infection, seeding, acquisition or segmentation. Inspect the experimental layout and images; the count ratio alone cannot distinguish those explanations.under-segmentation(fused objects)Detected the way
spacr.diameterdetects it, and for the same reason: both halves of the signature are required. The field must be dense enough for fusion to be the explanation (foreground at or aboveseg_qc_foreground_fraction, default 0.35 — the same numberdiameter.estimate_diameters(fused_fraction=...)uses, because it is the same question), and the mask must under-count what the pixels support: either one label covers more thanseg_qc_max_object_fractionof the field (0.25, again diameter.py’s number), or the Euclidean distance transform of the foreground resolves at leastseg_qc_split_ratio(2.0) inscribed-circle maxima per mask object. Requiring both matters in each direction: a dense but correctly separated field reaches 35% foreground and is fine, while an elongated or hollow object shatters the distance transform into several maxima and is also fine. diameter.py demands a 5-fold seed excess because it compares against raw Otsu components, where a confluent monolayer is one blob; here the comparison is against a Cellpose mask that already separates most objects, and the smallest fusion worth catching — every object being a welded pair — is exactly 2. Large, irregular or confluent biological objects can also trigger the heuristic; confirm boundaries against the raw images.over-segmentation(shattered objects)Two signatures. Absolutely: at least
seg_qc_tiny_fraction(0.30) of the field’s objects are underseg_qc_min_diameter(5 px) across — spaCR’s diameter estimator already discards components below 4 px as debris, but these defaults must be adapted for genuinely small objects. Relative to the plate: the field holdsseg_qc_size_ratio-fold more objects than the plate median at1/seg_qc_size_ratioof its median diameter. That knob defaults to 1.4 = sqrt(2) on purpose — two objects welded into one have sqrt(2) times the equivalent diameter of one, and one object split in two has 1/sqrt(2) of it, so 1.4 is the fused-pair / split-in-half signature itself rather than an arbitrary tolerance.% border-touching objectsObjects that touch the field edge are truncated, so their crops are cut off and their areas understate the truth. For objects of diameter d on a W-wide field the geometric expectation is about
2*d/W— 8% for 60 px cells on a 1400 px field.seg_qc_border_fractiondefaults to 0.30: a configurable review threshold, not a universal geometric limit.size outliersThe fraction of objects whose equivalent diameter falls outside
median ± seg_qc_outlier_mad * 1.4826 * MAD. Median and MAD, not mean and standard deviation: five pieces of debris inflate a standard deviation until nothing is an outlier any more, which is the exact case this check exists to catch (tests/test_seg_qc.pycontrasts the two). k = 5 is deliberately loose — real size distributions are lognormal and heavier tailed than Gaussian, so 3 sigma flags a few percent of a perfectly good field. The flag fires when more thanseg_qc_outlier_fraction(0.15) of objects are out there. Heavy tails, mixed populations and segmentation defects can all trigger it; the flag does not identify which is present. Border objects are excluded from every size statistic, exactly asdiameter._region_diametersexcludes them, because a truncated object’s area is a lie.empty / near-empty fieldsZero objects is a failure. Fewer than
seg_qc_min_objects(10) is a warning and suppresses the per-field robust statistics, because below ten objects a MAD is one object’s opinion — the same threshold at which diameter.py collapses its confidence two levels. If the plate median is also below that floor (a low-MOI pathogen channel, say), empty fields are demoted to a warning: that is a property of the assay, not of the field.
Public API¶
FieldQCOne field’s verdict: counts, flags, the numbers behind them, severity.
score_field(mask, object_type, **thresholds)Score one label image, with no plate context.
score_masks(source, object_type, ...)Score a folder of
.npymasks, a 3-D stack or a list of 2-D masks, then add the plate-relative flags.summarize_qc(field_qcs)Plate-level rollup: verdict, counts per severity, the failing field names.
format_scorecard(field_qcs)The printable card.
write_scorecard(field_qcs, dst, object_type)<dst>/qc/segmentation_qc_<object_type>.csv, one row per field.run_segmentation_qc(...)What
spacr.objectcalls: score, write, print, honourseg_qc.thresholds_from_settings(settings)Pull the
seg_qc_*knobs out of a settings dict.
Reading the verdict back — what a GUI shows before Measure¶
Everything above runs once, at mask time, and writes a CSV. The second half of this module reads that CSV back and turns it into something a user can act on, without scoring a single mask again:
FLAG_GUIDANCE/explain_flag(flag)Every flag in
FLAGSin plain language: what it means for the measurements, possible explanations, and what to inspect. Where uneven illumination is one possible cause, the entry says so and points atspacr.illumination, which estimates the lamp profile from the plate’s own fields and divides it out.parse_field_name(name)/FieldAddressplate1_E07_3→ plateplate1, wellE07, rowE, column 7. Naming the plate and the wells is the difference between “3 plates failed” and something a user can go and look at.diagnose(field_qcs)→[Finding]Per-plate, per-flag findings that name the plate and the wells, plus the positional ones no single field can see: a plate whose object count or object size steps between one half of the rows (or columns) and the other. These patterns warrant checking illumination, experimental layout, treatment and biology; they do not establish a cause.
read_digest(src)→QCDigestThe cheap path: locate
qc/segmentation_qc_*.csvunder a project, parse it, roll it up, diagnose it, and compare each card’s mtime against its mask stack so a card written before the last re-mask is reported as out of date rather than believed. Scores nothing.score_digest(src)→QCDigestThe expensive path, for the user who asks for it explicitly: score the mask stacks under a project, write the cards, return the same digest.
format_digest(digest)The printable version.
Exceptions¶
The plate failed segmentation QC and |
Classes¶
Where on the plate a scored field sits. |
|
One field's segmentation verdict. |
|
One thing worth telling the user, with the evidence attached. |
|
One flag, in the words of someone deciding whether to run Measure. |
|
Everything a screen needs to show a segmentation verdict. |
|
One |
Functions¶
|
Turn per-field verdicts into findings that name plates, wells and causes. |
|
Return the plain-language entry for |
|
|
|
Every |
|
Render a digest as text — what the console prints, what a test reads. |
|
Render findings as text, one block each. |
|
Render the scorecard a human reads before deciding to run Measure. |
|
Modification time of the newest mask in |
|
Split a field name into plate / well / row / column. |
|
Read |
|
Project folders to look for scorecards under, best first. |
|
Read the segmentation verdict for a project. Scores nothing. |
|
Rebuild |
|
Score a plate's masks, write the card, print the summary. |
|
Score the mask stacks under |
|
Score one label mask on its own, with no knowledge of the plate. |
|
Score every field of a plate and add the plate-relative flags. |
|
Roll the per-field verdicts up into one plate verdict. |
|
Pull the |
|
Write one CSV row per field to |
Module Contents¶
- exception spacr.seg_qc.SegmentationQCFailed(message: str, summary: Dict[str, Any] | None = None)[source]¶
Bases:
RuntimeErrorThe plate failed segmentation QC and
seg_qc='stop'was set.Carries the summary so a caller can report WHICH fields failed and why, rather than only that something did.
- Parameters:
message – the exception’s own message.
summary – the QC summary, so a caller can report WHICH fields failed and why rather than only that something did.
Nonewhen there is nothing to carry.
Initialize the message and copy its structured QC summary.
- class spacr.seg_qc.FieldAddress[source]¶
Where on the plate a scored field sits.
- Parameters:
field – the field name as the scorecard holds it.
plate – first underscore-separated token, or the whole stem when there is no underscore (a hand-assembled folder is its own plate).
well – second token when it parses as a well, else
''.row – the well’s letter part, uppercased, else
''.column – the well’s numeric part as an int, else
None.
- class spacr.seg_qc.FieldQC[source]¶
One field’s segmentation verdict.
- Parameters:
field – the mask’s name — the
.npystem, so it can be found on disk and opened.object_type –
'cell','nucleus','pathogen','organelle'— whatever was segmented.n_objects – labels present in the mask.
flags – the named defects, e.g.
['under_segmented', 'high_border_fraction']. Empty means clean.metrics – the numbers behind the flags, all floats so the card can be written to CSV without special cases.
float('nan')marks a metric that could not be computed (too few objects, or no plate context).severity –
'ok','warn'or'fail'— the worst of the flags raised.note – the same verdict in prose, with the numbers in it.
- class spacr.seg_qc.Finding[source]¶
One thing worth telling the user, with the evidence attached.
- Parameters:
severity –
'fail','warn'or'ok'— how a banner should colour it. Never a gate: nothing in this module blocks anything.kind –
'flag','count_gradient','size_gradient'or'clean'. What the finding is about, for a caller that wants to show one kind and not another.headline – one line, with the plate and the number in it.
detail – what it means for the measurements and why it happens.
fix – what to do about it.
flag – the seg_qc flag behind it, for
kind='flag'.plate – the plate it is about, when it is about one.
object_type – what was segmented.
wells – the wells implicated, in name order.
fields – the field names implicated, in name order.
n_fields – how many fields are implicated.
illumination – True when uneven illumination is a named cause, so a caller can offer
spacr.illuminationbeside the finding.
- class spacr.seg_qc.FlagGuidance[source]¶
One flag, in the words of someone deciding whether to run Measure.
- Parameters:
flag – the flag name, a member of
FLAGS.severity –
'fail'or'warn', from_FLAG_SEVERITY.headline – the flag in five words, for a table cell or a chip.
means – what it does to the measurements — not what the mask looks like, but which numbers in
measurements.dbcome out wrong.causes – the usual causes, most common first.
fix – what to do next, naming the setting or the module that does it.
illumination – True when uneven illumination is one of the causes, i.e. when
spacr.illuminationis part of the answer.
- class spacr.seg_qc.QCDigest[source]¶
Everything a screen needs to show a segmentation verdict.
Advisory by construction: there is no field here a caller is meant to branch a Run button on, and
blocks_runis a constant False that exists to say so in code rather than only in a docstring.- Parameters:
root – the project folder the cards were found under.
verdict –
'ok','warn','fail','missing'(nothing has scored these masks) or'error'.headline – the single most actionable sentence — the worst finding, with its plate and its number in it. What a banner puts in bold.
subhead – the counts behind it: how many fields, how many failed.
scorecards – one per object type found.
findings – what
diagnose()made of current, readable cards.stale – True when any card is older than its masks.
checked_at – when this digest was built (
time.time()).blocks_run – always
False; segmentation QC advises the user but never prevents them from continuing a run.
- class spacr.seg_qc.Scorecard[source]¶
One
qc/segmentation_qc_<object_type>.csv, read back.- Parameters:
path – where it was read from.
object_type – what it scored, taken from the file name.
field_qcs – the rows, rebuilt as
FieldQC.summary – what
summarize_qc()makes of them.mtime – the card’s modification time.
masks_mtime – the newest mask file in the stack it describes, or
0.0when that stack could not be found.stale – True when a mask is newer than the card, i.e. the plate was re-masked and nothing has scored the new masks.
error – why the card could not be read, when it could not be.
- spacr.seg_qc.diagnose(field_qcs: Sequence[FieldQC], *, gradient_ratio: float = GRADIENT_RATIO, min_fields_per_half: int = MIN_FIELDS_PER_HALF, max_named: int = MAX_NAMED) List[Finding][source]¶
Turn per-field verdicts into findings that name plates, wells and causes.
Two kinds, because they answer different questions:
flag findings — one per (plate, flag): which wells, how many fields, what that flag does to the measurements and what to do about it. This is the scorecard, grouped and translated.
gradient findings — a plate whose object count or object size steps between one half of its rows (or columns) and the other. No single field can raise this and no field-level flag implies it: a plate can be entirely free of per-field flags and still be lit unevenly enough to put a position term in every intensity feature.
- Parameters:
field_qcs – what
score_masks()returned, or whatread_scorecard()read back off disk.gradient_ratio – fold step between plate halves at which the positional findings fire.
min_fields_per_half – fewest fields a half-plate needs before its median is compared.
max_named – how many wells or fields a finding names.
- Returns:
findings, failures first, then by how many fields each implicates. An empty list means there is nothing to say.
- spacr.seg_qc.explain_flag(flag: str) FlagGuidance[source]¶
Return the plain-language entry for
flag.- Parameters:
flag – a member of
FLAGS.- Returns:
its
FlagGuidance.- Raises:
KeyError – for a flag with no entry, which is a bug in this module rather than a caller error — every flag it can raise is explained.
- spacr.seg_qc.find_mask_stacks(root: str) Dict[str, str][source]¶
{object_type: folder}for the mask stacks under a plate.spacr.objectwrites<src>/<object_type>_mask_stack, wheresrcis a folder inside the plate (norm_channel_stack), so the stacks sit one level below the plate root the card is written at. Both levels are checked, and neither is walked further.- Parameters:
root – a plate folder.
- spacr.seg_qc.find_scorecards(src: Any) Tuple[str, ...][source]¶
Every
qc/segmentation_qc_<object>.csvundersrc.- Parameters:
src – whatever
qc_roots()accepts.- Returns:
absolute paths, sorted, flag sidecars excluded.
- spacr.seg_qc.format_digest(digest: QCDigest) str[source]¶
Render a digest as text — what the console prints, what a test reads.
- Parameters:
digest – completed segmentation-QC digest to render.
- spacr.seg_qc.format_findings(findings: Sequence[Finding]) str[source]¶
Render findings as text, one block each.
- spacr.seg_qc.format_scorecard(field_qcs: Sequence[FieldQC], plate_fail_fraction: float | None = None, max_rows: int = _MAX_CARD_ROWS) str[source]¶
Render the scorecard a human reads before deciding to run Measure.
The table lists the fields that are not clean — on a good plate that is no rows at all, and on a bad one it is the list you want. Clean fields are counted, not printed: a 1536-field plate must not scroll a terminal.
- Parameters:
field_qcs – what
score_masks()returned.plate_fail_fraction – passed to
summarize_qc().max_rows – how many bad fields to name before summarising the rest.
- Returns:
a multi-line string, ready to print.
- spacr.seg_qc.mask_stack_mtime(folder: str) float[source]¶
Modification time of the newest mask in
folder, or0.0.The folder’s own mtime is included:
spacr.objectwrites masks by atomic rename, which touches the directory, so a stack whose files were all replaced is caught even if the sample below misses the newest one.- Parameters:
folder – a
<object_type>_mask_stackfolder.
- spacr.seg_qc.parse_field_name(name: str) FieldAddress[source]¶
Split a field name into plate / well / row / column.
Never raises and never guesses: a name that does not carry a well comes back with empty well, row and column, and callers do not make the positional claims that need them.
- Parameters:
name – a field name, file name or path — extension and directories are stripped.
- spacr.seg_qc.qc_mode(settings: Mapping[str, Any]) str[source]¶
Read
seg_qcout of a settings dict and normalise it.Accepts the three documented modes plus the shapes a settings CSV round trip produces (
None,'','False',True). Anything unrecognised falls back to'report', because the cost of computing a scorecard nobody asked for is a second, and the cost of silently skipping one somebody did ask for is a wasted Measure run.- Parameters:
settings – any mapping.
- Returns:
'off','report'or'flag'.
- spacr.seg_qc.qc_roots(src: Any) Tuple[str, ...][source]¶
Project folders to look for scorecards under, best first.
Handles the shapes
settings['src']actually takes:a plate folder — cards are in
<src>/qc;the merged folder Measure is usually pointed at (
<plate>/merged) — cards are one level up, the same hopspacr.ports.project_root()makes;a list of plates, which is how several plates are run at once;
a project root holding plate subfolders, each with its own
qc/.
- Parameters:
src – a path, a list of paths, or None.
- Returns:
existing folders, de-duplicated, in the order above.
- spacr.seg_qc.read_digest(src: Any, **kwargs) QCDigest[source]¶
Read the segmentation verdict for a project. Scores nothing.
The cheap path, and the one a screen calls: locate the scorecards the mask run already wrote, parse them, roll them up, diagnose them, and date each one against its mask stack.
- Parameters:
src – whatever
qc_roots()accepts — a plate folder, a merged folder, a list of plates or a project root.kwargs – passed to
diagnose().
- Returns:
a
QCDigest.verdict == 'missing'when no card exists, which is emphatically not the same as'ok'.
- spacr.seg_qc.read_scorecard(path: str) Tuple[List[FieldQC], str][source]¶
Rebuild
FieldQCrows from a scorecard CSV.The rows are handed back to
summarize_qc()anddiagnose()rather than re-derived, so what a screen shows is the verdict the run printed and not a second opinion that could disagree with it.- Parameters:
path – a
qc/segmentation_qc_<object_type>.csv.- Returns:
(field_qcs, error). A damaged card comes back as([], reason): half a card is a different verdict, and this module does not invent one.
- spacr.seg_qc.run_segmentation_qc(source: Any, object_type: str = 'object', dst: str | None = None, mode: str = 'report', thresholds: Mapping[str, Any] | None = None, verbose: bool = True, print_fn=print) Dict[str, Any] | None[source]¶
Score a plate’s masks, write the card, print the summary.
This is the entry point
spacr.objectcalls once per object type, immediately after the masks are on disk.- Parameters:
source – whatever
score_masks()accepts — normally the<object_type>_mask_stackfolder.object_type – what was segmented.
dst – plate folder the
qc/subfolder is written under. None scores and prints without writing anything.mode –
'off'does nothing at all and returns None;'report'computes, writes and prints, changing nothing;'flag'additionally writessegmentation_qc_<object_type>_flags.jsonmapping field name to flags, for a downstream step to consume. No mode deletes or skips a field — surfacing the problem is the job.thresholds – overrides for
QC_DEFAULTS, e.g. fromthresholds_from_settings().verbose – True prints the full card, False prints the one-line verdict. The verdict is always printed: a plate that failed QC must not be able to fail quietly.
print_fn – injection point for tests and for the GUI’s log widget.
- Returns:
{'mode', 'object_type', 'field_qcs', 'summary', 'csv_path', 'flags'}, or None whenmodeis'off'.
- spacr.seg_qc.score_digest(src: Any, object_types: Sequence[str] = (), thresholds: Mapping[str, Any] | None = None, write: bool = True, **kwargs) QCDigest[source]¶
Score the mask stacks under
srcand return the same digest.The expensive path — it opens every mask — so it is never called to draw a screen. It exists for the user who has just been told the verdict is missing or out of date and asks for it to be brought up to date; writing the cards is what makes the next
read_digest()cheap again.- Parameters:
src – whatever
qc_roots()accepts.object_types – which stacks to score; empty means all that exist.
thresholds – overrides for
QC_DEFAULTS, e.g. fromthresholds_from_settings().write – write each card back to
<plate>/qc/.kwargs – passed to
diagnose().
- spacr.seg_qc.score_field(mask: Any, object_type: str = 'object', field: str = 'field', **thresholds: Any) FieldQC[source]¶
Score one label mask on its own, with no knowledge of the plate.
Everything here is plate-independent: counts, foreground, border fraction, the robust size range, the fusion cross-check. The comparisons that need the plate (count and size relative to the plate median) are added by
score_masks()afterwards.- Parameters:
mask – a 2-D label image (or anything
numpy.squeeze()reduces to one). Booleans are labelled first; floats are rounded.object_type – what was segmented, carried through to the card.
field – the field’s name, used in the card and the CSV.
thresholds – any key of
QC_DEFAULTS. Unknown keys raise, so a typo in a threshold name cannot silently do nothing.
- Returns:
a
FieldQC. A mask that cannot be read comes back flaggedunreadable_maskrather than raising — one corrupt field must not cost the plate its scorecard.
- spacr.seg_qc.score_masks(source: Any, object_type: str = 'object', **thresholds: Any) List[FieldQC][source]¶
Score every field of a plate and add the plate-relative flags.
- Parameters:
source – a folder of
.npymasks (the<object>_mask_stackfolderspacr.objectwrites), a single.npyfile, a 3-D mask stack, a{name: mask}mapping, or a sequence of 2-D masks.object_type – what was segmented.
thresholds – any key of
QC_DEFAULTS.
- Returns:
one
FieldQCper field, in field-name order. An empty list when the source holds no mask — that is not an error, it is what a run withsave=Falselooks like.
- spacr.seg_qc.summarize_qc(field_qcs: Sequence[FieldQC], plate_fail_fraction: float | None = None) Dict[str, Any][source]¶
Roll the per-field verdicts up into one plate verdict.
- Parameters:
field_qcs – what
score_masks()returned.plate_fail_fraction – fraction of failing fields at which the plate itself is called a failure; defaults to
QC_DEFAULTS['plate_fail_fraction'](0.10 — roughly one column of a 96-well plate. Below that you can drop the bad fields and keep the plate; above it, what Measure produces is not the experiment).
- Returns:
a dict with the counts, the flag tally, the failing and warning field names, the plate medians and a one-line
message.
- spacr.seg_qc.thresholds_from_settings(settings: Mapping[str, Any]) Dict[str, float][source]¶
Pull the
seg_qc_*knobs out of a spaCR settings dict.- Parameters:
settings – any mapping; keys that are absent, blank or not numeric are skipped so the corresponding
QC_DEFAULTSentry stands.- Returns:
{threshold_name: value}, ready to splat intoscore_field()orscore_masks().
- spacr.seg_qc.write_scorecard(field_qcs: Sequence[FieldQC], dst: str, object_type: str = 'object') str | None[source]¶
Write one CSV row per field to
<dst>/qc/segmentation_qc_<object_type>.csv.- Parameters:
field_qcs – what
score_masks()returned.dst – the plate folder; the
qcsubfolder is created if needed.object_type – names the file.
- Returns:
the path written, or None when there was nothing to write.