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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions docs/contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 25 additions & 3 deletions src/morphobricks_normalization/optical_density.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
Loading
Loading