spacr.qt.mask_engine

Pure-Python mask editing and persistence for the Qt Make Masks screen.

This module provides image and mask I/O plus non-brush label operations, including fill, relabel, size and intensity filtering, Otsu detection, and magic-wand selection. It has no Qt dependency, so the editing operations can be tested without a display.

THREE INVERSIONS LIVE HERE AND THEY DO DIFFERENT THINGS. invert_intensity() is the photographic complement of an IMAGE – dtype_max - value, what a viewer’s Invert does, exactly reversible on every integer dtype – and it is what the Make Masks screen’s “Invert image” draws with. invert_for_detection() reflects an image about its OWN range instead, and is what the screen’s “Invert for detection” hands the detectors; its docstring has the measurement that says why a detector cannot use the complement, and it is NOT a duplicate to be merged away. invert_mask() flips a LABEL image’s foreground and background; on an ordinary field that gives one object covering the frame, which is why it was reported as doing nothing.

save_mask() passes labels through canonical_labels(), which preserves existing nonzero object identifiers rather than renumbering connected components. This maintains correspondence with measurements, tracks, and crops keyed by those identifiers.

Saving also writes the artifact’s spacr.curation.CurationLog sidecar, consistent with spacr.napari_bridge and spacr.qt.curation_tool. The sidecar allows spacr.curation.is_curated() to distinguish manually edited masks from pipeline-generated masks.

Where a field’s mask lives

The editor opens all three layouts spacr.curation_queue reads, and edits each one in place rather than converting it:

nested

<folder>/<image> with the mask at <folder>/masks/<stem>.tif. The default, and what every function below does when told nothing else.

sibling

<root>/images/<image> with the mask at <root>/masks/<stem>.tif. The editor opens <root>/images and passes masks_dir=<root>/masks; without it the mask would be read from and written to <root>/images/masks, a folder the set does not have.

seg

<folder>/<stem>_seg.npy, a Cellpose bundle holding the image and the labels in one pickled dict. The editor’s file list names the bundles themselves, and a name ending in SEG_SUFFIX is read and written as a bundle by load_image_and_mask(), save_mask(), write_recrop() and retire_recropped_original().

In place is sound for both, which is why there is no convert step. A sibling set differs from a nested one only in where its masks folder is, so passing that folder is the whole change. A bundle is rewritten with every key it already had kept, masks replaced, and the two keys derived from the masks – outlines and ismanual – brought up to date with it (see save_seg_bundle()); that is what the external curation tool did to the same files, less its stale outlines. A convert step would have been a second copy of every field, and the curator would have had to remember which copy was the truth.

Attributes

FILTER_BOUNDS

The four legacy bounds, named as filter_report()'s keywords are.

FILTER_KEYS

The keys of one filter entry, the whole of its serialised form.

Exceptions

RecropRefused

Raised when a proposed recrop does not satisfy recropping rules.

Classes

FailedBound

One side of one filter entry that an object fell outside.

FilterRemoval

One object the filter dropped, and which bound dropped it.

MaskHistory

Bounded undo/redo stack of mask arrays. Deep-copies on push so

ObjectLookup

The objects of one mask, measured as filter_objects() measures them.

ObjectRemoval

One object a filter list removed, with what it was measured as.

PixelReadout

What the Make Masks readout says about one pixel of the open field.

PrimarySecondaryReport

Label relationships between a primary mask and a secondary mask.

PropagateResult

What one maxima-and-propagate run found.

Recrop

Result of writing a recropped image and mask.

SecondaryResult

Secondary labels, their primary relationships and the common stop level.

Functions

apply_filters() → Tuple[numpy.ndarray, ...)

Make Masks' filter list applied to one mask; (mask, removals).

box_overlap(→ float)

Intersection over union of two (x0, y0, x1, y1) boxes.

canonical_labels(→ numpy.ndarray)

Return mask as uint16 labels, keeping every id it already had.

canonical_property(→ str)

name as the regionprop it means, or a ValueError naming the choices.

clear_mask(→ numpy.ndarray)

Return an all-zero array shaped like mask.

combine_masks(→ numpy.ndarray)

Fold a fresh detection into an existing mask.

connected_instances(→ numpy.ndarray)

Label every separated foreground region as its own object.

curation_csv_path(→ str)

The keep/discard CSV for the images in folder.

curation_folder(→ str)

Where a folder of images keeps its curation files.

curation_verdict(→ Optional[bool])

Whether this field is marked keep, discard, or not marked at all.

cut_recrop(→ Tuple[numpy.ndarray, numpy.ndarray])

Extract an image region and its complete labelled objects.

dilate_objects(→ numpy.ndarray)

Grow every object by distance pixels, without merging any two.

divide_object(mask, p0, p1[, width])

Cut every object the segment crosses in two; return (mask, splits).

erase_object_at(→ numpy.ndarray)

Zero out the object under (x, y). No-op if no object there.

erase_object_in_place(→ int)

Zero the object under (x, y) in place; return the id removed, or 0.

export_yolo_boxes(→ str)

Atomically write a YOLO label text file and sibling class metadata.

field_stem(→ str)

The stem a field is known by in curate_status.csv.

fill_holes(→ numpy.ndarray)

Fill enclosed background pixels, optionally retaining primary identities.

fill_label_holes(→ numpy.ndarray)

Close the holes inside each object, keeping every id it already had.

fill_polygon(mask, points[, label_value])

Fill a traced outline as ONE object; return (mask, label).

filter_objects(→ Tuple[numpy.ndarray, List[int]])

Drop objects outside the bounds; (mask, dropped ids).

filter_properties(→ Tuple[str, ...])

The regionprops a filter row may name, for an image at hand.

filter_removals(, require_finite_intensity)

Judge every object of labels against filters; return the failures.

filter_report(→ Tuple[numpy.ndarray, List[FilterRemoval]])

Filter mask by the legacy bounds and filters, measuring what went.

filters_need_intensity(→ bool)

Whether any entry of filters needs an intensity image.

invert_for_detection(→ numpy.ndarray)

Reflect an image about its OWN range, for a DETECTOR to read.

invert_intensity(→ numpy.ndarray)

Return the photographic complement of image: dark becomes bright.

invert_mask(→ numpy.ndarray)

Swap object and background in a LABEL image, and relabel.

invert_normalized(→ numpy.ndarray)

Normalise image to 0..1 on its OWN range, take 1 - v, fit back.

is_seg_bundle(→ bool)

Whether filename names a Cellpose _seg.npy bundle.

legacy_filters(→ List[dict])

The four old hard-coded bounds as entries of the filter list.

list_images(→ List[str])

Return filenames of image files in folder, sorted, or [].

load_image_and_mask(→ Tuple[numpy.ndarray, numpy.ndarray])

Load an image and its accompanying mask.

load_seg_bundle(→ Tuple[numpy.ndarray, numpy.ndarray])

Load a Cellpose bundle as the editor's (image, mask) pair.

load_yolo_boxes(→ dict)

Load this source's boxes, refusing stale bytes or changed image shape.

magic_wand(→ numpy.ndarray)

BFS flood-fill from (seed_x, seed_y) filling pixels whose intensity

mask_save_path(→ str)

Where this field's mask is written -- and where its ledger sits.

masks_folder(→ str)

Where the masks of the images in folder are kept.

maxima_propagate_instances(→ PropagateResult)

Segment bright objects with local maxima and an intensity watershed.

next_label(→ int)

The id to give the next object drawn on mask: one past its top.

normalise_filters(→ List[dict])

filters as the canonical list of {property, min, max} dicts.

normalize_for_detection(→ numpy.ndarray)

image stretched between two percentiles, as Make Masks draws it.

normalize_uint16(→ numpy.ndarray)

Return image clipped + rescaled to its dtype's full range.

object_filter_area_floor(→ int)

The smallest area object_filters keeps for object_type.

otsu_instances(→ numpy.ndarray)

Threshold image at Otsu's level and label what is left.

overlay_mask(→ numpy.ndarray)

Blend a colorized label mask onto a grayscale image, uint8 RGB.

paint_disk(→ None)

In-place stamp a filled square (radius half-width) at (cx, cy).

paint_line(→ None)

In-place stamp a line of disks between two points (Bresenham).

parse_object_filters(→ dict)

The object_filters setting as a dict of object type to rows.

primary_secondary_report(→ PrimarySecondaryReport)

Report shared, missing, orphaned and incompletely enclosed object IDs.

property_needs_intensity(→ bool)

Whether the regionprop name measures pixel values.

read_curation(→ Dict[str, Dict[str, str]])

Every verdict recorded for folder, keyed by image path.

read_image(→ numpy.ndarray)

Read one image file the way Make Masks shows it.

read_recrop_manifest(→ List[dict])

Return recrop-archive records in chronological order.

read_seg_bundle(→ Dict)

Read a Cellpose _seg.npy bundle as the dict it holds.

record_curation(→ str)

Record one verdict, replacing any the field already had.

recrop_archive_dir(→ str)

Return the archive directory for recropped source fields.

recrop_box() → Tuple[int, int, int, int])

Validate and clip a rectangular recrop selection.

recrop_child_name(→ str)

Return the next unused <field>__rNN filename.

relabel_objects(→ numpy.ndarray)

Return a mask whose connected components are labeled 1..N.

relative_tolerance(→ float)

Magic-wand tolerance as percent of image's intensity range.

remove_small_objects(→ numpy.ndarray)

Drop connected components with area < min_area (in pixels).

restore_recropped_original(→ List[str])

Restore an archived source field to the image queue.

retire_recropped_original([boxes])

Archive a source field after recropped children have been created.

save_mask(→ str)

