Skip to content

Commit 960aa3b

Browse files
authored
Add layer_names property to nImage and refactor channel name logic (#54)
# Description Provides clean property API for getting the layer name, which is a combination of current nImage information, including the channel, scene and path stem. This allows us to adjust things in better abstracted pieces going forward, and is more similar to the rest of the nImage API. The layer names property returns a list corresponding to the number of layers that would be added. This PR also removes the greedy `get_single_channel_name` and instead just directly uses lazy `nImage.channel_names` logic throughout.
1 parent 0a82e75 commit 960aa3b

File tree

4 files changed

+72
-114
lines changed

4 files changed

+72
-114
lines changed

src/ndevio/nimage.py

Lines changed: 55 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
from .utils._layer_utils import (
1515
build_layer_tuple,
1616
determine_in_memory,
17-
get_single_channel_name,
1817
resolve_layer_type,
1918
)
2019

@@ -234,6 +233,53 @@ def path_stem(self) -> str:
234233
return PurePosixPath(urlparse(self.path).path).stem
235234
return Path(self.path).stem
236235

236+
@property
237+
def layer_names(self) -> list[str]:
238+
"""Per-channel layer names for napari.
239+
240+
Returns one name per output layer — the same count as
241+
:meth:`get_layer_data_tuples` returns tuples. The base name is the
242+
scene-qualified :attr:`path_stem`; channel names are prepended using
243+
``' :: '`` as a delimiter when present.
244+
245+
Returns
246+
-------
247+
list[str]
248+
e.g. ``['membrane :: cells.ome', 'nuclei :: cells.ome']``
249+
for a 2-channel file, or ``['0 :: cells.ome']`` for a
250+
single-channel OME image with a default ``C`` coordinate.
251+
Only when no ``C`` dimension is present at all will the name
252+
be just ``['cells.ome']``.
253+
254+
Examples
255+
--------
256+
>>> nImage("cells.ome.tiff").layer_names
257+
['0 :: cells.ome']
258+
259+
"""
260+
# Build scene-qualified base name
261+
delim = ' :: '
262+
parts: list[str] = []
263+
if len(self.scenes) > 1 or self.current_scene != 'Image:0':
264+
parts.extend([str(self.current_scene_index), self.current_scene])
265+
parts.append(self.path_stem)
266+
base_name = delim.join(parts)
267+
268+
# RGB (Samples dim): single layer, no channel prefix
269+
if 'S' in self.dims.order:
270+
return [base_name]
271+
272+
# Use BioImage channel_names — metadata only, no data load
273+
channel_names = self.channel_names
274+
275+
# Single channel (C=1 is squeezed out of layer_data)
276+
if self.dims.C == 1:
277+
ch_name = channel_names[0]
278+
return [f'{ch_name} :: {base_name}' if ch_name else base_name]
279+
280+
# Multichannel
281+
return [f'{ch} :: {base_name}' for ch in channel_names]
282+
237283
@property
238284
def layer_scale(self) -> tuple[float, ...]:
239285
"""Physical scale for dimensions in layer data.
@@ -358,34 +404,6 @@ def layer_metadata(self) -> dict:
358404

359405
return meta
360406

361-
def _build_layer_name(self, channel_name: str | None = None) -> str:
362-
"""Build layer name from channel, scene, and file path.
363-
364-
Parameters
365-
----------
366-
channel_name : str, optional
367-
Name of the channel to include in the layer name.
368-
369-
Returns
370-
-------
371-
str
372-
Formatted layer name like "channel :: 0 :: Image:0 :: filename".
373-
374-
"""
375-
delim = ' :: '
376-
parts: list[str] = []
377-
378-
if channel_name:
379-
parts.append(channel_name)
380-
381-
# Include scene info if multi-scene or non-default scene name
382-
if len(self.scenes) > 1 or self.current_scene != 'Image:0':
383-
parts.extend([str(self.current_scene_index), self.current_scene])
384-
385-
parts.append(self.path_stem)
386-
387-
return delim.join(parts)
388-
389407
def get_layer_data_tuples(
390408
self,
391409
layer_type: str | None = None,
@@ -440,24 +458,22 @@ def get_layer_data_tuples(
440458
https://napari.org/dev/plugins/building_a_plugin/guides.html
441459
442460
"""
443-
# Access layer_data property to ensure it's loaded
444461
layer_data = self.layer_data
445-
446462
if layer_type is not None:
447463
channel_types = None # Global override ignores per-channel
448-
464+
names = self.layer_names
449465
base_metadata = self.layer_metadata
450466
scale = self.layer_scale
451467
axis_labels = self.layer_axis_labels
452468
units = self.layer_units
453469

454470
# Handle RGB images (Samples dimension 'S')
455-
if 'S' in self.reader.dims.order:
471+
if 'S' in self.dims.order:
456472
return [
457473
build_layer_tuple(
458474
layer_data.data,
459475
layer_type='image',
460-
name=self._build_layer_name(),
476+
name=names[0],
461477
metadata=base_metadata,
462478
scale=scale,
463479
axis_labels=axis_labels,
@@ -470,7 +486,7 @@ def get_layer_data_tuples(
470486

471487
# Single channel (no C dimension to split)
472488
if channel_dim not in layer_data.dims:
473-
channel_name = get_single_channel_name(layer_data, channel_dim)
489+
channel_name = self.channel_names[0]
474490
effective_type = resolve_layer_type(
475491
channel_name or '', layer_type, channel_types
476492
)
@@ -483,7 +499,7 @@ def get_layer_data_tuples(
483499
build_layer_tuple(
484500
layer_data.data,
485501
layer_type=effective_type,
486-
name=self._build_layer_name(channel_name),
502+
name=names[0],
487503
metadata=base_metadata,
488504
scale=scale,
489505
axis_labels=axis_labels,
@@ -493,17 +509,13 @@ def get_layer_data_tuples(
493509
]
494510

495511
# Multichannel - split into separate layers
496-
channel_names = [
497-
str(c) for c in layer_data.coords[channel_dim].data.tolist()
498-
]
512+
channel_names = self.channel_names
499513
channel_axis = layer_data.dims.index(channel_dim)
500514
total_channels = layer_data.shape[channel_axis]
501515

502516
tuples: list[LayerDataTuple] = []
503517
for i in range(total_channels):
504-
channel_name = (
505-
channel_names[i] if i < len(channel_names) else f'channel_{i}'
506-
)
518+
channel_name = channel_names[i]
507519
effective_type = resolve_layer_type(
508520
channel_name, layer_type, channel_types
509521
)
@@ -521,7 +533,7 @@ def get_layer_data_tuples(
521533
build_layer_tuple(
522534
channel_data,
523535
layer_type=effective_type,
524-
name=self._build_layer_name(channel_name),
536+
name=names[i],
525537
metadata=base_metadata,
526538
scale=scale,
527539
axis_labels=axis_labels,

src/ndevio/utils/_layer_utils.py

Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
from typing import TYPE_CHECKING
77

88
if TYPE_CHECKING:
9-
import xarray as xr
109
from bioio_base.types import ArrayLike
1110
from napari.types import LayerDataTuple
1211

@@ -16,41 +15,6 @@
1615
LABEL_KEYWORDS = frozenset({'label', 'mask', 'segmentation', 'seg', 'roi'})
1716

1817

19-
def get_single_channel_name(
20-
layer_data: xr.DataArray,
21-
channel_dim: str,
22-
) -> str | None:
23-
"""Extract channel name from coords for single-channel image.
24-
25-
When an image has been squeezed and no longer has a Channel dimension,
26-
the channel name may still be available in the coordinates.
27-
28-
Parameters
29-
----------
30-
layer_data : xr.DataArray
31-
The image data array.
32-
channel_dim : str
33-
Name of the channel dimension (e.g., 'C').
34-
35-
Returns
36-
-------
37-
str | None
38-
The channel name if found, else None.
39-
40-
Examples
41-
--------
42-
>>> # For a squeezed single-channel image with coords
43-
>>> get_single_channel_name(data, 'C')
44-
'DAPI'
45-
46-
"""
47-
if channel_dim in layer_data.coords:
48-
coord = layer_data.coords[channel_dim]
49-
if coord.size == 1:
50-
return str(coord.item())
51-
return None
52-
53-
5418
def infer_layer_type(channel_name: str) -> str:
5519
"""Infer layer type from channel name keywords.
5620

tests/test_nimage.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,23 @@ def test_layer_names_include_channel_names(self, resources_dir: Path):
303303
assert 'membrane' in names[0]
304304
assert 'nuclei' in names[1]
305305

306+
def test_layer_names_matches_tuple_names(self, resources_dir: Path):
307+
"""Test that layer_names property matches names in get_layer_data_tuples."""
308+
img = nImage(resources_dir / CELLS3D2CH_OME_TIFF)
309+
layer_tuples = img.get_layer_data_tuples()
310+
311+
# layer_names should match names baked into the tuples
312+
assert img.layer_names == [meta['name'] for _, meta, _ in layer_tuples]
313+
assert len(img.layer_names) == 2
314+
assert 'membrane' in img.layer_names[0]
315+
assert 'nuclei' in img.layer_names[1]
316+
317+
def test_layer_names_single_channel(self, resources_dir: Path):
318+
"""Test layer_names for a single-channel image."""
319+
img = nImage(resources_dir / LOGO_PNG)
320+
assert len(img.layer_names) == 1
321+
assert img.layer_names[0].endswith(img.path_stem)
322+
306323
def test_single_channel_image_returns_single_tuple(
307324
self, resources_dir: Path
308325
):

tests/test_utils/test_layer_utils.py

Lines changed: 0 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -239,38 +239,3 @@ def test_extra_kwargs_override(self):
239239

240240
assert result[1]['colormap'] == 'custom'
241241
assert result[1]['visible'] is False
242-
243-
244-
class TestGetSingleChannelName:
245-
"""Tests for get_single_channel_name function."""
246-
247-
def test_returns_channel_name_from_coords(self):
248-
"""Test extracting channel name from coords."""
249-
import numpy as np
250-
import xarray as xr
251-
252-
from ndevio.utils._layer_utils import get_single_channel_name
253-
254-
data = xr.DataArray(
255-
np.zeros((10, 10)),
256-
dims=['Y', 'X'],
257-
coords={'C': 'DAPI'}, # Single channel coord
258-
)
259-
260-
result = get_single_channel_name(data, 'C')
261-
assert result == 'DAPI'
262-
263-
def test_returns_none_when_no_coord(self):
264-
"""Test returns None when no channel coord."""
265-
import numpy as np
266-
import xarray as xr
267-
268-
from ndevio.utils._layer_utils import get_single_channel_name
269-
270-
data = xr.DataArray(
271-
np.zeros((10, 10)),
272-
dims=['Y', 'X'],
273-
)
274-
275-
result = get_single_channel_name(data, 'C')
276-
assert result is None

0 commit comments

Comments
 (0)