spacr.qt.widgets.cell_montage_view

Show the measured cells represented by a regression coefficient.

spacr.cell_montage selects the objects and records the selection reason; this module resolves and displays their image crops beside the run’s figures. Each attached database resolves its own exported-PNG or merged/<fov>.npy source so multi-plate montages can combine experiments with different storage layouts. The crop mask plane and channels come from spacr.crops.CropSpec or, by default, the run’s measurements.db metadata.

Image reads run through spacr.qt.job_runner.JobRunner and only the GUI-thread completion handler updates widgets. Converting at most 300 crops to QPixmap remains on the GUI thread because QPixmap is not transferable across threads; the recorded 224-pixel thumbnail conversion takes about 21 milliseconds for 300 crops.

The tab stays visible when crops are unavailable and reports the reason in its status, tooltip, and disabled action. Crop-source discovery is cached after a load and invalidated when the coefficient, databases, or crop settings change.

Classes

CellMontageView

The cells behind the selected coefficient, beside the run's figures.

MontageLoad

What came back from one load: the plans, the pixels, or the reason.

MontageRequest

Everything one montage load needs, as plain data.

Functions

candidate_colour(→ str)

The border a likely cell wears, in the annotation app's own palette.

coefficient_from_frame(→ Tuple[str, str, Optional[float]])

Turn a clicked coefficient into (name, level, effect).

experiment_root(→ str)

The experiment folder holding measurements/measurements.db.

fits_on_a_page(→ Tuple[int, int])

Calculate the thumbnail grid capacity of a viewport.

intercept_from_frame(→ Optional[float])

The fitted intercept out of the coefficient table, or None.

load(→ MontageLoad)

Select the objects behind one coefficient and cut their crops.

montage_figure(plans, images[, columns])

Draw the montage as one matplotlib figure, caption and all.

no_score_refusal() → str)

Explain where spaCR looked for a per-object classification score.

parse_channels(→ Optional[Tuple[int, ...]])

Read the channel box: "0,1,2" -> (0, 1, 2).

well_tab_label(well, guides[, name, level])

The tab's own name: THE WELL AND THE gRNA, both.

Module Contents

class spacr.qt.widgets.cell_montage_view.CellMontageView(frame_provider: Callable[[], Any] | None = None, results_provider: Callable[[], str] | None = None, database_provider: Callable[[], Any] | None = None, parent=None, *, threaded: bool = True)[source]

Bases: PySide6.QtWidgets.QWidget

The cells behind the selected coefficient, beside the run’s figures.

Connect set_coefficient() to the panel’s existing selection – RegressionResultsPanel.table.key_selected, which is the funnel every plot and the table already pass through. There is deliberately no second selection mechanism here: a montage of a different gene from the one the volcano is ringing is exactly the plausible-and-wrong output this feature is most at risk of.

Parameters:
  • frame_provider – called with no arguments for the coefficient table, which is where the fitted effect for a clicked key comes from.

  • results_provider – called with no arguments for the results CSV path, beside which regression_data.csv is found.

  • database_provider – called with no arguments for the run’s input table rows, which is where the measurement databases are attached. A callable rather than a snapshot, for the same reason the Measurements tab takes one: databases are attached after this widget is built, and a list captured at construction never grows.

  • threaded – False runs the load inline, emitting the same signals in the same order, so a test drives the whole tab without the behaviour diverging.

  • parent – parent widget; ownership only.

Build the montage tab.

Every piece of state a control reads is created before any signal is connected: a widget whose controls are live before its state exists is the crash that took the application down at launch, and the rule has its own test file.

The controls sit in a flow rather than a row, so they wrap before they clip – the four buttons alone measure over 550 px, and squeezing a required selector down to a few pixels was what met the nominal minimum before.

Parameters:
  • frame_provider – called for the coefficient table.

  • results_provider – called for the run’s results path.

  • database_provider – called for the measurements database.

  • parent – parent widget, or None.

  • threaded – run loads on a worker thread. Remembered, so a panel built later runs the way the view was asked to – an unthreaded view that grew a threaded tab would put a QThread into a test constructed to have none.