Write the mask to <folder>/masks/<stem>.tif and return that path.

save_seg_bundle(→ str)

Write edited labels back into the bundle they came from.

save_yolo_boxes(→ pathlib.Path)

Atomically save one source-bound box record in the project's ledger.

secondary_object_instances(→ SecondaryResult)

Grow secondary objects from labelled primaries with a seeded watershed.

seg_outlines(→ numpy.ndarray)

The outlines entry of a bundle, redrawn for labels.

settings_filters(→ List[dict])

The object_filters list a Mask run applies to object_type.

shrink_objects(→ numpy.ndarray)

Erode every object by distance pixels, each one on its own.

split_object_at(→ Tuple[numpy.ndarray, List[int]])

Cut the object under (x, y) where its halves meet; (mask, new_ids).

threshold_algorithms(→ Tuple[str, ...])

Every threshold algorithm name, global then local.

write_recrop(→ Recrop)

Write a recropped field, mask, and curation record.

yolo_box_lines(→ list[str])

Encode full-image pixel boxes as standard YOLO class cx cy w h.

Module Contents

exception spacr.qt.mask_engine.RecropRefused(reason: str, message: str)[source]

Bases: ValueError

Raised when a proposed recrop does not satisfy recropping rules.

Variables:

reason – Stable reason code: "no_field", "too_small", or "redraw".

Parameters:
  • reason – the stable code above. Callers branch on it, so it must be one of the three rather than prose.

  • message – what to say to the user. This is the exception’s own message, so it is what an unhandled raise would print.

Record why a re-crop was refused.

Parameters:
  • reason – the machine-readable refusal code, kept so a caller can branch on it rather than parsing the message.

  • message – the sentence shown to the user.

class spacr.qt.mask_engine.FailedBound[source]

Bases: NamedTuple

One side of one filter entry that an object fell outside.

Variables:
  • index – the entry’s position in the filter list.

  • property – the regionprop it judged.

  • side – "min" or "max".

  • bound – the number the user set.

  • value – the object’s measured value.

class spacr.qt.mask_engine.FilterRemoval[source]

Bases: NamedTuple

One object the filter dropped, and which bound dropped it.

The screen reports “object 22 with area x and intensity y was removed by minimum intensity”, one row per object, so the filter has to say more than which ids went.

Variables:
  • label – the id canonical_labels() gave the object – the same id the hover readout showed for it.

  • area – its pixel count.

  • mean_intensity – its mean value on the raw image.

  • bounds – the names of every LEGACY bound it failed, a subset of FILTER_BOUNDS in that order.

  • failed – every bound it failed, legacy or listed, as FailedBound entries.

class spacr.qt.mask_engine.MaskHistory(capacity: int = 20)[source]

Bounded undo/redo stack of mask arrays. Deep-copies on push so callers can mutate in place without corrupting older snapshots.

Prepare an empty history with a bounded snapshot capacity.

Parameters:

capacity – max snapshots kept in the undo (and redo) stack.

can_redo() → bool[source]

Return True when the redo stack has a snapshot to restore.

can_undo() → bool[source]

Return True when at least one prior snapshot is available to undo to.

clear() → None[source]

Discard every snapshot from both the undo and redo stacks.

head() → numpy.ndarray | None[source]

The newest snapshot — what an edit in progress started from.

Returned as held, not copied, so a caller that only wants to diff against it does not pay for a copy of a 16-bit field on every mouse release. It is already a private copy of whatever was pushed, so reading it cannot disturb the history; writing to it would.

push(mask: numpy.ndarray) → None[source]

Store a deep-copy of mask and drop any redo history.

Parameters:

mask – label mask to record as the newest undo state.

redo() → numpy.ndarray | None[source]

Restore the most-recently-undone snapshot, or None if empty.

undo() → numpy.ndarray | None[source]

Pop the top snapshot, save it to the redo stack, and return the previous snapshot (i.e. one step back). None if not possible.

class spacr.qt.mask_engine.ObjectLookup(mask: numpy.ndarray, image: numpy.ndarray, *, preserve_ids: bool = False)[source]

The objects of one mask, measured as filter_objects() measures them.

Built once for a mask state and then asked about one pixel at a time, so a readout that follows the mouse costs a bounding box per question rather than the whole field. The id is the one canonical_labels() gives, the area is that id’s pixel count, and the mean is taken on the raw image in float32 over the object’s pixels in raster order, which is the arithmetic skimage.measure.regionprops() performs for the filter. So an intensity bound set to the mean shown keeps the object, and a bound just past it removes it.

Parameters:
  • mask – the label image.

  • image – the raw image under it, with the mask’s height and width.

Number the objects and index their bounding boxes.

Parameters:
  • mask – the label image.

  • image – the raw image under it.

  • preserve_ids – index exact IDs without binary interpretation or splitting disconnected pieces; agrees with exact-ID filtering.

at(x: int, y: int) → PixelReadout | None[source]

The readout for image pixel (x, y).

Parameters:
  • x – column.

  • y – row.

Returns:

the readout, or None for a pixel outside the field.

measure(label: int) → Tuple[int, float] | None[source]

The area and mean intensity of object label.

Parameters:

label – an id in labels.

Returns:

(area, mean), or None when no object has that id.

class spacr.qt.mask_engine.ObjectRemoval[source]

Bases: NamedTuple

One object a filter list removed, with what it was measured as.

Variables:
  • label – the object’s id in the label image that was judged.

  • values – every property the pass measured for it, by name.

  • failed – each bound it fell outside, in list order.

class spacr.qt.mask_engine.PixelReadout[source]

Bases: NamedTuple

What the Make Masks readout says about one pixel of the open field.

Variables:
  • x – column, in image pixels.

  • y – row, in image pixels.

  • intensity – the raw image value at the pixel, before any display stretching.

  • label – the id of the object under the pixel, as canonical_labels() numbers it; 0 on background.

  • area – that object’s pixel count; 0 on background.

  • mean_intensity – that object’s mean raw intensity, or None on background.

class spacr.qt.mask_engine.PrimarySecondaryReport[source]

Bases: NamedTuple

Label relationships between a primary mask and a secondary mask.

All fields contain sorted tuples of Python integer IDs; 0 is excluded. matched_ids occur in both masks. missing_secondary_ids occur only in the primary mask, and orphan_secondary_ids only in the secondary mask. incomplete_primary_ids are matched IDs whose secondary does not contain every pixel of its primary. Matched IDs without any secondary pixels outside their own primary are listed in unexpanded_primary_ids; these can indicate a threshold that stopped growth immediately. A match alone does not prove correct cell boundaries.

Variables:
  • primary_ids – nonzero IDs present in the primary mask.

  • secondary_ids – nonzero IDs present in the secondary mask.

  • matched_ids – IDs present in both masks.

  • missing_secondary_ids – primary IDs absent from the secondary mask.

  • orphan_secondary_ids – secondary IDs absent from the primary mask.

  • incomplete_primary_ids – matched IDs whose secondary omits primary pixels.

  • unexpanded_primary_ids – matched IDs with no growth beyond their primary.

class spacr.qt.mask_engine.PropagateResult[source]

Bases: NamedTuple

What one maxima-and-propagate run found.

Parameters:
  • labels – the objects, one label per seed that survived.

  • seeds – how many local maxima were found. THE NUMBER THE USER TUNES AGAINST: too many and the minimum distance or the seed level is too low, too few and an object has no centre to grow from, and neither is visible from the objects alone.

  • level – the intensity the growth stopped at, for the stop rules that have ONE – absolute, percentile and a global threshold. None for seed_fraction, which has a different level per object and so has no single number to report.

class spacr.qt.mask_engine.Recrop[source]

Bases: NamedTuple

Result of writing a recropped image and mask.

Variables:
  • name – Filename assigned to the recropped field.

  • image_path – Path of the written image.

  • mask_path – Path of the written label mask.

  • n_objects – Number of complete labelled objects retained.

  • box – Source coordinates as (x0, y0, x1, y1).

class spacr.qt.mask_engine.SecondaryResult[source]

Bases: NamedTuple

Secondary labels, their primary relationships and the common stop level.

labels has the primary mask’s dtype, shape and retained object IDs. relationships includes primaries removed by minimum-area filtering. level is None for the per-primary peak-ratio rule or an empty primary mask; otherwise it is the common threshold in processed intensity units.

Variables:
  • labels – secondary label array retaining primary IDs and dtype.

  • relationships – primary/secondary identity report after filtering.

  • level – shared stop threshold, or None when no common threshold applies.

spacr.qt.mask_engine.apply_filters(mask: numpy.ndarray, image, filters, *, preserve_ids: bool = False, report=('area',)) → Tuple[numpy.ndarray, List[ObjectRemoval]][source]

Make Masks’ filter list applied to one mask; (mask, removals).

Objects are judged under canonical_labels() ids, the ids the hover readout shows, and the failing ones are zeroed in a copy; the other ids are left as they were. An intensity property reads image, the raw loaded pixels (a 3-D image is averaged over its last axis first), never the contrast-stretched display.

Parameters:
  • mask – the label mask to filter.

  • image – the raw intensity image, or None when none is open; required by any intensity property in filters.

  • filters – the filter list, in any form normalise_filters() accepts.

  • preserve_ids – judge objects by their supplied ids, as canonical_labels() does with it; disconnected pieces sharing an id count together.

  • report – properties measured for the ledger in the same pass; intensity_mean is added whenever an image is given.

Returns:

the original array untouched and an empty list when nothing fails.

spacr.qt.mask_engine.box_overlap(a, b) → float[source]

Intersection over union of two (x0, y0, x1, y1) boxes.

Parameters:
  • a – first box; its first four items are read and truncated to int.

  • b – second box, read the same way.

Returns:

A value from 0 for disjoint boxes to 1 for identical boxes.

spacr.qt.mask_engine.canonical_labels(mask: numpy.ndarray, *, preserve_ids: bool = False) → numpy.ndarray[source]

Return mask as uint16 labels, keeping every id it already had.

The old behaviour here was label(mask > 0), which renumbers the connected components 1..N on every save. That throws away the identity of every object: erase object 7 of 20 and objects 8..20 each slide down by one, so the saved mask no longer keys against the measurements, the crops or the tracks derived from the segmentation it was edited from.

So ids are kept, with two things settled:

  • A mask with one foreground value carries no ids to keep. That is what a purely brush-painted mask looks like – every stroke writes the same value – and it is a binary image, not a label image. Its components are numbered 1..N, which loses nothing.

  • A label that names two separated blobs is split. One id must mean one object. The largest piece keeps the id and the rest are given the smallest ids not already in use, so painting a second blob with the brush over a real segmentation adds an object instead of extending a distant one.

Each id is examined inside its own bounding box (scipy.ndimage.find_objects()) rather than across the whole field, which gives the same pieces in the same order and makes the call cheap enough to run while the mouse moves: on a 2048 x 2048 field of 400 objects it went from about 3.5 s to tens of milliseconds.

Parameters:
  • mask – a label image; any integer or boolean dtype in default mode.

  • preserve_ids – disable binary interpretation and component splitting. Use for primary/secondary relationships: a lone cell 900 remains 900, and separated pieces with the same primary ID remain one label. Requires a nonempty 2-D nonnegative integer array, not a boolean mask.

Returns:

the labels as uint16.

Raises:

ValueError – when an id does not fit in uint16, or exact-ID mode receives invalid labels. Oversized IDs are never truncated.

spacr.qt.mask_engine.canonical_property(name) → str[source]

name as the regionprop it means, or a ValueError naming the choices.

Old skimage spellings (mean_intensity, MajorAxisLength, convex_area) are accepted and mapped to the current name through skimage’s own alias table, so a filter written against an older release still names the same measurement.

Parameters:

name – a regionprop name, current or legacy spelling.

spacr.qt.mask_engine.clear_mask(mask: numpy.ndarray) → numpy.ndarray[source]

Return an all-zero array shaped like mask.

Parameters:

mask – label mask whose shape and dtype the result copies.

spacr.qt.mask_engine.combine_masks(old: numpy.ndarray, new: numpy.ndarray, mode: str = 'replace') → numpy.ndarray[source]

Fold a fresh detection into an existing mask.

Parameters:
  • old – existing label image used as the merge base; ignored when mode is "replace".

  • new – newly detected label image. In merge mode its positive labels are offset above old and copied only into background pixels.

  • mode – "replace" – the detection is the mask, and whatever was there is gone. "merge" – keep every existing object and add the detected ones only where nothing is labelled yet, with fresh ids above the existing maximum. Merge never overwrites or splits an object that was curated by hand, which is the point of offering the choice: a detection run halfway through an editing session should not be able to silently undo the first half of it.

Raises:

ValueError – for an unknown mode, rather than quietly picking one and discarding the user’s edits.

spacr.qt.mask_engine.connected_instances(binary: numpy.ndarray, min_area: int = 0) → numpy.ndarray[source]

Label every separated foreground region as its own object.

Parameters:
  • binary – array whose truthy pixels are foreground. Regions touching diagonally are connected under the editor’s eight-neighbour rule.

  • min_area – regions smaller than this are dropped rather than labelled, so a detection does not hand back a field of single-pixel speckles for the user to delete by hand.

spacr.qt.mask_engine.curation_csv_path(folder: str) → str[source]

The keep/discard CSV for the images in folder.

Parameters:

folder – the folder the editor opened.

Returns:

<folder>/csv/keep_discard.csv.

spacr.qt.mask_engine.curation_folder(folder: str) → str[source]

Where a folder of images keeps its curation files.

spaCR’s layout: masks live at <images>/masks and the curation CSV at <images>/csv. It is a folder rather than a file beside the images so that a folder listing of the fields is still a listing of the fields.

Parameters:

folder – the folder the editor opened.

Returns:

<folder>/csv.

spacr.qt.mask_engine.curation_verdict(folder: str, image_path: str) → bool | None[source]

Whether this field is marked keep, discard, or not marked at all.

Parameters:
  • folder – the folder the editor opened.

  • image_path – the field.

Returns:

True for keep, False for discard, None for no row.

spacr.qt.mask_engine.cut_recrop(image: numpy.ndarray, mask: numpy.ndarray, box) → Tuple[numpy.ndarray, numpy.ndarray][source]

Extract an image region and its complete labelled objects.

Objects touching the crop boundary are removed because their masks are incomplete. Remaining labels are renumbered consecutively from one.

Parameters:
  • image – Source microscopy image.

  • mask – Label image aligned with image.

  • box – Crop coordinates as (x0, y0, x1, y1).

Returns:

Cropped image and relabelled mask.

spacr.qt.mask_engine.dilate_objects(mask: numpy.ndarray, distance: int = 1) → numpy.ndarray[source]

Grow every object by distance pixels, without merging any two.

A label takes the background pixels within distance of it; a pixel contested by two labels goes to the nearer one, and a pixel that already carries a label is never taken. SO THE OBJECT COUNT CANNOT CHANGE, which is what makes this safe on a mask that has been curated: ids survive, and an object that has been given the right id keeps it, along with every measurement, track and crop keyed by it.

The distance is Euclidean (skimage.segmentation.expand_labels()), so 1 adds the four edge neighbours and not the corners – the same metric shrink_objects() takes away by, which is what makes a shrink after a dilate land back where it started on an object with no neighbour close enough to have blocked the growth.

Parameters:
  • mask – label image; 0 is background.

  • distance – pixels to grow by. 0 or less returns a copy.

Returns:

a mask of the same dtype with the same label values.

spacr.qt.mask_engine.divide_object(mask: numpy.ndarray, p0, p1, width: float = DIVIDE_CUT_WIDTH)[source]

Cut every object the segment crosses in two; return (mask, splits).

splits is a list of (id_split, id_created) pairs, empty when the line separated nothing.

Three decisions make the result usable:

  • Only the objects the line actually crosses are touched. The cut is clipped to them, so a line drawn past a neighbour leaves that neighbour’s every pixel where it was. (The standalone tool relabels the whole field after cutting, which renumbers every other object in it — the same re-keying canonical_labels() exists to avoid.)

  • The larger piece keeps the original id, and the smaller pieces get fresh ones above the mask’s top label. That is the rule canonical_labels() already applies when one id names two blobs, so dividing and then saving does not renumber anything, and the id stays on the piece that carries most of what it used to name.

  • A line that does not separate an object leaves it alone. Stopping halfway across would otherwise carve a groove into the object and call it a division; treating it as a miss means the gesture can just be redrawn.

Parameters:
  • mask – 2-D label mask; it is not modified.

  • p0 – first end of the cut as (x, y) in pixels.

  • p1 – second end of the cut as (x, y) in pixels.

spacr.qt.mask_engine.erase_object_at(mask: numpy.ndarray, x: int, y: int) → numpy.ndarray[source]

Zero out the object under (x, y). No-op if no object there.

Parameters:
  • mask – 2-D label mask; a modified copy is returned and the input is left alone.

  • x – column in pixels; out of range returns mask itself.

  • y – row in pixels; out of range returns mask itself.

spacr.qt.mask_engine.erase_object_in_place(mask: numpy.ndarray, x: int, y: int) → int[source]

Zero the object under (x, y) in place; return the id removed, or 0.

The copy-and-return erase_object_at() is right for a single click, where one edit is one undo step. A right-button sweep is dozens of move events and one thing the user did, so it deletes in place against a single pre-sweep snapshot: copying a 16-bit field per mouse event would both stutter and put every object of the sweep on its own undo step.

The returned id is what the ledger records as the sweep’s targets.

Parameters:
  • mask – 2-D label mask, modified in place.

  • x – column in pixels; out of range removes nothing.

  • y – row in pixels; out of range removes nothing.

spacr.qt.mask_engine.export_yolo_boxes(path, boxes, image_shape, classes) → str[source]

Atomically write a YOLO label text file and sibling class metadata.

The label path must end in .txt. Its sibling .classes.json keeps the ordered class names; an existing map may only be extended. Empty boxes intentionally writes an empty label file for a negative image. Source images and masks are never opened or converted by this export.

Parameters:
  • path – chosen .txt path in an existing directory.

  • boxes – (class_id, x0, y0, x1, y1) full-image pixel boxes.

  • image_shape – shape of the image the labels describe.

  • classes – ordered names whose indexes are the class IDs.

Returns:

the written label text path as a string.

spacr.qt.mask_engine.field_stem(filename) → str[source]

The stem a field is known by in curate_status.csv.

os.path.splitext gives well_A1_seg for well_A1_seg.npy, and the queue calls that field well_A1; a status row written under the first name would never take the field out of the queue.

Parameters:

filename – an image file name, or a _seg.npy bundle name.

Returns:

the file name without its extension, or without SEG_SUFFIX for a bundle.

spacr.qt.mask_engine.fill_holes(mask: numpy.ndarray, *, preserve_ids: bool = False) → numpy.ndarray[source]

Fill enclosed background pixels, optionally retaining primary identities.

Holes are filled per object by fill_label_holes(), never by filling the foreground and labelling it again: that joined every pair of touching cells into one object.

