From 6b4671da8640724399c06c8dad5c28ce46ebbeb8 Mon Sep 17 00:00:00 2001 From: Max Ferrin Date: Thu, 23 Jul 2026 14:25:34 -0700 Subject: [PATCH] feat(channels)!: emission-band colorblind palette as default; source-file opt-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the fluorophore-name → true-color lookup with an emission-band scheme (cyan/green/yellow/magenta/white — the Nature Methods "Points of View" CMY palette) so overlays stay distinguishable under red-green colorblindness and no two red-family dyes collide the way ALEXA 546 and ALEXA 647 did under the old substring match. Colors are driven by what the eye sees (emission midpoint), falling back to excitation, then dye-name substring, then a round-robin palette; residual collisions reallocate through UNKNOWN_PALETTE and fire ChannelColorCollisionWarning naming the reassigned channel so users know to reach for the override mechanism. Adds channel_colors="source-file" as a third accepted value on convert(): trusts the source file's stored per-channel color (LIF LUTName, OME-XML ARGB int) where present, falling through to the emission-band scheme where not. config.channel_colors in the audit record preserves the literal value (dict, None, or "source-file") so downstream tools can distinguish user intent. NAME_COLORS / PALETTE / color_for_channel are removed rather than kept as deprecated shims — the failure at import time is loud on purpose and no pilot user depends on the exact old hex values. See ADR-0007 for the full rationale, band boundaries, source-file design, and the resolution of the "555 nm" ambiguity from the reporting session (excitation, not emission — the excitation-fallback table's [545, 620) magenta band covers the yellow-orange laser bucket). Closes #51 Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 51 ++ docs/adr/0007-emission-band-channel-colors.md | 34 ++ src/zarrmony/api.py | 129 ++++- src/zarrmony/errors.py | 14 + src/zarrmony/metadata/channel_colors.py | 482 +++++++++++++++--- src/zarrmony/metadata/lif_channels.py | 105 +++- src/zarrmony/writers/plate.py | 13 +- src/zarrmony/writers/scene.py | 14 +- tests/test_channel_colors.py | 412 +++++++++++++-- tests/test_lif_channels.py | 16 + tests/test_lif_projections.py | 200 ++++++++ 11 files changed, 1320 insertions(+), 150 deletions(-) create mode 100644 docs/adr/0007-emission-band-channel-colors.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b731af..a5fe817 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed (BREAKING) + +- Default per-channel display colors now come from the fluorophore's emission + midpoint (falling back to excitation, then dye-name substring) mapped onto + the Nature Methods "Points of View" CMY / green palette + (`cyan / green / yellow / magenta / white`). Existing files with red-family + channels will render with different colors than they did under v0.8: on the + reporter's real file (DAPI 405 nm / Alexa 546 555 nm / Alexa 647 651 nm), + colors now render as cyan / magenta / white instead of the collision-prone + substring-match output. Users who need the old dye-brand true-color scheme + should pin their preferred hex per channel via + `convert(channel_colors={: ""})`. (ADR-0007, #51) +- `zarrmony.metadata.channel_colors.NAME_COLORS` and `PALETTE` are removed. + Callers importing them will fail at import time — the failure is loud on + purpose so a silent behavior change is impossible. Replace `NAME_COLORS` + with `DYE_TO_BAND` (semantics: name → band color, not name → dye color) + and `PALETTE` with `UNKNOWN_PALETTE`. (#51) +- `zarrmony.metadata.channel_colors.color_for_channel` is removed. Use + `assign_colors(...)` (batch) or the single-channel helpers + `color_for_emission_nm`, `color_for_excitation_nm`, `color_for_dye_name` + which cleanly separate the three fallback stages. (#51) + +### Added + +- `convert(..., channel_colors="source-file")` — new opt-in mode that trusts + the source file's stored per-channel color when present (LIF + ``, OME-XML ``), falling through + to the emission-band scheme for channels without a stored color. + `config.channel_colors` in the audit record preserves the literal string + `"source-file"` verbatim so downstream tools can distinguish it from + `None` and from a per-channel dict. (ADR-0007, #51) +- `ChannelColorCollisionWarning` — new warning class. Fires once per + conversion when two or more channels resolve to the same display color + (e.g. two far-red dyes both landing on white); later-in-order channels + round-robin through `UNKNOWN_PALETTE` skipping already-taken colors, and + the warning body names the reassigned channels. Suppress with the standard + `warnings.filterwarnings("ignore", category=ChannelColorCollisionWarning)` + or override deterministically with `channel_colors={: }`. (#51) +- `zarrmony.metadata.channel_colors.EMISSION_BANDS`, `EXCITATION_BANDS`, + `DYE_TO_BAND`, `UNKNOWN_PALETTE`, and the batch entry point + `assign_colors(channels, *, source_file_colors=None, overrides=None)`. (#51) +- `zarrmony.metadata.lif_channels.extract_channels` now surfaces an eighth + per-channel field, `lut_name` (the raw + `` attribute), consumed by + `channel_colors="source-file"`. Additive to the identity dict; existing + consumers reading the seven original keys are unaffected. (#51) +- ADR `docs/adr/0007-emission-band-channel-colors.md` documenting the + emission-band scheme, why colorblind CMY over dye-true-color, exact band + boundaries, the `source-file` opt-in, the collision policy, and the + interpretation of the "555 nm" ambiguity in the reporting session. (#51) + ## [0.8.0] - 2026-07-10 ### Added diff --git a/docs/adr/0007-emission-band-channel-colors.md b/docs/adr/0007-emission-band-channel-colors.md new file mode 100644 index 0000000..4f8461f --- /dev/null +++ b/docs/adr/0007-emission-band-channel-colors.md @@ -0,0 +1,34 @@ +# Emission-band colorblind channel colors + +Default per-channel display colors come from the fluorophore's **emission midpoint** (falling back to excitation, then dye-name substring) mapped onto a five-slot CMY palette — `cyan / green / yellow / magenta / white` — so a merged image stays readable under red-green colorblindness (~8% of Northern European males) and no two red-family dyes collide the way `ALEXA 546` and `ALEXA 647` did under the old name-based scheme. `convert(..., channel_colors=...)` grows a third accepted value, `"source-file"`, that trusts the reader's stored per-channel color (LIF ``, OME-XML ``) where present, falling through to the emission band scheme where not. Residual collisions (two far-red dyes both landing on white) round-robin through `UNKNOWN_PALETTE` and fire `ChannelColorCollisionWarning` naming the reassigned channel. Implementation tracked in #51. + +## Considered Options + +- **Keep the dye-brand true-color scheme.** Status quo: `NAME_COLORS` matches on dye substring and returns the dye's approximate emission color (`DAPI → 0099ff`, `mCherry → ff0000`, `AF647 → ff00ff`, …). Rejected on two grounds. First, the reporting session showed the substring match collides in real files: `ALEXA 546` hits the `ALEXA` fallback and `ALEXA 647` hits nothing, so two visually distinct red-family channels rendered as the same red — silent mis-color at seams where the user needed to distinguish them. Second, red-on-black stacks badly under deuteranopia (the common red-green deficiency), and the standard Nature Methods "Points of View" recommendation (Wong 2011) for multi-channel fluorescence is a CMY / green palette that stays distinguishable across the common color-vision deficiencies. The old scheme optimizes for "matches the dye box" and pays the cost in real-world readability; the new scheme optimizes for readability and pays no cost in fidelity because a display color is a rendering choice, not a data claim. +- **Drive colors from the emission midpoint the LIF extractor already surfaces.** Accepted. `extract_channels` reads `` and returns rounded `(low, high)` pairs per channel; averaging is one line and the midpoint is exactly what determines "what the eye sees." Preferring emission over excitation matters for a dye like AF546 whose emission (~572 nm) is in the yellow band while its excitation (~555 nm) is in the yellow-orange laser bucket — the eye sees emission, so emission is the load-bearing input. +- **Excitation-only fallback with the same band boundaries as emission.** Rejected. Emission runs ~30 nm above excitation, so a table sized for emission (500–545 nm → green, 545–580 nm → yellow, …) mis-buckets excitation values. AF488 excitation at 499 nm would land in cyan-region `[450, 500)`; AF546 excitation at 555 nm would land in yellow `[545, 580)` when the dye's emission is squarely in the magenta band. Solved by keeping a separate `EXCITATION_BANDS` table shaped around common laser lines (405, 488, 514/532, 552/561/594, 633/640/647, 730+). The reporter's real-file example — DAPI 405 nm exc / AF546 555 nm exc / AF647 651 nm exc — lands `cyan / magenta / white` under this table, which is the acceptance-criterion result. See _"The 555 nm interpretation"_ below. +- **Substring-key `DYE_TO_BAND` for readers with no wavelength surface.** Accepted. CZI/ND2/OME-TIFF via bioio's stubbed metadata paths don't populate `emission_low_nm`/`emission_high_nm`; a `DYE_TO_BAND` shim keyed on cleaned dye name (case-insensitive substring, longest-key-wins so `AF647` isn't shadowed by a shorter match) returns a _band color_, not a dye-true color, so those reader paths stay on the same colorblind palette as the LIF path. This is the only mechanism that reaches the ADR-0007 palette when the reader only knows `["DAPI", "GFP", "mCherry", "AF647"]`. +- **Hard-fail on collisions instead of round-robin reallocation.** Rejected. Two far-red channels in a file is a legitimate acquisition — a user staining for two markers in the AF647/Cy5 range wants distinct display colors even when the palette can't offer them from the band. Emitting `ChannelColorCollisionWarning` while reallocating out of `UNKNOWN_PALETTE` (skipping already-taken slots) gets the user a distinct color plus the signal that the assignment isn't the band-natural one; failing conversion would be a much worse UX than a coarse-but-distinct fallback. +- **Order collision reallocation by wavelength instead of acquisition order.** Rejected. Acquisition order is what the audit records and what downstream tools stack channels in; changing the ordering just for color assignment would mean the "primary" channel (the first one the user sees named in `channel_names[0]`) could silently lose its natural band color to a later channel. Left-to-right, first-in-order-keeps-its-color is the least-surprise rule and matches how a user reads a channel list. +- **Ship `channel_colors="source-file"` as the default.** Rejected. LIF `LUTName` is a rendering hint the operator picked during acquisition, and it often carries the same collision failure mode as the old scheme (three channels stored as `"Red"` / `"Red"` / `"Red"`). OME-XML `` on files touched by generic converters is frequently missing or randomly assigned. `"source-file"` is the right escape hatch for the user who trusts their vendor UI's coloring; the _default_ has to be the scheme with the strongest guarantee — the emission-band scheme is deterministic per file, collision-aware, and colorblind-safe. +- **Preserve `NAME_COLORS` as a deprecated shim.** Rejected. Nobody in the pilot depends on the exact `NAME_COLORS` hex values (both users' workflows treat channel color as a rendering hint, not a data field); a deprecated shim would grow a maintenance burden with no consumer to protect. Removing it in this slice keeps the codebase honest about what the resolution scheme is. +- **Add a `channel_colors="dye-true-color"` mode that restores the old behavior.** Deferred. If a real user needs it we can add it as a follow-up — the shape is a five-line map wrapper. Adding it now would ship an API surface with no consumer. + +## Consequences + +- **`channel_colors.py` is restructured** around `EMISSION_BANDS` + `EXCITATION_BANDS` + `DYE_TO_BAND` + `UNKNOWN_PALETTE` + the top-level `assign_colors(channels, *, source_file_colors=None, overrides=None) -> list[str]`. Public helpers `color_for_emission_nm`, `color_for_excitation_nm`, `color_for_dye_name`, `parse_source_color`, and `colors_for_channels` sit on top for the non-LIF adapter path and for external callers who want to reach the palette without going through the batch entry point. +- **New warning class: `ChannelColorCollisionWarning` in `errors.py`.** Fires once per `assign_colors` call — never per channel — with a body listing every reassigned channel and the color it would have taken (`"AF647-2 (would collide on #ffffff)"`). Users who don't care collapse it with `warnings.filterwarnings("ignore", category=ChannelColorCollisionWarning)`; users who do care follow the message's escape hint and pass `channel_colors={: }` explicitly. +- **`convert(..., channel_colors=...)` accepts three values.** `None` (default) → emission-band scheme; `dict[str, str]` (existing) → per-channel override map keyed by name/dye/fluor (case-insensitive), with unmapped channels falling through to the emission-band scheme; `"source-file"` (new) → parses the reader's stored per-channel color via `parse_source_color` (LIF `LUTName`, OME-XML ARGB int) with the same emission-band fallthrough. Passing any other string raises `ValueError` at the `convert()` boundary so a typo like `"soure-file"` never silently falls through. +- **`extract_channels` grows an eighth output field: `lut_name`.** The raw `` attribute, unmodified — Leica writes short color names (`"Blue"`, `"Green"`, `"Red"`) and gradient specs (`"Gradient (233,141,52)"`). Interpretation lives in `parse_source_color` (`channel_colors.py`) so `extract_channels` stays a pure extractor and the LIF stdlib-only-import invariant holds. The new field is additive; existing consumers reading the seven original keys are unaffected. +- **Non-LIF reader paths reach the same palette via the dye-name fallback.** `writers.scene._default_channels` used to hardcode `color="ffffff"` for every channel a reader named without wavelength; it now routes through `colors_for_channels(names)` and lands common dye names (`"DAPI"` / `"GFP"` / `"mCherry"` / `"AF647"`) on the exact same colorblind slots as their LIF-source counterparts. Anonymous channels (no dye-name match, no wavelength) round-robin through `UNKNOWN_PALETTE`. +- **`config.channel_colors` in the audit record is preserved verbatim.** `None`, `dict`, and `"source-file"` all survive round-trip so a downstream tool can distinguish "user accepted the default" from "user wrote overrides" from "user opted into source-file colors" without inspecting the actual per-channel hex values. Dicts are defensively copied so later user mutation can't retroactively change the audit. +- **The `assign_colors` overrides key surface is lenient.** A user's `channel_colors={"DAPI": "112233"}` matches against the channel's `name`, `dye`, and `fluor` fields in that order, case-insensitively. This means a caller who keys on the omero display label (`"DAPI (405 nm)"`), the cleaned dye (`"DAPI"`), or the raw dye (`"DAPI (dsDNA bound)"`) all resolve — the failure mode of "user typed the key we didn't expect" is unnecessarily hostile for a rendering hint. +- **Half-open `[lo, hi)` band boundaries are the intentional reading of the table.** Exactly-500-nm midpoint lands in green (matches "500–545 → green"); exactly-660-nm lands in white (matches "660–740 → far-red"). Documented in the `channel_colors.py` module docstring and exercised by the `test_emission_boundary_*` tests so a future contributor can't silently flip the convention. +- **Failure mode preservation.** `assign_colors` on an empty list returns `[]` (no warning). A source-file color that doesn't parse (unknown LUTName, non-hex string, non-int) returns `None` from `parse_source_color` and falls through to the band scheme rather than raising — the whole codebase treats channel color as a rendering hint that must not break conversion, and this ADR keeps that contract. +- **SemVer: v0.9 minor bump (not tagged in this slice).** Default per-channel colors change on every LIF file with red-family channels and on every non-LIF file whose channel names match `DYE_TO_BAND`. Existing scripts that pass explicit `channel_colors={...}` are unaffected. `NAME_COLORS` and `PALETTE` are removed (see the deprecated-shim decision above); any code importing them will fail loudly at import time, which is preferable to a silent rename. CHANGELOG documents the removal under `## [Unreleased]`. + +## The 555 nm interpretation + +The reporting session named the failing channels as `DAPI 405 nm / Alexa 546 555 nm / Alexa 647 651 nm`. Only the DAPI number is unambiguous — 405 is the DAPI excitation laser and also close to the emission (~460 nm). The `555` and `651` numbers are excitation, not emission: AF546's emission peak is ~572 nm (filter passband typically 560–600 nm, midpoint ~580 nm), and AF647's emission peak is ~665 nm; the reporter was quoting the wavelengths that appeared next to the channel in the LAS X UI, which for those two channels are the excitation laser lines. This matters because `emission → magenta` for 555 nm doesn't fit the ADR table (555 would land in yellow `[545, 580)`), but `excitation → magenta` for 555 nm does under a table sized for laser buckets — `[545, 620)` groups 552/561/594 lasers into the magenta band because every dye those lasers routinely excite is a red-emitter. + +The consequence: `_lif_scene_channels` prefers emission midpoint when the LIF `` element is populated (the confocal fixture case — AF546 with band 562–600 nm lands in yellow correctly on emission), and falls back to the excitation table when it isn't (the reporter's file case — AF546 with only a 555 nm excitation lands in magenta correctly on excitation). Both tables use the same five-color palette so a mixed file where some channels have emission and others only have excitation stays visually coherent. The `test_dapi_af546_af647_produces_cyan_magenta_white` case in `test_channel_colors.py` pins this behavior against regressions. diff --git a/src/zarrmony/api.py b/src/zarrmony/api.py index eafbc8c..5229508 100644 --- a/src/zarrmony/api.py +++ b/src/zarrmony/api.py @@ -60,6 +60,13 @@ ResolvedLayout = Literal["per-scene", "bf2raw", "plate"] _VALID_LAYOUTS: tuple[Layout, ...] = ("auto", "per-scene", "bf2raw", "plate") +# ADR-0007: opt-in mode for ``channel_colors`` that uses the source file's own +# stored per-channel color (LIF ````, OME-XML +# ````) instead of the emission-band scheme. Channels without a +# stored color fall through to the emission-band scheme. +_SOURCE_FILE_MODE = "source-file" +ChannelColorSpec = dict[str, str] | Literal["source-file"] | None + LifMosaic = Literal[ "auto-stitch", "per-tile", "grid-stitch", "stage-stitch", "bioio-lif" ] @@ -196,16 +203,39 @@ def _try_get_ome_image( return ome.images[0], None +def _split_channel_colors( + channel_colors: ChannelColorSpec, +) -> tuple[dict[str, str] | None, bool]: + """Split the ``channel_colors`` API surface into (overrides, use_source_file). + + ``None`` and dict pass through as ``(dict_or_None, False)``. The literal + string ``"source-file"`` — the ADR-0007 opt-in that trusts the reader's + stored per-channel color — becomes ``(None, True)`` so downstream code can + thread a single boolean flag without re-testing the sentinel. + """ + if channel_colors == _SOURCE_FILE_MODE: + return None, True + return channel_colors, False + + def _channels_for_scene( reader: Any, - channel_colors: dict[str, str] | None, + channel_colors: ChannelColorSpec, ) -> list[Channel] | None: + """Name-based omero channels for readers that don't surface wavelength. + + ``channel_colors="source-file"`` degrades to the emission-band scheme here + (the same as ``None``) — this path has no access to a reader-side stored + color. The LIF path (:func:`_lif_scene_channels`) handles ``"source-file"`` + for real via the extracted ``LUTName``. + """ + overrides, _use_source_file = _split_channel_colors(channel_colors) channel_names = ( list(reader.channel_names) if getattr(reader, "channel_names", None) else [] ) if not channel_names: return None - colors = colors_for_channels(channel_names, overrides=channel_colors) + colors = colors_for_channels(channel_names, overrides=overrides) return [ Channel(label=n, color=c) for n, c in zip(channel_names, colors, strict=True) ] @@ -232,7 +262,10 @@ def _scene_channel_count(reader: Any) -> int: return int(xarr.sizes["C"]) if "C" in xarr.dims else 1 -def _lif_scene_channels(reader: Any) -> tuple[list[dict] | None, list[Channel] | None]: +def _lif_scene_channels( + reader: Any, + channel_colors: ChannelColorSpec = None, +) -> tuple[list[dict] | None, list[Channel] | None]: """The LIF-vs-other decision, in one place. Returns ``(extracted, omero_channels)`` when ``reader`` is a LIF reader whose @@ -268,9 +301,14 @@ def _lif_scene_channels(reader: Any) -> tuple[list[dict] | None, list[Channel] | extracted = extract_channels(scene_xml) if not extracted or len(extracted) != _scene_channel_count(reader): return None, None + overrides, use_source_file = _split_channel_colors(channel_colors) omero_channels = [ Channel(label=o["label"], color=o["color"]) - for o in channels_to_omero(extracted) + for o in channels_to_omero( + extracted, + overrides=overrides, + use_source_file=use_source_file, + ) ] return extracted, omero_channels except Exception: # noqa: BLE001 — never break a conversion over metadata @@ -278,12 +316,18 @@ def _lif_scene_channels(reader: Any) -> tuple[list[dict] | None, list[Channel] | def _lif_ome_image( - extracted: list[dict], scene_index: int, name: str, scene_record: dict + extracted: list[dict], + scene_index: int, + name: str, + scene_record: dict, + channel_colors: ChannelColorSpec = None, ) -> Image | None: """A real per-scene OME ``Image`` carrying the extracted channel identities. Sizes come from ``scene_record`` (exactly as :func:`_stub_image` does); the - canonical ```` elements come from :func:`channels_to_ome_channels`. + canonical ```` elements come from :func:`channels_to_ome_channels`, + threaded with the same ``channel_colors`` spec as :func:`_lif_scene_channels` + so the omero block and OME-XML never disagree on per-channel color. ``extracted`` was already count-checked against the scene's C size in :func:`_lif_scene_channels`; the ``SizeC`` assertion here is belt-and- suspenders. Returns ``None`` on any surprise so the caller falls back to the @@ -291,7 +335,12 @@ def _lif_ome_image( """ try: image = _stub_image(scene_index, name, scene_record) - ome_channels = channels_to_ome_channels(extracted) + overrides, use_source_file = _split_channel_colors(channel_colors) + ome_channels = channels_to_ome_channels( + extracted, + overrides=overrides, + use_source_file=use_source_file, + ) if len(ome_channels) != image.pixels.size_c: return None image.pixels.channels = ome_channels @@ -358,7 +407,7 @@ def convert( layout: Layout = "auto", pyramid_min_size: int = 256, chunk_shape: Sequence[int] | None = None, - channel_colors: dict[str, str] | None = None, + channel_colors: ChannelColorSpec = None, force: bool = False, checksum: bool = False, validate: bool = True, @@ -409,6 +458,19 @@ def convert( Non-LIF readers ignore the flag entirely. + ``channel_colors`` (ADR-0007) governs per-channel display colors: + + - ``None`` (default) — emission-band colorblind scheme (cyan/green/yellow/ + magenta/white). Emission midpoint → excitation → dye-name substring. + Residual collisions round-robin through ``UNKNOWN_PALETTE`` with a + :class:`~zarrmony.errors.ChannelColorCollisionWarning`. + - ``dict[str, str]`` — per-channel override map (``{name: "RRGGBB"}``). + Unmapped channels fall through to the emission-band scheme. + - ``"source-file"`` — use the source file's stored per-channel color + when present (LIF ````, OME-XML + ````); channels without a stored color fall through to + the emission-band scheme. + Per-scene return shape: ``{"input": ..., "stores": [, ...]}``. The ``bf2raw`` and ``plate`` shapes return the single bundle's audit dict; plate audits use the schema-3 ``fields`` + ``plate`` keys. @@ -421,6 +483,12 @@ def convert( raise ValueError( f"lif_mosaic must be one of {list(_VALID_LIF_MOSAIC)} (got {lif_mosaic!r})" ) + if isinstance(channel_colors, str) and channel_colors != _SOURCE_FILE_MODE: + raise ValueError( + f"channel_colors as a string must be {_SOURCE_FILE_MODE!r} " + f"(got {channel_colors!r}); pass a dict for per-channel overrides " + "or None for the emission-band scheme" + ) reader, plugin, match_score = get_reader(input_path) if not reader.scenes: @@ -454,11 +522,22 @@ def convert( "to keep the plate structure with butt-jointed FOVs." ) + # Preserve ``channel_colors`` verbatim in the audit config: the caller may + # have passed a dict, ``None``, or the ADR-0007 ``"source-file"`` sentinel, + # and downstream audit consumers should be able to distinguish all three. + # Only defensively copy a dict so a later user mutation doesn't leak into + # the audit record. + audit_channel_colors: ChannelColorSpec + if isinstance(channel_colors, dict): + audit_channel_colors = dict(channel_colors) + else: + audit_channel_colors = channel_colors + config = { "layout": effective_layout, "pyramid_min_size": pyramid_min_size, "chunk_shape": list(chunk_shape) if chunk_shape else None, - "channel_colors": dict(channel_colors) if channel_colors else None, + "channel_colors": audit_channel_colors, "force": force, "checksum": checksum, "validate": validate, @@ -682,7 +761,7 @@ def _convert_per_scene( output: str | Path, pyramid_min_size: int, chunk_shape: Sequence[int] | None, - channel_colors: dict[str, str] | None, + channel_colors: ChannelColorSpec, force: bool, checksum: bool, config: dict, @@ -783,8 +862,11 @@ def _convert_per_scene( # LIF-vs-other decision in one place: for a LIF scene with extractable # channel identities, drive both omero (label/color) and the canonical # OME-XML s from that identity; otherwise fall through to the - # existing name-based path. (None, None) for everything non-LIF. - lif_extracted, lif_omero_channels = _lif_scene_channels(reader) + # existing name-based path. (None, None) for everything non-LIF. The + # ``channel_colors`` spec (dict override, source-file mode, or the + # emission-band default) is threaded into both branches so overrides + # and source-file color hints apply uniformly. + lif_extracted, lif_omero_channels = _lif_scene_channels(reader, channel_colors) channels = ( lif_omero_channels if lif_omero_channels is not None @@ -847,7 +929,11 @@ def _convert_per_scene( ome_image: Image | None = None if lif_extracted is not None: ome_image = _lif_ome_image( - lif_extracted, scene_index, scene_name, scene_record + lif_extracted, + scene_index, + scene_name, + scene_record, + channel_colors=channel_colors, ) if ome_image is None: # Non-LIF, or the LIF Image couldn't be built — existing behavior. @@ -954,7 +1040,7 @@ def _convert_per_tile_scene( output_str: str, pyramid_min_size: int, chunk_shape: Sequence[int] | None, - channel_colors: dict[str, str] | None, + channel_colors: ChannelColorSpec, force: bool, checksum: bool, config: dict, @@ -996,8 +1082,9 @@ def _convert_per_tile_scene( # Channel projection: as in per-scene mode, the LIF extracted identity wins; # otherwise fall through to the name-based path. Same channels for every - # tile of the same scene. - lif_extracted, lif_omero_channels = _lif_scene_channels(reader) + # tile of the same scene. ``channel_colors`` threads to both branches so + # dict overrides and ``"source-file"`` apply per-tile as they do per-scene. + lif_extracted, lif_omero_channels = _lif_scene_channels(reader, channel_colors) channels = ( lif_omero_channels if lif_omero_channels is not None @@ -1045,7 +1132,11 @@ def _convert_per_tile_scene( ome_image: Image | None = None if lif_extracted is not None: ome_image = _lif_ome_image( - lif_extracted, scene_index, tile_image_name, scene_record + lif_extracted, + scene_index, + tile_image_name, + scene_record, + channel_colors=channel_colors, ) if ome_image is None: ome_image, warning = _try_get_ome_image(reader, scene_index) @@ -1141,7 +1232,7 @@ def _convert_bf2raw( output: str | Path, pyramid_min_size: int, chunk_shape: Sequence[int] | None, - channel_colors: dict[str, str] | None, + channel_colors: ChannelColorSpec, force: bool, checksum: bool, config: dict, @@ -1234,7 +1325,7 @@ def _convert_plate( output: str | Path, pyramid_min_size: int, chunk_shape: Sequence[int] | None, - channel_colors: dict[str, str] | None, + channel_colors: ChannelColorSpec, force: bool, checksum: bool, config: dict, diff --git a/src/zarrmony/errors.py b/src/zarrmony/errors.py index ca1b12a..0e65d55 100644 --- a/src/zarrmony/errors.py +++ b/src/zarrmony/errors.py @@ -104,3 +104,17 @@ class MosaicMergedSiblingWarning(UserWarning): stitcher's 1-pixel-overlap assumption (see :class:`MosaicStitchingWarning`). """ + + +class ChannelColorCollisionWarning(UserWarning): + """Two or more channels resolved to the same display color. + + Emitted by ``zarrmony.metadata.channel_colors.assign_colors`` when the + emission-band scheme (ADR-0007) lands two channels in the same colorblind + slot — for example, two far-red dyes both mapping to white. The first + channel in acquisition order keeps its natural band color; later channels + round-robin through ``UNKNOWN_PALETTE`` skipping already-assigned colors + and the warning names both the reassigned channel and the color it would + have taken. Users who want deterministic per-channel colors pass + ``convert(..., channel_colors={: ""})`` to override. + """ diff --git a/src/zarrmony/metadata/channel_colors.py b/src/zarrmony/metadata/channel_colors.py index 1d1e487..5280299 100644 --- a/src/zarrmony/metadata/channel_colors.py +++ b/src/zarrmony/metadata/channel_colors.py @@ -1,90 +1,434 @@ -"""Heuristic channel-name → hex-color mapping with palette fallback. +"""Emission-band → colorblind-friendly display color mapping (ADR-0007). -Used by the per-scene writer to assign default OMERO channel colors when the -caller doesn't supply explicit colors. Coverage targets the dyes most common in -fluorescence microscopy at Calico (DAPI, GFP, mCherry, Cy5 family) plus a -brightfield/DIC fallback. Any unmatched channel falls back to a small -round-robin palette. +Assigns per-channel hex display colors from **what the eye sees** — the +fluorophore's emission midpoint — rather than the dye's true color. Uses the +Nature Methods "Points of View" CMY / green palette so overlays stay +distinguishable under red-green colorblindness (~8% of Northern European males) +and stack cleanly for 3–4 channel merged images. + +Resolution ladder (per :func:`assign_colors`): + +1. Per-channel override from ``convert(..., channel_colors={: })``. +2. Source file's stored channel color (opt-in via ``channel_colors="source-file"``, + parsed by :func:`parse_source_color` from LIF ``LUTName`` / OME-XML + ``Channel@Color``). +3. Emission midpoint → :data:`EMISSION_BANDS`. +4. Excitation → :data:`EXCITATION_BANDS` (blueshifted, laser-line oriented). +5. Dye-name substring → :data:`DYE_TO_BAND` (for readers without wavelengths). +6. Round-robin through :data:`UNKNOWN_PALETTE`. + +After per-channel resolution, :func:`assign_colors` walks the list left-to-right +and — when a later channel collides with an earlier one — reallocates it out of +``UNKNOWN_PALETTE`` (skipping already-taken colors) and fires +:class:`ChannelColorCollisionWarning`. First-in-order channels never move. + +Band boundaries are half-open ``[lo, hi)`` intervals so an exact-boundary +emission (500 nm, 545 nm, 580 nm, 610 nm, 660 nm, 740 nm) lands in the *upper* +band — matches the "500–545" reading of the table where 500 is green not cyan. """ -NAME_COLORS: dict[str, str] = { - # Blue (UV / 405) - "DAPI": "0099ff", - "Hoechst": "0099ff", - "BFP": "0040ff", - # Green (488) - "GFP": "00ff00", - "EGFP": "00ff00", - "AF488": "00ff00", - "Alexa488": "00ff00", - "FITC": "00ff00", - # Yellow (515 / 561 short) - "YFP": "ccff00", - "Citrine": "ccff00", - # Orange / Red (561 / 568) - "RFP": "ff0000", - "mCherry": "ff0000", - "AF555": "ff8000", - "Alexa555": "ff8000", - "Cy3": "ff8000", - "TexasRed": "ff4000", - "TRITC": "ff4000", - # Far red (640+) - "AF647": "ff00ff", - "Alexa647": "ff00ff", - "Cy5": "ff00ff", - "AF680": "ff00bb", - "AF750": "808080", - # Brightfield / transmission — display as white - "Brightfield": "ffffff", - "BF": "ffffff", - "DIC": "ffffff", - "Phase": "ffffff", - "PhaseContrast": "ffffff", +from __future__ import annotations + +import re +import warnings + +from zarrmony.errors import ChannelColorCollisionWarning + +# The five colors of the palette — kept as module constants so tests and +# downstream code can reference them symbolically instead of matching hex. +CYAN = "00ffff" +GREEN = "00ff00" +YELLOW = "ffff00" +MAGENTA = "ff00ff" +WHITE = "ffffff" + +# Emission-midpoint → color, per the ADR-0007 table. Half-open intervals so +# exact-boundary emissions (500, 545, 580, 610, 660, 740 nm) land in the upper +# band. NIR + brightfield/DIC/phase both surface as white (the "no principled +# color" bucket). +EMISSION_BANDS: tuple[tuple[float, float, str], ...] = ( + (0.0, 450.0, CYAN), # deep-blue / UV (DAPI, Hoechst) + (450.0, 500.0, CYAN), # cyan (CFP, BFP, mTurquoise) + (500.0, 545.0, GREEN), # green (GFP, AF488, FITC) + (545.0, 580.0, YELLOW), # yellow (YFP, Citrine) + (580.0, 610.0, YELLOW), # orange (mOrange, AF555, Cy3) + (610.0, 660.0, MAGENTA), # red (mCherry, AF546/568/594) + (660.0, 740.0, WHITE), # far-red (AF647, Cy5, AF680) + (740.0, float("inf"), WHITE), # NIR (Cy7, AF750) +) + +# Excitation-only fallback bands. Blueshifted from the emission table and +# clustered around common Leica laser lines so a channel that reports only its +# excitation wavelength still lands in the right colorblind slot: +# +# laser line → typical dye class → band color +# 405 → DAPI/CFP/BFP → cyan +# 488 → GFP/AF488 → green +# 514/532 → YFP/Citrine → yellow +# 552/561/594→ AF546/AF555/ → magenta +# mCherry/AF594 +# 633/640/647→ AF647/Cy5 → white +# 730+ → NIR → white +# +# The user-file example that drove ADR-0007 (DAPI 405 nm exc / AF546 555 nm exc +# / AF647 651 nm exc → cyan / magenta / white) lands correctly here because +# the yellow-orange laser bucket [545, 620) is a single magenta band. +EXCITATION_BANDS: tuple[tuple[float, float, str], ...] = ( + (0.0, 450.0, CYAN), # UV / 405 laser + (450.0, 500.0, GREEN), # 488 laser + (500.0, 545.0, YELLOW), # 514/532 lasers + (545.0, 620.0, MAGENTA), # 552/561/594 lasers + (620.0, 720.0, WHITE), # 633/640/647 red lasers + (720.0, float("inf"), WHITE), # NIR +) + +# Dye-name substring → band color, for readers that don't surface wavelength +# (CZI / ND2 / OME-TIFF paths that never populate emission or excitation). +# Keys are case-insensitive substrings; longer keys win to avoid "Cy5" also +# matching "Cy55" or "Cy2" matching "Cy20". Colors come from the same palette +# constants above so the band table stays the single source of truth. +DYE_TO_BAND: dict[str, str] = { + # Deep-blue / UV + "DAPI": CYAN, + "Hoechst": CYAN, + # Cyan + "CFP": CYAN, + "Cerulean": CYAN, + "mTurquoise": CYAN, + "BFP": CYAN, + # Green + "GFP": GREEN, + "EGFP": GREEN, + "Alexa Fluor 488": GREEN, + "Alexa 488": GREEN, + "Alexa488": GREEN, + "AF488": GREEN, + "FITC": GREEN, + "FAM": GREEN, + "Cy2": GREEN, + "mNeonGreen": GREEN, + "Emerald": GREEN, + # Yellow (yellow-green fluorescent proteins) + "YFP": YELLOW, + "Citrine": YELLOW, + "mCitrine": YELLOW, + "Venus": YELLOW, + "Alexa 514": YELLOW, + "AF514": YELLOW, + "Alexa 532": YELLOW, + "AF532": YELLOW, + # Orange (still yellow on the CMY palette) + "mOrange": YELLOW, + "dTomato": YELLOW, + "Alexa 555": YELLOW, + "AF555": YELLOW, + "Cy3": YELLOW, + "TRITC": YELLOW, + # Red (magenta on the CMY palette) + "mCherry": MAGENTA, + "mRFP": MAGENTA, + "RFP": MAGENTA, + "Alexa 546": MAGENTA, + "AF546": MAGENTA, + "Alexa 568": MAGENTA, + "AF568": MAGENTA, + "Alexa 594": MAGENTA, + "AF594": MAGENTA, + "Texas Red": MAGENTA, + "TexasRed": MAGENTA, + # Far-red (white) + "Alexa 647": WHITE, + "AF647": WHITE, + "Cy5": WHITE, + "Alexa 680": WHITE, + "AF680": WHITE, + "iRFP670": WHITE, + # NIR (white) + "Cy7": WHITE, + "Alexa 700": WHITE, + "AF700": WHITE, + "Alexa 750": WHITE, + "AF750": WHITE, + "Alexa 790": WHITE, + "AF790": WHITE, + # Brightfield / transmission (white) + "Brightfield": WHITE, + "BF": WHITE, + "DIC": WHITE, + "Phase": WHITE, + "PhaseContrast": WHITE, } -PALETTE: tuple[str, ...] = ( - "00ff00", - "ff0000", - "0099ff", - "ffff00", - "00ffff", - "ff00ff", - "ff8000", - "ffffff", +# Round-robin palette for channels that don't resolve any other way, and for +# collision reallocation. Ordering keeps the CMY primary triple first so +# unresolved channels stay visually coherent alongside band-assigned ones. +UNKNOWN_PALETTE: tuple[str, ...] = (CYAN, GREEN, MAGENTA, YELLOW, WHITE) + +# Named LUTs a source file might store as its per-channel display color. Leica +# LAS X writes ````, ``LUTName="Blue"``, +# ``LUTName="Gradient (R,G,B)"``, etc.; OME-XML uses a signed 32-bit ARGB int. +# Only names that unambiguously resolve to a display color live here. +_LUT_NAME_TO_HEX: dict[str, str] = { + "blue": "0000ff", + "cyan": CYAN, + "green": GREEN, + "yellow": YELLOW, + "orange": "ff8000", + "red": "ff0000", + "magenta": MAGENTA, + "white": WHITE, + "gray": WHITE, + "grey": WHITE, +} +_GRADIENT_LUT_RE = re.compile( + r"^\s*Gradient\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)\s*$", + re.IGNORECASE, ) +_HEX6_RE = re.compile(r"^[0-9a-fA-F]{6}$") + + +def _band_lookup(bands: tuple[tuple[float, float, str], ...], nm: float) -> str | None: + """Return the hex color whose ``[lo, hi)`` interval contains ``nm``.""" + for lo, hi, hex_ in bands: + if lo <= nm < hi: + return hex_ + return None + + +def color_for_emission_nm(nm: float | int | None) -> str | None: + """Look up ``nm`` in :data:`EMISSION_BANDS`; ``None`` if out of range/None.""" + if nm is None: + return None + return _band_lookup(EMISSION_BANDS, float(nm)) + + +def color_for_excitation_nm(nm: float | int | None) -> str | None: + """Look up ``nm`` in :data:`EXCITATION_BANDS`; ``None`` if out of range/None.""" + if nm is None: + return None + return _band_lookup(EXCITATION_BANDS, float(nm)) + + +def color_for_dye_name(name: str | None) -> str | None: + """Substring-match ``name`` against :data:`DYE_TO_BAND` (case-insensitive). + + Longest key wins so ``"AF488"`` isn't shadowed by a shorter substring. + Returns ``None`` when nothing matches. + """ + if not name: + return None + lower = name.lower() + matches = sorted( + (k for k in DYE_TO_BAND if k.lower() in lower), + key=len, + reverse=True, + ) + if matches: + return DYE_TO_BAND[matches[0]] + return None + + +def parse_source_color(raw: str | int | None) -> str | None: + """Interpret a source file's stored per-channel color hint as a hex6 string. + Accepts: -def color_for_channel(name: str, fallback_index: int = 0) -> str: - """Return a hex color for ``name``. + - a plain hex6 string ``"RRGGBB"`` (case-insensitive, optional leading ``#``); + - a named LUT (``"Green"``, ``"Blue"``, ``"Magenta"``, …) — the set + Leica LAS X writes as ````; + - a Leica gradient LUT (``"Gradient (R,G,B)"``) — the ``R,G,B`` triple is + taken as the display color; + - a signed 32-bit ARGB integer (from OME-XML ````) — the + alpha byte is dropped. - Lookup is case-insensitive. If no exact match, tries substring match (e.g. - "DAPI_405" matches "DAPI"). Falls back to PALETTE[fallback_index % len]. + Returns ``None`` when the input can't be resolved so the caller falls + through to the band scheme. """ - name_lower = name.lower() + if raw is None: + return None + if isinstance(raw, bool): + return None + if isinstance(raw, int): + rgb = raw & 0x00FFFFFF + return f"{rgb:06x}" + if not isinstance(raw, str): + return None + text = raw.strip().lstrip("#") + if _HEX6_RE.match(text): + return text.lower() + hit = _LUT_NAME_TO_HEX.get(text.lower()) + if hit is not None: + return hit + m = _GRADIENT_LUT_RE.match(text) + if m: + r, g, b = (int(x) & 0xFF for x in m.groups()) + return f"{r:02x}{g:02x}{b:02x}" + return None - for key, color in NAME_COLORS.items(): - if key.lower() == name_lower: - return color - for key, color in NAME_COLORS.items(): - if key.lower() in name_lower: - return color +def _emission_midpoint( + low: float | int | None, high: float | int | None +) -> float | None: + if low is None or high is None: + return None + return (float(low) + float(high)) / 2.0 - return PALETTE[fallback_index % len(PALETTE)] + +def _normalize_hex(color: str) -> str: + """Lowercase a hex color and strip an optional leading ``#``.""" + return color.lstrip("#").lower() + + +def _resolve_one(info: dict, source_color: str | None) -> str | None: + """Resolve a single channel down the wavelength → dye ladder; ``None`` if unresolved. + + Source-file color, when provided, wins over every wavelength/name lookup — + that's the whole point of ``channel_colors="source-file"``. Palette-index + fallback is left to the caller. + """ + if source_color is not None: + return _normalize_hex(source_color) + midpoint = _emission_midpoint( + info.get("emission_low_nm"), info.get("emission_high_nm") + ) + color = color_for_emission_nm(midpoint) + if color is not None: + return color + color = color_for_excitation_nm(info.get("excitation_nm")) + if color is not None: + return color + color = color_for_dye_name(info.get("dye") or info.get("name")) + if color is not None: + return color + return None + + +def _override_for(info: dict, overrides: dict[str, str]) -> str | None: + """Look up ``overrides`` by channel name / dye (case-insensitive fallback). + + The user's override key can match any of the identifiers we know for the + channel — the omero label (``"DAPI (405 nm)"``), the raw dye + (``"DAPI (dsDNA bound)"``), or the cleaned dye / channel name + (``"DAPI"``). First hit wins. + """ + if not overrides: + return None + lower_overrides = {k.lower(): v for k, v in overrides.items()} + for key in ( + info.get("name"), + info.get("dye"), + info.get("fluor"), + ): + if key is None: + continue + if key in overrides: + return overrides[key] + if key.lower() in lower_overrides: + return lower_overrides[key.lower()] + return None + + +def _channel_label(info: dict, index: int) -> str: + """Best-effort human name for a channel, for warning bodies.""" + return str( + info.get("name") or info.get("dye") or info.get("fluor") or f"channel {index}" + ) + + +def assign_colors( + channel_infos: list[dict], + *, + source_file_colors: list[str | None] | None = None, + overrides: dict[str, str] | None = None, +) -> list[str]: + """Resolve display colors for a batch of channels, with collision handling. + + ``channel_infos[i]`` is a dict that may include ``name``, ``dye``, + ``fluor``, ``excitation_nm``, ``emission_low_nm``, ``emission_high_nm``. + Missing fields degrade — a dict with only ``name`` still lands via the + dye-name substring path. + + Per-channel resolution ladder (top wins): + + 1. ``overrides`` — keyed by channel name / dye / fluor (case-insensitive). + 2. ``source_file_colors[i]`` — the parsed source-file color, or ``None``. + 3. Emission midpoint → :func:`color_for_emission_nm`. + 4. Excitation → :func:`color_for_excitation_nm`. + 5. Dye name substring → :func:`color_for_dye_name`. + 6. ``UNKNOWN_PALETTE[i % len]`` — position-based fallback. + + After per-channel resolution, walk left-to-right and reallocate later + duplicates out of ``UNKNOWN_PALETTE`` (skipping already-taken colors); + the first-in-order channel keeps its natural color. Fires + :class:`ChannelColorCollisionWarning` once per batch when any reallocation + happens, naming the affected channels and the colors they would have taken. + """ + n = len(channel_infos) + if source_file_colors is None: + source_file_colors = [None] * n + if len(source_file_colors) != n: + raise ValueError( + f"source_file_colors length {len(source_file_colors)} != " + f"channel_infos length {n}" + ) + overrides = overrides or {} + + # Pass 1: per-channel resolution (no cross-channel awareness). + resolved: list[str] = [] + for i, info in enumerate(channel_infos): + override = _override_for(info, overrides) + if override is not None: + resolved.append(_normalize_hex(override)) + continue + color = _resolve_one(info, source_file_colors[i]) + if color is None: + color = UNKNOWN_PALETTE[i % len(UNKNOWN_PALETTE)] + resolved.append(color) + + # Pass 2: collision reallocation. First-in-order keeps its color; later + # duplicates round-robin through UNKNOWN_PALETTE skipping taken slots. + taken: set[str] = set() + final: list[str] = [] + collisions: list[tuple[str, str]] = [] # (channel label, original color) + for i, color in enumerate(resolved): + if color not in taken: + taken.add(color) + final.append(color) + continue + new_color = color + for candidate in UNKNOWN_PALETTE: + if candidate not in taken: + new_color = candidate + break + # If every palette slot is taken, we're out of distinct colors — keep + # the original color for this channel; the warning still fires so + # users know two channels look identical. + taken.add(new_color) + final.append(new_color) + collisions.append((_channel_label(channel_infos[i], i), color)) + + if collisions: + parts = ", ".join( + f"{name!r} (would collide on #{color})" for name, color in collisions + ) + warnings.warn( + f"channel color collision: reassigned {parts}; pass " + f"channel_colors={{: }} to override", + ChannelColorCollisionWarning, + stacklevel=2, + ) + return final def colors_for_channels( channel_names: list[str], overrides: dict[str, str] | None = None, ) -> list[str]: - """Resolve a hex color for each channel name. + """Resolve one hex color per channel name, for reader paths without wavelength. - Per-channel overrides win over the heuristic. Unmatched channels round-robin - through PALETTE in their position order. + Thin adapter around :func:`assign_colors` for the CZI/ND2/OME-TIFF branch + where only the channel's display name is known. Reaches the same band + mapping via the dye-name fallback, then collision-reallocates. """ - overrides = overrides or {} - return [ - overrides.get(name, color_for_channel(name, fallback_index=i)) - for i, name in enumerate(channel_names) - ] + infos: list[dict] = [{"name": n, "dye": n} for n in channel_names] + return assign_colors(infos, overrides=overrides) diff --git a/src/zarrmony/metadata/lif_channels.py b/src/zarrmony/metadata/lif_channels.py index 2f75ce8..58df4e9 100644 --- a/src/zarrmony/metadata/lif_channels.py +++ b/src/zarrmony/metadata/lif_channels.py @@ -268,7 +268,13 @@ def extract_channels(scene_xml: str) -> list[dict]: Returns one dict per image channel, in acquisition (document) order, each with exactly the keys ``index``, ``dye``, ``fluor``, ``detector``, - ``excitation_nm``, ``emission_low_nm``, ``emission_high_nm``. + ``excitation_nm``, ``emission_low_nm``, ``emission_high_nm``, ``lut_name``. + + ``lut_name`` is the raw ```` attribute (e.g. + ``"Green"``, ``"Blue"``, ``"Gradient (233,141,52)"``) or ``None`` when + absent — surfaced so ``channel_colors="source-file"`` can consult the + source's stored channel color (see + :func:`zarrmony.metadata.channel_colors.parse_source_color`). Fail-closed: any parse failure, unsafe input, or missing structure yields ``[]``. Individual undeterminable fields degrade to ``None`` rather than @@ -291,6 +297,7 @@ def extract_channels(scene_xml: str) -> list[dict]: physical_channel = ( detector_to_channel.get(detector_name) if detector_name else None ) + lut_name = channel_desc.attrib.get("LUTName") or None # Resolve which real sequence acquired this channel. block = None @@ -314,6 +321,7 @@ def extract_channels(scene_xml: str) -> list[dict]: "excitation_nm": excitation, "emission_low_nm": emission_low, "emission_high_nm": emission_high, + "lut_name": lut_name, } ) return channels @@ -350,18 +358,6 @@ def _clean_dye(dye: str | None) -> str | None: return _TRAILING_PARENTHETICAL.sub("", dye).strip() or None -def _color_for(cleaned_dye: str | None, index: int) -> str: - """Heuristic hex color for a (cleaned) dye, palette-fallback by index. - - Imported locally so the bioio-portable core stays stdlib-only on import: - ``zarrmony.metadata.channel_colors`` won't exist once ``extract_channels`` - is lifted into ``bioio-lif``, but these projections stay zarrmony-side. - """ - from zarrmony.metadata.channel_colors import color_for_channel - - return color_for_channel(cleaned_dye or "", index) - - def _omero_label( cleaned_dye: str | None, excitation_nm: int | float | None ) -> str | None: @@ -375,44 +371,109 @@ def _omero_label( return None -def channels_to_omero(channels: list[dict]) -> list[dict]: +def _resolve_colors( + channels: list[dict], + *, + use_source_file: bool, + overrides: dict[str, str] | None, +) -> list[str]: + """Batch-resolve colors for the extracted channel identities. + + Wraps ``zarrmony.metadata.channel_colors.assign_colors`` with two LIF- + specific pieces the caller shouldn't have to know about: + + - When ``use_source_file`` is True, feeds the extracted ``lut_name`` through + :func:`parse_source_color` so ``channel_colors="source-file"`` picks up + Leica's LAS X per-channel color hint (``LUTName="Green"`` etc.). + - Attaches the cleaned dye as the channel's ``name`` field so overrides + keyed on the display name (``"DAPI"``) resolve alongside the raw dye. + """ + from zarrmony.metadata.channel_colors import assign_colors, parse_source_color + + infos: list[dict] = [] + for ch in channels: + cleaned = _clean_dye(ch.get("dye")) + infos.append( + { + # `name` is what the omero label uses (the cleaned dye), so it + # matches what a user sees and would key overrides on. + "name": cleaned, + "dye": ch.get("dye"), + "fluor": ch.get("fluor"), + "excitation_nm": ch.get("excitation_nm"), + "emission_low_nm": ch.get("emission_low_nm"), + "emission_high_nm": ch.get("emission_high_nm"), + } + ) + source_file_colors: list[str | None] | None = None + if use_source_file: + source_file_colors = [parse_source_color(ch.get("lut_name")) for ch in channels] + return assign_colors( + infos, source_file_colors=source_file_colors, overrides=overrides + ) + + +def channels_to_omero( + channels: list[dict], + *, + overrides: dict[str, str] | None = None, + use_source_file: bool = False, +) -> list[dict]: """Project identity dicts into omero display ``{"label", "color"}`` dicts. One dict per input channel, in order. ``label`` follows the dye/excitation - ladder (``None`` only when neither is known); ``color`` is the cleaned dye's - heuristic hex (falling back to the palette by index), so it is always a valid - six-hex string even for an unnamed channel. + ladder (``None`` only when neither is known); ``color`` comes from the + emission-band scheme (ADR-0007) with collision reallocation via + :func:`assign_colors`, so it is always a valid six-hex string even for an + unnamed channel and no two channels in the batch land on the same color + unless every palette slot is already taken. + + ``overrides`` lets the caller thread ``convert(channel_colors=...)`` down; + ``use_source_file=True`` implements ``channel_colors="source-file"`` by + parsing the extracted ``LUTName`` per channel. """ + colors = _resolve_colors( + channels, use_source_file=use_source_file, overrides=overrides + ) out: list[dict] = [] for index, ch in enumerate(channels): cleaned_dye = _clean_dye(ch.get("dye")) out.append( { "label": _omero_label(cleaned_dye, ch.get("excitation_nm")), - "color": _color_for(cleaned_dye, index), + "color": colors[index], } ) return out -def channels_to_ome_channels(channels: list[dict]) -> list[Channel]: +def channels_to_ome_channels( + channels: list[dict], + *, + overrides: dict[str, str] | None = None, + use_source_file: bool = False, +) -> list[Channel]: """Project identity dicts into canonical ome_types ``Channel`` elements. One ``Channel`` per input channel: ``id=Channel:0:``, ``name`` the cleaned dye, ``fluor`` the full (un-cleaned) dye, excitation/emission with - ``nm`` units, and the same hex ``color`` as :func:`channels_to_omero`. Any - field whose source is ``None`` is omitted entirely rather than set to a + ``nm`` units, and the same hex ``color`` as :func:`channels_to_omero` (same + :func:`assign_colors` batch, so the OME-XML and omero blocks never disagree). + Any field whose source is ``None`` is omitted entirely rather than set to a bogus value, so the element never claims wavelengths it doesn't have. """ # Local import: the bioio-portable core stays stdlib-only on module import. from ome_types.model import Channel + colors = _resolve_colors( + channels, use_source_file=use_source_file, overrides=overrides + ) out: list[Channel] = [] for index, ch in enumerate(channels): cleaned_dye = _clean_dye(ch.get("dye")) fields: dict = { "id": f"Channel:0:{index}", - "color": _color_for(cleaned_dye, index), + "color": colors[index], } if cleaned_dye is not None: fields["name"] = cleaned_dye diff --git a/src/zarrmony/writers/plate.py b/src/zarrmony/writers/plate.py index a45ee5e..e23a58b 100644 --- a/src/zarrmony/writers/plate.py +++ b/src/zarrmony/writers/plate.py @@ -177,14 +177,21 @@ def _group_fields_by_well( def _channels_for_current_scene( - reader: Any, channel_colors: dict[str, str] | None + reader: Any, channel_colors: dict[str, str] | str | None ) -> list[Channel] | None: + """Emission-band-colored channels for a plate FOV. + + 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``. + """ channel_names = ( list(reader.channel_names) if getattr(reader, "channel_names", None) else [] ) if not channel_names: return None - colors = colors_for_channels(channel_names, overrides=channel_colors) + overrides = channel_colors if isinstance(channel_colors, dict) else None + colors = colors_for_channels(channel_names, overrides=overrides) return [ Channel(label=n, color=c) for n, c in zip(channel_names, colors, strict=True) ] @@ -257,7 +264,7 @@ def write_plate( plate_layout: PlateLayout, pyramid_min_size: int = 256, chunk_shape: Sequence[int] | None = None, - channel_colors: dict[str, str] | None = None, + channel_colors: dict[str, str] | str | None = None, ome_image_for_field: Any = None, ome_xml_builder: Any = None, source_xml: str | None = None, diff --git a/src/zarrmony/writers/scene.py b/src/zarrmony/writers/scene.py index 1e7b60b..3f064d6 100644 --- a/src/zarrmony/writers/scene.py +++ b/src/zarrmony/writers/scene.py @@ -60,7 +60,19 @@ def _physical_scales_for_dims(dims: Sequence[str], reader: Any) -> list[float]: def _default_channels(channel_names: Sequence[str]) -> list[Channel]: - return [Channel(label=name, color="ffffff") for name in channel_names] + """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. + """ + 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)] def write_scene( diff --git a/tests/test_channel_colors.py b/tests/test_channel_colors.py index 0784213..b703242 100644 --- a/tests/test_channel_colors.py +++ b/tests/test_channel_colors.py @@ -1,62 +1,402 @@ +"""Tests for the emission-band colorblind palette (ADR-0007). + +Covers: + +- band-boundary edges (500 nm, 545 nm, 660 nm) for both emission and excitation, +- the emission → excitation → dye-name → palette resolution ladder, +- the dye-name fallback (used by CZI/ND2/OME-TIFF paths without wavelength), +- collision handling with ``ChannelColorCollisionWarning``, +- source-file color parsing (LIF LUTName, OME-XML ARGB int, hex), +- overrides (keyed on channel name / dye). + +Uses the module-level color constants ``CYAN``/``GREEN``/``YELLOW``/``MAGENTA``/ +``WHITE`` throughout so a test's intent reads at a glance without hex-matching. +""" + +from __future__ import annotations + +import warnings + +import pytest + +from zarrmony.errors import ChannelColorCollisionWarning from zarrmony.metadata.channel_colors import ( - NAME_COLORS, - PALETTE, - color_for_channel, + CYAN, + DYE_TO_BAND, + EMISSION_BANDS, + EXCITATION_BANDS, + GREEN, + MAGENTA, + UNKNOWN_PALETTE, + WHITE, + YELLOW, + assign_colors, + color_for_dye_name, + color_for_emission_nm, + color_for_excitation_nm, colors_for_channels, + parse_source_color, ) +# --- band lookups: table contents + boundary edges -------------------------- + + +def test_emission_band_table_covers_the_expected_bands() -> None: + # Sanity-check that the table matches the ADR-0007 palette: exactly five + # distinct colors across eight bands. + band_colors = {c for (_lo, _hi, c) in EMISSION_BANDS} + assert band_colors == {CYAN, GREEN, YELLOW, MAGENTA, WHITE} + assert len(EMISSION_BANDS) == 8 + + +def test_emission_dapi_is_cyan() -> None: + # DAPI midpoint ~460 nm; under the deep-blue band. + assert color_for_emission_nm(460) == CYAN + + +def test_emission_gfp_is_green() -> None: + # EGFP midpoint ~510 nm. + assert color_for_emission_nm(510) == GREEN + + +def test_emission_mcherry_is_magenta() -> None: + # mCherry midpoint ~610 nm (red band). + assert color_for_emission_nm(620) == MAGENTA + + +def test_emission_af647_is_white() -> None: + assert color_for_emission_nm(670) == WHITE + + +def test_emission_boundary_500_lands_in_green() -> None: + # Half-open [lo, hi): 500 nm exactly falls into the green [500, 545) band, + # matching the "500–545 → green" reading of the ADR-0007 table. + assert color_for_emission_nm(500) == GREEN + + +def test_emission_boundary_545_lands_in_yellow() -> None: + assert color_for_emission_nm(545) == YELLOW + + +def test_emission_boundary_580_lands_in_yellow() -> None: + # 580 nm sits at the yellow → orange edge; both bands map to yellow, but + # 580 must not slip into the red band. + assert color_for_emission_nm(580) == YELLOW + + +def test_emission_boundary_610_lands_in_magenta() -> None: + assert color_for_emission_nm(610) == MAGENTA + + +def test_emission_boundary_660_lands_in_white() -> None: + assert color_for_emission_nm(660) == WHITE + + +def test_emission_boundary_740_lands_in_white_nir() -> None: + # 740 nm exactly enters the NIR band — still white by the palette. + assert color_for_emission_nm(740) == WHITE + + +def test_emission_none_input_returns_none() -> None: + assert color_for_emission_nm(None) is None + + +# --- excitation bands: laser-line buckets ---------------------------------- + + +def test_excitation_dapi_405_is_cyan() -> None: + assert color_for_excitation_nm(405) == CYAN + + +def test_excitation_af488_499_is_green() -> None: + # AF488 excitation ~499 nm — must land in green, not the < 450 cyan band. + assert color_for_excitation_nm(499) == GREEN + + +def test_excitation_af546_555_is_magenta() -> None: + # Load-bearing for the ADR-0007 real-file example: AF546 at 555 nm + # excitation is the yellow-orange laser bucket → magenta. + assert color_for_excitation_nm(555) == MAGENTA + + +def test_excitation_af594_590_is_magenta() -> None: + assert color_for_excitation_nm(590) == MAGENTA + + +def test_excitation_af647_651_is_white() -> None: + assert color_for_excitation_nm(651) == WHITE + + +def test_excitation_boundary_620_lands_in_white() -> None: + # [620, 720) → white; 620 exactly is red-laser territory. + assert color_for_excitation_nm(620) == WHITE + + +def test_excitation_nir_is_white() -> None: + assert color_for_excitation_nm(780) == WHITE + + +# --- dye name substring fallback (non-LIF readers) -------------------------- + + +def test_dye_name_dapi_is_cyan() -> None: + assert color_for_dye_name("DAPI") == CYAN + -def test_dapi_is_blue() -> None: - assert color_for_channel("DAPI") == NAME_COLORS["DAPI"] +def test_dye_name_gfp_is_green() -> None: + assert color_for_dye_name("GFP") == GREEN -def test_gfp_is_green() -> None: - assert color_for_channel("GFP") == "00ff00" +def test_dye_name_mcherry_is_magenta() -> None: + assert color_for_dye_name("mCherry") == MAGENTA -def test_mcherry_is_red() -> None: - assert color_for_channel("mCherry") == "ff0000" +def test_dye_name_alexa_647_is_white() -> None: + assert color_for_dye_name("AF647") == WHITE + assert color_for_dye_name("Alexa 647") == WHITE -def test_cy5_is_magenta() -> None: - assert color_for_channel("Cy5") == "ff00ff" +def test_dye_name_is_case_insensitive() -> None: + assert color_for_dye_name("dapi") == CYAN + assert color_for_dye_name("MCHERRY") == MAGENTA -def test_brightfield_is_white() -> None: - assert color_for_channel("Brightfield") == "ffffff" - assert color_for_channel("DIC") == "ffffff" +def test_dye_name_substring_match() -> None: + # Common real-world channel names include wavelength / conjugate suffixes. + assert color_for_dye_name("DAPI_405") == CYAN + assert color_for_dye_name("GFP-EM") == GREEN + assert color_for_dye_name("Alexa 647 conjugate") == WHITE -def test_lookup_is_case_insensitive() -> None: - assert color_for_channel("dapi") == color_for_channel("DAPI") - assert color_for_channel("MCHERRY") == color_for_channel("mCherry") +def test_dye_name_unknown_returns_none() -> None: + assert color_for_dye_name("WEIRD_CHANNEL") is None + assert color_for_dye_name("") is None + assert color_for_dye_name(None) is None -def test_substring_match() -> None: - # Common in real channel names: dye + wavelength suffix - assert color_for_channel("DAPI_405") == color_for_channel("DAPI") - assert color_for_channel("GFP-EM") == color_for_channel("GFP") +def test_dye_name_brightfield_is_white() -> None: + assert color_for_dye_name("Brightfield") == WHITE + assert color_for_dye_name("DIC") == WHITE -def test_unknown_falls_back_to_palette() -> None: - assert color_for_channel("WEIRD_CHANNEL", fallback_index=0) == PALETTE[0] - assert color_for_channel("WEIRD_CHANNEL", fallback_index=1) == PALETTE[1] +# --- source-file color parsing (LIF LUTName, OME-XML ARGB) ------------------ -def test_palette_wraps_around() -> None: - big_idx = len(PALETTE) + 2 - assert color_for_channel("UNKNOWN", fallback_index=big_idx) == PALETTE[2] +def test_parse_source_color_hex_string() -> None: + assert parse_source_color("ff8800") == "ff8800" + assert parse_source_color("#FF8800") == "ff8800" + assert parse_source_color("00FFFF") == "00ffff" + + +def test_parse_source_color_lut_names() -> None: + assert parse_source_color("Green") == GREEN + assert parse_source_color("Blue") == "0000ff" + assert parse_source_color("Magenta") == MAGENTA + assert parse_source_color("Gray") == WHITE + # Case-insensitive. + assert parse_source_color("green") == GREEN + + +def test_parse_source_color_leica_gradient_lut() -> None: + # "Gradient (R,G,B)" — the R,G,B triple is the display color. + assert parse_source_color("Gradient (233,141,52)") == "e98d34" + + +def test_parse_source_color_argb_int() -> None: + # OME-XML is a signed 32-bit ARGB; the alpha byte drops. + assert parse_source_color(0xFFFF8800) == "ff8800" + assert parse_source_color(0x00FF8800) == "ff8800" + + +def test_parse_source_color_none_and_unknown_return_none() -> None: + assert parse_source_color(None) is None + assert parse_source_color("nonsense-name") is None + assert parse_source_color("") is None + assert parse_source_color(True) is None # bool is not a color + + +# --- assign_colors: end-to-end resolution + collision handling -------------- + + +def _info(**kw) -> dict: + """Build a channel-info dict with defaults so tests read compactly.""" + base = { + "name": None, + "dye": None, + "fluor": None, + "excitation_nm": None, + "emission_low_nm": None, + "emission_high_nm": None, + } + base.update(kw) + return base + + +def test_assign_colors_prefers_emission_over_excitation() -> None: + # Emission midpoint 620 (magenta) beats excitation 405 (would be cyan). + infos = [_info(emission_low_nm=610, emission_high_nm=630, excitation_nm=405)] + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert assign_colors(infos) == [MAGENTA] + + +def test_assign_colors_falls_back_to_excitation_when_no_emission() -> None: + infos = [_info(excitation_nm=555)] + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert assign_colors(infos) == [MAGENTA] + + +def test_assign_colors_falls_back_to_dye_name_when_no_wavelength() -> None: + infos = [_info(name="DAPI_405", dye="DAPI_405")] + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert assign_colors(infos) == [CYAN] + + +def test_assign_colors_falls_back_to_palette_when_nothing_resolves() -> None: + # Two mystery channels: distinct UNKNOWN_PALETTE slots, no collision. + infos = [_info(name="MYSTERY_A"), _info(name="MYSTERY_B")] + with warnings.catch_warnings(): + warnings.simplefilter("error") + colors = assign_colors(infos) + assert colors[0] == UNKNOWN_PALETTE[0] + assert colors[1] == UNKNOWN_PALETTE[1] + + +def test_assign_colors_reallocates_collisions_and_warns() -> None: + # Two AF647 channels → both would land on white. Second reallocates through + # UNKNOWN_PALETTE and the warning fires. + infos = [ + _info(name="AF647-1", dye="AF647", emission_low_nm=670, emission_high_nm=680), + _info(name="AF647-2", dye="AF647", emission_low_nm=670, emission_high_nm=680), + ] + with pytest.warns(ChannelColorCollisionWarning) as record: + colors = assign_colors(infos) + assert colors[0] == WHITE + assert colors[1] != WHITE + assert colors[1] in UNKNOWN_PALETTE + assert len(record) == 1 + body = str(record[0].message) + assert "AF647-2" in body + + +def test_assign_colors_source_file_beats_band_scheme() -> None: + # A channel with emission that would be magenta gets overridden by its + # source-file color hint (a hex). + infos = [_info(emission_low_nm=610, emission_high_nm=630)] + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert assign_colors(infos, source_file_colors=["ff8800"]) == ["ff8800"] + + +def test_assign_colors_overrides_beat_everything() -> None: + # Override on channel `name` wins over even the source-file color. + infos = [_info(name="DAPI", emission_low_nm=440, emission_high_nm=480)] + result = assign_colors( + infos, + source_file_colors=[GREEN], + overrides={"DAPI": "112233"}, + ) + assert result == ["112233"] + + +def test_assign_colors_overrides_case_insensitive() -> None: + infos = [_info(name="DAPI", dye="DAPI (dsDNA bound)")] + with warnings.catch_warnings(): + warnings.simplefilter("error") + result = assign_colors(infos, overrides={"dapi": "abcdef"}) + assert result == ["abcdef"] + + +def test_assign_colors_source_file_length_mismatch_raises() -> None: + with pytest.raises(ValueError): + assign_colors([_info(name="X")], source_file_colors=["ff8800", "112233"]) + + +def test_assign_colors_empty_input_is_empty() -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert assign_colors([]) == [] + + +# --- colors_for_channels: the name-only adapter used by non-LIF paths ------ + + +def test_colors_for_channels_uses_dye_name_fallback() -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error") + out = colors_for_channels(["DAPI", "GFP", "mCherry", "AF647"]) + assert out == [CYAN, GREEN, MAGENTA, WHITE] def test_colors_for_channels_with_overrides() -> None: - names = ["DAPI", "GFP", "Custom_X"] - overrides = {"Custom_X": "abcdef"} - out = colors_for_channels(names, overrides=overrides) - assert out[0] == NAME_COLORS["DAPI"] - assert out[1] == "00ff00" - assert out[2] == "abcdef" + with warnings.catch_warnings(): + warnings.simplefilter("error") + out = colors_for_channels( + ["DAPI", "GFP", "Custom_X"], overrides={"Custom_X": "abcdef"} + ) + assert out == [CYAN, GREEN, "abcdef"] + + +def test_colors_for_channels_unknown_name_uses_palette() -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error") + out = colors_for_channels(["WEIRD_A", "WEIRD_B"]) + assert out[0] == UNKNOWN_PALETTE[0] + assert out[1] == UNKNOWN_PALETTE[1] + + +# --- the ADR-0007 acceptance-criterion real-file case ---------------------- + + +def test_dapi_af546_af647_produces_cyan_magenta_white() -> None: + """The reporter's real file — DAPI 405 nm exc / AF546 555 nm exc / AF647 651 nm exc. + + The three channels are shaped as excitation-only (no emission band) to + match a reader path where filter cubes weren't surfaced. The default + emission-band scheme must still land them on the CMY palette: + cyan (deep-blue) / magenta (AF546 red-emitter) / white (far-red). + """ + infos = [ + _info(name="DAPI", dye="DAPI", excitation_nm=405), + _info(name="Alexa 546", dye="ALEXA 546", excitation_nm=555), + _info(name="Alexa 647", dye="ALEXA 647", excitation_nm=651), + ] + with warnings.catch_warnings(): + warnings.simplefilter("error") + colors = assign_colors(infos) + assert colors == [CYAN, MAGENTA, WHITE] + + +# --- DYE_TO_BAND table smoke tests ----------------------------------------- + + +def test_dye_to_band_table_uses_palette_colors_only() -> None: + # The DYE_TO_BAND lookup is a name → band-color shim; every value must be + # a palette color, not some legacy true-color hex. + palette = {CYAN, GREEN, YELLOW, MAGENTA, WHITE} + for name, color in DYE_TO_BAND.items(): + assert color in palette, f"{name} maps to non-palette color {color}" + + +def test_dye_to_band_includes_all_four_red_family_slots() -> None: + # ADR-0007 subdivides the red side into four visually-distinct slots. + assert DYE_TO_BAND["mOrange"] == YELLOW + assert DYE_TO_BAND["mCherry"] == MAGENTA + assert DYE_TO_BAND["AF647"] == WHITE + assert DYE_TO_BAND["Cy7"] == WHITE + + +def test_dye_to_band_and_emission_bands_agree_on_palette() -> None: + # Sanity: every band color that appears in EMISSION_BANDS also appears in + # DYE_TO_BAND (so a dye-name fallback can reach every band). + emission_colors = {c for (_lo, _hi, c) in EMISSION_BANDS} + dye_band_colors = set(DYE_TO_BAND.values()) + assert emission_colors.issubset(dye_band_colors) -def test_colors_for_channels_no_overrides() -> None: - out = colors_for_channels(["DAPI", "GFP"]) - assert out == [NAME_COLORS["DAPI"], "00ff00"] +def test_excitation_bands_use_the_same_palette() -> None: + palette = {CYAN, GREEN, YELLOW, MAGENTA, WHITE} + for _lo, _hi, color in EXCITATION_BANDS: + assert color in palette diff --git a/tests/test_lif_channels.py b/tests/test_lif_channels.py index ebee86f..e6bd590 100644 --- a/tests/test_lif_channels.py +++ b/tests/test_lif_channels.py @@ -34,11 +34,27 @@ def test_each_channel_has_exactly_the_contract_keys() -> None: "excitation_nm", "emission_low_nm", "emission_high_nm", + "lut_name", } for channel in _channels(): assert set(channel) == expected +def test_lut_names_come_from_channel_description() -> None: + # Surfaced verbatim from so + # channel_colors="source-file" can consult it via parse_source_color. + lut_names = [c["lut_name"] for c in _channels()] + assert lut_names == [ + "Blue", + "Gradient (233,141,52)", + "Gray", + "Green", + "Red", + "Yellow", + "Magenta", + ] + + def test_dyes_have_vendor_prefix_stripped() -> None: assert [c["dye"] for c in _channels()] == [ "DAPI (dsDNA bound)", diff --git a/tests/test_lif_projections.py b/tests/test_lif_projections.py index fb1fad7..64828e4 100644 --- a/tests/test_lif_projections.py +++ b/tests/test_lif_projections.py @@ -20,6 +20,7 @@ from tests.conftest import FakeReader from zarrmony import api as api_module from zarrmony import convert +from zarrmony.errors import ChannelColorCollisionWarning from zarrmony.metadata.lif_channels import ( channels_to_ome_channels, channels_to_omero, @@ -278,3 +279,202 @@ def test_api_lif_with_garbage_scene_root_falls_back( store = out / "s.ome.zarr" assert (store / "OME" / "METADATA.ome.xml").exists() + + +# --- ADR-0007: channel_colors="source-file" and dict overrides ------------- + + +def _one_channel_lif_xml(*, lut_name: str, dye: str = "DAPI (dsDNA bound)") -> str: + """Minimal LIF-shaped XML for a single-channel scene with a LUTName hint. + + The parser needs a ChannelDescription with a LUTName attribute plus a + valid sequence block so excitation/emission can attach; only the LUTName + is load-bearing for source-file-mode tests but the rest matches a real + Leica scene closely enough to route through the normal extraction path. + """ + return f""" + + DyeNameLeica/{dye} + DetectorNameHyD S 1 + SequentialSettingIndex0 + + + + + + + + + + """ + + +def test_api_source_file_mode_uses_stored_lut_color( + tmp_path: Path, monkeypatch +) -> None: + """``channel_colors="source-file"`` swaps in the LIF LUTName color. + + Emission (430–480 midpoint 455) would be cyan under the default; the + file's ``LUTName="Red"`` overrides that to plain red — the whole point of + source-file mode is to trust the reader's stored per-channel hint over + our band scheme. + """ + reader = FakeLifReader( + scene_xml=_one_channel_lif_xml(lut_name="Red"), + scenes=["scene0"], + dims="CYX", + shape=(1, 16, 16), + ) + _install(monkeypatch, reader) + out = tmp_path / "out" + + convert("/tmp/x.lif", out, pyramid_min_size=8, channel_colors="source-file") + + store = out / "scene0.ome.zarr" + g = zarr.open_group(str(store), mode="r") + colors = [c["color"] for c in g.attrs["ome"]["omero"]["channels"]] + assert colors == ["ff0000"] # LUTName="Red" → plain red, not band cyan + + +def test_api_source_file_mode_falls_through_when_lut_missing( + tmp_path: Path, monkeypatch +) -> None: + # No LUTName attribute → source-file mode has nothing to consume, so the + # emission-band scheme takes over (405 nm exc / 430–480 nm emission → cyan). + xml = _one_channel_lif_xml(lut_name="") # empty LUTName is treated as absent + xml = xml.replace('LUTName=""', "") + reader = FakeLifReader( + scene_xml=xml, + scenes=["scene0"], + dims="CYX", + shape=(1, 16, 16), + ) + _install(monkeypatch, reader) + out = tmp_path / "out" + + convert("/tmp/x.lif", out, pyramid_min_size=8, channel_colors="source-file") + + store = out / "scene0.ome.zarr" + g = zarr.open_group(str(store), mode="r") + colors = [c["color"] for c in g.attrs["ome"]["omero"]["channels"]] + # Emission midpoint 455 → deep-blue band → cyan. + assert colors == ["00ffff"] + + +def test_api_dict_override_wins_over_band_scheme(tmp_path: Path, monkeypatch) -> None: + # A dict override keyed on the cleaned dye ("DAPI") beats the emission band. + reader = FakeLifReader( + scene_xml=_one_channel_lif_xml(lut_name="Blue"), + scenes=["scene0"], + dims="CYX", + shape=(1, 16, 16), + ) + _install(monkeypatch, reader) + out = tmp_path / "out" + + convert( + "/tmp/x.lif", + out, + pyramid_min_size=8, + channel_colors={"DAPI": "112233"}, + ) + + store = out / "scene0.ome.zarr" + g = zarr.open_group(str(store), mode="r") + colors = [c["color"] for c in g.attrs["ome"]["omero"]["channels"]] + assert colors == ["112233"] + + +def test_convert_rejects_unknown_channel_colors_string(tmp_path: Path) -> None: + # Any string other than "source-file" is an error at the API surface — + # matches the ChannelColorSpec type and prevents typo-silent fallthrough. + import pytest + + with pytest.raises(ValueError, match="source-file"): + convert( + "/tmp/x.lif", + tmp_path / "out", + pyramid_min_size=8, + channel_colors="soure-file", # typo + ) + + +def test_convert_preserves_channel_colors_verbatim_in_audit( + tmp_path: Path, monkeypatch +) -> None: + """The audit's ``config.channel_colors`` records exactly what the user passed. + + ADR-0007: downstream consumers must be able to tell "user passed None" + apart from "user passed a dict" apart from "user passed 'source-file'"; + the audit is the only durable record. + """ + reader = FakeLifReader( + scene_xml=_one_channel_lif_xml(lut_name="Green"), + scenes=["scene0"], + dims="CYX", + shape=(1, 16, 16), + ) + _install(monkeypatch, reader) + + result = convert( + "/tmp/x.lif", + tmp_path / "out", + pyramid_min_size=8, + channel_colors="source-file", + ) + audit_config = result["stores"][0]["config"] + assert audit_config["channel_colors"] == "source-file" + + +# --- ADR-0007: collision handling on a real projection --------------------- + + +def test_channels_to_omero_reallocates_collisions() -> None: + """Two far-red channels both landing on white → second-in-order moves. + + Exercises the projection surface end-to-end (not just ``assign_colors``) + so any regression in the LIF-side wiring is caught. + """ + import warnings + + channels = [ + {"dye": "AF647", "emission_low_nm": 663, "emission_high_nm": 688}, + {"dye": "AF680", "emission_low_nm": 680, "emission_high_nm": 720}, + ] + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + omero = channels_to_omero(channels) + assert omero[0]["color"] == "ffffff" + assert omero[1]["color"] != "ffffff" + assert any( + isinstance(w.message, ChannelColorCollisionWarning) for w in caught + ), "collision warning must fire" + + +# --- ADR-0007: default writer path (non-LIF) uses band scheme via dye name - + + +def test_non_lif_reader_default_channels_use_band_scheme( + tmp_path: Path, monkeypatch +) -> None: + """``writers/scene.py::_default_channels`` reaches the emission bands. + + A non-LIF reader with recognizable dye names ('DAPI', 'GFP', 'mCherry', + 'AF647') should land those channels on the colorblind palette via the + dye-name substring fallback — no all-white fallback like the old default. + """ + reader = FakeReader( + scenes=["s"], + dims="CYX", + shape=(4, 16, 16), + channel_names=["DAPI", "GFP", "mCherry", "AF647"], + ) + _install(monkeypatch, reader) + out = tmp_path / "out" + + convert("/tmp/x.czi", out, pyramid_min_size=8) + + store = out / "s.ome.zarr" + g = zarr.open_group(str(store), mode="r") + colors = [c["color"] for c in g.attrs["ome"]["omero"]["channels"]] + assert colors == ["00ffff", "00ff00", "ff00ff", "ffffff"]