diff --git a/datalab/gui/actionhandler.py b/datalab/gui/actionhandler.py index e7ea8f7f..fbec6cc5 100644 --- a/datalab/gui/actionhandler.py +++ b/datalab/gui/actionhandler.py @@ -1615,6 +1615,7 @@ def create_first_actions(self): tip=_("Apply all thresholding methods"), ) with self.new_menu(_("Exposure"), icon_name="exposure.svg"): + self.action_for("adjust_brightness_contrast") self.action_for("adjust_gamma") self.action_for("adjust_log") self.action_for("adjust_sigmoid") diff --git a/datalab/gui/main.py b/datalab/gui/main.py index 95a6282d..722acecb 100644 --- a/datalab/gui/main.py +++ b/datalab/gui/main.py @@ -67,6 +67,7 @@ from datalab.gui.h5io import H5InputOutput from datalab.gui.panel import base, history, image, macro, signal from datalab.gui.pluginconfig import PluginConfigDialog +from datalab.gui.processor.preview import PreviewExecutorCache from datalab.gui.settings import AI_OPTION_NAMES, edit_settings from datalab.objectmodel import ObjectGroup, get_uuid from datalab.plugins import PluginRegistry, discover_plugins, discover_v020_plugins @@ -125,6 +126,7 @@ def __init__(self, console=None, hide_on_close=False): """Initialize main window""" self.started_at = datetime.now().astimezone() self.plugins_last_load_at = self.started_at + self.preview_executor_cache = PreviewExecutorCache() self.webapistatus: dl_status.WebAPIStatus | None = None self.pluginstatus: dl_status.PluginStatus | None = None @@ -936,6 +938,7 @@ def __unregister_plugins() -> None: def __restart_processor_pool(self) -> None: """Restart the shared pool after plugin paths change at runtime.""" + self.preview_executor_cache.reset() for processor in (self.imagepanel.processor, self.signalpanel.processor): if processor.worker is not None: processor.worker.restart_pool() @@ -1086,6 +1089,7 @@ def __apply_plugins_enabled_setting(self) -> None: self.reload_plugins() return + self.preview_executor_cache.reset() self.__unregister_plugins() for panel in (self.signalpanel, self.imagepanel): panel.acthandler.clear_plugin_actions() @@ -2327,6 +2331,7 @@ def _get_save_before_quit_message(self) -> str: def _close_managed_widgets(self) -> None: """Close DataLab panels and generic shell widgets.""" + self.preview_executor_cache.close() for panel in self.panels: if panel is not None: panel.close() diff --git a/datalab/gui/panel/base.py b/datalab/gui/panel/base.py index 207bfe8f..2c728506 100644 --- a/datalab/gui/panel/base.py +++ b/datalab/gui/panel/base.py @@ -434,6 +434,9 @@ def update_properties_from( # Remove only Creation and Processing tabs (dynamic tabs) # Use widget references instead of text labels for reliable identification + self.__auto_recompute_timer.stop() + if self.processing_param_editor is not None: + self.processing_param_editor.on_change = None if self.creation_scroll is not None: index = self.tabwidget.indexOf(self.creation_scroll) if index >= 0: @@ -442,6 +445,7 @@ def update_properties_from( index = self.tabwidget.indexOf(self.processing_scroll) if index >= 0: self.tabwidget.removeTab(index) + self.processing_scroll.deleteLater() if self.analysis_scroll is not None: index = self.tabwidget.indexOf(self.analysis_scroll) if index >= 0: @@ -820,6 +824,10 @@ def setup_processing_tab( Returns: True if Processing tab was set up, False otherwise """ + self.__auto_recompute_timer.stop() + if self.processing_param_editor is not None: + self.processing_param_editor.on_change = None + # Extract processing parameters proc_params = extract_processing_parameters(obj) if proc_params is None: @@ -842,23 +850,27 @@ def setup_processing_tab( if isinstance(param, list): return False - # Eventually call the `update_from_obj` method to properly initialize - # the parameter object from the current object state. - # Only do this when reset_params is True (initial setup), not when - # refreshing after user has modified parameters. - if reset_params and hasattr(param, "update_from_obj"): - # Warning: the `update_from_obj` method takes the input object as argument, - # not the output object (`obj` is the processed object here): - # Retrieve the input object from the source UUID - if proc_params.source_uuid is not None: - source_obj = self.panel.mainwindow.find_object_by_uuid( - proc_params.source_uuid - ) - if source_obj is not None: - param.update_from_obj(source_obj) + # Source-aware parameters may refresh transient editor context without + # replacing their persisted values. Legacy parameters keep the previous + # reset-only initialization behavior. + source_obj = None + if proc_params.source_uuid is not None: + source_obj = self.panel.mainwindow.find_object_by_uuid( + proc_params.source_uuid + ) + if hasattr(param, "update_editor_context"): + param.update_editor_context(source_obj) + elif ( + reset_params + and source_obj is not None + and hasattr(param, "update_from_obj") + ): + param.update_from_obj(source_obj) # Create parameter editor widget - editor = gdq.DataSetEditGroupBox( + from datalab.widgets.processingparameters import ProcessingParametersEditor + + editor = ProcessingParametersEditor( _("Processing Parameters"), param.__class__, wordwrap=True ) update_dataset(editor.dataset, param) @@ -868,22 +880,7 @@ def setup_processing_tab( editor.SIG_APPLY_BUTTON_CLICKED.connect(self.apply_processing_parameters) editor.set_apply_button_state(False) - # Hook into the per-edit change callback to support auto-recompute. - # ``DataSetEditLayout.change_callback`` is called whenever any widget - # value changes; wrap it so we can also (re)start the debounce timer. - try: - inner_layout = editor.edit # DataSetEditLayout instance - original_change_cb = inner_layout.change_callback - - def _wrapped_change_cb() -> None: - if original_change_cb is not None: - original_change_cb() - if self.__auto_recompute_enabled: - self.__auto_recompute_timer.start(300) - - inner_layout.change_callback = _wrapped_change_cb - except AttributeError: - pass + editor.on_change = lambda: self.__processing_parameters_changed(editor) # Store reference to be able to retrieve it later self.processing_param_editor = editor @@ -911,21 +908,32 @@ def _wrapped_change_cb() -> None: QW.QSizePolicy.Expanding, QW.QSizePolicy.Preferred ) - # Build the tab content: editor + "Auto-recompute" checkbox. - container = QW.QWidget() - vbox = QW.QVBoxLayout(container) - vbox.setContentsMargins(0, 0, 0, 0) - vbox.addWidget(editor) - auto_cb = QW.QCheckBox(_("Auto-recompute on edit"), container) + # Add the auto-recompute option below Apply, aligned with input fields. + auto_cb = QW.QCheckBox(_("Auto-recompute on edit"), editor) + auto_cb.setObjectName("auto_recompute_on_edit") + auto_cb.setIcon(get_icon("replay.svg")) auto_cb.setToolTip( _("Automatically re-run processing when parameters are modified") ) auto_cb.setChecked(self.__auto_recompute_enabled) auto_cb.toggled.connect(self.__set_auto_recompute_enabled) - vbox.addWidget(auto_cb) - vbox.addStretch(1) + form_layout = editor.edit.layout + apply_index = form_layout.indexOf(editor.apply_button) + apply_row, _column, _row_span, _column_span = form_layout.getItemPosition( + apply_index + ) + input_column = 1 + input_column_span = max(1, form_layout.columnCount() - input_column) + form_layout.addWidget( + auto_cb, + apply_row + 1, + input_column, + 1, + input_column_span, + QC.Qt.AlignLeft, + ) - self.processing_scroll.setWidget(container) + self.processing_scroll.setWidget(editor) self.tabwidget.insertTab( insert_index, self.processing_scroll, @@ -1138,17 +1146,29 @@ def __set_auto_recompute_enabled(self, enabled: bool) -> None: if not self.__auto_recompute_enabled: self.__auto_recompute_timer.stop() + def __processing_parameters_changed(self, editor) -> None: + """Debounce real processing only for the current valid, released editor.""" + if editor is not self.processing_param_editor: + return + self.__auto_recompute_timer.stop() + if ( + self.__auto_recompute_enabled + and not editor.dragging + and editor.edit.check_all_values() + ): + self.__auto_recompute_timer.start(300) + def __auto_recompute_trigger(self) -> None: """Debounced callback: push widget values then re-run processing.""" if not self.__auto_recompute_enabled: return editor = self.processing_param_editor - if editor is None: + if editor is None or editor.dragging or not editor.edit.check_all_values(): return # ``editor.set()`` synchronises widget values to the dataset and emits # ``SIG_APPLY_BUTTON_CLICKED`` which is already wired to # ``apply_processing_parameters``. - editor.set(check=False) + editor.set() def apply_processing_parameters( self, diff --git a/datalab/gui/processor/base.py b/datalab/gui/processor/base.py index 8856c6c2..88454151 100644 --- a/datalab/gui/processor/base.py +++ b/datalab/gui/processor/base.py @@ -763,6 +763,7 @@ class ComputingFeature(Generic[TypeObj]): :meth:`BaseProcessor.add_feature` time). ``None`` for built-in (Sigima/DataLab) features. pre_execute_hook: optional transactional source preparation hook + preview_enabled: allow speculative execution in standard 1-to-1 dialogs """ pattern: Literal["1_to_1", "1_to_0", "1_to_n", "n_to_1", "2_to_1"] @@ -776,6 +777,7 @@ class ComputingFeature(Generic[TypeObj]): skip_xarray_compat: Optional[bool] = None plugin_origin: Optional[dict[str, Any]] = field(default=None) pre_execute_hook: Optional[SourcePreparationHook[TypeObj]] = None + preview_enabled: bool = True def __post_init__(self): """Validate the function after initialization.""" @@ -1649,7 +1651,11 @@ def recompute_1_to_0( return result is not None and result.execution_success def _compute_1_to_1_subroutine( - self, funcs: list[Callable], params: list, title: str + self, + funcs: list[Callable], + params: list, + title: str, + preview_result: tuple[SignalObj | ImageObj, CompOut] | None = None, ) -> None: """Generic subroutine for 1-to-1 processing. @@ -1672,8 +1678,12 @@ def _compute_1_to_1_subroutine( i_title = f"{title} ({pvalue}/{n_glob})" progress.setLabelText(i_title) progress.setValue(pvalue) - args = (obj,) if param is None else (obj, param) - result = self.__exec_func(func, args, progress) + if preview_result is not None and preview_result[0] is obj: + result = preview_result[1] + preview_result = None + else: + args = (obj,) if param is None else (obj, param) + result = self.__exec_func(func, args, progress) if result is None: break new_obj = self.handle_output( @@ -1792,6 +1802,7 @@ def compute_1_to_1( title: str | None = None, comment: str | None = None, edit: bool | None = None, + preview_enabled: bool = True, ) -> None: """Generic processing method: 1 object in → 1 object out. @@ -1808,6 +1819,7 @@ def compute_1_to_1( title: Optional progress bar title. comment: Optional comment for parameter dialog. edit: Whether to open the parameter editor before execution. + preview_enabled: Allow an optional live preview in the parameter dialog. .. note:: With k selected objects, the method produces k outputs (one per input). @@ -1815,14 +1827,56 @@ def compute_1_to_1( .. note:: This method does not support pairwise mode. """ - if (edit is None or param is None) and paramclass is not None: - old_edit = edit - edit, param = self.init_param(param, paramclass, title, comment) - if old_edit is not None: - edit = old_edit - if param is not None: - if edit and not param.edit(parent=self.mainwindow): + sources = self.panel.objview.get_sel_objects(include_groups=True) + groups = self.panel.objview.get_sel_groups() + if not sources: + return + remember_defaults = param is None and paramclass is not None + if remember_defaults: + param = paramclass(title, comment) + defaults = self.PARAM_DEFAULTS.get(paramclass.__name__) + if defaults is not None: + gds.update_dataset(param, copy.deepcopy(defaults)) + if hasattr(param, "update_from_obj"): + param.update_from_obj(copy.deepcopy(sources[0])) + if edit is None: + edit = True + if param is not None and edit: + from datalab.widgets.processingpreview import edit_processing_parameters + + draft = copy.deepcopy(param) + preview_results = [] + feature = self.computing_registry.get(func.__name__) + allowed = preview_enabled and (feature is None or feature.preview_enabled) + if not edit_processing_parameters( + draft, + func, + sources, + self.mainwindow, + allowed, + preview_results, + executor_cache=self.mainwindow.preview_executor_cache, + ): + return + if any( + get_uuid(source) not in self.panel.objmodel.get_object_ids() + for source in sources + ): + QW.QMessageBox.warning( + self.mainwindow, + _("Warning"), + _("A preview source was removed. The processing was cancelled."), + ) return + gds.update_dataset(param, draft) + for index, selected in enumerate(groups or sources): + self.panel.objview.set_current_item_id( + get_uuid(selected), extend=index > 0 + ) + else: + preview_results = [] + if remember_defaults: + self.PARAM_DEFAULTS[type(param).__name__] = copy.deepcopy(param) plugin_origin = self._get_plugin_origin_for(func) pp = build_processing_parameters( func.__name__, "1-to-1", param=param, plugin_origin=plugin_origin @@ -1834,7 +1888,16 @@ def compute_1_to_1( plugin_origin=plugin_origin, ) with self.mainwindow.historypanel.capture_outputs(action): - self._compute_1_to_1_subroutine([func], [param], title) + self._compute_1_to_1_subroutine( + [func], + [param], + title, + preview_result=( + preview_results[0] + if len(sources) == 1 and not groups and preview_results + else None + ), + ) def compute_multiple_1_to_1( self, @@ -2663,6 +2726,7 @@ def register_1_to_1( icon_name: str | None = None, comment: str | None = None, edit: bool | None = None, + preview_enabled: bool = True, ) -> ComputingFeature: """Register a 1-to-1 processing function. @@ -2678,6 +2742,7 @@ def register_1_to_1( icon_name: icon name. Defaults to None. comment: comment. Defaults to None. edit: whether to open the parameter editor before execution. + preview_enabled: allow speculative execution before accepting parameters. Returns: Registered feature. @@ -2690,6 +2755,7 @@ def register_1_to_1( icon_name=icon_name, comment=comment, edit=edit, + preview_enabled=preview_enabled, ) self.add_feature(feature) return feature @@ -2975,6 +3041,10 @@ def run_feature( f"For pattern '{pattern}', 'param' must be a DataSet or None" ) compute_kwargs = {} + if pattern == "1_to_1": + compute_kwargs["preview_enabled"] = kwargs.pop( + "preview_enabled", feature.preview_enabled + ) if pattern == "n_to_1": compute_kwargs["pairwise"] = kwargs.pop("pairwise", None) return compute_method( diff --git a/datalab/gui/processor/image.py b/datalab/gui/processor/image.py index 7680dd67..74852943 100644 --- a/datalab/gui/processor/image.py +++ b/datalab/gui/processor/image.py @@ -415,6 +415,11 @@ def register_processing(self) -> None: self.register_1_to_1(sipi.threshold_triangle, _("Triangle thresholding")) self.register_1_to_1(sipi.threshold_yen, _("Yen thresholding")) # Exposure + self.register_1_to_1( + sipi.adjust_brightness_contrast, + _("Brightness and contrast"), + sipi.BrightnessContrastParam, + ) self.register_1_to_1( sipi.adjust_gamma, _("Gamma correction"), diff --git a/datalab/gui/processor/preview.py b/datalab/gui/processor/preview.py new file mode 100644 index 00000000..56831faf --- /dev/null +++ b/datalab/gui/processor/preview.py @@ -0,0 +1,283 @@ +"""Isolated, bounded speculative computation without workspace side effects.""" + +from __future__ import annotations + +import copy +import multiprocessing +import threading +import traceback +from concurrent.futures import Future, ThreadPoolExecutor +from typing import Callable + +from guidata.dataset import DataSet +from qtpy import QtCore as QC +from sigima.config import options as sigima_options +from sigima.objects import ImageObj, SignalObj + +from datalab.gui.processor.base import run_with_env +from datalab.gui.processor.catcher import CompOut + +__all__ = ["PreviewController", "PreviewExecutor", "PreviewExecutorCache"] + + +class PreviewExecutor: + """Own a private process whose startup and disposal never block Qt.""" + + def __init__(self) -> None: + self._threads = ThreadPoolExecutor(max_workers=1, thread_name_prefix="preview") + self._stop = threading.Event() + self._pool = None + self._closed = False + + def submit(self, function: Callable, args: tuple) -> Future: + """Submit independent arguments and the current scientific configuration.""" + if self._closed: + raise RuntimeError("Preview executor is closed") + return self._threads.submit( + self._execute, function, args, sigima_options.get_env() + ) + + def _execute(self, function: Callable, args: tuple, environment: str) -> CompOut: + try: + if self._stop.is_set(): + return CompOut(cancelled=True) + if self._pool is None: + self._pool = multiprocessing.get_context("spawn").Pool(1) + if self._stop.is_set(): + return CompOut(cancelled=True) + result = self._pool.apply_async(run_with_env, (function, args, environment)) + while not result.ready(): + if self._stop.wait(0.02): + return CompOut(cancelled=True) + return result.get() + except Exception: + return CompOut(error_msg=traceback.format_exc()) + finally: + if self._stop.is_set(): + self._dispose() + + def _dispose(self) -> None: + if self._pool is not None: + self._pool.terminate() + self._pool.join() + self._pool = None + + def close(self, wait: bool = False) -> None: + """Cancel work and release resources; waiting is intended for tests only.""" + if not self._closed: + self._closed = True + self._stop.set() + self._threads.submit(self._dispose) + self._threads.shutdown(wait=wait) + + +class PreviewExecutorCache: + """Retain at most one idle preview executor between dialogs.""" + + def __init__(self, executor_factory: Callable = PreviewExecutor) -> None: + self._executor_factory = executor_factory + self._idle = None + self._leased = {} + self._generation = 0 + self._closed = False + + def acquire(self) -> PreviewExecutor: + """Return an executor owned exclusively by the caller.""" + if self._closed: + raise RuntimeError("Preview executor cache is closed") + executor = self._idle + if executor is None: + executor = self._executor_factory() + else: + self._idle = None + self._leased[executor] = self._generation + return executor + + def release(self, executor: PreviewExecutor, reusable: bool = True) -> None: + """Return an executor to the idle slot or close it.""" + if executor not in self._leased: + return + generation = self._leased.pop(executor) + if ( + self._closed + or not reusable + or generation != self._generation + or self._idle is not None + ): + executor.close() + else: + self._idle = executor + + def reset(self) -> None: + """Discard idle state and reject executors from the previous generation.""" + self._generation += 1 + if self._idle is not None: + self._idle.close() + self._idle = None + + def close(self) -> None: + """Close every owned executor and reject future acquisitions.""" + if self._closed: + return + self._closed = True + self._generation += 1 + executors = list(self._leased) + self._leased.clear() + if self._idle is not None: + executors.append(self._idle) + self._idle = None + for executor in executors: + executor.close() + + +class PreviewController(QC.QObject): + """Keep one active computation and only the latest pending snapshot. + + The owner schedules requests after validating its form. ``mark_dirty`` + prevents older parameters from being presented as current. ``invalidate`` + additionally discards results from an obsolete source or invalid form. + """ + + SIG_RESULT = QC.Signal(object, bool) + SIG_ERROR = QC.Signal(str) + SIG_BUSY = QC.Signal(bool) + + def __init__( + self, + function: Callable, + parent: QC.QObject | None = None, + executor_factory: Callable = PreviewExecutor, + executor_cache: PreviewExecutorCache | None = None, + ) -> None: + super().__init__(parent) + self.function = function + self._executor_factory = executor_factory + self._executor_cache = executor_cache + self._executor = None + self._future = None + self._pending = None + self._active_key = None + self._active_source = None + self._current_result = None + self._epoch = 0 + self._revision = 0 + self._enabled = False + self._timer = QC.QTimer(self) + self._timer.setInterval(25) + self._timer.timeout.connect(self.poll) + + def set_enabled(self, enabled: bool) -> None: + """Enable requests, or cancel all speculative work.""" + if enabled: + self._enabled = True + else: + self.close() + + def mark_dirty(self) -> None: + """Forget pending parameters without publishing older ones as current.""" + self._revision += 1 + self._pending = None + self._current_result = None + + def invalidate(self) -> None: + """Invalidate even provisional results after a source/validity change.""" + self.mark_dirty() + self._epoch += 1 + + def request(self, source: SignalObj | ImageObj, param: DataSet) -> None: + """Snapshot validated data on the GUI thread and enqueue its computation.""" + if not self._enabled: + return + self._revision += 1 + try: + args = copy.deepcopy((source, param)) + except Exception: + self.invalidate() + self.SIG_ERROR.emit(traceback.format_exc()) + return + self._pending = ((self._epoch, self._revision), args, source) + self._current_result = None + if self._future is None: + self._start_pending() + + def _start_pending(self) -> None: + if self._pending is None or not self._enabled: + return + self._active_key, args, self._active_source = self._pending + self._pending = None + try: + if self._executor is None: + if self._executor_cache is None: + self._executor = self._executor_factory() + else: + self._executor = self._executor_cache.acquire() + self._future = self._executor.submit(self.function, args) + except Exception: + self._future = None + self._release_executor(reusable=False) + self.SIG_ERROR.emit(traceback.format_exc()) + return + self._timer.start() + self.SIG_BUSY.emit(True) + + def poll(self) -> None: + """Consume a completed future without waiting or pumping Qt events.""" + if self._future is None or not self._future.done(): + return + future, key = self._future, self._active_key + self._future = None + self._timer.stop() + try: + output = future.result() + except Exception: + output = CompOut(error_msg=traceback.format_exc()) + if self._enabled and key[0] == self._epoch: + current = key[1] == self._revision + if output.error_msg and current: + self.SIG_ERROR.emit(output.error_msg) + elif not output.cancelled and output.result is not None: + if current: + self._current_result = ( + key, + self._active_source, + output, + ) + self.SIG_RESULT.emit(output, current) + if self._pending is not None: + self._start_pending() + else: + self.SIG_BUSY.emit(False) + + def take_current_result(self, source: SignalObj | ImageObj) -> CompOut | None: + """Detach the completed result when it still matches *source*.""" + candidate = self._current_result + self._current_result = None + if candidate is None or not self._enabled: + return None + key, candidate_source, output = candidate + if key != (self._epoch, self._revision) or candidate_source is not source: + return None + return output + + def close(self) -> None: + """Disconnect the view from outstanding work and cancel privately.""" + reusable = self._future is None or self._future.done() + self._enabled = False + self.invalidate() + self._timer.stop() + self._future = None + self._active_source = None + self._current_result = None + self._release_executor(reusable=reusable) + self.SIG_BUSY.emit(False) + + def _release_executor(self, reusable: bool) -> None: + """Release the current executor according to its completion state.""" + executor = self._executor + self._executor = None + if executor is None: + return + if self._executor_cache is None: + executor.close() + else: + self._executor_cache.release(executor, reusable=reusable) diff --git a/datalab/locale/fr/LC_MESSAGES/datalab.po b/datalab/locale/fr/LC_MESSAGES/datalab.po index d4632574..d762d683 100644 --- a/datalab/locale/fr/LC_MESSAGES/datalab.po +++ b/datalab/locale/fr/LC_MESSAGES/datalab.po @@ -2179,6 +2179,9 @@ msgstr "En mode 'pairwise', vous devez sélectionner des objets dans au moins de msgid "In pairwise mode, you need to select the same number of objects in each group." msgstr "En mode 'pairwise', vous devez sélectionner le même nombre d'objets dans chaque groupe." +msgid "A preview source was removed. The processing was cancelled." +msgstr "Un objet source de l'aperçu a été supprimé. Le traitement a été annulé." + #, python-format msgid "Calculating: %s" msgstr "Calcul : %s" @@ -2461,6 +2464,9 @@ msgstr "Seuillage Triangle" msgid "Yen thresholding" msgstr "Seuillage Yen" +msgid "Brightness and contrast" +msgstr "Luminosité et contraste" + msgid "Gamma correction" msgstr "Correction gamma" @@ -3839,6 +3845,30 @@ msgstr "Configuration utilisateur" msgid "Plugins and I/O features" msgstr "Plugins et fonctionnalités d'entrée/sortie" +msgid "Preview source" +msgstr "Source de l'aperçu" + +msgid "Preview disabled" +msgstr "Aperçu désactivé" + +msgid "Computing preview..." +msgstr "Calcul de l'aperçu..." + +msgid "Invalid parameters" +msgstr "Paramètres invalides" + +msgid "Updating preview..." +msgstr "Mise à jour de l'aperçu..." + +msgid "Preview failed" +msgstr "Échec de l'aperçu" + +msgid "This result cannot be previewed." +msgstr "Ce résultat ne peut pas être prévisualisé." + +msgid "Preview up to date" +msgstr "Aperçu à jour" + msgid "Kernel / Mask preview" msgstr "Aperçu du noyau / masque" diff --git a/datalab/tests/backbone/preview_executor_unit_test.py b/datalab/tests/backbone/preview_executor_unit_test.py new file mode 100644 index 00000000..b4985520 --- /dev/null +++ b/datalab/tests/backbone/preview_executor_unit_test.py @@ -0,0 +1,162 @@ +"""Windows-compatible process lifecycle for speculative computations.""" + +from __future__ import annotations + +import multiprocessing +import os +import time + +import numpy as np +from sigima.objects import create_signal +from sigima.params import GaussianParam +from sigima.proc.signal import gaussian_filter + +from datalab.gui.processor import base +from datalab.gui.processor.preview import PreviewExecutor, PreviewExecutorCache + + +class FakePreviewExecutor: + """Record cache lifecycle operations without starting processes.""" + + def __init__(self): + self.close_count = 0 + + def close(self): + """Record resource disposal.""" + self.close_count += 1 + + +class ExecutorFactory: + """Create and retain fake executors for assertions.""" + + def __init__(self): + self.executors = [] + + def __call__(self): + executor = FakePreviewExecutor() + self.executors.append(executor) + return executor + + +def slow_identity(source, started): + """Represent an expensive computation that must be cancellable.""" + started.send(True) + time.sleep(30) + return source + + +def get_process_id(): + """Return the spawned worker process identifier.""" + return os.getpid() + + +def test_cache_reuses_one_idle_executor(): + """An idle executor is reused and never leased to two callers.""" + factory = ExecutorFactory() + cache = PreviewExecutorCache(factory) + + first = cache.acquire() + second = cache.acquire() + assert first is not second + cache.release(first) + cache.release(second) + assert first.close_count == 0 + assert second.close_count == 1 + + assert cache.acquire() is first + cache.release(first) + cache.release(first) + assert first.close_count == 0 + cache.close() + cache.close() + assert first.close_count == 1 + + +def test_cache_reset_rejects_previous_generation(): + """Reset closes idle state and prevents late returns from repopulating it.""" + factory = ExecutorFactory() + cache = PreviewExecutorCache(factory) + + leased = cache.acquire() + cache.reset() + cache.release(leased) + assert leased.close_count == 1 + + idle = cache.acquire() + cache.release(idle) + cache.reset() + assert idle.close_count == 1 + replacement = cache.acquire() + assert replacement not in (leased, idle) + + cache.close() + assert replacement.close_count == 1 + try: + cache.acquire() + except RuntimeError: + pass + else: + raise AssertionError("Closed cache accepted an acquisition") + + +def test_cache_reuses_spawned_process(): + """Completed leases preserve the process across preview sessions.""" + cache = PreviewExecutorCache() + production_pool = base.POOL + executor = cache.acquire() + try: + first = executor.submit(get_process_id, ()).result(timeout=60) + assert not first.error_msg, first.error_msg + cache.release(executor) + + reused = cache.acquire() + assert reused is executor + second = reused.submit(get_process_id, ()).result(timeout=60) + assert not second.error_msg, second.error_msg + assert second.result == first.result + cache.release(reused) + assert base.POOL is production_pool + finally: + cache.close() + executor.close(wait=True) + + +def test_private_pool_result_and_cleanup(): + """Full-resolution data survive spawn and the production pool is untouched.""" + source = create_signal("Source", np.arange(100.0), np.sin(np.arange(100.0))) + param = GaussianParam.create(sigma=2.0) + production_pool = base.POOL + executor = PreviewExecutor() + receiver, sender = multiprocessing.Pipe(duplex=False) + try: + failed = executor.submit(lambda: None, ()).result(timeout=60) + assert failed.error_msg + output = executor.submit(gaussian_filter, (source, param)).result(timeout=60) + assert not output.error_msg, output.error_msg + np.testing.assert_allclose(output.result.y, gaussian_filter(source, param).y) + assert output.result.y.shape == source.y.shape + future = executor.submit(slow_identity, (source, sender)) + assert receiver.poll(10) + assert receiver.recv() is True + start = time.monotonic() + executor.close() + assert time.monotonic() - start < 1.0 + executor.close(wait=True) + assert time.monotonic() - start < 10.0 + assert future.result().cancelled + assert executor._pool is None + assert base.POOL is production_pool + finally: + executor.close(wait=True) + receiver.close() + sender.close() + + +def test_close_during_startup(): + """Closing immediately is idempotent, including a not-yet-created pool.""" + executor = PreviewExecutor() + future = executor.submit(abs, (-1,)) + executor.close() + executor.close(wait=True) + assert future.done() + assert executor._pool is None diff --git a/datalab/tests/features/common/processing_preview_unit_test.py b/datalab/tests/features/common/processing_preview_unit_test.py new file mode 100644 index 00000000..c7310a4e --- /dev/null +++ b/datalab/tests/features/common/processing_preview_unit_test.py @@ -0,0 +1,671 @@ +"""Speculative requests cannot publish workspace objects or stale parameters.""" + +from __future__ import annotations + +from concurrent.futures import Future + +import numpy as np +import pytest +from guidata.qthelpers import qt_app_context +from qtpy import QtWidgets as QW +from sigima.objects import create_signal +from sigima.params import GaussianParam +from sigima.proc.signal import gaussian_filter + +from datalab.gui.processor.catcher import CompOut +from datalab.gui.processor.preview import PreviewController, PreviewExecutorCache +from datalab.objectmodel import set_number +from datalab.widgets.processingpreview import ProcessingPreviewDialog + + +class FakeExecutor: + """Manually completed tasks make request ordering deterministic.""" + + def __init__(self): + self.requests = [] + self.closed = False + + def submit(self, function, args): + future = Future() + self.requests.append((future, function, args)) + return future + + def close(self): + self.closed = True + + +@pytest.fixture(autouse=True) +def drain_pending_qt_timers(): + """Prevent unattended close timers from affecting the next preview test.""" + yield + if QW.QApplication.instance() is not None: + for _index in range(3): + QW.QApplication.processEvents() + + +def test_preview_latest_request_and_invalidation(): + """Only the latest pending request runs; invalid data discards old results.""" + with qt_app_context(): + executor = FakeExecutor() + controller = PreviewController( + gaussian_filter, executor_factory=lambda: executor + ) + outputs = [] + errors = [] + controller.SIG_RESULT.connect( + lambda result, current: outputs.append((result, current)) + ) + controller.SIG_ERROR.connect(errors.append) + source = create_signal("Source", np.arange(10.0), np.arange(10.0)) + param = GaussianParam.create(sigma=1.0) + controller.request(source, param) + assert not executor.requests + controller.set_enabled(True) + controller.request(source, param) + param.sigma = 2.0 + controller.request(source, param) + param.sigma = 3.0 + controller.request(source, param) + assert len(executor.requests) == 1 + assert executor.requests[0][2][1].sigma == 1.0 + executor.requests[0][2][0].y[:] = 99 + assert not np.all(source.y == 99) + executor.requests[0][0].set_result(CompOut(result=source.copy())) + controller.poll() + assert outputs[-1][1] is False + assert len(executor.requests) == 2 + assert executor.requests[-1][2][1].sigma == 3.0 + controller.invalidate() + executor.requests[-1][0].set_result(CompOut(error_msg="obsolete")) + controller.poll() + assert not errors + assert len(outputs) == 1 + controller.request(source, param) + executor.requests[-1][0].set_result(CompOut(result=source.copy())) + controller.poll() + assert outputs[-1][1] is True + assert controller.take_current_result(source) is outputs[-1][0] + assert controller.take_current_result(source) is None + controller.request(source, param) + executor.requests[-1][0].set_result(CompOut(result=source.copy())) + controller.poll() + controller.mark_dirty() + assert controller.take_current_result(source) is None + controller.close() + assert executor.closed + + +def test_controller_caches_only_completed_executors(): + """Completed work is reusable while active work keeps cancellation semantics.""" + with qt_app_context(): + executors = [] + + def create_executor(): + executor = FakeExecutor() + executors.append(executor) + return executor + + cache = PreviewExecutorCache(create_executor) + source = create_signal("Source", np.arange(10.0), np.arange(10.0)) + param = GaussianParam.create(sigma=1.0) + + completed = PreviewController(gaussian_filter, executor_cache=cache) + completed.set_enabled(True) + completed.request(source, param) + executors[0].requests[0][0].set_result(CompOut(result=source.copy())) + completed.close() + + active = PreviewController(gaussian_filter, executor_cache=cache) + active.set_enabled(True) + active.request(source, param) + assert len(executors) == 1 + active.close() + assert executors[0].closed + + replacement = PreviewController(gaussian_filter, executor_cache=cache) + replacement.set_enabled(True) + replacement.request(source, param) + assert len(executors) == 2 + replacement.close() + cache.close() + + +def test_dialog_is_opt_in_and_transactional(): + """Editing and rendering stay private until OK, including a source switch.""" + from sigima.objects import create_image + + with qt_app_context(): + executor = FakeExecutor() + source = create_signal("Source", np.arange(10.0), np.arange(10.0)) + second = create_signal("Other", np.arange(10.0), np.zeros(10)) + set_number(source, 1) + set_number(second, 2) + param = GaussianParam.create(sigma=1.0) + dialog = ProcessingPreviewDialog( + param, + gaussian_filter, + [source, second], + controller_factory=lambda function, parent: PreviewController( + function, parent, executor_factory=lambda: executor + ), + ) + assert not executor.requests + assert dialog.preview.plotwidget is not None + assert not dialog.preview.plotwidget.isHidden() + assert not dialog.preview.disabled_overlay.isHidden() + np.testing.assert_allclose(dialog.preview.item.get_data()[1], source.y) + field = dialog.edit_layout.get_terminal_widgets()[0] + field.edit.setText("2.5") + assert param.sigma == 1.0 + dialog.preview.enabled.setChecked(True) + assert dialog.preview.disabled_overlay.isHidden() + assert len(executor.requests) == 1 + assert executor.requests[0][2][1].sigma == 2.5 + result = gaussian_filter(source, dialog.instance) + executor.requests[0][0].set_result(CompOut(result=result)) + dialog.preview.controller.poll() + np.testing.assert_allclose(dialog.preview.item.get_data()[1], result.y) + dialog.preview.source_combo.setCurrentIndex(1) + assert dialog.instance.sigma == 2.5 + dialog.preview._timer.stop() + dialog.preview._request() + np.testing.assert_array_equal(executor.requests[-1][2][0].y, second.y) + dialog.preview._show_result( + CompOut(result=create_image("Image", np.arange(12.0).reshape(3, 4))), True + ) + np.testing.assert_array_equal( + dialog.preview.item.data, np.arange(12.0).reshape(3, 4) + ) + dialog.preview.enabled.setChecked(False) + assert not dialog.preview.plotwidget.isHidden() + assert not dialog.preview.disabled_overlay.isHidden() + np.testing.assert_array_equal( + dialog.preview.item.data, np.arange(12.0).reshape(3, 4) + ) + dialog.reject() + assert param.sigma == 1.0 + assert executor.closed + assert not dialog.preview._timer.isActive() + accepted = ProcessingPreviewDialog(param, gaussian_filter, [source]) + accepted.edit_layout.get_terminal_widgets()[0].edit.setText("3.5") + accepted.accept() + assert param.sigma == 3.5 + assert accepted.preview.controller._executor is None + + +def test_preview_busy_overlay_tracks_request_queue(): + """Delayed progress avoids flicker and covers the full request queue.""" + from qtpy.QtTest import QTest + + with qt_app_context(): + executor = FakeExecutor() + source = create_signal("Source", np.arange(10.0), np.arange(10.0)) + set_number(source, 1) + dialog = ProcessingPreviewDialog( + GaussianParam.create(sigma=1.0), + gaussian_filter, + [source], + controller_factory=lambda function, parent: PreviewController( + function, parent, executor_factory=lambda: executor + ), + ) + preview = dialog.preview + assert preview.busy_overlay.isHidden() + assert preview.busy_progress.minimum() == 0 + assert preview.busy_progress.maximum() == 0 + + preview.enabled.setChecked(True) + assert preview.busy_overlay.isHidden() + assert preview._busy_overlay_timer.isActive() + executor.requests[0][0].set_result(CompOut(result=source.copy())) + preview.controller.poll() + assert preview.busy_overlay.isHidden() + assert not preview._busy_overlay_timer.isActive() + QTest.qWait(preview._busy_overlay_timer.interval() + 50) + assert preview.busy_overlay.isHidden() + + field = dialog.edit_layout.get_terminal_widgets()[0] + field.edit.setText("2.0") + preview._timer.stop() + preview._request() + assert preview.busy_overlay.isHidden() + QTest.qWait(preview._busy_overlay_timer.interval() + 50) + assert not preview.busy_overlay.isHidden() + + field.edit.setText("3.0") + preview._timer.stop() + preview._request() + executor.requests[1][0].set_result(CompOut(result=source.copy())) + preview.controller.poll() + assert len(executor.requests) == 3 + assert not preview.busy_overlay.isHidden() + + executor.requests[2][0].set_result(CompOut(result=source.copy())) + preview.controller.poll() + assert preview.busy_overlay.isHidden() + + field.edit.setText("4.0") + preview._timer.stop() + preview._request() + preview._show_busy_overlay() + assert not preview.busy_overlay.isHidden() + executor.requests[3][0].set_result(CompOut(error_msg="preview error")) + preview.controller.poll() + assert preview.busy_overlay.isHidden() + assert not preview.details.isHidden() + + field.edit.setText("5.0") + preview._timer.stop() + preview._request() + assert preview._busy_overlay_timer.isActive() + preview.close_preview() + assert preview.busy_overlay.isHidden() + assert not preview._busy_overlay_timer.isActive() + assert executor.closed + dialog.reject() + + +def test_processor_cancel_and_accept(monkeypatch): + """Cancel keeps defaults and objects; OK uses normal processing for the lot.""" + from datalab.config import Conf + from datalab.tests import datalab_test_app_context + from datalab.widgets import processingpreview + + with qt_app_context(), Conf.process_isolation_enabled.context(False): + with datalab_test_app_context(history=True) as window: + panel = window.signalpanel + source = create_signal("Source", np.arange(20.0), np.sin(np.arange(20.0))) + other = create_signal("Other", np.arange(20.0), np.cos(np.arange(20.0))) + panel.add_object(source) + panel.add_object(other) + panel.objview.select_objects([1, 2]) + window.historypanel.toggle_record_mode(True) + history_count = len(window.historypanel) + processor = panel.processor + defaults = GaussianParam.create(sigma=1.2) + monkeypatch.setitem(processor.PARAM_DEFAULTS, "GaussianParam", defaults) + before = panel.objmodel.get_object_ids() + + def reject(dialog): + assert len(dialog.preview.sources) == 2 + dialog.edit_layout.get_terminal_widgets()[0].edit.setText("4.0") + dialog.reject() + return 0 + + monkeypatch.setattr(processingpreview, "exec_dialog", reject) + processor.run_feature("gaussian_filter") + assert panel.objmodel.get_object_ids() == before + assert len(window.historypanel) == history_count + assert processor.PARAM_DEFAULTS["GaussianParam"] is defaults + assert defaults.sigma == 1.2 + + def accept(dialog): + dialog.edit_layout.get_terminal_widgets()[0].edit.setText("2.0") + dialog.preview.source_combo.setCurrentIndex(1) + panel.objview.select_objects([2]) + dialog.accept() + return 1 + + monkeypatch.setattr(processingpreview, "exec_dialog", accept) + processor.run_feature("gaussian_filter") + assert len(panel.objmodel) == 4 + assert len(window.historypanel) == history_count + 1 + assert processor.PARAM_DEFAULTS["GaussianParam"].sigma == 2.0 + results = [ + panel.objmodel[uid] for uid in panel.objmodel.get_object_ids()[2:] + ] + for original, result in zip([source, other], results): + np.testing.assert_allclose( + result.y, gaussian_filter(original, sigma=2.0).y + ) + remembered = processor.PARAM_DEFAULTS["GaussianParam"] + processor.run_feature( + "gaussian_filter", GaussianParam.create(sigma=5.0), edit=False + ) + assert processor.PARAM_DEFAULTS["GaussianParam"] is remembered + assert remembered.sigma == 2.0 + + +def test_processor_reuses_completed_executor_between_dialogs(monkeypatch): + """Successive processor dialogs share the window's idle executor.""" + from datalab.config import Conf + from datalab.tests import datalab_test_app_context + from datalab.widgets import processingpreview + + executor = FakeExecutor() + with qt_app_context(), Conf.process_isolation_enabled.context(False): + with datalab_test_app_context() as window: + panel = window.signalpanel + source = create_signal("Source", np.arange(20.0), np.sin(np.arange(20.0))) + panel.add_object(source) + window.preview_executor_cache.reset() + window.preview_executor_cache._executor_factory = lambda: executor + + def complete_and_reject(dialog): + dialog.preview.enabled.setChecked(True) + future = executor.requests[-1][0] + future.set_result( + CompOut(result=gaussian_filter(source, dialog.instance)) + ) + dialog.preview.controller.poll() + dialog.reject() + return 0 + + monkeypatch.setattr(processingpreview, "exec_dialog", complete_and_reject) + for sigma in (1.0, 2.0): + panel.processor.compute_1_to_1( + gaussian_filter, + param=GaussianParam.create(sigma=sigma), + title="Gaussian filter", + edit=True, + ) + + assert len(executor.requests) == 2 + assert not executor.closed + assert executor.closed + + +def test_processor_reuses_only_current_single_object_preview(monkeypatch): + """OK reuses one current preview but computes a multi-selection normally.""" + from datalab.config import Conf + from datalab.tests import datalab_test_app_context + from datalab.widgets import processingpreview + + with qt_app_context(), Conf.process_isolation_enabled.context(False): + with datalab_test_app_context(history=True) as window: + panel = window.signalpanel + source = create_signal("Source", np.arange(20.0), np.sin(np.arange(20.0))) + panel.add_object(source) + executor = FakeExecutor() + window.preview_executor_cache.reset() + window.preview_executor_cache._executor_factory = lambda: executor + nominal_calls = [] + + def counted_filter(src, param): + nominal_calls.append((src, param)) + return gaussian_filter(src, param) + + def accept_current_preview(dialog): + dialog.preview.enabled.setChecked(True) + assert len(executor.requests) == 1 + preview_result = gaussian_filter(source, dialog.instance) + executor.requests[0][0].set_result(CompOut(result=preview_result)) + dialog.preview.controller.poll() + dialog.accept() + return 1 + + monkeypatch.setattr( + processingpreview, "exec_dialog", accept_current_preview + ) + window.historypanel.toggle_record_mode(True) + history_count = len(window.historypanel) + panel.processor.compute_1_to_1( + counted_filter, + param=GaussianParam.create(sigma=2.0), + title="Gaussian filter", + edit=True, + ) + + assert nominal_calls == [] + assert len(panel.objmodel) == 2 + assert len(window.historypanel) == history_count + 1 + result = panel.objmodel[panel.objmodel.get_object_ids()[-1]] + np.testing.assert_allclose(result.y, gaussian_filter(source, sigma=2.0).y) + + other = create_signal("Other", np.arange(20.0), np.cos(np.arange(20.0))) + panel.add_object(other) + panel.objview.select_objects([source, other]) + multi_executor = FakeExecutor() + window.preview_executor_cache.reset() + window.preview_executor_cache._executor_factory = lambda: multi_executor + + def accept_multi_preview(dialog): + dialog.preview.enabled.setChecked(True) + assert len(multi_executor.requests) == 1 + preview_source = dialog.preview.sources[0] + preview_result = gaussian_filter(preview_source, dialog.instance) + multi_executor.requests[0][0].set_result(CompOut(result=preview_result)) + dialog.preview.controller.poll() + dialog.accept() + return 1 + + monkeypatch.setattr(processingpreview, "exec_dialog", accept_multi_preview) + panel.processor.compute_1_to_1( + counted_filter, + param=GaussianParam.create(sigma=3.0), + title="Gaussian filter", + edit=True, + ) + + assert len(nominal_calls) == 2 + assert nominal_calls[0][0] is source + assert nominal_calls[1][0] is other + + +def test_special_dialog_preserves_counters_and_validation(): + """The special form keeps its decorations and integer-image guard.""" + from sigima.objects import create_image + from sigima.proc.signal import replace_special_values + + from datalab.widgets.replacespecialvalues import ( + ReplaceSpecialValuesImageParamDL, + ReplaceSpecialValuesSignalParamDL, + ) + + with qt_app_context(): + source = create_signal("Source", np.arange(5.0), np.array([1, np.nan, 2, 3, 4])) + set_number(source, 1) + param = ReplaceSpecialValuesSignalParamDL() + param.update_from_obj(source) + dialog = param.create_dialog() + dialog.attach_preview(replace_special_values, [source]) + assert "1" in dialog._count_badges["nan"].text() + assert dialog.preview.editor is dialog.edit_layout + assert not dialog.preview.enabled.isChecked() + assert len(dialog._kernel_previews) == 3 + dialog.reject() + image = create_image("Integer", np.ones((4, 4), dtype=np.uint16)) + set_number(image, 1) + image_param = ReplaceSpecialValuesImageParamDL() + image_param.update_from_obj(image) + blocked = image_param.create_dialog() + blocked.attach_preview(replace_special_values, [image]) + assert not blocked.preview.enabled.isEnabled() + blocked.accept() + assert blocked.result() == 0 + blocked.reject() + + +def test_processing_tab_debounces_valid_released_editor(monkeypatch): + """A drag or invalid field never applies, and old editors cannot restart it.""" + from datalab.config import Conf + from datalab.tests import datalab_test_app_context + + with qt_app_context(), Conf.process_isolation_enabled.context(False): + with datalab_test_app_context() as window: + panel = window.signalpanel + panel.add_object( + create_signal("Source", np.arange(20.0), np.sin(np.arange(20.0))) + ) + panel.processor.run_feature( + "gaussian_filter", GaussianParam.create(sigma=1.0) + ) + prop = panel.objprop + editor = prop.processing_param_editor + applied = [] + editor.SIG_APPLY_BUTTON_CLICKED.disconnect() + editor.SIG_APPLY_BUTTON_CLICKED.connect(lambda: applied.append(True)) + auto_cb = editor.findChild(QW.QCheckBox, "auto_recompute_on_edit") + assert auto_cb is not None + assert not auto_cb.icon().isNull() + form_layout = editor.edit.layout + auto_row, auto_column, _row_span, auto_column_span = ( + form_layout.getItemPosition(form_layout.indexOf(auto_cb)) + ) + apply_row, _column, _row_span, _column_span = form_layout.getItemPosition( + form_layout.indexOf(editor.apply_button) + ) + field = editor.edit.get_terminal_widgets()[0] + _row, field_column, _row_span, _column_span = form_layout.getItemPosition( + form_layout.indexOf(field.group) + ) + assert auto_row == apply_row + 1 + assert auto_column == field_column + assert auto_column_span == form_layout.columnCount() - auto_column + auto_cb.setChecked(True) + timer = prop._ObjectProp__auto_recompute_timer + field.edit.setText("2.0") + assert timer.isActive() + editor._slider_gesture(True) + assert not timer.isActive() + field.edit.setText("3.0") + assert not timer.isActive() + editor._slider_gesture(False) + assert timer.isActive() + field.edit.setText("-") + assert not timer.isActive() + editor.set() + assert not applied + field.edit.setText("4.0") + timer.stop() + prop._ObjectProp__auto_recompute_trigger() + assert applied == [True] + assert editor.dataset.sigma == 4.0 + field.edit.setText("5.0") + assert timer.isActive() + panel.objview.select_objects([1]) + assert not timer.isActive() + editor.change_callback() + assert not timer.isActive() + + +def test_preview_preserves_custom_editors_and_backends(monkeypatch): + """Unknown editors, alternate backends and feature vetoes retain their path.""" + from guidata.dataset import backends + + from datalab.widgets.processingpreview import edit_processing_parameters + + called = [] + + class CustomParam(GaussianParam): + def edit(self, parent=None): + called.append(self) + return 0 + + custom = CustomParam() + assert not edit_processing_parameters(custom, gaussian_filter, [], None) + assert called == [custom] + param = GaussianParam() + monkeypatch.setattr(param, "edit", lambda **kwargs: called.append(param) or 1) + assert edit_processing_parameters(param, gaussian_filter, [], None, False) + original = backends.get_handler("edit_dataset") + try: + backends.set_handler( + "edit_dataset", lambda instance, **kwargs: called.append(instance) or 1 + ) + other = GaussianParam() + assert edit_processing_parameters(other, gaussian_filter, [], None) + assert called[-1] is other + finally: + if original is None: + backends.clear_handler("edit_dataset") + else: + backends.set_handler("edit_dataset", original) + + +def test_live_image_to_signal_preview(tmp_path, monkeypatch): + """A Qt click drives a real spawn round-trip into a rendered PlotPy curve.""" + from qtpy import QtCore as QC + from qtpy import QtWidgets as QW + from qtpy.QtTest import QTest + from sigima.objects import create_image, create_image_roi + from sigima.proc.image import LineProfileParam, line_profile + + monkeypatch.setattr( + "guidata.qthelpers.close_widgets_and_quit", lambda **kwargs: None + ) + monkeypatch.setattr( + "sigimax.utils.qthelpers.close_widgets_and_quit", lambda **kwargs: None + ) + with qt_app_context(): + QW.QApplication.processEvents() + source = create_image("Image", np.arange(120, dtype=np.uint16).reshape(10, 12)) + source.roi = create_image_roi("rectangle", [2, 1, 8, 8], indices=True) + source.x0, source.y0, source.dx, source.dy = 10.0, -4.0, 0.25, 0.5 + source.xlabel, source.xunit = "Position", "mm" + source.zlabel, source.zunit = "Intensity", "a.u." + set_number(source, 1) + param = LineProfileParam.create(direction="horizontal", row=3) + expected = line_profile( + source.copy(), LineProfileParam.create(direction="horizontal", row=3) + ) + assert expected.x.size == 8 + dialog = ProcessingPreviewDialog(param, line_profile, [source]) + loop = QW.QApplication.instance() + timeout = QC.QTimer() + timeout.setSingleShot(True) + timeout.timeout.connect(loop.quit) + dialog.preview.controller.SIG_RESULT.connect(loop.quit) + dialog.preview.controller.SIG_ERROR.connect(loop.quit) + dialog.show() + executor = None + try: + assert dialog.preview.controller._executor is None + QTest.mouseClick( + dialog.preview.enabled, + QC.Qt.LeftButton, + pos=QC.QPoint(8, dialog.preview.enabled.height() // 2), + ) + assert dialog.preview.enabled.isChecked() + executor = dialog.preview.controller._executor + assert executor is not None + timeout.start(30000) + loop.exec_() + timeout.stop() + assert dialog.preview.item is not None, dialog.preview.details.toPlainText() + QW.QApplication.processEvents() + assert not dialog.preview.plotwidget.isHidden() + np.testing.assert_allclose(dialog.preview.item.get_data()[0], expected.x) + np.testing.assert_allclose(dialog.preview.item.get_data()[1], expected.y) + assert source.data.dtype == np.uint16 + np.testing.assert_array_equal(source.data, np.arange(120).reshape(10, 12)) + assert param.row == 3 + assert dialog.grab().save(str(tmp_path / "processing-preview.png")) + finally: + timeout.stop() + dialog.reject() + if executor is not None: + executor.close(wait=True) + + +@pytest.mark.parametrize("edit_mode", [False, True]) +def test_processing_apply_preserves_history_modes(edit_mode): + """Apply creates a result normally, but edits in place in history edit mode.""" + from datalab.config import Conf + from datalab.objectmodel import get_uuid + from datalab.tests import datalab_test_app_context + + with qt_app_context(), Conf.process_isolation_enabled.context(False): + with datalab_test_app_context(history=True) as window: + panel = window.signalpanel + source = create_signal("Source", np.arange(30.0), np.sin(np.arange(30.0))) + panel.add_object(source) + window.historypanel.toggle_record_mode(True) + panel.processor.run_feature( + "gaussian_filter", GaussianParam.create(sigma=1.0) + ) + original = panel.objview.get_current_object() + original_id = get_uuid(original) + history_count = len(window.historypanel) + window.historypanel.toggle_edit_mode(edit_mode) + editor = panel.objprop.processing_param_editor + editor.edit.get_terminal_widgets()[0].edit.setText("3.0") + editor.set() + assert len(panel.objmodel) == (2 if edit_mode else 3) + assert len(window.historypanel) == history_count + (0 if edit_mode else 1) + result = ( + panel.objmodel[original_id] + if edit_mode + else panel.objview.get_current_object() + ) + np.testing.assert_allclose(result.y, gaussian_filter(source, sigma=3.0).y) diff --git a/datalab/tests/features/image/brightness_contrast_app_test.py b/datalab/tests/features/image/brightness_contrast_app_test.py new file mode 100644 index 00000000..d7a78b0c --- /dev/null +++ b/datalab/tests/features/image/brightness_contrast_app_test.py @@ -0,0 +1,254 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Brightness and contrast application integration test.""" + +from __future__ import annotations + +import os.path as osp + +import numpy as np +import sigima.params +from guidata.dataset.qtitemwidgets import HistogramRangeWidget +from guidata.dataset.qtwidgets import DataSetEditDialog +from qtpy.QtCore import Qt +from qtpy.QtTest import QTest +from sigima.objects import create_image + +from datalab.gui.actionhandler import SelectCond +from datalab.gui.processor.base import extract_processing_parameters +from datalab.objectmodel import get_uuid +from datalab.tests import datalab_test_app_context, helpers + + +def test_brightness_contrast_integration() -> None: + """One parameter window produces an independent result for every source.""" + with datalab_test_app_context(console=False) as win: + panel = win.imagepanel + processor = panel.processor + feature = processor.get_feature("adjust_brightness_contrast") + assert feature.preview_enabled + + source_data = np.array([[0, 64, 128, 255]], dtype=np.uint8) + source = create_image("Source", source_data) + other_data = source_data.astype(np.uint16) + other = create_image("Other", other_data) + panel.add_object(source) + panel.add_object(other) + + managed_actions = panel.acthandler._BaseActionHandler__actions + action = next( + action + for action in managed_actions[SelectCond.at_least_one] + if action.text().startswith("Brightness and contrast") + ) + + panel.objview.select_objects([source, other]) + assert action.isEnabled() + param = sigima.params.BrightnessContrastParam() + param.update_from_obj(source) + param.minimum, param.maximum = 64.0, 192.0 + processor.run_feature(feature, param, edit=False) + + first_result, second_result = panel.objmodel.get_all_objects()[-2:] + np.testing.assert_array_equal(first_result.data, [[0, 0, 128, 255]]) + np.testing.assert_array_equal(second_result.data, [[0, 0, 32768, 65535]]) + assert extract_processing_parameters(first_result).source_uuid == get_uuid( + source + ) + assert extract_processing_parameters(second_result).source_uuid == get_uuid( + other + ) + np.testing.assert_array_equal(source.data, source_data) + np.testing.assert_array_equal(other.data, other_data) + + assert panel.objprop.setup_processing_tab(first_result) + edited = panel.objprop.processing_param_editor.dataset + assert (edited.minimum, edited.maximum) == (64.0, 192.0) + assert edited.histogram["domain"] == [0.0, 255.0] + + +def test_brightness_contrast_h5_roundtrip_and_missing_source() -> None: + """Saved bounds remain inspectable after reload and source deletion.""" + with helpers.WorkdirRestoringTempDir() as tmpdir: + with datalab_test_app_context(console=False) as win: + panel = win.imagepanel + source = create_image( + "Source", np.array([[0, 64, 128, 255]], dtype=np.uint8) + ) + panel.add_object(source) + param = sigima.params.BrightnessContrastParam() + param.update_from_obj(source) + param.minimum, param.maximum = 64.0, 192.0 + feature = panel.processor.get_feature("adjust_brightness_contrast") + panel.processor.run_feature(feature, param, edit=False) + result = panel.objmodel.get_all_objects()[-1] + source_uuid = get_uuid(source) + result_uuid = get_uuid(result) + + filename = osp.join(tmpdir, "brightness_contrast.h5") + win.save_h5_workspace(filename) + panel.remove_all_objects() + win.load_h5_workspace([filename], reset_all=True) + + loaded_source = win.find_object_by_uuid(source_uuid) + loaded_result = win.find_object_by_uuid(result_uuid) + assert loaded_source is not None + assert loaded_result is not None + assert panel.objprop.setup_processing_tab(loaded_result) + restored = panel.objprop.processing_param_editor.dataset + assert (restored.minimum, restored.maximum) == (64.0, 192.0) + assert restored.histogram["domain"] == [0.0, 255.0] + + panel.objview.set_current_object(loaded_source) + panel.remove_object(force=True) + assert panel.objprop.setup_processing_tab(loaded_result) + unavailable = panel.objprop.processing_param_editor.dataset + assert (unavailable.minimum, unavailable.maximum) == (64.0, 192.0) + assert unavailable.histogram == {} + report = panel.objprop.apply_processing_parameters(interactive=False) + assert not report.success + assert "no longer exists" in report.message.lower() + + +def test_grouped_brightness_contrast_uses_each_source_output_range() -> None: + """A grouped batch shares input bounds but keeps per-image output ranges.""" + with datalab_test_app_context(console=False) as win: + panel = win.imagepanel + source_group = panel.add_group("Images") + float_data = np.array([[0.0, 0.25, 0.5, 1.0]], dtype=np.float32) + integer_data = np.array([[0, 64, 128, 255]], dtype=np.uint8) + float_source = create_image("Float", float_data) + integer_source = create_image("Integer", integer_data) + group_id = get_uuid(source_group) + panel.add_object(float_source, group_id=group_id) + panel.add_object(integer_source, group_id=group_id) + panel.objview.select_groups([source_group]) + + param = sigima.params.BrightnessContrastParam() + param.update_from_obj(float_source) + param.minimum, param.maximum = 0.25, 0.75 + feature = panel.processor.get_feature("adjust_brightness_contrast") + panel.processor.run_feature(feature, param, edit=False) + + result_group = panel.objmodel.get_groups()[-1] + float_result, integer_result = result_group.get_objects() + np.testing.assert_allclose(float_result.data, [[0.0, 0.0, 0.5, 1.0]]) + np.testing.assert_array_equal(integer_result.data, [[0, 255, 255, 255]]) + assert float_result.data.dtype == np.float32 + assert integer_result.data.dtype == np.uint8 + assert extract_processing_parameters(float_result).source_uuid == get_uuid( + float_source + ) + assert extract_processing_parameters(integer_result).source_uuid == get_uuid( + integer_source + ) + np.testing.assert_array_equal(float_source.data, float_data) + np.testing.assert_array_equal(integer_source.data, integer_data) + + +def test_brightness_contrast_reapply_preserves_narrow_float64_range() -> None: + """An unchanged Processing editor round-trips exact narrow bounds.""" + with datalab_test_app_context(console=False) as win: + panel = win.imagepanel + source = create_image( + "Narrow", + np.array([[1.0, 1.00000000000025, 1.0000000000005]]), + ) + panel.add_object(source) + param = sigima.params.BrightnessContrastParam() + param.update_from_obj(source) + expected_range = (1.0000000000001, 1.0000000000004) + param.minimum, param.maximum = expected_range + feature = panel.processor.get_feature("adjust_brightness_contrast") + panel.processor.run_feature(feature, param, edit=False) + first_result = panel.objmodel.get_all_objects()[-1] + + assert panel.objprop.setup_processing_tab(first_result) + editor = panel.objprop.processing_param_editor + assert editor is not None + assert (editor.dataset.minimum, editor.dataset.maximum) == expected_range + editor.set() + + reapplied_result = panel.objmodel.get_all_objects()[-1] + reapplied_param = extract_processing_parameters(reapplied_result).param + assert (reapplied_param.minimum, reapplied_param.maximum) == expected_range + + +def test_brightness_contrast_edit_outside_float64_domain(): + """Actual input and Apply preserve the exact next representable bound.""" + with datalab_test_app_context(console=False) as win: + win.set_current_panel("image") + panel = win.imagepanel + data = np.array([[0.0, 0.5, 1.0]]) + source = create_image("Float source", data.copy()) + panel.add_object(source) + param = sigima.params.BrightnessContrastParam() + param.update_from_obj(source) + feature = panel.processor.get_feature("adjust_brightness_contrast") + panel.processor.run_feature(feature, param, edit=False) + result = panel.objmodel.get_all_objects()[-1] + assert panel.objprop.setup_processing_tab(result) + editor = panel.objprop.processing_param_editor + widget = next( + item + for item in editor.edit.get_terminal_widgets() + if isinstance(item, HistogramRangeWidget) + ) + assert widget.presentation == "brightness_contrast" + assert not widget.brightness_slider.isHidden() + assert not widget.contrast_slider.isHidden() + panel.objprop.tabwidget.setCurrentWidget(panel.objprop.processing_scroll) + win.show() + widget.minimum_edit.setFocus() + widget.minimum_edit.selectAll() + QTest.keyClicks(widget.minimum_edit, "2") + assert widget.minimum_edit.text() == "2" + assert widget.group.isEnabled() + QTest.keyClick(widget.minimum_edit, Qt.Key_Return) + assert widget._range() == (2.0, np.nextafter(2.0, np.inf)) + editor.set() + reapplied = panel.objmodel.get_all_objects()[-1] + metadata = extract_processing_parameters(reapplied) + assert (metadata.param.minimum, metadata.param.maximum) == widget._range() + assert metadata.source_uuid == get_uuid(source) + np.testing.assert_array_equal(reapplied.data, [[0.0, 0.0, 0.0]]) + np.testing.assert_array_equal(source.data, data) + + +def test_brightness_contrast_cancel_preserves_caller_parameters(monkeypatch): + """The processor owns the transactional copy edited by the Qt dialog.""" + with datalab_test_app_context(console=False) as win: + panel = win.imagepanel + source = create_image("Source", np.array([[0.0, 0.5, 1.0]])) + panel.add_object(source) + param = sigima.params.BrightnessContrastParam() + param.update_from_obj(source) + + def cancel_edit(draft, function, sources, parent, allowed, results, **kwargs): + assert draft is not param + assert allowed + dialog = DataSetEditDialog(draft, parent=parent) + widget = next( + item + for item in dialog.edit_layout[0].get_terminal_widgets() + if isinstance(item, HistogramRangeWidget) + ) + widget._set_range(0.2, 0.8) + dialog.edit_layout[0].accept_changes() + preview = function(sources[0].copy(), draft) + np.testing.assert_allclose(preview.data, [[0.0, 0.5, 1.0]]) + dialog.reject() + return False + + monkeypatch.setattr( + "datalab.widgets.processingpreview.edit_processing_parameters", cancel_edit + ) + feature = panel.processor.get_feature("adjust_brightness_contrast") + panel.processor.run_feature(feature, param, edit=True) + assert (param.minimum, param.maximum) == (0.0, 1.0) + assert panel.objmodel.get_all_objects() == [source] + np.testing.assert_array_equal(source.data, [[0.0, 0.5, 1.0]]) + + +if __name__ == "__main__": + test_brightness_contrast_integration() diff --git a/datalab/tests/features/plugins/plugins_app_test.py b/datalab/tests/features/plugins/plugins_app_test.py index 284cb0e6..baa7bea2 100644 --- a/datalab/tests/features/plugins/plugins_app_test.py +++ b/datalab/tests/features/plugins/plugins_app_test.py @@ -108,9 +108,13 @@ def test_plugin_system(): # pylint: disable=too-many-statements # Trigger reload processor = win.imagepanel.processor assert processor.worker is not None - with patch.object(processor.worker, "restart_pool") as restart_pool: + with ( + patch.object(processor.worker, "restart_pool") as restart_pool, + patch.object(win.preview_executor_cache, "reset") as reset_cache, + ): win.reload_plugins() restart_pool.assert_called_once_with() + reset_cache.assert_called_once_with() QW.QApplication.processEvents() # Verify both plugins are present @@ -383,6 +387,10 @@ def test_plugin_config_disabled(): or "désactivés" in args[0][2].lower() ) + with patch.object(win.preview_executor_cache, "reset") as reset_cache: + win.set_plugins_enabled(False) + reset_cache.assert_called_once_with() + @enabled_plugins_context() def test_plugin_error_handling(): diff --git a/datalab/widgets/processingparameters.py b/datalab/widgets/processingparameters.py new file mode 100644 index 00000000..8e367087 --- /dev/null +++ b/datalab/widgets/processingparameters.py @@ -0,0 +1,45 @@ +"""Processing editors keep Apply semantics with locally enabled sliders.""" + +from __future__ import annotations + +from collections.abc import Callable + +from guidata.dataset.qtwidgets import DataSetEditGroupBox, DataSetEditLayout + +__all__ = ["ProcessingParametersEditor"] + + +class ProcessingParametersEditor(DataSetEditGroupBox): + """Notify the owner through the existing layout callback, without auto-Apply.""" + + def __init__(self, *args, **kwargs): + self.on_change: Callable | None = None + self.dragging = False + super().__init__(*args, **kwargs) + + def get_edit_layout(self) -> DataSetEditLayout: + """Enable presentation options without changing shared DataItems.""" + return DataSetEditLayout( + self, + self.dataset, + self.grid_layout, + change_callback=self.change_callback, + auto_sliders=True, + slider_callback=self._slider_gesture, + ) + + def change_callback(self) -> None: + """Preserve Apply activation, then notify the owning processing tab.""" + super().change_callback() + if self.on_change is not None: + self.on_change() + + def _slider_gesture(self, pressed: bool) -> None: + self.dragging = pressed + if self.on_change is not None: + self.on_change() + + def set(self, check: bool = True) -> None: + """Do not emit Apply while any active field is invalid.""" + if self.edit.check_all_values(): + super().set(check=True) diff --git a/datalab/widgets/processingpreview.py b/datalab/widgets/processingpreview.py new file mode 100644 index 00000000..989ebea1 --- /dev/null +++ b/datalab/widgets/processingpreview.py @@ -0,0 +1,457 @@ +"""Processing parameter forms with opt-in, unpublished live results.""" + +from __future__ import annotations + +import copy +import functools +from collections.abc import Callable, Sequence + +from guidata.configtools import get_icon +from guidata.dataset import DataSet, update_dataset +from guidata.dataset.backends import get_handler +from guidata.dataset.qtwidgets import DataSetEditLayout +from guidata.qthelpers import exec_dialog, win32_fix_title_bar_background +from plotpy.plot import PlotOptions, PlotWidget +from qtpy import QtCore as QC +from qtpy import QtWidgets as QW +from sigima.objects import ImageObj, SignalObj +from sigimax.adapters_plotpy.objects.signal import CURVESTYLES + +from datalab.adapters_plotpy import create_adapter_from_object +from datalab.config import _ +from datalab.gui.processor.catcher import CompOut +from datalab.gui.processor.preview import PreviewController +from datalab.objectmodel import get_short_id, patch_title_with_ids + +__all__ = [ + "ProcessingPreviewDialog", + "ProcessingPreviewWidget", + "edit_processing_parameters", +] + + +def edit_processing_parameters( + instance, + function, + sources, + parent, + preview_enabled=True, + preview_result=None, + executor_cache=None, +) -> bool: + """Preserve alternate backends and custom editors outside the standard form.""" + from datalab.widgets.replacespecialvalues import ( + ReplaceSpecialValuesImageParamDL, + ReplaceSpecialValuesSignalParamDL, + ) + + if not preview_enabled or get_handler("edit_dataset") is not None: + return bool(instance.edit(parent=parent)) + controller_factory = functools.partial( + PreviewController, executor_cache=executor_cache + ) + if type(instance) in ( + ReplaceSpecialValuesSignalParamDL, + ReplaceSpecialValuesImageParamDL, + ): + dialog = instance.create_dialog(parent=parent) + dialog.attach_preview(function, sources, controller_factory) + elif type(instance).edit is DataSet.edit: + dialog = ProcessingPreviewDialog( + instance, function, sources, parent, controller_factory + ) + else: + return bool(instance.edit(parent=parent)) + try: + accepted = bool(exec_dialog(dialog)) + if ( + accepted + and preview_result is not None + and dialog.preview_result is not None + ): + preview_result.append(dialog.preview_result) + return accepted + finally: + dialog.preview.close_preview() + dialog.deleteLater() + + +class ProcessingPreviewWidget(QW.QWidget): + """Reusable preview view, independent of form and workspace ownership.""" + + def __init__( + self, + function: Callable, + sources: Sequence[SignalObj | ImageObj], + parent: QW.QWidget | None = None, + controller_factory: Callable = PreviewController, + ) -> None: + super().__init__(parent) + self.sources = tuple(sources) + self.editor: DataSetEditLayout | None = None + self.controller = controller_factory(function, self) + self._dragging = False + self._plot_kind = None + self._plot_signature = None + self.plotwidget: PlotWidget | None = None + self.item = None + self._timer = QC.QTimer(self) + self._timer.setSingleShot(True) + self._timer.timeout.connect(self._request) + self._layout = QW.QVBoxLayout(self) + self._layout.setContentsMargins(0, 0, 0, 0) + controls = QW.QHBoxLayout() + self.enabled = QW.QCheckBox(_("Preview")) + self.enabled.setObjectName("preview_enabled") + self.enabled.setEnabled(bool(sources)) + controls.addWidget(self.enabled) + self.source_combo = QW.QComboBox() + self.source_combo.setObjectName("preview_source") + self.source_combo.setSizeAdjustPolicy( + QW.QComboBox.AdjustToMinimumContentsLengthWithIcon + ) + self.source_combo.setMinimumContentsLength(16) + self.source_combo.setToolTip(_("Preview source")) + for source in sources: + self.source_combo.addItem(f"{get_short_id(source)}: {source.title}") + self.source_combo.setVisible(len(sources) > 1) + self.source_combo.setEnabled(False) + controls.addWidget(self.source_combo, 1) + self._layout.addLayout(controls) + self._stage = QW.QWidget() + self._stage.setMinimumSize(300, 240) + self._stage_layout = QW.QGridLayout(self._stage) + self._stage_layout.setContentsMargins(0, 0, 0, 0) + self._layout.addWidget(self._stage, 1) + self.disabled_overlay = QW.QFrame(self._stage) + self.disabled_overlay.setObjectName("preview_disabled_overlay") + self.disabled_overlay.setStyleSheet( + "QFrame#preview_disabled_overlay {" + " background-color: rgba(128, 128, 128, 150);" + " border: none;" + "}" + ) + disabled_layout = QW.QVBoxLayout(self.disabled_overlay) + disabled_layout.addStretch() + disabled_icon = QW.QLabel() + disabled_icon.setAlignment(QC.Qt.AlignCenter) + disabled_icon.setPixmap(get_icon("visualization.svg").pixmap(42, 42)) + disabled_layout.addWidget(disabled_icon) + disabled_label = QW.QLabel(_("Preview disabled")) + disabled_label.setAlignment(QC.Qt.AlignCenter) + disabled_label.setStyleSheet("font-weight: 600;") + disabled_layout.addWidget(disabled_label) + disabled_layout.addStretch() + self._stage_layout.addWidget(self.disabled_overlay, 0, 0) + self.busy_overlay = QW.QFrame(self._stage) + self.busy_overlay.setObjectName("preview_busy_overlay") + self.busy_overlay.setStyleSheet( + "QFrame#preview_busy_overlay {" + " background-color: rgba(64, 80, 96, 112);" + " border: none;" + "}" + ) + busy_layout = QW.QVBoxLayout(self.busy_overlay) + busy_layout.addStretch() + busy_panel = QW.QFrame(self.busy_overlay) + busy_panel.setObjectName("preview_busy_panel") + busy_panel.setFrameShape(QW.QFrame.StyledPanel) + busy_panel.setFrameShadow(QW.QFrame.Raised) + busy_panel.setAutoFillBackground(True) + busy_panel_layout = QW.QVBoxLayout(busy_panel) + busy_label = QW.QLabel(_("Computing preview..."), busy_panel) + busy_label.setAlignment(QC.Qt.AlignCenter) + busy_label.setStyleSheet("font-weight: 600;") + busy_panel_layout.addWidget(busy_label) + self.busy_progress = QW.QProgressBar(busy_panel) + self.busy_progress.setObjectName("preview_busy_progress") + self.busy_progress.setRange(0, 0) + self.busy_progress.setTextVisible(False) + self.busy_progress.setMinimumWidth(220) + busy_panel_layout.addWidget(self.busy_progress) + busy_layout.addWidget(busy_panel, 0, QC.Qt.AlignCenter) + busy_layout.addStretch() + self._stage_layout.addWidget(self.busy_overlay, 0, 0) + self.busy_overlay.hide() + self._busy = False + self._busy_overlay_timer = QC.QTimer(self) + self._busy_overlay_timer.setSingleShot(True) + self._busy_overlay_timer.setInterval(200) + self._busy_overlay_timer.timeout.connect(self._show_busy_overlay) + self.status = QW.QLabel() + self.status.setWordWrap(True) + self.status.setTextFormat(QC.Qt.PlainText) + self._layout.addWidget(self.status) + self.details = QW.QPlainTextEdit() + self.details.setReadOnly(True) + self.details.setMaximumHeight(90) + self.details.hide() + self._layout.addWidget(self.details) + self.enabled.toggled.connect(self._toggle) + self.source_combo.currentIndexChanged.connect(self._source_changed) + self.controller.SIG_RESULT.connect(self._show_result) + self.controller.SIG_ERROR.connect(self._show_error) + self.controller.SIG_BUSY.connect(self._set_busy) + if self.sources: + self._render(self.sources[0]) + self._set_disabled_appearance(True) + + def _set_disabled_appearance(self, disabled: bool) -> None: + """Dim the current graph while live preview is disabled.""" + self.disabled_overlay.setVisible(disabled) + if disabled: + self._busy = False + self._busy_overlay_timer.stop() + self.busy_overlay.hide() + self.disabled_overlay.raise_() + + def _set_busy(self, busy: bool) -> None: + """Delay progress feedback so fast previews do not cause flicker.""" + if busy: + if not self._busy: + self._busy = True + self._busy_overlay_timer.start() + return + self._busy = False + self._busy_overlay_timer.stop() + self.busy_overlay.hide() + + def _show_busy_overlay(self) -> None: + """Show delayed progress feedback while computation is still active.""" + if self._busy and self.enabled.isChecked(): + self.busy_overlay.show() + self.busy_overlay.raise_() + + def _toggle(self, checked: bool) -> None: + self._timer.stop() + self.controller.set_enabled(checked) + self.source_combo.setEnabled(checked) + self.details.hide() + self._set_disabled_appearance(not checked) + self.status.clear() + if checked: + self._request() + + def _source_changed(self) -> None: + self.controller.invalidate() + self._plot_signature = None + if self.sources: + self._render(self.sources[self.source_combo.currentIndex()]) + self.changed() + + def changed(self) -> None: + """Invalidate immediately, then read the form after its callbacks settle.""" + if self.editor is None or not self.enabled.isChecked(): + return + self.controller.mark_dirty() + self.details.hide() + if not self.editor.check_all_values(): + self.controller.invalidate() + self._timer.stop() + self.status.setText(_("Invalid parameters")) + return + self.status.setText(_("Updating preview...")) + if not self._dragging or not self._timer.isActive(): + self._timer.start(200 if self._dragging else 300) + + def slider_gesture(self, pressed: bool) -> None: + """Throttle drags and request the final value when the handle is released.""" + self._dragging = pressed + if not pressed and self.enabled.isChecked(): + self._timer.start(0) + + def _request(self) -> None: + if self.editor is None or not self.enabled.isChecked(): + return + if not self.editor.check_all_values(): + self.controller.invalidate() + self.status.setText(_("Invalid parameters")) + return + self.editor.accept_changes() + self.status.setText(_("Computing preview...")) + self.controller.request( + self.sources[self.source_combo.currentIndex()], self.editor.instance + ) + + def _show_error(self, message: str) -> None: + self.status.setText(_("Preview failed")) + self.details.setPlainText(message) + self.details.show() + + def _show_result(self, output: CompOut, current: bool) -> None: + result = output.result + if not isinstance(result, (SignalObj, ImageObj)): + self._show_error(_("This result cannot be previewed.")) + return + original_title = result.title + try: + patch_title_with_ids( + result, [self.sources[self.source_combo.currentIndex()]], get_short_id + ) + self._render(result) + except Exception as error: + self.controller.invalidate() + self._show_error(str(error)) + return + finally: + result.title = original_title + self.status.setText( + _("Preview up to date") if current else _("Updating preview...") + ) + self.details.setPlainText(output.warning_msg or "") + self.details.setVisible(bool(output.warning_msg)) + + def take_current_result(self) -> tuple[SignalObj | ImageObj, CompOut] | None: + """Detach the current computation for one-shot nominal publication.""" + if not self.enabled.isChecked() or not self.sources: + return None + source = self.sources[self.source_combo.currentIndex()] + output = self.controller.take_current_result(source) + return None if output is None else (source, output) + + def _render(self, result: SignalObj | ImageObj) -> None: + kind = "image" if isinstance(result, ImageObj) else "curve" + if self._plot_kind != kind: + if self.plotwidget is not None: + self._stage_layout.removeWidget(self.plotwidget) + self.plotwidget.hide() + self.plotwidget.deleteLater() + self.plotwidget = PlotWidget(self._stage, options=PlotOptions(type=kind)) + self.plotwidget.setMinimumSize(300, 240) + self._stage_layout.addWidget(self.plotwidget, 0, 0) + self._plot_kind = kind + self._plot_signature = None + self.item = None + plot = self.plotwidget.plot + adapter = create_adapter_from_object(result) + generator = CURVESTYLES.curve_style + try: + CURVESTYLES.curve_style = CURVESTYLES.style_generator() + item = adapter.make_item() + finally: + CURVESTYLES.curve_style = generator + if self.item is not None: + plot.del_item(self.item) + self.item = item + plot.add_item(item) + plot.set_active_item(item) + item.unselect() + plot.set_titles( + title=result.title, + xlabel=result.xlabel, + xunit=result.xunit, + ylabel=(result.ylabel, getattr(result, "zlabel", "")), + yunit=(result.yunit, getattr(result, "zunit", "")), + ) + for axis in ("x", "y"): + plot.set_axis_scale( + "bottom" if axis == "x" else "left", + "log" if getattr(result, f"{axis}scalelog", False) else "lin", + ) + if kind == "image": + domain = (result.data.shape, result.x0, result.y0, result.dx, result.dy) + else: + domain = ( + (result.x.size, result.x[0], result.x[-1]) if result.x.size else (0,) + ) + signature = ( + kind, + domain, + result.xlabel, + result.ylabel, + result.xunit, + result.yunit, + ) + if signature != self._plot_signature: + plot.do_autoscale() + self._plot_signature = signature + self.plotwidget.show() + plot.replot() + self._set_disabled_appearance(not self.enabled.isChecked()) + + def close_preview(self) -> None: + """Cancel work before the containing dialog is hidden or destroyed.""" + self._timer.stop() + self.controller.close() + + +class ProcessingPreviewDialog(QW.QDialog): + """Compose a guidata form with a private preview and transactional parameters.""" + + def __init__( + self, + instance: DataSet, + function: Callable, + sources: Sequence[SignalObj | ImageObj], + parent: QW.QWidget | None = None, + controller_factory: Callable = PreviewController, + ) -> None: + super().__init__(parent) + win32_fix_title_bar_background(self) + self.instance = copy.deepcopy(instance) + self._original = instance + self.preview_result = None + self.setModal(True) + self.setWindowTitle(instance.get_title()) + if instance.get_icon(): + self.setWindowIcon(get_icon(instance.get_icon())) + self.setObjectName(instance.__class__.__name__ + "Dialog") + layout = QW.QVBoxLayout(self) + splitter = QW.QSplitter(QC.Qt.Horizontal) + layout.addWidget(splitter, 1) + scroll = QW.QScrollArea() + scroll.setWidgetResizable(True) + form = QW.QWidget() + form_layout = QW.QVBoxLayout(form) + comment = instance.get_comment() + if comment: + label = QW.QLabel(comment) + label.setWordWrap(True) + form_layout.addWidget(label) + grid = QW.QGridLayout() + grid.setAlignment(QC.Qt.AlignTop) + form_layout.addLayout(grid) + form_layout.addStretch() + scroll.setWidget(form) + splitter.addWidget(scroll) + self.preview = ProcessingPreviewWidget( + function, sources, self, controller_factory + ) + splitter.addWidget(self.preview) + self.edit_layout = DataSetEditLayout( + self, + self.instance, + grid, + change_callback=self.preview.changed, + auto_sliders=True, + slider_callback=self.preview.slider_gesture, + ) + self.preview.editor = self.edit_layout + self.buttons = QW.QDialogButtonBox( + QW.QDialogButtonBox.Ok | QW.QDialogButtonBox.Cancel + ) + self.buttons.accepted.connect(self.accept) + self.buttons.rejected.connect(self.reject) + layout.addWidget(self.buttons) + self.finished.connect(self.preview.close_preview) + self.resize(1000, 650) + screen = self.screen().availableGeometry() + self.resize( + min(self.width(), screen.width()), min(self.height(), screen.height()) + ) + splitter.setSizes([450, 550]) + + def child_title(self, item) -> str: + """Supply the usual guidata title for nested editors.""" + title = QW.QApplication.applicationName() or self.windowTitle() + return f"{title} - {item.label()}" + + def accept(self) -> None: + """Commit validated parameters and detach a reusable current preview.""" + if not self.edit_layout.check_all_values(): + self.preview.status.setText(_("Invalid parameters")) + return + self.edit_layout.accept_changes() + update_dataset(self._original, self.instance) + self.preview_result = self.preview.take_current_result() + super().accept() diff --git a/datalab/widgets/replacespecialvalues.py b/datalab/widgets/replacespecialvalues.py index 880a9350..642a2e43 100644 --- a/datalab/widgets/replacespecialvalues.py +++ b/datalab/widgets/replacespecialvalues.py @@ -56,8 +56,8 @@ # -- Helper widgets ------------------------------------------------------------- -def _make_count_badge(key: str, count: int, total: int) -> QW.QLabel: - """Create a rich-text label with a colored dot and count information.""" +def _count_badge_text(key: str, count: int, total: int) -> str: + """Format one special-value counter.""" color = _BADGE_COLORS[key] label = _BADGE_LABELS[key] if total > 0 and count > 0: @@ -68,7 +68,12 @@ def _make_count_badge(key: str, count: int, total: int) -> QW.QLabel: ) else: text = f'\u25cf {label}: {count}' - lbl = QW.QLabel(text) + return text + + +def _make_count_badge(key: str, count: int, total: int) -> QW.QLabel: + """Create a rich-text label with a colored dot and count information.""" + lbl = QW.QLabel(_count_badge_text(key, count, total)) lbl.setTextFormat(QC.Qt.RichText) return lbl @@ -201,6 +206,8 @@ def __init__( self._is_image = is_image self._info_message = info_message self._can_apply = can_apply + self.preview = None + self.preview_result = None self.setWindowTitle(instance.get_title()) self.setMinimumWidth(480) @@ -208,8 +215,11 @@ def __init__( # --- Count badges --- count_row = QW.QHBoxLayout() + self._count_badges = {} for key in ("nan", "posinf", "neginf"): - count_row.addWidget(_make_count_badge(key, counts[key], total_size)) + badge = _make_count_badge(key, counts[key], total_size) + self._count_badges[key] = badge + count_row.addWidget(badge) count_row.addStretch() main_layout.addLayout(count_row) @@ -231,7 +241,12 @@ def __init__( grid = QW.QGridLayout() self.edit_layout: DataSetEditLayout | None = None edit_layout = DataSetEditLayout( - self, instance, grid, change_callback=self._on_change + self, + instance, + grid, + change_callback=self._on_change, + auto_sliders=True, + slider_callback=self._slider_gesture, ) self.edit_layout = edit_layout main_layout.addLayout(grid) @@ -268,10 +283,64 @@ def _on_change(self) -> None: """Slot called whenever a DataSet widget value changes.""" if self.edit_layout is None: return - # Sync current widget values → DataSet so the preview reads live data + if self.preview is not None: + self.preview.changed() + if not self.edit_layout.check_all_values(): + return self.edit_layout.accept_changes() self._refresh_preview() + def _slider_gesture(self, pressed: bool) -> None: + """Forward a slider gesture to the optional processing preview.""" + if self.preview is not None: + self.preview.slider_gesture(pressed) + + def attach_preview(self, function, sources, controller_factory=None) -> None: + """Compose the shared preview without replacing this custom editor.""" + from datalab.widgets.processingpreview import ProcessingPreviewWidget + + if controller_factory is None: + self.preview = ProcessingPreviewWidget(function, sources, self) + else: + self.preview = ProcessingPreviewWidget( + function, sources, self, controller_factory + ) + self.preview.editor = self.edit_layout + self.preview.enabled.setEnabled(self._can_apply) + self.preview.source_combo.currentIndexChanged.connect(self._update_counts) + main_layout = self.layout() + form = QW.QWidget() + form_layout = QW.QVBoxLayout(form) + while main_layout.count() > 1: + form_layout.addItem(main_layout.takeAt(0)) + form_layout.addStretch() + scroll = QW.QScrollArea() + scroll.setWidgetResizable(True) + scroll.setWidget(form) + splitter = QW.QSplitter(QC.Qt.Horizontal) + splitter.addWidget(scroll) + splitter.addWidget(self.preview) + main_layout.insertWidget(0, splitter, 1) + self.finished.connect(self.preview.close_preview) + screen = self.screen().availableGeometry() + self.resize(min(1000, screen.width()), min(700, screen.height())) + + def _update_counts(self) -> None: + """Refresh source-specific counters without resetting common parameters.""" + source = self.preview.sources[self.preview.source_combo.currentIndex()] + if self._is_image: + data = source.data + counts = count_special_values_2d(data) + else: + _, data = source.get_data() + counts = count_special_values(data) + for key, badge in self._count_badges.items(): + badge.setText(_count_badge_text(key, counts[key], data.size)) + + def child_title(self, item) -> str: + """Provide the standard title for guidata nested editors.""" + return f"{self.windowTitle()} - {item.label()}" + def _refresh_preview(self) -> None: """Update every kernel preview independently.""" p = self.instance @@ -350,6 +419,8 @@ def accept(self) -> None: if not self.edit_layout.check_all_values(): return self.edit_layout.accept_changes() + if self.preview is not None: + self.preview_result = self.preview.take_current_result() super().accept() diff --git a/doc/features/advanced/plugins.rst b/doc/features/advanced/plugins.rst index 8a6d4f5c..f8b8b464 100644 --- a/doc/features/advanced/plugins.rst +++ b/doc/features/advanced/plugins.rst @@ -38,6 +38,27 @@ DataLab supports three categories of plugins, each with its own purpose and regi - **HDF5 plugins** Special plugins that support HDF5 files with domain-specific tree structures. These allow DataLab to interpret signals or images organized in non-standard ways. +Live preview compatibility +-------------------------- + +.. note:: + + Standard parameterized functions registered with ``register_1_to_1`` can + participate in live preview without a custom renderer. They must return a + ``SignalObj`` or ``ImageObj``, be importable in a spawned process, and accept + serializable source/parameter copies. Preview uses a private process and does + not publish results to the workspace. It is not a sandbox for filesystem, + network or other external side effects. Register functions that must not run + speculatively with ``preview_enabled=False``. Serialization or computation + failures are reported in the preview; the normal OK path remains available. + Unknown custom ``DataSet.edit`` implementations and alternate guidata + backends are not replaced. + + The dedicated preview process may be reused between dialogs. Plugin functions + must not assume a fresh Python interpreter for each preview: process-local + module state may persist until a preview is cancelled while running, plugins + are reloaded, or DataLab exits. + Where to put a plugin? ---------------------- diff --git a/doc/features/common/overview.rst b/doc/features/common/overview.rst index bb8af1e1..e8f6bcc5 100644 --- a/doc/features/common/overview.rst +++ b/doc/features/common/overview.rst @@ -133,6 +133,44 @@ This is particularly useful for: Interactive 1-to-1 processing ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Live preview before applying a processing +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Standard parameter dialogs for 1-to-1 processing offer a **Preview** checkbox, +unchecked each time the dialog opens. Enable it to display a temporary result +while adjusting the parameters. With several signals, images or groups selected, +choose one source in the preview selector; **OK** still processes the whole +original selection with the common parameters. + +The preview uses the complete source data, including its regions of interest, +without adding objects or history entries. Invalid entries suspend updates. +Warnings and errors appear inside the dialog. Typing is debounced and slider +drags are throttled; large data and expensive algorithms may take longer to +update. The preview shows only the result, with its own axes and units, including +when an image processing returns a signal. + +Starting the first preview creates a dedicated process. After a preview has +finished, DataLab keeps this process ready for later preview dialogs, including +when switching between signal and image processing. Closing a dialog while a +preview is still running stops that process; the next preview then performs a +fresh startup. Reloading plugins also discards the cached preview process. + +**Cancel** discards the draft parameters and stops the preview. With one source, +**OK** reuses a completed, up-to-date preview once and publishes it through the +normal processing path, including history. If the preview is still running or +stale, or if several objects or groups are selected, DataLab runs the normal +processing instead. Analysis, multi-input operations, operations without +parameters and unknown custom parameter dialogs retain their existing workflow. + +Bounded numeric fields automatically offer sliders in compatible processing +forms and in the **Processing** tab. The exact text field remains authoritative; +moving its slider does not limit the precision of values entered by keyboard. +Fields without two usable bounds remain text-only. Sliders alone never enable +preview or automatic re-processing. + +Re-processing an existing result +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + When applying a 1-to-1 processing operation that has configurable parameters (e.g., Gaussian filter, threshold, morphological operations), DataLab stores the processing metadata, enabling parameter adjustment and re-processing: @@ -144,7 +182,11 @@ metadata, enabling parameter adjustment and re-processing: 5. Modify processing parameters (e.g., filter sigma value) 6. Click **Apply** to re-process with updated parameters -The processed object is updated in place with the new results. This workflow is ideal for: +Normally, **Apply** creates a new result and history entry. In the History panel's +edit mode it updates the existing result and recomputes dependent actions. The +optional **Auto-recompute on edit** checkbox follows the same rules, waits for +slider release and valid input, and does not apply edits from a previously +selected object. This workflow is ideal for: - Iteratively tuning filter parameters while observing results in real time - Adjusting threshold values without creating multiple intermediate objects @@ -168,7 +210,7 @@ The processed object is updated in place with the new results. This workflow is Example workflow ^^^^^^^^^^^^^^^^ -Here's a typical workflow using interactive processing: +Here's a typical workflow using interactive processing, with History edit mode enabled: 1. **Create a test signal**: Create > Gaussian signal diff --git a/doc/features/image/menu_processing.rst b/doc/features/image/menu_processing.rst index 41d45964..84831be8 100644 --- a/doc/features/image/menu_processing.rst +++ b/doc/features/image/menu_processing.rst @@ -361,7 +361,7 @@ The following thresholding methods are available: Exposure ^^^^^^^^ -Create a new image which is the result of exposure correction on each selected image. +Create new images by applying exposure corrections. Brightness and contrast uses the first selected image to initialize one shared input window, then applies that window independently to every selected image. Each result retains the output range of its own source image. The following functions are available: @@ -372,6 +372,9 @@ The following functions are available: * - Function - Implementation - Comments + * - Brightness and contrast + - :py:func:`sigima.proc.image.adjust_brightness_contrast` + - Histogram-driven clipped linear remapping, initialized from the first selected image * - Gamma correction - `skimage.exposure.adjust_gamma `_ - diff --git a/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po b/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po index c9e27f7a..9a21dd62 100644 --- a/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po +++ b/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po @@ -19,6 +19,15 @@ msgstr "DataLab, plugin, traitement, entrée/sortie, HDF5, format de fichier, an msgid "Plugins" msgstr "Plugins" +msgid "Live preview compatibility" +msgstr "Compatibilité avec l'aperçu interactif" + +msgid "Standard parameterized functions registered with ``register_1_to_1`` can participate in live preview without a custom renderer. They must return a ``SignalObj`` or ``ImageObj``, be importable in a spawned process, and accept serializable source/parameter copies. Preview uses a private process and does not publish results to the workspace. It is not a sandbox for filesystem, network or other external side effects. Register functions that must not run speculatively with ``preview_enabled=False``. Serialization or computation failures are reported in the preview; the normal OK path remains available. Unknown custom ``DataSet.edit`` implementations and alternate guidata backends are not replaced." +msgstr "Les fonctions paramétrées standard enregistrées avec ``register_1_to_1`` peuvent utiliser l'aperçu interactif sans moteur de rendu personnalisé. Elles doivent retourner un ``SignalObj`` ou un ``ImageObj``, être importables dans un processus démarré par spawn et accepter des copies sérialisables des sources et paramètres. L'aperçu utilise un processus privé et ne publie aucun résultat dans l'espace de travail. Il ne protège pas des effets externes sur les fichiers, le réseau ou d'autres ressources. Enregistrez les fonctions qui ne doivent pas être exécutées provisoirement avec ``preview_enabled=False``. Les erreurs de sérialisation ou de calcul sont signalées dans l'aperçu ; le chemin normal via OK reste disponible. Les implémentations personnalisées de ``DataSet.edit`` non prises en charge et les backends alternatifs de guidata ne sont pas remplacés." + +msgid "The dedicated preview process may be reused between dialogs. Plugin functions must not assume a fresh Python interpreter for each preview: process-local module state may persist until a preview is cancelled while running, plugins are reloaded, or DataLab exits." +msgstr "Le processus dédié à l'aperçu peut être réutilisé entre les boîtes de dialogue. Les fonctions des plugins ne doivent pas supposer qu'un nouvel interpréteur Python est créé pour chaque aperçu : l'état local des modules dans le processus peut persister jusqu'à ce qu'un aperçu en cours soit annulé, que les plugins soient rechargés ou que DataLab se ferme." + msgid "DataLab supports a robust plugin architecture, allowing users to extend the application’s features without modifying its core. Plugins can introduce new processing tools, data import/export formats, or custom GUI elements — all seamlessly integrated into the platform." msgstr "DataLab prend en charge une architecture de plugin robuste, permettant aux utilisateurs d'étendre les fonctionnalités de l'application sans modifier son noyau. Les plugins peuvent introduire de nouveaux outils de traitement, des formats d'importation/exportation de données ou des éléments d'interface graphique personnalisés, le tout intégré de manière transparente dans la plateforme." diff --git a/doc/locale/fr/LC_MESSAGES/features/common/overview.po b/doc/locale/fr/LC_MESSAGES/features/common/overview.po index f52eb537..7f4d879e 100644 --- a/doc/locale/fr/LC_MESSAGES/features/common/overview.po +++ b/doc/locale/fr/LC_MESSAGES/features/common/overview.po @@ -138,6 +138,27 @@ msgstr "La création interactive n'est disponible que pour les objets créés av msgid "Interactive 1-to-1 processing" msgstr "Traitement interactif 1-vers-1" +msgid "Live preview before applying a processing" +msgstr "Aperçu avant l'application d'un traitement" + +msgid "Standard parameter dialogs for 1-to-1 processing offer a **Preview** checkbox, unchecked each time the dialog opens. Enable it to display a temporary result while adjusting the parameters. With several signals, images or groups selected, choose one source in the preview selector; **OK** still processes the whole original selection with the common parameters." +msgstr "Les boîtes de dialogue standard des traitements 1-vers-1 proposent une case **Aperçu**, décochée à chaque ouverture. Cochez-la pour afficher un résultat temporaire pendant le réglage des paramètres. Si plusieurs signaux, images ou groupes sont sélectionnés, choisissez une source dans le sélecteur d'aperçu ; **OK** traite toujours toute la sélection initiale avec les paramètres communs." + +msgid "The preview uses the complete source data, including its regions of interest, without adding objects or history entries. Invalid entries suspend updates. Warnings and errors appear inside the dialog. Typing is debounced and slider drags are throttled; large data and expensive algorithms may take longer to update. The preview shows only the result, with its own axes and units, including when an image processing returns a signal." +msgstr "L'aperçu utilise toutes les données sources, y compris leurs régions d'intérêt, sans ajouter d'objet ni d'entrée d'historique. Une saisie invalide suspend les mises à jour. Les avertissements et erreurs s'affichent dans la boîte de dialogue. Les saisies rapprochées sont regroupées et la cadence des curseurs est limitée ; les données volumineuses et les algorithmes coûteux peuvent prendre davantage de temps. L'aperçu affiche uniquement le résultat, avec ses propres axes et unités, y compris lorsqu'un traitement d'image produit un signal." + +msgid "Starting the first preview creates a dedicated process. After a preview has finished, DataLab keeps this process ready for later preview dialogs, including when switching between signal and image processing. Closing a dialog while a preview is still running stops that process; the next preview then performs a fresh startup. Reloading plugins also discards the cached preview process." +msgstr "Le premier aperçu démarre un processus dédié. Une fois l'aperçu terminé, DataLab garde ce processus prêt pour les boîtes de dialogue d'aperçu suivantes, y compris lors du passage des traitements de signaux aux traitements d'images. Fermer une boîte de dialogue pendant qu'un aperçu est encore en cours arrête ce processus ; l'aperçu suivant effectue alors un nouveau démarrage. Le rechargement des plugins abandonne également le processus d'aperçu mis en cache." + +msgid "**Cancel** discards the draft parameters and stops the preview. With one source, **OK** reuses a completed, up-to-date preview once and publishes it through the normal processing path, including history. If the preview is still running or stale, or if several objects or groups are selected, DataLab runs the normal processing instead. Analysis, multi-input operations, operations without parameters and unknown custom parameter dialogs retain their existing workflow." +msgstr "**Annuler** abandonne les paramètres en cours d'édition et arrête l'aperçu. Avec une seule source, **OK** réutilise une fois l'aperçu terminé et à jour, puis le publie par le chemin de traitement normal, historique compris. Si l'aperçu est encore en cours ou obsolète, ou si plusieurs objets ou groupes sont sélectionnés, DataLab exécute le traitement normal. Les analyses, les opérations à plusieurs entrées, les opérations sans paramètres et les éditeurs personnalisés non pris en charge conservent leur fonctionnement habituel." + +msgid "Bounded numeric fields automatically offer sliders in compatible processing forms and in the **Processing** tab. The exact text field remains authoritative; moving its slider does not limit the precision of values entered by keyboard. Fields without two usable bounds remain text-only. Sliders alone never enable preview or automatic re-processing." +msgstr "Les champs numériques bornés proposent automatiquement des curseurs dans les formulaires de traitement compatibles et dans l'onglet **Traitement**. La valeur exacte du champ texte fait foi ; le curseur ne limite pas la précision des valeurs saisies au clavier. Les champs sans deux bornes utilisables restent textuels. Les curseurs n'activent jamais à eux seuls l'aperçu ou le retraitement automatique." + +msgid "Re-processing an existing result" +msgstr "Retraiter un résultat existant" + msgid "When applying a 1-to-1 processing operation that has configurable parameters (e.g., Gaussian filter, threshold, morphological operations), DataLab stores the processing metadata, enabling parameter adjustment and re-processing:" msgstr "Lors de l'application d'une opération de traitement 1-vers-1 qui a des paramètres configurables (par exemple, filtre gaussien, seuil, opérations morphologiques), DataLab stocke les métadonnées de traitement, permettant l'ajustement des paramètres et le retraitement :" @@ -159,8 +180,8 @@ msgstr "Modifier les paramètres de traitement (par exemple, valeur du sigma du msgid "Click **Apply** to re-process with updated parameters" msgstr "Cliquer sur **Appliquer** pour retraiter avec les paramètres mis à jour" -msgid "The processed object is updated in place with the new results. This workflow is ideal for:" -msgstr "L'objet traité est mis à jour sur place avec les nouveaux résultats. Ce flux de travail est idéal pour :" +msgid "Normally, **Apply** creates a new result and history entry. In the History panel's edit mode it updates the existing result and recomputes dependent actions. The optional **Auto-recompute on edit** checkbox follows the same rules, waits for slider release and valid input, and does not apply edits from a previously selected object. This workflow is ideal for:" +msgstr "Normalement, **Appliquer** crée un nouveau résultat et une entrée d'historique. En mode édition du panneau Historique, le résultat existant est mis à jour et les actions dépendantes sont recalculées. La case facultative **Retraiter automatiquement à la modification** suit les mêmes règles, attend le relâchement du curseur et une saisie valide, et n'applique pas les modifications d'un objet précédemment sélectionné. Ce fonctionnement convient notamment pour :" msgid "Iteratively tuning filter parameters while observing results in real time" msgstr "Ajuster de manière itérative les paramètres du filtre tout en observant les résultats en temps réel" @@ -201,8 +222,8 @@ msgstr "Ces modèles ne bénéficient pas de manière significative de l'ajustem msgid "Example workflow" msgstr "Exemple de flux de travail" -msgid "Here's a typical workflow using interactive processing:" -msgstr "Voici un flux de travail typique utilisant le traitement interactif :" +msgid "Here's a typical workflow using interactive processing, with History edit mode enabled:" +msgstr "Voici un flux de travail typique utilisant le traitement interactif, avec le mode édition de l'historique activé :" msgid "**Create a test signal**: Create > Gaussian signal" msgstr "**Créer un signal de test** : Création > Signal gaussien" diff --git a/doc/locale/fr/LC_MESSAGES/features/image/menu_processing.po b/doc/locale/fr/LC_MESSAGES/features/image/menu_processing.po index 8a648e04..45053559 100644 --- a/doc/locale/fr/LC_MESSAGES/features/image/menu_processing.po +++ b/doc/locale/fr/LC_MESSAGES/features/image/menu_processing.po @@ -529,12 +529,21 @@ msgstr "L'option \"Toutes les méthodes de seuillage\" permet d'appliquer toutes msgid "Exposure" msgstr "Exposition" -msgid "Create a new image which is the result of exposure correction on each selected image." -msgstr "Crée une image à partir du résultat d'une correction d'exposition sur chaque image sélectionnée." +msgid "Create new images by applying exposure corrections. Brightness and contrast uses the first selected image to initialize one shared input window, then applies that window independently to every selected image. Each result retains the output range of its own source image." +msgstr "Crée de nouvelles images en appliquant des corrections d'exposition. Le réglage de la luminosité et du contraste utilise la première image sélectionnée pour initialiser une plage d'entrée commune, puis applique cette plage indépendamment à chaque image sélectionnée. Chaque résultat conserve la plage de sortie propre à son image source." msgid "Comments" msgstr "Commentaires" +msgid "Brightness and contrast" +msgstr "Luminosité et contraste" + +msgid ":py:func:`sigima.proc.image.adjust_brightness_contrast`" +msgstr "" + +msgid "Histogram-driven clipped linear remapping, initialized from the first selected image" +msgstr "Remappage linéaire écrêté piloté par histogramme, initialisé à partir de la première image sélectionnée" + msgid "Gamma correction" msgstr "Correction gamma" diff --git a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.03.po b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.03.po index 780693c7..8936161d 100644 --- a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.03.po +++ b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.03.po @@ -383,3 +383,4 @@ msgstr "Ajout d'un exemple de plugin montrant des lignes de contour iso-niveau s msgid "Updated French translations across all new and modified documentation pages" msgstr "Mise à jour des traductions françaises sur toutes les nouvelles pages de documentation et celles modifiées" + diff --git a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po new file mode 100644 index 00000000..0c475ea2 --- /dev/null +++ b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po @@ -0,0 +1,44 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2023, DataLab Platform Developers +# This file is distributed under the same license as the DataLab package. +# FIRST AUTHOR , 2026. +# +#, fuzzy +msgid "" +msgstr "" +"Language: fr\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgid "Version 1.4" +msgstr "Version 1.4" + +msgid "DataLab Version 1.4.0" +msgstr "DataLab Version 1.4.0" + +msgid "✨ New Features" +msgstr "✨ Nouvelles fonctionnalités" + +msgid "**Brightness and contrast:**" +msgstr "**Luminosité et contraste :**" + +msgid "A new **Processing > Exposure > Brightness and contrast** operation provides a histogram with synchronized minimum, maximum, brightness and contrast controls, deterministic Auto and Reset actions, ROI-aware remapping, and live preview. One input window is initialized from the first selected image and applied to the full selection, with each result preserving its source image and data type." +msgstr "Une nouvelle opération **Traitement > Exposition > Luminosité et contraste** fournit un histogramme avec des contrôles synchronisés du minimum, du maximum, de la luminosité et du contraste, des actions Auto et Réinitialiser déterministes, un remappage tenant compte des ROI et un aperçu interactif. Une plage d'entrée est initialisée à partir de la première image sélectionnée et appliquée à toute la sélection, chaque résultat conservant son image source et son type de données." + +msgid "Intensity windows may extend beyond the observed data range. Exact floating-point bounds survive editing and reapplication, while declared parameter constraints remain enforced and Cancel preserves the original parameters." +msgstr "La fenêtre d'intensité peut s'étendre au-delà de la plage de données observée. Les limites en virgule flottante exactes survivent à l'édition et à la réapplication, tandis que les contraintes de paramètres déclarées restent appliquées et Annuler préserve les paramètres d'origine." + +msgid "**Live processing preview:**" +msgstr "**Aperçu interactif des traitements :**" + +msgid "Parameter dialogs for compatible 1-to-1 processing now offer an optional live preview for signals and images, including image-to-signal operations. Preview is off by default; Cancel leaves the workspace unchanged, while OK reuses a completed current preview for a single source and otherwise performs the normal processing on the original selection." +msgstr "Les boîtes de dialogue des traitements 1-vers-1 compatibles proposent désormais un aperçu interactif facultatif pour les signaux et images, y compris les opérations image-vers-signal. L'aperçu est désactivé par défaut ; Annuler laisse l'espace de travail inchangé, tandis que OK réutilise un aperçu courant terminé pour une source unique et exécute sinon le traitement normal sur la sélection initiale." + +msgid "Completed previews keep their dedicated process ready for later dialogs, avoiding repeated process startup. Cancelling a preview that is still running stops its process so speculative work cannot continue in the background." +msgstr "Les aperçus terminés conservent leur processus dédié prêt pour les boîtes de dialogue suivantes, ce qui évite des démarrages de processus répétés. L'annulation d'un aperçu encore en cours arrête son processus afin qu'aucun calcul spéculatif ne se poursuive en arrière-plan." + +msgid "Bounded numeric parameters now offer sliders alongside precise text entry. The Processing tab retains Apply and automatic re-processing, with updates deferred until slider release and valid input." +msgstr "Les paramètres numériques bornés proposent désormais des curseurs en complément d'une saisie textuelle précise. L'onglet Traitement conserve Appliquer et le retraitement automatique, avec des mises à jour différées jusqu'au relâchement du curseur et à une saisie valide." + diff --git a/doc/release_notes/release_1.04.md b/doc/release_notes/release_1.04.md new file mode 100644 index 00000000..027d22c5 --- /dev/null +++ b/doc/release_notes/release_1.04.md @@ -0,0 +1,17 @@ +# Version 1.4 # + +## DataLab Version 1.4.0 ## + +### ✨ New Features ### + +**Brightness and contrast:** + +* A new **Processing > Exposure > Brightness and contrast** operation provides a histogram with synchronized minimum, maximum, brightness and contrast controls, deterministic Auto and Reset actions, ROI-aware remapping, and live preview. One input window is initialized from the first selected image and applied to the full selection, with each result preserving its source image and data type. +* Intensity windows may extend beyond the observed data range. Exact floating-point bounds survive editing and reapplication, while declared parameter constraints remain enforced and Cancel preserves the original parameters. + +**Live processing preview:** + +* Parameter dialogs for compatible 1-to-1 processing now offer an optional live preview for signals and images, including image-to-signal operations. Preview is off by default; Cancel leaves the workspace unchanged, while OK reuses a completed current preview for a single source and otherwise performs the normal processing on the original selection. +* Completed previews keep their dedicated process ready for later dialogs, avoiding repeated process startup. Cancelling a preview that is still running stops its process so speculative work cannot continue in the background. +* Bounded numeric parameters now offer sliders alongside precise text entry. The Processing tab retains Apply and automatic re-processing, with updates deferred until slider release and valid input. +