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.
@@ -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)