ndslice 0.5.2__tar.gz → 0.6.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.5.2
3
+ Version: 0.6.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: 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
@@ -29,13 +29,13 @@ class Domain(Enum):
29
29
  class NDSliceWindow(QtWidgets.QMainWindow):
30
30
  # Styling constants — use pt (point) units so font sizes are DPI-independent
31
31
  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; }"
32
+ FLIP_ICON_STYLE = "QLabel { font-size: 15pt; padding: 0px; margin: 0px; color: palette(text); }"
33
+ BUTTON_STYLE = "QPushButton { font-size: 9pt; padding: 2px; margin: 2px; } QPushButton:disabled { color: palette(mid); }"
34
+ SPINBOX_STYLE = "QSpinBox { font-size: 9pt; } QSpinBox:disabled { color: palette(mid); }"
35
35
  RADIO_BUTTON_STYLE = "QRadioButton { font-size: 9pt; }"
36
36
  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; }"
37
37
 
38
- def __init__(self, data, complex_dim=None):
38
+ def __init__(self, data, complex_dim=None, filepath=None, dataset_path=None, selector_class_name=None):
39
39
  super(NDSliceWindow, self).__init__()
40
40
  self.resize(800,800)
41
41
 
@@ -45,6 +45,9 @@ class NDSliceWindow(QtWidgets.QMainWindow):
45
45
  self.channel = None
46
46
  self.scale = None
47
47
  self._force_autolevel = False
48
+ self._filepath = filepath
49
+ self._dataset_path = dataset_path
50
+ self._selector_class_name = selector_class_name
48
51
 
49
52
  self.axis_flipped = [False] * data.ndim # Track flip state so that one can toggle dims and come back to the same flip state
50
53
 
@@ -121,14 +124,7 @@ class NDSliceWindow(QtWidgets.QMainWindow):
121
124
  self.channel_button_group.addButton(self.widgets['buttons']['channel']['imag'])
122
125
  self.channel_button_group.addButton(self.widgets['buttons']['channel']['abs'])
123
126
  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'
127
+ self._update_channel_controls()
132
128
 
133
129
  self.scale_button_group = QtWidgets.QButtonGroup()
134
130
  #self.scale_button_group.addButton(self.widgets['buttons']['processing']['log'])
@@ -445,7 +441,16 @@ class NDSliceWindow(QtWidgets.QMainWindow):
445
441
 
446
442
  # Add tab widget to the main layout
447
443
  self.layouts['topDown'].addWidget(self.tab_widget)
448
-
444
+
445
+ # Reload / file-changed button (⟳ by default, ⚠️ when file changes on disk)
446
+ self._reload_btn = QtWidgets.QPushButton("⟳")
447
+ self._reload_btn.setStyleSheet("QPushButton { font-size: 18pt; padding: 1px 2px; margin: 0px; border: none; background: transparent; }")
448
+ self._reload_btn.setToolTip("Reload file")
449
+ self._reload_btn.setFlat(True)
450
+ self._reload_btn.setFixedSize(28, 20)
451
+ self._reload_btn.clicked.connect(self._reload_file)
452
+ self._reload_btn.setVisible(filepath is not None)
453
+ self.layouts['topUp'].addWidget(self._reload_btn)
449
454
  self.layouts['topUp'].addWidget(self.widgets['labels']['pixelValue'])
450
455
  self.layouts['topUp'].addWidget(self.widgets['labels']['arrayInfo'])
451
456
 
@@ -498,6 +503,13 @@ class NDSliceWindow(QtWidgets.QMainWindow):
498
503
  self.update()
499
504
  self.show()
500
505
 
506
+ # Set up file watcher if a filepath was provided (QFileSystemWatcher uses
507
+ # OS-native events: inotify on Linux, FSEvents on macOS, ReadDirectoryChanges on Windows)
508
+ self._file_watcher = None
509
+ if filepath is not None:
510
+ self._file_watcher = Qt.QtCore.QFileSystemWatcher([str(filepath)])
511
+ self._file_watcher.fileChanged.connect(self._on_file_changed)
512
+
501
513
 
502
514
 
503
515
 
