spacr.qt.prerun

What spaCR already knows about your data, on screen before you press Run.

Two things the pipeline computes, has always computed, and nobody has ever seen — because both of them print to a terminal that has since scrolled, or live in a module nothing 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 turns the guess into a measurement, per object type, and shows how many objects it measured so the number can be disbelieved. 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 by construction: it never touches the Run button, never disables it, never intercepts the click. A plate that failed QC is still a plate its owner may have every reason to measure, and a quality report that stops people is a quality report they switch off. 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 costs seconds to minutes, and a screen that pays that on every visit is a screen nobody keeps. 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 rather than believing 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 – cheap is not the same as free, and only free may run where the frames are painted. 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 installing this never costs a screen 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.

Asked for by a user: “in mask generation the, diamiter calculation should be a button in the model zoo button popup window not on the main screen. pressing the button should bring another pupup with all the information in the current container.” So this holds the whole DiameterPanel – the measure button, one row per object type with its evidence and note, Use per row and Use all – and the panel still 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. A proposal without those is just a different guess.

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: closing a window is not a reason to measure twice.

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 has no opinion about whether Measure should run. 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.

SPLIT IN TWO, and the split is the fix for a frozen application. 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 BUMPS _refresh_gen, the one that finds the field empty included. 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]

Ask for a refresh a beat from now, on the same debounce as typing.

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: a Model zoo that opens without this button’s popup is better than one that does not open.

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: a screen that opens without the banner is the old behaviour, and that is always better than a screen that does not open.

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:1251

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

Score the segmentation QC. Off the GUI thread.

spacr/qt/prerun.py:1071