spacr.qt.layer_viewer

Workflow inputs and outputs

Layer Viewer

Inspect aligned image, label and point/ROI layers for the selected field.

Open: QC → Layer Viewer.

Inputs and outputs below include conditional alternatives. The guidance and handoff notes say which route applies.

Inputs

  • Microscope images — Source image folder; original files, supported vendor files or imported TIFFs.

  • Label masks — masks/ when retained, or explicitly saved image/mask pairs. Intermediate masks may be removed by cleanup.

Outputs

  • Figures and table exports — The output location chosen by the tool; exports describe the selected data and filters.

API reference.

Module tutorial.

The Qt view over spacr.layers — a napari-style layer viewer.

Everything that decides what a pixel ends up being lives in spacr.layers, which knows nothing about Qt. This module is the part that can only exist with a display: a canvas that paints the model’s composite, a layer list that reorders it, and the controls that set the properties the model already knows how to honour.

The split is not tidiness. Five later features build on the layer model — ROI shapes read by Measure, a counting points layer, a label brush with timelapse track curation, orthogonal views with dimension sliders, and a synchronised comparison grid — and every one of them is a change to the model with a small widget on top. Putting the compositing rules in a paintEvent would make all five untestable without a running QApplication.

Why this canvas paints rather than reusing _ZoomView

spacr.qt.widgets.live_preview._ZoomView is the right answer when the thing being shown is a finished pixmap: it scales what it was given. Here the zoom is a property of the world, not of the picture — zooming in means spacr.layers.Canvas.zoomed() and a fresh render at the new resolution, which is how the labels layer stays crisp at 8× and how the orthogonal-view item gets its slice sliders for free. So the canvas owns a Canvas and paints; the array→pixmap step still goes through numpy_to_qpixmap(), and file loading through load_preview_image(), rather than growing a third copy of either.

Clicking an object

Clicking a labels layer picks the object under the cursor, and — if the layer was told which field it segments — publishes it through spacr.qt.linked_selection, so the same cell lights up in the UMAP, the plate view and the annotation grid. Double-clicking asks for it to be opened.

Classes

CanvasTool

Something that borrows the canvas's mouse: an ROI pen, a counter.

LayerCanvas

Paints a LayerStack through a world window.

LayerListWidget

The stack as a list, top layer first — the order the user sees it in.

LayerViewer

Canvas, layer list and per-layer controls over one

Functions

make_layer_viewer_screen(→ LayerViewer)

Build the viewer as an app screen. The factory= for

register_companion_apps(→ tuple)

Register the screens built on this viewer's world. Idempotent.

register_layer_viewer_app(*[, section, stage, key])

Put the viewer in the app registry, through the public seam. Idempotent.

stack_from_paths(→ spacr.layers.LayerStack)

Build a stack from an image file and/or a label-mask file.

Module Contents

class spacr.qt.layer_viewer.CanvasTool[source]

Something that borrows the canvas’s mouse: an ROI pen, a counter.

Two features need a click to mean something other than “select the object under the cursor” — drawing a polygon vertex (spacr.qt.roi_tool) and dropping a counted marker (spacr.qt.counting_tool) — and both need it in world coordinates, not widget pixels, so that a point placed at 8× zoom lands where the same point placed at 1× does.

Every handler is given the world position the canvas has already resolved and returns True when it consumed the event. Returning False (the default for every method here) leaves the canvas doing exactly what it did before tools existed, which is what makes attaching one reversible.

Subclass and override what you need; the base class is inert, so a tool that only wants clicks does not have to implement four no-ops.

detach() → None[source]

The tool was taken off the canvas. Drop anything half-drawn.

double_click(view: LayerCanvas, world: Dict[str, float], event: Any) → bool[source]

A double click at world — how a polygon is closed.

Parameters:
  • view – the LayerCanvas the tool is attached to.

  • world – world position already resolved from the cursor, as an axis-name to coordinate dict that includes the pinned depth axes.

  • event – the Qt mouse event, passed through unchanged.

Returns:

True to consume the double click; the base returns False.

key(view: LayerCanvas, event: Any) → bool[source]

A key was pressed while the canvas had focus.

Parameters:
  • view – the LayerCanvas the tool is attached to.

  • event – the Qt key event, passed through unchanged.

Returns:

True to consume the key; the base returns False so the canvas’s default handling runs.

move(view: LayerCanvas, world: Dict[str, float], event: Any) → bool[source]

The cursor moved to world with no drag in progress.

Parameters:
  • view – the LayerCanvas the tool is attached to.

  • world – world position already resolved from the cursor, as an axis-name to coordinate dict that includes the pinned depth axes.

  • event – the Qt mouse event, passed through unchanged.

Returns:

True to consume the move; the base returns False.

