Source code for ClearMap.gui.tabs_interfaces

"""
This module contains the interfaces to the different tabs and dialogs in the ClearMap GUI.
"""
from __future__ import annotations

import os
import functools
import warnings
from dataclasses import dataclass
from pathlib import Path
from contextlib import contextmanager
from typing import final, Callable, Any, TYPE_CHECKING, TypeVar, Generic, ParamSpec, Tuple

import numpy as np
from PyQt5.QtWidgets import QWhatsThis, QWidget, QApplication

from ClearMap.Utils.event_bus import BusSubscriberMixin
from ClearMap.Utils.exceptions import MissingRequirementException, PlotGraphError
from ClearMap.Utils.utilities import title_to_snake
from ClearMap.config.config_handler import ConfigHandler, ALTERNATIVES_REG

from .dialog_helpers import get_directory_dlg
from .exception_handler import handle_exception
from .gui_utils_base import create_clearmap_widget, replace_widget
from .widgets import ExtendableTabWidget, SamplePickerDialog
from ..Utils.events import ChannelsChanged, UiChannelsChanged

if TYPE_CHECKING:
    from PyQt5.QtWidgets import QMainWindow, QToolButton
    from ClearMap.pipeline_orchestrators.sample_info_management import SampleManager
    from ClearMap.pipeline_orchestrators.generic_orchestrators import PipelineOrchestrator
    from .app import ClearMapApp
    from .params_interfaces import UiParameter, UiParameterCollection
    from .params_mixins import OrthoviewerSlicingMixin
    from .params import SampleParameters
    from ClearMap.pipeline_orchestrators.experiment_controller import ExperimentController, AnalysisGroupController
    from ClearMap.Visualization.Qt.DataViewer import DataViewer


PathLike = str | Path

# Binds the worker type to a concrete PipelineOrchestrator subclass in each concrete tab.
# Free (unbound) in abstract intermediates (PipelineTab, Pre/PostProcessingTab),
# bound at the concrete leaf (e.g. CellCounterTab[CellDetector]).
TWorker = TypeVar('TWorker', bound='PipelineOrchestrator')
P = ParamSpec('P')


[docs] def channel_is_compound(channel) -> bool: return (isinstance(channel, str) and '-' in channel) or \ (isinstance(channel, (tuple, list)))
[docs] class GenericUi: """ The first layer of interface. This is not implemented directly but is the base class of GenericTab and GenericDialog, themselves interfaces """ # FIXME: seems to be called several times def __init__(self, main_window: QMainWindow, ui_file_name: str, name: str, widget_class_name: str): """ Parameters ---------- main_window: ClearMapGui name: str ui_file_name: str widget_class_name: str """ self.main_window: QMainWindow = main_window self.name: str = name self.ui_file_name: str = ui_file_name self.widget_class_name: str = widget_class_name self.ui: QWidget | None = None self.params: UiParameter | UiParameterCollection | None = None def _load_dot_ui(self) -> QWidget: return create_clearmap_widget(self.ui_file_name, patch_parent_class=self.widget_class_name) def _init_ui(self) -> None: self.ui = self._load_dot_ui()
[docs] def setup(self) -> None: """Setup more advanced features of the UI, notably the callbacks""" raise NotImplementedError()
[docs] def set_params(self, *args) -> None: """Set the params object which links the UI and the configuration file""" raise NotImplementedError()
def _load_config_to_gui(self) -> None: """Set every control on the UI to the value in the params object""" self.params.cfg_to_ui()
[docs] class GenericDialog(GenericUi): """ Interface to any dialog associated with parameters """ def __init__(self, main_window, name: str, file_name: str): super().__init__(main_window, file_name, name, 'QDialog') def _init_ui(self) -> None: super()._init_ui() self.ui.setWindowTitle(self.name.title())
[docs] def set_params(self, *args) -> None: raise NotImplementedError()
# TODO: SubTab needs a parent widget, tab and main_window
[docs] @dataclass class TabRequirements: needs_workspace: bool = False # requires an experiment folder + workspace needs_channels: bool = False # requires sample channels known/materialized
[docs] class GenericTab(GenericUi, BusSubscriberMixin): """ The interface to all tab managers. A tab manager includes a tab widget, the associated parameters and an optional processor object which handles the computations. """ requirements = TabRequirements(needs_workspace=False, needs_channels=False) # Think SampleInfoTab processing_type: str | None = None # Not a pipeline tab by default channels_ui_name: str = '' # The name of the ui file to create the channel tabs with_add_btn: bool = False # Whether to add the add channel (+) button to the channels tab def __init__(self, main_window: ClearMapApp, ui_file_name: str, tab_idx: int, name: str = ''): """ Parameters ---------- main_window: ClearMapApp ui_file_name: str tab_idx: int name: str """ name = name or self.get_tab_name() super().__init__(main_window, ui_file_name, name, 'QTabWidget') # REFACTORING: avoid direct access to a private attribute BusSubscriberMixin.__init__(self, bus=main_window.experiment_controller._bus) self.tab_idx: int = tab_idx self.params = None # Plain None here; ExperimentTab replaces this with a raising @property # backed by _sample_manager so the type narrows to SampleManager after assignment. # GroupTab / BatchTab keep it as a plain None attribute (no property needed). self.sample_manager = None self.inited: bool = False self.setup_complete: bool = False self.params_set: bool = False self.advanced_controls_names: list[str] = [] self.minimum_width: int = 200 # REFACTOR: self._selected_once: bool = False # Whether the tab has been selected once
[docs] @classmethod def requirements_fulfilled(cls, sample_manager) -> bool: """Whether this tab may be shown for the current sample_manager state.""" reqs = cls.requirements workspace_ready = sample_manager is not None and sample_manager.workspace is not None ch_ready = workspace_ready and sample_manager.channels # or sample_manager.stitchable_channels return (workspace_ready or not reqs.needs_workspace) and (ch_ready or not reqs.needs_channels)
[docs] def on_selected(self) -> None: """Called when this tab is selected; override in subclasses if needed.""" pass
[docs] @final def notify_selected(self) -> None: """ Notify that the tab has been selected (i.e. clicked on). It will also store that the tab has been selected at least once """ try: self.on_selected() self._selected_once = True except Exception as e: # optional logging print(f"[{self.name}] on_selected error: {e}")
[docs] @classmethod def get_tab_name(cls) -> str: snake = title_to_snake(cls.__name__.replace('Tab', '')) words = snake.split('_') tab_name = words[0].title() + ' ' + ' '.join(words[1:]) return tab_name.strip()
def _init_ui(self) -> None: """ Create and arrange the UI elements. Does minimum binding of signals. .. note:: It is normally called by the setup method but can be called by client code explicitly if needed. However, it is protected to avoid calling it multiple times. """ if self.inited: return super()._init_ui() if hasattr(self.ui, 'setMinimumWidth'): self.ui.setMinimumWidth(self.minimum_width) tab_widget = getattr(self.main_window.tabWidget, 'tabWidget', None) if tab_widget and all([hasattr(tab_widget, attr) for attr in ('tabText', 'removeTab', 'insertTab')]): if tab_widget.tabText(self.tab_idx) == self.name: tab_widget.removeTab(self.tab_idx) # remove if same tab tab_widget.insertTab(self.tab_idx, self.ui, self.name.title()) if hasattr(self.ui, 'advancedCheckBox'): self.set_advanced_controls_visibility(False) self.ui.advancedCheckBox.stateChanged.connect(self.handle_advanced_checked) self.inited = True
[docs] def setup(self) -> None: """Setup more advanced features of the UI, notably the callbacks""" self._init_ui() # Call protected by "self.inited". Called in case the tab is not yet initialised if self.setup_complete: return self._swap_channels_tab_widget(self.with_add_btn) self._bind() self._connect_children_whats_this() self.setup_complete = True
def _bind(self) -> None: """ Bind the signal/slots of the UI elements which are not automatically set through the params object attribute """ raise NotImplementedError(f"Method _bind not implemented in {self.__class__.__name__}") def _set_params(self) -> None: """Set the params object which links the UI and the configuration file""" pass def _bind_params_signals(self) -> None: """Bind the signals of the params object""" pass def _get_channels(self) -> list[str]: return [] # Default to no channels when implementing a tab without channels def _setup_workers(self) -> None: """Set up the optional workers (which handle the computations) associated with this tab""" pass
[docs] def finalise_workers_setup(self) -> None: """Finalise the setup of the workers. Typically called when the tab is selected""" pass
[docs] def set_params(self, sample_params: SampleParameters | None = None) -> None: # REFACTOR: rename to initialise or similar """Set the params object which links the UI and the configuration file""" self._set_params() self.params_set = True if self.params._get_view is not None: self.finalise_set_params()
[docs] @final def finalise_set_params(self) -> None: if not self.params_set: warnings.warn(f'Params not set for {self.__class__}. Call set_params before finalise_set_params') return self._setup_workers() self._create_channels() # Creates missing channels. FIXME: What about existing but renamed ones? self._load_config_to_gui() self._bind_params_signals() self.handle_advanced_checked() # in case channel.QtControl in advanced_controls_names
def _create_channels(self) -> None: if not hasattr(self.ui, 'channelsParamsTabWidget'): return if not isinstance(self.ui.channelsParamsTabWidget, ExtendableTabWidget): warnings.warn(f'Channel tab widget not finalised for {self.name}, skipping channel creation') return for channel in self._get_channels(): if channel not in self.ui.channelsParamsTabWidget.get_channels_names(): self.add_channel_tab(channel)
[docs] def get_channel_ui(self, channel: str) -> QWidget | None: """Get the UI widget for a specific channel""" return self.ui.channelsParamsTabWidget.get_channel_widget(channel)
def _swap_channels_tab_widget(self, with_add_btn: bool = False) -> None: """ Substitute the placeholder channel tab widget by the dynamic one Parameters ---------- with_add_btn: bool Whether to add the add channel button """ if (not hasattr(self.ui, 'channelsParamsTabWidget') or not hasattr(self.ui, 'channelsParamsTabWidgetLayout')): print(f'No channel tab widget found for {self.name}, skipping swap') return if not isinstance(self.ui.channelsParamsTabWidget, ExtendableTabWidget): layout = self.ui.channelsParamsTabWidgetLayout self.ui.channelsParamsTabWidget = replace_widget(self.ui.channelsParamsTabWidget, ExtendableTabWidget(self.ui, with_add_tab=with_add_btn), layout)
[docs] def add_channel_tab(self, channel: str = '') -> None: """ Add a tab for a specific channel. This should then call the connect_channel method to set up the tab bindings. .. hint:: This method is meant to be used for tab with channels. To use it ensure that your tab has a channelsParamsTabWidget attribute and set channels_ui_name to the name of the UI file for the channel tabs. """ if not self.channels_ui_name: warnings.warn(f'No channel UI name set for {self.name}. ' f'This method is meant to be used for tab with channels. To use it, ' f'ensure that your tab has a channelsParamsTabWidget attribute and set ' f'channels_ui_name to the name of the UI file for the channel tabs.') return channel, page_widget, created = self._get_or_init_channel_ui(channel) # TODO: check if still present: # bug in StitchingTab when add_channel calls reconcile_children_from_view -> # this triggers the creation of new channel params that don't have UI yet if channel not in self.params.keys(): self._create_channel_params(channel) self._setup_channel(page_widget, channel) self._bind_channel(page_widget, channel) self._connect_children_whats_this(page_widget) self._on_channel_added(channel) else: if created: self.params[channel].teardown() self._setup_channel(page_widget, channel) self._bind_channel(page_widget, channel) self._connect_children_whats_this(page_widget) self._on_channel_added(channel)
def _create_channel_params(self, channel: str) -> None: if isinstance(self, PipelineTab) and not channel_is_compound(channel): # isinstance narrows self to PipelineTab → ExperimentTab, # so self.sample_manager here is SampleManager (raising property) ✓ d_type = self.sample_manager.data_type(channel) self.params.add_channel(channel, d_type) else: self.params.add_channel(channel) def _on_channel_added(self, channel: str) -> None: """Child class should implement this to trigger any action needed when a channel is added""" pass
[docs] def remove_channel_tab(self, channel: str) -> None: """Wrapper so removal always triggers the hook.""" try: if hasattr(self, 'params') and hasattr(self.params, 'pop'): self.params.pop(channel) else: # Fallback only to ensure we do through the proper params teardown if hasattr(self.ui, 'channelsParamsTabWidget'): self.ui.channelsParamsTabWidget.remove_channel_widget(channel) finally: self._on_channel_removed(channel)
def _on_channel_removed(self, channel_name: str) -> None: """Child classes should implement this to trigger cleanup actions after a channel is removed""" pass def _get_or_init_channel_ui(self, channel: str = '') -> tuple[str, QWidget, bool]: """ Initialise the UI for a specific channel. This only creates the UI widget and adds it to the tab widget. Further setup should be done in the `__setup_channel` and __bind_channel methods Parameters ---------- channel: str The name of the channel Returns ------- tuple[str, QWidget, bool] The channel name, the page widget and whether the page was just created. """ tab_widget = self.ui.channelsParamsTabWidget page = tab_widget.get_channel_widget(channel) if page: return channel, page, False else: page_widget = create_clearmap_widget(self.channels_ui_name, patch_parent_class='QWidget') channel = tab_widget.add_channel_widget(page_widget, name=channel) return channel, page_widget, True def _bind_channel(self, page_widget: QWidget, channel: str) -> None: """ Bind the signal/slots of the UI elements for `channel` which are not automatically set through the params object attribute .. important:: All button bindings for channels should be done here """ pass def _setup_channel(self, page_widget: QWidget, channel: str) -> None: """ Perform additional setup for the channel (before binding) For example set default values or populate lists .. note:: Implement in subclass if needed """ pass
[docs] def handle_advanced_checked(self) -> None: """Activate the *advanced* mode which will display more controls""" if not hasattr(self.ui, 'advancedCheckBox'): # e.g. BatchProcessingTab ATM return self.set_advanced_controls_visibility(self.ui.advancedCheckBox.isChecked())
[docs] def set_advanced_controls_visibility(self, visible: bool) -> None: """ Set the visibility of the advanced controls Parameters ---------- visible : bool Whether to show the advanced controls """ if not self.params: # Tab not yet initialised return for ctrl_name in self.advanced_controls_names: if ctrl_name.startswith('channel.'): _, ctrl_name = ctrl_name.split('.') if not hasattr(self.ui, 'channelsParamsTabWidget'): # Channels not set yet warnings.warn(f'Could not find channel tab widget for {self.name}') return for channel in self._get_channels(): ctrl = getattr(self.ui.channelsParamsTabWidget.get_channel_widget(channel), ctrl_name) ctrl.setVisible(visible) else: ctrl = getattr(self.ui, ctrl_name, None) if ctrl: ctrl.setVisible(visible) else: warnings.warn(f'Could not find control {ctrl_name} in {self.name}')
[docs] def disable(self) -> None: """Disable this tab (UI element)""" self.ui.setEnabled(False)
[docs] def step_exists(self, step_name: str, file_list: list[PathLike] | PathLike) -> bool: """ Check that prerequisite step step_name has been run and produced the outputs in file_list Parameters ---------- step_name: str The name of the step file_list: list[str] | list[Path] | str | Path The list of files that should have been produced by the step Returns ------- bool Whether the step has been run """ if isinstance(file_list, (str, Path)): file_list = [file_list] for f_path in file_list: if not os.path.exists(f_path): self.main_window.print_error_msg(f'Missing {step_name} file {f_path}. ' f'Please ensure {step_name} is run first.') return False return True
[docs] def connect_whats_this_btn(self, info_btn: QToolButton, whats_this_ctrl: QWidget): """ Utility function to bind the info button to the display of the detailed *whatsThis* message of `whats_this_control` Parameters ---------- info_btn: QToolButton The button to display the info whats_this_ctrl: QWidget The control to display the info for (it should have a *whatsThis* message) """ def show_whats_this(widget): QWhatsThis.showText(widget.pos(), widget.whatsThis(), widget) info_btn.clicked.connect(lambda: show_whats_this(whats_this_ctrl))
def _connect_children_whats_this(self, parent: QWidget | None = None) -> None: # TODO: make tests safe """ Connect all the what's this buttons of the widgets `parent` to the corresponding labels Parameters ---------- parent : QWidget | None The widget to connect """ parent = parent or self.ui children = {child.objectName(): child for child in parent.findChildren(QWidget)} for ctrl_name, ctrl in children.items(): if ctrl_name.endswith('InfoToolButton'): base_name = ctrl_name.replace('InfoToolButton', '') widgets = [widget for name, widget in children.items() if name.startswith(base_name) and name != ctrl_name] widgets = [w for w in widgets if w.whatsThis()] if len(widgets) == 1: self.connect_whats_this_btn(ctrl, widgets[0]) else: raise ValueError(f'Could not find unique widget for "{base_name}" in "{parent.objectName()}",' f' got {[(w.objectName(), w) for w in widgets]}')
[docs] def wrap_step(self, task_name: str, func: Callable, step_args: list[Any] | None = None, step_kw_args: dict[str, Any] | None = None, n_steps: int = 1, abort_func: Callable | None = None, bypass_exceptions: Tuple[Exception] | None = None, save_cfg: bool = True, nested: bool = True, close_when_done: bool = True, main_thread: bool = False) -> None: """ Start a new thread (unless `main_thread` = `True`) for `func` and show a progress dialog. Ensures that the UI remains responsive. Parameters ---------- task_name : str The name of the task to be displayed func : Callable The function to run step_args : list[Any] | None The positional arguments to `func` step_kw_args : dict[str, Any] | None The keyword arguments to `func` n_steps : int The number of top level steps in the computation. This will be disabled if nested is False. abort_func : Callable | None The function to trigger to abort the execution of the computation (bound to the abort button) bypass_exceptions: Tuple[Exception] | None A tuple of exception types to bypass the GUI exception handling for. Defaults to empty. save_cfg : bool Whether to save the configuration to disk before running the computation. This is usually the right choice to ensure that the config reloaded by func is up to date. nested : bool Whether the computation has 2 levels of progress close_when_done : bool Close the progress dialog when func has finished executing main_thread : bool Whether to run in the main thread. Default is False to ensure that the UI thread remains responsive, a new thread will be spawned. """ step_args = step_args or [] step_kw_args = step_kw_args or {} if task_name: n_steps = n_steps if nested else 0 self.main_window.make_progress_dialog(task_name, n_steps=n_steps, abort=abort_func) try: if main_thread: func(*step_args, **step_kw_args) else: self.main_window.wrap_in_thread(func, *step_args, **step_kw_args) except Exception as err: if bypass_exceptions and isinstance(err, tuple(bypass_exceptions)): raise err # Leave to caller if not getattr(err, '_gui_handled', False): func_name = getattr(func, '__name__', str(func)) action = handle_exception(err, parent=self.main_window, context=f'{self.name}{func_name}') err._gui_handled = True if action == 'reset_workspace': self.main_window.trigger_workspace_reset() elif action == 'close': QApplication.instance().quit() raise err # Re-raise so caller loops (like run_stitching) can interrupt their sequence finally: # Access the private backing field directly instead of going through the property: # - avoids triggering ExperimentTab's raising property when _sample_manager is None # - returns None gracefully for GroupTab / BatchTab (no _sample_manager attribute at all) sm = getattr(self, '_sample_manager', None) # WARNING: intentional private access if sm is not None and sm.workspace is not None: # WARNING: hacky sm.workspace.executor = None # FIXME: do not pass workspace but semaphore instead if close_when_done: self.main_window.progress_watcher.finish() else: # FIXME: message different if exception msg = f'{self.main_window.progress_watcher.main_step_name} finished' self.main_window.print_status_msg(msg) self.main_window.log_progress(f' : {msg}')
[docs] def wrap_plot(self, plot_method: Callable[P, Any], *args: P.args, **kwargs: P.kwargs) -> 'list[DataViewer]': """ Wrapper to plot a graph and display it in the main window. It also handles ClearMap exceptions gracefully Parameters ---------- plot_method: function The function (or method) to plot the graph args: list The positional arguments to plot_function kwargs: dict The keyword arguments to plot_function Returns ------- list[DataViewer] The data viewers returned by plot_function """ self.main_window.clear_plots() try: dvs = plot_method(*args, **kwargs) except Exception as err: if not getattr(err, '_gui_handled', False): func_name = getattr(plot_method, '__name__', str(plot_method)) handle_exception(err, parent=self.main_window, context=f'{self.name}{func_name}') err._gui_handled = True return [] if not dvs: return [] if isinstance(dvs[0], list): dvs, titles = dvs self.main_window.setup_plots(dvs, titles) else: self.main_window.setup_plots(dvs) from ClearMap.Visualization.Qt.DataViewer import DataViewer # runtime import for isinstance return [widget for widget in dvs if isinstance(widget, DataViewer)]
[docs] @staticmethod def ui_plot(status_msg: str = '') -> Callable: """ Decorator for tab methods that produce plot widgets. Handles: - Status message display. - Clearing previous plots. - ``MissingRequirementException`` / ``PlotGraphError``. - ``(dvs, titles)`` tuple return pattern. - ``setup_plots()`` call. - Filtering return to ``DataViewer`` instances only. Parameters ---------- status_msg : str, optional Status message to display during plotting (default: ``''``). Returns ------- Callable Decorated function that wraps plot logic with error handling and setup. Examples -------- .. code-block:: python @GenericTab.ui_plot("Plotting p-values…") def plot_p_vals(self, ...): # pure plotting logic return [widget1, widget2] """ def deco(fn: Callable) -> Callable: @functools.wraps(fn) def wrapper(self, *args, **kwargs): if status_msg: self.main_window.print_status_msg(status_msg) bound_method = functools.partial(fn, self) return self.wrap_plot(bound_method, *args, **kwargs) return wrapper return deco
[docs] class ExperimentTab(GenericTab): def __init__(self, main_window: QMainWindow, ui_file_name: str, tab_idx: int, name: str = ''): super().__init__(main_window, ui_file_name, tab_idx, name) self._sample_manager: SampleManager | None = None self._exp_controller: ExperimentController | None = None self.sample_params: SampleParameters | None = None # ------------------------------------------------------------------ # # Raising properties — eliminates "| None has no attribute X" noise # # ------------------------------------------------------------------ # @property def sample_manager(self) -> SampleManager: """ The sample manager for this tab. Raises ------ RuntimeError If accessed before :meth:`setup_sample_manager` has been called. """ if self._sample_manager is None: raise RuntimeError( f'{type(self).__name__}.sample_manager accessed before assignment; ' f'call setup_sample_manager() first' ) return self._sample_manager @sample_manager.setter def sample_manager(self, value: SampleManager | None) -> None: self._sample_manager = value @property def exp_controller(self) -> ExperimentController: """ The experiment controller for this tab. Raises ------ RuntimeError If accessed before :meth:`set_controller` has been called. """ if self._exp_controller is None: raise RuntimeError( f'{type(self).__name__}.exp_controller accessed before assignment; ' f'call set_controller() first' ) return self._exp_controller @exp_controller.setter def exp_controller(self, value: ExperimentController | None) -> None: self._exp_controller = value # ------------------------------------------------------------------ #
[docs] def set_controller(self, controller: ExperimentController) -> None: self._exp_controller = controller
[docs] def setup_sample_manager(self, sample_manager: SampleManager) -> None: self._sample_manager = sample_manager
[docs] def set_params(self, sample_params: SampleParameters | None = None) -> None: # FIXME: never set if sample_params: self.sample_params = sample_params # REFACTORING: consider using sample_manager super().set_params()
[docs] def reconcile_channel_pages(self, desired_channels: list[str]) -> None: """ Update the configuration for the channels and the associated page widgets Parameters ---------- desired_channels: list[str] The list of channels that should be present """ if not self.params or not hasattr(self.ui, 'channelsParamsTabWidget'): # If not setup or tab without channels return tab_widget = self.ui.channelsParamsTabWidget # ordered list desired_channels = list(dict.fromkeys(desired_channels)) # dedupe, keep order # remove obsolete pages obsolete_channels = [c for c in tab_widget.get_channels_names() if c not in desired_channels] for ch in obsolete_channels: self.params.pop(ch) # self.remove_channel_tab(ch) should be implicit in pop self._on_channel_removed(ch) # add missing pages (in desired order) for ch in desired_channels: if ch not in tab_widget.get_channels_names(): self.add_channel_tab(ch) # will call _setup_channel/_bind_channel # setup workers ? # params.add_channel(ch) ?? self._after_channels_reconciled(desired_channels) self._setup_workers()
def _after_channels_reconciled(self, desired_channels: list[str]) -> None: """ Triggered at the end of channel reconciliation between UI and cfg Child classes should implement this method to e.g. populate lists or ComboBoxes that depend on the list of channels """ pass def _on_bus_channels_changed(self, event: UiChannelsChanged | ChannelsChanged) -> None: # Support UiChannelsChanged for Sample Tab relevant = set(self._get_channels()) desired = [c for c in event.after if c in relevant] self.reconcile_channel_pages(desired) def _setup_workers(self) -> None: """ Setup the optional workers (which handle the computations) associated with this tab .. warning:: This method must be implemented in the subclasses of PipelineTab .. note:: is required for create_channels (to get list of channels) bind_params_signals """ pass
[docs] class GroupTab(GenericTab): processing_type = 'group' def __init__(self, main_window: QMainWindow, ui_file_name: str, tab_idx: int, name: str = ''): super().__init__(main_window, ui_file_name, tab_idx, name) self.config_handler: ConfigHandler | None = None self.group_controller: AnalysisGroupController | None = None
[docs] def set_controller(self, controller: AnalysisGroupController) -> None: self.group_controller = controller
[docs] class PipelineTab(ExperimentTab, Generic[TWorker]): """ Introduces the TWorker TypeVar into the hierarchy. Concrete subclasses bind it:: class CellCounterTab(PostProcessingTab[CellDetector]): ... """ requirements = TabRequirements(needs_workspace=True, needs_channels=True) processing_type = '' pipeline_name: str = '' # Must be set in subclass workers_are_global: bool = False # Whether the workers are shared between channels _workers_sub_steps: tuple[str, ...] | None = None # The sub-steps to create workers for (if any) def __init__(self, main_window: QMainWindow, ui_file_name: str, tab_idx: int, name: str = ''): super().__init__(main_window, ui_file_name, tab_idx, name) self.sample_params = None def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) if not cls.processing_type: raise NotImplementedError(f'Class "{cls.__name__}" must override "processing_type" with a string value.')
[docs] @classmethod def requirements_fulfilled(cls, sample_manager) -> bool: if not super().requirements_fulfilled(sample_manager): return False # Config section must exist before the tab can be created if cls.pipeline_name: section = ALTERNATIVES_REG.pipeline_to_section_name(cls.pipeline_name) if section and not sample_manager.cfg_coordinator.get_config_view(section): return False return True
def _iter_substeps(self) -> tuple[str | None, ...]: return self._workers_sub_steps or (None,) def _workers_channel_keys(self) -> list: """ Worker keys handed to ExperimentController.reconcile_workers: - global workers: [None] - per-channel/per-pair: list from _get_channels() """ return [None] if self.workers_are_global else list(self._get_channels())
[docs] def setup_sample_manager(self, sample_manager: SampleManager) -> None: """ Associate the sample_manager to the current tab Parameters ---------- sample_manager : SampleManager The object that handles the sample data """ self._sample_manager = sample_manager
def _bind_btn(self, btn_name: str, func: Callable, channel: str | None = None, page_widget: QWidget | None = None, **kwargs) -> None: if channel: getattr(page_widget, btn_name).clicked.connect(functools.partial(func, channel, **kwargs)) else: getattr(self.ui, btn_name).clicked.connect(func)
[docs] class PreProcessingTab(PipelineTab[TWorker]): processing_type = 'pre' workers_are_global = True def __init__(self, main_window: QMainWindow, ui_file_name: str, tab_idx: int, name: str = ''): super().__init__(main_window, ui_file_name, tab_idx, name) @property def worker(self) -> TWorker: """ The single global worker for this pipeline step. Raises ------ RuntimeError If the worker has not yet been created (call _setup_workers first). """ w = self.exp_controller.get_worker(self.pipeline_name) if w is None: raise RuntimeError( f'{type(self).__name__}.worker: no worker found for pipeline ' f'"{self.pipeline_name}". Ensure _setup_workers() has been called.' ) return w def _setup_workers(self) -> None: # Use private field to avoid triggering the raising property for the None check if self._sample_manager is None or not self._sample_manager.setup_complete: return worker = self.worker if worker is not None: # e.g. not SampleInfoTab self.wrap_step('Setting up worker', worker.setup_if_needed, n_steps=1, save_cfg=False, nested=False)
[docs] class PostProcessingTab(PipelineTab[TWorker]): """ Interface to all the tab managers in charge of post-processing the data. One particularity of a post-processing tab manager is that it includes the corresponding pre-processor. A tab manager includes a tab widget, the associated parameters and potentially a processor object which handles the computations. """ processing_type = 'post' def __init__(self, main_window: QMainWindow, ui_file_name: str, tab_idx: int, name: str = ''): super().__init__(main_window, ui_file_name, tab_idx, name) def _setup_workers(self) -> None: # Use private field to avoid triggering the raising property for the None check if self._sample_manager is None or not self._sample_manager.setup_complete: self.main_window.print_warning_msg("SampleManager not initialised") return desired_channels = self._workers_channel_keys() for substep in self._iter_substeps(): workers = self.exp_controller.reconcile_workers( self.pipeline_name, desired_channels=desired_channels, substep=substep, keep_global=self.workers_are_global ) for w in workers.values(): w.setup_if_needed()
[docs] def get_worker(self, channel: str | tuple[str, str] | None = None, substep: str | None = None) -> TWorker: """ Retrieve the worker for the given channel / substep combination. Raises ------ ValueError If substep is inconsistent with _workers_sub_steps. RuntimeError If no worker is found (setup not complete). """ if substep and substep not in self._iter_substeps(): raise ValueError(f'Sub-step {substep} not in {self._workers_sub_steps}') elif not substep and self._workers_sub_steps: raise ValueError(f'Sub-step must be specified, available: {self._workers_sub_steps}') if self.workers_are_global: channel = None worker = self.exp_controller.get_worker(self.pipeline_name, channel=channel, substep=substep) if worker is None: raise RuntimeError( f'{type(self).__name__}.get_worker: no worker for pipeline="{self.pipeline_name}" ' f'{channel=!r} {substep=!r}. Ensure _setup_workers() has been called.' ) return worker
[docs] @contextmanager def debug_mode(self, channel: str, debug_status: str | bool): worker = self.get_worker(channel) status_backup = worker.workspace.debug try: worker.workspace.debug = debug_status yield finally: worker.workspace.debug = status_backup
[docs] def create_tuning_sample(self, channel: str) -> None: """Create an array from a subset of the sample to perform tests on""" worker = self.get_worker(channel) if not hasattr(worker, 'create_test_dataset'): return self.wrap_step('Creating tuning sample', worker.create_test_dataset, step_kw_args={'slicing': self.params[channel].slicing}, nested=False)
[docs] def plot_slicer(self, slicer_prefix: str, tab: QWidget, params: OrthoviewerSlicingMixin, channel: str | list[str]) -> None: """ Display the ortho-slicer to pick a subset of 3D data. This is typically used to create a small dataset to evaluate parameters for long-running operations before analysing the whole sample Parameters ---------- slicer_prefix tab params """ self.main_window.clear_plots() no_scale = False if isinstance(channel, (list, tuple)): sources = [self.sample_manager.get('resampled', channel=ch).open_ro() for ch in channel] if not np.all([src.shape == sources[0].shape for src in sources]): raise ValueError('Channels have different shapes') plot_image = np.mean([self.sample_manager.get('resampled', channel=ch).read() for ch in channel], axis=0) channel = channel[0] else: # asset = self.sample_manager.get('stitched', channel=channel) # if asset.exists: # no_scale = True # else: # missing stitched # FIXME: we cannot currently use stitched because we need to transpose it and it is too heavy for that asset = self.sample_manager.get('resampled', channel=channel) plot_image = asset.open_ro() self.main_window.ortho_viewer.setup(plot_image, params, parent=self.main_window, no_scale=no_scale) dvs = self.main_window.ortho_viewer.plot_orthogonal_views() if not no_scale: ranges = [[params.reverse_scale_axis(v, ax) for v in vals] for ax, vals in zip('xyz', params.slice_tuples)] self.main_window.ortho_viewer.update_ranges(ranges) self.main_window.setup_plots(dvs, ['x', 'y', 'z']) # WARNING: needs to be done after setup for axis, ax_max in zip('XYZ', self.sample_manager.stitched_shape(channel)): # WARNING: assumes stitched shape getattr(tab, f'{slicer_prefix}{axis}RangeMax').setMaximum(round(ax_max))
[docs] class BatchTab(GroupTab): def __init__(self, main_window: QMainWindow, tab_idx: int): super().__init__(main_window, title_to_snake(self.__class__.__name__), tab_idx) self.processing_type = 'batch' self.config_loader = None @property def initialised(self) -> bool: return self.params is not None def _setup_workers(self) -> None: pass def _bind(self) -> None: """ Bind the signal/slots of the UI elements which are not automatically set through the params object attribute. .. warning:: Child classes should call this method in their own _bind. """ self.ui.resultsFolderPushButton.clicked.connect(self.setup_results_folder) self.ui.folderPickerHelperPushButton.clicked.connect(self.create_wizard) self.ui.batchToolBox.setCurrentIndex(0)
[docs] def setup_results_folder(self) -> None: results_folder = get_directory_dlg(self.main_window.preference_editor.params.start_folder, 'Select the folder where results will be written') if not results_folder: return else: results_folder = Path(results_folder) self.main_window.logger.set_file(results_folder / 'info.log') # WARNING: set logs to global results folder self.main_window.error_logger.set_file(results_folder / 'errors.html') self.main_window.progress_watcher.log_path = self.main_window.logger.file.name self.set_params() self.params.results_folder = str(results_folder) self._load_config_to_gui() self._setup_workers()
[docs] def create_wizard(self) -> SamplePickerDialog: return SamplePickerDialog(self.params.results_folder, self.params) # FIXME: check if results_folder or make both equal with self.params.src_folder
def _infer_default_results_folder(self) -> str | None: """Derive a default results folder from the experiment source folder, falling back to preferences.start_folder.""" src = self.main_window.gui_controller.group_controller.group_base_dir return src