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
5 changes: 4 additions & 1 deletion docs/contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,10 @@ histogram's passes (table above). A `RangeCheck` holds:
`narrow_histogram_range` warning (§9.2) per channel with a narrow plane, listing the planes
in `items`, with `channel` set and the fewest central bins, the pixels outside the robust
limits and the robust central bins in the message. Only the preprocessing brick reports it,
in its step 3; `fit` adds no warning and its zero-spread error is unchanged (N57).
in its step 3. A channel must be a whole number 0 or above (nothing is rounded or parsed),
and the narrow planes of one channel must share one set of settings (range, central
percentiles, robust quantiles, `min_central_bins`); otherwise `IncompatibleRangeChecksError`
(a `ValueError`) names the planes of each setting (issue #59). `fit` adds no warning and its zero-spread error is unchanged (N57).

Parameters fitted from pixels (§4.5) use min-max histograms; the setting applies to
`plane_histograms.csv`.
Expand Down
8 changes: 7 additions & 1 deletion src/morphobricks_normalization/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,12 @@
WeightedHistogram,
pooled_statistics,
)
from .ranges import HistogramSettings, RangeCheck, range_check_issues
from .ranges import (
HistogramSettings,
IncompatibleRangeChecksError,
RangeCheck,
range_check_issues,
)
from .records import SINGLE, PlaneRecord, group_records, make_plane_id
from .rgb import apply_rgb, rgb_percentile
from .scopes import group_key, group_name, scope_groups
Expand All @@ -52,6 +57,7 @@
"HistogramSettings",
"HistogramTail",
"ImageSummary",
"IncompatibleRangeChecksError",
"IncompatibleSummariesError",
"IntensityCheckSettings",
"Issue",
Expand Down
50 changes: 49 additions & 1 deletion src/morphobricks_normalization/ranges.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@

from ._version import __version__
from .issues import NARROW_HISTOGRAM_RANGE, WARNING, Issue
from .parameter_checks import channel_indices

HISTOGRAM_RANGES = ("minmax", "robust")
DEFAULT_ROBUST_QUANTILES = (0.01, 99.99)
Expand Down Expand Up @@ -185,6 +186,43 @@ def narrow(self) -> bool:
return self.central_bins < self.min_central_bins


class IncompatibleRangeChecksError(ValueError):
"""Raised for narrow range checks of one channel made with different settings.

Planes checked with another histogram range, central percentiles, robust
quantiles or ``min_central_bins`` measure different things; one warning
cannot describe them, and none of the settings is taken as representative.
"""


def _settings_of(check: RangeCheck) -> tuple:
return (
check.histogram_range,
tuple(check.central_percentiles),
tuple(check.robust_quantiles),
check.min_central_bins,
)


def _check_one_setting(channel: int, found: list[tuple[str, RangeCheck]]) -> None:
"""Refuse narrow checks of one channel that were made with different settings."""
by_settings: dict[tuple, list[str]] = {}
for plane_id, check in found:
by_settings.setdefault(_settings_of(check), []).append(plane_id)
if len(by_settings) > 1:
parts = "; ".join(
f"range={sig[0]}, central percentiles={sig[1]}, robust quantiles={sig[2]}, "
f"min_central_bins={sig[3]}: {', '.join(dict.fromkeys(ids))}"
for sig, ids in sorted(by_settings.items(), key=lambda kv: repr(kv[0]))
)
raise IncompatibleRangeChecksError(
f"The narrow range checks of channel C{channel} were made with different "
f"settings ({parts}). One warning cannot describe them, and no setting is "
"taken as representative. Check all planes with the same histogram settings, "
"or pass the planes of each setting separately."
)


def range_check_issues(checks: Iterable[tuple[str, int, RangeCheck | None]]) -> list[Issue]:
"""Warnings for the narrow central ranges among ``checks``, one per channel (N60).

Expand All @@ -200,15 +238,25 @@ def range_check_issues(checks: Iterable[tuple[str, int, RangeCheck | None]]) ->
One ``narrow_histogram_range`` warning per channel with at least one
narrow plane, in channel order; ``items`` lists the planes and
``channel`` holds the channel. Empty when no range is narrow.

Raises
------
ValueError
For a channel that is not a whole number 0 or above (nothing is rounded
or parsed).
IncompatibleRangeChecksError
For narrow planes of one channel whose checks used different settings.
"""
by_channel: dict[int, list[tuple[str, RangeCheck]]] = {}
for plane_id, channel, check in checks:
(channel,) = channel_indices([channel])
if check is not None and check.narrow:
by_channel.setdefault(int(channel), []).append((plane_id, check))
by_channel.setdefault(channel, []).append((plane_id, check))
issues = []
for channel, found in sorted(by_channel.items()):
planes = list(dict.fromkeys(plane_id for plane_id, _ in found))
first = found[0][1]
_check_one_setting(channel, found)
low, high = (_percent(q) for q in first.central_percentiles)
q_low, q_high = (_percent(q) for q in first.robust_quantiles)
fewest = min(check.central_bins for _, check in found)
Expand Down
53 changes: 53 additions & 0 deletions tests/test_range_check_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""range_check_issues refuses malformed channels and mixed settings (issue #59)."""

import numpy as np
import pytest

from morphobricks_normalization import (
RangeCheck,
range_check_issues,
)


def _narrow(**changes):
values = {
"histogram_range": "minmax",
"central_percentiles": (1.0, 99.8),
"central_bins": 5,
"robust_quantiles": (0.01, 99.99),
"robust_limits": (0, 1),
"outside_robust_count": 3,
"robust_central_bins": 900,
"min_central_bins": 100,
}
return RangeCheck(**{**values, **changes})


@pytest.mark.parametrize(
"change",
[
{"histogram_range": "robust"},
{"robust_quantiles": (1.0, 99.0)},
{"central_percentiles": (2.0, 99.0)},
{"min_central_bins": 200},
],
)
def test_range_check_issues_refuse_mixed_settings_in_either_order(change):
pair = [("A", 0, _narrow()), ("B", 0, _narrow(**change))]
for order in (pair, pair[::-1]):
with pytest.raises(ValueError, match="channel C0.*A.*B|channel C0.*B.*A") as refused:
range_check_issues(order)
assert type(refused.value).__name__ == "IncompatibleRangeChecksError"
# the same settings in another channel are not mixed
assert len(range_check_issues([("A", 0, _narrow()), ("B", 1, _narrow(**change))])) == 2


@pytest.mark.parametrize("channel", [True, 1.0, 1.5, "1", -1, None])
def test_range_check_issues_refuse_malformed_channels(channel):
with pytest.raises(ValueError, match="whole number counted from 0"):
range_check_issues([("A", channel, _narrow())])


def test_range_check_issues_accept_numpy_integer_channels():
(issue,) = range_check_issues([("A", np.int64(2), _narrow())])
assert issue.channel == 2 and type(issue.channel) is int
Loading