napari-ome-arrow 0.0.3__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.
@@ -0,0 +1,474 @@
1
+ """OME-Arrow backed reading helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import warnings
7
+ from collections.abc import Sequence
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import numpy as np
12
+ import pyarrow as pa
13
+ from ome_arrow.core import OMEArrow
14
+ from ome_arrow.ingest import from_stack_pattern_path
15
+ from ome_arrow.meta import OME_ARROW_STRUCT
16
+
17
+ from ._reader_napari import _as_labels, _enable_grid, _maybe_set_viewer_3d
18
+ from ._reader_stack import (
19
+ _parse_stack_scale,
20
+ _prompt_stack_scale,
21
+ _scale_for_array,
22
+ _scale_from_ome_arrow,
23
+ )
24
+ from ._reader_types import LayerData
25
+
26
+
27
+ def _find_ome_parquet_columns(table: pa.Table) -> list[str]:
28
+ """Find Parquet columns matching the OME-Arrow schema.
29
+
30
+ Args:
31
+ table: Parquet table to scan.
32
+
33
+ Returns:
34
+ Column names that match the OME-Arrow struct schema.
35
+ """
36
+ import pyarrow as pa
37
+
38
+ # Match struct columns that exactly mirror the OME-Arrow schema.
39
+ expected_fields = {f.name for f in OME_ARROW_STRUCT}
40
+ names: list[str] = []
41
+ for name, col in zip(table.column_names, table.columns, strict=False):
42
+ if (
43
+ pa.types.is_struct(col.type)
44
+ and {f.name for f in col.type} == expected_fields
45
+ ):
46
+ names.append(name)
47
+ return names
48
+
49
+
50
+ def _read_vortex_scalar(src: str) -> pa.StructScalar:
51
+ """Read a single OME-Vortex row as a struct scalar.
52
+
53
+ Args:
54
+ src: OME-Vortex file path.
55
+
56
+ Returns:
57
+ Struct scalar for the selected row and column.
58
+
59
+ Raises:
60
+ ImportError: If OME-Vortex support is not available.
61
+ """
62
+ # Delegate Vortex parsing to ome-arrow, which handles the file format details.
63
+ try:
64
+ from ome_arrow.ingest import from_ome_vortex
65
+ except Exception as exc:
66
+ raise ImportError(
67
+ "OME-Vortex support requires ome-arrow with vortex support and the "
68
+ "optional 'vortex' extra (vortex-data). Install with "
69
+ "'pip install \"napari-ome-arrow[vortex]\"'."
70
+ ) from exc
71
+
72
+ # Allow column override for non-default schema names.
73
+ override = os.environ.get(
74
+ "NAPARI_OME_ARROW_VORTEX_COLUMN"
75
+ ) or os.environ.get("NAPARI_OME_ARROW_PARQUET_COLUMN")
76
+ # Use a single row from the requested/auto-detected column for OMEArrow.
77
+ return from_ome_vortex(
78
+ src,
79
+ column_name=override or "ome_arrow",
80
+ row_index=0,
81
+ strict_schema=False,
82
+ )
83
+
84
+
85
+ def _vortex_row_out_of_range(exc: Exception) -> bool:
86
+ """Check whether a Vortex row exception indicates an out-of-range index.
87
+
88
+ Args:
89
+ exc: Exception raised by the Vortex reader.
90
+
91
+ Returns:
92
+ True if the error indicates an out-of-range row, otherwise False.
93
+ """
94
+ if isinstance(exc, IndexError):
95
+ return True
96
+ msg = str(exc).lower()
97
+ return "out of range" in msg or ("row_index" in msg and "range" in msg)
98
+
99
+
100
+ def _read_vortex_rows(src: str, mode: str) -> list[LayerData] | None:
101
+ """Read multi-row OME-Vortex data as a layer grid.
102
+
103
+ Args:
104
+ src: OME-Vortex file path.
105
+ mode: "image" or "labels".
106
+
107
+ Returns:
108
+ A list of layers if multi-row data is detected, otherwise None.
109
+ """
110
+ s = src.lower()
111
+ if not (s.endswith((".ome.vortex", ".vortex"))):
112
+ return None
113
+
114
+ try:
115
+ from ome_arrow.ingest import from_ome_vortex
116
+ except Exception:
117
+ return None
118
+
119
+ # Reuse the parquet override env var for consistency.
120
+ override = os.environ.get(
121
+ "NAPARI_OME_ARROW_VORTEX_COLUMN"
122
+ ) or os.environ.get("NAPARI_OME_ARROW_PARQUET_COLUMN")
123
+ selected = override or "ome_arrow"
124
+
125
+ try:
126
+ first = from_ome_vortex(
127
+ src,
128
+ column_name=selected,
129
+ row_index=0,
130
+ strict_schema=False,
131
+ )
132
+ except Exception:
133
+ return None
134
+
135
+ try:
136
+ second = from_ome_vortex(
137
+ src,
138
+ column_name=selected,
139
+ row_index=1,
140
+ strict_schema=False,
141
+ )
142
+ except Exception:
143
+ return None
144
+
145
+ layers: list[LayerData] = []
146
+
147
+ def _append_layer(idx: int, scalar: pa.StructScalar) -> None:
148
+ # Per-row conversion with best-effort error handling.
149
+ try:
150
+ arr = OMEArrow(scalar).export(
151
+ how="numpy", dtype=np.uint16, strict=False
152
+ )
153
+ except Exception as e: # pragma: no cover - warn and skip bad rows
154
+ warnings.warn(
155
+ f"Skipping row {idx} in column '{selected}': {e}",
156
+ stacklevel=2,
157
+ )
158
+ return
159
+
160
+ # Layer metadata uses row index for unique naming.
161
+ add_kwargs: dict[str, Any] = {
162
+ "name": f"{Path(src).name}[{selected}][row {idx}]"
163
+ }
164
+ if mode == "image":
165
+ if arr.ndim >= 5:
166
+ add_kwargs["channel_axis"] = 1 # TCZYX
167
+ elif arr.ndim == 4:
168
+ add_kwargs["channel_axis"] = 0 # CZYX
169
+ layer_type = "image"
170
+ else:
171
+ if arr.ndim == 5:
172
+ arr = arr[:, 0, ...]
173
+ elif arr.ndim == 4:
174
+ arr = arr[0, ...]
175
+ arr = _as_labels(arr)
176
+ add_kwargs.setdefault("opacity", 0.7)
177
+ layer_type = "labels"
178
+
179
+ _maybe_set_viewer_3d(arr)
180
+ layers.append((arr, add_kwargs, layer_type))
181
+
182
+ _append_layer(0, first)
183
+ _append_layer(1, second)
184
+
185
+ max_rows = 10000
186
+ for idx in range(2, max_rows):
187
+ try:
188
+ scalar = from_ome_vortex(
189
+ src,
190
+ column_name=selected,
191
+ row_index=idx,
192
+ strict_schema=False,
193
+ )
194
+ except Exception as e:
195
+ if _vortex_row_out_of_range(e):
196
+ break
197
+ warnings.warn(
198
+ f"Skipping row {idx} in column '{selected}': {e}",
199
+ stacklevel=2,
200
+ )
201
+ continue
202
+ _append_layer(idx, scalar)
203
+
204
+ if layers:
205
+ _enable_grid(len(layers))
206
+ return layers or None
207
+
208
+
209
+ def _read_parquet_rows(src: str, mode: str) -> list[LayerData] | None:
210
+ """Read multi-row OME-Parquet data as a layer grid.
211
+
212
+ Args:
213
+ src: OME-Parquet file path.
214
+ mode: "image" or "labels".
215
+
216
+ Returns:
217
+ A list of layers if multi-row data is detected, otherwise None.
218
+ """
219
+ s = src.lower()
220
+ if not (s.endswith((".ome.parquet", ".parquet", ".pq"))):
221
+ return None
222
+
223
+ try:
224
+ import pyarrow as pa
225
+ import pyarrow.parquet as pq
226
+ except Exception:
227
+ return None
228
+
229
+ # Read all rows; per-row layers are assembled below.
230
+ table = pq.read_table(src)
231
+ ome_cols = _find_ome_parquet_columns(table)
232
+ if not ome_cols or table.num_rows <= 1:
233
+ return None
234
+
235
+ # Column override for multi-struct tables.
236
+ override = os.environ.get("NAPARI_OME_ARROW_PARQUET_COLUMN")
237
+ if override:
238
+ matched = [c for c in ome_cols if c.lower() == override.lower()]
239
+ selected = matched[0] if matched else ome_cols[0]
240
+ if not matched:
241
+ warnings.warn(
242
+ f"Column '{override}' not found in {Path(src).name}; using {selected!r}.",
243
+ stacklevel=2,
244
+ )
245
+ else:
246
+ selected = ome_cols[0]
247
+
248
+ column = table[selected]
249
+ layers: list[LayerData] = []
250
+
251
+ for idx in range(table.num_rows):
252
+ try:
253
+ record = column.slice(idx, 1).to_pylist()[0]
254
+ scalar = pa.scalar(record, type=OME_ARROW_STRUCT)
255
+ arr = OMEArrow(scalar).export(
256
+ how="numpy", dtype=np.uint16, strict=False
257
+ )
258
+ except Exception as e: # pragma: no cover - warn and skip bad rows
259
+ warnings.warn(
260
+ f"Skipping row {idx} in column '{selected}': {e}",
261
+ stacklevel=2,
262
+ )
263
+ continue
264
+
265
+ add_kwargs: dict[str, Any] = {
266
+ "name": f"{Path(src).name}[{selected}][row {idx}]"
267
+ }
268
+ if mode == "image":
269
+ if arr.ndim >= 5:
270
+ add_kwargs["channel_axis"] = 1 # TCZYX
271
+ elif arr.ndim == 4:
272
+ add_kwargs["channel_axis"] = 0 # CZYX
273
+ layer_type = "image"
274
+ else:
275
+ if arr.ndim == 5:
276
+ arr = arr[:, 0, ...]
277
+ elif arr.ndim == 4:
278
+ arr = arr[0, ...]
279
+ arr = _as_labels(arr)
280
+ add_kwargs.setdefault("opacity", 0.7)
281
+ layer_type = "labels"
282
+
283
+ _maybe_set_viewer_3d(arr)
284
+ layers.append((arr, add_kwargs, layer_type))
285
+
286
+ if layers:
287
+ _enable_grid(len(layers))
288
+ return layers or None
289
+
290
+
291
+ def _read_one(
292
+ src: str,
293
+ mode: str,
294
+ *,
295
+ stack_default_dim: str | None = None,
296
+ stack_scale_override: Sequence[float] | None = None,
297
+ ) -> LayerData:
298
+ """Read a single source into a napari layer tuple.
299
+
300
+ Args:
301
+ src: Source path or stack pattern.
302
+ mode: "image" or "labels".
303
+ stack_default_dim: Default dimension to use for stack patterns.
304
+ stack_scale_override: Optional scale override for stack patterns.
305
+
306
+ Returns:
307
+ Tuple of (data, add_kwargs, layer_type).
308
+
309
+ Raises:
310
+ ValueError: If the source cannot be parsed or inferred.
311
+ ImportError: If optional dependencies are required but missing.
312
+ FileNotFoundError: If referenced stack files are missing.
313
+ """
314
+ s = src.lower()
315
+ p = Path(src)
316
+
317
+ looks_stack = any(c in src for c in "<>*")
318
+ looks_zarr = (
319
+ s.endswith((".ome.zarr", ".zarr"))
320
+ or ".zarr/" in s
321
+ or p.exists()
322
+ and p.is_dir()
323
+ and p.suffix.lower() == ".zarr"
324
+ )
325
+ looks_parquet = s.endswith(
326
+ (".ome.parquet", ".parquet", ".pq")
327
+ ) or p.suffix.lower() in {
328
+ ".parquet",
329
+ ".pq",
330
+ }
331
+ looks_vortex = s.endswith(
332
+ (".ome.vortex", ".vortex")
333
+ ) or p.suffix.lower() in {
334
+ ".vortex",
335
+ }
336
+ looks_tiff = s.endswith(
337
+ (".ome.tif", ".ome.tiff", ".tif", ".tiff")
338
+ ) or p.suffix.lower() in {
339
+ ".tif",
340
+ ".tiff",
341
+ }
342
+ looks_npy = s.endswith(".npy")
343
+
344
+ add_kwargs: dict[str, Any] = {"name": p.name}
345
+
346
+ # ---- OME-Arrow-backed sources -----------------------------------
347
+ if (
348
+ looks_stack
349
+ or looks_zarr
350
+ or looks_parquet
351
+ or looks_tiff
352
+ or looks_vortex
353
+ ):
354
+ scalar = None
355
+ if looks_vortex:
356
+ # Vortex needs ome-arrow's ingest helper to produce a typed scalar.
357
+ scalar = _read_vortex_scalar(src)
358
+ if looks_stack and stack_default_dim is not None:
359
+ scalar = from_stack_pattern_path(
360
+ src,
361
+ default_dim_for_unspecified=stack_default_dim,
362
+ map_series_to="T",
363
+ clamp_to_uint16=True,
364
+ )
365
+ obj = OMEArrow(scalar if scalar is not None else src)
366
+ arr = obj.export(how="numpy", dtype=np.uint16) # TCZYX
367
+ info = obj.info() # may contain 'shape': (T, C, Z, Y, X)
368
+ scale_override: tuple[float, ...] | None = None
369
+ inferred_scale = _scale_from_ome_arrow(obj)
370
+ if looks_stack:
371
+ # Stack scales are optional and can be provided via env or prompt.
372
+ if stack_scale_override is not None:
373
+ scale_override = tuple(stack_scale_override)
374
+ else:
375
+ env_scale = os.environ.get("NAPARI_OME_ARROW_STACK_SCALE")
376
+ if env_scale:
377
+ try:
378
+ scale_override = _parse_stack_scale(env_scale)
379
+ except ValueError as exc:
380
+ warnings.warn(
381
+ f"Invalid NAPARI_OME_ARROW_STACK_SCALE '{env_scale}': {exc}.",
382
+ stacklevel=2,
383
+ )
384
+ scale_override = None
385
+
386
+ if scale_override is None and inferred_scale is None:
387
+ scale_override = _prompt_stack_scale(
388
+ sample_path=src, default_scale=None
389
+ )
390
+
391
+ # Recover from accidental 1D flatten by using metadata shape.
392
+ if getattr(arr, "ndim", 0) == 1:
393
+ T, C, Z, Y, X = info.get("shape", (1, 1, 1, 0, 0))
394
+ if Y and X and arr.size == Y * X:
395
+ arr = arr.reshape((1, 1, 1, Y, X))
396
+ else:
397
+ raise ValueError(
398
+ f"Flat array with unknown shape for {src}: size={arr.size}"
399
+ )
400
+
401
+ if mode == "image":
402
+ # Image: preserve channels
403
+ if arr.ndim >= 5:
404
+ add_kwargs["channel_axis"] = 1 # TCZYX
405
+ elif arr.ndim == 4:
406
+ add_kwargs["channel_axis"] = 0 # CZYX
407
+ layer_type = "image"
408
+ else:
409
+ # Labels: squash channels, ensure integer dtype
410
+ if arr.ndim == 5: # (T, C, Z, Y, X)
411
+ arr = arr[:, 0, ...]
412
+ elif arr.ndim == 4: # (C, Z, Y, X)
413
+ arr = arr[0, ...]
414
+ arr = _as_labels(arr)
415
+ add_kwargs.setdefault("opacity", 0.7)
416
+ layer_type = "labels"
417
+
418
+ # 🔹 Ask viewer to switch to 3D if there is a real Z-stack
419
+ _maybe_set_viewer_3d(arr)
420
+
421
+ if looks_stack:
422
+ # Stack scale can be inferred or user-provided.
423
+ scale_override = (
424
+ scale_override
425
+ if scale_override is not None
426
+ else inferred_scale
427
+ )
428
+ scale = (
429
+ _scale_for_array(arr, mode, add_kwargs, scale_override)
430
+ if scale_override is not None
431
+ else None
432
+ )
433
+ else:
434
+ scale = (
435
+ _scale_for_array(arr, mode, add_kwargs, inferred_scale)
436
+ if inferred_scale is not None
437
+ else None
438
+ )
439
+ if scale is not None:
440
+ add_kwargs["scale"] = scale
441
+
442
+ return arr, add_kwargs, layer_type
443
+
444
+ # ---- bare .npy fallback -----------------------------------------
445
+ if looks_npy:
446
+ # Minimal fallback for plain numpy arrays.
447
+ arr = np.load(src)
448
+ if arr.ndim == 1:
449
+ n = int(np.sqrt(arr.size))
450
+ if n * n == arr.size:
451
+ arr = arr.reshape(n, n)
452
+ else:
453
+ raise ValueError(
454
+ f".npy is 1D and not a square image: {arr.shape}"
455
+ )
456
+
457
+ if mode == "image":
458
+ if arr.ndim == 3 and arr.shape[0] <= 6:
459
+ add_kwargs["channel_axis"] = 0
460
+ layer_type = "image"
461
+ else:
462
+ # labels
463
+ if arr.ndim == 3: # treat as (C, Y, X) → first channel
464
+ arr = arr[0, ...]
465
+ arr = _as_labels(arr)
466
+ add_kwargs.setdefault("opacity", 0.7)
467
+ layer_type = "labels"
468
+
469
+ # 🔹 Same 3D toggle for npy-based data
470
+ _maybe_set_viewer_3d(arr)
471
+
472
+ return arr, add_kwargs, layer_type
473
+
474
+ raise ValueError(f"Unrecognized path for napari-ome-arrow reader: {src}")