spacr.layers

A napari-style layer model — images, labels, points and shapes in one world.

Every viewer spaCR has ever shipped draws one thing. The live preview draws an image with outlines burnt into it; the timelapse preview draws a frame with tracks burnt into it; Make Masks draws an image with a brush burnt into it. “Burnt in” is the problem: the mask is not a thing you can hide, fade, recolour or put underneath something else — it is pixels that were already RGB by the time the widget saw them.

This module is the model underneath a viewer where those are separate objects. It is deliberately pure numpy with no Qt anywhere, for the same reasons spacr.selection is:

  • it can be tested without a display, and the compositing rules — which layer wins, what opacity means, where a point lands — are exactly the rules that need testing;

  • the same stack can be rendered headless into a figure or a report;

  • and five later features (ROI shapes honoured by Measure, a counting points layer, a label brush, orthogonal views, a comparison grid) are written against this, not against a widget, so none of them needs a running QApplication to have a unit test.

spacr.qt.layer_viewer is the Qt view over it.

The world, and why it is not optional

Layers do not agree on pixels. A labels mask may be at full resolution while a downsampled preview is not; a points layer of centroids is in continuous coordinates and has no grid at all; and confocal z-stacks in this codebase are routinely anisotropic — 0.65 µm in x and y, 2 µm in z is an ordinary spaCR stack.

So a layer never says “row 40, column 12”. It says “this data axis is z, one step along it is 2 µm, and element 0 sits at 0 µm” — a Spacing — and everything downstream happens in world units. A Canvas is a window onto that world (an origin, a step and a size, over two named world axes), and rendering is: for every canvas pixel, ask each layer what is at that world coordinate.

Treating spacing as decoration is a silent error, not a cosmetic one: an overlay drawn a slice out of register still looks like a plausible image, and the number that comes out of it is wrong with no warning. Hence the guards here are loud — a zero scale raises, and a stack refuses to mix µm layers with px layers rather than quietly drawing them on top of each other.

Order

stack[0] is the BOTTOM layer and is drawn first, like a stack of acetates and like napari’s own layer list. Moving a layer up moves it towards the front.

Blending

Every layer contributes coverage (where it has something to say) and opacity (how loudly). All five modes combine them as alpha = coverage * opacity, except Blending.OPAQUE, which first hardens coverage to all-or-nothing — that is what makes it a curtain rather than a veil, and it is the only place the two are treated differently.

Exceptions

LayerError

A layer, spacing or stack that cannot mean what it was asked to mean.

Classes

Blending

How a layer combines with what is already on the canvas.

Canvas

The world window a render fills: an origin, a step and a size.

CanvasLink

Several canvases held on the same world window.

Colormap

A named ramp from a value in 0–1 to a colour.

FieldKey

Which field a labels layer segments, in the schema's own key columns.

ImageLayer

Intensity data — one channel or many, each with its own LUT.

LabelsLayer

An integer segmentation mask. 0 is background and draws as nothing.

Layer

One thing in the stack: a name, a place in the world, and how to draw it.

LayerEvent

Something changed. What a view listens for instead of polling.

LayerStack

An ordered list of layers sharing one world.

OrthoViews

The three planes of one volume, laid out so that they line up.

PointsLayer

Points in the world — centroids, counted objects, clicked markers.

Shape

One drawn region: a polygon, rectangle, ellipse, line or path.

ShapesLayer

Drawn regions of interest — the layer Measure will later read.

Spacing

Where a layer's array elements sit in the world.

Functions

colormap(→ Colormap)

Resolve spec to a Colormap.

label_color(→ Tuple[float, float, float])

A stable, vivid colour for integer label.

label_colors(→ numpy.ndarray)

label_color() over an array, returning (..., 3) float32.

to_rgba(→ Tuple[float, float, float, float])

Coerce anything that names a colour into (r, g, b, a) in 0–1.

Module Contents

exception spacr.layers.LayerError[source]

Bases: ValueError

A layer, spacing or stack that cannot mean what it was asked to mean.

Raised rather than repaired. Every case this covers — a zero voxel size, a stack mixing µm with px, a points array of the wrong width — produces a picture that still looks right while being out of register, which is the failure mode that reaches a figure.

Initialize self. See help(type(self)) for accurate signature.

class spacr.layers.Blending[source]

How a layer combines with what is already on the canvas.

Five modes, all of which honour opacity the same way — alpha = coverage * opacity — except OPAQUE, which hardens coverage to 0-or-1 first so that a soft-edged layer becomes a curtain. Opacity still scales an opaque layer; “opaque” describes what it does to the layers below it where it has something, not whether it can be faded.

static apply(dst: numpy.ndarray, src: numpy.ndarray, coverage: numpy.ndarray, opacity: float, mode: str) → Tuple[numpy.ndarray, numpy.ndarray][source]

Composite src over dst; returns (rgb, alpha).

Parameters:
  • dst – the canvas so far, (H, W, 3) float in 0–1.

  • src – this layer’s colour, (H, W, 3) float in 0–1.

  • coverage – where this layer has something, (H, W) in 0–1.

  • opacity – the layer’s opacity, 0–1.

  • mode – one of Blending.ALL.

Returns:

the new canvas and the alpha that was actually used, so a caller can accumulate the composite’s own coverage.

static check(mode: str) → str[source]

Normalise and validate a blending name.

Parameters:

mode – Blending-mode name. Leading and trailing whitespace and letter case are normalised before validation against Blending.ALL.

Raises:

LayerError – on an unknown mode — a typo that silently fell back to translucent would be a compositing bug nobody could see.

class spacr.layers.Canvas[source]

The world window a render fills: an origin, a step and a size.

Rows run along axes[0] and columns along axes[1]; every other world axis is pinned by depth. Naming the plane rather than assuming (y, x) is what makes an orthogonal view a different Canvas over the same stack rather than a different renderer:

Canvas.covering(stack, height=512, axes=("z", "x"), depth={"y": 40.0})
Parameters:
  • origin – world coordinate of the centre of pixel (0, 0).

  • step – world units per canvas pixel, along rows and columns. This is the zoom: halve it to zoom in.

  • shape – (height, width) in canvas pixels.

  • axes – which world axes rows and columns run along.

  • depth – world coordinate for the axes not in the plane — the slice being shown. Axes absent from it are read as 0.

__post_init__() → None[source]

Coerce and validate the canvas geometry, then freeze it.

Two dimensions exactly, two distinct axes, a positive shape and a non-zero finite step. Each is refused rather than repaired: a canvas whose rows and columns name the same axis has no meaning to fall back to.

at_depth(**coords: float) → Canvas[source]

The same window at a different slice: canvas.at_depth(z=12.0).

column_world() → numpy.ndarray[source]

World coordinate of every column centre, (width,).

classmethod covering(source: Any, *, height: int | None = None, width: int | None = None, axes: Tuple[str, str] = ('y', 'x'), depth: Mapping[str, float] | None = None, margin: float = 0.0) → Canvas[source]

A canvas showing all of source — a stack, a layer, or an extent.

Exactly one of height / width fixes the resolution; the other follows from the world aspect ratio, so an anisotropic stack viewed in ("z", "x") is not squashed. Giving both is allowed and stretches.

Parameters:
  • source – a LayerStack, a Layer, or a {axis: (low, high)} extent mapping.

  • margin – fraction of the extent to add on every side.

