ndslice 0.5.2__tar.gz → 0.6.1__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.5.2
3
+ Version: 0.6.1
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: MIT
@@ -18,6 +18,8 @@ Classifier: Programming Language :: Python :: 3.9
18
18
  Classifier: Programming Language :: Python :: 3.10
19
19
  Classifier: Programming Language :: Python :: 3.11
20
20
  Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
21
23
  Classifier: Topic :: Scientific/Engineering :: Visualization
22
24
  Requires-Python: >=3.8
23
25
  Description-Content-Type: text/markdown
@@ -45,7 +45,7 @@ For files with multiple datasets (HDF5, NPZ, MAT), a GUI selector will automatic
45
45
  # Single-dataset formats is handled by file_interpreters.load_file
46
46
  if suffix in ['.npy', '.rec', '.cfl', '.dcm', '.nii', '.nii.gz', '.txt']:
47
47
  data = load_file(filepath)
48
- ndslice(data=data, title=filepath.name, block=False)
48
+ ndslice(data=data, title=filepath.name, block=False, filepath=filepath)
49
49
  continue
50
50
 
51
51
  # Multi-dataset formats - use selectors
@@ -4,6 +4,7 @@ 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
8
9
  from .imageview2d import ImageView2D
9
10
  from .video_export import VideoExportWorker, VideoExportDialog, VideoExportSettingsDialog
@@ -29,13 +30,20 @@ class Domain(Enum):
29
30
  class NDSliceWindow(QtWidgets.QMainWindow):
30
31
  # Styling constants — use pt (point) units so font sizes are DPI-independent
31
32
  DIMENSION_LABEL_STYLE = "QLabel { font-size: 9pt; padding: 1px; margin: 2px; }"
32
- FLIP_ICON_STYLE = "QLabel { font-size: 15pt; padding: 0px; margin: 0px; }"
33
- BUTTON_STYLE = "QPushButton { font-size: 9pt; padding: 2px; margin: 2px; }"
34
- SPINBOX_STYLE = "QSpinBox { font-size: 9pt; }"
33
+ FLIP_ICON_STYLE = "QLabel { font-size: 15pt; padding: 0px; margin: 0px; color: palette(text); }"
34
+ BUTTON_STYLE = "QPushButton { font-size: 9pt; padding: 2px; margin: 2px; } QPushButton:disabled { color: palette(mid); }"
35
+ SPINBOX_STYLE = "QSpinBox { font-size: 9pt; } QSpinBox:disabled { color: palette(mid); }"
35
36
  RADIO_BUTTON_STYLE = "QRadioButton { font-size: 9pt; }"
36
37
  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; }"
38
+
39
+ @staticmethod
40
+ def _set_emoji_font(widget):
41
+ if platform.system() == 'Darwin':
42
+ font = widget.font()
43
+ font.setFamily('Apple Color Emoji')
44
+ widget.setFont(font)
37
45
 
38
- def __init__(self, data, complex_dim=None):
46
+ def __init__(self, data, complex_dim=None, filepath=None, dataset_path=None, selector_class_name=None):
39
47
  super(NDSliceWindow, self).__init__()
40
48
  self.resize(800,800)
41
49
 
@@ -45,6 +53,9 @@ class NDSliceWindow(QtWidgets.QMainWindow):
45
53
  self.channel = None
46
54
  self.scale = None
47
55
  self._force_autolevel = False
56
+ self._filepath = filepath
57
+ self._dataset_path = dataset_path
58
+ self._selector_class_name = selector_class_name
48
59
 
49
60
  self.axis_flipped = [False] * data.ndim # Track flip state so that one can toggle dims and come back to the same flip state
50
61
 
@@ -121,14 +132,7 @@ class NDSliceWindow(QtWidgets.QMainWindow):
121
132
  self.channel_button_group.addButton(self.widgets['buttons']['channel']['imag'])
122
133
  self.channel_button_group.addButton(self.widgets['buttons']['channel']['abs'])
123
134
  self.channel_button_group.addButton(self.widgets['buttons']['channel']['angle'])
124
-
125
- # Set default checked radio button
126
- if self.widgets['buttons']['channel']['abs'].isEnabled():
127
- self.widgets['buttons']['channel']['abs'].setChecked(True)
128
- #self.channel = 'abs'
129
- else:
130
- self.widgets['buttons']['channel']['real'].setChecked(True)
131
- #self.channel = 'real'
135
+ self._update_channel_controls()
132
136
 