@@ -541,10 +553,12 @@ class NDSliceWindow(QtWidgets.QMainWindow):
541
553
  def _apply_fft(self, dim):
542
554
  """Apply forward FFT along specified dimension"""
543
555
  self.data = np.fft.ifftshift(np.fft.ifft(np.fft.fftshift(self.data), axis=dim, norm='ortho'))
556
+ self._update_channel_controls()
544
557
 
545
558
  def _apply_ifft(self, dim):
546
559
  """Apply inverse FFT along specified dimension"""
547
560
  self.data = np.fft.ifftshift(np.fft.fft(np.fft.fftshift(self.data), axis=dim, norm='ortho'))
561
+ self._update_channel_controls()
548
562
 
549
563
  def flipAxisClicked(self, event, dim):
550
564
  """Handle click on flip axis icon"""
@@ -635,6 +649,30 @@ class NDSliceWindow(QtWidgets.QMainWindow):
635
649
  indicator.setText('')
636
650
  indicator.setToolTip('')
637
651
  indicator.setCursor(QtGui.QCursor(Qt.QtCore.Qt.CursorShape.ArrowCursor))
652
+
653
+ def _update_channel_controls(self):
654
+ """Keep channel options in sync with the current array dtype."""
655
+ is_complex = np.iscomplexobj(self.data)
656
+ channel_buttons = self.widgets['buttons']['channel']
657
+ enabled_channels = {
658
+ 'real': True,
659
+ 'abs': True,
660
+ 'imag': is_complex,
661
+ 'angle': is_complex,
662
+ }
663
+
664
+ checked_channel = next(
665
+ (name for name, button in channel_buttons.items() if button.isChecked()),
666
+ None,
667
+ )
668
+
669
+ for name, button in channel_buttons.items():
670
+ button.setEnabled(enabled_channels[name])
671
+
672
+ if checked_channel not in enabled_channels or not enabled_channels[checked_channel]:
673
+ checked_channel = 'abs' if enabled_channels['abs'] else 'real'
674
+
675
+ channel_buttons[checked_channel].setChecked(True)
638
676
 
639
677
  def complexOrRealClicked(self, event, dim):
640
678
  if self.can_combine_as_complex[dim] and not self.combined_as_complex[dim]:
@@ -683,12 +721,7 @@ class NDSliceWindow(QtWidgets.QMainWindow):
683
721
  self.line_plot_dimension = new_dim
684
722
  break
685
723
 
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)
724
+ self._update_channel_controls()
692
725
 
693
726
  # Update UI
694
727
  self.update_complex_indicators()
@@ -717,12 +750,7 @@ class NDSliceWindow(QtWidgets.QMainWindow):
717
750
  # Update max value for the spinbox for this dimension
718
751
  self.widgets['spins']['slice_indices'][dim].setMaximum(self.data.shape[dim] - 1)
719
752
 
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)
753
+ self._update_channel_controls()
726
754
 
727
755
  # Update UI
728
756
  self.update_complex_indicators()
@@ -1185,11 +1213,11 @@ class NDSliceWindow(QtWidgets.QMainWindow):
1185
1213
  event.accept()
1186
1214
  return
1187
1215
  elif key == Qt.QtCore.Qt.Key.Key_5:
1188
- self.setColormap('d3-cividis')
1216
+ self.setColormap('cividis')
1189
1217
  event.accept()
1190
1218
  return
1191
1219
  elif key == Qt.QtCore.Qt.Key.Key_6:
1192
- self.setColormap('d3-cubehelix')
1220
+ self.setColormap('CET-CBL1')
1193
1221
  event.accept()
1194
1222
  return
1195
1223
  elif key == Qt.QtCore.Qt.Key.Key_7:
@@ -1200,8 +1228,7 @@ class NDSliceWindow(QtWidgets.QMainWindow):
1200
1228
  self.setColormap('d3-warm')
1201
1229
  event.accept()
1202
1230
  return
1203
-
1204
-
1231
+
1205
1232
  # Pass event to parent if not handled
1206
1233
  super().keyPressEvent(event)
1207
1234
 
@@ -1462,23 +1489,159 @@ class NDSliceWindow(QtWidgets.QMainWindow):
1462
1489
  progress_dialog = VideoExportDialog(self)
