opencoverage 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. opencoverage-0.2.0/.gitignore +15 -0
  2. opencoverage-0.2.0/LICENSE +21 -0
  3. opencoverage-0.2.0/PKG-INFO +125 -0
  4. opencoverage-0.2.0/README.md +94 -0
  5. opencoverage-0.2.0/config/FireFlyTetracam.ini +28 -0
  6. opencoverage-0.2.0/config/RascalTetracam.ini +27 -0
  7. opencoverage-0.2.0/config/quad_tetracam.ini +27 -0
  8. opencoverage-0.2.0/pyproject.toml +56 -0
  9. opencoverage-0.2.0/src/opencoverage/__init__.py +26 -0
  10. opencoverage-0.2.0/src/opencoverage/cli.py +60 -0
  11. opencoverage-0.2.0/src/opencoverage/geometry/__init__.py +6 -0
  12. opencoverage-0.2.0/src/opencoverage/geometry/map.py +117 -0
  13. opencoverage-0.2.0/src/opencoverage/geometry/transforms.py +43 -0
  14. opencoverage-0.2.0/src/opencoverage/gpu/__init__.py +5 -0
  15. opencoverage-0.2.0/src/opencoverage/gpu/_backend.py +35 -0
  16. opencoverage-0.2.0/src/opencoverage/gpu/optimal_sweep.py +42 -0
  17. opencoverage-0.2.0/src/opencoverage/io/__init__.py +6 -0
  18. opencoverage-0.2.0/src/opencoverage/io/config.py +79 -0
  19. opencoverage-0.2.0/src/opencoverage/io/coords.py +72 -0
  20. opencoverage-0.2.0/src/opencoverage/io/kml.py +50 -0
  21. opencoverage-0.2.0/src/opencoverage/io/mission_planner.py +35 -0
  22. opencoverage-0.2.0/src/opencoverage/io/qgc.py +130 -0
  23. opencoverage-0.2.0/src/opencoverage/io/survey_input.py +21 -0
  24. opencoverage-0.2.0/src/opencoverage/mission_splitter.py +83 -0
  25. opencoverage-0.2.0/src/opencoverage/models.py +134 -0
  26. opencoverage-0.2.0/src/opencoverage/parameters.py +132 -0
  27. opencoverage-0.2.0/src/opencoverage/patterns/__init__.py +15 -0
  28. opencoverage-0.2.0/src/opencoverage/patterns/back_forth.py +57 -0
  29. opencoverage-0.2.0/src/opencoverage/patterns/base.py +74 -0
  30. opencoverage-0.2.0/src/opencoverage/patterns/following_wind.py +33 -0
  31. opencoverage-0.2.0/src/opencoverage/patterns/long_edge.py +35 -0
  32. opencoverage-0.2.0/src/opencoverage/patterns/optimal_sweep.py +38 -0
  33. opencoverage-0.2.0/src/opencoverage/planner.py +216 -0
  34. opencoverage-0.2.0/tests/test_back_forth.py +34 -0
  35. opencoverage-0.2.0/tests/test_config.py +14 -0
  36. opencoverage-0.2.0/tests/test_long_edge.py +26 -0
  37. opencoverage-0.2.0/tests/test_mission_splitter.py +22 -0
  38. opencoverage-0.2.0/tests/test_optimal_sweep.py +32 -0
  39. opencoverage-0.2.0/tests/test_parameters.py +55 -0
  40. opencoverage-0.2.0/tests/test_planner.py +56 -0
  41. opencoverage-0.2.0/tests/test_survey_input.py +19 -0
