From be491917243080550354a01cae9628b5eb64aafe Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 20:09:18 +0000 Subject: [PATCH] Report an unreadable OME-Zarr labels group as unreadable_mask find_masks caught the NgffError from reading an OME-Zarr image's labels group, logged it at info level and went on. When every image of a mask type was affected, the type vanished from the inventory and mask_missing never fired, so those objects were silently not measured. The group is now the error unreadable_mask with the path /labels, which stops check, run and the masks command. Without the zarr extra label images are still skipped, as documented. Fixes #38 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Scma6W3LC11s3qjXHZuGDm --- docs/contract.md | 2 +- docs/user_guide.md | 2 +- src/morphobricks_preprocessing/masks.py | 45 +++++++++++++++++++------ tests/test_masks.py | 26 ++++++++++++++ 4 files changed, 62 insertions(+), 13 deletions(-) diff --git a/docs/contract.md b/docs/contract.md index f90fe6e7..8ed5d172 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -308,7 +308,7 @@ masks/ masks folder, outside the experiment folder | Layout | Either one subfolder per mask type, each mirroring the experiment's folders (`//.../.tif`), or the experiment's folders with the type after the image name (`/.../_.tif`, as segmentation tools often write it, e.g. MorphoRelate's JUMP-Target-2 conversion); the two may be mixed. Any number of mask types. The label images of an OME-Zarr image (its OME-NGFF `labels` group, with the `zarr` extra) are masks too, of the type named by the label image; they are used whenever masks are asked for, with or without a masks folder. | | Pairing | A mask belongs to the image in the same leaf folder with the same `position_id` (§1), ignoring upper/lower case; its file is a TIFF (`.tif`, `.tiff`, optionally `.ome`, any case). Excluded images have no masks. | | Names | A mask type is named by its folder, by the suffix after the image name, or by its label image. A file that could belong to two images (an image name that starts another's, `a_b_cells.tif` beside images `a` and `a_b`) or fit both layouts is the error `ambiguous_mask`. Two names differing only in case are the error `mask_names_differ_in_case`; one image with two masks of one type (two extensions, or a file and a label image) is the error `duplicate_mask`. | -| Header checks | From the headers only, no pixels read (`check`, `run`, `preflight`): a mask is 2D once its length-1 axes are removed (`mask_not_2d`), has the Y and X sizes of its image (`mask_size_mismatch`), and integer or boolean pixels (`mask_pixel_type`); a mask that cannot be opened, or holds several series, is `unreadable_mask`. All four are errors. | +| Header checks | From the headers only, no pixels read (`check`, `run`, `preflight`): a mask is 2D once its length-1 axes are removed (`mask_not_2d`), has the Y and X sizes of its image (`mask_size_mismatch`), and integer or boolean pixels (`mask_pixel_type`); a mask that cannot be opened, or holds several series, is `unreadable_mask`, as is an OME-Zarr image's `labels` group that cannot be read or does not list its label images (path `/labels`), so its mask types never disappear unreported. All four are errors. | | Folder | The masks folder must exist (`masks_folder_not_found`) and be outside the experiment folder (`masks_inside_experiment`), where its folders would be read as labels. Both errors. | | Warnings | `mask_missing` (images without a mask of a type that other images have: their objects of that type cannot be measured), `mask_without_image` (files matching no analysed image), `mask_outside_type_folder` (files directly in the masks folder that are not TIFF masks). | | Run | `run_experiment(..., masks_folder=...)` stops on a mask error and records the masks in the manifest's `masks` key (§9). Without masks, the run and its outputs are unchanged. | diff --git a/docs/user_guide.md b/docs/user_guide.md index 10ff4bb3..1c6d989a 100644 --- a/docs/user_guide.md +++ b/docs/user_guide.md @@ -564,7 +564,7 @@ continues. | `masks_inside_experiment` | Error | The masks folder is inside the experiment folder, where its folders would be read as conditions and repeats. Move it next to the experiment folder. | | `mask_names_differ_in_case` | Error | Two kinds of mask have names that differ only in upper/lower case. Rename one. | | `duplicate_mask` | Error | An image has two masks of one kind (two extensions, or a file and an OME-Zarr label image of the same name). Keep one. | -| `unreadable_mask` | Error | A mask cannot be opened, or holds several images. Save it again as a TIFF label image. | +| `unreadable_mask` | Error | A mask cannot be opened, or holds several images, or the `labels` group of an OME-Zarr image cannot be read (it must list its label images). Save the mask again as a TIFF label image, or repair the OME-Zarr labels. | | `mask_not_2d` | Error | A mask has more than one plane. Save one 2D label image per position. | | `mask_size_mismatch` | Error | A mask does not have the width and height of its image. Check that it was made from that image at full resolution. | | `mask_pixel_type` | Error | A mask's pixels are not whole numbers (for example decimals). Save it as an integer label image. | diff --git a/src/morphobricks_preprocessing/masks.py b/src/morphobricks_preprocessing/masks.py index 2b45abb7..ef4ba16b 100644 --- a/src/morphobricks_preprocessing/masks.py +++ b/src/morphobricks_preprocessing/masks.py @@ -162,12 +162,14 @@ def find_masks( found, extra = _folder_masks(folder, discovery, images) files.extend(found) issues.extend(extra) + unreadable: list[str] = [] if ome_zarr_labels: - files.extend(_label_masks(discovery.root, images)) + found, unreadable = _label_masks(discovery.root, images) + files.extend(found) issues.extend(_name_issues(files)) files.sort(key=lambda f: (f.image, f.mask_type)) issues.extend(_duplicate_issues(files)) - issues.extend(_header_issues(files, images, folder, discovery.root)) + issues.extend(_header_issues(files, images, folder, discovery.root, unreadable)) types = tuple(sorted({f.mask_type for f in files})) issues.extend(_missing_issues(types, files, images)) return MaskInventory( @@ -310,19 +312,30 @@ def _layout_issues(loose: list[str], unpaired: list[str]) -> list[ValidationIssu return issues -def _label_masks(root: Path, images: Mapping[str, TiffMetadata]) -> list[MaskFile]: - """Return the label images of every OME-Zarr image, as masks.""" - files = [] +def _label_masks( + root: Path, images: Mapping[str, TiffMetadata] +) -> tuple[list[MaskFile], list[str]]: + """Return the label images of every OME-Zarr image, as masks. + + Also return the ``/labels`` groups that could not be read: their masks would + otherwise vanish without a word (and with them the ``mask_missing`` warning), so they + are reported as ``unreadable_mask``. + """ + files, unreadable = [], [] for image, meta in images.items(): if meta.kind != "ome-zarr" or not meta.readable: continue try: names = ngff_label_names(root / image) - except (ZarrUnavailableError, NgffError) as exc: + except ZarrUnavailableError as exc: # documented: label images need the zarr extra logger.info("No label images read from %s: %s", image, exc) continue + except NgffError as exc: + logger.info("The label images of %s could not be read: %s", image, exc) + unreadable.append(f"{image}/labels") + continue files.extend(MaskFile(image, name, f"{image}/labels/{name}", OME_ZARR) for name in names) - return files + return files, unreadable def _name_issues(files: list[MaskFile]) -> list[ValidationIssue]: @@ -366,10 +379,19 @@ def _duplicate_issues(files: list[MaskFile]) -> list[ValidationIssue]: def _header_issues( - files: list[MaskFile], images: Mapping[str, TiffMetadata], folder: Path | None, root: Path + files: list[MaskFile], + images: Mapping[str, TiffMetadata], + folder: Path | None, + root: Path, + unreadable: Iterable[str] = (), ) -> list[ValidationIssue]: - """Read each mask's header (shape, pixel type) and check it against its image.""" + """Read each mask's header (shape, pixel type) and check it against its image. + + ``unreadable`` are paths already known not to open (OME-Zarr ``labels`` groups). + """ problems: dict[str, list[str]] = {} + if unreadable: + problems["unreadable_mask"] = list(unreadable) for index, mask in enumerate(files): try: shape, dtype = _mask_header(mask, folder, root) @@ -399,8 +421,9 @@ def _header_issues( _TEXTS = { "unreadable_mask": ( "Some masks could not be opened.", - "Check that they are TIFF files (or OME-Zarr label images) with one image each and " - "not damaged; the technical details are in the log.", + "Check that they are TIFF files (or OME-Zarr label images, listed in the image's " + "labels group) with one image each and not damaged; the technical details are in the " + "log.", ), "mask_not_2d": ( "Some masks are not 2D: a mask must be one label image per position.", diff --git a/tests/test_masks.py b/tests/test_masks.py index 508ea129..f0521b24 100644 --- a/tests/test_masks.py +++ b/tests/test_masks.py @@ -304,3 +304,29 @@ 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_a_malformed_labels_group_is_an_error_not_a_vanished_mask_type(tmp_path): + zarr = pytest.importorskip("zarr") + from zarr_tree import write_labels, write_ngff + + paths = [] + for rel in ("ctrl/a.ome.zarr", "drug/a.ome.zarr"): + path = write_ngff(tmp_path / "exp" / rel, np.zeros((16, 20), np.uint8), "yx") + write_labels(path, {"cells": label_image(0)}, "yx") + paths.append(path) + # Both images' labels groups no longer list their label images: every cells mask is lost. + for path in paths: + group = zarr.open_group(str(path / "labels"), mode="a") + group.attrs.update({"labels": "cells"}) + found = preflight(tmp_path / "exp", ome_zarr_labels=True).masks + assert not found.ok + assert codes(found.issues) == ["unreadable_mask"] + assert found.issues[0].paths == ("ctrl/a.ome.zarr/labels", "drug/a.ome.zarr/labels") + result = run_experiment( + tmp_path / "exp", + prefill_mapping("YX"), + output_folder=tmp_path / "out", + ome_zarr_labels=True, + ) + assert not result.written and "unreadable_mask" in codes(result.issues)