ruststartracker 0.2.2__tar.gz → 0.2.3__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: ruststartracker
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Lightweight Python Star Tracker With Rust Backend
5
5
  License: MIT
6
6
  Author: Nicolas Tobler
@@ -34,6 +34,35 @@ Features:
34
34
 
35
35
  ## Example
36
36
 
37
+ ### Rust
38
+
39
+ See [examples/basic.rs](examples/basic.rs)
40
+
41
+ ```rust
42
+ // Get catalog positions
43
+ let catalog: StarCatalog = StarCatalog::from_gaia(max_magnitude: ...).unwrap();
44
+ let stars_xyz: Vec<[f32; 3]> = catalog.normalized_positions(epoch: ..., observer_position: ...);
45
+ let stars_mag: Vec<f32> = catalog.magnitudes();
46
+
47
+ // Create StarTracker instance (reuse this)
48
+ let star_matcher = StarMatcher::new(
49
+ stars_xyz,
50
+ stars_mag,
51
+ max_lookup_magnitude: ...
52
+ max_inter_star_angle: ...,
53
+ inter_star_angle_tolerance: ...,
54
+ min_matches: ...,
55
+ timeout: ...
56
+ );
57
+
58
+ // Normalized observation in the camera frame
59
+ let obs_xyz_camera: Vec<[f32; 3]> = ...
60
+
61
+ let result = star_matcher.find(&obs_xyz_camera);
62
+ println!("Result: {:?}", result);
63
+ ```
64
+
65
+ ### Python
37
66
  ```python
38
67
  import ruststartracker
39
68
 
@@ -72,11 +101,6 @@ print(result)
72
101
 
73
102
  - Install with `pip install ruststartracker` (Currently only ARM/x86 Linux wheels available).
74
103
 
75
- ## TODOs
76
-
77
- - Improve error messages.
78
- - Return more diagnostic data.
79
-
80
104
  ## Attributions
81
105
 
82
106
  ### Gaia Data
@@ -13,6 +13,35 @@ Features:
13
13
 
14
14
  ## Example
15
15
 
16
+ ### Rust
17
+
18
+ See [examples/basic.rs](examples/basic.rs)
19
+
20
+ ```rust
21
+ // Get catalog positions
22
+ let catalog: StarCatalog = StarCatalog::from_gaia(max_magnitude: ...).unwrap();
23
+ let stars_xyz: Vec<[f32; 3]> = catalog.normalized_positions(epoch: ..., observer_position: ...);
24
+ let stars_mag: Vec<f32> = catalog.magnitudes();
25
+
26
+ // Create StarTracker instance (reuse this)
27
+ let star_matcher = StarMatcher::new(
28
+ stars_xyz,
29
+ stars_mag,
30
+ max_lookup_magnitude: ...
31
+ max_inter_star_angle: ...,
32
+ inter_star_angle_tolerance: ...,
33
+ min_matches: ...,
34
+ timeout: ...
35
+ );
36
+
37
+ // Normalized observation in the camera frame
38
+ let obs_xyz_camera: Vec<[f32; 3]> = ...
39
+
40
+ let result = star_matcher.find(&obs_xyz_camera);
41
+ println!("Result: {:?}", result);
42
+ ```
43
+
44
+ ### Python
16
45
  ```python
17
46
  import ruststartracker
18
47
 
@@ -51,11 +80,6 @@ print(result)
51
80
 
52
81
  - Install with `pip install ruststartracker` (Currently only ARM/x86 Linux wheels available).
53
82
 
54
- ## TODOs
55
-
56
- - Improve error messages.
57
- - Return more diagnostic data.
58
-
59
83
  ## Attributions
60
84
 
61
85
  ### Gaia Data
@@ -50,7 +50,12 @@ def build_script() -> None:
50
50
  if not gaia_file.exists():
51
51
  download_gaia_data(gaia_file)
52
52
 
53
- subprocess.check_call(["cargo", "build", "--release"], cwd=cwd) # noqa: S603, S607
53
+ subprocess.check_call( # noqa: S603
54
+ ["cargo", "build", "--release", "--features", "improc,gaia"], # noqa: S607
55
+ cwd=cwd,
56
+ stdout=None,
57
+ stderr=None,
58
+ )
54
59
  shutil.copy(
55
60
  cwd / "target/release/libruststartracker.so", cwd / "ruststartracker/libruststartracker.so"
56
61
  )
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "ruststartracker"
3
- version = "0.2.2"
3
+ version = "0.2.3"
4
4
  description = "Lightweight Python Star Tracker With Rust Backend"
5
5
  authors = ["Nicolas Tobler <nitobler@gmail.com>"]
6
6
  readme = "README.md"
@@ -42,7 +42,7 @@ requires = ["poetry-core", "astroquery>=0.4.10"]
42
42
  build-backend = "poetry.core.masonry.api"
43
43
 
44
44
  [tool.bumpversion]
45
- current_version = "0.2.2"
45
+ current_version = "0.2.3"
46
46
  commit = true
47
47
  tag = true
48
48
  tag_name = "v{new_version}"
@@ -2,12 +2,15 @@ from collections.abc import Iterator
2
2
 
3
3
  import numpy as np
4
4
  import numpy.typing as npt
5
+ from typing_extensions import Self
5
6
 
6
7
  class StarMatcher:
7
8
  def __init__(
8
9
  self,
9
10
  stars_xyz: npt.NDArray[np.float32],
11
+ stars_mag: npt.NDArray[np.float32],
10
12
  max_inter_star_angle: float,
13
+ max_lookup_magnitude: float,
11
14
  inter_star_angle_tolerance: float,
12
15
  n_minimum_matches: int,
13
16
  timeout_secs: float,
@@ -19,7 +22,7 @@ class StarMatcher:
19
22
  npt.NDArray[np.uint32],
20
23
  npt.NDArray[np.uint32],
21
24
  int,
22
- list[list[float]],
25
+ npt.NDArray[np.float32],
23
26
  float,
24
27
  ]: ...
25
28
 
@@ -44,15 +47,43 @@ class IterTriangleFinder:
44
47
  class UnitVectorLookup:
45
48
  def __init__(self, vec: npt.NDArray[np.float32]) -> None: ...
46
49
  def lookup_nearest(self, key: npt.NDArray[np.float32]) -> int: ...
47
- def get_inter_star_index_numpy(
48
- self, vec: npt.NDArray[np.float32], angle_threshold: float
49
- ) -> tuple[list[list[int]], list[float], list[float]]: ...
50
50
  def get_inter_star_index(
51
- self, vec: npt.NDArray[np.float32], angle_threshold: float
51
+ self,
52
+ stars: npt.NDArray[np.float32],
53
+ magnitudes: npt.NDArray[np.float32],
54
+ max_angle_rad: float,
55
+ max_magnitude: float,
52
56
  ) -> tuple[list[list[int]], list[float], list[float]]: ...
53
57
  def look_up_close_angles(
54
- self, vectors: npt.NDArray[np.float32], max_angle_rad: float
58
+ self,
59
+ vectors: npt.NDArray[np.float32],
60
+ magnitudes: npt.NDArray[np.float32],
61
+ max_angle_rad: float,
62
+ max_magnitude: float,
55
63
  ) -> list[tuple[list[float], float]]: ...
56
64
  def look_up_close_angles_naive(
57
- self, vectors: npt.NDArray[np.float32], max_angle_rad: float
65
+ self,
66
+ vectors: npt.NDArray[np.float32],
67
+ magnitudes: npt.NDArray[np.float32],
68
+ max_angle_rad: float,
69
+ max_magnitude: float,
58
70
  ) -> list[tuple[list[float], float]]: ...
