ndslice 0.7.0__tar.gz → 0.9.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.9.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,10 @@ 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"])
74
+ # Optional mask overlay:
75
+ ndslice(data, mask=mask_data)
70
76
  ```
71
77
 
72
78
  ![Showcase](docs/images/showcase.gif)
@@ -115,6 +121,9 @@ Click arrow icons (⬇️/⬆️ and ⬅️/➡️) next to dimension labels to
115
121
  Default orientation is image-style (origin lower-left).
116
122
  Flip the primary axis for matrix-style (origin upper-left).
117
123
 
124
+ **Mask overlay**
125
+ Pass `mask=` to overlay a label volume on the main image. Mask values must be integer labels from `0` to `32`; label `0` is transparent, opacity is adjustable, and the small color swatch cycles between solid RGBCMY colors and a Glasbey rainbow palette for the labels present in the mask.
126
+
118
127
  **Non-blocking windows**
119
128
 
120
129
  By default, windows open in separate processes, allowing multiple simultaneous views:
@@ -129,11 +138,18 @@ ndslice(data1, block=True) # Script pauses here
129
138
  ndslice(data2) # Shown after first closes
130
139
  ```
131
140
 
141
+ If Qt is already initialized in the current process, `ndslice(..., block=False)`
142
+ cannot safely fork a child process. In that case:
143
+
144
+ - In IPython/Jupyter with `%gui qt`, ndslice opens inline in the current process (still non-blocking).
145
+ - Without an active Qt event loop, ndslice falls back to blocking mode and emits a warning.
132
146
 
133
147
  ### Command Line
148
+
134
149
  ```bash
135
150
  ndslice data.npy # Numpy file
136
151
  ndslice image.nii.gz
152
+ ndslice image.nii.gz --mask mask.nii.gz
137
153
  ndslice image.dcm
138
154
  ndslice some_dicom_dir/ # Automatically attemps to form an nd-array from DICOM the files
139
155
  ndslice --help # Show all options
@@ -25,6 +25,10 @@ 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"])
30
+ # Optional mask overlay:
31
+ ndslice(data, mask=mask_data)
28
32
  ```
29
33
 
30
34
  ![Showcase](docs/images/showcase.gif)
@@ -73,6 +77,9 @@ Click arrow icons (⬇️/⬆️ and ⬅️/➡️) next to dimension labels to
73
77
  Default orientation is image-style (origin lower-left).
74
78
  Flip the primary axis for matrix-style (origin upper-left).
75
79
 
80
+ **Mask overlay**
81
+ Pass `mask=` to overlay a label volume on the main image. Mask values must be integer labels from `0` to `32`; label `0` is transparent, opacity is adjustable, and the small color swatch cycles between solid RGBCMY colors and a Glasbey rainbow palette for the labels present in the mask.
82
+
76
83
  **Non-blocking windows**
77
84
 
78
85
  By default, windows open in separate processes, allowing multiple simultaneous views:
@@ -87,11 +94,18 @@ ndslice(data1, block=True) # Script pauses here
87
94
  ndslice(data2) # Shown after first closes
88
95
  ```
89
96
 
97
+ If Qt is already initialized in the current process, `ndslice(..., block=False)`
98
+ cannot safely fork a child process. In that case:
99
+
100
+ - In IPython/Jupyter with `%gui qt`, ndslice opens inline in the current process (still non-blocking).
101
+ - Without an active Qt event loop, ndslice falls back to blocking mode and emits a warning.
90
102
 
91
103
  ### Command Line
104
+
92
105
  ```bash
93
106
  ndslice data.npy # Numpy file
94
107
  ndslice image.nii.gz
108
+ ndslice image.nii.gz --mask mask.nii.gz
95
109
  ndslice image.dcm
96
110
  ndslice some_dicom_dir/ # Automatically attemps to form an nd-array from DICOM the files
97
111
  ndslice --help # Show all options
@@ -183,4 +197,4 @@ Henric Rydén
183
197
 
184
198
  Karolinska University Hospital
185
199
 
186
- Stockholm, Sweden
200
+ Stockholm, Sweden
@@ -0,0 +1,15 @@
1
+ """
2
+ ndslice - Interactive N-dimensional array viewer with FFT support
3
+ """
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+
7
+ from .ndslice import ndslice, NDSliceWindow, Domain
8
+ from .imageview2d import ImageView2D
9
+
10
+ try:
11
+ __version__ = version("ndslice")
12
+ except PackageNotFoundError:
13
+ __version__ = "0+unknown"
14
+
15
+ __all__ = ["ndslice", "NDSliceWindow", "ImageView2D", "Domain", "__version__"]
@@ -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
 
@@ -31,8 +31,19 @@ For files with multiple datasets (HDF5, NPZ, MAT), a GUI selector will automatic
31
31
  )
32
32
  parser.add_argument('files', type=str, nargs='+',
33
33
  help='Path(s) to data files or DICOM directories')
34
+ parser.add_argument('--mask', type=str, default=None,
35
+ help='Optional mask volume to overlay on a single main file')
34
36
 
