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
113 changes: 113 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 17 additions & 0 deletions docs/adr/0001-plate-wide-contrast-on-downsampled-pixels.md
Original file line number Diff line number Diff line change
@@ -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.
53 changes: 53 additions & 0 deletions src/pyphenix/_colormaps.py
Original file line number Diff line number Diff line change
@@ -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)]
25 changes: 3 additions & 22 deletions src/pyphenix/_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
import json
import warnings

from ._colormaps import channel_color


@dataclass
class PhenixMetadata:
Expand Down Expand Up @@ -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
Expand Down
29 changes: 2 additions & 27 deletions src/pyphenix/_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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, :, :, :]
Expand Down
60 changes: 60 additions & 0 deletions tests/test_colormaps.py
Original file line number Diff line number Diff line change
@@ -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)
Loading