spacr.qt.prerun

Show segmentation QC results and measured diameters before a run starts.

The module shows two results the pipeline computes that would otherwise only be printed to a terminal or left in a module that no screen calls:

  • The segmentation verdict. spacr.seg_qc scores every mask the moment it is written and files <plate>/qc/segmentation_qc_<object>.csv. Measure then spends hours cropping and measuring those masks without anyone having read it. The banner this module puts on the Measure screen reads that card back and says what it says, naming the plate, the wells and the likely cause — a verdict a user can act on, not a count of failures.

  • The diameter. spacr.diameter measures characteristic object size from a handful of the user’s own fields, without loading Cellpose or torch. diameter is the single most consequential Cellpose 4 setting spaCR exposes — CellposeModel.eval(diameter=...) rescales every image by 30/diameter so objects land near the size cpsam works at — and it is the one users guess at. The panel this module builds replaces the guess with a measurement, per object type, and shows how many objects it measured so the estimate can be judged. It opens in a popup of its own from the Measure diameters… button in Mask generation’s Model zoo popup, next to the choice of model it is the other half of, rather than on the main screen.

Neither one blocks anything. The banner is advisory: it never touches the Run button, never disables it and never intercepts the click, because the user may have reasons to measure a plate that failed QC. BLOCKS_RUN is False, tests/qt/test_prerun.py asserts it against the real screen, and there is no code path here that could change it.

Cost

The banner reads a verdict; it does not compute one. Opening a plate’s masks takes seconds to minutes, which is too slow for every visit to the screen. So spacr.seg_qc.read_digest() parses the CSVs the mask run already wrote, dates each one against its mask stack, and reports a card older than its masks as out of date instead of using it. Only the Score the masks now button scores anything, only when pressed, and it does it on a worker thread and writes the card so the next open is cheap again.

Neither half of that runs on the GUI thread any more. The read moved onto a worker on 2026-09-04, when a single os.path.exists under a sleeping autofs mount was measured not returning for twenty seconds while install_qc_banner was doing it inside MainWindow._build_screen. See SegQCBanner.refresh().

Installation goes through the seams that already exist rather than through the shared screen: spacr.qt.app.APP_FACTORIES, consulted by MainWindow._build_screen, and spacr.qt.theme.register_widget_qss() for the colours. AppScreen is untouched. A factory already registered for one of these keys — spacr.qt.chaining registers one for every module that declares ports — is kept and delegated to, so a screen keeps the strip it already had, in either registration order.

Classes

DiameterDialog

The diameter estimate in a popup of its own, opened from the Model zoo.

DiameterPanel

A measured Cellpose diameter, per object type, from the user's own fields.

SegQCBanner

The segmentation verdict, on the Measure screen, before Measure runs.

Functions

diameter_dialog(→ Optional[DiameterDialog])

Build the diameter popup for screen, not yet shown.

diameter_screen_of(→ Optional[PySide6.QtWidgets.QWidget])

The Mask generation screen widget sits in, or None.

install(→ None)

Install the segmentation-QC banner when screen is Measure's.

install_qc_banner(→ Optional[SegQCBanner])

Put a QC switch on screen's action row that opens the verdict.

qc_banner(→ Optional[SegQCBanner])

The banner installed on screen, or None.

register(→ bool)

Install the banner on the Measure screen. Idempotent.

unregister(→ int)

Undo register(), restoring whatever factory was displaced.

Module Contents

class spacr.qt.prerun.DiameterDialog(screen: PySide6.QtWidgets.QWidget, *, estimator=None, parent: PySide6.QtWidgets.QWidget | None = None)[source]

Bases: PySide6.QtWidgets.QDialog

The diameter estimate in a popup of its own, opened from the Model zoo.

Holds the whole DiameterPanel – the measure button, one row per object type with its evidence and note, Use per row and Use all – in a popup opened from the Model zoo popup instead of on the main Mask generation screen. The panel writes into the settings of the screen that opened the zoo.