133
137
  self.scale_button_group = QtWidgets.QButtonGroup()
134
138
  #self.scale_button_group.addButton(self.widgets['buttons']['processing']['log'])
@@ -168,12 +172,14 @@ class NDSliceWindow(QtWidgets.QMainWindow):
168
172
  flip_label.mousePressEvent = lambda event, i=i: self.flipAxisClicked(event, i)
169
173
  flip_label.setStyleSheet(self.FLIP_ICON_STYLE)
170
174
  flip_label.setAlignment(Qt.QtCore.Qt.AlignmentFlag.AlignLeft | Qt.QtCore.Qt.AlignmentFlag.AlignVCenter)
175
+ self._set_emoji_font(flip_label)
171
176
 
172
177
  # Set up complex indicator labels with click handlers
173
178
  for i, complex_label in enumerate(self.widgets['labels']['complex']):
174
179
  complex_label.mousePressEvent = lambda event, i=i: self.complexOrRealClicked(event, i)
175
180
  complex_label.setStyleSheet(self.FLIP_ICON_STYLE)
176
181
  complex_label.setAlignment(Qt.QtCore.Qt.AlignmentFlag.AlignRight | Qt.QtCore.Qt.AlignmentFlag.AlignVCenter)
182
+ self._set_emoji_font(complex_label)
177
183
 
178
184
  # Apply compact styling to dimension control widgets
179
185
  for label in self.widgets['labels']['dims']:
@@ -445,7 +451,17 @@ class NDSliceWindow(QtWidgets.QMainWindow):
445
451
 
446
452
  # Add tab widget to the main layout
447
453
  self.layouts['topDown'].addWidget(self.tab_widget)
448
-
454
+
455
+ # Reload / file-changed button (⟳ by default, ⚠️ when file changes on disk)
456
+ self._reload_btn = QtWidgets.QPushButton("⟳")
457
+ self._reload_btn.setStyleSheet("QPushButton { font-size: 18pt; padding: 1px 2px; margin: 0px; border: none; background: transparent; }")
458
+ self._reload_btn.setToolTip("Reload file")
459
+ self._reload_btn.setFlat(True)
460
+ self._reload_btn.setFixedSize(28, 20)
461
+ self._reload_btn.clicked.connect(self._reload_file)
462
+ self._reload_btn.setVisible(filepath is not None)
463
+ self._set_emoji_font(self._reload_btn)
464
+ self.layouts['topUp'].addWidget(self._reload_btn)
449
465
  self.layouts['topUp'].addWidget(self.widgets['labels']['pixelValue'])
450
466
  self.layouts['topUp'].addWidget(self.widgets['labels']['arrayInfo'])
451
467
 
@@ -498,6 +514,13 @@ class NDSliceWindow(QtWidgets.QMainWindow):
498
514
  self.update()
499
515
  self.show()
500
516
 
517
+ # Set up file watcher if a filepath was provided (QFileSystemWatcher uses
518
+ # OS-native events: inotify on Linux, FSEvents on macOS, ReadDirectoryChanges on Windows)
519
+ self._file_watcher = None
520
+ if filepath is not None:
521
+ self._file_watcher = Qt.QtCore.QFileSystemWatcher([str(filepath)])
522
+ self._file_watcher.fileChanged.connect(self._on_file_changed)
523
+
501
524
 
502
525
 
503
526
 
@@ -541,10 +564,12 @@ class NDSliceWindow(QtWidgets.QMainWindow):
541
564
  def _apply_fft(self, dim):
542
565
  """Apply forward FFT along specified dimension"""
543
566
  self.data = np.fft.ifftshift(np.fft.ifft(np.fft.fftshift(self.data), axis=dim, norm='ortho'))
567
+ self._update_channel_controls()
544
568
 
545
569
  def _apply_ifft(self, dim):
546
570
  """Apply inverse FFT along specified dimension"""
547
571
  self.data = np.fft.ifftshift(np.fft.fft(np.fft.fftshift(self.data), axis=dim, norm='ortho'))
572
+ self._update_channel_controls()
548
573
 
549
574
  def flipAxisClicked(self, event, dim):
550
575
  """Handle click on flip axis icon"""
