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
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,36 @@ bit-identical (`tools/dump_outputs.py` and `--corpus` lines, and the 400 golden
images only gave no warning when treated images of `r1` existed at another time point.
The groups that are fitted are now checked, and the warning's items and message name them
(`r1::t=0`). Without `per_time_point` the warning is unchanged. No dump output changes.
- **Exact percentiles in the written method name (#35).** `normalization_method` wrote each
percentile with `:g`, six significant digits, so different settings shared a name
(`percentile=(1.000001, 99.8)` and `(1.000002, 99.8)` both wrote `percentile 1-99.8`) and
`Parameters.from_columns` read parameters fitted with one as the other's. Each percentile is
now the shortest decimal that reads back as exactly the same float, never in exponent form
(`NormalizationSettings.method_name`, `percentile_range` and the new `normalization.percentile_name`).
Names of percentiles with at most six significant digits and at least `0.0001`, the default
`percentile 1-99.8` among them, are unchanged; others change, for example
`percentile 1-99.8` becomes `percentile 1.000001-99.8`, and `percentile 1e-05-50` becomes
`percentile 0.00001-50`. **Not compatible:** a parameter row written before this change with
such percentiles holds the old short name and is refused by `from_columns` with the existing
message naming the row's and the settings' names ("Column normalization_method is
'percentile 1-99.8', but the settings use 'percentile 1.000001-99.8'"); fit the parameters
again (the owner's ruling of 10 November 2026: no backward compatibility before the first
pipeline release). Fitted offsets and scales do not change, and no dump output changes.
- **Clip limits and result type of `NormalizationParameters.apply` (#39).** Reversed clip
limits (`clip=(1, 0)`) gave every pixel the same value, and an integer `dtype` truncated
and wrapped the normalized values, without a word. `apply` now refuses, with a
`ValueError` naming the value, clip limits that are not two finite numbers with
`low < high` (equal limits too), and every result type but float32 and float64. Valid
calls give bit-identical results; no dump output changes.
- **RGB images of 32- and 64-bit integers are refused (#23).** `check_rgb` accepted every
integer type although its message, the contract's, asks for 8- or 16-bit colour channels,
and `apply_rgb` repeats CSBDeep's float32 arithmetic, which cannot hold every 32- or 64-bit
value (above `2**24`). `check_rgb`, and with it `fit` of RGB pixels or plane rows, now
refuses `int32`, `uint32`, `int64` and `uint64` with that message ("... but these images
have 3 channels of type int32 ..."). `uint8`, `uint16`, `int8` and `int16` are accepted as
before, with bit-identical results; no dump output changes. **Not compatible:** RGB
images of 32- or 64-bit integers that were fitted before are refused; save them as 8- or
16-bit, or choose image type `fluorescence`.

## v0.4.0 (2026-11-10)

Expand Down
18 changes: 15 additions & 3 deletions docs/contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,12 @@ Every method is an offset and a scale; applying it computes `(pixels − offset)

A zero or non-finite scale is an error with a plain-language message.

`NormalizationParameters.apply(pixels, clip=None, dtype=float32)` returns float32 or float64
only and clips, when asked, to two finite limits with `low < high`; any other result type
(integer, bool, complex, object, float16) and reversed, equal, non-finite or malformed clip
limits are refused with `ValueError` naming the value (issue #39), before any pixel is
normalized.

`normalization_exact` (§8) is `true` for histogram-based parameters exactly when every
pooled histogram has `max_representative_error == 0`, so never when a histogram has tails
(§1.4).
Expand Down Expand Up @@ -386,7 +392,12 @@ The method identifier is a setting (`settings.method`); the written name is what
| `fluorescence` | `minmax`, `percentile` (default), `zscore`, `robust_zscore` | `min-max`, `percentile <low>-<high>` (e.g. `percentile 1-99.8`), `z-score`, `robust z-score` |
| `rgb` | `percentile` (the only one) | `rgb percentile <low>-<high>` (e.g. `rgb percentile 1-99.8`) |

Percentile values in names are formatted with `:g` (as `percentile_range` does today).
Each percentile value in a name is the shortest decimal that reads back as exactly the same
float, never in exponent form (`percentile_name`, used by `percentile_range` and
`NormalizationSettings.method_name`): `1`, `99.8`, `1.000001`, `0.00001`; `-0.0` is written `0`.
The name therefore identifies the percentiles exactly, and `from_columns` refuses a row whose
percentiles differ from the settings' however little (issue #35). Values with at most six
significant digits and at least `0.0001` are written as `:g` wrote them before.

### 3.2 Fluorescence

Expand Down Expand Up @@ -505,7 +516,7 @@ exact. Which scopes a calling brick offers without the file is the calling brick
| `image_type` | Channels | Pixel types | Methods |
|---|---|---|---|
| `fluorescence` (default) | any | every type of §1.2.1 | §3.2 |
| `rgb` | exactly 3 (R, G, B) | integer types | §3.3 only |
| `rgb` | exactly 3 (R, G, B) | 8- and 16-bit integer types (`uint8`, `uint16`, `int8`, `int16`); 32- and 64-bit integers are refused (issue #23): CSBDeep's float32 arithmetic (§3.3) cannot hold every value above `2**24` | §3.3 only |

- The type is chosen by the user: in the preprocessing brick, which suggests `rgb` when the
metadata has an RGB samples axis (TIFF photometric RGB, three samples per pixel) and
Expand Down Expand Up @@ -725,7 +736,7 @@ Raised as exceptions before any pixel is normalized; the calling brick blocks th
| A method other than `percentile` with `rgb` | `MethodNotAllowedError` | "RGB images are normalized with StarDist's percentile method only, which scales the three colour channels together. Use method 'percentile'." |
| An unknown scope, including the removed `condition`, `technical_repeat` and `experiment` | `ValueError` | "Scope '{scope}' does not exist. Choose 'biological_repeat' (one biological repeat across all conditions), 'image' or 'patch'." |
| `biological_repeat` without the statistics file | `MethodNotAllowedError` | "The biological-repeat scope needs the preprocessing brick's plane_histograms.csv, which was not given. Run the preprocessing brick on this experiment, or use the image or patch scope." |
| `rgb` without exactly three channels, or with a float pixel type | `ValueError` | "RGB normalization needs images with exactly three 8- or 16-bit colour channels, but these images have {n} channels of type {dtype}. Check the axis mapping (the samples axis must be the channel axis), or choose image type 'fluorescence'." |
| `rgb` without exactly three channels, or with a pixel type other than an 8- or 16-bit integer (float, 32- or 64-bit integer) | `ValueError` | "RGB normalization needs images with exactly three 8- or 16-bit colour channels, but these images have {n} channels of type {dtype}. Check the axis mapping (the samples axis must be the channel axis), or choose image type 'fluorescence'." |
| A `(condition, biological_repeat)` missing from `repeat_groups` | `ValueError` | Names the missing pairs and asks to update the experiment design in the preprocessing brick. |
| Unsupported pixel type; bin count not allowed (§1.2) | `UnsupportedDtypeError`, `ValueError` | Existing messages |
| Group without finite pixels | `NoValidPixelsError` | Existing message |
Expand All @@ -747,6 +758,7 @@ Raised as exceptions before any pixel is normalized; the calling brick blocks th
| Rows or images with different numbers of channels (N43) | `CsvFormatError` (table), `ValueError` (pixels) | Names the images and their channel counts, and says that every image of an experiment must have the same channels |
| `from_columns` with a missing or unreadable column, an unknown `normalization_scope`, or a channel without both its offset and scale | `ValueError` | Names the column |
| `from_columns` with a `normalization_method` or `normalization_scope` other than the settings' | `ValueError` | Names the column, the row's value and the settings' value, and asks to read the parameters with the settings of the run that wrote them |
| `NormalizationParameters.apply` with a result type other than float32 or float64, or clip limits that are not two finite numbers with `low < high` | `ValueError` | Names the type ("dtype must be float32 or float64; got 'uint8'") or the limits ("clip must be (low, high) with low < high; got low=1.0, high=0.0") |
| `Parameters` or `NormalizationParameters` with a non-finite offset, or a scale that is not finite and above 0; a channel index that is not a whole number 0 or above | `ValueError` | Names the channel (or method) and the value |

Tables of schema versions 1 to 3 are recognised by the `schema_version` value of their rows alone and
Expand Down
71 changes: 65 additions & 6 deletions src/morphobricks_normalization/normalization.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,56 @@ def apply(
Values to normalize; the input is never modified.
clip
Optional ``(low, high)`` limits applied after normalizing, for
example ``(0, 1)`` for min-max scaling.
example ``(0, 1)`` for min-max scaling: two finite numbers with
``low < high`` (equal limits would make every pixel the same value).
dtype
Floating-point type of the result.
Type of the result: float32 (default) or float64.

Raises
------
ValueError
For clip limits that are not two finite numbers with ``low < high``,
or another result type (issue #39): an integer type would truncate
and wrap the normalized values.
"""
result_dtype = _result_dtype(dtype)
limits = None if clip is None else _clip_limits(clip)
result = (np.asarray(pixels, dtype=np.float64) - self.offset) / self.scale
if clip is not None:
np.clip(result, clip[0], clip[1], out=result)
return result.astype(dtype, copy=False)
if limits is not None:
np.clip(result, *limits, out=result)
return result.astype(result_dtype, copy=False)


def _result_dtype(dtype: np.dtype | type | str) -> np.dtype:
"""Return ``dtype`` if it is float32 or float64; refuse every other type by name."""
try:
result = np.dtype(dtype)
except TypeError:
raise ValueError(f"dtype must be float32 or float64; got {dtype!r}.") from None
if result.name not in ("float32", "float64"): # by name: either byte order is allowed
raise ValueError(
f"dtype must be float32 or float64; got {result.name!r}. Normalized values are "
"real numbers: another type would round, truncate or wrap them."
)
return result


def _clip_limits(clip: tuple[float, float]) -> tuple[float, float]:
"""Return ``clip`` as two finite floats with ``low < high``, or refuse it by name."""
try:
if isinstance(clip, str | bytes):
raise TypeError
low, high = (float(limit) for limit in clip)
except (TypeError, ValueError):
raise ValueError(f"clip must be two numbers, (low, high); got {clip!r}.") from None
if not (np.isfinite(low) and np.isfinite(high)):
raise ValueError(f"clip limits must be finite numbers; got {(low, high)!r}.")
if not low < high:
raise ValueError(
f"clip must be (low, high) with low < high; got low={low}, high={high}. Reversed "
"or equal limits would give every pixel the same value."
)
return low, high


def min_max(pooled: PooledHistogram) -> NormalizationParameters:
Expand All @@ -83,7 +125,24 @@ def percentile_range(
if not 0 <= low < high <= 100:
raise ValueError(f"Expected 0 <= low < high <= 100; got low={low}, high={high}.")
lower = pooled.percentile(low)
return _parameters(f"percentile {low:g}-{high:g}", lower, pooled.percentile(high) - lower)
return _parameters(percentile_name(low, high), lower, pooled.percentile(high) - lower)


def percentile_name(low: float, high: float) -> str:
"""Return the written name of the percentile method, ``percentile <low>-<high>``.

Each percentile is written as the shortest decimal that reads back as exactly the same
float, never in exponent form (``1``, ``99.8``, ``1.000001``, ``0.0000001``), so two
settings with different percentiles never share a name (issue #35): ``from_columns``
compares this name to refuse parameters fitted with other percentiles. ``-0.0`` is
written as ``0``, the same percentile as ``0.0``.
"""
return f"percentile {_exact_decimal(low)}-{_exact_decimal(high)}"


def _exact_decimal(value: float) -> str:
"""Shortest positional decimal that round-trips to ``value`` (``-0.0`` as ``0``)."""
return np.format_float_positional(float(value) + 0.0, unique=True, trim="-")


def z_score(pooled: PooledHistogram) -> NormalizationParameters:
Expand Down
16 changes: 11 additions & 5 deletions src/morphobricks_normalization/rgb.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
# Added to the scale by csbdeep.utils.normalize_mi_ma (its ``eps``).
EPSILON = 1e-20
RGB_CHANNEL_COUNT = 3
# The pixel types of RGB images: 8- and 16-bit integers, every value exact in float32 (#23).
RGB_DTYPES = frozenset(np.dtype(name) for name in ("uint8", "uint16", "int8", "int16"))


def rgb_percentile(
Expand Down Expand Up @@ -80,7 +82,11 @@ def apply_rgb(


def check_rgb(channel_count: int, dtypes: Iterable[np.dtype]) -> None:
"""Refuse images that are not three channels of an integer type (contract §5, §9.1).
"""Refuse images that are not three channels of an 8- or 16-bit integer type (contract §5).

32- and 64-bit integers are refused (issue #23): CSBDeep's float32 arithmetic, which
:func:`apply_rgb` repeats, cannot hold every such value (above ``2**24``), and the
contract's message asks for 8- or 16-bit colour channels.

Parameters
----------
Expand All @@ -95,8 +101,8 @@ def check_rgb(channel_count: int, dtypes: Iterable[np.dtype]) -> None:
With the contract's message, naming the channel count and pixel type.
"""
dtypes = sorted({np.dtype(dtype).name for dtype in dtypes})
integer = all(np.issubdtype(np.dtype(name), np.integer) for name in dtypes)
if channel_count != RGB_CHANNEL_COUNT or not integer:
supported = all(np.dtype(name) in RGB_DTYPES for name in dtypes)
if channel_count != RGB_CHANNEL_COUNT or not supported:
raise ValueError(
"RGB normalization needs images with exactly three 8- or 16-bit colour channels, "
f"but these images have {channel_count} channels of type {' and '.join(dtypes)}. "
Expand All @@ -106,7 +112,7 @@ def check_rgb(channel_count: int, dtypes: Iterable[np.dtype]) -> None:


def check_rgb_pixels(pixels: np.ndarray, channel_axis: int | None) -> None:
"""Refuse a pixel array that is not three integer channels, before any work (issue #52).
"""Refuse a pixel array that is not three 8- or 16-bit channels, before any work (#52, #23).

Parameters
----------
Expand All @@ -132,7 +138,7 @@ def check_rgb_pixels(pixels: np.ndarray, channel_axis: int | None) -> None:


def check_rgb_records(records: list[PlaneRecord], group: str | None) -> None:
"""Refuse rows that are not three channels of one integer type (contract §5, §9.1, #51).
"""Refuse rows that are not three channels of one 8- or 16-bit type (contract §5, #51, #23).

RGB pools all images of a group into one distribution, so bit depths are never mixed
or converted; the message names the images of each pixel type.
Expand Down
11 changes: 7 additions & 4 deletions src/morphobricks_normalization/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

from ._version import __version__
from .issues import BIOLOGICAL_REPEAT_SCOPE, WARNING, Issue
from .normalization import NORMAL_CONSISTENCY
from .normalization import NORMAL_CONSISTENCY, percentile_name

IMAGE_TYPES = ("fluorescence", "rgb")
FLUORESCENCE_METHODS = ("minmax", "percentile", "zscore", "robust_zscore")
Expand Down Expand Up @@ -137,10 +137,13 @@ def __post_init__(self) -> None:

@property
def method_name(self) -> str:
"""Written name of the method, as in ``normalization_method`` (contract §3.1)."""
"""Written name of the method, as in ``normalization_method`` (contract §3.1).

The percentiles are written exactly (:func:`~.normalization.percentile_name`), so
the name identifies the settings that change the fitted parameters (issue #35).
"""
if self.method == "percentile":
low, high = self.percentile
name = f"percentile {low:g}-{high:g}"
name = percentile_name(*self.percentile)
return f"rgb {name}" if self.image_type == "rgb" else name
return _METHOD_NAMES[self.method]

Expand Down
26 changes: 26 additions & 0 deletions tests/test_fitting.py
Original file line number Diff line number Diff line change
Expand Up @@ -400,3 +400,29 @@ def test_parameters_from_records_and_existing_parameters_apply_identically():
pixels = np.random.default_rng(20).integers(0, 5000, (8, 8)).astype(np.uint16)
np.testing.assert_array_equal(parameters.apply(pixels), existing.apply(pixels))
assert isinstance(existing, NormalizationParameters)


@pytest.mark.parametrize("image_type", ["fluorescence", "rgb"])
def test_from_columns_refuses_rows_of_other_percentiles_with_a_colliding_short_name(image_type):
# Issue #35: 1.000001 and 1.000002 both printed as "1" with six significant
# digits, so a row fitted with one was read back with the other's settings.
written = NormalizationSettings(image_type=image_type, percentile=(1.000001, 99.8))
reading = NormalizationSettings(image_type=image_type, percentile=(1.000002, 99.8))
channels = 3 if image_type == "rgb" else 1
parameters = Parameters(
written.method_name,
"image",
"a",
True,
(1.0,) * channels,
(2.0,) * channels,
tuple(range(channels)),
)
columns = parameters.to_columns()
assert Parameters.from_columns(columns, written) == parameters
with pytest.raises(ValueError, match="Column normalization_method is .*1.000001-99.8"):
Parameters.from_columns(columns, reading)
# A row written before the fix carries the short name and is refused too.
columns["normalization_method"] = columns["normalization_method"].replace("1.000001", "1")
with pytest.raises(ValueError, match="Column normalization_method is .*'.*percentile 1-99.8'"):
Parameters.from_columns(columns, written)
Loading
Loading