Skip to content
This repository was archived by the owner on Aug 27, 2025. It is now read-only.

Commit 879101a

Browse files
better color display when high number of channels
1 parent 959ce9d commit 879101a

3 files changed

Lines changed: 50 additions & 44 deletions

File tree

spatialdata_xenium_explorer/_constants.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ class ExplorerConstants:
1212
QUALITY_SCORE = 40
1313
MICRONS_TO_PIXELS = 4.705882
1414

15-
COLORS = [400, 500, 550, 600, 650, 700]
16-
KNOWN_CHANNELS = {"DAPI": 400}
15+
COLORS = ["white", 400, 500, 600, 700]
16+
NUCLEUS_COLOR = "white"
17+
KNOWN_CHANNELS = {"DAPI": "white", "DNA1": "white", "DNA2": "white", "DAPI (000)": "white"}
1718

1819

1920
class Versions:

spatialdata_xenium_explorer/cli/app.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,10 +119,10 @@ def add_aligned(
119119
import spatialdata
120120

121121
from spatialdata_xenium_explorer import align
122-
from spatialdata_xenium_explorer.core.images import xenium_if
122+
from spatialdata_xenium_explorer.core.images import ome_tif
123123

124124
sdata = spatialdata.read_zarr(sdata_path)
125-
image = xenium_if(image_path)
125+
image = ome_tif(image_path)
126126

127127
align(
128128
sdata, image, transformation_matrix_path, overwrite=overwrite, image_key=original_image_key

spatialdata_xenium_explorer/core/images.py

Lines changed: 45 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -135,32 +135,34 @@ def _default_image_models_kwargs(image_models_kwargs: dict | None):
135135
return image_models_kwargs
136136

137137

138-
def _is_color_valid(channel_name: str) -> bool:
139-
"""The color is valid if it contains a wavelength (e.g., `550`) or is known by the Xenium Explorer"""
140-
known_colors = set(ExplorerConstants.KNOWN_CHANNELS.keys())
141-
contains_wavelength = bool(re.search(r"(?<![0-9])[0-9]{3}(?![0-9])", channel_name))
142-
return contains_wavelength or channel_name in known_colors
138+
def _to_color(channel_name: str, is_wavelength: bool, colors_iterator: list):
139+
if is_wavelength:
140+
return channel_name
141+
if channel_name in ExplorerConstants.KNOWN_CHANNELS:
142+
return f"{channel_name} (color={ExplorerConstants.KNOWN_CHANNELS[channel_name]})"
143+
return f"{channel_name} (color={colors_iterator.pop()})"
143144

144145

145146
def _set_colors(channel_names: list[str]) -> list[str]:
146147
"""
147148
Trick to provide a color to all channels on the Xenium Explorer.
148149
149-
Some colors are automatically colored by the Xenium explorer (e.g., DAPI is colored in blue).
150150
But some channels colors are set to white by default. This functions allows to color these
151151
channels with an available wavelength color (e.g., `550`).
152152
"""
153-
colors_valid = [_is_color_valid(name) for name in channel_names]
154-
155-
already_assigned_colors = {ExplorerConstants.KNOWN_CHANNELS.get(name) for name in channel_names}
156-
available_colors = sorted(list(set(ExplorerConstants.COLORS) - already_assigned_colors))
157-
158-
n_invalid = len(colors_valid) - sum(colors_valid)
159-
color_indices = list(np.linspace(0, len(available_colors) - 1, n_invalid).round().astype(int))
153+
existing_wavelength = [
154+
bool(re.search(r"(?<![0-9])[0-9]{3}(?![0-9])", c)) for c in channel_names
155+
]
156+
valid_colors = [c for c in ExplorerConstants.COLORS if c != ExplorerConstants.NUCLEUS_COLOR]
157+
n_missing = sum(
158+
not is_wavelength and not c in ExplorerConstants.KNOWN_CHANNELS
159+
for c, is_wavelength in zip(channel_names, existing_wavelength)
160+
)
161+
colors_iterator: list = np.repeat(valid_colors, ceil(n_missing / len(valid_colors))).tolist()
160162

161163
return [
162-
name if is_valid else f"{name} (color={available_colors[color_indices.pop()]})"
163-
for name, is_valid in zip(channel_names, colors_valid)
164+
_to_color(c, is_wavelength, colors_iterator)
165+
for c, is_wavelength in zip(channel_names, existing_wavelength)
164166
]
165167

166168

@@ -240,41 +242,44 @@ def align(
240242
sdata.add_image(image.name, image, overwrite=overwrite)
241243

242244

243-
def _get_channel_names_xenium_if(element, names):
244-
for child in element:
245-
_get_channel_names_xenium_if(child, names)
246-
if element.attrib:
247-
if "Name" in element.attrib:
248-
names.append(element.attrib["Name"])
249-
return names
245+
def _ome_channels_names(path: str):
246+
import xml.etree.ElementTree as ET
247+
248+
tiff = tf.TiffFile(path)
249+
omexml_string = tiff.pages[0].description
250+
251+
root = ET.fromstring(omexml_string)
252+
namespaces = {"ome": "http://www.openmicroscopy.org/Schemas/OME/2016-06"}
253+
channels = root.findall("ome:Image[1]/ome:Pixels/ome:Channel", namespaces)
254+
return [c.attrib["Name"] if "Name" in c.attrib else c.attrib["ID"] for c in channels]
250255

251256

252-
def xenium_if(path: Path) -> SpatialImage:
253-
"""Read the IF image associated to Xenium data
257+
def ome_tif(path: Path) -> SpatialImage:
258+
"""Read an `.ome.tif` image. This image should be a 2D image (with possibly multiple channels).
259+
Typically, this function can be used to open Xenium IF images.
254260
255261
Args:
256-
path: Path to the `.ime.tif` IF image
262+
path: Path to the `.ome.tif` image
257263
258264
Returns:
259-
A `SpatialImage` representing the Xenium IF image
265+
A `SpatialImage`
260266
"""
261267
image_models_kwargs = _default_image_models_kwargs(None)
262-
263-
image: da.Array = imread(path)
264-
image = image.rechunk(chunks=image_models_kwargs["chunks"])
265-
266268
image_name = Path(path).absolute().name.split(".")[0]
269+
image: da.Array = imread(path)
267270

268-
import xml.etree.ElementTree as ET
271+
if image.ndim == 4:
272+
assert image.shape[0] == 1, f"4D images not supported"
273+
image = da.moveaxis(image[0], 2, 0)
274+
log.info(f"Transformed 4D image into a 3D image of shape (c, y, x) = {image.shape}")
275+
elif image.ndim != 3:
276+
raise ValueError(f"Number of dimensions not supported: {image.ndim}")
269277

270-
with tf.TiffFile(path) as tif:
271-
page_series = tif.series[0]
272-
desc = page_series[0].description
273-
root = ET.fromstring(desc)
274-
names = _get_channel_names_xenium_if(root, [])
278+
image = image.rechunk(chunks=image_models_kwargs["chunks"])
275279

276-
if len(names) != len(image):
277-
names = [str(i) for i in range(len(image))]
278-
log.warn(f"Channel names couldn't be read. Using {names} instead.")
280+
channel_names = _ome_channels_names(path)
281+
if len(channel_names) != len(image):
282+
channel_names = [str(i) for i in range(len(image))]
283+
log.warn(f"Channel names couldn't be read. Using {channel_names} instead.")
279284

280-
return SpatialImage(image, dims=["c", "y", "x"], name=image_name, coords={"c": names})
285+
return SpatialImage(image, dims=["c", "y", "x"], name=image_name, coords={"c": channel_names})

0 commit comments

Comments
 (0)