Parameters:
  • mask – label image to fill.

  • preserve_ids – validate the ids as exact primary IDs first (uint16-compatible, via canonical_labels()). Either way every id is kept. By default a mask with a single foreground value – a brush-painted binary mask, which carries no ids – is numbered by connectivity first, as canonical_labels() numbers it.

spacr.qt.mask_engine.fill_label_holes(labels: numpy.ndarray) → numpy.ndarray[source]

Close the holes inside each object, keeping every id it already had.

THE ONE HOLE FILLER FOR LABEL IMAGES. fill_holes(), the propagation step above and spacr.utils.fill_holes_in_mask() (the Cellpose fill_in step) all come here. Filling the binary foreground and labelling it afresh with ndimage.label is what this replaces: it makes every pair of TOUCHING objects one object, so a field of 74 adjacent cells came back as 8. Here nothing is relabelled.

binary_fill_holes over the whole foreground would also fill the gap BETWEEN objects that happen to ring a piece of background, so each label is filled on its own and written back only onto background: a hole never overwrites another object’s pixels. Each label is filled inside its own bounding box (scipy.ndimage.find_objects()), which is exact – background on the edge of the box touches the outside of the box, where this label has no pixels, so it can never be one of its holes – and keeps a 2048 x 2048 field of hundreds of objects to a fraction of a second. Smaller boxes claim first, so a hole inside a ring that itself sits inside another ring goes to the inner one.

Parameters:

labels – a 2-D (or N-D) non-negative integer label image. A boolean mask is labelled by connectivity first, since it carries no ids to keep.

Returns:

an array of the input’s dtype (int32 for a boolean input) in which every object keeps its id and its holes carry that id.

spacr.qt.mask_engine.fill_polygon(mask: numpy.ndarray, points, label_value: int | None = None)[source]

Fill a traced outline as ONE object; return (mask, label).

This is the tool a brush is not. A brush stamps disks along the path, so tracing a cell’s rim with it labels the rim and leaves the middle background; draw closes the path (last point back to the first) and fills what it encloses, so one gesture produces one solid object with one id. Anything already labelled inside the outline is overwritten, which is the point: the outline asserts “all of this is one object”.

Parameters:
  • mask – existing label image to copy and edit; labels outside the enclosed pixels are preserved and the dtype widens when the new id requires it.

  • points – the traced path as image-pixel (x, y) pairs. A path that encloses less than one pixel – two points, or a straight line traced back over itself – is returned unchanged with label 0. It is a gesture that enclosed nothing, and the alternative is an object a pixel wide that the user then has to find and delete.

  • label_value – the id to give it; by default next_label().

spacr.qt.mask_engine.filter_objects(mask: numpy.ndarray, image: numpy.ndarray, *, min_area: int = 0, max_area: int = 0, min_intensity: float = 0.0, max_intensity: float = 0.0, filters=None, preserve_ids: bool = False) → Tuple[numpy.ndarray, List[int]][source]

Drop objects outside the bounds; (mask, dropped ids).

filter_report() without the measurements: the same engine, the same list, only the ids kept. Area is the pixel count; intensity is the mean on the raw image. Each legacy bound is off at 0.

Parameters:
  • mask – 2-D label mask; it is not modified.

  • image – raw intensity image the same height and width as mask.

  • filters – further filter entries (see normalise_filters()).

  • preserve_ids – retain primary/secondary identities when measuring and removing labels; disconnected pieces sharing an ID count together.

spacr.qt.mask_engine.filter_properties(*, intensity: bool = False) → Tuple[str, ...][source]

The regionprops a filter row may name, for an image at hand.

Parameters:

intensity – True when an intensity image exists for the mask, so the intensity statistics are real for it. Without one they are not offered at all, rather than offered and refused later.

Returns:

property names, shape properties first, each group sorted.

spacr.qt.mask_engine.filter_removals(labels: numpy.ndarray, filters, intensity=None, *, report=(), require_finite_intensity: bool = False) → List[ObjectRemoval][source]

Judge every object of labels against filters; return the failures.

THE ONE FILTER ENGINE. Make Masks’ Filter list, filter_report(), and Mask generation’s spacr.utils._filter_objects() all run this, so one list gives one answer wherever it is applied. It calls skimage.measure.regionprops_table() ONCE per mask with every property the list names plus report, never once per filter.

An object is kept when min <= value <= max for every entry; a side that is None is off. A NaN measurement fails no bound, since there is nothing to compare.

Parameters:
  • labels – integer label image, 2-D or 3-D.

  • filters – the filter list, in any form normalise_filters() accepts.

  • intensity – pixel values the same shape as labels; required exactly when an entry names an intensity property.

  • report – extra properties to measure in the same pass so a caller can print them; they judge nothing.

  • require_finite_intensity – raise when an intensity property is not finite for some object, as Mask generation always has.

Returns:

the removals sorted by label; empty when nothing fails.

Raises:

ValueError – an intensity property without an intensity image, or a property scikit-image cannot compute for this dimensionality.

spacr.qt.mask_engine.filter_report(mask: numpy.ndarray, image: numpy.ndarray, *, min_area: int = 0, max_area: int = 0, min_intensity: float = 0.0, max_intensity: float = 0.0, filters=None, preserve_ids: bool = False) → Tuple[numpy.ndarray, List[FilterRemoval]][source]

Filter mask by the legacy bounds and filters, measuring what went.

The four keyword bounds are migrated into the filter list by legacy_filters() and judged together with filters by apply_filters() – one engine, one regionprops pass.

Parameters:
  • mask – 2-D label mask; it is not modified.

  • image – raw intensity image the same height and width as mask; a 3-D image is averaged over its last axis first.

  • filters – further filter entries (see normalise_filters()).

  • preserve_ids – measure all pixels bearing an ID as one object, including lone or disconnected primary/secondary labels.

Returns:

(mask, removals), the removals sorted by id. Nothing to do returns the original array untouched and an empty list.

spacr.qt.mask_engine.filters_need_intensity(filters) → bool[source]

Whether any entry of filters needs an intensity image.

Parameters:

filters – a canonical filter list, as normalise_filters() returns.

spacr.qt.mask_engine.invert_for_detection(image: numpy.ndarray, *, bounds=None) → numpy.ndarray[source]

Reflect an image about its OWN range, for a DETECTOR to read.

Masks are generated from the inverted image so a threshold written for bright objects can take dark ones. It is max + min - value on the field’s own extremes.

WHY THIS IS NOT invert_intensity(), which is the other inversion in this module and is one line away. The difference is not taste and it is not a duplicate that wants merging – the two are read by different things and only one of them can afford to move the numbers:

  • invert_intensity() complements the DTYPE and is what “Invert image” draws with. It has to be exactly reversible, because a curator leaves it on all day, and nothing downstream reads its result.

  • this one is read by a THRESHOLD, and the Otsu threshold correction is a MULTIPLIER on the level Otsu finds, applied to absolute intensity in _otsu_levels(). Multiplying is not invariant to an offset, so an inversion that moves the field’s span moves what the correction means. Measured on a 12-bit field (216..4095) with dark objects, inverted and put through _otsu_instances() on the bright side:

    correction

    dtype complement

    reflection about range

    0.8

    1 object, 100% of the field – everything

    47 objects, 20%

    1.0

    3 objects, 8%

    3 objects, 8%

    1.3

    0 objects, 0% – nothing

    3 objects, 8%

    The complement puts that field into 61440..65535, so a correction of 0.8 asks for a cut at about 49000, below every pixel there is, and 1.3 asks for one above all of them. The correction stops being a dial and becomes an on/off switch. At exactly 1.0 the two agree, which is why this is easy to miss.

The other two candidates were considered and are worse. The DTYPE’s maximum is the case above. The CONTRAST-STRETCHED view is a viewing choice, and a detector reading it would move when the percentiles moved, which is the argument filter_objects() already makes about intensity bounds.

Reflecting about the image’s own extremes keeps the span exactly, maps the darkest pixel onto the brightest and back, and is its own inverse on an image whose extremes it has not changed.

Nonfinite pixels take no part in finding the extremes and are returned unchanged, since a NaN is not dark and is not bright.

Parameters:
  • image – any 2-D field, as the canvas holds it.

  • bounds – (lo, hi) to reflect about, instead of image’s own extremes. WHAT A CROP IS GIVEN: a region inverted about its own extremes is inverted differently wherever the box is put, so the magnifier hands it the whole field’s pair and the box stays a preview of what the detect button will do with the same setting. (invert_intensity() needs no such thing, being a function of the pixel value alone – another way the two differ.)

Returns:

a NEW array of the input’s dtype. The original is never touched: the readout and the filter must keep reporting the raw values whatever the detector was shown.

spacr.qt.mask_engine.invert_intensity(image: numpy.ndarray) → numpy.ndarray[source]

Return the photographic complement of image: dark becomes bright.

Low intensity becomes high intensity and vice versa, fitted to the dtype. WHAT IS BUILT IS THE COMPLEMENT, dtype_max - value, NOT THE RECIPROCAL, for three reasons that are worth having written down because the reciprocal is the obvious first thought:

  • it is what every image viewer means by Invert, so the picture that comes back is the one a reader expects from Invert;

  • it is EXACTLY reversible on an integer field – inverting twice returns the identical array, which is what makes it safe to leave switched on while curating, and is asserted by comparing arrays;

  • 1/value divides by zero on every background pixel, and it squashes the bright end non-linearly, so two objects a thousand counts apart come back indistinguishable while the background explodes.

The reciprocal remains a reasonable SECOND mode for anyone who wants a log-like lift of the dim end; it is not this one.

