nrl-tracker 1.1.3__py3-none-any.whl → 1.2.0__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.
@@ -11,11 +11,17 @@ References
11
11
  neighbor," ICML 2006.
12
12
  """
13
13
 
14
+ import logging
14
15
  from typing import Callable, List, NamedTuple, Optional, Set, Tuple
15
16
 
16
17
  import numpy as np
17
18
  from numpy.typing import ArrayLike, NDArray
18
19
 
20
+ from pytcl.containers.base import MetricSpatialIndex, validate_query_input
21
+
22
+ # Module logger
23
+ _logger = logging.getLogger("pytcl.containers.covertree")
24
+
19
25
 
20
26
  class CoverTreeResult(NamedTuple):
21
27
  """Result of Cover tree query.
@@ -60,7 +66,7 @@ class CoverTreeNode:
60
66
  self.children[level].append(child)
61
67
 
62
68
 
63
- class CoverTree:
69
+ class CoverTree(MetricSpatialIndex):
64
70
  """
65
71
  Cover Tree for metric space nearest neighbor search.
66
72
 
@@ -93,6 +99,11 @@ class CoverTree:
93
99
 
94
100
  The implementation uses a simplified version of the original
95
101
  algorithm for clarity.
102
+
103
+ See Also
104
+ --------
105
+ MetricSpatialIndex : Abstract base class for metric-based spatial indices.
106
+ VPTree : Alternative metric space index using vantage points.
96
107
  """
97
108
 
98
109
  def __init__(
@@ -101,19 +112,9 @@ class CoverTree:
101
112
  metric: Optional[Callable[[NDArray, NDArray], float]] = None,
102
113
  base: float = 2.0,
103
114
  ):
104
- self.data = np.asarray(data, dtype=np.float64)
105
-
106
- if self.data.ndim != 2:
107
- raise ValueError("Data must be 2-dimensional")
108
-
109
- self.n_samples, self.n_features = self.data.shape
115
+ super().__init__(data, metric)
110
116
  self.base = base
111
117
 
112
- if metric is None:
113
- self.metric = self._euclidean_distance
114
- else:
115
- self.metric = metric
116
-
117
118
  # Compute distance cache for small datasets
118
119
  self._distance_cache: dict[Tuple[int, int], float] = {}
119
120
 
@@ -124,10 +125,12 @@ class CoverTree:
124
125
 
125
126
  if self.n_samples > 0:
126
127
  self._build_tree()
127
-
128
- def _euclidean_distance(self, x: NDArray, y: NDArray) -> float:
129
- """Default Euclidean distance metric."""
130
- return float(np.sqrt(np.sum((x - y) ** 2)))
128
+ _logger.debug(
129
+ "CoverTree built with base=%.1f, levels=%d to %d",
130
+ base,
131
+ self.min_level,
132
+ self.max_level,
133
+ )
131
134
 
132
135
  def _distance(self, i: int, j: int) -> float:
133
136
  """Get distance between points i and j (with caching)."""
@@ -245,11 +248,7 @@ class CoverTree:
245
248
  result : CoverTreeResult
246
249
  Indices and distances of k nearest neighbors.
247
250
  """
248
- X = np.asarray(X, dtype=np.float64)
249
-
250
- if X.ndim == 1:
251
- X = X.reshape(1, -1)
252
-
251
+ X = validate_query_input(X, self.n_features)
253
252
  n_queries = X.shape[0]
254
253
 
255
254
  all_indices = np.zeros((n_queries, k), dtype=np.intp)
@@ -368,11 +367,7 @@ class CoverTree:
368
367
  indices : list of lists
369
368
  For each query, list of indices within radius.
370
369
  """
371
- X = np.asarray(X, dtype=np.float64)
372
-
373
- if X.ndim == 1:
374
- X = X.reshape(1, -1)
375
-
370
+ X = validate_query_input(X, self.n_features)
376
371
  n_queries = X.shape[0]
377
372
  results: List[List[int]] = []
378
373
 
@@ -13,11 +13,17 @@ References
13
13
  Finding Best Matches in Logarithmic Expected Time," ACM TOMS, 1977.
