placeframe-core 0.1.0.dev35547537192__py3-none-any.whl

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