Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<ChannelDescription LUTName>`, OME-XML `<Channel Color>`), 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={<name>: <hex6>}`. (#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
`<ChannelDescription LUTName>` 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

Expand All @@ -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={<name>: "<hex6>"})`. (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
Expand Down
34 changes: 34 additions & 0 deletions docs/adr/0007-emission-band-channel-colors.md
Original file line number Diff line number Diff line change
@@ -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 `<ChannelDescription LUTName>`, OME-XML `<Channel Color>`) 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 `<MultiBand LeftWorld RightWorld>` 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 `<Channel Color>` 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={<name>: <hex6>}` 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 `<ChannelDescription LUTName>` 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 `<MultiBand>` 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.
Loading
Loading