14
14
  """
15
15
 
16
+ import logging
16
17
  from typing import List, NamedTuple, Optional, Tuple
17
18
 
18
19
  import numpy as np
19
20
  from numpy.typing import ArrayLike, NDArray
20
21
 
22
+ from pytcl.containers.base import BaseSpatialIndex, validate_query_input
23
+
24
+ # Module logger
25
+ _logger = logging.getLogger("pytcl.containers.kd_tree")
26
+
21
27
 
22
28
  class KDNode:
23
29
  """A node in the k-d tree.
@@ -66,7 +72,7 @@ class NearestNeighborResult(NamedTuple):
66
72
  distances: NDArray[np.floating]
67
73
 
68
74
 
69
- class KDTree:
75
+ class KDTree(BaseSpatialIndex):
70
76
  """
71
77
  K-D Tree for efficient spatial queries.
72
78
 
@@ -97,6 +103,11 @@ class KDTree:
97
103
 
98
104
  Query complexity is O(log n) on average for nearest neighbor search,
99
105
  though worst case is O(n) for highly unbalanced queries.
106
+
107
+ See Also
108
+ --------
109
+ BaseSpatialIndex : Abstract base class defining the spatial index interface.
110
+ BallTree : Alternative spatial index using hyperspheres.
100
111
  """
101
112
 
102
113
  def __init__(
@@ -104,17 +115,13 @@ class KDTree:
104
115
  data: ArrayLike,
105
116
  leaf_size: int = 10,
106
117
  ):
107
- self.data = np.asarray(data, dtype=np.float64)
108
-
109
- if self.data.ndim != 2:
110
- raise ValueError("Data must be 2-dimensional (n_samples, n_features)")
111
-
112
- self.n_samples, self.n_features = self.data.shape
118
+ super().__init__(data)
113
119
  self.leaf_size = leaf_size
114
120
 
115
121
  # Build the tree
116
122
  indices = np.arange(self.n_samples)
117
123
  self.root = self._build_tree(indices, depth=0)
124
+ _logger.debug("KDTree built with leaf_size=%d", leaf_size)
118
125
 