@@ -635,6 +660,30 @@ class NDSliceWindow(QtWidgets.QMainWindow):
635
660
  indicator.setText('')
636
661
  indicator.setToolTip('')
637
662
  indicator.setCursor(QtGui.QCursor(Qt.QtCore.Qt.CursorShape.ArrowCursor))
663
+
664
+ def _update_channel_controls(self):
665
+ """Keep channel options in sync with the current array dtype."""
666
+ is_complex = np.iscomplexobj(self.data)
667
+ channel_buttons = self.widgets['buttons']['channel']
668
+ enabled_channels = {
669
+ 'real': True,
670
+ 'abs': True,
671
+ 'imag': is_complex,
672
+ 'angle': is_complex,
673
+ }
674
+
675
+ checked_channel = next(
676
+ (name for name, button in channel_buttons.items() if button.isChecked()),
677
+ None,
678
+ )
679
+
680
+ for name, button in channel_buttons.items():
681
+ button.setEnabled(enabled_channels[name])
682
+
683
+ if checked_channel not in enabled_channels or not enabled_channels[checked_channel]:
684
+ checked_channel = 'abs' if is_complex else 'real'
685
+
686
+ channel_buttons[checked_channel].setChecked(True)
638
687
 
639
688
  def complexOrRealClicked(self, event, dim):
640
689
  if self.can_combine_as_complex[dim] and not self.combined_as_complex[dim]:
@@ -683,12 +732,7 @@ class NDSliceWindow(QtWidgets.QMainWindow):
683
732
  self.line_plot_dimension = new_dim
684
733
  break
685
734
 
686
- # Enable complex channel buttons now that data is complex
687
- for button in self.widgets['buttons']['channel'].values():
688
- button.setEnabled(True)
689
-
690
- if not self.widgets['buttons']['channel']['abs'].isChecked():
691
- self.widgets['buttons']['channel']['abs'].setChecked(True)
735
+ self._update_channel_controls()
692
736
 
693
737
  # Update UI
694
738
  self.update_complex_indicators()
@@ -717,12 +761,7 @@ class NDSliceWindow(QtWidgets.QMainWindow):
717
761
  # Update max value for the spinbox for this dimension
718
762
  self.widgets['spins']['slice_indices'][dim].setMaximum(self.data.shape[dim] - 1)
719
763
 
720
- # Disable complex channel buttons (data is now real)
721
- for button in self.widgets['buttons']['channel'].values():
722
- button.setEnabled(False)
723
-
724
- # Switch to 'real' channel
725
- self.widgets['buttons']['channel']['real'].setChecked(True)
764
+ self._update_channel_controls()
726
765
 
727
766
  # Update UI
728
767
  self.update_complex_indicators()
@@ -1185,11 +1224,11 @@ class NDSliceWindow(QtWidgets.QMainWindow):
1185
1224
  event.accept()
1186
1225
  return
1187
1226
  elif key == Qt.QtCore.Qt.Key.Key_5:
1188
- self.setColormap('d3-cividis')
1227
+ self.setColormap('cividis')
1189
1228
  event.accept()
1190
1229
  return
1191
1230
  elif key == Qt.QtCore.Qt.Key.Key_6:
1192
- self.setColormap('d3-cubehelix')
1231
+ self.setColormap('CET-CBL1')
1193
1232
  event.accept()
1194
1233
  return
1195
1234
  elif key == Qt.QtCore.Qt.Key.Key_7:
@@ -1200,8 +1239,7 @@ class NDSliceWindow(QtWidgets.QMainWindow):
1200
1239
  self.setColormap('d3-warm')
1201
1240
  event.accept()
1202
1241
  return
1203
-
1204
-
1242
+
1205
1243
  # Pass event to parent if not handled
1206
1244
  super().keyPressEvent(event)
1207
1245
 
@@ -1462,23 +1500,159 @@ class NDSliceWindow(QtWidgets.QMainWindow):
1462
1500
  progress_dialog = VideoExportDialog(self)
1463
1501
  progress_dialog.start_export(worker, self.data.shape[export_dim])
1464
1502
 
1465
-
1466
- def _run_window(data, title, complex_dim=None):
1503
+ def _on_file_changed(self, path):
1504
+ """Called by QFileSystemWatcher when the source file changes on disk."""
1505
+ self._reload_btn.setText("⚠️")
1506
+ self._reload_btn.setToolTip("File changed — click to reload")
1507
+ # Re-add the path: handles atomic replacement where the original inode disappears
1508
+ if self._file_watcher:
1509
+ self._file_watcher.addPath(path)
1510
+
1511
+ def _reload_file(self):
1512
+ """Reload data from the source file, preserving slice positions where possible."""
1513
+ if self._filepath is None:
1514
+ return
1515
+ try:
1516
+ new_data = None
1517
+ new_dataset_path = self._dataset_path
1518
+
1519
+ if self._selector_class_name is None:
1520
+ from .file_interpreters import load_file
1521
+ new_data = load_file(self._filepath)
1522
+ else:
1523
+ from .selectors import H5DatasetSelector, NpzDatasetSelector, MatDatasetSelector
1524
+ selector_map = {
1525
+ 'H5DatasetSelector': H5DatasetSelector,
1526
+ 'NpzDatasetSelector': NpzDatasetSelector,
1527
+ 'MatDatasetSelector': MatDatasetSelector,
1528
+ }
1529
+ selector_cls = selector_map.get(self._selector_class_name)
1530
+ if selector_cls is None:
1531
+ return
1532
+ selector = selector_cls(self._filepath)
1533
+ compatible_keys = {d[0] for d in selector.compatible_datasets}
1534
+ if self._dataset_path is not None and self._dataset_path in compatible_keys:
1535
+ new_data = selector.load_data(self._dataset_path)
1536
+ selector.close()
1537
+ elif selector.requires_gui():
1538
+ selected = selector.show()
1539
+ if selected is None:
1540
+ selector.close()
1541
+ return # User cancelled — keep ⚠️ visible
1542
+ new_data = selector.load_data(selected)
1543
+ new_dataset_path = selected
1544
+ selector.close()
1545
+ else:
1546
+ result = selector.get_single_data()
1547
+ selector.close()
1548
+ if result is None:
1549
+ return
1550
+ new_dataset_path, new_data = result
1551
+
1552
+ if new_data is None:
1553
+ return
1554
+
1555
+ self._dataset_path = new_dataset_path
1556
+ self._reset_data(new_data)
1557
+ self._reload_btn.setText("⟳")
1558
+ self._reload_btn.setToolTip("Reload file")
1559
+ if self._file_watcher and self._filepath:
1560
+ self._file_watcher.addPath(str(self._filepath))
1561
+ except Exception as e:
1562
+ QtWidgets.QMessageBox.warning(self, "Reload Error", f"Failed to reload:\n{e}")
1563
+
1564
+ def _reset_data(self, new_data):
1565
+ """Replace the displayed data, clamping slice positions to the new shape."""
1566
+ old_ndim = self.data.ndim
1567
+ new_ndim = new_data.ndim
1568
+
1569
+ if new_ndim != old_ndim:
1570
+ # Per-dimension widgets were built for old_ndim and cannot be rebuilt in-place.
1571
+ # Open a fresh window with the new data and close this one.
1572
+ win = NDSliceWindow(new_data,
1573
+ filepath=self._filepath,
1574
+ dataset_path=self._dataset_path,
1575
+ selector_class_name=self._selector_class_name)
1576
+ win.setWindowTitle(self.windowTitle())
1577
+ win.show()
1578
+ self.close()
1579
+ return
1580
+
1581
+ self.data = new_data
1582
+ self.singleton = [e == 1 for e in new_data.shape]
1583
+
1584
+ if np.iscomplexobj(new_data):
1585
+ self.can_combine_as_complex = [False] * new_ndim
1586
+ else:
1587
+ self.can_combine_as_complex = [new_data.shape[i] == 2 for i in range(new_ndim)]
1588
+ self.combined_as_complex = [False] * new_ndim
1589
+
1590
+ # Reset FFT domain state and dim label styling
1591
+ self.domain = [Domain.NATIVE for _ in range(new_ndim)]
1592
+ for i, label in enumerate(self.widgets['labels']['dims']):
1593
+ label.setStyleSheet(self.DIMENSION_LABEL_STYLE)
1594
+ label.setText(f'[{new_data.shape[i]}]')
1595
+
1596
+ # Update spinbox maximums (auto-clamps current value)
1597
+ for i in range(new_ndim):
1598
+ self.widgets['spins']['slice_indices'][i].setMaximum(new_data.shape[i] - 1)
1599
+
1600
+ # Clamp selected_indices: keep existing choices where still valid, fill from valid dims
1601
+ valid_dims = [i for i in range(new_ndim) if not self.singleton[i]]
1602
+ clamped = [i for i in self.selected_indices if i < new_ndim and not self.singleton[i]]
1603
+ for d in valid_dims:
1604
+ if len(clamped) >= 2:
1605
+ break
1606
+ if d not in clamped:
1607
+ clamped.append(d)
1608
+ if not clamped and valid_dims:
1609
+ clamped = [valid_dims[0]]
1610
+ self.selected_indices = clamped[:2]
1611
+
1612
+ # Clamp line_plot_dimension
1613
+ if self.line_plot_dimension >= new_ndim or self.singleton[self.line_plot_dimension]:
1614
+ for d in range(new_ndim):
1615
+ if not self.singleton[d]:
1616
+ self.line_plot_dimension = d
1617
+ break
1618
+
1619
+ self.axis_flipped = [False] * new_ndim
1620
+
1621
+ self._update_channel_controls()
1622
+ self.update_complex_indicators()
1623
+ if len(self.selected_indices) >= 1:
1624
+ self.changedIndex(True, 0, self.selected_indices[0], update=False)
1625
+ if len(self.selected_indices) >= 2:
1626
+ self.changedIndex(True, 1, self.selected_indices[1], update=False)
1627
+ self.update_dimension_controls()
1628
+ self._force_autolevel = True
1629
+ self.update()
1630
+
1631
+
1632
+ def _run_window(data, title, complex_dim=None, filepath=None, dataset_path=None, selector_class_name=None):
1467
1633
  """Opens a window in a separate process which is blocked on exec()"""
