|
| 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] |
0 commit comments