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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`color_for_emission_nm`, `color_for_excitation_nm`, `color_for_dye_name`
which cleanly separate the three fallback stages. (#51)

### Fixed

- OMERO display window (`omero.channels[i].window`) now spans the array's
dtype range instead of the hardcoded 0–255. Both `Channel(...)` construction
sites (`api._lif_scene_channels` and `writers.scene._default_channels`, plus
the shared `api._channels_for_scene` name-based path) pass `window=` derived
from the reader dtype: integer dtypes use `np.iinfo(min, max)` and float
dtypes use `0.0`/`1.0` (OMERO convention for normalized floats). uint16 /
uint32 / float32 stores no longer open black in napari and OMERO because the
display window was clamping intensities into an 8-bit band. `start` / `end`
mirror `min` / `max` — percentile-based auto-contrast is out of scope for
this fix. (#50)

## [0.8.0] - 2026-07-10

### Added
Expand Down
9 changes: 6 additions & 3 deletions src/zarrmony/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
)
from zarrmony.writers.per_scene import write_per_scene_metadata
from zarrmony.writers.plate import summarize_plate_layout, write_plate
from zarrmony.writers.scene import write_scene
from zarrmony.writers.scene import _dtype_window, write_scene

Layout = Literal["auto", "per-scene", "bf2raw", "plate"]
ResolvedLayout = Literal["per-scene", "bf2raw", "plate"]
Expand Down Expand Up @@ -239,8 +239,10 @@ def _channels_for_scene(
if not channel_names:
return 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)
]


