placeframe-core 0.1.0.dev35547537192__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.
Files changed (24) hide show
  1. placeframe_core-0.1.0.dev35547537192/.gitignore +35 -0
  2. placeframe_core-0.1.0.dev35547537192/AGENTS.md +102 -0
  3. placeframe_core-0.1.0.dev35547537192/CLAUDE.md +1 -0
  4. placeframe_core-0.1.0.dev35547537192/PKG-INFO +19 -0
  5. placeframe_core-0.1.0.dev35547537192/README.md +5 -0
  6. placeframe_core-0.1.0.dev35547537192/main.py +6 -0
  7. placeframe_core-0.1.0.dev35547537192/pyproject.toml +32 -0
  8. placeframe_core-0.1.0.dev35547537192/src/placeframe_core/axis_convention.py +48 -0
  9. placeframe_core-0.1.0.dev35547537192/src/placeframe_core/calibration.py +158 -0
  10. placeframe_core-0.1.0.dev35547537192/src/placeframe_core/camera_config.py +20 -0
  11. placeframe_core-0.1.0.dev35547537192/src/placeframe_core/capture_session_manifest.py +26 -0
  12. placeframe_core-0.1.0.dev35547537192/src/placeframe_core/h5.py +62 -0
  13. placeframe_core-0.1.0.dev35547537192/src/placeframe_core/image_preprocess.py +88 -0
  14. placeframe_core-0.1.0.dev35547537192/src/placeframe_core/lightglue.py +90 -0
  15. placeframe_core-0.1.0.dev35547537192/src/placeframe_core/localization_metrics.py +25 -0
  16. placeframe_core-0.1.0.dev35547537192/src/placeframe_core/model_wrappers.py +69 -0
  17. placeframe_core-0.1.0.dev35547537192/src/placeframe_core/numpy_ops.py +33 -0
  18. placeframe_core-0.1.0.dev35547537192/src/placeframe_core/opq.py +83 -0
  19. placeframe_core-0.1.0.dev35547537192/src/placeframe_core/py.typed +0 -0
  20. placeframe_core-0.1.0.dev35547537192/src/placeframe_core/reconstruction_manifest.py +14 -0
  21. placeframe_core-0.1.0.dev35547537192/src/placeframe_core/reconstruction_metrics.py +86 -0
  22. placeframe_core-0.1.0.dev35547537192/src/placeframe_core/reconstruction_options.py +80 -0
  23. placeframe_core-0.1.0.dev35547537192/src/placeframe_core/tensor_types.py +17 -0
  24. placeframe_core-0.1.0.dev35547537192/src/placeframe_core/transform.py +21 -0
@@ -0,0 +1,35 @@
1
+ **/__pycache__
2
+ **/*.pyc
3
+ **/*.pyo
4
+ **/*.pyd
5
+ **/.venv/
6
+ **/*.egg-info
7
+ **/dist/
8
+ **/build/
9
+ !/build/
10
+ **/*.sln
11
+ **/*.slnx
12
+ **/bin/
13
+ **/obj/
14
+ **/bin.meta
15
+ **/obj.meta
16
+ .pytest_cache
17
+ .ruff_cache
18
+ .vs
19
+ .act
20
+ .env
21
+ .env.shas
22
+ .secrets
23
+ score/compose.yaml
24
+ score/manifests.yaml
25
+ score/.score-compose/
26
+ score/.score-k8s/
27
+ metadata.json
28
+ storage/
29
+ tidy-commits.json
30
+ .build-version.json
31
+ artifacts/
32
+ .pnpm-store/
33
+ .placeframe/
34
+ .claude/scheduled_tasks.lock
35
+ bookmark.md
@@ -0,0 +1,102 @@
1
+ # packages/python/core/
2
+
3
+ ## What this is
4
+
5
+ `placeframe_core` is the workspace Python package that holds the vocabulary shared between Placeframe's backend services. It contains the Pydantic schemas that travel over HTTP and through Postgres JSONB columns, the coordinate-frame primitives that bridge OpenCV-space (reconstructor / COLMAP) and Unity-space (phone clients / localizer responses), the image and intrinsics canonicalization used by both the map-builder and the query path, the HDF5 / FAISS-OPQ on-disk artifact formats, and the global confidence-calibration model. The distribution name is `placeframe-core` (published to PyPI under that name; tag ledger `placeframe-core-python-v*`) and every import is `from placeframe_core.<module>`. `docker/api/`, `docker/lease-server/`, `docker/localizer/`, `docker/reconstructor/`, `docker/zed-capture/`, and `scripts/` declare it as a workspace dep. See `docker/AGENTS.md` for the service mesh that consumes these types.
6
+
7
+ ## Shape
8
+
9
+ The package is flat: 16 leaf modules under `src/placeframe_core/`, no `__init__.py` re-exports, every consumer imports from a leaf. There are no tests inside `placeframe_core/`; behaviour is exercised end-to-end from the consumer test suites (`docker/localizer/tests/test_build_metrics.py`, `docker/reconstructor/tests/test_rig.py`, `scripts/tests/test_fit_calibration.py`).
10
+
11
+ ### Modules by role
12
+
13
+ **Wire-format schemas (Pydantic; serialize to HTTP / JSONB / the generated C# client).**
14
+
15
+ - `transform.py` — `Float3`, `Float4(x,y,z,w)`, `Transform(translation, rotation)`. Smallest building block; carried in URLs, capture manifests, and localization responses. Quaternion order is xyzw everywhere.
16
+ - `camera_config.py` — `ImageOrientation` (the 8 EXIF orientation tags as a Literal) and `PinholeCameraConfig(width, height, orientation, fx, fy, cx, cy)`. Foundational.
17
+ - `capture_session_manifest.py` — `RigCameraConfig`, `RigConfig`, `CaptureSessionManifest(axis_convention, rigs, capture_interval_seconds | None)`. The structured payload a phone client uploads alongside the image tar. `ref_sensor: bool` on `RigCameraConfig` identifies the rig origin.
18
+ - `reconstruction_options.py` — 32 optional Pydantic fields describing the reconstructor's COLMAP pipeline: keyframe-selection thresholds, sequential/spatial/retrieval pair-generation knobs, RANSAC thresholds, BA toggles, triangulation gates, OPQ params, pose-prior sigma, and `held_out_frame_timestamps` (used by calibration to exclude specific frames so they can be re-localized as held-out queries).
19
+ - `reconstruction_metrics.py` — 33 optional fields covering classic SfM metrics, match-verification counts (stereo / same-sensor / cross-sensor splits), map-quality features for the calibration model (`map_image_count`, `map_point_count`, `map_avg_track_length`, `map_viewpoint_diversity`), Umeyama-alignment residuals against ground-truth poses, per-phase wall-clock timings, and the reconstructor `pipeline_version`.
20
+ - `reconstruction_manifest.py` — `MANIFEST_VERSION = 1` and `Manifest(options, metrics)`. Stored as JSONB in `reconstructions.manifest`. The version constant is stamped onto `row.manifest_version` at write time.
21
+ - `localization_metrics.py` — `LocalizationMetrics`, the per-query response payload (inlier ratio, reprojection error, inlier counts, calibrated confidences, 6x6 measurement and PnP covariances, pipeline version). Also exposes `RETRIEVAL_TOP_K_DEFAULT = 12` and `RANSAC_THRESHOLD_DEFAULT = 8.0`. Caller and fallback must read these from one source because the `(reconstruction_id, frame_timestamp, retrieval_top_k, ransac_threshold, pipeline_version)` cache key in `localization_evaluations` relies on agreement.
22
+
23
+ **Coordinate-frame math.**
24
+
25
+ - `axis_convention.py` — `AxisConvention` enum (`OPENCV`, `UNITY`) plus four pure-numpy primitives built on a single basis-change matrix `diag(1, -1, 1)`: `change_basis_opencv_from_unity_pose`, `change_basis_unity_from_opencv_pose`, `change_basis_unity_from_opencv_points`, `change_basis_unity_from_opencv_poses` (vectorized, takes xyzw quaternions). OpenCV is `+X right, +Y down, +Z forward`; Unity is `+X right, +Y up, +Z forward`. The enum is a tag, not a dispatch — callers branch on it themselves.
26
+
27
+ **Image / intrinsics canonicalization.**
28
+
29
+ - `image_preprocess.py` — the producer/consumer agreement. `canonicalize_image(buffer, orientation)` EXIF-orients then LANCZOS-resizes the shorter side to `LOCAL_FEATURE_RESIZE_SHORTER_SIDE = 1024`. `canonicalize_intrinsics(camera)` applies the matching orientation swap and rescale to `fx/fy/cx/cy` (an 8-branch match where diagonal flips swap width/height *and* fx/fy *and* cx/cy axes). `tile_image(image)` slides a 1024px window with `RETRIEVAL_TILE_OVERLAP_FRACTION = 0.5` overlap. The reconstructor runs all three at map-build time; the localizer runs them at query time. `NumImages`, `MaxTiles`, `NumQueryTiles` `NewType` brands live here.
30
+
31
+ **On-disk artifact format.**
32
+
33
+ - `h5.py` — `GLOBAL_DESCRIPTORS_FILE = "global_descriptors.h5"`, `FEATURES_FILE = "features.h5"`, gzip-compressed chunked writers and image-name-keyed readers. The reconstructor writes; the localizer reads. Dataset names `global_descriptor`, `keypoints`, `pq_codes` are constants here.
34
+ - `opq.py` — FAISS OPQ matrix and product-quantizer training, encoding, decoding, and IO. File names `opq_matrix.tf` and `pq_quantizer.pq` pinned here. `decode_descriptors` L2-normalizes its reconstructed descriptors (with a `+1e-12` float32 denominator to avoid divide-by-zero).
35
+
36
+ **Confidence-calibration model.**
37
+
38
+ - `calibration.py` — `SCHEMA_VERSION = 2`. `RawLocalizationMetrics` and `RawMapMetrics` are the per-query and per-map raw inputs. `Features.compute(localization, map_metrics)` produces a 10-feature vector: `log1p` of inlier / match / image / point counts, `reproj_error_median / image_diagonal`, plus four ratios and diversities. `ToleranceModel` carries `logistic_weights` (Features-shaped), `logistic_intercept`, and an isotonic table (`isotonic_x_breakpoints`, `isotonic_y_breakpoints`). `CalibrationArtifact` bundles a `tight` and `loose` `ToleranceModel`, `sigma_meas_alpha` / `sigma_meas_beta` for the measurement-covariance scaling `Sigma_meas = alpha * Sigma_pnp + beta * I_6`, `loose_min` / `tight_min` floors, and version metadata. `load_global_calibration(path, expected_pipeline_version)` raises `CalibrationLoadError` with a remediation message if the file is missing, the schema version mismatches, or the pipeline version mismatches. `apply_global_calibration(calibration, features)` returns `(tight, loose, True)` — the third element is always literal `True`.
39
+
40
+ **Inference glue and typing shims (torch-aware).**
41
+
42
+ - `lightglue.py` — wraps `lightglue.LightGlue`. `Keypoints` / `Descriptors` `NewType`s over `dict[str, Tensor]` (and `*Arrays` variants for numpy input) brand the matcher's positional arguments so pyright catches keypoints/descriptors swaps. `lightglue_match` batches pairs, pads variable-length keypoint sequences, masks out `-1` non-matches.
43
+ - `model_wrappers.py` — four thin closures around a "model" callable: extract global descriptor, extract local features, run the matcher on tensors, run the matcher on numpy arrays. `RetrievalDim`, `NumKeypoints`, `LocalDescDim` `NewType` brands.
44
+ - `tensor_types.py` — `TT[*Shape]`: a generic subclass of `torch.Tensor` at type-check time, collapsed to plain `torch.Tensor` at runtime via `__class_getitem__`. The runtime collapse is required because `TT[Shape...]` must evaluate inside `cast()` calls and module-level tuple aliases.
45
+ - `numpy_ops.py` — shape-typed re-exports of `numpy.zeros` (overloaded for 1D / 2D / 3D), `nonzero`, `compress`, propagating dimension brands through PEP 695 generics.
46
+
47
+ **Stub.**
48
+
49
+ - `main.py` — six-line `print("Hello from core!")`. `uv init` scaffolding; not registered as a script.
50
+
51
+ ### Consumer map
52
+
53
+ Ranked by import volume:
54
+
55
+ localizer (30 sites) -- localize, build_metrics, map, main, schemas, torch_ops, tests
56
+ reconstructor (22) -- run_reconstruction, rig, main, metrics_builder, options_builder, tests
57
+ api (13) -- routers/{localization, reconstructions, leases, capture_sessions}
58
+ zed-capture (4) -- zed/zed.py (manifest writer)
59
+ scripts -- fit_calibration, tune_reconstruction
60
+
61
+ The most-imported leaves are `axis_convention` and `calibration` (8 sites each), `reconstruction_metrics` (7), then `transform`, `capture_session_manifest`, and `camera_config` (6 each).
62
+
63
+ ### Map / query contract
64
+
65
+ The reason this package is the hub of the stack: every artifact the reconstructor produces is read back by the localizer, byte-for-byte. The constants that pin the contract live in core:
66
+
67
+ reconstructor localizer
68
+ ------------- ---------
69
+ h5.write_global_descriptors --> h5.read_global_descriptors
70
+ h5.write_features --> h5.read_features
71
+ opq.{train,encode,write} --> opq.{read,decode}
72
+ image_preprocess.canonicalize --> image_preprocess.canonicalize (same constants, both sides)
73
+ image_preprocess.tile_image --> image_preprocess.tile_image
74
+ model_wrappers.RetrievalDim --> model_wrappers.RetrievalDim (phantom shape brand)
75
+
76
+ Flipping `LOCAL_FEATURE_RESIZE_SHORTER_SIDE`, `RETRIEVAL_TILE_OVERLAP_FRACTION`, an HDF5 dataset name, or an OPQ file name in core without rebuilding every existing map silently degrades retrieval quality without raising.
77
+
78
+ ## Constraints
79
+
80
+ **One flat package, no `__init__.py` re-exports.** Every consumer reaches into a leaf module, which makes the dependency graph immediately legible from import lines alone: `from placeframe_core.opq import decode_descriptors` says exactly what the consumer touches. The cost is verbose import blocks at the call site (the localizer's `localize.py` imports from `placeframe_core.model_wrappers` on two separate lines); the benefit is no parallel public-surface drift between `__init__.py` and the leaves.
81
+
82
+ **Pydantic on the schema side, pure numpy/torch on the math side.** Schemas need OpenAPI-generability and JSON round-tripping (they end up in C# via `generate-clients` and in Postgres JSONB via the API). Math functions need numpy-array-in / numpy-array-out so they compose with both the reconstructor's training loop and the localizer's per-query path with no Pydantic overhead. The two halves of the package never wrap each other.
83
+
84
+ **`AxisConvention` is a tag, not a dispatch.** The enum lives on `CaptureSessionManifest` and labels the producer's coordinate system, but core does not branch on it internally — callers do (the reconstructor's `rig.py` calls `change_basis_opencv_from_unity_pose` when the manifest is `UNITY`). Keeping the dispatch out of core means consumers can localize their own coordinate logic without paying for an indirection on every pose.
85
+
86
+ **Quaternion order is xyzw everywhere.** Matches `scipy.spatial.transform.Rotation.from_quat`'s default and the Unity / glTF convention. No `Float4`-with-wxyz ambiguity.
87
+
88
+ **`tensor_types.TT[*Shape]` runtime-collapses to `torch.Tensor`.** PEP 695 generic-class syntax is type-checker only, but `TT[Shape...]` appears inside `cast()` calls and module-level tuple aliases that *do* evaluate at runtime. `__class_getitem__` returning plain `torch.Tensor` is what lets both `cast(TT[NumKeypoints, LocalDescDim], ...)` and `LocalFeatureOutput = tuple[TT[NumKeypoints, Literal[2]], TT[NumKeypoints, LocalDescDim]]` work. The same pattern is in `numpy_ops` for ndarray shape brands.
89
+
90
+ **`NewType` brands on phantom dimension types (`RetrievalDim`, `NumImages`, `MaxTiles`, `NumQueryTiles`, `NumKeypoints`, `LocalDescDim`).** They cost nothing at runtime and let pyright catch axis-order mistakes when the reconstructor's `(NumImages, MaxTiles, RetrievalDim)` tile array is sliced or transposed against the localizer's expectation. The same brand-typing trick is applied to the matcher's positional arguments (`Keypoints` vs. `Descriptors`).
91
+
92
+ **Manifest fields default to `Optional[X] = None`.** Reconstruction is incremental: the reconstructor fills `ReconstructionMetrics` field by field during a multi-minute run, and the API accepts partial `ReconstructionOptions` blobs from clients that don't want to specify every COLMAP knob. The cost is that downstream readers must None-check; the alternative — fresh-write defaults — would either fabricate misleading numbers or split the model into write-time and read-time variants.
93
+
94
+ **The torch / lightglue dependency lives in core but is not declared in `pyproject.toml`.** A `DEP003` deptry exemption documents the asymmetry. The canonical PyTorch dep lives in `neural-networks` behind conflicting `cpu` / `cuda` / `rocm` extras, so declaring bare `torch` here would conflict. Consumers that touch the torch-aware modules (`lightglue`, `model_wrappers`, `tensor_types`) must also depend on `neural-networks` with the matching extra; the API does not depend on `neural-networks` and never imports those modules.
95
+
96
+ **Calibration is the only place with a real algorithm and the only place with a real version check.** `load_global_calibration` raises loudly on schema or pipeline mismatch. Every other versioned thing in core (notably `MANIFEST_VERSION`) trusts the caller. The asymmetry is deliberate where the calibration artifact is concerned — a wrong-version calibration silently miscalibrates every confidence in production — but is a known gap for the manifest path.
97
+
98
+ ## See also
99
+
100
+ - `docker/AGENTS.md` -- the service mesh that consumes these types. Core is the vocabulary on the arrows between services; that doc describes the arrows.
101
+ - `scripts/src/scripts/fit_calibration.py` -- the producer of `docker/localizer/calibration/global.json`. Reads `placeframe_core.calibration`, `placeframe_core.capture_session_manifest`, and `placeframe_core.localization_metrics`'s defaults.
102
+ - `packages/generated/` -- the OpenAPI client packages (Python and C#) generated from API routes that respond with `placeframe_core` schemas. A schema change here requires running `generate-clients`.
@@ -0,0 +1 @@
1
+ @AGENTS.md
@@ -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,5 @@
1
+ # placeframe-core
2
+
3
+ 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`.
4
+
5
+ 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,6 @@
1
+ def main():
2
+ print("Hello from core!")
3
+
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,32 @@
1
+ [project]
2
+ name = "placeframe-core"
3
+ version = "0.1.0.dev35547537192"
4
+ description = "Placeframe domain logic: camera configs, coordinate transforms, metrics"
5
+ readme = "README.md"
6
+ license = "Apache-2.0"
7
+ requires-python = ">=3.13"
8
+ dependencies = [
9
+ "scipy>=1.16.3",
10
+ "faiss-cpu>=1.12.0",
11
+ "pillow>=12.0.0",
12
+ "numpy>=2.4.0",
13
+ "pydantic>=2.12.5",
14
+ "h5py>=3.15.1",
15
+ ]
16
+
17
+ [dependency-groups]
18
+ dev = ["scipy-stubs>=1.16.3.0"]
19
+
20
+ [tool.deptry.per_rule_ignores]
21
+ # Code in this package directly imports 'torch', but cannot directly depend on torch, because torch has multiple conflicting
22
+ # platform-specific variants that are declared using 'extras' in the 'neural-networks' package, and declaring bare 'torch'
23
+ # directly in this package would lead to dependency conflicts; we therefore must ignore deptry's check for this specific package
24
+ DEP003 = ["torch", "lightglue"]
25
+
26
+ [build-system]
27
+ requires = ["hatchling"]
28
+ build-backend = "hatchling.build"
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["src/placeframe_core"]
32
+ include = ["src/placeframe_core/py.typed"]
@@ -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
@@ -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))
@@ -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)))
@@ -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