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.
@@ -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,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"]
@@ -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,6 @@
1
+ """Input/output helpers for configuration and mission files."""
2
+
3
+ from opencoverage.io.config import load_planner_config
4
+ from opencoverage.io.survey_input import read_survey_map
5
+
6
+ __all__ = ["load_planner_config", "read_survey_map"]
@@ -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)