diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 2cad0c2..775a50b 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -111,9 +111,11 @@ jobs:
--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 -e ".[dev,test,qt]" matplotlib
- - name: Verify PlotPy backend
- run: python -c "import plotpy; print(f'PlotPy loaded from {plotpy.__file__}')"
+ python -m pip install -e ".[dev,test,qt,plotly]" matplotlib
+ - name: Verify optional visualization backends
+ run: |
+ python -c "import plotpy; print(f'PlotPy loaded from {plotpy.__file__}')"
+ python -c "import plotly; print(f'Plotly loaded from {plotly.__file__}')"
- name: Test visualization backends
run: python -m pytest -o addopts="--import-mode=importlib" sigima/tests/viz -vv
- name: Lint with Pylint
diff --git a/doc/api/viz.rst b/doc/api/viz.rst
index d26a29b..d42be2c 100644
--- a/doc/api/viz.rst
+++ b/doc/api/viz.rst
@@ -14,7 +14,8 @@ This module provides visualization utilities for Sigima objects, useful for:
Backend Selection
-----------------
-The module automatically selects between **PlotPy** and **Matplotlib** backends based on availability and configuration settings.
+The module supports **PlotPy**, **Matplotlib**, and **Plotly** backends. The
+first two participate in automatic selection; Plotly is selected explicitly.
The backend selection follows this priority:
@@ -27,6 +28,10 @@ Backend selection logic:
- ``"auto"``: Try PlotPy first, fall back to Matplotlib
- ``"plotpy"``: Use PlotPy (raise :class:`ImportError` if not available)
- ``"matplotlib"``: Use Matplotlib (raise :class:`ImportError` if not available)
+- ``"plotly"``: Use browser-based Plotly (raise :class:`ImportError` if not available)
+
+Selecting Plotly does not change the ``"auto"`` priority. Install the optional
+dependency with ``pip install "sigima[plotly]"``.
.. rubric:: Configuring the Backend
@@ -56,7 +61,8 @@ Module Attributes
.. py:data:: BACKEND_NAME
:type: str
- Name of the currently selected backend: ``"plotpy"`` or ``"matplotlib"``.
+ Name of the currently selected backend: ``"plotpy"``, ``"matplotlib"``, or
+ ``"plotly"``.
.. py:data:: BACKEND_SOURCE
:type: str
@@ -104,10 +110,36 @@ 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 object. Matplotlib and Plotly ignore those opaque renderer-specific payloads. Use
the explicit migration described in :ref:`api_annotations` to make historical
annotations portable.
+Plotly JSON specifications
+--------------------------
+
+The :mod:`sigima.viz.plotly_spec` module builds plain JSON-compatible
+``dict``/``list`` structures without importing the Plotly Python package. These
+specifications may be consumed directly by Plotly.js or materialized as
+``plotly.graph_objects.Figure`` objects by the Plotly backend. Overlay builders
+are independent from the signal and image arrays so browser applications may
+reuse annotations, ROIs, and geometry results without copying large datasets.
+
+.. autofunction:: sigima.viz.plotly_spec.build_curve_figure_spec
+
+.. autofunction:: sigima.viz.plotly_spec.build_image_figure_spec
+
+.. autofunction:: sigima.viz.plotly_spec.build_signal_roi_overlay
+
+.. autofunction:: sigima.viz.plotly_spec.build_image_roi_overlay
+
+.. autofunction:: sigima.viz.plotly_spec.build_geometry_overlay
+
+The autonomous interactive gallery is available from the repository with:
+
+.. code-block:: powershell
+
+ python scripts/run_with_env.py python -m pytest sigima/tests/viz/plotly_gallery_gui_test.py --gui -v
+
Low-Level Viewing Functions
---------------------------
@@ -149,38 +181,50 @@ Annotation Items
Backend Differences
-------------------
-The two backends have different capabilities:
+The three backends have different capabilities:
.. list-table::
:header-rows: 1
- :widths: 40 30 30
+ :widths: 34 22 22 22
* - Feature
- PlotPy
- Matplotlib
+ - Plotly
* - Interactive zoom/pan
- - ✅ Full Qt tools
- - ✅ Basic toolbar
+ - Full Qt tools
+ - Basic toolbar
+ - Browser tools
* - ROI display
- - ✅ Native support
- - ✅ Patches overlay
+ - Native support
+ - Patches overlay
+ - JSON overlays
* - Geometry results
- - ✅ Shape annotations
- - ✅ Markers/lines
+ - Shape annotations
+ - Markers/lines
+ - Shapes/traces
* - Canonical annotations
- - ✅ Native interactive items
- - ✅ Read-only artists
+ - Native interactive items
+ - Read-only artists
+ - Read-only overlays
* - Historical PlotPy annotations
- - ✅ View-only compatibility
- - ❌ Opaque payload ignored
+ - View-only compatibility
+ - Opaque payload ignored
+ - Opaque payload ignored
* - Linked axes
- - ✅ Native
- - ✅ via ``sharex``/``sharey``
+ - Native
+ - via ``sharex``/``sharey``
+ - Plotly subplots
* - Qt integration
- - ✅ Native
- - ⚠️ Requires Qt backend
+ - Native
+ - Requires Qt backend
+ - Not required
* - Headless/CI
- - ⚠️ Needs display
- - ✅ ``Agg`` backend
-
-For automated testing and CI environments, Matplotlib with the ``Agg`` backend is recommended. For interactive data exploration, PlotPy provides a richer experience.
+ - Needs display
+ - ``Agg`` backend
+ - JSON validation
+
+For automated testing and CI environments, Matplotlib with the ``Agg`` backend
+or the dependency-free Plotly specifications may be used. PlotPy provides Qt
+editing tools; Plotly provides an interactive browser view with zoom, pan, and
+hover but does not edit canonical annotations.
diff --git a/doc/release_notes/release_1.03.md b/doc/release_notes/release_1.03.md
index 2d35939..c2515c2 100644
--- a/doc/release_notes/release_1.03.md
+++ b/doc/release_notes/release_1.03.md
@@ -4,4 +4,5 @@
### ✨ 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
+* **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).
+* **Interactive Plotly visualization**: Signals and images can now be inspected in a browser with zoom, pan and hover through the optional Plotly backend. Sigima also exposes dependency-free Plotly JSON builders for applications and notebooks, including portable annotation, ROI and geometry-result overlays.
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index dc77fc2..42c0d3f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -73,6 +73,7 @@ doc = [
]
test = ["pytest", "pytest-xvfb", "jsonschema >= 4"]
qt = ["qtpy", "PyQt5", "plotpy"]
+plotly = ["plotly >= 5.0"]
[tool.setuptools.packages.find]
include = ["sigima*"]
diff --git a/sigima/config.py b/sigima/config.py
index 0b24e34..5d8ad59 100644
--- a/sigima/config.py
+++ b/sigima/config.py
@@ -343,11 +343,12 @@ def __init__(self) -> None:
description=_(
"""Backend library for visualization (sigima.viz module).
-Valid values: ``"auto"``, ``"plotpy"``, ``"matplotlib"``.
+Valid values: ``"auto"``, ``"plotpy"``, ``"matplotlib"``, ``"plotly"``.
- ``"auto"`` (default): Automatically select PlotPy if available, otherwise Matplotlib
- ``"plotpy"``: Use PlotPy for interactive visualizations (requires PlotPy and Qt)
- ``"matplotlib"``: Use Matplotlib for visualizations (simpler, view-only)
+- ``"plotly"``: Use Plotly for browser-based interactive visualizations
This setting can also be overridden using the ``SIGIMA_VIZ_BACKEND`` environment
variable. Note that Matplotlib backend does not support all features of PlotPy
diff --git a/sigima/locale/fr/LC_MESSAGES/sigima.po b/sigima/locale/fr/LC_MESSAGES/sigima.po
index 51e547c..23223f5 100644
--- a/sigima/locale/fr/LC_MESSAGES/sigima.po
+++ b/sigima/locale/fr/LC_MESSAGES/sigima.po
@@ -42,11 +42,12 @@ msgstr ""
msgid ""
"Backend library for visualization (sigima.viz module).\n"
"\n"
-"Valid values: ``\"auto\"``, ``\"plotpy\"``, ``\"matplotlib\"``.\n"
+"Valid values: ``\"auto\"``, ``\"plotpy\"``, ``\"matplotlib\"``, ``\"plotly\"``.\n"
"\n"
"- ``\"auto\"`` (default): Automatically select PlotPy if available, otherwise Matplotlib\n"
"- ``\"plotpy\"``: Use PlotPy for interactive visualizations (requires PlotPy and Qt)\n"
"- ``\"matplotlib\"``: Use Matplotlib for visualizations (simpler, view-only)\n"
+"- ``\"plotly\"``: Use Plotly for browser-based interactive visualizations\n"
"\n"
"This setting can also be overridden using the ``SIGIMA_VIZ_BACKEND`` environment\n"
"variable. Note that Matplotlib backend does not support all features of PlotPy\n"
@@ -54,11 +55,12 @@ msgid ""
msgstr ""
"Bibliothèque backend pour la visualisation des tests (tests interactifs uniquement).\n"
"\n"
-"Valeurs valides : ``\"auto\"``, ``\"plotpy\"``, ``\"matplotlib\"``.\n"
+"Valeurs valides : ``\"auto\"``, ``\"plotpy\"``, ``\"matplotlib\"``, ``\"plotly\"``.\n"
"\n"
"- ``\"auto\"`` (par défaut) : Sélectionner automatiquement PlotPy si disponible, sinon Matplotlib\n"
"- ``\"plotpy\"`` : Utiliser PlotPy pour les visualisations interactives (nécessite PlotPy et Qt)\n"
"- ``\"matplotlib\"`` : Utiliser Matplotlib pour les visualisations (plus simple, statique)\n"
+"- ``\"plotly\"`` : Utiliser Plotly pour les visualisations interactives dans un navigateur\n"
"\n"
"Ce paramètre peut également être remplacé en utilisant la variable d'environnement ``SIGIMA_VIZ_BACKEND``.\n"
"Notez que le backend Matplotlib ne prend pas en charge toutes les fonctionnalités de PlotPy\n"
@@ -315,18 +317,18 @@ msgstr "Image sans titre"
msgid "Title"
msgstr "Titre"
-msgid "Height"
-msgstr "Hauteur"
-
msgid "Image height: number of rows"
msgstr "Hauteur de l'image : nombre de lignes"
-msgid "Width"
-msgstr "Largeur"
+msgid "Height"
+msgstr "Hauteur"
msgid "Image width: number of columns"
msgstr "Largeur de l'image : nombre de colonnes"
+msgid "Width"
+msgstr "Largeur"
+
msgid "Type"
msgstr "Type"
@@ -372,18 +374,18 @@ msgstr "Décalage X"
msgid "Y offset"
msgstr "Décalage Y"
-msgid "Minimum value"
-msgstr "Minimum"
-
msgid "Value for dark squares"
msgstr "Valeur des carrés foncés"
-msgid "Maximum value"
-msgstr "Maximum"
+msgid "Minimum value"
+msgstr "Minimum"
msgid "Value for light squares"
msgstr "Valeur des carrés clairs"
+msgid "Maximum value"
+msgstr "Maximum"
+
msgid "Amplitude and offset"
msgstr "Amplitude et décalage"
@@ -841,9 +843,6 @@ msgstr "- Infini"
msgid "Replace special values (image)"
msgstr "Remplacer les valeurs spéciales (image)"
-msgid "rows"
-msgstr "lignes"
-
msgid "columns"
msgstr "colonnes"
diff --git a/sigima/objects/__init__.py b/sigima/objects/__init__.py
index 8391190..5bba59f 100644
--- a/sigima/objects/__init__.py
+++ b/sigima/objects/__init__.py
@@ -143,6 +143,7 @@
"ImageROI",
"ImageTypes",
"KindShape",
+ "LegacyPlotPyMigrationReport",
"LinearChirpParam",
"LogisticParam",
"LorentzParam",
@@ -222,6 +223,8 @@
"flip_annotation_horizontally",
"flip_annotation_vertically",
"is_graphical_annotation_dict",
+ "legacy_plotpy_payload_to_annotations",
+ "migrate_legacy_plotpy_annotations",
"rotate_annotation",
"scale_annotation",
"transform_annotation",
@@ -245,6 +248,7 @@
EllipseAnnotation,
FillStyle,
GraphicalAnnotation,
+ LegacyPlotPyMigrationReport,
MarkerStyle,
PointAnnotation,
PolygonAnnotation,
@@ -261,6 +265,8 @@
flip_annotation_horizontally,
flip_annotation_vertically,
is_graphical_annotation_dict,
+ legacy_plotpy_payload_to_annotations,
+ migrate_legacy_plotpy_annotations,
rotate_annotation,
scale_annotation,
transform_annotation,
diff --git a/sigima/objects/annotations/__init__.py b/sigima/objects/annotations/__init__.py
index 985d4de..4793a33 100644
--- a/sigima/objects/annotations/__init__.py
+++ b/sigima/objects/annotations/__init__.py
@@ -2,6 +2,11 @@
"""Renderer-independent graphical annotations."""
+from sigima.objects.annotations.legacy_plotpy import (
+ LegacyPlotPyMigrationReport,
+ legacy_plotpy_payload_to_annotations,
+ migrate_legacy_plotpy_annotations,
+)
from sigima.objects.annotations.model import (
AnnotationKind,
AnnotationLabel,
@@ -59,6 +64,7 @@
"EllipseAnnotation",
"FillStyle",
"GraphicalAnnotation",
+ "LegacyPlotPyMigrationReport",
"MarkerStyle",
"PointAnnotation",
"PolygonAnnotation",
@@ -75,6 +81,8 @@
"flip_annotation_horizontally",
"flip_annotation_vertically",
"is_graphical_annotation_dict",
+ "legacy_plotpy_payload_to_annotations",
+ "migrate_legacy_plotpy_annotations",
"rotate_annotation",
"scale_annotation",
"transform_annotation",
diff --git a/sigima/objects/annotations/legacy_plotpy.py b/sigima/objects/annotations/legacy_plotpy.py
new file mode 100644
index 0000000..a0dbb4f
--- /dev/null
+++ b/sigima/objects/annotations/legacy_plotpy.py
@@ -0,0 +1,478 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Migration of historical PlotPy annotations to the canonical model."""
+
+from __future__ import annotations
+
+import json
+import math
+import re
+from collections.abc import Mapping, Sequence
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any
+
+from sigima.objects.annotations.model import (
+ 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_to_dict
+
+if TYPE_CHECKING:
+ from sigima.objects.base import BaseObj
+
+__all__ = [
+ "LegacyPlotPyMigrationReport",
+ "legacy_plotpy_payload_to_annotations",
+ "migrate_legacy_plotpy_annotations",
+]
+
+
+_ANCHORS = {
+ "TL": TextAnchor.TOP_LEFT,
+ "T": TextAnchor.TOP,
+ "TR": TextAnchor.TOP_RIGHT,
+ "L": TextAnchor.LEFT,
+ "C": TextAnchor.CENTER,
+ "R": TextAnchor.RIGHT,
+ "BL": TextAnchor.BOTTOM_LEFT,
+ "B": TextAnchor.BOTTOM,
+ "BR": TextAnchor.BOTTOM_RIGHT,
+}
+
+_AXES_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),
+}
+
+_DASH_STYLES = {
+ "SolidLine": "solid",
+ "DashLine": "dash",
+ "DotLine": "dot",
+ "DashDotLine": "dashdot",
+ "DashDotDotLine": (6.0, 3.0, 1.0, 3.0, 1.0, 3.0),
+}
+
+_MARKER_SYMBOLS = {
+ "NoSymbol": "none",
+ "Ellipse": "circle",
+ "Rect": "square",
+ "Diamond": "diamond",
+ "Cross": "x",
+ "Plus": "cross",
+ "TriangleUp": "triangle-up",
+ "TriangleDown": "triangle-down",
+ "TriangleLeft": "triangle-left",
+ "TriangleRight": "triangle-right",
+ "Star1": "star",
+ "Star2": "asterisk",
+}
+
+
+@dataclass(frozen=True)
+class LegacyPlotPyMigrationReport:
+ """Summary of one legacy annotation migration attempt."""
+
+ converted_count: int
+ preserved_count: int
+ diagnostics: tuple[str, ...]
+ applied: bool
+
+
+def _mapping(value: Any) -> Mapping[str, Any]:
+ """Return *value* as a mapping, or an empty mapping."""
+ return value if isinstance(value, Mapping) else {}
+
+
+def _float(value: Any, name: str) -> float:
+ """Return a finite floating-point value."""
+ result = float(value)
+ if not math.isfinite(result):
+ raise ValueError(f"{name} must be finite")
+ return result
+
+
+def _array(value: Any, name: str) -> Sequence[Any]:
+ """Unwrap guidata's JSON array representation."""
+ if (
+ isinstance(value, Sequence)
+ and not isinstance(value, (str, bytes))
+ and len(value) == 3
+ and value[0] == "array"
+ ):
+ value = value[1]
+ if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
+ raise ValueError(f"{name} must be an array")
+ return value
+
+
+def _points(item: Mapping[str, Any], minimum: int) -> list[tuple[float, float]]:
+ """Return PlotPy points as finite coordinate pairs."""
+ result = []
+ for index, point in enumerate(_array(item.get("points"), "points")):
+ values = _array(point, f"points[{index}]")
+ if len(values) != 2:
+ raise ValueError(f"points[{index}] must contain two coordinates")
+ result.append(
+ (
+ _float(values[0], f"points[{index}].x"),
+ _float(values[1], f"points[{index}].y"),
+ )
+ )
+ if len(result) < minimum:
+ raise ValueError(f"points must contain at least {minimum} entries")
+ return result
+
+
+def _distance(first: tuple[float, float], second: tuple[float, float]) -> float:
+ """Return the Euclidean distance between two points."""
+ return math.hypot(second[0] - first[0], second[1] - first[1])
+
+
+def _center(
+ first: tuple[float, float], second: tuple[float, float]
+) -> tuple[float, float]:
+ """Return the midpoint of two points."""
+ return (first[0] + second[0]) / 2.0, (first[1] + second[1]) / 2.0
+
+
+def _anchor(value: Any) -> TextAnchor:
+ """Convert a PlotPy anchor name to the canonical enum."""
+ return _ANCHORS.get(str(value), TextAnchor.TOP_LEFT)
+
+
+def _line_style(parameters: Mapping[str, Any]) -> StrokeStyle:
+ """Convert a PlotPy line parameter group."""
+ line = _mapping(parameters.get("line"))
+ style = str(line.get("style", "SolidLine"))
+ if style in ("NoPen", "NoLine"):
+ return StrokeStyle(color=None, width=0.0, opacity=0.0)
+ return StrokeStyle(
+ color=str(line.get("color", "#ff9933")),
+ width=_float(line.get("width", 1.0), "line width"),
+ dash=_DASH_STYLES.get(style, "solid"),
+ )
+
+
+def _fill_style(parameters: Mapping[str, Any]) -> FillStyle:
+ """Convert PlotPy shape or range fill parameters."""
+ fill = parameters.get("fill")
+ if isinstance(fill, Mapping):
+ style = str(fill.get("style", "NoBrush"))
+ opacity = (
+ 0.0
+ if style in ("NoBrush", "NoPattern")
+ else _float(fill.get("alpha", 1.0), "fill opacity")
+ )
+ return FillStyle(color=str(fill.get("color", "#000000")), opacity=opacity)
+ if isinstance(fill, str):
+ return FillStyle(
+ color=fill,
+ opacity=_float(parameters.get("shade", 0.0), "range opacity"),
+ )
+ return FillStyle()
+
+
+def _marker_style(parameters: Mapping[str, Any]) -> MarkerStyle:
+ """Convert a PlotPy symbol parameter group."""
+ symbol = _mapping(parameters.get("symbol"))
+ name = str(symbol.get("marker", "NoSymbol"))
+ return MarkerStyle(
+ symbol=_MARKER_SYMBOLS.get(name, "circle"),
+ size=_float(symbol.get("size", 6.0), "marker size"),
+ color=str(symbol.get("facecolor", symbol.get("edgecolor", "#ff9933"))),
+ )
+
+
+def _text_style(label_parameters: Mapping[str, Any]) -> TextStyle:
+ """Convert PlotPy label parameters."""
+ font = _mapping(label_parameters.get("font"))
+ family = font.get("family")
+ if family in (None, "", "default"):
+ family = None
+ return TextStyle(
+ family=str(family) if family is not None else None,
+ size=_float(font.get("size", 10.0), "font size"),
+ bold=bool(font.get("bold", False)),
+ italic=bool(font.get("italic", False)),
+ color=str(label_parameters.get("color", "#000000")),
+ background_color=str(label_parameters.get("bgcolor", "#ffffff")),
+ background_opacity=_float(
+ label_parameters.get("bgalpha", 0.0), "background opacity"
+ ),
+ )
+
+
+def _style(item: Mapping[str, Any]) -> AnnotationStyle:
+ """Convert the style groups shared by PlotPy item families."""
+ parameters = _mapping(item.get("shapeparam"))
+ if not parameters:
+ parameters = _mapping(item.get("markerparam"))
+ label_parameters = _mapping(item.get("labelparam"))
+ if not label_parameters:
+ label_parameters = _mapping(parameters.get("text"))
+ if label_parameters:
+ font = _mapping(label_parameters.get("font"))
+ label_parameters = {
+ "font": font,
+ "color": label_parameters.get("textcolor", "#000000"),
+ "bgcolor": label_parameters.get("background_color", "#ffffff"),
+ "bgalpha": label_parameters.get("background_alpha", 0.0),
+ }
+ return AnnotationStyle(
+ stroke=_line_style(parameters),
+ fill=_fill_style(parameters),
+ marker=_marker_style(parameters),
+ text=_text_style(label_parameters),
+ )
+
+
+def _label(item: Mapping[str, Any]) -> AnnotationLabel | None:
+ """Return the label attached to an annotated PlotPy shape."""
+ text = item.get("text")
+ if not isinstance(text, str) or not text:
+ return None
+ annotation_parameters = _mapping(item.get("annotationparam"))
+ label_parameters = _mapping(item.get("labelparam"))
+ return AnnotationLabel(
+ text=text,
+ visible=bool(annotation_parameters.get("show_label", True)),
+ anchor=_anchor(label_parameters.get("anchor", "TL")),
+ offset=(
+ _float(label_parameters.get("xc", 0.0), "label x offset"),
+ _float(label_parameters.get("yc", 0.0), "label y offset"),
+ ),
+ )
+
+
+def _common(item: Mapping[str, Any], item_class: str) -> dict[str, Any]:
+ """Return canonical fields shared by migrated items."""
+ annotation_parameters = _mapping(item.get("annotationparam"))
+ shape_parameters = _mapping(item.get("shapeparam"))
+ title = annotation_parameters.get("title") or shape_parameters.get("label") or ""
+ return {
+ "visible": bool(item.get("visible", True)),
+ "locked": bool(
+ annotation_parameters.get("readonly", False)
+ or shape_parameters.get("readonly", False)
+ ),
+ "z_index": int(round(_float(item.get("z", 0.0), "z index"))),
+ "title": str(title),
+ "style": _style(item),
+ "label": _label(item),
+ "extensions": {"plotpy": {"item_class": item_class}},
+ }
+
+
+def _item_to_annotation(
+ item_class: str, item: Mapping[str, Any]
+) -> GraphicalAnnotation:
+ """Convert one decoded PlotPy item to a canonical annotation."""
+ common = _common(item, item_class)
+ if item_class == "AnnotatedPoint":
+ point = _points(item, 1)[0]
+ annotation = PointAnnotation(x=point[0], y=point[1], **common)
+ elif item_class == "AnnotatedSegment":
+ points = _points(item, 2)
+ first, second = points[0], points[1]
+ annotation = SegmentAnnotation(
+ x0=first[0], y0=first[1], x1=second[0], y1=second[1], **common
+ )
+ elif item_class in ("AnnotatedRectangle", "AnnotatedObliqueRectangle"):
+ points = _points(item, 4)
+ center = _center(points[0], points[2])
+ annotation = RectangleAnnotation(
+ x=center[0],
+ y=center[1],
+ width=_distance(points[0], points[1]),
+ height=_distance(points[1], points[2]),
+ angle=math.atan2(points[1][1] - points[0][1], points[1][0] - points[0][0]),
+ **common,
+ )
+ elif item_class == "AnnotatedCircle":
+ points = _points(item, 2)
+ first, second = points[0], points[1]
+ center = _center(first, second)
+ annotation = CircleAnnotation(
+ cx=center[0], cy=center[1], radius=_distance(first, second) / 2.0, **common
+ )
+ elif item_class == "AnnotatedEllipse":
+ points = _points(item, 4)
+ first, second, third, fourth = points[0], points[1], points[2], points[3]
+ center = _center(first, second)
+ annotation = EllipseAnnotation(
+ cx=center[0],
+ cy=center[1],
+ radius_x=_distance(first, second) / 2.0,
+ radius_y=_distance(third, fourth) / 2.0,
+ angle=math.atan2(first[1] - second[1], first[0] - second[0]),
+ **common,
+ )
+ elif item_class == "AnnotatedPolygon":
+ points = _points(item, 2)
+ if bool(item.get("closed", True)):
+ annotation = PolygonAnnotation(points=tuple(points), **common)
+ else:
+ annotation = PolylineAnnotation(points=tuple(points), **common)
+ elif item_class == "LabelItem":
+ label_parameters = _mapping(item.get("labelparam"))
+ common["label"] = None
+ common["title"] = str(label_parameters.get("label", ""))
+ if bool(label_parameters.get("abspos", False)):
+ axes_position = _AXES_POSITIONS.get(
+ str(label_parameters.get("absg", "TL")), (0.0, 1.0)
+ )
+ x, y = axes_position[0], axes_position[1]
+ coordinate_space = CoordinateSpace.AXES
+ else:
+ x = _float(label_parameters.get("xg", 0.0), "label x")
+ y = _float(label_parameters.get("yg", 0.0), "label y")
+ coordinate_space = CoordinateSpace.DATA
+ annotation = TextAnnotation(
+ text=str(item.get("text", label_parameters.get("contents", ""))),
+ x=x,
+ y=y,
+ coordinate_space=coordinate_space,
+ anchor=_anchor(label_parameters.get("anchor", "TL")),
+ offset=(
+ _float(label_parameters.get("xc", 0.0), "label x offset"),
+ _float(label_parameters.get("yc", 0.0), "label y offset"),
+ ),
+ **common,
+ )
+ elif item_class == "Marker":
+ marker_parameters = _mapping(item.get("markerparam"))
+ marker_style = str(marker_parameters.get("markerstyle", "NoLine"))
+ x = _float(item.get("x", 0.0), "marker x")
+ y = _float(item.get("y", 0.0), "marker y")
+ common["label"] = None
+ if marker_style == "VLine":
+ annotation = CursorAnnotation(
+ orientation=CursorOrientation.VERTICAL, position=x, **common
+ )
+ elif marker_style == "HLine":
+ annotation = CursorAnnotation(
+ orientation=CursorOrientation.HORIZONTAL, position=y, **common
+ )
+ elif marker_style == "Cross":
+ annotation = CursorAnnotation(
+ orientation=CursorOrientation.CROSSHAIR, position=(x, y), **common
+ )
+ else:
+ annotation = PointAnnotation(x=x, y=y, **common)
+ elif item_class in ("AnnotatedXRange", "XRangeSelection"):
+ annotation = RangeAnnotation(
+ axis=Axis.X,
+ start=_float(item.get("min"), "range minimum"),
+ end=_float(item.get("max"), "range maximum"),
+ **common,
+ )
+ elif item_class in ("AnnotatedYRange", "YRangeSelection"):
+ annotation = RangeAnnotation(
+ axis=Axis.Y,
+ start=_float(item.get("min"), "range minimum"),
+ end=_float(item.get("max"), "range maximum"),
+ **common,
+ )
+ else:
+ raise ValueError(f"unsupported PlotPy annotation class {item_class!r}")
+ return annotation
+
+
+def _item_class(key: str, fallback: Any = None) -> str:
+ """Return the PlotPy class name encoded in a JSON item key."""
+ match = re.fullmatch(r"(.+)_\d+", key)
+ if match is not None:
+ return match.group(1)
+ if isinstance(fallback, str) and fallback:
+ return fallback
+ return key
+
+
+def legacy_plotpy_payload_to_annotations(
+ payload: Mapping[str, Any],
+) -> list[GraphicalAnnotation]:
+ """Convert one historical ``plotpy_json`` payload without importing PlotPy."""
+ if not isinstance(payload, Mapping):
+ raise TypeError("legacy PlotPy payload must be a mapping")
+ json_text = payload.get("plotpy_json")
+ if not isinstance(json_text, str):
+ raise ValueError("legacy PlotPy payload has no JSON string")
+ document = json.loads(json_text)
+ if not isinstance(document, Mapping):
+ raise ValueError("legacy PlotPy JSON root must be an object")
+ item_keys = document.get("plot_items")
+ if not isinstance(item_keys, list):
+ item_keys = [key for key in document if key != "plot_items"]
+ annotations = []
+ for key in item_keys:
+ if not isinstance(key, str):
+ raise ValueError("legacy PlotPy item key must be a string")
+ item = document.get(key)
+ if not isinstance(item, Mapping):
+ raise ValueError(f"legacy PlotPy item {key!r} must be an object")
+ annotations.append(
+ _item_to_annotation(_item_class(key, payload.get("item_class")), item)
+ )
+ if not annotations:
+ raise ValueError("legacy PlotPy payload contains no items")
+ return annotations
+
+
+def migrate_legacy_plotpy_annotations(
+ obj: BaseObj, *, dry_run: bool = False
+) -> LegacyPlotPyMigrationReport:
+ """Replace supported historical PlotPy payloads on *obj* with canonical ones."""
+ stored = obj.get_annotations()
+ migrated: list[dict[str, Any]] = []
+ converted_count = 0
+ preserved_count = 0
+ diagnostics = []
+ for index, payload in enumerate(stored):
+ if not isinstance(payload, Mapping) or "plotpy_json" not in payload:
+ migrated.append(payload)
+ preserved_count += 1
+ continue
+ try:
+ annotations = legacy_plotpy_payload_to_annotations(payload)
+ except (TypeError, ValueError, json.JSONDecodeError) as exc:
+ migrated.append(payload)
+ preserved_count += 1
+ diagnostics.append(f"annotation {index}: {exc}")
+ continue
+ migrated.extend(annotation_to_dict(annotation) for annotation in annotations)
+ converted_count += len(annotations)
+ applied = converted_count > 0 and not dry_run
+ if applied:
+ obj.set_annotations(migrated)
+ return LegacyPlotPyMigrationReport(
+ converted_count=converted_count,
+ preserved_count=preserved_count,
+ diagnostics=tuple(diagnostics),
+ applied=applied,
+ )
diff --git a/sigima/tests/common/legacy_plotpy_annotations_unit_test.py b/sigima/tests/common/legacy_plotpy_annotations_unit_test.py
new file mode 100644
index 0000000..9f12e9e
--- /dev/null
+++ b/sigima/tests/common/legacy_plotpy_annotations_unit_test.py
@@ -0,0 +1,204 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Tests for migration of historical PlotPy annotation payloads."""
+
+from __future__ import annotations
+
+import json
+
+import numpy as np
+import pytest
+
+from sigima.objects import (
+ CircleAnnotation,
+ CursorAnnotation,
+ EllipseAnnotation,
+ PointAnnotation,
+ PolygonAnnotation,
+ PolylineAnnotation,
+ RangeAnnotation,
+ RectangleAnnotation,
+ SegmentAnnotation,
+ TextAnnotation,
+ create_signal,
+)
+from sigima.objects.annotations.legacy_plotpy import (
+ legacy_plotpy_payload_to_annotations,
+ migrate_legacy_plotpy_annotations,
+)
+
+
+def _legacy_payload(item_class: str, item: dict) -> dict:
+ """Return a historical DataLab PlotPy payload."""
+ item_key = f"{item_class}_001"
+ return {
+ "type": "plotpy_item",
+ "item_class": item_class,
+ "plotpy_json": json.dumps({item_key: item, "plot_items": [item_key]}),
+ }
+
+
+def test_migrate_legacy_plotpy_rectangle_without_plotpy() -> None:
+ """A historical rectangle must become a canonical annotation."""
+ payload = _legacy_payload(
+ "AnnotatedRectangle",
+ {
+ "annotationparam": {"title": "Legacy rectangle"},
+ "shapeparam": {
+ "line": {
+ "style": "DashLine",
+ "color": "#112233",
+ "width": 2.0,
+ },
+ "fill": {
+ "style": "SolidPattern",
+ "color": "#445566",
+ "alpha": 0.25,
+ },
+ },
+ "points": [
+ "array",
+ [[0.0, 0.0], [4.0, 0.0], [4.0, 2.0], [0.0, 2.0]],
+ "float64",
+ ],
+ "closed": True,
+ "visible": True,
+ },
+ )
+ obj = create_signal("legacy", np.arange(5), np.arange(5))
+ obj.set_annotations([payload])
+
+ report = migrate_legacy_plotpy_annotations(obj)
+
+ assert report.converted_count == 1
+ assert report.applied
+ [annotation] = obj.get_graphical_annotations()
+ assert isinstance(annotation, RectangleAnnotation)
+ assert (annotation.x, annotation.y) == (2.0, 1.0)
+ assert (annotation.width, annotation.height, annotation.angle) == (4.0, 2.0, 0.0)
+ assert annotation.title == "Legacy rectangle"
+ assert annotation.style.stroke.color == "#112233"
+ assert annotation.style.stroke.dash == "dash"
+ assert annotation.style.fill.opacity == 0.25
+
+
+@pytest.mark.parametrize(
+ ("item_class", "item", "expected_type"),
+ [
+ (
+ "AnnotatedPoint",
+ {"points": ["array", [[1.0, 2.0]], "float64"]},
+ PointAnnotation,
+ ),
+ (
+ "AnnotatedSegment",
+ {"points": ["array", [[0.0, 0.0], [3.0, 4.0]], "float64"]},
+ SegmentAnnotation,
+ ),
+ (
+ "AnnotatedCircle",
+ {"points": ["array", [[0.0, 0.0], [4.0, 0.0]], "float64"]},
+ CircleAnnotation,
+ ),
+ (
+ "AnnotatedEllipse",
+ {
+ "points": [
+ "array",
+ [[4.0, 0.0], [0.0, 0.0], [2.0, -1.0], [2.0, 1.0]],
+ "float64",
+ ]
+ },
+ EllipseAnnotation,
+ ),
+ (
+ "AnnotatedPolygon",
+ {
+ "points": [
+ "array",
+ [[0.0, 0.0], [2.0, 0.0], [1.0, 3.0]],
+ "float64",
+ ],
+ "closed": True,
+ },
+ PolygonAnnotation,
+ ),
+ (
+ "AnnotatedPolygon",
+ {
+ "points": ["array", [[0.0, 0.0], [2.0, 1.0]], "float64"],
+ "closed": False,
+ },
+ PolylineAnnotation,
+ ),
+ (
+ "LabelItem",
+ {
+ "labelparam": {
+ "label": "Label #1",
+ "anchor": "TL",
+ "abspos": False,
+ "xg": 1.0,
+ "yg": 2.0,
+ "xc": 3,
+ "yc": 4,
+ },
+ "text": "Legacy label",
+ },
+ TextAnnotation,
+ ),
+ (
+ "Marker",
+ {"markerparam": {"markerstyle": "Cross"}, "x": 1.0, "y": 2.0},
+ CursorAnnotation,
+ ),
+ (
+ "AnnotatedXRange",
+ {"min": 1.0, "max": 5.0},
+ RangeAnnotation,
+ ),
+ (
+ "AnnotatedYRange",
+ {"min": 2.0, "max": 6.0},
+ RangeAnnotation,
+ ),
+ ],
+)
+def test_all_known_legacy_plotpy_types_are_supported(
+ item_class: str, item: dict, expected_type: type
+) -> None:
+ """Every historical DataLab annotation family must have a canonical type."""
+ annotations = legacy_plotpy_payload_to_annotations(
+ _legacy_payload(item_class, item)
+ )
+
+ assert len(annotations) == 1
+ assert isinstance(annotations[0], expected_type)
+
+
+def test_migration_is_idempotent_and_preserves_unknown_payloads() -> None:
+ """Unsupported consumers must survive migration unchanged."""
+ supported = _legacy_payload(
+ "AnnotatedPoint", {"points": ["array", [[1.0, 2.0]], "float64"]}
+ )
+ unsupported = _legacy_payload("CurveItem", {"x": [0, 1], "y": [1, 2]})
+ opaque = {"consumer": "unknown", "payload": {"keep": True}}
+ obj = create_signal("legacy", np.arange(5), np.arange(5))
+ obj.set_annotations([opaque, supported, unsupported])
+
+ preview = migrate_legacy_plotpy_annotations(obj, dry_run=True)
+ assert preview.converted_count == 1
+ assert not preview.applied
+ assert obj.get_annotations() == [opaque, supported, unsupported]
+
+ report = migrate_legacy_plotpy_annotations(obj)
+ assert report.converted_count == 1
+ assert report.preserved_count == 2
+ assert report.diagnostics
+ assert obj.get_annotations()[0] == opaque
+ assert obj.get_annotations()[-1] == unsupported
+
+ second_report = migrate_legacy_plotpy_annotations(obj)
+ assert second_report.converted_count == 0
+ assert obj.get_annotations()[0] == opaque
+ assert obj.get_annotations()[-1] == unsupported
diff --git a/sigima/tests/viz/annotation_plotly_unit_test.py b/sigima/tests/viz/annotation_plotly_unit_test.py
new file mode 100644
index 0000000..8941d22
--- /dev/null
+++ b/sigima/tests/viz/annotation_plotly_unit_test.py
@@ -0,0 +1,76 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Unit tests for the Plotly canonical annotation adapter."""
+
+from __future__ import annotations
+
+import importlib
+import json
+import sys
+
+from sigima.objects.annotations import (
+ Axis,
+ CircleAnnotation,
+ CoordinateSpace,
+ CursorAnnotation,
+ CursorOrientation,
+ EllipseAnnotation,
+ PointAnnotation,
+ PolygonAnnotation,
+ PolylineAnnotation,
+ RangeAnnotation,
+ RectangleAnnotation,
+ SegmentAnnotation,
+ TextAnnotation,
+)
+
+
+def test_annotation_adapter_does_not_import_plotly() -> None:
+ """The JSON adapter must not require or load the Plotly Python package."""
+ modules_before = set(sys.modules)
+ importlib.import_module("sigima.viz.annotation_plotly")
+ imported_modules = set(sys.modules) - modules_before
+ assert not any(
+ name == "plotly" or name.startswith("plotly.") for name in imported_modules
+ )
+
+
+def test_all_canonical_annotations_produce_json_plotly_specs() -> None:
+ """All canonical primitives must produce serializable Plotly overlays."""
+ adapter = importlib.import_module("sigima.viz.annotation_plotly")
+
+ annotations = [
+ PointAnnotation(x=1.0, y=2.0),
+ SegmentAnnotation(x0=0.0, y0=1.0, x1=2.0, y1=3.0),
+ RectangleAnnotation(x=2.0, y=3.0, width=4.0, height=2.0, angle=0.2),
+ CircleAnnotation(cx=3.0, cy=4.0, radius=1.0),
+ EllipseAnnotation(cx=4.0, cy=5.0, radius_x=2.0, radius_y=1.0, angle=0.3),
+ PolylineAnnotation(points=((0.0, 0.0), (1.0, 2.0))),
+ PolygonAnnotation(points=((0.0, 0.0), (2.0, 0.0), (1.0, 2.0))),
+ TextAnnotation(
+ text="axes text", x=0.5, y=0.9, coordinate_space=CoordinateSpace.AXES
+ ),
+ CursorAnnotation(orientation=CursorOrientation.CROSSHAIR, position=(1.0, 2.0)),
+ RangeAnnotation(axis=Axis.X, start=2.0, end=4.0),
+ ]
+
+ spec = adapter.annotations_to_plotly_spec(annotations)
+
+ assert set(spec) == {"traces", "shapes", "annotations"}
+ assert len(spec["traces"]) == 1
+ assert len(spec["shapes"]) == 9
+ assert len(spec["annotations"]) == 1
+ assert spec["annotations"][0]["xref"] == "paper"
+ assert spec["annotations"][0]["yref"] == "paper"
+ json.dumps(spec, allow_nan=False)
+
+
+def test_hidden_annotations_are_omitted() -> None:
+ """Hidden canonical annotations must not create Plotly overlay entries."""
+ adapter = importlib.import_module("sigima.viz.annotation_plotly")
+
+ spec = adapter.annotation_to_plotly_spec(
+ PointAnnotation(x=1.0, y=2.0, visible=False)
+ )
+
+ assert spec == {"traces": [], "shapes": [], "annotations": []}
diff --git a/sigima/tests/viz/backend_selection_unit_test.py b/sigima/tests/viz/backend_selection_unit_test.py
index 64c5a8a..f4441a6 100644
--- a/sigima/tests/viz/backend_selection_unit_test.py
+++ b/sigima/tests/viz/backend_selection_unit_test.py
@@ -22,7 +22,8 @@
HAS_PLOTPY = importlib.util.find_spec("plotpy") is not None
HAS_MPL = importlib.util.find_spec("matplotlib") is not None
-HAS_ANY_BACKEND = HAS_PLOTPY or HAS_MPL
+HAS_PLOTLY = importlib.util.find_spec("plotly") is not None
+HAS_ANY_BACKEND = HAS_PLOTPY or HAS_MPL or HAS_PLOTLY
# ===========================================================================
@@ -57,6 +58,16 @@ def test_select_backend_via_env_matplotlib(monkeypatch):
assert name == "matplotlib"
+@pytest.mark.skipif(not HAS_PLOTLY, reason="Plotly not installed")
+def test_select_backend_via_env_plotly(monkeypatch):
+ """Setting ``SIGIMA_VIZ_BACKEND=plotly`` selects the Plotly backend."""
+ monkeypatch.setenv("SIGIMA_VIZ_BACKEND", "plotly")
+ viz = _reload_viz()
+ name, source = viz._select_backend() # pylint: disable=protected-access
+ assert source == "env"
+ assert name == "plotly"
+
+
@pytest.mark.skipif(not HAS_ANY_BACKEND, reason="No viz backend installed")
def test_select_backend_via_env_auto(monkeypatch):
"""``SIGIMA_VIZ_BACKEND=auto`` is a recognised value that triggers detection."""
@@ -64,7 +75,7 @@ def test_select_backend_via_env_auto(monkeypatch):
viz = _reload_viz()
name, source = viz._select_backend() # pylint: disable=protected-access
assert source == "env"
- assert name in ("plotpy", "matplotlib")
+ assert name in ("plotpy", "matplotlib", "plotly")
@pytest.mark.skipif(not HAS_ANY_BACKEND, reason="No viz backend installed")
@@ -76,7 +87,7 @@ def test_select_backend_unrecognized_env_falls_back(monkeypatch):
viz = _reload_viz()
name, source = viz._select_backend() # pylint: disable=protected-access
assert source in ("config", "auto")
- assert name in ("plotpy", "matplotlib")
+ assert name in ("plotpy", "matplotlib", "plotly")
def test_dunder_attribute_raises(monkeypatch):
@@ -120,7 +131,7 @@ def test_lazy_attribute_access_initializes_backend(monkeypatch):
func = viz.view_curves
assert callable(func)
# Now BACKEND_NAME should be populated.
- assert viz.BACKEND_NAME in ("plotpy", "matplotlib")
+ assert viz.BACKEND_NAME in ("plotpy", "matplotlib", "plotly")
assert viz.BACKEND_SOURCE in ("env", "config", "auto")
diff --git a/sigima/tests/viz/plotly_gallery_gui_test.py b/sigima/tests/viz/plotly_gallery_gui_test.py
new file mode 100644
index 0000000..f3b8c76
--- /dev/null
+++ b/sigima/tests/viz/plotly_gallery_gui_test.py
@@ -0,0 +1,403 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Interactive visual gallery for Sigima's autonomous Plotly backend."""
+
+from __future__ import annotations
+
+import atexit
+import importlib.util
+import tempfile
+import webbrowser
+from html import escape
+from pathlib import Path
+
+import numpy as np
+import pytest
+
+from sigima.objects import (
+ GeometryResult,
+ KindShape,
+ create_image,
+ create_image_roi,
+ create_signal,
+ create_signal_roi,
+)
+from sigima.objects.annotations import (
+ AnnotationLabel,
+ AnnotationStyle,
+ Axis,
+ CircleAnnotation,
+ CoordinateSpace,
+ CursorAnnotation,
+ CursorOrientation,
+ EllipseAnnotation,
+ FillStyle,
+ MarkerStyle,
+ PointAnnotation,
+ PolygonAnnotation,
+ PolylineAnnotation,
+ RangeAnnotation,
+ RectangleAnnotation,
+ SegmentAnnotation,
+ StrokeStyle,
+ TextAnnotation,
+ TextStyle,
+)
+from sigima.viz.plotly_spec import build_curve_figure_spec, build_image_figure_spec
+from sigima.viz.viz_plotly import figure_from_spec
+
+pytestmark = [
+ pytest.mark.gui,
+ pytest.mark.skipif(
+ importlib.util.find_spec("plotly") is None,
+ reason="Plotly not installed",
+ ),
+]
+
+_GALLERY_FIGURES: list[tuple[str, str]] = []
+
+
+def _add_gallery_figure(name: str, spec: dict) -> None:
+ """Validate a figure spec and add its HTML fragment to the gallery."""
+ import plotly.io as pio # pylint: disable=import-outside-toplevel
+
+ figure = figure_from_spec(spec)
+ assert figure.data
+ figure_json = figure.to_plotly_json()
+ assert "data" in figure_json
+ assert "layout" in figure_json
+ fragment = pio.to_html(figure, include_plotlyjs=False, full_html=False)
+ _GALLERY_FIGURES.append((name, fragment))
+
+
+def _build_gallery_html() -> str:
+ """Return a standalone tabbed HTML document for accumulated figures."""
+ from plotly import offline # pylint: disable=import-outside-toplevel
+
+ navigation = []
+ panels = []
+ for index, (name, fragment) in enumerate(_GALLERY_FIGURES):
+ selected = " selected" if index == 0 else ""
+ hidden = "" if index == 0 else " hidden"
+ navigation.append(
+ f'"
+ )
+ panels.append(
+ f''
+ f"{escape(name)}
{fragment}"
+ )
+ return f"""
+
+
+
+
+Sigima Plotly visual gallery
+
+
+
+
+
+
+{"".join(panels)}
+
+
+
+"""
+
+
+def _open_gallery() -> None:
+ """Write and open the autonomous gallery after the GUI test process exits."""
+ if not _GALLERY_FIGURES:
+ return
+ gallery_path = Path(tempfile.gettempdir()) / "sigima-plotly-gallery.html"
+ gallery_path.write_text(_build_gallery_html(), encoding="utf-8")
+ print(f"Sigima Plotly gallery: {gallery_path}")
+ webbrowser.open(gallery_path.as_uri())
+
+
+atexit.register(_open_gallery)
+
+
+@pytest.mark.gui
+def test_canonical_annotations_plotly_gallery() -> None:
+ """Display every canonical annotation primitive on one image."""
+ image = create_image("Canonical annotations", data=np.zeros((100, 100)))
+ image.set_graphical_annotations(
+ [
+ PointAnnotation(
+ x=12,
+ y=15,
+ title="Point",
+ label=AnnotationLabel("Point"),
+ ),
+ SegmentAnnotation(
+ x0=8,
+ y0=30,
+ x1=35,
+ y1=42,
+ title="Segment",
+ label=AnnotationLabel("Segment"),
+ ),
+ RectangleAnnotation(
+ x=28,
+ y=68,
+ width=28,
+ height=14,
+ angle=0.35,
+ title="Rotated rectangle",
+ label=AnnotationLabel("Rectangle"),
+ ),
+ CircleAnnotation(
+ cx=58,
+ cy=22,
+ radius=10,
+ title="Circle",
+ label=AnnotationLabel("Circle"),
+ ),
+ EllipseAnnotation(
+ cx=75,
+ cy=55,
+ radius_x=16,
+ radius_y=8,
+ angle=-0.45,
+ title="Rotated ellipse",
+ label=AnnotationLabel("Ellipse"),
+ ),
+ PolylineAnnotation(
+ points=((5, 90), (20, 80), (35, 92), (48, 82)),
+ title="Polyline",
+ label=AnnotationLabel("Polyline"),
+ ),
+ PolygonAnnotation(
+ points=((62, 78), (80, 70), (92, 88), (72, 95)),
+ title="Polygon",
+ label=AnnotationLabel("Polygon"),
+ ),
+ TextAnnotation(
+ text="Axes coordinates",
+ x=0.02,
+ y=0.98,
+ coordinate_space=CoordinateSpace.AXES,
+ ),
+ CursorAnnotation(
+ orientation=CursorOrientation.CROSSHAIR,
+ position=(50, 50),
+ title="Crosshair",
+ label=AnnotationLabel("Cursor"),
+ ),
+ RangeAnnotation(
+ axis=Axis.X,
+ start=40,
+ end=55,
+ title="X range",
+ label=AnnotationLabel("Range"),
+ style=AnnotationStyle(fill=FillStyle("#00a896", 0.18)),
+ ),
+ ]
+ )
+
+ spec = build_image_figure_spec(image, colormap="gray")
+
+ assert len(spec["layout"]["shapes"]) == 9
+ assert len(spec["layout"]["annotations"]) == 10
+ _add_gallery_figure("Canonical annotations", spec)
+
+
+@pytest.mark.gui
+def test_annotation_styles_plotly_gallery() -> None:
+ """Display annotation color, dash, fill, marker, text, and lock states."""
+ image = create_image("Annotation styles", data=np.zeros((80, 120)))
+ styles = [
+ ("Solid", "solid", "#e63946"),
+ ("Dashed", "dash", "#457b9d"),
+ ("Dotted", "dot", "#2a9d8f"),
+ ("Dash-dot", "dashdot", "#f4a261"),
+ ]
+ annotations = []
+ for index, (label, dash, color) in enumerate(styles):
+ y_value = 12 + index * 14
+ annotations.append(
+ SegmentAnnotation(
+ x0=8,
+ y0=y_value,
+ x1=62,
+ y1=y_value,
+ title=label,
+ locked=index % 2 == 0,
+ label=AnnotationLabel(label),
+ style=AnnotationStyle(
+ stroke=StrokeStyle(color=color, width=2 + index, dash=dash)
+ ),
+ )
+ )
+ annotations.extend(
+ [
+ PointAnnotation(
+ x=84,
+ y=18,
+ title="Diamond",
+ label=AnnotationLabel("Diamond"),
+ style=AnnotationStyle(
+ marker=MarkerStyle("diamond", 14, "#e76f51"),
+ stroke=StrokeStyle("#7f2d1d", 2),
+ ),
+ ),
+ RectangleAnnotation(
+ x=88,
+ y=48,
+ width=28,
+ height=20,
+ title="Translucent fill",
+ label=AnnotationLabel("Fill"),
+ style=AnnotationStyle(
+ stroke=StrokeStyle("#264653", 3),
+ fill=FillStyle("#e9c46a", 0.45),
+ ),
+ ),
+ TextAnnotation(
+ text="Bold italic text",
+ x=0.98,
+ y=0.06,
+ coordinate_space=CoordinateSpace.AXES,
+ style=AnnotationStyle(
+ text=TextStyle(
+ size=15,
+ bold=True,
+ italic=True,
+ color="#1d3557",
+ background_color="#a8dadc",
+ background_opacity=0.8,
+ )
+ ),
+ ),
+ ]
+ )
+ image.set_graphical_annotations(annotations)
+
+ spec = build_image_figure_spec(image, colormap="gray")
+
+ assert len(spec["data"]) == 2
+ assert len(spec["layout"]["shapes"]) == 5
+ _add_gallery_figure("Styles and states", spec)
+
+
+@pytest.mark.gui
+def test_signal_roi_and_errors_plotly_gallery() -> None:
+ """Display multiple signals, error bars, styles, ROI, and annotations."""
+ x_values = np.linspace(0, 4 * np.pi, 160)
+ signal = create_signal(
+ "Measurement",
+ x=x_values,
+ y=np.sin(x_values),
+ dy=np.full_like(x_values, 0.08),
+ )
+ signal.xlabel = "Time"
+ signal.xunit = "s"
+ signal.ylabel = "Amplitude"
+ signal.yunit = "V"
+ signal.roi = create_signal_roi([2.5, 6.5], title="Analysis interval")
+ signal.set_graphical_annotations(
+ [
+ CursorAnnotation(
+ orientation=CursorOrientation.VERTICAL,
+ position=np.pi,
+ label=AnnotationLabel("Phase marker"),
+ )
+ ]
+ )
+ reference = create_signal("Reference", x=x_values, y=0.6 * np.cos(x_values))
+ reference.metadata["color"] = "#e76f51"
+ reference.metadata["linestyle"] = "DashLine"
+
+ spec = build_curve_figure_spec([signal, reference])
+
+ assert len(spec["data"]) == 3
+ assert "error_y" in spec["data"][0]
+ assert len(spec["layout"]["shapes"]) == 1
+ _add_gallery_figure("Signals, ROI, and errors", spec)
+
+
+@pytest.mark.gui
+def test_image_results_and_coordinates_plotly_gallery() -> None:
+ """Display calibrated images, ROI masks, and every geometry result kind."""
+ x_grid, y_grid = np.meshgrid(np.linspace(-3, 3, 90), np.linspace(-2, 2, 60))
+ data = np.exp(-(x_grid**2 + y_grid**2)) + 0.35 * np.exp(
+ -((x_grid - 1.4) ** 2 + (y_grid + 0.5) ** 2) / 0.25
+ )
+ image = create_image("Calibrated image", data=data)
+ image.xlabel = "X"
+ image.xunit = "mm"
+ image.ylabel = "Y"
+ image.yunit = "mm"
+ image.zlabel = "Intensity"
+ image.set_coords(
+ np.linspace(-3, 3, data.shape[1]) ** 3 / 9,
+ np.linspace(-2, 2, data.shape[0]) ** 3 / 4,
+ )
+ image.roi = create_image_roi("circle", [0.0, 0.0, 1.2], title="Circular ROI")
+ results = [
+ GeometryResult.from_coords("Peak", KindShape.POINT, np.array([[0, 0]])),
+ GeometryResult.from_coords(
+ "Centroid", KindShape.MARKER, np.array([[0.15, -0.1]])
+ ),
+ GeometryResult.from_coords(
+ "Bounds", KindShape.RECTANGLE, np.array([[-1.8, -1.2, 3.6, 2.4]])
+ ),
+ GeometryResult.from_coords("Radius", KindShape.CIRCLE, np.array([[0, 0, 0.8]])),
+ GeometryResult.from_coords(
+ "Diameter", KindShape.SEGMENT, np.array([[-0.8, 0, 0.8, 0]])
+ ),
+ GeometryResult.from_coords(
+ "Fit", KindShape.ELLIPSE, np.array([[0, 0, 1.5, 0.7, 0.4]])
+ ),
+ GeometryResult.from_coords(
+ "Envelope",
+ KindShape.POLYGON,
+ np.array([[-2, -0.4, -0.8, -1.4, 1.5, -1, 2, 0.8, 0, 1.6]]),
+ ),
+ ]
+
+ spec = build_image_figure_spec(image, results=results, colormap="Viridis")
+
+ assert len(spec["data"]) == 4
+ assert len(spec["layout"]["shapes"]) == 8
+ assert len(spec["layout"]["annotations"]) == 8
+ _add_gallery_figure("Images, ROI, and geometry", spec)
diff --git a/sigima/tests/viz/plotly_spec_unit_test.py b/sigima/tests/viz/plotly_spec_unit_test.py
new file mode 100644
index 0000000..0e44ddc
--- /dev/null
+++ b/sigima/tests/viz/plotly_spec_unit_test.py
@@ -0,0 +1,146 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Unit tests for dependency-free Plotly figure specifications."""
+
+from __future__ import annotations
+
+import importlib
+import json
+import sys
+
+import numpy as np
+
+from sigima.objects import (
+ GeometryResult,
+ KindShape,
+ create_image,
+ create_image_roi,
+ create_signal,
+ create_signal_roi,
+)
+from sigima.objects.annotations import PointAnnotation, TextAnnotation
+
+
+def test_plotly_spec_module_does_not_import_plotly() -> None:
+ """Building JSON figure specs must not load the Plotly Python package."""
+ modules_before = set(sys.modules)
+ importlib.import_module("sigima.viz.plotly_spec")
+ imported_modules = set(sys.modules) - modules_before
+ assert not any(
+ name == "plotly" or name.startswith("plotly.") for name in imported_modules
+ )
+
+
+def test_build_curve_figure_spec_with_errors_and_annotations() -> None:
+ """Signal specs must include data, errors, labels, styles, and overlays."""
+ plotly_spec = importlib.import_module("sigima.viz.plotly_spec")
+
+ signal = create_signal(
+ "Measured signal",
+ x=np.array([0.0, 1.0, 2.0]),
+ y=np.array([1.0, 3.0, 2.0]),
+ dx=np.array([0.1, 0.1, 0.1]),
+ dy=np.array([0.2, 0.3, 0.2]),
+ )
+ signal.xlabel = "Time"
+ signal.xunit = "s"
+ signal.ylabel = "Amplitude"
+ signal.yunit = "V"
+ signal.set_graphical_annotations([PointAnnotation(x=1.0, y=3.0)])
+
+ spec = plotly_spec.build_curve_figure_spec(signal)
+
+ assert len(spec["data"]) == 2
+ assert spec["data"][0]["error_x"]["array"] == [0.1, 0.1, 0.1]
+ assert spec["data"][0]["error_y"]["array"] == [0.2, 0.3, 0.2]
+ assert spec["layout"]["xaxis"]["title"]["text"] == "Time (s)"
+ assert spec["layout"]["yaxis"]["title"]["text"] == "Amplitude (V)"
+ json.dumps(spec, allow_nan=False)
+
+
+def test_build_image_figure_spec_with_coordinates_mask_and_annotations() -> None:
+ """Image specs must preserve calibrated coordinates and strict JSON values."""
+ plotly_spec = importlib.import_module("sigima.viz.plotly_spec")
+
+ image = create_image(
+ "Calibrated image",
+ data=np.array([[1.0, np.nan, 3.0], [4.0, 5.0, 6.0]]),
+ )
+ image.xlabel = "X"
+ image.xunit = "mm"
+ image.ylabel = "Y"
+ image.yunit = "mm"
+ image.zlabel = "Value"
+ image.zunit = "a.u."
+ image.set_uniform_coords(dx=0.5, dy=2.0, x0=10.0, y0=20.0)
+ image.roi = create_image_roi("rectangle", [0, 0, 2, 1], indices=True)
+ image.set_graphical_annotations([TextAnnotation(text="origin", x=10.0, y=20.0)])
+
+ spec = plotly_spec.build_image_figure_spec(image)
+
+ assert spec["data"][0]["x"] == [10.0, 10.5, 11.0]
+ assert spec["data"][0]["y"] == [20.0, 22.0]
+ assert spec["data"][0]["z"][0][1] is None
+ assert spec["data"][0]["colorbar"]["title"]["text"] == "Value (a.u.)"
+ assert len(spec["data"]) == 2
+ assert len(spec["layout"]["annotations"]) == 2
+ assert spec["layout"]["annotations"][1]["text"] == "origin"
+ json.dumps(spec, allow_nan=False)
+
+
+def test_raw_arrays_produce_strict_json_specs() -> None:
+ """Raw curve and image arrays must be accepted without Sigima objects."""
+ plotly_spec = importlib.import_module("sigima.viz.plotly_spec")
+
+ curve_spec = plotly_spec.build_curve_figure_spec(np.array([1.0, np.inf, 2.0]))
+ image_spec = plotly_spec.build_image_figure_spec(np.arange(6).reshape(2, 3))
+
+ assert curve_spec["data"][0]["y"] == [1.0, None, 2.0]
+ assert image_spec["data"][0]["x"] == [0, 1, 2]
+ json.dumps(curve_spec, allow_nan=False)
+ json.dumps(image_spec, allow_nan=False)
+
+
+def test_roi_and_geometry_overlays_are_portable_json() -> None:
+ """ROI and every GeometryResult kind must produce portable overlays."""
+ plotly_spec = importlib.import_module("sigima.viz.plotly_spec")
+
+ signal = create_signal(
+ "ROI signal",
+ x=np.linspace(0.0, 4.0, 9),
+ y=np.array([0.0, 1.0, 2.0, 1.0, 0.0, -1.0, -2.0, -1.0, 0.0]),
+ )
+ signal.roi = create_signal_roi([1.0, 3.0], title="Signal ROI")
+ image = create_image("ROI image", data=np.arange(100).reshape(10, 10))
+ image.roi = create_image_roi("rectangle", [2.0, 3.0, 4.0, 2.0], title="Image ROI")
+ results = [
+ GeometryResult.from_coords("Point", KindShape.POINT, np.array([[1, 2]])),
+ GeometryResult.from_coords("Marker", KindShape.MARKER, np.array([[2, 3]])),
+ GeometryResult.from_coords(
+ "Rectangle", KindShape.RECTANGLE, np.array([[1, 1, 3, 2]])
+ ),
+ GeometryResult.from_coords("Circle", KindShape.CIRCLE, np.array([[3, 3, 1]])),
+ GeometryResult.from_coords(
+ "Segment", KindShape.SEGMENT, np.array([[0, 0, 4, 4]])
+ ),
+ GeometryResult.from_coords(
+ "Ellipse", KindShape.ELLIPSE, np.array([[4, 4, 2, 1, 0.3]])
+ ),
+ GeometryResult.from_coords(
+ "Polygon", KindShape.POLYGON, np.array([[0, 0, 2, 0, 1, 2]])
+ ),
+ ]
+
+ signal_overlay = plotly_spec.build_signal_roi_overlay(signal)
+ image_overlay = plotly_spec.build_image_roi_overlay(image)
+ geometry_overlay = plotly_spec.build_geometry_overlay(results)
+
+ assert len(signal_overlay["traces"]) == 1
+ assert len(image_overlay["shapes"]) == 1
+ assert len(image_overlay["annotations"]) == 1
+ assert len(geometry_overlay["traces"]) == 2
+ assert len(geometry_overlay["shapes"]) == 7
+ assert len(geometry_overlay["annotations"]) == 7
+ json.dumps(signal_overlay, allow_nan=False)
+ json.dumps(image_overlay, allow_nan=False)
+ json.dumps(geometry_overlay, allow_nan=False)
diff --git a/sigima/tests/viz/viz_api_unit_test.py b/sigima/tests/viz/viz_api_unit_test.py
index 2e0e587..7c24678 100644
--- a/sigima/tests/viz/viz_api_unit_test.py
+++ b/sigima/tests/viz/viz_api_unit_test.py
@@ -26,6 +26,11 @@ def _has_matplotlib() -> bool:
return False
+HAS_MPL = "matplotlib" in sys.modules or _has_matplotlib()
+HAS_PLOTPY = importlib.util.find_spec("plotpy") is not None
+HAS_PLOTLY = importlib.util.find_spec("plotly") is not None
+
+
def get_public_functions(module) -> set[str]:
"""Get all public function names from a module.
@@ -46,8 +51,7 @@ def get_public_functions(module) -> set[str]:
@pytest.mark.skipif(
- ("matplotlib" not in sys.modules and not _has_matplotlib())
- or importlib.util.find_spec("plotpy") is None,
+ not HAS_MPL or not HAS_PLOTPY,
reason="Matplotlib or PlotPy not available",
)
def test_matplotlib_backend_has_all_plotpy_functions():
@@ -78,13 +82,35 @@ def test_matplotlib_backend_has_all_plotpy_functions():
)
+@pytest.mark.skipif(
+ not HAS_PLOTLY or not HAS_PLOTPY,
+ reason="Plotly or PlotPy not available",
+)
+def test_plotly_backend_has_all_plotpy_functions() -> None:
+ """Test that the Plotly backend implements the public PlotPy API."""
+ from sigima.viz import viz_plotly, viz_plotpy
+
+ missing_funcs = get_public_functions(viz_plotpy) - get_public_functions(viz_plotly)
+
+ if missing_funcs:
+ missing_list = "\n - ".join(sorted(missing_funcs))
+ pytest.fail(
+ f"Plotly backend is missing the following functions:\n - {missing_list}"
+ )
+
+
def test_annotation_visibility_parameter_has_backend_parity() -> None:
"""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"))
+ backend_modules = (
+ (HAS_MPL, "sigima.viz.viz_mpl"),
+ (HAS_PLOTLY, "sigima.viz.viz_plotly"),
+ (HAS_PLOTPY, "sigima.viz.viz_plotpy"),
+ )
+ backends = [
+ importlib.import_module(module_name)
+ for is_available, module_name in backend_modules
+ if is_available
+ ]
if not backends:
pytest.skip("No visualization backend available")
@@ -154,20 +180,7 @@ def test_backend_selection_option(monkeypatch):
def test_backend_info_available():
"""Test that backend information is exposed."""
- # Check if any backend is available
- try:
- import matplotlib # noqa: F401 # pylint: disable=unused-import
-
- backend_available = True
- except ImportError:
- try:
- import plotpy # noqa: F401 # pylint: disable=unused-import
-
- backend_available = True
- except ImportError:
- backend_available = False
-
- if not backend_available:
+ if not (HAS_MPL or HAS_PLOTPY or HAS_PLOTLY):
pytest.skip("No visualization backend available")
from sigima import viz
@@ -177,7 +190,7 @@ def test_backend_info_available():
assert hasattr(viz, "BACKEND_NAME")
assert hasattr(viz, "BACKEND_SOURCE")
- assert viz.BACKEND_NAME in ("plotpy", "matplotlib")
+ assert viz.BACKEND_NAME in ("plotpy", "matplotlib", "plotly")
assert viz.BACKEND_SOURCE in ("env", "config", "auto")
diff --git a/sigima/tests/viz/viz_plotly_unit_test.py b/sigima/tests/viz/viz_plotly_unit_test.py
new file mode 100644
index 0000000..a3bac99
--- /dev/null
+++ b/sigima/tests/viz/viz_plotly_unit_test.py
@@ -0,0 +1,64 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Unit tests for the optional Plotly visualization backend."""
+
+from __future__ import annotations
+
+import importlib.util
+
+import numpy as np
+import pytest
+
+from sigima.objects import GeometryResult, KindShape, create_image, create_signal
+from sigima.objects.annotations import CircleAnnotation, TextAnnotation
+
+pytestmark = pytest.mark.skipif(
+ importlib.util.find_spec("plotly") is None,
+ reason="Plotly not installed",
+)
+
+
+def test_curve_and_image_specs_materialize_as_plotly_figures() -> None:
+ """Pure Sigima specs must pass Plotly's runtime schema validation."""
+ plotly_spec = importlib.import_module("sigima.viz.plotly_spec")
+ viz_plotly = importlib.import_module("sigima.viz.viz_plotly")
+
+ signal = create_signal(
+ "Signal", x=np.arange(4, dtype=float), y=np.array([1.0, 3.0, 2.0, 4.0])
+ )
+ signal.set_graphical_annotations([CircleAnnotation(cx=2.0, cy=2.0, radius=0.5)])
+ image = create_image("Image", data=np.arange(12, dtype=float).reshape(3, 4))
+ image.set_graphical_annotations([TextAnnotation(text="Peak", x=2.0, y=1.0)])
+ result = GeometryResult.from_coords(
+ "Detected point", KindShape.POINT, np.array([[2.0, 1.0]])
+ )
+
+ curve_figure = viz_plotly.figure_from_spec(
+ plotly_spec.build_curve_figure_spec(signal)
+ )
+ image_figure = viz_plotly.figure_from_spec(
+ plotly_spec.build_image_figure_spec(image, results=result)
+ )
+
+ assert len(curve_figure.data) == 1
+ assert len(curve_figure.layout.shapes) == 1
+ assert len(image_figure.data) == 2
+ assert len(image_figure.layout.annotations) == 2
+
+
+def test_view_curves_uses_plotly_show(monkeypatch) -> None:
+ """The public Plotly viewer must materialize and display its figure."""
+ go = importlib.import_module("plotly.graph_objects")
+ viz_plotly = importlib.import_module("sigima.viz.viz_plotly")
+
+ shown = []
+
+ def record_figure(figure) -> None:
+ shown.append(figure)
+
+ monkeypatch.setattr(go.Figure, "show", record_figure)
+
+ viz_plotly.view_curves(np.array([1.0, 2.0, 1.0]))
+
+ assert len(shown) == 1
+ assert list(shown[0].data[0].y) == [1.0, 2.0, 1.0]
diff --git a/sigima/viz/__init__.py b/sigima/viz/__init__.py
index 818e0b3..e7b25cc 100644
--- a/sigima/viz/__init__.py
+++ b/sigima/viz/__init__.py
@@ -9,7 +9,7 @@
- Data analysis in Jupyter notebooks
- Quick visual inspection of processing results
-The module automatically selects between PlotPy and Matplotlib backends based on
+The module selects between PlotPy, Matplotlib, and Plotly backends based on
availability and configuration settings.
The backend selection follows this priority:
@@ -21,6 +21,7 @@
- "auto": Try PlotPy first, fall back to Matplotlib
- "plotpy": Use PlotPy (raise ImportError if not available)
- "matplotlib": Use Matplotlib (raise ImportError if not available)
+- "plotly": Use Plotly (raise ImportError if not available)
Module exports:
- BACKEND_NAME: Name of the selected backend ("plotpy" or "matplotlib")
@@ -224,7 +225,7 @@ def _select_backend() -> tuple[str, str]:
Returns:
Tuple of (backend_name, source) where:
- - backend_name: "plotpy" or "matplotlib"
+ - backend_name: "plotpy", "matplotlib", or "plotly"
- source: How the backend was selected ("env", "config", "auto")
Raises:
@@ -235,7 +236,7 @@ def _select_backend() -> tuple[str, str]:
# Priority 1: Environment variable
env_backend = os.environ.get("SIGIMA_VIZ_BACKEND", "").lower()
- if env_backend in ("plotpy", "matplotlib", "auto"):
+ if env_backend in ("plotpy", "matplotlib", "plotly", "auto"):
requested = env_backend
source = "env"
else:
@@ -272,6 +273,17 @@ def _select_backend() -> tuple[str, str]:
"Install with: pip install matplotlib"
) from exc
+ elif requested == "plotly":
+ try:
+ import plotly # noqa: F401
+
+ return ("plotly", source)
+ except ImportError as exc:
+ raise ImportError(
+ "Plotly backend requested but Plotly is not installed. "
+ "Install with: pip install 'sigima[plotly]'"
+ ) from exc
+
else: # "auto"
# Try PlotPy first
try:
@@ -334,6 +346,8 @@ def _initialize_backend():
_BACKEND_MODULE = importlib.import_module(".viz_plotpy", package=__name__)
elif _BACKEND_NAME == "matplotlib":
_BACKEND_MODULE = importlib.import_module(".viz_mpl", package=__name__)
+ elif _BACKEND_NAME == "plotly":
+ _BACKEND_MODULE = importlib.import_module(".viz_plotly", package=__name__)
finally:
_INITIALIZING = False
@@ -359,7 +373,7 @@ def __getattr__(name: str):
def _placeholder(*args, **kwargs):
raise ImportError(
f"Function '{name}' requires a visualization backend. "
- "Please install either PlotPy or Matplotlib."
+ "Please install PlotPy, Matplotlib, or Plotly."
)
_placeholder.__name__ = name
diff --git a/sigima/viz/annotation_plotly.py b/sigima/viz/annotation_plotly.py
new file mode 100644
index 0000000..f723b77
--- /dev/null
+++ b/sigima/viz/annotation_plotly.py
@@ -0,0 +1,488 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Plotly JSON renderer for canonical graphical annotations."""
+
+from __future__ import annotations
+
+import math
+from typing import Any
+
+from sigima.objects.annotations import (
+ Axis,
+ CircleAnnotation,
+ CursorAnnotation,
+ CursorOrientation,
+ EllipseAnnotation,
+ GraphicalAnnotation,
+ PointAnnotation,
+ PolygonAnnotation,
+ PolylineAnnotation,
+ RangeAnnotation,
+ RectangleAnnotation,
+ SegmentAnnotation,
+ TextAnchor,
+ TextAnnotation,
+)
+
+__all__ = [
+ "annotation_to_plotly_spec",
+ "annotations_to_plotly_spec",
+]
+
+
+_ANCHORS = {
+ TextAnchor.TOP_LEFT: ("left", "top"),
+ TextAnchor.TOP: ("center", "top"),
+ TextAnchor.TOP_RIGHT: ("right", "top"),
+ TextAnchor.LEFT: ("left", "middle"),
+ TextAnchor.CENTER: ("center", "middle"),
+ TextAnchor.RIGHT: ("right", "middle"),
+ TextAnchor.BOTTOM_LEFT: ("left", "bottom"),
+ TextAnchor.BOTTOM: ("center", "bottom"),
+ TextAnchor.BOTTOM_RIGHT: ("right", "bottom"),
+}
+
+_DASH_STYLES = {
+ "-": "solid",
+ "solid": "solid",
+ "--": "dash",
+ "dash": "dash",
+ "dashed": "dash",
+ ":": "dot",
+ "dot": "dot",
+ "dotted": "dot",
+ "-.": "dashdot",
+ "dashdot": "dashdot",
+}
+
+
+def _empty_spec() -> dict[str, list[dict[str, Any]]]:
+ """Return an empty Plotly overlay specification."""
+ return {"traces": [], "shapes": [], "annotations": []}
+
+
+def _color_with_opacity(color: str | None, opacity: float) -> str:
+ """Return a Plotly color preserving opacity for hexadecimal colors."""
+ if color is None:
+ return "rgba(0,0,0,0)"
+ if opacity == 1.0:
+ return color
+ value = color.lstrip("#")
+ if len(value) in (3, 4):
+ value = "".join(character * 2 for character in value)
+ if len(value) in (6, 8):
+ try:
+ red, green, blue = (
+ int(value[index : index + 2], 16) for index in (0, 2, 4)
+ )
+ except ValueError:
+ return color
+ source_alpha = int(value[6:8], 16) / 255 if len(value) == 8 else 1.0
+ return f"rgba({red},{green},{blue},{opacity * source_alpha:.6g})"
+ return color
+
+
+def _dash_style(dash: str | tuple[float, ...]) -> str:
+ """Return a Plotly-compatible line dash value."""
+ if isinstance(dash, str):
+ return _DASH_STYLES.get(dash, dash)
+ return ",".join(f"{value:g}px" for value in dash)
+
+
+def _line_spec(annotation: GraphicalAnnotation) -> dict[str, Any]:
+ """Return a Plotly line specification."""
+ stroke = annotation.style.stroke
+ return {
+ "color": _color_with_opacity(stroke.color, stroke.opacity),
+ "width": stroke.width,
+ "dash": _dash_style(stroke.dash),
+ }
+
+
+def _shape_spec(annotation: GraphicalAnnotation, **geometry: Any) -> dict[str, Any]:
+ """Return a styled Plotly shape specification."""
+ return {
+ **geometry,
+ "line": _line_spec(annotation),
+ "fillcolor": _color_with_opacity(
+ annotation.style.fill.color, annotation.style.fill.opacity
+ ),
+ "editable": not annotation.locked,
+ "visible": annotation.visible,
+ "name": annotation.title or annotation.kind.value,
+ }
+
+
+def _path(points: list[tuple[float, float]], closed: bool) -> str:
+ """Return a Plotly SVG path using data coordinates."""
+ commands = [f"M {points[0][0]:.12g},{points[0][1]:.12g}"]
+ commands.extend(f"L {x:.12g},{y:.12g}" for x, y in points[1:])
+ if closed:
+ commands.append("Z")
+ return " ".join(commands)
+
+
+def _rotated_points(
+ center_x: float,
+ center_y: float,
+ points: list[tuple[float, float]],
+ angle: float,
+) -> list[tuple[float, float]]:
+ """Rotate points around a center by an angle in radians."""
+ cosine = math.cos(angle)
+ sine = math.sin(angle)
+ return [
+ (
+ center_x + x * cosine - y * sine,
+ center_y + x * sine + y * cosine,
+ )
+ for x, y in points
+ ]
+
+
+def _rectangle_shape(annotation: RectangleAnnotation) -> dict[str, Any]:
+ """Return a Plotly shape for a possibly rotated rectangle."""
+ half_width = annotation.width / 2
+ half_height = annotation.height / 2
+ if annotation.angle == 0.0:
+ return _shape_spec(
+ annotation,
+ type="rect",
+ x0=annotation.x - half_width,
+ y0=annotation.y - half_height,
+ x1=annotation.x + half_width,
+ y1=annotation.y + half_height,
+ )
+ points = _rotated_points(
+ annotation.x,
+ annotation.y,
+ [
+ (-half_width, -half_height),
+ (half_width, -half_height),
+ (half_width, half_height),
+ (-half_width, half_height),
+ ],
+ annotation.angle,
+ )
+ return _shape_spec(annotation, type="path", path=_path(points, closed=True))
+
+
+def _ellipse_shape(annotation: EllipseAnnotation) -> dict[str, Any]:
+ """Return a Plotly shape for a possibly rotated ellipse."""
+ if annotation.angle == 0.0:
+ return _shape_spec(
+ annotation,
+ type="circle",
+ x0=annotation.cx - annotation.radius_x,
+ y0=annotation.cy - annotation.radius_y,
+ x1=annotation.cx + annotation.radius_x,
+ y1=annotation.cy + annotation.radius_y,
+ )
+ points = []
+ for index in range(72):
+ angle = 2 * math.pi * index / 72
+ points.append(
+ (
+ annotation.radius_x * math.cos(angle),
+ annotation.radius_y * math.sin(angle),
+ )
+ )
+ rotated = _rotated_points(annotation.cx, annotation.cy, points, annotation.angle)
+ return _shape_spec(annotation, type="path", path=_path(rotated, closed=True))
+
+
+def _styled_text(annotation: GraphicalAnnotation, text: str) -> str:
+ """Return text decorated with Plotly-supported HTML style tags."""
+ if annotation.style.text.italic:
+ text = f"{text}"
+ if annotation.style.text.bold:
+ text = f"{text}"
+ return text
+
+
+def _text_spec(
+ annotation: GraphicalAnnotation,
+ text: str,
+ x: float,
+ y: float,
+ anchor: TextAnchor,
+ offset: tuple[float, float],
+ xref: str = "x",
+ yref: str = "y",
+) -> dict[str, Any]:
+ """Return a styled Plotly text annotation specification."""
+ xanchor, yanchor = _ANCHORS[anchor]
+ text_style = annotation.style.text
+ font = {
+ "size": text_style.size,
+ "color": text_style.color,
+ }
+ if text_style.family is not None:
+ font["family"] = text_style.family
+ return {
+ "text": _styled_text(annotation, text),
+ "x": x,
+ "y": y,
+ "xref": xref,
+ "yref": yref,
+ "xanchor": xanchor,
+ "yanchor": yanchor,
+ "xshift": offset[0],
+ "yshift": offset[1],
+ "showarrow": False,
+ "font": font,
+ "bgcolor": _color_with_opacity(
+ text_style.background_color, text_style.background_opacity
+ ),
+ "visible": annotation.visible,
+ "captureevents": not annotation.locked,
+ }
+
+
+def _label_location(
+ annotation: GraphicalAnnotation,
+) -> tuple[float, float, str, str] | None:
+ """Return the Plotly position and references for an attached label."""
+ if isinstance(annotation, PointAnnotation):
+ location = (annotation.x, annotation.y, "x", "y")
+ elif isinstance(annotation, SegmentAnnotation):
+ location = (
+ (annotation.x0 + annotation.x1) / 2,
+ (annotation.y0 + annotation.y1) / 2,
+ "x",
+ "y",
+ )
+ elif isinstance(annotation, RectangleAnnotation):
+ location = (annotation.x, annotation.y, "x", "y")
+ elif isinstance(annotation, (CircleAnnotation, EllipseAnnotation)):
+ location = (annotation.cx, annotation.cy, "x", "y")
+ elif isinstance(annotation, (PolylineAnnotation, PolygonAnnotation)):
+ location = (
+ sum(point[0] for point in annotation.points) / len(annotation.points),
+ sum(point[1] for point in annotation.points) / len(annotation.points),
+ "x",
+ "y",
+ )
+ elif isinstance(annotation, CursorAnnotation):
+ if annotation.orientation == CursorOrientation.CROSSHAIR:
+ assert isinstance(annotation.position, tuple)
+ location = (*annotation.position, "x", "y")
+ else:
+ assert isinstance(annotation.position, float)
+ if annotation.orientation == CursorOrientation.VERTICAL:
+ location = (annotation.position, 1.0, "x", "paper")
+ else:
+ location = (1.0, annotation.position, "paper", "y")
+ elif isinstance(annotation, RangeAnnotation):
+ center = (annotation.start + annotation.end) / 2
+ if annotation.axis == Axis.X:
+ location = (center, 1.0, "x", "paper")
+ else:
+ location = (1.0, center, "paper", "y")
+ else:
+ location = None
+ return location
+
+
+def _label_spec(annotation: GraphicalAnnotation) -> dict[str, Any] | None:
+ """Return an attached label specification when visible."""
+ label = annotation.label
+ if label is None or not label.visible or not label.text:
+ return None
+ location = _label_location(annotation)
+ if location is None:
+ return None
+ x, y, xref, yref = location
+ return _text_spec(
+ annotation,
+ label.text,
+ x,
+ y,
+ label.anchor,
+ label.offset,
+ xref=xref,
+ yref=yref,
+ )
+
+
+def annotation_to_plotly_spec(
+ annotation: GraphicalAnnotation,
+) -> dict[str, list[dict[str, Any]]]:
+ """Convert one canonical annotation to a Plotly JSON overlay specification."""
+ spec = _empty_spec()
+ if not annotation.visible:
+ return spec
+
+ if isinstance(annotation, PointAnnotation):
+ marker = annotation.style.marker
+ marker_color = marker.color or annotation.style.stroke.color
+ trace_marker: dict[str, Any] = {
+ "symbol": "circle" if marker.symbol == "none" else marker.symbol,
+ "size": marker.size,
+ "color": _color_with_opacity(
+ marker_color,
+ 0.0 if marker.symbol == "none" else annotation.style.stroke.opacity,
+ ),
+ "line": {
+ "color": _color_with_opacity(
+ annotation.style.stroke.color,
+ annotation.style.stroke.opacity,
+ ),
+ "width": annotation.style.stroke.width,
+ },
+ }
+ spec["traces"].append(
+ {
+ "type": "scatter",
+ "mode": "markers",
+ "x": [annotation.x],
+ "y": [annotation.y],
+ "marker": trace_marker,
+ "name": annotation.title or annotation.kind.value,
+ "showlegend": False,
+ "meta": {"annotation_id": annotation.id},
+ }
+ )
+ elif isinstance(annotation, SegmentAnnotation):
+ spec["shapes"].append(
+ _shape_spec(
+ annotation,
+ type="line",
+ x0=annotation.x0,
+ y0=annotation.y0,
+ x1=annotation.x1,
+ y1=annotation.y1,
+ )
+ )
+ elif isinstance(annotation, RectangleAnnotation):
+ spec["shapes"].append(_rectangle_shape(annotation))
+ elif isinstance(annotation, CircleAnnotation):
+ spec["shapes"].append(
+ _shape_spec(
+ annotation,
+ type="circle",
+ x0=annotation.cx - annotation.radius,
+ y0=annotation.cy - annotation.radius,
+ x1=annotation.cx + annotation.radius,
+ y1=annotation.cy + annotation.radius,
+ )
+ )
+ elif isinstance(annotation, EllipseAnnotation):
+ spec["shapes"].append(_ellipse_shape(annotation))
+ elif isinstance(annotation, PolylineAnnotation):
+ spec["shapes"].append(
+ _shape_spec(
+ annotation,
+ type="path",
+ path=_path(list(annotation.points), closed=False),
+ )
+ )
+ elif isinstance(annotation, PolygonAnnotation):
+ spec["shapes"].append(
+ _shape_spec(
+ annotation,
+ type="path",
+ path=_path(list(annotation.points), closed=True),
+ )
+ )
+ elif isinstance(annotation, TextAnnotation):
+ reference = "paper" if annotation.coordinate_space.value == "axes" else "x"
+ y_reference = "paper" if reference == "paper" else "y"
+ spec["annotations"].append(
+ _text_spec(
+ annotation,
+ annotation.text,
+ annotation.x,
+ annotation.y,
+ annotation.anchor,
+ annotation.offset,
+ xref=reference,
+ yref=y_reference,
+ )
+ )
+ elif isinstance(annotation, CursorAnnotation):
+ positions: list[dict[str, Any]] = []
+ if annotation.orientation in (
+ CursorOrientation.VERTICAL,
+ CursorOrientation.CROSSHAIR,
+ ):
+ x_position = (
+ annotation.position[0]
+ if isinstance(annotation.position, tuple)
+ else annotation.position
+ )
+ positions.append(
+ {
+ "type": "line",
+ "x0": x_position,
+ "x1": x_position,
+ "y0": 0.0,
+ "y1": 1.0,
+ "xref": "x",
+ "yref": "paper",
+ }
+ )
+ if annotation.orientation in (
+ CursorOrientation.HORIZONTAL,
+ CursorOrientation.CROSSHAIR,
+ ):
+ y_position = (
+ annotation.position[1]
+ if isinstance(annotation.position, tuple)
+ else annotation.position
+ )
+ positions.append(
+ {
+ "type": "line",
+ "x0": 0.0,
+ "x1": 1.0,
+ "y0": y_position,
+ "y1": y_position,
+ "xref": "paper",
+ "yref": "y",
+ }
+ )
+ spec["shapes"].extend(
+ _shape_spec(annotation, **position) for position in positions
+ )
+ elif isinstance(annotation, RangeAnnotation):
+ if annotation.axis == Axis.X:
+ geometry = {
+ "type": "rect",
+ "x0": annotation.start,
+ "x1": annotation.end,
+ "y0": 0.0,
+ "y1": 1.0,
+ "xref": "x",
+ "yref": "paper",
+ }
+ else:
+ geometry = {
+ "type": "rect",
+ "x0": 0.0,
+ "x1": 1.0,
+ "y0": annotation.start,
+ "y1": annotation.end,
+ "xref": "paper",
+ "yref": "y",
+ }
+ spec["shapes"].append(_shape_spec(annotation, **geometry))
+ else: # pragma: no cover - protected by the closed model hierarchy
+ raise TypeError(f"Unsupported annotation type: {type(annotation).__name__}")
+
+ label = _label_spec(annotation)
+ if label is not None:
+ spec["annotations"].append(label)
+ return spec
+
+
+def annotations_to_plotly_spec(
+ annotations: list[GraphicalAnnotation],
+) -> dict[str, list[dict[str, Any]]]:
+ """Convert canonical annotations in deterministic layer order."""
+ spec = _empty_spec()
+ for annotation in sorted(annotations, key=lambda item: item.z_index):
+ converted = annotation_to_plotly_spec(annotation)
+ for key, values in spec.items():
+ values.extend(converted[key])
+ return spec
diff --git a/sigima/viz/plotly_spec.py b/sigima/viz/plotly_spec.py
new file mode 100644
index 0000000..6f64f70
--- /dev/null
+++ b/sigima/viz/plotly_spec.py
@@ -0,0 +1,735 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Dependency-free builders for Plotly-compatible JSON figure specifications."""
+
+from __future__ import annotations
+
+import math
+from typing import Any
+
+import numpy as np
+
+from sigima.objects import (
+ CircularROI,
+ GeometryResult,
+ ImageObj,
+ KindShape,
+ PolygonalROI,
+ RectangularROI,
+ SignalObj,
+)
+from sigima.viz.annotation_plotly import annotations_to_plotly_spec
+
+__all__ = [
+ "build_curve_figure_spec",
+ "build_geometry_overlay",
+ "build_image_figure_spec",
+ "build_image_roi_overlay",
+ "build_signal_roi_overlay",
+ "merge_plotly_overlay",
+]
+
+
+PLOTLY_COLORS = (
+ "#1f77b4",
+ "#d62728",
+ "#2ca02c",
+ "#ff7f0e",
+ "#9467bd",
+ "#8c564b",
+ "#e377c2",
+ "#7f7f7f",
+ "#bcbd22",
+)
+PLOTLY_DASHES = ("solid", "dash", "dashdot", "dot")
+MASK_OPACITY = 0.35
+ROI_FILL_ALPHA = 0.35
+ROI_FILL_COLORS = (
+ "#1f77b4",
+ "#ff7f0e",
+ "#2ca02c",
+ "#d62728",
+ "#9467bd",
+ "#8c564b",
+ "#e377c2",
+ "#7f7f7f",
+ "#bcbd22",
+ "#17becf",
+)
+
+_DASH_STYLES = {
+ "SolidLine": "solid",
+ "DashLine": "dash",
+ "DashDotLine": "dashdot",
+ "DashDotDotLine": "dot",
+ "-": "solid",
+ "--": "dash",
+ "-.": "dashdot",
+ ":": "dot",
+}
+
+
+def _json_value(value: Any) -> Any:
+ """Return a strict JSON-compatible copy of a NumPy-derived value."""
+ if isinstance(value, np.generic):
+ value = value.item()
+ if isinstance(value, complex):
+ value = abs(value)
+ if isinstance(value, float) and not math.isfinite(value):
+ return None
+ if isinstance(value, list):
+ return [_json_value(item) for item in value]
+ if isinstance(value, tuple):
+ return [_json_value(item) for item in value]
+ return value
+
+
+def _array_to_json(array: Any) -> list[Any]:
+ """Convert an array-like value to strict JSON-compatible nested lists."""
+ values = np.asarray(array)
+ if np.iscomplexobj(values):
+ values = np.abs(values)
+ return _json_value(values.tolist())
+
+
+def _format_axis_title(label: str | None, unit: str | None) -> str:
+ """Return an axis title with an optional parenthesized unit."""
+ label = label or ""
+ if not unit:
+ return label
+ return f"{label} ({unit})" if label else f"({unit})"
+
+
+def _metadata_option(obj: SignalObj | ImageObj, name: str, default: Any) -> Any:
+ """Read an object metadata option without mutating its defaults."""
+ return obj.metadata.get(f"__{name}", default)
+
+
+def _line_style(obj: SignalObj, index: int) -> dict[str, Any]:
+ """Return a Plotly line style for a signal object."""
+ color = PLOTLY_COLORS[index % len(PLOTLY_COLORS)]
+ dash = PLOTLY_DASHES[(index // len(PLOTLY_COLORS)) % len(PLOTLY_DASHES)]
+ metadata = obj.metadata
+ color = metadata.get("color", color)
+ dash = _DASH_STYLES.get(
+ metadata.get("linestyle", dash), metadata.get("linestyle", dash)
+ )
+ return {
+ "color": color,
+ "dash": dash,
+ "width": metadata.get("linewidth", 1),
+ }
+
+
+def _color_with_alpha(color: str, alpha: float) -> str:
+ """Return an RGBA Plotly color for a hexadecimal color when possible."""
+ value = color.lstrip("#")
+ if len(value) == 6:
+ try:
+ red, green, blue = (
+ int(value[index : index + 2], 16) for index in (0, 2, 4)
+ )
+ except ValueError:
+ return color
+ return f"rgba({red},{green},{blue},{alpha:.6g})"
+ return color
+
+
+def merge_plotly_overlay(
+ figure_spec: dict[str, Any], overlay: dict[str, list[dict[str, Any]]]
+) -> dict[str, Any]:
+ """Return a figure specification containing an additional Plotly overlay."""
+ merged = {
+ **figure_spec,
+ "data": list(figure_spec.get("data", [])),
+ "layout": dict(figure_spec.get("layout", {})),
+ }
+ merged["data"].extend(overlay.get("traces", []))
+ for key in ("shapes", "annotations"):
+ values = list(merged["layout"].get(key, []))
+ values.extend(overlay.get(key, []))
+ if values:
+ merged["layout"][key] = values
+ return merged
+
+
+def _empty_overlay() -> dict[str, list[dict[str, Any]]]:
+ """Return an empty Plotly overlay specification."""
+ return {"traces": [], "shapes": [], "annotations": []}
+
+
+def _overlay_label(text: str, x: float, y: float) -> dict[str, Any]:
+ """Return a compact Plotly label for an ROI or geometry overlay."""
+ return {
+ "text": text,
+ "x": x,
+ "y": y,
+ "showarrow": False,
+ "font": {"size": 10, "color": "#333333"},
+ "bgcolor": "rgba(255,255,255,0.8)",
+ "bordercolor": "rgba(80,80,80,0.5)",
+ "borderwidth": 1,
+ "borderpad": 3,
+ "xanchor": "left",
+ "yanchor": "bottom",
+ }
+
+
+def _plotly_path(points: list[tuple[float, float]]) -> str:
+ """Return a closed Plotly path for physical-coordinate points."""
+ path = "M " + " L ".join(f"{x:.12g},{y:.12g}" for x, y in points)
+ return f"{path} Z"
+
+
+def build_signal_roi_overlay(obj: SignalObj) -> dict[str, list[dict[str, Any]]]:
+ """Build Plotly overlays for signal ROIs, clipped to the signal curve."""
+ overlay = _empty_overlay()
+ if obj.roi is None or obj.roi.is_empty():
+ return overlay
+ x_values = np.asarray(obj.x, dtype=float)
+ y_values = np.asarray(obj.y, dtype=float)
+ finite = np.isfinite(x_values) & np.isfinite(y_values)
+ x_values = x_values[finite]
+ y_values = y_values[finite]
+ if x_values.size >= 2:
+ order = np.argsort(x_values)
+ x_values = x_values[order]
+ y_values = y_values[order]
+ for index, roi in enumerate(obj.roi):
+ start, end = roi.get_physical_coords(obj)
+ start, end = sorted((float(start), float(end)))
+ color = ROI_FILL_COLORS[index % len(ROI_FILL_COLORS)]
+ label = roi.title or f"ROI {index + 1}"
+ if x_values.size >= 2:
+ clipped_start = max(float(x_values[0]), start)
+ clipped_end = min(float(x_values[-1]), end)
+ if clipped_end > clipped_start:
+ mask = (x_values >= clipped_start) & (x_values <= clipped_end)
+ roi_x = np.concatenate(([clipped_start], x_values[mask], [clipped_end]))
+ roi_y = np.concatenate(
+ (
+ [float(np.interp(clipped_start, x_values, y_values))],
+ y_values[mask],
+ [float(np.interp(clipped_end, x_values, y_values))],
+ )
+ )
+ overlay["traces"].append(
+ {
+ "type": "scatter",
+ "mode": "none",
+ "x": _array_to_json(roi_x),
+ "y": _array_to_json(roi_y),
+ "fill": "tozeroy",
+ "fillcolor": _color_with_alpha(color, ROI_FILL_ALPHA),
+ "name": label,
+ "hoverinfo": "name",
+ "showlegend": False,
+ }
+ )
+ continue
+ overlay["shapes"].append(
+ {
+ "type": "rect",
+ "x0": start,
+ "x1": end,
+ "y0": 0,
+ "y1": 1,
+ "yref": "paper",
+ "line": {"color": color, "width": 1},
+ "fillcolor": _color_with_alpha(color, ROI_FILL_ALPHA),
+ "name": label,
+ }
+ )
+ return overlay
+
+
+def build_image_roi_overlay(obj: ImageObj) -> dict[str, list[dict[str, Any]]]:
+ """Build Plotly shape and label overlays for image ROIs."""
+ overlay = _empty_overlay()
+ if obj.roi is None or obj.roi.is_empty():
+ return overlay
+ for index, roi in enumerate(obj.roi):
+ label = roi.title or f"ROI {index + 1}"
+ line = {"color": "#ff3333", "width": 2}
+ if isinstance(roi, RectangularROI):
+ x0, y0, x1, y1 = roi.get_bounding_box(obj)
+ shape = {
+ "type": "rect",
+ "x0": x0,
+ "y0": y0,
+ "x1": x1,
+ "y1": y1,
+ "line": line,
+ "name": label,
+ }
+ label_x, label_y = (x0 + x1) / 2, y0
+ elif isinstance(roi, CircularROI):
+ center_x, center_y, radius = roi.get_physical_coords(obj)
+ shape = {
+ "type": "circle",
+ "x0": center_x - radius,
+ "y0": center_y - radius,
+ "x1": center_x + radius,
+ "y1": center_y + radius,
+ "line": line,
+ "name": label,
+ }
+ label_x, label_y = center_x, center_y - radius
+ elif isinstance(roi, PolygonalROI):
+ coords = np.asarray(roi.get_physical_coords(obj)).reshape(-1, 2)
+ points = [(float(x), float(y)) for x, y in coords]
+ shape = {
+ "type": "path",
+ "path": _plotly_path(points),
+ "line": line,
+ "name": label,
+ }
+ label_x = float(coords[:, 0].mean())
+ label_y = float(coords[:, 1].min())
+ else: # pragma: no cover - protected by the closed ROI hierarchy
+ raise TypeError(f"Unsupported image ROI type: {type(roi).__name__}")
+ overlay["shapes"].append(shape)
+ overlay["annotations"].append(_overlay_label(label, label_x, label_y))
+ return overlay
+
+
+def _ellipse_points(coords: np.ndarray) -> list[tuple[float, float]]:
+ """Return physical-coordinate points for a GeometryResult ellipse."""
+ center_x, center_y, radius_x, radius_y, angle = coords
+ cosine = math.cos(angle)
+ sine = math.sin(angle)
+ points = []
+ for index in range(72):
+ phase = 2 * math.pi * index / 72
+ x_value = radius_x * math.cos(phase)
+ y_value = radius_y * math.sin(phase)
+ points.append(
+ (
+ float(center_x + x_value * cosine - y_value * sine),
+ float(center_y + x_value * sine + y_value * cosine),
+ )
+ )
+ return points
+
+
+def build_geometry_overlay(
+ results: GeometryResult | list[GeometryResult] | tuple[GeometryResult, ...],
+) -> dict[str, list[dict[str, Any]]]:
+ """Build Plotly overlays from one or more geometry results."""
+ overlay = _empty_overlay()
+ result_list = list(results) if isinstance(results, (list, tuple)) else [results]
+ line = {"color": "#ffff00", "width": 2, "dash": "dash"}
+ for result in result_list:
+ for coords in result.coords:
+ label_x: float | None = None
+ label_y: float | None = None
+ if result.kind == KindShape.POINT:
+ x0, y0 = coords
+ overlay["traces"].append(
+ {
+ "type": "scatter",
+ "mode": "markers",
+ "x": [float(x0)],
+ "y": [float(y0)],
+ "marker": {
+ "color": "#ffff00",
+ "size": 8,
+ "line": {"color": "#000000", "width": 1},
+ },
+ "showlegend": False,
+ "name": result.title,
+ }
+ )
+ label_x, label_y = float(x0), float(y0)
+ elif result.kind == KindShape.MARKER:
+ x0, y0 = coords
+ overlay["traces"].append(
+ {
+ "type": "scatter",
+ "mode": "markers",
+ "x": [float(x0)],
+ "y": [float(y0)],
+ "marker": {
+ "symbol": "cross",
+ "color": "#ffff00",
+ "size": 12,
+ },
+ "showlegend": False,
+ "name": result.title,
+ }
+ )
+ overlay["shapes"].extend(
+ (
+ {
+ "type": "line",
+ "x0": float(x0),
+ "x1": float(x0),
+ "y0": 0,
+ "y1": 1,
+ "yref": "paper",
+ "line": line,
+ },
+ {
+ "type": "line",
+ "x0": 0,
+ "x1": 1,
+ "xref": "paper",
+ "y0": float(y0),
+ "y1": float(y0),
+ "line": line,
+ },
+ )
+ )
+ label_x, label_y = float(x0), float(y0)
+ elif result.kind == KindShape.RECTANGLE:
+ x0, y0, width, height = coords
+ overlay["shapes"].append(
+ {
+ "type": "rect",
+ "x0": float(x0),
+ "y0": float(y0),
+ "x1": float(x0 + width),
+ "y1": float(y0 + height),
+ "line": line,
+ }
+ )
+ label_x, label_y = float(x0), float(y0)
+ elif result.kind == KindShape.CIRCLE:
+ center_x, center_y, radius = coords
+ overlay["shapes"].append(
+ {
+ "type": "circle",
+ "x0": float(center_x - radius),
+ "y0": float(center_y - radius),
+ "x1": float(center_x + radius),
+ "y1": float(center_y + radius),
+ "line": line,
+ }
+ )
+ label_x, label_y = float(center_x + radius), float(center_y)
+ elif result.kind == KindShape.SEGMENT:
+ x0, y0, x1, y1 = coords
+ overlay["shapes"].append(
+ {
+ "type": "line",
+ "x0": float(x0),
+ "y0": float(y0),
+ "x1": float(x1),
+ "y1": float(y1),
+ "line": line,
+ }
+ )
+ label_x, label_y = float((x0 + x1) / 2), float((y0 + y1) / 2)
+ elif result.kind == KindShape.ELLIPSE:
+ points = _ellipse_points(coords)
+ overlay["shapes"].append(
+ {"type": "path", "path": _plotly_path(points), "line": line}
+ )
+ label_x, label_y = float(coords[0]), float(coords[1])
+ elif result.kind == KindShape.POLYGON:
+ finite_coords = coords[np.isfinite(coords)]
+ points = [(float(x), float(y)) for x, y in finite_coords.reshape(-1, 2)]
+ overlay["shapes"].append(
+ {"type": "path", "path": _plotly_path(points), "line": line}
+ )
+ label_x = sum(point[0] for point in points) / len(points)
+ label_y = sum(point[1] for point in points) / len(points)
+ else: # pragma: no cover - protected by KindShape validation
+ raise TypeError(f"Unsupported geometry kind: {result.kind}")
+ if label_x is not None and label_y is not None:
+ overlay["annotations"].append(
+ _overlay_label(result.title, label_x, label_y)
+ )
+ return overlay
+
+
+def _normalize_curve_items(
+ data_or_objs: list[Any] | tuple[Any, Any] | SignalObj | np.ndarray,
+) -> list[Any]:
+ """Return a normalized list of curve inputs."""
+ if isinstance(data_or_objs, (SignalObj, np.ndarray)):
+ return [data_or_objs]
+ if isinstance(data_or_objs, tuple) and len(data_or_objs) == 2:
+ return [data_or_objs]
+ if isinstance(data_or_objs, list):
+ return data_or_objs
+ raise TypeError(f"Unsupported curve data type: {type(data_or_objs).__name__}")
+
+
+def _signal_trace(obj: SignalObj, index: int) -> dict[str, Any]:
+ """Return the main Plotly trace for a signal object."""
+ line = _line_style(obj, index)
+ x_values = _array_to_json(obj.x)
+ y_values = _array_to_json(obj.y)
+ trace: dict[str, Any] = {
+ "type": "scatter",
+ "mode": "lines",
+ "x": x_values,
+ "y": y_values,
+ "line": line,
+ "name": obj.title or f"Signal {index + 1}",
+ }
+ curve_style = _metadata_option(obj, "curvestyle", "Lines")
+ if curve_style == "Sticks":
+ baseline = float(_metadata_option(obj, "baseline", 0.0))
+ stick_x: list[float | None] = []
+ stick_y: list[float | None] = []
+ for x_value, y_value in zip(x_values, y_values):
+ stick_x.extend((x_value, x_value, None))
+ stick_y.extend((baseline, y_value, None))
+ trace["x"] = stick_x
+ trace["y"] = stick_y
+ trace["line"] = {"color": line["color"], "width": line["width"]}
+ elif curve_style == "Steps":
+ trace["line"] = {**line, "shape": "hv"}
+ else:
+ if obj.dx is not None:
+ trace["error_x"] = {
+ "type": "data",
+ "array": _array_to_json(obj.dx),
+ "visible": True,
+ }
+ if obj.dy is not None:
+ trace["error_y"] = {
+ "type": "data",
+ "array": _array_to_json(obj.dy),
+ "visible": True,
+ }
+ shade = float(_metadata_option(obj, "shade", 0.0))
+ if shade > 0.0:
+ trace["fill"] = "tozeroy"
+ trace["fillcolor"] = _color_with_alpha(line["color"], shade)
+ return trace
+
+
+def _curve_axes(
+ first_obj: SignalObj | None,
+ xlabel: str | None,
+ ylabel: str | None,
+ xunit: str | None,
+ yunit: str | None,
+) -> tuple[dict[str, Any], dict[str, Any]]:
+ """Return Plotly X and Y axis specifications for curves."""
+ x_axis: dict[str, Any] = {
+ "title": {
+ "text": _format_axis_title(
+ xlabel or getattr(first_obj, "xlabel", None),
+ xunit or getattr(first_obj, "xunit", None),
+ )
+ },
+ "showgrid": True,
+ "gridcolor": "rgba(0,0,0,0.1)",
+ }
+ y_axis: dict[str, Any] = {
+ "title": {
+ "text": _format_axis_title(
+ ylabel or getattr(first_obj, "ylabel", None),
+ yunit or getattr(first_obj, "yunit", None),
+ )
+ },
+ "showgrid": True,
+ "gridcolor": "rgba(0,0,0,0.1)",
+ }
+ if first_obj is not None:
+ if first_obj.xscalelog:
+ x_axis["type"] = "log"
+ if first_obj.yscalelog:
+ y_axis["type"] = "log"
+ if not first_obj.autoscale:
+ x_axis["range"] = [first_obj.xscalemin, first_obj.xscalemax]
+ y_axis["range"] = [first_obj.yscalemin, first_obj.yscalemax]
+ return x_axis, y_axis
+
+
+def build_curve_figure_spec(
+ data_or_objs: list[Any] | tuple[Any, Any] | SignalObj | np.ndarray,
+ title: str | None = None,
+ xlabel: str | None = None,
+ ylabel: str | None = None,
+ xunit: str | None = None,
+ yunit: str | None = None,
+ show_roi: bool = True,
+ show_annotations: bool = True,
+ width: int = 640,
+ height: int = 480,
+) -> dict[str, Any]:
+ """Build a Plotly-compatible JSON figure specification for curves."""
+ items = _normalize_curve_items(data_or_objs)
+ traces: list[dict[str, Any]] = []
+ first_obj = next((item for item in items if isinstance(item, SignalObj)), None)
+ overlays = []
+ for index, item in enumerate(items):
+ if isinstance(item, SignalObj):
+ traces.append(_signal_trace(item, index))
+ if show_roi:
+ overlays.append(build_signal_roi_overlay(item))
+ if show_annotations:
+ overlays.append(
+ annotations_to_plotly_spec(item.get_graphical_annotations())
+ )
+ elif isinstance(item, tuple) and len(item) == 2:
+ traces.append(
+ {
+ "type": "scatter",
+ "mode": "lines",
+ "x": _array_to_json(item[0]),
+ "y": _array_to_json(item[1]),
+ "line": {
+ "color": PLOTLY_COLORS[index % len(PLOTLY_COLORS)],
+ "dash": PLOTLY_DASHES[
+ (index // len(PLOTLY_COLORS)) % len(PLOTLY_DASHES)
+ ],
+ },
+ "name": f"Curve {index + 1}",
+ }
+ )
+ elif isinstance(item, np.ndarray):
+ traces.append(
+ {
+ "type": "scatter",
+ "mode": "lines",
+ "x": list(range(len(item))),
+ "y": _array_to_json(item),
+ "line": {
+ "color": PLOTLY_COLORS[index % len(PLOTLY_COLORS)],
+ "dash": PLOTLY_DASHES[
+ (index // len(PLOTLY_COLORS)) % len(PLOTLY_DASHES)
+ ],
+ },
+ "name": f"Curve {index + 1}",
+ }
+ )
+ else:
+ raise TypeError(f"Unsupported curve data type: {type(item).__name__}")
+
+ x_axis, y_axis = _curve_axes(first_obj, xlabel, ylabel, xunit, yunit)
+ figure: dict[str, Any] = {
+ "data": traces,
+ "layout": {
+ "title": {"text": title or (first_obj.title if first_obj else "Curves")},
+ "xaxis": x_axis,
+ "yaxis": y_axis,
+ "template": "plotly_white",
+ "showlegend": len(items) > 1,
+ "width": width,
+ "height": height,
+ },
+ }
+ for overlay in overlays:
+ figure = merge_plotly_overlay(figure, overlay)
+ return figure
+
+
+def _image_coords(obj: ImageObj) -> tuple[list[Any], list[Any]]:
+ """Return JSON-compatible pixel-center coordinates for an image object."""
+ if not obj.is_uniform_coords:
+ return _array_to_json(obj.xcoords), _array_to_json(obj.ycoords)
+ row_count, column_count = obj.data.shape[:2]
+ x_coords = obj.x0 + np.arange(column_count) * obj.dx
+ y_coords = obj.y0 + np.arange(row_count) * obj.dy
+ return _array_to_json(x_coords), _array_to_json(y_coords)
+
+
+def build_image_figure_spec(
+ data_or_obj: ImageObj | np.ndarray,
+ *,
+ title: str | None = None,
+ xlabel: str | None = None,
+ ylabel: str | None = None,
+ zlabel: str | None = None,
+ xunit: str | None = None,
+ yunit: str | None = None,
+ zunit: str | None = None,
+ results: GeometryResult | list[GeometryResult] | None = None,
+ show_roi: bool = True,
+ show_annotations: bool = True,
+ colormap: str | None = None,
+ width: int = 640,
+ height: int = 520,
+) -> dict[str, Any]:
+ """Build a Plotly-compatible JSON figure specification for one image."""
+ if isinstance(data_or_obj, ImageObj):
+ obj = data_or_obj
+ data = obj.data
+ x_coords, y_coords = _image_coords(obj)
+ image_title = title or obj.title or "Image"
+ x_title = _format_axis_title(xlabel or obj.xlabel, xunit or obj.xunit)
+ y_title = _format_axis_title(ylabel or obj.ylabel, yunit or obj.yunit)
+ z_title = _format_axis_title(zlabel or obj.zlabel, zunit or obj.zunit)
+ colorscale = colormap or _metadata_option(obj, "colormap", "viridis")
+ if _metadata_option(obj, "invert_colormap", False):
+ colorscale = f"{colorscale}_r"
+ elif isinstance(data_or_obj, np.ndarray):
+ obj = None
+ data = data_or_obj
+ row_count, column_count = data.shape[:2]
+ x_coords = list(range(column_count))
+ y_coords = list(range(row_count))
+ image_title = title or "Image"
+ x_title = _format_axis_title(xlabel, xunit)
+ y_title = _format_axis_title(ylabel, yunit)
+ z_title = _format_axis_title(zlabel, zunit)
+ colorscale = colormap or "viridis"
+ else:
+ raise TypeError(f"Unsupported image data type: {type(data_or_obj).__name__}")
+
+ heatmap: dict[str, Any] = {
+ "type": "heatmap",
+ "z": _array_to_json(data),
+ "x": x_coords,
+ "y": y_coords,
+ "colorscale": colorscale,
+ }
+ if z_title:
+ heatmap["colorbar"] = {"title": {"text": z_title}}
+ figure: dict[str, Any] = {
+ "data": [heatmap],
+ "layout": {
+ "title": {"text": image_title},
+ "xaxis": {"title": {"text": x_title}},
+ "yaxis": {
+ "title": {"text": y_title},
+ "autorange": "reversed",
+ "scaleanchor": "x",
+ "constrain": "domain",
+ },
+ "template": "plotly_white",
+ "showlegend": False,
+ "width": width,
+ "height": height,
+ },
+ }
+ if obj is not None:
+ if obj.xscalelog:
+ figure["layout"]["xaxis"]["type"] = "log"
+ if obj.yscalelog:
+ figure["layout"]["yaxis"]["type"] = "log"
+ if not obj.autoscale:
+ figure["layout"]["xaxis"]["range"] = [obj.xscalemin, obj.xscalemax]
+ figure["layout"]["yaxis"]["range"] = [obj.yscalemax, obj.yscalemin]
+ if obj.maskdata is not None:
+ mask = np.where(obj.maskdata, 1.0, np.nan)
+ figure["data"].append(
+ {
+ "type": "heatmap",
+ "z": _array_to_json(mask),
+ "x": x_coords,
+ "y": y_coords,
+ "colorscale": [
+ [0.0, f"rgba(255,0,0,{MASK_OPACITY})"],
+ [1.0, f"rgba(255,0,0,{MASK_OPACITY})"],
+ ],
+ "showscale": False,
+ "hoverinfo": "skip",
+ }
+ )
+ if show_roi:
+ figure = merge_plotly_overlay(figure, build_image_roi_overlay(obj))
+ if show_annotations:
+ figure = merge_plotly_overlay(
+ figure,
+ annotations_to_plotly_spec(obj.get_graphical_annotations()),
+ )
+ if results is not None:
+ figure = merge_plotly_overlay(figure, build_geometry_overlay(results))
+ return figure
diff --git a/sigima/viz/viz_plotly.py b/sigima/viz/viz_plotly.py
new file mode 100644
index 0000000..80445cf
--- /dev/null
+++ b/sigima/viz/viz_plotly.py
@@ -0,0 +1,566 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Interactive Plotly visualization backend for Sigima."""
+
+from __future__ import annotations
+
+import math
+from typing import Any
+
+import numpy as np
+
+from sigima.objects import ImageObj, SignalObj
+from sigima.viz.plotly_spec import (
+ ROI_FILL_COLORS,
+ _array_to_json,
+ _format_axis_title,
+ build_curve_figure_spec,
+ build_image_figure_spec,
+)
+
+__all__ = [
+ "create_circle",
+ "create_contour_shapes",
+ "create_cursor",
+ "create_curve",
+ "create_image",
+ "create_label",
+ "create_marker",
+ "create_range",
+ "create_segment",
+ "figure_from_spec",
+ "roi_color_for_index",
+ "view_curve_items",
+ "view_curves",
+ "view_curves_and_images",
+ "view_image_items",
+ "view_images",
+ "view_images_side_by_side",
+]
+
+
+def figure_from_spec(spec: dict[str, Any]):
+ """Materialize a Plotly figure from a dependency-free JSON specification."""
+ import plotly.graph_objects as go # pylint: disable=import-outside-toplevel
+
+ return go.Figure(spec)
+
+
+def roi_color_for_index(index: int) -> str:
+ """Return the ROI fill color for the given index."""
+ return ROI_FILL_COLORS[index % len(ROI_FILL_COLORS)]
+
+
+def _show_spec(spec: dict[str, Any]) -> None:
+ """Display a Plotly figure specification using the configured renderer."""
+ figure_from_spec(spec).show()
+
+
+def create_curve(
+ x: np.ndarray, y: np.ndarray, title: str | None = None
+) -> dict[str, Any]:
+ """Create a Plotly scatter trace from X and Y data."""
+ return {
+ "type": "scatter",
+ "mode": "lines",
+ "x": _array_to_json(x),
+ "y": _array_to_json(y),
+ "name": title or "Curve",
+ }
+
+
+def create_image(
+ data: np.ndarray,
+ title: str | None = None,
+ interpolation: str = "linear",
+ colormap: str | None = None,
+ alpha_function: str | None = None,
+ xdata: list[float] | None = None,
+ ydata: list[float] | None = None,
+ **kwargs,
+) -> dict[str, Any]:
+ """Create a Plotly heatmap trace from image data."""
+ del interpolation, alpha_function, kwargs
+ row_count, column_count = data.shape[:2]
+ return {
+ "type": "heatmap",
+ "z": _array_to_json(data),
+ "x": xdata if xdata is not None else list(range(column_count)),
+ "y": ydata if ydata is not None else list(range(row_count)),
+ "colorscale": colormap or "viridis",
+ "name": title or "Image",
+ }
+
+
+def create_contour_shapes(coords: np.ndarray, shape) -> list[dict[str, Any]]:
+ """Create Plotly shapes for detected contour coordinates."""
+ shape_name = getattr(shape, "name", str(shape)).lower()
+ shapes = []
+ for values in coords:
+ if shape_name == "circle":
+ center_x, center_y, radius = values
+ shapes.append(
+ create_circle(float(center_x), float(center_y), float(radius))
+ )
+ elif shape_name == "ellipse":
+ center_x, center_y, radius_x, radius_y, angle = values
+ points = []
+ for index in range(72):
+ phase = 2 * math.pi * index / 72
+ cosine = math.cos(angle)
+ sine = math.sin(angle)
+ x_value = radius_x * math.cos(phase)
+ y_value = radius_y * math.sin(phase)
+ points.append(
+ (
+ center_x + x_value * cosine - y_value * sine,
+ center_y + x_value * sine + y_value * cosine,
+ )
+ )
+ path = "M " + " L ".join(f"{x:.12g},{y:.12g}" for x, y in points)
+ shapes.append(
+ {
+ "type": "path",
+ "path": f"{path} Z",
+ "line": {"color": "#ff9933", "width": 2},
+ }
+ )
+ else:
+ points = list(zip(values[::2], values[1::2]))
+ path = "M " + " L ".join(f"{x:.12g},{y:.12g}" for x, y in points)
+ shapes.append(
+ {
+ "type": "path",
+ "path": f"{path} Z",
+ "line": {"color": "#ff9933", "width": 2},
+ }
+ )
+ return shapes
+
+
+def create_circle(
+ xc: float, yc: float, r: float, label: str | None = None, **kwargs
+) -> dict[str, Any]:
+ """Create a Plotly circle shape."""
+ del kwargs
+ return {
+ "type": "circle",
+ "x0": xc - r,
+ "y0": yc - r,
+ "x1": xc + r,
+ "y1": yc + r,
+ "line": {"color": "#ff9933", "width": 2},
+ "name": label or "Circle",
+ }
+
+
+def create_segment(
+ x0: float,
+ y0: float,
+ x1: float,
+ y1: float,
+ label: str | None = None,
+ **kwargs,
+) -> dict[str, Any]:
+ """Create a Plotly line shape."""
+ del kwargs
+ return {
+ "type": "line",
+ "x0": x0,
+ "y0": y0,
+ "x1": x1,
+ "y1": y1,
+ "line": {"color": "#33ff00", "width": 3},
+ "name": label or "Segment",
+ }
+
+
+def create_cursor(
+ orientation: str,
+ position: float | tuple[float, float],
+ label: str,
+) -> list[dict[str, Any]]:
+ """Create horizontal, vertical, or crosshair Plotly cursor shapes."""
+ shapes = []
+ line = {"color": "#a7ff33", "width": 2, "dash": "dash"}
+ if orientation in ("v", "x"):
+ x_position = position[0] if isinstance(position, tuple) else position
+ shapes.append(
+ {
+ "type": "line",
+ "x0": x_position,
+ "x1": x_position,
+ "y0": 0,
+ "y1": 1,
+ "yref": "paper",
+ "line": line,
+ "name": label,
+ }
+ )
+ if orientation in ("h", "x"):
+ y_position = position[1] if isinstance(position, tuple) else position
+ shapes.append(
+ {
+ "type": "line",
+ "x0": 0,
+ "x1": 1,
+ "xref": "paper",
+ "y0": y_position,
+ "y1": y_position,
+ "line": line,
+ "name": label,
+ }
+ )
+ if not shapes:
+ raise ValueError("Orientation must be 'h', 'v', or 'x'")
+ return shapes
+
+
+def create_range(
+ orientation: str,
+ pos_min: float,
+ pos_max: float,
+ title: str,
+ **kwargs,
+) -> dict[str, Any]:
+ """Create a horizontal or vertical Plotly range shape."""
+ del kwargs
+ if orientation == "h":
+ geometry = {
+ "x0": pos_min,
+ "x1": pos_max,
+ "y0": 0,
+ "y1": 1,
+ "yref": "paper",
+ }
+ elif orientation == "v":
+ geometry = {
+ "x0": 0,
+ "x1": 1,
+ "xref": "paper",
+ "y0": pos_min,
+ "y1": pos_max,
+ }
+ else:
+ raise ValueError("Orientation must be 'h' or 'v'")
+ return {
+ "type": "rect",
+ **geometry,
+ "line": {"color": "#ff9933", "width": 1},
+ "fillcolor": "rgba(255,153,51,0.2)",
+ "name": title,
+ }
+
+
+def create_label(text: str) -> dict[str, Any]:
+ """Create a Plotly text annotation in the upper-left plot corner."""
+ return {
+ "text": text,
+ "x": 0,
+ "y": 1,
+ "xref": "paper",
+ "yref": "paper",
+ "xanchor": "left",
+ "yanchor": "top",
+ "showarrow": False,
+ }
+
+
+def create_marker(x: float, y: float, title: str | None = None) -> dict[str, Any]:
+ """Create a Plotly point marker trace."""
+ return {
+ "type": "scatter",
+ "mode": "markers",
+ "x": [x],
+ "y": [y],
+ "marker": {"symbol": "cross", "size": 10, "color": "yellow"},
+ "name": title or "Marker",
+ "showlegend": False,
+ }
+
+
+def _flatten_items(items: list[Any]) -> list[dict[str, Any]]:
+ """Flatten lists returned by multi-shape creation helpers."""
+ flattened = []
+ for item in items:
+ if isinstance(item, list):
+ flattened.extend(_flatten_items(item))
+ else:
+ flattened.append(item)
+ return flattened
+
+
+def _items_spec(items: list[Any], title: str | None, image: bool) -> dict[str, Any]:
+ """Build a Plotly figure spec from low-level creation helper results."""
+ data = []
+ shapes = []
+ annotations = []
+ for item in _flatten_items(items):
+ item_type = item.get("type")
+ if item_type in ("scatter", "heatmap", "image"):
+ data.append(item)
+ elif item_type in ("circle", "line", "path", "rect"):
+ shapes.append(item)
+ elif "text" in item:
+ annotations.append(item)
+ else:
+ raise TypeError(f"Unsupported Plotly item: {item!r}")
+ layout: dict[str, Any] = {
+ "title": {"text": title or ("Images" if image else "Curves")},
+ "template": "plotly_white",
+ }
+ if shapes:
+ layout["shapes"] = shapes
+ if annotations:
+ layout["annotations"] = annotations
+ if image:
+ layout["yaxis"] = {
+ "autorange": "reversed",
+ "scaleanchor": "x",
+ "constrain": "domain",
+ }
+ return {"data": data, "layout": layout}
+
+
+def view_curve_items(
+ items: list[Any],
+ name: str | None = None,
+ title: str | None = None,
+ xlabel: str | None = None,
+ ylabel: str | None = None,
+ xunit: str | None = None,
+ yunit: str | None = None,
+ add_legend: bool = True,
+ datetime_format: str | None = None,
+ object_name: str = "",
+) -> None:
+ """Display low-level Plotly curve items."""
+ del name, datetime_format, object_name
+ spec = _items_spec(items, title, image=False)
+ spec["layout"].update(
+ {
+ "xaxis": {"title": {"text": _format_axis_title(xlabel, xunit)}},
+ "yaxis": {"title": {"text": _format_axis_title(ylabel, yunit)}},
+ "showlegend": add_legend,
+ }
+ )
+ _show_spec(spec)
+
+
+def view_image_items(
+ items: list[Any],
+ name: str | None = None,
+ title: str | None = None,
+ xlabel: str | None = None,
+ ylabel: str | None = None,
+ zlabel: str | None = None,
+ xunit: str | None = None,
+ yunit: str | None = None,
+ zunit: str | None = None,
+ show_itemlist: bool = False,
+ object_name: str = "",
+) -> None:
+ """Display low-level Plotly image items."""
+ del name, zlabel, zunit, show_itemlist, object_name
+ spec = _items_spec(items, title, image=True)
+ spec["layout"]["xaxis"] = {"title": {"text": _format_axis_title(xlabel, xunit)}}
+ spec["layout"]["yaxis"].update(
+ {"title": {"text": _format_axis_title(ylabel, yunit)}}
+ )
+ _show_spec(spec)
+
+
+def view_curves(
+ data_or_objs,
+ name: str | None = None,
+ title: str | None = None,
+ xlabel: str | None = None,
+ ylabel: str | None = None,
+ xunit: str | None = None,
+ yunit: str | None = None,
+ show_roi: bool = True,
+ show_annotations: bool = True,
+ object_name: str = "",
+ **kwargs,
+) -> None:
+ """Display signals or curve arrays in an interactive Plotly figure."""
+ del name, object_name
+ spec = build_curve_figure_spec(
+ data_or_objs,
+ title=title,
+ xlabel=xlabel,
+ ylabel=ylabel,
+ xunit=xunit,
+ yunit=yunit,
+ show_roi=show_roi,
+ show_annotations=show_annotations,
+ width=kwargs.get("width", 640),
+ height=kwargs.get("height", 480),
+ )
+ _show_spec(spec)
+
+
+# Keep positional compatibility with the PlotPy and Matplotlib backends.
+# pylint: disable=too-many-positional-arguments
+def view_images(
+ data_or_objs,
+ name: str | None = None,
+ title: str | None = None,
+ xlabel: str | None = None,
+ ylabel: str | None = None,
+ zlabel: str | None = None,
+ xunit: str | None = None,
+ yunit: str | None = None,
+ zunit: str | None = None,
+ results=None,
+ show_roi: bool = True,
+ show_annotations: bool = True,
+ object_name: str = "",
+ **kwargs,
+) -> None:
+ """Display images in interactive Plotly figures."""
+ del name, object_name
+ if isinstance(data_or_objs, (list, tuple)):
+ view_images_side_by_side(
+ list(data_or_objs),
+ title=title,
+ results=results,
+ show_roi=show_roi,
+ show_annotations=show_annotations,
+ **kwargs,
+ )
+ return
+ spec = build_image_figure_spec(
+ data_or_objs,
+ title=title,
+ xlabel=xlabel,
+ ylabel=ylabel,
+ zlabel=zlabel,
+ xunit=xunit,
+ yunit=yunit,
+ zunit=zunit,
+ results=results,
+ show_roi=show_roi,
+ show_annotations=show_annotations,
+ colormap=kwargs.get("colormap"),
+ width=kwargs.get("width", 640),
+ height=kwargs.get("height", 520),
+ )
+ _show_spec(spec)
+
+
+def view_images_side_by_side(
+ images: list[np.ndarray | ImageObj],
+ titles: list[str] | None = None,
+ share_axes: bool = True,
+ rows: int | None = None,
+ maximized: bool = False,
+ title: str | None = None,
+ results=None,
+ show_roi: bool = True,
+ show_annotations: bool = True,
+ object_name: str = "",
+ **kwargs,
+) -> None:
+ """Display images in a grid of Plotly subplots."""
+ del maximized, object_name
+ from plotly.subplots import make_subplots # pylint: disable=import-outside-toplevel
+
+ row_count = rows or max(1, math.ceil(len(images) / min(4, len(images))))
+ column_count = math.ceil(len(images) / row_count)
+ subplot_titles = titles or [
+ image.title if isinstance(image, ImageObj) else f"Image {index + 1}"
+ for index, image in enumerate(images)
+ ]
+ figure = make_subplots(
+ rows=row_count,
+ cols=column_count,
+ subplot_titles=subplot_titles,
+ shared_xaxes=share_axes,
+ shared_yaxes=share_axes,
+ )
+ if results is None:
+ result_items = [None] * len(images)
+ elif isinstance(results, (list, tuple)) and len(results) == len(images):
+ result_items = list(results)
+ else:
+ result_items = [results] * len(images)
+ for index, image in enumerate(images):
+ row = index // column_count + 1
+ column = index % column_count + 1
+ spec = build_image_figure_spec(
+ image,
+ results=result_items[index],
+ show_roi=show_roi,
+ show_annotations=show_annotations,
+ colormap=kwargs.get("colormap"),
+ )
+ for trace in spec["data"]:
+ figure.add_trace(trace, row=row, col=column)
+ for shape in spec["layout"].get("shapes", []):
+ figure.add_shape(shape, row=row, col=column)
+ for annotation in spec["layout"].get("annotations", []):
+ figure.add_annotation(annotation, row=row, col=column)
+ figure.update_yaxes(
+ autorange="reversed",
+ scaleanchor=f"x{index + 1 if index else ''}",
+ constrain="domain",
+ row=row,
+ col=column,
+ )
+ figure.update_layout(
+ title={"text": title or "Images"},
+ template="plotly_white",
+ width=kwargs.get("width", 640 * column_count),
+ height=kwargs.get("height", 520 * row_count),
+ )
+ figure.show()
+
+
+def view_curves_and_images(
+ data_or_objs,
+ name: str | None = None,
+ title: str | None = None,
+ xlabel: str | None = None,
+ ylabel: str | None = None,
+ zlabel: str | None = None,
+ xunit: str | None = None,
+ yunit: str | None = None,
+ zunit: str | None = None,
+ object_name: str = "",
+ show_annotations: bool = True,
+ **kwargs,
+) -> None:
+ """Display mixed signals and images in successive Plotly figures."""
+ del name, object_name
+ objects = (
+ list(data_or_objs)
+ if isinstance(data_or_objs, (list, tuple))
+ else [data_or_objs]
+ )
+ curves = [obj for obj in objects if isinstance(obj, SignalObj)]
+ images = [obj for obj in objects if isinstance(obj, ImageObj)]
+ if curves:
+ view_curves(
+ curves,
+ title=title,
+ xlabel=xlabel,
+ ylabel=ylabel,
+ xunit=xunit,
+ yunit=yunit,
+ show_annotations=show_annotations,
+ **kwargs,
+ )
+ if images:
+ view_images(
+ images,
+ title=title,
+ xlabel=xlabel,
+ ylabel=ylabel,
+ zlabel=zlabel,
+ xunit=xunit,
+ yunit=yunit,
+ zunit=zunit,
+ show_annotations=show_annotations,
+ **kwargs,
+ )