classmethod for_grid(spacing: Spacing, shape: Sequence[int], *, axes: Tuple[str, str] = ('y', 'x'), depth: Mapping[str, float] | None = None) → Canvas[source]

The canvas that samples spacing’s own grid, one pixel per element.

The identity render: a layer drawn onto Canvas.for_grid(layer.spacing, layer.shape) comes back at its native resolution and alignment, which is what a shapes-to-mask conversion needs (see ShapesLayer.mask()).

Parameters:
  • spacing – the grid to sample. Origin, step and units are all taken from it, so the canvas reports the layer’s world units instead of the "px" a bare Canvas defaults to. Pixel (0, 0) is centred exactly on element 0 — no half-step inset, unlike covering().

  • shape – the layer’s whole array shape, in the spacing’s axis order. It is indexed by each plane axis’s position in spacing.axes, not by 0 and 1, so a three-axis spacing wants three entries even though only two are read; a two-entry shape against a 3-D spacing raises IndexError. Entries for axes outside the plane are ignored, non-integers are truncated, and a zero or negative in-plane size raises LayerError.

  • axes – which two spacing axes rows and columns run along. Any pair the spacing names is allowed, in any order — ("x", "y") transposes the result. An axis the spacing does not have, or the same axis twice, raises LayerError.

  • depth – world coordinate for the axes outside the plane. When omitted, each out-of-plane axis is pinned at the spacing’s own origin, so a translated volume starts on its first plane. Passing a mapping selects those world coordinates explicitly; entries naming an in-plane axis are kept but never consulted.

panned(d_row: float, d_column: float) → Canvas[source]

A canvas moved by (d_row, d_column) canvas pixels.

Parameters:
  • d_row – Signed displacement in canvas-row pixels.

  • d_column – Signed displacement in canvas-column pixels.

pixel_at(world: Mapping[str, float]) → Tuple[float, float][source]

The (fractional) canvas pixel a world point falls on.

The inverse of world_at(), and the reason a points layer and a labels layer at the same world coordinate land on the same pixel: they are both put through this, not through their own array indices.

Parameters:

world – Mapping containing world coordinates for both axes of this canvas plane.

resized(height: int, width: int) → Canvas[source]

The same world window at a different pixel size.

The world span is held, not the step, so a widget resize shows the same field of view rather than more of the sample.

Parameters:
  • height – New canvas height in pixels, coerced to at least one.

  • width – New canvas width in pixels, coerced to at least one.

row_world() → numpy.ndarray[source]

World coordinate of every row centre, (height,).

world_at(row: float, column: float) → Dict[str, float][source]

The world point under canvas pixel (row, column).

Includes the pinned depth axes, so the result is a complete world position a layer can be asked about.

Parameters:
  • row – Fractional canvas-row coordinate.

  • column – Fractional canvas-column coordinate.

zoomed(factor: float, centre: Tuple[float, float] | None = None) → Canvas[source]

A canvas factor× closer, about centre (canvas pixels).

factor > 1 zooms in. The default centre is the middle of the view, which is what a keyboard zoom means; a wheel zoom passes the cursor.

Parameters:

factor – Positive finite magnification factor; values above one zoom in and values below one zoom out.

property height: int[source]

rows run along axes[0].

Returns:

the row count.

Type:

The window’s height in pixels

property width: int[source]

columns run along axes[1].

Returns:

the column count.

Type:

The window’s width in pixels

Several canvases held on the same world window.

The model under a comparison grid: four panels showing four channels of one field, or the same field at four timepoints, or the same well under four conditions. Panning one pans all of them, zooming one zooms all of them, and the point is that a difference between two panels is a difference in the data rather than a difference in where they happen to be looking.

What is shared is the world window — the origin and the step — and NOT the pixel shape, because the panels are different widgets with different sizes. Sharing the shape would make the grid’s own layout part of the state; this way a panel that is 10 px narrower shows 10 px less of the same view at the same magnification, which is what a grid of unequal cells should do.

Parameters:

canvases – the panels to start with, as {key: canvas}. Added one at a time through add(), so each is adopted onto the shared world window as it arrives rather than the first one winning.

A panel can opt out with unlock(), for the ordinary case of wanting to look closely at one of them without losing the others’ place.

Link a set of named canvases so they can move together.

__contains__(key: Any) → bool[source]

Whether a panel key is in this link.

__getitem__(key: str) → Canvas[source]

One panel’s canvas, by key.

__len__() → int[source]

How many panels this link holds.

add(key: str, canvas: Canvas, *, locked: bool = True) → Canvas[source]

Put a panel in the link, adopting the shared window if there is one.

A panel added to a link that has already been panned starts where the others are, not where it was built — otherwise adding a fifth channel to a grid the user has zoomed into shows the whole field in one cell.

Parameters:
  • key – Unique panel identifier, coerced to a string.

  • canvas – Canvas that defines this panel’s plane, size and world window.

at_depth(*, key: str | None = None, **coords: float) → None[source]

Move every locked panel to another slice: link.at_depth(z=12.0).

canvases() → Dict[str, Canvas][source]

{key: canvas} — a copy, so a caller cannot mutate the link.

describe() → str[source]

One line per panel — what it is showing and whether it follows.

is_locked(key: str) → bool[source]

Whether a panel follows the others.

Parameters:

key – Identifier of an existing panel.

lock(key: str) → None[source]

Make a panel follow the others again, moving it there now.

Parameters:

key – Identifier of the panel to align and lock.

pan(d_row: float, d_column: float, *, key: str | None = None) → None[source]

Pan by a number of the DRIVING panel’s pixels.

Converted to world before it is handed to the others, so a grid whose panels are at different zooms (one unlocked, then relocked) still moves by the same distance across the sample rather than the same number of pixels.

Parameters:
  • d_row – Signed row displacement in pixels of the driving panel.

  • d_column – Signed column displacement in pixels of the driving panel.

remove(key: str) → Canvas[source]

Take a panel out and return its canvas.

Parameters:

key – Identifier of an existing panel.

reset(source: Any, **kwargs: Any) → None[source]

Fit every locked panel to source again, keeping its pixel size.

Parameters:

source – Layer stack, layer or extent mapping accepted by Canvas.covering().

resize(key: str, height: int, width: int) → None[source]

A panel’s widget changed size. Only that panel’s shape changes.

The STEP is held, not the span — the opposite of Canvas.resized(), and deliberately. In a linked grid the shared quantity is the magnification: a cell that is 10 px narrower than its neighbour must show 10 px less of the sample at the same scale, or the two pictures are at different magnifications and the comparison the grid exists for is not one.

Parameters:
  • key – Identifier of the existing panel whose widget resized.

  • height – New panel height in pixels, coerced to at least one.

  • width – New panel width in pixels, coerced to at least one.

set(key: str, canvas: Canvas) → None[source]

Replace one panel’s canvas, and bring the locked ones with it.

Parameters:
  • key – Identifier of the existing panel to replace.

  • canvas – Replacement canvas whose world window locked peers adopt.

subscribe(listener: Callable[[str], None]) → Callable[[str], None][source]

Be told, with the key that moved, whenever a panel changes.

Held by strong reference, exactly like LayerStack.subscribe(): a view that subscribes must unsubscribe() when it closes.

Parameters:

listener – Callable receiving the identifier of the panel that changed; it is retained by strong reference.

unlock(key: str) → None[source]

Let a panel be moved on its own.

Parameters:

key – Identifier of the panel to detach from linked movement.

unsubscribe(listener: Callable[[str], None]) → bool[source]

Stop being told. True if this call is what removed it.

Parameters:

listener – Previously subscribed callback to remove.

zoom(factor: float, *, key: str | None = None, centre: Tuple[float, float] | None = None) → None[source]

Zoom key (or every panel) by factor, about centre.

The world point under centre in the panel being driven is what the other panels are zoomed about too — not their own middles, or a wheel over one cell would slide the rest of the grid sideways.

Parameters:

factor – Positive finite magnification factor passed to each affected canvas.

property keys: Tuple[str, ...][source]

Every panel’s key, in the order they were added.

class spacr.layers.Colormap(name: str, colors: Sequence[Any], stops: Sequence[float] | None = None)[source]

A named ramp from a value in 0–1 to a colour.

Linear interpolation between colors at stops. Two stops covers every microscopy channel LUT there is (black → the channel’s colour), and more stops covers the perceptual maps without pulling matplotlib into a module that has to stay importable in a worker.

Parameters:
  • name – Display and lookup name stored with the colour ramp; it is coerced to a string.

  • colors – Ordered sequence of at least two colour specifications accepted by to_rgba(); the ramp interpolates them in order.

  • stops – where each colour sits, in 0..1. Defaults to spacing them evenly. One stop per colour, ascending – an out-of-order stop would make the ramp non-monotonic and is refused rather than sorted, since sorting would silently give a different ramp than the caller wrote.

Raises:

LayerError – with fewer than two colours, or when stops does not match colors one for one.

Build a colormap from at least two colours.

Fewer than two is refused: one colour is not a map, and interpolating between a colour and nothing has no answer.

__eq__(other: Any) → bool[source]

Equal when the name AND the colours and stops match.

The name alone is not enough – two maps can be renamed to the same thing – and the arrays alone are not either, because the name is what a saved setting refers to.

__hash__() → int[source]

Hash the name and the colours, so equal maps hash alike.

__repr__() → str[source]

The map’s name and how many stops it has.

map(values: Any) → numpy.ndarray[source]

Map values (any shape, 0–1, clipped) to (..., 3) float32.

Parameters:

values – Numeric scalar or array-like values to interpolate; values outside 0–1 are clipped to the nearest end of the ramp.

property colors: numpy.ndarray[source]

The ramp’s colours, (K, 3) float32 — a copy.

property stops: numpy.ndarray[source]

Where each colour sits in 0–1 — a copy.

class spacr.layers.FieldKey[source]

Which field a labels layer segments, in the schema’s own key columns.

A labels layer on its own knows that object 17 is object 17. Only the field it came from turns that into plate1_A_1_1_17 — the key the UMAP, the plate view and the annotation grid all already use. Keys are built by handing a frame to spacr.selection.object_keys() rather than joining strings here, so this cannot drift from the identity the rest of the codebase agrees on.

Parameters:
  • values – the field key columns and their values — everything in OBJECT_KEY_COLUMNS except the object label (and the timepoint too, when timelapse).

  • timelapse – key each frame of an object separately, which requires a timeid in values.

  • object_type – which mask this layer segments ('cell', 'nucleus', …). Without it the keys are untyped, so clicking the nucleus labelled 1 publishes the same string as clicking the pathogen labelled 1 in the same field, and the two views the click was supposed to link land on whichever of the two their table happened to hold first. None is honest for a mask loaded from a file that does not say what it is.

__post_init__() → None[source]

Coerce the field’s parts to strings and freeze them.

classmethod columns(*, timelapse: bool = False) → Tuple[str, ...][source]

The key columns a field key of this flavour needs, label excluded.

frame(labels: Iterable[int])[source]

A one-column-per-key-column frame for labels, in their order.

Parameters:

labels – Iterable of integer-like object labels to attach to this field identity.

classmethod from_row(row: Mapping[str, Any], *, timelapse: bool = False, object_type: str | None = None) → FieldKey[source]

Take the key columns out of a measurement row (or any mapping).

Anything else on the row is dropped — a field key is an identity, and carrying a measurement along with it would make two keys for the same field compare unequal.

Parameters:

row – Measurement-row mapping from which the required identity columns are extracted.

object_key(label: int) → str[source]

The one object key for label.

Parameters:

label – Integer-like object label to encode for this field.

object_keys(labels: Iterable[int])[source]

pandas.Index of object keys for labels, in order.

Parameters:

labels – Iterable of integer-like object labels to encode.

class spacr.layers.ImageLayer(data: Any, *, name: str = 'image', channel_axis: int | None = None, colormaps: Sequence[Any] | None = None, contrast_limits: Any | None = None, channel_names: Sequence[str] | None = None, channel_visible: Sequence[bool] | None = None, **kwargs: Any)[source]

Bases: Layer

Intensity data — one channel or many, each with its own LUT.

Parameters:
  • name – layer name. Default "image".

  • channel_names – a label per channel, for a channel list or a legend. Defaults to positional names – and naming them is what lets a reader tell which channel is the nucleus without counting.

Channels are composited additively within the layer, which is what makes a two-channel field read as one picture: a green nucleus channel and a magenta pathogen channel overlap as white rather than as whichever channel happened to be second. Across layers the user picks the blending.

Parameters:
  • data – the array. The spatial axes are every axis except channel_axis, in order.

  • channel_axis – which axis holds channels, if any. Negative indices work. A (H, W, 3) RGB stack is channel_axis=-1.

  • colormaps – one per channel; defaults walk DEFAULT_CHANNEL_COLORMAPS (a single-channel image gets grey).

  • contrast_limits – (low, high) per channel in data units. None means the DEFAULT_PERCENTILES stretch, computed once and remembered so that panning does not change the brightness — a per-view stretch is how two crops of the same field end up looking like different exposures.

  • channel_visible – per-channel visibility, for turning one stain off without splitting the layer.

Build an image layer.

Parameters:
  • data – the pixel array, channels included.

  • kwargs – name, spacing, colormaps and the rest of the layer contract.

auto_contrast(percentiles: Tuple[float, float] = DEFAULT_PERCENTILES) → None[source]

Recompute every channel’s limits from the data.

channel_data(channel: int) → numpy.ndarray[source]

The spatial array for one channel (a view, not a copy).

Parameters:

channel – Zero-based channel index accepted by normal sequence indexing.

channel_is_visible(channel: int) → bool[source]

Whether one channel participates in rendering.

Parameters:

channel – Zero-based channel index accepted by normal sequence indexing.

contrast_limits(channel: int = 0) → Tuple[float, float][source]

The limits in use for channel, computing the default if needed.

set_channel_visible(channel: int, visible: bool) → None[source]

Set whether one channel participates in rendering.

Parameters:
  • channel – Zero-based channel index accepted by normal sequence indexing.

  • visible – Truth value for the channel’s new visibility state.

set_colormap(value: Any, channel: int = 0) → None[source]

Give one channel a new LUT.

Parameters:

value – Colormap or colour specification accepted by colormap().

set_contrast_limits(low: float, high: float, channel: int = 0) → None[source]

Set one channel’s limits.

