arrayview 0.31.2__py3-none-any.whl → 0.32.0__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.
arrayview/__init__.py CHANGED
@@ -7,14 +7,12 @@ __all__ = [
7
7
  "arrayview",
8
8
  "view",
9
9
  "view_batch",
10
- "view_dir",
11
- "view_dir_patterns",
12
10
  "zarr_chunk_preset",
13
11
  ]
14
12
 
15
13
 
16
14
  def __getattr__(name: str):
17
- if name in {"arrayview", "view", "view_dir", "view_dir_patterns", "ViewHandle"}:
15
+ if name in {"arrayview", "view", "ViewHandle"}:
18
16
  from arrayview import _launcher
19
17
 
20
18
  return getattr(_launcher, name)
arrayview/_io.py CHANGED
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import fnmatch
6
+ import gzip
6
7
  import glob
7
8
  import os
8
9
  import re
@@ -18,6 +19,19 @@ import numpy as np
18
19
  _nib_mod = None
19
20
 
20
21
 
22
+ class MissingOverlayCasesError(ValueError):
23
+ """A required collection overlay has no file for some image cases."""
24
+
25
+ def __init__(self, overlay_name, missing_cases, total_cases):
26
+ self.overlay_name = str(overlay_name)
27
+ self.missing_cases = tuple(str(case) for case in missing_cases)
28
+ self.total_cases = int(total_cases)
29
+ missing = ", ".join(repr(case) for case in self.missing_cases)
30
+ super().__init__(
31
+ f"Overlay {self.overlay_name!r} is missing case(s): {missing}."
32
+ )
33
+
34
+
21
35
  def _nib():
22
36
  """Lazy nibabel import."""
23
37
  global _nib_mod
@@ -170,6 +184,194 @@ def _nifti_display_array(proxy):
170
184
  return np.asarray(proxy, dtype=display_dtype)
171
185
 
172
186
 
