Skip to content

Commit 56c3eab

Browse files
committed
Implement save/load methods for RegionOfInterest objects
1 parent 278d69b commit 56c3eab

File tree

4 files changed

+220
-0
lines changed

4 files changed

+220
-0
lines changed

movement/roi/base.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
from __future__ import annotations
44

5+
import json
56
from abc import ABC, abstractmethod
67
from collections.abc import Callable, Hashable, Sequence
8+
from pathlib import Path
79
from typing import TYPE_CHECKING, Any, Generic, TypeAlias, TypeVar, cast
810

911
import matplotlib.pyplot as plt
@@ -560,3 +562,81 @@ def plot(
560562
if fig is None or ax is None:
561563
fig, ax = plt.subplots(1, 1)
562564
return self._plot(fig, ax, **matplotlib_kwargs)
565+
566+
def to_file(self, path: str | Path) -> None:
567+
"""Save the region of interest to a file.
568+
569+
Parameters
570+
----------
571+
path : str | Path
572+
Path to save the ROI file. The file will be saved in JSON format.
573+
574+
See Also
575+
--------
576+
from_file : Load a region of interest from a file.
577+
578+
Examples
579+
--------
580+
>>> from movement.roi import PolygonOfInterest
581+
>>> roi = PolygonOfInterest([(0, 0), (1, 0), (1, 1)], name="triangle")
582+
>>> roi.to_file("my_roi.json") # doctest: +SKIP
583+
584+
"""
585+
data = {
586+
"name": self._name,
587+
"geometry_wkt": self.region.wkt,
588+
"dimensions": self.dimensions,
589+
"roi_type": self.__class__.__name__,
590+
}
591+
Path(path).write_text(json.dumps(data, indent=2))
592+
593+
@classmethod
594+
def from_file(cls, path: str | Path) -> BaseRegionOfInterest:
595+
"""Load a region of interest from a file.
596+
597+
Parameters
598+
----------
599+
path : str | Path
600+
Path to the ROI file to load. Must be a JSON file saved by
601+
:meth:`to_file`.
602+
603+
Returns
604+
-------
605+
BaseRegionOfInterest
606+
The loaded region of interest object. The specific subclass
607+
(LineOfInterest or PolygonOfInterest) is determined from the file.
608+
609+
Raises
610+
------
611+
FileNotFoundError
612+
If the specified file does not exist.
613+
614+
See Also
615+
--------
616+
to_file : Save a region of interest to a file.
617+
618+
Examples
619+
--------
620+
>>> from movement.roi import PolygonOfInterest
621+
>>> roi = PolygonOfInterest.from_file("my_roi.json") # doctest: +SKIP
622+
623+
"""
624+
file_path = Path(path)
625+
if not file_path.exists():
626+
raise FileNotFoundError(f"ROI file not found: {path}")
627+
628+
data = json.loads(file_path.read_text())
629+
geometry = shapely.from_wkt(data["geometry_wkt"])
630+
631+
# Import here to avoid circular imports
632+
from movement.roi import LineOfInterest, PolygonOfInterest
633+
634+
roi_type = data.get("roi_type", "")
635+
if roi_type == "LineOfInterest" or data["dimensions"] == 1:
636+
return LineOfInterest._from_geometry(
637+
geometry, name=data.get("name")
638+
)
639+
else:
640+
return PolygonOfInterest._from_geometry(
641+
geometry, name=data.get("name")
642+
)

movement/roi/line.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,31 @@ def __init__(
8484
line = shapely.normalize(line)
8585
super().__init__(line, name=name)
8686

87+
@classmethod
88+
def _from_geometry(
89+
cls,
90+
geometry: "shapely.LineString | shapely.LinearRing",
91+
name: str | None = None,
92+
) -> "LineOfInterest":
93+
"""Construct a LineOfInterest from a shapely geometry.
94+
95+
Parameters
96+
----------
97+
geometry : shapely.LineString | shapely.LinearRing
98+
The shapely geometry to construct from.
99+
name : str, optional
100+
Name for the LineOfInterest.
101+
102+
Returns
103+
-------
104+
LineOfInterest
105+
A new LineOfInterest instance.
106+
107+
"""
108+
points = geometry.coords
109+
loop = isinstance(geometry, shapely.LinearRing)
110+
return cls(points=points, loop=loop, name=name)
111+
87112
def _plot(
88113
self, fig: Figure | SubFigure, ax: Axes, **matplotlib_kwargs
89114
) -> tuple[Figure | SubFigure, Axes]:

movement/roi/polygon.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,35 @@ def __init__(
8585
)
8686
super().__init__(geometry=polygon, name=name)
8787

88+
@classmethod
89+
def _from_geometry(
90+
cls,
91+
geometry: shapely.Polygon,
92+
name: str | None = None,
93+
) -> PolygonOfInterest:
94+
"""Construct a PolygonOfInterest from a shapely geometry.
95+
96+
Parameters
97+
----------
98+
geometry : shapely.Polygon
99+
The shapely geometry to construct from.
100+
name : str, optional
101+
Name for the PolygonOfInterest.
102+
103+
Returns
104+
-------
105+
PolygonOfInterest
106+
A new PolygonOfInterest instance.
107+
108+
"""
109+
exterior = geometry.exterior.coords
110+
holes = (
111+
[interior.coords for interior in geometry.interiors]
112+
if geometry.interiors
113+
else None
114+
)
115+
return cls(exterior_boundary=exterior, holes=holes, name=name)
116+
88117
@property
89118
def _default_plot_args(self) -> dict[str, Any]:
90119
return {
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""Tests for saving and loading regions of interest to/from files."""
2+
3+
import json
4+
5+
import pytest
6+
7+
from movement.roi import LineOfInterest, PolygonOfInterest
8+
9+
10+
class TestROISaveLoad:
11+
"""Tests for ROI save/load functionality."""
12+
13+
def test_save_and_load_polygon_roi(self, tmp_path, triangle):
14+
"""Test round-trip save and load for PolygonOfInterest."""
15+
file_path = tmp_path / "triangle.json"
16+
17+
# Save
18+
triangle.to_file(file_path)
19+
20+
# Verify file exists and has correct content
21+
assert file_path.exists()
22+
data = json.loads(file_path.read_text())
23+
assert data["roi_type"] == "PolygonOfInterest"
24+
assert data["dimensions"] == 2
25+
assert data["name"] == "triangle"
26+
assert "geometry_wkt" in data
27+
28+
# Load
29+
loaded = PolygonOfInterest.from_file(file_path)
30+
31+
# Verify loaded ROI matches original
32+
assert loaded.name == triangle.name
33+
assert loaded.dimensions == triangle.dimensions
34+
assert loaded.region.equals(triangle.region)
35+
36+
def test_save_and_load_line_roi(self, tmp_path, segment_of_y_equals_x):
37+
"""Test round-trip save and load for LineOfInterest."""
38+
file_path = tmp_path / "line.json"
39+
40+
# Save
41+
segment_of_y_equals_x.to_file(file_path)
42+
43+
# Verify file exists
44+
assert file_path.exists()
45+
data = json.loads(file_path.read_text())
46+
assert data["roi_type"] == "LineOfInterest"
47+
assert data["dimensions"] == 1
48+
49+
# Load
50+
loaded = LineOfInterest.from_file(file_path)
51+
52+
# Verify loaded ROI matches original
53+
assert loaded.dimensions == segment_of_y_equals_x.dimensions
54+
assert loaded.region.equals(segment_of_y_equals_x.region)
55+
56+
def test_save_and_load_polygon_with_hole(
57+
self, tmp_path, unit_square_with_hole
58+
):
59+
"""Test round-trip for polygon with interior holes."""
60+
file_path = tmp_path / "square_with_hole.json"
61+
62+
# Save
63+
unit_square_with_hole.to_file(file_path)
64+
65+
# Load
66+
loaded = PolygonOfInterest.from_file(file_path)
67+
68+
# Verify holes are preserved
69+
assert loaded.region.equals(unit_square_with_hole.region)
70+
assert len(loaded.holes) == len(unit_square_with_hole.holes)
71+
72+
def test_load_nonexistent_file_raises(self, tmp_path):
73+
"""Test that loading a non-existent file raises FileNotFoundError."""
74+
with pytest.raises(FileNotFoundError, match="ROI file not found"):
75+
PolygonOfInterest.from_file(tmp_path / "nonexistent.json")
76+
77+
def test_save_with_none_name(self, tmp_path, triangle_pts):
78+
"""Test saving an ROI with no name."""
79+
roi = PolygonOfInterest(triangle_pts) # No name provided
80+
file_path = tmp_path / "unnamed.json"
81+
82+
roi.to_file(file_path)
83+
loaded = PolygonOfInterest.from_file(file_path)
84+
85+
# Name should be None in both
86+
assert loaded._name is None

0 commit comments

Comments
 (0)