From b6ce256f0a99e0e56417b9d8d3b9870373ed36e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 11:47:46 +0000 Subject: [PATCH 1/4] Add stain.check_options: pixel-free check of a to_scalar request (#97) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check_options(preset, *, white=None, stain_vectors=None, polarizer=None) makes, without pixels, every check to_scalar makes of the preset and these arguments: unknown preset, the presence rules (needed, unused, polarizer), white (range, shape, masked values before np.asarray) and the stain matrix (names, shape, finiteness, masked values, collinearity, conditioning; the preset's default vectors when none are given). to_scalar runs the same code first and uses its checked white and matrix, so there is one implementation; exception types, messages and outputs are unchanged. Tests call check_options with no image for every refusal, accept a request per preset, compare to_scalar's and check_options' refusals message for message, and show masked inputs are refused before np.asarray. Contract §3.4, CHANGELOG (Unreleased) and the AGENTS.md module map are updated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VbytvF3Gsa85ix8Ne2yMDW --- AGENTS.md | 3 +- CHANGELOG.md | 11 + docs/contract.md | 21 ++ src/morphobricks_normalization/stain.py | 142 +++++++-- tests/test_stain_check_options.py | 401 ++++++++++++++++++++++++ 5 files changed, 545 insertions(+), 33 deletions(-) create mode 100644 tests/test_stain_check_options.py diff --git a/AGENTS.md b/AGENTS.md index 19594b4..8bf2791 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,8 @@ src/morphobricks_normalization/ scopes.py group_key, group_name, scope_groups: groups at each scope, repeat groups rgb.py StarDist's percentile normalization: rgb_percentile, apply_rgb (CSBDeep's float32 arithmetic), check_rgb - stain.py image-to-scalar presets: to_scalar, rgb_to_scalar, PRESETS (catalogue with + stain.py image-to-scalar presets: to_scalar, rgb_to_scalar, check_options (the + pixel-free check of a request, run first by to_scalar), PRESETS (catalogue with modality and consumer hints), white_from_region, stain_vectors_from_regions (optional scikit-image extra `histology`; contract §3.4, N67, N68) optical_density.py optical density against a declared white, stain matrices (normalised, diff --git a/CHANGELOG.md b/CHANGELOG.md index bfd390c..dc3f76b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ Release notes and migration notes of `morphobricks-normalization`. Numbers are p issues of [CellMigrationLab/morphobricks-normalization](https://github.com/CellMigrationLab/morphobricks-normalization). The decisions are in [docs/plan.md](docs/plan.md) §12. +## Unreleased + +- **Pixel-free check of a conversion request** (issue #97, contract §3.4): new + `stain.check_options(preset, *, white=None, stain_vectors=None, polarizer=None) -> None` makes, + without pixels, every check `to_scalar` makes of the preset and these arguments (unknown preset, + needed and unused arguments, polarizer, `white` range and masked values, stain-vector names, + shape, finiteness, masked values, collinearity and conditioning), so consumers can refuse a + request before reading an image. `to_scalar` runs it first (one implementation): exception types, + messages and outputs are unchanged; an argument error is now reported before a pixel error when + both are present. + ## v0.6.0 (2026-11-13) Additive release (issue #94, PR #95; plan N67-N69, contract §3.4): a new `stain` module turns one diff --git a/docs/contract.md b/docs/contract.md index b6e5e36..d50d9ed 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -551,6 +551,27 @@ fibres) that look equally bright can get different values, which can bias detect fibre colours even with a circular polarizer. Hue is not used (later, as a separate feature). +**Checking a request without pixels** (issue #97). `check_options(preset, *, white=None, +stain_vectors=None, polarizer=None) -> None` makes, without any pixel, every check `to_scalar` +makes of the preset and of these arguments, so a consumer (FiberFeatures, MorphoFeature) refuses +an unusable conversion request when planning, before it reads an image: + +- an unknown preset, refused with the known ones listed; +- the presence rules above: each argument in `needs` given, every argument in neither `needs` + nor `accepts` refused (never ignored), `polarizer` one of `POLARIZERS`; +- `white`: three finite values in `(0, 255]` (`custom_od` below); a masked array (even with an + all-false mask) and a list or tuple holding a masked value (`np.ma.masked`) are refused with a + `TypeError` before `np.asarray` would drop the mask; +- `stain_vectors` (`custom_od`; the preset's default vectors when not given): the names, three + finite numbers per vector, a norm of at least 1e-6, masked values refused as for `white`, two + vectors closer than 5 degrees in either direction refused as collinear, a condition number + above 50 refused (`custom_od` below). + +It raises the exceptions `to_scalar` raises for these inputs, with the same messages, and +returns `None` when the request is usable. `to_scalar` runs the same code first, so there is one +implementation: an argument error is reported before any pixel or `axes` check, and only the +pixels and `axes` are checked after it. `check_options` needs NumPy only, whatever the preset. + **RGB inputs (refused, never repaired).** `axes` is exactly `"YXC"` or `"CYX"`, with no default; the array is 3-D with exactly three samples (R, G, B) on the colour axis (an alpha channel, or any other count, is refused with a message, never dropped); the dtype is `uint8` (8-bit sRGB diff --git a/src/morphobricks_normalization/stain.py b/src/morphobricks_normalization/stain.py index 859a1d5..09359b6 100644 --- a/src/morphobricks_normalization/stain.py +++ b/src/morphobricks_normalization/stain.py @@ -37,9 +37,12 @@ Nothing is reordered, rescaled or repaired silently, and the input array is never modified. Arguments a preset does not use (``white`` for ``he_eosin``, ``polarizer`` for ``lab_a``) are -refused, never ignored. ``psr_polarized`` needs ``polarizer`` (``"circular"`` or ``"linear"``): -a linear polarizer darkens fibres at some angles to its axis, so the provenance hints record -``orientation_valid = False`` for it and consumers must refuse orientation features then. +refused, never ignored. :func:`check_options` makes every check of the preset and of these +arguments without pixels, so a consumer can refuse a conversion before reading an image; +:func:`to_scalar` runs it first (one implementation, the same messages). ``psr_polarized`` +needs ``polarizer`` (``"circular"`` or ``"linear"``): a linear polarizer darkens fibres at some +angles to its axis, so the provenance hints record ``orientation_valid = False`` for it and +consumers must refuse orientation features then. Each preset carries consumer ``hints`` (``polarity``, ``scaling``, ``mask``, ``measures``): recommendations for consumers such as FiberFeatures (#214), recorded in the provenance and @@ -115,13 +118,37 @@ def _skimage_color() -> tuple[ModuleType, ModuleType]: return skimage, color -def _white_rescaled(rgb: np.ndarray, white: object) -> tuple[np.ndarray, dict[str, Any]]: - """Rescale ``rgb`` to a declared ``white`` if given; return it and the provenance entries.""" - if white is None: +@dataclass(frozen=True) +class _Options: + """The checked options of one conversion (:func:`_checked_options`). + + Attributes + ---------- + recipe + The preset. + white + The checked white as float64 ``(3,)``, or ``None`` if not given. + matrix + ``custom_od`` only: the checked stain matrix (user or default vectors), else ``None``. + vectors_source, vectors_citation + ``custom_od`` only: :data:`USER` or :data:`PRESET_DEFAULT`, and the default's citation. + """ + + recipe: StainPreset + white: np.ndarray | None + matrix: od.StainMatrix | None + vectors_source: str | None + vectors_citation: str | None + + +def _white_rescaled( + rgb: np.ndarray, values: np.ndarray | None +) -> tuple[np.ndarray, dict[str, Any]]: + """Rescale ``rgb`` to a checked white if given; return it and the provenance entries.""" + if values is None: entries = {"white": None, "white_applied": False, "white_rescale": None} entries |= {"white_clipped_fraction": None, "above_white_fraction": None} return rgb, entries - values = od.check_white(white) rescaled, clipped = od.rescale_to_white(rgb, values) entries = { "white": tuple(values.tolist()), @@ -156,20 +183,10 @@ def _convert(rgb: np.ndarray, preset: StainPreset) -> tuple[np.ndarray, np.ndarr return as_float32, excluded, skimage.__version__ -def _custom_od( - rgb: np.ndarray, preset: StainPreset, white: object, stain_vectors: object -) -> tuple[np.ndarray, np.ndarray, dict[str, Any]]: - """Unmix ``rgb`` with the declared white and vectors; return image, exclusion, provenance. - - Without ``stain_vectors`` the preset's published defaults are used (checked as user - vectors are); a preset without defaults never gets here without them. - """ - white_values = od.check_white(white) - source, citation = USER, None - if stain_vectors is None: - stain_vectors = preset.default_stain_vectors - source, citation = PRESET_DEFAULT, preset.default_stain_vectors_citation - matrix = od.stain_matrix(stain_vectors, preset.target_stain, preset.exclusion_stain) +def _custom_od(rgb: np.ndarray, options: _Options) -> tuple[np.ndarray, np.ndarray, dict[str, Any]]: + """Unmix ``rgb`` with the checked white and matrix; return image, exclusion, provenance.""" + white_values, matrix = options.white, options.matrix + source, citation = options.vectors_source, options.vectors_citation density, floored = od.optical_density(rgb, white_values) amounts = od.concentrations(density, matrix) image = np.ascontiguousarray(amounts[..., 0], dtype=np.float32) @@ -205,6 +222,69 @@ def _check_arguments(recipe: StainPreset, given: Mapping[str, Any]) -> None: raise ValueError(f"polarizer must be 'circular' or 'linear'; got {polarizer!r}.") +def _checked_options( + preset: str, white: object, stain_vectors: object, polarizer: object +) -> _Options: + """Make every check of :func:`check_options` and return the checked values. + + The order among these checks is the one :func:`to_scalar` had before #97 (the preset, the + presence rules, ``white``, then the stain matrix); they now all come before the pixels. + Without ``stain_vectors`` a ``custom_od`` preset's published defaults are checked as user + vectors are; a preset without defaults never gets here without them (the presence rules + refuse it). + """ + if preset not in PRESETS: + raise ValueError(f"Unknown preset {preset!r}; known presets: {', '.join(PRESETS)}.") + recipe = PRESETS[preset] + given = {"white": white, "stain_vectors": stain_vectors, "polarizer": polarizer} + _check_arguments(recipe, given) + values = None if white is None else od.check_white(white) + matrix, source, citation = None, None, None + if recipe.method == "custom_od": + source = USER + if stain_vectors is None: + stain_vectors = recipe.default_stain_vectors + source, citation = PRESET_DEFAULT, recipe.default_stain_vectors_citation + matrix = od.stain_matrix(stain_vectors, recipe.target_stain, recipe.exclusion_stain) + return _Options(recipe, values, matrix, source, citation) + + +def check_options( + preset: str, + *, + white: object = None, + stain_vectors: Mapping[str, Any] | None = None, + polarizer: str | None = None, +) -> None: + """Check a conversion request without pixels: every check :func:`to_scalar` makes of these. + + A consumer calls it when planning, before reading any image, so that an unusable request + (``white`` for ``he_eosin``, an unknown polarizer, collinear stain vectors) is refused + before any pixel is read. :func:`to_scalar` runs the same checks first, with the same + exceptions and messages; it then checks only the pixels and ``axes``. + + Parameters + ---------- + preset + A key of :data:`PRESETS`. + white, stain_vectors, polarizer + As for :func:`to_scalar`. + + Raises + ------ + ValueError + Unknown preset (the known ones are listed); a needed argument missing; an argument the + preset does not use given (refused, never ignored); a polarizer not in + :data:`POLARIZERS`; ``white`` not three finite values in ``(0, 255]``; stain vectors + with missing or other names, not three finite numbers, of (almost) zero length, closer + than 5 degrees (either sign) or with a condition number above 50 (contract §3.4). + TypeError + ``white`` or a stain vector is a masked array or holds a masked value (refused before + ``np.asarray`` would drop the mask); ``stain_vectors`` is not a mapping. + """ + _checked_options(preset, white, stain_vectors, polarizer) + + def _provenance( recipe: StainPreset, pixels: np.ndarray, axes: str, extra: Mapping[str, Any] ) -> Mapping[str, Any]: @@ -285,29 +365,27 @@ def to_scalar( ------ ValueError Unknown preset; wrong axes, shape or values; a missing, unused or invalid optional - argument; stain vectors that are missing, extra, collinear or ill-conditioned. + argument; stain vectors that are missing, extra, collinear or ill-conditioned. The + checks of :func:`check_options` come first, before any pixel is looked at. TypeError - ``pixels`` is not a NumPy array or has a refused dtype. + ``pixels`` is not a NumPy array or has a refused dtype; a masked optional argument + (as for :func:`check_options`). ImportError The preset needs scikit-image and it is not installed. """ - if preset not in PRESETS: - raise ValueError(f"Unknown preset {preset!r}; known presets: {', '.join(PRESETS)}.") - recipe = PRESETS[preset] - given = {"white": white, "stain_vectors": stain_vectors, "polarizer": polarizer} - _check_arguments(recipe, given) + # check_options' checks, before any pixel is looked at (#97); their values are used below. + options = _checked_options(preset, white, stain_vectors, polarizer) + recipe = options.recipe exclusion = None if recipe.method == "passthrough": image, extra = inputs.plane_float32(pixels, axes) elif recipe.method == "custom_od": - image, exclusion, extra = _custom_od( - inputs.rgb_yxc(pixels, axes), recipe, white, stain_vectors - ) + image, exclusion, extra = _custom_od(inputs.rgb_yxc(pixels, axes), options) else: rgb = inputs.rgb_yxc(pixels, axes) white_entries: dict[str, Any] = {} if "white" in recipe.accepts: - rgb, white_entries = _white_rescaled(rgb, white) + rgb, white_entries = _white_rescaled(rgb, options.white) image, exclusion, version = _convert(rgb, recipe) clipped = float(np.mean(image == 0)) if recipe.method == "fixed_deconvolution" else None extra = {"skimage_version": version, "zero_fraction": clipped, **white_entries} diff --git a/tests/test_stain_check_options.py b/tests/test_stain_check_options.py new file mode 100644 index 0000000..d44e3e3 --- /dev/null +++ b/tests/test_stain_check_options.py @@ -0,0 +1,401 @@ +"""check_options: every to_scalar check of the preset and its options, without pixels (#97).""" + +import math +import subprocess +import sys +import textwrap + +import numpy as np +import pytest + +from morphobricks_normalization import stain +from morphobricks_normalization.stain import PRESETS, check_options, to_scalar + +WHITE = (240.0, 235.0, 245.0) +PSR_VECTORS = {"psr": (0.10, 0.80, 0.55), "counterstain": (0.65, 0.70, 0.29)} +HE_VECTORS = {"eosin": (0.07, 0.99, 0.11), "hematoxylin": (0.65, 0.70, 0.29)} +COLLINEAR = math.radians(stain.od.MIN_ANGLE_DEGREES - 0.5) +NEAR_PLANE = (1.0, 1.0, 0.02) # 45 degrees from x and y, but almost in their plane + + +def psr(**vectors): + """psr_brightfield options with the given stain vectors.""" + return {"white": WHITE, "stain_vectors": vectors} + + +# (preset, options, exception, message pattern): every refusal check_options shares with +# to_scalar. The messages are the ones to_scalar gave before #97 (test_messages_are_unchanged). +REFUSALS = { + "unknown preset": ( + "he_eosine", + {}, + ValueError, + r"Unknown preset 'he_eosine'; known presets: he_eosin, ", + ), + "white missing (custom_od)": ("he_custom", {}, ValueError, "needs white; it has no default"), + "vectors missing (psr)": ( + "psr_brightfield", + {"white": WHITE}, + ValueError, + "needs stain_vectors", + ), + "polarizer missing": ("psr_polarized", {}, ValueError, "needs polarizer"), + "white unused (he_eosin)": ("he_eosin", {"white": WHITE}, ValueError, "does not use white"), + "white unused (passthrough)": ( + "fluorescence", + {"white": WHITE}, + ValueError, + "does not use white", + ), + "polarizer unused (lab_a)": ( + "lab_a", + {"polarizer": "circular"}, + ValueError, + "does not use polarizer", + ), + "vectors unused (trichrome_blue)": ( + "trichrome_blue", + {"stain_vectors": PSR_VECTORS}, + ValueError, + "does not use stain_vectors", + ), + "vectors unused (luminance)": ( + "luminance_inverted", + {"stain_vectors": PSR_VECTORS}, + ValueError, + "does not use stain_vectors", + ), + "polarizer unused (he_custom)": ( + "he_custom", + {"white": WHITE, "polarizer": "linear"}, + ValueError, + "does not use polarizer", + ), + "unknown polarizer": ( + "psr_polarized", + {"polarizer": "crossed"}, + ValueError, + "'circular' or 'linear'", + ), + "polarizer wrong case": ( + "psr_polarized", + {"polarizer": "Circular"}, + ValueError, + "'circular' or 'linear'", + ), + "white zero": ("he_custom", {"white": (0, 200, 200)}, ValueError, r"finite and in \(0, 255\]"), + "white above 255": ( + "lab_a", + {"white": (256, 200, 200)}, + ValueError, + r"finite and in \(0, 255\]", + ), + "white negative": ( + "psr_polarized", + {"white": (-1, 200, 200), "polarizer": "circular"}, + ValueError, + r"\(0, 255\]", + ), + "white nan": ( + "trichrome_custom", + {"white": (np.nan, 200, 200)}, + ValueError, + r"finite and in \(0, 255\]", + ), + "white inf": ( + "luminance_inverted", + {"white": (np.inf, 1, 1)}, + ValueError, + r"finite and in \(0, 255\]", + ), + "white two values": ( + "he_custom", + {"white": (200, 200)}, + ValueError, + "white must be three numbers", + ), + "white text": ( + "he_custom", + {"white": ("a", "b", "c")}, + ValueError, + "white must be three numbers", + ), + "white scalar": ("trichrome_green", {"white": 240}, ValueError, "white must be three numbers"), + "white masked array": ( + "he_custom", + {"white": np.ma.MaskedArray(WHITE, mask=[False, True, False])}, + TypeError, + "masked", + ), + "white all-false mask": ( + "lab_a", + {"white": np.ma.MaskedArray(WHITE, mask=False)}, + TypeError, + "white is a masked array", + ), + "white list holding masked": ( + "psr_brightfield", + {"white": [240, np.ma.masked, 245], "stain_vectors": PSR_VECTORS}, + TypeError, + "white is a masked array", + ), + "white tuple holding masked": ( + "luminance_inverted", + {"white": (np.ma.masked, 235, 245)}, + TypeError, + "white is a masked array", + ), + "vectors not a mapping": ( + "psr_brightfield", + {"white": WHITE, "stain_vectors": [(1, 0, 0), (0, 1, 0)]}, + TypeError, + "mapping", + ), + "vector name missing": ( + "psr_brightfield", + psr(psr=(1, 0, 0)), + ValueError, + r"missing \['counterstain'\]", + ), + "vector name extra": ( + "psr_brightfield", + psr(**PSR_VECTORS, dab=(0, 0, 1)), + ValueError, + r"not allowed \['dab'\]", + ), + "vector two values": ( + "psr_brightfield", + psr(psr=(1, 0), counterstain=(0, 1, 0)), + ValueError, + "three numbers", + ), + "vector text": ( + "he_custom", + {"white": WHITE, "stain_vectors": {**HE_VECTORS, "eosin": "red"}}, + ValueError, + "three numbers", + ), + "vector nan": ( + "psr_brightfield", + psr(psr=(np.nan, 1, 0), counterstain=(0, 1, 0)), + ValueError, + "not finite", + ), + "vector inf": ( + "trichrome_custom", + { + "white": WHITE, + "stain_vectors": {"collagen": (np.inf, 0, 0), "ponceau_fuchsin": (0, 1, 0)}, + }, + ValueError, + "not finite", + ), + "vector zero length": ( + "psr_brightfield", + psr(psr=(0, 0, 0), counterstain=(0, 1, 0)), + ValueError, + "zero length", + ), + "vector masked array": ( + "psr_brightfield", + psr(psr=np.ma.MaskedArray((1.0, 0, 0), mask=False), counterstain=(0, 1, 0)), + TypeError, + "Stain vector 'psr' is a masked array", + ), + "vector list holding masked": ( + "he_custom", + {"white": WHITE, "stain_vectors": {**HE_VECTORS, "hematoxylin": [0.6, np.ma.masked, 0.3]}}, + TypeError, + "Stain vector 'hematoxylin' is a masked array", + ), + "residual masked": ( + "psr_brightfield", + psr(**PSR_VECTORS, residual=np.ma.MaskedArray((0, 0, 1.0), mask=True)), + TypeError, + "Stain vector 'residual' is a masked array", + ), + "collinear": ( + "psr_brightfield", + psr(psr=(1, 0, 0), counterstain=(math.cos(COLLINEAR), math.sin(COLLINEAR), 0)), + ValueError, + "4.50 degrees apart", + ), + "collinear opposite sign": ( + "psr_brightfield", + psr(psr=(1, 0, 0), counterstain=(-math.cos(COLLINEAR), -math.sin(COLLINEAR), 0)), + ValueError, + "4.50 degrees apart", + ), + "collinear identical": ( + "he_custom", + {"white": WHITE, "stain_vectors": {"eosin": (0, 1, 0), "hematoxylin": (0, 2, 0)}}, + ValueError, + "collinear", + ), + "residual collinear": ( + "psr_brightfield", + psr(**PSR_VECTORS, residual=(0.20, 1.60, 1.10)), + ValueError, + "'psr' and 'residual'", + ), + "ill-conditioned": ( + "psr_brightfield", + psr(psr=(1, 0, 0), counterstain=(0, 1, 0), residual=NEAR_PLANE), + ValueError, + "ill-conditioned", + ), +} +CASES = [pytest.param(*case, id=name) for name, case in REFUSALS.items()] + + +def valid_pixels(preset): + """Pixels to_scalar accepts for ``preset``, so only the options can be refused.""" + rng = np.random.default_rng(3) + if PRESETS[preset].method == "passthrough": + return rng.normal(100, 10, (5, 6)), "YX" + return rng.integers(1, 256, (5, 6, 3), dtype=np.uint8), "YXC" + + +@pytest.mark.parametrize(("preset", "options", "error", "message"), CASES) +def test_check_options_refuses_without_pixels(preset, options, error, message): + with pytest.raises(error, match=message): + check_options(preset, **options) + + +@pytest.mark.parametrize(("preset", "options", "error", "message"), CASES) +def test_to_scalar_refuses_the_same_inputs_with_the_same_message(preset, options, error, message): + pixels, axes = valid_pixels(preset if preset in PRESETS else "fluorescence") + with pytest.raises(error) as planned: + check_options(preset, **options) + with pytest.raises(error) as converted: + to_scalar(pixels, axes=axes, preset=preset, **options) + assert type(converted.value) is type(planned.value) + assert str(converted.value) == str(planned.value) + + +def test_the_unknown_preset_message_lists_every_known_preset(): + with pytest.raises(ValueError) as refused: + check_options("nope") + assert str(refused.value) == f"Unknown preset 'nope'; known presets: {', '.join(PRESETS)}." + + +# One accepted request per preset family; check_options returns None and needs no pixels. +ACCEPTED = [ + ("fluorescence", {}), + ("shg", {}), + ("he_eosin", {}), + ("he_hematoxylin", {}), + ("trichrome_blue", {}), + ("luminance_inverted", {}), + ("luminance_inverted", {"white": [250, 251, 252.5]}), + ("lab_a", {"white": np.array([255, 255, 255], dtype=np.uint8)}), + ("trichrome_green", {}), + ("psr_polarized", {"polarizer": "circular"}), + ("psr_polarized", {"polarizer": "linear", "white": WHITE}), + ("psr_brightfield", {"white": WHITE, "stain_vectors": PSR_VECTORS}), + ( + "psr_brightfield", + {"white": WHITE, "stain_vectors": {**PSR_VECTORS, "residual": (0.3, 0.2, 0.9)}}, + ), + ("he_custom", {"white": WHITE}), + ("he_custom", {"white": WHITE, "stain_vectors": HE_VECTORS}), + ("trichrome_custom", {"white": (255, 255, 255)}), + ( + "trichrome_custom", + { + "white": WHITE, + "stain_vectors": {"collagen": (0.8, 0.59, 0.11), "ponceau_fuchsin": (0.1, 0.74, 0.67)}, + }, + ), +] + + +@pytest.mark.parametrize(("preset", "options"), ACCEPTED) +def test_check_options_accepts_a_usable_request(preset, options): + assert check_options(preset, **options) is None + + +def test_every_preset_has_an_accepted_case(): + assert {preset for preset, _ in ACCEPTED} == set(PRESETS) + + +@pytest.mark.parametrize( + "white", + [ + np.ma.MaskedArray(WHITE, mask=False), + np.ma.MaskedArray(WHITE, mask=[True, False, False]), + [240, np.ma.masked, 245], + (240, 235, np.ma.masked), + ], +) +@pytest.mark.parametrize("preset", ["he_custom", "lab_a"]) +def test_a_masked_white_is_refused_before_np_asarray(monkeypatch, preset, white): + def asarray(*args, **kwargs): + raise AssertionError("np.asarray was called on a masked white") + + monkeypatch.setattr(np, "asarray", asarray) + with pytest.raises(TypeError, match="white is a masked array"): + check_options(preset, white=white) + + +@pytest.mark.parametrize( + "vector", + [np.ma.MaskedArray((0.65, 0.70, 0.29), mask=False), [0.65, np.ma.masked, 0.29]], +) +def test_a_masked_stain_vector_is_refused_before_np_asarray(monkeypatch, vector): + real_asarray = np.asarray + + def asarray(value, *args, **kwargs): + if isinstance(value, np.ma.MaskedArray) or ( + isinstance(value, list) and any(v is np.ma.masked for v in value) + ): + raise AssertionError("np.asarray was called on a masked stain vector") + return real_asarray(value, *args, **kwargs) + + monkeypatch.setattr(np, "asarray", asarray) + vectors = {"psr": (0.10, 0.80, 0.55), "counterstain": vector} + with pytest.raises(TypeError, match="Stain vector 'counterstain' is a masked array"): + check_options("psr_brightfield", white=WHITE, stain_vectors=vectors) + + +def test_to_scalar_checks_the_options_before_the_pixels(): + # Neither the pixels nor the axes are usable; the option is refused first. + with pytest.raises(ValueError, match="does not use white"): + to_scalar(None, axes="??", preset="he_eosin", white=WHITE) + with pytest.raises(ValueError, match="'circular' or 'linear'"): + to_scalar(np.zeros((2, 2, 4), np.uint16), axes="YXC", preset="psr_polarized", polarizer="x") + + +def test_check_options_leaves_its_arguments_unchanged(): + white = np.array(WHITE) + vectors = {"psr": np.array((0.1, 0.8, 0.55)), "counterstain": np.array((0.65, 0.7, 0.29))} + copies = white.copy(), {k: v.copy() for k, v in vectors.items()} + check_options("psr_brightfield", white=white, stain_vectors=vectors) + np.testing.assert_array_equal(white, copies[0]) + assert vectors.keys() == copies[1].keys() + for name, values in vectors.items(): + np.testing.assert_array_equal(values, copies[1][name]) + + +def test_check_options_needs_no_scikit_image(): + code = textwrap.dedent( + """ + import sys + sys.modules["skimage"] = None + sys.modules["skimage.color"] = None + from morphobricks_normalization.stain import check_options + check_options("he_eosin") + check_options("psr_polarized", polarizer="circular", white=(250, 250, 250)) + check_options("lab_a", white=(250, 250, 250)) + try: + check_options("he_eosin", white=(250, 250, 250)) + except ValueError as error: + print(error) + assert "skimage" not in sys.modules or sys.modules["skimage"] is None + """ + ) + result = subprocess.run( + [sys.executable, "-c", code], capture_output=True, text=True, check=False + ) + assert result.returncode == 0, result.stderr + assert "does not use white" in result.stdout From a731a755e656a7447ce74ca3bf8dd48846d75ac6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 12:18:04 +0000 Subject: [PATCH 2/4] check_options: refuse non-str polarizers and nested masked values; freeze v0.6.0 parity (#97) Fixes the three review blockers of PR #98. B1. _check_arguments now requires a str polarizer before the membership test. np.array(["linear"]) passed `in POLARIZERS` element-wise and made orientation_valid a NumPy array; it is now refused with a ValueError naming the type (numpy.ndarray, bytes, int, list). None still gets the "needs polarizer" message. B2. optical_density._refuse_masked searches nested lists, tuples and object arrays (np.ma.masked or a masked array at any depth), so white and every stain vector (residual included) with masked content are refused as masked before np.asarray. v0.6.0 refused these later as "not three numbers" or an inhomogeneous sequence; the CHANGELOG lists this as a safety fix to the refusal-parity claim. B3. tests/data/stain_v060_parity.json, recorded by tests/stain_parity_cases.py on 95e1d6d (v0.6.0) in a temporary worktree, freezes the full exception type and message of the 40 refusals and the SHA-256 of image, exclusion and canonical provenance of 33 conversions. check_options and to_scalar must match the messages exactly; the 11 intentionally changed inputs (B1, B2) are listed with their v0.6.0 outcome and their new one. Default stain vectors are proven to go through the user-vector checks by replacing he_custom's defaults in a copy of PRESETS. The stale test_messages_are_unchanged comment is gone. test_stain_check_options.py joins the CI histology job so the scikit-image conversions run there without skips. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VbytvF3Gsa85ix8Ne2yMDW --- .github/workflows/ci.yml | 2 +- AGENTS.md | 4 +- CHANGELOG.md | 21 +- docs/contract.md | 13 +- .../optical_density.py | 28 +- src/morphobricks_normalization/stain.py | 30 +- tests/data/stain_v060_parity.json | 380 ++++++++++++++ tests/stain_parity_cases.py | 316 ++++++++++++ tests/test_stain_check_options.py | 470 +++++++++--------- 9 files changed, 1006 insertions(+), 258 deletions(-) create mode 100644 tests/data/stain_v060_parity.json create mode 100644 tests/stain_parity_cases.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 723dc0a..a108b05 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,4 +69,4 @@ jobs: with: python-version: "3.12" - run: python -m pip install -e ".[dev,histology]" - - run: python -m pytest tests/test_stain.py tests/test_stain_catalogue.py tests/test_stain_defaults.py tests/test_optical_density.py -rs + - run: python -m pytest tests/test_stain.py tests/test_stain_catalogue.py tests/test_stain_defaults.py tests/test_stain_check_options.py tests/test_optical_density.py -rs diff --git a/AGENTS.md b/AGENTS.md index 8bf2791..4da6466 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,7 +76,9 @@ src/morphobricks_normalization/ from_columns checks against the settings issues.py Issue: warnings returned to the calling brick (channel for the checks) tests/ pytest with simulated planes (tests/simulation.py); tests/data holds - the golden pixels and fitted parameters of tests/test_fit_goldens.py + the golden pixels and fitted parameters of tests/test_fit_goldens.py and + stain_v060_parity.json, the v0.6.0 refusals and output digests of + tests/test_stain_check_options.py (recorded by tests/stain_parity_cases.py) tools/check.py the release gate: ruff format, pytest and the code standards (design v0.4 §9); `--rules` runs only the standards (CI); tools/code_rules.py is the shared copy (SHA-256 pinned in tests/test_code_rules.py), tools/code_rules_baseline.json diff --git a/CHANGELOG.md b/CHANGELOG.md index dc3f76b..e5bfcc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,24 @@ The decisions are in [docs/plan.md](docs/plan.md) §12. without pixels, every check `to_scalar` makes of the preset and these arguments (unknown preset, needed and unused arguments, polarizer, `white` range and masked values, stain-vector names, shape, finiteness, masked values, collinearity and conditioning), so consumers can refuse a - request before reading an image. `to_scalar` runs it first (one implementation): exception types, - messages and outputs are unchanged; an argument error is now reported before a pixel error when - both are present. + request before reading an image. `to_scalar` runs it first (one implementation): outputs are + unchanged (33 conversions pinned by SHA-256 against v0.6.0), and so are the exception types and + full messages of the 40 refusals frozen from v0.6.0 in `tests/data/stain_v060_parity.json`, apart + from the two safety fixes below; an argument error is now reported before a pixel error when both + are present. +- **Safety fixes to the refusal parity** (PR #98 review). These inputs change classification on + purpose; each is listed with its v0.6.0 outcome in the frozen table: + - a `polarizer` that is not a `str` is refused with a `ValueError` naming its type, before the + membership test. v0.6.0 accepted `np.array(["linear"])` (element-wise comparison) and recorded + `orientation_valid` as a NumPy array, and raised NumPy's "truth value is ambiguous" + `ValueError` for a longer array; for bytes, numbers and lists it already raised a `ValueError`, + and only the message changes (it now names the type); + - masked content (`np.ma.masked` or a masked array) at any depth of nested lists, tuples or object + arrays, in `white` or a stain vector (residual included), is refused as masked (`TypeError`) + before `np.asarray`. v0.6.0 checked only a top-level masked array and the direct items of a list + or tuple, so these inputs reached `np.asarray` and were refused later as not three numbers or as + an inhomogeneous sequence (NumPy turned a nested `np.ma.masked` into NaN on the way). The same + check covers `stain_vectors_from_regions`' `white`. ## v0.6.0 (2026-11-13) diff --git a/docs/contract.md b/docs/contract.md index d50d9ed..86753fe 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -558,17 +558,22 @@ an unusable conversion request when planning, before it reads an image: - an unknown preset, refused with the known ones listed; - the presence rules above: each argument in `needs` given, every argument in neither `needs` - nor `accepts` refused (never ignored), `polarizer` one of `POLARIZERS`; + nor `accepts` refused (never ignored), `polarizer` a `str` (anything else, an array, bytes or + a list included, is refused with a `ValueError` naming its type, never compared) and one of + `POLARIZERS`; - `white`: three finite values in `(0, 255]` (`custom_od` below); a masked array (even with an - all-false mask) and a list or tuple holding a masked value (`np.ma.masked`) are refused with a - `TypeError` before `np.asarray` would drop the mask; + all-false mask), and a masked value (`np.ma.masked` or a masked array) anywhere inside nested + lists, tuples or object arrays, are refused with a `TypeError` before `np.asarray` would drop + the mask; - `stain_vectors` (`custom_od`; the preset's default vectors when not given): the names, three finite numbers per vector, a norm of at least 1e-6, masked values refused as for `white`, two vectors closer than 5 degrees in either direction refused as collinear, a condition number above 50 refused (`custom_od` below). It raises the exceptions `to_scalar` raises for these inputs, with the same messages, and -returns `None` when the request is usable. `to_scalar` runs the same code first, so there is one +returns `None` when the request is usable. The types and messages are those of v0.6.0 except for +the two safety fixes of PR #98 (a polarizer that is not a `str`; masked content nested in object +arrays or sequences), which v0.6.0 accepted or refused later with another message. `to_scalar` runs the same code first, so there is one implementation: an argument error is reported before any pixel or `axes` check, and only the pixels and `axes` are checked after it. `check_options` needs NumPy only, whatever the preset. diff --git a/src/morphobricks_normalization/optical_density.py b/src/morphobricks_normalization/optical_density.py index 174adce..905ef5c 100644 --- a/src/morphobricks_normalization/optical_density.py +++ b/src/morphobricks_normalization/optical_density.py @@ -90,10 +90,32 @@ def array(self) -> np.ndarray: return np.array(self.rows, dtype=np.float64) +def _holds_masked(value: object) -> bool: + """Return whether ``value`` is or holds, at any depth, a masked array or ``np.ma.masked``. + + Lists, tuples and object arrays are searched element by element (``np.ma.masked`` is a + masked array too); numeric arrays cannot hold a masked value. Each container is visited + once, so a list that holds itself ends the search. + """ + pending, seen = [value], set() + while pending: + item = pending.pop() + if isinstance(item, np.ma.MaskedArray): + return True + is_sequence = isinstance(item, (list, tuple)) + is_object_array = isinstance(item, np.ndarray) and item.dtype == object + if (is_sequence or is_object_array) and id(item) not in seen: + seen.add(id(item)) + pending.extend(item.flat if is_object_array else item) + return False + + def _refuse_masked(value: object, what: str) -> None: - """Refuse a masked array, or a sequence holding a masked value: ``np.asarray`` drops masks.""" - items = value if isinstance(value, (list, tuple)) else () - if isinstance(value, np.ma.MaskedArray) or any(isinstance(v, np.ma.MaskedArray) for v in items): + """Refuse a masked array or any masked content, before ``np.asarray`` drops the mask. + + Masked content is found in nested lists and tuples and in object arrays too (PR #98, B2). + """ + if _holds_masked(value): raise TypeError( f"{what} is a masked array (or holds a masked value): its mask would be dropped " "silently. Pass plain numbers." diff --git a/src/morphobricks_normalization/stain.py b/src/morphobricks_normalization/stain.py index 09359b6..4016efa 100644 --- a/src/morphobricks_normalization/stain.py +++ b/src/morphobricks_normalization/stain.py @@ -208,7 +208,7 @@ def _custom_od(rgb: np.ndarray, options: _Options) -> tuple[np.ndarray, np.ndarr def _check_arguments(recipe: StainPreset, given: Mapping[str, Any]) -> None: - """Refuse a missing required argument, an unused one, or an unknown polarizer.""" + """Refuse a missing required argument, an unused one, or a polarizer not in POLARIZERS.""" for name in OPTIONAL_ARGUMENTS: if name in recipe.needs and given[name] is None: raise ValueError(f"Preset {recipe.id!r} needs {name}; it has no default.") @@ -218,7 +218,21 @@ def _check_arguments(recipe: StainPreset, given: Mapping[str, Any]) -> None: f"Preset {recipe.id!r} does not use {name}; it is refused rather than ignored." ) polarizer = given["polarizer"] - if "polarizer" in recipe.needs and polarizer not in POLARIZERS: + if "polarizer" not in recipe.needs: + return + # A str first: an array would compare element-wise, so np.array(["linear"]) would pass the + # membership test and make orientation_valid an array (PR #98, B1). + if not isinstance(polarizer, str): + kind = type(polarizer) + type_name = ( + kind.__qualname__ + if kind.__module__ == "builtins" + else f"{kind.__module__}.{kind.__qualname__}" + ) + raise ValueError( + f"polarizer must be the text 'circular' or 'linear'; got {type_name} {polarizer!r}." + ) + if polarizer not in POLARIZERS: raise ValueError(f"polarizer must be 'circular' or 'linear'; got {polarizer!r}.") @@ -275,12 +289,14 @@ def check_options( ValueError Unknown preset (the known ones are listed); a needed argument missing; an argument the preset does not use given (refused, never ignored); a polarizer not in - :data:`POLARIZERS`; ``white`` not three finite values in ``(0, 255]``; stain vectors - with missing or other names, not three finite numbers, of (almost) zero length, closer - than 5 degrees (either sign) or with a condition number above 50 (contract §3.4). + :data:`POLARIZERS` or not a ``str`` (an array, bytes or a list is refused with its type + named, never compared); ``white`` not three finite values in ``(0, 255]``; stain + vectors with missing or other names, not three finite numbers, of (almost) zero length, + closer than 5 degrees (either sign) or with a condition number above 50 (contract §3.4). TypeError - ``white`` or a stain vector is a masked array or holds a masked value (refused before - ``np.asarray`` would drop the mask); ``stain_vectors`` is not a mapping. + ``white`` or a stain vector is a masked array or holds a masked value, at any depth of + nested lists, tuples or object arrays (refused before ``np.asarray`` would drop the + mask); ``stain_vectors`` is not a mapping. """ _checked_options(preset, white, stain_vectors, polarizer) diff --git a/tests/data/stain_v060_parity.json b/tests/data/stain_v060_parity.json new file mode 100644 index 0000000..311750d --- /dev/null +++ b/tests/data/stain_v060_parity.json @@ -0,0 +1,380 @@ +{ + "changed": { + "B1 polarizer bytes": { + "message": "polarizer must be 'circular' or 'linear'; got b'linear'.", + "type": "ValueError" + }, + "B1 polarizer list": { + "message": "polarizer must be 'circular' or 'linear'; got ['circular'].", + "type": "ValueError" + }, + "B1 polarizer number": { + "message": "polarizer must be 'circular' or 'linear'; got 1.", + "type": "ValueError" + }, + "B1 polarizer one-element array": { + "message": "", + "type": "accepted" + }, + "B1 polarizer two-element array": { + "message": "The truth value of an array with more than one element is ambiguous. Use a.any() or a.all()", + "type": "ValueError" + }, + "B2 residual tuple of a masked array": { + "message": "setting an array element with a sequence. The requested array has an inhomogeneous shape after 1 dimensions. The detected shape was (3,) + inhomogeneous part.", + "type": "ValueError" + }, + "B2 vector nested list holding masked": { + "message": "Stain vector 'psr' must be three numbers (R, G, B optical densities); got [[0.1, masked, 0.5]].", + "type": "ValueError" + }, + "B2 vector object array holding masked": { + "message": "Stain vector 'eosin' must be three numbers (R, G, B optical densities); got array([0.6, masked, 0.3], dtype=object).", + "type": "ValueError" + }, + "B2 white list holding a 0-d object array of masked": { + "message": "white must be three numbers (the background R, G, B of the 8-bit image); got [240, array(masked, dtype=object), 245].", + "type": "ValueError" + }, + "B2 white nested list holding masked": { + "message": "white must be three numbers (the background R, G, B of the 8-bit image); got [[240, masked, 245]].", + "type": "ValueError" + }, + "B2 white object array holding masked": { + "message": "white must be three numbers (the background R, G, B of the 8-bit image); got array([240, masked, 245], dtype=object).", + "type": "ValueError" + } + }, + "conversions": { + "00 he_eosin YXC plain": { + "exclusion": "22e63105d9d55b05e0edaa37ecb89192f77d30fb9abac740ba130ba26e893859", + "image": "f135d253652aad4aee1e433d5851335f3b4d3a021b42961efc433b73dcb8860f", + "provenance": "2d74d31d23c6e7c931006a5c53678c9f57c907c13bbe3d1f0381adfa74aab3ac" + }, + "01 he_eosin CYX plain": { + "exclusion": "22e63105d9d55b05e0edaa37ecb89192f77d30fb9abac740ba130ba26e893859", + "image": "f135d253652aad4aee1e433d5851335f3b4d3a021b42961efc433b73dcb8860f", + "provenance": "d053d8269ff25562bce75ec479caa6142a58785748aa10108e32b2b5bdb7648a" + }, + "02 he_hematoxylin YXC plain": { + "exclusion": null, + "image": "22e63105d9d55b05e0edaa37ecb89192f77d30fb9abac740ba130ba26e893859", + "provenance": "1f7da7ab40a98bc7d524e942529d9c114a2c5b49688152c98c7409cec9b656cf" + }, + "03 he_hematoxylin CYX plain": { + "exclusion": null, + "image": "22e63105d9d55b05e0edaa37ecb89192f77d30fb9abac740ba130ba26e893859", + "provenance": "6ec73b63e8ba8ea85e5bf7696354d4ddb21a16ef100ecc0336c345c74e8eea7a" + }, + "04 luminance_inverted YXC plain": { + "exclusion": null, + "image": "9b8c0ff16a65ed0890a38a7cd9ecd37747bfd736df69fdc9e5c6db22cd861adc", + "provenance": "25f6b6e835a2c352f3242a84b15513cc397dc6ad7409ffe93dcc761705addd49" + }, + "05 luminance_inverted CYX plain": { + "exclusion": null, + "image": "9b8c0ff16a65ed0890a38a7cd9ecd37747bfd736df69fdc9e5c6db22cd861adc", + "provenance": "d2cfacfeae19375b003e61f18139b046a32e261b8ab1dea8123e9abe9659d0ee" + }, + "06 luminance_inverted YXC white": { + "exclusion": null, + "image": "e71692b6d10f0bb9b203a641ca25c5f9cf278103b3a4aabb42c29a1f4f352fa4", + "provenance": "3569ec3f65751743fe76792de1243ac7757b5a9d283850e1fc1871e497304d70" + }, + "07 luminance_inverted YXC white": { + "exclusion": null, + "image": "9b8c0ff16a65ed0890a38a7cd9ecd37747bfd736df69fdc9e5c6db22cd861adc", + "provenance": "5baca8cf4d13630a79759136312184e9649f7fc9c58df5b5fbff979e6d631d01" + }, + "08 lab_a YXC plain": { + "exclusion": null, + "image": "bb6c35d7196fb2fe8208ba3475285185d007ec0108855b50f84cfc14587dab43", + "provenance": "c42db5ab1a42a9e7891a7709147829ca558fabb57cbfd67d400b486d4021f0bb" + }, + "09 lab_a CYX plain": { + "exclusion": null, + "image": "bb6c35d7196fb2fe8208ba3475285185d007ec0108855b50f84cfc14587dab43", + "provenance": "b36a7366d6d0a03f90876dc9b9e3b24185d9ba375b6a1140607fc9c6b72ad95d" + }, + "10 lab_a YXC white": { + "exclusion": null, + "image": "029956ebc3ab611868fe9f00f07c9665e68c45ac7857f075cf8a4cdca94ee429", + "provenance": "98d6a2ba1d71f0a337d98773ffbbf937211f7c765a2796480d1d6b0ba73b0bcd" + }, + "11 lab_a YXC white": { + "exclusion": null, + "image": "bb6c35d7196fb2fe8208ba3475285185d007ec0108855b50f84cfc14587dab43", + "provenance": "8aa8e44722c47f8738fc650e13c446164775886d17e11c7bf460d07407df0942" + }, + "12 fluorescence YX plain": { + "exclusion": null, + "image": "035ac21257eac4f6cb4d7c006cde252cab3cb5e2c218c06b0c5c6f75d242e93e", + "provenance": "5da7265713ca86c035b079f8c7f5b974c767f6990325027286532bcd6fba167e" + }, + "13 fluorescence YX plain": { + "exclusion": null, + "image": "5d0cc9fe28e96eaf769b6d91c9aa842f8f7768717e68a13e0829e0457f6bddfc", + "provenance": "efce1a5e2a0577921323967b8a2d4319f0c17c012f8765273676942ce12a67f3" + }, + "14 shg YX plain": { + "exclusion": null, + "image": "035ac21257eac4f6cb4d7c006cde252cab3cb5e2c218c06b0c5c6f75d242e93e", + "provenance": "006e6b86fcecfea5e32ad4807a0e38b4c7e34999513d69a48d6226815a27dcc4" + }, + "15 shg YX plain": { + "exclusion": null, + "image": "5d0cc9fe28e96eaf769b6d91c9aa842f8f7768717e68a13e0829e0457f6bddfc", + "provenance": "7b06146cb7074a09ee5d8061a655ca34a377c8008045ccbb461af616defcdcc2" + }, + "16 psr_brightfield YXC white+stain_vectors": { + "exclusion": "cb9a3e3e6d86170209eb31f5e4c624bce52d28600c4f0cb6bd821f814c79ac47", + "image": "d50f20d7824f3eba8a14ae54c5542be68ea523853421daf6beddce7bd57bde14", + "provenance": "d2391e8e04019abe1aefb2d085c4b0fd4458d0d16f1e9db84e94c954f2a9c2b7" + }, + "17 psr_brightfield YXC white+stain_vectors": { + "exclusion": "b6211402ff4f720d61876691a3336bb1da660287fedf36ab9e23694036cee5fb", + "image": "9e3da13901c3a943f792697802e3d5b87fc7d07a159c108be5d3b34bc32a1de4", + "provenance": "b23ef44fb2b92d59e946353e90158cce9f1162055e4c3deb03bd0a71ec445f7f" + }, + "18 psr_polarized YXC polarizer": { + "exclusion": null, + "image": "b671241d1a43af9da5fa4fd9d75a62821e40bf5202fcdb3c478e96dc539c44b5", + "provenance": "6fb944a9ca5ff6f95123b5fec325102ec28781a9f65c6b26e397c0ebbd16b7e2" + }, + "19 psr_polarized CYX polarizer": { + "exclusion": null, + "image": "b671241d1a43af9da5fa4fd9d75a62821e40bf5202fcdb3c478e96dc539c44b5", + "provenance": "1ca65e8389288a8f2ce92d12b2b43f4b9007fb4f21d63ec7e7635fc723f897fe" + }, + "20 psr_polarized YXC polarizer+white": { + "exclusion": null, + "image": "5abc50c647e360eac117ac21f83f55c946e5594cc455170ce3787b5c4dcdc15a", + "provenance": "988528dda553da0768158e38f251b3f4dd0a71b3143c1499863c6b4299fdb2ca" + }, + "21 psr_polarized YXC polarizer+white": { + "exclusion": null, + "image": "b671241d1a43af9da5fa4fd9d75a62821e40bf5202fcdb3c478e96dc539c44b5", + "provenance": "2d45c882392805a8127ca72ef74a3d7e6b3869060ddd76a9de4002abb80ee905" + }, + "22 psr_polarized YXC polarizer": { + "exclusion": null, + "image": "b671241d1a43af9da5fa4fd9d75a62821e40bf5202fcdb3c478e96dc539c44b5", + "provenance": "d166f03780831a8567d6e496cafc26cfd5a7c70a4ff3f68c110e0dbb723d0a7d" + }, + "23 trichrome_blue YXC plain": { + "exclusion": null, + "image": "8ce205b4cae44e90b7362d99263a01bfdc88ab38fc3d1f75d9596e83b6bb0f69", + "provenance": "e3dbb1eab2b2a204e822a46e39e893fb0396a67f9cd137ec9d85b2149a91070e" + }, + "24 trichrome_blue CYX plain": { + "exclusion": null, + "image": "8ce205b4cae44e90b7362d99263a01bfdc88ab38fc3d1f75d9596e83b6bb0f69", + "provenance": "10c2ea6c0ffd9bcbb9040bee62dcbe67a83632c098365fd2b4fdc0cbeee74d3c" + }, + "25 trichrome_green YXC plain": { + "exclusion": null, + "image": "dbf6064cbb3d4b31d67dcad3d383e343ee9e0b372bf4d704fd243a8281188c7e", + "provenance": "0a1db4db1b2aac6d75fb5e94be57ed4f49eedf17aa0de246b4ff2dc341e263e2" + }, + "26 trichrome_green CYX plain": { + "exclusion": null, + "image": "dbf6064cbb3d4b31d67dcad3d383e343ee9e0b372bf4d704fd243a8281188c7e", + "provenance": "f8863f29210f6562365ff39e1aae1dc50d533e03bf72c1bb564427a7ae458f5d" + }, + "27 trichrome_green YXC white": { + "exclusion": null, + "image": "32520819aae27a3e30e13812165cfac832bc83da8e5ecfd295d9c86edec33849", + "provenance": "f2bcb434dd7f6ccf1454ad52fc9020ef7902dd5538a71fc01bab9023835da087" + }, + "28 trichrome_green YXC white": { + "exclusion": null, + "image": "dbf6064cbb3d4b31d67dcad3d383e343ee9e0b372bf4d704fd243a8281188c7e", + "provenance": "04b14d551f8008f08d1ee01f8e12d620c92d83ea8f3a8b34dcdd832ca7d2401f" + }, + "29 he_custom YXC white": { + "exclusion": "84f378d8796a4052fffab0f46f2c375718e0aa4003fa75f7f3579fe58309ecc2", + "image": "c8ceb8e52149b7d22e4ae0cf3d102b935302eab6bc624d09262c20d6e5536b49", + "provenance": "0e4f08d8d6e3a395966b8357295c4ba5241cbd4aae8f1031a2f6ac769985d1f6" + }, + "30 he_custom YXC white+stain_vectors": { + "exclusion": "b6211402ff4f720d61876691a3336bb1da660287fedf36ab9e23694036cee5fb", + "image": "9e3da13901c3a943f792697802e3d5b87fc7d07a159c108be5d3b34bc32a1de4", + "provenance": "1b826b392efa762c984da67da0ce0eb7f54d5b7e21faec6ca848afc0baac6782" + }, + "31 trichrome_custom YXC white": { + "exclusion": "5563208d3c7c55d4d061f7d521f51c3fd31fb293320eec44d0009c7a950d9959", + "image": "2631650f44a8a2d795ce9fd32343efbff1ae7bc40c7f98cc6bf6cf56bef6375a", + "provenance": "5fd4e6b435a528be9b693fd5cffb888c2d2f1bcceeaae3411483e6439a16fac7" + }, + "32 trichrome_custom YXC white+stain_vectors": { + "exclusion": "b6211402ff4f720d61876691a3336bb1da660287fedf36ab9e23694036cee5fb", + "image": "9e3da13901c3a943f792697802e3d5b87fc7d07a159c108be5d3b34bc32a1de4", + "provenance": "5fa150cd439a25c72cde89b8aa6f0ce59203bbbf10f762b479b268a13f4a03f2" + } + }, + "numpy": "2.5.4", + "produced_by": "tests/stain_parity_cases.py on commit 95e1d6d (v0.6.0), to_scalar with valid pixels; see the module docstring", + "refusals": { + "collinear": { + "message": "Stain vectors 'psr' and 'counterstain' are 4.50 degrees apart (minimum 5.0); collinear stains cannot be separated.", + "type": "ValueError" + }, + "collinear identical": { + "message": "Stain vectors 'eosin' and 'hematoxylin' are 0.00 degrees apart (minimum 5.0); collinear stains cannot be separated.", + "type": "ValueError" + }, + "collinear opposite sign": { + "message": "Stain vectors 'psr' and 'counterstain' are 4.50 degrees apart (minimum 5.0); collinear stains cannot be separated.", + "type": "ValueError" + }, + "ill-conditioned": { + "message": "The stain matrix is ill-conditioned (condition number 141, maximum 50.0); the vectors are too close to a plane to be separated.", + "type": "ValueError" + }, + "polarizer missing": { + "message": "Preset 'psr_polarized' needs polarizer; it has no default.", + "type": "ValueError" + }, + "polarizer unused (he_custom)": { + "message": "Preset 'he_custom' does not use polarizer; it is refused rather than ignored.", + "type": "ValueError" + }, + "polarizer unused (lab_a)": { + "message": "Preset 'lab_a' does not use polarizer; it is refused rather than ignored.", + "type": "ValueError" + }, + "polarizer wrong case": { + "message": "polarizer must be 'circular' or 'linear'; got 'Circular'.", + "type": "ValueError" + }, + "residual collinear": { + "message": "Stain vectors 'psr' and 'residual' are 0.00 degrees apart (minimum 5.0); collinear stains cannot be separated.", + "type": "ValueError" + }, + "residual masked": { + "message": "Stain vector 'residual' is a masked array (or holds a masked value): its mask would be dropped silently. Pass plain numbers.", + "type": "TypeError" + }, + "unknown polarizer": { + "message": "polarizer must be 'circular' or 'linear'; got 'crossed'.", + "type": "ValueError" + }, + "unknown preset": { + "message": "Unknown preset 'he_eosine'; known presets: he_eosin, he_hematoxylin, luminance_inverted, lab_a, fluorescence, shg, psr_brightfield, psr_polarized, trichrome_blue, trichrome_green, he_custom, trichrome_custom.", + "type": "ValueError" + }, + "vector inf": { + "message": "Stain vector 'collagen' is not finite: (inf, 0.0, 0.0).", + "type": "ValueError" + }, + "vector list holding masked": { + "message": "Stain vector 'hematoxylin' is a masked array (or holds a masked value): its mask would be dropped silently. Pass plain numbers.", + "type": "TypeError" + }, + "vector masked array": { + "message": "Stain vector 'psr' is a masked array (or holds a masked value): its mask would be dropped silently. Pass plain numbers.", + "type": "TypeError" + }, + "vector name extra": { + "message": "stain_vectors must name 'psr' and 'counterstain', and optionally 'residual'; missing [], not allowed ['dab'].", + "type": "ValueError" + }, + "vector name missing": { + "message": "stain_vectors must name 'psr' and 'counterstain', and optionally 'residual'; missing ['counterstain'], not allowed [].", + "type": "ValueError" + }, + "vector nan": { + "message": "Stain vector 'psr' is not finite: (nan, 1.0, 0.0).", + "type": "ValueError" + }, + "vector text": { + "message": "Stain vector 'eosin' must be three numbers (R, G, B optical densities); got 'red'.", + "type": "ValueError" + }, + "vector two values": { + "message": "Stain vector 'psr' must be three numbers (R, G, B optical densities); got (1, 0).", + "type": "ValueError" + }, + "vector zero length": { + "message": "Stain vector 'psr' has (almost) zero length (0); it has no direction.", + "type": "ValueError" + }, + "vectors missing (psr)": { + "message": "Preset 'psr_brightfield' needs stain_vectors; it has no default.", + "type": "ValueError" + }, + "vectors not a mapping": { + "message": "stain_vectors must be a mapping name -> 3 values; got [(1, 0, 0), (0, 1, 0)].", + "type": "TypeError" + }, + "vectors unused (luminance)": { + "message": "Preset 'luminance_inverted' does not use stain_vectors; it is refused rather than ignored.", + "type": "ValueError" + }, + "vectors unused (trichrome_blue)": { + "message": "Preset 'trichrome_blue' does not use stain_vectors; it is refused rather than ignored.", + "type": "ValueError" + }, + "white above 255": { + "message": "Each value of white must be finite and in (0, 255]; got (256.0, 200.0, 200.0). Measure it on background, for example with white_from_region.", + "type": "ValueError" + }, + "white all-false mask": { + "message": "white is a masked array (or holds a masked value): its mask would be dropped silently. Pass plain numbers.", + "type": "TypeError" + }, + "white inf": { + "message": "Each value of white must be finite and in (0, 255]; got (inf, 1.0, 1.0). Measure it on background, for example with white_from_region.", + "type": "ValueError" + }, + "white list holding masked": { + "message": "white is a masked array (or holds a masked value): its mask would be dropped silently. Pass plain numbers.", + "type": "TypeError" + }, + "white masked array": { + "message": "white is a masked array (or holds a masked value): its mask would be dropped silently. Pass plain numbers.", + "type": "TypeError" + }, + "white missing (custom_od)": { + "message": "Preset 'he_custom' needs white; it has no default.", + "type": "ValueError" + }, + "white nan": { + "message": "Each value of white must be finite and in (0, 255]; got (nan, 200.0, 200.0). Measure it on background, for example with white_from_region.", + "type": "ValueError" + }, + "white negative": { + "message": "Each value of white must be finite and in (0, 255]; got (-1.0, 200.0, 200.0). Measure it on background, for example with white_from_region.", + "type": "ValueError" + }, + "white scalar": { + "message": "white must be three numbers (the background R, G, B of the 8-bit image); got 240.", + "type": "ValueError" + }, + "white text": { + "message": "white must be three numbers (the background R, G, B of the 8-bit image); got ('a', 'b', 'c').", + "type": "ValueError" + }, + "white tuple holding masked": { + "message": "white is a masked array (or holds a masked value): its mask would be dropped silently. Pass plain numbers.", + "type": "TypeError" + }, + "white two values": { + "message": "white must be three numbers (the background R, G, B of the 8-bit image); got (200, 200).", + "type": "ValueError" + }, + "white unused (he_eosin)": { + "message": "Preset 'he_eosin' does not use white; it is refused rather than ignored.", + "type": "ValueError" + }, + "white unused (passthrough)": { + "message": "Preset 'fluorescence' does not use white; it is refused rather than ignored.", + "type": "ValueError" + }, + "white zero": { + "message": "Each value of white must be finite and in (0, 255]; got (0.0, 200.0, 200.0). Measure it on background, for example with white_from_region.", + "type": "ValueError" + } + }, + "skimage": "0.26.0" +} diff --git a/tests/stain_parity_cases.py b/tests/stain_parity_cases.py new file mode 100644 index 0000000..7028547 --- /dev/null +++ b/tests/stain_parity_cases.py @@ -0,0 +1,316 @@ +"""Inputs of the v0.6.0 parity tables of tests/test_stain_check_options.py (#97, PR #98). + +``tests/data/stain_v060_parity.json`` freezes what ``stain.to_scalar`` of v0.6.0 (commit +``95e1d6d``) did with these inputs: the exception type and full message of every refusal in +:data:`REFUSALS` and :data:`CHANGED`, and the SHA-256 digests of the outputs of every conversion +in :data:`CONVERSIONS`. This module uses only names that existed in v0.6.0, so the table can be +recorded on that commit. To record it again (```` with the ``histology`` extra):: + + git worktree add /tmp/v060 95e1d6d + PYTHONPATH=/tmp/v060/src /bin/python tests/stain_parity_cases.py \\ + > tests/data/stain_v060_parity.json + git worktree remove /tmp/v060 + +The script prints the path of the ``stain`` module it used on standard error: check that it is +the worktree's. +""" + +from __future__ import annotations + +import hashlib +import json +import math +import sys +from collections.abc import Callable, Iterator, Mapping +from typing import Any + +import numpy as np + +from morphobricks_normalization import stain + +BASE_COMMIT = "95e1d6d" +WHITE = (240.0, 235.0, 245.0) +PSR_VECTORS = {"psr": (0.10, 0.80, 0.55), "counterstain": (0.65, 0.70, 0.29)} +HE_VECTORS = {"eosin": (0.07, 0.99, 0.11), "hematoxylin": (0.65, 0.70, 0.29)} +COLLINEAR = math.radians(stain.od.MIN_ANGLE_DEGREES - 0.5) +NEAR_PLANE = (1.0, 1.0, 0.02) # 45 degrees from x and y, but almost in their plane +SKIMAGE_VERSION = "" +"""Stands for ``skimage_version`` in the hashed provenance; the test checks the real value.""" + + +def psr(**vectors: Any) -> dict[str, Any]: + """Return psr_brightfield options with the given stain vectors.""" + return {"white": WHITE, "stain_vectors": vectors} + + +def _masked_white_array() -> np.ndarray: + """Return an object array holding ``np.ma.masked`` (built so NumPy keeps the constant).""" + values = np.empty(3, dtype=object) + values[:] = [240, np.ma.masked, 245] + return values + + +def _object_scalar(item: object) -> np.ndarray: + """Return a 0-d object array holding ``item``.""" + value = np.empty((), dtype=object) + value[()] = item + return value + + +def _object_vector(item: object) -> np.ndarray: + """Return an object array ``(0.6, item, 0.3)``.""" + values = np.empty(3, dtype=object) + values[:] = [0.6, item, 0.3] + return values + + +# name -> (preset, options): every refusal check_options shares with to_scalar. Their v0.6.0 +# exception types and messages are frozen in the "refusals" table and do not change. +REFUSALS: dict[str, tuple[str, dict[str, Any]]] = { + "unknown preset": ("he_eosine", {}), + "white missing (custom_od)": ("he_custom", {}), + "vectors missing (psr)": ("psr_brightfield", {"white": WHITE}), + "polarizer missing": ("psr_polarized", {}), + "white unused (he_eosin)": ("he_eosin", {"white": WHITE}), + "white unused (passthrough)": ("fluorescence", {"white": WHITE}), + "polarizer unused (lab_a)": ("lab_a", {"polarizer": "circular"}), + "vectors unused (trichrome_blue)": ("trichrome_blue", {"stain_vectors": PSR_VECTORS}), + "vectors unused (luminance)": ("luminance_inverted", {"stain_vectors": PSR_VECTORS}), + "polarizer unused (he_custom)": ("he_custom", {"white": WHITE, "polarizer": "linear"}), + "unknown polarizer": ("psr_polarized", {"polarizer": "crossed"}), + "polarizer wrong case": ("psr_polarized", {"polarizer": "Circular"}), + "white zero": ("he_custom", {"white": (0, 200, 200)}), + "white above 255": ("lab_a", {"white": (256, 200, 200)}), + "white negative": ("psr_polarized", {"white": (-1, 200, 200), "polarizer": "circular"}), + "white nan": ("trichrome_custom", {"white": (np.nan, 200, 200)}), + "white inf": ("luminance_inverted", {"white": (np.inf, 1, 1)}), + "white two values": ("he_custom", {"white": (200, 200)}), + "white text": ("he_custom", {"white": ("a", "b", "c")}), + "white scalar": ("trichrome_green", {"white": 240}), + "white masked array": ("he_custom", {"white": np.ma.MaskedArray(WHITE, mask=[0, 1, 0])}), + "white all-false mask": ("lab_a", {"white": np.ma.MaskedArray(WHITE, mask=False)}), + "white list holding masked": ( + "psr_brightfield", + {"white": [240, np.ma.masked, 245], "stain_vectors": PSR_VECTORS}, + ), + "white tuple holding masked": ("luminance_inverted", {"white": (np.ma.masked, 235, 245)}), + "vectors not a mapping": ( + "psr_brightfield", + {"white": WHITE, "stain_vectors": [(1, 0, 0), (0, 1, 0)]}, + ), + "vector name missing": ("psr_brightfield", psr(psr=(1, 0, 0))), + "vector name extra": ("psr_brightfield", psr(**PSR_VECTORS, dab=(0, 0, 1))), + "vector two values": ("psr_brightfield", psr(psr=(1, 0), counterstain=(0, 1, 0))), + "vector text": ("he_custom", {"white": WHITE, "stain_vectors": {**HE_VECTORS, "eosin": "red"}}), + "vector nan": ("psr_brightfield", psr(psr=(np.nan, 1, 0), counterstain=(0, 1, 0))), + "vector inf": ( + "trichrome_custom", + { + "white": WHITE, + "stain_vectors": {"collagen": (np.inf, 0, 0), "ponceau_fuchsin": (0, 1, 0)}, + }, + ), + "vector zero length": ("psr_brightfield", psr(psr=(0, 0, 0), counterstain=(0, 1, 0))), + "vector masked array": ( + "psr_brightfield", + psr(psr=np.ma.MaskedArray((1.0, 0, 0), mask=False), counterstain=(0, 1, 0)), + ), + "vector list holding masked": ( + "he_custom", + {"white": WHITE, "stain_vectors": {**HE_VECTORS, "hematoxylin": [0.6, np.ma.masked, 0.3]}}, + ), + "residual masked": ( + "psr_brightfield", + psr(**PSR_VECTORS, residual=np.ma.MaskedArray((0, 0, 1.0), mask=True)), + ), + "collinear": ( + "psr_brightfield", + psr(psr=(1, 0, 0), counterstain=(math.cos(COLLINEAR), math.sin(COLLINEAR), 0)), + ), + "collinear opposite sign": ( + "psr_brightfield", + psr(psr=(1, 0, 0), counterstain=(-math.cos(COLLINEAR), -math.sin(COLLINEAR), 0)), + ), + "collinear identical": ( + "he_custom", + {"white": WHITE, "stain_vectors": {"eosin": (0, 1, 0), "hematoxylin": (0, 2, 0)}}, + ), + "residual collinear": ("psr_brightfield", psr(**PSR_VECTORS, residual=(0.20, 1.60, 1.10))), + "ill-conditioned": ( + "psr_brightfield", + psr(psr=(1, 0, 0), counterstain=(0, 1, 0), residual=NEAR_PLANE), + ), +} + +# name -> (preset, options): inputs whose classification changed on purpose after v0.6.0 (PR +# #98 review blockers B1 and B2). The table keeps v0.6.0's outcome as evidence; the test checks +# the new refusal and that it differs. +CHANGED: dict[str, tuple[str, dict[str, Any]]] = { + "B1 polarizer one-element array": ("psr_polarized", {"polarizer": np.array(["linear"])}), + "B1 polarizer two-element array": ( + "psr_polarized", + {"polarizer": np.array(["linear", "circular"])}, + ), + "B1 polarizer bytes": ("psr_polarized", {"polarizer": b"linear"}), + "B1 polarizer number": ("psr_polarized", {"polarizer": 1}), + "B1 polarizer list": ("psr_polarized", {"polarizer": ["circular"]}), + "B2 white object array holding masked": ("lab_a", {"white": _masked_white_array()}), + "B2 white nested list holding masked": ("he_custom", {"white": [[240, np.ma.masked, 245]]}), + "B2 white list holding a 0-d object array of masked": ( + "luminance_inverted", + {"white": [240, _object_scalar(np.ma.masked), 245]}, + ), + "B2 vector object array holding masked": ( + "he_custom", + {"white": WHITE, "stain_vectors": {**HE_VECTORS, "eosin": _object_vector(np.ma.masked)}}, + ), + "B2 vector nested list holding masked": ( + "psr_brightfield", + psr(psr=[[0.1, np.ma.masked, 0.5]], counterstain=(0, 1, 0)), + ), + "B2 residual tuple of a masked array": ( + "psr_brightfield", + psr(**PSR_VECTORS, residual=(0.3, (np.ma.MaskedArray(0.2, mask=False),), 0.9)), + ), +} + + +def valid_pixels(preset: str) -> tuple[np.ndarray, str]: + """Return pixels to_scalar accepts for ``preset``, so only the options can be refused.""" + rng = np.random.default_rng(3) + if preset in stain.PRESETS and stain.PRESETS[preset].method == "passthrough": + return rng.normal(100, 10, (5, 6)), "YX" + return rng.integers(1, 256, (5, 6, 3), dtype=np.uint8), "YXC" + + +def _custom_od_cases(name: str, preset: Any, rgb: np.ndarray) -> Iterator[tuple]: + """Yield the custom_od conversions of one preset: default or declared, then user vectors.""" + base: dict[str, Any] = {"white": (240, 235, 250)} + if preset.default_stain_vectors is None: + base["stain_vectors"] = {"psr": (0.2, 0.9, 0.6), "counterstain": (0.6, 0.7, 0.3)} + yield name, rgb, "YXC", base + vectors = { + preset.target_stain: (0.1, 0.9, 0.2), + preset.exclusion_stain: (0.7, 0.6, 0.3), + "residual": (0.3, 0.3, 0.9), + } + yield name, rgb, "YXC", {"white": [250.5, 249, 251], "stain_vectors": vectors} + + +def conversions() -> Iterator[tuple[str, np.ndarray, str, dict[str, Any]]]: + """Yield ``(preset, pixels, axes, options)``: 33 conversions over every preset. + + YXC and CYX, with and without ``white``, default and user vectors, both polarizers, and + passthrough of a float64 and a uint16 plane. + """ + rng = np.random.default_rng(7) + rgb = rng.integers(0, 256, size=(13, 11, 3), dtype=np.uint8) + plane = rng.normal(100, 30, size=(13, 11)) + for name, preset in stain.PRESETS.items(): + if preset.method == "passthrough": + yield name, plane, "YX", {} + yield name, plane.astype(np.uint16), "YX", {} + continue + if preset.method == "custom_od": + yield from _custom_od_cases(name, preset, rgb) + continue + base = {"polarizer": "circular"} if "polarizer" in preset.needs else {} + yield name, rgb, "YXC", base + yield name, np.moveaxis(rgb, -1, 0).copy(), "CYX", base + if "white" in preset.accepts: + yield name, rgb, "YXC", {**base, "white": (230, 240, 250)} + yield name, rgb, "YXC", {**base, "white": np.array([255, 255, 255])} + if "polarizer" in preset.needs: + yield name, rgb, "YXC", {"polarizer": "linear"} + + +def conversion_id(index: int, preset: str, axes: str, options: Mapping[str, Any]) -> str: + """Return a readable, unique id of conversion ``index``.""" + return f"{index:02d} {preset} {axes} {'+'.join(options) or 'plain'}" + + +def _plain(value: object) -> object: + """Return ``value`` as plain JSON types; refuse any other type (a NumPy scalar included).""" + if isinstance(value, Mapping): + return {str(key): _plain(item) for key, item in value.items()} + if isinstance(value, tuple | list): + return [_plain(item) for item in value] + if value is None or type(value) in (bool, int, float, str): + return value + raise TypeError(f"Provenance value {value!r} of type {type(value).__name__} is not plain.") + + +def provenance_json(provenance: Mapping[str, Any]) -> str: + """Return the canonical JSON of a provenance, ``skimage_version`` replaced by a marker.""" + record = dict(provenance) + if record.get("skimage_version") is not None: + record["skimage_version"] = SKIMAGE_VERSION + return json.dumps(_plain(record), sort_keys=True, separators=(",", ":"), allow_nan=False) + + +def array_digest(values: np.ndarray | None) -> str | None: + """Return the SHA-256 of an array's dtype, shape and C-order bytes, or ``None``.""" + if values is None: + return None + header = f"{values.dtype.str}{list(values.shape)}".encode() + return hashlib.sha256(header + np.ascontiguousarray(values).tobytes()).hexdigest() + + +def output_digests(result: Any) -> dict[str, str | None]: + """Return the digests of a ScalarResult: image, exclusion and canonical provenance.""" + provenance = provenance_json(result.provenance).encode() + return { + "image": array_digest(result.image), + "exclusion": array_digest(result.exclusion), + "provenance": hashlib.sha256(provenance).hexdigest(), + } + + +def outcome(call: Callable[[], object]) -> dict[str, str]: + """Return the exception type and full message of ``call``, or ``accepted``.""" + try: + call() + except Exception as error: # noqa: BLE001 - every outcome is recorded, none hidden + return {"type": type(error).__name__, "message": str(error)} + return {"type": "accepted", "message": ""} + + +def _skimage_version() -> str | None: + """Return the installed scikit-image version, or ``None``.""" + try: + import skimage + except ImportError: + return None + return skimage.__version__ + + +def record() -> dict[str, Any]: + """Return the parity table of the installed ``stain`` module.""" + import warnings + + def refused(preset: str, options: dict[str, Any]) -> dict[str, str]: + pixels, axes = valid_pixels(preset) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + return outcome(lambda: stain.to_scalar(pixels, axes=axes, preset=preset, **options)) + + converted = {} + for index, (preset, pixels, axes, options) in enumerate(conversions()): + result = stain.to_scalar(pixels, axes=axes, preset=preset, **options) + converted[conversion_id(index, preset, axes, options)] = output_digests(result) + return { + "produced_by": ( + f"tests/stain_parity_cases.py on commit {BASE_COMMIT} (v0.6.0), to_scalar with " + "valid pixels; see the module docstring" + ), + "numpy": np.__version__, + "skimage": _skimage_version(), + "refusals": {name: refused(*case) for name, case in REFUSALS.items()}, + "changed": {name: refused(*case) for name, case in CHANGED.items()}, + "conversions": converted, + } + + +if __name__ == "__main__": + print(stain.__file__, file=sys.stderr) + print(json.dumps(record(), indent=1, sort_keys=True)) diff --git a/tests/test_stain_check_options.py b/tests/test_stain_check_options.py index d44e3e3..21b0462 100644 --- a/tests/test_stain_check_options.py +++ b/tests/test_stain_check_options.py @@ -1,276 +1,237 @@ """check_options: every to_scalar check of the preset and its options, without pixels (#97).""" -import math +import dataclasses +import json import subprocess import sys import textwrap +from pathlib import Path +from types import MappingProxyType import numpy as np import pytest from morphobricks_normalization import stain from morphobricks_normalization.stain import PRESETS, check_options, to_scalar +from stain_parity_cases import ( + CHANGED, + HE_VECTORS, + NEAR_PLANE, + PSR_VECTORS, + REFUSALS, + WHITE, + conversion_id, + conversions, + output_digests, + valid_pixels, +) -WHITE = (240.0, 235.0, 245.0) -PSR_VECTORS = {"psr": (0.10, 0.80, 0.55), "counterstain": (0.65, 0.70, 0.29)} -HE_VECTORS = {"eosin": (0.07, 0.99, 0.11), "hematoxylin": (0.65, 0.70, 0.29)} -COLLINEAR = math.radians(stain.od.MIN_ANGLE_DEGREES - 0.5) -NEAR_PLANE = (1.0, 1.0, 0.02) # 45 degrees from x and y, but almost in their plane +# What to_scalar of v0.6.0 (commit 95e1d6d) did with the inputs of tests/stain_parity_cases.py: +# full exception type and message of every refusal, digests of every conversion. Recorded on that +# commit in a temporary worktree (the command is in the docstring of stain_parity_cases.py), so +# the expected values do not come from the code under test. +V060 = json.loads((Path(__file__).parent / "data" / "stain_v060_parity.json").read_text()) +CASES = [pytest.param(*case, id=name) for name, case in REFUSALS.items()] -def psr(**vectors): - """psr_brightfield options with the given stain vectors.""" - return {"white": WHITE, "stain_vectors": vectors} +def object_values(*items): + """An object array holding ``items`` as they are (``np.array`` would convert masked ones).""" + if len(items) == 1: + value = np.empty((), dtype=object) + value[()] = items[0] + return value + values = np.empty(len(items), dtype=object) + values[:] = items + return values -# (preset, options, exception, message pattern): every refusal check_options shares with -# to_scalar. The messages are the ones to_scalar gave before #97 (test_messages_are_unchanged). -REFUSALS = { - "unknown preset": ( - "he_eosine", - {}, - ValueError, - r"Unknown preset 'he_eosine'; known presets: he_eosin, ", - ), - "white missing (custom_od)": ("he_custom", {}, ValueError, "needs white; it has no default"), - "vectors missing (psr)": ( - "psr_brightfield", - {"white": WHITE}, - ValueError, - "needs stain_vectors", - ), - "polarizer missing": ("psr_polarized", {}, ValueError, "needs polarizer"), - "white unused (he_eosin)": ("he_eosin", {"white": WHITE}, ValueError, "does not use white"), - "white unused (passthrough)": ( - "fluorescence", - {"white": WHITE}, - ValueError, - "does not use white", - ), - "polarizer unused (lab_a)": ( - "lab_a", - {"polarizer": "circular"}, - ValueError, - "does not use polarizer", - ), - "vectors unused (trichrome_blue)": ( - "trichrome_blue", - {"stain_vectors": PSR_VECTORS}, - ValueError, - "does not use stain_vectors", - ), - "vectors unused (luminance)": ( - "luminance_inverted", - {"stain_vectors": PSR_VECTORS}, - ValueError, - "does not use stain_vectors", - ), - "polarizer unused (he_custom)": ( - "he_custom", - {"white": WHITE, "polarizer": "linear"}, - ValueError, - "does not use polarizer", - ), - "unknown polarizer": ( - "psr_polarized", - {"polarizer": "crossed"}, - ValueError, - "'circular' or 'linear'", - ), - "polarizer wrong case": ( - "psr_polarized", - {"polarizer": "Circular"}, - ValueError, - "'circular' or 'linear'", - ), - "white zero": ("he_custom", {"white": (0, 200, 200)}, ValueError, r"finite and in \(0, 255\]"), - "white above 255": ( - "lab_a", - {"white": (256, 200, 200)}, - ValueError, - r"finite and in \(0, 255\]", - ), - "white negative": ( - "psr_polarized", - {"white": (-1, 200, 200), "polarizer": "circular"}, +def v060_refusal(name): + """The v0.6.0 refusal of REFUSALS[name] as ``(exception type, full message)``.""" + expected = V060["refusals"][name] + return {"ValueError": ValueError, "TypeError": TypeError}[expected["type"]], expected["message"] + + +def test_the_frozen_table_covers_every_case(): + assert len(REFUSALS) == 40 + assert V060["refusals"].keys() == REFUSALS.keys() + assert V060["changed"].keys() == CHANGED.keys() + assert len(V060["conversions"]) == 33 + assert all(outcome["type"] != "accepted" for outcome in V060["refusals"].values()) + + +@pytest.mark.parametrize("name", list(REFUSALS)) +def test_check_options_refuses_without_pixels_with_the_v060_message(name): + preset, options = REFUSALS[name] + error, message = v060_refusal(name) + with pytest.raises(Exception) as refused: + check_options(preset, **options) + assert type(refused.value) is error + assert str(refused.value) == message + + +@pytest.mark.parametrize("name", list(REFUSALS)) +def test_to_scalar_refuses_with_the_v060_message(name): + preset, options = REFUSALS[name] + error, message = v060_refusal(name) + pixels, axes = valid_pixels(preset) + with pytest.raises(Exception) as refused: + to_scalar(pixels, axes=axes, preset=preset, **options) + assert type(refused.value) is error + assert str(refused.value) == message + + +MASKED = "is a masked array (or holds a masked value): its mask would be dropped silently. " +# The inputs whose outcome changed on purpose after v0.6.0 (PR #98 review blockers). B1: a +# polarizer that is not a str is refused, its type named, before the membership test (v0.6.0 +# accepted a one-element array and raised NumPy's "truth value is ambiguous" for longer ones; for +# bytes, numbers and lists only the message changes, still a ValueError). B2: masked content inside +# object arrays and nested sequences is refused as masked before np.asarray (v0.6.0 refused these +# later, as not three numbers or as an inhomogeneous sequence). Every other refusal keeps its +# v0.6.0 type and message (above). +NOW = { + "B1 polarizer one-element array": ( ValueError, - r"\(0, 255\]", + "polarizer must be the text 'circular' or 'linear'; got numpy.ndarray " + "array(['linear'], dtype=' Date: Sat, 14 Nov 2026 15:29:38 +0000 Subject: [PATCH 3/4] Compare custom_od parity outputs with a tolerance (CI failure of #98) CI failed the six custom_od conversions of test_outputs_are_those_of_v060 on every OS with the same digests, while they passed on the machine that recorded the table: custom_od solves a stain matrix through LAPACK, and OpenBLAS selects its kernel by CPU, so the last bits of the image and of the provenance (stain matrix, condition number) depend on the CPU, not on this code. The parity table now freezes, for custom_od only, the image values and the plain provenance, compared with a relative tolerance of 1e-9 (floats; everything else exactly), and the exclusion's digest exactly; the 27 other conversions keep their SHA-256 digests. Re-recorded on 95e1d6d in a temporary worktree: the refusals, the changed cases and the 27 digests are byte-identical to the previous table, and the six exclusion digests are unchanged. A 1e-6 relative change of a frozen image is caught. tools/check.py: 2222 passed, 2 skipped (csbdeep not installed), code rules and Ruff clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VbytvF3Gsa85ix8Ne2yMDW --- CHANGELOG.md | 4 +- tests/data/stain_v060_parity.json | 1260 ++++++++++++++++++++++++++++- tests/stain_parity_cases.py | 31 +- tests/test_stain_check_options.py | 39 +- 4 files changed, 1316 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5bfcc3..25e77f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,9 @@ The decisions are in [docs/plan.md](docs/plan.md) §12. needed and unused arguments, polarizer, `white` range and masked values, stain-vector names, shape, finiteness, masked values, collinearity and conditioning), so consumers can refuse a request before reading an image. `to_scalar` runs it first (one implementation): outputs are - unchanged (33 conversions pinned by SHA-256 against v0.6.0), and so are the exception types and + unchanged (33 conversions pinned against v0.6.0: by SHA-256, except the six `custom_od` images + and provenances, compared with a relative tolerance of 1e-9 because LAPACK's last bits depend + on the CPU), and so are the exception types and full messages of the 40 refusals frozen from v0.6.0 in `tests/data/stain_v060_parity.json`, apart from the two safety fixes below; an argument error is now reported before a pixel error when both are present. diff --git a/tests/data/stain_v060_parity.json b/tests/data/stain_v060_parity.json index 311750d..45b3814 100644 --- a/tests/data/stain_v060_parity.json +++ b/tests/data/stain_v060_parity.json @@ -128,13 +128,425 @@ }, "16 psr_brightfield YXC white+stain_vectors": { "exclusion": "cb9a3e3e6d86170209eb31f5e4c624bce52d28600c4f0cb6bd821f814c79ac47", - "image": "d50f20d7824f3eba8a14ae54c5542be68ea523853421daf6beddce7bd57bde14", - "provenance": "d2391e8e04019abe1aefb2d085c4b0fd4458d0d16f1e9db84e94c954f2a9c2b7" + "image_dtype": "`` with the ``histology`` extra):: +in :data:`CONVERSIONS` (for ``custom_od``, the image values and provenance, compared with a +tolerance, and the exclusion's digest: :func:`frozen_output`). This module uses only names that +existed in v0.6.0, so the table can be recorded on that commit. To record it again (```` +with the ``histology`` extra):: git worktree add /tmp/v060 95e1d6d PYTHONPATH=/tmp/v060/src /bin/python tests/stain_parity_cases.py \\ @@ -266,6 +268,29 @@ def output_digests(result: Any) -> dict[str, str | None]: } +def output_values(result: Any) -> dict[str, Any]: + """Return a ScalarResult's image values and plain provenance, with the exclusion's digest. + + Used for ``custom_od`` conversions: they solve a stain matrix through LAPACK, whose last + bits depend on the CPU kernel OpenBLAS selects, so they are compared with a tolerance. + """ + image = np.asarray(result.image) + return { + "exclusion": array_digest(result.exclusion), + "image_dtype": image.dtype.str, + "image_shape": list(image.shape), + "image_values": image.ravel().tolist(), + "provenance": json.loads(provenance_json(result.provenance)), + } + + +def frozen_output(preset: str, result: Any) -> dict[str, Any]: + """Return what the table freezes for one conversion: digests, or values for custom_od.""" + if stain.PRESETS[preset].method == "custom_od": + return output_values(result) + return output_digests(result) + + def outcome(call: Callable[[], object]) -> dict[str, str]: """Return the exception type and full message of ``call``, or ``accepted``.""" try: @@ -297,7 +322,7 @@ def refused(preset: str, options: dict[str, Any]) -> dict[str, str]: converted = {} for index, (preset, pixels, axes, options) in enumerate(conversions()): result = stain.to_scalar(pixels, axes=axes, preset=preset, **options) - converted[conversion_id(index, preset, axes, options)] = output_digests(result) + converted[conversion_id(index, preset, axes, options)] = frozen_output(preset, result) return { "produced_by": ( f"tests/stain_parity_cases.py on commit {BASE_COMMIT} (v0.6.0), to_scalar with " diff --git a/tests/test_stain_check_options.py b/tests/test_stain_check_options.py index 21b0462..d3ffb6b 100644 --- a/tests/test_stain_check_options.py +++ b/tests/test_stain_check_options.py @@ -22,6 +22,7 @@ WHITE, conversion_id, conversions, + frozen_output, output_digests, valid_pixels, ) @@ -222,7 +223,7 @@ def test_the_replaced_default_vectors_are_the_ones_used(monkeypatch): @pytest.mark.parametrize("case", CONVERSIONS) -def test_outputs_are_bitwise_those_of_v060(request, case): +def test_outputs_are_those_of_v060(request, case): preset, pixels, axes, options = case skimage = None if PRESETS[preset].method not in ("passthrough", "custom_od"): @@ -231,7 +232,41 @@ def test_outputs_are_bitwise_those_of_v060(request, case): if result.provenance["skimage_version"] is not None: # Hashed as a marker (it names the installed scikit-image, not this code). assert result.provenance["skimage_version"] == skimage.__version__ - assert output_digests(result) == V060["conversions"][request.node.callspec.id] + frozen = V060["conversions"][request.node.callspec.id] + if PRESETS[preset].method != "custom_od": + assert output_digests(result) == frozen + return + # custom_od solves a stain matrix through LAPACK: its last bits depend on the CPU kernel. + values = frozen_output(preset, result) + assert values.keys() == frozen.keys() + assert values["exclusion"] == frozen["exclusion"] + assert (values["image_dtype"], values["image_shape"]) == ( + frozen["image_dtype"], + frozen["image_shape"], + ) + np.testing.assert_allclose(values["image_values"], frozen["image_values"], rtol=RTOL, atol=0) + assert_close(values["provenance"], frozen["provenance"]) + + +RTOL = 1e-9 +"""Relative tolerance of a custom_od value: far above LAPACK's last-bit differences.""" + + +def assert_close(actual: object, expected: object) -> None: + """Assert that two plain JSON values are equal, floats within :data:`RTOL`.""" + if isinstance(expected, dict): + assert isinstance(actual, dict) and actual.keys() == expected.keys() + for key in expected: + assert_close(actual[key], expected[key]) + elif isinstance(expected, list): + assert isinstance(actual, list) and len(actual) == len(expected) + for item, frozen_item in zip(actual, expected, strict=True): + assert_close(item, frozen_item) + elif type(expected) is float: + assert type(actual) is float + assert actual == pytest.approx(expected, rel=RTOL, abs=1e-12) + else: + assert type(actual) is type(expected) and actual == expected def test_the_unknown_preset_message_lists_every_known_preset(): From 43e61102ad70e61fe3f19a092ac4d11a58b50c6e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 16:13:40 +0000 Subject: [PATCH 4/4] Polarizer provenance as plain str and bool; float32 tolerance; cycle tests (review M) B1 (review M): an accepted str subclass such as np.str_ was stored as is in the provenance's polarizer. orientation_valid came from comparing it, and the test only compared it with another comparison result. The provenance now holds str(polarizer), and orientation_valid is bool(...). The test asserts type(...) is str and type(...) is bool for "circular", "linear", np.str_("circular") and np.str_("linear"). The numerical conversion is unchanged. FILE (review M): - custom_od parity now freezes the exclusion values as well as the image values. Both are float32, compared with rtol 1e-6 and atol 1e-7 (a few float32 ULPs; rtol 1e-9 was effectively bitwise for float32). Float64 provenance stays at 1e-9. Re-recorded on 95e1d6d: the refusals, the changed cases, the 27 digests, the six images and the six provenances are identical to the previous table. - Tests: the masked search ends on a list or an object array that holds itself, with and without masked content. stain_vectors_from_regions refuses a masked white before np.asarray. tools/check.py: 2230 passed, 2 skipped (csbdeep not installed), code rules and Ruff clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VbytvF3Gsa85ix8Ne2yMDW --- CHANGELOG.md | 10 +- src/morphobricks_normalization/stain.py | 4 +- tests/data/stain_v060_parity.json | 2730 +++++++++++++++-------- tests/stain_parity_cases.py | 27 +- tests/test_stain_check_options.py | 78 +- 5 files changed, 1916 insertions(+), 933 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25e77f9..d0b2349 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,9 @@ The decisions are in [docs/plan.md](docs/plan.md) §12. needed and unused arguments, polarizer, `white` range and masked values, stain-vector names, shape, finiteness, masked values, collinearity and conditioning), so consumers can refuse a request before reading an image. `to_scalar` runs it first (one implementation): outputs are - unchanged (33 conversions pinned against v0.6.0: by SHA-256, except the six `custom_od` images - and provenances, compared with a relative tolerance of 1e-9 because LAPACK's last bits depend - on the CPU), and so are the exception types and + unchanged (33 conversions pinned against v0.6.0: by SHA-256, except the six `custom_od` conversions, + whose float32 image and exclusion values (relative tolerance 1e-6) and float64 provenance (1e-9) are + compared with a tolerance because LAPACK's last bits depend on the CPU), and so are the exception types and full messages of the 40 refusals frozen from v0.6.0 in `tests/data/stain_v060_parity.json`, apart from the two safety fixes below; an argument error is now reported before a pixel error when both are present. @@ -24,7 +24,9 @@ The decisions are in [docs/plan.md](docs/plan.md) §12. membership test. v0.6.0 accepted `np.array(["linear"])` (element-wise comparison) and recorded `orientation_valid` as a NumPy array, and raised NumPy's "truth value is ambiguous" `ValueError` for a longer array; for bytes, numbers and lists it already raised a `ValueError`, - and only the message changes (it now names the type); + and only the message changes (it now names the type). An accepted `str` subclass such as + `np.str_` is recorded in the provenance as a plain `str`, and `orientation_valid` is always a + Python `bool` (v0.6.0 kept the `np.str_`); - masked content (`np.ma.masked` or a masked array) at any depth of nested lists, tuples or object arrays, in `white` or a stain vector (residual included), is refused as masked (`TypeError`) before `np.asarray`. v0.6.0 checked only a top-level masked array and the direct items of a list diff --git a/src/morphobricks_normalization/stain.py b/src/morphobricks_normalization/stain.py index 4016efa..773b3e8 100644 --- a/src/morphobricks_normalization/stain.py +++ b/src/morphobricks_normalization/stain.py @@ -308,7 +308,7 @@ def _provenance( hints = dict(recipe.hints) if "polarizer" in recipe.needs: # A linear polarizer darkens fibres at some angles to its axis (orientation bias). - hints["orientation_valid"] = extra["polarizer"] == "circular" + hints["orientation_valid"] = bool(extra["polarizer"] == "circular") record = { "preset": recipe.id, "preset_version": recipe.version, @@ -406,7 +406,7 @@ def to_scalar( clipped = float(np.mean(image == 0)) if recipe.method == "fixed_deconvolution" else None extra = {"skimage_version": version, "zero_fraction": clipped, **white_entries} if "polarizer" in recipe.needs: - extra["polarizer"] = polarizer + extra["polarizer"] = str(polarizer) # plain str in provenance (np.str_ accepted) provenance = _provenance(recipe, pixels, axes, extra) return ScalarResult(image=image, exclusion=exclusion, provenance=provenance) diff --git a/tests/data/stain_v060_parity.json b/tests/data/stain_v060_parity.json index 45b3814..731eee4 100644 --- a/tests/data/stain_v060_parity.json +++ b/tests/data/stain_v060_parity.json @@ -127,157 +127,310 @@ "provenance": "7b06146cb7074a09ee5d8061a655ca34a377c8008045ccbb461af616defcdcc2" }, "16 psr_brightfield YXC white+stain_vectors": { - "exclusion": "cb9a3e3e6d86170209eb31f5e4c624bce52d28600c4f0cb6bd821f814c79ac47", - "image_dtype": "`` with the ``histology`` extra):: @@ -269,19 +269,24 @@ def output_digests(result: Any) -> dict[str, str | None]: def output_values(result: Any) -> dict[str, Any]: - """Return a ScalarResult's image values and plain provenance, with the exclusion's digest. + """Return a ScalarResult's image and exclusion values and its plain provenance. Used for ``custom_od`` conversions: they solve a stain matrix through LAPACK, whose last bits depend on the CPU kernel OpenBLAS selects, so they are compared with a tolerance. """ - image = np.asarray(result.image) - return { - "exclusion": array_digest(result.exclusion), - "image_dtype": image.dtype.str, - "image_shape": list(image.shape), - "image_values": image.ravel().tolist(), - "provenance": json.loads(provenance_json(result.provenance)), - } + record: dict[str, Any] = {"provenance": json.loads(provenance_json(result.provenance))} + for name in ("image", "exclusion"): + values = getattr(result, name) + if values is None: + record[name] = None + continue + values = np.asarray(values) + record[name] = { + "dtype": values.dtype.str, + "shape": list(values.shape), + "values": values.ravel().tolist(), + } + return record def frozen_output(preset: str, result: Any) -> dict[str, Any]: diff --git a/tests/test_stain_check_options.py b/tests/test_stain_check_options.py index d3ffb6b..458f272 100644 --- a/tests/test_stain_check_options.py +++ b/tests/test_stain_check_options.py @@ -160,13 +160,19 @@ def test_a_none_polarizer_is_refused_as_missing_not_by_type(): assert str(refused.value) == "Preset 'psr_polarized' needs polarizer; it has no default." -@pytest.mark.parametrize("polarizer", ["circular", "linear", np.str_("circular")]) -def test_orientation_valid_is_a_python_bool(polarizer): +@pytest.mark.parametrize( + "polarizer", ["circular", "linear", np.str_("circular"), np.str_("linear")] +) +def test_polarizer_provenance_holds_a_python_str_and_bool(polarizer): pytest.importorskip("skimage") result = to_scalar( np.full((2, 3, 3), 90, np.uint8), axes="YXC", preset="psr_polarized", polarizer=polarizer ) - assert result.provenance["hints"]["orientation_valid"] is (polarizer == "circular") + assert type(result.provenance["polarizer"]) is str + assert result.provenance["polarizer"] == str(polarizer) + orientation_valid = result.provenance["hints"]["orientation_valid"] + assert type(orientation_valid) is bool + assert orientation_valid is (str(polarizer) == "circular") # Default stain vectors replaced by ones that must be refused: the default path is shown to run @@ -239,17 +245,23 @@ def test_outputs_are_those_of_v060(request, case): # custom_od solves a stain matrix through LAPACK: its last bits depend on the CPU kernel. values = frozen_output(preset, result) assert values.keys() == frozen.keys() - assert values["exclusion"] == frozen["exclusion"] - assert (values["image_dtype"], values["image_shape"]) == ( - frozen["image_dtype"], - frozen["image_shape"], - ) - np.testing.assert_allclose(values["image_values"], frozen["image_values"], rtol=RTOL, atol=0) + for name in ("image", "exclusion"): + if frozen[name] is None: + assert values[name] is None + continue + got, want = values[name], frozen[name] + assert (got["dtype"], got["shape"]) == (want["dtype"], want["shape"]) + np.testing.assert_allclose( + got["values"], want["values"], rtol=FLOAT32_RTOL, atol=FLOAT32_ATOL + ) assert_close(values["provenance"], frozen["provenance"]) +FLOAT32_RTOL, FLOAT32_ATOL = 1e-6, 1e-7 +"""Tolerance of a custom_od float32 image or exclusion value: a few float32 ULPs.""" + RTOL = 1e-9 -"""Relative tolerance of a custom_od value: far above LAPACK's last-bit differences.""" +"""Relative tolerance of a custom_od float64 provenance value (stain matrix, condition number).""" def assert_close(actual: object, expected: object) -> None: @@ -426,3 +438,49 @@ def test_check_options_needs_no_scikit_image(): ) assert result.returncode == 0, result.stderr assert "does not use white" in result.stdout + + +def self_referencing_list(*items): + """A list that holds itself after ``items``.""" + values = list(items) + values.append(values) + return values + + +def self_referencing_object_array(item): + """A 2-element object array holding ``item`` and itself.""" + values = np.empty(2, dtype=object) + values[0], values[1] = item, values + return values + + +@pytest.mark.parametrize( + "value, holds", + [ + (self_referencing_list(240, 235), False), + (self_referencing_list(240, np.ma.masked), True), + (self_referencing_object_array(240), False), + (self_referencing_object_array(np.ma.masked), True), + ([self_referencing_list(1), (np.ma.masked,)], True), + (np.arange(6.0).reshape(2, 3), False), + ], + ids=["list", "list masked", "object array", "object array masked", "nested", "numeric"], +) +def test_the_masked_search_ends_on_containers_that_hold_themselves(value, holds): + from morphobricks_normalization.optical_density import _holds_masked + + assert _holds_masked(value) is holds + + +def test_regions_refuse_a_masked_white_before_np_asarray(monkeypatch): + pixels = np.full((4, 4, 3), 120, np.uint8) + regions = {"eosin": np.ones((4, 4), bool), "hematoxylin": np.ones((4, 4), bool)} + + def asarray(*args, **kwargs): + raise AssertionError("np.asarray was called before the masked white was refused") + + monkeypatch.setattr(np, "asarray", asarray) + with pytest.raises(TypeError, match="white is a masked array"): + stain.stain_vectors_from_regions( + pixels, axes="YXC", white=[240, np.ma.masked, 245], regions=regions + )