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