WHICH RANGE IS COMPLEMENTED depends on the dtype, because fitting to the dtype only has a meaning where the dtype has ends:

unsigned integers

the dtype’s own range, so a uint16 field is 65535 - value. A 12-bit camera writing into uint16 therefore comes back in the top sixteenth of the range; a display that stretches by percentiles puts that back where a reader can see it, and the array is still exactly invertible, which a data-range complement would not be across two fields of different brightness.

signed integers

iinfo.min + iinfo.max - value, the same complement on the range the dtype actually spans.

bool

logical not.

floating point

the ARRAY’S OWN range, min + max - value, because a float image has no dtype maximum worth speaking of. The complement of a range maps its ends onto each other, so a second call computes the same two ends and comes back to the original – but NOT bit for bit: s - (s - x) rounds twice, and the round trip is out by up to one unit in the last place of s. Measured on a 200x200 field over 0..65535: 0.002 in float32 and 4e-12 in float64, against an interval of one count. The round trip is EXACT for every integer and boolean dtype, which is every dtype a field is read in.

Parameters:

image – any numeric or boolean array. It is not modified.

Returns:

a new array of the same shape and dtype.

spacr.qt.mask_engine.invert_mask(mask: numpy.ndarray) → numpy.ndarray[source]

Swap object and background in a LABEL image, and relabel.

NOT AN INTENSITY INVERSION – that is invert_intensity(), and the Make Masks screen’s “Invert image” is wired to that one. This flips the MASK: every labelled pixel becomes background and every background pixel becomes foreground, and what comes out is then labelled afresh. On an ordinary field the background is one connected region, so what comes back is a SINGLE field-sized object with holes where the objects were – which is why it looks as if it does not invert at all: one flat overlay over the whole frame reads as nothing having happened.

It is kept because it is a real thing to want – a curator who has outlined the space BETWEEN the cells has drawn the complement of what is wanted – but under the name that says what it does.

Parameters:

mask – label mask; every pixel above 0 counts as object. The result keeps its dtype.

spacr.qt.mask_engine.invert_normalized(image: numpy.ndarray) → numpy.ndarray[source]

Normalise image to 0..1 on its OWN range, take 1 - v, fit back.

THE ONE INVERSION. The field is normalised to 0..1 and every pixel becomes 1 - value, which gives an image that Otsu and the magnifier can work on when the objects are dark. That is the reason for the button.

So the purpose is a DETECTOR reading dark objects, and the picture the curator sees has to be the picture the detector reads – one switch, one meaning. That decision replaced the two inversions this module used to carry for Make Masks, invert_intensity() (the dtype complement, drawn but never detected on) and invert_for_detection() (detected on but never drawn). Both are kept for callers outside Make Masks and neither is what the screen uses now.

WHY NORMALISING FIRST IS THE POINT AND NOT A DETAIL. The Otsu threshold correction is a MULTIPLIER on an absolute level, so what it means depends on where the field’s intensities sit. A dtype complement moves a 12-bit field (216..4095) up into 61440..65535, and a correction of 0.8 then asks for a cut below every pixel present while 1.3 asks for one above them all – the dial becomes an on/off switch, which is the measurement recorded in invert_for_detection(). Normalising to the field’s own range first puts EVERY field on the same 0..1 span before the multiplier is applied, so one correction value means the same thing on the next image.

WHAT IS GIVEN UP, said plainly: this is not exactly reversible on the original numbers the way the dtype complement was. Inverting a field rescales it to the full range, and the original span cannot be recovered from the result. It does not need to be – the screen keeps the untouched array and re-derives this one, so nothing measured, filtered or saved ever sees it – but a caller that inverts an array and keeps only the result has lost where it sat.

THE RETURN DTYPE IS THE INPUT’S, because the display path hands the result to normalize_uint16(), which reads np.iinfo and raises on a float. An integer field comes back spanning that dtype’s full range; a float field comes back in 0..1, where a float already belongs.

A FLAT FIELD has no range to normalise onto. v - min is 0 everywhere, so the normalised value is taken as 0 and the inverse as 1: a flat field inverts to a flat bright one, which is the literal reading of the formula and is what an inversion of “no contrast” should look like.

Parameters:

image – the field, of any shape and any real dtype.

Returns:

a new array, same shape, same dtype, inverted.

spacr.qt.mask_engine.is_seg_bundle(filename) → bool[source]

Whether filename names a Cellpose _seg.npy bundle.

Parameters:

filename – a file name or path.

Returns:

True when it ends in SEG_SUFFIX, which is how a seg queue’s fields are named in the editor’s file list.

spacr.qt.mask_engine.legacy_filters(min_area=0, max_area=0, min_intensity=0.0, max_intensity=0.0) → List[dict][source]

The four old hard-coded bounds as entries of the filter list.

This is the migration: 0 meant off for each old bound, and it becomes a missing side here, so an old settings file or call is judged by the one filter engine with the answer it always had. Area becomes area and mean intensity becomes intensity_mean; equality was kept before and is kept now.

spacr.qt.mask_engine.list_images(folder: str) → List[str][source]

Return filenames of image files in folder, sorted, or [].

Parameters:

folder – directory to list (not recursively); an empty value or a missing directory gives []. Only names ending in IMAGE_EXTS, case-insensitively, are kept.

spacr.qt.mask_engine.load_image_and_mask(folder: str, filename: str, masks_dir: str | None = None) → Tuple[numpy.ndarray, numpy.ndarray][source]

Load an image and its accompanying mask.

  • Multi-channel images are collapsed to grayscale via BT.601 weights.

  • Missing masks are created as zeros of the image shape.

  • Images are returned as uint16; masks preserve uint8/uint16 label IDs.

  • A mask saved by save_mask() is found even when the source image had a non-TIFF extension. Its canonical TIFF takes precedence over an older mask under the source image’s extension.

  • A filename ending in SEG_SUFFIX is a Cellpose bundle and is read by load_seg_bundle() instead.

Parameters:
  • folder – the folder holding the image, or the bundle.

  • filename – the image, or the _seg.npy bundle, to load.

  • masks_dir – where the masks are, when not in <folder>/masks; see masks_folder().

Returns:

(image, mask).

Raises:

ValueError – for unsupported dimensions or an image/mask shape mismatch.

spacr.qt.mask_engine.load_seg_bundle(path: str) → Tuple[numpy.ndarray, numpy.ndarray][source]

Load a Cellpose bundle as the editor’s (image, mask) pair.

Parameters:

path – the _seg.npy bundle.

Returns:

(image, mask), checked and converted exactly as load_image_and_mask() converts a TIFF pair.

Raises:

ValueError – for a file that is not a bundle, a bundle with no image to show, or labels that do not fit the image.

spacr.qt.mask_engine.load_yolo_boxes(folder, filename, image_shape) → dict[source]

Load this source’s boxes, refusing stale bytes or changed image shape.

An unannotated source returns the existing project classes and no boxes. A project with no ledger starts with class object and no boxes.

Parameters:
  • folder – project folder holding the original image.

  • filename – source-image path relative to folder.

  • image_shape – shape of the displayed source image.

Returns:

classes, canonical boxes and the current source SHA-256.

spacr.qt.mask_engine.magic_wand(image: numpy.ndarray, mask: numpy.ndarray, seed_x: int, seed_y: int, tolerance: float, max_pixels: int = 100000, action: str = 'add') → numpy.ndarray[source]

BFS flood-fill from (seed_x, seed_y) filling pixels whose intensity is within tolerance (L2 distance) of the seed. Writes 255 (add) or 0 (erase) into the returned mask copy.

Parameters:
  • image – intensity image, 2-D or with channels on the last axis; distances are taken over the channel values.

  • mask – mask with the same height and width as image; it is copied, not modified.

  • seed_x – seed column in pixels.

  • seed_y – seed row in pixels. A seed outside the image returns mask unchanged.

  • tolerance – largest distance from the seed value that still fills, in the image’s own intensity units; see relative_tolerance().

spacr.qt.mask_engine.mask_save_path(folder: str, filename: str, masks_dir: str | None = None) → str[source]

Where this field’s mask is written – and where its ledger sits.

load_image_and_mask() will accept a mask under the image’s own extension, but everything save_mask() writes lands on <masks folder>/<stem>.tif. The ledger is keyed on the file that was actually written, so both have to agree on one name; ask here rather than rebuilding it at each call site.

Parameters:
  • folder – the folder the editor opened.

  • filename – the field’s image, or its _seg.npy bundle.

  • masks_dir – where the masks are, when not in <folder>/masks.

Returns:

the mask’s path; for a bundle, the bundle itself, which is where its labels are written back.

spacr.qt.mask_engine.masks_folder(folder: str, masks_dir: str | None = None) → str[source]

Where the masks of the images in folder are kept.

Parameters:
  • folder – the folder the editor opened.

  • masks_dir – the masks folder, when it is not beneath folder – the sibling layout’s <root>/masks beside <root>/images.

Returns:

masks_dir when given, else <folder>/masks.

spacr.qt.mask_engine.maxima_propagate_instances(image: numpy.ndarray, *, sigma: float = 2.0, min_distance: int = 10, seed_level: float = 90.0, seed_level_is_percentile: bool = True, exclude_border: bool = False, stop: str = 'seed_fraction', stop_value: float = 0.4, stop_algorithm: str = 'otsu', min_area: int = 0, fill_holes: bool = True) → PropagateResult[source]

Segment bright objects with local maxima and an intensity watershed.

Convert the field to float32, optionally blur it, and find centres with skimage.feature.peak_local_max(). Use those centres as markers for skimage.segmentation.watershed() on the negative blurred image. Distinct centres can split touching objects; noise can create extra centres, while smoothing or large centre spacing can remove real ones. No existing primary-object mask is accepted. This implementation has no CellProfiler propagation cost or distance/intensity weighting.