press(view: LayerCanvas, world: Dict[str, float], event: Any) → bool[source]

A mouse button went down at world. Return True to consume it.

Parameters:
  • view – the LayerCanvas the tool is attached to.

  • world – world position already resolved from the cursor, as an axis-name to coordinate dict that includes the pinned depth axes.

  • event – the Qt mouse event, passed through unchanged; a tool can read its button and modifiers.

release(view: LayerCanvas, world: Dict[str, float], event: Any) → bool[source]

The mouse button came up at world — the end of a drag.

What turns a drag into ONE action. A brush stroke is dozens of move() calls and exactly one thing the user did, so undo has to take back the stroke rather than the last few pixels of it; without a release the tool cannot tell where one stroke ends and the next begins. Inert by default, like the rest of this class, so a tool that only wants clicks is unaffected.

Parameters:
  • view – the LayerCanvas the tool is attached to.

  • world – world position already resolved from the cursor, as an axis-name to coordinate dict that includes the pinned depth axes.

  • event – the Qt mouse event, passed through unchanged.

class spacr.qt.layer_viewer.LayerCanvas(stack: spacr.layers.LayerStack | None = None, parent=None)[source]

Bases: PySide6.QtWidgets.QFrame

Paints a LayerStack through a world window.

Wheel zooms about the cursor, dragging pans, and every change re-renders at the widget’s own resolution rather than scaling a stale pixmap.

Parameters:
  • stack – the layers to paint. None builds an EMPTY stack rather than leaving the canvas without one, so every later call has something to act on instead of guarding for None.

  • parent – parent widget.

Build the canvas over one layer stack.

Parameters:
  • stack – the layers to paint.

  • parent – parent widget.

detach() → None[source]

Stop listening to the model. Call from closeEvent.

keyPressEvent(event) → None[source]

Offer the key to the active tool before the default handling.

Parameters:

event – the Qt key event.

mouseDoubleClickEvent(event) → None[source]

Hand the double-click to the active tool.

Parameters:

event – the Qt mouse event.

mouseMoveEvent(event) → None[source]

Continue a pan, or hand the move to the active tool.

Parameters:

event – the Qt mouse event.

mousePressEvent(event) → None[source]

Begin a pan, or hand the press to the active tool.

Parameters:

event – the Qt mouse event.

mouseReleaseEvent(event) → None[source]

End a pan, or hand the release to the active tool.

Parameters:

event – the Qt mouse event.

paintEvent(event) → None[source]

Draw every visible layer through the current world window.

Parameters:

event – the Qt paint event.

reset_view() → None[source]

Fit the whole stack into the widget.

set_depth(**coords: float) → None[source]

Move to another slice: canvas.set_depth(z=12.0).

set_plane(axes: Sequence[str], depth: Dict[str, float] | None = None) → None[source]

Look at a different plane — the seam the orthogonal-view item uses.

Parameters:

axes – world-axis names for the canvas rows and columns, in that order; only the first two items are used.

set_stack(stack: spacr.layers.LayerStack) → None[source]

Show a different stack, unsubscribing from the old one.

The unsubscribe matters: the model holds listeners by strong reference, so a canvas that swapped stacks without letting go would keep repainting for a stack nobody is looking at.

Parameters:

stack – the LayerStack to display; the canvas subscribes to its layer changes.

set_tool(tool: CanvasTool | None) → CanvasTool | None[source]

Attach a tool (or None to go back to picking); returns the old one.

Keyboard focus is granted only while a tool is attached: a tool typically wants Escape and Backspace, and a canvas that grabbed focus the rest of the time would swallow the arrow keys the surrounding screen uses.

Parameters:

tool – the CanvasTool to receive mouse and key events, or None. A different tool already attached is detached first.

wheelEvent(event) → None[source]

Zoom the world window about the pointer.

ABOUT THE POINTER, not the centre, so zooming toward something keeps it under the cursor – which is the only way to navigate a large field without losing the thing you were looking at.

Parameters:

event – the Qt wheel event.

property canvas: spacr.layers.Canvas | None[source]

The world window being shown, or None for an empty stack.

property stack: spacr.layers.LayerStack[source]

The layer stack this canvas paints.

Returns:

the stack.

property tool: CanvasTool | None[source]

The CanvasTool currently borrowing the mouse, if any.

class spacr.qt.layer_viewer.LayerListWidget(stack: spacr.layers.LayerStack, parent=None)[source]

Bases: PySide6.QtWidgets.QListWidget

The stack as a list, top layer first — the order the user sees it in.

Reversed on purpose: the model’s index 0 is the bottom layer (a stack of acetates), while a list reads top-down, so the topmost row is the layer nearest the viewer. Every drag and every button here is translated back into a model index, so the model stays the single source of order.