187
+ class _ProgressiveLoadCancelled(Exception):
188
+ """Internal signal used when newer navigation supersedes a cold load."""
189
+
190
+
191
+ def _read_exact(stream, size):
192
+ """Read exactly *size* bytes, or raise when a gzip stream ends early."""
193
+ chunks = []
194
+ remaining = size
195
+ while remaining:
196
+ chunk = stream.read(remaining)
197
+ if not chunk:
198
+ raise EOFError(f"NIfTI voxel data ended {remaining} bytes early.")
199
+ chunks.append(chunk)
200
+ remaining -= len(chunk)
201
+ return b"".join(chunks)
202
+
203
+
204
+ def _progressive_nifti_spec(filepath):
205
+ """Describe a gzip volume that can be decoded one canonical Z plane at a time."""
206
+ if not filepath.endswith(".nii.gz"):
207
+ return None
208
+
209
+ nib = _nib()
210
+ img = nib.load(filepath)
211
+ proxy = img.dataobj
212
+ if len(img.shape) != 3 or getattr(proxy, "order", None) != "F":
213
+ return None
214
+
215
+ source_ornt = nib.orientations.io_orientation(img.affine)
216
+ canonical_ornt = nib.orientations.axcodes2ornt(("R", "A", "S"))
217
+ transform = nib.orientations.ornt_transform(source_ornt, canonical_ornt)
218
+ # A source Z plane must remain a canonical Z plane. X/Y may be swapped or
219
+ # flipped inside the plane without requiring any later voxel data.
220
+ if int(transform[2, 0]) != 2:
221
+ return None
222
+
223
+ raw_dtype = np.dtype(proxy.dtype)
224
+ if raw_dtype.kind not in "biufc" or raw_dtype.itemsize <= 0:
225
+ return None
226
+ display_dtype = (
227
+ np.dtype(np.float32)
228
+ if np.issubdtype(raw_dtype, np.floating) and raw_dtype.itemsize > 4
229
+ else raw_dtype
230
+ )
231
+ canonical_shape = tuple(int(img.shape[int(axis)]) for axis in transform[:, 0])
232
+ return {
233
+ "source_shape": tuple(int(size) for size in img.shape),
234
+ "canonical_shape": canonical_shape,
235
+ "transform": transform,
236
+ "raw_dtype": raw_dtype,
237
+ "display_dtype": display_dtype,
238
+ "offset": int(proxy.offset),
239
+ "slope": proxy.slope,
240
+ "inter": proxy.inter,
241
+ }
242
+
243
+
244
+ class _ProgressiveNiftiJob:
245
+ """Decode one gzip stream once, releasing requested planes as they arrive."""
246
+
247
+ def __init__(self, filepath, spec, on_complete, on_finished):
248
+ self.filepath = filepath
249
+ self.spec = spec
250
+ self._on_complete = on_complete
251
+ self._on_finished = on_finished
252
+ self._condition = threading.Condition()
253
+ self._decoded = np.zeros(spec["canonical_shape"][2], dtype=bool)
254
+ # Allocate the large destination only once a worker actually starts.
255
+ # Superseded jobs can otherwise reserve hundreds of MiB while queued.
256
+ self._volume = None
257
+ self._done = False
258
+ self._cancelled = False
259
+ self._error = None
260
+ self.future = None
261
+
262
+ def cancel(self):
263
+ with self._condition:
264
+ self._cancelled = True
265
+ self._condition.notify_all()
266
+ if self.future is not None and self.future.cancel():
267
+ self._finish(cancelled=True)
268
+
269
+ def plane(self, z_index):
270
+ with self._condition:
271
+ while not self._decoded[z_index] and not self._done:
272
+ self._condition.wait()
273
+ if self._decoded[z_index]:
274
+ return self._volume[:, :, z_index]
275
+ if self._cancelled:
276
+ raise _ProgressiveLoadCancelled()
277
+ if self._error is not None:
278
+ raise self._error
279
+ raise RuntimeError("Progressive NIfTI loading stopped before the requested plane.")
280
+
281
+ def volume(self):
282
+ with self._condition:
283
+ while not self._done:
284
+ self._condition.wait()
285
+ if self._cancelled:
286
+ raise _ProgressiveLoadCancelled()
287
+ if self._error is not None:
288
+ raise self._error
289
+ return self._volume
290
+
291
+ def _finish(self, *, error=None, cancelled=False):
292
+ should_notify = False
293
+ with self._condition:
294
+ if self._done:
295
+ return
296
+ self._error = error
297
+ self._cancelled = self._cancelled or cancelled
298
+ self._done = True
299
+ self._condition.notify_all()
300
+ should_notify = True
301
+ if should_notify:
302
+ self._on_finished(self)
303
+
304
+ def run(self):
305
+ spec = self.spec
306
+ source_shape = spec["source_shape"]
307
+ plane_bytes = source_shape[0] * source_shape[1] * spec["raw_dtype"].itemsize
308
+ z_reversed = int(spec["transform"][2, 1]) < 0
309
+ try:
310
+ from nibabel.volumeutils import apply_read_scaling
311
+
312
+ self._volume = np.empty(
313
+ spec["canonical_shape"], dtype=spec["display_dtype"]
314
+ )
315
+ with gzip.open(self.filepath, "rb") as stream:
316
+ stream.seek(spec["offset"])
317
+ for source_z in range(source_shape[2]):
318
+ raw = _read_exact(stream, plane_bytes)
319
+ plane = np.ndarray(
320
+ source_shape[:2],
321
+ dtype=spec["raw_dtype"],
322
+ buffer=raw,
323
+ order="F",
324
+ )
325
+ plane = apply_read_scaling(plane, spec["slope"], spec["inter"])
326
+ plane = np.asarray(plane, dtype=spec["display_dtype"])
327
+ plane = _nib().orientations.apply_orientation(
328
+ plane[:, :, None], spec["transform"]
329
+ )[:, :, 0]
330
+ canonical_z = source_shape[2] - 1 - source_z if z_reversed else source_z
331
+ with self._condition:
332
+ self._volume[:, :, canonical_z] = plane
333
+ self._decoded[canonical_z] = True
334
+ self._condition.notify_all()
335
+ cancelled = self._cancelled
336
+ if cancelled:
337
+ self._finish(cancelled=True)
338
+ return
339
+ with self._condition:
340
+ cancelled = self._cancelled
341
+ if cancelled:
342
+ self._finish(cancelled=True)
343
+ return
344
+ self._on_complete(self._volume)
345
+ self._finish()
346
+ except Exception as exc:
347
+ self._finish(error=exc)
348
+
349
+
350
+ _PROGRESSIVE_NIFTI_POOL = None
351
+ _PROGRESSIVE_NIFTI_OVERLAY_POOL = None
352
+ _PROGRESSIVE_NIFTI_POOL_LOCK = threading.Lock()
353
+
354
+
355
+ def _get_progressive_nifti_pool(*, overlay_prefetch=False):
356
+ """Return a bounded gzip pool without mixing base and overlay prefetch."""
357
+ global _PROGRESSIVE_NIFTI_POOL, _PROGRESSIVE_NIFTI_OVERLAY_POOL
358
+ with _PROGRESSIVE_NIFTI_POOL_LOCK:
359
+ if overlay_prefetch:
360
+ if (
361
+ _PROGRESSIVE_NIFTI_OVERLAY_POOL is None
362
+ or _PROGRESSIVE_NIFTI_OVERLAY_POOL._shutdown
363
+ ):
364
+ _PROGRESSIVE_NIFTI_OVERLAY_POOL = ThreadPoolExecutor(
365
+ max_workers=2, thread_name_prefix="arrayview-nifti-overlay"
366
+ )
367
+ return _PROGRESSIVE_NIFTI_OVERLAY_POOL
368
+ if _PROGRESSIVE_NIFTI_POOL is None or _PROGRESSIVE_NIFTI_POOL._shutdown:
369
+ _PROGRESSIVE_NIFTI_POOL = ThreadPoolExecutor(
370
+ max_workers=2, thread_name_prefix="arrayview-nifti"
371
+ )
372
+ return _PROGRESSIVE_NIFTI_POOL
373
+
374
+
173
375
  def _load_nifti_with_meta(filepath):