@@ -0,0 +1,15 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ *.egg-info/
5
+ .eggs/
6
+ dist/
7
+ build/
8
+ .venv/
9
+ venv/
10
+ .pytest_cache/
11
+ .ruff_cache/
12
+ .mypy_cache/
13
+ .coverage
14
+ htmlcov/
15
+ *.so
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2018-2026 J. Irving Vasquez-Gomez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.4
2
+ Name: opencoverage
3
+ Version: 0.2.0
4
+ Summary: Coverage path planning for UAV aerial surveying
5
+ Project-URL: Homepage, https://github.com/irvingvasquez/opencoverage
6
+ Project-URL: Repository, https://github.com/irvingvasquez/opencoverage
7
+ Project-URL: Documentation, https://github.com/irvingvasquez/opencoverage#readme
8
+ Project-URL: Issues, https://github.com/irvingvasquez/opencoverage/issues
9
+ Author-email: Juan Irving Vasquez <jvasquezg@ipn.mx>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: coverage-path-planning,drone,precision-agriculture,survey,uav
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Scientific/Engineering :: GIS
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: numpy>=1.24
23
+ Requires-Dist: pyproj>=3.6
24
+ Requires-Dist: shapely>=2.0
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=7.0; extra == 'dev'
27
+ Requires-Dist: ruff>=0.4; extra == 'dev'
28
+ Provides-Extra: gpu
29
+ Requires-Dist: cupy-cuda12x>=13.0; extra == 'gpu'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # OpenCoverage
33
+
34
+ Coverage path planning for UAV aerial surveying. OpenCoverage computes waypoint
35
+ flight paths that fully cover a terrain polygon, with support for camera overlap,
36
+ flight constraints, and multiple sweep patterns.
37
+
38
+ Python reimplementation of the original UAV Planning library, using Shapely
39
+ instead of CGAL. CPU execution is the default; optional GPU acceleration via CuPy
40
+ is available for the optimal sweep search.
41
+
42
+ ## Features
43
+
44
+ - **Search patterns**: back-and-forth, long-edge, optimal sweep, following-wind
45
+ - **Planning targets**: spatial resolution, cruise velocity, or fixed altitude
46
+ - **Mission splitting** by UAV flight-time limits
47
+ - **Input formats**: KML polygons, Mission Planner `.poly` files, INI configuration
48
+ - **Output**: QGroundControl WPL 120 waypoint files
49
+ - **Optional GPU**: CuPy acceleration for optimal sweep angle search
50
+
51
+ ## Installation
52
+
53
+ ```bash
54
+ pip install -e ".[dev]"
55
+ ```
56
+
57
+ Optional GPU support:
58
+
59
+ ```bash
60
+ pip install -e ".[gpu]"
61
+ ```
62
+
63
+ ## Quick start
64
+
65
+ ```python
66
+ from shapely.geometry import Polygon
67
+
68
+ import opencoverage as oc
69
+
70
+ polygon = Polygon([(0, 0), (100, 0), (100, 80), (0, 80)])
71
+
72
+ mission = oc.plan(
73
+ polygon=polygon,
74
+ uav=oc.UAV(min_height=70, max_height=500, survey_velocity=17),
75
+ camera=oc.PinholeCamera(
76
+ pixel_size_mm=0.0032,
77
+ focal_length_mm=8.43,
78
+ sensor_width_mm=6.55,
79
+ sensor_height_mm=4.92,
80
+ capture_rate_s=3,
81
+ ),
82
+ pattern="optimal_sweep",
83
+ lateral_overlap=0.8,
84
+ forward_overlap=0.7,
85
+ target="velocity",
86
+ target_value=17.0,
87
+ )
88
+
89
+ print(len(mission.path), "waypoints at", mission.flight_height, "m")
90
+ ```
91
+
92
+ ## Command line
93
+
94
+ ```bash
95
+ opencoverage examples/sample_field.kml config/quad_tetracam.ini mission.txt
96
+ opencoverage examples/sample_field.poly config/quad_tetracam.ini mission.txt --split
97
+ opencoverage examples/sample_field.kml config/quad_tetracam.ini mission.txt --gpu
98
+ ```
99
+
100
+ ## Patterns
101
+
102
+ | Pattern | Description |
103
+ |---------|-------------|
104
+ | `back_forth` | Standard boustrophedon sweep |
105
+ | `long_edge` | Sweep lines perpendicular to the longest polygon edge |
106
+ | `optimal_sweep` | Minimize horizontal sweep width over rotation angles |
107
+ | `following_wind` | Align sweeps with meteorological wind direction |
108
+
109
+ ## Project layout
110
+
111
+ ```
112
+ src/opencoverage/
113
+ ├── models.py # UAV, camera, mission dataclasses
114
+ ├── parameters.py # Survey parameter formulas
115
+ ├── planner.py # High-level plan() API
116
+ ├── mission_splitter.py # Flight-time mission splitting
117
+ ├── geometry/ # Shapely map and transforms
118
+ ├── patterns/ # Coverage search patterns
119
+ ├── io/ # Config, KML, QGC, coordinate conversion
120
+ └── gpu/ # Optional CuPy kernels
121
+ ```
122
+
123
+ ## License
124
+
125
+ MIT License. Copyright (c) J. Irving Vasquez-Gomez.
@@ -0,0 +1,94 @@
1
+ # OpenCoverage
2
+
3
+ Coverage path planning for UAV aerial surveying. OpenCoverage computes waypoint
4
+ flight paths that fully cover a terrain polygon, with support for camera overlap,
5
+ flight constraints, and multiple sweep patterns.
6
+
7
+ Python reimplementation of the original UAV Planning library, using Shapely
8
+ instead of CGAL. CPU execution is the default; optional GPU acceleration via CuPy
9
+ is available for the optimal sweep search.
10
+
11
+ ## Features
12
+
13
+ - **Search patterns**: back-and-forth, long-edge, optimal sweep, following-wind
14
+ - **Planning targets**: spatial resolution, cruise velocity, or fixed altitude
15
+ - **Mission splitting** by UAV flight-time limits
16
+ - **Input formats**: KML polygons, Mission Planner `.poly` files, INI configuration
17
+ - **Output**: QGroundControl WPL 120 waypoint files
18
+ - **Optional GPU**: CuPy acceleration for optimal sweep angle search
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ pip install -e ".[dev]"
24
+ ```
25
+
26
+ Optional GPU support:
27
+
28
+ ```bash
29
+ pip install -e ".[gpu]"
30
+ ```
31
+
32
+ ## Quick start
33
+
34
+ ```python
35
+ from shapely.geometry import Polygon
36
+
37
+ import opencoverage as oc
38
+
39
+ polygon = Polygon([(0, 0), (100, 0), (100, 80), (0, 80)])
40
+
41
+ mission = oc.plan(
42
+ polygon=polygon,
43
+ uav=oc.UAV(min_height=70, max_height=500, survey_velocity=17),
44
+ camera=oc.PinholeCamera(
45
+ pixel_size_mm=0.0032,
46
+ focal_length_mm=8.43,
47
+ sensor_width_mm=6.55,
48
+ sensor_height_mm=4.92,
49
+ capture_rate_s=3,
50
+ ),
51
+ pattern="optimal_sweep",
52
+ lateral_overlap=0.8,
53
+ forward_overlap=0.7,
54
+ target="velocity",
55
+ target_value=17.0,
56
+ )
57
+
58
+ print(len(mission.path), "waypoints at", mission.flight_height, "m")
59
+ ```
60
+
61
+ ## Command line
62
+
63
+ ```bash
64
+ opencoverage examples/sample_field.kml config/quad_tetracam.ini mission.txt
65
+ opencoverage examples/sample_field.poly config/quad_tetracam.ini mission.txt --split
66
+ opencoverage examples/sample_field.kml config/quad_tetracam.ini mission.txt --gpu
67
+ ```
68
+
69
+ ## Patterns
70
+
71
+ | Pattern | Description |
72
+ |---------|-------------|
73
+ | `back_forth` | Standard boustrophedon sweep |
74
+ | `long_edge` | Sweep lines perpendicular to the longest polygon edge |
75
+ | `optimal_sweep` | Minimize horizontal sweep width over rotation angles |
76
+ | `following_wind` | Align sweeps with meteorological wind direction |
77
+
78
+ ## Project layout
79
+
80
+ ```
81
+ src/opencoverage/
82
+ ├── models.py # UAV, camera, mission dataclasses
83
+ ├── parameters.py # Survey parameter formulas
84
+ ├── planner.py # High-level plan() API
85
+ ├── mission_splitter.py # Flight-time mission splitting
86
+ ├── geometry/ # Shapely map and transforms
87
+ ├── patterns/ # Coverage search patterns
88
+ ├── io/ # Config, KML, QGC, coordinate conversion
89
+ └── gpu/ # Optional CuPy kernels
90
+ ```
91
+
92
+ ## License
93
+
94
+ MIT License. Copyright (c) J. Irving Vasquez-Gomez.
@@ -0,0 +1,28 @@
1
+ #
2
+ # This is the configuration file for planning a UAV mission
3
+ #
4
+
5
+ [Planner]
6
+ SearchPattern = 2; 1:back and forth, 2:Long Edge, 3:Optimal,
7
+ AutoTakeOff = false; Autonomous takeoff and landing
8
+ LateralOverlap = 0.5;
9
+ FordwardOVerlap = 0.7;
10
+ SpatialResolution = 40; mm/pixel
11
+ TargetVelocity = 17; m/s
12
+ TargetPlanning = 2; Planning mode, 1 = resolution, 2 = velocity, 3 = altitude;
13
+
14
+ [UAV]
15
+ FlightTime = 300; Seconds, put 0 to do not consider flight time
16
+ Velocity = 17; m/s
17
+ MinHeight = 70; meters
18
+ MaxHeight = 500; meters
19
+ MinVelocity = 10; m/s
20
+ MaxVelocity = 20; m/s
21
+
22
+ [Camera]
23
+ PixelSize = 0.0032; mm
24
+ FocalLenght = 8.43; mm
25
+ SensorWidth = 6.55; mm
26
+ SensorHeight = 4.92; mm
27
+ CaptureRate = 3; seg
28
+
@@ -0,0 +1,27 @@
1
+ #
2
+ # This is the configuration file for planning a UAV mission
3
+ #
4
+
5
+ [Planner]
6
+ SearchPattern = 3; 1:back and forth, 2:Long Edge, 3:Optimal,
7
+ LateralOverlap = 0.6;
8
+ FordwardOVerlap = 0.7;
9
+ SpatialResolution = 100; mm/pixel
10
+ TargetVelocity = 10.0; m/s
11
+ TargetPlanning = 2; Planning mode, 1 = resolution, 2 = velocity, 3 = altitude;
12
+ AutoTakeOff = true; Autonomous takeoff and landing
13
+
14
+ [UAV]
15
+ FlightTime = 300;Seconds, put 0 to do not consider flight time
16
+ MinVelocity = 8.0;
17
+ MaxVelocity = 20.0;
18
+ MinHeight = 50; meters
19
+ MaxHeight = 500; meters
20
+
21
+ [Camera]
22
+ PixelSize = 0.0032; mm
23
+ FocalLenght = 8.43; mm
24
+ SensorWidth = 6.55; mm
25
+ SensorHeight = 4.92; mm
26
+
27
+
@@ -0,0 +1,27 @@
1
+ #
2
+ # Example configuration for a quadrotor with a Tetracam micro camera.
3
+ #
4
+
5
+ [Planner]
6
+ SearchPattern = 1
7
+ AutoTakeOff = false
8
+ LateralOverlap = 0.8
9
+ ForwardOverlap = 0.7
10
+ SpatialResolution = 40
11
+ TargetVelocity = 17
12
+ TargetPlanning = 2
13
+
14
+ [UAV]
15
+ FlightTime = 300
16
+ Velocity = 17
17
+ MinHeight = 70
18
+ MaxHeight = 500
19
+ MinVelocity = 5
20
+ MaxVelocity = 20
21
+
22
+ [Camera]
23
+ PixelSize = 0.0032
24
+ FocalLenght = 8.43
25
+ SensorWidth = 6.55
26
+ SensorHeight = 4.92
27
+ CaptureRate = 3
@@ -0,0 +1,56 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "opencoverage"
7
+ version = "0.2.0"
8
+ description = "Coverage path planning for UAV aerial surveying"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "Juan Irving Vasquez", email = "jvasquezg@ipn.mx" },
14
+ ]
15
+ keywords = ["uav", "drone", "coverage-path-planning", "survey", "precision-agriculture"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Science/Research",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Topic :: Scientific/Engineering :: GIS",
25
+ ]
26
+ dependencies = [
27
+ "numpy>=1.24",
28
+ "shapely>=2.0",
29
+ "pyproj>=3.6",
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ gpu = ["cupy-cuda12x>=13.0"]
34
+ dev = ["pytest>=7.0", "ruff>=0.4"]
35
+
36
+ [project.scripts]
37
+ opencoverage = "opencoverage.cli:main"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/opencoverage"]
41
+
42
+ [tool.hatch.build.targets.sdist]
43
+ include = ["src/opencoverage", "config", "tests"]
44
+
45
+ [tool.pytest.ini_options]
46
+ testpaths = ["tests"]
47
+
48
+ [tool.ruff]
49
+ line-length = 100
50
+ target-version = "py310"
51
+
52
+ [project.urls]
53
+ Homepage = "https://github.com/irvingvasquez/opencoverage"
54
+ Repository = "https://github.com/irvingvasquez/opencoverage"
55
+ Documentation = "https://github.com/irvingvasquez/opencoverage#readme"
56
+ Issues = "https://github.com/irvingvasquez/opencoverage/issues"
@@ -0,0 +1,26 @@
1
+ """OpenCoverage: coverage path planning for UAV aerial surveying."""
2
+
3
+ from opencoverage.mission_splitter import MissionSplitter
4
+ from opencoverage.models import (
5
+ FlightMission,
6
+ GeodeticCoordinate,
7
+ PinholeCamera,
8
+ SearchPatternType,
9
+ TargetPlanning,
10
+ UAV,
11
+ )
12
+ from opencoverage.planner import plan
13
+
14
+ __all__ = [
15
+ "FlightMission",
16
+ "GeodeticCoordinate",
17
+ "MissionSplitter",
18
+ "PinholeCamera",
19
+ "SearchPatternType",
20
+ "TargetPlanning",
21
+ "UAV",
22
+ "plan",
23
+ "__version__",
24
+ ]
25
+
26
+ __version__ = "0.2.0"
@@ -0,0 +1,60 @@
1
+ """Command-line interface for OpenCoverage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from opencoverage.io.config import load_planner_config
8
+ from opencoverage.mission_splitter import MissionSplitter
9
+ from opencoverage.planner import plan
10
+
11
+
12
+ def main() -> int:
13
+ """Run mission planning from the command line."""
14
+ parser = argparse.ArgumentParser(
15
+ description="Plan a UAV coverage mission from a polygon and configuration file.",
16
+ )
17
+ parser.add_argument("polygon", help="Path to input polygon file (KML or Mission Planner)")
18
+ parser.add_argument("config", help="Path to INI configuration file")
19
+ parser.add_argument("output", help="Path to output QGroundControl waypoint file")
20
+ parser.add_argument(
21
+ "--gpu",
22
+ action="store_true",
23
+ help="Use CuPy for optimal sweep angle search when available",
24
+ )
25
+ parser.add_argument(
26
+ "--split",
27
+ action="store_true",
28
+ help="Split the mission according to UAV flight time limits",
29
+ )
30
+ args = parser.parse_args()
31
+
32
+ parsed_config = load_planner_config(args.config)
33
+ result = plan(
34
+ polygon=args.polygon,
35
+ config=args.config,
36
+ gpu=args.gpu,
37
+ split=args.split,
38
+ )
39
+ missions = result if isinstance(result, list) else [result]
40
+
41
+ reference = missions[0].reference
42
+ if reference is None:
43
+ raise SystemExit("Mission is missing a geodetic reference point.")
44
+
45
+ if len(missions) == 1:
46
+ mission = missions[0]
47
+ mission.save_qgc(args.output, reference=reference)
48
+ print(f"Saved {len(mission.path)} waypoints to {args.output}")
49
+ print(f"Flight height: {mission.flight_height:.2f} m")
50
+ return 0
51
+
52
+ splitter = MissionSplitter(parsed_config["uav_model"])
53
+ saved = splitter.save_qgc_files(missions, args.output, reference=reference)
54
+ for path in saved:
55
+ print(f"Saved mission segment to {path}")
56
+ return 0
57
+
58
+
59
+ if __name__ == "__main__":
60
+ raise SystemExit(main())
@@ -0,0 +1,6 @@
1
+ """Geometry helpers for survey maps and coordinate transforms."""
2
+
3
+ from opencoverage.geometry.map import SurveyMap
4
+ from opencoverage.geometry.transforms import rotate_points, rotate_polygon
5
+
6
+ __all__ = ["SurveyMap", "rotate_points", "rotate_polygon"]
@@ -0,0 +1,117 @@
1
+ """Polygon map representation backed by Shapely."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ import numpy as np
8
+ from shapely.geometry import LineString, Point, Polygon
9
+
10
+ from opencoverage.geometry.transforms import rotate_polygon
11
+ from opencoverage.models import GeodeticCoordinate
12
+
13
+
14
+ @dataclass
15
+ class SurveyMap:
16
+ """Survey area defined as a local-meter polygon."""
17
+
18
+ polygon: Polygon
19
+ reference: GeodeticCoordinate | None = None
20
+ home: GeodeticCoordinate | None = None
21
+ takeoff: GeodeticCoordinate | None = None
22
+ _home_local: tuple[float, float] | None = field(default=None, repr=False)
23
+
24
+ @classmethod
25
+ def from_polygon(cls, polygon: Polygon, **kwargs) -> SurveyMap:
26
+ """Create a map from an existing Shapely polygon."""
27
+ if polygon.is_empty or not polygon.is_valid:
28
+ raise ValueError("Survey polygon must be non-empty and valid")
29
+ return cls(polygon=polygon, **kwargs)
30
+
31
+ def copy(self) -> SurveyMap:
32
+ """Return a shallow copy of the map."""
33
+ return SurveyMap(
34
+ polygon=Polygon(self.polygon),
35
+ reference=self.reference,
36
+ home=self.home,
37
+ takeoff=self.takeoff,
38
+ _home_local=self._home_local,
39
+ )
40
+
41
+ def rotated_copy(self, angle_rad: float) -> SurveyMap:
42
+ """Return a new map with the polygon rotated by angle_rad."""
43
+ rotated = rotate_polygon(self.polygon, angle_rad)
44
+ return SurveyMap(
45
+ polygon=rotated,
46
+ reference=self.reference,
47
+ home=self.home,
48
+ takeoff=self.takeoff,
49
+ _home_local=self._home_local,
50
+ )
51
+
52
+ @property
53
+ def bounds(self) -> tuple[float, float, float, float]:
54
+ """Return polygon axis-aligned bounds (minx, miny, maxx, maxy)."""
55
+ return self.polygon.bounds
56
+
57
+ @property
58
+ def vertices(self) -> np.ndarray:
59
+ """Return exterior vertices as an (N, 2) array."""
60
+ coords = list(self.polygon.exterior.coords[:-1])
61
+ return np.asarray(coords, dtype=float)
62
+
63
+ def extreme_vertices(self) -> dict[str, tuple[float, float]]:
64
+ """Return left, right, top, and bottom vertices of the polygon."""
65
+ coords = list(self.polygon.exterior.coords[:-1])
66
+ left = min(coords, key=lambda p: p[0])
67
+ right = max(coords, key=lambda p: p[0])
68
+ bottom = min(coords, key=lambda p: p[1])
69
+ top = max(coords, key=lambda p: p[1])
70
+ return {"left": left, "right": right, "bottom": bottom, "top": top}
71
+
72
+ def sweep_width(self) -> float:
73
+ """Return horizontal extent used for back-and-forth sweep planning."""
74
+ extremes = self.extreme_vertices()
75
+ return extremes["right"][0] - extremes["left"][0]
76
+
77
+ def sweep_height(self) -> float:
78
+ """Return vertical extent of the polygon."""
79
+ extremes = self.extreme_vertices()
80
+ return extremes["top"][1] - extremes["bottom"][1]
81
+
82
+ def intersect_vertical_line(self, x: float) -> list[tuple[float, float]]:
83
+ """
84
+ Intersect the polygon with a vertical line at coordinate x.
85
+
86
+ Returns intersection points sorted by ascending y.
87
+ """
88
+ minx, miny, maxx, maxy = self.bounds
89
+ line = LineString([(x, miny - 1.0), (x, maxy + 1.0)])
90
+ intersection = self.polygon.intersection(line)
91
+
92
+ points: list[tuple[float, float]] = []
93
+ if intersection.is_empty:
94
+ return points
95
+
96
+ if isinstance(intersection, Point):
97
+ points.append((intersection.x, intersection.y))
98
+ elif intersection.geom_type == "MultiPoint":
99
+ points.extend((point.x, point.y) for point in intersection.geoms)
100
+ elif intersection.geom_type == "LineString":
101
+ points.extend(intersection.coords)
102
+ elif intersection.geom_type == "MultiLineString":
103
+ for segment in intersection.geoms:
104
+ points.extend(segment.coords)
105
+
106
+ unique: list[tuple[float, float]] = []
107
+ seen: set[tuple[float, float]] = set()
108
+ for point in sorted(points, key=lambda p: p[1]):
109
+ key = (round(point[0], 9), round(point[1], 9))
110
+ if key not in seen:
111
+ seen.add(key)
112
+ unique.append(point)
113
+ return unique
114
+
115
+ def replace_polygon(self, polygon: Polygon) -> None:
116
+ """Replace the internal polygon, for example after rotation."""
117
+ self.polygon = polygon
@@ -0,0 +1,43 @@
1
+ """Coordinate transforms for polygons and waypoint paths."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+ import numpy as np
8
+ from shapely.affinity import rotate as shapely_rotate
9
+ from shapely.geometry import Polygon
10
+
11
+
12
+ def rotate_polygon(polygon: Polygon, angle_rad: float, origin: tuple[float, float] = (0.0, 0.0)) -> Polygon:
13
+ """Rotate a polygon by angle_rad around origin."""
14
+ return shapely_rotate(polygon, angle_rad, origin=origin, use_radians=True)
15
+
16
+
17
+ def rotate_points(
18
+ points: list[tuple[float, float]],
19
+ angle_rad: float,
20
+ origin: tuple[float, float] = (0.0, 0.0),
21
+ ) -> list[tuple[float, float]]:
22
+ """Rotate a list of 2D points by angle_rad around origin."""
23
+ if not points:
24
+ return []
25
+
26
+ ox, oy = origin
27
+ cos_a = math.cos(angle_rad)
28
+ sin_a = math.sin(angle_rad)
29
+ rotated: list[tuple[float, float]] = []
30
+
31
+ for x, y in points:
32
+ dx = x - ox
33
+ dy = y - oy
34
+ rotated.append((ox + cos_a * dx - sin_a * dy, oy + sin_a * dx + cos_a * dy))
35
+
36
+ return rotated
37
+
38
+
39
+ def rotation_matrix(angle_rad: float) -> np.ndarray:
40
+ """Return a 2x2 rotation matrix for angle_rad."""
41
+ cos_a = math.cos(angle_rad)
42
+ sin_a = math.sin(angle_rad)
43
+ return np.array([[cos_a, -sin_a], [sin_a, cos_a]], dtype=float)
@@ -0,0 +1,5 @@
1
+ """Optional GPU acceleration backends."""
2
+
3
+ from opencoverage.gpu._backend import cupy_available, get_array_module
4
+
5
+ __all__ = ["cupy_available", "get_array_module"]