The four stop rules operate on the blurred values:

  • seed_fraction first partitions the entire image by watershed, then retains pixels at or above stop_value times their basin’s seed intensity. This is an intensity ratio, not a quantile. Adding a background offset changes the relative cut; equal measurements of bright and dim objects are not guaranteed. Trimming can leave disconnected pieces with the same label.

  • absolute restricts the watershed to pixels at or above stop_value, in the input’s intensity units.

  • percentile uses that percentile of all blurred input pixels as the common threshold. Changing the crop can change this level.

  • threshold obtains the common level from stop_algorithm and ignores stop_value.

Fill holes per label if requested, then discard labels smaller than min_area and renumber survivors. Hole filling can restore pixels below the selected intensity cut. The seed count is recorded before these operations and can exceed the number of surviving objects.

Input preparation is the caller’s responsibility: this function does not normalize intensities, subtract background, or invert dark objects. Use finite 2-D values; NaNs and infinities are not sanitized. Make Masks supplies the processed field or crop after its selected enhancements. Absolute levels and peak ratios therefore depend on that preparation.

Parameters:
  • image – nonempty 2-D intensity array, converted to float32 without range rescaling. Output coordinates and shape match this array.

  • sigma – Gaussian standard deviation in pixels; default 2.0. Positive values smooth both seed finding and growth; zero disables blur. Increasing it can suppress noise peaks or merge real peaks. Make Masks offers 0 to 50; the direct API also skips negative values.

  • min_distance – centre separation in pixels; default 10. Passed to peak finding as max(1, int(min_distance)), using its default Chebyshev distance. Increasing it suppresses nearby candidate seeds; reducing it can split an object into several detections. Make Masks offers 1 to 500.

  • seed_level – default 90.0. Candidate maxima must exceed this intensity, or the intensity at this percentile when seed_level_is_percentile is true. Percentiles must be between 0 and 100. Increasing the floor excludes dimmer candidate centres; it does not directly set the final object boundary.

  • seed_level_is_percentile – default true. Compute the seed floor from all blurred input pixels; false uses an absolute intensity. A crop and a whole field can yield different percentile floors.

  • exclude_border – default false. If true, exclude candidate centres within the effective min_distance of the input edge. This does not remove every object whose grown boundary touches the edge.

  • stop – default "seed_fraction"; one of PROPAGATE_STOPS, with the behavior described above.

  • stop_value – default 0.4. For seed_fraction, use a ratio from 0 to 1 with nonnegative intensities; increasing it removes dimmer basin pixels before hole filling. For absolute, use an intensity; for percentile, use 0 to 100. Higher common thresholds shrink the eligible mask. Ignored for threshold. The API does not clip ratios or absolute values; the GUI number box alone does not enforce rule-specific limits.

  • stop_algorithm – default "otsu"; a key of GLOBAL_THRESHOLDS, read only for stop="threshold". The threshold is estimated from the blurred field or crop.

  • min_area – default 0, disabling size removal. Labels with fewer than int(min_area) pixels after hole filling are discarded. Increasing it removes small labels without merging touching ones.

  • fill_holes – default true. Fill enclosed background pixels per label before size filtering. False preserves those holes.

Returns:

PropagateResult containing an int32 label array (0 is background, surviving labels are 1 through N), the original seed count, and the common stop level. The level is None for seed_fraction or when no seeds were found. A constant image or an overly high seed floor can return all-zero labels and zero seeds; an empty stop mask or size filtering can remove every seeded object.

Raises:

ValueError – for an unknown stop rule, an empty or non-2-D image, an out-of-range percentile when evaluated, or an unknown global threshold algorithm when that rule is evaluated.

For example, maxima_propagate_instances(image, sigma=2, min_distance=10, seed_level=90, stop="seed_fraction", stop_value=0.4, min_area=20) retains each basin above 40 percent of its seed intensity before filling holes and removing labels smaller than 20 pixels.

spacr.qt.mask_engine.next_label(mask: numpy.ndarray) → int[source]

The id to give the next object drawn on mask: one past its top.

Above the maximum rather than the lowest free id, because ids are what the ledger, the measurements and the crops name objects by. Handing a new object the id of one that was deleted makes two different cells share a name across a session, and nothing downstream can tell them apart afterwards.

Parameters:

mask – label mask; an empty array gives 1.

spacr.qt.mask_engine.normalise_filters(filters, *, strict: bool = True) → List[dict][source]

filters as the canonical list of {property, min, max} dicts.

Accepts the list itself, one dict, (property, min, max) tuples, or the list written as JSON or as a Python literal (a settings file stores it as text). Every entry is checked here, once, so a typo in a property name fails when the list is read rather than on the hundredth field.

Parameters:
  • filters – the list, one dict, tuples, or their JSON/literal text; None is no filters.

  • strict – also refuse a minimum above its maximum. The engine itself reads lists with strict=False, because a migrated legacy pair such as min_area=10, max_area=5 always meant “remove every object” and must go on meaning it.

Raises:

ValueError – for an unknown property, an unknown key, a bound that is not a finite number, or (strict) a minimum above its maximum.

spacr.qt.mask_engine.normalize_for_detection(image: numpy.ndarray, lower_pct: float = 1.0, upper_pct: float = 99.9) → numpy.ndarray[source]

image stretched between two percentiles, as Make Masks draws it.

normalize_uint16() for an integer field, so a detector reads the exact numbers the canvas paints; a float field has no integer range to fill and comes back on 0..1 instead.

Parameters:
  • image – the field.

  • lower_pct – the percentile mapped to the bottom of the range.

  • upper_pct – the percentile mapped to the top.

Returns:

the stretched field, the same shape.

spacr.qt.mask_engine.normalize_uint16(image: numpy.ndarray, lower_pct: float = 1.0, upper_pct: float = 99.9) → numpy.ndarray[source]

Return image clipped + rescaled to its dtype’s full range.

Parameters:

image – integer-typed image (any integer dtype, despite the name); clipped to its lower_pct and upper_pct percentiles. An empty array is returned as is.

spacr.qt.mask_engine.object_filter_area_floor(settings, object_type: str) → int[source]

The smallest area object_filters keeps for object_type.

Segmentation drops masks under this area as it makes them (Cellpose’s min_size), as the retired {object}_min_area did; the filter pass judges the rest. 0 when no area row sets a minimum.

Parameters:
  • settings – the Mask run’s settings.

  • object_type – cell, nucleus or pathogen.

spacr.qt.mask_engine.otsu_instances(image: numpy.ndarray, *, bright: bool = True, min_area: int = 0) → numpy.ndarray[source]

Threshold image at Otsu’s level and label what is left.

Parameters:
  • image – numeric intensity image. It is converted to float32 before the threshold is estimated and must contain at least one pixel.

  • bright – objects are brighter than background (fluorescence). False takes the dark side instead, for a brightfield or a stained-plaque image where the objects absorb.

  • min_area – passed to connected_instances().

Raises:

ValueError – on an empty image, which has no threshold to find.

spacr.qt.mask_engine.overlay_mask(image: numpy.ndarray, mask: numpy.ndarray, alpha: float = 0.5) → numpy.ndarray[source]

Blend a colorized label mask onto a grayscale image, uint8 RGB.

Parameters:
  • image – 2-D grayscale or RGB image in the 16-bit range; it is divided by 256 to reach 8 bits.

  • mask – label mask with the image’s height and width; each label gets a fixed pseudo-random colour and 0 stays unblended.

spacr.qt.mask_engine.paint_disk(mask: numpy.ndarray, cx: int, cy: int, radius: int, value: int = 255) → None[source]

In-place stamp a filled square (radius half-width) at (cx, cy).

Parameters:
  • mask – 2-D mask, modified in place; the square is clipped to it.

  • cx – centre column in pixels.

  • cy – centre row in pixels.

  • radius – half-width of the square in pixels; values below 1 are treated as 1.

spacr.qt.mask_engine.paint_line(mask: numpy.ndarray, x0: int, y0: int, x1: int, y1: int, radius: int, value: int = 255) → None[source]

In-place stamp a line of disks between two points (Bresenham).

Parameters:
  • mask – 2-D mask, modified in place.

  • x0 – start column in pixels.

  • y0 – start row in pixels.

  • x1 – end column in pixels.

  • y1 – end row in pixels.

  • radius – half-width of each stamp; see paint_disk().

spacr.qt.mask_engine.parse_object_filters(raw) → dict[source]

The object_filters setting as a dict of object type to rows.

A settings file stores the mapping as JSON or Python-literal text; the form and a script hand over the dict. None or blank text is an empty mapping. The rows are returned as given; settings_filters() checks them.

Parameters:

raw – the setting’s value.

Raises:

ValueError – when the value is not a mapping.

spacr.qt.mask_engine.primary_secondary_report(primary: numpy.ndarray, secondary: numpy.ndarray) → PrimarySecondaryReport[source]

Report shared, missing, orphaned and incompletely enclosed object IDs.

Parameters:
  • primary – nonempty 2-D nonnegative integer primary labels.

  • secondary – secondary labels of the same shape. Sparse and uint64 IDs are compared exactly, including values above signed int64.

Returns:

PrimarySecondaryReport; no array is modified. An ID match means the IDs agree, not that spatial overlap was used to infer or repair a parent assignment.

Raises:

ValueError – mismatched shapes or invalid label arrays.

spacr.qt.mask_engine.property_needs_intensity(name) → bool[source]

Whether the regionprop name measures pixel values.

