diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..c11ffea --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,113 @@ +# pyphenix + +A reader and napari widget for high-content microscopy data acquired on the +PerkinElmer/Revvity **Opera Phenix** and processed by its **Harmony** software. +This file captures the shared language used across the codebase, the napari +widget UI, and the wishlist features. + +## Language + +### Acquisition layout + +**Plate**: +A multi-well container the acquisition was performed on, typically 96-well +(8 rows × 12 columns) or 384-well (16 × 24). +_Avoid_: dish, sample holder. + +**Well**: +A single addressable position on the **Plate**, identified as `r{row:02d}c{col:02d}` +(e.g. `r02c03`). Rows are surfaced to humans as letters (A–H / A–P). +_Avoid_: site, position (these mean **Field**). + +**Field**: +A single rectangular field-of-view captured at one X,Y position inside a +**Well**. Opera Phenix typically captures a grid of **Fields** per **Well** +(e.g. 25 fields = 5×5) which can be stitched. +_Avoid_: tile (overloaded), FOV (only fine in passing). + +**Stitching**: +Combining all **Fields** of one **Well** into a single larger image using their +recorded stage coordinates. The opposite of "first-field" mode, which reads +only one **Field** per **Well**. + +**Channel**: +A fluorescence acquisition (excitation/emission/exposure) — e.g. `DAPI`, +`Alexa 488`. Each **Channel** has a name, an integer ID, and a suggested +display colormap. The channel→colormap mapping is the single source of truth +shared by the napari widget and the plate overview. + +**Z-plane** / **Z-slice**: +One depth slice in a Z-stack. Used interchangeably; **Z-slice** wins in API +parameter names (`z_slices=`). + +**Timepoint**: +One time index in a time-series acquisition. + +### Source data structure + +**Export**: +A directory produced by Harmony's "Export" operation. Image files are TIFFs +under `Images/`; metadata is in `Index.xml`. The primary input shape pyphenix +reads. + +**Archive**: +An alternate Harmony output (`.kw.txt` + per-image files). Supported by the +reader; less common in practice. + +**FFC (flat-field correction)**: +Per-**Channel** illumination-correction profile shipped alongside the acquisition. +When present and enabled, divides out the vignetting/gain pattern. On by default +in `read_data` and in the plate overview. + +### Plate overview (new) + +**Plate overview**: +A single diagnostic PNG showing a grid of downsampled **Well** thumbnails in +the layout of the **Plate**, used to spot acquisition or biology issues at a +glance. Generated as one PNG per **Channel combo**, written to a flat output +directory alongside a JSON provenance sidecar. + +**Channel combo**: +A non-empty subset of the acquired **Channels** rendered into one **Plate +overview** PNG. **Singletons** (one **Channel**) are colored with `viridis`; +**Merges** (two or more) use each **Channel**'s suggested colormap and additive +blending, matching what the napari widget shows. + +**Plate-wide contrast limits**: +A per-**Channel** intensity display range `[0, p99.5]` computed once across +the downsampled, max-projected pixels of every acquired **Well**. Every +**Well** in every **Plate overview** is rendered with these identical limits, +so signal differences between wells are visually comparable. + +**Field choice**: +The per-**Well** rule for what to render in the overview: a specific **Field** +index, `'stitched'`, or the default first **Field**. Applied uniformly to +every **Well** in the run. + +## Relationships + +- A **Plate** contains many **Wells**; a **Well** contains many **Fields**. +- A **Field** has many **Z-planes** × **Channels** × **Timepoints** (5-D `(T,C,Z,Y,X)`). +- A **Plate overview** is one PNG per **Channel combo** (so `2^N − 1` PNGs for + N selected **Channels**) plus one JSON sidecar. +- **Plate-wide contrast limits** are computed once per overview run and shared + by every **Channel combo** PNG in that run. +- The napari widget and the plate overview both consume the channel→colormap + mapping from the same shared module — they must not duplicate it. + +## Example dialogue + +> **Dev:** "If I run the overview on a 6-channel plate with `channels=[1,4]`, how many PNGs do I get?" +> **Domain expert:** "Three — Ch1 alone in viridis, Ch4 alone in viridis, and a Ch1+Ch4 merge in their assigned colormaps. Plate-wide contrast is still computed per channel from the full set, then applied identically to every well." + +> **Dev:** "Should the overview let users gray out wells filtered by the experimental metadata CSV?" +> **Domain expert:** "Not in v1. The point of the overview is to *see what was acquired*, not what you're going to analyze. If you've filtered wells, run the overview before the filter." + +## Flagged ambiguities + +- "Field" vs "tile" — both were used in the wishlist for what the napari + widget calls a "tile" in side-by-side mode. Resolved: **Field** is always + the acquisition unit; "tile" is only used for napari side-by-side viewer + positioning. +- "Stitched" applies to **Field**-level combination only. The **Plate overview** + grid is not "stitched" — it's a `matplotlib` grid of independent thumbnails. \ No newline at end of file diff --git a/docs/adr/0001-plate-wide-contrast-on-downsampled-pixels.md b/docs/adr/0001-plate-wide-contrast-on-downsampled-pixels.md new file mode 100644 index 0000000..92b5afd --- /dev/null +++ b/docs/adr/0001-plate-wide-contrast-on-downsampled-pixels.md @@ -0,0 +1,17 @@ +# Plate-wide contrast limits are computed on downsampled, max-projected pixels + +The plate overview needs a per-channel intensity range that's identical across +every well, so wells can be visually compared. The obvious approach — pool every +raw 16-bit pixel from every well and take the 99.5th percentile — roughly doubles +I/O (one streaming pass to compute limits, a second pass to render). Instead we +compute the percentile on the same downsampled, Z-max-projected pixels we already +hold for rendering, which keeps the overview single-pass and bounded in memory. + +The trade-off is that downsampling (block-mean) attenuates single-pixel hot spots, +so the resulting `[0, p99.5]` will sit slightly lower than the napari widget's +per-well number on the same data. For a *diagnostic* overview this is arguably an +improvement (suppresses isolated saturation), but it does mean overview intensities +are not directly comparable to live napari intensities. If a future use case +demands exact parity, switch to a two-pass implementation rather than changing the +percentile or the downsampling kernel — both of those would silently shift every +existing overview's apparent brightness. \ No newline at end of file diff --git a/src/pyphenix/_colormaps.py b/src/pyphenix/_colormaps.py new file mode 100644 index 0000000..979bee5 --- /dev/null +++ b/src/pyphenix/_colormaps.py @@ -0,0 +1,53 @@ +"""Canonical channel→colormap mapping shared by the napari widget, +the drag-and-drop reader, and the plate overview generator. + +There must be exactly one such mapping in the codebase; adding a +fluorophore here propagates everywhere it's rendered. +""" + +# Order matters: lookup is first-match on case-insensitive substring, +# so more specific names (e.g. "Alexa 488") should come before broader +# ones that might substring-match them. +CHANNEL_COLORS = { + 'Brightfield': 'gray', + 'DAPI': 'cyan', + 'Hoechst': 'cyan', + 'Alexa 488': 'green', + 'GFP': 'green', + 'EGFP': 'green', + 'Alexa 555': 'yellow', + 'Alexa 568': 'yellow', + 'Cy3': 'yellow', + 'mCherry': 'magenta', + 'mStrawberry': 'magenta', + 'Alexa 647': 'magenta', + 'Cy5': 'magenta', +} + +DEFAULT_COLORS = ['cyan', 'magenta', 'yellow', 'green', 'red', 'blue'] + + +def channel_color(name, idx): + """Return the napari colormap name for a channel. + + Parameters + ---------- + name : str + Channel name as reported by Harmony (e.g. ``"DAPI"``, + ``"Alexa 488"``). Matched case-insensitively as a substring + against the keys of :data:`CHANNEL_COLORS`. + idx : int + Zero-based channel index. Used as the fallback when *name* + matches nothing in :data:`CHANNEL_COLORS`, indexing into + :data:`DEFAULT_COLORS` with wrap-around. + + Returns + ------- + str + A napari colormap name (e.g. ``"green"``, ``"magenta"``). + """ + name_lower = name.lower() + for key, color in CHANNEL_COLORS.items(): + if key.lower() in name_lower: + return color + return DEFAULT_COLORS[idx % len(DEFAULT_COLORS)] diff --git a/src/pyphenix/_reader.py b/src/pyphenix/_reader.py index 16d2e08..3ead5fe 100644 --- a/src/pyphenix/_reader.py +++ b/src/pyphenix/_reader.py @@ -10,6 +10,8 @@ import json import warnings +from ._colormaps import channel_color + @dataclass class PhenixMetadata: @@ -1899,31 +1901,10 @@ def phenix_reader(path): pixel_size_y = metadata['pixel_size']['y'] * 1e6 z_step = metadata['z_step'] * 1e6 if metadata['z_step'] is not None else 1.0 - # Color mapping - color_map = { - 'DAPI': 'blue', - 'Hoechst': 'blue', - 'Alexa 488': 'green', - 'GFP': 'green', - 'Alexa 555': 'yellow', - 'mCherry': 'red', - 'Alexa 647': 'magenta', - 'Cy5': 'magenta', - } - default_colors = ['cyan', 'magenta', 'yellow', 'green', 'red', 'blue'] - # Add each channel as a layer for ch_idx, (ch_id, ch_info) in enumerate(channels_info.items()): ch_name = ch_info['name'] - - # Select color - color = None - for key, value in color_map.items(): - if key.lower() in ch_name.lower(): - color = value - break - if color is None: - color = default_colors[ch_idx % len(default_colors)] + color = channel_color(ch_name, ch_idx) # Get channel data if data.shape[0] > 1: # Multiple timepoints diff --git a/src/pyphenix/_widget.py b/src/pyphenix/_widget.py index 843b1f4..3f37eee 100644 --- a/src/pyphenix/_widget.py +++ b/src/pyphenix/_widget.py @@ -12,6 +12,7 @@ from qtpy.QtCore import Qt, Signal, QPropertyAnimation, QEasingCurve from ._reader import OperaPhenixReader +from ._colormaps import channel_color def _normalise_well_str(well: str) -> str: """ @@ -1834,38 +1835,12 @@ def _add_layers_to_viewer( else 1.0 ) - color_map = { - 'Brightfield': 'gray', - 'DAPI': 'cyan', - 'Hoechst': 'cyan', - 'Alexa 488': 'green', - 'GFP': 'green', - 'EGFP': 'green', - 'Alexa 555': 'yellow', - 'Alexa 568': 'yellow', - 'mCherry': 'magenta', - 'mStrawberry': 'magenta', - 'Alexa 647': 'magenta', - 'Cy5': 'magenta', - 'Cy3': 'yellow', - } - default_colors = [ - 'cyan', 'magenta', 'yellow', 'green', 'red', 'blue', - ] - ty, tx = translate_yx added_names = [] for ch_idx, (ch_id, ch_info) in enumerate(channels_info.items()): ch_name = ch_info['name'] - - color = None - for key, value in color_map.items(): - if key.lower() in ch_name.lower(): - color = value - break - if color is None: - color = default_colors[ch_idx % len(default_colors)] + color = channel_color(ch_name, ch_idx) if data.shape[0] > 1: channel_data = data[:, ch_idx, :, :, :] diff --git a/tests/test_colormaps.py b/tests/test_colormaps.py new file mode 100644 index 0000000..8cb36f3 --- /dev/null +++ b/tests/test_colormaps.py @@ -0,0 +1,60 @@ +import pytest + +from pyphenix._colormaps import CHANNEL_COLORS, DEFAULT_COLORS, channel_color + + +@pytest.mark.parametrize("name,expected", [ + ("DAPI", "cyan"), + ("Hoechst", "cyan"), + ("Brightfield", "gray"), + ("Alexa 488", "green"), + ("GFP", "green"), + ("EGFP", "green"), + ("Alexa 555", "yellow"), + ("Alexa 568", "yellow"), + ("Cy3", "yellow"), + ("mCherry", "magenta"), + ("mStrawberry", "magenta"), + ("Alexa 647", "magenta"), + ("Cy5", "magenta"), +]) +def test_known_channel_names_map_correctly(name, expected): + assert channel_color(name, idx=0) == expected + + +def test_known_channel_name_ignores_idx(): + assert channel_color("DAPI", idx=0) == "cyan" + assert channel_color("DAPI", idx=5) == "cyan" + + +def test_unknown_name_falls_back_to_default_by_idx(): + assert channel_color("MyNovelDye", idx=0) == DEFAULT_COLORS[0] + assert channel_color("MyNovelDye", idx=2) == DEFAULT_COLORS[2] + + +def test_unknown_name_wraps_idx_around_default_list(): + n = len(DEFAULT_COLORS) + assert channel_color("MyNovelDye", idx=n) == DEFAULT_COLORS[0] + assert channel_color("MyNovelDye", idx=n + 3) == DEFAULT_COLORS[3] + + +def test_case_insensitive_substring_matching(): + assert channel_color("dapi", idx=0) == "cyan" + assert channel_color("Dapi Signal", idx=0) == "cyan" + assert channel_color("Channel: ALEXA 488 nm", idx=0) == "green" + + +def test_substring_match_picks_first_listed_entry_on_ambiguity(): + # The dict is iterated in insertion order; the first key whose + # lowercase form is a substring of the channel name wins. This is + # the historical behavior we want to preserve. + expected = next( + color for key, color in CHANNEL_COLORS.items() + if key.lower() in "dapi channel".lower() + ) + assert channel_color("DAPI channel", idx=0) == expected + + +def test_returns_string(): + assert isinstance(channel_color("DAPI", idx=0), str) + assert isinstance(channel_color("Unknown", idx=0), str)