spacr.crops

On-demand single-object crops cut straight out of merged/*.npy.

Background

spacr.measure.measure_crop() writes a per-object PNG for every object it measures (<root>/data/.../{cell,nucleus,pathogen,cytoplasm}_png/) and records the paths in the png_list table of measurements/measurements.db. Every downstream consumer – the annotation GUIs, the classification datasets, the image UMAP – reads those PNGs. That costs disk, has to be regenerated whenever a crop setting changes, and goes stale silently.

The merged/ array already contains everything needed to cut the same crop on demand: the intensity planes and the integer label-mask planes. This module is that alternative source. It is deliberately additive – PngCropSource wraps the existing behaviour unchanged, MergedCropSource is the new one, and resolve_crop_source() picks between them and says which it picked.

Merged array layout

spacr.io._load_and_concatenate_arrays builds each merged/<fov>.npy as:

(H, W, n_intensity_channels + n_mask_planes)

The intensity channels come first (the subset selected by settings['channels'] at preprocessing time), then one uint16 label-mask plane per segmented object class, always in the order cell, nucleus, pathogen, organelle – each present only if that class was segmented. settings['cell_mask_dim'] / nucleus_mask_dim / pathogen_mask_dim / organelle_mask_dim record the resulting plane indices; spaCR’s default four-channel layout is {cell: 4, nucleus: 5, pathogen: 6, organelle: 7} (DEFAULT_MASK_DIMS).

There is no cytoplasm plane on disk: measure_crop derives cytoplasm as “cell minus nucleus/pathogen/organelle” in memory and never saves it back. This module derives it the same way (see MergedField.mask_plane()).

Fidelity

extract_crop() reproduces the PNG path in spacr.measure._measure_crop_core step for step: same channel selection (png_dims), same region definition (object mask, optionally replaced by its padded bounding box, optionally dilated), same _crop_center centering/padding, same normalize_to_dtype percentile normalisation, same dtype. png_view() turns that array into what a consumer sees after the PNG round trip, and read_crop_png() reads a crop PNG back into the same thing – the two are the two halves of one contract, and tests/test_crops.py asserts they agree.

Crop PNG format

This module is also the authority on what a crop PNG on disk means – see the “Crop PNG format” section below. In short: the current format is 3 (“declared_rgb”, CROP_FORMAT_CURRENT), whose red, green and blue slots hold exactly the source channels settings['png_channel_mapping'] names; format 1 (“legacy”, unmarked) holds the same bytes for the default mapping and is returned untouched; format 2, written for eleven days in 2026, is the one that is reversed, and read_crop_png() is what reverses it back. A folder says which format it is via a .spacr_crop_format.json sidecar, and an unmarked folder is format 1.

Dependencies

numpy, and the standard library (plus PIL only inside read_crop_png() and cv2 only inside migrate_crop_folder(), both imported lazily). No torch, no cellpose, no scipy, no skimage – importing this module must stay cheap enough for a GUI thumbnail path, and spacr.measure imports it for the writer helpers, so it must not import spacr back.

Exceptions

CorruptMergedFile

The .npy exists but cannot be read as an (H, W, C) array.

CropError

Base class for every failure raised while cutting an on-demand crop.

CropFormatConflict

The sidecar and the database disagree about a folder's crop format.

LabelMissing

The requested object label is not present in the mask plane.

MaskPlaneMissing

The array has no plane for the requested object type.

MergedFileMissing

The requested merged/*.npy does not exist.

PlaneLayoutConflict

A requested mask plane disagrees with the merged-folder manifest.

Classes

CropSource

A source of single-object crops.

CropSpec

Everything needed to reproduce one crop, byte for byte.

MergedCropSource

The new one: cut the crop out of merged/*.npy on demand.

MergedField

A memory-mapped merged/<fov>.npy plus its per-plane label indices.

MigrationResult

What migrate_crop_folder() did to one folder.

PngCropSource

The existing behaviour: read the pre-generated PNG named by the row.

Functions

build_png_channels(→ numpy.ndarray)

Assemble the crop planes in file order -- red, green, blue.

clear_crop_format_cache(→ None)

Forget every cached folder marker (and every "already stamped" folder).

clear_field_cache(→ None)

Drop every cached MergedField (and its label indices).

crop_folder_format(→ int)

Return the crop format that applies to folder as a whole.

crop_format_for_png(→ int)

Return the crop format of one crop PNG.

crop_settings_from_db(→ Dict[str, Any])

Read the settings table measure_crop writes into measurements.db.

crop_spec_from_settings(→ CropSpec)

Build a CropSpec from a measure_crop settings dict.

extract_crop(→ numpy.ndarray)

Cut one object out of a merged array, reproducing the PNG path exactly.

extract_crops(→ List[Optional[numpy.ndarray]])

Cut many objects out of one merged array, opening the file once.

find_crop_folders(→ List[str])

Return every *_png crop folder under root, sorted.

legacy_channel_names(→ List[str])

Map a legacy-trained model's train_channels onto format-2 crops.

legacy_png_view(→ numpy.ndarray)

Return what a naive PIL read of a legacy crop PNG gives back.

mask_dims_from_settings(→ Dict[str, int])

Return {object_type: plane index} from a measure_crop settings dict.

migrate_crop_folder(→ MigrationResult)

Repair reversed format-2 crops and stamp the folder. Idempotent.

migrate_crop_tree(→ List[MigrationResult])

Run migrate_crop_folder() on every crop folder under root.

narrow_to_uint8(→ numpy.ndarray)

Convert arr to uint8 using the crop-writer convention.

open_merged_field(→ MergedField)

Return a MergedField for path, reusing a cached one if possible.

png_dims_to_channel_mapping(→ Dict[str, Optional[int]])

Translate a legacy png_dims list into an explicit {r, g, b} map.

png_view(→ numpy.ndarray)

Return what a consumer sees after the crop has made the PNG round trip.

read_crop_folder_marker(→ Optional[Dict[str, Any]])

Return the parsed .spacr_crop_format.json of folder, or None.

read_crop_png(→ numpy.ndarray)

Read a crop PNG and return it in the corrected order, as 8-bit RGB.

read_db_crop_format(→ Optional[int])

Return the crop format recorded in the database, or None.

read_merged_plane_layout(→ Optional[Dict[str, Any]])

Read and validate a merged folder's optional plane-layout manifest.

reconcile_merged_mask_dims() → Dict[str, Any])

Apply a plane manifest and reject explicit conflicting mask indices.

resolve_crop_source(→ CropSource)

Pick the crop source for a run, and record which one it picked.

resolve_png_channel_mapping(→ Dict[str, Optional[int]])

Return the {r, g, b} source-channel mapping a run should use.

stamp_crop_folder(→ Optional[str])

Ensure that folder contains a crop-format marker.

stamp_crop_format_in_db(→ int)

Record the crop format on png_list, adding the column if needed.

to_cv2_bgr(→ numpy.ndarray)

Return png_channels in the order cv2.imwrite has to be handed it.

write_crop_folder_marker(→ str)

Write folder's crop-format sidecar atomically.

Module Contents

exception spacr.crops.CorruptMergedFile[source]

Bases: CropError

The .npy exists but cannot be read as an (H, W, C) array.

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

exception spacr.crops.CropError[source]

Bases: RuntimeError

Base class for every failure raised while cutting an on-demand crop.

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

exception spacr.crops.CropFormatConflict[source]

Bases: CropError

The sidecar and the database disagree about a folder’s crop format.

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

exception spacr.crops.LabelMissing[source]

Bases: CropError

The requested object label is not present in the mask plane.

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

exception spacr.crops.MaskPlaneMissing[source]

Bases: CropError

The array has no plane for the requested object type.

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

exception spacr.crops.MergedFileMissing[source]

Bases: CropError, FileNotFoundError

The requested merged/*.npy does not exist.

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

exception spacr.crops.PlaneLayoutConflict[source]

Bases: CropError

A requested mask plane disagrees with the merged-folder manifest.

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

class spacr.crops.CropSource[source]

A source of single-object crops.

Implementations return a (H, W, 3) uint8 RGB array from get(), so a consumer can swap one for the other without changing anything downstream.

describe() → str[source]

Return a one-line description for logs / the GUI status bar.

abstract get(row: Any) → numpy.ndarray[source]

Return the crop for row as a (H, W, 3) uint8 RGB array.

Parameters:

row – one measurement row – a mapping, a pandas Series, or any object carrying the fields as attributes. Which fields are required is the implementation’s business, not the interface’s: PngCropSource needs png_path (or accepts a bare path string), MergedCropSource needs the merged file and object_label.

get_image(row: Any)[source]

Return the crop for row as a PIL Image in RGB mode.

Parameters:

row – as for get(). PIL is imported inside this method, so a consumer that only ever wants arrays never pays for it.

get_many(rows: Iterable[Any]) → List[numpy.ndarray][source]

Return crops for many rows. Overridden by sources that can batch.

Parameters:

rows – rows to crop. The result has one entry per row in the same order, so a caller can zip the two. This base implementation is a plain loop over get() and therefore raises on the first row it cannot crop, and so does the one override shipped here, on MergedCropSource. Successful calls therefore never contain None.

class spacr.crops.CropSpec[source]

Everything needed to reproduce one crop, byte for byte.

The field names mirror the measure_crop settings that drive the PNG path, so a spec can be built straight from a saved settings snapshot (crop_spec_from_settings()).

Parameters:
  • merged_path – path to the merged/<fov>.npy the object lives in.

  • object_type – 'cell' | 'nucleus' | 'pathogen' | 'organelle' | 'cytoplasm' – selects the mask plane (measure_crop’s crop_mode).

  • label – the object’s integer label in that mask plane (object_label in measurements.db). 0 is background and is always an error.

  • channels – intensity plane indices, in output order (measure_crop’s png_dims).

  • size – (width, height) of the crop (measure_crop’s png_size). Note the width-first order – that is the PNG path’s convention.

  • mask_dims – object type -> plane index. None uses DEFAULT_MASK_DIMS.

  • use_bounding_box – crop the object’s padded bounding box instead of its exact outline (measure_crop’s use_bounding_box). The pad is hard-coded to 10 px in the PNG path; bbox_buffer mirrors it.

  • bbox_buffer – pad added around the bounding box, in pixels.

  • bbox – optional pre-computed (y0, y1, x0, x1) half-open bounding box (skimage regionprops convention) for this label, e.g. read from a database column. When given, the mask plane is never scanned to find the object, only the window is read.

  • dilate – dilate the region before cropping (dialate_pngs).

  • dilate_ratio – dilation radius as a fraction of sqrt(area) (dialate_png_ratios).

  • normalize – False (the shipped default) reproduces the PNG path’s fallback of a full 0-100 percentile stretch; a (p1, p2) pair reproduces the configured stretch.

  • normalize_by – 'png' (percentiles from the crop) or 'fov' (percentiles from the whole field before cropping).

with_(**kwargs) → CropSpec[source]

Return a copy of this spec with kwargs replaced.

class spacr.crops.MergedCropSource(spec: CropSpec | None = None, merged_root: str | None = None, object_type: str | None = None, reason: str = '')[source]

Bases: CropSource

The new one: cut the crop out of merged/*.npy on demand.

A row needs the merged array it came from and the object’s label. Both are already in measurements.db: path_name (written by spacr.utils._merge_and_save_to_database()) and object_label. prcfo / plateID / rowID / columnID / fieldID are used only as a fallback to rebuild <merged_root>/<plate>_<well>_<field>.npy.

Parameters:
  • spec – the template CropSpec; each row supplies merged_path and label.

  • merged_root – folder holding the .npy files, used to re-anchor a path_name recorded on another machine and for the prcfo fallback.

  • object_type – default object type when a row does not carry one.

  • reason – why this source was chosen (for describe()).

Configure on-demand crops, optionally overriding the object type.

get(row: Any) → numpy.ndarray[source]

Return the crop as a (H, W, 3) uint8 RGB array.

Deliberately routed through png_view(), so what a consumer gets here is identical to what read_crop_png() returns for the same object out of the PNG folder – 16-bit narrowing included.

Parameters:

row – a measurement row; spec_for() says which fields it has to carry. Each row is resolved and cut on its own, so use get_many() when filling a grid – it opens each merged file once for the whole batch instead of once per row.

get_array(row: Any) → numpy.ndarray[source]

Return the raw crop (native dtype, spec.channels order).

Parameters:

row – a measurement row; spec_for() says which fields it has to carry. What comes back is the pre-write array – the merged file’s dtype (uint16 on a normal run) and as many channels as the spec selects – not 8-bit RGB. Use get() for something a viewer or a classifier can take.

get_many(rows: Iterable[Any]) → List[numpy.ndarray][source]

Return crops for many rows, opening each merged file only once.

Parameters:

rows – rows to crop; spec_for() says which fields each has to carry. The result has one entry per row in the original order, however the rows were regrouped internally – they are bucketed by merged file so each .npy is memory-mapped and label-indexed once for the whole bucket. Every spec is built up front, so one row missing its label fails the batch before any file is opened. The default fail-loud extraction policy means successful calls never contain None.

resolve_path(row: Any) → str[source]

Return the merged .npy path for row.

The row-to-well conversion uses spacr.schema, imported lazily to keep this module’s import path dependency-light.

Parameters:

row – a measurement row. merged_path or path_name is used directly, and – when that path does not exist here – retried as <merged_root>/<basename>, which is how a database written on another machine still resolves. A path that exists nowhere is returned anyway, so the failure arrives later as MergedFileMissing. With neither key the name is rebuilt from file_name, or else from plateID / rowID / columnID / fieldID (all four required), and merged_root must be set or this raises.

spec_for(row: Any) → CropSpec[source]

Return the CropSpec describing row’s crop.

Parameters:

row – a measurement row. The label is the first present of object_label, label, cell_id, nucleus_id, pathogen_id, cytoplasm_id, and a row with none of them raises CropError; object_type, if present, overrides the template spec’s. bbox-0 .. bbox-3 (or bbox_0 .. bbox_3) are honoured only when all four are there, and are reordered out of the skimage regionprops convention (min_row, min_col, max_row, max_col) into the spec’s (y0, y1, x0, x1) – supplying them lets the crop skip the whole-plane label index scan. The mask plane inside that box is still read, unless the spec also sets use_bounding_box, which skips reading the label plane altogether.

class spacr.crops.MergedField(path: str, array=None, mask_dims: Mapping[str, int] | None = None)[source]

A memory-mapped merged/<fov>.npy plus its per-plane label indices.

The array is opened with mmap_mode='r': cutting one object reads the object’s mask plane once (cached) and then only the crop window, never the whole field. A 2048x2048x5 uint16 field is 40 MB; materialising it per object would make an on-demand grid slower than the PNG folder it replaces.

Only shape, dtype, ndim and __getitem__ are ever used on the underlying array, so tests can substitute a recording proxy to assert on the access pattern.

Parameters:
  • path – the merged/<fov>.npy field on disk.

  • array – the opened array. Defaults to memory-mapping path; pass one to substitute a proxy or an already-open handle.

  • mask_dims – which channel of the field holds each object’s mask, as {object: plane index}. Defaults to the layout recorded beside the file, and to DEFAULT_MASK_DIMS when the file records none.

Raises:

CorruptMergedFile – when the array is not (H, W, C).

Open or adopt a three-dimensional field and resolve its mask layout.

label_index(object_type: str) → _LabelIndex[source]

Return the cached _LabelIndex for object_type’s plane.

Parameters:

object_type – which plane to index. The cache is keyed on the plane index ('cytoplasm' gets its own key, since it has no plane on disk), so two object types recorded at the same mask_dim share one index. The scan happens on first use and is kept for the life of the field, which is what makes drawing many objects out of one field a single pass over the plane.

labels(object_type: str = 'cell') → List[int][source]

Return every non-zero label present in object_type’s plane.

Parameters:

object_type – which plane to list; defaults to 'cell' because that is the plane every spaCR run has. Labels come back in ascending order, background (0) is never among them, and an empty list means the plane holds no objects at all – not that the plane is missing, which raises instead.

mask_dim(object_type: str) → int[source]

Return the plane index holding object_type’s labels.

Parameters:

object_type – one of MASK_PLANE_ORDER – 'cell', 'nucleus', 'pathogen', 'organelle'. 'cytoplasm' is refused rather than defaulted: it has no plane on disk, so mask_plane() is the only way to get it. The index comes from this field’s mask_dims, not from the array, so a settings dict that names a plane the array does not have fails here rather than silently cropping by the wrong stain.

Raises:

MaskPlaneMissing – if the type has no plane, or the recorded plane index is out of range for this array.

mask_plane(object_type: str) → numpy.ndarray[source]

Return the 2-D label plane for object_type as a real array.

'cytoplasm' is derived on the fly – measure_crop computes it as the cell mask with every nucleus / pathogen / organelle pixel zeroed and never writes it back to the merged file.

Parameters:

object_type – 'cell' | 'nucleus' | 'pathogen' | 'organelle' come back as a view on the memory-mapped array, so nothing is read off disk until pixels are touched. 'cytoplasm' is the derived plane and costs a full pass over the field to build, so it is cached on this field and every later call is free.

read_mask_window(object_type: str, y0: int, y1: int, x0: int, x1: int) → numpy.ndarray[source]

Read object_type’s label plane over [y0:y1, x0:x1], zero-padded.

Parameters:
  • object_type – which label plane to read; 'cytoplasm' is served from the derived (and cached) plane, every other type straight off the memory map.

  • y0 – first row; may be negative, and the overhang comes back as zeros – which reads as background, so no label ever appears to run past the edge of the field.

  • y1 – one past the last row (half-open); may exceed the field height, padded as for y0.

  • x0 – first column, negative allowed as for y0.

  • x1 – one past the last column, over-wide allowed as for y1.

read_window(y0: int, y1: int, x0: int, x1: int, channels: Sequence[int], dtype=None) → numpy.ndarray[source]

Read channels over [y0:y1, x0:x1], zero-padding outside the array.

The window may run off any edge; the out-of-array part comes back as zeros, which is what the PNG path’s np.pad produces.

Parameters:
  • y0 – first row of the window. May be negative – that part is padded, not clamped, so the object stays centred in the result.

  • y1 – one past the last row (half-open). May exceed the field height; the overhang is padded the same way.

  • x0 – first column, negative allowed as for y0.

  • x1 – one past the last column, over-wide allowed as for y1.

  • channels – plane indices in output order – result channel k holds plane channels[k], and repeating an index repeats the plane. Each must satisfy 0 <= c < C; negative indices are rejected rather than wrapped, so -1 is an error and not “the last plane”. An empty sequence yields an (h, w, 0) array here; extract_crop() rejects it first.

  • dtype – dtype of the returned array. None uses crop_dtype, i.e. what the PNG path would have cropped in; pass one explicitly only to match an array you already hold.

property crop_dtype[source]

Return the dtype crops are cut in.

_measure_crop_core promotes anything that is not uint8/uint16 to uint16 before cropping; this mirrors that.

property dtype[source]

Return the on-disk dtype of the merged array.

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

Return the (H, W, C) shape of the merged array.

class spacr.crops.MigrationResult[source]

What migrate_crop_folder() did to one folder.

Parameters:
  • folder – crop folder that was examined or migrated.

  • converted – filenames whose channel order was or would be rewritten.

  • skipped – filenames needing no rewrite, including already-processed or single-channel crops.

  • failed – (filename, reason) pairs for crops that could not be converted.

  • already – whether the folder was already in a format requiring no migration.

  • dry_run – whether the result describes planned work without writing files.

  • mode – "rewrite" for pixel conversion or "mark" for recording legacy format without touching pixels.

describe() → str[source]

Return a one-line summary for a log.

class spacr.crops.PngCropSource(root: str | None = None, folder: str = 'data', reason: str = '', db_path: str | None = None)[source]

Bases: CropSource

The existing behaviour: read the pre-generated PNG named by the row.

Reads go through read_crop_png(), so a folder of legacy (format 1) crops is corrected on load and comes back in the same channel order as a new one – the caller cannot tell which it opened, which is the point.

Parameters:
  • root – optional experiment root used to re-anchor png_path values recorded on another machine (the same rewrite spacr.utils.correct_paths() performs).

  • folder – the anchor folder name for that rewrite.

  • reason – why this source was chosen (for describe()).

  • db_path – measurements.db consulted for the crop_format column when a folder carries no sidecar; defaults to <root>/measurements/measurements.db when root is given.

Configure reanchoring and discover an existing default database.

get(row: Any) → numpy.ndarray[source]

Return the PNG for row decoded as a (H, W, 3) uint8 RGB array.

Legacy content is converted on load, so this equals png_view(extract_crop(...)) for the same object whichever format the folder is in.

Parameters:

row – as for resolve(). The folder’s sidecar – failing that, this source’s db_path – is what decides whether the file’s channels are reversed on the way back, so the same row can legitimately give different pixels before and after a folder is marked or migrated.

resolve(row: Any) → str[source]

Return the on-disk PNG path for row, re-anchored under root.

Parameters:

row – a row carrying png_path (or path), or a bare path string, which is accepted as-is and only re-anchored. A row with neither raises CropError. Re-anchoring goes through reanchor_path(), so it is separator-agnostic (a Windows path opened on Linux re-anchors), it takes the LAST <folder> component rather than the first (an old root that itself contained a data folder used to produce a path naming a directory), and “already under the root” is a component-wise prefix test rather than a substring one. A path carrying no anchor at all is returned untouched even if it points nowhere on this machine, and the failure surfaces on read.

spacr.crops.build_png_channels(data: numpy.ndarray, mapping: Dict[str, int | None], dtype=None) → numpy.ndarray[source]

Assemble the crop planes in file order – red, green, blue.

The returned array uses the same channel order as the PNG file, so in-memory and decoded representations have the same colour semantics.

Greyscale is preserved: when all three colours name the same source channel the result is a single plane, so cv2 writes a one-channel PNG exactly as png_dims=[a] always did, rather than three identical planes at three times the size.

Parameters:
  • data – the merged (H, W, C) array.

  • mapping – as returned by resolve_png_channel_mapping().

  • dtype – optional dtype to cast the assembled planes to.

Returns:

(H, W, 1|3) array, red plane first.

Raises:

CropError – a mapping index is out of range for data.

spacr.crops.clear_crop_format_cache() → None[source]

Forget every cached folder marker (and every “already stamped” folder).

spacr.crops.clear_field_cache() → None[source]

Drop every cached MergedField (and its label indices).

spacr.crops.crop_folder_format(folder: str, db_path: str | None = None, *, strict: bool = False) → int[source]

Return the crop format that applies to folder as a whole.

Precedence: sidecar, then the database column, then CROP_FORMAT_LEGACY_BGR. When both are present and they disagree, the sidecar wins – it is the marker that travels with the folder – and the disagreement is reported: printed by default, raised when strict.

A folder in the middle of a migration reports CROP_FORMAT_LEGACY_BGR, because the files that have not been converted yet still are; use crop_format_for_png() to resolve one file inside such a folder.

Parameters:
  • folder – the crop folder.

  • db_path – optional measurements.db to consult.

  • strict – raise CropFormatConflict instead of printing.

Returns:

the format integer.

spacr.crops.crop_format_for_png(png_path: str, db_path: str | None = None, *, strict: bool = False) → int[source]

Return the crop format of one crop PNG.

Same precedence as crop_folder_format(), plus the two per-file overrides an interrupted migrate_crop_folder() leaves behind:

  • a leftover <name>.spacr_v2 staging file means the file next to it has not been converted yet – it is still legacy;

  • a name in the marker’s unconverted list is a file the migration could not rewrite. It stays legacy for good, in a folder that is otherwise format 2, so it still has to be read correctly;

  • otherwise, inside a folder whose marker carries a migration block, a file at or before the recorded watermark is converted and one after it is not.

Parameters:
  • png_path – the crop PNG.

  • db_path – optional measurements.db to consult.

  • strict – raise on a sidecar/database conflict.

Returns:

the format integer.

spacr.crops.crop_settings_from_db(db_path: str) → Dict[str, Any][source]

Read the settings table measure_crop writes into measurements.db.

spacr.io._save_settings_to_db stores every setting as (setting_key, setting_value) strings; this parses them back so a crop cut on demand can use the same png_dims / png_size / normalize that produced the PNG folder.

Parameters:

db_path – path to measurements.db.

Returns:

the settings dict, or {} if the table is absent.

spacr.crops.crop_spec_from_settings(settings: Mapping[str, Any], merged_path: str = '', object_type: str | None = None, label: int = 0) → CropSpec[source]

Build a CropSpec from a measure_crop settings dict.

Uses png_dims, png_size, normalize, normalize_by, use_bounding_box, dialate_pngs, dialate_png_ratios, crop_mode and the *_mask_dim keys – i.e. everything that shaped the PNG folder.

Parameters:
  • settings – The measure_crop settings. A scalar png_size defines a square crop. Object-specific values in nested png_size, dialate_pngs, and dialate_png_ratios are selected by the object’s position in crop_mode and fall back to the first entry when that object is absent. Channels are resolved from png_channel_mapping, or legacy png_dims, through channels_from_settings() and stored in colour order. Text forms such as "[2, 98]", "2,98", and "[1 99]" are parsed as percentile windows; a non-text sequence with a length other than two disables normalization.

  • merged_path – the merged/<fov>.npy to record on the spec. The default "" builds a template spec, which is what MergedCropSource wants: it fills the path (and label) in per row.

  • object_type – which mask plane to crop by; None takes the first entry of settings['crop_mode']. 'cytoplasm' forces dilate=False whatever the settings say, because _measure_crop_core hard-disables dilation for it.

  • label – the object’s object_label. The default 0 is background, so it is only meaningful on a template spec – cutting with it raises LabelMissing.

spacr.crops.extract_crop(merged_path: str, object_type: str = 'cell', label: int = 0, *, spec: CropSpec | None = None, field: MergedField | None = None, **kwargs) → numpy.ndarray[source]

Cut one object out of a merged array, reproducing the PNG path exactly.

The returned array is the pre-write array: exactly what _measure_crop_core hands to the writer. Its dtype is the merged array’s (uint16 for a normal spaCR run) and its channel order is spec.channels. Use png_view() to get what a consumer reading the written PNG (via read_crop_png()) would see.

Parameters:
  • merged_path – the merged/<fov>.npy.

  • object_type – which mask plane to crop by.

  • label – the object’s object_label.

  • spec – a ready-made CropSpec; merged_path / object_type / label and kwargs override its fields.

  • field – an already-open MergedField to cut from.

  • kwargs – any other CropSpec field.

Returns:

(height, width, n_channels) array.

Raises:
spacr.crops.extract_crops(merged_path: str, specs: Iterable[CropSpec], *, mask_dims: Mapping[str, int] | None = None, on_error: str = 'raise') → List[numpy.ndarray | None][source]

Cut many objects out of one merged array, opening the file once.

A grid draws from a handful of fields, so batching matters: the .npy is memory-mapped once and every object’s mask plane is indexed once, no matter how many objects are requested from it.

Parameters:
  • merged_path – the merged/<fov>.npy; every spec is cut from it, whatever spec.merged_path says.

  • specs – iterable of CropSpec.

  • mask_dims – object type -> plane index for the whole batch; defaults to the first spec’s mask_dims.

  • on_error – 'raise' (default) or 'none' to put None in the result for each failing spec instead of raising.

Returns:

list of crops, one per spec, in order.

spacr.crops.find_crop_folders(root: str) → List[str][source]

Return every *_png crop folder under root, sorted.

Accepts an experiment root, its data folder, or a crop folder itself.

Parameters:

root – where to look.

Returns:

absolute folder paths.

spacr.crops.legacy_channel_names(channels: Iterable[str]) → List[str][source]

Map a legacy-trained model’s train_channels onto format-2 crops.

A classifier trained on legacy crops learned “input plane 0 is whatever is in the file’s red channel”, and in a legacy file that is png_dims[-1]. Feed the same model a format-2 crop and plane 0 is now png_dims[0] – a permutation of its input, which it will happily score and get wrong, with no error anywhere.

Reversing the request undoes the permutation exactly: red and blue swap, green is unmoved. So a model trained with train_channels=['r','g','b'] keeps seeing the pixels it was trained on if it is applied with ['b','g','r'], and one trained with ['r','g'] with ['b','g'].

This is a stopgap for a model you cannot retrain. Retraining on corrected crops is the real fix, and it is cheap compared with getting this wrong.

Parameters:

channels – the train_channels the model was trained with.

Returns:

the equivalent list to apply it with on format-2 crops.

spacr.crops.legacy_png_view(crop: numpy.ndarray) → numpy.ndarray[source]

Return what a naive PIL read of a legacy crop PNG gives back.

Kept, and named for what it is, because it is the inverse of the format-1 write and therefore the thing read_crop_png() has to undo. Two behaviours, both of them the bug:

  • the channel order is reversed relative to png_dims – cv2.imwrite read the array as BGR;

  • a uint16 crop is a 16-bit PNG and PIL narrows it two different ways: the high byte (// 256) for an RGB image, but a clip at 255 for a single-channel one, which flattens any crop brighter than 255/65535 to solid white.

Nothing in spaCR calls this on the live path any more. It exists so tests can prove the legacy reader inverts the legacy writer exactly, and so code that genuinely needs bug-compatible pixels (a classifier trained on legacy crops, say) can ask for them by name instead of by accident.

Parameters:

crop – the array returned by extract_crop().

Returns:

(H, W, 3) uint8 RGB array.

spacr.crops.mask_dims_from_settings(settings: Mapping[str, Any]) → Dict[str, int][source]

Return {object_type: plane index} from a measure_crop settings dict.

Falls back to DEFAULT_MASK_DIMS for anything the dict does not name.

Parameters:

settings – a measure_crop settings mapping (a live dict or one read back by crop_settings_from_db()). Only the cell_mask_dim / nucleus_mask_dim / pathogen_mask_dim / organelle_mask_dim keys are read; a key that is absent, blank, the string 'none' or not an integer is skipped rather than raised on. The fallback is all-or-nothing: a dict naming even one plane returns only the planes it named, so an object type it left out has no entry at all – it is not filled in from DEFAULT_MASK_DIMS.

spacr.crops.migrate_crop_folder(folder: str, *, mode: str = 'rewrite', dry_run: bool = False, on_error: str = 'raise', db_path: str | None = None, progress: Any | None = None) → MigrationResult[source]

Repair reversed format-2 crops and stamp the folder. Idempotent.

Format 2 stores three-channel crops in the reverse of their declared channel mapping. Formats 1 and 3, as well as unmarked folders, already use declared order and require no pixel rewrite.

mode='rewrite' (the default) rewrites every 3-channel PNG of a format-2 folder with its channels put back, and marks the folder format 3. A folder that is format 1, format 3 or unmarked is already in declared order, so it is an immediate no-op – which is the answer for almost every folder that exists.

mode='mark' touches no pixels and only records that the folder is format 1 – use it when something outside spaCR reads those exact bytes and must keep seeing them (a classifier trained on legacy crops, for instance).

Interruption safety, which is the whole design:

  • each file is converted into a durable staging file <name>.spacr_v2 (itself written temp-then-os.replace, per io._save_array_atomic) and only then os.replace-d over the original, so the crop at its real name is always a complete PNG – the old one or the new one;

  • the folder marker carries a migration block with a done_through watermark, advanced before the install, so the rule “a staging file exists ⇒ the crop beside it is still legacy” resolves every file at every point in the sequence. That is what crop_format_for_png() reads, and it is why a killed migration is still read correctly and can be run again.

Running it on an already-converted folder is an immediate no-op: nothing is decoded, nothing is written, and result.already is True. Running it twice therefore cannot double-reverse anything.

The one exception is a folder finished with on_error='skip': its marker names the files that could not be rewritten, those stay legacy (and are read as legacy) inside an otherwise format-2 folder, and a later run retries only them.

Parameters:
  • folder – the crop folder (.../<well>/cell_png and friends).

  • mode – 'rewrite' or 'mark'.

  • dry_run – report what would happen; write nothing.

  • on_error – 'raise' (default) or 'skip', which records the file in the marker’s unconverted list and keeps reading it as legacy.

  • db_path – also stamp png_list.crop_format in this database.

  • progress – optional callable (done, total, name).

Returns:

a MigrationResult.

Raises:

CropError – bad arguments, or a file that cannot be converted when on_error='raise'.

spacr.crops.migrate_crop_tree(root: str, **kwargs) → List[MigrationResult][source]

Run migrate_crop_folder() on every crop folder under root.

Parameters:
  • root – experiment root, its data folder, or one crop folder.

  • kwargs – forwarded to migrate_crop_folder().

Returns:

one MigrationResult per folder, in folder order.

spacr.crops.narrow_to_uint8(arr: numpy.ndarray) → numpy.ndarray[source]

Convert arr to uint8 using the crop-writer convention.

uint16 (and anything wider) is narrowed by taking the high byte, which is a plain linear rescale of a crop that normalize_to_dtype already stretched across the full dtype range. Floats, which only appear when a caller hands in something the crop path never produces, are clipped – there is no dtype range to rescale from.

This differs from PIL’s format-dependent behaviour: PIL takes the high byte of a 16-bit RGB PNG but clips a 16-bit single-channel one at 255, so the same pixel value survives or saturates depending on how many channels its neighbours have. One behaviour, applied here, replaces both.

Parameters:

arr – any numeric array.

Returns:

uint8 array of the same shape.

spacr.crops.open_merged_field(path: str, mask_dims: Mapping[str, int] | None = None, use_cache: bool = True) → MergedField[source]

Return a MergedField for path, reusing a cached one if possible.

Parameters:
  • path – the merged/<fov>.npy.

  • mask_dims – object type -> plane index; None uses DEFAULT_MASK_DIMS.

  • use_cache – set False to force a fresh open (and a fresh label index).

Raises:
spacr.crops.png_dims_to_channel_mapping(png_dims) → Dict[str, int | None][source]

Translate a legacy png_dims list into an explicit {r, g, b} map.

png_dims never said which colour it meant; the answer was buried in cv2’s BGR interpretation of the array it was handed, which is how the convention got inverted for eleven days without anyone being able to point at the line that decided it. The list is still accepted – every settings CSV and every notebook in the wild holds one – but it is translated here, once, into a mapping that says what it means.

The translation is the legacy reading, because that is the one that was ever on screen: entry 0 is blue, 1 is green, 2 is red.

  • [a, b, c] -> {'r': c, 'g': b, 'b': a}

  • [a, b] -> {'r': None, 'g': b, 'b': a} (the old zero third plane)

  • [a] -> {'r': a, 'g': a, 'b': a} (greyscale; see build_png_channels(), which keeps it a one-plane image)

Parameters:

png_dims – the legacy list of source channel indices.

Returns:

a {'r': idx, 'g': idx, 'b': idx} dict; None means an empty plane.

Raises:

CropError – more than three entries, or an empty list.

spacr.crops.png_view(crop: numpy.ndarray) → numpy.ndarray[source]

Return what a consumer sees after the crop has made the PNG round trip.

This is the contract, and it is deliberately boring: channel i of the crop is channel i of the result, narrowed to 8 bit by narrow_to_uint8().

That holds because a crop is cut in COLOUR order – CropSpec.channels is (red_source, green_source, blue_source), built by channels_from_settings() from the declared png_channel_mapping. So channel 0 is the red one here, in the file, and in what read_crop_png() hands back. There is exactly one order and every part of the crop path speaks it.

The alternative – keeping crops in png_dims list order and translating at the edges – is what made the on-demand source and the PNG folder return different pixels for the same object.

read_crop_png() returns exactly this for the same object, for a crop written in either format, which is what makes the on-demand source and the PNG folder interchangeable.

Before spaCR grew a crop-format marker the answer was the reverse of this (see legacy_png_view()); that was a bug, not a convention.

Parameters:

crop – the array returned by extract_crop().

Returns:

(H, W, 3) uint8 RGB array.

spacr.crops.read_crop_folder_marker(folder: str, use_cache: bool = True) → Dict[str, Any] | None[source]

Return the parsed .spacr_crop_format.json of folder, or None.

A sidecar that cannot be parsed is treated as absent – a corrupt marker must not be more trusted than no marker, and no marker means legacy, which is the safe answer.

Parameters:
  • folder – the crop folder (the one holding the PNGs).

  • use_cache – reuse a cached read while the sidecar is unchanged.

Returns:

the marker dict, or None when there is no usable one.

spacr.crops.read_crop_png(path: str, fmt: int | None = None, db_path: str | None = None, as_format: int = CROP_FORMAT_CURRENT) → numpy.ndarray[source]

Read a crop PNG and return it in the corrected order, as 8-bit RGB.

The one function every consumer of a crop folder should go through. It resolves the file’s format (see crop_format_for_png()), reverses the channel axis when the file’s ordering differs from the one asked for – which today means format 2 only, since formats 1 and 3 are both already in declared order – and narrows to 8 bit with narrow_to_uint8(), so a legacy dataset and a new one come back identical and the caller never has to know which it opened.

The result equals png_view(extract_crop(...)) for the same object, under either format. That equality is the contract, and tests/test_crops.py asserts it.

Parameters:
  • path – the crop PNG.

  • fmt – what the file on disk is, when you know better than the marker does. None resolves it.

  • as_format – what ordering you want back. The default is the declared one. Formats 1 and 3 share this order; format 2 requests the intermediate reversed order. Classification also needs its recorded intensity-decoding policy, handled by spacr.classification_pixels.

  • db_path – optional measurements.db consulted when the folder has no sidecar.

Returns:

(H, W, 3) uint8 RGB array.

Raises:
spacr.crops.read_db_crop_format(db_path: str, png_path: str | None = None, table: str = 'png_list') → int | None[source]

Return the crop format recorded in the database, or None.

Reads the crop_format column of png_list: for one png_path if given, otherwise the single distinct value covering the whole table (a table holding both formats returns None – ambiguous is not an answer).

Parameters:
  • db_path – path to measurements.db.

  • png_path – restrict to one crop’s row.

  • table – table holding the crops.

Returns:

the format integer, or None when the column, the table or the database is absent, or the answer is ambiguous.

spacr.crops.read_merged_plane_layout(path: str) → Dict[str, Any] | None[source]

Read and validate a merged folder’s optional plane-layout manifest.

Legacy folders have no manifest and return None. A present but malformed manifest raises: once metadata exists, silently ignoring it would recreate the exact wrong-plane failure the manifest prevents.

Parameters:

path – a merged folder, or a .npy file inside one (its parent folder is used); the .spacr_plane_layout.json sidecar is read from that folder.

spacr.crops.reconcile_merged_mask_dims(settings: Mapping[str, Any], merged_folder: str, *, explicit_keys: Iterable[str] = ()) → Dict[str, Any][source]

Apply a plane manifest and reject explicit conflicting mask indices.

A manifest is authoritative for the folder it accompanies. Defaults are replaced automatically; a non-None value the caller explicitly supplied must agree or the run stops before measuring a wrong plane. Legacy folders without a manifest return an unchanged copy.

Every organelle slot the manifest records gets its plane, and every slot settings carries is set to the manifest’s answer. A slot that neither names is left out of the copy rather than set to None. The cell, nucleus and pathogen keys are always set, so a plane the manifest does not record is switched off.

Parameters:
  • settings – the run settings; its <role>_mask_dim keys are read and a copy with those keys reconciled is returned (the mapping itself is not modified).

  • merged_folder – the merged folder whose plane-layout manifest is applied.

  • explicit_keys – settings keys the caller supplied explicitly; a non-None value under one of these that disagrees with the manifest raises PlaneLayoutConflict.

spacr.crops.resolve_crop_source(settings_or_src: str | Sequence[str] | Mapping[str, Any], *, object_type: str | None = None, prefer: str | None = None, ask: Any | None = None) → CropSource[source]

Pick the crop source for a run, and record which one it picked.

The returned object’s CropSource.kind is 'png' or 'merged' and CropSource.reason says why, so a caller can print source.describe() instead of guessing.

Selection order:

  1. an explicit prefer argument, then settings['crop_source'] ('png' | 'merged' | 'auto');

  2. otherwise 'auto': the PNG folder if one exists (nothing changes for existing datasets), else the merged folder.

When the merged source is chosen and measurements.db holds the measure_crop settings, the crop parameters (png_dims, png_size, normalize, mask plane indices, …) are read back from it, so the on-demand crops match the PNGs that run would have produced.

Parameters:
  • settings_or_src – a settings dict (with src, optionally crop_source), a source path, or a list/tuple whose first entry is the source path – the experiment root or its merged folder.

  • object_type – default object type for the merged source.

  • prefer – force 'png' or 'merged'.

Raises:

CropError – the requested source is not available.

spacr.crops.resolve_png_channel_mapping(settings) → Dict[str, int | None][source]

Return the {r, g, b} source-channel mapping a run should use.

Precedence, and the reason for it:

  1. settings['png_channel_mapping'] – the explicit form. If the user said which channel is red, that is the answer.

  2. settings['png_dims'] – the legacy list, translated by png_dims_to_channel_mapping(). A settings CSV written by any older build lands here and keeps rendering the way it always did.

  3. DEFAULT_PNG_CHANNEL_MAPPING.

A mapping that names a colour spaCR does not have, or a non-integer index, is an error rather than a silent drop: a mis-keyed mapping would otherwise delete a whole stain from every crop in the run and say nothing.

Parameters:

settings – the run settings dict (or anything with .get).

Returns:

{'r': idx, 'g': idx, 'b': idx}; None means an empty plane.

Raises:

CropError – an unknown colour key or a non-integer channel index.

spacr.crops.stamp_crop_folder(folder: str, fmt: int = CROP_FORMAT_CURRENT) → str | None[source]

Ensure that folder contains a crop-format marker.

The crop writer calls this before writing the first PNG. An interrupted run therefore leaves a marked, possibly incomplete folder rather than an unmarked folder that could be interpreted as the legacy format.

Each folder is checked once per process. Failure to write the marker emits a warning instead of aborting the measurement run, because the image data remain valid but their stored channel convention becomes ambiguous.

If a folder already contains crops in another format, the function reports the conflict but does not migrate files. Migration remains a separate, single-process operation in migrate_crop_folder().

Parameters:
  • folder – the crop folder.

  • fmt – format to record; defaults to CROP_FORMAT_CURRENT.

Returns:

the sidecar path, or None if it could not be written.

spacr.crops.stamp_crop_format_in_db(db_path: str, png_paths: Iterable[str] | None = None, fmt: int = CROP_FORMAT_CURRENT, table: str = 'png_list') → int[source]

Record the crop format on png_list, adding the column if needed.

The database copy is advisory – crop_format_for_png() prefers the sidecar – but it makes “which of my plates are still legacy?” a query rather than a filesystem walk.

Parameters:
  • db_path – path to measurements.db.

  • png_paths – rows to stamp; None stamps every row.

  • fmt – the format to record.

  • table – table holding the crops.

Returns:

number of rows updated.

Raises:

CropError – unknown fmt.

spacr.crops.to_cv2_bgr(png_channels: numpy.ndarray) → numpy.ndarray[source]

Return png_channels in the order cv2.imwrite has to be handed it.

build_png_channels() assembles the crop in file order – red plane first – while cv2.imwrite interprets a 3-channel array as BGR. Reversing the channel axis here, once, in the writer, makes cv2’s interpretation land the array’s red plane in the file’s red slot, so the PNG’s slots hold the channels settings['png_channel_mapping'] named (format 3). Under DEFAULT_PNG_CHANNEL_MAPPING that puts png_dims[0] in blue, byte-identical to format 1.

  • 2-D or single-channel: returned unchanged. cv2 writes a grayscale PNG and does no colour interpretation, so there is nothing to reverse.

  • 2 channels: padded with a zero plane to RGB first, then reversed. build_png_channels() never emits two planes – it carries an empty colour as a zero plane in the slot the user left blank – so this is for callers that assemble their own array.

  • 3 channels: reversed.

  • 4 or more: refused. cv2 would write BGRA, and PIL then reads the fourth intensity plane as an alpha channel and drops it on convert('RGB') – a whole stain silently deleted from every crop. settings['png_dims'] documents a maximum of three entries; this is where a fourth stops being ignored and starts being an error.

Parameters:

png_channels – the crop in file order, as build_png_channels() assembles it for _measure_crop_core.

Returns:

the array to hand to cv2.imwrite.

Raises:

CropError – more than three channels.

spacr.crops.write_crop_folder_marker(folder: str, fmt: int = CROP_FORMAT_CURRENT, **extra: Any) → str[source]

Write folder’s crop-format sidecar atomically.

Temp file plus os.replace(), like spacr.io._save_array_atomic: a marker is either the previous one or the complete new one, never a half-written JSON document that read_crop_folder_marker() would then read as “no marker” – i.e. as legacy – over a folder of corrected crops.

Parameters:
  • folder – the crop folder.

  • fmt – any known crop format (1, 2 or 3); defaults to CROP_FORMAT_CURRENT, i.e. CROP_FORMAT_DECLARED_RGB.

  • extra – extra keys to record (migration, png_dims, …). A key whose value is None is dropped.

Returns:

the sidecar path.

Raises:

CropError – fmt is not a known format.