You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Replace the fluorophore-name → true-color mapping in src/zarrmony/metadata/channel_colors.py with an emission-wavelength-band → colorblind-friendly display color mapping, driven by the emission midpoint that extract_channels already surfaces from LIF XML. Preserve the existing convert(..., channel_colors=...) per-channel dict override, and add a channel_colors="source-file" string mode that trusts the source file's stored channel color when present.
Motivation
Zarrmony currently assigns display colors by looking up dye names in NAME_COLORS (DAPI → 0099ff, ALEXA 546 → ff0000, AF647 → ff00ff, …). Two problems:
Two red-family channels (ALEXA 546 and ALEXA 647 in a real user file) end up as ff0000 and 0099ff respectively because the substring match hits ALEXA for one and nothing for the other — collision-prone and surprising.
Red-on-black overlays poorly with red-green colorblindness (~8% of Northern European males); the standard Nature Methods "Points of View" recommendation is a CMY/green palette that stays distinguishable across common color vision deficiencies and stacks cleanly for 3–4 channel overlays.
Colors should be driven by what the eye sees (emission midpoint), not by dye brand names.
Band → color table
Bands are computed from emission_midpoint = (emission_low_nm + emission_high_nm) / 2. Fall back to excitation_nm only when emission is missing.
Auto-subdivide in the red side is the point: mOrange (~562 nm) → yellow, mCherry (~610 nm) → magenta, Alexa 647 (~668 nm) → white. Four red-family dyes each get a distinct display slot.
Dye-name fallback for non-LIF readers
Readers that don't surface emission wavelength (CZI/ND2/OME-TIFF paths that never populate emission_low_nm/emission_high_nm) need a way to still land in the right band. Add a DYE_TO_BAND lookup keyed on cleaned dye name (case-insensitive substring match, same shape as today's NAME_COLORS) that returns a band identifier; the band → color mapping stays the single source of truth. When neither wavelength nor dye name resolves, round-robin through a palette that matches the band colors so mystery channels stay visually coherent:
Even after subdivision, two channels can land on the same color (e.g., two far-red dyes both → white). When this happens: second-in-order and later channels round-robin through UNKNOWN_PALETTE skipping already-assigned colors, and a ChannelColorCollisionWarning (new warning class) names the affected channels so the user knows to reach for channel_colors= if it matters.
channel_colors= argument surface
The existing convert(..., channel_colors=None | dict[str, str]) parameter grows a third accepted value:
None (default) → emission-band scheme described above
dict[str, str] (existing behavior) → per-channel override map; unmapped channels fall through to the emission-band scheme
"source-file" (new) → use the source file's stored channel color when present (LIF <ChannelDescription LUTName> / OME-XML <Channel Color> / etc.); channels without a stored color fall through to the emission-band scheme
Update _channels_for_scene and its call sites in api.py accordingly. Preserve the audit record's config.channel_colors verbatim (dict, None, or "source-file").
Acceptance criteria
channel_colors.py restructured around EMISSION_BANDS (list of (lo_nm, hi_nm, hex) tuples) + DYE_TO_BAND (name → band identifier for the wavelength-less fallback path) + UNKNOWN_PALETTE.
_lif_scene_channels uses the emission midpoint when available; falls back to excitation, then dye name, then palette index.
Non-LIF reader paths (default writer's _default_channels) reach the same band mapping via the dye-name fallback.
channel_colors="source-file" implemented; reads the source's stored per-channel color where the reader exposes it; falls through to the band scheme where it doesn't.
Collision handling in place with ChannelColorCollisionWarning fired on residual collisions.
The real-file example from the reporting session (DAPI 405 nm / Alexa 546 555 nm / Alexa 647 651 nm) produces cyan / magenta / white under the default scheme.
NAME_COLORS removed (or kept only as a deprecated shim if migration matters — noted in CHANGELOG either way).
Tests: band-boundary edge cases (exactly 500 nm, exactly 660 nm), collision handling (two AF647), dye-name fallback (bioio-czi-style channel with no wavelength), source-file mode (mock source with and without stored color).
CHANGELOG entry under a minor version bump.
ADR (docs/adr/0007-emission-band-channel-colors.md) documenting: the emission-band scheme, why colorblind CMY over dye-true-color, the exact band boundaries and rationale, the source-file opt-in, and the collision policy.
What to build
Replace the fluorophore-name → true-color mapping in
src/zarrmony/metadata/channel_colors.pywith an emission-wavelength-band → colorblind-friendly display color mapping, driven by the emission midpoint thatextract_channelsalready surfaces from LIF XML. Preserve the existingconvert(..., channel_colors=...)per-channel dict override, and add achannel_colors="source-file"string mode that trusts the source file's stored channel color when present.Motivation
Zarrmony currently assigns display colors by looking up dye names in
NAME_COLORS(DAPI → 0099ff,ALEXA 546 → ff0000,AF647 → ff00ff, …). Two problems:ALEXA 546andALEXA 647in a real user file) end up asff0000and0099ffrespectively because the substring match hitsALEXAfor one and nothing for the other — collision-prone and surprising.Colors should be driven by what the eye sees (emission midpoint), not by dye brand names.
Band → color table
Bands are computed from
emission_midpoint = (emission_low_nm + emission_high_nm) / 2. Fall back toexcitation_nmonly when emission is missing.00ffff00ffff00ff00ffff00ffff00ff00ffffffffffffffffffffAuto-subdivide in the red side is the point: mOrange (~562 nm) → yellow, mCherry (~610 nm) → magenta, Alexa 647 (~668 nm) → white. Four red-family dyes each get a distinct display slot.
Dye-name fallback for non-LIF readers
Readers that don't surface emission wavelength (CZI/ND2/OME-TIFF paths that never populate
emission_low_nm/emission_high_nm) need a way to still land in the right band. Add aDYE_TO_BANDlookup keyed on cleaned dye name (case-insensitive substring match, same shape as today'sNAME_COLORS) that returns a band identifier; the band → color mapping stays the single source of truth. When neither wavelength nor dye name resolves, round-robin through a palette that matches the band colors so mystery channels stay visually coherent:Collision handling (residual case)
Even after subdivision, two channels can land on the same color (e.g., two far-red dyes both → white). When this happens: second-in-order and later channels round-robin through
UNKNOWN_PALETTEskipping already-assigned colors, and aChannelColorCollisionWarning(new warning class) names the affected channels so the user knows to reach forchannel_colors=if it matters.channel_colors=argument surfaceThe existing
convert(..., channel_colors=None | dict[str, str])parameter grows a third accepted value:None(default) → emission-band scheme described abovedict[str, str](existing behavior) → per-channel override map; unmapped channels fall through to the emission-band scheme"source-file"(new) → use the source file's stored channel color when present (LIF<ChannelDescription LUTName>/ OME-XML<Channel Color>/ etc.); channels without a stored color fall through to the emission-band schemeUpdate
_channels_for_sceneand its call sites inapi.pyaccordingly. Preserve the audit record'sconfig.channel_colorsverbatim (dict,None, or"source-file").Acceptance criteria
channel_colors.pyrestructured aroundEMISSION_BANDS(list of(lo_nm, hi_nm, hex)tuples) +DYE_TO_BAND(name → band identifier for the wavelength-less fallback path) +UNKNOWN_PALETTE._lif_scene_channelsuses the emission midpoint when available; falls back to excitation, then dye name, then palette index._default_channels) reach the same band mapping via the dye-name fallback.channel_colors="source-file"implemented; reads the source's stored per-channel color where the reader exposes it; falls through to the band scheme where it doesn't.ChannelColorCollisionWarningfired on residual collisions.cyan / magenta / whiteunder the default scheme.NAME_COLORSremoved (or kept only as a deprecated shim if migration matters — noted in CHANGELOG either way).source-filemode (mock source with and without stored color).docs/adr/0007-emission-band-channel-colors.md) documenting: the emission-band scheme, why colorblind CMY over dye-true-color, the exact band boundaries and rationale, thesource-fileopt-in, and the collision policy.Blocked by
None — can start immediately.