Spatial Data Structures

This example demonstrates spatial data structures for efficient queries.

Overview

Spatial data structures enable fast nearest-neighbor and range queries:

  • KD-Tree: k-dimensional binary search tree

  • R-Tree: Rectangle tree for bounding box queries

  • Ball Tree: Metric tree for arbitrary metrics

  • Cover Tree: Efficient for intrinsic dimensionality

Key Concepts

  • Nearest neighbor queries: Find k closest points

  • Range queries: Find all points within radius

  • Bulk loading: Efficient tree construction

  • Metric spaces: Distance-based operations

Data Structures

KD-Tree
  • Best for low-dimensional data (d < 20)

  • O(log n) average query time

  • Standard Euclidean distance

R-Tree
  • Designed for spatial indexing

  • Handles bounding boxes well

  • Good for GIS applications

Ball Tree
  • Works in any metric space

  • Better for high dimensions

  • Supports custom distance functions

Code Highlights

The example demonstrates:

  • Building KD-tree with KDTree()

  • Nearest neighbor queries with query()

  • Range queries with query_radius()

  • Bulk operations for efficiency

Source Code

  1"""
  2Spatial Data Structures Example
  3===============================
  4
  5This example demonstrates spatial data structures in PyTCL for
  6efficient nearest neighbor queries and spatial indexing:
  7
  8K-D Tree:
  9- Construction and querying
 10- K-nearest neighbor search
 11- Radius/range queries
 12
 13Ball Tree:
 14- Alternative to K-D tree for high dimensions
 15- Similar query interface
 16
 17R-Tree:
 18- Spatial indexing for bounding boxes
 19- Rectangle intersection queries
 20
 21VP-Tree (Vantage Point Tree):
 22- Metric space indexing
 23- Works with any distance metric
 24
 25Cover Tree:
 26- Approximate nearest neighbor search
 27- O(c^12 log n) query complexity
 28
 29These data structures are essential for efficient data association
 30in multi-target tracking and spatial analysis applications.
 31"""
 32
 33from pathlib import Path
 34
 35import numpy as np
 36import plotly.graph_objects as go
 37
 38# Output directory for generated plots
 39OUTPUT_DIR = Path(__file__).parent.parent / "docs" / "_static" / "images" / "examples"
 40OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
 41
 42# Global flag to control plotting
 43SHOW_PLOTS = True
 44
 45
 46from pytcl.containers import (  # K-D Tree; Ball Tree; R-Tree; VP-Tree; Cover Tree
 47    BallTree,
 48    BoundingBox,
 49    CoverTree,
 50    KDTree,
 51    NearestNeighborResult,
 52    RTree,
 53    VPTree,
 54    box_from_point,
 55    box_from_points,
 56    merge_boxes,
 57)
 58
 59
 60def demo_kdtree_basics():
 61    """Demonstrate K-D tree construction and basic queries."""
 62    print("=" * 70)
 63    print("K-D Tree Basics Demo")
 64    print("=" * 70)
 65
 66    np.random.seed(42)
 67
 68    # Generate 2D point cloud
 69    n_points = 100
 70    points = np.random.randn(n_points, 2) * 10
 71
 72    print(f"\nBuilding K-D tree with {n_points} 2D points")
 73
 74    # Build tree
 75    tree = KDTree(points)
 76
 77    print(f"Tree built successfully")
 78    print(f"Point cloud bounds:")
 79    print(f"  X: [{points[:, 0].min():.2f}, {points[:, 0].max():.2f}]")
 80    print(f"  Y: [{points[:, 1].min():.2f}, {points[:, 1].max():.2f}]")
 81
 82    # Query point
 83    query = np.array([0.0, 0.0])
 84    print(f"\nQuery point: {query}")
 85
 86    # K-nearest neighbors
 87    k = 5
 88    result = tree.query(query, k=k)
 89
 90    print(f"\n{k} nearest neighbors:")
 91    # indices/distances are 2D arrays, extract the first row for single query
 92    for i, (idx, dist) in enumerate(zip(result.indices[0], result.distances[0])):
 93        print(f"  {i + 1}. Point {idx}: {points[idx]} (distance={dist:.4f})")
 94
 95    # Plot KDTree result
 96    if SHOW_PLOTS:
 97        fig = go.Figure()
 98
 99        # All points
