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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ name: Install and Test on Ubuntu (latest)
on:
push:
branches: [ "main", "develop", "release" ]
# Validate stacked PRs whose base is another feature branch.
pull_request:
branches: [ "main", "develop", "release" ]
workflow_call:
workflow_dispatch:
inputs:
Expand Down Expand Up @@ -51,7 +51,7 @@ jobs:
sudo apt install libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 x11-utils
/sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -screen 0 1920x1200x24 -ac +extension GLX
python -m pip install --upgrade pip
python -m pip install ruff pytest
python -m pip install ruff
if [ "${{ github.ref_name }}" = "develop" ]; then
pip uninstall -y guidata
cd ..
Expand All @@ -63,8 +63,8 @@ jobs:
# Extract dependencies and save to file, then install
python -c "import tomli; f=open('pyproject.toml','rb'); data=tomli.load(f); deps=[d for d in data['project']['dependencies'] if not any(p in d for p in ['guidata'])]; open('deps.txt','w').write('\n'.join(deps))"
pip install -r deps.txt
# Install Sigima without dependencies
pip install --no-deps .
# Install Sigima and its test dependencies, keeping local guidata
pip install ".[test]"
elif [ "${{ github.ref_name }}" = "release" ]; then
pip uninstall -y guidata
cd ..
Expand All @@ -77,11 +77,11 @@ jobs:
# Extract dependencies and save to file, then install
python -c "import tomli; f=open('pyproject.toml','rb'); data=tomli.load(f); deps=[d for d in data['project']['dependencies'] if not any(p in d for p in ['guidata'])]; open('deps.txt','w').write('\n'.join(deps))"
pip install -r deps.txt
# Install Sigima without dependencies
pip install --no-deps .
# Install Sigima and its test dependencies, keeping local guidata
pip install ".[test]"
else
# Install from PyPI normally for main branch
pip install .
# Install Sigima and all dependencies needed by the test suite
pip install ".[test]"
fi
- name: Lint with Ruff
run: ruff check --output-format=github sigima
Expand Down
120 changes: 120 additions & 0 deletions doc/api/annotations.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
:orphan:

.. _api_annotations:

Graphical annotations
=====================

Sigima provides a renderer-independent model for editorial graphics attached to
signals and images. Graphical annotations are distinct from two other concepts:

- a region of interest selects samples or pixels for computation;
- a :class:`~sigima.objects.GeometryResult` stores an analysis result;
- a graphical annotation communicates information to a reader and may be edited
by a consuming application.

Model
-----

The canonical model supports points, segments, oriented rectangles, circles,
oriented ellipses, polylines, polygons, text, axis or crosshair cursors, and
axis ranges. All geometry is expressed in calibrated data coordinates. Text
may instead use normalized axes coordinates, from ``(0, 0)`` at the bottom left
to ``(1, 1)`` at the top right.

Annotations are immutable dataclasses. Their common fields include a stable
UUID, visibility, locking, layer order, title, structured style, optional label,
metadata, and namespaced extensions. Metadata and extensions accept only
JSON-compatible values and are copied into immutable containers.

.. autoclass:: sigima.objects.GraphicalAnnotation
.. autoclass:: sigima.objects.PointAnnotation
.. autoclass:: sigima.objects.SegmentAnnotation
.. autoclass:: sigima.objects.RectangleAnnotation
.. autoclass:: sigima.objects.CircleAnnotation
.. autoclass:: sigima.objects.EllipseAnnotation
.. autoclass:: sigima.objects.PolylineAnnotation
.. autoclass:: sigima.objects.PolygonAnnotation
.. autoclass:: sigima.objects.TextAnnotation
.. autoclass:: sigima.objects.CursorAnnotation
.. autoclass:: sigima.objects.RangeAnnotation

Object API
----------

The typed API is parallel to the historical free-form JSON API. This preserves
application-specific entries while allowing portable annotations to coexist in
the same ``annotations`` field.

.. code-block:: python

from sigima.objects import PointAnnotation, create_signal

signal = create_signal("Annotated signal", [0, 1], [2, 3])
signal.add_graphical_annotation(
PointAnnotation(x=1.0, y=3.0, title="Maximum")
)

