spacr.qt.screens.model_compare

Model Compare — two Cellpose models, the same three fields, one table.

Picking a segmentation model in spaCR means running a plate, looking at montages, changing one number and running it again. This screen makes the question small enough to answer in a minute: choose a folder, take three fields, configure two models, press Compare. The masks come back side by side over the same image with the object counts, the ARI and the split/merge attribution underneath.

Layout:

┌──────────────────────────────────────────────────────────────────────┐
│ /data/plate1/1                             [Choose folder…] [Load]   │
│ Fields [3]                                             [Compare]     │
├───────────────────────────────┬──────────────────────────────────────┤
│ Model A                       │ Model B                              │
│ model     [cpsam         ]    │ model     [cpsam            ]        │
│ diameter  [30.0]              │ diameter  [60.0]                     │
│ flow / cellprob / min size …  │ …                                    │
├───────────────────────────────┴──────────────────────────────────────┤
│ ! B: diam_mean=17 is ignored — use diameter=, that one rescales.     │
├──────────────────────────────────────────────────────────────────────┤
│ parameter          A       B        reaches the model?               │
│ diameter           30.0    60.0     yes  ←  varied                   │
│ diam_mean          –       17       no   ←  ignored by Cellpose 4    │
├──────────────────────────────────────────────────────────────────────┤
│ field  A obj  B obj  Δ   ARI   matched  splits  merges  only B …     │
├───────────────────────────────┬──────────────────────────────────────┤
│  [ image + A masks ]          │  [ image + B masks ]                 │
└───────────────────────────────┴──────────────────────────────────────┘

Design notes:

  • The resolved-parameter table is not decoration. Cellpose 4 accepts model_type, diam_mean, nchan, channels and rescale and then ignores every one of them, and it resolves all the pre-SAM model names to cpsam. A comparison that differs only in one of those reports “no difference”, which reads as “the models are equivalent” rather than “you changed nothing”. So the screen shows what actually reached each model, marks what was dropped, and says so in a banner above the numbers.

  • Off the GUI thread. Loading fields and segmentation can both be slow on a plate or NAS path, so they go through spacr.qt.bridge.make_thread() like every other spaCR job. Tests pass threaded=False, which runs the same code inline.

  • No modal dialogs on any error path. A folder with no images, a model that will not load, a field of the wrong shape — all of it lands in the inline status label. A QMessageBox hangs a headless run.

  • The preview colours by correspondence, not by label id. Label 3 in A has nothing to do with label 3 in B, so a shared random palette would invite exactly the wrong comparison. Objects that have a partner in the other mask are drawn in one colour and objects that do not in another, which is the comparison rather than a decoration of it.

  • Neither model is called correct. The screen renders spacr.model_compare’s directional wording as-is.

Classes

ModelCompareScreen

Compare two segmentation models on the same handful of fields.

Functions

compose_overlay(→ Optional[numpy.ndarray])

Blend a mask over its image, colouring by correspondence.

parse_extra(→ Dict[str, Any])

Parse a key=value, key=value line into eval keyword arguments.

to_display_gray(→ numpy.ndarray)

Reduce any field to a uint8 grayscale of shape, for a backdrop.

Module Contents

class spacr.qt.screens.model_compare.ModelCompareScreen(parent=None, threaded: bool = True)[source]

Bases: PySide6.QtWidgets.QWidget

Compare two segmentation models on the same handful of fields.

Parameters:
  • parent – Qt parent.

  • threaded – run the segmentation on a worker thread (the default). Tests pass False for deterministic, synchronous behaviour.

Variables:

last_error – text of the most recent failure, "" when the last operation succeeded. Errors are only ever reported here and in the inline status label — never in a modal dialog.

Build the screen and arm its drop zone.

The panel stylesheet is applied here rather than relied on from the launch sheet: app.py imports this module inside the branch that builds the screen, long after that sheet was generated, so the block is not in the sheet that is live and the panels would open bare.

Parameters:
  • parent – parent widget, or None.

  • threaded – segment on a worker thread. Set False in tests so compare finishes before it returns.

active_jobs() → int[source]

How many comparison threads are still winding down.

closeEvent(event)[source]

Let every in-flight comparison thread finish before the widget dies.

Parameters:

event – the close event; it is passed on to the base class after running threads are asked to quit and waited on for up to 5 s each.

compare() → bool[source]

Segment every loaded field with both models and fill the tables.

Returns:

for the synchronous path, whether a report was produced; for the threaded path, True once the job has started.

configure(model_a: str = '', model_b: str = '', folder: str = '', n_fields: int = 0) → bool[source]

Preload the screen with two models and a folder.

The public route for another screen to hand this one a comparison — the Model Zoo’s “compare the two selected” uses it. A caller reaching into _panel_a.model_edit would break the moment either panel is restructured.

