ndslice 0.7.0__tar.gz → 0.8.0__tar.gz

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
  Metadata-Version: 2.4
2
2
  Name: ndslice
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: Interactive N-dimensional numpy array viewer with FFT support
5
5
  Author-email: Henric Rydén <henric.ryden@gmail.com>
6
6
  License-Expression: MIT
@@ -26,6 +26,8 @@ License-File: LICENSE
26
26
  Requires-Dist: numpy>=1.20.0
27
27
  Requires-Dist: pyqtgraph>=0.14.0
28
28
  Requires-Dist: PyQt5>=5.15.0
29
+ Requires-Dist: platformdirs>=3.0.0
30
+ Requires-Dist: tomli>=1.1.0; python_version < "3.11"
29
31
  Requires-Dist: h5py>=3.0.0
30
32
  Requires-Dist: scipy>=1.7.0
31
33
  Requires-Dist: pydicom>=2.4.0
@@ -67,6 +69,8 @@ pha = np.pi/4 * (X + Y + Z)
67
69
  complex_data = mag * np.exp(1j * pha)
68
70
 
69
71
  ndslice(complex_data, title='3D Complex Gaussian')
72
+ # Optional dimension labels:
73
+ ndslice(data, dim_labels=["X", "Y", "Z"])
70
74
  ```
71
75
 
72
76
  ![Showcase](docs/images/showcase.gif)
@@ -129,8 +133,14 @@ ndslice(data1, block=True) # Script pauses here
129
133
  ndslice(data2) # Shown after first closes
130
134
  ```
131
135
 
136
+ If Qt is already initialized in the current process, `ndslice(..., block=False)`
137
+ cannot safely fork a child process. In that case:
138
+
139
+ - In IPython/Jupyter with `%gui qt`, ndslice opens inline in the current process (still non-blocking).
140
+ - Without an active Qt event loop, ndslice falls back to blocking mode and emits a warning.
132
141
 
133
142
  ### Command Line
143
+
134
144
  ```bash
135
145
  ndslice data.npy # Numpy file
136
146
  ndslice image.nii.gz
@@ -25,6 +25,8 @@ pha = np.pi/4 * (X + Y + Z)
25
25
  complex_data = mag * np.exp(1j * pha)
26
26
 
27
27
  ndslice(complex_data, title='3D Complex Gaussian')
28
+ # Optional dimension labels:
29
+ ndslice(data, dim_labels=["X", "Y", "Z"])
28
30
  ```
29
31
 
30
32
  ![Showcase](docs/images/showcase.gif)
@@ -87,8 +89,14 @@ ndslice(data1, block=True) # Script pauses here
87
89
  ndslice(data2) # Shown after first closes
88
90
  ```
89
91
 
92
+ If Qt is already initialized in the current process, `ndslice(..., block=False)`
93
+ cannot safely fork a child process. In that case:
94
+
95
+ - In IPython/Jupyter with `%gui qt`, ndslice opens inline in the current process (still non-blocking).
96
+ - Without an active Qt event loop, ndslice falls back to blocking mode and emits a warning.
90
97
 
91
98
  ### Command Line
99
+
92
100
  ```bash
93
101
  ndslice data.npy # Numpy file
94
102
  ndslice image.nii.gz
@@ -6,7 +6,7 @@ import argparse
6
6
  import numpy as np
7
7
  from pathlib import Path
8
8
  from .ndslice import ndslice
9
- from .selectors import H5DatasetSelector, NpzDatasetSelector, MatDatasetSelector
9
+ from .selectors import h5_selector_for_path, NpzDatasetSelector, MatDatasetSelector
10
10
  from .file_interpreters import load_path
11
11
 
12
12
 
@@ -50,13 +50,15 @@ For files with multiple datasets (HDF5, NPZ, MAT), a GUI selector will automatic
50
50
  detected_format = loaded.metadata.get('detected_format')
51
51
  if detected_format:
52
52
  title = f"{title} [{detected_format}]"
53
- ndslice(data=loaded.data, title=title, block=False, filepath=filepath)
53
+ ndslice(data=loaded.data, title=title, block=False, filepath=filepath,
54
+ dim_labels=loaded.metadata.get('dim_labels'),
55
+ voxel_spacing=loaded.metadata.get('voxel_spacing'))
54
56
  continue
55
57
 
56
58
  # Multi-dataset formats - use selectors
57
59
  selector = None
58
60
  if suffix in ['.h5', '.hdf5']:
59
- selector = H5DatasetSelector(filepath)
61
+ selector = h5_selector_for_path(filepath)
60
62
  elif suffix == '.npz':
61
63
  selector = NpzDatasetSelector(filepath)
62
64
  elif suffix == '.mat':
@@ -77,4 +79,4 @@ For files with multiple datasets (HDF5, NPZ, MAT), a GUI selector will automatic
77
79
 
78
80
 
79
81
  if __name__ == '__main__':
80
- main()
82
+ main()
@@ -0,0 +1,185 @@
1
+ """Persistent user configuration for ndslice."""
2
+
3
+ from dataclasses import dataclass, replace
4
+ from pathlib import Path
5
+ from platformdirs import user_config_dir
6
+
7
+ try:
8
+ import tomllib
9
+ except ImportError: # pragma: no cover - exercised only on Python < 3.11
10
+ import tomli as tomllib
11
+
12
+
13
+ INITIAL_INDEX_FIRST = "first"
14
+ INITIAL_INDEX_CENTER = "center"
15
+ INITIAL_INDEX_LAST = "last"
16
+ VALID_INITIAL_INDEX = {INITIAL_INDEX_FIRST, INITIAL_INDEX_CENTER, INITIAL_INDEX_LAST}
17
+ DEFAULT_SLICE_FIRST = "first"
18
+ DEFAULT_SLICE_LAST = "last"
19
+ VALID_DEFAULT_SLICE = {DEFAULT_SLICE_FIRST, DEFAULT_SLICE_LAST}
20
+
21
+ DEFAULT_COLORMAP = "gray"
22
+ ANGLE_COLORMAP_SAME = "same"
23
+ DEFAULT_CHANNEL = "auto"
24
+ SUPPORTED_CHANNELS = (
25
+ "auto",
26
+ "real",
27
+ "abs",
28
+ "imag",
29
+ "angle",
30
+ )
31
+ DEFAULT_ORIGIN = "lower_left"
32
+ SUPPORTED_ORIGINS = (
33
+ "upper_left",
34
+ "lower_left",
35
+ "upper_right",
36
+ "lower_right",
37
+ )
38
+ DEFAULT_DISPLAY_MODE = "square_pixels"
39
+ SUPPORTED_DISPLAY_MODES = (
40
+ "square_pixels",
41
+ "square_fov",
42
+ "auto",
43
+ )
44
+ DISPLAY_MODE_ALIASES = {
45
+ "fit": "auto",
46
+ }
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class ViewerConfig:
51
+ initial_index: str = INITIAL_INDEX_FIRST
52
+ default_slice: str = DEFAULT_SLICE_FIRST
53
+ initial_origin: str = DEFAULT_ORIGIN
54
+ default_channel: str = DEFAULT_CHANNEL
55
+ default_colormap: str = DEFAULT_COLORMAP
56
+ angle_colormap: str = ANGLE_COLORMAP_SAME
57
+ default_display_mode: str = DEFAULT_DISPLAY_MODE
58
+
59
+
60
+ def get_config_path():
61
+ return Path(user_config_dir("ndslice", appauthor=False)) / "config.toml"
62
+
63
+
64
+ def load_config(path=None, colormap_names=None):
65
+ """Load user config, falling back to defaults for any invalid value."""
66
+ config_path = Path(path) if path is not None else get_config_path()
67
+ valid_colormaps = tuple(colormap_names) if colormap_names is not None else None
68
+
69
+ try:
70
+ raw = tomllib.loads(config_path.read_text(encoding="utf-8"))
71
+ except Exception:
72
+ return ViewerConfig()
73
+
74
+ startup = raw.get("startup", {})
75
+ if not isinstance(startup, dict):
76
+ startup = {}
77
+
78
+ display = raw.get("display", {})
79
+ if not isinstance(display, dict):
80
+ display = {}
81
+
82
+ initial_index = startup.get("initial_index", INITIAL_INDEX_FIRST)
83
+ if initial_index not in VALID_INITIAL_INDEX:
84
+ initial_index = INITIAL_INDEX_FIRST
85
+
86
+ default_slice = startup.get("default_slice", DEFAULT_SLICE_FIRST)
87
+ if default_slice not in VALID_DEFAULT_SLICE:
88
+ default_slice = DEFAULT_SLICE_FIRST
89
+
90
+ initial_origin = startup.get("initial_origin", DEFAULT_ORIGIN)
91
+ if initial_origin not in SUPPORTED_ORIGINS:
92
+ initial_origin = DEFAULT_ORIGIN
93
+
94
+ default_channel = display.get("default_channel", DEFAULT_CHANNEL)
95
+ if default_channel not in SUPPORTED_CHANNELS:
96
+ default_channel = DEFAULT_CHANNEL
97
+
98
+ default_colormap = display.get("default_colormap", DEFAULT_COLORMAP)
99
+ if valid_colormaps is not None and default_colormap not in valid_colormaps:
100
+ default_colormap = DEFAULT_COLORMAP
101
+
102
+ angle_colormap = display.get("angle_colormap", ANGLE_COLORMAP_SAME)
103
+ valid_angle_colormaps = (
104
+ (ANGLE_COLORMAP_SAME,) + valid_colormaps
105
+ if valid_colormaps is not None
106
+ else None
107
+ )
108
+ if valid_angle_colormaps is not None and angle_colormap not in valid_angle_colormaps:
109
+ angle_colormap = ANGLE_COLORMAP_SAME
110
+
111
+ default_display_mode = display.get("default_display_mode", DEFAULT_DISPLAY_MODE)
112
+ default_display_mode = DISPLAY_MODE_ALIASES.get(default_display_mode, default_display_mode)
113
+ if default_display_mode not in SUPPORTED_DISPLAY_MODES:
114
+ default_display_mode = DEFAULT_DISPLAY_MODE
115
+
116
+ return ViewerConfig(
117
+ initial_index=initial_index,
118
+ default_slice=default_slice,
119
+ initial_origin=initial_origin,
120
+ default_channel=default_channel,
121
+ default_colormap=default_colormap,
122
+ angle_colormap=angle_colormap,
123
+ default_display_mode=default_display_mode,
124
+ )
125
+
126
+
127
+ def save_config(config, path=None, colormap_names=None):
128
+ """Write the complete viewer config to TOML."""
129
+ config_path = Path(path) if path is not None else get_config_path()
130
+ config_path.parent.mkdir(parents=True, exist_ok=True)
131
+ valid_colormaps = tuple(colormap_names) if colormap_names is not None else None
132
+
133
+ initial_index = config.initial_index
134
+ if initial_index not in VALID_INITIAL_INDEX:
135
+ initial_index = INITIAL_INDEX_FIRST
136
+
137
+ default_slice = config.default_slice
138
+ if default_slice not in VALID_DEFAULT_SLICE:
139
+ default_slice = DEFAULT_SLICE_FIRST
140
+
141
+ initial_origin = config.initial_origin
142
+ if initial_origin not in SUPPORTED_ORIGINS:
143
+ initial_origin = DEFAULT_ORIGIN
144
+
145
+ default_channel = config.default_channel
146
+ if default_channel not in SUPPORTED_CHANNELS:
147
+ default_channel = DEFAULT_CHANNEL
148
+
149
+ default_colormap = config.default_colormap
150
+ if valid_colormaps is not None and default_colormap not in valid_colormaps:
151
+ default_colormap = DEFAULT_COLORMAP
152
+
153
+ angle_colormap = config.angle_colormap
154
+ valid_angle_colormaps = (
155
+ (ANGLE_COLORMAP_SAME,) + valid_colormaps
156
+ if valid_colormaps is not None
157
+ else None
158
+ )
159
+ if valid_angle_colormaps is not None and angle_colormap not in valid_angle_colormaps:
160
+ angle_colormap = ANGLE_COLORMAP_SAME
161
+
162
+ default_display_mode = config.default_display_mode
163
+ default_display_mode = DISPLAY_MODE_ALIASES.get(default_display_mode, default_display_mode)
164
+ if default_display_mode not in SUPPORTED_DISPLAY_MODES:
165
+ default_display_mode = DEFAULT_DISPLAY_MODE
166
+
167
+ text = (
168
+ "[startup]\n"
169
+ f'initial_index = "{initial_index}"\n'
170
+ f'default_slice = "{default_slice}"\n'
171
+ f'initial_origin = "{initial_origin}"\n'
172
+ "\n"
173
+ "[display]\n"
174
+ f'default_channel = "{default_channel}"\n'
175
+ f'default_colormap = "{default_colormap}"\n'
176
+ f'angle_colormap = "{angle_colormap}"\n'
177
+ f'default_display_mode = "{default_display_mode}"\n'
178
+ )
179
+ config_path.write_text(text, encoding="utf-8")
180
+
181
+
182
+ def update_config(path=None, colormap_names=None, **changes):
183
+ config = replace(load_config(path, colormap_names=colormap_names), **changes)
184
+ save_config(config, path, colormap_names=colormap_names)
185
+ return config
@@ -23,6 +23,75 @@ def remove_trailing_singletons(data):
23
23
  return data.reshape(shape[:dim+1])