1463
1490
  progress_dialog.start_export(worker, self.data.shape[export_dim])
1464
1491
 
1465
-
1466
- def _run_window(data, title, complex_dim=None):
1492
+ def _on_file_changed(self, path):
1493
+ """Called by QFileSystemWatcher when the source file changes on disk."""
1494
+ self._reload_btn.setText("⚠️")
1495
+ self._reload_btn.setToolTip("File changed — click to reload")
1496
+ # Re-add the path: handles atomic replacement where the original inode disappears
1497
+ if self._file_watcher:
1498
+ self._file_watcher.addPath(path)
1499
+
1500
+ def _reload_file(self):
1501
+ """Reload data from the source file, preserving slice positions where possible."""
1502
+ if self._filepath is None:
1503
+ return
1504
+ try:
1505
+ new_data = None
1506
+ new_dataset_path = self._dataset_path
1507
+
1508
+ if self._selector_class_name is None:
1509
+ from .file_interpreters import load_file
1510
+ new_data = load_file(self._filepath)
1511
+ else:
1512
+ from .selectors import H5DatasetSelector, NpzDatasetSelector, MatDatasetSelector
1513
+ selector_map = {
1514
+ 'H5DatasetSelector': H5DatasetSelector,
1515
+ 'NpzDatasetSelector': NpzDatasetSelector,
1516
+ 'MatDatasetSelector': MatDatasetSelector,
1517
+ }
1518
+ selector_cls = selector_map.get(self._selector_class_name)
1519
+ if selector_cls is None:
1520
+ return
1521
+ selector = selector_cls(self._filepath)
1522
+ compatible_keys = {d[0] for d in selector.compatible_datasets}
1523
+ if self._dataset_path is not None and self._dataset_path in compatible_keys:
1524
+ new_data = selector.load_data(self._dataset_path)
1525
+ selector.close()
1526
+ elif selector.requires_gui():
1527
+ selected = selector.show()
1528
+ if selected is None:
1529
+ selector.close()
1530
+ return # User cancelled — keep ⚠️ visible
1531
+ new_data = selector.load_data(selected)
1532
+ new_dataset_path = selected
1533
+ selector.close()
1534
+ else:
1535
+ result = selector.get_single_data()
1536
+ selector.close()
1537
+ if result is None:
1538
+ return
1539
+ new_dataset_path, new_data = result
1540
+
1541
+ if new_data is None:
1542
+ return
1543
+
1544
+ self._dataset_path = new_dataset_path
1545
+ self._reset_data(new_data)
1546
+ self._reload_btn.setText("⟳")
1547
+ self._reload_btn.setToolTip("Reload file")
1548
+ if self._file_watcher and self._filepath:
1549
+ self._file_watcher.addPath(str(self._filepath))
1550
+ except Exception as e:
1551
+ QtWidgets.QMessageBox.warning(self, "Reload Error", f"Failed to reload:\n{e}")
1552
+
1553
+ def _reset_data(self, new_data):
1554
+ """Replace the displayed data, clamping slice positions to the new shape."""
1555
+ old_ndim = self.data.ndim
1556
+ new_ndim = new_data.ndim
1557
+
1558
+ if new_ndim != old_ndim:
1559
+ # Per-dimension widgets were built for old_ndim and cannot be rebuilt in-place.
1560
+ # Open a fresh window with the new data and close this one.
1561
+ win = NDSliceWindow(new_data,
1562
+ filepath=self._filepath,
1563
+ dataset_path=self._dataset_path,
1564
+ selector_class_name=self._selector_class_name)
1565
+ win.setWindowTitle(self.windowTitle())
1566
+ win.show()
1567
+ self.close()
1568
+ return
1569
+
1570
+ self.data = new_data
1571
+ self.singleton = [e == 1 for e in new_data.shape]
1572
+
1573
+ if np.iscomplexobj(new_data):
1574
+ self.can_combine_as_complex = [False] * new_ndim
1575
+ else:
1576
+ self.can_combine_as_complex = [new_data.shape[i] == 2 for i in range(new_ndim)]
1577
+ self.combined_as_complex = [False] * new_ndim
1578
+
1579
+ # Reset FFT domain state and dim label styling
1580
+ self.domain = [Domain.NATIVE for _ in range(new_ndim)]
1581
+ for i, label in enumerate(self.widgets['labels']['dims']):
1582
+ label.setStyleSheet(self.DIMENSION_LABEL_STYLE)
1583
+ label.setText(f'[{new_data.shape[i]}]')
1584
+
1585
+ # Update spinbox maximums (auto-clamps current value)
1586
+ for i in range(new_ndim):
1587
+ self.widgets['spins']['slice_indices'][i].setMaximum(new_data.shape[i] - 1)
1588
+
1589
+ # Clamp selected_indices: keep existing choices where still valid, fill from valid dims
1590
+ valid_dims = [i for i in range(new_ndim) if not self.singleton[i]]
1591
+ clamped = [i for i in self.selected_indices if i < new_ndim and not self.singleton[i]]
1592
+ for d in valid_dims:
1593
+ if len(clamped) >= 2:
1594
+ break
1595
+ if d not in clamped:
1596
+ clamped.append(d)
1597
+ if not clamped and valid_dims:
1598
+ clamped = [valid_dims[0]]
1599
+ self.selected_indices = clamped[:2]
1600
+
1601
+ # Clamp line_plot_dimension
1602
+ if self.line_plot_dimension >= new_ndim or self.singleton[self.line_plot_dimension]:
1603
+ for d in range(new_ndim):
1604
+ if not self.singleton[d]:
1605
+ self.line_plot_dimension = d
1606
+ break
1607
+
1608
+ self.axis_flipped = [False] * new_ndim
1609
+
1610
+ self._update_channel_controls()
1611
+ self.update_complex_indicators()
1612
+ if len(self.selected_indices) >= 1:
1613
+ self.changedIndex(True, 0, self.selected_indices[0], update=False)
1614
+ if len(self.selected_indices) >= 2:
1615
+ self.changedIndex(True, 1, self.selected_indices[1], update=False)
1616
+ self.update_dimension_controls()
1617
+ self._force_autolevel = True
1618
+ self.update()
1619
+
1620
+
1621
+ def _run_window(data, title, complex_dim=None, filepath=None, dataset_path=None, selector_class_name=None):
1467
1622
  """Opens a window in a separate process which is blocked on exec()"""
