diff --git a/.features/3drenderer/README.md b/.features/3drenderer/README.md new file mode 100644 index 00000000..c0250a54 --- /dev/null +++ b/.features/3drenderer/README.md @@ -0,0 +1,132 @@ +# 3D Renderer Feature Roadmap + +Branch: `improvements_3d_renderer` · Fork HEAD: [`14edb05c`](https://github.com/SchmollerLab/Cell_ACDC/commit/14edb05c) · PR: [#1102](https://github.com/SchmollerLab/Cell_ACDC/pull/1102) + +This folder tracks the 3D Z-stack renderer feature request in batches. The renderer lives in [`cellacdc/renderer3d.py`](../../cellacdc/renderer3d.py); the main GUI wires it through [`cellacdc/gui.py`](../../cellacdc/gui.py). + +## Commits to honor + +Upstream work by **ElpadoCan** on `improvements_3d_renderer` is the baseline this fork extends. When adding batches 2–4, preserve the multi-channel `_volume_nodes` model, overlay keys under `overlay:N` in the same dict, and colormap helpers in [`cellacdc/colors.py`](../../cellacdc/colors.py) (`vispy_cmap_from_spec` / `pg_to_vispy_cmap`). Do **not** revert Francesco's unified volume-node design or reintroduce `_ZPROJMODE_3D`. + +| Commit | Message | Intent | Status on branch | +|--------|---------|--------|------------------| +| `a3ad90e9` | feat: add GPU-accelerated 3D z-stack renderer | Initial renderer | **Preserved** | +| `6e9efa5f` | fix: install vispy and PyOpenGL for CI tests | `requirements_test.txt` + CI workflows | **Honored** — still present | +| `0f64a810` | fix: test_rendered3d failing | Test fixes | Superseded by refactor; patterns updated | +| `3f0d6f2a` | fix: test_rendered3d failing because of GUI init | GUI init in tests | Superseded by refactor | +| `ca5f179d` | fix: test_rendered3d failing because not closing gui elements | Test cleanup | Superseded by refactor | +| `5fdb9237` | ci: ignore test_renderer3d because it requires a GUI environment | Module-level `pytest.skip` | **Honored** — restored in `14edb05c` | +| `537cd967` | WIP: improving UI of 3D renderer | Remove `_ZPROJMODE_3D`, decouple 3D from z-proj dropdown | **Honored** — no `_ZPROJMODE_3D` in codebase | +| `1dd00e8e` | improvement: use launch 3d renderer button with 3d icon | `launch3dRendererButton` / `launch3dRendererAction` with `:3d.svg` | **Honored** in `gui.py` | +| `391377c1` | WIP: support for multiple overlay volumes | Overlay volume support | **Evolved** — unified `_volume_nodes` with `overlay:N` keys (not parallel list) in `e9c6311b` | +| `e8aceb63` | broken: multiple volume nodes one per channel | Multi-channel volume model | **Fixed/evolved** in `e9c6311b` — per-channel nodes + batch 1 completion | +| `e9c6311b` | Complete 3D renderer batch 1 on the multi-channel volume model | Batch 1 deliverables | Current baseline | +| `14edb05c` | Honor ElpadoCan CI and renderer UI conventions | CI skip + `relative_step_size=current_step` | **Latest on fork** | + +See [batch-1-done.md — Honored commits mapping](batch-1-done.md#honored-commits-mapping) for how each honored commit maps to delivered features. + +## Key files + +| File | Role | +|------|------| +| [`cellacdc/renderer3d.py`](../../cellacdc/renderer3d.py) | `VolumeRenderer3DWindow`, controls, LUT, overlay volume nodes | +| [`cellacdc/gui.py`](../../cellacdc/gui.py) | Launch adapter, `_get_overlay_zstacks()`, frame sync | +| [`cellacdc/widgets.py`](../../cellacdc/widgets.py) | `VolumeRendererToolbar`, `baseHistogramLUTitem`, `myHistogramLUTitem` | +| [`cellacdc/colors.py`](../../cellacdc/colors.py) | `pg_to_vispy_cmap`, `vispy_cmap_from_spec` | +| [`tests/test_renderer3d.py`](../../tests/test_renderer3d.py) | Smoke tests (module-level CI skip) | + +## Feature progress + +**Summary:** 16 Done · 0 Partial · 2 Not started (batch 4) + +| # | Feature | Status | Batch | Notes | +|---|---------|--------|-------|-------| +| 1 | "Clim:" colorbar slider (main GUI parity) | **Done** | [Batch 2](batch-2-lut-overlays.md) | `myHistogramLUTitem` with `Clim:` label | +| 2 | Auto + Full LUT buttons | **Done** | [Batch 1](batch-1-done.md) | | +| 3 | Colormap from LUT slider | **Done** | [Batch 1](batch-1-done.md) | `pg_to_vispy_cmap` path | +| 4 | Gamma slider + numeric control | **Done** | [Batch 1](batch-1-done.md) | | +| 5 | Step slider + numeric control | **Done** | [Batch 1](batch-1-done.md) | | +| 6 | Primary opacity (right-side grayscale colorbar) | **Done** | [Batch 2](batch-2-lut-overlays.md) | Right-side opacity LUT; form sliders removed | +| 7 | Home button with icon in toolbar | **Done** | [Batch 1](batch-1-done.md) | | +| 8 | Top toolbar (Home + Save) | **Done** | [Batch 1](batch-1-done.md) | | +| 9 | Shortcut `H` for home | **Done** | [Batch 1](batch-1-done.md) | | +| 10 | Overlaid segmentation masks | **Done** | [Batch 1](batch-1-done.md) | Data path via `_get_overlay_zstacks()` | +| 11 | Overlaid fluorescence channels | **Done** | [Batch 1](batch-1-done.md) | Data path via `_get_overlay_zstacks()` | +| 12 | Opacity sliders for overlay channels | **Done** | [Batch 2](batch-2-lut-overlays.md) | In-renderer + bidirectional sync with main GUI | +| 13 | LUT sliders for overlay channels | **Done** | [Batch 2](batch-2-lut-overlays.md) | PG gradient from main GUI `lutItem` | +| 14 | Segmentation mask opacity slider in 3D UI | **Done** | [Batch 2](batch-2-lut-overlays.md) | Overlays panel + sync with `labelsAlphaSlider` | +| 15 | Cell ID selector (show one cell) | **Done** | [Batch 3](batch-3-cell-id.md) | Spinbox + Show all; `_label_volumes` masking | +| 16 | Clickable Cell ID (show one cell) | **Done** | [Batch 3](batch-3-cell-id.md) | Shift+left-click pick on canvas | +| 17 | z-anisotropy via `scipy.ndimage.zoom` | **Not started** | [Batch 4](batch-4-z-anisotropy.md) | Transform-only scaling today | +| 18 | z-anisotropy numeric control | **Not started** | [Batch 4](batch-4-z-anisotropy.md) | | + +## Batch status + +| Batch | Theme | Progress | Status | Doc | +|-------|-------|----------|--------|-----| +| 1 | Core renderer, toolbar, primary LUT, overlay data path, launch button (`:3d.svg`) | 10 / 10 scope items | **Complete** | [batch-1-done.md](batch-1-done.md) | +| 2 | LUT polish + in-renderer overlay UI + live sync | 5 / 5 checklist items | **Complete** | [batch-2-lut-overlays.md](batch-2-lut-overlays.md) | +| 3 | Cell ID isolation (selector + Shift+click pick) | 2 / 2 checklist items | **Complete** | [batch-3-cell-id.md](batch-3-cell-id.md) | +| 4 | z-anisotropy UI + `ndimage.zoom` resampling | 0 / 2 checklist items | **Planned** | [batch-4-z-anisotropy.md](batch-4-z-anisotropy.md) | + +## Data flow + +```mermaid +flowchart LR + subgraph MainGUI["gui.py (guiWin)"] + ZStack["_get_current_zstack()"] + Overlays["_get_overlay_zstacks()"] + Voxels["_get_current_voxel_sizes()"] + Adapter["_GuiWinRenderer3DAdapter"] + FrameSync["_update_3d_renderer_if_active()"] + end + + subgraph Renderer["renderer3d.py (VolumeRenderer3DWindow)"] + UpdateVol["update_volume()"] + UpdateOvl["update_overlay_volumes()"] + SetScale["set_voxel_scale()"] + VolNodes["_volume_nodes"] + Primary["primary channel(s)"] + Overlay["overlay:N keys"] + LUT["lut_items + VolumeRendererControls"] + Toolbar["VolumeRendererToolbar"] + end + + Launch["_launch_3d_renderer()"] --> UpdateVol + Launch --> UpdateOvl + Launch --> SetScale + FrameSync --> UpdateVol + FrameSync --> UpdateOvl + FrameSync --> SetScale + + ZStack --> UpdateVol + Overlays --> UpdateOvl + Voxels --> SetScale + Adapter -.-> Launch + + UpdateVol --> Primary + UpdateOvl --> Overlay + Primary --> VolNodes + Overlay --> VolNodes + LUT --> Primary + Toolbar --> Renderer +``` + +## Architecture notes + +### Primary vs overlay colormap paths + +- **Primary channel:** PyQtGraph LUT (`baseHistogramLUTitem`) → [`pg_to_vispy_cmap`](../../cellacdc/colors.py) → vispy `Volume.cmap`. Contrast limits come from gradient tick positions via `set_clim` / `set_cmap`. +- **Overlay channels:** Hardcoded colour names from [`_get_overlay_zstacks()`](../../cellacdc/gui.py) → [`vispy_cmap_from_spec`](../../cellacdc/colors.py) (black→colour ramp for plain names). Overlay LUT widgets in the main GUI are **not** read today. + +### 2D/3D overlay colour mismatch + +[`_get_overlay_zstacks()`](../../cellacdc/gui.py) assigns colours from `_FLUO_CMAPS` / `_LABEL_CMAPS` by index. It ignores each overlay's `lutItem` gradient (the two-colour ramp built in [`getOverlayItems()`](../../cellacdc/gui.py)). Batch 2 should align 3D overlay LUTs with the main GUI pattern. + +### Shared rendering parameters + +Overlay volumes inherit global **gamma**, **step**, and **interpolation** from `VolumeRendererControls`, not per-overlay LUT settings. See `_init_overlay_volume_node()` in [`renderer3d.py`](../../cellacdc/renderer3d.py). + +### z-anisotropy today + +Physical voxel sizes flow from metadata via `_get_current_voxel_sizes()` → `set_voxel_scale()` → vispy `STTransform`. No user-facing control and no `scipy.ndimage.zoom` resampling yet. See [batch-4-z-anisotropy.md](batch-4-z-anisotropy.md). diff --git a/.features/3drenderer/batch-1-done.md b/.features/3drenderer/batch-1-done.md new file mode 100644 index 00000000..0c00a4f3 --- /dev/null +++ b/.features/3drenderer/batch-1-done.md @@ -0,0 +1,125 @@ +# Batch 1 — Completed + +Core 3D renderer infrastructure, primary-channel controls, toolbar, and overlay **data** path. UI for per-overlay LUT/opacity and several parity items remain for Batch 2. + +Baseline commit: [`e9c6311b`](https://github.com/SchmollerLab/Cell_ACDC/commit/e9c6311b) · Fork HEAD: [`14edb05c`](https://github.com/SchmollerLab/Cell_ACDC/commit/14edb05c) + +## Honored commits mapping + +How upstream ElpadoCan commits map to batch 1 deliverables on this branch. Full table: [README — Commits to honor](README.md#commits-to-honor). + +| Commit | Delivered feature(s) | Where in codebase | +|--------|----------------------|-------------------| +| `a3ad90e9` | Initial GPU 3D z-stack renderer | [`renderer3d.py`](../../cellacdc/renderer3d.py) — `VolumeRenderer3DWindow`, vispy canvas | +| `6e9efa5f` | CI test dependencies (vispy, PyOpenGL) | `requirements_test.txt`, CI workflows | +| `5fdb9237` | Module-level GUI skip for CI | [`tests/test_renderer3d.py`](../../tests/test_renderer3d.py) — restored in `14edb05c` | +| `537cd967` | Decouple 3D from z-projection dropdown | No `_ZPROJMODE_3D`; launch via dedicated button only | +| `1dd00e8e` | Launch 3D renderer button with `:3d.svg` | [`gui.py`](../../cellacdc/gui.py) — `launch3dRendererButton`, `launch3dRendererAction` | +| `391377c1` | Multiple overlay volumes | `_volume_nodes` dict with `overlay:N` keys (evolved from parallel list) | +| `e8aceb63` | Multi-channel volume nodes (one per channel) | Per-channel primary keys in `_volume_nodes`; fixed in `e9c6311b` | +| `e9c6311b` | Batch 1 completion on multi-channel model | Checklist items 2–11 below; colormap helpers in [`colors.py`](../../cellacdc/colors.py) | +| `14edb05c` | CI + renderer UI conventions | `pytest.skip` restore; `relative_step_size=current_step` in renderer | + +**Superseded (refactor, patterns retained):** `0f64a810`, `3f0d6f2a`, `ca5f179d` — test fixes for GUI init/cleanup; current tests follow the same intent with updated structure. + +**Preservation rules for later batches:** keep unified `_volume_nodes` (primary + `overlay:N`); keep `vispy_cmap_from_spec` / `pg_to_vispy_cmap` in `colors.py`; do not reintroduce `_ZPROJMODE_3D`. + +## Checklist items delivered + +| # | Feature | Implementation | +|---|---------|----------------| +| 2 | Auto + Full LUT buttons | `_add_lut_items()`, `_on_auto_clim()`, `_on_full_clim()` | +| 3 | Colormap from LUT slider | `_on_lut_changed()` → `set_cmap()` → `pg_to_vispy_cmap` | +| 4 | Gamma slider + numeric | `VolumeRendererControls._gamma_spin` (`sliderWithSpinBox`) | +| 5 | Step slider + numeric | `VolumeRendererControls._step_spin` | +| 6 | Opacity control (primary) | Form-row `sliderWithSpinBox` per channel — **not** right-side colorbar yet | +| 7–9 | Home toolbar + Save + `H` | `VolumeRendererToolbar`, wired in `_init_ui()` | +| 10–11 | Overlay segm + fluo volumes | `_get_overlay_zstacks()` → `update_overlay_volumes()` | +| — | Launch 3D renderer button | `launch3dRendererButton` / `launch3dRendererAction` (`1dd00e8e`, `537cd967`) | + +## Primary channel LUT + +[`renderer3d.py`](../../cellacdc/renderer3d.py) `_add_lut_items()` creates one [`baseHistogramLUTitem`](../../cellacdc/widgets.py) per channel with: + +- Auto / Full buttons above each histogram +- `sigLookupTableChanged` → `set_clim` + `set_cmap` +- `include_rescale_lut_options=False` (main GUI uses full `myHistogramLUTitem` menu) + +Partial vs main GUI (`gui.py` `imgGrad`): + +- Uses `baseHistogramLUTitem`, not `myHistogramLUTitem` +- No "Clim:" axis labelling parity (Batch 2) +- No rescale-intensities menu (2D image / z-stack / time) + +## Toolbar + +[`VolumeRendererToolbar`](../../cellacdc/widgets.py): + +- **Home view** — `:home.svg` icon, shortcut `H`, emits `sigHomeView` → `reset_view()` +- **Save** — `:file-save.svg`, shortcut `Ctrl+S`, emits `sigSave` → `save_screenshot()` + +Wired in `VolumeRenderer3DWindow._init_ui()`. + +## Rendering controls panel + +`VolumeRendererControls` provides: + +- Gamma, step, per-primary-channel opacity (form sliders) +- Rendering mode, interpolation, ISO threshold, attenuation, depiction mode, z-plane slider + +Overlay volumes pick up gamma/step/interp at creation time in `_init_overlay_volume_node()`. + +## Overlay data path + +### GUI side — [`gui.py`](../../cellacdc/gui.py) + +`_get_overlay_zstacks()` returns `list[tuple]` of `(data, opacity, cmap[, mode])`: + +1. **Fluorescence overlays** — checked overlay channels, alpha scrollbar value, hardcoded `_FLUO_CMAPS[i]` +2. **Primary segmentation mask** — when draw mode includes "overlay segm. masks" and `labelsAlphaSlider > 0`; binary `(lab > 0)` volume +3. **Overlay label channels** — when overlay-labels button active; binary masks with `_LABEL_CMAPS[j]` + +Launched and refreshed from: + +- `_launch_3d_renderer()` +- `_update_3d_renderer_if_active()` (frame/position navigation) + +Voxel sizes: `_get_current_voxel_sizes()` reads `PhysicalSizeZ/Y/X` from `posData`. + +### Renderer side — [`renderer3d.py`](../../cellacdc/renderer3d.py) + +- Unified `_volume_nodes` dict: primary keys = channel names; overlays = `overlay:0`, `overlay:1`, … +- `update_overlay_volumes()` replaces overlay nodes on each call +- `_normalize_overlay_volume()` min–max normalizes to [0, 1] +- `_init_overlay_volume_node()` applies `vispy_cmap_from_spec(cmap_spec)` and stored opacity + +## Colormap conversion + +| Path | Function | Used for | +|------|----------|----------| +| PG gradient → vispy | [`pg_to_vispy_cmap`](../../cellacdc/colors.py) | Primary LUT changes | +| Plain colour name | [`vispy_cmap_from_spec`](../../cellacdc/colors.py) | Overlay volumes | + +## z-anisotropy (transform only) + +`set_voxel_scale(dz, dy, dx)` stores physical µm sizes and applies vispy `STTransform` in `_apply_voxel_scale()`, accounting for GPU downsampling strides (`_last_strides`). No UI control; no `ndimage.zoom` resampling. + +## Launch adapter + +[`_GuiWinRenderer3DAdapter`](../../cellacdc/gui.py) implements `VolumeRendererAdapter`: + +- `get_current_zstack()` → `_get_current_zstack()` +- `get_voxel_sizes()` → `_get_current_voxel_sizes()` + +## CI + +[`tests/test_renderer3d.py`](../../tests/test_renderer3d.py) uses a module-level `pytest.skip` for GUI/OpenGL environments. Run locally: `pytest tests/test_renderer3d.py -v`. + +## Known gaps carried to later batches + +- Right-side grayscale opacity colorbar (`imgGradRight` pattern) +- In-renderer overlay LUT + opacity sliders with live sync +- `myHistogramLUTitem` / "Clim:" labelling parity +- Overlay colours ignore main GUI `lutItem` gradients +- Cell ID isolation +- z-anisotropy numeric UI and zoom-based correction diff --git a/.features/3drenderer/batch-2-lut-overlays.md b/.features/3drenderer/batch-2-lut-overlays.md new file mode 100644 index 00000000..902d3fbd --- /dev/null +++ b/.features/3drenderer/batch-2-lut-overlays.md @@ -0,0 +1,179 @@ +# Batch 2 — LUT Polish + Overlay UI + +Bring primary LUT and overlay controls in the 3D window to parity with the main GUI, and wire live updates so slider changes apply immediately (not only on frame navigation). + +## Checklist targets + +| # | Feature | +|---|---------| +| 1 | "Clim:" colorbar slider matching main GUI | +| 6 | Opacity as right-side grayscale colorbar | +| 12 | Opacity sliders for overlaid fluorescence channels | +| 13 | LUT sliders for overlay channels | +| 14 | Segmentation mask opacity slider in 3D UI | + +## Goals + +1. Upgrade primary LUT toward main GUI behaviour. +2. Add right-side grayscale opacity colorbar (mirror `imgGradRight`). +3. Add in-renderer overlay LUT + opacity controls. +4. Add segmentation mask opacity control in the 3D window. +5. Live-sync overlay control changes to volume nodes. + +--- + +## 1. Primary LUT parity + +### Current state + +[`renderer3d.py`](../../cellacdc/renderer3d.py) `_add_lut_items()` uses [`baseHistogramLUTitem`](../../cellacdc/widgets.py) with `include_rescale_lut_options=False`. + +Main GUI uses [`myHistogramLUTitem`](../../cellacdc/widgets.py) on `guiWin.imgGrad` with full gradient menu, child LUT linkage, and settings restore. + +### Tasks + +- [ ] Evaluate switching primary LUT to `myHistogramLUTitem` (or extend `baseHistogramLUTitem` with Clim labelling only). +- [ ] Match axis label style — user request specifies **"Clim:"** as the colorbar slider label (main GUI uses channel name via `setAxisLabel`; confirm desired label text). +- [ ] Consider enabling rescale-intensities options if 3D should respect the same rescale policy as 2D (may need adapter hooks to re-fetch normalized data). +- [ ] Keep existing Auto / Full buttons and `_on_lut_changed` → `set_clim` / `set_cmap` wiring. + +### Files + +| File | Changes | +|------|---------| +| [`cellacdc/renderer3d.py`](../../cellacdc/renderer3d.py) | `_add_lut_items()`, `_on_lut_changed`, `_on_auto_clim`, `_on_full_clim` | +| [`cellacdc/widgets.py`](../../cellacdc/widgets.py) | LUT item class choice, axis label helper if needed | + +### Reference — main GUI primary LUT + +```4256:4310:cellacdc/gui.py + self.imgGrad = widgets.myHistogramLUTitem(parent=self, name='image') + ... + self.imgGradRight = widgets.baseHistogramLUTitem( + name='image', parent=self, gradientPosition='left' + ) + ... + self.imgGrad.setChildLutItem(self.imgGradRight) +``` + +--- + +## 2. Right-side grayscale opacity colorbar + +### Current state + +Primary opacity is a form-row [`sliderWithSpinBox`](../../cellacdc/widgets.py) in `VolumeRendererControls` (`Opacity ({channel}):`). + +Main GUI uses vertical gradient LUT on the right (`imgGradRight`, `gradientPosition='left'`) linked via `setChildLutItem`. + +### Tasks + +- [ ] Add `baseHistogramLUTitem` with grayscale gradient to the right of the vispy canvas (same layout slot as LUT row, or dedicated column). +- [ ] Link to primary LUT via `setChildLutItem` if dual-slider behaviour should match 2D. +- [ ] Map gradient tick positions → `volume_node.opacity` (or equivalent vispy alpha control). +- [ ] Deprecate or hide redundant form-row opacity sliders once colorbar works (avoid duplicate controls). + +### Files + +| File | Changes | +|------|---------| +| [`cellacdc/renderer3d.py`](../../cellacdc/renderer3d.py) | `_init_ui()`, `_add_lut_items()` or new `_add_opacity_lut()`, `VolumeRendererControls` | +| [`cellacdc/widgets.py`](../../cellacdc/widgets.py) | Reuse `baseHistogramLUTitem` pattern from main GUI | + +--- + +## 3. In-renderer overlay LUT sliders + +### Current state + +Overlays receive hardcoded cmap strings from [`_get_overlay_zstacks()`](../../cellacdc/gui.py): + +```20191:20217:cellacdc/gui.py + _FLUO_CMAPS = ['green', 'magenta', 'cyan', 'yellow', 'orange'] + ... + cmap = _FLUO_CMAPS[i % len(_FLUO_CMAPS)] + result.append((data, opacity, cmap)) +``` + +Main GUI builds per-channel two-colour gradients in [`getOverlayItems()`](../../cellacdc/gui.py) via `initColormapOverlayLayerItem(initColor, lutItem)`. + +### Tasks + +- [ ] Extend overlay tuple schema to carry channel name + optional PG gradient state (or read from GUI `overlayLayersItems` at sync time). +- [ ] Add overlay LUT widgets in 3D window (one per active overlay fluo channel) — mirror two-colour gradient UI. +- [ ] On LUT change: `pg_to_vispy_cmap(lutItem.gradient.colorMap())` → update `overlay:N` node `cmap`. +- [ ] Fix 2D/3D colour mismatch by sourcing overlay colour from GUI `lutItem` instead of `_FLUO_CMAPS[i]`. + +### Files + +| File | Changes | +|------|---------| +| [`cellacdc/gui.py`](../../cellacdc/gui.py) | `_get_overlay_zstacks()` — include channel metadata / lut gradient | +| [`cellacdc/renderer3d.py`](../../cellacdc/renderer3d.py) | Overlay LUT layout, `update_overlay_volumes()`, node cmap updates | +| [`cellacdc/colors.py`](../../cellacdc/colors.py) | Already has `pg_to_vispy_cmap`; may need overlay-specific helpers | + +--- + +## 4. In-renderer overlay opacity sliders + +### Current state + +Opacity values are read once from main GUI alpha scrollbars when `_get_overlay_zstacks()` runs. No controls inside the 3D window for overlays. + +### Tasks + +- [ ] Add per-overlay opacity control in 3D UI (form slider or mini colorbar per overlay channel). +- [ ] On change: update `volume_node.opacity` directly for matching `overlay:N` key. +- [ ] Optionally bidirectional sync with main GUI alpha scrollbars (nice-to-have; document one-way vs two-way). + +### Segmentation mask opacity + +Main GUI: [`labelsAlphaSlider`](../../cellacdc/widgets.py) on `myHistogramLUTitem` / `imgGrad`. + +- [ ] Add dedicated "Segmentation opacity" control in 3D controls panel. +- [ ] When changed, rebuild or update segm overlay node opacity without full frame reload if possible. + +--- + +## 5. Live sync + +### Current state + +[`_update_3d_renderer_if_active()`](../../cellacdc/gui.py) pushes fresh data on frame/position changes only. Overlay slider edits in the main GUI do not refresh the 3D window until navigation. + +### Tasks + +- [ ] Connect main GUI overlay alpha / labels alpha changes to `_update_3d_renderer_if_active()` or a lighter `update_overlay_volumes()` call. +- [ ] In-renderer overlay control changes should update nodes in place (preferred) rather than full `_get_overlay_zstacks()` rebuild when only opacity/cmap changed. +- [ ] Add `VolumeRenderer3DWindow.set_overlay_opacity(index, value)` and `set_overlay_cmap(index, cmap)` helpers for incremental updates. + +### Files + +| File | Changes | +|------|---------| +| [`cellacdc/gui.py`](../../cellacdc/gui.py) | Signal connections from overlay widgets; optional sync callbacks | +| [`cellacdc/renderer3d.py`](../../cellacdc/renderer3d.py) | Incremental overlay node update API | + +--- + +## Suggested implementation order + +1. Fix overlay colour source in `_get_overlay_zstacks()` (quick win for 2D/3D match). +2. Primary LUT labelling + optional `myHistogramLUTitem` upgrade. +3. Right-side primary opacity colorbar. +4. Overlay opacity sliders in 3D UI. +5. Overlay LUT sliders in 3D UI. +6. Live sync wiring (main GUI ↔ 3D renderer). + +## Acceptance criteria + +- Primary LUT looks and behaves like main GUI Clim slider (label, gradient, Auto/Full, cmap). +- Primary opacity adjustable via right-side grayscale colorbar. +- Each active overlay fluo channel has LUT + opacity in the 3D window. +- Segmentation mask opacity adjustable without leaving the 3D window. +- Changing overlay opacity or LUT in either GUI updates the 3D view immediately. + +## Out of scope (later batches) + +- Cell ID isolation → [batch-3-cell-id.md](batch-3-cell-id.md) +- z-anisotropy UI → [batch-4-z-anisotropy.md](batch-4-z-anisotropy.md) diff --git a/.features/3drenderer/batch-3-cell-id.md b/.features/3drenderer/batch-3-cell-id.md new file mode 100644 index 00000000..8d9241e0 --- /dev/null +++ b/.features/3drenderer/batch-3-cell-id.md @@ -0,0 +1,79 @@ +# Batch 3 — Cell ID Isolation + +Allow the 3D renderer to show a single labelled cell in isolation — either by selecting a cell ID from a control or by clicking a cell in the volume view. + +## Checklist targets + +| # | Feature | Status | +|---|---------|--------| +| 15 | Cell ID selector (show one cell) | **Not started** | +| 16 | Clickable Cell ID (show one cell) | **Not started** | + +## Goals + +1. Add a cell ID selector control in the 3D window (dropdown, spin box, or list synced with main GUI label set). +2. When a cell ID is selected, mask the primary and/or overlay segmentation volumes so only that label is visible. +3. Support click-to-pick: raycast or slice-based picking on the vispy canvas to set the active cell ID. +4. Provide a clear "show all cells" reset (e.g. ID 0 or dedicated button). + +--- + +## 1. Cell ID selector + +### Current state + +Segmentation overlays arrive as binary `(lab > 0)` masks via [`_get_overlay_zstacks()`](../../cellacdc/gui.py). The renderer has no per-label filtering; all labelled pixels render together. + +### Tasks + +- [ ] Read label volume (not just binary mask) from adapter or a new `get_label_zstack()` hook. +- [ ] Add UI control for active cell ID (0 = all cells). +- [ ] Apply label mask in `update_volume()` / overlay node setup: `data * (labels == cell_id)` or equivalent GPU-friendly masking. +- [ ] Sync selector with main GUI cell selection if one exists (document one-way vs two-way). + +### Files + +| File | Changes | +|------|---------| +| [`cellacdc/gui.py`](../../cellacdc/gui.py) | Adapter hook for raw label data; optional sync with 2D cell selection | +| [`cellacdc/renderer3d.py`](../../cellacdc/renderer3d.py) | Cell ID control widget, masking logic on volume nodes | + +--- + +## 2. Clickable cell ID (pick in 3D view) + +### Current state + +No picking or cell-ID interaction on the vispy canvas. + +### Tasks + +- [ ] Handle mouse click on canvas → map screen coords to volume index (vispy scene picking or manual unproject). +- [ ] Read label value at picked voxel; set active cell ID and refresh masked volumes. +- [ ] Visual feedback (cursor, status bar, or highlight) for picked ID. +- [ ] Ignore picks on background (label 0). + +### Files + +| File | Changes | +|------|---------| +| [`cellacdc/renderer3d.py`](../../cellacdc/renderer3d.py) | Canvas event handler, pick → cell ID, refresh | + +--- + +## Dependencies + +- Batch 1 overlay data path (complete) — segmentation volumes must remain available as labelled data, not only binary masks. +- Batch 2 overlay UI (optional) — in-renderer segm opacity may interact with per-cell masking; coordinate designs before implementing. + +## Out of scope + +- z-anisotropy controls → [batch-4-z-anisotropy.md](batch-4-z-anisotropy.md) +- Tracking / lineage-aware cell selection across time (future enhancement) + +## Acceptance criteria + +- User can enter or select a cell ID and see only that cell in the 3D view. +- User can click a cell in the 3D canvas to isolate it. +- Resetting to "all cells" restores the full segmentation view. +- Frame navigation preserves the selected cell ID until explicitly cleared. diff --git a/.features/3drenderer/batch-4-z-anisotropy.md b/.features/3drenderer/batch-4-z-anisotropy.md new file mode 100644 index 00000000..6837d1f7 --- /dev/null +++ b/.features/3drenderer/batch-4-z-anisotropy.md @@ -0,0 +1,88 @@ +# Batch 4 — z-Anisotropy UI + Resampling + +Expose physical z-anisotropy to the user and optionally correct voxel data with `scipy.ndimage.zoom` instead of relying solely on vispy transform scaling. + +## Checklist targets + +| # | Feature | Status | +|---|---------|--------| +| 17 | z-anisotropy via `scipy.ndimage.zoom` | **Not started** | +| 18 | z-anisotropy numeric control | **Not started** | + +## Goals + +1. Add a user-facing z-anisotropy factor (numeric spin box / slider) in the 3D renderer controls. +2. Continue supporting metadata-driven physical sizes from `_get_current_voxel_sizes()`. +3. Optionally resample volume data with `scipy.ndimage.zoom` when anisotropy correction is enabled (vs. transform-only display today). +4. Document trade-offs: resampling quality/cost vs. GPU transform stretch. + +--- + +## 1. Current state (transform only) + +[`set_voxel_scale(dz, dy, dx)`](../../cellacdc/renderer3d.py) stores physical µm sizes and applies vispy `STTransform` in `_apply_voxel_scale()`, accounting for GPU downsampling strides (`_last_strides`). + +- Voxel sizes flow: `_get_current_voxel_sizes()` → `_launch_3d_renderer()` / `_update_3d_renderer_if_active()` → `set_voxel_scale()`. +- No user override; no `ndimage.zoom` on the numpy volume before upload. + +See also [batch-1-done.md — z-anisotropy (transform only)](batch-1-done.md#z-anisotropy-transform-only). + +--- + +## 2. Numeric z-anisotropy control + +### Tasks + +- [ ] Add control in `VolumeRendererControls` (e.g. "Z anisotropy:" spin box, default from metadata ratio `dz/dy` or `dz/dx`). +- [ ] On change: update `STTransform` scale even when metadata is missing or wrong. +- [ ] Persist value for the session (optional: save in renderer settings JSON if one exists). +- [ ] Show units / help text (physical µm per pixel vs. unitless stretch factor — pick one convention and document). + +### Files + +| File | Changes | +|------|---------| +| [`cellacdc/renderer3d.py`](../../cellacdc/renderer3d.py) | UI control, `_apply_voxel_scale()` user override | +| [`cellacdc/gui.py`](../../cellacdc/gui.py) | Optional: pass default anisotropy from metadata | + +--- + +## 3. `scipy.ndimage.zoom` resampling path + +### Tasks + +- [ ] Add toggle or mode: **transform only** (current) vs. **resample volume**. +- [ ] When resampling: compute zoom factors from desired anisotropy and apply `ndimage.zoom` to primary + overlay volumes before GPU upload. +- [ ] Update `_last_strides` / downsampling logic to match resampled shape. +- [ ] Benchmark memory and launch latency; consider lazy resample on anisotropy change only. +- [ ] Match interpolation order with vispy volume interpolation where possible. + +### Files + +| File | Changes | +|------|---------| +| [`cellacdc/renderer3d.py`](../../cellacdc/renderer3d.py) | Resample helper, hook in `update_volume()` / `update_overlay_volumes()` | +| [`cellacdc/gui.py`](../../cellacdc/gui.py) | Unlikely changes unless adapter passes resample preference | + +--- + +## Design notes + +| Approach | Pros | Cons | +|----------|------|------| +| Transform only (`STTransform`) | Fast, no extra memory, current behaviour | Stretched voxels; sampling artifacts on thick Z | +| `ndimage.zoom` resample | Cubic/isotropic voxels for rendering | CPU cost, memory spike, must re-run on data refresh | + +Batch 4 should implement the numeric control first (transform path), then add optional resampling behind an explicit user toggle. + +## Dependencies + +- Batch 1 volume upload and stride logic (complete). +- Cell ID masking (batch 3) should apply **after** resampling if both are active, or resample masked volumes consistently. + +## Acceptance criteria + +- User can adjust z-anisotropy from the 3D window without editing metadata. +- Default reflects `PhysicalSizeZ/Y/X` when available. +- Optional resampling mode visibly corrects elongated Z voxels and updates on frame sync. +- Transform-only mode remains available as the fast default. diff --git a/KEYBINDS.md b/KEYBINDS.md new file mode 100644 index 00000000..c1161443 --- /dev/null +++ b/KEYBINDS.md @@ -0,0 +1,34 @@ +# Cell-ACDC keyboard shortcuts + +## Core edit tools (customizable) + +These five tools are listed in **Settings → Customize keyboard shortcuts…** (`Ctrl+K`). + +| Key | Tool | Usage | +|-----|------|--------| +| `B` | Brush | Left-click / drag to paint (double-`B` = power brush) | +| `E` | Eraser | Left-click / drag to erase (double-`E` = power eraser) | +| `P` | Curvature tool | Left-click to place spline anchors | +| `I` | Edit ID | Left-click a cell to change its ID | +| `X` | Delete ID | Left-click a cell to delete it | + +All toolbar tools use **activate tool → left-click** on the image. + +--- + +## Navigation & file (not in shortcut editor) + +| Key | Action | +|-----|--------| +| `←` / `→` | Previous / next frame | +| `H` | Zoom to cells; double-`H` = reset zoom | +| `Ctrl+S` | Quick save | +| `Ctrl+Z` / `Ctrl+Y` | Undo / redo | +| `Ctrl+F` | Find ID | +| `Esc` | Cancel / clear highlights | + +See the GUI menus for additional bindings (slideshow, auto-pilot, cell-cycle table, etc.). + +--- + +*Custom bindings are saved in `shortcuts.ini` and override these defaults.* diff --git a/cellacdc/__main__.py b/cellacdc/__main__.py index 30d670d1..83fa6799 100755 --- a/cellacdc/__main__.py +++ b/cellacdc/__main__.py @@ -38,116 +38,131 @@ from cellacdc import _run -def run(): + +def _handle_parser_early_exit(): from cellacdc.config import parser_args - PARAMS_PATH = parser_args['params'] - if parser_args['version'] or parser_args['info']: from cellacdc.myutils import get_info_version_text - info_txt = get_info_version_text() - print(info_txt) + print(get_info_version_text()) exit() if parser_args['reset']: from cellacdc.myutils import reset_settings - reset_info_txt = reset_settings() - print(reset_info_txt) + print(reset_settings()) exit() - - if PARAMS_PATH: - _run.run_cli(PARAMS_PATH) - else: - run_gui() -def main(): - # Keep compatibility with users that installed older versions - # where the entry point was main() - run() -def run_gui(): +def _bootstrap_gui_app(): from ._run import ( - _setup_gui_libraries, + _setup_gui_libraries, _setup_symlink_app_name_macos, - _setup_numpy, - download_model_params, - _exit_on_setup + _setup_numpy, + download_model_params, + _exit_on_setup, ) - + _setup_symlink_app_name_macos() - + requires_exit = _setup_gui_libraries(exit_at_end=False) - + _setup_numpy() - + download_model_params() - + if requires_exit: _exit_on_setup() - - from qtpy import QtGui, QtWidgets, QtCore + + from qtpy import QtWidgets, QtCore if os.name == 'nt': try: - # Set taskbar icon in windows import ctypes - myappid = 'schmollerlab.cellacdc.pyqt.v1' # arbitrary string + myappid = 'schmollerlab.cellacdc.pyqt.v1' ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) - except Exception as e: + except Exception: pass - # Needed by pyqtgraph with display resolution scaling try: QtWidgets.QApplication.setAttribute( QtCore.Qt.HighDpiScaleFactorRoundingPolicy.PassThrough ) - except Exception as e: + except Exception: pass import pyqtgraph as pg - # Interpret image data as row-major instead of col-major pg.setConfigOption('imageAxisOrder', 'row-major') try: - import numba - pg.setConfigOption("useNumba", True) - except Exception as e: + import numba # noqa: F401 + pg.setConfigOption('useNumba', True) + except Exception: pass try: - import cupy as cp - pg.setConfigOption("useCupy", True) - except Exception as e: + import cupy as cp # noqa: F401 + pg.setConfigOption('useCupy', True) + except Exception: pass - # Create the application - app, splashScreen = _run._setup_app(splashscreen=True) + return _run._setup_app(splashscreen=True) + + +def _read_gui_version(logger_func=None): + from cellacdc import myutils + + version, success = myutils.read_version( + logger=logger_func, return_success=True + ) + if success: + return version + + error = myutils.check_install_package( + 'setuptools_scm', pypi_name='setuptools-scm' + ) + if error and logger_func is not None: + logger_func(error) + return myutils.read_version(logger=logger_func) + + +def run(): + from cellacdc.config import parser_args + + _handle_parser_early_exit() + + PARAMS_PATH = parser_args['params'] + + if PARAMS_PATH: + _run.run_cli(PARAMS_PATH) + else: + run_gui() + + +def main(): + # Keep compatibility with users that installed older versions + # where the entry point was main() + run() + + +def run_gui(): + app, splashScreen = _bootstrap_gui_app() + + from cellacdc import myutils - from cellacdc import myutils, printl - print('Launching application...') from cellacdc._main import mainWin - + if not splashScreen.isVisible(): splashScreen.show() - + win = mainWin(app) try: myutils.check_matplotlib_version(qparent=win) - except Exception as e: + except Exception: pass - version, success = myutils.read_version( - logger=win.logger.info, return_success=True - ) - if not success: - error = myutils.check_install_package( - 'setuptools_scm', pypi_name='setuptools-scm' - ) - if error: - win.logger.info(error) - else: - version = myutils.read_version(logger=win.logger.info) + + version = _read_gui_version(logger_func=win.logger.info) win.setVersion(version) win.launchWelcomeGuide() win.show() @@ -159,10 +174,65 @@ def run_gui(): win.logger.info(f'Welcome to Cell-ACDC v{version}') win.logger.info('**********************************************') win.logger.info('----------------------------------------------') - win.logger.info('NOTE: If application is not visible, it is probably minimized\n' - 'or behind some other open windows.') + win.logger.info( + 'NOTE: If application is not visible, it is probably minimized\n' + 'or behind some other open windows.' + ) + win.logger.info('----------------------------------------------') + splashScreen.close() + app.exec_() + + +def run_gui_direct(): + """Launch the annotation GUI directly, skipping the module launcher.""" + _handle_parser_early_exit() + + app, splashScreen = _bootstrap_gui_app() + + from cellacdc import myutils + from cellacdc.gui import guiWin + + print('Launching GUI...') + + if not splashScreen.isVisible(): + splashScreen.show() + + version = _read_gui_version() + gui_windows = [] + + def launch_gui_window(checked=False): + win = guiWin( + app, + mainWin=None, + version=version, + launcherSlot=launch_gui_window, + ) + gui_windows.append(win) + win.sigClosed.connect(_gui_window_closed) + win.run() + return win + + def _gui_window_closed(closed_win): + try: + gui_windows.remove(closed_win) + except ValueError: + pass + + win = launch_gui_window() + + try: + myutils.check_matplotlib_version(qparent=win) + except Exception: + pass + + win.logger.info('**********************************************') + win.logger.info(f'Welcome to Cell-ACDC GUI v{version}') + win.logger.info('**********************************************') + win.logger.info('----------------------------------------------') + win.logger.info( + 'NOTE: If application is not visible, it is probably minimized\n' + 'or behind some other open windows.' + ) win.logger.info('----------------------------------------------') splashScreen.close() - # splashScreenApp.quit() - # modernWin.show() - app.exec_() \ No newline at end of file + app.exec_() diff --git a/cellacdc/apps.py b/cellacdc/apps.py index 5bc4553c..0e8b09f7 100755 --- a/cellacdc/apps.py +++ b/cellacdc/apps.py @@ -14206,9 +14206,7 @@ def showEvent(self, event) -> None: class ShortcutEditorDialog(QBaseDialog): def __init__( - self, widgetsWithShortcut: dict, - delObjectKey='', - delObjectButton: Literal['Middle click', 'Left click']='Middle click', + self, widgetsWithShortcut: dict, zoomOutKeyValue: int=None, parent=None ): @@ -14226,26 +14224,8 @@ def __init__( scrollArea.setWidgetResizable(True) scrollAreaWidget = QWidget() entriesLayout = QGridLayout() - + row = 0 - button = widgets.PushButton(self, flat=True) - button.setIcon(QIcon(":del_obj_click.svg")) - self.delObjShortcutLineEdit = widgets.ShortcutLineEdit( - allowModifiers=True, notAllowedModifier=Qt.AltModifier - ) - if delObjectKey is not None: - self.delObjShortcutLineEdit.setText(delObjectKey) - self.delObjButtonCombobox = QComboBox() - self.delObjButtonCombobox.addItems(['Middle click', 'Left click']) - self.delObjButtonCombobox.setCurrentText(delObjectButton) - entriesLayout.addWidget(button, row, 0) - entriesLayout.addWidget(QLabel('Delete object:'), row, 1) - entriesLayout.addWidget(self.delObjShortcutLineEdit, row, 2) - entriesLayout.addWidget( - self.delObjButtonCombobox, row, 3, alignment=Qt.AlignLeft - ) - - row += 1 name = 'Zoom out' button = widgets.PushButton(self, flat=True) label = QLabel('Zoom out:') @@ -14261,7 +14241,7 @@ def __init__( entriesLayout.addWidget(label, row, 1) entriesLayout.addWidget(self.zoomShortcutLineEdit, row, 2) self.shortcutLineEdits[name] = self.zoomShortcutLineEdit - + row += 1 for row, (name, widget) in enumerate(widgetsWithShortcut.items(), start=row): button = widgets.PushButton(self, flat=True) @@ -14324,12 +14304,6 @@ def warnInvalidKeySequenceDelObjWithLeftClick(self): msg.warning(self, 'Invalid key sequence to delete objects', txt) def ok_cb(self): - delObjButtonText = self.delObjButtonCombobox.currentText() - delObjKeySequence = self.delObjShortcutLineEdit.keySequence - if delObjButtonText == 'Left click' and delObjKeySequence is None: - self.warnInvalidKeySequenceDelObjWithLeftClick() - return - self.shortcutLineEdits.pop('Zoom out') self.cancel = False for name, shortcutLineEdit in self.shortcutLineEdits.items(): @@ -14340,14 +14314,9 @@ def ok_cb(self): self.customShortcuts[name] = ( text, shortcutLineEdit.keySequence ) - - delObjQtButton = ( - Qt.MouseButton.LeftButton if delObjButtonText == 'Left click' - else Qt.MouseButton.MiddleButton - ) - self.delObjAction = delObjKeySequence, delObjQtButton + self.zoomOutKeyValue = self.zoomShortcutLineEdit.key - + self.close() def showEvent(self, event) -> None: diff --git a/cellacdc/colors.py b/cellacdc/colors.py index 8a5f26cf..464c6507 100644 --- a/cellacdc/colors.py +++ b/cellacdc/colors.py @@ -402,4 +402,61 @@ 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) + +# Plain colour names mapped to black→colour two-stop vispy colormaps for +# overlay channels (e.g. fluorescence hue on a black renderer background). +PLAIN_VISPY_COLOUR_NAMES = frozenset({ + 'red', 'green', 'blue', 'cyan', 'magenta', 'yellow', 'white', 'orange', +}) + + +def labels_lut_vispy_cmap(lut: np.ndarray): + """Convert the 2D labels LUT (N×4 uint8, index 0 transparent) to VisPy.""" + table = np.asarray(lut, dtype=np.float32) + if table.ndim != 2 or table.shape[1] < 3: + raise ValueError( + f'Expected labels LUT with shape (N, 4); got {table.shape}' + ) + if table.shape[1] == 3: + alpha = np.ones((len(table), 1), dtype=np.float32) + table = np.concatenate([table, alpha], axis=1) + rgba = table[:, :4] / 255.0 + transparent = (0.0, 0.0, 0.0, 0.0) + return VisPyColormap( + rgba, + interpolation='zero', + bad_color=transparent, + low_color=transparent, + high_color=transparent, + ) + + +def overlay_mask_vispy_cmap(color: str = 'red'): + """Colormap for binary label-mask overlays (transparent at 0, *color* at 1).""" + rgb = { + 'red': (1.0, 0.0, 0.0), + 'green': (0.0, 1.0, 0.0), + 'blue': (0.0, 0.0, 1.0), + 'cyan': (0.0, 1.0, 1.0), + 'magenta': (1.0, 0.0, 1.0), + 'yellow': (1.0, 1.0, 0.0), + 'orange': (1.0, 0.5, 0.0), + 'white': (1.0, 1.0, 1.0), + }.get(color, (1.0, 0.0, 0.0)) + colors = np.array( + [[0.0, 0.0, 0.0, 0.0], [*rgb, 1.0]], + dtype=np.float32, + ) + return VisPyColormap(colors) + + +def vispy_cmap_from_spec(spec: str): + """Return a vispy colormap object or name for *spec*. + + Plain colour names produce a black→colour ramp. Anything else is passed + through as a standard vispy colormap name string. + """ + if spec in PLAIN_VISPY_COLOUR_NAMES: + return VisPyColormap(['black', spec]) + return spec \ No newline at end of file diff --git a/cellacdc/config.py b/cellacdc/config.py index da319ee8..e458cdf5 100755 --- a/cellacdc/config.py +++ b/cellacdc/config.py @@ -44,7 +44,9 @@ def _resizeWarningHandler(self, msg_type, msg_log_context, msg_string): help_text = ( 'Welcome to Cell-ACDC!\n\n' 'You can run Cell-ACDC both as a GUI or in the command line.\n' - 'To run the GUI type `acdc`. To run the command line type `acdc -p `.\n' + 'To run the GUI type `acdc`. To open the annotation GUI directly ' + '(skipping the launcher) type `acdc-gui`.\n' + 'To run the command line type `acdc -p `.\n' 'The `` must be a workflow INI file.\n' 'If you do not have one, use the GUI to set up the parameters.\n\n' 'Enjoy!' diff --git a/cellacdc/gui.py b/cellacdc/gui.py index 87b8e85f..561addef 100755 --- a/cellacdc/gui.py +++ b/cellacdc/gui.py @@ -215,6 +215,128 @@ def get_voxel_sizes(self): def on_renderer_closed(self): pass + def push_overlays_from_main(self): + """One-way push: refresh 3D overlay volumes/colors from current 2D state.""" + from cellacdc.renderer3d import ( + OVERLAY_KIND_SEGM, + _parse_overlay_entry, + overlay_channel_name, + ) + + win = getattr(self._gui, '_renderer3d_window', None) + if win is None or not win.isVisible(): + return + if getattr(win, '_segmentation_only', False): + overlays = self._gui._get_3d_renderer_overlays_without_segm() + else: + overlays = self._gui._get_3d_renderer_overlays() + if not overlays: + win.update_overlay_volumes([]) + return + if not win.refresh_overlay_volumes(overlays): + win.update_overlay_volumes(overlays) + return + for index, entry in enumerate(overlays): + _data, opacity, cmap_spec, _mode, meta = _parse_overlay_entry( + entry, index + ) + key = overlay_channel_name(index) + if meta.get('kind') == OVERLAY_KIND_SEGM: + continue + win.set_overlay_opacity(key, opacity, sync_main_gui=False) + win.set_overlay_cmap(key, cmap_spec, sync_main_gui=False) + + def apply_overlay_control_from_renderer( + self, + channel_name: str, + opacity: float | None = None, + gradient_state: dict | None = None, + labels_alpha: float | None = None, + ) -> None: + return + + def get_available_cell_ids(self): + if not getattr(self._gui, 'isDataLoaded', False): + return [] + try: + posData = self._gui.data[self._gui.pos_i] + return [int(i) for i in posData.IDs] + except Exception: + return [] + + def get_labels_image_lut(self): + """One-time copy of the 2D per-label colour table when 3D opens.""" + if not hasattr(self._gui, 'getLabelsImageLut'): + return None + try: + return self._gui.getLabelsImageLut() + except Exception: + return None + + def get_labels_gradient_initial_state(self): + """One-time copy of the 2D labels gradient when the 3D window opens.""" + try: + grad = self._gui.labelsGrad.item.saveState() + bkgr = self._gui.labelsGrad.colorButton.color().getRgb()[:3] + return grad, bkgr + except Exception: + return None + + def _normalized_clim_from_main_image_levels(self): + """Map 2D img1 levels to [0, 1] clim for the current 3D z-stack.""" + zstack = self._gui._get_current_zstack() + if zstack is None: + return (0.0, 1.0) + try: + lo_raw, hi_raw = self._gui.img1.getLevels() + except Exception: + return (0.0, 1.0) + vmin, vmax = float(zstack.min()), float(zstack.max()) + if vmax <= vmin: + return (0.0, 1.0) + span = vmax - vmin + lo = max(0.0, min(1.0, (float(lo_raw) - vmin) / span)) + hi = max(0.0, min(1.0, (float(hi_raw) - vmin) / span)) + if hi <= lo: + return (0.0, 1.0) + return (lo, hi) + + def get_primary_image_lut_state(self): + return ( + self._gui.imgGrad.gradient.saveState(), + self._normalized_clim_from_main_image_levels(), + ) + + @staticmethod + def _primary_lut_cache_key(gradient_state, clim): + ticks = gradient_state.get('ticks', ()) + tick_key = tuple( + (round(float(pos), 6), tuple(color)) + for pos, color in ticks + ) + return (tick_key, round(float(clim[0]), 6), round(float(clim[1]), 6)) + + def apply_primary_lut_from_main(self): + win = getattr(self._gui, '_renderer3d_window', None) + if win is None or not win.isVisible(): + return + if getattr(win, '_segmentation_only', False): + return + if not win.lut_items: + return + gradient_state, clim = self.get_primary_image_lut_state() + cache_key = self._primary_lut_cache_key(gradient_state, clim) + if getattr(win, '_cached_primary_lut_key', None) == cache_key: + return + win._cached_primary_lut_key = cache_key + win.apply_primary_lut_from_main(gradient_state, clim) + + def apply_cell_id_from_renderer(self, cell_id: int) -> None: + return + + def on_cell_id_changed_from_main(self, cell_id: int) -> None: + return + class guiWin(QMainWindow, whitelist.WhitelistGUIElements, gui_combine.CombineGuiElements, @@ -718,10 +840,7 @@ def dropEvent(self, event): self.openFile(file_path=file_path) def changeEvent(self, event): - try: - self.delObjToolAction.setChecked(False) - except Exception as err: - return + pass def leaveEvent(self, event): if self.slideshowWin is not None: @@ -795,25 +914,9 @@ def isPanImageClick(self, mouseEvent, modifiers): return modifiers == Qt.AltModifier and left_click def middleClickText(self): - if self.delObjAction is None and is_mac: + if is_mac: return 'Command + Left Click' - - if self.delObjAction is None: - return 'Middle Click' - - delObjKeySequence, delObjQtButton = self.delObjAction - - if delObjQtButton == Qt.MouseButton.LeftButton: - buttonName = 'Left click' - elif delObjQtButton == Qt.MouseButton.RightButton: - buttonName = 'Right click' - else: - buttonName = 'Middle click' - - if delObjKeySequence is None: - return buttonName - - return f'{delObjKeySequence.toString()} + {buttonName}' + return 'Middle Click' def isDefaultMiddleClick(self, mouseEvent, modifiers): if is_mac: @@ -827,24 +930,7 @@ def isDefaultMiddleClick(self, mouseEvent, modifiers): return middle_click def isMiddleClick(self, mouseEvent, modifiers): - if self.delObjAction is None: - return self.isDefaultMiddleClick(mouseEvent, modifiers) - - delObjKeySequence, delObjQtButton = self.delObjAction - if delObjKeySequence is None: - # Setting only middle click on mac is allowed, however the - # delObjKeySequence is None and the tool button is never checked - isDelObjectActive = True - else: - isDelObjectActive = self.delObjToolAction.isChecked() - - mouseEventButton = self.changeRightClickToLeftOnMac(mouseEvent) - - middle_click = ( - mouseEventButton == delObjQtButton and isDelObjectActive - ) - - return middle_click + return self.isDefaultMiddleClick(mouseEvent, modifiers) def gui_createCursors(self): pixmap = QPixmap(":wand_cursor.svg") @@ -1219,7 +1305,7 @@ def gui_createToolBars(self): self.eraserButton.setIcon(QIcon(":eraser.svg")) self.eraserButton.setCheckable(True) editToolBar.addWidget(self.eraserButton) - self.eraserButton.keyPressShortcut = Qt.Key_X + self.eraserButton.keyPressShortcut = Qt.Key_E self.widgetsWithShortcut['Eraser'] = self.eraserButton self.checkableButtons.append(self.eraserButton) self.LeftClickButtons.append(self.eraserButton) @@ -1228,7 +1314,7 @@ def gui_createToolBars(self): self.curvToolButton = QToolButton(self) self.curvToolButton.setIcon(QIcon(":curvature-tool.svg")) self.curvToolButton.setCheckable(True) - self.curvToolButton.setShortcut('C') + self.curvToolButton.keyPressShortcut = Qt.Key_P self.curvToolButton.action = editToolBar.addWidget(self.curvToolButton) self.LeftClickButtons.append(self.curvToolButton) # self.functionsNotTested3D.append(self.curvToolButton) @@ -1239,27 +1325,20 @@ def gui_createToolBars(self): self.wandToolButton = QToolButton(self) self.wandToolButton.setIcon(QIcon(":magic_wand.svg")) self.wandToolButton.setCheckable(True) - self.wandToolButton.setShortcut('Ctrl+D') self.wandToolButton.action = editToolBar.addWidget(self.wandToolButton) self.LeftClickButtons.append(self.wandToolButton) self.checkableButtons.append(self.eraserButton) - self.widgetsWithShortcut['Magic wand'] = self.wandToolButton self.magicPromptsToolButton = QToolButton(self) self.magicPromptsToolButton.setIcon(QIcon(":magic-prompts.svg")) self.magicPromptsToolButton.setCheckable(True) - self.magicPromptsToolButton.setShortcut('W') self.magicPromptsToolButton.action = editToolBar.addWidget( self.magicPromptsToolButton ) - self.widgetsWithShortcut['Magic prompts'] = self.magicPromptsToolButton self.drawClearRegionButton = QToolButton(self) self.drawClearRegionButton.setCheckable(True) self.drawClearRegionButton.setIcon(QIcon(":clear_freehand_region.svg")) - self.widgetsWithShortcut['Clear freehand region'] = ( - self.drawClearRegionButton - ) self.toolsActiveInProj3Dsegm.add(self.drawClearRegionButton) self.checkableButtons.append(self.drawClearRegionButton) @@ -1269,56 +1348,37 @@ def gui_createToolBars(self): self.drawClearRegionButton ) - self.widgetsWithShortcut['Annotate mother/daughter pairing'] = ( - self.assignBudMothButton - ) - self.widgetsWithShortcut['Annotate unknown history'] = ( - self.setIsHistoryKnownButton - ) - self.copyLostObjButton = QToolButton(self) self.copyLostObjButton.setIcon(QIcon(":copyContour.svg")) self.copyLostObjButton.setCheckable(True) - self.copyLostObjButton.setShortcut('V') self.copyLostObjButton.action = editToolBar.addWidget( self.copyLostObjButton ) self.checkableButtons.append(self.copyLostObjButton) self.checkableQButtonsGroup.addButton(self.copyLostObjButton) - self.widgetsWithShortcut['Copy lost object contour'] = ( - self.copyLostObjButton - ) self.functionsNotTested3D.append(self.copyLostObjButton) self.labelRoiButton = widgets.rightClickToolButton(parent=self) self.labelRoiButton.setIcon(QIcon(":label_roi.svg")) self.labelRoiButton.setCheckable(True) - self.labelRoiButton.setShortcut('L') self.labelRoiButton.action = editToolBar.addWidget(self.labelRoiButton) self.LeftClickButtons.append(self.labelRoiButton) self.checkableButtons.append(self.labelRoiButton) self.checkableQButtonsGroup.addButton(self.labelRoiButton) - self.widgetsWithShortcut['Label ROI'] = self.labelRoiButton # self.functionsNotTested3D.append(self.labelRoiButton) self.manualAnnotPastButton = QToolButton(self) self.manualAnnotPastButton.setIcon(QIcon(":lock_id_annotate_future.svg")) self.manualAnnotPastButton.setCheckable(True) - self.manualAnnotPastButton.setShortcut('Y') self.manualAnnotPastButton.action = editToolBar.addWidget( self.manualAnnotPastButton ) self.checkableButtons.append(self.manualAnnotPastButton) - self.widgetsWithShortcut['Lock ID and annotate single object'] = ( - self.manualAnnotPastButton - ) self.functionsNotTested3D.append(self.manualAnnotPastButton) self.manulAnnotToolButtons.add(self.manualAnnotPastButton) self.segmentToolAction = QAction('Segment with last used model', self) self.segmentToolAction.setIcon(QIcon(":segment.svg")) - self.segmentToolAction.setShortcut('R') - self.widgetsWithShortcut['Repeat segmentation'] = self.segmentToolAction editToolBar.addAction(self.segmentToolAction) self.segForLostIDsButton = QToolButton(self) @@ -1336,11 +1396,9 @@ def gui_createToolBars(self): self.manualBackgroundButton = QToolButton(self) self.manualBackgroundButton.setIcon(QIcon(":manual_background.svg")) self.manualBackgroundButton.setCheckable(True) - self.manualBackgroundButton.setShortcut('G') self.LeftClickButtons.append(self.manualBackgroundButton) self.checkableButtons.append(self.manualBackgroundButton) self.checkableQButtonsGroup.addButton(self.manualBackgroundButton) - self.widgetsWithShortcut['Manual background'] = self.manualBackgroundButton self.manualBackgroundAction = editToolBar.addWidget( self.manualBackgroundButton @@ -1351,92 +1409,88 @@ def gui_createToolBars(self): 'Select a segmentation file and delete all objects on the background', self ) - self.delObjsOutSegmMaskAction.setShortcut('I') - self.widgetsWithShortcut['Delete all objects outside segm'] = ( - self.delObjsOutSegmMaskAction - ) editToolBar.addAction(self.delObjsOutSegmMaskAction) self.hullContToolButton = QToolButton(self) self.hullContToolButton.setIcon(QIcon(":hull.svg")) self.hullContToolButton.setCheckable(True) - self.hullContToolButton.setShortcut('O') self.hullContToolButton.action = editToolBar.addWidget(self.hullContToolButton) self.checkableButtons.append(self.hullContToolButton) self.checkableQButtonsGroup.addButton(self.hullContToolButton) self.functionsNotTested3D.append(self.hullContToolButton) - self.widgetsWithShortcut['Hull contour'] = self.hullContToolButton self.fillHolesToolButton = QToolButton(self) self.fillHolesToolButton.setIcon(QIcon(":fill_holes.svg")) self.fillHolesToolButton.setCheckable(True) - self.fillHolesToolButton.setShortcut('F') self.fillHolesToolButton.action = editToolBar.addWidget( self.fillHolesToolButton ) self.checkableButtons.append(self.fillHolesToolButton) self.checkableQButtonsGroup.addButton(self.fillHolesToolButton) self.functionsNotTested3D.append(self.fillHolesToolButton) - self.widgetsWithShortcut['Fill holes'] = self.fillHolesToolButton self.moveLabelToolButton = QToolButton(self) self.moveLabelToolButton.setIcon(QIcon(":moveLabel.svg")) self.moveLabelToolButton.setCheckable(True) - self.moveLabelToolButton.setShortcut('P') self.moveLabelToolButton.action = editToolBar.addWidget(self.moveLabelToolButton) self.checkableButtons.append(self.moveLabelToolButton) self.checkableQButtonsGroup.addButton(self.moveLabelToolButton) - self.widgetsWithShortcut['Move label'] = self.moveLabelToolButton self.expandLabelToolButton = QToolButton(self) self.expandLabelToolButton.setIcon(QIcon(":expandLabel.svg")) self.expandLabelToolButton.setCheckable(True) - self.expandLabelToolButton.setShortcut('E') self.expandLabelToolButton.action = editToolBar.addWidget(self.expandLabelToolButton) self.expandLabelToolButton.hide() self.checkableButtons.append(self.expandLabelToolButton) self.LeftClickButtons.append(self.expandLabelToolButton) self.checkableQButtonsGroup.addButton(self.expandLabelToolButton) - self.widgetsWithShortcut['Expand/shrink label'] = self.expandLabelToolButton + + self.deleteIDButton = QToolButton(self) + self.deleteIDButton.setIcon(QIcon(":del_obj_click.svg")) + self.deleteIDButton.setCheckable(True) + self.deleteIDButton.keyPressShortcut = Qt.Key_X + editToolBar.addWidget(self.deleteIDButton) + self.checkableButtons.append(self.deleteIDButton) + self.checkableQButtonsGroup.addButton(self.deleteIDButton) + self.widgetsWithShortcut['Delete ID'] = self.deleteIDButton self.editIDbutton = QToolButton(self) self.editIDbutton.setIcon(QIcon(":edit-id.svg")) self.editIDbutton.setCheckable(True) - self.editIDbutton.setShortcut('N') + self.editIDbutton.keyPressShortcut = Qt.Key_I editToolBar.addWidget(self.editIDbutton) self.checkableButtons.append(self.editIDbutton) self.checkableQButtonsGroup.addButton(self.editIDbutton) self.widgetsWithShortcut['Edit ID'] = self.editIDbutton + self.exclusiveEditTools = ( + self.brushButton, + self.eraserButton, + self.curvToolButton, + self.deleteIDButton, + self.editIDbutton, + ) + self.separateBudButton = QToolButton(self) self.separateBudButton.setIcon(QIcon(":separate-bud.svg")) self.separateBudButton.setCheckable(True) - self.separateBudButton.setShortcut('S') self.separateBudButton.action = editToolBar.addWidget(self.separateBudButton) self.checkableButtons.append(self.separateBudButton) self.checkableQButtonsGroup.addButton(self.separateBudButton) - # self.functionsNotTested3D.append(self.separateBudButton) - self.widgetsWithShortcut['Separate objects'] = self.separateBudButton self.mergeIDsButton = QToolButton(self) self.mergeIDsButton.setIcon(QIcon(":merge-IDs.svg")) self.mergeIDsButton.setCheckable(True) - self.mergeIDsButton.setShortcut('M') self.mergeIDsButton.action = editToolBar.addWidget(self.mergeIDsButton) self.checkableButtons.append(self.mergeIDsButton) self.checkableQButtonsGroup.addButton(self.mergeIDsButton) - # self.functionsNotTested3D.append(self.mergeIDsButton) - self.widgetsWithShortcut['Merge objects'] = self.mergeIDsButton self.keepIDsButton = QToolButton(self) self.keepIDsButton.setIcon(QIcon(":keep_objects.svg")) self.keepIDsButton.setCheckable(True) self.keepIDsButton.action = editToolBar.addWidget(self.keepIDsButton) - self.keepIDsButton.setShortcut('K') self.checkableButtons.append(self.keepIDsButton) self.checkableQButtonsGroup.addButton(self.keepIDsButton) - # self.functionsNotTested3D.append(self.keepIDsButton) - self.widgetsWithShortcut['Select objects to keep'] = self.keepIDsButton self.whitelistIDsButton = QToolButton(self) self.whitelistIDsButton.setIcon(QIcon(":whitelist.svg")) @@ -1444,14 +1498,9 @@ def gui_createToolBars(self): self.whitelistIDsButton.action = editToolBar.addWidget( self.whitelistIDsButton ) - self.whitelistIDsButton.setShortcut('Ctrl+K') self.checkableButtons.append(self.whitelistIDsButton) self.checkableQButtonsGroup.addButton(self.whitelistIDsButton) self.LeftClickButtons.append(self.whitelistIDsButton) - # self.functionsNotTested3D.append(self.whitelistIDsButton) - self.widgetsWithShortcut['Select objects to add to a tracking whitelist'] = ( - self.whitelistIDsButton - ) self.binCellButton = QToolButton(self) self.binCellButton.setIcon(QIcon(":bin.svg")) @@ -1465,20 +1514,16 @@ def gui_createToolBars(self): self.manualTrackingButton = QToolButton(self) self.manualTrackingButton.setIcon(QIcon(":manual_tracking.svg")) self.manualTrackingButton.setCheckable(True) - self.manualTrackingButton.setShortcut('T') self.checkableQButtonsGroup.addButton(self.manualTrackingButton) self.checkableButtons.append(self.manualTrackingButton) - self.widgetsWithShortcut['Manual tracking'] = self.manualTrackingButton self.ripCellButton = QToolButton(self) self.ripCellButton.setIcon(QIcon(":rip.svg")) self.ripCellButton.setCheckable(True) - self.ripCellButton.setShortcut('D') self.ripCellButton.action = editToolBar.addWidget(self.ripCellButton) self.checkableButtons.append(self.ripCellButton) self.checkableQButtonsGroup.addButton(self.ripCellButton) self.functionsNotTested3D.append(self.ripCellButton) - self.widgetsWithShortcut['Annotate cell as dead'] = self.ripCellButton editToolBar.addAction(self.addDelRoiAction) # editToolBar.addAction(self.addDelPolyLineRoiAction) @@ -1539,37 +1584,27 @@ def gui_createToolBars(self): self.findNextMotherButton.setCheckable(True) self.editLin_TreeBar.addWidget(self.findNextMotherButton) self.editLin_TreeGroup.addButton(self.findNextMotherButton) - self.findNextMotherButton.setShortcut('F') - self.widgetsWithShortcut['Find next potential mother (lineage tree)'] = self.findNextMotherButton self.unknownLineageButton = QToolButton(self) self.unknownLineageButton.setIcon(QIcon(":history.svg")) self.unknownLineageButton.setCheckable(True) self.editLin_TreeBar.addWidget(self.unknownLineageButton) self.editLin_TreeGroup.addButton(self.unknownLineageButton) - self.unknownLineageButton.setShortcut('U') - self.widgetsWithShortcut['Unknown lineage (lineage tree)'] = self.unknownLineageButton self.noToolLinTreeButton = QToolButton(self) self.noToolLinTreeButton.setIcon(QIcon(":arrow_cursor.svg")) self.noToolLinTreeButton.setCheckable(True) self.editLin_TreeBar.addWidget(self.noToolLinTreeButton) self.editLin_TreeGroup.addButton(self.noToolLinTreeButton) - self.noToolLinTreeButton.setShortcut('N') - self.widgetsWithShortcut['No tool (lineage tree)'] = self.noToolLinTreeButton self.propagateLinTreeButton = QToolButton(self) self.propagateLinTreeButton.setIcon(QIcon(":compute.svg")) self.editLin_TreeBar.addWidget(self.propagateLinTreeButton) - self.propagateLinTreeButton.setShortcut('P') - self.widgetsWithShortcut['Propagate (lineage tree)'] = self.propagateLinTreeButton self.propagateLinTreeButton.clicked.connect(self.propagateLinTreeAction) self.viewLinTreeInfoButton = QToolButton(self) self.viewLinTreeInfoButton.setIcon(QIcon(":addCustomAnnotation.svg")) self.editLin_TreeBar.addWidget(self.viewLinTreeInfoButton) - self.viewLinTreeInfoButton.setShortcut('S') - self.widgetsWithShortcut['View Changes (lineage tree)'] = self.viewLinTreeInfoButton self.viewLinTreeInfoButton.clicked.connect(self.viewLinTreeInfoAction) @@ -2072,6 +2107,20 @@ def gui_createControlsToolbar(self): if 'brushAutoHide' in self.df_settings.index: checked = self.df_settings.at['brushAutoHide', 'value'] == 'Yes' self.brushAutoHideCheckbox.setChecked(checked) + + brushEraserToolBar.addWidget(QLabel(' ')) + self.brushLazyModeCheckbox = QCheckBox('Lazy mode') + self.brushLazyModeCheckbox.setToolTip( + 'Click once to start brushing or erasing, move without holding ' + 'the mouse button, then click again to finish.' + ) + self.brushLazyModeAction = brushEraserToolBar.addWidget( + self.brushLazyModeCheckbox + ) + self.brushLazyModeAction.setVisible(False) + if 'brushLazyMode' in self.df_settings.index: + checked = self.df_settings.at['brushLazyMode', 'value'] == 'Yes' + self.brushLazyModeCheckbox.setChecked(checked) brushEraserToolBar.setVisible(False) self.brushEraserToolBar = brushEraserToolBar @@ -2330,7 +2379,7 @@ def gui_createControlsToolbar(self): "Copy lost object controls", self ) for name, action in self.copyLostObjToolbar.widgetsWithShortcut.items(): - self.widgetsWithShortcut[name] = action + pass # toolbar shortcuts not exposed in shortcut editor self.copyLostObjToolbar.sigCopyAllObjects.connect( self.copyAllLostObjects @@ -2358,7 +2407,7 @@ def gui_createControlsToolbar(self): addNewIDToggleState, self ) for name, action in self.whitelistIDsToolbar.widgetsWithShortcut.items(): - self.widgetsWithShortcut[name] = action + pass self.addToolBar(Qt.TopToolBarArea, self.whitelistIDsToolbar) self.whitelistIDsToolbar.setVisible(False) @@ -2366,7 +2415,7 @@ def gui_createControlsToolbar(self): self.magicPromptsToolbar = widgets.MagicPromptsToolbar(self) for name, action in self.magicPromptsToolbar.widgetsWithShortcut.items(): - self.widgetsWithShortcut[name] = action + pass self.magicPromptsToolbar.sigComputeOnZoom.connect( self.magicPromptsComputeOnZoomTriggered @@ -2409,21 +2458,9 @@ def gui_createControlsToolbar(self): self.promptSegmentPointsLayerToolbar ) - # Second level toolbar + # Second level toolbar (legacy placeholder, kept hidden) secondLevelToolbar = widgets.ToolBar("Second level toolbar", self) self.addToolBar(Qt.TopToolBarArea, secondLevelToolbar) - self.delObjToolAction = QAction(self) - self.delObjToolAction.setIcon(QIcon(":del_obj_click.svg")) - self.delObjToolAction.setCheckable(True) - self.delObjToolAction.setToolTip( - 'Customisable delete object action\n\n' - 'Go to the `Settings --> Customise keyboard shortcuts...` menu ' - 'on the top menubar\n' - 'to customise the action required to delete ' - 'an object with a click.\n\n' - 'When working with 3D segmentations, to delete only the z-slice mask, hold "Shift" while clicking.' - ) - secondLevelToolbar.addAction(self.delObjToolAction) secondLevelToolbar.setMovable(False) self.secondLevelToolbar = secondLevelToolbar self.secondLevelToolbar.setVisible(False) @@ -2710,13 +2747,9 @@ def gui_createActions(self): self.zoomRectButton = QToolButton(self) self.zoomRectButton.setIcon(QIcon(":zoom_rect.svg")) self.zoomRectButton.setCheckable(True) - self.zoomRectButton.setShortcut('Shift+Z') self.LeftClickButtons.append(self.zoomRectButton) self.checkableButtons.append(self.zoomRectButton) self.checkableQButtonsGroup.addButton(self.zoomRectButton) - self.widgetsWithShortcut['Zoom to rectangular area'] = ( - self.zoomRectButton - ) self.skipToNewIdAction = QAction(self) self.skipToNewIdAction.setIcon(QIcon(":skip_forward_new_ID.svg")) @@ -2773,8 +2806,6 @@ def gui_createActions(self): self.repeatTrackingAction = QAction( QIcon(":repeat-tracking.svg"), "Repeat tracking", self ) - self.repeatTrackingAction.setShortcut('Shift+T') - self.widgetsWithShortcut['Repeat Tracking'] = self.repeatTrackingAction self.editRtTrackerParamsAction = QAction( @@ -3435,6 +3466,8 @@ def gui_connectEditActions(self): self.brushButton.toggled.connect(self.Brush_cb) self.eraserButton.toggled.connect(self.Eraser_cb) self.curvToolButton.toggled.connect(self.curvTool_cb) + self.editIDbutton.toggled.connect(self.editID_cb) + self.deleteIDButton.toggled.connect(self.deleteID_cb) self.wandToolButton.toggled.connect(self.wand_cb) self.labelRoiButton.toggled.connect(self.labelRoi_cb) self.magicPromptsToolButton.toggled.connect(self.magicPrompts_cb) @@ -3602,6 +3635,7 @@ def gui_connectEditActions(self): self.brushAutoFillCheckbox.toggled.connect(self.brushAutoFillToggled) self.brushAutoHideCheckbox.toggled.connect(self.brushAutoHideToggled) + self.brushLazyModeCheckbox.toggled.connect(self.brushLazyModeToggled) self.imgGrad.sigAddScaleBar.connect(self.addScaleBarAction.setChecked) self.imgGrad.sigAddTimestamp.connect(self.addTimestampAction.setChecked) @@ -4992,8 +5026,6 @@ def gui_initImg1BottomWidgets(self): def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): modifiers = QGuiApplication.keyboardModifiers() alt = modifiers == Qt.AltModifier - shift = modifiers == Qt.ShiftModifier - shift_regardless = bool(modifiers & Qt.ShiftModifier) isMod = alt posData = self.data[self.pos_i] mode = str(self.modeComboBox.currentText()) @@ -5006,10 +5038,12 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): separateON = self.separateBudButton.isChecked() self.typingEditID = False - # Drag image if neither brush or eraser are On pressed + # Drag image if no left-click edit tool is active dragImg = ( - left_click and not eraserON and not - brushON and not middle_click + left_click and not eraserON and not brushON + and not self.deleteIDButton.isChecked() + and not middle_click + and not self._usesImg2MousePressHandler() ) if isPanImageClick: dragImg = True @@ -5065,13 +5099,36 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): editInViewerMode = ( (is_right_click_action_ON or is_right_click_custom_ON) - and (right_click or middle_click) and mode=='Viewer' + and left_click and mode=='Viewer' ) if editInViewerMode: self.startBlinkingModeCB() event.ignore() return + + if ( + left_click and brushON + and (mode == 'Segmentation and Tracking' or self.isSnapshot) + and not is_event_from_img1 + ): + ctrl = modifiers == Qt.ControlModifier + if self._lazyModeEnabled() and self.isMouseDragImg1: + self.finishLazyStroke() + return + self.startBrushStroke(xdata, ydata, ctrl=ctrl) + return + + if ( + left_click and eraserON + and (mode == 'Segmentation and Tracking' or self.isSnapshot) + and not is_event_from_img1 + ): + if self._lazyModeEnabled() and self.isMouseDragImg1: + self.finishLazyStroke() + return + self.startEraserStroke(xdata, ydata) + return # Left-click is used for brush, eraser, separate bud, curvature tool # and magic labeller @@ -5080,83 +5137,14 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): # separate ON canDelete = mode == 'Segmentation and Tracking' or self.isSnapshot - # Delete ID (set to 0) - if middle_click and canDelete: - t0 = time.perf_counter() - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - delID = self.get_2Dlab(posData.lab)[ydata, xdata] - if delID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) - delID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.
' - 'Enter here ID(s) that you want to delete

' - 'You can enter multiple IDs separated by comma', - parent=self, - allowedValues=posData.IDs, - defaultTxt=str(nearest_ID), - allowList=True, - isInteger=True - ) - delID_prompt.exec_() - if delID_prompt.cancel: - return - delIDs = delID_prompt.EntryID - else: - delIDs = [delID] - - # Ask to propagate change to all future visited frames - key = 'Delete ID' - askAction = self.askHowFutureFramesActions[key] - doNotShow = not askAction.isChecked() - (UndoFutFrames, applyFutFrames, endFrame_i, - doNotShowAgain) = self.propagateChange( - delIDs, key, doNotShow, - posData.UndoFutFrames_DelID, posData.applyFutFrames_DelID - ) - - if UndoFutFrames is None: - return - - # Store undo state before modifying stuff - self.storeUndoRedoStates(UndoFutFrames) - posData.doNotShowAgain_DelID = doNotShowAgain - posData.UndoFutFrames_DelID = UndoFutFrames - posData.applyFutFrames_DelID = applyFutFrames - includeUnvisited = posData.includeUnvisitedInfo['Delete ID'] - - delID_mask = self.deleteIDmiddleClick( - delIDs, applyFutFrames, includeUnvisited, shift=shift_regardless - ) - if delID_mask.ndim == 3: - delID_mask = delID_mask[self.z_lab()] - - if self.isSnapshot: - self.fixCcaDfAfterEdit('Delete ID') - else: - self.warnEditingWithCca_df('Delete ID', update_images=False) - - self.setImageImg2() - delROIsIDs = self.setAllTextAnnotations() - self.setAllContoursImages(delROIsIDs=delROIsIDs, compute=False) + if ( + left_click and self.deleteIDButton.isChecked() and canDelete + ): + self._handleDeleteIDClick(event) + return - how = self.drawIDsContComboBox.currentText() - if how.find('overlay segm. masks') != -1: - self.labelsLayerImg1.image[delID_mask] = 0 - self.labelsLayerImg1.setImage(self.labelsLayerImg1.image) - - how_ax2 = self.getAnnotateHowRightImage() - if how_ax2.find('overlay segm. masks') != -1: - self.labelsLayerRightImg.image[delID_mask] = 0 - self.labelsLayerRightImg.setImage(self.labelsLayerRightImg.image) - - self.highlightLostNew() - # Separate bud or objects with same ID - elif right_click and separateON: + elif left_click and separateON: x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] @@ -5183,22 +5171,19 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): self.storeUndoRedoStates(False) max_ID = max(posData.IDs, default=1) - if self.isSegm3D and not shift: + if self.isSegm3D: z = self.zSliceScrollBar.sliderPosition() posData.lab, splittedIDs = measure.separate_with_label( posData.lab, posData.rp, [ID], max_ID, click_coords_list=[(z, ydata, xdata)] ) success = True - # self.set_2Dlab(lab2D) - elif not shift: + else: result = core.split_along_convexity_defects( ID, self.get_2Dlab(posData.lab), max_ID ) lab2D, success, splittedIDs = result self.set_2Dlab(lab2D) - else: - success = False # If automatic bud separation was not successfull call manual one if not success: @@ -5248,7 +5233,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): self.separateBudButton.setChecked(False) # Fill holes - elif right_click and self.fillHolesToolButton.isChecked(): + elif left_click and self.fillHolesToolButton.isChecked(): x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] @@ -5287,7 +5272,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): self.fillHolesToolButton.setChecked(False) # Hull contour - elif right_click and self.hullContToolButton.isChecked(): + elif left_click and self.hullContToolButton.isChecked(): x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] @@ -5326,7 +5311,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): self.hullContToolButton.setChecked(False) # Move label - elif right_click and self.moveLabelToolButton.isChecked(): + elif left_click and self.moveLabelToolButton.isChecked(): # Store undo state before modifying stuff self.storeUndoRedoStates(False) @@ -5334,7 +5319,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): self.startMovingLabel(x, y) # Fill holes - elif right_click and self.fillHolesToolButton.isChecked(): + elif left_click and self.fillHolesToolButton.isChecked(): x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] @@ -5358,7 +5343,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): ID = clickedBkgrID.EntryID # Merge IDs - elif right_click and self.mergeIDsButton.isChecked(): + elif left_click and self.mergeIDsButton.isChecked(): x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] @@ -5391,7 +5376,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): self.clickObjYc, self.clickObjXc = int(yc), int(xc) # Edit ID - elif right_click and self.editIDbutton.isChecked(): + elif left_click and self.editIDbutton.isChecked(): x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] @@ -5452,11 +5437,10 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): self.applyEditID( ID, currentIDs, editID.how, x, y, - shift=shift, doPropagateUnvisited=editID.doPropagateFutureFrames ) - elif (right_click or left_click) and self.keepIDsButton.isChecked(): + elif left_click and self.keepIDsButton.isChecked(): x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] @@ -5488,7 +5472,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): self.updateTempLayerKeepIDs() # Annotate cell as removed from the analysis - elif right_click and self.binCellButton.isChecked(): + elif left_click and self.binCellButton.isChecked(): x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] @@ -5569,7 +5553,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): self.binCellButton.setChecked(False) # Annotate cell as dead - elif right_click and self.ripCellButton.isChecked(): + elif left_click and self.ripCellButton.isChecked(): x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] @@ -5839,103 +5823,11 @@ def gui_mouseDragEventImg1(self, event): # Brush dragging mouse --> keep brushing elif self.isMouseDragImg1 and self.brushButton.isChecked(): - lab_2D = self.get_2Dlab(posData.lab) - - # t1 = time.perf_counter() - - ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) - rrPoly, ccPoly = self.getPolygonBrush((y, x), Y, X) - - # t2 = time.perf_counter() - - diskSlice = (slice(ymin, ymax), slice(xmin, xmax)) - - # Build brush mask - mask = np.zeros(lab_2D.shape, bool) - mask[diskSlice][diskMask] = True - mask[rrPoly, ccPoly] = True - - modifiers = QGuiApplication.keyboardModifiers() - ctrl = modifiers == Qt.ControlModifier - - # t3 = time.perf_counter() - if not self.isPowerBrush() and not ctrl: - mask[lab_2D!=0] = False - self.setHoverToolSymbolColor( - xdata, ydata, self.ax2_BrushCirclePen, - (self.ax2_BrushCircle, self.ax1_BrushCircle), - self.brushButton, brush=self.ax2_BrushCircleBrush - ) - - # t4 = time.perf_counter() - - # Apply brush mask - self.applyBrushMask(mask, posData.brushID) - - self.setImageImg2(updateLookuptable=False) - - # t5 = time.perf_counter() - - lab2D = self.get_2Dlab(posData.lab) - brushMask = np.logical_and( - lab2D[diskSlice] == posData.brushID, diskMask - ) - self.setTempImg1Brush( - False, brushMask, posData.brushID, - toLocalSlice=diskSlice - ) - - # t6 = time.perf_counter() - - # printl( - # 'Brush exec times =\n' - # f' * {(t1-t0)*1000 = :.4f} ms\n' - # f' * {(t2-t1)*1000 = :.4f} ms\n' - # f' * {(t3-t2)*1000 = :.4f} ms\n' - # f' * {(t4-t3)*1000 = :.4f} ms\n' - # f' * {(t5-t4)*1000 = :.4f} ms\n' - # f' * {(t6-t5)*1000 = :.4f} ms\n' - # f' * {(t6-t0)*1000 = :.4f} ms' - # ) + self._continueBrushStroke(x, y, xdata, ydata) # Eraser dragging mouse --> keep erasing elif self.isMouseDragImg1 and self.eraserButton.isChecked(): - posData = self.data[self.pos_i] - lab_2D = self.get_2Dlab(posData.lab) - rrPoly, ccPoly = self.getPolygonBrush((y, x), Y, X) - - ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) - - diskSlice = (slice(ymin, ymax), slice(xmin, xmax)) - - # Build eraser mask - mask = np.zeros(lab_2D.shape, bool) - mask[ymin:ymax, xmin:xmax][diskMask] = True - mask[rrPoly, ccPoly] = True - - if self.eraseOnlyOneID: - mask[lab_2D!=self.erasedID] = False - self.setHoverToolSymbolColor( - xdata, ydata, self.eraserCirclePen, - (self.ax2_EraserCircle, self.ax1_EraserCircle), - self.eraserButton, hoverRGB=self.img2.lut[self.erasedID], - ID=self.erasedID - ) - - self.erasedIDs.update(lab_2D[mask]) - self.applyEraserMask(mask) - - self.setImageImg2() - - for erasedID in self.erasedIDs: - if erasedID == 0: - continue - self.erasedLab[lab_2D==erasedID] = erasedID - self.erasedLab[mask] = 0 - - eraserMask = mask[diskSlice] - self.setTempImg1Eraser(eraserMask, toLocalSlice=diskSlice) - self.setTempImg1Eraser(eraserMask, toLocalSlice=diskSlice, ax=1) + self._continueEraserStroke(x, y, xdata, ydata) # Move label dragging mouse --> keep moving elif self.isMovingLabel and self.moveLabelToolButton.isChecked(): @@ -6282,6 +6174,11 @@ def gui_hoverEventImg1(self, event, isHoverImg1=True): self.checkHighlightScaleBar(x, y, activeToolButton) self.checkHighlightTimestamp(x, y, activeToolButton) self.wcLabel.setText(hoverText) + if self._lazyStrokeActive(): + if self.brushButton.isChecked(): + self._continueBrushStroke(x, y, xdata, ydata) + elif self.eraserButton.isChecked(): + self._continueEraserStroke(x, y, xdata, ydata) else: self.clickedOnBud = False self.BudMothTempLine.setData([], []) @@ -6337,6 +6234,11 @@ def gui_hoverEventImg1(self, event, isHoverImg1=True): x, y = event.pos() self.updateBrushCursor(x, y, isHoverImg1=isHoverImg1) self.hideItemsHoverBrush(xy=(x, y)) + elif cursorsInfo['setCurvCursor'] and not event.isExit(): + x, y = event.pos() + xdata, ydata = int(x), int(y) + if self.autoIDcheckbox.isChecked(): + self.getHoverID(xdata, ydata) elif cursorsInfo['setAddPointCursor']: x, y = event.pos() self.setHoverCircleAddPoint(x, y) @@ -6511,6 +6413,9 @@ def gui_setCursor(self, modifiers, event): setEditIDCursor = ( self.editIDbutton.isChecked() and not event.isExit() ) + setDeleteIDCursor = ( + self.deleteIDButton.isChecked() and not event.isExit() + ) magicPromptsON = self.magicPromptsToolButton.isChecked() pointsLayerON = self.togglePointsLayerAction.isChecked() addPointsByClickingButton = self.buttonAddPointsByClickingActive() @@ -6554,6 +6459,11 @@ def gui_setCursor(self, modifiers, event): self.app.setOverrideCursor(Qt.CrossCursor) else: self.app.restoreOverrideCursor() + elif setDeleteIDCursor and overrideCursor is None: + if shift: + self.app.setOverrideCursor(Qt.CrossCursor) + else: + self.app.setOverrideCursor(Qt.PointingHandCursor) return { 'setBrushCursor': setBrushCursor, @@ -6571,7 +6481,8 @@ def gui_setCursor(self, modifiers, event): 'setManualBackgroundCursor': setManualBackgroundCursor, 'setAddPointCursor': setAddPointCursor, 'setZoomRectCursor': setZoomRectCursor, - 'setEditIDCursor': setEditIDCursor + 'setEditIDCursor': setEditIDCursor, + 'setDeleteIDCursor': setDeleteIDCursor } def warnAddingPointWithExistingId(self, point_id, table_endname=''): @@ -6671,10 +6582,27 @@ def gui_hoverEventImg2(self, event): # hoverText = self.hoverValuesFormatted(xdata, ydata) # self.wcLabel.setText(hoverText) else: - if self.eraserButton.isChecked() or self.brushButton.isChecked(): + if ( + (self.eraserButton.isChecked() or self.brushButton.isChecked()) + and not self._lazyStrokeActive() + ): self.gui_mouseReleaseEventImg2(event) self.wcLabel.setText(f'') + if not event.isExit(): + x, y = event.pos() + xdata, ydata = int(x), int(y) + if ( + self._lazyStrokeActive() + and myutils.is_in_bounds( + xdata, ydata, *self.currentLab2D.shape[::-1] + ) + ): + if self.brushButton.isChecked(): + self._continueBrushStroke(x, y, xdata, ydata) + elif self.eraserButton.isChecked(): + self._continueEraserStroke(x, y, xdata, ydata) + if setMoveLabelCursor or setExpandLabelCursor: x, y = event.pos() self.updateHoverLabelCursor(x, y) @@ -6743,66 +6671,12 @@ def gui_mouseDragEventImg2(self, event): return # Eraser dragging mouse --> keep erasing - if self.isMouseDragImg2 and self.eraserButton.isChecked(): - posData = self.data[self.pos_i] - lab_2D = self.get_2Dlab(posData.lab) - Y, X = lab_2D.shape - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - brushSize = self.brushSizeSpinbox.value() - rrPoly, ccPoly = self.getPolygonBrush((y, x), Y, X) - - ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) - - # Build eraser mask - mask = np.zeros(lab_2D.shape, bool) - mask[ymin:ymax, xmin:xmax][diskMask] = True - mask[rrPoly, ccPoly] = True - - if self.eraseOnlyOneID: - mask[lab_2D!=self.erasedID] = False - self.setHoverToolSymbolColor( - xdata, ydata, self.eraserCirclePen, - (self.ax2_EraserCircle, self.ax1_EraserCircle), - self.eraserButton, hoverRGB=self.img2.lut[self.erasedID], - ID=self.erasedID - ) - - self.erasedIDs.update(lab_2D[mask]) - - self.applyEraserMask(mask) - self.setImageImg2(updateLookuptable=False) + if self.isMouseDragImg1 and self.eraserButton.isChecked(): + self._continueEraserStroke(x, y, xdata, ydata) # Brush paint dragging mouse --> keep painting - if self.isMouseDragImg2 and self.brushButton.isChecked(): - posData = self.data[self.pos_i] - lab_2D = self.get_2Dlab(posData.lab) - Y, X = lab_2D.shape - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - - ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) - rrPoly, ccPoly = self.getPolygonBrush((y, x), Y, X) - - # Build brush mask - mask = np.zeros(lab_2D.shape, bool) - mask[ymin:ymax, xmin:xmax][diskMask] = True - mask[rrPoly, ccPoly] = True - - # If user double-pressed 'b' then draw over the labels - color = self.brushButton.palette().button().color().name() - if color != self.doublePressKeyButtonColor: - mask[lab_2D!=0] = False - self.setHoverToolSymbolColor( - xdata, ydata, self.ax2_BrushCirclePen, - (self.ax2_BrushCircle, self.ax1_BrushCircle), - self.eraserButton, brush=self.ax2_BrushCircleBrush - ) - - # Apply brush mask - self.applyBrushMask(mask, self.ax2BrushID) - - self.setImageImg2() + if self.isMouseDragImg1 and self.brushButton.isChecked(): + self._continueBrushStroke(x, y, xdata, ydata) # Move label dragging mouse --> keep moving elif self.isMovingLabel and self.moveLabelToolButton.isChecked(): @@ -6948,22 +6822,30 @@ def gui_mouseReleaseEventImg1(self, event): self.timestamp.clicked = False return - sendRightClickImg2 = ( + sendEditReleaseImg2 = ( (mode=='Segmentation and Tracking' or self.isSnapshot) - and right_click + and event.button() == Qt.MouseButton.LeftButton and not alt + and ( + self.mergeIDsButton.isChecked() + or ( + self.isMovingLabel + and self.moveLabelToolButton.isChecked() + ) + ) ) - if sendRightClickImg2: - # Allow right-click actions on both images + if sendEditReleaseImg2: self.gui_mouseReleaseEventImg2(event) - # Right-click curvature tool mouse release + # Curvature auto-contour mouse release if self.isRightClickDragImg1 and self.curvToolButton.isChecked(): self.isRightClickDragImg1 = False try: self.curvToolSplineToObj(isRightClick=True) self.update_rp() if self.autoIDcheckbox.isChecked(): - self.trackManuallyAddedObject(posData.brushID, True) + self.trackManuallyAddedObject( + posData.brushID, self.isNewID + ) if self.isSnapshot: self.fixCcaDfAfterEdit('Add new ID with curvature tool') self.updateAllImages() @@ -6978,25 +6860,17 @@ def gui_mouseReleaseEventImg1(self, event): # Eraser mouse release --> update IDs and contours elif self.isMouseDragImg1 and self.eraserButton.isChecked(): - self.isMouseDragImg1 = False - - self.clearTempBrushImage() - - # Update data (rp, etc) - self.update_rp() + if self._lazyModeEnabled(): + return - doUpdateImages = self.checkWarnDeletedIDwithEraser() - - if doUpdateImages: - self.updateAllImages() + self.finishEraserStroke() # Brush button mouse release elif self.isMouseDragImg1 and self.brushButton.isChecked(): - self.isMouseDragImg1 = False + if self._lazyModeEnabled(): + return - self.clearTempBrushImage() - - self.brushReleased() + self.finishBrushStroke() # Wand tool release, add new object elif self.isMouseDragImg1 and self.wandToolButton.isChecked(): @@ -7353,6 +7227,7 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): curvToolON = self.curvToolButton.isChecked() histON = self.setIsHistoryKnownButton.isChecked() eraserON = self.eraserButton.isChecked() + deleteIDON = self.deleteIDButton.isChecked() rulerON = self.rulerButton.isChecked() wandON = self.wandToolButton.isChecked() and not isPanImageClick polyLineRoiON = self.addDelPolyLineRoiButton.isChecked() @@ -7373,36 +7248,10 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): drawClearRegionON = self.drawClearRegionButton.isChecked() zoomRectON = self.zoomRectButton.isChecked() - # Check if right-click on segment of polyline roi to add segment - segments = self.gui_getHoveredSegmentsPolyLineRoi() - if len(segments) == 1 and right_click: - seg = segments[0] - seg.roi.segmentClicked(seg, event) - return - - # Check if right-click on handle of polyline roi to remove it - handles = self.gui_getHoveredHandlesPolyLineRoi() - if len(handles) == 1 and right_click: - handle = handles[0] - handle.roi.removeHandle(handle) - return - # Check if click on ROI isClickOnDelRoi = self.gui_clickedDelRoi(event, left_click, right_click) if isClickOnDelRoi: return - - dragImgLeft = ( - left_click and not brushON and not histON - and not curvToolON and not eraserON and not rulerON - and not wandON and not polyLineRoiON and not labelRoiON - and not middle_click and not keepObjON and not separateON - and not manualBackgroundON and not drawClearRegionON - and addPointsByClickingButton is None and not whitelistIDsON - and not zoomRectON - ) - if isPanImageClick: - dragImgLeft = True is_right_click_custom_ON = any([ b.isChecked() for b in self.customAnnotDict.keys() @@ -7417,48 +7266,70 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): and not separateON ) - # In timelapse mode division can be annotated if isCcaMode and right-click - # while in snapshot mode with Ctrl+right-click + # In timelapse mode division can be annotated with left-click in CCA mode isAnnotateDivision = ( - (right_click and isCcaMode and canAnnotateDivision) - or (right_click and ctrl and self.isSnapshot) + left_click and canAnnotateDivision + and (isCcaMode or self.isSnapshot) ) - isCustomAnnot = ( - (right_click or dragImgLeft) - and (isCustomAnnotMode or self.isSnapshot) + isCustomAnnotModeActive = ( + (isCustomAnnotMode or self.isSnapshot) and self.customAnnotButton is not None ) + + dragImgLeft = ( + left_click and not brushON and not histON + and not curvToolON and not eraserON and not rulerON + and not wandON and not polyLineRoiON and not labelRoiON + and not middle_click and not keepObjON and not separateON + and not manualBackgroundON and not drawClearRegionON + and addPointsByClickingButton is None and not whitelistIDsON + and not zoomRectON and not deleteIDON + and not self.fillHolesToolButton.isChecked() + and not self.hullContToolButton.isChecked() + and not self.moveLabelToolButton.isChecked() + and not self.mergeIDsButton.isChecked() + and not self.editIDbutton.isChecked() + and not self.binCellButton.isChecked() + and not self.ripCellButton.isChecked() + and not copyContourON + and not self.manualTrackingButton.isChecked() + and not self.assignBudMothButton.isChecked() + and not self.setIsHistoryKnownButton.isChecked() + and not findNextMotherButtonON and not unknownLineageButtonON + and not isAnnotateDivision + and not (left_click and isCustomAnnotModeActive) + ) + if isPanImageClick: + dragImgLeft = True + + isCustomAnnot = ( + (left_click or dragImgLeft) + and isCustomAnnotModeActive + ) is_right_click_action_ON = any([ b.isChecked() for b in self.checkableQButtonsGroup.buttons() ]) - isOnlyRightClick = ( - right_click and canAnnotateDivision and not isAnnotateDivision - and not isMod and not is_right_click_action_ON - and not is_right_click_custom_ON and not copyContourON + showImg1ContextMenu = ( + right_click and not isMod and not is_right_click_action_ON + and not is_right_click_custom_ON and not copyContourON and not findNextMotherButtonON and not unknownLineageButtonON - and not middle_click + and not middle_click and not isAnnotateDivision + and not isPanImageClick ) - - if isOnlyRightClick: - # Start timer or check if it is a double-right-click - if self.countRightClicks == 0: - self.isDoubleRightClick = False - self.countRightClicks = 1 - self.doubleRightClickTimeElapsed = False - screenPos = event.screenPos() - self._img1_click_xy = (screenPos.x(), screenPos.y()) - QTimer.singleShot(400, self.doubleRightClickTimerCallBack) - return - elif ( - self.countRightClicks == 1 - and not self.doubleRightClickTimeElapsed - ): - self.isDoubleRightClick = True - self.countRightClicks = 0 - self.editIDbutton.setChecked(True) + if showImg1ContextMenu: + screenPos = event.screenPos() + self.gui_imgGradShowContextMenu(screenPos.x(), screenPos.y()) + return + + if ( + left_click and deleteIDON + and (mode == 'Segmentation and Tracking' or self.isSnapshot) + ): + self._handleDeleteIDClick(event) + return # Left click actions canCurv = ( @@ -7474,7 +7345,7 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): and not dragImgLeft and not eraserON and not wandON and not labelRoiON and not manualBackgroundON and addPointsByClickingButton is None and not drawClearRegionON - and not magicPromptsON and not zoomRectON + and not magicPromptsON and not zoomRectON and not deleteIDON ) canErase = ( eraserON and not curvToolON and not rulerON @@ -7482,7 +7353,7 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): and not polyLineRoiON and not labelRoiON and addPointsByClickingButton is None and not manualBackgroundON and not drawClearRegionON - and not magicPromptsON and not zoomRectON + and not magicPromptsON and not zoomRectON and not deleteIDON ) canRuler = ( rulerON and not curvToolON and not brushON @@ -7592,12 +7463,10 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): event.ignore() return - # Allow right-click or middle-click actions on both images + # Forward left-click edit-tool actions to img2 handler (both images) eventOnImg2 = ( - ( - right_click or (middle_click and not canAddPoint) - # or (left_click and separateON) - ) + left_click + and self._usesImg2MousePressHandler() and (mode=='Segmentation and Tracking' or self.isSnapshot) and not isAnnotateDivision and not manualBackgroundON ) @@ -7619,100 +7488,24 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): xdata, ydata = int(x), int(y) lab_2D = self.get_2Dlab(posData.lab) Y, X = lab_2D.shape - - # Store undo state before modifying stuff - self.storeUndoRedoStates(False, storeOnlyZoom=True) - - ID = self.getHoverID(xdata, ydata) - - if ID > 0: - posData.brushID = ID - self.isNewID = False - else: - # Update brush ID. Take care of disappearing cells to remember - # to not use their IDs anymore in the future - self.isNewID = True - self.setBrushID() - self.updateLookuptable(lenNewLut=posData.brushID+1) - - self.brushColor = self.lut[posData.brushID]/255 self.yPressAx2, self.xPressAx2 = y, x - ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) - diskSlice = (slice(ymin, ymax), slice(xmin, xmax)) - - self.isMouseDragImg1 = True - - # Draw new objects - localLab = lab_2D[diskSlice] - mask = diskMask.copy() - if not self.isPowerBrush() and not ctrl: - mask[localLab!=0] = False - - self.applyBrushMask(mask, posData.brushID, toLocalSlice=diskSlice) - - self.setImageImg2(updateLookuptable=False) - - how = self.drawIDsContComboBox.currentText() - lab2D = self.get_2Dlab(posData.lab) - self.globalBrushMask = np.zeros(lab2D.shape, dtype=bool) - brushMask = localLab == posData.brushID - brushMask = np.logical_and(brushMask, diskMask) - self.setTempImg1Brush( - True, brushMask, posData.brushID, toLocalSlice=diskSlice - ) + if self._lazyModeEnabled() and self.isMouseDragImg1: + self.finishLazyStroke() + return - self.lastHoverID = -1 + self.startBrushStroke(xdata, ydata, ctrl=ctrl) elif left_click and canErase: x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) - lab_2D = self.get_2Dlab(posData.lab) - Y, X = lab_2D.shape - - # Store undo state before modifying stuff - self.storeUndoRedoStates(False, storeOnlyZoom=True) - - self.yPressAx2, self.xPressAx2 = y, x - # Keep a list of erased IDs got erased - self.erasedIDs = set() - - if self.xyOnCtrlPressedFirstTime is not None: - self.erasedID = self.getHoverID(*self.xyOnCtrlPressedFirstTime) - else: - self.erasedID = self.getHoverID(xdata, ydata) - ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) - - # Build eraser mask - mask = np.zeros(lab_2D.shape, bool) - mask[ymin:ymax, xmin:xmax][diskMask] = True - - - # If user double-pressed 'b' then erase over ALL labels - color = self.eraserButton.palette().button().color().name() - eraseOnlyOneID = ( - color != self.doublePressKeyButtonColor - and self.erasedID != 0 - ) - - self.eraseOnlyOneID = eraseOnlyOneID - - if eraseOnlyOneID: - mask[lab_2D!=self.erasedID] = False - - self.setTempImg1Eraser(mask, init=True) - self.applyEraserMask(mask) + if self._lazyModeEnabled() and self.isMouseDragImg1: + self.finishLazyStroke() + return - self.erasedIDs.update(lab_2D[mask]) - - for erasedID in self.erasedIDs: - if erasedID == 0: - continue - self.erasedLab[lab_2D==erasedID] = erasedID - - self.isMouseDragImg1 = True + self.startEraserStroke(xdata, ydata) elif canAddPoint: action = addPointsByClickingButton.action @@ -7733,7 +7526,7 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): else: point_id = self.getAddedPointId( magicPromptsON, addPointsByClickingButton, - right_click, left_click, middle_click + left_click, middle_click ) if point_id is None: return @@ -7897,27 +7690,13 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): self.whitelistUpdateTempLayer() - elif right_click and copyContourON: + elif left_click and copyContourON: hoverLostID = self.ax1_lostObjScatterItem.hoverLostID self.copyLostObjectMask(hoverLostID) self.update_rp() self.updateAllImages() self.store_data() - elif right_click and canCurv: - # Draw manually assisted auto contour - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - Y, X = self.get_2Dlab(posData.lab).shape - - self.autoCont_x0 = xdata - self.autoCont_y0 = ydata - self.xxA_autoCont, self.yyA_autoCont = [], [] - self.curvAnchors.addPoints([x], [y]) - img = self.getDisplayedImg1() - self.autoContObjMask = np.zeros(img.shape, np.uint8) - self.isRightClickDragImg1 = True - elif left_click and canCurv: # Draw manual spline x, y = event.pos().x(), event.pos().y() @@ -7952,7 +7731,9 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): self.curvToolSplineToObj() self.update_rp() if self.autoIDcheckbox.isChecked(): - self.trackManuallyAddedObject(posData.brushID, True) + self.trackManuallyAddedObject( + posData.brushID, self.isNewID + ) if self.isSnapshot: self.fixCcaDfAfterEdit('Add new ID with curvature tool') self.updateAllImages() @@ -8005,7 +7786,7 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): self.setTempBrushMaskFromWand(self.flood_mask, init=True) self.isMouseDragImg1 = True - elif right_click and self.manualTrackingButton.isChecked(): + elif left_click and self.manualTrackingButton.isChecked(): x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) manualTrackID = self.manualTrackingToolbar.spinboxID.value() @@ -8044,19 +7825,6 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): self.update_rp() self.updateAllImages() - elif right_click and manualBackgroundON: - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - - delID = posData.manualBackgroundLab[ydata, xdata] - if delID == 0: - return - - self.clearManualBackgroundObject(delID) - textItem = self.manualBackgroundTextItems.pop(delID) - self.ax1.removeItem(textItem) - self.setManualBackgroundImage() - elif left_click and canAddManualBackgroundObj: x, y = event.pos().x(), event.pos().y() @@ -8065,11 +7833,7 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): self.setManualBackgrounNextID() # Label ROI mouse press - elif (left_click or right_click) and canLabelRoi: - if right_click: - # Force model initialization on mouse release - self.labelRoiModel = None - + elif left_click and canLabelRoi: x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) @@ -8118,7 +7882,7 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): self.undoBudMothAssignment(ID) # Assign bud to mother (mouse down on bud) - elif right_click and self.assignBudMothButton.isChecked(): + elif left_click and self.assignBudMothButton.isChecked(): if self.clickedOnBud: # NOTE: self.clickedOnBud is set to False when assigning a mother # is successfull in mouse release event @@ -8172,7 +7936,7 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): self.xClickBud, self.yClickBud = xdata, ydata # Annotate (or undo) that cell has unknown history - elif right_click and self.setIsHistoryKnownButton.isChecked(): + elif left_click and self.setIsHistoryKnownButton.isChecked(): if posData.cca_df is None: return @@ -8238,36 +8002,25 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): if not keepActive: button.setChecked(False) - elif right_click and findNextMotherButtonON: + elif left_click and findNextMotherButtonON: if posData.frame_i == 0: return self.find_mother_action(posData, event, ydata, xdata) - elif right_click and unknownLineageButtonON: + elif left_click and unknownLineageButtonON: if posData.frame_i == 0: return self.annotate_unknown_lineage_action(posData, event, ydata, xdata) - elif (left_click or right_click) and canZoomRect: - if left_click: - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - - self.zoomRectItem.setPos((xdata, ydata)) - - self.isMouseDragImg1 = True - else: - try: - xRange, yRange = self.zoomRectItem.getLastRange() - self.ax1.setRange( - xRange=xRange, - yRange=yRange, - padding=0 - ) - except Exception as err: - QTimer.singleShot(100, self.autoRange) + elif left_click and canZoomRect: + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + + self.zoomRectItem.setPos((xdata, ydata)) + + self.isMouseDragImg1 = True def repeat_click_and_backup(self, posData, event, ydata, xdata): """ @@ -8316,8 +8069,6 @@ def repeat_click_and_backup(self, posData, event, ydata, xdata): self.right_click_i = 0 self.right_click_ID = ID self.original_mother_skipped = False - elif event.modifiers() & Qt.ShiftModifier: - self.right_click_i -= 1 else: self.right_click_i += 1 @@ -9810,7 +9561,13 @@ def getHoverID(self, xdata, ydata, byPassShiftCheck=False): if self.isSegm3D: z = self.z_lab() SizeZ = posData.lab.shape[0] - doNotLinkThroughZ = self.brushButton.isChecked() and shift + doNotLinkThroughZ = ( + shift + and ( + self.brushButton.isChecked() + or self.curvToolButton.isChecked() + ) + ) if doNotLinkThroughZ: if self.brushHoverCenterModeAction.isChecked() or ID>0: hoverID = ID @@ -9857,8 +9614,11 @@ def getHoverID(self, xdata, ydata, byPassShiftCheck=False): else: hoverID = 0 else: - if self.brushButton.isChecked() and shift: - # Force new ID with brush and Shift + if shift and ( + self.brushButton.isChecked() + or self.curvToolButton.isChecked() + ): + # Force new ID with brush/curvature tool and Shift hoverID = 0 elif self.brushHoverCenterModeAction.isChecked() or ID>0: hoverID = ID @@ -11614,32 +11374,229 @@ def delNewObj(self, checked): if frame_i == 0: return - - prev_IDs = posData.allData_li[frame_i-1]['IDs'] - curr_IDs = posData.IDs - new_IDs = list(set(curr_IDs) - set(prev_IDs)) + + prev_IDs = posData.allData_li[frame_i-1]['IDs'] + curr_IDs = posData.IDs + new_IDs = list(set(curr_IDs) - set(prev_IDs)) + + lab = posData.lab + del_mask = np.isin(lab, new_IDs) + lab[del_mask] = 0 + posData.lab = lab + + self.update_rp() + + if posData.cca_df is not None: + posData.cca_df = posData.cca_df.drop(index=new_IDs) + self.store_data() + self.updateAllImages() + + def brushAutoFillToggled(self, checked): + val = 'Yes' if checked else 'No' + self.df_settings.at['brushAutoFill', 'value'] = val + self.df_settings.to_csv(self.settings_csv_path) + + def brushAutoHideToggled(self, checked): + val = 'Yes' if checked else 'No' + self.df_settings.at['brushAutoHide', 'value'] = val + self.df_settings.to_csv(self.settings_csv_path) + + def brushLazyModeToggled(self, checked): + val = 'Yes' if checked else 'No' + self.df_settings.at['brushLazyMode', 'value'] = val + self.df_settings.to_csv(self.settings_csv_path) + if not checked: + self.finishLazyStroke() + + def _lazyModeEnabled(self): + return self.brushLazyModeCheckbox.isChecked() + + def _lazyStrokeActive(self): + return ( + self._lazyModeEnabled() + and self.isMouseDragImg1 + and ( + self.brushButton.isChecked() + or self.eraserButton.isChecked() + ) + ) + + def finishLazyStroke(self): + if self.brushButton.isChecked() and self.isMouseDragImg1: + self.finishBrushStroke() + elif self.eraserButton.isChecked() and self.isMouseDragImg1: + self.finishEraserStroke() + + def _continueBrushStroke(self, x, y, xdata, ydata): + posData = self.data[self.pos_i] + lab_2D = self.get_2Dlab(posData.lab) + Y, X = lab_2D.shape + + ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) + rrPoly, ccPoly = self.getPolygonBrush((y, x), Y, X) + diskSlice = (slice(ymin, ymax), slice(xmin, xmax)) + + mask = np.zeros(lab_2D.shape, bool) + mask[diskSlice][diskMask] = True + mask[rrPoly, ccPoly] = True + + modifiers = QGuiApplication.keyboardModifiers() + ctrl = modifiers == Qt.ControlModifier + if not self.isPowerBrush() and not ctrl: + mask[lab_2D != 0] = False + self.setHoverToolSymbolColor( + xdata, ydata, self.ax2_BrushCirclePen, + (self.ax2_BrushCircle, self.ax1_BrushCircle), + self.brushButton, brush=self.ax2_BrushCircleBrush, + ) + + self.applyBrushMask(mask, posData.brushID) + self.setImageImg2(updateLookuptable=False) + + lab2D = self.get_2Dlab(posData.lab) + brushMask = np.logical_and( + lab2D[diskSlice] == posData.brushID, diskMask + ) + self.setTempImg1Brush( + False, brushMask, posData.brushID, + toLocalSlice=diskSlice, + ) + + def startBrushStroke(self, xdata, ydata, ctrl=False): + posData = self.data[self.pos_i] + lab_2D = self.get_2Dlab(posData.lab) + + self.storeUndoRedoStates(False, storeOnlyZoom=True) + + ID = self.getHoverID(xdata, ydata) + + if ID > 0: + posData.brushID = ID + self.isNewID = False + else: + self.isNewID = True + self.setBrushID() + self.updateLookuptable(lenNewLut=posData.brushID+1) + + self.brushColor = self.lut[posData.brushID]/255 + + ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) + diskSlice = (slice(ymin, ymax), slice(xmin, xmax)) + + self.isMouseDragImg1 = True + + localLab = lab_2D[diskSlice] + mask = diskMask.copy() + if not self.isPowerBrush() and not ctrl: + mask[localLab!=0] = False + + self.applyBrushMask(mask, posData.brushID, toLocalSlice=diskSlice) + + self.setImageImg2(updateLookuptable=False) + + lab2D = self.get_2Dlab(posData.lab) + self.globalBrushMask = np.zeros(lab2D.shape, dtype=bool) + brushMask = localLab == posData.brushID + brushMask = np.logical_and(brushMask, diskMask) + self.setTempImg1Brush( + True, brushMask, posData.brushID, toLocalSlice=diskSlice + ) + + self.lastHoverID = -1 + + def startEraserStroke(self, xdata, ydata): + posData = self.data[self.pos_i] + lab_2D = self.get_2Dlab(posData.lab) + + self.storeUndoRedoStates(False, storeOnlyZoom=True) + + self.yPressAx2, self.xPressAx2 = ydata, xdata + self.erasedIDs = set() + + if self.xyOnCtrlPressedFirstTime is not None: + self.erasedID = self.getHoverID(*self.xyOnCtrlPressedFirstTime) + else: + self.erasedID = self.getHoverID(xdata, ydata) + + ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) + mask = np.zeros(lab_2D.shape, bool) + mask[ymin:ymax, xmin:xmax][diskMask] = True + + color = self.eraserButton.palette().button().color().name() + self.eraseOnlyOneID = ( + color != self.doublePressKeyButtonColor + and self.erasedID != 0 + ) + + if self.eraseOnlyOneID: + mask[lab_2D != self.erasedID] = False + + self.setTempImg1Eraser(mask, init=True) + self.applyEraserMask(mask) + + self.erasedIDs.update(lab_2D[mask]) + for erasedID in self.erasedIDs: + if erasedID == 0: + continue + self.erasedLab[lab_2D == erasedID] = erasedID + + self.isMouseDragImg1 = True + + def _continueEraserStroke(self, x, y, xdata, ydata): + posData = self.data[self.pos_i] + lab_2D = self.get_2Dlab(posData.lab) + Y, X = lab_2D.shape + + ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) + rrPoly, ccPoly = self.getPolygonBrush((y, x), Y, X) + diskSlice = (slice(ymin, ymax), slice(xmin, xmax)) + + mask = np.zeros(lab_2D.shape, bool) + mask[ymin:ymax, xmin:xmax][diskMask] = True + mask[rrPoly, ccPoly] = True + + if self.eraseOnlyOneID: + mask[lab_2D != self.erasedID] = False + self.setHoverToolSymbolColor( + xdata, ydata, self.eraserCirclePen, + (self.ax2_EraserCircle, self.ax1_EraserCircle), + self.eraserButton, hoverRGB=self.img2.lut[self.erasedID], + ID=self.erasedID, + ) + + self.erasedIDs.update(lab_2D[mask]) + self.applyEraserMask(mask) + self.setImageImg2() + + for erasedID in self.erasedIDs: + if erasedID == 0: + continue + self.erasedLab[lab_2D == erasedID] = erasedID + self.erasedLab[mask] = 0 + + eraserMask = mask[diskSlice] + self.setTempImg1Eraser(eraserMask, toLocalSlice=diskSlice) + self.setTempImg1Eraser(eraserMask, toLocalSlice=diskSlice, ax=1) + + def finishEraserStroke(self): + if not self.isMouseDragImg1: + return + + self.isMouseDragImg1 = False + self.clearTempBrushImage() + self.update_rp() + + doUpdateImages = self.checkWarnDeletedIDwithEraser() + if doUpdateImages: + self.updateAllImages() + + def finishBrushStroke(self): + if not self.isMouseDragImg1: + return - lab = posData.lab - del_mask = np.isin(lab, new_IDs) - lab[del_mask] = 0 - posData.lab = lab - - self.update_rp() - - if posData.cca_df is not None: - posData.cca_df = posData.cca_df.drop(index=new_IDs) - self.store_data() - self.updateAllImages() - - def brushAutoFillToggled(self, checked): - val = 'Yes' if checked else 'No' - self.df_settings.at['brushAutoFill', 'value'] = val - self.df_settings.to_csv(self.settings_csv_path) - - def brushAutoHideToggled(self, checked): - val = 'Yes' if checked else 'No' - self.df_settings.at['brushAutoHide', 'value'] = val - self.df_settings.to_csv(self.settings_csv_path) + self.isMouseDragImg1 = False + self.clearTempBrushImage() + self.brushReleased() def brushReleased(self): posData = self.data[self.pos_i] @@ -13183,6 +13140,24 @@ def disconnectLeftClickButtons(self): # Not all the LeftClickButtons have toggled connected pass + def deactivateOtherExclusiveEditTools(self, keep=None): + for tool in self.exclusiveEditTools: + if tool != keep and tool.isChecked(): + tool.setChecked(False) + + def _usesImg2MousePressHandler(self): + return any([ + self.separateBudButton.isChecked(), + self.fillHolesToolButton.isChecked(), + self.hullContToolButton.isChecked(), + self.moveLabelToolButton.isChecked(), + self.mergeIDsButton.isChecked(), + self.editIDbutton.isChecked(), + self.keepIDsButton.isChecked(), + self.binCellButton.isChecked(), + self.ripCellButton.isChecked(), + ]) + def uncheckLeftClickButtons(self, sender): for button in self.LeftClickButtons: if button != sender: @@ -13206,6 +13181,39 @@ def uncheckLeftClickButtons(self, sender): if sender is not None: self.keepIDsButton.setChecked(False) + def _matchesKeyPressShortcut(self, event: QKeyEvent, shortcut) -> bool: + key = event.key() + modifiers = event.modifiers() + if modifiers not in (Qt.NoModifier, Qt.KeypadModifier): + return False + if isinstance(shortcut, int): + return key == shortcut + if isinstance(shortcut, QKeySequence): + if shortcut.count() == 0: + return False + return shortcut[0] == key + try: + return int(shortcut) == key + except (TypeError, ValueError): + return False + + def activateExclusiveEditTool(self, tool): + if tool not in self.exclusiveEditTools: + if tool.isCheckable(): + tool.setChecked(not tool.isChecked()) + else: + tool.trigger() + return + + if tool.isChecked(): + tool.setChecked(False) + return + + self.disconnectLeftClickButtons() + self.deactivateOtherExclusiveEditTools(keep=tool) + tool.setChecked(True) + self.connectLeftClickButtons() + def connectLeftClickButtonsPointsLayersToolbar(self): for toolbar in self.pointsLayersToolbars: for action in toolbar.actions()[1:]: @@ -13222,6 +13230,8 @@ def connectLeftClickButtons(self): self.curvToolButton.toggled.connect(self.curvTool_cb) self.rulerButton.toggled.connect(self.ruler_cb) self.eraserButton.toggled.connect(self.Eraser_cb) + self.editIDbutton.toggled.connect(self.editID_cb) + self.deleteIDButton.toggled.connect(self.deleteID_cb) self.wandToolButton.toggled.connect(self.wand_cb) self.labelRoiButton.toggled.connect(self.labelRoi_cb) self.magicPromptsToolButton.toggled.connect(self.magicPrompts_cb) @@ -14229,12 +14239,14 @@ def Brush_cb(self, checked): self.setBrushID() self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(self.sender()) + self.deactivateOtherExclusiveEditTools(keep=self.sender()) c = self.defaultToolBarButtonColor self.eraserButton.setStyleSheet(f'background-color: {c}') self.connectLeftClickButtons() self.setFocusGraphics() else: + if self.isMouseDragImg1: + self.finishLazyStroke() self.ax1_lostObjScatterItem.setVisible(True) self.ax2_lostObjScatterItem.setVisible(True) self.ax1_lostTrackedScatterItem.setVisible(True) @@ -14246,7 +14258,20 @@ def Brush_cb(self, checked): self.resetCursors() self.showEditIDwidgets(checked) + self.brushLazyModeAction.setVisible(checked) self.enableSizeSpinbox(checked) + + def editID_cb(self, checked): + if checked: + self.disconnectLeftClickButtons() + self.deactivateOtherExclusiveEditTools(keep=self.editIDbutton) + self.connectLeftClickButtons() + + def deleteID_cb(self, checked): + if checked: + self.disconnectLeftClickButtons() + self.deactivateOtherExclusiveEditTools(keep=self.deleteIDButton) + self.connectLeftClickButtons() def showEditIDwidgets(self, visible): self.editIDLabelAction.setVisible(visible) @@ -14415,8 +14440,9 @@ def equalizeHist(self, checked=True): def curvTool_cb(self, checked): posData = self.data[self.pos_i] if checked: + self.setDiskMask() self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(self.curvToolButton) + self.deactivateOtherExclusiveEditTools(keep=self.curvToolButton) self.connectLeftClickButtons() self.hoverLinSpace = np.linspace(0, 1, 1000) self.curvPlotItem = pg.PlotDataItem(pen=self.newIDs_cpen) @@ -14513,11 +14539,13 @@ def Eraser_cb(self, checked): ) self.updateEraserCursor(self.xHoverImg, self.yHoverImg) self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(self.sender()) + self.deactivateOtherExclusiveEditTools(keep=self.sender()) c = self.defaultToolBarButtonColor self.brushButton.setStyleSheet(f'background-color: {c}') self.connectLeftClickButtons() else: + if self.isMouseDragImg1: + self.finishLazyStroke() self.setHoverToolSymbolData( [], [], (self.ax1_EraserCircle, self.ax2_EraserCircle, self.ax1_EraserX, self.ax2_EraserX) @@ -14526,6 +14554,7 @@ def Eraser_cb(self, checked): self.updateAllImages() self.showEditIDwidgets(checked) + self.brushLazyModeAction.setVisible(checked) self.enableSizeSpinbox(checked) def storeCurrentAnnotOptions_ax1(self, return_value=False): @@ -14719,88 +14748,23 @@ def _resizeLeaveSpaceTerminalBelow(self): height = geometry.height() self.setGeometry(left, top+10, width, height-200) - def checkSetDelObjActionActive(self, event): - if self.delObjAction is None and self.is_win: - return - - if self.delObjAction is None: - # On mac we check for Key_Control - if event.key() == Qt.Key_Control: - self.delObjToolAction.setChecked(True) - return - - delObjKeySequence, delObjQtButton = self.delObjAction - keySequenceText = widgets.QKeyEventToString(event).rstrip('+') - - if delObjKeySequence is None: - # self.delObjToolAction.setChecked(True) - return - - delObjKeySequenceText = widgets.macShortcutToWindows( - delObjKeySequence.toString() - ) - keySequenceText = widgets.macShortcutToWindows(keySequenceText) - - # printl( - # delObjKeySequence.toString(), - # keySequenceText, - # delObjKeySequenceText - # ) - - if keySequenceText == delObjKeySequenceText: - self.delObjToolAction.setChecked(True) - - def changeRightClickToLeftOnMac(self, mouseEvent): - button = mouseEvent.button() - if not is_mac: - return button - - delObjKeySequence, delObjQtButton = self.delObjAction - if delObjKeySequence is None: - return button - - if not delObjKeySequence.toString() == 'Control': - return button - - if button != Qt.MouseButton.RightButton: - return button - - if delObjQtButton == Qt.MouseButton.LeftButton: - # On mac, pressing "Control" and clicking with left button changes - # it to a right click button --> here, left click is required for - # delete object --> force return of left click - return Qt.MouseButton.LeftButton - - return button - - def checkTriggerKeyPressShortcuts(self, event: QKeyEvent): - isBrushKey = event.key() == self.brushButton.keyPressShortcut - isEraserKey = event.key() == self.eraserButton.keyPressShortcut + isBrushKey = self._matchesKeyPressShortcut( + event, self.brushButton.keyPressShortcut + ) + isEraserKey = self._matchesKeyPressShortcut( + event, self.eraserButton.keyPressShortcut + ) if isBrushKey or isEraserKey: return isBrushKey, isEraserKey - - modifierText = widgets.modifierKeyToText(event.modifiers()) - for widget in self.widgetsWithShortcut.values(): + + for widget in self.exclusiveEditTools: if not hasattr(widget, 'keyPressShortcut'): continue - - if event.key() == widget.keyPressShortcut: - if widget.isCheckable(): - widget.setChecked(True) - else: - widget.trigger() - continue - - shortcutText = widget.keyPressShortcut.toString() - try: - mod, key = shortcutText.split('+') - if modifierText == mod and event.key() == QKeySequence(key): - widget.trigger() - - except Exception as e: - pass - + if self._matchesKeyPressShortcut(event, widget.keyPressShortcut): + self.activateExclusiveEditTool(widget) + break + return isBrushKey, isEraserKey def _temp_debug(self, id=None): @@ -14895,8 +14859,6 @@ def keyPressEvent(self, ev): isCtrlModifier = modifiers == Qt.ControlModifier isShiftModifier = modifiers == Qt.ShiftModifier - self.checkSetDelObjActionActive(ev) - self.isZmodifier = ( ev.key()== Qt.Key_Z and not isAltModifier and not isCtrlModifier and not isShiftModifier @@ -15027,8 +14989,11 @@ def keyPressEvent(self, ev): # If first time clicking B activate brush and start timer # to catch double press of B if not self.Button.isChecked(): + self.disconnectLeftClickButtons() + self.deactivateOtherExclusiveEditTools(keep=self.Button) self.uncheck = False self.Button.setChecked(True) + self.connectLeftClickButtons() else: self.uncheck = True self.countKeyPress = 1 @@ -15131,14 +15096,11 @@ def keyReleaseEvent(self, ev): or ev.key() == Qt.Key_Down or ev.key() == Qt.Key_Control or ev.key() == Qt.Key_Backspace - or self.delObjToolAction.isChecked() ) if canRepeat and ev.isAutoRepeat(): return - self.delObjToolAction.setChecked(False) - if ev.isAutoRepeat() and not ev.key() == Qt.Key_Z: if self.warnKeyPressedMsg is not None: return @@ -20180,7 +20142,10 @@ def _get_current_voxel_sizes(self): return None def _get_overlay_zstacks(self): - """Return list of (data, opacity, cmap) for each active overlay volume. + """Return list of overlay tuples for each active overlay volume. + + Each entry is ``(data, opacity, cmap_spec, mode_override, meta)`` where + *meta* contains ``kind``, ``channel_name``, and ``overlay_index``. Covers three sources: 1. Fluorescence overlay channels (alpha scrollbar opacity, toolbar gate). @@ -20188,7 +20153,6 @@ def _get_overlay_zstacks(self): and labelsAlphaSlider > 0. 3. Overlay labels channels — when the overlay-labels button is checked. """ - _FLUO_CMAPS = ['green', 'magenta', 'cyan', 'yellow', 'orange'] _LABEL_CMAPS = ['blue', 'cyan', 'magenta'] if not self.isDataLoaded: return [] @@ -20213,8 +20177,13 @@ def _get_overlay_zstacks(self): if data.ndim != 3: continue opacity = alphaSB.value() / alphaSB.maximum() - cmap = _FLUO_CMAPS[i % len(_FLUO_CMAPS)] - result.append((data, opacity, cmap)) + cmap_spec = lutItem.gradient.colorMap() + meta = { + 'kind': 'fluo', + 'channel_name': chName, + 'overlay_index': len(result), + } + result.append((data, opacity, cmap_spec, None, meta)) # -- 2. Primary segmentation mask (2D or 3D segmentation) ------------- how = self.drawIDsContComboBox.currentText() @@ -20222,16 +20191,40 @@ def _get_overlay_zstacks(self): if 'overlay segm. masks' in how and labels_alpha > 0 and posData.lab is not None: lab = posData.lab if lab.ndim == 3: + label_vol = lab.astype(np.int32, copy=False) mask = (lab > 0).astype(np.float32) elif lab.ndim == 2: - # 2D segmentation on a 3D z-stack: extrude along Z (cylinder) + label_vol = np.repeat( + lab.astype(np.int32, copy=False)[np.newaxis], + posData.SizeZ, + axis=0, + ) mask = np.repeat( (lab > 0).astype(np.float32)[np.newaxis], posData.SizeZ, axis=0 ) else: mask = None - if mask is not None: - result.append((mask, float(labels_alpha), 'red', 'mip')) + label_vol = None + if mask is not None and label_vol is not None: + meta = { + 'kind': 'segm', + 'channel_name': '__segm__', + 'overlay_index': len(result), + 'label_data': label_vol, + } + segm_alpha_max = max(1, self.imgGrad.labelsAlphaSlider.maximum()) + segm_blend = float(labels_alpha) + if segm_blend <= 0: + segm_blend = 50.0 + result.append( + ( + label_vol.astype(np.float32, copy=False), + segm_blend, + self.getLabelsImageLut(), + None, + meta, + ) + ) # -- 3. Overlay label channels ----------------------------------------- ol_labels_active = ( @@ -20246,15 +20239,27 @@ def _get_overlay_zstacks(self): continue ol_lab = posData.ol_labels_data[segmEndname][posData.frame_i] if ol_lab.ndim == 3: + label_vol = ol_lab.astype(np.int32, copy=False) mask = (ol_lab > 0).astype(np.float32) elif ol_lab.ndim == 2: + label_vol = np.repeat( + ol_lab.astype(np.int32, copy=False)[np.newaxis], + posData.SizeZ, + axis=0, + ) mask = np.repeat( (ol_lab > 0).astype(np.float32)[np.newaxis], posData.SizeZ, axis=0 ) else: continue cmap = _LABEL_CMAPS[j % len(_LABEL_CMAPS)] - result.append((mask, 0.5, cmap, 'mip')) + meta = { + 'kind': 'ol_labels', + 'channel_name': segmEndname, + 'overlay_index': len(result), + 'label_data': label_vol, + } + result.append((mask, 0.5, cmap, 'translucent', meta)) return result @@ -20277,13 +20282,135 @@ def _get_current_zstack(self): return None return data + def _get_segm_label_volume(self): + """Return (Z, Y, X) int32 labels aligned to the current image z-stack.""" + if not self.isDataLoaded: + return None + posData = self.data[self.pos_i] + if posData.SizeZ <= 1 or posData.lab is None: + return None + img = posData.img_data[posData.frame_i] + if img.ndim == 4: + z_depth = img.shape[0] + elif img.ndim == 3: + z_depth = img.shape[0] + else: + return None + lab = posData.lab + if lab.ndim == 3: + label_vol = lab.astype(np.int32, copy=False) + if label_vol.shape[0] == 1 and z_depth > 1: + label_vol = np.repeat(label_vol, z_depth, axis=0) + elif lab.ndim == 2: + label_vol = np.repeat( + lab.astype(np.int32, copy=False)[np.newaxis], + z_depth, + axis=0, + ) + else: + return None + return label_vol + + def _get_3d_renderer_overlays_without_segm(self): + from .renderer3d import OVERLAY_KIND_SEGM, _parse_overlay_entry + + overlays = self._get_overlay_zstacks() + filtered = [] + for index, entry in enumerate(overlays): + *_, meta = _parse_overlay_entry(entry, index) + if meta.get('kind') == OVERLAY_KIND_SEGM: + continue + filtered.append(entry) + return filtered + + def _current_3d_segm_blend(self) -> float: + """BF↔Segm crossfade — 3D-only (defaults to 50 until the 3D window exists).""" + win = getattr(self, '_renderer3d_window', None) + if win is not None: + return float(win._segm_blend) + return 50.0 + + def _with_3d_segm_blend(self, overlays): + """Replace segm overlay blend with the live 3D crossfade value.""" + from .renderer3d import OVERLAY_KIND_SEGM, _parse_overlay_entry + + win = getattr(self, '_renderer3d_window', None) + if win is None or not win.isVisible(): + return overlays + blend = float(win._segm_blend) + patched = [] + for index, entry in enumerate(overlays): + data, _opacity, cmap, mode, meta = _parse_overlay_entry(entry, index) + if meta.get('kind') == OVERLAY_KIND_SEGM: + patched.append((data, blend, cmap, mode, meta)) + else: + patched.append(entry) + return patched + + def _get_3d_renderer_overlays(self): + """Overlays for the 3D window, always including segmentation when labels exist.""" + from .renderer3d import OVERLAY_KIND_SEGM, _parse_overlay_entry + + overlays = self._with_3d_segm_blend(list(self._get_overlay_zstacks())) + for index, entry in enumerate(overlays): + *_, meta = _parse_overlay_entry(entry, index) + if meta.get('kind') == OVERLAY_KIND_SEGM: + return overlays + + label_vol = self._get_segm_label_volume() + if label_vol is None: + return overlays + + segm_blend = self._current_3d_segm_blend() + + meta = { + 'kind': OVERLAY_KIND_SEGM, + 'channel_name': '__segm__', + 'overlay_index': len(overlays), + 'label_data': label_vol, + } + overlays.append( + ( + label_vol.astype(np.float32, copy=False), + segm_blend, + None, + None, + meta, + ) + ) + return overlays + + def _push_volumes_to_3d_renderer(self, win) -> bool: + """Load brightfield/image as primary and segmentation as overlay.""" + if getattr(self, '_pushing_3d_volumes', False): + return True + self._pushing_3d_volumes = True + try: + data = self._get_current_zstack() + if data is None: + return False + + overlays = self._get_3d_renderer_overlays() + + if getattr(win, '_segmentation_only', False): + win.switch_to_image_volume(data) + elif win._volumes_data is None or not win.channels: + win.set_volume(data, channel_name='Channel 1') + else: + win.update_volume(data) + + if not win.refresh_overlay_volumes(overlays): + win.update_overlay_volumes(overlays) + return True + finally: + self._pushing_3d_volumes = False + @exception_handler def _launch_3d_renderer(self, *args, **kwargs): """Create (if needed) and show the 3D renderer; feed current data.""" from . import renderer3d # renderer3d itself only needs numpy/qtpy - data = self._get_current_zstack() - if data is None: + if self._get_current_zstack() is None: return myutils.check_install_package( @@ -20308,23 +20435,46 @@ def _launch_3d_renderer(self, *args, **kwargs): hide_on_close=True, adapter=adapter, ) - self._renderer3d_window.update_volume(data) - self._renderer3d_window.update_overlay_volumes( - self._get_overlay_zstacks() - ) - voxel_sizes = self._get_current_voxel_sizes() - if voxel_sizes is not None: - self._renderer3d_window.set_voxel_scale(*voxel_sizes) + else: + win = self._renderer3d_window + if ( + win._volumes_data is not None + and (win._volume_nodes is None or not win._volume_nodes) + ): + win._hide_on_close = False + win.close() + self._renderer3d_window = None + first_launch = True + adapter = _GuiWinRenderer3DAdapter(self) + self._renderer3d_window = renderer3d.create_renderer( + parent=None, + hide_on_close=True, + adapter=adapter, + ) + try: + if not self._push_volumes_to_3d_renderer(self._renderer3d_window): + return + voxel_sizes = self._get_current_voxel_sizes() + if voxel_sizes is not None: + self._renderer3d_window.set_metadata_voxel_sizes(*voxel_sizes) - posData = self.data[self.pos_i] - self._renderer3d_window.setWindowTitle( - f'3D Z-Stack Renderer — ' - f'Pos {self.pos_i + 1}, Frame {posData.frame_i + 1}' - ) - if first_launch: - self._position_renderer3d_window() - self._renderer3d_window.show() - self._renderer3d_window.raise_() + posData = self.data[self.pos_i] + self._renderer3d_window.setWindowTitle( + f'3D Z-Stack Renderer — ' + f'Pos {self.pos_i + 1}, Frame {posData.frame_i + 1}' + ) + if first_launch: + self._position_renderer3d_window() + self._renderer3d_window.update_cell_id_range() + self._renderer3d_window.show() + self._renderer3d_window.raise_() + except Exception: + if first_launch and self._renderer3d_window is not None: + failed_win = self._renderer3d_window + self._renderer3d_window = None + failed_win._hide_on_close = False + failed_win.close() + raise def _position_renderer3d_window(self): """Place the renderer window to the right of (or below) the main window.""" @@ -20369,16 +20519,15 @@ def _update_3d_renderer_if_active(self): return if not self._renderer3d_window.isVisible(): return - data = self._get_current_zstack() - if data is None: + if not self._push_volumes_to_3d_renderer(self._renderer3d_window): return - self._renderer3d_window.update_volume(data) - self._renderer3d_window.update_overlay_volumes( - self._get_overlay_zstacks() + self._renderer3d_window.update_cell_id_range() + self._renderer3d_window.set_active_cell_id( + self._renderer3d_window._active_cell_id, ) voxel_sizes = self._get_current_voxel_sizes() if voxel_sizes is not None: - self._renderer3d_window.set_voxel_scale(*voxel_sizes) + self._renderer3d_window.set_metadata_voxel_sizes(*voxel_sizes) posData = self.data[self.pos_i] self._renderer3d_window.setWindowTitle( f'3D Z-Stack Renderer — ' @@ -20513,16 +20662,32 @@ def curvToolSplineToObj(self, xxA=None, yyA=None, isRightClick=False): xxS, yyS = self.getClosedSplineCoords() + xx, yy = self.curvAnchors.getData() + if len(xx) > 0: + xdata, ydata = int(round(xx[0])), int(round(yy[0])) + else: + xdata, ydata = int(self.autoCont_x0), int(self.autoCont_y0) + if self.autoIDcheckbox.isChecked(): - self.setBrushID() - curvToolID = posData.brushID + hoverID = self.getHoverID(xdata, ydata) + if hoverID > 0: + curvToolID = hoverID + self.isNewID = False + else: + self.setBrushID() + curvToolID = posData.brushID + self.isNewID = True + self.updateLookuptable(lenNewLut=posData.brushID+1) else: curvToolID = self.editIDspinbox.value() - posData.brushID = curvToolID - - if curvToolID <= 0: - self.setBrushID() - curvToolID = posData.brushID + if curvToolID <= 0: + self.setBrushID() + curvToolID = posData.brushID + self.isNewID = True + self.updateLookuptable(lenNewLut=posData.brushID+1) + else: + self.isNewID = False + posData.brushID = curvToolID lab2D = self.get_2Dlab(posData.lab).copy() newIDMask = np.zeros(lab2D.shape, bool) @@ -23950,7 +24115,7 @@ def setupAddPointsByClicking(self, toolButton, isLoadedDf, toolbar): rightClickIDSpinbox.setMaximumWidth(pointIdSpinbox.sizeHint().width()) rightClickIDSpinbox.setValue(pointIdSpinbox.value()) rightClickIDSpinbox.setMinimum(0) - rightClickIDSpinbox.label = QLabel(' | Right-click ID: ') + rightClickIDSpinbox.label = QLabel(' | 2nd ID: ') rightClickIDSpinbox.labelAction = toolbar.addWidget( rightClickIDSpinbox.label ) @@ -24027,12 +24192,10 @@ def undoAddPoint(self, action): def getAddedPointId( self, isMagicPrompts, addPointsByClickingButton, - right_click, left_click, middle_click + left_click, middle_click ): action = addPointsByClickingButton.action - if right_click: - id = addPointsByClickingButton.rightClickIDSpinbox.value() - elif left_click: + if left_click: id = addPointsByClickingButton.pointIdSpinbox.value() id = self.getClickedPointNewId( action, id, addPointsByClickingButton.pointIdSpinbox, @@ -25842,10 +26005,10 @@ def initShortcuts(self): cp = config.ConfigParser() if os.path.exists(shortcut_filepath): cp.read(shortcut_filepath) - + if 'keyboard.shortcuts' not in cp: cp['keyboard.shortcuts'] = {} - + if cp.has_option('keyboard.shortcuts', 'Zoom out'): zoomOutKeyValueStr = cp['keyboard.shortcuts']['Zoom out'] try: @@ -25855,25 +26018,7 @@ def initShortcuts(self): f'{zoomOutKeyValueStr} is not a valid key ' 'zooming out action. Restoring default key "H".' ) - - if 'delete_object.action' not in cp: - self.delObjAction = None - else: - delObjKeySequenceText = cp['delete_object.action']['Key sequence'] - delObjButtonText = cp['delete_object.action']['Mouse button'] - delObjQtButton = ( - Qt.MouseButton.LeftButton if delObjButtonText == 'Left click' - else Qt.MouseButton.MiddleButton - ) - if not delObjKeySequenceText: - delObjKeySequence = None - else: - delObjKeySequence = widgets.KeySequenceFromText( - delObjKeySequenceText - ) - self.delObjToolAction.setChecked(True) - self.delObjAction = delObjKeySequence, delObjQtButton - + shortcuts = {} for name, widget in self.widgetsWithShortcut.items(): if name not in cp.options('keyboard.shortcuts'): @@ -25887,12 +26032,12 @@ def initShortcuts(self): else: shortcut_text = cp['keyboard.shortcuts'][name] shortcut = widgets.KeySequenceFromText(shortcut_text) - + shortcuts[name] = (shortcut_text, shortcut) self.setShortcuts(shortcuts, save=False) with open(shortcut_filepath, 'w') as ini: cp.write(ini) - + def setShortcuts(self, shortcuts: dict, save=True): for name, (text, shortcut) in shortcuts.items(): widget = self.widgetsWithShortcut[name] @@ -25905,85 +26050,29 @@ def setShortcuts(self, shortcuts: dict, save=True): s = widget.toolTip() toolTip = re.sub(r'Shortcut: "(.*)"', f'Shortcut: "{text}"', s) widget.setToolTip(toolTip) - - if not save: + + if not save: return - + from . import config cp = config.ConfigParser() if os.path.exists(shortcut_filepath): cp.read(shortcut_filepath) - + if 'keyboard.shortcuts' not in cp: cp['keyboard.shortcuts'] = {} for name, (text, shortcut) in shortcuts.items(): cp['keyboard.shortcuts'][name] = text - + cp['keyboard.shortcuts']['Zoom out'] = str(self.zoomOutKeyValue) - - if self.delObjAction is None: - with open(shortcut_filepath, 'w') as ini: - cp.write(ini) - return - - delObjKeySequence, delObjQtButton = self.delObjAction - try: - if delObjKeySequence is None: - delObjKeySequenceText = '' - else: - delObjKeySequenceText = delObjKeySequence.toString() - - delObjKeySequenceText = ( - delObjKeySequenceText - .encode('ascii', 'ignore') - .decode('utf-8') - ) - delObjButtonText = ( - 'Left click' if delObjQtButton == Qt.MouseButton.LeftButton - else 'Middle click' - ) - cp['delete_object.action'] = { - 'Key sequence': delObjKeySequenceText, - 'Mouse button': delObjButtonText - } - except Exception as err: - self.logger.warning( - f'{delObjKeySequence} is not a valid keys sequence for ' - 'deleting objects. Setting default action' - ) - self.delObjAction = None - cp.remove_section('delete_object.action') - + with open(shortcut_filepath, 'w') as ini: cp.write(ini) - + def editShortcuts_cb(self): - if is_mac: - delObjKeySequenceText = 'Ctrl' - delObjButtonText = 'Left click' - else: - delObjKeySequenceText = '' - delObjButtonText = 'Middle click' - - if self.delObjAction is not None: - delObjKeySequence, delObjQtButton = self.delObjAction - if delObjKeySequence is None: - delObjKeySequenceText = '' - else: - delObjKeySequenceText = delObjKeySequence.toString() - delObjKeySequenceText = ( - delObjKeySequenceText.encode('ascii', 'ignore').decode('utf-8') - ) - delObjButtonText = ( - 'Left click' if delObjQtButton == Qt.MouseButton.LeftButton - else 'Middle click' - ) - win = apps.ShortcutEditorDialog( - self.widgetsWithShortcut, - delObjectKey=delObjKeySequenceText, - delObjectButton=delObjButtonText, + self.widgetsWithShortcut, zoomOutKeyValue=self.zoomOutKeyValue, parent=self ) @@ -25991,7 +26080,6 @@ def editShortcuts_cb(self): if win.cancel: return - self.delObjAction = win.delObjAction self.zoomOutKeyValue = win.zoomOutKeyValue self.setShortcuts(win.customShortcuts) @@ -28296,6 +28384,81 @@ def removeStoredContours(self, delID, frame_i=None, z_slice=None): except KeyError as err: pass + @disableWindow + def _handleDeleteIDClick(self, event, shift=False): + posData = self.data[self.pos_i] + x, y = event.pos().x(), event.pos().y() + delID = self.get_2Dlab(posData.lab)[int(y), int(x)] + if delID == 0: + nearest_ID = core.nearest_nonzero_2D( + self.get_2Dlab(posData.lab), y, x + ) + delID_prompt = apps.QLineEditDialog( + title='Clicked on background', + msg='You clicked on the background.
' + 'Enter here ID(s) that you want to delete

' + 'You can enter multiple IDs separated by comma', + parent=self, + allowedValues=posData.IDs, + defaultTxt=str(nearest_ID), + allowList=True, + isInteger=True + ) + delID_prompt.exec_() + if delID_prompt.cancel: + return + delIDs = delID_prompt.EntryID + else: + delIDs = [delID] + + key = 'Delete ID' + askAction = self.askHowFutureFramesActions[key] + doNotShow = not askAction.isChecked() + (UndoFutFrames, applyFutFrames, endFrame_i, + doNotShowAgain) = self.propagateChange( + delIDs, key, doNotShow, + posData.UndoFutFrames_DelID, posData.applyFutFrames_DelID + ) + + if UndoFutFrames is None: + return + + self.storeUndoRedoStates(UndoFutFrames) + posData.doNotShowAgain_DelID = doNotShowAgain + posData.UndoFutFrames_DelID = UndoFutFrames + posData.applyFutFrames_DelID = applyFutFrames + includeUnvisited = posData.includeUnvisitedInfo['Delete ID'] + + delID_mask = self.deleteIDmiddleClick( + delIDs, applyFutFrames, includeUnvisited, shift=shift + ) + if delID_mask.ndim == 3: + delID_mask = delID_mask[self.z_lab()] + + if self.isSnapshot: + self.fixCcaDfAfterEdit('Delete ID') + else: + self.warnEditingWithCca_df('Delete ID', update_images=False) + + self.setImageImg2() + delROIsIDs = self.setAllTextAnnotations() + self.setAllContoursImages(delROIsIDs=delROIsIDs, compute=False) + + how = self.drawIDsContComboBox.currentText() + if how.find('overlay segm. masks') != -1: + self.labelsLayerImg1.image[delID_mask] = 0 + self.labelsLayerImg1.setImage(self.labelsLayerImg1.image) + + how_ax2 = self.getAnnotateHowRightImage() + if how_ax2.find('overlay segm. masks') != -1: + self.labelsLayerRightImg.image[delID_mask] = 0 + self.labelsLayerRightImg.setImage(self.labelsLayerRightImg.image) + + self.highlightLostNew() + + if not self.deleteIDButton.findChild(QAction).isChecked(): + self.deleteIDButton.setChecked(False) + @disableWindow def deleteIDmiddleClick( self, delIDs: Iterable, applyFutFrames, includeUnvisited, @@ -33045,6 +33208,18 @@ def onEscape( self.typingEditID = False QTimer.singleShot(300, self.autoRange) return + + if ( + self.isMouseDragImg1 + and self._lazyModeEnabled() + and ( + self.brushButton.isChecked() + or self.eraserButton.isChecked() + ) + ): + self.finishLazyStroke() + QTimer.singleShot(300, self.autoRange) + return if isTypingIDFunctionChecked and self.typingEditID: self.typingEditID = False diff --git a/cellacdc/renderer3d.py b/cellacdc/renderer3d.py index 009ff8ea..39ce29e6 100644 --- a/cellacdc/renderer3d.py +++ b/cellacdc/renderer3d.py @@ -1,5 +1,4 @@ from __future__ import annotations -from functools import partial import numpy as np @@ -18,7 +17,7 @@ QGraphicsProxyWidget, QGridLayout ) -from qtpy.QtCore import Qt +from qtpy.QtCore import Qt, QTimer from qtpy.QtGui import QKeySequence import pyqtgraph as pg @@ -90,6 +89,131 @@ 'plane_x': ([1.0, 0.0, 0.0], 2), # normal along scene-X = data-X axis } +# Overlay channels live in ``_volume_nodes`` with this prefix (e.g. +# ``overlay:0``). Primary fluorescence channels use plain names from +# ``self.channels`` and have LUT sliders; overlays do not. +OVERLAY_CHANNEL_PREFIX = 'overlay:' + + +def is_overlay_channel(channel: str) -> bool: + return channel.startswith(OVERLAY_CHANNEL_PREFIX) + + +def overlay_channel_name(index: int) -> str: + return f'{OVERLAY_CHANNEL_PREFIX}{index}' + + +OVERLAY_KIND_FLUO = 'fluo' +OVERLAY_KIND_SEGM = 'segm' +# Label overlays use translucent alpha compositing (not additive, which sums +# RGB along the ray and washes multi-colour labels to white). +_LABEL_OVERLAY_MODE = 'translucent' +_LABEL_VOLUME_INTERP = 'nearest' + +SEGM_PRIMARY_CHANNEL = 'Segmentation' + +OVERLAY_KIND_OL_LABELS = 'ol_labels' + + +def _volume_cmap_from_spec(cmap_spec): + """Build a vispy colormap from a PG ColorMap, colour name, or passthrough.""" + if cmap_spec is None: + return colors.vispy_cmap_from_spec('green') + if hasattr(cmap_spec, 'getLookupTable'): + return colors.pg_to_vispy_cmap(cmap_spec) + if isinstance(cmap_spec, str): + return colors.vispy_cmap_from_spec(cmap_spec) + return cmap_spec + + +def _parse_overlay_entry(entry, index: int): + """Return (data, opacity, cmap_spec, mode_override, meta) from an overlay tuple.""" + data, opacity, cmap_spec = entry[0], entry[1], entry[2] + mode_override = None + meta: dict = {'overlay_index': index} + if len(entry) > 3: + third = entry[3] + if third is None: + if len(entry) > 4 and isinstance(entry[4], dict): + meta.update(entry[4]) + elif isinstance(third, str): + mode_override = third + if len(entry) > 4 and isinstance(entry[4], dict): + meta.update(entry[4]) + elif isinstance(third, dict): + meta.update(third) + return data, opacity, cmap_spec, mode_override, meta + + +def _is_labels_lut_spec(cmap_spec) -> bool: + return ( + isinstance(cmap_spec, np.ndarray) + and cmap_spec.ndim == 2 + and cmap_spec.shape[1] >= 3 + ) + + +def _labels_overlay_clim(cmap_spec) -> tuple[float, float]: + if _is_labels_lut_spec(cmap_spec): + # Center integer label IDs on LUT entries (matches napari / PG indexing). + n = float(len(cmap_spec)) + return (-0.5, n - 0.5) + return (0.0, 1.0) + + +def _label_overlay_cmap(cmap_spec): + if _is_labels_lut_spec(cmap_spec): + return colors.labels_lut_vispy_cmap(cmap_spec) + if isinstance(cmap_spec, str): + return colors.overlay_mask_vispy_cmap(cmap_spec) + return _volume_cmap_from_spec(cmap_spec) + + +def _labels_for_display(labels: np.ndarray, cell_id: int) -> np.ndarray: + """Return label IDs for 3D overlay coloring (matches 2D LUT indexing).""" + lab = labels.astype(np.float32, copy=False) + if int(cell_id) <= 0: + return lab + cid = float(int(cell_id)) + return np.where(lab == cid, lab, 0.0).astype(np.float32, copy=False) + + +def _segm_blend_opacities(blend_0_to_100: float) -> tuple[float, float]: + """Return (brightfield_opacity, segmentation_opacity) for blend 0–100.""" + t = max(0.0, min(100.0, float(blend_0_to_100))) / 100.0 + return 1.0 - t, t + + +def _resample_z_axis( + vol: np.ndarray, + z_factor: float, + *, + is_labels: bool, + ) -> np.ndarray: + """Stretch or compress the Z axis with ``scipy.ndimage.zoom``.""" + if abs(z_factor - 1.0) < 1e-6: + return vol + try: + import scipy.ndimage # noqa: PLC0415 + except ImportError: + return vol + order = 0 if is_labels else 1 + return scipy.ndimage.zoom(vol, (z_factor, 1.0, 1.0), order=order) + + +def _scene_pos_to_voxel_indices( + local_pos, + shape: tuple[int, int, int], + ) -> tuple[int, int, int] | None: + """Convert vispy volume local coordinates to (z, y, x) voxel indices.""" + nz, ny, nx = shape + x = int(np.floor(float(local_pos[0]) + 0.5)) + y = int(np.floor(float(local_pos[1]) + 0.5)) + z = int(np.floor(float(local_pos[2]) + 0.5)) + if 0 <= z < nz and 0 <= y < ny and 0 <= x < nx: + return (z, y, x) + return None + # --------------------------------------------------------------------------- # Pure-stdlib PNG writer (fallback when skimage is not available) @@ -136,6 +260,28 @@ def get_voxel_sizes(self) -> tuple[float, float, float] | None: def on_renderer_closed(self) -> None: """Called when the renderer window is closed (hidden).""" + def on_main_overlay_changed(self) -> None: + """Push overlay opacity/cmap changes from the main GUI to the renderer.""" + + def apply_overlay_control_from_renderer( + self, + channel_name: str, + opacity: float | None = None, + gradient_state: dict | None = None, + labels_alpha: float | None = None, + ) -> None: + """Update main GUI overlay widgets from the 3D renderer (bidirectional sync).""" + + def get_available_cell_ids(self) -> list[int]: + """Return valid cell IDs for the current frame, or empty if unknown.""" + return [] + + def apply_cell_id_from_renderer(self, cell_id: int) -> None: + """Update main GUI cell selection from the 3D renderer.""" + + def on_cell_id_changed_from_main(self, cell_id: int) -> None: + """Push main GUI cell ID selection to the 3D renderer.""" + # --------------------------------------------------------------------------- # Controls widget @@ -202,31 +348,80 @@ def _build(self) -> None: layout.addFormWidget(_step_form_widget, row=row) row += 1 - self._opacity_spins = {} - for r, channel in enumerate(self._channels): - opacity_spin = widgets.sliderWithSpinBox( - title_loc='in_line', - isFloat=True, - parent=self, - normalize_factor=20 - ) - opacity_spin.setRange(0.0, 1.0) - opacity_spin.setSingleStep(0.05) - opacity_spin.setValue(1.0) - opacity_spin.setDecimals(2) - opacity_spin.setToolTip( - f'Opacity for {channel} (0 = transparent, 1 = opaque).\n' - 'Mirrors napari\'s layer opacity control.' - ) - opacity_spin.valueChanged.connect( - partial(self._on_opacity_changed, channel=channel) - ) - _opacity_form_widget = widgets.formWidget( - opacity_spin, - labelTextLeft=f'Opacity ({channel}):', - ) - layout.addFormWidget(_opacity_form_widget, row=row+r) - self._opacity_spins[channel] = opacity_spin + cell_id_row = QHBoxLayout() + self._cell_id_spin = widgets.SpinBox() + self._cell_id_spin.setMinimum(0) + self._cell_id_spin.setMaximum(999999) + self._cell_id_spin.setToolTip( + 'Show only this cell ID in segmentation overlays (0 = all cells).\n' + 'Shift+left-click on the volume to pick a cell.' + ) + self._cell_id_spin.valueChanged.connect(self._on_cell_id_changed) + cell_id_row.addWidget(self._cell_id_spin) + self._show_all_cells_btn = QPushButton('Show all') + self._show_all_cells_btn.setToolTip( + 'Reset to show all labelled cells (Cell ID 0)' + ) + self._show_all_cells_btn.clicked.connect(self._on_show_all_cells) + cell_id_row.addWidget(self._show_all_cells_btn) + cell_id_widget = QWidget() + cell_id_widget.setLayout(cell_id_row) + _cell_id_form = widgets.formWidget( + cell_id_widget, + labelTextLeft='Cell ID:', + ) + layout.addFormWidget(_cell_id_form, row=row) + + row += 1 + z_aniso_row = QHBoxLayout() + self._z_aniso_spin = widgets.sliderWithSpinBox( + title_loc='in_line', + isFloat=True, + parent=self, + normalize_factor=10, + ) + self._z_aniso_spin.setRange(0.1, 50.0) + self._z_aniso_spin.setSingleStep(0.1) + self._z_aniso_spin.setValue(1.0) + self._z_aniso_spin.setToolTip( + 'Physical Z/X voxel size ratio.\n' + '1.0 = isotropic. Values >1 stretch Z.' + ) + self._z_aniso_spin.valueChanged.connect(self._on_z_aniso_changed) + z_aniso_row.addWidget(self._z_aniso_spin) + self._z_aniso_reset_btn = QPushButton('Reset') + self._z_aniso_reset_btn.setToolTip( + 'Reset Z anisotropy to the value from image metadata' + ) + self._z_aniso_reset_btn.clicked.connect(self._on_z_aniso_reset) + z_aniso_row.addWidget(self._z_aniso_reset_btn) + z_aniso_widget = QWidget() + z_aniso_widget.setLayout(z_aniso_row) + layout.addFormWidget( + widgets.formWidget(z_aniso_widget, labelTextLeft='Z anisotropy (dz/dx):'), + row=row, + ) + + row += 1 + self._resample_z_cb = QCheckBox('Resample Z to isotropic voxels') + self._resample_z_cb.setToolTip( + 'Resample volume data along Z before GPU upload so voxels are\n' + 'physically isotropic. Slower and uses more memory than transform-only.' + ) + self._resample_z_cb.toggled.connect(self._on_resample_z_changed) + layout.addFormWidget( + widgets.formWidget(self._resample_z_cb, labelTextLeft=''), + row=row, + ) + + self._z_aniso_debounce = QTimer(self) + self._z_aniso_debounce.setSingleShot(True) + self._z_aniso_debounce.setInterval(150) + self._z_aniso_debounce.timeout.connect(self._emit_z_aniso_changed) + self._pending_z_aniso = 1.0 + + row += 1 + # Segmentation label colours are edited via the right-side labels gradient bar. layout.addNewColumn(with_separator=True) @@ -255,32 +450,23 @@ def _build(self) -> None: labelTextLeft='Interpolation:', ) layout.addFormWidget(_interp_form_widget, row=row) - - row1 = QHBoxLayout() - row2 = QHBoxLayout() - - # Rendering mode - row1.addWidget(QLabel('Render:')) - self._mode_combo = QComboBox() - for mode_id, label in RENDERING_MODES: - self._mode_combo.addItem(label, mode_id) - self._mode_combo.currentIndexChanged.connect(self._on_mode_changed) - row1.addWidget(self._mode_combo) - # ISO threshold + smooth option (iso mode only) - self._iso_label = QLabel('ISO:') - row1.addWidget(self._iso_label) + row += 1 self._iso_spin = QDoubleSpinBox() self._iso_spin.setRange(0.0, 1.0) self._iso_spin.setSingleStep(0.01) self._iso_spin.setValue(0.5) self._iso_spin.setDecimals(3) - self._iso_spin.setFixedWidth(65) self._iso_spin.setToolTip('Isosurface threshold') self._iso_spin.valueChanged.connect(self._on_iso_changed) - row1.addWidget(self._iso_spin) + self._iso_label = _iso_form_widget = widgets.formWidget( + self._iso_spin, + labelTextLeft='ISO:', + ) + layout.addFormWidget(_iso_form_widget, row=row) - self._smooth_iso_cb = QCheckBox('Smooth') + row += 1 + self._smooth_iso_cb = QCheckBox('Pre-smooth volume for ISO rendering') self._smooth_iso_cb.setToolTip( 'Pre-smooth the volume with a Gaussian filter (σ=1) before ISO\n' 'rendering. Approximates napari\'s SMOOTH_GRADIENT_DEFINITION\n' @@ -288,29 +474,26 @@ def _build(self) -> None: 'requiring custom GLSL injection.' ) self._smooth_iso_cb.toggled.connect(self._on_smooth_iso_changed) - row1.addWidget(self._smooth_iso_cb) + layout.addFormWidget( + widgets.formWidget(self._smooth_iso_cb, labelTextLeft=''), + row=row, + ) - # Attenuation (attenuated_mip mode only) - self._attn_label = QLabel('Attn:') - row1.addWidget(self._attn_label) + row += 1 self._attn_spin = QDoubleSpinBox() self._attn_spin.setRange(0.0, 2.0) self._attn_spin.setSingleStep(0.05) self._attn_spin.setValue(0.5) self._attn_spin.setDecimals(2) - self._attn_spin.setFixedWidth(60) self._attn_spin.setToolTip('Attenuation factor for Attenuated MIP') self._attn_spin.valueChanged.connect(self._on_attn_changed) - row1.addWidget(self._attn_spin) - - row1.addStretch() - - # ── Row 2: Display / camera parameters ─────────────────────────────── - - + self._attn_label = _attn_form_widget = widgets.formWidget( + self._attn_spin, + labelTextLeft='Attenuation:', + ) + layout.addFormWidget(_attn_form_widget, row=row) - # Depiction (napari: layer.depiction — volume vs plane) - row2.addWidget(QLabel('Depict:')) + row += 1 self._depict_combo = QComboBox() for did, dlabel in DEPICTION_MODES: self._depict_combo.addItem(dlabel, did) @@ -319,34 +502,42 @@ def _build(self) -> None: 'Z-Plane: single cross-section embedded in 3D space.' ) self._depict_combo.currentIndexChanged.connect(self._on_depiction_changed) - row2.addWidget(self._depict_combo) + layout.addFormWidget( + widgets.formWidget(self._depict_combo, labelTextLeft='Depiction:'), + row=row, + ) - self._zplane_label = QLabel('Pos:') - row2.addWidget(self._zplane_label) + row += 1 self._zplane_slider = QSlider(Qt.Horizontal) self._zplane_slider.setMinimum(0) self._zplane_slider.setMaximum(99) self._zplane_slider.setValue(50) - self._zplane_slider.setFixedWidth(80) - self._zplane_slider.setToolTip('Position of the cross-section plane (0=start, 100=end)') + self._zplane_slider.setToolTip( + 'Position of the cross-section plane (0=start, 100=end)' + ) self._zplane_slider.valueChanged.connect(self._on_zplane_changed) - row2.addWidget(self._zplane_slider) + self._zplane_label = _zplane_form_widget = widgets.formWidget( + self._zplane_slider, + labelTextLeft='Plane pos:', + ) + layout.addFormWidget(_zplane_form_widget, row=row) - # Plane thickness (napari: layer.plane.thickness / _on_plane_thickness_change) - self._plane_thick_label = QLabel('Thick:') - row2.addWidget(self._plane_thick_label) + row += 1 self._plane_thick_spin = QDoubleSpinBox() self._plane_thick_spin.setRange(1.0, 50.0) self._plane_thick_spin.setSingleStep(1.0) self._plane_thick_spin.setValue(1.0) self._plane_thick_spin.setDecimals(1) - self._plane_thick_spin.setFixedWidth(55) self._plane_thick_spin.setToolTip( 'Thickness of the plane cross-section in voxels.\n' 'Mirrors napari\'s plane.thickness parameter.' ) self._plane_thick_spin.valueChanged.connect(self._on_plane_thickness_changed) - row2.addWidget(self._plane_thick_spin) + self._plane_thick_label = _plane_thick_form_widget = widgets.formWidget( + self._plane_thick_spin, + labelTextLeft='Plane thick:', + ) + layout.addFormWidget(_plane_thick_form_widget, row=row) # Initially hide plane controls self._zplane_label.setVisible(False) @@ -354,8 +545,6 @@ def _build(self) -> None: self._plane_thick_label.setVisible(False) self._plane_thick_spin.setVisible(False) - row2.addStretch() - # Initial visibility for mode-specific controls self._update_mode_controls('mip') @@ -365,10 +554,8 @@ def _update_mode_controls(self, mode: str) -> None: show_iso = mode in _ISO_MODES show_attn = mode in _ATTN_MODES self._iso_label.setVisible(show_iso) - self._iso_spin.setVisible(show_iso) self._smooth_iso_cb.setVisible(show_iso) self._attn_label.setVisible(show_attn) - self._attn_spin.setVisible(show_attn) # -- slots ---------------------------------------------------------------- @@ -377,6 +564,33 @@ def _on_mode_changed(self, idx: int) -> None: self._update_mode_controls(mode) self._renderer.set_rendering_mode(mode) + def _on_cell_id_changed(self, value: int) -> None: + if getattr(self._renderer, '_syncing_cell_id_from_renderer', False): + return + self._renderer.set_active_cell_id(int(value)) + + def _on_show_all_cells(self) -> None: + self._cell_id_spin.setValue(0) + + def _on_z_aniso_changed(self, value: float) -> None: + if getattr(self._renderer, '_syncing_z_aniso', False): + return + self._pending_z_aniso = float(value) + self._z_aniso_debounce.start() + + def _emit_z_aniso_changed(self) -> None: + self._renderer.set_z_anisotropy_ratio( + self._pending_z_aniso, + from_user=True, + ) + + def _on_z_aniso_reset(self) -> None: + self._z_aniso_debounce.stop() + self._renderer.reset_z_anisotropy_to_metadata() + + def _on_resample_z_changed(self, checked: bool) -> None: + self._renderer.set_resample_z_enabled(checked) + def _on_gamma_changed(self, value: float) -> None: self._renderer.set_gamma(value) @@ -406,7 +620,9 @@ def _on_depiction_changed(self, idx: int) -> None: if is_plane: # Label shows which data axis the slider moves along. axis_names = {'plane_z': 'Z:', 'plane_y': 'Y:', 'plane_x': 'X:'} - self._zplane_label.setText(axis_names.get(mode, 'Pos:')) + self._zplane_label.labelLeft.setText( + axis_names.get(mode, 'Plane pos:') + ) # Reset the slider to centre so slider position always matches the # rendered plane (set_depiction initialises the plane at 0.5). self._zplane_slider.blockSignals(True) @@ -460,7 +676,30 @@ def __init__( self._adapter = adapter self._volume_nodes: dict[str, visuals.Volume] | None = None self._volumes_data: dict[str, np.ndarray] | None = None + self._raw_volumes_data: dict[str, np.ndarray] | None = None self.lut_items: dict[str, widgets.baseHistogramLUTitem] | None = None + self._labels_grad: widgets.labelsGradientWidget | None = None + self._3d_labels_lut_rgba: np.ndarray | None = None + self._3d_lut_rgb: np.ndarray | None = None + self._overlay_meta: dict[str, dict] = {} + self._overlay_widgets: dict[str, dict] = {} + self._overlay_controls_layout: QVBoxLayout | None = None + self._overlay_controls_host: QWidget | None = None + self._syncing_overlay_from_main = False + self._syncing_overlay_from_renderer = False + self._label_volumes: dict[str, np.ndarray] = {} + self._raw_label_volumes: dict[str, np.ndarray] = {} + self._raw_overlay_data: dict[str, np.ndarray] = {} + self._cached_overlay_entries: list[tuple] = [] + self._metadata_voxel_sizes: tuple[float, float, float] = (1.0, 1.0, 1.0) + self._z_aniso_user_override: float | None = None + self._resample_z_enabled: bool = False + self._syncing_z_aniso: bool = False + self._active_cell_id: int = 0 + self._syncing_cell_id_from_renderer = False + self._syncing_primary_lut_from_main = False + self._syncing_primary_lut_from_renderer = False + self._overlay_mode_overrides: dict[str, str | None] = {} self.channels: list[str] | None = None self._last_shape: tuple | None = None self._max_texture_3d: int | None = None # resolved on first upload @@ -468,10 +707,229 @@ def __init__( self._gpu_data_is_smoothed: bool = False # tracks whether GPU texture has Gaussian filter self._last_raw_data: np.ndarray | None = None # float32, for re-render self._ui_initialised: bool = False - self._is_set_volumes_first_call: bool = False + self._is_set_volumes_first_call: bool = True + self._controls = None + self._segmentation_only: bool = False + self._cached_primary_lut_state = None + self._segm_blend: float = 50.0 self._init_vispy() + # -- volume-node helpers (primary + overlay share ``_volume_nodes``) ------ + + def _ensure_volume_nodes(self) -> dict: + if self._volume_nodes is None: + self._volume_nodes = {} + return self._volume_nodes + + def _has_volume_nodes(self) -> bool: + return bool(self._volume_nodes) + + def _each_volume_node(self): + if self._volume_nodes: + yield from self._volume_nodes.values() + + def _volume_shape(self, channel: str) -> tuple[int, int, int] | None: + if self._volumes_data and channel in self._volumes_data: + return self._volumes_data[channel].shape + return self._last_shape + + def _remove_overlay_channels(self, clear_widgets: bool = True) -> None: + if not self._volume_nodes: + if clear_widgets: + self._clear_overlay_widgets() + return + for name in list(self._volume_nodes): + if not is_overlay_channel(name): + continue + self._volume_nodes[name].parent = None + del self._volume_nodes[name] + if self._volumes_data is not None: + self._volumes_data.pop(name, None) + self._overlay_meta.pop(name, None) + self._label_volumes.pop(name, None) + self._raw_label_volumes.pop(name, None) + self._raw_overlay_data.pop(name, None) + self._overlay_mode_overrides.clear() + if clear_widgets: + self._clear_overlay_widgets() + + def _clear_overlay_widgets(self) -> None: + self._overlay_widgets.clear() + if self._overlay_controls_layout is None: + return + while self._overlay_controls_layout.count(): + item = self._overlay_controls_layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.deleteLater() + + def _normalize_overlay_volume( + self, + data: np.ndarray, + *, + skip_resample: bool = False, + ) -> np.ndarray: + if data.ndim != 3: + raise ValueError( + f'Expected 3-D (Z, Y, X) overlay array; got shape {data.shape}' + ) + vol = data.astype(np.float32, copy=False) + if self._resample_z_enabled and not skip_resample: + vol = _resample_z_axis( + vol, + self._effective_z_ratio(), + is_labels=False, + ) + vmin, vmax = float(vol.min()), float(vol.max()) + max_tex = self._resolve_max_texture_3d() + if max(vol.shape) > max_tex: + vol = self._downsample_max_pool(vol, max_tex) + if vmax > vmin: + vol = (vol - vmin) / (vmax - vmin) + else: + vol = np.zeros_like(vol) + return vol + + def _process_label_volume(self, lab: np.ndarray) -> np.ndarray: + if self._resample_z_enabled: + lab = _resample_z_axis( + lab, + self._effective_z_ratio(), + is_labels=True, + ) + max_tex = self._resolve_max_texture_3d() + if max(lab.shape) > max_tex: + lab = self._downsample_max_pool(lab.astype(np.float32), max_tex).astype( + np.int32 + ) + return np.ascontiguousarray(lab) + + def _store_label_volume( + self, + channel_name: str, + label_data: np.ndarray, + ) -> None: + if label_data.ndim != 3: + return + lab = label_data.astype(np.int32, copy=False) + self._raw_label_volumes[channel_name] = np.ascontiguousarray(lab) + self._label_volumes[channel_name] = self._process_label_volume(lab) + + def _overlay_volume_from_labels(self, channel_name: str) -> np.ndarray | None: + labels = self._label_volumes.get(channel_name) + if labels is None: + return None + return np.ascontiguousarray( + _labels_for_display(labels, self._active_cell_id) + ) + + def _resolve_label_cmap_spec(self, cmap_spec): + if _is_labels_lut_spec(cmap_spec): + return cmap_spec + local = self._get_segm_labels_cmap() + if local is not None: + return local + return cmap_spec + + def _label_overlay_clim_for_key(self, channel_name: str) -> tuple[float, float]: + meta = self._overlay_meta.get(channel_name, {}) + cmap_spec = self._resolve_label_cmap_spec(meta.get('cmap_spec')) + return _labels_overlay_clim(cmap_spec) + + def _get_segm_labels_cmap(self): + lut = self._build_labels_image_lut() + if _is_labels_lut_spec(lut): + return lut + return None + + def _connect_canvas_events(self) -> None: + if not hasattr(self, '_canvas') or self._canvas is None: + return + self._canvas.events.mouse_press.connect(self._on_canvas_mouse_press) + + def _on_canvas_mouse_press(self, event) -> None: + modifiers = getattr(event, 'modifiers', ()) or () + if event.button != 1 or 'Shift' not in modifiers: + return + picked_id = self._pick_cell_id_at_canvas_pos(event.pos) + if picked_id is None: + return + self.set_active_cell_id(int(picked_id)) + + def _pick_cell_id_at_canvas_pos(self, canvas_pos) -> int | None: + key = self._find_overlay_by_kind(OVERLAY_KIND_SEGM) + if key is None: + for candidate, labels in self._label_volumes.items(): + if labels is not None: + key = candidate + break + if key is None or self._volume_nodes is None: + return None + labels = self._label_volumes.get(key) + volume_node = self._volume_nodes.get(key) + if labels is None or volume_node is None: + return None + try: + scene_pos = self._canvas.scene.node_transform.imap(canvas_pos) + node_tr = volume_node.transform + if node_tr is None: + return None + inv = node_tr.inverse + if inv is None: + return None + local_pos = inv.map(scene_pos) + except Exception: + return None + indices = _scene_pos_to_voxel_indices(local_pos, labels.shape) + if indices is None: + return None + z, y, x = indices + cell_id = int(labels[z, y, x]) + return cell_id if cell_id > 0 else None + + def update_cell_id_range(self) -> None: + if self._controls is None: + return + adapter = self._adapter + if adapter is None or not hasattr(adapter, 'get_available_cell_ids'): + return + ids = adapter.get_available_cell_ids() + if not ids: + return + spin = self._controls._cell_id_spin + spin.setMaximum(max(ids)) + + def set_active_cell_id(self, cell_id: int) -> None: + cell_id = int(cell_id) + self._active_cell_id = cell_id + if self._controls is not None and not self._syncing_cell_id_from_renderer: + self._syncing_cell_id_from_renderer = True + try: + spin = self._controls._cell_id_spin + spin.blockSignals(True) + spin.setValue(cell_id) + spin.blockSignals(False) + finally: + self._syncing_cell_id_from_renderer = False + + if self._volume_nodes is not None: + for key in list(self._label_volumes.keys()): + vol = self._overlay_volume_from_labels(key) + if vol is None: + continue + clim = self._label_overlay_clim_for_key(key) + self._volumes_data[key] = vol + volume_node = self._volume_nodes.get(key) + if volume_node is not None: + volume_node.set_data(vol, clim=clim) + volume_node.clim = clim + if self._segmentation_only and key == SEGM_PRIMARY_CHANNEL: + self._apply_segmentation_volume_style(volume_node, key) + + if hasattr(self, '_canvas') and self._canvas is not None: + self._canvas.update() + # -- vispy setup ---------------------------------------------------------- def _init_vispy(self) -> None: @@ -497,6 +955,47 @@ def _init_vispy(self) -> None: self._axis_visual = scene.visuals.XYZAxis(parent=self._view.scene) self._axis_visual.visible = False + def _configure_renderer_lut_item( + self, + lut_item: widgets.myHistogramLUTitem, + ) -> None: + """Strip 2D-only gradient menu entries not applicable to the 3D renderer.""" + lut_item.removeAddScaleBarAction() + lut_item.removeAddTimestampAction() + menu = lut_item.gradient.menu + for attr in ( + 'invertBwAction', + 'fontSizeMenu', + 'contLineWightActionGroup', + 'mothBudLineWightActionGroup', + ): + item = getattr(lut_item, attr, None) + if item is None: + continue + try: + if hasattr(item, 'menuAction'): + menu.removeAction(item.menuAction()) + else: + menu.removeAction(item) + except Exception: + pass + for attr in ('textColorButton', 'contoursColorButton', 'mothBudLineColorButton'): + button = getattr(lut_item, attr, None) + if button is None: + continue + for action in menu.actions(): + get_widget = getattr(action, 'defaultWidget', None) + if get_widget is None: + continue + widget = get_widget() + if widget is None: + continue + try: + if button in widget.findChildren(type(button)): + menu.removeAction(action) + except Exception: + pass + def _add_lut_items(self, scene_layout: QHBoxLayout) -> None: self.lut_items_graphics_layout = pg.GraphicsLayoutWidget() self.lut_items_graphics_layout.setBackground('black') @@ -511,38 +1010,325 @@ def _add_lut_items(self, scene_layout: QHBoxLayout) -> None: auto_btn_proxy.setWidget(auto_btn) self.lut_items_layout.addItem(auto_btn_proxy, row=0, col=c) - reset_btn = QPushButton('Reset') - reset_btn_proxy = QGraphicsProxyWidget() - reset_btn_proxy.setWidget(reset_btn) - self.lut_items_layout.addItem(reset_btn_proxy, row=1, col=c) + full_btn = QPushButton('Full') + full_btn.setToolTip( + 'Reset contrast limits to full normalized range [0, 1]' + ) + full_btn_proxy = QGraphicsProxyWidget() + full_btn_proxy.setWidget(full_btn) + self.lut_items_layout.addItem(full_btn_proxy, row=1, col=c) - lut_item = widgets.baseHistogramLUTitem( - parent=self, - name=channel, - axisLabel=channel, - include_rescale_lut_options=False + lut_item = widgets.myHistogramLUTitem( + parent=self, + name=channel, + axisLabel='Clim:', + include_rescale_lut_options=False, ) - self.lut_items[channel] = (lut_item, auto_btn, reset_btn) + self._configure_renderer_lut_item(lut_item) + self.lut_items[channel] = (lut_item, auto_btn, full_btn) self.lut_items_layout.addItem(lut_item, row=2, col=c) lut_item.channel = channel lut_item.sigLookupTableChanged.connect(self._on_lut_changed) auto_btn.clicked.connect( - partial(self._on_auto_clim, lut_item=lut_item) + lambda *_args, li=lut_item: self._on_auto_clim(li) ) - reset_btn.clicked.connect( - partial(self._on_reset_clim, lut_item=lut_item) + full_btn.clicked.connect( + lambda *_args, li=lut_item: self._on_full_clim(li) ) total_width += lut_item.sizeHint(Qt.PreferredSize).width() scene_layout.addWidget(self.lut_items_graphics_layout, stretch=0) - - # Add some padding to prevent clipping - self.lut_items_graphics_layout.setFixedWidth(int(total_width + 20)) + self.lut_items_graphics_layout.setFixedWidth(int(total_width + 20)) + + def _add_labels_lut_widget(self, scene_layout: QHBoxLayout) -> None: + """Right-side labels gradient (same role as the main GUI labelsGrad bar).""" + self._labels_grad = widgets.labelsGradientWidget( + parent=self, + orientation='left', + ) + self._labels_grad.setToolTip( + 'Colormap for segmentation label IDs (independent from the 2D labels bar).' + ) + self._labels_grad.sigGradientChangeFinished.connect( + self._on_labels_grad_finished + ) + self._labels_grad.shuffleCmapAction.triggered.connect( + self._shuffle_3d_labels_lut + ) + self._labels_grad.colorButton.sigColorChanged.connect( + self._on_labels_bkgr_color_changed + ) + width = max(80, self._labels_grad.sizeHint().width()) + self._labels_grad.setFixedWidth(int(width)) + scene_layout.addWidget(self._labels_grad, stretch=0) + self._init_labels_grad_from_main() + + def _init_labels_grad_from_main(self) -> None: + if self._labels_grad is None: + return + adapter = self._adapter + copied_lut = None + if adapter is not None and hasattr(adapter, 'get_labels_image_lut'): + copied_lut = adapter.get_labels_image_lut() + if copied_lut is not None: + self._3d_labels_lut_rgba = np.asarray(copied_lut, dtype=np.uint8).copy() + self._3d_lut_rgb = self._3d_labels_lut_rgba[:, :3].copy() + if adapter is not None and hasattr(adapter, 'get_labels_gradient_initial_state'): + state = adapter.get_labels_gradient_initial_state() + if state: + grad, bkgr = state + self._labels_grad.item.restoreState(grad) + self._labels_grad.colorButton.setColor(bkgr) + else: + if adapter is not None and hasattr(adapter, 'get_labels_gradient_initial_state'): + state = adapter.get_labels_gradient_initial_state() + if state: + grad, bkgr = state + self._labels_grad.item.restoreState(grad) + self._labels_grad.colorButton.setColor(bkgr) + self._rebuild_3d_labels_lut_from_gradient(shuffle=True) + self._ensure_3d_labels_lut_size() + self._apply_labels_lut_to_segm() + + def _sample_gradient_rgb_table(self, *, shuffle: bool) -> np.ndarray: + table = np.asarray( + self._labels_grad.item.colorMap().getLookupTable(0, 1, 255), + dtype=np.uint8, + ) + if table.ndim != 2: + raise ValueError(f'Expected 2D LUT table; got shape {table.shape}') + table = table[:, :3].copy() + if shuffle: + np.random.shuffle(table) + return table + + def _labels_rgba_from_lut_rgb(self, lut_rgb: np.ndarray) -> np.ndarray: + lut_rgb = np.asarray(lut_rgb, dtype=np.uint8) + rgba = np.zeros((len(lut_rgb), 4), dtype=np.uint8) + rgba[:, :3] = lut_rgb[:, :3] + rgba[:, 3] = 255 + rgba[0] = [0, 0, 0, 0] + return rgba + + def _max_label_id_in_volumes(self) -> int: + max_id = 0 + for labels in self._label_volumes.values(): + if labels is None or labels.size == 0: + continue + max_id = max(max_id, int(labels.max())) + return max_id + + def _ensure_3d_labels_lut_size(self) -> None: + """Grow the LUT when label IDs exceed the current table (matches 2D extendLabelsLUT).""" + if self._3d_labels_lut_rgba is None: + return + need = self._max_label_id_in_volumes() + 1 + if need <= len(self._3d_labels_lut_rgba): + return + old = self._3d_labels_lut_rgba + extra_count = need - len(old) + if len(old) > 1: + pick = np.random.randint(1, len(old), size=extra_count) + extra = old[pick].copy() + else: + extra = np.zeros((extra_count, 4), dtype=np.uint8) + extra[:, 3] = 255 + self._3d_labels_lut_rgba = np.concatenate([old, extra], axis=0) + if self._3d_lut_rgb is not None: + self._3d_lut_rgb = self._3d_labels_lut_rgba[:, :3].copy() + + def _rebuild_3d_labels_lut_from_gradient(self, *, shuffle: bool) -> None: + table = self._sample_gradient_rgb_table(shuffle=shuffle) + bkgr = np.asarray( + self._labels_grad.colorButton.color().getRgb()[:3], + dtype=np.uint8, + ) + self._3d_lut_rgb = np.insert(table, 0, bkgr, axis=0) + self._3d_labels_lut_rgba = self._labels_rgba_from_lut_rgb(self._3d_lut_rgb) + + def _build_labels_image_lut(self) -> np.ndarray | None: + if self._3d_labels_lut_rgba is None and self._labels_grad is not None: + self._rebuild_3d_labels_lut_from_gradient(shuffle=True) + self._ensure_3d_labels_lut_size() + return self._3d_labels_lut_rgba + + def _apply_labels_lut_to_segm(self) -> None: + lut = self._build_labels_image_lut() + if lut is None: + return + key = self._find_overlay_by_kind(OVERLAY_KIND_SEGM) + if key is not None: + self.set_overlay_cmap(key, lut, sync_main_gui=False) + volume_node = self._volume_nodes.get(key) if self._volume_nodes else None + if volume_node is not None: + volume_node.clim = _labels_overlay_clim(lut) + return + if self._segmentation_only and self._volume_nodes is not None: + volume_node = self._volume_nodes.get(SEGM_PRIMARY_CHANNEL) + if volume_node is not None: + volume_node.cmap = colors.labels_lut_vispy_cmap(lut) + volume_node.clim = _labels_overlay_clim(lut) + self._apply_label_overlay_node_style(volume_node) + self._canvas.update() + + def _on_labels_grad_finished(self, *_args) -> None: + # Match 2D updateLabelsCmap → setLut(shuffle=True): permute palette entries. + self._rebuild_3d_labels_lut_from_gradient(shuffle=True) + self._ensure_3d_labels_lut_size() + self._apply_labels_lut_to_segm() + + def _on_labels_bkgr_color_changed(self, *_args) -> None: + if self._3d_lut_rgb is not None: + bkgr = np.asarray( + self._labels_grad.colorButton.color().getRgb()[:3], + dtype=np.uint8, + ) + self._3d_lut_rgb[0] = bkgr + self._3d_labels_lut_rgba = self._labels_rgba_from_lut_rgb(self._3d_lut_rgb) + else: + self._rebuild_3d_labels_lut_from_gradient(shuffle=True) + self._apply_labels_lut_to_segm() + + def _shuffle_3d_labels_lut(self) -> None: + if self._3d_lut_rgb is None or len(self._3d_lut_rgb) < 2: + self._rebuild_3d_labels_lut_from_gradient(shuffle=True) + else: + np.random.shuffle(self._3d_lut_rgb[1:]) + self._3d_labels_lut_rgba = self._labels_rgba_from_lut_rgb(self._3d_lut_rgb) + self._ensure_3d_labels_lut_size() + self._apply_labels_lut_to_segm() + + def _primary_bf_opacity(self) -> float: + bf_opacity, _segm_opacity = _segm_blend_opacities(self._segm_blend) + return bf_opacity + + def _pg_cmap_to_gradient(self, pg_cmap): + table = pg_cmap.getLookupTable(0.0, 1.0, 2) + rgba = [tuple(row) for row in table] + return colors.get_pg_gradient(rgba) + + def _rebuild_overlay_controls(self, overlay_entries) -> None: + if self._overlay_controls_layout is None: + return + self._clear_overlay_widgets() + if not overlay_entries: + if self._overlay_controls_host is not None: + self._overlay_controls_host.hide() + return + if self._overlay_controls_host is not None: + self._overlay_controls_host.show() + + for channel_name, opacity, cmap_spec, _mode_override, meta in overlay_entries: + kind = meta.get('kind', '') + row_widget = QWidget() + row_layout = QHBoxLayout(row_widget) + row_layout.setContentsMargins(0, 0, 0, 0) + widgets_info: dict = {} + + if kind == OVERLAY_KIND_FLUO: + label = meta.get('channel_name', channel_name) + lut_item = widgets.baseHistogramLUTitem( + parent=self, + name=channel_name, + axisLabel=label, + include_rescale_lut_options=False, + ) + lut_item.vb.hide() + if hasattr(cmap_spec, 'getLookupTable'): + lut_item.setGradient(self._pg_cmap_to_gradient(cmap_spec)) + elif isinstance(cmap_spec, str): + bkgr = (0, 0, 0, 255) + fg = colors.FLUO_CHANNELS_COLORS.get( + cmap_spec, (0, 255, 0, 255) + ) + if len(fg) == 3: + fg = (*fg, 255) + lut_item.setGradient(colors.get_pg_gradient((bkgr, fg))) + lut_item.sigLookupTableChanged.connect( + lambda *_args, k=channel_name, li=lut_item: ( + self._on_overlay_lut_changed(k, li) + ) + ) + row_layout.addWidget(lut_item) + widgets_info['lut_item'] = lut_item + + opacity_spin = widgets.sliderWithSpinBox( + title_loc='in_line', + isFloat=True, + parent=row_widget, + normalize_factor=20, + ) + if kind == OVERLAY_KIND_SEGM: + opacity_spin.setRange(0.0, 100.0) + opacity_spin.setSingleStep(1.0) + opacity_spin.setDecimals(0) + opacity_spin.setValue(float(opacity)) + opacity_spin.setToolTip( + 'Crossfade brightfield ↔ segmentation (0–100). ' + '0 = 100% brightfield / 0% labels, ' + '40 = 60% brightfield / 40% labels, ' + '100 = 0% brightfield / 100% labels.' + ) + label_widget = QLabel('BF ↔ Segm:') + row_layout.addWidget(label_widget) + else: + opacity_spin.setRange(0.0, 1.0) + opacity_spin.setSingleStep(0.05) + opacity_spin.setDecimals(2) + opacity_spin.setValue(float(opacity)) + if kind == OVERLAY_KIND_OL_LABELS: + opacity_spin.setToolTip( + f'Opacity for overlay labels ' + f'({meta.get("channel_name", "")})' + ) + else: + opacity_spin.setToolTip( + f'Opacity for overlay channel ' + f'{meta.get("channel_name", "")}' + ) + opacity_spin.valueChanged.connect( + lambda value, k=channel_name: ( + self._on_overlay_opacity_changed(k, value) + ) + ) + row_layout.addWidget(opacity_spin) + widgets_info['opacity_spin'] = opacity_spin + self._overlay_widgets[channel_name] = widgets_info + self._overlay_controls_layout.addWidget(row_widget) + + def _on_overlay_lut_changed( + self, + overlay_key: str, + lut_item: widgets.baseHistogramLUTitem, + ) -> None: + if self._syncing_overlay_from_main: + return + self.set_overlay_cmap( + overlay_key, + lut_item.gradient.colorMap(), + sync_main_gui=False, + ) + + def _on_overlay_opacity_changed( + self, + overlay_key: str, + value: float, + ) -> None: + if self._syncing_overlay_from_main or self._syncing_overlay_from_renderer: + return + self.set_overlay_opacity(overlay_key, value, sync_main_gui=False) def _on_lut_changed(self, lut_item: widgets.baseHistogramLUTitem) -> None: + if ( + self._segmentation_only + and lut_item.channel == SEGM_PRIMARY_CHANNEL + ): + return + if self._syncing_primary_lut_from_main: + return ticks = lut_item.gradient.listTicks() ticks_pos = [x for t, x in ticks] min_val = min(ticks_pos) if ticks_pos else 0.0 @@ -551,7 +1337,8 @@ def _on_lut_changed(self, lut_item: widgets.baseHistogramLUTitem) -> None: self.set_cmap(lut_item) def _on_auto_clim(self, lut_item: widgets.baseHistogramLUTitem) -> None: - lo, hi = self.get_auto_contrast_percentile() + lo, hi = self.get_auto_contrast_percentile(channel=lut_item.channel) + low_tick = high_tick = None max_tick_val = -np.inf min_tick_val = np.inf for tick, x in lut_item.gradient.listTicks(): @@ -562,12 +1349,30 @@ def _on_auto_clim(self, lut_item: widgets.baseHistogramLUTitem) -> None: if x < min_tick_val: low_tick = tick min_tick_val = x - - lut_item.gradient.setTickValue(high_tick, hi) - lut_item.gradient.setTickValue(low_tick, lo) + + if low_tick is not None and high_tick is not None: + lut_item.gradient.setTickValue(low_tick, lo) + lut_item.gradient.setTickValue(high_tick, hi) + self.set_clim(lo, hi, lut_item.channel) - def _on_reset_clim(self, lut_item: widgets.baseHistogramLUTitem) -> None: - lut_item.resetState() + def _on_full_clim(self, lut_item: widgets.baseHistogramLUTitem) -> None: + lo, hi = 0.0, 1.0 + low_tick = high_tick = None + max_tick_val = -np.inf + min_tick_val = np.inf + for tick, x in lut_item.gradient.listTicks(): + if x > max_tick_val: + high_tick = tick + max_tick_val = x + + if x < min_tick_val: + low_tick = tick + min_tick_val = x + + if low_tick is not None and high_tick is not None: + lut_item.gradient.setTickValue(low_tick, lo) + lut_item.gradient.setTickValue(high_tick, hi) + self.set_clim(lo, hi, lut_item.channel) # -- Qt UI ---------------------------------------------------------------- @@ -580,6 +1385,7 @@ def _init_ui(self) -> None: self.topToolBar.sigHomeView.connect(self.reset_view) self.topToolBar.sigSave.connect(self.save_screenshot) + self.topToolBar.homeViewAction.setShortcutContext(Qt.WindowShortcut) controls_box = QGroupBox('Rendering Controls') self._controls = VolumeRendererControls( @@ -590,14 +1396,21 @@ def _init_ui(self) -> None: box_layout = QVBoxLayout(controls_box) box_layout.setContentsMargins(4, 4, 4, 4) box_layout.addWidget(self._controls) + controls_box.hide() self.scene_layout = QHBoxLayout() + self._overlay_controls_host = QGroupBox('Overlays') + self._overlay_controls_layout = QVBoxLayout(self._overlay_controls_host) + self._overlay_controls_layout.setContentsMargins(4, 4, 4, 4) + self._overlay_controls_host.hide() + central = QWidget() main_layout = QVBoxLayout(central) main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setSpacing(0) main_layout.addLayout(self.scene_layout) + main_layout.addWidget(self._overlay_controls_host) main_layout.addWidget(controls_box) self.setCentralWidget(central) @@ -614,12 +1427,16 @@ def _init_ui(self) -> None: # Per-axis downsampling strides used in the last upload (z, y, x). # Stored so set_voxel_scale can correct for non-uniform stride compression. _last_strides: tuple = (1, 1, 1) - # Last physical voxel sizes (µm) passed to set_voxel_scale. + # Last physical voxel sizes (µm) used for STTransform scaling. # Auto-reapplied when a new volume node is created in update_volume so - # callers need not re-call set_voxel_scale after a shape change. + # callers need not re-call set_metadata_voxel_sizes after a shape change. _voxel_dz: float = 1.0 _voxel_dy: float = 1.0 _voxel_dx: float = 1.0 + _metadata_voxel_sizes: tuple[float, float, float] = (1.0, 1.0, 1.0) + _z_aniso_user_override: float | None = None + _resample_z_enabled: bool = False + _syncing_z_aniso: bool = False _SETTINGS_ORG = 'Cell-ACDC' _SETTINGS_APP = 'renderer3d' @@ -640,14 +1457,12 @@ def _load_settings(self) -> None: # Restore numeric spinboxes — clamp to widget range so stale values # from older versions don't break the UI. - c._clim_min.setValue( - max(c._clim_min.minimum(), - min(s.value('clim_min', 0.0, type=float), c._clim_min.maximum())) - ) - c._clim_max.setValue( - max(c._clim_max.minimum(), - min(s.value('clim_max', 1.0, type=float), c._clim_max.maximum())) - ) + if self.lut_items and self.channels: + channel = self.channels[0] + lo = max(0.0, min(s.value('clim_min', 0.0, type=float), 1.0)) + hi = max(0.0, min(s.value('clim_max', 1.0, type=float), 1.0)) + if hi > lo: + self.set_clim(lo, hi, channel) c._gamma_spin.setValue( max(c._gamma_spin.minimum(), min(s.value('gamma', 1.0, type=float), c._gamma_spin.maximum())) @@ -658,6 +1473,13 @@ def _load_settings(self) -> None: c._step_spin.maximum())) ) c._smooth_iso_cb.setChecked(s.value('smooth_iso', False, type=bool)) + c._resample_z_cb.setChecked(s.value('resample_z', False, type=bool)) + z_aniso = s.value('z_aniso_ratio', None, type=float) + if z_aniso is not None: + c._z_aniso_spin.setValue( + max(c._z_aniso_spin.minimum(), + min(z_aniso, c._z_aniso_spin.maximum())) + ) # Depiction and plane parameters. depict_idx = s.value('depict_idx', 0, type=int) @@ -669,11 +1491,12 @@ def _load_settings(self) -> None: min(s.value('plane_thickness', 1.0, type=float), c._plane_thick_spin.maximum())) ) - c._opacity_spin.setValue( - max(c._opacity_spin.minimum(), - min(s.value('opacity', 1.0, type=float), - c._opacity_spin.maximum())) - ) + if self.channels: + channel = self.channels[0] + self.set_opacity( + max(0.0, min(s.value('opacity', 1.0, type=float), 1.0)), + channel=channel, + ) def _save_settings(self) -> None: """Persist current rendering settings so they survive app restarts.""" @@ -682,14 +1505,19 @@ def _save_settings(self) -> None: c = self._controls s.setValue('mode_idx', c._mode_combo.currentIndex()) s.setValue('interp_idx', c._interp_combo.currentIndex()) - s.setValue('clim_min', c._clim_min.value()) - s.setValue('clim_max', c._clim_max.value()) + if self.lut_items and self.channels: + channel = self.channels[0] + lo, hi = self._get_clim(self._get_lut_item(channel)) + s.setValue('clim_min', lo) + s.setValue('clim_max', hi) + s.setValue('opacity', self._primary_bf_opacity()) s.setValue('gamma', c._gamma_spin.value()) s.setValue('step_size', c._step_spin.value()) s.setValue('smooth_iso', c._smooth_iso_cb.isChecked()) + s.setValue('resample_z', c._resample_z_cb.isChecked()) + s.setValue('z_aniso_ratio', c._z_aniso_spin.value()) s.setValue('depict_idx', c._depict_combo.currentIndex()) s.setValue('plane_thickness', c._plane_thick_spin.value()) - s.setValue('opacity', c._opacity_spin.value()) # -- GPU helpers ---------------------------------------------------------- @@ -716,8 +1544,13 @@ def _apply_mode_cutoffs_to(self, node, mode: str, lo: float, hi: float) -> None: node.minip_cutoff = hi def _apply_mode_cutoffs(self, mode: str, lo: float, hi: float) -> None: - """Apply cutoffs to the primary volume node (wrapper for backwards compat).""" - self._apply_mode_cutoffs_to(self._volume_node, mode, lo, hi) + """Apply cutoffs to primary volume nodes (not overlays).""" + if self._volume_nodes is None or not self.channels: + return + for channel in self.channels: + volume_node = self._volume_nodes.get(channel) + if volume_node is not None: + self._apply_mode_cutoffs_to(volume_node, mode, lo, hi) @staticmethod def _downsample(vol: np.ndarray, max_size: int) -> np.ndarray: @@ -732,7 +1565,25 @@ def _downsample(vol: np.ndarray, max_size: int) -> np.ndarray: return vol return np.ascontiguousarray(vol[::strides[0], ::strides[1], ::strides[2]]) - def _preprocess_volume(self, volume: np.ndarray): + @staticmethod + def _downsample_max_pool(vol: np.ndarray, max_size: int) -> np.ndarray: + """Downsample *vol* preserving any foreground in each block (for masks).""" + strides = tuple(max(1, int(np.ceil(s / max_size))) for s in vol.shape) + if all(s == 1 for s in strides): + return np.ascontiguousarray(vol) + pad_shape = tuple(int(np.ceil(s / st)) * st for s, st in zip(vol.shape, strides)) + padded = np.zeros(pad_shape, dtype=vol.dtype) + padded[: vol.shape[0], : vol.shape[1], : vol.shape[2]] = vol + sz, sy, sx = strides + z, y, x = pad_shape + reshaped = padded.reshape(z // sz, sz, y // sy, sy, x // sx, sx) + return np.ascontiguousarray(reshaped.max(axis=(1, 3, 5))) + + def _preprocess_volume( + self, + volume: np.ndarray, + channel: str | None = None, + ): if volume.ndim != 3: raise ValueError( f'Expected 3-D (Z, Y, X) array; got shape {volume.shape}') @@ -744,7 +1595,17 @@ def _preprocess_volume(self, volume: np.ndarray): # Cache raw float32 data so smooth-ISO toggle can re-process without # requiring a frame navigation in the host application. self._last_raw_data = vol - original_shape = vol.shape + if channel is not None: + if self._raw_volumes_data is None: + self._raw_volumes_data = {} + self._raw_volumes_data[channel] = vol + + if self._resample_z_enabled: + vol = _resample_z_axis( + vol, + self._effective_z_ratio(), + is_labels=False, + ) # Compute the value range on the full-resolution data BEFORE downsampling # so that stride-based subsampling cannot accidentally exclude extreme voxels @@ -769,7 +1630,10 @@ def _preprocess_volume(self, volume: np.ndarray): else: vol = np.zeros_like(vol) - current_mode = self._controls._mode_combo.currentData() or 'mip' + current_mode = ( + self._controls._mode_combo.currentData() or 'mip' + if self._controls is not None else 'mip' + ) # Smooth ISO pre-filter: approximates napari's SMOOTH_GRADIENT_DEFINITION # (Sobel-Feldman 27-sample kernel) without requiring custom GLSL injection. @@ -790,6 +1654,71 @@ def _preprocess_volume(self, volume: np.ndarray): def _get_lut_item(self, channel_name: str): return self.lut_items[channel_name][0] + + def _primary_channel_name(self) -> str | None: + for channel in self.channels or []: + if not is_overlay_channel(channel): + return channel + return None + + def _set_primary_clim_ticks( + self, + lut_item: widgets.baseHistogramLUTitem, + clim: tuple[float, float], + ) -> None: + lo, hi = clim + low_tick = high_tick = None + max_tick_val = -np.inf + min_tick_val = np.inf + for tick, x in lut_item.gradient.listTicks(): + if x > max_tick_val: + high_tick = tick + max_tick_val = x + if x < min_tick_val: + low_tick = tick + min_tick_val = x + if low_tick is not None and high_tick is not None: + lut_item.gradient.setTickValue(low_tick, lo) + lut_item.gradient.setTickValue(high_tick, hi) + + def apply_primary_lut_from_main( + self, + gradient_state, + clim: tuple[float, float], + ) -> None: + """Apply the main GUI brightfield LUT (gradient + contrast) to 3D.""" + if self._segmentation_only or not self.lut_items: + return + channel = self._primary_channel_name() + if channel is None: + return + lut_item = self._get_lut_item(channel) + self._syncing_primary_lut_from_main = True + try: + lut_item.blockSignals(True) + lut_item.gradient.blockSignals(True) + try: + lut_item.gradient.restoreState(gradient_state) + self._set_primary_clim_ticks(lut_item, clim) + finally: + lut_item.gradient.blockSignals(False) + lut_item.blockSignals(False) + lo, hi = clim + self.set_clim(lo, hi, channel) + self.set_cmap(lut_item) + # LUT-only update: do not touch BF↔Segm crossfade or opacity LUT. + finally: + self._syncing_primary_lut_from_main = False + + def get_primary_lut_state_for_main(self) -> tuple | None: + """Return (gradient saveState, clim) for the primary 3D channel.""" + if self._segmentation_only or not self.lut_items: + return None + channel = self._primary_channel_name() + if channel is None: + return None + lut_item = self._get_lut_item(channel) + return lut_item.gradient.saveState(), self._get_clim(lut_item) def _init_volume_node( self, @@ -819,14 +1748,12 @@ def _init_volume_node( method=current_mode, cmap=current_cmap, interpolation=current_interp, - relative_step_size=2.5, + relative_step_size=current_step, parent=self._view.scene, ) volume_node.gamma = self._controls._gamma_spin.value() - volume_node.opacity = ( - self._controls._opacity_spins[channel_name].value() - ) + volume_node.opacity = self._primary_bf_opacity() volume_node.attenuation = 0.05 if current_mode in _ATTN_MODES: @@ -865,6 +1792,105 @@ def _init_volume_node( self._canvas.update() return volume_node + + def _init_overlay_volume_node( + self, + volume: np.ndarray, + channel_name: str, + opacity: float, + cmap_spec: str, + mode_override: str | None, + *, + is_label_mask: bool = False, + ): + from vispy.scene import visuals # noqa: PLC0415 + + primary_mode = self._controls._mode_combo.currentData() or 'mip' + if is_label_mask: + node_mode = _LABEL_OVERLAY_MODE + else: + node_mode = mode_override or primary_mode + if is_label_mask: + current_interp = _LABEL_VOLUME_INTERP + else: + current_interp = self._controls._interp_combo.currentData() or 'linear' + current_step = self._controls._step_spin.value() + depict_mode = self._controls._depict_combo.currentData() or 'volume' + is_plane = depict_mode in _PLANE_CONFIGS + plane_fraction = self._controls._zplane_slider.value() / 100.0 + + if is_label_mask: + cmap_spec = self._resolve_label_cmap_spec(cmap_spec) + cmap = _label_overlay_cmap(cmap_spec) + else: + cmap = _volume_cmap_from_spec(cmap_spec) + + clim = _labels_overlay_clim(cmap_spec) if is_label_mask else (0.0, 1.0) + + volume_node = visuals.Volume( + volume, + clim=clim, + method=node_mode, + cmap=cmap, + interpolation=current_interp, + relative_step_size=current_step, + parent=self._view.scene, + ) + if is_label_mask and opacity > 1.0: + _bf_opacity, segm_opacity = _segm_blend_opacities(opacity) + volume_node.opacity = segm_opacity + else: + volume_node.opacity = max(0.0, min(1.0, opacity)) + volume_node.gamma = 1.0 if is_label_mask else self._controls._gamma_spin.value() + if node_mode in _MIP_CUTOFF_MODES: + self._apply_mode_cutoffs_to(volume_node, node_mode, 0.0, 1.0) + if node_mode in _ATTN_MODES: + volume_node.attenuation = self._controls._attn_spin.value() + if node_mode in _ISO_MODES: + volume_node.threshold = self._controls._iso_spin.value() + if node_mode in ('translucent', 'additive'): + volume_node.set_gl_state(node_mode, depth_test=False) + if is_plane: + volume_node.raycasting_mode = 'plane' + self._set_plane_uniforms( + depict_mode, + plane_fraction, + shape=volume.shape, + node=volume_node, + ) + + self._apply_voxel_scale(volume_node) + if is_label_mask: + if opacity > 1.0: + _bf_opacity, segm_opacity = _segm_blend_opacities(opacity) + else: + segm_opacity = max(0.0, min(1.0, float(opacity))) + self._apply_label_overlay_node_style( + volume_node, opacity=segm_opacity + ) + self._overlay_mode_overrides[channel_name] = ( + _LABEL_OVERLAY_MODE if is_label_mask else mode_override + ) + return volume_node + + def _is_label_overlay_channel(self, channel_name: str) -> bool: + if channel_name in self._label_volumes: + return True + kind = self._overlay_meta.get(channel_name, {}).get('kind') + return kind in (OVERLAY_KIND_SEGM, OVERLAY_KIND_OL_LABELS) + + def _apply_label_overlay_node_style( + self, + volume_node, + *, + opacity: float | None = None, + ) -> None: + volume_node.method = _LABEL_OVERLAY_MODE + volume_node.interpolation = _LABEL_VOLUME_INTERP + volume_node.gamma = 1.0 + if opacity is not None: + volume_node.opacity = max(0.0, min(1.0, float(opacity))) + volume_node.set_gl_state(_LABEL_OVERLAY_MODE, depth_test=False) def _get_clim(self, lut_item): ticks = lut_item.gradient.listTicks() @@ -888,6 +1914,142 @@ def _set_cmap( # -- Public API ----------------------------------------------------------- + def _clear_primary_channels(self) -> None: + if not self._volume_nodes: + return + for ch in list(self.channels or []): + if is_overlay_channel(ch): + continue + node = self._volume_nodes.pop(ch, None) + if node is not None: + node.parent = None + if self._volumes_data is not None: + self._volumes_data.pop(ch, None) + self._label_volumes.pop(ch, None) + self._raw_label_volumes.pop(ch, None) + self.channels = [] + + def _apply_segmentation_volume_style( + self, + volume_node=None, + channel: str | None = None, + ) -> None: + channel = channel or SEGM_PRIMARY_CHANNEL + if volume_node is None and self._volume_nodes is not None: + volume_node = self._volume_nodes.get(channel) + if volume_node is None: + return + volume_node.method = _LABEL_OVERLAY_MODE + labels_cmap = self._get_segm_labels_cmap() + if labels_cmap is not None: + volume_node.cmap = colors.labels_lut_vispy_cmap(labels_cmap) + volume_node.clim = _labels_overlay_clim(labels_cmap) + else: + volume_node.cmap = colors.overlay_mask_vispy_cmap('red') + volume_node.clim = (0.0, 1.0) + volume_node.interpolation = _LABEL_VOLUME_INTERP + volume_node.gamma = 1.0 + volume_node.opacity = 1.0 + volume_node.set_gl_state(_LABEL_OVERLAY_MODE, depth_test=False) + + def set_segmentation_volume(self, label_data: np.ndarray) -> None: + """Show only the segmentation mask as the primary 3D volume.""" + if label_data.ndim != 3: + raise ValueError( + f'Expected 3-D (Z, Y, X) label array; got shape {label_data.shape}' + ) + self._segmentation_only = True + channel = SEGM_PRIMARY_CHANNEL + self._clear_primary_channels() + self._store_label_volume(channel, label_data) + labels_cmap = self._get_segm_labels_cmap() + vol = self._overlay_volume_from_labels(channel) + if vol is None: + vol = np.ascontiguousarray( + _labels_for_display(label_data, self._active_cell_id) + ) + cmap_spec = labels_cmap if labels_cmap is not None else 'red' + + if self._volumes_data is None: + self._volumes_data = {} + if self._volume_nodes is None: + self._volume_nodes = {} + + self.channels = [channel] + self._init_ui() + + if self.lut_items is None: + self.scene_layout.addWidget(self._canvas.native, stretch=1) + self._connect_canvas_events() + + self._volume_nodes[channel] = self._init_overlay_volume_node( + vol, + channel, + 1.0, + cmap_spec, + _LABEL_OVERLAY_MODE, + is_label_mask=True, + ) + self._apply_segmentation_volume_style(self._volume_nodes[channel], channel) + self._volumes_data[channel] = vol + self._last_shape = vol.shape + self._last_raw_data = None + + if self._is_set_volumes_first_call: + from vispy import gloo + gloo.set_state(blend=True, depth_test=False) + self._is_set_volumes_first_call = False + + self._apply_voxel_scale(self._volume_nodes[channel]) + self._view.camera.set_range() + self._canvas.update() + + def switch_to_image_volume( + self, + data: np.ndarray, + channel_name: str = 'Channel 1', + ) -> None: + """Replace a segmentation-only view with a normal image primary volume.""" + self._segmentation_only = False + if self._volume_nodes and SEGM_PRIMARY_CHANNEL in self._volume_nodes: + self._volume_nodes[SEGM_PRIMARY_CHANNEL].parent = None + del self._volume_nodes[SEGM_PRIMARY_CHANNEL] + self._label_volumes.pop(SEGM_PRIMARY_CHANNEL, None) + self._raw_label_volumes.pop(SEGM_PRIMARY_CHANNEL, None) + if self._volumes_data is not None: + self._volumes_data.pop(SEGM_PRIMARY_CHANNEL, None) + self.channels = [] + self.lut_items = None + self.set_volume(data, channel_name=channel_name) + + def update_segmentation_volume(self, label_data: np.ndarray) -> None: + """Replace the segmentation-only primary volume.""" + if label_data.ndim != 3: + raise ValueError( + f'Expected 3-D (Z, Y, X) label array; got shape {label_data.shape}' + ) + channel = SEGM_PRIMARY_CHANNEL + if ( + not self._segmentation_only + or self._volume_nodes is None + or channel not in self._volume_nodes + ): + self.set_segmentation_volume(label_data) + return + + self._store_label_volume(channel, label_data) + vol = self._overlay_volume_from_labels(channel) + if vol is None: + vol = np.ascontiguousarray( + _labels_for_display(label_data, self._active_cell_id) + ) + clim = self._label_overlay_clim_for_key(channel) + self._volumes_data[channel] = vol + self._volume_nodes[channel].set_data(vol, clim=clim) + self._apply_segmentation_volume_style(self._volume_nodes[channel], channel) + self._last_shape = vol.shape + self._canvas.update() + def set_volume( self, volume: np.ndarray, @@ -906,6 +2068,8 @@ def set_volume( self.set_volumes([volume], channel_names) + self._segmentation_only = False + if cmap is None: return @@ -929,15 +2093,14 @@ def set_volumes( if not isinstance(volumes, dict): if channel_names is None: - keys = [ + channel_names = [ f'Channel {ch_idx+1}' for ch_idx in range(num_volumes) ] - - volumes = dict(zip(keys, volumes)) - channel_names = keys + + volumes = dict(zip(channel_names, volumes)) if cmaps is not None and not isinstance(cmaps, dict): - cmaps = dict(zip(channel_names, volumes)) + cmaps = dict(zip(channel_names, cmaps)) self.channels = list(volumes.keys()) self._init_ui() @@ -945,32 +2108,35 @@ def set_volumes( if self._volume_nodes is None: self._volume_nodes = {} - if self.lut_items is None: - lut_items = self._add_lut_items(self.scene_layout) - self.scene_layout.addWidget(self._canvas.native, stretch=1) + if not self.lut_items: + self._add_lut_items(self.scene_layout) + if self._canvas.native.parent() is None: + self.scene_layout.addWidget(self._canvas.native, stretch=1) + self._add_labels_lut_widget(self.scene_layout) + self._connect_canvas_events() if cmaps is not None: for channel_name, cmap in cmaps.items(): self._set_cmap(cmap, channel_name) for channel, volume in volumes.items(): - vol = self._preprocess_volume(volume) - self._volumes_data[channel] = vol - + vol = self._preprocess_volume(volume, channel=channel) + vol_node = self._init_volume_node( vol, channel, update_canvas=False ) self._volume_nodes[channel] = vol_node + self._volumes_data[channel] = vol + self._last_shape = vol.shape if self._is_set_volumes_first_call: # Enable blending from vispy import gloo gloo.set_state(blend=True, depth_test=False) gloo.set_blend_func('one', 'one') + self._is_set_volumes_first_call = False self._canvas.update() - - self._is_set_volumes_first_call = False def update_volume( self, @@ -989,25 +2155,32 @@ def update_volume( Data is automatically downsampled if any dimension exceeds the GPU's maximum 3-D texture size. """ - vol = self._preprocess_volume(data) - + if self._volumes_data is None or self.channels is None: + name = channel_name or 'Channel 1' + self.set_volume(data, channel_name=name) + return + + vol = self._preprocess_volume(data, channel=channel_name) + if channel_name is None and channel_index is None: - raise ValueError( - 'Both `channel_name` and `channel_index` are None. ' - 'Updating volume requires either one of them.' - ) + channel_name = self.channels[0] if channel_index is not None and channel_index >= len(self.channels): self.channels.append(f'Channel {channel_index+1}') channel_name = self.channels[-1] - elif channel_index < len(self.channels): + elif channel_index is not None and channel_index < len(self.channels): channel_name = self.channels[channel_index] if channel_name not in self._volumes_data.keys(): self.set_volume(data, channel_name) - + return + + volume_node = self._volume_nodes.get(channel_name) + if volume_node is None: + self.set_volume(data, channel_name=channel_name) + return + current_mode = self._controls._mode_combo.currentData() or 'mip' - volume_node = self._volume_nodes[channel_name] lut_item = self._get_lut_item(channel_name) @@ -1017,32 +2190,342 @@ def update_volume( volume_node.set_data(vol, clim=clim) self._apply_mode_cutoffs_to(volume_node, current_mode, lo, hi) - # self._last_shape = vol.shape + self._last_shape = vol.shape + + self._canvas.update() + + def _overlays_match_existing(self, overlays: list[tuple]) -> bool: + if not self._volume_nodes or not overlays: + return False + for index, entry in enumerate(overlays): + _, _, _, _, meta = _parse_overlay_entry(entry, index) + key = overlay_channel_name(index) + if key not in self._volume_nodes: + return False + old_meta = self._overlay_meta.get(key, {}) + if old_meta.get('kind') != meta.get('kind'): + return False + if old_meta.get('channel_name') != meta.get('channel_name'): + return False + expected = {overlay_channel_name(i) for i in range(len(overlays))} + existing = {k for k in self._volume_nodes if is_overlay_channel(k)} + return existing == expected + + def _overlay_volume_from_entry( + self, + entry: tuple, + index: int, + channel_name: str, + ) -> tuple[np.ndarray, object, float, dict]: + data, opacity, cmap_spec, _mode_override, meta = _parse_overlay_entry( + entry, index + ) + if 'label_data' in meta: + self._store_label_volume(channel_name, meta['label_data']) + vol = self._overlay_volume_from_labels(channel_name) + if vol is None: + vol = np.ascontiguousarray( + _labels_for_display( + self._label_volumes[channel_name], + self._active_cell_id, + ) + ) + else: + self._raw_overlay_data[channel_name] = np.ascontiguousarray( + data.astype(np.float32, copy=False) + ) + vol = self._normalize_overlay_volume(data) + return vol, cmap_spec, opacity, meta + + def refresh_overlay_volumes(self, overlays: list[tuple]) -> bool: + """Update overlay textures in place when structure is unchanged.""" + if self._controls is None or not self._overlays_match_existing(overlays): + return False + + self._cached_overlay_entries = list(overlays) + for index, entry in enumerate(overlays): + channel_name = overlay_channel_name(index) + vol, cmap_spec, opacity, meta = self._overlay_volume_from_entry( + entry, index, channel_name + ) + volume_node = self._volume_nodes.get(channel_name) + if volume_node is None: + return False + is_label_mask = 'label_data' in meta + if is_label_mask: + cmap_spec = self._resolve_label_cmap_spec(cmap_spec) + clim = _labels_overlay_clim(cmap_spec) + volume_node.cmap = _label_overlay_cmap(cmap_spec) + volume_node.interpolation = _LABEL_VOLUME_INTERP + volume_node.gamma = 1.0 + self._apply_label_overlay_node_style(volume_node) + else: + clim = (0.0, 1.0) + volume_node.set_data(vol, clim=clim) + volume_node.clim = clim + if not is_label_mask: + volume_node.opacity = max(0.0, min(1.0, float(opacity))) + self._volumes_data[channel_name] = vol + meta.setdefault('overlay_index', index) + meta.setdefault('channel_name', channel_name) + meta['cmap_spec'] = cmap_spec + self._overlay_meta[channel_name] = meta + + self._canvas.update() + return True + + def update_overlay_volumes( + self, + overlays: list[tuple], + preserve_widgets: bool = False, + ) -> None: + """Replace overlay volumes stored in ``_volume_nodes`` under ``overlay:N``.""" + if self._controls is None: + return + + self._cached_overlay_entries = list(overlays) + self._remove_overlay_channels(clear_widgets=not preserve_widgets) + + if not overlays: + if hasattr(self, '_canvas') and self._canvas is not None: + self._canvas.update() + return + + nodes = self._ensure_volume_nodes() + if self._volumes_data is None: + self._volumes_data = {} + + overlay_entries = [] + for index, entry in enumerate(overlays): + data, opacity, cmap_spec, mode_override, meta = _parse_overlay_entry( + entry, index + ) + if data.ndim != 3: + continue + channel_name = overlay_channel_name(index) + if 'label_data' in meta: + self._store_label_volume(channel_name, meta['label_data']) + vol = self._overlay_volume_from_labels(channel_name) + if vol is None: + vol = np.ascontiguousarray( + _labels_for_display( + self._label_volumes[channel_name], + self._active_cell_id, + ) + ) + else: + self._raw_overlay_data[channel_name] = np.ascontiguousarray( + data.astype(np.float32, copy=False) + ) + vol = self._normalize_overlay_volume(data) + self._volumes_data[channel_name] = vol + meta.setdefault('overlay_index', index) + meta.setdefault('channel_name', channel_name) + meta['cmap_spec'] = cmap_spec + self._overlay_meta[channel_name] = meta + nodes[channel_name] = self._init_overlay_volume_node( + vol, + channel_name, + opacity, + cmap_spec, + mode_override, + is_label_mask='label_data' in meta, + ) + overlay_entries.append( + (channel_name, opacity, cmap_spec, mode_override, meta) + ) + + if not preserve_widgets: + self._rebuild_overlay_controls(overlay_entries) + + for _channel_name, blend, _cmap, _mode, meta in overlay_entries: + if meta.get('kind') == OVERLAY_KIND_SEGM and not preserve_widgets: + self.set_segm_brightfield_blend( + blend, sync_main_gui=False, update_widget=True + ) + break + + self._canvas.update() + + def _overlay_key_from_index(self, index_or_key) -> str | None: + if isinstance(index_or_key, str): + if index_or_key in (self._volume_nodes or {}): + return index_or_key + if index_or_key.isdigit(): + index_or_key = int(index_or_key) + if isinstance(index_or_key, int): + key = overlay_channel_name(index_or_key) + if self._volume_nodes and key in self._volume_nodes: + return key + return None + + def _find_overlay_by_kind(self, kind: str) -> str | None: + for key, meta in self._overlay_meta.items(): + if meta.get('kind') == kind: + return key + return None + + def set_segm_brightfield_blend( + self, + blend_0_to_100: float, + *, + sync_main_gui: bool = False, + update_widget: bool = True, + ) -> None: + """Crossfade primary brightfield vs segmentation (0=BF only, 100=segm only).""" + blend = max(0.0, min(100.0, float(blend_0_to_100))) + if abs(self._segm_blend - blend) < 1e-6: + return + self._segm_blend = blend + bf_opacity, segm_opacity = _segm_blend_opacities(blend) + + primary = self._primary_channel_name() + if primary is not None and self._volume_nodes is not None: + volume_node = self._volume_nodes.get(primary) + if volume_node is not None: + volume_node.opacity = bf_opacity + + key = self._find_overlay_by_kind(OVERLAY_KIND_SEGM) + if key is not None and self._volume_nodes is not None: + volume_node = self._volume_nodes.get(key) + if volume_node is not None: + volume_node.opacity = segm_opacity + if ( + update_widget + and not self._syncing_overlay_from_main + ): + widgets_info = self._overlay_widgets.get(key) + opacity_spin = ( + widgets_info.get('opacity_spin') if widgets_info else None + ) + if opacity_spin is not None: + self._syncing_overlay_from_renderer = True + try: + opacity_spin.blockSignals(True) + opacity_spin.setValue(blend) + opacity_spin.blockSignals(False) + finally: + self._syncing_overlay_from_renderer = False self._canvas.update() + def set_overlay_opacity( + self, + index_or_key, + value: float, + *, + sync_main_gui: bool = False, + ) -> None: + key = self._overlay_key_from_index(index_or_key) + if key is None or self._volume_nodes is None: + return + meta = self._overlay_meta.get(key, {}) + if meta.get('kind') == OVERLAY_KIND_SEGM: + self.set_segm_brightfield_blend( + value, + sync_main_gui=sync_main_gui, + update_widget=False, + ) + return + volume_node = self._volume_nodes.get(key) + if volume_node is None: + return + opacity = max(0.0, min(1.0, float(value))) + volume_node.opacity = opacity + widgets_info = self._overlay_widgets.get(key) + if widgets_info is not None: + opacity_spin = widgets_info.get('opacity_spin') + if opacity_spin is not None and not self._syncing_overlay_from_main: + self._syncing_overlay_from_renderer = True + try: + opacity_spin.blockSignals(True) + opacity_spin.setValue(opacity) + opacity_spin.blockSignals(False) + finally: + self._syncing_overlay_from_renderer = False + self._canvas.update() + + def set_overlay_cmap( + self, + index_or_key, + cmap_spec, + *, + sync_main_gui: bool = False, + ) -> None: + key = self._overlay_key_from_index(index_or_key) + if key is None or self._volume_nodes is None: + return + volume_node = self._volume_nodes.get(key) + if volume_node is None: + return + is_label_mask = ( + key in self._label_volumes + or self._overlay_meta.get(key, {}).get('kind') + in (OVERLAY_KIND_SEGM, OVERLAY_KIND_OL_LABELS) + ) + if is_label_mask: + volume_node.cmap = _label_overlay_cmap(cmap_spec) + if _is_labels_lut_spec(cmap_spec): + volume_node.clim = _labels_overlay_clim(cmap_spec) + meta = self._overlay_meta.setdefault(key, {}) + meta['cmap_spec'] = cmap_spec + self._apply_label_overlay_node_style(volume_node) + elif isinstance(cmap_spec, str): + volume_node.cmap = _volume_cmap_from_spec(cmap_spec) + else: + volume_node.cmap = _volume_cmap_from_spec(cmap_spec) + self._canvas.update() + + def set_segm_overlay_opacity( + self, + value: float, + *, + sync_main_gui: bool = False, + ) -> None: + key = self._find_overlay_by_kind(OVERLAY_KIND_SEGM) + if key is None: + return + self.set_overlay_opacity( + key, value, sync_main_gui=sync_main_gui + ) + def set_rendering_mode(self, mode: str) -> None: + if not self._has_volume_nodes(): + return + + if mode in _ISO_MODES and (self._smooth_iso != self._gpu_data_is_smoothed): + for channel in self.channels or []: + volume_node = self._volume_nodes.get(channel) + if volume_node is not None: + volume_node.method = mode + self._rerender() + return + for channel, volume_node in self._volume_nodes.items(): - # When entering ISO mode, the GPU texture must match the smooth flag. - # Re-upload if the smooth state changed since the last upload (e.g. - # the user toggled Smooth while in MIP mode, then switches to ISO — - # the GPU still has the old texture from the last update_volume call). - if mode in _ISO_MODES and (self._smooth_iso != self._gpu_data_is_smoothed): - volume_node.method = mode # set before _rerender reads it - self._rerender() - return - - volume_node.method = mode - + if is_overlay_channel(channel): + if self._is_label_overlay_channel(channel): + self._apply_label_overlay_node_style(volume_node) + continue + if self._overlay_mode_overrides.get(channel) is not None: + continue + volume_node.method = mode + self._apply_mode_cutoffs_to(volume_node, mode, 0.0, 1.0) + if mode in _ISO_MODES: + volume_node.threshold = self._controls._iso_spin.value() + if mode in _ATTN_MODES: + volume_node.attenuation = self._controls._attn_spin.value() + continue + + if self._segmentation_only and channel == SEGM_PRIMARY_CHANNEL: + self._apply_segmentation_volume_style(volume_node, channel) + continue + lut_item = self._get_lut_item(channel) - - ticks = lut_item.gradient.listTicks() - ticks_pos = [x for t, x in ticks] + volume_node.method = mode + ticks_pos = [x for t, x in lut_item.gradient.listTicks()] lo = min(ticks_pos) if ticks_pos else 0.0 hi = max(ticks_pos) if ticks_pos else 1.0 - self._apply_mode_cutoffs_to(volume_node, mode, lo, hi) - if mode in _ISO_MODES: volume_node.threshold = self._controls._iso_spin.value() if mode in _ATTN_MODES: @@ -1051,6 +2534,10 @@ def set_rendering_mode(self, mode: str) -> None: self._canvas.update() def set_clim(self, lo: float, hi: float, channel: str) -> None: + if self._segmentation_only and channel == SEGM_PRIMARY_CHANNEL: + return + if self._is_label_overlay_channel(channel): + return volume_node = self._volume_nodes.get(channel, None) if volume_node is None: return @@ -1061,8 +2548,12 @@ def set_clim(self, lo: float, hi: float, channel: str) -> None: self._canvas.update() def set_cmap(self, lut_item: widgets.baseHistogramLUTitem): - cmap = colors.pg_to_vispy_cmap(lut_item.gradient.colorMap()) channel = lut_item.channel + if self._segmentation_only and channel == SEGM_PRIMARY_CHANNEL: + return + if self._is_label_overlay_channel(channel): + return + cmap = colors.pg_to_vispy_cmap(lut_item.gradient.colorMap()) volume_node = self._volume_nodes.get(channel, None) if volume_node is None: return @@ -1071,7 +2562,10 @@ def set_cmap(self, lut_item: widgets.baseHistogramLUTitem): self._canvas.update() def get_auto_contrast_percentile( - self, lo_pct: float = 1.0, hi_pct: float = 99.5 + self, + lo_pct: float = 1.0, + hi_pct: float = 99.5, + channel: str | None = None, ) -> tuple[float, float]: """ Set contrast limits to the *lo_pct*–*hi_pct* percentile of the raw @@ -1084,10 +2578,19 @@ def get_auto_contrast_percentile( Falls back to [0, 1] when no volume has been loaded yet. """ - if self._last_raw_data is None: + raw = None + if ( + channel is not None + and self._raw_volumes_data is not None + and channel in self._raw_volumes_data + ): + raw = self._raw_volumes_data[channel] + elif self._last_raw_data is not None: + raw = self._last_raw_data + + if raw is None: lo, hi = 0.0, 1.0 else: - raw = self._last_raw_data vmin_raw = float(raw.min()) vmax_raw = float(raw.max()) if vmax_raw <= vmin_raw: @@ -1108,8 +2611,24 @@ def get_auto_contrast_percentile( return lo, hi + def auto_contrast_percentile( + self, + lo_pct: float = 1.0, + hi_pct: float = 99.5, + channel: str | None = None, + ) -> tuple[float, float]: + """Backwards-compatible alias for :meth:`get_auto_contrast_percentile`.""" + return self.get_auto_contrast_percentile( + lo_pct=lo_pct, hi_pct=hi_pct, channel=channel + ) + def set_gamma(self, value: float) -> None: - for volume_node in self._volume_nodes.values(): + if not self._has_volume_nodes(): + return + for channel, volume_node in self._volume_nodes.items(): + if self._is_label_overlay_channel(channel): + volume_node.gamma = 1.0 + continue volume_node.gamma = value self._canvas.update() @@ -1121,6 +2640,8 @@ def set_opacity(self, value: float, channel: str | None = None) -> None: depends on the rendering mode: most visible in translucent and additive modes; has no visual effect in MIP/MinIP (which project to a 2D plane). """ + if self._volume_nodes is None: + return volume_node = self._volume_nodes.get(channel) if volume_node is None: return @@ -1129,12 +2650,16 @@ def set_opacity(self, value: float, channel: str | None = None) -> None: self._canvas.update() def set_iso_threshold(self, value: float) -> None: + if self._volume_nodes is None: + return for volume_node in self._volume_nodes.values(): volume_node.threshold = value self._canvas.update() def set_attenuation(self, value: float) -> None: + if self._volume_nodes is None: + return for volume_node in self._volume_nodes.values(): volume_node.attenuation = value @@ -1142,9 +2667,14 @@ def set_attenuation(self, value: float) -> None: def set_interpolation(self, method: str) -> None: """Set 3D volume interpolation method (e.g. 'linear', 'nearest', 'catrom').""" - for volume_node in self._volume_nodes.values(): - volume_node.interpolation = method - + if self._volume_nodes is None: + return + for channel, volume_node in self._volume_nodes.items(): + if self._is_label_overlay_channel(channel): + volume_node.interpolation = _LABEL_VOLUME_INTERP + else: + volume_node.interpolation = method + self._canvas.update() def set_depiction(self, mode: str) -> None: @@ -1163,15 +2693,17 @@ def set_depiction(self, mode: str) -> None: 'plane_x' — YZ cross-section (normal along X). """ is_plane = mode in _PLANE_CONFIGS - if self._volume_node is not None: - self._volume_node.raycasting_mode = 'plane' if is_plane else 'volume' - if is_plane and self._last_shape is not None: - self._set_plane_uniforms(mode, 0.5) - for node in self._overlay_nodes: - node.raycasting_mode = 'plane' if is_plane else 'volume' - if is_plane and self._last_shape is not None: - self._set_plane_uniforms(mode, 0.5, node=node) - self._canvas.update() + if self._volume_nodes is not None: + for channel, volume_node in self._volume_nodes.items(): + volume_node.raycasting_mode = 'plane' if is_plane else 'volume' + if is_plane: + shape = self._volume_shape(channel) + if shape is not None: + self._set_plane_uniforms( + mode, 0.5, shape=shape, node=volume_node + ) + if hasattr(self, '_canvas') and self._canvas is not None: + self._canvas.update() def set_zplane_position(self, fraction: float) -> None: """ @@ -1179,15 +2711,19 @@ def set_zplane_position(self, fraction: float) -> None: The axis is determined by the currently selected depiction mode. """ - if self._last_shape is None: + if self._last_shape is None and not self._has_volume_nodes(): return current_mode = self._controls._depict_combo.currentData() or 'volume' if current_mode not in _PLANE_CONFIGS: return - if self._volume_node is not None: - self._set_plane_uniforms(current_mode, fraction) - for node in self._overlay_nodes: - self._set_plane_uniforms(current_mode, fraction, node=node) + if self._volume_nodes is not None: + for channel, volume_node in self._volume_nodes.items(): + shape = self._volume_shape(channel) + if shape is None: + continue + self._set_plane_uniforms( + current_mode, fraction, shape=shape, node=volume_node + ) self._canvas.update() def _set_plane_uniforms( @@ -1206,10 +2742,8 @@ def _set_plane_uniforms( ``self._last_shape``. Must be supplied when called from ``update_volume`` before ``_last_shape`` is updated. node: - Volume node to update. Defaults to ``self._volume_node``. + Volume node to update. When omitted, the caller must iterate nodes. """ - if node is None: - node = self._volume_node if node is None: return normal, axis = _PLANE_CONFIGS[mode] @@ -1249,18 +2783,133 @@ def set_plane_thickness(self, thickness: float) -> None: Larger values produce a thicker slab that shows more context. """ t = max(1.0, thickness) - if self._volume_node is not None: - self._volume_node.plane_thickness = t - for node in self._overlay_nodes: - node.plane_thickness = t - if self._volume_node is not None or self._overlay_nodes: - self._canvas.update() + if not self._has_volume_nodes(): + return + for volume_node in self._each_volume_node(): + volume_node.plane_thickness = t + self._canvas.update() def _rerender(self) -> None: """Re-process and re-upload the last received volume (smooth ISO toggle).""" - if self._last_raw_data is not None: + self._rerender_all() + + def _rerender_all(self) -> None: + """Re-process primary and overlay volumes from cached raw data.""" + if self._segmentation_only: + channel = SEGM_PRIMARY_CHANNEL + raw_labels = self._raw_label_volumes.get(channel) + if raw_labels is not None: + self.update_segmentation_volume(raw_labels) + if self._cached_overlay_entries: + self.update_overlay_volumes( + self._cached_overlay_entries, + preserve_widgets=True, + ) + return + + if self._raw_volumes_data: + for channel_name, raw in self._raw_volumes_data.items(): + self.update_volume(raw, channel_name=channel_name) + elif self._last_raw_data is not None: self.update_volume(self._last_raw_data) + if self._cached_overlay_entries: + self.update_overlay_volumes( + self._cached_overlay_entries, + preserve_widgets=True, + ) + + def _metadata_z_ratio(self) -> float: + dz, _dy, dx = self._metadata_voxel_sizes + if dx <= 0: + return 1.0 + return dz / dx + + def _effective_z_ratio(self) -> float: + if self._z_aniso_user_override is not None: + return self._z_aniso_user_override + return self._metadata_z_ratio() + + def sync_z_aniso_spinbox(self, ratio: float) -> None: + if self._controls is None: + return + self._syncing_z_aniso = True + try: + spin = self._controls._z_aniso_spin + spin.blockSignals(True) + spin.setValue(float(ratio)) + spin.blockSignals(False) + finally: + self._syncing_z_aniso = False + + def _apply_effective_voxel_sizes(self) -> None: + dz_meta, dy_meta, dx_meta = self._metadata_voxel_sizes + if dx_meta <= 0: + dx_meta = 1.0 + if self._resample_z_enabled: + self._voxel_dz = dx_meta + self._voxel_dy = dy_meta + self._voxel_dx = dx_meta + else: + self._voxel_dz = self._effective_z_ratio() * dx_meta + self._voxel_dy = dy_meta + self._voxel_dx = dx_meta + if self._has_volume_nodes(): + self._apply_voxel_scale() + + def set_metadata_voxel_sizes( + self, + dz: float = 1.0, + dy: float = 1.0, + dx: float = 1.0, + ) -> None: + """Store physical voxel sizes from metadata and apply the active Z ratio.""" + old_ratio = self._metadata_z_ratio() + self._metadata_voxel_sizes = (float(dz), float(dy), float(dx)) + ratio_changed = ( + self._z_aniso_user_override is None + and abs(self._metadata_z_ratio() - old_ratio) > 1e-9 + ) + if self._z_aniso_user_override is None: + self.sync_z_aniso_spinbox(self._metadata_z_ratio()) + self._apply_effective_voxel_sizes() + if self._resample_z_enabled and ratio_changed: + self._rerender_all() + + def set_z_anisotropy_ratio( + self, + ratio: float, + *, + from_user: bool = True, + ) -> None: + ratio = float(ratio) + if from_user: + self._z_aniso_user_override = ratio + self.sync_z_aniso_spinbox(ratio) + if self._resample_z_enabled: + self._apply_effective_voxel_sizes() + self._rerender_all() + else: + self._apply_effective_voxel_sizes() + + def reset_z_anisotropy_to_metadata(self) -> None: + self._z_aniso_user_override = None + ratio = self._metadata_z_ratio() + self.sync_z_aniso_spinbox(ratio) + if self._resample_z_enabled: + self._apply_effective_voxel_sizes() + self._rerender_all() + else: + self._apply_effective_voxel_sizes() + + def set_resample_z_enabled(self, enabled: bool) -> None: + enabled = bool(enabled) + if enabled == self._resample_z_enabled: + return + self._resample_z_enabled = enabled + self._apply_effective_voxel_sizes() + self._rerender_all() + def set_smooth_iso(self, enabled: bool) -> None: """ Toggle Gaussian pre-smoothing for ISO surface rendering. @@ -1286,10 +2935,12 @@ def set_step_size(self, value: float) -> None: Smaller values cast more rays per voxel → sharper but slower. vispy default and napari default: 0.8. Range: (0, 2]. """ - for volume_node in self._volume_nodes.values(): + if not self._has_volume_nodes(): + return + for volume_node in self._each_volume_node(): volume_node.relative_step_size = value - - self._canvas.update() + if hasattr(self, '_canvas') and self._canvas is not None: + self._canvas.update() def set_voxel_scale( self, @@ -1297,39 +2948,11 @@ def set_voxel_scale( dy: float = 1.0, dx: float = 1.0, ) -> None: - """ - Correct for anisotropic voxel sizes by scaling the volume transform. - - Parameters - ---------- - dz, dy, dx: - Physical voxel size in µm along Z, Y, X. All three are normalised - to *dx* so the rendered volume has correct physical proportions. - - The scale also accounts for per-axis downsampling strides (stored in - ``_last_strides`` from the last ``update_volume`` call). When the GPU - texture limit forces non-uniform downsampling (e.g. stride_x=4 while - stride_z=1), each downsampled voxel along X spans ``stride_x × dx`` - physical µm. Ignoring this would compress the X axis by 4×. - - Example — 100×512×2048 confocal stack on a 512-texture GPU: - Physical voxels: dz=1 µm, dy=0.2 µm, dx=0.2 µm - Downsampling: stride=(1, 1, 4) → shape (100, 512, 512) - Effective sizes: dz_eff=1, dy_eff=0.2, dx_eff=0.8 µm - Scale: (1.0, 0.2/0.8, 1.0/0.8) = (1, 0.25, 1.25) - """ - # Persist so the transform is re-applied automatically when the node is - # rebuilt due to a shape change (update_volume calls _apply_voxel_scale). - self._voxel_dz, self._voxel_dy, self._voxel_dx = dz, dy, dx - if self._volume_node is None: - return - self._apply_voxel_scale() + """Backward-compatible alias for :meth:`set_metadata_voxel_sizes`.""" + self.set_metadata_voxel_sizes(dz, dy, dx) def _apply_voxel_scale(self, node=None) -> None: - """Apply the stored voxel scale and stride correction to all volume nodes.""" - if node is None: - node = self._volume_node - + """Apply the stored voxel scale and stride correction to volume nodes.""" from vispy.visuals.transforms import STTransform # noqa: PLC0415 sz, sy, sx = self._last_strides dx_eff = self._voxel_dx * sx @@ -1338,8 +2961,13 @@ def _apply_voxel_scale(self, node=None) -> None: if dx_eff <= 0: dx_eff = 1.0 transform = STTransform(scale=(1.0, dy_eff / dx_eff, dz_eff / dx_eff)) - node.transform = transform - self._canvas.update() + if node is not None: + node.transform = transform + else: + for volume_node in self._each_volume_node(): + volume_node.transform = transform + if hasattr(self, '_canvas') and self._canvas is not None: + self._canvas.update() def reset_view(self) -> None: """Reset the camera to the default orientation and fit the volume.""" diff --git a/cellacdc/widgets.py b/cellacdc/widgets.py index bb81314b..5c61b850 100755 --- a/cellacdc/widgets.py +++ b/cellacdc/widgets.py @@ -12156,4 +12156,5 @@ def __init__(self, name='Volume Renderer Toolbar', parent=None): ) 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/pyproject.toml b/pyproject.toml index a11398d9..622ab6ce 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -148,6 +148,7 @@ dev = [ [project.scripts] cellacdc = "cellacdc.__main__:run" acdc = "cellacdc.__main__:run" +acdc-gui = "cellacdc.__main__:run_gui_direct" Cell-ACDC = "cellacdc.__main__:run" [tool.setuptools] diff --git a/tests/test_renderer3d.py b/tests/test_renderer3d.py index 72dc9a34..4c99ef6c 100644 --- a/tests/test_renderer3d.py +++ b/tests/test_renderer3d.py @@ -3,15 +3,22 @@ These tests verify module structure, constants, and numpy-only logic without requiring a running display or GPU context. Window/canvas creation is not tested here because it requires an OpenGL context. + +Skipped in CI (see commit 5fdb9237) because the suite needs a GUI/OpenGL env. +Run locally with: pytest tests/test_renderer3d.py -v """ try: import pytest - pytest.skip('skipping this test since it is gui based', allow_module_level=True) -except Exception as e: + pytest.skip( + 'skipping this test since it is gui based', + allow_module_level=True, + ) +except Exception: pass import numpy as np +import pytest def test_module_imports(): @@ -181,12 +188,14 @@ def test_renderer_public_api(): """VolumeRenderer3DWindow must expose all expected public methods.""" from cellacdc.renderer3d import VolumeRenderer3DWindow required = { - 'update_volume', 'set_rendering_mode', 'set_clim', + 'update_volume', 'update_overlay_volumes', 'set_rendering_mode', 'set_clim', 'set_gamma', 'set_opacity', 'set_iso_threshold', 'set_attenuation', 'set_interpolation', 'set_step_size', 'set_smooth_iso', 'set_depiction', 'set_zplane_position', 'set_plane_thickness', - 'set_voxel_scale', 'reset_view', 'save_screenshot', - 'auto_contrast_percentile', # 'set_colormap' + 'set_voxel_scale', 'set_metadata_voxel_sizes', 'set_z_anisotropy_ratio', + 'reset_z_anisotropy_to_metadata', 'set_resample_z_enabled', + 'reset_view', 'save_screenshot', + 'auto_contrast_percentile', 'get_auto_contrast_percentile', } missing = required - set(dir(VolumeRenderer3DWindow)) assert not missing, f"Missing public methods: {missing}" @@ -206,12 +215,12 @@ def test_set_voxel_scale_noop_without_node(): class _Bare(VolumeRenderer3DWindow): def _init_vispy(self): - if self._volume_node is None: - return + pass def _init_ui(self): self._controls = None r = _Bare() - r._volume_node = None + r._volume_nodes = None + r._overlay_mode_overrides = {} r.set_voxel_scale(0.5, 0.2, 0.2) # must not raise r.close() del r @@ -230,17 +239,18 @@ def test_set_voxel_scale_stride_correction(): class _Bare(VolumeRenderer3DWindow): def _init_vispy(self): - if self._volume_node is None: - return + pass def _init_ui(self): self._controls = None r = _Bare() - r._volume_node = MagicMock() + mock_node = MagicMock() + r._volume_nodes = {'Channel 1': mock_node} + r._overlay_mode_overrides = {} r._canvas = MagicMock() r._last_strides = (1, 1, 4) # only X was downsampled assigned_transforms = [] - type(r._volume_node).transform = property( + type(mock_node).transform = property( fget=lambda self: None, fset=lambda self, v: assigned_transforms.append(v), ) @@ -278,12 +288,12 @@ def test_voxel_scale_persists_across_node_rebuild(): class _Bare(VolumeRenderer3DWindow): def _init_vispy(self): - if self._volume_node is None: - return + pass def _init_ui(self): self._controls = None r = _Bare() - r._volume_node = None + r._volume_nodes = None + r._overlay_mode_overrides = {} # Store scale without a node — must not raise, must persist. r.set_voxel_scale(dz=2.0, dy=1.0, dx=1.0) assert r._voxel_dz == 2.0 @@ -321,12 +331,12 @@ def test_step_size_noop_without_node(): class _Bare(VolumeRenderer3DWindow): def _init_vispy(self): - if self._volume_node is None: - return + pass def _init_ui(self): self._controls = None r = _Bare() - r._volume_node = None + r._volume_nodes = None + r._overlay_mode_overrides = {} r.set_step_size(0.5) # must not raise r.close() del r @@ -338,12 +348,12 @@ def test_set_opacity_noop_without_node(): class _Bare(VolumeRenderer3DWindow): def _init_vispy(self): - if self._volume_node is None: - return + pass def _init_ui(self): self._controls = None r = _Bare() - r._volume_node = None + r._volume_nodes = None + r._overlay_mode_overrides = {} r.set_opacity(0.5) # must not raise r.close() del r @@ -356,28 +366,28 @@ def test_set_opacity_clamps_to_unit_range(): class _Bare(VolumeRenderer3DWindow): def _init_vispy(self): - if self._volume_node is None: - return + pass def _init_ui(self): self._controls = None r = _Bare() - r._volume_node = MagicMock() + mock_node = MagicMock() + r._volume_nodes = {'Channel 1': mock_node} + r._overlay_mode_overrides = {} r._canvas = MagicMock() - # Track what value was actually assigned to _volume_node.opacity assigned = [] - type(r._volume_node).opacity = property( + type(mock_node).opacity = property( fget=lambda self: None, fset=lambda self, v: assigned.append(v), ) - r.set_opacity(2.0) # above 1 → clamp to 1.0 + r.set_opacity(2.0, channel='Channel 1') assert assigned[-1] == 1.0 - r.set_opacity(-0.5) # below 0 → clamp to 0.0 + r.set_opacity(-0.5, channel='Channel 1') assert assigned[-1] == 0.0 - r.set_opacity(0.7) # in-range → pass through + r.set_opacity(0.7, channel='Channel 1') assert abs(assigned[-1] - 0.7) < 1e-9 r.close() @@ -405,12 +415,12 @@ def test_apply_mode_cutoffs_noop_without_node(): class _Bare(VolumeRenderer3DWindow): def _init_vispy(self): - if self._volume_node is None: - return + pass def _init_ui(self): self._controls = None r = _Bare() - r._volume_node = None + r._volume_nodes = None + r._overlay_mode_overrides = {} r._apply_mode_cutoffs('mip', 0.1, 0.9) # must not raise r.close() @@ -451,12 +461,12 @@ def test_plane_thickness_noop_without_node(): class _Bare(VolumeRenderer3DWindow): def _init_vispy(self): - if self._volume_node is None: - return + pass def _init_ui(self): self._controls = None r = _Bare() - r._volume_node = None + r._volume_nodes = None + r._overlay_mode_overrides = {} r.set_plane_thickness(5.0) # must not raise r.set_plane_thickness(0.0) # must clamp silently (no node) r.close() @@ -469,12 +479,12 @@ def test_zplane_uniforms_noop_without_node(): class _Bare(VolumeRenderer3DWindow): def _init_vispy(self): - if self._volume_node is None: - return + pass def _init_ui(self): self._controls = None r = _Bare() - r._volume_node = None + r._volume_nodes = None + r._overlay_mode_overrides = {} r._last_shape = None r.set_depiction('plane') # must not raise r.set_zplane_position(0.5) # must not raise @@ -488,7 +498,7 @@ def test_set_plane_uniforms_geometry(): from unittest.mock import MagicMock r = VolumeRenderer3DWindow.__new__(VolumeRenderer3DWindow) - r._volume_node = MagicMock() + plane_node = MagicMock() r._last_shape = None # Set up _controls mock directly (not via _init_ui to avoid display). controls = MagicMock() @@ -498,9 +508,9 @@ def test_set_plane_uniforms_geometry(): shape = (30, 64, 128) # NZ=30, NY=64, NX=128 # --- plane_z: XY cross-section, normal along Z (scene-z = data-Z axis) --- - r._set_plane_uniforms('plane_z', 0.0, shape=shape) - pos = r._volume_node.plane_position - normal = r._volume_node.plane_normal + r._set_plane_uniforms('plane_z', 0.0, shape=shape, node=plane_node) + pos = plane_node.plane_position + normal = plane_node.plane_normal assert normal == [0.0, 0.0, 1.0] # fraction=0.0 → z = 0.0*(30-1) = 0.0 (first voxel centre) assert abs(pos[2] - 0.0) < 1e-6, f"plane_z pos[2] should be 0.0, got {pos[2]}" @@ -508,38 +518,38 @@ def test_set_plane_uniforms_geometry(): assert abs(pos[0] - 63.5) < 1e-6 assert abs(pos[1] - 31.5) < 1e-6 - r._set_plane_uniforms('plane_z', 1.0, shape=shape) - pos = r._volume_node.plane_position + r._set_plane_uniforms('plane_z', 1.0, shape=shape, node=plane_node) + pos = plane_node.plane_position # fraction=1.0 → z = 1.0*(30-1) = 29.0 (last voxel centre) assert abs(pos[2] - 29.0) < 1e-6, f"plane_z pos[2] should be 29.0, got {pos[2]}" - r._set_plane_uniforms('plane_z', 0.5, shape=shape) - pos = r._volume_node.plane_position + r._set_plane_uniforms('plane_z', 0.5, shape=shape, node=plane_node) + pos = plane_node.plane_position # fraction=0.5 → z = 0.5*(30-1) = 14.5 → texture_z = (14.5+0.5)/30 = 0.5 ✓ (exact centre) assert abs(pos[2] - 14.5) < 1e-6, f"plane_z pos[2] should be 14.5, got {pos[2]}" # --- plane_y: XZ cross-section, normal along Y (scene-y = data-Y axis) --- - r._set_plane_uniforms('plane_y', 0.5, shape=shape) - pos = r._volume_node.plane_position - normal = r._volume_node.plane_normal + r._set_plane_uniforms('plane_y', 0.5, shape=shape, node=plane_node) + pos = plane_node.plane_position + normal = plane_node.plane_normal assert normal == [0.0, 1.0, 0.0] # fraction=0.5 → y = 0.5*(64-1) = 31.5 → texture_y = (31.5+0.5)/64 = 0.5 ✓ assert abs(pos[1] - 31.5) < 1e-6, f"plane_y pos[1] should be 31.5, got {pos[1]}" # --- plane_x: YZ cross-section, normal along X (scene-x = data-X axis) --- - r._set_plane_uniforms('plane_x', 0.5, shape=shape) - pos = r._volume_node.plane_position - normal = r._volume_node.plane_normal + r._set_plane_uniforms('plane_x', 0.5, shape=shape, node=plane_node) + pos = plane_node.plane_position + normal = plane_node.plane_normal assert normal == [1.0, 0.0, 0.0] # fraction=0.5 → x = 0.5*(128-1) = 63.5 → texture_x = (63.5+0.5)/128 = 0.5 ✓ assert abs(pos[0] - 63.5) < 1e-6, f"plane_x pos[0] should be 63.5, got {pos[0]}" # Thickness must be read from the spinbox (mocked to 2.0) - assert r._volume_node.plane_thickness == 2.0 + assert plane_node.plane_thickness == 2.0 # None shape + _last_shape=None → must return without crash r._last_shape = None - r._set_plane_uniforms('plane_z', 0.5, shape=None) # must not raise + r._set_plane_uniforms('plane_z', 0.5, shape=None, node=plane_node) def test_smooth_iso_constant(): @@ -635,7 +645,6 @@ def _init_ui(self): # Verify all 10 settings were persisted correctly. s = QSettings(TEST_ORG, TEST_APP) assert s.value('mode_idx', type=int) == 3 - assert s.value('colormap', type=str) == 'viridis' assert s.value('interp_idx', type=int) == 1 assert abs(s.value('clim_min', type=float) - 0.05) < 1e-6 assert abs(s.value('clim_max', type=float) - 0.95) < 1e-6 @@ -686,16 +695,12 @@ def test_gui_renderer3d_methods_exist(): def test_gui_py_constant(): - """_ZPROJMODE_3D must appear exactly once as a string literal in gui.py.""" + """gui.py must wire the 3D renderer launch helper.""" import pathlib gui_src = pathlib.Path(__file__).parent.parent / 'cellacdc' / 'gui.py' text = gui_src.read_text(encoding='utf-8') - # The constant definition line is the only place the raw string should appear. - count = text.count("'3D z-render'") - assert count == 1, ( - f"Expected exactly 1 occurrence of raw '3D z-render' in gui.py " - f"(the constant definition); found {count}." - ) + assert '_launch_3d_renderer' in text + assert 'renderer3d.create_renderer' in text def test_last_raw_data_cached_after_update_volume(): @@ -732,11 +737,17 @@ def _init_ui(self): win = _Headless.__new__(_Headless) win._hide_on_close = True win._adapter = None - win._volume_node = None - win._last_shape = None + win.channels = ['Channel 1'] + win._volume_nodes = {'Channel 1': MagicMock()} + lut_item = MagicMock() + lut_item.gradient.listTicks.return_value = [(MagicMock(), 0.0), (MagicMock(), 1.0)] + win.lut_items = {'Channel 1': (lut_item, None, None)} + win._volumes_data = {'Channel 1': np.zeros((10, 20, 30), dtype=np.float32)} + win._last_shape = (10, 20, 30) win._max_texture_3d = 512 win._smooth_iso = False win._last_raw_data = None + win._raw_volumes_data = {} win._init_ui() canvas_mock = MagicMock() @@ -747,7 +758,7 @@ def _init_ui(self): statusbar_mock = MagicMock() win.statusBar = MagicMock(return_value=statusbar_mock) - win.update_volume(data) + win.update_volume(data, channel_name='Channel 1') assert win._last_raw_data is not None assert win._last_raw_data.dtype == np.float32 @@ -820,7 +831,14 @@ def _init_ui(self): win._smooth_iso = True # smooth enabled win._gpu_data_is_smoothed = False # but GPU has unsmoothed data (stale) win._last_raw_data = np.zeros((10, 10, 10), dtype=np.float32) - win._volume_node = MagicMock() + mock_node = MagicMock() + win._volume_nodes = {'Channel 1': mock_node} + win.channels = ['Channel 1'] + lut_item = MagicMock() + lut_item.gradient.listTicks.return_value = [(MagicMock(), 0.0), (MagicMock(), 1.0)] + win.lut_items = {'Channel 1': (lut_item, None, None)} + win._overlay_mode_overrides = {} + win._overlay_mode_overrides = {} win._canvas = MagicMock() win._view = MagicMock() win.statusBar = MagicMock(return_value=MagicMock()) @@ -845,25 +863,17 @@ def _spy_rerender(self): def test_auto_contrast_percentile_no_data(): """auto_contrast_percentile returns [0,1] when no raw data is cached.""" from cellacdc.renderer3d import VolumeRenderer3DWindow - from unittest.mock import MagicMock class _Headless(VolumeRenderer3DWindow): def _init_vispy(self): pass - def _init_ui(self): - c = MagicMock() - c._clim_min.value.return_value = 0.0 - c._clim_max.value.return_value = 1.0 - self._controls = c + def _init_ui(self): pass win = _Headless.__new__(_Headless) win._last_raw_data = None - win._volume_node = None - win._init_ui() - win.auto_contrast_percentile() # must not raise - - # Spinboxes set to full range fallback - win._controls._clim_min.setValue.assert_called_with(0.0) - win._controls._clim_max.setValue.assert_called_with(1.0) + win._raw_volumes_data = None + lo, hi = win.auto_contrast_percentile() + assert lo == 0.0 + assert hi == 1.0 def test_auto_contrast_percentile_with_data(): @@ -894,18 +904,13 @@ def _init_ui(self): win = _Headless.__new__(_Headless) win._last_raw_data = raw - win._volume_node = None - win._init_ui() - win.auto_contrast_percentile(lo_pct=1.0, hi_pct=99.5) + win._raw_volumes_data = {'Channel 1': raw} + lo, hi = win.auto_contrast_percentile(lo_pct=1.0, hi_pct=99.5, channel='Channel 1') - # The 99.5th percentile (index 994 of 1000) falls within the 997 background - # voxels → hi is mapped to well below 1.0 in normalised space. - hi_call = win._controls._clim_max.setValue.call_args[0][0] - assert hi_call < 1.0, ( - f"Expected hi < 1.0 (outliers excluded by percentile), got {hi_call}" + assert hi < 1.0, ( + f"Expected hi < 1.0 (outliers excluded by percentile), got {hi}" ) - lo_call = win._controls._clim_min.setValue.call_args_list[0][0][0] - assert 0.0 <= lo_call <= hi_call <= 1.0 + assert 0.0 <= lo <= hi <= 1.0 def test_auto_contrast_percentile_constant_data(): @@ -925,13 +930,9 @@ def _init_ui(self): win = _Headless.__new__(_Headless) win._last_raw_data = raw - win._volume_node = None - win._init_ui() - win.auto_contrast_percentile() - - # Falls back to [0, 1] when span is zero (degenerate data). - win._controls._clim_min.setValue.assert_called_with(0.0) - win._controls._clim_max.setValue.assert_called_with(1.0) + lo, hi = win.auto_contrast_percentile() + assert lo == 0.0 + assert hi == 1.0 def test_auto_contrast_percentile_large_volume_subsampled(): @@ -955,12 +956,7 @@ def _init_ui(self): win = _Headless.__new__(_Headless) win._last_raw_data = raw - win._volume_node = None - win._init_ui() - win.auto_contrast_percentile(lo_pct=1.0, hi_pct=99.0) - - lo = win._controls._clim_min.setValue.call_args_list[0][0][0] - hi = win._controls._clim_max.setValue.call_args[0][0] + lo, hi = win.auto_contrast_percentile(lo_pct=1.0, hi_pct=99.0) assert 0.0 <= lo <= hi <= 1.0, f"Invalid limits: lo={lo}, hi={hi}" @@ -971,12 +967,13 @@ def test_apply_voxel_scale_updates_canvas(): class _Bare(VolumeRenderer3DWindow): def _init_vispy(self): - if self._volume_node is None: - return + pass def _init_ui(self): self._controls = None r = _Bare() - r._volume_node = MagicMock() + mock_node = MagicMock() + r._volume_nodes = {'Channel 1': mock_node} + r._overlay_mode_overrides = {} r._canvas = MagicMock() r._last_strides = (1, 1, 1) r._voxel_dz = 2.0 @@ -984,7 +981,7 @@ def _init_ui(self): self._controls = None r._voxel_dx = 1.0 assigned = [] - type(r._volume_node).transform = property( + type(mock_node).transform = property( fget=lambda self: None, fset=lambda self, v: assigned.append(v), ) @@ -996,3 +993,350 @@ def _init_ui(self): self._controls = None r.close() del r + + +def test_vispy_cmap_from_spec_plain_colour(): + from cellacdc.colors import vispy_cmap_from_spec + from vispy.color import Colormap + + cmap = vispy_cmap_from_spec('green') + assert isinstance(cmap, Colormap) + assert vispy_cmap_from_spec('viridis') == 'viridis' + + +def test_overlay_channel_helpers(): + from cellacdc.renderer3d import ( + OVERLAY_CHANNEL_PREFIX, + is_overlay_channel, + overlay_channel_name, + ) + + assert OVERLAY_CHANNEL_PREFIX == 'overlay:' + assert is_overlay_channel('overlay:0') + assert not is_overlay_channel('Channel 1') + assert overlay_channel_name(2) == 'overlay:2' + + +def test_update_overlay_volumes_clear_and_add(): + from cellacdc.renderer3d import VolumeRenderer3DWindow + from unittest.mock import MagicMock, patch + + class _Headless(VolumeRenderer3DWindow): + def _init_vispy(self): pass + def _init_ui(self): + c = MagicMock() + c._mode_combo.currentData.return_value = 'mip' + c._interp_combo.currentData.return_value = 'linear' + c._step_spin.value.return_value = 0.8 + c._depict_combo.currentData.return_value = 'volume' + c._zplane_slider.value.return_value = 50 + c._gamma_spin.value.return_value = 1.0 + c._attn_spin.value.return_value = 0.5 + c._iso_spin.value.return_value = 0.5 + self._controls = c + + win = _Headless.__new__(_Headless) + win._overlay_mode_overrides = {} + win._volume_nodes = None + win._volumes_data = None + win._canvas = MagicMock() + win._view = MagicMock() + win._last_strides = (1, 1, 1) + win._voxel_dz = 1.0 + win._voxel_dy = 1.0 + win._voxel_dx = 1.0 + win._max_texture_3d = 512 + win._init_ui() + + win.update_overlay_volumes([]) + assert win._overlay_mode_overrides == {} + + data = np.ones((4, 8, 8), dtype=np.float32) + + def _fake_init_overlay(self, vol, channel_name, opacity, cmap_spec, mode_override): + node = MagicMock() + self._overlay_mode_overrides[channel_name] = mode_override + return node + + with patch.object( + _Headless, '_init_overlay_volume_node', _fake_init_overlay + ): + win.update_overlay_volumes([(data, 0.5, 'green', 'mip')]) + + assert 'overlay:0' in win._volume_nodes + assert win._overlay_mode_overrides == {'overlay:0': 'mip'} + assert win._volumes_data['overlay:0'].shape == data.shape + + +def test_on_full_clim_sets_normalized_range(): + from cellacdc.renderer3d import VolumeRenderer3DWindow + from unittest.mock import MagicMock + + win = VolumeRenderer3DWindow.__new__(VolumeRenderer3DWindow) + low_tick = MagicMock() + high_tick = MagicMock() + lut_item = MagicMock() + lut_item.channel = 'Channel 1' + lut_item.gradient.listTicks.return_value = [ + (low_tick, 0.2), + (high_tick, 0.8), + ] + + set_clim_calls = [] + win.set_clim = lambda lo, hi, channel: set_clim_calls.append((lo, hi, channel)) + win._on_full_clim(lut_item) + + lut_item.gradient.setTickValue.assert_any_call(low_tick, 0.0) + lut_item.gradient.setTickValue.assert_any_call(high_tick, 1.0) + assert set_clim_calls == [(0.0, 1.0, 'Channel 1')] + + +def test_parse_overlay_entry_with_meta(): + from cellacdc.renderer3d import _parse_overlay_entry + + entry = (None, 0.5, 'green', 'mip', {'kind': 'segm'}) + data, opacity, cmap, mode, meta = _parse_overlay_entry(entry, 0) + assert mode == 'mip' + assert meta['kind'] == 'segm' + assert opacity == 0.5 + + legacy = (None, 0.3, 'cyan') + _d, op, cm, mo, me = _parse_overlay_entry(legacy, 1) + assert mo is None + assert me['overlay_index'] == 1 + + +def test_set_overlay_opacity_incremental(): + from cellacdc.renderer3d import VolumeRenderer3DWindow + from unittest.mock import MagicMock + + win = VolumeRenderer3DWindow.__new__(VolumeRenderer3DWindow) + node = MagicMock() + node.opacity = 1.0 + win._volume_nodes = {'overlay:0': node} + win._overlay_meta = { + 'overlay:0': {'kind': 'fluo', 'channel_name': 'GFP'}, + } + win._overlay_widgets = {} + win._canvas = MagicMock() + win._adapter = None + win._syncing_overlay_from_main = False + win._syncing_overlay_from_renderer = False + + win.set_overlay_opacity('overlay:0', 0.4, sync_main_gui=False) + assert node.opacity == 0.4 + + +def test_set_segm_overlay_opacity_finds_segm_node(): + from cellacdc.renderer3d import ( + OVERLAY_KIND_SEGM, + VolumeRenderer3DWindow, + ) + from unittest.mock import MagicMock + + win = VolumeRenderer3DWindow.__new__(VolumeRenderer3DWindow) + node = MagicMock() + win._volume_nodes = {'overlay:0': node} + win._overlay_meta = { + 'overlay:0': {'kind': OVERLAY_KIND_SEGM, 'channel_name': '__segm__'}, + } + win._overlay_widgets = {} + win._canvas = MagicMock() + win._adapter = MagicMock() + win._syncing_overlay_from_main = False + win._syncing_overlay_from_renderer = False + + win.set_segm_overlay_opacity(0.25, sync_main_gui=True) + assert node.opacity == 0.25 + win._adapter.apply_overlay_control_from_renderer.assert_called_once() + + +def test_volume_cmap_from_spec_pg_colormap(): + from cellacdc.renderer3d import _volume_cmap_from_spec + from pyqtgraph.colormap import ColorMap + from vispy.color import Colormap + + pg_cmap = ColorMap(pos=[0, 1], color=[[0, 0, 0, 255], [0, 255, 0, 255]]) + result = _volume_cmap_from_spec(pg_cmap) + assert isinstance(result, Colormap) + assert _volume_cmap_from_spec('viridis') == 'viridis' + + +def test_mask_labels_for_display(): + from cellacdc.renderer3d import _mask_labels_for_display + + labels = np.array([[[0, 1], [2, 0]]], dtype=np.int32) + all_mask = _mask_labels_for_display(labels, 0) + assert all_mask.shape == labels.shape + assert all_mask.max() == 1.0 + assert all_mask.min() == 0.0 + + one_mask = _mask_labels_for_display(labels, 2) + assert one_mask[0, 1, 0] == 1.0 + assert one_mask.sum() == 1.0 + + +def test_scene_pos_to_voxel_indices(): + from cellacdc.renderer3d import _scene_pos_to_voxel_indices + + assert _scene_pos_to_voxel_indices((0.0, 0.0, 0.0), (4, 8, 8)) == (0, 0, 0) + assert _scene_pos_to_voxel_indices((7.4, 3.6, 1.2), (4, 8, 8)) == (1, 4, 7) + assert _scene_pos_to_voxel_indices((99, 0, 0), (4, 8, 8)) is None + + +def test_set_active_cell_id_updates_overlay_node(): + from cellacdc.renderer3d import VolumeRenderer3DWindow + from unittest.mock import MagicMock + + win = VolumeRenderer3DWindow.__new__(VolumeRenderer3DWindow) + node = MagicMock() + win._volume_nodes = {'overlay:0': node} + win._label_volumes = { + 'overlay:0': np.array([[[0, 1], [2, 0]]], dtype=np.int32), + } + win._volumes_data = {} + win._overlay_meta = {'overlay:0': {'kind': 'segm'}} + win._controls = MagicMock() + win._controls._cell_id_spin = MagicMock() + win._canvas = MagicMock() + win._adapter = None + win._max_texture_3d = 512 + win._resample_z_enabled = False + win._active_cell_id = 0 + win._syncing_cell_id_from_main = False + win._syncing_cell_id_from_renderer = False + + win.set_active_cell_id(2, sync_main_gui=False) + assert win._active_cell_id == 2 + node.set_data.assert_called_once() + + +def _make_bare_renderer(): + from cellacdc.renderer3d import VolumeRenderer3DWindow + + class _Bare(VolumeRenderer3DWindow): + def _init_vispy(self): + pass + + def _init_ui(self): + self._controls = None + + r = _Bare() + r._overlay_mode_overrides = {} + return r + + +def test_z_aniso_ratio_default_from_metadata(): + from unittest.mock import MagicMock + + r = _make_bare_renderer() + r._controls = MagicMock() + r._controls._z_aniso_spin = MagicMock() + r.set_metadata_voxel_sizes(2.0, 1.0, 1.0) + assert r._metadata_z_ratio() == 2.0 + assert r._z_aniso_user_override is None + r._controls._z_aniso_spin.setValue.assert_called_with(2.0) + r.close() + del r + + +def test_z_aniso_user_override_updates_transform(): + from unittest.mock import MagicMock + + r = _make_bare_renderer() + mock_node = MagicMock() + r._volume_nodes = {'Channel 1': mock_node} + r._canvas = MagicMock() + r._last_strides = (1, 1, 1) + r.set_metadata_voxel_sizes(1.0, 1.0, 1.0) + + assigned_transforms = [] + type(mock_node).transform = property( + fget=lambda self: None, + fset=lambda self, v: assigned_transforms.append(v), + ) + + r.set_z_anisotropy_ratio(3.0, from_user=True) + assert r._z_aniso_user_override == 3.0 + assert r._voxel_dz == 3.0 + assert len(assigned_transforms) >= 1 + scale = assigned_transforms[-1].scale + assert abs(scale[2] - 3.0) < 1e-6 + r.close() + del r + + +def test_metadata_update_preserves_user_override(): + from unittest.mock import MagicMock + + r = _make_bare_renderer() + mock_node = MagicMock() + r._volume_nodes = {'Channel 1': mock_node} + r._canvas = MagicMock() + r._last_strides = (1, 1, 1) + r._controls = MagicMock() + r._controls._z_aniso_spin = MagicMock() + r.set_metadata_voxel_sizes(1.0, 1.0, 1.0) + r.set_z_anisotropy_ratio(4.0, from_user=True) + r._controls._z_aniso_spin.reset_mock() + + r.set_metadata_voxel_sizes(2.0, 0.5, 0.5) + assert r._z_aniso_user_override == 4.0 + r._controls._z_aniso_spin.setValue.assert_not_called() + assert r._voxel_dz == 4.0 * 0.5 + r.close() + del r + + +def test_resample_z_axis_shape(): + from cellacdc.renderer3d import _resample_z_axis + + vol = np.zeros((10, 20, 30), dtype=np.float32) + out = _resample_z_axis(vol, 2.0, is_labels=False) + assert out.shape[0] == 20 + assert out.shape[1:] == vol.shape[1:] + + +def test_resample_labels_uses_nearest(): + from cellacdc.renderer3d import _resample_z_axis + + labels = np.zeros((4, 2, 2), dtype=np.int32) + labels[1, 0, 0] = 5 + out = _resample_z_axis(labels, 2.0, is_labels=True) + assert np.issubdtype(out.dtype, np.integer) + assert out.max() <= 5 + + +def test_resample_then_mask_cell_id(): + from cellacdc.renderer3d import _mask_labels_for_display, _resample_z_axis + + labels = np.zeros((4, 2, 2), dtype=np.int32) + labels[1, 0, 0] = 2 + labels[2, 1, 1] = 3 + resampled = _resample_z_axis(labels, 2.0, is_labels=True) + mask = _mask_labels_for_display(resampled, 2) + assert mask.sum() >= 1.0 + assert mask.max() <= 1.0 + + +def test_resample_disabled_skips_zoom(): + from cellacdc.renderer3d import _resample_z_axis + + vol = np.arange(24, dtype=np.float32).reshape(2, 3, 4) + out = _resample_z_axis(vol, 1.0, is_labels=False) + assert out is vol or np.array_equal(out, vol) + + +def test_reset_z_anisotropy_to_metadata(): + from unittest.mock import MagicMock + + r = _make_bare_renderer() + r._controls = MagicMock() + r._controls._z_aniso_spin = MagicMock() + r.set_metadata_voxel_sizes(3.0, 1.0, 1.0) + r.set_z_anisotropy_ratio(7.0, from_user=True) + r.reset_z_anisotropy_to_metadata() + assert r._z_aniso_user_override is None + assert r._metadata_z_ratio() == 3.0 + r.close() + del r