annotate_the_cells(*_args)[source]

Open annotation strategies for the cells in the current montage.

Returns:

The annotation-strategy panel, constructed on first use.

apply_workspace_state(state) → bool[source]

Put the montage’s settings back. Does NOT rebuild it.

Returns whether anything was applied.

DELIBERATELY NOT REBUILT. Loading the crops is the slow half – the first montage of a run reads images off disk for seconds – and a restore that started it would freeze a window the user had just opened to look around in. The settings are put back and the button is there; 155’s “the montage says how it chose” is on screen either way.

Parameters:

state – mapping as saved with the workspace; its picture_settings, widgets, picture_mode and coefficient entries are each applied when present, and a non-dict applies nothing.

build() → bool[source]

Load the montage for the selected coefficient, off the GUI thread.

Returns:

True when a load was started.

build_every_selected() → int[source]

Queue one montage for each selected coefficient.

Loads are chained in selection order because montage construction is asynchronous. Completed well tabs remain available, enabling comparison across coefficients while preserving each montage’s guide-specific crop ranking.

Returns:

int – Number of selected coefficients queued for loading.

cancel_loading()[source]

Cancel the active montage and queued selections without blocking.

Returns:

whether there was a current montage request to cancel. An existing database or filesystem read may finish before exit; no later stage or stale result is shown.

caption_text() → str[source]

Every caption now on screen, exactly as the figure would carry it.

clear() → None[source]

Empty every open well tab’s grid, leaving the tabs standing.

PUBLIC BECAUSE IT IS CALLED FROM OUTSIDE, which it was already: the Regression screen empties this view when the loaded run changes, because a montage answering a coefficient from the previous run’s table means nothing under the new one. It called clear() – the obvious name, and the name the well tabs themselves use – and got an AttributeError every time, reported by a user on 2026-09-02 (issue 116) whose Cells tab kept showing the run before the one he had just loaded.

clear_picking_override() → None[source]

Clear a temporary picking fallback.

The next montage request will ask again if the selected picking method remains unavailable.

closeEvent(event)[source]

Shut the loader down before the widget goes.

Parameters:

event – the close event; passed to the base class after shutdown().

compare_a_measurement(*_args)[source]

Open the Compare tab for the cells selected by this montage.

Returns the shared comparison panel, or None when no cells have been selected or the panel cannot be created. Reusing the tab keeps the comparison synchronized with the montage and avoids duplicate floating views.

count_csvs() → Tuple[str, ...][source]

The COUNT CSVs attached to the run’s input table.

THE SAME PROVIDER THE DATABASES COME FROM. The input table’s rows are {"plate", "score", "count", "database"}, so the counts were always one field away – which is why requiring a run folder for the guide fractions was never necessary, only unexamined.

crop_count() → int[source]

Return the total number of crops held across all tab pages.

This differs from len(thumbnails()), which counts only widgets on the pages currently displayed.

databases() → Tuple[str, ...][source]

The measurement databases attached to the run’s input table.

Read through spacr.qt.widgets.measurement_scan_panel.attached_databases(), which is the same reader the Measurements tab uses – one vocabulary for “which plate has a database and is it still on disk”.

edit_picture_settings() → bool[source]

Open the settings window. Returns whether anything was changed.

images() → Tuple[Tuple[Any, ...], ...][source]

The crops now on screen, one tuple per plan.

loaded_run_name() → str[source]

The run this tab is describing, as a name a user recognises.

The run folder’s own basename – ols_3 – which is what the Runs tab calls it and what the figure grid heads its section with. “” when no run is loaded, so a caller can tell “no run” from “a run whose name I could not work out”.

multivariate_shortfall(request=None) → str[source]

Return why multivariate picking is unavailable, or "".

Multivariate picking requires a gene-by-measurement effects grid. The message directs the user to create that grid or explicitly choose rank-based picking rather than silently changing the selected method.

picked_groups() → dict[source]

{gene: the object index values this tab picked for it}.

THE PICKER’S OWN ANSWER, read off the plans rather than recomputed – montage_candidate is the column select_montage marks, so whichever mode is in force (rank, attributed, assigned, multivariate) this is what the montage actually drew.

