Skip to content

Commit b8ac63c

Browse files
authored
Continue refactor of nImage and create _layer_utils.py (#46)
# Description Follow-up to #45 This fixes an accidentally broken property and then continues to refactor nImage. Largely, this is meant to super clean up the API. It 1. Refactors out many methods to a new module `_layer_utils.py` 2. Cleans up the `get_layer_data_tuples` code path, especially for RGB 3. Cleans up typing throughout 4. Removes dead code
1 parent 88dd815 commit b8ac63c

File tree

10 files changed

+929
-653
lines changed

10 files changed

+929
-653
lines changed

src/ndevio/_layer_utils.py

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
"""Utilities for building napari layer data from BioImage objects."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from pathlib import Path
7+
from typing import TYPE_CHECKING
8+
9+
if TYPE_CHECKING:
10+
import xarray as xr
11+
from bioio_base.types import ArrayLike
12+
from napari.types import LayerDataTuple
13+
14+
logger = logging.getLogger(__name__)
15+
16+
# Keywords that indicate a channel contains labels/segmentation data
17+
LABEL_KEYWORDS = frozenset({'label', 'mask', 'segmentation', 'seg', 'roi'})
18+
19+
20+
def get_single_channel_name(
21+
layer_data: xr.DataArray,
22+
channel_dim: str,
23+
) -> str | None:
24+
"""Extract channel name from coords for single-channel image.
25+
26+
When an image has been squeezed and no longer has a Channel dimension,
27+
the channel name may still be available in the coordinates.
28+
29+
Parameters
30+
----------
31+
layer_data : xr.DataArray
32+
The image data array.
33+
channel_dim : str
34+
Name of the channel dimension (e.g., 'C').
35+
36+
Returns
37+
-------
38+
str | None
39+
The channel name if found, else None.
40+
41+
Examples
42+
--------
43+
>>> # For a squeezed single-channel image with coords
44+
>>> get_single_channel_name(data, 'C')
45+
'DAPI'
46+
47+
"""
48+
if channel_dim in layer_data.coords:
49+
coord = layer_data.coords[channel_dim]
50+
if coord.size == 1:
51+
return str(coord.item())
52+
return None
53+
54+
55+
def infer_layer_type(channel_name: str) -> str:
56+
"""Infer layer type from channel name keywords.
57+
58+
Parameters
59+
----------
60+
channel_name : str
61+
The channel name to check.
62+
63+
Returns
64+
-------
65+
str
66+
'labels' if channel_name contains a label keyword, else 'image'.
67+
68+
Examples
69+
--------
70+
>>> infer_layer_type('nuclei_mask')
71+
'labels'
72+
>>> infer_layer_type('DAPI')
73+
'image'
74+
75+
"""
76+
name_lower = channel_name.lower()
77+
return (
78+
'labels' if any(kw in name_lower for kw in LABEL_KEYWORDS) else 'image'
79+
)
80+
81+
82+
def resolve_layer_type(
83+
channel_name: str,
84+
global_override: str | None,
85+
channel_types: dict[str, str] | None,
86+
) -> str:
87+
"""Resolve layer type: global override > per-channel > auto-detect.
88+
89+
Parameters
90+
----------
91+
channel_name : str
92+
Name of the channel.
93+
global_override : str | None
94+
If set, this layer type is used for all channels.
95+
channel_types : dict[str, str] | None
96+
Per-channel layer type mapping.
97+
98+
Returns
99+
-------
100+
str
101+
The resolved layer type.
102+
103+
"""
104+
if global_override is not None:
105+
return global_override
106+
if channel_types and channel_name in channel_types:
107+
return channel_types[channel_name]
108+
return infer_layer_type(channel_name)
109+
110+
111+
def determine_in_memory(
112+
path: Path | None,
113+
max_in_mem_bytes: float = 4e9,
114+
max_in_mem_percent: float = 0.3,
115+
) -> bool:
116+
"""Determine whether to load image data in memory or as dask array.
117+
118+
Parameters
119+
----------
120+
path : Path | None
121+
Path to the image file. If None (array data), returns True.
122+
max_in_mem_bytes : float
123+
Maximum file size in bytes for in-memory loading.
124+
Default is 4 GB (4e9 bytes).
125+
max_in_mem_percent : float
126+
Maximum percentage of available memory for in-memory loading.
127+
Default is 30%.
128+
129+
Returns
130+
-------
131+
bool
132+
True if image should be loaded in memory, False for dask array.
133+
134+
"""
135+
from bioio_base.io import pathlike_to_fs
136+
from psutil import virtual_memory
137+
138+
# No file path means array data - always in memory
139+
if path is None:
140+
return True
141+
142+
fs, path_str = pathlike_to_fs(path)
143+
filesize: int = fs.size(path_str) # type: ignore[assignment]
144+
available_mem = virtual_memory().available
145+
146+
return (
147+
filesize <= max_in_mem_bytes
148+
and filesize < max_in_mem_percent * available_mem
149+
)
150+
151+
152+
def build_layer_tuple(
153+
data: ArrayLike,
154+
*,
155+
layer_type: str,
156+
name: str,
157+
metadata: dict,
158+
scale: tuple[float, ...],
159+
axis_labels: tuple[str, ...],
160+
units: tuple[str | None, ...],
161+
channel_idx: int = 0,
162+
total_channels: int = 1,
163+
rgb: bool = False,
164+
extra_kwargs: dict | None = None,
165+
) -> LayerDataTuple:
166+
"""Build a single LayerDataTuple for napari.
167+
168+
Parameters
169+
----------
170+
data : ArrayLike
171+
Image data for this layer.
172+
layer_type : str
173+
Layer type ('image', 'labels', etc.).
174+
name : str
175+
Layer name.
176+
metadata : dict
177+
Base metadata dict (bioimage, raw_metadata, etc.).
178+
scale : tuple[float, ...]
179+
Scale for each dimension.
180+
axis_labels : tuple[str, ...]
181+
Dimension labels.
182+
units : tuple[str | None, ...]
183+
Physical units for each dimension.
184+
channel_idx : int
185+
Channel index (for colormap/blending selection). Default 0.
186+
total_channels : int
187+
Total channels (for colormap selection). Default 1.
188+
rgb : bool
189+
Whether this is an RGB image.
190+
extra_kwargs : dict, optional
191+
Additional napari layer kwargs to merge (overrides defaults).
192+
193+
Returns
194+
-------
195+
LayerDataTuple
196+
(data, metadata, layer_type) tuple.
197+
198+
"""
199+
from ._colormap_utils import get_colormap_for_channel
200+
201+
layer_kwargs: dict = {
202+
'name': name,
203+
'metadata': metadata,
204+
'scale': scale,
205+
'axis_labels': axis_labels,
206+
'units': units,
207+
}
208+
209+
if rgb:
210+
layer_kwargs['rgb'] = True
211+
elif layer_type == 'image':
212+
# Add colormap/blending for non-RGB images
213+
layer_kwargs['colormap'] = get_colormap_for_channel(
214+
channel_idx, total_channels
215+
)
216+
layer_kwargs['blending'] = (
217+
'additive'
218+
if channel_idx > 0 and total_channels > 1
219+
else 'translucent_no_depth'
220+
)
221+
222+
# Apply extra overrides last
223+
if extra_kwargs:
224+
layer_kwargs.update(extra_kwargs)
225+
226+
return (data, layer_kwargs, layer_type) # type: ignore[return-value]

src/ndevio/_napari_reader.py

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616

1717
def napari_get_reader(
1818
path: PathLike,
19-
in_memory: bool | None = None,
2019
open_first_scene_only: bool | None = None,
2120
open_all_scenes: bool | None = None,
2221
) -> ReaderFunction | None:
@@ -31,8 +30,6 @@ def napari_get_reader(
3130
----------
3231
path : PathLike
3332
Path to the file to be read
34-
in_memory : bool, optional
35-
Whether to read the file in memory, by default None
3633
open_first_scene_only : bool, optional
3734
Whether to ignore multi-scene files and just open the first scene,
3835
by default None, which uses the setting
@@ -81,15 +78,13 @@ def napari_get_reader(
8178
# The actual reading happens in napari_reader_function
8279
return partial(
8380
napari_reader_function,
84-
in_memory=in_memory,
8581
open_first_scene_only=open_first_scene_only,
8682
open_all_scenes=open_all_scenes,
8783
)
8884

8985

9086
def napari_reader_function(
9187
path: PathLike,
92-
in_memory: bool | None = None,
9388
open_first_scene_only: bool = False,
9489
open_all_scenes: bool = False,
9590
) -> list[LayerDataTuple] | None:
@@ -103,8 +98,6 @@ def napari_reader_function(
10398
----------
10499
path : PathLike
105100
Path to the file to be read
106-
in_memory : bool, optional
107-
Whether to read the file in memory, by default None.
108101
open_first_scene_only : bool, optional
109102
Whether to ignore multi-scene files and just open the first scene,
110103
by default False.
@@ -132,24 +125,22 @@ def napari_reader_function(
132125

133126
# open first scene only
134127
if len(img.scenes) == 1 or open_first_scene_only:
135-
return img.get_layer_data_tuples(in_memory=in_memory)
128+
return img.get_layer_data_tuples()
136129

137130
# open all scenes as layers
138131
if open_all_scenes:
139132
layer_list = []
140133
for scene in img.scenes:
141134
img.set_scene(scene)
142-
layer_list.extend(img.get_layer_data_tuples(in_memory=in_memory))
135+
layer_list.extend(img.get_layer_data_tuples())
143136
return layer_list
144137

145138
# else: open scene widget
146-
_open_scene_container(path=path, img=img, in_memory=in_memory)
139+
_open_scene_container(path=path, img=img)
147140
return [(None,)] # type: ignore[return-value]
148141

149142

150-
def _open_scene_container(
151-
path: PathLike, img: nImage, in_memory: bool | None
152-
) -> None:
143+
def _open_scene_container(path: PathLike, img: nImage) -> None:
153144
from pathlib import Path
154145

155146
import napari
@@ -158,7 +149,7 @@ def _open_scene_container(
158149

159150
viewer = napari.current_viewer()
160151
viewer.window.add_dock_widget(
161-
nImageSceneWidget(viewer, path, img, in_memory),
152+
nImageSceneWidget(viewer, path, img),
162153
area='right',
163154
name=f'{Path(path).stem}{DELIMITER}Scenes',
164155
)

src/ndevio/_plugin_manager.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,35 @@ def get_installation_message(self) -> str:
142142
installed_plugins=self.installed_plugins,
143143
installable_plugins=self.installable_plugins,
144144
)
145+
146+
147+
def raise_unsupported_with_suggestions(path: PathLike) -> None:
148+
"""Raise UnsupportedFileFormatError with installation suggestions.
149+
150+
Parameters
151+
----------
152+
path : PathLike
153+
Path to the unsupported file.
154+
155+
Raises
156+
------
157+
UnsupportedFileFormatError
158+
Always raised, with optional installation suggestions.
159+
160+
"""
161+
from bioio_base.exceptions import UnsupportedFileFormatError
162+
from ndev_settings import get_settings
163+
164+
settings = get_settings()
165+
manager = ReaderPluginManager(path)
166+
msg_extra = (
167+
manager.get_installation_message()
168+
if settings.ndevio_reader.suggest_reader_plugins # type: ignore
169+
else None
170+
)
171+
172+
raise UnsupportedFileFormatError(
173+
reader_name='ndevio',
174+
path=str(path),
175+
msg_extra=msg_extra,
176+
) from None

0 commit comments

Comments
 (0)