Coordinate Visualization

This example provides interactive 3D visualizations of coordinate transforms.

Overview

Visualizing coordinate transformations helps understand:

  • Rotation effects: How rotations change frame orientation

  • Projection distortions: Map projection artifacts

  • Frame relationships: ECEF, ENU, NED orientations

  • Interpolation paths: Quaternion vs Euler interpolation

Key Concepts

  • Frame axes: Visualizing coordinate system orientation

  • Transformation chains: Sequential rotations

  • Geodetic surface: Earth ellipsoid visualization

  • Great circles: Shortest paths on sphere

Rotation Axes: Visualizing how rotations affect coordinate frame orientation.

Euler Sequences: ZYX, ZXZ, and other Euler angle conventions produce different rotation paths.

SLERP Interpolation: Spherical linear interpolation provides smooth rotation paths between orientations.

Spherical Coordinates: Converting between Cartesian and spherical coordinate systems.

Code Highlights

The example demonstrates:

  • 3D plotting of coordinate frames

  • Animated rotation sequences

  • Interactive frame selection

  • Projection comparison views

Source Code

  1"""
  2Interactive 3D Coordinate System Visualization.
  3
  4This example demonstrates:
  51. 3D visualization of coordinate transformations
  62. Interactive rotation matrix visualization
  73. Quaternion SLERP animation
  84. Geodetic to ECEF coordinate plotting
  9
 10Run with: python examples/coordinate_visualization.py
 11"""
 12
 13import sys
 14from pathlib import Path
 15
 16sys.path.insert(0, str(Path(__file__).parent.parent))
 17
 18# Output directory for generated plots
 19OUTPUT_DIR = Path(__file__).parent.parent / "docs" / "_static" / "images" / "examples"
 20OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
 21
 22import numpy as np  # noqa: E402
 23import plotly.graph_objects as go  # noqa: E402
 24from plotly.subplots import make_subplots  # noqa: E402
 25
 26from pytcl.coordinate_systems import (  # noqa: E402
 27    euler2rotmat,
 28    quat2rotmat,
 29    rotx,
 30    roty,
 31    rotz,
 32    slerp,
 33    sphere2cart,
 34)
 35from pytcl.navigation import geodetic_to_ecef  # noqa: E402
 36
 37
 38def plot_rotation_axes() -> go.Figure:
 39    """
 40    Visualize how rotation matrices transform coordinate axes.
 41
 42    Creates an interactive 3D plot showing:
 43    - Original coordinate axes (XYZ)
 44    - Rotated axes after applying rotx, roty, rotz
 45    """
 46    fig = make_subplots(
 47        rows=1,
 48        cols=3,
 49        specs=[[{"type": "scene"}, {"type": "scene"}, {"type": "scene"}]],
 50        subplot_titles=[
 51            "Rotation about X (45°)",
 52            "Rotation about Y (45°)",
 53            "Rotation about Z (45°)",
 54        ],
 55    )
 56
 57    # Original axes
 58    _origin = np.array([0, 0, 0])  # noqa: F841
 59    x_axis = np.array([1, 0, 0])
 60    y_axis = np.array([0, 1, 0])
 61    z_axis = np.array([0, 0, 1])
 62
 63    def add_axes(fig, R, col, original_alpha=0.3):
 64        """Add original and rotated axes to subplot."""
 65        # Original axes (faded)
 66        for axis, color, name in [
 67            (x_axis, "red", "X"),
 68            (y_axis, "green", "Y"),
 69            (z_axis, "blue", "Z"),
 70        ]:
 71            fig.add_trace(
 72                go.Scatter3d(
 73                    x=[0, axis[0]],
 74                    y=[0, axis[1]],
 75                    z=[0, axis[2]],
 76                    mode="lines",
 77                    line=dict(color=color, width=3),
 78                    opacity=original_alpha,
 79                    name=f"Original {name}",
 80                    showlegend=(col == 1),
 81                ),
 82                row=1,
 83                col=col,
 84            )
 85
 86        # Rotated axes
 87        for axis, color, name in [
 88            (x_axis, "red", "X'"),
 89            (y_axis, "green", "Y'"),
 90            (z_axis, "blue", "Z'"),
 91        ]:
 92            rotated = R @ axis
 93            fig.add_trace(
 94                go.Scatter3d(
 95                    x=[0, rotated[0]],
 96                    y=[0, rotated[1]],
 97                    z=[0, rotated[2]],
 98                    mode="lines+markers",
 99                    line=dict(color=color, width=5),
100                    marker=dict(size=4),
101                    name=f"Rotated {name}",
102                    showlegend=(col == 1),
103                ),
104                row=1,
105                col=col,
106            )
107
108    # Apply rotations
109    angle = np.pi / 4  # 45 degrees
110    add_axes(fig, rotx(angle), col=1)
111    add_axes(fig, roty(angle), col=2)
112    add_axes(fig, rotz(angle), col=3)
113
114    # Update layout for each scene
115    for i in range(1, 4):
116        fig.update_scenes(
117            dict(
118                xaxis=dict(range=[-1.5, 1.5], title="X"),
119                yaxis=dict(range=[-1.5, 1.5], title="Y"),
120                zaxis=dict(range=[-1.5, 1.5], title="Z"),
121                aspectmode="cube",
122            ),
123            row=1,
124            col=i,
125        )
126
127    fig.update_layout(
128        title="Rotation Matrix Visualization (45° rotations)",
129        width=1400,
130        height=500,
131    )
132
133    return fig
134
135
136def plot_euler_rotation_sequence() -> go.Figure:
137    """
138    Visualize Euler angle rotation sequence (ZYX - aerospace convention).
139
140    Shows how yaw, pitch, roll are applied sequentially.
141    """
142    fig = make_subplots(
143        rows=1,
144        cols=4,
145        specs=[[{"type": "scene"}] * 4],
146        subplot_titles=[
147            "Initial",
148            "After Yaw (Z)",
149            "After Pitch (Y)",
150            "After Roll (X)",
151        ],
152    )
153
154    # Define Euler angles
155    yaw = np.radians(30)
156    pitch = np.radians(20)
157    roll = np.radians(15)
158
159    # Rotation matrices at each stage
160    R0 = np.eye(3)
161    R1 = rotz(yaw)
162    R2 = R1 @ roty(pitch)
163    R3 = R2 @ rotx(roll)
164
165    # Also compute full rotation for comparison
166    R_full = euler2rotmat([yaw, pitch, roll], "ZYX")
167    assert np.allclose(R3, R_full), "Rotation matrices should match"
168
169    # Create a simple airplane shape
170    def airplane_points():
171        """Generate points representing an airplane."""
172        # Fuselage
173        fuselage = np.array(
174            [
175                [1, 0, 0],  # nose
176                [-1, 0, 0],  # tail
177            ]
178        )
179        # Wings
180        wings = np.array(
181            [
182                [0, 0.8, 0],  # left wing
183                [0, -0.8, 0],  # right wing
184            ]
185        )
186        # Tail fin
187        tail = np.array(
188            [
189                [-0.8, 0, 0],
190                [-1, 0, 0.3],
191            ]
192        )
193        return fuselage, wings, tail
194
195    def add_airplane(fig, R, col):
196        """Add rotated airplane to subplot."""
197        fuselage, wings, tail = airplane_points()
198
199        # Rotate all points
200        fuselage_rot = (R @ fuselage.T).T
201        wings_rot = (R @ wings.T).T
202        tail_rot = (R @ tail.T).T
203
204        # Fuselage
205        fig.add_trace(
206            go.Scatter3d(
207                x=fuselage_rot[:, 0],
208                y=fuselage_rot[:, 1],
209                z=fuselage_rot[:, 2],
210                mode="lines",
211                line=dict(color="blue", width=8),
212                name="Fuselage",
213                showlegend=(col == 1),
214            ),
215            row=1,
216            col=col,
217        )
218
219        # Wings
220        fig.add_trace(
221            go.Scatter3d(
222                x=wings_rot[:, 0],
223                y=wings_rot[:, 1],
224                z=wings_rot[:, 2],
225                mode="lines",
226                line=dict(color="red", width=6),
227                name="Wings",
228                showlegend=(col == 1),
229            ),
230            row=1,
231            col=col,
232        )
233
234        # Tail
235        fig.add_trace(
236            go.Scatter3d(
237                x=tail_rot[:, 0],
238                y=tail_rot[:, 1],
239                z=tail_rot[:, 2],
240                mode="lines",
241                line=dict(color="green", width=4),
242                name="Tail",
243                showlegend=(col == 1),
244            ),
245            row=1,
246            col=col,
247        )
248
249    # Add airplane at each rotation stage
250    for i, R in enumerate([R0, R1, R2, R3], 1):
251        add_airplane(fig, R, col=i)
252
253    # Update layout
254    for i in range(1, 5):
255        fig.update_scenes(
256            dict(
257                xaxis=dict(range=[-1.5, 1.5], title="X"),
258                yaxis=dict(range=[-1.5, 1.5], title="Y"),
259                zaxis=dict(range=[-1.5, 1.5], title="Z"),
260                aspectmode="cube",
261                camera=dict(eye=dict(x=1.5, y=1.5, z=1.0)),
262            ),
263            row=1,
264            col=i,
265        )
266
267    fig.update_layout(
268        title=(
269            f"Euler Rotation Sequence (ZYX): Yaw={np.degrees(yaw):.0f}°, "
270            f"Pitch={np.degrees(pitch):.0f}°, Roll={np.degrees(roll):.0f}°"
271        ),
272        width=1600,
273        height=500,
274    )
275
276    return fig
277
278
279def plot_quaternion_slerp() -> go.Figure:
280    """
281    Visualize quaternion SLERP interpolation.
282
283    Shows smooth interpolation between two orientations.
284    """
285    fig = go.Figure()
286
287    # Start and end quaternions
288    # Identity (no rotation)
289    q1 = np.array([1, 0, 0, 0])
290
291    # 90 degree rotation about Z axis
292    angle = np.pi / 2
293    q2 = np.array([np.cos(angle / 2), 0, 0, np.sin(angle / 2)])
294
295    # Interpolation steps
296    n_steps = 20
297    t_values = np.linspace(0, 1, n_steps)
298
299    # Colors for interpolation
300    colors = [
301        f"rgb({int(255 * (1 - t))}, {int(100 + 155 * t)}, {int(255 * t)})"
302        for t in t_values
303    ]
304
305    # Reference vector to rotate
306    v = np.array([1, 0, 0])
307
308    # Add interpolated vectors
309    for i, t in enumerate(t_values):
310        q_interp = slerp(q1, q2, t)
311        R = quat2rotmat(q_interp)
312        v_rot = R @ v
313
314        fig.add_trace(
315            go.Scatter3d(
316                x=[0, v_rot[0]],
317                y=[0, v_rot[1]],
318                z=[0, v_rot[2]],
319                mode="lines+markers",
320                line=dict(color=colors[i], width=4),
321                marker=dict(size=4, color=colors[i]),
322                name=f"t={t:.2f}",
323                showlegend=(i % 5 == 0),
324            )
325        )
326
327    # Add arc showing the path
328    arc_points = []
329    for t in np.linspace(0, 1, 100):
330        q_interp = slerp(q1, q2, t)
331        R = quat2rotmat(q_interp)
332        arc_points.append(R @ v)
333    arc_points = np.array(arc_points)
334
335    fig.add_trace(
336        go.Scatter3d(
337            x=arc_points[:, 0],
338            y=arc_points[:, 1],
339            z=arc_points[:, 2],
340            mode="lines",
341            line=dict(color="gray", width=2, dash="dash"),
342            name="SLERP path",
343        )
344    )
345
346    fig.update_layout(
347        title="Quaternion SLERP Interpolation (0° to 90° about Z-axis)",
348        scene=dict(
349            xaxis=dict(range=[-1.5, 1.5], title="X"),
350            yaxis=dict(range=[-1.5, 1.5], title="Y"),
351            zaxis=dict(range=[-1.5, 1.5], title="Z"),
352            aspectmode="cube",
353        ),
354        width=800,
355        height=700,
356    )
357
358    return fig
359
360
361def plot_spherical_coordinates() -> go.Figure:
362    """
363    Visualize spherical coordinate system.
364
365    Shows the relationship between Cartesian and spherical coordinates.
366    """
367    fig = go.Figure()
368
369    # Generate a grid of points on a sphere
370    n_az = 24
371    n_el = 12
372    r = 1.0
373
374    # Azimuth lines (constant azimuth, varying elevation)
375    for az in np.linspace(0, 2 * np.pi, n_az, endpoint=False):
376        el_range = np.linspace(-np.pi / 2, np.pi / 2, 50)
377        points = np.array(
378            [sphere2cart(r, az, el, system_type="az-el") for el in el_range]
379        )
380        fig.add_trace(
381            go.Scatter3d(
382                x=points[:, 0],
383                y=points[:, 1],
384                z=points[:, 2],
385                mode="lines",
386                line=dict(color="lightblue", width=1),
387                showlegend=False,
388            )
389        )
390
391    # Elevation lines (constant elevation, varying azimuth)
392    for el in np.linspace(-np.pi / 2 + 0.1, np.pi / 2 - 0.1, n_el):
393        az_range = np.linspace(0, 2 * np.pi, 50)
394        points = np.array(
395            [sphere2cart(r, az, el, system_type="az-el") for az in az_range]
396        )
397        fig.add_trace(
398            go.Scatter3d(
399                x=points[:, 0],
400                y=points[:, 1],
401                z=points[:, 2],
402                mode="lines",
403                line=dict(color="lightgreen", width=1),
404                showlegend=False,
405            )
406        )
407
408    # Highlight a specific point
409    test_az = np.radians(45)
410    test_el = np.radians(30)
411    test_point = sphere2cart(r, test_az, test_el, system_type="az-el")
412
413    # Add the point
414    fig.add_trace(
415        go.Scatter3d(
416            x=[test_point[0]],
417            y=[test_point[1]],
418            z=[test_point[2]],
419            mode="markers",
420            marker=dict(size=10, color="red"),
421            name=f"Point (az={np.degrees(test_az):.0f}°, el={np.degrees(test_el):.0f}°)",
422        )
423    )
424
425    # Add lines showing the coordinates
426    # Line from origin to projection on xy-plane
427    proj_xy = np.array([test_point[0], test_point[1], 0])
428    fig.add_trace(
429        go.Scatter3d(
430            x=[0, proj_xy[0]],
431            y=[0, proj_xy[1]],
432            z=[0, 0],
433            mode="lines",
434            line=dict(color="blue", width=3, dash="dash"),
435            name="XY projection",
436        )
437    )
438
439    # Line from projection to point (showing elevation)
440    fig.add_trace(
441        go.Scatter3d(
442            x=[proj_xy[0], test_point[0]],
443            y=[proj_xy[1], test_point[1]],
444            z=[0, test_point[2]],
445            mode="lines",
446            line=dict(color="green", width=3, dash="dash"),
447            name="Elevation",
448        )
449    )
450
451    # Line from origin to point (range)
452    fig.add_trace(
453        go.Scatter3d(
454            x=[0, test_point[0]],
455            y=[0, test_point[1]],
456            z=[0, test_point[2]],
457            mode="lines",
458            line=dict(color="red", width=3),
459            name="Range vector",
460        )
461    )
462
463    # Add coordinate axes
464    axis_len = 1.3
465    for axis, color, name in [
466        ([axis_len, 0, 0], "red", "X"),
467        ([0, axis_len, 0], "green", "Y"),
468        ([0, 0, axis_len], "blue", "Z"),
469    ]:
470        fig.add_trace(
471            go.Scatter3d(
472                x=[0, axis[0]],
473                y=[0, axis[1]],
474                z=[0, axis[2]],
475                mode="lines+text",
476                line=dict(color=color, width=4),
477                text=["", name],
478                textposition="top center",
479                name=f"{name}-axis",
480                showlegend=False,
481            )
482        )
483
484    fig.update_layout(
485        title="Spherical Coordinate System (az-el convention)",
486        scene=dict(
487            xaxis=dict(range=[-1.5, 1.5], title="X"),
488            yaxis=dict(range=[-1.5, 1.5], title="Y"),
489            zaxis=dict(range=[-1.5, 1.5], title="Z"),
490            aspectmode="cube",
491        ),
492        width=900,
493        height=800,
494    )
495
496    return fig
497
498
499def plot_earth_coordinates() -> go.Figure:
500    """
501    Visualize geodetic coordinates on Earth.
502
503    Shows major cities and their ECEF coordinates.
504    """
505    fig = go.Figure()
506
507    # Create a sphere representing Earth
508    u = np.linspace(0, 2 * np.pi, 100)
509    v = np.linspace(0, np.pi, 50)
510    R_earth = 6371000  # meters (approximate)
511    scale = 1e-6  # Scale to make numbers manageable
512
513    x = R_earth * scale * np.outer(np.cos(u), np.sin(v))
514    y = R_earth * scale * np.outer(np.sin(u), np.sin(v))
515    z = R_earth * scale * np.outer(np.ones(np.size(u)), np.cos(v))
516
517    fig.add_trace(
518        go.Surface(
519            x=x,
520            y=y,
521            z=z,
522            colorscale=[[0, "lightblue"], [1, "lightblue"]],
523            opacity=0.6,
524            showscale=False,
525            name="Earth",
526        )
527    )
528
529    # Major cities with their geodetic coordinates
530    cities = {
531        "New York": (40.7128, -74.0060),
532        "London": (51.5074, -0.1278),
533        "Tokyo": (35.6762, 139.6503),
534        "Sydney": (-33.8688, 151.2093),
535        "São Paulo": (-23.5505, -46.6333),
536        "Cairo": (30.0444, 31.2357),
537    }
538
539    # Convert to ECEF and plot
540    city_x, city_y, city_z = [], [], []
541    city_names = []
542
543    for name, (lat_deg, lon_deg) in cities.items():
544        lat = np.radians(lat_deg)
545        lon = np.radians(lon_deg)
546        ecef = geodetic_to_ecef(lat, lon, 0)
547        city_x.append(ecef[0] * scale)
548        city_y.append(ecef[1] * scale)
549        city_z.append(ecef[2] * scale)
550        city_names.append(name)
551
552    fig.add_trace(
553        go.Scatter3d(
554            x=city_x,
555            y=city_y,
556            z=city_z,
557            mode="markers+text",
558            marker=dict(size=8, color="red"),
559            text=city_names,
560            textposition="top center",
561            name="Cities",
562        )
563    )
564
565    # Add equator
566    eq_lon = np.linspace(0, 2 * np.pi, 100)
567    eq_ecef = np.array([geodetic_to_ecef(0, lon, 0) for lon in eq_lon])
568    fig.add_trace(
569        go.Scatter3d(
570            x=eq_ecef[:, 0] * scale,
571            y=eq_ecef[:, 1] * scale,
572            z=eq_ecef[:, 2] * scale,
573            mode="lines",
574            line=dict(color="yellow", width=3),
575            name="Equator",
576        )
577    )
578
579    # Add prime meridian
580    pm_lat = np.linspace(-np.pi / 2, np.pi / 2, 100)
581    pm_ecef = np.array([geodetic_to_ecef(lat, 0, 0) for lat in pm_lat])
582    fig.add_trace(
583        go.Scatter3d(
584            x=pm_ecef[:, 0] * scale,
585            y=pm_ecef[:, 1] * scale,
586            z=pm_ecef[:, 2] * scale,
587            mode="lines",
588            line=dict(color="orange", width=3),
589            name="Prime Meridian",
590        )
591    )
592
593    fig.update_layout(
594        title="Earth: Geodetic to ECEF Coordinate Conversion",
595        scene=dict(
596            xaxis=dict(title="X (1000 km)"),
597            yaxis=dict(title="Y (1000 km)"),
598            zaxis=dict(title="Z (1000 km)"),
599            aspectmode="data",
600        ),
601        width=900,
602        height=800,
603    )
604
605    return fig
606
607
608def main():
609    """Run coordinate visualization examples."""
610    print("Coordinate System Visualization Examples")
611    print("=" * 50)
612
613    # 1. Rotation axes
614    print("\n1. Generating rotation axes visualization...")
615    fig1 = plot_rotation_axes()
616    fig1.write_html(str(OUTPUT_DIR / "coord_viz_rotation_axes.html"))
617    print(f"   Saved to {OUTPUT_DIR / 'coord_viz_rotation_axes.html'}")
618
619    # 2. Euler rotation sequence
620    print("\n2. Generating Euler rotation sequence...")
621    fig2 = plot_euler_rotation_sequence()
622    fig2.write_html(str(OUTPUT_DIR / "coord_viz_euler_sequence.html"))
623    print(f"   Saved to {OUTPUT_DIR / 'coord_viz_euler_sequence.html'}")
624
625    # 3. Quaternion SLERP
626    print("\n3. Generating quaternion SLERP visualization...")
627    fig3 = plot_quaternion_slerp()
628    fig3.write_html(str(OUTPUT_DIR / "coord_viz_slerp.html"))
629    print(f"   Saved to {OUTPUT_DIR / 'coord_viz_slerp.html'}")
630
631    # 4. Spherical coordinates
632    print("\n4. Generating spherical coordinates visualization...")
633    fig4 = plot_spherical_coordinates()
634    fig4.write_html(str(OUTPUT_DIR / "coord_viz_spherical.html"))
635    print(f"   Saved to {OUTPUT_DIR / 'coord_viz_spherical.html'}")
636
637    # 5. Earth coordinates
638    print("\n5. Generating Earth coordinates visualization...")
639    fig5 = plot_earth_coordinates()
640    fig5.write_html(str(OUTPUT_DIR / "coord_viz_earth.html"))
641    print(f"   Saved to {OUTPUT_DIR / 'coord_viz_earth.html'}")
642
643    # Show all figures
644    print("\nOpening visualizations in browser...")
645    fig1.show()
646    fig2.show()
647    fig3.show()
648    fig4.show()
649    fig5.show()
650
651    print("\nDone!")
652
653
654if __name__ == "__main__":
655    main()

Running the Example

python examples/coordinate_visualization.py

See Also