24
24
 
25
25
 
26
+ def _nifti_dim_labels(ndim):
27
+ labels = [''] * ndim
28
+ for dim, label in enumerate(('X', 'Y', 'Z', 'T')):
29
+ if dim < ndim:
30
+ labels[dim] = label
31
+ return labels
32
+
33
+
34
+ def _valid_spacing_value(value):
35
+ try:
36
+ value = float(value)
37
+ except (TypeError, ValueError):
38
+ return None
39
+ return value if np.isfinite(value) and value > 0 else None
40
+
41
+
42
+ def _nifti_voxel_spacing(zooms, ndim):
43
+ spacing = [None] * ndim
44
+ for dim in range(min(ndim, 3, len(zooms))):
45
+ spacing[dim] = _valid_spacing_value(zooms[dim])
46
+ return spacing
47
+
48
+
49
+ def _dicom_pixel_voxel_spacing(pixel_spacing, ndim):
50
+ spacing = [None] * ndim
51
+ if pixel_spacing is None or ndim < 2:
52
+ return spacing
53
+
54
+ values = list(pixel_spacing)
55
+ if len(values) < 2:
56
+ return spacing
57
+
58
+ spacing[-2] = _valid_spacing_value(values[0])
59
+ spacing[-1] = _valid_spacing_value(values[1])
60
+ return spacing
61
+
62
+
63
+ def _metadata_voxel_spacing(voxel_spacing, ndim):
64
+ spacing = [None] * ndim
65
+ if voxel_spacing is None:
66
+ return spacing
67
+
68
+ for dim, value in enumerate(voxel_spacing):
69
+ if dim >= ndim:
70
+ break
71
+ spacing[dim] = _valid_spacing_value(value)
72
+ return spacing
73
+
74
+
75
+ def _append_dim_label(dim_labels, label):
76
+ labels = list(dim_labels or [])
77
+ labels.append(label or '')
78
+ return labels
79
+
80
+
81
+ def _append_voxel_spacing(voxel_spacing, spacing):
82
+ values = list(voxel_spacing or [])
83
+ values.append(spacing)
84
+ return values
85
+
86
+
87
+ def _reverse_spatial_metadata(values, ndim):
88
+ values = list(values or [])
89
+ values = (values + [None] * ndim)[:ndim]
90
+ spatial_ndim = min(3, ndim)
91
+ values[:spatial_ndim] = reversed(values[:spatial_ndim])
92
+ return values
93
+
94
+
26
95
  class PhilipsRECLoader:
27
96
  def __init__(self, rec_path):
28
97
  self.rec_path = Path(rec_path)
@@ -273,14 +342,16 @@ class DicomLoader:
273
342
  )
274
343
 
275
344
  data = remove_trailing_singletons(np.asarray(dcm.pixel_array))
345
+ pixel_spacing = getattr(dcm, 'PixelSpacing', None)
276
346
  self.metadata.update({
277
347
  'shape': tuple(data.shape),
278
348
  'dtype': str(data.dtype),
349
+ 'voxel_spacing': _dicom_pixel_voxel_spacing(pixel_spacing, data.ndim),
279
350
  'dicom_metadata': {
280
351
  'patient_name': str(getattr(dcm, 'PatientName', '')),
281
352
  'series_description': str(getattr(dcm, 'SeriesDescription', '')),
282
353
  'modality': str(getattr(dcm, 'Modality', '')),
283
- 'pixel_spacing': list(getattr(dcm, 'PixelSpacing', [])) if hasattr(dcm, 'PixelSpacing') else None,
354
+ 'pixel_spacing': list(pixel_spacing) if pixel_spacing is not None else None,
284
355
  'image_position_patient': list(getattr(dcm, 'ImagePositionPatient', [])) if hasattr(dcm, 'ImagePositionPatient') else None,
285
356
  },
286
357
  })
@@ -399,6 +470,8 @@ class DicomDirectoryLoader:
399
470
  nifti_paths = []
400
471
  json_paths = []
401
472
  sidecar_metadata = []
473
+ nifti_dim_labels = []
474
+ nifti_voxel_spacings = []
402
475
 
403
476
  for nifti_path, json_path in nifti_outputs:
404
477
  nifti_loader = NiftiLoader(nifti_path)
@@ -406,9 +479,18 @@ class DicomDirectoryLoader:
406
479
  nifti_paths.append(str(nifti_path))
407
480
  json_paths.append(str(json_path) if json_path else None)
408
481
  sidecar_metadata.append(_read_json_sidecar(json_path))
482
+ nifti_dim_labels.append(nifti_loader.metadata.get('dim_labels', []))
483
+ nifti_voxel_spacings.append(nifti_loader.metadata.get('voxel_spacing', []))
409
484
 
410
485
  if len(arrays) == 1:
411
486
  data = arrays[0]
487
+ base_dim_labels = list(nifti_dim_labels[0]) if nifti_dim_labels else _nifti_dim_labels(data.ndim)
488
+ dim_labels = _reverse_spatial_metadata(base_dim_labels, data.ndim)
489
+ base_voxel_spacing = _metadata_voxel_spacing(
490
+ nifti_voxel_spacings[0] if nifti_voxel_spacings else None,
491
+ data.ndim,
492
+ )
493
+ voxel_spacing = base_voxel_spacing
412
494
  stacking_label = None
413
495
  stacking_key = None
414
496
  stacking_values = None
@@ -430,10 +512,20 @@ class DicomDirectoryLoader:
430
512
  if any(description is not None for description in series_descriptions):
431
513
  print(f" SeriesDescription: {series_descriptions}")
432
514
  data = np.stack(arrays, axis=-1)
515
+ base_dim_labels = list(nifti_dim_labels[0]) if nifti_dim_labels else _nifti_dim_labels(arrays[0].ndim)
516
+ base_dim_labels = _reverse_spatial_metadata(base_dim_labels, arrays[0].ndim)
517
+ dim_labels = _append_dim_label(base_dim_labels, stacking_label)
518
+ base_voxel_spacing = _metadata_voxel_spacing(
519
+ nifti_voxel_spacings[0] if nifti_voxel_spacings else None,
520
+ arrays[0].ndim,
521
+ )
522
+ voxel_spacing = _append_voxel_spacing(base_voxel_spacing, None)
433
523
 
434
524
  self.metadata.update({
435
525
  'shape': tuple(data.shape),
436
526
  'dtype': str(data.dtype),
527
+ 'dim_labels': dim_labels,
528
+ 'voxel_spacing': voxel_spacing,
437
529
  'nifti_output_path': nifti_paths[0] if len(nifti_paths) == 1 else nifti_paths,
438
530
  'sidecar_json_path': json_paths[0] if len(json_paths) == 1 else json_paths,
439
531
  'stacked_dimension': stacking_label,
@@ -463,11 +555,14 @@ class NiftiLoader:
463
555
  "Install it with: pip install nibabel"
464
556
  )
465
557
 
466
- data = nib.load(self.file_path).get_fdata()
558
+ image = nib.load(self.file_path)
559
+ data = image.get_fdata()
467
560
  data = remove_trailing_singletons(data)
468
561
  self.metadata.update({
469
562
  'shape': tuple(data.shape),
470
563
  'dtype': str(data.dtype),
564
+ 'dim_labels': _nifti_dim_labels(data.ndim),
565
+ 'voxel_spacing': _nifti_voxel_spacing(image.header.get_zooms(), data.ndim),
471
566
  })
472
567
  return data
473
568
 
@@ -601,4 +696,4 @@ def load_file(filepath):
601
696
  NumPy array
602
697
 
603
698
  """
604
- return load_path(filepath).data
699
+ return load_path(filepath).data
@@ -34,6 +34,7 @@ class ImageView2D(QtWidgets.QWidget):
34
34
  self.levelMin = None
35
35
  self.levelMax = None
36
36
  self.displayMode = 'square_pixels' # Default to square pixels
37
+ self.autoAspectRatio = None
37
38
 
38
39
  # Create the UI layout
39
40
  self.setupUI()
@@ -222,18 +223,26 @@ class ImageView2D(QtWidgets.QWidget):
222
223
  """Set the color map for the histogram"""
223
224
  self.histogram.gradient.setColorMap(colormap)
224
225
 
225
- def setDisplayMode(self, mode):
226
+ def setDisplayMode(self, mode, auto_aspect_ratio=None):
226
227
  """Set the display mode.
227
228
 
228
229
  Modes:
229
230
  - 'square_pixels': force square pixel display (aspect ratio 1.0)
230
231
  - 'square_fov' : lock aspect ratio to image width/height (field of view square)
231
- - 'fit' : allow non-uniform scaling so the entire image fits viewport
232
+ - 'auto' : lock to provided physical aspect, or fit if unavailable
232
233
  """
233
- if mode not in ('square_pixels', 'square_fov', 'fit'):
234
+ if mode == 'fit':
235
+ mode = 'auto'
236
+ if mode not in ('square_pixels', 'square_fov', 'auto'):
234
237
  raise ValueError(f"Unknown display mode: {mode}")
235
238
  self.displayMode = mode
239
+ self.autoAspectRatio = auto_aspect_ratio
236
240
  self._updateAspectRatio()
241
+
242
+ def setAutoAspectRatio(self, auto_aspect_ratio):
243
+ self.autoAspectRatio = auto_aspect_ratio
244
+ if self.displayMode == 'auto':
245
+ self._updateAspectRatio()
237
246
 
238
247
  def _updateAspectRatio(self):
239
248
  """Update the aspect ratio based on display mode"""
@@ -248,11 +257,12 @@ class ImageView2D(QtWidgets.QWidget):
248
257
  height, width = self.image.shape
249
258
  aspect_ratio = width / height
250
259
  self.view.setAspectLocked(True, ratio=aspect_ratio)
251
- elif self.displayMode == 'fit':
252
- # Fit: allow free aspect so the whole image fits inside the view box
253
- self.view.setAspectLocked(False)
254
- # Ensure view box ranges cover the image exactly
255
- self.view.autoRange()
260
+ elif self.displayMode == 'auto':
261
+ if self.autoAspectRatio is None:
262
+ self.view.setAspectLocked(False)
263
+ self.view.autoRange()
264
+ else:
265
+ self.view.setAspectLocked(True, ratio=self.autoAspectRatio)
256
266
 
257
267
  # Trigger a refresh of the view
258
268
  if hasattr(self, 'imageItem') and self.imageItem is not None:
@@ -260,7 +270,7 @@ class ImageView2D(QtWidgets.QWidget):
260
270
 
261
271
  # --- Qt Events -----------------------------------------------------
262
272
  def resizeEvent(self, event):
263
- """On resize, if in 'fit' mode keep the image fully visible."""
273
+ """On resize, if in auto-fit mode keep the image fully visible."""
264
274
  super().resizeEvent(event)
265
- if self.displayMode == 'fit' and self.image is not None:
266
- self.view.autoRange()
275
+ if self.displayMode == 'auto' and self.autoAspectRatio is None and self.image is not None:
276
+ self.view.autoRange()