pyFlightPlotter 0.3.1__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.
- pyFlightPlotter/__init__.py +22 -0
- pyFlightPlotter/core.py +958 -0
- pyFlightPlotter/crafts.py +55 -0
- pyFlightPlotter/cursor.py +107 -0
- pyFlightPlotter/style.py +25 -0
- pyflightplotter-0.3.1.dist-info/METADATA +181 -0
- pyflightplotter-0.3.1.dist-info/RECORD +10 -0
- pyflightplotter-0.3.1.dist-info/WHEEL +5 -0
- pyflightplotter-0.3.1.dist-info/licenses/LICENSE +674 -0
- pyflightplotter-0.3.1.dist-info/top_level.txt +1 -0
pyFlightPlotter/core.py
ADDED
|
@@ -0,0 +1,958 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import os
|
|
3
|
+
import importlib
|
|
4
|
+
from scipy.interpolate import interp1d
|
|
5
|
+
from scipy.spatial.transform import Rotation as R
|
|
6
|
+
|
|
7
|
+
from matplotlib import pyplot as plt
|
|
8
|
+
from matplotlib.gridspec import GridSpec
|
|
9
|
+
from matplotlib.lines import Line2D
|
|
10
|
+
from matplotlib.widgets import Button, Slider, TextBox
|
|
11
|
+
|
|
12
|
+
COLORS = plt.rcParams['axes.prop_cycle'].by_key()['color']
|
|
13
|
+
|
|
14
|
+
class FlightPlotterBase(object):
|
|
15
|
+
def __init__(self, time, name="Flight Plotter"):
|
|
16
|
+
self.t = np.asarray(time, dtype=float)
|
|
17
|
+
# make sure time is a 1D array
|
|
18
|
+
if self.t.ndim != 1:
|
|
19
|
+
raise ValueError("time must be a 1D array")
|
|
20
|
+
|
|
21
|
+
self.name = name
|
|
22
|
+
self.all_axes = []
|
|
23
|
+
|
|
24
|
+
def define_layout(self, figsize=(12, 8), nrows=3, ncols=3, width_ratios=None, height_ratios=None):
|
|
25
|
+
if width_ratios is None:
|
|
26
|
+
width_ratios = [1] * ncols
|
|
27
|
+
if height_ratios is None:
|
|
28
|
+
height_ratios = [1] * nrows
|
|
29
|
+
|
|
30
|
+
self.fig = plt.figure(figsize=figsize)
|
|
31
|
+
self.gs = GridSpec(nrows=nrows, ncols=ncols,
|
|
32
|
+
width_ratios=width_ratios,
|
|
33
|
+
height_ratios=height_ratios)
|
|
34
|
+
|
|
35
|
+
def plot(self):
|
|
36
|
+
self._populate()
|
|
37
|
+
self._dress()
|
|
38
|
+
# self.curser = BlittedCursor(self.all_axes, self.fig.canvas, sharex=True)
|
|
39
|
+
self.fig.show()
|
|
40
|
+
|
|
41
|
+
def _populate(self):
|
|
42
|
+
raise NotImplementedError("Child class should implement this method to populate the plot.")
|
|
43
|
+
|
|
44
|
+
def connect_viewport(self, viewport):
|
|
45
|
+
self._add_callback('motion_notify_event', viewport.mouseHoverCallback)
|
|
46
|
+
|
|
47
|
+
def _add_callback(self, event_type, callback):
|
|
48
|
+
# e,g, motion_notify_event
|
|
49
|
+
self.fig.canvas.mpl_connect(event_type, callback)
|
|
50
|
+
|
|
51
|
+
def _find_axis(self, subplot_spec):
|
|
52
|
+
axes = [ax for ax in self.fig.axes
|
|
53
|
+
if ax.get_subplotspec() == subplot_spec]
|
|
54
|
+
if len(axes) == 0:
|
|
55
|
+
return None
|
|
56
|
+
return axes[0]
|
|
57
|
+
|
|
58
|
+
def _plot_timeseries(self, ax, light=None, solid=None, dashed=None, true_values=None, series_labels=[], style_labels=[None, None, None], title="", ylabel="", ylimits=(None, None)):
|
|
59
|
+
if solid is None or len(solid) == 0:
|
|
60
|
+
raise ValueError("At least one solid series must be provided.")
|
|
61
|
+
if len(series_labels) != len(solid):
|
|
62
|
+
raise ValueError("series_labels must have the same length as solid series.")
|
|
63
|
+
if len(style_labels) != 3:
|
|
64
|
+
raise ValueError("style_labels must have exactly 3 entries. Set to None if not needed.")
|
|
65
|
+
if light is None or len(light) == 0:
|
|
66
|
+
light = [None] * len(solid)
|
|
67
|
+
if dashed is None or len(dashed) == 0:
|
|
68
|
+
dashed = [None] * len(solid)
|
|
69
|
+
if true_values is None or len(true_values) == 0:
|
|
70
|
+
true_values = [None] * len(solid)
|
|
71
|
+
lengths = np.array([len(solid), len(light), len(dashed), len(true_values), len(series_labels)])
|
|
72
|
+
if not (lengths == lengths[0]).all():
|
|
73
|
+
raise ValueError("light, solid, dashed, true_values and labels must all have the same length if given.")
|
|
74
|
+
|
|
75
|
+
for i, series in enumerate(light):
|
|
76
|
+
if series is not None:
|
|
77
|
+
ax.plot(self.t, series, color=COLORS[i], alpha=0.3, lw=1.0, linestyle='-')
|
|
78
|
+
|
|
79
|
+
for i, series in enumerate(solid):
|
|
80
|
+
if series is not None:
|
|
81
|
+
ax.plot(self.t, series, label=series_labels[i], color=COLORS[i], alpha=0.8, lw=1.0, linestyle='-')
|
|
82
|
+
|
|
83
|
+
for i, series in enumerate(dashed):
|
|
84
|
+
if series is not None:
|
|
85
|
+
ax.plot(self.t, series, color=COLORS[i], lw=1.5, linestyle='--')
|
|
86
|
+
|
|
87
|
+
for i, series in enumerate(true_values):
|
|
88
|
+
if series is not None:
|
|
89
|
+
ax.hlines(series, self.t[0], self.t[-1], color=COLORS[i], lw=1.0, linestyle=':')
|
|
90
|
+
|
|
91
|
+
self.all_axes.append(ax)
|
|
92
|
+
ax.set_title(title)
|
|
93
|
+
ax.set_ylabel(ylabel)
|
|
94
|
+
ax.set_ylim(ylimits)
|
|
95
|
+
|
|
96
|
+
ax.add_artist(ax.legend(loc='upper left'))
|
|
97
|
+
ax.add_artist(self._generate_style_legend(ax, style_labels))
|
|
98
|
+
|
|
99
|
+
def _generate_style_legend(self, ax, labels):
|
|
100
|
+
linestyles = []
|
|
101
|
+
if labels[0] is not None:
|
|
102
|
+
linestyles.append(Line2D([0], [0], color='gray', alpha=0.3, lw=1.0, linestyle='-', label=labels[0]))
|
|
103
|
+
if labels[1] is not None:
|
|
104
|
+
linestyles.append(Line2D([0], [0], color='gray', alpha=0.8, lw=1.0, linestyle='-', label=labels[1]))
|
|
105
|
+
if labels[2] is not None:
|
|
106
|
+
linestyles.append(Line2D([0], [0], color='gray', alpha=1.0, lw=1.5, linestyle='--', label=labels[2]))
|
|
107
|
+
|
|
108
|
+
leg_styles = ax.legend(handles=linestyles, title='Line Styles', loc='lower left')
|
|
109
|
+
|
|
110
|
+
return leg_styles
|
|
111
|
+
|
|
112
|
+
def _dress(self):
|
|
113
|
+
for ax in self.all_axes:
|
|
114
|
+
ax.grid(True)
|
|
115
|
+
# only set time label if in lowest row
|
|
116
|
+
if ax.get_subplotspec().is_last_row():
|
|
117
|
+
ax.set_xlabel("Time [s]")
|
|
118
|
+
|
|
119
|
+
self.fig.suptitle(self.name)
|
|
120
|
+
|
|
121
|
+
class Craft3D(object):
|
|
122
|
+
"""Base class that can generate x,y,z coordinates to visualize a craft
|
|
123
|
+
|
|
124
|
+
This class contains a generate method that takes in an attitude quaternion
|
|
125
|
+
and optional rotor and surface controls, and returns x,y,z coordinates that
|
|
126
|
+
can be given to matplotlib for 3D plotting.
|
|
127
|
+
|
|
128
|
+
This class can be extended to define specific craft geometries, see crafts.py
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
body_geometry: list of Nx3 arrays defining the body geometry
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Craft3D object
|
|
135
|
+
|
|
136
|
+
"""
|
|
137
|
+
def __init__(self, body_geometry=[np.array([[0,0,0]])]):
|
|
138
|
+
|
|
139
|
+
# check that array is Nx3
|
|
140
|
+
self.Ng = len(body_geometry)
|
|
141
|
+
self.geometry = []
|
|
142
|
+
for element in body_geometry:
|
|
143
|
+
self.geometry.append(np.asarray(element, dtype=float))
|
|
144
|
+
if self.geometry[-1].ndim != 2 or self.geometry[-1].shape[1] != 3:
|
|
145
|
+
raise ValueError("Base geometry must be a list of Nx3 arrays")
|
|
146
|
+
|
|
147
|
+
self.rotors = []
|
|
148
|
+
self.surfaces = []
|
|
149
|
+
|
|
150
|
+
def addRotor(self,
|
|
151
|
+
xyz=[0, 0, 0], axis=[0, 0, -1],
|
|
152
|
+
tilt_xyz=[0, 0, 0], tilt_axis=[1, 0, 0],
|
|
153
|
+
R=0.1, N=20):
|
|
154
|
+
|
|
155
|
+
xyz = np.asarray(xyz, dtype=float)
|
|
156
|
+
axis = np.asarray(axis, dtype=float)
|
|
157
|
+
axis /= np.linalg.norm(axis)
|
|
158
|
+
|
|
159
|
+
tilt_xyz = np.asarray(tilt_xyz, dtype=float)
|
|
160
|
+
tilt_axis = np.asarray(tilt_axis, dtype=float)
|
|
161
|
+
tilt_axis /= np.linalg.norm(tilt_axis)
|
|
162
|
+
|
|
163
|
+
# compute cross tilt axis, and check that tilt_axis is not parallel to axis
|
|
164
|
+
cross = np.cross(axis, tilt_axis)
|
|
165
|
+
if np.linalg.norm(cross) < 1e-4:
|
|
166
|
+
raise ValueError("tilt_axis cannot be parallel to axis")
|
|
167
|
+
cross /= np.linalg.norm(cross)
|
|
168
|
+
|
|
169
|
+
self.rotors.append({
|
|
170
|
+
"xyz": xyz,
|
|
171
|
+
"axis": axis,
|
|
172
|
+
"tilt_xyz": tilt_xyz,
|
|
173
|
+
"tilt_axis": tilt_axis,
|
|
174
|
+
"tilt2_axis": cross,
|
|
175
|
+
"R": R,
|
|
176
|
+
"N": N,
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
def addSurface(self,
|
|
180
|
+
tilt_xyz=[0, 0, 0], tilt_axis=[0, 1, 0],
|
|
181
|
+
geometry=[np.array([0, 0, 0])]):
|
|
182
|
+
tilt_xyz = np.asarray(tilt_xyz, dtype=float)
|
|
183
|
+
tilt_axis = np.asarray(tilt_axis, dtype=float)
|
|
184
|
+
geometry = [np.asarray(g, dtype=float) for g in geometry]
|
|
185
|
+
|
|
186
|
+
self.surfaces.append({
|
|
187
|
+
"tilt_xyz": tilt_xyz,
|
|
188
|
+
"tilt_axis": tilt_axis,
|
|
189
|
+
"geometry": geometry
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
def generate(self, quat, rotor_controls=None, surface_controls=None):
|
|
193
|
+
if np.linalg.norm(quat) < 1e-6:
|
|
194
|
+
quat = np.array([0, 0, 0, 1])
|
|
195
|
+
body_rotation = R.from_quat(quat)
|
|
196
|
+
|
|
197
|
+
xs, ys, zs = np.array([]), np.array([]), np.array([])
|
|
198
|
+
for geo in self.geometry:
|
|
199
|
+
geo_rotated = body_rotation.apply(geo)
|
|
200
|
+
xs = np.concatenate((xs, geo_rotated[:, 0], np.array([np.nan])))
|
|
201
|
+
ys = np.concatenate((ys, geo_rotated[:, 1], np.array([np.nan])))
|
|
202
|
+
zs = np.concatenate((zs, geo_rotated[:, 2], np.array([np.nan])))
|
|
203
|
+
|
|
204
|
+
if rotor_controls is None:
|
|
205
|
+
rotor_controls = np.zeros((len(self.rotors), 3))
|
|
206
|
+
elif rotor_controls.shape[0] != len(self.rotors):
|
|
207
|
+
raise ValueError("rotor_controls must have the same length as the number of rotors")
|
|
208
|
+
|
|
209
|
+
if surface_controls is None:
|
|
210
|
+
surface_controls = np.zeros((len(self.surfaces),))
|
|
211
|
+
elif surface_controls.shape[0] != len(self.surfaces):
|
|
212
|
+
raise ValueError("surface_controls must have the same length as the number of surfaces")
|
|
213
|
+
|
|
214
|
+
arrows = []
|
|
215
|
+
for rotor, controls in zip(self.rotors, rotor_controls):
|
|
216
|
+
# generate circle in the rotor plane by using xyz, axis
|
|
217
|
+
Rr = rotor["R"]
|
|
218
|
+
xyz = rotor["xyz"]
|
|
219
|
+
axis = rotor["axis"]
|
|
220
|
+
tilt_xyz = rotor["tilt_xyz"]
|
|
221
|
+
|
|
222
|
+
tilt1_axis = rotor["tilt_axis"]
|
|
223
|
+
tilt1_angle = controls[1]
|
|
224
|
+
tilt1_rotation = R.from_rotvec(tilt1_axis*tilt1_angle)
|
|
225
|
+
|
|
226
|
+
tilt2_axis = rotor["tilt2_axis"]
|
|
227
|
+
tilt2_angle = controls[2]
|
|
228
|
+
tilt2_rotation = R.from_rotvec(tilt2_axis*tilt2_angle)
|
|
229
|
+
tilt_rotation = tilt2_rotation * tilt1_rotation
|
|
230
|
+
|
|
231
|
+
N = rotor["N"]
|
|
232
|
+
u = np.linspace(0, 2*np.pi, N)
|
|
233
|
+
|
|
234
|
+
# generate circle in xy plane
|
|
235
|
+
x = Rr * np.cos(u)
|
|
236
|
+
y = Rr * np.sin(u)
|
|
237
|
+
z = np.zeros_like(u)
|
|
238
|
+
circle = np.vstack((x, y, z)).T
|
|
239
|
+
|
|
240
|
+
# rotate circle to align with rotor axis
|
|
241
|
+
circle_tilted = tilt_rotation.apply(circle)
|
|
242
|
+
|
|
243
|
+
foot_xyz = xyz - tilt_xyz
|
|
244
|
+
real_xyz = foot_xyz + tilt_rotation.apply(tilt_xyz)
|
|
245
|
+
|
|
246
|
+
circle_tilted += real_xyz
|
|
247
|
+
|
|
248
|
+
circle_rotated = body_rotation.apply(circle_tilted)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
xs = np.concatenate((xs, circle_rotated[:, 0], np.array([np.nan])))
|
|
252
|
+
ys = np.concatenate((ys, circle_rotated[:, 1], np.array([np.nan])))
|
|
253
|
+
zs = np.concatenate((zs, circle_rotated[:, 2], np.array([np.nan])))
|
|
254
|
+
|
|
255
|
+
# arrow for thrust
|
|
256
|
+
thrust = controls[0]
|
|
257
|
+
real_axis = body_rotation.apply(tilt_rotation.apply(axis))
|
|
258
|
+
rotated_real_xyz = body_rotation.apply(real_xyz)
|
|
259
|
+
|
|
260
|
+
arrow_start = rotated_real_xyz
|
|
261
|
+
arrow_end = rotated_real_xyz + real_axis * thrust * 2. * Rr # scale thrust for visualization
|
|
262
|
+
arrow_max = rotated_real_xyz + real_axis * 2. * Rr # max arrow length for visualization
|
|
263
|
+
|
|
264
|
+
arrows.append([arrow_start, arrow_end, arrow_max])
|
|
265
|
+
|
|
266
|
+
for surface, control in zip(self.surfaces, surface_controls):
|
|
267
|
+
geometry = surface["geometry"]
|
|
268
|
+
tilt_xyz = surface["tilt_xyz"]
|
|
269
|
+
tilt_axis = surface["tilt_axis"]
|
|
270
|
+
tilt_angle = control
|
|
271
|
+
tilt_rotation = R.from_rotvec(tilt_axis*tilt_angle)
|
|
272
|
+
|
|
273
|
+
for geo in geometry:
|
|
274
|
+
geo_tilted = tilt_rotation.apply(geo - tilt_xyz) + tilt_xyz
|
|
275
|
+
geo_rotated = body_rotation.apply(geo_tilted)
|
|
276
|
+
xs = np.concatenate((xs, geo_rotated[:, 0], np.array([np.nan])))
|
|
277
|
+
ys = np.concatenate((ys, geo_rotated[:, 1], np.array([np.nan])))
|
|
278
|
+
zs = np.concatenate((zs, geo_rotated[:, 2], np.array([np.nan])))
|
|
279
|
+
|
|
280
|
+
return xs, ys, zs, arrows
|
|
281
|
+
|
|
282
|
+
class Viewport(object):
|
|
283
|
+
"""Generate an updatable 3D viewport for visualizing the state of a craft
|
|
284
|
+
"""
|
|
285
|
+
def __init__(self, craft: Craft3D,
|
|
286
|
+
time,
|
|
287
|
+
att, attSet=None, attMeas=None,
|
|
288
|
+
pos=None, posSet=None, posMeas=None,
|
|
289
|
+
vel=None, velSet=None, velMeas=None,
|
|
290
|
+
acc=None, accSet=None, accMeas=None,
|
|
291
|
+
rotor=None, rotorSet=None,
|
|
292
|
+
surface=None, surfaceSet=None,
|
|
293
|
+
follow=False, interpolation="previous", title="Viewport"):
|
|
294
|
+
"""Initialize the viewport and open its plot window. Numpy arrays expected.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
craft: Craft3D object defining the vehicle geometry
|
|
298
|
+
time: 1D array of time stamps
|
|
299
|
+
att: Nx4 array of attitude quaternions [w, x, y, z]
|
|
300
|
+
attSet: Nx4 array of attitude setpoint quaternions [w, x, y, z]
|
|
301
|
+
attMeas: Nx4 array of attitude measurement quaternions [w, x, y, z]
|
|
302
|
+
pos: Nx3 array of position [x, y, z]
|
|
303
|
+
posSet: Nx3 array of position setpoint [x, y, z]
|
|
304
|
+
posMeas: Nx3 array of position measurement [x, y, z]
|
|
305
|
+
vel: Nx3 array of velocity [vx, vy, vz]
|
|
306
|
+
velSet: Nx3 array of velocity setpoint [vx, vy, vz]
|
|
307
|
+
velMeas: Nx3 array of velocity measurement [vx, vy, vz]
|
|
308
|
+
acc: Nx3 array of acceleration [ax, ay, az]
|
|
309
|
+
accSet: Nx3 array of acceleration setpoint [ax, ay, az]
|
|
310
|
+
accMeas: Nx3 array of acceleration measurement [ax, ay, az]
|
|
311
|
+
rotor: NxMx3 array of rotor state for M rotors
|
|
312
|
+
rotorSet: NxMx3 array of rotor controls (thrust, tilt1, tilt2) for M rotors
|
|
313
|
+
surface: NxK array of surface state for K surfaces
|
|
314
|
+
surfaceSet: NxK array of surface controls for K surfaces
|
|
315
|
+
follow: if True, the camera will follow the vehicle position
|
|
316
|
+
interpolation: interpolation method for data ("previous", "linear")
|
|
317
|
+
title: title of the plot
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
Viewport object
|
|
321
|
+
"""
|
|
322
|
+
|
|
323
|
+
self.craft = craft
|
|
324
|
+
self.name = title
|
|
325
|
+
self.t = time
|
|
326
|
+
self.follow = follow
|
|
327
|
+
self.has_pos = pos is not None
|
|
328
|
+
self.interpolation = interpolation
|
|
329
|
+
|
|
330
|
+
if self.interpolation not in ["previous", "linear"]:
|
|
331
|
+
raise ValueError("interpolation must be one of 'previous' or 'linear'")
|
|
332
|
+
|
|
333
|
+
#%% define data series and their plotting styles
|
|
334
|
+
# attitude
|
|
335
|
+
self.att = {"att": {"raw": att[:, [1,2,3,0]], "style": "solid", "color": COLORS[0], "marker": None, "width": 2.0, "label": "Attitude Estimate"}}
|
|
336
|
+
if attSet is not None:
|
|
337
|
+
self.att["attSet"] = {"raw": attSet[:, [1,2,3,0]], "style": "dashed", "color": COLORS[1], "marker": None, "width": 0.8, "label": "Attitude Setpoint"}
|
|
338
|
+
if attMeas is not None:
|
|
339
|
+
self.att["attMeas"] = {"raw": attMeas[:, [1,2,3,0]], "style": "dashed", "color": COLORS[2], "marker": None, "width": 0.8, "label": "Attitude Measurement"}
|
|
340
|
+
|
|
341
|
+
# position
|
|
342
|
+
self.pos = {}
|
|
343
|
+
if pos is not None:
|
|
344
|
+
self.pos["pos"] = {"raw": pos, "style": "solid", "color": COLORS[0], "marker": ".", "width": 1.5, "label": "Position Estimate"}
|
|
345
|
+
if posSet is not None:
|
|
346
|
+
self.pos["posSet"] = {"raw": posSet, "style": "dashed", "color": COLORS[1], "marker": "o", "width": 1.0, "label": "Position Setpoint"}
|
|
347
|
+
if posMeas is not None:
|
|
348
|
+
self.pos["posMeas"] = {"raw": posMeas, "style": "solid", "color": COLORS[2], "marker": "o", "width": 3.0, "label": "Position Measurement"}
|
|
349
|
+
|
|
350
|
+
# velocity
|
|
351
|
+
self.vel = {}
|
|
352
|
+
if vel is not None:
|
|
353
|
+
self.vel["vel"] = {"raw": vel, "style": "solid", "color": COLORS[0], "marker": None, "width": 2.0, "label": "Velocity Estimate"}
|
|
354
|
+
if velSet is not None:
|
|
355
|
+
self.vel["velSet"] = {"raw": velSet, "style": "dashed", "color": COLORS[1], "marker": None, "width": 1.0, "label": "Velocity Setpoint"}
|
|
356
|
+
if velMeas is not None:
|
|
357
|
+
self.vel["velMeas"] = {"raw": velMeas, "style": "dashed", "color": COLORS[2], "marker": None, "width": 1.0, "label": "Velocity Measurement"}
|
|
358
|
+
|
|
359
|
+
# acceleration
|
|
360
|
+
self.acc = {}
|
|
361
|
+
if acc is not None:
|
|
362
|
+
self.acc["acc"] = {"raw": acc, "style": "solid", "color": COLORS[3], "marker": None, "width": 2.0, "label": "Acceleration Estimate"}
|
|
363
|
+
if accSet is not None:
|
|
364
|
+
self.acc["accSet"] = {"raw": accSet, "style": "dashed", "color": COLORS[4], "marker": None, "width": 1.0, "label": "Acceleration Setpoint"}
|
|
365
|
+
if accMeas is not None:
|
|
366
|
+
self.acc["accMeas"] = {"raw": accMeas, "style": "dashed", "color": COLORS[5], "marker": None, "width": 1.0, "label": "Acceleration Measurement"}
|
|
367
|
+
|
|
368
|
+
# controls
|
|
369
|
+
self.controls = {}
|
|
370
|
+
if rotor is not None:
|
|
371
|
+
self.controls["rotor"] = {"raw": rotor, "style": "solid", "color": COLORS[5], "marker": "s", "width": 3.0, "label": "Rotor States"}
|
|
372
|
+
if rotorSet is not None:
|
|
373
|
+
self.controls["rotorSet"] = {"raw": rotorSet, "style": "solid", "color": COLORS[6], "marker": "s", "width": 3.0, "label": "Rotor Controls"}
|
|
374
|
+
if surface is not None:
|
|
375
|
+
self.controls["surface"] = {"raw": surface, "style": "solid", "color": COLORS[7], "marker": None, "width": 3.0, "label": None}
|
|
376
|
+
if surfaceSet is not None:
|
|
377
|
+
self.controls["surfaceSet"] = {"raw": surfaceSet, "style": "solid", "color": COLORS[7], "marker": None, "width": 3.0, "label": None}
|
|
378
|
+
|
|
379
|
+
# collect all series
|
|
380
|
+
self.series = {**self.att, **self.pos, **self.vel, **self.acc, **self.controls}
|
|
381
|
+
|
|
382
|
+
#%% figure setup
|
|
383
|
+
self.fig = plt.figure(figsize=(8, 8))
|
|
384
|
+
self.ax = self.fig.add_subplot(111, projection='3d')
|
|
385
|
+
self.ax.set_xlabel("X")
|
|
386
|
+
self.ax.set_ylabel("Y")
|
|
387
|
+
self.ax.set_zlabel("Z")
|
|
388
|
+
|
|
389
|
+
# interpolators and initial plotting lines
|
|
390
|
+
self._create_interpolators()
|
|
391
|
+
|
|
392
|
+
for _, value in self.series.items():
|
|
393
|
+
value['line'] = self.ax.plot(
|
|
394
|
+
[np.nan], [np.nan], [np.nan],
|
|
395
|
+
linestyle=value['style'],
|
|
396
|
+
color=value['color'],
|
|
397
|
+
lw=value['width'],
|
|
398
|
+
label=value["label"])[0]
|
|
399
|
+
|
|
400
|
+
# set view angle
|
|
401
|
+
self.ax.view_init(elev=-25, azim=150, roll=180)
|
|
402
|
+
|
|
403
|
+
# set limits
|
|
404
|
+
self._update_limits()
|
|
405
|
+
|
|
406
|
+
# legend, title and show
|
|
407
|
+
self.ax.legend(loc='upper left')
|
|
408
|
+
self.ax.set_title(self.name)
|
|
409
|
+
self.fig.show()
|
|
410
|
+
|
|
411
|
+
def _update_limits(self):
|
|
412
|
+
if self.has_pos:
|
|
413
|
+
minx, miny, minz = np.min(self.series["pos"]["raw"], axis=0)
|
|
414
|
+
maxx, maxy, maxz = np.max(self.series["pos"]["raw"], axis=0)
|
|
415
|
+
else:
|
|
416
|
+
minx, miny, minz = 0, 0, 0
|
|
417
|
+
maxx, maxy, maxz = 0, 0, 0
|
|
418
|
+
|
|
419
|
+
self.ax.set_xlim(minx-0.5, maxx+0.5)
|
|
420
|
+
self.ax.set_ylim(miny-0.5, maxy+0.5)
|
|
421
|
+
self.ax.set_zlim(minz-0.5, maxz+0.5)
|
|
422
|
+
|
|
423
|
+
def _create_interpolators(self):
|
|
424
|
+
for _, value in self.series.items():
|
|
425
|
+
value['interpolator'] = interp1d(
|
|
426
|
+
self.t,
|
|
427
|
+
np.moveaxis(value['raw'], 0, -1),
|
|
428
|
+
kind=self.interpolation,
|
|
429
|
+
bounds_error=False,
|
|
430
|
+
fill_value=(value["raw"][0], value["raw"][-1]))
|
|
431
|
+
|
|
432
|
+
def pushNewFrame(self, time,
|
|
433
|
+
att, attSet=None, attMeas=None,
|
|
434
|
+
pos=None, posSet=None, posMeas=None,
|
|
435
|
+
vel=None, velSet=None, velMeas=None,
|
|
436
|
+
acc=None, accSet=None, accMeas=None,
|
|
437
|
+
rotor=None, rotorSet=None,
|
|
438
|
+
surface=None, surfaceSet=None):
|
|
439
|
+
"""Push a new frame of data. All data that was not None at initialization must be given here as well
|
|
440
|
+
"""
|
|
441
|
+
|
|
442
|
+
self.t = np.append(self.t, time)
|
|
443
|
+
self.series["att"]['raw'] = np.vstack((self.series["att"]['raw'], att[[1,2,3,0]]))
|
|
444
|
+
if attSet is not None:
|
|
445
|
+
# accept errors if series was not given before, that's up to the user
|
|
446
|
+
self.series["attSet"]['raw'] = np.vstack((self.series["attSet"]['raw'], attSet[[1,2,3,0]]))
|
|
447
|
+
if attMeas is not None:
|
|
448
|
+
self.series["attMeas"]['raw'] = np.vstack((self.series["attMeas"]['raw'], attMeas[[1,2,3,0]]))
|
|
449
|
+
if pos is not None:
|
|
450
|
+
self.series["pos"]['raw'] = np.vstack((self.series["pos"]['raw'], pos))
|
|
451
|
+
if posSet is not None:
|
|
452
|
+
self.series["posSet"]['raw'] = np.vstack((self.series["posSet"]['raw'], posSet))
|
|
453
|
+
if posMeas is not None:
|
|
454
|
+
self.series["posMeas"]['raw'] = np.vstack((self.series["posMeas"]['raw'], posMeas))
|
|
455
|
+
if vel is not None:
|
|
456
|
+
self.series["vel"]['raw'] = np.vstack((self.series["vel"]['raw'], vel))
|
|
457
|
+
if velSet is not None:
|
|
458
|
+
self.series["velSet"]['raw'] = np.vstack((self.series["velSet"]['raw'], velSet))
|
|
459
|
+
if velMeas is not None:
|
|
460
|
+
self.series["velMeas"]['raw'] = np.vstack((self.series["velMeas"]['raw'], velMeas))
|
|
461
|
+
if acc is not None:
|
|
462
|
+
self.series["acc"]['raw'] = np.vstack((self.series["acc"]['raw'], acc))
|
|
463
|
+
if accSet is not None:
|
|
464
|
+
self.series["accSet"]['raw'] = np.vstack((self.series["accSet"]['raw'], accSet))
|
|
465
|
+
if accMeas is not None:
|
|
466
|
+
self.series["accMeas"]['raw'] = np.vstack((self.series["accMeas"]['raw'], accMeas))
|
|
467
|
+
if rotor is not None:
|
|
468
|
+
self.series["rotor"]['raw'] = np.vstack((self.series["rotor"]['raw'], rotor[np.newaxis]))
|
|
469
|
+
if rotorSet is not None:
|
|
470
|
+
self.series["rotorSet"]['raw'] = np.vstack((self.series["rotorSet"]['raw'], rotorSet[np.newaxis]))
|
|
471
|
+
if surface is not None:
|
|
472
|
+
self.series["surface"]['raw'] = np.vstack((self.series["surface"]['raw'], surface))
|
|
473
|
+
if surfaceSet is not None:
|
|
474
|
+
self.series["surfaceSet"]['raw'] = np.vstack((self.series["surfaceSet"]['raw'], surfaceSet))
|
|
475
|
+
|
|
476
|
+
self._create_interpolators()
|
|
477
|
+
self._update_limits()
|
|
478
|
+
|
|
479
|
+
def showAtTime(self, time=None):
|
|
480
|
+
"""Update the viewport to show the state at a specific time
|
|
481
|
+
|
|
482
|
+
Args:
|
|
483
|
+
time (float): time stamp to show
|
|
484
|
+
|
|
485
|
+
Returns:
|
|
486
|
+
None
|
|
487
|
+
"""
|
|
488
|
+
|
|
489
|
+
if time is None:
|
|
490
|
+
time = self.t[-1]
|
|
491
|
+
|
|
492
|
+
# remove old lines
|
|
493
|
+
for _, ser in self.series.items():
|
|
494
|
+
try:
|
|
495
|
+
ser['line'].remove()
|
|
496
|
+
except ValueError:
|
|
497
|
+
pass
|
|
498
|
+
|
|
499
|
+
# get interpolates
|
|
500
|
+
interpolates = {}
|
|
501
|
+
for ser in self.series.keys():
|
|
502
|
+
interpolates[ser] = self.series[ser]['interpolator'](time)
|
|
503
|
+
|
|
504
|
+
# plot attitude
|
|
505
|
+
for ser in [x for x in ["att", "attSet", "attMeas"] if x in self.series.keys()]:
|
|
506
|
+
# invoke craft to get the rotated geometry
|
|
507
|
+
if ser == "attSet":
|
|
508
|
+
rotor_controls = interpolates["rotorSet"] if "rotorSet" in interpolates.keys() else None
|
|
509
|
+
surface_controls = interpolates["surfaceSet"] if "surfaceSet" in interpolates.keys() else None
|
|
510
|
+
elif ser == "att":
|
|
511
|
+
rotor_controls = interpolates["rotor"] if "rotor" in interpolates.keys() else None
|
|
512
|
+
surface_controls = interpolates["surface"] if "surface" in interpolates.keys() else None
|
|
513
|
+
else:
|
|
514
|
+
# never draw rotors or surface motion for measured attitude as it
|
|
515
|
+
# would suggest that these are also measured
|
|
516
|
+
rotor_controls = None
|
|
517
|
+
surface_controls = None
|
|
518
|
+
if self.follow:
|
|
519
|
+
# skip all of this entirely, as it would change the limits
|
|
520
|
+
continue
|
|
521
|
+
|
|
522
|
+
xs, ys, zs, q = self.craft.generate(interpolates[ser],
|
|
523
|
+
rotor_controls=rotor_controls,
|
|
524
|
+
surface_controls=surface_controls,
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
qs = np.empty((0, 3))
|
|
528
|
+
for arrow in q:
|
|
529
|
+
arrow_start, arrow_end, arrow_max = arrow
|
|
530
|
+
qs = np.concatenate((qs,
|
|
531
|
+
arrow_start[np.newaxis],
|
|
532
|
+
arrow_end[np.newaxis],
|
|
533
|
+
np.array([[np.nan, np.nan, np.nan]]),
|
|
534
|
+
arrow_max[np.newaxis],
|
|
535
|
+
np.array([[np.nan, np.nan, np.nan]]),
|
|
536
|
+
))
|
|
537
|
+
|
|
538
|
+
if "pos" in interpolates.keys():
|
|
539
|
+
qs += interpolates["pos"]
|
|
540
|
+
|
|
541
|
+
# translate geometry, if necessary
|
|
542
|
+
if "pos" in interpolates.keys():
|
|
543
|
+
if ser == "attMeas" and "posMeas" in interpolates.keys():
|
|
544
|
+
# offset by measured position
|
|
545
|
+
xs += interpolates["posMeas"][0]
|
|
546
|
+
ys += interpolates["posMeas"][1]
|
|
547
|
+
zs += interpolates["posMeas"][2]
|
|
548
|
+
else:
|
|
549
|
+
# offset by estimator position
|
|
550
|
+
xs += interpolates["pos"][0]
|
|
551
|
+
ys += interpolates["pos"][1]
|
|
552
|
+
zs += interpolates["pos"][2]
|
|
553
|
+
|
|
554
|
+
# update plot line
|
|
555
|
+
self.series[ser]['line'] = self.ax.plot(xs, ys, zs,
|
|
556
|
+
linestyle=self.series[ser]['style'],
|
|
557
|
+
color=self.series[ser]['color'],
|
|
558
|
+
lw=self.series[ser]['width'])[0]
|
|
559
|
+
|
|
560
|
+
# add arrows for rotors
|
|
561
|
+
if ser == "attSet" and len(q) > 0 and "rotorSet" in self.series.keys():
|
|
562
|
+
self.series["rotorSet"]['line'] = self.ax.plot(qs[:, 0], qs[:, 1], qs[:, 2],
|
|
563
|
+
linestyle=self.series["rotorSet"]['style'],
|
|
564
|
+
color=self.series["rotorSet"]['color'],
|
|
565
|
+
marker=self.series["rotorSet"]['marker'],
|
|
566
|
+
markersize=1,
|
|
567
|
+
lw=self.series["rotorSet"]['width'])[0]
|
|
568
|
+
|
|
569
|
+
if ser == "att" and len(q) > 0 and "rotor" in self.series.keys():
|
|
570
|
+
self.series["rotor"]['line'] = self.ax.plot(qs[:, 0], qs[:, 1], qs[:, 2],
|
|
571
|
+
linestyle=self.series["rotor"]['style'],
|
|
572
|
+
color=self.series["rotor"]['color'],
|
|
573
|
+
marker=self.series["rotor"]['marker'],
|
|
574
|
+
markersize=1,
|
|
575
|
+
lw=self.series["rotor"]['width'])[0]
|
|
576
|
+
|
|
577
|
+
if not self.follow:
|
|
578
|
+
# scatter plot for position
|
|
579
|
+
for ser in [x for x in ["pos", "posSet", "posMeas"] if x in self.series.keys()]:
|
|
580
|
+
self.series[ser]['line'] = self.ax.scatter(
|
|
581
|
+
interpolates[ser][0],
|
|
582
|
+
interpolates[ser][1],
|
|
583
|
+
interpolates[ser][2],
|
|
584
|
+
linestyle=self.series[ser]['style'],
|
|
585
|
+
color=self.series[ser]['color'],
|
|
586
|
+
lw=self.series[ser]['width'],
|
|
587
|
+
marker=self.series[ser]['marker'],
|
|
588
|
+
facecolor='none',
|
|
589
|
+
s=50*self.series[ser]['width'])
|
|
590
|
+
|
|
591
|
+
# line to show velocity
|
|
592
|
+
for ser in [x for x in ["vel", "velSet", "velMeas"] if x in self.series.keys()]:
|
|
593
|
+
if ser == "velMeas" and "posMeas" in interpolates.keys():
|
|
594
|
+
offset = interpolates["posMeas"]
|
|
595
|
+
elif "pos" in interpolates.keys():
|
|
596
|
+
offset = interpolates["pos"]
|
|
597
|
+
else:
|
|
598
|
+
offset = np.array([0, 0, 0])
|
|
599
|
+
|
|
600
|
+
self.series[ser]['line'] = self.ax.plot(
|
|
601
|
+
[offset[0], 0.2*interpolates[ser][0] + offset[0]],
|
|
602
|
+
[offset[1], 0.2*interpolates[ser][1] + offset[1]],
|
|
603
|
+
[offset[2], 0.2*interpolates[ser][2] + offset[2]],
|
|
604
|
+
linestyle=self.series[ser]['style'],
|
|
605
|
+
color=self.series[ser]['color'],
|
|
606
|
+
lw=self.series[ser]['width'])[0]
|
|
607
|
+
|
|
608
|
+
# line to show acceleration
|
|
609
|
+
for ser in [x for x in ["acc", "accSet", "accMeas"] if x in self.series.keys()]:
|
|
610
|
+
if ser == "accMeas" and "posMeas" in interpolates.keys():
|
|
611
|
+
offset = interpolates["posMeas"]
|
|
612
|
+
elif "pos" in interpolates.keys():
|
|
613
|
+
offset = interpolates["pos"]
|
|
614
|
+
else:
|
|
615
|
+
offset = np.array([0, 0, 0])
|
|
616
|
+
|
|
617
|
+
self.series[ser]['line'] = self.ax.plot(
|
|
618
|
+
[offset[0], 0.1*interpolates[ser][0] + offset[0]],
|
|
619
|
+
[offset[1], 0.1*interpolates[ser][1] + offset[1]],
|
|
620
|
+
[offset[2], 0.1*interpolates[ser][2] + offset[2]],
|
|
621
|
+
linestyle=self.series[ser]['style'],
|
|
622
|
+
color=self.series[ser]['color'],
|
|
623
|
+
lw=self.series[ser]['width'])[0]
|
|
624
|
+
|
|
625
|
+
# make sure all the axes are equal
|
|
626
|
+
if self.follow:
|
|
627
|
+
self.ax.set_aspect('equal', adjustable='datalim')
|
|
628
|
+
else:
|
|
629
|
+
self.ax.set_aspect('equal', adjustable='box')
|
|
630
|
+
|
|
631
|
+
# finally, update the canvas
|
|
632
|
+
self.fig.canvas.draw()
|
|
633
|
+
|
|
634
|
+
def mouseHoverCallback(self, event):
|
|
635
|
+
"""Callback function to update the viewport on mouse hover
|
|
636
|
+
|
|
637
|
+
Args:
|
|
638
|
+
event (matplotlib.backend_bases.MouseEvent): mouse event
|
|
639
|
+
|
|
640
|
+
Returns:
|
|
641
|
+
None
|
|
642
|
+
"""
|
|
643
|
+
if event.xdata is not None:
|
|
644
|
+
self.showAtTime(event.xdata)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
class VideoViewport(object):
|
|
648
|
+
"""MP4 viewport synchronized to the FlightPlotter timeline.
|
|
649
|
+
|
|
650
|
+
The mapping from data time to video time is:
|
|
651
|
+
video_time = data_time + time_offset
|
|
652
|
+
"""
|
|
653
|
+
|
|
654
|
+
def __init__(self, time, title="Video Viewport", mp4_path=None, offset_s=0.0):
|
|
655
|
+
self.t = np.asarray(time, dtype=float)
|
|
656
|
+
if self.t.ndim != 1:
|
|
657
|
+
raise ValueError("time must be a 1D array")
|
|
658
|
+
|
|
659
|
+
self.name = title
|
|
660
|
+
self.time_offset_s = float(offset_s)
|
|
661
|
+
self.current_data_time = float(self.t[0]) if len(self.t) > 0 else 0.0
|
|
662
|
+
|
|
663
|
+
self._video_path = None
|
|
664
|
+
self._video_filename = "<no video loaded>"
|
|
665
|
+
self._video_capture = None
|
|
666
|
+
self._fps = None
|
|
667
|
+
self._frame_count = None
|
|
668
|
+
self._duration_s = None
|
|
669
|
+
self._backend = None
|
|
670
|
+
self._last_frame_id = None
|
|
671
|
+
self._last_frame_rgb = None
|
|
672
|
+
self._last_rendered_frame_id = None
|
|
673
|
+
|
|
674
|
+
self.fig = plt.figure(figsize=(10, 7))
|
|
675
|
+
self.gs = GridSpec(nrows=10, ncols=12, figure=self.fig)
|
|
676
|
+
|
|
677
|
+
self.ax_video = self.fig.add_subplot(self.gs[0:8, :])
|
|
678
|
+
self.ax_video.set_axis_off()
|
|
679
|
+
self._imshow = None
|
|
680
|
+
|
|
681
|
+
self._overlay = self.ax_video.text(
|
|
682
|
+
0.01,
|
|
683
|
+
0.99,
|
|
684
|
+
"",
|
|
685
|
+
transform=self.ax_video.transAxes,
|
|
686
|
+
va='top',
|
|
687
|
+
ha='left',
|
|
688
|
+
color='white',
|
|
689
|
+
fontsize=10,
|
|
690
|
+
bbox=dict(facecolor='black', alpha=0.6, edgecolor='none', boxstyle='round,pad=0.25')
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
self.ax_open = self.fig.add_subplot(self.gs[8, 0:2])
|
|
694
|
+
self.ax_load = self.fig.add_subplot(self.gs[8, 2:4])
|
|
695
|
+
self.ax_path = self.fig.add_subplot(self.gs[8, 4:12])
|
|
696
|
+
self.ax_offset_slider = self.fig.add_subplot(self.gs[9, 0:10])
|
|
697
|
+
self.ax_offset_box = self.fig.add_subplot(self.gs[9, 10:12])
|
|
698
|
+
|
|
699
|
+
self.btn_open = Button(self.ax_open, "Open MP4")
|
|
700
|
+
self.btn_load = Button(self.ax_load, "Load")
|
|
701
|
+
self.path_box = TextBox(self.ax_path, "Path", initial="")
|
|
702
|
+
|
|
703
|
+
self.offset_slider = Slider(
|
|
704
|
+
self.ax_offset_slider,
|
|
705
|
+
"Offset [s]",
|
|
706
|
+
valmin=-30.0,
|
|
707
|
+
valmax=30.0,
|
|
708
|
+
valinit=self.time_offset_s,
|
|
709
|
+
valstep=None,
|
|
710
|
+
)
|
|
711
|
+
self.offset_box = TextBox(self.ax_offset_box, "", initial=f"{self.time_offset_s:.3f}")
|
|
712
|
+
|
|
713
|
+
self.btn_open.on_clicked(self._on_open_clicked)
|
|
714
|
+
self.btn_load.on_clicked(self._on_load_clicked)
|
|
715
|
+
self.offset_slider.on_changed(self._on_offset_slider_changed)
|
|
716
|
+
self.offset_box.on_submit(self._on_offset_box_submit)
|
|
717
|
+
self.path_box.on_submit(self._on_path_submit)
|
|
718
|
+
self.fig.canvas.mpl_connect('key_press_event', self._on_key_press)
|
|
719
|
+
|
|
720
|
+
self.ax_video.set_title(self.name)
|
|
721
|
+
self._set_placeholder("No video loaded")
|
|
722
|
+
self.fig.show()
|
|
723
|
+
|
|
724
|
+
if mp4_path is not None:
|
|
725
|
+
self.path_box.set_val(str(mp4_path))
|
|
726
|
+
self.load(mp4_path)
|
|
727
|
+
|
|
728
|
+
def _set_placeholder(self, text):
|
|
729
|
+
h, w = 360, 640
|
|
730
|
+
placeholder = np.zeros((h, w, 3), dtype=np.uint8)
|
|
731
|
+
if self._imshow is None:
|
|
732
|
+
self._imshow = self.ax_video.imshow(placeholder)
|
|
733
|
+
else:
|
|
734
|
+
self._imshow.set_data(placeholder)
|
|
735
|
+
self._update_overlay(frame_id=None, video_time=None, data_time=self.current_data_time, message=text)
|
|
736
|
+
self.fig.canvas.draw_idle()
|
|
737
|
+
|
|
738
|
+
def _try_open_dialog(self):
|
|
739
|
+
try:
|
|
740
|
+
import tkinter as tk
|
|
741
|
+
from tkinter import filedialog
|
|
742
|
+
|
|
743
|
+
root = tk.Tk()
|
|
744
|
+
root.withdraw()
|
|
745
|
+
root.attributes('-topmost', True)
|
|
746
|
+
selected = filedialog.askopenfilename(
|
|
747
|
+
title="Select MP4 file",
|
|
748
|
+
filetypes=[("MP4 files", "*.mp4"), ("All files", "*.*")],
|
|
749
|
+
)
|
|
750
|
+
root.destroy()
|
|
751
|
+
return selected
|
|
752
|
+
except Exception:
|
|
753
|
+
return None
|
|
754
|
+
|
|
755
|
+
def _on_open_clicked(self, _event):
|
|
756
|
+
selected = self._try_open_dialog()
|
|
757
|
+
if selected:
|
|
758
|
+
self.path_box.set_val(selected)
|
|
759
|
+
self.load(selected)
|
|
760
|
+
|
|
761
|
+
def _on_load_clicked(self, _event):
|
|
762
|
+
path = self.path_box.text.strip()
|
|
763
|
+
if path:
|
|
764
|
+
self.load(path)
|
|
765
|
+
|
|
766
|
+
def _on_path_submit(self, text):
|
|
767
|
+
path = text.strip()
|
|
768
|
+
if path:
|
|
769
|
+
self.load(path)
|
|
770
|
+
|
|
771
|
+
def _on_offset_slider_changed(self, value):
|
|
772
|
+
self.time_offset_s = float(value)
|
|
773
|
+
self.offset_box.set_val(f"{self.time_offset_s:.3f}")
|
|
774
|
+
self.showAtDataTime(self.current_data_time)
|
|
775
|
+
|
|
776
|
+
def _on_offset_box_submit(self, text):
|
|
777
|
+
try:
|
|
778
|
+
offset = float(text)
|
|
779
|
+
except ValueError:
|
|
780
|
+
self.offset_box.set_val(f"{self.time_offset_s:.3f}")
|
|
781
|
+
return
|
|
782
|
+
|
|
783
|
+
self.time_offset_s = offset
|
|
784
|
+
slider_min = float(self.offset_slider.valmin)
|
|
785
|
+
slider_max = float(self.offset_slider.valmax)
|
|
786
|
+
if offset < slider_min or offset > slider_max:
|
|
787
|
+
span = max(1.0, abs(offset) + 1.0)
|
|
788
|
+
self.offset_slider.valmin = -span
|
|
789
|
+
self.offset_slider.valmax = span
|
|
790
|
+
self.offset_slider.ax.set_xlim(self.offset_slider.valmin, self.offset_slider.valmax)
|
|
791
|
+
self.offset_slider.set_val(self.time_offset_s)
|
|
792
|
+
|
|
793
|
+
def _frame_time_step(self):
|
|
794
|
+
# Use video frame period if available; otherwise fall back to a sensible default.
|
|
795
|
+
fps = self._fps if self._fps is not None and self._fps > 0 else 30.0
|
|
796
|
+
return 1.0 / fps
|
|
797
|
+
|
|
798
|
+
def _set_offset(self, offset):
|
|
799
|
+
self.time_offset_s = float(offset)
|
|
800
|
+
slider_min = float(self.offset_slider.valmin)
|
|
801
|
+
slider_max = float(self.offset_slider.valmax)
|
|
802
|
+
if self.time_offset_s < slider_min or self.time_offset_s > slider_max:
|
|
803
|
+
span = max(1.0, abs(self.time_offset_s) + 1.0)
|
|
804
|
+
self.offset_slider.valmin = -span
|
|
805
|
+
self.offset_slider.valmax = span
|
|
806
|
+
self.offset_slider.ax.set_xlim(self.offset_slider.valmin, self.offset_slider.valmax)
|
|
807
|
+
self.offset_slider.set_val(self.time_offset_s)
|
|
808
|
+
|
|
809
|
+
def _on_key_press(self, event):
|
|
810
|
+
if event.key not in ("left", "right"):
|
|
811
|
+
return
|
|
812
|
+
|
|
813
|
+
step = self._frame_time_step()
|
|
814
|
+
if event.key == "left":
|
|
815
|
+
self._set_offset(self.time_offset_s - step)
|
|
816
|
+
elif event.key == "right":
|
|
817
|
+
self._set_offset(self.time_offset_s + step)
|
|
818
|
+
|
|
819
|
+
def _ensure_backend(self):
|
|
820
|
+
if self._backend is not None:
|
|
821
|
+
return
|
|
822
|
+
|
|
823
|
+
try:
|
|
824
|
+
cv2 = importlib.import_module('cv2')
|
|
825
|
+
self._backend = 'cv2'
|
|
826
|
+
self._cv2 = cv2
|
|
827
|
+
except ImportError:
|
|
828
|
+
raise ImportError("OpenCV (cv2) is required for VideoViewport. Install python package 'opencv-python'.")
|
|
829
|
+
|
|
830
|
+
def load(self, path):
|
|
831
|
+
self._ensure_backend()
|
|
832
|
+
|
|
833
|
+
if self._video_capture is not None:
|
|
834
|
+
self._video_capture.release()
|
|
835
|
+
self._video_capture = None
|
|
836
|
+
|
|
837
|
+
resolved = os.path.abspath(os.path.expanduser(path))
|
|
838
|
+
if not os.path.isfile(resolved):
|
|
839
|
+
self._set_placeholder(f"File not found: {resolved}")
|
|
840
|
+
return False
|
|
841
|
+
|
|
842
|
+
if not resolved.lower().endswith('.mp4'):
|
|
843
|
+
self._set_placeholder("Selected file is not an MP4")
|
|
844
|
+
return False
|
|
845
|
+
|
|
846
|
+
cap = self._cv2.VideoCapture(resolved)
|
|
847
|
+
if not cap.isOpened():
|
|
848
|
+
self._set_placeholder(f"Could not open video: {resolved}")
|
|
849
|
+
cap.release()
|
|
850
|
+
return False
|
|
851
|
+
|
|
852
|
+
fps = float(cap.get(self._cv2.CAP_PROP_FPS))
|
|
853
|
+
if not np.isfinite(fps) or fps <= 0:
|
|
854
|
+
fps = 30.0
|
|
855
|
+
|
|
856
|
+
frame_count = int(cap.get(self._cv2.CAP_PROP_FRAME_COUNT))
|
|
857
|
+
if frame_count <= 0:
|
|
858
|
+
frame_count = None
|
|
859
|
+
|
|
860
|
+
self._video_capture = cap
|
|
861
|
+
self._video_path = resolved
|
|
862
|
+
self._video_filename = os.path.basename(resolved)
|
|
863
|
+
self._fps = fps
|
|
864
|
+
self._frame_count = frame_count
|
|
865
|
+
self._duration_s = (frame_count / fps) if frame_count is not None else None
|
|
866
|
+
self._last_frame_id = None
|
|
867
|
+
self._last_frame_rgb = None
|
|
868
|
+
self._last_rendered_frame_id = None
|
|
869
|
+
|
|
870
|
+
if self._duration_s is not None:
|
|
871
|
+
self.offset_slider.valmin = -self._duration_s
|
|
872
|
+
self.offset_slider.ax.set_xlim(self.offset_slider.valmin, self.offset_slider.valmax)
|
|
873
|
+
if self.time_offset_s < self.offset_slider.valmin:
|
|
874
|
+
self.time_offset_s = self.offset_slider.valmin
|
|
875
|
+
self.offset_box.set_val(f"{self.time_offset_s:.3f}")
|
|
876
|
+
self.offset_slider.set_val(self.time_offset_s)
|
|
877
|
+
|
|
878
|
+
self.showAtDataTime(self.current_data_time)
|
|
879
|
+
return True
|
|
880
|
+
|
|
881
|
+
def _read_frame(self, frame_id):
|
|
882
|
+
if self._video_capture is None:
|
|
883
|
+
return None
|
|
884
|
+
|
|
885
|
+
if self._frame_count is not None:
|
|
886
|
+
frame_id = int(np.clip(frame_id, 0, self._frame_count - 1))
|
|
887
|
+
else:
|
|
888
|
+
frame_id = max(0, int(frame_id))
|
|
889
|
+
|
|
890
|
+
# Reuse the last decoded frame and avoid expensive seeking when stepping forward.
|
|
891
|
+
if self._last_frame_id == frame_id and self._last_frame_rgb is not None:
|
|
892
|
+
return self._last_frame_rgb
|
|
893
|
+
|
|
894
|
+
if self._last_frame_id is None or frame_id != (self._last_frame_id + 1):
|
|
895
|
+
self._video_capture.set(self._cv2.CAP_PROP_POS_FRAMES, frame_id)
|
|
896
|
+
|
|
897
|
+
ok, frame = self._video_capture.read()
|
|
898
|
+
if not ok or frame is None:
|
|
899
|
+
return None
|
|
900
|
+
|
|
901
|
+
frame_rgb = self._cv2.cvtColor(frame, self._cv2.COLOR_BGR2RGB)
|
|
902
|
+
self._last_frame_id = frame_id
|
|
903
|
+
self._last_frame_rgb = frame_rgb
|
|
904
|
+
return frame_rgb
|
|
905
|
+
|
|
906
|
+
def _update_overlay(self, frame_id, video_time, data_time, message=None):
|
|
907
|
+
lines = [
|
|
908
|
+
f"file: {self._video_filename}",
|
|
909
|
+
f"frame: {frame_id if frame_id is not None else '-'}",
|
|
910
|
+
f"video time: {video_time:.3f} s" if video_time is not None else "video time: -",
|
|
911
|
+
f"data time: {data_time:.3f} s",
|
|
912
|
+
f"offset: {self.time_offset_s:+.3f} s",
|
|
913
|
+
]
|
|
914
|
+
if message:
|
|
915
|
+
lines.append(message)
|
|
916
|
+
self._overlay.set_text("\n".join(lines))
|
|
917
|
+
|
|
918
|
+
def showAtDataTime(self, data_time):
|
|
919
|
+
self.current_data_time = float(data_time)
|
|
920
|
+
|
|
921
|
+
if self._video_capture is None:
|
|
922
|
+
self._update_overlay(frame_id=None, video_time=None, data_time=self.current_data_time, message="No video loaded")
|
|
923
|
+
self.fig.canvas.draw_idle()
|
|
924
|
+
return
|
|
925
|
+
|
|
926
|
+
video_time = self.current_data_time + self.time_offset_s
|
|
927
|
+
video_time = max(0.0, video_time)
|
|
928
|
+
if self._duration_s is not None:
|
|
929
|
+
video_time = min(video_time, max(0.0, self._duration_s - 1.0 / self._fps))
|
|
930
|
+
|
|
931
|
+
frame_id = int(round(video_time * self._fps))
|
|
932
|
+
if self._frame_count is not None:
|
|
933
|
+
frame_id = int(np.clip(frame_id, 0, self._frame_count - 1))
|
|
934
|
+
|
|
935
|
+
if self._last_rendered_frame_id == frame_id and self._imshow is not None:
|
|
936
|
+
self._update_overlay(frame_id=frame_id, video_time=video_time, data_time=self.current_data_time)
|
|
937
|
+
self.fig.canvas.draw_idle()
|
|
938
|
+
return
|
|
939
|
+
|
|
940
|
+
frame = self._read_frame(frame_id)
|
|
941
|
+
if frame is None:
|
|
942
|
+
self._update_overlay(frame_id=frame_id, video_time=video_time, data_time=self.current_data_time, message="Failed to decode frame")
|
|
943
|
+
self.fig.canvas.draw_idle()
|
|
944
|
+
return
|
|
945
|
+
|
|
946
|
+
if self._imshow is None:
|
|
947
|
+
self._imshow = self.ax_video.imshow(frame)
|
|
948
|
+
else:
|
|
949
|
+
self._imshow.set_data(frame)
|
|
950
|
+
|
|
951
|
+
self._last_rendered_frame_id = frame_id
|
|
952
|
+
self._update_overlay(frame_id=frame_id, video_time=video_time, data_time=self.current_data_time)
|
|
953
|
+
self.fig.canvas.draw_idle()
|
|
954
|
+
|
|
955
|
+
def mouseHoverCallback(self, event):
|
|
956
|
+
"""Update video on hover over a linked FlightPlotter timeline."""
|
|
957
|
+
if event.xdata is not None:
|
|
958
|
+
self.showAtDataTime(event.xdata)
|