Skip to content
1 change: 1 addition & 0 deletions datalab/gui/actionhandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
5 changes: 5 additions & 0 deletions datalab/gui/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
104 changes: 62 additions & 42 deletions datalab/gui/panel/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -842,23 +850,27 @@ def setup_processing_tab(
if isinstance(param, list):
return False

# Eventually call the `update_from_obj` method to properly initialize
# the parameter object from the current object state.
# Only do this when reset_params is True (initial setup), not when
# refreshing after user has modified parameters.
if reset_params and hasattr(param, "update_from_obj"):
# Warning: the `update_from_obj` method takes the input object as argument,
# not the output object (`obj` is the processed object here):
# Retrieve the input object from the source UUID
if proc_params.source_uuid is not None:
source_obj = self.panel.mainwindow.find_object_by_uuid(
proc_params.source_uuid
)
if source_obj is not None:
param.update_from_obj(source_obj)
# Source-aware parameters may refresh transient editor context without
# replacing their persisted values. Legacy parameters keep the previous
# reset-only initialization behavior.
source_obj = None
if proc_params.source_uuid is not None:
source_obj = self.panel.mainwindow.find_object_by_uuid(
proc_params.source_uuid
)
if hasattr(param, "update_editor_context"):
param.update_editor_context(source_obj)
elif (
reset_params
and source_obj is not None
and hasattr(param, "update_from_obj")
):
param.update_from_obj(source_obj)

# Create parameter editor widget
editor = gdq.DataSetEditGroupBox(
from datalab.widgets.processingparameters import ProcessingParametersEditor

editor = ProcessingParametersEditor(
_("Processing Parameters"), param.__class__, wordwrap=True
)
update_dataset(editor.dataset, param)
Expand All @@ -868,22 +880,7 @@ def setup_processing_tab(
editor.SIG_APPLY_BUTTON_CLICKED.connect(self.apply_processing_parameters)
editor.set_apply_button_state(False)

# Hook into the per-edit change callback to support auto-recompute.
# ``DataSetEditLayout.change_callback`` is called whenever any widget
# value changes; wrap it so we can also (re)start the debounce timer.
try:
inner_layout = editor.edit # DataSetEditLayout instance
original_change_cb = inner_layout.change_callback

def _wrapped_change_cb() -> None:
if original_change_cb is not None:
original_change_cb()
if self.__auto_recompute_enabled:
self.__auto_recompute_timer.start(300)

inner_layout.change_callback = _wrapped_change_cb
except AttributeError:
pass
editor.on_change = lambda: self.__processing_parameters_changed(editor)

# Store reference to be able to retrieve it later
self.processing_param_editor = editor
Expand Down Expand Up @@ -911,21 +908,32 @@ def _wrapped_change_cb() -> None:
QW.QSizePolicy.Expanding, QW.QSizePolicy.Preferred
)

# Build the tab content: editor + "Auto-recompute" checkbox.
container = QW.QWidget()
vbox = QW.QVBoxLayout(container)
vbox.setContentsMargins(0, 0, 0, 0)
vbox.addWidget(editor)
auto_cb = QW.QCheckBox(_("Auto-recompute on edit"), container)
# Add the auto-recompute option below Apply, aligned with input fields.
auto_cb = QW.QCheckBox(_("Auto-recompute on edit"), editor)
auto_cb.setObjectName("auto_recompute_on_edit")
auto_cb.setIcon(get_icon("replay.svg"))
auto_cb.setToolTip(
_("Automatically re-run processing when parameters are modified")
)
auto_cb.setChecked(self.__auto_recompute_enabled)
auto_cb.toggled.connect(self.__set_auto_recompute_enabled)
vbox.addWidget(auto_cb)
vbox.addStretch(1)
form_layout = editor.edit.layout
apply_index = form_layout.indexOf(editor.apply_button)
apply_row, _column, _row_span, _column_span = form_layout.getItemPosition(
apply_index
)
input_column = 1
input_column_span = max(1, form_layout.columnCount() - input_column)
form_layout.addWidget(
auto_cb,
apply_row + 1,
input_column,
1,
input_column_span,
QC.Qt.AlignLeft,
)

self.processing_scroll.setWidget(container)
self.processing_scroll.setWidget(editor)
self.tabwidget.insertTab(
insert_index,
self.processing_scroll,
Expand Down Expand Up @@ -1138,17 +1146,29 @@ def __set_auto_recompute_enabled(self, enabled: bool) -> None:
if not self.__auto_recompute_enabled:
self.__auto_recompute_timer.stop()

def __processing_parameters_changed(self, editor) -> None:
"""Debounce real processing only for the current valid, released editor."""
if editor is not self.processing_param_editor:
return
self.__auto_recompute_timer.stop()
if (
self.__auto_recompute_enabled
and not editor.dragging
and editor.edit.check_all_values()
):
self.__auto_recompute_timer.start(300)

def __auto_recompute_trigger(self) -> None:
"""Debounced callback: push widget values then re-run processing."""
if not self.__auto_recompute_enabled:
return
editor = self.processing_param_editor
if editor is None:
if editor is None or editor.dragging or not editor.edit.check_all_values():
return
# ``editor.set()`` synchronises widget values to the dataset and emits
# ``SIG_APPLY_BUTTON_CLICKED`` which is already wired to
# ``apply_processing_parameters``.
editor.set(check=False)
editor.set()

def apply_processing_parameters(
self,
Expand Down
92 changes: 81 additions & 11 deletions datalab/gui/processor/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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."""
Expand Down Expand Up @@ -1649,7 +1651,11 @@ def recompute_1_to_0(
return result is not None and result.execution_success

def _compute_1_to_1_subroutine(
self, funcs: list[Callable], params: list, title: str
self,
funcs: list[Callable],
params: list,
title: str,
preview_result: tuple[SignalObj | ImageObj, CompOut] | None = None,
) -> None:
"""Generic subroutine for 1-to-1 processing.

Expand All @@ -1672,8 +1678,12 @@ def _compute_1_to_1_subroutine(
i_title = f"{title} ({pvalue}/{n_glob})"
progress.setLabelText(i_title)
progress.setValue(pvalue)
args = (obj,) if param is None else (obj, param)
result = self.__exec_func(func, args, progress)
if preview_result is not None and preview_result[0] is obj:
result = preview_result[1]
preview_result = None
else:
args = (obj,) if param is None else (obj, param)
result = self.__exec_func(func, args, progress)
if result is None:
break
new_obj = self.handle_output(
Expand Down Expand Up @@ -1792,6 +1802,7 @@ def compute_1_to_1(
title: str | None = None,
comment: str | None = None,
edit: bool | None = None,
preview_enabled: bool = True,
) -> None:
"""Generic processing method: 1 object in → 1 object out.

Expand All @@ -1808,21 +1819,64 @@ 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).

.. note::
This method does not support pairwise mode.
"""
if (edit is None or param is None) and paramclass is not None:
old_edit = edit
edit, param = self.init_param(param, paramclass, title, comment)
if old_edit is not None:
edit = old_edit
if param is not None:
if edit and not param.edit(parent=self.mainwindow):
sources = self.panel.objview.get_sel_objects(include_groups=True)
groups = self.panel.objview.get_sel_groups()
if not sources:
return
remember_defaults = param is None and paramclass is not None
if remember_defaults:
param = paramclass(title, comment)
defaults = self.PARAM_DEFAULTS.get(paramclass.__name__)
if defaults is not None:
gds.update_dataset(param, copy.deepcopy(defaults))
if hasattr(param, "update_from_obj"):
param.update_from_obj(copy.deepcopy(sources[0]))
if edit is None:
edit = True
if param is not None and edit:
from datalab.widgets.processingpreview import edit_processing_parameters

draft = copy.deepcopy(param)
preview_results = []
feature = self.computing_registry.get(func.__name__)
allowed = preview_enabled and (feature is None or feature.preview_enabled)
if not edit_processing_parameters(
draft,
func,
sources,
self.mainwindow,
allowed,
preview_results,
executor_cache=self.mainwindow.preview_executor_cache,
):
return
if any(
get_uuid(source) not in self.panel.objmodel.get_object_ids()
for source in sources
):
QW.QMessageBox.warning(
self.mainwindow,
_("Warning"),
_("A preview source was removed. The processing was cancelled."),
)
return
gds.update_dataset(param, draft)
for index, selected in enumerate(groups or sources):
self.panel.objview.set_current_item_id(
get_uuid(selected), extend=index > 0
)
else:
preview_results = []
if remember_defaults:
self.PARAM_DEFAULTS[type(param).__name__] = copy.deepcopy(param)
plugin_origin = self._get_plugin_origin_for(func)
pp = build_processing_parameters(
func.__name__, "1-to-1", param=param, plugin_origin=plugin_origin
Expand All @@ -1834,7 +1888,16 @@ def compute_1_to_1(
plugin_origin=plugin_origin,
)
with self.mainwindow.historypanel.capture_outputs(action):
self._compute_1_to_1_subroutine([func], [param], title)
self._compute_1_to_1_subroutine(
[func],
[param],
title,
preview_result=(
preview_results[0]
if len(sources) == 1 and not groups and preview_results
else None
),
)

def compute_multiple_1_to_1(
self,
Expand Down Expand Up @@ -2663,6 +2726,7 @@ def register_1_to_1(
icon_name: str | None = None,
comment: str | None = None,
edit: bool | None = None,
preview_enabled: bool = True,
) -> ComputingFeature:
"""Register a 1-to-1 processing function.

Expand All @@ -2678,6 +2742,7 @@ def register_1_to_1(
icon_name: icon name. Defaults to None.
comment: comment. Defaults to None.
edit: whether to open the parameter editor before execution.
preview_enabled: allow speculative execution before accepting parameters.

Returns:
Registered feature.
Expand All @@ -2690,6 +2755,7 @@ def register_1_to_1(
icon_name=icon_name,
comment=comment,
edit=edit,
preview_enabled=preview_enabled,
)
self.add_feature(feature)
return feature
Expand Down Expand Up @@ -2975,6 +3041,10 @@ def run_feature(
f"For pattern '{pattern}', 'param' must be a DataSet or None"
)
compute_kwargs = {}
if pattern == "1_to_1":
compute_kwargs["preview_enabled"] = kwargs.pop(
"preview_enabled", feature.preview_enabled
)
if pattern == "n_to_1":
compute_kwargs["pairwise"] = kwargs.pop("pairwise", None)
return compute_method(
Expand Down
Loading
Loading