diff --git a/.github/workflows/build-macos_pyqt5.yml b/.github/workflows/build-macos_pyqt5.yml
index 2829032b2..bb89d811b 100644
--- a/.github/workflows/build-macos_pyqt5.yml
+++ b/.github/workflows/build-macos_pyqt5.yml
@@ -32,6 +32,7 @@ jobs:
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
python -m pip install -e .
python -m pip install -r requirements_gui_pyqt5.txt
+ python -m pip install -r requirements_test.txt
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
@@ -40,4 +41,4 @@ jobs:
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Test with pytest
run: |
- pytest
+ pytest -vv -s
diff --git a/.github/workflows/build-macos_pyqt6.yml b/.github/workflows/build-macos_pyqt6.yml
index 316be7c92..b615d713a 100644
--- a/.github/workflows/build-macos_pyqt6.yml
+++ b/.github/workflows/build-macos_pyqt6.yml
@@ -32,6 +32,7 @@ jobs:
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
python -m pip install -e .
python -m pip install -r requirements_gui_pyqt6.txt
+ python -m pip install -r requirements_test.txt
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
@@ -40,4 +41,4 @@ jobs:
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Test with pytest
run: |
- pytest
+ pytest -vv -s
diff --git a/.github/workflows/build-ubuntu_pyqt5.yml b/.github/workflows/build-ubuntu_pyqt5.yml
index c5952811c..d2fdd6d62 100644
--- a/.github/workflows/build-ubuntu_pyqt5.yml
+++ b/.github/workflows/build-ubuntu_pyqt5.yml
@@ -38,6 +38,7 @@ jobs:
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
python -m pip install -e .
python -m pip install -r requirements_gui_pyqt5.txt
+ python -m pip install -r requirements_test.txt
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
@@ -46,4 +47,4 @@ jobs:
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Test with pytest
run: |
- pytest
+ pytest -vv -s
diff --git a/.github/workflows/build-windows_pyqt5.yml b/.github/workflows/build-windows_pyqt5.yml
index 50daf8217..6dd0c1ea3 100644
--- a/.github/workflows/build-windows_pyqt5.yml
+++ b/.github/workflows/build-windows_pyqt5.yml
@@ -31,6 +31,7 @@ jobs:
python -m pip install flake8 pytest
python -m pip install -e .
python -m pip install -r requirements_gui_pyqt5.txt
+ python -m pip install -r requirements_test.txt
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
@@ -39,4 +40,4 @@ jobs:
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Test with pytest
run: |
- pytest
+ pytest -vv -s
diff --git a/.github/workflows/build-windows_pyqt6.yml b/.github/workflows/build-windows_pyqt6.yml
index 6d292d313..16e958ce0 100644
--- a/.github/workflows/build-windows_pyqt6.yml
+++ b/.github/workflows/build-windows_pyqt6.yml
@@ -31,6 +31,7 @@ jobs:
python -m pip install flake8 pytest
python -m pip install -e .
python -m pip install -r requirements_gui_pyqt6.txt
+ python -m pip install -r requirements_test.txt
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
@@ -39,4 +40,4 @@ jobs:
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Test with pytest
run: |
- pytest
+ pytest -vv -s
diff --git a/cellacdc/__init__.py b/cellacdc/__init__.py
index c4fcaf553..f286e50a4 100755
--- a/cellacdc/__init__.py
+++ b/cellacdc/__init__.py
@@ -89,7 +89,7 @@ def _warn_ask_install_package(
print(text)
message_on_exit = (
- '[WARNING]: Execution aborted. Run the following commands before '
+ '[WARNING]: Execution cancelled. Run the following commands before '
f'running spotMAX again:\n\n{commands_txt}\n'
)
msg_on_invalid = (
diff --git a/cellacdc/_main.py b/cellacdc/_main.py
index f220a3b91..f4f54388d 100644
--- a/cellacdc/_main.py
+++ b/cellacdc/_main.py
@@ -1029,7 +1029,7 @@ def getSelectedPosPath(self, utilityName):
buttonsTexts=('Cancel', 'Ok')
)
if msg.cancel:
- print(f'{utilityName} aborted by the user.')
+ print(f'{utilityName} cancelled by the user.')
return
mostRecentPath = myutils.getMostRecentPath()
@@ -1038,7 +1038,7 @@ def getSelectedPosPath(self, utilityName):
mostRecentPath
)
if not exp_path:
- print(f'{utilityName} aborted by the user.')
+ print(f'{utilityName} cancelled by the user.')
return
myutils.addToRecentPaths(exp_path)
@@ -1072,7 +1072,7 @@ def getSelectedPosPath(self, utilityName):
buttonsTexts=('Cancel', 'Try again')
)
if msg.cancel:
- print(f'{utilityName} aborted by the user.')
+ print(f'{utilityName} cancelled by the user.')
return
if len(posFolders) > 1:
@@ -1111,7 +1111,7 @@ def getSelectedExpPaths(
buttonsTexts=('Cancel', 'Ok')
)
if msg.cancel:
- self.logger.info(f'{utilityName} aborted by the user.')
+ self.logger.info(f'{utilityName} cancelled by the user.')
return
expPaths = {}
@@ -1150,7 +1150,7 @@ def getSelectedExpPaths(
if not selected_exp_paths:
cancel = self.warnNoValidExpPaths(exp_path)
if cancel:
- self.logger.info(f'{utilityName} aborted by the user.')
+ self.logger.info(f'{utilityName} cancelled by the user.')
return
continue
@@ -1162,7 +1162,7 @@ def getSelectedExpPaths(
selected_path, exp_path
)
if not proceed:
- self.logger.info(f'{utilityName} aborted by the user.')
+ self.logger.info(f'{utilityName} cancelled by the user.')
return
warn_exp_already_selected = False
expPaths[exp_path].extend(pos_folders)
@@ -1185,7 +1185,7 @@ def getSelectedExpPaths(
break
if not expPaths:
- self.logger.info(f'{utilityName} aborted by the user.')
+ self.logger.info(f'{utilityName} cancelled by the user.')
return
if len(expPaths) > 1 or is_multi_pos:
@@ -1197,7 +1197,7 @@ def getSelectedExpPaths(
)
selectPosWin.exec_()
if selectPosWin.cancel:
- self.logger.info(f'{utilityName} aborted by the user.')
+ self.logger.info(f'{utilityName} cancelled by the user.')
return
selectedExpPaths = selectPosWin.selectedPaths
else:
@@ -1839,7 +1839,7 @@ def _showDataStructWin(self):
buttonsTexts=('Cancel', *buttons)
)
if msg.cancel:
- self.logger.info('Creating data structure process aborted by the user.')
+ self.logger.info('Creating data structure process cancelled by the user.')
self.restoreDefaultButtons()
return
diff --git a/cellacdc/_run.py b/cellacdc/_run.py
index 3e615cf9c..fc85fd42e 100644
--- a/cellacdc/_run.py
+++ b/cellacdc/_run.py
@@ -434,11 +434,21 @@ def download_model_params():
)
print(e)
pass
-
+
def _setup_app(splashscreen=False, icon_path=None, logo_path=None, scheme=None):
from qtpy import QtCore
if QtCore.QCoreApplication.instance() is not None:
- return QtCore.QCoreApplication.instance(), None
+ class DummySplashScreen:
+ def __init__(self):
+ pass
+
+ def show(self):
+ pass
+
+ def close(self):
+ pass
+
+ return QtCore.QCoreApplication.instance(), DummySplashScreen()
from qtpy import QtWidgets
# Handle high resolution displays:
diff --git a/cellacdc/cca_functions.py b/cellacdc/cca_functions.py
index cd87389b5..5226920be 100755
--- a/cellacdc/cca_functions.py
+++ b/cellacdc/cca_functions.py
@@ -78,7 +78,7 @@ def configuration_dialog():
win.exec_()
if win.cancel:
print('******************************')
- print('Execution aborted by the user')
+ print('Execution cancelled by the user')
print('******************************')
raise InterruptedError
pos = win.selectedItemsText
@@ -131,7 +131,7 @@ def get_segm_endname(images_path, basename):
)
selectSegmWin.exec_()
if selectSegmWin.cancel:
- raise FileNotFoundError(f'Segmentation file selection aborted by the user.')
+ raise FileNotFoundError(f'Segmentation file selection cancelled by the user.')
return selectSegmWin.selectedItemsText[0]
diff --git a/cellacdc/cli.py b/cellacdc/cli.py
index 04b4e578f..dd08e8b2a 100644
--- a/cellacdc/cli.py
+++ b/cellacdc/cli.py
@@ -103,7 +103,7 @@ def quit(self, error=None):
print('-'*60)
self.logger.info(f'[ERROR]: {error}{error_up_str}')
err_msg = (
- 'Cell-ACDC aborted due to **error**. '
+ 'Cell-ACDC cancelled due to **error**. '
'More details above or in the following log file:\n\n'
f'{self.log_path}\n\n'
'If you cannot solve it, you can report this error by opening '
diff --git a/cellacdc/colors.py b/cellacdc/colors.py
index 0c71ef0e7..c8000f00c 100644
--- a/cellacdc/colors.py
+++ b/cellacdc/colors.py
@@ -20,9 +20,14 @@
try:
import networkx as nx
NETWORKX_INSTALLED = True
-except:
+except Exception as err:
NETWORKX_INSTALLED = False
+try:
+ from vispy.color import Colormap as VisPyColormap
+except Exception as err:
+ pass
+
__all__ = ['ColorMap']
FLUO_CHANNELS_COLORS = {
@@ -366,4 +371,29 @@ def grayscale_apply_lut(image, lut):
def get_complementary_color(rgba_str: str) -> str:
r, g, b, a = rgba_str_to_values(rgba_str)
- return f'rgba({255 - r}, {255 - g}, {255 - b}, {a})'
\ No newline at end of file
+ return f'rgba({255 - r}, {255 - g}, {255 - b}, {a})'
+
+def pg_to_vispy_cmap(pg_cmap, n=256):
+ """Convert PyQtGraph colormap to vispy
+
+ Parameters
+ ----------
+ pg_cmap : pyqtgraph.colormap.ColorMap
+ PyQtGraph Colormap. For example, it can be obtained with
+ `pyqtgraph.HistogramLUTItem.gradient.colorMap()`
+ n : int, optional
+ Number of colors, by default 256
+
+ Returns
+ -------
+ vispy.color.Colormap
+ VisPy colormap
+ """
+
+ # Sample the colormap
+ colors = pg_cmap.getLookupTable(0.0, 1.0, n)
+
+ # Normalize to 0–1 (VisPy expects floats)
+ colors = np.array(colors) / 255.0
+
+ return VisPyColormap(colors)
\ No newline at end of file
diff --git a/cellacdc/dataPrep.py b/cellacdc/dataPrep.py
index 10b558445..379d38aa5 100755
--- a/cellacdc/dataPrep.py
+++ b/cellacdc/dataPrep.py
@@ -2347,7 +2347,7 @@ def prepData(self, event):
)
if msg.cancel:
self.setEnabledCropActions(True)
- self.titleLabel.setText('Process aborted', color='w')
+ self.titleLabel.setText('Process cancelled', color='w')
return
if yes == msg.clickedButton:
doZip = True
diff --git a/cellacdc/dataStruct.py b/cellacdc/dataStruct.py
index b41dbbc28..92a61dcaf 100755
--- a/cellacdc/dataStruct.py
+++ b/cellacdc/dataStruct.py
@@ -1303,7 +1303,7 @@ def checkInstallPythonBioformats(self, parent):
cancel = myutils.install_javabridge_help(parent=self)
if cancel:
raise ModuleNotFoundError(
- 'User aborted javabridge installation'
+ 'User cancelled javabridge installation'
)
isGitInstalled = myutils.check_git_installed(parent=self)
@@ -1357,7 +1357,7 @@ def checkInstallPythonBioformats(self, parent):
cancel = myutils.install_java()
if cancel:
raise ModuleNotFoundError(
- 'User aborted Java installation'
+ 'User cancelled Java installation'
)
return
@@ -1371,7 +1371,7 @@ def checkInstallPythonBioformats(self, parent):
cancel = myutils.install_java()
if cancel:
raise ModuleNotFoundError(
- 'User aborted Java installation'
+ 'User cancelled Java installation'
)
return
myutils.install_javabridge(
diff --git a/cellacdc/gui.py b/cellacdc/gui.py
index 7247f462e..87b8e85f8 100755
--- a/cellacdc/gui.py
+++ b/cellacdc/gui.py
@@ -199,7 +199,23 @@ def inner_function(self, *args, **kwargs):
QTimer.singleShot(200, self.resetRange)
return result
return inner_function
-
+
+class _GuiWinRenderer3DAdapter:
+ """Thin adapter that wires a guiWin instance to VolumeRenderer3DWindow."""
+
+ def __init__(self, gui_win):
+ self._gui = gui_win
+
+ def get_current_zstack(self):
+ return self._gui._get_current_zstack()
+
+ def get_voxel_sizes(self):
+ return self._gui._get_current_voxel_sizes()
+
+ def on_renderer_closed(self):
+ pass
+
+
class guiWin(QMainWindow, whitelist.WhitelistGUIElements,
gui_combine.CombineGuiElements,
gui_combine.CombineGUIWorker):
@@ -295,6 +311,7 @@ def run(self, module='acdc_gui', logs_path=None):
self.progressWin = None
self.slideshowWin = None
self.ccaTableWin = None
+ self._renderer3d_window = None
self.exportToImageWindow = None
self.customAnnotButton = None
self.ccaCheckerRunning = False
@@ -891,11 +908,13 @@ def gui_createMenuBar(self):
self.viewMenu = menuBar.addMenu("&View")
self.viewMenu.addSeparator()
self.viewMenu.addAction(self.viewCcaTableAction)
+ self.viewMenu.addAction(self.launch3dRendererAction)
# Image menu
ImageMenu = menuBar.addMenu("&Image")
ImageMenu.addSeparator()
ImageMenu.addAction(self.imgPropertiesAction)
+ self.viewMenu.addAction(self.launch3dRendererAction)
self.defaultRescaleIntensLutMenu = ImageMenu.addMenu(
"Default method to rescale intensities (LUT)"
)
@@ -1069,6 +1088,7 @@ def gui_createToolBars(self):
navigateToolBar.addAction(self.findIdAction)
navigateToolBar.addWidget(self.zoomRectButton)
+ navigateToolBar.addAction(self.launch3dRendererAction)
self.slideshowButton = QToolButton(self)
self.slideshowButton.setIcon(QIcon(":eye-plus.svg"))
@@ -2897,6 +2917,15 @@ def gui_createActions(self):
)
self.manuallyEditCcaAction.setShortcut('Ctrl+Shift+P')
self.manuallyEditCcaAction.setDisabled(True)
+
+ self.launch3dRendererAction = QAction(
+ 'Launch 3D renderer...', self
+ )
+ self.launch3dRendererAction.setIcon(QIcon(":3d.svg"))
+ self.launch3dRendererAction.setToolTip(
+ 'Launch the 3D renderer in a separate window'
+ )
+ self.launch3dRendererAction.setDisabled(True)
self.viewCcaTableAction = QAction(
'View cell cycle annotations...', self
@@ -3368,6 +3397,9 @@ def gui_connectEditActions(self):
self.loadPosAction.triggered.connect(self.loadPosTriggered)
# self.reloadAction.triggered.connect(self.reload_cb)
self.findIdAction.triggered.connect(self.findID)
+ self.launch3dRendererAction.triggered.connect(
+ self.launch3dRendererButton.click
+ )
self.zoomRectButton.toggled.connect(self.zoomRectActionToggled)
self.autoPilotButton.toggled.connect(self.autoPilotToggled)
self.skipToNewIdAction.triggered.connect(self.skipForwardToNewID)
@@ -3917,6 +3949,12 @@ def gui_createImg1Widgets(self):
# z-slice scrollbars
self.zSliceScrollBar = widgets.linkedQScrollbar(Qt.Horizontal)
+
+ self.launch3dRendererButton = widgets.threeDPushButton()
+ self.launch3dRendererButton.setToolTip(
+ 'Launch the 3D renderer in a separate window'
+ )
+ self.launch3dRendererButton.hide()
self.zProjComboBox = widgets.ComboBox()
self.zProjComboBox.setFont(_font)
@@ -3924,7 +3962,7 @@ def gui_createImg1Widgets(self):
'single z-slice',
'max z-projection',
'mean z-projection',
- 'median z-proj.'
+ 'median z-proj.',
])
self.zProjLockViewButton = widgets.LockPushButton()
self.zProjLockViewButton.setCheckable(True)
@@ -4020,11 +4058,14 @@ def gui_getImg1BottomWidgets(self):
zSliceCheckboxLayout, row, 0, alignment=Qt.AlignRight
)
bottomLeftLayout.addWidget(self.zSliceScrollBar, row, 1, 1, 2)
- bottomLeftLayout.addWidget(self.zProjComboBox, row, 3)
- bottomLeftLayout.addWidget(self.zProjLockViewButton, row, 4)
- bottomLeftLayout.addWidget(self.switchPlaneCombobox, row, 5)
+ bottomLeftLayout.addWidget(self.launch3dRendererButton, row, 3)
+ bottomLeftLayout.addWidget(self.zProjComboBox, row, 4)
+ bottomLeftLayout.addWidget(self.zProjLockViewButton, row, 5)
+ bottomLeftLayout.addWidget(self.switchPlaneCombobox, row, 6)
self.zSliceSpinbox.connectValueChanged(self.onZsliceSpinboxValueChange)
- self.zSliceSpinbox.editingFinished.connect(self.zSliceScrollBarReleased)
+ self.zSliceSpinbox.editingFinished.connect(
+ self.zSliceScrollBarReleased
+ )
row += 1
bottomLeftLayout.addWidget(
@@ -12481,6 +12522,8 @@ def enableZstackWidgets(self, enabled):
self.zSliceScrollBar.show()
self.zSliceCheckbox.show()
self.zSliceSpinbox.show()
+ self.launch3dRendererButton.show()
+ self.launch3dRendererAction.setDisabled(False)
self.switchPlaneCombobox.show()
self.switchPlaneCombobox.setDisabled(False)
self.SizeZlabel.show()
@@ -12499,6 +12542,10 @@ def enableZstackWidgets(self, enabled):
self.SizeZlabel.hide()
self.switchPlaneCombobox.hide()
self.switchPlaneCombobox.setDisabled(True)
+ self.launch3dRendererButton.hide()
+ self.launch3dRendererAction.setDisabled(True)
+ # Close the 3D renderer if open — no z-stack data available.
+ self._hide_3d_renderer_if_open()
self.imgGrad.rescaleAcrossZstackAction.setDisabled(not enabled)
for ch, overlayItems in self.overlayLayersItems.items():
@@ -15612,7 +15659,7 @@ def repeatTrackingVideo(self, checked=False):
)
win.exec_()
if win.cancel:
- self.logger.info('Tracking aborted.')
+ self.logger.info('Tracking cancelled.')
return
trackerName = win.selectedItemsText[0]
@@ -15633,7 +15680,7 @@ def repeatTrackingVideo(self, checked=False):
posData, trackerName, qparent=self, return_init_params=True
)
if self.track_params is None:
- self.logger.info('Tracking aborted.')
+ self.logger.info('Tracking cancelled.')
return
warningText = myutils.validate_tracker_input(
@@ -15664,7 +15711,7 @@ def repeatTrackingVideo(self, checked=False):
last_cca_i, start_n
)
if not proceed:
- self.logger.info('Tracking aborted.')
+ self.logger.info('Tracking cancelled.')
return
self.logger.info(f'Removing annotations from frame n. {start_n}.')
@@ -17036,7 +17083,7 @@ def segmVideoCallback(self, action):
)
win.exec_()
if win.cancel:
- self.logger.info('Segmentation on multiple frames aborted.')
+ self.logger.info('Segmentation on multiple frames cancelled.')
return
idx = self.segmActionsVideo.index(action)
@@ -17257,8 +17304,8 @@ def repeatSegm(
)
)
if msg.cancel:
- self.titleLabel.setText('Segmentation process aborted.')
- self.logger.info('Segmentation process aborted.')
+ self.titleLabel.setText('Segmentation process cancelled.')
+ self.logger.info('Segmentation process cancelled.')
return
self.segment3D = msg.clickedButton == segment3DButton
if msg.doNotShowAgainCheckbox.isChecked():
@@ -17281,8 +17328,8 @@ def repeatSegm(
selectZtool.exec_()
self.update_z_slice(orignal_z)
if selectZtool.cancel:
- self.titleLabel.setText('Segmentation process aborted.')
- self.logger.info('Segmentation process aborted.')
+ self.titleLabel.setText('Segmentation process cancelled.')
+ self.logger.info('Segmentation process cancelled.')
return
startZ = selectZtool.lowerZscrollbar.value()
stopZ = selectZtool.upperZscrollbar.value()
@@ -17648,7 +17695,7 @@ def autoAssignBud_YeastMate(self):
)
win.exec_()
if win.cancel:
- self.titleLabel.setText('Segmentation aborted.')
+ self.titleLabel.setText('Segmentation cancelled.')
return
use_gpu = win.init_kwargs.get('gpu', False)
@@ -19849,6 +19896,7 @@ def connectScrollbars(self):
self.zProjComboBox.activated.disconnect()
self.switchPlaneCombobox.sigPlaneChanged.disconnect()
self.zProjLockViewButton.toggled.disconnect()
+ self.launch3dRendererButton.clicked.disconnect()
except Exception as e:
pass
self.zSliceScrollBar.actionTriggered.connect(
@@ -19857,6 +19905,9 @@ def connectScrollbars(self):
self.zSliceScrollBar.sliderReleased.connect(
self.zSliceScrollBarReleased
)
+ self.launch3dRendererButton.clicked.connect(
+ self._launch_3d_renderer
+ )
self.zProjComboBox.currentTextChanged.connect(self.updateZproj)
self.zProjComboBox.activated.connect(self.clearComboBoxFocus)
self.switchPlaneCombobox.sigPlaneChanged.connect(
@@ -20095,14 +20146,14 @@ def updateZproj(self, how):
for p, posData in enumerate(self.data[self.pos_i:]):
if self.zProjLockViewButton.isChecked():
idx = [
- (posData.filename, frame_i)
+ (posData.filename, frame_i)
for frame_i in range(posData.SizeT)
]
else:
idx = [(posData.filename, posData.frame_i)]
posData.segmInfo_df.loc[idx, 'which_z_proj_gui'] = how
posData.segmInfo_df.to_csv(posData.segmInfo_df_csv_path)
-
+
posData = self.data[self.pos_i]
if how == 'single z-slice':
self.zSliceScrollBar.setDisabled(False)
@@ -20116,6 +20167,223 @@ def updateZproj(self, how):
self.zSliceCheckbox.setDisabled(True)
self.setZprojDisabled(self.isSegm3D)
self.updateAllImages()
+
+ def _get_current_voxel_sizes(self):
+ """Return (dz, dy, dx) physical voxel sizes in µm, or None if unavailable."""
+ try:
+ posData = self.data[self.pos_i]
+ dz = float(getattr(posData, 'PhysicalSizeZ', 1.0) or 1.0)
+ dy = float(getattr(posData, 'PhysicalSizeY', 1.0) or 1.0)
+ dx = float(getattr(posData, 'PhysicalSizeX', 1.0) or 1.0)
+ return (dz, dy, dx)
+ except Exception:
+ return None
+
+ def _get_overlay_zstacks(self):
+ """Return list of (data, opacity, cmap) for each active overlay volume.
+
+ Covers three sources:
+ 1. Fluorescence overlay channels (alpha scrollbar opacity, toolbar gate).
+ 2. Primary segmentation mask — when 'overlay segm. masks' is selected
+ and labelsAlphaSlider > 0.
+ 3. Overlay labels channels — when the overlay-labels button is checked.
+ """
+ _FLUO_CMAPS = ['green', 'magenta', 'cyan', 'yellow', 'orange']
+ _LABEL_CMAPS = ['blue', 'cyan', 'magenta']
+ if not self.isDataLoaded:
+ return []
+ posData = self.data[self.pos_i]
+ if posData.SizeZ <= 1:
+ return []
+ result = []
+
+ # -- 1. Fluorescence overlay channels ----------------------------------
+ if getattr(self, 'checkedOverlayChannels', None):
+ for i, (chName, items) in enumerate(self.overlayLayersItems.items()):
+ if chName not in self.checkedOverlayChannels:
+ continue
+ imageItem, lutItem, alphaSB = items[:3]
+ toolbutton = items[3]
+ if not toolbutton.isChecked() or not toolbutton.isVisible():
+ continue
+ _, filename = self.getPathFromChName(chName, posData)
+ if filename is None or filename not in posData.ol_data_dict:
+ continue
+ data = posData.ol_data_dict[filename][posData.frame_i]
+ if data.ndim != 3:
+ continue
+ opacity = alphaSB.value() / alphaSB.maximum()
+ cmap = _FLUO_CMAPS[i % len(_FLUO_CMAPS)]
+ result.append((data, opacity, cmap))
+
+ # -- 2. Primary segmentation mask (2D or 3D segmentation) -------------
+ how = self.drawIDsContComboBox.currentText()
+ labels_alpha = self.imgGrad.labelsAlphaSlider.value()
+ if 'overlay segm. masks' in how and labels_alpha > 0 and posData.lab is not None:
+ lab = posData.lab
+ if lab.ndim == 3:
+ mask = (lab > 0).astype(np.float32)
+ elif lab.ndim == 2:
+ # 2D segmentation on a 3D z-stack: extrude along Z (cylinder)
+ mask = np.repeat(
+ (lab > 0).astype(np.float32)[np.newaxis], posData.SizeZ, axis=0
+ )
+ else:
+ mask = None
+ if mask is not None:
+ result.append((mask, float(labels_alpha), 'red', 'mip'))
+
+ # -- 3. Overlay label channels -----------------------------------------
+ ol_labels_active = (
+ getattr(self, 'overlayLabelsButton', None) is not None
+ and self.overlayLabelsButton.isChecked()
+ and getattr(self, 'drawModeOverlayLabelsChannels', None)
+ and posData.ol_labels_data is not None
+ )
+ if ol_labels_active:
+ for j, segmEndname in enumerate(self.drawModeOverlayLabelsChannels):
+ if segmEndname not in posData.ol_labels_data:
+ continue
+ ol_lab = posData.ol_labels_data[segmEndname][posData.frame_i]
+ if ol_lab.ndim == 3:
+ mask = (ol_lab > 0).astype(np.float32)
+ elif ol_lab.ndim == 2:
+ mask = np.repeat(
+ (ol_lab > 0).astype(np.float32)[np.newaxis], posData.SizeZ, axis=0
+ )
+ else:
+ continue
+ cmap = _LABEL_CMAPS[j % len(_LABEL_CMAPS)]
+ result.append((mask, 0.5, cmap, 'mip'))
+
+ return result
+
+ def _get_current_zstack(self):
+ """Return a (Z, Y, X) float32 array for the current position and frame.
+
+ For colour z-stacks (Z, Y, X, C) the luminance channel is extracted.
+ Returns None when no 3-D data is loaded.
+ """
+ if not self.isDataLoaded:
+ return None
+ posData = self.data[self.pos_i]
+ if posData.SizeZ <= 1:
+ return None
+ data = posData.img_data[posData.frame_i]
+ if data.ndim == 4:
+ # (Z, Y, X, C) — average channels to produce greyscale volume
+ data = data.mean(axis=-1)
+ if data.ndim != 3:
+ return None
+ return data
+
+ @exception_handler
+ def _launch_3d_renderer(self, *args, **kwargs):
+ """Create (if needed) and show the 3D renderer; feed current data."""
+ from . import renderer3d # renderer3d itself only needs numpy/qtpy
+
+ data = self._get_current_zstack()
+ if data is None:
+ return
+
+ myutils.check_install_package(
+ 'VisPy',
+ import_pkg_name='vispy',
+ pypi_name='vispy',
+ parent=self,
+ )
+
+ myutils.check_install_package(
+ 'PyOpenGL',
+ import_pkg_name='OpenGL',
+ pypi_name='PyOpenGL',
+ parent=self,
+ )
+
+ first_launch = self._renderer3d_window is None
+ if first_launch:
+ adapter = _GuiWinRenderer3DAdapter(self)
+ self._renderer3d_window = renderer3d.create_renderer(
+ parent=None,
+ hide_on_close=True,
+ adapter=adapter,
+ )
+ self._renderer3d_window.update_volume(data)
+ self._renderer3d_window.update_overlay_volumes(
+ self._get_overlay_zstacks()
+ )
+ voxel_sizes = self._get_current_voxel_sizes()
+ if voxel_sizes is not None:
+ self._renderer3d_window.set_voxel_scale(*voxel_sizes)
+
+ posData = self.data[self.pos_i]
+ self._renderer3d_window.setWindowTitle(
+ f'3D Z-Stack Renderer — '
+ f'Pos {self.pos_i + 1}, Frame {posData.frame_i + 1}'
+ )
+ if first_launch:
+ self._position_renderer3d_window()
+ self._renderer3d_window.show()
+ self._renderer3d_window.raise_()
+
+ def _position_renderer3d_window(self):
+ """Place the renderer window to the right of (or below) the main window."""
+ win = self._renderer3d_window
+ if win is None:
+ return
+ try:
+ screen = self.screen()
+ if screen is None:
+ return
+ available = screen.availableGeometry()
+ main = self.frameGeometry()
+ rw, rh = win.width(), win.height()
+ # Prefer: to the right of the main window, aligned at the top.
+ x = main.right() + 4
+ y = main.top()
+ if x + rw > available.right():
+ # Not enough space to the right — place below main window.
+ x = max(available.left(), main.left())
+ y = main.bottom() + 4
+ # Clamp to screen bounds.
+ x = max(available.left(), min(x, available.right() - rw))
+ y = max(available.top(), min(y, available.bottom() - rh))
+ win.move(x, y)
+ except Exception:
+ pass # positioning is best-effort
+
+ def _hide_3d_renderer_if_open(self):
+ """Hide the 3D renderer window without triggering the close-adapter callback."""
+ win = getattr(self, '_renderer3d_window', None)
+ if win is not None and win.isVisible():
+ # Temporarily detach the adapter so hiding doesn't trigger
+ # on_renderer_closed → setCurrentText → updateZproj recursion.
+ adapter = win._adapter
+ win._adapter = None
+ win.hide()
+ win._adapter = adapter
+
+ def _update_3d_renderer_if_active(self):
+ """Push new volume data to the renderer when the frame changes."""
+ if self._renderer3d_window is None:
+ return
+ if not self._renderer3d_window.isVisible():
+ return
+ data = self._get_current_zstack()
+ if data is None:
+ return
+ self._renderer3d_window.update_volume(data)
+ self._renderer3d_window.update_overlay_volumes(
+ self._get_overlay_zstacks()
+ )
+ voxel_sizes = self._get_current_voxel_sizes()
+ if voxel_sizes is not None:
+ self._renderer3d_window.set_voxel_scale(*voxel_sizes)
+ posData = self.data[self.pos_i]
+ self._renderer3d_window.setWindowTitle(
+ f'3D Z-Stack Renderer — '
+ f'Pos {self.pos_i + 1}, Frame {posData.frame_i + 1}'
+ )
def setZprojDisabled(self, disabled, storePrevState=False):
self.combineChannelsAction.setDisabled(disabled)
@@ -21933,7 +22201,7 @@ def initCca(self):
msg = 'Cell cycle analysis initialised!'
self.titleLabel.setText(msg, color='g')
elif msg.cancel:
- msg = 'Cell cycle analysis aborted.'
+ msg = 'Cell cycle analysis cancelled.'
self.logger.info(msg)
self.titleLabel.setText(msg, color=self.titleColor)
self.modeComboBox.setCurrentText(defaultMode)
@@ -21952,7 +22220,7 @@ def initCca(self):
buttonsTexts=('Yes', 'No', 'Cancel')
)
if msg.cancel:
- msg = 'Cell cycle analysis aborted.'
+ msg = 'Cell cycle analysis cancelled.'
self.logger.info(msg)
self.titleLabel.setText(msg, color=self.titleColor)
self.modeComboBox.setCurrentText(defaultMode)
@@ -22085,7 +22353,7 @@ def initLinTree(self, force=False):
msg = 'Lineage tree analysis initialised!'
self.titleLabel.setText(msg, color='g')
elif msg.cancel:
- msg = 'Lineage tree analysis aborted.'
+ msg = 'Lineage tree analysis cancelled.'
self.logger.info(msg)
self.titleLabel.setText(msg, color=self.titleColor)
self.modeComboBox.setCurrentText(defaultMode)
@@ -22113,7 +22381,7 @@ def initLinTree(self, force=False):
self.updateAllImages() # i dont think I need to change this
self.updateScrollbars() # i dont think I need to change this
elif msg.cancel:
- msg = 'Lineage tree analysis aborted.'
+ msg = 'Lineage tree analysis cancelled.'
self.logger.info(msg)
self.titleLabel.setText(msg, color=self.titleColor)
self.modeComboBox.setCurrentText(defaultMode)
@@ -24837,7 +25105,7 @@ def initLabelRoiModel(self):
self.initLabelRoiModelDialog = apps.QDialogSelectModel(parent=self)
self.initLabelRoiModelDialog.exec_()
if self.initLabelRoiModelDialog.cancel:
- self.logger.info('Magic labeller aborted.')
+ self.logger.info('Magic labeller cancelled.')
self.initLabelRoiModelDialog = None
return True
self.app.setOverrideCursor(Qt.WaitCursor)
@@ -24964,7 +25232,7 @@ def criticalFluoChannelNotFound(self, fluo_ch, posData):
'either one of the following files:
'
f'{posData.basename}{fluo_ch}.tif
'
f'{posData.basename}{fluo_ch}_aligned.npz
'
- 'Data loading aborted.'
+ 'Data loading cancelled.'
)
msg.addShowInFileManagerButton(posData.images_path)
okButton = msg.warning(
@@ -25089,6 +25357,8 @@ def getOlImg(self, key, frame_i=None):
ol_img = img.mean(axis=0)
elif zProjHow == 'median z-proj.':
ol_img = np.median(img, axis=0)
+ else:
+ ol_img = img[z].copy()
else:
ol_img = img.copy()
@@ -26079,16 +26349,16 @@ def get_2Dimg_from_3D(self, imgData, isLayer0=True, frame_i=None):
try:
z = posData.segmInfo_df.at[idx, 'z_slice_used_gui']
except ValueError as e:
- z = posData.segmInfo_df.loc[idx, 'z_slice_used_gui'].iloc[0]
+ z = posData.segmInfo_df.loc[idx, 'z_slice_used_gui'].iloc[0]
zProjHow = zProjHow_L0
else:
z = self.zSliceOverlay_SB.sliderPosition()
zProjHow_L1 = self.zProjOverlay_CB.currentText()
- if zProjHow_L1 == 'same as above':
+ if zProjHow_L1 == 'same as above':
zProjHow = zProjHow_L0
else:
zProjHow = zProjHow_L1
-
+
if zProjHow == 'single z-slice':
img = imgData[z] #.copy()
elif zProjHow == 'max z-projection':
@@ -26113,9 +26383,7 @@ def updateZsliceScrollbar(self, frame_i):
zProjHow = posData.segmInfo_df.at[idx, 'which_z_proj_gui']
except ValueError as e:
zProjHow = posData.segmInfo_df.loc[idx, 'which_z_proj_gui'].iloc[0]
-
- self.zProjComboBox.setCurrentText(zProjHow)
-
+
reconnect = False
try:
self.zSliceScrollBar.actionTriggered.disconnect()
@@ -27930,11 +28198,13 @@ def updateAllImages(
self.setManualBackgroundImage()
self.annotateAssignedObjsAcdcTrackerSecondStep()
- self.highlightSearchedID(self.highlightedID, force=True)
- self.updateTimestampFrame()
-
+ self.highlightSearchedID(self.highlightedID, force=True)
+ self.updateTimestampFrame()
+
posData.visited = True
+ self._update_3d_renderer_if_active()
+
def updateTimestampFrame(self):
if not hasattr(self, 'timestamp'):
return
@@ -29744,7 +30014,7 @@ def reinitCustomAnnot(self):
def loadingDataAborted(self):
self.openFolderAction.setEnabled(True)
- self.titleLabel.setText('Loading data aborted.')
+ self.titleLabel.setText('Loading data cancelled.')
def cleanUpOnError(self):
self.onEscape()
@@ -30124,13 +30394,15 @@ def overlayChannelToggled(self, checked):
self.loadOverlayData([channelName], addToExisting=True)
else:
_, filename = self.getPathFromChName(channelName, posData)
+ if posData.ol_data is None:
+ posData.ol_data = {}
posData.ol_data[filename] = (
posData.ol_data_dict[filename].copy()
)
-
- self.checkedOverlayChannels.add(channelName)
+
+ self.checkedOverlayChannels.add(channelName)
else:
- self.checkedOverlayChannels.remove(channelName)
+ self.checkedOverlayChannels.discard(channelName)
imageItem = self.overlayLayersItems[channelName][0]
imageItem.clear()
@@ -32868,6 +33140,13 @@ def closeEvent(self, event):
self.slideshowWin.close()
if self.ccaTableWin is not None:
self.ccaTableWin.close()
+ _r3d = getattr(self, '_renderer3d_window', None)
+ if _r3d is not None:
+ # Allow actual destruction (not just hide) so the OpenGL context
+ # is released before the Qt application exits.
+ _r3d._hide_on_close = False
+ _r3d.close()
+ self._renderer3d_window = None
proceed = self.askSaveOnClosing(event)
if not proceed:
diff --git a/cellacdc/load.py b/cellacdc/load.py
index 62a336464..b27ad744d 100755
--- a/cellacdc/load.py
+++ b/cellacdc/load.py
@@ -3619,7 +3619,7 @@ def on_closing(self):
self.root.quit()
self.root.destroy()
if self.allow_abort:
- exit('Execution aborted by the user')
+ exit('Execution cancelled by the user')
def load_shifts(parent_path, basename=None):
diff --git a/cellacdc/models/YeastMate/__init__.py b/cellacdc/models/YeastMate/__init__.py
index 138c1f61c..186683a18 100755
--- a/cellacdc/models/YeastMate/__init__.py
+++ b/cellacdc/models/YeastMate/__init__.py
@@ -45,7 +45,7 @@
cancel = myutils._install_package_msg('YeastMate')
if cancel:
raise ModuleNotFoundError(
- 'User aborted YeastMate installation'
+ 'User cancelled YeastMate installation'
)
subprocess.check_call(
diff --git a/cellacdc/myutils.py b/cellacdc/myutils.py
index f2695bcd3..26ea4a216 100644
--- a/cellacdc/myutils.py
+++ b/cellacdc/myutils.py
@@ -767,7 +767,7 @@ def checkDataIntegrity(filenames, parent_path, parentQWidget=None):
)
if msg.cancel:
raise TypeError(
- 'Process aborted by the user.'
+ 'Process cancelled by the user.'
)
return False
return True
@@ -3433,7 +3433,7 @@ def check_install_package(
if not proceed:
if raise_on_cancel:
raise ModuleNotFoundError(
- f'User aborted {pkg_name} installation'
+ f'User cancelled {pkg_name} installation'
)
else:
return traceback.format_exc()
@@ -3511,7 +3511,7 @@ def check_matplotlib_version(qparent=None):
proceed = _install_package_msg('matplotlib', parent=qparent, upgrade=True)
if not proceed:
raise ModuleNotFoundError(
- f'User aborted "matplotlib" installation'
+ f'User cancelled "matplotlib" installation'
)
import subprocess
try:
diff --git a/cellacdc/napari_utils/arboretum.py b/cellacdc/napari_utils/arboretum.py
index e23de9edf..53a8f1bb3 100644
--- a/cellacdc/napari_utils/arboretum.py
+++ b/cellacdc/napari_utils/arboretum.py
@@ -42,7 +42,7 @@ def launchNapariArboretum(self, posPath):
)
selectImageFile.exec_()
if selectImageFile.cancel:
- self.logger.info('napari-arboretum utility aborted.')
+ self.logger.info('napari-arboretum utility cancelled.')
return
imageFile = selectImageFile.selectedItemsText[0]
@@ -65,7 +65,7 @@ def launchNapariArboretum(self, posPath):
)
win.exec_()
if win.cancel:
- self.logger.info('napari-arboretum utility aborted.')
+ self.logger.info('napari-arboretum utility cancelled.')
return
selectedSegmEndName = win.selectedItemText
else:
@@ -97,7 +97,7 @@ def launchNapariArboretum(self, posPath):
)
selectProps.exec_()
if selectProps.cancel:
- self.logger.info('napari-arboretum utility aborted.')
+ self.logger.info('napari-arboretum utility cancelled.')
return
for col in selectProps.selectedItemsText:
diff --git a/cellacdc/prompts.py b/cellacdc/prompts.py
index 86e20a3cc..88f80d47b 100755
--- a/cellacdc/prompts.py
+++ b/cellacdc/prompts.py
@@ -288,7 +288,7 @@ def _test(self, name=None, index=None, mode=None):
def _abort(self):
self.was_aborted = True
if self.allow_abort:
- exit('Execution aborted by the user')
+ exit('Execution cancelled by the user')
def exportToImageFinished(filepath, qparent=None):
from cellacdc import widgets
diff --git a/cellacdc/renderer3d.py b/cellacdc/renderer3d.py
new file mode 100644
index 000000000..422175b38
--- /dev/null
+++ b/cellacdc/renderer3d.py
@@ -0,0 +1,1511 @@
+from __future__ import annotations
+from functools import partial
+
+import numpy as np
+
+from qtpy.QtWidgets import (
+ QCheckBox,
+ QComboBox,
+ QDoubleSpinBox,
+ QGroupBox,
+ QHBoxLayout,
+ QLabel,
+ QMainWindow,
+ QPushButton,
+ QSlider,
+ QVBoxLayout,
+ QWidget,
+ QGraphicsProxyWidget,
+ QGridLayout
+)
+from qtpy.QtCore import Qt
+from qtpy.QtGui import QKeySequence
+
+import pyqtgraph as pg
+
+from cellacdc import printl
+from cellacdc import widgets
+from cellacdc._run import _setup_app
+from cellacdc import colors
+
+from typing import Literal
+
+VolumeBlending = Literal[
+ "translucent",
+ "translucent_no_depth",
+ "additive",
+]
+
+# Mirrors napari._vispy.utils.gl.BLENDING_MODES (subset used for volumes).
+_BLENDING_MODES: dict[VolumeBlending, dict] = {
+ "translucent": {
+ "depth_test": True,
+ "cull_face": False,
+ "blend": True,
+ "blend_func": ("src_alpha", "one_minus_src_alpha", "one", "one"),
+ "blend_equation": "func_add",
+ },
+ "translucent_no_depth": {
+ "depth_test": False,
+ "cull_face": False,
+ "blend": True,
+ "blend_func": ("src_alpha", "one_minus_src_alpha", "one", "one"),
+ "blend_equation": "func_add",
+ },
+ "additive": {
+ "depth_test": False,
+ "cull_face": False,
+ "blend": True,
+ "blend_func": ("src_alpha", "dst_alpha", "one", "one"),
+ "blend_equation": "func_add",
+ },
+}
+
+def volume_gl_state(
+ blending: VolumeBlending,
+ *,
+ first_visible: bool,
+) -> dict:
+ """Return kwargs for ``vispy`` ``set_gl_state`` for a volume visual."""
+ state = dict(_BLENDING_MODES[blending])
+ if not first_visible:
+ return state
+
+ # Bottommost visible layer: avoid pathological blending with the canvas.
+ if blending == "additive":
+ src_color, dst_color = "src_alpha", "zero"
+ else:
+ src_color, dst_color = "src_alpha", "one_minus_src_alpha"
+ return {
+ "depth_test": state["depth_test"],
+ "cull_face": False,
+ "blend": True,
+ "blend_func": (src_color, dst_color, "one", "one"),
+ "blend_equation": "func_add",
+ }
+
+# ---------------------------------------------------------------------------
+# Rendering-mode registry
+# Matches exactly the set in vispy.scene.visuals.Volume._rendering_methods
+# ---------------------------------------------------------------------------
+RENDERING_MODES: list[tuple[str, str]] = [
+ ('mip', 'Max Intensity Projection (MIP)'),
+ ('minip', 'Min Intensity Projection'),
+ ('attenuated_mip', 'Attenuated MIP'),
+ ('iso', 'Isosurface'),
+ ('translucent', 'Translucent'),
+ ('additive', 'Additive'),
+ ('average', 'Average Intensity'),
+]
+
+COLORMAPS: list[str] = [
+ 'grays', 'viridis', 'hot', 'coolwarm', 'blues', 'reds',
+ 'greens', 'plasma', 'inferno', 'magma',
+]
+
+# Practical subset of vispy's 17 interpolation filters for 3D volumes.
+# 'Linear' is napari's default (interpolation3d); 'Nearest' is fastest.
+INTERPOLATION_MODES: list[tuple[str, str]] = [
+ ('linear', 'Linear'),
+ ('nearest', 'Nearest'),
+ ('catrom', 'Catmull-Rom (sharp)'),
+]
+
+# Gaussian sigma used for smooth-ISO pre-filter (approximates napari's
+# SMOOTH_GRADIENT_DEFINITION Sobel-Feldman shader without GLSL injection).
+_SMOOTH_ISO_SIGMA: float = 1.0
+
+# Modes that use the ISO threshold parameter
+_ISO_MODES: frozenset[str] = frozenset({'iso'})
+# Modes that use the attenuation parameter
+_ATTN_MODES: frozenset[str] = frozenset({'attenuated_mip'})
+# Modes where mip_cutoff = lower clim makes background transparent (napari approach)
+_MIP_CUTOFF_MODES: frozenset[str] = frozenset({'mip', 'attenuated_mip'})
+# Modes where minip_cutoff = upper clim makes bright values transparent
+_MINIP_CUTOFF_MODES: frozenset[str] = frozenset({'minip'})
+
+# Default ray-marching step size — matches napari and vispy defaults.
+# Smaller → more samples per ray → sharper but slower. Range: (0, 1].
+_DEFAULT_STEP_SIZE: float = 0.8
+
+# Depiction modes (napari: layer.depiction)
+# 'volume' = full 3D raycasting; 'plane' = single cross-section in 3D space.
+# For plane modes we store the axis index (0=Z, 1=Y, 2=X) in the data key.
+DEPICTION_MODES: list[tuple[str, str]] = [
+ ('volume', 'Volume'),
+ ('plane_z', 'XY-Plane (Z-axis)'),
+ ('plane_y', 'XZ-Plane (Y-axis)'),
+ ('plane_x', 'YZ-Plane (X-axis)'),
+]
+
+# Map depiction key → (plane_normal in scene XYZ, data-shape axis moved by slider)
+_PLANE_CONFIGS: dict[str, tuple[list[float], int]] = {
+ # normal (scene x,y,z) axis in data shape (z,y,x)
+ 'plane_z': ([0.0, 0.0, 1.0], 0), # normal along scene-Z = data-Z axis
+ 'plane_y': ([0.0, 1.0, 0.0], 1), # normal along scene-Y = data-Y axis
+ 'plane_x': ([1.0, 0.0, 0.0], 2), # normal along scene-X = data-X axis
+}
+
+
+# Plain colour names that _make_cmap converts to black→colour two-stop maps.
+# Anything else is forwarded directly to vispy as a standard colormap name.
+_PLAIN_COLOURS = frozenset({
+ 'red', 'green', 'blue', 'cyan', 'magenta', 'yellow', 'white', 'orange',
+})
+
+
+def _make_cmap(spec: str):
+ """Return a vispy colormap for *spec*.
+
+ Plain colour names ('red', 'green', …) produce a black→colour ramp so
+ the channel appears in that hue against the black renderer background.
+ Anything else is passed through as a standard vispy colormap name.
+ """
+ if spec in _PLAIN_COLOURS:
+ from vispy.color import Colormap # noqa: PLC0415
+ return Colormap(['black', spec])
+ return spec
+
+
+# ---------------------------------------------------------------------------
+# Pure-stdlib PNG writer (fallback when skimage is not available)
+# ---------------------------------------------------------------------------
+def _write_png(path: str, rgba: np.ndarray) -> None:
+ """Write an RGBA uint8 array to *path* as PNG using only stdlib."""
+ import struct, zlib # noqa: PLC0415
+
+ h, w = rgba.shape[:2]
+
+ def _chunk(tag: bytes, data: bytes) -> bytes:
+ c = struct.pack('>I', len(data)) + tag + data
+ return c + struct.pack('>I', zlib.crc32(tag + data) & 0xFFFFFFFF)
+
+ raw = b''.join(b'\x00' + rgba[y].tobytes() for y in range(h))
+ with open(path, 'wb') as f:
+ f.write(b'\x89PNG\r\n\x1a\n')
+ f.write(_chunk(b'IHDR', struct.pack('>IIBBBBB', w, h, 8, 6, 0, 0, 0)))
+ f.write(_chunk(b'IDAT', zlib.compress(raw, 9)))
+ f.write(_chunk(b'IEND', b''))
+
+
+# ---------------------------------------------------------------------------
+# Adapter protocol (for type-checking / documentation)
+# ---------------------------------------------------------------------------
+class VolumeRendererAdapter:
+ """
+ Minimal interface an application must implement to drive VolumeRenderer3DWindow.
+
+ Concrete examples live outside this module (e.g. in Cell_ACDC's gui.py).
+ The adapter is responsible for:
+ - Fetching the current (Z, Y, X) numpy array from the application data.
+ - Calling ``renderer.update_volume(data)`` and ``renderer.show()``.
+ """
+
+ def get_current_zstack(self) -> np.ndarray:
+ """Return the current (Z, Y, X) uint/float numpy array."""
+ raise NotImplementedError
+
+ def get_voxel_sizes(self) -> tuple[float, float, float] | None:
+ """Return (dz, dy, dx) physical voxel sizes in µm, or None if unknown."""
+ return None
+
+ def on_renderer_closed(self) -> None:
+ """Called when the renderer window is closed (hidden)."""
+
+
+# ---------------------------------------------------------------------------
+# Controls widget
+# ---------------------------------------------------------------------------
+class VolumeRendererControls(QWidget):
+ """Parameter panel that drives a VolumeRenderer3DWindow."""
+
+ def __init__(
+ self,
+ renderer: 'VolumeRenderer3DWindow',
+ parent: QWidget | None = None,
+ channels: list[str] | None = None,
+ ):
+ super().__init__(parent)
+ self._renderer = renderer
+ if channels is None:
+ channels = ['Channel 1'] # default single channel name
+ self._channels = channels
+ self._build()
+
+ def _build(self) -> None:
+ layout = widgets.FormLayout()
+ self.setLayout(layout)
+
+ row = 0
+ self._gamma_spin = widgets.sliderWithSpinBox(
+ title_loc='in_line',
+ isFloat=True,
+ parent=self,
+ normalize_factor=10
+ )
+
+ self._gamma_spin.setRange(0.1, 5.0)
+ self._gamma_spin.setSingleStep(0.1)
+ self._gamma_spin.setValue(1.0)
+ self._gamma_spin.setToolTip('Gamma correction')
+ self._gamma_spin.valueChanged.connect(self._on_gamma_changed)
+ _gamma_form_widget = widgets.formWidget(
+ self._gamma_spin,
+ labelTextLeft='Gamma:',
+ )
+ layout.addFormWidget(_gamma_form_widget, row=row)
+
+ row += 1
+ self._step_spin = widgets.sliderWithSpinBox(
+ title_loc='in_line',
+ isFloat=True,
+ parent=self,
+ normalize_factor=10
+ )
+ self._step_spin.setRange(0.1, 2.0)
+ self._step_spin.setSingleStep(0.1)
+ self._step_spin.setValue(_DEFAULT_STEP_SIZE)
+ self._step_spin.setDecimals(2)
+ self._step_spin.setToolTip(
+ 'Ray-marching step size relative to voxel size.\n'
+ 'Smaller = sharper rendering; larger = faster.'
+ )
+ self._step_spin.valueChanged.connect(self._on_step_changed)
+ _step_form_widget = widgets.formWidget(
+ self._step_spin,
+ labelTextLeft='Step:',
+ )
+ layout.addFormWidget(_step_form_widget, row=row)
+
+ row += 1
+ self._opacity_spins = {}
+ for r, channel in enumerate(self._channels):
+ opacity_spin = widgets.sliderWithSpinBox(
+ title_loc='in_line',
+ isFloat=True,
+ parent=self,
+ normalize_factor=20
+ )
+ opacity_spin.setRange(0.0, 1.0)
+ opacity_spin.setSingleStep(0.05)
+ opacity_spin.setValue(1.0)
+ opacity_spin.setDecimals(2)
+ opacity_spin.setToolTip(
+ f'Opacity for {channel} (0 = transparent, 1 = opaque).\n'
+ 'Mirrors napari\'s layer opacity control.'
+ )
+ opacity_spin.valueChanged.connect(
+ partial(self._on_opacity_changed, channel=channel)
+ )
+ _opacity_form_widget = widgets.formWidget(
+ opacity_spin,
+ labelTextLeft=f'Opacity ({channel}):',
+ )
+ layout.addFormWidget(_opacity_form_widget, row=row+r)
+ self._opacity_spins[channel] = opacity_spin
+
+ layout.addNewColumn(with_separator=True)
+
+ row = 0
+ self._mode_combo = QComboBox()
+ for mode_id, label in RENDERING_MODES:
+ self._mode_combo.addItem(label, mode_id)
+ self._mode_combo.currentIndexChanged.connect(self._on_mode_changed)
+ _mode_form_widget = widgets.formWidget(
+ self._mode_combo,
+ labelTextLeft='Rendering mode:',
+ )
+ layout.addFormWidget(_mode_form_widget, row=row)
+
+ # Interpolation (napari: interpolation3d)
+ row += 1
+ self._interp_combo = QComboBox()
+ for iid, ilabel in INTERPOLATION_MODES:
+ self._interp_combo.addItem(ilabel, iid)
+ self._interp_combo.setToolTip(
+ 'Volume texture interpolation (Linear = smooth, Nearest = pixelated)'
+ )
+ self._interp_combo.currentIndexChanged.connect(self._on_interp_changed)
+ _interp_form_widget = widgets.formWidget(
+ self._interp_combo,
+ labelTextLeft='Interpolation:',
+ )
+ layout.addFormWidget(_interp_form_widget, row=row)
+
+ row1 = QHBoxLayout()
+ row2 = QHBoxLayout()
+
+ # Rendering mode
+ row1.addWidget(QLabel('Render:'))
+ self._mode_combo = QComboBox()
+ for mode_id, label in RENDERING_MODES:
+ self._mode_combo.addItem(label, mode_id)
+ self._mode_combo.currentIndexChanged.connect(self._on_mode_changed)
+ row1.addWidget(self._mode_combo)
+
+ # ISO threshold + smooth option (iso mode only)
+ self._iso_label = QLabel('ISO:')
+ row1.addWidget(self._iso_label)
+ self._iso_spin = QDoubleSpinBox()
+ self._iso_spin.setRange(0.0, 1.0)
+ self._iso_spin.setSingleStep(0.01)
+ self._iso_spin.setValue(0.5)
+ self._iso_spin.setDecimals(3)
+ self._iso_spin.setFixedWidth(65)
+ self._iso_spin.setToolTip('Isosurface threshold')
+ self._iso_spin.valueChanged.connect(self._on_iso_changed)
+ row1.addWidget(self._iso_spin)
+
+ self._smooth_iso_cb = QCheckBox('Smooth')
+ self._smooth_iso_cb.setToolTip(
+ 'Pre-smooth the volume with a Gaussian filter (σ=1) before ISO\n'
+ 'rendering. Approximates napari\'s SMOOTH_GRADIENT_DEFINITION\n'
+ 'Sobel-Feldman shader — produces softer surface normals without\n'
+ 'requiring custom GLSL injection.'
+ )
+ self._smooth_iso_cb.toggled.connect(self._on_smooth_iso_changed)
+ row1.addWidget(self._smooth_iso_cb)
+
+ # Attenuation (attenuated_mip mode only)
+ self._attn_label = QLabel('Attn:')
+ row1.addWidget(self._attn_label)
+ self._attn_spin = QDoubleSpinBox()
+ self._attn_spin.setRange(0.0, 2.0)
+ self._attn_spin.setSingleStep(0.05)
+ self._attn_spin.setValue(0.5)
+ self._attn_spin.setDecimals(2)
+ self._attn_spin.setFixedWidth(60)
+ self._attn_spin.setToolTip('Attenuation factor for Attenuated MIP')
+ self._attn_spin.valueChanged.connect(self._on_attn_changed)
+ row1.addWidget(self._attn_spin)
+
+ row1.addStretch()
+
+ # ── Row 2: Display / camera parameters ───────────────────────────────
+
+
+
+ # Depiction (napari: layer.depiction — volume vs plane)
+ row2.addWidget(QLabel('Depict:'))
+ self._depict_combo = QComboBox()
+ for did, dlabel in DEPICTION_MODES:
+ self._depict_combo.addItem(dlabel, did)
+ self._depict_combo.setToolTip(
+ 'Volume: full 3D raycasting. '
+ 'Z-Plane: single cross-section embedded in 3D space.'
+ )
+ self._depict_combo.currentIndexChanged.connect(self._on_depiction_changed)
+ row2.addWidget(self._depict_combo)
+
+ self._zplane_label = QLabel('Pos:')
+ row2.addWidget(self._zplane_label)
+ self._zplane_slider = QSlider(Qt.Horizontal)
+ self._zplane_slider.setMinimum(0)
+ self._zplane_slider.setMaximum(99)
+ self._zplane_slider.setValue(50)
+ self._zplane_slider.setFixedWidth(80)
+ self._zplane_slider.setToolTip('Position of the cross-section plane (0=start, 100=end)')
+ self._zplane_slider.valueChanged.connect(self._on_zplane_changed)
+ row2.addWidget(self._zplane_slider)
+
+ # Plane thickness (napari: layer.plane.thickness / _on_plane_thickness_change)
+ self._plane_thick_label = QLabel('Thick:')
+ row2.addWidget(self._plane_thick_label)
+ self._plane_thick_spin = QDoubleSpinBox()
+ self._plane_thick_spin.setRange(1.0, 50.0)
+ self._plane_thick_spin.setSingleStep(1.0)
+ self._plane_thick_spin.setValue(1.0)
+ self._plane_thick_spin.setDecimals(1)
+ self._plane_thick_spin.setFixedWidth(55)
+ self._plane_thick_spin.setToolTip(
+ 'Thickness of the plane cross-section in voxels.\n'
+ 'Mirrors napari\'s plane.thickness parameter.'
+ )
+ self._plane_thick_spin.valueChanged.connect(self._on_plane_thickness_changed)
+ row2.addWidget(self._plane_thick_spin)
+
+ # Initially hide plane controls
+ self._zplane_label.setVisible(False)
+ self._zplane_slider.setVisible(False)
+ self._plane_thick_label.setVisible(False)
+ self._plane_thick_spin.setVisible(False)
+
+ row2.addStretch()
+
+ # Initial visibility for mode-specific controls
+ self._update_mode_controls('mip')
+
+ # -- mode-aware control visibility ----------------------------------------
+
+ def _update_mode_controls(self, mode: str) -> None:
+ show_iso = mode in _ISO_MODES
+ show_attn = mode in _ATTN_MODES
+ self._iso_label.setVisible(show_iso)
+ self._iso_spin.setVisible(show_iso)
+ self._smooth_iso_cb.setVisible(show_iso)
+ self._attn_label.setVisible(show_attn)
+ self._attn_spin.setVisible(show_attn)
+
+ # -- slots ----------------------------------------------------------------
+
+ def _on_mode_changed(self, idx: int) -> None:
+ mode = self._mode_combo.itemData(idx)
+ self._update_mode_controls(mode)
+ self._renderer.set_rendering_mode(mode)
+
+ def _on_gamma_changed(self, value: float) -> None:
+ self._renderer.set_gamma(value)
+
+ def _on_iso_changed(self, value: float) -> None:
+ self._renderer.set_iso_threshold(value)
+
+ def _on_attn_changed(self, value: float) -> None:
+ self._renderer.set_attenuation(value)
+
+ def _on_interp_changed(self, idx: int) -> None:
+ iid = self._interp_combo.itemData(idx)
+ self._renderer.set_interpolation(iid)
+
+ def _on_step_changed(self, value: float) -> None:
+ self._renderer.set_step_size(value)
+
+ def _on_smooth_iso_changed(self, checked: bool) -> None:
+ self._renderer.set_smooth_iso(checked)
+
+ def _on_depiction_changed(self, idx: int) -> None:
+ mode = self._depict_combo.itemData(idx)
+ is_plane = mode in _PLANE_CONFIGS
+ self._zplane_label.setVisible(is_plane)
+ self._zplane_slider.setVisible(is_plane)
+ self._plane_thick_label.setVisible(is_plane)
+ self._plane_thick_spin.setVisible(is_plane)
+ if is_plane:
+ # Label shows which data axis the slider moves along.
+ axis_names = {'plane_z': 'Z:', 'plane_y': 'Y:', 'plane_x': 'X:'}
+ self._zplane_label.setText(axis_names.get(mode, 'Pos:'))
+ # Reset the slider to centre so slider position always matches the
+ # rendered plane (set_depiction initialises the plane at 0.5).
+ self._zplane_slider.blockSignals(True)
+ self._zplane_slider.setValue(50)
+ self._zplane_slider.blockSignals(False)
+ self._renderer.set_depiction(mode)
+
+ def _on_zplane_changed(self, value: int) -> None:
+ self._renderer.set_zplane_position(value / 100.0)
+
+ def _on_plane_thickness_changed(self, value: float) -> None:
+ self._renderer.set_plane_thickness(value)
+
+ def _on_opacity_changed(self, value: float, channel: str) -> None:
+ self._renderer.set_opacity(value, channel=channel)
+
+
+# ---------------------------------------------------------------------------
+# Main renderer window
+# ---------------------------------------------------------------------------
+class VolumeRenderer3DWindow(QMainWindow):
+ """
+ A standalone Qt window that displays a 3D z-stack volume using vispy.
+
+ Usage (minimal)::
+
+ renderer = VolumeRenderer3DWindow()
+ renderer.update_volume(zstack_array) # (Z, Y, X) numpy array
+ renderer.run()
+
+ The window hides (rather than closes) when the user presses X so the GPU
+ state is preserved for re-display. Pass ``hide_on_close=False`` to
+ destroy on close instead.
+ """
+
+ def __init__(
+ self,
+ parent: QWidget | None = None,
+ hide_on_close: bool = False,
+ adapter: VolumeRendererAdapter | None = None,
+ ) -> None:
+
+ from vispy.scene import visuals
+
+ self.app, self.splashScreen = _setup_app(splashscreen=True)
+ super().__init__(parent)
+ self.setWindowTitle('3D Z-Stack Renderer')
+ self.resize(960, 720)
+
+ self._hide_on_close = hide_on_close
+ self._adapter = adapter
+ self._volume_nodes: dict[str, visuals.Volume] | None = None
+ self._volumes_data: dict[str, np.ndarray] | None = None
+ self.lut_items: dict[str, widgets.baseHistogramLUTitem] | None = None
+ self._overlay_nodes: list = []
+ self._overlay_mode_overrides: list = [] # str or None per overlay node
+ self._last_shape: tuple | None = None
+ self._max_texture_3d: int | None = None # resolved on first upload
+ self._smooth_iso: bool = False
+ self._gpu_data_is_smoothed: bool = False # tracks whether GPU texture has Gaussian filter
+ self._last_raw_data: np.ndarray | None = None # float32, for re-render
+
+ self._init_vispy()
+
+ # -- vispy setup ----------------------------------------------------------
+
+ def _init_vispy(self) -> None:
+ # Configure the backend to match the host Qt binding.
+ import vispy
+ from qtpy import API_NAME
+ vispy.use(API_NAME)
+
+ from vispy import scene # noqa: PLC0415 (late import required by vispy)
+
+ self._canvas = scene.SceneCanvas(keys='interactive', bgcolor='black')
+ self._view = self._canvas.central_widget.add_view()
+ # TurntableCamera: left-drag to orbit, scroll to zoom, right-drag to pan.
+ # Keeps the "up" axis fixed, which suits microscopy z-stacks better than
+ # a free arcball.
+ self._view.camera = scene.cameras.TurntableCamera(
+ fov=45, elevation=30.0, azimuth=-60.0
+ )
+
+ # XYZ axis indicator at the front-bottom-left corner of the volume.
+ # Red=X (data axis 2), Green=Y (data axis 1), Blue=Z (data axis 0).
+ # Scale and visibility are updated in update_volume on first load.
+ self._axis_visual = scene.visuals.XYZAxis(parent=self._view.scene)
+ self._axis_visual.visible = False
+
+ def _add_lut_items(self, scene_layout: QHBoxLayout) -> None:
+ self.lut_items_graphics_layout = pg.GraphicsLayoutWidget()
+ self.lut_items_graphics_layout.setBackground('black')
+ self.lut_items_layout = self.lut_items_graphics_layout.addLayout(
+ row=0, col=0
+ )
+ self.lut_items = {}
+ total_width = 0
+ for c, channel in enumerate(self.channels):
+ auto_btn = QPushButton('Auto')
+ auto_btn_proxy = QGraphicsProxyWidget()
+ auto_btn_proxy.setWidget(auto_btn)
+ self.lut_items_layout.addItem(auto_btn_proxy, row=0, col=c)
+
+ reset_btn = QPushButton('Reset')
+ reset_btn_proxy = QGraphicsProxyWidget()
+ reset_btn_proxy.setWidget(reset_btn)
+ self.lut_items_layout.addItem(reset_btn_proxy, row=1, col=c)
+
+ lut_item = widgets.baseHistogramLUTitem(
+ parent=self,
+ name=channel,
+ axisLabel=channel,
+ include_rescale_lut_options=False
+ )
+ self.lut_items[channel] = (lut_item, auto_btn, reset_btn)
+ self.lut_items_layout.addItem(lut_item, row=2, col=c)
+
+ lut_item.channel = channel
+
+ lut_item.sigLookupTableChanged.connect(self._on_lut_changed)
+ auto_btn.clicked.connect(
+ partial(self._on_auto_clim, lut_item=lut_item)
+ )
+ reset_btn.clicked.connect(
+ partial(self._on_reset_clim, lut_item=lut_item)
+ )
+
+ total_width += lut_item.sizeHint(Qt.PreferredSize).width()
+
+ scene_layout.addWidget(self.lut_items_graphics_layout, stretch=0)
+
+ # Add some padding to prevent clipping
+ self.lut_items_graphics_layout.setFixedWidth(int(total_width + 20))
+
+ def _on_lut_changed(self, lut_item: widgets.baseHistogramLUTitem) -> None:
+ ticks = lut_item.gradient.listTicks()
+ ticks_pos = [x for t, x in ticks]
+ min_val = min(ticks_pos) if ticks_pos else 0.0
+ max_val = max(ticks_pos) if ticks_pos else 1.0
+ self.set_clim(min_val, max_val, lut_item.channel)
+ self.set_cmap(lut_item)
+
+ def _on_auto_clim(self, lut_item: widgets.baseHistogramLUTitem) -> None:
+ lo, hi = self.get_auto_contrast_percentile()
+ max_tick_val = -np.inf
+ min_tick_val = np.inf
+ for tick, x in lut_item.gradient.listTicks():
+ if x > max_tick_val:
+ high_tick = tick
+ max_tick_val = x
+
+ if x < min_tick_val:
+ low_tick = tick
+ min_tick_val = x
+
+ lut_item.gradient.setTickValue(high_tick, hi)
+ lut_item.gradient.setTickValue(low_tick, lo)
+
+ def _on_reset_clim(self, lut_item: widgets.baseHistogramLUTitem) -> None:
+ lut_item.resetState()
+
+ # -- Qt UI ----------------------------------------------------------------
+
+ def _init_ui(self) -> None:
+ self.topToolBar = widgets.VolumeRendererToolbar(parent=self)
+ self.addToolBar(Qt.TopToolBarArea, self.topToolBar)
+
+ self.topToolBar.sigHomeView.connect(self.reset_view)
+ self.topToolBar.sigSave.connect(self.save_screenshot)
+
+ controls_box = QGroupBox('Rendering Controls')
+ self._controls = VolumeRendererControls(
+ self,
+ parent=controls_box,
+ channels=self.channels
+ )
+ box_layout = QVBoxLayout(controls_box)
+ box_layout.setContentsMargins(4, 4, 4, 4)
+ box_layout.addWidget(self._controls)
+
+ self.scene_layout = QHBoxLayout()
+
+ central = QWidget()
+ main_layout = QVBoxLayout(central)
+ main_layout.setContentsMargins(0, 0, 0, 0)
+ main_layout.setSpacing(0)
+ main_layout.addLayout(self.scene_layout)
+ main_layout.addWidget(controls_box)
+ self.setCentralWidget(central)
+
+ # Restore settings saved in a previous session.
+ # self._load_settings()
+
+ # -- Settings persistence -------------------------------------------------
+
+ # Class-level sentinels — always findable without triggering Qt's __getattr__
+ # on incompletely-initialised test instances created with __new__.
+ _axis_visual = None
+ # Per-axis downsampling strides used in the last upload (z, y, x).
+ # Stored so set_voxel_scale can correct for non-uniform stride compression.
+ _last_strides: tuple = (1, 1, 1)
+ # Last physical voxel sizes (µm) passed to set_voxel_scale.
+ # Auto-reapplied when a new volume node is created in update_volume so
+ # callers need not re-call set_voxel_scale after a shape change.
+ _voxel_dz: float = 1.0
+ _voxel_dy: float = 1.0
+ _voxel_dx: float = 1.0
+
+ _SETTINGS_ORG = 'Cell-ACDC'
+ _SETTINGS_APP = 'renderer3d'
+
+ def _load_settings(self) -> None:
+ """Restore rendering settings from a previous session via QSettings."""
+ from qtpy.QtCore import QSettings # noqa: PLC0415
+ s = QSettings(self._SETTINGS_ORG, self._SETTINGS_APP)
+ c = self._controls
+
+ # Restore combobox indices safely (bounds-check against current count).
+ mode_idx = s.value('mode_idx', 0, type=int)
+ c._mode_combo.setCurrentIndex(min(mode_idx, c._mode_combo.count() - 1))
+ interp_idx = s.value('interp_idx', 0, type=int)
+ c._interp_combo.setCurrentIndex(
+ min(interp_idx, c._interp_combo.count() - 1)
+ )
+
+ # Restore numeric spinboxes — clamp to widget range so stale values
+ # from older versions don't break the UI.
+ c._clim_min.setValue(
+ max(c._clim_min.minimum(),
+ min(s.value('clim_min', 0.0, type=float), c._clim_min.maximum()))
+ )
+ c._clim_max.setValue(
+ max(c._clim_max.minimum(),
+ min(s.value('clim_max', 1.0, type=float), c._clim_max.maximum()))
+ )
+ c._gamma_spin.setValue(
+ max(c._gamma_spin.minimum(),
+ min(s.value('gamma', 1.0, type=float), c._gamma_spin.maximum()))
+ )
+ c._step_spin.setValue(
+ max(c._step_spin.minimum(),
+ min(s.value('step_size', _DEFAULT_STEP_SIZE, type=float),
+ c._step_spin.maximum()))
+ )
+ c._smooth_iso_cb.setChecked(s.value('smooth_iso', False, type=bool))
+
+ # Depiction and plane parameters.
+ depict_idx = s.value('depict_idx', 0, type=int)
+ c._depict_combo.setCurrentIndex(
+ min(depict_idx, c._depict_combo.count() - 1)
+ )
+ c._plane_thick_spin.setValue(
+ max(c._plane_thick_spin.minimum(),
+ min(s.value('plane_thickness', 1.0, type=float),
+ c._plane_thick_spin.maximum()))
+ )
+ c._opacity_spin.setValue(
+ max(c._opacity_spin.minimum(),
+ min(s.value('opacity', 1.0, type=float),
+ c._opacity_spin.maximum()))
+ )
+
+ def _save_settings(self) -> None:
+ """Persist current rendering settings so they survive app restarts."""
+ from qtpy.QtCore import QSettings # noqa: PLC0415
+ s = QSettings(self._SETTINGS_ORG, self._SETTINGS_APP)
+ c = self._controls
+ s.setValue('mode_idx', c._mode_combo.currentIndex())
+ s.setValue('interp_idx', c._interp_combo.currentIndex())
+ s.setValue('clim_min', c._clim_min.value())
+ s.setValue('clim_max', c._clim_max.value())
+ s.setValue('gamma', c._gamma_spin.value())
+ s.setValue('step_size', c._step_spin.value())
+ s.setValue('smooth_iso', c._smooth_iso_cb.isChecked())
+ s.setValue('depict_idx', c._depict_combo.currentIndex())
+ s.setValue('plane_thickness', c._plane_thick_spin.value())
+ s.setValue('opacity', c._opacity_spin.value())
+
+ # -- GPU helpers ----------------------------------------------------------
+
+ def _resolve_max_texture_3d(self) -> int:
+ """Return the GPU's maximum 3-D texture size (cached after first call)."""
+ if self._max_texture_3d is not None:
+ return self._max_texture_3d
+ try:
+ from vispy.gloo.util import get_max_texture_sizes # noqa: PLC0415
+ _max_2d, max_3d = get_max_texture_sizes()
+ self._max_texture_3d = int(max_3d)
+ except Exception:
+ # Fallback: conservatively assume 512³ if GL query fails.
+ self._max_texture_3d = 512
+ return self._max_texture_3d
+
+ def _apply_mode_cutoffs_to(self, node, mode: str, lo: float, hi: float) -> None:
+ """Set mip_cutoff / minip_cutoff on *node* to make background transparent."""
+ if node is None:
+ return
+ if mode in _MIP_CUTOFF_MODES:
+ node.mip_cutoff = lo
+ if mode in _MINIP_CUTOFF_MODES:
+ node.minip_cutoff = hi
+
+ def _apply_mode_cutoffs(self, mode: str, lo: float, hi: float) -> None:
+ """Apply cutoffs to the primary volume node (wrapper for backwards compat)."""
+ self._apply_mode_cutoffs_to(self._volume_node, mode, lo, hi)
+
+ @staticmethod
+ def _downsample(vol: np.ndarray, max_size: int) -> np.ndarray:
+ """
+ Return a stride-subsampled view of *vol* so no dimension exceeds *max_size*.
+
+ Uses integer strides (fast, no interpolation) — suitable for interactive
+ previews. Returns the original array unchanged if no downsampling is needed.
+ """
+ strides = tuple(max(1, int(np.ceil(s / max_size))) for s in vol.shape)
+ if all(s == 1 for s in strides):
+ return vol
+ return np.ascontiguousarray(vol[::strides[0], ::strides[1], ::strides[2]])
+
+ def _preprocess_volume(self, volume: np.ndarray):
+ if volume.ndim != 3:
+ raise ValueError(
+ f'Expected 3-D (Z, Y, X) array; got shape {volume.shape}')
+
+ # copy=False avoids a redundant allocation when data is already float32
+ # (e.g. when _rerender calls update_volume(self._last_raw_data)).
+ vol = volume.astype(np.float32, copy=False)
+
+ # Cache raw float32 data so smooth-ISO toggle can re-process without
+ # requiring a frame navigation in the host application.
+ self._last_raw_data = vol
+ original_shape = vol.shape
+
+ # Compute the value range on the full-resolution data BEFORE downsampling
+ # so that stride-based subsampling cannot accidentally exclude extreme voxels
+ # and cause incorrect normalisation (e.g. a single bright fluorescence spot
+ # being dropped by the stride may lower the apparent maximum).
+ vmin, vmax = float(vol.min()), float(vol.max())
+
+ # Downsample to fit GPU texture limits (after range is already captured).
+ # Store the per-axis strides so set_voxel_scale can correct for
+ # non-uniform compression (e.g. stride_x=4 while stride_z=1).
+ max_tex = self._resolve_max_texture_3d()
+ if max(vol.shape) > max_tex:
+ strides = tuple(max(1, int(np.ceil(s / max_tex))) for s in vol.shape)
+ self._last_strides = strides
+ vol = self._downsample(vol, max_tex)
+ else:
+ self._last_strides = (1, 1, 1)
+
+ # Normalise the (possibly downsampled) array using the full-resolution range.
+ if vmax > vmin:
+ vol = (vol - vmin) / (vmax - vmin)
+ else:
+ vol = np.zeros_like(vol)
+
+ current_mode = self._controls._mode_combo.currentData() or 'mip'
+
+ # Smooth ISO pre-filter: approximates napari's SMOOTH_GRADIENT_DEFINITION
+ # (Sobel-Feldman 27-sample kernel) without requiring custom GLSL injection.
+ # Applied after normalisation so the threshold remains in [0, 1].
+ want_smooth = self._smooth_iso and current_mode in _ISO_MODES
+ smoothed = False
+ if want_smooth:
+ try:
+ import scipy.ndimage # noqa: PLC0415
+ vol = scipy.ndimage.gaussian_filter(vol, sigma=_SMOOTH_ISO_SIGMA)
+ smoothed = True
+ except ImportError:
+ pass # scipy not available — skip smoothing silently
+
+ self._gpu_data_is_smoothed = smoothed
+
+ return vol
+
+ def _init_volume_node(
+ self,
+ volume: np.ndarray,
+ channel_name: str,
+ update_canvas=False
+ ):
+ from vispy.scene import visuals
+
+ current_mode = self._controls._mode_combo.currentData() or 'mip'
+ current_interp = self._controls._interp_combo.currentData() or 'linear'
+ current_step = self._controls._step_spin.value()
+
+ lut_item = self.lut_items[channel_name][0]
+
+ pg_cmap = lut_item.gradient.colorMap()
+ current_cmap = colors.pg_to_vispy_cmap(pg_cmap)
+ ticks = lut_item.gradient.listTicks()
+ ticks_pos = [x for t, x in ticks]
+ min_val = min(ticks_pos) if ticks_pos else 0.0
+ max_val = max(ticks_pos) if ticks_pos else 1.0
+ clim = (min_val, max_val)
+
+ volume_node = visuals.Volume(
+ np.zeros((2, 2, 2), dtype=np.float32),
+ method="mip",
+ parent=self._view.scene,
+ )
+
+ # volume_node.gamma = self._controls._gamma_spin.value()
+ # volume_node.opacity = (
+ # self._controls._opacity_spins[channel_name].value()
+ # )
+
+ # if current_mode in _ATTN_MODES:
+ # self._volume_node.attenuation = self._controls._attn_spin.value()
+ # if current_mode in _ISO_MODES:
+ # self._volume_node.threshold = self._controls._iso_spin.value()
+
+ # self._apply_mode_cutoffs_to(
+ # volume_node, current_mode, clim[0], clim[1]
+ # )
+
+ # depict_mode = self._controls._depict_combo.currentData() or 'volume'
+
+ # if depict_mode in _PLANE_CONFIGS:
+ # volume_node.raycasting_mode = 'plane'
+ # # Pass vol.shape explicitly: _last_shape not yet updated here.
+ # self._set_plane_uniforms(
+ # depict_mode,
+ # self._controls._zplane_slider.value() / 100.0,
+ # shape=volume.shape,
+ # node=volume_node
+ # )
+
+ # self._apply_voxel_scale(volume_node)
+
+ # self._view.camera.set_range()
+
+ # from vispy.visuals.transforms import STTransform # noqa: PLC0415
+ # axis_scale = max(2.0, max(volume.shape) * 0.10)
+ # self._axis_visual.transform = STTransform(
+ # scale=(axis_scale, axis_scale, axis_scale)
+ # )
+ # self._axis_visual.visible = True
+
+ if update_canvas:
+ self._canvas.update()
+
+ return volume_node
+
+ def _get_clim(self, lut_item):
+ ticks = lut_item.gradient.listTicks()
+ ticks_pos = [x for t, x in ticks]
+ min_val = min(ticks_pos) if ticks_pos else 0.0
+ max_val = max(ticks_pos) if ticks_pos else 1.0
+ clim = (min_val, max_val)
+ return clim
+
+ # -- Public API -----------------------------------------------------------
+
+ def set_volume(
+ self,
+ volume: np.ndarray,
+ channel_name: None | str=None,
+ ):
+ channel_names = None
+ if channel_name is not None:
+ channel_names = [channel_name]
+
+ self.set_volumes([volume], channel_names)
+
+ def set_volumes(
+ self,
+ volumes: dict[str, np.ndarray] | list[np.ndarray],
+ channel_names: None | list[str]=None,
+ ):
+ if self._volumes_data is None:
+ self._volumes_data = {}
+ elif channel_names is not None:
+ channel_names = [*self.channels, *channel_names]
+
+ num_volumes = len(self._volumes_data) + len(volumes)
+
+ if not isinstance(volumes, dict):
+ if channel_names is None:
+ keys = [
+ f'Channel {ch_idx+1}' for ch_idx in range(num_volumes)
+ ]
+
+ volumes = dict(zip(keys, volumes))
+
+ self.channels = list(volumes.keys())
+
+ self._init_ui()
+
+ if self._volume_nodes is None:
+ self._volume_nodes = {}
+
+ if self.lut_items is None:
+ lut_items = self._add_lut_items(self.scene_layout)
+ self.scene_layout.addWidget(self._canvas.native, stretch=1)
+
+ for channel, volume in volumes.items():
+ vol = self._preprocess_volume(volume)
+ self._volumes_data[channel] = vol
+
+ vol_node = self._init_volume_node(
+ vol, channel, update_canvas=False
+ )
+ self._volume_nodes[channel] = vol_node
+
+ i = 0
+ for channel, node in self._volume_nodes.items():
+ node.order = i
+ node.set_data(self._volumes_data[channel])
+ node.opacity = 0.5
+ blending = "translucent_no_depth" if i == 0 else "additive"
+ node.set_gl_state(
+ **volume_gl_state(blending, first_visible=i==0)
+ )
+ i += 1
+
+
+ self._canvas.update()
+
+ def update_volume(
+ self,
+ data: np.ndarray,
+ channel_name: None | str=None,
+ channel_index: None | int=None
+ ) -> None:
+ """
+ Replace the displayed volume with *data*.
+
+ Parameters
+ ----------
+ data:
+ A (Z, Y, X) array of any numeric dtype. Values are normalised to
+ float32 [0, 1] before upload; existing contrast limits are preserved.
+ Data is automatically downsampled if any dimension exceeds the GPU's
+ maximum 3-D texture size.
+ """
+ vol = self._preprocess_volume(data)
+
+ if channel_name is None and channel_index is None:
+ raise ValueError(
+ 'Both `channel_name` and `channel_index` are None. '
+ 'Updating volume requires either one of them.'
+ )
+
+ if channel_index is not None and channel_index >= len(self.channels):
+ self.channels.append(f'Channel {channel_index+1}')
+ channel_name = self.channels[-1]
+ elif channel_index < len(self.channels):
+ channel_name = self.channels[channel_index]
+
+ if channel_name not in self._volumes_data.keys():
+ self.set_volume(data, channel_name)
+
+ current_mode = self._controls._mode_combo.currentData() or 'mip'
+ volume_node = self._volume_nodes[channel_name]
+
+ lut_item = self.lut_items[channel_name]
+
+ clim = self._get_clim(lut_item)
+ lo, hi = clim
+
+ volume_node.set_data(vol, clim=clim)
+ self._apply_mode_cutoffs_to(volume_node, current_mode, lo, hi)
+
+ # self._last_shape = vol.shape
+
+ self._canvas.update()
+
+ def update_overlay_volumes(
+ self,
+ overlays: list[tuple],
+ ) -> None:
+ """Replace the displayed overlay volumes.
+
+ Parameters
+ ----------
+ overlays:
+ List of tuples ``(data, opacity, colormap[, mode_override])``.
+ *data* is a ``(Z, Y, X)`` array; *opacity* ∈ ``[0, 1]``;
+ *colormap* is a vispy colormap name. The optional fourth element
+ *mode_override* (str or None) forces a specific rendering mode for
+ this overlay independently of the primary volume's mode — pass
+ ``'mip'`` for binary masks so filled interiors are always visible
+ regardless of the primary's ISO/translucent mode.
+ Pass an empty list to clear all overlays.
+ """
+ from vispy.scene import visuals # noqa: PLC0415
+
+ for node in self._overlay_nodes:
+ node.parent = None
+ self._overlay_nodes.clear()
+ self._overlay_mode_overrides.clear()
+
+ if not overlays:
+ self._canvas.update()
+ return
+
+ max_tex = self._resolve_max_texture_3d()
+ primary_mode = self._controls._mode_combo.currentData() or 'mip'
+ current_interp = self._controls._interp_combo.currentData() or 'linear'
+ current_step = self._controls._step_spin.value()
+ depict_mode = self._controls._depict_combo.currentData() or 'volume'
+ is_plane = depict_mode in _PLANE_CONFIGS
+ plane_fraction = self._controls._zplane_slider.value() / 100.0
+
+ for entry in overlays:
+ data, opacity, cmap = entry[0], entry[1], entry[2]
+ mode_override = entry[3] if len(entry) > 3 else None
+ node_mode = mode_override or primary_mode
+
+ if data.ndim != 3:
+ continue
+ vol = data.astype(np.float32, copy=False)
+ vmin, vmax = float(vol.min()), float(vol.max())
+ if max(vol.shape) > max_tex:
+ vol = self._downsample(vol, max_tex)
+ if vmax > vmin:
+ vol = (vol - vmin) / (vmax - vmin)
+ else:
+ vol = np.zeros_like(vol)
+
+ node = visuals.Volume(
+ vol,
+ clim=(0.0, 1.0),
+ method=node_mode,
+ cmap=_make_cmap(cmap),
+ interpolation=current_interp,
+ relative_step_size=current_step,
+ parent=self._view.scene,
+ )
+ node.opacity = max(0.0, min(1.0, opacity))
+ node.gamma = self._controls._gamma_spin.value()
+ self._apply_mode_cutoffs_to(node, node_mode, 0.0, 1.0)
+ if node_mode in _ATTN_MODES:
+ node.attenuation = self._controls._attn_spin.value()
+ if node_mode in _ISO_MODES:
+ node.threshold = self._controls._iso_spin.value()
+ if is_plane:
+ node.raycasting_mode = 'plane'
+ self._set_plane_uniforms(depict_mode, plane_fraction,
+ shape=vol.shape, node=node)
+ self._overlay_nodes.append(node)
+ self._overlay_mode_overrides.append(mode_override)
+
+ # Apply the stored voxel-scale transform so overlays align with the
+ # primary volume even when dz≠dy≠dx.
+ self._apply_voxel_scale()
+
+ def set_rendering_mode(self, mode: str) -> None:
+ for channel, volume_node in self._volume_nodes.items():
+ # When entering ISO mode, the GPU texture must match the smooth flag.
+ # Re-upload if the smooth state changed since the last upload (e.g.
+ # the user toggled Smooth while in MIP mode, then switches to ISO —
+ # the GPU still has the old texture from the last update_volume call).
+ if mode in _ISO_MODES and (self._smooth_iso != self._gpu_data_is_smoothed):
+ volume_node.method = mode # set before _rerender reads it
+ self._rerender()
+ return
+
+ lut_item = self.lut_items[channel]
+
+ ticks = lut_item.gradient.listTicks()
+ ticks_pos = [x for t, x in ticks]
+ lo = min(ticks_pos) if ticks_pos else 0.0
+ hi = max(ticks_pos) if ticks_pos else 1.0
+
+ self._apply_mode_cutoffs_to(volume_node, mode, lo, hi)
+
+ if mode in _ISO_MODES:
+ volume_node.threshold = self._controls._iso_spin.value()
+ if mode in _ATTN_MODES:
+ volume_node.attenuation = self._controls._attn_spin.value()
+
+ self._canvas.update()
+
+ def set_clim(self, lo: float, hi: float, channel: str) -> None:
+ volume_node = self._volume_nodes[channel]
+ volume_node.clim = (lo, hi)
+ current_mode = self._controls._mode_combo.currentData() or 'mip'
+ self._apply_mode_cutoffs_to(volume_node, current_mode, lo, hi)
+ self._canvas.update()
+
+ def set_cmap(self, lut_item):
+ cmap = colors.pg_to_vispy_cmap(lut_item.gradient.colorMap())
+ channel = lut_item.channel
+ volume_node = self._volume_nodes[channel]
+ volume_node.cmap = cmap
+ self._canvas.update()
+
+ def get_auto_contrast_percentile(
+ self, lo_pct: float = 1.0, hi_pct: float = 99.5
+ ) -> tuple[float, float]:
+ """
+ Set contrast limits to the *lo_pct*–*hi_pct* percentile of the raw
+ volume data, clipped to [0, 1] in the normalised space.
+
+ This is more useful for fluorescence microscopy than a full-range reset
+ because bright artefact voxels are excluded without manual adjustment.
+ Mirrors the spirit of napari's auto-contrast which maps the meaningful
+ intensity range rather than the absolute min–max.
+
+ Falls back to [0, 1] when no volume has been loaded yet.
+ """
+ if self._last_raw_data is None:
+ lo, hi = 0.0, 1.0
+ else:
+ raw = self._last_raw_data
+ vmin_raw = float(raw.min())
+ vmax_raw = float(raw.max())
+ if vmax_raw <= vmin_raw:
+ lo, hi = 0.0, 1.0
+ else:
+ # Subsample large volumes for speed (< 1 M samples is fast).
+ flat = raw.ravel()
+ if flat.size > 1_000_000:
+ step = flat.size // 1_000_000 + 1
+ flat = flat[::step]
+ p_lo = float(np.percentile(flat, lo_pct))
+ p_hi = float(np.percentile(flat, hi_pct))
+ span = vmax_raw - vmin_raw
+ lo = max(0.0, min(1.0, (p_lo - vmin_raw) / span))
+ hi = max(0.0, min(1.0, (p_hi - vmin_raw) / span))
+ if hi <= lo:
+ lo, hi = 0.0, 1.0
+
+ return lo, hi
+
+ def set_gamma(self, value: float) -> None:
+ for volume_node in self._volume_nodes.values():
+ volume_node.gamma = value
+ self._canvas.update()
+
+ def set_opacity(self, value: float, channel: str | None = None) -> None:
+ """
+ Set the overall volume opacity (0 = fully transparent, 1 = opaque).
+
+ Mirrors napari's ``layer.opacity → node.opacity`` pathway. The effect
+ depends on the rendering mode: most visible in translucent and additive
+ modes; has no visual effect in MIP/MinIP (which project to a 2D plane).
+ """
+ for volume_node in self._volume_nodes.values():
+ volume_node.opacity = max(0.0, min(1.0, value))
+
+ self._canvas.update()
+
+ def set_iso_threshold(self, value: float) -> None:
+ for volume_node in self._volume_nodes.values():
+ volume_node.threshold = value
+
+ self._canvas.update()
+
+ def set_attenuation(self, value: float) -> None:
+ for volume_node in self._volume_nodes.values():
+ volume_node.attenuation = value
+
+ self._canvas.update()
+
+ def set_interpolation(self, method: str) -> None:
+ """Set 3D volume interpolation method (e.g. 'linear', 'nearest', 'catrom')."""
+ for volume_node in self._volume_nodes.values():
+ volume_node.interpolation = method
+
+ self._canvas.update()
+
+ def set_depiction(self, mode: str) -> None:
+ """
+ Switch between full volume raycasting and a planar cross-section.
+
+ Mirrors napari's ``layer.depiction`` which calls
+ ``node.raycasting_mode = str(layer.depiction)``.
+
+ Parameters
+ ----------
+ mode : {'volume', 'plane_z', 'plane_y', 'plane_x'}
+ 'volume' — full 3D raycasting.
+ 'plane_z' — XY cross-section (normal along Z).
+ 'plane_y' — XZ cross-section (normal along Y).
+ 'plane_x' — YZ cross-section (normal along X).
+ """
+ is_plane = mode in _PLANE_CONFIGS
+ if self._volume_node is not None:
+ self._volume_node.raycasting_mode = 'plane' if is_plane else 'volume'
+ if is_plane and self._last_shape is not None:
+ self._set_plane_uniforms(mode, 0.5)
+ for node in self._overlay_nodes:
+ node.raycasting_mode = 'plane' if is_plane else 'volume'
+ if is_plane and self._last_shape is not None:
+ self._set_plane_uniforms(mode, 0.5, node=node)
+ self._canvas.update()
+
+ def set_zplane_position(self, fraction: float) -> None:
+ """
+ Move the active cross-section plane to *fraction* ∈ [0, 1] of its axis.
+
+ The axis is determined by the currently selected depiction mode.
+ """
+ if self._last_shape is None:
+ return
+ current_mode = self._controls._depict_combo.currentData() or 'volume'
+ if current_mode not in _PLANE_CONFIGS:
+ return
+ if self._volume_node is not None:
+ self._set_plane_uniforms(current_mode, fraction)
+ for node in self._overlay_nodes:
+ self._set_plane_uniforms(current_mode, fraction, node=node)
+ self._canvas.update()
+
+ def _set_plane_uniforms(
+ self,
+ mode: str,
+ fraction: float,
+ shape: tuple[int, int, int] | None = None,
+ node=None,
+ ) -> None:
+ """Set plane_position/normal/thickness for the given plane mode.
+
+ Parameters
+ ----------
+ shape:
+ (NZ, NY, NX) to use for positioning. Defaults to
+ ``self._last_shape``. Must be supplied when called from
+ ``update_volume`` before ``_last_shape`` is updated.
+ node:
+ Volume node to update. Defaults to ``self._volume_node``.
+ """
+ if node is None:
+ node = self._volume_node
+ if node is None:
+ return
+ normal, axis = _PLANE_CONFIGS[mode]
+ if shape is None:
+ shape = self._last_shape
+ if shape is None:
+ return # no data loaded yet — cannot compute plane position
+ nz, ny, nx = shape
+
+ # Scene-space centre for the two axes orthogonal to the slice axis.
+ centers = [nx / 2 - 0.5, ny / 2 - 0.5, nz / 2 - 0.5]
+
+ # Move the plane along its axis: scene coords match data-shape order
+ # (scene-x=data-X, scene-y=data-Y, scene-z=data-Z).
+ # Voxel centres are at integer positions 0, 1, …, n-1 in scene space.
+ # vispy's shader maps: texture_coord = (scene_pos + 0.5) / shape
+ # so scene_pos = fraction*(n-1) gives texture_coord = (n*fraction)/n
+ # = fraction → fraction=0.5 lands exactly at the volume centre.
+ axis_scene = {0: 2, 1: 1, 2: 0}[axis] # data axis → scene axis index
+ n_along = shape[axis]
+ centers[axis_scene] = fraction * (n_along - 1)
+
+ thickness = (
+ self._controls._plane_thick_spin.value()
+ if self._controls is not None else 1.0
+ )
+ node.plane_normal = normal
+ node.plane_position = centers
+ node.plane_thickness = thickness
+
+ def set_plane_thickness(self, thickness: float) -> None:
+ """
+ Set the cross-section plane thickness in voxels.
+
+ Mirrors napari's ``layer.plane.thickness`` /
+ ``_on_plane_thickness_change``. Thickness ≥ 1 (one voxel).
+ Larger values produce a thicker slab that shows more context.
+ """
+ t = max(1.0, thickness)
+ if self._volume_node is not None:
+ self._volume_node.plane_thickness = t
+ for node in self._overlay_nodes:
+ node.plane_thickness = t
+ if self._volume_node is not None or self._overlay_nodes:
+ self._canvas.update()
+
+ def _rerender(self) -> None:
+ """Re-process and re-upload the last received volume (smooth ISO toggle)."""
+ if self._last_raw_data is not None:
+ self.update_volume(self._last_raw_data)
+
+ def set_smooth_iso(self, enabled: bool) -> None:
+ """
+ Toggle Gaussian pre-smoothing for ISO surface rendering.
+
+ When *enabled*, ``scipy.ndimage.gaussian_filter(vol, sigma=1)`` is applied
+ to the normalised volume before GPU upload whenever the rendering mode is
+ ``iso``. This approximates napari's SMOOTH_GRADIENT_DEFINITION (Sobel-
+ Feldman 27-sample kernel) without requiring custom GLSL shader injection.
+ The threshold control remains meaningful — smoothing is applied after
+ normalisation, so values are still in [0, 1].
+
+ The change takes effect immediately by re-uploading the cached volume data.
+ """
+ self._smooth_iso = enabled
+ current_mode = self._controls._mode_combo.currentData() or 'mip'
+ if current_mode in _ISO_MODES:
+ self._rerender()
+
+ def set_step_size(self, value: float) -> None:
+ """
+ Set the ray-marching relative step size (napari: relative_step_size).
+
+ Smaller values cast more rays per voxel → sharper but slower.
+ vispy default and napari default: 0.8. Range: (0, 2].
+ """
+ for volume_node in self._volume_nodes.values():
+ volume_node.relative_step_size = value
+
+ self._canvas.update()
+
+ def set_voxel_scale(
+ self,
+ dz: float = 1.0,
+ dy: float = 1.0,
+ dx: float = 1.0,
+ ) -> None:
+ """
+ Correct for anisotropic voxel sizes by scaling the volume transform.
+
+ Parameters
+ ----------
+ dz, dy, dx:
+ Physical voxel size in µm along Z, Y, X. All three are normalised
+ to *dx* so the rendered volume has correct physical proportions.
+
+ The scale also accounts for per-axis downsampling strides (stored in
+ ``_last_strides`` from the last ``update_volume`` call). When the GPU
+ texture limit forces non-uniform downsampling (e.g. stride_x=4 while
+ stride_z=1), each downsampled voxel along X spans ``stride_x × dx``
+ physical µm. Ignoring this would compress the X axis by 4×.
+
+ Example — 100×512×2048 confocal stack on a 512-texture GPU:
+ Physical voxels: dz=1 µm, dy=0.2 µm, dx=0.2 µm
+ Downsampling: stride=(1, 1, 4) → shape (100, 512, 512)
+ Effective sizes: dz_eff=1, dy_eff=0.2, dx_eff=0.8 µm
+ Scale: (1.0, 0.2/0.8, 1.0/0.8) = (1, 0.25, 1.25)
+ """
+ # Persist so the transform is re-applied automatically when the node is
+ # rebuilt due to a shape change (update_volume calls _apply_voxel_scale).
+ self._voxel_dz, self._voxel_dy, self._voxel_dx = dz, dy, dx
+ if self._volume_node is None:
+ return
+ self._apply_voxel_scale()
+
+ def _apply_voxel_scale(self, node=None) -> None:
+ """Apply the stored voxel scale and stride correction to all volume nodes."""
+ if node is None:
+ node = self._volume_node
+
+ from vispy.visuals.transforms import STTransform # noqa: PLC0415
+ sz, sy, sx = self._last_strides
+ dx_eff = self._voxel_dx * sx
+ dy_eff = self._voxel_dy * sy
+ dz_eff = self._voxel_dz * sz
+ if dx_eff <= 0:
+ dx_eff = 1.0
+ transform = STTransform(scale=(1.0, dy_eff / dx_eff, dz_eff / dx_eff))
+ node.transform = transform
+ self._canvas.update()
+
+ def reset_view(self) -> None:
+ """Reset the camera to the default orientation and fit the volume."""
+ self._view.camera.set_range()
+ self._view.camera.elevation = 30.0
+ self._view.camera.azimuth = -60.0
+ self._canvas.update()
+
+ def save_screenshot(self) -> None:
+ """Save the current 3D view as a PNG file via a file-save dialog."""
+ from qtpy.QtWidgets import QFileDialog # noqa: PLC0415
+
+ path, _ = QFileDialog.getSaveFileName(
+ self,
+ 'Save 3D View',
+ 'renderer3d_snapshot.png',
+ 'PNG image (*.png);;TIFF image (*.tif *.tiff)',
+ )
+ if not path:
+ return
+
+ # canvas.render() returns (H, W, 4) uint8 RGBA
+ frame = self._canvas.render(alpha=True)
+
+ try:
+ import skimage.io as skio # noqa: PLC0415
+ skio.imsave(path, frame, check_contrast=False)
+ except ImportError:
+ # stdlib PNG fallback — TIFF requires skimage; normalise extension.
+ if not path.lower().endswith('.png'):
+ path = path.rsplit('.', 1)[0] + '.png'
+ _write_png(path, frame)
+
+ self.statusBar().showMessage(f'Saved → {path}', 4000)
+
+ # -- Qt overrides ---------------------------------------------------------
+
+ def closeEvent(self, event):
+ # self._save_settings()
+ if self._hide_on_close:
+ event.ignore()
+ self.hide()
+ if self._adapter is not None:
+ self._adapter.on_renderer_closed()
+ else:
+ super().closeEvent(event)
+
+ def run(self) -> None:
+ """Start the Qt event loop (if not already running)."""
+ super().show()
+ self.splashScreen.close()
+ self.app.exec_()
+
+
+# ---------------------------------------------------------------------------
+# Convenience factory
+# ---------------------------------------------------------------------------
+def create_renderer(
+ parent: QWidget | None = None,
+ hide_on_close: bool = True,
+ adapter: VolumeRendererAdapter | None = None,
+ ) -> VolumeRenderer3DWindow:
+ """Return a new :class:`VolumeRenderer3DWindow` instance."""
+ return VolumeRenderer3DWindow(
+ parent=parent,
+ hide_on_close=hide_on_close,
+ adapter=adapter,
+ )
diff --git a/cellacdc/test_segm_model.py b/cellacdc/test_segm_model.py
index 6cc950975..8f78ea4e8 100755
--- a/cellacdc/test_segm_model.py
+++ b/cellacdc/test_segm_model.py
@@ -97,7 +97,7 @@
win.exec_()
if win.cancel:
- sys.exit('Execution aborted')
+ sys.exit('Execution cancelled')
model_name = win.selectedItemsText[0]
if model_name == 'Automatic thresholding':
diff --git a/cellacdc/test_tracker.py b/cellacdc/test_tracker.py
index 2edc18be1..8f5570e88 100644
--- a/cellacdc/test_tracker.py
+++ b/cellacdc/test_tracker.py
@@ -75,7 +75,7 @@
win.exec_()
if win.cancel:
- sys.exit('Execution aborted')
+ sys.exit('Execution cancelled during tracker selection')
trackerName = win.selectedItemsText[0]
@@ -84,7 +84,7 @@
posData, trackerName, qparent=None, realTime=REAL_TIME_TRACKER
)
if track_params is None:
- exit('Execution aborted')
+ exit('Execution cancelled during tracker initialization')
print(posData.segm_data.shape)
lab_stack = posData.segm_data[START_FRAME:STOP_FRAME+1]
diff --git a/cellacdc/utils/acdcToSymDiv.py b/cellacdc/utils/acdcToSymDiv.py
index 24f514f2f..f4a35a7da 100644
--- a/cellacdc/utils/acdcToSymDiv.py
+++ b/cellacdc/utils/acdcToSymDiv.py
@@ -218,10 +218,10 @@ def workerFinished(self, worker):
self.progressWin.close()
if worker.abort:
- txt = 'Adding lineage tree table ABORTED.'
+ txt = 'Adding lineage tree table cancelled.'
self.logger.info(txt)
msg = widgets.myMessageBox(wrapText=False, showCentered=False)
- msg.warning(self, 'Process aborted', html_utils.paragraph(txt))
+ msg.warning(self, 'Process cancelled', html_utils.paragraph(txt))
elif worker.errors or worker.missingAnnotErrors:
if worker.errors:
self.warnErrors(worker.errors)
diff --git a/cellacdc/utils/convert.py b/cellacdc/utils/convert.py
index 987d03487..cf7489d80 100755
--- a/cellacdc/utils/convert.py
+++ b/cellacdc/utils/convert.py
@@ -422,18 +422,18 @@ def addToRecentPaths(self, exp_path):
def doAbort(self):
if self.allowExit:
- exit('Execution aborted by the user')
+ exit('Execution cancelled by the user')
else:
- print('Conversion task aborted by the user.')
+ print('Conversion task cancelled by the user.')
return True
def closeEvent(self, event):
if not self.success:
msg = widgets.myMessageBox(showCentered=False)
txt = html_utils.paragraph("""
- Conversion process aborted.
+ Conversion process cancelled.
""")
- msg.warning(self, 'Process aborted', txt)
+ msg.warning(self, 'Process cancelled', txt)
if self.actionToEnable is not None:
self.actionToEnable.setDisabled(False)
diff --git a/cellacdc/utils/rename.py b/cellacdc/utils/rename.py
index 7801ad696..8e96e6cea 100755
--- a/cellacdc/utils/rename.py
+++ b/cellacdc/utils/rename.py
@@ -295,9 +295,9 @@ def addToRecentPaths(self, exp_path):
def doAbort(self):
if self.allowExit:
- exit('Execution aborted by the user')
+ exit('Execution cancelled by the user')
else:
- print('Conversion task aborted by the user.')
+ print('Conversion task cancelled by the user.')
return True
def closeEvent(self, event):
diff --git a/cellacdc/utils/repeat.py b/cellacdc/utils/repeat.py
index 25383a9bf..5aa8bdcee 100644
--- a/cellacdc/utils/repeat.py
+++ b/cellacdc/utils/repeat.py
@@ -142,8 +142,8 @@ def start(self):
)
if select_folder.cancel:
self.logger.info(
- 'Process aborted by the user '
- '(cancelled at Postion selection)'
+ 'Process cancelled by the user '
+ 'during Position selection.'
)
self.stop()
return
diff --git a/cellacdc/utils/trackSubCellObjects.py b/cellacdc/utils/trackSubCellObjects.py
index 87d25326f..80d444bca 100644
--- a/cellacdc/utils/trackSubCellObjects.py
+++ b/cellacdc/utils/trackSubCellObjects.py
@@ -80,13 +80,13 @@ def workerAborted(self):
def workerFinished(self, worker, aborted=False):
if aborted:
- txt = 'Tracking sub-cellular objects aborted.'
+ txt = 'Tracking sub-cellular objects cancelled.'
else:
txt = 'Tracking sub-cellular objects completed.'
self.logger.info(txt)
msg = widgets.myMessageBox(wrapText=False, showCentered=False)
if aborted:
- msg.warning(self, 'Process completed', html_utils.paragraph(txt))
+ msg.warning(self, 'Process cancelled', html_utils.paragraph(txt))
else:
msg.information(self, 'Process completed', html_utils.paragraph(txt))
super().workerFinished(worker)
diff --git a/cellacdc/widgets.py b/cellacdc/widgets.py
index b22e1cec4..bb81314bb 100755
--- a/cellacdc/widgets.py
+++ b/cellacdc/widgets.py
@@ -2979,14 +2979,17 @@ def closeEvent(self, event):
class FormLayout(QGridLayout):
def __init__(self):
QGridLayout.__init__(self)
+ self._col = 0
def addFormWidget(
- self, formWidget,
+ self,
+ formWidget,
leftLabelAlignment=Qt.AlignRight,
align=None,
row=0
):
- for col, item in enumerate(formWidget.items):
+ for i, item in enumerate(formWidget.items):
+ col = self._col + i
if col==0:
alignment = leftLabelAlignment
elif col==2:
@@ -3000,6 +3003,18 @@ def addFormWidget(
self.addWidget(item, row, col, alignment=alignment)
except TypeError:
self.addLayout(item, row, col)
+
+ def addNewColumn(self, with_separator=False, separator_width=5):
+ self._col = self.columnCount()
+ if not with_separator:
+ return
+
+ separator = QVLine()
+ self.addWidget(separator, 0, self._col, self.rowCount(), 1)
+ self.setColumnMinimumWidth(self._col, separator_width)
+ self.setColumnStretch(self._col, 0)
+
+ self._col += 1
def macShortcutToWindows(shortcut: str):
if shortcut is None:
@@ -5262,11 +5277,20 @@ class baseHistogramLUTitem(pg.HistogramLUTItem):
sigAddColormap = Signal(object, str)
sigRescaleIntes = Signal(object)
- def __init__(self, name='image', axisLabel='', parent=None, **kwargs):
+ def __init__(
+ self,
+ name='image',
+ axisLabel='',
+ parent=None,
+ include_rescale_lut_options=True,
+ **kwargs
+ ):
pg.GradientEditorItem = BaseGradientEditorItemLabels
super().__init__(**kwargs)
+ self._defaultState = super().saveState()
+
self.labelStyle = {'color': '#ffffff', 'font-size': '11px'}
if axisLabel:
@@ -5294,53 +5318,54 @@ def __init__(self, name='image', axisLabel='', parent=None, **kwargs):
self.gradient.menu.removeAction(HSV_action)
self.gradient.menu.removeAction(RGB_ation)
- # Rescale intensities (LUT)
- rescaleIntensMenu = self.gradient.menu.addMenu(
- 'Rescale intensities (LUT)'
- )
- rescaleActionGroup = QActionGroup(self)
- rescaleActionGroup.setExclusive(True)
-
- self.rescaleEach2DimgAction = QAction(
- 'Rescale each 2D image', rescaleIntensMenu
- )
- self.rescaleEach2DimgAction.setCheckable(True)
- self.rescaleEach2DimgAction.setChecked(True)
- rescaleActionGroup.addAction(self.rescaleEach2DimgAction)
- rescaleIntensMenu.addAction(self.rescaleEach2DimgAction)
-
- self.rescaleAcrossZstackAction = QAction(
- 'Rescale across z-stack', rescaleIntensMenu
- )
- self.rescaleAcrossZstackAction.setCheckable(True)
- self.rescaleAcrossZstackAction.setChecked(False)
- rescaleActionGroup.addAction(self.rescaleAcrossZstackAction)
- rescaleIntensMenu.addAction(self.rescaleAcrossZstackAction)
-
- self.rescaleAcrossTimeAction = QAction(
- 'Rescale across time frames', rescaleIntensMenu
- )
- self.rescaleAcrossTimeAction.setCheckable(True)
- self.rescaleAcrossTimeAction.setChecked(False)
- rescaleActionGroup.addAction(self.rescaleAcrossTimeAction)
- rescaleIntensMenu.addAction(self.rescaleAcrossTimeAction)
-
- self.customRescaleAction = QAction(
- 'Choose custom levels...', rescaleIntensMenu
- )
- self.customRescaleAction.setCheckable(True)
- rescaleActionGroup.addAction(self.customRescaleAction)
- rescaleIntensMenu.addAction(self.customRescaleAction)
-
- self.doNotRescaleAction = QAction(
- 'Do no rescale, display raw image', rescaleIntensMenu
- )
- self.doNotRescaleAction.setCheckable(True)
- rescaleActionGroup.addAction(self.doNotRescaleAction)
- rescaleIntensMenu.addAction(self.doNotRescaleAction)
-
- self.rescaleActionGroup = rescaleActionGroup
- rescaleActionGroup.triggered.connect(self.rescaleActionTriggered)
+ if include_rescale_lut_options:
+ # Rescale intensities (LUT)
+ rescaleIntensMenu = self.gradient.menu.addMenu(
+ 'Rescale intensities (LUT)'
+ )
+ rescaleActionGroup = QActionGroup(self)
+ rescaleActionGroup.setExclusive(True)
+
+ self.rescaleEach2DimgAction = QAction(
+ 'Rescale each 2D image', rescaleIntensMenu
+ )
+ self.rescaleEach2DimgAction.setCheckable(True)
+ self.rescaleEach2DimgAction.setChecked(True)
+ rescaleActionGroup.addAction(self.rescaleEach2DimgAction)
+ rescaleIntensMenu.addAction(self.rescaleEach2DimgAction)
+
+ self.rescaleAcrossZstackAction = QAction(
+ 'Rescale across z-stack', rescaleIntensMenu
+ )
+ self.rescaleAcrossZstackAction.setCheckable(True)
+ self.rescaleAcrossZstackAction.setChecked(False)
+ rescaleActionGroup.addAction(self.rescaleAcrossZstackAction)
+ rescaleIntensMenu.addAction(self.rescaleAcrossZstackAction)
+
+ self.rescaleAcrossTimeAction = QAction(
+ 'Rescale across time frames', rescaleIntensMenu
+ )
+ self.rescaleAcrossTimeAction.setCheckable(True)
+ self.rescaleAcrossTimeAction.setChecked(False)
+ rescaleActionGroup.addAction(self.rescaleAcrossTimeAction)
+ rescaleIntensMenu.addAction(self.rescaleAcrossTimeAction)
+
+ self.customRescaleAction = QAction(
+ 'Choose custom levels...', rescaleIntensMenu
+ )
+ self.customRescaleAction.setCheckable(True)
+ rescaleActionGroup.addAction(self.customRescaleAction)
+ rescaleIntensMenu.addAction(self.customRescaleAction)
+
+ self.doNotRescaleAction = QAction(
+ 'Do no rescale, display raw image', rescaleIntensMenu
+ )
+ self.doNotRescaleAction.setCheckable(True)
+ rescaleActionGroup.addAction(self.doNotRescaleAction)
+ rescaleIntensMenu.addAction(self.doNotRescaleAction)
+
+ self.rescaleActionGroup = rescaleActionGroup
+ rescaleActionGroup.triggered.connect(self.rescaleActionTriggered)
# Add custom colormap action
self.customCmapsMenu = self.gradient.menu.addMenu('Custom colormaps')
@@ -5506,6 +5531,7 @@ def addCustomGradient(self, gradient_name, gradient_ticks, restore=True):
# action.triggered.connect(self.gradient.contextMenuClicked)
action.delButton.clicked.connect(self.removeCustomGradient)
action.cmap = colors.pg_ticks_to_colormap(gradient_ticks['ticks'])
+ action.cmap.name = gradient_name
# self.gradient.menu.insertAction(self.saveColormapAction, action)
self.customCmapsMenu.addAction(action)
self.gradient.length = self.originalLength
@@ -5540,6 +5566,9 @@ def _askNameColormap(self):
cmapName = inputWin.answer
return cmapName
+ def resetState(self):
+ self.restoreState(self._defaultState)
+
def saveColormap(self):
cmapName = self._askNameColormap()
if cmapName is None:
@@ -6942,6 +6971,8 @@ def __init__(self, *args, **kwargs):
if isFloat is not None and isFloat:
self._isFloat = True
+ self._normalize_factor = kwargs.get('normalize_factor', 1.0)
+
self.slider = QSlider(Qt.Horizontal, self)
if self._normalize or self._isFloat:
@@ -6990,7 +7021,6 @@ def __init__(self, *args, **kwargs):
self.setLayout(layout)
-
if maximum_on_label is not None:
self.setMaximum(maximum_on_label)
self.labelMaximum.setText(f'/{maximum_on_label}')
@@ -7009,7 +7039,7 @@ def setValue(self, value, emitSignal=False):
if self._normalize:
valueInt = int(value*self.slider.maximum())
elif self._isFloat:
- valueInt = int(value)
+ valueInt = int(value*self._normalize_factor)
self.spinBox.valueChanged.disconnect()
self.spinBox.setValue(value)
@@ -7025,18 +7055,30 @@ def setValue(self, value, emitSignal=False):
self.sigValueChange.emit(self.value())
self.valueChanged.emit(self.value())
- def setMaximum(self, max, including_spinbox=False):
- self.slider.setMaximum(max)
+ def setMaximum(self, max_val, including_spinbox=False):
+ max_val_int = max_val
+ if isinstance(max_val, float):
+ max_val_int = int(max_val*self._normalize_factor)
+
+ self.slider.setMaximum(max_val_int)
if including_spinbox:
- self.spinBox.setMaximum(max)
+ self.spinBox.setMaximum(max_val)
def setSingleStep(self, step):
self.spinBox.setSingleStep(step)
- def setMinimum(self, min, including_spinbox=False):
- self.slider.setMinimum(min)
+ def setMinimum(self, min_val, including_spinbox=False):
+ min_val_int = min_val
+ if isinstance(min_val, float):
+ min_val_int = int(min_val*self._normalize_factor)
+
+ self.slider.setMinimum(min_val_int)
if including_spinbox:
- self.spinBox.setMinimum(min)
+ self.spinBox.setMinimum(min_val)
+
+ def setRange(self, min_val, max_val, including_spinbox=False):
+ self.setMinimum(min_val, including_spinbox=including_spinbox)
+ self.setMaximum(max_val, including_spinbox=including_spinbox)
def setSingleStep(self, step):
self.spinBox.setSingleStep(step)
@@ -7051,13 +7093,16 @@ def setTickInterval(self, interval):
self.slider.setTickInterval(interval)
def sliderValueChanged(self, val):
- self.spinBox.valueChanged.disconnect()
+ self.spinBox.blockSignals(True)
if self._normalize:
valF = val/self.slider.maximum()
self.spinBox.setValue(valF)
+ elif self._isFloat:
+ val_float = val / self._normalize_factor
+ self.spinBox.setValue(val_float)
else:
self.spinBox.setValue(val)
- self.spinBox.valueChanged.connect(self.spinboxValueChanged)
+ self.spinBox.blockSignals(False)
self.sigValueChange.emit(self.value())
self.valueChanged.emit(self.value())
@@ -12083,4 +12128,32 @@ def closeEvent(self, event):
if self.screenShotWin is not None:
self.screenShotWin.close()
- return super().closeEvent(event)
\ No newline at end of file
+ return super().closeEvent(event)
+
+class VolumeRendererToolbar(ToolBar):
+ sigHomeView = Signal()
+ sigSave = Signal()
+
+ def __init__(self, name='Volume Renderer Toolbar', parent=None):
+
+ super().__init__(name, parent)
+
+ self.parentWin = parent
+
+ self.setContextMenuPolicy(Qt.PreventContextMenu)
+
+ self.homeViewAction = QAction(QIcon(':home.svg'), 'Home view', self)
+ self.homeViewAction.setShortcut('H')
+ self.homeViewAction.setToolTip(
+ 'Reset the view to the default orientation and zoom level'
+ )
+ self.addAction(self.homeViewAction)
+
+ self.saveAction = QAction(QIcon(':file-save.svg'), 'Save', self)
+ self.saveAction.setShortcut('Ctrl+S')
+ self.saveAction.setToolTip(
+ 'Save the current view to PNG file'
+ )
+ self.addAction(self.saveAction)
+
+ self.saveAction.triggered.connect(self.sigSave.emit)
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index e96d7a741..a11398d9b 100755
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -127,11 +127,18 @@ deepsea = [
"munkres",
]
+renderer3d = [
+ "vispy>=0.16.1",
+ "PyOpenGL",
+]
+
all = [
"PyQt6",
"torchvision",
"tensorflow<2.16",
"tables",
+ "vispy>=0.16.1",
+ "PyOpenGL",
]
dev = [
"pytest",
diff --git a/requirements_test.txt b/requirements_test.txt
new file mode 100644
index 000000000..ad7752e3f
--- /dev/null
+++ b/requirements_test.txt
@@ -0,0 +1,2 @@
+vispy
+PyOpenGL
\ No newline at end of file
diff --git a/tests/test_renderer3d.py b/tests/test_renderer3d.py
new file mode 100644
index 000000000..72dc9a346
--- /dev/null
+++ b/tests/test_renderer3d.py
@@ -0,0 +1,998 @@
+"""Smoke tests for cellacdc.renderer3d.
+
+These tests verify module structure, constants, and numpy-only logic without
+requiring a running display or GPU context. Window/canvas creation is not
+tested here because it requires an OpenGL context.
+"""
+
+try:
+ import pytest
+ pytest.skip('skipping this test since it is gui based', allow_module_level=True)
+except Exception as e:
+ pass
+
+import numpy as np
+
+
+def test_module_imports():
+ """renderer3d must be importable without vispy being present at import time."""
+ from cellacdc import renderer3d # noqa: F401
+
+
+def test_rendering_modes_match_vispy():
+ """Every mode registered in renderer3d must be accepted by vispy.Volume."""
+ from cellacdc import renderer3d
+ import vispy
+ from qtpy import API_NAME
+ vispy.use(API_NAME)
+ from vispy.scene.visuals import Volume
+
+ vispy_methods = set(Volume._rendering_methods.keys())
+ for mode_id, _label in renderer3d.RENDERING_MODES:
+ assert mode_id in vispy_methods, (
+ f"Rendering mode '{mode_id}' in RENDERING_MODES is not a valid "
+ f"vispy Volume method. Valid methods: {sorted(vispy_methods)}"
+ )
+
+
+def test_rendering_modes_structure():
+ from cellacdc import renderer3d
+
+ assert len(renderer3d.RENDERING_MODES) > 0
+ for entry in renderer3d.RENDERING_MODES:
+ assert len(entry) == 2, "Each RENDERING_MODES entry must be (id, label)"
+ mode_id, label = entry
+ assert isinstance(mode_id, str) and mode_id
+ assert isinstance(label, str) and label
+
+
+def test_colormaps_non_empty():
+ from cellacdc import renderer3d
+
+ assert len(renderer3d.COLORMAPS) > 0
+ for cmap in renderer3d.COLORMAPS:
+ assert isinstance(cmap, str) and cmap
+
+
+def test_adapter_protocol():
+ """VolumeRendererAdapter subclass must raise NotImplementedError."""
+ from cellacdc.renderer3d import VolumeRendererAdapter
+
+ adapter = VolumeRendererAdapter()
+ with pytest.raises(NotImplementedError):
+ adapter.get_current_zstack()
+ # on_renderer_closed has a default no-op implementation
+ adapter.on_renderer_closed() # must not raise
+
+
+def test_create_renderer_callable():
+ """create_renderer must be callable with the documented signature."""
+ from cellacdc import renderer3d
+ import inspect
+
+ sig = inspect.signature(renderer3d.create_renderer)
+ params = set(sig.parameters)
+ assert 'parent' in params
+ assert 'hide_on_close' in params
+ assert 'adapter' in params
+
+
+def test_volume_normalisation():
+ """update_volume normalises data to [0, 1] float32 — verify on headless path."""
+ from cellacdc import renderer3d
+
+ def _norm(data):
+ # Mirrors update_volume: copy=False avoids redundant allocation for float32.
+ vol = data.astype(np.float32, copy=False)
+ vmin, vmax = float(vol.min()), float(vol.max())
+ if vmax > vmin:
+ vol = (vol - vmin) / (vmax - vmin)
+ else:
+ vol = np.zeros_like(vol)
+ assert vol.dtype == np.float32
+ assert vol.min() >= 0.0
+ assert vol.max() <= 1.0
+
+ rng = np.random.default_rng(42)
+ _norm(rng.integers(0, 65535, size=(10, 64, 64)).astype(np.uint16))
+ _norm(rng.random((5, 32, 32)).astype(np.float64))
+ _norm(np.full((4, 16, 16), 42, dtype=np.uint8)) # all-same → all zeros
+
+ # float32 input: copy=False returns same object; normalisation still correct
+ f32 = np.linspace(0, 1, 5 * 4 * 4, dtype=np.float32).reshape(5, 4, 4)
+ vol = f32.astype(np.float32, copy=False)
+ assert vol is f32 # no unnecessary copy
+ vmin, vmax = float(vol.min()), float(vol.max())
+ assert vmax > vmin
+
+
+def test_downsample_static():
+ """_downsample returns a smaller array when a dimension exceeds max_size."""
+ from cellacdc.renderer3d import VolumeRenderer3DWindow
+
+ ds = VolumeRenderer3DWindow._downsample
+
+ vol = np.zeros((200, 512, 512), dtype=np.float32)
+
+ # max_size=256 → each dim reduced by ceil(s/256)
+ out = ds(vol, 256)
+ assert all(s <= 256 for s in out.shape), f"Shape {out.shape} exceeds limit 256"
+ assert out.dtype == np.float32
+
+ # max_size large enough → no change
+ out_no_ds = ds(vol, 1024)
+ assert out_no_ds.shape == vol.shape
+
+ # Downsampling preserves dtype
+ vol_u16 = np.zeros((100, 300, 300), dtype=np.uint16)
+ out_u16 = ds(vol_u16, 256)
+ assert out_u16.dtype == np.uint16
+ assert all(s <= 256 for s in out_u16.shape)
+
+ # Stride logic: ceil(200/256)=1, ceil(512/256)=2
+ vol2 = np.arange(200 * 512 * 512, dtype=np.float32).reshape(200, 512, 512)
+ out2 = ds(vol2, 256)
+ assert out2.shape == (200, 256, 256)
+
+
+def test_volume_shape_validation():
+ """update_volume must raise ValueError for non-3D input."""
+ from cellacdc import renderer3d
+
+ class _HeadlessRenderer(renderer3d.VolumeRenderer3DWindow):
+ def _init_vispy(self): pass
+ def _init_ui(self): pass
+
+ r = _HeadlessRenderer.__new__(_HeadlessRenderer)
+ # Grab the normalisation-only path from the real class
+ original = renderer3d.VolumeRenderer3DWindow.update_volume
+
+ with pytest.raises(ValueError, match='3-D'):
+ original(r, np.zeros((64, 64))) # 2D
+
+ with pytest.raises(ValueError, match='3-D'):
+ original(r, np.zeros((1, 2, 3, 4))) # 4D
+
+
+def test_interpolation_modes_structure():
+ """INTERPOLATION_MODES entries must be (id, label) pairs."""
+ from cellacdc import renderer3d
+ assert len(renderer3d.INTERPOLATION_MODES) > 0
+ for entry in renderer3d.INTERPOLATION_MODES:
+ assert len(entry) == 2
+ iid, label = entry
+ assert isinstance(iid, str) and iid
+ assert isinstance(label, str) and label
+
+
+def test_interpolation_modes_valid_in_vispy():
+ """Every INTERPOLATION_MODES id must be a valid vispy Volume interpolation."""
+ from cellacdc import renderer3d
+ from vispy.io import load_spatial_filters
+ _, vispy_methods = load_spatial_filters()
+ valid = {m.lower() for m in vispy_methods}
+ for iid, label in renderer3d.INTERPOLATION_MODES:
+ assert iid in valid, (
+ f"Interpolation mode '{iid}' is not in vispy's filter set: {sorted(valid)}"
+ )
+
+
+def test_renderer_public_api():
+ """VolumeRenderer3DWindow must expose all expected public methods."""
+ from cellacdc.renderer3d import VolumeRenderer3DWindow
+ required = {
+ 'update_volume', 'set_rendering_mode', 'set_clim',
+ 'set_gamma', 'set_opacity', 'set_iso_threshold', 'set_attenuation',
+ 'set_interpolation', 'set_step_size', 'set_smooth_iso',
+ 'set_depiction', 'set_zplane_position', 'set_plane_thickness',
+ 'set_voxel_scale', 'reset_view', 'save_screenshot',
+ 'auto_contrast_percentile', # 'set_colormap'
+ }
+ missing = required - set(dir(VolumeRenderer3DWindow))
+ assert not missing, f"Missing public methods: {missing}"
+
+
+def test_adapter_get_voxel_sizes_default():
+ """VolumeRendererAdapter.get_voxel_sizes() must return None by default."""
+ from cellacdc.renderer3d import VolumeRendererAdapter
+ adapter = VolumeRendererAdapter()
+ result = adapter.get_voxel_sizes()
+ assert result is None
+
+
+def test_set_voxel_scale_noop_without_node():
+ """set_voxel_scale must be a no-op when no volume node exists."""
+ from cellacdc.renderer3d import VolumeRenderer3DWindow
+
+ class _Bare(VolumeRenderer3DWindow):
+ def _init_vispy(self):
+ if self._volume_node is None:
+ return
+ def _init_ui(self): self._controls = None
+
+ r = _Bare()
+ r._volume_node = None
+ r.set_voxel_scale(0.5, 0.2, 0.2) # must not raise
+ r.close()
+ del r
+
+
+def test_set_voxel_scale_stride_correction():
+ """set_voxel_scale must incorporate per-axis downsampling strides.
+
+ Scenario: 100×512×2048 volume downsampled with stride (1,1,4) to fit GPU.
+ Each downsampled X-voxel spans 4 × dx physical µm, so the effective dx
+ is 0.8 µm. The STTransform scale for (dz=1, dy=0.2, dx=0.2) with these
+ strides should be (1.0, 0.25, 1.25) not (1.0, 1.0, 5.0).
+ """
+ from cellacdc.renderer3d import VolumeRenderer3DWindow
+ from unittest.mock import MagicMock
+
+ class _Bare(VolumeRenderer3DWindow):
+ def _init_vispy(self):
+ if self._volume_node is None:
+ return
+ def _init_ui(self): self._controls = None
+
+ r = _Bare()
+ r._volume_node = MagicMock()
+ r._canvas = MagicMock()
+ r._last_strides = (1, 1, 4) # only X was downsampled
+
+ assigned_transforms = []
+ type(r._volume_node).transform = property(
+ fget=lambda self: None,
+ fset=lambda self, v: assigned_transforms.append(v),
+ )
+
+ r.set_voxel_scale(dz=1.0, dy=0.2, dx=0.2)
+
+ assert len(assigned_transforms) == 1
+ t = assigned_transforms[0]
+ scale = t.scale # (scene-x, scene-y, scene-z)
+ # Effective voxel sizes: dx_eff=0.8, dy_eff=0.2, dz_eff=1.0
+ # Expected scale: (1.0, 0.2/0.8, 1.0/0.8) = (1.0, 0.25, 1.25)
+ assert abs(scale[0] - 1.0) < 1e-6, f"scale-x wrong: {scale[0]}"
+ assert abs(scale[1] - 0.25) < 1e-6, f"scale-y wrong: {scale[1]}"
+ assert abs(scale[2] - 1.25) < 1e-6, f"scale-z wrong: {scale[2]}"
+
+ r.close()
+ del r
+
+
+def test_voxel_scale_persists_across_node_rebuild():
+ """_voxel_d* stored in set_voxel_scale must survive a volume node rebuild.
+
+ set_voxel_scale before update_volume is a common standalone pattern:
+ renderer.set_voxel_scale(dz=2.0, dy=1.0, dx=1.0)
+ renderer.update_volume(data)
+ The scale must be applied even though the node didn't exist yet when
+ set_voxel_scale was called.
+ """
+ from cellacdc.renderer3d import VolumeRenderer3DWindow
+ assert VolumeRenderer3DWindow._voxel_dz == 1.0 # class default
+ assert VolumeRenderer3DWindow._voxel_dy == 1.0
+ assert VolumeRenderer3DWindow._voxel_dx == 1.0
+
+ from unittest.mock import MagicMock
+
+ class _Bare(VolumeRenderer3DWindow):
+ def _init_vispy(self):
+ if self._volume_node is None:
+ return
+ def _init_ui(self): self._controls = None
+
+ r = _Bare()
+ r._volume_node = None
+ # Store scale without a node — must not raise, must persist.
+ r.set_voxel_scale(dz=2.0, dy=1.0, dx=1.0)
+ assert r._voxel_dz == 2.0
+ assert r._voxel_dy == 1.0
+ assert r._voxel_dx == 1.0
+
+ r.close()
+ del r
+
+
+def test_write_png_stdlib(tmp_path):
+ """_write_png must produce a valid PNG file readable by skimage."""
+ import skimage.io
+ from cellacdc.renderer3d import _write_png
+
+ rng = np.random.default_rng(0)
+ rgba = rng.integers(0, 255, (16, 32, 4), dtype=np.uint8)
+ dest = str(tmp_path / 'test.png')
+ _write_png(dest, rgba)
+
+ loaded = skimage.io.imread(dest)
+ assert loaded.shape == rgba.shape
+ np.testing.assert_array_equal(loaded, rgba)
+
+
+def test_default_step_size():
+ """_DEFAULT_STEP_SIZE must match vispy's own default (0.8)."""
+ from cellacdc import renderer3d
+ assert renderer3d._DEFAULT_STEP_SIZE == 0.8
+
+
+def test_step_size_noop_without_node():
+ """set_step_size must not raise when no volume node exists."""
+ from cellacdc.renderer3d import VolumeRenderer3DWindow
+
+ class _Bare(VolumeRenderer3DWindow):
+ def _init_vispy(self):
+ if self._volume_node is None:
+ return
+ def _init_ui(self): self._controls = None
+
+ r = _Bare()
+ r._volume_node = None
+ r.set_step_size(0.5) # must not raise
+ r.close()
+ del r
+
+
+def test_set_opacity_noop_without_node():
+ """set_opacity must not raise when no volume node exists."""
+ from cellacdc.renderer3d import VolumeRenderer3DWindow
+
+ class _Bare(VolumeRenderer3DWindow):
+ def _init_vispy(self):
+ if self._volume_node is None:
+ return
+ def _init_ui(self): self._controls = None
+
+ r = _Bare()
+ r._volume_node = None
+ r.set_opacity(0.5) # must not raise
+ r.close()
+ del r
+
+
+def test_set_opacity_clamps_to_unit_range():
+ """set_opacity must clamp out-of-range values to [0, 1] before applying."""
+ from cellacdc.renderer3d import VolumeRenderer3DWindow
+ from unittest.mock import MagicMock, call, patch
+
+ class _Bare(VolumeRenderer3DWindow):
+ def _init_vispy(self):
+ if self._volume_node is None:
+ return
+ def _init_ui(self): self._controls = None
+
+ r = _Bare()
+ r._volume_node = MagicMock()
+ r._canvas = MagicMock()
+
+ # Track what value was actually assigned to _volume_node.opacity
+ assigned = []
+ type(r._volume_node).opacity = property(
+ fget=lambda self: None,
+ fset=lambda self, v: assigned.append(v),
+ )
+
+ r.set_opacity(2.0) # above 1 → clamp to 1.0
+ assert assigned[-1] == 1.0
+
+ r.set_opacity(-0.5) # below 0 → clamp to 0.0
+ assert assigned[-1] == 0.0
+
+ r.set_opacity(0.7) # in-range → pass through
+ assert abs(assigned[-1] - 0.7) < 1e-9
+
+ r.close()
+ del r
+
+
+def test_mip_cutoff_mode_sets():
+ """_MIP_CUTOFF_MODES and _MINIP_CUTOFF_MODES must only reference valid vispy methods."""
+ from cellacdc import renderer3d
+ import vispy
+ from qtpy import API_NAME
+ vispy.use(API_NAME)
+ from vispy.scene.visuals import Volume
+
+ vispy_methods = set(Volume._rendering_methods.keys())
+ for mode in renderer3d._MIP_CUTOFF_MODES:
+ assert mode in vispy_methods, f"mip-cutoff mode '{mode}' not in vispy methods"
+ for mode in renderer3d._MINIP_CUTOFF_MODES:
+ assert mode in vispy_methods, f"minip-cutoff mode '{mode}' not in vispy methods"
+
+
+def test_apply_mode_cutoffs_noop_without_node():
+ """_apply_mode_cutoffs must be a no-op when no volume node exists."""
+ from cellacdc.renderer3d import VolumeRenderer3DWindow
+
+ class _Bare(VolumeRenderer3DWindow):
+ def _init_vispy(self):
+ if self._volume_node is None:
+ return
+ def _init_ui(self): self._controls = None
+
+ r = _Bare()
+ r._volume_node = None
+ r._apply_mode_cutoffs('mip', 0.1, 0.9) # must not raise
+
+ r.close()
+ del r
+
+
+def test_depiction_modes_structure():
+ """DEPICTION_MODES must contain 'volume' and at least one plane mode."""
+ from cellacdc import renderer3d
+ assert len(renderer3d.DEPICTION_MODES) >= 2
+ ids = {d[0] for d in renderer3d.DEPICTION_MODES}
+ assert 'volume' in ids
+ plane_ids = {k for k in ids if k.startswith('plane_')}
+ assert len(plane_ids) >= 1, "At least one plane depiction mode required"
+ # _PLANE_CONFIGS must cover all plane ids
+ for pid in plane_ids:
+ assert pid in renderer3d._PLANE_CONFIGS, f"'{pid}' missing from _PLANE_CONFIGS"
+
+
+def test_depiction_plane_configs_valid_in_vispy():
+ """_PLANE_CONFIGS normals must be unit vectors; vispy supports 'plane' mode."""
+ import vispy; from qtpy import API_NAME; vispy.use(API_NAME)
+ from vispy.scene.visuals import Volume
+ from cellacdc import renderer3d
+ assert 'plane' in Volume._raycasting_modes # vispy supports 'plane'
+ assert 'volume' in Volume._raycasting_modes
+ # Each normal should be a length-3 unit vector
+ for mode_key, (normal, axis) in renderer3d._PLANE_CONFIGS.items():
+ assert len(normal) == 3, f"Normal for '{mode_key}' must be length-3"
+ mag = sum(v**2 for v in normal) ** 0.5
+ assert abs(mag - 1.0) < 1e-6, f"Normal for '{mode_key}' is not unit: {normal}"
+ assert axis in (0, 1, 2), f"Axis for '{mode_key}' must be 0,1,2 got {axis}"
+
+
+def test_plane_thickness_noop_without_node():
+ """set_plane_thickness must be a no-op when no volume node exists."""
+ from cellacdc.renderer3d import VolumeRenderer3DWindow
+
+ class _Bare(VolumeRenderer3DWindow):
+ def _init_vispy(self):
+ if self._volume_node is None:
+ return
+ def _init_ui(self): self._controls = None
+
+ r = _Bare()
+ r._volume_node = None
+ r.set_plane_thickness(5.0) # must not raise
+ r.set_plane_thickness(0.0) # must clamp silently (no node)
+ r.close()
+ del r
+
+
+def test_zplane_uniforms_noop_without_node():
+ """set_depiction and set_zplane_position must be no-ops when no node exists."""
+ from cellacdc.renderer3d import VolumeRenderer3DWindow
+
+ class _Bare(VolumeRenderer3DWindow):
+ def _init_vispy(self):
+ if self._volume_node is None:
+ return
+ def _init_ui(self): self._controls = None
+
+ r = _Bare()
+ r._volume_node = None
+ r._last_shape = None
+ r.set_depiction('plane') # must not raise
+ r.set_zplane_position(0.5) # must not raise
+ r.close()
+ del r
+
+
+def test_set_plane_uniforms_geometry():
+ """_set_plane_uniforms must compute correct scene-space position and normal."""
+ from cellacdc.renderer3d import VolumeRenderer3DWindow, _PLANE_CONFIGS
+ from unittest.mock import MagicMock
+
+ r = VolumeRenderer3DWindow.__new__(VolumeRenderer3DWindow)
+ r._volume_node = MagicMock()
+ r._last_shape = None
+ # Set up _controls mock directly (not via _init_ui to avoid display).
+ controls = MagicMock()
+ controls._plane_thick_spin.value.return_value = 2.0
+ r._controls = controls
+
+ shape = (30, 64, 128) # NZ=30, NY=64, NX=128
+
+ # --- plane_z: XY cross-section, normal along Z (scene-z = data-Z axis) ---
+ r._set_plane_uniforms('plane_z', 0.0, shape=shape)
+ pos = r._volume_node.plane_position
+ normal = r._volume_node.plane_normal
+ assert normal == [0.0, 0.0, 1.0]
+ # fraction=0.0 → z = 0.0*(30-1) = 0.0 (first voxel centre)
+ assert abs(pos[2] - 0.0) < 1e-6, f"plane_z pos[2] should be 0.0, got {pos[2]}"
+ # X and Y centres: (NX-1)/2 = 63.5, (NY-1)/2 = 31.5
+ assert abs(pos[0] - 63.5) < 1e-6
+ assert abs(pos[1] - 31.5) < 1e-6
+
+ r._set_plane_uniforms('plane_z', 1.0, shape=shape)
+ pos = r._volume_node.plane_position
+ # fraction=1.0 → z = 1.0*(30-1) = 29.0 (last voxel centre)
+ assert abs(pos[2] - 29.0) < 1e-6, f"plane_z pos[2] should be 29.0, got {pos[2]}"
+
+ r._set_plane_uniforms('plane_z', 0.5, shape=shape)
+ pos = r._volume_node.plane_position
+ # fraction=0.5 → z = 0.5*(30-1) = 14.5 → texture_z = (14.5+0.5)/30 = 0.5 ✓ (exact centre)
+ assert abs(pos[2] - 14.5) < 1e-6, f"plane_z pos[2] should be 14.5, got {pos[2]}"
+
+ # --- plane_y: XZ cross-section, normal along Y (scene-y = data-Y axis) ---
+ r._set_plane_uniforms('plane_y', 0.5, shape=shape)
+ pos = r._volume_node.plane_position
+ normal = r._volume_node.plane_normal
+ assert normal == [0.0, 1.0, 0.0]
+ # fraction=0.5 → y = 0.5*(64-1) = 31.5 → texture_y = (31.5+0.5)/64 = 0.5 ✓
+ assert abs(pos[1] - 31.5) < 1e-6, f"plane_y pos[1] should be 31.5, got {pos[1]}"
+
+ # --- plane_x: YZ cross-section, normal along X (scene-x = data-X axis) ---
+ r._set_plane_uniforms('plane_x', 0.5, shape=shape)
+ pos = r._volume_node.plane_position
+ normal = r._volume_node.plane_normal
+ assert normal == [1.0, 0.0, 0.0]
+ # fraction=0.5 → x = 0.5*(128-1) = 63.5 → texture_x = (63.5+0.5)/128 = 0.5 ✓
+ assert abs(pos[0] - 63.5) < 1e-6, f"plane_x pos[0] should be 63.5, got {pos[0]}"
+
+ # Thickness must be read from the spinbox (mocked to 2.0)
+ assert r._volume_node.plane_thickness == 2.0
+
+ # None shape + _last_shape=None → must return without crash
+ r._last_shape = None
+ r._set_plane_uniforms('plane_z', 0.5, shape=None) # must not raise
+
+
+def test_smooth_iso_constant():
+ """_SMOOTH_ISO_SIGMA must be a positive float."""
+ from cellacdc import renderer3d
+ assert isinstance(renderer3d._SMOOTH_ISO_SIGMA, float)
+ assert renderer3d._SMOOTH_ISO_SIGMA > 0
+
+
+def test_smooth_iso_gaussian():
+ """Gaussian pre-filter reduces variance, preserves shape and float32 dtype."""
+ import scipy.ndimage
+ from cellacdc.renderer3d import _SMOOTH_ISO_SIGMA
+
+ rng = np.random.default_rng(7)
+ vol = rng.random((20, 20, 20), dtype=np.float32)
+ smoothed = scipy.ndimage.gaussian_filter(vol, sigma=_SMOOTH_ISO_SIGMA)
+ assert smoothed.std() < vol.std() # smoothing reduces variance
+ assert smoothed.shape == vol.shape # shape unchanged
+ assert smoothed.dtype == np.float32 # dtype preserved (important for GPU upload)
+
+
+def test_smooth_iso_only_in_iso_mode():
+ """_smooth_iso flag must not apply filtering in non-ISO rendering modes."""
+ import scipy.ndimage
+ from cellacdc.renderer3d import _SMOOTH_ISO_SIGMA, _ISO_MODES
+
+ rng = np.random.default_rng(11)
+ vol = rng.random((10, 10, 10), dtype=np.float32)
+
+ # Modes that should NOT trigger smoothing
+ non_iso_modes = ['mip', 'minip', 'attenuated_mip', 'translucent', 'additive', 'average']
+ for mode in non_iso_modes:
+ assert mode not in _ISO_MODES, f"'{mode}' unexpectedly in _ISO_MODES"
+
+ # Verify ISO is the only smooth-triggering mode
+ assert 'iso' in _ISO_MODES
+
+
+def test_settings_constants():
+ """_SETTINGS_ORG and _SETTINGS_APP must be non-empty strings."""
+ from cellacdc.renderer3d import VolumeRenderer3DWindow
+ assert isinstance(VolumeRenderer3DWindow._SETTINGS_ORG, str)
+ assert isinstance(VolumeRenderer3DWindow._SETTINGS_APP, str)
+ assert VolumeRenderer3DWindow._SETTINGS_ORG
+ assert VolumeRenderer3DWindow._SETTINGS_APP
+
+
+def test_settings_roundtrip():
+ """_save_settings / _load_settings must preserve all control values."""
+ from qtpy.QtCore import QSettings
+ from cellacdc.renderer3d import VolumeRenderer3DWindow
+ from unittest.mock import MagicMock
+
+ # Use an isolated QSettings group to avoid polluting real settings.
+ TEST_ORG = 'Cell-ACDC-test'
+ TEST_APP = 'renderer3d-test'
+
+ class _HeadlessWindow(VolumeRenderer3DWindow):
+ _SETTINGS_ORG = TEST_ORG
+ _SETTINGS_APP = TEST_APP
+
+ def _init_vispy(self): pass
+ def _init_ui(self):
+ c = MagicMock()
+ c._mode_combo.currentIndex.return_value = 3
+ c._cmap_combo.currentText.return_value = 'viridis'
+ c._interp_combo.currentIndex.return_value = 1
+ c._clim_min.value.return_value = 0.05
+ c._clim_max.value.return_value = 0.95
+ c._gamma_spin.value.return_value = 1.5
+ c._step_spin.value.return_value = 0.4
+ self._controls = c
+
+ try:
+ win = _HeadlessWindow.__new__(_HeadlessWindow)
+ win._hide_on_close = True
+ win._adapter = None
+ win._volume_node = None
+ win._last_shape = None
+ win._max_texture_3d = None
+ win._init_ui()
+
+ # Set all persisted controls explicitly so return values are typed correctly.
+ win._controls._depict_combo.currentIndex.return_value = 1
+ win._controls._plane_thick_spin.value.return_value = 3.0
+ win._controls._opacity_spin.value.return_value = 0.75
+ win._controls._smooth_iso_cb.isChecked.return_value = True # explicit bool
+
+ # Save the mocked values.
+ win._save_settings()
+
+ # Verify all 10 settings were persisted correctly.
+ s = QSettings(TEST_ORG, TEST_APP)
+ assert s.value('mode_idx', type=int) == 3
+ assert s.value('colormap', type=str) == 'viridis'
+ assert s.value('interp_idx', type=int) == 1
+ assert abs(s.value('clim_min', type=float) - 0.05) < 1e-6
+ assert abs(s.value('clim_max', type=float) - 0.95) < 1e-6
+ assert abs(s.value('gamma', type=float) - 1.5) < 1e-6
+ assert abs(s.value('step_size', type=float) - 0.4) < 1e-6
+ assert s.value('smooth_iso', type=bool) is True
+ assert s.value('depict_idx', type=int) == 1
+ assert abs(s.value('plane_thickness', type=float) - 3.0) < 1e-6
+ assert abs(s.value('opacity', type=float) - 0.75) < 1e-6
+ finally:
+ QSettings(TEST_ORG, TEST_APP).clear()
+
+
+def test_gui_adapter_implements_protocol():
+ """_GuiWinRenderer3DAdapter must expose all VolumeRendererAdapter methods."""
+ from cellacdc.renderer3d import VolumeRendererAdapter
+ from cellacdc.gui import _GuiWinRenderer3DAdapter
+
+ # All public methods on the base protocol must be present on the adapter.
+ protocol_methods = {
+ m for m in dir(VolumeRendererAdapter)
+ if not m.startswith('_')
+ }
+ adapter_methods = {
+ m for m in dir(_GuiWinRenderer3DAdapter)
+ if not m.startswith('_')
+ }
+ missing = protocol_methods - adapter_methods
+ assert not missing, (
+ f"_GuiWinRenderer3DAdapter does not implement: {missing}"
+ )
+
+
+def test_gui_renderer3d_methods_exist():
+ """guiWin must define all the adapter/renderer integration methods."""
+ from cellacdc.gui import guiWin
+
+ required = {
+ '_get_current_zstack',
+ '_get_current_voxel_sizes',
+ '_launch_3d_renderer',
+ '_update_3d_renderer_if_active',
+ '_hide_3d_renderer_if_open',
+ '_position_renderer3d_window',
+ }
+ missing = required - set(dir(guiWin))
+ assert not missing, f"guiWin missing renderer3d methods: {missing}"
+
+
+def test_gui_py_constant():
+ """_ZPROJMODE_3D must appear exactly once as a string literal in gui.py."""
+ import pathlib
+ gui_src = pathlib.Path(__file__).parent.parent / 'cellacdc' / 'gui.py'
+ text = gui_src.read_text(encoding='utf-8')
+ # The constant definition line is the only place the raw string should appear.
+ count = text.count("'3D z-render'")
+ assert count == 1, (
+ f"Expected exactly 1 occurrence of raw '3D z-render' in gui.py "
+ f"(the constant definition); found {count}."
+ )
+
+
+def test_last_raw_data_cached_after_update_volume():
+ """update_volume must cache raw float32 data in _last_raw_data."""
+ from cellacdc.renderer3d import VolumeRenderer3DWindow
+ from unittest.mock import MagicMock, patch
+
+ class _Headless(VolumeRenderer3DWindow):
+ def _init_vispy(self): pass
+ def _init_ui(self):
+ c = MagicMock()
+ c._mode_combo.currentData.return_value = 'mip'
+ c._cmap_combo.currentText.return_value = 'grays'
+ c._interp_combo.currentData.return_value = 'linear'
+ c._step_spin.value.return_value = 0.8
+ c._clim_min.value.return_value = 0.0
+ c._clim_max.value.return_value = 1.0
+ c._gamma_spin.value.return_value = 1.0
+ c._attn_spin.value.return_value = 0.5
+ c._iso_spin.value.return_value = 0.5
+ c._depict_combo.currentData.return_value = 'volume'
+ c._zplane_slider.value.return_value = 50
+ c._plane_thick_spin.value.return_value = 1.0
+ c._smooth_iso_cb.isChecked.return_value = False
+ self._controls = c
+
+ rng = np.random.default_rng(42)
+ data = rng.integers(0, 256, (10, 20, 30), dtype=np.uint16)
+
+ with patch('vispy.scene.visuals.Volume'):
+ from vispy.scene import visuals
+ visuals.Volume = MagicMock(return_value=MagicMock())
+
+ win = _Headless.__new__(_Headless)
+ win._hide_on_close = True
+ win._adapter = None
+ win._volume_node = None
+ win._last_shape = None
+ win._max_texture_3d = 512
+ win._smooth_iso = False
+ win._last_raw_data = None
+ win._init_ui()
+
+ canvas_mock = MagicMock()
+ win._canvas = canvas_mock
+ view_mock = MagicMock()
+ win._view = view_mock
+
+ statusbar_mock = MagicMock()
+ win.statusBar = MagicMock(return_value=statusbar_mock)
+
+ win.update_volume(data)
+
+ assert win._last_raw_data is not None
+ assert win._last_raw_data.dtype == np.float32
+ assert win._last_raw_data.shape == data.shape
+ # data.shape (10,20,30) is well within the 512 texture limit → no downsampling
+ assert win._last_strides == (1, 1, 1), (
+ f"Expected no-downsampling strides (1,1,1), got {win._last_strides}"
+ )
+
+
+def test_rerender_callable_without_data():
+ """_rerender must be a no-op when _last_raw_data is None."""
+ from cellacdc.renderer3d import VolumeRenderer3DWindow
+
+ class _Headless(VolumeRenderer3DWindow):
+ def _init_vispy(self): pass
+ def _init_ui(self): pass
+
+ win = _Headless.__new__(_Headless)
+ win._last_raw_data = None
+ win._rerender() # must not raise
+
+
+def test_gpu_data_is_smoothed_initial_state():
+ """_gpu_data_is_smoothed must default to False before any volume upload."""
+ from cellacdc.renderer3d import VolumeRenderer3DWindow
+
+ class _Headless(VolumeRenderer3DWindow):
+ def _init_vispy(self): pass
+ def _init_ui(self): pass
+
+ win = _Headless.__new__(_Headless)
+ win._hide_on_close = True
+ win._adapter = None
+ win._volume_node = None
+ win._last_shape = None
+ win._max_texture_3d = None
+ win._smooth_iso = False
+ win._gpu_data_is_smoothed = False
+ win._last_raw_data = None
+ assert win._gpu_data_is_smoothed is False
+
+
+def test_set_rendering_mode_triggers_rerender_on_stale_smooth():
+ """set_rendering_mode must re-render when GPU smooth state mismatches _smooth_iso.
+
+ Reproduces the stale-GPU bug: user enables smooth ISO while in MIP mode
+ (no re-render), then switches to ISO — GPU texture is still unsmoothed.
+ The mode switch must detect the mismatch and call _rerender().
+ """
+ from cellacdc.renderer3d import VolumeRenderer3DWindow
+ from unittest.mock import MagicMock, patch
+
+ class _Headless(VolumeRenderer3DWindow):
+ def _init_vispy(self): pass
+ def _init_ui(self):
+ c = MagicMock()
+ c._mode_combo.currentData.return_value = 'iso'
+ c._clim_min.value.return_value = 0.0
+ c._clim_max.value.return_value = 1.0
+ c._iso_spin.value.return_value = 0.5
+ c._attn_spin.value.return_value = 0.5
+ self._controls = c
+
+ win = _Headless.__new__(_Headless)
+ win._hide_on_close = True
+ win._adapter = None
+ win._last_shape = (10, 10, 10)
+ win._max_texture_3d = 512
+ win._smooth_iso = True # smooth enabled
+ win._gpu_data_is_smoothed = False # but GPU has unsmoothed data (stale)
+ win._last_raw_data = np.zeros((10, 10, 10), dtype=np.float32)
+ win._volume_node = MagicMock()
+ win._canvas = MagicMock()
+ win._view = MagicMock()
+ win.statusBar = MagicMock(return_value=MagicMock())
+ win._init_ui()
+
+ rerender_calls = []
+ original_rerender = VolumeRenderer3DWindow._rerender
+
+ def _spy_rerender(self):
+ rerender_calls.append(1)
+ # Don't actually re-render (would need full vispy setup); just update flag.
+ self._gpu_data_is_smoothed = self._smooth_iso
+
+ with patch.object(_Headless, '_rerender', _spy_rerender):
+ win.set_rendering_mode('iso')
+
+ assert len(rerender_calls) == 1, (
+ '_rerender must be called when switching to ISO with stale GPU smooth state'
+ )
+
+
+def test_auto_contrast_percentile_no_data():
+ """auto_contrast_percentile returns [0,1] when no raw data is cached."""
+ from cellacdc.renderer3d import VolumeRenderer3DWindow
+ from unittest.mock import MagicMock
+
+ class _Headless(VolumeRenderer3DWindow):
+ def _init_vispy(self): pass
+ def _init_ui(self):
+ c = MagicMock()
+ c._clim_min.value.return_value = 0.0
+ c._clim_max.value.return_value = 1.0
+ self._controls = c
+
+ win = _Headless.__new__(_Headless)
+ win._last_raw_data = None
+ win._volume_node = None
+ win._init_ui()
+ win.auto_contrast_percentile() # must not raise
+
+ # Spinboxes set to full range fallback
+ win._controls._clim_min.setValue.assert_called_with(0.0)
+ win._controls._clim_max.setValue.assert_called_with(1.0)
+
+
+def test_auto_contrast_percentile_with_data():
+ """auto_contrast_percentile maps raw percentiles into normalised [0,1] space.
+
+ Data setup: 997 background voxels uniform in [0, 500] + 3 outlier voxels
+ at 5000. The 3 outliers are < 0.5% of 1000 voxels, so the 99.5th
+ percentile lands in the background region, giving hi < 1.0 in normalised
+ space.
+ """
+ from cellacdc.renderer3d import VolumeRenderer3DWindow
+ from unittest.mock import MagicMock
+
+ class _Headless(VolumeRenderer3DWindow):
+ def _init_vispy(self): pass
+ def _init_ui(self):
+ c = MagicMock()
+ c._clim_min.value.return_value = 0.0
+ c._clim_max.value.return_value = 1.0
+ self._controls = c
+
+ rng = np.random.default_rng(0)
+ bg = rng.uniform(0, 500, 997).astype(np.float32)
+ outliers = np.full(3, 5000.0, dtype=np.float32)
+ raw = np.concatenate([bg, outliers]).reshape(10, 10, 10)
+ # Sanity: vmax is the outlier value
+ assert raw.max() == 5000.0
+
+ win = _Headless.__new__(_Headless)
+ win._last_raw_data = raw
+ win._volume_node = None
+ win._init_ui()
+ win.auto_contrast_percentile(lo_pct=1.0, hi_pct=99.5)
+
+ # The 99.5th percentile (index 994 of 1000) falls within the 997 background
+ # voxels → hi is mapped to well below 1.0 in normalised space.
+ hi_call = win._controls._clim_max.setValue.call_args[0][0]
+ assert hi_call < 1.0, (
+ f"Expected hi < 1.0 (outliers excluded by percentile), got {hi_call}"
+ )
+ lo_call = win._controls._clim_min.setValue.call_args_list[0][0][0]
+ assert 0.0 <= lo_call <= hi_call <= 1.0
+
+
+def test_auto_contrast_percentile_constant_data():
+ """auto_contrast_percentile falls back to [0,1] when vmax == vmin (all-same data)."""
+ from cellacdc.renderer3d import VolumeRenderer3DWindow
+ from unittest.mock import MagicMock
+
+ class _Headless(VolumeRenderer3DWindow):
+ def _init_vispy(self): pass
+ def _init_ui(self):
+ c = MagicMock()
+ c._clim_min.value.return_value = 0.0
+ c._clim_max.value.return_value = 1.0
+ self._controls = c
+
+ raw = np.full((5, 5, 5), 42.0, dtype=np.float32) # constant → vmax == vmin
+
+ win = _Headless.__new__(_Headless)
+ win._last_raw_data = raw
+ win._volume_node = None
+ win._init_ui()
+ win.auto_contrast_percentile()
+
+ # Falls back to [0, 1] when span is zero (degenerate data).
+ win._controls._clim_min.setValue.assert_called_with(0.0)
+ win._controls._clim_max.setValue.assert_called_with(1.0)
+
+
+def test_auto_contrast_percentile_large_volume_subsampled():
+ """auto_contrast_percentile subsample path (>1 M voxels) must not crash
+ and must still return valid limits in [0, 1]."""
+ from cellacdc.renderer3d import VolumeRenderer3DWindow
+ from unittest.mock import MagicMock
+
+ class _Headless(VolumeRenderer3DWindow):
+ def _init_vispy(self): pass
+ def _init_ui(self):
+ c = MagicMock()
+ c._clim_min.value.return_value = 0.0
+ c._clim_max.value.return_value = 1.0
+ self._controls = c
+
+ # 128×100×100 = 1.28 M voxels → triggers the stride-subsample branch
+ rng = np.random.default_rng(5)
+ raw = rng.uniform(0, 1000, (128, 100, 100)).astype(np.float32)
+ assert raw.size > 1_000_000
+
+ win = _Headless.__new__(_Headless)
+ win._last_raw_data = raw
+ win._volume_node = None
+ win._init_ui()
+ win.auto_contrast_percentile(lo_pct=1.0, hi_pct=99.0)
+
+ lo = win._controls._clim_min.setValue.call_args_list[0][0][0]
+ hi = win._controls._clim_max.setValue.call_args[0][0]
+ assert 0.0 <= lo <= hi <= 1.0, f"Invalid limits: lo={lo}, hi={hi}"
+
+
+def test_apply_voxel_scale_updates_canvas():
+ """_apply_voxel_scale must call canvas.update() so the frame redraws."""
+ from cellacdc.renderer3d import VolumeRenderer3DWindow
+ from unittest.mock import MagicMock
+
+ class _Bare(VolumeRenderer3DWindow):
+ def _init_vispy(self):
+ if self._volume_node is None:
+ return
+ def _init_ui(self): self._controls = None
+
+ r = _Bare()
+ r._volume_node = MagicMock()
+ r._canvas = MagicMock()
+ r._last_strides = (1, 1, 1)
+ r._voxel_dz = 2.0
+ r._voxel_dy = 1.0
+ r._voxel_dx = 1.0
+
+ assigned = []
+ type(r._volume_node).transform = property(
+ fget=lambda self: None,
+ fset=lambda self, v: assigned.append(v),
+ )
+
+ r._apply_voxel_scale()
+
+ assert len(assigned) == 1, "transform must be set exactly once"
+ r._canvas.update.assert_called_once()
+
+ r.close()
+ del r