opencoverage 0.2.0__py3-none-any.whl
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.
- opencoverage/__init__.py +26 -0
- opencoverage/cli.py +60 -0
- opencoverage/geometry/__init__.py +6 -0
- opencoverage/geometry/map.py +117 -0
- opencoverage/geometry/transforms.py +43 -0
- opencoverage/gpu/__init__.py +5 -0
- opencoverage/gpu/_backend.py +35 -0
- opencoverage/gpu/optimal_sweep.py +42 -0
- opencoverage/io/__init__.py +6 -0
- opencoverage/io/config.py +79 -0
- opencoverage/io/coords.py +72 -0
- opencoverage/io/kml.py +50 -0
- opencoverage/io/mission_planner.py +35 -0
- opencoverage/io/qgc.py +130 -0
- opencoverage/io/survey_input.py +21 -0
- opencoverage/mission_splitter.py +83 -0
- opencoverage/models.py +134 -0
- opencoverage/parameters.py +132 -0
- opencoverage/patterns/__init__.py +15 -0
- opencoverage/patterns/back_forth.py +57 -0
- opencoverage/patterns/base.py +74 -0
- opencoverage/patterns/following_wind.py +33 -0
- opencoverage/patterns/long_edge.py +35 -0
- opencoverage/patterns/optimal_sweep.py +38 -0
- opencoverage/planner.py +216 -0
- opencoverage-0.2.0.dist-info/METADATA +125 -0
- opencoverage-0.2.0.dist-info/RECORD +30 -0
- opencoverage-0.2.0.dist-info/WHEEL +4 -0
- opencoverage-0.2.0.dist-info/entry_points.txt +2 -0
- opencoverage-0.2.0.dist-info/licenses/LICENSE +21 -0
opencoverage/__init__.py
ADDED
|
@@ -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"
|
opencoverage/cli.py
ADDED
|
@@ -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,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,35 @@
|
|
|
1
|
+
"""Optional GPU backend helpers (CuPy). CPU is always the default."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def cupy_available() -> bool:
|
|
9
|
+
"""Return True when CuPy is installed and a CUDA device is accessible."""
|
|
10
|
+
try:
|
|
11
|
+
import cupy as cp
|
|
12
|
+
|
|
13
|
+
return cp.cuda.is_available()
|
|
14
|
+
except ImportError:
|
|
15
|
+
return False
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_array_module(prefer_gpu: bool = False) -> Any:
|
|
19
|
+
"""
|
|
20
|
+
Return NumPy or CuPy for numeric kernels.
|
|
21
|
+
|
|
22
|
+
GPU is only selected when explicitly requested and CuPy is available.
|
|
23
|
+
"""
|
|
24
|
+
if prefer_gpu:
|
|
25
|
+
try:
|
|
26
|
+
import cupy as cp
|
|
27
|
+
|
|
28
|
+
if cp.cuda.is_available():
|
|
29
|
+
return cp
|
|
30
|
+
except ImportError:
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
import numpy as np
|
|
34
|
+
|
|
35
|
+
return np
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""GPU-accelerated optimal sweep angle search."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from opencoverage.gpu._backend import get_array_module
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def find_optimal_sweep_angle(
|
|
11
|
+
vertices: np.ndarray,
|
|
12
|
+
n_steps: int = 10_000,
|
|
13
|
+
prefer_gpu: bool = False,
|
|
14
|
+
) -> float:
|
|
15
|
+
"""
|
|
16
|
+
Find the rotation angle in [0, pi) that minimizes sweep width.
|
|
17
|
+
|
|
18
|
+
Parameters
|
|
19
|
+
----------
|
|
20
|
+
vertices:
|
|
21
|
+
Polygon vertices as an (N, 2) array in local meters.
|
|
22
|
+
n_steps:
|
|
23
|
+
Number of angles to evaluate.
|
|
24
|
+
prefer_gpu:
|
|
25
|
+
Use CuPy when available.
|
|
26
|
+
"""
|
|
27
|
+
xp = get_array_module(prefer_gpu)
|
|
28
|
+
points = xp.asarray(vertices, dtype=float)
|
|
29
|
+
x = points[:, 0]
|
|
30
|
+
y = points[:, 1]
|
|
31
|
+
|
|
32
|
+
alphas = xp.linspace(0.0, xp.pi, n_steps, endpoint=False)
|
|
33
|
+
cos_a = xp.cos(alphas)[:, xp.newaxis]
|
|
34
|
+
sin_a = xp.sin(alphas)[:, xp.newaxis]
|
|
35
|
+
|
|
36
|
+
rotated_x = cos_a * x - sin_a * y
|
|
37
|
+
widths = rotated_x.max(axis=1) - rotated_x.min(axis=1)
|
|
38
|
+
best_index = int(widths.argmin())
|
|
39
|
+
|
|
40
|
+
if prefer_gpu and xp.__name__ == "cupy":
|
|
41
|
+
return float(xp.asnumpy(alphas[best_index]))
|
|
42
|
+
return float(alphas[best_index])
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""INI configuration file loader."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import configparser
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from opencoverage.models import PinholeCamera, SearchPatternType, TargetPlanning, UAV
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _parse_bool(value: str) -> bool:
|
|
12
|
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _parse_value(raw: str) -> str | float | int | bool:
|
|
16
|
+
"""Parse INI value, stripping inline comments after semicolon."""
|
|
17
|
+
cleaned = raw.split(";", 1)[0].strip()
|
|
18
|
+
if cleaned.lower() in {"true", "false"}:
|
|
19
|
+
return _parse_bool(cleaned)
|
|
20
|
+
if "." in cleaned:
|
|
21
|
+
return float(cleaned)
|
|
22
|
+
try:
|
|
23
|
+
return int(cleaned)
|
|
24
|
+
except ValueError:
|
|
25
|
+
return cleaned
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_planner_config(path: str | Path) -> dict:
|
|
29
|
+
"""
|
|
30
|
+
Load planner, UAV, and camera settings from an INI file.
|
|
31
|
+
|
|
32
|
+
Returns a dictionary with keys: planner, uav, camera, and model instances.
|
|
33
|
+
"""
|
|
34
|
+
parser = configparser.ConfigParser()
|
|
35
|
+
parser.read(path)
|
|
36
|
+
|
|
37
|
+
planner_section = {key: _parse_value(value) for key, value in parser.items("Planner")}
|
|
38
|
+
uav_section = {key: _parse_value(value) for key, value in parser.items("UAV")}
|
|
39
|
+
camera_section = {key: _parse_value(value) for key, value in parser.items("Camera")}
|
|
40
|
+
|
|
41
|
+
planner = {
|
|
42
|
+
"search_pattern": SearchPatternType(int(planner_section["searchpattern"])),
|
|
43
|
+
"auto_takeoff": bool(planner_section.get("autotakeoff", False)),
|
|
44
|
+
"lateral_overlap": float(planner_section["lateraloverlap"]),
|
|
45
|
+
"forward_overlap": float(
|
|
46
|
+
planner_section.get("forwardoverlap", planner_section.get("fordwardoverlap", 0.7))
|
|
47
|
+
),
|
|
48
|
+
"spatial_resolution_mm": float(planner_section.get("spatialresolution", 40)),
|
|
49
|
+
"target_velocity": float(planner_section.get("targetvelocity", 17)),
|
|
50
|
+
"target_planning": TargetPlanning(
|
|
51
|
+
int(planner_section.get("targetplanning", TargetPlanning.VELOCITY))
|
|
52
|
+
),
|
|
53
|
+
"wind_direction_deg": float(planner_section.get("winddirection", 0)),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
uav_config = {
|
|
57
|
+
"flight_time_s": float(uav_section.get("flighttime", 0)),
|
|
58
|
+
"survey_velocity": float(uav_section.get("velocity", planner["target_velocity"])),
|
|
59
|
+
"min_height": float(uav_section["minheight"]),
|
|
60
|
+
"max_height": float(uav_section["maxheight"]),
|
|
61
|
+
"min_velocity": float(uav_section.get("minvelocity", 5)),
|
|
62
|
+
"max_velocity": float(uav_section.get("maxvelocity", 20)),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
camera_config = {
|
|
66
|
+
"pixel_size_mm": float(camera_section["pixelsize"]),
|
|
67
|
+
"focal_length_mm": float(camera_section["focallenght"]),
|
|
68
|
+
"sensor_width_mm": float(camera_section["sensorwidth"]),
|
|
69
|
+
"sensor_height_mm": float(camera_section["sensorheight"]),
|
|
70
|
+
"capture_rate_s": float(camera_section.get("capturerate", 3)),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
"planner": planner,
|
|
75
|
+
"uav": uav_config,
|
|
76
|
+
"camera": camera_config,
|
|
77
|
+
"uav_model": UAV.from_config(uav_config),
|
|
78
|
+
"camera_model": PinholeCamera.from_config(camera_config),
|
|
79
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Coordinate conversion between geodetic and local meters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pyproj import Transformer
|
|
6
|
+
from shapely.geometry import Polygon
|
|
7
|
+
|
|
8
|
+
from opencoverage.models import GeodeticCoordinate
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _local_crs(reference: GeodeticCoordinate) -> str:
|
|
12
|
+
return (
|
|
13
|
+
"+proj=aeqd +lat_0={lat} +lon_0={lon} +x_0=0 +y_0=0 +datum=WGS84 +units=m +no_defs".format(
|
|
14
|
+
lat=reference.latitude,
|
|
15
|
+
lon=reference.longitude,
|
|
16
|
+
)
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def geodetics_to_local_polygon(
|
|
21
|
+
geodetics: list[GeodeticCoordinate],
|
|
22
|
+
reference: GeodeticCoordinate,
|
|
23
|
+
) -> Polygon:
|
|
24
|
+
"""Project geodetic coordinates to a local meter polygon."""
|
|
25
|
+
transformer = Transformer.from_crs(
|
|
26
|
+
"EPSG:4326",
|
|
27
|
+
_local_crs(reference),
|
|
28
|
+
always_xy=True,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
local_coords: list[tuple[float, float]] = []
|
|
32
|
+
for geo in geodetics:
|
|
33
|
+
x, y = transformer.transform(geo.longitude, geo.latitude)
|
|
34
|
+
local_coords.append((x, y))
|
|
35
|
+
|
|
36
|
+
if local_coords[0] != local_coords[-1]:
|
|
37
|
+
local_coords.append(local_coords[0])
|
|
38
|
+
|
|
39
|
+
return Polygon(local_coords)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def local_to_geodetic(
|
|
43
|
+
x: float,
|
|
44
|
+
y: float,
|
|
45
|
+
reference: GeodeticCoordinate,
|
|
46
|
+
altitude: float | None = None,
|
|
47
|
+
) -> GeodeticCoordinate:
|
|
48
|
+
"""Convert local meter coordinates back to geodetic."""
|
|
49
|
+
transformer = Transformer.from_crs(
|
|
50
|
+
_local_crs(reference),
|
|
51
|
+
"EPSG:4326",
|
|
52
|
+
always_xy=True,
|
|
53
|
+
)
|
|
54
|
+
lon, lat = transformer.transform(x, y)
|
|
55
|
+
return GeodeticCoordinate(
|
|
56
|
+
latitude=lat,
|
|
57
|
+
longitude=lon,
|
|
58
|
+
altitude=reference.altitude if altitude is None else altitude,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def path_to_geodetics(
|
|
63
|
+
path: list[tuple[float, float]],
|
|
64
|
+
reference: GeodeticCoordinate,
|
|
65
|
+
flight_height_m: float,
|
|
66
|
+
) -> list[GeodeticCoordinate]:
|
|
67
|
+
"""Convert a local waypoint path to geodetic coordinates at flight height."""
|
|
68
|
+
waypoint_altitude = reference.altitude + flight_height_m
|
|
69
|
+
return [
|
|
70
|
+
local_to_geodetic(x, y, reference, altitude=waypoint_altitude)
|
|
71
|
+
for x, y in path
|
|
72
|
+
]
|
opencoverage/io/kml.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""KML polygon reader."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import xml.etree.ElementTree as ET
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from shapely.geometry import Polygon
|
|
9
|
+
|
|
10
|
+
from opencoverage.geometry.map import SurveyMap
|
|
11
|
+
from opencoverage.io.coords import geodetics_to_local_polygon
|
|
12
|
+
from opencoverage.models import GeodeticCoordinate
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _parse_coordinates(text: str) -> list[GeodeticCoordinate]:
|
|
16
|
+
"""Parse KML coordinate tuples (lon, lat, alt)."""
|
|
17
|
+
coords: list[GeodeticCoordinate] = []
|
|
18
|
+
for chunk in text.strip().split():
|
|
19
|
+
parts = chunk.split(",")
|
|
20
|
+
if len(parts) < 2:
|
|
21
|
+
continue
|
|
22
|
+
lon = float(parts[0])
|
|
23
|
+
lat = float(parts[1])
|
|
24
|
+
alt = float(parts[2]) if len(parts) > 2 else 0.0
|
|
25
|
+
coords.append(GeodeticCoordinate(latitude=lat, longitude=lon, altitude=alt))
|
|
26
|
+
return coords
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def read_polygon_from_kml(path: str | Path) -> SurveyMap:
|
|
30
|
+
"""Read the first polygon found in a KML file into a SurveyMap."""
|
|
31
|
+
tree = ET.parse(path)
|
|
32
|
+
root = tree.getroot()
|
|
33
|
+
|
|
34
|
+
coordinates_text = None
|
|
35
|
+
for element in root.iter():
|
|
36
|
+
tag = element.tag.split("}")[-1]
|
|
37
|
+
if tag == "coordinates" and element.text:
|
|
38
|
+
coordinates_text = element.text
|
|
39
|
+
break
|
|
40
|
+
|
|
41
|
+
if not coordinates_text:
|
|
42
|
+
raise ValueError(f"No polygon coordinates found in KML file: {path}")
|
|
43
|
+
|
|
44
|
+
geodetics = _parse_coordinates(coordinates_text)
|
|
45
|
+
if len(geodetics) < 3:
|
|
46
|
+
raise ValueError("KML polygon must contain at least three coordinates")
|
|
47
|
+
|
|
48
|
+
reference = geodetics[0]
|
|
49
|
+
polygon = geodetics_to_local_polygon(geodetics, reference)
|
|
50
|
+
return SurveyMap.from_polygon(polygon, reference=reference)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Mission Planner polygon file reader."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from opencoverage.geometry.map import SurveyMap
|
|
8
|
+
from opencoverage.io.coords import geodetics_to_local_polygon
|
|
9
|
+
from opencoverage.models import GeodeticCoordinate
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def read_polygon_from_mission_planner(path: str | Path) -> SurveyMap:
|
|
13
|
+
"""
|
|
14
|
+
Read a polygon from a Mission Planner poly file.
|
|
15
|
+
|
|
16
|
+
Each non-empty line contains ``latitude longitude`` in decimal degrees.
|
|
17
|
+
"""
|
|
18
|
+
geodetics: list[GeodeticCoordinate] = []
|
|
19
|
+
content = Path(path).read_text(encoding="utf-8")
|
|
20
|
+
|
|
21
|
+
for line in content.splitlines()[1:]:
|
|
22
|
+
parts = line.split()
|
|
23
|
+
if len(parts) < 2:
|
|
24
|
+
continue
|
|
25
|
+
latitude = float(parts[0])
|
|
26
|
+
longitude = float(parts[1])
|
|
27
|
+
altitude = float(parts[2]) if len(parts) > 2 else 0.0
|
|
28
|
+
geodetics.append(GeodeticCoordinate(latitude=latitude, longitude=longitude, altitude=altitude))
|
|
29
|
+
|
|
30
|
+
if len(geodetics) < 3:
|
|
31
|
+
raise ValueError(f"Mission Planner file must contain at least three points: {path}")
|
|
32
|
+
|
|
33
|
+
reference = geodetics[0]
|
|
34
|
+
polygon = geodetics_to_local_polygon(geodetics, reference)
|
|
35
|
+
return SurveyMap.from_polygon(polygon, reference=reference)
|