Parameters:
  • low – Lower intensity bound in the channel’s data units.

  • high – Upper intensity bound in the channel’s data units; it must be greater than low.

Raises:

LayerError – if they do not increase — an inverted pair renders a black image, which reads as “no signal” rather than as a bad setting.

world_extent() → Dict[str, Tuple[float, float]][source]

The world box this layer occupies, per named axis.

What lets the viewer frame a stack whose layers have different shapes and spacings without any of them knowing about the others.

Returns:

{axis: (low, high)}.

property channel_names: Tuple[str, ...][source]

What each channel is called, in channel order.

Returns:

one name per channel.

property colormap: Colormap[source]

Channel 0’s LUT — the one a single-channel layer has.

property colormaps: Tuple[Colormap, ...][source]

One colormap per channel, in channel order.

A TUPLE COPY, so a caller cannot reorder this layer’s colormaps by mutating what it was handed.

Returns:

the colormaps.

property data: numpy.ndarray[source]

The pixel array, channels included.

Returns:

the array as given; not a copy.

property n_channels: int[source]

How many channels this image carries.

Returns:

the channel count.

property ndim: int[source]

How many SPATIAL axes this image has, excluding channels.

A channel is not a dimension you can navigate, so counting it here would put a slider on the viewer for something that is not a place.

Returns:

the spatial axis count.

property shape: Tuple[int, ...][source]

The spatial shape, excluding channels.

Returns:

the spatial extent per axis.

class spacr.layers.LabelsLayer(data: Any, *, name: str = 'labels', field: FieldKey | None = None, seed: int = 0, **kwargs: Any)[source]

Bases: Layer

An integer segmentation mask. 0 is background and draws as nothing.

Parameters:
  • name – layer name. Default "labels".

  • seed – seeds the label-to-colour mapping. The same seed gives the same object the same colour across sessions and across figures, which is what makes two screenshots of one field comparable.

Colours come from label_color(), so an object keeps its colour between sessions and between this viewer and the live preview.

Parameters:
  • data – Two- or three-dimensional array-like segmentation labels; values must be integers or exactly integral numbers.

  • field – the FieldKey this mask segments. Optional, but without it a click can only say “label 17” — with it, the click can publish plate1_A_1_1_17 to every other view.

Build a labels layer.

Parameters:
  • data – the label array; 0 is background.

  • kwargs – name, spacing, field and the rest of the layer contract.

brush_index(world: Mapping[str, float], *, radius: float = 0.0) → Tuple[numpy.ndarray, ...][source]

The elements a world-space ball brush covers, as index arrays.

The brush is a ball in WORLD space, so on an anisotropic stack it covers fewer z-slices than y-rows — which is what the user means by a 5 µm brush. A brush measured in array elements would be a 5-slice cylinder, i.e. 10 µm deep and 3.25 µm wide on an ordinary spaCR stack.

Split out of paint() so a caller that has to record what it changed — spacr.curation.MaskCuration, which keeps an undo history and an audit trail — asks the layer for the geometry instead of re-deriving the anisotropy rule and getting it subtly different. There is one ball, and it is defined here.

Parameters:

world – Mapping from axis names to the brush centre in world coordinates; omitted spacing axes are evaluated at zero.

Returns:

one integer array per axis, ready to index data with. Empty arrays when the brush falls entirely off the grid, which indexes to nothing rather than raising.

label_at_world(world: Mapping[str, float]) → int[source]

The label under a world point, or 0 for background / outside.

Parameters:

world – Mapping from axis names to a point in world coordinates.

labels() → numpy.ndarray[source]

Every non-zero label present, sorted.

object_key_at_world(world: Mapping[str, float]) → str | None[source]

The measurement-table key of the object under a world point.

None for background, or when this layer was not told which field it segments. This is the whole reason a labels layer carries a FieldKey: the string this returns is the same string the UMAP, the plate view and the annotation grid use, so publishing it through spacr.qt.linked_selection highlights the same cell everywhere.

Parameters:

world – Mapping from axis names to the world point to identify.

object_keys(labels: Iterable[int] | None = None)[source]

Keys for labels (default: every label present), in order.

paint(world: Mapping[str, float], label: int, *, radius: float = 0.0) → int[source]

Set every element within radius world units of a point.

brush_index() owns the ball; this is that plus the write.

Parameters:
  • world – Mapping from axis names to the brush centre in world coordinates.

  • label – Integer-like label to paint.

Returns:

how many elements changed.

set_labels_at(index: Sequence[numpy.ndarray], label: int) → int[source]

Set the elements index names to label; how many changed.

The write half of brush_index(), and the ONE place a labels layer’s data is edited element-wise — so exactly one call notifies. A caller doing its own arithmetic on data would mutate the array without telling the canvas, and the picture would come right only at the next unrelated repaint.

Parameters:
  • index – One integer index array per data axis, suitable for NumPy advanced indexing.

  • label – Integer-like label to write at the selected elements.

world_extent() → Dict[str, Tuple[float, float]][source]

The world box this layer occupies, per named axis.

What lets the viewer frame a stack whose layers have different shapes and spacings without any of them knowing about the others.

Returns:

{axis: (low, high)}.

property data: numpy.ndarray[source]

one integer per pixel, 0 for background.

Returns:

the array as given; not a copy.

Type:

The label array

property field: FieldKey | None[source]

Which imaging field these labels belong to, if any.

Returns:

the FieldKey, or None when unattached.

property ndim: int[source]

How many axes the label array has.

Returns:

the axis count.

property selected_label: int[source]

The label the user last picked. 0 means none.

property shape: Tuple[int, ...][source]

The label array’s shape.

Returns:

the extent per axis.

class spacr.layers.Layer(*, name: str, spacing: Spacing | None = None, visible: bool = True, opacity: float = 1.0, blending: str = Blending.TRANSLUCENT, metadata: Mapping[str, Any] | None = None)[source]

One thing in the stack: a name, a place in the world, and how to draw it.

Subclasses implement _draw(); everything shared — visibility, opacity, blending, spacing, notification — lives here.

Display properties are plain attributes with setters that notify the stack, so a view repaints because the model changed rather than because the widget that changed it remembered to ask.

Parameters:
  • name – Non-blank layer identifier; names are uniquified when the layer enters a LayerStack.

  • spacing – physical size of one pixel along each spatial axis. Defaults to isotropic. It is what makes a measurement in microns mean the same thing on two microscopes, so it must have exactly ndim axes.

  • visible – whether the layer draws at all. Default True.

  • opacity – 0.0 transparent to 1.0 opaque. Default 1.0.

  • blending – how the layer combines with what is under it, from Blending. Default translucent.

  • metadata – arbitrary values carried with the layer. Copied, so the caller’s mapping is not held; spaCR itself reads nothing from it.

Raises:

LayerError – when the name is blank, the opacity is not a finite number, the blending is unknown, or spacing has the wrong number of axes for this layer.

Set the layer’s name, opacity and visibility, validated.

__repr__() → str[source]

The layer’s class, name and visibility.

describe() → str[source]

One line for a layer list row.

render(canvas: Canvas) → Tuple[numpy.ndarray, numpy.ndarray][source]

Draw onto canvas; returns (rgb, coverage).

rgb is (H, W, 3) float32 in 0–1 and coverage is (H, W) float32 in 0–1 saying where this layer has anything to say. Opacity and blending are NOT applied here — the stack applies them, so a layer’s own render is the same whatever it is composited with.

