spacr.align¶
Workflow inputs and outputs¶
Align & Stitch¶
Inspect tile geometry, overlap and channel mapping, then save the mosaic with coordinate records. OPS also needs the original cycle/site identities.
Open: Home → Align & Stitch.
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.
Outputs
Aligned mosaic and coordinates — Align & Stitch destination: composed image and the tile-coordinate/layout records needed to interpret it.
After this module
OPS: Carry tile coordinates and original sequencing cycles into OPS; a flattened mosaic alone is insufficient.
Stitch and align an arbitrary number of image tiles into one canvas.
A 10x10 grid of 2048x2048 uint16 fields is a 20480x20480 canvas: 800 MB for one channel, 3.2 GB for four. Nothing in this module ever holds that canvas — or the whole input set — in RAM. That constraint drives every design decision here:
scan_tiles()reads headers only..npygoes throughnumpy.lib.format.read_magic()plus the publicread_array_header_*pair; TIFF throughtifffile.TiffFile’s series metadata. A thousand tiles cost a thousandseeks, no pixels.estimate_offsets()registers pairs on the overlap strip only, read out of a memory-mapped tile through the same windowed access patternspacr.crops.MergedFielduses for on-demand crops. A 10% overlap on a 2048x2048 tile is a 2048x205 strip — 0.8 MB, not 8 MB.plan_canvas()computes the canvas geometry from the offsets before anything is allocated, so an impossible stitch is refused on arithmetic rather than discovered by the OOM killer.write_stack()fills the output one horizontal band at a time. The peak working set is one band’s float32 accumulator plus one tile window, both bounded bymax_buffer_bytesand both independent of the canvas size.
The other half of the problem is being honest about what registered and what did not.
Offsets are solved globally, never accumulated. Chaining tile-to-tile
shifts along a row of a 10x10 grid adds every pairwise error together, so
the last tile lands tens of pixels out with nothing in the output saying
so. Instead every overlapping neighbour pair contributes one equation
p_j - p_i = d_ij and the whole set is solved at once as a weighted
least-squares problem over the pair graph (see solve_positions()).
Redundant edges — the diagonal neighbours of a grid, the i/i+2
pairs of a heavily-overlapped row — then cancel error instead of
compounding it, and each tile gets a residual: how far its solved
position sits from what its own pairs asked for. A large residual is the
signal that a tile did not register, and it is written into the table
rather than averaged away.
A pair that does not register is reported, not guessed. Phase
correlation over a blank or near-empty overlap returns a confident-looking
peak that is pure noise. Every pair is therefore scored by the normalised
cross-correlation of the two strips after the measured shift is applied;
below min_confidence the pair is dropped, and a tile left with no
surviving pair keeps its nominal stage position and is marked
method='nominal'. A nominally-placed tile is not the same thing as a
registered one and ALIGN_TABLE says which is which, per tile.
Channels share one solution. Registration runs on
reference_channel alone and the resulting placement is applied to
every channel of that site. Aligning channels independently would shear
the composite — a nucleus mask and its own DAPI channel would no longer
line up — so it is not offered.
z is not stitched. Tiles are 2-D (H, W) or channel-last 3-D
(H, W, C), which is the layout spaCR’s merged/*.npy already uses.
A z-stack must be projected first, or aligned one z-plane at a time by
calling this module per plane; a 4-D array is refused with a message that
says so rather than being silently reinterpreted.
Typical use:
from spacr import align
tiles = align.scan_tiles('plate1/tiles', grid=(10, 10), overlap=0.1)
plan = align.estimate_offsets(tiles)
print(align.format_plan(plan))
result = align.write_stack(plan, 'plate1/stitched')
align.save_coordinates(plan, 'plate1/measurements/measurements.db',
canvas=result.canvas, stack_path=result.stack_path)
or in one call from a settings dict, the shape every other spaCR module takes:
align.align_folder({'src': 'plate1/tiles', 'dst': 'plate1/stitched',
'grid': (10, 10), 'db_path': '.../measurements.db'})
The coordinates table is keyed exactly the way every measurement table in
measurements.db is keyed — plateID / rowID / columnID /
fieldID plus the prc / prcf composites, built through
spacr.schema, which is the one definition spacr.utils writes them
with too — so:
SELECT c.*, a.y, a.x, a.method, a.confidence, a.residual
FROM cell AS c
JOIN align_coordinates AS a USING (plateID, rowID, columnID, fieldID)
puts every measured object next to the stitch quality of the field it came from. That is the join that lets a suspicious cluster of hits be traced back to “those three fields fell back to nominal”.
This module is deliberately free of torch, cellpose, Qt and TensorFlow: it is imported by a GUI screen and by header-only scans, and neither should pay for a deep-learning stack.
Exceptions¶
A tile could not be read, or a stitch could not be written. |
Classes¶
The full solution: what goes where, and everything that went wrong. |
|
What |
|
Canvas geometry, computed before a single byte is allocated. |
|
One neighbour pair, registered or refused. |
|
Where one tile goes, and how much that position is worth. |
|
One site: everything known about a tile without reading its pixels. |
Functions¶
|
Scan, plan, optionally write and optionally record, in one call. |
|
Return the settings |
|
Register every overlapping neighbour pair and solve the whole set at once. |
|
Render a plan as the block a user should read before writing 800 MB. |
|
Split tiles into one stitchable set per |
|
Compute the canvas geometry from the offsets, allocating nothing. |
|
Read |
|
Write one row per tile into |
|
Return one |
|
Solve every tile position at once from the pair graph. |
|
Composite the plan into one |
Module Contents¶
- exception spacr.align.AlignError[source]¶
Bases:
RuntimeErrorA tile could not be read, or a stitch could not be written.
Initialize self. See help(type(self)) for accurate signature.
- class spacr.align.AlignPlan[source]¶
The full solution: what goes where, and everything that went wrong.
- Variables:
tiles – every tile handed in, readable or not.
placements – one per readable tile, in tile order.
canvas_shape –
(H, W, C)the stitch would occupy.overlaps – every candidate pair, accepted or refused.
warnings – non-fatal problems — mixed dtypes, disconnected components, tiles with no overlap at all.
unplaced –
(tile, reason)for every tile that could not be placed. These are excluded from the canvas, not silently zeroed.origin –
(y, x)of the canvas origin in the global frame.feather – seam ramp width in pixels, derived from the real overlaps rather than guessed.
dtype – dtype the canvas would be written in.
reference_channel – the channel registration was measured on.
- nominal_placements() List[Placement][source]¶
Every tile that did not register, worst confidence first.
- property accepted_pairs: List[PairResult][source]¶
Pairs that fed the global solve.
- property refused_pairs: List[PairResult][source]¶
Pairs that were scored and not believed.
- class spacr.align.AlignResult[source]¶
What
write_stack()actually did.- Parameters:
plan – alignment plan supplied to the write or retained by a preview result.
canvas – planned output-canvas geometry.
stack_path – path of the written
.npystack, or""for a dry run, preview, or empty write.n_written – number of distinct tiles that contributed at least one pixel to the canvas.
n_skipped – number of tiles whose reader or window read failed while writing; each tile is counted at most once.
peak_buffer_bytes – predicted maximum bytes occupied by the band accumulator and weight plane.
band_rows – maximum number of canvas rows held in one write band.
writer – canvas writer selected for the run,
"stream"or"memmap".status – write outcome:
"empty"before pixels are written,"complete"when no tile read failed, or"partial"when at least one tile was skipped.warnings – non-fatal dry-run, empty-canvas, read-failure, placement, or artifact-stamping messages.
db_path – coordinate database written by
save_coordinates(), or""when coordinates were not saved.
- class spacr.align.CanvasSpec[source]¶
Canvas geometry, computed before a single byte is allocated.
- Parameters:
height – number of rows in the output canvas.
width – number of columns in the output canvas.
channels – number of image planes in the output canvas.
dtype – NumPy dtype name used for the output array.
origin_y – global-frame row mapped to canvas row zero, preserving negative vertical offsets.
origin_x – global-frame column mapped to canvas column zero, preserving negative horizontal offsets.
- canvas_yx(y: float, x: float) Tuple[int, int][source]¶
Map a global-frame position onto integer canvas indices.
- Parameters:
y – global-frame row position, in pixels;
origin_yis subtracted and the result rounded to the nearest integer.x – global-frame column position, in pixels;
origin_xis subtracted and the result rounded to the nearest integer.
- class spacr.align.PairResult[source]¶
One neighbour pair, registered or refused.
- Parameters:
i – index of the first tile.
j – index of the second tile.
dy – row displacement of tile
jrelative to tileiused by this result: measured when accepted and normally the nominal fallback when refused.dx – column displacement of tile
jrelative to tileiused by this result: measured when accepted and normally the nominal fallback when refused.nominal_dy – row displacement predicted by nominal stage positions.
nominal_dx – column displacement predicted by nominal stage positions.
confidence – best non-negative normalized cross-correlation score. It may be below the acceptance threshold for a refused pair and is zero when no usable candidate could be scored.
accepted – whether this pair contributes an edge to the global position solve.
overlap_px – number of overlap pixels associated with the registration decision.
note – explanation for a refused pair, or
""for an accepted pair.
- class spacr.align.Placement[source]¶
Where one tile goes, and how much that position is worth.
- Parameters:
tile – source
Tileplaced by this result.y – solved row offset in the global frame; negative positions are preserved until canvas planning.
x – solved column offset in the global frame.
confidence – greatest accepted-pair confidence supporting this tile, zero for nominal fallback, or one for the single-tile case.
method – placement method, one of
METHOD_REGISTRATION,METHOD_NOMINAL, orMETHOD_SINGLE.note – explanation of fallback or refused-pair evidence.
residual – root-mean-square disagreement, in pixels, between the solved position and accepted incident pairs.
n_pairs – number of accepted registration pairs incident on this tile.
- class spacr.align.Tile[source]¶
One site: everything known about a tile without reading its pixels.
A “site” is a position on the stage, not a file. When each channel is its own file — the Yokogawa case — the sibling paths live in
channel_pathsandpathis the reference channel’s; when the channels are planes of one(H, W, C)array — spaCR’smerged/*.npy—channel_pathsis empty andpathis the only file. Either way there is exactly oneTile, and so exactly onePlacement, per stage position.- Parameters:
path – absolute path of the reference-channel file, or the sole file when all channels are planes of one array.
index – zero-based tile index used by pair results, the global solve, and the reader cache.
plate – plate token parsed from the filename, or
""when absent.well – well token parsed from the filename, such as
"B07", or""when absent.field – one-based field identifier assigned during scanning; zero only on a manually constructed tile without an assigned field.
channel – zero-based assembled-channel index selected to drive registration.
shape – assembled-site shape
(height, width, channels); unreadable headers retain zero height and width.dtype – NumPy dtype name reported by the reference header, or
"uint16"as the unreadable-header fallback.grid_row – zero-based nominal row in the acquisition grid.
grid_col – zero-based nominal column in the acquisition grid.
nominal_y – nominal vertical stage position in pixels.
nominal_x – nominal horizontal stage position in pixels.
channel_paths – absolute sibling-file paths in assembled channel order, or
()when all channels live insidepath.error – header-read failure text, or
""when the tile is readable.
- spacr.align.align_folder(settings: Mapping[str, Any] | None = None, **overrides: Any) List[AlignResult][source]¶
Scan, plan, optionally write and optionally record, in one call.
Always prints the plan before writing anything, so even a headless run leaves the “these four tiles fell back to nominal” block in the log where a surprised user can find it.
- Parameters:
settings – see
default_settings().overrides – keyword form of the same keys; these win.
- Returns:
one
AlignResultper stitched group (one per well whengroup_by_well).- Raises:
ConfigurationError – no
src, or no readable tiles.
- spacr.align.default_settings(settings: Mapping[str, Any] | None = None) Dict[str, Any][source]¶
Return the settings
align_folder()understands, with defaults.Shaped like every other
spacrsettings factory — pass a partial dict, get it back filled in — so the CLI and the Qt bridge can build a panel from it without special-casing this module.- Parameters:
settings – partial settings; keys given here win.
- spacr.align.estimate_offsets(tiles: Sequence[Tile], *, reference_channel: int | None = None, min_confidence: float = DEFAULT_MIN_CONFIDENCE, min_overlap_px: int = DEFAULT_MIN_OVERLAP_PX, upsample: int = DEFAULT_UPSAMPLE, neighbour_radius: int = 1, max_shift: float | None = None, anchor_weight: float = DEFAULT_ANCHOR_WEIGHT, dtype: Any | None = None, max_open_tiles: int = 8, ledger: spacr.errors.RunLedger | None = None) AlignPlan[source]¶
Register every overlapping neighbour pair and solve the whole set at once.
Memory: the peak allocation is two overlap strips. For a 10% overlap on 2048x2048 tiles that is 2 x 0.8 MB, whatever the canvas turns out to be.
- Parameters:
tiles – from
scan_tiles(), or built by hand.reference_channel – the plane registration is measured on. Every channel of a site then shares that site’s single solution — registering channels independently would shear the composite.
None(the default) takes the channel the tiles already carry, which is the onescan_tiles()selected. This used to default to0, soscan_tiles(reference_channel=2)followed by the documentedestimate_offsets(tiles)silently registered on channel 0 instead — the selection was stored on every tile and read by nothing. Tiles that disagree fall back to0; the resolved value is recorded on the plan and printed byformat_plan(), so whichever way it went is visible rather than assumed.min_confidence – normalised cross-correlation below which a pair is refused and the tile falls back to nominal.
min_overlap_px – overlaps narrower than this in either axis are not attempted.
upsample – sub-pixel refinement factor; 10 resolves 0.1 px.
neighbour_radius – Chebyshev grid distance to consider. 1 is the eight surrounding tiles. Raise it when the overlap exceeds 50%, so the
i/i+2pairs become real redundancy in the solve.max_shift – refuse any pair whose correction exceeds this many pixels. Defaults to a quarter of the smaller tile dimension.
anchor_weight – see
solve_positions().dtype – force the canvas dtype; defaults to the promotion of every tile’s dtype.
max_open_tiles – how many tiles may be memory-mapped at once. A column strip faults in a whole row-major tile, so leaving every tile mapped would make the resident set the size of the input folder; 8 covers the neighbourhood being registered.
ledger – optional
spacr.errors.RunLedgerto record per-tile success/failure into.
- Returns:
an
AlignPlan; nothing has been allocated or written.- Raises:
ConfigurationError – no readable tiles at all.
- spacr.align.format_plan(plan: AlignPlan, *, max_rows: int = 12) str[source]¶
Render a plan as the block a user should read before writing 800 MB.
Leads with what is wrong — nominal fallbacks, refused pairs, the worst residual — because that is the part a stitch summary usually buries.
- Parameters:
plan – alignment plan to summarise: its canvas shape, origin, placement and pair counts, nominal fallbacks, residuals, unplaced tiles and warnings are listed.
- spacr.align.group_tiles(tiles: Sequence[Tile]) Dict[Tuple[str, str], List[Tile]][source]¶
Split tiles into one stitchable set per
(plate, well).Fields of different wells are not neighbours and must never be registered against each other. Indices are renumbered within each group so the returned lists can go straight into
estimate_offsets().- Parameters:
tiles – tiles to split; each is grouped by its
plateandwellattributes and sorted by its originalindexwithin the group.
- spacr.align.plan_canvas(placements: Sequence[Placement], *, dtype: Any | None = None, channels: int | None = None) CanvasSpec[source]¶
Compute the canvas geometry from the offsets, allocating nothing.
Negative offsets are the normal case — the solve fixes the gauge to the nominal centroid, so a tile may land above or left of the origin. The canvas is sized to the bounding box of every placed tile and
CanvasSpec.origin_y/origin_xcarry the mapping back to the global frame, so nothing is ever clipped.- Parameters:
placements – from
AlignPlan.placements.dtype – canvas dtype; defaults to promoting every tile’s.
channels – canvas planes; defaults to the widest tile.
- Returns:
a
CanvasSpec.height/widthare 0 for an empty placement list.
- spacr.align.read_coordinates(db_path: str | os.PathLike, *, table: str = ALIGN_TABLE, plate: str | None = None, well: str | None = None) pandas.DataFrame[source]¶
Read
ALIGN_TABLEback out, closing the loop.- Parameters:
db_path – the database
save_coordinates()wrote.table – table name.
plate – optionally restrict to one
plateID.well – optionally restrict to one well, given as
'B07'or as an already-mappedrowID/columnIDpair joined by_.
- Returns:
the table as a DataFrame, in tile order.
- Raises:
ConfigurationError – the database or the table is missing.
- spacr.align.save_coordinates(plan: AlignPlan | Iterable[AlignPlan], db_path: str | os.PathLike, *, table: str = ALIGN_TABLE, canvas: CanvasSpec | None = None, stack_path: str = '', if_exists: str = 'replace') int[source]¶
Write one row per tile into
measurements.db.The point of this table is that a stitch is a claim: “field 47 of B07 sits at canvas row 3641, column 8192”. Downstream, a cluster of odd measurements in one corner of a well is either biology or a tile that silently fell back to its nominal position, and only this table can tell the two apart. So every row carries
method,confidenceandresidualnext to the coordinates.It is keyed exactly the way
conversion_mapand every measurement table are keyed, so it joins on the same four columns:SELECT c.*, a.canvas_y, a.canvas_x, a.method, a.residual FROM cell AS c JOIN align_coordinates AS a ON c.plateID = a.plateID AND c.rowID = a.rowID AND c.columnID = a.columnID AND c.fieldID = a.fieldID
or on the single
prcfcolumn, which is indexed. The same join works againstnucleus/pathogen/cytoplasm/png_listand againstconversion_mapitself, which chains a stitched coordinate all the way back to the original vendor file.Tiles that could never be read are written too, with
method='unreadable'and NULL coordinates — a missing field must be visible in the table, not absent from it.- Parameters:
plan – one
AlignPlanor several (one per well).db_path – SQLite database; created if missing.
table – table name.
canvas – the geometry actually written, when it differs from the plan’s.
stack_path – the
.npythese coordinates index into.if_exists –
'replace'(default),'append'or'fail'.
- Returns:
number of rows written.
- spacr.align.scan_tiles(src: str | os.PathLike | Sequence[Any], *, grid: Sequence[int] | None = None, overlap: float = DEFAULT_OVERLAP, order: str = 'row-major', recursive: bool = False, positions: Mapping[int, Sequence[float]] | None = None, reference_channel: int | None = None, group_by_well: bool = False) List[Tile][source]¶
Return one
Tileper stage position, without reading pixels.Every file’s shape and dtype comes out of its header. A folder of 1000 tiles is scanned in the time it takes to open 1000 files, which is what makes “show me the plan before you write 800 MB” possible.
Files that share
(plate, well, field)are collapsed into one tile with one plane per channel: a Yokogawa well with four channels per field produces oneTileper field, not four.- Parameters:
src – folder of tiles, a single file, or an explicit list of paths (which fixes the order — useful when the filenames carry no field number).
grid –
(rows, cols)of the acquisition. Inferred from the tile count when omitted.overlap – nominal neighbour overlap as a fraction of the tile, used only to seed the search; registration corrects it.
order – how a flat field sequence maps onto the grid — see
ORDERS. Serpentine acquisitions need'snake-row'.recursive – walk sub-folders too.
positions – optional
{field: (y, x)}of known stage positions in pixels. Overrides the grid entirely — pass the microscope’s real coordinates when you have them.reference_channel – which channel drives registration. Defaults to the lowest channel present.
group_by_well – lay
gridout once per well instead of once across the whole folder. A grid is a property of the acquisition and the acquisition is per well — the same field pattern is repeated in every well — so a folder holding two wells imaged 2x2 is two 2x2 grids, not one 2x4. With this off, such a folder was refused with “grid 2x2 has room for 4 tiles but 8 were found”, andgrid=Noneinferred a single 2x4 that laid each well out as a 1x4 strip and put its vertical neighbours outside the overlap.align_folder()passes its owngroup_by_wellsetting through.
- Returns:
tiles in grid order, each with
indexset.- Raises:
ConfigurationError –
srcdoes not exist, orgridis too small for the tiles found.
- spacr.align.solve_positions(n_tiles: int, edges: Sequence[Tuple[int, int, float, float, float]], nominal: numpy.ndarray, *, anchor_weight: float = DEFAULT_ANCHOR_WEIGHT) Tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray][source]¶
Solve every tile position at once from the pair graph.
Each edge
(i, j, dy, dx, w)contributes one weighted equationp_j - p_i = (dy, dx); each tile contributes one weak equationp_i = nominal_i. Minimising the whole residual at once is what stops error accumulating.Why not accumulate. Walking a row and adding each measured shift to the previous position gives
p_k = sum(d_0..d_k), so every measurement error is carried forward: ten tiles with 0.3 px errors put the last one 3 px out, and nothing in the output says so. Least squares over the same measurements distributes the error instead — and because a real grid has redundant edges (the tile below as well as the tile to the right, or thei/i+2pairs of a heavily overlapped row), disagreements cancel rather than compound. The surviving disagreement is the per-tile residual, which is returned.The anchor equations do two jobs: they fix the gauge (a pure pair graph determines positions only up to a global translation) and they place tiles that ended up in no edge at all — an isolated tile comes back at exactly its nominal position rather than at the origin.
- Parameters:
n_tiles – number of unknown positions.
edges –
(i, j, dy, dx, weight)per accepted pair.nominal –
(n_tiles, 2)array of nominal(y, x).anchor_weight – weight of the “stay at nominal” equations.
- Returns:
(positions, residuals, degrees)—(n, 2)solved positions,(n,)RMS per-tile residual in pixels, and(n,)count of accepted edges per tile.
- spacr.align.write_stack(plan: AlignPlan, dst: str | os.PathLike, *, blend: str = 'feather', band_rows: int | None = None, max_buffer_bytes: int = DEFAULT_MAX_BUFFER_BYTES, feather: int | None = None, dtype: Any | None = None, subpixel: bool = False, writer: str = 'stream', max_open_tiles: int = 8, overwrite: bool = False, name: str | None = None, dry_run: bool = False, ledger: spacr.errors.RunLedger | None = None) AlignResult[source]¶
Composite the plan into one
.npy, one horizontal band at a time.The canvas is never an in-RAM array. The output file is created at full size up front (so a disk that cannot hold it fails immediately, not at 90%), then filled band by band; the only heap allocations are the band’s float32 accumulator, its weight plane, and one tile window. All three are bounded by
max_buffer_bytesand none of them scales with the canvas.Seams are feathered: each tile’s contribution is weighted by a linear ramp that rises from the tile edge inward over
AlignPlan.featherpixels, and the band is divided by the total weight. A hard cut would leave a straight, high-contrast edge exactly where two fields meet — and a straight edge is the single most reliable thing a downstream segmentation will find and call an object boundary. Feathering costs one extra float32 plane per band and makes the join a gradient instead.blend='average'weights every contributor equally andblend='none'is last-writer-wins, both kept mainly so the difference can be measured.- Parameters:
plan – from
estimate_offsets().dst – output
.npy, or a folder to write into.blend – one of
BLEND_MODES.band_rows – force the band height; normally derived from
max_buffer_bytes.max_buffer_bytes – ceiling on the RAM the write may use for the band. This is the knob; see
DEFAULT_MAX_BUFFER_BYTESfor why the default is small.feather – override the ramp width in pixels.
dtype – override the canvas dtype.
subpixel – resample each tile by its fractional offset before compositing. Off by default: it costs an interpolation per tile window, and the sub-pixel part is recorded in the table either way.
writer –
'stream'(default; never maps the canvas) or'memmap'(open_memmapplus per-band flush and drop).max_open_tiles – how many tiles may be mapped at once. The tiles intersecting one band are the working set; older mappings are dropped so the input folder never becomes resident.
overwrite – replace an existing output. False refuses.
name – output filename when
dstis a folder.dry_run – compute the geometry and the band plan, write nothing.
ledger – optional
spacr.errors.RunLedger.
- Returns:
an
AlignResult, whosepeak_buffer_bytesis the band buffer size predicted from the band geometry, computed before anything is allocated and reported on thedry_runpath too.- Raises:
ConfigurationError – bad
blend/writer, or an existing output withoutoverwrite.