napari-ome-arrow 0.0.4__py3-none-any.whl → 0.0.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  """
2
2
  Minimal napari reader for OME-Arrow sources (stack patterns, OME-Zarr, OME-Parquet,
3
- OME-TIFF) plus a fallback .npy example.
3
+ OME-Vortex, OME-TIFF) plus a fallback .npy example.
4
4
 
5
5
  Behavior:
6
6
  * If NAPARI_OME_ARROW_LAYER_TYPE is set to "image" or "labels",
@@ -11,64 +11,91 @@ Behavior:
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
- import math
15
14
  import os
16
15
  import warnings
17
- from collections.abc import Sequence
16
+ from collections.abc import Callable, Sequence
18
17
  from pathlib import Path
19
- from typing import Any, Union
20
-
21
- import numpy as np
22
- import pyarrow as pa
23
- from ome_arrow.core import OMEArrow
24
- from ome_arrow.meta import OME_ARROW_STRUCT
25
-
26
- PathLike = Union[str, Path]
27
- LayerData = tuple[np.ndarray, dict[str, Any], str]
28
-
29
-
30
- def _maybe_set_viewer_3d(arr: np.ndarray) -> None:
31
- """
32
- If the array has a Z axis with size > 1, switch the current napari viewer
33
- to 3D (ndisplay = 3).
34
-
35
- Assumes OME-Arrow's TCZYX convention or a subset, i.e., Z is always
36
- the third-from-last axis. No-op if there's no active viewer.
37
- """
38
- # Need at least (Z, Y, X)
39
- if arr.ndim < 3:
40
- return
41
-
42
- z_size = arr.shape[-3]
43
- if z_size <= 1:
44
- return
45
-
46
- try:
47
- import napari
48
-
49
- viewer = napari.current_viewer()
50
- except Exception:
51
- # no viewer / not in GUI context → silently skip
52
- return
53
-
54
- if viewer is not None:
55
- viewer.dims.ndisplay = 3
56
18
 
19
+ from ._reader_infer import ( # noqa: F401
20
+ _infer_layer_mode_from_record,
21
+ _infer_layer_mode_from_source,
22
+ _looks_like_ome_source,
23
+ _normalize_image_type,
24
+ )
25
+ from ._reader_napari import ( # noqa: F401
26
+ _maybe_set_viewer_3d,
27
+ _strip_channel_axis,
28
+ )
29
+ from ._reader_omearrow import ( # noqa: F401
30
+ _read_one,
31
+ _read_parquet_rows,
32
+ _read_vortex_rows,
33
+ )
34
+ from ._reader_stack import ( # noqa: F401
35
+ _channel_names_from_pattern,
36
+ _collect_stack_files,
37
+ _infer_stack_scale_from_pattern,
38
+ _parse_stack_scale,
39
+ _prompt_stack_pattern,
40
+ _prompt_stack_scale,
41
+ _read_rgb_stack_pattern,
42
+ _replace_channel_placeholder,
43
+ _stack_default_dim_for_pattern,
44
+ _suggest_stack_pattern,
45
+ )
46
+ from ._reader_types import LayerData, PathLike
47
+
48
+ __all__ = [
49
+ "napari_get_reader",
50
+ "reader_function",
51
+ "_infer_layer_mode_from_record",
52
+ "_infer_layer_mode_from_source",
53
+ "_looks_like_ome_source",
54
+ "_normalize_image_type",
55
+ "_maybe_set_viewer_3d",
56
+ "_strip_channel_axis",
57
+ "_read_one",
58
+ "_read_parquet_rows",
59
+ "_read_vortex_rows",
60
+ "_channel_names_from_pattern",
61
+ "_collect_stack_files",
62
+ "_infer_stack_scale_from_pattern",
63
+ "_parse_stack_scale",
64
+ "_prompt_stack_pattern",
65
+ "_prompt_stack_scale",
66
+ "_read_rgb_stack_pattern",
67
+ "_replace_channel_placeholder",
68
+ "_stack_default_dim_for_pattern",
69
+ "_suggest_stack_pattern",
70
+ ]
57
71
 
58
72
  # --------------------------------------------------------------------- #
59
73
  # Mode selection (env var + GUI prompt)
60
74
  # --------------------------------------------------------------------- #
61
75
 
62
76
 
63
- def _get_layer_mode(sample_path: str) -> str:
64
- """
65
- Decide whether to load as 'image' or 'labels'.
77
+ def _get_layer_mode(
78
+ sample_path: str, *, image_type_hint: str | None = None
79
+ ) -> str:
80
+ """Decide whether to load as "image" or "labels".
66
81
 
67
82
  Priority:
68
- 1. NAPARI_OME_ARROW_LAYER_TYPE env var (image/labels)
69
- 2. If in a Qt GUI context, show a modal dialog asking the user
70
- 3. Otherwise, default to 'image'
83
+ 1. `NAPARI_OME_ARROW_LAYER_TYPE` env var (image/labels).
84
+ 2. If in a Qt GUI context, show a modal dialog asking the user.
85
+ 3. Otherwise, default to "image".
86
+
87
+ Args:
88
+ sample_path: Path used for prompt labeling.
89
+ image_type_hint: Optional inferred mode from metadata.
90
+
91
+ Returns:
92
+ The selected mode, "image" or "labels".
93
+
94
+ Raises:
95
+ RuntimeError: If the environment variable is invalid or the user
96
+ cancels the dialog.
71
97
  """