174
376
  """Load a NIfTI file, canonical-reorient, return (array, meta).
175
377
 
@@ -198,7 +400,74 @@ def _load_nifti_with_meta(filepath):
198
400
  # ---------------------------------------------------------------------------
199
401
 
200
402
 
201
- class _NiftiSeries:
403
+ class _ProgressiveNiftiSeriesMixin:
404
+ """Progressive gzip support shared by dense and ragged NIfTI stacks."""
405
+
406
+ def _init_progressive_loading(self):
407
+ self._progressive_jobs = {}
408
+ self._progressive_lock = threading.RLock()
409
+
410
+ def _cache_volume(self, cache_key, vol):
411
+ with self._cache_lock:
412
+ old = self._vol_cache.get(cache_key)
413
+ if old is not None:
414
+ self._vol_cache_bytes -= int(getattr(old, "nbytes", 0))
415
+ self._vol_cache[cache_key] = vol
416
+ self._vol_cache.move_to_end(cache_key)
417
+ self._vol_cache_bytes += int(getattr(vol, "nbytes", 0))
418
+ while self._vol_cache_bytes > self._vol_cache_max_bytes and len(self._vol_cache) > 1:
419
+ _key, evicted = self._vol_cache.popitem(last=False)
420
+ self._vol_cache_bytes -= int(getattr(evicted, "nbytes", 0))
421
+
422
+ def _get_progressive_plane(self, p_idx, m_idx, z_index):
423
+ cache_key = (p_idx, m_idx)
424
+ with self._cache_lock:
425
+ if cache_key in self._vol_cache:
426
+ self._vol_cache.move_to_end(cache_key)
427
+ return self._vol_cache[cache_key][:, :, z_index]
428
+
429
+ filepath = self._file_matrix[p_idx][m_idx]
430
+ if filepath is None:
431
+ return None
432
+ with self._progressive_lock:
433
+ job = self._progressive_jobs.get(cache_key)
434
+ if job is None:
435
+ spec = _progressive_nifti_spec(filepath)
436
+ if spec is None:
437
+ return None
438
+ # Keep the visible patient and one neighbor requested by the
439
+ # existing collection prefetch. A third cold request cancels
440
+ # the oldest unfinished decode instead of growing a queue.
441
+ while len(self._progressive_jobs) >= 2:
442
+ old_key, old_job = next(iter(self._progressive_jobs.items()))
443
+ old_job.cancel()
444
+ self._progressive_jobs.pop(old_key, None)
445
+
446
+ def _complete(volume):
447
+ self._cache_volume(cache_key, volume)
448
+
449
+ def _finished(finished_job):
450
+ with self._progressive_lock:
451
+ if self._progressive_jobs.get(cache_key) is finished_job:
452
+ self._progressive_jobs.pop(cache_key, None)
453
+
454
+ job = _ProgressiveNiftiJob(filepath, spec, _complete, _finished)
455
+ self._progressive_jobs[cache_key] = job
456
+ overlay_prefetch = threading.current_thread().name.startswith(
457
+ "arrayview-overlay-prefetch"
458
+ )
459
+ job.future = _get_progressive_nifti_pool(
460
+ overlay_prefetch=overlay_prefetch
461
+ ).submit(job.run)
462
+ try:
463
+ return job.plane(z_index)
464
+ except _ProgressiveLoadCancelled:
465
+ raise
466
+ except Exception:
467
+ return None
468
+
469
+
470
+ class _NiftiSeries(_ProgressiveNiftiSeriesMixin):
202
471
  """Lazy 4D/5D view over a directory of same-shape NIfTI files.
