ruststartracker 0.2.1__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Nicolas Tobler
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.3
2
+ Name: ruststartracker
3
+ Version: 0.2.1
4
+ Summary: Lightweight Python Star Tracker With Rust Backend
5
+ License: MIT
6
+ Author: Nicolas Tobler
7
+ Author-email: nitobler@gmail.com
8
+ Requires-Python: >=3.10,<4.0
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Requires-Dist: numpy (>=1.26.0,<2.0.0)
16
+ Requires-Dist: opencv-python-headless (>=4.9.0,<5.0.0)
17
+ Description-Content-Type: text/markdown
18
+
19
+ # Lightweight Python Star Tracker With Rust Backend
20
+
21
+ Based on the methodology used in https://github.com/nasa/COTS-Star-Tracker, with following improvements:
22
+ - Reduced dependencies to opencv and numpy for lightweight usage in a Raspberry Pi.
23
+ - Reimplemented computationally expensive parts in rust. This includes most parts that are not image processing related.
24
+ - Added quadratic inter star angle index look up polynomial for faster triangle search.
25
+ - Added spatial index to look up neighboring stars.
26
+
27
+ Features:
28
+ - Attitude estimation from image and camera calibration parameters.
29
+ - Attitude estimation from list of star observation coordinates.
30
+ - Star catalog creation with temporal corrections.
31
+
32
+ ## Example
33
+
34
+ ```python
35
+ import ruststartracker
36
+
37
+ # Get catalog positions
38
+ catalog = ruststartracker.StarCatalog()
39
+ star_catalog_vecs = catalog.normalized_positions()
40
+
41
+ # Define opencv camera parameters, see https://docs.opencv.org/4.x/dc/dbb/tutorial_py_calibration.html
42
+ camera_params = ruststartracker.CameraParameters(
43
+ camera_matrix=...,
44
+ cam_resolution=...,
45
+ dist_coefs=...,
46
+ )
47
+
48
+ # Create StarTracker instance (reuse this)
49
+ st = ruststartracker.StarTracker(
50
+ star_catalog_vecs,
51
+ camera_params,
52
+ max_inter_star_angle=...,
53
+ inter_star_angle_tolerance=...,
54
+ n_minimum_matches=...,
55
+ )
56
+
57
+ # Obtain numpy array image
58
+ img = ...
59
+
60
+ # Find attitude from given image
61
+ result = st.process_image(img)
62
+
63
+ print(result)
64
+ # StarTrackerResult(quat=[-0.43977802991867065, -0.439766526222229, -0.4398997128009796, 0.6478340029716492], match_ids=[1435, 1272, 1140, 2035, 1070, 1438, 1338, 903, 260, 2141, 1771, 1727, 385, 1717, 2204, 2062, 1989, 1634, 708, 1357], n_matches=20, duration_s=0.0003700880042742938)
65
+ ```
66
+
67
+ ## Installation
68
+
69
+ - Make sure rust tool chain (`cargo`) is installed and in the `PATH` environment variable.
70
+ - Install with `pip install git+https://github.com/ntobler/ruststartracker.git`.
71
+
72
+ ## TODOs
73
+
74
+ - Improve error messages.
75
+ - Return more diagnostic data.
76
+
@@ -0,0 +1,57 @@
1
+ # Lightweight Python Star Tracker With Rust Backend
2
+
3
+ Based on the methodology used in https://github.com/nasa/COTS-Star-Tracker, with following improvements:
4
+ - Reduced dependencies to opencv and numpy for lightweight usage in a Raspberry Pi.
5
+ - Reimplemented computationally expensive parts in rust. This includes most parts that are not image processing related.
6
+ - Added quadratic inter star angle index look up polynomial for faster triangle search.
7
+ - Added spatial index to look up neighboring stars.
8
+
9
+ Features:
10
+ - Attitude estimation from image and camera calibration parameters.
11
+ - Attitude estimation from list of star observation coordinates.
12
+ - Star catalog creation with temporal corrections.
13
+
14
+ ## Example
15
+
16
+ ```python
17
+ import ruststartracker
18
+
19
+ # Get catalog positions
20
+ catalog = ruststartracker.StarCatalog()
21
+ star_catalog_vecs = catalog.normalized_positions()
22
+
23
+ # Define opencv camera parameters, see https://docs.opencv.org/4.x/dc/dbb/tutorial_py_calibration.html
24
+ camera_params = ruststartracker.CameraParameters(
25
+ camera_matrix=...,
26
+ cam_resolution=...,
27
+ dist_coefs=...,
28
+ )
29
+
30
+ # Create StarTracker instance (reuse this)
31
+ st = ruststartracker.StarTracker(
32
+ star_catalog_vecs,
33
+ camera_params,
34
+ max_inter_star_angle=...,
35
+ inter_star_angle_tolerance=...,
36
+ n_minimum_matches=...,
37
+ )
38
+
39
+ # Obtain numpy array image
40
+ img = ...
41
+
42
+ # Find attitude from given image
43
+ result = st.process_image(img)
44
+
45
+ print(result)
46
+ # StarTrackerResult(quat=[-0.43977802991867065, -0.439766526222229, -0.4398997128009796, 0.6478340029716492], match_ids=[1435, 1272, 1140, 2035, 1070, 1438, 1338, 903, 260, 2141, 1771, 1727, 385, 1717, 2204, 2062, 1989, 1634, 708, 1357], n_matches=20, duration_s=0.0003700880042742938)
47
+ ```
48
+
49
+ ## Installation
50
+
51
+ - Make sure rust tool chain (`cargo`) is installed and in the `PATH` environment variable.
52
+ - Install with `pip install git+https://github.com/ntobler/ruststartracker.git`.
53
+
54
+ ## TODOs
55
+
56
+ - Improve error messages.
57
+ - Return more diagnostic data.
@@ -0,0 +1,138 @@
1
+ [tool.poetry]
2
+ name = "ruststartracker"
3
+ version = "0.2.1"
4
+ description = "Lightweight Python Star Tracker With Rust Backend"
5
+ authors = ["Nicolas Tobler <nitobler@gmail.com>"]
6
+ readme = "README.md"
7
+ license = "MIT"
8
+ include = [
9
+ { path = "ruststartracker/star_catalog.tsv", format = ["sdist", "wheel"] },
10
+ { path = "ruststartracker/libruststartracker.so", format = ["wheel"] },
11
+ ]
12
+
13
+ [tool.poetry.dependencies]
14
+ python = "^3.10"
15
+ numpy = "^1.26.0"
16
+ opencv-python-headless = "^4.9.0"
17
+
18
+ [tool.poetry.group.dev.dependencies]
19
+ matplotlib = "^3.9.2"
20
+ scipy = "^1.14.1"
21
+ viztracer = "^0.16.3"
22
+ pytest = "^8.3.3"
23
+ pytest-cov = "^6.0.0"
24
+ pre-commit = "^3.8.0"
25
+ astropy = "^6.1.4"
26
+ mypy = "^1.13.0"
27
+ scipy-stubs = "^1.14.1.5"
28
+ ruff = "^0.9.3"
29
+ bump-my-version = "^1.2.0"
30
+
31
+ [tool.poetry.build]
32
+ script = "rust_build.py"
33
+
34
+ [build-system]
35
+ requires = ["poetry-core"]
36
+ build-backend = "poetry.core.masonry.api"
37
+
38
+ [tool.bumpversion]
39
+ current_version = "0.2.1"
40
+ commit = true
41
+ tag = true
42
+ tag_name = "v{new_version}"
43
+ commit_message = "ci: bump version to {new_version}"
44
+
45
+ [[tool.bumpversion.files]]
46
+ filename = "pyproject.toml"
47
+ search = 'version = "{current_version}"'
48
+ replace = 'version = "{new_version}"'
49
+
50
+ [[tool.bumpversion.files]]
51
+ filename = "Cargo.toml"
52
+ search = 'version = "{current_version}"'
53
+ replace = 'version = "{new_version}"'
54
+
55
+ [tool.ruff]
56
+ line-length = 100
57
+
58
+ [tool.ruff.lint]
59
+ select = [
60
+ # Pyflakes
61
+ "F",
62
+ # pycodestyle error
63
+ "E",
64
+ # pycodestyle warning
65
+ "W",
66
+ # pydocstring
67
+ "D",
68
+ # mccabe
69
+ "C90",
70
+ # isort
71
+ "I",
72
+ # Pep8 naming
73
+ "N",
74
+ # pyupgrade
75
+ "UP",
76
+ # flake8-2020
77
+ "YTT",
78
+ # flake8-annotations
79
+ "ANN",
80
+ # flake8-bandit
81
+ "S",
82
+ # flake8-bugbear
83
+ "B",
84
+ # flake8-simplify
85
+ "SIM",
86
+ # boolean trap
87
+ "FBT001",
88
+ # Built in shadowing
89
+ "A",
90
+ # flake8-comprehensions
91
+ "C4",
92
+ "NPY",
93
+ "DTZ",
94
+ "T10",
95
+ "EXE",
96
+ "ISC",
97
+ "ICN",
98
+ "LOG",
99
+ "PIE",
100
+ "Q",
101
+ "RET",
102
+ "SLF",
103
+ "TID",
104
+ "TCH",
105
+ "ARG",
106
+ "PTH",
107
+ # Perflint
108
+ "PERF",
109
+ # Ruff-specific rules
110
+ "RUF"
111
+ ]
112
+ ignore = [
113
+ "ISC001" # conflict with formatter
114
+ ]
115
+ pydocstyle.convention = "google"
116
+
117
+ [tool.ruff.lint.per-file-ignores]
118
+ "test*" = ["D100", "D103", "SLF001", "ANN", "S101"]
119
+
120
+ [tool.mypy]
121
+ packages = ["ruststartracker"]
122
+ python_version = "3.10"
123
+ warn_return_any = true
124
+ warn_unused_configs = true
125
+
126
+ [tool.pytest.ini_options]
127
+ addopts = "--cov=ruststartracker --cov-report xml --cov-report term --cov-fail-under=95"
128
+
129
+ [tool.codespell]
130
+ ignore-words-list = "crate"
131
+
132
+ [tool.coverage.report]
133
+ exclude_lines = [
134
+ 'if __name__ == "__main__":',
135
+ 'if plot:',
136
+ 'if debug:',
137
+ 'if verbose:',
138
+ ]
@@ -0,0 +1,21 @@
1
+ """Build rust backend.
2
+
3
+ This script is automatically run when the pyproject is installed.
4
+ """
5
+
6
+ import pathlib
7
+ import shutil
8
+ import subprocess
9
+
10
+
11
+ def rust_build() -> None:
12
+ """Build rust backend and move shared library to correct folder."""
13
+ cwd = pathlib.Path(__file__).parent.expanduser().absolute()
14
+ subprocess.check_call(["cargo", "build", "--release"], cwd=cwd) # noqa: S603, S607
15
+ shutil.copy(
16
+ cwd / "target/release/libruststartracker.so", cwd / "ruststartracker/libruststartracker.so"
17
+ )
18
+
19
+
20
+ if __name__ == "__main__":
21
+ rust_build()
@@ -0,0 +1,12 @@
1
+ """Star tracker implementation with backend in rust."""
2
+
3
+ from ruststartracker.catalog import StarCatalog
4
+ from ruststartracker.star import CameraParameters, StarTracker, StarTrackerError, StarTrackerResult
5
+
6
+ __all__ = [
7
+ "CameraParameters",
8
+ "StarCatalog",
9
+ "StarTracker",
10
+ "StarTrackerError",
11
+ "StarTrackerResult",
12
+ ]
@@ -0,0 +1,151 @@
1
+ """Create star catalog."""
2
+
3
+ import csv
4
+ import datetime
5
+ import math
6
+ import pathlib
7
+
8
+ import numpy as np
9
+ import numpy.typing as npt
10
+
11
+ AU: float = 149597870.693
12
+ """Astronomical unit."""
13
+
14
+ INTERNAL_CATALOG_FILE = pathlib.Path(__file__).parent.expanduser().absolute() / "star_catalog.tsv"
15
+ """Location of the internal star catalog file."""
16
+
17
+
18
+ def time_to_epoch(t: datetime.datetime) -> float:
19
+ """Convert a date time object to the Epoch in Julian years."""
20
+ # Get time stamp at the Julian year J2000
21
+ delta_t_j2000 = 64 # Delta T at J2000 https://en.wikipedia.org/wiki/%CE%94T_(timekeeping)
22
+ j2000 = datetime.datetime.fromisoformat("2000-01-01T12:00:00").timestamp() - delta_t_j2000
23
+ # Calculate difference from given datetime to j2000 and convert to Julian years
24
+ seconds_in_j_year = 365.25 * 86400
25
+ return (t.timestamp() - j2000) / seconds_in_j_year + 2000.0
26
+
27
+
28
+ class StarCatalog:
29
+ """Star catalog from Hipparcos data."""
30
+
31
+ _data: npt.NDArray[np.float32]
32
+ """Underlying data array."""
33
+ ra: npt.NDArray[np.float32]
34
+ """Right ascension at epoch in rads."""
35
+ de: npt.NDArray[np.float32]
36
+ """Declination at epoch in rads."""
37
+ parallax: npt.NDArray[np.float32]
38
+ """Parallax in rads."""
39
+ proper_motion_ra: npt.NDArray[np.float32]
40
+ """Proper motion of the right ascension in mad."""
41
+ proper_motion_de: npt.NDArray[np.float32]
42
+ """Proper motion of the declination in mad."""
43
+ magnitude: npt.NDArray[np.float32]
44
+ """Magnitude values."""
45
+ epoch: float = 1992.25
46
+ """Epoch of the catalog in years."""
47
+
48
+ def __init__(
49
+ self, filename: pathlib.Path | str | None = None, max_magnitude: float = 6.0
50
+ ) -> None:
51
+ """Read catalog from file.
52
+
53
+ Args:
54
+ filename: Star catalog filename.
55
+ max_magnitude: Maximum magnitude to include.
56
+ """
57
+ # Use locally included star catalog is no file is given
58
+ filename = INTERNAL_CATALOG_FILE if filename is None else pathlib.Path(filename)
59
+
60
+ keep_columns = ("RArad", "DErad", "Plx", "pmRA", "pmDE", "Hpmag")
61
+
62
+ with filename.open("r") as f:
63
+ it = csv.reader(f, delimiter="\t", strict=True)
64
+
65
+ # skip head
66
+ [next(it) for _ in range(52)]
67
+
68
+ # Get columns
69
+ columns = next(it)
70
+
71
+ keep_columns_indices = tuple(columns.index(x) for x in keep_columns)
72
+ min_length = len(keep_columns_indices)
73
+
74
+ mag_column_index = columns.index("Hpmag")
75
+
76
+ # Skip unit and horizontal bar
77
+ [next(it) for _ in range(2)]
78
+
79
+ rows = [
80
+ [float(line[j]) for j in keep_columns_indices]
81
+ for line in it
82
+ if len(line) >= min_length and float(line[mag_column_index]) <= max_magnitude
83
+ ]
84
+ self._data = np.array(rows, dtype=np.float32)
85
+
86
+ self.ra = self._data[:, keep_columns.index("RArad")]
87
+ self.de = self._data[:, keep_columns.index("DErad")]
88
+ self.parallax = self._data[:, keep_columns.index("Plx")]
89
+ self.proper_motion_ra = self._data[:, keep_columns.index("pmRA")]
90
+ self.proper_motion_de = self._data[:, keep_columns.index("pmDE")]
91
+ self.magnitude = self._data[:, keep_columns.index("Hpmag")]
92
+
93
+ deg2rad = math.pi / 180
94
+ arcsec2deg = 1 / 3600
95
+ mas2arcsec = 1 / 1000
96
+ mas2rad = mas2arcsec * arcsec2deg * deg2rad
97
+
98
+ # Convert data to rad
99
+ self.ra *= deg2rad
100
+ self.de *= deg2rad
101
+ self.proper_motion_ra *= mas2rad
102
+ self.proper_motion_de *= mas2rad
103
+ self.parallax *= mas2rad
104
+
105
+ def normalized_positions(
106
+ self, *, epoch: float | None = None, observer_position: np.ndarray | None = None
107
+ ) -> npt.NDArray[np.float32]:
108
+ """Get star positions as normalized (x, y, z) vector.
109
+
110
+ Args:
111
+ epoch: The Julian epoch at which the positions should be calculated in Julian
112
+ years. E.g. 2024.3. If None, the current local time us used.
113
+ observer_position: The position of the observer in the Equatorial coordinate
114
+ system relative to the sun. If None, position is not corrected.
115
+
116
+ Returns:
117
+ Normalized (x, y, z) vector of star positions, shape=[n, 3]
118
+ """
119
+ if epoch is None:
120
+ epoch = time_to_epoch(datetime.datetime.now(tz=datetime.timezone.utc))
121
+
122
+ delta_epoch = epoch - self.epoch
123
+
124
+ # Precalculate sin and cos
125
+ cos_ra: npt.NDArray[np.float32] = np.cos(self.ra)
126
+ sin_ra: npt.NDArray[np.float32] = np.sin(self.ra)
127
+ sin_de: npt.NDArray[np.float32] = np.sin(self.de)
128
+ cos_de: npt.NDArray[np.float32] = np.cos(self.de)
129
+ zeros = np.zeros_like(self.de)
130
+
131
+ # Get star positions as normalized vector (x, y, z)
132
+ vectors: npt.NDArray[np.float32] = np.stack(
133
+ [cos_de * cos_ra, cos_de * sin_ra, sin_de], axis=-1
134
+ )
135
+
136
+ # Correct proper motion
137
+ p_hat = np.stack([-sin_ra, cos_ra, zeros], axis=-1)
138
+ q_hat = np.stack([-sin_de * cos_ra, -sin_de * sin_ra, cos_de], axis=-1)
139
+ pm = delta_epoch * (
140
+ self.proper_motion_ra[..., np.newaxis] * p_hat
141
+ + self.proper_motion_de[..., np.newaxis] * q_hat
142
+ )
143
+ vectors += pm
144
+
145
+ if observer_position is not None:
146
+ plx = self.parallax[:, np.newaxis] * (observer_position / AU)[np.newaxis, :]
147
+ vectors -= plx
148
+
149
+ # normalize star positions
150
+ vectors /= np.linalg.norm(vectors, axis=-1, keepdims=True)
151
+ return vectors
@@ -0,0 +1,58 @@
1
+ from collections.abc import Iterator
2
+
3
+ import numpy as np
4
+ import numpy.typing as npt
5
+
6
+ class StarMatcher:
7
+ def __init__(
8
+ self,
9
+ stars_xyz: npt.NDArray[np.float32],
10
+ max_inter_star_angle: float,
11
+ inter_star_angle_tolerance: float,
12
+ n_minimum_matches: int,
13
+ timeout_secs: float,
14
+ ) -> None: ...
15
+ def find(
16
+ self, obs_xyz: npt.NDArray[np.float32]
17
+ ) -> tuple[
18
+ npt.NDArray[np.float32],
19
+ npt.NDArray[np.uint32],
20
+ npt.NDArray[np.uint32],
21
+ int,
22
+ list[list[float]],
23
+ float,
24
+ ]: ...
25
+
26
+ class TriangleFinder:
27
+ def __init__(
28
+ self,
29
+ ab: npt.NDArray[np.float32],
30
+ ac: npt.NDArray[np.float32],
31
+ bc: npt.NDArray[np.float32],
32
+ ) -> None: ...
33
+ def get(self) -> list[int]: ...
34
+
35
+ class IterTriangleFinder:
36
+ def __init__(
37
+ self,
38
+ ab: npt.NDArray[np.float32],
39
+ ac: npt.NDArray[np.float32],
40
+ bc: npt.NDArray[np.float32],
41
+ ) -> None: ...
42
+ def __iter__(self) -> Iterator[list[int]]: ...
43
+
44
+ class UnitVectorLookup:
45
+ def __init__(self, vec: npt.NDArray[np.float32]) -> None: ...
46
+ 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
+ def get_inter_star_index(
51
+ self, vec: npt.NDArray[np.float32], angle_threshold: float
52
+ ) -> tuple[list[list[int]], list[float], list[float]]: ...
53
+ def look_up_close_angles(
54
+ self, vectors: npt.NDArray[np.float32], max_angle_rad: float
55
+ ) -> list[tuple[list[float], float]]: ...
56
+ def look_up_close_angles_naive(
57
+ self, vectors: npt.NDArray[np.float32], max_angle_rad: float
58
+ ) -> list[tuple[list[float], float]]: ...
File without changes