diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c0d95c..7863c04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and LIF scenes with no objective info emit the pre-#52 no-instrument shape unchanged. Both per-scene and per-tile (`lif_mosaic="per-tile"`) write paths participate. (#52) +- `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) ### Changed @@ -34,6 +60,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 field is additive); consumers pinned to schema `6` should widen their pin. (#52) +### 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) + ## [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 21edbd2..e5fe58b 100644 --- a/src/zarrmony/api.py +++ b/src/zarrmony/api.py @@ -63,6 +63,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" ] @@ -199,16 +206,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) ] @@ -237,6 +267,7 @@ def _scene_channel_count(reader: Any) -> int: def _lif_scene_channels( reader: Any, + channel_colors: ChannelColorSpec = None, ) -> tuple[list[dict] | None, list[Channel] | None, dict | None]: """The LIF-vs-other decision, in one place. @@ -293,9 +324,14 @@ def _lif_scene_channels( # Channel extraction fell through the count check, but the # objective is decoupled from SizeC — still surface it. return None, None, objective + 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, objective except Exception: # noqa: BLE001 — never break a conversion over metadata @@ -303,12 +339,18 @@ def _lif_scene_channels( 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 @@ -316,7 +358,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 @@ -411,7 +458,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, @@ -462,6 +509,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. @@ -474,6 +534,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: @@ -507,11 +573,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, @@ -735,7 +812,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, @@ -837,9 +914,14 @@ def _convert_per_scene( # 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, None) for everything non-LIF. - # ``lif_objective`` is decoupled from the channel-count check so a - # scene with a garbled channel list still surfaces its objective. - lif_extracted, lif_omero_channels, lif_objective = _lif_scene_channels(reader) + # 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_objective`` is + # decoupled from the channel-count check so a scene with a garbled + # channel list still surfaces its objective. + lif_extracted, lif_omero_channels, lif_objective = _lif_scene_channels( + reader, channel_colors + ) channels = ( lif_omero_channels if lif_omero_channels is not None @@ -902,7 +984,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. @@ -1017,7 +1103,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, @@ -1059,10 +1145,13 @@ 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. Objective is likewise scene-scoped (one - # objective per scene, shared by all tiles) and re-attached to each - # tile's OME-XML below. - lif_extracted, lif_omero_channels, lif_objective = _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. + # Objective is scene-scoped (one objective per scene, shared by all tiles) + # and re-attached to each tile's OME-XML below. + lif_extracted, lif_omero_channels, lif_objective = _lif_scene_channels( + reader, channel_colors + ) channels = ( lif_omero_channels if lif_omero_channels is not None @@ -1110,7 +1199,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) @@ -1213,7 +1306,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, @@ -1306,7 +1399,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"]