From e1c1f83bb769078fc018999c34cdc47ffe2105ae Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 12:34:01 +0000 Subject: [PATCH 1/4] Declared axes: leave a plain stack's positional Y and X open tifffile reports the last two axes of any plain page as Y and X (QYX, QQYX, IYX) even when the file has no axis metadata. check_declared_axes treated them as named, so it refused a correct declaration such as YXC on a plain QYX stack (a (Y, X, C) array written without metadata). Only what the metadata names now binds a declaration. New named_axes gives the rule position by position (? for open), and both header_axes and check_declared_axes use it. TiffMetadata.axes_from_metadata records whether the axes came from axis metadata (OME, ImageJ, shaped with axes, other vendor formats, OME-NGFF). Declarations the header contradicts (ImageJ ZYX as CYX, OME or shaped CYX as YXC) are still refused. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VbytvBRB9CmKQ5AAkhEFcv --- AGENTS.md | 2 +- docs/contract.md | 4 +- docs/plan.md | 8 ++ src/morphobricks_preprocessing/__init__.py | 4 + src/morphobricks_preprocessing/axes.py | 33 +++--- src/morphobricks_preprocessing/metadata.py | 72 +++++++++++- tests/test_header_axes.py | 126 ++++++++++++++++++++- tests/test_metadata.py | 1 + 8 files changed, 225 insertions(+), 25 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index de65ada7..fc651204 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,7 +67,7 @@ src/morphobricks_preprocessing/ metadata.py TIFF header inspection (tifffile, no pixel reads), dtype/dimension/series checks, OME channel names + significant bits, physical sizes (TiffMetadata.physical_sizes); header consistency warnings (width/height, plane counts, pixel sizes); - header_axes (the axes the metadata names, contract §16) + header_axes and named_axes (the axes the metadata names, contract §16) physical_sizes.py PhysicalSizes (pixel sizes, z-step, time step, a unit per axis, micrometers() for comparing), space_unit/to_micrometers (the shared unit rule, contract §16), read_physical_sizes (OME / ImageJ TIFF metadata; also importable diff --git a/docs/contract.md b/docs/contract.md index f200358a..6872a41a 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -406,8 +406,8 @@ instead of keeping copies. | Length units (D-a2) | `space_unit(text)`, `to_micrometers(value, unit)`, `SPACE_UNITS`, `UnsupportedUnitError` (`physical_sizes`) | Microscopy units only: `pm`; `Å`/`angstrom`; `nm`; `µm`/`um`/`micron(s)`/`micrometer(s)`/`micrometre(s)`; `mm`; `cm`; `m`; with each name in US and British spelling and in the plural (`nanometre`, `millimeters`, ...). Symbols of two letters or fewer are matched exactly (`Mm` is not `mm`), longer names in any case; ImageJ's `\u00B5` is decoded. `space_unit` returns the OME-NGFF name (`"micrometer"`), None when no unit is given, and refuses any other unit by name (`UnsupportedUnitError`); `to_micrometers` converts a positive finite length, None when the length or its unit is not known. This brick's readers (§2) use the same table: since D86, ImageJ and OME-NGFF sizes in British spellings (`micrometre`) are calibrated, where they were unknown before. | | Plain-TIFF resolution (D-c) | `read_physical_sizes` | A TIFF's resolution tags give a pixel size only with OME or ImageJ metadata and a unit; a plain TIFF (scanner or image-editor dpi) is uncalibrated, never converted from inches or centimetres. | | Labels (D-b) | `check_labels(pixels, name)`, `LARGEST_LABEL` (`masks`) | A mask holds whole-number labels from 0 (background) to `LARGEST_LABEL` = 2,147,483,647: MorphoBricks labels are int32 in every brick. A negative label, a label above the limit, or a pixel type that is not integer or boolean is refused with a `ValueError` naming the mask and the value. `read_mask`, `piece_counts`, `pair_counts` and `scan_masks` call it. Since D86 the limit is int32; before, this brick refused only labels above 2^63 − 1 (int64), so a `uint32` or wider mask with a label of 3,000,000,000 is now refused. | -| Header axes (D-d) | `header_axes(meta)`, `PLACEHOLDER_AXES` (`metadata`) | The axes an image's own metadata names, as `tifffile` reads them with length-1 axes left out: OME, ImageJ and `tifffile` "shaped" metadata (`{"axes": "CYX"}`, decision D-d), a plain 2D `YX` (or `YXS` RGB) TIFF, and an OME-Zarr image's OME-NGFF axes. None when an axis is a placeholder (`I`: a plain stack of pages; `Q`: shaped without axes, or an OME-NGFF axis of another type), or the image does not open: the file does not say which dimension is depth, time or channel. | -| Declared axes | `check_declared_axes(meta, declared)` (`axes`) | Axes a brick or user declares for an image or mask (`image_axes="CYX"`) may name what the header leaves open, never contradict it: one letter per header axis (any case), roles X, Y, Z, C, T each at most once; an axis the header names keeps its letter; a placeholder axis may be declared Z, C or T (never X or Y); the RGB samples `S` may be declared C when the header has no C; any other header axis (tile `R`, ...) cannot be declared. Otherwise the error `declared_axes_mismatch` naming the image and both axes (`unreadable_tiff` or `unreadable_ome_zarr` when the header does not open). | +| Header axes (D-d) | `header_axes(meta)`, `named_axes(meta)`, `PLACEHOLDER_AXES`, `OPEN_AXIS` (`metadata`) | The axes an image's own metadata names, as `tifffile` reads them with length-1 axes left out: OME, ImageJ and `tifffile` "shaped" metadata (`{"axes": "CYX"}`, decision D-d), a plain 2D `YX` (or `YXS` RGB) TIFF, and an OME-Zarr image's OME-NGFF axes. None when an axis is a placeholder (`I`: a plain stack of pages; `Q`: shaped without axes, or an OME-NGFF axis of another type), or the image does not open: the file does not say which dimension is depth, time or channel. `named_axes` gives the rule position by position, with `?` (`OPEN_AXIS`) for a dimension the metadata does not name: a placeholder, and, in a plain stack of pages without axis metadata (`IYX`, `QYX`, `QQYX`; `TiffMetadata.axes_from_metadata` False), the `Y` and `X` too, which only say where each page's rows and columns are stored (a (Y, X, C) array written without metadata reads as `QYX`). `header_axes` gives axes exactly when nothing is open, and `check_declared_axes` then accepts those axes and no others. | +| Declared axes | `check_declared_axes(meta, declared)` (`axes`) | Axes a brick or user declares for an image or mask (`image_axes="CYX"`) may name what the header leaves open, never contradict it: one letter per header axis (any case), roles X, Y, Z, C, T each at most once; an axis the metadata names (`named_axes`) keeps its letter; a dimension it leaves open may be declared any role, so a plain `QYX` stack may be declared `YXC` as well as `CYX` (since v0.3.4; before, its positional `Y` and `X` were binding and a placeholder could not be declared X or Y), while an ImageJ `ZYX` declared `CYX`, an OME `CYX` declared `YXC` or a shaped `CYX` declared `YXC` are refused; the RGB samples `S` may be declared C when the header has no C; any other header axis (tile `R`, ...) cannot be declared. Otherwise the error `declared_axes_mismatch` naming the image and both axes (`unreadable_tiff` or `unreadable_ome_zarr` when the header does not open). | | Mask files | `read_mask_file(path, z=, t=)` (`masks`) | Reads one plane of a TIFF mask or of an OME-Zarr label image (`.zarr/labels/`) given by its path, with `read_mask`'s rules (§15): its axes are its own metadata's, Y and X plus at most a Z and a T axis named so; any other or unnamed dimension (a plain stack, `IYX`; a `C` axis) is refused, never taken for depth or time; `z` and `t` are required for a mask with that axis; integer or boolean pixels; labels checked with `check_labels`. A `ValueError` names the file and the problem. | | Identity (D-e) | `FOV_KEY`, `check_identity_text(value, field)`, `check_plane_index(value, axis)`, `check_identity_case(keys)`, `MISSING_TEXT`, `IdentityError` (`discovery`) | `FOV_KEY` = (`condition`, `biological_repeat`, `technical_repeat`, `position_id`), the position key of §1. `check_identity_text` accepts a label or `position_id` that is text without leading or trailing whitespace and not `""`, `NA`, `nan`, `none`, `null`, `n/a` or `` in any case (`MISSING_TEXT`); `check_plane_index` accepts None or an integer of at least 0, a Python `int` or a NumPy integer (returned as a plain `int`), and refuses everything else (bool, float, `"1"`) without converting it. Both raise `IdentityError` (a `ValueError`) naming the field and the value. Discovery applies the text rule to every folder and image name (`invalid_name`) and refuses names of one level that differ only in case anywhere in the experiment (`case_conflict`, §1). `position_id` keeps its own scope: unique within its technical repeat, ignoring case (`duplicate_position_id`). `check_identity_case` applies the same case rule to identities a brick reads from a table, one key per row in `FOV_KEY` order (or the three labels): a label of one level spelled two ways anywhere in the run, or two `position_id` values of one technical repeat equal ignoring case, raise `IdentityError` naming both spellings; the same key may repeat (several planes of one position). | | Sources of a run | `check_sources(manifest, experiment_root, relative_paths, image_axes=, identities=)`, `source_fingerprint`, `file_sha256` (`manifest`) | The checks a brick makes before using a run's table for some images (formerly CellPatcher `check_images` and MorphoFeature `histogram_fit`). Each image must be in the manifest (`images_not_in_manifest`), still exist (`images_missing`), and be unchanged by `source_fingerprint`, its size and modification time, for an OME-Zarr folder its files' total size and newest time, so a rewritten chunk counts (`images_changed`). Its recorded `position_id` must be the one its name gives; with `identities` (`FOV_KEY` order), its labels and `position_id` must be the caller's (`identity_differs`; `position_id_not_recorded` for a manifest written before positions were recorded). With `image_axes`, the caller's axes must give each axis the role recorded for the file (`FileEntry.roles`, or the run's mapping resolved for the header in older manifests); an axis without a role (length 1) fits any letter (`axis_mapping_mismatch`). All errors, naming their images; the table's own hash is checked with `file_sha256` against `csv_sha256`. | diff --git a/docs/plan.md b/docs/plan.md index c3dce928..cff81893 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -938,6 +938,14 @@ Raised during review; to be decided before the milestones that need them: only drift and no image stands out; it replaces the four-significant-digit rounding. Owner's follow-up decisions: `null`, `n/a` and `` are refused as names too, and `check_plane_index` returns NumPy integers as plain ints.* + *Amended (31 October 2026, for v0.3.4): only the axes the metadata names bind a + declaration. The `Y` and `X` `tifffile` reports for a plain stack of pages (`IYX`, + `QYX`, `QQYX`, no axis metadata) only say where each page's rows and columns are + stored, so like the placeholders they are left open: a plain `QYX` stack may be + declared `YXC` or `CYX`. `named_axes` is the one rule behind `header_axes` and + `check_declared_axes`; a declaration the metadata contradicts (ImageJ `ZYX` as `CYX`, + OME or shaped `CYX` as `YXC`) is still refused. MorphoRelate's workaround (checking only + when `header_axes` names every axis) is no longer needed.* 39. What does v0.3.3 hold? *Decided (31 October 2026): v0.3.2 and the shared rules of question 38 (PRs #49–#55), with the warning `unsupported_pixel_size_unit` naming an image, axis and unit outside diff --git a/src/morphobricks_preprocessing/__init__.py b/src/morphobricks_preprocessing/__init__.py index f1e90fea..349f28a9 100644 --- a/src/morphobricks_preprocessing/__init__.py +++ b/src/morphobricks_preprocessing/__init__.py @@ -121,6 +121,7 @@ read_mask_file, ) from .metadata import ( + OPEN_AXIS, PLACEHOLDER_AXES, ImageMetadata, MetadataReport, @@ -132,6 +133,7 @@ inspect_images, inspect_ome_zarr, inspect_tiff, + named_axes, ) from .physical_sizes import ( PIXEL_SIZE_TOLERANCE, @@ -217,6 +219,7 @@ def __dir__() -> list[str]: "MASK_NAME_SUFFIXES", "MASK_REPORT_FILENAME", "MISSING_TEXT", + "OPEN_AXIS", "OUTPUT_FILENAME", "PIXEL_SIZE_TOLERANCE", "PLACEHOLDER_AXES", @@ -329,6 +332,7 @@ def __dir__() -> list[str]: "mask_plane_issues", "mask_thresholds", "min_max", + "named_axes", "ngff_label_names", "ome_zarr_relative_path", "open_ngff", diff --git a/src/morphobricks_preprocessing/axes.py b/src/morphobricks_preprocessing/axes.py index b44fd846..5ac4df2a 100644 --- a/src/morphobricks_preprocessing/axes.py +++ b/src/morphobricks_preprocessing/axes.py @@ -18,7 +18,13 @@ from dataclasses import dataclass, field from types import MappingProxyType -from .metadata import PLACEHOLDER_AXES, MetadataReport, TiffMetadata, channel_count_issues +from .metadata import ( + OPEN_AXIS, + MetadataReport, + TiffMetadata, + channel_count_issues, + named_axes, +) from .validation import ValidationIssue, error, has_errors, plural ROLES = ("X", "Y", "Z", "C", "T") @@ -240,12 +246,15 @@ def check_declared_axes(meta: TiffMetadata, declared: str) -> ValidationIssue | One rule for every brick that is told an image's (or mask's) axes, for example ``image_axes="CYX"`` (MorphoBricks D86): a declaration can name what the header leaves - open, never contradict it. Position by position: - - - an axis the metadata names (:func:`.metadata.header_axes`: X, Y, Z, C, T) must be - declared with that letter; - - a placeholder axis (``I``, ``Q``: the file does not say) may be declared Z, C or T, - never X or Y; + open, never contradict it. Only what the metadata names binds + (:func:`.metadata.named_axes`, the same rule as :func:`.metadata.header_axes`). + Position by position: + + - an axis the metadata names (X, Y, Z, C, T) must be declared with that letter; + - a dimension it leaves open may be declared any role: a placeholder axis (``I``, + ``Q``), and in a plain stack of pages without axis metadata (``IYX``, ``QYX``, + ``QQYX``) the ``Y`` and ``X`` too, which only say where the pages' rows and columns + are stored (a (Y, X, C) array written without metadata reads as ``QYX``); - the RGB samples axis ``S`` may be declared C when the header has no C (as the axis review's prefill does); - any other header axis (tile ``R``, angle ``A``, ...) has no role and cannot be @@ -276,7 +285,7 @@ def check_declared_axes(meta: TiffMetadata, declared: str) -> ValidationIssue | "Export the image again, or leave it out.", ) text = declared.upper() if isinstance(declared, str) else "" - problem = _declared_axes_problem(meta.axes, text) + problem = _declared_axes_problem(meta.axes, named_axes(meta), text) if problem is None: return None return error( @@ -289,7 +298,7 @@ def check_declared_axes(meta: TiffMetadata, declared: str) -> ValidationIssue | ) -def _declared_axes_problem(header: str, declared: str) -> str | None: +def _declared_axes_problem(header: str, named: str, declared: str) -> str | None: if not declared or len(declared) != len(header): return f"{len(declared)} declared for {len(header)} dimensions" unknown = sorted(set(declared) - set(ROLES)) @@ -298,10 +307,8 @@ def _declared_axes_problem(header: str, declared: str) -> str | None: repeated = sorted({axis for axis in declared if declared.count(axis) > 1}) if repeated: return f"{', '.join(repeated)} declared more than once" - for index, (have, said) in enumerate(zip(header, declared, strict=True)): - if have == said: - continue - if have in PLACEHOLDER_AXES and said in PLANE_ROLES: + for index, (have, said) in enumerate(zip(named, declared, strict=True)): + if have in (said, OPEN_AXIS): continue if have == "S" and said == "C" and "C" not in header: continue diff --git a/src/morphobricks_preprocessing/metadata.py b/src/morphobricks_preprocessing/metadata.py index 62ddf7b7..cebd7805 100644 --- a/src/morphobricks_preprocessing/metadata.py +++ b/src/morphobricks_preprocessing/metadata.py @@ -100,6 +100,12 @@ class TiffMetadata: RGB with three samples per pixel, which ``tifffile`` labels ``S``. It is the metadata the image type suggestion uses (:mod:`.image_type`). False for other TIFF files (also RGB with an alpha sample) and OME-Zarr images. + axes_from_metadata + True when the axes come from metadata that names them: OME, ImageJ or other + microscope metadata ``tifffile`` reads, ``tifffile`` "shaped" metadata with axes + (decision D-d), or OME-NGFF. False for a plain TIFF (no axis metadata), whose + ``Y`` and ``X`` only say where each page's rows and columns are stored + (:func:`header_axes`, :func:`.axes.check_declared_axes`). """ relative_path: str @@ -116,6 +122,7 @@ class TiffMetadata: physical_sizes: PhysicalSizes = PhysicalSizes() rgb_samples: bool = False channel_names_trimmed: bool = False + axes_from_metadata: bool = True def length(self, axis: str) -> int: """Return the length of the dimension labelled ``axis``, 1 if there is none.""" @@ -179,6 +186,45 @@ def dtype(self) -> np.dtype | None: plain stack of pages) and ``Q`` (a "shaped" file without axes, or an OME-NGFF axis of another type). They are never metadata: the user gives such a dimension its role.""" +OPEN_AXIS = "?" +"""Marks a dimension the metadata does not name in :func:`named_axes`.""" + + +def named_axes(meta: TiffMetadata) -> str: + """Return the image's axes with every dimension its metadata does not name as ``?``. + + The one rule behind :func:`header_axes` and :func:`.axes.check_declared_axes` + (MorphoBricks D86): a dimension is named when ``tifffile`` (or OME-NGFF) gives it a + letter from the file's metadata. Left open (:data:`OPEN_AXIS`) are + + - placeholder axes (:data:`PLACEHOLDER_AXES`: ``I``, ``Q``), and + - in a plain stack of pages (a placeholder axis and no axis metadata, e.g. ``QYX``, + ``QQYX`` or ``IYX``), the ``Y`` and ``X`` too: they are only where each page's + rows and columns are stored. A (Y, X, C) array written without metadata reads as + ``QYX`` with its channels as ``X``. + + The ``Y`` and ``X`` of a single plain page (``YX``, RGB ``YXS``) are named (decision + D-d), as is ``S``, which comes from the samples-per-pixel tag. + + Parameters + ---------- + meta + The image's header (:func:`inspect_image`). + + Returns + ------- + str + One character per dimension, e.g. ``"CYX"``, ``"?YX"`` (shaped axes ``QYX``) or + ``"???"`` (a plain ``QYX`` stack); ``""`` for an unreadable image. + """ + if not meta.readable: + return "" + plain_stack = not meta.axes_from_metadata and bool(PLACEHOLDER_AXES & set(meta.axes)) + return "".join( + OPEN_AXIS if axis in PLACEHOLDER_AXES or (plain_stack and axis in "YX") else axis + for axis in meta.axes + ) + def header_axes(meta: TiffMetadata) -> str | None: """Return the axes an image's own metadata gives, or None when it does not name them all. @@ -186,11 +232,12 @@ def header_axes(meta: TiffMetadata) -> str | None: These are the axes every MorphoBricks brick trusts (MorphoBricks D86, decision D-d): the series axes ``tifffile`` reads from OME, ImageJ and "shaped" metadata (the ``{"axes": ...}`` ``tifffile`` writes), a plain 2D ``YX`` image (or ``YXS`` RGB), and - the OME-NGFF axes of an OME-Zarr image. When any axis is a placeholder - (:data:`PLACEHOLDER_AXES`: a plain multi-page TIFF is ``IYX``, a shaped one without - axes ``QYX``), the file does not say which dimension is depth, time or channel, so - there are no header axes and nothing may be guessed. Length-1 axes are left out, as - ``tifffile`` reads them. + the OME-NGFF axes of an OME-Zarr image. When any dimension is left open + (:func:`named_axes`: a placeholder axis, so a plain multi-page TIFF ``IYX`` or a shaped + one without axes ``QYX``), the file does not say which dimension is depth, time or + channel, so there are no header axes and nothing may be guessed. Exactly when this + gives axes, :func:`.axes.check_declared_axes` accepts those axes and no others. + Length-1 axes are left out, as ``tifffile`` reads them. Parameters ---------- @@ -202,7 +249,8 @@ def header_axes(meta: TiffMetadata) -> str | None: str or None For example ``"ZYX"``; None for an unreadable image or one with a placeholder axis. """ - if not meta.readable or not meta.axes or PLACEHOLDER_AXES & set(meta.axes): + named = named_axes(meta) + if not named or OPEN_AXIS in named: return None return meta.axes @@ -242,6 +290,7 @@ def inspect_tiff(path: str | os.PathLike, relative_path: str | None = None) -> T **_ome_details(tif), physical_sizes=_tiff_sizes(tif), rgb_samples=_rgb_samples(first), + axes_from_metadata=_axes_from_metadata(tif, first), ) except Exception as exc: # any failure to parse means the file cannot be used logger.debug("Cannot read the TIFF header of %s", path, exc_info=True) @@ -499,6 +548,17 @@ def _rgb_samples(series: tifffile.TiffPageSeries) -> bool: ) +def _axes_from_metadata(tif: tifffile.TiffFile, series: tifffile.TiffPageSeries) -> bool: + """Return False for a plain TIFF: a series whose axes no metadata names.""" + kind = str(series.kind).lower() + if kind in ("generic", "uniform"): + return False + if kind == "shaped": + shaped = tif.shaped_metadata + return bool(shaped) and "axes" in shaped[0] + return True + + def _local_name(tag: str) -> str: return tag.rsplit("}", 1)[-1] diff --git a/tests/test_header_axes.py b/tests/test_header_axes.py index f6ed46f3..50dc8938 100644 --- a/tests/test_header_axes.py +++ b/tests/test_header_axes.py @@ -1,14 +1,18 @@ """The shared header-axes rules (MorphoBricks D86, API A4, decision D-d).""" +import itertools + import numpy as np import pytest import tifffile from morphobricks_preprocessing import ( + OPEN_AXIS, PLACEHOLDER_AXES, check_declared_axes, header_axes, inspect_image, + named_axes, read_mask_file, ) from tiff_tree import write_data @@ -66,6 +70,34 @@ def test_header_axes(tmp_path, write, expected): assert header_axes(inspect_image(path, "a.tif")) == expected +@pytest.mark.parametrize( + ("write", "axes", "named"), + [ + (lambda p: plain(p, np.zeros((3, 8, 8), np.uint8)), "IYX", "???"), + (lambda p: write_data(p, np.zeros((3, 8, 8), np.uint8)), "QYX", "???"), + (lambda p: write_data(p, np.zeros((2, 3, 8, 8), np.uint8)), "QQYX", "????"), + (lambda p: write_data(p, np.zeros((3, 8, 8), np.uint8), "QYX"), "QYX", "?YX"), + (lambda p: write_data(p, np.zeros((3, 8, 8), np.uint8), "CYX"), "CYX", "CYX"), + (lambda p: imagej(p, np.zeros((3, 8, 8), np.uint8), "ZYX"), "ZYX", "ZYX"), + (lambda p: plain(p, np.zeros((8, 8), np.uint8)), "YX", "YX"), + (lambda p: write_data(p, np.zeros((8, 8), np.uint8)), "YX", "YX"), + ( + lambda p: tifffile.imwrite( + p, np.zeros((2, 8, 8, 3), np.uint8), photometric="rgb", metadata=None + ), + "IYXS", + "???S", + ), + ], +) +def test_named_axes(tmp_path, write, axes, named): + path = tmp_path / "a.tif" + write(path) + meta = inspect_image(path, "a.tif") + assert (meta.axes, named_axes(meta)) == (axes, named) + assert OPEN_AXIS == "?" + + def test_header_axes_of_an_unreadable_file(tmp_path): path = tmp_path / "a.tif" path.write_bytes(b"not a tiff") @@ -120,9 +152,97 @@ def test_a_placeholder_axis_may_be_declared_depth_time_or_channel(tmp_path): assert meta.axes == "IYX" for declared in ("ZYX", "TYX", "CYX"): assert check_declared_axes(meta, declared) is None - # but never X or Y, and the header's Y and X stay where they are - assert check_declared_axes(meta, "XZY").code == "declared_axes_mismatch" - assert check_declared_axes(meta, "YXZ").code == "declared_axes_mismatch" + + +def shaped_without_axes(path, data): + return write_data(path, data) + + +@pytest.mark.parametrize("write", [plain, shaped_without_axes]) +@pytest.mark.parametrize("declared", ["YXC", "CYX", "ZYX", "TYX", "XYZ", "yxc"]) +def test_a_plain_stack_leaves_every_dimension_open(tmp_path, write, declared): + # The Y and X tifffile reports for a plain stack are only where each page's rows and + # columns are stored: a (Y, X, C) array written without metadata reads as QYX or IYX. + meta = inspect_image(write(tmp_path / "a.tif", np.zeros((8, 8, 3), np.uint8)), "a.tif") + assert meta.axes in ("QYX", "IYX") + assert check_declared_axes(meta, declared) is None + + +def test_a_plain_stack_of_two_unnamed_dimensions_is_open(tmp_path): + meta = inspect_image(write_data(tmp_path / "a.tif", np.zeros((2, 3, 8, 8), np.uint8))) + assert meta.axes == "QQYX" + for declared in ("ZCYX", "TZYX", "YXZC", "CYXT"): + assert check_declared_axes(meta, declared) is None + + +@pytest.mark.parametrize("declared", ["YX", "ZCYX", "ZZX", "ZYQ", "YXS", "", None]) +def test_a_plain_stack_still_needs_one_role_per_dimension(tmp_path, declared): + meta = inspect_image(plain(tmp_path / "a.tif", np.zeros((3, 8, 8), np.uint8)), "a.tif") + assert check_declared_axes(meta, declared).code == "declared_axes_mismatch" + + +def test_named_axes_stay_binding_beside_a_placeholder(tmp_path): + # Axes metadata that names Y and X but leaves one dimension open: only that one is open. + path = write_data(tmp_path / "a.tif", np.zeros((3, 8, 8), np.uint8), "QYX") + meta = inspect_image(path, "a.tif") + assert check_declared_axes(meta, "CYX") is None + assert check_declared_axes(meta, "YXC").code == "declared_axes_mismatch" + + +def test_rgb_samples_of_a_plain_stack_stay_binding(tmp_path): + # S comes from the SamplesPerPixel tag, not from where the pages are stored. + path = tmp_path / "rgb.tif" + tifffile.imwrite(path, np.zeros((2, 8, 8, 3), np.uint8), photometric="rgb", metadata=None) + meta = inspect_image(path, "rgb.tif") + assert meta.axes == "IYXS" + assert check_declared_axes(meta, "TYXC") is None + assert check_declared_axes(meta, "TYXZ").code == "declared_axes_mismatch" + + +def test_an_ome_channel_stack_declared_samples_last_is_refused(tmp_path): + path = tmp_path / "a.ome.tif" + tifffile.imwrite(path, np.zeros((3, 8, 8), np.uint8), ome=True, metadata={"axes": "CYX"}) + meta = inspect_image(path, "a.ome.tif") + assert meta.axes == "CYX" + assert check_declared_axes(meta, "CYX") is None + assert check_declared_axes(meta, "YXC").code == "declared_axes_mismatch" + + +def test_shaped_axes_declared_in_another_order_are_refused(tmp_path): + # Decision D-d: tifffile "shaped" axes are header axes, so they bind like OME's. + meta = inspect_image(write_data(tmp_path / "a.tif", np.zeros((3, 8, 8), np.uint8), "CYX")) + assert check_declared_axes(meta, "YXC").code == "declared_axes_mismatch" + + +@pytest.mark.parametrize( + "write", + [ + lambda p: plain(p, np.zeros((3, 8, 8), np.uint8)), + lambda p: write_data(p, np.zeros((3, 8, 8), np.uint8)), + lambda p: write_data(p, np.zeros((3, 8, 8), np.uint8), "CYX"), + lambda p: write_data(p, np.zeros((3, 8, 8), np.uint8), "QYX"), + lambda p: imagej(p, np.zeros((3, 8, 8), np.uint8), "ZYX"), + lambda p: plain(p, np.zeros((8, 8), np.uint8)), + lambda p: write_data(p, np.zeros((8, 8), np.uint8)), + ], +) +def test_header_axes_are_exactly_what_check_declared_axes_binds(tmp_path, write): + path = tmp_path / "a.tif" + write(path) + meta = inspect_image(path, "a.tif") + roles = "XYZCT" + accepted = { + "".join(order) + for order in itertools.permutations(roles, len(meta.axes)) + if check_declared_axes(meta, "".join(order)) is None + } + named = header_axes(meta) + if named is None: + # Something is left open, so more than one declaration fits. + assert len(accepted) > 1 + else: + # Every axis is named, so only the header's own axes fit. + assert accepted == {named} def test_rgb_samples_may_be_declared_channels(tmp_path): diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 6680ce9e..c7ed0088 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -56,6 +56,7 @@ def test_inspect_2d(tmp_path, no_pixel_reads): axes="YX", series_count=1, page_count=1, + axes_from_metadata=False, # written without axes ) assert info.ndim == 2 and info.dtype_supported From ab5f31339cb8e9a47157e6ba73f373704dd04e17 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 12:38:16 +0000 Subject: [PATCH 2/4] Header axes: public series_axes_from_metadata for other series A brick that builds a TiffMetadata for a series other than the first (MorphoRelate's series reader) needs the same flag inspect_tiff records. A header built by hand keeps the strict default: a placeholder stack's Y and X stay binding. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VbytvBRB9CmKQ5AAkhEFcv --- docs/contract.md | 2 +- src/morphobricks_preprocessing/__init__.py | 2 ++ src/morphobricks_preprocessing/metadata.py | 36 ++++++++++++++++++---- tests/test_header_axes.py | 27 ++++++++++++++++ 4 files changed, 60 insertions(+), 7 deletions(-) diff --git a/docs/contract.md b/docs/contract.md index 6872a41a..0dd4ce92 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -406,7 +406,7 @@ instead of keeping copies. | Length units (D-a2) | `space_unit(text)`, `to_micrometers(value, unit)`, `SPACE_UNITS`, `UnsupportedUnitError` (`physical_sizes`) | Microscopy units only: `pm`; `Å`/`angstrom`; `nm`; `µm`/`um`/`micron(s)`/`micrometer(s)`/`micrometre(s)`; `mm`; `cm`; `m`; with each name in US and British spelling and in the plural (`nanometre`, `millimeters`, ...). Symbols of two letters or fewer are matched exactly (`Mm` is not `mm`), longer names in any case; ImageJ's `\u00B5` is decoded. `space_unit` returns the OME-NGFF name (`"micrometer"`), None when no unit is given, and refuses any other unit by name (`UnsupportedUnitError`); `to_micrometers` converts a positive finite length, None when the length or its unit is not known. This brick's readers (§2) use the same table: since D86, ImageJ and OME-NGFF sizes in British spellings (`micrometre`) are calibrated, where they were unknown before. | | Plain-TIFF resolution (D-c) | `read_physical_sizes` | A TIFF's resolution tags give a pixel size only with OME or ImageJ metadata and a unit; a plain TIFF (scanner or image-editor dpi) is uncalibrated, never converted from inches or centimetres. | | Labels (D-b) | `check_labels(pixels, name)`, `LARGEST_LABEL` (`masks`) | A mask holds whole-number labels from 0 (background) to `LARGEST_LABEL` = 2,147,483,647: MorphoBricks labels are int32 in every brick. A negative label, a label above the limit, or a pixel type that is not integer or boolean is refused with a `ValueError` naming the mask and the value. `read_mask`, `piece_counts`, `pair_counts` and `scan_masks` call it. Since D86 the limit is int32; before, this brick refused only labels above 2^63 − 1 (int64), so a `uint32` or wider mask with a label of 3,000,000,000 is now refused. | -| Header axes (D-d) | `header_axes(meta)`, `named_axes(meta)`, `PLACEHOLDER_AXES`, `OPEN_AXIS` (`metadata`) | The axes an image's own metadata names, as `tifffile` reads them with length-1 axes left out: OME, ImageJ and `tifffile` "shaped" metadata (`{"axes": "CYX"}`, decision D-d), a plain 2D `YX` (or `YXS` RGB) TIFF, and an OME-Zarr image's OME-NGFF axes. None when an axis is a placeholder (`I`: a plain stack of pages; `Q`: shaped without axes, or an OME-NGFF axis of another type), or the image does not open: the file does not say which dimension is depth, time or channel. `named_axes` gives the rule position by position, with `?` (`OPEN_AXIS`) for a dimension the metadata does not name: a placeholder, and, in a plain stack of pages without axis metadata (`IYX`, `QYX`, `QQYX`; `TiffMetadata.axes_from_metadata` False), the `Y` and `X` too, which only say where each page's rows and columns are stored (a (Y, X, C) array written without metadata reads as `QYX`). `header_axes` gives axes exactly when nothing is open, and `check_declared_axes` then accepts those axes and no others. | +| Header axes (D-d) | `header_axes(meta)`, `named_axes(meta)`, `series_axes_from_metadata(series)`, `PLACEHOLDER_AXES`, `OPEN_AXIS` (`metadata`) | The axes an image's own metadata names, as `tifffile` reads them with length-1 axes left out: OME, ImageJ and `tifffile` "shaped" metadata (`{"axes": "CYX"}`, decision D-d), a plain 2D `YX` (or `YXS` RGB) TIFF, and an OME-Zarr image's OME-NGFF axes. None when an axis is a placeholder (`I`: a plain stack of pages; `Q`: shaped without axes, or an OME-NGFF axis of another type), or the image does not open: the file does not say which dimension is depth, time or channel. `named_axes` gives the rule position by position, with `?` (`OPEN_AXIS`) for a dimension the metadata does not name: a placeholder, and, in a plain stack of pages without axis metadata (`IYX`, `QYX`, `QQYX`; `TiffMetadata.axes_from_metadata` False, from `series_axes_from_metadata`; True, the strict reading, for a header built by hand), the `Y` and `X` too, which only say where each page's rows and columns are stored (a (Y, X, C) array written without metadata reads as `QYX`). `header_axes` gives axes exactly when nothing is open, and `check_declared_axes` then accepts those axes and no others. | | Declared axes | `check_declared_axes(meta, declared)` (`axes`) | Axes a brick or user declares for an image or mask (`image_axes="CYX"`) may name what the header leaves open, never contradict it: one letter per header axis (any case), roles X, Y, Z, C, T each at most once; an axis the metadata names (`named_axes`) keeps its letter; a dimension it leaves open may be declared any role, so a plain `QYX` stack may be declared `YXC` as well as `CYX` (since v0.3.4; before, its positional `Y` and `X` were binding and a placeholder could not be declared X or Y), while an ImageJ `ZYX` declared `CYX`, an OME `CYX` declared `YXC` or a shaped `CYX` declared `YXC` are refused; the RGB samples `S` may be declared C when the header has no C; any other header axis (tile `R`, ...) cannot be declared. Otherwise the error `declared_axes_mismatch` naming the image and both axes (`unreadable_tiff` or `unreadable_ome_zarr` when the header does not open). | | Mask files | `read_mask_file(path, z=, t=)` (`masks`) | Reads one plane of a TIFF mask or of an OME-Zarr label image (`.zarr/labels/`) given by its path, with `read_mask`'s rules (§15): its axes are its own metadata's, Y and X plus at most a Z and a T axis named so; any other or unnamed dimension (a plain stack, `IYX`; a `C` axis) is refused, never taken for depth or time; `z` and `t` are required for a mask with that axis; integer or boolean pixels; labels checked with `check_labels`. A `ValueError` names the file and the problem. | | Identity (D-e) | `FOV_KEY`, `check_identity_text(value, field)`, `check_plane_index(value, axis)`, `check_identity_case(keys)`, `MISSING_TEXT`, `IdentityError` (`discovery`) | `FOV_KEY` = (`condition`, `biological_repeat`, `technical_repeat`, `position_id`), the position key of §1. `check_identity_text` accepts a label or `position_id` that is text without leading or trailing whitespace and not `""`, `NA`, `nan`, `none`, `null`, `n/a` or `` in any case (`MISSING_TEXT`); `check_plane_index` accepts None or an integer of at least 0, a Python `int` or a NumPy integer (returned as a plain `int`), and refuses everything else (bool, float, `"1"`) without converting it. Both raise `IdentityError` (a `ValueError`) naming the field and the value. Discovery applies the text rule to every folder and image name (`invalid_name`) and refuses names of one level that differ only in case anywhere in the experiment (`case_conflict`, §1). `position_id` keeps its own scope: unique within its technical repeat, ignoring case (`duplicate_position_id`). `check_identity_case` applies the same case rule to identities a brick reads from a table, one key per row in `FOV_KEY` order (or the three labels): a label of one level spelled two ways anywhere in the run, or two `position_id` values of one technical repeat equal ignoring case, raise `IdentityError` naming both spellings; the same key may repeat (several planes of one position). | diff --git a/src/morphobricks_preprocessing/__init__.py b/src/morphobricks_preprocessing/__init__.py index 349f28a9..2b9d05aa 100644 --- a/src/morphobricks_preprocessing/__init__.py +++ b/src/morphobricks_preprocessing/__init__.py @@ -134,6 +134,7 @@ inspect_ome_zarr, inspect_tiff, named_axes, + series_axes_from_metadata, ) from .physical_sizes import ( PIXEL_SIZE_TOLERANCE, @@ -366,6 +367,7 @@ def __dir__() -> list[str]: "same_pixel_size", "sample_planes", "scan_masks", + "series_axes_from_metadata", "source_fingerprint", "space_unit", "to_micrometers", diff --git a/src/morphobricks_preprocessing/metadata.py b/src/morphobricks_preprocessing/metadata.py index cebd7805..fefeefe9 100644 --- a/src/morphobricks_preprocessing/metadata.py +++ b/src/morphobricks_preprocessing/metadata.py @@ -15,6 +15,7 @@ from __future__ import annotations +import json import logging import os from collections.abc import Callable, Hashable, Iterable, Mapping @@ -105,7 +106,8 @@ class TiffMetadata: microscope metadata ``tifffile`` reads, ``tifffile`` "shaped" metadata with axes (decision D-d), or OME-NGFF. False for a plain TIFF (no axis metadata), whose ``Y`` and ``X`` only say where each page's rows and columns are stored - (:func:`header_axes`, :func:`.axes.check_declared_axes`). + (:func:`series_axes_from_metadata`, :func:`named_axes`). Defaults to True, the + strict reading, for a header built by hand. """ relative_path: str @@ -290,7 +292,7 @@ def inspect_tiff(path: str | os.PathLike, relative_path: str | None = None) -> T **_ome_details(tif), physical_sizes=_tiff_sizes(tif), rgb_samples=_rgb_samples(first), - axes_from_metadata=_axes_from_metadata(tif, first), + axes_from_metadata=series_axes_from_metadata(first), ) except Exception as exc: # any failure to parse means the file cannot be used logger.debug("Cannot read the TIFF header of %s", path, exc_info=True) @@ -548,14 +550,36 @@ def _rgb_samples(series: tifffile.TiffPageSeries) -> bool: ) -def _axes_from_metadata(tif: tifffile.TiffFile, series: tifffile.TiffPageSeries) -> bool: - """Return False for a plain TIFF: a series whose axes no metadata names.""" +def series_axes_from_metadata(series: tifffile.TiffPageSeries) -> bool: + """Return True when axis metadata names the axes of a ``tifffile`` series. + + False for a plain TIFF: a series ``tifffile`` reads as ``generic`` or ``uniform`` pages, + or as "shaped" without axes (``{"shape": ...}`` only). True for OME, ImageJ, shaped + metadata with ``"axes"`` (decision D-d) and the other microscope formats ``tifffile`` + reads axes from. :func:`inspect_tiff` records it for the first series as + :attr:`TiffMetadata.axes_from_metadata`; a brick that builds a :class:`TiffMetadata` + for another series passes it the same way. + + Parameters + ---------- + series + A series of an open ``tifffile.TiffFile``. + + Returns + ------- + bool + Whether the series' axes come from metadata rather than from where its pages are + stored. + """ kind = str(series.kind).lower() if kind in ("generic", "uniform"): return False if kind == "shaped": - shaped = tif.shaped_metadata - return bool(shaped) and "axes" in shaped[0] + try: + shaped = json.loads(series.keyframe.shaped_description or "") + except (AttributeError, TypeError, ValueError): + return False + return isinstance(shaped, dict) and "axes" in shaped return True diff --git a/tests/test_header_axes.py b/tests/test_header_axes.py index 50dc8938..872ba474 100644 --- a/tests/test_header_axes.py +++ b/tests/test_header_axes.py @@ -1,5 +1,6 @@ """The shared header-axes rules (MorphoBricks D86, API A4, decision D-d).""" +import dataclasses import itertools import numpy as np @@ -9,11 +10,13 @@ from morphobricks_preprocessing import ( OPEN_AXIS, PLACEHOLDER_AXES, + TiffMetadata, check_declared_axes, header_axes, inspect_image, named_axes, read_mask_file, + series_axes_from_metadata, ) from tiff_tree import write_data @@ -98,6 +101,30 @@ def test_named_axes(tmp_path, write, axes, named): assert OPEN_AXIS == "?" +def test_series_axes_from_metadata_reads_each_series(tmp_path): + # A brick building a TiffMetadata for another series passes the same flag. + path = tmp_path / "two.tif" + tifffile.imwrite(path, np.zeros((3, 8, 8), np.uint8), photometric="minisblack", metadata={}) + tifffile.imwrite( + path, + np.zeros((2, 8, 8), np.uint8), + photometric="minisblack", + metadata={"axes": "CYX"}, + append=True, + ) + with tifffile.TiffFile(path) as tif: + assert [series_axes_from_metadata(series) for series in tif.series] == [False, True] + + +def test_a_header_built_by_hand_reads_strictly(): + # Without the flag, a placeholder stack's Y and X stay binding (refuse rather than guess). + meta = TiffMetadata("a.tif", True, (3, 8, 8), np.dtype("uint8"), "QYX", 1, 3) + assert named_axes(meta) == "?YX" + assert check_declared_axes(meta, "YXC").code == "declared_axes_mismatch" + plain_stack = dataclasses.replace(meta, axes_from_metadata=False) + assert check_declared_axes(plain_stack, "YXC") is None + + def test_header_axes_of_an_unreadable_file(tmp_path): path = tmp_path / "a.tif" path.write_bytes(b"not a tiff") From 1d73172df738e84d8cda673904690da25d2c162a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 12:57:00 +0000 Subject: [PATCH 3/4] Declared axes: accept the named Y and X swapped, and report it Owner decision of 31 October 2026: a declaration that differs from the header's named axes only by swapping Y and X (CYX declared CXY, ZYX declared ZXY) means the image is read transposed and is accepted. The new declared_axes_transposed(meta, declared) tells the caller so it can record it; it raises ValueError for a declaration that does not fit. Any other disagreement stays refused (CYX as YXC, ZYX as CYX or CXY). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VbytvBRB9CmKQ5AAkhEFcv --- docs/contract.md | 2 +- docs/plan.md | 6 ++- src/morphobricks_preprocessing/__init__.py | 2 + src/morphobricks_preprocessing/axes.py | 43 +++++++++++++++- tests/test_header_axes.py | 58 +++++++++++++++++++++- 5 files changed, 106 insertions(+), 5 deletions(-) diff --git a/docs/contract.md b/docs/contract.md index 0dd4ce92..f360b167 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -407,7 +407,7 @@ instead of keeping copies. | Plain-TIFF resolution (D-c) | `read_physical_sizes` | A TIFF's resolution tags give a pixel size only with OME or ImageJ metadata and a unit; a plain TIFF (scanner or image-editor dpi) is uncalibrated, never converted from inches or centimetres. | | Labels (D-b) | `check_labels(pixels, name)`, `LARGEST_LABEL` (`masks`) | A mask holds whole-number labels from 0 (background) to `LARGEST_LABEL` = 2,147,483,647: MorphoBricks labels are int32 in every brick. A negative label, a label above the limit, or a pixel type that is not integer or boolean is refused with a `ValueError` naming the mask and the value. `read_mask`, `piece_counts`, `pair_counts` and `scan_masks` call it. Since D86 the limit is int32; before, this brick refused only labels above 2^63 − 1 (int64), so a `uint32` or wider mask with a label of 3,000,000,000 is now refused. | | Header axes (D-d) | `header_axes(meta)`, `named_axes(meta)`, `series_axes_from_metadata(series)`, `PLACEHOLDER_AXES`, `OPEN_AXIS` (`metadata`) | The axes an image's own metadata names, as `tifffile` reads them with length-1 axes left out: OME, ImageJ and `tifffile` "shaped" metadata (`{"axes": "CYX"}`, decision D-d), a plain 2D `YX` (or `YXS` RGB) TIFF, and an OME-Zarr image's OME-NGFF axes. None when an axis is a placeholder (`I`: a plain stack of pages; `Q`: shaped without axes, or an OME-NGFF axis of another type), or the image does not open: the file does not say which dimension is depth, time or channel. `named_axes` gives the rule position by position, with `?` (`OPEN_AXIS`) for a dimension the metadata does not name: a placeholder, and, in a plain stack of pages without axis metadata (`IYX`, `QYX`, `QQYX`; `TiffMetadata.axes_from_metadata` False, from `series_axes_from_metadata`; True, the strict reading, for a header built by hand), the `Y` and `X` too, which only say where each page's rows and columns are stored (a (Y, X, C) array written without metadata reads as `QYX`). `header_axes` gives axes exactly when nothing is open, and `check_declared_axes` then accepts those axes and no others. | -| Declared axes | `check_declared_axes(meta, declared)` (`axes`) | Axes a brick or user declares for an image or mask (`image_axes="CYX"`) may name what the header leaves open, never contradict it: one letter per header axis (any case), roles X, Y, Z, C, T each at most once; an axis the metadata names (`named_axes`) keeps its letter; a dimension it leaves open may be declared any role, so a plain `QYX` stack may be declared `YXC` as well as `CYX` (since v0.3.4; before, its positional `Y` and `X` were binding and a placeholder could not be declared X or Y), while an ImageJ `ZYX` declared `CYX`, an OME `CYX` declared `YXC` or a shaped `CYX` declared `YXC` are refused; the RGB samples `S` may be declared C when the header has no C; any other header axis (tile `R`, ...) cannot be declared. Otherwise the error `declared_axes_mismatch` naming the image and both axes (`unreadable_tiff` or `unreadable_ome_zarr` when the header does not open). | +| Declared axes | `check_declared_axes(meta, declared)`, `declared_axes_transposed(meta, declared)` (`axes`) | Axes a brick or user declares for an image or mask (`image_axes="CYX"`) may name what the header leaves open, never contradict it: one letter per header axis (any case), roles X, Y, Z, C, T each at most once; an axis the metadata names (`named_axes`) keeps its letter, except that the named `Y` and `X` may be declared swapped (header `CYX` declared `CXY`, `ZYX` declared `ZXY`): the image is read transposed, and `declared_axes_transposed` returns True so the caller can record it (owner decision of 31 October 2026; it raises `ValueError` for a declaration that does not fit); any other disagreement is refused; a dimension it leaves open may be declared any role, so a plain `QYX` stack may be declared `YXC` as well as `CYX` (since v0.3.4; before, its positional `Y` and `X` were binding and a placeholder could not be declared X or Y), while an ImageJ `ZYX` declared `CYX`, an OME `CYX` declared `YXC` or a shaped `CYX` declared `YXC` are refused; the RGB samples `S` may be declared C when the header has no C; any other header axis (tile `R`, ...) cannot be declared. Otherwise the error `declared_axes_mismatch` naming the image and both axes (`unreadable_tiff` or `unreadable_ome_zarr` when the header does not open). | | Mask files | `read_mask_file(path, z=, t=)` (`masks`) | Reads one plane of a TIFF mask or of an OME-Zarr label image (`.zarr/labels/`) given by its path, with `read_mask`'s rules (§15): its axes are its own metadata's, Y and X plus at most a Z and a T axis named so; any other or unnamed dimension (a plain stack, `IYX`; a `C` axis) is refused, never taken for depth or time; `z` and `t` are required for a mask with that axis; integer or boolean pixels; labels checked with `check_labels`. A `ValueError` names the file and the problem. | | Identity (D-e) | `FOV_KEY`, `check_identity_text(value, field)`, `check_plane_index(value, axis)`, `check_identity_case(keys)`, `MISSING_TEXT`, `IdentityError` (`discovery`) | `FOV_KEY` = (`condition`, `biological_repeat`, `technical_repeat`, `position_id`), the position key of §1. `check_identity_text` accepts a label or `position_id` that is text without leading or trailing whitespace and not `""`, `NA`, `nan`, `none`, `null`, `n/a` or `` in any case (`MISSING_TEXT`); `check_plane_index` accepts None or an integer of at least 0, a Python `int` or a NumPy integer (returned as a plain `int`), and refuses everything else (bool, float, `"1"`) without converting it. Both raise `IdentityError` (a `ValueError`) naming the field and the value. Discovery applies the text rule to every folder and image name (`invalid_name`) and refuses names of one level that differ only in case anywhere in the experiment (`case_conflict`, §1). `position_id` keeps its own scope: unique within its technical repeat, ignoring case (`duplicate_position_id`). `check_identity_case` applies the same case rule to identities a brick reads from a table, one key per row in `FOV_KEY` order (or the three labels): a label of one level spelled two ways anywhere in the run, or two `position_id` values of one technical repeat equal ignoring case, raise `IdentityError` naming both spellings; the same key may repeat (several planes of one position). | | Sources of a run | `check_sources(manifest, experiment_root, relative_paths, image_axes=, identities=)`, `source_fingerprint`, `file_sha256` (`manifest`) | The checks a brick makes before using a run's table for some images (formerly CellPatcher `check_images` and MorphoFeature `histogram_fit`). Each image must be in the manifest (`images_not_in_manifest`), still exist (`images_missing`), and be unchanged by `source_fingerprint`, its size and modification time, for an OME-Zarr folder its files' total size and newest time, so a rewritten chunk counts (`images_changed`). Its recorded `position_id` must be the one its name gives; with `identities` (`FOV_KEY` order), its labels and `position_id` must be the caller's (`identity_differs`; `position_id_not_recorded` for a manifest written before positions were recorded). With `image_axes`, the caller's axes must give each axis the role recorded for the file (`FileEntry.roles`, or the run's mapping resolved for the header in older manifests); an axis without a role (length 1) fits any letter (`axis_mapping_mismatch`). All errors, naming their images; the table's own hash is checked with `file_sha256` against `csv_sha256`. | diff --git a/docs/plan.md b/docs/plan.md index cff81893..eba3aa4b 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -945,7 +945,11 @@ Raised during review; to be decided before the milestones that need them: declared `YXC` or `CYX`. `named_axes` is the one rule behind `header_axes` and `check_declared_axes`; a declaration the metadata contradicts (ImageJ `ZYX` as `CYX`, OME or shaped `CYX` as `YXC`) is still refused. MorphoRelate's workaround (checking only - when `header_axes` names every axis) is no longer needed.* + when `header_axes` names every axis) is no longer needed. A declaration that differs + from the named axes only by swapping Y and X (`CYX` declared `CXY`) is accepted: the + image is read transposed, and `declared_axes_transposed` tells the caller so it can + record it (as CellPatcher and MorphoFeature allowed). Any other disagreement stays + refused.* 39. What does v0.3.3 hold? *Decided (31 October 2026): v0.3.2 and the shared rules of question 38 (PRs #49–#55), with the warning `unsupported_pixel_size_unit` naming an image, axis and unit outside diff --git a/src/morphobricks_preprocessing/__init__.py b/src/morphobricks_preprocessing/__init__.py index 2b9d05aa..36dfed6d 100644 --- a/src/morphobricks_preprocessing/__init__.py +++ b/src/morphobricks_preprocessing/__init__.py @@ -17,6 +17,7 @@ axis_length_ranges, check_declared_axes, check_mapping, + declared_axes_transposed, declared_mapping, file_mapping_for, prefill_experiment_mapping, @@ -302,6 +303,7 @@ def __dir__() -> list[str]: "check_table", "compute_block_histogram", "compute_plane_histogram", + "declared_axes_transposed", "declared_mapping", "default_bin_count", "default_design", diff --git a/src/morphobricks_preprocessing/axes.py b/src/morphobricks_preprocessing/axes.py index 5ac4df2a..11bc6f96 100644 --- a/src/morphobricks_preprocessing/axes.py +++ b/src/morphobricks_preprocessing/axes.py @@ -250,7 +250,10 @@ def check_declared_axes(meta: TiffMetadata, declared: str) -> ValidationIssue | (:func:`.metadata.named_axes`, the same rule as :func:`.metadata.header_axes`). Position by position: - - an axis the metadata names (X, Y, Z, C, T) must be declared with that letter; + - an axis the metadata names (X, Y, Z, C, T) must be declared with that letter, except + that the named Y and X may be declared swapped (header ``CYX`` declared ``CXY``): the + image is then read transposed, which :func:`declared_axes_transposed` reports so the + caller can record it (owner decision, 31 October 2026); - a dimension it leaves open may be declared any role: a placeholder axis (``I``, ``Q``), and in a plain stack of pages without axis metadata (``IYX``, ``QYX``, ``QQYX``) the ``Y`` and ``X`` too, which only say where the pages' rows and columns @@ -298,6 +301,40 @@ def check_declared_axes(meta: TiffMetadata, declared: str) -> ValidationIssue | ) +def declared_axes_transposed(meta: TiffMetadata, declared: str) -> bool: + """Return True when a declaration fits the header only with its named Y and X swapped. + + :func:`check_declared_axes` accepts a declaration that differs from the axes the + metadata names only by swapping Y and X (header ``CYX`` declared ``CXY``): the image is + read transposed. A brick that accepts such a declaration records it with this function. + A plain stack's Y and X are not named (:func:`.metadata.named_axes`), so declaring them + the other way round is never a swap. + + Parameters + ---------- + meta + The image's header (:func:`.metadata.inspect_image`). + declared + The declared axes, one letter per dimension (any case). + + Returns + ------- + bool + True when the named Y is declared X (and the named X declared Y). + + Raises + ------ + ValueError + When the declaration does not fit the header at all (:func:`check_declared_axes` + gives an error), with that error's message. + """ + issue = check_declared_axes(meta, declared) + if issue is not None: + raise ValueError(issue.message) + named = named_axes(meta) + return "Y" in named and declared.upper()[named.index("Y")] == "X" + + def _declared_axes_problem(header: str, named: str, declared: str) -> str | None: if not declared or len(declared) != len(header): return f"{len(declared)} declared for {len(header)} dimensions" @@ -310,6 +347,10 @@ def _declared_axes_problem(header: str, named: str, declared: str) -> str | None for index, (have, said) in enumerate(zip(named, declared, strict=True)): if have in (said, OPEN_AXIS): continue + # The named Y and X swapped: with one letter per role, the other position must then + # be swapped too, so this accepts a swap of the two and nothing else. + if have in "YX" and said in "YX": + continue if have == "S" and said == "C" and "C" not in header: continue return f"dimension {index} is {_axis_label(have)} in the header, declared {said}" diff --git a/tests/test_header_axes.py b/tests/test_header_axes.py index 872ba474..6d61d21c 100644 --- a/tests/test_header_axes.py +++ b/tests/test_header_axes.py @@ -12,6 +12,7 @@ PLACEHOLDER_AXES, TiffMetadata, check_declared_axes, + declared_axes_transposed, header_axes, inspect_image, named_axes, @@ -268,8 +269,61 @@ def test_header_axes_are_exactly_what_check_declared_axes_binds(tmp_path, write) # Something is left open, so more than one declaration fits. assert len(accepted) > 1 else: - # Every axis is named, so only the header's own axes fit. - assert accepted == {named} + # Every axis is named, so only the header's own axes fit, or them with Y and X + # swapped (read transposed, and reported as such). + swapped = named.translate(str.maketrans("XY", "YX")) + assert accepted == {named, swapped} + assert [declared_axes_transposed(meta, d) for d in (named, swapped)] == [False, True] + + +def ome(path, data, axes): + tifffile.imwrite(path, data, ome=True, metadata={"axes": axes}) + return path + + +@pytest.mark.parametrize( + ("write", "header", "declared"), + [ + (lambda p: imagej(p, np.zeros((3, 8, 6), np.uint8), "CYX"), "CYX", "CXY"), + (lambda p: imagej(p, np.zeros((3, 8, 6), np.uint8), "ZYX"), "ZYX", "ZXY"), + (lambda p: ome(p, np.zeros((3, 8, 6), np.uint8), "CYX"), "CYX", "cxy"), + (lambda p: write_data(p, np.zeros((3, 8, 6), np.uint8), "CYX"), "CYX", "CXY"), + (lambda p: plain(p, np.zeros((8, 6), np.uint8)), "YX", "XY"), + ], +) +def test_named_y_and_x_declared_swapped_are_read_transposed(tmp_path, write, header, declared): + # Owner decision (31 October 2026): only a swap of the named Y and X is accepted, and + # the caller is told so it can record that the image is read transposed. + path = tmp_path / "a.tif" + write(path) + meta = inspect_image(path, "a.tif") + assert meta.axes == header + assert check_declared_axes(meta, declared) is None + assert declared_axes_transposed(meta, declared) is True + assert declared_axes_transposed(meta, header) is False + + +@pytest.mark.parametrize( + ("header", "declared"), + [ + ("CYX", "YXC"), # the named C moved: not a swap of Y and X + ("ZYX", "CYX"), + ("ZYX", "CXY"), # a swap together with another change + ("ZYX", "XZY"), + ], +) +def test_any_other_disagreement_stays_refused(tmp_path, header, declared): + meta = inspect_image(imagej(tmp_path / "a.tif", np.zeros((3, 8, 6), np.uint8), header)) + assert check_declared_axes(meta, declared).code == "declared_axes_mismatch" + with pytest.raises(ValueError, match="do not fit"): + declared_axes_transposed(meta, declared) + + +def test_a_plain_stack_is_never_reported_transposed(tmp_path): + # Its Y and X are open, not named: there is nothing to swap. + meta = inspect_image(plain(tmp_path / "a.tif", np.zeros((3, 8, 6), np.uint8)), "a.tif") + assert declared_axes_transposed(meta, "CXY") is False + assert declared_axes_transposed(meta, "YXC") is False def test_rgb_samples_may_be_declared_channels(tmp_path): From 9f321df06b0486fd7d7e3666035d07156bd95270 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 13:11:32 +0000 Subject: [PATCH 4/4] Declared axes: a plain stack must still declare Y and X Leaving a plain stack's positional Y and X open let a declaration with no spatial axes at all (QYX declared CTZ) pass. Refuse it by name, and correct the header_axes/contract wording: check_declared_axes accepts the header's axes, them with Y and X swapped, and S declared C, not "no others". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VbytvBRB9CmKQ5AAkhEFcv --- docs/contract.md | 4 ++-- src/morphobricks_preprocessing/axes.py | 8 +++++++- src/morphobricks_preprocessing/metadata.py | 4 +++- tests/test_header_axes.py | 10 ++++++++++ 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/docs/contract.md b/docs/contract.md index f360b167..db878017 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -406,8 +406,8 @@ instead of keeping copies. | Length units (D-a2) | `space_unit(text)`, `to_micrometers(value, unit)`, `SPACE_UNITS`, `UnsupportedUnitError` (`physical_sizes`) | Microscopy units only: `pm`; `Å`/`angstrom`; `nm`; `µm`/`um`/`micron(s)`/`micrometer(s)`/`micrometre(s)`; `mm`; `cm`; `m`; with each name in US and British spelling and in the plural (`nanometre`, `millimeters`, ...). Symbols of two letters or fewer are matched exactly (`Mm` is not `mm`), longer names in any case; ImageJ's `\u00B5` is decoded. `space_unit` returns the OME-NGFF name (`"micrometer"`), None when no unit is given, and refuses any other unit by name (`UnsupportedUnitError`); `to_micrometers` converts a positive finite length, None when the length or its unit is not known. This brick's readers (§2) use the same table: since D86, ImageJ and OME-NGFF sizes in British spellings (`micrometre`) are calibrated, where they were unknown before. | | Plain-TIFF resolution (D-c) | `read_physical_sizes` | A TIFF's resolution tags give a pixel size only with OME or ImageJ metadata and a unit; a plain TIFF (scanner or image-editor dpi) is uncalibrated, never converted from inches or centimetres. | | Labels (D-b) | `check_labels(pixels, name)`, `LARGEST_LABEL` (`masks`) | A mask holds whole-number labels from 0 (background) to `LARGEST_LABEL` = 2,147,483,647: MorphoBricks labels are int32 in every brick. A negative label, a label above the limit, or a pixel type that is not integer or boolean is refused with a `ValueError` naming the mask and the value. `read_mask`, `piece_counts`, `pair_counts` and `scan_masks` call it. Since D86 the limit is int32; before, this brick refused only labels above 2^63 − 1 (int64), so a `uint32` or wider mask with a label of 3,000,000,000 is now refused. | -| Header axes (D-d) | `header_axes(meta)`, `named_axes(meta)`, `series_axes_from_metadata(series)`, `PLACEHOLDER_AXES`, `OPEN_AXIS` (`metadata`) | The axes an image's own metadata names, as `tifffile` reads them with length-1 axes left out: OME, ImageJ and `tifffile` "shaped" metadata (`{"axes": "CYX"}`, decision D-d), a plain 2D `YX` (or `YXS` RGB) TIFF, and an OME-Zarr image's OME-NGFF axes. None when an axis is a placeholder (`I`: a plain stack of pages; `Q`: shaped without axes, or an OME-NGFF axis of another type), or the image does not open: the file does not say which dimension is depth, time or channel. `named_axes` gives the rule position by position, with `?` (`OPEN_AXIS`) for a dimension the metadata does not name: a placeholder, and, in a plain stack of pages without axis metadata (`IYX`, `QYX`, `QQYX`; `TiffMetadata.axes_from_metadata` False, from `series_axes_from_metadata`; True, the strict reading, for a header built by hand), the `Y` and `X` too, which only say where each page's rows and columns are stored (a (Y, X, C) array written without metadata reads as `QYX`). `header_axes` gives axes exactly when nothing is open, and `check_declared_axes` then accepts those axes and no others. | -| Declared axes | `check_declared_axes(meta, declared)`, `declared_axes_transposed(meta, declared)` (`axes`) | Axes a brick or user declares for an image or mask (`image_axes="CYX"`) may name what the header leaves open, never contradict it: one letter per header axis (any case), roles X, Y, Z, C, T each at most once; an axis the metadata names (`named_axes`) keeps its letter, except that the named `Y` and `X` may be declared swapped (header `CYX` declared `CXY`, `ZYX` declared `ZXY`): the image is read transposed, and `declared_axes_transposed` returns True so the caller can record it (owner decision of 31 October 2026; it raises `ValueError` for a declaration that does not fit); any other disagreement is refused; a dimension it leaves open may be declared any role, so a plain `QYX` stack may be declared `YXC` as well as `CYX` (since v0.3.4; before, its positional `Y` and `X` were binding and a placeholder could not be declared X or Y), while an ImageJ `ZYX` declared `CYX`, an OME `CYX` declared `YXC` or a shaped `CYX` declared `YXC` are refused; the RGB samples `S` may be declared C when the header has no C; any other header axis (tile `R`, ...) cannot be declared. Otherwise the error `declared_axes_mismatch` naming the image and both axes (`unreadable_tiff` or `unreadable_ome_zarr` when the header does not open). | +| Header axes (D-d) | `header_axes(meta)`, `named_axes(meta)`, `series_axes_from_metadata(series)`, `PLACEHOLDER_AXES`, `OPEN_AXIS` (`metadata`) | The axes an image's own metadata names, as `tifffile` reads them with length-1 axes left out: OME, ImageJ and `tifffile` "shaped" metadata (`{"axes": "CYX"}`, decision D-d), a plain 2D `YX` (or `YXS` RGB) TIFF, and an OME-Zarr image's OME-NGFF axes. None when an axis is a placeholder (`I`: a plain stack of pages; `Q`: shaped without axes, or an OME-NGFF axis of another type), or the image does not open: the file does not say which dimension is depth, time or channel. `named_axes` gives the rule position by position, with `?` (`OPEN_AXIS`) for a dimension the metadata does not name: a placeholder, and, in a plain stack of pages without axis metadata (`IYX`, `QYX`, `QQYX`; `TiffMetadata.axes_from_metadata` False, from `series_axes_from_metadata`; True, the strict reading, for a header built by hand), the `Y` and `X` too, which only say where each page's rows and columns are stored (a (Y, X, C) array written without metadata reads as `QYX`). `header_axes` gives axes exactly when nothing is open, and `check_declared_axes` then accepts those axes, them with `Y` and `X` swapped, and (without a C axis) `S` declared C; nothing else. | +| Declared axes | `check_declared_axes(meta, declared)`, `declared_axes_transposed(meta, declared)` (`axes`) | Axes a brick or user declares for an image or mask (`image_axes="CYX"`) may name what the header leaves open, never contradict it: one letter per header axis (any case), roles X, Y, Z, C, T each at most once, with `Y` and `X` declared when the header has them (a plain stack too); an axis the metadata names (`named_axes`) keeps its letter, except that the named `Y` and `X` may be declared swapped (header `CYX` declared `CXY`, `ZYX` declared `ZXY`): the image is read transposed, and `declared_axes_transposed` returns True so the caller can record it (owner decision of 31 October 2026; it raises `ValueError` for a declaration that does not fit); any other disagreement is refused; a dimension it leaves open may be declared any role, so a plain `QYX` stack may be declared `YXC` as well as `CYX` (since v0.3.4; before, its positional `Y` and `X` were binding and a placeholder could not be declared X or Y), while an ImageJ `ZYX` declared `CYX`, an OME `CYX` declared `YXC` or a shaped `CYX` declared `YXC` are refused; the RGB samples `S` may be declared C when the header has no C; any other header axis (tile `R`, ...) cannot be declared. Otherwise the error `declared_axes_mismatch` naming the image and both axes (`unreadable_tiff` or `unreadable_ome_zarr` when the header does not open). | | Mask files | `read_mask_file(path, z=, t=)` (`masks`) | Reads one plane of a TIFF mask or of an OME-Zarr label image (`.zarr/labels/`) given by its path, with `read_mask`'s rules (§15): its axes are its own metadata's, Y and X plus at most a Z and a T axis named so; any other or unnamed dimension (a plain stack, `IYX`; a `C` axis) is refused, never taken for depth or time; `z` and `t` are required for a mask with that axis; integer or boolean pixels; labels checked with `check_labels`. A `ValueError` names the file and the problem. | | Identity (D-e) | `FOV_KEY`, `check_identity_text(value, field)`, `check_plane_index(value, axis)`, `check_identity_case(keys)`, `MISSING_TEXT`, `IdentityError` (`discovery`) | `FOV_KEY` = (`condition`, `biological_repeat`, `technical_repeat`, `position_id`), the position key of §1. `check_identity_text` accepts a label or `position_id` that is text without leading or trailing whitespace and not `""`, `NA`, `nan`, `none`, `null`, `n/a` or `` in any case (`MISSING_TEXT`); `check_plane_index` accepts None or an integer of at least 0, a Python `int` or a NumPy integer (returned as a plain `int`), and refuses everything else (bool, float, `"1"`) without converting it. Both raise `IdentityError` (a `ValueError`) naming the field and the value. Discovery applies the text rule to every folder and image name (`invalid_name`) and refuses names of one level that differ only in case anywhere in the experiment (`case_conflict`, §1). `position_id` keeps its own scope: unique within its technical repeat, ignoring case (`duplicate_position_id`). `check_identity_case` applies the same case rule to identities a brick reads from a table, one key per row in `FOV_KEY` order (or the three labels): a label of one level spelled two ways anywhere in the run, or two `position_id` values of one technical repeat equal ignoring case, raise `IdentityError` naming both spellings; the same key may repeat (several planes of one position). | | Sources of a run | `check_sources(manifest, experiment_root, relative_paths, image_axes=, identities=)`, `source_fingerprint`, `file_sha256` (`manifest`) | The checks a brick makes before using a run's table for some images (formerly CellPatcher `check_images` and MorphoFeature `histogram_fit`). Each image must be in the manifest (`images_not_in_manifest`), still exist (`images_missing`), and be unchanged by `source_fingerprint`, its size and modification time, for an OME-Zarr folder its files' total size and newest time, so a rewritten chunk counts (`images_changed`). Its recorded `position_id` must be the one its name gives; with `identities` (`FOV_KEY` order), its labels and `position_id` must be the caller's (`identity_differs`; `position_id_not_recorded` for a manifest written before positions were recorded). With `image_axes`, the caller's axes must give each axis the role recorded for the file (`FileEntry.roles`, or the run's mapping resolved for the header in older manifests); an axis without a role (length 1) fits any letter (`axis_mapping_mismatch`). All errors, naming their images; the table's own hash is checked with `file_sha256` against `csv_sha256`. | diff --git a/src/morphobricks_preprocessing/axes.py b/src/morphobricks_preprocessing/axes.py index 11bc6f96..8cf71c5b 100644 --- a/src/morphobricks_preprocessing/axes.py +++ b/src/morphobricks_preprocessing/axes.py @@ -263,7 +263,8 @@ def check_declared_axes(meta: TiffMetadata, declared: str) -> ValidationIssue | - any other header axis (tile ``R``, angle ``A``, ...) has no role and cannot be declared. - The declaration must have one letter per header axis, each role at most once. + The declaration must have one letter per header axis, each role at most once, and + declare Y and X when the header has them (also where a plain stack leaves them open). Parameters ---------- @@ -344,6 +345,11 @@ def _declared_axes_problem(header: str, named: str, declared: str) -> str | None repeated = sorted({axis for axis in declared if declared.count(axis) > 1}) if repeated: return f"{', '.join(repeated)} declared more than once" + # Even where a plain stack leaves its Y and X open, the image has rows and columns: + # a declaration without them would read no plane at all. + undeclared = [axis for axis in "YX" if axis in header and axis not in declared] + if undeclared: + return f"{' and '.join(undeclared)} {'is' if len(undeclared) == 1 else 'are'} not declared" for index, (have, said) in enumerate(zip(named, declared, strict=True)): if have in (said, OPEN_AXIS): continue diff --git a/src/morphobricks_preprocessing/metadata.py b/src/morphobricks_preprocessing/metadata.py index fefeefe9..9f91a306 100644 --- a/src/morphobricks_preprocessing/metadata.py +++ b/src/morphobricks_preprocessing/metadata.py @@ -238,7 +238,9 @@ def header_axes(meta: TiffMetadata) -> str | None: (:func:`named_axes`: a placeholder axis, so a plain multi-page TIFF ``IYX`` or a shaped one without axes ``QYX``), the file does not say which dimension is depth, time or channel, so there are no header axes and nothing may be guessed. Exactly when this - gives axes, :func:`.axes.check_declared_axes` accepts those axes and no others. + gives axes, :func:`.axes.check_declared_axes` accepts those axes, them with Y and X + swapped (read transposed) and, without a C axis, the RGB samples ``S`` declared C; + nothing else. Length-1 axes are left out, as ``tifffile`` reads them. Parameters diff --git a/tests/test_header_axes.py b/tests/test_header_axes.py index 6d61d21c..1b44f0e6 100644 --- a/tests/test_header_axes.py +++ b/tests/test_header_axes.py @@ -209,6 +209,16 @@ def test_a_plain_stack_still_needs_one_role_per_dimension(tmp_path, declared): assert check_declared_axes(meta, declared).code == "declared_axes_mismatch" +@pytest.mark.parametrize("declared", ["CTZ", "ZCX", "YCT"]) +def test_a_plain_stack_must_still_declare_y_and_x(tmp_path, declared): + # Its Y and X are open, but the image still has rows and columns: a declaration + # without them reads no plane at all. + meta = inspect_image(plain(tmp_path / "a.tif", np.zeros((3, 8, 8), np.uint8)), "a.tif") + issue = check_declared_axes(meta, declared) + assert issue.code == "declared_axes_mismatch" + assert "not declared" in issue.message + + def test_named_axes_stay_binding_beside_a_placeholder(tmp_path): # Axes metadata that names Y and X but leaves one dimension open: only that one is open. path = write_data(tmp_path / "a.tif", np.zeros((3, 8, 8), np.uint8), "QYX")