From 46da83a4349693f6c6a76d7615e99d69c35bc26f Mon Sep 17 00:00:00 2001 From: Duy Anh Philippe PHAM Date: Fri, 28 Aug 2026 10:25:40 +0200 Subject: [PATCH 1/9] Feat: Add format-aware image export options Image exports need destination-specific controls to avoid unsafe implicit data conversion, while automated saves and legacy formats must remain compatible. The export workflow now uses Sigima capabilities and records only completed, replayable file outputs with stable object references. * [NEW] : Add a format-aware image export dialog after filename selection with safe normalization defaults and supported data type choices * [CHG] : Forward optional export parameters to Sigima while preserving legacy formats and explicit noninteractive saves * [FIX] : Record only confirmed and successful exports in file-output history and replay them against their original object UUIDs * [FIX] : Skip stale object references without misaligning filenames or export parameters * [FIX] : Preserve mixed export parameter and empty entries across history encoding and HDF5 persistence, including legacy representations * [NEW] : Add focused tests for format defaults, cancellation, failed writes, stale UUIDs, explicit saves, legacy formats, and history round trips * [NEW] : Add French translations for the image export interface and warnings --- datalab/gui/panel/base.py | 109 ++++- datalab/gui/panel/image.py | 87 ++++ datalab/history/core.py | 18 +- datalab/locale/fr/LC_MESSAGES/datalab.po | 12 + .../tests/features/image/export_unit_test.py | 443 ++++++++++++++++++ 5 files changed, 648 insertions(+), 21 deletions(-) create mode 100644 datalab/tests/features/image/export_unit_test.py diff --git a/datalab/gui/panel/base.py b/datalab/gui/panel/base.py index eb79ccbe9..0cb0687a9 100644 --- a/datalab/gui/panel/base.py +++ b/datalab/gui/panel/base.py @@ -111,6 +111,7 @@ if TYPE_CHECKING: from plotpy.items import CurveItem, LabelItem, MaskedXYImageItem + from sigima.io import ImageExportParam from sigima.io.image import ImageIORegistry from sigima.io.signal import SignalIORegistry @@ -2662,13 +2663,34 @@ def __load_from_file( self.selection_changed() return objs - def __save_to_file(self, obj: TypeObj, filename: str) -> None: + def edit_export_parameters( + self, obj: TypeObj, filename: str + ) -> tuple[bool, ImageExportParam | None]: + """Edit optional object export parameters. + + Args: + obj: Object to export + filename: File name + + Returns: + A tuple containing the confirmation state and optional export parameters + """ + return True, None + + def write_object_to_file( + self, + obj: TypeObj, + filename: str, + param: ImageExportParam | None = None, + ) -> None: """Save object to file (signal/image). Args: obj: object filename: file name + param: optional image export parameters """ + del param self.IO_REGISTRY.write(filename, obj) def load_from_directory(self, directory: str | None = None) -> list[TypeObj]: @@ -2831,24 +2853,57 @@ def load_from_files( self.mainwindow.historypanel.tree.refresh_action_item(action) return objs - def save_to_files(self, filenames: list[str] | str | None = None) -> None: + def save_to_files( + self, + filenames: list[str] | str | None = None, + export_params: list[ImageExportParam | None] | None = None, + object_uuids: list[str] | None = None, + ) -> None: """Save selected objects to files (signal/image). Args: filenames: File names + export_params: Optional image export parameters aligned with file names + object_uuids: Optional object UUIDs aligned with file names """ - objs = self.objview.get_sel_objects(include_groups=True) if isinstance(filenames, str): filenames = [filenames] - if filenames is None: # pragma: no cover - filenames = [None] * len(objs) - assert len(filenames) == len(objs), ( + interactive = filenames is None + if object_uuids is None: + objs = self.objview.get_sel_objects(include_groups=True) + object_uuids = [get_uuid(obj) for obj in objs] + else: + objs = None + object_count = len(object_uuids) + if interactive: # pragma: no cover + filenames = [None] * object_count + assert len(filenames) == object_count, ( "Number of filenames must match number of objects" ) + if export_params is None: + export_params = [None] * object_count + assert len(export_params) == object_count, ( + "Number of export parameters must match number of objects" + ) + aligned: list[tuple[TypeObj, str | None, ImageExportParam | None, str]] = [] + if objs is not None: + aligned = list(zip(objs, filenames, export_params, object_uuids)) + else: + for object_uuid, filename, param in zip( + object_uuids, filenames, export_params + ): + if not self.objmodel.has_uuid(object_uuid): + warnings.warn( + _("Skipping export: object %r no longer exists.") % object_uuid + ) + continue + aligned.append( + (self.objmodel[object_uuid], filename, param, object_uuid) + ) # Ask for missing file names first, so that the history entry reflects the # actual files written (and is skipped altogether if the user cancels) - pairs: list[tuple[TypeObj, str]] = [] - for obj, filename in zip(objs, filenames): + pairs: list[tuple[TypeObj, str, ImageExportParam | None, str]] = [] + for obj, filename, param, object_uuid in aligned: if filename is None: # pragma: no cover basedir = Conf.base_dir.get() filters = self.IO_REGISTRY.get_write_filters() @@ -2857,25 +2912,45 @@ def save_to_files(self, filenames: list[str] | str | None = None) -> None: self, _("Save as"), basedir, filters ) if filename: - pairs.append((obj, filename)) + confirmed = True + if interactive: # pragma: no cover + confirmed, param = self.edit_export_parameters(obj, filename) + if confirmed: + pairs.append((obj, filename, param, object_uuid)) if not pairs: # pragma: no cover return - nbf = len(pairs) + successful_pairs: list[tuple[TypeObj, str, ImageExportParam | None, str]] = [] + for obj, filename, param, object_uuid in pairs: + with qt_try_loadsave_file(self.parentWidget(), filename, "save"): + Conf.base_dir.set(filename) + self.write_object_to_file(obj, filename, param) + successful_pairs.append((obj, filename, param, object_uuid)) + if not successful_pairs: + return + nbf = len(successful_pairs) if nbf > 1: entry_title = _("Save to %d different files") % nbf else: - entry_title = _('Save to "%s"') % osp.basename(pairs[0][1]) + entry_title = _('Save to "%s"') % osp.basename(successful_pairs[0][1]) + history_kwargs: dict[str, Any] = { + "filenames": [ + filename for _obj, filename, _param, _uuid in successful_pairs + ], + "object_uuids": [ + object_uuid for _obj, _filename, _param, object_uuid in successful_pairs + ], + } + if any(param is not None for _obj, _filename, param, _uuid in successful_pairs): + history_kwargs["export_params"] = [ + param for _obj, _filename, param, _uuid in successful_pairs + ] self.mainwindow.historypanel.add_ui_entry( entry_title, target=self.PANEL_STR_ID + "panel", method_name="save_to_files", save_state=False, - filenames=[filename for _obj, filename in pairs], + **history_kwargs, ) - for obj, filename in pairs: - with qt_try_loadsave_file(self.parentWidget(), filename, "save"): - Conf.base_dir.set(filename) - self.__save_to_file(obj, filename) def save_to_directory(self, param: SaveToDirectoryParam | None = None) -> None: """Save signals or images to directory using a filename pattern. @@ -2930,7 +3005,7 @@ def save_to_directory(self, param: SaveToDirectoryParam | None = None) -> None: if progress.wasCanceled(): break with qt_try_loadsave_file(self.parentWidget(), path, "save"): - self.__save_to_file(obj, path) + self.write_object_to_file(obj, path) def handle_dropped_files(self, filenames: list[str] | None = None) -> None: """Handle dropped files diff --git a/datalab/gui/panel/image.py b/datalab/gui/panel/image.py index bb6ed4e51..908370db6 100644 --- a/datalab/gui/panel/image.py +++ b/datalab/gui/panel/image.py @@ -8,9 +8,13 @@ from __future__ import annotations +import os.path as osp from typing import TYPE_CHECKING, Type from weakref import ReferenceType, ref +import guidata.dataset as gds +import numpy as np +from guidata.dataset import update_dataset from plotpy.interfaces import IVoiImageItemType from plotpy.tools import ( AnnotatedCircleTool, @@ -20,6 +24,7 @@ AnnotatedSegmentTool, LabelTool, ) +from sigima.io import ImageExportParam, get_supported_export_dtypes, write_image from sigima.io.image import ImageIORegistry from sigima.objects import ImageDatatypes, ImageObj, ImageROI, NewImageParam @@ -39,6 +44,51 @@ from datalab.gui.docks import DockablePlotWidget +def get_image_export_dtype_choices( + param: ImageExportGUIParam, _item: gds.ChoiceItem, _value: str +) -> list[tuple[str, str, None]]: + """Return target dtype choices supported by the selected image format.""" + return [ + (dtype, _("Automatic") if dtype == "auto" else dtype, None) + for dtype in param.supported_dtypes + ] + + +class ImageExportGUIParam(ImageExportParam, title=_("Image export")): + """Image export parameters restricted to the selected file format.""" + + supported_dtypes: tuple[str, ...] = ("auto",) + target_dtype = gds.ChoiceItem( + _("Target data type"), get_image_export_dtype_choices, default="auto" + ) + + +def create_image_export_gui_param(obj: ImageObj, filename: str) -> ImageExportGUIParam: + """Create format-aware image export parameters with practical defaults. + + Args: + obj: Source image + filename: Destination file name + + Returns: + Image export GUI parameters + """ + param = ImageExportGUIParam() + param.supported_dtypes = get_supported_export_dtypes(filename) + source_dtype = np.dtype(obj.data.dtype) + concrete_dtypes = param.supported_dtypes[1:] + source_is_supported = source_dtype.name in concrete_dtypes + preserves_source = osp.splitext(filename)[1].lower() == ".npy" + forces_integer_conversion = concrete_dtypes and all( + np.issubdtype(np.dtype(dtype), np.integer) for dtype in concrete_dtypes + ) + if not (source_is_supported or preserves_source) and forces_integer_conversion: + param.normalization = "minmax" + param.behavior = "rescale" + param.target_dtype = "auto" + return param + + class ImagePanel(BaseDataPanel[ImageObj, ImageROI, roieditor.ImageROIEditor]): """Object handling the item list, the selected item properties and plot, specialized for Image objects""" @@ -75,6 +125,43 @@ def get_roieditor_class() -> Type[roieditor.ImageROIEditor]: """Return ROI editor class""" return roieditor.ImageROIEditor + def edit_export_parameters( + self, obj: ImageObj, filename: str + ) -> tuple[bool, ImageExportParam | None]: + """Edit image export parameters for the selected file format. + + Args: + obj: Image to export + filename: Destination file name + + Returns: + A tuple containing the confirmation state and export parameters + """ + try: + guiparam = create_image_export_gui_param(obj, filename) + except ValueError: + return True, None + if not guiparam.edit(parent=self.parentWidget()): + return False, None + param = ImageExportParam() + update_dataset(param, guiparam) + return True, param + + def write_object_to_file( + self, + obj: ImageObj, + filename: str, + param: ImageExportParam | None = None, + ) -> None: + """Write an image with optional format-aware export parameters. + + Args: + obj: Image to export + filename: Destination file name + param: Optional image export parameters + """ + write_image(filename, obj, param) + def __init__( self, parent: QW.QWidget, diff --git a/datalab/history/core.py b/datalab/history/core.py index 709e66c74..1e54d80e2 100644 --- a/datalab/history/core.py +++ b/datalab/history/core.py @@ -34,6 +34,7 @@ # arbitrary Python objects. _DATASET_MARKER = "__dataset_json__" _DATASET_LIST_MARKER = "__dataset_list_json__" +_DATASET_LIST_NONE_SENTINEL = "__datalab_history_dataset_none__" _ROI_MARKER = "__roi_json__" @@ -111,7 +112,7 @@ def decode_roi(encoded: str) -> Any: def encode_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]: - """Encode kwargs for HDF5 storage: replace ``DataSet``, ``list[DataSet]``, + """Encode kwargs for HDF5 storage: replace ``DataSet``, DataSet lists, and sigima ROI values with marker dicts holding their JSON representation. All other values must already be HDF5-friendly primitives (str, int, float, @@ -134,10 +135,16 @@ def encode_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]: elif ( isinstance(value, list) and value - and all(isinstance(item, DataSet) for item in value) + and any(isinstance(item, DataSet) for item in value) + and all(item is None or isinstance(item, DataSet) for item in value) ): encoded[key] = { - _DATASET_LIST_MARKER: [dataset_to_json(item) for item in value] + _DATASET_LIST_MARKER: [ + dataset_to_json(item) + if item is not None + else _DATASET_LIST_NONE_SENTINEL + for item in value + ] } else: encoded[key] = value @@ -179,7 +186,10 @@ def decode_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]: elif isinstance(value, dict) and _DATASET_LIST_MARKER in value: try: decoded[key] = [ - json_to_dataset(item) for item in value[_DATASET_LIST_MARKER] + None + if item in (None, "", _DATASET_LIST_NONE_SENTINEL) + else json_to_dataset(item) + for item in value[_DATASET_LIST_MARKER] ] except (TypeError, ValueError, KeyError): warnings.warn( diff --git a/datalab/locale/fr/LC_MESSAGES/datalab.po b/datalab/locale/fr/LC_MESSAGES/datalab.po index 441bc6677..36922f93c 100644 --- a/datalab/locale/fr/LC_MESSAGES/datalab.po +++ b/datalab/locale/fr/LC_MESSAGES/datalab.po @@ -4378,3 +4378,15 @@ 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 "Automatic" +msgstr "Automatique" + +msgid "Image export" +msgstr "Exportation d'image" + +msgid "Target data type" +msgstr "Type de données cible" + +#, python-format +msgid "Skipping export: object %r no longer exists." +msgstr "Export ignoré : l'objet %r n'existe plus." diff --git a/datalab/tests/features/image/export_unit_test.py b/datalab/tests/features/image/export_unit_test.py new file mode 100644 index 000000000..57784c4d8 --- /dev/null +++ b/datalab/tests/features/image/export_unit_test.py @@ -0,0 +1,443 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Format-aware image export unit tests.""" + +from __future__ import annotations + +from contextlib import contextmanager, nullcontext +from types import SimpleNamespace +from unittest.mock import MagicMock, Mock, call, patch + +import numpy as np +import pytest +from sigima.io import ImageExportParam + +from datalab.gui.panel import base as panel_base +from datalab.gui.panel import image as image_panel +from datalab.gui.panel.base import BaseDataPanel +from datalab.gui.panel.image import ( + ImageExportGUIParam, + ImagePanel, + create_image_export_gui_param, +) +from datalab.h5.native import NativeH5Reader, NativeH5Writer +from datalab.history.action import HistoryAction +from datalab.history.core import decode_kwargs, encode_kwargs +from datalab.history.session import HistorySession + + +class SavePanelStub: + """Minimal panel implementing the collaborators used by ``save_to_files``.""" + + PANEL_STR_ID = "image" + + def __init__( + self, objects: list[object], selected_objects: list[object] | None = None + ) -> None: + selected_objects = objects if selected_objects is None else selected_objects + self.objview = SimpleNamespace( + get_sel_objects=Mock(return_value=selected_objects) + ) + objects_by_uuid = {obj.metadata["__uuid"]: obj for obj in objects} + self.objmodel = MagicMock() + self.objmodel.has_uuid.side_effect = objects_by_uuid.__contains__ + self.objmodel.__getitem__.side_effect = objects_by_uuid.__getitem__ + io_registry = SimpleNamespace( + get_write_filters=Mock(return_value="Images (*.png)") + ) + setattr(self, "IO_REGISTRY", io_registry) + setattr(self, "parentWidget", self.parent_widget) + self.edit_export_parameters = Mock(return_value=(True, None)) + self.write_object_to_file = Mock() + self.historypanel = SimpleNamespace(add_ui_entry=Mock()) + self.mainwindow = SimpleNamespace(historypanel=self.historypanel) + + def parent_widget(self): + """Return the parent expected by the generic save flow.""" + return None + + +def create_stub_object(uuid: str) -> SimpleNamespace: + """Return a minimal object with a stable DataLab UUID.""" + return SimpleNamespace(metadata={"__uuid": uuid}) + + +@contextmanager +def suppress_write_error(*_args): + """Suppress an expected writer error like ``qt_try_loadsave_file``.""" + try: + yield + except OSError: + pass + + +def get_dtype_choice_keys(param) -> list[str]: + """Return the target dtype keys exposed by an export parameter.""" + item = next(item for item in param.get_items() if item.get_name() == "target_dtype") + choices = item.get_prop_value("data", param, "choices") + return [key for key, _label, _icon in choices] + + +@pytest.mark.parametrize( + ("extension", "expected_choices", "expected_normalizations"), + [ + ("png", ["auto", "uint8"], ["minmax", "minmax", "minmax"]), + ("jpeg", ["auto", "uint8"], ["minmax", "minmax", "minmax"]), + ("bmp", ["auto", "uint8"], ["minmax", "minmax", "minmax"]), + ( + "jp2", + ["auto", "uint8", "uint16"], + ["none", "minmax", "minmax"], + ), + ( + "tiff", + ["auto", "uint8", "uint16", "float32", "float64"], + ["none", "none", "none"], + ), + ( + "npy", + ["auto", "uint8", "uint16", "float32", "float64"], + ["none", "none", "none"], + ), + ], +) +def test_image_export_choices_and_defaults_are_format_aware( + extension: str, + expected_choices: list[str], + expected_normalizations: list[str], +) -> None: + """Check format defaults for representative unsigned, signed and float data.""" + for source_dtype, expected_normalization in zip( + (np.uint16, np.int32, np.float32), expected_normalizations + ): + source_data = np.array([[1, 3]], dtype=source_dtype) + image = SimpleNamespace(data=source_data) + + param = create_image_export_gui_param(image, f"image.{extension}") + + assert get_dtype_choice_keys(param) == expected_choices + assert param.normalization == expected_normalization + assert param.behavior == "rescale" + assert param.target_dtype == "auto" + np.testing.assert_array_equal(image.data, source_data) + + +def test_interactive_export_cancellation_skips_write_and_history_pair() -> None: + """Exclude a cancelled image export dialog from writing and history.""" + objects = [create_stub_object("uuid-1"), create_stub_object("uuid-2")] + panel = SavePanelStub(objects) + confirmed_param = ImageExportParam.create(normalization="minmax") + panel.edit_export_parameters.side_effect = [ + (False, None), + (True, confirmed_param), + ] + + with ( + patch.object( + panel_base, + "getsavefilename", + side_effect=[("cancelled.png", ""), ("confirmed.png", "")], + ), + patch.object(panel_base, "save_restore_stds", side_effect=nullcontext), + patch.object( + panel_base, + "qt_try_loadsave_file", + side_effect=lambda *_args: nullcontext(), + ), + patch.object(panel_base.Conf.base_dir, "get", return_value=""), + patch.object(panel_base.Conf.base_dir, "set"), + ): + BaseDataPanel.save_to_files(panel) + + panel.write_object_to_file.assert_called_once_with( + objects[1], "confirmed.png", confirmed_param + ) + history_call = panel.historypanel.add_ui_entry.call_args + assert history_call.kwargs["filenames"] == ["confirmed.png"] + assert history_call.kwargs["export_params"] == [confirmed_param] + assert history_call.kwargs["object_uuids"] == ["uuid-2"] + + +def test_object_uuids_resolve_exports_independently_of_selection() -> None: + """Resolve replay exports by recorded UUID and preserve their order.""" + first = create_stub_object("uuid-1") + second = create_stub_object("uuid-2") + unrelated = create_stub_object("uuid-unrelated") + panel = SavePanelStub([first, second, unrelated], selected_objects=[unrelated]) + + with ( + patch.object( + panel_base, + "qt_try_loadsave_file", + side_effect=lambda *_args: nullcontext(), + ), + patch.object(panel_base.Conf.base_dir, "set"), + ): + BaseDataPanel.save_to_files( + panel, + ["second.png", "first.png"], + object_uuids=["uuid-2", "uuid-1"], + ) + + panel.objview.get_sel_objects.assert_not_called() + panel.write_object_to_file.assert_has_calls( + [call(second, "second.png", None), call(first, "first.png", None)] + ) + history_call = panel.historypanel.add_ui_entry.call_args + assert history_call.kwargs["filenames"] == ["second.png", "first.png"] + assert history_call.kwargs["object_uuids"] == ["uuid-2", "uuid-1"] + + +def test_missing_object_uuid_is_warned_and_skipped_without_realigning() -> None: + """Skip a stale UUID while preserving each surviving export tuple.""" + first = create_stub_object("uuid-1") + second = create_stub_object("uuid-2") + panel = SavePanelStub([first, second]) + first_param = ImageExportParam.create(normalization="none") + second_param = ImageExportParam.create(normalization="minmax") + stale_param = ImageExportParam.create(normalization="percentile") + + with ( + patch.object( + panel_base, + "qt_try_loadsave_file", + side_effect=lambda *_args: nullcontext(), + ), + patch.object(panel_base.Conf.base_dir, "set"), + pytest.warns(UserWarning, match="uuid-missing"), + ): + BaseDataPanel.save_to_files( + panel, + ["second.png", "missing.png", "first.png"], + [second_param, stale_param, first_param], + ["uuid-2", "uuid-missing", "uuid-1"], + ) + + panel.write_object_to_file.assert_has_calls( + [ + call(second, "second.png", second_param), + call(first, "first.png", first_param), + ] + ) + history_call = panel.historypanel.add_ui_entry.call_args + assert history_call.kwargs["filenames"] == ["second.png", "first.png"] + assert history_call.kwargs["export_params"] == [second_param, first_param] + assert history_call.kwargs["object_uuids"] == ["uuid-2", "uuid-1"] + + +def test_all_missing_object_uuids_write_and_record_nothing() -> None: + """Do not write or record an export when all recorded UUIDs are stale.""" + panel = SavePanelStub([]) + + with pytest.warns(UserWarning, match="no longer exists") as caught: + BaseDataPanel.save_to_files( + panel, + ["first.png", "second.png"], + object_uuids=["uuid-missing-1", "uuid-missing-2"], + ) + + assert len(caught) == 2 + panel.write_object_to_file.assert_not_called() + panel.historypanel.add_ui_entry.assert_not_called() + + +def test_failed_write_is_excluded_from_history() -> None: + """Record only the tuple whose writer returned successfully.""" + objects = [create_stub_object("uuid-1"), create_stub_object("uuid-2")] + panel = SavePanelStub(objects) + panel.write_object_to_file.side_effect = [OSError("failed"), None] + + with ( + patch.object( + panel_base, + "qt_try_loadsave_file", + side_effect=suppress_write_error, + ), + patch.object(panel_base.Conf.base_dir, "set"), + ): + BaseDataPanel.save_to_files(panel, ["failed.png", "saved.png"]) + + history_call = panel.historypanel.add_ui_entry.call_args + assert history_call.kwargs["filenames"] == ["saved.png"] + assert history_call.kwargs["object_uuids"] == ["uuid-2"] + assert "export_params" not in history_call.kwargs + + +def test_all_failed_writes_create_no_history_action() -> None: + """Do not record an action when every writer call fails.""" + objects = [create_stub_object("uuid-1"), create_stub_object("uuid-2")] + panel = SavePanelStub(objects) + panel.write_object_to_file.side_effect = OSError("failed") + + with ( + patch.object( + panel_base, + "qt_try_loadsave_file", + side_effect=suppress_write_error, + ), + patch.object(panel_base.Conf.base_dir, "set"), + ): + BaseDataPanel.save_to_files(panel, ["failed-1.png", "failed-2.png"]) + + panel.historypanel.add_ui_entry.assert_not_called() + + +def test_explicit_filename_is_noninteractive_and_uses_legacy_path() -> None: + """Keep explicit saves noninteractive when no export parameter is supplied.""" + obj = create_stub_object("uuid-1") + panel = SavePanelStub([obj]) + + with ( + patch.object( + panel_base, + "qt_try_loadsave_file", + side_effect=lambda *_args: nullcontext(), + ), + patch.object(panel_base.Conf.base_dir, "set"), + ): + BaseDataPanel.save_to_files(panel, ["explicit.png"]) + + panel.edit_export_parameters.assert_not_called() + panel.write_object_to_file.assert_called_once_with(obj, "explicit.png", None) + history_call = panel.historypanel.add_ui_entry.call_args + assert history_call.kwargs["filenames"] == ["explicit.png"] + assert history_call.kwargs["object_uuids"] == ["uuid-1"] + assert "export_params" not in history_call.kwargs + + +@pytest.mark.parametrize("extension", ["h5", "csv", "txt", "asc", "mat"]) +def test_unsupported_extension_uses_legacy_writer_parameters(extension: str) -> None: + """Bypass export preparation and its dialog for writable legacy formats.""" + obj = SimpleNamespace(data=np.array([[1, 3]], dtype=np.float32)) + panel = SimpleNamespace(parentWidget=Mock()) + + with patch.object(ImageExportGUIParam, "edit") as edit: + result = ImagePanel.edit_export_parameters(panel, obj, f"image.{extension}") + + assert result == (True, None) + edit.assert_not_called() + panel.parentWidget.assert_not_called() + + +def test_export_dialog_validation_error_is_not_suppressed() -> None: + """Propagate validation errors raised after GUI parameter creation.""" + obj = SimpleNamespace(data=np.array([[1, 3]], dtype=np.float32)) + panel = SimpleNamespace(parentWidget=Mock(return_value=None)) + guiparam = Mock() + guiparam.edit.side_effect = ValueError("invalid parameters") + + with ( + patch.object( + image_panel, "create_image_export_gui_param", return_value=guiparam + ), + pytest.raises(ValueError, match="invalid parameters"), + ): + ImagePanel.edit_export_parameters(panel, obj, "image.png") + + +def test_explicit_image_export_parameter_is_forwarded() -> None: + """Forward an explicit export parameter through the image writer hook.""" + obj = object() + param = ImageExportParam.create(normalization="minmax") + with patch.object(image_panel, "write_image") as write_image: + ImagePanel.write_object_to_file(SimpleNamespace(), obj, "image.png", param) + write_image.assert_called_once_with("image.png", obj, param) + + +def test_default_export_hooks_preserve_signal_registry_write() -> None: + """Keep the generic signal/default path parameter-free and registry-based.""" + obj = object() + registry = SimpleNamespace(write=Mock()) + panel = SimpleNamespace(IO_REGISTRY=registry) + + assert BaseDataPanel.edit_export_parameters(panel, obj, "signal.csv") == ( + True, + None, + ) + BaseDataPanel.write_object_to_file(panel, obj, "signal.csv") + registry.write.assert_called_once_with("signal.csv", obj) + + +def test_history_codec_preserves_aligned_optional_export_parameters() -> None: + """Round-trip mixed export parameter lists without losing alignment.""" + param = ImageExportParam.create(normalization="minmax", target_dtype="auto") + + decoded = decode_kwargs(encode_kwargs({"export_params": [param, None]})) + + decoded_params = decoded["export_params"] + assert isinstance(decoded_params[0], ImageExportParam) + assert decoded_params[0].normalization == "minmax" + assert decoded_params[0].target_dtype == "auto" + assert decoded_params[1] is None + + +def test_history_hdf5_preserves_aligned_optional_export_parameters(tmp_path) -> None: + """Round-trip mixed export parameters through the native HDF5 codec.""" + param = ImageExportParam.create(normalization="minmax", target_dtype="auto") + action = HistoryAction( + title="Export", + target="imagepanel", + method_name="save_to_files", + kwargs={"export_params": [param, None]}, + ) + session = HistorySession(number=1) + session.add_action(action) + path = str(tmp_path / "history.dlhist") + + with NativeH5Writer(path) as writer: + writer.write_object_list([session], "history_session") + with NativeH5Reader(path) as reader: + loaded = reader.read_object_list("history_session", HistorySession)[0] + + decoded_params = loaded.actions[0].kwargs["export_params"] + assert isinstance(decoded_params[0], ImageExportParam) + assert decoded_params[0].normalization == "minmax" + assert decoded_params[0].target_dtype == "auto" + assert decoded_params[1] is None + + +@pytest.mark.parametrize("legacy_none", [None, ""]) +def test_history_codec_decodes_legacy_optional_dataset_entries(legacy_none) -> None: + """Decode legacy Python and HDF5 representations of optional entries.""" + param = ImageExportParam.create(normalization="minmax") + encoded = encode_kwargs({"export_params": [param, None]}) + encoded["export_params"]["__dataset_list_json__"][1] = legacy_none + + decoded_params = decode_kwargs(encoded)["export_params"] + + assert isinstance(decoded_params[0], ImageExportParam) + assert decoded_params[1] is None + + +def test_history_codec_malformed_dataset_list_payload_is_safe() -> None: + """Keep the warning and empty-list fallback for malformed nonempty JSON.""" + encoded = {"export_params": {"__dataset_list_json__": ["not-json"]}} + + with pytest.warns(UserWarning, match="DataSet-list"): + decoded = decode_kwargs(encoded) + + assert decoded["export_params"] == [] + + +def test_history_codec_preserves_primitive_lists() -> None: + """Round-trip primitive lists without treating them as DataSet lists.""" + kwargs = { + "filenames": ["first.png", "second.png"], + "object_uuids": ["1", "2"], + } + + assert decode_kwargs(encode_kwargs(kwargs)) == kwargs + + +def test_history_codec_preserves_homogeneous_dataset_lists() -> None: + """Round-trip lists containing only export parameter DataSets.""" + params = [ + ImageExportParam.create(normalization="none"), + ImageExportParam.create(normalization="minmax"), + ] + + decoded = decode_kwargs(encode_kwargs({"export_params": params})) + + decoded_params = decoded["export_params"] + assert all(isinstance(param, ImageExportParam) for param in decoded_params) + assert [param.normalization for param in decoded_params] == ["none", "minmax"] From cb67f2db20502738c62dd2706e432360844f421c Mon Sep 17 00:00:00 2001 From: Duy Anh Philippe PHAM Date: Fri, 28 Aug 2026 14:17:21 +0200 Subject: [PATCH 2/9] Feat: Add RAW image import workflow RAW imports need user-provided binary layout parameters and deterministic history replay without changing scientific pixel values. * [NEW] : Add the RAW parameter dialog and optional same-folder file expansion * [CHG] : Preserve per-file parameter snapshots across successful imports and history replay * [NEW] : Apply white-is-zero display options and add unit, replay and translation coverage --- datalab/gui/panel/base.py | 127 +++++- datalab/gui/panel/image.py | 76 +++- datalab/locale/fr/LC_MESSAGES/datalab.po | 6 + .../features/image/raw_import_app_test.py | 66 +++ .../features/image/raw_import_unit_test.py | 423 ++++++++++++++++++ 5 files changed, 686 insertions(+), 12 deletions(-) create mode 100644 datalab/tests/features/image/raw_import_app_test.py create mode 100644 datalab/tests/features/image/raw_import_unit_test.py diff --git a/datalab/gui/panel/base.py b/datalab/gui/panel/base.py index 0cb0687a9..074ebee06 100644 --- a/datalab/gui/panel/base.py +++ b/datalab/gui/panel/base.py @@ -2629,7 +2629,11 @@ def set_current_object_title(self, title: str) -> None: ) def __load_from_file( - self, filename: str, create_group: bool = True, add_objects: bool = True + self, + filename: str, + create_group: bool = True, + add_objects: bool = True, + import_param: gds.DataSet | None = None, ) -> list[SignalObj] | list[ImageObj]: """Open and return objects from file, optionally adding them to DataLab. @@ -2638,12 +2642,20 @@ def __load_from_file( create_group: if True, create a new group if more than one object is loaded. Defaults to True. If False, all objects are added to the current group. add_objects: if True, add objects to the panel. Defaults to True. + import_param: Optional format-specific import parameters. Returns: New object or list of new objects """ - worker = CallbackWorker(lambda worker: self.IO_REGISTRY.read(filename, worker)) + + def read_objects(worker): + if import_param is None: + return self.IO_REGISTRY.read(filename, worker) + return self.IO_REGISTRY.read(filename, worker, param=import_param) + + worker = CallbackWorker(read_objects) objs = qt_long_callback(self, _("Reading objects from file"), worker, True) + self.apply_import_parameters(objs, import_param) group_id = None if len(objs) > 1 and create_group: # Create a new group if more than one object is loaded @@ -2663,6 +2675,44 @@ def __load_from_file( self.selection_changed() return objs + def edit_import_parameters(self, filename: str) -> tuple[bool, gds.DataSet | None]: + """Edit optional object import parameters. + + Args: + filename: File name. + + Returns: + A tuple containing the confirmation state and optional import parameters. + """ + return True, None + + def prepare_import_parameters( + self, filename: str, param: gds.DataSet | None + ) -> list[tuple[str, gds.DataSet | None]]: + """Prepare file names and parameters for object import. + + Args: + filename: Selected file name. + param: Optional parameters returned by the import dialog or history. + + Returns: + File names and aligned import parameters. + """ + return [(filename, param)] + + def apply_import_parameters( + self, + objs: list[SignalObj] | list[ImageObj], + param: gds.DataSet | None, + ) -> None: + """Apply optional import parameters to newly read objects. + + Args: + objs: Imported objects. + param: Optional format-specific import parameters. + """ + del objs, param + def edit_export_parameters( self, obj: TypeObj, filename: str ) -> tuple[bool, ImageExportParam | None]: @@ -2769,6 +2819,7 @@ def load_from_files( create_group: bool = False, add_objects: bool = True, ignore_errors: bool = False, + import_params: list[gds.DataSet | None] | None = None, ) -> list[TypeObj]: """Open and return objects from files, optionally adding them to DataLab. @@ -2779,6 +2830,8 @@ def load_from_files( group. add_objects: if True, add objects to the panel. Defaults to True. ignore_errors: if True, ignore errors when loading files. Defaults to False. + import_params: Optional format-specific parameters aligned with file names. + When provided by history replay, import dialogs are not reopened. Returns: list of new objects @@ -2792,9 +2845,43 @@ def load_from_files( filenames, _filt = getopenfilenames(self, _("Open"), basedir, filters) if not filenames: # pragma: no cover return [] - # Sort filenames to ensure consistent alphabetical order across all platforms - filenames = sorted(filenames) - nbf = len(filenames) + parameters_provided = import_params is not None + if import_params is None: + import_params = [None] * len(filenames) + assert len(import_params) == len(filenames), ( + "Number of import parameters must match number of files" + ) + selected_pairs = sorted(zip(filenames, import_params), key=lambda pair: pair[0]) + prepared_pairs: list[tuple[str, gds.DataSet | None]] = [] + prepared_paths: set[str] = set() + expanded_paths: set[str] = set() + for filename, param in selected_pairs: + selected_path = osp.normcase(osp.abspath(filename)) + if selected_path in expanded_paths: + continue + if not parameters_provided: + confirmed, param = self.edit_import_parameters(filename) + if not confirmed: + continue + new_pairs = self.prepare_import_parameters(filename, param) + is_expansion = len(new_pairs) > 1 + for prepared_filename, prepared_param in new_pairs: + path_key = osp.normcase(osp.abspath(prepared_filename)) + if path_key in expanded_paths or ( + is_expansion and path_key in prepared_paths + ): + continue + prepared_pairs.append((prepared_filename, prepared_param)) + prepared_paths.add(path_key) + if is_expansion: + expanded_paths.update( + osp.normcase(osp.abspath(path)) for path, _param in new_pairs + ) + if not prepared_pairs: + return [] + filenames = [filename for filename, _param in prepared_pairs] + import_params = [param for _filename, param in prepared_pairs] + nbf = len(prepared_pairs) if nbf > 1: entry_title = _("Load from %d files") % nbf else: @@ -2807,21 +2894,30 @@ def load_from_files( # Offer a fresh history session for this batch *before* recording # any entry. self.mainwindow.historypanel.maybe_start_session_for_input(load=True) + history_kwargs: dict[str, Any] = { + "filenames": list(filenames), + "create_group": create_group, + "add_objects": add_objects, + "ignore_errors": ignore_errors, + } + if any(param is not None for param in import_params): + history_kwargs["import_params"] = [ + copy.deepcopy(param) if param is not None else None + for param in import_params + ] action = self.mainwindow.historypanel.add_ui_entry( entry_title, target=self.PANEL_STR_ID + "panel", method_name="load_from_files", save_state=False, - filenames=filenames, - create_group=create_group, - add_objects=add_objects, - ignore_errors=ignore_errors, + **history_kwargs, ) objs = [] loaded_filenames: list[str] = [] + loaded_params: list[gds.DataSet | None] = [] with self.mainwindow.historypanel.session_prompt_suppressed(): with self.mainwindow.historypanel.capture_outputs(action): - for filename in filenames: + for filename, import_param in prepared_pairs: with qt_try_loadsave_file(self.parentWidget(), filename, "load"): Conf.base_dir.set(filename) try: @@ -2829,6 +2925,7 @@ def load_from_files( filename, create_group=create_group, add_objects=add_objects, + import_param=import_param, ) except Exception as exc: # pylint: disable=broad-exception-caught if ignore_errors: @@ -2840,6 +2937,7 @@ def load_from_files( objs += new_objs if new_objs: loaded_filenames.append(filename) + loaded_params.append(import_param) if action is not None and loaded_filenames and len(loaded_filenames) < nbf: # Some files could not be loaded: make the recorded entry reflect # the files actually loaded, so the title is accurate and replay @@ -2849,7 +2947,14 @@ def load_from_files( action.title = _("Load from %d files") % len(loaded_filenames) else: action.title = _('Load "%s"') % osp.basename(loaded_filenames[0]) - action.kwargs["filenames"] = loaded_filenames + action.kwargs["filenames"] = list(loaded_filenames) + if any(param is not None for param in loaded_params): + action.kwargs["import_params"] = [ + copy.deepcopy(param) if param is not None else None + for param in loaded_params + ] + else: + action.kwargs.pop("import_params", None) self.mainwindow.historypanel.tree.refresh_action_item(action) return objs diff --git a/datalab/gui/panel/image.py b/datalab/gui/panel/image.py index 908370db6..1c4086d2c 100644 --- a/datalab/gui/panel/image.py +++ b/datalab/gui/panel/image.py @@ -8,6 +8,8 @@ from __future__ import annotations +import copy +import os import os.path as osp from typing import TYPE_CHECKING, Type from weakref import ReferenceType, ref @@ -24,7 +26,12 @@ AnnotatedSegmentTool, LabelTool, ) -from sigima.io import ImageExportParam, get_supported_export_dtypes, write_image +from sigima.io import ( + ImageExportParam, + RawImageImportParam, + get_supported_export_dtypes, + write_image, +) from sigima.io.image import ImageIORegistry from sigima.objects import ImageDatatypes, ImageObj, ImageROI, NewImageParam @@ -63,6 +70,14 @@ class ImageExportGUIParam(ImageExportParam, title=_("Image export")): ) +class RawImageImportGUIParam(RawImageImportParam, title=_("RAW image import")): + """RAW image import parameters extended with DataLab batch options.""" + + open_all_files = gds.BoolItem( + _("Open all RAW files in the same folder"), default=False + ) + + def create_image_export_gui_param(obj: ImageObj, filename: str) -> ImageExportGUIParam: """Create format-aware image export parameters with practical defaults. @@ -125,6 +140,65 @@ def get_roieditor_class() -> Type[roieditor.ImageROIEditor]: """Return ROI editor class""" return roieditor.ImageROIEditor + def edit_import_parameters( + self, filename: str + ) -> tuple[bool, RawImageImportGUIParam | None]: + """Edit RAW image import parameters when required by the file format. + + Args: + filename: Source file name. + + Returns: + A tuple containing the confirmation state and optional import parameters. + """ + if osp.splitext(filename)[1].lower() != ".raw": + return True, None + param = RawImageImportGUIParam() + if not param.edit(parent=self.parentWidget()): + return False, None + return True, param + + def prepare_import_parameters( + self, filename: str, param: gds.DataSet | None + ) -> list[tuple[str, gds.DataSet | None]]: + """Prepare RAW file names and backend parameters for import. + + Args: + filename: Selected file name. + param: Optional GUI or backend import parameters. + + Returns: + File names and aligned backend import parameters. + """ + if not isinstance(param, RawImageImportGUIParam): + return [(filename, param)] + backend_param = RawImageImportParam() + update_dataset(backend_param, param) + filenames = [filename] + if param.open_all_files: + directory = osp.dirname(filename) or "." + filenames = sorted( + osp.join(directory, name) + for name in os.listdir(directory) + if osp.isfile(osp.join(directory, name)) + and osp.splitext(name)[1].lower() == ".raw" + ) + return [(path, copy.deepcopy(backend_param)) for path in filenames] + + def apply_import_parameters( + self, objs: list[ImageObj], param: gds.DataSet | None + ) -> None: + """Apply RAW display semantics without changing imported values. + + Args: + objs: Imported image objects. + param: Optional format-specific import parameters. + """ + if isinstance(param, RawImageImportParam) and param.white_is_zero: + for obj in objs: + obj.set_metadata_option("colormap", "gray") + obj.set_metadata_option("invert_colormap", True) + def edit_export_parameters( self, obj: ImageObj, filename: str ) -> tuple[bool, ImageExportParam | None]: diff --git a/datalab/locale/fr/LC_MESSAGES/datalab.po b/datalab/locale/fr/LC_MESSAGES/datalab.po index 36922f93c..4695ce969 100644 --- a/datalab/locale/fr/LC_MESSAGES/datalab.po +++ b/datalab/locale/fr/LC_MESSAGES/datalab.po @@ -4390,3 +4390,9 @@ msgstr "Type de données cible" #, python-format msgid "Skipping export: object %r no longer exists." msgstr "Export ignoré : l'objet %r n'existe plus." + +msgid "RAW image import" +msgstr "Importation d'image RAW" + +msgid "Open all RAW files in the same folder" +msgstr "Ouvrir tous les fichiers RAW du même dossier" diff --git a/datalab/tests/features/image/raw_import_app_test.py b/datalab/tests/features/image/raw_import_app_test.py new file mode 100644 index 000000000..760332bdd --- /dev/null +++ b/datalab/tests/features/image/raw_import_app_test.py @@ -0,0 +1,66 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""RAW image import and History replay application test.""" + +from __future__ import annotations + +from unittest.mock import patch + +import numpy as np +from sigima.io import RawImageImportParam + +from datalab.gui.panel.image import RawImageImportGUIParam +from datalab.objectmodel import get_uuid +from datalab.tests import datalab_test_app_context +from datalab.tests.features.common.history_test_helpers import select_tree_entry + + +def test_raw_import_replay_reloads_after_recorded_deletion(tmp_path) -> None: + """Replay a recorded RAW import after its image was deleted.""" + expected = np.array([[1, 2, 3], [4, 5, 6]], dtype=np.uint16) + filename = tmp_path / "image.raw" + expected.tofile(filename) + param = RawImageImportParam.create(dtype="uint16", width=3, height=2) + + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.imagepanel + history.toggle_record_mode(True) + + with patch.object(RawImageImportGUIParam, "edit") as edit: + objects = panel.load_from_files([str(filename)], import_params=[param]) + + assert len(objects) == 1 + np.testing.assert_array_equal(objects[0].data, expected) + action = history[len(history)] + assert action.method_name == "load_from_files" + history_param = action.kwargs["import_params"][0] + assert isinstance(history_param, RawImageImportParam) + assert history_param is not param + param.width = 1 + assert history_param.width == 3 + + output_uuid = get_uuid(objects[0]) + assert action.output_uuids == [output_uuid] + panel.objview.select_objects([output_uuid]) + panel.remove_object(force=True) + assert len(panel.objmodel) == 0 + assert history[len(history)].method_name == "remove_object" + + select_tree_entry(history, action.uuid) + history.replay_restore_actions(restore_selection=False) + + edit.assert_not_called() + + assert len(panel.objmodel) == 1 + assert action.is_stale is False + replayed_output_uuid = action.output_uuids[0] + assert replayed_output_uuid != output_uuid + assert history.runtime.objects.action_output_uuids[action.uuid] == [ + replayed_output_uuid + ] + assert ( + history.runtime.objects.output_to_action[replayed_output_uuid] + == action.uuid + ) + recreated = panel.objmodel[replayed_output_uuid] + np.testing.assert_array_equal(recreated.data, expected) diff --git a/datalab/tests/features/image/raw_import_unit_test.py b/datalab/tests/features/image/raw_import_unit_test.py new file mode 100644 index 000000000..1e01011f7 --- /dev/null +++ b/datalab/tests/features/image/raw_import_unit_test.py @@ -0,0 +1,423 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Parameterized RAW image import unit tests.""" + +from __future__ import annotations + +from contextlib import nullcontext +from types import SimpleNamespace +from unittest.mock import Mock, call, patch + +import numpy as np +from sigima.io import RawImageImportParam +from sigima.io.image import ImageIORegistry +from sigima.objects import create_image + +from datalab.adapters_plotpy import create_adapter_from_object +from datalab.gui.panel import base as panel_base +from datalab.gui.panel.base import BaseDataPanel +from datalab.gui.panel.image import ImagePanel, RawImageImportGUIParam +from datalab.h5.native import NativeH5Reader, NativeH5Writer +from datalab.history.action import HistoryAction +from datalab.history.session import HistorySession + + +class HistoryPanelStub: + """Minimal history collaborator used by the generic import flow.""" + + def __init__(self) -> None: + self.actions: list[HistoryAction] = [] + self.tree = SimpleNamespace(refresh_action_item=Mock()) + + def maybe_start_session_for_input(self, *, load: bool = False) -> bool: + """Accept the current history session.""" + del load + return False + + def add_ui_entry( + self, + title: str, + target: str, + method_name: str, + save_state: bool = True, + **kwargs, + ) -> HistoryAction: + """Record and return one UI action.""" + del save_state + action = HistoryAction( + title=title, + target=target, + method_name=method_name, + kwargs=kwargs, + ) + self.actions.append(action) + return action + + @staticmethod + def session_prompt_suppressed(): + """Return a no-op batch context.""" + return nullcontext() + + @staticmethod + def capture_outputs(_action): + """Return a no-op output capture context.""" + return nullcontext() + + @staticmethod + def is_output_suppressed() -> bool: + """Return whether replay output is suppressed.""" + return False + + +class RawLoadPanelStub: + """Panel stub retaining the production import orchestration hooks.""" + + PANEL_STR_ID = "image" + load_from_files = BaseDataPanel.load_from_files + edit_import_parameters = ImagePanel.edit_import_parameters + prepare_import_parameters = ImagePanel.prepare_import_parameters + apply_import_parameters = ImagePanel.apply_import_parameters + + def __init__(self) -> None: + self.historypanel = HistoryPanelStub() + self.mainwindow = SimpleNamespace( + confirm_memory_state=Mock(return_value=True), + historypanel=self.historypanel, + imagepanel=self, + ) + self.loaded_objects = [object()] + self.load_file = Mock(return_value=self.loaded_objects) + setattr(self, "parentWidget", Mock(return_value=None)) + setattr(self, "_BaseDataPanel__load_from_file", self.load_file) + + +def run_stub_import(panel: RawLoadPanelStub, filenames, **kwargs): + """Run the generic import flow without Qt error dialogs.""" + with ( + patch.object( + panel_base, + "qt_try_loadsave_file", + side_effect=lambda *_args: nullcontext(), + ), + patch.object(panel_base.Conf.base_dir, "set"), + ): + return panel.load_from_files(filenames, **kwargs) + + +def confirm_raw_dialog(**values): + """Return a dialog edit replacement that sets values and confirms.""" + + def edit(param, parent=None): + del parent + for name, value in values.items(): + setattr(param, name, value) + return True + + return edit + + +def test_raw_dialog_confirmation_forwards_backend_parameters(tmp_path) -> None: + """Forward confirmed RAW settings to the registry-facing load helper.""" + filename = str(tmp_path / "image.raw") + panel = RawLoadPanelStub() + + with patch.object( + RawImageImportGUIParam, + "edit", + autospec=True, + side_effect=confirm_raw_dialog( + dtype="uint8", + width=3, + height=2, + offset=4, + count=2, + gap=1, + little_endian=False, + white_is_zero=True, + ), + ): + run_stub_import(panel, [filename]) + + import_param = panel.load_file.call_args.kwargs["import_param"] + assert isinstance(import_param, RawImageImportParam) + assert not isinstance(import_param, RawImageImportGUIParam) + assert ( + import_param.dtype, + import_param.width, + import_param.height, + import_param.offset, + import_param.count, + import_param.gap, + import_param.little_endian, + import_param.white_is_zero, + ) == ("uint8", 3, 2, 4, 2, 1, False, True) + history_param = panel.historypanel.actions[0].kwargs["import_params"][0] + assert history_param is not import_param + import_param.width = 9 + assert history_param.width == 3 + + +def test_raw_dialog_cancellation_skips_read_and_history(tmp_path) -> None: + """Do not read or record a RAW file after dialog cancellation.""" + panel = RawLoadPanelStub() + + with patch.object(RawImageImportGUIParam, "edit", return_value=False): + objects = run_stub_import(panel, [str(tmp_path / "cancelled.raw")]) + + assert objects == [] + panel.load_file.assert_not_called() + assert not panel.historypanel.actions + + +def test_non_raw_import_keeps_parameter_free_path(tmp_path) -> None: + """Keep normal image imports parameter-free and dialog-free.""" + filename = str(tmp_path / "image.npy") + panel = RawLoadPanelStub() + + with patch.object(RawImageImportGUIParam, "edit") as edit: + objects = run_stub_import(panel, [filename]) + + assert objects == panel.loaded_objects + edit.assert_not_called() + panel.load_file.assert_called_once_with( + filename, + create_group=False, + add_objects=True, + import_param=None, + ) + assert "import_params" not in panel.historypanel.actions[0].kwargs + + +def test_non_raw_duplicate_filenames_keep_existing_behavior(tmp_path) -> None: + """Do not apply RAW expansion deduplication to ordinary imports.""" + filename = str(tmp_path / "image.npy") + panel = RawLoadPanelStub() + + run_stub_import(panel, [filename, filename]) + + assert panel.load_file.call_count == 2 + assert panel.historypanel.actions[0].kwargs["filenames"] == [filename, filename] + + +def test_open_all_raw_files_expands_sorted_without_duplicates(tmp_path) -> None: + """Expand one confirmed RAW file to unique RAW siblings in sorted order.""" + first = tmp_path / "a.raw" + second = tmp_path / "b.RAW" + other = tmp_path / "c.txt" + for path in (first, second, other): + path.write_bytes(b"") + panel = RawLoadPanelStub() + assert RawImageImportGUIParam().open_all_files is False + + with patch.object( + RawImageImportGUIParam, + "edit", + autospec=True, + side_effect=confirm_raw_dialog(open_all_files=True), + ) as edit: + run_stub_import(panel, [str(second), str(first)]) + + assert edit.call_count == 1 + loaded_filenames = [item.args[0] for item in panel.load_file.call_args_list] + assert loaded_filenames == [str(first), str(second)] + assert panel.historypanel.actions[0].kwargs["filenames"] == loaded_filenames + params = panel.historypanel.actions[0].kwargs["import_params"] + assert len(params) == 2 + assert all(isinstance(param, RawImageImportParam) for param in params) + loaded_params = [ + item.kwargs["import_param"] for item in panel.load_file.call_args_list + ] + assert loaded_params[0] is not loaded_params[1] + assert params[0] is not params[1] + assert all( + history_param is not loaded_param + for history_param, loaded_param in zip(params, loaded_params) + ) + loaded_params[0].width = 9 + assert loaded_params[1].width != 9 + assert params[0].width != 9 + + +def test_partial_failure_keeps_successful_filenames_and_params_aligned( + tmp_path, +) -> None: + """Drop a failed file and only its matching parameter from history.""" + plain = str(tmp_path / "a.npy") + failed = str(tmp_path / "m.raw") + loaded = str(tmp_path / "z.raw") + failed_param = RawImageImportParam.create(width=2, height=2) + loaded_param = RawImageImportParam.create(width=3, height=1) + caller_params = [loaded_param, None, failed_param] + panel = RawLoadPanelStub() + + def load_file(filename, **_kwargs): + if filename == failed: + raise OSError("unreadable") + return [object()] + + panel.load_file.side_effect = load_file + run_stub_import( + panel, + [loaded, plain, failed], + import_params=caller_params, + ignore_errors=True, + ) + + action = panel.historypanel.actions[0] + assert action.kwargs["filenames"] == [plain, loaded] + history_param = action.kwargs["import_params"][1] + assert action.kwargs["import_params"][0] is None + assert history_param is not loaded_param + assert panel.load_file.call_args_list == [ + call(plain, create_group=False, add_objects=True, import_param=None), + call( + failed, + create_group=False, + add_objects=True, + import_param=failed_param, + ), + call( + loaded, + create_group=False, + add_objects=True, + import_param=loaded_param, + ), + ] + loaded_param.width = 9 + caller_params.clear() + assert action.kwargs["import_params"][0] is None + assert history_param.width == 3 + + +def test_history_replay_uses_recorded_params_without_dialog(tmp_path) -> None: + """Replay a RAW load with its backend parameters and no dialog.""" + filename = str(tmp_path / "image.raw") + param = RawImageImportParam.create(dtype="uint8", width=2, height=2) + panel = RawLoadPanelStub() + action = HistoryAction( + title="RAW import", + target="imagepanel", + method_name="load_from_files", + kwargs={"filenames": [filename], "import_params": [param]}, + ) + + with ( + patch.object(RawImageImportGUIParam, "edit") as edit, + patch.object( + panel_base, + "qt_try_loadsave_file", + side_effect=lambda *_args: nullcontext(), + ), + patch.object(panel_base.Conf.base_dir, "set"), + ): + action.replay_ui(panel.mainwindow, edit=False) + + edit.assert_not_called() + panel.load_file.assert_called_once_with( + filename, + create_group=False, + add_objects=True, + import_param=param, + ) + + +def test_history_hdf5_preserves_optional_raw_import_parameters(tmp_path) -> None: + """Round-trip mixed RAW import parameters through native HDF5 history.""" + param = RawImageImportParam.create( + dtype="float32", + width=4, + height=3, + offset=8, + count=2, + gap=4, + little_endian=False, + white_is_zero=True, + ) + action = HistoryAction( + title="RAW import", + target="imagepanel", + method_name="load_from_files", + kwargs={"import_params": [param, None]}, + ) + session = HistorySession(number=1) + session.add_action(action) + path = str(tmp_path / "history.dlhist") + + with NativeH5Writer(path) as writer: + writer.write_object_list([session], "history_session") + with NativeH5Reader(path) as reader: + loaded = reader.read_object_list("history_session", HistorySession)[0] + + decoded = loaded.actions[0].kwargs["import_params"] + assert isinstance(decoded[0], RawImageImportParam) + assert ( + decoded[0].dtype, + decoded[0].width, + decoded[0].height, + decoded[0].offset, + decoded[0].count, + decoded[0].gap, + decoded[0].little_endian, + decoded[0].white_is_zero, + ) == ("float32", 4, 3, 8, 2, 4, False, True) + assert decoded[1] is None + + +def test_panel_load_helper_reads_raw_through_image_registry(tmp_path) -> None: + """Read RAW bytes through the panel helper and the real image registry.""" + expected = np.array([[1, 2], [3, 4]], dtype=np.uint16) + filename = str(tmp_path / "image.raw") + expected.tofile(filename) + param = RawImageImportParam.create(width=2, height=2) + panel = SimpleNamespace( + IO_REGISTRY=ImageIORegistry, + apply_import_parameters=Mock(), + selection_changed=Mock(), + ) + progress = SimpleNamespace(setValue=Mock(), wasCanceled=Mock(return_value=False)) + loader = getattr(BaseDataPanel, "_BaseDataPanel__load_from_file") + + with ( + patch.object( + panel_base, "CallbackWorker", side_effect=lambda callback: callback + ), + patch.object( + panel_base, + "qt_long_callback", + side_effect=lambda _panel, _title, callback, _show: callback(None), + ), + patch.object( + panel_base, + "create_progress_bar", + return_value=nullcontext(progress), + ), + ): + objects = loader( + panel, + filename, + create_group=False, + add_objects=False, + import_param=param, + ) + + np.testing.assert_array_equal(objects[0].data, expected) + panel.apply_import_parameters.assert_called_once_with(objects, param) + + +def test_white_is_zero_uses_inverted_gray_metadata_without_changing_data() -> None: + """Represent white-is-zero through stable PlotPy options, not data inversion.""" + data = np.array([[0, 1], [2, 255]], dtype=np.uint8) + image = create_image("RAW", data=data.copy(), metadata={"white_is_zero": True}) + param = RawImageImportParam.create( + dtype="uint8", width=2, height=2, white_is_zero=True + ) + + ImagePanel.apply_import_parameters(SimpleNamespace(), [image], param) + + np.testing.assert_array_equal(image.data, data) + assert image.metadata["white_is_zero"] is True + assert image.get_metadata_option("colormap") == "gray" + assert image.get_metadata_option("invert_colormap") is True + item = create_adapter_from_object(image).make_item() + assert item.param.colormap == "gray" + assert item.param.invert_colormap is True From 360a1a1d954a1624d8fbe510d3f7d120a7d7b61e Mon Sep 17 00:00:00 2001 From: Duy Anh Philippe PHAM Date: Fri, 28 Aug 2026 16:54:18 +0200 Subject: [PATCH 3/9] Feat: Add format-specific image export controls and preview Generate translated export controls from Sigima capabilities and preview the exact encoded result in memory. Add robust option serialization and validation handling, format-aware defaults, focused tests, and French translations. --- datalab/gui/panel/image.py | 299 +++++++++++++++++- datalab/locale/fr/LC_MESSAGES/datalab.po | 124 +++++++- .../tests/features/image/export_unit_test.py | 238 +++++++++++++- 3 files changed, 645 insertions(+), 16 deletions(-) diff --git a/datalab/gui/panel/image.py b/datalab/gui/panel/image.py index 908370db6..9043a6fc9 100644 --- a/datalab/gui/panel/image.py +++ b/datalab/gui/panel/image.py @@ -8,14 +8,17 @@ from __future__ import annotations -import os.path as osp from typing import TYPE_CHECKING, Type from weakref import ReferenceType, ref import guidata.dataset as gds +import guidata.dataset.qtwidgets as gdq import numpy as np from guidata.dataset import update_dataset +from guidata.qthelpers import exec_dialog +from plotpy.builder import make from plotpy.interfaces import IVoiImageItemType +from plotpy.plot import PlotDialog, PlotOptions from plotpy.tools import ( AnnotatedCircleTool, AnnotatedEllipseTool, @@ -24,7 +27,15 @@ AnnotatedSegmentTool, LabelTool, ) -from sigima.io import ImageExportParam, get_supported_export_dtypes, write_image +from qtpy import QtWidgets as QW +from sigima.io import ( + ImageExportOptionSpec, + ImageExportParam, + get_image_export_capabilities, + get_supported_export_dtypes, + prepare_image_export_preview, + write_image, +) from sigima.io.image import ImageIORegistry from sigima.objects import ImageDatatypes, ImageObj, ImageROI, NewImageParam @@ -39,11 +50,56 @@ if TYPE_CHECKING: from plotpy.plot import BasePlot - from qtpy import QtWidgets as QW from datalab.gui.docks import DockablePlotWidget +IMAGE_EXPORT_OPTION_LABELS = { + "compress_level": _("Compression level"), + "compression": _("Compression"), + "compression_level": _("Compression level"), + "do_compression": _("MAT compression"), + "quality": _("Quality"), + "subsampling": _("Subsampling"), + "progressive": _("Progressive"), + "optimize": _("Optimize"), + "smooth": _("Smoothing"), + "quality_mode": _("Quality mode"), + "quality_layers": _("Quality layers"), + "irreversible": _("Irreversible"), + "progression": _("Progression order"), + "num_resolutions": _("Number of resolutions"), + "tile_size": _("Tile size"), + "plt": _("PLT markers"), + "predictor": _("Predictor"), + "rows_per_strip": _("Rows per strip"), + "resolution": _("Resolution"), + "resolution_unit": _("Resolution unit"), + "photometric": _("Photometric interpretation"), + "delimiter": _("Delimiter"), + "precision": _("Precision"), +} + +IMAGE_EXPORT_CHOICE_LABELS = { + "none": _("None"), + "rates": _("Rates"), + "lzw": "LZW", + "deflate": _("Deflate"), + "zstd": _("Zstandard"), + "jpeg": "JPEG", + "whitespace": _("Whitespace"), + "tab": _("Tab"), + "comma": _("Comma"), + "semicolon": _("Semicolon"), + "inch": _("Inch"), + "centimeter": _("Centimeter"), + "horizontal": _("Horizontal"), + "floatingpoint": _("Floating-point"), + "minisblack": _("Black is zero"), + "miniswhite": _("White is zero"), +} + + def get_image_export_dtype_choices( param: ImageExportGUIParam, _item: gds.ChoiceItem, _value: str ) -> list[tuple[str, str, None]]: @@ -58,9 +114,143 @@ class ImageExportGUIParam(ImageExportParam, title=_("Image export")): """Image export parameters restricted to the selected file format.""" supported_dtypes: tuple[str, ...] = ("auto",) + format_extension = "" + format_option_specs: tuple[ImageExportOptionSpec, ...] = () target_dtype = gds.ChoiceItem( _("Target data type"), get_image_export_dtype_choices, default="auto" ) + gamma = gds.FloatItem( + _("Gamma"), default=None, min=0.01, check=False, allow_none=True + ).set_prop( + "display", + active=gds.FuncProp( + ImageExportParam.normalization_prop, lambda value: value != "none" + ), + ) + invert = gds.BoolItem(_("Invert"), default=False).set_prop( + "display", + active=gds.FuncProp( + ImageExportParam.normalization_prop, lambda value: value != "none" + ), + ) + + def get_format_options(self) -> dict[str, object]: + """Return writer options exposed by this format parameter class.""" + options = {} + for spec in self.format_option_specs: + value = getattr(self, spec.key) + if spec.value_kind in ("int_pair", "float_pair", "float_list"): + value = serialize_image_export_sequence(value, spec) + options[spec.key] = value + return options + + +def get_image_export_choice_label(value: object) -> str: + """Return a translated human-readable label for an option choice.""" + return IMAGE_EXPORT_CHOICE_LABELS.get(value, str(value)) + + +def serialize_image_export_sequence( + value: object, spec: ImageExportOptionSpec +) -> tuple[int, int] | tuple[float, float] | list[float] | None: + """Serialize a pair or list option from its editable GUI representation.""" + if value is None or (isinstance(value, str) and not value.strip()): + if spec.allow_none: + return None + label = IMAGE_EXPORT_OPTION_LABELS[spec.key] + raise ValueError(_("%s may not be empty") % label) + if isinstance(value, str): + values = [part.strip() for part in value.split(",")] + else: + values = list(value) + if spec.value_kind in ("int_pair", "float_pair") and len(values) != 2: + label = IMAGE_EXPORT_OPTION_LABELS[spec.key] + raise ValueError(_("%s must contain 2 values") % label) + try: + if spec.value_kind == "int_pair": + return tuple(int(item) for item in values) + converted = [float(item) for item in values] + except (TypeError, ValueError) as exc: + label = IMAGE_EXPORT_OPTION_LABELS[spec.key] + raise ValueError(_("%s contains invalid numbers") % label) from exc + if spec.value_kind == "float_pair": + return tuple(converted) + return converted + + +def create_image_export_option_item(spec: ImageExportOptionSpec) -> gds.DataItem: + """Create a guidata item for an image export capability option.""" + label = IMAGE_EXPORT_OPTION_LABELS[spec.key] + if spec.value_kind == "bool": + return gds.BoolItem(label, default=spec.default, allow_none=spec.allow_none) + if spec.value_kind == "int": + return gds.IntItem( + label, + default=spec.default, + min=spec.minimum, + max=spec.maximum, + nonzero=spec.minimum == 0 and not spec.minimum_inclusive, + allow_none=spec.allow_none, + ) + if spec.value_kind == "float": + return gds.FloatItem( + label, + default=spec.default, + min=spec.minimum, + max=spec.maximum, + nonzero=spec.minimum == 0 and not spec.minimum_inclusive, + allow_none=spec.allow_none, + ) + if spec.value_kind == "choice": + choices = [ + (choice, get_image_export_choice_label(choice)) for choice in spec.choices + ] + return gds.ChoiceItem( + label, choices, default=spec.default, allow_none=spec.allow_none + ) + if spec.value_kind in ("int_pair", "float_pair", "float_list"): + default = ( + "" + if spec.default is None + else ", ".join(str(value) for value in spec.default) + ) + return gds.StringItem(label, default=default) + if spec.value_kind == "string": + return gds.StringItem(label, default=spec.default, allow_none=spec.allow_none) + raise AssertionError(f"Unknown image export option kind: {spec.value_kind!r}") + + +IMAGE_EXPORT_PARAM_CLASSES: dict[str, type[ImageExportGUIParam]] = {} + + +def get_image_export_param_class(filename: str) -> type[ImageExportGUIParam]: + """Return the cached GUI parameter class for an export format.""" + capabilities = get_image_export_capabilities(filename) + canonical_extension = capabilities.canonical_extension + paramclass = IMAGE_EXPORT_PARAM_CLASSES.get(canonical_extension) + if paramclass is None: + class_name = "".join( + character + for character in capabilities.format_name.title() + if character.isalnum() + ) + class_attributes = { + "__doc__": f"{capabilities.format_name} export parameters.", + "format_option_specs": capabilities.option_specs, + } + class_attributes.update( + { + spec.key: create_image_export_option_item(spec) + for spec in capabilities.option_specs + } + ) + paramclass = type( + f"{class_name}ImageExportGUIParam", + (ImageExportGUIParam,), + class_attributes, + ) + IMAGE_EXPORT_PARAM_CLASSES[canonical_extension] = paramclass + return paramclass def create_image_export_gui_param(obj: ImageObj, filename: str) -> ImageExportGUIParam: @@ -73,22 +263,112 @@ def create_image_export_gui_param(obj: ImageObj, filename: str) -> ImageExportGU Returns: Image export GUI parameters """ - param = ImageExportGUIParam() + capabilities = get_image_export_capabilities(filename) + paramclass = get_image_export_param_class(filename) + param = paramclass() + param.format_extension = capabilities.canonical_extension param.supported_dtypes = get_supported_export_dtypes(filename) source_dtype = np.dtype(obj.data.dtype) concrete_dtypes = param.supported_dtypes[1:] source_is_supported = source_dtype.name in concrete_dtypes - preserves_source = osp.splitext(filename)[1].lower() == ".npy" forces_integer_conversion = concrete_dtypes and all( np.issubdtype(np.dtype(dtype), np.integer) for dtype in concrete_dtypes ) - if not (source_is_supported or preserves_source) and forces_integer_conversion: + if ( + not capabilities.raw_preserving + and not source_is_supported + and forces_integer_conversion + ): param.normalization = "minmax" param.behavior = "rescale" param.target_dtype = "auto" return param +class ImageExportDialog(PlotDialog): + """Format-aware image export dialog with an in-memory graphical preview.""" + + def __init__( + self, + parent: QW.QWidget | None, + obj: ImageObj, + filename: str, + param: ImageExportGUIParam, + ) -> None: + self.obj = obj + self.filename = filename + self.preview_item = None + self.param_widget = gdq.DataSetEditGroupBox( + _("Export options"), + param.__class__, + button_text=_("Preview"), + button_icon="reload.svg", + ) + update_dataset(self.param_widget.dataset, param) + self.param_widget.dataset.supported_dtypes = param.supported_dtypes + self.param_widget.dataset.format_extension = param.format_extension + super().__init__( + parent=parent, + title=_("Export image"), + icon="export.svg", + toolbar=False, + edit=True, + options=PlotOptions(type="image", show_contrast=True), + size=(900, 600), + ) + self.param_widget.SIG_APPLY_BUTTON_CLICKED.connect(self.update_preview) + self.param_widget.set_apply_button_state(True) + self.update_preview() + + def setup_layout(self) -> None: + """Add export controls beside the graphical preview.""" + super().setup_layout() + self.plot_layout.addWidget(self.param_widget, 0, 1) + self.plot_layout.setColumnStretch(0, 2) + self.plot_layout.setColumnStretch(1, 1) + + def get_export_param(self) -> ImageExportParam: + """Return backend parameters from the current GUI values.""" + guiparam = self.param_widget.dataset + param = ImageExportParam() + update_dataset(param, guiparam) + param.format_options = guiparam.get_format_options() + return param + + def update_preview(self) -> bool: + """Render current export preparation directly in the preview plot.""" + try: + data = prepare_image_export_preview( + self.obj.data, self.filename, self.get_export_param() + ) + except (TypeError, ValueError, OSError) as exc: + QW.QMessageBox.warning(self, _("Export image"), str(exc)) + return False + plot = self.manager.get_plot() + if self.preview_item is None: + self.preview_item = make.image(data, colormap="gray") + plot.add_item(self.preview_item) + plot.set_active_item(self.preview_item) + plot.do_autoscale() + else: + self.preview_item.set_data(data) + plot.replot() + return True + + def accept(self) -> None: + """Validate controls and preview preparation before accepting.""" + if not self.param_widget.edit.check_all_values(): + QW.QMessageBox.warning( + self, + _("Export image"), + _("Some required entries are incorrect."), + ) + return + self.param_widget.edit.accept_changes() + if self.update_preview(): + QW.QDialog.accept(self) + + class ImagePanel(BaseDataPanel[ImageObj, ImageROI, roieditor.ImageROIEditor]): """Object handling the item list, the selected item properties and plot, specialized for Image objects""" @@ -141,11 +421,10 @@ def edit_export_parameters( guiparam = create_image_export_gui_param(obj, filename) except ValueError: return True, None - if not guiparam.edit(parent=self.parentWidget()): + dialog = ImageExportDialog(self.parentWidget(), obj, filename, guiparam) + if not exec_dialog(dialog): return False, None - param = ImageExportParam() - update_dataset(param, guiparam) - return True, param + return True, dialog.get_export_param() def write_object_to_file( self, diff --git a/datalab/locale/fr/LC_MESSAGES/datalab.po b/datalab/locale/fr/LC_MESSAGES/datalab.po index 36922f93c..95cbe49e0 100644 --- a/datalab/locale/fr/LC_MESSAGES/datalab.po +++ b/datalab/locale/fr/LC_MESSAGES/datalab.po @@ -4,7 +4,6 @@ # 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" @@ -4387,6 +4386,129 @@ msgstr "Exportation d'image" msgid "Target data type" msgstr "Type de données cible" +msgid "Compression level" +msgstr "Niveau de compression" + +msgid "Compression" +msgstr "Compression" + +msgid "MAT compression" +msgstr "Compression MAT" + +msgid "Quality" +msgstr "Qualité" + +msgid "Subsampling" +msgstr "Sous-échantillonnage" + +msgid "Progressive" +msgstr "Progressif" + +msgid "Optimize" +msgstr "Optimiser" + +msgid "Smoothing" +msgstr "Lissage" + +msgid "Quality mode" +msgstr "Mode de qualité" + +msgid "Quality layers" +msgstr "Couches de qualité" + +msgid "Irreversible" +msgstr "Irréversible" + +msgid "Progression order" +msgstr "Ordre de progression" + +msgid "Number of resolutions" +msgstr "Nombre de résolutions" + +msgid "Tile size" +msgstr "Taille des tuiles" + +msgid "PLT markers" +msgstr "Marqueurs PLT" + +msgid "Predictor" +msgstr "Prédicteur" + +msgid "Rows per strip" +msgstr "Lignes par bande" + +msgid "Resolution" +msgstr "Résolution" + +msgid "Resolution unit" +msgstr "Unité de résolution" + +msgid "Photometric interpretation" +msgstr "Interprétation photométrique" + +msgid "Precision" +msgstr "Précision" + +msgid "Rates" +msgstr "Taux" + +msgid "Deflate" +msgstr "Deflate" + +msgid "Zstandard" +msgstr "Zstandard" + +msgid "Whitespace" +msgstr "Espaces blancs" + +msgid "Semicolon" +msgstr "Point-virgule" + +msgid "Inch" +msgstr "Pouce" + +msgid "Centimeter" +msgstr "Centimètre" + +msgid "Horizontal" +msgstr "Horizontal" + +msgid "Floating-point" +msgstr "Virgule flottante" + +msgid "Black is zero" +msgstr "Le noir vaut zéro" + +msgid "White is zero" +msgstr "Le blanc vaut zéro" + +msgid "Gamma" +msgstr "Gamma" + +msgid "Invert" +msgstr "Inverser" + +#, python-format +msgid "%s may not be empty" +msgstr "%s ne peut pas être vide" + +#, python-format +msgid "%s must contain 2 values" +msgstr "%s doit contenir 2 valeurs" + +#, python-format +msgid "%s contains invalid numbers" +msgstr "%s contient des nombres non valides" + +msgid "Export options" +msgstr "Options d'exportation" + +msgid "Export image" +msgstr "Exporter l'image" + +msgid "Some required entries are incorrect." +msgstr "Certaines entrées obligatoires sont incorrectes." + #, python-format msgid "Skipping export: object %r no longer exists." msgstr "Export ignoré : l'objet %r n'existe plus." diff --git a/datalab/tests/features/image/export_unit_test.py b/datalab/tests/features/image/export_unit_test.py index 57784c4d8..4228c00ef 100644 --- a/datalab/tests/features/image/export_unit_test.py +++ b/datalab/tests/features/image/export_unit_test.py @@ -8,14 +8,21 @@ from types import SimpleNamespace from unittest.mock import MagicMock, Mock, call, patch +import guidata.dataset as gds import numpy as np import pytest -from sigima.io import ImageExportParam +from sigima.io import ( + IMAGE_EXPORT_CAPABILITIES, + ImageExportParam, + get_image_export_capabilities, + validate_image_export_options, +) from datalab.gui.panel import base as panel_base from datalab.gui.panel import image as image_panel from datalab.gui.panel.base import BaseDataPanel from datalab.gui.panel.image import ( + ImageExportDialog, ImageExportGUIParam, ImagePanel, create_image_export_gui_param, @@ -96,7 +103,16 @@ def get_dtype_choice_keys(param) -> list[str]: ), ( "npy", - ["auto", "uint8", "uint16", "float32", "float64"], + [ + "auto", + "uint8", + "uint16", + "int16", + "int32", + "float32", + "float64", + "complex128", + ], ["none", "none", "none"], ), ], @@ -122,6 +138,205 @@ def test_image_export_choices_and_defaults_are_format_aware( np.testing.assert_array_equal(image.data, source_data) +@pytest.mark.parametrize("extension", sorted(IMAGE_EXPORT_CAPABILITIES)) +def test_only_capability_format_options_are_exposed(extension: str) -> None: + """Expose exactly the writer controls declared by each format capability.""" + image = SimpleNamespace(data=np.arange(4, dtype=np.uint8).reshape(2, 2)) + param = create_image_export_gui_param(image, f"image.{extension}") + common_item_names = {item.get_name() for item in ImageExportGUIParam().get_items()} + item_names = {item.get_name() for item in param.get_items()} + option_keys = { + spec.key for spec in get_image_export_capabilities(extension).option_specs + } + + assert item_names - common_item_names == option_keys + assert set(param.get_format_options()) == option_keys + + +@pytest.mark.parametrize("aliases", [("jpg", "jpeg"), ("tif", "tiff"), ("txt", "asc")]) +def test_export_format_aliases_share_generated_class(aliases: tuple[str, str]) -> None: + """Reuse one generated DataSet class for aliases of a canonical format.""" + image = SimpleNamespace(data=np.zeros((2, 2), dtype=np.uint8)) + first = create_image_export_gui_param(image, f"image.{aliases[0]}") + second = create_image_export_gui_param(image, f"image.{aliases[1]}") + + assert first.__class__ is second.__class__ + + +@pytest.mark.parametrize( + ("extension", "expected_options"), + [ + ("png", {"compress_level": 6, "optimize": False}), + ( + "jpeg", + { + "quality": 75, + "subsampling": "4:2:0", + "progressive": False, + "optimize": False, + "smooth": 0, + }, + ), + ( + "jp2", + { + "quality_mode": "rates", + "quality_layers": [20.0], + "irreversible": False, + "progression": "LRCP", + "num_resolutions": None, + "tile_size": None, + "plt": False, + }, + ), + ( + "tiff", + { + "compression": "none", + "compression_level": None, + "predictor": "none", + "rows_per_strip": None, + "tile_size": None, + "resolution": None, + "resolution_unit": "none", + "photometric": "minisblack", + }, + ), + ("mat", {"do_compression": False}), + ("txt", {"delimiter": "whitespace", "precision": 18}), + ("csv", {"delimiter": "comma", "precision": 18}), + ], +) +def test_export_format_option_defaults( + extension: str, expected_options: dict[str, object] +) -> None: + """Return complete backend defaults for representative configurable formats.""" + image = SimpleNamespace(data=np.zeros((2, 2), dtype=np.uint8)) + param = create_image_export_gui_param(image, f"image.{extension}") + + assert param.get_format_options() == expected_options + + +def test_jp2_num_resolutions_accepts_none() -> None: + """Expose the optional JP2 resolution count as a nullable integer item.""" + image = SimpleNamespace(data=np.zeros((2, 2), dtype=np.uint8)) + param = create_image_export_gui_param(image, "image.jp2") + item = next( + item for item in param.get_items() if item.get_name() == "num_resolutions" + ) + + assert isinstance(item, gds.IntItem) + assert item.get_default() is None + assert item.get_prop_value("data", param, "allow_none") is True + param.num_resolutions = 6 + param.num_resolutions = None + assert param.num_resolutions is None + + +def test_sequence_format_options_serialize_to_backend_shapes() -> None: + """Serialize editable list and pair values to Sigima-compatible structures.""" + image = SimpleNamespace(data=np.zeros((2, 2), dtype=np.uint8)) + jp2_param = create_image_export_gui_param(image, "image.jp2") + jp2_param.quality_layers = "10, 20.5" + jp2_param.tile_size = "128, 256" + jp2_options = jp2_param.get_format_options() + + assert jp2_options["quality_layers"] == [10.0, 20.5] + assert jp2_options["tile_size"] == (128, 256) + assert validate_image_export_options("jp2", jp2_options) == jp2_options + + tiff_param = create_image_export_gui_param(image, "image.tiff") + tiff_param.tile_size = "32, 64" + tiff_param.resolution = "300, 150.5" + tiff_options = tiff_param.get_format_options() + + assert tiff_options["tile_size"] == (32, 64) + assert tiff_options["resolution"] == (300.0, 150.5) + assert validate_image_export_options("tiff", tiff_options) == tiff_options + + +@pytest.mark.parametrize("option_name", ["quality_layers", "tile_size"]) +@pytest.mark.parametrize("value", ["16,,32", "16,", ",16"]) +def test_malformed_sequence_format_options_are_rejected( + option_name: str, value: str +) -> None: + """Reject empty components in editable list and pair values.""" + image = SimpleNamespace(data=np.zeros((2, 2), dtype=np.uint8)) + param = create_image_export_gui_param(image, "image.jp2") + setattr(param, option_name, value) + + with pytest.raises(ValueError): + param.get_format_options() + + +@pytest.mark.parametrize( + ("option_name", "value", "expected"), + [ + ("quality_layers", " 10 , 20.5 ", [10.0, 20.5]), + ("tile_size", " 128 , 256 ", (128, 256)), + ], +) +def test_sequence_format_options_accept_comma_whitespace( + option_name: str, value: str, expected: object +) -> None: + """Accept whitespace around comma-separated list and pair values.""" + image = SimpleNamespace(data=np.zeros((2, 2), dtype=np.uint8)) + param = create_image_export_gui_param(image, "image.jp2") + setattr(param, option_name, value) + + assert param.get_format_options()[option_name] == expected + + +def test_export_preview_delegates_and_updates_existing_item() -> None: + """Use Sigima's exact preview and update the existing plot item in place.""" + source = np.arange(4, dtype=np.uint8).reshape(2, 2) + preview = source + 1 + export_param = ImageExportParam.create(normalization="none") + preview_item = Mock() + plot = Mock() + dialog = SimpleNamespace( + obj=SimpleNamespace(data=source), + filename="image.jpeg", + preview_item=preview_item, + get_export_param=Mock(return_value=export_param), + manager=SimpleNamespace(get_plot=Mock(return_value=plot)), + ) + + with patch.object( + image_panel, "prepare_image_export_preview", return_value=preview + ) as prepare_preview: + result = ImageExportDialog.update_preview(dialog) + + assert result is True + dialog.get_export_param.assert_called_once_with() + prepare_preview.assert_called_once_with(source, "image.jpeg", export_param) + preview_item.set_data.assert_called_once_with(preview) + plot.replot.assert_called_once_with() + plot.add_item.assert_not_called() + + +@pytest.mark.parametrize("exception_type", [TypeError, ValueError, OSError]) +def test_export_preview_reports_expected_errors( + exception_type: type[Exception], +) -> None: + """Report expected validation and encoding errors without updating the plot.""" + error = exception_type("Invalid export options") + dialog = SimpleNamespace( + obj=SimpleNamespace(data=np.zeros((2, 2), dtype=np.uint8)), + filename="image.png", + get_export_param=Mock(return_value=ImageExportParam()), + ) + + with ( + patch.object(image_panel, "prepare_image_export_preview", side_effect=error), + patch.object(image_panel.QW.QMessageBox, "warning") as warning, + ): + result = ImageExportDialog.update_preview(dialog) + + assert result is False + warning.assert_called_once_with(dialog, image_panel._("Export image"), str(error)) + + def test_interactive_export_cancellation_skips_write_and_history_pair() -> None: """Exclude a cancelled image export dialog from writing and history.""" objects = [create_stub_object("uuid-1"), create_stub_object("uuid-2")] @@ -305,7 +520,7 @@ def test_explicit_filename_is_noninteractive_and_uses_legacy_path() -> None: assert "export_params" not in history_call.kwargs -@pytest.mark.parametrize("extension", ["h5", "csv", "txt", "asc", "mat"]) +@pytest.mark.parametrize("extension", ["h5", "camera", "plugin"]) def test_unsupported_extension_uses_legacy_writer_parameters(extension: str) -> None: """Bypass export preparation and its dialog for writable legacy formats.""" obj = SimpleNamespace(data=np.array([[1, 3]], dtype=np.float32)) @@ -324,16 +539,29 @@ def test_export_dialog_validation_error_is_not_suppressed() -> None: obj = SimpleNamespace(data=np.array([[1, 3]], dtype=np.float32)) panel = SimpleNamespace(parentWidget=Mock(return_value=None)) guiparam = Mock() - guiparam.edit.side_effect = ValueError("invalid parameters") + dialog = Mock() with ( patch.object( image_panel, "create_image_export_gui_param", return_value=guiparam - ), + ) as create_guiparam, + patch.object( + image_panel, "ImageExportDialog", return_value=dialog + ) as dialog_class, + patch.object( + image_panel, + "exec_dialog", + side_effect=ValueError("invalid parameters"), + ) as exec_dialog, pytest.raises(ValueError, match="invalid parameters"), ): ImagePanel.edit_export_parameters(panel, obj, "image.png") + create_guiparam.assert_called_once_with(obj, "image.png") + panel.parentWidget.assert_called_once_with() + dialog_class.assert_called_once_with(None, obj, "image.png", guiparam) + exec_dialog.assert_called_once_with(dialog) + def test_explicit_image_export_parameter_is_forwarded() -> None: """Forward an explicit export parameter through the image writer hook.""" From 7960f81acf4e0ddf34a00f3919a9e5d84a4e588b Mon Sep 17 00:00:00 2001 From: Thomas MALLET Date: Wed, 14 Oct 2026 15:35:08 +0200 Subject: [PATCH 4/9] Bundle imagecodecs codecs to open compressed TIFF files * [UPDATE] : DataLab.spec: - collect lazily loaded imagecodecs submodules as hidden imports - exclude optional numcodecs and zarr integration modules * [UPDATE] : doc/release_notes/release_1.04.md: - add compressed TIFF bug fix entry (fixes #381) * [UPDATE] : doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po: - translate new release note entries * [UPDATE] : doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po: - update catalog with new Sigima image format docstrings --- DataLab.spec | 5 +++++ .../LC_MESSAGES/features/advanced/plugins.po | 22 +++++++++++++++++++ .../LC_MESSAGES/release_notes/release_1.04.po | 6 +++++ doc/release_notes/release_1.04.md | 4 ++++ 4 files changed, 37 insertions(+) diff --git a/DataLab.spec b/DataLab.spec index 3d58f6ec4..056f10a6a 100644 --- a/DataLab.spec +++ b/DataLab.spec @@ -7,6 +7,11 @@ from PyInstaller.utils.hooks import collect_submodules, collect_data_files, copy all_hidden_imports = collect_submodules('datalab') # Plugins installed from a file may import any Sigima module all_hidden_imports += collect_submodules('sigima', filter=lambda name: '.tests' not in name) +# imagecodecs loads its codec extension modules lazily (``importlib``), so they +# are not detected by static analysis; tifffile needs them for compressed TIFF +all_hidden_imports += collect_submodules( + 'imagecodecs', filter=lambda name: name.split('.')[-1] not in ('numcodecs', 'zarr') +) datas = collect_data_files('datalab') + [('datalab\\plugins\\builtin', 'datalab\\plugins\\builtin')] # Macro templates are read with ``pkgutil.get_data`` and must exist on disk, # not only inside the PYZ archive (``collect_data_files`` skips ``.py`` files): diff --git a/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po b/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po index 5921691d2..9df4052ef 100644 --- a/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po +++ b/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po @@ -562,6 +562,9 @@ msgstr "" msgid "Write data to file" msgstr "" +msgid "Write classic image data with validated format-specific options." +msgstr "" + msgid "Information about a plugin that failed to load or instantiate." msgstr "Informations sur un plugin qui n'a pas pu être chargé ou instancié." @@ -577,12 +580,18 @@ msgstr "" msgid "This class is used to define the interface for image I/O formats. It is an abstract base class that defines the methods that must be implemented by any image format class." msgstr "" +msgid "Reject read parameters for formats that do not support them." +msgstr "" + msgid "Read list of image objects from file" msgstr "" msgid "Callback worker object" msgstr "" +msgid "Optional format-specific read parameters" +msgstr "" + msgid "List of image objects" msgstr "" @@ -599,6 +608,19 @@ msgstr "Lève" msgid "if format is not supported" msgstr "" +msgid "Write an image with format-specific options." +msgstr "" + +#, fuzzy +msgid "Image object" +msgstr "objets sélectionnés" + +msgid "Format-specific writer options" +msgstr "" + +msgid "If this format does not support writer options" +msgstr "" + msgid "Plugin base class" msgstr "Classe de base du plugin" 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 index 71693d081..6ea206f18 100644 --- a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po +++ b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po @@ -216,6 +216,12 @@ msgstr "**Disposition de la fenêtre :**" msgid "Fixed the width of the signal/image panels not being restored at startup: after narrowing the panels and restarting DataLab, they came back wider (up to their maximum width) instead of keeping the width chosen by the user" msgstr "Correction de la largeur des panneaux Signal/Image non restaurée au démarrage : après avoir réduit les panneaux puis redémarré DataLab, ils réapparaissaient plus larges (jusqu'à leur largeur maximale) au lieu de conserver la largeur choisie par l'utilisateur" +msgid "**File import:**" +msgstr "**Import de fichiers :**" + +msgid "Fixed compressed TIFF files (LZW, JPEG, WebP or CCITT compression) failing to open with the error \"requires the 'imagecodecs' package\"; LZW is the default compression of many acquisition and image-editing tools. These files now open out of the box, including with the standalone installer (fixes [Issue #381](https://github.com/DataLab-Platform/DataLab/issues/381))" +msgstr "Correction de l'ouverture des fichiers TIFF compress?s (compression LZW, JPEG, WebP ou CCITT), qui ?chouait avec l'erreur ? requires the 'imagecodecs' package ? ; LZW est la compression par d?faut de nombreux outils d'acquisition et d'?dition d'images. Ces fichiers s'ouvrent d?sormais sans installation suppl?mentaire, y compris avec l'installeur autonome (corrige [Issue #381](https://github.com/DataLab-Platform/DataLab/issues/381))" + msgid "**Metadata:**" msgstr "**Métadonnées :**" diff --git a/doc/release_notes/release_1.04.md b/doc/release_notes/release_1.04.md index 4539b1080..6f57d98ab 100644 --- a/doc/release_notes/release_1.04.md +++ b/doc/release_notes/release_1.04.md @@ -96,6 +96,10 @@ DataLab now uses stable UUID-based object references across object trees, result * Fixed the width of the signal/image panels not being restored at startup: after narrowing the panels and restarting DataLab, they came back wider (up to their maximum width) instead of keeping the width chosen by the user +**File import:** + +* Fixed compressed TIFF files (LZW, JPEG, WebP or CCITT compression) failing to open with the error "requires the 'imagecodecs' package"; LZW is the default compression of many acquisition and image-editing tools. These files now open out of the box, including with the standalone installer (fixes [Issue #381](https://github.com/DataLab-Platform/DataLab/issues/381)) + **Metadata:** * Adding, pasting or deleting metadata now marks the workspace as modified, so DataLab asks to save these changes before closing From 07e7d313ed3722e2c5dee447b9b7fd5ea0c809b0 Mon Sep 17 00:00:00 2001 From: Thomas MALLET Date: Wed, 14 Oct 2026 15:44:32 +0200 Subject: [PATCH 5/9] Clarify CCITT TIFF support in release notes * [UPDATE] : doc/release_notes/release_1.04.md: - state that CCITT compression requires Python 3.11 or later * [UPDATE] : doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po: - update translation of the compressed TIFF entry --- doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po | 4 ++-- doc/release_notes/release_1.04.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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 index 6ea206f18..39ce6e15d 100644 --- a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po +++ b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po @@ -219,8 +219,8 @@ msgstr "Correction de la largeur des panneaux Signal/Image non restaurée au dé msgid "**File import:**" msgstr "**Import de fichiers :**" -msgid "Fixed compressed TIFF files (LZW, JPEG, WebP or CCITT compression) failing to open with the error \"requires the 'imagecodecs' package\"; LZW is the default compression of many acquisition and image-editing tools. These files now open out of the box, including with the standalone installer (fixes [Issue #381](https://github.com/DataLab-Platform/DataLab/issues/381))" -msgstr "Correction de l'ouverture des fichiers TIFF compress?s (compression LZW, JPEG, WebP ou CCITT), qui ?chouait avec l'erreur ? requires the 'imagecodecs' package ? ; LZW est la compression par d?faut de nombreux outils d'acquisition et d'?dition d'images. Ces fichiers s'ouvrent d?sormais sans installation suppl?mentaire, y compris avec l'installeur autonome (corrige [Issue #381](https://github.com/DataLab-Platform/DataLab/issues/381))" +msgid "Fixed compressed TIFF files (LZW, JPEG or WebP compression) failing to open with the error \"requires the 'imagecodecs' package\"; LZW is the default compression of many acquisition and image-editing tools. These files now open out of the box, including with the standalone installer. CCITT-compressed bilevel images are also supported with the standalone installer and with Python 3.11 or later (fixes [Issue #381](https://github.com/DataLab-Platform/DataLab/issues/381))" +msgstr "Correction de l'ouverture des fichiers TIFF compress?s (compression LZW, JPEG ou WebP), qui ?chouait avec l'erreur ? requires the 'imagecodecs' package ? ; LZW est la compression par d?faut de nombreux outils d'acquisition et d'?dition d'images. Ces fichiers s'ouvrent d?sormais sans installation suppl?mentaire, y compris avec l'installeur autonome. Les images binaires compress?es en CCITT sont ?galement prises en charge avec l'installeur autonome et avec Python 3.11 ou ult?rieur (corrige [Issue #381](https://github.com/DataLab-Platform/DataLab/issues/381))" msgid "**Metadata:**" msgstr "**Métadonnées :**" diff --git a/doc/release_notes/release_1.04.md b/doc/release_notes/release_1.04.md index 6f57d98ab..b43982400 100644 --- a/doc/release_notes/release_1.04.md +++ b/doc/release_notes/release_1.04.md @@ -98,7 +98,7 @@ DataLab now uses stable UUID-based object references across object trees, result **File import:** -* Fixed compressed TIFF files (LZW, JPEG, WebP or CCITT compression) failing to open with the error "requires the 'imagecodecs' package"; LZW is the default compression of many acquisition and image-editing tools. These files now open out of the box, including with the standalone installer (fixes [Issue #381](https://github.com/DataLab-Platform/DataLab/issues/381)) +* Fixed compressed TIFF files (LZW, JPEG or WebP compression) failing to open with the error "requires the 'imagecodecs' package"; LZW is the default compression of many acquisition and image-editing tools. These files now open out of the box, including with the standalone installer. CCITT-compressed bilevel images are also supported with the standalone installer and with Python 3.11 or later (fixes [Issue #381](https://github.com/DataLab-Platform/DataLab/issues/381)) **Metadata:** From 464cbe64a1a69a4f7f6a83cacb124f401dfaa36b Mon Sep 17 00:00:00 2001 From: Thomas MALLET Date: Wed, 14 Oct 2026 17:30:23 +0200 Subject: [PATCH 6/9] Unify RAW import parameters and expose them through the remote API * [UPDATE] : datalab/gui/panel/base.py: - replace per-file edit/prepare import hooks with a batch edit_import_parameters - add param argument to load_from_files (applied without dialog) - collect all files in load_from_directory and ask import parameters once - record import_filenames/import_params in directory history entries - reuse recorded parameters on replay, only asking for new files * [UPDATE] : datalab/gui/panel/image.py: - remove RawImageImportGUIParam and the "open all RAW files" option - ask whether to apply the same parameters to several RAW files - open one dialog for all files, or one dialog per file - add is_raw_image_file helper * [UPDATE] : datalab/history/action.py: - remap save_to_files object_uuids in copy_with_uuid_remap - update docstring listing remapped references * [UPDATE] : datalab/control/remote.py: - accept serialized import parameters in load_from_files/load_from_directory - serialize import parameters in RemoteClient like calc * [UPDATE] : datalab/control/baseproxy.py: - add param argument to load_from_files/load_from_directory * [UPDATE] : datalab/gui/main.py: - forward import parameters to the current panel * [UPDATE] : datalab/aiassistant/tools/builtin.py: - add optional RAW import_parameters to the load_file tool * [UPDATE] : datalab/locale/fr/LC_MESSAGES/datalab.po: - translate new RAW import strings, remove obsolete entries * [UPDATE] : datalab/tests/features/image/raw_import_unit_test.py: - adapt tests to the batch import mechanism - add shared/per-file/cancel tests for several RAW files - add param argument and directory import/replay tests * [UPDATE] : datalab/tests/features/image/raw_import_app_test.py: - add test_raw_directory_import_replay_reuses_recorded_parameters * [UPDATE] : datalab/tests/features/common/history_model_unit_test.py: - add test_action_copy_remaps_exported_object_uuids * [UPDATE] : datalab/tests/aiassistant/test_tool_registry.py: - add load_file import parameter tests --- datalab/aiassistant/tools/builtin.py | 43 +- datalab/control/baseproxy.py | 32 +- datalab/control/remote.py | 54 +- datalab/gui/main.py | 14 +- datalab/gui/panel/base.py | 168 +- datalab/gui/panel/image.py | 123 +- datalab/history/action.py | 10 +- datalab/locale/fr/LC_MESSAGES/datalab.po | 1782 ++++++++--------- .../tests/aiassistant/test_tool_registry.py | 70 + .../common/history_model_unit_test.py | 24 + .../features/image/raw_import_app_test.py | 50 +- .../features/image/raw_import_unit_test.py | 306 ++- 12 files changed, 1621 insertions(+), 1055 deletions(-) diff --git a/datalab/aiassistant/tools/builtin.py b/datalab/aiassistant/tools/builtin.py index fe6f73977..80c332d38 100644 --- a/datalab/aiassistant/tools/builtin.py +++ b/datalab/aiassistant/tools/builtin.py @@ -26,6 +26,8 @@ from qtpy import QtCore as QC from qtpy import QtGui as QG from qtpy import QtWidgets as QW +from sigima.io import RawImageImportParam +from sigima.io.image.raw import RAW_DTYPES from datalab.aiassistant.providers.base import ChatMessage from datalab.aiassistant.tools.registry import Tool, ToolRegistry, ToolResult @@ -408,10 +410,15 @@ def _tool_load_file( mainwindow: DLMainWindow, filename: str, panel: str | None = None, + import_parameters: dict[str, Any] | None = None, ) -> dict[str, Any]: panel_widget = _resolve_panel(mainwindow, panel) proxy.set_current_panel(panel_widget.PANEL_STR_ID) - objs = panel_widget.load_from_files([filename]) + param = None + if import_parameters: + # ``create`` rejects unknown parameter names + param = RawImageImportParam.create(**import_parameters) + objs = panel_widget.load_from_files([filename], param=param) return { "filename": filename, "panel": panel_widget.PANEL_STR_ID, @@ -994,13 +1001,45 @@ def build_default_registry(expose_macro_tool: bool = True) -> ToolRegistry: name="load_file", description=( "Load a signal or image file into DataLab. The file format is " - "auto-detected based on the filename extension." + "auto-detected based on the filename extension. Headerless RAW " + "image files (*.raw) require 'import_parameters' describing their " + "binary layout; otherwise, the user is asked for them." ), parameters={ "type": "object", "properties": { "filename": {"type": "string"}, "panel": _PANEL_PARAM, + "import_parameters": { + "type": "object", + "description": ( + "RAW image import parameters (image panel only). " + "Omitted fields keep their default value." + ), + "properties": { + "dtype": {"type": "string", "enum": list(RAW_DTYPES)}, + "width": {"type": "integer", "minimum": 1}, + "height": {"type": "integer", "minimum": 1}, + "offset": { + "type": "integer", + "minimum": 0, + "description": "Bytes before the first image.", + }, + "count": { + "type": "integer", + "minimum": 1, + "description": "Number of images in the file.", + }, + "gap": { + "type": "integer", + "minimum": 0, + "description": "Bytes between consecutive images.", + }, + "little_endian": {"type": "boolean"}, + "white_is_zero": {"type": "boolean"}, + }, + "additionalProperties": False, + }, }, "required": ["filename"], "additionalProperties": False, diff --git a/datalab/control/baseproxy.py b/datalab/control/baseproxy.py index 1fcc404fc..1d3cac4e5 100644 --- a/datalab/control/baseproxy.py +++ b/datalab/control/baseproxy.py @@ -242,19 +242,29 @@ def save_h5_workspace(self, filename: str) -> None: """ @abc.abstractmethod - def load_from_files(self, filenames: list[str]) -> None: + def load_from_files( + self, filenames: list[str], param: gds.DataSet | None = None + ) -> None: """Open objects from files in current panel (signals/images). Args: filenames: list of file names + param: Optional format-specific import parameters applied to every + file requiring them, without showing any dialog (e.g. + :class:`sigima.io.RawImageImportParam` for RAW images). + Defaults to None. """ @abc.abstractmethod - def load_from_directory(self, path: str) -> None: + def load_from_directory(self, path: str, param: gds.DataSet | None = None) -> None: """Open objects from directory in current panel (signals/images). Args: path: directory path + param: Optional format-specific import parameters applied to every + file requiring them, without showing any dialog (e.g. + :class:`sigima.io.RawImageImportParam` for RAW images). + Defaults to None. """ @abc.abstractmethod @@ -796,21 +806,31 @@ def save_h5_workspace(self, filename: str) -> None: """ self._datalab.save_h5_workspace(filename) - def load_from_files(self, filenames: list[str]) -> None: + def load_from_files( + self, filenames: list[str], param: gds.DataSet | None = None + ) -> None: """Open objects from files in current panel (signals/images). Args: filenames: list of file names + param: Optional format-specific import parameters applied to every + file requiring them, without showing any dialog (e.g. + :class:`sigima.io.RawImageImportParam` for RAW images). + Defaults to None. """ - self._datalab.load_from_files(filenames) + self._datalab.load_from_files(filenames, param) - def load_from_directory(self, path: str) -> None: + def load_from_directory(self, path: str, param: gds.DataSet | None = None) -> None: """Open objects from directory in current panel (signals/images). Args: path: directory path + param: Optional format-specific import parameters applied to every + file requiring them, without showing any dialog (e.g. + :class:`sigima.io.RawImageImportParam` for RAW images). + Defaults to None. """ - self._datalab.load_from_directory(path) + self._datalab.load_from_directory(path, param) def get_sel_object_uuids(self, include_groups: bool = False) -> list[str]: """Return selected objects uuids. diff --git a/datalab/control/remote.py b/datalab/control/remote.py index 8a75a3124..f69cdd941 100644 --- a/datalab/control/remote.py +++ b/datalab/control/remote.py @@ -86,8 +86,8 @@ class RemoteServer(QC.QThread): SIG_ADD_OBJECT = QC.Signal(object, str, bool) SIG_SET_OBJECT = QC.Signal(object) SIG_ADD_GROUP = QC.Signal(str, str, bool) - SIG_LOAD_FROM_FILES = QC.Signal(list) - SIG_LOAD_FROM_DIRECTORY = QC.Signal(str) + SIG_LOAD_FROM_FILES = QC.Signal(list, object) + SIG_LOAD_FROM_DIRECTORY = QC.Signal(str, object) SIG_SELECT_OBJECTS = QC.Signal(list, str) SIG_SELECT_GROUPS = QC.Signal(list, str) SIG_SELECT_ALL_GROUPS = QC.Signal(str) @@ -337,22 +337,32 @@ def save_h5_workspace(self, filename: str) -> None: self.SIG_SAVE_H5_WORKSPACE.emit(filename) @remote_call - def load_from_files(self, filenames: list[str]) -> None: + def load_from_files( + self, filenames: list[str], param_data: list[str] | None = None + ) -> None: """Open objects from files in current panel (signals/images). Args: filenames: list of file names + param_data: Optional serialized format-specific import parameters + applied to every file requiring them. Defaults to None. """ - self.SIG_LOAD_FROM_FILES.emit(filenames) + param = None if param_data is None else utils.rpcjson_to_dataset(param_data) + self.SIG_LOAD_FROM_FILES.emit(filenames, param) @remote_call - def load_from_directory(self, path: str) -> None: + def load_from_directory( + self, path: str, param_data: list[str] | None = None + ) -> None: """Open objects from directory in current panel (signals/images). Args: path: directory path + param_data: Optional serialized format-specific import parameters + applied to every file requiring them. Defaults to None. """ - self.SIG_LOAD_FROM_DIRECTORY.emit(path) + param = None if param_data is None else utils.rpcjson_to_dataset(param_data) + self.SIG_LOAD_FROM_DIRECTORY.emit(path, param) @remote_call def add_signal( @@ -1118,6 +1128,38 @@ def set_object(self, obj: SignalObj | ImageObj) -> None: obj_data = utils.dataset_to_rpcjson(obj) self._datalab.set_object(obj_data) + def load_from_files( + self, filenames: list[str], param: gds.DataSet | None = None + ) -> None: + """Open objects from files in current panel (signals/images). + + Args: + filenames: list of file names + param: Optional format-specific import parameters applied to every + file requiring them, without showing any dialog (e.g. + :class:`sigima.io.RawImageImportParam` for RAW images). + Defaults to None. + """ + if param is None: + self._datalab.load_from_files(filenames) + else: + self._datalab.load_from_files(filenames, utils.dataset_to_rpcjson(param)) + + def load_from_directory(self, path: str, param: gds.DataSet | None = None) -> None: + """Open objects from directory in current panel (signals/images). + + Args: + path: directory path + param: Optional format-specific import parameters applied to every + file requiring them, without showing any dialog (e.g. + :class:`sigima.io.RawImageImportParam` for RAW images). + Defaults to None. + """ + if param is None: + self._datalab.load_from_directory(path) + else: + self._datalab.load_from_directory(path, utils.dataset_to_rpcjson(param)) + def calc(self, name: str, param: gds.DataSet | None = None) -> None: """Call computation feature ``name`` diff --git a/datalab/gui/main.py b/datalab/gui/main.py index 1fd986d43..09f2ef3d5 100644 --- a/datalab/gui/main.py +++ b/datalab/gui/main.py @@ -2297,24 +2297,30 @@ def set_object(self, obj: SignalObj | ImageObj) -> None: raise TypeError(f"Unsupported object type {type(obj)}") @remote_controlled - def load_from_files(self, filenames: list[str]) -> None: + def load_from_files( + self, filenames: list[str], param: gds.DataSet | None = None + ) -> None: """Open objects from files in current panel (signals/images) Args: filenames: list of filenames + param: Optional format-specific import parameters applied to every + file requiring them, without showing any dialog. Defaults to None. """ panel = self.__get_current_basedatapanel() - panel.load_from_files(filenames) + panel.load_from_files(filenames, param=param) @remote_controlled - def load_from_directory(self, path: str) -> None: + def load_from_directory(self, path: str, param: gds.DataSet | None = None) -> None: """Open objects from directory in current panel (signals/images). Args: path: directory path + param: Optional format-specific import parameters applied to every + file requiring them, without showing any dialog. Defaults to None. """ panel = self.__get_current_basedatapanel() - panel.load_from_directory(path) + panel.load_from_directory(path, param=param) # ------Other methods related to AbstractDLControl interface def get_version(self) -> str: diff --git a/datalab/gui/panel/base.py b/datalab/gui/panel/base.py index ed4d33703..3ebccb182 100644 --- a/datalab/gui/panel/base.py +++ b/datalab/gui/panel/base.py @@ -3023,30 +3023,29 @@ def read_objects(worker): self.selection_changed() return objs - def edit_import_parameters(self, filename: str) -> tuple[bool, gds.DataSet | None]: - """Edit optional object import parameters. + def edit_import_parameters( + self, filenames: list[str], param: gds.DataSet | None = None + ) -> list[tuple[str, gds.DataSet | None]] | None: + """Return files to import with their optional format-specific parameters. Args: - filename: File name. + filenames: File names to import. + param: Optional import parameters applied to every file requiring + parameters, without showing any dialog. Returns: - A tuple containing the confirmation state and optional import parameters. - """ - return True, None - - def prepare_import_parameters( - self, filename: str, param: gds.DataSet | None - ) -> list[tuple[str, gds.DataSet | None]]: - """Prepare file names and parameters for object import. - - Args: - filename: Selected file name. - param: Optional parameters returned by the import dialog or history. + File names with aligned import parameters, without the files whose + parameter dialog was cancelled, or None if the whole import was + cancelled. - Returns: - File names and aligned import parameters. + Raises: + TypeError: If ``param`` is given, as this panel has no import parameters. """ - return [(filename, param)] + if param is not None: + raise TypeError( + f"{self.__class__.__name__} does not support import parameters" + ) + return [(fname, None) for fname in filenames] def apply_import_parameters( self, @@ -3091,13 +3090,27 @@ def write_object_to_file( del param self.IO_REGISTRY.write(filename, obj) - def load_from_directory(self, directory: str | None = None) -> list[TypeObj]: + def load_from_directory( + self, + directory: str | None = None, + import_filenames: list[str] | None = None, + import_params: list[gds.DataSet] | None = None, + param: gds.DataSet | None = None, + ) -> list[TypeObj]: """Open objects from directory (signals or images, depending on the panel), add them to DataLab and return them. If the directory is not specified, ask the user to select a directory. Args: directory: directory name + import_filenames: Optional file names, relative to ``directory``, of + the files imported with format-specific parameters. Recorded by + history so that replay does not reopen import dialogs. + import_params: Optional format-specific parameters aligned with + ``import_filenames``. + param: Optional format-specific parameters applied to every file + requiring import parameters (e.g. RAW images), without showing any + dialog. Returns: list of new objects @@ -3110,6 +3123,54 @@ def load_from_directory(self, directory: str | None = None) -> list[TypeObj]: directory = getexistingdirectory(self, _("Open"), basedir) if not directory: return [] + folders: list[tuple[str, list[str]]] = [] + for path in glob.glob(osp.join(directory, "**"), recursive=True): + if osp.isdir(path): + path = osp.normpath(path) + fnames = sorted( + osp.join(path, fname) + for fname in os.listdir(path) + if osp.isfile(osp.join(path, fname)) + ) + if fnames: + folders.append((path, fnames)) + + def relative_key(filename: str) -> str: + return osp.normcase(osp.normpath(osp.relpath(filename, directory))) + + # Parameters recorded by history are reused; import parameters are asked + # once for all the other files of the directory tree + recorded_params: dict[str, gds.DataSet] = {} + if import_filenames is not None: + assert import_params is not None and len(import_params) == len( + import_filenames + ), "Number of import parameters must match number of files" + recorded_params = { + osp.normcase(osp.normpath(fname)): fparam + for fname, fparam in zip(import_filenames, import_params) + } + all_files = [fname for _path, fnames in folders for fname in fnames] + pending = [ + fname for fname in all_files if relative_key(fname) not in recorded_params + ] + prepared_pairs = self.edit_import_parameters(pending, param) + if prepared_pairs is None: + return [] + file_params: dict[str, gds.DataSet | None] = dict(prepared_pairs) + for fname in all_files: + if relative_key(fname) in recorded_params: + file_params[fname] = recorded_params[relative_key(fname)] + history_kwargs: dict[str, Any] = {"directory": directory} + param_files = [ + fname for fname in all_files if file_params.get(fname) is not None + ] + if param_files: + history_kwargs["import_filenames"] = [ + osp.relpath(fname, directory) for fname in param_files + ] + history_kwargs["import_params"] = [ + copy.deepcopy(file_params[fname]) for fname in param_files + ] # Offer a fresh history session for this batch *before* loading anything. self.mainwindow.historypanel.maybe_start_session_for_input(load=True) action = self.mainwindow.historypanel.add_ui_entry( @@ -3117,13 +3178,8 @@ def load_from_directory(self, directory: str | None = None) -> list[TypeObj]: target=self.PANEL_STR_ID + "panel", method_name="load_from_directory", save_state=False, - directory=directory, + **history_kwargs, ) - folders = [ - path - for path in glob.glob(osp.join(directory, "**"), recursive=True) - if osp.isdir(path) and len(os.listdir(path)) > 0 - ] objs = [] with self.mainwindow.historypanel.session_prompt_suppressed(): with self.mainwindow.historypanel.capture_outputs(action): @@ -3131,23 +3187,20 @@ def load_from_directory(self, directory: str | None = None) -> list[TypeObj]: self, _("Scanning directory"), max_=len(folders) - 1 ) as progress: # Iterate over all subfolders in the directory: - for i_path, path in enumerate(folders): + for i_path, (path, fnames) in enumerate(folders): progress.setValue(i_path + 1) if progress.wasCanceled(): break - path = osp.normpath(path) - fnames = sorted( - [ - osp.join(path, fname) - for fname in os.listdir(path) - if osp.isfile(osp.join(path, fname)) - ] - ) + # Files whose import dialog was cancelled are skipped + fnames = [fname for fname in fnames if fname in file_params] + if not fnames: + continue new_objs = self.load_from_files( fnames, create_group=False, add_objects=False, ignore_errors=True, + import_params=[file_params[fname] for fname in fnames], ) if new_objs: objs += new_objs @@ -3168,6 +3221,7 @@ def load_from_files( add_objects: bool = True, ignore_errors: bool = False, import_params: list[gds.DataSet | None] | None = None, + param: gds.DataSet | None = None, ) -> list[TypeObj]: """Open and return objects from files, optionally adding them to DataLab. @@ -3180,10 +3234,15 @@ def load_from_files( ignore_errors: if True, ignore errors when loading files. Defaults to False. import_params: Optional format-specific parameters aligned with file names. When provided by history replay, import dialogs are not reopened. + param: Optional format-specific parameters applied to every file + requiring import parameters (e.g. RAW images), without showing any + dialog. Cannot be combined with ``import_params``. Returns: list of new objects """ + if import_params is not None and param is not None: + raise ValueError("import_params and param cannot be combined") if not self.mainwindow.confirm_memory_state(): return [] if filenames is None: # pragma: no cover @@ -3193,38 +3252,17 @@ def load_from_files( filenames, _filt = getopenfilenames(self, _("Open"), basedir, filters) if not filenames: # pragma: no cover return [] - parameters_provided = import_params is not None if import_params is None: - import_params = [None] * len(filenames) - assert len(import_params) == len(filenames), ( - "Number of import parameters must match number of files" - ) - selected_pairs = sorted(zip(filenames, import_params), key=lambda pair: pair[0]) - prepared_pairs: list[tuple[str, gds.DataSet | None]] = [] - prepared_paths: set[str] = set() - expanded_paths: set[str] = set() - for filename, param in selected_pairs: - selected_path = osp.normcase(osp.abspath(filename)) - if selected_path in expanded_paths: - continue - if not parameters_provided: - confirmed, param = self.edit_import_parameters(filename) - if not confirmed: - continue - new_pairs = self.prepare_import_parameters(filename, param) - is_expansion = len(new_pairs) > 1 - for prepared_filename, prepared_param in new_pairs: - path_key = osp.normcase(osp.abspath(prepared_filename)) - if path_key in expanded_paths or ( - is_expansion and path_key in prepared_paths - ): - continue - prepared_pairs.append((prepared_filename, prepared_param)) - prepared_paths.add(path_key) - if is_expansion: - expanded_paths.update( - osp.normcase(osp.abspath(path)) for path, _param in new_pairs - ) + prepared_pairs = self.edit_import_parameters(sorted(filenames), param) + if prepared_pairs is None: + return [] + else: + assert len(import_params) == len(filenames), ( + "Number of import parameters must match number of files" + ) + prepared_pairs = sorted( + zip(filenames, import_params), key=lambda pair: pair[0] + ) if not prepared_pairs: return [] filenames = [filename for filename, _param in prepared_pairs] diff --git a/datalab/gui/panel/image.py b/datalab/gui/panel/image.py index 457dd77c8..cb85d282f 100644 --- a/datalab/gui/panel/image.py +++ b/datalab/gui/panel/image.py @@ -9,7 +9,6 @@ from __future__ import annotations import copy -import os import os.path as osp from typing import TYPE_CHECKING, Type from weakref import ReferenceType, ref @@ -44,6 +43,7 @@ from sigima.objects import ImageDatatypes, ImageObj, ImageROI, NewImageParam from datalab.config import Conf, _ +from datalab.env import execenv from datalab.gui import roieditor from datalab.gui.actionhandler import ImageActionHandler from datalab.gui.newobject import create_image_gui @@ -257,12 +257,16 @@ def get_image_export_param_class(filename: str) -> type[ImageExportGUIParam]: return paramclass -class RawImageImportGUIParam(RawImageImportParam, title=_("RAW image import")): - """RAW image import parameters extended with DataLab batch options.""" +def is_raw_image_file(filename: str) -> bool: + """Return True if the file is a headerless RAW image requiring parameters. - open_all_files = gds.BoolItem( - _("Open all RAW files in the same folder"), default=False - ) + Args: + filename: File name. + + Returns: + True if the file has a ``.raw`` extension. + """ + return osp.splitext(filename)[1].lower() == ".raw" def create_image_export_gui_param(obj: ImageObj, filename: str) -> ImageExportGUIParam: @@ -418,49 +422,94 @@ def get_roieditor_class() -> Type[roieditor.ImageROIEditor]: return roieditor.ImageROIEditor def edit_import_parameters( - self, filename: str - ) -> tuple[bool, RawImageImportGUIParam | None]: - """Edit RAW image import parameters when required by the file format. + self, filenames: list[str], param: gds.DataSet | None = None + ) -> list[tuple[str, gds.DataSet | None]] | None: + """Return files to import with their RAW image import parameters. + + When several RAW files are imported, the user first chooses whether the same + parameters apply to all of them (a single dialog) or not (one dialog per + file). Args: - filename: Source file name. + filenames: File names to import. + param: Optional RAW image import parameters applied to every RAW file + without showing any dialog (e.g. remote control). Returns: - A tuple containing the confirmation state and optional import parameters. + File names with aligned import parameters, without the RAW files whose + dialog was cancelled, or None if the whole import was cancelled. + + Raises: + TypeError: If ``param`` is not a RAW image import parameter set. """ - if osp.splitext(filename)[1].lower() != ".raw": - return True, None - param = RawImageImportGUIParam() - if not param.edit(parent=self.parentWidget()): - return False, None - return True, param + raw_files = [fname for fname in filenames if is_raw_image_file(fname)] + if param is not None: + if not isinstance(param, RawImageImportParam): + raise TypeError( + "Image import parameters must be RawImageImportParam, " + f"got {type(param).__name__}" + ) + raw_params = {fname: copy.deepcopy(param) for fname in raw_files} + elif raw_files: + raw_params = self.edit_raw_import_parameters(raw_files) + if raw_params is None: + return None + else: + raw_params = {} + return [ + (fname, raw_params.get(fname)) + for fname in filenames + if not is_raw_image_file(fname) or fname in raw_params + ] - def prepare_import_parameters( - self, filename: str, param: gds.DataSet | None - ) -> list[tuple[str, gds.DataSet | None]]: - """Prepare RAW file names and backend parameters for import. + def edit_raw_import_parameters( + self, raw_files: list[str] + ) -> dict[str, RawImageImportParam] | None: + """Edit RAW image import parameters, once for all files or per file. Args: - filename: Selected file name. - param: Optional GUI or backend import parameters. + raw_files: RAW file names. Returns: - File names and aligned backend import parameters. + Parameters of the confirmed files, or None if the import was cancelled. """ - if not isinstance(param, RawImageImportGUIParam): - return [(filename, param)] - backend_param = RawImageImportParam() - update_dataset(backend_param, param) - filenames = [filename] - if param.open_all_files: - directory = osp.dirname(filename) or "." - filenames = sorted( - osp.join(directory, name) - for name in os.listdir(directory) - if osp.isfile(osp.join(directory, name)) - and osp.splitext(name)[1].lower() == ".raw" + same_for_all = True + if len(raw_files) > 1 and not execenv.unattended: + answer = QW.QMessageBox.question( + self.parentWidget(), + _("RAW image import"), + _( + "%d RAW files are about to be imported.

" + "Do you want to apply the same import parameters to all of them?" + "

Choose No to set the parameters of each file." + ) + % len(raw_files), + QW.QMessageBox.Yes | QW.QMessageBox.No | QW.QMessageBox.Cancel, + QW.QMessageBox.Yes, + ) + if answer == QW.QMessageBox.Cancel: + return None + same_for_all = answer == QW.QMessageBox.Yes + param = RawImageImportParam() + if same_for_all: + if len(raw_files) == 1: + title = f"{_('RAW image import')} - {osp.basename(raw_files[0])}" + else: + title = _("RAW image import (%d files)") % len(raw_files) + param.set_title(title) + if not param.edit(parent=self.parentWidget()): + return {} + return {fname: copy.deepcopy(param) for fname in raw_files} + raw_params: dict[str, RawImageImportParam] = {} + for index, fname in enumerate(raw_files): + param.set_title( + f"{_('RAW image import')} - {osp.basename(fname)} " + f"({index + 1}/{len(raw_files)})" ) - return [(path, copy.deepcopy(backend_param)) for path in filenames] + # Each dialog starts from the values confirmed for the previous file + if param.edit(parent=self.parentWidget()): + raw_params[fname] = copy.deepcopy(param) + return raw_params def apply_import_parameters( self, objs: list[ImageObj], param: gds.DataSet | None diff --git a/datalab/history/action.py b/datalab/history/action.py index 84f761639..5e8323e0a 100644 --- a/datalab/history/action.py +++ b/datalab/history/action.py @@ -296,8 +296,9 @@ def copy_with_uuid_remap( ) -> HistoryAction: """Return a copy with supported object UUID references rewritten. - State selections and metadata, ``obj2_uuids``, and ``output_uuids`` are - remapped. Other UI keyword arguments are preserved. + State selections and metadata, ``obj2_uuids``, ``object_uuids``, + ``source_uuid``, ``output_uuid``, ``output_uuids``, ``target_uuids`` and + ``effects`` keys are remapped. Other UI keyword arguments are preserved. Args: uuid_remap: Per-panel mapping ``{panel_str: {old_uuid: new_uuid}}`` @@ -337,6 +338,11 @@ def remap_owned_uuid(uuid: str) -> str: new_action.kwargs["obj2_uuids"] = ( rewritten[0] if len(rewritten) == 1 else rewritten ) + # Objects exported by ``save_to_files`` live in the action panel. + object_uuids = new_action.kwargs.get("object_uuids") + if isinstance(object_uuids, (list, tuple)): + pmap = uuid_remap.get(new_action.effective_panel_str(), {}) + new_action.kwargs["object_uuids"] = [pmap.get(u, u) for u in object_uuids] source_uuid = new_action.kwargs.get("source_uuid") if isinstance(source_uuid, str): pmap = uuid_remap.get(new_action.effective_panel_str(), {}) diff --git a/datalab/locale/fr/LC_MESSAGES/datalab.po b/datalab/locale/fr/LC_MESSAGES/datalab.po index 15e460ce9..09343d71c 100644 --- a/datalab/locale/fr/LC_MESSAGES/datalab.po +++ b/datalab/locale/fr/LC_MESSAGES/datalab.po @@ -4,6 +4,7 @@ # 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" @@ -961,6 +962,9 @@ msgstr "Chargement des données depuis %s..." msgid "Clipping uint32 data to int32." msgstr "Troncature de données uint32 en int32." +msgid "Warning" +msgstr "Attention" + msgid "No supported data available in HDF5 file(s)." msgstr "Aucune donnée prise en charge dans le(s) fichier(s) HDF5." @@ -1014,6 +1018,9 @@ msgstr "# ==> Impossible d'exécuter la macro '%s'" msgid "Macro Python interpreter failed to start!" msgstr "Le démarrage de l'interpréteur Python des macros a échoué !" +msgid "Error" +msgstr "Erreur" + #, python-format msgid "# ==> Running '%s' macro..." msgstr "# ==> Exécution de la macro '%s'..." @@ -1044,6 +1051,10 @@ msgstr " ou dans la documentation PDF." msgid "Choosing to ignore this message will prevent it from appearing again." msgstr "Ignorer ce message empêchera son affichage ultérieur." +#, python-format +msgid "Opening tool '%s'" +msgstr "Ouverture de l'outil '%s'" + msgid "Plugins" msgstr "Plugins" @@ -1080,6 +1091,9 @@ msgstr "Afficher les titres des objets graphiques" msgid "Show or hide ROI and other graphical object titles or subtitles" msgstr "Afficher ou cacher les titres (et sous-titres) des régions d'intérêt et autres objets graphiques" +msgid "Browse application plugins, recipes and examples" +msgstr "Parcourir les plugins d'application, les méthodes et les exemples" + msgid "Reload plugins" msgstr "Recharger les plugins" @@ -1134,6 +1148,9 @@ msgstr "Traitement" msgid "Analysis" msgstr "Analyse" +msgid "Welcome page" +msgstr "Page d'accueil" + msgid "Tour" msgstr "Visite guidée" @@ -1170,6 +1187,9 @@ msgstr "Gestionnaire de Macros" msgid "History Panel" msgstr "Historique" +msgid "Welcome" +msgstr "Accueil" + msgid "Disable auto-refresh?" msgstr "Désactiver le rafraîchissement automatique ?" @@ -1304,6 +1324,10 @@ msgstr "" "Vous souhaitez travailler avec des signaux 1D ?\n" "Passez au panneau Signal en utilisant l'onglet ci-dessus." +#, python-format +msgid "e.g. %s" +msgstr "ex. %s" + msgid "History" msgstr "Historique" @@ -1413,20 +1437,6 @@ msgstr "L'objet source n'existe plus." msgid "The object that was used to create this processed object has been deleted and cannot be used for reprocessing." msgstr "L'objet qui a été utilisé pour créer cet objet traité a été supprimé et ne peut pas être utilisé pour le retraitement." -#, python-format -msgid "" -"Failed to reprocess object:\n" -"%s" -msgstr "" -"Échec du retraitement de l'objet :\n" -"%s" - -msgid "Processing was cancelled." -msgstr "Le traitement a été annulé." - -msgid "Failed to reprocess object." -msgstr "Échec du retraitement de l'objet." - msgid "Geometry results" msgstr "Résultats géométriques" @@ -1466,9 +1476,12 @@ msgstr "Écraser les fichiers existants" msgid "Preview" msgstr "Aperçu" -msgid "Add a new metadata item to the selected objects.

The metadata key will be the same for all objects, but the value can use a pattern to generate different values.
Click the Help button for details on the pattern syntax.
" +msgid "Add a new metadata item to the selected objects.

The metadata key will be the same for all objects, but the value can use a pattern to generate different values, optionally extracted with a regular expression.
Click the Help button for details on the pattern syntax.
" msgstr "Ajouter un nouvel élément de métadonnée aux objets sélectionnés.

La clé de métadonnée sera la même pour tous les objets, mais la valeur peut utiliser un modèle pour générer des valeurs différentes, éventuellement extraites par une expression régulière.
Cliquez sur le bouton Aide pour plus de détails sur la syntaxe du modèle.
" +msgid "Select a key..." +msgstr "Choisir une clé..." + msgid "String" msgstr "Chaîne de caractères" @@ -1481,18 +1494,48 @@ msgstr "Entier" msgid "Boolean" msgstr "Booléen" +msgid "unchanged" +msgstr "inchangé" + msgid "Metadata key" msgstr "Clé de métadonnées" msgid "The key name for the metadata item" msgstr "Le nom de la clé pour l'élément de métadonnées" +msgid "Known keys" +msgstr "Clés connues" + +msgid "Copy a key found on the selected objects into the metadata key" +msgstr "Copier dans la clé de métadonnée une clé présente sur les objets sélectionnés" + msgid "Value pattern" msgstr "Modèle de valeur" +msgid "Extraction pattern" +msgstr "Motif d'extraction" + +msgid "Optional regular expression searched in the formatted value: its first group, or the whole match, becomes the value" +msgstr "Expression régulière facultative recherchée dans la valeur formatée : son premier groupe, ou toute la correspondance, devient la valeur" + +msgid "If no match" +msgstr "Sans correspondance" + +msgid "Leave the object unchanged" +msgstr "Laisser l'objet inchangé" + +msgid "Report an error" +msgstr "Signaler une erreur" + msgid "Conversion" msgstr "Conversion" +msgid "Scale factor" +msgstr "Facteur d'échelle" + +msgid "Multiplies numeric values, e.g. 0.001 to convert ms to s" +msgstr "Multiplie les valeurs numériques, par ex. 0.001 pour convertir des ms en s" + msgid "Duplicate object or group" msgstr "Dupliquer l'objet ou le groupe" @@ -1518,12 +1561,12 @@ msgstr "Supprimer les métadonnées" msgid "Some selected objects have regions of interest.
Do you want to delete them as well?" msgstr "Certains objets sélectionnés ont des régions d'intérêt.
Souhaitez-vous les supprimer également ?" -msgid "Group name:" -msgstr "Nom du groupe :" - msgid "New group" msgstr "Nouveau groupe" +msgid "Group name:" +msgstr "Nom du groupe :" + #, python-format msgid "New group \"%s\"" msgstr "Nouveau groupe \"%s\"" @@ -1568,6 +1611,10 @@ msgstr "Charger depuis %d fichiers" msgid "Load \"%s\"" msgstr "Charger \"%s\"" +#, python-format +msgid "Skipping export: object %r no longer exists." +msgstr "Export ignoré : l'objet %r n'existe plus." + msgid "Save as" msgstr "Enregistrer sous" @@ -1901,6 +1948,161 @@ msgstr "Pas à pas" msgid "Replay the selection step by step, editing parameters at each step" msgstr "Rejouer la sélection pas à pas, en modifiant les paramètres à chaque étape" +msgid "Compression level" +msgstr "Niveau de compression" + +msgid "Compression" +msgstr "Compression" + +msgid "MAT compression" +msgstr "Compression MAT" + +msgid "Quality" +msgstr "Qualité" + +msgid "Subsampling" +msgstr "Sous-échantillonnage" + +msgid "Progressive" +msgstr "Progressif" + +msgid "Optimize" +msgstr "Optimiser" + +msgid "Smoothing" +msgstr "Lissage" + +msgid "Quality mode" +msgstr "Mode de qualité" + +msgid "Quality layers" +msgstr "Couches de qualité" + +msgid "Irreversible" +msgstr "Irréversible" + +msgid "Progression order" +msgstr "Ordre de progression" + +msgid "Number of resolutions" +msgstr "Nombre de résolutions" + +msgid "Tile size" +msgstr "Taille des tuiles" + +msgid "PLT markers" +msgstr "Marqueurs PLT" + +msgid "Predictor" +msgstr "Prédicteur" + +msgid "Rows per strip" +msgstr "Lignes par bande" + +msgid "Resolution" +msgstr "Résolution" + +msgid "Resolution unit" +msgstr "Unité de résolution" + +msgid "Photometric interpretation" +msgstr "Interprétation photométrique" + +msgid "Delimiter" +msgstr "Délimiteur" + +msgid "Precision" +msgstr "Précision" + +msgid "None" +msgstr "Aucun" + +msgid "Rates" +msgstr "Taux" + +msgid "Deflate" +msgstr "Deflate" + +msgid "Zstandard" +msgstr "Zstandard" + +msgid "Whitespace" +msgstr "Espaces blancs" + +msgid "Tab" +msgstr "Tabulation" + +msgid "Comma" +msgstr "Virgule" + +msgid "Semicolon" +msgstr "Point-virgule" + +msgid "Inch" +msgstr "Pouce" + +msgid "Centimeter" +msgstr "Centimètre" + +msgid "Horizontal" +msgstr "Horizontal" + +msgid "Floating-point" +msgstr "Virgule flottante" + +msgid "Black is zero" +msgstr "Le noir vaut zéro" + +msgid "White is zero" +msgstr "Le blanc vaut zéro" + +msgid "Automatic" +msgstr "Automatique" + +msgid "Image export" +msgstr "Exportation d'image" + +msgid "Target data type" +msgstr "Type de données cible" + +msgid "Gamma" +msgstr "Gamma" + +msgid "Invert" +msgstr "Inverser" + +#, python-format +msgid "%s may not be empty" +msgstr "%s ne peut pas être vide" + +#, python-format +msgid "%s must contain 2 values" +msgstr "%s doit contenir 2 valeurs" + +#, python-format +msgid "%s contains invalid numbers" +msgstr "%s contient des nombres non valides" + +msgid "Export options" +msgstr "Options d'exportation" + +msgid "Export image" +msgstr "Exporter l'image" + +msgid "Some required entries are incorrect." +msgstr "Certaines entrées obligatoires sont incorrectes." + +msgid "RAW image import" +msgstr "Importation d'image RAW" + +#, python-format +msgid "%d RAW files are about to be imported.

Do you want to apply the same import parameters to all of them?

Choose No to set the parameters of each file." +msgstr "%d fichiers RAW vont ?tre import?s.

Voulez-vous appliquer les m?mes param?tres d'importation ? tous ces fichiers ?

Choisissez Non pour d?finir les param?tres de chaque fichier." + +#, python-format +msgid "RAW image import (%d files)" +msgstr "Importation d'image RAW (%d fichiers)" + msgid "New image" msgstr "Nouvelle image" @@ -2006,27 +2208,183 @@ msgstr "Création des formes géométriques" msgid "Creating plot items" msgstr "Création des objets graphiques" -msgid "today" -msgstr "aujourd'hui" - -msgid "yesterday" -msgstr "hier" - #, python-format -msgid "Last loaded: %s at %s" -msgstr "Dernier chargement : %s à %s" +msgid "Version: %s" +msgstr "Version : %s" -msgid "Active" -msgstr "Actif" +msgid "Expected inputs" +msgstr "Entrées attendues" -msgid "Disabled" -msgstr "Désactivé" +msgid "Run on selection..." +msgstr "Exécuter sur la sélection..." -msgid "No description available" -msgstr "Aucune description disponible" +msgid "Examples designed for this method" +msgstr "Exemples conçus pour cette méthode" -msgid "Open file" -msgstr "Ouvrir le fichier" +msgid "Try with this example" +msgstr "Essayer avec cet exemple" + +msgid "The current selection could not be assessed" +msgstr "La sélection courante n'a pas pu être évaluée" + +msgid "Show on welcome page" +msgstr "Afficher sur la page d'accueil" + +msgid "Pin to the welcome page" +msgstr "Épingler sur la page d'accueil" + +msgid "Documentation" +msgstr "Documentation" + +#, python-format +msgid "Plugin ID: %s" +msgstr "ID du plugin : %s" + +msgid "No methods declared" +msgstr "Aucune méthode déclarée" + +msgid "Open tool" +msgstr "Ouvrir l'outil" + +#, python-format +msgid "Tools (%d)" +msgstr "Outils (%d)" + +msgid "Open dataset" +msgstr "Ouvrir le jeu de données" + +#, python-format +msgid "Datasets (%d)" +msgstr "Jeux de données (%d)" + +#, python-format +msgid "Created %d objects" +msgstr "%d objets créés" + +msgid "Search applications..." +msgstr "Rechercher des applications..." + +msgid "Hide the application list" +msgstr "Masquer la liste des applications" + +msgid "Show the application list" +msgstr "Afficher la liste des applications" + +msgid "No application plugins are currently loaded." +msgstr "Aucun plugin d'application n'est actuellement chargé." + +#, python-format +msgid "Starting analysis '%s'" +msgstr "Lancement de l'analyse '%s'" + +#, python-format +msgid "Trying example '%s'" +msgstr "Essai de l'exemple '%s'" + +#, python-format +msgid "Opening example '%s'" +msgstr "Ouverture de l'exemple '%s'" + +msgid "Official" +msgstr "Officiel" + +msgid "Community" +msgstr "Communauté" + +msgid "Source code" +msgstr "Code source" + +#, python-format +msgid "Withdrawn: %s" +msgstr "Retiré : %s" + +#, python-format +msgid "Not available for Python %s" +msgstr "Non disponible pour Python %s" + +msgid "Not available for DataLab desktop" +msgstr "Non disponible pour DataLab desktop" + +#, python-format +msgid "Installed in the Python environment (%s)" +msgstr "Installé dans l'environnement Python (%s)" + +msgid "Installed" +msgstr "Installé" + +#, python-format +msgid "Deprecated: %s" +msgstr "Obsolète : %s" + +#, python-format +msgid "Installed: %s" +msgstr "Installé : %s" + +#, python-format +msgid "Update to %s" +msgstr "Mettre à jour vers %s" + +msgid "Install" +msgstr "Installer" + +#, python-format +msgid "Plugins listed in the DataLab plugin catalog. Official plugins are maintained by the DataLab team, community plugins by their authors: install only plugins from authors you trust." +msgstr "Plugins répertoriés dans le catalogue de plugins DataLab. Les plugins officiels sont maintenus par l'équipe DataLab, les plugins de la communauté par leurs auteurs : n'installez que des plugins provenant d'auteurs de confiance." + +msgid "Search plugins" +msgstr "Rechercher des plugins" + +msgid "No plugin catalog is configured." +msgstr "Aucun catalogue de plugins n'est configuré." + +msgid "Loading the plugin catalog..." +msgstr "Chargement du catalogue de plugins..." + +#, python-format +msgid "Downloading %s..." +msgstr "Téléchargement de %s..." + +#, python-format +msgid "Plugins in the catalog: %d" +msgstr "Plugins dans le catalogue : %d" + +msgid "The plugin catalog cannot be loaded:" +msgstr "Le catalogue de plugins ne peut pas être chargé :" + +#, python-format +msgid "DataLab plugin catalog (%s)" +msgstr "Catalogue de plugins DataLab (%s)" + +msgid "Install plugin" +msgstr "Installer un plugin" + +msgid "Input/output" +msgstr "Entrée/sortie" + +msgid "Application" +msgstr "Application" + +msgid "today" +msgstr "aujourd'hui" + +msgid "yesterday" +msgstr "hier" + +#, python-format +msgid "Last loaded: %s at %s" +msgstr "Dernier chargement : %s à %s" + +msgid "Active" +msgstr "Actif" + +msgid "Disabled" +msgstr "Désactivé" + +msgid "No description available" +msgstr "Aucune description disponible" + +msgid "Open file" +msgstr "Ouvrir le fichier" msgid "Show in folder" msgstr "Afficher dans le dossier" @@ -2052,6 +2410,12 @@ msgstr "Activer/désactiver les plugins" msgid "Plugin settings" msgstr "Paramètres des plugins" +msgid "Available plugins" +msgstr "Plugins disponibles" + +msgid "Install plugins" +msgstr "Installer des plugins" + msgid "Apply and reload plugins" msgstr "Appliquer et recharger les plugins" @@ -2125,73 +2489,302 @@ msgstr "Recharger les plugins" msgid "Plugin configuration has been saved. Do you want to reload plugins now to apply changes?" msgstr "La configuration des plugins a été enregistrée. Voulez-vous recharger les plugins maintenant pour appliquer les modifications ?" -msgid "Failed to deserialize processing parameters from JSON." -msgstr "Échec de la désérialisation des paramètres de traitement depuis le format JSON." +msgid "Source:" +msgstr "Source :" -msgid "DataLab relies on various libraries to perform the computation. During the computation, errors may occur because of the data (e.g. division by zero, unexpected data type, etc.) or because of the libraries (e.g. memory error, etc.). If you encounter an error, before reporting it, please ensure that the computation is correct, by checking the data and the parameters." -msgstr "DataLab utilise plusieurs bibliothèques pour réaliser les calculs. Lors des calculs, des erreurs peuvent survenir en raison de la nature des données (par exemple division par zéro, type de données inattendu, etc.) ou à cause de limitations ou d'erreurs dans les bibliothèques (par exemple erreur mémoire, etc.). Si vous rencontrez une erreur, avant de la signaler, assurez-vous que le calcul est correct, en vérifiant les données et les paramètres." +msgid "Plugin:" +msgstr "Plugin :" -msgid "X-array incompatibility" -msgstr "Incompatibilité des tableaux des X" +msgid "Version:" +msgstr "Version :" -msgid "" -"The selected signals have different X arrays.\n" -"\n" -"To perform the computation, signals need to be interpolated to match a common X array.\n" -"\n" -"Do you want to continue with automatic interpolation?" -msgstr "" -"Les signaux sélectionnés ont des tableaux des X différents.\n" -"\n" -"Pour effectuer le calcul, les signaux doivent être interpolés pour correspondre à un tableau des X commun.\n" -"\n" -"Souhaitez-vous continuer avec une interpolation automatique ?" +msgid "Description:" +msgstr "Description :" -msgid "Yes to All" -msgstr "Oui à tout" +msgid "Plugin classes:" +msgstr "Classes de plugin :" -msgid "Processing metadata is incomplete (missing source UUID)." -msgstr "Les métadonnées de traitement sont incomplètes (UUID source manquant)." +msgid "not applicable" +msgstr "non applicable" -msgid "Signal was reprocessed." -msgstr "Le signal a été retraité." +msgid "Dependencies:" +msgstr "Dépendances :" -msgid "Image was reprocessed." -msgstr "L'image a été retraitée." +msgid "Plugin module:" +msgstr "Module de plugin :" -msgid "Recomputing..." -msgstr "Retraitement en cours..." +msgid "File:" +msgstr "Fichier :" -msgid "Processing object with updated parameters..." -msgstr "Traitement de l'objet avec les paramètres mis à jour..." +msgid "Size:" +msgstr "Taille :" -msgid "Recomputing" -msgstr "Retraitement" +msgid "SHA-256:" +msgstr "SHA-256 :" + +msgid "A plugin runs with the same rights as DataLab: it may read and modify your files. Install only plugins from authors you trust." +msgstr "Un plugin s'exécute avec les mêmes droits que DataLab : il peut lire et modifier vos fichiers. N'installez que des plugins provenant d'auteurs de confiance." #, python-format -msgid "Computing: %s" -msgstr "Calcul en cours : %s" +msgid "Installed on %s from %s" +msgstr "Installé le %s depuis %s" -msgid "In pairwise mode, you need to select objects in at least two groups." -msgstr "En mode 'pairwise', vous devez sélectionner des objets dans au moins deux groupes." +msgid "Uninstall" +msgstr "Désinstaller" -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 "Install a plugin shared as a wheel file (.whl) or as a Python module (datalab_*.py). Wheels must be pure Python and rely only on packages already provided by DataLab." +msgstr "Installez un plugin partagé sous forme de fichier wheel (.whl) ou de module Python (datalab_*.py). Les wheels doivent être en Python pur et ne dépendre que de paquets déjà fournis par DataLab." -#, python-format -msgid "Calculating: %s" -msgstr "Calcul : %s" +msgid "Install from file..." +msgstr "Installer depuis un fichier..." -#, python-format -msgid "Select %s" -msgstr "Sélectionner %s" +msgid "Plugins installed from a file" +msgstr "Plugins installés depuis un fichier" -#, python-format -msgid "Note: operation mode is pairwise: %s object(s) expected (i.e. as many as in the first group)" -msgstr "Note : le mode d'opération est pairwise : %s objet(s) attendu(s) (c'est-à-dire autant que dans le premier groupe)" +msgid "No plugin has been installed from a file." +msgstr "Aucun plugin n'a été installé depuis un fichier." -msgid "Note: operation mode is single operand: 1 object expected" -msgstr "Note : le mode d'opération est single : 1 objet attendu" +msgid "Install plugin from file" +msgstr "Installer un plugin depuis un fichier" + +msgid "DataLab plugins" +msgstr "Plugins DataLab" + +msgid "This file cannot be installed as a plugin:" +msgstr "Ce fichier ne peut pas être installé comme plugin :" + +#, python-format +msgid "%s will be loaded at the next start of DataLab." +msgstr "%s sera chargé au prochain démarrage de DataLab." + +#, python-format +msgid "%s has been installed." +msgstr "%s a été installé." + +msgid "Uninstall plugin" +msgstr "Désinstaller le plugin" + +#, python-format +msgid "Do you want to uninstall %s?" +msgstr "Voulez-vous désinstaller %s ?" + +#, python-format +msgid "%s has been uninstalled." +msgstr "%s a été désinstallé." + +msgid "Do you want to reload plugins now?" +msgstr "Voulez-vous recharger les plugins maintenant ?" + +msgid "Live" +msgstr "En direct" + +msgid "Refresh the view continuously" +msgstr "Rafraîchir la vue en continu" + +msgid "Acquire" +msgstr "Acquérir" + +msgid "Add an acquisition to the workspace, in a new group" +msgstr "Ajouter une acquisition à l'espace de travail, dans un nouveau groupe" + +msgid "Invalid settings" +msgstr "Réglages non valides" + +#, python-format +msgid "%d objects added to group '%s'" +msgstr "%d objets ajoutés au groupe '%s'" + +msgid "One signal" +msgstr "Un signal" + +msgid "One image" +msgstr "Une image" + +#, python-format +msgid "At least %d signals" +msgstr "Au moins %d signaux" + +#, python-format +msgid "At least %d images" +msgstr "Au moins %d images" + +msgid "One or more signals" +msgstr "Un ou plusieurs signaux" + +msgid "One or more images" +msgstr "Une ou plusieurs images" + +#, python-format +msgid "%s (optional)" +msgstr "%s (facultatif)" + +msgid "required metadata" +msgstr "métadonnée requise" + +msgid "hint" +msgstr "indicative" + +msgid "No input data" +msgstr "Aucune donnée d'entrée" + +#, python-format +msgid "%(slot)s: no object assigned" +msgstr "%(slot)s : aucun objet affecté" + +#, python-format +msgid "%(slot)s: choose the objects to use" +msgstr "%(slot)s : choisir les objets à utiliser" + +#, python-format +msgid "%(slot)s: %(count)d object(s) assigned, at least %(min_count)d required" +msgstr "%(slot)s : %(count)d objet(s) affecté(s), au moins %(min_count)d requis" + +#, python-format +msgid "%(slot)s: accepts only one object (%(count)d assigned)" +msgstr "%(slot)s : un seul objet accepté (%(count)d affectés)" + +#, python-format +msgid "%(slot)s: %(count)d object(s) of the wrong type (%(titles)s)" +msgstr "%(slot)s : %(count)d objet(s) de type incorrect (%(titles)s)" + +#, python-format +msgid "%(slot)s: metadata '%(key)s' missing on %(count)d object(s) (%(titles)s)" +msgstr "%(slot)s : métadonnée '%(key)s' absente de %(count)d objet(s) (%(titles)s)" + +#, python-format +msgid "%(slot)s: %(count)d object(s) assigned twice (%(titles)s)" +msgstr "%(slot)s : %(count)d objet(s) affecté(s) deux fois (%(titles)s)" + +msgid "Information" +msgstr "Information" + +msgid "Ready to run on the current selection" +msgstr "Exécutable sur la sélection courante" + +msgid "Ready to run, with warnings" +msgstr "Exécutable, avec des avertissements" + +msgid "Ready to run once the inputs are assigned" +msgstr "Exécutable une fois les entrées affectées" + +msgid "The current selection cannot be analyzed" +msgstr "La sélection courante ne peut pas être analysée" + +msgid "Select the input data in the workspace" +msgstr "Sélectionner les données d'entrée dans l'espace de travail" + +msgid "To set it, use Edit > Metadata > Add metadata..." +msgstr "Pour la définir, utilisez Édition > Métadonnées > Ajouter des métadonnées..." + +#, python-format +msgid "Inputs of '%s'" +msgstr "Entrées de '%s'" + +#, python-format +msgid "(missing: %s)" +msgstr "(manquant : %s)" + +msgid "Not assigned" +msgstr "Non assigné" + +msgid "No compatible object" +msgstr "Aucun objet compatible" + +#, python-format +msgid "No selected object can be used by '%s'." +msgstr "Aucun objet sélectionné n'est utilisable par '%s'." + +msgid "Expected inputs:" +msgstr "Entrées attendues :" + +#, python-format +msgid "Running '%s'" +msgstr "Exécution de '%s'" + +msgid "Failed to deserialize processing parameters from JSON." +msgstr "Échec de la désérialisation des paramètres de traitement depuis le format JSON." + +msgid "DataLab relies on various libraries to perform the computation. During the computation, errors may occur because of the data (e.g. division by zero, unexpected data type, etc.) or because of the libraries (e.g. memory error, etc.). If you encounter an error, before reporting it, please ensure that the computation is correct, by checking the data and the parameters." +msgstr "DataLab utilise plusieurs bibliothèques pour réaliser les calculs. Lors des calculs, des erreurs peuvent survenir en raison de la nature des données (par exemple division par zéro, type de données inattendu, etc.) ou à cause de limitations ou d'erreurs dans les bibliothèques (par exemple erreur mémoire, etc.). Si vous rencontrez une erreur, avant de la signaler, assurez-vous que le calcul est correct, en vérifiant les données et les paramètres." + +msgid "X-array incompatibility" +msgstr "Incompatibilité des tableaux des X" + +msgid "" +"The selected signals have different X arrays.\n" +"\n" +"To perform the computation, signals need to be interpolated to match a common X array.\n" +"\n" +"Do you want to continue with automatic interpolation?" +msgstr "" +"Les signaux sélectionnés ont des tableaux des X différents.\n" +"\n" +"Pour effectuer le calcul, les signaux doivent être interpolés pour correspondre à un tableau des X commun.\n" +"\n" +"Souhaitez-vous continuer avec une interpolation automatique ?" + +msgid "Yes to All" +msgstr "Oui à tout" + +msgid "Processing metadata is incomplete (missing source UUID)." +msgstr "Les métadonnées de traitement sont incomplètes (UUID source manquant)." + +#, python-format +msgid "" +"Failed to reprocess object:\n" +"%s" +msgstr "" +"Échec du retraitement de l'objet :\n" +"%s" + +msgid "Processing was cancelled." +msgstr "Le traitement a été annulé." + +msgid "Failed to reprocess object." +msgstr "Échec du retraitement de l'objet." + +msgid "Signal was reprocessed." +msgstr "Le signal a été retraité." + +msgid "Image was reprocessed." +msgstr "L'image a été retraitée." + +msgid "Recomputing..." +msgstr "Retraitement en cours..." + +msgid "Processing object with updated parameters..." +msgstr "Traitement de l'objet avec les paramètres mis à jour..." + +msgid "Recomputing" +msgstr "Retraitement" + +#, python-format +msgid "Computing: %s" +msgstr "Calcul en cours : %s" + +msgid "In pairwise mode, you need to select objects in at least two groups." +msgstr "En mode 'pairwise', vous devez sélectionner des objets dans au moins deux groupes." + +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" + +#, python-format +msgid "Select %s" +msgstr "Sélectionner %s" + +#, python-format +msgid "Note: operation mode is pairwise: %s object(s) expected (i.e. as many as in the first group)" +msgstr "Note : le mode d'opération est pairwise : %s objet(s) attendu(s) (c'est-à-dire autant que dans le premier groupe)" + +msgid "Note: operation mode is single operand: 1 object expected" +msgstr "Note : le mode d'opération est single : 1 objet attendu" msgid "Regions of Interest" msgstr "Régions d'intérêt" @@ -2358,9 +2951,6 @@ msgstr "" " • y' = a0 + a1*y + a2*y^2 + ...\n" " • z' = a0 + a1*z + a2*z^2 + ..." -msgid "Swap X/Y axes" -msgstr "Permuter les axes X/Y" - msgid "Normalize" msgstr "Normaliser" @@ -2460,6 +3050,9 @@ msgstr "Seuillage Triangle" msgid "Yen thresholding" msgstr "Seuillage Yen" +msgid "Brightness and contrast" +msgstr "Luminosité et contraste" + msgid "Gamma correction" msgstr "Correction gamma" @@ -2751,6 +3344,9 @@ msgstr "" " • x' = ax + b\n" " • y' = ay + b" +msgid "Swap X/Y axes" +msgstr "Permuter les axes X/Y" + msgid "Reverse X-axis" msgstr "Inverser l'axe des X" @@ -2993,11 +3589,23 @@ msgstr "Thème de couleur" msgid "Color mode for the application" msgstr "Thème de couleur pour l'application" -msgid "Process isolation" -msgstr "Isolation de processus" +msgid "Show welcome page when the current panel is empty" +msgstr "Afficher la page d'accueil lorsque le panneau courant est vide" -msgid "With process isolation, each computation is run in a separate process,
which prevents the application from freezing during long computations." -msgstr "Avec l'isolation de processus, chaque calcul est exécuté dans un processus séparé,
ce qui évite que l'application ne se fige pendant les calculs longs." +msgid "The welcome page gathers the main actions to get started (create or open data, guided tour, documentation...). It is shown at startup and whenever the current signal or image panel is empty." +msgstr "La page d'accueil rassemble les principales actions pour bien démarrer (créer ou ouvrir des données, visite guidée, documentation...). Elle est affichée au démarrage et chaque fois que le panneau signal ou image courant est vide." + +msgid "Welcome page application rows" +msgstr "Lignes d'applications de la page d'accueil" + +msgid "Maximum number of application tile rows on the welcome page. Other applications remain available from the Applications catalog." +msgstr "Nombre maximal de lignes de tuiles d'applications sur la page d'accueil. Les autres applications restent accessibles depuis le catalogue des applications." + +msgid "Process isolation" +msgstr "Isolation de processus" + +msgid "With process isolation, each computation is run in a separate process,
which prevents the application from freezing during long computations." +msgstr "Avec l'isolation de processus, chaque calcul est exécuté dans un processus séparé,
ce qui évite que l'application ne se fige pendant les calculs longs." msgid "RPC server" msgstr "Serveur XML-RPC" @@ -3076,6 +3684,18 @@ msgstr "Mode d'opération" msgid "Operation mode for computations taking N inputs:
  • single: single operand mode
  • pairwise: pairwise operation mode

Computations taking N inputs are the ones where:
  • N(>=2) objects in %s 1 object out
  • N(>=1) objects + 1 object in %s N objects out
" msgstr "Mode d'opération pour les calculs prenant N entrées:
  • single: mode d'opération avec un seul opérande
  • pairwise: mode d'opération par paire

Les calculs prenant N entrées sont ceux où :
  • N(>=2) objets en %s 1 objet en sortie
  • N(>=1) objets + 1 objet en %s N objets en sortie
" +msgid "References in result titles" +msgstr "Références dans les titres de résultats" + +msgid "Source UUID" +msgstr "UUID source" + +msgid "Source title" +msgstr "Titre de la source" + +msgid "Display source references in generated titles as short UUIDs or as the current source titles. This setting does not change the stored UUID references." +msgstr "Afficher les références aux sources dans les titres générés sous forme d'UUID courts ou avec les titres actuels des sources. Ce réglage ne modifie pas les références UUID enregistrées." + msgid "Use current signal bounds" msgstr "Utiliser les bornes du signal courant" @@ -3534,15 +4154,41 @@ msgstr "C'est la fin de la visite guidée !" msgid "You can show the tour again, or close this dialog box." msgstr "Vous pouvez afficher la visite guidée à nouveau, ou fermer cette boîte de dialogue." -msgid "Welcome" -msgstr "Accueil" +msgid "More actions" +msgstr "Plus d'actions" -msgid "Show welcome page when the current panel is empty" -msgstr "Afficher la page d'accueil lorsque le panneau courant est vide" +#, python-format +msgid "More applications (%d)" +msgstr "Autres applications (%d)" + +msgid "Browse all applications in the catalog." +msgstr "Parcourir toutes les applications du catalogue." + +msgid "Browse all applications..." +msgstr "Parcourir toutes les applications..." + +msgid "Applications" +msgstr "Applications" + +msgid "Pin to the top" +msgstr "Épingler en tête" + +msgid "Unpin" +msgstr "Désépingler" + +msgid "Hide from welcome page" +msgstr "Masquer de la page d'accueil" + +#, python-format +msgid "Launching '%s'" +msgstr "Lancement de '%s'" #, python-format -msgid "Version %s" -msgstr "Version %s" +msgid "Scientific signal and image processing — version %s" +msgstr "Traitement scientifique du signal et de l'image — version %s" + +msgid "Desktop edition of DataLab" +msgstr "Édition Desktop de DataLab" msgid "Create..." msgstr "Créer..." @@ -3650,14 +4296,38 @@ msgstr "Aucun plugin disponible" msgid "Plugins are disabled (see DataLab settings)" msgstr "Les plugins sont désactivés (voir les préférences de DataLab)" -msgid "Warning" -msgstr "Attention" +msgid "Select one signal" +msgstr "Sélectionnez un signal" -msgid "Error" -msgstr "Erreur" +msgid "Select one image" +msgstr "Sélectionnez une image" -msgid "Information" -msgstr "Information" +msgid "Select one object" +msgstr "Sélectionnez un objet" + +msgid "Select at least one signal" +msgstr "Sélectionnez au moins un signal" + +msgid "Select at least one image" +msgstr "Sélectionnez au moins une image" + +msgid "Select at least one object" +msgstr "Sélectionnez au moins un objet" + +msgid "Select at least two signals" +msgstr "Sélectionnez au moins deux signaux" + +msgid "Select at least two images" +msgstr "Sélectionnez au moins deux images" + +msgid "Select at least two objects" +msgstr "Sélectionnez au moins deux objets" + +msgid "Opening this example replaces the current workspace. Continue?" +msgstr "L'ouverture de cet exemple remplace l'espace de travail actuel. Continuer ?" + +msgid "Open example" +msgstr "Ouvrir l'exemple" msgid "Question" msgstr "Question" @@ -3707,6 +4377,103 @@ msgstr "Créer une image avec un anneau" msgid "Create image with a grid of gaussian spots" msgstr "Créer une image avec une grille de spots gaussiens" +msgid "Plugin name must be a non-empty single line" +msgstr "Le nom du plugin doit être une ligne non vide" + +msgid "Package name must start with a lowercase letter and contain only lowercase letters, digits, and underscores" +msgstr "Le nom du package doit commencer par une lettre minuscule et ne contenir que des lettres minuscules, des chiffres et des tirets bas" + +msgid "Package name must not be a Python keyword" +msgstr "Le nom du package ne doit pas être un mot-clé Python" + +msgid "Plugin ID must be a lowercase dotted identifier such as 'org.example.my-plugin'" +msgstr "L'ID du plugin doit être un identifiant en minuscules séparé par des points, comme 'org.example.mon-plugin'" + +msgid "Description must be a single line" +msgstr "La description doit tenir sur une seule ligne" + +msgid "Plugin capabilities are invalid" +msgstr "Les capacités du plugin ne sont pas valides" + +msgid "Plugin capabilities must be unique" +msgstr "Les capacités du plugin doivent être uniques" + +msgid "Object kind must be 'signal' or 'image'" +msgstr "Le type d'objet doit être 'signal' ou 'image'" + +msgid "The minimal template currently supports BSD-3-Clause" +msgstr "Le modèle minimal prend actuellement en charge BSD-3-Clause" + +msgid "GitHub account must be a GitHub user or organization" +msgstr "Le compte GitHub doit être un utilisateur ou une organisation GitHub" + +msgid "Plugin name must contain letters or digits" +msgstr "Le nom du plugin doit contenir des lettres ou des chiffres" + +#, python-format +msgid "Destination already exists: %s" +msgstr "La destination existe déjà : %s" + +#, python-format +msgid "%s is required" +msgstr "%s est requis" + +msgid "Plugin name" +msgstr "Nom du plugin" + +msgid "GitHub account (optional)" +msgstr "Compte GitHub (facultatif)" + +msgid "Package name" +msgstr "Nom du package" + +msgid "Plugin ID" +msgstr "ID du plugin" + +#, python-format +msgid "%s plugin for DataLab" +msgstr "Plugin %s pour DataLab" + +msgid "Description" +msgstr "Description" + +msgid "Destination" +msgstr "Destination" + +msgid "Create and maintain DataLab plugin projects." +msgstr "Créer et maintenir des projets de plugins DataLab." + +msgid "Create a layered installable plugin project." +msgstr "Créer un projet de plugin installable structuré en couches." + +msgid "Plugin display name" +msgstr "Nom d'affichage du plugin" + +msgid "Python import package name" +msgstr "Nom du package d'import Python" + +msgid "Stable reverse-domain ID" +msgstr "ID stable en notation de domaine inversé" + +msgid "One-line description" +msgstr "Description sur une ligne" + +msgid "GitHub user or organization hosting the project" +msgstr "Utilisateur ou organisation GitHub hébergeant le projet" + +msgid "Plugin capability; may be repeated" +msgstr "Capacité du plugin ; l'option peut être répétée" + +msgid "Object type used by the sample processing" +msgstr "Type d'objet utilisé par le traitement d'exemple" + +msgid "Project license" +msgstr "Licence du projet" + +#, python-format +msgid "Created DataLab plugin project at %s" +msgstr "Projet de plugin DataLab créé dans %s" + msgid "Host application" msgstr "Application hôte" @@ -3894,15 +4661,12 @@ msgstr "Afficher les détails" msgid "Hide details" msgstr "Masquer les détails" -msgid "Title" -msgstr "Titre" - -msgid "Description" -msgstr "Description" - msgid "Date and time" msgstr "Date et heure" +msgid "Title" +msgstr "Titre" + msgid "Action is compatible with the current workspace state." msgstr "L'action est compatible avec l'état actuel de l'espace de travail." @@ -3990,12 +4754,12 @@ msgstr "Cliquer pour voir les infos de connexion" msgid "Source" msgstr "Source" -msgid "File" -msgstr "Fichier" - msgid "Clipboard" msgstr "Presse-papiers" +msgid "File" +msgstr "Fichier" + msgid "Source of the data" msgstr "Source des données" @@ -4017,12 +4781,6 @@ msgstr "Sélectionner la source des données :" msgid "Source Parameters" msgstr "Paramètres de la source" -msgid "Delimiter" -msgstr "Délimiteur" - -msgid "Tab" -msgstr "Tabulation" - msgid "Space" msgstr "Espace" @@ -4035,9 +4793,6 @@ msgstr "Décimal" msgid "Point" msgstr "Point" -msgid "Comma" -msgstr "Virgule" - msgid "Decimal separator character" msgstr "Caractère séparateur décimal" @@ -4062,9 +4817,6 @@ msgstr "Nombre maximum de lignes à importer" msgid "Header" msgstr "En-tête" -msgid "None" -msgstr "Aucun" - msgid "Infer" msgstr "Inférer" @@ -4184,640 +4936,29 @@ msgstr "Forme" msgid "Dimensions" msgstr "Dimensions" -#, python-format -msgid "Opening tool '%s'" -msgstr "Ouverture de l'outil '%s'" +msgid "Minimum value" +msgstr "Valeur minimum" -msgid "Browse application plugins, recipes and examples" -msgstr "Parcourir les plugins d'application, les méthodes et les exemples" +msgid "Maximum value" +msgstr "Valeur maximum" -msgid "Welcome page" -msgstr "Page d'accueil" +msgid "Step between levels" +msgstr "Pas entre les niveaux" -#, python-format -msgid "e.g. %s" -msgstr "ex. %s" +msgid "Overlay image" +msgstr "Image de fond" -msgid "Add a new metadata item to the selected objects.

The metadata key will be the same for all objects, but the value can use a pattern to generate different values, optionally extracted with a regular expression.
Click the Help button for details on the pattern syntax.
" -msgstr "Ajouter un nouvel élément de métadonnée aux objets sélectionnés.

La clé de métadonnée sera la même pour tous les objets, mais la valeur peut utiliser un modèle pour générer des valeurs différentes, éventuellement extraites par une expression régulière.
Cliquez sur le bouton Aide pour plus de détails sur la syntaxe du modèle.
" +msgid "Show level labels" +msgstr "Afficher les étiquettes de niveaux" -msgid "Select a key..." -msgstr "Choisir une clé..." +msgid "Contour isoline plot" +msgstr "Tracé de lignes de contour" -msgid "unchanged" -msgstr "inchangé" +msgid "Display isolines (contour lines) overlaid on the selected image, with configurable level range, step, and optional value labels" +msgstr "Afficher les isolignes (lignes de contour) superposées à l'image sélectionnée, avec une plage de niveaux configurable, un pas entre les niveaux, et des étiquettes de valeurs optionnelles" -msgid "Known keys" -msgstr "Clés connues" - -msgid "Copy a key found on the selected objects into the metadata key" -msgstr "Copier dans la clé de métadonnée une clé présente sur les objets sélectionnés" - -msgid "Extraction pattern" -msgstr "Motif d'extraction" - -msgid "Optional regular expression searched in the formatted value: its first group, or the whole match, becomes the value" -msgstr "Expression régulière facultative recherchée dans la valeur formatée : son premier groupe, ou toute la correspondance, devient la valeur" - -msgid "If no match" -msgstr "Sans correspondance" - -msgid "Leave the object unchanged" -msgstr "Laisser l'objet inchangé" - -msgid "Report an error" -msgstr "Signaler une erreur" - -msgid "Scale factor" -msgstr "Facteur d'échelle" - -msgid "Multiplies numeric values, e.g. 0.001 to convert ms to s" -msgstr "Multiplie les valeurs numériques, par ex. 0.001 pour convertir des ms en s" - -#, python-format -msgid "Version: %s" -msgstr "Version : %s" - -msgid "Expected inputs" -msgstr "Entrées attendues" - -msgid "Run on selection..." -msgstr "Exécuter sur la sélection..." - -msgid "Examples designed for this method" -msgstr "Exemples conçus pour cette méthode" - -msgid "Try with this example" -msgstr "Essayer avec cet exemple" - -msgid "The current selection could not be assessed" -msgstr "La sélection courante n'a pas pu être évaluée" - -msgid "Show on welcome page" -msgstr "Afficher sur la page d'accueil" - -msgid "Pin to the welcome page" -msgstr "Épingler sur la page d'accueil" - -msgid "Documentation" -msgstr "Documentation" - -#, python-format -msgid "Plugin ID: %s" -msgstr "ID du plugin : %s" - -msgid "No methods declared" -msgstr "Aucune méthode déclarée" - -msgid "Open tool" -msgstr "Ouvrir l'outil" - -#, python-format -msgid "Tools (%d)" -msgstr "Outils (%d)" - -msgid "Open dataset" -msgstr "Ouvrir le jeu de données" - -#, python-format -msgid "Datasets (%d)" -msgstr "Jeux de données (%d)" - -#, python-format -msgid "Created %d objects" -msgstr "%d objets créés" - -msgid "Search applications..." -msgstr "Rechercher des applications..." - -msgid "Hide the application list" -msgstr "Masquer la liste des applications" - -msgid "Show the application list" -msgstr "Afficher la liste des applications" - -msgid "No application plugins are currently loaded." -msgstr "Aucun plugin d'application n'est actuellement chargé." - -#, python-format -msgid "Starting analysis '%s'" -msgstr "Lancement de l'analyse '%s'" - -#, python-format -msgid "Trying example '%s'" -msgstr "Essai de l'exemple '%s'" - -#, python-format -msgid "Opening example '%s'" -msgstr "Ouverture de l'exemple '%s'" - -msgid "Official" -msgstr "Officiel" - -msgid "Community" -msgstr "Communauté" - -msgid "Source code" -msgstr "Code source" - -#, python-format -msgid "Withdrawn: %s" -msgstr "Retiré : %s" - -#, python-format -msgid "Not available for Python %s" -msgstr "Non disponible pour Python %s" - -msgid "Not available for DataLab desktop" -msgstr "Non disponible pour DataLab desktop" - -#, python-format -msgid "Installed in the Python environment (%s)" -msgstr "Installé dans l'environnement Python (%s)" - -msgid "Installed" -msgstr "Installé" - -#, python-format -msgid "Deprecated: %s" -msgstr "Obsolète : %s" - -#, python-format -msgid "Installed: %s" -msgstr "Installé : %s" - -#, python-format -msgid "Update to %s" -msgstr "Mettre à jour vers %s" - -msgid "Install" -msgstr "Installer" - -#, python-format -msgid "Plugins listed in the DataLab plugin catalog. Official plugins are maintained by the DataLab team, community plugins by their authors: install only plugins from authors you trust." -msgstr "Plugins répertoriés dans le catalogue de plugins DataLab. Les plugins officiels sont maintenus par l'équipe DataLab, les plugins de la communauté par leurs auteurs : n'installez que des plugins provenant d'auteurs de confiance." - -msgid "Search plugins" -msgstr "Rechercher des plugins" - -msgid "No plugin catalog is configured." -msgstr "Aucun catalogue de plugins n'est configuré." - -msgid "Loading the plugin catalog..." -msgstr "Chargement du catalogue de plugins..." - -#, python-format -msgid "Downloading %s..." -msgstr "Téléchargement de %s..." - -#, python-format -msgid "Plugins in the catalog: %d" -msgstr "Plugins dans le catalogue : %d" - -msgid "The plugin catalog cannot be loaded:" -msgstr "Le catalogue de plugins ne peut pas être chargé :" - -#, python-format -msgid "DataLab plugin catalog (%s)" -msgstr "Catalogue de plugins DataLab (%s)" - -msgid "Install plugin" -msgstr "Installer un plugin" - -msgid "Input/output" -msgstr "Entrée/sortie" - -msgid "Application" -msgstr "Application" - -msgid "Available plugins" -msgstr "Plugins disponibles" - -msgid "Install plugins" -msgstr "Installer des plugins" - -msgid "Source:" -msgstr "Source :" - -msgid "Plugin:" -msgstr "Plugin :" - -msgid "Version:" -msgstr "Version :" - -msgid "Description:" -msgstr "Description :" - -msgid "Plugin classes:" -msgstr "Classes de plugin :" - -msgid "not applicable" -msgstr "non applicable" - -msgid "Dependencies:" -msgstr "Dépendances :" - -msgid "Plugin module:" -msgstr "Module de plugin :" - -msgid "File:" -msgstr "Fichier :" - -msgid "Size:" -msgstr "Taille :" - -msgid "SHA-256:" -msgstr "SHA-256 :" - -msgid "A plugin runs with the same rights as DataLab: it may read and modify your files. Install only plugins from authors you trust." -msgstr "Un plugin s'exécute avec les mêmes droits que DataLab : il peut lire et modifier vos fichiers. N'installez que des plugins provenant d'auteurs de confiance." - -#, python-format -msgid "Installed on %s from %s" -msgstr "Installé le %s depuis %s" - -msgid "Uninstall" -msgstr "Désinstaller" - -msgid "Install a plugin shared as a wheel file (.whl) or as a Python module (datalab_*.py). Wheels must be pure Python and rely only on packages already provided by DataLab." -msgstr "Installez un plugin partagé sous forme de fichier wheel (.whl) ou de module Python (datalab_*.py). Les wheels doivent être en Python pur et ne dépendre que de paquets déjà fournis par DataLab." - -msgid "Install from file..." -msgstr "Installer depuis un fichier..." - -msgid "Plugins installed from a file" -msgstr "Plugins installés depuis un fichier" - -msgid "No plugin has been installed from a file." -msgstr "Aucun plugin n'a été installé depuis un fichier." - -msgid "Install plugin from file" -msgstr "Installer un plugin depuis un fichier" - -msgid "DataLab plugins" -msgstr "Plugins DataLab" - -msgid "This file cannot be installed as a plugin:" -msgstr "Ce fichier ne peut pas être installé comme plugin :" - -#, python-format -msgid "%s will be loaded at the next start of DataLab." -msgstr "%s sera chargé au prochain démarrage de DataLab." - -#, python-format -msgid "%s has been installed." -msgstr "%s a été installé." - -msgid "Uninstall plugin" -msgstr "Désinstaller le plugin" - -#, python-format -msgid "Do you want to uninstall %s?" -msgstr "Voulez-vous désinstaller %s ?" - -#, python-format -msgid "%s has been uninstalled." -msgstr "%s a été désinstallé." - -msgid "Do you want to reload plugins now?" -msgstr "Voulez-vous recharger les plugins maintenant ?" - -msgid "Live" -msgstr "En direct" - -msgid "Refresh the view continuously" -msgstr "Rafraîchir la vue en continu" - -msgid "Acquire" -msgstr "Acquérir" - -msgid "Add an acquisition to the workspace, in a new group" -msgstr "Ajouter une acquisition à l'espace de travail, dans un nouveau groupe" - -msgid "Invalid settings" -msgstr "Réglages non valides" - -#, python-format -msgid "%d objects added to group '%s'" -msgstr "%d objets ajoutés au groupe '%s'" - -msgid "One signal" -msgstr "Un signal" - -msgid "One image" -msgstr "Une image" - -#, python-format -msgid "At least %d signals" -msgstr "Au moins %d signaux" - -#, python-format -msgid "At least %d images" -msgstr "Au moins %d images" - -msgid "One or more signals" -msgstr "Un ou plusieurs signaux" - -msgid "One or more images" -msgstr "Une ou plusieurs images" - -#, python-format -msgid "%s (optional)" -msgstr "%s (facultatif)" - -msgid "required metadata" -msgstr "métadonnée requise" - -msgid "hint" -msgstr "indicative" - -msgid "No input data" -msgstr "Aucune donnée d'entrée" - -#, python-format -msgid "%(slot)s: no object assigned" -msgstr "%(slot)s : aucun objet affecté" - -#, python-format -msgid "%(slot)s: choose the objects to use" -msgstr "%(slot)s : choisir les objets à utiliser" - -#, python-format -msgid "%(slot)s: %(count)d object(s) assigned, at least %(min_count)d required" -msgstr "%(slot)s : %(count)d objet(s) affecté(s), au moins %(min_count)d requis" - -#, python-format -msgid "%(slot)s: accepts only one object (%(count)d assigned)" -msgstr "%(slot)s : un seul objet accepté (%(count)d affectés)" - -#, python-format -msgid "%(slot)s: %(count)d object(s) of the wrong type (%(titles)s)" -msgstr "%(slot)s : %(count)d objet(s) de type incorrect (%(titles)s)" - -#, python-format -msgid "%(slot)s: metadata '%(key)s' missing on %(count)d object(s) (%(titles)s)" -msgstr "%(slot)s : métadonnée '%(key)s' absente de %(count)d objet(s) (%(titles)s)" - -#, python-format -msgid "%(slot)s: %(count)d object(s) assigned twice (%(titles)s)" -msgstr "%(slot)s : %(count)d objet(s) affecté(s) deux fois (%(titles)s)" - -msgid "Ready to run on the current selection" -msgstr "Exécutable sur la sélection courante" - -msgid "Ready to run, with warnings" -msgstr "Exécutable, avec des avertissements" - -msgid "Ready to run once the inputs are assigned" -msgstr "Exécutable une fois les entrées affectées" - -msgid "The current selection cannot be analyzed" -msgstr "La sélection courante ne peut pas être analysée" - -msgid "Select the input data in the workspace" -msgstr "Sélectionner les données d'entrée dans l'espace de travail" - -msgid "To set it, use Edit > Metadata > Add metadata..." -msgstr "Pour la définir, utilisez Édition > Métadonnées > Ajouter des métadonnées..." - -#, python-format -msgid "Inputs of '%s'" -msgstr "Entrées de '%s'" - -#, python-format -msgid "(missing: %s)" -msgstr "(manquant : %s)" - -msgid "Not assigned" -msgstr "Non assigné" - -msgid "No compatible object" -msgstr "Aucun objet compatible" - -#, python-format -msgid "No selected object can be used by '%s'." -msgstr "Aucun objet sélectionné n'est utilisable par '%s'." - -msgid "Expected inputs:" -msgstr "Entrées attendues :" - -#, python-format -msgid "Running '%s'" -msgstr "Exécution de '%s'" - -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 "Brightness and contrast" -msgstr "Luminosité et contraste" - -msgid "The welcome page gathers the main actions to get started (create or open data, guided tour, documentation...). It is shown at startup and whenever the current signal or image panel is empty." -msgstr "La page d'accueil rassemble les principales actions pour bien démarrer (créer ou ouvrir des données, visite guidée, documentation...). Elle est affichée au démarrage et chaque fois que le panneau signal ou image courant est vide." - -msgid "Welcome page application rows" -msgstr "Lignes d'applications de la page d'accueil" - -msgid "Maximum number of application tile rows on the welcome page. Other applications remain available from the Applications catalog." -msgstr "Nombre maximal de lignes de tuiles d'applications sur la page d'accueil. Les autres applications restent accessibles depuis le catalogue des applications." - -msgid "References in result titles" -msgstr "Références dans les titres de résultats" - -msgid "Source UUID" -msgstr "UUID source" - -msgid "Source title" -msgstr "Titre de la source" - -msgid "Display source references in generated titles as short UUIDs or as the current source titles. This setting does not change the stored UUID references." -msgstr "Afficher les références aux sources dans les titres générés sous forme d'UUID courts ou avec les titres actuels des sources. Ce réglage ne modifie pas les références UUID enregistrées." - -msgid "More actions" -msgstr "Plus d'actions" - -#, python-format -msgid "More applications (%d)" -msgstr "Autres applications (%d)" - -msgid "Browse all applications in the catalog." -msgstr "Parcourir toutes les applications du catalogue." - -msgid "Browse all applications..." -msgstr "Parcourir toutes les applications..." - -msgid "Applications" -msgstr "Applications" - -msgid "Pin to the top" -msgstr "Épingler en tête" - -msgid "Unpin" -msgstr "Désépingler" - -msgid "Hide from welcome page" -msgstr "Masquer de la page d'accueil" - -#, python-format -msgid "Launching '%s'" -msgstr "Lancement de '%s'" - -#, python-format -msgid "Scientific signal and image processing — version %s" -msgstr "Traitement scientifique du signal et de l'image — version %s" - -msgid "Desktop edition of DataLab" -msgstr "Édition Desktop de DataLab" - -msgid "Select one signal" -msgstr "Sélectionnez un signal" - -msgid "Select one image" -msgstr "Sélectionnez une image" - -msgid "Select one object" -msgstr "Sélectionnez un objet" - -msgid "Select at least one signal" -msgstr "Sélectionnez au moins un signal" - -msgid "Select at least one image" -msgstr "Sélectionnez au moins une image" - -msgid "Select at least one object" -msgstr "Sélectionnez au moins un objet" - -msgid "Select at least two signals" -msgstr "Sélectionnez au moins deux signaux" - -msgid "Select at least two images" -msgstr "Sélectionnez au moins deux images" - -msgid "Select at least two objects" -msgstr "Sélectionnez au moins deux objets" - -msgid "Opening this example replaces the current workspace. Continue?" -msgstr "L'ouverture de cet exemple remplace l'espace de travail actuel. Continuer ?" - -msgid "Open example" -msgstr "Ouvrir l'exemple" - -msgid "Plugin name must be a non-empty single line" -msgstr "Le nom du plugin doit être une ligne non vide" - -msgid "Package name must start with a lowercase letter and contain only lowercase letters, digits, and underscores" -msgstr "Le nom du package doit commencer par une lettre minuscule et ne contenir que des lettres minuscules, des chiffres et des tirets bas" - -msgid "Package name must not be a Python keyword" -msgstr "Le nom du package ne doit pas être un mot-clé Python" - -msgid "Plugin ID must be a lowercase dotted identifier such as 'org.example.my-plugin'" -msgstr "L'ID du plugin doit être un identifiant en minuscules séparé par des points, comme 'org.example.mon-plugin'" - -msgid "Description must be a single line" -msgstr "La description doit tenir sur une seule ligne" - -msgid "Plugin capabilities are invalid" -msgstr "Les capacités du plugin ne sont pas valides" - -msgid "Plugin capabilities must be unique" -msgstr "Les capacités du plugin doivent être uniques" - -msgid "Object kind must be 'signal' or 'image'" -msgstr "Le type d'objet doit être 'signal' ou 'image'" - -msgid "The minimal template currently supports BSD-3-Clause" -msgstr "Le modèle minimal prend actuellement en charge BSD-3-Clause" - -msgid "GitHub account must be a GitHub user or organization" -msgstr "Le compte GitHub doit être un utilisateur ou une organisation GitHub" - -msgid "Plugin name must contain letters or digits" -msgstr "Le nom du plugin doit contenir des lettres ou des chiffres" - -#, python-format -msgid "Destination already exists: %s" -msgstr "La destination existe déjà : %s" - -#, python-format -msgid "%s is required" -msgstr "%s est requis" - -msgid "Plugin name" -msgstr "Nom du plugin" - -msgid "GitHub account (optional)" -msgstr "Compte GitHub (facultatif)" - -msgid "Package name" -msgstr "Nom du package" - -msgid "Plugin ID" -msgstr "ID du plugin" - -#, python-format -msgid "%s plugin for DataLab" -msgstr "Plugin %s pour DataLab" - -msgid "Destination" -msgstr "Destination" - -msgid "Create and maintain DataLab plugin projects." -msgstr "Créer et maintenir des projets de plugins DataLab." - -msgid "Create a layered installable plugin project." -msgstr "Créer un projet de plugin installable structuré en couches." - -msgid "Plugin display name" -msgstr "Nom d'affichage du plugin" - -msgid "Python import package name" -msgstr "Nom du package d'import Python" - -msgid "Stable reverse-domain ID" -msgstr "ID stable en notation de domaine inversé" - -msgid "One-line description" -msgstr "Description sur une ligne" - -msgid "GitHub user or organization hosting the project" -msgstr "Utilisateur ou organisation GitHub hébergeant le projet" - -msgid "Plugin capability; may be repeated" -msgstr "Capacité du plugin ; l'option peut être répétée" - -msgid "Object type used by the sample processing" -msgstr "Type d'objet utilisé par le traitement d'exemple" - -msgid "Project license" -msgstr "Licence du projet" - -#, python-format -msgid "Created DataLab plugin project at %s" -msgstr "Projet de plugin DataLab créé dans %s" - -msgid "Minimum value" -msgstr "Valeur minimum" - -msgid "Maximum value" -msgstr "Valeur maximum" - -msgid "Step between levels" -msgstr "Pas entre les niveaux" - -msgid "Overlay image" -msgstr "Image de fond" - -msgid "Show level labels" -msgstr "Afficher les étiquettes de niveaux" - -msgid "Contour isoline plot" -msgstr "Tracé de lignes de contour" - -msgid "Display isolines (contour lines) overlaid on the selected image, with configurable level range, step, and optional value labels" -msgstr "Afficher les isolignes (lignes de contour) superposées à l'image sélectionnée, avec une plage de niveaux configurable, un pas entre les niveaux, et des étiquettes de valeurs optionnelles" - -msgid "Annotation" -msgstr "Annotation" +msgid "Annotation" +msgstr "Annotation" msgid "Contour" msgstr "Contour" @@ -4843,144 +4984,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 "Automatic" -msgstr "Automatique" - -msgid "Image export" -msgstr "Exportation d'image" - -msgid "Target data type" -msgstr "Type de données cible" - -msgid "Compression level" -msgstr "Niveau de compression" - -msgid "Compression" -msgstr "Compression" - -msgid "MAT compression" -msgstr "Compression MAT" - -msgid "Quality" -msgstr "Qualité" - -msgid "Subsampling" -msgstr "Sous-échantillonnage" - -msgid "Progressive" -msgstr "Progressif" - -msgid "Optimize" -msgstr "Optimiser" - -msgid "Smoothing" -msgstr "Lissage" - -msgid "Quality mode" -msgstr "Mode de qualité" - -msgid "Quality layers" -msgstr "Couches de qualité" - -msgid "Irreversible" -msgstr "Irréversible" - -msgid "Progression order" -msgstr "Ordre de progression" - -msgid "Number of resolutions" -msgstr "Nombre de résolutions" - -msgid "Tile size" -msgstr "Taille des tuiles" - -msgid "PLT markers" -msgstr "Marqueurs PLT" - -msgid "Predictor" -msgstr "Prédicteur" - -msgid "Rows per strip" -msgstr "Lignes par bande" - -msgid "Resolution" -msgstr "Résolution" - -msgid "Resolution unit" -msgstr "Unité de résolution" - -msgid "Photometric interpretation" -msgstr "Interprétation photométrique" - -msgid "Precision" -msgstr "Précision" - -msgid "Rates" -msgstr "Taux" - -msgid "Deflate" -msgstr "Deflate" - -msgid "Zstandard" -msgstr "Zstandard" - -msgid "Whitespace" -msgstr "Espaces blancs" - -msgid "Semicolon" -msgstr "Point-virgule" - -msgid "Inch" -msgstr "Pouce" - -msgid "Centimeter" -msgstr "Centimètre" - -msgid "Horizontal" -msgstr "Horizontal" - -msgid "Floating-point" -msgstr "Virgule flottante" - -msgid "Black is zero" -msgstr "Le noir vaut zéro" - -msgid "White is zero" -msgstr "Le blanc vaut zéro" - -msgid "Gamma" -msgstr "Gamma" - -msgid "Invert" -msgstr "Inverser" - -#, python-format -msgid "%s may not be empty" -msgstr "%s ne peut pas être vide" - -#, python-format -msgid "%s must contain 2 values" -msgstr "%s doit contenir 2 valeurs" - -#, python-format -msgid "%s contains invalid numbers" -msgstr "%s contient des nombres non valides" - -msgid "Export options" -msgstr "Options d'exportation" - -msgid "Export image" -msgstr "Exporter l'image" - -msgid "Some required entries are incorrect." -msgstr "Certaines entrées obligatoires sont incorrectes." - -#, python-format -msgid "Skipping export: object %r no longer exists." -msgstr "Export ignoré : l'objet %r n'existe plus." - -msgid "RAW image import" -msgstr "Importation d'image RAW" - -msgid "Open all RAW files in the same folder" -msgstr "Ouvrir tous les fichiers RAW du même dossier" diff --git a/datalab/tests/aiassistant/test_tool_registry.py b/datalab/tests/aiassistant/test_tool_registry.py index c51bdbe7e..510aac770 100644 --- a/datalab/tests/aiassistant/test_tool_registry.py +++ b/datalab/tests/aiassistant/test_tool_registry.py @@ -5,8 +5,11 @@ from __future__ import annotations import json +from types import SimpleNamespace +from unittest.mock import Mock import pytest +from sigima.io import RawImageImportParam from datalab.aiassistant.tools.builtin import build_default_registry from datalab.aiassistant.tools.registry import Tool, ToolRegistry, ToolResult @@ -108,3 +111,70 @@ def test_default_registry_can_hide_macro_tool() -> None: assert "create_and_run_macro" not in names # Read-only macro inspection tool stays available (no code execution). assert "get_macro_console_output" in names + + +def _make_load_file_context() -> tuple[Mock, SimpleNamespace, Mock]: + """Return a proxy, a main window and its image panel for ``load_file``.""" + imagepanel = Mock(PANEL_STR_ID="image") + imagepanel.load_from_files.return_value = [] + mainwindow = SimpleNamespace(imagepanel=imagepanel, signalpanel=Mock()) + return Mock(), mainwindow, imagepanel + + +def test_load_file_forwards_raw_import_parameters() -> None: + """``load_file`` converts RAW import parameters into a DataSet.""" + reg = build_default_registry() + proxy, mainwindow, imagepanel = _make_load_file_context() + arguments = { + "filename": "image.raw", + "panel": "image", + "import_parameters": {"dtype": "uint8", "width": 4, "height": 3}, + } + + res = reg.call("load_file", arguments, proxy=proxy, mainwindow=mainwindow) + + assert res.ok is True + param = imagepanel.load_from_files.call_args.kwargs["param"] + assert isinstance(param, RawImageImportParam) + assert (param.dtype, param.width, param.height) == ("uint8", 4, 3) + + +def test_load_file_without_import_parameters_keeps_default_path() -> None: + """``load_file`` passes no import parameter when none is given.""" + reg = build_default_registry() + proxy, mainwindow, imagepanel = _make_load_file_context() + + res = reg.call( + "load_file", + {"filename": "image.png", "panel": "image"}, + proxy=proxy, + mainwindow=mainwindow, + ) + + assert res.ok is True + imagepanel.load_from_files.assert_called_once_with(["image.png"], param=None) + + +def test_load_file_rejects_unknown_import_parameter() -> None: + """``load_file`` reports invalid RAW import parameters as tool errors.""" + reg = build_default_registry() + proxy, mainwindow, imagepanel = _make_load_file_context() + + res = reg.call( + "load_file", + {"filename": "a.raw", "panel": "image", "import_parameters": {"nope": 1}}, + proxy=proxy, + mainwindow=mainwindow, + ) + + assert res.ok is False + assert "nope" in (res.error or "") + imagepanel.load_from_files.assert_not_called() + + +def test_load_file_schema_describes_raw_import_parameters() -> None: + """The ``load_file`` schema exposes RAW import parameter fields.""" + reg = build_default_registry() + schema = next(s for s in reg.list_schemas() if s["name"] == "load_file") + props = schema["parameters"]["properties"]["import_parameters"]["properties"] + assert set(props) == {item.get_name() for item in RawImageImportParam().get_items()} diff --git a/datalab/tests/features/common/history_model_unit_test.py b/datalab/tests/features/common/history_model_unit_test.py index c8e314493..8e28bb764 100644 --- a/datalab/tests/features/common/history_model_unit_test.py +++ b/datalab/tests/features/common/history_model_unit_test.py @@ -839,6 +839,30 @@ def test_action_copy_remaps_cross_panel_outputs() -> None: assert copied.output_uuids == ["new-signal-output"] +def test_action_copy_remaps_exported_object_uuids() -> None: + """Remap objects exported by ``save_to_files`` in the action panel.""" + action = HistoryAction( + title="Save to 2 different files", + target="imagepanel", + method_name="save_to_files", + kwargs={ + "filenames": ["first.png", "second.png"], + "object_uuids": ["first-uuid", "unmapped-uuid"], + }, + ) + + copied = action.copy_with_uuid_remap( + { + "image": {"first-uuid": "new-first"}, + "signal": {"unmapped-uuid": "other-panel-uuid"}, + } + ) + + assert copied.kwargs["object_uuids"] == ["new-first", "unmapped-uuid"] + assert copied.kwargs["filenames"] == ["first.png", "second.png"] + assert action.kwargs["object_uuids"] == ["first-uuid", "unmapped-uuid"] + + def _make_analysis_action(obj_uuid: str) -> HistoryAction: """Build a 1-to-0 compute action analysing ``obj_uuid``.""" return HistoryAction( diff --git a/datalab/tests/features/image/raw_import_app_test.py b/datalab/tests/features/image/raw_import_app_test.py index 760332bdd..e2c1efa77 100644 --- a/datalab/tests/features/image/raw_import_app_test.py +++ b/datalab/tests/features/image/raw_import_app_test.py @@ -4,12 +4,12 @@ from __future__ import annotations +from pathlib import Path from unittest.mock import patch import numpy as np from sigima.io import RawImageImportParam -from datalab.gui.panel.image import RawImageImportGUIParam from datalab.objectmodel import get_uuid from datalab.tests import datalab_test_app_context from datalab.tests.features.common.history_test_helpers import select_tree_entry @@ -26,7 +26,7 @@ def test_raw_import_replay_reloads_after_recorded_deletion(tmp_path) -> None: history, panel = win.historypanel, win.imagepanel history.toggle_record_mode(True) - with patch.object(RawImageImportGUIParam, "edit") as edit: + with patch.object(RawImageImportParam, "edit") as edit: objects = panel.load_from_files([str(filename)], import_params=[param]) assert len(objects) == 1 @@ -64,3 +64,49 @@ def test_raw_import_replay_reloads_after_recorded_deletion(tmp_path) -> None: ) recreated = panel.objmodel[replayed_output_uuid] np.testing.assert_array_equal(recreated.data, expected) + + +def test_raw_directory_import_replay_reuses_recorded_parameters(tmp_path) -> None: + """Replay a RAW directory import without reopening import dialogs.""" + first = np.array([[1, 2, 3], [4, 5, 6]], dtype=np.uint16) + second = np.array([[7, 8, 9], [10, 11, 12]], dtype=np.uint16) + (tmp_path / "sub").mkdir() + first.tofile(tmp_path / "a.raw") + second.tofile(tmp_path / "sub" / "b.raw") + param = RawImageImportParam.create(dtype="uint16", width=3, height=2) + + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.imagepanel + history.toggle_record_mode(True) + + with patch.object(RawImageImportParam, "edit") as edit: + objects = panel.load_from_directory(str(tmp_path), param=param) + + assert len(objects) == 2 + action = history[len(history)] + assert action.method_name == "load_from_directory" + assert sorted(action.kwargs["import_filenames"]) == sorted( + ["a.raw", str(Path("sub") / "b.raw")] + ) + assert all( + isinstance(recorded, RawImageImportParam) + and (recorded.width, recorded.height) == (3, 2) + for recorded in action.kwargs["import_params"] + ) + + panel.objview.select_objects([get_uuid(obj) for obj in objects]) + panel.remove_object(force=True) + assert len(panel.objmodel) == 0 + + select_tree_entry(history, action.uuid) + history.replay_restore_actions(restore_selection=False) + + edit.assert_not_called() + + reloaded = sorted( + (panel.objmodel[uuid].data for uuid in action.output_uuids), + key=lambda data: int(data[0, 0]), + ) + assert len(reloaded) == 2 + np.testing.assert_array_equal(reloaded[0], first) + np.testing.assert_array_equal(reloaded[1], second) diff --git a/datalab/tests/features/image/raw_import_unit_test.py b/datalab/tests/features/image/raw_import_unit_test.py index 1e01011f7..383909623 100644 --- a/datalab/tests/features/image/raw_import_unit_test.py +++ b/datalab/tests/features/image/raw_import_unit_test.py @@ -4,19 +4,22 @@ from __future__ import annotations -from contextlib import nullcontext +import os.path as osp +from contextlib import contextmanager, nullcontext from types import SimpleNamespace from unittest.mock import Mock, call, patch import numpy as np +import pytest from sigima.io import RawImageImportParam from sigima.io.image import ImageIORegistry from sigima.objects import create_image from datalab.adapters_plotpy import create_adapter_from_object from datalab.gui.panel import base as panel_base +from datalab.gui.panel import image as panel_image from datalab.gui.panel.base import BaseDataPanel -from datalab.gui.panel.image import ImagePanel, RawImageImportGUIParam +from datalab.gui.panel.image import ImagePanel from datalab.h5.native import NativeH5Reader, NativeH5Writer from datalab.history.action import HistoryAction from datalab.history.session import HistorySession @@ -74,8 +77,9 @@ class RawLoadPanelStub: PANEL_STR_ID = "image" load_from_files = BaseDataPanel.load_from_files + load_from_directory = BaseDataPanel.load_from_directory edit_import_parameters = ImagePanel.edit_import_parameters - prepare_import_parameters = ImagePanel.prepare_import_parameters + edit_raw_import_parameters = ImagePanel.edit_raw_import_parameters apply_import_parameters = ImagePanel.apply_import_parameters def __init__(self) -> None: @@ -87,9 +91,18 @@ def __init__(self) -> None: ) self.loaded_objects = [object()] self.load_file = Mock(return_value=self.loaded_objects) + self.add_group = Mock(return_value=SimpleNamespace()) + self.add_object = Mock() setattr(self, "parentWidget", Mock(return_value=None)) setattr(self, "_BaseDataPanel__load_from_file", self.load_file) + def loaded_params(self) -> dict[str, RawImageImportParam | None]: + """Return the import parameter used for each loaded file.""" + return { + item.args[0]: item.kwargs["import_param"] + for item in self.load_file.call_args_list + } + def run_stub_import(panel: RawLoadPanelStub, filenames, **kwargs): """Run the generic import flow without Qt error dialogs.""" @@ -104,6 +117,36 @@ def run_stub_import(panel: RawLoadPanelStub, filenames, **kwargs): return panel.load_from_files(filenames, **kwargs) +def run_stub_directory_import(panel: RawLoadPanelStub, directory, **kwargs): + """Run the directory import flow without Qt dialogs or progress bars.""" + progress = SimpleNamespace(setValue=Mock(), wasCanceled=Mock(return_value=False)) + with ( + patch.object( + panel_base, + "qt_try_loadsave_file", + side_effect=lambda *_args: nullcontext(), + ), + patch.object(panel_base.Conf.base_dir, "set"), + patch.object( + panel_base, "create_progress_bar", return_value=nullcontext(progress) + ), + patch.object(panel_base, "get_uuid", return_value="group-uuid"), + ): + return panel.load_from_directory(str(directory), **kwargs) + + +@contextmanager +def interactive_question(answer: int): + """Answer the multi-file RAW question with ``answer`` in interactive mode.""" + with ( + patch.object(panel_image, "execenv", SimpleNamespace(unattended=False)), + patch.object( + panel_image.QW.QMessageBox, "question", return_value=answer + ) as question, + ): + yield question + + def confirm_raw_dialog(**values): """Return a dialog edit replacement that sets values and confirms.""" @@ -116,13 +159,24 @@ def edit(param, parent=None): return edit +def create_files(directory, *names: str) -> list[str]: + """Create empty files and return their paths.""" + paths = [] + for name in names: + path = directory / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"") + paths.append(str(path)) + return paths + + def test_raw_dialog_confirmation_forwards_backend_parameters(tmp_path) -> None: """Forward confirmed RAW settings to the registry-facing load helper.""" filename = str(tmp_path / "image.raw") panel = RawLoadPanelStub() with patch.object( - RawImageImportGUIParam, + RawImageImportParam, "edit", autospec=True, side_effect=confirm_raw_dialog( @@ -140,7 +194,6 @@ def test_raw_dialog_confirmation_forwards_backend_parameters(tmp_path) -> None: import_param = panel.load_file.call_args.kwargs["import_param"] assert isinstance(import_param, RawImageImportParam) - assert not isinstance(import_param, RawImageImportGUIParam) assert ( import_param.dtype, import_param.width, @@ -161,7 +214,7 @@ def test_raw_dialog_cancellation_skips_read_and_history(tmp_path) -> None: """Do not read or record a RAW file after dialog cancellation.""" panel = RawLoadPanelStub() - with patch.object(RawImageImportGUIParam, "edit", return_value=False): + with patch.object(RawImageImportParam, "edit", return_value=False): objects = run_stub_import(panel, [str(tmp_path / "cancelled.raw")]) assert objects == [] @@ -174,7 +227,7 @@ def test_non_raw_import_keeps_parameter_free_path(tmp_path) -> None: filename = str(tmp_path / "image.npy") panel = RawLoadPanelStub() - with patch.object(RawImageImportGUIParam, "edit") as edit: + with patch.object(RawImageImportParam, "edit") as edit: objects = run_stub_import(panel, [filename]) assert objects == panel.loaded_objects @@ -199,43 +252,216 @@ def test_non_raw_duplicate_filenames_keep_existing_behavior(tmp_path) -> None: assert panel.historypanel.actions[0].kwargs["filenames"] == [filename, filename] -def test_open_all_raw_files_expands_sorted_without_duplicates(tmp_path) -> None: - """Expand one confirmed RAW file to unique RAW siblings in sorted order.""" - first = tmp_path / "a.raw" - second = tmp_path / "b.RAW" - other = tmp_path / "c.txt" - for path in (first, second, other): - path.write_bytes(b"") +def test_several_raw_files_share_parameters_after_confirmation(tmp_path) -> None: + """Apply one dialog to all RAW files when the user accepts shared parameters.""" + first, second, plain = create_files(tmp_path, "b.raw", "a.RAW", "c.npy") panel = RawLoadPanelStub() - assert RawImageImportGUIParam().open_all_files is False - with patch.object( - RawImageImportGUIParam, - "edit", - autospec=True, - side_effect=confirm_raw_dialog(open_all_files=True), - ) as edit: - run_stub_import(panel, [str(second), str(first)]) + with ( + interactive_question(panel_image.QW.QMessageBox.Yes) as question, + patch.object( + RawImageImportParam, + "edit", + autospec=True, + side_effect=confirm_raw_dialog(width=3, height=1), + ) as edit, + ): + run_stub_import(panel, [first, plain, second]) + question.assert_called_once() assert edit.call_count == 1 - loaded_filenames = [item.args[0] for item in panel.load_file.call_args_list] - assert loaded_filenames == [str(first), str(second)] - assert panel.historypanel.actions[0].kwargs["filenames"] == loaded_filenames - params = panel.historypanel.actions[0].kwargs["import_params"] - assert len(params) == 2 - assert all(isinstance(param, RawImageImportParam) for param in params) - loaded_params = [ - item.kwargs["import_param"] for item in panel.load_file.call_args_list + assert "2" in edit.call_args.args[0].get_title() + params = panel.loaded_params() + assert list(params) == [second, first, plain] + assert params[plain] is None + assert params[first] is not params[second] + assert (params[first].width, params[second].width) == (3, 3) + history_params = panel.historypanel.actions[0].kwargs["import_params"] + assert [param.width if param else None for param in history_params] == [ + 3, + 3, + None, ] - assert loaded_params[0] is not loaded_params[1] - assert params[0] is not params[1] - assert all( - history_param is not loaded_param - for history_param, loaded_param in zip(params, loaded_params) - ) - loaded_params[0].width = 9 - assert loaded_params[1].width != 9 - assert params[0].width != 9 + assert history_params[0] is not params[second] + + +def test_several_raw_files_get_one_dialog_each(tmp_path) -> None: + """Edit each RAW file separately, skipping only the cancelled one.""" + first, second, third = create_files(tmp_path, "a.raw", "b.raw", "c.raw") + panel = RawLoadPanelStub() + titles = [] + answers = iter([(True, 3), (False, None), (True, 5)]) + + def edit(param, parent=None): + del parent + titles.append(param.get_title()) + confirmed, width = next(answers) + if confirmed: + param.width = width + return confirmed + + with ( + interactive_question(panel_image.QW.QMessageBox.No), + patch.object(RawImageImportParam, "edit", autospec=True, side_effect=edit), + ): + run_stub_import(panel, [third, first, second]) + + assert ["a.raw" in titles[0], "b.raw" in titles[1], "c.raw" in titles[2]] == [ + True, + True, + True, + ] + params = panel.loaded_params() + assert list(params) == [first, third] + assert (params[first].width, params[third].width) == (3, 5) + assert panel.historypanel.actions[0].kwargs["filenames"] == [first, third] + + +def test_cancelling_several_raw_files_question_aborts_import(tmp_path) -> None: + """Abort the whole import, including non-RAW files, from the question.""" + filenames = create_files(tmp_path, "a.raw", "b.raw", "c.npy") + panel = RawLoadPanelStub() + + with ( + interactive_question(panel_image.QW.QMessageBox.Cancel), + patch.object(RawImageImportParam, "edit") as edit, + ): + objects = run_stub_import(panel, filenames) + + assert objects == [] + edit.assert_not_called() + panel.load_file.assert_not_called() + assert not panel.historypanel.actions + + +def test_cancelling_shared_raw_dialog_keeps_other_files(tmp_path) -> None: + """Skip all RAW files, but still load other files, on shared dialog cancel.""" + raw1, raw2, plain = create_files(tmp_path, "a.raw", "b.raw", "c.npy") + panel = RawLoadPanelStub() + + with ( + interactive_question(panel_image.QW.QMessageBox.Yes), + patch.object(RawImageImportParam, "edit", return_value=False), + ): + run_stub_import(panel, [raw1, raw2, plain]) + + assert panel.loaded_params() == {plain: None} + + +def test_import_param_applies_to_raw_files_without_dialog(tmp_path) -> None: + """Apply a caller-provided RAW parameter set (e.g. remote control).""" + raw1, raw2, plain = create_files(tmp_path, "a.raw", "b.raw", "c.npy") + param = RawImageImportParam.create(dtype="uint8", width=4, height=2) + panel = RawLoadPanelStub() + + with ( + interactive_question(panel_image.QW.QMessageBox.Yes) as question, + patch.object(RawImageImportParam, "edit") as edit, + ): + run_stub_import(panel, [plain, raw2, raw1], param=param) + + question.assert_not_called() + edit.assert_not_called() + params = panel.loaded_params() + assert params[plain] is None + assert params[raw1] is not param and params[raw1] is not params[raw2] + assert (params[raw1].width, params[raw2].dtype) == (4, "uint8") + history_params = panel.historypanel.actions[0].kwargs["import_params"] + assert history_params[0].width == 4 and history_params[2] is None + + +def test_invalid_import_param_is_rejected(tmp_path) -> None: + """Reject unsupported or conflicting import parameter arguments.""" + (filename,) = create_files(tmp_path, "a.raw") + panel = RawLoadPanelStub() + param = RawImageImportParam.create(width=2, height=2) + + with pytest.raises(TypeError, match="RawImageImportParam"): + run_stub_import(panel, [filename], param=object()) + with pytest.raises(ValueError, match="cannot be combined"): + run_stub_import(panel, [filename], param=param, import_params=[param]) + with pytest.raises(TypeError, match="does not support import parameters"): + BaseDataPanel.edit_import_parameters(panel, [filename], param) + panel.load_file.assert_not_called() + + +def test_directory_import_asks_raw_parameters_once_for_all_folders( + tmp_path, +) -> None: + """Ask RAW parameters once for a directory tree and record them per file.""" + root_raw, plain, sub_raw = create_files(tmp_path, "a.raw", "b.npy", "sub/c.raw") + panel = RawLoadPanelStub() + + with ( + interactive_question(panel_image.QW.QMessageBox.Yes) as question, + patch.object( + RawImageImportParam, + "edit", + autospec=True, + side_effect=confirm_raw_dialog(width=6, height=1), + ) as edit, + ): + run_stub_directory_import(panel, tmp_path) + + question.assert_called_once() + assert edit.call_count == 1 + params = panel.loaded_params() + assert params[plain] is None + assert (params[root_raw].width, params[sub_raw].width) == (6, 6) + assert panel.add_group.call_count == 2 + (action,) = panel.historypanel.actions + assert action.method_name == "load_from_directory" + assert action.kwargs["import_filenames"] == ["a.raw", osp.join("sub", "c.raw")] + assert [param.width for param in action.kwargs["import_params"]] == [6, 6] + assert action.kwargs["import_params"][0] is not params[root_raw] + + +def test_directory_replay_reuses_recorded_raw_parameters(tmp_path) -> None: + """Replay a directory import with recorded parameters, asking only new files.""" + recorded_raw, new_raw = create_files(tmp_path, "a.raw", "sub/new.raw") + recorded = RawImageImportParam.create(width=2, height=3) + panel = RawLoadPanelStub() + + with ( + interactive_question(panel_image.QW.QMessageBox.Yes) as question, + patch.object( + RawImageImportParam, + "edit", + autospec=True, + side_effect=confirm_raw_dialog(width=7, height=1), + ) as edit, + ): + run_stub_directory_import( + panel, + tmp_path, + import_filenames=["a.raw"], + import_params=[recorded], + ) + + question.assert_not_called() + assert edit.call_count == 1 + assert "new.raw" in edit.call_args.args[0].get_title() + params = panel.loaded_params() + assert params[recorded_raw] is recorded + assert params[new_raw].width == 7 + (action,) = panel.historypanel.actions + assert [param.width for param in action.kwargs["import_params"]] == [2, 7] + + +def test_directory_import_without_raw_files_records_directory_only( + tmp_path, +) -> None: + """Keep directory history entries unchanged when no RAW file is imported.""" + create_files(tmp_path, "a.npy", "sub/b.npy") + panel = RawLoadPanelStub() + + with patch.object(RawImageImportParam, "edit") as edit: + run_stub_directory_import(panel, tmp_path) + + edit.assert_not_called() + assert panel.load_file.call_count == 2 + (action,) = panel.historypanel.actions + assert action.kwargs == {"directory": str(tmp_path)} def test_partial_failure_keeps_successful_filenames_and_params_aligned( @@ -302,7 +528,7 @@ def test_history_replay_uses_recorded_params_without_dialog(tmp_path) -> None: ) with ( - patch.object(RawImageImportGUIParam, "edit") as edit, + patch.object(RawImageImportParam, "edit") as edit, patch.object( panel_base, "qt_try_loadsave_file", From 00c56d873c43bc0d0fb7f96e91abbbbfc69bb862 Mon Sep 17 00:00:00 2001 From: Thomas MALLET Date: Thu, 15 Oct 2026 15:15:43 +0200 Subject: [PATCH 7/9] Reuse processing preview in image export dialog * [UPDATE] : datalab/gui/panel/image.py: - derive ImageExportDialog from ProcessingPreviewDialog - remove the dedicated plot and preview button - add compute_image_export_preview, run in the preview process - enable the preview when the dialog opens - validate the export configuration on accept - use the main window preview executor cache and close the preview on exit * [UPDATE] : datalab/widgets/processingpreview.py: - add optional prepare_param hook converting edited parameters before request - report parameter conversion errors as invalid parameters * [UPDATE] : datalab/tests/features/image/export_unit_test.py: - replace tests of the removed dedicated preview - add tests for preview function, preview on open, invalid options and accept * [UPDATE] : datalab/locale/fr/LC_MESSAGES/datalab.po: - remove obsolete "Export options" entry --- datalab/gui/panel/image.py | 144 +++++++------ datalab/locale/fr/LC_MESSAGES/datalab.po | 9 +- .../control/remote_import_param_unit_test.py | 69 ++++++ .../tests/features/image/export_unit_test.py | 198 ++++++++++++++---- datalab/widgets/processingpreview.py | 17 +- 5 files changed, 324 insertions(+), 113 deletions(-) create mode 100644 datalab/tests/features/control/remote_import_param_unit_test.py diff --git a/datalab/gui/panel/image.py b/datalab/gui/panel/image.py index cb85d282f..b9c71b233 100644 --- a/datalab/gui/panel/image.py +++ b/datalab/gui/panel/image.py @@ -9,18 +9,18 @@ from __future__ import annotations import copy +import functools import os.path as osp +from collections.abc import Callable from typing import TYPE_CHECKING, Type from weakref import ReferenceType, ref import guidata.dataset as gds -import guidata.dataset.qtwidgets as gdq import numpy as np +from guidata.configtools import get_icon from guidata.dataset import update_dataset from guidata.qthelpers import exec_dialog -from plotpy.builder import make from plotpy.interfaces import IVoiImageItemType -from plotpy.plot import PlotDialog, PlotOptions from plotpy.tools import ( AnnotatedCircleTool, AnnotatedEllipseTool, @@ -37,6 +37,8 @@ get_image_export_capabilities, get_supported_export_dtypes, prepare_image_export_preview, + prepare_image_for_export, + validate_image_export_configuration, write_image, ) from sigima.io.image import ImageIORegistry @@ -50,7 +52,9 @@ from datalab.gui.panel.base import BaseDataPanel from datalab.gui.plothandler import ImagePlotHandler from datalab.gui.processor.image import ImageProcessor +from datalab.gui.processor.preview import PreviewController from datalab.objectmodel import get_uuid +from datalab.widgets.processingpreview import ProcessingPreviewDialog if TYPE_CHECKING: from plotpy.plot import BasePlot @@ -301,8 +305,30 @@ def create_image_export_gui_param(obj: ImageObj, filename: str) -> ImageExportGU return param -class ImageExportDialog(PlotDialog): - """Format-aware image export dialog with an in-memory graphical preview.""" +def compute_image_export_preview( + source: ImageObj, param: ImageExportParam, filename: str +) -> ImageObj: + """Return the image as it will be read back from the exported file. + + This function runs in the preview process, so its arguments must be picklable. + + Args: + source: Image to export + param: Image export parameters + filename: Destination file name + + Returns: + Copy of the source image holding the exported data, displayed in gray levels + """ + result = source.copy() + result.data = prepare_image_export_preview(source.data, filename, param) + result.set_metadata_option("colormap", "gray") + result.set_metadata_option("invert_colormap", False) + return result + + +class ImageExportDialog(ProcessingPreviewDialog): + """Format-aware image export dialog with a live preview of the exported data.""" def __init__( self, @@ -310,79 +336,64 @@ def __init__( obj: ImageObj, filename: str, param: ImageExportGUIParam, + controller_factory: Callable = PreviewController, ) -> None: self.obj = obj self.filename = filename - self.preview_item = None - self.param_widget = gdq.DataSetEditGroupBox( - _("Export options"), - param.__class__, - button_text=_("Preview"), - button_icon="reload.svg", - ) - update_dataset(self.param_widget.dataset, param) - self.param_widget.dataset.supported_dtypes = param.supported_dtypes - self.param_widget.dataset.format_extension = param.format_extension super().__init__( - parent=parent, - title=_("Export image"), - icon="export.svg", - toolbar=False, - edit=True, - options=PlotOptions(type="image", show_contrast=True), - size=(900, 600), + param, + functools.partial(compute_image_export_preview, filename=filename), + [obj], + parent, + controller_factory, ) - self.param_widget.SIG_APPLY_BUTTON_CLICKED.connect(self.update_preview) - self.param_widget.set_apply_button_state(True) - self.update_preview() + self.setWindowTitle(_("Export image")) + self.setWindowIcon(get_icon("export.svg")) + self.preview.prepare_param = self.to_export_param + self.preview.enabled.setChecked(True) - def setup_layout(self) -> None: - """Add export controls beside the graphical preview.""" - super().setup_layout() - self.plot_layout.addWidget(self.param_widget, 0, 1) - self.plot_layout.setColumnStretch(0, 2) - self.plot_layout.setColumnStretch(1, 1) + @staticmethod + def to_export_param(guiparam: ImageExportGUIParam) -> ImageExportParam: + """Return backend export parameters from GUI parameters. - def get_export_param(self) -> ImageExportParam: - """Return backend parameters from the current GUI values.""" - guiparam = self.param_widget.dataset + Args: + guiparam: Format-specific GUI parameters + + Returns: + Backend image export parameters + + Raises: + ValueError: If a format option value is invalid + """ param = ImageExportParam() update_dataset(param, guiparam) param.format_options = guiparam.get_format_options() return param - def update_preview(self) -> bool: - """Render current export preparation directly in the preview plot.""" - try: - data = prepare_image_export_preview( - self.obj.data, self.filename, self.get_export_param() - ) - except (TypeError, ValueError, OSError) as exc: - QW.QMessageBox.warning(self, _("Export image"), str(exc)) - return False - plot = self.manager.get_plot() - if self.preview_item is None: - self.preview_item = make.image(data, colormap="gray") - plot.add_item(self.preview_item) - plot.set_active_item(self.preview_item) - plot.do_autoscale() - else: - self.preview_item.set_data(data) - plot.replot() - return True + def get_export_param(self) -> ImageExportParam: + """Return backend parameters from the current GUI values.""" + return self.to_export_param(self.instance) def accept(self) -> None: - """Validate controls and preview preparation before accepting.""" - if not self.param_widget.edit.check_all_values(): + """Validate the export configuration before accepting.""" + if not self.edit_layout.check_all_values(): QW.QMessageBox.warning( self, _("Export image"), _("Some required entries are incorrect."), ) return - self.param_widget.edit.accept_changes() - if self.update_preview(): - QW.QDialog.accept(self) + self.edit_layout.accept_changes() + try: + param = self.get_export_param() + prepared = prepare_image_for_export(self.obj.data, self.filename, param) + validate_image_export_configuration( + self.filename, prepared.dtype, param.format_options, prepared.shape + ) + except (TypeError, ValueError) as exc: + QW.QMessageBox.warning(self, _("Export image"), str(exc)) + return + super().accept() class ImagePanel(BaseDataPanel[ImageObj, ImageROI, roieditor.ImageROIEditor]): @@ -541,10 +552,19 @@ def edit_export_parameters( guiparam = create_image_export_gui_param(obj, filename) except ValueError: return True, None - dialog = ImageExportDialog(self.parentWidget(), obj, filename, guiparam) - if not exec_dialog(dialog): - return False, None - return True, dialog.get_export_param() + controller_factory = functools.partial( + PreviewController, executor_cache=self.mainwindow.preview_executor_cache + ) + dialog = ImageExportDialog( + self.parentWidget(), obj, filename, guiparam, controller_factory + ) + try: + if not exec_dialog(dialog): + return False, None + return True, dialog.get_export_param() + finally: + dialog.preview.close_preview() + dialog.deleteLater() def write_object_to_file( self, diff --git a/datalab/locale/fr/LC_MESSAGES/datalab.po b/datalab/locale/fr/LC_MESSAGES/datalab.po index 09343d71c..eeb2d5927 100644 --- a/datalab/locale/fr/LC_MESSAGES/datalab.po +++ b/datalab/locale/fr/LC_MESSAGES/datalab.po @@ -2083,9 +2083,6 @@ msgstr "%s doit contenir 2 valeurs" msgid "%s contains invalid numbers" msgstr "%s contient des nombres non valides" -msgid "Export options" -msgstr "Options d'exportation" - msgid "Export image" msgstr "Exporter l'image" @@ -4528,6 +4525,9 @@ msgstr "Obtenir la liste des uuids" msgid "Get object" msgstr "Obtenir un objet" +msgid "Invalid parameters" +msgstr "Paramètres invalides" + msgid "Raw data (2D-Gaussian)" msgstr "Image brute (gaussienne 2D)" @@ -4697,9 +4697,6 @@ 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..." diff --git a/datalab/tests/features/control/remote_import_param_unit_test.py b/datalab/tests/features/control/remote_import_param_unit_test.py new file mode 100644 index 000000000..b3f1ae50d --- /dev/null +++ b/datalab/tests/features/control/remote_import_param_unit_test.py @@ -0,0 +1,69 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +Test format-specific import parameters through the remote control API + +RAW image files require import parameters describing their binary layout. Remote +clients pass them to ``load_from_files`` and ``load_from_directory`` as a DataSet, +like computation parameters with ``calc``, so that no dialog is shown. +""" + +# guitest: show + +from __future__ import annotations + +import time + +import numpy as np +from sigima.io import RawImageImportParam + +from datalab.control.proxy import RemoteProxy +from datalab.tests import datalab_in_background_context + + +def wait_for_image_uuids(proxy: RemoteProxy, count: int) -> list[str]: + """Return image UUIDs once ``count`` images exist. + + The remote call may return before the main window has finished adding the + loaded objects, so the object list is polled for a short time. + """ + deadline = time.monotonic() + 10.0 + uuids = proxy.get_object_uuids("image") + while len(uuids) < count and time.monotonic() < deadline: + time.sleep(0.1) + uuids = proxy.get_object_uuids("image") + return uuids + + +def test_remote_raw_import_with_param(tmp_path) -> None: + """Load RAW files and directories remotely with import parameters.""" + file_data = np.array([[1, 2, 3], [4, 5, 6]], dtype=np.uint16) + dir_data = np.array([[7, 8], [9, 10], [11, 12]], dtype=np.uint16) + filename = tmp_path / "image.raw" + file_data.tofile(filename) + directory = tmp_path / "directory" + directory.mkdir() + dir_data.tofile(directory / "image.raw") + + with datalab_in_background_context() as proxy: + proxy.set_current_panel("image") + + param = RawImageImportParam.create(dtype="uint16", width=3, height=2) + proxy.load_from_files([str(filename)], param=param) + uuids = wait_for_image_uuids(proxy, 1) + assert len(uuids) == 1 + np.testing.assert_array_equal(proxy.get_object(uuids[0]).data, file_data) + + param = RawImageImportParam.create(dtype="uint16", width=2, height=3) + proxy.load_from_directory(str(directory), param=param) + uuids = wait_for_image_uuids(proxy, 2) + assert len(uuids) == 2 + np.testing.assert_array_equal(proxy.get_object(uuids[1]).data, dir_data) + + +if __name__ == "__main__": + import tempfile + from pathlib import Path + + with tempfile.TemporaryDirectory() as tmpdir: + test_remote_raw_import_with_param(Path(tmpdir)) diff --git a/datalab/tests/features/image/export_unit_test.py b/datalab/tests/features/image/export_unit_test.py index 4228c00ef..ebe107f2b 100644 --- a/datalab/tests/features/image/export_unit_test.py +++ b/datalab/tests/features/image/export_unit_test.py @@ -4,6 +4,8 @@ from __future__ import annotations +import pickle +from concurrent.futures import Future from contextlib import contextmanager, nullcontext from types import SimpleNamespace from unittest.mock import MagicMock, Mock, call, patch @@ -11,12 +13,15 @@ import guidata.dataset as gds import numpy as np import pytest +from guidata.qthelpers import qt_app_context +from qtpy import QtWidgets as QW from sigima.io import ( IMAGE_EXPORT_CAPABILITIES, ImageExportParam, get_image_export_capabilities, validate_image_export_options, ) +from sigima.objects import ImageObj, create_image from datalab.gui.panel import base as panel_base from datalab.gui.panel import image as image_panel @@ -27,6 +32,7 @@ ImagePanel, create_image_export_gui_param, ) +from datalab.gui.processor.preview import PreviewController from datalab.h5.native import NativeH5Reader, NativeH5Writer from datalab.history.action import HistoryAction from datalab.history.core import decode_kwargs, encode_kwargs @@ -287,54 +293,154 @@ def test_sequence_format_options_accept_comma_whitespace( assert param.get_format_options()[option_name] == expected -def test_export_preview_delegates_and_updates_existing_item() -> None: - """Use Sigima's exact preview and update the existing plot item in place.""" - source = np.arange(4, dtype=np.uint8).reshape(2, 2) - preview = source + 1 - export_param = ImageExportParam.create(normalization="none") - preview_item = Mock() - plot = Mock() - dialog = SimpleNamespace( - obj=SimpleNamespace(data=source), - filename="image.jpeg", - preview_item=preview_item, - get_export_param=Mock(return_value=export_param), - manager=SimpleNamespace(get_plot=Mock(return_value=plot)), +class FakePreviewExecutor: + """Record preview requests without starting any process.""" + + def __init__(self) -> None: + self.requests: list[tuple[Future, object, tuple]] = [] + self.closed = False + + def submit(self, function, args) -> Future: + """Record a request and return its pending future.""" + future = Future() + self.requests.append((future, function, args)) + return future + + def close(self) -> None: + """Mark the executor as closed.""" + self.closed = True + + +def create_export_dialog( + obj: ImageObj, filename: str, executor: FakePreviewExecutor +) -> ImageExportDialog: + """Create an export dialog whose preview requests are recorded.""" + return ImageExportDialog( + None, + obj, + filename, + create_image_export_gui_param(obj, filename), + controller_factory=lambda function, parent: PreviewController( + function, parent, executor_factory=lambda: executor + ), ) + +def test_compute_image_export_preview_returns_exported_image() -> None: + """Return a gray-level copy holding Sigima's exact export preview.""" + source = create_image("Source", np.arange(6, dtype=np.float64).reshape(2, 3)) + source.set_metadata_option("colormap", "jet") + source.set_metadata_option("invert_colormap", True) + preview = np.arange(6, dtype=np.uint8).reshape(2, 3) + param = ImageExportParam.create(normalization="minmax") + with patch.object( image_panel, "prepare_image_export_preview", return_value=preview ) as prepare_preview: - result = ImageExportDialog.update_preview(dialog) - - assert result is True - dialog.get_export_param.assert_called_once_with() - prepare_preview.assert_called_once_with(source, "image.jpeg", export_param) - preview_item.set_data.assert_called_once_with(preview) - plot.replot.assert_called_once_with() - plot.add_item.assert_not_called() - - -@pytest.mark.parametrize("exception_type", [TypeError, ValueError, OSError]) -def test_export_preview_reports_expected_errors( + result = image_panel.compute_image_export_preview(source, param, "image.png") + + prepare_preview.assert_called_once_with(source.data, "image.png", param) + assert result is not source + np.testing.assert_array_equal(result.data, preview) + assert result.title == source.title + assert result.get_metadata_option("colormap") == "gray" + assert result.get_metadata_option("invert_colormap") is False + assert source.data.dtype == np.float64 + assert source.get_metadata_option("colormap") == "jet" + + +def test_export_dialog_previews_backend_parameters_on_open() -> None: + """Enable the generic preview on open and send picklable backend parameters.""" + obj = create_image("Source", np.linspace(0.0, 1.0, 12).reshape(3, 4)) + executor = FakePreviewExecutor() + with qt_app_context(): + dialog = create_export_dialog(obj, "image.jpg", executor) + try: + assert dialog.preview.enabled.isChecked() + assert dialog.preview.disabled_overlay.isHidden() + assert len(executor.requests) == 1 + _future, function, (source, param) = executor.requests[0] + assert type(param) is ImageExportParam + assert param.format_options["quality"] == 75 + assert function.keywords == {"filename": "image.jpg"} + np.testing.assert_array_equal(source.data, obj.data) + pickle.dumps((function, source, param)) + finally: + dialog.preview.close_preview() + dialog.deleteLater() + + +def test_export_dialog_reports_invalid_format_options_in_preview() -> None: + """Show format option serialization errors instead of requesting a preview.""" + obj = create_image("Source", np.linspace(0.0, 1.0, 12).reshape(3, 4)) + executor = FakePreviewExecutor() + with qt_app_context(): + dialog = create_export_dialog(obj, "image.tif", executor) + try: + requests = len(executor.requests) + with patch.object( + type(dialog.instance), + "get_format_options", + side_effect=ValueError("Tile size must contain 2 values"), + ): + dialog.preview._request() + assert len(executor.requests) == requests + assert dialog.preview.status.text() == image_panel._("Invalid parameters") + assert "Tile size" in dialog.preview.details.toPlainText() + finally: + dialog.preview.close_preview() + dialog.deleteLater() + + +@pytest.mark.parametrize("exception_type", [TypeError, ValueError]) +def test_export_dialog_accept_reports_invalid_configuration( exception_type: type[Exception], ) -> None: - """Report expected validation and encoding errors without updating the plot.""" + """Keep the dialog open and warn when the export configuration is invalid.""" + obj = create_image("Source", np.linspace(0.0, 1.0, 12).reshape(3, 4)) + executor = FakePreviewExecutor() error = exception_type("Invalid export options") - dialog = SimpleNamespace( - obj=SimpleNamespace(data=np.zeros((2, 2), dtype=np.uint8)), - filename="image.png", - get_export_param=Mock(return_value=ImageExportParam()), - ) - - with ( - patch.object(image_panel, "prepare_image_export_preview", side_effect=error), - patch.object(image_panel.QW.QMessageBox, "warning") as warning, - ): - result = ImageExportDialog.update_preview(dialog) - - assert result is False - warning.assert_called_once_with(dialog, image_panel._("Export image"), str(error)) + with qt_app_context(): + dialog = create_export_dialog(obj, "image.png", executor) + try: + with ( + patch.object( + image_panel, "prepare_image_for_export", side_effect=error + ), + patch.object(image_panel.QW.QMessageBox, "warning") as warning, + patch.object(image_panel.ProcessingPreviewDialog, "accept") as accept, + ): + dialog.accept() + warning.assert_called_once_with( + dialog, image_panel._("Export image"), str(error) + ) + accept.assert_not_called() + finally: + dialog.preview.close_preview() + dialog.deleteLater() + + +def test_export_dialog_accept_returns_validated_backend_parameters() -> None: + """Accept a valid configuration and return backend export parameters.""" + obj = create_image("Source", np.linspace(0.0, 1.0, 12).reshape(3, 4)) + executor = FakePreviewExecutor() + with qt_app_context(): + dialog = create_export_dialog(obj, "image.png", executor) + try: + widget = next( + widget + for widget in dialog.edit_layout.get_terminal_widgets() + if widget.item.item.get_name() == "compress_level" + ) + widget.edit.setText("9") + dialog.accept() + assert dialog.result() == QW.QDialog.Accepted + param = dialog.get_export_param() + assert type(param) is ImageExportParam + assert param.format_options["compress_level"] == 9 + finally: + dialog.preview.close_preview() + dialog.deleteLater() def test_interactive_export_cancellation_skips_write_and_history_pair() -> None: @@ -537,7 +643,11 @@ def test_unsupported_extension_uses_legacy_writer_parameters(extension: str) -> def test_export_dialog_validation_error_is_not_suppressed() -> None: """Propagate validation errors raised after GUI parameter creation.""" obj = SimpleNamespace(data=np.array([[1, 3]], dtype=np.float32)) - panel = SimpleNamespace(parentWidget=Mock(return_value=None)) + executor_cache = object() + panel = SimpleNamespace( + parentWidget=Mock(return_value=None), + mainwindow=SimpleNamespace(preview_executor_cache=executor_cache), + ) guiparam = Mock() dialog = Mock() @@ -559,8 +669,12 @@ def test_export_dialog_validation_error_is_not_suppressed() -> None: create_guiparam.assert_called_once_with(obj, "image.png") panel.parentWidget.assert_called_once_with() - dialog_class.assert_called_once_with(None, obj, "image.png", guiparam) + args = dialog_class.call_args.args + assert args[:4] == (None, obj, "image.png", guiparam) + assert args[4].keywords == {"executor_cache": executor_cache} exec_dialog.assert_called_once_with(dialog) + dialog.preview.close_preview.assert_called_once_with() + dialog.deleteLater.assert_called_once_with() def test_explicit_image_export_parameter_is_forwarded() -> None: diff --git a/datalab/widgets/processingpreview.py b/datalab/widgets/processingpreview.py index 7c1088639..093145fb4 100644 --- a/datalab/widgets/processingpreview.py +++ b/datalab/widgets/processingpreview.py @@ -89,6 +89,9 @@ def __init__( super().__init__(parent) self.sources = tuple(sources) self.editor: DataSetEditLayout | None = None + # Optional conversion of the edited parameters into the parameters sent to + # the preview process (e.g. when the form class cannot be pickled) + self.prepare_param: Callable[[DataSet], DataSet] | None = None self.controller = controller_factory(function, self) self._dragging = False self._plot_kind = None @@ -268,10 +271,18 @@ def _request(self) -> None: self.status.setText(_("Invalid parameters")) return self.editor.accept_changes() + param = self.editor.instance + if self.prepare_param is not None: + try: + param = self.prepare_param(param) + except (TypeError, ValueError) as error: + self.controller.invalidate() + self.status.setText(_("Invalid parameters")) + self.details.setPlainText(str(error)) + self.details.show() + return self.status.setText(_("Computing preview...")) - self.controller.request( - self.sources[self.source_combo.currentIndex()], self.editor.instance - ) + self.controller.request(self.sources[self.source_combo.currentIndex()], param) def _show_error(self, message: str) -> None: self.status.setText(_("Preview failed")) From 67f9cb9fafb605633c8cc48a8cb259d54de8b9ac Mon Sep 17 00:00:00 2001 From: Thomas MALLET Date: Thu, 15 Oct 2026 15:36:17 +0200 Subject: [PATCH 8/9] Prevent incompatible image export option combinations * [UPDATE] : datalab/gui/panel/image.py: - disable TIFF compression level and predictor for unsupported compressions - ignore inactive parameters and format options when exporting - add per-format help on option constraints - remove automatic slider on TIFF compression level - reject complex images upfront for formats without complex data types - show only the message of expected validation errors in the preview * [UPDATE] : datalab/widgets/processingpreview.py: - add optional format_error hook for preview error messages * [UPDATE] : datalab/tests/features/image/export_unit_test.py: - add test_tiff_options_follow_compression - add test_lzw_with_leftover_compression_level_is_exportable - add test_inactive_gamma_and_invert_are_ignored - add help/slider, error formatting and complex image tests * [UPDATE] : datalab/locale/fr/LC_MESSAGES/datalab.po: - translate new help and warning messages Assisted by : Opus 5.5 --- datalab/gui/panel/image.py | 160 ++++++++++++++++-- datalab/locale/fr/LC_MESSAGES/datalab.po | 22 +++ .../tests/features/image/export_unit_test.py | 110 ++++++++++++ datalab/widgets/processingpreview.py | 5 + 4 files changed, 284 insertions(+), 13 deletions(-) diff --git a/datalab/gui/panel/image.py b/datalab/gui/panel/image.py index b9c71b233..f6de70f77 100644 --- a/datalab/gui/panel/image.py +++ b/datalab/gui/panel/image.py @@ -108,6 +108,49 @@ } +# Format options only taken into account for some values of another (choice) option: +# they are inactive (and ignored) otherwise. These rules mirror the constraints +# checked by :func:`sigima.io.validate_image_export_configuration`. +IMAGE_EXPORT_OPTION_DEPENDENCIES: dict[ + str, dict[str, tuple[str, Callable[[object], bool]]] +] = { + "tif": { + "compression_level": ( + "compression", + lambda value: value in ("deflate", "zstd", "jpeg"), + ), + "predictor": ("compression", lambda value: value in ("deflate", "lzw", "zstd")), + }, +} + +IMAGE_EXPORT_OPTION_HELP = { + "tif": { + "compression": _("JPEG compression requires 8-bit data (uint8)"), + "compression_level": _( + "Deflate: 0 to 9, Zstandard: 1 to 22, JPEG: 1 to 100.
" + "Leave empty to use the default level." + ), + "predictor": _( + "Horizontal: integer data only.
Floating-point: floating data only." + ), + "rows_per_strip": _("Cannot be combined with tile size"), + "tile_size": _( + "Height and width, multiples of 16.
" + "Cannot be combined with rows per strip." + ), + }, + "jp2": { + "num_resolutions": _( + "Leave empty to use the default value.
" + "The maximum depends on the image (or tile) size." + ), + }, +} + +# Options whose valid range depends on another option: no automatic slider +IMAGE_EXPORT_OPTIONS_WITHOUT_SLIDER = {("tif", "compression_level")} + + def get_image_export_dtype_choices( param: ImageExportGUIParam, _item: gds.ChoiceItem, _value: str ) -> list[tuple[str, str, None]]: @@ -142,16 +185,49 @@ class ImageExportGUIParam(ImageExportParam, title=_("Image export")): ), ) + def is_item_active(self, name: str) -> bool: + """Return True if the item named ``name`` is taken into account.""" + item = next(item for item in self.get_items() if item.get_name() == name) + return bool(item.get_prop_value("display", self, "active", True)) + def get_format_options(self) -> dict[str, object]: - """Return writer options exposed by this format parameter class.""" + """Return writer options exposed by this format parameter class. + + Inactive options are replaced by their default value, as they are not + taken into account for the current values of the other options. + """ options = {} for spec in self.format_option_specs: + if not self.is_item_active(spec.key): + options[spec.key] = spec.default + continue value = getattr(self, spec.key) if spec.value_kind in ("int_pair", "float_pair", "float_list"): value = serialize_image_export_sequence(value, spec) options[spec.key] = value return options + def to_export_param(self) -> ImageExportParam: + """Return backend export parameters from the current GUI values. + + Inactive parameters (e.g. gamma without normalization) are reset to their + default value, as they are not taken into account. + + Returns: + Backend image export parameters + + Raises: + ValueError: If a format option value is invalid + """ + param = ImageExportParam() + update_dataset(param, self) + for item in param.get_items(): + name = item.get_name() + if not self.is_item_active(name): + setattr(param, name, item.get_default()) + param.format_options = self.get_format_options() + return param + def get_image_export_choice_label(value: object) -> str: """Return a translated human-readable label for an option choice.""" @@ -186,8 +262,29 @@ def serialize_image_export_sequence( return converted -def create_image_export_option_item(spec: ImageExportOptionSpec) -> gds.DataItem: - """Create a guidata item for an image export capability option.""" +def create_image_export_option_item( + spec: ImageExportOptionSpec, extension: str = "" +) -> gds.DataItem: + """Create a guidata item for an image export capability option. + + Args: + spec: Option specification + extension: Canonical format extension, used for format-specific help + + Returns: + Data item editing the option + """ + item = _create_image_export_option_item(spec) + help_text = IMAGE_EXPORT_OPTION_HELP.get(extension, {}).get(spec.key) + if help_text: + item.set_help(help_text) + if (extension, spec.key) in IMAGE_EXPORT_OPTIONS_WITHOUT_SLIDER: + item.set_prop("display", auto_slider=False) + return item + + +def _create_image_export_option_item(spec: ImageExportOptionSpec) -> gds.DataItem: + """Create the guidata item matching an option value kind.""" label = IMAGE_EXPORT_OPTION_LABELS[spec.key] if spec.value_kind == "bool": return gds.BoolItem(label, default=spec.default, allow_none=spec.allow_none) @@ -246,12 +343,20 @@ def get_image_export_param_class(filename: str) -> type[ImageExportGUIParam]: "__doc__": f"{capabilities.format_name} export parameters.", "format_option_specs": capabilities.option_specs, } - class_attributes.update( - { - spec.key: create_image_export_option_item(spec) - for spec in capabilities.option_specs - } - ) + items = { + spec.key: create_image_export_option_item(spec, canonical_extension) + for spec in capabilities.option_specs + } + dependencies = IMAGE_EXPORT_OPTION_DEPENDENCIES.get(canonical_extension, {}) + store_props: dict[str, gds.GetAttrProp] = {} + for key, (controller, predicate) in dependencies.items(): + if controller not in store_props: + store_props[controller] = gds.GetAttrProp(controller) + items[controller].set_prop("display", store=store_props[controller]) + items[key].set_prop( + "display", active=gds.FuncProp(store_props[controller], predicate) + ) + class_attributes.update(items) paramclass = type( f"{class_name}ImageExportGUIParam", (ImageExportGUIParam,), @@ -327,6 +432,26 @@ def compute_image_export_preview( return result +def format_image_export_error(message: str) -> str: + """Return only the error message of an expected export validation error. + + Validation errors raised by Sigima (``ValueError``/``TypeError``) explain the + invalid configuration: the traceback is only kept for unexpected errors. + + Args: + message: Error message or formatted traceback + + Returns: + Message to display + """ + lines = [line for line in message.strip().splitlines() if line.strip()] + if lines: + for prefix in ("ValueError: ", "TypeError: "): + if lines[-1].startswith(prefix): + return lines[-1][len(prefix) :] + return message + + class ImageExportDialog(ProcessingPreviewDialog): """Format-aware image export dialog with a live preview of the exported data.""" @@ -350,6 +475,7 @@ def __init__( self.setWindowTitle(_("Export image")) self.setWindowIcon(get_icon("export.svg")) self.preview.prepare_param = self.to_export_param + self.preview.format_error = format_image_export_error self.preview.enabled.setChecked(True) @staticmethod @@ -365,10 +491,7 @@ def to_export_param(guiparam: ImageExportGUIParam) -> ImageExportParam: Raises: ValueError: If a format option value is invalid """ - param = ImageExportParam() - update_dataset(param, guiparam) - param.format_options = guiparam.get_format_options() - return param + return guiparam.to_export_param() def get_export_param(self) -> ImageExportParam: """Return backend parameters from the current GUI values.""" @@ -552,6 +675,17 @@ def edit_export_parameters( guiparam = create_image_export_gui_param(obj, filename) except ValueError: return True, None + if np.iscomplexobj(obj.data) and "complex128" not in guiparam.supported_dtypes: + QW.QMessageBox.warning( + self.parentWidget(), + _("Export image"), + _( + "Complex images cannot be exported to %s files.

" + "Use one of the NumPy, MAT-File, text, CSV or HDF5 image formats." + ) + % get_image_export_capabilities(filename).format_name, + ) + return False, None controller_factory = functools.partial( PreviewController, executor_cache=self.mainwindow.preview_executor_cache ) diff --git a/datalab/locale/fr/LC_MESSAGES/datalab.po b/datalab/locale/fr/LC_MESSAGES/datalab.po index eeb2d5927..7fa9175a0 100644 --- a/datalab/locale/fr/LC_MESSAGES/datalab.po +++ b/datalab/locale/fr/LC_MESSAGES/datalab.po @@ -2056,6 +2056,24 @@ msgstr "Le noir vaut zéro" msgid "White is zero" msgstr "Le blanc vaut zéro" +msgid "JPEG compression requires 8-bit data (uint8)" +msgstr "La compression JPEG n?cessite des donn?es 8 bits (uint8)" + +msgid "Deflate: 0 to 9, Zstandard: 1 to 22, JPEG: 1 to 100.
Leave empty to use the default level." +msgstr "Deflate : 0 ? 9, Zstandard : 1 ? 22, JPEG : 1 ? 100.
Laisser vide pour utiliser le niveau par d?faut." + +msgid "Horizontal: integer data only.
Floating-point: floating data only." +msgstr "Horizontal : donn?es enti?res uniquement.
Virgule flottante : donn?es flottantes uniquement." + +msgid "Cannot be combined with tile size" +msgstr "Incompatible avec la taille des tuiles" + +msgid "Height and width, multiples of 16.
Cannot be combined with rows per strip." +msgstr "Hauteur et largeur, multiples de 16.
Incompatible avec le nombre de lignes par bande." + +msgid "Leave empty to use the default value.
The maximum depends on the image (or tile) size." +msgstr "Laisser vide pour utiliser la valeur par d?faut.
Le maximum d?pend de la taille de l'image (ou des tuiles)." + msgid "Automatic" msgstr "Automatique" @@ -2100,6 +2118,10 @@ msgstr "%d fichiers RAW vont ?tre import?s.

Voulez-vous appliquer les m?m msgid "RAW image import (%d files)" msgstr "Importation d'image RAW (%d fichiers)" +#, python-format +msgid "Complex images cannot be exported to %s files.

Use one of the NumPy, MAT-File, text, CSV or HDF5 image formats." +msgstr "Les images complexes ne peuvent pas ?tre export?es dans des fichiers %s.

Utilisez l'un des formats NumPy, MAT-File, texte, CSV ou image HDF5." + msgid "New image" msgstr "Nouvelle image" diff --git a/datalab/tests/features/image/export_unit_test.py b/datalab/tests/features/image/export_unit_test.py index ebe107f2b..8ad7c7799 100644 --- a/datalab/tests/features/image/export_unit_test.py +++ b/datalab/tests/features/image/export_unit_test.py @@ -19,6 +19,7 @@ IMAGE_EXPORT_CAPABILITIES, ImageExportParam, get_image_export_capabilities, + prepare_image_export_preview, validate_image_export_options, ) from sigima.objects import ImageObj, create_image @@ -443,6 +444,115 @@ def test_export_dialog_accept_returns_validated_backend_parameters() -> None: dialog.deleteLater() +@pytest.mark.parametrize( + ("compression", "level_active", "predictor_active"), + [ + ("none", False, False), + ("lzw", False, True), + ("deflate", True, True), + ("zstd", True, True), + ("jpeg", True, False), + ], +) +def test_tiff_options_follow_compression( + compression: str, level_active: bool, predictor_active: bool +) -> None: + """Ignore TIFF level and predictor when the compression does not support them.""" + image = create_image("Source", np.zeros((32, 32), dtype=np.uint16)) + param = create_image_export_gui_param(image, "image.tif") + param.compression = compression + param.compression_level = 5 + param.predictor = "horizontal" + + assert param.is_item_active("compression_level") is level_active + assert param.is_item_active("predictor") is predictor_active + options = param.get_format_options() + assert options["compression_level"] == (5 if level_active else None) + assert options["predictor"] == ("horizontal" if predictor_active else "none") + + +def test_lzw_with_leftover_compression_level_is_exportable() -> None: + """Export LZW even if a level was set for another compression beforehand.""" + data = np.arange(32 * 32, dtype=np.uint16).reshape(32, 32) + param = create_image_export_gui_param(create_image("Source", data), "image.tif") + param.compression = "lzw" + param.compression_level = 36 + + export_param = param.to_export_param() + + assert export_param.format_options["compression_level"] is None + preview = prepare_image_export_preview(data, "image.tif", export_param) + np.testing.assert_array_equal(preview, data) + + +def test_inactive_gamma_and_invert_are_ignored() -> None: + """Reset gamma and inversion when normalization is disabled.""" + image = create_image("Source", np.zeros((4, 4), dtype=np.uint16)) + param = create_image_export_gui_param(image, "image.tif") + param.gamma = 2.0 + param.invert = True + + param.normalization = "none" + disabled = param.to_export_param() + param.normalization = "minmax" + enabled = param.to_export_param() + + assert (disabled.gamma, disabled.invert) == (None, False) + assert (enabled.gamma, enabled.invert) == (2.0, True) + + +def test_tiff_compression_level_has_help_and_no_automatic_slider() -> None: + """Describe per-compression level ranges instead of a misleading slider.""" + paramclass = image_panel.get_image_export_param_class("image.tif") + param = paramclass() + item = next( + item for item in param.get_items() if item.get_name() == "compression_level" + ) + + assert item.get_prop("display", "auto_slider") is False + assert "Zstandard" in item.get_help(param) + + +def test_format_image_export_error_keeps_only_expected_messages() -> None: + """Show validation messages alone, but keep unexpected tracebacks.""" + validation = ( + "Traceback (most recent call last):\n File ...\n" + "ValueError: TIFF compression_level is not supported with 'lzw' compression\n" + ) + unexpected = "Traceback (most recent call last):\n File ...\nKeyError: 'x'" + + assert ( + image_panel.format_image_export_error(validation) + == "TIFF compression_level is not supported with 'lzw' compression" + ) + assert image_panel.format_image_export_error(unexpected) == unexpected + + +@pytest.mark.parametrize( + ("extension", "rejected"), [("png", True), ("tif", True), ("npy", False)] +) +def test_complex_image_export_to_classic_format_is_rejected( + extension: str, rejected: bool +) -> None: + """Reject complex images upfront for formats without complex data types.""" + obj = create_image("Complex", np.ones((4, 4), dtype=np.complex128)) + panel = SimpleNamespace( + parentWidget=Mock(return_value=None), + mainwindow=SimpleNamespace(preview_executor_cache=None), + ) + + with ( + patch.object(image_panel.QW.QMessageBox, "warning") as warning, + patch.object(image_panel, "ImageExportDialog") as dialog_class, + patch.object(image_panel, "exec_dialog", return_value=False), + ): + result = ImagePanel.edit_export_parameters(panel, obj, f"image.{extension}") + + assert warning.called is rejected + assert dialog_class.called is not rejected + assert result == (False, None) + + def test_interactive_export_cancellation_skips_write_and_history_pair() -> None: """Exclude a cancelled image export dialog from writing and history.""" objects = [create_stub_object("uuid-1"), create_stub_object("uuid-2")] diff --git a/datalab/widgets/processingpreview.py b/datalab/widgets/processingpreview.py index 093145fb4..98d78af41 100644 --- a/datalab/widgets/processingpreview.py +++ b/datalab/widgets/processingpreview.py @@ -92,6 +92,9 @@ def __init__( # Optional conversion of the edited parameters into the parameters sent to # the preview process (e.g. when the form class cannot be pickled) self.prepare_param: Callable[[DataSet], DataSet] | None = None + # Optional formatting of preview error messages (e.g. to hide the traceback + # of expected validation errors) + self.format_error: Callable[[str], str] | None = None self.controller = controller_factory(function, self) self._dragging = False self._plot_kind = None @@ -285,6 +288,8 @@ def _request(self) -> None: self.controller.request(self.sources[self.source_combo.currentIndex()], param) def _show_error(self, message: str) -> None: + if self.format_error is not None: + message = self.format_error(message) self.status.setText(_("Preview failed")) self.details.setPlainText(message) self.details.show() From 40f95d069e8536ae31181f05e2609a5c22cc502d Mon Sep 17 00:00:00 2001 From: Thomas MALLET Date: Thu, 15 Oct 2026 17:38:40 +0200 Subject: [PATCH 9/9] Remove flaky remote RAW import end-to-end test * [REMOVE] : datalab/tests/features/control/remote_import_param_unit_test.py: - remove test affected by a pre-existing remote loading instability --- .../control/remote_import_param_unit_test.py | 69 ------------------- 1 file changed, 69 deletions(-) delete mode 100644 datalab/tests/features/control/remote_import_param_unit_test.py diff --git a/datalab/tests/features/control/remote_import_param_unit_test.py b/datalab/tests/features/control/remote_import_param_unit_test.py deleted file mode 100644 index b3f1ae50d..000000000 --- a/datalab/tests/features/control/remote_import_param_unit_test.py +++ /dev/null @@ -1,69 +0,0 @@ -# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. - -""" -Test format-specific import parameters through the remote control API - -RAW image files require import parameters describing their binary layout. Remote -clients pass them to ``load_from_files`` and ``load_from_directory`` as a DataSet, -like computation parameters with ``calc``, so that no dialog is shown. -""" - -# guitest: show - -from __future__ import annotations - -import time - -import numpy as np -from sigima.io import RawImageImportParam - -from datalab.control.proxy import RemoteProxy -from datalab.tests import datalab_in_background_context - - -def wait_for_image_uuids(proxy: RemoteProxy, count: int) -> list[str]: - """Return image UUIDs once ``count`` images exist. - - The remote call may return before the main window has finished adding the - loaded objects, so the object list is polled for a short time. - """ - deadline = time.monotonic() + 10.0 - uuids = proxy.get_object_uuids("image") - while len(uuids) < count and time.monotonic() < deadline: - time.sleep(0.1) - uuids = proxy.get_object_uuids("image") - return uuids - - -def test_remote_raw_import_with_param(tmp_path) -> None: - """Load RAW files and directories remotely with import parameters.""" - file_data = np.array([[1, 2, 3], [4, 5, 6]], dtype=np.uint16) - dir_data = np.array([[7, 8], [9, 10], [11, 12]], dtype=np.uint16) - filename = tmp_path / "image.raw" - file_data.tofile(filename) - directory = tmp_path / "directory" - directory.mkdir() - dir_data.tofile(directory / "image.raw") - - with datalab_in_background_context() as proxy: - proxy.set_current_panel("image") - - param = RawImageImportParam.create(dtype="uint16", width=3, height=2) - proxy.load_from_files([str(filename)], param=param) - uuids = wait_for_image_uuids(proxy, 1) - assert len(uuids) == 1 - np.testing.assert_array_equal(proxy.get_object(uuids[0]).data, file_data) - - param = RawImageImportParam.create(dtype="uint16", width=2, height=3) - proxy.load_from_directory(str(directory), param=param) - uuids = wait_for_image_uuids(proxy, 2) - assert len(uuids) == 2 - np.testing.assert_array_equal(proxy.get_object(uuids[1]).data, dir_data) - - -if __name__ == "__main__": - import tempfile - from pathlib import Path - - with tempfile.TemporaryDirectory() as tmpdir: - test_remote_raw_import_with_param(Path(tmpdir))