Parameters:

canvas – World window and output grid on which to draw the layer.

to_data(world: Mapping[str, float]) → Tuple[float, ...][source]

{axis: world} → fractional data index.

Parameters:

world – Mapping from world-axis names to coordinates; omitted spacing axes are evaluated at world coordinate zero.

to_world(index: Sequence[float]) → Dict[str, float][source]

Data index → {axis: world}.

Parameters:

index – Fractional data coordinate in this layer’s spacing-axis order.

abstract world_extent() → Dict[str, Tuple[float, float]][source]

The world box this layer occupies, keyed by axis.

property axes: Tuple[str, ...][source]

The world axes this layer’s data is indexed by, outermost first.

NAMED, NOT POSITIONAL, so two layers with different dimensionality can still agree on what “z” means – which is what lets a 2D mask sit over a 3D stack without either of them guessing.

Returns:

the axis names.

property blending: str[source]

How this layer’s pixels combine with what is under it.

Returns:

the blending mode’s name.

property name: str[source]

This layer’s name, unique within its stack.

Returns:

the name.

property ndim: int[source]
Abstractmethod:

How many spatial axes this layer has.

property opacity: float[source]

How opaque this layer is drawn, from 0 to 1.

Returns:

the opacity.

property shape: Tuple[int, ...][source]

The layer’s spatial shape in elements, () when it has no grid.

property spacing: Spacing[source]

Where this layer’s elements sit in the world.

Returns:

the Spacing.

property stack: LayerStack | None[source]

The stack this layer is in, or None.

property visible: bool[source]

Whether this layer is drawn.

Returns:

True when visible.

class spacr.layers.LayerEvent[source]

Something changed. What a view listens for instead of polling.

Parameters:
  • kind – "inserted", "removed", "moved", "renamed", "changed" (a display property), "data" (the array itself) or "selected".

  • layer – the layer it happened to, or None for stack-wide events.

  • index – where it is (or was) in the stack, or -1.

  • detail – the property name for "changed", else free text.

class spacr.layers.LayerStack(layers: Iterable[Layer] | None = None, *, units: str | None = None)[source]

An ordered list of layers sharing one world.

Parameters:
  • layers – the layers to start with, bottom first. Added through add(), so each is renamed to be unique and adopted as it arrives.

  • units – the name of the world unit – "um", "px" – shown on scale bars and axis labels. It LABELS the spacing rather than converting it: it is what the numbers in Spacing already mean, so setting it does not rescale anything.

stack[0] is the bottom and is drawn first. Everything a viewer does to the list — add, remove, reorder, rename, select — happens here and is announced through subscribe(), so the widget is a view over this rather than the other way round.

The stack owns two invariants the layers cannot enforce alone:

  • Names are unique. A duplicate name means “hide the mask” hides the wrong one. Colliding names are suffixed rather than refused, because a user adding a second mask should get one, not an error dialog.

  • Units agree. A layer measured in µm and a layer measured in pixels cannot be composited: the numbers would line up and the picture would not. Mixing them raises rather than drawing something plausible.

Build a stack, optionally seeded with layers.

Parameters:

layers – the layers to start with.

__contains__(item: Any) → bool[source]

Whether a layer or a layer name is in the stack.

Parameters:

item – a layer, or its name.

Returns:

True when present.

__getitem__(key: Any) → Any[source]

One layer, by name or by position.

Parameters:

key – the layer’s name, or its index.

Returns:

the layer.

__iter__()[source]

Iterate the layers, bottom to top.

Returns:

an iterator over the layers.

__len__() → int[source]

How many layers the stack holds.

Returns:

the layer count.

add_image(data: Any, **kwargs: Any) → ImageLayer[source]

Construct and append an image layer.

Parameters:

data – Array-like intensity data passed to ImageLayer.

add_labels(data: Any, **kwargs: Any) → LabelsLayer[source]

Construct and append a labels layer.

Parameters:

data – Array-like segmentation labels passed to LabelsLayer.

add_points(data: Any = None, **kwargs: Any) → PointsLayer[source]

Build a points layer and append it to this stack.

Parameters:
  • data – an (n, ndim) array of world coordinates, or None.

  • kwargs – passed to PointsLayer.

Returns:

the layer that was added.

add_shapes(shapes: Iterable[Shape] | None = None, **kwargs: Any) → ShapesLayer[source]

Build a shapes layer and append it to this stack.

Parameters:
  • shapes – the shapes to start with, or None for an empty layer.

  • kwargs – passed to ShapesLayer.

Returns:

the layer that was added.

append(layer: Layer) → Layer[source]

Put layer on top.

Parameters:

layer – Layer instance to attach to this stack.

canvas(**kwargs: Any) → Canvas[source]

A Canvas showing all of this stack — see Canvas.covering().

clear() → None[source]

Remove every layer, top first.

describe() → str[source]

One line per layer, top first — what the layer list shows.

get(layer: LayerLike) → Layer[source]

The layer named by an object, a name or an index.

Parameters:

layer – Layer object, unique layer name or integer stack index to resolve.

index(layer: LayerLike) → int[source]

Where layer sits, by object, name or index.

Parameters:

layer – Layer object, unique layer name or integer stack index to resolve.

Raises:

LayerError – if it is not in this stack. -1 would be a valid index from the top and would silently affect the wrong layer.

insert(index: int, layer: Layer) → Layer[source]

Put layer at index (0 is the bottom).

Parameters:
  • index – Requested insertion position, clamped between zero and the current stack length.

  • layer – Layer instance to attach to this stack.

lower_layer(layer: LayerLike) → int[source]

One step towards the back.

Parameters:

layer – Layer object, unique layer name or integer stack index to lower.

move(source: LayerLike, destination: int) → int[source]

Move a layer to destination; returns where it ended up.

The z-order control. destination is clamped, so “move to the top” can be spelled with any large number.

Parameters:
  • source – Layer object, unique layer name or integer stack index to move.

  • destination – Requested destination index, clamped to the stack after the source has been removed.

pick(canvas: Canvas, row: float, column: float) → Tuple[Layer | None, Dict[str, float], Any][source]

What is under a canvas pixel: (layer, world, value).

Walks from the TOP down and stops at the first visible layer with something there, which is what a click means — the thing you can see. value is the label for a labels layer, the point index for a points layer, the shape index for a shapes layer, and None otherwise.

Parameters:
  • canvas – Canvas whose displayed stack is being queried.

  • row – Fractional canvas-row coordinate of the query pixel.

  • column – Fractional canvas-column coordinate of the query pixel.

raise_layer(layer: LayerLike) → int[source]

One step towards the front.

Parameters:

layer – Layer object, unique layer name or integer stack index to raise.

remove(layer: LayerLike) → Layer[source]

Take a layer out and return it.

Parameters:

layer – Layer object, unique layer name or integer stack index to remove.

rename(layer: LayerLike, name: str) → str[source]

Rename a layer, uniquifying if needed; returns the name it got.

Parameters:
  • layer – Layer object, unique layer name or integer stack index to rename.

  • name – Requested non-blank name; a numeric suffix is added if it collides with another layer.

render(canvas: Canvas) → numpy.ndarray[source]

Composite every visible layer; (H, W, 3) float32 in 0–1.

Parameters:

