From 3e61c2219de7a6121d0d26cd674ac06785eb1f12 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 20:06:50 +0000 Subject: [PATCH] Refuse negative mask labels instead of counting the background as an object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A signed mask holding a negative value made piece_counts keep 0 as a label (0 was dropped only when it was the smallest value), so the background became an object and the mask kinds, counts and mask_relationships.json were wrong with no warning. Label 0 is the background and objects are positive (contract §15): read_mask, piece_counts and pair_counts now raise ValueError for negative labels, and the masks command prints the message and exits 1 without writing a report. Fixes #35 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Scma6W3LC11s3qjXHZuGDm --- docs/contract.md | 9 +++++--- docs/user_guide.md | 7 +++++-- src/morphobricks_preprocessing/cli.py | 6 +++++- src/morphobricks_preprocessing/mask_scan.py | 21 ++++++++++++++++--- src/morphobricks_preprocessing/masks.py | 23 ++++++++++++++++++++- tests/test_mask_scan.py | 23 +++++++++++++++++++++ tests/test_masks.py | 10 +++++++++ 7 files changed, 89 insertions(+), 10 deletions(-) diff --git a/docs/contract.md b/docs/contract.md index f90fe6e7..e696e23c 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -317,7 +317,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 | @@ -375,5 +378,5 @@ 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 is not 2D, 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 10ff4bb3..fc389d63 100644 --- a/docs/user_guide.md +++ b/docs/user_guide.md @@ -423,7 +423,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 @@ -647,7 +649,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..5775f815 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: # a mask whose pixels cannot be used (not 2D, 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 3a64d55f..58bae70d 100644 --- a/src/morphobricks_preprocessing/mask_scan.py +++ b/src/morphobricks_preprocessing/mask_scan.py @@ -50,7 +50,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__) @@ -242,15 +242,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 @@ -268,8 +275,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) @@ -342,7 +356,8 @@ def scan_masks( Raises ------ ValueError - For an invalid threshold, before any mask is read. + For an invalid threshold, before any mask is read; for a mask that is not 2D 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 2b45abb7..937c3f7f 100644 --- a/src/morphobricks_preprocessing/masks.py +++ b/src/morphobricks_preprocessing/masks.py @@ -184,7 +184,8 @@ def read_mask(mask: MaskFile, masks_folder: Path | None, experiment_root: Path) Raises ------ ValueError - If the mask is not 2D once its length-1 axes are removed. + If the mask is not 2D once its length-1 axes are removed, or holds negative labels + (label 0 is the background and objects are positive, contract §15). """ if mask.source == FOLDER: pixels = tifffile.imread(Path(masks_folder) / mask.path) @@ -195,9 +196,29 @@ def read_mask(mask: MaskFile, masks_folder: Path | None, experiment_root: Path) pixels = np.squeeze(pixels) if pixels.ndim != 2: raise ValueError(f"the mask {mask.path} is not 2D ({pixels.shape})") + check_labels(pixels, mask.path) return pixels +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 06d0a8ef..3b86ee31 100644 --- a/tests/test_mask_scan.py +++ b/tests/test_mask_scan.py @@ -317,3 +317,26 @@ def test_both_layouts_give_the_same_report(tmp_path): by_suffix = scan_masks(inventory, root) assert by_suffix.to_dict()["pairs"] == by_folder.to_dict()["pairs"] assert dict(by_suffix.hierarchy) == dict(by_folder.hierarchy) + + +# 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) diff --git a/tests/test_masks.py b/tests/test_masks.py index 508ea129..ff91c95d 100644 --- a/tests/test_masks.py +++ b/tests/test_masks.py @@ -304,3 +304,13 @@ def test_masks_command_takes_thresholds(experiment, masks, tmp_path, capsys): assert report["thresholds"]["same_objects_iou"] == 0.8 assert main([*args, "--grouped-label-share", "1.5"]) == 1 assert "must be above 0 and at most 1" in capsys.readouterr().out + + +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()