annotations = signal.get_graphical_annotations()
signal.set_graphical_annotations(annotations, preserve_opaque=True)

The methods ``get_annotations()`` and ``set_annotations()`` retain their
existing free-form behavior. ``set_graphical_annotations()`` replaces only
canonical entries by default. PlotPy payloads and unknown consumer data remain
unchanged. An entry declaring the canonical format but using an unsupported
version raises an error instead of being silently treated as opaque.

Serialization and files
-----------------------

Each canonical dictionary is marked with ``format: "sigima.annotation"`` and
``version: "1.0"``. The versioned JSON Schema is distributed as
``sigima/objects/annotations/schema-v1.json``. It is independent from the
historical object wrapper version and from the ``.dlabann`` container version.

.. autofunction:: sigima.objects.annotation_to_dict
.. autofunction:: sigima.objects.annotation_from_dict
.. autofunction:: sigima.io.write_graphical_annotations
.. autofunction:: sigima.io.read_graphical_annotations

Canonical annotations survive object copies and the normal ``.h5sig``,
``.h5ima``, and ``.dlabann`` round trips without a renderer dependency.

Transformations
---------------

Pure transformation functions return a new annotation and preserve its UUID
and non-geometric fields. Translation, quarter turns, flips, transposition,
and scaling are also applied by the corresponding image operations. Resizing
does not move annotations because their coordinates are calibrated data values.
Arbitrary image rotation clears canonical annotations, like regions of interest,
when the output coordinate mapping is not reliable; opaque payloads are kept.

.. autofunction:: sigima.objects.translate_annotation
.. autofunction:: sigima.objects.rotate_annotation
.. autofunction:: sigima.objects.flip_annotation_horizontally
.. autofunction:: sigima.objects.flip_annotation_vertically
.. autofunction:: sigima.objects.transpose_annotation
.. autofunction:: sigima.objects.scale_annotation

An exact transform may change the primitive type, for example from a circle to
an ellipse under anisotropic scaling. A transform that cannot be represented
exactly raises :class:`~sigima.objects.AnnotationTransformError`.

PlotPy migration
----------------

The PlotPy backend can display historical ``plotpy_json`` payloads without
rewriting them. Migration to the canonical model is always explicit:

.. code-block:: python

from sigima.viz.annotation_plotpy import migrate_legacy_plotpy_annotations

preview = migrate_legacy_plotpy_annotations(signal, dry_run=True)
if not preview.diagnostics:
report = migrate_legacy_plotpy_annotations(signal)

Known PlotPy annotation types are converted. A malformed payload, an unknown
item class, or a payload containing a partially unsupported item is preserved
and reported. Running migration again is idempotent.
3 changes: 3 additions & 0 deletions doc/api/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ The public Application Programming Interface (API) of Sigima offers a set of fun
* - :mod:`sigima.objects`
- Object model for signals and images (:class:`sigima.objects.SignalObj` and :class:`sigima.objects.ImageObj`), scalar results (:class:`sigima.objects.GeometryResult` and :class:`sigima.objects.TableResult`), and related functions

* - :mod:`sigima.objects.annotations`
- Renderer-independent graphical annotation model, serialization, and transformations (see :doc:`annotations`)

* - :mod:`sigima.proc`
- Computation functions, which operate on signal and image objects (:class:`sigima.objects.SignalObj` or :class:`sigima.objects.ImageObj`) and return signal or image objects, or scalar results (:class:`sigima.objects.GeometryResult` or :class:`sigima.objects.TableResult`).

