From fe6caf590a353481270759dc03a02761005fa80b Mon Sep 17 00:00:00 2001
From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com>
Date: Sat, 5 Sep 2026 17:00:31 +0200
Subject: [PATCH 1/7] feat(processing): add live parameter previews
Preview one selected source without publishing temporary results.
Keep cancellation transactional and process the original selection
after confirmation.
---
datalab/gui/panel/base.py | 45 +-
datalab/gui/processor/base.py | 58 ++-
datalab/gui/processor/preview.py | 185 ++++++++
datalab/locale/fr/LC_MESSAGES/datalab.po | 25 +-
.../backbone/preview_executor_unit_test.py | 62 +++
.../common/processing_preview_unit_test.py | 403 ++++++++++++++++++
datalab/widgets/processingparameters.py | 45 ++
datalab/widgets/processingpreview.py | 334 +++++++++++++++
datalab/widgets/replacespecialvalues.py | 75 +++-
doc/features/advanced/plugins.rst | 16 +
doc/features/common/overview.rst | 39 +-
.../LC_MESSAGES/features/advanced/plugins.po | 6 +
.../LC_MESSAGES/features/common/overview.po | 26 +-
.../LC_MESSAGES/release_notes/release_1.03.po | 9 +
doc/release_notes/release_1.03.md | 5 +
15 files changed, 1294 insertions(+), 39 deletions(-)
create mode 100644 datalab/gui/processor/preview.py
create mode 100644 datalab/tests/backbone/preview_executor_unit_test.py
create mode 100644 datalab/tests/features/common/processing_preview_unit_test.py
create mode 100644 datalab/widgets/processingparameters.py
create mode 100644 datalab/widgets/processingpreview.py
diff --git a/datalab/gui/panel/base.py b/datalab/gui/panel/base.py
index 207bfe8f..3f793515 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:
@@ -858,7 +866,9 @@ def setup_processing_tab(
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 +878,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
@@ -1138,17 +1133,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..bc6965fb 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."""
@@ -1792,6 +1794,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 +1811,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 +1819,47 @@ 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)
+ 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
+ ):
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
+ )
+ 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
@@ -2663,6 +2700,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 +2716,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 +2729,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 +3015,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/preview.py b/datalab/gui/processor/preview.py
new file mode 100644
index 00000000..5fd5e623
--- /dev/null
+++ b/datalab/gui/processor/preview.py
@@ -0,0 +1,185 @@
+"""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"]
+
+
+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 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,
+ ) -> None:
+ super().__init__(parent)
+ self.function = function
+ self._executor_factory = executor_factory
+ self._executor = None
+ self._future = None
+ self._pending = None
+ self._active_key = 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
+
+ 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)
+ 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._pending
+ self._pending = None
+ try:
+ if self._executor is None:
+ self._executor = self._executor_factory()
+ self._future = self._executor.submit(self.function, args)
+ except Exception:
+ self._future = None
+ 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:
+ self.SIG_RESULT.emit(output, current)
+ if self._pending is not None:
+ self._start_pending()
+ else:
+ self.SIG_BUSY.emit(False)
+
+ def close(self) -> None:
+ """Disconnect the view from outstanding work and cancel privately."""
+ self._enabled = False
+ self.invalidate()
+ self._timer.stop()
+ self._future = None
+ if self._executor is not None:
+ self._executor.close()
+ self._executor = None
+ self.SIG_BUSY.emit(False)
diff --git a/datalab/locale/fr/LC_MESSAGES/datalab.po b/datalab/locale/fr/LC_MESSAGES/datalab.po
index d4632574..5553e847 100644
--- a/datalab/locale/fr/LC_MESSAGES/datalab.po
+++ b/datalab/locale/fr/LC_MESSAGES/datalab.po
@@ -4081,6 +4081,30 @@ msgstr "Image"
msgid "Dimensions"
msgstr "Dimensions"
+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é."
+
+msgid "Preview source"
+msgstr "Source de l'aperçu"
+
+msgid "Invalid parameters"
+msgstr "Paramètres invalides"
+
+msgid "Updating preview..."
+msgstr "Mise à jour de l'aperçu..."
+
+msgid "Computing preview..."
+msgstr "Calcul 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 "Minimum value"
msgstr "Valeur minimum"
@@ -4128,4 +4152,3 @@ msgstr "Aucun contour n'a été trouvé pour la plage de niveaux sélectionnée.
msgid "Show contour plot..."
msgstr "Afficher le tracé de contours..."
-
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..78946132
--- /dev/null
+++ b/datalab/tests/backbone/preview_executor_unit_test.py
@@ -0,0 +1,62 @@
+"""Windows-compatible process lifecycle for speculative computations."""
+
+from __future__ import annotations
+
+import multiprocessing
+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
+
+
+def slow_identity(source, started):
+ """Represent an expensive computation that must be cancellable."""
+ started.send(True)
+ time.sleep(30)
+ return source
+
+
+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..307ed168
--- /dev/null
+++ b/datalab/tests/features/common/processing_preview_unit_test.py
@@ -0,0 +1,403 @@
+"""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 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
+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
+
+
+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
+ controller.close()
+ assert executor.closed
+
+
+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
+ field = dialog.edit_layout.get_terminal_widgets()[0]
+ field.edit.setText("2.5")
+ assert param.sigma == 1.0
+ dialog.preview.enabled.setChecked(True)
+ 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.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_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_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))
+ prop._ObjectProp__set_auto_recompute_enabled(True)
+ timer = prop._ObjectProp__auto_recompute_timer
+ field = editor.edit.get_terminal_widgets()[0]
+ 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 visible 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()
+ assert dialog.preview.plotwidget.isVisible()
+ 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/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..051aab12
--- /dev/null
+++ b/datalab/widgets/processingpreview.py
@@ -0,0 +1,334 @@
+"""Processing parameter forms with opt-in, unpublished live results."""
+
+from __future__ import annotations
+
+import copy
+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
+) -> 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))
+ if type(instance) in (
+ ReplaceSpecialValuesSignalParamDL,
+ ReplaceSpecialValuesImageParamDL,
+ ):
+ dialog = instance.create_dialog(parent=parent)
+ dialog.attach_preview(function, sources)
+ elif type(instance).edit is DataSet.edit:
+ dialog = ProcessingPreviewDialog(instance, function, sources, parent)
+ else:
+ return bool(instance.edit(parent=parent))
+ try:
+ return bool(exec_dialog(dialog))
+ 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.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)
+
+ def _toggle(self, checked: bool) -> None:
+ self._timer.stop()
+ self.controller.set_enabled(checked)
+ self.source_combo.setEnabled(checked)
+ self.details.hide()
+ if self.plotwidget is not None:
+ self.plotwidget.hide()
+ self.status.clear()
+ if checked:
+ self._request()
+
+ def _source_changed(self) -> None:
+ self.controller.invalidate()
+ self._plot_signature = None
+ if self.plotwidget is not None:
+ self.plotwidget.hide()
+ 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
+ try:
+ patch_title_with_ids(
+ result, [self.sources[self.source_combo.currentIndex()]], get_short_id
+ )
+ self._render(result)
+ except Exception as error:
+ self._show_error(str(error))
+ return
+ 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 _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._layout.removeWidget(self.plotwidget)
+ self.plotwidget.hide()
+ self.plotwidget.deleteLater()
+ self.plotwidget = PlotWidget(self, options=PlotOptions(type=kind))
+ self.plotwidget.setMinimumSize(300, 240)
+ self._layout.addWidget(self.plotwidget, 1)
+ 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()
+
+ 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.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:
+ """Publish only validated parameter values, never the preview result."""
+ 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)
+ super().accept()
diff --git a/datalab/widgets/replacespecialvalues.py b/datalab/widgets/replacespecialvalues.py
index 880a9350..a16ee467 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,7 @@ def __init__(
self._is_image = is_image
self._info_message = info_message
self._can_apply = can_apply
+ self.preview = None
self.setWindowTitle(instance.get_title())
self.setMinimumWidth(480)
@@ -208,8 +214,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 +240,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 +282,59 @@ 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) -> None:
+ """Compose the shared preview without replacing this custom editor."""
+ from datalab.widgets.processingpreview import ProcessingPreviewWidget
+
+ self.preview = ProcessingPreviewWidget(function, sources, self)
+ 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
diff --git a/doc/features/advanced/plugins.rst b/doc/features/advanced/plugins.rst
index 8a6d4f5c..f7e261c1 100644
--- a/doc/features/advanced/plugins.rst
+++ b/doc/features/advanced/plugins.rst
@@ -38,6 +38,22 @@ 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.
+
Where to put a plugin?
----------------------
diff --git a/doc/features/common/overview.rst b/doc/features/common/overview.rst
index bb8af1e1..d2184dd3 100644
--- a/doc/features/common/overview.rst
+++ b/doc/features/common/overview.rst
@@ -133,6 +133,37 @@ 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.
+
+**Cancel** discards the draft parameters and stops the preview. **OK** runs the
+normal processing again: it does not reuse the temporary result. Random
+processing without a fixed seed may therefore produce a different final result.
+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 +175,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 +203,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/locale/fr/LC_MESSAGES/features/advanced/plugins.po b/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po
index c9e27f7a..4d10b7a5 100644
--- a/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po
+++ b/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po
@@ -19,6 +19,12 @@ 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 "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..ec169797 100644
--- a/doc/locale/fr/LC_MESSAGES/features/common/overview.po
+++ b/doc/locale/fr/LC_MESSAGES/features/common/overview.po
@@ -138,6 +138,24 @@ 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 "**Cancel** discards the draft parameters and stops the preview. **OK** runs the normal processing again: it does not reuse the temporary result. Random processing without a fixed seed may therefore produce a different final result. 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. **OK** exécute à nouveau le traitement normal : le résultat temporaire n'est pas réutilisé. Un traitement aléatoire sans graine fixée peut donc produire un résultat final différent. 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 +177,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 +219,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/release_notes/release_1.03.po b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.03.po
index 780693c7..c8d26e35 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
@@ -18,6 +18,15 @@ msgstr "DataLab Version 1.3.0"
msgid "✨ New Features"
msgstr "✨ Nouvelles fonctionnalités"
+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 and OK 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é et OK exécute le traitement normal sur la sélection initiale."
+
+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."
+
msgid "**History Panel:**"
msgstr "**Panneau d'historique :**"
diff --git a/doc/release_notes/release_1.03.md b/doc/release_notes/release_1.03.md
index a34b7176..5adb89e0 100644
--- a/doc/release_notes/release_1.03.md
+++ b/doc/release_notes/release_1.03.md
@@ -4,6 +4,11 @@
### ✨ New Features ###
+**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 and OK performs the normal processing on the original selection.
+* 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.
+
**History Panel:**
DataLab now records what you do and lets you replay it. A new **History** panel keeps track of every action applied to signals
From 2b847d4e19107f1931616b8719be0edcae18676e Mon Sep 17 00:00:00 2001
From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com>
Date: Sun, 6 Sep 2026 12:52:22 +0200
Subject: [PATCH 2/7] feat(preview): add disabled overlay and French
translation for preview disabled message
---
datalab/locale/fr/LC_MESSAGES/datalab.po | 3 ++
.../common/processing_preview_unit_test.py | 11 +++++
datalab/widgets/processingpreview.py | 48 ++++++++++++++++---
3 files changed, 55 insertions(+), 7 deletions(-)
diff --git a/datalab/locale/fr/LC_MESSAGES/datalab.po b/datalab/locale/fr/LC_MESSAGES/datalab.po
index 5553e847..2fc1ce4f 100644
--- a/datalab/locale/fr/LC_MESSAGES/datalab.po
+++ b/datalab/locale/fr/LC_MESSAGES/datalab.po
@@ -4087,6 +4087,9 @@ msgstr "Un objet source de l'aperçu a été supprimé. Le traitement a été an
msgid "Preview source"
msgstr "Source de l'aperçu"
+msgid "Preview disabled"
+msgstr "Aperçu désactivé"
+
msgid "Invalid parameters"
msgstr "Paramètres invalides"
diff --git a/datalab/tests/features/common/processing_preview_unit_test.py b/datalab/tests/features/common/processing_preview_unit_test.py
index 307ed168..38e6bbdb 100644
--- a/datalab/tests/features/common/processing_preview_unit_test.py
+++ b/datalab/tests/features/common/processing_preview_unit_test.py
@@ -98,10 +98,15 @@ def test_dialog_is_opt_in_and_transactional():
),
)
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)
@@ -119,6 +124,12 @@ def test_dialog_is_opt_in_and_transactional():
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
diff --git a/datalab/widgets/processingpreview.py b/datalab/widgets/processingpreview.py
index 051aab12..27160981 100644
--- a/datalab/widgets/processingpreview.py
+++ b/datalab/widgets/processingpreview.py
@@ -99,6 +99,31 @@ def __init__(
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.status = QW.QLabel()
self.status.setWordWrap(True)
self.status.setTextFormat(QC.Qt.PlainText)
@@ -112,14 +137,22 @@ def __init__(
self.source_combo.currentIndexChanged.connect(self._source_changed)
self.controller.SIG_RESULT.connect(self._show_result)
self.controller.SIG_ERROR.connect(self._show_error)
+ 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.disabled_overlay.raise_()
def _toggle(self, checked: bool) -> None:
self._timer.stop()
self.controller.set_enabled(checked)
self.source_combo.setEnabled(checked)
self.details.hide()
- if self.plotwidget is not None:
- self.plotwidget.hide()
+ self._set_disabled_appearance(not checked)
self.status.clear()
if checked:
self._request()
@@ -127,8 +160,8 @@ def _toggle(self, checked: bool) -> None:
def _source_changed(self) -> None:
self.controller.invalidate()
self._plot_signature = None
- if self.plotwidget is not None:
- self.plotwidget.hide()
+ if self.sources:
+ self._render(self.sources[self.source_combo.currentIndex()])
self.changed()
def changed(self) -> None:
@@ -193,12 +226,12 @@ 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._layout.removeWidget(self.plotwidget)
+ self._stage_layout.removeWidget(self.plotwidget)
self.plotwidget.hide()
self.plotwidget.deleteLater()
- self.plotwidget = PlotWidget(self, options=PlotOptions(type=kind))
+ self.plotwidget = PlotWidget(self._stage, options=PlotOptions(type=kind))
self.plotwidget.setMinimumSize(300, 240)
- self._layout.addWidget(self.plotwidget, 1)
+ self._stage_layout.addWidget(self.plotwidget, 0, 0)
self._plot_kind = kind
self._plot_signature = None
self.item = None
@@ -247,6 +280,7 @@ def _render(self, result: SignalObj | ImageObj) -> None:
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."""
From 1b42cc5db71ca17609fd0cdcbcb832a985fbc390 Mon Sep 17 00:00:00 2001
From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com>
Date: Sun, 6 Sep 2026 15:51:40 +0200
Subject: [PATCH 3/7] feat(preview): implement live processing preview feature
with result reuse and update handling
---
datalab/gui/processor/base.py | 35 ++++++--
datalab/gui/processor/preview.py | 27 +++++-
.../common/processing_preview_unit_test.py | 82 +++++++++++++++++++
datalab/widgets/processingpreview.py | 32 +++++++-
datalab/widgets/replacespecialvalues.py | 3 +
doc/features/common/overview.rst | 11 +--
.../LC_MESSAGES/features/common/overview.po | 4 +-
.../LC_MESSAGES/release_notes/release_1.03.po | 4 +-
doc/release_notes/release_1.03.md | 2 +-
9 files changed, 180 insertions(+), 20 deletions(-)
diff --git a/datalab/gui/processor/base.py b/datalab/gui/processor/base.py
index bc6965fb..f3e9ecdf 100644
--- a/datalab/gui/processor/base.py
+++ b/datalab/gui/processor/base.py
@@ -1651,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.
@@ -1674,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(
@@ -1837,10 +1845,16 @@ def compute_1_to_1(
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
+ draft,
+ func,
+ sources,
+ self.mainwindow,
+ allowed,
+ preview_results,
):
return
if any(
@@ -1858,6 +1872,8 @@ def compute_1_to_1(
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)
@@ -1871,7 +1887,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,
diff --git a/datalab/gui/processor/preview.py b/datalab/gui/processor/preview.py
index 5fd5e623..6851cbe9 100644
--- a/datalab/gui/processor/preview.py
+++ b/datalab/gui/processor/preview.py
@@ -96,6 +96,8 @@ def __init__(
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
@@ -114,6 +116,7 @@ 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."""
@@ -131,14 +134,15 @@ def request(self, source: SignalObj | ImageObj, param: DataSet) -> None:
self.invalidate()
self.SIG_ERROR.emit(traceback.format_exc())
return
- self._pending = ((self._epoch, self._revision), args)
+ 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._pending
+ self._active_key, args, self._active_source = self._pending
self._pending = None
try:
if self._executor is None:
@@ -167,18 +171,37 @@ def poll(self) -> None:
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."""
self._enabled = False
self.invalidate()
self._timer.stop()
self._future = None
+ self._active_source = None
+ self._current_result = None
if self._executor is not None:
self._executor.close()
self._executor = None
diff --git a/datalab/tests/features/common/processing_preview_unit_test.py b/datalab/tests/features/common/processing_preview_unit_test.py
index 38e6bbdb..be8fe7fb 100644
--- a/datalab/tests/features/common/processing_preview_unit_test.py
+++ b/datalab/tests/features/common/processing_preview_unit_test.py
@@ -74,6 +74,13 @@ def test_preview_latest_request_and_invalidation():
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
@@ -202,6 +209,81 @@ def accept(dialog):
assert remembered.sigma == 2.0
+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()
+ nominal_calls = []
+
+ def counted_filter(src, param):
+ nominal_calls.append((src, param))
+ return gaussian_filter(src, param)
+
+ def accept_current_preview(dialog):
+ dialog.preview.controller._executor_factory = lambda: executor
+ 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()
+
+ def accept_multi_preview(dialog):
+ dialog.preview.controller._executor_factory = lambda: multi_executor
+ 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
diff --git a/datalab/widgets/processingpreview.py b/datalab/widgets/processingpreview.py
index 27160981..e3e4646a 100644
--- a/datalab/widgets/processingpreview.py
+++ b/datalab/widgets/processingpreview.py
@@ -30,7 +30,12 @@
def edit_processing_parameters(
- instance, function, sources, parent, preview_enabled=True
+ instance,
+ function,
+ sources,
+ parent,
+ preview_enabled=True,
+ preview_result=None,
) -> bool:
"""Preserve alternate backends and custom editors outside the standard form."""
from datalab.widgets.replacespecialvalues import (
@@ -51,7 +56,14 @@ def edit_processing_parameters(
else:
return bool(instance.edit(parent=parent))
try:
- return bool(exec_dialog(dialog))
+ 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()
@@ -208,20 +220,32 @@ def _show_result(self, output: CompOut, current: bool) -> None:
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:
@@ -303,6 +327,7 @@ def __init__(
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():
@@ -359,10 +384,11 @@ def child_title(self, item) -> str:
return f"{title} - {item.label()}"
def accept(self) -> None:
- """Publish only validated parameter values, never the preview result."""
+ """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 a16ee467..211c52f1 100644
--- a/datalab/widgets/replacespecialvalues.py
+++ b/datalab/widgets/replacespecialvalues.py
@@ -207,6 +207,7 @@ def __init__(
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)
@@ -413,6 +414,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/common/overview.rst b/doc/features/common/overview.rst
index d2184dd3..56cf7de3 100644
--- a/doc/features/common/overview.rst
+++ b/doc/features/common/overview.rst
@@ -149,11 +149,12 @@ 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.
-**Cancel** discards the draft parameters and stops the preview. **OK** runs the
-normal processing again: it does not reuse the temporary result. Random
-processing without a fixed seed may therefore produce a different final result.
-Analysis, multi-input operations, operations without parameters and unknown
-custom parameter dialogs retain their existing workflow.
+**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;
diff --git a/doc/locale/fr/LC_MESSAGES/features/common/overview.po b/doc/locale/fr/LC_MESSAGES/features/common/overview.po
index ec169797..39b68c38 100644
--- a/doc/locale/fr/LC_MESSAGES/features/common/overview.po
+++ b/doc/locale/fr/LC_MESSAGES/features/common/overview.po
@@ -147,8 +147,8 @@ msgstr "Les boîtes de dialogue standard des traitements 1-vers-1 proposent une
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 "**Cancel** discards the draft parameters and stops the preview. **OK** runs the normal processing again: it does not reuse the temporary result. Random processing without a fixed seed may therefore produce a different final result. 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. **OK** exécute à nouveau le traitement normal : le résultat temporaire n'est pas réutilisé. Un traitement aléatoire sans graine fixée peut donc produire un résultat final différent. 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 "**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."
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 c8d26e35..895b0df1 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
@@ -21,8 +21,8 @@ msgstr "✨ Nouvelles fonctionnalités"
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 and OK 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é et OK exécute le traitement normal sur la sélection initiale."
+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 "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.03.md b/doc/release_notes/release_1.03.md
index 5adb89e0..85c7f0a7 100644
--- a/doc/release_notes/release_1.03.md
+++ b/doc/release_notes/release_1.03.md
@@ -6,7 +6,7 @@
**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 and OK performs the normal processing on the original selection.
+* 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.
* 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.
**History Panel:**
From c36d041616f3088b0b648fcc3898f8c3530de05b Mon Sep 17 00:00:00 2001
From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com>
Date: Wed, 9 Sep 2026 10:52:58 +0200
Subject: [PATCH 4/7] feat(preview): implement preview executor cache for
efficient resource management
---
datalab/gui/main.py | 5 +
datalab/gui/panel/base.py | 29 ++-
datalab/gui/processor/base.py | 1 +
datalab/gui/processor/preview.py | 85 +++++++-
.../backbone/preview_executor_unit_test.py | 102 +++++++++-
.../common/processing_preview_unit_test.py | 184 +++++++++++++++++-
.../features/plugins/plugins_app_test.py | 10 +-
datalab/widgets/processingpreview.py | 67 ++++++-
datalab/widgets/replacespecialvalues.py | 9 +-
doc/features/advanced/plugins.rst | 5 +
doc/features/common/overview.rst | 6 +
.../LC_MESSAGES/features/advanced/plugins.po | 3 +
.../LC_MESSAGES/features/common/overview.po | 3 +
.../LC_MESSAGES/release_notes/release_1.03.po | 3 +
doc/release_notes/release_1.03.md | 1 +
15 files changed, 488 insertions(+), 25 deletions(-)
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 3f793515..802cfd96 100644
--- a/datalab/gui/panel/base.py
+++ b/datalab/gui/panel/base.py
@@ -906,21 +906,32 @@ def setup_processing_tab(
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,
diff --git a/datalab/gui/processor/base.py b/datalab/gui/processor/base.py
index f3e9ecdf..88454151 100644
--- a/datalab/gui/processor/base.py
+++ b/datalab/gui/processor/base.py
@@ -1855,6 +1855,7 @@ def compute_1_to_1(
self.mainwindow,
allowed,
preview_results,
+ executor_cache=self.mainwindow.preview_executor_cache,
):
return
if any(
diff --git a/datalab/gui/processor/preview.py b/datalab/gui/processor/preview.py
index 6851cbe9..56831faf 100644
--- a/datalab/gui/processor/preview.py
+++ b/datalab/gui/processor/preview.py
@@ -17,7 +17,7 @@
from datalab.gui.processor.base import run_with_env
from datalab.gui.processor.catcher import CompOut
-__all__ = ["PreviewController", "PreviewExecutor"]
+__all__ = ["PreviewController", "PreviewExecutor", "PreviewExecutorCache"]
class PreviewExecutor:
@@ -71,6 +71,65 @@ def close(self, wait: bool = False) -> None:
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.
@@ -88,10 +147,12 @@ def __init__(
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
@@ -146,10 +207,14 @@ def _start_pending(self) -> None:
self._pending = None
try:
if self._executor is None:
- self._executor = self._executor_factory()
+ 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()
@@ -196,13 +261,23 @@ def take_current_result(self, source: SignalObj | ImageObj) -> CompOut | None:
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
- if self._executor is not None:
- self._executor.close()
- self._executor = 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/tests/backbone/preview_executor_unit_test.py b/datalab/tests/backbone/preview_executor_unit_test.py
index 78946132..b4985520 100644
--- a/datalab/tests/backbone/preview_executor_unit_test.py
+++ b/datalab/tests/backbone/preview_executor_unit_test.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import multiprocessing
+import os
import time
import numpy as np
@@ -11,7 +12,30 @@
from sigima.proc.signal import gaussian_filter
from datalab.gui.processor import base
-from datalab.gui.processor.preview import PreviewExecutor
+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):
@@ -21,6 +45,82 @@ def slow_identity(source, started):
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)))
diff --git a/datalab/tests/features/common/processing_preview_unit_test.py b/datalab/tests/features/common/processing_preview_unit_test.py
index be8fe7fb..58ef0131 100644
--- a/datalab/tests/features/common/processing_preview_unit_test.py
+++ b/datalab/tests/features/common/processing_preview_unit_test.py
@@ -7,12 +7,13 @@
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
+from datalab.gui.processor.preview import PreviewController, PreviewExecutorCache
from datalab.objectmodel import set_number
from datalab.widgets.processingpreview import ProcessingPreviewDialog
@@ -33,6 +34,15 @@ 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():
@@ -85,6 +95,41 @@ def test_preview_latest_request_and_invalidation():
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
@@ -148,6 +193,78 @@ def test_dialog_is_opt_in_and_transactional():
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
@@ -209,6 +326,45 @@ def accept(dialog):
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
@@ -221,6 +377,8 @@ def test_processor_reuses_only_current_single_object_preview(monkeypatch):
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):
@@ -228,7 +386,6 @@ def counted_filter(src, param):
return gaussian_filter(src, param)
def accept_current_preview(dialog):
- dialog.preview.controller._executor_factory = lambda: executor
dialog.preview.enabled.setChecked(True)
assert len(executor.requests) == 1
preview_result = gaussian_filter(source, dialog.instance)
@@ -259,9 +416,10 @@ def accept_current_preview(dialog):
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.controller._executor_factory = lambda: multi_executor
dialog.preview.enabled.setChecked(True)
assert len(multi_executor.requests) == 1
preview_source = dialog.preview.sources[0]
@@ -337,9 +495,25 @@ def test_processing_tab_debounces_valid_released_editor(monkeypatch):
applied = []
editor.SIG_APPLY_BUTTON_CLICKED.disconnect()
editor.SIG_APPLY_BUTTON_CLICKED.connect(lambda: applied.append(True))
- prop._ObjectProp__set_auto_recompute_enabled(True)
- timer = prop._ObjectProp__auto_recompute_timer
+ 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)
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/processingpreview.py b/datalab/widgets/processingpreview.py
index e3e4646a..989ebea1 100644
--- a/datalab/widgets/processingpreview.py
+++ b/datalab/widgets/processingpreview.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import copy
+import functools
from collections.abc import Callable, Sequence
from guidata.configtools import get_icon
@@ -36,6 +37,7 @@ def edit_processing_parameters(
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 (
@@ -45,14 +47,19 @@ def edit_processing_parameters(
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)
+ dialog.attach_preview(function, sources, controller_factory)
elif type(instance).edit is DataSet.edit:
- dialog = ProcessingPreviewDialog(instance, function, sources, parent)
+ dialog = ProcessingPreviewDialog(
+ instance, function, sources, parent, controller_factory
+ )
else:
return bool(instance.edit(parent=parent))
try:
@@ -136,6 +143,41 @@ def __init__(
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)
@@ -149,6 +191,7 @@ def __init__(
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)
@@ -157,8 +200,28 @@ 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)
diff --git a/datalab/widgets/replacespecialvalues.py b/datalab/widgets/replacespecialvalues.py
index 211c52f1..642a2e43 100644
--- a/datalab/widgets/replacespecialvalues.py
+++ b/datalab/widgets/replacespecialvalues.py
@@ -295,11 +295,16 @@ def _slider_gesture(self, pressed: bool) -> None:
if self.preview is not None:
self.preview.slider_gesture(pressed)
- def attach_preview(self, function, sources) -> None:
+ 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
- self.preview = ProcessingPreviewWidget(function, sources, self)
+ 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)
diff --git a/doc/features/advanced/plugins.rst b/doc/features/advanced/plugins.rst
index f7e261c1..f8b8b464 100644
--- a/doc/features/advanced/plugins.rst
+++ b/doc/features/advanced/plugins.rst
@@ -54,6 +54,11 @@ Live preview compatibility
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 56cf7de3..e8f6bcc5 100644
--- a/doc/features/common/overview.rst
+++ b/doc/features/common/overview.rst
@@ -149,6 +149,12 @@ 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
diff --git a/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po b/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po
index 4d10b7a5..9a21dd62 100644
--- a/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po
+++ b/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po
@@ -25,6 +25,9 @@ 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 39b68c38..7f4d879e 100644
--- a/doc/locale/fr/LC_MESSAGES/features/common/overview.po
+++ b/doc/locale/fr/LC_MESSAGES/features/common/overview.po
@@ -147,6 +147,9 @@ msgstr "Les boîtes de dialogue standard des traitements 1-vers-1 proposent une
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."
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 895b0df1..45b1926d 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
@@ -24,6 +24,9 @@ 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.03.md b/doc/release_notes/release_1.03.md
index 85c7f0a7..96f29d08 100644
--- a/doc/release_notes/release_1.03.md
+++ b/doc/release_notes/release_1.03.md
@@ -7,6 +7,7 @@
**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.
**History Panel:**
From 7eb666446be857a238a00a780c23d0ecf275d1f1 Mon Sep 17 00:00:00 2001
From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com>
Date: Wed, 9 Sep 2026 20:33:44 +0200
Subject: [PATCH 5/7] feat(image): add brightness and contrast processing
---
datalab/gui/actionhandler.py | 1 +
datalab/gui/panel/base.py | 30 ++-
datalab/gui/processor/image.py | 5 +
datalab/locale/fr/LC_MESSAGES/datalab.po | 6 +
.../image/brightness_contrast_app_test.py | 254 ++++++++++++++++++
doc/features/image/menu_processing.rst | 5 +-
.../features/image/menu_processing.po | 13 +-
.../LC_MESSAGES/release_notes/release_1.03.po | 6 +
doc/release_notes/release_1.03.md | 5 +
9 files changed, 308 insertions(+), 17 deletions(-)
create mode 100644 datalab/tests/features/image/brightness_contrast_app_test.py
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/panel/base.py b/datalab/gui/panel/base.py
index 802cfd96..2c728506 100644
--- a/datalab/gui/panel/base.py
+++ b/datalab/gui/panel/base.py
@@ -850,20 +850,22 @@ 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
from datalab.widgets.processingparameters import ProcessingParametersEditor
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/locale/fr/LC_MESSAGES/datalab.po b/datalab/locale/fr/LC_MESSAGES/datalab.po
index 2fc1ce4f..26fbe3c6 100644
--- a/datalab/locale/fr/LC_MESSAGES/datalab.po
+++ b/datalab/locale/fr/LC_MESSAGES/datalab.po
@@ -4155,3 +4155,9 @@ msgstr "Aucun contour n'a été trouvé pour la plage de niveaux sélectionnée.
msgid "Show contour plot..."
msgstr "Afficher le tracé de contours..."
+
+msgid "Brightness and contrast"
+msgstr "Luminosité et contraste"
+
+msgid "This feature requires exactly one selected object."
+msgstr "Cette fonctionnalité nécessite de sélectionner exactement un objet."
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/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/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 45b1926d..31d53a9a 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
@@ -18,6 +18,12 @@ msgstr "DataLab Version 1.3.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 "**Live processing preview:**"
msgstr "**Aperçu interactif des traitements :**"
diff --git a/doc/release_notes/release_1.03.md b/doc/release_notes/release_1.03.md
index 96f29d08..4d401867 100644
--- a/doc/release_notes/release_1.03.md
+++ b/doc/release_notes/release_1.03.md
@@ -4,6 +4,11 @@
### ✨ 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.
From 79c75a896dd2f66a98e00d2d2a0af3344e2adcb2 Mon Sep 17 00:00:00 2001
From: Thomas MALLET
Date: Thu, 10 Sep 2026 18:30:58 +0200
Subject: [PATCH 6/7] docs(release-notes): move brightness/contrast and live
preview to v1.4.0
The "Brightness and contrast" and "Live processing preview" entries were
added to the v1.3 release notes, but these features target v1.4.0.
* Add doc/release_notes/release_1.04.md with both sections
* Remove them from doc/release_notes/release_1.03.md
* Add the French catalog release_1.04.po with the corresponding
translations and drop the moved entries from release_1.03.po
---
datalab/locale/fr/LC_MESSAGES/datalab.po | 62 +++++++++----------
.../LC_MESSAGES/release_notes/release_1.03.po | 19 +-----
.../LC_MESSAGES/release_notes/release_1.04.po | 44 +++++++++++++
doc/release_notes/release_1.03.md | 11 ----
doc/release_notes/release_1.04.md | 17 +++++
5 files changed, 92 insertions(+), 61 deletions(-)
create mode 100644 doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po
create mode 100644 doc/release_notes/release_1.04.md
diff --git a/datalab/locale/fr/LC_MESSAGES/datalab.po b/datalab/locale/fr/LC_MESSAGES/datalab.po
index 26fbe3c6..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"
@@ -4081,33 +4111,6 @@ msgstr "Image"
msgid "Dimensions"
msgstr "Dimensions"
-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é."
-
-msgid "Preview source"
-msgstr "Source de l'aperçu"
-
-msgid "Preview disabled"
-msgstr "Aperçu désactivé"
-
-msgid "Invalid parameters"
-msgstr "Paramètres invalides"
-
-msgid "Updating preview..."
-msgstr "Mise à jour de l'aperçu..."
-
-msgid "Computing preview..."
-msgstr "Calcul 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 "Minimum value"
msgstr "Valeur minimum"
@@ -4156,8 +4159,3 @@ msgstr "Aucun contour n'a été trouvé pour la plage de niveaux sélectionnée.
msgid "Show contour plot..."
msgstr "Afficher le tracé de contours..."
-msgid "Brightness and contrast"
-msgstr "Luminosité et contraste"
-
-msgid "This feature requires exactly one selected object."
-msgstr "Cette fonctionnalité nécessite de sélectionner exactement un objet."
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 31d53a9a..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
@@ -18,24 +18,6 @@ msgstr "DataLab Version 1.3.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 "**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."
-
msgid "**History Panel:**"
msgstr "**Panneau d'historique :**"
@@ -401,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.03.md b/doc/release_notes/release_1.03.md
index 4d401867..a34b7176 100644
--- a/doc/release_notes/release_1.03.md
+++ b/doc/release_notes/release_1.03.md
@@ -4,17 +4,6 @@
### ✨ 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.
-
**History Panel:**
DataLab now records what you do and lets you replay it. A new **History** panel keeps track of every action applied to signals
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.
+
From 391a9b6e99107f0c4c73a1ae5e38cc5a776a7f7d Mon Sep 17 00:00:00 2001
From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com>
Date: Sat, 12 Sep 2026 13:59:48 +0200
Subject: [PATCH 7/7] test(preview): update test description and ensure plot
widget visibility in live image preview
---
.../tests/features/common/processing_preview_unit_test.py | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/datalab/tests/features/common/processing_preview_unit_test.py b/datalab/tests/features/common/processing_preview_unit_test.py
index 58ef0131..c7310a4e 100644
--- a/datalab/tests/features/common/processing_preview_unit_test.py
+++ b/datalab/tests/features/common/processing_preview_unit_test.py
@@ -574,7 +574,7 @@ def edit(self, parent=None):
def test_live_image_to_signal_preview(tmp_path, monkeypatch):
- """A Qt click drives a real spawn round-trip into a visible PlotPy curve."""
+ """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
@@ -623,7 +623,8 @@ def test_live_image_to_signal_preview(tmp_path, monkeypatch):
loop.exec_()
timeout.stop()
assert dialog.preview.item is not None, dialog.preview.details.toPlainText()
- assert dialog.preview.plotwidget.isVisible()
+ 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