Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions docs/contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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.
7 changes: 5 additions & 2 deletions docs/user_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
6 changes: 5 additions & 1 deletion src/morphobricks_preprocessing/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
20 changes: 17 additions & 3 deletions src/morphobricks_preprocessing/mask_scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
23 changes: 22 additions & 1 deletion src/morphobricks_preprocessing/masks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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


Expand Down Expand Up @@ -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 [
Expand Down
38 changes: 38 additions & 0 deletions tests/test_mask_scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
10 changes: 10 additions & 0 deletions tests/test_masks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading