diff --git a/docs/contract.md b/docs/contract.md index a461f70a..a42c5d6d 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -321,7 +321,10 @@ masks/ masks folder, outside the experiment folder The optional mask step reads every image's masks, one image at a time, and describes each mask type and every ordered pair of types over the whole experiment. Pieces are counted with -4-connectivity; label 0 is background. Every conclusion is a suggestion with its evidence: +4-connectivity; label 0 is background and every object has a positive label: a mask holding +a negative value (possible in a signed integer type, which the header check accepts) raises +an error when its pixels are read, and no report is written, so the background or a negative +value is never counted as an object. Every conclusion is a suggestion with its evidence: the user names the masks and confirms or corrects the relationships. | Kind of mask | Rule | @@ -384,5 +387,6 @@ The report is UTF-8 JSON (two-space indent, LF), written atomically to the outpu | `versions` | As in the manifest (§9); information only. | The step is the command `masks` and the functions `find_masks` and `scan_masks`; it reads -each image's masks once and holds only one image's masks in memory. A mask that is not 2D -when its pixels are read raises an error. +each image's masks once and holds only one image's masks in memory. A mask that cannot be read +as the planes its header gave, or holds negative labels, when its pixels are read raises an +error. diff --git a/docs/user_guide.md b/docs/user_guide.md index 4bb6ef55..7957a4f1 100644 --- a/docs/user_guide.md +++ b/docs/user_guide.md @@ -427,7 +427,9 @@ mask that segment the same objects are reported as such. These are suggestions f pixels, not decisions: you name each kind of mask and check the relationships in the bricks that use them. The report is written to `mask_relationships.json` in the output folder (§6). The command takes `--output`, `--exclude` and `--log` like the others, and exits with -status 1 when an error blocks it or when no masks are found. +status 1 when an error blocks it or when no masks are found. Label 0 is the background and +objects have positive labels: a mask with a negative value stops the command with a message +naming the mask, and no report is written. The rules behind each suggestion use thresholds you can change, for example `--inside-object-share 0.8` to call a kind of mask "inside" another when 80% of its objects @@ -654,7 +656,8 @@ write_mask_report(report, "/data/exp1_MorphoBricks_Preprocessing/mask_relationsh `scan_masks(..., thresholds={"inside_object_share": 0.8})` changes a threshold (the others keep their defaults, `DEFAULT_THRESHOLDS`). `preflight(..., ome_zarr_labels=True)` uses the -label images of OME-Zarr images without a masks folder; `read_mask` reads one mask's pixels; +label images of OME-Zarr images without a masks folder; `read_mask` reads one mask's pixels +(a `ValueError` for negative labels, as in `piece_counts`, `pair_counts` and `scan_masks`); `pair_counts` and `piece_counts` give the counts behind the report for one pair of masks or one mask. diff --git a/src/morphobricks_preprocessing/cli.py b/src/morphobricks_preprocessing/cli.py index aec77490..7316e92c 100644 --- a/src/morphobricks_preprocessing/cli.py +++ b/src/morphobricks_preprocessing/cli.py @@ -403,7 +403,11 @@ def progress(done: int, total: int, path: str) -> None: print(f"[{done + 1}/{total}] {path}", flush=True) print() - report = scan_masks(pre.masks, pre.discovery.root, progress=progress, thresholds=thresholds) + try: + report = scan_masks(pre.masks, pre.discovery.root, progress=progress, thresholds=thresholds) + except ValueError as exc: # masks whose pixels cannot be used (planes, negative labels) + print(f"The masks cannot be used: {exc}") + return 1 output = Path(args.output) if args.output else default_output_folder(pre.discovery.root) path = write_mask_report(report, output / MASK_REPORT_FILENAME) print() diff --git a/src/morphobricks_preprocessing/mask_scan.py b/src/morphobricks_preprocessing/mask_scan.py index 1a286839..d21a8d26 100644 --- a/src/morphobricks_preprocessing/mask_scan.py +++ b/src/morphobricks_preprocessing/mask_scan.py @@ -54,7 +54,7 @@ from scipy import ndimage from .manifest import package_versions -from .masks import MaskInventory, read_mask +from .masks import MaskInventory, check_labels, read_mask logger = logging.getLogger(__name__) @@ -247,15 +247,22 @@ def piece_counts(mask: np.ndarray) -> tuple[np.ndarray, np.ndarray]: """Return the labels of a 2D mask and the number of 4-connected pieces of each. Two pixels are in one piece when they carry the same label and touch along an edge. + Label 0 is the background. Returns ------- tuple of numpy.ndarray The sorted nonzero labels, and the number of pieces of each. + + Raises + ------ + ValueError + If the mask holds negative labels (:func:`.masks.check_labels`). """ + check_labels(mask) values, dense = np.unique(mask, return_inverse=True) dense = dense.reshape(mask.shape) - if values.size and values[0] == 0: + if values.size and values[0] == 0: # labels are never negative, so 0 comes first values = values[1:] else: dense = dense + 1 # no background: every value is a label @@ -273,8 +280,15 @@ def pair_counts( Both are 2D label images of one position; label 0 is background. ``thresholds`` changes the default ``inside_pixel_share`` and ``same_objects_iou`` (:func:`mask_thresholds`). + + Raises + ------ + ValueError + If either mask holds negative labels (:func:`.masks.check_labels`). """ limits = mask_thresholds(thresholds) + check_labels(child, "the child mask") + check_labels(parent, "the parent mask") counts = dict.fromkeys(_COUNTS, 0) on = child != 0 child_labels, child_of = np.unique(child[on], return_inverse=True) @@ -349,7 +363,7 @@ def scan_masks( ValueError For an invalid threshold, before any mask is read; for an image whose masks have different depth slices or time points; for a mask that cannot be read as the planes - its header gave (:func:`.masks.read_mask`). + its header gave, or holds negative labels (:func:`.masks.read_mask`). """ limits = mask_thresholds(thresholds) root = Path(experiment_root).resolve() diff --git a/src/morphobricks_preprocessing/masks.py b/src/morphobricks_preprocessing/masks.py index c87be4fb..fbb6e262 100644 --- a/src/morphobricks_preprocessing/masks.py +++ b/src/morphobricks_preprocessing/masks.py @@ -233,7 +233,8 @@ def read_mask( ------ ValueError If the mask's dimensions are not the ones its header gave (``mask.axes``), or ``z`` - or ``t`` is missing or outside the mask. + or ``t`` is missing or outside the mask, or the plane holds negative labels + (label 0 is the background and objects are positive, contract §15). """ wanted = {"Z": z, "T": t} for axis, index in wanted.items(): @@ -258,6 +259,7 @@ def read_mask( axes = "".join(a for a, s in zip(labels.axes, selection, strict=True) if s == slice(None)) if axes != "YX" or (mask.axes and pixels.shape != tuple(_yx(mask.axes, mask.shape))): raise ValueError(f"the mask {mask.path} is not the 2D plane its header gave ({axes})") + check_labels(pixels, mask.path) return pixels @@ -298,6 +300,25 @@ def _yx(axes: str, shape: tuple[int, ...]) -> tuple[int, int]: return shape[axes.index("Y")], shape[axes.index("X")] +def check_labels(pixels: np.ndarray, name: str = "the mask") -> None: + """Refuse a mask holding negative labels. + + Label 0 is the background and every object has a positive label (contract §15). A + negative value is neither, so it is refused rather than read as an object (which would + also make the background one). + + Raises + ------ + ValueError + If any pixel is below 0. + """ + if pixels.dtype.kind == "i" and pixels.size and pixels.min() < 0: + raise ValueError( + f"{name} holds negative labels (smallest {pixels.min()}); label 0 is the " + "background and objects must have positive labels." + ) + + def _folder_issues(folder: Path, experiment_root: Path) -> list[ValidationIssue]: if not folder.is_dir(): return [ diff --git a/tests/test_mask_scan.py b/tests/test_mask_scan.py index 3c206f86..9a0e7bd7 100644 --- a/tests/test_mask_scan.py +++ b/tests/test_mask_scan.py @@ -350,3 +350,41 @@ def test_masks_with_time_points_are_scanned_plane_by_plane(tmp_path): assert [(p.child, p.parent, dict(p.evidence)) for p in report.pairs] == [ (p.child, p.parent, dict(p.evidence)) for p in flat.pairs ] + + +# Negative labels (issue #35) ---------------------------------------------------------- + + +def test_negative_labels_are_refused_never_read_as_objects(tmp_path): + signed = np.zeros(SHAPE, dtype=np.int16) + signed[2:8, 2:8] = -1 + signed[20:30, 20:30] = 3 + with pytest.raises(ValueError, match="negative labels"): + piece_counts(signed) + with pytest.raises(ValueError, match="negative labels"): + pair_counts(signed, boxes(CELLS)) + with pytest.raises(ValueError, match="negative labels"): + pair_counts(boxes(NUCLEI), signed) + # Signed masks without negative labels keep 0 as the background. + labels, _ = piece_counts(np.abs(signed)) + assert labels.tolist() == [1, 3] + root, folder = write_experiment(tmp_path, {"Cells": boxes(CELLS), "Signed": signed}) + inventory = preflight(root, masks_folder=folder).masks + assert inventory.ok # headers only: an int16 mask is fine + with pytest.raises(ValueError, match="Signed/ctrl/a.tif holds negative labels"): + scan_masks(inventory, root) + + +def test_negative_labels_in_a_later_time_point_are_refused(tmp_path): + # #36 reads masks plane by plane; #35's check must cover every plane, not only the first. + frames = hierarchy_masks() + signed = np.stack([frames["Cells"].astype(np.int16)] * 3) + signed[2, 0, 0] = -1 + root, folder = tmp_path / "exp", tmp_path / "masks" + write_data(root / "ctrl" / "a.tif", np.zeros((3, *SHAPE), dtype=np.uint8), "TYX") + write_data(folder / "Cells" / "ctrl" / "a.tif", signed, "TYX") + write_data(folder / "Nuclei" / "ctrl" / "a.tif", np.stack([frames["Nuclei"]] * 3), "TYX") + inventory = preflight(root, masks_folder=folder).masks + assert inventory.ok, inventory.issues + with pytest.raises(ValueError, match="Cells/ctrl/a.tif holds negative labels"): + scan_masks(inventory, root) diff --git a/tests/test_masks.py b/tests/test_masks.py index 8ff44f7c..76bbc816 100644 --- a/tests/test_masks.py +++ b/tests/test_masks.py @@ -435,3 +435,13 @@ def test_label_images_with_time_points(tmp_path): ome_zarr_labels=True, ) assert result.written, result.issues + + +def test_masks_command_refuses_negative_labels(experiment, masks, tmp_path, capsys): + signed = label_image(0).astype(np.int32) + signed[0, 0] = -5 + write_data(masks / "Cells" / "ctrl" / "rep1" / "a.tif", signed) + out = tmp_path / "out" + assert main(["masks", str(experiment), "--masks", str(masks), "--output", str(out)]) == 1 + assert "Cells/ctrl/rep1/a.tif holds negative labels (smallest -5)" in capsys.readouterr().out + assert not (out / "mask_relationships.json").exists()