35
37
  args = parser.parse_args()
38
+
39
+ mask_path = None
40
+ if args.mask is not None:
41
+ if len(args.files) != 1:
42
+ parser.error('--mask requires exactly one main file')
43
+
44
+ mask_path = Path(args.mask)
45
+ if not mask_path.exists():
46
+ parser.error(f'Mask file not found: {mask_path}')
36
47
 
37
48
  for file_arg in args.files:
38
49
  filepath = Path(file_arg)
@@ -50,16 +61,29 @@ For files with multiple datasets (HDF5, NPZ, MAT), a GUI selector will automatic
50
61
  detected_format = loaded.metadata.get('detected_format')
51
62
  if detected_format:
52
63
  title = f"{title} [{detected_format}]"
53
- ndslice(data=loaded.data, title=title, block=False, filepath=filepath)
64
+ mask_data = load_path(mask_path).data if mask_path is not None else None
65
+ ndslice(data=loaded.data, title=title, block=False, filepath=filepath,
66
+ dim_labels=loaded.metadata.get('dim_labels'),
67
+ voxel_spacing=loaded.metadata.get('voxel_spacing'),
68
+ mask=mask_data)
54
69
  continue
55
70
 
56
71
  # Multi-dataset formats - use selectors
57
72
  selector = None
58
73
  if suffix in ['.h5', '.hdf5']:
59
- selector = H5DatasetSelector(filepath)
74
+ if mask_path is not None:
75
+ print("--mask is not supported for multi-dataset main files yet")
76
+ continue
77
+ selector = h5_selector_for_path(filepath)
60
78
  elif suffix == '.npz':
79
+ if mask_path is not None:
80
+ print("--mask is not supported for multi-dataset main files yet")
81
+ continue
61
82
  selector = NpzDatasetSelector(filepath)
62
83
  elif suffix == '.mat':
84
+ if mask_path is not None:
85
+ print("--mask is not supported for multi-dataset main files yet")
86
+ continue
63
87
  selector = MatDatasetSelector(filepath)
64
88
  else:
65
89
  print(f"Unsupported file type: {suffix}. Supported types: directories with DICOM .dcm files, .h5, .hdf5, .npy, .npz, .mat, .REC, .cfl, .dcm, .nii, .nii.gz, .txt")
@@ -77,4 +101,4 @@ For files with multiple datasets (HDF5, NPZ, MAT), a GUI selector will automatic
77
101
 
78
102
 
79
103
  if __name__ == '__main__':
80
- main()
104
+ main()
@@ -0,0 +1,205 @@
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
+ DEFAULT_MASK_OPACITY = 0.8
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class ViewerConfig:
52
+ initial_index: str = INITIAL_INDEX_FIRST
53
+ default_slice: str = DEFAULT_SLICE_FIRST
54
+ initial_origin: str = DEFAULT_ORIGIN
55
+ default_channel: str = DEFAULT_CHANNEL
56
+ default_colormap: str = DEFAULT_COLORMAP
57
+ angle_colormap: str = ANGLE_COLORMAP_SAME
58
+ default_display_mode: str = DEFAULT_DISPLAY_MODE
59
+ default_mask_opacity: float = DEFAULT_MASK_OPACITY
60
+
61
+
62
+ def get_config_path():
63
+ return Path(user_config_dir("ndslice", appauthor=False)) / "config.toml"
64
+
65
+
66
+ def load_config(path=None, colormap_names=None):
67
+ """Load user config, falling back to defaults for any invalid value."""
68
+ config_path = Path(path) if path is not None else get_config_path()
69
+ valid_colormaps = tuple(colormap_names) if colormap_names is not None else None
70
+
71
+ try:
72
+ raw = tomllib.loads(config_path.read_text(encoding="utf-8"))
73
+ except Exception:
74
+ return ViewerConfig()
75
+
76
+ startup = raw.get("startup", {})
77
+ if not isinstance(startup, dict):
78
+ startup = {}
79
+
80
+ display = raw.get("display", {})
81
+ if not isinstance(display, dict):
82
+ display = {}
83
+
84
+ initial_index = startup.get("initial_index", INITIAL_INDEX_FIRST)
85
+ if initial_index not in VALID_INITIAL_INDEX:
86
+ initial_index = INITIAL_INDEX_FIRST
87
+
88
+ default_slice = startup.get("default_slice", DEFAULT_SLICE_FIRST)
89
+ if default_slice not in VALID_DEFAULT_SLICE:
90
+ default_slice = DEFAULT_SLICE_FIRST
91
+
92
+ initial_origin = startup.get("initial_origin", DEFAULT_ORIGIN)
93
+ if initial_origin not in SUPPORTED_ORIGINS:
94
+ initial_origin = DEFAULT_ORIGIN
95
+
96
+ default_channel = display.get("default_channel", DEFAULT_CHANNEL)
97
+ if default_channel not in SUPPORTED_CHANNELS:
98
+ default_channel = DEFAULT_CHANNEL
99
+
100
+ default_colormap = display.get("default_colormap", DEFAULT_COLORMAP)
101
+ if valid_colormaps is not None and default_colormap not in valid_colormaps:
102
+ default_colormap = DEFAULT_COLORMAP
103
+
104
+ angle_colormap = display.get("angle_colormap", ANGLE_COLORMAP_SAME)
105
+ valid_angle_colormaps = (
106
+ (ANGLE_COLORMAP_SAME,) + valid_colormaps
107
+ if valid_colormaps is not None
108
+ else None
109
+ )
110
+ if valid_angle_colormaps is not None and angle_colormap not in valid_angle_colormaps:
111
+ angle_colormap = ANGLE_COLORMAP_SAME
112
+
113
+ default_display_mode = display.get("default_display_mode", DEFAULT_DISPLAY_MODE)
114
+ default_display_mode = DISPLAY_MODE_ALIASES.get(default_display_mode, default_display_mode)
115
+ if default_display_mode not in SUPPORTED_DISPLAY_MODES:
116
+ default_display_mode = DEFAULT_DISPLAY_MODE
117
+
118
+ default_mask_opacity = _clean_mask_opacity(
119
+ display.get("default_mask_opacity", DEFAULT_MASK_OPACITY)
120
+ )
121
+
122
+ return ViewerConfig(
123
+ initial_index=initial_index,
124
+ default_slice=default_slice,
125
+ initial_origin=initial_origin,
126
+ default_channel=default_channel,
127
+ default_colormap=default_colormap,
128
+ angle_colormap=angle_colormap,
129
+ default_display_mode=default_display_mode,
130
+ default_mask_opacity=default_mask_opacity,
131
+ )
132
+
133
+
134
+ def save_config(config, path=None, colormap_names=None):
135
+ """Write the complete viewer config to TOML."""
136
+ config_path = Path(path) if path is not None else get_config_path()
137
+ config_path.parent.mkdir(parents=True, exist_ok=True)
138
+ valid_colormaps = tuple(colormap_names) if colormap_names is not None else None
139
+
140
+ initial_index = config.initial_index
141
+ if initial_index not in VALID_INITIAL_INDEX:
142
+ initial_index = INITIAL_INDEX_FIRST
143
+
144
+ default_slice = config.default_slice
145
+ if default_slice not in VALID_DEFAULT_SLICE:
146
+ default_slice = DEFAULT_SLICE_FIRST
147
+
148
+ initial_origin = config.initial_origin
149
+ if initial_origin not in SUPPORTED_ORIGINS:
150
+ initial_origin = DEFAULT_ORIGIN
151
+
152
+ default_channel = config.default_channel
153
+ if default_channel not in SUPPORTED_CHANNELS:
154
+ default_channel = DEFAULT_CHANNEL
155
+
156
+ default_colormap = config.default_colormap
157
+ if valid_colormaps is not None and default_colormap not in valid_colormaps:
158
+ default_colormap = DEFAULT_COLORMAP
159
+
160
+ angle_colormap = config.angle_colormap
161
+ valid_angle_colormaps = (
162
+ (ANGLE_COLORMAP_SAME,) + valid_colormaps
163
+ if valid_colormaps is not None
164
+ else None
165
+ )
166
+ if valid_angle_colormaps is not None and angle_colormap not in valid_angle_colormaps:
167
+ angle_colormap = ANGLE_COLORMAP_SAME
168
+
169
+ default_display_mode = config.default_display_mode
170
+ default_display_mode = DISPLAY_MODE_ALIASES.get(default_display_mode, default_display_mode)
171
+ if default_display_mode not in SUPPORTED_DISPLAY_MODES:
172
+ default_display_mode = DEFAULT_DISPLAY_MODE
173
+
174
+ default_mask_opacity = _clean_mask_opacity(config.default_mask_opacity)
175
+
176
+ text = (
177
+ "[startup]\n"
178
+ f'initial_index = "{initial_index}"\n'
179
+ f'default_slice = "{default_slice}"\n'
180
+ f'initial_origin = "{initial_origin}"\n'
181
+ "\n"
182
+ "[display]\n"
183
+ f'default_channel = "{default_channel}"\n'
184
+ f'default_colormap = "{default_colormap}"\n'
185
+ f'angle_colormap = "{angle_colormap}"\n'
186
+ f'default_display_mode = "{default_display_mode}"\n'
187
+ f"default_mask_opacity = {default_mask_opacity:.6g}\n"
188
+ )
189
+ config_path.write_text(text, encoding="utf-8")
190
+
191
+
192
+ def update_config(path=None, colormap_names=None, **changes):
193
+ config = replace(load_config(path, colormap_names=colormap_names), **changes)
194
+ save_config(config, path, colormap_names=colormap_names)
195
+ return config
196
+
197
+
198
+ def _clean_mask_opacity(value):
199
+ try:
200
+ opacity = float(value)
201
+ except (TypeError, ValueError):
202
+ return DEFAULT_MASK_OPACITY
203
+ if not 0.0 <= opacity <= 1.0:
204
+ return DEFAULT_MASK_OPACITY
205
+ return opacity
@@ -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