Being a QDialog is what dresses it: spacr.qt.widgets.glass gives every dialog the translucent card, the rounded corners and the travelling rim, and it recognises one by its type. The way out is Close, red, at the bottom right, like every other spaCR dialog.

Parameters:
  • screen – the Mask generation screen the estimates are for.

  • estimator – diameter estimator, for tests.

  • parent – the window that opened it, normally the Model zoo popup.

Build the popup around a fresh DiameterPanel.

Parameters:
  • screen – the screen whose settings the estimates are for.

  • estimator – how to measure the diameters.

  • parent – parent widget.

class spacr.qt.prerun.DiameterPanel(screen: PySide6.QtWidgets.QWidget, *, estimator=None, parent=None)[source]

Bases: _JobMixin, PySide6.QtWidgets.QFrame

A measured Cellpose diameter, per object type, from the user’s own fields.

CellposeModel.eval(diameter=...) under Cellpose 4 rescales the input by 30/diameter so objects land near the size cpsam works at, which makes a two-fold error in this one number a two-fold error in every mask, count and measurement downstream. spacr.diameter measures it from a handful of sampled fields — no Cellpose, no torch — and this panel is where that measurement reaches the settings form.

Every row carries its evidence: the value, the 10th-90th percentile range, how many objects it was pooled from, how many fields contributed, how it was measured and how much to trust it.

It lives in a DiameterDialog, opened from the Model zoo popup, so the last measurement is kept on the screen it was made for and shown again the next time the popup opens, without measuring again.

Parameters:
  • screen – the AppScreen it belongs to.

  • estimator – what to call to estimate, for tests. Defaults to spacr.diameter.estimate_diameters().

  • parent – parent widget; ownership only.

Build the diameter estimator’s panel.

Parameters:
  • screen – the screen this panel advises.

  • estimator – how to measure the diameters.

  • parent – parent widget.

apply(object_type: str) → bool[source]

Write one proposal into its <object>_diameter field.

Only ever called from the row’s own button — nothing here writes a setting on its own, and an unusable estimate (NaN, by construction in spacr.diameter.DiameterEstimate) is never written at all.

Parameters:

object_type – 'cell', 'nucleus' or 'pathogen'.

Returns:

True when the value reached the widget.

property estimates: Dict[str, Any][source]

The last set of proposals, keyed by object type.

class spacr.qt.prerun.SegQCBanner(screen: PySide6.QtWidgets.QWidget, *, reader=None, threaded: bool = True, parent=None)[source]

Bases: _JobMixin, PySide6.QtWidgets.QFrame

The segmentation verdict, on the Measure screen, before Measure runs.

Reads <plate>/qc/segmentation_qc_<object>.csv — the card spacr.seg_qc wrote when the masks were made — and shows what it says: the plate, the wells, the likely cause and what to do. It scores nothing on its own; the Score the masks now button is the only path in this class that opens a mask, and it exists because “no card” and “a card older than the masks” are both answers a user should be able to fix from here.

It never prevents Measure from running. See BLOCKS_RUN.

Parameters:
  • screen – the AppScreen it belongs to.

  • reader – what to call to read a digest, for tests. Defaults to spacr.seg_qc.read_digest().

  • threaded – False runs the read inline instead of on a worker, emitting the same signals in the same order, so a test can drive the banner synchronously without the behaviour diverging. The interface never builds one that way – see refresh().

  • parent – parent widget; ownership only.

Build the segmentation-QC banner for one screen.

Parameters:
  • screen – the screen this banner reports on.

  • reader – how to read the QC scores.

  • threaded – whether the read runs on a worker.

  • parent – parent widget.

refresh() → None[source]

Ask for the verdict and redraw when it lands. Never scores a mask.

The work is split in two so that no filesystem call runs on the GUI thread. What stays here is widget state — the src field, and whether it names anything — and it is free. What moved into _refresh_job() is every filesystem call: find_scorecards walking the user’s plate folders, one os.stat per card, and read_digest opening and parsing the CSVs. All of it is I/O on a path the user supplied.