canvas – World window and output grid on which to composite the stack.

render_rgba(canvas: Canvas) → numpy.ndarray[source]

render() plus the composite’s own coverage, (H, W, 4).

The alpha channel is accumulated source-over whatever the layers’ blending modes were: it records where the stack drew anything, which is what a caller compositing this onto a page background needs.

Parameters:

canvas – World window and output grid on which to composite the stack.

render_uint8(canvas: Canvas) → numpy.ndarray[source]

render() as (H, W, 3) uint8 — what a QImage wants.

Parameters:

canvas – World window and output grid on which to composite the stack.

select(layer: LayerLike | None) → Layer | None[source]

Select a layer (or None); returns it.

Parameters:

layer – Layer object, unique layer name, integer stack index or None to clear selection.

subscribe(listener: Listener) → Listener[source]

Be told when anything changes. Returns listener, for unsubscribing.

Listeners are held by strong reference and are NOT weak: a view that subscribes must unsubscribe() when it closes, exactly as spacr.qt.linked_selection.LinkedView must unlink.

Parameters:

listener – Callable receiving each LayerEvent; it is retained by strong reference.

to_bottom(layer: LayerLike) → int[source]

Move one layer to the back of the stack.

Parameters:

layer – Layer object, unique layer name or integer stack index to move.

to_top(layer: LayerLike) → int[source]

Move one layer to the front of the stack.

Parameters:

layer – Layer object, unique layer name or integer stack index to move.

unsubscribe(listener: Listener) → bool[source]

Stop being told. True if this call is what removed it.

Parameters:

listener – Previously subscribed event callback to remove.

world_extent() → Dict[str, Tuple[float, float]][source]

The union of every layer’s world box, keyed by axis.

Empty layers (a points layer with no points yet) do not drag the extent to the origin — an empty layer occupies nothing, and letting it vote would zoom the view out to include a point nobody has placed.

property names: Tuple[str, ...][source]

Every layer’s name, bottom first.

property selected: Layer | None[source]

The layer the layer-list has highlighted — what edits apply to.

property selected_index: int[source]

Where the selected layer sits, or -1 when nothing is selected.

property units: str[source]

The world unit every layer in this stack is measured in.

class spacr.layers.OrthoViews[source]

The three planes of one volume, laid out so that they line up.

A z-stack seen only from above hides the thing a z-stack is acquired for. The three canvases here are the top view and the two side views through a crosshair, arranged the way ImageJ’s orthogonal views are:

  • xy — the top view, rows y and columns x, at depth z;

  • zx — BELOW it, rows z and columns x, so it shares its columns with the top view;

  • yz — BESIDE it, rows y and columns z, so it shares its rows with the top view.

Every panel uses one world-units-per-pixel scale. That is the whole difficulty, and it is why this is a class rather than three Canvas.covering() calls. Confocal stacks in this codebase are routinely 0.65 µm in xy and 2 µm in z; a side view drawn one pixel per slice is three times too thin, and it does not look wrong — it looks like a slightly flat cell. Nuclear sphericity, colocalisation depth and every 3-D shape feature read off such a picture are wrong by a factor nobody sees. Here the side views are \(z_{span}/step\) pixels tall, so a 10-slice 2 µm stack is 31 px of a 0.65 µm/px view, not 10.

Build one with covering() and move the crosshair with at().

Parameters:
  • xy – Top-view canvas whose rows and columns span the second and third volume axes.

  • zx – Lower side-view canvas whose rows span depth and whose columns align with xy.

  • yz – Side-view canvas whose rows align with xy and whose columns span depth.

  • point – Mapping from world-axis names to the shared crosshair coordinates.

__post_init__() → None[source]

Coerce and validate the axis names, then freeze them.

at(**coords: float) → OrthoViews[source]

The same views with the crosshair moved: views.at(z=12.0).

Each panel is re-pinned at the axes it does not span, so moving z changes what the top view shows and nothing else — which is what makes a z slider one call rather than three.

at_pixel(panel: str, row: float, column: float) → OrthoViews[source]

Move the crosshair to the world point under a pixel of one panel.

Clicking the side view to move the top view’s slice, which is the interaction an orthogonal view is for.

Parameters:
  • panel – Panel key: "xy", "zx" or "yz".

  • row – Fractional row coordinate within the selected panel.

  • column – Fractional column coordinate within the selected panel.

canvases() → Dict[str, Canvas][source]

{'xy': …, 'zx': …, 'yz': …} — what a three-panel view draws.

clamped(**coords: float) → OrthoViews[source]

at(), with each coordinate held inside the volume.

What a slider wants: dragging past the last slice shows the last slice, not an empty canvas that looks like a failed load.

classmethod covering(source: Any, *, width: int = 512, point: Mapping[str, float] | None = None, axes: Tuple[str, str, str] = DEFAULT_AXES, margin: float = 0.0) → OrthoViews[source]

Three canvases showing all of source, crossing at point.

Parameters:
  • source – a LayerStack, a Layer or a {axis: (low, high)} extent mapping.

  • width – the top view’s width in pixels. Every other panel size follows from it and from the world extents, so that the layout lines up and nothing is squashed.

  • point – where the crosshair starts, {axis: world}. Defaults to the middle of the volume, which is the slice a user opening a stack wants to see first — slice 0 of a confocal stack is usually empty.

  • axes – the three world axes, depth first.

  • margin – fraction of the extent to add on every side.

Raises:

LayerError – if the source does not span all three axes — an orthogonal view of a 2-D field is not a view, it is a mistake, and silently drawing one plane would hide it.

describe() → str[source]

One line for a status bar: where the crosshair is.

n_slices(axis: str) → int[source]

How many slices the slider on axis has.

Parameters:

axis – World axis whose extent and voxel step define the count.

render(stack: LayerStack) → Dict[str, numpy.ndarray][source]

{panel: (H, W, 3)} — the same stack drawn on all three planes.

Parameters:

stack – Layer stack to render through each orthogonal canvas.

resized(width: int) → OrthoViews[source]

The same crosshair at a different pixel width, panels still aligned.

Parameters:

width – New top-panel width in pixels; derived panel dimensions remain aligned to the same world scale.

slider(axis: str) → Tuple[float, float, float][source]

(low, high, step) in WORLD units for a slice slider.

The step is the source’s own voxel size along that axis, so dragging the slider moves one slice at a time rather than an arbitrary fraction of the volume — and a µm-calibrated stack’s slider is labelled in µm, which is the number a user can check against the acquisition settings.

Parameters:

axis – World axis for which to return bounds and voxel step.

zoomed(factor: float) → OrthoViews[source]

Every panel factor times closer, about the crosshair.

All three together, and about the crosshair rather than about each panel’s own middle, so the panels still line up afterwards.

Parameters:

factor – Positive finite magnification factor applied to every panel; values above one zoom in.

property axes: Tuple[str, str, str][source]

The three world axes, depth first.

property scale: float[source]

World units per pixel — the same in every panel, by construction.

class spacr.layers.PointsLayer(data: Any = None, *, name: str = 'points', ndim: int = 2, size: Any = 10.0, face_color: Any = 'yellow', border_color: Any = 'black', border_width: float = 0.0, properties: Mapping[str, Any] | None = None, **kwargs: Any)[source]

Bases: Layer

Points in the world — centroids, counted objects, clicked markers.