1468
1623
  app = pg.mkQApp() # sets QT_ENABLE_HIGHDPI_SCALING + PassThrough rounding before creating QApp
1469
- win = NDSliceWindow(data, complex_dim=complex_dim)
1624
+ app.setStyle('Fusion')
1625
+ win = NDSliceWindow(data, complex_dim=complex_dim, filepath=filepath,
1626
+ dataset_path=dataset_path, selector_class_name=selector_class_name)
1470
1627
  win.setWindowTitle(title)
1471
1628
  win.show()
1472
1629
  app.exec()
1473
1630
 
1474
- def ndslice(data, title='', block=False, complex_dim=None):
1631
+ def ndslice(data, title='', block=False, complex_dim=None, filepath=None, dataset_path=None, selector_class_name=None):
1475
1632
  if not isinstance(data, np.ndarray):
1476
1633
  raise TypeError("data must be a numpy array")
1477
1634
  if data.ndim < 1:
1478
1635
  raise ValueError("data must have at least 1 dimension")
1479
1636
 
1480
1637
  if block:
1481
- _run_window(data, title, complex_dim)
1638
+ _run_window(data, title, complex_dim, filepath=filepath,
1639
+ dataset_path=dataset_path, selector_class_name=selector_class_name)
1482
1640
  else:
1483
- process = mp.Process(target=_run_window, args=(data, title, complex_dim)).start()
1641
+ import multiprocessing.process as _mp_process
1642
+ p = mp.Process(target=_run_window, args=(data, title, complex_dim),
1643
+ kwargs={'filepath': filepath, 'dataset_path': dataset_path,
1644
+ 'selector_class_name': selector_class_name})
1645
+ p.start()
1646
+ _mp_process._children.discard(p)
1484
1647
 
@@ -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.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: 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.0"
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