1468
1634
  app = pg.mkQApp() # sets QT_ENABLE_HIGHDPI_SCALING + PassThrough rounding before creating QApp
1469
- win = NDSliceWindow(data, complex_dim=complex_dim)
1635
+ app.setStyle('Fusion')
1636
+ win = NDSliceWindow(data, complex_dim=complex_dim, filepath=filepath,
1637
+ dataset_path=dataset_path, selector_class_name=selector_class_name)
1470
1638
  win.setWindowTitle(title)
1471
1639
  win.show()
1472
1640
  app.exec()
1473
1641
 
1474
- def ndslice(data, title='', block=False, complex_dim=None):
1642
+ def ndslice(data, title='', block=False, complex_dim=None, filepath=None, dataset_path=None, selector_class_name=None):
1475
1643
  if not isinstance(data, np.ndarray):
1476
1644
  raise TypeError("data must be a numpy array")
1477
1645
  if data.ndim < 1:
1478
1646
  raise ValueError("data must have at least 1 dimension")
1479
1647
 
1480
1648
  if block:
1481
- _run_window(data, title, complex_dim)
1649
+ _run_window(data, title, complex_dim, filepath=filepath,
1650
+ dataset_path=dataset_path, selector_class_name=selector_class_name)
1482
1651
  else:
1483
- process = mp.Process(target=_run_window, args=(data, title, complex_dim)).start()
1652
+ import multiprocessing.process as _mp_process
1653
+ p = mp.Process(target=_run_window, args=(data, title, complex_dim),
1654
+ kwargs={'filepath': filepath, 'dataset_path': dataset_path,
1655
+ 'selector_class_name': selector_class_name})
1656
+ p.start()
1657
+ _mp_process._children.discard(p)
1484
1658
 
@@ -34,10 +34,14 @@ def _show_selector(filepath, selector_class_name, interpret_as_complex):
34
34
  # Show dialog
35
35
  if selected_path := selector.show():
36
36
  data = selector.load_data(selected_path)
37
+ selector.close()
37
38
  ndslice(data=data,
38
39
  title=f"{filepath.name} - {selected_path}",
39
40
  block=True,
40
- complex_dim=interpret_as_complex)
41
+ complex_dim=interpret_as_complex,
42
+ filepath=filepath,
43
+ dataset_path=selected_path,
44
+ selector_class_name=selector_class_name)
41
45
 
42
46
 
43
47
  class DatasetSelector:
@@ -68,7 +72,11 @@ class DatasetSelector:
68
72
  def load_data(self, path):
69
73
  """Load data for a given dataset path/name. Returns numpy array."""
