diff --git a/docs/source/02_user_guide/03_user_interface_walkthrough/05_display_notebooks.rst b/docs/source/02_user_guide/03_user_interface_walkthrough/05_display_notebooks.rst index cf787eef4..18c2d2c85 100644 --- a/docs/source/02_user_guide/03_user_interface_walkthrough/05_display_notebooks.rst +++ b/docs/source/02_user_guide/03_user_interface_walkthrough/05_display_notebooks.rst @@ -14,7 +14,8 @@ Camera View The :guilabel:`Camera View` notebook shows the current image and display controls. 1. Left-clicking the image toggles crosshairs. -2. The right panel includes :ref:`LUT `, image metrics, and display mode controls. +2. The right panel includes :guilabel:`Display Mode`, :ref:`LUT `, image metrics, and image display controls. +3. Display updates are deferred when the tab is hidden, then refreshed with the newest frame when the view is visible again. HistogramFrame -------------- @@ -34,10 +35,17 @@ IntensityFrame :align: center :alt: IntensityFrame in the Camera View tab. -1. Select :guilabel:`Gray`, :guilabel:`Gradient`, or :guilabel:`Rainbow` display LUT. -2. :guilabel:`Flip XY` transposes display axes. -3. :guilabel:`Autoscale` toggles automatic min/max display scaling. -4. :guilabel:`Min Counts` and :guilabel:`Max Counts` are used when autoscale is disabled. +This frame now uses one compact LUT editor for both single-channel and multichannel workflows in both Camera and MIP tabs. + +1. :guilabel:`Channel` selects which channel settings are being edited. +2. In :guilabel:`Single` mode, channel selection is fixed to :guilabel:`All` and disabled. +3. In :guilabel:`Overlay` mode (with multiple active channels), channel selection is enabled so each channel can be configured independently. +4. :guilabel:`LUT` uses ImageJ-style colors (:guilabel:`Green`, :guilabel:`Red`, :guilabel:`Magenta`, :guilabel:`Cyan`, :guilabel:`Yellow`, :guilabel:`Blue`, :guilabel:`Orange`, :guilabel:`Gray`). +5. :guilabel:`Visible` toggles channel contribution on/off. +6. :guilabel:`Alpha` controls per-channel opacity from 0 to 100%. +7. :guilabel:`Gamma` controls per-channel gamma from 0.0 to 2.0 (default 1.0). +8. :guilabel:`Autoscale` applies per-channel automatic min/max scaling. +9. :guilabel:`Min Counts` and :guilabel:`Max Counts` are used when autoscale is disabled. MetricsFrame ------------ @@ -58,6 +66,7 @@ RenderFrame :alt: RenderFrame in the Camera View tab. This frame switches between live and slice display modes. +When channel-aware LUT controls are active, the channel selection in this frame is disabled. MipRenderFrame -------------- @@ -68,6 +77,10 @@ MipRenderFrame This frame controls MIP perspective and channel selection. +1. :guilabel:`Perspective` provides :guilabel:`Multi`, :guilabel:`XY`, :guilabel:`ZY`, and :guilabel:`ZX` views. +2. In :guilabel:`Multi` perspective, the XY MIP is shown with YZ on the right and XZ on the bottom. +3. YZ and ZX views are rescaled from acquisition metadata so axial and lateral spacing display isotropically. + .. _ui_waveform_settings: Waveform Settings diff --git a/docs/source/02_user_guide/05_acquiring_data/01_acquiring_guide/01_acquiring_guide.rst b/docs/source/02_user_guide/05_acquiring_data/01_acquiring_guide/01_acquiring_guide.rst index 3a6835abb..2299c759e 100644 --- a/docs/source/02_user_guide/05_acquiring_data/01_acquiring_guide/01_acquiring_guide.rst +++ b/docs/source/02_user_guide/05_acquiring_data/01_acquiring_guide/01_acquiring_guide.rst @@ -36,7 +36,7 @@ It is implemented as a :ref:`feature list `, shown in its : ) ] -The sequence begins with the `PrepareNextChannel` feature and loops over `experiment.MicroscopeState.selected_channels`. As such, continuous mode will display a live preview of all :ref:`selected color channels ` in sequence, then return the first color channel and start again. +The sequence begins with the `PrepareNextChannel` feature and loops over `experiment.MicroscopeState.selected_channels`. As such, continuous mode will display a live preview of all :ref:`selected color channels ` in sequence, then return the first color channel and start again. In the display notebook, users can choose :guilabel:`Single` view (one channel at a time) or :guilabel:`Overlay` view (multichannel composite) without changing acquisition order. ---------------- diff --git a/docs/source/images/CameraTab.png b/docs/source/images/CameraTab.png index ee1b6bd56..9523c3cf2 100644 Binary files a/docs/source/images/CameraTab.png and b/docs/source/images/CameraTab.png differ diff --git a/docs/source/images/MIPTab.png b/docs/source/images/MIPTab.png index ffc47392d..09a1141fc 100644 Binary files a/docs/source/images/MIPTab.png and b/docs/source/images/MIPTab.png differ diff --git a/docs/source/images/MainApp.png b/docs/source/images/MainApp.png index e99ba90b0..44156434e 100644 Binary files a/docs/source/images/MainApp.png and b/docs/source/images/MainApp.png differ diff --git a/docs/source/images/histogram-frame.png b/docs/source/images/histogram-frame.png index 54112c940..b7486d567 100644 Binary files a/docs/source/images/histogram-frame.png and b/docs/source/images/histogram-frame.png differ diff --git a/docs/source/images/intensity-frame.png b/docs/source/images/intensity-frame.png index 03acdb34c..0e94a5bfe 100644 Binary files a/docs/source/images/intensity-frame.png and b/docs/source/images/intensity-frame.png differ diff --git a/docs/source/images/metrics-frame.png b/docs/source/images/metrics-frame.png index 5afd5ea2e..17fb4a2e1 100644 Binary files a/docs/source/images/metrics-frame.png and b/docs/source/images/metrics-frame.png differ diff --git a/docs/source/images/mip-render-frame.png b/docs/source/images/mip-render-frame.png index 923d97d36..75b5e0540 100644 Binary files a/docs/source/images/mip-render-frame.png and b/docs/source/images/mip-render-frame.png differ diff --git a/docs/source/images/render-frame.png b/docs/source/images/render-frame.png index b624cbcbf..101755160 100644 Binary files a/docs/source/images/render-frame.png and b/docs/source/images/render-frame.png differ diff --git a/docs/source/images/waveform-plots-frame.png b/docs/source/images/waveform-plots-frame.png index 1be897dea..89caceeb9 100644 Binary files a/docs/source/images/waveform-plots-frame.png and b/docs/source/images/waveform-plots-frame.png differ diff --git a/docs/source/images/waveform-settings-frame.png b/docs/source/images/waveform-settings-frame.png index 55440f660..f6fd2d5a8 100644 Binary files a/docs/source/images/waveform-settings-frame.png and b/docs/source/images/waveform-settings-frame.png differ diff --git a/src/navigate/controller/sub_controllers/camera_view.py b/src/navigate/controller/sub_controllers/camera_view.py index 3078a1d58..d160f32bb 100644 --- a/src/navigate/controller/sub_controllers/camera_view.py +++ b/src/navigate/controller/sub_controllers/camera_view.py @@ -35,7 +35,7 @@ from tkinter import messagebox import logging import threading -from typing import Dict, Optional +from typing import Any, Dict, Optional import tempfile import os import time @@ -60,6 +60,27 @@ p = __name__.split(".")[1] logger = logging.getLogger(p) +IMAGEJ_CHANNEL_COLOR_BGR = { + "Green": (0, 255, 0), + "Red": (0, 0, 255), + "Magenta": (255, 0, 255), + "Cyan": (255, 255, 0), + "Yellow": (0, 255, 255), + "Blue": (255, 0, 0), + "Orange": (0, 165, 255), + "Gray": (255, 255, 255), +} +IMAGEJ_DEFAULT_COLOR_ORDER = ( + "Green", + "Red", + "Magenta", + "Cyan", + "Yellow", + "Blue", + "Orange", + "Gray", +) + class ABaseViewController(metaclass=abc.ABCMeta): """Abstract Base View Controller Class.""" @@ -204,10 +225,10 @@ def __init__(self, view, parent_controller=None) -> None: self._display_after_id = None #: int: The maximum counts of the image. - self.max_counts = None + self.max_counts = 2**16 - 1 #: int: The minimum counts of the image. - self.min_counts = None + self.min_counts = 0 #: int: The number of channels in the image. self.number_of_channels = 0 @@ -265,6 +286,23 @@ def __init__(self, view, parent_controller=None) -> None: #: dict: The dictionary of image palette widgets. self.image_palette = view.lut.get_widgets() + #: dict: The display mode widgets. + self.display_mode_widgets = ( + view.display_mode.get_widgets() if hasattr(view, "display_mode") else {} + ) + + #: dict: Cached per-channel overlay display settings. + self.overlay_channel_settings: Dict[str, Dict[str, Any]] = {} + #: dict: Cached BGR LUT tables for overlay colors. + self._overlay_colormap_cache: Dict[str, np.ndarray] = {} + #: dict: Cached 8-bit gamma lookup tables for display mapping. + self._gamma_lut_cache: Dict[int, np.ndarray] = {} + #: dict: Cache of colorized channel buffers keyed by source/settings signature. + self._colorized_channel_cache: Dict[tuple, tuple[np.ndarray, float]] = {} + #: np.ndarray: Reused additive overlay buffer in BGR. + self._overlay_bgr_buf: Optional[np.ndarray] = None + #: bool: Guard for suppressing callback loops while syncing controls. + self._syncing_overlay_controls = False #: Optional[str]: after() id for debouncing min/max updates self._minmax_after_id = None @@ -291,6 +329,12 @@ def __init__(self, view, parent_controller=None) -> None: command=lambda: self.update_transpose_state(display=True) ) + if "mode" in self.display_mode_widgets: + self.display_mode_widgets["mode"].widget.bind( + "<>", + self._on_display_mode_changed, + ) + #: int: The x position of the mouse. self.move_to_x = None @@ -316,6 +360,50 @@ def __init__(self, view, parent_controller=None) -> None: self.menu.add_command(label="Move Here", command=self.move_stage) self.menu.add_command(label="Mark Position", command=self.mark_position) + self._bind_visibility_events() + + def _bind_visibility_events(self) -> None: + """Bind visibility events so hidden tabs can defer redraw work.""" + notebook = getattr(self.view, "master", None) + if notebook is not None and hasattr(notebook, "bind"): + notebook.bind( + "<>", + self._on_visibility_changed, + add="+", + ) + if hasattr(self.view, "bind"): + self.view.bind("", self._on_visibility_changed, add="+") + + def _on_visibility_changed(self, *_) -> None: + """Render the latest pending frame when this view becomes visible.""" + self._request_display_if_needed() + + def _is_display_visible(self) -> bool: + """Return whether this view is currently visible to the user.""" + view = getattr(self, "view", None) + if view is None: + return False + if not getattr(view, "is_docked", True): + return bool(getattr(view, "winfo_ismapped", lambda: False)()) + + notebook = getattr(view, "master", None) + if notebook is None: + return True + try: + current_tab = notebook.select() + except Exception: + return False + return bool(current_tab) and str(current_tab) == str(view) + + def _request_display_if_needed(self) -> None: + """Queue a display callback if there is pending data and the view is visible.""" + if self._pending_display_image is None: + return + if not self._is_display_visible(): + return + if self._display_after_id is None: + self._display_after_id = self.view.after_idle(self._flush_pending_display) + def _on_minmax_changed(self, *args) -> None: """Debounce updates to min/max entry changes by 100 ms.""" @@ -360,6 +448,424 @@ def set_mode(self, mode: str = "") -> None: """ self.mode = mode + def _should_use_overlay_mode(self) -> bool: + """Return whether multichannel overlay mode is currently active.""" + mode_widget = self.display_mode_widgets.get("mode") + if mode_widget is None: + return False + if not self._has_multiple_selected_channels(): + return False + return mode_widget.get() == "Overlay" + + def _has_selected_channels(self) -> bool: + """Return whether at least one acquisition channel is active.""" + return ( + isinstance(self.selected_channels, list) and len(self.selected_channels) > 0 + ) + + def _has_multiple_selected_channels(self) -> bool: + """Return whether more than one acquisition channel is active.""" + return self._has_selected_channels() and len(self.selected_channels) > 1 + + def _get_multichannel_active_channel(self) -> Optional[str]: + """Get the active channel from compact LUT controls.""" + if not self._has_selected_channels(): + return None + if not self._has_multiple_selected_channels(): + if self.selected_channels: + return self.selected_channels[0] + return None + if hasattr(self.view, "lut") and hasattr( + self.view.lut, "get_multichannel_active_channel" + ): + channel = self.view.lut.get_multichannel_active_channel() + if channel in self.selected_channels: + return channel + return self.selected_channels[0] if self.selected_channels else None + + def _default_overlay_lut_for_channel(self, index: int) -> str: + """Pick a default ImageJ-like color for a channel index.""" + return IMAGEJ_DEFAULT_COLOR_ORDER[index % len(IMAGEJ_DEFAULT_COLOR_ORDER)] + + def _ensure_overlay_channel_settings(self) -> None: + """Ensure every selected channel has persisted overlay display settings.""" + if not isinstance(self.selected_channels, list): + return + for index, channel in enumerate(self.selected_channels): + if channel not in self.overlay_channel_settings: + self.overlay_channel_settings[channel] = { + "lut_name": self._default_overlay_lut_for_channel(index), + "autoscale": True, + "min_counts": float(self.min_counts), + "max_counts": float(self.max_counts), + "visible": True, + "alpha": 1.0, + "gamma": 1.0, + } + + def _sync_overlay_controls_from_cache(self) -> None: + """Populate multichannel controls from cached per-channel state.""" + if not hasattr(self.view, "lut"): + return + if not hasattr(self.view.lut, "set_multichannel_channel_state"): + return + self._syncing_overlay_controls = True + try: + for channel in self.selected_channels or []: + self.view.lut.set_multichannel_channel_state( + channel, + self.overlay_channel_settings.get(channel, {}), + ) + finally: + self._syncing_overlay_controls = False + + def _sync_overlay_cache_from_controls(self, channel: Optional[str] = None) -> None: + """Persist the latest multichannel control values into controller cache.""" + if not hasattr(self.view, "lut"): + return + if not hasattr(self.view.lut, "get_multichannel_channel_state"): + return + channels = [channel] if channel else list(self.selected_channels or []) + for channel_name in channels: + state = self.view.lut.get_multichannel_channel_state(channel_name) + if state: + self.overlay_channel_settings.setdefault(channel_name, {}).update(state) + + def _on_multichannel_control_changed(self, channel: str, _field: str) -> None: + """Handle per-channel display changes from the compact multichannel UI.""" + if self._syncing_overlay_controls: + return + self._sync_overlay_cache_from_controls(channel) + if self._has_selected_channels(): + self._refresh_after_display_mode_change() + + def _configure_display_mode_controls(self) -> None: + """Configure display mode widgets and channel-scaled LUT controls.""" + if "mode" not in self.display_mode_widgets or not hasattr(self.view, "lut"): + return + + mode_widget = self.display_mode_widgets["mode"].widget + has_multiple_channels = ( + isinstance(self.selected_channels, list) and len(self.selected_channels) > 1 + ) + if has_multiple_channels: + mode_widget.state(["!disabled", "readonly"]) + if hasattr(self.view, "display_mode"): + self.view.display_mode.grid() + else: + self.display_mode_widgets["mode"].set("Single") + mode_widget.state(["disabled"]) + if hasattr(self.view, "display_mode"): + self.view.display_mode.grid() + + self._ensure_overlay_channel_settings() + if hasattr(self.view.lut, "configure_multichannel_controls"): + default_luts = [ + self.overlay_channel_settings[channel]["lut_name"] + for channel in (self.selected_channels or []) + ] + self.view.lut.configure_multichannel_controls( + channels=self.selected_channels or [], + default_luts=default_luts, + on_change=self._on_multichannel_control_changed, + ) + self._sync_overlay_controls_from_cache() + self._update_multichannel_channel_selector_mode() + + self._update_display_mode_visibility() + self._update_channel_selector_for_display_mode() + + def _update_display_mode_visibility(self) -> None: + """Show the compact LUT controls (always used for this UI).""" + if not hasattr(self.view, "lut"): + return + if hasattr(self.view.lut, "set_multichannel_controls_visible"): + self.view.lut.set_multichannel_controls_visible(True) + + def _update_channel_selector_for_display_mode(self) -> None: + """Hook for subclasses to disable irrelevant single-channel selectors.""" + return + + def _update_multichannel_channel_selector_mode(self) -> None: + """Set LUT channel selector behavior for single vs overlay modes.""" + if not hasattr(self.view, "lut"): + return + if hasattr(self.view.lut, "set_multichannel_channel_selector_mode"): + self.view.lut.set_multichannel_channel_selector_mode( + overlay_mode=self._should_use_overlay_mode(), + channels=self.selected_channels or [], + ) + + def _on_display_mode_changed(self, *_) -> None: + """Handle single-channel vs multichannel overlay mode changes.""" + self._update_multichannel_channel_selector_mode() + self._update_display_mode_visibility() + self._update_channel_selector_for_display_mode() + self._refresh_after_display_mode_change() + + def _refresh_after_display_mode_change(self) -> None: + """Hook for subclasses to refresh display after display mode changes.""" + if self.image is not None: + self.process_image() + + def _redraw_current_view(self) -> None: + """Redraw with the active display pipeline to keep LUT state consistent. + + Notes + ----- + When channels are selected, this intentionally redraws through the current + compact LUT path (single or overlay mode) rather than the legacy + ``process_image`` path to prevent transient LUT fallback flashes. + """ + if self._has_selected_channels(): + self._refresh_after_display_mode_change() + elif getattr(self, "image", None) is not None: + self.process_image() + + def _scale_image_intensity_with_bounds( + self, + image: np.ndarray, + autoscale: bool, + min_counts: float, + max_counts: float, + ) -> tuple[np.ndarray, float]: + """Scale an image to uint8 using channel-specific or single-channel bounds.""" + min_value, max_value, _, _ = cv2.minMaxLoc(image) + + if autoscale: + if max_value > min_value: + scale = 255.0 / (max_value - min_value) + beta = -min_value * scale + return cv2.convertScaleAbs(image, alpha=scale, beta=beta), max_value + return np.ones_like(image, dtype=np.uint8) * 255, max_value + + if max_counts > min_counts: + scale = 255.0 / (max_counts - min_counts) + beta = -min_counts * scale + return cv2.convertScaleAbs(image, alpha=scale, beta=beta), max_value + + return np.ones_like(image, dtype=np.uint8) * 255, max_value + + def _build_overlay_colormap(self, lut_name: str) -> np.ndarray: + """Build an OpenCV BGR colormap table for an ImageJ-like channel color.""" + color_bgr = IMAGEJ_CHANNEL_COLOR_BGR.get( + lut_name, IMAGEJ_CHANNEL_COLOR_BGR["Gray"] + ) + ramp = np.arange(256, dtype=np.uint8) + colormap = np.empty((256, 1, 3), dtype=np.uint8) + for i, channel_value in enumerate(color_bgr): + if channel_value >= 255: + colormap[:, 0, i] = ramp + elif channel_value <= 0: + colormap[:, 0, i] = 0 + else: + colormap[:, 0, i] = ( + ramp.astype(np.uint16) * int(channel_value) // 255 + ).astype(np.uint8) + return colormap + + def _get_overlay_colormap(self, lut_name: str) -> np.ndarray: + """Fetch a cached OpenCV BGR colormap table for channel overlay rendering.""" + colormap = self._overlay_colormap_cache.get(lut_name) + if colormap is None: + colormap = self._build_overlay_colormap(lut_name) + self._overlay_colormap_cache[lut_name] = colormap + return colormap + + @staticmethod + def _normalize_gamma(gamma: float) -> float: + """Clamp display gamma to the supported range [0.0, 2.0].""" + return max(0.0, min(2.0, float(gamma))) + + def _build_gamma_lut(self, gamma: float) -> np.ndarray: + """Build a uint8 lookup table for intensity gamma correction.""" + gamma = self._normalize_gamma(gamma) + if np.isclose(gamma, 1.0, atol=1e-6): + return np.arange(256, dtype=np.uint8).reshape(-1, 1) + if gamma <= 0.0: + lut = np.full((256, 1), 255, dtype=np.uint8) + lut[0, 0] = 0 + return lut + + ramp = np.linspace(0.0, 1.0, 256, dtype=np.float32) + corrected = np.power(ramp, gamma) + return np.rint(corrected * 255.0).clip(0, 255).astype(np.uint8).reshape(-1, 1) + + def _get_gamma_lut(self, gamma: float) -> np.ndarray: + """Fetch a cached gamma LUT for 8-bit intensity remapping.""" + normalized = self._normalize_gamma(gamma) + cache_key = int(round(normalized * 1000.0)) + lut = self._gamma_lut_cache.get(cache_key) + if lut is None: + lut = self._build_gamma_lut(normalized) + self._gamma_lut_cache[cache_key] = lut + return lut + + def _get_channel_overlay_state(self, channel: str) -> Dict[str, Any]: + """Return normalized display settings for one selected channel.""" + self._ensure_overlay_channel_settings() + state = self.overlay_channel_settings.get(channel, {}) + return { + "lut_name": str(state.get("lut_name", "Gray")), + "autoscale": bool(state.get("autoscale", True)), + "min_counts": float(state.get("min_counts", self.min_counts)), + "max_counts": float(state.get("max_counts", self.max_counts)), + "visible": bool(state.get("visible", True)), + "alpha": max(0.0, min(1.0, float(state.get("alpha", 1.0)))), + "gamma": self._normalize_gamma(state.get("gamma", 1.0)), + } + + def _get_colorized_channel_buffer( + self, + channel: str, + image: np.ndarray, + y_slice: slice, + x_slice: slice, + channel_state: Dict[str, Any], + signature: Optional[Any] = None, + ) -> tuple[np.ndarray, float]: + """Return a BGR colorized channel buffer, using cache when signature is stable.""" + cache_key = None + if signature is not None: + cache_key = ( + channel, + signature, + int(y_slice.start or 0), + int(y_slice.stop or -1), + int(x_slice.start or 0), + int(x_slice.stop or -1), + int(self.canvas_width), + int(self.canvas_height), + str(channel_state["lut_name"]), + bool(channel_state["autoscale"]), + float(channel_state["min_counts"]), + float(channel_state["max_counts"]), + float(channel_state["gamma"]), + ) + cached = self._colorized_channel_cache.get(cache_key) + if cached is not None: + return cached + + image = self._crop_image_with_zoom(image, y_slice, x_slice) + image = self.down_sample_image(image) + scaled, channel_max = self._scale_image_intensity_with_bounds( + image=image, + autoscale=bool(channel_state["autoscale"]), + min_counts=float(channel_state["min_counts"]), + max_counts=float(channel_state["max_counts"]), + ) + gamma = self._normalize_gamma(channel_state.get("gamma", 1.0)) + if not np.isclose(gamma, 1.0, atol=1e-6): + scaled = cv2.LUT(scaled, self._get_gamma_lut(gamma)) + color_lut = self._get_overlay_colormap(str(channel_state["lut_name"])) + colorized = cv2.applyColorMap(scaled, color_lut) + + if cache_key is not None: + self._colorized_channel_cache[cache_key] = (colorized, channel_max) + if len(self._colorized_channel_cache) > 256: + first_key = next(iter(self._colorized_channel_cache)) + self._colorized_channel_cache.pop(first_key, None) + return colorized, channel_max + + @staticmethod + def _apply_channel_alpha(colorized_bgr: np.ndarray, alpha: float) -> np.ndarray: + """Apply per-channel alpha to a BGR buffer.""" + alpha = max(0.0, min(1.0, float(alpha))) + if alpha >= 1.0: + return colorized_bgr + return cv2.convertScaleAbs(colorized_bgr, alpha=alpha, beta=0.0) + + def _render_single_multichannel_frame( + self, + channel: str, + image: np.ndarray, + channel_signature: Optional[Any] = None, + ) -> np.ndarray: + """Render one channel using compact multichannel LUT controls.""" + y_slice, x_slice = self._prepare_zoom_window() + channel_state = self._get_channel_overlay_state(channel) + if not channel_state["visible"]: + self._last_frame_display_max = 0.0 + empty_rgb = np.zeros( + (self.canvas_height, self.canvas_width, 3), dtype=np.uint8 + ) + return self.add_crosshair(empty_rgb) + + colorized, channel_max = self._get_colorized_channel_buffer( + channel=channel, + image=image, + y_slice=y_slice, + x_slice=x_slice, + channel_state=channel_state, + signature=channel_signature, + ) + colorized = self._apply_channel_alpha(colorized, channel_state["alpha"]) + self._last_frame_display_max = float(channel_max) + rgb = cv2.cvtColor(colorized, cv2.COLOR_BGR2RGB) + return self.add_crosshair(rgb) + + def _compose_overlay_from_channels( + self, + channel_images: Dict[str, np.ndarray], + channel_signatures: Optional[Dict[str, Any]] = None, + ) -> Optional[np.ndarray]: + """Compose channel images into a single additive RGB overlay frame.""" + if not channel_images: + return None + + y_slice, x_slice = self._prepare_zoom_window() + overlay_bgr = None + max_intensity = 0.0 + + for channel in self.selected_channels or []: + image = channel_images.get(channel) + if image is None: + continue + + channel_state = self._get_channel_overlay_state(channel) + if not channel_state["visible"]: + continue + + signature = ( + None if channel_signatures is None else channel_signatures.get(channel) + ) + colorized, channel_max = self._get_colorized_channel_buffer( + channel=channel, + image=image, + y_slice=y_slice, + x_slice=x_slice, + channel_state=channel_state, + signature=signature, + ) + + max_intensity = max(max_intensity, float(channel_max)) + colorized_for_compose = self._apply_channel_alpha( + colorized, + channel_state["alpha"], + ) + + if overlay_bgr is None: + if ( + self._overlay_bgr_buf is None + or self._overlay_bgr_buf.shape != colorized_for_compose.shape + ): + self._overlay_bgr_buf = np.empty_like(colorized_for_compose) + self._overlay_bgr_buf[:] = colorized_for_compose + overlay_bgr = self._overlay_bgr_buf + else: + cv2.add(overlay_bgr, colorized_for_compose, overlay_bgr) + + if overlay_bgr is None: + self._last_frame_display_max = 0.0 + empty_rgb = np.zeros( + (self.canvas_height, self.canvas_width, 3), dtype=np.uint8 + ) + return self.add_crosshair(empty_rgb) + + self._last_frame_display_max = max_intensity + overlay_rgb = cv2.cvtColor(overlay_bgr, cv2.COLOR_BGR2RGB) + return self.add_crosshair(overlay_rgb) + def flip_image(self, image: np.ndarray) -> np.ndarray: """Flip the image according to the flip flags. @@ -476,12 +982,16 @@ def try_to_display_image(self, image: np.ndarray) -> None: # Keep only the most recent image until the next idle cycle. self._pending_display_image = image + if not self._is_display_visible(): + return if self._display_after_id is None: self._display_after_id = self.view.after_idle(self._flush_pending_display) def _flush_pending_display(self) -> None: """Render the latest queued frame on the Tk main thread.""" self._display_after_id = None + if not self._is_display_visible(): + return image = self._pending_display_image self._pending_display_image = None if image is None: @@ -492,7 +1002,11 @@ def _flush_pending_display(self) -> None: logger.exception("Error in display callback: %s", e) # If a newer frame arrived while rendering, schedule one more idle draw. - if self._pending_display_image is not None and self._display_after_id is None: + if ( + self._pending_display_image is not None + and self._display_after_id is None + and self._is_display_visible() + ): self._display_after_id = self.view.after_idle(self._flush_pending_display) def display_image(self, image: np.ndarray) -> None: @@ -601,6 +1115,7 @@ def initialize_non_live_display( self.number_of_channels = len(self.selected_channels) self.number_of_slices = int(microscope_state["number_z_steps"]) self.total_images_per_volume = self.number_of_channels * self.number_of_slices + self._colorized_channel_cache.clear() if self.transpose: self.original_image_width = int(camera_parameters["img_y_pixels"]) self.original_image_height = int(camera_parameters["img_x_pixels"]) @@ -621,6 +1136,7 @@ def initialize_non_live_display( "y": camera_config.get("flip_y", False), } + self._configure_display_mode_controls() self.update_canvas_size() self.reset_display(False, False) @@ -666,7 +1182,7 @@ def reset_display( self.zoom_value = 1 self.zoom_scale = 1 if display_flag: - self.process_image() + self._redraw_current_view() def move_crosshair(self) -> None: """Move the crosshair to a non-default position.""" @@ -675,7 +1191,7 @@ def move_crosshair(self) -> None: height = (self.zoom_rect[1][1] - self.zoom_rect[1][0]) / self.zoom_scale self.crosshair_x = self.move_to_x / width self.crosshair_y = self.move_to_y / height - self.process_image() + self._redraw_current_view() def mark_position(self) -> None: """Marks the current position of the microscope in @@ -812,17 +1328,8 @@ def update_canvas_size(self) -> None: self.original_image_height / self.canvas_height ) - def digital_zoom(self) -> np.ndarray: - """Apply digital zoom. - - The x and y positions are between 0 and the canvas width and height - respectively. - - Returns - ------- - image : np.ndarray - Image after digital zoom applied - """ + def _prepare_zoom_window(self) -> tuple[slice, slice]: + """Update zoom state and return crop slices for Y and X.""" self.zoom_rect = self.zoom_rect - self.zoom_offset self.zoom_rect = self.zoom_rect * self.zoom_value self.zoom_rect = self.zoom_rect + self.zoom_offset @@ -838,15 +1345,31 @@ def digital_zoom(self) -> np.ndarray: y_start_index = int(-self.zoom_rect[1][0] / self.zoom_scale) y_end_index = int(y_start_index + self.zoom_height) - zoom_image = self.image[ - int(y_start_index * self.canvas_height_scale) : int( - y_end_index * self.canvas_height_scale - ), - int(x_start_index * self.canvas_width_scale) : int( - x_end_index * self.canvas_width_scale - ), - ] + y_slice = slice( + int(y_start_index * self.canvas_height_scale), + int(y_end_index * self.canvas_height_scale), + ) + x_slice = slice( + int(x_start_index * self.canvas_width_scale), + int(x_end_index * self.canvas_width_scale), + ) + return y_slice, x_slice + def _crop_image_with_zoom( + self, + image: np.ndarray, + y_slice: slice, + x_slice: slice, + ) -> np.ndarray: + """Crop a source image using zoom slices.""" + return image[y_slice, x_slice] + + def digital_zoom(self, source_image: Optional[np.ndarray] = None) -> np.ndarray: + """Apply digital zoom to the current image or a provided source image.""" + if source_image is None: + source_image = self.image + y_slice, x_slice = self._prepare_zoom_window() + zoom_image = self._crop_image_with_zoom(source_image, y_slice, x_slice) return zoom_image def down_sample_image(self, image: np.ndarray) -> np.ndarray: @@ -889,25 +1412,14 @@ def scale_image_intensity(self, image: np.ndarray) -> np.ndarray: image : np.ndarray Scaled image data (uint8). """ - # Compute min/max once and reuse; also store display max - min_value, max_value, _, _ = cv2.minMaxLoc(image) + scaled, max_value = self._scale_image_intensity_with_bounds( + image=image, + autoscale=self.autoscale, + min_counts=self.min_counts, + max_counts=self.max_counts, + ) self._last_frame_display_max = max_value - - if self.autoscale: - # In the off chance that we get a flat image, we set the min and max. - if max_value > min_value: - scale = 255.0 / (max_value - min_value) - beta = -min_value * scale - return cv2.convertScaleAbs(image, alpha=scale, beta=beta) - - # If autoscale is disabled, we use the min/max counts from the GUI. - if self.max_counts > self.min_counts: - scale = 255.0 / (self.max_counts - self.min_counts) - beta = -self.min_counts * scale - return cv2.convertScaleAbs(image, alpha=scale, beta=beta) - - # If the user has provided incorrect min/max values, we return a flat image. - return np.ones_like(image, dtype=np.uint8) * 255 + return scaled def add_crosshair(self, image: np.ndarray) -> np.ndarray: """Adds a cross-hair to the image. @@ -940,8 +1452,12 @@ def add_crosshair(self, image: np.ndarray) -> np.ndarray: crosshair_x = -1 if crosshair_y < 0 or crosshair_y >= self.canvas_height: crosshair_y = -1 - image[:, int(crosshair_x)] = 255 - image[int(crosshair_y), :] = 255 + if image.ndim == 2: + image[:, int(crosshair_x)] = 255 + image[int(crosshair_y), :] = 255 + else: + image[:, int(crosshair_x), :] = 255 + image[int(crosshair_y), :, :] = 255 return image @@ -1085,9 +1601,8 @@ def process_image(self) -> None: def left_click(self, *_) -> None: """Toggles cross-hair on image upon left click event.""" - if self.image is not None: - self.apply_cross_hair = not self.apply_cross_hair - self.process_image() + self.apply_cross_hair = not self.apply_cross_hair + self._redraw_current_view() def resize(self, event: tk.Event) -> None: """Resize the window. @@ -1210,7 +1725,7 @@ def mouse_wheel(self, event: tk.Event) -> None: elif self.zoom_width < 5 or self.zoom_height < 5: return - self.process_image() + self._redraw_current_view() class CameraViewController(BaseViewController): @@ -1251,6 +1766,12 @@ def __init__(self, view, parent_controller=None) -> None: #: str: The display state. self.display_state = "Live" + #: int: Last observed channel index from acquisition stream. + self._latest_channel_idx = 0 + #: int: Last observed slice index from acquisition stream. + self._latest_slice_idx = 0 + #: dict: Per-channel/slice revision counters for overlay cache signatures. + self._channel_slice_revision: Dict[tuple[int, int], int] = {} #: int: The number of frames to average. self.rolling_frames = 1 @@ -1327,6 +1848,112 @@ def overlay_mask(self, image: np.ndarray, alpha=0.2) -> Optional[np.ndarray]: image = cv2.addWeighted(image, 1 - alpha, seg_mask, alpha, 0) return image + def _update_channel_selector_for_display_mode(self) -> None: + """Disable single-channel selectors while overlay mode is active.""" + if self.display_state != "Slice": + return + if self._has_selected_channels(): + self.view.live_frame.channel.configure(state="disabled") + else: + self.view.live_frame.channel.state(["!disabled", "readonly"]) + + def _refresh_after_display_mode_change(self) -> None: + """Re-render the current camera view after display mode transitions.""" + self.update_display_state() + if self.display_state == "Slice": + self.slider_update() + elif self._pending_display_image is not None: + self._request_display_if_needed() + elif self.spooled_images is not None: + latest_image = self.spooled_images.load_image( + channel=int(getattr(self, "_latest_channel_idx", 0)), + slice_index=int(getattr(self, "_latest_slice_idx", 0)), + ) + if latest_image is not None: + self.display_image(latest_image) + + def _get_overlay_target_slice(self) -> int: + """Get the current slice index used for camera overlay composition.""" + if self.display_state == "Slice": + return int(self.view.slider.get()) + return int(getattr(self, "_latest_slice_idx", 0)) + + def _collect_camera_overlay_channels( + self, + current_image: Optional[np.ndarray] = None, + ) -> tuple[Dict[str, np.ndarray], Dict[str, Any], bool]: + """Collect one image/signature per selected channel for camera overlay. + + Parameters + ---------- + current_image : Optional[np.ndarray] + Optional latest incoming image. When provided, this is used for the + matching latest channel/slice tuple to avoid an immediate spool reload. + + Returns + ------- + tuple[Dict[str, np.ndarray], Dict[str, Any], bool] + ``(channel_images, channel_signatures, all_channels_available)`` where + ``all_channels_available`` is True only when every selected channel has + data for the target slice. + """ + channel_images: Dict[str, np.ndarray] = {} + channel_signatures: Dict[str, Any] = {} + all_channels_available = True + if not isinstance(self.selected_channels, list): + return channel_images, channel_signatures, False + + target_slice = self._get_overlay_target_slice() + latest_channel_idx = int(getattr(self, "_latest_channel_idx", 0)) + latest_slice_idx = int(getattr(self, "_latest_slice_idx", target_slice)) + + for channel_idx, channel_name in enumerate(self.selected_channels): + if ( + current_image is not None + and channel_idx == latest_channel_idx + and latest_slice_idx == target_slice + ): + image = current_image + else: + image = self.spooled_images.load_image( + channel=channel_idx, + slice_index=target_slice, + ) + if image is None: + all_channels_available = False + continue + channel_images[channel_name] = self.flip_image(image) + revision = int( + getattr(self, "_channel_slice_revision", {}).get( + (channel_idx, int(target_slice)), + 0, + ) + ) + channel_signatures[channel_name] = ( + "camera", + channel_idx, + int(target_slice), + revision, + ) + + if len(channel_images) != len(self.selected_channels): + all_channels_available = False + return channel_images, channel_signatures, all_channels_available + + def _build_camera_channel_signature( + self, + channel_index: int, + slice_index: int, + ) -> tuple[str, int, int, int]: + """Build a stable signature used for per-slice channel cache reuse.""" + revision = int( + getattr(self, "_channel_slice_revision", {}).get( + (int(channel_index), int(slice_index)), + 0, + ) + ) + return ("camera", int(channel_index), int(slice_index), revision) + def try_to_display_image(self, image: np.ndarray) -> None: """Try to display an image. @@ -1343,6 +1970,10 @@ def try_to_display_image(self, image: np.ndarray) -> None: """ # Identify the channel index and slice index, update GUI. channel_idx, slice_idx = self.identify_channel_index_and_slice() + self._latest_channel_idx = channel_idx + self._latest_slice_idx = slice_idx + key = (channel_idx, slice_idx) + self._channel_slice_revision[key] = self._channel_slice_revision.get(key, 0) + 1 self.image_metrics["Channel"].set(int(self.selected_channels[channel_idx][2:])) # Save the image to the spooled image loader. @@ -1353,14 +1984,34 @@ def try_to_display_image(self, image: np.ndarray) -> None: # Update image according to the display state. self.display_state = self.view.live_frame.live.get() if self.display_state == "Live": - super().try_to_display_image(image) + if self._should_use_overlay_mode(): + super().try_to_display_image(image) + elif self._has_selected_channels(): + active_channel = self._get_multichannel_active_channel() + if ( + active_channel in self.selected_channels + and channel_idx == self.selected_channels.index(active_channel) + ): + super().try_to_display_image(image) + else: + super().try_to_display_image(image) elif self.display_state == "Slice": requested_slice = self.view.slider.get() - requested_channel = self.view.live_frame.channel.get() - requested_channel = int(requested_channel[-1]) - 1 - if slice_idx == requested_slice and channel_idx == requested_channel: - super().try_to_display_image(image) + if self._should_use_overlay_mode(): + if slice_idx == requested_slice: + super().try_to_display_image(image) + else: + if self._has_selected_channels(): + active_channel = self._get_multichannel_active_channel() + if active_channel not in self.selected_channels: + return + requested_channel = self.selected_channels.index(active_channel) + else: + requested_channel = self.view.live_frame.channel.get() + requested_channel = int(requested_channel[-1]) - 1 + if slice_idx == requested_slice and channel_idx == requested_channel: + super().try_to_display_image(image) def initialize_non_live_display( self, microscope_state: dict, camera_parameters: dict @@ -1375,9 +2026,11 @@ def initialize_non_live_display( Camera parameters. """ super().initialize_non_live_display(microscope_state, camera_parameters) - self.update_display_state() + self._channel_slice_revision = {} self.view.live_frame.channel["values"] = self.selected_channels self.view.live_frame.channel.set(self.selected_channels[0]) + self._configure_display_mode_controls() + self.update_display_state() self.spooled_images = SpooledImageLoader( channels=self.number_of_channels, size_y=self.original_image_height, @@ -1397,8 +2050,31 @@ def slider_update(self, *_) -> None: """Updates the image when the slider is moved.""" slider_index = self.view.slider.get() - channel_index = self.view.live_frame.channel.get() - channel_index = self.selected_channels.index(channel_index) + if self._should_use_overlay_mode(): + channel_images, channel_signatures, all_available = ( + self._collect_camera_overlay_channels() + ) + if not all_available: + return + img_out = self._compose_overlay_from_channels( + channel_images, + channel_signatures=channel_signatures, + ) + img_out = self.overlay_mask(img_out) + if img_out is None: + return + self.view.after(0, lambda img=img_out: self.populate_image(img)) + self.update_max_counts() + return + + if self._has_selected_channels(): + active_channel = self._get_multichannel_active_channel() + if active_channel not in self.selected_channels: + return + channel_index = self.selected_channels.index(active_channel) + else: + channel_index = self.view.live_frame.channel.get() + channel_index = self.selected_channels.index(channel_index) image = self.spooled_images.load_image( channel=channel_index, slice_index=slider_index ) @@ -1407,7 +2083,23 @@ def slider_update(self, *_) -> None: return self.image = self.flip_image(image) - self.process_image() + if self._has_selected_channels(): + active_channel = self._get_multichannel_active_channel() + if active_channel not in self.selected_channels: + return + channel_signature = self._build_camera_channel_signature( + channel_index=channel_index, + slice_index=slider_index, + ) + img_out = self._render_single_multichannel_frame( + active_channel, + self.image, + channel_signature=channel_signature, + ) + img_out = self.overlay_mask(img_out) + self.view.after(0, lambda img=img_out: self.populate_image(img)) + else: + self.process_image() self.update_max_counts() def update_display_state(self, *_) -> None: @@ -1433,10 +2125,12 @@ def update_display_state(self, *_) -> None: ) self.view.slider.configure(state="normal") self.view.slider.grid() - self.view.live_frame.channel.state(["!disabled", "readonly"]) - # was normal - if self.view.live_frame.channel.get() not in self.selected_channels: - self.view.live_frame.channel.set(self.selected_channels[0]) + if self._has_selected_channels(): + self.view.live_frame.channel.configure(state="disabled") + else: + self.view.live_frame.channel.state(["!disabled", "readonly"]) + if self.view.live_frame.channel.get() not in self.selected_channels: + self.view.live_frame.channel.set(self.selected_channels[0]) def initialize(self, name: str, data: list): """Sets widgets based on data given from main controller/config. @@ -1463,6 +2157,14 @@ def initialize(self, name: str, data: list): self.image_palette["Max"].set(max_value) self.image_palette["Min"].widget["state"] = "disabled" self.image_palette["Max"].widget["state"] = "disabled" + self.min_counts = float(min_value) + self.max_counts = float(max_value) + + self._ensure_overlay_channel_settings() + for channel in self.selected_channels or []: + self.overlay_channel_settings[channel]["min_counts"] = float(min_value) + self.overlay_channel_settings[channel]["max_counts"] = float(max_value) + self._sync_overlay_controls_from_cache() self.image_palette["Flip XY"].widget.invoke() @@ -1511,7 +2213,6 @@ def update_max_counts(self) -> None: elif self.rolling_frames == 1: rolling_average = self._last_frame_display_max elif self._max_intensity_history_idx >= self.rolling_frames: - rolling_average = ( sum( self.max_intensity_history[ @@ -1545,9 +2246,45 @@ def display_image(self, image: np.ndarray) -> None: image : np.ndarray Image data. """ + if self._should_use_overlay_mode(): + self._sync_overlay_cache_from_controls() + channel_images, channel_signatures, all_available = ( + self._collect_camera_overlay_channels(image) + ) + if not all_available: + return + img_out = self._compose_overlay_from_channels( + channel_images, + channel_signatures=channel_signatures, + ) + img_out = self.overlay_mask(img_out) + if img_out is not None: + self.view.after(0, lambda img=img_out: self.populate_image(img)) + self.update_max_counts() + return self.image = self.flip_image(image) + if self._has_selected_channels(): + self._sync_overlay_cache_from_controls() + active_channel = self._get_multichannel_active_channel() + if active_channel not in self.selected_channels: + return + channel_index = self.selected_channels.index(active_channel) + channel_signature = self._build_camera_channel_signature( + channel_index=channel_index, + slice_index=int(getattr(self, "_latest_slice_idx", 0)), + ) + img_out = self._render_single_multichannel_frame( + active_channel, + self.image, + channel_signature=channel_signature, + ) + img_out = self.overlay_mask(img_out) + self.view.after(0, lambda img=img_out: self.populate_image(img)) + self.update_max_counts() + return + if self._snr_selected: self.image = compute_signal_to_noise( self.image, self._offset, self._variance @@ -1624,6 +2361,12 @@ def __init__(self, view, parent_controller=None) -> None: #: int: Scaling factor for ratio of lateral and axial dimensions. self.Z_image_value = None + #: float: Ratio of axial spacing to lateral pixel size. + self.axial_to_lateral_ratio = 1.0 + + #: int: Pixel gap between panes in the multi-perspective layout. + self.multi_view_gap = 6 + #: np.ndarray: The image data. self.image = None @@ -1636,6 +2379,15 @@ def __init__(self, view, parent_controller=None) -> None: #: np.ndarray: The maximum intensity projection in the XY plane. self.xy_mip = None + #: np.ndarray: Scratch buffer for ZY max-reduction updates. + self._zy_reduce_buf = None + + #: np.ndarray: Scratch buffer for ZX max-reduction updates. + self._zx_reduce_buf = None + + #: dict: Per-channel revision counters for MIP projection updates. + self._mip_channel_revision: Dict[str, int] = {} + #: bool: The autoscale flag. self.autoscale = True @@ -1683,6 +2435,11 @@ def update_experiment(self) -> None: self.parent_controller.configuration["gui"]["mip_display"]["enabled"] = state # Communicate changes back to the menu controller. self.parent_controller.menu_controller.mip_enabled.set(state) + if state: + self._request_display_if_needed() + self.display_mip_image() + elif self._is_display_visible(): + self._clear_mip() def initialize(self, name: str, data: list) -> None: """Initialize the MIP view. @@ -1710,12 +2467,18 @@ def initialize(self, name: str, data: list) -> None: self.image_palette["Autoscale"].widget.invoke() self.image_palette["SNR"].grid_remove() - self.render_widgets["perspective"].widget["values"] = ("XY", "ZY", "ZX") - self.render_widgets["perspective"].set("XY") + self.render_widgets["perspective"].widget["values"] = ( + "Multi", + "XY", + "ZY", + "ZX", + ) + self.render_widgets["perspective"].set("Multi") self.get_selected_channels() if isinstance(self.selected_channels, list) and len(self.selected_channels) > 0: self.render_widgets["channel"].set(self.selected_channels[0]) + self._configure_display_mode_controls() # event binding self.render_widgets["perspective"].get_variable().trace_add( @@ -1732,6 +2495,11 @@ def prepare_mip_view(self) -> None: Pre-allocate the matrices for the MIP. """ self.render_widgets["channel"].widget["values"] = self.selected_channels + if isinstance(self.selected_channels, list): + self._mip_channel_revision = { + channel: 0 for channel in self.selected_channels + } + self._update_channel_selector_for_display_mode() self.preallocate_matrices() def preallocate_matrices(self) -> None: @@ -1767,6 +2535,124 @@ def preallocate_matrices(self) -> None: dtype=np.uint16, ) + self._zy_reduce_buf = np.empty((1, self.original_image_width), dtype=np.uint16) + self._zx_reduce_buf = np.empty((self.original_image_height, 1), dtype=np.uint16) + + def _ensure_mip_buffers_compatible(self, image: np.ndarray) -> np.ndarray: + """Ensure MIP buffers match incoming frame shape and dtype. + + Parameters + ---------- + image : np.ndarray + Incoming 2D frame from the capture buffer. + + Returns + ------- + np.ndarray + Frame converted (when needed) to the dtype used by MIP buffers. + """ + frame = np.asarray(image) + if frame.ndim != 2: + frame = np.squeeze(frame) + if frame.ndim != 2: + raise ValueError("MIP rendering requires a 2D frame.") + + frame_height, frame_width = frame.shape + xy_mip = getattr(self, "xy_mip", None) + needs_realloc = xy_mip is None or xy_mip.shape[1:] != ( + frame_height, + frame_width, + ) + if needs_realloc: + self.original_image_height = frame_height + self.original_image_width = frame_width + self.preallocate_matrices() + xy_mip = self.xy_mip + + target_dtype = xy_mip.dtype + if frame.dtype != target_dtype: + frame = frame.astype(target_dtype, copy=False) + + zy_reduce_buf = getattr(self, "_zy_reduce_buf", None) + if ( + zy_reduce_buf is None + or zy_reduce_buf.shape != (1, frame_width) + or zy_reduce_buf.dtype != target_dtype + ): + self._zy_reduce_buf = np.empty((1, frame_width), dtype=target_dtype) + zx_reduce_buf = getattr(self, "_zx_reduce_buf", None) + if ( + zx_reduce_buf is None + or zx_reduce_buf.shape != (frame_height, 1) + or zx_reduce_buf.dtype != target_dtype + ): + self._zx_reduce_buf = np.empty((frame_height, 1), dtype=target_dtype) + + return frame + + def _update_channel_selector_for_display_mode(self) -> None: + """Disable MIP legacy channel selector when compact channel picker is active.""" + if self._has_selected_channels(): + self.render_widgets["channel"].widget.state(["disabled"]) + else: + self.render_widgets["channel"].widget.state(["!disabled", "readonly"]) + + def _refresh_after_display_mode_change(self) -> None: + """Refresh MIP display after changing single/overlay mode.""" + self._update_channel_selector_for_display_mode() + self.display_mip_image() + + def _get_mip_projection_for_channel( + self, + channel_idx: int, + perspective: Optional[str] = None, + ) -> np.ndarray: + """Return one channel's MIP projection for the selected perspective.""" + display_mode = perspective or self.render_widgets["perspective"].get() + if display_mode == "Multi": + image = self._compose_multi_perspective(channel_idx) + elif display_mode == "XY": + image = self.xy_mip[channel_idx] + elif display_mode == "ZY": + image = self.zx_mip[channel_idx, :].T + image = self._rescale_orthogonal_for_anisotropy(image, display_mode) + else: + image = self.zy_mip[channel_idx, :] + image = self._rescale_orthogonal_for_anisotropy(image, display_mode) + return self.flip_image(image) + + def _get_active_mip_channel_name(self) -> Optional[str]: + """Get active channel for MIP single-channel rendering.""" + if not self._has_selected_channels(): + return None + channel = self._get_multichannel_active_channel() + if channel in self.selected_channels: + return channel + return self.selected_channels[0] if self.selected_channels else None + + def _collect_mip_overlay_channels( + self, + ) -> tuple[Dict[str, np.ndarray], Dict[str, Any]]: + """Collect per-channel MIP projections/signatures for overlay rendering.""" + channel_images: Dict[str, np.ndarray] = {} + channel_signatures: Dict[str, Any] = {} + if not isinstance(self.selected_channels, list): + return channel_images, channel_signatures + mip_channel_revision = getattr(self, "_mip_channel_revision", {}) + display_mode = self.render_widgets["perspective"].get() + for channel_idx, channel_name in enumerate(self.selected_channels): + channel_images[channel_name] = self._get_mip_projection_for_channel( + channel_idx, + display_mode, + ) + channel_signatures[channel_name] = ( + "mip", + channel_idx, + str(display_mode), + int(mip_channel_revision.get(channel_name, 0)), + ) + return channel_images, channel_signatures + def get_mip_image(self) -> np.ndarray or None: """Get MIP image according to perspective and channel id @@ -1779,25 +2665,133 @@ def get_mip_image(self) -> np.ndarray or None: if any(view is None for view in views): return None - display_mode = self.render_widgets["perspective"].get() - channel = self.render_widgets["channel"].get() - if channel in self.selected_channels: - channel_idx = self.selected_channels.index(channel) - else: + channel = self._get_active_mip_channel_name() + if channel is None: return - if display_mode == "XY": - image = self.xy_mip[channel_idx] - elif display_mode == "ZY": - image = self.zy_mip[channel_idx, :].T - else: - image = self.zx_mip[channel_idx, :] - - image = self.flip_image(image) + channel_idx = self.selected_channels.index(channel) + image = self._get_mip_projection_for_channel(channel_idx) # map the image to canvas size() image = self.down_sample_image(image, True) return image + def _compute_axial_to_lateral_ratio( + self, microscope_state: dict, camera_parameters: dict + ) -> float: + """Compute axial/lateral spacing ratio for isotropic orthogonal rendering.""" + lateral_size_um = None + fov_x = camera_parameters.get("fov_x") + img_x_pixels = camera_parameters.get("img_x_pixels", self.XY_image_width) + try: + if fov_x is not None and img_x_pixels not in (None, 0): + lateral_size_um = abs(float(fov_x)) / float(img_x_pixels) + except (TypeError, ValueError, ZeroDivisionError): + lateral_size_um = None + + if lateral_size_um is None or lateral_size_um <= 0: + microscope_name = microscope_state.get("microscope_name") + zoom = microscope_state.get("zoom") + try: + lateral_size_um = float( + self.parent_controller.configuration["configuration"][ + "microscopes" + ][microscope_name]["zoom"]["pixel_size"][zoom] + ) + except Exception: + lateral_size_um = None + + axial_size_um = None + step_size = microscope_state.get("step_size") + try: + if step_size not in (None, ""): + axial_size_um = abs(float(step_size)) + except (TypeError, ValueError): + axial_size_um = None + + if (axial_size_um is None or axial_size_um <= 0) and self.number_of_slices > 1: + try: + z_start = float(microscope_state.get("abs_z_start", 0.0)) + z_end = float(microscope_state.get("abs_z_end", 0.0)) + axial_size_um = abs(z_end - z_start) / float(self.number_of_slices - 1) + except (TypeError, ValueError, ZeroDivisionError): + axial_size_um = None + + if ( + lateral_size_um is None + or lateral_size_um <= 0 + or axial_size_um is None + or axial_size_um <= 0 + ): + return 1.0 + + return max(axial_size_um / lateral_size_um, 1e-6) + + def _rescale_orthogonal_for_anisotropy( + self, image: np.ndarray, display_mode: str + ) -> np.ndarray: + """Rescale orthogonal projections along Z so display spacing is isotropic. + + ZY keeps Z on image width; ZX keeps Z on image height. + """ + if display_mode == "XY": + return image + + ratio = float(getattr(self, "axial_to_lateral_ratio", 1.0)) + if np.isclose(ratio, 1.0, rtol=1e-3, atol=1e-3): + return image + + if display_mode == "ZY": + target_width = max(1, int(round(image.shape[1] * ratio))) + if target_width == image.shape[1]: + return image + return cv2.resize( + image, + (target_width, image.shape[0]), + interpolation=cv2.INTER_NEAREST, + ) + + target_height = max(1, int(round(image.shape[0] * ratio))) + if target_height == image.shape[0]: + return image + + return cv2.resize( + image, + (image.shape[1], target_height), + interpolation=cv2.INTER_NEAREST, + ) + + def _compose_multi_perspective(self, channel_idx: int) -> np.ndarray: + """Compose XY main pane, YZ right, XZ bottom into one monochrome frame.""" + xy = self.xy_mip[channel_idx] + yz = self._rescale_orthogonal_for_anisotropy( + self.zx_mip[channel_idx, :].T, + "ZY", + ) + xz = self._rescale_orthogonal_for_anisotropy( + self.zy_mip[channel_idx, :], + "ZX", + ) + + gap = int(getattr(self, "multi_view_gap", 6)) + total_height = xy.shape[0] + gap + xz.shape[0] + total_width = xy.shape[1] + gap + yz.shape[1] + fill_value = int(min(xy.min(), yz.min(), xz.min())) + composite = np.full( + (total_height, total_width), + fill_value=fill_value, + dtype=xy.dtype, + ) + + xy_y0, xy_x0 = 0, 0 + composite[xy_y0 : xy_y0 + xy.shape[0], xy_x0 : xy_x0 + xy.shape[1]] = xy + + yz_x0 = xy_x0 + xy.shape[1] + gap + composite[xy_y0 : xy_y0 + yz.shape[0], yz_x0 : yz_x0 + yz.shape[1]] = yz + + xz_y0 = xy_y0 + xy.shape[0] + gap + composite[xz_y0 : xz_y0 + xz.shape[0], xy_x0 : xy_x0 + xz.shape[1]] = xz + return composite + def initialize_non_live_display( self, microscope_state: dict, camera_parameters: dict ) -> None: @@ -1811,20 +2805,23 @@ def initialize_non_live_display( Camera parameters. """ super().initialize_non_live_display(microscope_state, camera_parameters) + self._mip_channel_revision = { + channel: 0 for channel in (self.selected_channels or []) + } if isinstance(self.selected_channels, list) and len(self.selected_channels) > 0: self.render_widgets["channel"].set(self.selected_channels[0]) + self._configure_display_mode_controls() self.perspective = self.render_widgets["perspective"].get() self.XY_image_width = self.original_image_width self.XY_image_height = self.original_image_height - z_range = microscope_state["abs_z_end"] - microscope_state["abs_z_start"] - - # TODO: may stretch by the value of binning. - if z_range == 0: - self.Z_image_value = 1 - else: - self.Z_image_value = int( - self.XY_image_width * camera_parameters["fov_x"] / z_range - ) + self.axial_to_lateral_ratio = self._compute_axial_to_lateral_ratio( + microscope_state, + camera_parameters, + ) + self.Z_image_value = max( + 1, + int(round(self.number_of_slices * self.axial_to_lateral_ratio)), + ) self.prepare_mip_view() self.update_perspective() @@ -1842,17 +2839,33 @@ def try_to_display_image(self, image: np.ndarray) -> None: return if not self.display_enabled.get(): - self._clear_mip() + if self._is_display_visible(): + self._clear_mip() return + image = self._ensure_mip_buffers_compatible(image) + # Orthogonal maximum intensity projections. - self.xy_mip[channel_idx] = np.maximum(self.xy_mip[channel_idx], image) - self.zy_mip[channel_idx, slice_idx] = np.maximum( - self.zy_mip[channel_idx, slice_idx], np.max(image, axis=0) - ) - self.zx_mip[channel_idx, slice_idx] = np.maximum( - self.zx_mip[channel_idx, slice_idx], np.max(image, axis=1) - ) + cv2.max(self.xy_mip[channel_idx], image, self.xy_mip[channel_idx]) + zy_slice = self.zy_mip[channel_idx, slice_idx].reshape(1, -1) + cv2.reduce(image, 0, cv2.REDUCE_MAX, self._zy_reduce_buf) + cv2.max(zy_slice, self._zy_reduce_buf, zy_slice) + zx_slice = self.zx_mip[channel_idx, slice_idx].reshape(-1, 1) + cv2.reduce(image, 1, cv2.REDUCE_MAX, self._zx_reduce_buf) + cv2.max(zx_slice, self._zx_reduce_buf, zx_slice) + selected_channels = getattr(self, "selected_channels", None) + if isinstance(selected_channels, list) and 0 <= channel_idx < len( + selected_channels + ): + channel_name = selected_channels[channel_idx] + if ( + not hasattr(self, "_mip_channel_revision") + or self._mip_channel_revision is None + ): + self._mip_channel_revision = {} + self._mip_channel_revision[channel_name] = ( + self._mip_channel_revision.get(channel_name, 0) + 1 + ) super().try_to_display_image(image) @@ -1884,16 +2897,83 @@ def display_image(self, image: np.ndarray) -> None: image : np.ndarray Image data. """ + if self._should_use_overlay_mode(): + self._sync_overlay_cache_from_controls() + channel_images, channel_signatures = self._collect_mip_overlay_channels() + overlay = self._compose_overlay_from_channels( + channel_images, + channel_signatures=channel_signatures, + ) + if overlay is not None: + self.populate_image(overlay) + return + + if self._has_selected_channels(): + self._sync_overlay_cache_from_controls() + active_channel = self._get_active_mip_channel_name() + if active_channel not in self.selected_channels: + return + channel_index = self.selected_channels.index(active_channel) + projection = self._get_mip_projection_for_channel(channel_index) + channel_signature = ( + "mip", + channel_index, + str(self.render_widgets["perspective"].get()), + int(self._mip_channel_revision.get(active_channel, 0)), + ) + img_out = self._render_single_multichannel_frame( + active_channel, + projection, + channel_signature=channel_signature, + ) + self.populate_image(img_out) + return + self.image = self.get_mip_image() self.process_image() def display_mip_image(self, *_) -> None: """Display MIP image in non-live view.""" + if not self._is_display_visible(): + return + self._update_channel_selector_for_display_mode() if self.perspective != self.render_widgets["perspective"].get(): self.update_perspective() if self.mode != "stop": return + if self._should_use_overlay_mode(): + self._sync_overlay_cache_from_controls() + channel_images, channel_signatures = self._collect_mip_overlay_channels() + overlay = self._compose_overlay_from_channels( + channel_images, + channel_signatures=channel_signatures, + ) + if overlay is not None: + self.populate_image(overlay) + return + + if self._has_selected_channels(): + self._sync_overlay_cache_from_controls() + active_channel = self._get_active_mip_channel_name() + if active_channel not in self.selected_channels: + return + channel_index = self.selected_channels.index(active_channel) + projection = self._get_mip_projection_for_channel(channel_index) + channel_signature = ( + "mip", + channel_index, + str(self.render_widgets["perspective"].get()), + int(self._mip_channel_revision.get(active_channel, 0)), + ) + img_out = self._render_single_multichannel_frame( + active_channel, + projection, + channel_signature=channel_signature, + ) + self.populate_image(img_out) + return + self.image = self.get_mip_image() if self.image is not None: self.process_image() @@ -1913,15 +2993,20 @@ def update_perspective(self) -> None: display_mode = self.render_widgets["perspective"].get() self.perspective = display_mode - if display_mode == "XY": + if display_mode == "Multi": + z_scaled = max(1, self.Z_image_value) + gap = int(getattr(self, "multi_view_gap", 6)) + self.original_image_width = self.XY_image_width + gap + z_scaled + self.original_image_height = self.XY_image_height + gap + z_scaled + elif display_mode == "XY": self.original_image_width = self.XY_image_width self.original_image_height = self.XY_image_height elif display_mode == "ZY": self.original_image_width = self.Z_image_value self.original_image_height = self.XY_image_height elif display_mode == "ZX": - self.original_image_width = self.Z_image_value - self.original_image_height = self.XY_image_width + self.original_image_width = self.XY_image_width + self.original_image_height = self.Z_image_value self.update_canvas_size() self.reset_display(False) @@ -1944,6 +3029,11 @@ def down_sample_image( Down-sampled image data. """ sx, sy = self.canvas_width, self.canvas_height + if self.render_widgets["perspective"].get() == "Multi": + sx = int(self.view.canvas["width"]) + sy = int(self.view.canvas["height"]) + self.canvas_width = sx + self.canvas_height = sy down_sampled_image = cv2.resize(image, (sx, sy)) if reset_original: self.original_image_width = self.canvas_width diff --git a/src/navigate/view/custom_widgets/LabelInputWidgetFactory.py b/src/navigate/view/custom_widgets/LabelInputWidgetFactory.py index 67f360173..b45db4ff8 100644 --- a/src/navigate/view/custom_widgets/LabelInputWidgetFactory.py +++ b/src/navigate/view/custom_widgets/LabelInputWidgetFactory.py @@ -31,9 +31,10 @@ # POSSIBILITY OF SUCH DAMAGE. # Standard Library Imports +import logging import tkinter as tk from tkinter import ttk -import logging +from typing import Any # Third Party Imports @@ -74,6 +75,18 @@ class LabelInput(ttk.Frame): """ + _selection_widgets = ( + ttk.Checkbutton, + ttk.Radiobutton, + HoverCheckButton, + HoverRadioButton, + ) + _button_widgets = _selection_widgets + ( + ttk.Button, + HoverButton, + HoverTkButton, + ) + def __init__( self, parent, @@ -122,24 +135,28 @@ def __init__( self.variable = input_var #: tk.Widget: The widget of the input widget self.input_class = input_class + #: Optional[ttk.Label]: The label paired with the input widget when present. + self.label = None + + # Selection widgets should honor label placement like other form controls + # when a left or top label is requested. + external_label = ( + input_class not in self._button_widgets + or (input_class in self._selection_widgets and label_pos in ("left", "top")) + ) """ Create widgets based on their type, considering formatting differences.""" - if input_class in ( - ttk.Checkbutton, - ttk.Button, - ttk.Radiobutton, - HoverButton, - HoverTkButton, - HoverCheckButton, - HoverRadioButton, - ): - input_args["text"] = label - input_args["variable"] = input_var - else: + if external_label: #: ttk.Label: The label of the input widget self.label = ttk.Label(self, text=label, **label_args) - self.label.grid(row=0, column=0, sticky=tk.EW) - input_args["textvariable"] = input_var + if input_class in self._button_widgets: + input_args["variable"] = input_var + else: + input_args["textvariable"] = input_var + else: + input_args.setdefault("text", label) + if input_class in self._button_widgets: + input_args["variable"] = input_var """Call the passed widget type constructor with the passed args""" #: tk.Widget: The widget of the input widget @@ -147,15 +164,27 @@ def __init__( """Specify label position""" if label_pos == "top": - self.widget.grid(row=1, column=0, sticky=(tk.W + tk.E)) + if self.label is not None: + self.label.grid(row=0, column=0, sticky=tk.EW) + widget_row = 1 + self.rowconfigure(index=0, weight=1) + self.rowconfigure(index=1, weight=1) + else: + widget_row = 0 + self.rowconfigure(index=0, weight=1) + self.widget.grid(row=widget_row, column=0, sticky=(tk.W + tk.E)) self.columnconfigure(0, weight=1) - self.rowconfigure(index=0, weight=1) - self.rowconfigure(index=1, weight=1) else: - self.widget.grid(row=0, column=1, sticky=(tk.W + tk.E)) + if self.label is not None: + self.label.grid(row=0, column=0, sticky=tk.EW) + widget_column = 1 + self.columnconfigure(index=0, weight=1) + self.columnconfigure(index=1, weight=1) + else: + widget_column = 0 + self.columnconfigure(index=0, weight=1) + self.widget.grid(row=0, column=widget_column, sticky=(tk.W + tk.E)) self.rowconfigure(0, weight=1) - self.columnconfigure(index=0, weight=1) - self.columnconfigure(index=1, weight=1) def get(self, default=None): """Returns the value of the input widget @@ -302,3 +331,62 @@ def pad_input(self, left, up, right, down): >>> widget.pad_input(10, 10, 10, 10) """ self.widget.grid(padx=(left, right), pady=(up, down)) + + +class WidgetInputAdapter: + """Expose a standalone widget through the same accessors as ``LabelInput``. + + This is useful when the parent container owns the grid layout directly and a + nested ``LabelInput`` frame would disrupt column alignment. + + Parameters + ---------- + widget : tk.Widget + Widget to expose through the adapter. + variable : Any, optional + Tk variable bound to the widget, by default None. + label : ttk.Label, optional + Optional external label paired with the widget, by default None. + """ + + def __init__(self, widget, variable=None, label=None): + """Initialize the widget adapter.""" + self.widget = widget + self.variable = variable + self.label = label + self.master = widget.master + + def get(self, default=None): + """Return the current widget value.""" + try: + if self.variable is not None: + return self.variable.get() + elif isinstance(self.widget, tk.Text): + return self.widget.get("1.0", tk.END) + return self.widget.get() + except (TypeError, tk.TclError): + if default is not None: + return default + return "" + + def get_variable(self): + """Return the Tk variable associated with the widget.""" + return self.variable + + def set(self, value, *args: Any, **kwargs: Any): + """Set the widget value through the bound variable when possible.""" + if isinstance(self.variable, tk.BooleanVar): + self.variable.set(bool(value)) + elif self.variable is not None: + self.variable.set(value, *args, **kwargs) + elif type(self.widget).__name__.endswith("button"): + if value: + self.widget.select() + else: + self.widget.deselect() + elif isinstance(self.widget, tk.Text): + self.widget.delete("1.0", tk.END) + self.widget.insert("1.0", value) + else: + self.widget.delete(0, tk.END) + self.widget.insert(0, value) diff --git a/src/navigate/view/main_window_content/display_notebook.py b/src/navigate/view/main_window_content/display_notebook.py index da6037077..bb255e654 100644 --- a/src/navigate/view/main_window_content/display_notebook.py +++ b/src/navigate/view/main_window_content/display_notebook.py @@ -34,7 +34,7 @@ import tkinter as tk from tkinter import ttk import logging -from typing import Iterable, Dict, Any +from typing import Callable, Iterable, Dict, Any, Optional # Third Party Imports from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg @@ -42,15 +42,25 @@ # Local Imports from navigate.view.custom_widgets.DockableNotebook import DockableNotebook -from navigate.view.custom_widgets.LabelInputWidgetFactory import LabelInput +from navigate.view.custom_widgets.LabelInputWidgetFactory import ( + LabelInput, + WidgetInputAdapter, +) from navigate.view.custom_widgets.validation import ValidatedSpinbox from navigate.view.custom_widgets.common import CommonMethods, uniform_grid +from navigate.view.theme import get_theme_font, get_theme_spacing # Logger Setup p = __name__.split(".")[1] logger = logging.getLogger(p) +def _space(px: int) -> int: + """Resolve spacing through the active GUI theme token map.""" + normalized_px = max(0, int(px)) + return get_theme_spacing(f"space_{normalized_px}", normalized_px) + + class CameraNotebook(DockableNotebook): """This class is the notebook that holds the camera view and waveform settings tabs.""" @@ -137,7 +147,10 @@ def __init__( self.canvas = tk.Canvas( self.cam_image, width=self.canvas_width, height=self.canvas_height ) - self.canvas.grid(row=0, column=0, sticky=tk.NSEW, padx=5, pady=5) + outer_pad = _space(5) + self.canvas.grid( + row=0, column=0, sticky=tk.NSEW, padx=outer_pad, pady=outer_pad + ) #: matplotlib.figure.Figure: The figure that will hold the camera image. self.matplotlib_figure = Figure(figsize=(6.0, 6.0), tight_layout=True) @@ -145,13 +158,21 @@ def __init__( #: FigureCanvasTkAgg: The canvas that will hold the camera image. self.matplotlib_canvas = FigureCanvasTkAgg(self.matplotlib_figure, self.canvas) + #: DisplayModeFrame: The frame that controls single-channel vs overlay display. + self.display_mode = DisplayModeFrame(self) + self.display_mode.grid( + row=0, column=1, sticky=tk.NSEW, padx=outer_pad, pady=outer_pad + ) + #: IntensityFrame: The frame that will hold the scale settings/palette color. self.lut = IntensityFrame(self) - self.lut.grid(row=0, column=1, sticky=tk.NSEW, padx=5, pady=5) + self.lut.grid(row=1, column=1, sticky=tk.NSEW, padx=outer_pad, pady=outer_pad) #: RenderFrame: The frame that will hold the live display functionality. self.render = MipRenderFrame(self) - self.render.grid(row=1, column=1, sticky=tk.NSEW, padx=5, pady=5) + self.render.grid( + row=2, column=1, sticky=tk.NSEW, padx=outer_pad, pady=outer_pad + ) uniform_grid(self) @@ -201,7 +222,10 @@ def __init__( self.canvas = tk.Canvas( self.cam_image, width=self.canvas_width, height=self.canvas_height ) - self.canvas.grid(row=0, column=0, sticky=tk.NSEW, padx=5, pady=5) + outer_pad = _space(5) + self.canvas.grid( + row=0, column=0, sticky=tk.NSEW, padx=outer_pad, pady=outer_pad + ) #: matplotlib.figure.Figure: The figure that will hold the camera image. self.matplotlib_figure = Figure(figsize=[6, 6], tight_layout=True) @@ -219,27 +243,44 @@ def __init__( showvalue=0, label="Slice", ) - self.slider.configure(state="disabled") - self.slider.grid(row=1, column=0, sticky=tk.NSEW, padx=5, pady=5) + self.slider.configure(state="disabled", font=get_theme_font("caption")) + self.slider.grid( + row=1, column=0, sticky=tk.NSEW, padx=outer_pad, pady=outer_pad + ) self.slider.grid_remove() #: HistogramFrame: The frame that will hold the histogram. self.histogram = HistogramFrame(self) self.histogram.grid( - row=2, column=0, columnspan=2, sticky=tk.NSEW, padx=5, pady=5 + row=2, + column=0, + columnspan=2, + sticky=tk.NSEW, + padx=outer_pad, + pady=outer_pad, + ) + + #: IntensityFrame: The frame that will hold the scale settings/palette color. + self.display_mode = DisplayModeFrame(self.display_setting) + self.display_mode.grid( + row=0, column=1, sticky=tk.NSEW, padx=outer_pad, pady=outer_pad ) #: IntensityFrame: The frame that will hold the scale settings/palette color. self.lut = IntensityFrame(self.display_setting) - self.lut.grid(row=0, column=1, sticky=tk.NSEW, padx=5, pady=5) + self.lut.grid(row=1, column=1, sticky=tk.NSEW, padx=outer_pad, pady=outer_pad) #: MetricsFrame: The frame that will hold the camera selection and counts. self.image_metrics = MetricsFrame(self.display_setting) - self.image_metrics.grid(row=1, column=1, sticky=tk.NSEW, padx=5, pady=5) + self.image_metrics.grid( + row=2, column=1, sticky=tk.NSEW, padx=outer_pad, pady=outer_pad + ) #: RenderFrame: The frame that will hold the live display functionality. self.live_frame = RenderFrame(self.display_setting) - self.live_frame.grid(row=2, column=1, sticky=tk.NSEW, padx=5, pady=5) + self.live_frame.grid( + row=3, column=1, sticky=tk.NSEW, padx=outer_pad, pady=outer_pad + ) class HistogramFrame(ttk.Labelframe): @@ -268,7 +309,13 @@ def __init__( #: ttk.Frame: The frame for the histogram. self.frame = ttk.Frame(self) - self.frame.grid(row=0, column=0, sticky=tk.NSEW, padx=0, pady=0) + self.frame.grid( + row=0, + column=0, + sticky=tk.NSEW, + padx=_space(0), + pady=_space(0), + ) self.frame.grid_rowconfigure(0, weight=1) self.frame.grid_columnconfigure(0, weight=1) @@ -296,6 +343,35 @@ def _resize_figure_to_frame(self, event: tk.Event) -> None: self.figure_canvas.draw_idle() +class DisplayModeFrame(ttk.Labelframe, CommonMethods): + """Display mode controls for single-channel or overlay rendering.""" + + def __init__( + self, camera_tab: CameraTab, *args: Iterable, **kwargs: Dict[str, Any] + ) -> None: + text_label = "Display Mode" + ttk.Labelframe.__init__(self, camera_tab, text=text_label, *args, **kwargs) + + self.inputs = { + "mode": LabelInput( + parent=self, + label="Mode", + input_class=ttk.Combobox, + input_var=tk.StringVar(), + input_args={"width": 9}, + ) + } + self.inputs["mode"].widget["values"] = ("Single", "Overlay") + self.inputs["mode"].set("Single") + self.inputs["mode"].widget.state(["!disabled", "readonly"]) + compact_pad = _space(3) + self.inputs["mode"].grid( + row=0, column=0, sticky=tk.NSEW, padx=compact_pad, pady=compact_pad + ) + + uniform_grid(self) + + class RenderFrame(ttk.Labelframe): """This class is the frame that holds the live display functionality.""" @@ -381,8 +457,13 @@ def __init__( } self.inputs["perspective"].widget.state(["!disabled", "readonly"]) self.inputs["channel"].widget.state(["!disabled", "readonly"]) - self.inputs["perspective"].grid(row=0, column=0, sticky=tk.EW, padx=3, pady=3) - self.inputs["channel"].grid(row=1, column=0, sticky=tk.EW, padx=3, pady=3) + compact_pad = _space(3) + self.inputs["perspective"].grid( + row=0, column=0, sticky=tk.EW, padx=compact_pad, pady=compact_pad + ) + self.inputs["channel"].grid( + row=1, column=0, sticky=tk.EW, padx=compact_pad, pady=compact_pad + ) self.columnconfigure(0, weight=1) uniform_grid(self) @@ -428,7 +509,10 @@ def __init__( #: WaveformSettingsFrame: The frame that will hold the waveform settings. self.waveform_settings = WaveformSettingsFrame(self) - self.waveform_settings.grid(row=1, column=0, sticky=tk.NSEW, padx=5, pady=5) + outer_pad = _space(5) + self.waveform_settings.grid( + row=1, column=0, sticky=tk.NSEW, padx=outer_pad, pady=outer_pad + ) uniform_grid(self) @@ -465,7 +549,10 @@ def __init__( ) } - self.inputs["sample_rate"].grid(row=0, column=0, sticky=tk.NSEW, padx=3, pady=3) + compact_pad = _space(3) + self.inputs["sample_rate"].grid( + row=0, column=0, sticky=tk.NSEW, padx=compact_pad, pady=compact_pad + ) self.inputs["waveform_template"] = LabelInput( parent=self, @@ -475,7 +562,7 @@ def __init__( input_args={"width": 20}, ) self.inputs["waveform_template"].grid( - row=0, column=1, sticky=tk.NSEW, padx=3, pady=3 + row=0, column=1, sticky=tk.NSEW, padx=compact_pad, pady=compact_pad ) uniform_grid(self) @@ -511,6 +598,8 @@ def __init__( self.names = ["Frames", "Image", "Channel"] # Loop for widgets + outer_pad = _space(5) + compact_pad = _space(3) for i in range(len(self.labels)): if i == 0: self.inputs[self.names[i]] = LabelInput( @@ -522,7 +611,11 @@ def __init__( label_pos="top", ) self.inputs[self.names[i]].grid( - row=i, column=0, sticky=tk.NSEW, padx=5, pady=3 + row=i, + column=0, + sticky=tk.NSEW, + padx=outer_pad, + pady=compact_pad, ) if i > 0: self.inputs[self.names[i]] = LabelInput( @@ -534,7 +627,11 @@ def __init__( label_pos="top", ) self.inputs[self.names[i]].grid( - row=i, column=0, sticky=tk.NSEW, padx=5, pady=3 + row=i, + column=0, + sticky=tk.NSEW, + padx=outer_pad, + pady=compact_pad, ) self.inputs[self.names[i]].configure(width=5) @@ -562,8 +659,198 @@ def __init__( text_label = "LUT" ttk.Labelframe.__init__(self, camera_tab, text=text_label, *args, **kwargs) - #: dict: The dictionary that holds the widgets. - self.inputs = {} + #: dict: The dictionary that holds the single-channel widgets. + self.inputs: Dict[str, Any] = {} + #: dict: Channel-specific compact control states keyed by channel name. + self._multichannel_channel_states: Dict[str, Dict[str, Any]] = {} + #: Optional[Callable[[str, str], None]]: Callback for per-channel control changes. + self._multichannel_on_change: Optional[Callable[[str, str], None]] = None + #: bool: Guard to prevent recursive callbacks while synchronizing control values. + self._multichannel_syncing = False + #: bool: Whether channel selector should expose concrete channels (Overlay mode). + self._multichannel_overlay_mode = False + #: list[str]: Active acquisition channels for compact controls. + self._multichannel_channels: list[str] = [] + #: str: Label used for the disabled aggregate selector in single mode. + self._all_channels_label = "All" + self._active_multichannel_channel = tk.StringVar() + self._active_multichannel_lut = tk.StringVar() + self._active_multichannel_visible = tk.BooleanVar(value=True) + self._active_multichannel_autoscale = tk.BooleanVar(value=True) + self._active_multichannel_min = tk.IntVar(value=0) + self._active_multichannel_max = tk.IntVar(value=2**16 - 1) + self._active_multichannel_alpha = tk.DoubleVar(value=100.0) + self._active_multichannel_gamma = tk.DoubleVar(value=1.0) + self.transpose = tk.BooleanVar() + self.trans = "Flip XY" + dense_pad = _space(2) + compact_pad = _space(3) + + self.single_channel_frame = ttk.Frame(self) + self.single_channel_frame.grid(row=0, column=0, sticky=tk.NSEW) + self.multichannel_frame = ttk.Frame(self) + self.multichannel_frame.grid(row=0, column=0, sticky=tk.NSEW) + self.multichannel_frame.grid_remove() + + ttk.Label(self.multichannel_frame, text="Channel").grid( + row=0, column=0, sticky=tk.W, padx=dense_pad, pady=dense_pad + ) + self._multichannel_channel_widget = ttk.Combobox( + self.multichannel_frame, + textvariable=self._active_multichannel_channel, + width=9, + state="disabled", + ) + self._multichannel_channel_widget.grid( + row=0, column=1, sticky=tk.EW, padx=dense_pad, pady=dense_pad + ) + + ttk.Label(self.multichannel_frame, text="LUT").grid( + row=1, column=0, sticky=tk.W, padx=dense_pad, pady=dense_pad + ) + self._multichannel_lut_widget = ttk.Combobox( + self.multichannel_frame, + textvariable=self._active_multichannel_lut, + width=9, + state="readonly", + values=self.multichannel_color_labels, + ) + self._multichannel_lut_widget.grid( + row=1, column=1, sticky=tk.EW, padx=dense_pad, pady=dense_pad + ) + + self._multichannel_visible_label = ttk.Label( + self.multichannel_frame, text="Visible" + ) + self._multichannel_visible_label.grid( + row=2, column=0, sticky=tk.W, padx=dense_pad, pady=dense_pad + ) + self._multichannel_visible_widget = ttk.Checkbutton( + self.multichannel_frame, + variable=self._active_multichannel_visible, + ) + self._multichannel_visible_widget.grid( + row=2, column=1, sticky=tk.W, padx=dense_pad, pady=dense_pad + ) + + ttk.Label(self.multichannel_frame, text="Alpha").grid( + row=3, column=0, sticky=tk.W, padx=dense_pad, pady=dense_pad + ) + self._multichannel_alpha_widget = ttk.Scale( + self.multichannel_frame, + variable=self._active_multichannel_alpha, + from_=0.0, + to=100.0, + orient=tk.HORIZONTAL, + ) + self._multichannel_alpha_widget.grid( + row=3, column=1, sticky=tk.EW, padx=dense_pad, pady=dense_pad + ) + + ttk.Label(self.multichannel_frame, text="Gamma").grid( + row=4, column=0, sticky=tk.W, padx=dense_pad, pady=dense_pad + ) + self._multichannel_gamma_widget = ttk.Spinbox( + self.multichannel_frame, + textvariable=self._active_multichannel_gamma, + from_=0.0, + to=2.0, + increment=0.01, + width=9, + ) + self._multichannel_gamma_widget.grid( + row=4, column=1, sticky=tk.EW, padx=dense_pad, pady=dense_pad + ) + + self._multichannel_transpose_label = ttk.Label( + self.multichannel_frame, text=self.trans + ) + self._multichannel_transpose_label.grid( + row=5, column=0, sticky=tk.W, padx=dense_pad, pady=dense_pad + ) + self._multichannel_transpose_widget = ttk.Checkbutton( + self.multichannel_frame, + variable=self.transpose, + ) + self._multichannel_transpose_widget.grid( + row=5, column=1, sticky=tk.W, padx=dense_pad, pady=dense_pad + ) + self.inputs[self.trans] = WidgetInputAdapter( + self._multichannel_transpose_widget, + variable=self.transpose, + label=self._multichannel_transpose_label, + ) + + self._multichannel_autoscale_label = ttk.Label( + self.multichannel_frame, text="Autoscale" + ) + self._multichannel_autoscale_label.grid( + row=6, column=0, sticky=tk.W, padx=dense_pad, pady=dense_pad + ) + self._multichannel_autoscale_widget = ttk.Checkbutton( + self.multichannel_frame, + variable=self._active_multichannel_autoscale, + ) + self._multichannel_autoscale_widget.grid( + row=6, column=1, sticky=tk.W, padx=dense_pad, pady=dense_pad + ) + + ttk.Label(self.multichannel_frame, text="Min Counts").grid( + row=7, column=0, sticky=tk.W, padx=dense_pad, pady=dense_pad + ) + self._multichannel_min_widget = ttk.Spinbox( + self.multichannel_frame, + textvariable=self._active_multichannel_min, + from_=0, + to=2**16 - 1, + increment=1, + width=9, + ) + self._multichannel_min_widget.grid( + row=7, column=1, sticky=tk.EW, padx=dense_pad, pady=dense_pad + ) + + ttk.Label(self.multichannel_frame, text="Max Counts").grid( + row=8, column=0, sticky=tk.W, padx=dense_pad, pady=dense_pad + ) + self._multichannel_max_widget = ttk.Spinbox( + self.multichannel_frame, + textvariable=self._active_multichannel_max, + from_=0, + to=2**16 - 1, + increment=1, + width=9, + ) + self._multichannel_max_widget.grid( + row=8, column=1, sticky=tk.EW, padx=dense_pad, pady=dense_pad + ) + + self._multichannel_channel_widget.bind( + "<>", + self._on_multichannel_channel_selected, + ) + self._multichannel_lut_widget.bind( + "<>", + lambda *_: self._on_multichannel_value_changed("lut"), + ) + self._active_multichannel_visible.trace_add( + "write", lambda *_: self._on_multichannel_value_changed("visible") + ) + self._active_multichannel_alpha.trace_add( + "write", lambda *_: self._on_multichannel_value_changed("alpha") + ) + self._active_multichannel_gamma.trace_add( + "write", lambda *_: self._on_multichannel_value_changed("gamma") + ) + self._active_multichannel_autoscale.trace_add( + "write", lambda *_: self._on_multichannel_value_changed("autoscale") + ) + self._active_multichannel_min.trace_add( + "write", lambda *_: self._on_multichannel_value_changed("min") + ) + self._active_multichannel_max.trace_add( + "write", lambda *_: self._on_multichannel_value_changed("max") + ) #: list: The list of LUTs for the image display. self.color_labels = [ @@ -586,31 +873,17 @@ def __init__( self.color = tk.StringVar() for i in range(len(self.color_labels)): self.inputs[self.color_labels[i]] = LabelInput( - parent=self, + parent=self.single_channel_frame, label=self.color_labels[i], input_class=ttk.Radiobutton, input_var=self.color, input_args={"value": self.color_values[i]}, ) self.inputs[self.color_labels[i]].grid( - row=row, column=0, sticky=tk.W, pady=3 + row=row, column=0, sticky=tk.W, pady=compact_pad ) row += 1 - #: tk.BooleanVar: The variable that holds the flip xy flag. - self.transpose = tk.BooleanVar() - - #: str: The name of the flip xy flag. - self.trans = "Flip XY" - self.inputs[self.trans] = LabelInput( - parent=self, - label=self.trans, - input_class=ttk.Checkbutton, - input_var=self.transpose, - ) - self.inputs[self.trans].grid(row=row, column=0, sticky=tk.W, pady=3) - row += 1 - #: tk.BooleanVar: The variable that holds the autoscale flag. self.autoscale = tk.BooleanVar() @@ -623,18 +896,18 @@ def __init__( #: list: The list of min and max names. self.minmax_names = ["Min", "Max"] self.inputs[self.auto] = LabelInput( - parent=self, + parent=self.single_channel_frame, label=self.auto, input_class=ttk.Checkbutton, input_var=self.autoscale, ) - self.inputs[self.auto].grid(row=row, column=0, sticky=tk.W, pady=3) + self.inputs[self.auto].grid(row=row, column=0, sticky=tk.W, pady=compact_pad) row += 1 # Max and Min Counts for i in range(len(self.minmax)): self.inputs[self.minmax_names[i]] = LabelInput( - parent=self, + parent=self.single_channel_frame, label=self.minmax[i], input_class=ttk.Spinbox, input_var=tk.IntVar(), @@ -644,9 +917,308 @@ def __init__( row=row, column=0, sticky=tk.W, - padx=3, - pady=3, + padx=compact_pad, + pady=compact_pad, ) row += 1 + uniform_grid(self.single_channel_frame) + self.multichannel_frame.grid_columnconfigure(0, weight=0) + self.multichannel_frame.grid_columnconfigure(1, weight=1) uniform_grid(self) + # Default to the compact LUT editor from startup, before acquisition begins. + self.set_multichannel_controls_visible(True) + + @property + def multichannel_color_labels(self): + """Standard ImageJ-like colors for multichannel overlays.""" + return ( + "Green", + "Red", + "Magenta", + "Cyan", + "Yellow", + "Blue", + "Orange", + "Gray", + ) + + def set_multichannel_controls_visible(self, visible: bool) -> None: + """Toggle between single-channel and multichannel control groups.""" + if visible: + self.single_channel_frame.grid_remove() + self.multichannel_frame.grid() + else: + self.multichannel_frame.grid_remove() + self.single_channel_frame.grid() + + def configure_multichannel_controls( + self, + channels: Iterable[str], + default_luts: Iterable[str], + on_change: Optional[Callable[[str, str], None]] = None, + ) -> None: + """Configure compact per-channel controls for multichannel display.""" + channels = list(channels) + default_luts = list(default_luts) + self._multichannel_channels = channels + self._multichannel_on_change = on_change + if len(channels) == 0: + self._multichannel_channel_widget["values"] = () + self._active_multichannel_channel.set("") + self._multichannel_channel_widget.configure(state="disabled") + return + + self._multichannel_syncing = True + try: + for index, channel in enumerate(channels): + defaults = { + "lut_name": ( + default_luts[index] + if index < len(default_luts) + else self.multichannel_color_labels[ + index % len(self.multichannel_color_labels) + ] + ), + "autoscale": True, + "min_counts": 0.0, + "max_counts": float(2**16 - 1), + "visible": True, + "alpha": 1.0, + "gamma": 1.0, + } + cached = self._multichannel_channel_states.get(channel, {}) + merged = defaults.copy() + merged.update(cached) + self._multichannel_channel_states[channel] = merged + finally: + self._multichannel_syncing = False + + self.set_multichannel_channel_selector_mode( + overlay_mode=self._multichannel_overlay_mode, + channels=channels, + ) + + def set_multichannel_channel_selector_mode( + self, + overlay_mode: bool, + channels: Optional[Iterable[str]] = None, + ) -> None: + """Configure channel selector behavior for single vs overlay display mode. + + Parameters + ---------- + overlay_mode : bool + When True, the selector is enabled and channel names are listed. When + False, the selector is disabled and set to ``"All"``. + channels : Optional[Iterable[str]] + Optional explicit channel list. If None, uses the currently cached list. + """ + if channels is not None: + self._multichannel_channels = list(channels) + else: + self._multichannel_channels = list(self._multichannel_channels) + self._multichannel_overlay_mode = bool(overlay_mode) + + if len(self._multichannel_channels) == 0: + self._multichannel_channel_widget["values"] = () + self._active_multichannel_channel.set("") + self._multichannel_channel_widget.configure(state="disabled") + return + + self._multichannel_syncing = True + try: + if self._multichannel_overlay_mode and len(self._multichannel_channels) > 1: + self._multichannel_channel_widget["values"] = ( + self._multichannel_channels + ) + self._multichannel_channel_widget.configure(state="readonly") + active_channel = self._active_multichannel_channel.get() + if active_channel not in self._multichannel_channels: + self._active_multichannel_channel.set( + self._multichannel_channels[0] + ) + else: + self._multichannel_channel_widget["values"] = ( + self._all_channels_label, + ) + self._multichannel_channel_widget.configure(state="disabled") + self._active_multichannel_channel.set(self._all_channels_label) + finally: + self._multichannel_syncing = False + + self._load_active_multichannel_values() + self._set_multichannel_minmax_state() + + def _on_multichannel_channel_selected(self, *_args) -> None: + self._load_active_multichannel_values() + self._notify_multichannel_change("channel") + + def _notify_multichannel_change(self, field: str) -> None: + channel = self._active_multichannel_channel.get() + if self._multichannel_on_change is None or not channel: + return + if channel == self._all_channels_label: + for selected_channel in self._multichannel_channels: + self._multichannel_on_change(selected_channel, field) + return + self._multichannel_on_change(channel, field) + + def _on_multichannel_value_changed(self, field: str) -> None: + if self._multichannel_syncing: + return + self._store_active_multichannel_values() + if field == "autoscale": + self._set_multichannel_minmax_state() + self._notify_multichannel_change(field) + + def _safe_get_float(self, tk_var: Any, fallback: float) -> float: + """Read a Tk variable as float while tolerating transient invalid edits.""" + try: + return float(tk_var.get()) + except (tk.TclError, TypeError, ValueError): + return float(fallback) + + def _safe_get_bool(self, tk_var: Any, fallback: bool) -> bool: + """Read a Tk variable as bool while tolerating transient invalid edits.""" + try: + value = tk_var.get() + except (tk.TclError, TypeError, ValueError): + return bool(fallback) + + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in ("", "none"): + return bool(fallback) + if normalized in ("0", "false", "off", "no"): + return False + if normalized in ("1", "true", "on", "yes"): + return True + return bool(value) + + def _safe_get_string(self, tk_var: Any, fallback: str) -> str: + """Read a Tk variable as string while tolerating transient invalid edits.""" + try: + return str(tk_var.get()) + except (tk.TclError, TypeError, ValueError): + return str(fallback) + + def _store_active_multichannel_values(self) -> None: + channel = self._safe_get_string(self._active_multichannel_channel, "") + if not channel: + return + targets = ( + list(self._multichannel_channels) + if channel == self._all_channels_label + else [channel] + ) + for target_channel in targets: + state = self._multichannel_channel_states.setdefault(target_channel, {}) + lut_name = self._safe_get_string( + self._active_multichannel_lut, + str(state.get("lut_name", "Green")), + ).strip() + state["lut_name"] = ( + lut_name if lut_name else str(state.get("lut_name", "Green")) + ) + state["autoscale"] = self._safe_get_bool( + self._active_multichannel_autoscale, + bool(state.get("autoscale", True)), + ) + state["min_counts"] = self._safe_get_float( + self._active_multichannel_min, + float(state.get("min_counts", 0.0)), + ) + state["max_counts"] = self._safe_get_float( + self._active_multichannel_max, + float(state.get("max_counts", float(2**16 - 1))), + ) + state["visible"] = self._safe_get_bool( + self._active_multichannel_visible, + bool(state.get("visible", True)), + ) + state["alpha"] = max( + 0.0, + min( + 1.0, + self._safe_get_float( + self._active_multichannel_alpha, + float(state.get("alpha", 1.0)) * 100.0, + ) + / 100.0, + ), + ) + state["gamma"] = max( + 0.0, + min( + 2.0, + self._safe_get_float( + self._active_multichannel_gamma, + float(state.get("gamma", 1.0)), + ), + ), + ) + + def _load_active_multichannel_values(self) -> None: + channel = self._active_multichannel_channel.get() + source_channel = channel + if channel == self._all_channels_label and self._multichannel_channels: + source_channel = self._multichannel_channels[0] + state = self._multichannel_channel_states.get(source_channel, {}) + if not state: + return + self._multichannel_syncing = True + try: + self._active_multichannel_lut.set(state.get("lut_name", "Green")) + self._active_multichannel_autoscale.set(bool(state.get("autoscale", True))) + self._active_multichannel_min.set(int(state.get("min_counts", 0.0))) + self._active_multichannel_max.set( + int(state.get("max_counts", float(2**16 - 1))) + ) + self._active_multichannel_visible.set(bool(state.get("visible", True))) + self._active_multichannel_alpha.set(float(state.get("alpha", 1.0)) * 100.0) + self._active_multichannel_gamma.set( + max(0.0, min(2.0, float(state.get("gamma", 1.0)))) + ) + finally: + self._multichannel_syncing = False + self._set_multichannel_minmax_state() + + def _set_multichannel_minmax_state(self) -> None: + autoscale_enabled = self._safe_get_bool( + self._active_multichannel_autoscale, + True, + ) + state = "disabled" if autoscale_enabled else "normal" + self._multichannel_min_widget["state"] = state + self._multichannel_max_widget["state"] = state + + def get_multichannel_widgets(self) -> Dict[str, Dict[str, Any]]: + """Return channel-mapped compact control state.""" + return self._multichannel_channel_states + + def get_multichannel_channel_state(self, channel: str) -> Dict[str, Any]: + """Return current settings for one channel.""" + self._store_active_multichannel_values() + state = self._multichannel_channel_states.get(channel) + if state is None: + return {} + return state.copy() + + def set_multichannel_channel_state( + self, channel: str, state: Dict[str, Any] + ) -> None: + """Populate controls for one channel from cached state.""" + merged = self._multichannel_channel_states.get(channel, {}).copy() + merged.update(state) + self._multichannel_channel_states[channel] = merged + if channel == self._active_multichannel_channel.get(): + self._load_active_multichannel_values() + + def get_multichannel_active_channel(self) -> str: + """Return the currently selected channel in compact multichannel controls.""" + self._store_active_multichannel_values() + channel = self._active_multichannel_channel.get() + if channel == self._all_channels_label and self._multichannel_channels: + return self._multichannel_channels[0] + return channel diff --git a/test/controller/sub_controllers/test_camera_view.py b/test/controller/sub_controllers/test_camera_view.py index 531393414..8ab7db6d7 100644 --- a/test/controller/sub_controllers/test_camera_view.py +++ b/test/controller/sub_controllers/test_camera_view.py @@ -96,7 +96,6 @@ def setup_class(self, dummy_controller): } def test_init(self): - assert isinstance(self.camera_view, CameraViewController) def test_update_display_state(self): @@ -122,7 +121,6 @@ def mock_winfo_pointery(): assert y == self.y def test_popup_menu(self, monkeypatch): - # create a fake event object self.startx = int(random.random()) self.starty = int(random.random()) @@ -166,7 +164,6 @@ def mock_grab_release(): @pytest.mark.parametrize("name", ["minmax", "image"]) @pytest.mark.parametrize("data", [[random.randint(0, 49), random.randint(50, 100)]]) def test_initialize(self, name, data): - self.camera_view.initialize(name, data) # Checking values @@ -177,7 +174,6 @@ def test_initialize(self, name, data): assert self.camera_view.image_metrics["Frames"].get() == data[0] def test_set_mode(self): - # Test default mode self.camera_view.set_mode() assert self.camera_view.mode == "" @@ -200,7 +196,6 @@ def test_set_mode(self): @pytest.mark.parametrize("mode", ["stop", "live"]) def test_move_stage(self, mode): - # Setup to check formula inside func is correct microscope_name = self.camera_view.parent_controller.configuration[ "experiment" @@ -256,15 +251,18 @@ def test_move_stage(self, mode): assert new_pos == self.camera_view.parent_controller.stage_pos def test_reset_display(self, monkeypatch): + # Mock redraw function + redraw_called = False - # Mock process image function - process_image_called = False + def mock_redraw_current_view(): + nonlocal redraw_called + redraw_called = True - def mock_process_image(): - nonlocal process_image_called - process_image_called = True - - monkeypatch.setattr(self.camera_view, "process_image", mock_process_image) + monkeypatch.setattr( + self.camera_view, + "_redraw_current_view", + mock_redraw_current_view, + ) # Reset and check self.camera_view.reset_display() @@ -282,7 +280,7 @@ def mock_process_image(): assert self.camera_view.zoom_scale == 1 assert self.camera_view.zoom_width == self.camera_view.view.canvas_width assert self.camera_view.zoom_height == self.camera_view.view.canvas_height - assert process_image_called is True + assert redraw_called is True def test_process_image(self): self.camera_view.image = np.random.randint(0, 256, (600, 800)) @@ -306,42 +304,42 @@ def test_process_image(self): @pytest.mark.parametrize("num,value", [(4, 0.95), (5, 1.05)]) def test_mouse_wheel(self, num, value): - # Test zoom in and out event = MagicMock() event.num = num - event.x = int(random.random()) - event.y = int(random.random()) + event.x = 10 + event.y = 10 event.delta = 120 + self.camera_view.canvas_width = 100 + self.camera_view.canvas_height = 80 + self.camera_view.view.canvas_width = 100 + self.camera_view.view.canvas_height = 80 self.camera_view.zoom_value = 1 self.camera_view.zoom_scale = 1 - self.camera_view.zoom_width = self.camera_view.view.canvas_width - self.camera_view.zoom_height = self.camera_view.view.canvas_height + self.camera_view.zoom_width = self.camera_view.canvas_width + self.camera_view.zoom_height = self.camera_view.canvas_height self.camera_view.reset_display = MagicMock() - self.camera_view.process_image = MagicMock() + self.camera_view._redraw_current_view = MagicMock() self.camera_view.mouse_wheel(event) assert self.camera_view.zoom_value == value assert self.camera_view.zoom_scale == value - assert self.camera_view.zoom_width == self.camera_view.view.canvas_width / value - assert ( - self.camera_view.zoom_height == self.camera_view.view.canvas_height / value - ) + assert self.camera_view.zoom_width == self.camera_view.canvas_width / value + assert self.camera_view.zoom_height == self.camera_view.canvas_height / value if ( - self.camera_view.zoom_width > self.camera_view.view.canvas_width - or self.camera_view.zoom_height > self.camera_view.view.canvas_height + self.camera_view.zoom_width > self.camera_view.canvas_width + or self.camera_view.zoom_height > self.camera_view.canvas_height ): assert self.camera_view.reset_display.called is True - self.camera_view.process_image.assert_called() else: assert self.camera_view.reset_display.called is False - not self.camera_view.process_image.assert_called() + + self.camera_view._redraw_current_view.assert_called_once_with() @pytest.mark.skip("AssertionError: Expected 'mock' to have been called.") @pytest.mark.parametrize("transpose", [True, False]) def test_digital_zoom(self, transpose): - # Setup a, b, c, d, e, f = [random.randint(1, 100) for _ in range(6)] g, h = [random.randint(100, 400) for _ in range(2)] @@ -381,20 +379,16 @@ def test_digital_zoom(self, transpose): # Calculate expected image based on transpose flag if transpose: new_image = self.camera_view.image[ - x_start_index - * self.camera_view.canvas_width_scale : x_end_index + x_start_index * self.camera_view.canvas_width_scale : x_end_index * self.camera_view.canvas_width_scale, - y_start_index - * self.camera_view.canvas_height_scale : y_end_index + y_start_index * self.camera_view.canvas_height_scale : y_end_index * self.camera_view.canvas_height_scale, ] else: new_image = self.camera_view.image[ - y_start_index - * self.camera_view.canvas_height_scale : y_end_index + y_start_index * self.camera_view.canvas_height_scale : y_end_index * self.camera_view.canvas_height_scale, - x_start_index - * self.camera_view.canvas_width_scale : x_end_index + x_start_index * self.camera_view.canvas_width_scale : x_end_index * self.camera_view.canvas_width_scale, ] @@ -481,18 +475,9 @@ def test_down_sample_image(self, monkeypatch): test_image = np.random.rand(100, 100) self.zoom_image = test_image - # set the widget size - widget = type("MyWidget", (object,), {"widget": self.camera_view.view}) - event = type( - "MyEvent", - (object,), - { - "widget": widget, - "width": np.random.randint(5, 1000), - "height": np.random.randint(5, 1000), - }, - ) - self.camera_view.resize(event) + # set a deterministic target canvas size for down-sampling + self.camera_view.canvas_width = 100 + self.camera_view.canvas_height = 80 # monkeypatch cv2.resize def mocked_resize(src, dsize, interpolation=1): @@ -505,8 +490,8 @@ def mocked_resize(src, dsize, interpolation=1): # assert that the image has been resized correctly assert np.shape(down_sampled_image) == ( - self.camera_view.view.canvas_width, - self.camera_view.view.canvas_height, + self.camera_view.canvas_width, + self.camera_view.canvas_height, ) # assert that the image has not been modified @@ -702,7 +687,6 @@ def test_display_image(self, transpose): assert self.camera_view.image_count == count + 4 def test_add_crosshair(self): - # Arrange x = self.camera_view.canvas_width y = self.camera_view.canvas_height @@ -726,7 +710,6 @@ def test_update_LUT(self): pass def test_toggle_min_max_button(self): - # Setup for true path self.camera_view.image_palette["Autoscale"].set(True) diff --git a/test/controller/sub_controllers/test_mip_view_projection.py b/test/controller/sub_controllers/test_mip_view_projection.py new file mode 100644 index 000000000..e34374aad --- /dev/null +++ b/test/controller/sub_controllers/test_mip_view_projection.py @@ -0,0 +1,592 @@ +# Copyright (c) 2021-2026 The University of Texas Southwestern Medical Center. +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without +# modification, are permitted for academic and research use only +# (subject to the limitations in the disclaimer below) +# provided that the following conditions are met: + +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. + +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. + +# * Neither the name of the copyright holders nor the names of its +# contributors may be used to endorse or promote products derived from this +# software without specific prior written permission. + +# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY +# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND +# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR +# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER +# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import numpy as np + +from navigate.controller.sub_controllers.camera_view import ( + BaseViewController, + CameraViewController, + MIPViewController, +) + + +class _Getter: + def __init__(self, value): + self.value = value + + def get(self): + return self.value + + +def test_try_to_display_image_updates_orthogonal_projections(monkeypatch): + controller = MIPViewController.__new__(MIPViewController) + controller.image_mode = "z-stack" + controller.display_enabled = SimpleNamespace(get=lambda: True) + controller._clear_mip = MagicMock() + controller.identify_channel_index_and_slice = lambda: (0, 1) + controller.xy_mip = np.zeros((1, 3, 4), dtype=np.uint16) + controller.zy_mip = np.zeros((1, 2, 4), dtype=np.uint16) + controller.zx_mip = np.zeros((1, 2, 3), dtype=np.uint16) + + parent_calls = [] + + def _parent_try_to_display_image(self, image): + parent_calls.append(image.copy()) + + monkeypatch.setattr( + BaseViewController, + "try_to_display_image", + _parent_try_to_display_image, + ) + + image_1 = np.array( + [ + [1, 10, 3, 9], + [8, 2, 7, 4], + [6, 5, 12, 11], + ], + dtype=np.uint16, + ) + image_2 = np.array( + [ + [2, 9, 13, 1], + [3, 15, 6, 8], + [10, 4, 5, 14], + ], + dtype=np.uint16, + ) + + controller.try_to_display_image(image_1) + controller.try_to_display_image(image_2) + + np.testing.assert_array_equal( + controller.xy_mip[0], + np.maximum(image_1, image_2), + ) + np.testing.assert_array_equal( + controller.zy_mip[0, 1], + np.maximum(np.max(image_1, axis=0), np.max(image_2, axis=0)), + ) + np.testing.assert_array_equal( + controller.zx_mip[0, 1], + np.maximum(np.max(image_1, axis=1), np.max(image_2, axis=1)), + ) + assert len(parent_calls) == 2 + controller._clear_mip.assert_not_called() + + +def test_try_to_display_image_clears_and_returns_when_disabled(monkeypatch): + controller = MIPViewController.__new__(MIPViewController) + controller.image_mode = "z-stack" + controller.display_enabled = SimpleNamespace(get=lambda: False) + controller._is_display_visible = lambda: True + controller._clear_mip = MagicMock() + controller.identify_channel_index_and_slice = lambda: (0, 0) + controller.xy_mip = np.full((1, 2, 2), 99, dtype=np.uint16) + controller.zy_mip = np.full((1, 2, 2), 99, dtype=np.uint16) + controller.zx_mip = np.full((1, 2, 2), 99, dtype=np.uint16) + + monkeypatch.setattr( + BaseViewController, + "try_to_display_image", + lambda self, image: (_ for _ in ()).throw( + AssertionError("BaseViewController.try_to_display_image should not run") + ), + ) + + image = np.array([[1, 2], [3, 4]], dtype=np.uint16) + controller.try_to_display_image(image) + + np.testing.assert_array_equal(controller.xy_mip, np.full((1, 2, 2), 99)) + np.testing.assert_array_equal(controller.zy_mip, np.full((1, 2, 2), 99)) + np.testing.assert_array_equal(controller.zx_mip, np.full((1, 2, 2), 99)) + controller._clear_mip.assert_called_once_with() + + +def test_try_to_display_image_casts_frame_dtype_for_opencv_max(monkeypatch): + controller = MIPViewController.__new__(MIPViewController) + controller.image_mode = "z-stack" + controller.display_enabled = SimpleNamespace(get=lambda: True) + controller._clear_mip = MagicMock() + controller.identify_channel_index_and_slice = lambda: (0, 0) + controller.number_of_channels = 1 + controller.number_of_slices = 2 + controller.original_image_height = 2 + controller.original_image_width = 3 + controller.xy_mip = np.zeros((1, 2, 3), dtype=np.uint16) + controller.zy_mip = np.zeros((1, 2, 3), dtype=np.uint16) + controller.zx_mip = np.zeros((1, 2, 2), dtype=np.uint16) + controller._zy_reduce_buf = np.empty((1, 3), dtype=np.uint16) + controller._zx_reduce_buf = np.empty((2, 1), dtype=np.uint16) + + monkeypatch.setattr(BaseViewController, "try_to_display_image", lambda *_: None) + + image = np.array([[1.2, 2.8, 3.1], [4.9, 5.0, 6.7]], dtype=np.float32) + controller.try_to_display_image(image) + + np.testing.assert_array_equal(controller.xy_mip[0], image.astype(np.uint16)) + assert controller.xy_mip.dtype == np.uint16 + + +def test_try_to_display_image_reallocates_mip_buffers_when_shape_changes(monkeypatch): + controller = MIPViewController.__new__(MIPViewController) + controller.image_mode = "z-stack" + controller.display_enabled = SimpleNamespace(get=lambda: True) + controller._clear_mip = MagicMock() + controller.identify_channel_index_and_slice = lambda: (0, 1) + controller.number_of_channels = 1 + controller.number_of_slices = 2 + controller.original_image_height = 2 + controller.original_image_width = 2 + controller.xy_mip = np.zeros((1, 2, 2), dtype=np.uint16) + controller.zy_mip = np.zeros((1, 2, 2), dtype=np.uint16) + controller.zx_mip = np.zeros((1, 2, 2), dtype=np.uint16) + controller._zy_reduce_buf = np.empty((1, 2), dtype=np.uint16) + controller._zx_reduce_buf = np.empty((2, 1), dtype=np.uint16) + + monkeypatch.setattr(BaseViewController, "try_to_display_image", lambda *_: None) + + image = np.arange(200, 212, dtype=np.uint16).reshape(3, 4) + controller.try_to_display_image(image) + + assert controller.xy_mip.shape == (1, 3, 4) + assert controller.zy_mip.shape == (1, 2, 4) + assert controller.zx_mip.shape == (1, 2, 3) + np.testing.assert_array_equal(controller.xy_mip[0], image) + + +def test_get_mip_image_uses_correct_projection_and_anisotropic_scaling_zy(): + controller = MIPViewController.__new__(MIPViewController) + controller.axial_to_lateral_ratio = 2.0 + controller.selected_channels = ["CH1"] + controller.render_widgets = { + "perspective": _Getter("ZY"), + "channel": _Getter("CH1"), + } + controller.flip_image = lambda image: image + controller.down_sample_image = lambda image, *_: image + controller.xy_mip = np.zeros((1, 2, 3), dtype=np.uint16) + controller.zy_mip = np.zeros((1, 4, 3), dtype=np.uint16) + controller.zx_mip = np.array( + [ + [ + [1, 2], + [3, 4], + [5, 6], + [7, 8], + ] + ], + dtype=np.uint16, + ) + + image = controller.get_mip_image() + + # ZY should source from zx_mip (Z-by-Y), transpose to Y-by-Z, then scale Z width. + expected = np.array([[1, 3, 5, 7], [2, 4, 6, 8]], dtype=np.uint16) + expected = np.repeat(expected, 2, axis=1) + np.testing.assert_array_equal(image, expected) + + +def test_get_mip_image_uses_correct_projection_and_anisotropic_scaling_zx(): + controller = MIPViewController.__new__(MIPViewController) + controller.axial_to_lateral_ratio = 1.5 + controller.selected_channels = ["CH1"] + controller.render_widgets = { + "perspective": _Getter("ZX"), + "channel": _Getter("CH1"), + } + controller.flip_image = lambda image: image + controller.down_sample_image = lambda image, *_: image + controller.xy_mip = np.zeros((1, 2, 3), dtype=np.uint16) + controller.zy_mip = np.array( + [ + [ + [10, 11, 12], + [20, 21, 22], + [30, 31, 32], + [40, 41, 42], + ] + ], + dtype=np.uint16, + ) + controller.zx_mip = np.zeros((1, 4, 2), dtype=np.uint16) + + image = controller.get_mip_image() + + # ZX should source from zy_mip (Z-by-X) and scale Z along image height. + expected = np.array( + [ + [10, 11, 12], + [20, 21, 22], + [30, 31, 32], + [40, 41, 42], + ], + dtype=np.uint16, + ) + # 4 * 1.5 -> 6 rows + expected = np.repeat(expected, [2, 1, 2, 1], axis=0) + np.testing.assert_array_equal(image, expected) + + +def test_get_mip_image_multi_perspective_composition(): + controller = MIPViewController.__new__(MIPViewController) + controller.axial_to_lateral_ratio = 1.0 + controller.multi_view_gap = 1 + controller.selected_channels = ["CH1"] + controller.render_widgets = { + "perspective": _Getter("Multi"), + "channel": _Getter("CH1"), + } + controller.flip_image = lambda image: image + controller.down_sample_image = lambda image, *_: image + + controller.xy_mip = np.array([[[100, 101], [102, 103]]], dtype=np.uint16) + # ZY source (zx_mip -> transpose => 2x2) + controller.zx_mip = np.array([[[10, 20], [30, 40]]], dtype=np.uint16) + # ZX source (zy_mip => 2x2) + controller.zy_mip = np.array([[[50, 60], [70, 80]]], dtype=np.uint16) + + image = controller.get_mip_image() + + # gap=1 with no outer padding -> output shape 5x5. + assert image.shape == (5, 5) + # XY upper-left + np.testing.assert_array_equal(image[0:2, 0:2], np.array([[100, 101], [102, 103]])) + # YZ right + np.testing.assert_array_equal(image[0:2, 3:5], np.array([[10, 30], [20, 40]])) + # ZX bottom + np.testing.assert_array_equal(image[3:5, 0:2], np.array([[50, 60], [70, 80]])) + + +def test_overlay_channel_defaults_follow_imagej_order(): + controller = MIPViewController.__new__(MIPViewController) + controller.selected_channels = ["CH1", "CH2", "CH3", "CH4"] + controller.overlay_channel_settings = {} + controller.min_counts = 0 + controller.max_counts = 65535 + + controller._ensure_overlay_channel_settings() + + assert controller.overlay_channel_settings["CH1"]["lut_name"] == "Green" + assert controller.overlay_channel_settings["CH2"]["lut_name"] == "Red" + assert controller.overlay_channel_settings["CH3"]["lut_name"] == "Magenta" + assert controller.overlay_channel_settings["CH4"]["lut_name"] == "Cyan" + + +def test_compose_overlay_from_channels_adds_colorized_channels(): + controller = MIPViewController.__new__(MIPViewController) + controller.selected_channels = ["CH1", "CH2"] + controller.overlay_channel_settings = { + "CH1": { + "lut_name": "Red", + "autoscale": False, + "min_counts": 0.0, + "max_counts": 255.0, + }, + "CH2": { + "lut_name": "Green", + "autoscale": False, + "min_counts": 0.0, + "max_counts": 255.0, + }, + } + controller._overlay_colormap_cache = {} + controller._overlay_bgr_buf = None + controller.min_counts = 0.0 + controller.max_counts = 255.0 + controller._prepare_zoom_window = lambda: (slice(None), slice(None)) + controller._crop_image_with_zoom = lambda image, y_slice, x_slice: image[ + y_slice, x_slice + ] + controller.down_sample_image = lambda image: image + controller.add_crosshair = lambda image: image + + image = controller._compose_overlay_from_channels( + { + "CH1": np.full((3, 3), 100, dtype=np.uint8), + "CH2": np.full((3, 3), 50, dtype=np.uint8), + } + ) + + assert image.shape == (3, 3, 3) + np.testing.assert_array_equal(image[0, 0], np.array([100, 50, 0], dtype=np.uint8)) + assert controller._last_frame_display_max == 100.0 + + +def test_should_use_overlay_mode_requires_multiple_channels(): + controller = MIPViewController.__new__(MIPViewController) + controller.display_mode_widgets = {"mode": _Getter("Overlay")} + controller.selected_channels = ["CH1"] + assert not controller._should_use_overlay_mode() + + controller.selected_channels = ["CH1", "CH2"] + assert controller._should_use_overlay_mode() + + +def test_get_mip_image_uses_compact_active_channel_for_multichannel_single_mode(): + controller = MIPViewController.__new__(MIPViewController) + controller.selected_channels = ["CH1", "CH2"] + controller.render_widgets = { + "perspective": _Getter("XY"), + "channel": _Getter("CH1"), + } + controller._get_multichannel_active_channel = lambda: "CH2" + controller.flip_image = lambda image: image + controller.down_sample_image = lambda image, *_: image + controller.xy_mip = np.array( + [ + [[1, 2], [3, 4]], + [[10, 20], [30, 40]], + ], + dtype=np.uint16, + ) + controller.zy_mip = np.zeros((2, 2, 2), dtype=np.uint16) + controller.zx_mip = np.zeros((2, 2, 2), dtype=np.uint16) + + image = controller.get_mip_image() + + np.testing.assert_array_equal( + image, + np.array([[10, 20], [30, 40]], dtype=np.uint16), + ) + + +def test_collect_mip_overlay_channels_returns_perspective_signatures(): + controller = MIPViewController.__new__(MIPViewController) + controller.selected_channels = ["CH1", "CH2"] + controller.render_widgets = {"perspective": _Getter("ZX")} + controller._mip_channel_revision = {"CH1": 3, "CH2": 7} + controller._get_mip_projection_for_channel = lambda idx, mode: np.full( + (2, 2), idx + 1, dtype=np.uint16 + ) + + channel_images, channel_signatures = controller._collect_mip_overlay_channels() + + np.testing.assert_array_equal( + channel_images["CH1"], np.full((2, 2), 1, dtype=np.uint16) + ) + np.testing.assert_array_equal( + channel_images["CH2"], np.full((2, 2), 2, dtype=np.uint16) + ) + assert channel_signatures["CH1"] == ("mip", 0, "ZX", 3) + assert channel_signatures["CH2"] == ("mip", 1, "ZX", 7) + + +def test_render_single_multichannel_frame_applies_channel_alpha(): + controller = MIPViewController.__new__(MIPViewController) + controller.selected_channels = ["CH1"] + controller.overlay_channel_settings = { + "CH1": { + "lut_name": "Red", + "autoscale": False, + "min_counts": 0.0, + "max_counts": 255.0, + "visible": True, + "alpha": 0.5, + } + } + controller._overlay_colormap_cache = {} + controller._colorized_channel_cache = {} + controller.min_counts = 0.0 + controller.max_counts = 255.0 + controller.canvas_width = 3 + controller.canvas_height = 3 + controller._prepare_zoom_window = lambda: (slice(None), slice(None)) + controller._crop_image_with_zoom = lambda image, y_slice, x_slice: image[ + y_slice, x_slice + ] + controller.down_sample_image = lambda image: image + controller.add_crosshair = lambda image: image + + out = controller._render_single_multichannel_frame( + "CH1", + np.full((3, 3), 100, dtype=np.uint8), + channel_signature=("mip", 0, "XY", 1), + ) + + assert out.shape == (3, 3, 3) + np.testing.assert_array_equal(out[0, 0], np.array([50, 0, 0], dtype=np.uint8)) + assert controller._last_frame_display_max == 100.0 + + +def test_render_single_multichannel_frame_applies_gamma_mapping(): + controller = MIPViewController.__new__(MIPViewController) + controller.selected_channels = ["CH1"] + controller.overlay_channel_settings = { + "CH1": { + "lut_name": "Red", + "autoscale": False, + "min_counts": 0.0, + "max_counts": 255.0, + "visible": True, + "alpha": 1.0, + "gamma": 2.0, + } + } + controller._overlay_colormap_cache = {} + controller._gamma_lut_cache = {} + controller._colorized_channel_cache = {} + controller.min_counts = 0.0 + controller.max_counts = 255.0 + controller.canvas_width = 2 + controller.canvas_height = 2 + controller._prepare_zoom_window = lambda: (slice(None), slice(None)) + controller._crop_image_with_zoom = lambda image, y_slice, x_slice: image[ + y_slice, x_slice + ] + controller.down_sample_image = lambda image: image + controller.add_crosshair = lambda image: image + + out = controller._render_single_multichannel_frame( + "CH1", + np.full((2, 2), 128, dtype=np.uint8), + channel_signature=("mip", 0, "XY", 2), + ) + + # gamma=2 maps 128 -> round((128/255)^2*255) = 64, Red LUT => RGB [64, 0, 0] + np.testing.assert_array_equal(out[0, 0], np.array([64, 0, 0], dtype=np.uint8)) + + +def test_collect_camera_overlay_channels_requires_all_selected_channels(): + controller = CameraViewController.__new__(CameraViewController) + controller.selected_channels = ["CH1", "CH2"] + controller.display_state = "Live" + controller._latest_channel_idx = 0 + controller._latest_slice_idx = 0 + controller._channel_slice_revision = {(0, 0): 1} + controller._get_overlay_target_slice = lambda: 0 + controller.flip_image = lambda image: image + + def _load_image(channel, slice_index): + assert slice_index == 0 + return None if channel == 1 else np.full((2, 2), 99, dtype=np.uint16) + + controller.spooled_images = SimpleNamespace(load_image=_load_image) + channel_images, channel_signatures, all_available = ( + controller._collect_camera_overlay_channels(np.full((2, 2), 7, dtype=np.uint16)) + ) + + assert not all_available + np.testing.assert_array_equal( + channel_images["CH1"], np.full((2, 2), 7, dtype=np.uint16) + ) + assert "CH2" not in channel_images + assert channel_signatures["CH1"] == ("camera", 0, 0, 1) + + +def test_camera_display_image_overlay_skips_partial_channel_frames(): + controller = CameraViewController.__new__(CameraViewController) + controller._should_use_overlay_mode = lambda: True + controller._sync_overlay_cache_from_controls = lambda *_args, **_kwargs: None + controller._collect_camera_overlay_channels = lambda image: ( + {"CH1": image}, + {"CH1": ("camera", 0, 0, 1)}, + False, + ) + controller._compose_overlay_from_channels = lambda *_args, **_kwargs: ( + _ for _ in () + ).throw(AssertionError("should not compose overlay from incomplete channels")) + controller.overlay_mask = lambda image: image + controller.populate_image = MagicMock() + controller.update_max_counts = MagicMock() + controller.view = SimpleNamespace(after=lambda *_args, **_kwargs: None) + + controller.display_image(np.full((2, 2), 7, dtype=np.uint16)) + + controller.populate_image.assert_not_called() + controller.update_max_counts.assert_not_called() + + +def test_move_crosshair_uses_active_display_pipeline_for_selected_channels(): + controller = CameraViewController.__new__(CameraViewController) + controller.selected_channels = ["CH1", "CH2"] + controller.zoom_rect = np.array([[0.0, 100.0], [0.0, 50.0]]) + controller.zoom_scale = 1.0 + controller.move_to_x = 25.0 + controller.move_to_y = 10.0 + controller._refresh_after_display_mode_change = MagicMock() + controller.process_image = MagicMock() + + controller.move_crosshair() + + assert controller.offset_crosshair is True + assert controller.crosshair_x == 0.25 + assert controller.crosshair_y == 0.2 + controller._refresh_after_display_mode_change.assert_called_once_with() + controller.process_image.assert_not_called() + + +def test_reset_display_uses_active_pipeline_for_selected_channels(): + controller = CameraViewController.__new__(CameraViewController) + controller.selected_channels = ["CH1"] + controller.canvas_width = 256 + controller.canvas_height = 128 + controller.zoom_width = 20 + controller.zoom_height = 10 + controller.zoom_rect = np.array([[3.0, 11.0], [2.0, 7.0]]) + controller.zoom_offset = np.array([[2.0], [1.0]]) + controller.zoom_value = 1.7 + controller.zoom_scale = 2.5 + controller._refresh_after_display_mode_change = MagicMock() + controller.process_image = MagicMock() + controller.offset_crosshair = True + controller.crosshair_x = 0.1 + controller.crosshair_y = 0.2 + + controller.reset_display(display_flag=True, reset_crosshair=False) + + assert controller.zoom_width == 256 + assert controller.zoom_height == 128 + np.testing.assert_array_equal(controller.zoom_rect, np.array([[0, 256], [0, 128]])) + np.testing.assert_array_equal(controller.zoom_offset, np.array([[0], [0]])) + assert controller.zoom_value == 1 + assert controller.zoom_scale == 1 + controller._refresh_after_display_mode_change.assert_called_once_with() + controller.process_image.assert_not_called() + + +def test_reset_display_without_selected_channels_uses_process_image(): + controller = CameraViewController.__new__(CameraViewController) + controller.selected_channels = None + controller.canvas_width = 64 + controller.canvas_height = 64 + controller.image = np.zeros((2, 2), dtype=np.uint16) + controller._refresh_after_display_mode_change = MagicMock() + controller.process_image = MagicMock() + + controller.reset_display(display_flag=True, reset_crosshair=True) + + controller.process_image.assert_called_once_with() + controller._refresh_after_display_mode_change.assert_not_called() diff --git a/test/controller/sub_controllers/test_view_visibility.py b/test/controller/sub_controllers/test_view_visibility.py new file mode 100644 index 000000000..e4c4760b5 --- /dev/null +++ b/test/controller/sub_controllers/test_view_visibility.py @@ -0,0 +1,105 @@ +# Copyright (c) 2021-2026 The University of Texas Southwestern Medical Center. +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without +# modification, are permitted for academic and research use only +# (subject to the limitations in the disclaimer below) +# provided that the following conditions are met: + +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. + +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. + +# * Neither the name of the copyright holders nor the names of its +# contributors may be used to endorse or promote products derived from this +# software without specific prior written permission. + +# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY +# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND +# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR +# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER +# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import numpy as np + +from navigate.controller.sub_controllers.camera_view import ( + BaseViewController, + MIPViewController, +) + + +def test_try_to_display_image_defers_when_view_hidden(): + controller = BaseViewController.__new__(BaseViewController) + controller._min_frame_interval = 0.0 + controller._last_enqueue_time = 0.0 + controller._pending_display_image = None + controller._display_after_id = None + controller._is_display_visible = lambda: False + controller.view = SimpleNamespace(after_idle=MagicMock(return_value="cb-1")) + + image = np.array([[1, 2], [3, 4]], dtype=np.uint16) + controller.try_to_display_image(image) + + assert controller._pending_display_image is image + controller.view.after_idle.assert_not_called() + + +def test_request_display_if_needed_queues_when_visible(): + controller = BaseViewController.__new__(BaseViewController) + controller._pending_display_image = np.array([[1]], dtype=np.uint16) + controller._display_after_id = None + controller._is_display_visible = lambda: True + controller.view = SimpleNamespace(after_idle=MagicMock(return_value="cb-2")) + + controller._request_display_if_needed() + + controller.view.after_idle.assert_called_once_with(controller._flush_pending_display) + assert controller._display_after_id == "cb-2" + + +def test_flush_pending_display_keeps_latest_when_hidden(): + controller = BaseViewController.__new__(BaseViewController) + image = np.array([[9]], dtype=np.uint16) + controller._pending_display_image = image + controller._display_after_id = "cb-3" + controller._is_display_visible = lambda: False + controller.display_image = MagicMock() + + controller._flush_pending_display() + + assert controller._display_after_id is None + assert controller._pending_display_image is image + controller.display_image.assert_not_called() + + +def test_is_display_visible_true_for_popped_out_view(): + controller = BaseViewController.__new__(BaseViewController) + controller.view = SimpleNamespace(is_docked=False, winfo_ismapped=lambda: True) + + assert controller._is_display_visible() is True + + +def test_mip_try_to_display_image_does_not_clear_when_hidden_and_disabled(): + controller = MIPViewController.__new__(MIPViewController) + controller.image_mode = "z-stack" + controller.display_enabled = SimpleNamespace(get=lambda: False) + controller._is_display_visible = lambda: False + controller._clear_mip = MagicMock() + controller.identify_channel_index_and_slice = lambda: (0, 0) + + controller.try_to_display_image(np.array([[1]], dtype=np.uint16)) + + controller._clear_mip.assert_not_called() diff --git a/test/view/custom_widgets/test_LabelInputWidgetFactory.py b/test/view/custom_widgets/test_LabelInputWidgetFactory.py index ea3cc9a1d..942866738 100644 --- a/test/view/custom_widgets/test_LabelInputWidgetFactory.py +++ b/test/view/custom_widgets/test_LabelInputWidgetFactory.py @@ -1,4 +1,5 @@ import tkinter as tk +from tkinter import ttk class NastyVar: @@ -18,3 +19,55 @@ def test_label_input_get(): assert label_input.get() == "" assert label_input.get(1) == 1 root.destroy() + + +def test_label_input_checkbutton_honors_left_label_position(tk_root): + from navigate.view.custom_widgets.LabelInputWidgetFactory import LabelInput + + label_input = LabelInput( + tk_root, + label_pos="left", + label="Flip XY", + input_class=ttk.Checkbutton, + input_var=tk.BooleanVar(master=tk_root), + ) + tk_root.update_idletasks() + + assert isinstance(label_input.label, ttk.Label) + assert label_input.label.cget("text") == "Flip XY" + assert isinstance(label_input.widget, ttk.Checkbutton) + assert label_input.widget.cget("text") == "" + assert int(label_input.label.grid_info()["column"]) == 0 + assert int(label_input.widget.grid_info()["column"]) == 1 + + +def test_label_input_button_keeps_inline_text_when_left_aligned(tk_root): + from navigate.view.custom_widgets.LabelInputWidgetFactory import LabelInput + + label_input = LabelInput( + tk_root, + label_pos="left", + label="Apply", + input_class=ttk.Button, + ) + tk_root.update_idletasks() + + assert label_input.label is None + assert label_input.widget.cget("text") == "Apply" + assert int(label_input.widget.grid_info()["column"]) == 0 + + +def test_widget_input_adapter_exposes_label_input_style_accessors(tk_root): + from navigate.view.custom_widgets.LabelInputWidgetFactory import WidgetInputAdapter + + label = ttk.Label(tk_root, text="Flip XY") + variable = tk.BooleanVar(master=tk_root, value=False) + widget = ttk.Checkbutton(tk_root, variable=variable) + adapter = WidgetInputAdapter(widget, variable=variable, label=label) + + adapter.set(True) + + assert adapter.master == tk_root + assert adapter.label is label + assert adapter.get() is True + assert adapter.get_variable() is variable diff --git a/test/view/main_window_content/test_display_notebook.py b/test/view/main_window_content/test_display_notebook.py index 548f4a8b7..c26f829c5 100644 --- a/test/view/main_window_content/test_display_notebook.py +++ b/test/view/main_window_content/test_display_notebook.py @@ -1,7 +1,53 @@ from types import SimpleNamespace from unittest.mock import MagicMock +import tkinter as tk -from navigate.view.main_window_content.display_notebook import HistogramFrame +from navigate.view.main_window_content.display_notebook import ( + HistogramFrame, + IntensityFrame, +) + + +class _MockTkVar: + """Minimal mock of Tk variable objects used in IntensityFrame tests.""" + + def __init__(self, value=None, *, raises: bool = False): + self.value = value + self.raises = raises + + def get(self): + if self.raises: + raise tk.TclError('expected floating-point number but got ""') + return self.value + + +def _build_intensity_frame_state() -> IntensityFrame: + """Construct an IntensityFrame instance for pure state-method testing.""" + frame = IntensityFrame.__new__(IntensityFrame) + frame._all_channels_label = "All" + frame._multichannel_channels = ["CH1", "CH2"] + frame._multichannel_channel_states = { + "CH1": { + "lut_name": "Green", + "autoscale": True, + "min_counts": 10.0, + "max_counts": 1000.0, + "visible": True, + "alpha": 0.7, + "gamma": 1.4, + } + } + frame._active_multichannel_channel = _MockTkVar("CH1") + frame._active_multichannel_lut = _MockTkVar("Magenta") + frame._active_multichannel_autoscale = _MockTkVar(False) + frame._active_multichannel_min = _MockTkVar(12) + frame._active_multichannel_max = _MockTkVar(1200) + frame._active_multichannel_visible = _MockTkVar(False) + frame._active_multichannel_alpha = _MockTkVar(50.0) + frame._active_multichannel_gamma = _MockTkVar(1.1) + frame._multichannel_min_widget = {} + frame._multichannel_max_widget = {} + return frame def test_resize_figure_to_frame_updates_size_and_draw(): @@ -35,3 +81,63 @@ def test_resize_figure_to_frame_ignores_tiny_or_duplicate_resize(): frame.figure.set_size_inches.assert_not_called() frame.figure_canvas.draw_idle.assert_not_called() assert frame._last_resize_pixels == (400, 120) + + +def test_store_active_multichannel_values_tolerates_empty_gamma() -> None: + frame = _build_intensity_frame_state() + frame._active_multichannel_gamma = _MockTkVar(raises=True) + + IntensityFrame._store_active_multichannel_values(frame) + + state = frame._multichannel_channel_states["CH1"] + assert state["gamma"] == 1.4 + assert state["alpha"] == 0.5 + assert state["min_counts"] == 12.0 + assert state["max_counts"] == 1200.0 + + +def test_store_active_multichannel_values_tolerates_empty_numeric_widgets() -> None: + frame = _build_intensity_frame_state() + frame._active_multichannel_min = _MockTkVar(raises=True) + frame._active_multichannel_max = _MockTkVar(raises=True) + frame._active_multichannel_alpha = _MockTkVar(raises=True) + frame._active_multichannel_gamma = _MockTkVar(raises=True) + + IntensityFrame._store_active_multichannel_values(frame) + + state = frame._multichannel_channel_states["CH1"] + assert state["min_counts"] == 10.0 + assert state["max_counts"] == 1000.0 + assert state["alpha"] == 0.7 + assert state["gamma"] == 1.4 + + +def test_set_multichannel_minmax_state_tolerates_invalid_autoscale_var() -> None: + frame = _build_intensity_frame_state() + frame._active_multichannel_autoscale = _MockTkVar(raises=True) + + IntensityFrame._set_multichannel_minmax_state(frame) + + assert frame._multichannel_min_widget["state"] == "disabled" + assert frame._multichannel_max_widget["state"] == "disabled" + + +def test_flip_xy_control_is_attached_to_compact_lut_frame(tk_root) -> None: + frame = IntensityFrame(tk_root) + tk_root.update_idletasks() + + assert frame.inputs["Flip XY"].master == frame.multichannel_frame + assert frame.inputs["Flip XY"].label.cget("text") == "Flip XY" + assert frame._multichannel_visible_label.master == frame.multichannel_frame + assert frame._multichannel_visible_label.cget("text") == "Visible" + assert frame._multichannel_visible_widget.master == frame.multichannel_frame + assert frame._multichannel_visible_widget.cget("text") == "" + assert frame._multichannel_autoscale_label.master == frame.multichannel_frame + assert frame._multichannel_autoscale_label.cget("text") == "Autoscale" + assert frame._multichannel_autoscale_widget.master == frame.multichannel_frame + assert frame._multichannel_autoscale_widget.cget("text") == "" + assert int(frame._multichannel_visible_widget.grid_info()["column"]) == 1 + assert int(frame._multichannel_transpose_widget.grid_info()["column"]) == 1 + assert int(frame._multichannel_autoscale_widget.grid_info()["column"]) == 1 + assert frame.multichannel_frame.winfo_manager() == "grid" + assert frame.single_channel_frame.winfo_manager() == ""