diff --git a/.gitignore b/.gitignore index 6d90763c..498e27e1 100755 --- a/.gitignore +++ b/.gitignore @@ -95,6 +95,9 @@ instructions/Screenshots UserManual/* !UserManual/Cell-ACDC_User_Manual.pdf +cellacdc/volume_renderer/_demos/data/* +!cellacdc/volume_renderer/_demos/data/.gitkeep + # Ignore models folder but keep the folder with placeholder.txt dummy file models/* !models/placeholder.txt diff --git a/cellacdc/__init__.py b/cellacdc/__init__.py index b4987d81..45996e1d 100755 --- a/cellacdc/__init__.py +++ b/cellacdc/__init__.py @@ -818,4 +818,10 @@ def inner_function(self, *args, **kwargs): '.tif', '.npz', '_symlink.ini' -) \ No newline at end of file +) + +if GUI_INSTALLED: + try: + from cellacdc.volume_renderer.canvas import VolumeRendererWindow + except ModuleNotFoundError: + pass \ No newline at end of file diff --git a/cellacdc/apps.py b/cellacdc/apps.py index 46332a18..9e9f72a5 100755 --- a/cellacdc/apps.py +++ b/cellacdc/apps.py @@ -638,9 +638,21 @@ def show(self, block=False): self.loop.exec_() class _PointsLayerAppearanceGroupbox(QGroupBox): - def __init__(self, *args): + sigValueChanged = Signal(object) + + def __init__( + self, + *args, + backend: Literal['pyqtgraph', 'vispy']='pyqtgraph', + is_3d=False, + add_opacity_slider=False + ): super().__init__(*args) + self._backend = backend + self._add_opacity_slider = add_opacity_slider + self._is_3d = is_3d + self.setTitle('Points appearance') layout = widgets.FormLayout() @@ -651,12 +663,18 @@ def __init__(self, *args): Symbol used to draw the points. """) symbolInfoTxt = (f'{html_utils.paragraph(symbolInfoTxt)}') + if backend == 'pyqtgraph': + symbolCombobox = widgets.pgScatterSymbolsCombobox() + elif backend == 'vispy': + symbolCombobox = widgets.VisPyMarkersSymbolsCombobox() + self.symbolWidget = widgets.formWidget( - widgets.pgScatterSymbolsCombobox(), addInfoButton=True, + symbolCombobox, addInfoButton=True, labelTextLeft='Symbol: ', parent=self, infoTxt=symbolInfoTxt, stretchWidget=False ) layout.addFormWidget(self.symbolWidget, row=row) + symbolCombobox.currentTextChanged.connect(self.emitSigValueChanged) '----------------------------------------------------------------------' '----------------------------------------------------------------------' @@ -669,6 +687,7 @@ def __init__(self, *args): layout.addFormWidget(self.colorWidget, align=Qt.AlignLeft, row=row) self.colorButton.clicked.disconnect() self.colorButton.clicked.connect(self.selectColor) + self.colorButton.sigColorChanging.connect(self.emitSigValueChanged) '----------------------------------------------------------------------' '----------------------------------------------------------------------' @@ -680,24 +699,15 @@ def __init__(self, *args): labelTextLeft='Size: ', parent=self ) layout.addFormWidget(self.sizeWidget, row=row) + self.sizeSpinBox.valueChanged.connect(self.emitSigValueChanged) '----------------------------------------------------------------------' '----------------------------------------------------------------------' - row += 1 - zHeightTooltip = ( - 'If "Z-depth" is greater than 1, the points will be annotated ' - 'in all the z-slices in the range `z - (Z-depth/2) < z < z + (Z-depth/2)`\n' - 'where `z` is the center z-slice of the added point.' - ) - self.zHeightSpinBox = widgets.OddSpinBox() - self.zHeightSpinBox.setValue(1) - self.zHeightSpinBox.setMinimum(1) - self.zHeightWidget = widgets.formWidget( - self.zHeightSpinBox, stretchWidget=True, - labelTextLeft='Z-depth: ', parent=self, - toolTip=zHeightTooltip - ) - layout.addFormWidget(self.zHeightWidget, row=row) + self.zHeightSpinBox = widgets.DummyWidget() + if not is_3d: + row += 1 + self.addZheightSpinbox(row) + layout.addFormWidget(self.zHeightWidget, row=row) '----------------------------------------------------------------------' '----------------------------------------------------------------------' @@ -712,15 +722,66 @@ def __init__(self, *args): ) layout.addFormWidget(self.shortcutWidget, row=row) '----------------------------------------------------------------------' + + '----------------------------------------------------------------------' + self.opacitySlider = widgets.DummyWidget() + if add_opacity_slider: + row += 1 + self.opacitySlider = widgets.sliderWithSpinBox( + title_loc='in_line', + isFloat=True, + parent=self, + normalize_factor=20 + ) + self.opacitySlider.setValue(0.3) + self.opacitySlider.setRange(0.0, 1.0) + self.opacitySlider.setSingleStep(0.05) + self.opacitySlider.setDecimals(2) + self.opacityWidget = widgets.formWidget( + self.opacitySlider, stretchWidget=True, + labelTextLeft='Opacity: ', parent=self + ) + layout.addFormWidget(self.opacityWidget, row=row) + self.opacitySlider.valueChanged.connect(self.emitSigValueChanged) + '----------------------------------------------------------------------' self.setLayout(layout) - def restoreState(self, state): - self.shortcutWidget.widget.setText(state['shortcut']) - self.colorButton.setColor(state['color']) - self.symbolWidget.widget.setCurrentText(state['symbol']) - self.sizeSpinBox.setValue(state['pointSize']) - self.zHeightSpinBox.setValue(state['zHeight']) + def emitSigValueChanged(self, *args): + self.sigValueChanged.emit(self.state()) + + def addZheightSpinbox(self, row: int): + zHeightTooltip = ( + 'If "Z-depth" is greater than 1, the points will be annotated ' + 'in all the z-slices in the range ' + '`z - (Z-depth/2) < z < z + (Z-depth/2)`\n' + 'where `z` is the center z-slice of the added point.' + ) + self.zHeightSpinBox = widgets.OddSpinBox() + self.zHeightSpinBox.setValue(1) + self.zHeightSpinBox.setMinimum(1) + self.zHeightWidget = widgets.formWidget( + self.zHeightSpinBox, stretchWidget=True, + labelTextLeft='Z-depth: ', parent=self, + toolTip=zHeightTooltip + ) + self.zHeightSpinBox.valueChanged.connect(self.emitSigValueChanged) + + def restoreState(self, state: dict): + key_widget_setter_mapper = { + 'shortcut': self.shortcutWidget.widget.setText, + 'color': self.colorButton.setColor, + 'symbol': self.symbolWidget.widget.setCurrentText, + 'pointSize': self.sizeSpinBox.setValue, + 'zHeight': self.zHeightSpinBox.setValue, + 'opacity': self.opacitySlider.setValue + } + for key, setter in key_widget_setter_mapper.items(): + value = state.get(key) + if value is None: + continue + + setter(value) def selectColor(self): color = self.colorButton.color() @@ -741,9 +802,14 @@ def state(self): 'symbol': self.symbolWidget.widget.currentText(), 'color': (r,g,b), 'pointSize': self.sizeSpinBox.value(), - 'zHeight': self.zHeightSpinBox.value(), 'shortcut': self.shortcutWidget.widget.text() } + if not self._is_3d: + _state['zHeight'] = self.zHeightSpinBox.value() + + if self._add_opacity_slider: + _state['opacity'] = self.opacitySlider.value() + return _state class AddPointsLayerDialog(QBaseDialog): @@ -1416,19 +1482,38 @@ def showEvent(self, event) -> None: class EditPointsLayerAppearanceDialog(QBaseDialog): sigClosed = Signal() + sigValueChanged = Signal(object) - def __init__(self, parent=None): + def __init__( + self, + backend: Literal['pyqtgraph', 'vispy']='pyqtgraph', + is_3d=False, + add_opacity_slider=False, + hide_on_close=False, + title='Points Layer Properties', + parent=None + ): self.cancel = True super().__init__(parent) self._parent = parent + self._backend = backend + self._is_3d = is_3d + self._add_opacity_slider = add_opacity_slider + self._hide_on_close = hide_on_close - self.setWindowTitle('Custom annotation') + self.setWindowTitle(title) self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) mainLayout = QVBoxLayout() - self.appearanceGroupbox = _PointsLayerAppearanceGroupbox() + self.appearanceGroupbox = _PointsLayerAppearanceGroupbox( + backend=backend, + is_3d=is_3d, + add_opacity_slider=add_opacity_slider + ) + + self.appearanceGroupbox.sigValueChanged.connect(self.emitValueChanged) buttonsLayout = widgets.CancelOkButtonsLayout() @@ -1443,13 +1528,24 @@ def __init__(self, parent=None): self.setFont(font) + def emitValueChanged(self, *args): + self.sigValueChanged.emit(self.appearanceGroupbox.state()) + def restoreState(self, state): self.appearanceGroupbox.restoreState(state) def closeEvent(self, event): - super().closeEvent(event) + if self._hide_on_close: + event.ignore() + self.hide() + else: + super().closeEvent(event) self.sigClosed.emit() + def force_close(self): + self._hide_on_close = False + self.close() + def state(self): _state = self.appearanceGroupbox.state() return _state @@ -1457,14 +1553,27 @@ def state(self): def ok_cb(self): self.cancel = False symbol = self.appearanceGroupbox.symbolWidget.widget.currentText() - self.symbol = re.findall(r"\'(.+)\'", symbol)[0] + self.symbol = symbol + if self._backend == 'pyqtgraph': + self.symbol = re.findall(r"\'(.+)\'", symbol)[0] self.color = self.appearanceGroupbox.colorButton.color() self.pointSize = self.appearanceGroupbox.sizeSpinBox.value() - self.zHeight = self.appearanceGroupbox.zHeightSpinBox.value() + + self.zHeight = None + if not self._is_3d: + self.zHeight = self.appearanceGroupbox.zHeightSpinBox.value() + + self.opacity = 1.0 + if self._add_opacity_slider: + self.opacity = self.appearanceGroupbox.opacitySlider.value() + shortcutWidget = self.appearanceGroupbox.shortcutWidget self.shortcut = shortcutWidget.widget.text() self.keySequence = shortcutWidget.widget.keySequence - self.close() + if self._hide_on_close: + self.hide() + else: + self.close() class filenameDialog(QDialog): def __init__( diff --git a/cellacdc/colors.py b/cellacdc/colors.py index e7502687..00a0cbbd 100644 --- a/cellacdc/colors.py +++ b/cellacdc/colors.py @@ -8,6 +8,7 @@ import cv2 from collections.abc import Callable, Sequence +from typing import Literal import numpy as np from . import GUI_INSTALLED, printl @@ -28,20 +29,76 @@ __all__ = ['ColorMap'] +CYAN_RGB = (0, 255, 255) +MAGENTA_RGB = (255, 0, 255) +YELLOW_RGB = (255, 255, 0) +GREEN_RGB = (0, 255, 0) + FLUO_CHANNELS_COLORS = { - 'mCardinal': (255, 0, 255), - 'mNeonGreen': (0, 255, 0), - 'NeonGreen': (0, 255, 0), - 'mNG': (0, 255, 0), - 'mScarlet': (255, 0, 255), - 'mScarlet-I3': (255, 0, 255), - 'mKate': (255, 0, 255), - 'mKate2': (255, 0, 255), - 'GFP': (0, 255, 0), - 'EGFP': (0, 255, 0), - 'mCitrine': (255, 255, 0) + 'mCardinal': MAGENTA_RGB, + 'mNeonGreen': CYAN_RGB, + 'NeonGreen': CYAN_RGB, + 'KaedeGreen': GREEN_RGB, + 'mNeon': CYAN_RGB, + 'mNG': CYAN_RGB, + 'mScarlet': MAGENTA_RGB, + 'mScarlet-I3': MAGENTA_RGB, + 'mKate': MAGENTA_RGB, + 'mKate2': MAGENTA_RGB, + 'GFP': CYAN_RGB, + 'EGFP': CYAN_RGB, + 'mCitrine': YELLOW_RGB } +PltColorName = str +if GUI_INSTALLED: + import matplotlib.colors as mcolors + PltColorName = ( + Literal[tuple(mcolors.BASE_COLORS)] + | Literal[tuple(mcolors.TABLEAU_COLORS)] + | Literal[tuple(mcolors.CSS4_COLORS)] + ) + +# Tuple of 3 or 4 uint8 values +RgbaColor = tuple[int] + +AcdcPyQtGraphColorMapName = Literal[ + 'hot', + 'flame', + 'yellowy', + 'bipolar', + 'spectrum', + 'cyclic', + 'greyclip', + 'grey', + 'viridis', + 'inferno', + 'plasma', + 'magma', + 'turbo', + 'cividis', + 'cool', + 'sunset' +] + +AcdcColorMap = list[RgbaColor] | AcdcPyQtGraphColorMapName | list[PltColorName] + +overlay_rgbs = [ + (255, 255, 0), + (252, 72, 254), + (49, 222, 134), + (22, 108, 27) +] +if GUI_INSTALLED: + overlay_default_plt_cmap = matplotlib.colormaps['hsv'] + overlay_rgbs.extend( + [tuple([round(c*255) for c in overlay_default_plt_cmap(i)][:3]) + for i in np.linspace(0,1,8)] + ) + +# See pg.HistogramLUTItem.saveState() and pg.HistogramLUTItem.restoreState() +LutItemState = dict[str, object] + _mapCache = {} def getFromMatplotlib(name): """ @@ -371,6 +428,88 @@ 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})' +def pg_to_vispy_cmap(pg_cmap, n=256, debug=False, transparent_zero=False): + """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 + transparent_zero : bool, optional + If `True`, zero value will be mapped to transparent RGBA + + Returns + ------- + vispy.color.Colormap + VisPy colormap + """ + + from vispy.color import Colormap as VisPyColormap + + # 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 + + if transparent_zero: + colors = replace_background_rgba_lut(colors) + + return VisPyColormap(colors) + +def replace_background_rgba_lut( + lut: np.ndarray, + background_rgba: tuple[int]=(0, 0, 0, 0) + ) -> np.ndarray: + """Replace the background color in a LUT with a new RGBA color. + + Parameters + ---------- + lut : np.ndarray + Lookup table of shape (N, 4) with RGBA values. + background_rgba : tuple[int] + New background color as an RGBA tuple. Default is (0, 0, 0, 0) + for transparent black. + + Returns + ------- + np.ndarray + Updated lookup table with the new background color. + """ + updated_lut = lut.copy() + if updated_lut.shape[-1] == 3: + rgba_lut = np.ones((len(updated_lut), 4)) + rgba_lut[:, :3] = updated_lut + updated_lut = rgba_lut + + updated_lut[0] = background_rgba + return updated_lut + +def get_auto_contrast_percentile( + image_data: np.ndarray, + low_pct=0.02, + high_pct=99.98 + ): + # Subsample large volumes for speed (< 1 M samples is fast). + vmin_raw = float(image_data.min()) + vmax_raw = float(image_data.max()) + flat = image_data.ravel() + if flat.size > 1_000_000: + step = flat.size // 1_000_000 + 1 + flat = flat[::step] + p_lo = float(np.percentile(flat, low_pct)) + p_hi = float(np.percentile(flat, high_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 image_2d_rgb_or_rgba_to_uint8_grayscale(image: np.ndarray): if image.ndim != 3: raise TypeError( diff --git a/cellacdc/gui.py b/cellacdc/gui.py index 4407a228..6d6b6065 100755 --- a/cellacdc/gui.py +++ b/cellacdc/gui.py @@ -935,6 +935,7 @@ def gui_createMenuBar(self): ImageMenu.addAction(self.greedyShuffleCmapAction) ImageMenu.addAction(self.zoomToObjsAction) ImageMenu.addAction(self.zoomOutAction) + self.ImageMenu = ImageMenu # Segment menu SegmMenu = menuBar.addMenu("&Segment") @@ -1074,6 +1075,15 @@ def gui_createToolBars(self): self.slideshowButton.setShortcut('Ctrl+W') navigateToolBar.addWidget(self.slideshowButton) + self.launch3dViewerAction = QAction( + 'Launch 3D viewer for the currently visualised volume', self + ) + self.launch3dViewerAction.setIcon(QIcon(":3d.svg")) + self.launch3dViewerAction.setCheckable(True) + navigateToolBar.addAction(self.launch3dViewerAction) + + self.ImageMenu.addAction(self.launch3dViewerAction) + navigateToolBar.addAction(self.autoPilotButton) # navigateToolBar.setIconSize(QSize(toolbarSize, toolbarSize)) @@ -3127,6 +3137,96 @@ def gui_connectActions(self): def zProjLockViewToggled(self, checked): self.updateZproj(self.zProjComboBox.currentText()) + def launch3dViewerToggled(self, checked): + self.logger.info('Launching 3D volume viewer...') + + myutils.check_install_package( + 'PyOpenGL', + import_pkg_name='OpenGL', + pypi_name='PyOpenGL', + parent=self, + ) + + myutils.check_install_package( + 'VisPy', + import_pkg_name='vispy', + pypi_name='vispy', + parent=self, + ) + + self.launch3dViewerAction.blockSignals(True) + self.launch3dViewerAction.setChecked(checked) + self.launch3dViewerAction.blockSignals(False) + + if not checked: + self._volume_renderer.close() + self._volume_renderer = None + return + + posData = self.data[self.pos_i] + + volumes = { + self.user_ch_name: posData.img_data[posData.frame_i] + } + lut_items_states = { + self.user_ch_name: self.imgGrad.super_saveState() + } + + ol_data = posData.ol_data + if ol_data is None: + ol_data = {} + + for filename, ol_image_data in ol_data.items(): + volume = ol_image_data[posData.frame_i] + + channel_name = myutils.get_chname_from_basename( + filename, posData.basename, remove_ext=False + ) + volumes[channel_name] = volume + + overlayItems = self.overlayLayersItems[channel_name] + lutItem = overlayItems[1] + lut_items_states[channel_name] = lutItem.super_saveState() + + voxel_size = ( + posData.PhysicalSizeZ, posData.PhysicalSizeY, posData.PhysicalSizeX + ) + + from cellacdc.volume_renderer.canvas import VolumeRendererWindow + + self._volume_renderer = VolumeRendererWindow( + app=self.app, + parent=self, + version=self._version, + hide_on_close=False, + logger_func=self.logger.info + ) + self._volume_renderer.set_volumes( + volumes, + lut_items_states=lut_items_states, + voxel_size=voxel_size, + ) + self._volume_renderer.set_labels( + posData.lab, + gradient_item_state=self.labelsGrad.item.saveState(), + SizeZ=posData.SizeZ + ) + + self.addPointsLayersToVolumeViewer(self._volume_renderer) + + self._volume_renderer.sigClose.connect(self.onClose3dViewer) + self._volume_renderer.sigUpdate.connect(self.onUpdate3dViewer) + self._volume_renderer.run(block=False) + self._volume_renderer.raise_() + self._volume_renderer.activateWindow() + + def onClose3dViewer(self, *args, **kwargs): + self.launch3dViewerAction.setChecked(False) + + def onUpdate3dViewer(self): + self.launch3dViewerAction.setChecked(False) + self.launch3dViewerAction.setChecked(True) + def rescaleIntensExportToVideoDialog(self, how, channel, setImage=True): if channel == self.user_ch_name: lutItem = self.imgGrad @@ -3372,6 +3472,10 @@ def gui_connectEditActions(self): self.autoPilotButton.toggled.connect(self.autoPilotToggled) self.skipToNewIdAction.triggered.connect(self.skipForwardToNewID) self.slideshowButton.toggled.connect(self.launchSlideshow) + self.launch3dViewerAction.toggled.connect( + self.launch3dViewerButton.setChecked + ) + self.copyLostObjButton.toggled.connect(self.copyLostObjContour_cb) self.manualAnnotPastButton.toggled.connect( @@ -3489,8 +3593,8 @@ def gui_connectEditActions(self): # self.labelsGrad.fontSizeMenu, self.setFontSizeActionChecked # ) - self.labelsGrad.shuffleCmapAction.triggered.connect(self.shuffle_cmap) - self.labelsGrad.greedyShuffleCmapAction.triggered.connect( + self.labelsGrad.sigShuffleCmap.connect(self.shuffle_cmap) + self.labelsGrad.sigGreeedyShuffleCmap.connect( self.greedyShuffleCmap ) self.labelsGrad.permanentGreedyCmapAction.toggled.connect( @@ -3937,6 +4041,12 @@ def gui_createImg1Widgets(self): self.switchPlaneCombobox.setToolTip( 'Switch viewed plane' ) + + self.launch3dViewerButton = widgets.threeDPushButton() + self.launch3dViewerButton.setToolTip( + 'Launch 3D viewer for the currently visualised volume' + ) + self.launch3dViewerButton.setCheckable(True) self.zSliceOverlay_SB = widgets.ScrollBar(Qt.Horizontal) _z_label = QLabel('Overlay z-slice ') @@ -4023,6 +4133,7 @@ def gui_getImg1BottomWidgets(self): bottomLeftLayout.addWidget(self.zProjComboBox, row, 3) bottomLeftLayout.addWidget(self.zProjLockViewButton, row, 4) bottomLeftLayout.addWidget(self.switchPlaneCombobox, row, 5) + bottomLeftLayout.addWidget(self.launch3dViewerButton, row, 6) self.zSliceSpinbox.connectValueChanged(self.onZsliceSpinboxValueChange) self.zSliceSpinbox.editingFinished.connect(self.zSliceScrollBarReleased) @@ -4252,6 +4363,8 @@ def gui_addGraphicsItems(self): self.viewMenu.addAction(self.labelsGrad.showLabelsImgAction) self.viewMenu.addAction(self.labelsGrad.showRightImgAction) + self.viewMenu.addAction(self.launch3dViewerAction) + # Right image histogram self.imgGradRight = widgets.baseHistogramLUTitem( name='image', parent=self, gradientPosition='left' @@ -12486,6 +12599,9 @@ def enableZstackWidgets(self, enabled): self.switchPlaneCombobox.show() self.switchPlaneCombobox.setDisabled(False) self.SizeZlabel.show() + self.launch3dViewerButton.show() + self.launch3dViewerButton.setDisabled(False) + self.launch3dViewerAction.setDisabled(False) else: myutils.setRetainSizePolicy(self.zSliceScrollBar, retain=False) myutils.setRetainSizePolicy(self.zProjComboBox, retain=False) @@ -12501,6 +12617,9 @@ def enableZstackWidgets(self, enabled): self.SizeZlabel.hide() self.switchPlaneCombobox.hide() self.switchPlaneCombobox.setDisabled(True) + self.launch3dViewerButton.hide() + self.launch3dViewerButton.setDisabled(True) + self.launch3dViewerAction.setDisabled(True) self.imgGrad.rescaleAcrossZstackAction.setDisabled(not enabled) for ch, overlayItems in self.overlayLayersItems.items(): @@ -19865,6 +19984,7 @@ def connectScrollbars(self): self.zProjComboBox.currentTextChanged.disconnect() self.zProjComboBox.activated.disconnect() self.switchPlaneCombobox.sigPlaneChanged.disconnect() + self.launch3dViewerButton.toggled.disconnect() self.zProjLockViewButton.toggled.disconnect() except Exception as e: pass @@ -19880,6 +20000,9 @@ def connectScrollbars(self): self.switchViewedPlane ) self.zProjLockViewButton.toggled.connect(self.zProjLockViewToggled) + self.launch3dViewerButton.toggled.connect( + self.launch3dViewerToggled + ) posData = self.data[self.pos_i] if posData.SizeT == 1: @@ -23383,17 +23506,7 @@ def load_fluo_data(self, fluo_path, isGuiThread=True): return fluo_data, bkgrData def setOverlayColors(self): - self.overlayRGBs = [ - (255, 255, 0), - (252, 72, 254), - (49, 222, 134), - (22, 108, 27) - ] - self.overlayCmap = matplotlib.colormaps['hsv'] - self.overlayRGBs.extend( - [tuple([round(c*255) for c in self.overlayCmap(i)][:3]) - for i in np.linspace(0,1,8)] - ) + self.overlayRGBs = colors.overlay_rgbs.copy() def getFileExtensions(self, images_path): alignedFound = any([f.find('_aligned.np')!=-1 @@ -24226,6 +24339,7 @@ def addPointsLayer(self, toolbar=None): np.array(action.penColor)/255, 0.3 ) action.pointSize = pointSize + action.symbol = symbol action.zRadius = zRadius action.button = toolButton action.scatterItem = scatterItem @@ -24583,6 +24697,44 @@ def getCentroidsPointsData(self, action): obj.label ) + def addPointsLayersToVolumeViewer(self, volume_renderer): + from cellacdc.plot import PyQtGraphScatterPlotSymbolToVispyMapper + + posData = self.data[self.pos_i] + for toolbar in self.pointsLayersToolbars: + for action in toolbar.actions()[1:]: + if not hasattr(action, 'layerTypeIdx'): + continue + + if action.layerTypeIdx < 2: + self.getCentroidsPointsData(action) + + frames = action.pointsData.get(self.pos_i, set()) + if posData.frame_i not in frames: + continue + + framePointsData = action.pointsData[self.pos_i][posData.frame_i] + xx, yy, zz = [], [], [] + for z, z_data in framePointsData.items(): + xx.extend(z_data['x']) + yy.extend(z_data['y']) + zz.extend([z]*len(z_data['x'])) + + color = np.array(action.penColor)/255 + vispy_symbol = ( + PyQtGraphScatterPlotSymbolToVispyMapper[action.symbol] + ) + points_zyx = np.column_stack((zz, yy, xx)) + volume_renderer.add_points_layer( + str(id(action.button)), + points=points_zyx, + color=color, + size=action.pointSize, + opacity=1.0, + symbol=vispy_symbol, + visible=action.button.isChecked() + ) + def drawPointsLayers(self, computePointsLayers=True): posData = self.data[self.pos_i] for toolbar in self.pointsLayersToolbars: @@ -25052,7 +25204,8 @@ def getOlImg(self, key, frame_i=None): self.updateOverlayZslice ) if zProjHow == 'single z-slice': - self.overlay_z_label.setText(f'Overlay z-slice {z+1:02}/{posData.SizeZ}') + self.overlay_z_label.setText( + f'Overlay z-slice {z+1:02}/{posData.SizeZ}') ol_img = img[z].copy() elif zProjHow == 'max z-projection': ol_img = img.max(axis=0) diff --git a/cellacdc/myutils.py b/cellacdc/myutils.py index 638e3141..b369f19c 100644 --- a/cellacdc/myutils.py +++ b/cellacdc/myutils.py @@ -5717,3 +5717,21 @@ def separate_fluo_segment_channels(channels): else: channels_to_load.append(ch) return segms_to_load, channels_to_load, current_segm + +def download_3d_renderer_demo_data(): + from cellacdc.volume_renderer import data_path + + url = 'https://hmgubox2.helmholtz-muenchen.de/public.php/dav/files/5maWLyHRX4Lrk7x/?accept=zip' + file_size = 66398550 + + dst_zip_filepath = os.path.join(data_path, 'mtHINESS_Sample_Position.zip') + exp_folderpath = dst_zip_filepath.replace('.zip', '') + + if os.path.exists(exp_folderpath): + return exp_folderpath + + download_url(url, dst_zip_filepath, file_size=file_size) + extract_zip(dst_zip_filepath, data_path) + + return exp_folderpath + \ No newline at end of file diff --git a/cellacdc/plot.py b/cellacdc/plot.py index 3d33b3c2..0d7570f6 100644 --- a/cellacdc/plot.py +++ b/cellacdc/plot.py @@ -22,11 +22,96 @@ if GUI_INSTALLED: from qtpy import QtGui from . import widgets - + import pyqtgraph as pg + + from . import printl from . import _core, error_below, error_close from . import _run, core, myutils +VisPyMarkerSymbols = Literal[ + 'disc', + 'arrow', + 'ring', + 'clobber', + 'square', + 'diamond', + 'vbar', + 'hbar', + 'cross', + 'tailed_arrow', + 'x', + 'triangle_up', + 'triangle_down', + 'star' +] +PyQtGraphScatterPlotSymbolToVispyMapper = { + 'o': 'disc', + 's': 'square', + 'd': 'diamond', + 't': 'triangle_up', + 't1': 'triangle_down', + 'arrow_up': 'arrow', + '+': 'cross', + 'x': 'x', + 'star': 'star', + '|': 'vbar', + '_': 'hbar' +} + + +PyQtGraphScatterPlotSymbolPathMatter = {} +if GUI_INSTALLED: + PyQtGraphScatterPlotSymbolPathMatter = ( + pg.graphicsItems.ScatterPlotItem.Symbols.copy() + ) + # VisPy -> PyQtGraph + PyQtGraphScatterPlotSymbolPathMatter['disc'] = ( + PyQtGraphScatterPlotSymbolPathMatter['o'] + ) + PyQtGraphScatterPlotSymbolPathMatter['ring'] = ( + PyQtGraphScatterPlotSymbolPathMatter['o'] + ) + PyQtGraphScatterPlotSymbolPathMatter['square'] = ( + PyQtGraphScatterPlotSymbolPathMatter['s'] + ) + PyQtGraphScatterPlotSymbolPathMatter['diamond'] = ( + PyQtGraphScatterPlotSymbolPathMatter['d'] + ) + PyQtGraphScatterPlotSymbolPathMatter['triangle_up'] = ( + PyQtGraphScatterPlotSymbolPathMatter['t'] + ) + PyQtGraphScatterPlotSymbolPathMatter['triangle_down'] = ( + PyQtGraphScatterPlotSymbolPathMatter['t1'] + ) + PyQtGraphScatterPlotSymbolPathMatter['arrow'] = ( + PyQtGraphScatterPlotSymbolPathMatter['arrow_up'] + ) + PyQtGraphScatterPlotSymbolPathMatter['cross'] = ( + PyQtGraphScatterPlotSymbolPathMatter['+'] + ) + PyQtGraphScatterPlotSymbolPathMatter['x'] = ( + PyQtGraphScatterPlotSymbolPathMatter['x'] + ) + PyQtGraphScatterPlotSymbolPathMatter['star'] = ( + PyQtGraphScatterPlotSymbolPathMatter['star'] + ) + PyQtGraphScatterPlotSymbolPathMatter['vbar'] = ( + PyQtGraphScatterPlotSymbolPathMatter['|'] + ) + PyQtGraphScatterPlotSymbolPathMatter['hbar'] = ( + PyQtGraphScatterPlotSymbolPathMatter['_'] + ) + + # Closest available approximations + PyQtGraphScatterPlotSymbolPathMatter['clobber'] = ( + PyQtGraphScatterPlotSymbolPathMatter['x'] + ) + PyQtGraphScatterPlotSymbolPathMatter['tailed_arrow'] = ( + PyQtGraphScatterPlotSymbolPathMatter['arrow_up'] + ) + + def matplotlib_cmap_to_lut( cmap: Union[Iterable, matplotlib.colors.Colormap, str], n_colors: int=256 diff --git a/cellacdc/volume_renderer/__init__.py b/cellacdc/volume_renderer/__init__.py new file mode 100644 index 00000000..63e4ac69 --- /dev/null +++ b/cellacdc/volume_renderer/__init__.py @@ -0,0 +1,4 @@ +import os + +cwd_path = os.path.dirname(os.path.abspath(__file__)) +data_path = os.path.join(cwd_path, '_demos', 'data') \ No newline at end of file diff --git a/cellacdc/volume_renderer/_demos/_demo_lab_2d.py b/cellacdc/volume_renderer/_demos/_demo_lab_2d.py new file mode 100644 index 00000000..b88a7b3b --- /dev/null +++ b/cellacdc/volume_renderer/_demos/_demo_lab_2d.py @@ -0,0 +1,28 @@ +import os + +import numpy as np + +import skimage + +from cellacdc._run import _setup_app + +from cellacdc import VolumeRendererWindow +from cellacdc import myutils + +exp_folderpath = myutils.download_3d_renderer_demo_data() + +images_path = os.path.join( + exp_folderpath, + 'Position_3', + 'Images', +) + +lab_filepath = os.path.join( + images_path, 'FPY015-2_SCD-03_s03_segm.npz' +) + +lab = np.load(lab_filepath)['arr_0'] + +renderer = VolumeRendererWindow() +renderer.set_labels(lab, SizeZ=10) +renderer.run() \ No newline at end of file diff --git a/cellacdc/volume_renderer/_demos/_demo_multi_channel.py b/cellacdc/volume_renderer/_demos/_demo_multi_channel.py new file mode 100644 index 00000000..d1487cec --- /dev/null +++ b/cellacdc/volume_renderer/_demos/_demo_multi_channel.py @@ -0,0 +1,35 @@ +import os + +import skimage + +from cellacdc._run import _setup_app + +from cellacdc import VolumeRendererWindow +from cellacdc import myutils + +exp_folderpath = myutils.download_3d_renderer_demo_data() + +images_path = os.path.join( + exp_folderpath, + 'Position_3', + 'Images', +) + +kaedegreen_filepath = os.path.join( + images_path, 'FPY015-2_SCD-03_s03_run_num1_KaeGr_preprocessed.tif' +) +mscarlet_filepath = os.path.join( + images_path, 'FPY015-2_SCD-03_s03_run_num1_mScarlet_preprocessed.tif' +) + +kaedegreen_volume = skimage.io.imread(kaedegreen_filepath) +mscarlet_volume = skimage.io.imread(mscarlet_filepath) + +data = { + 'mScarlet-I3': mscarlet_volume, + 'KaedeGreen': kaedegreen_volume +} + +renderer = VolumeRendererWindow() +renderer.set_volumes(data) +renderer.run() \ No newline at end of file diff --git a/cellacdc/volume_renderer/_demos/_demo_multi_channel_lab_3d_with_points.py b/cellacdc/volume_renderer/_demos/_demo_multi_channel_lab_3d_with_points.py new file mode 100644 index 00000000..6d846b77 --- /dev/null +++ b/cellacdc/volume_renderer/_demos/_demo_multi_channel_lab_3d_with_points.py @@ -0,0 +1,74 @@ +import os + +import numpy as np +import pandas as pd + +import skimage + +from cellacdc._run import _setup_app + +from cellacdc import VolumeRendererWindow +from cellacdc import myutils + +exp_folderpath = myutils.download_3d_renderer_demo_data() + +images_path = os.path.join( + exp_folderpath, + 'Position_3', + 'Images', +) +spotmax_out_path = os.path.join( + exp_folderpath, + 'Position_3', + 'spotMAX_output', +) + +kaedegreen_filepath = os.path.join( + images_path, 'FPY015-2_SCD-03_s03_run_num1_KaeGr_preprocessed.tif' +) +mscarlet_filepath = os.path.join( + images_path, 'FPY015-2_SCD-03_s03_run_num1_mScarlet_preprocessed.tif' +) + +lab_filepath = os.path.join( + images_path, 'FPY015-2_SCD-03_s03_run_num1_mScarlet_ref_ch_segm_mask.npz' +) + +df_points_filepath = os.path.join( + spotmax_out_path, '1_2_spotfit.csv' +) + +lab = np.load(lab_filepath)['arr_0'] + +metadata_filepath = os.path.join( + images_path, 'FPY015-2_SCD-03_s03_metadata.csv' +) + +df_metadata = pd.read_csv(metadata_filepath, index_col='Description') +voxel_size = ( + float(df_metadata.at['PhysicalSizeZ', 'values']), + float(df_metadata.at['PhysicalSizeY', 'values']), + float(df_metadata.at['PhysicalSizeX', 'values']) +) + +kaedegreen_volume = skimage.io.imread(kaedegreen_filepath) +mscarlet_volume = skimage.io.imread(mscarlet_filepath) + +df_points = pd.read_csv(df_points_filepath) + +data = { + 'mScarlet-I3': mscarlet_volume, + 'KaedeGreen': kaedegreen_volume +} + +renderer = VolumeRendererWindow() +renderer.set_volumes( + data, + voxel_size=voxel_size +) +renderer.set_labels(lab) +renderer.add_points_layer( + 'SpotFIT', + points_df=df_points +) +renderer.run() \ No newline at end of file diff --git a/cellacdc/volume_renderer/_demos/_demo_single_channel.py b/cellacdc/volume_renderer/_demos/_demo_single_channel.py new file mode 100644 index 00000000..46ce2390 --- /dev/null +++ b/cellacdc/volume_renderer/_demos/_demo_single_channel.py @@ -0,0 +1,19 @@ +import os + +import skimage + +from cellacdc._run import _setup_app + +from cellacdc import VolumeRendererWindow +from cellacdc import data_path + +zstack_filepath = os.path.join( + data_path, 'test_3Dsegm', 'Arohi', 'Position_1', 'Images', + 'CMJ030_1.100 H3__fl-01_s1_Ch1_IF_H3_405_T3.tif' +) + +zstack_array = skimage.io.imread(zstack_filepath) + +renderer = VolumeRendererWindow() +renderer.set_volume(zstack_array) # (Z, Y, X) numpy array +renderer.run() \ No newline at end of file diff --git a/cellacdc/volume_renderer/_demos/_demo_single_channel_custom_cmap.py b/cellacdc/volume_renderer/_demos/_demo_single_channel_custom_cmap.py new file mode 100644 index 00000000..33222272 --- /dev/null +++ b/cellacdc/volume_renderer/_demos/_demo_single_channel_custom_cmap.py @@ -0,0 +1,26 @@ +import os + +import skimage + +from cellacdc._run import _setup_app + +from cellacdc import VolumeRendererWindow +from cellacdc import myutils + +exp_folderpath = myutils.download_3d_renderer_demo_data() + +images_path = os.path.join( + exp_folderpath, + 'Position_3', + 'Images', +) + +kaedegreen_filepath = os.path.join( + images_path, 'FPY015-2_SCD-03_s03_run_num1_KaeGr_preprocessed.tif' +) + +kaedegreen_volume = skimage.io.imread(kaedegreen_filepath) + +renderer = VolumeRendererWindow() +renderer.set_volume(kaedegreen_volume, cmap=['black', 'green']) # (Z, Y, X) numpy array +renderer.run() \ No newline at end of file diff --git a/cellacdc/volume_renderer/_demos/_demo_single_channel_lab_2d.py b/cellacdc/volume_renderer/_demos/_demo_single_channel_lab_2d.py new file mode 100644 index 00000000..75649159 --- /dev/null +++ b/cellacdc/volume_renderer/_demos/_demo_single_channel_lab_2d.py @@ -0,0 +1,51 @@ +import os + +import numpy as np +import pandas as pd + +import skimage + +from cellacdc._run import _setup_app + +from cellacdc import VolumeRendererWindow +from cellacdc import myutils + +exp_folderpath = myutils.download_3d_renderer_demo_data() + +images_path = os.path.join( + exp_folderpath, + 'Position_3', + 'Images', +) + +kaedegreen_filepath = os.path.join( + images_path, 'FPY015-2_SCD-03_s03_run_num1_KaeGr_preprocessed.tif' +) + +lab_filepath = os.path.join( + images_path, 'FPY015-2_SCD-03_s03_segm.npz' +) + +lab = np.load(lab_filepath)['arr_0'] + +metadata_filepath = os.path.join( + images_path, 'FPY015-2_SCD-03_s03_metadata.csv' +) + +df_metadata = pd.read_csv(metadata_filepath, index_col='Description') +voxel_size = ( + float(df_metadata.at['PhysicalSizeZ', 'values']), + float(df_metadata.at['PhysicalSizeY', 'values']), + float(df_metadata.at['PhysicalSizeX', 'values']) +) + +volume = skimage.io.imread(kaedegreen_filepath) +data = {'KaedeGreen': volume} + +renderer = VolumeRendererWindow() +renderer.set_volumes( + data, + voxel_size=voxel_size +) +renderer.set_labels(lab) +renderer.run() \ No newline at end of file diff --git a/cellacdc/volume_renderer/_demos/_demo_single_channel_lab_3d.py b/cellacdc/volume_renderer/_demos/_demo_single_channel_lab_3d.py new file mode 100644 index 00000000..0f68970a --- /dev/null +++ b/cellacdc/volume_renderer/_demos/_demo_single_channel_lab_3d.py @@ -0,0 +1,51 @@ +import os + +import numpy as np +import pandas as pd + +import skimage + +from cellacdc._run import _setup_app + +from cellacdc import VolumeRendererWindow +from cellacdc import myutils + +exp_folderpath = myutils.download_3d_renderer_demo_data() + +images_path = os.path.join( + exp_folderpath, + 'Position_3', + 'Images', +) + +mscarlet_filepath = os.path.join( + images_path, 'FPY015-2_SCD-03_s03_run_num1_mScarlet_preprocessed.tif' +) + +lab_filepath = os.path.join( + images_path, 'FPY015-2_SCD-03_s03_run_num1_mScarlet_ref_ch_segm_mask.npz' +) + +lab = np.load(lab_filepath)['arr_0'] + +metadata_filepath = os.path.join( + images_path, 'FPY015-2_SCD-03_s03_metadata.csv' +) + +df_metadata = pd.read_csv(metadata_filepath, index_col='Description') +voxel_size = ( + float(df_metadata.at['PhysicalSizeZ', 'values']), + float(df_metadata.at['PhysicalSizeY', 'values']), + float(df_metadata.at['PhysicalSizeX', 'values']) +) + +volume = skimage.io.imread(mscarlet_filepath) +data = {'mScarlet-I3': volume} + +renderer = VolumeRendererWindow() +renderer.set_volumes( + data, + voxel_size=voxel_size +) +renderer.set_labels(lab) +renderer.run() \ No newline at end of file diff --git a/cellacdc/volume_renderer/_demos/_demo_single_channel_lab_3d_with_points.py b/cellacdc/volume_renderer/_demos/_demo_single_channel_lab_3d_with_points.py new file mode 100644 index 00000000..20bf78e2 --- /dev/null +++ b/cellacdc/volume_renderer/_demos/_demo_single_channel_lab_3d_with_points.py @@ -0,0 +1,67 @@ +import os + +import numpy as np +import pandas as pd + +import skimage + +from cellacdc._run import _setup_app + +from cellacdc import VolumeRendererWindow +from cellacdc import myutils + +exp_folderpath = myutils.download_3d_renderer_demo_data() + +images_path = os.path.join( + exp_folderpath, + 'Position_3', + 'Images', +) +spotmax_out_path = os.path.join( + exp_folderpath, + 'Position_3', + 'spotMAX_output', +) + +mscarlet_filepath = os.path.join( + images_path, 'FPY015-2_SCD-03_s03_run_num1_mScarlet_preprocessed.tif' +) + +lab_filepath = os.path.join( + images_path, 'FPY015-2_SCD-03_s03_run_num1_mScarlet_ref_ch_segm_mask.npz' +) + +df_points_filepath = os.path.join( + spotmax_out_path, '1_2_spotfit.csv' +) + +lab = np.load(lab_filepath)['arr_0'] + +metadata_filepath = os.path.join( + images_path, 'FPY015-2_SCD-03_s03_metadata.csv' +) + +df_metadata = pd.read_csv(metadata_filepath, index_col='Description') +voxel_size = ( + float(df_metadata.at['PhysicalSizeZ', 'values']), + float(df_metadata.at['PhysicalSizeY', 'values']), + float(df_metadata.at['PhysicalSizeX', 'values']) +) + +volume = skimage.io.imread(mscarlet_filepath) + +df_points = pd.read_csv(df_points_filepath) + +data = {'mScarlet-I3': volume} + +renderer = VolumeRendererWindow() +renderer.set_volumes( + data, + voxel_size=voxel_size +) +renderer.set_labels(lab) +renderer.add_points_layer( + 'SpotFIT', + points_df=df_points +) +renderer.run() \ No newline at end of file diff --git a/cellacdc/volume_renderer/_demos/data/.gitkeep b/cellacdc/volume_renderer/_demos/data/.gitkeep new file mode 100644 index 00000000..a08fd50d --- /dev/null +++ b/cellacdc/volume_renderer/_demos/data/.gitkeep @@ -0,0 +1 @@ +Run any of the demos scripts to automatically download the required data \ No newline at end of file diff --git a/cellacdc/volume_renderer/_widgets.py b/cellacdc/volume_renderer/_widgets.py new file mode 100644 index 00000000..288e4a9d --- /dev/null +++ b/cellacdc/volume_renderer/_widgets.py @@ -0,0 +1,92 @@ +from qtpy.QtCore import ( + Signal, Qt +) +from qtpy.QtWidgets import ( + QAction +) +from qtpy.QtGui import ( + QIcon, +) + +from cellacdc.widgets import ToolBar + +class VolumeRendererToolbar(ToolBar): + sigHomeView = Signal() + sigSave = Signal() + sigSetSingleChannel = Signal(bool) + sigSelectObjects = Signal(bool) + sigUpdate = Signal() + + def __init__(self, name='Volume Renderer Toolbar', parent=None): + + super().__init__(name, parent) + + self.parentWin = parent + + self.setContextMenuPolicy(Qt.PreventContextMenu) + + self.emitSigUpdateAction = QAction( + QIcon(':reload.svg'), 'Emit update signal', self) + self.emitSigUpdateAction.setToolTip( + 'Emit the `sigUpdate` signal.' + 'Click to tell the Cell-ACDC GUI to update the 3D viewer ' + 'with the current data.' + ) + self.addAction(self.emitSigUpdateAction) + + 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.addSeparator() + + # self.selectObjectsAction = QAction( + # QIcon(':keep_objects.svg'), 'Select objects', self) + # self.selectObjectsAction.setToolTip( + # 'Select objects in the view.\n\n' + # 'Ctrl+Click to select multiple objects.\n\n' + # 'Press Esc to exit selection mode.' + # ) + # self.selectObjectsAction.setCheckable(True) + # self.addAction(self.selectObjectsAction) + + # self.addSeparator() + + self.singleChannelCheckbox = self.addCheckBox( + text='Single channel' + ) + + self.singleChannelCheckbox.setToolTip( + 'When single channel mode is activated, selecting a channel ' + 'will display only that channel in the overlay.' + ) + + self.emitSigUpdateAction.triggered.connect(self.sigUpdate.emit) + self.homeViewAction.triggered.connect(self.sigHomeView.emit) + self.saveAction.triggered.connect(self.sigSave.emit) + # self.selectObjectsAction.toggled.connect( + # self.sigSelectObjects.emit + # ) + + self.singleChannelCheckbox.toggled.connect( + self.sigSetSingleChannel.emit + ) + + def is_single_channel_mode(self) -> bool: + return self.singleChannelCheckbox.isChecked() + +class PointsLayersToolbar(ToolBar): + def __init__(self, name='Points Layer Toolbar', parent=None): + super().__init__(name, parent) + + self.addLabel('Points: ') \ No newline at end of file diff --git a/cellacdc/volume_renderer/canvas.py b/cellacdc/volume_renderer/canvas.py new file mode 100644 index 00000000..e0a32e2c --- /dev/null +++ b/cellacdc/volume_renderer/canvas.py @@ -0,0 +1,1421 @@ +from dataclasses import dataclass +from functools import partial +from math import ceil + +from typing import Literal + +import numpy as np +import pandas as pd + +import skimage + +from qtpy.QtCore import ( + Signal, Qt, QCoreApplication, QEventLoop +) +from qtpy.QtWidgets import ( + QMainWindow, QHBoxLayout, QVBoxLayout, QWidget, QPushButton, + QGraphicsProxyWidget, QGroupBox, QCheckBox, QMenu, QRadioButton, + QButtonGroup +) + +import pyqtgraph as pg + +from .. import printl +from .._run import _setup_app +from .. import apps, widgets +from .. import colors +from .. import plot + +from . import _widgets, utils + +import vispy.scene +import vispy.color + +rng = np.random.default_rng(42) + +AcdcColorMap = colors.AcdcColorMap +AcdcPyQtGraphColorMapName = colors.AcdcPyQtGraphColorMapName +PltColorName = colors.PltColorName +ColorMapPerChannel = ( + list[AcdcColorMap] + | dict[str, AcdcColorMap] +) +LabelsGradientName = colors.AcdcPyQtGraphColorMapName + +LutItemState = colors.LutItemState +LutItemStates = ( + list[LutItemState] + | dict[str, LutItemState] +) + +MarkerSymbols = plot.VisPyMarkerSymbols + +_SLIDER_NORMALIZE_FACTOR = 20 +_DEFAULT_LABELS_CMAP_NAME = 'viridis' + +@dataclass +class _ChannelData: + node: vispy.scene.visuals.Volume + + volume: np.ndarray + _raw_volume: np.ndarray + _off_focus_volume: np.ndarray + + auto_button: QPushButton + reset_button: QPushButton + lut_item: widgets.baseHistogramLUTitem + + opacity_slider: widgets.sliderWithSpinBox + toolbutton: widgets.OverlayChannelToolButton + +@dataclass +class _PointsLayer: + name: str + + points_xyz: np.ndarray # (N, [x, y, z]) voxel coordinates + _label_ids: np.ndarray | None # (N, id) id in self._orig_lab + labels: list[str] | None + + markers: vispy.scene.visuals.Markers + + toolbutton: widgets.PointsLayerToolButton + properties_dialog: apps.EditPointsLayerAppearanceDialog + context_menu: widgets.PointsLayerContextMenu + + df: pd.DataFrame | None=None + text: vispy.scene.visuals.Text | None=None + +class VolumeRendererWindow(QMainWindow): + """ + A standalone Qt window that displays a 3D z-stack volume using vispy. + + Usage (minimal):: + + renderer = VolumeRenderer3DWindow() + renderer.set_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. + """ + sigClose = Signal(object) + sigUpdate = Signal() + + def __init__( + self, + app=None, + parent=None, + version=None, + title='Cell-ACDC - Volume Renderer', + hide_on_close=False, + logger_func=print + ): + """Initializer.""" + self._version = version + self._logger_func = logger_func + self._ui_initialised = False + self._is_labels_set = False + self._hide_on_close = hide_on_close + self._max_texture_3d = None + self._lut_items_width = 20 # Start with some padding + self._SizeZ = None + self._gradient_item_state = None + self._lab_gradient_cmap_name = None + self._lab_gradient_item_state = None + self._lab_node = None + self._orig_lab = None + self._canvas = None + self._voxel_size = None + self._voxel_size_transform = None + self._lab_ncolors = 256 + self._voxel_size_strides_transform = None + self._downsample_strides = None + self._object_labels_list_buttongroup = None + self._data_shape = None + + if app is None: + app = QCoreApplication.instance() + + if app is None: + app, splashScreen = _setup_app(splashscreen=True) + splashScreen.close() + + self.app = app + + super().__init__(parent) + self.setWindowTitle(title) + + self._channels_data: dict[str, _ChannelData] = {} + self._points_layers: dict[str, _PointsLayer] = {} + + self._init_default_rgbs() + self._init_vispy() + self._init_ui() + + 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 + + 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 _init_lab_ui_items( + self, + lab_volume, + cmap_name=None, + gradient_item_state=None, + ): + from vispy.scene import visuals + from .gl_blend import volume_gl_state + + vmin, vmax = float(lab_volume.min()), float(lab_volume.max()) + + self._lab_node = visuals.Volume( + lab_volume, + method="translucent", + interpolation="nearest", + parent=self._view.scene, + ) + self._lab_node.clim = (vmin, vmax) + + self._lab_opacity_slider = widgets.sliderWithSpinBox( + title_loc='in_line', + isFloat=True, + parent=self, + normalize_factor=_SLIDER_NORMALIZE_FACTOR + ) + + self._lab_opacity_slider.setRange(0.0, 1.0) + self._lab_opacity_slider.setSingleStep(0.05) + self._lab_opacity_slider.setValue(0.3) + self._lab_opacity_slider.setDecimals(2) + + self._lab_opacity_slider.setToolTip(f'Opacity of segmentation masks') + opacity_slider_form_widget = widgets.formWidget( + self._lab_opacity_slider, + labelTextLeft='Segmentation masks opacity:', + ) + self._controls_layout.addFormWidget( + opacity_slider_form_widget, row=0 + ) + + lab_reset_btn = QPushButton('Reset') + self._lab_gradient_item_layout.addWidget(lab_reset_btn) + + lab_gradient_item = widgets.BaseLabelsGradientWidget(parent=self) + self._lab_gradient_item_layout.addWidget(lab_gradient_item) + self._lab_gradient_item = lab_gradient_item + + self._lab_opacity_slider.valueChanged.connect( + self._on_lab_opacity_changed + ) + lab_gradient_item.sigGradientChangeFinished.connect( + self._on_lab_gradient_changed + ) + lab_gradient_item.sigShuffleCmap.connect( + self._random_shuffle_lab_gradient_cmap + ) + lab_gradient_item.sigGreeedyShuffleCmap.connect( + self._greedy_shuffle_lab_gradient_cmap + ) + lab_gradient_item.shuffleCmapAction.setShortcut('Shift+S') + lab_gradient_item.greedyShuffleCmapAction.setShortcut('Alt+Shift+S') + # lab_lut_item.sigGradientChanged.connect(self.ticksCmapMoved) + lab_reset_btn.clicked.connect(self._on_reset_lab_gradient) + + lab_reset_btn.setMaximumWidth( + ceil(lab_gradient_item.sizeHint().width())) + + if gradient_item_state is not None: + lab_gradient_item.item.restoreState(gradient_item_state) + self._lab_gradient_item_state = gradient_item_state + elif cmap_name is not None: + self._lab_gradient_cmap_name = cmap_name + lab_gradient_item.item.loadPreset(cmap_name) + else: + lab_gradient_item.item.loadPreset(_DEFAULT_LABELS_CMAP_NAME) + + self._lab_node.opacity = self._lab_opacity_slider.value() + + self._object_labels_list_buttongroup = QButtonGroup(self) + self._object_labels_list_buttongroup.setExclusive(False) + for obj in self._rp: + obj_checkbox = QCheckBox(f'{obj.label}') + obj_checkbox.setChecked(True) + obj_checkbox.obj = obj + self._object_labels_list_layout.addWidget(obj_checkbox) + self._object_labels_list_buttongroup.addButton(obj_checkbox) + obj_checkbox.toggled.connect( + partial(self._set_object_checked, obj=obj) + ) + obj_checkbox.setDisabled(True) + + self._object_labels_list_layout.addStretch(1) + + select_all_button = widgets.selectAllPushButton() + select_all_button.sigClicked.connect( + self._set_all_object_labels_list_checked + ) + self._right_vertical_layout.addWidget(select_all_button) + + self._right_vertical_layout.addSpacing(10) + + display_mode_groupbox = QGroupBox('Display mode') + display_mode_layout = QVBoxLayout() + display_mode_groupbox.setLayout(display_mode_layout) + + display_mode_buttonsgroup = QButtonGroup(self) + + self._display_mode_show_all_rb = QRadioButton( + 'Show all objets', self + ) + self._display_mode_hide_unselected_rb = QRadioButton( + 'Hide unselected', self + ) + self._display_mode_focus_selected_rb = QRadioButton( + 'Focus on selected', self + ) + self._display_mode_show_all_rb.setChecked(True) + display_mode_buttonsgroup.addButton(self._display_mode_show_all_rb) + display_mode_buttonsgroup.addButton( + self._display_mode_hide_unselected_rb) + display_mode_buttonsgroup.addButton( + self._display_mode_focus_selected_rb) + + display_mode_buttonsgroup.buttonToggled.connect( + self._display_mode_radio_button_toggled + ) + + display_mode_layout.addWidget(self._display_mode_show_all_rb) + display_mode_layout.addWidget(self._display_mode_hide_unselected_rb) + display_mode_layout.addWidget(self._display_mode_focus_selected_rb) + + self._right_vertical_layout.addWidget(display_mode_groupbox) + + def _display_mode_radio_button_toggled( + self, button: QRadioButton, toggled: bool + ): + if not toggled: + return + + is_show_all = self._display_mode_show_all_rb.isChecked() + self._set_object_labels_checkboxes_disabled(disabled=is_show_all) + + self._update_display() + + def _set_all_object_labels_list_checked(self, select_all_button, checked): + for checkbox in self._object_labels_list_buttongroup.buttons(): + checkbox.blockSignals(True) + checkbox.setChecked(checked) + checkbox.blockSignals(False) + + self._update_display() + + def _update_lab_node(self, update=True): + for checkbox in self._object_labels_list_buttongroup.buttons(): + obj = checkbox.obj + checked = checkbox.isChecked() + _id = obj.label if checked else 0 + self._lab[obj.slice][obj.image] = _id + + self._lab_node.set_data(self._lab) + + if update: + self._canvas.update() + + def _update_markers(self, update=True): + for points_layer in self._points_layers.values(): + state = points_layer.properties_dialog.state() + self._update_points_layer_properties( + state, points_layer=points_layer, update=update + ) + + if update: + self._canvas.update() + + def _update_volume_nodes(self, update=True): + for channel_data in self._channels_data.values(): + if self._display_mode_show_all_rb.isChecked(): + displayed_volume = channel_data._raw_volume + channel_data.node.set_data(displayed_volume) + continue + + displayed_volume = channel_data.volume + if self._display_mode_focus_selected_rb.isChecked(): + unselected_vals = channel_data._off_focus_volume + np.copyto(displayed_volume, unselected_vals) + elif self._display_mode_hide_unselected_rb.isChecked(): + displayed_volume[:] = 0 + + for checkbox in self._object_labels_list_buttongroup.buttons(): + obj = checkbox.obj + checked = checkbox.isChecked() + if not checked: + continue + + src_intensities = ( + channel_data._raw_volume[obj.slice][obj.image] + ) + displayed_volume[obj.slice][obj.image] = src_intensities + + channel_data.node.set_data(displayed_volume) + + if update: + self._canvas.update() + + def _update_display(self, exclude_lab=False, exclude_markers=False): + if not exclude_lab: + self._update_lab_node(update=False) + + if not exclude_markers: + self._update_markers(update=False) + + self._update_volume_nodes(update=False) + + self._canvas.update() + + def _init_points_layer_ui_items( + self, + markers: vispy.scene.visuals.Markers, + name: str, + points_xyz: np.ndarray | None=None, # (N, [z, y, x]) voxel coordinates + labels: list[str] | None=None, + color: vispy.color.Color='red', + size: float=8.0, + opacity: float=1.0, + symbol: MarkerSymbols='disc', + points_df: pd.DataFrame | None=None, + ): + rgb_color = vispy.color.Color(color).rgb + rgb_color = [round(val*255) for val in rgb_color] + + toolbutton = widgets.PointsLayerToolButton( + symbol, rgb_color, parent=self + ) + toolbutton.setCheckable(True) + toolbutton.setChecked(True) + + properties_dialog = apps.EditPointsLayerAppearanceDialog( + backend='vispy', + is_3d=True, + add_opacity_slider=True, + parent=toolbutton, + hide_on_close=True + ) + + properties_dialog.restoreState({ + 'symbol': symbol, + 'color': rgb_color, + 'pointSize': size, + 'opacity': opacity + }) + properties_dialog.hide() + + context_menu = widgets.PointsLayerContextMenu(toolbutton) + toolbutton._contextMenu = context_menu + context_menu.hide() + + toolbutton.action = self._points_toolbar.addWidget(toolbutton) + self._points_toolbar.show() + + return toolbutton, properties_dialog, context_menu + + def _random_shuffle_lab_gradient_cmap(self): + from vispy.color import Colormap as VisPyColormap + + perm = rng.permutation(self._lab_ncolors) + lut = self._lab_gradient_item.colorMap().getLookupTable( + 0.0, 1.0, self._lab_ncolors + ) + shuffle_lut = lut[perm] + + shuffle_lut = np.array(shuffle_lut) / 255.0 + shuffle_lut = colors.replace_background_rgba_lut(shuffle_lut) + + cmap = VisPyColormap(shuffle_lut) + + self._lab_node.cmap = cmap + self._canvas.update() + + def _greedy_shuffle_lab_gradient_cmap(self): + from vispy.color import Colormap as VisPyColormap + + lut = self._lab_gradient_item.colorMap().getLookupTable( + 0.0, 1.0, self._lab_ncolors + ) + labels = [obj.label for obj in self._rp] + greedy_lut = colors.get_greedy_lut(self._lab, lut, ids=labels) + + greedy_lut = np.array(greedy_lut) / 255.0 + greedy_lut = colors.replace_background_rgba_lut(greedy_lut) + + cmap = VisPyColormap(greedy_lut) + + self._lab_node.cmap = cmap + self._canvas.update() + + def _set_object_checked(self, checked: bool, obj=None): + if obj is None: + return + + _id = obj.label if checked else 0 + self._lab[obj.slice][obj.image] = _id + + self._lab_node.set_data(self._lab) + self._update_display(exclude_lab=True) + + def _on_reset_lab_gradient(self, *args, **kwargs): + if self._lab_gradient_item_state is not None: + self._lab_gradient_item.item.restoreState( + self._lab_gradient_item_state) + return + + if self._lab_gradient_cmap_name is not None: + self._lab_gradient_item.item.loadPreset( + self._lab_gradient_cmap_name) + return + + self._lab_gradient_item.item.loadPreset(_DEFAULT_LABELS_CMAP_NAME) + + def _on_lab_gradient_changed(self, lab_gradient_item, update: bool=True): + cmap = colors.pg_to_vispy_cmap( + lab_gradient_item.colorMap(), transparent_zero=True, + n=self._lab_ncolors + ) + self._lab_node.cmap = cmap + if update: + self._canvas.update() + + def _on_lab_opacity_changed( + self, + value, + update: bool=True + ): + lab_gradient_item = self._lab_gradient_item + + if lab_gradient_item is None: + return + + node = self._lab_node + node.opacity = value + if update: + self._canvas.update() + + def _init_ui(self): + if self._ui_initialised: + return + + self._toolbar = _widgets.VolumeRendererToolbar(parent=self) + self.addToolBar(Qt.TopToolBarArea, self._toolbar) + + self.addToolBarBreak(Qt.TopToolBarArea) + self._points_toolbar = _widgets.PointsLayersToolbar(parent=self) + self.addToolBar(Qt.TopToolBarArea, self._points_toolbar) + self._points_toolbar.hide() + + self._toolbar.sigUpdate.connect(self.sigUpdate.emit) + self._toolbar.sigHomeView.connect(self.reset_view) + self._toolbar.sigSave.connect(self.save_screenshot) + self._toolbar.sigSetSingleChannel.connect(self._set_single_channel) + + lut_items_graphics_layout = pg.GraphicsLayoutWidget() + lut_items_graphics_layout.setBackground('black') + self._lut_items_layout = lut_items_graphics_layout.addLayout( + row=0, col=0 + ) + self._lut_items_graphics_layout = lut_items_graphics_layout + + self._lab_gradient_item_layout = QVBoxLayout() + self._lab_gradient_item_layout.setContentsMargins(5, 5, 5, 5) + + self._object_labels_list_groupbox = QGroupBox('Object labels') + self._object_labels_list_scrollarea = widgets.ScrollArea() + self._object_labels_list_scrollarea.setWidget( + self._object_labels_list_groupbox + ) + self._object_labels_list_layout = QVBoxLayout() + self._object_labels_list_groupbox.setLayout( + self._object_labels_list_layout + ) + self._set_object_labels_checkboxes_disabled() + + self._right_vertical_layout = QVBoxLayout() + self._right_vertical_layout.addWidget( + self._object_labels_list_scrollarea + ) + self._right_vertical_layout.addSpacing(10) + + self._scene_layout = QHBoxLayout() + self._scene_layout.addWidget(lut_items_graphics_layout, stretch=0) + self._scene_layout.addWidget(self._canvas.native, stretch=10) + self._scene_layout.addLayout( + self._lab_gradient_item_layout, stretch=0 + ) + self._scene_layout.addLayout( + self._right_vertical_layout, stretch=1 + ) + + self._controls_groupbox = QGroupBox('Controls') + self._controls_layout = widgets.FormLayout() + self._controls_groupbox.setLayout(self._controls_layout) + + 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(self._controls_groupbox) + main_layout.setStretch(0, 10) + main_layout.setStretch(1, 0) + self.setCentralWidget(central) + + self._ui_initialised = True + + def _set_object_labels_checkboxes_disabled(self, disabled=True): + if disabled: + tooltip = ( + 'Object selection is disabled when display mode ' + 'is "Show all objects"\n\n' + 'Choose a different display mode below to activate selection.' + ) + else: + tooltip = None + + self._object_labels_list_groupbox.setToolTip(tooltip) + if self._object_labels_list_buttongroup is None: + return + + for checkbox in self._object_labels_list_buttongroup.buttons(): + checkbox.blockSignals(True) + if disabled: + checkbox.setChecked(True) + checkbox.setDisabled(disabled) + checkbox.blockSignals(False) + + + def _block_exec(self): + if hasattr(self, 'loop'): + self.loop.exit() + + self.loop = QEventLoop() + self.loop.exec_() + + 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 _downsample(self, 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. + """ + if self._downsample_strides is None: + strides = tuple( + max(1, int(np.ceil(s / max_size))) for s in vol.shape + ) + self._downsample_strides = strides + else: + strides = self._downsample_strides + + 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}') + + self._SizeZ = len(volume) + + # copy=False avoids a redundant allocation when data is already float32 + vol = volume.astype(np.float32, copy=False) + + # 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: + vol = self._downsample(vol, max_tex) + self._last_max_tex = max_tex + + # Normalise the (possibly downsampled) array using the full-resolution range. + if vmax > vmin: + vol = (vol - vmin) / (vmax - vmin) + else: + vol = np.zeros_like(vol) + + return vol + + def _preprocess_lab(self, lab: np.ndarray): + if lab.ndim != 3: + raise ValueError( + f'Expected 3-D (Z, Y, X) labels array; got shape {lab.shape}') + + max_tex = self._resolve_max_texture_3d() + lab = self._downsample(lab, max_tex) + + return lab + + def _init_default_rgbs(self): + self._default_rgbs = colors.overlay_rgbs.copy() + self._default_rgbs.insert(0, (255, 255, 255)) + + def _set_channel_default_cmap(self, channel_name: str, channel_idx: int): + foregr_rgb = colors.FLUO_CHANNELS_COLORS.get(channel_name) + if foregr_rgb is None: + foregr_rgb = self._default_rgbs.pop(channel_idx) + + pg_gradient = colors.get_pg_gradient([(0,0,0,0), foregr_rgb]) + lut_item = self._channels_data[channel_name].lut_item + lut_item.setGradient(pg_gradient) + pg_cmap = lut_item.gradient.colorMap() + cmap = colors.pg_to_vispy_cmap(pg_cmap) + return cmap + + def _add_opacity_slider(self, channel_name: str, row: int): + opacity_slider = widgets.sliderWithSpinBox( + title_loc='in_line', + isFloat=True, + parent=self, + normalize_factor=_SLIDER_NORMALIZE_FACTOR + ) + opacity_slider.setRange(0.0, 1.0) + opacity_slider.setSingleStep(0.05) + opacity_slider.setValue(1.0) + opacity_slider.setDecimals(2) + opacity_slider.setToolTip(f'Opacity of channel {channel_name}') + opacity_slider_form_widget = widgets.formWidget( + opacity_slider, + labelTextLeft=f'{channel_name} opacity:', + ) + self._controls_layout.addFormWidget( + opacity_slider_form_widget, row=row + ) + + return opacity_slider + + def _set_channels_data( + self, + volumes: list[np.ndarray], + channel_names: list[str], + ): + from vispy.scene import visuals + + for channel, volume in zip(channel_names, volumes): + c_idx = len(self._channels_data) + + 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_idx) + + 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_idx) + + node = visuals.Volume( + volume, + method="mip", + parent=self._view.scene, + ) + + lut_item = widgets.baseHistogramLUTitem() + + opacity_slider = self._add_opacity_slider(channel, row=c_idx+1) + + toolbutton = widgets.OverlayChannelToolButton( + channel, lut_item, shortcut=str(c_idx) + ) + toolbutton.action = self._toolbar.addWidget(toolbutton) + toolbutton.setChecked(True) + + _raw_volume = volume.copy() + _off_focus_volume = skimage.filters.gaussian(_raw_volume, sigma=1.2) + _off_focus_volume *= ( + np.percentile(_raw_volume, 99.9) + / np.percentile(_off_focus_volume, 99.9) + ) + _off_focus_volume *= 0.8 + + channel_data = _ChannelData( + node=node, + lut_item=lut_item, + volume=volume, + _raw_volume=_raw_volume, + _off_focus_volume=_off_focus_volume, + auto_button=auto_btn, + reset_button=reset_btn, + opacity_slider=opacity_slider, + toolbutton=toolbutton + ) + + auto_btn.clicked.connect( + partial(self._on_auto_clim, channel_data=channel_data) + ) + reset_btn.clicked.connect( + partial(self._on_reset_clim, channel_data=channel_data) + ) + + self._lut_items_layout.addItem(lut_item, row=2, col=c_idx) + + lut_item.sigLookupTableChanged.connect( + partial(self._on_lut_changed, channel_data=channel_data) + ) + opacity_slider.valueChanged.connect( + partial(self._on_opacity_changed, channel_data=channel_data) + ) + toolbutton.clicked.connect( + partial( + self._on_channel_toolbutton_clicked, + channel_data=channel_data + ) + ) + self._channels_data[channel] = channel_data + + lut_item_width = lut_item.sizeHint(Qt.PreferredSize).width() + self._lut_items_width += lut_item_width + + auto_btn.setMaximumWidth(ceil(lut_item_width)) + reset_btn.setMaximumWidth(ceil(lut_item_width)) + + self._lut_items_graphics_layout.setFixedWidth(int(self._lut_items_width)) + + def number_of_visible_channels(self) -> int: + return sum( + 1 for ch_data in self._channels_data.values() + if ch_data.node.visible + ) + + def _on_channel_toolbutton_clicked( + self, + checked, + channel_data: _ChannelData, + update: bool=True + ): + node = channel_data.node + node.visible = checked + if update: + self._canvas.update() + + if not self._toolbar.is_single_channel_mode(): + return + + if checked: + for other_ch, other_ch_data in self._channels_data.items(): + if other_ch_data is channel_data: + continue + + if other_ch_data.node.visible: + other_ch_data.toolbutton.setChecked(False) + + self._set_visiblity() + + def _set_visiblity(self, update=False): + for channel_data in self._channels_data.values(): + channel_data.node.visible = channel_data.toolbutton.isChecked() + + self._set_gl_blend_states() + + if update: + self._canvas.update() + + def _on_opacity_changed( + self, + value, + channel_data: _ChannelData=None, + channel_name: str=None, + update: bool=True + ) -> None: + if channel_data is None: + channel_data = self._channels_data[channel_name] + + node = channel_data.node + node.opacity = value + if update: + self._canvas.update() + + def _on_lut_changed( + self, + lut_item, + channel_data: _ChannelData=None, + channel_name: str=None, + update: bool=True + ) -> None: + if channel_data is None: + channel_data = self._channels_data[channel_name] + + cmap = colors.pg_to_vispy_cmap(lut_item.gradient.colorMap()) + node = channel_data.node + node.cmap = cmap + if update: + self._canvas.update() + + def _on_auto_clim(self, channel_data: _ChannelData) -> None: + lut_item = channel_data.lut_item + + if len(lut_item.gradient.listTicks()) != 2: + self._logger_func( + '[WARNING]: Auto contrast is available only with LUTs ' + 'that have two ticks.' + ) + return + + volume = channel_data.volume + lo, hi = colors.get_auto_contrast_percentile(volume) + (low_tick, _), (high_tick, _) = lut_item.gradient.listTicks() + lut_item.gradient.setTickValue(high_tick, hi) + lut_item.gradient.setTickValue(low_tick, lo) + + def _on_reset_clim(self, channel_data: _ChannelData) -> None: + lut_item = channel_data.lut_item + lut_item.resetState() + + def _set_gl_blend_states(self): + from .gl_blend import volume_gl_state + + for c, (channel, channel_data) in enumerate(self._channels_data.items()): + blending = "translucent_no_depth" if c == 0 else "additive" + node = channel_data.node + node.order = c + node.opacity = channel_data.opacity_slider.value() + node.set_gl_state(**volume_gl_state(blending, first_visible=c==0)) + + def _set_single_channel(self, single: bool): + if single: + for ch_data in self._channels_data.values(): + ch_data.toolbutton.setChecked(False) + + self._last_visible_channels_data = [ + ch_data for ch_data in self._channels_data.values() + if ch_data.node.visible + ] + if len(self._last_visible_channels_data) == 0: + self._last_visible_channels_data = [ + list(self._channels_data.values())[0] + ] + + first_visible_channel_data = self._last_visible_channels_data[0] + first_visible_channel_data.toolbutton.setChecked(True) + else: + for ch_data in self._last_visible_channels_data: + if not ch_data.node.visible: + ch_data.toolbutton.setChecked(True) + + self._set_visiblity(update=True) + + def _update_points_layer_properties( + self, points_state, points_layer=None, update=True + ): + if points_layer is None: + return + + symbol = points_state['symbol'] + color = [val/255 for val in points_state['color']] + size = points_state['pointSize'] + opacity = points_state['opacity'] + + face_color = vispy.color.Color(color, alpha=opacity*0.5) + edge_color = vispy.color.Color(color, alpha=opacity) + + points_xyz = points_layer.points_xyz + if self._orig_lab is not None: + visible_label_ids = [ + int(cb.text()) + for cb in self._object_labels_list_buttongroup.buttons() + if cb.isChecked() + ] + visible = np.isin(points_layer._label_ids, visible_label_ids) + points_xyz = points_xyz[visible] + + markers = points_layer.markers + markers.set_data( + points_xyz, + symbol=symbol, + size=size, + face_color=face_color, + edge_color=edge_color + ) + + if update: + self._canvas.update() + + def _toggle_points_layer(self, checked, points_layer=None): + if points_layer is None: + return + + markers = points_layer.markers + markers.visible = checked + + self._canvas.update() + + def _edit_points_layer_properties(self, points_layer=None): + if points_layer is None: + return + + properties_dialog = points_layer.properties_dialog + properties_dialog.exec_() + + def _remove_points_layer(self, points_layer=None): + if points_layer is None: + return + + name = points_layer.name + points_layer.markers.parent = None + if points_layer.text is not None: + points_layer.text = None + + points_layer.properties_dialog.force_close() + + points_layer.toolbutton.setChecked(False) + self._points_toolbar.removeAction(points_layer.toolbutton.action) + + del self._points_layers[name] + + def set_labels( + self, + lab: np.ndarray, + voxel_size: tuple[float, float, float]=None, + cmap_name: AcdcPyQtGraphColorMapName=None, + gradient_item_state: dict=None, + SizeZ: int=None + ): + if self._is_labels_set: + self._logger_func( + '[WARNING]: Labels already set. ' + 'Only one labels volumes can be set' + ) + return + + if self._data_shape is None: + self._data_shape = lab.shape + + if lab.shape != self._data_shape: + raise ValueError( + f'Labels array shape {lab.shape} does not match the current ' + f'viewer shape {self._data_shape}. ' + 'All displayed volumes must have the same shape.' + ) + + if lab.dtype == bool: + lab = lab.astype(np.uint8) + + if lab.ndim == 2 and SizeZ is None and self._SizeZ is None: + raise ValueError( + f'Labels array is 2D but SizeZ not set.' + ) + + if lab.ndim == 2: + if SizeZ is None: + SizeZ = self._SizeZ + + lab = np.array([lab]*SizeZ) + + if lab.ndim != 3: + raise ValueError( + f'Expected 3-D (Z, Y, X) labels array; got shape {lab.shape}') + + self._orig_lab = lab.copy() + self._lab = self._preprocess_lab(lab) + self._rp = skimage.measure.regionprops(self._lab) + + self._init_lab_ui_items( + self._lab, + cmap_name=cmap_name, + gradient_item_state=gradient_item_state + ) + + if self._voxel_size_strides_transform is not None: + self._lab_node.transform = self._voxel_size_strides_transform + else: + self._set_voxel_size_strides_transform(voxel_size) + + self._is_labels_set = True + + set_segmentation_masks = set_labels + + def set_volume( + self, + volume: np.ndarray, + channel_name: str='', + voxel_size: tuple[float, float, float]=None, + lut_item_state: LutItemState | None=None, + cmap: list[colors.RgbaColor] | AcdcPyQtGraphColorMapName=None, + ): + num_channels = len(self._channels_data) + if not channel_name: + channel_name = f'channel_{num_channels+1}' + + cmaps = None + lut_items_states = None + if lut_item_state is not None: + lut_items_states = {channel_name: lut_item_state} + elif cmap is not None: + cmaps = {channel_name: cmap} + + volumes = {channel_name: volume} + + self.set_volumes( + volumes, + cmaps=cmaps, + lut_items_states=lut_items_states, + voxel_size=voxel_size + ) + + def _set_voxel_size_strides_transform( + self, + voxel_size: tuple[float, float, float] | None + ): + if voxel_size is None: + voxel_size = self._voxel_size + + if voxel_size is None: + voxel_size = (1.0, 1.0, 1.0) + + if self._downsample_strides: + self._downsample_strides = (1.0, 1.0, 1.0) + + self._voxel_size = voxel_size + + from vispy.visuals.transforms import STTransform + + sx, sy, sz = utils.voxel_display_scale(*voxel_size) + scale = ( + sx * self._downsample_strides[2], + sy * self._downsample_strides[1], + sz * self._downsample_strides[0], + ) + transform = STTransform(scale=scale) + self._voxel_size_strides_transform = transform + self._voxel_size_transform = STTransform(scale=(sx, sy, sz)) + + for channel_data in self._channels_data.values(): + channel_data.node.transform = transform + + if self._lab_node is not None: + self._lab_node.transform = transform + + for points_layer in self._points_layers.values(): + points_layer.markers.transform = self._voxel_size_transform + + if self._canvas is not None: + self._canvas.update() + + def set_volumes( + self, + volumes: list[np.ndarray] | dict[str, np.ndarray], + channel_names: list[str] | None=None, + voxel_size: tuple[float, float, float]=None, + lut_items_states: LutItemStates | None=None, + cmaps: ColorMapPerChannel=None, + ): + if isinstance(volumes, dict): + channel_names = list(volumes.keys()) + volumes = list(volumes.values()) + + if self._data_shape is None: + self._data_shape = volumes[0].shape + + for volume in volumes: + if volume.shape != self._data_shape: + raise ValueError( + f'Volume shape {volume.shape} does not match the current ' + f'viewer shape {self._data_shape}. ' + 'All displayed volumes must have the same shape.' + ) + + volumes = [self._preprocess_volume(vol) for vol in volumes] + + num_existing_channels = len(self._channels_data) + tot_num_channels = num_existing_channels + len(volumes) + + if channel_names is None: + channel_names = [ + f'channel_{c+1}' + for c in range(num_existing_channels, tot_num_channels) + ] + + self._set_channels_data( + volumes, + channel_names, + ) + + if lut_items_states is not None: + for c, (ch, channel_data) in enumerate(self._channels_data.items()): + if isinstance(lut_items_states, list): + state = lut_items_states[c] + else: + state = lut_items_states.get(ch) + channel_data.lut_item.restoreState(state) + elif cmaps is not None: + for c, (ch, channel_data) in enumerate(self._channels_data.items()): + if isinstance(cmaps, list): + cmap = cmaps[c] + else: + cmap = cmaps.get(ch) + + channel_data.lut_item.setColormap(cmap) + for tick in channel_data.lut_item.gradient.listTicks(): + tick[0].hide() + else: + cmaps = { + ch: self._set_channel_default_cmap(ch, c) + for c, ch in enumerate(channel_names) + } + + self._set_gl_blend_states() + + if self._voxel_size_strides_transform is not None: + for channel_data in self._channels_data.values(): + channel_data.node.transform = self._voxel_size_strides_transform + else: + self._set_voxel_size_strides_transform(voxel_size) + + self._canvas.update() + + def add_points_layer( + self, + name: str, + points: np.ndarray | None=None, # (N, [z, y, x]) voxel coordinates + points_df: pd.DataFrame | None=None, + zyx_columns_names: list[str] | None=None, + labels: list[str] | None=None, + color: vispy.color.Color='red', + size: float=8.0, + opacity: float=1.0, + symbol: MarkerSymbols='disc', + visible: bool=True, + scaling: Literal['fixed', 'scene']='scene' + ): + if name in self._points_layers.keys(): + raise NameError( + f'Points layer with name "{name}" already existing. ' + 'Choose a different name' + ) + + from vispy.scene import visuals + + if zyx_columns_names is None: + zyx_columns_names = ['z', 'y', 'x'] + + if points_df is not None: + points_xyz = points_df[zyx_columns_names[::-1]].to_numpy() + elif points is not None: + points = np.asarray(points) + points_xyz = points[:, [2, 1, 0]] + else: + raise ValueError( + "Either 'points' or 'points_df' must be provided." + ) + + face_color = vispy.color.Color(color, alpha=opacity*0.5) + edge_color = vispy.color.Color(color, alpha=opacity) + markers = visuals.Markers( + parent=self._view.scene, + spherical=True, + scaling=scaling + ) + + markers.set_data( + points_xyz, + symbol=symbol, + size=size, + face_color=face_color, + edge_color=edge_color + ) + + markers.set_gl_state(depth_test=False) + if self._voxel_size_transform is not None: + markers.transform = self._voxel_size_transform + + _label_ids = None + if self._orig_lab is not None: + _label_ids = self._orig_lab[ + points_xyz[:, 2], + points_xyz[:, 1], + points_xyz[:, 0], + ] + + ui_items = self._init_points_layer_ui_items( + markers, + name, + points_xyz, + labels=labels, + color=edge_color, + size=size, + opacity=opacity, + symbol=symbol, + points_df=points_df + ) + + toolbutton, properties_dialog, context_menu = ui_items + + points_layer = _PointsLayer( + name=name, + points_xyz=points_xyz, + _label_ids=_label_ids, + labels=labels, + markers=markers, + toolbutton=toolbutton, + properties_dialog=properties_dialog, + context_menu=context_menu, + df=points_df + ) + + properties_dialog.sigValueChanged.connect( + partial( + self._update_points_layer_properties, points_layer=points_layer + ) + ) + + context_menu.sigEditPropertes.connect( + partial( + self._edit_points_layer_properties, points_layer=points_layer + ) + ) + + context_menu.sigRemove.connect( + partial( + self._remove_points_layer, points_layer=points_layer + ) + ) + + toolbutton.toggled.connect( + partial( + self._toggle_points_layer, points_layer=points_layer + ) + ) + if not visible: + toolbutton.setChecked(False) + + self._points_layers[name] = points_layer + + def show(self, block=False): + self.resize(960, 720) + self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + super().show() + + self.set_camera_view() + + try: + self.setEnabled(True) + except Exception as err: + pass + + if block: + self._block_exec() + + def run(self, block=True): + self.show() + if block: + self._block_exec() + + def force_close(self): + """Force the window to close (bypassing hide_on_close).""" + self._hide_on_close = False + self.close() + + def closeEvent(self, event): + self.sigClose.emit(self) + + if self._hide_on_close: + event.ignore() + self.hide() + return + + for points_layer in self._points_layers.values(): + points_layer.properties_dialog.force_close() + + if hasattr(self, 'loop'): + self.loop.exit() + + return super().closeEvent(event) + + def reset_view(self): + camera = self._view.camera + + camera.center = self._home_center + camera.distance = self._home_distance + camera.azimuth = self._home_azimuth + camera.elevation = self._home_elevation + camera.scale_factor = self._default_scale_factor + + def set_camera_view(self): + """Reset the camera to the default orientation and fit the volume.""" + if not self._channels_data: + Z, Y, X = self._lab.shape + dummy_vol = np.zeros((Z, Y, X), dtype=np.float32) + self.set_volume(dummy_vol, channel_name='1') + + first_channel = list(self._channels_data.keys())[0] + first_volume = self._channels_data[first_channel].volume + first_node = self._channels_data[first_channel].node + Z, Y, X = first_volume.shape + + xyz_center = (X/2, Y/2, Z/2) + corners = np.array([ + [0, 0, 0], + [X, Y, Z], + ]) + world = first_node.transform.map(corners)[:, :3] + diag = np.linalg.norm(world[1] - world[0]) + + self._home_center = first_node.transform.map(xyz_center)[:3] + self._home_elevation = 30.0 + self._home_azimuth = 45.0 + self._home_fov = 60.0 + self._home_distance = diag + + self._view.camera.set_range() + self._view.camera.center = first_node.transform.map(xyz_center)[:3] + self._view.camera.elevation = 30.0 + self._view.camera.azimuth = 45.0 + self._view.camera.fov = 60.0 + self._view.camera.distance = diag + + self._default_scale_factor = self._view.camera.scale_factor + + self._canvas.update() + + def save_screenshot(self): + ... \ No newline at end of file diff --git a/cellacdc/volume_renderer/gl_blend.py b/cellacdc/volume_renderer/gl_blend.py new file mode 100644 index 00000000..bebd0c2d --- /dev/null +++ b/cellacdc/volume_renderer/gl_blend.py @@ -0,0 +1,66 @@ +"""OpenGL blend state for vispy volume layers (napari-compatible). + +Napari composites multiple MIP fluor channels by giving the first visible +channel ``translucent_no_depth`` and additional channels ``additive``, with a +special case for the bottommost visible layer so additive does not blend +against stale framebuffer alpha (see napari ``_vispy/layers/base.py``). +""" + +from __future__ import annotations + +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", + } diff --git a/cellacdc/volume_renderer/utils.py b/cellacdc/volume_renderer/utils.py new file mode 100644 index 00000000..cdf6f54a --- /dev/null +++ b/cellacdc/volume_renderer/utils.py @@ -0,0 +1,6 @@ +def voxel_display_scale(dz: float, dy: float, dx: float) -> tuple[float, float, float]: + """Return vispy ``STTransform`` scale for ``(Z, Y, X)`` data (Cell-ACDC style).""" + dx_eff = dx if dx > 0 else 1.0 + dy_eff = dy if dy > 0 else 1.0 + dz_eff = dz if dz > 0 else 1.0 + return (1.0, dy_eff / dx_eff, dz_eff / dx_eff) \ No newline at end of file diff --git a/cellacdc/widgets.py b/cellacdc/widgets.py index 12da450e..b28a85c1 100755 --- a/cellacdc/widgets.py +++ b/cellacdc/widgets.py @@ -1,5 +1,5 @@ from collections import defaultdict, deque -from typing import Dict, List, Union, Iterable, Sequence +from typing import Dict, List, Union, Iterable, Sequence, get_args import os import sys import operator @@ -73,6 +73,7 @@ from . import _core, core from . import QtScoped from . import prompts +from . import plot from .acdc_regex import float_regex from .config import PREPROCESS_MAPPER from . import _base_widgets @@ -2233,6 +2234,12 @@ def __init__(self, parent=None): ] self.addItems(symbols) +class VisPyMarkersSymbolsCombobox(QComboBox): + def __init__(self, parent=None): + super().__init__(parent) + + symbols = get_args(plot.VisPyMarkerSymbols) + self.addItems(symbols) class alphaNumericLineEdit(QLineEdit): sigInvalidCharacterPressed = Signal(str) @@ -3393,6 +3400,7 @@ def __init__(self, symbol, color='r', parent=None): super().__init__(parent=parent) if not isinstance(color, QColor): color = pg.mkColor(color) + self.symbol = symbol self.setColor(color) @@ -3420,15 +3428,15 @@ def updateIcon(self, symbol, color): def paintEvent(self, event): QToolButton.paintEvent(self, event) - p = QPainter(self) - w, h = self.width(), self.height() - sf = 0.6 - p.scale(w*sf, h*sf) - p.translate(0.5/sf, 0.5/sf) - symbol = pg.graphicsItems.ScatterPlotItem.Symbols[self.symbol] - pen = pg.mkPen(color=self.penColor, width=2) - brush = pg.mkBrush(color=self.brushColor) try: + p = QPainter(self) + w, h = self.width(), self.height() + sf = 0.6 + p.scale(w*sf, h*sf) + p.translate(0.5/sf, 0.5/sf) + symbol = plot.PyQtGraphScatterPlotSymbolPathMatter[self.symbol] + pen = pg.mkPen(color=self.penColor, width=2) + brush = pg.mkBrush(color=self.brushColor) p.setRenderHint(QPainter.RenderHint.Antialiasing) p.setPen(pen) p.setBrush(brush) @@ -3484,8 +3492,9 @@ class PointsLayerToolButton(ToolButtonCustomColor): def __init__(self, symbol, color='r', parent=None): super().__init__(symbol, color=color, parent=parent) self.sigRightClick.connect(self.showContextMenu) + self.initContextMenu() - def showContextMenu(self, event): + def initContextMenu(self): contextMenu = QMenu(self) contextMenu.addSeparator() @@ -3502,8 +3511,11 @@ def showContextMenu(self, event): showIdsAction.setChecked(True) contextMenu.addAction(showIdsAction) showIdsAction.toggled.connect(self.emitShowIdsToggled) - - contextMenu.exec(event.globalPos()) + + self._contextMenu = contextMenu + + def showContextMenu(self, event): + self._contextMenu.exec(event.globalPos()) def emitRemove(self): self.sigRemove.emit(self) @@ -5268,12 +5280,14 @@ def restoreState(self, state): class baseHistogramLUTitem(pg.HistogramLUTItem): sigAddColormap = Signal(object, str) sigRescaleIntes = Signal(object) + sigGradientChanged = Signal(object) def __init__(self, name='image', axisLabel='', parent=None, **kwargs): pg.GradientEditorItem = BaseGradientEditorItemLabels super().__init__(**kwargs) + self._defaultState = super().saveState() self.labelStyle = {'color': '#ffffff', 'font-size': '11px'} if axisLabel: @@ -5383,6 +5397,10 @@ def rescaleActionTriggered(self, action): def onShowCustomCmapsMenu(self): self.customCmapsMenu.show() + + def gradientChanged(self): + super().gradientChanged() + self.sigGradientChanged.emit(self) def customCmapsMenuTriggered(self, action): cmap = action.cmap @@ -5415,6 +5433,9 @@ def sortTicks(self, ticks): sortedTicks = sorted(ticks, key=operator.itemgetter(0)) return sortedTicks + def resetState(self): + self.restoreState(self._defaultState) + def getInvertedGradients(self): invertedGradients = {} for name, gradient in Gradients.items(): @@ -5585,6 +5606,33 @@ def setRescaleIntensitiesHow(self, how): if action.text() == how: action.setChecked(True) return + + def setColormap( + self, + cmap: colors.AcdcColorMap + ) -> None: + if isinstance(cmap, str): + self.gradient.loadPreset(cmap) + return + + lut = [] + for color in cmap: + qcolor = pg.mkColor(color) + rgba_float = qcolor.getRgbF() + rgba_uint = [int(c*255) for c in rgba_float] + lut.append(rgba_uint) + + ncolors = len(lut) + positions = np.linspace(0, 1, len(lut)) + state = { + "mode": "rgb", + "ticks": [ + (float(pos), tuple(color)) + for pos, color in zip(positions, lut) + ] + } + + self.gradient.restoreState(state) class ROI(pg.ROI): def __init__( @@ -5780,7 +5828,6 @@ def onToggled(self, checked): class myHistogramLUTitem(baseHistogramLUTitem): sigGradientMenuEvent = Signal(object) - sigGradientChanged = Signal(object) sigTickColorAccepted = Signal(object) sigAddScaleBar = Signal(bool) sigAddTimestamp = Signal(bool) @@ -5942,10 +5989,6 @@ def emitAddScaleBar(self): def emitAddTimestamp(self): self.sigAddTimestamp.emit(self.addTimestampAction.isChecked()) - def gradientChanged(self): - super().gradientChanged() - self.sigGradientChanged.emit(self) - def gradientMenuEventFilter(self, object, event): if event.type() == QEvent.Type.MouseMove: hoveredAction = self.gradient.menu.actionAt(event.pos()) @@ -6042,6 +6085,9 @@ def restoreState(self, df): self.restoreColormap(df) + def super_saveState(self): + return super().saveState() + def saveState(self, df): # remove previous state df = df[~df.index.str.contains('img_cmap')].copy() @@ -6345,11 +6391,10 @@ def updateImageLut(self, gradientItem): def updateImageOpacity(self, value): self.imageItem.setOpacity(value) -class labelsGradientWidget(pg.GradientWidget): - sigShowRightImgToggled = Signal(bool) - sigShowLabelsImgToggled = Signal(bool) - sigShowNextFrameToggled = Signal(bool) - +class BaseLabelsGradientWidget(pg.GradientWidget): + sigShuffleCmap = Signal() + sigGreeedyShuffleCmap = Signal() + def __init__( self, *args, parent=None, orientation='right', **kargs): pg.GradientEditorItem = BaseGradientEditorItemLabels @@ -6380,9 +6425,22 @@ def __init__( self, *args, parent=None, orientation='right', **kargs): self.saveColormapAction.triggered.connect( self.saveColormap ) + + self.menu.addSeparator() + + # Shuffle colors action + self.shuffleCmapAction = QAction( + 'Randomly shuffle colormap (Shift+S)', self + ) + self.menu.addAction(self.shuffleCmapAction) - self.addCustomGradients() + self.greedyShuffleCmapAction = QAction( + 'Greedily shuffle colormap (Alt+Shift+S)', self + ) + self.menu.addAction(self.greedyShuffleCmapAction) + self.addCustomGradients() + # Background color button hbox = QHBoxLayout() hbox.addWidget(QLabel('Background color: ')) @@ -6395,6 +6453,91 @@ def __init__( self, *args, parent=None, orientation='right', **kargs): act.setDefaultWidget(widget) act.triggered.connect(self.colorButton.click) self.menu.addAction(act) + + self.shuffleCmapAction.triggered.connect(self.sigShuffleCmap.emit) + self.greedyShuffleCmapAction.triggered.connect( + self.sigGreeedyShuffleCmap.emit + ) + + def onShowCustomCmapsMenu(self): + self.customCmapsMenu.show() + + def customCmapsMenuTriggered(self, action): + cmap = action.cmap + self.item.colorMapMenuClicked(cmap) + self.item.showTicks(True) + + def saveColormap(self): + cmapName = self._askNameColormap() + if cmapName is None: + return + + cp = config.ConfigParser() + if os.path.exists(custom_cmaps_filepath): + cp.read(custom_cmaps_filepath) + + SECTION = f'{self.name}.{cmapName}' + cp[SECTION] = {} + + state = self.item.saveState() + for key, value in state.items(): + if key != 'ticks': + continue + for t, tick in enumerate(value): + pos, rgb = tick + rgb = ','.join([str(c) for c in rgb]) + val = f'{pos},{rgb}' + cp[SECTION][f'tick_{t}_pos_rgb'] = val + + with open(custom_cmaps_filepath, mode='w') as file: + cp.write(file) + + self.addCustomGradient(cmapName, state, restore=False) + + def addCustomGradients(self): + try: + CustomGradients = getCustomGradients(name='labels') + if not CustomGradients: + return + for gradient_name, gradient_ticks in CustomGradients.items(): + self.addCustomGradient(gradient_name, gradient_ticks) + except Exception as e: + printl(traceback.format_exc()) + pass + + def _askNameColormap(self): + inputWin = apps.QInput(parent=self._parent, title='Colormap name') + inputWin.askText('Insert a name for the colormap: ', allowEmpty=False) + if inputWin.cancel: + return + cmapName = inputWin.answer + return cmapName + + def addCustomGradient(self, gradient_name, gradient_ticks, restore=True): + currentState = self.item.saveState() + self.originalLength = self.item.length + self.item.length = 100 + if restore: + self.item.restoreState(gradient_ticks) + gradient = self.item.getGradient() + action = CustomGradientMenuAction(gradient, gradient_name, self.item) + # action.triggered.connect(self.item.contextMenuClicked) + action.delButton.clicked.connect(self.removeCustomGradient) + action.cmap = colors.pg_ticks_to_colormap(gradient_ticks['ticks']) + # self.item.menu.insertAction(self.saveColormapAction, action) + self.customCmapsMenu.addAction(action) + self.item.length = self.originalLength + self.item.restoreState(currentState) + GradientsLabels[gradient_name] = gradient_ticks + +class labelsGradientWidget(BaseLabelsGradientWidget): + sigShowRightImgToggled = Signal(bool) + sigShowLabelsImgToggled = Signal(bool) + sigShowNextFrameToggled = Signal(bool) + + def __init__( self, *args, parent=None, orientation='right', **kargs): + super().__init__( + *args, parent=parent, orientation=orientation, **kargs) # Font size menu action self.fontSizeMenu = QMenu('Text font size', self) @@ -6413,17 +6556,6 @@ def __init__( self, *args, parent=None, orientation='right', **kargs): act.triggered.connect(self.textColorButton.click) self.menu.addAction(act) self.menu.addSeparator() - - # Shuffle colors action - self.shuffleCmapAction = QAction( - 'Randomly shuffle colormap (Shift+S)', self - ) - self.menu.addAction(self.shuffleCmapAction) - - self.greedyShuffleCmapAction = QAction( - 'Greedily shuffle colormap (Alt+Shift+S)', self - ) - self.menu.addAction(self.greedyShuffleCmapAction) self.permanentGreedyCmapAction = QAction( 'Always use greedy colormap', self @@ -6452,7 +6584,8 @@ def __init__( self, *args, parent=None, orientation='right', **kargs): self.menu.addAction(self.showNextFrameAction) # Default settings - self.defaultSettingsAction = QAction('Restore default settings...', self) + self.defaultSettingsAction = QAction( + 'Restore default settings...', self) self.menu.addAction(self.defaultSettingsAction) self.menu.addSeparator() @@ -6461,31 +6594,6 @@ def __init__( self, *args, parent=None, orientation='right', **kargs): self.showLabelsImgAction.toggled.connect(self.showLabelsImageToggled) self.showNextFrameAction.toggled.connect(self.showNextFrameToggled) - def onShowCustomCmapsMenu(self): - self.customCmapsMenu.show() - - def customCmapsMenuTriggered(self, action): - cmap = action.cmap - self.item.colorMapMenuClicked(cmap) - self.item.showTicks(True) - - def addCustomGradient(self, gradient_name, gradient_ticks, restore=True): - currentState = self.item.saveState() - self.originalLength = self.item.length - self.item.length = 100 - if restore: - self.item.restoreState(gradient_ticks) - gradient = self.item.getGradient() - action = CustomGradientMenuAction(gradient, gradient_name, self.item) - # action.triggered.connect(self.item.contextMenuClicked) - action.delButton.clicked.connect(self.removeCustomGradient) - action.cmap = colors.pg_ticks_to_colormap(gradient_ticks['ticks']) - # self.item.menu.insertAction(self.saveColormapAction, action) - self.customCmapsMenu.addAction(action) - self.item.length = self.originalLength - self.item.restoreState(currentState) - GradientsLabels[gradient_name] = gradient_ticks - def removeCustomGradient(self): button = self.sender() action = button.action @@ -6496,52 +6604,6 @@ def removeCustomGradient(self): with open(custom_cmaps_filepath, mode='w') as file: cp.write(file) - def addCustomGradients(self): - try: - CustomGradients = getCustomGradients(name='labels') - if not CustomGradients: - return - for gradient_name, gradient_ticks in CustomGradients.items(): - self.addCustomGradient(gradient_name, gradient_ticks) - except Exception as e: - printl(traceback.format_exc()) - pass - - def _askNameColormap(self): - inputWin = apps.QInput(parent=self._parent, title='Colormap name') - inputWin.askText('Insert a name for the colormap: ', allowEmpty=False) - if inputWin.cancel: - return - cmapName = inputWin.answer - return cmapName - - def saveColormap(self): - cmapName = self._askNameColormap() - if cmapName is None: - return - - cp = config.ConfigParser() - if os.path.exists(custom_cmaps_filepath): - cp.read(custom_cmaps_filepath) - - SECTION = f'{self.name}.{cmapName}' - cp[SECTION] = {} - - state = self.item.saveState() - for key, value in state.items(): - if key != 'ticks': - continue - for t, tick in enumerate(value): - pos, rgb = tick - rgb = ','.join([str(c) for c in rgb]) - val = f'{pos},{rgb}' - cp[SECTION][f'tick_{t}_pos_rgb'] = val - - with open(custom_cmaps_filepath, mode='w') as file: - cp.write(file) - - self.addCustomGradient(cmapName, state, restore=False) - def isRightImageVisible(self): return ( self.showLabelsImgAction.isChecked() @@ -6949,6 +7011,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: @@ -6997,7 +7061,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}') @@ -7016,8 +7079,8 @@ 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) self.spinBox.valueChanged.connect(self.spinboxValueChanged) @@ -7032,18 +7095,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) @@ -7058,13 +7133,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()) @@ -7072,8 +7150,8 @@ def spinboxValueChanged(self, val): if self._normalize: val = int(val*self.slider.maximum()) elif self._isFloat: - val = int(val) - + val = int(val*self._normalize_factor) + self.slider.valueChanged.disconnect() self.slider.setValue(val) self.slider.valueChanged.connect(self.sliderValueChanged) @@ -12094,4 +12172,44 @@ 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 PointsLayerContextMenu(QMenu): + sigEditPropertes = Signal() + sigRemove = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + + editPropertiesAction = QAction('Edit properties...', self) + self.addAction(editPropertiesAction) + + self.addSeparator() + + removeAction = QAction('Remove points layer', self) + self.addAction(removeAction) + + editPropertiesAction.triggered.connect(self.emitSigEditProperties) + removeAction.triggered.connect(self.emitSigRemoveAction) + + def emitSigEditProperties(self): + self.sigEditPropertes.emit() + + def emitSigRemoveAction(self): + self.sigRemove.emit() + +class DummyWidget: + def __init__(self, *args, **kwargs): + pass + + def setValue(self, *args, **kwargs): + pass + + def setText(self, *args, **kwargs): + pass + + def text(self): + return + + def value(self): + return \ No newline at end of file