picture_mode() → str[source]

The mode the user chose, as a stored crop-source value.

picture_settings() → dict[source]

How the cells are drawn. The annotator’s keys, the annotator’s defaults, and whatever the user has changed.

plans() → Tuple[Any, ...][source]

The montage plans now on screen, in order.

reason() → str[source]

Why the montage cannot be built right now, or ''.

The rule: a control that cannot do anything is greyed out AND SAYS WHY. Every branch here is a sentence a user can act on, and the order is the order the inputs are needed in, so the first missing thing is the one named.

refresh() → None[source]

Re-read the providers. Call this when the tab is opened.

Databases are attached to the input table while this tab is behind another one, so what it can do changes without any signal reaching it – the same reason the Measurements tab re-reads on open.

The grid is re-flowed too. A montage built while this tab was behind another one was laid out against a viewport that had never been through a layout pass – the same trap that made a snapshot of the unshown volcano a 100x9 rectangle of one colour – so its column count is whatever fitted in that guess, which is one.

remember_inventory(source=None, objects=None) → None[source]

Keep what the last load resolved, so the settings window can offer THIS screen’s mask planes and object columns rather than free text.

request() → MontageRequest | None[source]

The request the button would submit, or None if it cannot.

Public because it is what a test drives and what the completion handler compares a landed answer against.

resizeEvent(event)[source]

Reflow the grid after a resize settles.

Parameters:

event – the resize event; passed to the base class, and the reflow timer is restarted so only the last of a burst reflows.

rows_to_compare()[source]

Return object rows available to the measurement comparison panel.

Returns:

pandas.DataFrame or None – The full object inventory when it contains every montage-plan index; otherwise only the concatenated plan rows. None is returned when no plan contains object rows.

Notes

Control-well and other-well contrasts require objects that may not be displayed in the montage. The wider inventory is used only when its index preserves the plan rows’ group identities.

save(path: str | None = None) → str | None[source]

Write the montage as a figure, honouring the format preference.

Parameters:

path – where to write. None asks. The extension is left to spacr.plot.save_figure(), which corrects it to the format the user chose – naming one here is how a PNG ends up in a file called .pdf, which is a complaint this project has had twice.

Returns:

the path written, or None.

score_csvs() → Tuple[str, ...][source]

The SCORE CSVs attached to the run’s input table.

The same provider and the same rows as count_csvs() – the input table’s rows are {"plate", "score", "count", "database"}, so the scores were always one field away too.

A database whose png_list has no pred column is not necessarily a screen without scores: these files carry one row per cell and the fit was run on exactly those numbers. load_montage_objects joins them in memory when the database has none, and writes nothing.

selected_coefficients() → List[str][source]

Every coefficient in the current selection, in pick order.

set_coefficient(key: str) → None[source]

A coefficient was picked. THE SLOT TO CONNECT key_selected TO.

Takes the feature string and nothing else, so the volcano, the Q-Q, the effect-rank plot and the coefficient table all reach it by the same one route the gene tile already uses.

It does NOT load. A montage is seconds of disk for a click whose usual purpose is to read a row, so the selection arms the button and the user asks for the pictures.

Parameters:

key – the feature the panel emitted.

set_coefficients(keys) → None[source]

Store an ordered coefficient selection and show its latest member.

A montage represents one coefficient because crop ranking depends on that coefficient’s effect. The full selection remains available from selected_coefficients(); show_next_coefficient() cycles the displayed montage without discarding the other selected coefficients.

Parameters:

keys – every selected feature, in pick order.

show_next_coefficient() → str | None[source]

Move the grid to the next coefficient in the selection.

Returns:

the key now shown, or None if fewer than two are selected and there is nowhere to step to.

shutdown() → None[source]

Stop waiting for a load, and let no QThread outlive this widget.

Qt aborts the whole process when a running QThread is destroyed, and a merged-source montage is seconds long – so leaving the screen mid-load is not a rare case. JobRunner.shutdown drops the results and waits a bounded time rather than joining on the GUI thread, which is the freeze it exists to remove.

status_text() → str[source]

The status line: the summary, or the reason there is none.

tab_labels() → Tuple[str, ...][source]

Every tab’s text, summary first – what a user reads across.

thumbnails() → Tuple[PySide6.QtWidgets.QWidget, ...][source]

Return thumbnails visible on the currently displayed tab pages.

Use crop_count() to obtain the total number of objects held across all well tabs, including pages that are not displayed.

well_tabs() → Tuple[_WellTab, ...][source]

The open well tabs, in the order they are on screen.

workspace_state() → dict[source]

The montage the session had open, as data.

THE SETTINGS AND THE CHOICE, NOT THE PIXELS. A montage is tens of megabytes of crops that the run’s own images regenerate exactly; what cannot be regenerated is which coefficient was on screen, how the cells were picked, and how they were drawn. Those are what go in.

The picked groups ride along as a RECORD, not as an input – picked_groups() is what the picker chose given these settings, and restoring the settings reproduces it. Written down because a reader of a saved run wants to know which cells the claim rested on without re-running anything.

write_scores_into_the_databases(*, confirm=None) → dict[source]

Merge loaded per-object scores into attached databases on request.

The montage can use score files without modifying a database. This method writes only after explicit confirmation.

Parameters:

confirm – Optional callable receiving (databases, score_files) and returning whether to proceed. When omitted, spaCR displays a confirmation dialog.

Returns:

dict – Mapping of each updated database path to its matched-row count. Returns an empty mapping when no inputs are available, the user declines, or no database can be updated.

class spacr.qt.widgets.cell_montage_view.MontageLoad[source]

What came back from one load: the plans, the pixels, or the reason.

Parameters:
  • request – the request this answers, so a stale answer can be recognised and dropped.

  • plans – one MontagePlan per montage – one for a summed gene, one per guide when the guides were asked for separately.

  • images – the crops, one list per plan, aligned with that plan’s objects rows. An entry is None only where a source returned nothing for a row.

  • sources – {experiment root: description} – which crop source drew each plate, in words.

  • error – why there is no montage, or ''. A SENTENCE, not an exception: a tab that cannot be filled has to say why and stay on screen.

  • unavailable – True when the reason is a permanent property of this run – no crop source anywhere – rather than a bad request. That is what lets the button grey itself out afterwards instead of inviting the same click again.

  • shapes – the crop shapes EVERY plate’s route can actually cut. A shape one plate cannot produce is not offered, because a montage in which some crops follow the mask and some do not is a montage whose pictures are not comparable.

  • shape_reason – why a shape is missing from shapes, for the disabled entry’s tooltip.

  • objects – EVERY object row the load read, not just the ones the plans selected. The Compare panel’s wider contrasts (187 B) need the cells the montage did NOT pick – “against every other well” has nothing on its other side without them – and the join that makes every database measurement reachable (187 A) is keyed on these rows. A reference, not a copy: the frame is already in memory.

  • counts – the per-well guide fractions this load resolved. Carried for the same reason: naming a control (184) is a question about the COUNT data, and the panel cannot answer it from object rows.

property n_objects: int[source]

How many objects the load drew in total.

property ok: bool[source]

True when at least one plan came back, empty or not.

An EMPTY plan is a success: it carries the wells that reported the guide, the window that admitted nothing and the caption that says so, which is an answer. Only a missing plan is a failure.

class spacr.qt.widgets.cell_montage_view.MontageRequest[source]

Everything one montage load needs, as plain data.

A frozen record rather than a pile of arguments because it crosses a thread boundary and is what the completion handler compares against to know whether the answer that arrived is still the one on screen.

