ds-msp 0.3.0__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.
ds_msp/__init__.py ADDED
@@ -0,0 +1,47 @@
1
+ from .core.contracts import CameraModel
2
+ from .model import (
3
+ DoubleSphereCamera,
4
+ ds_project,
5
+ ds_unproject,
6
+ undistort_fisheye,
7
+ solve_pnp_fisheye,
8
+ )
9
+ from .models import (
10
+ DoubleSphereModel,
11
+ EUCMModel,
12
+ KannalaBrandtModel,
13
+ OCamModel,
14
+ RadTanModel,
15
+ UCMModel,
16
+ )
17
+ from .adapt import convert
18
+ from .ops import Undistorter, solve_pnp
19
+ from .cv import (
20
+ projectPoints,
21
+ undistortPoints,
22
+ distortPoints,
23
+ initUndistortRectifyMap,
24
+ undistortImage,
25
+ estimateNewCameraMatrixForUndistortRectify,
26
+ solvePnP
27
+ )
28
+ from .ldc import (
29
+ TI_LDC_MeshGenerator,
30
+ TI_LDC_PointUndistorter
31
+ )
32
+
33
+ # Public API (re-exported above). Listing it here documents the surface and tells
34
+ # linters these imports are intentional re-exports, not dead code.
35
+ __all__ = [
36
+ "CameraModel",
37
+ "DoubleSphereCamera", "ds_project", "ds_unproject",
38
+ "undistort_fisheye", "solve_pnp_fisheye",
39
+ "DoubleSphereModel", "EUCMModel", "KannalaBrandtModel",
40
+ "OCamModel", "RadTanModel", "UCMModel",
41
+ "convert", "Undistorter", "solve_pnp",
42
+ "projectPoints", "undistortPoints", "distortPoints",
43
+ "initUndistortRectifyMap", "undistortImage",
44
+ "estimateNewCameraMatrixForUndistortRectify", "solvePnP",
45
+ "TI_LDC_MeshGenerator", "TI_LDC_PointUndistorter",
46
+ ]
47
+
@@ -0,0 +1,7 @@
1
+ """Model conversion ("adapter"): convert calibrated params between models."""
2
+
3
+ from .convert import convert
4
+ from .evaluate import reprojection_report
5
+ from .sampling import sample_image_grid
6
+
7
+ __all__ = ["convert", "reprojection_report", "sample_image_grid"]
@@ -0,0 +1,93 @@
1
+ """
2
+ Camera model conversion ("adapter").
3
+
4
+ Converts an already-calibrated source model to a target model **without images or
5
+ recalibration**: sample pixels -> unproject with the source -> linear seed the
6
+ target -> refine with Levenberg-Marquardt using each model's **analytic** param
7
+ Jacobian (no autodiff). Mirrors the fisheye-calib-adapter pipeline in pure Python.
8
+
9
+ Decoupled by dependency injection: ``convert`` takes the source instance and the
10
+ target *class*, so this module imports no concrete model — only the contract and
11
+ SciPy. Works with any model satisfying ``CameraModel``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Optional, Tuple, Type
17
+
18
+ import numpy as np
19
+ from scipy.optimize import least_squares
20
+
21
+ from ..core.contracts import CameraModel
22
+ from .evaluate import reprojection_report
23
+ from .sampling import sample_image_grid
24
+
25
+
26
+ def convert(source: CameraModel, target_cls: Type[CameraModel], *,
27
+ width: int, height: int, n_samples: int = 500,
28
+ max_fov_deg: Optional[float] = None,
29
+ verbose: bool = False) -> Tuple[CameraModel, dict]:
30
+ """Fit ``target_cls`` parameters to reproduce ``source`` over the image.
31
+
32
+ Parameters
33
+ ----------
34
+ source : CameraModel
35
+ The calibrated source model.
36
+ target_cls : type[CameraModel]
37
+ The model class to convert into.
38
+ width, height : int
39
+ Image size used for sampling and reporting.
40
+ n_samples : int
41
+ Approximate number of grid samples used for the fit.
42
+ max_fov_deg : float, optional
43
+ Restrict the fitted FOV (full angle). Useful when the target is narrower
44
+ than the source (e.g. converting a >180 deg fisheye into a pinhole-like
45
+ model) so the fit is not dragged by unrepresentable rays.
46
+
47
+ Returns
48
+ -------
49
+ (target, report) : the fitted model and a quality report (see
50
+ ``reprojection_report``).
51
+ """
52
+ # 1. sample pixels -> source bearing rays (forward hemisphere only)
53
+ pixels = sample_image_grid(width, height, n_samples)
54
+ rays, valid = source.unproject(pixels)
55
+ keep = valid & (rays[:, 2] > 1e-6)
56
+ if max_fov_deg is not None:
57
+ ang = np.degrees(np.arccos(np.clip(rays[:, 2], -1.0, 1.0)))
58
+ keep &= ang <= (max_fov_deg / 2.0)
59
+ rays, pixels = rays[keep], pixels[keep]
60
+ if len(rays) < len(target_cls.param_names):
61
+ raise ValueError(
62
+ f"Too few forward correspondences ({len(rays)}) to fit "
63
+ f"{target_cls.name}; increase n_samples or max_fov_deg."
64
+ )
65
+
66
+ # 2. linear seed: inherit intrinsics from the source, SVD-seed distortion
67
+ target = target_cls.from_params(np.zeros(len(target_cls.param_names)))
68
+ target.initialize_from_correspondences(source.K, rays, pixels)
69
+
70
+ # 3. nonlinear refine: minimize project_target(rays) - pixels, analytic Jac.
71
+ lb, ub = target_cls.param_bounds()
72
+ x0 = np.clip(target.params, lb, ub)
73
+
74
+ def residual(p):
75
+ uv, _ = target_cls.from_params(p).project(rays)
76
+ return (uv - pixels).ravel()
77
+
78
+ def jac(p):
79
+ _, _, j_param, _ = target_cls.from_params(p).project_jacobian(rays)
80
+ return j_param.reshape(-1, p.size)
81
+
82
+ res = least_squares(residual, x0, jac=jac, bounds=(lb, ub),
83
+ method="trf", x_scale="jac",
84
+ verbose=2 if verbose else 0)
85
+ target = target_cls.from_params(res.x)
86
+
87
+ # 4. evaluate over the (possibly FOV-restricted) image region
88
+ report = reprojection_report(source, target, width, height,
89
+ max_fov_deg=max_fov_deg, gt_params=None)
90
+ report["converged"] = bool(res.success)
91
+ report["source_model"] = source.name
92
+ report["target_model"] = target_cls.name
93
+ return target, report
@@ -0,0 +1,54 @@
1
+ """
2
+ Conversion quality evaluation (pure numpy + the CameraModel contract).
3
+
4
+ Reprojection Error (RE) = || project_target(unproject_source(u)) - u ||, plus
5
+ FOV coverage so lossy conversions (e.g. fisheye -> pinhole) are visible, never
6
+ silent.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Optional
12
+
13
+ import numpy as np
14
+
15
+ from .sampling import sample_image_grid
16
+
17
+
18
+ def reprojection_report(source, target, width: int, height: int,
19
+ n_samples: int = 2000,
20
+ max_fov_deg: Optional[float] = None,
21
+ gt_params: Optional[np.ndarray] = None) -> dict:
22
+ """Measure how well ``target`` reproduces ``source`` over the image.
23
+
24
+ Returns a dict with rms/max/median pixel error, sample counts, FOV coverage,
25
+ and (if ``gt_params`` given) parameter error. ``max_fov_deg`` restricts the
26
+ evaluated region to match a narrower target (e.g. pinhole/RadTan), so the
27
+ report reflects the region the target is meant to cover rather than being
28
+ dominated by unrepresentable peripheral rays.
29
+ """
30
+ pixels = sample_image_grid(width, height, n_samples)
31
+ rays, valid = source.unproject(pixels)
32
+ keep = valid & (rays[:, 2] > 1e-6)
33
+ if max_fov_deg is not None:
34
+ ang_all = np.degrees(np.arccos(np.clip(rays[:, 2], -1.0, 1.0)))
35
+ keep &= ang_all <= (max_fov_deg / 2.0)
36
+ pixels_k, rays_k = pixels[keep], rays[keep]
37
+
38
+ uv, vt = target.project(rays_k)
39
+ ok = vt
40
+ err = np.linalg.norm(uv[ok] - pixels_k[ok], axis=1)
41
+
42
+ ang = np.degrees(np.arccos(np.clip(rays_k[:, 2], -1.0, 1.0)))
43
+ report = {
44
+ "rms_px": float(np.sqrt(np.mean(err ** 2))) if err.size else float("nan"),
45
+ "max_px": float(err.max()) if err.size else float("nan"),
46
+ "median_px": float(np.median(err)) if err.size else float("nan"),
47
+ "n_sampled": int(len(pixels)),
48
+ "n_forward": int(keep.sum()),
49
+ "n_target_valid": int(ok.sum()),
50
+ "fov_covered_deg": float(2.0 * ang.max()) if ang.size else float("nan"),
51
+ }
52
+ if gt_params is not None:
53
+ report["param_error"] = float(np.linalg.norm(np.asarray(gt_params) - target.params))
54
+ return report
@@ -0,0 +1,24 @@
1
+ """
2
+ Sampling strategies for model conversion.
3
+
4
+ Pure numpy. Produces the pixel/ray correspondences the converter fits against.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+
11
+
12
+ def sample_image_grid(width: int, height: int, n_samples: int = 500) -> np.ndarray:
13
+ """Regular grid of pixel centers, aspect-ratio preserving (FCA-style).
14
+
15
+ Returns ``(M, 2)`` float64 pixels with ``M ≈ n_samples``.
16
+ """
17
+ nx = max(2, int(round(np.sqrt(n_samples * width / height))))
18
+ ny = max(2, int(round(np.sqrt(n_samples * height / width))))
19
+ cw = width / nx
20
+ ch = height / ny
21
+ xs = (np.arange(nx) + 0.5) * cw
22
+ ys = (np.arange(ny) + 0.5) * ch
23
+ gx, gy = np.meshgrid(xs, ys, indexing="xy")
24
+ return np.stack([gx.ravel(), gy.ravel()], axis=-1).astype(np.float64)
@@ -0,0 +1,19 @@
1
+ """Generic calibration (bundle adjustment) for any CameraModel.
2
+
3
+ ``calibrate`` and ``AprilGridTarget`` are dependency-light. ``detect_aprilgrid``
4
+ needs the optional ``aprilgrid`` backend (``pip install ds_msp[calib]``) and is
5
+ imported lazily so this package stays importable without it.
6
+ """
7
+
8
+ from .bundle import calibrate
9
+ from .targets import AprilGridTarget
10
+
11
+ __all__ = ["calibrate", "AprilGridTarget", "detect_aprilgrid"]
12
+
13
+
14
+ def __getattr__(name: str):
15
+ # Lazy: only pull in the OpenCV+aprilgrid detection adapter on demand.
16
+ if name == "detect_aprilgrid":
17
+ from .detect import detect_aprilgrid
18
+ return detect_aprilgrid
19
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
ds_msp/calib/bundle.py ADDED
@@ -0,0 +1,147 @@
1
+ """
2
+ Generic bundle-adjustment calibration for ANY camera model.
3
+
4
+ Given checkerboard correspondences and an initial model (for intrinsics seed +
5
+ the model type), jointly refines intrinsics and per-image extrinsics by
6
+ Levenberg-Marquardt using the model's **analytic** projection Jacobian (no
7
+ autodiff). Works for DS/UCM/EUCM/KB/RadTan or any ``CameraModel``.
8
+
9
+ Decoupled: imports only the contract + SciPy/OpenCV; the model type comes from
10
+ the injected ``init_model`` (no concrete-model import).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Dict, List
16
+
17
+ import cv2
18
+ import numpy as np
19
+ from scipy.optimize import least_squares
20
+
21
+ from ..core.contracts import CameraModel
22
+
23
+
24
+ def _seed_poses(init_model, X_world_list, keypoints_list, visibility_list):
25
+ rvecs, tvecs = [], []
26
+ for Xw, uv, vis in zip(X_world_list, keypoints_list, visibility_list):
27
+ Xv = Xw[vis].astype(np.float64)
28
+ uvv = uv[vis].astype(np.float64)
29
+ rays, vr = init_model.unproject(uvv)
30
+ use = vr & (rays[:, 2] > 1e-6)
31
+ if use.sum() >= 4:
32
+ pn = rays[use, :2] / rays[use, 2:3]
33
+ ok, rv, tv = cv2.solvePnP(Xv[use], pn, np.eye(3), None)
34
+ if ok:
35
+ rvecs.append(rv.ravel())
36
+ tvecs.append(tv.ravel())
37
+ continue
38
+ rvecs.append(np.zeros(3))
39
+ tvecs.append(np.array([0.0, 0.0, 1.5]))
40
+ return rvecs, tvecs
41
+
42
+
43
+ def calibrate(init_model: CameraModel,
44
+ X_world_list: List[np.ndarray],
45
+ keypoints_list: List[np.ndarray],
46
+ visibility_list: List[np.ndarray],
47
+ *, max_nfev: int = 200, verbose: int = 0,
48
+ loss: str = "linear", f_scale: float = 1.0) -> Dict:
49
+ """Calibrate any model from checkerboard correspondences.
50
+
51
+ Parameters
52
+ ----------
53
+ loss : str
54
+ Robust loss for the least-squares solve (SciPy ``least_squares`` kernels):
55
+ ``"linear"`` (plain L2), ``"huber"``, ``"soft_l1"``, ``"cauchy"``. A robust
56
+ kernel keeps *every* corner but **down-weights** large residuals instead of
57
+ letting one mis-localized corner drag the L2 fit — the right tool when a few
58
+ peripheral corners are mis-detected. Prefer this over hard outlier dropping.
59
+ f_scale : float
60
+ Residual scale (px) at which down-weighting kicks in; residuals below it stay
61
+ ~quadratic. ~1 px is sensible for sub-pixel-targeted corner detection.
62
+
63
+ Returns a dict ``{model, poses, rms_px, success}`` where ``poses`` is a list of
64
+ ``(rvec, tvec)`` per image and ``rms_px`` is the **true** reprojection RMS over
65
+ valid observations (independent of ``loss``, so it stays comparable across kernels).
66
+ """
67
+ cls = type(init_model)
68
+ P = len(cls.param_names)
69
+ n_img = len(X_world_list)
70
+ sizes = [len(X) for X in X_world_list]
71
+
72
+ rvecs, tvecs = _seed_poses(init_model, X_world_list, keypoints_list, visibility_list)
73
+ x0 = np.concatenate([init_model.params]
74
+ + [np.concatenate([r, t]) for r, t in zip(rvecs, tvecs)])
75
+
76
+ lb_i, ub_i = cls.param_bounds()
77
+ ext_lb = np.array([-np.pi, -np.pi, -np.pi, -10.0, -10.0, 1e-3])
78
+ ext_ub = np.array([np.pi, np.pi, np.pi, 10.0, 10.0, 50.0])
79
+ lb = np.concatenate([lb_i] + [ext_lb] * n_img)
80
+ ub = np.concatenate([ub_i] + [ext_ub] * n_img)
81
+ x0 = np.clip(x0, lb, ub)
82
+
83
+ def residual(p):
84
+ m = cls.from_params(p[:P])
85
+ out = []
86
+ off = P
87
+ for Xw, uv, vis in zip(X_world_list, keypoints_list, visibility_list):
88
+ r, t = p[off:off + 3], p[off + 3:off + 6]
89
+ off += 6
90
+ R, _ = cv2.Rodrigues(r)
91
+ Xc = (R @ Xw.T).T + t
92
+ uvp, valid = m.project(Xc)
93
+ diff = np.zeros_like(uv, dtype=np.float64)
94
+ mask = vis & valid
95
+ diff[mask] = uvp[mask] - uv[mask]
96
+ out.append(diff.ravel())
97
+ return np.concatenate(out)
98
+
99
+ def jac(p):
100
+ m = cls.from_params(p[:P])
101
+ J = np.zeros((2 * sum(sizes), P + 6 * n_img), dtype=np.float64)
102
+ row = 0
103
+ off = P
104
+ for i, (Xw, uv, vis) in enumerate(zip(X_world_list, keypoints_list, visibility_list)):
105
+ r = p[off:off + 3]
106
+ t = p[off + 3:off + 6]
107
+ off += 6
108
+ R, jacR = cv2.Rodrigues(r)
109
+ Xc = (R @ Xw.T).T + t
110
+ _, J_point, J_param, valid = m.project_jacobian(Xc)
111
+ mask = (vis & valid)[:, None, None].astype(np.float64)
112
+ dR = jacR.T.reshape(3, 3, 3)
113
+ dXc_dr = np.einsum('abc,nb->nac', dR, Xw)
114
+ J_rvec = np.einsum('nij,njc->nic', J_point, dXc_dr)
115
+ J_ext = np.concatenate([J_rvec, J_point], axis=-1) * mask
116
+ J_par = J_param * mask
117
+ N = sizes[i]
118
+ J[row:row + 2 * N, 0:P] = J_par.reshape(2 * N, P)
119
+ ec = P + 6 * i
120
+ J[row:row + 2 * N, ec:ec + 6] = J_ext.reshape(2 * N, 6)
121
+ row += 2 * N
122
+ return J
123
+
124
+ res = least_squares(residual, x0, jac=jac, bounds=(lb, ub),
125
+ method="trf", x_scale="jac", max_nfev=max_nfev, verbose=verbose,
126
+ loss=loss, f_scale=f_scale)
127
+
128
+ model = cls.from_params(res.x[:P])
129
+ poses = [(res.x[P + 6 * i:P + 6 * i + 3], res.x[P + 6 * i + 3:P + 6 * i + 6])
130
+ for i in range(n_img)]
131
+
132
+ # True reprojection RMS over valid observations. Computed directly (not from
133
+ # res.cost) so it means the same thing under any robust ``loss``: a robust
134
+ # kernel reshapes the cost, but the pixel error of the fit is what we report.
135
+ sq, n = 0.0, 0
136
+ off = P
137
+ for Xw, uv, vis in zip(X_world_list, keypoints_list, visibility_list):
138
+ r, t = res.x[off:off + 3], res.x[off + 3:off + 6]
139
+ off += 6
140
+ R, _ = cv2.Rodrigues(r)
141
+ uvp, valid = model.project((R @ Xw.T).T + t)
142
+ m = vis & valid
143
+ d = uvp[m] - uv[m]
144
+ sq += float((d * d).sum())
145
+ n += int(m.sum())
146
+ rms = float(np.sqrt(sq / n)) if n else float("nan")
147
+ return {"model": model, "poses": poses, "rms_px": rms, "success": bool(res.success)}
ds_msp/calib/detect.py ADDED
@@ -0,0 +1,99 @@
1
+ """
2
+ AprilGrid detection adapter — pixels in, tag corners out.
3
+
4
+ The one place in the library that depends on an AprilTag backend. It wraps the
5
+ pure-Python ``aprilgrid`` detector (``pip install ds_msp[calib]``) and OpenCV for
6
+ image loading + subpixel corner refinement, and hands back per-image
7
+ ``{tag_id: (4, 2)}`` dictionaries that ``AprilGridTarget.build_correspondences``
8
+ turns into calibration inputs. Isolating the heavy/optional dependency here keeps
9
+ the rest of ``ds_msp`` installable and importable without it.
10
+
11
+ **Why a dedicated AprilGrid backend and not OpenCV's aruco or apriltag3?**
12
+ Kalibr-style boards (TUM-VI, EuRoC, …) print each tag with a **2-cell black
13
+ border**; stock AprilTag-3 / aruco assume a **1-cell** border, locate the tag quad
14
+ but then sample the code bits at the wrong places and decode nothing. The
15
+ ``aprilgrid`` package defaults to the 2-cell border, which is why it succeeds where
16
+ the others silently return zero detections.
17
+
18
+ Imports: numpy + OpenCV always; ``aprilgrid`` lazily (only when you detect).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import Dict, List, Sequence
24
+
25
+ import cv2
26
+ import numpy as np
27
+
28
+ _SUBPIX_CRITERIA = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.01)
29
+
30
+
31
+ def _load_gray_u8(path: str) -> np.ndarray:
32
+ """Load an image as contiguous 8-bit grayscale (TUM-VI ships 16-bit PNGs)."""
33
+ img = cv2.imread(path, cv2.IMREAD_UNCHANGED)
34
+ if img is None:
35
+ raise FileNotFoundError(path)
36
+ if img.ndim == 3:
37
+ img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
38
+ if img.dtype != np.uint8:
39
+ img = (img.astype(np.float64) / 256.0).clip(0, 255).astype(np.uint8)
40
+ return np.ascontiguousarray(img)
41
+
42
+
43
+ def _make_detector(family: str):
44
+ try:
45
+ from aprilgrid import Detector # optional dependency
46
+ except ImportError as exc: # pragma: no cover - import-guard
47
+ raise ImportError(
48
+ "AprilGrid detection needs the 'aprilgrid' package. Install it with:\n"
49
+ " pip install ds_msp[calib] (or: pip install aprilgrid)\n"
50
+ "It defaults to Kalibr's 2-cell tag border, which AprilTag-3/aruco do not."
51
+ ) from exc
52
+ return Detector(family)
53
+
54
+
55
+ def detect_aprilgrid(
56
+ image_paths: Sequence[str], *,
57
+ family: str = "t36h11", min_tags: int = 6, refine: bool = True,
58
+ subpix_window: int = 5,
59
+ ) -> List[Dict[int, np.ndarray]]:
60
+ """Detect AprilGrid tags in a list of images.
61
+
62
+ Parameters
63
+ ----------
64
+ image_paths : sequence of str
65
+ Paths to the calibration frames.
66
+ family : str
67
+ AprilTag family of the board (TUM-VI / Kalibr default is ``"t36h11"``).
68
+ min_tags : int
69
+ Skip frames where fewer than this many tags are found (a near-empty frame
70
+ contributes little and risks outliers).
71
+ refine : bool
72
+ Apply ``cv2.cornerSubPix`` to each corner. The raw detector localizes to
73
+ ~pixel; subpixel refinement is what brings calibration RMS from ~0.6 px to
74
+ ~0.2 px (Kalibr does the same).
75
+
76
+ Returns
77
+ -------
78
+ list of dict
79
+ One ``{tag_id: (4, 2) float64}`` per *kept* frame, corners in the detector's
80
+ order (bottom-left, bottom-right, top-right, top-left) — matching
81
+ ``AprilGridTarget.object_points``.
82
+ """
83
+ detector = _make_detector(family)
84
+ w = (subpix_window, subpix_window)
85
+ out: List[Dict[int, np.ndarray]] = []
86
+ for path in image_paths:
87
+ gray = _load_gray_u8(path)
88
+ dets = detector.detect(gray)
89
+ if len(dets) < min_tags:
90
+ continue
91
+ frame: Dict[int, np.ndarray] = {}
92
+ for d in dets:
93
+ corners = np.ascontiguousarray(
94
+ np.asarray(d.corners, dtype=np.float32).reshape(4, 1, 2))
95
+ if refine:
96
+ cv2.cornerSubPix(gray, corners, w, (-1, -1), _SUBPIX_CRITERIA)
97
+ frame[int(d.tag_id)] = corners.reshape(4, 2).astype(np.float64)
98
+ out.append(frame)
99
+ return out
@@ -0,0 +1,99 @@
1
+ """
2
+ Calibration target geometry — the 3D model of the board you photograph.
3
+
4
+ Pure NumPy, no OpenCV, no detector. An ``AprilGridTarget`` knows where every tag
5
+ corner sits in 3D board coordinates (metres), and turns per-image tag detections
6
+ into the ``(X_world, keypoints, visibility)`` correspondence lists that
7
+ ``ds_msp.calib.bundle.calibrate`` consumes. Detection (which needs OpenCV + an
8
+ AprilTag backend) lives separately in ``detect.py``; this module stays dependency
9
+ -light so the board math can be imported and unit-tested on its own.
10
+
11
+ Imports: numpy only.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import List, Mapping, Tuple
17
+
18
+ import numpy as np
19
+
20
+
21
+ class AprilGridTarget:
22
+ """A Kalibr-style AprilGrid: a ``rows x cols`` grid of AprilTags.
23
+
24
+ Geometry follows Kalibr's convention exactly so detections from a board
25
+ calibrated by Kalibr/Basalt line up:
26
+
27
+ - Tag ids run row-major from the bottom-left corner: ``id = row * cols + col``.
28
+ - Each tag's four corners are ordered **counter-clockwise starting bottom-left**
29
+ ``(BL, BR, TR, TL)`` — the same order the AprilGrid detector returns them.
30
+ - Tags are squares of side ``tag_size`` (metres) separated by a gap of
31
+ ``tag_spacing * tag_size`` (``tag_spacing`` is Kalibr's gap/size ratio).
32
+
33
+ ``tag_size`` only sets absolute scale, which affects the recovered *extrinsic*
34
+ translations, not the intrinsics — so a slightly wrong board size still yields
35
+ correct ``fx, fy, cx, cy`` and distortion.
36
+ """
37
+
38
+ def __init__(self, tag_rows: int = 6, tag_cols: int = 6,
39
+ tag_size: float = 0.088, tag_spacing: float = 0.3) -> None:
40
+ self.tag_rows = int(tag_rows)
41
+ self.tag_cols = int(tag_cols)
42
+ self.tag_size = float(tag_size)
43
+ self.tag_spacing = float(tag_spacing)
44
+
45
+ @property
46
+ def n_tags(self) -> int:
47
+ return self.tag_rows * self.tag_cols
48
+
49
+ def object_points(self, tag_id: int) -> np.ndarray:
50
+ """3D board coordinates (metres) of one tag's 4 corners, ``(4, 3)``.
51
+
52
+ Order matches the detector: bottom-left, bottom-right, top-right, top-left.
53
+ """
54
+ if not 0 <= tag_id < self.n_tags:
55
+ raise ValueError(f"tag_id {tag_id} out of range [0, {self.n_tags})")
56
+ row, col = divmod(tag_id, self.tag_cols)
57
+ pitch = self.tag_size * (1.0 + self.tag_spacing)
58
+ s = self.tag_size
59
+ x0, y0 = col * pitch, row * pitch
60
+ return np.array([[x0, y0, 0.0],
61
+ [x0 + s, y0, 0.0],
62
+ [x0 + s, y0 + s, 0.0],
63
+ [x0, y0 + s, 0.0]], dtype=np.float64)
64
+
65
+ def all_object_points(self) -> np.ndarray:
66
+ """Every corner of the board, ``(n_tags * 4, 3)`` in tag-id order."""
67
+ return np.concatenate([self.object_points(t) for t in range(self.n_tags)])
68
+
69
+ def build_correspondences(
70
+ self, detections_per_image: List[Mapping[int, np.ndarray]],
71
+ *, min_corners: int = 8,
72
+ ) -> Tuple[List[np.ndarray], List[np.ndarray], List[np.ndarray]]:
73
+ """Turn per-image ``{tag_id: (4, 2) pixels}`` into calibration inputs.
74
+
75
+ Returns ``(X_world_list, keypoints_list, visibility_list)`` ready for
76
+ ``ds_msp.calib.bundle.calibrate``. Images with fewer than ``min_corners``
77
+ detected corners are dropped (too few to constrain a pose).
78
+ """
79
+ X_world_list: List[np.ndarray] = []
80
+ keypoints_list: List[np.ndarray] = []
81
+ visibility_list: List[np.ndarray] = []
82
+ for det in detections_per_image:
83
+ obj, pix = [], []
84
+ for tag_id, corners in det.items():
85
+ corners = np.asarray(corners, dtype=np.float64).reshape(-1, 2)
86
+ if corners.shape[0] != 4:
87
+ continue
88
+ obj.append(self.object_points(int(tag_id)))
89
+ pix.append(corners)
90
+ if not obj:
91
+ continue
92
+ X = np.concatenate(obj)
93
+ uv = np.concatenate(pix)
94
+ if len(X) < min_corners:
95
+ continue
96
+ X_world_list.append(X)
97
+ keypoints_list.append(uv)
98
+ visibility_list.append(np.ones(len(X), dtype=bool))
99
+ return X_world_list, keypoints_list, visibility_list
@@ -0,0 +1,14 @@
1
+ """Core contracts and shared primitives (dependency-free foundation layer)."""
2
+
3
+ from .contracts import (
4
+ CameraModel,
5
+ Params,
6
+ Pixels,
7
+ Points3D,
8
+ Rays,
9
+ Valid,
10
+ )
11
+ from .pinhole import balanced_pinhole_K
12
+
13
+ __all__ = ["CameraModel", "Points3D", "Pixels", "Rays", "Valid", "Params",
14
+ "balanced_pinhole_K"]