71
+
72
+ def get_threshold_from_histogram(
73
+ img: npt.NDArray[np.uint8],
74
+ *,
75
+ fraction: float,
76
+ ) -> int: ...
77
+ def extract_observations(
78
+ img: npt.NDArray[np.uint8],
79
+ threshold: int,
80
+ min_star_area: int,
81
+ max_star_area: int,
82
+ ) -> tuple[npt.NDArray[np.float32], npt.NDArray[np.float32]]: ...
83
+
84
+ class StarCatalog:
85
+ @classmethod
86
+ def from_gaia(cls, *, max_magnitude: float | None) -> Self: ...
87
+ def normalized_positions(
88
+ self, *, epoch: float | None, observer_position: np.ndarray | None
89
+ ) -> npt.NDArray[np.float32]: ...
@@ -87,8 +87,10 @@ class StarTracker:
87
87
  def __init__(
88
88
  self,
89
89
  stars_xyz: npt.NDArray[np.float32],
90
+ stars_mag: npt.NDArray[np.float32],
90
91
  camera_params: CameraParameters,
91
92
  *,
93
+ max_lookup_magnitude: float | None = None,
92
94
  max_inter_star_angle: float | None = None,
93
95
  inter_star_angle_tolerance: float = 0.0008,
94
96
  n_minimum_matches: int = 10,
@@ -98,11 +100,15 @@ class StarTracker:
98
100
 
99
101
  Args:
100
102
  stars_xyz: Positions of catalog stars
103
+ stars_mag: Magnitudes of catalog stars
101
104
  camera_params: Calibrated camera parameters
105
+ max_lookup_magnitude: Maximum magnitude of stars used in the triangulation. Reducing
106
+ this number means only bright stars are used for triangulation. This results in
107
+ faster lookup performance.
102
108
  max_inter_star_angle: Maximum angle between stars that should be indexed.
103
109
  Calculating large inter star angles is expensive. If None, the angle is
104
110
  calculated from the camera field of view.
105
- inter_star_angle_tolerance: Tolerance for inter star angle matching.
111
+ inter_star_angle_tolerance: Tolerance for inter star angle matching in rad.
106
112
  n_minimum_matches: Minimum amount of required matches for a successful
107
113
  attitude estimation
108
114
  timeout_secs: Maximum allowed search time in seconds. A StarTrackerError is raised
@@ -127,8 +133,13 @@ class StarTracker:
127
133
  dot_products = (corner_coords_xyz * np.array([0, 0, 1], dtype=np.float32)).sum(axis=-1)
128
134
  max_inter_star_angle = float(np.arccos(dot_products.max())) * 2
129
135
 
136
+ if max_lookup_magnitude is None:
137
+ max_lookup_magnitude = 100.0 # A very faint star. Almost infinity
138
+
130
139
  self._star_matcher = ruststartracker.libruststartracker.StarMatcher(
131
140
  np.ascontiguousarray(stars_xyz, dtype=np.float32),
141
+ np.ascontiguousarray(stars_mag, dtype=np.float32),
142
+ float(max_lookup_magnitude),
132
143
  float(max_inter_star_angle),
133
144
  float(inter_star_angle_tolerance),
134
145
  int(n_minimum_matches),
@@ -178,6 +189,20 @@ class StarTracker:
178
189
  max_star_area=max_star_area,
179
190
  )
180
191
 
192
+ if threshold is None:
193
+ threshold = ruststartracker.libruststartracker.get_threshold_from_histogram(
194
+ img, fraction=0.99
195
+ )
196
+
197
+ centroids, intensities = ruststartracker.libruststartracker.extract_observations(
198
+ img,
199
+ threshold,
200
+ min_star_area,
201
+ max_star_area,
202
+ )
203
+ centroids = np.array(centroids, dtype=np.float32)
204
+ intensities = np.array(intensities, dtype=np.float32)
205
+
181
206
  # At least 3 observations are required (one triangle)
182
207
  if len(centroids) < 3:
183
208
  raise StarTrackerError("Found too few star candidates (< 3) to continue.")
@@ -238,12 +263,12 @@ class StarTracker:
238
263
  quat, match_ids, obs_indices, n_matches, matched_obs, duration_s = result
239
264
 
240
265
  return StarTrackerResult(
241
- quat=np.asarray(quat, dtype=np.float32),
242
- match_ids=np.asarray(match_ids, dtype=np.uint32),
266
+ quat=quat,
267
+ match_ids=match_ids,
243
268
  n_matches=n_matches,
244
269
  duration_s=duration_s,
245
- mached_obs_x=np.asarray(matched_obs, dtype=np.float32),
246
- obs_indices=np.asarray(obs_indices, dtype=np.uint32),
270
+ mached_obs_x=matched_obs,
271
+ obs_indices=obs_indices,
247
272
  )
248
273
 
249
274
 
@@ -54,9 +54,12 @@ def test_unit_vector_lookup():
54
54
  close_indices_gt = np.concatenate(results, axis=-1).T[args]
55
55
  angles_gt = angles[args]
56
56
 
57
- close_indices, angles, poly = uvl.get_inter_star_index_numpy(vec, angle_threshold)
58
-
59
- close_indices, angles, poly = uvl.get_inter_star_index(vec[:, :3], angle_threshold)
57
+ close_indices, angles, poly = uvl.get_inter_star_index(
58
+ np.array(vec[:, :3], dtype=np.float32),
59
+ np.ones(len(vec), dtype=np.float32),
60
+ angle_threshold,
61
+ 10,
62
+ )
60
63
  close_indices = np.array(close_indices)
61
64
  angles = np.array(angles)
62
65
  poly = np.array(poly)
@@ -101,6 +104,8 @@ def test_star_matcher():
101
104
  vec = rng.normal(size=[n_cat_stars, 3]).astype(np.float32)
102
105
  vec /= np.linalg.norm(vec, axis=-1, keepdims=True)
103
106
 
107
+ magnitudes = rng.uniform(0, 10, size=vec.shape[:1]).astype(np.float32)
108
+
104
109
  key = rng.normal(size=[3]).astype(np.float32)
105
110
  key /= np.linalg.norm(key, axis=-1, keepdims=True)
106
111
 
@@ -113,10 +118,16 @@ def test_star_matcher():
113
118
 
114
119
  rot = scipy.spatial.transform.Rotation.from_rotvec([1, 1, 1])
115
120
 
116
- obs_rotated = rot.apply(obs)
121
+ obs_rotated = rot.apply(obs).astype(np.float32)
117
122
 
118
123
  index = libruststartracker.StarMatcher(
119
- vec, np.radians(10).item(), np.radians(0.1).item(), 4, 999.0
124
+ vec,
125
+ magnitudes,
126
+ 10,
127
+ np.radians(10).item(),
128
+ np.radians(0.1).item(),
129
+ 4,
130
+ 999.0,
120
131
  )
121
132
 
122
133
  res = index.find(obs_rotated)
@@ -124,7 +135,7 @@ def test_star_matcher():
124
135
  assert res is not None
125
136
 
126
137
  quat, match_ids, obs_indices, n_matches, matched_obs, time_s = res
127
- np.testing.assert_allclose(quat, rot.inv().as_quat())
138
+ np.testing.assert_allclose(quat, rot.inv().as_quat(), rtol=1e-6)
128
139
  assert n_matches >= 4
129
140
  assert len(obs_index) == len(match_ids)
130
141
 
@@ -1,10 +1,12 @@
1
1
  import datetime
2
+ import time
2
3
 
3
4
  import astropy.time # type: ignore[import]
4
5
  import numpy as np
5
6
  import pytest
6
7
 
7
8
  import ruststartracker.catalog
9
+ import ruststartracker.libruststartracker
8
10
 
9
11
 
10
12
  def test_time_to_epoch():
@@ -43,5 +45,17 @@ def test_extract_observations():
43
45
  np.testing.assert_allclose(np.linalg.norm(positions, axis=-1), 1.0, rtol=1e-5)
44
46
 
45
47
 
48
+ def test_python_rust():
49
+ t0 = time.monotonic()
50
+ positions = ruststartracker.catalog.StarCatalog().normalized_positions(epoch=2025.0)
51
+ print(f"Python catalog took {time.monotonic() - t0:.3f} seconds")
52
+ t0 = time.monotonic()
53
+ positions2 = ruststartracker.libruststartracker.StarCatalog.from_gaia(
54
+ max_magnitude=6.0
55
+ ).normalized_positions(epoch=2025.0, observer_position=None)
56
+ print(f"Rust catalog took {time.monotonic() - t0:.3f} seconds")
57
+ np.testing.assert_allclose(positions, positions2, rtol=1e-5, atol=1e-5)
58
+
59
+
46
60
  if __name__ == "__main__":
47
61
  pytest.main([__file__])
@@ -26,6 +26,7 @@ def prepare() -> tuple[ruststartracker.StarTracker, np.ndarray]:
26
26
 
27
27
  catalog = ruststartracker.StarCatalog()
28
28
  star_catalog_vecs = catalog.normalized_positions(epoch=2024)
29
+ star_catalog_magnitudes = catalog.magnitude
29
30
 
30
31
  camera_params = ruststartracker.CameraParameters(
31
32
  camera_matrix=camera_matrix,
@@ -35,6 +36,7 @@ def prepare() -> tuple[ruststartracker.StarTracker, np.ndarray]:
35
36
 
36
37
  st = ruststartracker.StarTracker(
37
38
  star_catalog_vecs,
39
+ star_catalog_magnitudes,
38
40
  camera_params,
39
41
  inter_star_angle_tolerance=np.radians(0.05).item(),
40
42
  n_minimum_matches=5,
@@ -5,16 +5,31 @@ import pytest
5
5
  import scipy.spatial
6
6
 
7
7
  import ruststartracker
8
+ import ruststartracker.libruststartracker
8
9
  import ruststartracker.star
9
10
 
10
11
 
11
- def test_extract_observations():
12
- size_x, size_y = (40, 50)
12
+ @pytest.mark.parametrize("impl", ["python", "rust"])
13
+ def test_extract_observations(impl: str):
14
+ size_x, size_y = (960, 480)
13
15
  img = np.zeros((size_y, size_x), np.uint8)
14
- points = np.array([(3, 5), (23, 13)])
16
+ points = np.array([(3, 5), (23, 13), (30, 50), (230, 130)])
15
17
  for x, y in points:
16
18
  img[y - 1 : y + 3, x - 1 : x + 3] = 50
17
- centers, intensities = ruststartracker.star._extract_observations(img, threshold=30)
19
+
20
+ t0 = time.monotonic()
21
+ if impl == "python":
22
+ centers, intensities = ruststartracker.star._extract_observations(img, threshold=30)
23
+ elif impl == "rust":
24
+ centers, intensities = ruststartracker.libruststartracker.extract_observations(
25
+ img, 30, 3, 300
26
+ )
27
+ else:
28
+ raise AssertionError
29
+ print(f"Extracting observations took {time.monotonic() - t0:.5f} seconds")
30
+
31
+ assert isinstance(centers, np.ndarray)
32
+ assert isinstance(intensities, np.ndarray)
18
33
  np.testing.assert_almost_equal(centers, points + 0.5)
19
34
  np.testing.assert_almost_equal(intensities, 50 * 16)
20
35
 
@@ -28,6 +43,8 @@ def setup():
28
43
  vec = rng.normal(size=[n_cat_stars, 3]).astype(np.float32)
29
44
  vec /= np.linalg.norm(vec, axis=-1, keepdims=True)
30
45
 
46
+ mag = rng.uniform(0, 10, size=vec.shape[:1]).astype(np.float32)
47
+
31
48
  angle_threshold = np.radians(10)
32
49
  dotp = np.sum([0, 0, 1] * vec, axis=-1)
33
50
  threshold = np.cos(angle_threshold).item()
@@ -55,17 +72,18 @@ def setup():
55
72
  image_patch = img[y - 1 : y + 2, x - 1 : x + 2]
56
73
  image_patch[:] = 50
57
74
 
58
- return img, vec, camera_params
75
+ return img, vec, mag, pixel_in_frame, camera_params
59
76
 
60
77
 
61
78
  def test_star_matcher_success(setup):
62
- img, vec, camera_params = setup
79
+ img, vec, mag, _, camera_params = setup
63
80
 
64
81
  rot = scipy.spatial.transform.Rotation.from_rotvec([1, 1, 1])
65
82
  vec = rot.inv().apply(vec)
66
83
 
67
84
  st = ruststartracker.StarTracker(
68
85
  vec,
86
+ mag,
69
87
  camera_params,
70
88
  inter_star_angle_tolerance=np.radians(0.1).item(),
71
89
  n_minimum_matches=6,
@@ -78,34 +96,51 @@ def test_star_matcher_success(setup):
78
96
 
79
97
 
80
98
  def test_star_matcher_exhaust(setup):
81
- img, vec, camera_params = setup
99
+ img, vec, mag, _, camera_params = setup
82
100
  st = ruststartracker.StarTracker(
83
101
  vec,
102
+ mag,
84
103
  camera_params,
85
104
  inter_star_angle_tolerance=np.radians(0.001).item(),
86
105
  n_minimum_matches=500,
87
106
  timeout_secs=999.0,
88
107
  )
89
- with pytest.raises(ruststartracker.StarTrackerError, match="exhaust"):
108
+ with pytest.raises(ruststartracker.StarTrackerError, match="SearchExhausted"):
90
109
  st.process_image(img)
91
110
 
92
111
 
93
112
  def test_star_matcher_timout(setup):
94
- img, vec, camera_params = setup
95
- timeout = 0.2
113
+ img, vec, mag, _, camera_params = setup
114
+ timeout = 0.0002
96
115
  st = ruststartracker.StarTracker(
97
116
  vec,
117
+ mag,
98
118
  camera_params,
99
119
  inter_star_angle_tolerance=np.radians(0.1).item(),
100
120
  n_minimum_matches=500,
101
121
  timeout_secs=timeout,
102
122
  )
103
123
  t = time.monotonic()
104
- with pytest.raises(ruststartracker.StarTrackerError, match="Timeout reached"):
124
+ with pytest.raises(ruststartracker.StarTrackerError, match="Timeout"):
105
125
  st.process_image(img)
106
126
  passed_time = time.monotonic() - t
107
127
  assert passed_time > timeout
108
128
 
109
129
 
130
+ def test_star_matcher_not_enough_stars(setup):
131
+ _, vec, mag, pixel_in_frame, camera_params = setup
132
+ timeout = 0.2
133
+ st = ruststartracker.StarTracker(
134
+ vec,
135
+ mag,
136
+ camera_params,
137
+ inter_star_angle_tolerance=np.radians(0.1).item(),
138
+ n_minimum_matches=500,
139
+ timeout_secs=timeout,
140
+ )
141
+ with pytest.raises(ruststartracker.StarTrackerError, match="NotEnoughStars"):
142
+ st.process_image_coordiantes(pixel_in_frame[:2])
143
+
144
+
110
145
  if __name__ == "__main__":
111
- pytest.main([__file__])
146
+ pytest.main([__file__, "--capture=no"])
File without changes