ndslice 0.6.0__tar.gz → 0.7.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,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ndslice
3
- Version: 0.6.0
3
+ Version: 0.7.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
- License: MIT
6
+ License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/henricryden/ndslice
8
8
  Project-URL: Documentation, https://github.com/henricryden/ndslice#readme
9
9
  Project-URL: Bug Tracker, https://github.com/henricryden/ndslice/issues
@@ -11,7 +11,6 @@ Keywords: visualization,numpy,fft,image-viewer,data-visualization
11
11
  Classifier: Development Status :: 4 - Beta
12
12
  Classifier: Intended Audience :: Developers
13
13
  Classifier: Intended Audience :: Science/Research
14
- Classifier: License :: OSI Approved :: MIT License
15
14
  Classifier: Programming Language :: Python :: 3
16
15
  Classifier: Programming Language :: Python :: 3.8
17
16
  Classifier: Programming Language :: Python :: 3.9
@@ -27,31 +26,18 @@ License-File: LICENSE
27
26
  Requires-Dist: numpy>=1.20.0
28
27
  Requires-Dist: pyqtgraph>=0.14.0
29
28
  Requires-Dist: PyQt5>=5.15.0
29
+ Requires-Dist: h5py>=3.0.0
30
+ Requires-Dist: scipy>=1.7.0
31
+ Requires-Dist: pydicom>=2.4.0
32
+ Requires-Dist: nibabel>=4.0.0
33
+ Requires-Dist: imageio>=2.9.0
34
+ Requires-Dist: imageio-ffmpeg>=0.4.2
35
+ Requires-Dist: Pillow>=8.0.0
30
36
  Provides-Extra: pyqt6
31
37
  Requires-Dist: PyQt6>=6.4.0; extra == "pyqt6"
32
38
  Provides-Extra: dev
33
39
  Requires-Dist: build>=0.10.0; extra == "dev"
34
40
  Requires-Dist: twine>=4.0.0; extra == "dev"
35
- Provides-Extra: hdf5
36
- Requires-Dist: h5py>=3.0.0; extra == "hdf5"
37
- Provides-Extra: matlab
38
- Requires-Dist: scipy>=1.7.0; extra == "matlab"
39
- Provides-Extra: dicom
40
- Requires-Dist: pydicom>=2.4.0; extra == "dicom"
41
- Provides-Extra: nifti
42
- Requires-Dist: nibabel>=4.0.0; extra == "nifti"
43
- Provides-Extra: video-export
44
- Requires-Dist: imageio>=2.9.0; extra == "video-export"
45
- Requires-Dist: imageio-ffmpeg>=0.4.2; extra == "video-export"
46
- Requires-Dist: Pillow>=8.0.0; extra == "video-export"
47
- Provides-Extra: all
48
- Requires-Dist: h5py>=3.0.0; extra == "all"
49
- Requires-Dist: scipy>=1.7.0; extra == "all"
50
- Requires-Dist: pydicom>=2.4.0; extra == "all"
51
- Requires-Dist: nibabel>=4.0.0; extra == "all"
52
- Requires-Dist: imageio>=2.9.0; extra == "all"
53
- Requires-Dist: imageio-ffmpeg>=0.4.2; extra == "all"
54
- Requires-Dist: Pillow>=8.0.0; extra == "all"
55
41
  Dynamic: license-file
56
42
 
57
43
  [![Python versions](https://img.shields.io/pypi/pyversions/ndslice.svg)](https://pypi.org/project/ndslice/)
@@ -147,6 +133,9 @@ ndslice(data2) # Shown after first closes
147
133
  ### Command Line
148
134
  ```bash
149
135
  ndslice data.npy # Numpy file
136
+ ndslice image.nii.gz
137
+ ndslice image.dcm
138
+ ndslice some_dicom_dir/ # Automatically attemps to form an nd-array from DICOM the files
150
139
  ndslice --help # Show all options
151
140
  ```
152
141
 
@@ -160,10 +149,13 @@ ndslice has CLI support and can conveniently display:
160
149
  | [BART](https://mrirecon.github.io/bart/) | `.cfl` + `.hdr` | — |
161
150
  | Philips REC | `.REC` + `.xml` | — |
162
151
  | [NIfTI](https://nifti.nimh.nih.gov/) | `.nii`, `.nii.gz` | nibabel |
163
- | DICOM (pixel array) | `.dcm` | pydicom |
152
+ | DICOM file | `.dcm` | pydicom |
153
+ | DICOM directory | directory containing `.dcm` files | pydicom, nibabel, `dcm2niix` on `PATH` |
164
154
 
165
155
  HDF5 files can be compound complex dtype, or real/imag fields.
166
156
 
157
+ For DICOM directories, ndslice does not infer series dimensions itself. It runs `dcm2niix`, then loads the produced NIfTI volume.
158
+
167
159
  If there are multiple datasets in the file, a selection GUI appears which highlights arrays supported by ndslice (essentially numeric).
168
160
  Double click to open.
169
161
 
@@ -175,11 +167,19 @@ Double click to open.
175
167
  ### From PyPI
176
168
 
177
169
  ```bash
178
- pip install ndslice
179
- pip install ndslice[video_export] # Include video export dependencies (imageio, Pillow, imageio-ffmpeg)
180
- pip install ndslice[all] # includes all optional dependencies (video export and file formats)
170
+ pip install ndslice
171
+ # Or, if you want just the package without pulling in dependencies:
172
+ pip install --no-deps ndslice
173
+ ```
174
+
175
+ For DICOM directories you also need the external `dcm2niix` binary available on `PATH`. A practical install route is:
176
+
177
+ ```bash
178
+ conda install -c conda-forge dcm2niix
181
179
  ```
182
180
 
181
+ If `dcm2niix` is missing or conversion fails, ndslice reports a clear error instead of trying to infer the series layout itself.
182
+
183
183
  ### From source
184
184
 
185
185
  ```bash
@@ -199,7 +199,13 @@ pip install -e .
199
199
  - NumPy >= 1.20.0
200
200
  - PyQtGraph >= 0.12.0
201
201
  - PyQt5 >= 5.15.0
202
- - h5py >= 3.0.0 (for HDF5 file support in CLI)
202
+ - h5py >= 3.0.0
203
+ - scipy >= 1.7.0
204
+ - pydicom >= 2.4.0
205
+ - nibabel >= 4.0.0
206
+ - imageio >= 2.9.0
207
+ - imageio-ffmpeg >= 0.4.2
208
+ - Pillow >= 8.0.0
203
209
 
204
210
  ## License
205
211
 
@@ -91,6 +91,9 @@ ndslice(data2) # Shown after first closes
91
91
  ### Command Line
92
92
  ```bash
93
93
  ndslice data.npy # Numpy file
94
+ ndslice image.nii.gz
95
+ ndslice image.dcm
96
+ ndslice some_dicom_dir/ # Automatically attemps to form an nd-array from DICOM the files
94
97
  ndslice --help # Show all options
95
98
  ```
96
99
 
@@ -104,10 +107,13 @@ ndslice has CLI support and can conveniently display:
104
107
  | [BART](https://mrirecon.github.io/bart/) | `.cfl` + `.hdr` | — |
105
108
  | Philips REC | `.REC` + `.xml` | — |
106
109
  | [NIfTI](https://nifti.nimh.nih.gov/) | `.nii`, `.nii.gz` | nibabel |
107
- | DICOM (pixel array) | `.dcm` | pydicom |
110
+ | DICOM file | `.dcm` | pydicom |
111
+ | DICOM directory | directory containing `.dcm` files | pydicom, nibabel, `dcm2niix` on `PATH` |
108
112
 
109
113
  HDF5 files can be compound complex dtype, or real/imag fields.
110
114
 
115
+ For DICOM directories, ndslice does not infer series dimensions itself. It runs `dcm2niix`, then loads the produced NIfTI volume.
116
+
111
117
  If there are multiple datasets in the file, a selection GUI appears which highlights arrays supported by ndslice (essentially numeric).
112
118
  Double click to open.
113
119
 
@@ -119,11 +125,19 @@ Double click to open.
119
125
  ### From PyPI
120
126
 
121
127
  ```bash
122
- pip install ndslice
123
- pip install ndslice[video_export] # Include video export dependencies (imageio, Pillow, imageio-ffmpeg)
124
- pip install ndslice[all] # includes all optional dependencies (video export and file formats)
128
+ pip install ndslice
129
+ # Or, if you want just the package without pulling in dependencies:
130
+ pip install --no-deps ndslice
131
+ ```
132
+
133
+ For DICOM directories you also need the external `dcm2niix` binary available on `PATH`. A practical install route is:
134
+
135
+ ```bash
136
+ conda install -c conda-forge dcm2niix
125
137
  ```
126
138
 
139
+ If `dcm2niix` is missing or conversion fails, ndslice reports a clear error instead of trying to infer the series layout itself.
140
+
127
141
  ### From source
128
142
 
129
143
  ```bash
@@ -143,7 +157,13 @@ pip install -e .
143
157
  - NumPy >= 1.20.0
144
158
  - PyQtGraph >= 0.12.0
145
159
  - PyQt5 >= 5.15.0
146
- - h5py >= 3.0.0 (for HDF5 file support in CLI)
160
+ - h5py >= 3.0.0
161
+ - scipy >= 1.7.0
162
+ - pydicom >= 2.4.0
163
+ - nibabel >= 4.0.0
164
+ - imageio >= 2.9.0
165
+ - imageio-ffmpeg >= 0.4.2
166
+ - Pillow >= 8.0.0
147
167
 
148
168
  ## License
149
169
 
@@ -7,7 +7,7 @@ import numpy as np
7
7
  from pathlib import Path
8
8
  from .ndslice import ndslice
9
9
  from .selectors import H5DatasetSelector, NpzDatasetSelector, MatDatasetSelector
10
- from .file_interpreters import load_file
10
+ from .file_interpreters import load_path
11
11
 
12
12
 
13
13
  def main():
@@ -21,6 +21,7 @@ Examples:
21
21
  ndslice data.h5 data2.npy data3.npz # View multiple files
22
22
  ndslice scan.REC # View Philips REC/XML pair
23
23
  ndslice ref.cfl # View BART CFL/HDR pair
24
+ ndslice dicomdir/ # Convert DICOM directory via dcm2niix, then view
24
25
  ndslice scan.dcm # View DICOM file
25
26
  ndslice scan.nii # View NIfTI file
26
27
  ndslice data.txt # View text file with numeric data
@@ -29,7 +30,7 @@ For files with multiple datasets (HDF5, NPZ, MAT), a GUI selector will automatic
29
30
  """
30
31
  )
31
32
  parser.add_argument('files', type=str, nargs='+',
32
- help='Path(s) to data file(s) (.h5, .npy, .mat, ...)')
33
+ help='Path(s) to data files or DICOM directories')
33
34
 
34
35
  args = parser.parse_args()
35
36
 
@@ -42,10 +43,14 @@ For files with multiple datasets (HDF5, NPZ, MAT), a GUI selector will automatic
42
43
 
43
44
  try:
44
45
  suffix = ''.join(filepath.suffixes).lower()
45
- # Single-dataset formats is handled by file_interpreters.load_file
46
- if suffix in ['.npy', '.rec', '.cfl', '.dcm', '.nii', '.nii.gz', '.txt']:
47
- data = load_file(filepath)
48
- ndslice(data=data, title=filepath.name, block=False, filepath=filepath)
46
+ # Single-dataset formats and DICOM directories are handled by file_interpreters.load_path
47
+ if filepath.is_dir() or suffix in ['.npy', '.rec', '.cfl', '.dcm', '.nii', '.nii.gz', '.txt']:
48
+ loaded = load_path(filepath)
49
+ title = filepath.name or str(filepath)
50
+ detected_format = loaded.metadata.get('detected_format')
51
+ if detected_format:
52
+ title = f"{title} [{detected_format}]"
53
+ ndslice(data=loaded.data, title=title, block=False, filepath=filepath)
49
54
  continue
50
55
 
51
56
  # Multi-dataset formats - use selectors
@@ -57,7 +62,7 @@ For files with multiple datasets (HDF5, NPZ, MAT), a GUI selector will automatic
57
62
  elif suffix == '.mat':
58
63
  selector = MatDatasetSelector(filepath)
59
64
  else:
60
- print(f"Unsupported file type: {suffix}. Supported types: .h5, .hdf5, .npy, .npz, .mat, .REC, .cfl, .dcm, .nii, .txt")
65
+ 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")
61
66
  continue
62
67
 
63
68
  # Select and view dataset (shows GUI if multiple datasets)
@@ -1,7 +1,17 @@
1
1
  import xml.etree.ElementTree as ET
2
2
  import numpy as np
3
3
  import struct
4
+ import subprocess
5
+ import tempfile
6
+ from dataclasses import dataclass
4
7
  from pathlib import Path
8
+ import shutil
9
+
10
+
11
+ @dataclass
12
+ class LoadedPath:
13
+ data: np.ndarray
14
+ metadata: dict
5
15
 
6
16
 
7
17
  def remove_trailing_singletons(data):
@@ -233,6 +243,10 @@ class BartLoader:
233
243
  class DicomLoader:
234
244
  def __init__(self, dcm_path):
235
245
  self.dcm_path = Path(dcm_path)
246
+ self.metadata = {
247
+ 'source_path': str(self.dcm_path),
248
+ 'detected_format': 'dicom_file',
249
+ }
236
250
 
237
251
  if not self.dcm_path.exists():
238
252
  raise FileNotFoundError(f"DICOM file not found: {self.dcm_path}")
@@ -247,12 +261,195 @@ class DicomLoader:
247
261
  )
248
262
 
249
263
  dcm = pydicom.dcmread(self.dcm_path)
250
- return remove_trailing_singletons(dcm.pixel_array)
264
+ if not hasattr(dcm, 'pixel_array'):
265
+ raise ValueError(
266
+ f"DICOM file does not contain pixel data: {self.dcm_path}"
267
+ )
268
+
269
+ if getattr(dcm, 'SamplesPerPixel', 1) != 1:
270
+ raise ValueError(
271
+ "Single-file DICOM loading currently supports grayscale pixel data only. "
272
+ f"Unsupported SamplesPerPixel={dcm.SamplesPerPixel} for {self.dcm_path}"
273
+ )
274
+
275
+ data = remove_trailing_singletons(np.asarray(dcm.pixel_array))
276
+ self.metadata.update({
277
+ 'shape': tuple(data.shape),
278
+ 'dtype': str(data.dtype),
279
+ 'dicom_metadata': {
280
+ 'patient_name': str(getattr(dcm, 'PatientName', '')),
281
+ 'series_description': str(getattr(dcm, 'SeriesDescription', '')),
282
+ 'modality': str(getattr(dcm, 'Modality', '')),
283
+ 'pixel_spacing': list(getattr(dcm, 'PixelSpacing', [])) if hasattr(dcm, 'PixelSpacing') else None,
284
+ 'image_position_patient': list(getattr(dcm, 'ImagePositionPatient', [])) if hasattr(dcm, 'ImagePositionPatient') else None,
285
+ },
286
+ })
287
+ return data
288
+
289
+
290
+ def _find_dicom_files(directory_path):
291
+ directory_path = Path(directory_path)
292
+ return sorted(
293
+ path for path in directory_path.rglob('*')
294
+ if path.is_file() and path.suffix.lower() == '.dcm'
295
+ )
296
+
297
+
298
+ def _json_sidecar_for_nifti(nifti_path):
299
+ nifti_path = Path(nifti_path)
300
+ json_path = nifti_path.with_suffix('.json')
301
+ if nifti_path.suffix == '.gz':
302
+ json_path = nifti_path.with_suffix('').with_suffix('.json')
303
+ return json_path if json_path.exists() else None
304
+
305
+
306
+ def _pick_converted_niftis(output_dir):
307
+ output_dir = Path(output_dir)
308
+ nifti_paths = sorted(output_dir.glob('*.nii')) + sorted(output_dir.glob('*.nii.gz'))
309
+
310
+ if not nifti_paths:
311
+ raise ValueError("dcm2niix did not produce a NIfTI file")
312
+
313
+ return [(nifti_path, _json_sidecar_for_nifti(nifti_path)) for nifti_path in nifti_paths]
314
+
315
+
316
+ def _read_json_sidecar(json_path):
317
+ if json_path is None:
318
+ return {}
319
+
320
+ import json
321
+
322
+ with open(json_path, 'r') as json_file:
323
+ return json.load(json_file)
324
+
325
+
326
+ def _describe_stacking_axis(sidecar_metadata):
327
+ axis_candidates = [
328
+ ('EchoNumber', 'echoes'),
329
+ ('FlipAngle', 'flip angles'),
330
+ ('DiffusionBValue', 'b-values'),
331
+ ('SeriesNumber', 'series'),
332
+ ('AcquisitionNumber', 'acquisitions'),
333
+ ]
334
+
335
+ for key, label in axis_candidates:
336
+ values = [metadata.get(key) for metadata in sidecar_metadata]
337
+ if any(value is not None for value in values) and len(set(values)) > 1:
338
+ return label, key, values
339
+
340
+ names = [metadata.get('ProtocolName') or metadata.get('SeriesDescription') for metadata in sidecar_metadata]
341
+ if any(name for name in names) and len(set(names)) > 1:
342
+ return 'series', 'SeriesDescription', names
343
+
344
+ return 'series', None, None
345
+
346
+
347
+ def _run_dcm2niix(directory_path, output_dir):
348
+ dcm2niix_path = shutil.which('dcm2niix')
349
+ if dcm2niix_path is None:
350
+ raise RuntimeError(
351
+ "dcm2niix is required to load DICOM directories. Install it, for example, with "
352
+ "'conda install -c conda-forge dcm2niix', and ensure it is on PATH."
353
+ )
354
+
355
+ command = [
356
+ dcm2niix_path,
357
+ '-b', 'y',
358
+ '-z', 'n',
359
+ '-f', 'ndslice_%s',
360
+ '-o', str(output_dir),
361
+ str(directory_path),
362
+ ]
363
+ completed = subprocess.run(command, capture_output=True)
364
+ if completed.returncode != 0:
365
+ stdout = completed.stdout.decode('utf-8', errors='replace').strip()
366
+ stderr = completed.stderr.decode('utf-8', errors='replace').strip()
367
+ error_output = '\n'.join(part for part in [stdout, stderr] if part)
368
+ raise RuntimeError(
369
+ "dcm2niix failed to convert the DICOM directory. "
370
+ f"Input: {directory_path}\n{error_output}"
371
+ )
372
+
373
+ return _pick_converted_niftis(output_dir)
374
+
375
+
376
+ class DicomDirectoryLoader:
377
+ def __init__(self, directory_path):
378
+ self.directory_path = Path(directory_path)
379
+ self.metadata = {
380
+ 'source_path': str(self.directory_path),
381
+ 'detected_format': 'dicom_directory',
382
+ }
383
+
384
+ if not self.directory_path.exists():
385
+ raise FileNotFoundError(f"DICOM directory not found: {self.directory_path}")
386
+ if not self.directory_path.is_dir():
387
+ raise NotADirectoryError(f"Expected a directory, got: {self.directory_path}")
388
+
389
+ def load(self):
390
+ dicom_files = _find_dicom_files(self.directory_path)
391
+ if not dicom_files:
392
+ raise ValueError(
393
+ f"No DICOM files with suffix .dcm were found under directory: {self.directory_path}"
394
+ )
395
+
396
+ with tempfile.TemporaryDirectory(prefix='ndslice_dicom_') as temp_dir:
397
+ nifti_outputs = _run_dcm2niix(self.directory_path, temp_dir)
398
+ arrays = []
399
+ nifti_paths = []
400
+ json_paths = []
401
+ sidecar_metadata = []
402
+
403
+ for nifti_path, json_path in nifti_outputs:
404
+ nifti_loader = NiftiLoader(nifti_path)
405
+ arrays.append(nifti_loader.load())
406
+ nifti_paths.append(str(nifti_path))
407
+ json_paths.append(str(json_path) if json_path else None)
408
+ sidecar_metadata.append(_read_json_sidecar(json_path))
409
+
410
+ if len(arrays) == 1:
411
+ data = arrays[0]
412
+ stacking_label = None
413
+ stacking_key = None
414
+ stacking_values = None
415
+ else:
416
+ shapes = [tuple(array.shape) for array in arrays]
417
+ if len(set(shapes)) != 1:
418
+ paths = ', '.join(Path(path).name for path in nifti_paths)
419
+ raise ValueError(
420
+ "dcm2niix produced multiple NIfTI files with different shapes, so ndslice "
421
+ f"cannot stack them automatically. Outputs: {paths}"
422
+ )
423
+
424
+ stacking_label, stacking_key, stacking_values = _describe_stacking_axis(sidecar_metadata)
425
+ print(f"Dataset stacked automatically along new dimension: {stacking_label}")
426
+ if stacking_key is not None and stacking_values is not None:
427
+ print(f" {stacking_key}: {stacking_values}")
428
+ if stacking_key == 'SeriesNumber':
429
+ series_descriptions = [metadata.get('SeriesDescription') for metadata in sidecar_metadata]
430
+ if any(description is not None for description in series_descriptions):
431
+ print(f" SeriesDescription: {series_descriptions}")
432
+ data = np.stack(arrays, axis=-1)
433
+
434
+ self.metadata.update({
435
+ 'shape': tuple(data.shape),
436
+ 'dtype': str(data.dtype),
437
+ 'nifti_output_path': nifti_paths[0] if len(nifti_paths) == 1 else nifti_paths,
438
+ 'sidecar_json_path': json_paths[0] if len(json_paths) == 1 else json_paths,
439
+ 'stacked_dimension': stacking_label,
440
+ 'stacked_dimension_key': stacking_key,
441
+ 'stacked_dimension_values': stacking_values,
442
+ })
443
+ return data
251
444
 
252
445
 
253
446
  class NiftiLoader:
254
447
  def __init__(self, file_path):
255
448
  self.file_path = Path(file_path)
449
+ self.metadata = {
450
+ 'source_path': str(self.file_path),
451
+ 'detected_format': 'nifti',
452
+ }
256
453
 
257
454
  if not self.file_path.exists():
258
455
  raise FileNotFoundError(f"NIfTI file not found: {self.file_path}")
@@ -267,12 +464,21 @@ class NiftiLoader:
267
464
  )
268
465
 
269
466
  data = nib.load(self.file_path).get_fdata()
270
- return remove_trailing_singletons(data)
467
+ data = remove_trailing_singletons(data)
468
+ self.metadata.update({
469
+ 'shape': tuple(data.shape),
470
+ 'dtype': str(data.dtype),
471
+ })
472
+ return data
271
473
 
272
474
 
273
475
  class TextLoader:
274
476
  def __init__(self, file_path):
275
477
  self.file_path = Path(file_path)
478
+ self.metadata = {
479
+ 'source_path': str(self.file_path),
480
+ 'detected_format': 'text',
481
+ }
276
482
 
277
483
  if not self.file_path.exists():
278
484
  raise FileNotFoundError(f"Text file not found: {self.file_path}")
@@ -318,7 +524,58 @@ class TextLoader:
318
524
  # Different row lengths - flatten to 1D
319
525
  data = np.array([val for row in numeric_lines for val in row], dtype=np.float32)
320
526
 
321
- return remove_trailing_singletons(data)
527
+ data = remove_trailing_singletons(data)
528
+ self.metadata.update({
529
+ 'shape': tuple(data.shape),
530
+ 'dtype': str(data.dtype),
531
+ })
532
+ return data
533
+
534
+
535
+ def load_path(filepath):
536
+ filepath = Path(filepath)
537
+
538
+ if filepath.is_dir():
539
+ loader = DicomDirectoryLoader(filepath)
540
+ data = loader.load()
541
+ return LoadedPath(data=data, metadata=loader.metadata)
542
+
543
+ suffix = ''.join(filepath.suffixes).lower()
544
+
545
+ if suffix == '.npy':
546
+ data = np.load(filepath)
547
+ return LoadedPath(
548
+ data=data,
549
+ metadata={
550
+ 'source_path': str(filepath),
551
+ 'detected_format': 'numpy',
552
+ 'shape': tuple(data.shape),
553
+ 'dtype': str(data.dtype),
554
+ },
555
+ )
556
+
557
+ if suffix == '.rec':
558
+ loader = PhilipsRECLoader(filepath)
559
+ elif suffix == '.cfl':
560
+ loader = BartLoader(filepath)
561
+ elif suffix == '.dcm':
562
+ loader = DicomLoader(filepath)
563
+ elif suffix in ['.nii', '.nii.gz']:
564
+ loader = NiftiLoader(filepath)
565
+ elif suffix == '.txt':
566
+ loader = TextLoader(filepath)
567
+ else:
568
+ raise ValueError(f"Unsupported file format: {suffix}")
569
+
570
+ data = loader.load()
571
+ metadata = {
572
+ 'source_path': str(filepath),
573
+ 'detected_format': suffix.lstrip('.'),
574
+ 'shape': tuple(data.shape),
575
+ 'dtype': str(data.dtype),
576
+ }
577
+ metadata.update(getattr(loader, 'metadata', {}))
578
+ return LoadedPath(data=data, metadata=metadata)
322
579
 
323
580
 
324
581
 
@@ -326,9 +583,10 @@ class TextLoader:
326
583
 
327
584
  def load_file(filepath):
328
585
  """
329
- Generic file loader - automatically detects format and returns NumPy array.
586
+ Generic path loader - automatically detects format and returns a NumPy array.
330
587
 
331
588
  Supported formats:
589
+ - directory: DICOM directory via dcm2niix
332
590
  - .npy: NumPy binary format
333
591
  - .REC: Philips XLM+REC (requires xml file)
334
592
  - .cfl: BART format (requires hdr file)
@@ -337,36 +595,10 @@ def load_file(filepath):
337
595
  - .txt: Simple text files with numeric data
338
596
 
339
597
  Args:
340
- filepath: Path to data file
598
+ filepath: Path to data file or DICOM directory
341
599
 
342
600
  Returns:
343
601
  NumPy array
344
602
 
345
603
  """
346
- filepath = Path(filepath)
347
- suffix = ''.join(Path(filepath).suffixes).lower()
348
-
349
- if suffix == '.npy':
350
- return np.load(filepath)
351
-
352
- elif suffix == '.rec':
353
- loader = PhilipsRECLoader(filepath)
354
- return loader.load()
355
-
356
- elif suffix == '.cfl':
357
- loader = BartLoader(filepath)
358
- return loader.load()
359
-
360
- elif suffix == '.dcm':
361
- loader = DicomLoader(filepath)
362
- return loader.load()
363
- elif suffix in ['.nii', '.nii.gz']:
364
- loader = NiftiLoader(filepath)
365
- return loader.load()
366
-
367
- elif suffix == '.txt':
368
- loader = TextLoader(filepath)
369
- return loader.load()
370
-
371
- else:
372
- raise ValueError(f"Unsupported file format: {suffix}")
604
+ return load_path(filepath).data
@@ -4,7 +4,9 @@ import pyqtgraph.Qt as Qt
4
4
  from pyqtgraph.Qt import QtWidgets, QtGui
5
5
  import os
6
6
  import math
7
+ import platform
7
8
  from enum import Enum
9
+ from pathlib import Path
8
10
  from .imageview2d import ImageView2D
9
11
  from .video_export import VideoExportWorker, VideoExportDialog, VideoExportSettingsDialog
10
12
  import multiprocessing as mp
@@ -26,6 +28,292 @@ class Domain(Enum):
26
28
  NATIVE=0
27
29
  FOURIER=1
28
30
 
31
+
32
+ QT_SIGNAL = getattr(Qt.QtCore, 'Signal', getattr(Qt.QtCore, 'pyqtSignal'))
33
+
34
+
35
+ class RangeSlider(QtWidgets.QWidget):
36
+ """A minimal horizontal two-handle slider for inclusive integer ranges."""
37
+
38
+ valuesChanged = QT_SIGNAL(int, int)
39
+
40
+ def __init__(self, parent=None, minimum=0, maximum=0):
41
+ super().__init__(parent)
42
+ self._minimum = int(minimum)
43
+ self._maximum = max(int(minimum), int(maximum))
44
+ self._lower_value = self._minimum
45
+ self._upper_value = self._maximum
46
+ self._active_handle = None
47
+ self.setMouseTracking(True)
48
+ self.setMinimumHeight(30)
49
+
50
+ def sizeHint(self):
51
+ return Qt.QtCore.QSize(240, 30)
52
+
53
+ def values(self):
54
+ return self._lower_value, self._upper_value
55
+
56
+ def setValues(self, lower_value, upper_value):
57
+ lower_value = max(self._minimum, min(int(lower_value), self._maximum))
58
+ upper_value = max(self._minimum, min(int(upper_value), self._maximum))
59
+ if lower_value > upper_value:
60
+ lower_value, upper_value = upper_value, lower_value
61
+
62
+ if (lower_value, upper_value) == (self._lower_value, self._upper_value):
63
+ return
64
+
65
+ self._lower_value = lower_value
66
+ self._upper_value = upper_value
67
+ self.valuesChanged.emit(self._lower_value, self._upper_value)
68
+ self.update()
69
+
70
+ def paintEvent(self, event):
71
+ del event
72
+ painter = QtGui.QPainter(self)
73
+ painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
74
+
75
+ palette = self.palette()
76
+ groove_rect = self._groove_rect()
77
+ lower_center = self._handle_center(self._lower_value)
78
+ upper_center = self._handle_center(self._upper_value)
79
+
80
+ painter.setPen(Qt.QtCore.Qt.PenStyle.NoPen)
81
+ painter.setBrush(palette.midlight())
82
+ painter.drawRoundedRect(groove_rect, 3, 3)
83
+
84
+ selected_rect = Qt.QtCore.QRectF(
85
+ lower_center.x(),
86
+ groove_rect.top(),
87
+ max(upper_center.x() - lower_center.x(), 1),
88
+ groove_rect.height(),
89
+ )
90
+ painter.setBrush(palette.highlight())
91
+ painter.drawRoundedRect(selected_rect, 3, 3)
92
+
93
+ self._paint_handle(painter, lower_center, self._active_handle == 'lower')
94
+ self._paint_handle(painter, upper_center, self._active_handle == 'upper')
95
+
96
+ def mousePressEvent(self, event):
97
+ if event.button() != Qt.QtCore.Qt.MouseButton.LeftButton:
98
+ event.ignore()
99
+ return
100
+
101
+ point = self._event_point(event)
102
+ self._active_handle = self._closest_handle(point)
103
+ self._move_active_handle(point.x())
104
+ event.accept()
105
+
106
+ def mouseMoveEvent(self, event):
107
+ point = self._event_point(event)
108
+ if self._active_handle is not None:
109
+ self._move_active_handle(point.x())
110
+ event.accept()
111
+ return
112
+
113
+ self.setCursor(
114
+ QtGui.QCursor(
115
+ Qt.QtCore.Qt.CursorShape.SizeHorCursor if self._is_over_handle(point) else Qt.QtCore.Qt.CursorShape.ArrowCursor
116
+ )
117
+ )
118
+ event.ignore()
119
+
120
+ def mouseReleaseEvent(self, event):
121
+ if event.button() == Qt.QtCore.Qt.MouseButton.LeftButton:
122
+ self._active_handle = None
123
+ self.update()
124
+ event.accept()
125
+
126
+ def leaveEvent(self, event):
127
+ self.setCursor(QtGui.QCursor(Qt.QtCore.Qt.CursorShape.ArrowCursor))
128
+ super().leaveEvent(event)
129
+
130
+ def _event_point(self, event):
131
+ if hasattr(event, 'position'):
132
+ return event.position()
133
+ if hasattr(event, 'localPos'):
134
+ return event.localPos()
135
+ return Qt.QtCore.QPointF(event.pos())
136
+
137
+ def _handle_radius(self):
138
+ return 8.0
139
+
140
+ def _groove_rect(self):
141
+ margin = self._handle_radius() + 4.0
142
+ return Qt.QtCore.QRectF(
143
+ margin,
144
+ self.height() / 2.0 - 3.0,
145
+ max(self.width() - 2.0 * margin, 1.0),
146
+ 6.0,
147
+ )
148
+
149
+ def _handle_center(self, value):
150
+ groove_rect = self._groove_rect()
151
+ span = max(self._maximum - self._minimum, 1)
152
+ ratio = (value - self._minimum) / span
153
+ return Qt.QtCore.QPointF(groove_rect.left() + groove_rect.width() * ratio, groove_rect.center().y())
154
+
155
+ def _handle_rect(self, value):
156
+ center = self._handle_center(value)
157
+ radius = self._handle_radius()
158
+ return Qt.QtCore.QRectF(center.x() - radius, center.y() - radius, radius * 2.0, radius * 2.0)
159
+
160
+ def _paint_handle(self, painter, center, active):
161
+ radius = self._handle_radius()
162
+ palette = self.palette()
163
+ rect = Qt.QtCore.QRectF(center.x() - radius, center.y() - radius, radius * 2.0, radius * 2.0)
164
+ painter.setPen(QtGui.QPen(palette.shadow().color(), 1.0))
165
+ painter.setBrush(palette.highlight() if active else palette.button())
166
+ painter.drawEllipse(rect)
167
+
168
+ inner_rect = rect.adjusted(3.0, 3.0, -3.0, -3.0)
169
+ painter.setPen(Qt.QtCore.Qt.PenStyle.NoPen)
170
+ painter.setBrush(palette.base())
171
+ painter.drawEllipse(inner_rect)
172
+
173
+ def _closest_handle(self, point):
174
+ lower_distance = abs(point.x() - self._handle_center(self._lower_value).x())
175
+ upper_distance = abs(point.x() - self._handle_center(self._upper_value).x())
176
+ return 'lower' if lower_distance <= upper_distance else 'upper'
177
+
178
+ def _is_over_handle(self, point):
179
+ expanded_lower = self._handle_rect(self._lower_value).adjusted(-4, -4, 4, 4)
180
+ expanded_upper = self._handle_rect(self._upper_value).adjusted(-4, -4, 4, 4)
181
+ return expanded_lower.contains(point) or expanded_upper.contains(point)
182
+
183
+ def _value_from_position(self, position_x):
184
+ groove_rect = self._groove_rect()
185
+ clamped_x = min(max(position_x, groove_rect.left()), groove_rect.right())
186
+ ratio = (clamped_x - groove_rect.left()) / max(groove_rect.width(), 1.0)
187
+ return int(round(self._minimum + ratio * (self._maximum - self._minimum)))
188
+
189
+ def _move_active_handle(self, position_x):
190
+ value = self._value_from_position(position_x)
191
+ if self._active_handle == 'lower':
192
+ self.setValues(min(value, self._upper_value), self._upper_value)
193
+ elif self._active_handle == 'upper':
194
+ self.setValues(self._lower_value, max(value, self._lower_value))
195
+
196
+
197
+ class SaveRangeDialog(QtWidgets.QDialog):
198
+ """Dialog for selecting an inclusive index range along each dimension."""
199
+
200
+ def __init__(self, parent, data_shape):
201
+ super().__init__(parent)
202
+ self.setWindowTitle('Save as NumPy')
203
+ self._controls = []
204
+ self._data_shape = tuple(data_shape)
205
+
206
+ layout = QtWidgets.QVBoxLayout(self)
207
+ layout.setContentsMargins(12, 12, 12, 12)
208
+ layout.setSpacing(10)
209
+
210
+ scroll_area = QtWidgets.QScrollArea()
211
+ scroll_area.setWidgetResizable(True)
212
+ scroll_area.setHorizontalScrollBarPolicy(Qt.QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
213
+ content = QtWidgets.QWidget()
214
+ content_layout = QtWidgets.QVBoxLayout(content)
215
+ content_layout.setContentsMargins(4, 4, 4, 4)
216
+ content_layout.setSpacing(10)
217
+
218
+ for dim, size in enumerate(data_shape):
219
+ max_index = max(0, size - 1)
220
+ row_widget = QtWidgets.QWidget()
221
+ row_layout = QtWidgets.QGridLayout(row_widget)
222
+ row_layout.setContentsMargins(0, 0, 0, 0)
223
+ row_layout.setHorizontalSpacing(8)
224
+ row_layout.setVerticalSpacing(4)
225
+
226
+ dimension_label = QtWidgets.QLabel(f'Dim {dim}')
227
+ dimension_label.setStyleSheet('QLabel { font-size: 9pt; font-weight: bold; }')
228
+ slider = RangeSlider(row_widget, 0, max_index)
229
+
230
+ start_spinbox = QtWidgets.QSpinBox()
231
+ end_spinbox = QtWidgets.QSpinBox()
232
+ for spinbox in (start_spinbox, end_spinbox):
233
+ spinbox.setRange(0, max_index)
234
+ spinbox.setButtonSymbols(QtWidgets.QAbstractSpinBox.ButtonSymbols.NoButtons)
235
+ spinbox.setStyleSheet(NDSliceWindow.SPINBOX_STYLE)
236
+ spinbox.setFixedWidth(70)
237
+
238
+ end_spinbox.setValue(max_index)
239
+ start_spinbox.valueChanged.connect(lambda value, end_box=end_spinbox: end_box.setMinimum(value))
240
+ end_spinbox.valueChanged.connect(lambda value, start_box=start_spinbox: start_box.setMaximum(value))
241
+ start_spinbox.valueChanged.connect(lambda value, slider_widget=slider, end_box=end_spinbox: slider_widget.setValues(value, end_box.value()))
242
+ end_spinbox.valueChanged.connect(lambda value, slider_widget=slider, start_box=start_spinbox: slider_widget.setValues(start_box.value(), value))
243
+ start_spinbox.valueChanged.connect(lambda _value: self._update_output_shape_label())
244
+ end_spinbox.valueChanged.connect(lambda _value: self._update_output_shape_label())
245
+ slider.valuesChanged.connect(lambda lower, upper, start_box=start_spinbox, end_box=end_spinbox: self._sync_spinboxes(start_box, end_box, lower, upper))
246
+
247
+ slider.setValues(0, max_index)
248
+
249
+ row_layout.addWidget(dimension_label, 0, 0)
250
+ row_layout.addWidget(slider, 0, 1, 1, 4)
251
+ row_layout.addWidget(QtWidgets.QLabel('start'), 1, 0)
252
+ row_layout.addWidget(start_spinbox, 1, 1)
253
+ row_layout.addItem(QtWidgets.QSpacerItem(24, 1, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum), 1, 2)
254
+ row_layout.addWidget(QtWidgets.QLabel('end'), 1, 3)
255
+ row_layout.addWidget(end_spinbox, 1, 4)
256
+ row_layout.setColumnStretch(1, 0)
257
+ row_layout.setColumnStretch(2, 1)
258
+ row_layout.setColumnStretch(4, 0)
259
+ content_layout.addWidget(row_widget)
260
+ self._controls.append((slider, start_spinbox, end_spinbox))
261
+
262
+ content_layout.addStretch(1)
263
+
264
+ scroll_area.setWidget(content)
265
+ scroll_area.setMaximumHeight(min(440, 84 * max(len(data_shape), 1)))
266
+ layout.addWidget(scroll_area)
267
+
268
+ footer_layout = QtWidgets.QHBoxLayout()
269
+ footer_layout.setContentsMargins(0, 0, 0, 0)
270
+ footer_layout.setSpacing(12)
271
+ self._squeeze_checkbox = QtWidgets.QCheckBox('Squeeze singleton dimensions')
272
+ self._squeeze_checkbox.setChecked(True)
273
+ self._squeeze_checkbox.toggled.connect(self._update_output_shape_label)
274
+ self._output_shape_label = QtWidgets.QLabel()
275
+ self._output_shape_label.setStyleSheet('QLabel { font-size: 9pt; color: palette(windowText); }')
276
+ footer_layout.addWidget(self._squeeze_checkbox)
277
+ footer_layout.addStretch(1)
278
+ footer_layout.addWidget(self._output_shape_label)
279
+ layout.addLayout(footer_layout)
280
+
281
+ buttons = QtWidgets.QDialogButtonBox(
282
+ QtWidgets.QDialogButtonBox.StandardButton.Save | QtWidgets.QDialogButtonBox.StandardButton.Cancel
283
+ )
284
+ buttons.accepted.connect(self.accept)
285
+ buttons.rejected.connect(self.reject)
286
+ layout.addWidget(buttons)
287
+
288
+ self._update_output_shape_label()
289
+ self.resize(640, min(560, 150 + 84 * len(data_shape)))
290
+
291
+ def _sync_spinboxes(self, start_spinbox, end_spinbox, lower_value, upper_value):
292
+ start_spinbox.blockSignals(True)
293
+ end_spinbox.blockSignals(True)
294
+ start_spinbox.setValue(lower_value)
295
+ end_spinbox.setValue(upper_value)
296
+ start_spinbox.blockSignals(False)
297
+ end_spinbox.blockSignals(False)
298
+ self._update_output_shape_label()
299
+
300
+ def _selected_shape(self):
301
+ selected_shape = [max(end.value() - start.value() + 1, 0) for _, start, end in self._controls]
302
+ if self.should_squeeze():
303
+ squeezed_shape = [size for size in selected_shape if size != 1]
304
+ return squeezed_shape or [1]
305
+ return selected_shape
306
+
307
+ def _update_output_shape_label(self):
308
+ self._output_shape_label.setText(f'Output shape: {self._selected_shape()}')
309
+
310
+ def get_ranges(self):
311
+ """Return Python slice bounds as (start, stop) tuples."""
312
+ return [(start.value(), end.value() + 1) for _, start, end in self._controls]
313
+
314
+ def should_squeeze(self):
315
+ return self._squeeze_checkbox.isChecked()
316
+
29
317
  class NDSliceWindow(QtWidgets.QMainWindow):
30
318
  # Styling constants — use pt (point) units so font sizes are DPI-independent
31
319
  DIMENSION_LABEL_STYLE = "QLabel { font-size: 9pt; padding: 1px; margin: 2px; }"
@@ -34,6 +322,13 @@ class NDSliceWindow(QtWidgets.QMainWindow):
34
322
  SPINBOX_STYLE = "QSpinBox { font-size: 9pt; } QSpinBox:disabled { color: palette(mid); }"
35
323
  RADIO_BUTTON_STYLE = "QRadioButton { font-size: 9pt; }"
36
324
  GROUPBOX_BASE_STYLE = "QGroupBox { font-size: 9pt; font-weight: bold; border: 1px solid palette(mid); border-radius: 3px; margin-top: 1.4ex; padding-top: 3pt; } QGroupBox::title { subcontrol-origin: margin; left: 8px; padding: 0 3px; }"
325
+
326
+ @staticmethod
327
+ def _set_emoji_font(widget):
328
+ if platform.system() == 'Darwin':
329
+ font = widget.font()
330
+ font.setFamily('Apple Color Emoji')
331
+ widget.setFont(font)
37
332
 
38
333
  def __init__(self, data, complex_dim=None, filepath=None, dataset_path=None, selector_class_name=None):
39
334
  super(NDSliceWindow, self).__init__()
@@ -164,12 +459,14 @@ class NDSliceWindow(QtWidgets.QMainWindow):
164
459
  flip_label.mousePressEvent = lambda event, i=i: self.flipAxisClicked(event, i)
165
460
  flip_label.setStyleSheet(self.FLIP_ICON_STYLE)
166
461
  flip_label.setAlignment(Qt.QtCore.Qt.AlignmentFlag.AlignLeft | Qt.QtCore.Qt.AlignmentFlag.AlignVCenter)
462
+ self._set_emoji_font(flip_label)
167
463
 
168
464
  # Set up complex indicator labels with click handlers
169
465
  for i, complex_label in enumerate(self.widgets['labels']['complex']):
170
466
  complex_label.mousePressEvent = lambda event, i=i: self.complexOrRealClicked(event, i)
171
467
  complex_label.setStyleSheet(self.FLIP_ICON_STYLE)
172
468
  complex_label.setAlignment(Qt.QtCore.Qt.AlignmentFlag.AlignRight | Qt.QtCore.Qt.AlignmentFlag.AlignVCenter)
469
+ self._set_emoji_font(complex_label)
173
470
 
174
471
  # Apply compact styling to dimension control widgets
175
472
  for label in self.widgets['labels']['dims']:
@@ -241,6 +538,8 @@ class NDSliceWindow(QtWidgets.QMainWindow):
241
538
  w.valueChanged.connect(self.update)
242
539
 
243
540
  self._setup_export_context_menus()
541
+ self._save_shortcut = QtGui.QShortcut(QtGui.QKeySequence.StandardKey.Save, self)
542
+ self._save_shortcut.activated.connect(self._save_current_numpy_file)
244
543
 
245
544
  # Create a single compact control panel with all radio buttons
246
545
  controls_widget = QtWidgets.QWidget()
@@ -450,6 +749,7 @@ class NDSliceWindow(QtWidgets.QMainWindow):
450
749
  self._reload_btn.setFixedSize(28, 20)
451
750
  self._reload_btn.clicked.connect(self._reload_file)
452
751
  self._reload_btn.setVisible(filepath is not None)
752
+ self._set_emoji_font(self._reload_btn)
453
753
  self.layouts['topUp'].addWidget(self._reload_btn)
454
754
  self.layouts['topUp'].addWidget(self.widgets['labels']['pixelValue'])
455
755
  self.layouts['topUp'].addWidget(self.widgets['labels']['arrayInfo'])
@@ -670,7 +970,7 @@ class NDSliceWindow(QtWidgets.QMainWindow):
670
970
  button.setEnabled(enabled_channels[name])
671
971
 
672
972
  if checked_channel not in enabled_channels or not enabled_channels[checked_channel]:
673
- checked_channel = 'abs' if enabled_channels['abs'] else 'real'
973
+ checked_channel = 'abs' if is_complex else 'real'
674
974
 
675
975
  channel_buttons[checked_channel].setChecked(True)
676
976
 
@@ -1489,6 +1789,43 @@ class NDSliceWindow(QtWidgets.QMainWindow):
1489
1789
  progress_dialog = VideoExportDialog(self)
1490
1790
  progress_dialog.start_export(worker, self.data.shape[export_dim])
1491
1791
 
1792
+ def _save_current_numpy_file(self):
1793
+ """Save the currently displayed array state to a NumPy .npy file."""
1794
+ range_dialog = SaveRangeDialog(self, self.data.shape)
1795
+ if range_dialog.exec() != QtWidgets.QDialog.DialogCode.Accepted:
1796
+ return
1797
+
1798
+ ranges = range_dialog.get_ranges()
1799
+ sliced_data = self.data[tuple(slice(start, stop) for start, stop in ranges)]
1800
+ output_data = np.squeeze(sliced_data) if range_dialog.should_squeeze() else sliced_data
1801
+
1802
+ default_name = 'ndslice.npy'
1803
+ if self._filepath is not None:
1804
+ source_path = Path(self._filepath)
1805
+ source_name = source_path.name
1806
+ if source_name.lower().endswith('.nii.gz'):
1807
+ default_name = source_name[:-7] + '.npy'
1808
+ else:
1809
+ default_name = f"{source_path.stem}.npy"
1810
+
1811
+ file_path, _ = QtWidgets.QFileDialog.getSaveFileName(
1812
+ self,
1813
+ "Save current array as NumPy file",
1814
+ default_name,
1815
+ "NumPy files (*.npy)"
1816
+ )
1817
+ if not file_path:
1818
+ return
1819
+
1820
+ if not file_path.lower().endswith('.npy'):
1821
+ file_path += '.npy'
1822
+
1823
+ try:
1824
+ np.save(file_path, output_data)
1825
+ print(f"Saved array {list(output_data.shape)} to {file_path}")
1826
+ except Exception as e:
1827
+ QtWidgets.QMessageBox.warning(self, "Save Error", f"Failed to save NumPy file:\n{e}")
1828
+
1492
1829
  def _on_file_changed(self, path):
1493
1830
  """Called by QFileSystemWatcher when the source file changes on disk."""
1494
1831
  self._reload_btn.setText("⚠️")
@@ -452,15 +452,15 @@ class VideoExportSettingsDialog(QtWidgets.QDialog):
452
452
  format_options = [
453
453
  ("PNG frames", "png", True),
454
454
  ("GIF", "gif", True),
455
- ("MP4 (requires imageio-ffmpeg)", "mp4", True),
456
- ("WebM (requires imageio-ffmpeg)", "webm", True),
455
+ ("MP4", "mp4", True),
456
+ ("WebM", "webm", True),
457
457
  ]
458
458
  else:
459
459
  format_options = [
460
460
  ("PNG frames", "png", True),
461
461
  ("GIF", "gif", True),
462
- ("MP4", "mp4", False),
463
- ("WebM", "webm", False),
462
+ ("MP4 (requires imageio-ffmpeg)", "mp4", False),
463
+ ("WebM (requires imageio-ffmpeg)", "webm", False),
464
464
  ]
465
465
 
466
466
 
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ndslice
3
- Version: 0.6.0
3
+ Version: 0.7.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
- License: MIT
6
+ License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/henricryden/ndslice
8
8
  Project-URL: Documentation, https://github.com/henricryden/ndslice#readme
9
9
  Project-URL: Bug Tracker, https://github.com/henricryden/ndslice/issues
@@ -11,7 +11,6 @@ Keywords: visualization,numpy,fft,image-viewer,data-visualization
11
11
  Classifier: Development Status :: 4 - Beta
12
12
  Classifier: Intended Audience :: Developers
13
13
  Classifier: Intended Audience :: Science/Research
14
- Classifier: License :: OSI Approved :: MIT License
15
14
  Classifier: Programming Language :: Python :: 3
16
15
  Classifier: Programming Language :: Python :: 3.8
17
16
  Classifier: Programming Language :: Python :: 3.9
@@ -27,31 +26,18 @@ License-File: LICENSE
27
26
  Requires-Dist: numpy>=1.20.0
28
27
  Requires-Dist: pyqtgraph>=0.14.0
29
28
  Requires-Dist: PyQt5>=5.15.0
29
+ Requires-Dist: h5py>=3.0.0
30
+ Requires-Dist: scipy>=1.7.0
31
+ Requires-Dist: pydicom>=2.4.0
32
+ Requires-Dist: nibabel>=4.0.0
33
+ Requires-Dist: imageio>=2.9.0
34
+ Requires-Dist: imageio-ffmpeg>=0.4.2
35
+ Requires-Dist: Pillow>=8.0.0
30
36
  Provides-Extra: pyqt6
31
37
  Requires-Dist: PyQt6>=6.4.0; extra == "pyqt6"
32
38
  Provides-Extra: dev
33
39
  Requires-Dist: build>=0.10.0; extra == "dev"
34
40
  Requires-Dist: twine>=4.0.0; extra == "dev"
35
- Provides-Extra: hdf5
36
- Requires-Dist: h5py>=3.0.0; extra == "hdf5"
37
- Provides-Extra: matlab
38
- Requires-Dist: scipy>=1.7.0; extra == "matlab"
39
- Provides-Extra: dicom
40
- Requires-Dist: pydicom>=2.4.0; extra == "dicom"
41
- Provides-Extra: nifti
42
- Requires-Dist: nibabel>=4.0.0; extra == "nifti"
43
- Provides-Extra: video-export
44
- Requires-Dist: imageio>=2.9.0; extra == "video-export"
45
- Requires-Dist: imageio-ffmpeg>=0.4.2; extra == "video-export"
46
- Requires-Dist: Pillow>=8.0.0; extra == "video-export"
47
- Provides-Extra: all
48
- Requires-Dist: h5py>=3.0.0; extra == "all"
49
- Requires-Dist: scipy>=1.7.0; extra == "all"
50
- Requires-Dist: pydicom>=2.4.0; extra == "all"
51
- Requires-Dist: nibabel>=4.0.0; extra == "all"
52
- Requires-Dist: imageio>=2.9.0; extra == "all"
53
- Requires-Dist: imageio-ffmpeg>=0.4.2; extra == "all"
54
- Requires-Dist: Pillow>=8.0.0; extra == "all"
55
41
  Dynamic: license-file
56
42
 
57
43
  [![Python versions](https://img.shields.io/pypi/pyversions/ndslice.svg)](https://pypi.org/project/ndslice/)
@@ -147,6 +133,9 @@ ndslice(data2) # Shown after first closes
147
133
  ### Command Line
148
134
  ```bash
149
135
  ndslice data.npy # Numpy file
136
+ ndslice image.nii.gz
137
+ ndslice image.dcm
138
+ ndslice some_dicom_dir/ # Automatically attemps to form an nd-array from DICOM the files
150
139
  ndslice --help # Show all options
151
140
  ```
152
141
 
@@ -160,10 +149,13 @@ ndslice has CLI support and can conveniently display:
160
149
  | [BART](https://mrirecon.github.io/bart/) | `.cfl` + `.hdr` | — |
161
150
  | Philips REC | `.REC` + `.xml` | — |
162
151
  | [NIfTI](https://nifti.nimh.nih.gov/) | `.nii`, `.nii.gz` | nibabel |
163
- | DICOM (pixel array) | `.dcm` | pydicom |
152
+ | DICOM file | `.dcm` | pydicom |
153
+ | DICOM directory | directory containing `.dcm` files | pydicom, nibabel, `dcm2niix` on `PATH` |
164
154
 
165
155
  HDF5 files can be compound complex dtype, or real/imag fields.
166
156
 
157
+ For DICOM directories, ndslice does not infer series dimensions itself. It runs `dcm2niix`, then loads the produced NIfTI volume.
158
+
167
159
  If there are multiple datasets in the file, a selection GUI appears which highlights arrays supported by ndslice (essentially numeric).
168
160
  Double click to open.
169
161
 
@@ -175,11 +167,19 @@ Double click to open.
175
167
  ### From PyPI
176
168
 
177
169
  ```bash
178
- pip install ndslice
179
- pip install ndslice[video_export] # Include video export dependencies (imageio, Pillow, imageio-ffmpeg)
180
- pip install ndslice[all] # includes all optional dependencies (video export and file formats)
170
+ pip install ndslice
171
+ # Or, if you want just the package without pulling in dependencies:
172
+ pip install --no-deps ndslice
173
+ ```
174
+
175
+ For DICOM directories you also need the external `dcm2niix` binary available on `PATH`. A practical install route is:
176
+
177
+ ```bash
178
+ conda install -c conda-forge dcm2niix
181
179
  ```
182
180
 
181
+ If `dcm2niix` is missing or conversion fails, ndslice reports a clear error instead of trying to infer the series layout itself.
182
+
183
183
  ### From source
184
184
 
185
185
  ```bash
@@ -199,7 +199,13 @@ pip install -e .
199
199
  - NumPy >= 1.20.0
200
200
  - PyQtGraph >= 0.12.0
201
201
  - PyQt5 >= 5.15.0
202
- - h5py >= 3.0.0 (for HDF5 file support in CLI)
202
+ - h5py >= 3.0.0
203
+ - scipy >= 1.7.0
204
+ - pydicom >= 2.4.0
205
+ - nibabel >= 4.0.0
206
+ - imageio >= 2.9.0
207
+ - imageio-ffmpeg >= 0.4.2
208
+ - Pillow >= 8.0.0
203
209
 
204
210
  ## License
205
211
 
@@ -1,8 +1,6 @@
1
1
  numpy>=1.20.0
2
2
  pyqtgraph>=0.14.0
3
3
  PyQt5>=5.15.0
4
-
5
- [all]
6
4
  h5py>=3.0.0
7
5
  scipy>=1.7.0
8
6
  pydicom>=2.4.0
@@ -15,22 +13,5 @@ Pillow>=8.0.0
15
13
  build>=0.10.0
16
14
  twine>=4.0.0
17
15
 
18
- [dicom]
19
- pydicom>=2.4.0
20
-
21
- [hdf5]
22
- h5py>=3.0.0
23
-
24
- [matlab]
25
- scipy>=1.7.0
26
-
27
- [nifti]
28
- nibabel>=4.0.0
29
-
30
16
  [pyqt6]
31
17
  PyQt6>=6.4.0
32
-
33
- [video_export]
34
- imageio>=2.9.0
35
- imageio-ffmpeg>=0.4.2
36
- Pillow>=8.0.0
@@ -4,18 +4,17 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ndslice"
7
- version = "0.6.0"
7
+ version = "0.7.0"
8
8
  description = "Interactive N-dimensional numpy array viewer with FFT support"
9
9
  readme = "README.md"
10
10
  authors = [
11
11
  {name = "Henric Rydén", email = "henric.ryden@gmail.com"}
12
12
  ]
13
- license = {text = "MIT"}
13
+ license = "MIT"
14
14
  classifiers = [
15
15
  "Development Status :: 4 - Beta",
16
16
  "Intended Audience :: Developers",
17
17
  "Intended Audience :: Science/Research",
18
- "License :: OSI Approved :: MIT License",
19
18
  "Programming Language :: Python :: 3",
20
19
  "Programming Language :: Python :: 3.8",
21
20
  "Programming Language :: Python :: 3.9",
@@ -32,6 +31,13 @@ dependencies = [
32
31
  "numpy>=1.20.0",
33
32
  "pyqtgraph>=0.14.0",
34
33
  "PyQt5>=5.15.0",
34
+ "h5py>=3.0.0",
35
+ "scipy>=1.7.0",
36
+ "pydicom>=2.4.0",
37
+ "nibabel>=4.0.0",
38
+ "imageio>=2.9.0",
39
+ "imageio-ffmpeg>=0.4.2",
40
+ "Pillow>=8.0.0",
35
41
  ]
36
42
 
37
43
  [project.scripts]
@@ -43,32 +49,6 @@ dev = [
43
49
  "build>=0.10.0",
44
50
  "twine>=4.0.0",
45
51
  ]
46
- hdf5 = [
47
- "h5py>=3.0.0",
48
- ]
49
- matlab = [
50
- "scipy>=1.7.0",
51
- ]
52
- dicom = [
53
- "pydicom>=2.4.0",
54
- ]
55
- nifti = [
56
- "nibabel>=4.0.0",
57
- ]
58
- video_export = [
59
- "imageio>=2.9.0",
60
- "imageio-ffmpeg>=0.4.2",
61
- "Pillow>=8.0.0",
62
- ]
63
- all = [
64
- "h5py>=3.0.0",
65
- "scipy>=1.7.0",
66
- "pydicom>=2.4.0",
67
- "nibabel>=4.0.0",
68
- "imageio>=2.9.0",
69
- "imageio-ffmpeg>=0.4.2",
70
- "Pillow>=8.0.0",
71
- ]
72
52
  [project.urls]
73
53
  Homepage = "https://github.com/henricryden/ndslice"
74
54
  Documentation = "https://github.com/henricryden/ndslice#readme"
File without changes
File without changes
File without changes
File without changes
File without changes