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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<ATLConfocalSettingDefinition>` and/or `<Objective>` elements), the audit
Expand Down
30 changes: 30 additions & 0 deletions src/zarrmony/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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/
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
30 changes: 30 additions & 0 deletions src/zarrmony/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -161,13 +182,22 @@ def convert_cmd(
``<scene>.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,
output=output,
layout=layout,
pyramid_min_size=pyramid_min_size,
chunk_shape=chunk_shape,
contrast_percentile=resolved_contrast,
force=force,
checksum=checksum,
validate=validate,
Expand Down
12 changes: 9 additions & 3 deletions src/zarrmony/writers/plate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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 []
Expand All @@ -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)
]


Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
{
Expand Down
Loading
Loading