diff --git a/CHANGELOG.md b/CHANGELOG.md index 4749c54..e77dca2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Data-driven omero display window: `convert()` (and the `zarrmony convert` + CLI) now computes per-channel `(min, 99.9th percentile)` on the pyramid + write pass and writes them into `omero.channels[i].window.start` / `.end`, + so uint16/uint32/float fluorescence stores open with sensible auto-contrast + in napari / OMERO on first click instead of appearing black. Fused into the + same `da.compute` call as the pyramid writes — raw data is read once. The + quantile is computed on the coarsest pyramid level (recorded in the audit + as `contrast.method = "coarsest-pyramid-level"`) so the sort stays cheap + even for 80+ GB LIF inputs. New keyword: `convert(..., contrast_percentile=99.9)` + — pass a different float in `(0, 100)` to shift the tail, or `None` to skip + the extra ops and keep the dtype-range placeholder from issue #50. CLI + mirrors: `--contrast-percentile FLOAT` and `--no-contrast`. The audit + records the resolved percentile under `config.contrast_percentile` and the + per-channel bounds under `per_scene[i].contrast.per_channel[]`. Also + backfills `_channels_for_current_scene` in the plate writer to pass + `window=_dtype_window(reader.dtype)` (closing the #50 gap on plate FOVs). + (#53) - Per-scene objective-lens extraction for Leica LIF conversions. When a LIF scene's XML carries objective attributes (on `` and/or `` elements), the audit diff --git a/src/zarrmony/api.py b/src/zarrmony/api.py index bd06d9c..2061565 100644 --- a/src/zarrmony/api.py +++ b/src/zarrmony/api.py @@ -462,6 +462,7 @@ def convert( pyramid_min_size: int = 256, chunk_shape: Sequence[int] | None = None, channel_colors: ChannelColorSpec = None, + contrast_percentile: float | None = 99.9, force: bool = False, checksum: bool = False, validate: bool = True, @@ -512,6 +513,17 @@ def convert( Non-LIF readers ignore the flag entirely. + ``contrast_percentile`` (issue #53) drives the omero display window's + ``start``/``end`` fields from actual data instead of the dtype range: + + - ``99.9`` (default) — per-channel ``(min, 99.9th percentile)`` computed + from the coarsest pyramid level, fused into the pyramid write's dask + graph so raw data is read once. Fixes "everything opens black except + the brightest pixel" on uint16 fluorescence stores. + - ``float`` in ``(0, 100)`` — same shape, but at a different percentile. + - ``None`` — skip the extra ops; ``start``/``end`` stay pinned to the + dtype range (the issue-#50 behavior). + ``channel_colors`` (ADR-0007) governs per-channel display colors: - ``None`` (default) — emission-band colorblind scheme (cyan/green/yellow/ @@ -543,6 +555,11 @@ def convert( f"(got {channel_colors!r}); pass a dict for per-channel overrides " "or None for the emission-band scheme" ) + if contrast_percentile is not None and not (0.0 < contrast_percentile < 100.0): + raise ValueError( + f"contrast_percentile must be None or a float in (0, 100) exclusive; " + f"got {contrast_percentile!r}" + ) reader, plugin, match_score = get_reader(input_path) if not reader.scenes: @@ -592,6 +609,7 @@ def convert( "pyramid_min_size": pyramid_min_size, "chunk_shape": list(chunk_shape) if chunk_shape else None, "channel_colors": audit_channel_colors, + "contrast_percentile": contrast_percentile, "force": force, "checksum": checksum, "validate": validate, @@ -609,6 +627,7 @@ def convert( pyramid_min_size=pyramid_min_size, chunk_shape=chunk_shape, channel_colors=channel_colors, + contrast_percentile=contrast_percentile, force=force, checksum=checksum, config=config, @@ -626,6 +645,7 @@ def convert( pyramid_min_size=pyramid_min_size, chunk_shape=chunk_shape, channel_colors=channel_colors, + contrast_percentile=contrast_percentile, force=force, checksum=checksum, config=config, @@ -641,6 +661,7 @@ def convert( pyramid_min_size=pyramid_min_size, chunk_shape=chunk_shape, channel_colors=channel_colors, + contrast_percentile=contrast_percentile, force=force, checksum=checksum, config=config, @@ -816,6 +837,7 @@ def _convert_per_scene( pyramid_min_size: int, chunk_shape: Sequence[int] | None, channel_colors: ChannelColorSpec, + contrast_percentile: float | None, force: bool, checksum: bool, config: dict, @@ -862,6 +884,7 @@ def _convert_per_scene( pyramid_min_size=pyramid_min_size, chunk_shape=chunk_shape, channel_colors=channel_colors, + contrast_percentile=contrast_percentile, force=force, checksum=checksum, config=config, @@ -963,6 +986,7 @@ def _convert_per_scene( image_name=scene_name, xarr_override=override_xarr, record_mosaic_summary=not reassembly_mode, + contrast_percentile=contrast_percentile, ) scene_record["store_path"] = store_path scene_record["dirname"] = dirnames[scene_index] @@ -1107,6 +1131,7 @@ def _convert_per_tile_scene( pyramid_min_size: int, chunk_shape: Sequence[int] | None, channel_colors: ChannelColorSpec, + contrast_percentile: float | None, force: bool, checksum: bool, config: dict, @@ -1189,6 +1214,7 @@ def _convert_per_tile_scene( # the audit's per-tile discriminator + tile_stores belong on the # audit's mosaic block, not duplicated under per_scene[0].mosaic. record_mosaic_summary=False, + contrast_percentile=contrast_percentile, ) scene_record["store_path"] = store_path scene_record["tile_index"] = m @@ -1310,6 +1336,7 @@ def _convert_bf2raw( pyramid_min_size: int, chunk_shape: Sequence[int] | None, channel_colors: ChannelColorSpec, + contrast_percentile: float | None, force: bool, checksum: bool, config: dict, @@ -1339,6 +1366,7 @@ def _convert_bf2raw( chunk_shape=chunk_shape, channels=channels, image_name=scene_name, + contrast_percentile=contrast_percentile, ) per_scene_records.append(scene_record) @@ -1403,6 +1431,7 @@ def _convert_plate( pyramid_min_size: int, chunk_shape: Sequence[int] | None, channel_colors: ChannelColorSpec, + contrast_percentile: float | None, force: bool, checksum: bool, config: dict, @@ -1444,6 +1473,7 @@ def _ome_image_for_field(scene_index: int, scene_record: dict) -> Image: pyramid_min_size=pyramid_min_size, chunk_shape=chunk_shape, channel_colors=channel_colors, + contrast_percentile=contrast_percentile, ome_image_for_field=_ome_image_for_field, ome_xml_builder=build_combined_ome_xml, source_xml=source_xml, diff --git a/src/zarrmony/cli.py b/src/zarrmony/cli.py index 50252d6..99d570b 100644 --- a/src/zarrmony/cli.py +++ b/src/zarrmony/cli.py @@ -112,6 +112,25 @@ def _parse_chunk_shape( "output." ), ) +@click.option( + "--contrast-percentile", + type=float, + default=99.9, + show_default=True, + help=( + "Data-driven omero display window: per-channel (min, Nth percentile) " + "computed off the coarsest pyramid level and written into " + "omero.channels[i].window.start/end. Fused into the pyramid write so " + "raw data is read once. Pass -1 (or use --no-contrast) to disable and " + "keep the dtype-range placeholder from issue #50." + ), +) +@click.option( + "--no-contrast", + "no_contrast", + is_flag=True, + help="Skip percentile-based contrast; leave window.start/end at the dtype range.", +) @click.option( "--lif-mosaic", type=click.Choice( @@ -149,6 +168,8 @@ def convert_cmd( layout: str, pyramid_min_size: int, chunk_shape: tuple[int, ...] | None, + contrast_percentile: float, + no_contrast: bool, force: bool, checksum: bool, validate: bool, @@ -161,6 +182,14 @@ def convert_cmd( ``.ome.zarr`` per scene under OUTPUT, plate-shaped readers write a single OME-NGFF HCS plate store at OUTPUT. """ + # --no-contrast wins over --contrast-percentile; either -1 sentinel or the + # flag disables percentile-based contrast at the API boundary. + resolved_contrast: float | None + if no_contrast or contrast_percentile < 0: + resolved_contrast = None + else: + resolved_contrast = contrast_percentile + try: result = zm_api.convert( input_path=input_path, @@ -168,6 +197,7 @@ def convert_cmd( layout=layout, pyramid_min_size=pyramid_min_size, chunk_shape=chunk_shape, + contrast_percentile=resolved_contrast, force=force, checksum=checksum, validate=validate, diff --git a/src/zarrmony/writers/plate.py b/src/zarrmony/writers/plate.py index e23a58b..aa34a79 100644 --- a/src/zarrmony/writers/plate.py +++ b/src/zarrmony/writers/plate.py @@ -35,7 +35,7 @@ select_auto_stitch_cascade, ) from zarrmony.readers.plate import PlateField, PlateLayout -from zarrmony.writers.scene import write_scene +from zarrmony.writers.scene import _dtype_window, write_scene NGFF_VERSION = "0.5" @@ -183,7 +183,9 @@ def _channels_for_current_scene( Accepts the same ``channel_colors`` spec as ``convert()``: a dict per-channel override, the ``"source-file"`` sentinel (degrades to band-scheme on this - non-LIF path — see :func:`api._channels_for_scene`), or ``None``. + non-LIF path — see :func:`api._channels_for_scene`), or ``None``. Passes + ``window=`` so a plate FOV honours the dtype-range display bounds (issue + #50) rather than falling through to bioio-ome-zarr's 0–255 default. """ channel_names = ( list(reader.channel_names) if getattr(reader, "channel_names", None) else [] @@ -192,8 +194,10 @@ def _channels_for_current_scene( return None overrides = channel_colors if isinstance(channel_colors, dict) else None colors = colors_for_channels(channel_names, overrides=overrides) + window = _dtype_window(reader.dtype) return [ - Channel(label=n, color=c) for n, c in zip(channel_names, colors, strict=True) + Channel(label=n, color=c, window=window) + for n, c in zip(channel_names, colors, strict=True) ] @@ -265,6 +269,7 @@ def write_plate( pyramid_min_size: int = 256, chunk_shape: Sequence[int] | None = None, channel_colors: dict[str, str] | str | None = None, + contrast_percentile: float | None = None, ome_image_for_field: Any = None, ome_xml_builder: Any = None, source_xml: str | None = None, @@ -381,6 +386,7 @@ def write_plate( image_name=image_name, xarr_override=grid_xarr, record_mosaic_summary=not grid_stitch_this_fov, + contrast_percentile=contrast_percentile, ) scene_record.update( { diff --git a/src/zarrmony/writers/scene.py b/src/zarrmony/writers/scene.py index 0313dd3..970ecff 100644 --- a/src/zarrmony/writers/scene.py +++ b/src/zarrmony/writers/scene.py @@ -14,9 +14,19 @@ import xarray as xr from bioio_ome_zarr.writers import Channel, OMEZarrWriter +from zarrmony._storage import open_root_group from zarrmony.transforms import NGFF_AXIS_TYPE, NGFF_AXIS_UNIT, normalize_axes from zarrmony.writers.pyramid import build_pyramid, compute_level_shapes +# Approximation label recorded in the audit whenever data-driven contrast runs. +# The min + percentile are computed off the COARSEST pyramid level rather than +# the base — the coarse level is derived from the base in the same dask graph, +# so raw pixels are still read once (piggybacks on the pyramid write pass), and +# the sort/quantile stays trivially cheap even for 80+ GB inputs. Mean-pooling +# raises the observed min a hair and blurs the tail slightly; for a viewer +# auto-contrast default the difference is well below what a human eye reads. +_CONTRAST_METHOD = "coarsest-pyramid-level" + class ZarrmonyWriter(OMEZarrWriter): """OMEZarrWriter subclass that lets us initialize the on-disk arrays @@ -28,8 +38,20 @@ def initialize(self) -> None: if not self._initialized: self._initialize() - def write_pyramid(self, level_arrays: Sequence[da.Array]) -> None: - """Write pre-computed per-level dask arrays into the on-disk pyramid.""" + def write_pyramid( + self, + level_arrays: Sequence[da.Array], + *, + extra_ops: Sequence[da.Array] = (), + ) -> tuple[Any, ...]: + """Write pre-computed per-level dask arrays into the on-disk pyramid. + + ``extra_ops`` are additional lazy dask values (e.g. per-channel min / + percentile) fused into the same ``da.compute`` call so the raw data is + read once — the pyramid write and the extras share the underlying + chunk reads. Returns a tuple of the computed values for ``extra_ops`` + in the order they were passed; empty when no extras were provided. + """ self.initialize() if len(level_arrays) != len(self.datasets): raise ValueError( @@ -44,7 +66,9 @@ def write_pyramid(self, level_arrays: Sequence[da.Array]) -> None: ops.append(da.to_zarr(src, self.datasets[i], compute=False)) else: ops.append(da.store(src, self.datasets[i], lock=True, compute=False)) - da.compute(*ops) + n_write = len(ops) + results = da.compute(*ops, *extra_ops) + return tuple(results[n_write:]) def _physical_scales_for_dims(dims: Sequence[str], reader: Any) -> list[float]: @@ -101,6 +125,86 @@ def _default_channels(channel_names: Sequence[str], dtype: np.dtype) -> list[Cha ] +def _channel_contrast_ops( + coarse: da.Array, + dims: Sequence[str], + channel_count: int, + contrast_percentile: float, +) -> list[da.Array]: + """Per-channel ``(min, percentile)`` lazy values on the coarsest pyramid level. + + Returns a flat list of length ``2 * channel_count``: for channel ``i`` the + entries live at indices ``2*i`` (min) and ``2*i + 1`` (percentile). Callers + thread this list through :meth:`ZarrmonyWriter.write_pyramid`'s + ``extra_ops`` so the underlying chunk reads fuse with the pyramid writes, + then re-pair the results. Emits nothing (returns ``[]``) when the array + carries no channel dimension AND ``channel_count`` is zero, so the "no + omero channels to update" path stays a no-op. + + Percentile is computed via :func:`dask.array.percentile`'s default + ``internal_method`` — no ``crick`` T-digest dependency — and only on the + coarse level, which for a typical microscopy scene is a few hundred KB per + channel. See ``_CONTRAST_METHOD`` for the approximation trade-off. + """ + c_axis = dims.index("C") if "C" in dims else None + ops: list[da.Array] = [] + for i in range(channel_count): + if c_axis is None: + ch = coarse + else: + idx: list[Any] = [slice(None)] * coarse.ndim + idx[c_axis] = i + ch = coarse[tuple(idx)] + flat = ch.ravel() + ops.append(flat.min()) + ops.append(da.percentile(flat, [contrast_percentile])[0]) + return ops + + +def _pair_contrast_results( + results: Sequence[Any], channel_count: int +) -> list[tuple[Any, Any]]: + """Pair a flat ``[ch0_min, ch0_pct, ch1_min, ch1_pct, ...]`` list into tuples.""" + return [(results[2 * i], results[2 * i + 1]) for i in range(channel_count)] + + +def _to_json_scalar(v: Any) -> Any: + """Cast a numpy 0-d scalar to a native Python type for JSON-serializable attrs.""" + return v.item() if hasattr(v, "item") else v + + +def _update_omero_window_start_end( + store_path: Any, per_channel_stats: Sequence[tuple[Any, Any]] +) -> None: + """Rewrite ``omero.channels[i].window.start / .end`` in-place on ``store_path``. + + Runs after :meth:`ZarrmonyWriter.write_pyramid` returns computed per-channel + contrast stats. Preserves ``min``/``max`` (dtype-range bounds set at + ``Channel`` construction time — see :func:`_dtype_window`) and only touches + ``start``/``end``. No-op when the store has no ``omero`` block or fewer + channels than stats (a defensive guard — the caller only computes stats + when ``channel_count`` matches the omero channels). + """ + root = open_root_group(store_path, mode="a") + ome = dict(root.attrs.get("ome", {})) + omero = ome.get("omero") + if not omero: + return + channels = list(omero.get("channels", [])) + if not channels: + return + for i, (min_v, pct_v) in enumerate(per_channel_stats): + if i >= len(channels): + break + window = dict(channels[i].get("window", {})) + window["start"] = _to_json_scalar(min_v) + window["end"] = _to_json_scalar(pct_v) + channels[i] = {**channels[i], "window": window} + omero = {**omero, "channels": channels} + ome = {**ome, "omero": omero} + root.attrs["ome"] = ome + + def write_scene( reader: Any, scene_index: int, @@ -113,6 +217,7 @@ def write_scene( creator_info: dict | None = None, xarr_override: xr.DataArray | None = None, record_mosaic_summary: bool = True, + contrast_percentile: float | None = None, ) -> dict: """Convert one scene to an OME-Zarr image at ``store_path``. @@ -126,6 +231,16 @@ def write_scene( in the returned audit dict — the per-tile path emits its own ``per_tile`` discriminator at the audit caller, so attaching the scene-level mosaic summary to each tile's own audit would double-count and mislead. + + ``contrast_percentile`` (issue #53) drives data-driven display contrast: + when set (a float in ``(0, 100)``, typically ``99.9``), per-channel ``(min, + percentile)`` values are computed off the coarsest pyramid level — fused + into the pyramid dask graph so the raw data is read once — and written into + the omero ``window.start`` / ``window.end`` fields, replacing the + dtype-range placeholders (issue #50). ``None`` skips the extra ops entirely + and leaves ``start`` / ``end`` matching ``min`` / ``max``. The audit dict + gets a ``contrast`` block naming the percentile, the approximation method + (see ``_CONTRAST_METHOD``), and the resolved per-channel bounds. """ reader.set_scene(scene_index) scene_name = reader.scenes[scene_index] @@ -172,7 +287,22 @@ def write_scene( chunk_shape=chunk_shape, creator_info=creator_info, ) - writer.write_pyramid(pyramid) + + # Only run the extra contrast ops when we actually have channels to update. + # A scene with no C dim and no `channels` argument has no omero.channels + # to rewrite, so there's nothing to compute; skipping keeps the pyramid + # write graph unchanged for that case. + run_contrast = contrast_percentile is not None and channel_count > 0 + if run_contrast: + contrast_ops = _channel_contrast_ops( + pyramid[-1], dims, channel_count, float(contrast_percentile) + ) + results = writer.write_pyramid(pyramid, extra_ops=contrast_ops) + contrast_stats = _pair_contrast_results(results, channel_count) + _update_omero_window_start_end(store_path, contrast_stats) + else: + writer.write_pyramid(pyramid) + contrast_stats = [] record = { "scene_index": scene_index, @@ -186,4 +316,17 @@ def write_scene( } if mosaic_summary is not None: record["mosaic"] = mosaic_summary + if run_contrast: + record["contrast"] = { + "percentile": float(contrast_percentile), + "method": _CONTRAST_METHOD, + "per_channel": [ + { + "channel_index": i, + "start": _to_json_scalar(min_v), + "end": _to_json_scalar(pct_v), + } + for i, (min_v, pct_v) in enumerate(contrast_stats) + ], + } return record diff --git a/tests/test_api.py b/tests/test_api.py index d4641c0..3214e6d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -231,6 +231,9 @@ def test_per_scene_omero_window_matches_reader_dtype( an OMERO display window spanning the reader's dtype range — not the bioio-ome-zarr ``Channel`` default of 0–255 that would make uint16 stores open black. Regression guard for #50. + + Pinned to ``contrast_percentile=None`` so this test isolates the dtype-range + behavior; issue-#53 percentile contrast is exercised separately. """ reader = FakeReader( scenes=["s"], @@ -242,7 +245,7 @@ def test_per_scene_omero_window_matches_reader_dtype( patched_reader(reader) out = tmp_path / "out" - convert("/tmp/x.czi", out, pyramid_min_size=8) + convert("/tmp/x.czi", out, pyramid_min_size=8, contrast_percentile=None) g = zarr.open_group(str(out / "s.ome.zarr"), mode="r") channels = g.attrs["ome"]["omero"]["channels"] @@ -251,6 +254,89 @@ def test_per_scene_omero_window_matches_reader_dtype( assert c["window"] == expected_window +# ---------- contrast_percentile (issue #53) ---------- + + +def test_convert_records_contrast_percentile_in_audit_config( + tmp_path: Path, patched_reader +) -> None: + """Integration guard: whatever the caller passes for ``contrast_percentile`` + (including the default) surfaces in ``audit.config.contrast_percentile``, + and the scene record grows a ``contrast`` block naming the same percentile + plus the approximation method. + """ + reader = FakeReader( + scenes=["s"], + dims="TCYX", + shape=(1, 1, 32, 32), + channel_names=["c0"], + ) + patched_reader(reader) + out = tmp_path / "out" + + convert("/tmp/x.czi", out, pyramid_min_size=8, contrast_percentile=95.0) + + g = zarr.open_group(str(out / "s.ome.zarr"), mode="r") + audit = g.attrs["zarrmony"] + assert audit["config"]["contrast_percentile"] == 95.0 + scene_contrast = audit["per_scene"][0]["contrast"] + assert scene_contrast["percentile"] == 95.0 + assert scene_contrast["method"] == "coarsest-pyramid-level" + assert len(scene_contrast["per_channel"]) == 1 + + +def test_convert_contrast_percentile_none_records_none( + tmp_path: Path, patched_reader +) -> None: + """``contrast_percentile=None`` records ``None`` in the config and omits + the per-scene ``contrast`` block. Wall-clock cost of the extra ops goes to + zero — the omero window stays at the dtype-range placeholder (issue #50). + """ + reader = FakeReader( + scenes=["s"], + dims="TCYX", + shape=(1, 1, 32, 32), + channel_names=["c0"], + ) + patched_reader(reader) + out = tmp_path / "out" + + convert("/tmp/x.czi", out, pyramid_min_size=8, contrast_percentile=None) + + g = zarr.open_group(str(out / "s.ome.zarr"), mode="r") + audit = g.attrs["zarrmony"] + assert audit["config"]["contrast_percentile"] is None + assert "contrast" not in audit["per_scene"][0] + + +def test_convert_contrast_percentile_default_is_99_9( + tmp_path: Path, patched_reader +) -> None: + """The default value must be ``99.9`` — the value the issue asks for as + the ready-to-use auto-contrast, without the caller having to opt in. + """ + reader = FakeReader( + scenes=["s"], + dims="TCYX", + shape=(1, 1, 32, 32), + channel_names=["c0"], + ) + patched_reader(reader) + out = tmp_path / "out" + + convert("/tmp/x.czi", out, pyramid_min_size=8) + + g = zarr.open_group(str(out / "s.ome.zarr"), mode="r") + audit = g.attrs["zarrmony"] + assert audit["config"]["contrast_percentile"] == 99.9 + + +@pytest.mark.parametrize("bad", [0.0, 100.0, -1.0, 101.0]) +def test_convert_contrast_percentile_out_of_range_raises(bad: float) -> None: + with pytest.raises(ValueError, match="contrast_percentile"): + convert("/tmp/x.czi", "/tmp/out", contrast_percentile=bad) + + # ---------- bf2raw mode (opt-in) ---------- diff --git a/tests/test_lif_projections.py b/tests/test_lif_projections.py index 36ed68c..de99add 100644 --- a/tests/test_lif_projections.py +++ b/tests/test_lif_projections.py @@ -279,6 +279,9 @@ def test_api_lif_omero_window_matches_reader_dtype( when constructing the OMERO display window. Same regression as #50 but for the LIF-identity branch that would otherwise ship 0–255 next to real fluorophore labels. + + Pinned to ``contrast_percentile=None`` so this test isolates the dtype-range + behavior; issue-#53 percentile contrast is exercised separately. """ xml = FIXTURE.read_text(encoding="utf-8") reader = FakeLifReader( @@ -291,7 +294,7 @@ def test_api_lif_omero_window_matches_reader_dtype( _install(monkeypatch, reader) out = tmp_path / "out" - convert("/tmp/x.lif", out, pyramid_min_size=8) + convert("/tmp/x.lif", out, pyramid_min_size=8, contrast_percentile=None) g = zarr.open_group(str(out / "scene0.ome.zarr"), mode="r") channels = g.attrs["ome"]["omero"]["channels"] diff --git a/tests/test_scene_writer.py b/tests/test_scene_writer.py index 629bdf6..10898f1 100644 --- a/tests/test_scene_writer.py +++ b/tests/test_scene_writer.py @@ -1,7 +1,9 @@ """Integration tests for write_scene using FakeReader from conftest.""" +import dask.array as da import numpy as np import pytest +import xarray as xr import zarr from tests.conftest import FakePhysicalPixelSizes, FakeReader @@ -159,6 +161,131 @@ def test_write_scene_omero_window_matches_array_dtype( assert channels[0]["window"] == expected_window +def test_write_scene_contrast_percentile_updates_omero_start_end(tmp_path) -> None: + """Issue #53 — per-channel ``(min, 99.9th pct)`` should override the + dtype-range ``start`` / ``end`` placeholder, while ``min`` / ``max`` (dtype + range from issue #50) stay pinned. + + Uses a linear ramp so the coarse-pyramid approximation still produces a + near-monotonic distribution: the tolerances below account for + mean-pool-induced shift at the ends. + """ + base = np.zeros((1, 2, 32, 32), dtype=np.uint16) + base[0, 0] = np.arange(1024, dtype=np.uint16).reshape(32, 32) + base[0, 1] = np.arange(2000, 3024, dtype=np.uint16).reshape(32, 32) + xarr = xr.DataArray(da.from_array(base), dims=["T", "C", "Y", "X"]) + + reader = FakeReader( + scenes=["s"], + dims="TCYX", + shape=(1, 2, 32, 32), + dtype=np.uint16, + channel_names=["ch0", "ch1"], + ) + out = tmp_path / "contrast.zarr" + + audit = write_scene( + reader, + scene_index=0, + store_path=str(out), + pyramid_min_size=8, + xarr_override=xarr, + contrast_percentile=99.9, + ) + + g = zarr.open_group(str(out), mode="r") + channels = g.attrs["ome"]["omero"]["channels"] + + for c in channels: + assert c["window"]["min"] == 0 + assert c["window"]["max"] == 65535 + + # Coarse (8x8) level of a 32x32 linear ramp — verified by hand: min~=49, + # p99.9~=973. Tolerances bracket that with room for future coarsening + # tweaks without becoming a change detector. + ch0 = channels[0]["window"] + assert ch0["start"] <= 60 + assert 950 <= ch0["end"] <= 1023 + + ch1 = channels[1]["window"] + assert 2000 <= ch1["start"] <= 2060 + assert 2950 <= ch1["end"] <= 3023 + + contrast = audit["contrast"] + assert contrast["percentile"] == 99.9 + assert contrast["method"] == "coarsest-pyramid-level" + assert len(contrast["per_channel"]) == 2 + assert contrast["per_channel"][0]["channel_index"] == 0 + assert contrast["per_channel"][0]["start"] == ch0["start"] + assert contrast["per_channel"][0]["end"] == ch0["end"] + + +def test_write_scene_contrast_percentile_none_leaves_dtype_window(tmp_path) -> None: + """``contrast_percentile=None`` short-circuits the extra ops and leaves the + dtype-range placeholder that #50 installs. Also verifies the audit dict + omits the ``contrast`` block. + """ + reader = FakeReader( + scenes=["s"], + dims="TCYX", + shape=(1, 1, 32, 32), + dtype=np.uint16, + channel_names=["ch0"], + ) + out = tmp_path / "no_contrast.zarr" + + audit = write_scene( + reader, + scene_index=0, + store_path=str(out), + pyramid_min_size=8, + contrast_percentile=None, + ) + + g = zarr.open_group(str(out), mode="r") + window = g.attrs["ome"]["omero"]["channels"][0]["window"] + assert window == {"min": 0, "max": 65535, "start": 0, "end": 65535} + assert "contrast" not in audit + + +def test_write_scene_contrast_percentile_single_channel_no_c_dim(tmp_path) -> None: + """Scenes without a C dim still have one implicit omero channel — the + contrast code path must not crash and should record the single-channel + bounds in the audit. + """ + base = np.arange(1024, dtype=np.uint16).reshape(32, 32) + xarr = xr.DataArray(da.from_array(base), dims=["Y", "X"]) + + reader = FakeReader( + scenes=["s"], + dims="YX", + shape=(32, 32), + dtype=np.uint16, + ) + out = tmp_path / "single.zarr" + + audit = write_scene( + reader, + scene_index=0, + store_path=str(out), + pyramid_min_size=8, + xarr_override=xarr, + contrast_percentile=99.9, + ) + + g = zarr.open_group(str(out), mode="r") + ome = g.attrs["ome"] + if "omero" in ome and ome["omero"].get("channels"): + window = ome["omero"]["channels"][0]["window"] + assert window["min"] == 0 + assert window["max"] == 65535 + assert window["start"] <= 60 + assert 950 <= window["end"] <= 1023 + # Either way, the audit records the contrast block when at least one + # channel is emitted; a no-channel path returns [] and is a no-op. + assert "contrast" in audit or audit.get("channel_count", 0) == 0 + + def test_write_scene_records_mosaic_summary(tmp_path) -> None: mosaic = {"stitched": True, "tile_count": 12, "tile_shape": {"Y": 5048, "X": 5048}} reader = FakeReader(