119
126
  def _build_tree(
120
127
  self,
@@ -173,12 +180,9 @@ class KDTree:
173
180
  >>> result.indices
174
181
  array([[0, 1]])
175
182
  """
176
- X = np.asarray(X, dtype=np.float64)
177
-
178
- if X.ndim == 1:
179
- X = X.reshape(1, -1)
180
-
183
+ X = validate_query_input(X, self.n_features)
181
184
  n_queries = X.shape[0]
185
+ _logger.debug("KDTree.query: %d queries, k=%d", n_queries, k)
182
186
 
183
187
  all_indices = np.zeros((n_queries, k), dtype=np.intp)
184
188
  all_distances = np.full((n_queries, k), np.inf)
@@ -263,11 +267,7 @@ class KDTree:
263
267
  >>> tree.query_radius([[0, 0]], r=1.5)
264
268
  [[0, 1, 2]]
265
269
  """
266
- X = np.asarray(X, dtype=np.float64)
267
-
268
- if X.ndim == 1:
269
- X = X.reshape(1, -1)
270
-
270
+ X = validate_query_input(X, self.n_features)
271
271
  n_queries = X.shape[0]
272
272
  results: List[List[int]] = []
273
273
 
@@ -331,7 +331,7 @@ class KDTree:
331
331
  return self.query_radius(X, r)
332
332
 
333
333
 
334
- class BallTree:
334
+ class BallTree(BaseSpatialIndex):
335
335
  """
336
336
  Ball Tree for efficient spatial queries.
337
337
 
@@ -357,6 +357,11 @@ class BallTree:
357
357
  -----
358
358
  Ball trees have O(n log n) construction and O(log n) average-case
359
359
  query time. They can outperform k-d trees in high dimensions.
360
+
361
+ See Also
362
+ --------
363
+ BaseSpatialIndex : Abstract base class defining the spatial index interface.
364
+ KDTree : Alternative spatial index using axis-aligned splits.
360
365
  """
361
366
 
362
367
  def __init__(
@@ -364,12 +369,7 @@ class BallTree:
364
369
  data: ArrayLike,
365
370
  leaf_size: int = 10,
366
371
  ):
367
- self.data = np.asarray(data, dtype=np.float64)
368
-
369
- if self.data.ndim != 2:
370
- raise ValueError("Data must be 2-dimensional")
371
-
372
- self.n_samples, self.n_features = self.data.shape
372
+ super().__init__(data)
373
373
  self.leaf_size = leaf_size
374
374
 
375
375
  # Build tree using indices
@@ -382,6 +382,7 @@ class BallTree:
382
382
  self._leaf_indices: List[Optional[NDArray[np.intp]]] = []
383
383
 
384
384
  self._build_tree(self._indices)
385
+ _logger.debug("BallTree built with leaf_size=%d", leaf_size)
385
386
 
386
387
  def _build_tree(
387
388
  self,
@@ -459,11 +460,7 @@ class BallTree:
459
460
  result : NearestNeighborResult
460
461
  Indices and distances of k nearest neighbors.
461
462
  """
462
- X = np.asarray(X, dtype=np.float64)
463
-
464
- if X.ndim == 1:
465
- X = X.reshape(1, -1)
466
-
463
+ X = validate_query_input(X, self.n_features)
467
464
  n_queries = X.shape[0]
468
465
  all_indices = np.zeros((n_queries, k), dtype=np.intp)
469
466
  all_distances = np.full((n_queries, k), np.inf)
@@ -478,6 +475,74 @@ class BallTree:
478
475
 
479
476
  return NearestNeighborResult(indices=all_indices, distances=all_distances)
480
477
 
478
+ def query_radius(
479
+ self,
480
+ X: ArrayLike,
481
+ r: float,
482
+ ) -> List[List[int]]:
483
+ """
484
+ Query the tree for all points within radius r.
485
+
486
+ Parameters
487
+ ----------
488
+ X : array_like
489
+ Query points of shape (n_queries, n_features) or (n_features,).
490
+ r : float
491
+ Query radius.
492
+
493
+ Returns
494
+ -------
495
+ indices : list of lists
496
+ For each query, a list of indices of points within radius r.
497
+ """
498
+ X = validate_query_input(X, self.n_features)
499
+ n_queries = X.shape[0]
500
+ results: List[List[int]] = []
501
+
502
+ for i in range(n_queries):
503
+ indices = self._query_radius_single(X[i], r)
504
+ results.append(indices)
505
+
506
+ return results
507
+
508
+ def _query_radius_single(
509
+ self,
510
+ query: NDArray[np.floating],
511
+ r: float,
512
+ ) -> List[int]:
513
+ """Find all points within radius r of query point."""
514
+ indices: List[int] = []
515
+
516
+ def _search(node_id: int) -> None:
517
+ if node_id < 0:
518
+ return
519
+
520
+ centroid = self._centroids[node_id]
521
+ radius = self._radii[node_id]
522
+
523
+ # Distance to ball surface
524
+ dist_to_center = np.sqrt(np.sum((query - centroid) ** 2))
525
+
526
+ # Prune if ball is farther than radius
527
+ if dist_to_center - radius > r:
528
+ return
529
+
530
+ if self._is_leaf[node_id]:
531
+ # Check all points in leaf
532
+ leaf_indices = self._leaf_indices[node_id]
533
+ if leaf_indices is not None:
534
+ for idx in leaf_indices:
535
+ dist = np.sqrt(np.sum((query - self.data[idx]) ** 2))
536
+ if dist <= r:
537
+ indices.append(idx)
538
+ else:
539
+ # Visit both children
540
+ _search(self._left[node_id])
541
+ _search(self._right[node_id])
542
+
543
+ _search(0)
544
+ return indices
545
+
481
546
  def _query_single(
482
547
  self,
483
548
  query: NDArray[np.floating],
@@ -11,11 +11,17 @@ References
11
11
  neighbor search in general metric spaces," SODA 1993.
12
12
  """
13
13
 
14
+ import logging
14
15
  from typing import Callable, List, NamedTuple, Optional, Tuple
15
16
 
16
17
  import numpy as np
17
18
  from numpy.typing import ArrayLike, NDArray
18
19
 
20
+ from pytcl.containers.base import MetricSpatialIndex, validate_query_input
21
+
22
+ # Module logger
23
+ _logger = logging.getLogger("pytcl.containers.vptree")
24
+
19
25
 
20
26
  class VPTreeResult(NamedTuple):
21
27
  """Result of VP-tree query.
@@ -56,7 +62,7 @@ class VPNode:
56
62
  self.right: Optional["VPNode"] = None
57
63
 
58
64
 
59
- class VPTree:
65
+ class VPTree(MetricSpatialIndex):
60
66
  """
61
67
  Vantage Point Tree for metric space nearest neighbor search.
62
68
 
@@ -89,6 +95,11 @@ class VPTree:
89
95
 
90
96
  Query complexity is O(log n) on average but can degrade to O(n)
91
97
  for pathological distance distributions.
98
+
99
+ See Also
100
+ --------
101
+ MetricSpatialIndex : Abstract base class for metric-based spatial indices.
102
+ CoverTree : Alternative metric space index with theoretical guarantees.
92
103
  """
93
104
 
94
105
  def __init__(
@@ -96,25 +107,13 @@ class VPTree:
96
107
  data: ArrayLike,
97
108
  metric: Optional[Callable[[NDArray, NDArray], float]] = None,
98
109
  ):
99
- self.data = np.asarray(data, dtype=np.float64)
100
-
101
- if self.data.ndim != 2:
102
- raise ValueError("Data must be 2-dimensional")
103
-
104
- self.n_samples, self.n_features = self.data.shape
105
-
106
- if metric is None:
107
- self.metric = self._euclidean_distance
108
- else:
109
- self.metric = metric
110
+ super().__init__(data, metric)
110
111
 
111
112
  # Build tree
112
113
  indices = np.arange(self.n_samples)
113
114
  self.root = self._build_tree(indices)
114
-
115
- def _euclidean_distance(self, x: NDArray, y: NDArray) -> float:
116
- """Default Euclidean distance metric."""
117
- return float(np.sqrt(np.sum((x - y) ** 2)))
115
+ metric_name = metric.__name__ if metric else "euclidean"
116
+ _logger.debug("VPTree built with metric=%s", metric_name)
118
117
 
119
118
  def _build_tree(self, indices: NDArray[np.intp]) -> Optional[VPNode]:
120
119
  """Recursively build the VP-tree."""
@@ -169,11 +168,7 @@ class VPTree:
169
168
  result : VPTreeResult
170
169
  Indices and distances of k nearest neighbors.
171
170
  """
172
- X = np.asarray(X, dtype=np.float64)
173
-
174
- if X.ndim == 1:
175
- X = X.reshape(1, -1)
176
-
171
+ X = validate_query_input(X, self.n_features)
177
172
  n_queries = X.shape[0]
178
173
 
179
174
  all_indices = np.zeros((n_queries, k), dtype=np.intp)
@@ -259,11 +254,7 @@ class VPTree:
259
254
  indices : list of lists
260
255
  For each query, list of indices within radius.
261
256
  """
262
- X = np.asarray(X, dtype=np.float64)
263
-
264
- if X.ndim == 1:
265
- X = X.reshape(1, -1)
266
-
257
+ X = validate_query_input(X, self.n_features)
267
258
  n_queries = X.shape[0]
268
259
  results: List[List[int]] = []
269
260
 
pytcl/core/__init__.py CHANGED
@@ -24,10 +24,19 @@ from pytcl.core.constants import (
24
24
  PhysicalConstants,
25
25
  )
26
26
  from pytcl.core.validation import (
27
+ ArraySpec,
28
+ ScalarSpec,
29
+ ValidationError,
30
+ check_compatible_shapes,
27
31
  ensure_2d,
28
32
  ensure_column_vector,
33
+ ensure_positive_definite,
29
34
  ensure_row_vector,
35
+ ensure_square_matrix,
36
+ ensure_symmetric,
30
37
  validate_array,
38
+ validate_inputs,
39
+ validate_same_shape,
31
40
  )
32
41
 
33
42
  __all__ = [
@@ -40,10 +49,19 @@ __all__ = [
40
49
  "WGS84",
41
50
  "PhysicalConstants",
42
51
  # Validation
52
+ "ValidationError",
43
53
  "validate_array",
54
+ "validate_inputs",
55
+ "validate_same_shape",
56
+ "check_compatible_shapes",
57
+ "ArraySpec",
58
+ "ScalarSpec",
44
59
  "ensure_2d",
45
60
  "ensure_column_vector",
46
61
  "ensure_row_vector",
62
+ "ensure_square_matrix",
63
+ "ensure_symmetric",
64
+ "ensure_positive_definite",
47
65
  # Array utilities
48
66
  "wrap_to_pi",
49
67
  "wrap_to_2pi",