Parameters:
  • name – the gene or guide the coefficient names.

  • effect – its fitted coefficient.

  • level – 'gene' or 'grna'.

  • results_path – the results CSV, or the folder holding it. Either way regression_data.csv in that FOLDER is what is read – see spacr.cell_montage.read_well_guide_fractions(), which refuses the two obvious wrong CSVs by name.

  • databases – the measurements.db files attached to the run’s input table.

  • count_csvs – the COUNT CSVs from the same input table. The fallback source of the per-well guide fractions, and the reason a run folder is no longer required: a fraction is count / well total, which these files carry outright. Used only when results_path yields nothing readable.

  • object_type – which mask plane a crop is cut by.

  • channels – intensity planes for the picture, or None for the ones the run itself saved.

  • prefer – '' / 'png' / 'merged'.

  • score_column – the per-object classification score. A screen with more than one classifier output has more than one candidate.

  • cap – the largest montage to draw.

  • per_guide – one montage per guide instead of the gene’s guides summed. They are different questions – see spacr.cell_montage.select_montage_per_guide() – and each plan says in its own caption which one it answers.

  • half_widths – the score window’s half-width in robust scales – the direct stringency control. 0 means the module’s own default. One value is applied to every coefficient in the screen to prevent gene-specific adjustment after the output has been inspected.

  • baseline – the baseline to centre the window on, or None for the screen median.

  • baseline_label – what to call that baseline in the caption.

  • crop_shape – 'object' or 'bbox' – see SHAPE_CHOICES.

spacr.qt.widgets.cell_montage_view.candidate_colour() → str[source]

The border a likely cell wears, in the annotation app’s own palette.

Class 1 – the first annotation colour – because these ARE the panel’s first class of thing: the cells consistent with the coefficient. Theme aware, because label_to_hex is: the same hue deepens against a light tile so it stays readable, which is issue #6.

spacr.qt.widgets.cell_montage_view.coefficient_from_frame(key: str, frame) → Tuple[str, str, float | None][source]

Turn a clicked coefficient into (name, level, effect).

The join is on the KEY and the parse is spacr.hits.guide_of() / spacr.hits.gene_of(), which is the same rule the volcano, the gene tile and the metadata join already use – a fourth copy of “which gene is this term” is how two surfaces start naming different guides for one dot.

Parameters:
  • key – the feature the panel emitted, e.g. fraction:grna[233460_1] or gene_fraction:gene[233460].

  • frame – the coefficient table, for the fitted effect. None is allowed and yields None for the effect rather than raising.

Returns:

the gene or guide name, 'grna' or 'gene', and the fitted coefficient. The name is '' for a term that names neither – an Intercept or a row/column nuisance term – which is a real answer and the reason the montage is refused for it.

spacr.qt.widgets.cell_montage_view.experiment_root(db_path: str) → str[source]

The experiment folder holding measurements/measurements.db.

Parameters:

db_path – the database an attached plate names.

Returns:

the folder spacr.crops.resolve_crop_source() wants – the one with merged/ and data/ under it. A database somewhere other than measurements/ yields its own folder, so a project laid out by hand still resolves instead of silently pointing one level up.

spacr.qt.widgets.cell_montage_view.fits_on_a_page(width: int, height: int, thumb_px: int, spacing: int = 6) → Tuple[int, int][source]

Calculate the thumbnail grid capacity of a viewport.

Parameters:
  • width (int) – Available viewport dimensions in pixels.

  • height (int) – Available viewport dimensions in pixels.

  • thumb_px (int) – Thumbnail width and height in pixels.

  • spacing (int, default=6) – Space between adjacent thumbnails in pixels.

Returns:

  • int – Number of thumbnail columns.

  • int – Total thumbnails per page. Both values are at least one, including when the viewport is smaller than a thumbnail.

spacr.qt.widgets.cell_montage_view.intercept_from_frame(frame) → float | None[source]

The fitted intercept out of the coefficient table, or None.

The other baseline the score window can be centred on. Under the well-level model the intercept IS the score of a well carrying none of the guide, which is the same quantity the screen median estimates – so offering both is offering the model’s answer beside the data’s, and the caption says which produced the picture.

Parameters:

frame – the coefficient table. Every spelling statsmodels and this project use is accepted (Intercept, (Intercept), const), because a baseline silently not found would fall back to the median and the montage would say median while the user had asked for the intercept.

Returns:

the fitted value, or None when the table names no intercept or its value is not a finite number.

spacr.qt.widgets.cell_montage_view.load(request: MontageRequest, *, progress=None, cancelled=None) → MontageLoad[source]

Select the objects behind one coefficient and cut their crops.

Runs on a worker thread and touches no widget. Every failure comes back as MontageLoad.error rather than as an exception, because the caller is a tab that must stay on screen and say why.

Parameters:
  • request – what to draw.

  • progress – optional callback receiving stage text on the calling thread. A GUI caller must relay it through a queued Qt signal.

  • cancelled – optional zero-argument predicate checked between reads and processing stages. An operation already in progress may finish.

Returns:

the plans, the crops, and which source drew them.

spacr.qt.widgets.cell_montage_view.montage_figure(plans: Sequence[Any], images: Sequence[Sequence[Any]], columns: int = 8)[source]

Draw the montage as one matplotlib figure, caption and all.

The caption is not decoration and is not optional: it is what stops a reader taking these for genotyped cells, and caption() always ends with the sentence that says membership is inferred from a well-level fraction. A figure without it is the one output this feature must never produce, so it is drawn from the plan rather than passed in.

Parameters:
  • plans – the plans to draw, in order.

  • images – the crops for each plan.

  • columns – how many crops per row.

Returns:

the Figure. The caller writes it through spacr.plot.save_figure(), which is what honours the user’s figure-format and resolution preferences – never savefig directly, and never a hard-coded extension.

spacr.qt.widgets.cell_montage_view.no_score_refusal(score_csvs, troubles=()) → str[source]

Explain where spaCR looked for a per-object classification score.

Parameters:
  • score_csvs – Score files loaded with the regression result.

  • troubles – Additional diagnostics from the attached databases.

Returns:

str – A user-facing explanation that identifies both possible score sources and suggests the next useful action.

spacr.qt.widgets.cell_montage_view.parse_channels(text: str) → Tuple[int, ...] | None[source]

Read the channel box: "0,1,2" -> (0, 1, 2).

Parameters:

text – what the user typed. Empty means “as the run saved them”, which is None – and None is not the same as (): it is what lets spacr.crops.resolve_crop_source() read png_dims back out of measurements.db and reproduce that run’s own crops.

Returns:

the channel indices, or None for “leave it to the run”.

Raises:

ValueError – the box holds something that is not a channel index.

spacr.qt.widgets.cell_montage_view.well_tab_label(well, guides, name='', level='gene')[source]

The tab’s own name: THE WELL AND THE gRNA, both.

A gene with several guides pulls the same well more than once, so two tabs called p1_r3_c7 are indistinguishable – and the whole point of a tab that outlives the selection is comparing one gene’s cells with another’s, which a label naming only the well makes impossible.

THE COEFFICIENT IS NAMED TOO WHEN THE GUIDE ALONE DOES NOT IDENTIFY IT. Driving the real tab found the case: the guide-level coefficient GRA14_1, and the GENE GRA14 shown one guide at a time, both open a tab for GRA14_1 in the same well – and they are DIFFERENT montages with different effects, different windows and different cells. Both read plate1_r1_c1 · GRA14_1 and nothing on the tab bar told them apart.

Parameters:
  • well – the well key as the plan spells it.

  • guides – the guides this montage covers.

  • name – the coefficient’s own name, used when the guides are summed and there is therefore no single guide to name.

  • level – 'gene' or 'grna' – which kind of coefficient this montage answers for.

Returns:

e.g. 'plate1_r1_c1 · GRA14_1' for a guide term, 'plate1_r1_c1 · GRA14_1 (of GRA14)' for that same guide inside a gene term, or 'plate1_r1_c1 · GRA14 (2 guides)' for the sum.

Nested helpers

CellMontageView.build.report(message)

Queue progress only for an active request and cancel if its Qt receiver has disappeared.

spacr/qt/widgets/cell_montage_view.py:2177

CellMontageView.build.work()

Load the captured montage request and convert failures into a request-bound error result.

spacr/qt/widgets/cell_montage_view.py:2185

_load._design_spelling(name: str) → str

The design’s own spelling of a name, for display.

spacr/qt/widgets/cell_montage_view.py:775

load.step(message)

Report a loading stage while checking cancellation before and after the callback.

spacr/qt/widgets/cell_montage_view.py:589