98
+ # Env var override (used in headless/batch workflows).
72
99
  mode = os.environ.get("NAPARI_OME_ARROW_LAYER_TYPE")
73
100
  if mode is not None:
74
101
  mode = mode.lower()
@@ -78,6 +105,10 @@ def _get_layer_mode(sample_path: str) -> str:
78
105
  f"Invalid NAPARI_OME_ARROW_LAYER_TYPE={mode!r}; expected 'image' or 'labels'."
79
106
  )
80
107
 
108
+ # Metadata hint (best effort).
109
+ if image_type_hint in {"image", "labels"}:
110
+ return image_type_hint
111
+
81
112
  # No env var → try to prompt in GUI context
82
113
  try:
83
114
  from qtpy import QtWidgets
@@ -127,180 +158,21 @@ def _get_layer_mode(sample_path: str) -> str:
127
158
  raise RuntimeError("User cancelled napari-ome-arrow load dialog.")
128
159
 
129
160
 
130
- # --------------------------------------------------------------------- #
131
- # Helper utilities
132
- # --------------------------------------------------------------------- #
133
-
134
-
135
- def _as_labels(arr: np.ndarray) -> np.ndarray:
136
- """Convert any array into an integer label array."""
137
- if arr.dtype.kind == "f":
138
- arr = np.nan_to_num(arr, nan=0.0, posinf=0.0, neginf=0.0)
139
- arr = np.round(arr).astype(np.int32, copy=False)
140
- elif arr.dtype.kind not in ("i", "u"):
141
- arr = arr.astype(np.int32, copy=False)
142
- return arr
143
-
144
-
145
- def _looks_like_ome_source(path_str: str) -> bool:
146
- """Basic extension / pattern sniffing for OME-Arrow supported formats."""
147
- s = path_str.strip().lower()
148
- p = Path(path_str)
149
-
150
- looks_stack = any(c in path_str for c in "<>*")
151
- looks_zarr = (
152
- s.endswith((".ome.zarr", ".zarr"))
153
- or ".zarr/" in s
154
- or p.exists()
155
- and p.is_dir()
156
- and p.suffix.lower() == ".zarr"
157
- )
158
- looks_parquet = s.endswith(
159
- (".ome.parquet", ".parquet", ".pq")
160
- ) or p.suffix.lower() in {
161
- ".parquet",
162
- ".pq",
163
- }
164
- looks_tiff = s.endswith(
165
- (".ome.tif", ".ome.tiff", ".tif", ".tiff")
166
- ) or p.suffix.lower() in {
167
- ".tif",
168
- ".tiff",
169
- }
170
- looks_npy = s.endswith(".npy")
171
- return (
172
- looks_stack or looks_zarr or looks_parquet or looks_tiff or looks_npy
173
- )
174
-
175
-
176
- # --------------------------------------------------------------------- #
177
- # OME-Parquet helpers (multi-row grid)
178
- # --------------------------------------------------------------------- #
179
-
180
-
181
- def _find_ome_parquet_columns(table: pa.Table) -> list[str]:
182
- """Return struct columns matching the OME-Arrow schema."""
183
- import pyarrow as pa
184
-
185
- expected_fields = {f.name for f in OME_ARROW_STRUCT}
186
- names: list[str] = []
187
- for name, col in zip(table.column_names, table.columns, strict=False):
188
- if (
189
- pa.types.is_struct(col.type)
190
- and {f.name for f in col.type} == expected_fields
191
- ):
192
- names.append(name)
193
- return names
194
-
195
-
196
- def _enable_grid(n_layers: int) -> None:
197
- """Switch current viewer into grid view when possible."""
198
- if n_layers <= 1:
199
- return
200
- try:
201
- import napari
202
-
203
- viewer = napari.current_viewer()
204
- except Exception:
205
- return
206
- if viewer is None:
207
- return
208
-
209
- cols = math.ceil(math.sqrt(n_layers))
210
- rows = math.ceil(n_layers / cols)
211
- try:
212
- viewer.grid.enabled = True
213
- viewer.grid.shape = (rows, cols)
214
- except Exception:
215
- # grid is best-effort; ignore if unavailable
216
- return
217
-
218
-
219
- def _read_parquet_rows(src: str, mode: str) -> list[LayerData] | None:
220
- """
221
- Specialized path for multi-row OME-Parquet:
222
- create one layer per row and enable napari grid view.
223
- """
224
- s = src.lower()
225
- if not (s.endswith((".ome.parquet", ".parquet", ".pq"))):
226
- return None
227
-
228
- try:
229
- import pyarrow as pa
230
- import pyarrow.parquet as pq
231
- except Exception:
232
- return None
233
-
234
- table = pq.read_table(src)
235
- ome_cols = _find_ome_parquet_columns(table)
236
- if not ome_cols or table.num_rows <= 1:
237
- return None
238
-
239
- override = os.environ.get("NAPARI_OME_ARROW_PARQUET_COLUMN")
240
- if override:
241
- matched = [c for c in ome_cols if c.lower() == override.lower()]
242
- selected = matched[0] if matched else ome_cols[0]
243
- if not matched:
244
- warnings.warn(
245
- f"Column '{override}' not found in {Path(src).name}; using {selected!r}.",
246
- stacklevel=2,
247
- )
248
- else:
249
- selected = ome_cols[0]
250
-
251
- column = table[selected]
252
- layers: list[LayerData] = []
253
-
254
- for idx in range(table.num_rows):
255
- try:
256
- record = column.slice(idx, 1).to_pylist()[0]
257
- scalar = pa.scalar(record, type=OME_ARROW_STRUCT)
258
- arr = OMEArrow(scalar).export(
259
- how="numpy", dtype=np.uint16, strict=False
260
- )
261
- except Exception as e: # pragma: no cover - warn and skip bad rows
262
- warnings.warn(
263
- f"Skipping row {idx} in column '{selected}': {e}",
264
- stacklevel=2,
265
- )
266
- continue
267
-
268
- add_kwargs: dict[str, Any] = {
269
- "name": f"{Path(src).name}[{selected}][row {idx}]"
270
- }
271
- if mode == "image":
272
- if arr.ndim >= 5:
273
- add_kwargs["channel_axis"] = 1 # TCZYX
274
- elif arr.ndim == 4:
275
- add_kwargs["channel_axis"] = 0 # CZYX
276
- layer_type = "image"
277
- else:
278
- if arr.ndim == 5:
279
- arr = arr[:, 0, ...]
280
- elif arr.ndim == 4:
281
- arr = arr[0, ...]
282
- arr = _as_labels(arr)
283
- add_kwargs.setdefault("opacity", 0.7)
284
- layer_type = "labels"
285
-
286
- _maybe_set_viewer_3d(arr)
287
- layers.append((arr, add_kwargs, layer_type))
288
-
289
- if layers:
290
- _enable_grid(len(layers))
291
- return layers or None
292
-
293
-
294
161
  # --------------------------------------------------------------------- #