Parameters:
  • stack – the layers to list. Required here, unlike the canvas: this widget IS the stack’s presentation and has nothing to show without one.

  • parent – parent widget.

Build the list over one layer stack.

Parameters:
  • stack – the layers to list.

  • parent – parent widget.

detach() → None[source]

Stop listening to the model. Call from closeEvent.

refresh() → None[source]

Rebuild every row from the model.

class spacr.qt.layer_viewer.LayerViewer(stack: spacr.layers.LayerStack | None = None, parent=None)[source]

Bases: spacr.qt.linked_selection.LinkedView, PySide6.QtWidgets.QWidget

Canvas, layer list and per-layer controls over one LayerStack.

Parameters:
  • stack – the layers to show. Handed to the canvas and the list, so the three share one stack rather than three copies of it.

  • parent – parent widget.

Build the viewer: canvas, layer list and per-layer controls.

Parameters:
  • stack – the layers to show, or None for an empty one.

  • parent – parent widget.

add_image_file(path) → spacr.layers.ImageLayer | None[source]

Load path as an image layer. Returns it, or None on failure.

Parameters:

path – image file read through stack_from_paths(); a load error is logged and shown in the status line.

add_labels_file(path, field: spacr.layers.FieldKey | None = None) → spacr.layers.LabelsLayer | None[source]

Load path as a labels layer.

Parameters:

path – label-mask file read through stack_from_paths(); returns None after logging and showing a load error.

closeEvent(event) → None[source]

Unlink the selection and unsubscribe from the stack before going.

BOTH, and both matter: a linked view outliving its window is a broadcast to a dead widget, and a live subscription on the stack is a callback into one. Either is a crash rather than a leak.

Parameters:

event – the Qt close event.

on_linked_selection_changed(selection) → None[source]

Highlight the object another view selected, when we hold it.

Parameters:

selection – the published Selection; only a selection of exactly one key is acted on, and that key is matched against each labels layer’s object keys.

property stack: spacr.layers.LayerStack[source]

The layer stack this viewer shows.

Returns:

the stack.

spacr.qt.layer_viewer.make_layer_viewer_screen(**_kwargs) → LayerViewer[source]

Build the viewer as an app screen. The factory= for register_app().

spacr.qt.layer_viewer.register_companion_apps() → tuple[source]

Register the screens built on this viewer’s world. Idempotent.

spacr.qt.app holds the one import-time table of self-registering modules (_SELF_REGISTERING_APPS) and calls register_layer_viewer_app() out of it. The screens in COMPANION_APPS grew out of this module: they either borrow the layer world directly (a CanvasTool on LayerCanvas) or join the same linked-selection contract this viewer joined. Registering them from here rather than giving each one a row in app.py keeps the chain one hop long and written down in a single tuple — the next screen adds a line to it, not a mechanism.

One companion’s failure costs that companion and nothing else, the same posture app.py takes towards its own table and towards plugins: an optional screen must never stop the window opening.

Returns:

the module names that registered without raising.

spacr.qt.layer_viewer.register_layer_viewer_app(*, section: str | None = None, stage: str | None = None, key: str = LAYER_VIEWER_APP_KEY)[source]

Put the viewer in the app registry, through the public seam. Idempotent.

Called at import from the bottom of spacr.qt.app, which is the only place a registration is visible to everybody — see _SELF_REGISTERING_APPS there for why it cannot be called at the top of this module.

The row itself – the key, the name, the blurb, the section, the “no headless run” sentence, the API doc link and the nine translations of the display name – is declared in spacr.qt.app_catalog. spacr.qt.app.register_app() distributes those into the four tables each used to need a hand-edit in, and this function’s whole job is to name which row. That is what lets the app be registered without importing this module at all: the launch reads the table, and the screen is imported when somebody opens it.

Returns:

the registry row that was added, or None when the key was already registered. Safe to call twice: this module is reachable from three import paths and a duplicate key would otherwise raise.

spacr.qt.layer_viewer.stack_from_paths(image_path=None, labels_path=None, *, spacing: spacr.layers.Spacing | None = None, field: spacr.layers.FieldKey | None = None) → spacr.layers.LayerStack[source]

Build a stack from an image file and/or a label-mask file.

Loading goes through spacr.qt.widgets.live_preview.load_preview_image(), which already knows to read TIFFs with tifffile so a 16-bit field keeps its bit depth, and channel order through spacr.qt.widgets.timelapse_preview.frame_channel(), which resolves channels-first against channels-last the same way the timelapse preview does. Neither is re-implemented here.

Parameters:

spacing – the world spacing both layers share. Defaults to one world unit per pixel — correct for a single field, and the thing to override the moment a z-stack or a µm-calibrated mosaic is involved.