Parameters:
  • name – layer name. Default "points".

  • ndim – how many axes a point has. Used only when data is empty; otherwise the data decides, so an explicit ndim cannot contradict the points that were passed.

  • face_color – fill colour, as any to_rgba() specification.

  • border_color – outline colour.

  • border_width – outline width in world units. 0 draws no outline, which is the default because an outline on a centroid marker costs more than it says.

Coordinates are stored in DATA units with the layer’s own spacing, exactly like an image, so a points layer built from a centroid table in pixel coordinates lines up with the mask those centroids came from without the caller converting anything. Use add_world() / world when you have world coordinates already.

Parameters:
  • data – (N, ndim) array of data coordinates, in the spacing’s axis order.

  • size – point DIAMETER in world units — scalar or one per point. World, not pixels: a 5 µm marker is 5 µm at every zoom, and on an anisotropic stack it is a sphere rather than an ellipsoid.

  • properties – {name: (N,) array} rider columns — what the counting item hangs its categories off.

Build a points layer.

Parameters:
  • data – an (n, ndim) array of world coordinates, or None.

  • kwargs – name, spacing, colours and the rest of the layer contract.

add(point: Sequence[float], *, size: float | None = None, **properties: Any) → int[source]

Append one point in DATA coordinates; returns its index.

Parameters:

point – Data-coordinate sequence with one value per layer axis.

add_world(world: Mapping[str, float], **kwargs: Any) → int[source]

Append one point given as {axis: world}.

Parameters:

world – Mapping from axis names to world coordinates; omitted layer axes are evaluated at world coordinate zero.

nearest(world: Mapping[str, float]) → int | None[source]

Index of the point whose disc contains world, nearest first.

Parameters:

world – Mapping from axis names to the query point in world coordinates; omitted layer axes are evaluated at zero.

remove(index: int) → None[source]

Drop one point.

Parameters:

index – Zero-based index of the point to remove; negative indices are rejected.

set_size(value: Any) → None[source]

Set every point’s diameter, or one per point.

Parameters:

value – Scalar diameter applied to every point, or an array-like sequence with one world-unit diameter per point.

world_extent() → Dict[str, Tuple[float, float]][source]

The world box these points occupy, INCLUDING their drawn size.

Half a point’s size is added on each side, because a point centred on the edge of the data is still drawn past it – and a viewer that framed the centres would clip every point on the boundary.

An empty layer reports a zero box rather than an infinite one.

Returns:

{axis: (low, high)}.

property border_color: Tuple[float, float, float, float][source]

The outline colour every point is drawn with.

Returns:

RGBA, each component 0 to 1.

property border_width: float[source]

Border thickness in world units. 0 draws no border.

property data: numpy.ndarray[source]

The points in data coordinates, (N, ndim).

property face_color: Tuple[float, float, float, float][source]

The fill colour every point is drawn with.

Returns:

RGBA, each component 0 to 1.

property ndim: int[source]

How many world axes each point is placed on.

Returns:

the axis count.

property size: numpy.ndarray[source]

Per-point diameters in world units.

property world: numpy.ndarray[source]

The points in world coordinates, (N, ndim).

class spacr.layers.Shape[source]

One drawn region: a polygon, rectangle, ellipse, line or path.

Compared by identity (eq=False): a generated __eq__ would compare the vertex arrays elementwise and raise “truth value of an array is ambiguous” from anything as ordinary as shape in layer.shapes.

Parameters:
  • kind – "polygon", "rectangle", "ellipse", "line" or "path". The first three enclose an area and can be turned into a mask; the last two are open and only have an outline.

  • data – (M, ndim) vertices in DATA coordinates, in the layer’s axis order. A rectangle and an ellipse are stored as their four bounding corners.

  • name – what the ROI is called, so a mask can be attributed.

__post_init__() → None[source]

Normalise the kind and refuse one this layer cannot draw.

REFUSED AT CONSTRUCTION rather than at paint time, because a shape with an unknown kind draws nothing and the reader would look for the fault in the renderer.

property is_closed: bool[source]

Whether the outline joins back to its first vertex.

A closed shape can be filled and can be asked what is inside it; an open one is a path and can only be drawn.

Returns:

True when closed.

property ndim: int[source]

How many world axes this shape’s vertices are placed on.

Returns:

the axis count.

class spacr.layers.ShapesLayer(shapes: Iterable[Shape] | None = None, *, name: str = 'shapes', ndim: int = 2, **kwargs: Any)[source]

Bases: Layer

Drawn regions of interest — the layer Measure will later read.

Parameters:
  • shapes – the Shape objects to start with. Every one must have the same number of axes – a layer holding a 2-D and a 3-D shape could not be rasterised into one mask.

  • name – layer name. Default "shapes".

  • ndim – axes per shape, used only when shapes is empty; the shapes decide otherwise.

Raises:

LayerError – when the shapes disagree about how many axes they have.

Shapes are geometry, not pixels: they are stored as vertices in data coordinates and rasterised on demand, so the same ROI can be turned into a mask for a full-resolution mask layer and for a downsampled preview and mean the same region in both. mask() is that conversion.

Build a shapes layer.

Parameters:
  • shapes – the shapes to start with, or None for an empty layer.

  • kwargs – name, spacing and the rest of the layer contract.

__len__() → int[source]

How many shapes this layer holds.

Returns:

the shape count.

add(shape: Shape) → int[source]

Append a shape; returns its index.

Parameters:

shape – Shape with the same dimensionality as this layer.

add_ellipse(corner_a: Any, corner_b: Any, **kwargs: Any) → int[source]

Append an axis-aligned ellipse inside an opposite-corner box.

Parameters:
  • corner_a – First bounding-box corner in data coordinates, one value per axis.

  • corner_b – Opposite bounding-box corner in data coordinates, one value per axis.

add_path(vertices: Any, **kwargs: Any) → int[source]

Append an open path.

Parameters:

vertices – Array-like vertices in data coordinates, shaped (M, ndim) with at least two rows.

add_polygon(vertices: Any, **kwargs: Any) → int[source]

Append a closed polygon.

Parameters:

vertices – Array-like vertices in data coordinates, shaped (M, ndim) with at least three rows.

add_rectangle(corner_a: Any, corner_b: Any, **kwargs: Any) → int[source]

Append an axis-aligned rectangle from opposite corners.

Parameters:
  • corner_a – First corner in data coordinates, one value per axis.

  • corner_b – Opposite corner in data coordinates, one value per axis.

mask(canvas: Canvas, indices: Iterable[int] | None = None) → numpy.ndarray[source]

Rasterise the enclosed shapes onto canvas; (H, W) bool.

The conversion Measure needs: hand it Canvas.for_grid(mask_layer.spacing, mask_layer.shape) and the ROI comes back on that layer’s own grid, whatever grid it was drawn on.

Boundaries are HALF-OPEN: a pixel centre exactly on the low edge of a rectangle is inside it and one exactly on the high edge is not. That is the even-odd rule’s own convention, and it is the one that matters — two ROIs sharing an edge partition the pixels between them instead of both claiming the seam, so an object on the boundary is counted once.

Open shapes (lines, paths) enclose nothing and contribute nothing.

Parameters:

canvas – World plane and pixel grid on which to rasterise the closed shapes.

remove(index: int) → Shape[source]