295
162
  # napari entry point: napari_get_reader
296
163
  # --------------------------------------------------------------------- #
297
164
 
298
165
 
299
- def napari_get_reader(path: Union[PathLike, Sequence[PathLike]]):
300
- """
301
- Napari plugin hook: return a reader callable if this plugin can read `path`.
166
+ def napari_get_reader(
167
+ path: PathLike | Sequence[PathLike],
168
+ ) -> Callable[[PathLike | Sequence[PathLike]], list[LayerData]] | None:
169
+ """Return a reader callable for napari if the path is supported.
302
170
 
303
- This MUST return a function object (e.g. `reader_function`) or None.
171
+ Args:
172
+ path: A single path or a sequence of paths provided by napari.
173
+
174
+ Returns:
175
+ Reader callable if the path is supported, otherwise None.
304
176
  """
305
177
  # napari may pass a list/tuple or a single path
306
178
  first = str(path[0] if isinstance(path, (list, tuple)) else path).strip()
@@ -315,135 +187,147 @@ def napari_get_reader(path: Union[PathLike, Sequence[PathLike]]):
315
187
  # --------------------------------------------------------------------- #
316
188
 
317
189
 
318
- def _read_one(src: str, mode: str) -> LayerData:
319
- """
320
- Read a single source into (data, add_kwargs, layer_type),
321
- obeying `mode` = 'image' or 'labels'.
322
- """
323
- s = src.lower()
324
- p = Path(src)
325
-
326
- looks_stack = any(c in src for c in "<>*")
327
- looks_zarr = (
328
- s.endswith((".ome.zarr", ".zarr"))
329
- or ".zarr/" in s
330
- or p.exists()
331
- and p.is_dir()
332
- and p.suffix.lower() == ".zarr"
333
- )
334
- looks_parquet = s.endswith(
335
- (".ome.parquet", ".parquet", ".pq")
336
- ) or p.suffix.lower() in {
337
- ".parquet",
338
- ".pq",
339
- }
340
- looks_tiff = s.endswith(
341
- (".ome.tif", ".ome.tiff", ".tif", ".tiff")
342
- ) or p.suffix.lower() in {
343
- ".tif",
344
- ".tiff",
345
- }
346
- looks_npy = s.endswith(".npy")
347
-
348
- add_kwargs: dict[str, Any] = {"name": p.name}
349
-
350
- # ---- OME-Arrow-backed sources -----------------------------------
351
- if looks_stack or looks_zarr or looks_parquet or looks_tiff:
352
- obj = OMEArrow(src)
353
- arr = obj.export(how="numpy", dtype=np.uint16) # TCZYX
354
- info = obj.info() # may contain 'shape': (T, C, Z, Y, X)
355
-
356
- # Recover from accidental 1D flatten
357
- if getattr(arr, "ndim", 0) == 1:
358
- T, C, Z, Y, X = info.get("shape", (1, 1, 1, 0, 0))
359
- if Y and X and arr.size == Y * X:
360
- arr = arr.reshape((1, 1, 1, Y, X))
361
- else:
362
- raise ValueError(
363
- f"Flat array with unknown shape for {src}: size={arr.size}"
364
- )
365
-
366
- if mode == "image":
367
- # Image: preserve channels
368
- if arr.ndim >= 5:
369
- add_kwargs["channel_axis"] = 1 # TCZYX
370
- elif arr.ndim == 4:
371
- add_kwargs["channel_axis"] = 0 # CZYX
372
- layer_type = "image"
373
- else:
374
- # Labels: squash channels, ensure integer dtype
375
- if arr.ndim == 5: # (T, C, Z, Y, X)
376
- arr = arr[:, 0, ...]
377
- elif arr.ndim == 4: # (C, Z, Y, X)
378
- arr = arr[0, ...]
379
- arr = _as_labels(arr)
380
- add_kwargs.setdefault("opacity", 0.7)
381
- layer_type = "labels"
382
-
383
- # 🔹 Ask viewer to switch to 3D if there is a real Z-stack
384
- _maybe_set_viewer_3d(arr)
385
-
386
- return arr, add_kwargs, layer_type
387
-
388
- # ---- bare .npy fallback -----------------------------------------
389
- if looks_npy:
390
- arr = np.load(src)
391
- if arr.ndim == 1:
392
- n = int(np.sqrt(arr.size))
393
- if n * n == arr.size:
394
- arr = arr.reshape(n, n)
395
- else:
396
- raise ValueError(
397
- f".npy is 1D and not a square image: {arr.shape}"
398
- )
399
-
400
- if mode == "image":
401
- if arr.ndim == 3 and arr.shape[0] <= 6:
402
- add_kwargs["channel_axis"] = 0
403
- layer_type = "image"
404
- else:
405
- # labels
406
- if arr.ndim == 3: # treat as (C, Y, X) → first channel
407
- arr = arr[0, ...]
408
- arr = _as_labels(arr)
409
- add_kwargs.setdefault("opacity", 0.7)
410
- layer_type = "labels"
411
-
412
- # 🔹 Same 3D toggle for npy-based data
413
- _maybe_set_viewer_3d(arr)
414
-
415
- return arr, add_kwargs, layer_type
190
+ def reader_function(
191
+ path: PathLike | Sequence[PathLike],
192
+ ) -> list[LayerData]:
193
+ """Read one or more paths into napari layer data.
416
194
 