Every argument is optional; an empty one leaves that control alone. n_fields is applied BEFORE the folder so the reload the folder triggers already uses the right count, rather than loading N fields and immediately reloading with a different N.

Parameters:
  • model_a – model name or checkpoint path for the left panel.

  • model_b – same, for the right panel.

  • folder – directory of fields to compare on.

  • n_fields – how many fields; 0 keeps the current value.

Returns:

what set_source() returned, or True when no folder was given.

field_names() → List[str][source]

The loaded field names, in order.

is_busy() → bool[source]

Whether anything is still running.

What the window asks before closing.

Returns:

True while work is outstanding.

metric_rows() → List[List[str]][source]

The per-field table as plain strings.

model_configs()[source]

(config_a, config_b) as currently configured.

Raises:

ValueError – when either extra line does not parse.

parameter_rows() → List[List[str]][source]

The resolved-parameter table as plain strings.

preview_captions()[source]

(a, b) caption strings under the two panels.

preview_sizes()[source]

(a, b) preview sizes on screen — (0, 0) for an empty panel.

The size the picture OCCUPIES, not the pixel count it was drawn with: on a HiDPI screen the panel is rendered at twice the density and the two answers differ by that factor.

report() → spacr.model_compare.ComparisonReport | None[source]

The most recent ComparisonReport.

select_field(row: int) → bool[source]

Draw field row’s two masks side by side over the same image.

Parameters:

row – index into the per-field table.

Returns:

True when both panels rendered.

set_segment_fn(fn: Callable | None) → None[source]

Override the segmentation backend.

The Model Zoo (and every test in this file) hands in its own callable rather than loading Cellpose; None restores spacr.model_compare.segment_with_cellpose().

Parameters:

fn – fn(images, config) -> masks, or None.

set_source(folder: str) → bool[source]

Load the first N fields out of folder without blocking Qt.

Every failure here is a normal state — a mistyped path, a folder of CSVs, an empty plate — so it lands in the status label and returns False. This never raises and never opens a dialog.

Parameters:

folder – a directory of .tif / .png / .npy / .npz fields.

Returns:

with threaded=False, True when at least one field loaded; otherwise True once the load job starts.

source_folder() → str[source]

The loaded folder, or ''.

status_text() → str[source]

Current inline status message (test/introspection helper).

summary_text() → str[source]

The aggregate summary line, or '' before a report exists.

warning_text() → str[source]

The banner above the numbers, or '' when there is nothing to say.

spacr.qt.screens.model_compare.compose_overlay(image: numpy.ndarray | None, mask: Any, matched: Sequence[int] | None = None, alpha: float = 0.45) → numpy.ndarray | None[source]

Blend a mask over its image, colouring by correspondence.

Objects listed in matched (they have a partner in the other model’s mask) are drawn in one colour and the rest in another. Colouring by label id would be worse than useless here: label 3 in A has nothing to do with label 3 in B, so a shared palette invites the eye to compare things that are not the same object.

Parameters:
  • image – the field, or None for a black backdrop.

  • mask – a 2-D label image.

  • matched – labels of mask that have a partner; None colours everything as unmatched.

  • alpha – overlay strength.

Returns:

a uint8 (H, W, 3) RGB array, or None when the mask is not a 2-D label image.

spacr.qt.screens.model_compare.parse_extra(text: str) → Dict[str, Any][source]

Parse a key=value, key=value line into eval keyword arguments.

Numbers and booleans are coerced so diam_mean=17 arrives as a number rather than the string "17" — the report compares values, and "17" and 17 would look like a difference where there is none.

Parameters:

text – the raw line; blank returns {}.

Returns:

the parsed mapping.

Raises:

ValueError – on a fragment with no = in it, naming the fragment.

spacr.qt.screens.model_compare.to_display_gray(image: numpy.ndarray | None, shape) → numpy.ndarray[source]

Reduce any field to a uint8 grayscale of shape, for a backdrop.

Multi-channel fields are collapsed to their max projection rather than a mean: a nucleus channel averaged with an empty channel is a dim nucleus. Contrast is a 1-99.9 percentile stretch, the same window spacr.qt.mask_engine.normalize_uint16() uses. A missing or wrong-shaped image comes back black rather than raising — the masks are what the screen is really showing.

Parameters:
  • image – the field, or None.

  • shape – (height, width) the mask expects.

Returns:

a uint8 array of shape.

Nested helpers

ModelCompareScreen._run_job._job(payload: Dict[str, Any]) → None

Call the wrapped function, stashing its result in the payload.

spacr/qt/screens/model_compare.py:969

ModelCompareScreen.compare._job() → mc.ComparisonReport

Run both models over the same fields. Off the GUI thread.

spacr/qt/screens/model_compare.py:730

ModelCompareScreen.set_source._job()

Load the comparison fields. Off the GUI thread.

spacr/qt/screens/model_compare.py:655