Parameters:

name – a regionprop name, current or legacy spelling.

spacr.qt.mask_engine.read_curation(folder: str) → Dict[str, Dict[str, str]][source]

Every verdict recorded for folder, keyed by image path.

A file that is missing, empty or unreadable is NO VERDICTS rather than an error: this is a curation aid, and refusing to open a folder because its CSV was edited by hand would be the wrong trade.

Parameters:

folder – the folder the editor opened.

Returns:

image path -> the row, as strings.

spacr.qt.mask_engine.read_image(path: str) → numpy.ndarray[source]

Read one image file the way Make Masks shows it.

A Bio-Rad Image Lab .scn is read by spacr.convert.read_scn() (uint16, inverted so it looks like Image Lab’s own exports); every other format goes through imageio.v2.imread().

Parameters:

path – the image file.

Returns:

the pixels.

spacr.qt.mask_engine.read_recrop_manifest(folder: str) → List[dict][source]

Return recrop-archive records in chronological order.

Parameters:

folder – image folder whose recrop_archive_dir() holds the manifest; a missing, unreadable or non-list manifest gives [].

spacr.qt.mask_engine.read_seg_bundle(path: str) → Dict[source]

Read a Cellpose _seg.npy bundle as the dict it holds.

A bundle is a pickle, and unpickling runs whatever the file says, so a bundle is only ever read from a queue folder the curator chose – the same trust the external curation tool and Cellpose itself extend to it.

Parameters:

path – the bundle.

Returns:

the bundle’s dict, with every key it was written with.

Raises:

ValueError – when the file does not hold a dict with a masks entry.

spacr.qt.mask_engine.record_curation(folder: str, image_path: str, mask_path: str, object_count: int, keep: bool) → str[source]

Record one verdict, replacing any the field already had.

ONE ROW PER FIELD. Keep and then Discard on the same image leaves the later verdict and nothing else, because two rows that disagree are worse than no file – whoever reads it downstream would have to guess which press came last, and a CSV does not say.

Written to a dot-name in the same folder and renamed over the target, so a reader never sees half a file and a crash leaves the old one whole. The same shape as _write_bundle() above.

Parameters:
  • folder – the folder the editor opened.

  • image_path – the field being judged.

  • mask_path – its mask, from mask_save_path().

  • object_count – how many objects the mask holds right now.

  • keep – True for Keep, False for Discard.

Returns:

the CSV’s path.

spacr.qt.mask_engine.recrop_archive_dir(folder: str) → str[source]

Return the archive directory for recropped source fields.

Parameters:

folder – image folder; the archive is its RECROP_ARCHIVE_DIRNAME subfolder. Nothing is created.

spacr.qt.mask_engine.recrop_box(shape, p0, p1, existing=()) → Tuple[int, int, int, int][source]

Validate and clip a rectangular recrop selection.

Parameters:
  • shape – Image shape as (height, width). Coordinates outside the image are clipped to this extent.

  • p0 – First selection corner as (x, y).

  • p1 – Opposite selection corner as (x, y).

  • existing – Previously accepted boxes. A selection whose intersection over union exceeds RECROP_MAX_OVERLAP is rejected.

Returns:

Validated (x0, y0, x1, y1) coordinates.

Raises:

RecropRefused – If a side is shorter than RECROP_MIN_SIDE or the selection duplicates an existing box.

spacr.qt.mask_engine.recrop_child_name(folder: str, filename: str, ext: str = '.tif', masks_dir: str | None = None) → str[source]

Return the next unused <field>__rNN filename.

Names are checked against the image queue, mask directory, and recrop archive to prevent overwriting output from an earlier editing session.

Parameters:
  • folder – the folder the editor opened.

  • filename – the field being cut.

  • ext – the child image’s extension. Ignored for a bundle, whose child is a bundle too, <field>__rNN_seg.npy, because a TIFF written into a folder of bundles would make it two layouts at once.

  • masks_dir – where the masks are, when not in <folder>/masks.

Returns:

the child’s file name.

spacr.qt.mask_engine.relabel_objects(mask: numpy.ndarray) → numpy.ndarray[source]

Return a mask whose connected components are labeled 1..N.

Parameters:

mask – label or binary mask; every pixel above 0 is foreground and the result keeps its dtype.

spacr.qt.mask_engine.relative_tolerance(image: numpy.ndarray, percent: float) → float[source]

Magic-wand tolerance as percent of image’s intensity range.

An absolute tolerance is not a portable setting. The value that grabs one nucleus in an 8-bit field (range 0..255) selects nothing at all in a 16-bit one (range 0..65535), and the one tuned for 16-bit floods the entire 8-bit frame. A percentage of this image’s own range means one number behaves the same on both.

The floor of 1.0 keeps the wand usable on a flat field: a range of zero would otherwise give a tolerance of zero, and a tolerance of zero fills only pixels exactly equal to the seed.

Parameters:
  • image – intensity image whose max minus min sets the range; an empty array gives 1.0.

  • percent – share of that range, in percent (5 means 5 %).

spacr.qt.mask_engine.remove_small_objects(mask: numpy.ndarray, min_area: int) → numpy.ndarray[source]

Drop connected components with area < min_area (in pixels).

Parameters:
  • mask – label or binary mask; every pixel above 0 is foreground. The survivors are relabelled 1..N in the input’s dtype.

  • min_area – smallest component to keep, in pixels; 0 or less returns a copy unchanged.

spacr.qt.mask_engine.restore_recropped_original(folder: str, original: str) → List[str][source]

Restore an archived source field to the image queue.

Existing archived files listed for original are moved back to their source locations. Recropped child fields are not modified.

Parameters:
  • folder – Image-queue directory.

  • original – Original source filename recorded in the manifest.

Returns:

Paths restored to the queue.

spacr.qt.mask_engine.retire_recropped_original(folder: str, filename: str, *, children=(), boxes=(), masks_dir: str | None = None) → dict[source]

Archive a source field after recropped children have been created.

The source image, mask, and curation ledger are moved to <folder>/recropped_originals and recorded in RECROP_MANIFEST. This removes the multi-object source from the training queue without deleting it. A bundle goes with its ledger and with any display image of its stem beside it, as the external curation tool moved its .png.

Parameters:
  • folder – Image-queue directory.

  • filename – Source image filename.

  • children – Filenames created from the source field.

  • boxes – Crop boxes corresponding to children.

  • masks_dir – where the masks are, when not in <folder>/masks.

Returns:

Manifest record, including the original and archived paths.

spacr.qt.mask_engine.save_mask(folder: str, filename: str, mask: numpy.ndarray, log: spacr.curation.CurationLog | None = None, masks_dir: str | None = None, *, preserve_ids: bool = False) → str[source]

Write the mask to <folder>/masks/<stem>.tif and return that path.

Object ids are preserved – see canonical_labels() for what that costs and why the alternative is worse. A _seg.npy bundle is written back into itself by save_seg_bundle(), and masks_dir moves the TIFF to the sibling layout’s masks folder.

Parameters:
  • folder – field directory under which the masks directory is created.

  • filename – source image name; its extension is discarded and its stem becomes the TIFF mask name. A name ending in SEG_SUFFIX is the bundle to write into.

  • mask – label image to canonicalise and write. Existing multi-label object identifiers are retained where possible.

  • log – the session’s spacr.curation.CurationLog for this field. Given one holding at least one edit, it is written to <mask>.curation.json beside the mask, so spacr.curation.is_curated() reports the saved mask as hand-edited. The log is written whole, so it must have been seeded from CurationLog.read_beside() to keep earlier sessions’ entries – which is what the screen does on load. A log with no edits writes no sidecar: a session that opened the editor and painted nothing has not curated anything, and a ledger that exists for every mask ever opened answers no question.

  • masks_dir – where the masks are, when not in <folder>/masks.

  • preserve_ids – retain exact primary/secondary IDs without interpreting single-valued masks as binary or splitting disconnected pieces. The explicit mode validates nonnegative 2-D integer labels and refuses IDs above 65535 before writing; it never silently renumbers them.

Returns:

the path written.

spacr.qt.mask_engine.save_seg_bundle(path: str, mask: numpy.ndarray, *, preserve_ids: bool = False) → str[source]

Write edited labels back into the bundle they came from.

Every key the bundle already had is kept – img, flows, filename, source_image, diameter, and any other – and masks is replaced by canonical_labels() of mask. Two keys are DERIVED from the masks and would describe the old ones if left:

  • outlines is redrawn by seg_outlines();

  • ismanual, one flag per object, is resized to the new largest id: an id the bundle already flagged keeps its flag, and an id beyond the old list was drawn in this editor, so it is flagged manual.

Parameters:
  • path – the _seg.npy bundle.

  • mask – the edited labels.

  • preserve_ids – keep every supplied label exactly, including a lone ID or disconnected pieces with the same ID. Labels must fit uint16.

Returns:

path.

Raises:

ValueError – when path is not a bundle.

spacr.qt.mask_engine.save_yolo_boxes(folder, filename, image_shape, boxes, classes, expected_source_sha256=None) → pathlib.Path[source]

Atomically save one source-bound box record in the project’s ledger.

folder holds the untouched source image. filename is relative to that folder. Existing class IDs retain their meanings: the old class map must be a prefix of classes. The saved source SHA-256 and full image shape must agree when load_yolo_boxes() is next called. When an expected source digest is supplied, an external image change between loading and saving is refused before touching the ledger.

Parameters:
  • folder – project folder holding the original image.

  • filename – source-image path relative to folder.

  • image_shape – shape of the displayed source image.

  • boxes – (class_id, x0, y0, x1, y1) full-image pixel boxes.

  • classes – ordered names whose indexes are the class IDs.

  • expected_source_sha256 – digest received from a previous load, or None when the caller has no prior image snapshot.

