placeframe-core 0.1.0.dev35547537192__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.
- placeframe_core/axis_convention.py +48 -0
- placeframe_core/calibration.py +158 -0
- placeframe_core/camera_config.py +20 -0
- placeframe_core/capture_session_manifest.py +26 -0
- placeframe_core/h5.py +62 -0
- placeframe_core/image_preprocess.py +88 -0
- placeframe_core/lightglue.py +90 -0
- placeframe_core/localization_metrics.py +25 -0
- placeframe_core/model_wrappers.py +69 -0
- placeframe_core/numpy_ops.py +33 -0
- placeframe_core/opq.py +83 -0
- placeframe_core/py.typed +0 -0
- placeframe_core/reconstruction_manifest.py +14 -0
- placeframe_core/reconstruction_metrics.py +86 -0
- placeframe_core/reconstruction_options.py +80 -0
- placeframe_core/tensor_types.py +17 -0
- placeframe_core/transform.py +21 -0
- placeframe_core-0.1.0.dev35547537192.dist-info/METADATA +19 -0
- placeframe_core-0.1.0.dev35547537192.dist-info/RECORD +20 -0
- placeframe_core-0.1.0.dev35547537192.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
from numpy import array, float64
|
|
4
|
+
from numpy.typing import NDArray # noqa: TID251 — tracked in PLE-233
|
|
5
|
+
from scipy.spatial.transform import Rotation
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AxisConvention(Enum):
|
|
9
|
+
OPENCV = "OPENCV"
|
|
10
|
+
UNITY = "UNITY"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
basis_unity = array([[1, 0, 0], [0, 1, 0], [0, 0, 1]])
|
|
14
|
+
basis_opencv = array([[1, 0, 0], [0, -1, 0], [0, 0, 1]])
|
|
15
|
+
basic_change_unity_from_opencv = basis_unity.T @ basis_opencv
|
|
16
|
+
basis_change_opencv_from_unity = basic_change_unity_from_opencv.T
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def change_basis_opencv_from_unity_pose(
|
|
20
|
+
translation: NDArray[float64], rotation: NDArray[float64]
|
|
21
|
+
) -> tuple[NDArray[float64], NDArray[float64]]:
|
|
22
|
+
new_translation = basis_change_opencv_from_unity @ translation
|
|
23
|
+
new_rotation = basis_change_opencv_from_unity @ rotation @ basic_change_unity_from_opencv
|
|
24
|
+
return new_translation, new_rotation
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def change_basis_unity_from_opencv_pose(
|
|
28
|
+
translation: NDArray[float64], rotation: NDArray[float64]
|
|
29
|
+
) -> tuple[NDArray[float64], NDArray[float64]]:
|
|
30
|
+
new_translation = basic_change_unity_from_opencv @ translation
|
|
31
|
+
new_rotation = basic_change_unity_from_opencv @ rotation @ basis_change_opencv_from_unity
|
|
32
|
+
return new_translation, new_rotation
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def change_basis_unity_from_opencv_points(points: NDArray[float64]) -> NDArray[float64]:
|
|
36
|
+
return (basic_change_unity_from_opencv @ points.T).T
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def change_basis_unity_from_opencv_poses(
|
|
40
|
+
translations: NDArray[float64], orientations_xyzw: NDArray[float64]
|
|
41
|
+
) -> tuple[NDArray[float64], NDArray[float64]]:
|
|
42
|
+
new_translations = change_basis_unity_from_opencv_points(translations)
|
|
43
|
+
|
|
44
|
+
rotation_matrices = Rotation.from_quat(orientations_xyzw).as_matrix()
|
|
45
|
+
new_rotation_matrices = basic_change_unity_from_opencv @ rotation_matrices @ basis_change_opencv_from_unity
|
|
46
|
+
new_orientations_xyzw = Rotation.from_matrix(new_rotation_matrices).as_quat()
|
|
47
|
+
|
|
48
|
+
return new_translations, new_orientations_xyzw
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from numpy import interp, log1p
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
SCHEMA_VERSION = 2
|
|
12
|
+
|
|
13
|
+
# Sentinel `pipeline_version` value that bypasses the pipeline-version check in
|
|
14
|
+
# `load_global_calibration`. Use only with placeholder calibrations whose values
|
|
15
|
+
# don't depend on the inference pipeline (zeroed weights, fixed sigma_meas
|
|
16
|
+
# constants). A real calibration fit against a real corpus must pin to the
|
|
17
|
+
# localizer image's CONTEXT_SHA so any pipeline change forces a paired refit.
|
|
18
|
+
PLACEHOLDER_PIPELINE_VERSION = "placeholder"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CalibrationLoadError(RuntimeError):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RawLocalizationMetrics(BaseModel):
|
|
26
|
+
num_inliers: int
|
|
27
|
+
inlier_ratio: float
|
|
28
|
+
reproj_error_median: float
|
|
29
|
+
inlier_coverage: float
|
|
30
|
+
num_matches: int
|
|
31
|
+
query_image_diagonal_px: float
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RawMapMetrics(BaseModel):
|
|
35
|
+
map_image_count: int
|
|
36
|
+
map_point_count: int
|
|
37
|
+
map_avg_track_length: float
|
|
38
|
+
map_viewpoint_diversity: float
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Features(BaseModel):
|
|
42
|
+
log_inliers: float
|
|
43
|
+
inlier_ratio: float
|
|
44
|
+
reproj_err_norm: float
|
|
45
|
+
inlier_coverage: float
|
|
46
|
+
log_num_matches: float
|
|
47
|
+
log_map_image_count: float
|
|
48
|
+
log_map_point_count: float
|
|
49
|
+
map_avg_track_length: float
|
|
50
|
+
map_viewpoint_diversity: float
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def zeros(cls) -> Features:
|
|
54
|
+
return cls(**dict.fromkeys(cls.model_fields, 0.0))
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def compute(cls, *, localization: RawLocalizationMetrics, map_metrics: RawMapMetrics) -> Features:
|
|
58
|
+
return cls(
|
|
59
|
+
log_inliers=float(log1p(localization.num_inliers)),
|
|
60
|
+
inlier_ratio=localization.inlier_ratio,
|
|
61
|
+
reproj_err_norm=localization.reproj_error_median / localization.query_image_diagonal_px,
|
|
62
|
+
inlier_coverage=localization.inlier_coverage,
|
|
63
|
+
log_num_matches=float(log1p(localization.num_matches)),
|
|
64
|
+
log_map_image_count=float(log1p(map_metrics.map_image_count)),
|
|
65
|
+
log_map_point_count=float(log1p(map_metrics.map_point_count)),
|
|
66
|
+
map_avg_track_length=map_metrics.map_avg_track_length,
|
|
67
|
+
map_viewpoint_diversity=map_metrics.map_viewpoint_diversity,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ToleranceModel(BaseModel):
|
|
72
|
+
logistic_weights: Features
|
|
73
|
+
logistic_intercept: float
|
|
74
|
+
isotonic_x_breakpoints: list[float]
|
|
75
|
+
isotonic_y_breakpoints: list[float]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class CalibrationArtifact(BaseModel):
|
|
79
|
+
schema_version: int
|
|
80
|
+
pipeline_version: str
|
|
81
|
+
fit_at: str
|
|
82
|
+
fit_by: str
|
|
83
|
+
sample_count: int
|
|
84
|
+
tight: ToleranceModel
|
|
85
|
+
loose: ToleranceModel
|
|
86
|
+
sigma_meas_alpha: float
|
|
87
|
+
sigma_meas_beta: float
|
|
88
|
+
loose_min: float
|
|
89
|
+
tight_min: float
|
|
90
|
+
|
|
91
|
+
def write(self, path: Path) -> None:
|
|
92
|
+
path.write_text(self.model_dump_json(indent=2) + "\n", encoding="utf-8")
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def read(cls, path: Path) -> CalibrationArtifact:
|
|
96
|
+
return cls.model_validate_json(path.read_text(encoding="utf-8"))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def load_global_calibration(path: Path, expected_pipeline_version: str) -> CalibrationArtifact:
|
|
100
|
+
if not path.exists():
|
|
101
|
+
raise CalibrationLoadError(
|
|
102
|
+
f"Global calibration not found at {path}. "
|
|
103
|
+
f"Expected pipeline version: {expected_pipeline_version}. "
|
|
104
|
+
f"Run scripts/fit_calibration.py against this pipeline and commit "
|
|
105
|
+
f"the resulting docker/localizer/calibration/global.json."
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
calibration = CalibrationArtifact.read(path)
|
|
109
|
+
|
|
110
|
+
if calibration.schema_version != SCHEMA_VERSION:
|
|
111
|
+
raise CalibrationLoadError(
|
|
112
|
+
f"Unsupported calibration schema_version {calibration.schema_version} "
|
|
113
|
+
f"in {path}. Localizer expects schema_version {SCHEMA_VERSION}."
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
if calibration.pipeline_version == PLACEHOLDER_PIPELINE_VERSION:
|
|
117
|
+
print(
|
|
118
|
+
f"WARNING: loading placeholder calibration from {path}. "
|
|
119
|
+
f"Pipeline-version check bypassed (expected {expected_pipeline_version}). "
|
|
120
|
+
"Tight/loose confidence gates are no-ops; outputs are not trustworthy. "
|
|
121
|
+
"Refit via scripts/fit_calibration.py before relying on calibrated confidences.",
|
|
122
|
+
file=sys.stderr,
|
|
123
|
+
flush=True,
|
|
124
|
+
)
|
|
125
|
+
return calibration
|
|
126
|
+
|
|
127
|
+
if calibration.pipeline_version != expected_pipeline_version:
|
|
128
|
+
raise CalibrationLoadError(
|
|
129
|
+
"Global calibration pipeline-version mismatch.\n"
|
|
130
|
+
f" Calibration file: {path}\n"
|
|
131
|
+
f" File version: {calibration.pipeline_version}\n"
|
|
132
|
+
f" Expected version: {expected_pipeline_version}\n"
|
|
133
|
+
"Refit calibration against the new pipeline "
|
|
134
|
+
"(scripts/fit_calibration.py), commit the updated artifact, "
|
|
135
|
+
"and redeploy."
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
return calibration
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _sigmoid(x: float) -> float:
|
|
142
|
+
return 1.0 / (1.0 + math.exp(-x))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _apply_tolerance(model: ToleranceModel, features: Features) -> float:
|
|
146
|
+
weights = model.logistic_weights.model_dump()
|
|
147
|
+
feature_values = features.model_dump()
|
|
148
|
+
logit = model.logistic_intercept + sum(weights[name] * feature_values[name] for name in feature_values)
|
|
149
|
+
raw = _sigmoid(logit)
|
|
150
|
+
if not model.isotonic_x_breakpoints:
|
|
151
|
+
return raw
|
|
152
|
+
return float(interp(raw, model.isotonic_x_breakpoints, model.isotonic_y_breakpoints))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def apply_global_calibration(calibration: CalibrationArtifact, features: Features) -> tuple[float, float, bool]:
|
|
156
|
+
tight = _apply_tolerance(calibration.tight, features)
|
|
157
|
+
loose = _apply_tolerance(calibration.loose, features)
|
|
158
|
+
return tight, loose, True
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
# See "Orientation" property here: https://exiv2.org/tags-xmp-tiff.html
|
|
8
|
+
ImageOrientation = Literal[
|
|
9
|
+
"TOP_LEFT", "TOP_RIGHT", "BOTTOM_RIGHT", "BOTTOM_LEFT", "LEFT_TOP", "RIGHT_TOP", "RIGHT_BOTTOM", "LEFT_BOTTOM"
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PinholeCameraConfig(BaseModel):
|
|
14
|
+
width: int
|
|
15
|
+
height: int
|
|
16
|
+
orientation: ImageOrientation
|
|
17
|
+
fx: float
|
|
18
|
+
fy: float
|
|
19
|
+
cx: float
|
|
20
|
+
cy: float
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from .axis_convention import AxisConvention
|
|
6
|
+
from .camera_config import PinholeCameraConfig
|
|
7
|
+
from .transform import Float3, Float4
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RigCameraConfig(BaseModel):
|
|
11
|
+
id: str
|
|
12
|
+
ref_sensor: bool
|
|
13
|
+
rotation: Float4
|
|
14
|
+
translation: Float3
|
|
15
|
+
camera_config: PinholeCameraConfig
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RigConfig(BaseModel):
|
|
19
|
+
id: str
|
|
20
|
+
cameras: list[RigCameraConfig]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CaptureSessionManifest(BaseModel):
|
|
24
|
+
axis_convention: AxisConvention
|
|
25
|
+
rigs: list[RigConfig]
|
|
26
|
+
capture_interval_seconds: float | None = None
|
placeframe_core/h5.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Any, Iterable, Mapping, cast
|
|
3
|
+
|
|
4
|
+
from h5py import Dataset, File, Group
|
|
5
|
+
from numpy import asarray, float32, uint8
|
|
6
|
+
from numpy.typing import NDArray # noqa: TID251 — tracked in PLE-233
|
|
7
|
+
|
|
8
|
+
GLOBAL_DESCRIPTORS_DATASET_NAME = "global_descriptor"
|
|
9
|
+
KEYPOINTS_DATASET_NAME = "keypoints"
|
|
10
|
+
PQ_CODES_DATASET_NAME = "pq_codes"
|
|
11
|
+
GLOBAL_DESCRIPTORS_FILE = "global_descriptors.h5"
|
|
12
|
+
FEATURES_FILE = "features.h5"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def write_global_descriptors(root_path: Path, global_descriptors: Mapping[str, NDArray[float32]]):
|
|
16
|
+
path = root_path / GLOBAL_DESCRIPTORS_FILE
|
|
17
|
+
with File(str(path), "w") as file:
|
|
18
|
+
for name, global_descriptor in global_descriptors.items():
|
|
19
|
+
group = file.create_group(name)
|
|
20
|
+
_create_dataset(group, GLOBAL_DESCRIPTORS_DATASET_NAME, global_descriptor)
|
|
21
|
+
|
|
22
|
+
return GLOBAL_DESCRIPTORS_FILE, path.read_bytes()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def write_features(root_path: Path, keypoints: Mapping[str, NDArray[float32]], pq_codes: Mapping[str, NDArray[uint8]]):
|
|
26
|
+
path = root_path / FEATURES_FILE
|
|
27
|
+
with File(str(path), "w") as file:
|
|
28
|
+
for name, image_keypoints in keypoints.items():
|
|
29
|
+
group = file.create_group(name)
|
|
30
|
+
_create_dataset(group, KEYPOINTS_DATASET_NAME, image_keypoints)
|
|
31
|
+
_create_dataset(group, PQ_CODES_DATASET_NAME, pq_codes[name])
|
|
32
|
+
|
|
33
|
+
return FEATURES_FILE, path.read_bytes()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _create_dataset(group: Group, name: str, data: Any):
|
|
37
|
+
group.create_dataset(name, data=data, compression="gzip", compression_opts=9, shuffle=True, chunks=True)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def read_global_descriptors(root_path: Path, image_names: Iterable[str]) -> dict[str, NDArray[float32]]:
|
|
41
|
+
result: dict[str, NDArray[float32]] = {}
|
|
42
|
+
with File(str(root_path / GLOBAL_DESCRIPTORS_FILE), "r") as file:
|
|
43
|
+
for name in image_names:
|
|
44
|
+
group = cast(Group, file[name])
|
|
45
|
+
result[name] = asarray(cast(Dataset, group[GLOBAL_DESCRIPTORS_DATASET_NAME])[()], dtype=float32)
|
|
46
|
+
|
|
47
|
+
return result
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def read_features(
|
|
51
|
+
root_path: Path, image_names: Iterable[str]
|
|
52
|
+
) -> tuple[dict[str, NDArray[float32]], dict[str, NDArray[uint8]]]:
|
|
53
|
+
keypoints_by_name: dict[str, NDArray[float32]] = {}
|
|
54
|
+
pq_codes_by_name: dict[str, NDArray[uint8]] = {}
|
|
55
|
+
|
|
56
|
+
with File(str(root_path / FEATURES_FILE), "r") as file:
|
|
57
|
+
for name in image_names:
|
|
58
|
+
group = cast(Group, file[name])
|
|
59
|
+
keypoints_by_name[name] = asarray(cast(Dataset, group[KEYPOINTS_DATASET_NAME])[()], dtype=float32)
|
|
60
|
+
pq_codes_by_name[name] = asarray(cast(Dataset, group[PQ_CODES_DATASET_NAME])[()], dtype=uint8)
|
|
61
|
+
|
|
62
|
+
return keypoints_by_name, pq_codes_by_name
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from io import BytesIO
|
|
4
|
+
|
|
5
|
+
from PIL import Image as PILImage
|
|
6
|
+
from PIL.Image import Resampling, Transpose
|
|
7
|
+
|
|
8
|
+
from .camera_config import ImageOrientation, PinholeCameraConfig
|
|
9
|
+
|
|
10
|
+
# Standardizes per-pixel scale across cameras with different resolutions, so the feature extractor's
|
|
11
|
+
# fixed-pixel receptive field sees comparable structure regardless of source camera.
|
|
12
|
+
LOCAL_FEATURE_RESIZE_SHORTER_SIDE = 1024
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def canonicalize_image(image_buffer: bytes, orientation: ImageOrientation) -> PILImage.Image:
|
|
16
|
+
image = PILImage.open(BytesIO(image_buffer))
|
|
17
|
+
image = _orient(image, orientation)
|
|
18
|
+
new_width, new_height = _resized_dimensions(image.width, image.height)
|
|
19
|
+
if (new_width, new_height) != (image.width, image.height):
|
|
20
|
+
image = image.resize((new_width, new_height), Resampling.LANCZOS)
|
|
21
|
+
return image.convert("RGB")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def canonicalize_intrinsics(camera: PinholeCameraConfig):
|
|
25
|
+
width, height, fx, fy, cx, cy = _oriented_intrinsics(camera)
|
|
26
|
+
new_width, new_height = _resized_dimensions(width, height)
|
|
27
|
+
scale_x = new_width / width
|
|
28
|
+
scale_y = new_height / height
|
|
29
|
+
return new_width, new_height, fx * scale_x, fy * scale_y, cx * scale_x, cy * scale_y
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _resized_dimensions(width: int, height: int) -> tuple[int, int]:
|
|
33
|
+
scale = LOCAL_FEATURE_RESIZE_SHORTER_SIDE / min(width, height)
|
|
34
|
+
return round(width * scale), round(height * scale)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _orient(image: PILImage.Image, orientation: ImageOrientation) -> PILImage.Image:
|
|
38
|
+
match orientation:
|
|
39
|
+
case "TOP_LEFT":
|
|
40
|
+
return image
|
|
41
|
+
case "TOP_RIGHT":
|
|
42
|
+
return image.transpose(Transpose.FLIP_LEFT_RIGHT)
|
|
43
|
+
case "BOTTOM_RIGHT":
|
|
44
|
+
return image.transpose(Transpose.ROTATE_180)
|
|
45
|
+
case "BOTTOM_LEFT":
|
|
46
|
+
return image.transpose(Transpose.FLIP_TOP_BOTTOM)
|
|
47
|
+
case "LEFT_TOP":
|
|
48
|
+
return image.transpose(Transpose.TRANSPOSE)
|
|
49
|
+
case "RIGHT_TOP":
|
|
50
|
+
return image.transpose(Transpose.ROTATE_270)
|
|
51
|
+
case "RIGHT_BOTTOM":
|
|
52
|
+
return image.transpose(Transpose.TRANSVERSE)
|
|
53
|
+
case "LEFT_BOTTOM":
|
|
54
|
+
return image.transpose(Transpose.ROTATE_90)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _oriented_intrinsics(camera: PinholeCameraConfig) -> tuple[int, int, float, float, float, float]:
|
|
58
|
+
width = camera.width
|
|
59
|
+
height = camera.height
|
|
60
|
+
|
|
61
|
+
if camera.orientation == "TOP_LEFT":
|
|
62
|
+
return camera.width, camera.height, camera.fx, camera.fy, camera.cx, camera.cy
|
|
63
|
+
|
|
64
|
+
if camera.orientation == "TOP_RIGHT":
|
|
65
|
+
return camera.width, camera.height, camera.fx, camera.fy, (width - camera.cx), camera.cy
|
|
66
|
+
|
|
67
|
+
if camera.orientation == "BOTTOM_RIGHT":
|
|
68
|
+
return camera.width, camera.height, camera.fx, camera.fy, (width - camera.cx), (height - camera.cy)
|
|
69
|
+
|
|
70
|
+
if camera.orientation == "BOTTOM_LEFT":
|
|
71
|
+
return camera.width, camera.height, camera.fx, camera.fy, camera.cx, (height - camera.cy)
|
|
72
|
+
|
|
73
|
+
new_width = camera.height
|
|
74
|
+
new_height = camera.width
|
|
75
|
+
|
|
76
|
+
if camera.orientation == "LEFT_TOP":
|
|
77
|
+
return new_width, new_height, camera.fy, camera.fx, camera.cy, camera.cx
|
|
78
|
+
|
|
79
|
+
if camera.orientation == "RIGHT_TOP":
|
|
80
|
+
return new_width, new_height, camera.fy, camera.fx, (height - camera.cy), camera.cx
|
|
81
|
+
|
|
82
|
+
if camera.orientation == "RIGHT_BOTTOM":
|
|
83
|
+
return new_width, new_height, camera.fy, camera.fx, (height - camera.cy), (width - camera.cx)
|
|
84
|
+
|
|
85
|
+
if camera.orientation == "LEFT_BOTTOM":
|
|
86
|
+
return new_width, new_height, camera.fy, camera.fx, camera.cy, (width - camera.cx)
|
|
87
|
+
|
|
88
|
+
raise ValueError(f"Unknown orientation: {camera.orientation!r}")
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import NewType
|
|
5
|
+
|
|
6
|
+
from lightglue import LightGlue # type: ignore
|
|
7
|
+
from numpy import bool_, dtype, float32, intp, ndarray
|
|
8
|
+
from torch import Tensor, from_numpy, inference_mode, tensor # type: ignore
|
|
9
|
+
from torch.nn.utils.rnn import pad_sequence
|
|
10
|
+
|
|
11
|
+
from .numpy_ops import compress, nonzero
|
|
12
|
+
|
|
13
|
+
NumMatches = NewType("NumMatches", int)
|
|
14
|
+
|
|
15
|
+
MatchIndices = dict[
|
|
16
|
+
tuple[str, str],
|
|
17
|
+
tuple[ndarray[tuple[NumMatches], dtype[intp]], ndarray[tuple[NumMatches], dtype[intp]]],
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
# Per-image-name dicts for the matcher's two distinct positional arguments. Branded at the dict
|
|
21
|
+
# level so pyright catches positional swaps at the call site — passing Keypoints where Descriptors
|
|
22
|
+
# is expected (or vice versa) is a type error, even though both wrap dict[str, Tensor] at runtime.
|
|
23
|
+
Keypoints = NewType("Keypoints", dict[str, Tensor])
|
|
24
|
+
Descriptors = NewType("Descriptors", dict[str, Tensor])
|
|
25
|
+
KeypointsArrays = NewType("KeypointsArrays", dict[str, ndarray[tuple[int, int], dtype[float32]]])
|
|
26
|
+
DescriptorsArrays = NewType("DescriptorsArrays", dict[str, ndarray[tuple[int, int], dtype[float32]]])
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def lightglue_match(
|
|
30
|
+
lightglue: LightGlue,
|
|
31
|
+
pairs: list[tuple[str, str]],
|
|
32
|
+
keypoints: KeypointsArrays,
|
|
33
|
+
descriptors: DescriptorsArrays,
|
|
34
|
+
sizes: dict[str, tuple[int, int]],
|
|
35
|
+
batch_size: int,
|
|
36
|
+
device: str,
|
|
37
|
+
on_progress: Callable[[int], None] | None = None,
|
|
38
|
+
) -> MatchIndices:
|
|
39
|
+
keypoints_tensors = Keypoints({name: from_numpy(kp).to(device) for name, kp in keypoints.items()})
|
|
40
|
+
descriptors_tensors = Descriptors({name: from_numpy(desc).to(device) for name, desc in descriptors.items()})
|
|
41
|
+
|
|
42
|
+
return lightglue_match_tensors(
|
|
43
|
+
lightglue, pairs, keypoints_tensors, descriptors_tensors, sizes, batch_size, device, on_progress
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def lightglue_match_tensors(
|
|
48
|
+
lightglue: LightGlue,
|
|
49
|
+
pairs: list[tuple[str, str]],
|
|
50
|
+
keypoints: Keypoints,
|
|
51
|
+
descriptors: Descriptors,
|
|
52
|
+
sizes: dict[str, tuple[int, int]],
|
|
53
|
+
batch_size: int,
|
|
54
|
+
device: str,
|
|
55
|
+
on_progress: Callable[[int], None] | None = None,
|
|
56
|
+
) -> MatchIndices:
|
|
57
|
+
num_batches = (len(pairs) + batch_size - 1) // batch_size
|
|
58
|
+
match_indices: MatchIndices = {}
|
|
59
|
+
for batch_start in range(0, len(pairs), batch_size):
|
|
60
|
+
print(f"Matching features: batch {batch_start // batch_size + 1} of {num_batches}")
|
|
61
|
+
batch_pairs = pairs[batch_start : batch_start + batch_size]
|
|
62
|
+
|
|
63
|
+
with inference_mode():
|
|
64
|
+
matches = lightglue({
|
|
65
|
+
"image0": {
|
|
66
|
+
"keypoints": pad_sequence([keypoints[a] for a, _ in batch_pairs], batch_first=True),
|
|
67
|
+
"descriptors": pad_sequence([descriptors[a] for a, _ in batch_pairs], batch_first=True),
|
|
68
|
+
"image_size": tensor([sizes[a] for a, _ in batch_pairs], device=device),
|
|
69
|
+
},
|
|
70
|
+
"image1": {
|
|
71
|
+
"keypoints": pad_sequence([keypoints[b] for _, b in batch_pairs], batch_first=True),
|
|
72
|
+
"descriptors": pad_sequence([descriptors[b] for _, b in batch_pairs], batch_first=True),
|
|
73
|
+
"image_size": tensor([sizes[b] for _, b in batch_pairs], device=device),
|
|
74
|
+
},
|
|
75
|
+
})["matches0"]
|
|
76
|
+
|
|
77
|
+
for i, (image_a, image_b) in enumerate(batch_pairs):
|
|
78
|
+
image_a_num_keypoints = keypoints[image_a].shape[0]
|
|
79
|
+
|
|
80
|
+
# Get actual batch matches (without padding), move to CPU, and convert to numpy
|
|
81
|
+
batch_matches = matches[i, :image_a_num_keypoints].cpu().numpy().astype(intp)
|
|
82
|
+
|
|
83
|
+
# Mask out non-matches (-1)
|
|
84
|
+
mask: ndarray[tuple[int], dtype[bool_]] = batch_matches >= 0
|
|
85
|
+
match_indices[(image_a, image_b)] = (nonzero(mask)[0], compress(mask, batch_matches))
|
|
86
|
+
|
|
87
|
+
if on_progress is not None:
|
|
88
|
+
on_progress(batch_start + len(batch_pairs))
|
|
89
|
+
|
|
90
|
+
return match_indices
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
# Localizer hyperparameters. The localizer falls back to these when callers omit the values, and
|
|
5
|
+
# fit_calibration passes them explicitly to api.localize_image and persists them on each
|
|
6
|
+
# localization_evaluations row. The (reconstruction_id, frame_timestamp, retrieval_top_k,
|
|
7
|
+
# ransac_threshold, pipeline_version) cache key in localization_evaluations relies on caller and
|
|
8
|
+
# fallback agreeing, so both sides must read these constants from one source.
|
|
9
|
+
RETRIEVAL_TOP_K_DEFAULT = 12
|
|
10
|
+
RANSAC_THRESHOLD_DEFAULT = 8.0
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LocalizationMetrics(BaseModel):
|
|
14
|
+
inlier_ratio: float
|
|
15
|
+
reprojection_error_median: float
|
|
16
|
+
num_inliers: int
|
|
17
|
+
num_correspondences: int
|
|
18
|
+
num_matches: int
|
|
19
|
+
inlier_coverage: float
|
|
20
|
+
confidence_tight: float
|
|
21
|
+
confidence_loose: float
|
|
22
|
+
confidence_is_calibrated: bool
|
|
23
|
+
measurement_covariance: list[list[float]]
|
|
24
|
+
pnp_covariance: list[list[float]]
|
|
25
|
+
pipeline_version: str
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any, Literal, NewType, cast
|
|
5
|
+
|
|
6
|
+
from torch import Tensor
|
|
7
|
+
|
|
8
|
+
from .lightglue import (
|
|
9
|
+
Descriptors,
|
|
10
|
+
DescriptorsArrays,
|
|
11
|
+
Keypoints,
|
|
12
|
+
KeypointsArrays,
|
|
13
|
+
MatchIndices,
|
|
14
|
+
lightglue_match,
|
|
15
|
+
lightglue_match_tensors,
|
|
16
|
+
)
|
|
17
|
+
from .tensor_types import TT
|
|
18
|
+
|
|
19
|
+
RetrievalDim = NewType("RetrievalDim", int)
|
|
20
|
+
NumKeypoints = NewType("NumKeypoints", int)
|
|
21
|
+
LocalDescDim = NewType("LocalDescDim", int)
|
|
22
|
+
|
|
23
|
+
LocalFeatureOutput = tuple[TT[NumKeypoints, Literal[2]], TT[NumKeypoints, LocalDescDim]]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def make_global_descriptor_extractor(model: Any) -> Callable[[Tensor], TT[RetrievalDim]]:
|
|
27
|
+
def extract(image: Tensor) -> TT[RetrievalDim]:
|
|
28
|
+
return cast(TT[RetrievalDim], model({"image": image})["global_descriptor"][0])
|
|
29
|
+
|
|
30
|
+
return extract
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def make_local_feature_extractor(model: Any) -> Callable[[Tensor], LocalFeatureOutput]:
|
|
34
|
+
def extract(image: Tensor) -> LocalFeatureOutput:
|
|
35
|
+
output = model({"image": image})
|
|
36
|
+
return (
|
|
37
|
+
cast(TT[NumKeypoints, Literal[2]], output["keypoints"][0]),
|
|
38
|
+
cast(TT[NumKeypoints, LocalDescDim], output["descriptors"][0]),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
return extract
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def make_local_feature_matcher_for_tensors(model: Any, device: str):
|
|
45
|
+
def match(
|
|
46
|
+
pairs: list[tuple[str, str]],
|
|
47
|
+
keypoints: Keypoints,
|
|
48
|
+
descriptors: Descriptors,
|
|
49
|
+
sizes: dict[str, tuple[int, int]],
|
|
50
|
+
batch_size: int,
|
|
51
|
+
on_progress: Callable[[int], None] | None = None,
|
|
52
|
+
) -> MatchIndices:
|
|
53
|
+
return lightglue_match_tensors(model, pairs, keypoints, descriptors, sizes, batch_size, device, on_progress)
|
|
54
|
+
|
|
55
|
+
return match
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def make_local_feature_matcher_for_arrays(model: Any, device: str):
|
|
59
|
+
def match(
|
|
60
|
+
pairs: list[tuple[str, str]],
|
|
61
|
+
keypoints: KeypointsArrays,
|
|
62
|
+
descriptors: DescriptorsArrays,
|
|
63
|
+
sizes: dict[str, tuple[int, int]],
|
|
64
|
+
batch_size: int,
|
|
65
|
+
on_progress: Callable[[int], None] | None = None,
|
|
66
|
+
) -> MatchIndices:
|
|
67
|
+
return lightglue_match(model, pairs, keypoints, descriptors, sizes, batch_size, device, on_progress)
|
|
68
|
+
|
|
69
|
+
return match
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import cast, overload
|
|
4
|
+
|
|
5
|
+
from numpy import bool_, dtype, generic, intp, ndarray
|
|
6
|
+
from numpy import compress as _compress
|
|
7
|
+
from numpy import nonzero as _nonzero
|
|
8
|
+
from numpy import zeros as _zeros
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@overload
|
|
12
|
+
def zeros[A: int, T: generic](shape: tuple[A], dtype: type[T]) -> ndarray[tuple[A], dtype[T]]: ...
|
|
13
|
+
@overload
|
|
14
|
+
def zeros[A: int, B: int, T: generic](shape: tuple[A, B], dtype: type[T]) -> ndarray[tuple[A, B], dtype[T]]: ...
|
|
15
|
+
@overload
|
|
16
|
+
def zeros[A: int, B: int, C: int, T: generic](
|
|
17
|
+
shape: tuple[A, B, C], dtype: type[T]
|
|
18
|
+
) -> ndarray[tuple[A, B, C], dtype[T]]: ...
|
|
19
|
+
def zeros(shape: tuple[int, ...], dtype: type[generic]) -> ndarray[tuple[int, ...], dtype[generic]]:
|
|
20
|
+
return _zeros(shape, dtype=dtype)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def nonzero[A: int, B: int](
|
|
24
|
+
array: ndarray[tuple[A], dtype[generic]],
|
|
25
|
+
) -> tuple[ndarray[tuple[B], dtype[intp]]]:
|
|
26
|
+
return cast("tuple[ndarray[tuple[B], dtype[intp]]]", _nonzero(array))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def compress[A: int, B: int, T: generic](
|
|
30
|
+
condition: ndarray[tuple[A], dtype[bool_]],
|
|
31
|
+
array: ndarray[tuple[A], dtype[T]],
|
|
32
|
+
) -> ndarray[tuple[B], dtype[T]]:
|
|
33
|
+
return cast("ndarray[tuple[B], dtype[T]]", _compress(condition, array))
|
placeframe_core/opq.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import cast
|
|
3
|
+
|
|
4
|
+
from faiss import ( # type: ignore
|
|
5
|
+
OPQMatrix,
|
|
6
|
+
ProductQuantizer,
|
|
7
|
+
read_ProductQuantizer, # type: ignore
|
|
8
|
+
read_VectorTransform, # type: ignore
|
|
9
|
+
write_ProductQuantizer, # type: ignore
|
|
10
|
+
write_VectorTransform, # type: ignore
|
|
11
|
+
)
|
|
12
|
+
from numpy import ascontiguousarray, float32, uint8
|
|
13
|
+
from numpy.linalg import norm
|
|
14
|
+
from numpy.typing import NDArray # noqa: TID251 — tracked in PLE-233
|
|
15
|
+
|
|
16
|
+
OPQ_MATRIX_FILE = "opq_matrix.tf"
|
|
17
|
+
PQ_QUANTIZER_FILE = "pq_quantizer.pq"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def train_opq_matrix(number_of_subvectors: int, number_of_training_iterations: int, training_unit: NDArray[float32]):
|
|
21
|
+
opq_matrix = OPQMatrix(training_unit.shape[1], number_of_subvectors)
|
|
22
|
+
opq_matrix.niter = number_of_training_iterations
|
|
23
|
+
opq_matrix.verbose = True
|
|
24
|
+
opq_matrix.train(training_unit) # type: ignore
|
|
25
|
+
return opq_matrix
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def train_pq_quantizer(
|
|
29
|
+
number_of_subvectors: int, number_of_bits_per_subvector: int, opq_matrix: OPQMatrix, training_unit: NDArray[float32]
|
|
30
|
+
):
|
|
31
|
+
rotated_training_unit = opq_matrix.apply(training_unit) # type: ignore
|
|
32
|
+
product_quantizer = ProductQuantizer(training_unit.shape[1], number_of_subvectors, number_of_bits_per_subvector)
|
|
33
|
+
product_quantizer.verbose = True
|
|
34
|
+
product_quantizer.train(rotated_training_unit) # type: ignore
|
|
35
|
+
return product_quantizer
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def encode_descriptors(
|
|
39
|
+
opq_matrix: OPQMatrix, product_quantizer: ProductQuantizer, image_descriptors: dict[str, NDArray[float32]]
|
|
40
|
+
):
|
|
41
|
+
images_codes: dict[str, NDArray[uint8]] = {}
|
|
42
|
+
for i, name in enumerate(image_descriptors.keys()):
|
|
43
|
+
print(f"Encoding image {i + 1} of {len(image_descriptors)}")
|
|
44
|
+
descriptors_contiguous = ascontiguousarray(image_descriptors[name])
|
|
45
|
+
descriptors_rotated = cast(NDArray[float32], opq_matrix.apply(descriptors_contiguous)) # type: ignore
|
|
46
|
+
codes = cast(NDArray[uint8], product_quantizer.compute_codes(descriptors_rotated)) # type: ignore
|
|
47
|
+
images_codes[name] = codes
|
|
48
|
+
|
|
49
|
+
return images_codes
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def decode_descriptors(opq_matrix: OPQMatrix, product_quantizer: ProductQuantizer, pq_codes: dict[int, NDArray[uint8]]):
|
|
53
|
+
descriptors: dict[int, NDArray[float32]] = {}
|
|
54
|
+
for image_id, code in pq_codes.items():
|
|
55
|
+
decoded = cast(NDArray[float32], product_quantizer.decode(code)) # type: ignore
|
|
56
|
+
reversed_transformed = cast(NDArray[float32], opq_matrix.reverse_transform(decoded)) # type: ignore
|
|
57
|
+
descriptors[image_id] = _l2_normalize_rows(reversed_transformed)
|
|
58
|
+
|
|
59
|
+
return descriptors
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _l2_normalize_rows(matrix: NDArray[float32]) -> NDArray[float32]:
|
|
63
|
+
return (matrix / (norm(matrix, axis=1, keepdims=True).astype(float32) + float32(1e-12))).astype(float32, copy=False)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def write_opq_matrix(opq_matrix: OPQMatrix, root_path: Path):
|
|
67
|
+
path = root_path / OPQ_MATRIX_FILE
|
|
68
|
+
write_VectorTransform(opq_matrix, str(path))
|
|
69
|
+
return OPQ_MATRIX_FILE, path.read_bytes()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def write_pq_quantizer(pq_quantizer: ProductQuantizer, root_path: Path):
|
|
73
|
+
path = root_path / PQ_QUANTIZER_FILE
|
|
74
|
+
write_ProductQuantizer(pq_quantizer, str(path))
|
|
75
|
+
return PQ_QUANTIZER_FILE, path.read_bytes()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def read_opq_matrix(root_path: Path):
|
|
79
|
+
return cast(OPQMatrix, read_VectorTransform(str(root_path / OPQ_MATRIX_FILE)))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def read_pq_quantizer(root_path: Path):
|
|
83
|
+
return cast(ProductQuantizer, read_ProductQuantizer(str(root_path / PQ_QUANTIZER_FILE)))
|
placeframe_core/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from .reconstruction_metrics import ReconstructionMetrics
|
|
6
|
+
from .reconstruction_options import ReconstructionOptions
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
MANIFEST_VERSION = 1
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Manifest(BaseModel):
|
|
13
|
+
options: ReconstructionOptions
|
|
14
|
+
metrics: ReconstructionMetrics
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PhaseTiming(BaseModel):
|
|
9
|
+
phase: str = Field(description="ReconstructionStatus value of the phase, e.g. 'extracting_features'.")
|
|
10
|
+
duration_seconds: float = Field(description="Wall-clock seconds the phase spent in-flight.")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ReconstructionMetrics(BaseModel):
|
|
14
|
+
reprojection_pixel_error_50th_percentile: Optional[float] = Field(
|
|
15
|
+
default=None,
|
|
16
|
+
description="Median reprojection error in pixels across all valid 2D observations in registered images.",
|
|
17
|
+
)
|
|
18
|
+
reprojection_pixel_error_90th_percentile: Optional[float] = Field(
|
|
19
|
+
default=None,
|
|
20
|
+
description="90th percentile reprojection error in pixels across all valid 2D observations.",
|
|
21
|
+
)
|
|
22
|
+
track_length_50th_percentile: Optional[float] = Field(
|
|
23
|
+
default=None,
|
|
24
|
+
description="Median number of distinct images observing each 3D point.",
|
|
25
|
+
)
|
|
26
|
+
all_verified_matches: Optional[int] = Field(
|
|
27
|
+
default=None, description="Total number of verified matches across all image pairs."
|
|
28
|
+
)
|
|
29
|
+
all_verified_match_rate: Optional[float] = Field(
|
|
30
|
+
default=None, description="Percentage of image pairs that passed two-view geometry verification."
|
|
31
|
+
)
|
|
32
|
+
all_verified_match_inliers_mean: Optional[float] = Field(
|
|
33
|
+
default=None, description="Mean inlier count among verified image pairs."
|
|
34
|
+
)
|
|
35
|
+
all_verified_match_inliers_median: Optional[float] = Field(
|
|
36
|
+
default=None, description="Median inlier count among verified image pairs."
|
|
37
|
+
)
|
|
38
|
+
stereo_verified_matches: Optional[int] = Field(
|
|
39
|
+
default=None, description="Number of verified stereo pairs (same frame, different sensors)."
|
|
40
|
+
)
|
|
41
|
+
stereo_verified_match_rate: Optional[float] = Field(
|
|
42
|
+
default=None, description="Percentage of stereo pairs that passed verification."
|
|
43
|
+
)
|
|
44
|
+
stereo_verified_match_inliers_mean: Optional[float] = Field(
|
|
45
|
+
default=None, description="Mean inlier count among verified stereo pairs."
|
|
46
|
+
)
|
|
47
|
+
stereo_verified_match_inliers_median: Optional[float] = Field(
|
|
48
|
+
default=None, description="Median inlier count among verified stereo pairs."
|
|
49
|
+
)
|
|
50
|
+
map_image_count: Optional[int] = Field(
|
|
51
|
+
default=None, description="Number of registered images in the reconstruction."
|
|
52
|
+
)
|
|
53
|
+
map_point_count: Optional[int] = Field(
|
|
54
|
+
default=None, description="Number of triangulated 3D points in the reconstruction."
|
|
55
|
+
)
|
|
56
|
+
map_avg_track_length: Optional[float] = Field(
|
|
57
|
+
default=None, description="Mean number of image observations per 3D point."
|
|
58
|
+
)
|
|
59
|
+
map_viewpoint_diversity: Optional[float] = Field(
|
|
60
|
+
default=None,
|
|
61
|
+
description="1 minus the magnitude of the mean unit viewing direction across registered cameras; 0 means uniform direction, approaches 1 as viewpoints spread.",
|
|
62
|
+
)
|
|
63
|
+
gravity_aligned_in_map_frame: Optional[bool] = Field(
|
|
64
|
+
default=None,
|
|
65
|
+
description="True when per-frame gravity samples aligned the map's vertical axis; False when no samples were available and only origin-shift was applied.",
|
|
66
|
+
)
|
|
67
|
+
gravity_sample_count: Optional[int] = Field(
|
|
68
|
+
default=None,
|
|
69
|
+
description="Number of registered frames that contributed gravity samples to the map-frame alignment.",
|
|
70
|
+
)
|
|
71
|
+
prior_drift_residual_rms_m: Optional[float] = Field(
|
|
72
|
+
default=None,
|
|
73
|
+
description="RMS residual in meters of a rigid Umeyama fit from map camera centers to VIO position priors; None for multi-camera captures, which run priors-off and carry no per-frame positions.",
|
|
74
|
+
)
|
|
75
|
+
prior_drift_residual_max_m: Optional[float] = Field(
|
|
76
|
+
default=None,
|
|
77
|
+
description="Maximum residual in meters of the same Umeyama fit; surfaces single-frame outliers the RMS smooths over.",
|
|
78
|
+
)
|
|
79
|
+
phase_timings: Optional[list[PhaseTiming]] = Field(
|
|
80
|
+
default=None,
|
|
81
|
+
description="Per-phase wall-clock durations in execution order, captured at each set_phase boundary.",
|
|
82
|
+
)
|
|
83
|
+
pipeline_version: Optional[str] = Field(
|
|
84
|
+
default=None,
|
|
85
|
+
description="RECONSTRUCTOR_SHA of the image that produced this reconstruction.",
|
|
86
|
+
)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ReconstructionOptions(BaseModel):
|
|
9
|
+
deterministic_seed: Optional[int] = Field(
|
|
10
|
+
default=None,
|
|
11
|
+
description="PRNG seed and single-threaded gate for reproducible reconstructions; None means non-deterministic.",
|
|
12
|
+
)
|
|
13
|
+
keyframe_min_distance_m: float = Field(
|
|
14
|
+
default=1.0,
|
|
15
|
+
description="Minimum VIO-translation distance (meters) between successive kept keyframes; frames closer to the last kept frame than this are dropped before feature extraction.",
|
|
16
|
+
)
|
|
17
|
+
sequential_window_m: float = Field(
|
|
18
|
+
default=3.0,
|
|
19
|
+
description="VIO-path-distance window (meters) used to enumerate same-rig sequential pairs. For each keyframe, every later keyframe whose cumulative segment-by-segment path length along the VIO trajectory is within this many metres is paired with it. Path distance — not straight-line distance — so doubling back along the trajectory (e.g. corridor return pass) walks away from earlier frames rather than landing on them. Scales the temporal match-graph backbone to actual device motion: stationary stretches shrink to almost no extra pairs, fast-motion stretches grow to cover the swept arc.",
|
|
20
|
+
)
|
|
21
|
+
retrieval_neighbors: int = Field(
|
|
22
|
+
default=20,
|
|
23
|
+
description="Top-K most-similar images (DIR cosine) paired with each image for loop closures; 0 disables retrieval.",
|
|
24
|
+
)
|
|
25
|
+
retrieval_min_score: float = Field(
|
|
26
|
+
default=0.35,
|
|
27
|
+
description="Minimum cosine similarity for retrieval candidates; drops visually-weak matches before BA.",
|
|
28
|
+
)
|
|
29
|
+
ransac_max_error: float = Field(
|
|
30
|
+
default=2.0,
|
|
31
|
+
description="Two-view RANSAC inlier threshold in pixels; lower is stricter.",
|
|
32
|
+
)
|
|
33
|
+
ransac_min_inlier_ratio: float = Field(
|
|
34
|
+
default=0.25,
|
|
35
|
+
description="Two-view RANSAC minimum inlier ratio to accept a pair's geometry.",
|
|
36
|
+
)
|
|
37
|
+
two_view_min_num_inliers: int = Field(
|
|
38
|
+
default=30,
|
|
39
|
+
description="Absolute minimum inlier count for a verified two-view geometry, applied alongside ransac_min_inlier_ratio. Raised above pycolmap's SIFT-era default of 15 to reject small false-positive clusters on repetitive structure.",
|
|
40
|
+
)
|
|
41
|
+
triangulation_minimum_angle: float = Field(
|
|
42
|
+
default=3.0,
|
|
43
|
+
description="Minimum triangulation angle in degrees; applied at creation time and again in mapper filtering.",
|
|
44
|
+
)
|
|
45
|
+
mapper_filter_max_reprojection_error: float = Field(
|
|
46
|
+
default=2.0,
|
|
47
|
+
description="Post-BA outlier reprojection threshold in pixels; points exceeding it are culled.",
|
|
48
|
+
)
|
|
49
|
+
bundle_adjustment_global_frames_ratio: float = Field(
|
|
50
|
+
default=1.5,
|
|
51
|
+
description="Frame-count growth ratio that triggers a global BA event; larger = fewer events.",
|
|
52
|
+
)
|
|
53
|
+
bundle_adjustment_global_function_tolerance: float = Field(
|
|
54
|
+
default=1e-3,
|
|
55
|
+
description="Ceres function tolerance for global BA exit; larger = earlier exit on residual plateaus.",
|
|
56
|
+
)
|
|
57
|
+
pose_prior_position_sigma_m: float = Field(
|
|
58
|
+
default=0.05,
|
|
59
|
+
description="Standard deviation in meters for the position prior covariance; consumed only by monocular captures (multi-camera captures run priors-off).",
|
|
60
|
+
)
|
|
61
|
+
pair_vio_em_max_rotation_disagreement_deg: float = Field(
|
|
62
|
+
default=25.0,
|
|
63
|
+
description="At two-view verification time, every sequential pair whose VIO poses carry rotation has its essential-matrix relative pose compared against the VIO-implied relative pose. The pair is rejected (its two-view geometry deleted from the database) when the angle between the two rotations exceeds this threshold. Sequential-only because retrieval pairs span genuine loop closures where VIO drift can disagree with the essential matrix legitimately, and intra-frame stereo is already validated by the rig constraint. 0 disables. Applied only to pairs with 7-column VIO rows (quaternion present).",
|
|
64
|
+
)
|
|
65
|
+
pair_vio_em_max_translation_direction_deg: float = Field(
|
|
66
|
+
default=60.0,
|
|
67
|
+
description="Companion to pair_vio_em_max_rotation_disagreement_deg: bounds the angle between the essential-matrix translation direction (camera-1 origin direction in camera-2) and the VIO-implied translation direction for the same camera pair. Skipped when the essential-matrix baseline is below pair_vio_em_min_baseline_m, where translation direction is ill-conditioned. 0 disables.",
|
|
68
|
+
)
|
|
69
|
+
pair_vio_em_min_baseline_m: float = Field(
|
|
70
|
+
default=0.3,
|
|
71
|
+
description="Essential-matrix-baseline floor below which the VIO-vs-essential-matrix translation-direction component is skipped. Near-co-located camera pairs (intra-rig stereo timing jitter, hover frames in slow motion) have ill-defined essential-matrix translation direction; the rotation component still applies.",
|
|
72
|
+
)
|
|
73
|
+
max_keypoints_per_image: int = Field(
|
|
74
|
+
default=2500,
|
|
75
|
+
description="Maximum ALIKED keypoints retained per image.",
|
|
76
|
+
)
|
|
77
|
+
held_out_frame_timestamps: Optional[list[int]] = Field(
|
|
78
|
+
default=None,
|
|
79
|
+
description="Frame timestamps (ms) to exclude from this reconstruction so they can later be localized as held-out queries.",
|
|
80
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
import torch
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
|
|
9
|
+
class TT[*Shape](torch.Tensor): ...
|
|
10
|
+
|
|
11
|
+
else:
|
|
12
|
+
# PEP 695 generic-class syntax is type-checker only. At runtime, TT[Shape...]
|
|
13
|
+
# must evaluate (e.g. inside cast() and module-level tuple[...] aliases),
|
|
14
|
+
# so collapse subscription to plain torch.Tensor.
|
|
15
|
+
class TT:
|
|
16
|
+
def __class_getitem__(cls, _params: object) -> type[torch.Tensor]:
|
|
17
|
+
return torch.Tensor
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Float3(BaseModel):
|
|
7
|
+
x: float
|
|
8
|
+
y: float
|
|
9
|
+
z: float
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Float4(BaseModel):
|
|
13
|
+
x: float
|
|
14
|
+
y: float
|
|
15
|
+
z: float
|
|
16
|
+
w: float
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Transform(BaseModel):
|
|
20
|
+
translation: Float3
|
|
21
|
+
rotation: Float4
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: placeframe-core
|
|
3
|
+
Version: 0.1.0.dev35547537192
|
|
4
|
+
Summary: Placeframe domain logic: camera configs, coordinate transforms, metrics
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.13
|
|
7
|
+
Requires-Dist: faiss-cpu>=1.12.0
|
|
8
|
+
Requires-Dist: h5py>=3.15.1
|
|
9
|
+
Requires-Dist: numpy>=2.4.0
|
|
10
|
+
Requires-Dist: pillow>=12.0.0
|
|
11
|
+
Requires-Dist: pydantic>=2.12.5
|
|
12
|
+
Requires-Dist: scipy>=1.16.3
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# placeframe-core
|
|
16
|
+
|
|
17
|
+
Domain vocabulary for Placeframe's backend: Pydantic wire schemas (transforms, camera configs, capture manifests, reconstruction options/metrics), OpenCV↔Unity coordinate-frame primitives, image and intrinsics canonicalization, HDF5/FAISS-OPQ artifact formats, and the global confidence-calibration model. Distribution name `placeframe-core`, import name `placeframe_core`.
|
|
18
|
+
|
|
19
|
+
Consumed by the `api`, `lease-server`, `localizer`, `reconstructor`, `zed-capture`, and `scripts` packages in the [placeframe](https://github.com/outernet-foundation/placeframe) repo. Versions are published to PyPI from per-package git tags; the committed `pyproject.toml` version is a permanent `0.0.0.dev0` sentinel patched at publish time.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
placeframe_core/axis_convention.py,sha256=W3sHw0RFe9DAhf46jiY8VrMptLdBub-7BYAHL4dd79A,1905
|
|
2
|
+
placeframe_core/calibration.py,sha256=yBtorsBWM-Ti8MxPXI7xjZuCnepqufV9q6HaRWrV7cc,5521
|
|
3
|
+
placeframe_core/camera_config.py,sha256=pz5pvLhMWra0xLmAMSANU6CkP52XGKqExQ0kz77d4bQ,474
|
|
4
|
+
placeframe_core/capture_session_manifest.py,sha256=kVMWWaiDPPo2FK0TthOOGVWAU-0Zn3uVf2ps_lSofZo,582
|
|
5
|
+
placeframe_core/h5.py,sha256=7VaZpK2iIzXW6iq7hAPUlcTVR73vizLUE11S-T4t0jU,2611
|
|
6
|
+
placeframe_core/image_preprocess.py,sha256=fDouVHMT8lsbVWKRENXsGgLHMOkTcGUtXU1mTE9vsSA,3550
|
|
7
|
+
placeframe_core/lightglue.py,sha256=V1AQAHsjqZBVGC2pweHgUOA8_sxX8IT8MDkSZ7QKL4o,3870
|
|
8
|
+
placeframe_core/localization_metrics.py,sha256=vSoSDfa9pAwMiZ5T9zyGbsdbpZU27q8K9iH70T4Vuko,944
|
|
9
|
+
placeframe_core/model_wrappers.py,sha256=SRzqm1Tu-DaEt775z8FBcHMtAjLwifY3cNyI8LURPk0,2148
|
|
10
|
+
placeframe_core/numpy_ops.py,sha256=MV9d3EVce-DtzLvRitlApk6w3UHeXmeGh-sdwXTIVps,1191
|
|
11
|
+
placeframe_core/opq.py,sha256=uomGrNJ48EFkgVoxXp5Orr761W2_fvizruHptxxMsP4,3395
|
|
12
|
+
placeframe_core/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
placeframe_core/reconstruction_manifest.py,sha256=IZAfQ9BzOk2owahTS1Ud6r6ZR2S1lLExHSzaq7pqMX4,306
|
|
14
|
+
placeframe_core/reconstruction_metrics.py,sha256=0k0P0RZErdBIBb2Mf0M_XKwOAA9CBj3ctnV-OqtsOk4,4173
|
|
15
|
+
placeframe_core/reconstruction_options.py,sha256=Xt6LfrweoNLCig49N53UcCEbCrJ0O2TL2TSh4WOiU0A,5227
|
|
16
|
+
placeframe_core/tensor_types.py,sha256=svnB8TnY4kiBqWzc5g734l5zCVqWfcY2aFK7LbJBhuQ,485
|
|
17
|
+
placeframe_core/transform.py,sha256=Vv5FwaVWAXMiEwN2X1h9qf2DDm-lVeXVDtBS5FMdHWk,287
|
|
18
|
+
placeframe_core-0.1.0.dev35547537192.dist-info/METADATA,sha256=2KuZPmkMBesC7u_ACDQeXDRvr2zz9vpAdBMc5NWRwPs,1170
|
|
19
|
+
placeframe_core-0.1.0.dev35547537192.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
20
|
+
placeframe_core-0.1.0.dev35547537192.dist-info/RECORD,,
|