100        fig.add_trace(
101            go.Scatter(
102                x=points[:, 0],
103                y=points[:, 1],
104                mode="markers",
105                marker=dict(color="lightblue", size=8, opacity=0.6),
106                name="Points",
107            )
108        )
109
110        # K nearest neighbors
111        nn_indices = result.indices[0]
112        fig.add_trace(
113            go.Scatter(
114                x=points[nn_indices, 0],
115                y=points[nn_indices, 1],
116                mode="markers",
117                marker=dict(color="green", size=12, opacity=0.8),
118                name=f"{k} nearest neighbors",
119            )
120        )
121
122        # Query point
123        fig.add_trace(
124            go.Scatter(
125                x=[query[0]],
126                y=[query[1]],
127                mode="markers",
128                marker=dict(color="red", size=15, symbol="star"),
129                name="Query",
130            )
131        )
132
133        # Draw circle for max distance
134        max_dist = result.distances[0, -1]
135        theta = np.linspace(0, 2 * np.pi, 100)
136        circle_x = query[0] + max_dist * np.cos(theta)
137        circle_y = query[1] + max_dist * np.sin(theta)
138        fig.add_trace(
139            go.Scatter(
140                x=circle_x,
141                y=circle_y,
142                mode="lines",
143                line=dict(color="green", dash="dash", width=2),
144                name="Search radius",
145                showlegend=True,
146            )
147        )
148
149        fig.update_layout(
150            title="K-D Tree: K-Nearest Neighbor Query",
151            xaxis_title="x",
152            yaxis_title="y",
153            height=600,
154            width=600,
155            showlegend=True,
156            xaxis=dict(scaleanchor="y", scaleratio=1),
157        )
158        fig.write_html(
159            str(OUTPUT_DIR / "spatial_kdtree.html"),
160            include_plotlyjs="cdn",
161            div_id="spatial_kdtree",
162        )
163        print("\n  [Plot saved to spatial_kdtree.html]")
164
165
166def demo_kdtree_queries():
167    """Demonstrate K-D tree query types."""
168    print("\n" + "=" * 70)
169    print("K-D Tree Query Types Demo")
170    print("=" * 70)
171
172    np.random.seed(42)
173
174    # Create structured point cloud
175    # Grid + noise
176    x = np.linspace(-10, 10, 11)
177    y = np.linspace(-10, 10, 11)
178    xx, yy = np.meshgrid(x, y)
179    points = np.column_stack([xx.ravel(), yy.ravel()])
180    points += np.random.randn(*points.shape) * 0.3
181
182    tree = KDTree(points)
183
184    print(f"\nGrid-based point cloud: {len(points)} points")
185
186    # Different query types
187    query = np.array([0.0, 0.0])
188
189    # K-NN query
190    k_values = [1, 5, 10, 20]
191    print("\n--- K-Nearest Neighbors ---")
192    for k in k_values:
193        result = tree.query(query, k=k)
194        max_dist = result.distances[0, -1]  # 2D array: [query_idx, neighbor_idx]
195        print(f"  k={k:>2}: max distance = {max_dist:.4f}")
196
197    # Radius query
198    print("\n--- Radius Queries ---")
199    radii = [1.0, 2.0, 5.0, 10.0]
200    for r in radii:
201        result = tree.query_radius(query, r)
202        # query_radius returns list of index arrays (one per query point)
203        print(f"  radius={r:.1f}: {len(result[0])} points found")
204
205
206def demo_balltree():
207    """Demonstrate Ball Tree for higher dimensions."""
208    print("\n" + "=" * 70)
209    print("Ball Tree Demo")
210    print("=" * 70)
211
212    np.random.seed(42)
213
214    # Higher dimensional data
215    n_points = 500
216    n_dims = 10  # 10-dimensional space
217
218    points = np.random.randn(n_points, n_dims)
219
220    print(f"\nBuilding Ball Tree with {n_points} points in {n_dims}D space")
221
222    tree = BallTree(points)
223
224    # Query
225    query = np.zeros(n_dims)
226    k = 5
227
228    result = tree.query(query, k=k)
229
230    print(f"\nQuery: origin in {n_dims}D")
231    print(f"{k} nearest neighbors:")
232    for i, (idx, dist) in enumerate(zip(result.indices[0], result.distances[0])):
233        print(f"  {i + 1}. Point {idx}: distance = {dist:.4f}")
234
235    print("\nNote: Ball Tree is often more efficient than K-D Tree")
236    print("for higher dimensional data (curse of dimensionality).")
237
238
239def demo_rtree():
240    """Demonstrate R-Tree for bounding box indexing."""
241    print("\n" + "=" * 70)
242    print("R-Tree Demo")
243    print("=" * 70)
244
245    np.random.seed(42)
246
247    # Create bounding boxes (e.g., for spatial objects)
248    n_boxes = 50
249    boxes = []
250
251    for i in range(n_boxes):
252        # Random center and size
253        center = np.random.uniform(-50, 50, 2)
254        size = np.random.uniform(2, 10, 2)
255
256        min_coords = center - size / 2
257        max_coords = center + size / 2
258
259        box = BoundingBox(min_coords=min_coords, max_coords=max_coords)
260        boxes.append(box)
261
262    print(f"\nCreated {n_boxes} bounding boxes")
263
264    # Build R-Tree
265    tree = RTree()
266    for i, box in enumerate(boxes):
267        tree.insert(box, i)
268
269    print("R-Tree built successfully")
270
271    # Query: find boxes intersecting a search region
272    search_min = np.array([-10, -10])
273    search_max = np.array([10, 10])
274    search_box = BoundingBox(min_coords=search_min, max_coords=search_max)
275
276    print(f"\nSearch region: ({search_min} to {search_max})")
277
278    result = tree.query_intersect(search_box)
279
280    print(f"Found {len(result.indices)} intersecting boxes")
281
282    # Show some results
283    if len(result.indices) > 0:
284        print("\nFirst 5 intersecting boxes:")
285        for idx in result.indices[:5]:
286            box = boxes[idx]
287            print(f"  Box {idx}: ({box.min_coords} to {box.max_coords})")
288
289    # Plot R-Tree result
290    if SHOW_PLOTS:
291        fig = go.Figure()
292
293        # Draw all boxes
294        for i, box in enumerate(boxes):
295            is_intersecting = i in result.indices
296            color = "green" if is_intersecting else "lightblue"
297            opacity = 0.6 if is_intersecting else 0.3
298
299            # Create rectangle as a filled shape
300            x0, y0 = box.min_coords
301            x1, y1 = box.max_coords
302
303            fig.add_shape(
304                type="rect",
305                x0=x0,
306                y0=y0,
307                x1=x1,
308                y1=y1,
309                fillcolor=color,
310                line=dict(color="black", width=1),
311                opacity=opacity,
312            )
313
314        # Draw search region
315        fig.add_shape(
316            type="rect",
317            x0=search_min[0],
318            y0=search_min[1],
319            x1=search_max[0],
320            y1=search_max[1],
321            fillcolor="rgba(0,0,0,0)",
322            line=dict(color="red", width=3, dash="dash"),
323        )
324
325        # Add legend traces (invisible points for legend)
326        fig.add_trace(
327            go.Scatter(
328                x=[None],
329                y=[None],
330                mode="markers",
331                marker=dict(size=15, color="green", opacity=0.6),
332                name="Intersecting",
333            )
334        )
335        fig.add_trace(
336            go.Scatter(
337                x=[None],
338                y=[None],
339                mode="markers",
340                marker=dict(size=15, color="lightblue", opacity=0.3),
341                name="Non-intersecting",
342            )
343        )
344        fig.add_trace(
345            go.Scatter(
346                x=[None],
347                y=[None],
348                mode="lines",
349                line=dict(color="red", width=3, dash="dash"),
350                name="Search region",
351            )
352        )
353
354        fig.update_layout(
355            title=f"R-Tree: {len(result.indices)} boxes intersecting search region",
356            xaxis_title="x",
357            yaxis_title="y",
358            height=700,
359            width=700,
360            showlegend=True,
361            xaxis=dict(range=[-60, 60], scaleanchor="y", scaleratio=1),
362            yaxis=dict(range=[-60, 60]),
363        )
364        fig.write_html(
365            str(OUTPUT_DIR / "spatial_rtree.html"),
366            include_plotlyjs="cdn",
367            div_id="spatial_rtree",
368        )
369        print("\n  [Plot saved to spatial_rtree.html]")
370
371
372def demo_bounding_box_operations():
373    """Demonstrate bounding box utility functions."""
374    print("\n" + "=" * 70)
375    print("Bounding Box Operations Demo")
376    print("=" * 70)
377
378    # Create boxes
379    box1 = BoundingBox(min_coords=np.array([0, 0]), max_coords=np.array([5, 5]))
380
381    box2 = BoundingBox(min_coords=np.array([3, 3]), max_coords=np.array([8, 8]))
382
383    box3 = BoundingBox(min_coords=np.array([10, 10]), max_coords=np.array([12, 12]))
384
385    print("\nBox 1: (0,0) to (5,5)")
386    print(f"  Center: {box1.center}")
387    print(f"  Dimensions: {box1.dimensions}")
388    print(f"  Volume: {box1.volume}")
389
390    print("\nBox 2: (3,3) to (8,8)")
391    print(f"  Center: {box2.center}")
392
393    print("\nBox 3: (10,10) to (12,12)")
394    print(f"  Center: {box3.center}")
395
396    # Intersection tests
397    print("\n--- Intersection Tests ---")
398    print(f"  Box1 intersects Box2: {box1.intersects(box2)}")
399    print(f"  Box1 intersects Box3: {box1.intersects(box3)}")
400    print(f"  Box2 intersects Box3: {box2.intersects(box3)}")
401
402    # Point containment
403    test_points = [
404        np.array([2.5, 2.5]),
405        np.array([4.0, 4.0]),
406        np.array([7.0, 7.0]),
407    ]
408
409    print("\n--- Point Containment Tests ---")
410    for p in test_points:
411        print(f"  Point {p}:")
412        print(f"    In Box1: {box1.contains_point(p)}")
413        print(f"    In Box2: {box2.contains_point(p)}")
414
415    # Merge boxes
416    merged = merge_boxes([box1, box2])  # Takes a list of boxes
417    print("\n--- Merged Box (Box1 + Box2) ---")
418    print(f"  Min: {merged.min_coords}")
419    print(f"  Max: {merged.max_coords}")
420
421    # Create box from points
422    points = np.array([[1, 2], [5, 3], [2, 8], [7, 4]])
423    bbox = box_from_points(points)
424    print("\n--- Bounding Box of Points ---")
425    print(f"  Points:\n{points}")
426    print(f"  Bounding box: ({bbox.min_coords} to {bbox.max_coords})")
427
428
429def demo_vptree():
430    """Demonstrate VP-Tree for metric space indexing."""
431    print("\n" + "=" * 70)
432    print("VP-Tree Demo")
433    print("=" * 70)
434
435    np.random.seed(42)
436
437    # Generate points
438    n_points = 200
439    points = np.random.randn(n_points, 3) * 5
440
441    print(f"\nBuilding VP-Tree with {n_points} 3D points")
442
443    tree = VPTree(points)
444
445    # Query
446    query = np.array([1.0, 1.0, 1.0])
447    k = 5
448
449    result = tree.query(query, k=k)
450
451    print(f"\nQuery point: {query}")
452    print(f"{k} nearest neighbors:")
453    for i, (idx, dist) in enumerate(zip(result.indices[0], result.distances[0])):
454        print(f"  {i + 1}. Point {idx}: distance = {dist:.4f}")
455
456    print("\nNote: VP-Tree works with any distance metric,")
457    print("not just Euclidean distance.")
458
459
460def demo_covertree():
461    """Demonstrate Cover Tree for approximate nearest neighbor."""
462    print("\n" + "=" * 70)
463    print("Cover Tree Demo")
464    print("=" * 70)
465
466    np.random.seed(42)
467
468    # Generate points
469    n_points = 300
470    points = np.random.randn(n_points, 4) * 3  # 4D
471
472    print(f"\nBuilding Cover Tree with {n_points} 4D points")
473
474    tree = CoverTree(points)
475
476    # Query
477    query = np.zeros(4)
478    k = 5
479
480    result = tree.query(query, k=k)
481
482    print(f"\nQuery: origin in 4D")
483    print(f"{k} nearest neighbors:")
484    for i, (idx, dist) in enumerate(zip(result.indices[0], result.distances[0])):
485        print(f"  {i + 1}. Point {idx}: distance = {dist:.4f}")
486
487    print("\nNote: Cover Tree provides O(c^12 log n) query complexity")
488    print("where c is the expansion constant of the data.")
489
490
491def demo_performance_comparison():
492    """Compare performance of different spatial data structures."""
493    print("\n" + "=" * 70)
494    print("Performance Comparison Demo")
495    print("=" * 70)
496
497    import time
498
499    np.random.seed(42)
500
501    # Test data
502    n_points = 5000
503    n_queries = 100
504    dims = 3
505    k = 10
506
507    points = np.random.randn(n_points, dims) * 10
508    queries = np.random.randn(n_queries, dims) * 10
509
510    print(f"\nDataset: {n_points} points in {dims}D")
511    print(f"Queries: {n_queries} k-NN queries (k={k})")
512
513    results = {}
514
515    # K-D Tree
516    t0 = time.time()
517    kdtree = KDTree(points)
518    build_time = time.time() - t0
519
520    t0 = time.time()
521    for q in queries:
522        kdtree.query(q, k=k)
523    query_time = time.time() - t0
524
525    results["K-D Tree"] = (build_time, query_time)
526
527    # Ball Tree
528    t0 = time.time()
529    balltree = BallTree(points)
530    build_time = time.time() - t0
531
532    t0 = time.time()
533    for q in queries:
534        balltree.query(q, k=k)
535    query_time = time.time() - t0
536
537    results["Ball Tree"] = (build_time, query_time)
538
539    # VP Tree
540    t0 = time.time()
541    vptree = VPTree(points)
542    build_time = time.time() - t0
543
544    t0 = time.time()
545    for q in queries:
546        vptree.query(q, k=k)
547    query_time = time.time() - t0
548
549    results["VP-Tree"] = (build_time, query_time)
550
551    # Cover Tree
552    t0 = time.time()
553    covertree = CoverTree(points)
554    build_time = time.time() - t0
555
556    t0 = time.time()
557    for q in queries:
558        covertree.query(q, k=k)
559    query_time = time.time() - t0
560
561    results["Cover Tree"] = (build_time, query_time)
562
563    # Print results
564    print("\n" + "-" * 50)
565    print(f"{'Structure':<15} {'Build (ms)':>12} {'Query (ms)':>12}")
566    print("-" * 50)
567    for name, (build, query) in results.items():
568        print(f"{name:<15} {build * 1000:>12.2f} {query * 1000:>12.2f}")
569
570    print("\nNote: Performance depends on data distribution and dimensionality.")
571
572
573def demo_tracking_application():
574    """Demonstrate spatial indexing in tracking context."""
575    print("\n" + "=" * 70)
576    print("Tracking Application Demo")
577    print("=" * 70)
578
579    np.random.seed(42)
580
581    # Simulated scenario: sensor provides measurements,
582    # need to associate with predicted track positions
583
584    # Track predictions
585    n_tracks = 20
586    track_positions = np.random.uniform(-100, 100, (n_tracks, 2))
587
588    # Measurements (some from tracks, some false alarms)
589    n_measurements = 30
590    # First n_tracks measurements near track positions
591    measurements = np.zeros((n_measurements, 2))
592    for i in range(min(n_tracks, n_measurements)):
593        measurements[i] = track_positions[i] + np.random.randn(2) * 2.0
594
595    # Remaining are false alarms
596    for i in range(n_tracks, n_measurements):
597        measurements[i] = np.random.uniform(-100, 100, 2)
598
599    print(f"\n{n_tracks} track predictions")
600    print(
601        f"{n_measurements} measurements ({n_tracks} true + "
602        f"{n_measurements - n_tracks} false alarms)"
603    )
604
605    # Build spatial index on track predictions
606    tree = KDTree(track_positions)
607
608    # For each measurement, find nearest track
609    print("\nMeasurement-to-track association using K-D tree:")
610    print("-" * 50)
611
612    gating_threshold = 5.0  # meters
613    associations = []
614
615    for m_idx, meas in enumerate(measurements):
616        result = tree.query(meas, k=1)
617        nearest_track = result.indices[0, 0]  # 2D array [query_idx, neighbor_idx]
618        distance = result.distances[0, 0]
619
620        if distance < gating_threshold:
621            associations.append((m_idx, nearest_track, distance))
622
623    print(f"Gating threshold: {gating_threshold} m")
624    print(f"Measurements passing gate: {len(associations)}/{n_measurements}")
625
626    # Show some associations
627    print("\nFirst 5 associations:")
628    for m_idx, t_idx, dist in associations[:5]:
629        true_assoc = m_idx == t_idx  # Simplified ground truth
630        status = "+" if true_assoc else "?"
631        print(f"  Meas {m_idx:>2} -> Track {t_idx:>2} (dist={dist:.2f}) {status}")
632
633    # Radius query for gating
634    print("\n--- Using Radius Query for Gating ---")
635    meas_test = measurements[0]
636    result = tree.query_radius(meas_test, gating_threshold)
637    # query_radius returns list of index arrays (one per query point)
638    print(f"Measurement 0: {len(result[0])} tracks within gate")
639
640
641def main():
642    """Run all demonstrations."""
643    print("\n" + "#" * 70)
644    print("# PyTCL Spatial Data Structures Example")
645    print("#" * 70)
646
647    demo_kdtree_basics()
648    demo_kdtree_queries()
649    demo_balltree()
650    demo_rtree()
651    demo_bounding_box_operations()
652    demo_vptree()
653    demo_covertree()
654    demo_performance_comparison()
655    demo_tracking_application()
656
657    print("\n" + "=" * 70)
658    print("Example complete!")
659    if SHOW_PLOTS:
660        print("Plots saved: spatial_kdtree.html, spatial_rtree.html")
661    print("=" * 70)
662
663
664if __name__ == "__main__":
665    main()

Running the Example

python examples/spatial_data_structures.py

See Also