Returns:

the private project-ledger path.

spacr.qt.mask_engine.secondary_object_instances(image: numpy.ndarray, primary: numpy.ndarray, *, sigma: float = 2.0, stop: str = 'threshold', stop_value: float = 0.4, stop_algorithm: str = 'otsu', min_area: int = 0, fill_holes: bool = True, growth: str = 'intensity') → SecondaryResult[source]

Grow secondary objects from labelled primaries with a seeded watershed.

Every positive primary label is a marker, including all its pixels. Intensity growth follows the negative, optionally Gaussian-smoothed image. Distance growth floods a flat surface from the primary pixels. Common stop thresholds constrain four-connected paths around excluded pixels; seed_fraction trims after growth. This is not unrestricted Euclidean nearest-primary assignment. Neither mode is CellProfiler’s distance/intensity Propagation algorithm.

The four rules in PROPAGATE_STOPS use processed intensities. absolute, percentile and threshold restrict growth with a common foreground mask. seed_fraction trims each watershed basin at a fraction of the brightest processed pixel inside its primary. That ratio is not a quantile and depends on background offset. With a dark nucleus in a cytoplasmic channel, use a common threshold instead of a nucleus-relative peak ratio.

Primary pixels are always included before minimum-area filtering, even below the threshold. Hole filling can also restore below-threshold pixels. Filtering can remove a whole secondary together with its seed; the missing ID is reported. Remaining labels retain their primary IDs exactly, without splitting or renumbering disconnected components. Sparse IDs use compact internal markers, never arrays sized by max ID.

Parameters:
  • image – finite nonempty 2-D intensities, converted to float32. The caller supplies normalization, background correction or inversion.

  • primary – same-shape nonnegative integer primary labels; 0 means background. The returned labels retain this dtype and these IDs.

  • sigma – finite nonnegative Gaussian sigma in pixels; 0 disables smoothing. Smoothing affects growth and threshold estimation.

  • stop – one of PROPAGATE_STOPS, default "threshold".

  • stop_value – intensity for absolute, percentile in [0,100] for percentile, or a fraction in [0,1] for seed_fraction. Ignored by threshold. Fractions require nonnegative processed intensities.

  • stop_algorithm – global threshold algorithm, default "otsu"; read only for threshold. The full supplied image determines it.

  • min_area – minimum secondary area after hole filling; 0 disables filtering. The whole primary footprint counts toward the area.

  • fill_holes – fill enclosed background pixels per label before filtering. Does not overwrite another primary’s labelled pixels.

  • growth – intensity (default) uses negative image intensity; distance floods a flat surface. Common stop rules constrain paths; the primary-relative fraction trims after growth. Both retain the same stop rules and exact primary IDs. Distance can help when bright structures attract an intensity basin across cells.

Returns:

SecondaryResult, including ID relationship diagnostics. Empty primary masks yield an empty result and no common stop level.

Raises:

ValueError – invalid images, labels, shape, sigma, stop rule, rule-specific stop value or negative minimum area.

spacr.qt.mask_engine.seg_outlines(labels: numpy.ndarray, like) → numpy.ndarray[source]

The outlines entry of a bundle, redrawn for labels.

A pixel is on an outline when it belongs to an object and one of its four neighbours does not belong to the same one. like is the entry the bundle had: an entry holding only 0 and 1 gets a 0/1 outline back, and one holding ids gets each outline pixel’s id, which is what Cellpose’s own masks_flows_to_seg writes. An entry of all zeros says nothing about its form and gets ids, Cellpose’s default.

Parameters:
  • labels – the labels being saved.

  • like – the bundle’s previous outlines, for its form and type.

Returns:

the new outlines, the shape of labels.

spacr.qt.mask_engine.settings_filters(settings, object_type: str) → List[dict][source]

The object_filters list a Mask run applies to object_type.

object_filters maps an object type (cell, nucleus, pathogen, organelle or an organelle slot) to its filter list, so each object type is filtered on its own properties. A missing or empty setting is no filters. The retired {object}_min_area family of a saved settings file is folded into this mapping when the file is loaded (spacr.settings._fold_object_bounds()).

Parameters:
  • settings – the Mask run’s settings; None is no filters.

  • object_type – the object type whose list is wanted.

Raises:

ValueError – when the setting is not a mapping, names an object type spaCR does not segment, or holds an invalid filter.

spacr.qt.mask_engine.shrink_objects(mask: numpy.ndarray, distance: int = 1) → numpy.ndarray[source]

Erode every object by distance pixels, each one on its own.

Each object is eroded against everything that is not itself – background AND the objects touching it – so two objects sharing a border both pull back from it and the seam between them widens. Eroding the foreground as one binary would instead leave that seam untouched, which is the opposite of what a curator reaching for Shrink wants.

AN OBJECT THINNER THAN TWICE THE DISTANCE DISAPPEARS. That is what erosion means, and the screen says how many went rather than letting them go quietly; the edit is one undo step, so the way back is one press.

The image border counts as background, matching scipy.ndimage.binary_erosion()’s own default, so an object the field cut off pulls back from the cut too.

Each object is eroded inside its own bounding box, so the cost follows the area of the objects rather than the area of the field – the same reason canonical_labels() works in boxes.

Parameters:
  • mask – label image; 0 is background.

  • distance – pixels to erode by. 0 or less returns a copy.

Returns:

a mask of the same dtype, holding the ids that survived.

spacr.qt.mask_engine.split_object_at(mask: numpy.ndarray, x: int, y: int, *, min_area: int = 0) → Tuple[numpy.ndarray, List[int]][source]

Cut the object under (x, y) where its halves meet; (mask, new_ids).

What Ctrl + left click does. The cut is a watershed on the object’s own distance to background, the same recipe _split_touching_objects() runs on a whole field: every local maximum of that distance is one half’s middle and the ridge between two of them is the waist where they meet.

Three decisions, and the first is the one to read:

  • AN OBJECT WITH ONE CENTRE IS LEFT ALONE and reported as such, rather than being halved through the click. A single click carries no direction, so a forced cut would have to invent one, and the object that needs cutting is almost always a pair that merged – which has two centres. The gesture for a cut the user aims themselves already exists and is the Divide tool (divide_object()).

  • The largest piece keeps the id and the others are given ids above the mask’s top label, which is canonical_labels()’ own rule, so splitting and then saving renumbers nothing.

  • NO PIXEL IS LOST. Every pixel of the object ends up under one of the new ids. min_area sets how far apart two centres must be to count as two (_split_touching_objects()’ seed spacing) and is NOT applied as a drop here: a hand edit moves pixels between ids, and a gesture that quietly erased the smaller half would be a delete wearing a split’s name.

Parameters:
  • mask – the label image.

  • x – column clicked, in image pixels.

  • y – row clicked, in image pixels.

  • min_area – the smallest object the screen is willing to keep, in pixels; it sets the seed spacing, so an object this size is not itself cut in two.

Returns:

(mask, new_ids) – a new mask and the ids the split created, or a copy and an empty list when the click was on background, outside the field, or on an object with one centre.

spacr.qt.mask_engine.threshold_algorithms() → Tuple[str, ...][source]

Every threshold algorithm name, global then local.

spacr.qt.mask_engine.write_recrop(folder: str, filename: str, image: numpy.ndarray, mask: numpy.ndarray, box, masks_dir: str | None = None) → Recrop[source]

Write a recropped field, mask, and curation record.

The image is stored as an unscaled uint16 TIFF beside the source images, and the relabelled mask is stored in <folder>/masks. The curation record distinguishes deliberately removed boundary objects from missed segmentation objects.

A field cut from a _seg.npy bundle becomes a bundle of its own, holding img, masks, empty flows and a filename naming the parent and the box – the form the external curation tool gave its recrops, so either tool can open the other’s.

Parameters:
  • folder – Image-queue directory.

  • filename – Source image filename.

  • image – Source microscopy image.

  • mask – Label image aligned with image.

  • box – Coordinates returned by recrop_box().

  • masks_dir – where the masks are, when not in <folder>/masks.

Returns:

Filename and retained-object count for the new field.

spacr.qt.mask_engine.yolo_box_lines(boxes, width: int, height: int) → list[str][source]

Encode full-image pixel boxes as standard YOLO class cx cy w h.

Corners may be reversed or outside the image. Every box is clipped and checked before any line is returned; overlapping boxes stay distinct. Coordinates use exclusive right and bottom edges and are normalised by the positive image width and height.

Parameters:
  • boxes – (class_id, x0, y0, x1, y1) full-image pixel boxes.

  • width – positive integer width of the original image.

  • height – positive integer height of the original image.

Returns:

one normalised YOLO label line per input box, in input order.

spacr.qt.mask_engine.FILTER_BOUNDS = ('min_area', 'max_area', 'min_intensity', 'max_intensity')[source]

The four legacy bounds, named as filter_report()’s keywords are.

They are no longer a second filter. legacy_filters() turns them into entries of the one filter list, so a caller that still passes min_area=20 is judged by the same regionprops pass as a user who added an area row, and a FilterRemoval still names the legacy bound an object failed so the older ledgers read the same.

spacr.qt.mask_engine.FILTER_KEYS = ('property', 'min', 'max')[source]

The keys of one filter entry, the whole of its serialised form.

A filter list is a plain list of {"property": name, "min": number or None, "max": number or None} dicts. It goes into the curation ledger, into a settings file and through JSON unchanged, which is what lets Make Masks and Mask generation be handed the same list and give the same answer.