Measured on one workstation: os.path.exists on a path under /nas_mnt — an autofs mount whose share was asleep — had not returned after twenty seconds, because the stat is what triggers the automount. This method used to do that work inline, and install_qc_banner used to call it inside MainWindow._build_screen, so it was the whole interface frozen on every Measure open. It left no traceback, because a stalled event loop is not a crash; it was reported as “opening map barcodes crashes spacr”, plus hover flicker and glimpses of other screens.

The banner keeps whatever it last drew while a read is in flight. There is deliberately no spacr.qt.path_probe gate in front of the job: the probe answers isdir optimistically-cheap but the scan has to happen off the GUI thread regardless, so a gate would only add a first-visit blank for no protection this does not give.

Every call increments _refresh_gen, including a call that finds the field empty. That is what makes a read cancellable without being interruptible: nothing can stop the worker mid-stat, but its answer is checked against the question before it is painted and dropped when the source has moved on. Without it, clearing src hid the banner and the read still in flight put the old plate’s verdict straight back on screen under no name at all.

schedule_refresh() → None[source]

Schedule refresh() after the typing debounce delay (450 ms).

What screen construction calls instead of refresh(). Even the asynchronous refresh builds a QThread, and there is no reason to pay for one inside MainWindow._build_screen: the banner is advisory, and 450 ms later is soon enough for advice.

property digest[source]

The last digest read, or None.

spacr.qt.prerun.diameter_dialog(screen, *, estimator=None, parent: PySide6.QtWidgets.QWidget | None = None) → DiameterDialog | None[source]

Build the diameter popup for screen, not yet shown.

Parameters:
  • screen – the Mask generation screen.

  • estimator – diameter estimator, for tests.

  • parent – the window that opens it.

Returns:

the dialog, or None when the screen has no diameter setting or the popup could not be built. Never raises, so the Model zoo still opens when this popup cannot be built.

spacr.qt.prerun.diameter_screen_of(widget) → PySide6.QtWidgets.QWidget | None[source]

The Mask generation screen widget sits in, or None.

Walks the parents, so the Model zoo popup finds the screen whichever control opened it – the per-object model cell or a *_model_name field’s button – without either of them passing it along. A screen with no <object>_diameter setting has nothing to write an estimate into, and does not count.

Parameters:

widget – any widget, or None.

spacr.qt.prerun.install(screen) → None[source]

Install the segmentation-QC banner when screen is Measure’s.

Mask generation’s diameter panel is no longer installed on its screen: it opens from the Model zoo popup instead, see diameter_dialog().

spacr.qt.prerun.install_qc_banner(screen, *, reader=None, threaded: bool = True) → SegQCBanner | None[source]

Put a QC switch on screen’s action row that opens the verdict.

The switch sits left of the 3D and Time switches; the banner lives in its popup, so the Run row keeps its place whatever the verdict says.

Parameters:
  • screen – an AppScreen.

  • reader – digest reader, for tests.

  • threaded – False to read inline, for tests.

Returns:

the banner, or None when this screen cannot carry one. Never raises: if the banner cannot be installed, the screen opens without it.

spacr.qt.prerun.qc_banner(screen) → SegQCBanner | None[source]

The banner installed on screen, or None.

Parameters:

screen – the screen widget whose _seg_qc_banner attribute is read.

spacr.qt.prerun.register() → bool[source]

Install the banner on the Measure screen. Idempotent.

Called by spacr.qt.register_self_registering_modules() after app.py has finished importing and before the first window is built.

Returns:

True when anything was registered.

spacr.qt.prerun.unregister() → int[source]

Undo register(), restoring whatever factory was displaced.

Returns:

how many keys were handed back.

Nested helpers

DiameterPanel._on_measure_clicked._job(box: Dict[str, Any]) → None

Run the estimator. Off the GUI thread.

spacr/qt/prerun.py:1245

SegQCBanner._on_score_clicked._job(box: Dict[str, Any]) → None

Score the segmentation QC. Off the GUI thread.

spacr/qt/prerun.py:1067