Drop a shape and return it.

Parameters:

index – Integer index of the shape to remove; normal Python negative indexing is accepted.

world_extent() → Dict[str, Tuple[float, float]][source]

The world box every shape’s vertices fall within.

An empty layer reports a zero box rather than an infinite one, so a viewer framing the stack is not dragged to infinity by a layer that has nothing in it yet.

Returns:

{axis: (low, high)}.

property ndim: int[source]

How many world axes this layer’s shapes are placed on.

Returns:

the axis count.

property shapes: Tuple[Shape, ...][source]

The shapes this layer holds.

A TUPLE COPY, so a caller cannot add or reorder the layer’s shapes by mutating what it was handed.

Returns:

the shapes, in draw order.

class spacr.layers.Spacing[source]

Where a layer’s array elements sit in the world.

world = translate + scale * index, per axis, with axes named so that two layers can agree on what “z” means without agreeing on array shape.

Parameters:
  • scale – world size of one element along each axis — the voxel size. Non-zero and finite, per axis: a zero would collapse the axis and every world query on it would answer with element 0, which draws a plausible picture of the wrong slice.

  • translate – world coordinate of element 0 along each axis. Defaults to zeros. This is the crop offset — a tile cut out of a mosaic keeps its place in the mosaic by carrying it here.

  • axes – axis names, outermost first. Defaults to ("z", "y", "x") truncated to len(scale).

  • units – what one world unit is. Compared by name when layers are stacked, because “0.65” means nothing until you know it is µm.

Anisotropy is the normal case, not the exception:

Spacing.from_map({"z": 2.0, "y": 0.65, "x": 0.65}, units="um")
__post_init__() → None[source]

Coerce and validate the voxel size, then freeze it.

A ZERO OR NON-FINITE SCALE IS REFUSED HERE rather than tolerated. It collapses an axis – every world coordinate on it resolves to element 0 – and the overlay is then drawn a slice out of register with NO VISIBLE SYMPTOM, which is why this raises rather than clamping.

axis_index(axis: str) → int[source]

Position of axis in this spacing.

Parameters:

axis – Axis name to locate in axes.

Raises:

LayerError – if this spacing has no such axis.

data_from_map(world: Mapping[str, float]) → Tuple[float, ...][source]

Fractional data index from a {axis: world} mapping.

Axes the mapping does not mention are taken to be 0 in world units, which is what a 2-D click on a 3-D layer means once the viewer has supplied the slice it is showing.

Parameters:

world – Mapping from axis names to world coordinates. Unnamed spacing axes are evaluated at world coordinate zero.

describe() → str[source]

One line for a status bar: z 2, y 0.65, x 0.65 um.

extent(shape: Sequence[int]) → Dict[str, Tuple[float, float]][source]

World bounding box of an array of shape, keyed by axis.

Measured to the OUTER EDGE of the end elements (half a voxel beyond the first and last centres), because that is the region the data actually covers — a canvas fitted to element centres clips half a voxel off every side, which at 2 µm z-steps is a visible slab.

Parameters:

shape – Array shape in axes order, with one size per spacing axis.

classmethod from_map(sizes: Mapping[str, float], *, origin: Mapping[str, float] | None = None, units: str = 'px') → Spacing[source]

Build from {"z": 2.0, "y": 0.65, "x": 0.65}.

Order is the mapping’s own, which for a dict literal is the order it was written — the same order the array’s axes are in. Naming the axes at the call site is the point: (2.0, 0.65, 0.65) on its own has been read backwards before.

Parameters:

sizes – Mapping from axis name to non-zero finite voxel size; mapping iteration order becomes the array-axis order.

has_axis(axis: str) → bool[source]

Whether this spacing contains axis.

Parameters:

axis – Axis name to test after coercion to a string.

classmethod isotropic(ndim: int = 2, step: float = 1.0, units: str = 'px') → Spacing[source]

Equal spacing on every axis — the pixel-grid default.

rescaled(**sizes: float) → Spacing[source]

A copy with some axes’ voxel sizes replaced: sp.rescaled(z=1.5).

to_data(world: Sequence[float]) → Tuple[float, ...][source]

World coordinate → (fractional) data index, per axis.

Parameters:

world – World coordinate in axes order, with one entry per spacing axis.

to_world(index: Sequence[float]) → Tuple[float, ...][source]

Data index → world coordinate, per axis.

Parameters:

index – Fractional data coordinate in axes order, with one entry per spacing axis.

translated(**offsets: float) → Spacing[source]

A copy with some axes’ origins replaced.

world_map(index: Sequence[float]) → Dict[str, float][source]

to_world() keyed by axis name.

Parameters:

index – Fractional data coordinate in axes order.

property ndim: int[source]

How many world axes this spacing describes.

Returns:

the axis count.

spacr.layers.colormap(spec: Any) → Colormap[source]

Resolve spec to a Colormap.

Takes a Colormap (returned unchanged), a name in COLORMAPS, a colour (any to_rgba() form) which becomes a black→colour ramp, or — as a last resort — a matplotlib colormap name.

Matplotlib is tried last and lazily. This module must stay importable where matplotlib is not installed and cheap to import in a worker, so it is never a hard dependency; but if a user types "viridis" and matplotlib is there, refusing would be pedantry.

Parameters:

spec – Existing Colormap, built-in or matplotlib colormap name, or a colour specification from which to build a black-to-colour ramp.

Raises:

LayerError – if spec names nothing.

spacr.layers.label_color(label: int, *, seed: int = 0) → Tuple[float, float, float][source]

A stable, vivid colour for integer label.

Golden-ratio hue spacing with a deterministic saturation/value jitter, so adjacent labels are far apart in hue and the same object keeps its colour across zooms, re-renders and sessions. Label 0 is background and is black (callers draw it transparent).

The construction is deliberately the same one spacr.qt.widgets.live_preview._random_outline_palette() uses for outlines, so a mask has the same object colours in both viewers. It is reimplemented rather than imported because that module imports PySide6 and this one must not — see the module docstring.

Parameters:

label – Integer-like object label. Label 0 is reserved for the black background colour.

spacr.layers.label_colors(labels: Any, *, seed: int = 0) → numpy.ndarray[source]

label_color() over an array, returning (..., 3) float32.

Built by looking up the unique labels once rather than per pixel: a 2048×2048 mask holds a few hundred objects and tens of millions of pixels.

Parameters:

labels – Array-like integer labels; the output preserves their shape and appends one RGB axis.

spacr.layers.to_rgba(colour: Any, *, alpha: float | None = None) → Tuple[float, float, float, float][source]

Coerce anything that names a colour into (r, g, b, a) in 0–1.

Accepts a name from _NAMED_COLORS, #rgb / #rrggbb / #rrggbbaa, a 3- or 4-tuple in 0–1, or a 3- or 4-tuple in 0–255 (any component above 1 switches the whole tuple to the 0–255 reading, which is the only unambiguous rule — (1, 1, 1) is white either way).

Parameters:
  • colour – named, hexadecimal or three-/four-component colour value to normalise; numeric components may use either the 0–1 or 0–255 scale.

  • alpha – overrides whatever the colour carried.

Raises:

LayerError – on anything else.

Nested helpers

OrthoViews.covering._canvas(pair, shape)

Return a shared-scale Canvas for pair and shape.

spacr/layers.py:1090