Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions .github/workflows/test_pyqt5.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,26 +52,29 @@ 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
python -c "import tomli; f=open('pyproject.toml','rb'); data=tomli.load(f); deps=[d for d in data['project']['dependencies'] if not any(p in d for p in ['guidata','PlotPy','Sigima'])]; open('deps.txt','w').write('\n'.join(deps))"
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
Expand Down
9 changes: 6 additions & 3 deletions .github/workflows/test_pyqt6.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,26 +52,29 @@ 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
python -c "import tomli; f=open('pyproject.toml','rb'); data=tomli.load(f); deps=[d for d in data['project']['dependencies'] if not any(p in d for p in ['guidata','PlotPy','Sigima'])]; open('deps.txt','w').write('\n'.join(deps))"
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
Expand Down
13 changes: 8 additions & 5 deletions datalab/gui/panel/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand Down
26 changes: 21 additions & 5 deletions datalab/tests/features/common/annotations_management_unit_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
31 changes: 22 additions & 9 deletions datalab/tests/features/control/remoteclient_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
# pylint: disable=duplicate-code
# guitest: skip

import os
import os.path as osp

import numpy as np
Expand All @@ -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):
Expand All @@ -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)
Expand Down Expand Up @@ -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")


Expand Down
28 changes: 27 additions & 1 deletion datalab/tests/features/hdf5/h5workspace_unit_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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()
Expand Down Expand Up @@ -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():
Expand Down
63 changes: 63 additions & 0 deletions datalab/tests/features/image/annotations_unit_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
8 changes: 7 additions & 1 deletion doc/release_notes/release_1.03.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**

Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading