arrayview 0.43.4__py3-none-any.whl → 0.45.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/_analysis.py CHANGED
@@ -42,6 +42,11 @@ def _build_metadata(session) -> dict:
42
42
  # Collection axes are appended after the image dimensions. They
43
43
  # select a case; they must not influence the startup plane.
44
44
  default_shape = target_shape[:spatial_ndim]
45
+ # NIfTI metadata identifies the first three axes as anatomical.
46
+ # Later axes may be echoes, parameters, or time points, so they
47
+ # must not change which anatomical plane opens first.
48
+ if getattr(session, "spatial_meta", None) is not None:
49
+ default_shape = default_shape[:3]
45
50
  default_dims = _session_mod._startup_dims_for_data(session.data, default_shape)
46
51
  if default_dims is not None:
47
52
  meta["default_dims"] = [int(default_dims[0]), int(default_dims[1])]
@@ -65,6 +70,12 @@ def _build_metadata(session) -> dict:
65
70
  [list(shape) for shape in row]
66
71
  for row in session.data.ragged_spatial_shapes
67
72
  ]
73
+ case_voxel_spacings = getattr(session.data, "case_voxel_spacings", None)
74
+ if case_voxel_spacings:
75
+ meta["case_voxel_spacings"] = [
76
+ [list(spacing) for spacing in row]
77
+ for row in case_voxel_spacings
78
+ ]
68
79
  return meta
69
80
 
70
81
 
arrayview/_io.py CHANGED
@@ -172,7 +172,9 @@ def _nifti_header_with_meta(filepath, *, native=False):
172
172
  (abs(norm_rot[i, j]) for i in range(3) for j in range(3) if i != j),
173
173
  default=0.0,
174
174
  )
175
- canonical_shape = tuple(int(img.shape[int(axis)]) for axis in transform[:, 0])
175
+ canonical_shape = tuple(
176
+ int(img.shape[int(axis)]) for axis in transform[:, 0]
177
+ ) + tuple(int(size) for size in img.shape[3:])
176
178
  meta = {
177
179
  "affine": original_affine,
178
180
  "affine_canonical": affine_canonical,
@@ -281,6 +283,11 @@ class _ProgressiveNiftiJob:
281
283
  if self.future is not None and self.future.cancel():
282
284
  self._finish(cancelled=True)
283
285
 
286
+ def has_plane(self, z_index) -> bool:
287
+ """True if *z_index* is already decoded (a non-blocking peek)."""
288
+ with self._condition:
289
+ return bool(self._decoded[z_index]) or self._done
290
+
284
291
  def plane(self, z_index):
285
292
  with self._condition:
286
293
  while not self._decoded[z_index] and not self._done:
@@ -440,7 +447,8 @@ class _ProgressiveNiftiSeriesMixin:
440
447
  return None
441
448
  with self._progressive_lock:
442
449
  job = self._progressive_jobs.get(cache_key)
443
- if job is None:
450
+ is_new_job = job is None
451
+ if is_new_job:
444
452
  spec = _progressive_nifti_spec(filepath)
445
453
  if spec is None:
446
454
  return None
@@ -469,7 +477,18 @@ class _ProgressiveNiftiSeriesMixin:
469
477
  overlay_prefetch=overlay_prefetch
470
478
  ).submit(job.run)
471
479
  try:
472
- return job.plane(z_index)
480
+ # The request that starts the job waits on just the one plane, so
481
+ # the first frame of a fresh file shows up as soon as the decoder
482
+ # reaches it rather than waiting for the whole thing. Every later
483
+ # request for this same file instead waits for the whole volume
484
+ # (unless its plane is already decoded): scrolling through every
485
+ # plane that way finishes in less total time than paying a small
486
+ # wait on each individual plane (~200ms vs ~315ms measured on a
487
+ # 75-plane volume), so it also keeps later frames from feeling
488
+ # slower than the ones on the other two axes.
489
+ if is_new_job or job.has_plane(z_index):
490
+ return job.plane(z_index)
491
+ return job.volume()[:, :, z_index]
473
492
  except _ProgressiveLoadCancelled:
474
493
  raise
475
494
  except Exception:
@@ -489,11 +508,12 @@ class _NiftiSeries(_ProgressiveNiftiSeriesMixin):
489
508
 
490
509
  _av_lazy = True
491
510
 
492
- def __init__(self, file_matrix, vol_shape, dtype, spatial_meta=None):
511
+ def __init__(self, file_matrix, vol_shape, dtype, spatial_meta=None, spacing_matrix=None):
493
512
  self._file_matrix = file_matrix
494
513
  self._vol_shape = tuple(vol_shape)
495
514
  self._dtype = np.dtype(dtype)
496
515
  self._spatial_meta = spatial_meta
516
+ self._spacing_matrix = spacing_matrix
497
517
  n_patients = len(file_matrix)
498
518
  n_modalities = len(file_matrix[0]) if file_matrix else 0
499
519
  if n_modalities <= 1:
@@ -516,6 +536,10 @@ class _NiftiSeries(_ProgressiveNiftiSeriesMixin):
516
536
  def dtype(self):
517
537
  return self._dtype
518
538
 
539
+ @property
540
+ def case_voxel_spacings(self):
541
+ return self._spacing_matrix
542
+
519
543
  def _get_volume(self, p_idx, m_idx):
520
544
  cache_key = (p_idx, m_idx)
521
545
  with self._cache_lock:
@@ -709,7 +733,10 @@ class _RaggedFileSeries(_ProgressiveNiftiSeriesMixin):
709
733
  _av_lazy = True
710
734
  _av_ragged = True
711
735
 
712
- def __init__(self, file_matrix, spatial_shapes, dtype, spatial_meta=None, all_nifti=False):
736
+ def __init__(
737
+ self, file_matrix, spatial_shapes, dtype, spatial_meta=None, all_nifti=False,
738
+ spacing_matrix=None,
739
+ ):
713
740
  self._file_matrix = file_matrix
714
741
  self._spatial_shapes = [
715
742
  [tuple(int(s) for s in shape) for shape in row]
@@ -717,6 +744,7 @@ class _RaggedFileSeries(_ProgressiveNiftiSeriesMixin):
717
744
  ]
718
745
  self._dtype = np.dtype(dtype)
719
746
  self._spatial_meta = spatial_meta
747
+ self._spacing_matrix = spacing_matrix
720
748
  self._all_nifti = bool(all_nifti)
721
749
  self._spatial_ndim = len(self._spatial_shapes[0][0])
722
750
  self._vol_shape = tuple(
@@ -753,6 +781,10 @@ class _RaggedFileSeries(_ProgressiveNiftiSeriesMixin):
753
781
  def ragged_spatial_shapes(self):
754
782
  return self._spatial_shapes
755
783
 
784
+ @property
785
+ def case_voxel_spacings(self):
786
+ return self._spacing_matrix
787
+
756
788
  def _normalize_collection_indices(self, idx_list):
757
789
  n_p = len(self._file_matrix)
758
790
  n_m = len(self._file_matrix[0]) if self._file_matrix else 0
@@ -990,12 +1022,14 @@ def _series_from_file_matrix(
990
1022
 
991
1023
  if all_nifti:
992
1024
  spatial_shapes = []
1025
+ voxel_sizes_list = []
993
1026
  for fpath, img, item_meta in _iter_nifti_headers(
994
1027
  all_paths, scan_progress=scan_progress, scan_label=scan_label
995
1028
  ):
996
1029
  shape = tuple(item_meta["canonical_shape"])
997
1030
  dtype = img.get_data_dtype()
998
1031
  spatial_shapes.append(shape)
1032
+ voxel_sizes_list.append(tuple(item_meta["voxel_sizes"]))
999
1033
  if ref_shape is None:
1000
1034
  ref_shape = shape
1001
1035
  ref_dtype = dtype
@@ -1011,16 +1045,21 @@ def _series_from_file_matrix(
1011
1045
  f"{dtype}, expected {ref_dtype}."
1012
1046
  )
1013
1047
  shape_matrix = []
1048
+ spacing_matrix = []
1014
1049
  pos = 0
1015
1050
  for row in file_matrix:
1016
1051
  row_shapes = []
1052
+ row_spacings = []
1017
1053
  for path in row:
1018
1054
  if path is None:
1019
1055
  row_shapes.append(ref_shape)
1056
+ row_spacings.append(voxel_sizes_list[0] if voxel_sizes_list else (1.0, 1.0, 1.0))
1020
1057
  else:
1021
1058
  row_shapes.append(spatial_shapes[pos])
1059
+ row_spacings.append(voxel_sizes_list[pos])
1022
1060
  pos += 1
1023
1061
  shape_matrix.append(row_shapes)
1062
+ spacing_matrix.append(row_spacings)
1024
1063
  shapes_differ = any(shape != ref_shape for shape in spatial_shapes)
1025
1064
  if stack == "dense" and shapes_differ:
1026
1065
  raise ValueError("Dense stacking requires every file to have the same shape.")
@@ -1031,9 +1070,13 @@ def _series_from_file_matrix(
1031
1070
  ref_dtype,
1032
1071
  spatial_meta=spatial_meta,
1033
1072
  all_nifti=True,
1073
+ spacing_matrix=spacing_matrix,
1034
1074
  )
1035
1075
  else:
1036
- series = _NiftiSeries(file_matrix, ref_shape, ref_dtype, spatial_meta)
1076
+ series = _NiftiSeries(
1077
+ file_matrix, ref_shape, ref_dtype, spatial_meta,
1078
+ spacing_matrix=spacing_matrix,
1079
+ )
1037
1080
  if load == "eager":
1038
1081
  for p, row in enumerate(file_matrix):
1039
1082
  for m, path in enumerate(row):
@@ -401,148 +401,162 @@ def register_loading_routes(app, *, notify_shells, setup_rgb) -> None:
401
401
  "pending": True,
402
402
  }
403
403
  signature_before_load = file_signature(abs_path) if not dir_patterns else None
404
+ # A collection scan+load runs synchronously on this request (no
405
+ # "background" fast path — see the `not dir_patterns` guard above),
406
+ # but the caller already navigates the browser to `requested_sid`
407
+ # before this returns. Without marking the sid pending here, the
408
+ # viewer's very first phase report can 404 (sid known to neither
409
+ # SESSIONS nor PENDING_SESSIONS yet) and the opener misreports launch
410
+ # as failed even though the load goes on to succeed.
411
+ dir_pending_sid = requested_sid if (dir_patterns and requested_sid) else None
412
+ if dir_pending_sid:
413
+ PENDING_SESSIONS.add(dir_pending_sid)
404
414
  try:
405
- from ._io import load_data_with_meta, load_dir_collection, list_array_keys
406
-
407
- # Multi-array .npz/.mat: if no key provided, return the key list so
408
- # the client can show a picker instead of blocking on terminal input.
409
- _key = body.get("key")
410
- _array_keys = None
411
- if dir_patterns:
412
- data, spatial_meta, dir_overlay_items, summary = await asyncio.to_thread(
413
- load_dir_collection,
414
- list(dir_patterns),
415
- overlays=[tuple(item) for item in body.get("dir_overlay_specs", [])],
416
- case_regex=body.get("dir_case_regex"),
417
- exclude_cases=body.get("dir_exclude_cases", []),
418
- load=body.get("load", "lazy"),
419
- stack=body.get("stack", "auto"),
420
- )
421
- elif filepath.endswith(".npz") or filepath.endswith(".mat"):
422
- _array_keys = await asyncio.to_thread(list_array_keys, filepath)
423
- if len(_array_keys) > 1 and not _key:
424
- return {"array_keys": _array_keys, "filepath": filepath}
425
- if len(_array_keys) == 1 and not _key:
426
- _key = _array_keys[0]["key"]
427
-
428
- data, spatial_meta = await asyncio.to_thread(
429
- load_data_with_meta,
430
- filepath,
431
- key=_key,
432
- select=body.get("select"),
433
- ) if not dir_patterns else (data, spatial_meta)
434
- except Exception as e:
435
- if staging_dir:
436
- cleanup_staging_directory(staging_dir)
437
- return {"error": str(e)}
438
- try:
439
- session = await asyncio.to_thread(
440
- Session, data, filepath=None if dir_patterns else filepath, name=name
441
- )
442
- except Exception as e:
443
- if staging_dir:
444
- cleanup_staging_directory(staging_dir)
445
- return {"error": str(e)}
446
- if requested_sid:
447
- session.sid = requested_sid
448
- session.release_on_disconnect = bool(
449
- body.get("release_on_disconnect", False)
450
- )
451
- session.related_release_sids = [
452
- str(value) for value in body.get("related_sids", [])
453
- ]
454
- if staging_dir:
455
- session._source_staging_dirs = [str(staging_dir)]
456
- if not dir_patterns:
457
415
  try:
458
- signature_after_load = file_signature(abs_path)
416
+ from ._io import load_data_with_meta, load_dir_collection, list_array_keys
417
+
418
+ # Multi-array .npz/.mat: if no key provided, return the key list so
419
+ # the client can show a picker instead of blocking on terminal input.
420
+ _key = body.get("key")
421
+ _array_keys = None
422
+ if dir_patterns:
423
+ data, spatial_meta, dir_overlay_items, summary = await asyncio.to_thread(
424
+ load_dir_collection,
425
+ list(dir_patterns),
426
+ overlays=[tuple(item) for item in body.get("dir_overlay_specs", [])],
427
+ case_regex=body.get("dir_case_regex"),
428
+ exclude_cases=body.get("dir_exclude_cases", []),
429
+ load=body.get("load", "lazy"),
430
+ stack=body.get("stack", "auto"),
431
+ )
432
+ elif filepath.endswith(".npz") or filepath.endswith(".mat"):
433
+ _array_keys = await asyncio.to_thread(list_array_keys, filepath)
434
+ if len(_array_keys) > 1 and not _key:
435
+ return {"array_keys": _array_keys, "filepath": filepath}
436
+ if len(_array_keys) == 1 and not _key:
437
+ _key = _array_keys[0]["key"]
438
+
439
+ data, spatial_meta = await asyncio.to_thread(
440
+ load_data_with_meta,
441
+ filepath,
442
+ key=_key,
443
+ select=body.get("select"),
444
+ ) if not dir_patterns else (data, spatial_meta)
459
445
  except Exception as e:
460
446
  if staging_dir:
461
447
  cleanup_staging_directory(staging_dir)
462
448
  return {"error": str(e)}
463
- if signature_before_load == signature_after_load:
464
- session.file_signature = signature_after_load
465
- if dir_patterns:
466
- session.collection_spatial_ndim = len(summary["spatial_shape"])
467
- session.collection_identity = collection_identity
468
- if spatial_meta is not None:
469
- session.spatial_meta = spatial_meta
470
- session.original_volume = data
471
- if _array_keys and len(_array_keys) > 1:
472
- session.array_keys = _array_keys
473
- session.array_filepath = filepath
474
- if body.get("rgb"):
475
449
  try:
476
- await asyncio.to_thread(setup_rgb, session)
477
- except ValueError as e:
478
- if staging_dir:
479
- cleanup_staging_directory(staging_dir)
480
- return {"error": str(e)}
481
- overlay_sids = []
482
- overlay_names = []
483
- overlay_sessions = []
484
- for item in dir_overlay_items if dir_patterns else []:
485
- try:
486
- overlay_session = await asyncio.to_thread(
487
- Session, item["data"], filepath=None, name=item["name"]
450
+ session = await asyncio.to_thread(
451
+ Session, data, filepath=None if dir_patterns else filepath, name=name
488
452
  )
489
453
  except Exception as e:
490
454
  if staging_dir:
491
455
  cleanup_staging_directory(staging_dir)
492
456
  return {"error": str(e)}
493
- overlay_sessions.append(overlay_session)
494
- overlay_sids.append(overlay_session.sid)
495
- overlay_names.append(item["name"])
496
- if dir_patterns:
497
- session.collection_overlay_sids = list(overlay_sids)
498
- session.collection_overlay_names = list(overlay_names)
499
- if requested_sid:
500
- if not commit_session_group_unless_cancelled(
501
- session.sid,
502
- [session, *overlay_sessions],
503
- ):
504
- CANCELLED_PENDING_SESSIONS.discard(session.sid)
505
- if staging_dir:
506
- cleanup_staging_directory(staging_dir)
507
- return {"error": "load request was cancelled"}
508
- else:
509
- SESSIONS[session.sid] = session
510
- for overlay_session in overlay_sessions:
511
- SESSIONS[overlay_session.sid] = overlay_session
512
- if body.get("watch"):
513
- import arrayview._session as _session_mod
514
- from ._watch import attach_file_watch
515
-
516
- attach_file_watch(session, filepath, _session_mod.SERVER_PORT or 8000)
517
- notified = False
518
- if notify:
519
- tab_url = None
520
- if body.get("compare_sids"):
521
- tab_url = (
522
- f"/?sid={session.sid}"
523
- f"&compare_sid={body['compare_sid']}"
524
- f"&compare_sids={body['compare_sids']}"
525
- )
526
- if overlay_sids:
527
- tab_url = (
528
- f"/?sid={session.sid}"
529
- f"&overlay_sid={','.join(overlay_sids)}"
530
- f"&overlay_names={urllib.parse.quote(','.join(overlay_names))}"
531
- )
532
- if native_request_id:
533
- tab_url = tab_url or f"/?sid={session.sid}"
534
- tab_url += (
535
- "&native_request_id="
536
- + urllib.parse.quote(native_request_id)
537
- )
538
- notified = await notify_shells(session.sid, name, url=tab_url, wait=False)
539
- return {
540
- "sid": session.sid,
541
- "name": name,
542
- "notified": notified,
543
- "overlay_sids": overlay_sids,
544
- "overlay_names": overlay_names,
545
- }
457
+ if requested_sid:
458
+ session.sid = requested_sid
459
+ session.release_on_disconnect = bool(
460
+ body.get("release_on_disconnect", False)
461
+ )
462
+ session.related_release_sids = [
463
+ str(value) for value in body.get("related_sids", [])
464
+ ]
465
+ if staging_dir:
466
+ session._source_staging_dirs = [str(staging_dir)]
467
+ if not dir_patterns:
468
+ try:
469
+ signature_after_load = file_signature(abs_path)
470
+ except Exception as e:
471
+ if staging_dir:
472
+ cleanup_staging_directory(staging_dir)
473
+ return {"error": str(e)}
474
+ if signature_before_load == signature_after_load:
475
+ session.file_signature = signature_after_load
476
+ if dir_patterns:
477
+ session.collection_spatial_ndim = len(summary["spatial_shape"])
478
+ session.collection_identity = collection_identity
479
+ if spatial_meta is not None:
480
+ session.spatial_meta = spatial_meta
481
+ session.original_volume = data
482
+ if _array_keys and len(_array_keys) > 1:
483
+ session.array_keys = _array_keys
484
+ session.array_filepath = filepath
485
+ if body.get("rgb"):
486
+ try:
487
+ await asyncio.to_thread(setup_rgb, session)
488
+ except ValueError as e:
489
+ if staging_dir:
490
+ cleanup_staging_directory(staging_dir)
491
+ return {"error": str(e)}
492
+ overlay_sids = []
493
+ overlay_names = []
494
+ overlay_sessions = []
495
+ for item in dir_overlay_items if dir_patterns else []:
496
+ try:
497
+ overlay_session = await asyncio.to_thread(
498
+ Session, item["data"], filepath=None, name=item["name"]
499
+ )
500
+ except Exception as e:
501
+ if staging_dir:
502
+ cleanup_staging_directory(staging_dir)
503
+ return {"error": str(e)}
504
+ overlay_sessions.append(overlay_session)
505
+ overlay_sids.append(overlay_session.sid)
506
+ overlay_names.append(item["name"])
507
+ if dir_patterns:
508
+ session.collection_overlay_sids = list(overlay_sids)
509
+ session.collection_overlay_names = list(overlay_names)
510
+ if requested_sid:
511
+ if not commit_session_group_unless_cancelled(
512
+ session.sid,
513
+ [session, *overlay_sessions],
514
+ ):
515
+ CANCELLED_PENDING_SESSIONS.discard(session.sid)
516
+ if staging_dir:
517
+ cleanup_staging_directory(staging_dir)
518
+ return {"error": "load request was cancelled"}
519
+ else:
520
+ SESSIONS[session.sid] = session
521
+ for overlay_session in overlay_sessions:
522
+ SESSIONS[overlay_session.sid] = overlay_session
523
+ if body.get("watch"):
524
+ import arrayview._session as _session_mod
525
+ from ._watch import attach_file_watch
526
+
527
+ attach_file_watch(session, filepath, _session_mod.SERVER_PORT or 8000)
528
+ notified = False
529
+ if notify:
530
+ tab_url = None
531
+ if body.get("compare_sids"):
532
+ tab_url = (
533
+ f"/?sid={session.sid}"
534
+ f"&compare_sid={body['compare_sid']}"
535
+ f"&compare_sids={body['compare_sids']}"
536
+ )
537
+ if overlay_sids:
538
+ tab_url = (
539
+ f"/?sid={session.sid}"
540
+ f"&overlay_sid={','.join(overlay_sids)}"
541
+ f"&overlay_names={urllib.parse.quote(','.join(overlay_names))}"
542
+ )
543
+ if native_request_id:
544
+ tab_url = tab_url or f"/?sid={session.sid}"
545
+ tab_url += (
546
+ "&native_request_id="
547
+ + urllib.parse.quote(native_request_id)
548
+ )
549
+ notified = await notify_shells(session.sid, name, url=tab_url, wait=False)
550
+ return {
551
+ "sid": session.sid,
552
+ "name": name,
553
+ "notified": notified,
554
+ "overlay_sids": overlay_sids,
555
+ "overlay_names": overlay_names,
556
+ }
557
+ finally:
558
+ if dir_pending_sid:
559
+ PENDING_SESSIONS.discard(dir_pending_sid)
546
560
 
547
561
  @app.post("/notify/{sid}")
548
562
  async def notify_existing_session(sid: str, request: Request):