417
- raise ValueError(f"Unrecognized path for napari-ome-arrow reader: {src}")
195
+ The user may be prompted (or an env var used) to decide "image" vs
196
+ "labels".
418
197
 
198
+ Args:
199
+ path: A single path or a sequence of paths provided by napari.
419
200
 
420
- def reader_function(
421
- path: Union[PathLike, Sequence[PathLike]],
422
- ) -> list[LayerData]:
423
- """
424
- The actual reader callable napari will use.
201
+ Returns:
202
+ List of layer tuples for napari.
425
203
 
426
- It reads one or more paths, prompting the user (or using the env var)
427
- to decide 'image' vs 'labels', and returns a list of LayerData tuples.
204
+ Raises:
205
+ ValueError: If no readable inputs are found.
428
206
  """
429
207
  paths: list[str] = [
430
208
  str(p) for p in (path if isinstance(path, (list, tuple)) else [path])
431
209
  ]
432
210
  layers: list[LayerData] = []
433
211
 
434
- # Use the first path as context for the dialog label
212
+ # Offer a stack pattern prompt when multiple compatible files are present.
213
+ stack_selection = _collect_stack_files(paths)
214
+ stack_pattern = None
215
+ if stack_selection is not None:
216
+ stack_pattern = _prompt_stack_pattern(*stack_selection)
217
+
218
+ # Use the original first path as context for the dialog label
219
+ mode_hint = None
220
+ if stack_pattern is not None:
221
+ stack_default_dim = _stack_default_dim_for_pattern(stack_pattern)
222
+ mode_hint = _infer_layer_mode_from_source(
223
+ stack_pattern, stack_default_dim=stack_default_dim
224
+ )
225
+ elif paths:
226
+ mode_hint = _infer_layer_mode_from_source(paths[0])
435
227
  try:
436
- mode = _get_layer_mode(sample_path=paths[0]) # 'image' or 'labels'
228
+ mode = _get_layer_mode(
229
+ sample_path=paths[0], image_type_hint=mode_hint
230
+ ) # 'image' or 'labels'
437
231
  except RuntimeError as e:
438
232
  # If user canceled the dialog, propagate a clean error for napari
439
233
  raise ValueError(str(e)) from e
440
234
 
235
+ if stack_pattern is not None:
236
+ try:
237
+ channel_names = _channel_names_from_pattern(
238
+ stack_pattern, stack_default_dim
239
+ )
240
+ resolved_stack_scale = None
241
+ if mode == "image" and channel_names and len(channel_names) > 1:
242
+ # Split each channel into separate layers for stack patterns.
243
+ inferred_stack_scale = _infer_stack_scale_from_pattern(
244
+ stack_pattern, stack_default_dim
245
+ )
246
+ env_scale = os.environ.get("NAPARI_OME_ARROW_STACK_SCALE")
247
+ if env_scale:
248
+ try:
249
+ resolved_stack_scale = _parse_stack_scale(env_scale)
250
+ except ValueError as exc:
251
+ warnings.warn(
252
+ f"Invalid NAPARI_OME_ARROW_STACK_SCALE '{env_scale}': {exc}.",
253
+ stacklevel=2,
254
+ )
255
+ resolved_stack_scale = None
256
+ if (
257
+ resolved_stack_scale is None
258
+ and inferred_stack_scale is None
259
+ ):
260
+ resolved_stack_scale = _prompt_stack_scale(
261
+ sample_path=stack_pattern, default_scale=None
262
+ )
263
+
264
+ for label in channel_names:
265
+ channel_pattern = _replace_channel_placeholder(
266
+ stack_pattern, label, stack_default_dim
267
+ )
268
+ channel_default_dim = _stack_default_dim_for_pattern(
269
+ channel_pattern
270
+ )
271
+ try:
272
+ arr, add_kwargs, layer_type = _read_one(
273
+ channel_pattern,
274
+ mode=mode,
275
+ stack_default_dim=channel_default_dim,
276
+ stack_scale_override=resolved_stack_scale,
277
+ )
278
+ arr, add_kwargs = _strip_channel_axis(arr, add_kwargs)
279
+ except Exception as exc:
280
+ try:
281
+ arr, is_rgb = _read_rgb_stack_pattern(
282
+ channel_pattern
283
+ )
284
+ except Exception:
285
+ warnings.warn(
286
+ f"Failed to read channel '{label}' from stack "
287
+ f"pattern '{stack_pattern}': {exc}",
288
+ stacklevel=2,
289
+ )
290
+ continue
291
+ add_kwargs = {"name": Path(channel_pattern).name}
292
+ if is_rgb:
293
+ add_kwargs["rgb"] = True
294
+ layer_type = "image"
295
+
296
+ add_kwargs["name"] = (
297
+ f"{add_kwargs.get('name', label)}[{label}]"
298
+ )
299
+ if not add_kwargs.get("rgb"):
300
+ _maybe_set_viewer_3d(arr)
301
+ layers.append((arr, add_kwargs, layer_type))
302
+ else:
303
+ # Single-layer stack read.
304
+ arr, add_kwargs, layer_type = _read_one(
305
+ stack_pattern,
306
+ mode=mode,
307
+ stack_default_dim=stack_default_dim,
308
+ )
309
+ layers.append((arr, add_kwargs, layer_type))
310
+ except Exception as e:
311
+ warnings.warn(
312
+ f"Failed to read stack pattern '{stack_pattern}': {e}. "
313
+ "Loading files individually instead.",
314
+ stacklevel=2,
315
+ )
316
+ else:
317
+ return layers
318
+ elif stack_selection is not None and len(paths) == 1:
319
+ paths = [str(p) for p in stack_selection[0]]
320
+
441
321
  for src in paths:
442
322
  try:
443
323
  parquet_layers = _read_parquet_rows(src, mode)
444
324
  if parquet_layers is not None:
445
325
  layers.extend(parquet_layers)
446
326
  continue
327
+ vortex_layers = _read_vortex_rows(src, mode)
328
+ if vortex_layers is not None:
329
+ layers.extend(vortex_layers)
330
+ continue
447
331
  layers.append(_read_one(src, mode=mode))
448
332
  except Exception as e:
449
333
  warnings.warn(