diff --git a/CMakeLists.txt b/CMakeLists.txt index 5106171..43238ea 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -13,5 +13,5 @@ set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) # Add subdirectories -add_subdirectory(rpt) +add_subdirectory(rpt_cpp) add_subdirectory(bindings) diff --git a/README.md b/README.md index feeb91a..dc4d34e 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ The current fork keeps the triangulation core, exposes it through `nanobind`, an Current package status: - Python `>=3.10` -- NumPy runtime dependency +- Runtime dependencies: NumPy, jaxtyping - Current version: `0.2.0` ## Current Capabilities @@ -54,6 +54,12 @@ Run the test suite: uv run pytest -q ``` +Run static typing checks against the Python package: + +```bash +uv run basedpyright +``` + Build source and wheel artifacts: ```bash @@ -62,6 +68,20 @@ uv build `run_container.sh` is still present in the repo, but it is a leftover helper script rather than the primary or best-supported development workflow. +## Typing Workflow + +The Python package ships a typed facade in `src/rpt` plus a checked-in stub for the compiled nanobind module at `src/rpt/_core.pyi`. + +Refresh the extension stub after changing the bindings: + +```bash +cmake --build build --target rpt_core_stub +cp build/bindings/rpt/_core.pyi src/rpt/_core.pyi +uv run basedpyright +``` + +`tests/test_typing_artifacts.py` checks that the checked-in `_core.pyi` matches the generated nanobind stub whenever the build artifact is available. + ## Python API Overview Typical triangulation flow: diff --git a/bindings/CMakeLists.txt b/bindings/CMakeLists.txt index c428e07..5aac509 100644 --- a/bindings/CMakeLists.txt +++ b/bindings/CMakeLists.txt @@ -30,7 +30,7 @@ set_target_properties(rpt_core_ext PROPERTIES target_link_libraries(rpt_core_ext PRIVATE rpt_core) target_include_directories(rpt_core_ext PRIVATE - "${PROJECT_SOURCE_DIR}/rpt" + "${PROJECT_SOURCE_DIR}/rpt_cpp" ) nanobind_add_stub(rpt_core_stub @@ -43,3 +43,4 @@ nanobind_add_stub(rpt_core_stub install(TARGETS rpt_core_ext LIBRARY DESTINATION rpt) install(FILES "${RPT_PYTHON_PACKAGE_DIR}/__init__.pyi" DESTINATION rpt) install(FILES "${RPT_PYTHON_PACKAGE_DIR}/_core.pyi" DESTINATION rpt) +install(FILES "${RPT_PYTHON_PACKAGE_DIR}/py.typed" DESTINATION rpt) diff --git a/pyproject.toml b/pyproject.toml index 94356a2..ef263a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,10 +11,16 @@ version = "0.2.0" description = "Rapid Pose Triangulation library with nanobind Python bindings" readme = "README.md" requires-python = ">=3.10" -dependencies = ["numpy>=2.0"] +dependencies = [ + "jaxtyping", + "numpy>=2.0", +] [dependency-groups] -dev = ["pytest>=8.3"] +dev = [ + "basedpyright>=1.38.3", + "pytest>=8.3", +] [tool.scikit-build] minimum-version = "build-system.requires" diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000..6b66f30 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,13 @@ +{ + "include": ["src"], + "ignore": ["src/rpt/_core.pyi"], + "failOnWarnings": false, + "pythonVersion": "3.10", + "reportMissingModuleSource": "none", + "executionEnvironments": [ + { + "root": "tests", + "extraPaths": ["src"] + } + ] +} diff --git a/rpt/CMakeLists.txt b/rpt_cpp/CMakeLists.txt similarity index 100% rename from rpt/CMakeLists.txt rename to rpt_cpp/CMakeLists.txt diff --git a/rpt/camera.cpp b/rpt_cpp/camera.cpp similarity index 100% rename from rpt/camera.cpp rename to rpt_cpp/camera.cpp diff --git a/rpt/camera.hpp b/rpt_cpp/camera.hpp similarity index 100% rename from rpt/camera.hpp rename to rpt_cpp/camera.hpp diff --git a/rpt/interface.cpp b/rpt_cpp/interface.cpp similarity index 100% rename from rpt/interface.cpp rename to rpt_cpp/interface.cpp diff --git a/rpt/interface.hpp b/rpt_cpp/interface.hpp similarity index 100% rename from rpt/interface.hpp rename to rpt_cpp/interface.hpp diff --git a/rpt/rgbd_merger.cpp b/rpt_cpp/rgbd_merger.cpp similarity index 100% rename from rpt/rgbd_merger.cpp rename to rpt_cpp/rgbd_merger.cpp diff --git a/rpt/triangulator.cpp b/rpt_cpp/triangulator.cpp similarity index 100% rename from rpt/triangulator.cpp rename to rpt_cpp/triangulator.cpp diff --git a/src/rpt/__init__.py b/src/rpt/__init__.py index 891fd51..7d049ca 100644 --- a/src/rpt/__init__.py +++ b/src/rpt/__init__.py @@ -34,7 +34,15 @@ if TYPE_CHECKING: import numpy as np import numpy.typing as npt - from ._helpers import CameraLike, CameraModelLike, DepthImageLike, Matrix3x3Like, PoseViewLike, VectorLike + from ._helpers import ( + CameraLike, + CameraModelLike, + DepthImageLike, + Matrix3x3Like, + PoseViewLike, + TranslationVectorLike, + VectorLike, + ) PoseArray2D = npt.NDArray[np.float32] PoseArray3D = npt.NDArray[np.float32] @@ -64,22 +72,25 @@ def make_camera( K: "Matrix3x3Like", DC: "VectorLike", R: "Matrix3x3Like", - T: "Sequence[Sequence[float]]", + T: "TranslationVectorLike", width: int, height: int, model: "CameraModel | CameraModelLike", ) -> Camera: - """Create an immutable camera and precompute its cached projection fields.""" + """Create an immutable camera and precompute its cached projection fields. - from ._helpers import _coerce_camera_model, _coerce_distortion + `T` may be a flat `[x, y, z]` vector or a nested translation matrix with shape `[1, 3]` or `[3, 1]`. + """ + + from ._helpers import _coerce_camera_model, _coerce_distortion, _coerce_matrix3x3, _coerce_translation camera_model = _coerce_camera_model(model) return _make_camera( name, - K, + _coerce_matrix3x3(K, "K").tolist(), _coerce_distortion(DC, camera_model), - R, - T, + _coerce_matrix3x3(R, "R").tolist(), + _coerce_translation(T).tolist(), width, height, camera_model, diff --git a/src/rpt/__init__.pyi b/src/rpt/__init__.pyi index e1b2be1..5677e05 100644 --- a/src/rpt/__init__.pyi +++ b/src/rpt/__init__.pyi @@ -5,25 +5,34 @@ import numpy as np import numpy.typing as npt from ._core import ( - AssociationReport, - AssociationStatus, - Camera, - CameraModel, - CoreProposalDebug, - FinalPoseAssociationDebug, - FullProposalDebug, - GroupingDebug, - MergeDebug, - PairCandidate, - PreviousPoseFilterDebug, - PreviousPoseMatch, - ProposalGroupDebug, - TriangulationConfig, - TriangulationOptions, - TriangulationResult, - TriangulationTrace, + AssociationReport as AssociationReport, + AssociationStatus as AssociationStatus, + Camera as Camera, + CameraModel as CameraModel, + CoreProposalDebug as CoreProposalDebug, + FinalPoseAssociationDebug as FinalPoseAssociationDebug, + FullProposalDebug as FullProposalDebug, + GroupingDebug as GroupingDebug, + MergeDebug as MergeDebug, + PairCandidate as PairCandidate, + PreviousPoseFilterDebug as PreviousPoseFilterDebug, + PreviousPoseMatch as PreviousPoseMatch, + ProposalGroupDebug as ProposalGroupDebug, + TriangulationConfig as TriangulationConfig, + TriangulationOptions as TriangulationOptions, + TriangulationResult as TriangulationResult, + TriangulationTrace as TriangulationTrace, +) +from ._helpers import ( + CameraLike, + CameraModelLike, + DepthImageLike, + Matrix3x3Like, + PoseViewLike, + RoomParamsLike, + TranslationVectorLike, + VectorLike, ) -from ._helpers import CameraLike, CameraModelLike, DepthImageLike, Matrix3x3Like, PoseViewLike, RoomParamsLike, VectorLike PoseArray2D: TypeAlias = npt.NDArray[np.float32] PoseArray3D: TypeAlias = npt.NDArray[np.float32] @@ -40,7 +49,7 @@ def make_camera( K: Matrix3x3Like, DC: VectorLike, R: Matrix3x3Like, - T: Sequence[Sequence[float]], + T: TranslationVectorLike, width: int, height: int, model: CameraModel | CameraModelLike, @@ -155,4 +164,36 @@ def triangulate_with_report( ) -> TriangulationResult: ... -__all__: list[str] +__all__ = [ + "Camera", + "CameraModel", + "AssociationReport", + "AssociationStatus", + "apply_depth_offsets", + "FinalPoseAssociationDebug", + "TriangulationConfig", + "TriangulationOptions", + "TriangulationResult", + "CoreProposalDebug", + "FullProposalDebug", + "GroupingDebug", + "MergeDebug", + "PairCandidate", + "PreviousPoseFilterDebug", + "PreviousPoseMatch", + "ProposalGroupDebug", + "TriangulationTrace", + "build_pair_candidates", + "convert_cameras", + "filter_pairs_with_previous_poses", + "lift_depth_poses_to_world", + "make_camera", + "make_triangulation_config", + "merge_rgbd_views", + "pack_poses_2d", + "reconstruct_rgbd", + "sample_depth_for_poses", + "triangulate_debug", + "triangulate_poses", + "triangulate_with_report", +] diff --git a/src/rpt/_core.pyi b/src/rpt/_core.pyi new file mode 100644 index 0000000..4d9f94a --- /dev/null +++ b/src/rpt/_core.pyi @@ -0,0 +1,530 @@ +from collections.abc import Sequence +import enum +from typing import Annotated, overload + +import numpy +from numpy.typing import NDArray + + +class CameraModel(enum.Enum): + PINHOLE = 0 + + FISHEYE = 1 + +class Camera: + """Immutable camera calibration with precomputed projection cache fields.""" + + @property + def name(self) -> str: ... + + @property + def K(self) -> list[list[float]]: ... + + @property + def DC(self) -> list[float]: ... + + @property + def R(self) -> list[list[float]]: ... + + @property + def T(self) -> list[list[float]]: ... + + @property + def width(self) -> int: ... + + @property + def height(self) -> int: ... + + @property + def model(self) -> CameraModel: ... + + @property + def invR(self) -> list[list[float]]: ... + + @property + def center(self) -> list[float]: ... + + @property + def newK(self) -> list[list[float]]: ... + + @property + def invK(self) -> list[list[float]]: ... + + def __repr__(self) -> str: ... + +class TriangulationOptions: + """Score and grouping thresholds used by triangulation.""" + + def __init__(self) -> None: ... + + @property + def min_match_score(self) -> float: ... + + @min_match_score.setter + def min_match_score(self, arg: float, /) -> None: ... + + @property + def min_group_size(self) -> int: ... + + @min_group_size.setter + def min_group_size(self, arg: int, /) -> None: ... + +class TriangulationConfig: + """Stable scene configuration used for triangulation.""" + + def __init__(self) -> None: ... + + @property + def cameras(self) -> list[Camera]: ... + + @cameras.setter + def cameras(self, arg: Sequence[Camera], /) -> None: ... + + @property + def roomparams(self) -> list[list[float]]: ... + + @roomparams.setter + def roomparams(self, arg: Sequence[Sequence[float]], /) -> None: ... + + @property + def joint_names(self) -> list[str]: ... + + @joint_names.setter + def joint_names(self, arg: Sequence[str], /) -> None: ... + + @property + def options(self) -> TriangulationOptions: ... + + @options.setter + def options(self, arg: TriangulationOptions, /) -> None: ... + +class PairCandidate: + def __init__(self) -> None: ... + + @property + def view1(self) -> int: ... + + @view1.setter + def view1(self, arg: int, /) -> None: ... + + @property + def view2(self) -> int: ... + + @view2.setter + def view2(self, arg: int, /) -> None: ... + + @property + def person1(self) -> int: ... + + @person1.setter + def person1(self, arg: int, /) -> None: ... + + @property + def person2(self) -> int: ... + + @person2.setter + def person2(self, arg: int, /) -> None: ... + + @property + def global_person1(self) -> int: ... + + @global_person1.setter + def global_person1(self, arg: int, /) -> None: ... + + @property + def global_person2(self) -> int: ... + + @global_person2.setter + def global_person2(self, arg: int, /) -> None: ... + +class PreviousPoseMatch: + def __init__(self) -> None: ... + + @property + def previous_pose_index(self) -> int: ... + + @previous_pose_index.setter + def previous_pose_index(self, arg: int, /) -> None: ... + + @property + def previous_track_id(self) -> int: ... + + @previous_track_id.setter + def previous_track_id(self, arg: int, /) -> None: ... + + @property + def score_view1(self) -> float: ... + + @score_view1.setter + def score_view1(self, arg: float, /) -> None: ... + + @property + def score_view2(self) -> float: ... + + @score_view2.setter + def score_view2(self, arg: float, /) -> None: ... + + @property + def matched_view1(self) -> bool: ... + + @matched_view1.setter + def matched_view1(self, arg: bool, /) -> None: ... + + @property + def matched_view2(self) -> bool: ... + + @matched_view2.setter + def matched_view2(self, arg: bool, /) -> None: ... + + @property + def kept(self) -> bool: ... + + @kept.setter + def kept(self, arg: bool, /) -> None: ... + + @property + def decision(self) -> str: ... + + @decision.setter + def decision(self, arg: str, /) -> None: ... + +class PreviousPoseFilterDebug: + def __init__(self) -> None: ... + + @property + def used_previous_poses(self) -> bool: ... + + @used_previous_poses.setter + def used_previous_poses(self, arg: bool, /) -> None: ... + + @property + def matches(self) -> list[PreviousPoseMatch]: ... + + @matches.setter + def matches(self, arg: Sequence[PreviousPoseMatch], /) -> None: ... + + @property + def kept_pair_indices(self) -> list[int]: ... + + @kept_pair_indices.setter + def kept_pair_indices(self, arg: Sequence[int], /) -> None: ... + + @property + def kept_pairs(self) -> list[PairCandidate]: ... + + @kept_pairs.setter + def kept_pairs(self, arg: Sequence[PairCandidate], /) -> None: ... + +class CoreProposalDebug: + def __init__(self) -> None: ... + + @property + def pair_index(self) -> int: ... + + @pair_index.setter + def pair_index(self, arg: int, /) -> None: ... + + @property + def pair(self) -> PairCandidate: ... + + @pair.setter + def pair(self, arg: PairCandidate, /) -> None: ... + + @property + def score(self) -> float: ... + + @score.setter + def score(self, arg: float, /) -> None: ... + + @property + def kept(self) -> bool: ... + + @kept.setter + def kept(self, arg: bool, /) -> None: ... + + @property + def drop_reason(self) -> str: ... + + @drop_reason.setter + def drop_reason(self, arg: str, /) -> None: ... + + @property + def pose_3d(self) -> Annotated[NDArray[numpy.float32], dict(shape=(None, 4), order='C')]: ... + +class ProposalGroupDebug: + def __init__(self) -> None: ... + + @property + def center(self) -> list[float]: ... + + @center.setter + def center(self, arg: Sequence[float], /) -> None: ... + + @property + def proposal_indices(self) -> list[int]: ... + + @proposal_indices.setter + def proposal_indices(self, arg: Sequence[int], /) -> None: ... + + @property + def pose_3d(self) -> Annotated[NDArray[numpy.float32], dict(shape=(None, 4), order='C')]: ... + +class GroupingDebug: + def __init__(self) -> None: ... + + @property + def initial_groups(self) -> list[ProposalGroupDebug]: ... + + @initial_groups.setter + def initial_groups(self, arg: Sequence[ProposalGroupDebug], /) -> None: ... + + @property + def duplicate_pair_drops(self) -> list[int]: ... + + @duplicate_pair_drops.setter + def duplicate_pair_drops(self, arg: Sequence[int], /) -> None: ... + + @property + def groups(self) -> list[ProposalGroupDebug]: ... + + @groups.setter + def groups(self, arg: Sequence[ProposalGroupDebug], /) -> None: ... + +class FullProposalDebug: + def __init__(self) -> None: ... + + @property + def source_core_proposal_index(self) -> int: ... + + @source_core_proposal_index.setter + def source_core_proposal_index(self, arg: int, /) -> None: ... + + @property + def pair(self) -> PairCandidate: ... + + @pair.setter + def pair(self, arg: PairCandidate, /) -> None: ... + + @property + def pose_3d(self) -> Annotated[NDArray[numpy.float32], dict(shape=(None, 4), order='C')]: ... + +class MergeDebug: + def __init__(self) -> None: ... + + @property + def group_proposal_indices(self) -> list[list[int]]: ... + + @group_proposal_indices.setter + def group_proposal_indices(self, arg: Sequence[Sequence[int]], /) -> None: ... + + @property + def merged_poses(self) -> Annotated[NDArray[numpy.float32], dict(shape=(None, None, 4), order='C')]: ... + +class AssociationStatus(enum.Enum): + MATCHED = 0 + + NEW = 1 + + AMBIGUOUS = 2 + +class AssociationReport: + """Track-association summary for a tracked triangulation call.""" + + def __init__(self) -> None: ... + + @property + def pose_previous_indices(self) -> list[int]: ... + + @pose_previous_indices.setter + def pose_previous_indices(self, arg: Sequence[int], /) -> None: ... + + @property + def pose_previous_track_ids(self) -> list[int]: ... + + @pose_previous_track_ids.setter + def pose_previous_track_ids(self, arg: Sequence[int], /) -> None: ... + + @property + def pose_status(self) -> list[AssociationStatus]: ... + + @pose_status.setter + def pose_status(self, arg: Sequence[AssociationStatus], /) -> None: ... + + @property + def pose_candidate_previous_indices(self) -> list[list[int]]: ... + + @pose_candidate_previous_indices.setter + def pose_candidate_previous_indices(self, arg: Sequence[Sequence[int]], /) -> None: ... + + @property + def pose_candidate_previous_track_ids(self) -> list[list[int]]: ... + + @pose_candidate_previous_track_ids.setter + def pose_candidate_previous_track_ids(self, arg: Sequence[Sequence[int]], /) -> None: ... + + @property + def unmatched_previous_indices(self) -> list[int]: ... + + @unmatched_previous_indices.setter + def unmatched_previous_indices(self, arg: Sequence[int], /) -> None: ... + + @property + def unmatched_previous_track_ids(self) -> list[int]: ... + + @unmatched_previous_track_ids.setter + def unmatched_previous_track_ids(self, arg: Sequence[int], /) -> None: ... + + @property + def new_pose_indices(self) -> list[int]: ... + + @new_pose_indices.setter + def new_pose_indices(self, arg: Sequence[int], /) -> None: ... + + @property + def ambiguous_pose_indices(self) -> list[int]: ... + + @ambiguous_pose_indices.setter + def ambiguous_pose_indices(self, arg: Sequence[int], /) -> None: ... + +class FinalPoseAssociationDebug: + def __init__(self) -> None: ... + + @property + def final_pose_index(self) -> int: ... + + @final_pose_index.setter + def final_pose_index(self, arg: int, /) -> None: ... + + @property + def source_core_proposal_indices(self) -> list[int]: ... + + @source_core_proposal_indices.setter + def source_core_proposal_indices(self, arg: Sequence[int], /) -> None: ... + + @property + def source_pair_indices(self) -> list[int]: ... + + @source_pair_indices.setter + def source_pair_indices(self, arg: Sequence[int], /) -> None: ... + + @property + def candidate_previous_indices(self) -> list[int]: ... + + @candidate_previous_indices.setter + def candidate_previous_indices(self, arg: Sequence[int], /) -> None: ... + + @property + def candidate_previous_track_ids(self) -> list[int]: ... + + @candidate_previous_track_ids.setter + def candidate_previous_track_ids(self, arg: Sequence[int], /) -> None: ... + + @property + def resolved_previous_index(self) -> int: ... + + @resolved_previous_index.setter + def resolved_previous_index(self, arg: int, /) -> None: ... + + @property + def resolved_previous_track_id(self) -> int: ... + + @resolved_previous_track_id.setter + def resolved_previous_track_id(self, arg: int, /) -> None: ... + + @property + def status(self) -> AssociationStatus: ... + + @status.setter + def status(self, arg: AssociationStatus, /) -> None: ... + +class TriangulationTrace: + """ + Full debug trace for triangulation, including pair, grouping, and association stages. + """ + + def __init__(self) -> None: ... + + @property + def pairs(self) -> list[PairCandidate]: ... + + @pairs.setter + def pairs(self, arg: Sequence[PairCandidate], /) -> None: ... + + @property + def previous_filter(self) -> PreviousPoseFilterDebug: ... + + @previous_filter.setter + def previous_filter(self, arg: PreviousPoseFilterDebug, /) -> None: ... + + @property + def core_proposals(self) -> list[CoreProposalDebug]: ... + + @core_proposals.setter + def core_proposals(self, arg: Sequence[CoreProposalDebug], /) -> None: ... + + @property + def grouping(self) -> GroupingDebug: ... + + @grouping.setter + def grouping(self, arg: GroupingDebug, /) -> None: ... + + @property + def full_proposals(self) -> list[FullProposalDebug]: ... + + @full_proposals.setter + def full_proposals(self, arg: Sequence[FullProposalDebug], /) -> None: ... + + @property + def merge(self) -> MergeDebug: ... + + @merge.setter + def merge(self, arg: MergeDebug, /) -> None: ... + + @property + def association(self) -> AssociationReport: ... + + @association.setter + def association(self, arg: AssociationReport, /) -> None: ... + + @property + def final_pose_associations(self) -> list[FinalPoseAssociationDebug]: ... + + @final_pose_associations.setter + def final_pose_associations(self, arg: Sequence[FinalPoseAssociationDebug], /) -> None: ... + + @property + def final_poses(self) -> Annotated[NDArray[numpy.float32], dict(shape=(None, None, 4), order='C')]: ... + +class TriangulationResult: + """ + Tracked triangulation output containing poses and association metadata. + """ + + def __init__(self) -> None: ... + + @property + def association(self) -> AssociationReport: ... + + @association.setter + def association(self, arg: AssociationReport, /) -> None: ... + + @property + def poses_3d(self) -> Annotated[NDArray[numpy.float32], dict(shape=(None, None, 4), order='C')]: ... + +def make_camera(name: str, K: Sequence[Sequence[float]], DC: Sequence[float], R: Sequence[Sequence[float]], T: Sequence[Sequence[float]], width: int, height: int, model: CameraModel) -> Camera: ... + +def build_pair_candidates(poses_2d: Annotated[NDArray[numpy.float32], dict(shape=(None, None, None, 3), order='C', writable=False)], person_counts: Annotated[NDArray[numpy.uint32], dict(shape=(None,), order='C', writable=False)]) -> list[PairCandidate]: ... + +def filter_pairs_with_previous_poses(poses_2d: Annotated[NDArray[numpy.float32], dict(shape=(None, None, None, 3), order='C', writable=False)], person_counts: Annotated[NDArray[numpy.uint32], dict(shape=(None,), order='C', writable=False)], config: TriangulationConfig, previous_poses_3d: Annotated[NDArray[numpy.float32], dict(shape=(None, None, 4), order='C', writable=False)], previous_track_ids: Annotated[NDArray[numpy.int64], dict(shape=(None,), order='C', writable=False)]) -> PreviousPoseFilterDebug: ... + +@overload +def triangulate_debug(poses_2d: Annotated[NDArray[numpy.float32], dict(shape=(None, None, None, 3), order='C', writable=False)], person_counts: Annotated[NDArray[numpy.uint32], dict(shape=(None,), order='C', writable=False)], config: TriangulationConfig) -> TriangulationTrace: ... + +@overload +def triangulate_debug(poses_2d: Annotated[NDArray[numpy.float32], dict(shape=(None, None, None, 3), order='C', writable=False)], person_counts: Annotated[NDArray[numpy.uint32], dict(shape=(None,), order='C', writable=False)], config: TriangulationConfig, previous_poses_3d: Annotated[NDArray[numpy.float32], dict(shape=(None, None, 4), order='C', writable=False)], previous_track_ids: Annotated[NDArray[numpy.int64], dict(shape=(None,), order='C', writable=False)]) -> TriangulationTrace: ... + +def triangulate_poses(poses_2d: Annotated[NDArray[numpy.float32], dict(shape=(None, None, None, 3), order='C', writable=False)], person_counts: Annotated[NDArray[numpy.uint32], dict(shape=(None,), order='C', writable=False)], config: TriangulationConfig) -> Annotated[NDArray[numpy.float32], dict(shape=(None, None, 4), order='C')]: ... + +def merge_rgbd_views(poses_3d: Annotated[NDArray[numpy.float32], dict(shape=(None, None, None, 4), order='C', writable=False)], person_counts: Annotated[NDArray[numpy.uint32], dict(shape=(None,), order='C', writable=False)], config: TriangulationConfig, max_distance: float = 0.5) -> Annotated[NDArray[numpy.float32], dict(shape=(None, None, 4), order='C')]: ... + +def triangulate_with_report(poses_2d: Annotated[NDArray[numpy.float32], dict(shape=(None, None, None, 3), order='C', writable=False)], person_counts: Annotated[NDArray[numpy.uint32], dict(shape=(None,), order='C', writable=False)], config: TriangulationConfig, previous_poses_3d: Annotated[NDArray[numpy.float32], dict(shape=(None, None, 4), order='C', writable=False)], previous_track_ids: Annotated[NDArray[numpy.int64], dict(shape=(None,), order='C', writable=False)]) -> TriangulationResult: ... diff --git a/src/rpt/_helpers.py b/src/rpt/_helpers.py index f170d93..c5ad58b 100644 --- a/src/rpt/_helpers.py +++ b/src/rpt/_helpers.py @@ -1,34 +1,44 @@ -from __future__ import annotations - from collections.abc import Sequence from typing import Literal, TypeAlias, TypedDict +from jaxtyping import Float import numpy as np import numpy.typing as npt -from ._core import Camera, CameraModel, TriangulationConfig, TriangulationOptions, make_camera +from ._core import Camera, CameraModel, TriangulationConfig, TriangulationOptions, make_camera as _make_camera -Matrix3x3Like: TypeAlias = Sequence[Sequence[float]] -VectorLike: TypeAlias = Sequence[float] +Matrix3x3: TypeAlias = Float[np.ndarray, "3 3"] +DistortionVector: TypeAlias = Float[np.ndarray, "coeffs"] +TranslationVector: TypeAlias = Float[np.ndarray, "3"] +TranslationColumn: TypeAlias = Float[np.ndarray, "3 1"] +TranslationRow: TypeAlias = Float[np.ndarray, "1 3"] +Matrix3x3Like: TypeAlias = Matrix3x3 | Sequence[Sequence[float]] +VectorLike: TypeAlias = DistortionVector | Sequence[float] +TranslationVectorLike: TypeAlias = ( + TranslationVector | TranslationColumn | TranslationRow | Sequence[float] | Sequence[Sequence[float]] +) RoomParamsLike: TypeAlias = npt.NDArray[np.generic] | Sequence[Sequence[float]] PoseViewLike: TypeAlias = npt.NDArray[np.generic] | Sequence[Sequence[Sequence[float]]] | Sequence[Sequence[float]] DepthImageLike: TypeAlias = npt.NDArray[np.generic] | Sequence[Sequence[float]] -class CameraDict(TypedDict, total=False): +class _CameraDictRequired(TypedDict): name: str K: Matrix3x3Like DC: VectorLike R: Matrix3x3Like - T: Sequence[Sequence[float]] + T: TranslationVectorLike width: int height: int + + +class CameraDict(_CameraDictRequired, total=False): type: Literal["pinhole", "fisheye"] model: Literal["pinhole", "fisheye"] | CameraModel CameraModelLike: TypeAlias = CameraModel | Literal["pinhole", "fisheye"] -CameraLike = Camera | CameraDict +CameraLike: TypeAlias = Camera | CameraDict DEFAULT_DEPTH_OFFSETS_METERS: dict[str, float] = { "nose": 0.005, @@ -79,6 +89,24 @@ def _coerce_distortion(distortion: VectorLike, camera_model: CameraModel) -> tup return values +def _coerce_matrix3x3(matrix: object, field_name: str) -> Matrix3x3: + array = np.asarray(matrix, dtype=np.float32) + if array.shape != (3, 3): + raise ValueError(f"{field_name} must have shape [3, 3].") + return np.ascontiguousarray(array, dtype=np.float32) + + +def _coerce_translation(translation: object) -> TranslationColumn: + array = np.asarray(translation, dtype=np.float32) + if array.shape == (3,): + array = array[:, np.newaxis] + elif array.shape == (1, 3): + array = array.T + if array.shape != (3, 1): + raise ValueError("T must have shape [3], [1, 3], or [3, 1].") + return np.ascontiguousarray(array, dtype=np.float32) + + def _coerce_depth_image(depth_image: DepthImageLike) -> npt.NDArray[np.float32]: array = np.asarray(depth_image, dtype=np.float32) if array.ndim == 3 and array.shape[-1] == 1: @@ -99,12 +127,12 @@ def convert_cameras(cameras: Sequence[CameraLike]) -> list[Camera]: camera_model = _coerce_camera_model(cam.get("model", cam.get("type", "pinhole"))) converted.append( - make_camera( + _make_camera( str(cam["name"]), - cam["K"], + _coerce_matrix3x3(cam["K"], "K").tolist(), _coerce_distortion(cam["DC"], camera_model), - cam["R"], - cam["T"], + _coerce_matrix3x3(cam["R"], "R").tolist(), + _coerce_translation(cam["T"]).tolist(), int(cam["width"]), int(cam["height"]), camera_model, diff --git a/tests/test_interface.py b/tests/test_interface.py index 393be20..26db164 100644 --- a/tests/test_interface.py +++ b/tests/test_interface.py @@ -56,7 +56,7 @@ def test_camera_structure_repr(): [[1, 0, 0], [0, 1, 0], [0, 0, 1]], [0, 0, 0, 0, 0], [[1, 0, 0], [0, 1, 0], [0, 0, 1]], - [[1], [2], [3]], + [1, 2, 3], 640, 480, rpt.CameraModel.PINHOLE, @@ -65,6 +65,7 @@ def test_camera_structure_repr(): rendered = repr(camera) assert "Camera 1" in rendered assert "pinhole" in rendered + np.testing.assert_allclose(np.asarray(camera.T, dtype=np.float32).reshape(3), [1.0, 2.0, 3.0]) @pytest.mark.parametrize( diff --git a/tests/test_typing_artifacts.py b/tests/test_typing_artifacts.py new file mode 100644 index 0000000..4a76b60 --- /dev/null +++ b/tests/test_typing_artifacts.py @@ -0,0 +1,18 @@ +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] + + +def test_checked_in_core_stub_exists(): + assert (ROOT / "src" / "rpt" / "_core.pyi").exists() + + +def test_checked_in_core_stub_matches_generated_stub(): + generated_stub = ROOT / "build" / "bindings" / "rpt" / "_core.pyi" + if not generated_stub.exists(): + pytest.skip("Build-generated nanobind stub is unavailable.") + + checked_in_stub = ROOT / "src" / "rpt" / "_core.pyi" + assert checked_in_stub.read_text(encoding="utf-8") == generated_stub.read_text(encoding="utf-8") diff --git a/uv.lock b/uv.lock index f92b472..c8d23cd 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,18 @@ resolution-markers = [ "python_full_version < '3.11'", ] +[[package]] +name = "basedpyright" +version = "1.38.4" +source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/" } +dependencies = [ + { name = "nodejs-wheel-binaries" }, +] +sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/08/b4/26cb812eaf8ab56909c792c005fe1690706aef6f21d61107639e46e9c54c/basedpyright-1.38.4.tar.gz", hash = "sha256:8e7d4f37ffb6106621e06b9355025009cdf5b48f71c592432dd2dd304bf55e70", size = 25354730, upload-time = "2026-03-25T13:50:44.353Z" } +wheels = [ + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/62/0b/3f95fd47def42479e61077523d3752086d5c12009192a7f1c9fd5507e687/basedpyright-1.38.4-py3-none-any.whl", hash = "sha256:90aa067cf3e8a3c17ad5836a72b9e1f046bc72a4ad57d928473d9368c9cd07a2", size = 12352258, upload-time = "2026-03-25T13:50:41.059Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -36,6 +48,52 @@ wheels = [ { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jaxtyping" +version = "0.3.7" +source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "wadler-lindig", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/38/40/a2ea3ce0e3e5f540eb970de7792c90fa58fef1b27d34c83f9fa94fea4729/jaxtyping-0.3.7.tar.gz", hash = "sha256:3bd7d9beb7d3cb01a89f93f90581c6f4fff3e5c5dc3c9307e8f8687a040d10c4", size = 45721, upload-time = "2026-01-30T14:18:47.409Z" } +wheels = [ + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/78/42/caf65e9a0576a3abadc537e2f831701ba9081f21317fb3be87d64451587a/jaxtyping-0.3.7-py3-none-any.whl", hash = "sha256:303ab8599edf412eeb40bf06c863e3168fa186cf0e7334703fa741ddd7046e66", size = 56101, upload-time = "2026-01-30T14:18:45.954Z" }, +] + +[[package]] +name = "jaxtyping" +version = "0.3.9" +source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/" } +resolution-markers = [ + "python_full_version >= '3.11'", +] +dependencies = [ + { name = "wadler-lindig", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/c2/be/00294e369938937e31b094437d5ea040e4fd1a20b998ebe572c4a1dcfa68/jaxtyping-0.3.9.tar.gz", hash = "sha256:f8c02d1b623d5f1b6665d4f3ddaec675d70004f16a792102c2fc51264190951d", size = 45857, upload-time = "2026-02-16T10:35:13.263Z" } +wheels = [ + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/94/05/3e39d416fb92b2738a76e8265e6bfc5d10542f90a7c32ad1eb831eea3fa3/jaxtyping-0.3.9-py3-none-any.whl", hash = "sha256:a00557a9d616eff157491f06ed2e21ed94886fad3832399273eb912b345da378", size = 56274, upload-time = "2026-02-16T10:35:11.795Z" }, +] + +[[package]] +name = "nodejs-wheel-binaries" +version = "24.14.0" +source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/" } +sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/71/05/c75c0940b1ebf82975d14f37176679b6f3229eae8b47b6a70d1e1dae0723/nodejs_wheel_binaries-24.14.0.tar.gz", hash = "sha256:c87b515e44b0e4a523017d8c59f26ccbd05b54fe593338582825d4b51fc91e1c", size = 8057, upload-time = "2026-02-27T02:57:30.931Z" } +wheels = [ + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/58/8c/b057c2db3551a6fe04e93dd14e33d810ac8907891534ffcc7a051b253858/nodejs_wheel_binaries-24.14.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:59bb78b8eb08c3e32186da1ef913f1c806b5473d8bd0bb4492702092747b674a", size = 54798488, upload-time = "2026-02-27T02:56:56.831Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/30/88/7e1b29c067b6625c97c81eb8b0ef37cf5ad5b62bb81e23f4bde804910ec9/nodejs_wheel_binaries-24.14.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:348fa061b57625de7250d608e2d9b7c4bc170544da7e328325343860eadd59e5", size = 54972803, upload-time = "2026-02-27T02:57:01.696Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/1e/e0/a83f0ff12faca2a56366462e572e38ac6f5cb361877bb29e289138eb7f24/nodejs_wheel_binaries-24.14.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:222dbf516ccc877afcad4e4789a81b4ee93daaa9f0ad97c464417d9597f49449", size = 59340859, upload-time = "2026-02-27T02:57:06.125Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/e2/9f/06fad4ae8a723ae7096b5311eba67ad8b4df5f359c0a68e366750b7fef78/nodejs_wheel_binaries-24.14.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:b35d6fcccfe4fb0a409392d237fbc67796bac0d357b996bc12d057a1531a238b", size = 59838751, upload-time = "2026-02-27T02:57:10.449Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/8c/72/4916dadc7307c3e9bcfa43b4b6f88237932d502c66f89eb2d90fb07810db/nodejs_wheel_binaries-24.14.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:519507fb74f3f2b296ab1e9f00dcc211f36bbfb93c60229e72dcdee9dafd301a", size = 61340534, upload-time = "2026-02-27T02:57:15.309Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/2e/df/a8ba881ee5d04b04e0d93abc8ce501ff7292813583e97f9789eb3fc0472a/nodejs_wheel_binaries-24.14.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:68c93c52ff06d704bcb5ed160b4ba04ab1b291d238aaf996b03a5396e0e9a7ed", size = 61922394, upload-time = "2026-02-27T02:57:20.24Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/60/8c/b8c5f61201c72a0c7dc694b459941f89a6defda85deff258a9940a4e2efc/nodejs_wheel_binaries-24.14.0-py2.py3-none-win_amd64.whl", hash = "sha256:60b83c4e98b0c7d836ac9ccb67dcb36e343691cbe62cd325799ff9ed936286f3", size = 41218783, upload-time = "2026-02-27T02:57:24.175Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/91/23/1f904bc9cbd8eece393e20840c08ba3ac03440090c3a4e95168fa6d2709f/nodejs_wheel_binaries-24.14.0-py2.py3-none-win_arm64.whl", hash = "sha256:78a9bd1d6b11baf1433f9fb84962ff8aa71c87d48b6434f98224bc49a2253a6e", size = 38926103, upload-time = "2026-02-27T02:57:27.458Z" }, +] + [[package]] name = "numpy" version = "2.2.6" @@ -233,20 +291,29 @@ name = "rapid-pose-triangulation" version = "0.2.0" source = { editable = "." } dependencies = [ + { name = "jaxtyping", version = "0.3.7", source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/" }, marker = "python_full_version < '3.11'" }, + { name = "jaxtyping", version = "0.3.9", source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/" }, marker = "python_full_version >= '3.11'" }, { name = "numpy", version = "2.2.6", source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.4.3", source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/" }, marker = "python_full_version >= '3.11'" }, ] [package.dev-dependencies] dev = [ + { name = "basedpyright" }, { name = "pytest" }, ] [package.metadata] -requires-dist = [{ name = "numpy", specifier = ">=2.0" }] +requires-dist = [ + { name = "jaxtyping" }, + { name = "numpy", specifier = ">=2.0" }, +] [package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=8.3" }] +dev = [ + { name = "basedpyright", specifier = ">=1.38.3" }, + { name = "pytest", specifier = ">=8.3" }, +] [[package]] name = "tomli" @@ -310,3 +377,12 @@ sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/72/94/1a wheels = [ { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] + +[[package]] +name = "wadler-lindig" +version = "0.1.7" +source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/" } +sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/1e/67/cbae4bf7683a64755c2c1778c418fea96d00e34395bb91743f08bd951571/wadler_lindig-0.1.7.tar.gz", hash = "sha256:81d14d3fe77d441acf3ebd7f4aefac20c74128bf460e84b512806dccf7b2cd55", size = 15842, upload-time = "2025-06-18T07:00:42.843Z" } +wheels = [ + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/8d/96/04e7b441807b26b794da5b11e59ed7f83b2cf8af202bd7eba8ad2fa6046e/wadler_lindig-0.1.7-py3-none-any.whl", hash = "sha256:e3ec83835570fd0a9509f969162aeb9c65618f998b1f42918cfc8d45122fe953", size = 20516, upload-time = "2025-06-18T07:00:41.684Z" }, +]