gtsam_plotly.py
Go to the documentation of this file.
1 # gtsam_plotly.py
2 import base64
3 from dataclasses import dataclass
4 from typing import Any, Callable, Dict, List, Optional, Tuple
5 
6 import graphviz
7 import numpy as np
8 import plotly.graph_objects as go
9 from tqdm.notebook import tqdm # Optional progress bar
10 
11 import gtsam
12 
13 
14 # --- Dataclass for History ---
15 @dataclass
17  """Holds all data needed for a single frame of the SLAM animation."""
18 
19  step_index: int
20  results: gtsam.Values # Estimates for variables active at this step
21  marginals: Optional[gtsam.Marginals] # Marginals for variables active at this step
22  graph_dot_string: Optional[str] = None # Graphviz DOT string for visualization
23 
24 
25 # --- Core Ellipse Calculation & Path Generation ---
26 
27 
29  cx: float, cy: float, cov_matrix: np.ndarray, scale: float = 2.0, N: int = 60
30 ) -> str:
31  """Generates SVG path string for an ellipse from 2x2 covariance."""
32  cov = cov_matrix[:2, :2] + np.eye(2) * 1e-9 # Ensure positive definite 2x2
33  try:
34  eigvals, eigvecs = np.linalg.eigh(cov)
35  eigvals = np.maximum(eigvals, 1e-9) # Ensure positive eigenvalues
36  minor_std, major_std = np.sqrt(eigvals) # eigh sorts ascending
37  v_minor, v_major = eigvecs[:, 0], eigvecs[:, 1]
38  except np.linalg.LinAlgError:
39  # Fallback to a small circle if decomposition fails
40  radius = 0.1 * scale
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)
44  else:
45  # Parametric equation using eigenvectors and eigenvalues
46  t = np.linspace(0, 2 * np.pi, N)
47  cos_t, sin_t = np.cos(t), np.sin(t)
48  x_p = cx + scale * (
49  major_std * cos_t * v_major[0] + minor_std * sin_t * v_minor[0]
50  )
51  y_p = cy + scale * (
52  major_std * cos_t * v_major[1] + minor_std * sin_t * v_minor[1]
53  )
54 
55  # Build SVG path string
56  path = (
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:]))
59  + " Z"
60  )
61  return path
62 
63 
64 # --- Plotly Element Generators ---
65 
66 
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:
72  return None
73  return go.Scatter(
74  x=landmarks_gt[0, :],
75  y=landmarks_gt[1, :],
76  mode="markers",
77  marker=dict(color="black", size=8, symbol="star"),
78  name="Landmarks GT",
79  )
80 
81 
82 def create_gt_path_trace(poses_gt: Optional[List[gtsam.Pose2]]) -> Optional[go.Scatter]:
83  """Creates line trace for ground truth path."""
84  if not poses_gt:
85  return None
86  return go.Scatter(
87  x=[p.x() for p in poses_gt],
88  y=[p.y() for p in poses_gt],
89  mode="lines",
90  line=dict(color="gray", width=1, dash="dash"),
91  name="Path GT",
92  )
93 
94 
96  est_path_x: List[float], est_path_y: List[float]
97 ) -> go.Scatter:
98  """Creates trace for the estimated path (all poses up to current)."""
99  return go.Scatter(
100  x=est_path_x,
101  y=est_path_y,
102  mode="lines+markers",
103  line=dict(color="rgba(255, 0, 0, 0.3)", width=1), # Fainter line for history
104  marker=dict(size=4, color="red"), # Keep markers prominent
105  name="Path Est",
106  )
107 
108 
110  current_pose: Optional[gtsam.Pose2],
111 ) -> Optional[go.Scatter]:
112  """Creates a single marker trace for the current estimated pose."""
113  if current_pose is None:
114  return None
115  return go.Scatter(
116  x=[current_pose.x()],
117  y=[current_pose.y()],
118  mode="markers",
119  marker=dict(color="magenta", size=10, symbol="cross"),
120  name="Current Pose",
121  )
122 
123 
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:
129  return None
130  return go.Scatter(
131  x=est_landmarks_x,
132  y=est_landmarks_y,
133  mode="markers",
134  marker=dict(color="blue", size=6, symbol="x"),
135  name="Landmarks Est",
136  )
137 
138 
140  cx: float, cy: float, cov: np.ndarray, scale: float, fillcolor: str, line_color: str
141 ) -> Dict[str, Any]:
142  """Helper: Creates dict for a Plotly ellipse shape from covariance."""
143  path = create_ellipse_path_from_cov(cx, cy, cov, scale)
144  return dict(
145  type="path",
146  path=path,
147  xref="x",
148  yref="y",
149  fillcolor=fillcolor,
150  line_color=line_color,
151  opacity=0.7, # Make ellipses slightly transparent
152  )
153 
154 
156  pose_mean_xy: np.ndarray, pose_cov: np.ndarray, scale: float
157 ) -> Dict[str, Any]:
158  """Creates shape dict for a pose covariance ellipse."""
160  cx=pose_mean_xy[0],
161  cy=pose_mean_xy[1],
162  cov=pose_cov,
163  scale=scale,
164  fillcolor="rgba(255,0,255,0.2)", # Magenta fill
165  line_color="rgba(255,0,255,0.5)", # Magenta line
166  )
167 
168 
170  lm_mean_xy: np.ndarray, lm_cov: np.ndarray, scale: float
171 ) -> Dict[str, Any]:
172  """Creates shape dict for a landmark covariance ellipse."""
174  cx=lm_mean_xy[0],
175  cy=lm_mean_xy[1],
176  cov=lm_cov,
177  scale=scale,
178  fillcolor="rgba(0,0,255,0.1)", # Blue fill
179  line_color="rgba(0,0,255,0.3)", # Blue line
180  )
181 
182 
184  dot_string: Optional[str], engine: str = "neato"
185 ) -> Optional[str]:
186  """Renders a DOT string to a base64 encoded SVG using graphviz."""
187  if not dot_string:
188  return None
189  try:
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}")
196  return None
197  except Exception as e:
198  print(f"Unexpected error during Graphviz SVG generation: {e}")
199  return None
200 
201 
202 # --- Frame Content Generation ---
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
214  # Use the results specific to this frame for current elements
215  step_results = frame_data.results
216  step_marginals = frame_data.marginals
217 
218  frame_dynamic_traces: List[go.Scatter] = []
219  frame_shapes: List[Dict[str, Any]] = []
220  layout_image: Optional[Dict[str, Any]] = None
221 
222  # 1. Estimated Path (Full History or Partial)
223  est_path_x = []
224  est_path_y = []
225  current_pose_est = None
226 
227  # Plot poses currently existing in the step_results (e.g., within lag)
228  for i in range(k + 1): # Check poses up to current step index
229  pose_key = X(i)
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())
234  if i == k:
235  current_pose_est = pose
236 
237  path_trace = create_est_path_trace(est_path_x, est_path_y)
238  if path_trace:
239  frame_dynamic_traces.append(path_trace)
240 
241  # Add a distinct marker for the current pose estimate
242  current_pose_trace = create_current_pose_trace(current_pose_est)
243  if current_pose_trace:
244  frame_dynamic_traces.append(current_pose_trace)
245 
246  # 2. Estimated Landmarks (Only those present in step_results)
247  est_landmarks_x, est_landmarks_y, landmark_keys = [], [], []
248  for j in range(max_landmark_index + 1):
249  lm_key = L(j)
250  # Check existence in the results for the *current frame*
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) # Store keys for covariance lookup
256 
257  lm_trace = create_est_landmarks_trace(est_landmarks_x, est_landmarks_y)
258  if lm_trace:
259  frame_dynamic_traces.append(lm_trace)
260 
261  # 3. Covariance Ellipses (Only for items in step_results AND step_marginals)
262  if step_marginals:
263  # Current Pose Ellipse
264  pose_key = X(k)
265  # Retrieve cov from marginals specific to this frame
266  cov = step_marginals.marginalCovariance(pose_key)
267  # Ensure mean comes from the pose in current results
268  mean = step_results.atPose2(pose_key).translation()
269  frame_shapes.append(create_pose_ellipse_shape(mean, cov, ellipse_scale))
270 
271  # Landmark Ellipses (Iterate over keys found in step_results)
272  for lm_key in landmark_keys:
273  try:
274  # Retrieve cov from marginals specific to this frame
275  cov = step_marginals.marginalCovariance(lm_key)
276  # Ensure mean comes from the landmark in current results
277  mean = step_results.atPoint2(lm_key)
278  frame_shapes.append(
279  create_landmark_ellipse_shape(mean, cov, ellipse_scale)
280  )
281  except RuntimeError: # Covariance might not be available
282  if verbose:
283  print(
284  f"Warn: LM {gtsam.Symbol(lm_key).index()} cov not in marginals @ step {k}"
285  )
286  except Exception as e:
287  if verbose:
288  print(
289  f"Warn: LM {gtsam.Symbol(lm_key).index()} cov OTHER err @ step {k}: {e}"
290  )
291 
292  # 4. Graph Image for Layout
293  img_src = dot_string_to_base64_svg(
294  frame_data.graph_dot_string, engine=graphviz_engine
295  )
296  if img_src:
297  layout_image = dict(
298  source=img_src,
299  xref="paper",
300  yref="paper",
301  x=0,
302  y=1,
303  sizex=0.48,
304  sizey=1,
305  xanchor="left",
306  yanchor="top",
307  layer="below",
308  sizing="contain",
309  )
310 
311  # Return dynamic elements for this frame
312  return frame_dynamic_traces, frame_shapes, layout_image
313 
314 
315 # --- Figure Configuration ---
316 
317 
319  fig: go.Figure,
320  num_steps: int,
321  world_size: float,
322  initial_shapes: List[Dict[str, Any]],
323  initial_image: Optional[Dict[str, Any]],
324 ) -> None:
325  """Configures Plotly figure layout for side-by-side display."""
326  steps = list(range(num_steps + 1))
327  plot_domain = [0.52, 1.0] # Right pane for the SLAM plot
328 
329  sliders = [
330  dict(
331  active=0,
332  currentvalue={"prefix": "Step: "},
333  pad={"t": 50},
334  steps=[
335  dict(
336  label=str(k),
337  method="animate",
338  args=[
339  [str(k)],
340  dict(
341  mode="immediate",
342  frame=dict(duration=100, redraw=True),
343  transition=dict(duration=0),
344  ),
345  ],
346  )
347  for k in steps
348  ],
349  )
350  ]
351  updatemenus = [
352  dict(
353  type="buttons",
354  showactive=False,
355  direction="left",
356  pad={"r": 10, "t": 20},
357  x=plot_domain[0],
358  xanchor="left",
359  y=0,
360  yanchor="top",
361  buttons=[
362  dict(
363  label="Play",
364  method="animate",
365  args=[
366  None,
367  dict(
368  mode="immediate",
369  frame=dict(duration=100, redraw=True),
370  transition=dict(duration=0),
371  fromcurrent=True,
372  ),
373  ],
374  ),
375  dict(
376  label="Pause",
377  method="animate",
378  args=[
379  [None],
380  dict(
381  mode="immediate",
382  frame=dict(duration=0, redraw=False),
383  transition=dict(duration=0),
384  ),
385  ],
386  ),
387  ],
388  )
389  ]
390 
391  fig.update_layout(
392  title="Factor Graph SLAM Animation (Graph Left, Results Right)",
393  xaxis=dict(
394  range=[-world_size / 2 - 2, world_size / 2 + 2],
395  domain=plot_domain,
396  constrain="domain",
397  ),
398  yaxis=dict(
399  range=[-world_size / 2 - 2, world_size / 2 + 2],
400  scaleanchor="x",
401  scaleratio=1,
402  domain=[0, 1],
403  ),
404  autosize=True,
405  height=600,
406  hovermode="closest",
407  updatemenus=updatemenus,
408  sliders=sliders,
409  shapes=initial_shapes, # Initial shapes (frame 0)
410  images=([initial_image] if initial_image else []), # Initial image (frame 0)
411  legend=dict(
412  x=plot_domain[0],
413  y=1,
414  traceorder="normal", # Position legend
415  bgcolor="rgba(255,255,255,0.5)",
416  ),
417  showlegend=False,
418  margin=dict(l=0, r=0, t=50, b=50),
419  )
420 
421 
422 # --- Main Animation Orchestrator ---
423 
424 
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,
431  poses_gt: Optional[List[gtsam.Pose2]] = None,
432  world_size: float = 20.0,
433  ellipse_scale: float = 2.0,
434  graphviz_engine: str = "neato",
435  verbose_cov_errors: bool = False,
436 ) -> go.Figure:
437  """Creates a side-by-side Plotly SLAM animation using a history of dataclasses."""
438  if not history:
439  raise ValueError("History cannot be empty.")
440  print("Generating Plotly animation...")
441  num_steps = history[-1].step_index
442  fig = go.Figure()
443 
444  # 1. Create static GT traces ONCE
445  gt_traces = []
446  gt_lm_trace = create_gt_landmarks_trace(landmarks_gt_array)
447  if gt_lm_trace:
448  gt_traces.append(gt_lm_trace)
449  gt_path_trace = create_gt_path_trace(poses_gt)
450  if gt_path_trace:
451  gt_traces.append(gt_path_trace)
452 
453  # 2. Generate content for the initial frame (k=0) to set up the figure
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.")
457 
458  (
459  initial_dynamic_traces,
460  initial_shapes,
461  initial_image,
463  initial_frame_data,
464  X,
465  L,
466  max_landmark_index,
467  ellipse_scale,
468  graphviz_engine,
469  verbose_cov_errors,
470  )
471 
472  # 3. Add initial traces (GT + dynamic frame 0)
473  for trace in gt_traces:
474  fig.add_trace(trace)
475  for trace in initial_dynamic_traces:
476  fig.add_trace(trace)
477 
478  # 4. Generate frames for the animation (k=0 to num_steps)
479  frames = []
480  steps_iterable = range(num_steps + 1)
481  steps_iterable = tqdm(steps_iterable, desc="Creating Frames")
482 
483  for k in steps_iterable:
484  frame_data = next((item for item in history if item.step_index == k), None)
485 
486  # Generate dynamic content specific to this frame
487  frame_dynamic_traces, frame_shapes, layout_image = generate_frame_content(
488  frame_data,
489  X,
490  L,
491  max_landmark_index,
492  ellipse_scale,
493  graphviz_engine,
494  verbose_cov_errors,
495  )
496 
497  # Frame definition: includes static GT + dynamic traces for this step
498  # Layout updates only include shapes and images for this step
499  frames.append(
500  go.Frame(
501  data=gt_traces
502  + frame_dynamic_traces, # GT must be in each frame's data
503  name=str(k),
504  layout=go.Layout(
505  shapes=frame_shapes, # Replaces shapes list for this frame
506  images=(
507  [layout_image] if layout_image else []
508  ), # Replaces image list
509  ),
510  )
511  )
512 
513  # 5. Assign frames to the figure
514  fig.update(frames=frames)
515 
516  # 6. Configure overall layout (sliders, buttons, axes, etc.)
517  configure_figure_layout(fig, num_steps, world_size, initial_shapes, initial_image)
518 
519  print("Plotly animation generated.")
520  return fig
gtsam.examples.gtsam_plotly.create_current_pose_trace
Optional[go.Scatter] create_current_pose_trace(Optional[gtsam.Pose2] current_pose)
Definition: gtsam_plotly.py:109
gtsam.examples.gtsam_plotly.create_gt_path_trace
Optional[go.Scatter] create_gt_path_trace(Optional[List[gtsam.Pose2]] poses_gt)
Definition: gtsam_plotly.py:82
gtsam.examples.gtsam_plotly.SlamFrameData
Definition: gtsam_plotly.py:16
gtsam::translation
Point3_ translation(const Pose3_ &pose)
Definition: slam/expressions.h:97
gtsam.examples.gtsam_plotly._create_ellipse_shape_dict
Dict[str, Any] _create_ellipse_shape_dict(float cx, float cy, np.ndarray cov, float scale, str fillcolor, str line_color)
Definition: gtsam_plotly.py:139
gtsam.examples.gtsam_plotly.create_gt_landmarks_trace
Optional[go.Scatter] create_gt_landmarks_trace(Optional[np.ndarray] landmarks_gt)
Definition: gtsam_plotly.py:67
list
Definition: pytypes.h:2168
gtsam.examples.gtsam_plotly.create_slam_animation
go.Figure create_slam_animation(List[SlamFrameData] history, Callable[[int], int] X, Callable[[int], int] L, int max_landmark_index, Optional[np.ndarray] landmarks_gt_array=None, Optional[List[gtsam.Pose2]] poses_gt=None, float world_size=20.0, float ellipse_scale=2.0, str graphviz_engine="neato", bool verbose_cov_errors=False)
Definition: gtsam_plotly.py:425
gtsam::Marginals
Definition: Marginals.h:32
gtsam.examples.gtsam_plotly.create_est_landmarks_trace
Optional[go.Scatter] create_est_landmarks_trace(List[float] est_landmarks_x, List[float] est_landmarks_y)
Definition: gtsam_plotly.py:124
X
#define X
Definition: icosphere.cpp:20
gtsam.examples.gtsam_plotly.create_pose_ellipse_shape
Dict[str, Any] create_pose_ellipse_shape(np.ndarray pose_mean_xy, np.ndarray pose_cov, float scale)
Definition: gtsam_plotly.py:155
gtsam::range
Double_ range(const Point2_ &p, const Point2_ &q)
Definition: slam/expressions.h:30
gtsam::print
void print(const Matrix &A, const string &s, ostream &stream)
Definition: Matrix.cpp:145
dict
Definition: pytypes.h:2109
decode
IndexPair decode(Index ij)
Definition: indexed_view.cpp:49
L
MatrixXd L
Definition: LLT_example.cpp:6
str
Definition: pytypes.h:1560
gtsam.examples.gtsam_plotly.configure_figure_layout
None configure_figure_layout(go.Figure fig, int num_steps, float world_size, List[Dict[str, Any]] initial_shapes, Optional[Dict[str, Any]] initial_image)
Definition: gtsam_plotly.py:318
gtsam::Values
Definition: Values.h:65
gtsam.examples.gtsam_plotly.generate_frame_content
Tuple[List[go.Scatter], List[Dict[str, Any]], Optional[Dict[str, Any]]] generate_frame_content(SlamFrameData frame_data, Callable[[int], int] X, Callable[[int], int] L, int max_landmark_index, float ellipse_scale=2.0, str graphviz_engine="neato", bool verbose=False)
Definition: gtsam_plotly.py:203
gtsam.examples.gtsam_plotly.dot_string_to_base64_svg
Optional[str] dot_string_to_base64_svg(Optional[str] dot_string, str engine="neato")
Definition: gtsam_plotly.py:183
gtsam.examples.gtsam_plotly.create_ellipse_path_from_cov
str create_ellipse_path_from_cov(float cx, float cy, np.ndarray cov_matrix, float scale=2.0, int N=60)
Definition: gtsam_plotly.py:28
gtsam.examples.gtsam_plotly.create_est_path_trace
go.Scatter create_est_path_trace(List[float] est_path_x, List[float] est_path_y)
Definition: gtsam_plotly.py:95
gtsam::Pose2
Definition: Pose2.h:39
gtsam.examples.gtsam_plotly.create_landmark_ellipse_shape
Dict[str, Any] create_landmark_ellipse_shape(np.ndarray lm_mean_xy, np.ndarray lm_cov, float scale)
Definition: gtsam_plotly.py:169


gtsam
Author(s):
autogenerated on Wed May 28 2025 03:01:23