203
472
 
204
473
  ``file_matrix`` is a P x M list-of-lists of filepaths (P patients,
@@ -232,6 +501,7 @@ class _NiftiSeries:
232
501
  )
233
502
  self._cache_lock = threading.Lock()
234
503
  self._load_locks = {}
504
+ self._init_progressive_loading()
235
505
 
236
506
  @property
237
507
  def dtype(self):
@@ -249,6 +519,13 @@ class _NiftiSeries:
249
519
  if cache_key in self._vol_cache:
250
520
  self._vol_cache.move_to_end(cache_key)
251
521
  return self._vol_cache[cache_key]
522
+ with self._progressive_lock:
523
+ job = self._progressive_jobs.get(cache_key)
524
+ if job is not None:
525
+ try:
526
+ return job.volume()
527
+ except Exception:
528
+ pass
252
529
  filepath = self._file_matrix[p_idx][m_idx]
253
530
  if filepath is None:
254
531
  return np.zeros(self._vol_shape, dtype=self._dtype)
@@ -259,13 +536,7 @@ class _NiftiSeries:
259
536
  # only the requested slice. Gzip files must be decompressed once and
260
537
  # are therefore materialized into the byte-bounded cache.
261
538
  vol = canon.dataobj if filepath.endswith(".nii") else _nifti_display_array(canon.dataobj)
262
- with self._cache_lock:
263
- self._vol_cache[cache_key] = vol
264
- self._vol_cache.move_to_end(cache_key)
265
- self._vol_cache_bytes += int(getattr(vol, "nbytes", 0))
266
- while self._vol_cache_bytes > self._vol_cache_max_bytes and len(self._vol_cache) > 1:
267
- _key, evicted = self._vol_cache.popitem(last=False)
268
- self._vol_cache_bytes -= int(getattr(evicted, "nbytes", 0))
539
+ self._cache_volume(cache_key, vol)
269
540
  return vol
270
541
 
271
542
  def __getitem__(self, key):
@@ -291,6 +562,24 @@ class _NiftiSeries:
291
562
  m_idx = int(stack_vals[1])
292
563
  if m_idx < 0:
293
564
  m_idx += n_m
565
+ if (
566
+ len(self._vol_shape) == 3
567
+ and all(
568
+ isinstance(item, slice)
569
+ and item.start is None
570
+ and item.stop is None
571
+ and item.step is None
572
+ for item in spatial_key[:2]
573
+ )
574
+ and isinstance(spatial_key[2], (int, np.integer))
575
+ ):
576
+ z_index = int(spatial_key[2])
577
+ if z_index < 0:
578
+ z_index += self._vol_shape[2]
579
+ if 0 <= z_index < self._vol_shape[2]:
580
+ plane = self._get_progressive_plane(p_idx, m_idx, z_index)
581
+ if plane is not None:
582
+ return plane
294
583
  vol = self._get_volume(p_idx, m_idx)
295
584
  return vol[spatial_key]
296
585
  return np.asarray(self)[key]
@@ -400,7 +689,7 @@ class _FileSeries:
400
689
  return result
401
690
 
402
691
 
403
- class _RaggedFileSeries:
692
+ class _RaggedFileSeries(_ProgressiveNiftiSeriesMixin):
404
693
  """Lazy collection view over same-rank, same-dtype files with varying shapes.
405
694
 
406
695
  The public ``shape`` is a max-spatial-shape facade so existing viewer
@@ -445,6 +734,7 @@ class _RaggedFileSeries:
445
734
  )
446
735
  self._cache_lock = threading.Lock()
447
736
  self._load_locks = {}
737
+ self._init_progressive_loading()
448
738
 
449
739
  @property
450
740
  def dtype(self):
@@ -497,6 +787,13 @@ class _RaggedFileSeries:
497
787
  if cache_key in self._vol_cache:
498
788
  self._vol_cache.move_to_end(cache_key)
499
789
  return self._vol_cache[cache_key]
790
+ with self._progressive_lock:
791
+ job = self._progressive_jobs.get(cache_key)
792
+ if job is not None:
793
+ try:
794
+ return job.volume()
795
+ except Exception:
796
+ pass
500
797
  filepath = self._file_matrix[p_idx][m_idx]
501
798
  if filepath is None:
502
799
  return np.zeros(self._spatial_shapes[p_idx][m_idx], dtype=self._dtype)
@@ -507,13 +804,7 @@ class _RaggedFileSeries:
507
804
  vol = proxy if filepath.endswith(".nii") else _nifti_display_array(proxy)
508
805
  else:
509
806
  vol = load_data(filepath)
510
- with self._cache_lock:
511
- self._vol_cache[cache_key] = vol
512
- self._vol_cache.move_to_end(cache_key)
513
- self._vol_cache_bytes += int(getattr(vol, "nbytes", 0))
514
- while self._vol_cache_bytes > self._vol_cache_max_bytes and len(self._vol_cache) > 1:
515
- _key, evicted = self._vol_cache.popitem(last=False)
516
- self._vol_cache_bytes -= int(getattr(evicted, "nbytes", 0))
807
+ self._cache_volume(cache_key, vol)
517
808
  return vol
518
809
 
519
810
  def __getitem__(self, key):
@@ -540,6 +831,23 @@ class _RaggedFileSeries:
540
831
  spatial_key.append(idx)
541
832
  else:
542
833
  spatial_key.append(val)
834
+ if (
835
+ self._all_nifti
836
+ and len(spatial_shape) == 3
837
+ and all(
838
+ isinstance(item, slice)
839
+ and item.start is None
840
+ and item.stop is None
841
+ and item.step is None
842
+ for item in spatial_key[:2]
843
+ )
844
+ and isinstance(spatial_key[2], (int, np.integer))
845
+ ):
846
+ z_index = int(spatial_key[2])
847
+ if 0 <= z_index < spatial_shape[2]:
848
+ plane = self._get_progressive_plane(p_idx, m_idx, z_index)
849
+ if plane is not None:
850
+ return plane
543
851
  return self._get_volume(p_idx, m_idx)[tuple(spatial_key)]
544
852
  raise TypeError("Ragged collections require concrete collection indices.")
545
853
 
@@ -808,6 +1116,7 @@ def load_dir_collection(
808
1116
  load="lazy",
809
1117
  stack="auto",
810
1118
  scan_progress=None,
1119
+ exclude_cases=None,
811
1120
  ):
812
1121
  """Load recursive collection patterns as aligned lazy image/overlay stacks.
813
1122
 
@@ -816,6 +1125,9 @@ def load_dir_collection(
816
1125
  sparse overlay whose missing cases should render as empty masks. By
817
1126
  default, each pattern's sorted matches are paired by position. With
818
1127
  *case_regex*, files are paired by the regex's named ``case`` group instead.
1128
+ *exclude_cases* removes known case ids before any image or overlay data is
1129
+ opened. It is used by the interactive CLI after the user accepts a partial
1130
+ overlay match.
819
1131
  """
820
1132
  if not base_patterns:
821
1133
  raise ValueError("--stack requires at least one positional image pattern.")
@@ -865,6 +1177,19 @@ def load_dir_collection(
865
1177
  for path in base_lists[0]
866
1178
  ]
867
1179
  base_matrix = [list(row) for row in zip(*base_lists)]
1180
+
1181
+ excluded = {str(case) for case in (exclude_cases or ())}
1182
+ if excluded:
1183
+ kept = [
1184
+ (case, row)
1185
+ for case, row in zip(case_ids, base_matrix)
1186
+ if case not in excluded
1187
+ ]
1188
+ if not kept:
1189
+ raise ValueError("No image cases remain after excluding missing overlays.")
1190
+ case_ids = [case for case, _row in kept]
1191
+ base_matrix = [row for _case, row in kept]
1192
+
868
1193
  data, spatial_meta = _series_from_file_matrix(
869
1194
  base_matrix,
870
1195
  load=load,
@@ -884,9 +1209,8 @@ def load_dir_collection(
884
1209
  by_case = pattern
885
1210
  missing_cases = [case for case in case_ids if case not in by_case]
886
1211
  if missing_cases and not allow_missing:
887
- raise ValueError(
888
- f"Overlay {name!r} is missing case(s): "
889
- f"{', '.join(repr(c) for c in missing_cases)}."
1212
+ raise MissingOverlayCasesError(
1213
+ name, missing_cases, total_cases=len(case_ids)
890
1214
  )
891
1215
  matrix = [[by_case.get(case)] for case in case_ids]
892
1216
  extras = sorted(set(by_case.keys()) - set(case_ids))
@@ -894,9 +1218,8 @@ def load_dir_collection(
894
1218
  by_case = _collection_pattern_map(pattern, case_regex=case_regex)
895
1219
  missing_cases = [case for case in case_ids if case not in by_case]
896
1220
  if missing_cases and not allow_missing:
897
- raise ValueError(
898
- f"Overlay {name!r} is missing case(s): "
899
- f"{', '.join(repr(c) for c in missing_cases)}."
1221
+ raise MissingOverlayCasesError(
1222
+ name, missing_cases, total_cases=len(case_ids)
900
1223
  )
901
1224
  matrix = [[by_case.get(case)] for case in case_ids]
902
1225
  extras = sorted(set(by_case.keys()) - set(case_ids))
@@ -912,9 +1235,8 @@ def load_dir_collection(
912
1235
  )
913
1236
  missing_cases = [case for case in case_ids if case not in by_case]
914
1237
  if missing_cases and not allow_missing:
915
- raise ValueError(
916
- f"Overlay {name!r} is missing case(s): "
917
- f"{', '.join(repr(c) for c in missing_cases)}."
1238
+ raise MissingOverlayCasesError(
1239
+ name, missing_cases, total_cases=len(case_ids)
918
1240
  )
919
1241
  matrix = [[by_case.get(case)] for case in case_ids]
920
1242
  extras = sorted(set(by_case) - set(case_ids))
@@ -968,13 +1290,12 @@ def load_dir_collection(
968
1290
  return data, spatial_meta, overlay_items, summary
969
1291
 
970
1292
 
971
- def _load_file_series(path, select=None, *, load="lazy", stack="auto"):
1293
+ def _load_file_series(path, *, load="lazy", stack="auto"):
972
1294
  """Build a lazy series from a directory of supported array files.
973
1295
 
974
1296
  Walks *path* recursively, groups files of any supported format by
975
- immediate parent folder (= patient). With *select* (a list of fnmatch
976
- patterns), picks one file per pattern per patient → 5D ``(*vol, P, M)``.
977
- Without *select*, requires exactly one file per patient → 4D ``(*vol, P)``.
1297
+ immediate parent folder (= patient). Requires exactly one file per
1298
+ patient → 4D ``(*vol, P)``.
978
1299
 
979
1300
  If every file is NIfTI (.nii/.nii.gz), delegates to
980
1301
  ``_load_nifti_series`` for canonical reorientation. Otherwise builds
@@ -1008,60 +1329,32 @@ def _load_file_series(path, select=None, *, load="lazy", stack="auto"):
1008
1329
 
1009
1330
  # NIfTI-only → preserve canonical reorientation + lazy dataobj slicing
1010
1331
  if all_nifti:
1011
- return _load_nifti_series(path, select=select, load=load, stack=stack)
1332
+ return _load_nifti_series(path, load=load, stack=stack)
1012
1333
 
1013
1334
  # ── build file matrix ──────────────────────────────────────────
1014
- select_patterns = select or []
1015
1335
  patient_dirs = sorted(patients.keys())
1016
1336
  file_matrix: list[list[str]] = []
1017
1337
 
1018
- if select_patterns:
1019
- for pdir in patient_dirs:
1020
- selected: list[str] = []
1021
- for pattern in select_patterns:
1022
- matches = [
1023
- f
1024
- for f in patients[pdir]
1025
- if fnmatch.fnmatch(os.path.basename(f), pattern)
1026
- ]
1027
- if not matches:
1028
- raise ValueError(
1029
- f"Folder {pdir!r}: no file matches --select "
1030
- f"pattern {pattern!r}. Available: "
1031
- f"{[os.path.basename(f) for f in patients[pdir]]}"
1032
- )
1033
- if len(matches) > 1:
1034
- raise ValueError(
1035
- f"Folder {pdir!r}: multiple files match "
1036
- f"--select pattern {pattern!r}: "
1037
- f"{[os.path.basename(f) for f in matches]}. "
1038
- "Make patterns more specific."
1039
- )
1040
- selected.append(matches[0])
1041
- file_matrix.append(selected)
1042
- else:
1043
- for pdir in patient_dirs:
1044
- files = patients[pdir]
1045
- if len(files) == 1:
1046
- file_matrix.append([files[0]])
1047
- else:
1048
- raise ValueError(
1049
- f"Folder {pdir!r} contains {len(files)} files: "
1050
- f"{[os.path.basename(f) for f in files]}. "
1051
- "Use --select PATTERN to pick one (or more) per folder. "
1052
- "Example: --select '*t1*' --select '*t2*' --select '*flair*'"
1053
- )
1338
+ for pdir in patient_dirs:
1339
+ files = patients[pdir]
1340
+ if len(files) == 1:
1341
+ file_matrix.append([files[0]])
1342
+ else:
1343
+ raise ValueError(
1344
+ f"Folder {pdir!r} contains {len(files)} files: "
1345
+ f"{[os.path.basename(f) for f in files]}. "
1346
+ "Use --stack with explicit file patterns for multi-file folders."
1347
+ )
1054
1348
 
1055
1349
  return _series_from_file_matrix(file_matrix, load=load, stack=stack)
1056
1350
 
1057
1351
 
1058
- def _load_nifti_series(path, select=None, *, load="lazy", stack="auto"):
1352
+ def _load_nifti_series(path, *, load="lazy", stack="auto"):
1059
1353
  """Build a lazy ``_NiftiSeries`` from a directory of NIfTI files.
1060
1354
 
1061
1355
  Walks *path* recursively, groups ``.nii``/``.nii.gz`` by immediate parent
1062
- folder (= patient). With *select* (a list of fnmatch patterns), picks one
1063
- file per pattern per patient → 5D ``(*vol, P, M)``. Without *select*,
1064
- requires exactly one NIfTI per patient → 4D ``(*vol, P)``.
1356
+ folder (= patient). Requires exactly one NIfTI per patient → 4D
1357
+ ``(*vol, P)``.
1065
1358
 
1066
1359
  Returns ``(series, spatial_meta)``.
1067
1360
  """
@@ -1087,51 +1380,24 @@ def _load_nifti_series(path, select=None, *, load="lazy", stack="auto"):
1087
1380
  "first (e.g. `dcm2niix -o <out> <dicom_dir>`)."
1088
1381
  )
1089
1382
 
1090
- select_patterns = select or []
1091
1383
  patient_dirs = sorted(patients.keys())
1092
1384
  file_matrix: list[list[str]] = []
1093
1385
 
1094
- if select_patterns:
1095
- for pdir in patient_dirs:
1096
- selected: list[str] = []
1097
- for pattern in select_patterns:
1098
- matches = [
1099
- f
1100
- for f in patients[pdir]
1101
- if fnmatch.fnmatch(os.path.basename(f), pattern)
1102
- ]
1103
- if not matches:
1104
- raise ValueError(
1105
- f"Patient folder {pdir!r}: no file matches --select "
1106
- f"pattern {pattern!r}. Available: "
1107
- f"{[os.path.basename(f) for f in patients[pdir]]}"
1108
- )
1109
- if len(matches) > 1:
1110
- raise ValueError(
1111
- f"Patient folder {pdir!r}: multiple files match "
1112
- f"--select pattern {pattern!r}: "
1113
- f"{[os.path.basename(f) for f in matches]}. "
1114
- "Make patterns more specific."
1115
- )
1116
- selected.append(matches[0])
1117
- file_matrix.append(selected)
1118
- else:
1119
- for pdir in patient_dirs:
1120
- nii_files = patients[pdir]
1121
- if len(nii_files) == 1:
1122
- file_matrix.append([nii_files[0]])
1123
- else:
1124
- raise ValueError(
1125
- f"Patient folder {pdir!r} contains {len(nii_files)} NIfTI "
1126
- f"files: {[os.path.basename(f) for f in nii_files]}. "
1127
- "Use --select PATTERN to pick one (or more) per patient. "
1128
- "Example: --select '*t1*' --select '*t2*' --select '*flair*'"
1129
- )
1386
+ for pdir in patient_dirs:
1387
+ nii_files = patients[pdir]
1388
+ if len(nii_files) == 1:
1389
+ file_matrix.append([nii_files[0]])
1390
+ else:
1391
+ raise ValueError(
1392
+ f"Patient folder {pdir!r} contains {len(nii_files)} NIfTI "
1393
+ f"files: {[os.path.basename(f) for f in nii_files]}. "
1394
+ "Use --stack with explicit file patterns for multi-file folders."
1395
+ )
1130
1396
 
1131
1397
  return _series_from_file_matrix(file_matrix, load=load, stack=stack)
1132
1398
 
1133
1399
 
1134
- def load_data_with_meta(filepath, key=None, select=None, *, load="lazy", stack="auto"):
1400
+ def load_data_with_meta(filepath, key=None, *, load="lazy", stack="auto"):
1135
1401
  """Like load_data but also returns spatial metadata for NIfTI files.
1136
1402
 
1137
1403
  Returns (array, meta_or_None). meta is None for non-NIfTI formats.
@@ -1139,7 +1405,7 @@ def load_data_with_meta(filepath, key=None, select=None, *, load="lazy", stack="
1139
1405
  ``_load_file_series``).
1140
1406
  """
1141
1407
  if os.path.isdir(filepath):
1142
- return _load_file_series(filepath, select=select, load=load, stack=stack)
1408
+ return _load_file_series(filepath, load=load, stack=stack)
1143
1409
  if filepath.endswith(".nii") or filepath.endswith(".nii.gz"):
1144
1410
  return _load_nifti_with_meta(filepath)
1145
1411
  return load_data(filepath, key=key), None