From 15f935b0099124d3a4d6df5045b400da9c8fcc8e Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:03:01 +0200 Subject: [PATCH 1/2] feat(annotations): add renderer-agnostic graphical annotations Introduce a versioned canonical model, schema, persistence and geometric transformations with PlotPy and Matplotlib renderers. Preserve legacy and unknown annotation payloads across round trips. Implements #53. --- doc/api/annotations.rst | 120 ++++ doc/api/index.rst | 3 + doc/api/viz.rst | 20 + doc/release_notes/release_1.03.md | 7 + pyproject.toml | 2 +- sigima/io/__init__.py | 4 + sigima/io/common/objmeta.py | 47 +- sigima/objects/__init__.py | 72 ++ sigima/objects/annotations/__init__.py | 83 +++ sigima/objects/annotations/model.py | 492 ++++++++++++++ sigima/objects/annotations/schema-v1.json | 260 ++++++++ sigima/objects/annotations/serialization.py | 299 +++++++++ sigima/objects/annotations/transform.py | 490 ++++++++++++++ sigima/objects/base.py | 89 ++- sigima/proc/image/geometry.py | 9 + sigima/proc/image/transformations.py | 22 + .../common/annotations_model_unit_test.py | 103 +++ .../common/annotations_schema_unit_test.py | 107 +++ .../common/annotations_transform_unit_test.py | 112 ++++ sigima/tests/common/annotations_unit_test.py | 76 +++ sigima/tests/image/geometry_unit_test.py | 85 +++ sigima/tests/io/annotations_io_unit_test.py | 33 +- sigima/tests/viz/annotation_mpl_unit_test.py | 82 +++ .../tests/viz/annotation_plotpy_unit_test.py | 197 ++++++ sigima/tests/viz/annotations_gui_test.py | 390 +++++++++++ sigima/tests/viz/viz_api_unit_test.py | 17 + sigima/viz/__init__.py | 4 + sigima/viz/annotation_mpl.py | 328 +++++++++ sigima/viz/annotation_plotpy.py | 625 ++++++++++++++++++ sigima/viz/viz_mpl.py | 17 + sigima/viz/viz_plotpy.py | 33 + 31 files changed, 4223 insertions(+), 5 deletions(-) create mode 100644 doc/api/annotations.rst create mode 100644 doc/release_notes/release_1.03.md create mode 100644 sigima/objects/annotations/__init__.py create mode 100644 sigima/objects/annotations/model.py create mode 100644 sigima/objects/annotations/schema-v1.json create mode 100644 sigima/objects/annotations/serialization.py create mode 100644 sigima/objects/annotations/transform.py create mode 100644 sigima/tests/common/annotations_model_unit_test.py create mode 100644 sigima/tests/common/annotations_schema_unit_test.py create mode 100644 sigima/tests/common/annotations_transform_unit_test.py create mode 100644 sigima/tests/viz/annotation_mpl_unit_test.py create mode 100644 sigima/tests/viz/annotation_plotpy_unit_test.py create mode 100644 sigima/tests/viz/annotations_gui_test.py create mode 100644 sigima/viz/annotation_mpl.py create mode 100644 sigima/viz/annotation_plotpy.py diff --git a/doc/api/annotations.rst b/doc/api/annotations.rst new file mode 100644 index 00000000..052ffe37 --- /dev/null +++ b/doc/api/annotations.rst @@ -0,0 +1,120 @@ +:orphan: + +.. _api_annotations: + +Graphical annotations +===================== + +Sigima provides a renderer-independent model for editorial graphics attached to +signals and images. Graphical annotations are distinct from two other concepts: + +- a region of interest selects samples or pixels for computation; +- a :class:`~sigima.objects.GeometryResult` stores an analysis result; +- a graphical annotation communicates information to a reader and may be edited + by a consuming application. + +Model +----- + +The canonical model supports points, segments, oriented rectangles, circles, +oriented ellipses, polylines, polygons, text, axis or crosshair cursors, and +axis ranges. All geometry is expressed in calibrated data coordinates. Text +may instead use normalized axes coordinates, from ``(0, 0)`` at the bottom left +to ``(1, 1)`` at the top right. + +Annotations are immutable dataclasses. Their common fields include a stable +UUID, visibility, locking, layer order, title, structured style, optional label, +metadata, and namespaced extensions. Metadata and extensions accept only +JSON-compatible values and are copied into immutable containers. + +.. autoclass:: sigima.objects.GraphicalAnnotation +.. autoclass:: sigima.objects.PointAnnotation +.. autoclass:: sigima.objects.SegmentAnnotation +.. autoclass:: sigima.objects.RectangleAnnotation +.. autoclass:: sigima.objects.CircleAnnotation +.. autoclass:: sigima.objects.EllipseAnnotation +.. autoclass:: sigima.objects.PolylineAnnotation +.. autoclass:: sigima.objects.PolygonAnnotation +.. autoclass:: sigima.objects.TextAnnotation +.. autoclass:: sigima.objects.CursorAnnotation +.. autoclass:: sigima.objects.RangeAnnotation + +Object API +---------- + +The typed API is parallel to the historical free-form JSON API. This preserves +application-specific entries while allowing portable annotations to coexist in +the same ``annotations`` field. + +.. code-block:: python + + from sigima.objects import PointAnnotation, create_signal + + signal = create_signal("Annotated signal", [0, 1], [2, 3]) + signal.add_graphical_annotation( + PointAnnotation(x=1.0, y=3.0, title="Maximum") + ) + + annotations = signal.get_graphical_annotations() + signal.set_graphical_annotations(annotations, preserve_opaque=True) + +The methods ``get_annotations()`` and ``set_annotations()`` retain their +existing free-form behavior. ``set_graphical_annotations()`` replaces only +canonical entries by default. PlotPy payloads and unknown consumer data remain +unchanged. An entry declaring the canonical format but using an unsupported +version raises an error instead of being silently treated as opaque. + +Serialization and files +----------------------- + +Each canonical dictionary is marked with ``format: "sigima.annotation"`` and +``version: "1.0"``. The versioned JSON Schema is distributed as +``sigima/objects/annotations/schema-v1.json``. It is independent from the +historical object wrapper version and from the ``.dlabann`` container version. + +.. autofunction:: sigima.objects.annotation_to_dict +.. autofunction:: sigima.objects.annotation_from_dict +.. autofunction:: sigima.io.write_graphical_annotations +.. autofunction:: sigima.io.read_graphical_annotations + +Canonical annotations survive object copies and the normal ``.h5sig``, +``.h5ima``, and ``.dlabann`` round trips without a renderer dependency. + +Transformations +--------------- + +Pure transformation functions return a new annotation and preserve its UUID +and non-geometric fields. Translation, quarter turns, flips, transposition, +and scaling are also applied by the corresponding image operations. Resizing +does not move annotations because their coordinates are calibrated data values. +Arbitrary image rotation clears canonical annotations, like regions of interest, +when the output coordinate mapping is not reliable; opaque payloads are kept. + +.. autofunction:: sigima.objects.translate_annotation +.. autofunction:: sigima.objects.rotate_annotation +.. autofunction:: sigima.objects.flip_annotation_horizontally +.. autofunction:: sigima.objects.flip_annotation_vertically +.. autofunction:: sigima.objects.transpose_annotation +.. autofunction:: sigima.objects.scale_annotation + +An exact transform may change the primitive type, for example from a circle to +an ellipse under anisotropic scaling. A transform that cannot be represented +exactly raises :class:`~sigima.objects.AnnotationTransformError`. + +PlotPy migration +---------------- + +The PlotPy backend can display historical ``plotpy_json`` payloads without +rewriting them. Migration to the canonical model is always explicit: + +.. code-block:: python + + from sigima.viz.annotation_plotpy import migrate_legacy_plotpy_annotations + + preview = migrate_legacy_plotpy_annotations(signal, dry_run=True) + if not preview.diagnostics: + report = migrate_legacy_plotpy_annotations(signal) + +Known PlotPy annotation types are converted. A malformed payload, an unknown +item class, or a payload containing a partially unsupported item is preserved +and reported. Running migration again is idempotent. diff --git a/doc/api/index.rst b/doc/api/index.rst index 4118d11c..47b62500 100644 --- a/doc/api/index.rst +++ b/doc/api/index.rst @@ -21,6 +21,9 @@ The public Application Programming Interface (API) of Sigima offers a set of fun * - :mod:`sigima.objects` - Object model for signals and images (:class:`sigima.objects.SignalObj` and :class:`sigima.objects.ImageObj`), scalar results (:class:`sigima.objects.GeometryResult` and :class:`sigima.objects.TableResult`), and related functions + * - :mod:`sigima.objects.annotations` + - Renderer-independent graphical annotation model, serialization, and transformations (see :doc:`annotations`) + * - :mod:`sigima.proc` - Computation functions, which operate on signal and image objects (:class:`sigima.objects.SignalObj` or :class:`sigima.objects.ImageObj`) and return signal or image objects, or scalar results (:class:`sigima.objects.GeometryResult` or :class:`sigima.objects.TableResult`). diff --git a/doc/api/viz.rst b/doc/api/viz.rst index 6ac20fa8..d26a29b0 100644 --- a/doc/api/viz.rst +++ b/doc/api/viz.rst @@ -94,6 +94,20 @@ These functions display Sigima objects (:class:`~sigima.objects.SignalObj` and : .. autofunction:: view_curves_and_images +Canonical annotations +--------------------- + +Object viewing functions render canonical graphical annotations by default. +Pass ``show_annotations=False`` to hide them independently from regions of +interest. Both backends support all canonical primitives, styles, attached +labels, and layer order. Text may be positioned in data coordinates or in +normalized axes coordinates. + +PlotPy also displays valid historical ``plotpy_json`` payloads without changing +the object. Matplotlib ignores those opaque renderer-specific payloads. Use +the explicit migration described in :ref:`api_annotations` to make historical +annotations portable. + Low-Level Viewing Functions --------------------------- @@ -153,6 +167,12 @@ The two backends have different capabilities: * - Geometry results - ✅ Shape annotations - ✅ Markers/lines + * - Canonical annotations + - ✅ Native interactive items + - ✅ Read-only artists + * - Historical PlotPy annotations + - ✅ View-only compatibility + - ❌ Opaque payload ignored * - Linked axes - ✅ Native - ✅ via ``sharex``/``sharey`` diff --git a/doc/release_notes/release_1.03.md b/doc/release_notes/release_1.03.md new file mode 100644 index 00000000..2d359397 --- /dev/null +++ b/doc/release_notes/release_1.03.md @@ -0,0 +1,7 @@ +# Version 1.3 # + +## Sigima Version 1.3.0 ## + +### ✨ New features since version 1.2.0 ### + +* **Portable graphical annotations**: Signals and images may now carry versioned, renderer-independent points, shapes, text, cursors and axis ranges. Annotations survive Sigima file round trips and supported image transformations, and are displayed consistently by the PlotPy and Matplotlib visualization backends. Existing PlotPy annotations remain readable and can be migrated explicitly while unknown application data is preserved. This implements [Issue #53](https://github.com/DataLab-Platform/Sigima/issues/53). \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 6d65bcd6..dc77fc2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,7 +71,7 @@ doc = [ "matplotlib", "opencv-python-headless >= 4.8.1.78", ] -test = ["pytest", "pytest-xvfb"] +test = ["pytest", "pytest-xvfb", "jsonschema >= 4"] qt = ["qtpy", "PyQt5", "plotpy"] [tool.setuptools.packages.find] diff --git a/sigima/io/__init__.py b/sigima/io/__init__.py index 6dbbb855..283e1f62 100644 --- a/sigima/io/__init__.py +++ b/sigima/io/__init__.py @@ -79,10 +79,12 @@ def read_data(filename: str) -> np.ndarray: from sigima.io.common.objmeta import ( read_annotations, + read_graphical_annotations, read_metadata, read_roi, read_roi_grid, write_annotations, + write_graphical_annotations, write_metadata, write_roi, write_roi_grid, @@ -106,6 +108,7 @@ def read_data(filename: str) -> np.ndarray: "ImageIORegistry", "SignalIORegistry", "read_annotations", + "read_graphical_annotations", "read_image", "read_images", "read_metadata", @@ -114,6 +117,7 @@ def read_data(filename: str) -> np.ndarray: "read_signal", "read_signals", "write_annotations", + "write_graphical_annotations", "write_image", "write_images", "write_metadata", diff --git a/sigima/io/common/objmeta.py b/sigima/io/common/objmeta.py index ad22767a..771ca92b 100644 --- a/sigima/io/common/objmeta.py +++ b/sigima/io/common/objmeta.py @@ -8,7 +8,14 @@ from guidata.io import JSONHandler, JSONReader, JSONWriter -from sigima.objects import ImageROI, SignalROI +from sigima.objects import ( + GraphicalAnnotation, + ImageROI, + SignalROI, + annotation_from_dict, + annotation_to_dict, + is_graphical_annotation_dict, +) if TYPE_CHECKING: from sigima.params import ROIGridParam @@ -210,3 +217,41 @@ def read_annotations(filepath: str) -> list[dict[str, Any]]: json_dict = read_dict(filepath) _check_tag(json_dict, expected_format="annotations") return json_dict["annotations"] + + +def write_graphical_annotations( + filepath: str, annotations: list[GraphicalAnnotation] +) -> None: + """Write canonical graphical annotations to a ``.dlabann`` JSON file. + + Args: + filepath: The file path to write the annotations to. + annotations: Canonical graphical annotations to serialize. + + Raises: + TypeError: If annotations is not a list of GraphicalAnnotation objects. + """ + if not isinstance(annotations, list) or not all( + isinstance(item, GraphicalAnnotation) for item in annotations + ): + raise TypeError("annotations must be a list of GraphicalAnnotation objects") + write_annotations(filepath, [annotation_to_dict(item) for item in annotations]) + + +def read_graphical_annotations(filepath: str) -> list[GraphicalAnnotation]: + """Read canonical graphical annotations from a ``.dlabann`` JSON file. + + Opaque entries remain available through :func:`read_annotations` and are ignored + by this typed convenience function. + + Args: + filepath: The file path to read the annotations from. + + Returns: + Canonical graphical annotations in storage order. + """ + return [ + annotation_from_dict(item) + for item in read_annotations(filepath) + if is_graphical_annotation_dict(item) + ] diff --git a/sigima/objects/__init__.py b/sigima/objects/__init__.py index a9f96966..83911902 100644 --- a/sigima/objects/__init__.py +++ b/sigima/objects/__init__.py @@ -113,17 +113,31 @@ """ __all__ = [ + "ANNOTATION_FORMAT", + "ANNOTATION_VERSION", "CREATION_PARAMS_VERSION", "NO_ROI", "PEAK_PARAMETERIZATION", + "AnnotationKind", + "AnnotationLabel", + "AnnotationStyle", + "AnnotationTransformError", + "Axis", "Checkerboard2DParam", + "CircleAnnotation", "CircularROI", + "CoordinateSpace", "CosineParam", + "CursorAnnotation", + "CursorOrientation", "CustomSignalParam", + "EllipseAnnotation", "ExponentialParam", + "FillStyle", "Gauss2DParam", "GaussParam", "GeometryResult", + "GraphicalAnnotation", "ImageDatatypes", "ImageObj", "ImageROI", @@ -132,24 +146,31 @@ "LinearChirpParam", "LogisticParam", "LorentzParam", + "MarkerStyle", "NewImageParam", "NewSignalParam", "NormalDistribution1DParam", "NormalDistribution2DParam", "NormalDistributionParam", "PlanckParam", + "PointAnnotation", "PoissonDistribution1DParam", "PoissonDistribution2DParam", "PoissonDistributionParam", "PolyParam", + "PolygonAnnotation", "PolygonalROI", + "PolylineAnnotation", "PulseParam", "ROI1DParam", "ROI2DParam", "Ramp2DParam", + "RangeAnnotation", + "RectangleAnnotation", "RectangularROI", "Ring2DParam", "SawtoothParam", + "SegmentAnnotation", "SegmentROI", "SiemensStar2DParam", "SignalObj", @@ -163,9 +184,13 @@ "SquarePulseParam", "StepParam", "StepPulseParam", + "StrokeStyle", "TableKind", "TableResult", "TableResultBuilder", + "TextAnchor", + "TextAnnotation", + "TextStyle", "TriangleParam", "TypeObj", "TypeROI", @@ -177,6 +202,8 @@ "VoigtParam", "Zero2DParam", "ZeroParam", + "annotation_from_dict", + "annotation_to_dict", "calc_table_from_data", "concat_geometries", "concat_tables", @@ -192,9 +219,54 @@ "create_signal_roi", "filter_geometry_by_roi", "filter_table_by_roi", + "flip_annotation_horizontally", + "flip_annotation_vertically", + "is_graphical_annotation_dict", + "rotate_annotation", + "scale_annotation", + "transform_annotation", + "translate_annotation", + "transpose_annotation", "validate_peak_creation_params", ] +from sigima.objects.annotations import ( + ANNOTATION_FORMAT, + ANNOTATION_VERSION, + AnnotationKind, + AnnotationLabel, + AnnotationStyle, + AnnotationTransformError, + Axis, + CircleAnnotation, + CoordinateSpace, + CursorAnnotation, + CursorOrientation, + EllipseAnnotation, + FillStyle, + GraphicalAnnotation, + MarkerStyle, + PointAnnotation, + PolygonAnnotation, + PolylineAnnotation, + RangeAnnotation, + RectangleAnnotation, + SegmentAnnotation, + StrokeStyle, + TextAnchor, + TextAnnotation, + TextStyle, + annotation_from_dict, + annotation_to_dict, + flip_annotation_horizontally, + flip_annotation_vertically, + is_graphical_annotation_dict, + rotate_annotation, + scale_annotation, + transform_annotation, + translate_annotation, + transpose_annotation, +) from sigima.objects.base import ( NormalDistributionParam, PoissonDistributionParam, diff --git a/sigima/objects/annotations/__init__.py b/sigima/objects/annotations/__init__.py new file mode 100644 index 00000000..985d4dec --- /dev/null +++ b/sigima/objects/annotations/__init__.py @@ -0,0 +1,83 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Renderer-independent graphical annotations.""" + +from sigima.objects.annotations.model import ( + AnnotationKind, + AnnotationLabel, + AnnotationStyle, + Axis, + CircleAnnotation, + CoordinateSpace, + CursorAnnotation, + CursorOrientation, + EllipseAnnotation, + FillStyle, + GraphicalAnnotation, + MarkerStyle, + PointAnnotation, + PolygonAnnotation, + PolylineAnnotation, + RangeAnnotation, + RectangleAnnotation, + SegmentAnnotation, + StrokeStyle, + TextAnchor, + TextAnnotation, + TextStyle, +) +from sigima.objects.annotations.serialization import ( + ANNOTATION_FORMAT, + ANNOTATION_VERSION, + annotation_from_dict, + annotation_to_dict, + is_graphical_annotation_dict, +) +from sigima.objects.annotations.transform import ( + AnnotationTransformError, + flip_annotation_horizontally, + flip_annotation_vertically, + rotate_annotation, + scale_annotation, + transform_annotation, + translate_annotation, + transpose_annotation, +) + +__all__ = [ + "ANNOTATION_FORMAT", + "ANNOTATION_VERSION", + "AnnotationKind", + "AnnotationLabel", + "AnnotationStyle", + "AnnotationTransformError", + "Axis", + "CircleAnnotation", + "CoordinateSpace", + "CursorAnnotation", + "CursorOrientation", + "EllipseAnnotation", + "FillStyle", + "GraphicalAnnotation", + "MarkerStyle", + "PointAnnotation", + "PolygonAnnotation", + "PolylineAnnotation", + "RangeAnnotation", + "RectangleAnnotation", + "SegmentAnnotation", + "StrokeStyle", + "TextAnchor", + "TextAnnotation", + "TextStyle", + "annotation_from_dict", + "annotation_to_dict", + "flip_annotation_horizontally", + "flip_annotation_vertically", + "is_graphical_annotation_dict", + "rotate_annotation", + "scale_annotation", + "transform_annotation", + "translate_annotation", + "transpose_annotation", +] diff --git a/sigima/objects/annotations/model.py b/sigima/objects/annotations/model.py new file mode 100644 index 00000000..5406ce08 --- /dev/null +++ b/sigima/objects/annotations/model.py @@ -0,0 +1,492 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Renderer-independent graphical annotation model.""" + +from __future__ import annotations + +import enum +import math +import uuid +from dataclasses import dataclass, field +from types import MappingProxyType +from typing import Any, ClassVar, Mapping, Sequence + + +class AnnotationKind(str, enum.Enum): + """Supported graphical annotation primitives.""" + + POINT = "point" + SEGMENT = "segment" + RECTANGLE = "rectangle" + CIRCLE = "circle" + ELLIPSE = "ellipse" + POLYLINE = "polyline" + POLYGON = "polygon" + TEXT = "text" + CURSOR = "cursor" + RANGE = "range" + + +class CoordinateSpace(str, enum.Enum): + """Coordinate space used by an annotation.""" + + DATA = "data" + AXES = "axes" + + +class CursorOrientation(str, enum.Enum): + """Cursor orientation.""" + + HORIZONTAL = "horizontal" + VERTICAL = "vertical" + CROSSHAIR = "crosshair" + + +class Axis(str, enum.Enum): + """Plot axis.""" + + X = "x" + Y = "y" + + +class TextAnchor(str, enum.Enum): + """Text anchor relative to its position.""" + + TOP_LEFT = "top-left" + TOP = "top" + TOP_RIGHT = "top-right" + LEFT = "left" + CENTER = "center" + RIGHT = "right" + BOTTOM_LEFT = "bottom-left" + BOTTOM = "bottom" + BOTTOM_RIGHT = "bottom-right" + + +def _validate_number(value: float, name: str) -> None: + """Validate that a value is a finite real number.""" + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError(f"{name} must be a real number") + if not math.isfinite(value): + raise ValueError(f"{name} must be finite") + + +def _validate_non_negative(value: float, name: str) -> None: + """Validate that a value is finite and non-negative.""" + _validate_number(value, name) + if value < 0: + raise ValueError(f"{name} must be non-negative") + + +def _freeze_json(value: Any, path: str) -> Any: + """Return an immutable copy of a JSON-compatible value.""" + if value is None or isinstance(value, (str, bool, int)): + return value + if isinstance(value, float): + if not math.isfinite(value): + raise ValueError(f"{path} must not contain NaN or infinity") + return value + if isinstance(value, Mapping): + frozen = {} + for key, item in value.items(): + if not isinstance(key, str): + raise TypeError(f"{path} keys must be strings") + frozen[key] = _freeze_json(item, f"{path}.{key}") + return MappingProxyType(frozen) + if isinstance(value, (list, tuple)): + return tuple(_freeze_json(item, f"{path}[]") for item in value) + raise TypeError(f"{path} contains a non-JSON value: {type(value).__name__}") + + +def _normalize_points( + points: Sequence[Sequence[float]], minimum: int, name: str +) -> tuple[tuple[float, float], ...]: + """Validate and normalize a sequence of 2D points.""" + normalized = [] + for index, point in enumerate(points): + if len(point) != 2: + raise ValueError(f"{name}[{index}] must contain exactly two values") + x, y = point + _validate_number(x, f"{name}[{index}].x") + _validate_number(y, f"{name}[{index}].y") + normalized.append((float(x), float(y))) + if len(normalized) < minimum: + raise ValueError(f"{name} must contain at least {minimum} points") + return tuple(normalized) + + +@dataclass(frozen=True) +class StrokeStyle: + """Line appearance shared by shape annotations.""" + + color: str | None = "#ff9933" + width: float = 1.0 + opacity: float = 1.0 + dash: str | tuple[float, ...] = "solid" + + def __post_init__(self) -> None: + """Validate stroke style values.""" + if self.color is not None and not isinstance(self.color, str): + raise TypeError("stroke color must be a string or None") + _validate_non_negative(self.width, "stroke width") + _validate_number(self.opacity, "stroke opacity") + if not 0.0 <= self.opacity <= 1.0: + raise ValueError("stroke opacity must be between 0 and 1") + if isinstance(self.dash, str): + if not self.dash: + raise ValueError("stroke dash name must not be empty") + else: + dash = tuple(float(value) for value in self.dash) + if not dash: + raise ValueError("stroke dash pattern must not be empty") + for value in dash: + _validate_non_negative(value, "stroke dash value") + object.__setattr__(self, "dash", dash) + + +@dataclass(frozen=True) +class FillStyle: + """Fill appearance shared by closed shape annotations.""" + + color: str | None = None + opacity: float = 0.0 + + def __post_init__(self) -> None: + """Validate fill style values.""" + if self.color is not None and not isinstance(self.color, str): + raise TypeError("fill color must be a string or None") + _validate_number(self.opacity, "fill opacity") + if not 0.0 <= self.opacity <= 1.0: + raise ValueError("fill opacity must be between 0 and 1") + + +@dataclass(frozen=True) +class MarkerStyle: + """Point marker appearance.""" + + symbol: str = "circle" + size: float = 6.0 + color: str | None = None + + def __post_init__(self) -> None: + """Validate marker style values.""" + if not isinstance(self.symbol, str) or not self.symbol: + raise ValueError("marker symbol must be a non-empty string") + _validate_non_negative(self.size, "marker size") + if self.color is not None and not isinstance(self.color, str): + raise TypeError("marker color must be a string or None") + + +@dataclass(frozen=True) +class TextStyle: + """Text appearance.""" + + family: str | None = None + size: float = 10.0 + bold: bool = False + italic: bool = False + color: str = "#000000" + background_color: str | None = None + background_opacity: float = 0.0 + + def __post_init__(self) -> None: + """Validate text style values.""" + _validate_non_negative(self.size, "text size") + if not isinstance(self.color, str): + raise TypeError("text color must be a string") + if self.background_color is not None and not isinstance( + self.background_color, str + ): + raise TypeError("text background color must be a string or None") + _validate_number(self.background_opacity, "text background opacity") + if not 0.0 <= self.background_opacity <= 1.0: + raise ValueError("text background opacity must be between 0 and 1") + + +@dataclass(frozen=True) +class AnnotationStyle: + """Renderer-independent annotation style.""" + + stroke: StrokeStyle = field(default_factory=StrokeStyle) + fill: FillStyle = field(default_factory=FillStyle) + marker: MarkerStyle = field(default_factory=MarkerStyle) + text: TextStyle = field(default_factory=TextStyle) + + +@dataclass(frozen=True) +class AnnotationLabel: + """Optional label attached to a graphical annotation.""" + + text: str = "" + visible: bool = True + anchor: TextAnchor = TextAnchor.TOP + offset: tuple[float, float] = (0.0, 0.0) + + def __post_init__(self) -> None: + """Validate and normalize label values.""" + if not isinstance(self.text, str): + raise TypeError("label text must be a string") + anchor = TextAnchor(self.anchor) + if len(self.offset) != 2: + raise ValueError("label offset must contain exactly two values") + x_offset, y_offset = self.offset + _validate_number(x_offset, "label x offset") + _validate_number(y_offset, "label y offset") + object.__setattr__(self, "anchor", anchor) + object.__setattr__(self, "offset", (float(x_offset), float(y_offset))) + + +@dataclass(frozen=True) +class GraphicalAnnotation: + """Base class for renderer-independent graphical annotations.""" + + id: str = field(default_factory=lambda: str(uuid.uuid4())) + visible: bool = True + locked: bool = False + z_index: int = 0 + title: str = "" + style: AnnotationStyle = field(default_factory=AnnotationStyle) + label: AnnotationLabel | None = None + metadata: Mapping[str, Any] = field(default_factory=dict) + extensions: Mapping[str, Any] = field(default_factory=dict) + + KIND: ClassVar[AnnotationKind | None] = None + + def __post_init__(self) -> None: + """Validate and freeze common annotation fields.""" + if self.KIND is None: + raise TypeError("GraphicalAnnotation cannot be instantiated directly") + if not isinstance(self.visible, bool): + raise TypeError("annotation visible flag must be a boolean") + if not isinstance(self.locked, bool): + raise TypeError("annotation locked flag must be a boolean") + if not isinstance(self.id, str): + raise TypeError("annotation id must be a string") + try: + uuid.UUID(self.id) + except (ValueError, AttributeError) as exc: + raise ValueError("annotation id must be a valid UUID") from exc + if isinstance(self.z_index, bool) or not isinstance(self.z_index, int): + raise TypeError("annotation z_index must be an integer") + if not isinstance(self.title, str): + raise TypeError("annotation title must be a string") + if not isinstance(self.style, AnnotationStyle): + raise TypeError("annotation style must be an AnnotationStyle") + if self.label is not None and not isinstance(self.label, AnnotationLabel): + raise TypeError("annotation label must be an AnnotationLabel or None") + object.__setattr__(self, "metadata", _freeze_json(self.metadata, "metadata")) + object.__setattr__( + self, "extensions", _freeze_json(self.extensions, "extensions") + ) + + @property + def kind(self) -> AnnotationKind: + """Return the annotation discriminator.""" + assert self.KIND is not None + return self.KIND + + +@dataclass(frozen=True) +class PointAnnotation(GraphicalAnnotation): + """Point annotation in data coordinates.""" + + x: float = 0.0 + y: float = 0.0 + + KIND: ClassVar[AnnotationKind] = AnnotationKind.POINT + + def __post_init__(self) -> None: + """Validate point coordinates.""" + super().__post_init__() + _validate_number(self.x, "x") + _validate_number(self.y, "y") + + +@dataclass(frozen=True) +class SegmentAnnotation(GraphicalAnnotation): + """Line segment annotation in data coordinates.""" + + x0: float = 0.0 + y0: float = 0.0 + x1: float = 0.0 + y1: float = 0.0 + + KIND: ClassVar[AnnotationKind] = AnnotationKind.SEGMENT + + def __post_init__(self) -> None: + """Validate segment coordinates.""" + super().__post_init__() + for name in ("x0", "y0", "x1", "y1"): + _validate_number(getattr(self, name), name) + + +@dataclass(frozen=True) +class RectangleAnnotation(GraphicalAnnotation): + """Possibly rotated rectangle annotation in data coordinates.""" + + x: float = 0.0 + y: float = 0.0 + width: float = 0.0 + height: float = 0.0 + angle: float = 0.0 + + KIND: ClassVar[AnnotationKind] = AnnotationKind.RECTANGLE + + def __post_init__(self) -> None: + """Validate rectangle coordinates.""" + super().__post_init__() + _validate_number(self.x, "x") + _validate_number(self.y, "y") + _validate_non_negative(self.width, "width") + _validate_non_negative(self.height, "height") + _validate_number(self.angle, "angle") + + +@dataclass(frozen=True) +class CircleAnnotation(GraphicalAnnotation): + """Circle annotation in data coordinates.""" + + cx: float = 0.0 + cy: float = 0.0 + radius: float = 0.0 + + KIND: ClassVar[AnnotationKind] = AnnotationKind.CIRCLE + + def __post_init__(self) -> None: + """Validate circle coordinates.""" + super().__post_init__() + _validate_number(self.cx, "cx") + _validate_number(self.cy, "cy") + _validate_non_negative(self.radius, "radius") + + +@dataclass(frozen=True) +class EllipseAnnotation(GraphicalAnnotation): + """Possibly rotated ellipse annotation in data coordinates.""" + + cx: float = 0.0 + cy: float = 0.0 + radius_x: float = 0.0 + radius_y: float = 0.0 + angle: float = 0.0 + + KIND: ClassVar[AnnotationKind] = AnnotationKind.ELLIPSE + + def __post_init__(self) -> None: + """Validate ellipse coordinates.""" + super().__post_init__() + _validate_number(self.cx, "cx") + _validate_number(self.cy, "cy") + _validate_non_negative(self.radius_x, "radius_x") + _validate_non_negative(self.radius_y, "radius_y") + _validate_number(self.angle, "angle") + + +@dataclass(frozen=True) +class PolylineAnnotation(GraphicalAnnotation): + """Open polyline annotation in data coordinates.""" + + points: tuple[tuple[float, float], ...] = () + + KIND: ClassVar[AnnotationKind] = AnnotationKind.POLYLINE + + def __post_init__(self) -> None: + """Validate polyline points.""" + super().__post_init__() + object.__setattr__(self, "points", _normalize_points(self.points, 2, "points")) + + +@dataclass(frozen=True) +class PolygonAnnotation(GraphicalAnnotation): + """Closed polygon annotation in data coordinates.""" + + points: tuple[tuple[float, float], ...] = () + + KIND: ClassVar[AnnotationKind] = AnnotationKind.POLYGON + + def __post_init__(self) -> None: + """Validate polygon points.""" + super().__post_init__() + object.__setattr__(self, "points", _normalize_points(self.points, 3, "points")) + + +@dataclass(frozen=True) +class TextAnnotation(GraphicalAnnotation): + """Standalone text anchored in data or normalized axes coordinates.""" + + text: str = "" + x: float = 0.0 + y: float = 0.0 + coordinate_space: CoordinateSpace = CoordinateSpace.DATA + anchor: TextAnchor = TextAnchor.TOP_LEFT + offset: tuple[float, float] = (0.0, 0.0) + + KIND: ClassVar[AnnotationKind] = AnnotationKind.TEXT + + def __post_init__(self) -> None: + """Validate text annotation values.""" + super().__post_init__() + if not isinstance(self.text, str): + raise TypeError("text must be a string") + _validate_number(self.x, "x") + _validate_number(self.y, "y") + if len(self.offset) != 2: + raise ValueError("text offset must contain exactly two values") + x_offset, y_offset = self.offset + _validate_number(x_offset, "text x offset") + _validate_number(y_offset, "text y offset") + object.__setattr__( + self, "coordinate_space", CoordinateSpace(self.coordinate_space) + ) + object.__setattr__(self, "anchor", TextAnchor(self.anchor)) + object.__setattr__(self, "offset", (float(x_offset), float(y_offset))) + + +@dataclass(frozen=True) +class CursorAnnotation(GraphicalAnnotation): + """Horizontal, vertical, or crosshair cursor annotation.""" + + orientation: CursorOrientation = CursorOrientation.VERTICAL + position: float | tuple[float, float] = 0.0 + + KIND: ClassVar[AnnotationKind] = AnnotationKind.CURSOR + + def __post_init__(self) -> None: + """Validate cursor position for its orientation.""" + super().__post_init__() + orientation = CursorOrientation(self.orientation) + if orientation == CursorOrientation.CROSSHAIR: + if not isinstance(self.position, (tuple, list)) or len(self.position) != 2: + raise ValueError("crosshair position must contain x and y") + x, y = self.position + _validate_number(x, "cursor x") + _validate_number(y, "cursor y") + position: float | tuple[float, float] = (float(x), float(y)) + else: + if isinstance(self.position, (tuple, list)): + raise ValueError("axis cursor position must be a scalar") + _validate_number(self.position, "cursor position") + position = float(self.position) + object.__setattr__(self, "orientation", orientation) + object.__setattr__(self, "position", position) + + +@dataclass(frozen=True) +class RangeAnnotation(GraphicalAnnotation): + """Highlighted interval along one plot axis.""" + + axis: Axis = Axis.X + start: float = 0.0 + end: float = 0.0 + + KIND: ClassVar[AnnotationKind] = AnnotationKind.RANGE + + def __post_init__(self) -> None: + """Validate and normalize range bounds.""" + super().__post_init__() + _validate_number(self.start, "range start") + _validate_number(self.end, "range end") + start, end = sorted((float(self.start), float(self.end))) + object.__setattr__(self, "axis", Axis(self.axis)) + object.__setattr__(self, "start", start) + object.__setattr__(self, "end", end) diff --git a/sigima/objects/annotations/schema-v1.json b/sigima/objects/annotations/schema-v1.json new file mode 100644 index 00000000..7911356c --- /dev/null +++ b/sigima/objects/annotations/schema-v1.json @@ -0,0 +1,260 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://datalab-platform.com/schemas/sigima/annotation-v1.json", + "title": "Sigima graphical annotation", + "type": "object", + "additionalProperties": false, + "required": [ + "format", + "version", + "id", + "kind", + "visible", + "locked", + "z_index", + "title", + "style", + "label", + "metadata", + "extensions" + ], + "properties": { + "format": { "const": "sigima.annotation" }, + "version": { "const": "1.0" }, + "id": { "type": "string", "format": "uuid" }, + "kind": { + "enum": [ + "point", + "segment", + "rectangle", + "circle", + "ellipse", + "polyline", + "polygon", + "text", + "cursor", + "range" + ] + }, + "visible": { "type": "boolean" }, + "locked": { "type": "boolean" }, + "z_index": { "type": "integer" }, + "title": { "type": "string" }, + "style": { "$ref": "#/$defs/style" }, + "label": { + "oneOf": [ + { "type": "null" }, + { "$ref": "#/$defs/label" } + ] + }, + "metadata": { "$ref": "#/$defs/jsonObject" }, + "extensions": { "$ref": "#/$defs/jsonObject" }, + "x": { "type": "number" }, + "y": { "type": "number" }, + "x0": { "type": "number" }, + "y0": { "type": "number" }, + "x1": { "type": "number" }, + "y1": { "type": "number" }, + "width": { "type": "number", "minimum": 0 }, + "height": { "type": "number", "minimum": 0 }, + "angle": { "type": "number" }, + "cx": { "type": "number" }, + "cy": { "type": "number" }, + "radius": { "type": "number", "minimum": 0 }, + "radius_x": { "type": "number", "minimum": 0 }, + "radius_y": { "type": "number", "minimum": 0 }, + "points": { + "type": "array", + "items": { "$ref": "#/$defs/point" } + }, + "text": { "type": "string" }, + "coordinate_space": { "enum": ["data", "axes"] }, + "anchor": { "$ref": "#/$defs/anchor" }, + "offset": { "$ref": "#/$defs/point" }, + "orientation": { "enum": ["horizontal", "vertical", "crosshair"] }, + "position": { + "oneOf": [ + { "type": "number" }, + { "$ref": "#/$defs/point" } + ] + }, + "axis": { "enum": ["x", "y"] }, + "start": { "type": "number" }, + "end": { "type": "number" } + }, + "oneOf": [ + { + "properties": { "kind": { "const": "point" } }, + "required": ["x", "y"] + }, + { + "properties": { "kind": { "const": "segment" } }, + "required": ["x0", "y0", "x1", "y1"] + }, + { + "properties": { "kind": { "const": "rectangle" } }, + "required": ["x", "y", "width", "height", "angle"] + }, + { + "properties": { "kind": { "const": "circle" } }, + "required": ["cx", "cy", "radius"] + }, + { + "properties": { "kind": { "const": "ellipse" } }, + "required": ["cx", "cy", "radius_x", "radius_y", "angle"] + }, + { + "properties": { + "kind": { "const": "polyline" }, + "points": { "minItems": 2 } + }, + "required": ["points"] + }, + { + "properties": { + "kind": { "const": "polygon" }, + "points": { "minItems": 3 } + }, + "required": ["points"] + }, + { + "properties": { "kind": { "const": "text" } }, + "required": ["text", "x", "y", "coordinate_space", "anchor", "offset"] + }, + { + "properties": { "kind": { "const": "cursor" } }, + "required": ["orientation", "position"] + }, + { + "properties": { "kind": { "const": "range" } }, + "required": ["axis", "start", "end"] + } + ], + "$defs": { + "jsonValue": { + "oneOf": [ + { "type": "null" }, + { "type": "boolean" }, + { "type": "number" }, + { "type": "string" }, + { + "type": "array", + "items": { "$ref": "#/$defs/jsonValue" } + }, + { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/jsonValue" } + } + ] + }, + "jsonObject": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/jsonValue" } + }, + "point": { + "type": "array", + "prefixItems": [ + { "type": "number" }, + { "type": "number" } + ], + "minItems": 2, + "maxItems": 2 + }, + "anchor": { + "enum": [ + "top-left", + "top", + "top-right", + "left", + "center", + "right", + "bottom-left", + "bottom", + "bottom-right" + ] + }, + "style": { + "type": "object", + "additionalProperties": false, + "required": ["stroke", "fill", "marker", "text"], + "properties": { + "stroke": { + "type": "object", + "additionalProperties": false, + "required": ["color", "width", "opacity", "dash"], + "properties": { + "color": { "type": ["string", "null"] }, + "width": { "type": "number", "minimum": 0 }, + "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, + "dash": { + "oneOf": [ + { "type": "string", "minLength": 1 }, + { + "type": "array", + "minItems": 1, + "items": { "type": "number", "minimum": 0 } + } + ] + } + } + }, + "fill": { + "type": "object", + "additionalProperties": false, + "required": ["color", "opacity"], + "properties": { + "color": { "type": ["string", "null"] }, + "opacity": { "type": "number", "minimum": 0, "maximum": 1 } + } + }, + "marker": { + "type": "object", + "additionalProperties": false, + "required": ["symbol", "size", "color"], + "properties": { + "symbol": { "type": "string", "minLength": 1 }, + "size": { "type": "number", "minimum": 0 }, + "color": { "type": ["string", "null"] } + } + }, + "text": { + "type": "object", + "additionalProperties": false, + "required": [ + "family", + "size", + "bold", + "italic", + "color", + "background_color", + "background_opacity" + ], + "properties": { + "family": { "type": ["string", "null"] }, + "size": { "type": "number", "minimum": 0 }, + "bold": { "type": "boolean" }, + "italic": { "type": "boolean" }, + "color": { "type": "string" }, + "background_color": { "type": ["string", "null"] }, + "background_opacity": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + } + } + } + }, + "label": { + "type": "object", + "additionalProperties": false, + "required": ["text", "visible", "anchor", "offset"], + "properties": { + "text": { "type": "string" }, + "visible": { "type": "boolean" }, + "anchor": { "$ref": "#/$defs/anchor" }, + "offset": { "$ref": "#/$defs/point" } + } + } + } +} \ No newline at end of file diff --git a/sigima/objects/annotations/serialization.py b/sigima/objects/annotations/serialization.py new file mode 100644 index 00000000..20655c1a --- /dev/null +++ b/sigima/objects/annotations/serialization.py @@ -0,0 +1,299 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""JSON serialization for renderer-independent graphical annotations.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from sigima.objects.annotations.model import ( + AnnotationKind, + AnnotationLabel, + AnnotationStyle, + CircleAnnotation, + CursorAnnotation, + EllipseAnnotation, + FillStyle, + GraphicalAnnotation, + MarkerStyle, + PointAnnotation, + PolygonAnnotation, + PolylineAnnotation, + RangeAnnotation, + RectangleAnnotation, + SegmentAnnotation, + StrokeStyle, + TextAnnotation, + TextStyle, +) + +ANNOTATION_FORMAT = "sigima.annotation" +ANNOTATION_VERSION = "1.0" + +_COMMON_FIELDS = { + "format", + "version", + "id", + "kind", + "visible", + "locked", + "z_index", + "title", + "style", + "label", + "metadata", + "extensions", +} + +_KIND_FIELDS = { + AnnotationKind.POINT: {"x", "y"}, + AnnotationKind.SEGMENT: {"x0", "y0", "x1", "y1"}, + AnnotationKind.RECTANGLE: {"x", "y", "width", "height", "angle"}, + AnnotationKind.CIRCLE: {"cx", "cy", "radius"}, + AnnotationKind.ELLIPSE: {"cx", "cy", "radius_x", "radius_y", "angle"}, + AnnotationKind.POLYLINE: {"points"}, + AnnotationKind.POLYGON: {"points"}, + AnnotationKind.TEXT: { + "text", + "x", + "y", + "coordinate_space", + "anchor", + "offset", + }, + AnnotationKind.CURSOR: {"orientation", "position"}, + AnnotationKind.RANGE: {"axis", "start", "end"}, +} + +_ANNOTATION_CLASSES = { + AnnotationKind.POINT: PointAnnotation, + AnnotationKind.SEGMENT: SegmentAnnotation, + AnnotationKind.RECTANGLE: RectangleAnnotation, + AnnotationKind.CIRCLE: CircleAnnotation, + AnnotationKind.ELLIPSE: EllipseAnnotation, + AnnotationKind.POLYLINE: PolylineAnnotation, + AnnotationKind.POLYGON: PolygonAnnotation, + AnnotationKind.TEXT: TextAnnotation, + AnnotationKind.CURSOR: CursorAnnotation, + AnnotationKind.RANGE: RangeAnnotation, +} + + +def _json_value(value: Any) -> Any: + """Convert immutable model collections to mutable JSON values.""" + if isinstance(value, Mapping): + return {key: _json_value(item) for key, item in value.items()} + if isinstance(value, tuple): + return [_json_value(item) for item in value] + return value + + +def _style_to_dict(style: AnnotationStyle) -> dict[str, Any]: + """Serialize an annotation style.""" + return { + "stroke": { + "color": style.stroke.color, + "width": style.stroke.width, + "opacity": style.stroke.opacity, + "dash": _json_value(style.stroke.dash), + }, + "fill": { + "color": style.fill.color, + "opacity": style.fill.opacity, + }, + "marker": { + "symbol": style.marker.symbol, + "size": style.marker.size, + "color": style.marker.color, + }, + "text": { + "family": style.text.family, + "size": style.text.size, + "bold": style.text.bold, + "italic": style.text.italic, + "color": style.text.color, + "background_color": style.text.background_color, + "background_opacity": style.text.background_opacity, + }, + } + + +def _check_keys(data: Mapping[str, Any], allowed: set[str], path: str) -> None: + """Reject unknown fields in normalized structures.""" + unknown = set(data) - allowed + if unknown: + names = ", ".join(sorted(unknown)) + raise ValueError(f"Unknown {path} field(s): {names}") + + +def _style_from_dict(data: Any) -> AnnotationStyle: + """Deserialize an annotation style.""" + if not isinstance(data, Mapping): + raise TypeError("annotation style must be an object") + _check_keys(data, {"stroke", "fill", "marker", "text"}, "style") + + stroke_data = data.get("stroke", {}) + fill_data = data.get("fill", {}) + marker_data = data.get("marker", {}) + text_data = data.get("text", {}) + for name, value in ( + ("stroke", stroke_data), + ("fill", fill_data), + ("marker", marker_data), + ("text", text_data), + ): + if not isinstance(value, Mapping): + raise TypeError(f"annotation {name} style must be an object") + + _check_keys(stroke_data, {"color", "width", "opacity", "dash"}, "stroke") + _check_keys(fill_data, {"color", "opacity"}, "fill") + _check_keys(marker_data, {"symbol", "size", "color"}, "marker") + _check_keys( + text_data, + { + "family", + "size", + "bold", + "italic", + "color", + "background_color", + "background_opacity", + }, + "text", + ) + return AnnotationStyle( + stroke=StrokeStyle(**stroke_data), + fill=FillStyle(**fill_data), + marker=MarkerStyle(**marker_data), + text=TextStyle(**text_data), + ) + + +def _label_to_dict(label: AnnotationLabel) -> dict[str, Any]: + """Serialize an annotation label.""" + return { + "text": label.text, + "visible": label.visible, + "anchor": label.anchor.value, + "offset": list(label.offset), + } + + +def _label_from_dict(data: Any) -> AnnotationLabel | None: + """Deserialize an optional annotation label.""" + if data is None: + return None + if not isinstance(data, Mapping): + raise TypeError("annotation label must be an object or null") + _check_keys(data, {"text", "visible", "anchor", "offset"}, "label") + return AnnotationLabel(**data) + + +def annotation_to_dict(annotation: GraphicalAnnotation) -> dict[str, Any]: + """Serialize a graphical annotation to a JSON-compatible dictionary.""" + if not isinstance(annotation, GraphicalAnnotation): + raise TypeError("annotation must be a GraphicalAnnotation") + data = { + "format": ANNOTATION_FORMAT, + "version": ANNOTATION_VERSION, + "id": annotation.id, + "kind": annotation.kind.value, + "visible": annotation.visible, + "locked": annotation.locked, + "z_index": annotation.z_index, + "title": annotation.title, + "style": _style_to_dict(annotation.style), + "label": ( + _label_to_dict(annotation.label) if annotation.label is not None else None + ), + "metadata": _json_value(annotation.metadata), + "extensions": _json_value(annotation.extensions), + } + if isinstance(annotation, PointAnnotation): + data.update(x=annotation.x, y=annotation.y) + elif isinstance(annotation, SegmentAnnotation): + data.update( + x0=annotation.x0, + y0=annotation.y0, + x1=annotation.x1, + y1=annotation.y1, + ) + elif isinstance(annotation, RectangleAnnotation): + data.update( + x=annotation.x, + y=annotation.y, + width=annotation.width, + height=annotation.height, + angle=annotation.angle, + ) + elif isinstance(annotation, CircleAnnotation): + data.update(cx=annotation.cx, cy=annotation.cy, radius=annotation.radius) + elif isinstance(annotation, EllipseAnnotation): + data.update( + cx=annotation.cx, + cy=annotation.cy, + radius_x=annotation.radius_x, + radius_y=annotation.radius_y, + angle=annotation.angle, + ) + elif isinstance(annotation, (PolylineAnnotation, PolygonAnnotation)): + data["points"] = [list(point) for point in annotation.points] + elif isinstance(annotation, TextAnnotation): + data.update( + text=annotation.text, + x=annotation.x, + y=annotation.y, + coordinate_space=annotation.coordinate_space.value, + anchor=annotation.anchor.value, + offset=list(annotation.offset), + ) + elif isinstance(annotation, CursorAnnotation): + data.update( + orientation=annotation.orientation.value, + position=_json_value(annotation.position), + ) + elif isinstance(annotation, RangeAnnotation): + data.update( + axis=annotation.axis.value, start=annotation.start, end=annotation.end + ) + else: # pragma: no cover - protected by the closed model hierarchy + raise TypeError(f"Unsupported annotation type: {type(annotation).__name__}") + return data + + +def is_graphical_annotation_dict(data: Any) -> bool: + """Return whether a dictionary declares the canonical annotation format.""" + return isinstance(data, Mapping) and data.get("format") == ANNOTATION_FORMAT + + +def annotation_from_dict(data: Mapping[str, Any]) -> GraphicalAnnotation: + """Deserialize and validate a canonical graphical annotation dictionary.""" + if not isinstance(data, Mapping): + raise TypeError("annotation data must be an object") + if data.get("format") != ANNOTATION_FORMAT: + raise ValueError(f"Unsupported annotation format: {data.get('format')!r}") + if data.get("version") != ANNOTATION_VERSION: + raise ValueError(f"Unsupported annotation version: {data.get('version')!r}") + try: + kind = AnnotationKind(data["kind"]) + except KeyError as exc: + raise ValueError("Missing annotation kind") from exc + except ValueError as exc: + raise ValueError(f"Unsupported annotation kind: {data.get('kind')!r}") from exc + _check_keys(data, _COMMON_FIELDS | _KIND_FIELDS[kind], "annotation") + + common = { + "id": data["id"], + "visible": data.get("visible", True), + "locked": data.get("locked", False), + "z_index": data.get("z_index", 0), + "title": data.get("title", ""), + "style": _style_from_dict(data.get("style", {})), + "label": _label_from_dict(data.get("label")), + "metadata": data.get("metadata", {}), + "extensions": data.get("extensions", {}), + } + geometry = {name: data[name] for name in _KIND_FIELDS[kind]} + annotation_class = _ANNOTATION_CLASSES[kind] + return annotation_class(**common, **geometry) diff --git a/sigima/objects/annotations/transform.py b/sigima/objects/annotations/transform.py new file mode 100644 index 00000000..b3bad466 --- /dev/null +++ b/sigima/objects/annotations/transform.py @@ -0,0 +1,490 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Geometric transformations for graphical annotations.""" + +from __future__ import annotations + +import math +from dataclasses import replace +from typing import Any + +import numpy as np + +from sigima.objects.annotations.model import ( + Axis, + CircleAnnotation, + CursorAnnotation, + CursorOrientation, + EllipseAnnotation, + GraphicalAnnotation, + PointAnnotation, + PolygonAnnotation, + PolylineAnnotation, + RangeAnnotation, + RectangleAnnotation, + SegmentAnnotation, + TextAnnotation, +) + + +class AnnotationTransformError(ValueError): + """Raised when a transformed annotation is not exactly representable.""" + + +def _normalize_angle(angle: float) -> float: + """Normalize an angle to the [-pi, pi] interval.""" + return math.atan2(math.sin(angle), math.cos(angle)) + + +def _rotate_point( + x: float, y: float, angle: float, center: tuple[float, float] +) -> tuple[float, float]: + """Rotate one point around a center.""" + cx, cy = center + cos_angle = math.cos(angle) + sin_angle = math.sin(angle) + dx, dy = x - cx, y - cy + return ( + cx + cos_angle * dx - sin_angle * dy, + cy + sin_angle * dx + cos_angle * dy, + ) + + +def _scale_point( + x: float, y: float, sx: float, sy: float, center: tuple[float, float] +) -> tuple[float, float]: + """Scale one point around a center.""" + cx, cy = center + return cx + sx * (x - cx), cy + sy * (y - cy) + + +def _map_points(annotation: GraphicalAnnotation, function) -> GraphicalAnnotation: + """Map all explicit points of a point-based annotation.""" + if isinstance(annotation, PointAnnotation): + x, y = function(annotation.x, annotation.y) + return replace(annotation, x=x, y=y) + if isinstance(annotation, SegmentAnnotation): + x0, y0 = function(annotation.x0, annotation.y0) + x1, y1 = function(annotation.x1, annotation.y1) + return replace(annotation, x0=x0, y0=y0, x1=x1, y1=y1) + if isinstance(annotation, (PolylineAnnotation, PolygonAnnotation)): + points = tuple(function(x, y) for x, y in annotation.points) + return replace(annotation, points=points) + if isinstance(annotation, TextAnnotation): + if annotation.coordinate_space.value == "axes": + return annotation + x, y = function(annotation.x, annotation.y) + return replace(annotation, x=x, y=y) + raise TypeError(f"Unsupported point-based annotation: {type(annotation).__name__}") + + +def _require_quarter_turn(angle: float) -> int: + """Return a quarter-turn count or raise for an oblique axis primitive.""" + turns = round(angle / (math.pi / 2.0)) + if not math.isclose(angle, turns * math.pi / 2.0, abs_tol=1e-12): + raise AnnotationTransformError( + "Axis cursor and range annotations only support quarter-turn rotations" + ) + return turns % 4 + + +def _rotate_cursor( + annotation: CursorAnnotation, + angle: float, + center: tuple[float, float], +) -> CursorAnnotation: + """Rotate an axis cursor by a representable quarter turn.""" + turns = _require_quarter_turn(angle) + cx, cy = center + if annotation.orientation == CursorOrientation.CROSSHAIR: + assert isinstance(annotation.position, tuple) + position = _rotate_point(*annotation.position, angle, center) + return replace(annotation, position=position) + if annotation.orientation == CursorOrientation.HORIZONTAL: + assert isinstance(annotation.position, float) + point = _rotate_point(cx, annotation.position, angle, center) + orientation = ( + CursorOrientation.HORIZONTAL + if turns % 2 == 0 + else CursorOrientation.VERTICAL + ) + else: + assert isinstance(annotation.position, float) + point = _rotate_point(annotation.position, cy, angle, center) + orientation = ( + CursorOrientation.VERTICAL + if turns % 2 == 0 + else CursorOrientation.HORIZONTAL + ) + position = point[1] if orientation == CursorOrientation.HORIZONTAL else point[0] + return replace(annotation, orientation=orientation, position=position) + + +def _rotate_range( + annotation: RangeAnnotation, + angle: float, + center: tuple[float, float], +) -> RangeAnnotation: + """Rotate an axis range by a representable quarter turn.""" + turns = _require_quarter_turn(angle) + cx, cy = center + if annotation.axis == Axis.X: + points = ( + _rotate_point(annotation.start, cy, angle, center), + _rotate_point(annotation.end, cy, angle, center), + ) + axis = Axis.X if turns % 2 == 0 else Axis.Y + else: + points = ( + _rotate_point(cx, annotation.start, angle, center), + _rotate_point(cx, annotation.end, angle, center), + ) + axis = Axis.Y if turns % 2 == 0 else Axis.X + coordinate = 0 if axis == Axis.X else 1 + return replace( + annotation, + axis=axis, + start=points[0][coordinate], + end=points[1][coordinate], + ) + + +def translate_annotation( + annotation: GraphicalAnnotation, dx: float, dy: float +) -> GraphicalAnnotation: + """Translate an annotation in data coordinates.""" + if isinstance( + annotation, + ( + PointAnnotation, + SegmentAnnotation, + PolylineAnnotation, + PolygonAnnotation, + TextAnnotation, + ), + ): + return _map_points(annotation, lambda x, y: (x + dx, y + dy)) + if isinstance(annotation, (RectangleAnnotation,)): + return replace(annotation, x=annotation.x + dx, y=annotation.y + dy) + if isinstance(annotation, (CircleAnnotation, EllipseAnnotation)): + return replace(annotation, cx=annotation.cx + dx, cy=annotation.cy + dy) + if isinstance(annotation, CursorAnnotation): + if annotation.orientation == CursorOrientation.CROSSHAIR: + assert isinstance(annotation.position, tuple) + return replace( + annotation, + position=(annotation.position[0] + dx, annotation.position[1] + dy), + ) + assert isinstance(annotation.position, float) + delta = dy if annotation.orientation == CursorOrientation.HORIZONTAL else dx + return replace(annotation, position=annotation.position + delta) + if isinstance(annotation, RangeAnnotation): + delta = dx if annotation.axis == Axis.X else dy + return replace( + annotation, + start=annotation.start + delta, + end=annotation.end + delta, + ) + raise TypeError(f"Unsupported annotation type: {type(annotation).__name__}") + + +def rotate_annotation( + annotation: GraphicalAnnotation, + angle: float, + center: tuple[float, float] = (0.0, 0.0), +) -> GraphicalAnnotation: + """Rotate an annotation counterclockwise around a center.""" + + def point_transform(x: float, y: float) -> tuple[float, float]: + return _rotate_point(x, y, angle, center) + + if isinstance( + annotation, + ( + PointAnnotation, + SegmentAnnotation, + PolylineAnnotation, + PolygonAnnotation, + TextAnnotation, + ), + ): + return _map_points(annotation, point_transform) + if isinstance(annotation, RectangleAnnotation): + x, y = point_transform(annotation.x, annotation.y) + return replace( + annotation, x=x, y=y, angle=_normalize_angle(annotation.angle + angle) + ) + if isinstance(annotation, CircleAnnotation): + cx, cy = point_transform(annotation.cx, annotation.cy) + return replace(annotation, cx=cx, cy=cy) + if isinstance(annotation, EllipseAnnotation): + cx, cy = point_transform(annotation.cx, annotation.cy) + return replace( + annotation, + cx=cx, + cy=cy, + angle=_normalize_angle(annotation.angle + angle), + ) + if isinstance(annotation, CursorAnnotation): + return _rotate_cursor(annotation, angle, center) + if isinstance(annotation, RangeAnnotation): + return _rotate_range(annotation, angle, center) + raise TypeError(f"Unsupported annotation type: {type(annotation).__name__}") + + +def flip_annotation_horizontally( + annotation: GraphicalAnnotation, cx: float = 0.0 +) -> GraphicalAnnotation: + """Flip an annotation around the vertical line ``x=cx``.""" + if isinstance(annotation, CursorAnnotation): + if annotation.orientation == CursorOrientation.HORIZONTAL: + return annotation + if annotation.orientation == CursorOrientation.VERTICAL: + assert isinstance(annotation.position, float) + return replace(annotation, position=2 * cx - annotation.position) + assert isinstance(annotation.position, tuple) + return replace( + annotation, + position=(2 * cx - annotation.position[0], annotation.position[1]), + ) + if isinstance(annotation, RangeAnnotation): + if annotation.axis == Axis.Y: + return annotation + return replace( + annotation, start=2 * cx - annotation.end, end=2 * cx - annotation.start + ) + transformed = scale_annotation(annotation, -1.0, 1.0, center=(cx, 0.0)) + return transformed + + +def flip_annotation_vertically( + annotation: GraphicalAnnotation, cy: float = 0.0 +) -> GraphicalAnnotation: + """Flip an annotation around the horizontal line ``y=cy``.""" + if isinstance(annotation, CursorAnnotation): + if annotation.orientation == CursorOrientation.VERTICAL: + return annotation + if annotation.orientation == CursorOrientation.HORIZONTAL: + assert isinstance(annotation.position, float) + return replace(annotation, position=2 * cy - annotation.position) + assert isinstance(annotation.position, tuple) + return replace( + annotation, + position=(annotation.position[0], 2 * cy - annotation.position[1]), + ) + if isinstance(annotation, RangeAnnotation): + if annotation.axis == Axis.X: + return annotation + return replace( + annotation, start=2 * cy - annotation.end, end=2 * cy - annotation.start + ) + transformed = scale_annotation(annotation, 1.0, -1.0, center=(0.0, cy)) + return transformed + + +def transpose_annotation(annotation: GraphicalAnnotation) -> GraphicalAnnotation: + """Transpose an annotation by exchanging its X and Y axes.""" + if isinstance(annotation, CursorAnnotation): + if annotation.orientation == CursorOrientation.CROSSHAIR: + assert isinstance(annotation.position, tuple) + transformed = replace(annotation, position=annotation.position[::-1]) + else: + orientation = ( + CursorOrientation.VERTICAL + if annotation.orientation == CursorOrientation.HORIZONTAL + else CursorOrientation.HORIZONTAL + ) + transformed = replace(annotation, orientation=orientation) + elif isinstance(annotation, RangeAnnotation): + axis = Axis.Y if annotation.axis == Axis.X else Axis.X + transformed = replace(annotation, axis=axis) + elif isinstance(annotation, RectangleAnnotation): + transformed = replace( + annotation, + x=annotation.y, + y=annotation.x, + angle=_normalize_angle(math.pi / 2.0 - annotation.angle), + ) + elif isinstance(annotation, CircleAnnotation): + transformed = replace(annotation, cx=annotation.cy, cy=annotation.cx) + elif isinstance(annotation, EllipseAnnotation): + transformed = replace( + annotation, + cx=annotation.cy, + cy=annotation.cx, + angle=_normalize_angle(math.pi / 2.0 - annotation.angle), + ) + else: + transformed = _map_points(annotation, lambda x, y: (y, x)) + return transformed + + +def _scale_rectangle( + annotation: RectangleAnnotation, + sx: float, + sy: float, + center: tuple[float, float], +) -> RectangleAnnotation: + """Scale a rectangle when the transformed edges remain orthogonal.""" + cos_angle = math.cos(annotation.angle) + sin_angle = math.sin(annotation.angle) + edge_x = np.array( + [annotation.width * cos_angle * sx, annotation.width * sin_angle * sy] + ) + edge_y = np.array( + [-annotation.height * sin_angle * sx, annotation.height * cos_angle * sy] + ) + if np.linalg.norm(edge_x) and np.linalg.norm(edge_y): + dot = float(np.dot(edge_x, edge_y)) + tolerance = 1e-12 * float(np.linalg.norm(edge_x) * np.linalg.norm(edge_y)) + if not math.isclose(dot, 0.0, abs_tol=tolerance): + raise AnnotationTransformError( + "An anisotropically scaled rotated rectangle becomes a parallelogram" + ) + x, y = _scale_point(annotation.x, annotation.y, sx, sy, center) + if np.linalg.norm(edge_x): + angle = math.atan2(edge_x[1], edge_x[0]) + elif np.linalg.norm(edge_y): + angle = math.atan2(edge_y[1], edge_y[0]) - math.pi / 2.0 + else: + angle = annotation.angle + return replace( + annotation, + x=x, + y=y, + width=float(np.linalg.norm(edge_x)), + height=float(np.linalg.norm(edge_y)), + angle=_normalize_angle(angle), + ) + + +def _scale_ellipse( + annotation: EllipseAnnotation, + sx: float, + sy: float, + center: tuple[float, float], +) -> EllipseAnnotation: + """Scale an ellipse through singular-value decomposition.""" + cos_angle = math.cos(annotation.angle) + sin_angle = math.sin(annotation.angle) + rotation = np.array([[cos_angle, -sin_angle], [sin_angle, cos_angle]], dtype=float) + transform = ( + np.diag([sx, sy]) + @ rotation + @ np.diag([annotation.radius_x, annotation.radius_y]) + ) + axes, radii, _ = np.linalg.svd(transform) + x_axis = axes[:, 0] + angle = math.atan2(x_axis[1], x_axis[0]) + cx, cy = _scale_point(annotation.cx, annotation.cy, sx, sy, center) + return replace( + annotation, + cx=cx, + cy=cy, + radius_x=float(radii[0]), + radius_y=float(radii[1]), + angle=_normalize_angle(angle), + ) + + +def scale_annotation( + annotation: GraphicalAnnotation, + sx: float, + sy: float, + center: tuple[float, float] = (0.0, 0.0), +) -> GraphicalAnnotation: + """Scale an annotation around a center.""" + + def point_transform(x: float, y: float) -> tuple[float, float]: + return _scale_point(x, y, sx, sy, center) + + if isinstance( + annotation, + ( + PointAnnotation, + SegmentAnnotation, + PolylineAnnotation, + PolygonAnnotation, + TextAnnotation, + ), + ): + transformed = _map_points(annotation, point_transform) + elif isinstance(annotation, RectangleAnnotation): + transformed = _scale_rectangle(annotation, sx, sy, center) + elif isinstance(annotation, CircleAnnotation): + cx, cy = point_transform(annotation.cx, annotation.cy) + if math.isclose(abs(sx), abs(sy)): + transformed = replace( + annotation, cx=cx, cy=cy, radius=annotation.radius * abs(sx) + ) + else: + transformed = EllipseAnnotation( + id=annotation.id, + visible=annotation.visible, + locked=annotation.locked, + z_index=annotation.z_index, + title=annotation.title, + style=annotation.style, + label=annotation.label, + metadata=annotation.metadata, + extensions=annotation.extensions, + cx=cx, + cy=cy, + radius_x=annotation.radius * abs(sx), + radius_y=annotation.radius * abs(sy), + ) + elif isinstance(annotation, EllipseAnnotation): + transformed = _scale_ellipse(annotation, sx, sy, center) + elif isinstance(annotation, CursorAnnotation): + if annotation.orientation == CursorOrientation.CROSSHAIR: + assert isinstance(annotation.position, tuple) + transformed = replace( + annotation, position=point_transform(*annotation.position) + ) + else: + assert isinstance(annotation.position, float) + cx, cy = center + if annotation.orientation == CursorOrientation.HORIZONTAL: + position = cy + sy * (annotation.position - cy) + else: + position = cx + sx * (annotation.position - cx) + transformed = replace(annotation, position=position) + elif isinstance(annotation, RangeAnnotation): + origin = center[0] if annotation.axis == Axis.X else center[1] + factor = sx if annotation.axis == Axis.X else sy + transformed = replace( + annotation, + start=origin + factor * (annotation.start - origin), + end=origin + factor * (annotation.end - origin), + ) + else: + raise TypeError(f"Unsupported annotation type: {type(annotation).__name__}") + return transformed + + +def transform_annotation( + annotation: GraphicalAnnotation, operation: str, **kwargs: Any +) -> GraphicalAnnotation: + """Apply a named geometric operation and return a new annotation.""" + if operation == "translate": + return translate_annotation( + annotation, kwargs.get("dx", 0), kwargs.get("dy", 0) + ) + if operation == "rotate": + return rotate_annotation( + annotation, kwargs.get("angle", 0), kwargs.get("center", (0, 0)) + ) + if operation == "fliph": + return flip_annotation_horizontally(annotation, kwargs.get("cx", 0)) + if operation == "flipv": + return flip_annotation_vertically(annotation, kwargs.get("cy", 0)) + if operation == "transpose": + return transpose_annotation(annotation) + if operation == "scale": + return scale_annotation( + annotation, + kwargs.get("sx", 1), + kwargs.get("sy", 1), + kwargs.get("center", (0, 0)), + ) + raise ValueError(f"Unknown annotation transformation: {operation}") diff --git a/sigima/objects/base.py b/sigima/objects/base.py index 1bc5d890..01660af2 100644 --- a/sigima/objects/base.py +++ b/sigima/objects/base.py @@ -21,6 +21,12 @@ from numpy import ma from sigima.config import _ +from sigima.objects.annotations import ( + GraphicalAnnotation, + annotation_from_dict, + annotation_to_dict, + is_graphical_annotation_dict, +) if sys.version_info >= (3, 11): # Use Self from typing module in Python 3.11+ @@ -68,8 +74,7 @@ def deepcopy_metadata( special_keys: set[str] | None = None, all_metadata: bool = False, ) -> dict[str, Any]: - """Deepcopy metadata, except keys starting with "_" (private keys) - with the exception of "_roi_" and "_ann_" keys. + """Deepcopy metadata, except private keys and explicitly preserved keys. Args: metadata: Metadata dictionary to deepcopy. @@ -711,6 +716,86 @@ def has_annotations(self) -> bool: """ return bool(self.get_annotations()) + def get_graphical_annotations(self) -> list[GraphicalAnnotation]: + """Return canonical graphical annotations stored on the object. + + Opaque application-specific entries are ignored. Entries declaring the + canonical Sigima format are strictly validated and raise an exception when + malformed or unsupported, preventing silent reinterpretation. + + Returns: + Canonical graphical annotations in storage order. + """ + return [ + annotation_from_dict(item) + for item in self.get_annotations() + if is_graphical_annotation_dict(item) + ] + + def set_graphical_annotations( + self, + annotations: list[GraphicalAnnotation], + preserve_opaque: bool = True, + ) -> None: + """Set canonical graphical annotations on the object. + + Args: + annotations: Canonical graphical annotations to store. + preserve_opaque: Preserve non-canonical entries already stored on the + object. Defaults to True. + + Raises: + TypeError: If annotations is not a list of GraphicalAnnotation objects. + """ + if not isinstance(annotations, list): + raise TypeError( + f"Graphical annotations must be a list, got {type(annotations)}" + ) + if not all(isinstance(item, GraphicalAnnotation) for item in annotations): + raise TypeError( + "Graphical annotations must contain GraphicalAnnotation objects" + ) + stored = [] + if preserve_opaque: + stored.extend( + item + for item in self.get_annotations() + if not is_graphical_annotation_dict(item) + ) + stored.extend(annotation_to_dict(item) for item in annotations) + if stored: + self.set_annotations(stored) + else: + self.clear_annotations() + + def add_graphical_annotation(self, annotation: GraphicalAnnotation) -> None: + """Append a canonical graphical annotation without altering opaque entries. + + Args: + annotation: Canonical graphical annotation to append. + """ + if not isinstance(annotation, GraphicalAnnotation): + raise TypeError("annotation must be a GraphicalAnnotation") + stored = self.get_annotations() + stored.append(annotation_to_dict(annotation)) + self.set_annotations(stored) + + def clear_graphical_annotations(self) -> None: + """Remove canonical graphical annotations while preserving opaque entries.""" + opaque = [ + item + for item in self.get_annotations() + if not is_graphical_annotation_dict(item) + ] + if opaque: + self.set_annotations(opaque) + else: + self.clear_annotations() + + def has_graphical_annotations(self) -> bool: + """Return whether the object stores canonical graphical annotations.""" + return bool(self.get_graphical_annotations()) + class BaseROIParamMeta(abc.ABCMeta, gds.DataSetMeta): """Mixed metaclass to avoid conflicts""" diff --git a/sigima/proc/image/geometry.py b/sigima/proc/image/geometry.py index 40f94339..2fa56afc 100644 --- a/sigima/proc/image/geometry.py +++ b/sigima/proc/image/geometry.py @@ -88,6 +88,7 @@ def translate(src: ImageObj, p: TranslateParam) -> ImageObj: else: dst.set_coords(src.xcoords + p.dx, src.ycoords + p.dy) transformer.transform_roi(dst, "translate", dx=p.dx, dy=p.dy) + transformer.transform_annotations(dst, "translate", dx=p.dx, dy=p.dy) return dst @@ -150,6 +151,7 @@ def rotate(src: ImageObj, p: RotateParam) -> ImageObj: prefilter=p.prefilter, ) dst.roi = None # Reset ROI as it may change after rotation + dst.clear_graphical_annotations() return dst @@ -192,6 +194,8 @@ def rotate90(src: ImageObj) -> ImageObj: # shapes cannot do (a rotated rectangle is no longer a rectangle). transformer.transform_roi(dst, "transpose") transformer.transform_roi(dst, "flipv", cy=dst.yc) + transformer.transform_annotations(dst, "transpose") + transformer.transform_annotations(dst, "flipv", cy=dst.yc) return dst @@ -211,6 +215,8 @@ def rotate270(src: ImageObj) -> ImageObj: # ``numpy.rot90(a, 3)`` is ``numpy.fliplr(a.T)`` (see :func:`rotate90`). transformer.transform_roi(dst, "transpose") transformer.transform_roi(dst, "fliph", cx=dst.xc) + transformer.transform_annotations(dst, "transpose") + transformer.transform_annotations(dst, "fliph", cx=dst.xc) return dst @@ -227,6 +233,7 @@ def fliph(src: ImageObj) -> ImageObj: dst = dst_1_to_1(src, "fliph") dst.data = np.fliplr(src.data) transformer.transform_roi(dst, "fliph", cx=dst.xc) + transformer.transform_annotations(dst, "fliph", cx=dst.xc) return dst @@ -243,6 +250,7 @@ def flipv(src: ImageObj) -> ImageObj: dst = dst_1_to_1(src, "flipv") dst.data = np.flipud(src.data) transformer.transform_roi(dst, "flipv", cy=dst.yc) + transformer.transform_annotations(dst, "flipv", cy=dst.yc) return dst @@ -319,6 +327,7 @@ def transpose(src: ImageObj) -> ImageObj: dst.data = np.transpose(src.data) __swap_axes(src, dst) transformer.transform_roi(dst, "transpose") + transformer.transform_annotations(dst, "transpose") return dst diff --git a/sigima/proc/image/transformations.py b/sigima/proc/image/transformations.py index 913b5445..c3bd71f7 100644 --- a/sigima/proc/image/transformations.py +++ b/sigima/proc/image/transformations.py @@ -15,6 +15,7 @@ import numpy as np +from sigima.objects.annotations import transform_annotation from sigima.objects.scalar import GeometryResult, KindShape from sigima.objects.shape import ( CircleCoordinates, @@ -214,6 +215,27 @@ def transform_roi(self, image: ImageObj, operation: str, **kwargs: Any) -> None: image.roi = new_roi + def transform_annotations( + self, image: ImageObj, operation: str, **kwargs: Any + ) -> None: + """Transform all canonical annotations of an image inplace. + + Opaque application-specific annotation payloads are preserved unchanged. + + Args: + image: Image object whose canonical annotations will be transformed. + operation: Operation name. + **kwargs: Operation-specific parameters. + """ + annotations = image.get_graphical_annotations() + if not annotations: + return + transformed = [ + transform_annotation(annotation, operation, **kwargs) + for annotation in annotations + ] + image.set_graphical_annotations(transformed, preserve_opaque=True) + def _apply_operation( self, shape_coords: ( diff --git a/sigima/tests/common/annotations_model_unit_test.py b/sigima/tests/common/annotations_model_unit_test.py new file mode 100644 index 00000000..73ea16b4 --- /dev/null +++ b/sigima/tests/common/annotations_model_unit_test.py @@ -0,0 +1,103 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Unit tests for the renderer-independent annotation model.""" + +from dataclasses import FrozenInstanceError + +import pytest + +from sigima.objects.annotations import ( + AnnotationKind, + AnnotationLabel, + AnnotationStyle, + Axis, + CircleAnnotation, + CursorAnnotation, + CursorOrientation, + EllipseAnnotation, + PointAnnotation, + PolygonAnnotation, + PolylineAnnotation, + RangeAnnotation, + RectangleAnnotation, + SegmentAnnotation, + StrokeStyle, + TextAnnotation, +) + + +def test_all_annotation_primitives() -> None: + """Check that every canonical primitive can be constructed.""" + annotations = [ + PointAnnotation(x=1, y=2), + SegmentAnnotation(x0=0, y0=1, x1=2, y1=3), + RectangleAnnotation(x=1, y=2, width=3, height=4, angle=0.5), + CircleAnnotation(cx=1, cy=2, radius=3), + EllipseAnnotation(cx=1, cy=2, radius_x=3, radius_y=4, angle=0.5), + PolylineAnnotation(points=((0, 0), (1, 1))), + PolygonAnnotation(points=((0, 0), (1, 0), (0, 1))), + TextAnnotation(text="Peak", x=1, y=2), + CursorAnnotation(orientation=CursorOrientation.CROSSHAIR, position=(1, 2)), + RangeAnnotation(axis=Axis.X, start=2, end=1), + ] + + assert [annotation.kind for annotation in annotations] == list(AnnotationKind) + assert annotations[-1].start == 1 + assert annotations[-1].end == 2 + + +def test_annotation_is_deeply_immutable() -> None: + """Check that common and nested values cannot be modified after creation.""" + annotation = PointAnnotation( + x=1, + y=2, + metadata={"source": {"names": ["a", "b"]}}, + extensions={"plotpy": {"custom": True}}, + ) + + with pytest.raises(FrozenInstanceError): + annotation.x = 3 # type: ignore[misc] + with pytest.raises(TypeError): + annotation.metadata["new"] = "value" # type: ignore[index] + with pytest.raises(TypeError): + annotation.metadata["source"]["new"] = "value" # type: ignore[index] + assert annotation.metadata["source"]["names"] == ("a", "b") + + +def test_common_style_and_label() -> None: + """Check renderer-neutral style and label values.""" + annotation = SegmentAnnotation( + x0=0, + y0=0, + x1=1, + y1=1, + style=AnnotationStyle( + stroke=StrokeStyle(color="#123456", width=2, dash=(4, 2)) + ), + label=AnnotationLabel(text="Distance", offset=(2, 4)), + locked=True, + z_index=5, + ) + + assert annotation.style.stroke.dash == (4.0, 2.0) + assert annotation.label is not None + assert annotation.label.offset == (2.0, 4.0) + assert annotation.locked + assert annotation.z_index == 5 + + +@pytest.mark.parametrize( + "factory", + [ + lambda: PointAnnotation(x=float("nan")), + lambda: CircleAnnotation(radius=-1), + lambda: PolygonAnnotation(points=((0, 0), (1, 1))), + lambda: CursorAnnotation(orientation=CursorOrientation.CROSSHAIR, position=1), + lambda: PointAnnotation(metadata={"bad": object()}), + lambda: StrokeStyle(opacity=2), + ], +) +def test_invalid_annotation_values(factory) -> None: + """Check that invalid or non-portable values are rejected.""" + with pytest.raises((TypeError, ValueError)): + factory() diff --git a/sigima/tests/common/annotations_schema_unit_test.py b/sigima/tests/common/annotations_schema_unit_test.py new file mode 100644 index 00000000..fd388c08 --- /dev/null +++ b/sigima/tests/common/annotations_schema_unit_test.py @@ -0,0 +1,107 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Unit tests for graphical annotation serialization and JSON Schema.""" + +import copy +import json +from importlib import resources + +import jsonschema +import pytest + +from sigima.objects.annotations import ( + AnnotationLabel, + Axis, + CircleAnnotation, + CursorAnnotation, + CursorOrientation, + EllipseAnnotation, + PointAnnotation, + PolygonAnnotation, + PolylineAnnotation, + RangeAnnotation, + RectangleAnnotation, + SegmentAnnotation, + TextAnnotation, + annotation_from_dict, + annotation_to_dict, +) + + +@pytest.fixture(name="annotations") +def annotations_fixture(): + """Return one instance of every canonical annotation primitive.""" + common = {"metadata": {"author": "Sigima"}, "extensions": {"test": [1, 2]}} + return [ + PointAnnotation(x=1, y=2, label=AnnotationLabel(text="Point"), **common), + SegmentAnnotation(x0=0, y0=1, x1=2, y1=3, **common), + RectangleAnnotation(x=1, y=2, width=3, height=4, angle=0.5, **common), + CircleAnnotation(cx=1, cy=2, radius=3, **common), + EllipseAnnotation(cx=1, cy=2, radius_x=3, radius_y=4, angle=0.5, **common), + PolylineAnnotation(points=((0, 0), (1, 1)), **common), + PolygonAnnotation(points=((0, 0), (1, 0), (0, 1)), **common), + TextAnnotation(text="Peak", x=1, y=2, offset=(3, 4), **common), + CursorAnnotation( + orientation=CursorOrientation.CROSSHAIR, position=(1, 2), **common + ), + RangeAnnotation(axis=Axis.Y, start=1, end=2, **common), + ] + + +@pytest.fixture(name="annotation_schema") +def annotation_schema_fixture(): + """Load the packaged graphical annotation schema.""" + path = resources.files("sigima.objects.annotations").joinpath("schema-v1.json") + return json.loads(path.read_text(encoding="utf-8")) + + +def test_annotation_round_trip(annotations) -> None: + """Check lossless model-to-JSON-to-model round-trips.""" + for annotation in annotations: + data = annotation_to_dict(annotation) + json_data = json.loads(json.dumps(data, allow_nan=False)) + assert annotation_from_dict(json_data) == annotation + + +def test_all_annotations_match_schema(annotations, annotation_schema) -> None: + """Check all serialized primitives against the packaged schema.""" + validator = jsonschema.Draft202012Validator( + annotation_schema, format_checker=jsonschema.FormatChecker() + ) + for annotation in annotations: + validator.validate(annotation_to_dict(annotation)) + + +def test_schema_and_deserializer_reject_unknown_field( + annotations, annotation_schema +) -> None: + """Check that normalized fields cannot silently drift.""" + data = annotation_to_dict(annotations[0]) + data["unexpected"] = True + + with pytest.raises(jsonschema.ValidationError): + jsonschema.Draft202012Validator(annotation_schema).validate(data) + with pytest.raises(ValueError, match="Unknown annotation field"): + annotation_from_dict(data) + + +def test_schema_and_deserializer_reject_missing_geometry( + annotations, annotation_schema +) -> None: + """Check that required primitive coordinates are enforced.""" + data = copy.deepcopy(annotation_to_dict(annotations[0])) + del data["x"] + + with pytest.raises(jsonschema.ValidationError): + jsonschema.Draft202012Validator(annotation_schema).validate(data) + with pytest.raises(KeyError): + annotation_from_dict(data) + + +def test_deserializer_rejects_future_version(annotations) -> None: + """Check that unsupported canonical versions are never reinterpreted.""" + data = annotation_to_dict(annotations[0]) + data["version"] = "2.0" + + with pytest.raises(ValueError, match="Unsupported annotation version"): + annotation_from_dict(data) diff --git a/sigima/tests/common/annotations_transform_unit_test.py b/sigima/tests/common/annotations_transform_unit_test.py new file mode 100644 index 00000000..03577009 --- /dev/null +++ b/sigima/tests/common/annotations_transform_unit_test.py @@ -0,0 +1,112 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Unit tests for canonical annotation transformations.""" + +import math + +import pytest + +from sigima.objects.annotations import ( + AnnotationTransformError, + Axis, + CircleAnnotation, + CursorAnnotation, + CursorOrientation, + EllipseAnnotation, + PointAnnotation, + PolygonAnnotation, + RangeAnnotation, + RectangleAnnotation, + TextAnnotation, + rotate_annotation, + scale_annotation, + transform_annotation, + translate_annotation, + transpose_annotation, +) + + +def test_translate_preserves_identity_and_common_fields() -> None: + """Check point translation without changing annotation identity.""" + source = PointAnnotation(x=1, y=2, title="Peak", metadata={"source": "test"}) + + result = translate_annotation(source, 3, 4) + + assert (result.x, result.y) == (4, 6) + assert result.id == source.id + assert result.title == source.title + assert result.metadata == source.metadata + + +def test_rotate_rectangle_and_polygon() -> None: + """Check rotation of oriented and vertex-based geometries.""" + rectangle = RectangleAnnotation(x=1, y=0, width=2, height=1) + polygon = PolygonAnnotation(points=((0, 0), (1, 0), (0, 1))) + + rotated_rectangle = rotate_annotation(rectangle, math.pi / 2) + rotated_polygon = rotate_annotation(polygon, math.pi / 2) + + assert rotated_rectangle.x == pytest.approx(0) + assert rotated_rectangle.y == pytest.approx(1) + assert rotated_rectangle.angle == pytest.approx(math.pi / 2) + assert rotated_polygon.points[1] == pytest.approx((0, 1)) + + +def test_anisotropic_circle_scale_returns_ellipse() -> None: + """Check exact circle conversion under anisotropic scaling.""" + circle = CircleAnnotation(cx=1, cy=2, radius=3) + + result = scale_annotation(circle, 2, 4) + + assert isinstance(result, EllipseAnnotation) + assert (result.cx, result.cy) == (2, 8) + assert (result.radius_x, result.radius_y) == (6, 12) + assert result.id == circle.id + + +def test_anisotropic_rotated_rectangle_is_rejected() -> None: + """Check that non-representable parallelograms are not approximated.""" + rectangle = RectangleAnnotation(x=0, y=0, width=2, height=1, angle=math.pi / 4) + + with pytest.raises(AnnotationTransformError, match="parallelogram"): + scale_annotation(rectangle, 2, 1) + + +def test_axis_primitives_transpose_and_rotate() -> None: + """Check exact transformations of cursor and range primitives.""" + cursor = CursorAnnotation(orientation=CursorOrientation.VERTICAL, position=2) + interval = RangeAnnotation(axis=Axis.X, start=1, end=3) + + transposed = transpose_annotation(cursor) + rotated = rotate_annotation(interval, math.pi / 2) + + assert transposed.orientation == CursorOrientation.HORIZONTAL + assert transposed.position == 2 + assert rotated.axis == Axis.Y + assert (rotated.start, rotated.end) == pytest.approx((1, 3)) + + +def test_oblique_axis_primitive_rotation_is_rejected() -> None: + """Check that oblique infinite axis primitives are not approximated.""" + cursor = CursorAnnotation(orientation=CursorOrientation.HORIZONTAL, position=2) + + with pytest.raises(AnnotationTransformError, match="quarter-turn"): + rotate_annotation(cursor, math.pi / 4) + + +def test_axes_text_is_not_transformed() -> None: + """Check that overlay text remains fixed in normalized axes coordinates.""" + text = TextAnnotation(text="Title", x=0.1, y=0.9, coordinate_space="axes") + + result = transform_annotation(text, "translate", dx=10, dy=20) + + assert result is text + + +def test_range_scale_normalizes_reversed_bounds() -> None: + """Check range normalization after a negative scale.""" + interval = RangeAnnotation(axis=Axis.Y, start=1, end=3) + + result = scale_annotation(interval, 1, -1) + + assert (result.start, result.end) == (-3, -1) diff --git a/sigima/tests/common/annotations_unit_test.py b/sigima/tests/common/annotations_unit_test.py index 133e47d5..bd124512 100644 --- a/sigima/tests/common/annotations_unit_test.py +++ b/sigima/tests/common/annotations_unit_test.py @@ -4,8 +4,11 @@ import json +import numpy as np import pytest +import sigima.io +from sigima.objects import PointAnnotation from sigima.objects.image.creation import create_image from sigima.objects.signal.creation import create_signal @@ -146,5 +149,78 @@ def test_malformed_json_structure(): assert obj.get_annotations() == [] +def test_graphical_annotation_api_preserves_opaque_entries(): + """Test that typed replacement keeps application-specific payloads.""" + obj = create_signal("Test") + opaque = {"type": "plotpy_item", "plotpy_json": "{}"} + first = PointAnnotation(x=1, y=2, title="First") + second = PointAnnotation(x=3, y=4, title="Second") + obj.set_annotations([opaque]) + + obj.set_graphical_annotations([first]) + obj.add_graphical_annotation(second) + + assert obj.get_annotations()[0] == opaque + assert obj.get_graphical_annotations() == [first, second] + assert obj.has_graphical_annotations() + + +def test_clear_graphical_annotations_preserves_opaque_entries(): + """Test selective clearing of canonical annotations.""" + obj = create_signal("Test") + opaque = {"type": "future_application_payload"} + obj.set_annotations([opaque]) + obj.add_graphical_annotation(PointAnnotation(x=1, y=2)) + + obj.clear_graphical_annotations() + + assert obj.get_annotations() == [opaque] + assert not obj.has_graphical_annotations() + + +def test_graphical_annotation_api_rejects_invalid_canonical_entry(): + """Test that future canonical versions are not silently ignored.""" + obj = create_signal("Test") + obj.set_annotations([{"format": "sigima.annotation", "version": "2.0"}]) + + with pytest.raises(ValueError, match="Unsupported annotation version"): + obj.get_graphical_annotations() + + +def test_graphical_annotation_persistence_through_copy(): + """Test that typed annotations and identifiers persist through copies.""" + obj = create_image("Test") + annotation = PointAnnotation(x=1, y=2) + obj.set_graphical_annotations([annotation]) + + assert obj.copy().get_graphical_annotations() == [annotation] + + +def test_signal_graphical_annotation_hdf5_round_trip(tmp_path): + """Test canonical annotations through the native signal HDF5 format.""" + obj = create_signal("Test", x=np.array([0.0, 1.0]), y=np.array([2.0, 3.0])) + annotation = PointAnnotation(x=1, y=2, extensions={"test": [1, 2]}) + obj.set_graphical_annotations([annotation]) + filepath = str(tmp_path / "annotated.h5sig") + + sigima.io.write_signal(filepath, obj) + restored = sigima.io.read_signal(filepath) + + assert restored.get_graphical_annotations() == [annotation] + + +def test_image_graphical_annotation_hdf5_round_trip(tmp_path): + """Test canonical annotations through the native image HDF5 format.""" + obj = create_image("Test", data=np.arange(4).reshape(2, 2)) + annotation = PointAnnotation(x=1, y=2, metadata={"author": "Sigima"}) + obj.set_graphical_annotations([annotation]) + filepath = str(tmp_path / "annotated.h5ima") + + sigima.io.write_image(filepath, obj) + restored = sigima.io.read_image(filepath) + + assert restored.get_graphical_annotations() == [annotation] + + if __name__ == "__main__": pytest.main([__file__]) diff --git a/sigima/tests/image/geometry_unit_test.py b/sigima/tests/image/geometry_unit_test.py index 4f10c2ed..abd85fb1 100644 --- a/sigima/tests/image/geometry_unit_test.py +++ b/sigima/tests/image/geometry_unit_test.py @@ -239,6 +239,91 @@ def test_roi_translation() -> None: __check_roi_properties(ima, translated) +def __get_image_with_annotations() -> sigima.objects.ImageObj: + """Create a calibrated image with canonical and opaque annotations.""" + image = sigima.objects.create_image( + "annotations", np.arange(15, dtype=float).reshape(3, 5) + ) + image.set_uniform_coords(1.0, 2.0, 0.0, 0.0) + image.set_annotations([{"type": "plotpy_item", "plotpy_json": "{}"}]) + image.add_graphical_annotation(sigima.objects.PointAnnotation(x=1.0, y=2.0)) + return image + + +def test_annotation_translation() -> None: + """Canonical annotations follow physical image translations.""" + source = __get_image_with_annotations() + result = sigima.proc.image.translate( + source, sigima.params.TranslateParam.create(dx=3.0, dy=-1.0) + ) + + annotation = result.get_graphical_annotations()[0] + assert isinstance(annotation, sigima.objects.PointAnnotation) + assert (annotation.x, annotation.y) == (4.0, 1.0) + assert result.get_annotations()[0]["type"] == "plotpy_item" + + +@pytest.mark.parametrize( + "operation,expected", + [ + (sigima.proc.image.fliph, lambda image: (2 * image.xc - 1.0, 2.0)), + (sigima.proc.image.flipv, lambda image: (1.0, 2 * image.yc - 2.0)), + (sigima.proc.image.transpose, lambda _image: (2.0, 1.0)), + ], +) +def test_annotation_exact_geometry_operations(operation, expected) -> None: + """Canonical annotations follow exact flips and transposition.""" + source = __get_image_with_annotations() + expected_point = expected(source) + + result = operation(source) + annotation = result.get_graphical_annotations()[0] + + assert isinstance(annotation, sigima.objects.PointAnnotation) + assert (annotation.x, annotation.y) == pytest.approx(expected_point) + + +@pytest.mark.parametrize( + "operation,flip_operation", + [ + (sigima.proc.image.rotate90, sigima.proc.image.flipv), + (sigima.proc.image.rotate270, sigima.proc.image.fliph), + ], +) +def test_annotation_quarter_turn_composition(operation, flip_operation) -> None: + """Quarter-turn annotations follow the same transpose/flip composition.""" + source = __get_image_with_annotations() + + result = operation(source) + reference = flip_operation(sigima.proc.image.transpose(source)) + + assert result.get_graphical_annotations() == reference.get_graphical_annotations() + + +def test_arbitrary_rotation_clears_only_canonical_annotations() -> None: + """Arbitrary image rotation preserves opaque data but clears unsafe geometry.""" + source = __get_image_with_annotations() + + result = sigima.proc.image.rotate( + source, sigima.params.RotateParam.create(angle=30.0) + ) + + assert not result.has_graphical_annotations() + assert result.get_annotations() == [{"type": "plotpy_item", "plotpy_json": "{}"}] + + +def test_resize_preserves_data_coordinate_annotations() -> None: + """Resizing does not move annotations expressed in calibrated coordinates.""" + source = __get_image_with_annotations() + annotation = source.get_graphical_annotations()[0] + + result = sigima.proc.image.resize( + source, sigima.params.ResizeParam.create(zoom=2.0) + ) + + assert result.get_graphical_annotations() == [annotation] + + @pytest.mark.validation def test_image_rotate() -> None: """Image rotation test.""" diff --git a/sigima/tests/io/annotations_io_unit_test.py b/sigima/tests/io/annotations_io_unit_test.py index 4cf6f5b3..56819de8 100644 --- a/sigima/tests/io/annotations_io_unit_test.py +++ b/sigima/tests/io/annotations_io_unit_test.py @@ -5,7 +5,13 @@ import tempfile from pathlib import Path -from sigima.io import read_annotations, write_annotations +from sigima.io import ( + read_annotations, + read_graphical_annotations, + write_annotations, + write_graphical_annotations, +) +from sigima.objects import PointAnnotation, SegmentAnnotation, annotation_to_dict from sigima.objects.signal.creation import create_signal @@ -78,6 +84,31 @@ def test_write_read_annotations_with_object(): Path(filepath).unlink(missing_ok=True) +def test_write_read_graphical_annotations(tmp_path): + """Test typed graphical annotation file round-trip.""" + filepath = str(tmp_path / "canonical.dlabann") + annotations = [ + PointAnnotation(x=1, y=2, title="Peak"), + SegmentAnnotation(x0=0, y0=1, x1=2, y1=3), + ] + + write_graphical_annotations(filepath, annotations) + + assert read_graphical_annotations(filepath) == annotations + assert len(read_annotations(filepath)) == 2 + + +def test_read_graphical_annotations_ignores_opaque_entries(tmp_path): + """Test that typed file reads leave opaque entries to the raw API.""" + filepath = str(tmp_path / "mixed.dlabann") + annotation = PointAnnotation(x=1, y=2) + opaque = {"type": "plotpy_item", "plotpy_json": "{}"} + write_annotations(filepath, [opaque, annotation_to_dict(annotation)]) + + assert read_graphical_annotations(filepath) == [annotation] + assert read_annotations(filepath)[0] == opaque + + if __name__ == "__main__": test_write_read_annotations() test_write_read_annotations_with_object() diff --git a/sigima/tests/viz/annotation_mpl_unit_test.py b/sigima/tests/viz/annotation_mpl_unit_test.py new file mode 100644 index 00000000..da9658f4 --- /dev/null +++ b/sigima/tests/viz/annotation_mpl_unit_test.py @@ -0,0 +1,82 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Unit tests for canonical annotation rendering with Matplotlib.""" + +from collections import Counter + +import matplotlib.pyplot as plt +from matplotlib.lines import Line2D +from matplotlib.patches import Circle, Ellipse, Polygon, Rectangle +from matplotlib.text import Text + +from sigima.objects import ( + Axis, + CircleAnnotation, + CursorAnnotation, + CursorOrientation, + EllipseAnnotation, + PointAnnotation, + PolygonAnnotation, + PolylineAnnotation, + RangeAnnotation, + RectangleAnnotation, + SegmentAnnotation, + TextAnnotation, +) +from sigima.viz.annotation_mpl import add_annotations_to_axes + + +def test_all_annotation_primitives_create_expected_artists() -> None: + """Check structural rendering of every canonical primitive.""" + figure, axes = plt.subplots() + annotations = [ + PointAnnotation(x=1, y=2, z_index=1), + SegmentAnnotation(x0=0, y0=0, x1=1, y1=1, z_index=2), + RectangleAnnotation(x=1, y=2, width=3, height=4, z_index=3), + CircleAnnotation(cx=1, cy=2, radius=3, z_index=4), + EllipseAnnotation(cx=1, cy=2, radius_x=3, radius_y=4, z_index=5), + PolylineAnnotation(points=((0, 0), (1, 1)), z_index=6), + PolygonAnnotation(points=((0, 0), (1, 0), (0, 1)), z_index=7), + TextAnnotation(text="Peak", x=1, y=2, z_index=8), + CursorAnnotation( + orientation=CursorOrientation.CROSSHAIR, position=(1, 2), z_index=9 + ), + RangeAnnotation(axis=Axis.X, start=1, end=2, z_index=10), + ] + + artists = add_annotations_to_axes(axes, annotations) + + assert len(artists) == 11 + assert sum(isinstance(artist, Line2D) for artist in artists) == 5 + artist_types = Counter(map(type, artists)) + assert artist_types[Rectangle] == 2 + assert artist_types[Circle] == 1 + assert artist_types[Ellipse] == 1 + assert artist_types[Polygon] == 1 + assert sum(isinstance(artist, Text) for artist in artists) == 1 + assert [artist.get_zorder() for artist in artists] == sorted( + artist.get_zorder() for artist in artists + ) + plt.close(figure) + + +def test_axes_text_uses_normalized_transform() -> None: + """Check that overlay text uses the normalized axes coordinate system.""" + figure, axes = plt.subplots() + annotation = TextAnnotation(text="Overlay", x=0.1, y=0.9, coordinate_space="axes") + + artists = add_annotations_to_axes(axes, [annotation]) + + assert len(artists) == 1 + assert artists[0].get_transform() != axes.transData + plt.close(figure) + + +def test_hidden_annotation_creates_no_artist() -> None: + """Check annotation visibility at the renderer boundary.""" + figure, axes = plt.subplots() + + artists = add_annotations_to_axes(axes, [PointAnnotation(visible=False)]) + + assert not artists + plt.close(figure) diff --git a/sigima/tests/viz/annotation_plotpy_unit_test.py b/sigima/tests/viz/annotation_plotpy_unit_test.py new file mode 100644 index 00000000..9904dd96 --- /dev/null +++ b/sigima/tests/viz/annotation_plotpy_unit_test.py @@ -0,0 +1,197 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Unit tests for canonical annotation integration with PlotPy.""" + +import math + +import numpy as np +from guidata.io import JSONWriter +from plotpy.builder import make +from plotpy.io import save_items +from plotpy.items import ( + AnnotatedCircle, + AnnotatedEllipse, + AnnotatedObliqueRectangle, + AnnotatedPoint, + AnnotatedPolygon, + AnnotatedSegment, + AnnotatedXRange, + LabelItem, + Marker, +) + +import sigima.objects +from sigima.objects import ( + AnnotationStyle, + Axis, + CircleAnnotation, + CursorAnnotation, + CursorOrientation, + EllipseAnnotation, + MarkerStyle, + PointAnnotation, + PolygonAnnotation, + PolylineAnnotation, + RangeAnnotation, + RectangleAnnotation, + SegmentAnnotation, + TextAnnotation, +) +from sigima.viz.annotation_plotpy import ( + AxesLabelItem, + annotations_to_plotpy_items, + load_legacy_plotpy_items, + migrate_legacy_plotpy_annotations, +) + + +def test_all_annotation_primitives_create_native_items() -> None: + """Check conversion of every canonical primitive to a PlotPy item.""" + annotations = [ + PointAnnotation(x=1, y=2), + SegmentAnnotation(x0=0, y0=0, x1=1, y1=1), + RectangleAnnotation(x=1, y=2, width=3, height=4, angle=math.pi / 4), + CircleAnnotation(cx=1, cy=2, radius=3), + EllipseAnnotation(cx=1, cy=2, radius_x=3, radius_y=4), + PolylineAnnotation(points=((0, 0), (1, 1))), + PolygonAnnotation(points=((0, 0), (1, 0), (0, 1))), + TextAnnotation(text="Data", x=1, y=2), + TextAnnotation(text="Axes", x=0.1, y=0.9, coordinate_space="axes"), + CursorAnnotation(orientation=CursorOrientation.CROSSHAIR, position=(1, 2)), + RangeAnnotation(axis=Axis.X, start=1, end=2), + ] + + items = annotations_to_plotpy_items(annotations) + + assert [type(item) for item in items] == [ + AnnotatedPoint, + AnnotatedSegment, + AnnotatedObliqueRectangle, + AnnotatedCircle, + AnnotatedEllipse, + AnnotatedPolygon, + AnnotatedPolygon, + LabelItem, + AxesLabelItem, + Marker, + AnnotatedXRange, + ] + assert not items[5].is_closed() + assert items[6].is_closed() + + +def test_all_canonical_marker_symbols_create_valid_plotpy_markers() -> None: + """Check that every portable marker name maps to a valid PlotPy symbol.""" + expected_markers = { + "circle": "Ellipse", + "square": "Rect", + "diamond": "Diamond", + "cross": "Cross", + "x": "XCross", + "triangle-up": "UTriangle", + "triangle-down": "DTriangle", + "none": "NoSymbol", + } + + for symbol, expected in expected_markers.items(): + annotation = PointAnnotation( + style=AnnotationStyle(marker=MarkerStyle(symbol=symbol)) + ) + [item] = annotations_to_plotpy_items([annotation]) + + assert item.shape.shapeparam.symbol.marker == expected + + +def test_legacy_plotpy_payload_load_and_migration() -> None: + """Check explicit migration of a known historical PlotPy payload.""" + source_item = make.annotated_point(3.0, 4.0, title="Legacy point") + writer = JSONWriter(None) + save_items(writer, [source_item]) + payload = { + "type": "plotpy_item", + "item_class": "AnnotatedPoint", + "plotpy_json": writer.get_json(), + } + obj = sigima.objects.create_signal("legacy", np.arange(5), np.arange(5)) + obj.set_annotations([payload, {"consumer": "unknown"}]) + + loaded = load_legacy_plotpy_items(obj) + preview = migrate_legacy_plotpy_annotations(obj, dry_run=True) + assert preview.converted_count == 1 + assert not preview.applied + assert obj.get_annotations() == [payload, {"consumer": "unknown"}] + + report = migrate_legacy_plotpy_annotations(obj) + + assert len(loaded) == 1 + assert isinstance(loaded[0], AnnotatedPoint) + assert report.converted_count == 1 + assert report.applied + [annotation] = obj.get_graphical_annotations() + assert isinstance(annotation, PointAnnotation) + assert (annotation.x, annotation.y) == (3.0, 4.0) + assert {"consumer": "unknown"} in obj.get_annotations() + assert migrate_legacy_plotpy_annotations(obj).converted_count == 0 + + +def test_all_known_legacy_plotpy_types_are_migrated() -> None: + """Check migration coverage for the historical DataLab PlotPy surface.""" + items = [ + make.annotated_point(1, 2), + make.annotated_segment(0, 0, 1, 1), + make.annotated_rectangle(0, 0, 2, 3), + make.annotated_circle(0, 0, 2, 0), + make.annotated_ellipse(0, 0, 2, 0, 1, -2, 1, 2), + make.annotated_polygon(np.array([[0, 0], [1, 0], [0, 1]])), + make.label("Legacy label", (1, 2), (3, 4), "TL"), + make.marker(position=(1, 2), markerstyle="+"), + make.annotated_xrange(1, 2), + make.annotated_yrange(3, 4), + ] + obj = sigima.objects.create_signal("legacy", np.arange(5), np.arange(5)) + payloads = [] + for item in items: + writer = JSONWriter(None) + save_items(writer, [item]) + payloads.append( + { + "type": "plotpy_item", + "item_class": type(item).__name__, + "plotpy_json": writer.get_json(), + } + ) + obj.set_annotations(payloads) + + report = migrate_legacy_plotpy_annotations(obj) + annotations = obj.get_graphical_annotations() + + assert report.converted_count == len(items) + assert not report.diagnostics + assert [type(annotation) for annotation in annotations] == [ + PointAnnotation, + SegmentAnnotation, + RectangleAnnotation, + CircleAnnotation, + EllipseAnnotation, + PolygonAnnotation, + TextAnnotation, + CursorAnnotation, + RangeAnnotation, + RangeAnnotation, + ] + + +def test_unknown_legacy_item_is_preserved() -> None: + """Check that migration leaves unsupported PlotPy items untouched.""" + writer = JSONWriter(None) + save_items(writer, [make.curve([0, 1], [1, 2])]) + payload = {"type": "plotpy_item", "plotpy_json": writer.get_json()} + obj = sigima.objects.create_signal("legacy", np.arange(5), np.arange(5)) + obj.set_annotations([payload]) + + report = migrate_legacy_plotpy_annotations(obj) + + assert report.converted_count == 0 + assert report.preserved_count == 1 + assert report.diagnostics + assert obj.get_annotations() == [payload] diff --git a/sigima/tests/viz/annotations_gui_test.py b/sigima/tests/viz/annotations_gui_test.py new file mode 100644 index 00000000..a5b39d4c --- /dev/null +++ b/sigima/tests/viz/annotations_gui_test.py @@ -0,0 +1,390 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Interactive visual tests for renderer-independent graphical annotations.""" + +from __future__ import annotations + +import math + +import numpy as np +import pytest + +import sigima.objects as sio +from sigima.tests import guiutils + + +def _label(text: str, offset: tuple[float, float] = (0.0, 8.0)) -> sio.AnnotationLabel: + """Create a visible annotation label.""" + return sio.AnnotationLabel( + text=text, + anchor=sio.TextAnchor.BOTTOM, + offset=offset, + ) + + +def _style( + color: str, + *, + width: float = 2.0, + dash: str | tuple[float, ...] = "solid", + fill: str | None = None, + fill_opacity: float = 0.0, + marker: str = "circle", + marker_size: float = 8.0, +) -> sio.AnnotationStyle: + """Create a high-contrast style for visual inspection.""" + return sio.AnnotationStyle( + stroke=sio.StrokeStyle(color=color, width=width, dash=dash), + fill=sio.FillStyle(color=fill, opacity=fill_opacity), + marker=sio.MarkerStyle(symbol=marker, size=marker_size, color=color), + text=sio.TextStyle( + size=10, + bold=True, + color="#ffffff", + background_color="#20242b", + background_opacity=0.85, + ), + ) + + +def _create_image(title: str) -> sio.ImageObj: + """Create a calibrated image with enough contrast for overlays.""" + y_grid, x_grid = np.mgrid[0:96, 0:96] + data = 0.35 * x_grid + 0.2 * y_grid + 15.0 * np.sin(x_grid / 9.0) + image = sio.create_image( + title, + data, + units=("mm", "mm", "a.u."), + labels=("X", "Y", "Intensity"), + ) + image.set_uniform_coords(1.0, 1.0, 0.0, 0.0) + return image + + +def _create_signal(title: str) -> sio.SignalObj: + """Create a calibrated signal suitable for cursor and range overlays.""" + x_data = np.linspace(0.0, 4.0 * math.pi, 600) + y_data = np.sin(x_data) + 0.18 * np.sin(3.0 * x_data) + return sio.create_signal( + title, + x_data, + y_data, + units=("s", "V"), + labels=("Time", "Amplitude"), + ) + + +@pytest.mark.gui +def test_geometric_annotations_interactive() -> None: + """Visually inspect point, segment, rectangle, circle, ellipse and paths.""" + image = _create_image("Geometric annotation primitives") + annotations = [ + sio.PointAnnotation( + x=12, + y=78, + title="Point", + label=_label("Point"), + style=_style("#ffffff", marker="diamond", marker_size=11), + ), + sio.SegmentAnnotation( + x0=7, + y0=12, + x1=36, + y1=29, + title="Segment", + label=_label("Segment"), + style=_style("#00e5ff", width=3), + ), + sio.RectangleAnnotation( + x=29, + y=65, + width=28, + height=15, + angle=math.radians(18), + title="Rectangle", + label=_label("Oriented rectangle"), + style=_style("#ffea00", fill="#ffea00", fill_opacity=0.18, marker_size=5), + ), + sio.CircleAnnotation( + cx=68, + cy=73, + radius=12, + title="Circle", + label=_label("Circle"), + style=_style("#ff4081", fill="#ff4081", fill_opacity=0.16), + ), + sio.EllipseAnnotation( + cx=73, + cy=43, + radius_x=17, + radius_y=8, + angle=math.radians(-30), + title="Ellipse", + label=_label("Oriented ellipse"), + style=_style("#76ff03", fill="#76ff03", fill_opacity=0.16), + ), + sio.PolylineAnnotation( + points=((7, 44), (16, 55), (25, 43), (37, 53)), + title="Polyline", + label=_label("Open polyline", (0, 11)), + style=_style("#e040fb", width=3, dash="dashed"), + ), + sio.PolygonAnnotation( + points=((47, 10), (67, 8), (84, 20), (61, 31)), + title="Polygon", + label=_label("Closed polygon"), + style=_style("#ff9100", width=3, fill="#ff9100", fill_opacity=0.22), + ), + ] + image.set_graphical_annotations(annotations) + + assert {annotation.kind for annotation in annotations} == { + sio.AnnotationKind.POINT, + sio.AnnotationKind.SEGMENT, + sio.AnnotationKind.RECTANGLE, + sio.AnnotationKind.CIRCLE, + sio.AnnotationKind.ELLIPSE, + sio.AnnotationKind.POLYLINE, + sio.AnnotationKind.POLYGON, + } + with guiutils.lazy_qt_app_context(force=True): + from sigima import viz # pylint: disable=import-outside-toplevel + + viz.view_images( + image, + title="Visual check: geometric annotations", + show_annotations=True, + ) + + +@pytest.mark.gui +def test_text_cursor_and_range_annotations_interactive() -> None: + """Visually inspect data/axes text, cursors and X/Y ranges on a signal.""" + signal = _create_signal("Text, cursor and range annotations") + guide_style = _style("#d81b60", width=2, dash="dashdot") + annotations = [ + sio.TextAnnotation( + text="Data coordinates", + x=math.pi / 2, + y=1.18, + anchor=sio.TextAnchor.BOTTOM, + offset=(0, 8), + title="Data text", + style=_style("#ffffff"), + z_index=8, + ), + sio.TextAnnotation( + text="Normalized axes coordinates", + x=0.02, + y=0.97, + coordinate_space=sio.CoordinateSpace.AXES, + anchor=sio.TextAnchor.TOP_LEFT, + offset=(4, -4), + title="Axes text", + style=_style("#ffffff"), + z_index=9, + ), + sio.CursorAnnotation( + orientation=sio.CursorOrientation.VERTICAL, + position=math.pi, + title="Vertical cursor", + label=_label("Vertical cursor"), + style=guide_style, + z_index=5, + ), + sio.CursorAnnotation( + orientation=sio.CursorOrientation.HORIZONTAL, + position=0.55, + title="Horizontal cursor", + label=_label("Horizontal cursor"), + style=_style("#00acc1", width=2, dash="dotted"), + z_index=5, + ), + sio.CursorAnnotation( + orientation=sio.CursorOrientation.CROSSHAIR, + position=(3.0 * math.pi / 2.0, -1.18), + title="Crosshair cursor", + label=_label("Crosshair"), + style=_style("#fdd835", width=2, dash="dashed"), + z_index=6, + ), + sio.RangeAnnotation( + axis=sio.Axis.X, + start=2.0 * math.pi, + end=2.6 * math.pi, + title="X range", + label=_label("X range"), + style=_style("#7b1fa2", fill="#ab47bc", fill_opacity=0.22, width=2), + z_index=1, + ), + sio.RangeAnnotation( + axis=sio.Axis.Y, + start=-0.25, + end=0.25, + title="Y range", + label=_label("Y range"), + style=_style("#2e7d32", fill="#66bb6a", fill_opacity=0.18, width=2), + z_index=2, + ), + ] + signal.set_graphical_annotations(annotations) + + assert {annotation.kind for annotation in annotations} == { + sio.AnnotationKind.TEXT, + sio.AnnotationKind.CURSOR, + sio.AnnotationKind.RANGE, + } + with guiutils.lazy_qt_app_context(force=True): + from sigima import viz # pylint: disable=import-outside-toplevel + + viz.view_curves( + signal, + title="Visual check: text, cursors and ranges", + show_annotations=True, + ) + + +@pytest.mark.gui +def test_annotation_styles_and_states_interactive() -> None: + """Visually inspect markers, strokes, fills, layers and locked state.""" + marker_image = _create_image("Marker and stroke styles") + marker_names = ( + "circle", + "square", + "diamond", + "cross", + "x", + "triangle-up", + "triangle-down", + ) + colors = ( + "#ffffff", + "#00e5ff", + "#ffea00", + "#ff4081", + "#76ff03", + "#e040fb", + "#ff9100", + ) + marker_annotations = [ + sio.PointAnnotation( + x=10 + index * 12, + y=77, + title=name, + label=_label(name, (0, 10)), + style=_style(color, marker=name, marker_size=12), + ) + for index, (name, color) in enumerate(zip(marker_names, colors)) + ] + marker_annotations.extend( + [ + sio.SegmentAnnotation( + x0=8, + y0=53, + x1=86, + y1=53, + title="Solid", + label=_label("solid"), + style=_style("#ffffff", width=4), + ), + sio.SegmentAnnotation( + x0=8, + y0=39, + x1=86, + y1=39, + title="Dashed", + label=_label("dashed"), + style=_style("#00e5ff", width=3, dash="dashed"), + ), + sio.SegmentAnnotation( + x0=8, + y0=25, + x1=86, + y1=25, + title="Custom dash", + label=_label("custom dash"), + style=_style("#ffea00", width=3, dash=(8, 3, 2, 3)), + ), + ] + ) + marker_image.set_graphical_annotations(marker_annotations) + + state_image = _create_image("Fill, layer and interaction states") + state_annotations = [ + sio.RectangleAnnotation( + x=35, + y=48, + width=44, + height=35, + angle=math.radians(-12), + title="Back layer", + label=_label("Back layer (z=1)"), + style=_style("#00bcd4", fill="#00bcd4", fill_opacity=0.28), + z_index=1, + ), + sio.CircleAnnotation( + cx=54, + cy=49, + radius=22, + title="Front layer", + label=_label("Front layer (z=3)"), + style=_style("#ff4081", fill="#ff4081", fill_opacity=0.35), + z_index=3, + ), + sio.PointAnnotation( + x=18, + y=82, + title="Movable point", + label=_label("Movable"), + style=_style("#76ff03", marker="diamond", marker_size=13), + locked=False, + z_index=5, + ), + sio.PointAnnotation( + x=78, + y=82, + title="Locked point", + label=_label("Locked"), + style=_style("#ffea00", marker="square", marker_size=13), + locked=True, + z_index=5, + ), + sio.PointAnnotation( + x=48, + y=12, + title="Hidden point", + label=_label("Must not be visible"), + style=_style("#ffffff", marker_size=18), + visible=False, + z_index=10, + ), + sio.TextAnnotation( + text="The hidden point and label must be absent", + x=0.5, + y=0.04, + coordinate_space=sio.CoordinateSpace.AXES, + anchor=sio.TextAnchor.BOTTOM, + style=_style("#ffffff"), + z_index=11, + ), + ] + state_image.set_graphical_annotations(state_annotations) + + assert len(marker_image.get_graphical_annotations()) == 10 + assert len(state_image.get_graphical_annotations()) == 6 + with guiutils.lazy_qt_app_context(force=True): + from sigima import viz # pylint: disable=import-outside-toplevel + + viz.view_images_side_by_side( + [marker_image, state_image], + share_axes=False, + title="Visual check: annotation styles and states", + show_annotations=True, + ) + + +if __name__ == "__main__": + guiutils.enable_gui() + test_geometric_annotations_interactive() + test_text_cursor_and_range_annotations_interactive() + test_annotation_styles_and_states_interactive() diff --git a/sigima/tests/viz/viz_api_unit_test.py b/sigima/tests/viz/viz_api_unit_test.py index 4e240966..40def3f9 100644 --- a/sigima/tests/viz/viz_api_unit_test.py +++ b/sigima/tests/viz/viz_api_unit_test.py @@ -75,6 +75,23 @@ def test_matplotlib_backend_has_all_plotpy_functions(): ) +def test_annotation_visibility_parameter_has_backend_parity() -> None: + """Check the public annotation visibility switch on both backends.""" + from sigima.viz import viz_mpl, viz_plotpy + + for function_name in ( + "view_curves", + "view_images", + "view_images_side_by_side", + "view_curves_and_images", + ): + for backend in (viz_mpl, viz_plotpy): + parameter = inspect.signature(getattr(backend, function_name)).parameters[ + "show_annotations" + ] + assert parameter.default is True + + def test_backend_selection_env_var(monkeypatch): """Test that SIGIMA_VIZ_BACKEND environment variable works.""" import importlib diff --git a/sigima/viz/__init__.py b/sigima/viz/__init__.py index d8856122..818e0b3a 100644 --- a/sigima/viz/__init__.py +++ b/sigima/viz/__init__.py @@ -68,6 +68,7 @@ def view_curves( maximized: bool = False, results: list[GeometryResult] | GeometryResult | None = None, show_roi: bool = True, + show_annotations: bool = True, object_name: str = "", **kwargs, ) -> None: @@ -80,6 +81,7 @@ def view_images( maximized: bool = False, results: list[GeometryResult] | GeometryResult | None = None, show_roi: bool = True, + show_annotations: bool = True, object_name: str = "", **kwargs, ) -> None: @@ -94,6 +96,7 @@ def view_images_side_by_side( title: str | None = None, results: list[GeometryResult] | GeometryResult | None = None, show_roi: bool = True, + show_annotations: bool = True, object_name: str = "", **kwargs, ) -> None: @@ -108,6 +111,7 @@ def view_curves_and_images( maximized: bool = False, results: list[GeometryResult] | GeometryResult | None = None, show_roi: bool = True, + show_annotations: bool = True, object_name: str = "", **kwargs, ) -> None: diff --git a/sigima/viz/annotation_mpl.py b/sigima/viz/annotation_mpl.py new file mode 100644 index 00000000..11d4152c --- /dev/null +++ b/sigima/viz/annotation_mpl.py @@ -0,0 +1,328 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Matplotlib renderer for canonical graphical annotations.""" + +from __future__ import annotations + +import math +from typing import Any + +from matplotlib import patches, transforms +from matplotlib.colors import to_rgba + +from sigima.objects.annotations import ( + Axis, + CircleAnnotation, + CursorAnnotation, + CursorOrientation, + EllipseAnnotation, + GraphicalAnnotation, + PointAnnotation, + PolygonAnnotation, + PolylineAnnotation, + RangeAnnotation, + RectangleAnnotation, + SegmentAnnotation, + TextAnchor, + TextAnnotation, +) + +_ANCHOR_ALIGNMENT = { + TextAnchor.TOP_LEFT: ("left", "top"), + TextAnchor.TOP: ("center", "top"), + TextAnchor.TOP_RIGHT: ("right", "top"), + TextAnchor.LEFT: ("left", "center"), + TextAnchor.CENTER: ("center", "center"), + TextAnchor.RIGHT: ("right", "center"), + TextAnchor.BOTTOM_LEFT: ("left", "bottom"), + TextAnchor.BOTTOM: ("center", "bottom"), + TextAnchor.BOTTOM_RIGHT: ("right", "bottom"), +} + +_MARKERS = { + "circle": "o", + "square": "s", + "diamond": "D", + "cross": "+", + "x": "x", + "triangle-up": "^", + "triangle-down": "v", + "none": "", +} + + +def _line_kwargs(annotation: GraphicalAnnotation) -> dict[str, Any]: + """Return Matplotlib line keyword arguments for an annotation.""" + stroke = annotation.style.stroke + return { + "color": stroke.color or "none", + "linewidth": stroke.width, + "alpha": stroke.opacity, + "linestyle": stroke.dash if isinstance(stroke.dash, str) else "-", + "zorder": annotation.z_index, + } + + +def _apply_custom_dash(line, annotation: GraphicalAnnotation) -> None: + """Apply a custom dash sequence to a line artist.""" + dash = annotation.style.stroke.dash + if isinstance(dash, tuple): + line.set_dashes(dash) + + +def _patch_kwargs(annotation: GraphicalAnnotation) -> dict[str, Any]: + """Return Matplotlib patch keyword arguments for an annotation.""" + stroke = annotation.style.stroke + fill = annotation.style.fill + edgecolor = ( + to_rgba(stroke.color, stroke.opacity) if stroke.color is not None else "none" + ) + facecolor = to_rgba(fill.color, fill.opacity) if fill.color is not None else "none" + linestyle = stroke.dash if isinstance(stroke.dash, str) else "-" + return { + "edgecolor": edgecolor, + "facecolor": facecolor, + "linewidth": stroke.width, + "linestyle": linestyle, + "zorder": annotation.z_index, + } + + +def _text_kwargs(annotation: GraphicalAnnotation) -> dict[str, Any]: + """Return Matplotlib text keyword arguments for an annotation.""" + style = annotation.style.text + kwargs = { + "fontsize": style.size, + "fontweight": "bold" if style.bold else "normal", + "fontstyle": "italic" if style.italic else "normal", + "color": style.color, + "zorder": annotation.z_index, + } + if style.family is not None: + kwargs["fontfamily"] = style.family + if style.background_color is not None: + kwargs["bbox"] = { + "facecolor": to_rgba(style.background_color, style.background_opacity), + "edgecolor": "none", + } + return kwargs + + +def _offset_transform(ax, base_transform, offset: tuple[float, float]): + """Return a transform shifted by an offset expressed in display points.""" + return transforms.offset_copy( + base_transform, + fig=ax.figure, + x=offset[0], + y=offset[1], + units="points", + ) + + +def _add_text( + ax, + annotation: GraphicalAnnotation, + text: str, + x: float, + y: float, + anchor: TextAnchor, + offset: tuple[float, float], + transform, +): + """Add styled annotation text to axes.""" + horizontal, vertical = _ANCHOR_ALIGNMENT[anchor] + return ax.text( + x, + y, + text, + horizontalalignment=horizontal, + verticalalignment=vertical, + transform=_offset_transform(ax, transform, offset), + **_text_kwargs(annotation), + ) + + +def _label_location(ax, annotation: GraphicalAnnotation): + """Return a label anchor position and transform for an annotation.""" + if isinstance(annotation, PointAnnotation): + location = (annotation.x, annotation.y, ax.transData) + elif isinstance(annotation, SegmentAnnotation): + location = ( + (annotation.x0 + annotation.x1) / 2, + (annotation.y0 + annotation.y1) / 2, + ax.transData, + ) + elif isinstance(annotation, RectangleAnnotation): + location = (annotation.x, annotation.y, ax.transData) + elif isinstance(annotation, (CircleAnnotation, EllipseAnnotation)): + location = (annotation.cx, annotation.cy, ax.transData) + elif isinstance(annotation, (PolylineAnnotation, PolygonAnnotation)): + x = sum(point[0] for point in annotation.points) / len(annotation.points) + y = sum(point[1] for point in annotation.points) / len(annotation.points) + location = (x, y, ax.transData) + elif isinstance(annotation, CursorAnnotation): + if annotation.orientation == CursorOrientation.CROSSHAIR: + assert isinstance(annotation.position, tuple) + location = (*annotation.position, ax.transData) + else: + assert isinstance(annotation.position, float) + if annotation.orientation == CursorOrientation.VERTICAL: + location = (annotation.position, 1.0, ax.get_xaxis_transform()) + else: + location = (1.0, annotation.position, ax.get_yaxis_transform()) + elif isinstance(annotation, RangeAnnotation): + center = (annotation.start + annotation.end) / 2 + if annotation.axis == Axis.X: + location = (center, 1.0, ax.get_xaxis_transform()) + else: + location = (1.0, center, ax.get_yaxis_transform()) + else: + location = None + return location + + +def _add_label(ax, annotation: GraphicalAnnotation): + """Add an attached annotation label when visible.""" + label = annotation.label + if label is None or not label.visible or not label.text: + return None + location = _label_location(ax, annotation) + if location is None: + return None + x, y, transform = location + return _add_text( + ax, + annotation, + label.text, + x, + y, + label.anchor, + label.offset, + transform, + ) + + +def add_annotation_to_axes(ax, annotation: GraphicalAnnotation) -> list[Any]: + """Add one canonical graphical annotation to Matplotlib axes.""" + if not annotation.visible: + return [] + artists = [] + line_kwargs = _line_kwargs(annotation) + if isinstance(annotation, PointAnnotation): + marker = _MARKERS.get( + annotation.style.marker.symbol, annotation.style.marker.symbol + ) + (line,) = ax.plot( + [annotation.x], + [annotation.y], + marker=marker, + markersize=annotation.style.marker.size, + markerfacecolor=annotation.style.marker.color or line_kwargs["color"], + markeredgecolor=line_kwargs["color"], + linestyle="", + alpha=annotation.style.stroke.opacity, + zorder=annotation.z_index, + ) + artists.append(line) + elif isinstance(annotation, SegmentAnnotation): + (line,) = ax.plot( + [annotation.x0, annotation.x1], + [annotation.y0, annotation.y1], + **line_kwargs, + ) + _apply_custom_dash(line, annotation) + artists.append(line) + elif isinstance(annotation, RectangleAnnotation): + patch = patches.Rectangle( + (annotation.x - annotation.width / 2, annotation.y - annotation.height / 2), + annotation.width, + annotation.height, + **_patch_kwargs(annotation), + ) + patch.set_transform( + transforms.Affine2D().rotate_around( + annotation.x, annotation.y, annotation.angle + ) + + ax.transData + ) + ax.add_patch(patch) + artists.append(patch) + elif isinstance(annotation, CircleAnnotation): + patch = patches.Circle( + (annotation.cx, annotation.cy), + annotation.radius, + **_patch_kwargs(annotation), + ) + ax.add_patch(patch) + artists.append(patch) + elif isinstance(annotation, EllipseAnnotation): + patch = patches.Ellipse( + (annotation.cx, annotation.cy), + 2 * annotation.radius_x, + 2 * annotation.radius_y, + angle=math.degrees(annotation.angle), + **_patch_kwargs(annotation), + ) + ax.add_patch(patch) + artists.append(patch) + elif isinstance(annotation, PolylineAnnotation): + x, y = zip(*annotation.points) + (line,) = ax.plot(x, y, **line_kwargs) + _apply_custom_dash(line, annotation) + artists.append(line) + elif isinstance(annotation, PolygonAnnotation): + patch = patches.Polygon( + annotation.points, closed=True, **_patch_kwargs(annotation) + ) + ax.add_patch(patch) + artists.append(patch) + elif isinstance(annotation, TextAnnotation): + transform = ( + ax.transData + if annotation.coordinate_space.value == "data" + else ax.transAxes + ) + artists.append( + _add_text( + ax, + annotation, + annotation.text, + annotation.x, + annotation.y, + annotation.anchor, + annotation.offset, + transform, + ) + ) + elif isinstance(annotation, CursorAnnotation): + if annotation.orientation == CursorOrientation.HORIZONTAL: + assert isinstance(annotation.position, float) + artists.append(ax.axhline(annotation.position, **line_kwargs)) + elif annotation.orientation == CursorOrientation.VERTICAL: + assert isinstance(annotation.position, float) + artists.append(ax.axvline(annotation.position, **line_kwargs)) + else: + assert isinstance(annotation.position, tuple) + artists.append(ax.axvline(annotation.position[0], **line_kwargs)) + artists.append(ax.axhline(annotation.position[1], **line_kwargs)) + elif isinstance(annotation, RangeAnnotation): + kwargs = _patch_kwargs(annotation) + if annotation.axis == Axis.X: + artists.append(ax.axvspan(annotation.start, annotation.end, **kwargs)) + else: + artists.append(ax.axhspan(annotation.start, annotation.end, **kwargs)) + else: # pragma: no cover - protected by the closed model hierarchy + raise TypeError(f"Unsupported annotation type: {type(annotation).__name__}") + + label = _add_label(ax, annotation) + if label is not None: + artists.append(label) + return artists + + +def add_annotations_to_axes(ax, annotations: list[GraphicalAnnotation]) -> list[Any]: + """Add canonical annotations to axes in deterministic layer order.""" + artists = [] + for annotation in sorted(annotations, key=lambda item: item.z_index): + artists.extend(add_annotation_to_axes(ax, annotation)) + return artists diff --git a/sigima/viz/annotation_plotpy.py b/sigima/viz/annotation_plotpy.py new file mode 100644 index 00000000..f4ea7f6e --- /dev/null +++ b/sigima/viz/annotation_plotpy.py @@ -0,0 +1,625 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""PlotPy adapter for canonical and historical graphical annotations.""" + +from __future__ import annotations + +import json +import math +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +import numpy as np +from guidata.io import JSONReader +from plotpy.builder import make +from plotpy.io import load_items +from plotpy.items import ( + AnnotatedCircle, + AnnotatedEllipse, + AnnotatedObliqueRectangle, + AnnotatedPoint, + AnnotatedPolygon, + AnnotatedRectangle, + AnnotatedSegment, + AnnotatedShape, + AnnotatedXRange, + AnnotatedYRange, + LabelItem, + Marker, +) +from qtpy import QtCore as QC +from qwt import QwtPlotMarker + +from sigima.objects.annotations import ( + AnnotationLabel, + AnnotationStyle, + Axis, + CircleAnnotation, + CursorAnnotation, + CursorOrientation, + EllipseAnnotation, + FillStyle, + GraphicalAnnotation, + MarkerStyle, + PointAnnotation, + PolygonAnnotation, + PolylineAnnotation, + RangeAnnotation, + RectangleAnnotation, + SegmentAnnotation, + StrokeStyle, + TextAnchor, + TextAnnotation, + TextStyle, + annotation_to_dict, + is_graphical_annotation_dict, +) + +if TYPE_CHECKING: + from sigima.objects.base import BaseObj + +_ANCHORS = { + TextAnchor.TOP_LEFT: "TL", + TextAnchor.TOP: "T", + TextAnchor.TOP_RIGHT: "TR", + TextAnchor.LEFT: "L", + TextAnchor.CENTER: "C", + TextAnchor.RIGHT: "R", + TextAnchor.BOTTOM_LEFT: "BL", + TextAnchor.BOTTOM: "B", + TextAnchor.BOTTOM_RIGHT: "BR", +} + +_LINE_STYLES = { + "solid": "SolidLine", + "dashed": "DashLine", + "dotted": "DotLine", + "dashdot": "DashDotLine", + "-": "SolidLine", + "--": "DashLine", + ":": "DotLine", + "-.": "DashDotLine", +} + +_MARKERS = { + "circle": "Ellipse", + "square": "Rect", + "diamond": "Diamond", + "cross": "Cross", + "x": "XCross", + "triangle-up": "UTriangle", + "triangle-down": "DTriangle", + "none": "NoSymbol", +} + +_PLOTPY_ANCHOR_POSITIONS = { + "TL": (0.0, 1.0), + "T": (0.5, 1.0), + "TR": (1.0, 1.0), + "L": (0.0, 0.5), + "C": (0.5, 0.5), + "R": (1.0, 0.5), + "BL": (0.0, 0.0), + "B": (0.5, 0.0), + "BR": (1.0, 0.0), +} + +_PLOTPY_TEXT_ANCHORS = {value: key for key, value in _ANCHORS.items()} + + +@dataclass(frozen=True) +class PlotPyMigrationReport: + """Result of an explicit historical PlotPy migration.""" + + converted_count: int + preserved_count: int + diagnostics: tuple[str, ...] + applied: bool + + +class AxesLabelItem(LabelItem): + """PlotPy label positioned in normalized axes coordinates.""" + + def __init__(self, text: str, position: tuple[float, float], labelparam) -> None: + super().__init__(text, labelparam) + self.axes_position = position + + def get_origin(self, xMap, yMap, canvasRect) -> tuple[float, float]: + """Return the normalized axes position in canvas coordinates.""" + x, y = self.axes_position + return ( + canvasRect.left() + x * canvasRect.width(), + canvasRect.bottom() - y * canvasRect.height(), + ) + + +def _shape_corners(annotation: RectangleAnnotation) -> np.ndarray: + """Return oriented rectangle corners in PlotPy order.""" + half_width = annotation.width / 2 + half_height = annotation.height / 2 + corners = np.array( + [ + [-half_width, -half_height], + [half_width, -half_height], + [half_width, half_height], + [-half_width, half_height], + ] + ) + cos_angle = math.cos(annotation.angle) + sin_angle = math.sin(annotation.angle) + rotation = np.array([[cos_angle, -sin_angle], [sin_angle, cos_angle]]) + return corners @ rotation.T + np.array([annotation.x, annotation.y]) + + +def _annotation_options(annotation: GraphicalAnnotation) -> dict[str, Any]: + """Return common PlotPy builder options for an annotated shape.""" + label = annotation.label + return { + "title": annotation.title, + "show_label": bool(label and label.visible and label.text), + "show_computations": bool(label and label.visible and label.text), + "show_subtitle": False, + "readonly": annotation.locked, + } + + +def _configure_shape(item: AnnotatedShape, annotation: GraphicalAnnotation) -> None: + """Apply common canonical state and style to a PlotPy shape item.""" + item.setVisible(annotation.visible) + item.setZ(annotation.z_index) + item.set_readonly(annotation.locked) + item.setTitle(annotation.title) + if annotation.label is not None and annotation.label.text: + text = annotation.label.text + item.set_info_callback(lambda _item, value=text: value) + item.set_label_visible(annotation.label.visible) + + shape_param = item.shape.shapeparam + stroke = annotation.style.stroke + fill = annotation.style.fill + marker = annotation.style.marker + shape_param.line.color = stroke.color or "#000000" + shape_param.line.width = stroke.width + shape_param.line.style = _LINE_STYLES.get( + stroke.dash if isinstance(stroke.dash, str) else "solid", "SolidLine" + ) + if isinstance(item, (AnnotatedXRange, AnnotatedYRange)): + shape_param.fill = fill.color or "#000000" + shape_param.shade = fill.opacity if fill.color is not None else 0.0 + else: + shape_param.fill.color = fill.color or "#000000" + shape_param.fill.alpha = fill.opacity if fill.color is not None else 0.0 + shape_param.fill.style = "SolidPattern" if fill.color is not None else "NoBrush" + shape_param.symbol.marker = _MARKERS.get(marker.symbol, marker.symbol) + shape_param.symbol.size = round(marker.size) + shape_param.symbol.edgecolor = marker.color or stroke.color or "#000000" + shape_param.symbol.facecolor = marker.color or stroke.color or "#000000" + shape_param.symbol.alpha = stroke.opacity + if hasattr(shape_param, "readonly"): + shape_param.readonly = annotation.locked + shape_param.update_item(item.shape) + + for pen in (item.shape.pen, item.shape.sel_pen): + color = pen.color() + color.setAlphaF(stroke.opacity) + pen.setColor(color) + if isinstance(stroke.dash, tuple): + pen.setStyle(QC.Qt.CustomDashLine) + pen.setDashPattern(list(stroke.dash)) + + +def _configure_label(item: LabelItem, annotation: TextAnnotation) -> None: + """Apply canonical state and text style to a PlotPy label item.""" + item.setVisible(annotation.visible) + item.setZ(annotation.z_index) + item.set_readonly(annotation.locked) + item.setTitle(annotation.title) + param = item.labelparam + style = annotation.style.text + param.font.family = style.family or param.font.family + param.font.size = round(style.size) + param.font.bold = style.bold + param.font.italic = style.italic + param.color = style.color + param.bgcolor = style.background_color or "#ffffff" + param.bgalpha = style.background_opacity + param.update_item(item) + + +def annotation_to_plotpy_item(annotation: GraphicalAnnotation): + """Convert one canonical annotation to a native PlotPy item.""" + options = _annotation_options(annotation) + item: Any + if isinstance(annotation, PointAnnotation): + item = make.annotated_point(annotation.x, annotation.y, **options) + elif isinstance(annotation, SegmentAnnotation): + item = make.annotated_segment( + annotation.x0, annotation.y0, annotation.x1, annotation.y1, **options + ) + elif isinstance(annotation, RectangleAnnotation): + corners = _shape_corners(annotation) + if math.isclose(annotation.angle, 0.0, abs_tol=1e-12): + item = make.annotated_rectangle( + corners[0, 0], + corners[0, 1], + corners[2, 0], + corners[2, 1], + **options, + ) + else: + item = AnnotatedObliqueRectangle(*corners.ravel()) + elif isinstance(annotation, CircleAnnotation): + item = make.annotated_circle( + annotation.cx - annotation.radius, + annotation.cy, + annotation.cx + annotation.radius, + annotation.cy, + **options, + ) + elif isinstance(annotation, EllipseAnnotation): + cos_angle = math.cos(annotation.angle) + sin_angle = math.sin(annotation.angle) + dx = annotation.radius_x * cos_angle + dy = annotation.radius_x * sin_angle + ex = -annotation.radius_y * sin_angle + ey = annotation.radius_y * cos_angle + item = make.annotated_ellipse( + annotation.cx - dx, + annotation.cy - dy, + annotation.cx + dx, + annotation.cy + dy, + annotation.cx - ex, + annotation.cy - ey, + annotation.cx + ex, + annotation.cy + ey, + **options, + ) + elif isinstance(annotation, (PolylineAnnotation, PolygonAnnotation)): + item = make.annotated_polygon(np.asarray(annotation.points), **options) + item.set_closed(isinstance(annotation, PolygonAnnotation)) + elif isinstance(annotation, TextAnnotation): + anchor = _ANCHORS[annotation.anchor] + offset = tuple(round(value) for value in annotation.offset) + if annotation.coordinate_space.value == "data": + item = make.label( + annotation.text, + (annotation.x, annotation.y), + offset, + anchor, + title=annotation.title, + ) + else: + template = make.label( + annotation.text, "TL", offset, anchor, title=annotation.title + ) + item = AxesLabelItem( + annotation.text, + (annotation.x, annotation.y), + template.labelparam, + ) + _configure_label(item, annotation) + return item + elif isinstance(annotation, CursorAnnotation): + if annotation.orientation == CursorOrientation.CROSSHAIR: + assert isinstance(annotation.position, tuple) + position = annotation.position + markerstyle = "+" + elif annotation.orientation == CursorOrientation.VERTICAL: + assert isinstance(annotation.position, float) + position = (annotation.position, 0.0) + markerstyle = "|" + else: + assert isinstance(annotation.position, float) + position = (0.0, annotation.position) + markerstyle = "-" + stroke = annotation.style.stroke + item = make.marker( + position=position, + markerstyle=markerstyle, + movable=not annotation.locked, + readonly=annotation.locked, + color=stroke.color, + linewidth=stroke.width, + ) + item.setVisible(annotation.visible) + item.setZ(annotation.z_index) + item.setTitle(annotation.title) + return item + elif isinstance(annotation, RangeAnnotation): + builder = ( + make.annotated_xrange + if annotation.axis == Axis.X + else make.annotated_yrange + ) + item = builder(annotation.start, annotation.end, **options) + else: # pragma: no cover - protected by the closed model hierarchy + raise TypeError(f"Unsupported annotation type: {type(annotation).__name__}") + _configure_shape(item, annotation) + return item + + +def annotations_to_plotpy_items( + annotations: list[GraphicalAnnotation], +) -> list[Any]: + """Convert canonical annotations to PlotPy items in layer order.""" + return [ + annotation_to_plotpy_item(annotation) + for annotation in sorted(annotations, key=lambda item: item.z_index) + ] + + +def _load_legacy_range(payload: dict[str, Any]): + """Load a historical range affected by PlotPy's deserialize ordering bug.""" + document = json.loads(payload["plotpy_json"]) + item_keys = document.get("plot_items") + if not isinstance(item_keys, list) or len(item_keys) != 1: + raise ValueError("Expected one PlotPy range item") + item_key = item_keys[0] + item_data = document[item_key] + if item_key.startswith("AnnotatedXRange"): + builder = make.annotated_xrange + elif item_key.startswith("AnnotatedYRange"): + builder = make.annotated_yrange + else: + raise ValueError("Payload is not an annotated PlotPy range") + annotation_param = item_data.get("annotationparam", {}) + item = builder( + item_data["min"], + item_data["max"], + title=annotation_param.get("title"), + show_label=annotation_param.get("show_label"), + show_computations=annotation_param.get("show_computations"), + show_subtitle=annotation_param.get("show_subtitle"), + readonly=annotation_param.get("readonly"), + private=annotation_param.get("private"), + ) + shape_data = item_data.get("shapeparam", {}) + shape_param = item.shape.shapeparam + for name in ("line", "sel_line"): + line_data = shape_data.get(name, {}) + line_param = getattr(shape_param, name) + line_param.style = line_data.get("style", line_param.style) + line_param.color = line_data.get("color", line_param.color) + line_param.width = line_data.get("width", line_param.width) + shape_param.fill = shape_data.get("fill", shape_param.fill) + shape_param.shade = shape_data.get("shade", shape_param.shade) + shape_param.update_item(item.shape) + item.setVisible(item_data.get("visible", True)) + return item + + +def _load_legacy_plotpy_payload(payload: dict[str, Any]) -> list[Any]: + """Load one historical payload, including known PlotPy range defects.""" + try: + return load_items(JSONReader(payload["plotpy_json"])) + except TypeError: + return [_load_legacy_range(payload)] + + +def load_legacy_plotpy_items(obj: BaseObj) -> list[Any]: + """Load historical PlotPy payloads without mutating the object.""" + items = [] + for payload in obj.get_annotations(): + if is_graphical_annotation_dict(payload) or "plotpy_json" not in payload: + continue + try: + items.extend(_load_legacy_plotpy_payload(payload)) + except (json.JSONDecodeError, KeyError, TypeError, ValueError): + continue + return items + + +def _legacy_title(item: AnnotatedShape) -> str: + """Return an item's persisted title as plain text.""" + return str(item.title().text()) + + +def _legacy_style(item: AnnotatedShape) -> AnnotationStyle: + """Convert the portable part of a PlotPy annotated-shape style.""" + pen = item.shape.pen + brush = item.shape.brush + symbol = item.shape.symbol + pen_color = pen.color() + brush_color = brush.color() + symbol_pen = symbol.pen() + symbol_brush = symbol.brush() + return AnnotationStyle( + stroke=StrokeStyle( + color=pen_color.name(), + width=pen.widthF(), + opacity=pen_color.alphaF(), + ), + fill=FillStyle( + color=brush_color.name() if brush_color.alphaF() > 0 else None, + opacity=brush_color.alphaF(), + ), + marker=MarkerStyle( + symbol="circle", + size=symbol.size().width(), + color=( + symbol_brush.color().name() + if symbol_brush.color().alphaF() > 0 + else symbol_pen.color().name() + ), + ), + ) + + +def _legacy_common(item: Any) -> dict[str, Any]: + """Return canonical fields shared by migrated PlotPy items.""" + common = { + "title": str(item.title().text()), + "visible": item.isVisible(), + "locked": item.is_readonly(), + "z_index": round(item.z()), + "extensions": {"plotpy": {"item_class": type(item).__name__}}, + } + if isinstance(item, AnnotatedShape): + common["style"] = _legacy_style(item) + title = str(item.title().text()) + if title: + common["label"] = AnnotationLabel( + text=title, visible=item.is_label_visible() + ) + return common + + +def plotpy_item_to_annotation(item: Any) -> GraphicalAnnotation | None: + """Convert a known historical PlotPy item to a canonical annotation.""" + common = _legacy_common(item) + annotation = None + if isinstance(item, AnnotatedPoint): + x, y = item.get_pos() + annotation = PointAnnotation(x=x, y=y, **common) + elif isinstance(item, AnnotatedSegment): + x0, y0, x1, y1 = item.get_rect() + annotation = SegmentAnnotation(x0=x0, y0=y0, x1=x1, y1=y1, **common) + elif isinstance(item, AnnotatedObliqueRectangle): + points = np.asarray(item.shape.points) + center = points.mean(axis=0) + edge_x = points[1] - points[0] + edge_y = points[3] - points[0] + annotation = RectangleAnnotation( + x=center[0], + y=center[1], + width=np.linalg.norm(edge_x), + height=np.linalg.norm(edge_y), + angle=math.atan2(edge_x[1], edge_x[0]), + **common, + ) + elif isinstance(item, AnnotatedRectangle): + x0, y0, x1, y1 = item.get_rect() + annotation = RectangleAnnotation( + x=(x0 + x1) / 2, + y=(y0 + y1) / 2, + width=abs(x1 - x0), + height=abs(y1 - y0), + **common, + ) + elif isinstance(item, AnnotatedCircle): + x0, y0, x1, y1 = item.get_xdiameter() + annotation = CircleAnnotation( + cx=(x0 + x1) / 2, + cy=(y0 + y1) / 2, + radius=math.hypot(x1 - x0, y1 - y0) / 2, + **common, + ) + elif isinstance(item, AnnotatedEllipse): + x0, y0, x1, y1 = item.get_xdiameter() + x2, y2, x3, y3 = item.get_ydiameter() + annotation = EllipseAnnotation( + cx=(x0 + x1) / 2, + cy=(y0 + y1) / 2, + radius_x=math.hypot(x1 - x0, y1 - y0) / 2, + radius_y=math.hypot(x3 - x2, y3 - y2) / 2, + angle=math.atan2(y1 - y0, x1 - x0), + **common, + ) + elif isinstance(item, AnnotatedPolygon): + points = tuple(map(tuple, item.get_points())) + annotation_class = PolygonAnnotation if item.is_closed() else PolylineAnnotation + annotation = annotation_class(points=points, **common) + elif isinstance(item, (AnnotatedXRange, AnnotatedYRange)): + start, end = item.get_range() + axis = Axis.X if isinstance(item, AnnotatedXRange) else Axis.Y + annotation = RangeAnnotation(axis=axis, start=start, end=end, **common) + elif isinstance(item, LabelItem): + if item.G in _PLOTPY_ANCHOR_POSITIONS: + x, y = _PLOTPY_ANCHOR_POSITIONS[item.G] + coordinate_space = "axes" + elif isinstance(item.G, tuple): + x, y = item.G + coordinate_space = "data" + else: + return None + param = item.labelparam + style = AnnotationStyle( + text=TextStyle( + family=param.font.family, + size=param.font.size, + bold=param.font.bold, + italic=param.font.italic, + color=param.color, + background_color=param.bgcolor, + background_opacity=param.bgalpha, + ) + ) + annotation = TextAnnotation( + text=item.get_plain_text(), + x=x, + y=y, + coordinate_space=coordinate_space, + anchor=_PLOTPY_TEXT_ANCHORS.get(item.anchor, TextAnchor.TOP_LEFT), + offset=tuple(item.C), + style=style, + **common, + ) + elif isinstance(item, Marker): + line_style = item.lineStyle() + if line_style == QwtPlotMarker.VLine: + orientation = CursorOrientation.VERTICAL + position = item.xValue() + elif line_style == QwtPlotMarker.HLine: + orientation = CursorOrientation.HORIZONTAL + position = item.yValue() + elif line_style == QwtPlotMarker.Cross: + orientation = CursorOrientation.CROSSHAIR + position = (item.xValue(), item.yValue()) + else: + return PointAnnotation(x=item.xValue(), y=item.yValue(), **common) + annotation = CursorAnnotation( + orientation=orientation, position=position, **common + ) + return annotation + + +def migrate_legacy_plotpy_annotations( + obj: BaseObj, *, dry_run: bool = False +) -> PlotPyMigrationReport: + """Replace fully recognized historical PlotPy payloads with canonical data. + + Unknown, malformed, or only partially supported payloads remain byte-for-byte + represented by their original dictionary. + + Args: + obj: Object containing historical PlotPy payloads. + dry_run: If True, inspect migration without modifying the object. + + Returns: + Structured migration report. + """ + migrated_count = 0 + preserved_count = 0 + diagnostics = [] + output = [] + for payload in obj.get_annotations(): + if is_graphical_annotation_dict(payload) or "plotpy_json" not in payload: + output.append(payload) + continue + try: + items = _load_legacy_plotpy_payload(payload) + except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc: + output.append(payload) + preserved_count += 1 + diagnostics.append( + f"Malformed PlotPy payload preserved: {type(exc).__name__}: {exc}" + ) + continue + converted = [plotpy_item_to_annotation(item) for item in items] + if not items or any(annotation is None for annotation in converted): + output.append(payload) + preserved_count += 1 + class_names = ", ".join(type(item).__name__ for item in items) or "empty" + diagnostics.append(f"Unsupported PlotPy payload preserved: {class_names}") + continue + output.extend(annotation_to_dict(annotation) for annotation in converted) + migrated_count += len(converted) + applied = bool(migrated_count and not dry_run) + if applied: + obj.set_annotations(output) + return PlotPyMigrationReport( + converted_count=migrated_count, + preserved_count=preserved_count, + diagnostics=tuple(diagnostics), + applied=applied, + ) diff --git a/sigima/viz/viz_mpl.py b/sigima/viz/viz_mpl.py index d2506713..30ad537f 100644 --- a/sigima/viz/viz_mpl.py +++ b/sigima/viz/viz_mpl.py @@ -34,6 +34,7 @@ SegmentROI, SignalObj, ) +from sigima.viz.annotation_mpl import add_annotations_to_axes # Style configuration COLORS = ["blue", "red", "green", "orange", "purple", "brown", "pink", "gray", "olive"] @@ -143,6 +144,7 @@ def view_curves( xunit: str | None = None, yunit: str | None = None, show_roi: bool = True, + show_annotations: bool = True, object_name: str = "", # Qt-specific # pylint: disable=unused-argument ) -> None: """Create a matplotlib figure and plot curves. @@ -159,6 +161,7 @@ def view_curves( yunit: Unit for the y-axis, or None for no unit show_roi: Whether to show ROIs defined in `SignalObj` instances, default is True (ignored if `data_or_objs` is not a `SignalObj`) + show_annotations: Whether to show canonical annotations, default is True object_name: Object name for screenshot functionality (unused in matplotlib - kept for API compatibility with PlotPy version) """ @@ -244,6 +247,8 @@ def view_curves( color=roi_color, label=roi_label, ) + if show_annotations: + add_annotations_to_axes(ax, obj.get_graphical_annotations()) elif isinstance(data_or_obj, tuple) and len(data_or_obj) == 2: # Tuple of (x, y) arrays @@ -288,6 +293,7 @@ def view_images( zunit: str | None = None, results: list[GeometryResult] | GeometryResult | None = None, show_roi: bool = True, + show_annotations: bool = True, object_name: str = "", # Qt-specific # pylint: disable=unused-argument **kwargs, ) -> None: @@ -308,6 +314,7 @@ def view_images( if no overlay is needed. show_roi: Whether to show ROIs defined in `ImageObj` instances, default is True (ignored if `data_or_objs` is not a `ImageObj`) + show_annotations: Whether to show canonical annotations, default is True object_name: Object name for screenshot functionality (unused in matplotlib - kept for API compatibility with PlotPy version) **kwargs: Additional keyword arguments (e.g., colormap settings) @@ -414,6 +421,8 @@ def view_images( if show_roi and isinstance(data_or_obj, ImageObj) and data_or_obj.roi: for single_roi in data_or_obj.roi.single_rois: _add_single_roi_to_axes(ax, single_roi) + if show_annotations and isinstance(data_or_obj, ImageObj): + add_annotations_to_axes(ax, data_or_obj.get_graphical_annotations()) # Overlay geometry results if results is not None: @@ -564,6 +573,7 @@ def view_images_side_by_side( title: str | None = None, results: list[GeometryResult] | GeometryResult | None = None, show_roi: bool = True, + show_annotations: bool = True, object_name: str = "", # Qt-specific # pylint: disable=unused-argument **kwargs, ) -> None: @@ -580,6 +590,7 @@ def view_images_side_by_side( results: Single `GeometryResult` or list of these to overlay on images, or None if no overlay is needed. show_roi: Whether to show ROIs defined in `ImageObj` instances, default is True + show_annotations: Whether to show canonical annotations, default is True object_name: Object name for screenshot functionality (unused in matplotlib - kept for API compatibility with PlotPy version) **kwargs: Additional keyword arguments (e.g., colormap settings) @@ -677,6 +688,8 @@ def view_images_side_by_side( if show_roi and is_image_obj and img.roi: for roi in img.roi: _add_single_roi_to_axes(ax, roi) + if show_annotations and is_image_obj: + add_annotations_to_axes(ax, img.get_graphical_annotations()) # Overlay geometry results if result is not None: @@ -701,6 +714,7 @@ def view_curves_and_images( yunit: str | None = None, zunit: str | None = None, object_name: str = "", # Qt-specific: unused in matplotlib implementation + show_annotations: bool = True, ) -> None: """View signals, then images in two successive matplotlib figures. @@ -717,6 +731,7 @@ def view_curves_and_images( zunit: Unit for the z-axis (color scale), or None for no unit object_name: Object name for screenshot functionality (unused in matplotlib - kept for API compatibility with PlotPy version) + show_annotations: Whether to show canonical annotations, default is True """ if isinstance(data_or_objs, (tuple, list)): objs = data_or_objs @@ -750,6 +765,7 @@ def view_curves_and_images( xunit=xunit, yunit=yunit, object_name=f"{object_name}_curves", + show_annotations=show_annotations, ) # Display images @@ -765,6 +781,7 @@ def view_curves_and_images( yunit=yunit, zunit=zunit, object_name=f"{object_name}_images", + show_annotations=show_annotations, ) diff --git a/sigima/viz/viz_plotpy.py b/sigima/viz/viz_plotpy.py index 63bc2b26..d0a61a68 100644 --- a/sigima/viz/viz_plotpy.py +++ b/sigima/viz/viz_plotpy.py @@ -63,6 +63,10 @@ SignalObj, ) from sigima.tools import coordinates +from sigima.viz.annotation_plotpy import ( + annotations_to_plotpy_items, + load_legacy_plotpy_items, +) # Optional imports for test environment integration @@ -998,6 +1002,7 @@ def view_curves( xunit: str | None = None, yunit: str | None = None, show_roi: bool = True, + show_annotations: bool = True, object_name: str = "", ) -> None: """Create a curve dialog and plot curves @@ -1013,6 +1018,8 @@ def view_curves( yunit: Unit for the y-axis, or None for no unit show_roi: Whether to show ROIs defined in `SignalObj` instances, default is True (ignored if `data_or_objs` is not a `SignalObj`) + show_annotations: Whether to show canonical and historical annotations, + default is True object_name: Object name for the dialog (for screenshot functionality) """ __ensure_qapp() @@ -1040,6 +1047,11 @@ def view_curves( if isinstance(data_or_obj, SignalObj) and show_roi: items.extend(__create_curve_roi_items(data_or_obj)) items.append(item) + if isinstance(data_or_obj, SignalObj) and show_annotations: + items.extend( + annotations_to_plotpy_items(data_or_obj.get_graphical_annotations()) + ) + items.extend(load_legacy_plotpy_items(data_or_obj)) view_curve_items( items, name=name, @@ -1179,6 +1191,7 @@ def view_images( zunit: str | None = None, results: list[GeometryResult] | GeometryResult | None = None, show_roi: bool = True, + show_annotations: bool = True, object_name: str = "", **kwargs, ) -> None: @@ -1198,6 +1211,8 @@ def view_images( if no overlay is needed. show_roi: Whether to show ROIs defined in `ImageObj` instances, default is True (ignored if `data_or_objs` is not a `ImageObj`) + show_annotations: Whether to show canonical and historical annotations, + default is True object_name: Object name for the dialog (for screenshot functionality) **kwargs: Additional keyword arguments to pass to `make.maskedimage()` """ @@ -1244,6 +1259,11 @@ def view_images( ) if isinstance(data_or_obj, ImageObj) and show_roi: items.extend(__create_image_roi_items(data_or_obj)) + if isinstance(data_or_obj, ImageObj) and show_annotations: + items.extend( + annotations_to_plotpy_items(data_or_obj.get_graphical_annotations()) + ) + items.extend(load_legacy_plotpy_items(data_or_obj)) if results is not None: if isinstance(results, GeometryResult): results = [results] @@ -1276,6 +1296,7 @@ def view_curves_and_images( yunit: str | None = None, zunit: str | None = None, object_name: str = "", + show_annotations: bool = True, ) -> None: """View signals, then images in two successive dialogs @@ -1290,6 +1311,8 @@ def view_curves_and_images( yunit: Unit for the y-axis, or None for no unit zunit: Unit for the z-axis (color scale), or None for no unit object_name: Object name for the dialog (for screenshot functionality) + show_annotations: Whether to show canonical and historical annotations, + default is True """ __ensure_qapp() if isinstance(data_or_objs, (tuple, list)): @@ -1307,6 +1330,7 @@ def view_curves_and_images( xunit=xunit, yunit=yunit, object_name=f"{object_name}_curves", + show_annotations=show_annotations, ) ima_objs = [obj for obj in objs if isinstance(obj, (ImageObj, np.ndarray))] if ima_objs: @@ -1321,6 +1345,7 @@ def view_curves_and_images( yunit=yunit, zunit=zunit, object_name=f"{object_name}_images", + show_annotations=show_annotations, ) @@ -1355,6 +1380,7 @@ def view_images_side_by_side( title: str | None = None, results: list[GeometryResult] | GeometryResult | None = None, show_roi: bool = True, + show_annotations: bool = True, object_name: str = "", **kwargs, ) -> None: @@ -1371,6 +1397,8 @@ def view_images_side_by_side( if no overlay is needed. show_roi: Whether to show ROIs defined in `ImageObj` instances, default is True (ignored if `images` do not contain `ImageObj` instances) + show_annotations: Whether to show canonical and historical annotations, + default is True object_name: Object name for the dialog widget (used for screenshot filename) **kwargs: Additional keyword arguments to pass to `make.maskedimage()` """ @@ -1403,6 +1431,11 @@ def view_images_side_by_side( item = __create_image_item(img, title=imtitle, **imparameters) if isinstance(img, ImageObj) and show_roi: other_items.extend(__create_image_roi_items(img)) + if isinstance(img, ImageObj) and show_annotations: + other_items.extend( + annotations_to_plotpy_items(img.get_graphical_annotations()) + ) + other_items.extend(load_legacy_plotpy_items(img)) plot.add_item(item) for other_item in other_items: plot.add_item(other_item) From 3b400a6f4295df4cf845180abb86e480436cf0c6 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:02:58 +0200 Subject: [PATCH 2/2] fix(ci): restore annotation and stacked PR checks Install declared test dependencies in the build matrix and keep optional backend tests importable without their renderers. Run CI for stacked PRs. --- .github/workflows/test.yml | 16 ++--- sigima/tests/viz/annotation_mpl_unit_test.py | 28 ++++++-- .../tests/viz/annotation_plotpy_unit_test.py | 67 +++++++++++++------ sigima/tests/viz/viz_api_unit_test.py | 23 ++++--- 4 files changed, 93 insertions(+), 41 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c8ba9d25..2cad0c26 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,8 +8,8 @@ name: Install and Test on Ubuntu (latest) on: push: branches: [ "main", "develop", "release" ] + # Validate stacked PRs whose base is another feature branch. pull_request: - branches: [ "main", "develop", "release" ] workflow_call: workflow_dispatch: inputs: @@ -51,7 +51,7 @@ jobs: sudo apt install libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 x11-utils /sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -screen 0 1920x1200x24 -ac +extension GLX python -m pip install --upgrade pip - python -m pip install ruff pytest + python -m pip install ruff if [ "${{ github.ref_name }}" = "develop" ]; then pip uninstall -y guidata cd .. @@ -63,8 +63,8 @@ jobs: # 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'])]; open('deps.txt','w').write('\n'.join(deps))" pip install -r deps.txt - # Install Sigima without dependencies - pip install --no-deps . + # Install Sigima and its test dependencies, keeping local guidata + pip install ".[test]" elif [ "${{ github.ref_name }}" = "release" ]; then pip uninstall -y guidata cd .. @@ -77,11 +77,11 @@ jobs: # 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'])]; open('deps.txt','w').write('\n'.join(deps))" pip install -r deps.txt - # Install Sigima without dependencies - pip install --no-deps . + # Install Sigima and its test dependencies, keeping local guidata + pip install ".[test]" else - # Install from PyPI normally for main branch - pip install . + # Install Sigima and all dependencies needed by the test suite + pip install ".[test]" fi - name: Lint with Ruff run: ruff check --output-format=github sigima diff --git a/sigima/tests/viz/annotation_mpl_unit_test.py b/sigima/tests/viz/annotation_mpl_unit_test.py index da9658f4..b53a4c4d 100644 --- a/sigima/tests/viz/annotation_mpl_unit_test.py +++ b/sigima/tests/viz/annotation_mpl_unit_test.py @@ -2,12 +2,12 @@ """Unit tests for canonical annotation rendering with Matplotlib.""" +# pylint: disable=import-outside-toplevel + +import importlib.util from collections import Counter -import matplotlib.pyplot as plt -from matplotlib.lines import Line2D -from matplotlib.patches import Circle, Ellipse, Polygon, Rectangle -from matplotlib.text import Text +import pytest from sigima.objects import ( Axis, @@ -23,11 +23,21 @@ SegmentAnnotation, TextAnnotation, ) -from sigima.viz.annotation_mpl import add_annotations_to_axes + +pytestmark = pytest.mark.skipif( + importlib.util.find_spec("matplotlib") is None, reason="Matplotlib not installed" +) def test_all_annotation_primitives_create_expected_artists() -> None: """Check structural rendering of every canonical primitive.""" + import matplotlib.pyplot as plt + from matplotlib.lines import Line2D + from matplotlib.patches import Circle, Ellipse, Polygon, Rectangle + from matplotlib.text import Text + + from sigima.viz.annotation_mpl import add_annotations_to_axes + figure, axes = plt.subplots() annotations = [ PointAnnotation(x=1, y=2, z_index=1), @@ -62,6 +72,10 @@ def test_all_annotation_primitives_create_expected_artists() -> None: def test_axes_text_uses_normalized_transform() -> None: """Check that overlay text uses the normalized axes coordinate system.""" + import matplotlib.pyplot as plt + + from sigima.viz.annotation_mpl import add_annotations_to_axes + figure, axes = plt.subplots() annotation = TextAnnotation(text="Overlay", x=0.1, y=0.9, coordinate_space="axes") @@ -74,6 +88,10 @@ def test_axes_text_uses_normalized_transform() -> None: def test_hidden_annotation_creates_no_artist() -> None: """Check annotation visibility at the renderer boundary.""" + import matplotlib.pyplot as plt + + from sigima.viz.annotation_mpl import add_annotations_to_axes + figure, axes = plt.subplots() artists = add_annotations_to_axes(axes, [PointAnnotation(visible=False)]) diff --git a/sigima/tests/viz/annotation_plotpy_unit_test.py b/sigima/tests/viz/annotation_plotpy_unit_test.py index 9904dd96..1d01f746 100644 --- a/sigima/tests/viz/annotation_plotpy_unit_test.py +++ b/sigima/tests/viz/annotation_plotpy_unit_test.py @@ -2,23 +2,13 @@ """Unit tests for canonical annotation integration with PlotPy.""" +# pylint: disable=import-outside-toplevel + +import importlib.util import math import numpy as np -from guidata.io import JSONWriter -from plotpy.builder import make -from plotpy.io import save_items -from plotpy.items import ( - AnnotatedCircle, - AnnotatedEllipse, - AnnotatedObliqueRectangle, - AnnotatedPoint, - AnnotatedPolygon, - AnnotatedSegment, - AnnotatedXRange, - LabelItem, - Marker, -) +import pytest import sigima.objects from sigima.objects import ( @@ -37,16 +27,31 @@ SegmentAnnotation, TextAnnotation, ) -from sigima.viz.annotation_plotpy import ( - AxesLabelItem, - annotations_to_plotpy_items, - load_legacy_plotpy_items, - migrate_legacy_plotpy_annotations, + +pytestmark = pytest.mark.skipif( + importlib.util.find_spec("plotpy") is None, reason="PlotPy not installed" ) def test_all_annotation_primitives_create_native_items() -> None: """Check conversion of every canonical primitive to a PlotPy item.""" + from plotpy.items import ( + AnnotatedCircle, + AnnotatedEllipse, + AnnotatedObliqueRectangle, + AnnotatedPoint, + AnnotatedPolygon, + AnnotatedSegment, + AnnotatedXRange, + LabelItem, + Marker, + ) + + from sigima.viz.annotation_plotpy import ( + AxesLabelItem, + annotations_to_plotpy_items, + ) + annotations = [ PointAnnotation(x=1, y=2), SegmentAnnotation(x0=0, y0=0, x1=1, y1=1), @@ -82,6 +87,8 @@ def test_all_annotation_primitives_create_native_items() -> None: def test_all_canonical_marker_symbols_create_valid_plotpy_markers() -> None: """Check that every portable marker name maps to a valid PlotPy symbol.""" + from sigima.viz.annotation_plotpy import annotations_to_plotpy_items + expected_markers = { "circle": "Ellipse", "square": "Rect", @@ -104,6 +111,16 @@ def test_all_canonical_marker_symbols_create_valid_plotpy_markers() -> None: def test_legacy_plotpy_payload_load_and_migration() -> None: """Check explicit migration of a known historical PlotPy payload.""" + from guidata.io import JSONWriter + from plotpy.builder import make + from plotpy.io import save_items + from plotpy.items import AnnotatedPoint + + from sigima.viz.annotation_plotpy import ( + load_legacy_plotpy_items, + migrate_legacy_plotpy_annotations, + ) + source_item = make.annotated_point(3.0, 4.0, title="Legacy point") writer = JSONWriter(None) save_items(writer, [source_item]) @@ -136,6 +153,12 @@ def test_legacy_plotpy_payload_load_and_migration() -> None: def test_all_known_legacy_plotpy_types_are_migrated() -> None: """Check migration coverage for the historical DataLab PlotPy surface.""" + from guidata.io import JSONWriter + from plotpy.builder import make + from plotpy.io import save_items + + from sigima.viz.annotation_plotpy import migrate_legacy_plotpy_annotations + items = [ make.annotated_point(1, 2), make.annotated_segment(0, 0, 1, 1), @@ -183,6 +206,12 @@ def test_all_known_legacy_plotpy_types_are_migrated() -> None: def test_unknown_legacy_item_is_preserved() -> None: """Check that migration leaves unsupported PlotPy items untouched.""" + from guidata.io import JSONWriter + from plotpy.builder import make + from plotpy.io import save_items + + from sigima.viz.annotation_plotpy import migrate_legacy_plotpy_annotations + writer = JSONWriter(None) save_items(writer, [make.curve([0, 1], [1, 2])]) payload = {"type": "plotpy_item", "plotpy_json": writer.get_json()} diff --git a/sigima/tests/viz/viz_api_unit_test.py b/sigima/tests/viz/viz_api_unit_test.py index 40def3f9..2e0e5879 100644 --- a/sigima/tests/viz/viz_api_unit_test.py +++ b/sigima/tests/viz/viz_api_unit_test.py @@ -8,6 +8,8 @@ from __future__ import annotations +import importlib +import importlib.util import inspect import sys @@ -44,8 +46,9 @@ def get_public_functions(module) -> set[str]: @pytest.mark.skipif( - "matplotlib" not in sys.modules and not _has_matplotlib(), - reason="matplotlib not available", + ("matplotlib" not in sys.modules and not _has_matplotlib()) + or importlib.util.find_spec("plotpy") is None, + reason="Matplotlib or PlotPy not available", ) def test_matplotlib_backend_has_all_plotpy_functions(): """Test that matplotlib backend implements stubs for all PlotPy functions. @@ -76,8 +79,14 @@ def test_matplotlib_backend_has_all_plotpy_functions(): def test_annotation_visibility_parameter_has_backend_parity() -> None: - """Check the public annotation visibility switch on both backends.""" - from sigima.viz import viz_mpl, viz_plotpy + """Check the public annotation visibility switch on available backends.""" + backends = [] + if importlib.util.find_spec("matplotlib") is not None: + backends.append(importlib.import_module("sigima.viz.viz_mpl")) + if importlib.util.find_spec("plotpy") is not None: + backends.append(importlib.import_module("sigima.viz.viz_plotpy")) + if not backends: + pytest.skip("No visualization backend available") for function_name in ( "view_curves", @@ -85,7 +94,7 @@ def test_annotation_visibility_parameter_has_backend_parity() -> None: "view_images_side_by_side", "view_curves_and_images", ): - for backend in (viz_mpl, viz_plotpy): + for backend in backends: parameter = inspect.signature(getattr(backend, function_name)).parameters[ "show_annotations" ] @@ -94,8 +103,6 @@ def test_annotation_visibility_parameter_has_backend_parity() -> None: def test_backend_selection_env_var(monkeypatch): """Test that SIGIMA_VIZ_BACKEND environment variable works.""" - import importlib - # Check if matplotlib is available try: import matplotlib # noqa: F401 # pylint: disable=unused-import @@ -119,8 +126,6 @@ def test_backend_selection_env_var(monkeypatch): def test_backend_selection_option(monkeypatch): """Test that configuration option viz_backend works.""" - import importlib - # Check if matplotlib is available try: import matplotlib # noqa: F401 # pylint: disable=unused-import