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/io/qgc.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""QGroundControl waypoint file export."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from opencoverage.io.coords import path_to_geodetics
|
|
8
|
+
from opencoverage.models import GeodeticCoordinate
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _write_command(
|
|
12
|
+
waypoint_id: int,
|
|
13
|
+
is_current: int,
|
|
14
|
+
coordinate_frame: int,
|
|
15
|
+
command: int,
|
|
16
|
+
param1: float,
|
|
17
|
+
param2: float,
|
|
18
|
+
param3: float,
|
|
19
|
+
param4: float,
|
|
20
|
+
latitude: float,
|
|
21
|
+
longitude: float,
|
|
22
|
+
altitude: float,
|
|
23
|
+
auto_continue: int,
|
|
24
|
+
) -> str:
|
|
25
|
+
return (
|
|
26
|
+
f"{waypoint_id}\t{is_current}\t{coordinate_frame}\t{command}\t"
|
|
27
|
+
f"{param1}\t{param2}\t{param3}\t{param4}\t"
|
|
28
|
+
f"{latitude:.15f}\t{longitude:.15f}\t{altitude:.6f}\t{auto_continue}"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def write_qgc_file(
|
|
33
|
+
filename: str | Path,
|
|
34
|
+
*,
|
|
35
|
+
path: list[tuple[float, float]],
|
|
36
|
+
flight_height: float,
|
|
37
|
+
reference: GeodeticCoordinate,
|
|
38
|
+
home: GeodeticCoordinate | None = None,
|
|
39
|
+
takeoff: GeodeticCoordinate | None = None,
|
|
40
|
+
auto_takeoff: bool = False,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""
|
|
43
|
+
Write a QGroundControl waypoint file from a local-meter path.
|
|
44
|
+
|
|
45
|
+
Format follows the legacy QGC WPL 120 tab-separated layout.
|
|
46
|
+
"""
|
|
47
|
+
geodetics = path_to_geodetics(path, reference, flight_height)
|
|
48
|
+
lines = ["QGC WPL 120"]
|
|
49
|
+
waypoint_id = 0
|
|
50
|
+
current_id = 0
|
|
51
|
+
auto_continue = 1
|
|
52
|
+
|
|
53
|
+
if auto_takeoff and takeoff is not None:
|
|
54
|
+
lines.append(
|
|
55
|
+
_write_command(
|
|
56
|
+
waypoint_id,
|
|
57
|
+
1 if waypoint_id == current_id else 0,
|
|
58
|
+
3,
|
|
59
|
+
22,
|
|
60
|
+
15,
|
|
61
|
+
0,
|
|
62
|
+
0,
|
|
63
|
+
0,
|
|
64
|
+
takeoff.latitude,
|
|
65
|
+
takeoff.longitude,
|
|
66
|
+
takeoff.altitude,
|
|
67
|
+
auto_continue,
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
waypoint_id += 1
|
|
71
|
+
|
|
72
|
+
if geodetics:
|
|
73
|
+
first = geodetics[0]
|
|
74
|
+
lines.append(
|
|
75
|
+
_write_command(
|
|
76
|
+
waypoint_id,
|
|
77
|
+
1 if waypoint_id == current_id else 0,
|
|
78
|
+
3,
|
|
79
|
+
16,
|
|
80
|
+
0,
|
|
81
|
+
0,
|
|
82
|
+
0,
|
|
83
|
+
0,
|
|
84
|
+
first.latitude,
|
|
85
|
+
first.longitude,
|
|
86
|
+
first.altitude,
|
|
87
|
+
auto_continue,
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
waypoint_id += 1
|
|
91
|
+
|
|
92
|
+
for geo in geodetics:
|
|
93
|
+
lines.append(
|
|
94
|
+
_write_command(
|
|
95
|
+
waypoint_id,
|
|
96
|
+
1 if waypoint_id == current_id else 0,
|
|
97
|
+
3,
|
|
98
|
+
16,
|
|
99
|
+
0,
|
|
100
|
+
0,
|
|
101
|
+
0,
|
|
102
|
+
0,
|
|
103
|
+
geo.latitude,
|
|
104
|
+
geo.longitude,
|
|
105
|
+
geo.altitude,
|
|
106
|
+
auto_continue,
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
waypoint_id += 1
|
|
110
|
+
|
|
111
|
+
if geodetics:
|
|
112
|
+
first = geodetics[0]
|
|
113
|
+
lines.append(
|
|
114
|
+
_write_command(
|
|
115
|
+
waypoint_id,
|
|
116
|
+
1 if waypoint_id == current_id else 0,
|
|
117
|
+
3,
|
|
118
|
+
17,
|
|
119
|
+
0,
|
|
120
|
+
0,
|
|
121
|
+
15,
|
|
122
|
+
0,
|
|
123
|
+
first.latitude,
|
|
124
|
+
first.longitude,
|
|
125
|
+
first.altitude,
|
|
126
|
+
auto_continue,
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
Path(filename).write_text("\r\n".join(lines) + "\r\n", encoding="utf-8")
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Survey polygon input readers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from opencoverage.geometry.map import SurveyMap
|
|
8
|
+
from opencoverage.io.kml import read_polygon_from_kml
|
|
9
|
+
from opencoverage.io.mission_planner import read_polygon_from_mission_planner
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def read_survey_map(path: str | Path) -> SurveyMap:
|
|
13
|
+
"""
|
|
14
|
+
Load a survey map from KML or Mission Planner polygon file.
|
|
15
|
+
|
|
16
|
+
Supported extensions: ``.kml``, ``.kmz`` (KML only), ``.poly``, ``.txt``.
|
|
17
|
+
"""
|
|
18
|
+
suffix = Path(path).suffix.lower()
|
|
19
|
+
if suffix in {".kml", ".kmz"}:
|
|
20
|
+
return read_polygon_from_kml(path)
|
|
21
|
+
return read_polygon_from_mission_planner(path)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Split long missions according to UAV flight-time limits."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from opencoverage.models import FlightMission, GeodeticCoordinate, UAV
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MissionSplitter:
|
|
11
|
+
"""Divide a mission into segments that fit within UAV flight time."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, uav: UAV) -> None:
|
|
14
|
+
self.uav = uav
|
|
15
|
+
|
|
16
|
+
def split(self, mission: FlightMission) -> list[FlightMission]:
|
|
17
|
+
"""
|
|
18
|
+
Split a mission into sub-missions when flight_time_s is configured.
|
|
19
|
+
|
|
20
|
+
When flight_time_s is zero, the original mission is returned unchanged.
|
|
21
|
+
"""
|
|
22
|
+
if self.uav.flight_time_s <= 0 or not mission.path:
|
|
23
|
+
return [mission]
|
|
24
|
+
|
|
25
|
+
segments: list[FlightMission] = []
|
|
26
|
+
partial_path: list[tuple[float, float]] = []
|
|
27
|
+
last_point: tuple[float, float] | None = None
|
|
28
|
+
|
|
29
|
+
for point in mission.path:
|
|
30
|
+
partial_path.append(point)
|
|
31
|
+
elapsed = self.uav.estimate_flight_time(partial_path)
|
|
32
|
+
if elapsed > self.uav.flight_time_s:
|
|
33
|
+
partial_path.pop()
|
|
34
|
+
if partial_path:
|
|
35
|
+
segments.append(self._clone_mission(mission, partial_path))
|
|
36
|
+
partial_path = []
|
|
37
|
+
if last_point is not None:
|
|
38
|
+
partial_path.append(last_point)
|
|
39
|
+
partial_path.append(point)
|
|
40
|
+
|
|
41
|
+
last_point = point
|
|
42
|
+
|
|
43
|
+
if partial_path:
|
|
44
|
+
segments.append(self._clone_mission(mission, partial_path))
|
|
45
|
+
|
|
46
|
+
return segments or [mission]
|
|
47
|
+
|
|
48
|
+
def save_qgc_files(
|
|
49
|
+
self,
|
|
50
|
+
missions: list[FlightMission],
|
|
51
|
+
base_filename: str | Path,
|
|
52
|
+
reference: GeodeticCoordinate,
|
|
53
|
+
) -> list[Path]:
|
|
54
|
+
"""Save each mission segment as a numbered QGroundControl file."""
|
|
55
|
+
base_path = Path(base_filename)
|
|
56
|
+
stem = base_path.stem
|
|
57
|
+
suffix = base_path.suffix or ".txt"
|
|
58
|
+
parent = base_path.parent
|
|
59
|
+
|
|
60
|
+
saved: list[Path] = []
|
|
61
|
+
for index, mission in enumerate(missions, start=1):
|
|
62
|
+
output = parent / f"{stem}{index}{suffix}"
|
|
63
|
+
mission.save_qgc(output, reference=reference)
|
|
64
|
+
saved.append(output)
|
|
65
|
+
return saved
|
|
66
|
+
|
|
67
|
+
def _clone_mission(
|
|
68
|
+
self,
|
|
69
|
+
mission: FlightMission,
|
|
70
|
+
path: list[tuple[float, float]],
|
|
71
|
+
) -> FlightMission:
|
|
72
|
+
return FlightMission(
|
|
73
|
+
path=list(path),
|
|
74
|
+
flight_height=mission.flight_height,
|
|
75
|
+
home=mission.home,
|
|
76
|
+
takeoff=mission.takeoff,
|
|
77
|
+
reference=mission.reference,
|
|
78
|
+
auto_takeoff=mission.auto_takeoff,
|
|
79
|
+
forward_distance=mission.forward_distance,
|
|
80
|
+
lateral_distance=mission.lateral_distance,
|
|
81
|
+
capture_rate=mission.capture_rate,
|
|
82
|
+
flight_time_s=self.uav.estimate_flight_time(path),
|
|
83
|
+
)
|
opencoverage/models.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Domain models for UAV survey planning."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import IntEnum
|
|
7
|
+
from typing import Sequence
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TargetPlanning(IntEnum):
|
|
13
|
+
"""Planning objective used to derive flight height and capture rate."""
|
|
14
|
+
|
|
15
|
+
RESOLUTION = 1
|
|
16
|
+
VELOCITY = 2
|
|
17
|
+
ALTITUDE = 3
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SearchPatternType(IntEnum):
|
|
21
|
+
"""Available coverage sweep patterns."""
|
|
22
|
+
|
|
23
|
+
BACK_AND_FORTH = 1
|
|
24
|
+
LONG_EDGE = 2
|
|
25
|
+
OPTIMAL_SWEEP = 3
|
|
26
|
+
FOLLOWING_WIND = 4
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class GeodeticCoordinate:
|
|
31
|
+
"""WGS84 geodetic coordinate."""
|
|
32
|
+
|
|
33
|
+
latitude: float
|
|
34
|
+
longitude: float
|
|
35
|
+
altitude: float = 0.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class CartesianCoordinate:
|
|
40
|
+
"""Local Cartesian coordinate in meters (north-east-down style x/y)."""
|
|
41
|
+
|
|
42
|
+
x: float
|
|
43
|
+
y: float
|
|
44
|
+
z: float = 0.0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class PinholeCamera:
|
|
49
|
+
"""Pinhole camera model. All geometric parameters are in millimeters."""
|
|
50
|
+
|
|
51
|
+
pixel_size_mm: float
|
|
52
|
+
focal_length_mm: float
|
|
53
|
+
sensor_width_mm: float
|
|
54
|
+
sensor_height_mm: float
|
|
55
|
+
capture_rate_s: float = 3.0
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_config(cls, config: dict[str, float]) -> PinholeCamera:
|
|
59
|
+
"""Build a camera from parsed INI camera section values."""
|
|
60
|
+
return cls(
|
|
61
|
+
pixel_size_mm=config["pixel_size_mm"],
|
|
62
|
+
focal_length_mm=config["focal_length_mm"],
|
|
63
|
+
sensor_width_mm=config["sensor_width_mm"],
|
|
64
|
+
sensor_height_mm=config["sensor_height_mm"],
|
|
65
|
+
capture_rate_s=config.get("capture_rate_s", 3.0),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class UAV:
|
|
71
|
+
"""Multirotor UAV flight constraints."""
|
|
72
|
+
|
|
73
|
+
min_height: float = 70.0
|
|
74
|
+
max_height: float = 500.0
|
|
75
|
+
min_velocity: float = 5.0
|
|
76
|
+
max_velocity: float = 20.0
|
|
77
|
+
survey_velocity: float = 17.0
|
|
78
|
+
flight_time_s: float = 0.0
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def from_config(cls, config: dict[str, float]) -> UAV:
|
|
82
|
+
"""Build a UAV from parsed INI UAV section values."""
|
|
83
|
+
return cls(
|
|
84
|
+
min_height=config["min_height"],
|
|
85
|
+
max_height=config["max_height"],
|
|
86
|
+
min_velocity=config.get("min_velocity", 5.0),
|
|
87
|
+
max_velocity=config.get("max_velocity", 20.0),
|
|
88
|
+
survey_velocity=config.get("survey_velocity", config.get("velocity", 17.0)),
|
|
89
|
+
flight_time_s=config.get("flight_time_s", 0.0),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def estimate_path_distance(self, path: Sequence[tuple[float, float]]) -> float:
|
|
93
|
+
"""Return total path length in meters."""
|
|
94
|
+
if len(path) < 2:
|
|
95
|
+
return 0.0
|
|
96
|
+
points = np.asarray(path, dtype=float)
|
|
97
|
+
deltas = np.diff(points, axis=0)
|
|
98
|
+
return float(np.sum(np.hypot(deltas[:, 0], deltas[:, 1])))
|
|
99
|
+
|
|
100
|
+
def estimate_flight_time(self, path: Sequence[tuple[float, float]]) -> float:
|
|
101
|
+
"""Return estimated flight duration in seconds."""
|
|
102
|
+
if self.survey_velocity <= 0:
|
|
103
|
+
return 0.0
|
|
104
|
+
return self.estimate_path_distance(path) / self.survey_velocity
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class FlightMission:
|
|
109
|
+
"""Planned survey mission with waypoint path in local coordinates."""
|
|
110
|
+
|
|
111
|
+
path: list[tuple[float, float]] = field(default_factory=list)
|
|
112
|
+
flight_height: float = 0.0
|
|
113
|
+
home: GeodeticCoordinate | None = None
|
|
114
|
+
takeoff: GeodeticCoordinate | None = None
|
|
115
|
+
reference: GeodeticCoordinate | None = None
|
|
116
|
+
auto_takeoff: bool = False
|
|
117
|
+
forward_distance: float = 0.0
|
|
118
|
+
lateral_distance: float = 0.0
|
|
119
|
+
capture_rate: float = 0.0
|
|
120
|
+
flight_time_s: float = 0.0
|
|
121
|
+
|
|
122
|
+
def save_qgc(self, filename: str, reference: GeodeticCoordinate) -> None:
|
|
123
|
+
"""Export the mission as a QGroundControl waypoint file."""
|
|
124
|
+
from opencoverage.io.qgc import write_qgc_file
|
|
125
|
+
|
|
126
|
+
write_qgc_file(
|
|
127
|
+
filename,
|
|
128
|
+
path=self.path,
|
|
129
|
+
flight_height=self.flight_height,
|
|
130
|
+
reference=reference,
|
|
131
|
+
home=self.home,
|
|
132
|
+
takeoff=self.takeoff,
|
|
133
|
+
auto_takeoff=self.auto_takeoff,
|
|
134
|
+
)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Survey parameter calculations derived from camera and UAV models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from opencoverage.models import PinholeCamera, TargetPlanning, UAV
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def flight_height_from_resolution(spatial_resolution_mm: float, camera: PinholeCamera) -> float:
|
|
9
|
+
"""Compute flight height in meters for a target ground resolution."""
|
|
10
|
+
height_m = (spatial_resolution_mm * camera.focal_length_mm) / camera.pixel_size_mm
|
|
11
|
+
return height_m / 1000.0
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def spatial_resolution_from_height(flight_height_m: float, camera: PinholeCamera) -> float:
|
|
15
|
+
"""Compute ground resolution in mm/pixel for a given flight height."""
|
|
16
|
+
resolution_m = camera.pixel_size_mm * flight_height_m / camera.focal_length_mm
|
|
17
|
+
return resolution_m * 1000.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def lateral_line_spacing(
|
|
21
|
+
flight_height_m: float,
|
|
22
|
+
lateral_overlap: float,
|
|
23
|
+
camera: PinholeCamera,
|
|
24
|
+
) -> float:
|
|
25
|
+
"""Distance between adjacent sweep lines in meters."""
|
|
26
|
+
return (
|
|
27
|
+
(flight_height_m / camera.focal_length_mm)
|
|
28
|
+
* camera.sensor_width_mm
|
|
29
|
+
* (1.0 - lateral_overlap)
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def forward_trigger_distance(
|
|
34
|
+
flight_height_m: float,
|
|
35
|
+
forward_overlap: float,
|
|
36
|
+
camera: PinholeCamera,
|
|
37
|
+
) -> float:
|
|
38
|
+
"""Forward camera trigger distance in meters."""
|
|
39
|
+
return (
|
|
40
|
+
(flight_height_m / camera.focal_length_mm)
|
|
41
|
+
* camera.sensor_height_mm
|
|
42
|
+
* (1.0 - forward_overlap)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def cruise_height_for_velocity(
|
|
47
|
+
velocity_m_s: float,
|
|
48
|
+
forward_overlap: float,
|
|
49
|
+
camera: PinholeCamera,
|
|
50
|
+
) -> float:
|
|
51
|
+
"""Compute flight height that matches velocity and camera capture rate."""
|
|
52
|
+
return (
|
|
53
|
+
velocity_m_s
|
|
54
|
+
* camera.capture_rate_s
|
|
55
|
+
* camera.focal_length_mm
|
|
56
|
+
/ (camera.sensor_height_mm * (1.0 - forward_overlap))
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def capture_rate_for_height(
|
|
61
|
+
flight_height_m: float,
|
|
62
|
+
forward_overlap: float,
|
|
63
|
+
velocity_m_s: float,
|
|
64
|
+
camera: PinholeCamera,
|
|
65
|
+
) -> float:
|
|
66
|
+
"""Required capture interval in seconds for the given flight parameters."""
|
|
67
|
+
trigger_distance = forward_trigger_distance(flight_height_m, forward_overlap, camera)
|
|
68
|
+
return trigger_distance / velocity_m_s
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def minimum_survey_height(
|
|
72
|
+
forward_overlap: float,
|
|
73
|
+
camera: PinholeCamera,
|
|
74
|
+
uav: UAV,
|
|
75
|
+
) -> float:
|
|
76
|
+
"""Minimum feasible survey height given UAV minimum velocity."""
|
|
77
|
+
min_trigger = camera.capture_rate_s * uav.min_velocity
|
|
78
|
+
return (min_trigger * camera.focal_length_mm) / (
|
|
79
|
+
camera.sensor_height_mm * (1.0 - forward_overlap)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def resolve_survey_parameters(
|
|
84
|
+
*,
|
|
85
|
+
uav: UAV,
|
|
86
|
+
camera: PinholeCamera,
|
|
87
|
+
lateral_overlap: float,
|
|
88
|
+
forward_overlap: float,
|
|
89
|
+
target: TargetPlanning,
|
|
90
|
+
target_value: float,
|
|
91
|
+
) -> dict[str, float]:
|
|
92
|
+
"""
|
|
93
|
+
Resolve flight height, spatial resolution, velocity, and trigger distance.
|
|
94
|
+
|
|
95
|
+
Mirrors the parameter selection logic from the original C++ SearchPattern class.
|
|
96
|
+
"""
|
|
97
|
+
if target == TargetPlanning.RESOLUTION:
|
|
98
|
+
flight_height = flight_height_from_resolution(target_value, camera)
|
|
99
|
+
spatial_resolution = target_value
|
|
100
|
+
velocity = uav.survey_velocity
|
|
101
|
+
elif target == TargetPlanning.ALTITUDE:
|
|
102
|
+
flight_height = target_value
|
|
103
|
+
spatial_resolution = spatial_resolution_from_height(flight_height, camera)
|
|
104
|
+
velocity = uav.survey_velocity
|
|
105
|
+
elif target == TargetPlanning.VELOCITY:
|
|
106
|
+
velocity = target_value
|
|
107
|
+
flight_height = cruise_height_for_velocity(velocity, forward_overlap, camera)
|
|
108
|
+
spatial_resolution = spatial_resolution_from_height(flight_height, camera)
|
|
109
|
+
else:
|
|
110
|
+
raise ValueError(f"Unsupported planning target: {target}")
|
|
111
|
+
|
|
112
|
+
if flight_height < uav.min_height:
|
|
113
|
+
flight_height = uav.min_height
|
|
114
|
+
spatial_resolution = spatial_resolution_from_height(flight_height, camera)
|
|
115
|
+
|
|
116
|
+
if flight_height > uav.max_height:
|
|
117
|
+
flight_height = uav.max_height
|
|
118
|
+
spatial_resolution = spatial_resolution_from_height(flight_height, camera)
|
|
119
|
+
|
|
120
|
+
trigger_distance = forward_trigger_distance(flight_height, forward_overlap, camera)
|
|
121
|
+
required_capture_rate = capture_rate_for_height(
|
|
122
|
+
flight_height, forward_overlap, velocity, camera
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
"flight_height": flight_height,
|
|
127
|
+
"spatial_resolution": spatial_resolution,
|
|
128
|
+
"velocity": velocity,
|
|
129
|
+
"trigger_distance": trigger_distance,
|
|
130
|
+
"required_capture_rate": required_capture_rate,
|
|
131
|
+
"lateral_spacing": lateral_line_spacing(flight_height, lateral_overlap, camera),
|
|
132
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Coverage search pattern implementations."""
|
|
2
|
+
|
|
3
|
+
from opencoverage.patterns.back_forth import BackAndForthPattern
|
|
4
|
+
from opencoverage.patterns.base import SearchPattern
|
|
5
|
+
from opencoverage.patterns.following_wind import FollowingWindPattern
|
|
6
|
+
from opencoverage.patterns.long_edge import LongEdgePattern
|
|
7
|
+
from opencoverage.patterns.optimal_sweep import OptimalSweepPattern
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"BackAndForthPattern",
|
|
11
|
+
"FollowingWindPattern",
|
|
12
|
+
"LongEdgePattern",
|
|
13
|
+
"OptimalSweepPattern",
|
|
14
|
+
"SearchPattern",
|
|
15
|
+
]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Back-and-forth (boustrophedon) coverage sweep pattern."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from opencoverage.geometry.map import SurveyMap
|
|
6
|
+
from opencoverage.geometry.transforms import rotate_points
|
|
7
|
+
from opencoverage.models import FlightMission
|
|
8
|
+
from opencoverage.patterns.base import SearchPattern
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BackAndForthPattern(SearchPattern):
|
|
12
|
+
"""Generate parallel sweep lines across the polygon bounding width."""
|
|
13
|
+
|
|
14
|
+
LEFT_TO_RIGHT = 1
|
|
15
|
+
RIGHT_TO_LEFT = -1
|
|
16
|
+
BOTTOM_UP = 1
|
|
17
|
+
TOP_DOWN = -1
|
|
18
|
+
|
|
19
|
+
def calculate(self) -> FlightMission:
|
|
20
|
+
waypoints = self.generate_pattern(self.survey_map)
|
|
21
|
+
return self.build_mission(waypoints)
|
|
22
|
+
|
|
23
|
+
def generate_rotated_pattern(self, angle_rad: float) -> list[tuple[float, float]]:
|
|
24
|
+
"""Generate a back-and-forth path after rotating the map by angle_rad."""
|
|
25
|
+
rotated_map = self.survey_map.rotated_copy(angle_rad)
|
|
26
|
+
waypoints = self.generate_pattern(rotated_map)
|
|
27
|
+
return rotate_points(waypoints, -angle_rad)
|
|
28
|
+
|
|
29
|
+
def generate_pattern(self, survey_map: SurveyMap) -> list[tuple[float, float]]:
|
|
30
|
+
"""Generate waypoint path for the given map."""
|
|
31
|
+
extremes = survey_map.extreme_vertices()
|
|
32
|
+
left_point = extremes["left"]
|
|
33
|
+
right_point = extremes["right"]
|
|
34
|
+
|
|
35
|
+
horizontal_order = self.LEFT_TO_RIGHT
|
|
36
|
+
vertical_order = self.BOTTOM_UP
|
|
37
|
+
lateral_displacement = self.lateral_spacing * horizontal_order
|
|
38
|
+
|
|
39
|
+
if horizontal_order == self.LEFT_TO_RIGHT:
|
|
40
|
+
line_x = left_point[0]
|
|
41
|
+
min_x = left_point[0]
|
|
42
|
+
max_x = right_point[0]
|
|
43
|
+
else:
|
|
44
|
+
line_x = right_point[0]
|
|
45
|
+
min_x = left_point[0]
|
|
46
|
+
max_x = right_point[0]
|
|
47
|
+
|
|
48
|
+
waypoints: list[tuple[float, float]] = []
|
|
49
|
+
|
|
50
|
+
while min_x <= line_x <= max_x:
|
|
51
|
+
points = survey_map.intersect_vertical_line(line_x)
|
|
52
|
+
points.sort(key=lambda p: p[1], reverse=vertical_order == self.TOP_DOWN)
|
|
53
|
+
vertical_order *= -1
|
|
54
|
+
waypoints.extend(points)
|
|
55
|
+
line_x += lateral_displacement
|
|
56
|
+
|
|
57
|
+
return waypoints
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Base class for coverage search patterns."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
|
|
7
|
+
from opencoverage.geometry.map import SurveyMap
|
|
8
|
+
from opencoverage.models import FlightMission, PinholeCamera, TargetPlanning, UAV
|
|
9
|
+
from opencoverage.parameters import resolve_survey_parameters
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SearchPattern(ABC):
|
|
13
|
+
"""Abstract coverage path planner."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, uav: UAV, camera: PinholeCamera, survey_map: SurveyMap) -> None:
|
|
16
|
+
self.uav = uav
|
|
17
|
+
self.camera = camera
|
|
18
|
+
self.survey_map = survey_map
|
|
19
|
+
self.lateral_overlap = 0.0
|
|
20
|
+
self.forward_overlap = 0.0
|
|
21
|
+
self.target = TargetPlanning.VELOCITY
|
|
22
|
+
self.target_value = 0.0
|
|
23
|
+
self.flight_height = 0.0
|
|
24
|
+
self.spatial_resolution = 0.0
|
|
25
|
+
self.velocity = 0.0
|
|
26
|
+
self.trigger_distance = 0.0
|
|
27
|
+
self.lateral_spacing = 0.0
|
|
28
|
+
self.flight_time_s = 0.0
|
|
29
|
+
|
|
30
|
+
def set_search_parameters(
|
|
31
|
+
self,
|
|
32
|
+
lateral_overlap: float,
|
|
33
|
+
forward_overlap: float,
|
|
34
|
+
target_value: float,
|
|
35
|
+
target: TargetPlanning,
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Configure overlap ratios and the planning objective."""
|
|
38
|
+
self.lateral_overlap = lateral_overlap
|
|
39
|
+
self.forward_overlap = forward_overlap
|
|
40
|
+
self.target = target
|
|
41
|
+
self.target_value = target_value
|
|
42
|
+
|
|
43
|
+
params = resolve_survey_parameters(
|
|
44
|
+
uav=self.uav,
|
|
45
|
+
camera=self.camera,
|
|
46
|
+
lateral_overlap=lateral_overlap,
|
|
47
|
+
forward_overlap=forward_overlap,
|
|
48
|
+
target=target,
|
|
49
|
+
target_value=target_value,
|
|
50
|
+
)
|
|
51
|
+
self.flight_height = params["flight_height"]
|
|
52
|
+
self.spatial_resolution = params["spatial_resolution"]
|
|
53
|
+
self.velocity = params["velocity"]
|
|
54
|
+
self.trigger_distance = params["trigger_distance"]
|
|
55
|
+
self.lateral_spacing = params["lateral_spacing"]
|
|
56
|
+
|
|
57
|
+
def build_mission(self, waypoints: list[tuple[float, float]]) -> FlightMission:
|
|
58
|
+
"""Create a FlightMission from a local waypoint path."""
|
|
59
|
+
capture_rate = self.trigger_distance / self.velocity if self.velocity else 0.0
|
|
60
|
+
return FlightMission(
|
|
61
|
+
path=waypoints,
|
|
62
|
+
flight_height=self.flight_height,
|
|
63
|
+
home=self.survey_map.home,
|
|
64
|
+
takeoff=self.survey_map.takeoff,
|
|
65
|
+
reference=self.survey_map.reference,
|
|
66
|
+
forward_distance=self.trigger_distance,
|
|
67
|
+
lateral_distance=self.lateral_spacing,
|
|
68
|
+
capture_rate=capture_rate,
|
|
69
|
+
flight_time_s=self.uav.estimate_flight_time(waypoints),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
@abstractmethod
|
|
73
|
+
def calculate(self) -> FlightMission:
|
|
74
|
+
"""Compute the coverage mission."""
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Wind-aligned coverage sweep pattern."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
from opencoverage.models import FlightMission
|
|
8
|
+
from opencoverage.patterns.back_forth import BackAndForthPattern
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FollowingWindPattern(BackAndForthPattern):
|
|
12
|
+
"""
|
|
13
|
+
Align sweep lines with a wind direction.
|
|
14
|
+
|
|
15
|
+
Wind direction uses meteorological convention in degrees:
|
|
16
|
+
0 = from north, 90 = from east, 180 = from south, 270 = from west.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
uav,
|
|
22
|
+
camera,
|
|
23
|
+
survey_map,
|
|
24
|
+
*,
|
|
25
|
+
wind_direction_deg: float = 0.0,
|
|
26
|
+
) -> None:
|
|
27
|
+
super().__init__(uav, camera, survey_map)
|
|
28
|
+
self.wind_direction_deg = wind_direction_deg
|
|
29
|
+
|
|
30
|
+
def calculate(self) -> FlightMission:
|
|
31
|
+
rotation = math.radians(self.wind_direction_deg)
|
|
32
|
+
waypoints = self.generate_rotated_pattern(rotation)
|
|
33
|
+
return self.build_mission(waypoints)
|