From 0a5483c179261136ae791cda10ec0bab3a2195f2 Mon Sep 17 00:00:00 2001 From: Francesco Date: Thu, 28 May 2026 20:25:24 +0200 Subject: [PATCH 01/34] WIP: code for sub-module and main window UI --- cellacdc/volume_renderer/__init__.py | 0 cellacdc/volume_renderer/_widgets.py | 34 +++++++++++ cellacdc/volume_renderer/canvas.py | 90 ++++++++++++++++++++++++++++ cellacdc/volume_renderer/gl_blend.py | 66 ++++++++++++++++++++ 4 files changed, 190 insertions(+) create mode 100644 cellacdc/volume_renderer/__init__.py create mode 100644 cellacdc/volume_renderer/_widgets.py create mode 100644 cellacdc/volume_renderer/canvas.py create mode 100644 cellacdc/volume_renderer/gl_blend.py diff --git a/cellacdc/volume_renderer/__init__.py b/cellacdc/volume_renderer/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/cellacdc/volume_renderer/_widgets.py b/cellacdc/volume_renderer/_widgets.py new file mode 100644 index 000000000..e9dc8cb08 --- /dev/null +++ b/cellacdc/volume_renderer/_widgets.py @@ -0,0 +1,34 @@ +from qtpy.QtCore import ( + Signal +) + +from cellacdc.widgets import ToolBar + +class VolumeRendererToolbar(ToolBar): + sigHomeView = Signal() + sigSave = Signal() + + def __init__(self, name='Volume Renderer Toolbar', parent=None): + + super().__init__(name, parent) + + self.parentWin = parent + + self.setContextMenuPolicy(Qt.PreventContextMenu) + + self.homeViewAction = QAction(QIcon(':home.svg'), 'Home view', self) + self.homeViewAction.setShortcut('H') + self.homeViewAction.setToolTip( + 'Reset the view to the default orientation and zoom level' + ) + self.addAction(self.homeViewAction) + + self.saveAction = QAction(QIcon(':file-save.svg'), 'Save', self) + self.saveAction.setShortcut('Ctrl+S') + self.saveAction.setToolTip( + 'Save the current view to PNG file' + ) + self.addAction(self.saveAction) + + self.homeViewAction.triggered.connect(self.sigHomeView.emit) + self.saveAction.triggered.connect(self.sigSave.emit) \ 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 000000000..54295ef75 --- /dev/null +++ b/cellacdc/volume_renderer/canvas.py @@ -0,0 +1,90 @@ +from dataclasses import dataclass + +from qtpy import QtCore +from qtpy.QtWidgets import ( + QMainWindow +) + +from .._run import _setup_app + +@dataclass +class _ImageChannel: + node: object + lut: VolumeImageLutBar + +class VolumeRendererWindow(QMainWindow): + def __init__( + self, + app=None, + parent=None, + version=None, + title='Cell-ACDC - Volume Renderer' + ): + """Initializer.""" + + super().__init__(parent) + self.setWindowTitle(title) + + self._version = version + self._ui_initialised = False + + if app is None: + app = QtCore.QCoreApplication.instance() + + self.app = app + + self._init_ui() + + def _init_ui(self): + if self._ui_initialised: + return + + self.scene_layout = QHBoxLayout() + + self.topToolBar = widgets.VolumeRendererToolbar(parent=self) + self.addToolBar(Qt.TopToolBarArea, self.topToolBar) + + self.topToolBar.sigHomeView.connect(self.reset_view) + self.topToolBar.sigSave.connect(self.save_screenshot) + + central = QWidget() + main_layout = QVBoxLayout(central) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + main_layout.addLayout(self.scene_layout) + self.setCentralWidget(central) + + self._ui_initialised = True + + def show(self, block=False): + self.resize(960, 720) + self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + super().show() + + try: + self.setEnabled(True) + except Exception as err: + pass + + if block: + self.loop = QEventLoop() + self.loop.exec_() + + def run(self, block=False): + if self.app is None: + app, splashScreen = _setup_app(splashscreen=True) + splashScreen.close() + + self.show(block=block) + + def closeEvent(self, event): + if hasattr(self, 'loop'): + self.loop.exit() + + return super().closeEvent() + + def reset_view(self): + ... + + 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 000000000..bebd0c2d3 --- /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", + } From 3bd23ba9d6ae6395e49b5d1af95842890fb36f10 Mon Sep 17 00:00:00 2001 From: Francesco Date: Sat, 30 May 2026 12:43:56 +0200 Subject: [PATCH 02/34] WIP: base UI layout, working on lut items --- cellacdc/__init__.py | 4 +- cellacdc/colors.py | 65 ++++++- cellacdc/gui.py | 12 +- cellacdc/volume_renderer/_widgets.py | 8 +- cellacdc/volume_renderer/canvas.py | 266 +++++++++++++++++++++++++-- 5 files changed, 323 insertions(+), 32 deletions(-) diff --git a/cellacdc/__init__.py b/cellacdc/__init__.py index b4987d810..6be43a558 100755 --- a/cellacdc/__init__.py +++ b/cellacdc/__init__.py @@ -818,4 +818,6 @@ def inner_function(self, *args, **kwargs): '.tif', '.npz', '_symlink.ini' -) \ No newline at end of file +) + +from cellacdc.volume_renderer.canvas import VolumeRendererWindow \ No newline at end of file diff --git a/cellacdc/colors.py b/cellacdc/colors.py index 0c71ef0e7..c167dbb4b 100644 --- a/cellacdc/colors.py +++ b/cellacdc/colors.py @@ -5,6 +5,7 @@ import skimage.measure from collections.abc import Callable, Sequence +from typing import Literal import numpy as np from . import GUI_INSTALLED, printl @@ -39,6 +40,41 @@ 'mCitrine': (255, 255, 0) } +# 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' +] + +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)] + ) + _mapCache = {} def getFromMatplotlib(name): """ @@ -366,4 +402,31 @@ def grayscale_apply_lut(image, lut): def get_complementary_color(rgba_str: str) -> str: r, g, b, a = rgba_str_to_values(rgba_str) - return f'rgba({255 - r}, {255 - g}, {255 - b}, {a})' \ No newline at end of file + return f'rgba({255 - r}, {255 - g}, {255 - b}, {a})' + +def pg_to_vispy_cmap(pg_cmap, n=256): + """Convert PyQtGraph colormap to vispy + + Parameters + ---------- + pg_cmap : pyqtgraph.colormap.ColorMap + PyQtGraph Colormap. For example, it can be obtained with + `pyqtgraph.HistogramLUTItem.gradient.colorMap()` + n : int, optional + Number of colors, by default 256 + + Returns + ------- + vispy.color.Colormap + VisPy colormap + """ + + 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 + + return VisPyColormap(colors) \ No newline at end of file diff --git a/cellacdc/gui.py b/cellacdc/gui.py index ac4fdf82d..fba4254ac 100755 --- a/cellacdc/gui.py +++ b/cellacdc/gui.py @@ -23367,17 +23367,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_default_plt_cmap.copy() def getFileExtensions(self, images_path): alignedFound = any([f.find('_aligned.np')!=-1 diff --git a/cellacdc/volume_renderer/_widgets.py b/cellacdc/volume_renderer/_widgets.py index e9dc8cb08..62891ff17 100644 --- a/cellacdc/volume_renderer/_widgets.py +++ b/cellacdc/volume_renderer/_widgets.py @@ -1,5 +1,11 @@ from qtpy.QtCore import ( - Signal + Signal, Qt +) +from qtpy.QtWidgets import ( + QAction +) +from qtpy.QtGui import ( + QIcon, ) from cellacdc.widgets import ToolBar diff --git a/cellacdc/volume_renderer/canvas.py b/cellacdc/volume_renderer/canvas.py index 54295ef75..0386ecd97 100644 --- a/cellacdc/volume_renderer/canvas.py +++ b/cellacdc/volume_renderer/canvas.py @@ -1,24 +1,61 @@ from dataclasses import dataclass -from qtpy import QtCore +import numpy as np + +from qtpy.QtCore import ( + Signal, Qt, QCoreApplication, QEventLoop +) from qtpy.QtWidgets import ( - QMainWindow + QMainWindow, QHBoxLayout, QVBoxLayout, QWidget ) +import pyqtgraph as pg + +from .. import printl from .._run import _setup_app +from .. import widgets +from .. import colors + +from . import _widgets + +RgbaColor = colors.RgbaColor +AcdcPyQtGraphColorMapName = colors.AcdcPyQtGraphColorMapName +ColorMapPerChannel = ( + list[list[RgbaColor]] + | dict[str, list[RgbaColor]] + | list[AcdcPyQtGraphColorMapName] + | dict[str, AcdcPyQtGraphColorMapName] +) @dataclass -class _ImageChannel: +class _ChannelData: node: object - lut: VolumeImageLutBar + lut: widgets.baseHistogramLUTitem + volume: np.ndarray + pg_cmap_name: str 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. + """ + def __init__( self, app=None, parent=None, version=None, - title='Cell-ACDC - Volume Renderer' + title='Cell-ACDC - Volume Renderer', + hide_on_close=True ): """Initializer.""" @@ -27,35 +64,217 @@ def __init__( self._version = version self._ui_initialised = False + self._hide_on_close = hide_on_close + self._max_texture_3d = None if app is None: - app = QtCore.QCoreApplication.instance() + app = QCoreApplication.instance() self.app = app + self._channels_data: dict[_ChannelData] = {} + self._default_rgbs = 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_ui(self): if self._ui_initialised: return - self.scene_layout = QHBoxLayout() + from vispy.scene import visuals + + self._lab_node = visuals.Volume( + np.zeros((2, 2, 2), dtype=np.float32), + method="translucent", + interpolation="nearest", + parent=self._view.scene, + ) + self._lab_node.visible = False + + self._toolbar = _widgets.VolumeRendererToolbar(parent=self) + self.addToolBar(Qt.TopToolBarArea, self._toolbar) + + self._toolbar.sigHomeView.connect(self.reset_view) + self._toolbar.sigSave.connect(self.save_screenshot) - self.topToolBar = widgets.VolumeRendererToolbar(parent=self) - self.addToolBar(Qt.TopToolBarArea, self.topToolBar) + 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.topToolBar.sigHomeView.connect(self.reset_view) - self.topToolBar.sigSave.connect(self.save_screenshot) + self._scene_layout = QHBoxLayout() + self._scene_layout.addWidget(lut_items_graphics_layout, stretch=0) + self._scene_layout.addWidget(self._canvas.native, stretch=1) 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.addLayout(self._scene_layout) self.setCentralWidget(central) self._ui_initialised = True + 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 + + @staticmethod + def _downsample(vol: np.ndarray, max_size: int) -> np.ndarray: + """ + Return a stride-subsampled view of *vol* so no dimension exceeds *max_size*. + + Uses integer strides (fast, no interpolation) — suitable for interactive + previews. Returns the original array unchanged if no downsampling is needed. + """ + strides = tuple(max(1, int(np.ceil(s / max_size))) for s in vol.shape) + if all(s == 1 for s in strides): + return vol + return np.ascontiguousarray(vol[::strides[0], ::strides[1], ::strides[2]]) + + def _preprocess_volume(self, volume: np.ndarray): + if volume.ndim != 3: + raise ValueError( + f'Expected 3-D (Z, Y, X) array; got shape {volume.shape}') + + # copy=False avoids a redundant allocation when data is already float32 + 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: + strides = tuple(max(1, int(np.ceil(s / max_tex))) for s in vol.shape) + self._last_strides = strides + vol = self._downsample(vol, max_tex) + else: + self._last_strides = (1, 1, 1) + + # Normalise the (possibly downsampled) array using the full-resolution range. + if vmax > vmin: + vol = (vol - vmin) / (vmax - vmin) + else: + vol = np.zeros_like(vol) + + return vol + + def _init_default_rgbs(self): + self._default_rgbs = colors.overlay_rgbs.copy() + self._default_rgbs.insert(0, (255, 255, 255)) + + def _get_channel_default_cmap(self, channel_name: str): + foregr_rgb = colors.FLUO_CHANNELS_COLORS.get(channel_name) + if foregr_rgb is None: + foregr_rgb = self._default_rgbs.pop(0) + + pg_gradient = colors.get_pg_gradient([(0,0,0,0), foregr_rgb]) + lut_item.setGradient(gradient) + cmap = colors.pg_to_vispy_cmap(lut_item) + return cmap + + def set_volume( + self, + volume: np.ndarray, + channel_name: str='', + cmap: list[RgbaColor] | AcdcPyQtGraphColorMapName=None, + ): + num_channels = len(self._channels_data) + if not channel_name: + channel_name = f'channel_{num_channels+1}' + + cmaps = None + if cmap is not None: + cmaps = {channel_name: cmap} + + volumes = {channel_name: volume} + + self.set_volumes(volumes, cmaps) + + def set_volumes( + self, + volumes: list[np.ndarray] | dict[str, np.ndarray], + channel_names: list[str] | None=None, + cmaps: ColorMapPerChannel=None, + ): + if isinstance(volumes, dict): + channel_names = list(volumes.keys()) + volumes = list(volumes.values()) + + 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._add_lut_items() + + if cmaps is None: + cmaps = { + ch: self._get_channel_default_cmap(ch) for ch in channel_names + } + + printl(cmaps) + printl(channel_names) + def show(self, block=False): self.resize(960, 720) self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) @@ -67,24 +286,35 @@ def show(self, block=False): pass if block: - self.loop = QEventLoop() - self.loop.exec_() + self._block_exec() - def run(self, block=False): + def run(self, block=True): if self.app is None: app, splashScreen = _setup_app(splashscreen=True) splashScreen.close() - self.show(block=block) + self.show() + self.reset_view() + if block: + self._block_exec() def closeEvent(self, event): + if self._hide_on_close: + event.ignore() + self.hide() + return + if hasattr(self, 'loop'): self.loop.exit() - return super().closeEvent() + return super().closeEvent(event) def reset_view(self): - ... + """Reset the camera to the default orientation and fit the volume.""" + self._view.camera.set_range() + self._view.camera.elevation = 30.0 + self._view.camera.azimuth = -60.0 + self._canvas.update() def save_screenshot(self): ... \ No newline at end of file From 0fe4e6774bb80b1ffd27bc6c7062f983bb44f1f0 Mon Sep 17 00:00:00 2001 From: Francesco Date: Sun, 31 May 2026 16:56:40 +0200 Subject: [PATCH 03/34] WIP: set channels data --- cellacdc/colors.py | 24 +++++++- cellacdc/volume_renderer/canvas.py | 93 ++++++++++++++++++++++++++---- 2 files changed, 106 insertions(+), 11 deletions(-) diff --git a/cellacdc/colors.py b/cellacdc/colors.py index c167dbb4b..3f0153ca6 100644 --- a/cellacdc/colors.py +++ b/cellacdc/colors.py @@ -429,4 +429,26 @@ def pg_to_vispy_cmap(pg_cmap, n=256): # Normalize to 0–1 (VisPy expects floats) colors = np.array(colors) / 255.0 - return VisPyColormap(colors) \ No newline at end of file + return VisPyColormap(colors) + +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 \ No newline at end of file diff --git a/cellacdc/volume_renderer/canvas.py b/cellacdc/volume_renderer/canvas.py index 0386ecd97..fc8be00d7 100644 --- a/cellacdc/volume_renderer/canvas.py +++ b/cellacdc/volume_renderer/canvas.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from functools import partial import numpy as np @@ -6,7 +7,8 @@ Signal, Qt, QCoreApplication, QEventLoop ) from qtpy.QtWidgets import ( - QMainWindow, QHBoxLayout, QVBoxLayout, QWidget + QMainWindow, QHBoxLayout, QVBoxLayout, QWidget, QPushButton, + QGraphicsProxyWidget ) import pyqtgraph as pg @@ -30,9 +32,10 @@ @dataclass class _ChannelData: node: object - lut: widgets.baseHistogramLUTitem + lut_item: widgets.baseHistogramLUTitem volume: np.ndarray - pg_cmap_name: str + auto_button: QPushButton + reset_button: QPushButton class VolumeRendererWindow(QMainWindow): """ @@ -222,10 +225,82 @@ def _get_channel_default_cmap(self, channel_name: str): foregr_rgb = self._default_rgbs.pop(0) pg_gradient = colors.get_pg_gradient([(0,0,0,0), foregr_rgb]) - lut_item.setGradient(gradient) + lut_item = self._channels_data[channel_name].lut_item + lut_item.setGradient(pg_gradient) cmap = colors.pg_to_vispy_cmap(lut_item) return cmap + + def _set_channels_data( + self, + volumes: list[np.ndarray], + channel_names: list[str], + cmaps: ColorMapPerChannel=None, + ): + from vispy.scene import visuals + for channel, volume in zip(channel_names, volumes): + col = 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=col) + + 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=col) + + node = visuals.Volume( + volume, + method="mip", + parent=self._view.scene, + ) + + lut_item = widgets.baseHistogramLUTitem() + + channel_data = _ChannelData( + node=node, + lut_item=lut_item, + volume=volume, + auto_button=auto_btn, + reset_button=reset_btn + ) + + 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=col) + + if cmaps is None: + cmaps = { + ch: self._get_channel_default_cmap(ch) for ch in channel_names + } + + def _on_auto_clim(self, channel_data: _ChannelData) -> None: + lut_item = channel_data.lut_item + + if len(lut_item.gradient.listTicks()) != 2: + self.logger.info( + '[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, lut_item: widgets.baseHistogramLUTitem) -> None: + lut_item = channel_data.lut_item + lut_item.resetState() + def set_volume( self, volume: np.ndarray, @@ -265,12 +340,10 @@ def set_volumes( for c in range(num_existing_channels, tot_num_channels) ] - self._add_lut_items() - - if cmaps is None: - cmaps = { - ch: self._get_channel_default_cmap(ch) for ch in channel_names - } + self._set_channels_data( + volumes, channel_names, + cmaps=cmaps + ) printl(cmaps) printl(channel_names) From 57e6e400725c718ab53dbe0d686afbb882cb4f97 Mon Sep 17 00:00:00 2001 From: Francesco Date: Fri, 5 Jun 2026 11:58:50 +0200 Subject: [PATCH 04/34] WIP: store channels dataclass --- cellacdc/gui.py | 2 +- cellacdc/volume_renderer/canvas.py | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/cellacdc/gui.py b/cellacdc/gui.py index fba4254ac..54dec1005 100755 --- a/cellacdc/gui.py +++ b/cellacdc/gui.py @@ -23367,7 +23367,7 @@ def load_fluo_data(self, fluo_path, isGuiThread=True): return fluo_data, bkgrData def setOverlayColors(self): - self.overlayRGBs = colors.overlay_default_plt_cmap.copy() + self.overlayRGBs = colors.overlay_rgbs.copy() def getFileExtensions(self, images_path): alignedFound = any([f.find('_aligned.np')!=-1 diff --git a/cellacdc/volume_renderer/canvas.py b/cellacdc/volume_renderer/canvas.py index fc8be00d7..8ade3c0a3 100644 --- a/cellacdc/volume_renderer/canvas.py +++ b/cellacdc/volume_renderer/canvas.py @@ -275,11 +275,15 @@ def _set_channels_data( ) self._lut_items_layout.addItem(lut_item, row=2, col=col) + + self._channels_data[channel] = channel_data if cmaps is None: cmaps = { ch: self._get_channel_default_cmap(ch) for ch in channel_names } + + printl(cmaps) def _on_auto_clim(self, channel_data: _ChannelData) -> None: lut_item = channel_data.lut_item @@ -297,7 +301,7 @@ def _on_auto_clim(self, channel_data: _ChannelData) -> None: lut_item.gradient.setTickValue(high_tick, hi) lut_item.gradient.setTickValue(low_tick, lo) - def _on_reset_clim(self, lut_item: widgets.baseHistogramLUTitem) -> None: + def _on_reset_clim(self, channel_data: _ChannelData) -> None: lut_item = channel_data.lut_item lut_item.resetState() @@ -345,9 +349,6 @@ def set_volumes( cmaps=cmaps ) - printl(cmaps) - printl(channel_names) - def show(self, block=False): self.resize(960, 720) self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) From b9e98b3a3786f775a9dc682ddfe953574104dca1 Mon Sep 17 00:00:00 2001 From: Francesco Date: Sun, 7 Jun 2026 21:10:49 +0200 Subject: [PATCH 05/34] WIP: correct multi-channel blending options --- cellacdc/colors.py | 27 +++++---- cellacdc/volume_renderer/canvas.py | 94 +++++++++++++++++++++++++++--- cellacdc/widgets.py | 40 +++++++++---- 3 files changed, 132 insertions(+), 29 deletions(-) diff --git a/cellacdc/colors.py b/cellacdc/colors.py index 3f0153ca6..90091000a 100644 --- a/cellacdc/colors.py +++ b/cellacdc/colors.py @@ -26,18 +26,23 @@ __all__ = ['ColorMap'] +CYAN_RGB = (0, 255, 255) +MAGENTA_RGB = (255, 0, 255) +YELLOW_RGB = (255, 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, + '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 } # Tuple of 3 or 4 uint8 values diff --git a/cellacdc/volume_renderer/canvas.py b/cellacdc/volume_renderer/canvas.py index 8ade3c0a3..b342c7b22 100644 --- a/cellacdc/volume_renderer/canvas.py +++ b/cellacdc/volume_renderer/canvas.py @@ -2,6 +2,7 @@ from functools import partial import numpy as np +from math import ceil from qtpy.QtCore import ( Signal, Qt, QCoreApplication, QEventLoop @@ -36,6 +37,7 @@ class _ChannelData: volume: np.ndarray auto_button: QPushButton reset_button: QPushButton + opacity_slider: widgets.sliderWithSpinBox class VolumeRendererWindow(QMainWindow): """ @@ -69,6 +71,7 @@ def __init__( self._ui_initialised = False self._hide_on_close = hide_on_close self._max_texture_3d = None + self._lut_items_width = 20 # Start with some padding if app is None: app = QCoreApplication.instance() @@ -76,8 +79,8 @@ def __init__( self.app = app self._channels_data: dict[_ChannelData] = {} - self._default_rgbs = self._init_default_rgbs() + self._init_default_rgbs() self._init_vispy() self._init_ui() @@ -140,11 +143,14 @@ def _init_ui(self): self._scene_layout.addWidget(lut_items_graphics_layout, stretch=0) self._scene_layout.addWidget(self._canvas.native, stretch=1) + self._controls_layout = widgets.FormLayout() + 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.addLayout(self._controls_layout) self.setCentralWidget(central) self._ui_initialised = True @@ -219,17 +225,39 @@ def _init_default_rgbs(self): self._default_rgbs = colors.overlay_rgbs.copy() self._default_rgbs.insert(0, (255, 255, 255)) - def _get_channel_default_cmap(self, channel_name: str): + def _get_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(0) + 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) - cmap = colors.pg_to_vispy_cmap(lut_item) + 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=10 + ) + 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.valueChanged.connect(self._on_opacity_changed) + opacity_slider_form_widget = widgets.formWidget( + opacity_slider, + labelTextLeft=f'{channel_name} opacity:', + ) + layout.addFormWidget(opacity_slider_form_widget, row=row) + + return opacity_slider + def _set_channels_data( self, volumes: list[np.ndarray], @@ -259,12 +287,20 @@ def _set_channels_data( lut_item = widgets.baseHistogramLUTitem() + opacity_slider = widgets.sliderWithSpinBox( + title_loc='in_line', + isFloat=True, + parent=self, + normalize_factor=10 + ) + channel_data = _ChannelData( node=node, lut_item=lut_item, volume=volume, auto_button=auto_btn, - reset_button=reset_btn + reset_button=reset_btn, + opacity_slider=opacity_slider ) auto_btn.clicked.connect( @@ -276,14 +312,41 @@ def _set_channels_data( self._lut_items_layout.addItem(lut_item, row=2, col=col) + lut_item.sigLookupTableChanged.connect( + partial(self._on_lut_changed, 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)) if cmaps is None: cmaps = { - ch: self._get_channel_default_cmap(ch) for ch in channel_names + ch: self._get_channel_default_cmap(ch, c) + for c, ch in enumerate(channel_names) } - printl(cmaps) + 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 @@ -304,7 +367,18 @@ def _on_auto_clim(self, channel_data: _ChannelData) -> None: 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 + + opacity = 0.5 if len(self._channels_data) > 1 else 1.0 + 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 = opacity + node.set_gl_state(**volume_gl_state(blending, first_visible=c==0)) + def set_volume( self, volume: np.ndarray, @@ -349,6 +423,10 @@ def set_volumes( cmaps=cmaps ) + self._set_gl_blend_states() + + self._canvas.update() + def show(self, block=False): self.resize(960, 720) self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) diff --git a/cellacdc/widgets.py b/cellacdc/widgets.py index 7a4fa944b..da9983873 100755 --- a/cellacdc/widgets.py +++ b/cellacdc/widgets.py @@ -5276,6 +5276,7 @@ def __init__(self, name='image', axisLabel='', parent=None, **kwargs): super().__init__(**kwargs) + self._defaultState = super().saveState() self.labelStyle = {'color': '#ffffff', 'font-size': '11px'} if axisLabel: @@ -5417,6 +5418,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(): @@ -6951,6 +6955,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: @@ -6999,7 +7005,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}') @@ -7018,7 +7023,7 @@ def setValue(self, value, emitSignal=False): if self._normalize: valueInt = int(value*self.slider.maximum()) elif self._isFloat: - valueInt = int(value) + valueInt = int(value*self._normalize_factor) self.spinBox.valueChanged.disconnect() self.spinBox.setValue(value) @@ -7034,18 +7039,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) @@ -7060,13 +7077,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()) From bd63a2646aac784a7808070773753014556c64e5 Mon Sep 17 00:00:00 2001 From: Francesco Date: Sat, 13 Jun 2026 21:49:53 +0200 Subject: [PATCH 06/34] WIP: add opacity sliders --- cellacdc/volume_renderer/canvas.py | 41 +++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/cellacdc/volume_renderer/canvas.py b/cellacdc/volume_renderer/canvas.py index b342c7b22..7cb569036 100644 --- a/cellacdc/volume_renderer/canvas.py +++ b/cellacdc/volume_renderer/canvas.py @@ -249,12 +249,13 @@ def _add_opacity_slider(self, channel_name: str, row: int): opacity_slider.setValue(1.0) opacity_slider.setDecimals(2) opacity_slider.setToolTip(f'Opacity of channel {channel_name}') - opacity_slider.valueChanged.connect(self._on_opacity_changed) opacity_slider_form_widget = widgets.formWidget( opacity_slider, labelTextLeft=f'{channel_name} opacity:', ) - layout.addFormWidget(opacity_slider_form_widget, row=row) + self._controls_layout.addFormWidget( + opacity_slider_form_widget, row=row + ) return opacity_slider @@ -266,6 +267,7 @@ def _set_channels_data( ): from vispy.scene import visuals + c = 0 for channel, volume in zip(channel_names, volumes): col = len(self._channels_data) @@ -287,12 +289,7 @@ def _set_channels_data( lut_item = widgets.baseHistogramLUTitem() - opacity_slider = widgets.sliderWithSpinBox( - title_loc='in_line', - isFloat=True, - parent=self, - normalize_factor=10 - ) + opacity_slider = self._add_opacity_slider(channel, row=c) channel_data = _ChannelData( node=node, @@ -315,6 +312,9 @@ def _set_channels_data( 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) + ) self._channels_data[channel] = channel_data @@ -323,6 +323,8 @@ def _set_channels_data( auto_btn.setMaximumWidth(ceil(lut_item_width)) reset_btn.setMaximumWidth(ceil(lut_item_width)) + + c += 1 self._lut_items_graphics_layout.setFixedWidth(int(self._lut_items_width)) @@ -331,7 +333,22 @@ def _set_channels_data( ch: self._get_channel_default_cmap(ch, c) for c, ch in enumerate(channel_names) } + + 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, @@ -370,13 +387,12 @@ def _on_reset_clim(self, channel_data: _ChannelData) -> None: def _set_gl_blend_states(self): from .gl_blend import volume_gl_state - - opacity = 0.5 if len(self._channels_data) > 1 else 1.0 + 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 = opacity + node.opacity = channel_data.node.opacity_slider.value() node.set_gl_state(**volume_gl_state(blending, first_visible=c==0)) def set_volume( @@ -419,7 +435,8 @@ def set_volumes( ] self._set_channels_data( - volumes, channel_names, + volumes, + channel_names, cmaps=cmaps ) From 95a4f8beebea408128c1f26c170a434098a57cf4 Mon Sep 17 00:00:00 2001 From: Francesco Date: Sun, 14 Jun 2026 10:37:28 +0200 Subject: [PATCH 07/34] WIP: add toolbutton, still missing the slot --- cellacdc/volume_renderer/canvas.py | 33 ++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/cellacdc/volume_renderer/canvas.py b/cellacdc/volume_renderer/canvas.py index 7cb569036..6dbe93303 100644 --- a/cellacdc/volume_renderer/canvas.py +++ b/cellacdc/volume_renderer/canvas.py @@ -38,6 +38,7 @@ class _ChannelData: auto_button: QPushButton reset_button: QPushButton opacity_slider: widgets.sliderWithSpinBox + toolbutton: widgets.OverlayChannelToolButton class VolumeRendererWindow(QMainWindow): """ @@ -267,19 +268,18 @@ def _set_channels_data( ): from vispy.scene import visuals - c = 0 for channel, volume in zip(channel_names, volumes): - col = len(self._channels_data) + 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=col) - + 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=col) + self._lut_items_layout.addItem(reset_btn_proxy, row=1, col=c_idx) node = visuals.Volume( volume, @@ -288,16 +288,22 @@ def _set_channels_data( ) lut_item = widgets.baseHistogramLUTitem() + + opacity_slider = self._add_opacity_slider(channel, row=c_idx) - opacity_slider = self._add_opacity_slider(channel, row=c) - + toolbutton = widgets.OverlayChannelToolButton( + channel, lut_item, shortcut=str(c_idx) + ) + toolbutton.action = self.overlayToolbar.addWidget(toolbutton) + channel_data = _ChannelData( node=node, lut_item=lut_item, volume=volume, auto_button=auto_btn, reset_button=reset_btn, - opacity_slider=opacity_slider + opacity_slider=opacity_slider, + toolbutton=toolbutton ) auto_btn.clicked.connect( @@ -307,7 +313,7 @@ def _set_channels_data( partial(self._on_reset_clim, channel_data=channel_data) ) - self._lut_items_layout.addItem(lut_item, row=2, col=col) + 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) @@ -315,9 +321,14 @@ def _set_channels_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 From 4b566f6c20326d21afc229e3754835dfaf1ca41a Mon Sep 17 00:00:00 2001 From: Francesco Date: Sun, 14 Jun 2026 20:33:15 +0200 Subject: [PATCH 08/34] WIP: add channels toolbar button and single channel mode --- cellacdc/volume_renderer/_widgets.py | 21 +++++++- cellacdc/volume_renderer/canvas.py | 72 +++++++++++++++++++++++++--- cellacdc/widgets.py | 10 ++-- 3 files changed, 91 insertions(+), 12 deletions(-) diff --git a/cellacdc/volume_renderer/_widgets.py b/cellacdc/volume_renderer/_widgets.py index 62891ff17..7dbbbd857 100644 --- a/cellacdc/volume_renderer/_widgets.py +++ b/cellacdc/volume_renderer/_widgets.py @@ -13,6 +13,7 @@ class VolumeRendererToolbar(ToolBar): sigHomeView = Signal() sigSave = Signal() + sigSetSingleChannel = Signal(bool) def __init__(self, name='Volume Renderer Toolbar', parent=None): @@ -36,5 +37,23 @@ def __init__(self, name='Volume Renderer Toolbar', parent=None): ) self.addAction(self.saveAction) + 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.homeViewAction.triggered.connect(self.sigHomeView.emit) - self.saveAction.triggered.connect(self.sigSave.emit) \ No newline at end of file + self.saveAction.triggered.connect(self.sigSave.emit) + + self.singleChannelCheckbox.toggled.connect( + self.sigSetSingleChannel.emit + ) + + def is_single_channel_mode(self) -> bool: + return self.singleChannelCheckbox.isChecked() \ No newline at end of file diff --git a/cellacdc/volume_renderer/canvas.py b/cellacdc/volume_renderer/canvas.py index 6dbe93303..2341c2f1b 100644 --- a/cellacdc/volume_renderer/canvas.py +++ b/cellacdc/volume_renderer/canvas.py @@ -32,7 +32,7 @@ @dataclass class _ChannelData: - node: object + node: object # vispy.scene.visuals.Volume lut_item: widgets.baseHistogramLUTitem volume: np.ndarray auto_button: QPushButton @@ -132,6 +132,7 @@ def _init_ui(self): 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') @@ -294,7 +295,8 @@ def _set_channels_data( toolbutton = widgets.OverlayChannelToolButton( channel, lut_item, shortcut=str(c_idx) ) - toolbutton.action = self.overlayToolbar.addWidget(toolbutton) + toolbutton.action = self._toolbar.addWidget(toolbutton) + toolbutton.setChecked(True) channel_data = _ChannelData( node=node, @@ -334,8 +336,6 @@ def _set_channels_data( auto_btn.setMaximumWidth(ceil(lut_item_width)) reset_btn.setMaximumWidth(ceil(lut_item_width)) - - c += 1 self._lut_items_graphics_layout.setFixedWidth(int(self._lut_items_width)) @@ -345,6 +345,43 @@ def _set_channels_data( for c, ch in enumerate(channel_names) } + 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() + + if update: + self._canvas.update() + def _on_opacity_changed( self, value, @@ -403,9 +440,32 @@ def _set_gl_blend_states(self): blending = "translucent_no_depth" if c == 0 else "additive" node = channel_data.node node.order = c - node.opacity = channel_data.node.opacity_slider.value() + 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 set_volume( self, volume: np.ndarray, diff --git a/cellacdc/widgets.py b/cellacdc/widgets.py index da9983873..22770623a 100755 --- a/cellacdc/widgets.py +++ b/cellacdc/widgets.py @@ -5270,6 +5270,7 @@ 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 @@ -5386,6 +5387,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 @@ -5786,7 +5791,6 @@ def onToggled(self, checked): class myHistogramLUTitem(baseHistogramLUTitem): sigGradientMenuEvent = Signal(object) - sigGradientChanged = Signal(object) sigTickColorAccepted = Signal(object) sigAddScaleBar = Signal(bool) sigAddTimestamp = Signal(bool) @@ -5948,10 +5952,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()) From 7163cc3e9d0789ea2eed25224df841a91866d8e0 Mon Sep 17 00:00:00 2001 From: Francesco Date: Mon, 15 Jun 2026 10:05:46 +0200 Subject: [PATCH 09/34] fix: import volume renderer only if GUI libraries installed --- cellacdc/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cellacdc/__init__.py b/cellacdc/__init__.py index 6be43a558..92cc07dca 100755 --- a/cellacdc/__init__.py +++ b/cellacdc/__init__.py @@ -820,4 +820,5 @@ def inner_function(self, *args, **kwargs): '_symlink.ini' ) -from cellacdc.volume_renderer.canvas import VolumeRendererWindow \ No newline at end of file +if GUI_INSTALLED: + from cellacdc.volume_renderer.canvas import VolumeRendererWindow \ No newline at end of file From 5b167b14f0630d796ef95b9aa24513352984a490 Mon Sep 17 00:00:00 2001 From: Francesco Date: Tue, 30 Jun 2026 10:20:08 +0200 Subject: [PATCH 10/34] WIP: add labels ui items --- .../volume_renderer/_demo_single_channel.py | 19 ++++ cellacdc/volume_renderer/canvas.py | 92 ++++++++++++++++--- 2 files changed, 96 insertions(+), 15 deletions(-) create mode 100644 cellacdc/volume_renderer/_demo_single_channel.py diff --git a/cellacdc/volume_renderer/_demo_single_channel.py b/cellacdc/volume_renderer/_demo_single_channel.py new file mode 100644 index 000000000..46ce23909 --- /dev/null +++ b/cellacdc/volume_renderer/_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/canvas.py b/cellacdc/volume_renderer/canvas.py index 2341c2f1b..ce596d026 100644 --- a/cellacdc/volume_renderer/canvas.py +++ b/cellacdc/volume_renderer/canvas.py @@ -61,24 +61,31 @@ def __init__( parent=None, version=None, title='Cell-ACDC - Volume Renderer', - hide_on_close=True + hide_on_close=False, + logger_func=print ): """Initializer.""" - - super().__init__(parent) - self.setWindowTitle(title) - 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 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[_ChannelData] = {} self._init_default_rgbs() @@ -113,10 +120,7 @@ def _init_vispy(self) -> None: self._axis_visual = scene.visuals.XYZAxis(parent=self._view.scene) self._axis_visual.visible = False - def _init_ui(self): - if self._ui_initialised: - return - + def _init_lab_ui_items(self): from vispy.scene import visuals self._lab_node = visuals.Volume( @@ -127,6 +131,31 @@ def _init_ui(self): ) self._lab_node.visible = False + self._lab_opacity_slider = widgets.sliderWithSpinBox( + title_loc='in_line', + isFloat=True, + parent=self, + normalize_factor=10 + ) + + self._lab_opacity_slider.setRange(0.0, 1.0) + self._lab_opacity_slider.setSingleStep(0.05) + self._lab_opacity_slider.setValue(0.5) + 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=f'Segmentation masks opacity:', + ) + self._controls_layout.addFormWidget( + opacity_slider_form_widget, row=0 + ) + + def _init_ui(self): + if self._ui_initialised: + return + self._toolbar = _widgets.VolumeRendererToolbar(parent=self) self.addToolBar(Qt.TopToolBarArea, self._toolbar) @@ -195,6 +224,8 @@ def _preprocess_volume(self, volume: np.ndarray): 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) @@ -290,7 +321,7 @@ def _set_channels_data( lut_item = widgets.baseHistogramLUTitem() - opacity_slider = self._add_opacity_slider(channel, row=c_idx) + opacity_slider = self._add_opacity_slider(channel, row=c_idx+1) toolbutton = widgets.OverlayChannelToolButton( channel, lut_item, shortcut=str(c_idx) @@ -466,6 +497,41 @@ def _set_single_channel(self, single: bool): self._set_visiblity(update=True) + def set_labels( + self, + lab: np.ndarray, + cmap: list[RgbaColor] | AcdcPyQtGraphColorMapName=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 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._lab = self._preprocess_volume(lab) + + self._init_lab_ui_items() + + self._is_labels_set = True + + def set_volume( self, volume: np.ndarray, @@ -528,11 +594,7 @@ def show(self, block=False): if block: self._block_exec() - def run(self, block=True): - if self.app is None: - app, splashScreen = _setup_app(splashscreen=True) - splashScreen.close() - + def run(self, block=True): self.show() self.reset_view() if block: From 4c322b379f861820dd393289df0d7739c473f0d7 Mon Sep 17 00:00:00 2001 From: Francesco Date: Tue, 30 Jun 2026 15:19:37 +0200 Subject: [PATCH 11/34] WIP: adds labels volume renderer --- cellacdc/colors.py | 21 +++- cellacdc/volume_renderer/_demo_lab_2d.py | 28 ++++++ .../volume_renderer/_demo_multi_channel.py | 35 +++++++ .../_demo_single_channel_lab_2d.py | 36 +++++++ .../_demo_single_channel_lab_3d.py | 19 ++++ cellacdc/volume_renderer/canvas.py | 99 ++++++++++++++++--- cellacdc/widgets.py | 4 +- 7 files changed, 228 insertions(+), 14 deletions(-) create mode 100644 cellacdc/volume_renderer/_demo_lab_2d.py create mode 100644 cellacdc/volume_renderer/_demo_multi_channel.py create mode 100644 cellacdc/volume_renderer/_demo_single_channel_lab_2d.py create mode 100644 cellacdc/volume_renderer/_demo_single_channel_lab_3d.py diff --git a/cellacdc/colors.py b/cellacdc/colors.py index 90091000a..b03c17cb2 100644 --- a/cellacdc/colors.py +++ b/cellacdc/colors.py @@ -45,6 +45,15 @@ '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] @@ -409,7 +418,7 @@ 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): +def pg_to_vispy_cmap(pg_cmap, n=256, debug=False, transparent_zero=False): """Convert PyQtGraph colormap to vispy Parameters @@ -419,6 +428,8 @@ def pg_to_vispy_cmap(pg_cmap, n=256): `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 ------- @@ -433,6 +444,14 @@ def pg_to_vispy_cmap(pg_cmap, n=256): # Normalize to 0–1 (VisPy expects floats) colors = np.array(colors) / 255.0 + + if transparent_zero: + if colors.shape[-1] == 3: + rgba_colors = np.ones((len(colors), 4)) + rgba_colors[:, :3] = colors + colors = rgba_colors + + colors[0] = [0, 0, 0, 0] return VisPyColormap(colors) diff --git a/cellacdc/volume_renderer/_demo_lab_2d.py b/cellacdc/volume_renderer/_demo_lab_2d.py new file mode 100644 index 000000000..371339aeb --- /dev/null +++ b/cellacdc/volume_renderer/_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 data_path + +images_path = os.path.join( + data_path, + 'test_snapshots', + 'mtDNA_Anika', + 'Position_17', + 'Images', +) + +lab_filepath = os.path.join( + images_path, 'ASY15-1_0nM-17_s17_segm.npz' +) + +lab = np.load(lab_filepath)['arr_0'] + +renderer = VolumeRendererWindow() +renderer.set_labels(lab) +renderer.run() \ No newline at end of file diff --git a/cellacdc/volume_renderer/_demo_multi_channel.py b/cellacdc/volume_renderer/_demo_multi_channel.py new file mode 100644 index 000000000..e26c3a1e8 --- /dev/null +++ b/cellacdc/volume_renderer/_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 data_path + +images_path = os.path.join( + data_path, + 'test_snapshots', + 'mtDNA_Anika', + 'Position_17', + 'Images', +) + +mkate_filepath = os.path.join( + images_path, 'ASY15-1_0nM-17_s17_mKate.tif' +) +mneon_filepath = os.path.join( + images_path, 'ASY15-1_0nM-17_s17_mNeon.tif' +) + +mkate_volume = skimage.io.imread(mkate_filepath) +mneon_volume = skimage.io.imread(mneon_filepath) + +data = { + 'mKate': mkate_volume, + 'mNeon': mneon_volume +} + +renderer = VolumeRendererWindow() +renderer.set_volumes(data) +renderer.run() \ No newline at end of file diff --git a/cellacdc/volume_renderer/_demo_single_channel_lab_2d.py b/cellacdc/volume_renderer/_demo_single_channel_lab_2d.py new file mode 100644 index 000000000..1f7dad2a4 --- /dev/null +++ b/cellacdc/volume_renderer/_demo_single_channel_lab_2d.py @@ -0,0 +1,36 @@ +import os + +import numpy as np + +import skimage + +from cellacdc._run import _setup_app + +from cellacdc import VolumeRendererWindow +from cellacdc import data_path + +images_path = os.path.join( + data_path, + 'test_snapshots', + 'mtDNA_Anika', + 'Position_17', + 'Images', +) + +mneon_filepath = os.path.join( + images_path, 'ASY15-1_0nM-17_s17_mNeon.tif' +) + +lab_filepath = os.path.join( + images_path, 'ASY15-1_0nM-17_s17_segm.npz' +) + +lab = np.load(lab_filepath)['arr_0'] + +volume = skimage.io.imread(mneon_filepath) +data = {'mNeon': volume} + +renderer = VolumeRendererWindow() +renderer.set_volumes(data) # (Z, Y, X) numpy array +renderer.set_labels(lab) +renderer.run() \ No newline at end of file diff --git a/cellacdc/volume_renderer/_demo_single_channel_lab_3d.py b/cellacdc/volume_renderer/_demo_single_channel_lab_3d.py new file mode 100644 index 000000000..46ce23909 --- /dev/null +++ b/cellacdc/volume_renderer/_demo_single_channel_lab_3d.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/canvas.py b/cellacdc/volume_renderer/canvas.py index ce596d026..9da1725a9 100644 --- a/cellacdc/volume_renderer/canvas.py +++ b/cellacdc/volume_renderer/canvas.py @@ -23,12 +23,16 @@ RgbaColor = colors.RgbaColor AcdcPyQtGraphColorMapName = colors.AcdcPyQtGraphColorMapName +PltColorName = colors.PltColorName ColorMapPerChannel = ( list[list[RgbaColor]] | dict[str, list[RgbaColor]] | list[AcdcPyQtGraphColorMapName] | dict[str, AcdcPyQtGraphColorMapName] + | list[PltColorName] + | dict[str, PltColorName] ) +LabelsGradientName = colors.AcdcPyQtGraphColorMapName @dataclass class _ChannelData: @@ -120,16 +124,20 @@ def _init_vispy(self) -> None: self._axis_visual = scene.visuals.XYZAxis(parent=self._view.scene) self._axis_visual.visible = False - def _init_lab_ui_items(self): + def _init_lab_ui_items(self, lab_volume, cmap=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( - np.zeros((2, 2, 2), dtype=np.float32), + lab_volume, method="translucent", interpolation="nearest", parent=self._view.scene, + ) - self._lab_node.visible = False + self._lab_node.clim = (vmin, vmax) self._lab_opacity_slider = widgets.sliderWithSpinBox( title_loc='in_line', @@ -140,18 +148,74 @@ def _init_lab_ui_items(self): self._lab_opacity_slider.setRange(0.0, 1.0) self._lab_opacity_slider.setSingleStep(0.05) - self._lab_opacity_slider.setValue(0.5) + 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=f'Segmentation masks opacity:', + 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.labelsGradientWidget(parent=self) + self._lab_gradient_item_layout.addWidget(lab_gradient_item) + self._lab_gradient_item = lab_gradient_item + + self._lab_opacity_slider.valueChanged.connect( + partial( + self._on_lab_opacity_changed, + lab_gradient_item=lab_gradient_item + ) + ) + lab_gradient_item.sigGradientChangeFinished.connect( + self._on_lab_gradient_changed + ) + # lab_lut_item.sigGradientChanged.connect(self.ticksCmapMoved) + lab_reset_btn.clicked.connect( + partial( + self._on_reset_lab_gradient, + lab_gradient_item=lab_gradient_item) + ) + + if cmap is None: + lab_gradient_item.item.loadPreset('viridis') + + self._lab_node.opacity = self._lab_opacity_slider.value() + + def _on_reset_lab_gradient(self, *args, lab_gradient_item=None): + if lab_gradient_item is None: + return + + lab_gradient_item.item.loadPreset('viridis') + + 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 + ) + self._lab_node.cmap = cmap + if update: + self._canvas.update() + + def _on_lab_opacity_changed( + self, + value, + lab_gradient_item=None, + update: bool=True + ): + 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 @@ -169,10 +233,16 @@ def _init_ui(self): 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._scene_layout = QHBoxLayout() self._scene_layout.addWidget(lut_items_graphics_layout, stretch=0) self._scene_layout.addWidget(self._canvas.native, stretch=1) + self._scene_layout.addLayout( + self._lab_gradient_item_layout, stretch=0 + ) self._controls_layout = widgets.FormLayout() @@ -240,11 +310,8 @@ def _preprocess_volume(self, volume: np.ndarray): # non-uniform compression (e.g. stride_x=4 while stride_z=1). max_tex = self._resolve_max_texture_3d() if max(vol.shape) > max_tex: - strides = tuple(max(1, int(np.ceil(s / max_tex))) for s in vol.shape) - self._last_strides = strides vol = self._downsample(vol, max_tex) - else: - self._last_strides = (1, 1, 1) + self._last_max_tex = max_tex # Normalise the (possibly downsampled) array using the full-resolution range. if vmax > vmin: @@ -254,6 +321,16 @@ def _preprocess_volume(self, volume: np.ndarray): 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)) @@ -525,9 +602,9 @@ def set_labels( raise ValueError( f'Expected 3-D (Z, Y, X) labels array; got shape {lab.shape}') - self._lab = self._preprocess_volume(lab) + self._lab = self._preprocess_lab(lab) - self._init_lab_ui_items() + self._init_lab_ui_items(self._lab, cmap=cmap) self._is_labels_set = True diff --git a/cellacdc/widgets.py b/cellacdc/widgets.py index 22770623a..74bb1d57f 100755 --- a/cellacdc/widgets.py +++ b/cellacdc/widgets.py @@ -7094,8 +7094,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) From 50790cc3b664db158a5d5b1d8f23e990d9c5f714 Mon Sep 17 00:00:00 2001 From: Francesco Date: Tue, 30 Jun 2026 15:26:39 +0200 Subject: [PATCH 12/34] WIP: overloaded set_labels --- cellacdc/volume_renderer/_demo_lab_2d.py | 2 +- cellacdc/volume_renderer/canvas.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/cellacdc/volume_renderer/_demo_lab_2d.py b/cellacdc/volume_renderer/_demo_lab_2d.py index 371339aeb..8847b7675 100644 --- a/cellacdc/volume_renderer/_demo_lab_2d.py +++ b/cellacdc/volume_renderer/_demo_lab_2d.py @@ -24,5 +24,5 @@ lab = np.load(lab_filepath)['arr_0'] renderer = VolumeRendererWindow() -renderer.set_labels(lab) +renderer.set_labels(lab, SizeZ=10) renderer.run() \ No newline at end of file diff --git a/cellacdc/volume_renderer/canvas.py b/cellacdc/volume_renderer/canvas.py index 9da1725a9..5b7deb72e 100644 --- a/cellacdc/volume_renderer/canvas.py +++ b/cellacdc/volume_renderer/canvas.py @@ -574,6 +574,14 @@ def _set_single_channel(self, single: bool): self._set_visiblity(update=True) + def set_segmentation_masks( + self, + lab: np.ndarray, + cmap: list[RgbaColor] | AcdcPyQtGraphColorMapName=None, + SizeZ: int=None + ): + self.set_labels(lab, cmap=cmap, Size=SizeZ) + def set_labels( self, lab: np.ndarray, From b6513cce04903fff907caf969a07c73849fcaeab Mon Sep 17 00:00:00 2001 From: Francesco Date: Tue, 30 Jun 2026 18:16:57 +0200 Subject: [PATCH 13/34] WIP: labels node cmap on gradient change --- cellacdc/volume_renderer/canvas.py | 47 ++++++-- cellacdc/widgets.py | 182 +++++++++++++++-------------- 2 files changed, 132 insertions(+), 97 deletions(-) diff --git a/cellacdc/volume_renderer/canvas.py b/cellacdc/volume_renderer/canvas.py index 5b7deb72e..f6533fdc2 100644 --- a/cellacdc/volume_renderer/canvas.py +++ b/cellacdc/volume_renderer/canvas.py @@ -34,6 +34,9 @@ ) LabelsGradientName = colors.AcdcPyQtGraphColorMapName +_SLIDER_NORMALIZE_FACTOR = 20 +_DEFAULT_LABELS_CMAP_NAME = 'viridis' + @dataclass class _ChannelData: node: object # vispy.scene.visuals.Volume @@ -77,6 +80,8 @@ def __init__( 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 if app is None: app = QCoreApplication.instance() @@ -124,7 +129,12 @@ def _init_vispy(self) -> None: self._axis_visual = scene.visuals.XYZAxis(parent=self._view.scene) self._axis_visual.visible = False - def _init_lab_ui_items(self, lab_volume, cmap=None): + 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 @@ -143,7 +153,7 @@ def _init_lab_ui_items(self, lab_volume, cmap=None): title_loc='in_line', isFloat=True, parent=self, - normalize_factor=10 + normalize_factor=_SLIDER_NORMALIZE_FACTOR ) self._lab_opacity_slider.setRange(0.0, 1.0) @@ -163,7 +173,7 @@ def _init_lab_ui_items(self, lab_volume, cmap=None): lab_reset_btn = QPushButton('Reset') self._lab_gradient_item_layout.addWidget(lab_reset_btn) - lab_gradient_item = widgets.labelsGradientWidget(parent=self) + lab_gradient_item = widgets.BaseLabelsGradientWidget(parent=self) self._lab_gradient_item_layout.addWidget(lab_gradient_item) self._lab_gradient_item = lab_gradient_item @@ -183,8 +193,14 @@ def _init_lab_ui_items(self, lab_volume, cmap=None): lab_gradient_item=lab_gradient_item) ) - if cmap is None: - lab_gradient_item.item.loadPreset('viridis') + 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() @@ -192,7 +208,15 @@ def _on_reset_lab_gradient(self, *args, lab_gradient_item=None): if lab_gradient_item is None: return - lab_gradient_item.item.loadPreset('viridis') + if self._lab_gradient_item_state is not None: + lab_gradient_item.item.restoreState(self._lab_gradient_item_state) + return + + if self._lab_gradient_cmap_name is not None: + lab_gradient_item.item.loadPreset(self._lab_gradient_cmap_name) + return + + 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( @@ -352,7 +376,7 @@ def _add_opacity_slider(self, channel_name: str, row: int): title_loc='in_line', isFloat=True, parent=self, - normalize_factor=10 + normalize_factor=_SLIDER_NORMALIZE_FACTOR ) opacity_slider.setRange(0.0, 1.0) opacity_slider.setSingleStep(0.05) @@ -585,7 +609,8 @@ def set_segmentation_masks( def set_labels( self, lab: np.ndarray, - cmap: list[RgbaColor] | AcdcPyQtGraphColorMapName=None, + cmap_name: AcdcPyQtGraphColorMapName=None, + gradient_item_state: dict=None, SizeZ: int=None ): if self._is_labels_set: @@ -612,7 +637,11 @@ def set_labels( self._lab = self._preprocess_lab(lab) - self._init_lab_ui_items(self._lab, cmap=cmap) + self._init_lab_ui_items( + self._lab, + cmap_name=cmap_name, + gradient_item_state=gradient_item_state + ) self._is_labels_set = True diff --git a/cellacdc/widgets.py b/cellacdc/widgets.py index 74bb1d57f..dc44231a5 100755 --- a/cellacdc/widgets.py +++ b/cellacdc/widgets.py @@ -6351,11 +6351,7 @@ 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): def __init__( self, *args, parent=None, orientation='right', **kargs): pg.GradientEditorItem = BaseGradientEditorItemLabels @@ -6388,7 +6384,42 @@ def __init__( self, *args, parent=None, orientation='right', **kargs): ) self.addCustomGradients() + + 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) + # Background color button hbox = QHBoxLayout() hbox.addWidget(QLabel('Background color: ')) @@ -6401,6 +6432,62 @@ def __init__( self, *args, parent=None, orientation='right', **kargs): act.setDefaultWidget(widget) act.triggered.connect(self.colorButton.click) self.menu.addAction(act) + + # 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) + + 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) @@ -6419,17 +6506,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 @@ -6458,7 +6534,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() @@ -6467,31 +6544,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 @@ -6502,52 +6554,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() From 34134fb2ef0ba29c1c722af91c81215550104f09 Mon Sep 17 00:00:00 2001 From: Francesco Date: Wed, 1 Jul 2026 10:53:32 +0200 Subject: [PATCH 14/34] WIP: selectable object labels --- cellacdc/gui.py | 4 +- .../_demo_single_channel_lab_2d.py | 17 ++- cellacdc/volume_renderer/_widgets.py | 16 +++ cellacdc/volume_renderer/canvas.py | 102 +++++++++++++++--- cellacdc/volume_renderer/utils.py | 6 ++ cellacdc/widgets.py | 30 ++++-- 6 files changed, 149 insertions(+), 26 deletions(-) create mode 100644 cellacdc/volume_renderer/utils.py diff --git a/cellacdc/gui.py b/cellacdc/gui.py index 54dec1005..2db163ee3 100755 --- a/cellacdc/gui.py +++ b/cellacdc/gui.py @@ -3489,8 +3489,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( diff --git a/cellacdc/volume_renderer/_demo_single_channel_lab_2d.py b/cellacdc/volume_renderer/_demo_single_channel_lab_2d.py index 1f7dad2a4..fc36d2b88 100644 --- a/cellacdc/volume_renderer/_demo_single_channel_lab_2d.py +++ b/cellacdc/volume_renderer/_demo_single_channel_lab_2d.py @@ -1,6 +1,7 @@ import os import numpy as np +import pandas as pd import skimage @@ -27,10 +28,24 @@ lab = np.load(lab_filepath)['arr_0'] +metadata_filepath = os.path.join( + images_path, 'ASY15-1_0nM-17_s17_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(mneon_filepath) data = {'mNeon': volume} renderer = VolumeRendererWindow() -renderer.set_volumes(data) # (Z, Y, X) numpy array +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/_widgets.py b/cellacdc/volume_renderer/_widgets.py index 7dbbbd857..71e6ecdc4 100644 --- a/cellacdc/volume_renderer/_widgets.py +++ b/cellacdc/volume_renderer/_widgets.py @@ -14,6 +14,7 @@ class VolumeRendererToolbar(ToolBar): sigHomeView = Signal() sigSave = Signal() sigSetSingleChannel = Signal(bool) + sigSelectObjects = Signal(bool) def __init__(self, name='Volume Renderer Toolbar', parent=None): @@ -39,6 +40,18 @@ def __init__(self, name='Volume Renderer Toolbar', parent=None): 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' ) @@ -50,6 +63,9 @@ def __init__(self, name='Volume Renderer Toolbar', parent=None): 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 diff --git a/cellacdc/volume_renderer/canvas.py b/cellacdc/volume_renderer/canvas.py index f6533fdc2..e2a2ce527 100644 --- a/cellacdc/volume_renderer/canvas.py +++ b/cellacdc/volume_renderer/canvas.py @@ -3,13 +3,14 @@ import numpy as np from math import ceil +import skimage from qtpy.QtCore import ( Signal, Qt, QCoreApplication, QEventLoop ) from qtpy.QtWidgets import ( QMainWindow, QHBoxLayout, QVBoxLayout, QWidget, QPushButton, - QGraphicsProxyWidget + QGraphicsProxyWidget, QGroupBox, QCheckBox ) import pyqtgraph as pg @@ -19,7 +20,7 @@ from .. import widgets from .. import colors -from . import _widgets +from . import _widgets, utils RgbaColor = colors.RgbaColor AcdcPyQtGraphColorMapName = colors.AcdcPyQtGraphColorMapName @@ -82,6 +83,9 @@ def __init__( self._SizeZ = None self._gradient_item_state = None self._lab_gradient_cmap_name = None + self._lab_node = None + self._canvas = None + self._voxel_size = None if app is None: app = QCoreApplication.instance() @@ -186,6 +190,12 @@ def _init_lab_ui_items( 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_lut_item.sigGradientChanged.connect(self.ticksCmapMoved) lab_reset_btn.clicked.connect( partial( @@ -193,6 +203,9 @@ def _init_lab_ui_items( lab_gradient_item=lab_gradient_item) ) + 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 @@ -203,6 +216,34 @@ def _init_lab_ui_items( lab_gradient_item.item.loadPreset(_DEFAULT_LABELS_CMAP_NAME) self._lab_node.opacity = self._lab_opacity_slider.value() + + for obj in self._rp: + obj_checkbox = QCheckBox(f'{obj.label}') + obj_checkbox.setChecked(True) + self._object_labels_list_layout.addWidget(obj_checkbox) + + obj_checkbox.toggled.connect( + partial(self._set_object_checked, obj=obj) + ) + + self._object_labels_list_layout.addStretch(1) + + def _random_shuffle_lab_gradient_cmap(self): + ... + + def _greedy_shuffle_lab_gradient_cmap(self): + ... + + 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._canvas.update() def _on_reset_lab_gradient(self, *args, lab_gradient_item=None): if lab_gradient_item is None: @@ -261,21 +302,38 @@ def _init_ui(self): 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._scene_layout = QHBoxLayout() self._scene_layout.addWidget(lut_items_graphics_layout, stretch=0) - self._scene_layout.addWidget(self._canvas.native, stretch=1) + self._scene_layout.addWidget(self._canvas.native, stretch=10) self._scene_layout.addLayout( self._lab_gradient_item_layout, stretch=0 ) + self._scene_layout.addWidget( + self._object_labels_list_scrollarea, 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.addLayout(self._controls_layout) + main_layout.addWidget(self._controls_groupbox) + main_layout.setStretch(0, 10) + main_layout.setStretch(1, 0) self.setCentralWidget(central) self._ui_initialised = True @@ -598,17 +656,10 @@ def _set_single_channel(self, single: bool): self._set_visiblity(update=True) - def set_segmentation_masks( - self, - lab: np.ndarray, - cmap: list[RgbaColor] | AcdcPyQtGraphColorMapName=None, - SizeZ: int=None - ): - self.set_labels(lab, cmap=cmap, Size=SizeZ) - 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 @@ -636,6 +687,7 @@ def set_labels( f'Expected 3-D (Z, Y, X) labels array; got shape {lab.shape}') self._lab = self._preprocess_lab(lab) + self._rp = skimage.measure.regionprops(self._lab) self._init_lab_ui_items( self._lab, @@ -643,13 +695,16 @@ def set_labels( gradient_item_state=gradient_item_state ) + self.set_voxel_size(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, cmap: list[RgbaColor] | AcdcPyQtGraphColorMapName=None, ): num_channels = len(self._channels_data) @@ -664,10 +719,31 @@ def set_volume( self.set_volumes(volumes, cmaps) + def set_voxel_size(self, voxel_size: tuple[float, float, float] | None): + if voxel_size is None: + voxel_size = self._voxel_size + + self._voxel_size = voxel_size + + from vispy.visuals.transforms import STTransform + + scale = utils.voxel_display_scale(*voxel_size) + transform = STTransform(scale=scale) + + 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 + + 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, cmaps: ColorMapPerChannel=None, ): if isinstance(volumes, dict): @@ -693,6 +769,8 @@ def set_volumes( self._set_gl_blend_states() + self.set_voxel_size(voxel_size) + self._canvas.update() def show(self, block=False): diff --git a/cellacdc/volume_renderer/utils.py b/cellacdc/volume_renderer/utils.py new file mode 100644 index 000000000..cdf6f54ad --- /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 dc44231a5..94415af89 100755 --- a/cellacdc/widgets.py +++ b/cellacdc/widgets.py @@ -6352,6 +6352,9 @@ def updateImageOpacity(self, value): self.imageItem.setOpacity(value) class BaseLabelsGradientWidget(pg.GradientWidget): + sigShuffleCmap = Signal() + sigGreeedyShuffleCmap = Signal() + def __init__( self, *args, parent=None, orientation='right', **kargs): pg.GradientEditorItem = BaseGradientEditorItemLabels @@ -6382,8 +6385,24 @@ def __init__( self, *args, parent=None, orientation='right', **kargs): self.saveColormapAction.triggered.connect( self.saveColormap ) + + # 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.addCustomGradients() + + self.shuffleCmapAction.triggered.connect(self.sigShuffleCmap.emit) + self.greedyShuffleCmapAction.triggered.connect( + self.sigGreeedyShuffleCmap.emit + ) def onShowCustomCmapsMenu(self): self.customCmapsMenu.show() @@ -6432,17 +6451,6 @@ def saveColormap(self): act.setDefaultWidget(widget) act.triggered.connect(self.colorButton.click) self.menu.addAction(act) - - # 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) def addCustomGradients(self): try: From bc4f9152930572e2fd1e5aba523aecf55eb03ab9 Mon Sep 17 00:00:00 2001 From: Francesco Date: Wed, 1 Jul 2026 13:54:45 +0200 Subject: [PATCH 15/34] feat: 3d viewer API --- cellacdc/colors.py | 35 ++++++++-- .../_demo_single_channel_lab_3d.py | 42 ++++++++++-- cellacdc/volume_renderer/canvas.py | 67 ++++++++++++++----- cellacdc/widgets.py | 4 +- 4 files changed, 118 insertions(+), 30 deletions(-) diff --git a/cellacdc/colors.py b/cellacdc/colors.py index b03c17cb2..155d5f5f6 100644 --- a/cellacdc/colors.py +++ b/cellacdc/colors.py @@ -446,15 +446,38 @@ def pg_to_vispy_cmap(pg_cmap, n=256, debug=False, transparent_zero=False): colors = np.array(colors) / 255.0 if transparent_zero: - if colors.shape[-1] == 3: - rgba_colors = np.ones((len(colors), 4)) - rgba_colors[:, :3] = colors - colors = rgba_colors - - colors[0] = [0, 0, 0, 0] + 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, diff --git a/cellacdc/volume_renderer/_demo_single_channel_lab_3d.py b/cellacdc/volume_renderer/_demo_single_channel_lab_3d.py index 46ce23909..c11bda58b 100644 --- a/cellacdc/volume_renderer/_demo_single_channel_lab_3d.py +++ b/cellacdc/volume_renderer/_demo_single_channel_lab_3d.py @@ -1,5 +1,8 @@ import os +import numpy as np +import pandas as pd + import skimage from cellacdc._run import _setup_app @@ -7,13 +10,42 @@ 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' +images_path = os.path.join( + data_path, + 'test_snapshots', + 'mtDNA_Anika', + 'Position_17', + 'Images', +) + +mneon_filepath = os.path.join( + images_path, 'ASY15-1_0nM-17_s17_mNeon.tif' ) -zstack_array = skimage.io.imread(zstack_filepath) +lab_filepath = os.path.join( + images_path, 'ASY15-1_0nM-17_s17_mKate_mask.npz' +) + +lab = np.load(lab_filepath)['arr_0'] + +metadata_filepath = os.path.join( + images_path, 'ASY15-1_0nM-17_s17_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(mneon_filepath) +data = {'mNeon': volume} renderer = VolumeRendererWindow() -renderer.set_volume(zstack_array) # (Z, Y, X) numpy array +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/canvas.py b/cellacdc/volume_renderer/canvas.py index e2a2ce527..a720bb59d 100644 --- a/cellacdc/volume_renderer/canvas.py +++ b/cellacdc/volume_renderer/canvas.py @@ -22,6 +22,8 @@ from . import _widgets, utils +rng = np.random.default_rng(42) + RgbaColor = colors.RgbaColor AcdcPyQtGraphColorMapName = colors.AcdcPyQtGraphColorMapName PltColorName = colors.PltColorName @@ -86,6 +88,7 @@ def __init__( self._lab_node = None self._canvas = None self._voxel_size = None + self._lab_ncolors = 256 if app is None: app = QCoreApplication.instance() @@ -149,7 +152,6 @@ def _init_lab_ui_items( method="translucent", interpolation="nearest", parent=self._view.scene, - ) self._lab_node.clim = (vmin, vmax) @@ -182,10 +184,7 @@ def _init_lab_ui_items( self._lab_gradient_item = lab_gradient_item self._lab_opacity_slider.valueChanged.connect( - partial( - self._on_lab_opacity_changed, - lab_gradient_item=lab_gradient_item - ) + self._on_lab_opacity_changed ) lab_gradient_item.sigGradientChangeFinished.connect( self._on_lab_gradient_changed @@ -196,6 +195,8 @@ def _init_lab_ui_items( 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( partial( @@ -229,10 +230,38 @@ def _init_lab_ui_items( self._object_labels_list_layout.addStretch(1) 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: @@ -245,23 +274,23 @@ def _set_object_checked(self, checked: bool, obj=None): self._canvas.update() - def _on_reset_lab_gradient(self, *args, lab_gradient_item=None): - if lab_gradient_item is None: - return - + def _on_reset_lab_gradient(self, *args): if self._lab_gradient_item_state is not None: - lab_gradient_item.item.restoreState(self._lab_gradient_item_state) + self._lab_gradient_item.item.restoreState( + self._lab_gradient_item_state) return if self._lab_gradient_cmap_name is not None: - lab_gradient_item.item.loadPreset(self._lab_gradient_cmap_name) + self._lab_gradient_item.item.loadPreset( + self._lab_gradient_cmap_name) return - lab_gradient_item.item.loadPreset(_DEFAULT_LABELS_CMAP_NAME) + 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 + lab_gradient_item.colorMap(), transparent_zero=True, + n=self._lab_ncolors ) self._lab_node.cmap = cmap if update: @@ -270,9 +299,10 @@ def _on_lab_gradient_changed(self, lab_gradient_item, update: bool=True): def _on_lab_opacity_changed( self, value, - lab_gradient_item=None, update: bool=True ): + lab_gradient_item = self._lab_gradient_item + if lab_gradient_item is None: return @@ -671,6 +701,9 @@ def set_labels( ) return + 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.' diff --git a/cellacdc/widgets.py b/cellacdc/widgets.py index 94415af89..1977ca339 100755 --- a/cellacdc/widgets.py +++ b/cellacdc/widgets.py @@ -6388,12 +6388,12 @@ def __init__( self, *args, parent=None, orientation='right', **kargs): # Shuffle colors action self.shuffleCmapAction = QAction( - 'Randomly shuffle colormap (Shift+S)', self + 'Randomly shuffle colormap (Shift+S)', self ) self.menu.addAction(self.shuffleCmapAction) self.greedyShuffleCmapAction = QAction( - 'Greedily shuffle colormap (Alt+Shift+S)', self + 'Greedily shuffle colormap (Alt+Shift+S)', self ) self.menu.addAction(self.greedyShuffleCmapAction) From 99dfd08dd849a840abbb153af5e4ce1bdd718060 Mon Sep 17 00:00:00 2001 From: Francesco Date: Wed, 1 Jul 2026 16:13:00 +0200 Subject: [PATCH 16/34] feat: add 3d viewer to gui --- cellacdc/colors.py | 5 + cellacdc/gui.py | 115 +++++++++++++++++- .../_demo_single_channel_custom_cmap.py | 19 +++ cellacdc/volume_renderer/_widgets.py | 11 ++ cellacdc/volume_renderer/canvas.py | 89 ++++++++++---- cellacdc/widgets.py | 58 +++++++-- 6 files changed, 262 insertions(+), 35 deletions(-) create mode 100644 cellacdc/volume_renderer/_demo_single_channel_custom_cmap.py diff --git a/cellacdc/colors.py b/cellacdc/colors.py index 155d5f5f6..91c7a275f 100644 --- a/cellacdc/colors.py +++ b/cellacdc/colors.py @@ -76,6 +76,8 @@ 'sunset' ] +AcdcColorMap = list[RgbaColor] | AcdcPyQtGraphColorMapName | list[PltColorName] + overlay_rgbs = [ (255, 255, 0), (252, 72, 254), @@ -89,6 +91,9 @@ for i in np.linspace(0,1,8)] ) +# See pg.HistogramLUTItem.saveState() and pg.HistogramLUTItem.restoreState() +LutItemState = dict[str, object] + _mapCache = {} def getFromMatplotlib(name): """ diff --git a/cellacdc/gui.py b/cellacdc/gui.py index 2db163ee3..1842dcb90 100755 --- a/cellacdc/gui.py +++ b/cellacdc/gui.py @@ -937,6 +937,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") @@ -1076,6 +1077,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)) @@ -3129,6 +3139,85 @@ def gui_connectActions(self): def zProjLockViewToggled(self, checked): self.updateZproj(self.zProjComboBox.currentText()) + def launch3dViewerToggled(self, checked): + 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[2] + lut_items_states[channel_name] = lutItem.super_saveState() + + voxel_size = ( + posData.PhysicalSizeZ, posData.PhysicalSizeY, posData.PhysicalSizeX + ) + + from cellacdc 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._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): + 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 +3461,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( @@ -3937,6 +4030,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 +4122,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 +4352,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' @@ -12484,6 +12586,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) @@ -12499,6 +12604,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(): @@ -19848,6 +19956,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 @@ -19863,6 +19972,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: @@ -25026,7 +25138,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/volume_renderer/_demo_single_channel_custom_cmap.py b/cellacdc/volume_renderer/_demo_single_channel_custom_cmap.py new file mode 100644 index 000000000..613f30973 --- /dev/null +++ b/cellacdc/volume_renderer/_demo_single_channel_custom_cmap.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, cmap=['black', 'cyan']) # (Z, Y, X) numpy array +renderer.run() \ No newline at end of file diff --git a/cellacdc/volume_renderer/_widgets.py b/cellacdc/volume_renderer/_widgets.py index 71e6ecdc4..a7537606f 100644 --- a/cellacdc/volume_renderer/_widgets.py +++ b/cellacdc/volume_renderer/_widgets.py @@ -15,6 +15,7 @@ class VolumeRendererToolbar(ToolBar): sigSave = Signal() sigSetSingleChannel = Signal(bool) sigSelectObjects = Signal(bool) + sigUpdate = Signal() def __init__(self, name='Volume Renderer Toolbar', parent=None): @@ -24,6 +25,15 @@ def __init__(self, name='Volume Renderer Toolbar', parent=None): 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( @@ -61,6 +71,7 @@ def __init__(self, name='Volume Renderer Toolbar', parent=None): '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( diff --git a/cellacdc/volume_renderer/canvas.py b/cellacdc/volume_renderer/canvas.py index a720bb59d..f3e5f0b60 100644 --- a/cellacdc/volume_renderer/canvas.py +++ b/cellacdc/volume_renderer/canvas.py @@ -24,19 +24,21 @@ rng = np.random.default_rng(42) -RgbaColor = colors.RgbaColor +AcdcColorMap = colors.AcdcColorMap AcdcPyQtGraphColorMapName = colors.AcdcPyQtGraphColorMapName PltColorName = colors.PltColorName ColorMapPerChannel = ( - list[list[RgbaColor]] - | dict[str, list[RgbaColor]] - | list[AcdcPyQtGraphColorMapName] - | dict[str, AcdcPyQtGraphColorMapName] - | list[PltColorName] - | dict[str, PltColorName] + list[AcdcColorMap] + | dict[str, AcdcColorMap] ) LabelsGradientName = colors.AcdcPyQtGraphColorMapName +LutItemState = colors.LutItemState +LutItemStates = ( + list[LutItemState] + | dict[str, LutItemState] +) + _SLIDER_NORMALIZE_FACTOR = 20 _DEFAULT_LABELS_CMAP_NAME = 'viridis' @@ -64,6 +66,8 @@ class VolumeRendererWindow(QMainWindow): state is preserved for re-display. Pass ``hide_on_close=False`` to destroy on close instead. """ + sigClose = Signal(object) + sigUpdate = Signal() def __init__( self, @@ -318,6 +322,7 @@ def _init_ui(self): self._toolbar = _widgets.VolumeRendererToolbar(parent=self) self.addToolBar(Qt.TopToolBarArea, self._toolbar) + 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) @@ -447,7 +452,7 @@ def _init_default_rgbs(self): self._default_rgbs = colors.overlay_rgbs.copy() self._default_rgbs.insert(0, (255, 255, 255)) - def _get_channel_default_cmap(self, channel_name: str, channel_idx: int): + 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) @@ -485,7 +490,6 @@ def _set_channels_data( self, volumes: list[np.ndarray], channel_names: list[str], - cmaps: ColorMapPerChannel=None, ): from vispy.scene import visuals @@ -558,12 +562,6 @@ def _set_channels_data( reset_btn.setMaximumWidth(ceil(lut_item_width)) self._lut_items_graphics_layout.setFixedWidth(int(self._lut_items_width)) - - if cmaps is None: - cmaps = { - ch: self._get_channel_default_cmap(ch, c) - for c, ch in enumerate(channel_names) - } def number_of_visible_channels(self) -> int: return sum( @@ -738,7 +736,7 @@ def set_volume( volume: np.ndarray, channel_name: str='', voxel_size: tuple[float, float, float]=None, - cmap: list[RgbaColor] | AcdcPyQtGraphColorMapName=None, + cmap: list[colors.RgbaColor] | AcdcPyQtGraphColorMapName=None, ): num_channels = len(self._channels_data) if not channel_name: @@ -747,15 +745,18 @@ def set_volume( cmaps = None if cmap is not None: cmaps = {channel_name: cmap} - + volumes = {channel_name: volume} - self.set_volumes(volumes, cmaps) + self.set_volumes(volumes, cmaps=cmaps) def set_voxel_size(self, voxel_size: tuple[float, float, float] | None): if voxel_size is None: voxel_size = self._voxel_size + if voxel_size is None: + return + self._voxel_size = voxel_size from vispy.visuals.transforms import STTransform @@ -777,6 +778,7 @@ def set_volumes( 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): @@ -797,9 +799,31 @@ def set_volumes( self._set_channels_data( volumes, channel_names, - cmaps=cmaps ) + 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() self.set_voxel_size(voxel_size) @@ -825,7 +849,14 @@ def run(self, block=True): 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() @@ -838,9 +869,25 @@ def closeEvent(self, event): def reset_view(self): """Reset the camera to the default orientation and fit the volume.""" - self._view.camera.set_range() + 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) + self._view.camera.center = first_node.transform.map(xyz_center)[:3] self._view.camera.elevation = 30.0 - self._view.camera.azimuth = -60.0 + self._view.camera.azimuth = 45.0 + self._view.camera.fov = 60.0 + + 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._view.camera.distance = diag + self._canvas.update() def save_screenshot(self): diff --git a/cellacdc/widgets.py b/cellacdc/widgets.py index 1977ca339..4134c7749 100755 --- a/cellacdc/widgets.py +++ b/cellacdc/widgets.py @@ -5596,6 +5596,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__( @@ -6048,6 +6075,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() @@ -6386,6 +6416,8 @@ def __init__( self, *args, parent=None, orientation='right', **kargs): self.saveColormap ) + self.menu.addSeparator() + # Shuffle colors action self.shuffleCmapAction = QAction( 'Randomly shuffle colormap (Shift+S)', self @@ -6399,6 +6431,19 @@ def __init__( self, *args, parent=None, orientation='right', **kargs): self.addCustomGradients() + # Background color button + hbox = QHBoxLayout() + hbox.addWidget(QLabel('Background color: ')) + self.colorButton = myColorButton(color=(25,25,25)) + hbox.addStretch(1) + hbox.addWidget(self.colorButton) + widget = QWidget() + widget.setLayout(hbox) + act = highlightableQWidgetAction(self) + 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 @@ -6438,19 +6483,6 @@ def saveColormap(self): cp.write(file) self.addCustomGradient(cmapName, state, restore=False) - - # Background color button - hbox = QHBoxLayout() - hbox.addWidget(QLabel('Background color: ')) - self.colorButton = myColorButton(color=(25,25,25)) - hbox.addStretch(1) - hbox.addWidget(self.colorButton) - widget = QWidget() - widget.setLayout(hbox) - act = highlightableQWidgetAction(self) - act.setDefaultWidget(widget) - act.triggered.connect(self.colorButton.click) - self.menu.addAction(act) def addCustomGradients(self): try: From 3bd2acbb237edc548f01cd2a18bfebd4e90d0586 Mon Sep 17 00:00:00 2001 From: Francesco Date: Wed, 1 Jul 2026 16:20:35 +0200 Subject: [PATCH 17/34] fix: set viewer camera range --- cellacdc/volume_renderer/canvas.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cellacdc/volume_renderer/canvas.py b/cellacdc/volume_renderer/canvas.py index f3e5f0b60..741f784bb 100644 --- a/cellacdc/volume_renderer/canvas.py +++ b/cellacdc/volume_renderer/canvas.py @@ -869,6 +869,8 @@ def closeEvent(self, event): def reset_view(self): """Reset the camera to the default orientation and fit the volume.""" + self._view.camera.set_range() + first_channel = list(self._channels_data.keys())[0] first_volume = self._channels_data[first_channel].volume first_node = self._channels_data[first_channel].node From 0ca2386364f056500605411211b0e19e821622ce Mon Sep 17 00:00:00 2001 From: Francesco Date: Wed, 1 Jul 2026 16:25:03 +0200 Subject: [PATCH 18/34] improvement: set and reset view as distinct methods --- cellacdc/volume_renderer/canvas.py | 33 ++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/cellacdc/volume_renderer/canvas.py b/cellacdc/volume_renderer/canvas.py index 741f784bb..d2729f6d4 100644 --- a/cellacdc/volume_renderer/canvas.py +++ b/cellacdc/volume_renderer/canvas.py @@ -834,6 +834,8 @@ 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) @@ -845,7 +847,6 @@ def show(self, block=False): def run(self, block=True): self.show() - self.reset_view() if block: self._block_exec() @@ -868,19 +869,22 @@ def closeEvent(self, event): 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.""" - self._view.camera.set_range() 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) - 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 - + xyz_center = (X/2, Y/2, Z/2) corners = np.array([ [0, 0, 0], [X, Y, Z], @@ -888,8 +892,21 @@ def reset_view(self): 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): From 78444afe82c6cf2fafacd6b0ffcbc01a3aa2c8cd Mon Sep 17 00:00:00 2001 From: Francesco Date: Thu, 2 Jul 2026 09:23:57 +0200 Subject: [PATCH 19/34] WIP: add points layer markers --- cellacdc/__init__.py | 5 +- cellacdc/gui.py | 2 +- cellacdc/plot.py | 17 ++++++ cellacdc/volume_renderer/canvas.py | 91 ++++++++++++++++++++++++++++-- 4 files changed, 107 insertions(+), 8 deletions(-) diff --git a/cellacdc/__init__.py b/cellacdc/__init__.py index 92cc07dca..45996e1d3 100755 --- a/cellacdc/__init__.py +++ b/cellacdc/__init__.py @@ -821,4 +821,7 @@ def inner_function(self, *args, **kwargs): ) if GUI_INSTALLED: - from cellacdc.volume_renderer.canvas import VolumeRendererWindow \ No newline at end of file + try: + from cellacdc.volume_renderer.canvas import VolumeRendererWindow + except ModuleNotFoundError: + pass \ No newline at end of file diff --git a/cellacdc/gui.py b/cellacdc/gui.py index 1842dcb90..1f00af83f 100755 --- a/cellacdc/gui.py +++ b/cellacdc/gui.py @@ -3185,7 +3185,7 @@ def launch3dViewerToggled(self, checked): posData.PhysicalSizeZ, posData.PhysicalSizeY, posData.PhysicalSizeX ) - from cellacdc import VolumeRendererWindow + from cellacdc.volume_renderer.canvas import VolumeRendererWindow self._volume_renderer = VolumeRendererWindow( app=self.app, diff --git a/cellacdc/plot.py b/cellacdc/plot.py index 3d33b3c23..8292eb301 100644 --- a/cellacdc/plot.py +++ b/cellacdc/plot.py @@ -27,6 +27,23 @@ 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' +] + def matplotlib_cmap_to_lut( cmap: Union[Iterable, matplotlib.colors.Colormap, str], n_colors: int=256 diff --git a/cellacdc/volume_renderer/canvas.py b/cellacdc/volume_renderer/canvas.py index d2729f6d4..59dc7e3b9 100644 --- a/cellacdc/volume_renderer/canvas.py +++ b/cellacdc/volume_renderer/canvas.py @@ -1,8 +1,12 @@ from dataclasses import dataclass from functools import partial +from math import ceil + +from typing import Literal import numpy as np -from math import ceil +import pandas as pd + import skimage from qtpy.QtCore import ( @@ -20,7 +24,10 @@ from .. import widgets from .. import colors -from . import _widgets, utils +from . import _widgets, utils, plot + +import vispy.scene +import vispy.color rng = np.random.default_rng(42) @@ -39,12 +46,14 @@ | dict[str, LutItemState] ) +MarkerSymbols = plot.VisPyMarkerSymbols + _SLIDER_NORMALIZE_FACTOR = 20 _DEFAULT_LABELS_CMAP_NAME = 'viridis' @dataclass class _ChannelData: - node: object # vispy.scene.visuals.Volume + node: vispy.scene.visuals.Volume lut_item: widgets.baseHistogramLUTitem volume: np.ndarray auto_button: QPushButton @@ -52,6 +61,24 @@ class _ChannelData: opacity_slider: widgets.sliderWithSpinBox toolbutton: widgets.OverlayChannelToolButton +@dataclass +class _PointsLayer: + name: str + + points: np.ndarray # (N, [z, y, x]) voxel coordinates + labels: list[str] | None + df: pd.DataFrame | None=None + + markers: vispy.scene.visuals.Markers + text: vispy.scene.visuals.Text | None=None + + visible: bool = True + color: vispy.color.Color = 'red' + size: float = 8.0 + symbol: str = 'disc' + opacity_slider: widgets.sliderWithSpinBox + + class VolumeRendererWindow(QMainWindow): """ A standalone Qt window that displays a 3D z-stack volume using vispy. @@ -106,7 +133,8 @@ def __init__( super().__init__(parent) self.setWindowTitle(title) - self._channels_data: dict[_ChannelData] = {} + self._channels_data: dict[str, _ChannelData] = {} + self._points_layers: dict[str, _PointsLayer] = {} self._init_default_rgbs() self._init_vispy() @@ -736,6 +764,7 @@ def set_volume( 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) @@ -743,12 +772,19 @@ def set_volume( channel_name = f'channel_{num_channels+1}' cmaps = None - if cmap is not None: + lut_items_states = None + if lut_item_state is not None: + lut_items_states = {channel_name: cmap} + elif cmap is not None: cmaps = {channel_name: cmap} volumes = {channel_name: volume} - self.set_volumes(volumes, cmaps=cmaps) + self.set_volumes( + volumes, + cmaps=cmaps, + lut_items_states=lut_items_states + ) def set_voxel_size(self, voxel_size: tuple[float, float, float] | None): if voxel_size is None: @@ -830,6 +866,49 @@ def set_volumes( 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', + scaling: Literal['fixed', 'scene']='scene' + ): + 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: + 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 + ) + def show(self, block=False): self.resize(960, 720) self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) From b48815265f349c5af05fd3feefeadf94c831c449 Mon Sep 17 00:00:00 2001 From: Francesco Date: Sat, 4 Jul 2026 20:54:43 +0200 Subject: [PATCH 20/34] WIP: adding ui items for points layer --- .gitignore | 3 + cellacdc/colors.py | 2 + cellacdc/myutils.py | 18 ++++ cellacdc/plot.py | 56 +++++++++- cellacdc/volume_renderer/__init__.py | 4 + .../volume_renderer/_demo_multi_channel.py | 35 ------ .../_demo_single_channel_custom_cmap.py | 19 ---- .../{ => _demos}/_demo_lab_2d.py | 12 +-- .../_demos/_demo_multi_channel.py | 35 ++++++ .../{ => _demos}/_demo_single_channel.py | 0 .../_demo_single_channel_custom_cmap.py | 26 +++++ .../_demo_single_channel_lab_2d.py | 22 ++-- .../_demo_single_channel_lab_3d.py | 22 ++-- ..._demo_single_channel_lab_3d_with_points.py | 67 ++++++++++++ cellacdc/volume_renderer/_demos/data/.gitkeep | 1 + cellacdc/volume_renderer/_widgets.py | 8 +- cellacdc/volume_renderer/canvas.py | 101 ++++++++++++++---- cellacdc/widgets.py | 46 ++++++-- 18 files changed, 365 insertions(+), 112 deletions(-) delete mode 100644 cellacdc/volume_renderer/_demo_multi_channel.py delete mode 100644 cellacdc/volume_renderer/_demo_single_channel_custom_cmap.py rename cellacdc/volume_renderer/{ => _demos}/_demo_lab_2d.py (65%) create mode 100644 cellacdc/volume_renderer/_demos/_demo_multi_channel.py rename cellacdc/volume_renderer/{ => _demos}/_demo_single_channel.py (100%) create mode 100644 cellacdc/volume_renderer/_demos/_demo_single_channel_custom_cmap.py rename cellacdc/volume_renderer/{ => _demos}/_demo_single_channel_lab_2d.py (62%) rename cellacdc/volume_renderer/{ => _demos}/_demo_single_channel_lab_3d.py (60%) create mode 100644 cellacdc/volume_renderer/_demos/_demo_single_channel_lab_3d_with_points.py create mode 100644 cellacdc/volume_renderer/_demos/data/.gitkeep diff --git a/.gitignore b/.gitignore index 6d90763c1..498e27e17 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/colors.py b/cellacdc/colors.py index 91c7a275f..b0469fdc9 100644 --- a/cellacdc/colors.py +++ b/cellacdc/colors.py @@ -29,11 +29,13 @@ CYAN_RGB = (0, 255, 255) MAGENTA_RGB = (255, 0, 255) YELLOW_RGB = (255, 255, 0) +GREEN_RGB = (0, 255, 0) FLUO_CHANNELS_COLORS = { 'mCardinal': MAGENTA_RGB, 'mNeonGreen': CYAN_RGB, 'NeonGreen': CYAN_RGB, + 'KaedeGreen': GREEN_RGB, 'mNeon': CYAN_RGB, 'mNG': CYAN_RGB, 'mScarlet': MAGENTA_RGB, diff --git a/cellacdc/myutils.py b/cellacdc/myutils.py index 661dfa1b8..4cc764b77 100644 --- a/cellacdc/myutils.py +++ b/cellacdc/myutils.py @@ -5693,3 +5693,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 8292eb301..31e657982 100644 --- a/cellacdc/plot.py +++ b/cellacdc/plot.py @@ -22,7 +22,9 @@ 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 @@ -44,6 +46,58 @@ 'star' ] +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 index e69de29bb..63e4ac69a 100644 --- a/cellacdc/volume_renderer/__init__.py +++ 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/_demo_multi_channel.py b/cellacdc/volume_renderer/_demo_multi_channel.py deleted file mode 100644 index e26c3a1e8..000000000 --- a/cellacdc/volume_renderer/_demo_multi_channel.py +++ /dev/null @@ -1,35 +0,0 @@ -import os - -import skimage - -from cellacdc._run import _setup_app - -from cellacdc import VolumeRendererWindow -from cellacdc import data_path - -images_path = os.path.join( - data_path, - 'test_snapshots', - 'mtDNA_Anika', - 'Position_17', - 'Images', -) - -mkate_filepath = os.path.join( - images_path, 'ASY15-1_0nM-17_s17_mKate.tif' -) -mneon_filepath = os.path.join( - images_path, 'ASY15-1_0nM-17_s17_mNeon.tif' -) - -mkate_volume = skimage.io.imread(mkate_filepath) -mneon_volume = skimage.io.imread(mneon_filepath) - -data = { - 'mKate': mkate_volume, - 'mNeon': mneon_volume -} - -renderer = VolumeRendererWindow() -renderer.set_volumes(data) -renderer.run() \ No newline at end of file diff --git a/cellacdc/volume_renderer/_demo_single_channel_custom_cmap.py b/cellacdc/volume_renderer/_demo_single_channel_custom_cmap.py deleted file mode 100644 index 613f30973..000000000 --- a/cellacdc/volume_renderer/_demo_single_channel_custom_cmap.py +++ /dev/null @@ -1,19 +0,0 @@ -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, cmap=['black', 'cyan']) # (Z, Y, X) numpy array -renderer.run() \ No newline at end of file diff --git a/cellacdc/volume_renderer/_demo_lab_2d.py b/cellacdc/volume_renderer/_demos/_demo_lab_2d.py similarity index 65% rename from cellacdc/volume_renderer/_demo_lab_2d.py rename to cellacdc/volume_renderer/_demos/_demo_lab_2d.py index 8847b7675..b88a7b3b1 100644 --- a/cellacdc/volume_renderer/_demo_lab_2d.py +++ b/cellacdc/volume_renderer/_demos/_demo_lab_2d.py @@ -7,18 +7,18 @@ from cellacdc._run import _setup_app from cellacdc import VolumeRendererWindow -from cellacdc import data_path +from cellacdc import myutils + +exp_folderpath = myutils.download_3d_renderer_demo_data() images_path = os.path.join( - data_path, - 'test_snapshots', - 'mtDNA_Anika', - 'Position_17', + exp_folderpath, + 'Position_3', 'Images', ) lab_filepath = os.path.join( - images_path, 'ASY15-1_0nM-17_s17_segm.npz' + images_path, 'FPY015-2_SCD-03_s03_segm.npz' ) lab = np.load(lab_filepath)['arr_0'] 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 000000000..d1487cec5 --- /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/_demo_single_channel.py b/cellacdc/volume_renderer/_demos/_demo_single_channel.py similarity index 100% rename from cellacdc/volume_renderer/_demo_single_channel.py rename to cellacdc/volume_renderer/_demos/_demo_single_channel.py 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 000000000..332222724 --- /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/_demo_single_channel_lab_2d.py b/cellacdc/volume_renderer/_demos/_demo_single_channel_lab_2d.py similarity index 62% rename from cellacdc/volume_renderer/_demo_single_channel_lab_2d.py rename to cellacdc/volume_renderer/_demos/_demo_single_channel_lab_2d.py index fc36d2b88..756491590 100644 --- a/cellacdc/volume_renderer/_demo_single_channel_lab_2d.py +++ b/cellacdc/volume_renderer/_demos/_demo_single_channel_lab_2d.py @@ -8,28 +8,28 @@ from cellacdc._run import _setup_app from cellacdc import VolumeRendererWindow -from cellacdc import data_path +from cellacdc import myutils + +exp_folderpath = myutils.download_3d_renderer_demo_data() images_path = os.path.join( - data_path, - 'test_snapshots', - 'mtDNA_Anika', - 'Position_17', + exp_folderpath, + 'Position_3', 'Images', ) -mneon_filepath = os.path.join( - images_path, 'ASY15-1_0nM-17_s17_mNeon.tif' +kaedegreen_filepath = os.path.join( + images_path, 'FPY015-2_SCD-03_s03_run_num1_KaeGr_preprocessed.tif' ) lab_filepath = os.path.join( - images_path, 'ASY15-1_0nM-17_s17_segm.npz' + images_path, 'FPY015-2_SCD-03_s03_segm.npz' ) lab = np.load(lab_filepath)['arr_0'] metadata_filepath = os.path.join( - images_path, 'ASY15-1_0nM-17_s17_metadata.csv' + images_path, 'FPY015-2_SCD-03_s03_metadata.csv' ) df_metadata = pd.read_csv(metadata_filepath, index_col='Description') @@ -39,8 +39,8 @@ float(df_metadata.at['PhysicalSizeX', 'values']) ) -volume = skimage.io.imread(mneon_filepath) -data = {'mNeon': volume} +volume = skimage.io.imread(kaedegreen_filepath) +data = {'KaedeGreen': volume} renderer = VolumeRendererWindow() renderer.set_volumes( diff --git a/cellacdc/volume_renderer/_demo_single_channel_lab_3d.py b/cellacdc/volume_renderer/_demos/_demo_single_channel_lab_3d.py similarity index 60% rename from cellacdc/volume_renderer/_demo_single_channel_lab_3d.py rename to cellacdc/volume_renderer/_demos/_demo_single_channel_lab_3d.py index c11bda58b..0f68970a1 100644 --- a/cellacdc/volume_renderer/_demo_single_channel_lab_3d.py +++ b/cellacdc/volume_renderer/_demos/_demo_single_channel_lab_3d.py @@ -8,28 +8,28 @@ from cellacdc._run import _setup_app from cellacdc import VolumeRendererWindow -from cellacdc import data_path +from cellacdc import myutils + +exp_folderpath = myutils.download_3d_renderer_demo_data() images_path = os.path.join( - data_path, - 'test_snapshots', - 'mtDNA_Anika', - 'Position_17', + exp_folderpath, + 'Position_3', 'Images', ) -mneon_filepath = os.path.join( - images_path, 'ASY15-1_0nM-17_s17_mNeon.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, 'ASY15-1_0nM-17_s17_mKate_mask.npz' + 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, 'ASY15-1_0nM-17_s17_metadata.csv' + images_path, 'FPY015-2_SCD-03_s03_metadata.csv' ) df_metadata = pd.read_csv(metadata_filepath, index_col='Description') @@ -39,8 +39,8 @@ float(df_metadata.at['PhysicalSizeX', 'values']) ) -volume = skimage.io.imread(mneon_filepath) -data = {'mNeon': volume} +volume = skimage.io.imread(mscarlet_filepath) +data = {'mScarlet-I3': volume} renderer = VolumeRendererWindow() renderer.set_volumes( 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 000000000..189d127aa --- /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 000000000..a08fd50d8 --- /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 index a7537606f..288e4a9de 100644 --- a/cellacdc/volume_renderer/_widgets.py +++ b/cellacdc/volume_renderer/_widgets.py @@ -83,4 +83,10 @@ def __init__(self, name='Volume Renderer Toolbar', parent=None): ) def is_single_channel_mode(self) -> bool: - return self.singleChannelCheckbox.isChecked() \ No newline at end of file + 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 index 59dc7e3b9..886bfb4a5 100644 --- a/cellacdc/volume_renderer/canvas.py +++ b/cellacdc/volume_renderer/canvas.py @@ -14,17 +14,18 @@ ) from qtpy.QtWidgets import ( QMainWindow, QHBoxLayout, QVBoxLayout, QWidget, QPushButton, - QGraphicsProxyWidget, QGroupBox, QCheckBox + QGraphicsProxyWidget, QGroupBox, QCheckBox, QMenu ) import pyqtgraph as pg from .. import printl from .._run import _setup_app -from .. import widgets +from .. import apps, widgets from .. import colors +from .. import plot -from . import _widgets, utils, plot +from . import _widgets, utils import vispy.scene import vispy.color @@ -65,19 +66,17 @@ class _ChannelData: class _PointsLayer: name: str - points: np.ndarray # (N, [z, y, x]) voxel coordinates + points_xyz: np.ndarray # (N, [x, y, z]) voxel coordinates labels: list[str] | None - df: pd.DataFrame | None=None markers: vispy.scene.visuals.Markers - text: vispy.scene.visuals.Text | None=None - visible: bool = True - color: vispy.color.Color = 'red' - size: float = 8.0 - symbol: str = 'disc' - opacity_slider: widgets.sliderWithSpinBox + 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): """ @@ -120,6 +119,8 @@ def __init__( self._canvas = None self._voxel_size = None self._lab_ncolors = 256 + self._voxel_size_transform = None + self._downsample_strides = None if app is None: app = QCoreApplication.instance() @@ -261,6 +262,30 @@ def _init_lab_ui_items( self._object_labels_list_layout.addStretch(1) + 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.setChecked(True) + + self._points_toolbar.addWidget(toolbutton) + + self._points_toolbar.show() + def _random_shuffle_lab_gradient_cmap(self): from vispy.color import Colormap as VisPyColormap @@ -350,6 +375,11 @@ def _init_ui(self): 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) @@ -421,15 +451,21 @@ def _resolve_max_texture_3d(self) -> int: self._max_texture_3d = 512 return self._max_texture_3d - @staticmethod - def _downsample(vol: np.ndarray, max_size: int) -> np.ndarray: + 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. """ - strides = tuple(max(1, int(np.ceil(s / max_size))) for s in vol.shape) + 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]]) @@ -753,8 +789,12 @@ def set_labels( cmap_name=cmap_name, gradient_item_state=gradient_item_state ) - - self.set_voxel_size(voxel_size) + + if self._voxel_size_transform is not None: + self._lab_node.transform = self._voxel_size_transform + else: + self.set_voxel_size(voxel_size) + self._is_labels_set = True set_segmentation_masks = set_labels @@ -791,21 +831,30 @@ def set_voxel_size(self, voxel_size: tuple[float, float, float] | None): voxel_size = self._voxel_size if voxel_size is None: - return + voxel_size = (1.0, 1.0, 1.0) self._voxel_size = voxel_size from vispy.visuals.transforms import STTransform - scale = utils.voxel_display_scale(*voxel_size) + 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_transform = transform 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 = transform + if self._canvas is not None: self._canvas.update() @@ -908,6 +957,20 @@ def add_points_layer( face_color=face_color, edge_color=edge_color ) + + markers.set_gl_state(depth_test=False) + + 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 + ) def show(self, block=False): self.resize(960, 720) diff --git a/cellacdc/widgets.py b/cellacdc/widgets.py index 4134c7749..b27070b73 100755 --- a/cellacdc/widgets.py +++ b/cellacdc/widgets.py @@ -71,6 +71,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 @@ -3391,6 +3392,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) @@ -3418,15 +3420,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) @@ -12158,4 +12160,30 @@ 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() + + \ No newline at end of file From c1902a37e25dcd1957319e24a660290525a7fe28 Mon Sep 17 00:00:00 2001 From: Francesco Date: Sun, 5 Jul 2026 13:42:09 +0200 Subject: [PATCH 21/34] WIP: adapting edit points layer dialog to vispy markers --- cellacdc/apps.py | 55 ++++++++++++++++++++++++++++++--------------- cellacdc/widgets.py | 2 +- 2 files changed, 38 insertions(+), 19 deletions(-) diff --git a/cellacdc/apps.py b/cellacdc/apps.py index 4711b2d0d..7a7d00f7a 100755 --- a/cellacdc/apps.py +++ b/cellacdc/apps.py @@ -638,9 +638,16 @@ def show(self, block=False): self.loop.exec_() class _PointsLayerAppearanceGroupbox(QGroupBox): - def __init__(self, *args): + def __init__( + self, + *args, + backend: Literal['pyqtgraph', 'vispy']='pyqtgraph', + is_3d=False + ): super().__init__(*args) + self._backend = backend + self.setTitle('Points appearance') layout = widgets.FormLayout() @@ -683,21 +690,10 @@ def __init__(self, *args): '----------------------------------------------------------------------' '----------------------------------------------------------------------' - 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) + if not is_3d: + row += 1 + self.addZheightSpinbox(row) + layout.addFormWidget(self.zHeightWidget, row=row) '----------------------------------------------------------------------' '----------------------------------------------------------------------' @@ -715,6 +711,22 @@ def __init__(self, *args): self.setLayout(layout) + 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 + ) + def restoreState(self, state): self.shortcutWidget.widget.setText(state['shortcut']) self.colorButton.setColor(state['color']) @@ -1417,18 +1429,25 @@ def showEvent(self, event) -> None: class EditPointsLayerAppearanceDialog(QBaseDialog): sigClosed = Signal() - def __init__(self, parent=None): + def __init__( + self, + backend: Literal['pyqtgraph', 'vispy']='pyqtgraph', + parent=None + ): self.cancel = True super().__init__(parent) self._parent = parent + self._backend = backend self.setWindowTitle('Custom annotation') self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) mainLayout = QVBoxLayout() - self.appearanceGroupbox = _PointsLayerAppearanceGroupbox() + self.appearanceGroupbox = _PointsLayerAppearanceGroupbox( + backend=self._backend + ) buttonsLayout = widgets.CancelOkButtonsLayout() diff --git a/cellacdc/widgets.py b/cellacdc/widgets.py index b27070b73..2ec44a68b 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 From 3b68bffdab5b25994e0e18df1d7d19f992f8d3d5 Mon Sep 17 00:00:00 2001 From: Francesco Date: Mon, 6 Jul 2026 08:21:19 +0200 Subject: [PATCH 22/34] WIP: adapting edit points layer dialog to vispy markers --- cellacdc/apps.py | 6 +++++- cellacdc/widgets.py | 6 ++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/cellacdc/apps.py b/cellacdc/apps.py index 7a7d00f7a..39cc4c1d8 100755 --- a/cellacdc/apps.py +++ b/cellacdc/apps.py @@ -658,8 +658,12 @@ def __init__( 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 ) diff --git a/cellacdc/widgets.py b/cellacdc/widgets.py index 2ec44a68b..9f61098c3 100755 --- a/cellacdc/widgets.py +++ b/cellacdc/widgets.py @@ -2233,6 +2233,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) From e0470c010b4f96cebc8255e393e62a9e124e7992 Mon Sep 17 00:00:00 2001 From: Francesco Date: Mon, 6 Jul 2026 14:04:58 +0200 Subject: [PATCH 23/34] feat: points layer with editing and removing options --- cellacdc/apps.py | 114 +++++++++++-- ..._demo_single_channel_lab_3d_with_points.py | 2 +- cellacdc/volume_renderer/canvas.py | 159 ++++++++++++++++-- cellacdc/widgets.py | 26 ++- 4 files changed, 270 insertions(+), 31 deletions(-) diff --git a/cellacdc/apps.py b/cellacdc/apps.py index 39cc4c1d8..21684d313 100755 --- a/cellacdc/apps.py +++ b/cellacdc/apps.py @@ -638,15 +638,20 @@ def show(self, block=False): self.loop.exec_() class _PointsLayerAppearanceGroupbox(QGroupBox): + sigValueChanged = Signal(object) + def __init__( self, *args, backend: Literal['pyqtgraph', 'vispy']='pyqtgraph', - is_3d=False + 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') @@ -662,12 +667,14 @@ def __init__( symbolCombobox = widgets.pgScatterSymbolsCombobox() elif backend == 'vispy': symbolCombobox = widgets.VisPyMarkersSymbolsCombobox() + self.symbolWidget = widgets.formWidget( symbolCombobox, addInfoButton=True, labelTextLeft='Symbol: ', parent=self, infoTxt=symbolInfoTxt, stretchWidget=False ) layout.addFormWidget(self.symbolWidget, row=row) + symbolCombobox.currentTextChanged.connect(self.emitSigValueChanged) '----------------------------------------------------------------------' '----------------------------------------------------------------------' @@ -680,6 +687,7 @@ def __init__( 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) '----------------------------------------------------------------------' '----------------------------------------------------------------------' @@ -691,9 +699,11 @@ def __init__( labelTextLeft='Size: ', parent=self ) layout.addFormWidget(self.sizeWidget, row=row) + self.sizeSpinBox.valueChanged.connect(self.emitSigValueChanged) '----------------------------------------------------------------------' '----------------------------------------------------------------------' + self.zHeightSpinBox = widgets.DummyWidget() if not is_3d: row += 1 self.addZheightSpinbox(row) @@ -712,9 +722,34 @@ def __init__( ) 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 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 ' @@ -730,13 +765,23 @@ def addZheightSpinbox(self, row: int): labelTextLeft='Z-depth: ', parent=self, toolTip=zHeightTooltip ) + self.zHeightSpinBox.valueChanged.connect(self.emitSigValueChanged) - 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 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() @@ -757,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): @@ -1432,10 +1482,15 @@ def showEvent(self, event) -> None: class EditPointsLayerAppearanceDialog(QBaseDialog): sigClosed = Signal() + sigValueChanged = Signal(object) 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 @@ -1443,15 +1498,22 @@ def __init__( 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( - backend=self._backend + backend=backend, + is_3d=is_3d, + add_opacity_slider=add_opacity_slider ) + + self.appearanceGroupbox.sigValueChanged.connect(self.emitValueChanged) buttonsLayout = widgets.CancelOkButtonsLayout() @@ -1466,13 +1528,24 @@ def __init__( self.setFont(font) + def emitValueChanged(self, state): + self.sigValueChanged.emit(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 @@ -1480,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/volume_renderer/_demos/_demo_single_channel_lab_3d_with_points.py b/cellacdc/volume_renderer/_demos/_demo_single_channel_lab_3d_with_points.py index 189d127aa..20bf78e29 100644 --- 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 @@ -57,7 +57,7 @@ renderer = VolumeRendererWindow() renderer.set_volumes( data, - # voxel_size=voxel_size + voxel_size=voxel_size ) renderer.set_labels(lab) renderer.add_points_layer( diff --git a/cellacdc/volume_renderer/canvas.py b/cellacdc/volume_renderer/canvas.py index 886bfb4a5..9d9d82be9 100644 --- a/cellacdc/volume_renderer/canvas.py +++ b/cellacdc/volume_renderer/canvas.py @@ -118,8 +118,9 @@ def __init__( self._lab_node = None self._canvas = None self._voxel_size = None - self._lab_ncolors = 256 self._voxel_size_transform = None + self._lab_ncolors = 256 + self._voxel_size_strides_transform = None self._downsample_strides = None if app is None: @@ -280,11 +281,33 @@ def _init_points_layer_ui_items( toolbutton = widgets.PointsLayerToolButton( symbol, rgb_color, parent=self ) + toolbutton.setCheckable(True) toolbutton.setChecked(True) - self._points_toolbar.addWidget(toolbutton) - + 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 @@ -748,6 +771,60 @@ def _set_single_channel(self, single: bool): self._set_visiblity(update=True) + def _update_points_layer_properties(self, points_state, points_layer=None): + 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) + + markers = points_layer.markers + markers.set_data( + points_layer.points_xyz, + symbol=symbol, + size=size, + face_color=face_color, + edge_color=edge_color + ) + 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, @@ -790,10 +867,10 @@ def set_labels( gradient_item_state=gradient_item_state ) - if self._voxel_size_transform is not None: - self._lab_node.transform = self._voxel_size_transform + if self._voxel_size_strides_transform is not None: + self._lab_node.transform = self._voxel_size_strides_transform else: - self.set_voxel_size(voxel_size) + self.set_voxel_size_strides_transform(voxel_size) self._is_labels_set = True @@ -826,7 +903,10 @@ def set_volume( lut_items_states=lut_items_states ) - def set_voxel_size(self, voxel_size: tuple[float, float, float] | None): + def set_voxel_size_strides_transform( + self, + voxel_size: tuple[float, float, float] | None + ): if voxel_size is None: voxel_size = self._voxel_size @@ -844,7 +924,8 @@ def set_voxel_size(self, voxel_size: tuple[float, float, float] | None): sz * self._downsample_strides[0], ) transform = STTransform(scale=scale) - self._voxel_size_transform = transform + 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 @@ -853,7 +934,7 @@ def set_voxel_size(self, voxel_size: tuple[float, float, float] | None): self._lab_node.transform = transform for points_layer in self._points_layers.values(): - points_layer.markers.transform = transform + points_layer.markers.transform = self._voxel_size_transform if self._canvas is not None: self._canvas.update() @@ -911,7 +992,11 @@ def set_volumes( self._set_gl_blend_states() - self.set_voxel_size(voxel_size) + 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() @@ -928,6 +1013,12 @@ def add_points_layer( symbol: MarkerSymbols='disc', 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: @@ -959,8 +1050,10 @@ def add_points_layer( ) markers.set_gl_state(depth_test=False) + if self._voxel_size_transform is not None: + markers.transform = self._voxel_size_transform - self._init_points_layer_ui_items( + ui_items = self._init_points_layer_ui_items( markers, name, points_xyz, @@ -971,6 +1064,45 @@ def add_points_layer( symbol=symbol, points_df=points_df ) + + toolbutton, properties_dialog, context_menu = ui_items + + points_layer = _PointsLayer( + name=name, + points_xyz=points_xyz, + 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 + ) + ) + + self._points_layers[name] = points_layer def show(self, block=False): self.resize(960, 720) @@ -1004,7 +1136,10 @@ def closeEvent(self, event): 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() diff --git a/cellacdc/widgets.py b/cellacdc/widgets.py index 9f61098c3..07f2552e9 100755 --- a/cellacdc/widgets.py +++ b/cellacdc/widgets.py @@ -3490,8 +3490,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() @@ -3508,8 +3509,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) @@ -12192,4 +12196,18 @@ def emitSigEditProperties(self): def emitSigRemoveAction(self): self.sigRemove.emit() - \ No newline at end of file +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 From bc863c5845d04f54041d3307190025c713aab678 Mon Sep 17 00:00:00 2001 From: Francesco Date: Mon, 6 Jul 2026 16:27:11 +0200 Subject: [PATCH 24/34] feat: hide unselected or focus on selected diplay option --- cellacdc/apps.py | 4 +- cellacdc/volume_renderer/canvas.py | 168 +++++++++++++++++++++++++++-- 2 files changed, 160 insertions(+), 12 deletions(-) diff --git a/cellacdc/apps.py b/cellacdc/apps.py index 21684d313..83d2e244a 100755 --- a/cellacdc/apps.py +++ b/cellacdc/apps.py @@ -1528,8 +1528,8 @@ def __init__( self.setFont(font) - def emitValueChanged(self, state): - self.sigValueChanged.emit(state) + def emitValueChanged(self, *args): + self.sigValueChanged.emit(self.appearanceGroupbox.state()) def restoreState(self, state): self.appearanceGroupbox.restoreState(state) diff --git a/cellacdc/volume_renderer/canvas.py b/cellacdc/volume_renderer/canvas.py index 9d9d82be9..3c8b666b2 100644 --- a/cellacdc/volume_renderer/canvas.py +++ b/cellacdc/volume_renderer/canvas.py @@ -14,7 +14,8 @@ ) from qtpy.QtWidgets import ( QMainWindow, QHBoxLayout, QVBoxLayout, QWidget, QPushButton, - QGraphicsProxyWidget, QGroupBox, QCheckBox, QMenu + QGraphicsProxyWidget, QGroupBox, QCheckBox, QMenu, QRadioButton, + QButtonGroup ) import pyqtgraph as pg @@ -55,10 +56,15 @@ @dataclass class _ChannelData: node: vispy.scene.visuals.Volume - lut_item: widgets.baseHistogramLUTitem + 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 @@ -67,6 +73,7 @@ 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 @@ -116,6 +123,7 @@ def __init__( self._gradient_item_state = None self._lab_gradient_cmap_name = None self._lab_node = None + self._orig_lab = None self._canvas = None self._voxel_size = None self._voxel_size_transform = None @@ -252,17 +260,118 @@ def _init_lab_ui_items( 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) ) 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) + + self._display_mode_hide_unselected_rb = QRadioButton( + 'Hide unselected', self + ) + self._display_mode_focus_selected_rb = QRadioButton( + 'Focus on selected', self + ) + self._display_mode_hide_unselected_rb.setChecked(True) + + self._display_mode_focus_selected_rb.toggled.connect( + self._display_mode_radio_button_toggled + ) + + 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, toggled: bool): + 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(): + 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, @@ -351,8 +460,7 @@ def _set_object_checked(self, checked: bool, obj=None): self._lab[obj.slice][obj.image] = _id self._lab_node.set_data(self._lab) - - self._canvas.update() + self._update_display(exclude_lab=True) def _on_reset_lab_gradient(self, *args): if self._lab_gradient_item_state is not None: @@ -428,14 +536,20 @@ def _init_ui(self): self._object_labels_list_layout ) + 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.addWidget( - self._object_labels_list_scrollarea, stretch=1 + self._scene_layout.addLayout( + self._right_vertical_layout, stretch=1 ) self._controls_groupbox = QGroupBox('Controls') @@ -608,11 +722,21 @@ def _set_channels_data( ) 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.9 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, @@ -771,7 +895,9 @@ def _set_single_channel(self, single: bool): self._set_visiblity(update=True) - def _update_points_layer_properties(self, points_state, points_layer=None): + def _update_points_layer_properties( + self, points_state, points_layer=None, update=True + ): if points_layer is None: return @@ -783,15 +909,27 @@ def _update_points_layer_properties(self, points_state, points_layer=None): 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_layer.points_xyz, + points_xyz, symbol=symbol, size=size, face_color=face_color, edge_color=edge_color ) - self._canvas.update() + + if update: + self._canvas.update() def _toggle_points_layer(self, checked, points_layer=None): if points_layer is None: @@ -858,6 +996,7 @@ def set_labels( 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) @@ -1053,6 +1192,14 @@ def add_points_layer( 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, @@ -1070,6 +1217,7 @@ def add_points_layer( points_layer = _PointsLayer( name=name, points_xyz=points_xyz, + _label_ids=_label_ids, labels=labels, markers=markers, toolbutton=toolbutton, From 0414de6f1ba308dddaa3dcbe514541245fb4d882 Mon Sep 17 00:00:00 2001 From: Francesco Date: Mon, 6 Jul 2026 17:31:27 +0200 Subject: [PATCH 25/34] WIP: add points layer to volume renderer from gui --- cellacdc/gui.py | 40 ++++++++++++++++++++++++++++++ cellacdc/plot.py | 14 +++++++++++ cellacdc/volume_renderer/canvas.py | 3 +++ 3 files changed, 57 insertions(+) diff --git a/cellacdc/gui.py b/cellacdc/gui.py index 1f00af83f..d8fb8df66 100755 --- a/cellacdc/gui.py +++ b/cellacdc/gui.py @@ -3205,6 +3205,8 @@ def launch3dViewerToggled(self, checked): 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) @@ -24312,6 +24314,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 @@ -24669,6 +24672,43 @@ 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 and computePointsLayers: + self.getCentroidsPointsData(action) + + frames = action.pointsData.get(self.pos_i, set()) + if posData.frame_i not in frames: + continue + + 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( + name, + 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: diff --git a/cellacdc/plot.py b/cellacdc/plot.py index 31e657982..0d7570f68 100644 --- a/cellacdc/plot.py +++ b/cellacdc/plot.py @@ -45,6 +45,20 @@ '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: diff --git a/cellacdc/volume_renderer/canvas.py b/cellacdc/volume_renderer/canvas.py index 3c8b666b2..e27898193 100644 --- a/cellacdc/volume_renderer/canvas.py +++ b/cellacdc/volume_renderer/canvas.py @@ -1150,6 +1150,7 @@ def add_points_layer( 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(): @@ -1249,6 +1250,8 @@ def add_points_layer( self._toggle_points_layer, points_layer=points_layer ) ) + if not visible: + toolbutton.setChecked(False) self._points_layers[name] = points_layer From b631b2c297a897a0d9f061c707fa53d833b28f74 Mon Sep 17 00:00:00 2001 From: Francesco Date: Tue, 7 Jul 2026 11:39:03 +0200 Subject: [PATCH 26/34] feat: show all radio button to show raw volume --- cellacdc/gui.py | 4 +- cellacdc/volume_renderer/canvas.py | 59 +++++++++++++++++++++++++++--- 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/cellacdc/gui.py b/cellacdc/gui.py index d8fb8df66..bb58962f8 100755 --- a/cellacdc/gui.py +++ b/cellacdc/gui.py @@ -3140,6 +3140,8 @@ def zProjLockViewToggled(self, checked): self.updateZproj(self.zProjComboBox.currentText()) def launch3dViewerToggled(self, checked): + self.logger.info('Launching 3D volume viewer...') + myutils.check_install_package( 'VisPy', import_pkg_name='vispy', @@ -3178,7 +3180,7 @@ def launch3dViewerToggled(self, checked): volumes[channel_name] = volume overlayItems = self.overlayLayersItems[channel_name] - lutItem = overlayItems[2] + lutItem = overlayItems[1] lut_items_states[channel_name] = lutItem.super_saveState() voxel_size = ( diff --git a/cellacdc/volume_renderer/canvas.py b/cellacdc/volume_renderer/canvas.py index e27898193..1f05885b2 100644 --- a/cellacdc/volume_renderer/canvas.py +++ b/cellacdc/volume_renderer/canvas.py @@ -130,6 +130,7 @@ def __init__( self._lab_ncolors = 256 self._voxel_size_strides_transform = None self._downsample_strides = None + self._object_labels_list_buttongroup = None if app is None: app = QCoreApplication.instance() @@ -271,6 +272,7 @@ def _init_lab_ui_items( obj_checkbox.toggled.connect( partial(self._set_object_checked, obj=obj) ) + obj_checkbox.setDisabled(True) self._object_labels_list_layout.addStretch(1) @@ -286,24 +288,43 @@ def _init_lab_ui_items( 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_hide_unselected_rb.setChecked(True) - - self._display_mode_focus_selected_rb.toggled.connect( + 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, toggled: bool): + 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): @@ -338,6 +359,11 @@ def _update_markers(self, update=True): 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 @@ -535,6 +561,7 @@ def _init_ui(self): 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( @@ -568,6 +595,28 @@ def _init_ui(self): 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() @@ -729,7 +778,7 @@ def _set_channels_data( np.percentile(_raw_volume, 99.9) / np.percentile(_off_focus_volume, 99.9) ) - _off_focus_volume *= 0.9 + _off_focus_volume *= 0.8 channel_data = _ChannelData( node=node, From d30a553aeae676b9f6d6191fc0cb5b37dbf5812c Mon Sep 17 00:00:00 2001 From: Francesco Date: Tue, 7 Jul 2026 11:45:02 +0200 Subject: [PATCH 27/34] chore: add multi channel demo with points --- .../_demo_multi_channel_lab_3d_with_points.py | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 cellacdc/volume_renderer/_demos/_demo_multi_channel_lab_3d_with_points.py 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 000000000..6d846b778 --- /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 From 32be2c23985320d9e79509a429da66dfc9b8f0e0 Mon Sep 17 00:00:00 2001 From: Francesco Date: Tue, 7 Jul 2026 15:49:54 +0200 Subject: [PATCH 28/34] fix: install PyOpenGL when running 3d viewer first time --- cellacdc/gui.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cellacdc/gui.py b/cellacdc/gui.py index 2fc7cb84d..fb88e38c4 100755 --- a/cellacdc/gui.py +++ b/cellacdc/gui.py @@ -3141,6 +3141,13 @@ def zProjLockViewToggled(self, checked): def launch3dViewerToggled(self, checked): self.logger.info('Launching 3D volume viewer...') + myutils.check_install_package( + 'PyOpenGL', + import_pkg_name='pyopengl', + pypi_name='pyopengl', + parent=self, + ) + myutils.check_install_package( 'VisPy', import_pkg_name='vispy', From 147440f89660d2772e482093529781ef9d374cc3 Mon Sep 17 00:00:00 2001 From: Francesco Date: Tue, 7 Jul 2026 15:52:07 +0200 Subject: [PATCH 29/34] WIP: fixing demo only labels --- cellacdc/volume_renderer/canvas.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/cellacdc/volume_renderer/canvas.py b/cellacdc/volume_renderer/canvas.py index 1f05885b2..395efd5a3 100644 --- a/cellacdc/volume_renderer/canvas.py +++ b/cellacdc/volume_renderer/canvas.py @@ -1357,10 +1357,15 @@ def reset_view(self): def set_camera_view(self): """Reset the camera to the default orientation and fit the volume.""" - 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 + if self._channels_data: + 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 + else: + first_node = self._lab_node + Z, Y, X = self._lab.shape + xyz_center = (X/2, Y/2, Z/2) corners = np.array([ [0, 0, 0], From b012b75114c629c9755621a206b495c29bca4d45 Mon Sep 17 00:00:00 2001 From: Francesco Date: Tue, 7 Jul 2026 16:56:33 +0200 Subject: [PATCH 30/34] fix: check import OpenGL correctly --- cellacdc/gui.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cellacdc/gui.py b/cellacdc/gui.py index fb88e38c4..e26763fd2 100755 --- a/cellacdc/gui.py +++ b/cellacdc/gui.py @@ -3143,8 +3143,8 @@ def launch3dViewerToggled(self, checked): myutils.check_install_package( 'PyOpenGL', - import_pkg_name='pyopengl', - pypi_name='pyopengl', + import_pkg_name='OpenGL', + pypi_name='PyOpenGL', parent=self, ) From 4e766237014814721c44883826d98457d0c9a8e6 Mon Sep 17 00:00:00 2001 From: Francesco Date: Tue, 7 Jul 2026 16:57:32 +0200 Subject: [PATCH 31/34] fix: reset lab gradient item --- cellacdc/volume_renderer/canvas.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/cellacdc/volume_renderer/canvas.py b/cellacdc/volume_renderer/canvas.py index 395efd5a3..f686acd5c 100644 --- a/cellacdc/volume_renderer/canvas.py +++ b/cellacdc/volume_renderer/canvas.py @@ -241,11 +241,7 @@ def _init_lab_ui_items( 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( - partial( - self._on_reset_lab_gradient, - lab_gradient_item=lab_gradient_item) - ) + lab_reset_btn.clicked.connect(self._on_reset_lab_gradient) lab_reset_btn.setMaximumWidth( ceil(lab_gradient_item.sizeHint().width())) @@ -488,7 +484,7 @@ def _set_object_checked(self, checked: bool, obj=None): self._lab_node.set_data(self._lab) self._update_display(exclude_lab=True) - def _on_reset_lab_gradient(self, *args): + 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) From 75a13566b6c3130ddb335506eafe02d49ee6adf7 Mon Sep 17 00:00:00 2001 From: Francesco Date: Tue, 7 Jul 2026 17:03:22 +0200 Subject: [PATCH 32/34] fix: lint error --- cellacdc/gui.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cellacdc/gui.py b/cellacdc/gui.py index e26763fd2..b18a4ae5e 100755 --- a/cellacdc/gui.py +++ b/cellacdc/gui.py @@ -24694,13 +24694,14 @@ def addPointsLayersToVolumeViewer(self, volume_renderer): if not hasattr(action, 'layerTypeIdx'): continue - if action.layerTypeIdx < 2 and computePointsLayers: + 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']) @@ -24713,7 +24714,7 @@ def addPointsLayersToVolumeViewer(self, volume_renderer): ) points_zyx = np.column_stack((zz, yy, xx)) volume_renderer.add_points_layer( - name, + str(id(action.button)), points=points_zyx, color=color, size=action.pointSize, From e337e18b7d4b108aaeda043a732fd4b15f853d48 Mon Sep 17 00:00:00 2001 From: Francesco Date: Tue, 7 Jul 2026 17:09:07 +0200 Subject: [PATCH 33/34] fix: view only segm labels in 3d viewer --- cellacdc/volume_renderer/canvas.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/cellacdc/volume_renderer/canvas.py b/cellacdc/volume_renderer/canvas.py index f686acd5c..46ce3eef8 100644 --- a/cellacdc/volume_renderer/canvas.py +++ b/cellacdc/volume_renderer/canvas.py @@ -122,6 +122,7 @@ def __init__( 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 @@ -1352,16 +1353,16 @@ def reset_view(self): def set_camera_view(self): """Reset the camera to the default orientation and fit the volume.""" - - if self._channels_data: - 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 - else: - first_node = self._lab_node + 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], From 40eba5a4f809358af2c0bdfd511e0f32ea6c83bb Mon Sep 17 00:00:00 2001 From: Francesco Date: Tue, 7 Jul 2026 18:46:25 +0200 Subject: [PATCH 34/34] fix: address copilot comments --- cellacdc/gui.py | 2 +- cellacdc/volume_renderer/canvas.py | 43 +++++++++++++++++++++++++----- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/cellacdc/gui.py b/cellacdc/gui.py index 7740a9793..6d6b60659 100755 --- a/cellacdc/gui.py +++ b/cellacdc/gui.py @@ -3220,7 +3220,7 @@ def launch3dViewerToggled(self, checked): self._volume_renderer.raise_() self._volume_renderer.activateWindow() - def onClose3dViewer(self): + def onClose3dViewer(self, *args, **kwargs): self.launch3dViewerAction.setChecked(False) def onUpdate3dViewer(self): diff --git a/cellacdc/volume_renderer/canvas.py b/cellacdc/volume_renderer/canvas.py index 46ce3eef8..e0a32e2cb 100644 --- a/cellacdc/volume_renderer/canvas.py +++ b/cellacdc/volume_renderer/canvas.py @@ -132,6 +132,7 @@ def __init__( 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() @@ -854,6 +855,8 @@ 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() @@ -892,7 +895,7 @@ def _on_auto_clim(self, channel_data: _ChannelData) -> None: lut_item = channel_data.lut_item if len(lut_item.gradient.listTicks()) != 2: - self.logger.info( + self._logger_func( '[WARNING]: Auto contrast is available only with LUTs ' 'that have two ticks.' ) @@ -1024,6 +1027,16 @@ def set_labels( ) 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) @@ -1055,7 +1068,7 @@ def set_labels( 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._set_voxel_size_strides_transform(voxel_size) self._is_labels_set = True @@ -1076,7 +1089,7 @@ def set_volume( cmaps = None lut_items_states = None if lut_item_state is not None: - lut_items_states = {channel_name: cmap} + lut_items_states = {channel_name: lut_item_state} elif cmap is not None: cmaps = {channel_name: cmap} @@ -1085,10 +1098,11 @@ def set_volume( self.set_volumes( volumes, cmaps=cmaps, - lut_items_states=lut_items_states + lut_items_states=lut_items_states, + voxel_size=voxel_size ) - def set_voxel_size_strides_transform( + def _set_voxel_size_strides_transform( self, voxel_size: tuple[float, float, float] | None ): @@ -1098,6 +1112,9 @@ def set_voxel_size_strides_transform( 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 @@ -1136,6 +1153,17 @@ def set_volumes( 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) @@ -1181,7 +1209,7 @@ def set_volumes( 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._set_voxel_size_strides_transform(voxel_size) self._canvas.update() @@ -1212,7 +1240,8 @@ def add_points_layer( if points_df is not None: points_xyz = points_df[zyx_columns_names[::-1]].to_numpy() - elif points: + elif points is not None: + points = np.asarray(points) points_xyz = points[:, [2, 1, 0]] else: raise ValueError(