Expand Down Expand Up @@ -325,8 +327,9 @@ def _lif_scene_channels(
# objective is decoupled from SizeC — still surface it.
return None, None, objective
overrides, use_source_file = _split_channel_colors(channel_colors)
window = _dtype_window(reader.dtype)
omero_channels = [
Channel(label=o["label"], color=o["color"])
Channel(label=o["label"], color=o["color"], window=window)
for o in channels_to_omero(
extracted,
overrides=overrides,
Expand Down
34 changes: 30 additions & 4 deletions src/zarrmony/writers/scene.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from typing import Any

import dask.array as da
import numpy as np
import xarray as xr
from bioio_ome_zarr.writers import Channel, OMEZarrWriter

Expand Down Expand Up @@ -59,20 +60,45 @@ def _physical_scales_for_dims(dims: Sequence[str], reader: Any) -> list[float]:
return [base[d] for d in dims]


def _default_channels(channel_names: Sequence[str]) -> list[Channel]:
def _dtype_window(dtype: np.dtype) -> dict[str, int | float]:
"""OMERO display-window bounds spanning ``dtype``'s full range.

Integer dtypes → ``np.iinfo(dtype).min`` / ``max``; float dtypes → ``0.0`` /
``1.0`` (OMERO convention for normalized floats). ``start`` / ``end`` mirror
``min`` / ``max`` so first-open viewers see the full range unclipped —
percentile-based auto-contrast is a separate concern (see #50). Prevents
the bioio-ome-zarr ``Channel`` default of ``0``–``255`` from clamping
uint16/uint32/float32 pixels into a black-on-first-open display.
"""
if np.issubdtype(dtype, np.integer):
info = np.iinfo(dtype)
lo: int | float = int(info.min)
hi: int | float = int(info.max)
else:
lo, hi = 0.0, 1.0
return {"min": lo, "max": hi, "start": lo, "end": hi}


def _default_channels(channel_names: Sequence[str], dtype: np.dtype) -> list[Channel]:
"""Emission-band-colored channels for readers that surface no wavelength.

Reaches the ADR-0007 palette via the dye-name substring fallback in
:func:`zarrmony.metadata.channel_colors.colors_for_channels` so a CZI/ND2/
OME-TIFF scene named "DAPI"/"GFP"/"mCherry"/"Cy5" lands in the same
colorblind slots as its LIF-source counterpart, and collisions are handled
identically.
identically. ``dtype`` drives the OMERO display window (see #50) so
uint16/uint32/float32 stores open with the full dtype range rather than
the bioio-ome-zarr 0–255 default.
"""
from zarrmony.metadata.channel_colors import colors_for_channels

names = list(channel_names)
colors = colors_for_channels(names)
return [Channel(label=n, color=c) for n, c in zip(names, colors, strict=True)]
window = _dtype_window(dtype)
return [
Channel(label=n, color=c, window=window)
for n, c in zip(names, colors, strict=True)
]


def write_scene(
Expand Down Expand Up @@ -124,7 +150,7 @@ def write_scene(
channel_names = [f"C:{i}" for i in range(canonical.sizes["C"])]
else:
channel_names = []
channels = _default_channels(channel_names)
channels = _default_channels(channel_names, canonical.dtype)
channel_count = len(channels)

axes_names = [d.lower() for d in dims]
Expand Down
18 changes: 16 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ def __init__(
mosaic_summary: dict | None = None,
skip_reasons: dict[int, str] | None = None,
per_tile_scenes: dict[int, "TileScene"] | None = None,
dtype: np.typing.DTypeLike = np.uint16,
) -> None:
self.scenes = tuple(scenes)
self._dims = dims
Expand All @@ -136,6 +137,7 @@ def __init__(
self.mosaic_summary = mosaic_summary
self._skip_reasons = skip_reasons or {}
self._per_tile_scenes = per_tile_scenes or {}
self._dtype = np.dtype(dtype)

@property
def skip_reason(self) -> str | None:
Expand All @@ -145,6 +147,16 @@ def skip_reason(self) -> str | None:
def channel_names(self) -> list[str]:
return self._channel_names

@property
def dtype(self) -> np.dtype:
"""Mirrors bioio's ``Reader.dtype`` — the pixel dtype of the current scene.

Held as a scalar attr so tests can vary it without allocating the full
xarray. The ``xarray_dask_data`` property honors this dtype when
materializing the fake pixel buffer.
"""
return self._dtype

def set_scene(self, idx: int | str) -> None:
if isinstance(idx, str):
idx = self.scenes.index(idx)
Expand All @@ -161,7 +173,9 @@ def is_mosaic_reassembly_eligible(self) -> bool:
@property
def xarray_dask_data(self) -> xr.DataArray:
# Fill with scene-index+1 so tests can assert "the right scene was read"
arr = np.full(self._shape, fill_value=self._current_scene + 1, dtype=np.uint16)
arr = np.full(
self._shape, fill_value=self._current_scene + 1, dtype=self._dtype
)
coords: dict[str, list[str]] = {}
if "C" in self._dims and self._channel_names:
coords["C"] = self._channel_names
Expand All @@ -185,7 +199,7 @@ def tiles_xarray_dask_data(self) -> xr.DataArray:
scene.tile_yx[0],
scene.tile_yx[1],
)
arr = np.zeros(shape, dtype=np.uint16)
arr = np.zeros(shape, dtype=self._dtype)
for i in range(m):
arr[i].fill(i + 1)
return xr.DataArray(da.from_array(arr), dims=["M", "T", "C", "Z", "Y", "X"])
Expand Down
39 changes: 39 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import warnings
from pathlib import Path

import numpy as np
import pytest
import zarr
from ome_types import from_xml
Expand Down Expand Up @@ -212,6 +213,44 @@ def test_per_scene_sanitizes_scene_names_in_dirnames(
assert set(by_name) == {"a/b", "c d"}


# ---------- omero display window matches array dtype (#50) ----------


@pytest.mark.parametrize(
"dtype, expected_window",
[
(np.uint8, {"min": 0, "max": 255, "start": 0, "end": 255}),
(np.uint16, {"min": 0, "max": 65535, "start": 0, "end": 65535}),
(np.float32, {"min": 0.0, "max": 1.0, "start": 0.0, "end": 1.0}),
],
)
def test_per_scene_omero_window_matches_reader_dtype(
tmp_path: Path, patched_reader, dtype, expected_window
) -> None:
"""The ``_channels_for_scene`` api path (named channels, non-LIF) must ship
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.
"""
reader = FakeReader(
scenes=["s"],
dims="TCYX",
shape=(1, 2, 32, 32),
channel_names=["DAPI", "GFP"],
dtype=dtype,
)
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")
channels = g.attrs["ome"]["omero"]["channels"]
assert len(channels) == 2
for c in channels:
assert c["window"] == expected_window


# ---------- bf2raw mode (opt-in) ----------


Expand Down
12 changes: 12 additions & 0 deletions tests/test_api_lif_wiring.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,17 @@
import xml.etree.ElementTree as ET
from pathlib import Path

import numpy as np

from zarrmony.api import _lif_scene_channels

FIX = Path(__file__).parent / "fixtures" / "lif_confocal_7ch.xml"

# Every double below mirrors bioio's ``Reader.dtype`` surface (uint16 is the
# LIF acquisition default) — ``_lif_scene_channels`` reads it to size the
# OMERO display window per array-dtype range (issue #50).
_DTYPE = np.dtype(np.uint16)


class _Xarr:
def __init__(self, c):
Expand All @@ -37,6 +44,7 @@ class _Raising:
can't be located either way, so it must decline (not crash)."""

xarray_dask_data = _Xarr(7)
dtype = _DTYPE

@property
def scene_root(self):
Expand All @@ -47,12 +55,14 @@ class _NonLif:
"""Neither ``scene_root`` nor ``metadata`` — a non-LIF reader."""

xarray_dask_data = _Xarr(7)
dtype = _DTYPE


class _Good:
"""Plate-shaped reader: ``scene_root`` returns the scene ``<Element>`` directly."""

xarray_dask_data = _Xarr(7)
dtype = _DTYPE

@property
def scene_root(self):
Expand All @@ -61,6 +71,7 @@ def scene_root(self):

class _CountMismatch:
xarray_dask_data = _Xarr(2)
dtype = _DTYPE

@property
def scene_root(self):
Expand Down Expand Up @@ -102,6 +113,7 @@ def __init__(self, c=7, have_metadata=True):
self._md = _two_scene_metadata() if have_metadata else None
self._c = c
self.current_scene_index = 0
self.dtype = _DTYPE

@property
def metadata(self):
Expand Down
38 changes: 38 additions & 0 deletions tests/test_lif_projections.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
import xml.etree.ElementTree as ET
from pathlib import Path

import numpy as np
import pytest
import zarr
from ome_types import from_xml

Expand Down Expand Up @@ -262,6 +264,42 @@ def test_api_lif_channel_count_mismatch_falls_back(tmp_path: Path, monkeypatch)
assert len(g.attrs["ome"]["omero"]["channels"]) == 2


@pytest.mark.parametrize(
"dtype, expected_window",
[
(np.uint8, {"min": 0, "max": 255, "start": 0, "end": 255}),
(np.uint16, {"min": 0, "max": 65535, "start": 0, "end": 65535}),
(np.float32, {"min": 0.0, "max": 1.0, "start": 0.0, "end": 1.0}),
],
)
def test_api_lif_omero_window_matches_reader_dtype(
tmp_path: Path, monkeypatch, dtype, expected_window
) -> None:
"""The LIF ``_lif_scene_channels`` path must also honor the reader's 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.
"""
xml = FIXTURE.read_text(encoding="utf-8")
reader = FakeLifReader(
scene_xml=xml,
scenes=["scene0"],
dims="CYX",
shape=(7, 16, 16),
dtype=dtype,
)
_install(monkeypatch, reader)
out = tmp_path / "out"

convert("/tmp/x.lif", out, pyramid_min_size=8)

g = zarr.open_group(str(out / "scene0.ome.zarr"), mode="r")
channels = g.attrs["ome"]["omero"]["channels"]
assert len(channels) == 7
for c in channels:
assert c["window"] == expected_window


def test_api_lif_with_garbage_scene_root_falls_back(
tmp_path: Path, monkeypatch
) -> None:
Expand Down
Loading
Loading