3 from dataclasses
import dataclass
4 from typing
import Any, Callable, Dict, List, Optional, Tuple
8 import plotly.graph_objects
as go
9 from tqdm.notebook
import tqdm
17 """Holds all data needed for a single frame of the SLAM animation."""
22 graph_dot_string: Optional[str] =
None
29 cx: float, cy: float, cov_matrix: np.ndarray, scale: float = 2.0, N: int = 60
31 """Generates SVG path string for an ellipse from 2x2 covariance."""
32 cov = cov_matrix[:2, :2] + np.eye(2) * 1e-9
34 eigvals, eigvecs = np.linalg.eigh(cov)
35 eigvals = np.maximum(eigvals, 1e-9)
36 minor_std, major_std = np.sqrt(eigvals)
37 v_minor, v_major = eigvecs[:, 0], eigvecs[:, 1]
38 except np.linalg.LinAlgError:
41 t = np.linspace(0, 2 * np.pi, N)
42 x_p = cx + radius * np.cos(t)
43 y_p = cy + radius * np.sin(t)
46 t = np.linspace(0, 2 * np.pi, N)
47 cos_t, sin_t = np.cos(t), np.sin(t)
49 major_std * cos_t * v_major[0] + minor_std * sin_t * v_minor[0]
52 major_std * cos_t * v_major[1] + minor_std * sin_t * v_minor[1]
57 f
"M {x_p[0]},{y_p[0]} "
58 +
" ".join(f
"L{x_},{y_}" for x_, y_
in zip(x_p[1:], y_p[1:]))
68 landmarks_gt: Optional[np.ndarray],
69 ) -> Optional[go.Scatter]:
70 """Creates scatter trace for ground truth landmarks."""
71 if landmarks_gt
is None or landmarks_gt.size == 0:
77 marker=
dict(color=
"black", size=8, symbol=
"star"),
83 """Creates line trace for ground truth path."""
87 x=[p.x()
for p
in poses_gt],
88 y=[p.y()
for p
in poses_gt],
90 line=
dict(color=
"gray", width=1, dash=
"dash"),
96 est_path_x: List[float], est_path_y: List[float]
98 """Creates trace for the estimated path (all poses up to current)."""
102 mode=
"lines+markers",
103 line=
dict(color=
"rgba(255, 0, 0, 0.3)", width=1),
104 marker=
dict(size=4, color=
"red"),
111 ) -> Optional[go.Scatter]:
112 """Creates a single marker trace for the current estimated pose."""
113 if current_pose
is None:
116 x=[current_pose.x()],
117 y=[current_pose.y()],
119 marker=
dict(color=
"magenta", size=10, symbol=
"cross"),
125 est_landmarks_x: List[float], est_landmarks_y: List[float]
126 ) -> Optional[go.Scatter]:
127 """Creates trace for currently estimated landmarks."""
128 if not est_landmarks_x:
134 marker=
dict(color=
"blue", size=6, symbol=
"x"),
135 name=
"Landmarks Est",
140 cx: float, cy: float, cov: np.ndarray, scale: float, fillcolor: str, line_color: str
142 """Helper: Creates dict for a Plotly ellipse shape from covariance."""
150 line_color=line_color,
156 pose_mean_xy: np.ndarray, pose_cov: np.ndarray, scale: float
158 """Creates shape dict for a pose covariance ellipse."""
164 fillcolor=
"rgba(255,0,255,0.2)",
165 line_color=
"rgba(255,0,255,0.5)",
170 lm_mean_xy: np.ndarray, lm_cov: np.ndarray, scale: float
172 """Creates shape dict for a landmark covariance ellipse."""
178 fillcolor=
"rgba(0,0,255,0.1)",
179 line_color=
"rgba(0,0,255,0.3)",
184 dot_string: Optional[str], engine: str =
"neato"
186 """Renders a DOT string to a base64 encoded SVG using graphviz."""
190 source = graphviz.Source(dot_string, engine=engine)
191 svg_bytes = source.pipe(format=
"svg")
192 encoded_string = base64.b64encode(svg_bytes).
decode(
"utf-8")
193 return f
"data:image/svg+xml;base64,{encoded_string}"
194 except graphviz.backend.execute.CalledProcessError
as e:
195 print(f
"Graphviz rendering error ({engine}): {e}")
197 except Exception
as e:
198 print(f
"Unexpected error during Graphviz SVG generation: {e}")
204 frame_data: SlamFrameData,
205 X: Callable[[int], int],
206 L: Callable[[int], int],
207 max_landmark_index: int,
208 ellipse_scale: float = 2.0,
209 graphviz_engine: str =
"neato",
210 verbose: bool =
False,
211 ) -> Tuple[List[go.Scatter], List[Dict[str, Any]], Optional[Dict[str, Any]]]:
212 """Generates dynamic traces, shapes, and layout image for a single frame."""
213 k = frame_data.step_index
215 step_results = frame_data.results
216 step_marginals = frame_data.marginals
218 frame_dynamic_traces: List[go.Scatter] = []
219 frame_shapes: List[Dict[str, Any]] = []
220 layout_image: Optional[Dict[str, Any]] =
None
225 current_pose_est =
None
228 for i
in range(k + 1):
230 if step_results.exists(pose_key):
231 pose = step_results.atPose2(pose_key)
232 est_path_x.append(pose.x())
233 est_path_y.append(pose.y())
235 current_pose_est = pose
239 frame_dynamic_traces.append(path_trace)
243 if current_pose_trace:
244 frame_dynamic_traces.append(current_pose_trace)
247 est_landmarks_x, est_landmarks_y, landmark_keys = [], [], []
248 for j
in range(max_landmark_index + 1):
251 if step_results.exists(lm_key):
252 lm_point = step_results.atPoint2(lm_key)
253 est_landmarks_x.append(lm_point[0])
254 est_landmarks_y.append(lm_point[1])
255 landmark_keys.append(lm_key)
259 frame_dynamic_traces.append(lm_trace)
266 cov = step_marginals.marginalCovariance(pose_key)
268 mean = step_results.atPose2(pose_key).
translation()
272 for lm_key
in landmark_keys:
275 cov = step_marginals.marginalCovariance(lm_key)
277 mean = step_results.atPoint2(lm_key)
284 f
"Warn: LM {gtsam.Symbol(lm_key).index()} cov not in marginals @ step {k}"
286 except Exception
as e:
289 f
"Warn: LM {gtsam.Symbol(lm_key).index()} cov OTHER err @ step {k}: {e}"
294 frame_data.graph_dot_string, engine=graphviz_engine
312 return frame_dynamic_traces, frame_shapes, layout_image
322 initial_shapes: List[Dict[str, Any]],
323 initial_image: Optional[Dict[str, Any]],
325 """Configures Plotly figure layout for side-by-side display."""
327 plot_domain = [0.52, 1.0]
332 currentvalue={
"prefix":
"Step: "},
342 frame=
dict(duration=100, redraw=
True),
343 transition=
dict(duration=0),
356 pad={
"r": 10,
"t": 20},
369 frame=
dict(duration=100, redraw=
True),
370 transition=
dict(duration=0),
382 frame=
dict(duration=0, redraw=
False),
383 transition=
dict(duration=0),
392 title=
"Factor Graph SLAM Animation (Graph Left, Results Right)",
394 range=[-world_size / 2 - 2, world_size / 2 + 2],
399 range=[-world_size / 2 - 2, world_size / 2 + 2],
407 updatemenus=updatemenus,
409 shapes=initial_shapes,
410 images=([initial_image]
if initial_image
else []),
415 bgcolor=
"rgba(255,255,255,0.5)",
418 margin=
dict(l=0, r=0, t=50, b=50),
426 history: List[SlamFrameData],
427 X: Callable[[int], int],
428 L: Callable[[int], int],
429 max_landmark_index: int,
430 landmarks_gt_array: Optional[np.ndarray] =
None,
432 world_size: float = 20.0,
433 ellipse_scale: float = 2.0,
434 graphviz_engine: str =
"neato",
435 verbose_cov_errors: bool =
False,
437 """Creates a side-by-side Plotly SLAM animation using a history of dataclasses."""
439 raise ValueError(
"History cannot be empty.")
440 print(
"Generating Plotly animation...")
441 num_steps = history[-1].step_index
448 gt_traces.append(gt_lm_trace)
451 gt_traces.append(gt_path_trace)
454 initial_frame_data = next((item
for item
in history
if item.step_index == 0),
None)
455 if initial_frame_data
is None:
456 raise ValueError(
"History must contain data for step 0.")
459 initial_dynamic_traces,
473 for trace
in gt_traces:
475 for trace
in initial_dynamic_traces:
480 steps_iterable =
range(num_steps + 1)
481 steps_iterable = tqdm(steps_iterable, desc=
"Creating Frames")
483 for k
in steps_iterable:
484 frame_data = next((item
for item
in history
if item.step_index == k),
None)
502 + frame_dynamic_traces,
507 [layout_image]
if layout_image
else []
514 fig.update(frames=frames)
519 print(
"Plotly animation generated.")