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 19594b4..4da6466 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, @@ -75,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 bfd390c..d0b2349 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,36 @@ 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): outputs are + 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. +- **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). 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 + 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) 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..86753fe 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -551,6 +551,32 @@ 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` 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 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. 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. + **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/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 859a1d5..773b3e8 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) @@ -191,7 +208,7 @@ def _custom_od( 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.") @@ -201,10 +218,89 @@ 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}.") +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` 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, 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) + + def _provenance( recipe: StainPreset, pixels: np.ndarray, axes: str, extra: Mapping[str, Any] ) -> Mapping[str, Any]: @@ -212,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, @@ -285,34 +381,32 @@ 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} 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 new file mode 100644 index 0000000..731eee4 --- /dev/null +++ b/tests/data/stain_v060_parity.json @@ -0,0 +1,2534 @@ +{ + "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": { + "dtype": " 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..6e22f15 --- /dev/null +++ b/tests/stain_parity_cases.py @@ -0,0 +1,346 @@ +"""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` (for ``custom_od``, the image and exclusion values and the provenance, +compared with a tolerance: :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 \\ + > 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 output_values(result: Any) -> dict[str, Any]: + """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. + """ + 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]: + """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: + 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)] = frozen_output(preset, 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 new file mode 100644 index 0000000..458f272 --- /dev/null +++ b/tests/test_stain_check_options.py @@ -0,0 +1,486 @@ +"""check_options: every to_scalar check of the preset and its options, without pixels (#97).""" + +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, + frozen_output, + output_digests, + valid_pixels, +) + +# 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 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 + + +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, + "polarizer must be the text 'circular' or 'linear'; got numpy.ndarray " + "array(['linear'], dtype=' 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(): + 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), + object_values(240, np.ma.masked, 245), + object_values(240, np.ma.MaskedArray(235.0, mask=False), 245), + [[240, np.ma.masked, 245]], + (240, [235, (np.ma.masked,)], 245), + [240, object_values(np.ma.masked), 245], + [np.float64(240), object_values(object_values(np.ma.masked)), 245], + ], +) +@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) + + +def holds_masked(value): + """Whether ``value`` holds a masked array at any depth (the test's own search).""" + if isinstance(value, np.ma.MaskedArray): + return True + if isinstance(value, np.ndarray) and value.dtype == object: + return any(holds_masked(item) for item in value.ravel().tolist()) + return isinstance(value, list | tuple) and any(holds_masked(item) for item in value) + + +@pytest.mark.parametrize( + "vector", + [ + np.ma.MaskedArray((0.65, 0.70, 0.29), mask=False), + [0.65, np.ma.masked, 0.29], + object_values(0.65, np.ma.masked, 0.29), + object_values(0.65, np.ma.MaskedArray(0.7, mask=True), 0.29), + [[0.65, np.ma.masked, 0.29]], + (0.65, (0.70, [np.ma.masked]), 0.29), + [0.65, object_values(np.ma.masked), 0.29], + ], +) +@pytest.mark.parametrize("name", ["counterstain", "residual"]) +def test_a_masked_stain_vector_is_refused_before_np_asarray(monkeypatch, name, vector): + real_asarray = np.asarray + + def asarray(value, *args, **kwargs): + if holds_masked(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": (0.65, 0.70, 0.29), name: vector} + with pytest.raises(TypeError, match=f"Stain vector '{name}' is a masked array"): + check_options("psr_brightfield", white=WHITE, stain_vectors=vectors) + + +def test_masked_content_is_not_searched_in_strings_or_numeric_arrays(): + # Only lists, tuples and object arrays can hold a masked value; these reach the usual checks. + with pytest.raises(ValueError, match="white must be three numbers"): + check_options("lab_a", white="masked") + with pytest.raises(ValueError, match="white must be three numbers"): + check_options("lab_a", white=object_values(240, 235.5, np.float32(245))) + assert check_options("lab_a", white=np.array([240, 235.5, 245], dtype=np.float32)) is None + + +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 + + +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 + )