diff --git a/DataLab.spec b/DataLab.spec
index e1cbd2781..562a4a404 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/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 c41d45d30..a7812b637 100644
--- a/datalab/control/baseproxy.py
+++ b/datalab/control/baseproxy.py
@@ -283,19 +283,29 @@ def open_capsule(self, filename: str, workspace: str | None = None) -> 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
@@ -886,21 +896,31 @@ def open_capsule(self, filename: str, workspace: str | None = None) -> None:
"""
self._datalab.open_capsule(filename, workspace)
- 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 40275fb69..1cf687ab1 100644
--- a/datalab/control/remote.py
+++ b/datalab/control/remote.py
@@ -88,8 +88,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)
@@ -412,22 +412,32 @@ def open_capsule(self, filename: str, workspace: str | None = None) -> None:
)
@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(
@@ -1193,6 +1203,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 a92da45f5..f3bb16716 100644
--- a/datalab/gui/main.py
+++ b/datalab/gui/main.py
@@ -2581,24 +2581,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 bf4c90429..d7ec0d105 100644
--- a/datalab/gui/panel/base.py
+++ b/datalab/gui/panel/base.py
@@ -120,6 +120,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
@@ -3206,7 +3207,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.
@@ -3215,12 +3220,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
@@ -3240,22 +3253,94 @@ def __load_from_file(
self.selection_changed()
return objs
- def __save_to_file(self, obj: TypeObj, filename: str) -> None:
+ 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:
+ filenames: File names to import.
+ param: Optional import parameters applied to every file requiring
+ parameters, without showing any dialog.
+
+ Returns:
+ File names with aligned import parameters, without the files whose
+ parameter dialog was cancelled, or None if the whole import was
+ cancelled.
+
+ Raises:
+ TypeError: If ``param`` is given, as this panel has no import parameters.
+ """
+ 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,
+ 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]:
+ """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]:
+ 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
@@ -3268,6 +3353,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(
@@ -3275,13 +3408,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):
@@ -3289,23 +3417,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
@@ -3325,6 +3450,8 @@ def load_from_files(
create_group: bool = False,
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.
@@ -3335,10 +3462,17 @@ 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.
+ 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
@@ -3348,9 +3482,22 @@ 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)
+ if import_params is None:
+ 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]
+ import_params = [param for _filename, param in prepared_pairs]
+ nbf = len(prepared_pairs)
if nbf > 1:
entry_title = _("Load from %d files") % nbf
else:
@@ -3363,21 +3510,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:
@@ -3385,6 +3541,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:
@@ -3396,6 +3553,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
@@ -3405,28 +3563,68 @@ 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
- 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()
@@ -3435,25 +3633,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.
@@ -3512,7 +3730,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..f6de70f77 100644
--- a/datalab/gui/panel/image.py
+++ b/datalab/gui/panel/image.py
@@ -8,9 +8,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 numpy as np
+from guidata.configtools import get_icon
+from guidata.dataset import update_dataset
+from guidata.qthelpers import exec_dialog
from plotpy.interfaces import IVoiImageItemType
from plotpy.tools import (
AnnotatedCircleTool,
@@ -20,25 +29,496 @@
AnnotatedSegmentTool,
LabelTool,
)
+from qtpy import QtWidgets as QW
+from sigima.io import (
+ ImageExportOptionSpec,
+ ImageExportParam,
+ RawImageImportParam,
+ 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
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
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
- 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"),
+}
+
+
+# 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]]:
+ """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",)
+ 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 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.
+
+ 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."""
+ 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, 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)
+ 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,
+ }
+ 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,),
+ class_attributes,
+ )
+ IMAGE_EXPORT_PARAM_CLASSES[canonical_extension] = paramclass
+ return paramclass
+
+
+def is_raw_image_file(filename: str) -> bool:
+ """Return True if the file is a headerless RAW image requiring parameters.
+
+ 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:
+ """Create format-aware image export parameters with practical defaults.
+
+ Args:
+ obj: Source image
+ filename: Destination file name
+
+ Returns:
+ Image export GUI parameters
+ """
+ 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
+ forces_integer_conversion = concrete_dtypes and all(
+ np.issubdtype(np.dtype(dtype), np.integer) for dtype in concrete_dtypes
+ )
+ 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
+
+
+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
+
+
+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."""
+
+ def __init__(
+ self,
+ parent: QW.QWidget | None,
+ obj: ImageObj,
+ filename: str,
+ param: ImageExportGUIParam,
+ controller_factory: Callable = PreviewController,
+ ) -> None:
+ self.obj = obj
+ self.filename = filename
+ super().__init__(
+ param,
+ functools.partial(compute_image_export_preview, filename=filename),
+ [obj],
+ parent,
+ controller_factory,
+ )
+ 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
+ def to_export_param(guiparam: ImageExportGUIParam) -> ImageExportParam:
+ """Return backend export parameters from GUI parameters.
+
+ Args:
+ guiparam: Format-specific GUI parameters
+
+ Returns:
+ Backend image export parameters
+
+ Raises:
+ ValueError: If a format option value is invalid
+ """
+ return guiparam.to_export_param()
+
+ 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 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.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]):
"""Object handling the item list, the selected item properties and plot,
specialized for Image objects"""
@@ -75,6 +555,166 @@ def get_roieditor_class() -> Type[roieditor.ImageROIEditor]:
"""Return ROI editor class"""
return roieditor.ImageROIEditor
+ 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 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:
+ 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:
+ 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.
+ """
+ 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 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:
+ raw_files: RAW file names.
+
+ Returns:
+ Parameters of the confirmed files, or None if the import was cancelled.
+ """
+ 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)})"
+ )
+ # 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
+ ) -> 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]:
+ """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 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
+ )
+ 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,
+ 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/action.py b/datalab/history/action.py
index 85dc0f44b..3eedac7db 100644
--- a/datalab/history/action.py
+++ b/datalab/history/action.py
@@ -303,8 +303,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}}``
@@ -344,6 +345,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/history/core.py b/datalab/history/core.py
index 2a60badb3..504e1e351 100644
--- a/datalab/history/core.py
+++ b/datalab/history/core.py
@@ -36,6 +36,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__"
@@ -115,7 +116,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,
@@ -138,10 +139,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
@@ -183,7 +190,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 71ecc9e9d..4c24fa419 100644
--- a/datalab/locale/fr/LC_MESSAGES/datalab.po
+++ b/datalab/locale/fr/LC_MESSAGES/datalab.po
@@ -65,6 +65,9 @@ msgstr "Assistant IA prêt. Configurez votre fournisseur dans le menu Edition >
msgid "You"
msgstr "Vous"
+msgid "Assistant"
+msgstr "Assistant"
+
msgid "Save to Macros"
msgstr "Enregistrer dans les macros"
@@ -311,9 +314,6 @@ msgstr ""
"\n"
"Souhaitez-vous néanmoins continuer ?"
-msgid "The registry address must be an http or https URL"
-msgstr "L'adresse du registre doit être une URL http ou https"
-
msgid "DataLab is a generic signal and image processing platform"
msgstr "DataLab est une plateforme générique de traitement du signal et de l'image"
@@ -940,32 +940,6 @@ msgstr "Afficher le panneau de contraste"
msgid "Show or hide contrast adjustment panel"
msgstr "Afficher ou cacher le panneau de réglage du contraste"
-#, python-format
-msgid "%s too large: %s, %s MiB (limit: %s MiB)"
-msgstr "%s : taille excessive, %s, %s Mio (limite : %s Mio)"
-
-msgid "Capsule"
-msgstr "Capsule"
-
-msgid "Referenced workspace"
-msgstr "Espace de travail référencé"
-
-msgid "Workspace"
-msgstr "Espace de travail"
-
-#, python-format
-msgid "Attachments are limited to 16 MiB: %s"
-msgstr "Les pièces jointes sont limitées à 16 Mio : %s"
-
-msgid "Attachment"
-msgstr "Pièce jointe"
-
-msgid "The capsule, its referenced workspace and its attachments must be different files"
-msgstr "La capsule, son espace de travail référencé et ses pièces jointes doivent être des fichiers différents"
-
-msgid "This capsule references its workspace: select the workspace file"
-msgstr "Cette capsule référence son espace de travail : sélectionnez le fichier de l'espace de travail"
-
msgid "Command palette"
msgstr "Palette de commandes"
@@ -981,26 +955,10 @@ msgstr "Signal personnalisé"
msgid "Process signal"
msgstr "Traiter le signal"
-msgid "The data will be added, but their earlier provenance will not be imported. Open the file replacing the current workspace to keep it."
-msgstr "Les données seront ajoutées, mais leur provenance antérieure ne sera pas importée. Ouvrez le fichier en remplaçant l'espace de travail actuel pour la conserver."
-
#, python-format
msgid "Loading data from %s..."
msgstr "Chargement des données depuis %s..."
-#, python-format
-msgid "The provenance data of this workspace are invalid: %s"
-msgstr "Les données de provenance de cet espace de travail sont invalides : %s"
-
-msgid "Warning"
-msgstr "Attention"
-
-msgid "Add without provenance"
-msgstr "Ajouter sans la provenance"
-
-msgid "This file is not a DataLab workspace"
-msgstr "Ce fichier n'est pas un espace de travail DataLab"
-
msgid "Clipping uint32 data to int32."
msgstr "Troncature de données uint32 en int32."
@@ -1057,9 +1015,6 @@ 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'..."
@@ -1106,12 +1061,6 @@ msgstr "Préférences..."
msgid "Open settings dialog"
msgstr "Ouvrir la boîte de dialogue des préférences"
-msgid "Workspace capsule..."
-msgstr "Capsule d'espace de travail..."
-
-msgid "Export, inspect, replay and compare workspace capsules (workspace and provenance of its processing)"
-msgstr "Exporter, inspecter, rejouer et comparer des capsules d'espace de travail (espace de travail et provenance de ses traitements)"
-
msgid "Command palette..."
msgstr "Palette de commandes..."
@@ -1247,17 +1196,8 @@ msgstr "Vous devrez cliquer manuellement sur le bouton de rafraîchissement pour
msgid "Are you sure you want to disable auto-refresh?"
msgstr "Êtes-vous sûr de vouloir désactiver le rafraîchissement automatique ?"
-msgid "Export capsule"
-msgstr "Exporter une capsule"
-
-msgid "Open capsule"
-msgstr "Ouvrir une capsule"
-
-msgid "Open"
-msgstr "Ouvrir"
-
-msgid "HDF5 files (*.h5 *.hdf5 *.hdf *.he5);;All files (*)"
-msgstr "Fichiers HDF5 (*.h5 *.hdf5 *.hdf *.he5);;Tous les fichiers (*)"
+msgid "Save to HDF5 file"
+msgstr "Enregistrer dans un fichier HDF5"
#, python-format
msgid "Open %d HDF5 files"
@@ -1266,9 +1206,6 @@ msgstr "Ouvrir %d fichiers HDF5"
msgid "Open HDF5 file"
msgstr "Ouvrir un fichier HDF5"
-msgid "Save to HDF5 file"
-msgstr "Enregistrer dans un fichier HDF5"
-
msgid "Do you want to clear current workspace (signals and images) before importing data from HDF5 files?"
msgstr "Souhaitez-vous vider l'espace de travail actuel (signaux et images) avant d'importer des données depuis des fichiers HDF5 ?"
@@ -1285,12 +1222,6 @@ msgstr "Oui"
msgid "No"
msgstr "Non"
-msgid "Provenance:"
-msgstr "Provenance :"
-
-msgid "If you choose No: "
-msgstr "Si vous choisissez Non : "
-
#, python-format
msgid "New %s"
msgstr "Créer un nouvel objet %s"
@@ -1391,8 +1322,9 @@ msgstr ""
msgid "e.g. %s"
msgstr "ex. %s"
-msgid "Provenance"
-msgstr "Provenance"
+#, fuzzy
+msgid "History"
+msgstr "Historique…"
msgid "Analysis parameters"
msgstr "Paramètres d'analyse"
@@ -1410,6 +1342,9 @@ msgstr "Créé : paramètres de pic historiques"
msgid "Created: invalid creation parameters"
msgstr "Créé : paramètres de création invalides"
+msgid "Created"
+msgstr "Créé"
+
msgid "Original object"
msgstr "Objet original"
@@ -1465,21 +1400,6 @@ msgstr "Recalcul automatique lors de l'édition"
msgid "Automatically re-run processing when parameters are modified"
msgstr "Relancer automatiquement le traitement à chaque modification de paramètre"
-msgid "This processing has no parameters."
-msgstr "Ce traitement n'a pas de paramètres."
-
-msgid "The parameters of this processing cannot be edited here."
-msgstr "Les paramètres de ce traitement ne peuvent pas être modifiés ici."
-
-msgid "Reapply"
-msgstr "Réappliquer"
-
-msgid "Apply this processing again to the current input objects"
-msgstr "Appliquer à nouveau ce traitement aux objets d'entrée actuels"
-
-msgid "(deleted object)"
-msgstr "(objet supprimé)"
-
msgid "Analysis Parameters"
msgstr "Paramètres d'analyse"
@@ -1506,6 +1426,12 @@ msgstr "Aucun objet de traitement disponible."
msgid "Processing metadata is incomplete."
msgstr "Les métadonnées de traitement sont incomplètes."
+msgid "Source object no longer exists."
+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."
+
msgid "Geometry results"
msgstr "Résultats géométriques"
@@ -1630,12 +1556,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 "New group"
-msgstr "Nouveau groupe"
-
msgid "Group name:"
msgstr "Nom du groupe :"
+msgid "New group"
+msgstr "Nouveau groupe"
+
#, python-format
msgid "New group \"%s\""
msgstr "Nouveau groupe \"%s\""
@@ -1662,6 +1588,9 @@ msgstr "Lecture des objets depuis le fichier"
msgid "Adding objects to workspace"
msgstr "Ajout d'objets à l'espace de travail"
+msgid "Open"
+msgstr "Ouvrir"
+
#, python-format
msgid "Load from directory \"%s\""
msgstr "Charger depuis le répertoire \"%s\""
@@ -1677,6 +1606,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"
@@ -1877,6 +1810,9 @@ msgstr "L'action %s a été ignorée : ses paramètres enregistrés n'ont pas pu
msgid "History panel"
msgstr "Panneau d'historique"
+msgid "History files"
+msgstr "Fichiers d'historique"
+
#, python-format
msgid "Action %s: target panel not found — skipping."
msgstr "Action %s : panneau cible introuvable — ignorée."
@@ -2007,11 +1943,157 @@ 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 "Show execution provenance"
-msgstr "Afficher la provenance de l'exécution"
+msgid "Compression level"
+msgstr "Niveau de compression"
-msgid "Show the recorded provenance of the last successful execution of the selected action"
-msgstr "Afficher la provenance enregistrée de la dernière exécution réussie de l'action sélectionnée"
+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 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"
@@ -2031,6 +2113,9 @@ msgstr "Effacer toutes les macros récentes ?"
msgid "Macro panel"
msgstr "Panneau des macros"
+msgid "Python files"
+msgstr "Fichiers Python"
+
msgid "Clear console"
msgstr "Effacer la console"
@@ -2115,66 +2200,185 @@ msgstr "Création des formes géométriques"
msgid "Creating plot items"
msgstr "Création des objets graphiques"
-#, python-format
-msgid "Version: %s"
-msgstr "Version : %s"
+msgid "today"
+msgstr "aujourd'hui"
-msgid "Expected inputs"
-msgstr "Entrées attendues"
+msgid "yesterday"
+msgstr "hier"
-msgid "Run on selection..."
-msgstr "Exécuter sur la sélection..."
+#, python-format
+msgid "Last loaded: %s at %s"
+msgstr "Dernier chargement : %s à %s"
-msgid "Examples designed for this method"
-msgstr "Exemples conçus pour cette méthode"
+msgid "Active"
+msgstr "Actif"
-msgid "Try with this example"
-msgstr "Essayer avec cet exemple"
+msgid "Disabled"
+msgstr "Désactivé"
-msgid "The current selection could not be assessed"
-msgstr "La sélection courante n'a pas pu être évaluée"
+msgid "No description available"
+msgstr "Aucune description disponible"
-msgid "Show on welcome page"
-msgstr "Afficher sur la page d'accueil"
+msgid "Open file"
+msgstr "Ouvrir le fichier"
-msgid "Pin to the welcome page"
-msgstr "Épingler sur la page d'accueil"
+msgid "Show in folder"
+msgstr "Afficher dans le dossier"
-msgid "Documentation"
-msgstr "Documentation"
+msgid "Import error"
+msgstr "Erreur d'importation"
-#, python-format
-msgid "Plugin ID: %s"
-msgstr "ID du plugin : %s"
+msgid "Edit directory"
+msgstr "Modifier le répertoire"
-msgid "No methods declared"
-msgstr "Aucune méthode déclarée"
+msgid "Remove directory"
+msgstr "Supprimer le répertoire"
-msgid "Open tool"
-msgstr "Ouvrir l'outil"
+msgid "from"
+msgstr "depuis"
-#, python-format
-msgid "Tools (%d)"
-msgstr "Outils (%d)"
+msgid "Plugin Configuration"
+msgstr "Configuration des plugins"
-msgid "Open dataset"
-msgstr "Ouvrir le jeu de données"
+msgid "Enable/disable plugins"
+msgstr "Activer/désactiver les plugins"
-#, python-format
-msgid "Datasets (%d)"
-msgstr "Jeux de données (%d)"
+msgid "Plugin settings"
+msgstr "Paramètres des plugins"
-#, python-format
-msgid "Created %d objects"
-msgstr "%d objets créés"
+msgid "Apply and reload plugins"
+msgstr "Appliquer et recharger les plugins"
-msgid "Search applications..."
-msgstr "Rechercher des applications..."
+msgid "Third-party plugins are globally disabled."
+msgstr "Les plugins tiers sont désactivés globalement."
-msgid "Hide the application list"
-msgstr "Masquer la liste des applications"
+msgid "Changes will be applied after clicking OK and reloading plugins."
+msgstr "Les modifications seront appliquées après avoir cliqué sur OK et rechargé les plugins."
-msgid "Show the application list"
+msgid "The following directories are scanned at startup for plugins:"
+msgstr "Les répertoires suivants sont analysés au démarrage pour détecter les plugins :"
+
+msgid "Default plugin directories"
+msgstr "Répertoires de plugins par défaut"
+
+msgid "Additional plugin directories"
+msgstr "Répertoires de plugins supplémentaires"
+
+msgid "Add"
+msgstr "Ajouter"
+
+msgid "Additional directories are saved in DataLab configuration."
+msgstr "Les répertoires supplémentaires sont enregistrés dans la configuration de DataLab."
+
+msgid "No additional plugin directory is configured."
+msgstr "Aucun répertoire de plugins supplémentaire n'est configuré."
+
+#, python-format
+msgid "Directories provided via the %s environment variable (multiple paths separated by '%s') also appear above as read-only entries. Changes take effect at DataLab startup."
+msgstr "Les répertoires fournis via la variable d'environnement %s (plusieurs chemins séparés par '%s') apparaissent également ci-dessus en lecture seule. Les modifications prennent effet au démarrage de DataLab."
+
+msgid "Compatibility warnings"
+msgstr "Avertissements de compatibilité"
+
+msgid "Hide warnings for incompatible DataLab v0.20 plugins"
+msgstr "Masquer les avertissements pour les plugins DataLab v0.20 incompatibles"
+
+msgid "If enabled, DataLab will not warn you about v0.20 plugins that are no longer compatible with v1.0."
+msgstr "Si activé, DataLab ne vous avertira pas des plugins v0.20 qui ne sont plus compatibles avec v1.0."
+
+msgid "Select plugin directory"
+msgstr "Sélectionner un répertoire de plugins"
+
+msgid "Enable all plugins"
+msgstr "Activer tous les plugins"
+
+msgid "Filter:"
+msgstr "Filtre :"
+
+msgid "All plugins"
+msgstr "Tous les plugins"
+
+msgid "Enabled plugins"
+msgstr "Plugins activés"
+
+msgid "Disabled plugins"
+msgstr "Plugins désactivés"
+
+msgid "Plugins with errors"
+msgstr "Plugins en erreur"
+
+msgid "Disable plugins globally"
+msgstr "Désactiver globalement les plugins"
+
+msgid "Enable plugins globally"
+msgstr "Activer globalement les plugins"
+
+msgid "Reload Plugins"
+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 ?"
+
+#, 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."
@@ -2198,12 +2402,8 @@ msgstr "Officiel"
msgid "Community"
msgstr "Communauté"
-msgid "Local catalog"
-msgstr "Catalogue local"
-
-#, python-format
-msgid "Local catalog: %s"
-msgstr "Catalogue local : %s"
+msgid "Source code"
+msgstr "Code source"
#, python-format
msgid "Withdrawn: %s"
@@ -2238,8 +2438,8 @@ msgstr "Mettre à jour vers %s"
msgid "Install"
msgstr "Installer"
-#, python-format
-msgid "Plugins listed in the DataLab plugin catalog and in the local catalogs of the Plugin settings tab, which take precedence. Official plugins are maintained by the DataLab team, community plugins by their authors, and local catalogs by your organization: install only plugins from authors you trust."
+#, fuzzy, 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 et dans les catalogues locaux de l'onglet Paramètres des plugins, qui sont prioritaires. Les plugins officiels sont maintenus par l'équipe DataLab, les plugins de la communauté par leurs auteurs et les catalogues locaux par votre organisation : n'installez que des plugins provenant d'auteurs de confiance."
msgid "Search plugins"
@@ -2248,17 +2448,14 @@ msgstr "Rechercher des plugins"
msgid "No plugin catalog is configured."
msgstr "Aucun catalogue de plugins n'est configuré."
-msgid "Loading the plugin catalogs..."
+#, fuzzy
+msgid "Loading the plugin catalog..."
msgstr "Chargement des catalogues de plugins..."
#, python-format
msgid "Downloading %s..."
msgstr "Téléchargement de %s..."
-#, python-format
-msgid "Plugins in the catalogs: %d"
-msgstr "Plugins dans les catalogues : %d"
-
#, python-format
msgid "Plugins in the catalog: %d"
msgstr "Plugins dans le catalogue : %d"
@@ -2266,10 +2463,6 @@ 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 "Local plugin catalog (%s)"
-msgstr "Catalogue local de plugins (%s)"
-
#, python-format
msgid "DataLab plugin catalog (%s)"
msgstr "Catalogue de plugins DataLab (%s)"
@@ -2283,218 +2476,86 @@ 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 "Available plugins"
+msgstr "Plugins disponibles"
-msgid "Active"
-msgstr "Actif"
+msgid "Install plugins"
+msgstr "Installer des plugins"
-msgid "Disabled"
-msgstr "Désactivé"
+msgid "Source:"
+msgstr "Source :"
-msgid "No description available"
-msgstr "Aucune description disponible"
+msgid "Plugin:"
+msgstr "Plugin :"
-msgid "Open file"
-msgstr "Ouvrir le fichier"
+msgid "Version:"
+msgstr "Version :"
-msgid "Show in folder"
-msgstr "Afficher dans le dossier"
+msgid "Description:"
+msgstr "Description :"
-msgid "Import error"
-msgstr "Erreur d'importation"
+msgid "Plugin classes:"
+msgstr "Classes de plugin :"
-msgid "Edit directory"
-msgstr "Modifier le répertoire"
+msgid "not applicable"
+msgstr "non applicable"
-msgid "Remove directory"
-msgstr "Supprimer le répertoire"
+msgid "Dependencies:"
+msgstr "Dépendances :"
-msgid "Plugin Configuration"
-msgstr "Configuration des plugins"
+msgid "Plugin module:"
+msgstr "Module de plugin :"
-msgid "Enable/disable plugins"
-msgstr "Activer/désactiver les plugins"
+msgid "File:"
+msgstr "Fichier :"
-msgid "Plugin settings"
-msgstr "Paramètres des plugins"
+msgid "Size:"
+msgstr "Taille :"
-msgid "Available plugins"
-msgstr "Plugins disponibles"
+msgid "SHA-256:"
+msgstr "SHA-256 :"
-msgid "Install plugins"
-msgstr "Installer des plugins"
+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."
-msgid "Apply and reload plugins"
-msgstr "Appliquer et recharger les plugins"
+#, python-format
+msgid "Installed on %s from %s"
+msgstr "Installé le %s depuis %s"
-msgid "Third-party plugins are globally disabled."
-msgstr "Les plugins tiers sont désactivés globalement."
+msgid "Uninstall"
+msgstr "Désinstaller"
-msgid "Changes will be applied after clicking OK and reloading plugins."
-msgstr "Les modifications seront appliquées après avoir cliqué sur OK et rechargé les plugins."
+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 "The following directories are scanned at startup for plugins:"
-msgstr "Les répertoires suivants sont analysés au démarrage pour détecter les plugins :"
+msgid "Install from file..."
+msgstr "Installer depuis un fichier..."
-msgid "Default plugin directories"
-msgstr "Répertoires de plugins par défaut"
+msgid "Plugins installed from a file"
+msgstr "Plugins installés depuis un fichier"
-msgid "Additional plugin directories"
-msgstr "Répertoires de plugins supplémentaires"
+msgid "No plugin has been installed from a file."
+msgstr "Aucun plugin n'a été installé depuis un fichier."
-msgid "Add"
-msgstr "Ajouter"
+msgid "Install plugin from file"
+msgstr "Installer un plugin depuis un fichier"
-msgid "Additional directories are saved in DataLab configuration."
-msgstr "Les répertoires supplémentaires sont enregistrés dans la configuration de DataLab."
+msgid "DataLab plugins"
+msgstr "Plugins DataLab"
-msgid "No additional plugin directory is configured."
-msgstr "Aucun répertoire de plugins supplémentaire n'est configuré."
+msgid "This file cannot be installed as a plugin:"
+msgstr "Ce fichier ne peut pas être installé comme plugin :"
#, python-format
-msgid "Directories provided via the %s environment variable (multiple paths separated by '%s') also appear above as read-only entries. Changes take effect at DataLab startup."
-msgstr "Les répertoires fournis via la variable d'environnement %s (plusieurs chemins séparés par '%s') apparaissent également ci-dessus en lecture seule. Les modifications prennent effet au démarrage de DataLab."
-
-msgid "Local plugin catalogs"
-msgstr "Catalogues locaux de plugins"
+msgid "%s will be loaded at the next start of DataLab."
+msgstr "%s sera chargé au prochain démarrage de DataLab."
-msgid "Folders, possibly shared on the network, whose plugins are listed in the Available plugins tab before those of the DataLab plugin catalog. A folder provides the plugins of its catalog.json file if any, otherwise its plugin wheels."
-msgstr "Dossiers, éventuellement partagés sur le réseau, dont les plugins sont proposés dans l'onglet Plugins disponibles avant ceux du catalogue de plugins DataLab. Un dossier fournit les plugins de son fichier catalog.json s'il existe, sinon ses wheels de plugins."
+#, python-format
+msgid "%s has been installed."
+msgstr "%s a été installé."
-msgid "No local plugin catalog is configured."
-msgstr "Aucun catalogue local de plugins n'est configuré."
-
-#, python-format
-msgid "Folders provided via the %s environment variable (multiple paths separated by '%s') come first and appear above as read-only entries."
-msgstr "Les dossiers fournis via la variable d'environnement %s (plusieurs chemins séparés par '%s') sont prioritaires et apparaissent ci-dessus en lecture seule."
-
-msgid "Compatibility warnings"
-msgstr "Avertissements de compatibilité"
-
-msgid "Hide warnings for incompatible DataLab v0.20 plugins"
-msgstr "Masquer les avertissements pour les plugins DataLab v0.20 incompatibles"
-
-msgid "If enabled, DataLab will not warn you about v0.20 plugins that are no longer compatible with v1.0."
-msgstr "Si activé, DataLab ne vous avertira pas des plugins v0.20 qui ne sont plus compatibles avec v1.0."
-
-msgid "Select plugin directory"
-msgstr "Sélectionner un répertoire de plugins"
-
-msgid "Select local plugin catalog folder"
-msgstr "Sélectionner un dossier de catalogue local de plugins"
-
-msgid "Enable all plugins"
-msgstr "Activer tous les plugins"
-
-msgid "Filter:"
-msgstr "Filtre :"
-
-msgid "All plugins"
-msgstr "Tous les plugins"
-
-msgid "Enabled plugins"
-msgstr "Plugins activés"
-
-msgid "Disabled plugins"
-msgstr "Plugins désactivés"
-
-msgid "Plugins with errors"
-msgstr "Plugins en erreur"
-
-msgid "Disable plugins globally"
-msgstr "Désactiver globalement les plugins"
-
-msgid "Enable plugins globally"
-msgstr "Activer globalement les plugins"
-
-msgid "Reload Plugins"
-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 "Source:"
-msgstr "Source :"
-
-msgid "Plugin:"
-msgstr "Plugin :"
-
-msgid "Version:"
-msgstr "Version :"
-
-msgid "Description:"
-msgstr "Description :"
-
-msgid "Plugin classes:"
-msgstr "Classes de plugin :"
-
-msgid "Dependencies:"
-msgstr "Dépendances :"
-
-msgid "None"
-msgstr "Aucun"
-
-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"
+msgid "Uninstall plugin"
+msgstr "Désinstaller le plugin"
#, python-format
msgid "Do you want to uninstall %s?"
@@ -2587,9 +2648,6 @@ msgstr "%(slot)s : métadonnée '%(key)s' absente de %(count)d objet(s) (%(title
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"
@@ -2658,27 +2716,8 @@ msgstr ""
msgid "Yes to All"
msgstr "Oui à tout"
-msgid "The signals have different units: no conversion is applied."
-msgstr "Les signaux ont des unités différentes : aucune conversion n'est appliquée."
-
-msgid "To interpolate the operand, the X values of both signals must be finite and strictly increasing."
-msgstr "Pour interpoler l'opérande, les abscisses des deux signaux doivent être finies et strictement croissantes."
-
-msgid "The X range of the operand does not cover the X range of the source: extrapolation is not allowed."
-msgstr "L'intervalle des abscisses de l'opérande ne couvre pas celui de la source : l'extrapolation n'est pas autorisée."
-
-msgid "The operand has uncertainties, which cannot be interpolated."
-msgstr "L'opérande comporte des incertitudes, qui ne peuvent pas être interpolées."
-
-msgid "The signals cannot be aligned."
-msgstr "Les signaux ne peuvent pas être alignés."
-
-#, python-format
-msgid "Calculating: %s"
-msgstr "Calcul : %s"
-
-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."
+msgid "Processing metadata is incomplete (missing source UUID)."
+msgstr "Les métadonnées de traitement sont incomplètes (UUID source manquant)."
#, python-format
msgid ""
@@ -2709,6 +2748,10 @@ 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."
@@ -2719,8 +2762,8 @@ 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 "Computing: %s"
-msgstr "Calcul en cours : %s"
+msgid "Calculating: %s"
+msgstr "Calcul : %s"
#, python-format
msgid "Select %s"
@@ -3093,12 +3136,6 @@ msgstr "Filtre de Sobel (horizontal)"
msgid "Sobel filter (vertical)"
msgstr "Filtre de Sobel (vertical)"
-msgid "Extract ROI"
-msgstr "Extraire une ROI"
-
-msgid "Translate"
-msgstr "Translation"
-
msgid "Resampling"
msgstr "Rééchantillonnage"
@@ -3177,6 +3214,9 @@ msgstr "Détection de taches basée sur SimpleBlobDetector d'OpenCV"
msgid "Creating a ROI grid will overwrite any existing ROI.
Do you want to continue?"
msgstr "La création d'une grille de ROI écrasera toute ROI existante.
Voulez-vous continuer ?"
+msgid "Extract ROI"
+msgstr "Extraire une ROI"
+
msgid "Do you want to extract images from the defined ROI?"
msgstr "Souhaitez-vous extraire les images de la ROI définie ?"
@@ -4246,6 +4286,18 @@ 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 "Error"
+msgstr "Erreur"
+
+msgid "Information"
+msgstr "Information"
+
+msgid "Question"
+msgstr "Question"
+
msgid "Select one signal"
msgstr "Sélectionnez un signal"
@@ -4279,9 +4331,6 @@ msgstr "L'ouverture de cet exemple remplace l'espace de travail actuel. Continue
msgid "Open example"
msgstr "Ouvrir l'exemple"
-msgid "Question"
-msgstr "Question"
-
msgid "Test data"
msgstr "Données de test"
@@ -4380,13 +4429,13 @@ msgstr "Nom du package"
msgid "Plugin ID"
msgstr "ID du plugin"
+msgid "Description"
+msgstr "Description"
+
#, python-format
msgid "%s plugin for DataLab"
msgstr "Plugin %s pour DataLab"
-msgid "Description"
-msgstr "Description"
-
msgid "Destination"
msgstr "Destination"
@@ -4417,8 +4466,639 @@ 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"
+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"
+
+msgid "Raise window"
+msgstr "Placer la fenêtre au premier plan"
+
+msgid "Add signal objects"
+msgstr "Ajouter des objets signaux"
+
+msgid "Add image objects"
+msgstr "Ajouter des objets images"
+
+msgid "Remove all objects"
+msgstr "Supprimer tous les objets"
+
+msgid "Import macro from file (example)"
+msgstr "Importer une macro depuis un fichier (exemple)"
+
+msgid "Close DataLab"
+msgstr "Fermer DataLab"
+
+msgid "This the host application, which embeds DataLab."
+msgstr "Ceci est une application embarquant DataLab."
+
+msgid "Open DataLab"
+msgstr "Ouvrir DataLab"
+
+msgid "Import signal from DataLab"
+msgstr "Importer un signal depuis DataLab"
+
+msgid "Import image from DataLab"
+msgstr "Importer une image depuis DataLab"
+
+msgid "Remote client test"
+msgstr "Test du client distant"
+
+msgid "This the client application, which connects to DataLab."
+msgstr "Ceci l'application cliente, qui se connecte à DataLab."
+
+msgid "Connect to DataLab"
+msgstr "Se connecter à DataLab"
+
+msgid "Execute multiple commands"
+msgstr "Exécuter des commandes multiples"
+
+msgid "Get object titles"
+msgstr "Obtenir la liste des titres"
+
+msgid "Get object uuids"
+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)"
+
+msgid "Flat data (Uniform random)"
+msgstr "Image d'homogénéité (Distribution uniforme)"
+
+msgid "Random function"
+msgstr "Fonction gaussienne aléatoire"
+
+msgid "Multi-Gaussian"
+msgstr "Multi-gaussienne"
+
+msgid "2D Peaks with noise background"
+msgstr "Pics 2D avec fond bruité"
+
+msgid "Click OK to start the demo.
Note:
- Demo will cover a selection of DataLab features (for a complete list of features, please refer to the documentation).
- It won't require any user interaction."
+msgstr "Cliquer sur OK pour démarrer la démo.
Note :
- La démo couvre une sélection des fonctionnalités de DataLab (pour une liste complète des fonctionnalités, veuillez vous référer à la documentation).
- Elle ne nécessite aucune interaction de l'utilisateur."
+
+msgid "Click OK to end demo."
+msgstr "Cliquer sur OK pour terminer la démo."
+
+msgid "Start Web API Server"
+msgstr "Démarrer le serveur API Web"
+
+msgid "Start the HTTP/JSON Web API server for external access"
+msgstr "Démarrer le serveur API Web HTTP/JSON pour l'accès externe"
+
+msgid "Stop Web API Server"
+msgstr "Arrêter le serveur API Web"
+
+msgid "Copy Connection Info"
+msgstr "Copier les infos de connexion"
+
+msgid "Copy URL and token to clipboard"
+msgstr "Copier l'URL et le jeton dans le presse-papier"
+
+msgid "Status: Not running"
+msgstr "Statut : Non démarré"
+
+msgid "Web API unavailable (install datalab-platform[webapi])"
+msgstr "API Web non disponible (installer datalab-platform[webapi])"
+
+msgid "Web API"
+msgstr "API Web"
+
+msgid "Failed to start Web API server:"
+msgstr "Échec du démarrage du serveur API Web :"
+
+msgid "Connection info copied to clipboard"
+msgstr "Infos de connexion copiées dans le presse-papier"
+
+msgid "Web API Server Started"
+msgstr "Serveur API Web démarré"
+
+msgid "The Web API server is now running. Use the following credentials to connect:"
+msgstr "Le serveur API Web est maintenant en cours d'exécution. Utilisez les identifiants suivants pour vous connecter :"
+
+msgid "URL:"
+msgstr "URL :"
+
+msgid "Token:"
+msgstr "Jeton :"
+
+msgid "Tip: Set these environment variables in your notebook:\n"
+msgstr "Astuce : Définissez ces variables d'environnement dans votre notebook :\n"
+
+msgid "Copy to Clipboard"
+msgstr "Copier dans le presse-papier"
+
+#, python-brace-format
+msgid "Status: Running at {}"
+msgstr "Statut : En cours d'exécution à {}"
+
+msgid "Web API server error:"
+msgstr "Erreur du serveur API Web :"
+
+msgid ""
+"Do you want to start the Web API server?\n"
+"\n"
+"This will allow external applications to connect to DataLab and control it remotely via HTTP/JSON."
+msgstr ""
+"Souhaitez-vous démarrer le serveur API Web ?\n"
+"\n"
+"Cela permettra aux applications externes de se connecter à DataLab et de le contrôler à distance via HTTP/JSON."
+
+msgid "Show more"
+msgstr "Afficher plus"
+
+msgid "Show less"
+msgstr "Afficher moins"
+
+msgid "Find"
+msgstr "Rechercher"
+
+msgid "Replace with"
+msgstr "Remplacer par"
+
+msgid "Aa"
+msgstr "Aa"
+
+msgid "Match case"
+msgstr "Respecter la casse"
+
+msgid "W"
+msgstr "M"
+
+msgid "Whole words only"
+msgstr "Mots entiers uniquement"
+
+msgid "Next"
+msgstr "Suivant"
+
+msgid "Prev"
+msgstr "Précédent"
+
+msgid "Replace"
+msgstr "Remplacer"
+
+msgid "Replace all"
+msgstr "Tout remplacer"
+
+msgid "Find:"
+msgstr "Rechercher :"
+
+msgid "Replace:"
+msgstr "Remplacer :"
+
+msgid "Show details"
+msgstr "Afficher les détails"
+
+msgid "Hide details"
+msgstr "Masquer les détails"
+
+msgid "Title"
+msgstr "Titre"
+
+msgid "Date and time"
+msgstr "Date et heure"
+
+msgid "Action is compatible with the current workspace state."
+msgstr "L'action est compatible avec l'état actuel de l'espace de travail."
+
+msgid "Action is not compatible with the current workspace state."
+msgstr "L'action n'est pas compatible avec l'état actuel de l'espace de travail."
+
+msgid "This step is outdated: replay it to refresh its result."
+msgstr "Cette étape est obsolète : rejouez-la pour actualiser son résultat."
+
+msgid "Active recording session."
+msgstr "Session d'enregistrement active."
+
+msgid "Installation configuration"
+msgstr "Configuration d'installation"
+
+msgid "User configuration"
+msgstr "Configuration utilisateur"
+
+msgid "Plugins and I/O features"
+msgstr "Plugins et fonctionnalités d'entrée/sortie"
+
+msgid "Preview source"
+msgstr "Source de l'aperçu"
+
+msgid "Preview disabled"
+msgstr "Aperçu désactivé"
+
+msgid "Computing preview..."
+msgstr "Calcul de l'aperçu..."
+
+msgid "Updating preview..."
+msgstr "Mise à jour de l'aperçu..."
+
+msgid "Preview failed"
+msgstr "Échec de l'aperçu"
+
+msgid "This result cannot be previewed."
+msgstr "Ce résultat ne peut pas être prévisualisé."
+
+msgid "Preview up to date"
+msgstr "Aperçu à jour"
+
+msgid "Kernel / Mask preview"
+msgstr "Aperçu du noyau / masque"
+
+#, python-brace-format
+msgid "{label} — Kernel / Mask preview"
+msgstr "{label} — Aperçu du noyau / masque"
+
+#, python-brace-format
+msgid "±{n} points"
+msgstr "±{n} points"
+
+#, python-brace-format
+msgid "±{n} rows × ±{n} columns"
+msgstr "±{n} lignes × ±{n} colonnes"
+
+msgid "This image uses an integer data type, so it cannot contain NaN or infinite values. Replace special values is therefore not applicable."
+msgstr "Cette image utilise un type de données entier, elle ne peut donc pas contenir de valeurs NaN ou infinies. Le remplacement des valeurs spéciales n'est donc pas applicable."
+
+msgid "Plugins:"
+msgstr "Plugins :"
+
+msgid "XML-RPC:"
+msgstr "XML-RPC :"
+
+msgid "Web API server is not running"
+msgstr "Serveur API Web arrêté"
+
+msgid "Click to start"
+msgstr "Cliquer pour démarrer"
+
+msgid "Web API:"
+msgstr "API Web :"
+
+msgid "Web API server is running"
+msgstr "Serveur API Web démarré"
+
+msgid "Click to view connection info"
+msgstr "Cliquer pour voir les infos de connexion"
+
+msgid "Source"
+msgstr "Source"
+
+msgid "File"
+msgstr "Fichier"
+
+msgid "Clipboard"
+msgstr "Presse-papiers"
+
+msgid "Source of the data"
+msgstr "Source des données"
+
+msgid "File containing the data"
+msgstr "Fichier contenant les données"
+
+msgid "Preview parameters"
+msgstr "Paramètres de l'aperçu"
+
+msgid "Maximum Number of Rows"
+msgstr "Nombre maximum de lignes"
+
+msgid "Maximum number of rows to display"
+msgstr "Nombre maximum de lignes à afficher"
+
+msgid "Select the source of the data:"
+msgstr "Sélectionner la source des données :"
+
+msgid "Source Parameters"
+msgstr "Paramètres de la source"
+
+msgid "Space"
+msgstr "Espace"
+
+msgid "Column delimiter character"
+msgstr "Caractère délimiteur de colonne"
+
+msgid "Decimal"
+msgstr "Décimal"
+
+msgid "Point"
+msgstr "Point"
+
+msgid "Decimal separator character"
+msgstr "Caractère séparateur décimal"
+
+msgid "Comments"
+msgstr "Commentaires"
+
+msgid "Character that indicates a comment line"
+msgstr "Caractère indiquant une ligne de commentaire"
+
+msgid "Rows to Skip"
+msgstr "Lignes à sauter"
+
+msgid "Number of rows to skip at the beginning of the file (including comments)"
+msgstr "Nombre de lignes à sauter au début du fichier (y compris les commentaires)"
+
+msgid "Max. nb of rows"
+msgstr "Nb max. de lignes"
+
+msgid "Maximum number of rows to import"
+msgstr "Nombre maximum de lignes à importer"
+
+msgid "Header"
+msgstr "En-tête"
+
+msgid "Infer"
+msgstr "Inférer"
+
+msgid "First row"
+msgstr "Première ligne"
+
+msgid "Row index to use as the column names"
+msgstr "Indice de ligne à utiliser comme noms de colonnes"
+
+msgid "Transpose"
+msgstr "Transposer"
+
+msgid "Transpose the data (swap rows and columns)"
+msgstr "Transposer les données (échanger les lignes et les colonnes)"
+
+msgid "Data type"
+msgstr "Type de données"
+
+msgid "Output signal data type."
+msgstr "Type de données du signal de sortie."
+
+msgid "First Column is X"
+msgstr "La première colonne est X"
+
+msgid ""
+"First column contains the X values\n"
+"(ignored if there is only one column)"
+msgstr ""
+"La première colonne contient les valeurs des X\n"
+"(ignoré s'il n'y a qu'une seule colonne)"
+
+msgid "Output image data type."
+msgstr "Type de données de l'image générée."
+
+msgid "Raw Data"
+msgstr "Données brutes"
+
+msgid "Data Preview"
+msgstr "Aperçu des données"
+
+msgid "Preview and modify the import settings:"
+msgstr "Aperçu et modification des paramètres d'importation :"
+
+msgid "Import Parameters"
+msgstr "Paramètres d'importation"
+
+msgid "Reading data"
+msgstr "Lecture des données"
+
+#, python-format
+msgid ""
+"The number of signals to import is very high (%d).\n"
+"This may be an error.\n"
+"\n"
+"Are you sure you want to continue?"
+msgstr ""
+"Le nombre de signaux à importer est très élevé (%d).\n"
+"Cela peut être une erreur.\n"
+"\n"
+"Etes-vous sûr de vouloir continuer ?"
+
+msgid "Graphical Representation"
+msgstr "Représentation graphique"
+
+msgid "Graphical representation of the imported data"
+msgstr "Représentation graphique des données importées"
+
+#, python-format
+msgid "Unselect the %s that you do not want to import:"
+msgstr "Désélectionner les %s que vous ne voulez pas importer :"
+
+msgid "Select all"
+msgstr "Tout sélectionner"
+
+msgid "Adding data to the plot"
+msgstr "Ajout des données au graphique"
+
+msgid "X label"
+msgstr "Titre X"
+
+msgid "Y label"
+msgstr "Titre Y"
+
+msgid "X unit"
+msgstr "Unité X"
+
+msgid "Y unit"
+msgstr "Unité Y"
+
+msgid "Z label"
+msgstr "Titre Z"
+
+msgid "Z unit"
+msgstr "Unité Z"
+
+msgid "Labels and units"
+msgstr "Titres et unités"
+
+msgid "Set the labels and units for the imported data"
+msgstr "Définir les titres et unités pour les données importées"
+
+msgid "The following title, labels and units will be applied to the data.
Note: Leave empty the labels and units to keep the default values (i.e. the values which were inferred from the data)."
+msgstr "Les titres, étiquettes et unités suivants seront appliqués aux données.
Note : Laisser vide les étiquettes et unités pour conserver les valeurs par défaut (c'est-à-dire les valeurs qui ont été déduites des données)."
+
+msgid "clipboard"
+msgstr "presse-papiers"
+
+msgid "Imported from:"
+msgstr "Importer depuis :"
+
+msgid "DataLab Import Wizard"
+msgstr "Assistant d'importation de DataLab"
+
+msgid "Shape"
+msgstr "Forme"
+
+msgid "Dimensions"
+msgstr "Dimensions"
+
+msgid "The registry address must be an http or https URL"
+msgstr "L'adresse du registre doit être une URL http ou https"
+
+#, python-format
+msgid "%s too large: %s, %s MiB (limit: %s MiB)"
+msgstr "%s : taille excessive, %s, %s Mio (limite : %s Mio)"
+
+msgid "Capsule"
+msgstr "Capsule"
+
+msgid "Referenced workspace"
+msgstr "Espace de travail référencé"
+
+msgid "Workspace"
+msgstr "Espace de travail"
+
+#, python-format
+msgid "Attachments are limited to 16 MiB: %s"
+msgstr "Les pièces jointes sont limitées à 16 Mio : %s"
+
+msgid "Attachment"
+msgstr "Pièce jointe"
+
+msgid "The capsule, its referenced workspace and its attachments must be different files"
+msgstr "La capsule, son espace de travail référencé et ses pièces jointes doivent être des fichiers différents"
+
+msgid "This capsule references its workspace: select the workspace file"
+msgstr "Cette capsule référence son espace de travail : sélectionnez le fichier de l'espace de travail"
+
+msgid "The data will be added, but their earlier provenance will not be imported. Open the file replacing the current workspace to keep it."
+msgstr "Les données seront ajoutées, mais leur provenance antérieure ne sera pas importée. Ouvrez le fichier en remplaçant l'espace de travail actuel pour la conserver."
+
+#, python-format
+msgid "The provenance data of this workspace are invalid: %s"
+msgstr "Les données de provenance de cet espace de travail sont invalides : %s"
+
+msgid "Add without provenance"
+msgstr "Ajouter sans la provenance"
+
+msgid "This file is not a DataLab workspace"
+msgstr "Ce fichier n'est pas un espace de travail DataLab"
+
+msgid "Workspace capsule..."
+msgstr "Capsule d'espace de travail..."
+
+msgid "Export, inspect, replay and compare workspace capsules (workspace and provenance of its processing)"
+msgstr "Exporter, inspecter, rejouer et comparer des capsules d'espace de travail (espace de travail et provenance de ses traitements)"
+
+msgid "Export capsule"
+msgstr "Exporter une capsule"
+
+msgid "Open capsule"
+msgstr "Ouvrir une capsule"
+
+msgid "HDF5 files (*.h5 *.hdf5 *.hdf *.he5);;All files (*)"
+msgstr "Fichiers HDF5 (*.h5 *.hdf5 *.hdf *.he5);;Tous les fichiers (*)"
+
+msgid "Provenance:"
+msgstr "Provenance :"
+
+msgid "If you choose No: "
+msgstr "Si vous choisissez Non : "
+
+msgid "Provenance"
+msgstr "Provenance"
+
+msgid "This processing has no parameters."
+msgstr "Ce traitement n'a pas de paramètres."
+
+msgid "The parameters of this processing cannot be edited here."
+msgstr "Les paramètres de ce traitement ne peuvent pas être modifiés ici."
+
+msgid "Reapply"
+msgstr "Réappliquer"
+
+msgid "Apply this processing again to the current input objects"
+msgstr "Appliquer à nouveau ce traitement aux objets d'entrée actuels"
+
+msgid "(deleted object)"
+msgstr "(objet supprimé)"
+
+msgid "Show execution provenance"
+msgstr "Afficher la provenance de l'exécution"
+
+msgid "Show the recorded provenance of the last successful execution of the selected action"
+msgstr "Afficher la provenance enregistrée de la dernière exécution réussie de l'action sélectionnée"
+
+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)."
+
+#, 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 "Local catalog"
+msgstr "Catalogue local"
+
+#, python-format
+msgid "Local catalog: %s"
+msgstr "Catalogue local : %s"
+
+#, python-format
+msgid "Plugins listed in the DataLab plugin catalog and in the local catalogs of the Plugin settings tab, which take precedence. Official plugins are maintained by the DataLab team, community plugins by their authors, and local catalogs by your organization: install only plugins from authors you trust."
+msgstr "Plugins répertoriés dans le catalogue de plugins DataLab et dans les catalogues locaux de l'onglet Paramètres des plugins, qui sont prioritaires. Les plugins officiels sont maintenus par l'équipe DataLab, les plugins de la communauté par leurs auteurs et les catalogues locaux par votre organisation : n'installez que des plugins provenant d'auteurs de confiance."
+
+msgid "Loading the plugin catalogs..."
+msgstr "Chargement des catalogues de plugins..."
+
+#, python-format
+msgid "Plugins in the catalogs: %d"
+msgstr "Plugins dans les catalogues : %d"
+
+#, python-format
+msgid "Local plugin catalog (%s)"
+msgstr "Catalogue local de plugins (%s)"
+
+msgid "Local plugin catalogs"
+msgstr "Catalogues locaux de plugins"
+
+msgid "Folders, possibly shared on the network, whose plugins are listed in the Available plugins tab before those of the DataLab plugin catalog. A folder provides the plugins of its catalog.json file if any, otherwise its plugin wheels."
+msgstr "Dossiers, éventuellement partagés sur le réseau, dont les plugins sont proposés dans l'onglet Plugins disponibles avant ceux du catalogue de plugins DataLab. Un dossier fournit les plugins de son fichier catalog.json s'il existe, sinon ses wheels de plugins."
+
+msgid "No local plugin catalog is configured."
+msgstr "Aucun catalogue local de plugins n'est configuré."
+
+#, python-format
+msgid "Folders provided via the %s environment variable (multiple paths separated by '%s') come first and appear above as read-only entries."
+msgstr "Les dossiers fournis via la variable d'environnement %s (plusieurs chemins séparés par '%s') sont prioritaires et apparaissent ci-dessus en lecture seule."
+
+msgid "Select local plugin catalog folder"
+msgstr "Sélectionner un dossier de catalogue local de plugins"
+
+msgid "The signals have different units: no conversion is applied."
+msgstr "Les signaux ont des unités différentes : aucune conversion n'est appliquée."
+
+msgid "To interpolate the operand, the X values of both signals must be finite and strictly increasing."
+msgstr "Pour interpoler l'opérande, les abscisses des deux signaux doivent être finies et strictement croissantes."
+
+msgid "The X range of the operand does not cover the X range of the source: extrapolation is not allowed."
+msgstr "L'intervalle des abscisses de l'opérande ne couvre pas celui de la source : l'extrapolation n'est pas autorisée."
+
+msgid "The operand has uncertainties, which cannot be interpolated."
+msgstr "L'opérande comporte des incertitudes, qui ne peuvent pas être interpolées."
+
+msgid "The signals cannot be aligned."
+msgstr "Les signaux ne peuvent pas être alignés."
+
+msgid "Translate"
+msgstr "Translation"
msgid "Build or check a local plugin catalog folder."
msgstr "Construire ou vérifier un dossier de catalogue local de plugins."
@@ -4448,13 +5128,6 @@ msgstr "%s écrit, répertoriant %d plugins."
msgid "The local plugin catalog lists %d plugins."
msgstr "Le catalogue local de plugins répertorie %d plugins."
-#, python-format
-msgid "Created DataLab plugin project at %s"
-msgstr "Projet de plugin DataLab créé dans %s"
-
-msgid "Source object no longer exists."
-msgstr "L'objet source n'existe plus."
-
msgid "An input object no longer exists."
msgstr "Un objet d'entrée n'existe plus."
@@ -4482,148 +5155,6 @@ msgstr "Les paramètres enregistrés ne peuvent pas être utilisés : %s"
msgid "This processing takes several parameter sets: it cannot be applied again from here."
msgstr "Ce traitement utilise plusieurs jeux de paramètres : il ne peut pas être appliqué à nouveau d'ici."
-msgid "Processing metadata is incomplete (missing source UUID)."
-msgstr "Les métadonnées de traitement sont incomplètes (UUID source manquant)."
-
-msgid "Host application"
-msgstr "Application hôte"
-
-msgid "Raise window"
-msgstr "Placer la fenêtre au premier plan"
-
-msgid "Add signal objects"
-msgstr "Ajouter des objets signaux"
-
-msgid "Add image objects"
-msgstr "Ajouter des objets images"
-
-msgid "Remove all objects"
-msgstr "Supprimer tous les objets"
-
-msgid "Import macro from file (example)"
-msgstr "Importer une macro depuis un fichier (exemple)"
-
-msgid "Close DataLab"
-msgstr "Fermer DataLab"
-
-msgid "This the host application, which embeds DataLab."
-msgstr "Ceci est une application embarquant DataLab."
-
-msgid "Open DataLab"
-msgstr "Ouvrir DataLab"
-
-msgid "Import signal from DataLab"
-msgstr "Importer un signal depuis DataLab"
-
-msgid "Import image from DataLab"
-msgstr "Importer une image depuis DataLab"
-
-msgid "Remote client test"
-msgstr "Test du client distant"
-
-msgid "This the client application, which connects to DataLab."
-msgstr "Ceci l'application cliente, qui se connecte à DataLab."
-
-msgid "Connect to DataLab"
-msgstr "Se connecter à DataLab"
-
-msgid "Execute multiple commands"
-msgstr "Exécuter des commandes multiples"
-
-msgid "Get object titles"
-msgstr "Obtenir la liste des titres"
-
-msgid "Get object uuids"
-msgstr "Obtenir la liste des uuids"
-
-msgid "Get object"
-msgstr "Obtenir un objet"
-
-msgid "Raw data (2D-Gaussian)"
-msgstr "Image brute (gaussienne 2D)"
-
-msgid "Flat data (Uniform random)"
-msgstr "Image d'homogénéité (Distribution uniforme)"
-
-msgid "Random function"
-msgstr "Fonction gaussienne aléatoire"
-
-msgid "Multi-Gaussian"
-msgstr "Multi-gaussienne"
-
-msgid "2D Peaks with noise background"
-msgstr "Pics 2D avec fond bruité"
-
-msgid "Click OK to start the demo.
Note:
- Demo will cover a selection of DataLab features (for a complete list of features, please refer to the documentation).
- It won't require any user interaction."
-msgstr "Cliquer sur OK pour démarrer la démo.
Note :
- La démo couvre une sélection des fonctionnalités de DataLab (pour une liste complète des fonctionnalités, veuillez vous référer à la documentation).
- Elle ne nécessite aucune interaction de l'utilisateur."
-
-msgid "Click OK to end demo."
-msgstr "Cliquer sur OK pour terminer la démo."
-
-msgid "Start Web API Server"
-msgstr "Démarrer le serveur API Web"
-
-msgid "Start the HTTP/JSON Web API server for external access"
-msgstr "Démarrer le serveur API Web HTTP/JSON pour l'accès externe"
-
-msgid "Stop Web API Server"
-msgstr "Arrêter le serveur API Web"
-
-msgid "Copy Connection Info"
-msgstr "Copier les infos de connexion"
-
-msgid "Copy URL and token to clipboard"
-msgstr "Copier l'URL et le jeton dans le presse-papier"
-
-msgid "Status: Not running"
-msgstr "Statut : Non démarré"
-
-msgid "Web API unavailable (install datalab-platform[webapi])"
-msgstr "API Web non disponible (installer datalab-platform[webapi])"
-
-msgid "Web API"
-msgstr "API Web"
-
-msgid "Failed to start Web API server:"
-msgstr "Échec du démarrage du serveur API Web :"
-
-msgid "Connection info copied to clipboard"
-msgstr "Infos de connexion copiées dans le presse-papier"
-
-msgid "Web API Server Started"
-msgstr "Serveur API Web démarré"
-
-msgid "The Web API server is now running. Use the following credentials to connect:"
-msgstr "Le serveur API Web est maintenant en cours d'exécution. Utilisez les identifiants suivants pour vous connecter :"
-
-msgid "URL:"
-msgstr "URL :"
-
-msgid "Token:"
-msgstr "Jeton :"
-
-msgid "Tip: Set these environment variables in your notebook:\n"
-msgstr "Astuce : Définissez ces variables d'environnement dans votre notebook :\n"
-
-msgid "Copy to Clipboard"
-msgstr "Copier dans le presse-papier"
-
-#, python-brace-format
-msgid "Status: Running at {}"
-msgstr "Statut : En cours d'exécution à {}"
-
-msgid "Web API server error:"
-msgstr "Erreur du serveur API Web :"
-
-msgid ""
-"Do you want to start the Web API server?\n"
-"\n"
-"This will allow external applications to connect to DataLab and control it remotely via HTTP/JSON."
-msgstr ""
-"Souhaitez-vous démarrer le serveur API Web ?\n"
-"\n"
-"Cela permettra aux applications externes de se connecter à DataLab et de le contrôler à distance via HTTP/JSON."
-
msgid "DataLab capsules"
msgstr "Capsules DataLab"
@@ -4872,9 +5403,6 @@ msgstr "Révisions exportées, inspectées ou récupérées sur cet ordinateur.
msgid "Parent"
msgstr "Parente"
-msgid "Source"
-msgstr "Source"
-
msgid "Compare"
msgstr "Comparer"
@@ -4949,122 +5477,23 @@ msgstr "Récupérer"
msgid "Manifest checked: valid, fingerprint %s. Added to the revisions of this computer."
msgstr "Manifeste vérifié : valide, empreinte %s. Ajouté aux révisions de cet ordinateur."
-msgid "valid"
-msgstr "valide"
-
-msgid "Workspace capsule"
-msgstr "Capsule d'espace de travail"
-
-msgid "Inspect"
-msgstr "Inspecter"
-
-msgid "Local replay"
-msgstr "Rejeu local"
-
-msgid "Revisions"
-msgstr "Révisions"
-
-msgid "Registry"
-msgstr "Registre"
-
-msgid "Show more"
-msgstr "Afficher plus"
-
-msgid "Show less"
-msgstr "Afficher moins"
-
-msgid "Find"
-msgstr "Rechercher"
-
-msgid "Replace with"
-msgstr "Remplacer par"
-
-msgid "Aa"
-msgstr "Aa"
-
-msgid "Match case"
-msgstr "Respecter la casse"
-
-msgid "W"
-msgstr "M"
-
-msgid "Whole words only"
-msgstr "Mots entiers uniquement"
-
-msgid "Next"
-msgstr "Suivant"
-
-msgid "Prev"
-msgstr "Précédent"
-
-msgid "Replace"
-msgstr "Remplacer"
-
-msgid "Replace all"
-msgstr "Tout remplacer"
-
-msgid "Find:"
-msgstr "Rechercher :"
-
-msgid "Replace:"
-msgstr "Remplacer :"
-
-msgid "Show details"
-msgstr "Afficher les détails"
-
-msgid "Hide details"
-msgstr "Masquer les détails"
-
-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."
-
-msgid "Action is not compatible with the current workspace state."
-msgstr "L'action n'est pas compatible avec l'état actuel de l'espace de travail."
-
-msgid "This step is outdated: replay it to refresh its result."
-msgstr "Cette étape est obsolète : rejouez-la pour actualiser son résultat."
-
-msgid "Active recording session."
-msgstr "Session d'enregistrement active."
-
-msgid "Installation configuration"
-msgstr "Configuration d'installation"
-
-msgid "User configuration"
-msgstr "Configuration utilisateur"
-
-msgid "Plugins and I/O features"
-msgstr "Plugins et fonctionnalités d'entrée/sortie"
-
-msgid "Preview source"
-msgstr "Source de l'aperçu"
-
-msgid "Preview disabled"
-msgstr "Aperçu désactivé"
-
-msgid "Computing preview..."
-msgstr "Calcul de l'aperçu..."
+msgid "valid"
+msgstr "valide"
-msgid "Invalid parameters"
-msgstr "Paramètres invalides"
+msgid "Workspace capsule"
+msgstr "Capsule d'espace de travail"
-msgid "Updating preview..."
-msgstr "Mise à jour de l'aperçu..."
+msgid "Inspect"
+msgstr "Inspecter"
-msgid "Preview failed"
-msgstr "Échec de l'aperçu"
+msgid "Local replay"
+msgstr "Rejeu local"
-msgid "This result cannot be previewed."
-msgstr "Ce résultat ne peut pas être prévisualisé."
+msgid "Revisions"
+msgstr "Révisions"
-msgid "Preview up to date"
-msgstr "Aperçu à jour"
+msgid "Registry"
+msgstr "Registre"
#, python-format
msgid "array %s (%s)"
@@ -5296,6 +5725,9 @@ msgstr "Vérifier cette étape"
msgid "History action"
msgstr "Action de l'historique"
+msgid "Inputs"
+msgstr "Entr?es"
+
msgid "already shown above (cycle)"
msgstr "déjà affiché plus haut (cycle)"
@@ -5339,236 +5771,6 @@ msgstr "La provenance de cette exécution n'est pas disponible dans cet espace d
msgid "The parameters of this action were edited since its last successful execution: the provenance below describes that execution."
msgstr "Les paramètres de cette action ont été modifiés depuis sa dernière exécution réussie : la provenance ci-dessous décrit cette exécution."
-msgid "Kernel / Mask preview"
-msgstr "Aperçu du noyau / masque"
-
-#, python-brace-format
-msgid "{label} — Kernel / Mask preview"
-msgstr "{label} — Aperçu du noyau / masque"
-
-#, python-brace-format
-msgid "±{n} points"
-msgstr "±{n} points"
-
-#, python-brace-format
-msgid "±{n} rows × ±{n} columns"
-msgstr "±{n} lignes × ±{n} colonnes"
-
-msgid "This image uses an integer data type, so it cannot contain NaN or infinite values. Replace special values is therefore not applicable."
-msgstr "Cette image utilise un type de données entier, elle ne peut donc pas contenir de valeurs NaN ou infinies. Le remplacement des valeurs spéciales n'est donc pas applicable."
-
-msgid "Plugins:"
-msgstr "Plugins :"
-
-msgid "XML-RPC:"
-msgstr "XML-RPC :"
-
-msgid "Web API server is not running"
-msgstr "Serveur API Web arrêté"
-
-msgid "Click to start"
-msgstr "Cliquer pour démarrer"
-
-msgid "Web API:"
-msgstr "API Web :"
-
-msgid "Web API server is running"
-msgstr "Serveur API Web démarré"
-
-msgid "Click to view connection info"
-msgstr "Cliquer pour voir les infos de connexion"
-
-msgid "Clipboard"
-msgstr "Presse-papiers"
-
-msgid "File"
-msgstr "Fichier"
-
-msgid "Source of the data"
-msgstr "Source des données"
-
-msgid "File containing the data"
-msgstr "Fichier contenant les données"
-
-msgid "Preview parameters"
-msgstr "Paramètres de l'aperçu"
-
-msgid "Maximum Number of Rows"
-msgstr "Nombre maximum de lignes"
-
-msgid "Maximum number of rows to display"
-msgstr "Nombre maximum de lignes à afficher"
-
-msgid "Select the source of the data:"
-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"
-
-msgid "Column delimiter character"
-msgstr "Caractère délimiteur de colonne"
-
-msgid "Decimal"
-msgstr "Décimal"
-
-msgid "Point"
-msgstr "Point"
-
-msgid "Comma"
-msgstr "Virgule"
-
-msgid "Decimal separator character"
-msgstr "Caractère séparateur décimal"
-
-msgid "Comments"
-msgstr "Commentaires"
-
-msgid "Character that indicates a comment line"
-msgstr "Caractère indiquant une ligne de commentaire"
-
-msgid "Rows to Skip"
-msgstr "Lignes à sauter"
-
-msgid "Number of rows to skip at the beginning of the file (including comments)"
-msgstr "Nombre de lignes à sauter au début du fichier (y compris les commentaires)"
-
-msgid "Max. nb of rows"
-msgstr "Nb max. de lignes"
-
-msgid "Maximum number of rows to import"
-msgstr "Nombre maximum de lignes à importer"
-
-msgid "Header"
-msgstr "En-tête"
-
-msgid "Infer"
-msgstr "Inférer"
-
-msgid "First row"
-msgstr "Première ligne"
-
-msgid "Row index to use as the column names"
-msgstr "Indice de ligne à utiliser comme noms de colonnes"
-
-msgid "Transpose"
-msgstr "Transposer"
-
-msgid "Transpose the data (swap rows and columns)"
-msgstr "Transposer les données (échanger les lignes et les colonnes)"
-
-msgid "Data type"
-msgstr "Type de données"
-
-msgid "Output signal data type."
-msgstr "Type de données du signal de sortie."
-
-msgid "First Column is X"
-msgstr "La première colonne est X"
-
-msgid ""
-"First column contains the X values\n"
-"(ignored if there is only one column)"
-msgstr ""
-"La première colonne contient les valeurs des X\n"
-"(ignoré s'il n'y a qu'une seule colonne)"
-
-msgid "Output image data type."
-msgstr "Type de données de l'image générée."
-
-msgid "Raw Data"
-msgstr "Données brutes"
-
-msgid "Data Preview"
-msgstr "Aperçu des données"
-
-msgid "Preview and modify the import settings:"
-msgstr "Aperçu et modification des paramètres d'importation :"
-
-msgid "Import Parameters"
-msgstr "Paramètres d'importation"
-
-msgid "Reading data"
-msgstr "Lecture des données"
-
-#, python-format
-msgid ""
-"The number of signals to import is very high (%d).\n"
-"This may be an error.\n"
-"\n"
-"Are you sure you want to continue?"
-msgstr ""
-"Le nombre de signaux à importer est très élevé (%d).\n"
-"Cela peut être une erreur.\n"
-"\n"
-"Etes-vous sûr de vouloir continuer ?"
-
-msgid "Graphical Representation"
-msgstr "Représentation graphique"
-
-msgid "Graphical representation of the imported data"
-msgstr "Représentation graphique des données importées"
-
-#, python-format
-msgid "Unselect the %s that you do not want to import:"
-msgstr "Désélectionner les %s que vous ne voulez pas importer :"
-
-msgid "Select all"
-msgstr "Tout sélectionner"
-
-msgid "Adding data to the plot"
-msgstr "Ajout des données au graphique"
-
-msgid "X label"
-msgstr "Titre X"
-
-msgid "Y label"
-msgstr "Titre Y"
-
-msgid "X unit"
-msgstr "Unité X"
-
-msgid "Y unit"
-msgstr "Unité Y"
-
-msgid "Z label"
-msgstr "Titre Z"
-
-msgid "Z unit"
-msgstr "Unité Z"
-
-msgid "Labels and units"
-msgstr "Titres et unités"
-
-msgid "Set the labels and units for the imported data"
-msgstr "Définir les titres et unités pour les données importées"
-
-msgid "The following title, labels and units will be applied to the data.
Note: Leave empty the labels and units to keep the default values (i.e. the values which were inferred from the data)."
-msgstr "Les titres, étiquettes et unités suivants seront appliqués aux données.
Note : Laisser vide les étiquettes et unités pour conserver les valeurs par défaut (c'est-à-dire les valeurs qui ont été déduites des données)."
-
-msgid "clipboard"
-msgstr "presse-papiers"
-
-msgid "Imported from:"
-msgstr "Importer depuis :"
-
-msgid "DataLab Import Wizard"
-msgstr "Assistant d'importation de DataLab"
-
-msgid "Shape"
-msgstr "Forme"
-
-msgid "Dimensions"
-msgstr "Dimensions"
-
msgid "Minimum value"
msgstr "Valeur minimum"
@@ -5616,4 +5818,3 @@ msgstr "Aucun contour n'a été trouvé pour la plage de niveaux sélectionnée.
msgid "Show contour plot..."
msgstr "Afficher le tracé de contours..."
-
diff --git a/datalab/tests/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 634e105d0..9caaa9528 100644
--- a/datalab/tests/features/common/history_model_unit_test.py
+++ b/datalab/tests/features/common/history_model_unit_test.py
@@ -840,6 +840,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/export_unit_test.py b/datalab/tests/features/image/export_unit_test.py
new file mode 100644
index 000000000..8ad7c7799
--- /dev/null
+++ b/datalab/tests/features/image/export_unit_test.py
@@ -0,0 +1,895 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Format-aware image export unit tests."""
+
+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
+
+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,
+ prepare_image_export_preview,
+ 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
+from datalab.gui.panel.base import BaseDataPanel
+from datalab.gui.panel.image import (
+ ImageExportDialog,
+ ImageExportGUIParam,
+ 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
+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",
+ "int16",
+ "int32",
+ "float32",
+ "float64",
+ "complex128",
+ ],
+ ["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)
+
+
+@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
+
+
+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 = 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:
+ """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")
+ 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()
+
+
+@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")]
+ 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", "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))
+ 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))
+ executor_cache = object()
+ panel = SimpleNamespace(
+ parentWidget=Mock(return_value=None),
+ mainwindow=SimpleNamespace(preview_executor_cache=executor_cache),
+ )
+ guiparam = Mock()
+ 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()
+ 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:
+ """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"]
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..e2c1efa77
--- /dev/null
+++ b/datalab/tests/features/image/raw_import_app_test.py
@@ -0,0 +1,112 @@
+# 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 pathlib import Path
+from unittest.mock import patch
+
+import numpy as np
+from sigima.io import RawImageImportParam
+
+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(RawImageImportParam, "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)
+
+
+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
new file mode 100644
index 000000000..383909623
--- /dev/null
+++ b/datalab/tests/features/image/raw_import_unit_test.py
@@ -0,0 +1,649 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Parameterized RAW image import unit tests."""
+
+from __future__ import annotations
+
+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
+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
+ load_from_directory = BaseDataPanel.load_from_directory
+ edit_import_parameters = ImagePanel.edit_import_parameters
+ edit_raw_import_parameters = ImagePanel.edit_raw_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)
+ 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."""
+ 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 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."""
+
+ def edit(param, parent=None):
+ del parent
+ for name, value in values.items():
+ setattr(param, name, value)
+ return True
+
+ 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(
+ RawImageImportParam,
+ "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 (
+ 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(RawImageImportParam, "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(RawImageImportParam, "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_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()
+
+ 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
+ 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 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(
+ 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(RawImageImportParam, "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
diff --git a/datalab/widgets/processingpreview.py b/datalab/widgets/processingpreview.py
index 7c1088639..98d78af41 100644
--- a/datalab/widgets/processingpreview.py
+++ b/datalab/widgets/processingpreview.py
@@ -89,6 +89,12 @@ 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
+ # 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
@@ -268,12 +274,22 @@ 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:
+ if self.format_error is not None:
+ message = self.format_error(message)
self.status.setText(_("Preview failed"))
self.details.setPlainText(message)
self.details.show()
diff --git a/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po b/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po
index d0a80f388..348b0c6fd 100644
--- a/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po
+++ b/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po
@@ -598,6 +598,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é."
@@ -613,12 +616,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 ""
@@ -635,6 +644,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/features/advanced/proxy.po b/doc/locale/fr/LC_MESSAGES/features/advanced/proxy.po
index 60546710e..3ebefdf2c 100644
--- a/doc/locale/fr/LC_MESSAGES/features/advanced/proxy.po
+++ b/doc/locale/fr/LC_MESSAGES/features/advanced/proxy.po
@@ -6,7 +6,6 @@
#, fuzzy
msgid ""
msgstr ""
-"Plural-Forms: nplurals=2; plural=(n > 1);\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
@@ -14,27 +13,19 @@ msgstr ""
msgid "Proxy objects (:mod:`datalab.control.proxy`)"
msgstr ""
-msgid ""
-"The :mod:`datalab.control.proxy` module provides a way to access DataLab "
-"features from a proxy class."
+msgid "The :mod:`datalab.control.proxy` module provides a way to access DataLab features from a proxy class."
msgstr ""
msgid "Remote proxy"
msgstr ""
-msgid ""
-"The remote proxy is used when DataLab is started from a different process"
-" than the proxy. In this case, the proxy connects to DataLab XML-RPC "
-"server."
+msgid "The remote proxy is used when DataLab is started from a different process than the proxy. In this case, the proxy connects to DataLab XML-RPC server."
msgstr ""
msgid "DataLab remote proxy class."
msgstr ""
-msgid ""
-"This class provides access to DataLab features from a proxy class. This "
-"is the remote version of proxy, which is used when DataLab is started "
-"from a different process than the proxy."
+msgid "This class provides access to DataLab features from a proxy class. This is the remote version of proxy, which is used when DataLab is started from a different process than the proxy."
msgstr ""
msgid "Parameters"
@@ -58,9 +49,7 @@ msgstr ""
msgid "Examples"
msgstr ""
-msgid ""
-"Here is a simple example of how to use RemoteProxy in a Python script or "
-"in a Jupyter notebook:"
+msgid "Here is a simple example of how to use RemoteProxy in a Python script or in a Jupyter notebook:"
msgstr ""
msgid "Add object annotations (annotation plot items)."
@@ -72,9 +61,7 @@ msgstr ""
msgid "refresh plot. Defaults to True."
msgstr ""
-msgid ""
-"panel name (valid values: \"signal\", \"image\"). If None, current panel "
-"is used."
+msgid "panel name (valid values: \"signal\", \"image\"). If None, current panel is used."
msgstr ""
msgid "Add group to DataLab."
@@ -179,11 +166,7 @@ msgstr ""
msgid "Call computation feature ``name``"
msgstr ""
-msgid ""
-"This calls either the processor's ``compute_`` method (if it "
-"exists), or the processor's ```` computation feature (if it is "
-"registered, using the ``run_feature`` method). It looks for the function "
-"in all panels, starting with the current one."
+msgid "This calls either the processor's ``compute_`` method (if it exists), or the processor's ```` computation feature (if it is registered, using the ``run_feature`` method). It looks for the function in all panels, starting with the current one."
msgstr ""
msgid "Compute function name"
@@ -198,9 +181,7 @@ msgstr ""
msgid "Call a public method on a panel or main window."
msgstr ""
-msgid ""
-"Method resolution order when panel is None: 1. Try main window "
-"(DLMainWindow) 2. If not found, try current panel (BaseDataPanel)"
+msgid "Method resolution order when panel is None: 1. Try main window (DLMainWindow) 2. If not found, try current panel (BaseDataPanel)"
msgstr ""
msgid "Name of the method to call"
@@ -209,9 +190,7 @@ msgstr ""
msgid "Positional arguments to pass to the method"
msgstr ""
-msgid ""
-"Panel name (\"signal\", \"image\", or None for auto-detection). Defaults "
-"to None."
+msgid "Panel name (\"signal\", \"image\", or None for auto-detection). Defaults to None."
msgstr ""
msgid "Keyword arguments to pass to the method"
@@ -232,19 +211,13 @@ msgstr ""
msgid "Try to connect to DataLab XML-RPC server."
msgstr ""
-msgid ""
-"XML-RPC port to connect to. If not specified, the port is automatically "
-"retrieved from DataLab configuration."
+msgid "XML-RPC port to connect to. If not specified, the port is automatically retrieved from DataLab configuration."
msgstr ""
-msgid ""
-"Maximum time to wait for connection in seconds. Defaults to 5.0. This is "
-"the total maximum wait time, not per retry."
+msgid "Maximum time to wait for connection in seconds. Defaults to 5.0. This is the total maximum wait time, not per retry."
msgstr ""
-msgid ""
-"Number of retries. Defaults to 10. This parameter is deprecated and will "
-"be removed in a future version (kept for backward compatibility)."
+msgid "Number of retries. Defaults to 10. This parameter is deprecated and will be removed in a future version (kept for backward compatibility)."
msgstr ""
msgid "Return a context manager to temporarily disable auto refresh."
@@ -268,6 +241,33 @@ msgstr ""
msgid "Disconnect from DataLab XML-RPC server."
msgstr ""
+msgid "Export the workspace as a capsule (workspace and its provenance)."
+msgstr ""
+
+msgid "Capsule file name (``.dlcapsule``)."
+msgstr ""
+
+msgid "Capsule name."
+msgstr ""
+
+msgid "Capsule description."
+msgstr ""
+
+msgid "When the workspace was opened from a capsule, name that capsule as the parent revision. Defaults to True."
+msgstr ""
+
+msgid "Absolute IRI where the workspace will be published: the capsule then references the workspace instead of including it."
+msgstr ""
+
+msgid "File name of the workspace published at *workspace_ref* (default: last segment of the IRI, next to the capsule)."
+msgstr ""
+
+msgid "Light files declared in the capsule (16 MiB at most each), never opened or executed."
+msgstr ""
+
+msgid "Manifest fingerprint of the capsule."
+msgstr ""
+
msgid "Return current object uuid in current panel."
msgstr ""
@@ -295,9 +295,7 @@ msgstr ""
msgid "Get object (signal/image) from index."
msgstr ""
-msgid ""
-"Object number, or object id, or object title. Defaults to None (current "
-"object)."
+msgid "Object number, or object id, or object title. Defaults to None (current object)."
msgstr ""
msgid "Panel name. Defaults to None (current panel)."
@@ -315,14 +313,10 @@ msgstr ""
msgid "List of plot item shapes"
msgstr ""
-msgid ""
-"Get object (signal/image) list for current panel. Objects are sorted by "
-"group number and object index in group."
+msgid "Get object (signal/image) list for current panel. Objects are sorted by group number and object index in group."
msgstr ""
-msgid ""
-"panel name (valid values: \"signal\", \"image\", \"macro\"). If None, "
-"current data panel is used (i.e. signal or image panel)."
+msgid "panel name (valid values: \"signal\", \"image\", \"macro\"). If None, current data panel is used (i.e. signal or image panel)."
msgstr ""
msgid "List of object titles"
@@ -331,9 +325,7 @@ msgstr ""
msgid "if panel not found"
msgstr ""
-msgid ""
-"Get object (signal/image) uuid list for current panel. Objects are sorted"
-" by group number and object index in group."
+msgid "Get object (signal/image) uuid list for current panel. Objects are sorted by group number and object index in group."
msgstr ""
msgid "Group number, or group id, or group title. Defaults to None (all groups)."
@@ -393,6 +385,9 @@ msgstr ""
msgid "directory path"
msgstr ""
+msgid "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."
+msgstr ""
+
msgid "Open objects from files in current panel (signals/images)."
msgstr ""
@@ -402,15 +397,10 @@ msgstr ""
msgid "Load native DataLab HDF5 workspace files without any GUI elements."
msgstr ""
-msgid ""
-"This method can be safely called from scripts (e.g., internal console, "
-"macros) as it does not create any Qt widgets, dialogs, or progress bars."
+msgid "This method can be safely called from scripts (e.g., internal console, macros) as it does not create any Qt widgets, dialogs, or progress bars."
msgstr ""
-msgid ""
-"This method only supports native DataLab HDF5 files. For importing "
-"arbitrary HDF5 files (non-native), use :meth:`open_h5_files` or "
-":meth:`import_h5_file` instead."
+msgid "This method only supports native DataLab HDF5 files. For importing arbitrary HDF5 files (non-native), use :meth:`open_h5_files` or :meth:`import_h5_file` instead."
msgstr ""
msgid "List of native DataLab HDF5 filenames"
@@ -422,6 +412,12 @@ msgstr ""
msgid "If a file is not a valid native DataLab HDF5 file"
msgstr ""
+msgid "Open a capsule as the workspace, replacing the current one."
+msgstr ""
+
+msgid "Workspace file of a capsule that references its workspace instead of including it."
+msgstr ""
+
msgid "Open a DataLab HDF5 file or import from any other HDF5 file."
msgstr ""
@@ -467,14 +463,10 @@ msgstr ""
msgid "Select groups in current panel."
msgstr ""
-msgid ""
-"List of group numbers (1 to N), or list of group uuids, or None to select"
-" all groups. Defaults to None."
+msgid "List of group numbers (1 to N), or list of group uuids, or None to select all groups. Defaults to None."
msgstr ""
-msgid ""
-"panel name (valid values: \"signal\", \"image\"). If None, current panel "
-"is used. Defaults to None."
+msgid "panel name (valid values: \"signal\", \"image\"). If None, current panel is used. Defaults to None."
msgstr ""
msgid "Select objects in current panel."
@@ -492,15 +484,10 @@ msgstr ""
msgid "Set object data in DataLab."
msgstr ""
-msgid ""
-"Update an existing object in DataLab with new data from ``obj``. The "
-"object is identified by its UUID (which is carried by ``obj`` from a "
-"previous :meth:`get_object` call)."
+msgid "Update an existing object in DataLab with new data from ``obj``. The object is identified by its UUID (which is carried by ``obj`` from a previous :meth:`get_object` call)."
msgstr ""
-msgid ""
-"Signal or image object (must have the same UUID as an existing object in "
-"DataLab)"
+msgid "Signal or image object (must have the same UUID as an existing object in DataLab)"
msgstr ""
msgid "if no object with matching UUID is found"
@@ -509,9 +496,7 @@ msgstr ""
msgid "Set XML-RPC port to connect to."
msgstr ""
-msgid ""
-"XML-RPC port to connect to. If None, the port is automatically retrieved "
-"from DataLab configuration."
+msgid "XML-RPC port to connect to. If None, the port is automatically retrieved from DataLab configuration."
msgstr ""
msgid "Start the WebAPI server."
@@ -550,19 +535,13 @@ msgstr ""
msgid "Local proxy"
msgstr ""
-msgid ""
-"The local proxy is used when DataLab is started from the same process as "
-"the proxy. In this case, the proxy is directly connected to DataLab main "
-"window instance. The typical use case is high-level scripting."
+msgid "The local proxy is used when DataLab is started from the same process as the proxy. In this case, the proxy is directly connected to DataLab main window instance. The typical use case is high-level scripting."
msgstr ""
msgid "DataLab local proxy class."
msgstr ""
-msgid ""
-"This class provides access to DataLab features from a proxy class. This "
-"is the local version of proxy, which is used when DataLab is started from"
-" the same process as the proxy."
+msgid "This class provides access to DataLab features from a proxy class. This is the local version of proxy, which is used when DataLab is started from the same process as the proxy."
msgstr ""
msgid "DLMainWindow instance."
@@ -592,9 +571,7 @@ msgstr ""
msgid "Compute function parameter. Defaults to None"
msgstr ""
-msgid ""
-"Object number, or object id, or object title. Defaults to None (current "
-"object)"
+msgid "Object number, or object id, or object title. Defaults to None (current object)"
msgstr ""
msgid "Panel name. Defaults to None (current panel)"
@@ -603,18 +580,13 @@ msgstr ""
msgid "refresh plot. Defaults to True"
msgstr ""
-msgid ""
-"panel name (valid values: \"signal\", \"image\"). If None, current panel "
-"is used"
+msgid "panel name (valid values: \"signal\", \"image\"). If None, current panel is used"
msgstr ""
msgid "Load HDF5 workspace files without showing file dialog."
msgstr ""
-msgid ""
-"This method loads one or more DataLab native HDF5 files directly, "
-"bypassing the file dialog. It is safe to call from the internal console "
-"or any context where Qt dialogs would cause threading issues."
+msgid "This method loads one or more DataLab native HDF5 files directly, bypassing the file dialog. It is safe to call from the internal console or any context where Qt dialogs would cause threading issues."
msgstr ""
msgid "Path(s) to HDF5 file(s). Can be a single path string or a list of paths"
@@ -629,10 +601,7 @@ msgstr ""
msgid "Save workspace to HDF5 file without showing file dialog."
msgstr ""
-msgid ""
-"This method saves the current workspace to a DataLab native HDF5 file "
-"directly, bypassing the file dialog. It is safe to call from the internal"
-" console or any context where Qt dialogs would cause threading issues."
+msgid "This method saves the current workspace to a DataLab native HDF5 file directly, bypassing the file dialog. It is safe to call from the internal console or any context where Qt dialogs would cause threading issues."
msgstr ""
msgid "Path to the output HDF5 file"
@@ -662,29 +631,19 @@ msgstr ""
msgid "Proxy context manager"
msgstr ""
-msgid ""
-"The proxy context manager is a convenient way to handle proxy creation "
-"and destruction. It is used as follows:"
+msgid "The proxy context manager is a convenient way to handle proxy creation and destruction. It is used as follows:"
msgstr ""
-msgid ""
-"The proxy type can be \"local\" or \"remote\". For remote proxy, the port"
-" can be specified as \"remote:port\"."
+msgid "The proxy type can be \"local\" or \"remote\". For remote proxy, the port can be specified as \"remote:port\"."
msgstr ""
-msgid ""
-"The proxy context manager allows to use the proxy in various contexts "
-"(Python script, Jupyter notebook, etc.). It also allows to switch "
-"seamlessly between local and remote proxy, keeping the same code inside "
-"the context."
+msgid "The proxy context manager allows to use the proxy in various contexts (Python script, Jupyter notebook, etc.). It also allows to switch seamlessly between local and remote proxy, keeping the same code inside the context."
msgstr ""
msgid "Context manager handling DL proxy creation and destruction."
msgstr ""
-msgid ""
-"proxy type (\"local\" or \"remote\") For remote proxy, the port can be "
-"specified as \"remote:port\""
+msgid "proxy type (\"local\" or \"remote\") For remote proxy, the port can be specified as \"remote:port\""
msgstr ""
msgid "Yields"
@@ -693,9 +652,7 @@ msgstr ""
msgid "proxy"
msgstr ""
-msgid ""
-"LocalProxy if what == \"local\" RemoteProxy if what == \"remote\" or "
-"\"remote:port\""
+msgid "LocalProxy if what == \"local\" RemoteProxy if what == \"remote\" or \"remote:port\""
msgstr ""
msgid "with proxy_context(\"local\") as proxy:"
@@ -707,9 +664,7 @@ msgstr ""
msgid "Calling processor methods using proxy objects"
msgstr ""
-msgid ""
-"All the proxy objects provide access to the DataLab computing methods "
-"exposed by the processor classes:"
+msgid "All the proxy objects provide access to the DataLab computing methods exposed by the processor classes:"
msgstr ""
msgid ":class:`datalab.gui.processor.signal.SignalProcessor`"
@@ -718,7 +673,6 @@ msgstr ""
msgid ":class:`datalab.gui.processor.image.ImageProcessor`"
msgstr ""
-msgid ""
-"To run a computation feature associated to a processor, you can use the "
-":meth:`calc` method of the proxy object:"
+msgid "To run a computation feature associated to a processor, you can use the :meth:`calc` method of the proxy object:"
msgstr ""
+
diff --git a/doc/locale/fr/LC_MESSAGES/features/advanced/remote.po b/doc/locale/fr/LC_MESSAGES/features/advanced/remote.po
index 3fdc481f5..53e16bd12 100644
--- a/doc/locale/fr/LC_MESSAGES/features/advanced/remote.po
+++ b/doc/locale/fr/LC_MESSAGES/features/advanced/remote.po
@@ -280,6 +280,21 @@ msgstr "Objet signal ou image (doit avoir le même UUID qu'un objet existant dan
msgid "if no object with matching UUID is found"
msgstr "si aucun objet avec un UUID correspondant n'est trouvé"
+msgid "Open objects from files in current panel (signals/images)."
+msgstr "Ouvrir des objets depuis des fichiers dans le panneau courant (signaux/images)."
+
+msgid "list of file names"
+msgstr "liste des noms de fichiers"
+
+msgid "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."
+msgstr ""
+
+msgid "Open objects from directory in current panel (signals/images)."
+msgstr "Ouvrir des objets depuis un répertoire dans le panneau courant (signaux/images)."
+
+msgid "directory path"
+msgstr "chemin du répertoire"
+
msgid "Call computation feature ``name``"
msgstr "Appeler la fonctionnalité de calcul ``name``"
@@ -523,18 +538,6 @@ msgstr "Importer une macro depuis un fichier"
msgid "Filename."
msgstr "Nom du fichier."
-msgid "Open objects from directory in current panel (signals/images)."
-msgstr "Ouvrir des objets depuis un répertoire dans le panneau courant (signaux/images)."
-
-msgid "directory path"
-msgstr "chemin du répertoire"
-
-msgid "Open objects from files in current panel (signals/images)."
-msgstr "Ouvrir des objets depuis des fichiers dans le panneau courant (signaux/images)."
-
-msgid "list of file names"
-msgstr "liste des noms de fichiers"
-
msgid "Load native DataLab HDF5 workspace files without any GUI elements."
msgstr "Charger des fichiers d'espace de travail HDF5 natifs DataLab sans aucun élément d'interface graphique."
diff --git a/doc/locale/fr/LC_MESSAGES/features/common/capsules.po b/doc/locale/fr/LC_MESSAGES/features/common/capsules.po
index 94dc5e984..913ddb7e3 100644
--- a/doc/locale/fr/LC_MESSAGES/features/common/capsules.po
+++ b/doc/locale/fr/LC_MESSAGES/features/common/capsules.po
@@ -6,18 +6,11 @@
#, fuzzy
msgid ""
msgstr ""
-"Project-Id-Version: PROJECT VERSION\n"
-"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
-"POT-Creation-Date: 2026-10-19 07:30+0200\n"
-"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
-"Last-Translator: FULL NAME \n"
"Language: fr\n"
-"Language-Team: fr \n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
-"Generated-By: Babel 2.18.0\n"
msgid "Workspace capsules in DataLab: export a workspace with the provenance of its processing, inspect a capsule, replay its activities, compare revisions, write reviews and exchange them through a registry"
msgstr "Capsules d'espace de travail dans DataLab : exporter un espace de travail avec la provenance de ses traitements, inspecter une capsule, rejouer ses activités, comparer des révisions, rédiger des revues et les échanger via un registre"
diff --git a/doc/locale/fr/LC_MESSAGES/features/common/provenance.po b/doc/locale/fr/LC_MESSAGES/features/common/provenance.po
index c5ae362f2..9a5b85810 100644
--- a/doc/locale/fr/LC_MESSAGES/features/common/provenance.po
+++ b/doc/locale/fr/LC_MESSAGES/features/common/provenance.po
@@ -5,18 +5,11 @@
#
msgid ""
msgstr ""
-"Project-Id-Version: PROJECT VERSION\n"
-"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
-"POT-Creation-Date: 2026-10-19 07:30+0200\n"
-"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
-"Last-Translator: FULL NAME \n"
"Language: fr\n"
-"Language-Team: fr \n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
-"Generated-By: Babel 2.18.0\n"
msgid "Provenance of results in DataLab: consultation, data check, verification by recomputation, applying a processing again, link with the History panel"
msgstr "Provenance des résultats dans DataLab : consultation, contrôle des données, vérification par recalcul, nouvelle application d'un traitement, lien avec le panneau Historique"
diff --git a/doc/locale/fr/LC_MESSAGES/intro/installation.po b/doc/locale/fr/LC_MESSAGES/intro/installation.po
index 07af2216c..ac1fc2082 100644
--- a/doc/locale/fr/LC_MESSAGES/intro/installation.po
+++ b/doc/locale/fr/LC_MESSAGES/intro/installation.po
@@ -226,7 +226,7 @@ msgstr "Langage de programmation Python"
msgid "guidata"
msgstr ""
-msgid ">= 3.15.1"
+msgid ">= 3.16.0"
msgstr ""
msgid "Automatic GUI generation for easy dataset editing and display"
@@ -244,7 +244,7 @@ msgstr "Outils de tracé de courbes et d'images pour les applications Python/Qt"
msgid "Sigima"
msgstr ""
-msgid ">= 1.2.0"
+msgid ">= 1.3.0"
msgstr ""
msgid "Scientific computing engine for 1D signals and 2D images, part of the DataLab open-source platform."
@@ -253,12 +253,21 @@ msgstr ""
msgid "SigimaX"
msgstr ""
-msgid ">= 1.0.2"
+msgid ">= 1.1.0"
msgstr ""
msgid "Reusable GUI components and application utilities, part of the DataLab platform for signal and image processing"
msgstr ""
+msgid "datalab-capsule"
+msgstr ""
+
+msgid ">= 0.1.0, < 0.2"
+msgstr ""
+
+msgid "Exchange and verify scientific workspace provenance as open RO-Crate capsules (library and CLI, no DataLab required)"
+msgstr ""
+
msgid "NumPy"
msgstr ""
@@ -483,3 +492,4 @@ msgstr ""
msgid "Python 3.11 and PyQt5 are the reference for production release"
msgstr "Python 3.11 et PyQt5 sont les références pour la version de production"
+
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 d4550f362..29d095b95 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
@@ -321,6 +321,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 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/locale/fr/LC_MESSAGES/requirements.po b/doc/locale/fr/LC_MESSAGES/requirements.po
index ea443c002..3dbac983c 100644
--- a/doc/locale/fr/LC_MESSAGES/requirements.po
+++ b/doc/locale/fr/LC_MESSAGES/requirements.po
@@ -34,7 +34,7 @@ msgstr "Langage de programmation Python"
msgid "guidata"
msgstr ""
-msgid ">= 3.15.1"
+msgid ">= 3.16.0"
msgstr ""
msgid "Automatic GUI generation for easy dataset editing and display"
@@ -52,7 +52,7 @@ msgstr "Outils de tracé de courbes et d'images pour les applications Python/Qt"
msgid "Sigima"
msgstr ""
-msgid ">= 1.2.0"
+msgid ">= 1.3.0"
msgstr ""
msgid "Scientific computing engine for 1D signals and 2D images, part of the DataLab open-source platform."
@@ -61,7 +61,7 @@ msgstr "Moteur de calcul scientifique pour signaux 1D et images 2D, faisant part
msgid "SigimaX"
msgstr ""
-msgid ">= 1.0.2"
+msgid ">= 1.1.0"
msgstr ""
msgid "Reusable GUI components and application utilities, part of the DataLab platform for signal and image processing"
@@ -297,3 +297,4 @@ msgstr ""
msgid "The next generation HTTP client."
msgstr "Client HTTP de nouvelle génération."
+
diff --git a/doc/release_notes/release_1.04.md b/doc/release_notes/release_1.04.md
index 6c627077c..804d9d300 100644
--- a/doc/release_notes/release_1.04.md
+++ b/doc/release_notes/release_1.04.md
@@ -139,6 +139,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 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:**
* Adding, pasting or deleting metadata now marks the workspace as modified, so DataLab asks to save these changes before closing