Expand Down
20 changes: 20 additions & 0 deletions doc/api/viz.rst
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,20 @@ These functions display Sigima objects (:class:`~sigima.objects.SignalObj` and :

.. autofunction:: view_curves_and_images

Canonical annotations
---------------------

Object viewing functions render canonical graphical annotations by default.
Pass ``show_annotations=False`` to hide them independently from regions of
interest. Both backends support all canonical primitives, styles, attached
labels, and layer order. Text may be positioned in data coordinates or in
normalized axes coordinates.

PlotPy also displays valid historical ``plotpy_json`` payloads without changing
the object. Matplotlib ignores those opaque renderer-specific payloads. Use
the explicit migration described in :ref:`api_annotations` to make historical
annotations portable.

Low-Level Viewing Functions
---------------------------

Expand Down Expand Up @@ -153,6 +167,12 @@ The two backends have different capabilities:
* - Geometry results
- ✅ Shape annotations
- ✅ Markers/lines
* - Canonical annotations
- ✅ Native interactive items
- ✅ Read-only artists
* - Historical PlotPy annotations
- ✅ View-only compatibility
- ❌ Opaque payload ignored
* - Linked axes
- ✅ Native
- ✅ via ``sharex``/``sharey``
Expand Down
7 changes: 7 additions & 0 deletions doc/release_notes/release_1.03.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Version 1.3 #

## Sigima Version 1.3.0 ##

### ✨ New features since version 1.2.0 ###

* **Portable graphical annotations**: Signals and images may now carry versioned, renderer-independent points, shapes, text, cursors and axis ranges. Annotations survive Sigima file round trips and supported image transformations, and are displayed consistently by the PlotPy and Matplotlib visualization backends. Existing PlotPy annotations remain readable and can be migrated explicitly while unknown application data is preserved. This implements [Issue #53](https://github.com/DataLab-Platform/Sigima/issues/53).
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ doc = [
"matplotlib",
"opencv-python-headless >= 4.8.1.78",
]
test = ["pytest", "pytest-xvfb"]
test = ["pytest", "pytest-xvfb", "jsonschema >= 4"]
qt = ["qtpy", "PyQt5", "plotpy"]

[tool.setuptools.packages.find]
Expand Down
4 changes: 4 additions & 0 deletions sigima/io/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,12 @@ def read_data(filename: str) -> np.ndarray:

from sigima.io.common.objmeta import (
read_annotations,
read_graphical_annotations,
read_metadata,
read_roi,
read_roi_grid,
write_annotations,
write_graphical_annotations,
write_metadata,
write_roi,
write_roi_grid,
Expand All @@ -106,6 +108,7 @@ def read_data(filename: str) -> np.ndarray:
"ImageIORegistry",
"SignalIORegistry",
"read_annotations",
"read_graphical_annotations",
"read_image",
"read_images",
"read_metadata",
Expand All @@ -114,6 +117,7 @@ def read_data(filename: str) -> np.ndarray:
"read_signal",
"read_signals",
"write_annotations",
"write_graphical_annotations",
"write_image",
"write_images",
"write_metadata",
Expand Down
47 changes: 46 additions & 1 deletion sigima/io/common/objmeta.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,14 @@

from guidata.io import JSONHandler, JSONReader, JSONWriter

from sigima.objects import ImageROI, SignalROI
from sigima.objects import (
GraphicalAnnotation,
ImageROI,
SignalROI,
annotation_from_dict,
annotation_to_dict,
is_graphical_annotation_dict,
)

if TYPE_CHECKING:
from sigima.params import ROIGridParam
Expand Down Expand Up @@ -210,3 +217,41 @@ def read_annotations(filepath: str) -> list[dict[str, Any]]:
json_dict = read_dict(filepath)
_check_tag(json_dict, expected_format="annotations")
return json_dict["annotations"]


def write_graphical_annotations(
filepath: str, annotations: list[GraphicalAnnotation]
) -> None:
"""Write canonical graphical annotations to a ``.dlabann`` JSON file.

Args:
filepath: The file path to write the annotations to.
annotations: Canonical graphical annotations to serialize.

Raises:
TypeError: If annotations is not a list of GraphicalAnnotation objects.
"""
if not isinstance(annotations, list) or not all(
isinstance(item, GraphicalAnnotation) for item in annotations
):
raise TypeError("annotations must be a list of GraphicalAnnotation objects")
write_annotations(filepath, [annotation_to_dict(item) for item in annotations])


def read_graphical_annotations(filepath: str) -> list[GraphicalAnnotation]:
"""Read canonical graphical annotations from a ``.dlabann`` JSON file.

Opaque entries remain available through :func:`read_annotations` and are ignored
by this typed convenience function.

Args:
filepath: The file path to read the annotations from.

Returns:
Canonical graphical annotations in storage order.
"""
return [
annotation_from_dict(item)
for item in read_annotations(filepath)
if is_graphical_annotation_dict(item)
]
Loading