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.
- placeframe_core-0.1.0.dev35547537192/.gitignore +35 -0
- placeframe_core-0.1.0.dev35547537192/AGENTS.md +102 -0
- placeframe_core-0.1.0.dev35547537192/CLAUDE.md +1 -0
- placeframe_core-0.1.0.dev35547537192/PKG-INFO +19 -0
- placeframe_core-0.1.0.dev35547537192/README.md +5 -0
- placeframe_core-0.1.0.dev35547537192/main.py +6 -0
- placeframe_core-0.1.0.dev35547537192/pyproject.toml +32 -0
- placeframe_core-0.1.0.dev35547537192/src/placeframe_core/axis_convention.py +48 -0
- placeframe_core-0.1.0.dev35547537192/src/placeframe_core/calibration.py +158 -0
- placeframe_core-0.1.0.dev35547537192/src/placeframe_core/camera_config.py +20 -0
- placeframe_core-0.1.0.dev35547537192/src/placeframe_core/capture_session_manifest.py +26 -0
- placeframe_core-0.1.0.dev35547537192/src/placeframe_core/h5.py +62 -0
- placeframe_core-0.1.0.dev35547537192/src/placeframe_core/image_preprocess.py +88 -0
- placeframe_core-0.1.0.dev35547537192/src/placeframe_core/lightglue.py +90 -0
- placeframe_core-0.1.0.dev35547537192/src/placeframe_core/localization_metrics.py +25 -0
- placeframe_core-0.1.0.dev35547537192/src/placeframe_core/model_wrappers.py +69 -0
- placeframe_core-0.1.0.dev35547537192/src/placeframe_core/numpy_ops.py +33 -0
- placeframe_core-0.1.0.dev35547537192/src/placeframe_core/opq.py +83 -0
- placeframe_core-0.1.0.dev35547537192/src/placeframe_core/py.typed +0 -0
- placeframe_core-0.1.0.dev35547537192/src/placeframe_core/reconstruction_manifest.py +14 -0
- placeframe_core-0.1.0.dev35547537192/src/placeframe_core/reconstruction_metrics.py +86 -0
- placeframe_core-0.1.0.dev35547537192/src/placeframe_core/reconstruction_options.py +80 -0
- placeframe_core-0.1.0.dev35547537192/src/placeframe_core/tensor_types.py +17 -0
- 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,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)))
|
|
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
|