70
74
  raise NotImplementedError("Subclasses must implement load_data()")
71
-
75
+
76
+ def close(self):
77
+ """Close any open file handles. Subclasses override if needed."""
78
+ pass
79
+
72
80
  def _build_tree(self):
73
81
  """Build the tree widget. Must be implemented by subclasses."""
74
82
  raise NotImplementedError("Subclasses must implement _build_tree()")
@@ -94,10 +102,14 @@ class DatasetSelector:
94
102
  result = self.get_single_data()
95
103
  if result:
96
104
  name, data = result
105
+ self.close()
97
106
  ndslice(data=data,
98
107
  title=f"{self.filepath.name} - {name}",
99
108
  block=block,
100
- complex_dim=self.interpret_as_complex)
109
+ complex_dim=self.interpret_as_complex,
110
+ filepath=self.filepath,
111
+ dataset_path=name,
112
+ selector_class_name=self.__class__.__name__)
101
113
  return True
102
114
  else:
103
115
  return False
@@ -110,16 +122,24 @@ class DatasetSelector:
110
122
 
111
123
  if selected_path := self.show():
112
124
  data = self.load_data(selected_path)
125
+ self.close()
113
126
  ndslice(data=data,
114
127
  title=f"{self.filepath.name} - {selected_path}",
115
128
  block=True,
116
- complex_dim=self.interpret_as_complex)
129
+ complex_dim=self.interpret_as_complex,
130
+ filepath=self.filepath,
131
+ dataset_path=selected_path,
132
+ selector_class_name=self.__class__.__name__)
117
133
  return True
118
134
  return False
119
135
  else:
120
136
  import multiprocessing as mp
121
- mp.Process(target=_show_selector,
122
- args=(self.filepath, self.__class__.__name__, self.interpret_as_complex)).start()
137
+ import multiprocessing.process as _mp_process
138
+ p = mp.Process(target=_show_selector,
139
+ args=(self.filepath, self.__class__.__name__, self.interpret_as_complex))
140
+ p.start()
141
+ _mp_process._children.discard(p)
142
+ self.close()
123
143
  return True
124
144
 
125
145
  def _create_dialog(self):
@@ -249,6 +269,10 @@ class H5DatasetSelector(DatasetSelector):
249
269
  if hasattr(self, 'h5_file') and self.h5_file:
250
270
  self.h5_file.close()
251
271
 
272
+ def close(self):
273
+ if hasattr(self, 'h5_file') and self.h5_file:
274
+ self.h5_file.close()
275
+
252
276
  def _detect_complex_interpretation(self):
253
277
  """
254
278
  If there's a single compatible dataset with 'complex' in name and a size-2 dimension,
@@ -406,7 +430,11 @@ class NpzDatasetSelector(DatasetSelector):
406
430
  """Close the NPZ file when selector is destroyed."""
407
431
  if hasattr(self, 'npz_file'):
408
432
  self.npz_file.close()
409
-
433
+
434
+ def close(self):
435
+ if hasattr(self, 'npz_file'):
436
+ self.npz_file.close()
437
+
410
438
  def _find_compatible_datasets(self):
411
439
  """Find all compatible datasets in the NPZ file."""
412
440
  compatible = []
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ndslice
3
- Version: 0.5.2
3
+ Version: 0.6.1
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: MIT
@@ -18,6 +18,8 @@ Classifier: Programming Language :: Python :: 3.9
18
18
  Classifier: Programming Language :: Python :: 3.10
19
19
  Classifier: Programming Language :: Python :: 3.11
20
20
  Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
21
23
  Classifier: Topic :: Scientific/Engineering :: Visualization
22
24
  Requires-Python: >=3.8
23
25
  Description-Content-Type: text/markdown
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ndslice"
7
- version = "0.5.2"
7
+ version = "0.6.1"
8
8
  description = "Interactive N-dimensional numpy array viewer with FFT support"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -22,6 +22,8 @@ classifiers = [
22
22
  "Programming Language :: Python :: 3.10",
23
23
  "Programming Language :: Python :: 3.11",
24
24
  "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Programming Language :: Python :: 3.14",
25
27
  "Topic :: Scientific/Engineering :: Visualization",
26
28
  ]
27
29
  keywords = ["visualization", "numpy", "fft", "image-viewer", "data-visualization"]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes