dji-telemetry 1.0.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.
- dji_telemetry/__init__.py +80 -0
- dji_telemetry/exporter.py +210 -0
- dji_telemetry/overlay.py +248 -0
- dji_telemetry/parser.py +267 -0
- dji_telemetry/video.py +299 -0
- dji_telemetry-1.0.0.dist-info/METADATA +299 -0
- dji_telemetry-1.0.0.dist-info/RECORD +11 -0
- dji_telemetry-1.0.0.dist-info/WHEEL +5 -0
- dji_telemetry-1.0.0.dist-info/entry_points.txt +2 -0
- dji_telemetry-1.0.0.dist-info/licenses/LICENSE +25 -0
- dji_telemetry-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DJI Telemetry Overlay Library
|
|
3
|
+
|
|
4
|
+
A Python library to parse DJI drone SRT telemetry files and overlay
|
|
5
|
+
flight data onto video footage.
|
|
6
|
+
|
|
7
|
+
Basic usage:
|
|
8
|
+
from dji_telemetry import parse_srt, process_video
|
|
9
|
+
|
|
10
|
+
# Parse telemetry
|
|
11
|
+
telemetry = parse_srt('video.SRT')
|
|
12
|
+
|
|
13
|
+
# Process video with overlay
|
|
14
|
+
process_video('video.MP4', telemetry, 'output.mp4')
|
|
15
|
+
|
|
16
|
+
Export telemetry data:
|
|
17
|
+
from dji_telemetry import parse_srt, export
|
|
18
|
+
|
|
19
|
+
telemetry = parse_srt('video.SRT')
|
|
20
|
+
export(telemetry, 'telemetry.csv')
|
|
21
|
+
export(telemetry, 'telemetry.json')
|
|
22
|
+
export(telemetry, 'telemetry.gpx')
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
__version__ = '1.0.0'
|
|
26
|
+
|
|
27
|
+
# Core parser
|
|
28
|
+
from .parser import (
|
|
29
|
+
parse_srt,
|
|
30
|
+
TelemetryFrame,
|
|
31
|
+
TelemetryData,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Exporters
|
|
35
|
+
from .exporter import (
|
|
36
|
+
export,
|
|
37
|
+
to_csv,
|
|
38
|
+
to_json,
|
|
39
|
+
to_gpx,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Overlay rendering
|
|
43
|
+
from .overlay import (
|
|
44
|
+
OverlayConfig,
|
|
45
|
+
OverlayRenderer,
|
|
46
|
+
create_transparent_frame,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Video processing
|
|
50
|
+
from .video import (
|
|
51
|
+
process_video,
|
|
52
|
+
generate_overlay_video,
|
|
53
|
+
generate_overlay_frames,
|
|
54
|
+
add_audio,
|
|
55
|
+
get_video_info,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
# Version
|
|
60
|
+
'__version__',
|
|
61
|
+
# Parser
|
|
62
|
+
'parse_srt',
|
|
63
|
+
'TelemetryFrame',
|
|
64
|
+
'TelemetryData',
|
|
65
|
+
# Exporters
|
|
66
|
+
'export',
|
|
67
|
+
'to_csv',
|
|
68
|
+
'to_json',
|
|
69
|
+
'to_gpx',
|
|
70
|
+
# Overlay
|
|
71
|
+
'OverlayConfig',
|
|
72
|
+
'OverlayRenderer',
|
|
73
|
+
'create_transparent_frame',
|
|
74
|
+
# Video
|
|
75
|
+
'process_video',
|
|
76
|
+
'generate_overlay_video',
|
|
77
|
+
'generate_overlay_frames',
|
|
78
|
+
'add_audio',
|
|
79
|
+
'get_video_info',
|
|
80
|
+
]
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Export telemetry data to various formats (CSV, JSON, GPX).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import json
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
from xml.etree import ElementTree as ET
|
|
11
|
+
from xml.dom import minidom
|
|
12
|
+
|
|
13
|
+
from .parser import TelemetryData
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def to_csv(data: TelemetryData, output_path: str | Path, include_all_fields: bool = True) -> Path:
|
|
17
|
+
"""
|
|
18
|
+
Export telemetry data to CSV format.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
data: TelemetryData object
|
|
22
|
+
output_path: Output file path
|
|
23
|
+
include_all_fields: Include all fields or just essential ones
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Path to the created file
|
|
27
|
+
"""
|
|
28
|
+
output_path = Path(output_path)
|
|
29
|
+
|
|
30
|
+
if include_all_fields:
|
|
31
|
+
fieldnames = [
|
|
32
|
+
'frame_num', 'timestamp', 'start_time_ms', 'end_time_ms',
|
|
33
|
+
'latitude', 'longitude', 'rel_altitude_m', 'abs_altitude_m',
|
|
34
|
+
'h_speed_ms', 'h_speed_kmh', 'v_speed_ms', 'distance_m',
|
|
35
|
+
'iso', 'shutter', 'fnum', 'ev', 'color_temp'
|
|
36
|
+
]
|
|
37
|
+
else:
|
|
38
|
+
fieldnames = [
|
|
39
|
+
'timestamp', 'latitude', 'longitude', 'rel_altitude_m',
|
|
40
|
+
'h_speed_kmh', 'v_speed_ms'
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
with open(output_path, 'w', newline='', encoding='utf-8') as f:
|
|
44
|
+
writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction='ignore')
|
|
45
|
+
writer.writeheader()
|
|
46
|
+
for frame in data.frames:
|
|
47
|
+
writer.writerow(frame.to_dict())
|
|
48
|
+
|
|
49
|
+
return output_path
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def to_json(data: TelemetryData, output_path: str | Path, indent: int = 2) -> Path:
|
|
53
|
+
"""
|
|
54
|
+
Export telemetry data to JSON format.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
data: TelemetryData object
|
|
58
|
+
output_path: Output file path
|
|
59
|
+
indent: JSON indentation level (None for compact)
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Path to the created file
|
|
63
|
+
"""
|
|
64
|
+
output_path = Path(output_path)
|
|
65
|
+
|
|
66
|
+
output = {
|
|
67
|
+
'metadata': {
|
|
68
|
+
'source_file': data.source_file,
|
|
69
|
+
'total_frames': len(data.frames),
|
|
70
|
+
'duration_seconds': data.duration_seconds,
|
|
71
|
+
'total_distance_m': data.total_distance,
|
|
72
|
+
'max_altitude_m': data.max_altitude,
|
|
73
|
+
'max_speed_kmh': data.max_speed * 3.6,
|
|
74
|
+
'start_coordinates': {
|
|
75
|
+
'latitude': data.start_coordinates[0],
|
|
76
|
+
'longitude': data.start_coordinates[1]
|
|
77
|
+
},
|
|
78
|
+
'end_coordinates': {
|
|
79
|
+
'latitude': data.end_coordinates[0],
|
|
80
|
+
'longitude': data.end_coordinates[1]
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
'frames': data.to_list()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
with open(output_path, 'w', encoding='utf-8') as f:
|
|
87
|
+
json.dump(output, f, indent=indent)
|
|
88
|
+
|
|
89
|
+
return output_path
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def to_gpx(
|
|
93
|
+
data: TelemetryData,
|
|
94
|
+
output_path: str | Path,
|
|
95
|
+
name: Optional[str] = None,
|
|
96
|
+
description: Optional[str] = None
|
|
97
|
+
) -> Path:
|
|
98
|
+
"""
|
|
99
|
+
Export telemetry data to GPX format.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
data: TelemetryData object
|
|
103
|
+
output_path: Output file path
|
|
104
|
+
name: Track name (defaults to source filename)
|
|
105
|
+
description: Track description
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Path to the created file
|
|
109
|
+
"""
|
|
110
|
+
output_path = Path(output_path)
|
|
111
|
+
|
|
112
|
+
# Create GPX root element
|
|
113
|
+
gpx = ET.Element('gpx')
|
|
114
|
+
gpx.set('version', '1.1')
|
|
115
|
+
gpx.set('creator', 'dji-telemetry-overlay')
|
|
116
|
+
gpx.set('xmlns', 'http://www.topografix.com/GPX/1/1')
|
|
117
|
+
gpx.set('xmlns:xsi', 'http://www.w3.org/2001/XMLSchema-instance')
|
|
118
|
+
gpx.set('xsi:schemaLocation', 'http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd')
|
|
119
|
+
|
|
120
|
+
# Metadata
|
|
121
|
+
metadata = ET.SubElement(gpx, 'metadata')
|
|
122
|
+
if name or data.source_file:
|
|
123
|
+
name_elem = ET.SubElement(metadata, 'name')
|
|
124
|
+
name_elem.text = name or Path(data.source_file).stem if data.source_file else 'DJI Flight'
|
|
125
|
+
if description:
|
|
126
|
+
desc_elem = ET.SubElement(metadata, 'desc')
|
|
127
|
+
desc_elem.text = description
|
|
128
|
+
|
|
129
|
+
# Create track
|
|
130
|
+
trk = ET.SubElement(gpx, 'trk')
|
|
131
|
+
|
|
132
|
+
trk_name = ET.SubElement(trk, 'name')
|
|
133
|
+
trk_name.text = name or (Path(data.source_file).stem if data.source_file else 'DJI Flight')
|
|
134
|
+
|
|
135
|
+
# Track segment
|
|
136
|
+
trkseg = ET.SubElement(trk, 'trkseg')
|
|
137
|
+
|
|
138
|
+
# Add track points
|
|
139
|
+
for frame in data.frames:
|
|
140
|
+
trkpt = ET.SubElement(trkseg, 'trkpt')
|
|
141
|
+
trkpt.set('lat', f'{frame.latitude:.6f}')
|
|
142
|
+
trkpt.set('lon', f'{frame.longitude:.6f}')
|
|
143
|
+
|
|
144
|
+
# Elevation (using absolute altitude for GPX)
|
|
145
|
+
ele = ET.SubElement(trkpt, 'ele')
|
|
146
|
+
ele.text = f'{frame.abs_alt:.1f}'
|
|
147
|
+
|
|
148
|
+
# Time
|
|
149
|
+
if frame.timestamp:
|
|
150
|
+
time_elem = ET.SubElement(trkpt, 'time')
|
|
151
|
+
try:
|
|
152
|
+
# Parse timestamp and convert to ISO format
|
|
153
|
+
dt = datetime.strptime(frame.timestamp, '%Y-%m-%d %H:%M:%S.%f')
|
|
154
|
+
time_elem.text = dt.strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z'
|
|
155
|
+
except ValueError:
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
# Extensions for speed data
|
|
159
|
+
extensions = ET.SubElement(trkpt, 'extensions')
|
|
160
|
+
|
|
161
|
+
speed = ET.SubElement(extensions, 'speed')
|
|
162
|
+
speed.text = f'{frame.h_speed:.2f}'
|
|
163
|
+
|
|
164
|
+
vspeed = ET.SubElement(extensions, 'vspeed')
|
|
165
|
+
vspeed.text = f'{frame.v_speed:.2f}'
|
|
166
|
+
|
|
167
|
+
# Pretty print
|
|
168
|
+
xml_str = ET.tostring(gpx, encoding='unicode')
|
|
169
|
+
dom = minidom.parseString(xml_str)
|
|
170
|
+
pretty_xml = dom.toprettyxml(indent=' ')
|
|
171
|
+
|
|
172
|
+
# Remove extra blank lines
|
|
173
|
+
lines = [line for line in pretty_xml.split('\n') if line.strip()]
|
|
174
|
+
pretty_xml = '\n'.join(lines)
|
|
175
|
+
|
|
176
|
+
with open(output_path, 'w', encoding='utf-8') as f:
|
|
177
|
+
f.write(pretty_xml)
|
|
178
|
+
|
|
179
|
+
return output_path
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def export(
|
|
183
|
+
data: TelemetryData,
|
|
184
|
+
output_path: str | Path,
|
|
185
|
+
format: Optional[str] = None
|
|
186
|
+
) -> Path:
|
|
187
|
+
"""
|
|
188
|
+
Export telemetry data to specified format (auto-detected from extension if not specified).
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
data: TelemetryData object
|
|
192
|
+
output_path: Output file path
|
|
193
|
+
format: Output format ('csv', 'json', 'gpx') - auto-detected if None
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
Path to the created file
|
|
197
|
+
"""
|
|
198
|
+
output_path = Path(output_path)
|
|
199
|
+
|
|
200
|
+
if format is None:
|
|
201
|
+
format = output_path.suffix.lower().lstrip('.')
|
|
202
|
+
|
|
203
|
+
if format == 'csv':
|
|
204
|
+
return to_csv(data, output_path)
|
|
205
|
+
elif format == 'json':
|
|
206
|
+
return to_json(data, output_path)
|
|
207
|
+
elif format == 'gpx':
|
|
208
|
+
return to_gpx(data, output_path)
|
|
209
|
+
else:
|
|
210
|
+
raise ValueError(f"Unsupported format: {format}. Use 'csv', 'json', or 'gpx'.")
|
dji_telemetry/overlay.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Telemetry overlay rendering.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import cv2
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from .parser import TelemetryFrame
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class OverlayConfig:
|
|
17
|
+
"""Configuration for telemetry overlay rendering."""
|
|
18
|
+
# What to display
|
|
19
|
+
show_altitude: bool = True
|
|
20
|
+
show_speed: bool = True
|
|
21
|
+
show_vertical_speed: bool = True
|
|
22
|
+
show_coordinates: bool = True
|
|
23
|
+
show_camera_settings: bool = True
|
|
24
|
+
show_timestamp: bool = True
|
|
25
|
+
show_speed_gauge: bool = True
|
|
26
|
+
|
|
27
|
+
# Speed gauge settings
|
|
28
|
+
gauge_max_speed_kmh: float = 50.0
|
|
29
|
+
|
|
30
|
+
# Font settings
|
|
31
|
+
font_scale_factor: float = 1.0
|
|
32
|
+
|
|
33
|
+
# Colors (BGR format)
|
|
34
|
+
text_color: tuple[int, int, int] = (255, 255, 255)
|
|
35
|
+
shadow_color: tuple[int, int, int] = (30, 30, 30)
|
|
36
|
+
gauge_color: tuple[int, int, int] = (255, 255, 255)
|
|
37
|
+
gauge_needle_color: tuple[int, int, int] = (255, 200, 0)
|
|
38
|
+
|
|
39
|
+
# Position adjustments (relative to video dimensions)
|
|
40
|
+
padding_factor: float = 0.015 # Padding as fraction of height
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class OverlayRenderer:
|
|
44
|
+
"""Renders telemetry overlay onto video frames."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, width: int, height: int, config: Optional[OverlayConfig] = None):
|
|
47
|
+
"""
|
|
48
|
+
Initialize the overlay renderer.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
width: Video width in pixels
|
|
52
|
+
height: Video height in pixels
|
|
53
|
+
config: Overlay configuration (uses defaults if None)
|
|
54
|
+
"""
|
|
55
|
+
self.width = width
|
|
56
|
+
self.height = height
|
|
57
|
+
self.config = config or OverlayConfig()
|
|
58
|
+
|
|
59
|
+
# Calculate scaled values
|
|
60
|
+
self.scale_factor = (height / 1080.0) * self.config.font_scale_factor
|
|
61
|
+
self.font = cv2.FONT_HERSHEY_SIMPLEX
|
|
62
|
+
self.font_scale_large = 0.7 * self.scale_factor
|
|
63
|
+
self.font_scale_small = 0.55 * self.scale_factor
|
|
64
|
+
self.thickness = max(1, int(2 * self.scale_factor))
|
|
65
|
+
self.padding = int(height * self.config.padding_factor)
|
|
66
|
+
self.line_height = int(30 * self.scale_factor)
|
|
67
|
+
|
|
68
|
+
def _draw_text_with_shadow(
|
|
69
|
+
self,
|
|
70
|
+
img: np.ndarray,
|
|
71
|
+
text: str,
|
|
72
|
+
pos: tuple[int, int],
|
|
73
|
+
font_scale: float,
|
|
74
|
+
color: Optional[tuple[int, int, int]] = None
|
|
75
|
+
):
|
|
76
|
+
"""Draw text with shadow for better visibility."""
|
|
77
|
+
x, y = pos
|
|
78
|
+
color = color or self.config.text_color
|
|
79
|
+
shadow_offset = max(1, int(2 * self.scale_factor))
|
|
80
|
+
|
|
81
|
+
cv2.putText(img, text, (x + shadow_offset, y + shadow_offset),
|
|
82
|
+
self.font, font_scale, self.config.shadow_color,
|
|
83
|
+
self.thickness + 1, cv2.LINE_AA)
|
|
84
|
+
cv2.putText(img, text, (x, y), self.font, font_scale, color,
|
|
85
|
+
self.thickness, cv2.LINE_AA)
|
|
86
|
+
|
|
87
|
+
def _get_text_size(self, text: str, font_scale: float) -> tuple[int, int]:
|
|
88
|
+
"""Get the size of text."""
|
|
89
|
+
size = cv2.getTextSize(text, self.font, font_scale, self.thickness)[0]
|
|
90
|
+
return size[0], size[1]
|
|
91
|
+
|
|
92
|
+
def render(self, telemetry: TelemetryFrame, frame: Optional[np.ndarray] = None) -> np.ndarray:
|
|
93
|
+
"""
|
|
94
|
+
Render telemetry overlay.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
telemetry: Telemetry data for the current frame
|
|
98
|
+
frame: Video frame to draw on (creates transparent if None)
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Frame with telemetry overlay
|
|
102
|
+
"""
|
|
103
|
+
if frame is None:
|
|
104
|
+
# Create transparent frame (BGRA)
|
|
105
|
+
overlay = np.zeros((self.height, self.width, 4), dtype=np.uint8)
|
|
106
|
+
is_transparent = True
|
|
107
|
+
else:
|
|
108
|
+
overlay = frame.copy()
|
|
109
|
+
is_transparent = False
|
|
110
|
+
|
|
111
|
+
# Get color for drawing (handle transparent vs opaque)
|
|
112
|
+
def get_color(bgr_color):
|
|
113
|
+
if is_transparent:
|
|
114
|
+
return (*bgr_color, 255) # Add alpha
|
|
115
|
+
return bgr_color
|
|
116
|
+
|
|
117
|
+
text_color = get_color(self.config.text_color)
|
|
118
|
+
shadow_color = get_color(self.config.shadow_color)
|
|
119
|
+
|
|
120
|
+
# === TOP LEFT: Flight Data ===
|
|
121
|
+
y_pos = self.padding + self.line_height
|
|
122
|
+
|
|
123
|
+
if self.config.show_altitude:
|
|
124
|
+
alt_text = f"ALT: {telemetry.rel_alt:.1f}m"
|
|
125
|
+
self._draw_text_with_shadow(overlay, alt_text, (self.padding, y_pos), self.font_scale_large)
|
|
126
|
+
y_pos += self.line_height
|
|
127
|
+
|
|
128
|
+
if self.config.show_speed:
|
|
129
|
+
h_speed_kmh = telemetry.h_speed * 3.6
|
|
130
|
+
speed_text = f"H.SPD: {h_speed_kmh:.1f} km/h"
|
|
131
|
+
self._draw_text_with_shadow(overlay, speed_text, (self.padding, y_pos), self.font_scale_large)
|
|
132
|
+
y_pos += self.line_height
|
|
133
|
+
|
|
134
|
+
if self.config.show_vertical_speed:
|
|
135
|
+
v_speed_text = f"V.SPD: {telemetry.v_speed:+.1f} m/s"
|
|
136
|
+
self._draw_text_with_shadow(overlay, v_speed_text, (self.padding, y_pos), self.font_scale_large)
|
|
137
|
+
|
|
138
|
+
# === TOP RIGHT: Camera Settings ===
|
|
139
|
+
if self.config.show_camera_settings:
|
|
140
|
+
y_pos = self.padding + self.line_height
|
|
141
|
+
right_margin = self.width - self.padding
|
|
142
|
+
|
|
143
|
+
# ISO
|
|
144
|
+
iso_text = f"ISO {telemetry.iso}"
|
|
145
|
+
text_w, _ = self._get_text_size(iso_text, self.font_scale_small)
|
|
146
|
+
self._draw_text_with_shadow(overlay, iso_text,
|
|
147
|
+
(right_margin - text_w, y_pos), self.font_scale_small)
|
|
148
|
+
y_pos += self.line_height
|
|
149
|
+
|
|
150
|
+
# Shutter
|
|
151
|
+
shutter_text = f"{telemetry.shutter}s"
|
|
152
|
+
text_w, _ = self._get_text_size(shutter_text, self.font_scale_small)
|
|
153
|
+
self._draw_text_with_shadow(overlay, shutter_text,
|
|
154
|
+
(right_margin - text_w, y_pos), self.font_scale_small)
|
|
155
|
+
y_pos += self.line_height
|
|
156
|
+
|
|
157
|
+
# Aperture
|
|
158
|
+
fnum_text = f"f/{telemetry.fnum}"
|
|
159
|
+
text_w, _ = self._get_text_size(fnum_text, self.font_scale_small)
|
|
160
|
+
self._draw_text_with_shadow(overlay, fnum_text,
|
|
161
|
+
(right_margin - text_w, y_pos), self.font_scale_small)
|
|
162
|
+
y_pos += self.line_height
|
|
163
|
+
|
|
164
|
+
# EV
|
|
165
|
+
ev_text = f"EV {telemetry.ev:+.1f}"
|
|
166
|
+
text_w, _ = self._get_text_size(ev_text, self.font_scale_small)
|
|
167
|
+
self._draw_text_with_shadow(overlay, ev_text,
|
|
168
|
+
(right_margin - text_w, y_pos), self.font_scale_small)
|
|
169
|
+
|
|
170
|
+
# === BOTTOM LEFT: GPS Coordinates ===
|
|
171
|
+
if self.config.show_coordinates:
|
|
172
|
+
y_pos = self.height - self.padding - self.line_height
|
|
173
|
+
lat_dir = "S" if telemetry.latitude < 0 else "N"
|
|
174
|
+
lon_dir = "W" if telemetry.longitude < 0 else "E"
|
|
175
|
+
coords_text = f"{abs(telemetry.latitude):.6f}{lat_dir} {abs(telemetry.longitude):.6f}{lon_dir}"
|
|
176
|
+
self._draw_text_with_shadow(overlay, coords_text, (self.padding, y_pos), self.font_scale_small)
|
|
177
|
+
|
|
178
|
+
# === BOTTOM RIGHT: Timestamp ===
|
|
179
|
+
if self.config.show_timestamp and telemetry.timestamp:
|
|
180
|
+
time_only = telemetry.timestamp.split(' ')[-1].split('.')[0] # Get HH:MM:SS
|
|
181
|
+
text_w, _ = self._get_text_size(time_only, self.font_scale_small)
|
|
182
|
+
self._draw_text_with_shadow(overlay, time_only,
|
|
183
|
+
(self.width - self.padding - text_w,
|
|
184
|
+
self.height - self.padding - self.line_height),
|
|
185
|
+
self.font_scale_small)
|
|
186
|
+
|
|
187
|
+
# === BOTTOM CENTER: Speed Gauge ===
|
|
188
|
+
if self.config.show_speed_gauge:
|
|
189
|
+
self._draw_speed_gauge(overlay, telemetry.h_speed * 3.6)
|
|
190
|
+
|
|
191
|
+
return overlay
|
|
192
|
+
|
|
193
|
+
def _draw_speed_gauge(self, img: np.ndarray, speed_kmh: float):
|
|
194
|
+
"""Draw the speed gauge at bottom center."""
|
|
195
|
+
gauge_center_x = self.width // 2
|
|
196
|
+
gauge_center_y = self.height - int(80 * self.scale_factor)
|
|
197
|
+
gauge_radius = int(50 * self.scale_factor)
|
|
198
|
+
|
|
199
|
+
# Check if transparent (BGRA) or opaque (BGR)
|
|
200
|
+
is_transparent = img.shape[2] == 4
|
|
201
|
+
|
|
202
|
+
def get_color(bgr_color):
|
|
203
|
+
if is_transparent:
|
|
204
|
+
return (*bgr_color, 255)
|
|
205
|
+
return bgr_color
|
|
206
|
+
|
|
207
|
+
gauge_color = get_color(self.config.gauge_color)
|
|
208
|
+
shadow_color = get_color(self.config.shadow_color)
|
|
209
|
+
needle_color = get_color(self.config.gauge_needle_color)
|
|
210
|
+
|
|
211
|
+
# Draw gauge background arc
|
|
212
|
+
cv2.ellipse(img, (gauge_center_x, gauge_center_y), (gauge_radius, gauge_radius),
|
|
213
|
+
0, 180, 360, shadow_color, max(2, int(4 * self.scale_factor)))
|
|
214
|
+
cv2.ellipse(img, (gauge_center_x, gauge_center_y), (gauge_radius, gauge_radius),
|
|
215
|
+
0, 180, 360, gauge_color, max(1, int(2 * self.scale_factor)))
|
|
216
|
+
|
|
217
|
+
# Speed indicator needle
|
|
218
|
+
speed_ratio = min(speed_kmh / self.config.gauge_max_speed_kmh, 1.0)
|
|
219
|
+
angle_deg = 180 + speed_ratio * 180
|
|
220
|
+
angle_rad = math.radians(angle_deg)
|
|
221
|
+
|
|
222
|
+
needle_length = gauge_radius - int(10 * self.scale_factor)
|
|
223
|
+
needle_x = int(gauge_center_x + needle_length * math.cos(angle_rad))
|
|
224
|
+
needle_y = int(gauge_center_y + needle_length * math.sin(angle_rad))
|
|
225
|
+
|
|
226
|
+
cv2.line(img, (gauge_center_x, gauge_center_y), (needle_x, needle_y),
|
|
227
|
+
needle_color, max(2, int(3 * self.scale_factor)), cv2.LINE_AA)
|
|
228
|
+
|
|
229
|
+
# Speed value
|
|
230
|
+
speed_val_text = f"{speed_kmh:.0f}"
|
|
231
|
+
text_w, text_h = self._get_text_size(speed_val_text, self.font_scale_large)
|
|
232
|
+
self._draw_text_with_shadow(img, speed_val_text,
|
|
233
|
+
(gauge_center_x - text_w // 2,
|
|
234
|
+
gauge_center_y - int(10 * self.scale_factor)),
|
|
235
|
+
self.font_scale_large)
|
|
236
|
+
|
|
237
|
+
# km/h label
|
|
238
|
+
unit_text = "km/h"
|
|
239
|
+
text_w, _ = self._get_text_size(unit_text, self.font_scale_small * 0.8)
|
|
240
|
+
self._draw_text_with_shadow(img, unit_text,
|
|
241
|
+
(gauge_center_x - text_w // 2,
|
|
242
|
+
gauge_center_y + int(15 * self.scale_factor)),
|
|
243
|
+
self.font_scale_small * 0.8)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def create_transparent_frame(width: int, height: int) -> np.ndarray:
|
|
247
|
+
"""Create a transparent BGRA frame."""
|
|
248
|
+
return np.zeros((height, width, 4), dtype=np.uint8)
|