Skip to content

feat(channels): emission-band colorblind palette as default; source-file opt-in #51

Description

@ferrinm

What to build

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:

  1. 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.
  2. 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.

Band Emission midpoint (nm) Display color Hex Representative dyes
Deep-blue / UV < 450 Cyan 00ffff DAPI, Hoechst 33258/33342
Cyan 450–500 Cyan 00ffff CFP, Cerulean, mTurquoise, BFP
Green 500–545 Green 00ff00 GFP, EGFP, Alexa 488, FITC, FAM, Cy2, mNeonGreen, Emerald
Yellow 545–580 Yellow ffff00 YFP, Citrine, Venus, mCitrine, Alexa 514, Alexa 532
Orange 580–610 Yellow ffff00 mOrange, dTomato, Alexa 555, Cy3, TRITC
Red 610–660 Magenta ff00ff mCherry, mRFP, Alexa 546/568/594, Texas Red
Far-red 660–740 White ffffff Alexa 647, Cy5, AF680, iRFP670
NIR > 740 White ffffff Cy7, AF750, Alexa 790
Brightfield / DIC / Phase n/a White ffffff Brightfield, BF, DIC, Phase

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:

UNKNOWN_PALETTE = ("00ffff", "00ff00", "ff00ff", "ffff00", "ffffff")

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_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.

Blocked by

None — can start immediately.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestready-for-agentTriaged and ready for an agent to implement

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions