From c64e043e8ad97c5a4ec00446185acf03c9a43c06 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:12:14 +0200 Subject: [PATCH 1/2] feat: migrate plot annotations to portable format Lazily migrate legacy PlotPy annotations while preserving workspace compatibility. Assisted-by: GPT-5.6 Sol --- datalab/gui/panel/base.py | 13 ++-- .../annotations_management_unit_test.py | 26 ++++++-- .../features/control/remoteclient_unit.py | 31 ++++++--- .../features/hdf5/h5workspace_unit_test.py | 28 ++++++++- .../features/image/annotations_unit_test.py | 63 +++++++++++++++++++ doc/release_notes/release_1.03.md | 8 ++- pyproject.toml | 4 +- requirements.txt | 4 +- 8 files changed, 152 insertions(+), 25 deletions(-) diff --git a/datalab/gui/panel/base.py b/datalab/gui/panel/base.py index eb79ccbe9..2bb4067c3 100644 --- a/datalab/gui/panel/base.py +++ b/datalab/gui/panel/base.py @@ -3516,8 +3516,10 @@ def toggle_annotations(enabled: bool): plot = dlg.get_plot() for item in plot.items: item.set_selectable(False) - for item in create_adapter_from_object(obj).iterate_shape_items(editable=True): + adapter = create_adapter_from_object(obj) + for item in adapter.iterate_shape_items(editable=True): plot.add_item(item) + adapter.annotation_adapter.capture_item_reference(item) self.__separate_views[dlg] = obj toggle_annotations(edit_annotations) if len(oids) > 1: @@ -3539,13 +3541,14 @@ def __separate_view_finished(self, result: int) -> None: """ dlg: PlotDialog = self.sender() if result == QW.QDialog.DialogCode.Accepted: + obj = self.__separate_views[dlg] + adapter = create_adapter_from_object(obj) rw_items = [] for item in dlg.get_plot().get_items(): - if not item.is_readonly() and is_plot_item_serializable(item): + if adapter.annotation_adapter.is_annotation_item( + item + ) and is_plot_item_serializable(item): rw_items.append(item) - obj = self.__separate_views[dlg] - # Use the annotation adapter to set annotations in the new format - adapter = create_adapter_from_object(obj) adapter.set_annotations_from_items(rw_items) self.selection_changed(update_items=True) self.__separate_views.pop(dlg) diff --git a/datalab/tests/features/common/annotations_management_unit_test.py b/datalab/tests/features/common/annotations_management_unit_test.py index 4907833ad..5b0aca81a 100644 --- a/datalab/tests/features/common/annotations_management_unit_test.py +++ b/datalab/tests/features/common/annotations_management_unit_test.py @@ -4,12 +4,31 @@ import os.path as osp +from sigima.objects import RectangleAnnotation, annotation_to_dict from sigima.tests import data as test_data from datalab.env import execenv from datalab.tests import datalab_test_app_context, helpers +def make_portable_annotations() -> list[dict]: + """Return canonical and opaque annotations for persistence tests.""" + return [ + annotation_to_dict( + RectangleAnnotation( + x=1.0, + y=2.0, + width=3.0, + height=4.0, + title="Portable", + metadata={"owner": "test"}, + extensions={"vendor": {"keep": True}}, + ) + ), + {"consumer": "custom", "payload": {"keep": True}}, + ] + + def test_annotations_copy_paste(): """Test copying and pasting annotations between objects.""" with execenv.context(unattended=True): @@ -21,10 +40,7 @@ def test_annotations_copy_paste(): sig2 = test_data.create_paracetamol_signal() # Add annotations to first signal - orig_annotations = [ - {"type": "label", "text": "Peak 1"}, - {"type": "label", "text": "Peak 2"}, - ] + orig_annotations = make_portable_annotations() sig1.set_annotations(orig_annotations) # Add objects to panel - sig1 will be selected after this @@ -53,7 +69,7 @@ def test_annotations_import_export(): # Create signal with annotations sig = test_data.create_paracetamol_signal() - orig_annotations = [{"type": "label", "text": "Test annotation"}] + orig_annotations = make_portable_annotations() sig.set_annotations(orig_annotations) panel.add_object(sig) diff --git a/datalab/tests/features/control/remoteclient_unit.py b/datalab/tests/features/control/remoteclient_unit.py index 7bbdc6b93..4ba212696 100644 --- a/datalab/tests/features/control/remoteclient_unit.py +++ b/datalab/tests/features/control/remoteclient_unit.py @@ -8,7 +8,6 @@ # pylint: disable=duplicate-code # guitest: skip -import os import os.path as osp import numpy as np @@ -17,10 +16,9 @@ from sigima.params import XYCalibrateParam from sigima.tests.data import create_2d_gaussian, create_paracetamol_signal -from datalab import app from datalab.control.proxy import RemoteProxy from datalab.env import execenv -from datalab.tests import helpers +from datalab.tests import datalab_in_background_context, helpers def multiple_commands(remote: RemoteProxy): @@ -36,12 +34,31 @@ def multiple_commands(remote: RemoteProxy): remote.add_annotations_from_items([rect]) uuid = remote.get_sel_object_uuids()[0] assert remote.get_current_object_uuid() == uuid + canonical_annotations = remote.get_object(uuid).get_annotations() + assert len(canonical_annotations) == 1 + assert canonical_annotations[0]["format"] == "sigima.annotation" + assert "plotpy_json" not in canonical_annotations[0] items = remote.get_object_shapes() assert len(items) == 1 and items[0].get_rect() == area remote.add_label_with_title(f"Image uuid: {uuid}") remote.select_groups([1]) remote.select_objects([uuid]) remote.delete_metadata() + canonical_annotations = remote.get_object(uuid).get_annotations() + + annotations_workspace = osp.join(tmpdir, "annotations_workspace.h5") + remote.save_h5_workspace(annotations_workspace) + remote.reset_all() + remote.load_h5_workspace([annotations_workspace], reset_all=True) + remote.set_current_panel("image") + restored_annotations = [ + remote.get_object(image_uuid).get_annotations() + for image_uuid in remote.get_object_uuids() + ] + assert canonical_annotations in restored_annotations, ( + "Canonical annotations were not restored from the workspace: " + f"expected {canonical_annotations!r}, got {restored_annotations!r}" + ) fname = osp.join(tmpdir, osp.basename("remote_test.h5")) remote.save_to_h5_file(fname) @@ -82,14 +99,10 @@ def multiple_commands(remote: RemoteProxy): def test_remoteclient_unit(): """Remote client test""" - env = os.environ.copy() - env[execenv.DO_NOT_QUIT_ENV] = "1" - execenv.print("Launching DataLab in a separate process") - helpers.exec_script(app.__file__, wait=False, env=env) - remote = RemoteProxy() execenv.print("Executing multiple commands...", end="") with qt_app_context(): # needed for building plot items - multiple_commands(remote) + with datalab_in_background_context() as remote: + multiple_commands(remote) execenv.print("OK") diff --git a/datalab/tests/features/hdf5/h5workspace_unit_test.py b/datalab/tests/features/hdf5/h5workspace_unit_test.py index de244b442..684dcdb58 100644 --- a/datalab/tests/features/hdf5/h5workspace_unit_test.py +++ b/datalab/tests/features/hdf5/h5workspace_unit_test.py @@ -24,8 +24,11 @@ import h5py import pytest +from guidata.io import JSONWriter from numpy import ma -from sigima.objects import GaussParam +from plotpy.builder import make +from plotpy.io import save_items +from sigima.objects import GaussParam, RectangleAnnotation, annotation_to_dict from sigima.objects.scalar import NO_ROI, TableResult, TableResultBuilder from sigima.tests.data import ( create_noisy_gaussian_image, @@ -44,6 +47,28 @@ def test_save_and_load_h5_workspace(): with datalab_test_app_context(console=False) as win: # === Create test objects sig1 = create_paracetamol_signal() + legacy_item = make.annotated_segment(1.0, 2.0, 5.0, 8.0, title="Historical") + writer = JSONWriter(None) + save_items(writer, [legacy_item]) + annotations = [ + annotation_to_dict( + RectangleAnnotation( + x=1.0, + y=2.0, + width=3.0, + height=4.0, + title="Canonical", + extensions={"vendor": {"keep": True}}, + ) + ), + { + "type": "plotpy_item", + "item_class": type(legacy_item).__name__, + "plotpy_json": writer.get_json(), + }, + {"consumer": "custom", "payload": {"keep": True}}, + ] + sig1.set_annotations(annotations) win.signalpanel.add_object(sig1) ima1 = create_noisy_gaussian_image() @@ -79,6 +104,7 @@ def test_save_and_load_h5_workspace(): loaded_ima = win.imagepanel.objmodel.get_all_objects()[0] assert loaded_sig.title == sig_title assert loaded_ima.title == ima_title + assert loaded_sig.get_annotations() == annotations def test_peak_creation_parameters_h5_roundtrip(): diff --git a/datalab/tests/features/image/annotations_unit_test.py b/datalab/tests/features/image/annotations_unit_test.py index 60af9c69d..3f03e8723 100644 --- a/datalab/tests/features/image/annotations_unit_test.py +++ b/datalab/tests/features/image/annotations_unit_test.py @@ -11,11 +11,14 @@ # guitest: show +from guidata.io import JSONWriter from plotpy.builder import make +from plotpy.io import save_items from plotpy.items import AnnotatedShape, PolygonShape from plotpy.plot import BasePlot from qtpy import QtCore as QC from qtpy import QtWidgets as QW +from sigima.objects import RectangleAnnotation, annotation_to_dict, create_image_roi from sigima.tests import data as test_data from datalab.adapters_plotpy import create_adapter_from_object @@ -56,6 +59,10 @@ def test_annotations_unit(): label = make.label("Test", (1000, 1000), (0, 0), "BR") adapter = create_adapter_from_object(ima1) adapter.add_annotations_from_items([rect, circ, elli, segm, label]) + assert all( + payload["format"] == "sigima.annotation" + for payload in ima1.get_annotations() + ) panel.add_object(ima1) # Create another image with annotations @@ -78,6 +85,62 @@ def test_annotations_unit(): execenv.print("OK") +def test_separate_view_migrates_annotations_only_when_accepted() -> None: + """The annotation dialog migrates lazily and preserves opaque state.""" + with datalab_test_app_context() as win: + panel = win.imagepanel + image = test_data.create_multigaussian_image() + canonical = annotation_to_dict( + RectangleAnnotation( + x=3.0, + y=5.0, + width=4.0, + height=6.0, + locked=True, + title="Locked", + metadata={"owner": "test"}, + extensions={"vendor": {"keep": True}}, + ) + ) + legacy_item = make.annotated_segment(1.0, 2.0, 5.0, 8.0, title="Legacy") + writer = JSONWriter(None) + save_items(writer, [legacy_item]) + legacy = { + "type": "plotpy_item", + "item_class": type(legacy_item).__name__, + "plotpy_json": writer.get_json(), + } + opaque = {"consumer": "custom", "payload": {"keep": True}} + image.set_annotations([canonical, legacy, opaque]) + image.roi = create_image_roi("rectangle", [10, 20, 30, 40]) + original_roi = image.roi.to_dict() + panel.add_object(image) + original = image.annotations + + dialog = panel.open_separate_view(edit_annotations=True) + assert dialog is not None + locked_items = [ + item + for item in dialog.get_plot().get_items() + if isinstance(item, AnnotatedShape) and str(item.title().text()) == "Locked" + ] + assert len(locked_items) == 1 + assert locked_items[0].is_readonly() + dialog.done(QW.QDialog.DialogCode.Rejected) + assert image.annotations == original + + dialog = panel.open_separate_view(edit_annotations=True) + assert dialog is not None + dialog.done(QW.QDialog.DialogCode.Accepted) + + preserved, migrated, preserved_opaque = image.get_annotations() + assert preserved == canonical + assert migrated["format"] == "sigima.annotation" + assert "plotpy_json" not in migrated + assert preserved_opaque == opaque + assert image.roi.to_dict() == original_roi + + def test_open_separate_view_without_main_plot_item() -> None: """Open a separate view when the object has no item in the main plot.""" with datalab_test_app_context() as win: diff --git a/doc/release_notes/release_1.03.md b/doc/release_notes/release_1.03.md index a34b7176d..69b14b3b7 100644 --- a/doc/release_notes/release_1.03.md +++ b/doc/release_notes/release_1.03.md @@ -125,11 +125,17 @@ and Image panels (implements ### 🔄 Changes ### +**Portable plot annotations:** + +* Plot annotations are now stored in a renderer-independent format shared with DataLab-Web, so annotations in workspaces and `.dlabann` files are no longer tied to PlotPy +* Existing PlotPy annotations remain readable and are converted only after an annotation edit is accepted; simply opening a workspace or cancelling the editor leaves its data unchanged +* Annotation identifiers, lock state, custom metadata and extension data are preserved across edits, while unknown third-party payloads are retained without modification + **DataLab now builds on SigimaX:** * All the generic, application-level parts of DataLab (main window skeleton, configuration system, dockable plot widgets, HDF5 workspace and browser, log viewer, splash screen, status bar, scientific dialogs and PlotPy adapters) have been extracted into a new reusable library, **SigimaX**, and DataLab now derives from it instead of maintaining its own copies (implements [Issue #182](https://github.com/DataLab-Platform/DataLab/issues/182)) * This is an internal refactoring: existing workflows, settings and files are unchanged, but it considerably reduces duplicated code and makes it possible to build other Qt scientific applications on the same foundation -* As a consequence, DataLab now requires SigimaX ≥ 1.0.1, and its minimum requirements are aligned with it: Sigima ≥ 1.2.0, guidata ≥ 3.15.0 and PlotPy ≥ 2.11.0 +* As a consequence, DataLab now requires SigimaX ≥ 1.1.0, and its minimum requirements are aligned with it: Sigima ≥ 1.3.0, guidata ≥ 3.15.0 and PlotPy ≥ 2.11.0 **Configuration system:** diff --git a/pyproject.toml b/pyproject.toml index 78710659d..21785a256 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,8 +43,8 @@ requires-python = ">=3.9, <4" dependencies = [ "guidata >= 3.15.0", "PlotPy >= 2.11.0", - "Sigima >= 1.2.0", - "SigimaX >= 1.0.1", + "Sigima >= 1.3.0", + "SigimaX >= 1.1.0", "NumPy >= 1.22, < 2.5", "SciPy >= 1.10.1, < 1.17", "scikit-image >= 0.19.2, < 0.27", diff --git a/requirements.txt b/requirements.txt index bd71d04ae..cbda6183b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,8 +4,8 @@ PlotPy >= 2.11.0 PyQt5 >= 5.15.6 PyWavelets >= 1.2, < 2.0 SciPy >= 1.10.1, < 1.17 -Sigima >= 1.2.0 -SigimaX >= 1.0.1 +Sigima >= 1.3.0 +SigimaX >= 1.1.0 babel build fastapi >= 0.110.0 From ac00f7aa92c3eb6223424f240cdfce25367c50c9 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:37:22 +0200 Subject: [PATCH 2/2] fix(ci): install stacked annotation dependencies Use matching Sigima and SigimaX branches before their releases. Assisted-by: GPT-5.6 Sol --- .github/workflows/test_pyqt5.yml | 9 ++++++--- .github/workflows/test_pyqt6.yml | 9 ++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test_pyqt5.yml b/.github/workflows/test_pyqt5.yml index 86216478d..b2d10caeb 100644 --- a/.github/workflows/test_pyqt5.yml +++ b/.github/workflows/test_pyqt5.yml @@ -52,18 +52,21 @@ jobs: python -m pip install --upgrade pip python -m pip install ruff pytest httpx pip install PyQt5 - if [ "${{ github.ref_name }}" = "develop" ]; then + if [ "${{ github.base_ref || github.ref_name }}" = "develop" ]; then # Clone and install development versions of key dependencies with editable install cd .. git clone --depth 1 https://github.com/PlotPyStack/PythonQwt.git git clone --depth 1 --branch develop https://github.com/PlotPyStack/guidata.git git clone --depth 1 --branch develop https://github.com/PlotPyStack/plotpy.git - git clone --depth 1 --branch develop https://github.com/DataLab-Platform/sigima.git + DEPENDENCY_BRANCH="${{ github.head_ref || github.ref_name }}" + git clone --depth 1 --branch "$DEPENDENCY_BRANCH" https://github.com/DataLab-Platform/sigima.git || git clone --depth 1 --branch develop https://github.com/DataLab-Platform/sigima.git + git clone --depth 1 --branch "$DEPENDENCY_BRANCH" https://github.com/DataLab-Platform/SigimaX.git || git clone --depth 1 --branch develop https://github.com/DataLab-Platform/SigimaX.git cd DataLab pip install -e ../guidata pip install -e ../PythonQwt pip install -e ../plotpy pip install -e ../sigima + pip install -e ../SigimaX --no-deps # Install tomli for TOML parsing (safe if already present) pip install tomli # Extract dependencies and save to file, then install @@ -71,7 +74,7 @@ jobs: pip install -r deps.txt # Install DataLab without dependencies pip install --no-deps . - elif [ "${{ github.ref_name }}" = "release" ]; then + elif [ "${{ github.base_ref || github.ref_name }}" = "release" ]; then # Clone dependencies from release branches (with fallback to main/master) cd .. # Try cloning PythonQwt from main or master diff --git a/.github/workflows/test_pyqt6.yml b/.github/workflows/test_pyqt6.yml index 2bd710dd4..afbe83efa 100644 --- a/.github/workflows/test_pyqt6.yml +++ b/.github/workflows/test_pyqt6.yml @@ -52,18 +52,21 @@ jobs: python -m pip install --upgrade pip python -m pip install ruff pytest httpx pip install PyQt6 - if [ "${{ github.ref_name }}" = "develop" ]; then + if [ "${{ github.base_ref || github.ref_name }}" = "develop" ]; then # Clone and install development versions of key dependencies with editable install cd .. git clone --depth 1 https://github.com/PlotPyStack/PythonQwt.git git clone --depth 1 --branch develop https://github.com/PlotPyStack/guidata.git git clone --depth 1 --branch develop https://github.com/PlotPyStack/plotpy.git - git clone --depth 1 --branch develop https://github.com/DataLab-Platform/sigima.git + DEPENDENCY_BRANCH="${{ github.head_ref || github.ref_name }}" + git clone --depth 1 --branch "$DEPENDENCY_BRANCH" https://github.com/DataLab-Platform/sigima.git || git clone --depth 1 --branch develop https://github.com/DataLab-Platform/sigima.git + git clone --depth 1 --branch "$DEPENDENCY_BRANCH" https://github.com/DataLab-Platform/SigimaX.git || git clone --depth 1 --branch develop https://github.com/DataLab-Platform/SigimaX.git cd DataLab pip install -e ../guidata pip install -e ../PythonQwt pip install -e ../plotpy pip install -e ../sigima + pip install -e ../SigimaX --no-deps # Install tomli for TOML parsing (safe if already present) pip install tomli # Extract dependencies and save to file, then install @@ -71,7 +74,7 @@ jobs: pip install -r deps.txt # Install DataLab without dependencies pip install --no-deps . - elif [ "${{ github.ref_name }}" = "release" ]; then + elif [ "${{ github.base_ref || github.ref_name }}" = "release" ]; then # Clone dependencies from release branches (with fallback to main/master) cd .. # Try cloning PythonQwt from main or master