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 +47 -0
- ds_msp/adapt/__init__.py +7 -0
- ds_msp/adapt/convert.py +93 -0
- ds_msp/adapt/evaluate.py +54 -0
- ds_msp/adapt/sampling.py +24 -0
- ds_msp/calib/__init__.py +19 -0
- ds_msp/calib/bundle.py +147 -0
- ds_msp/calib/detect.py +99 -0
- ds_msp/calib/targets.py +99 -0
- ds_msp/core/__init__.py +14 -0
- ds_msp/core/contracts.py +120 -0
- ds_msp/core/pinhole.py +30 -0
- ds_msp/cv.py +296 -0
- ds_msp/io/__init__.py +17 -0
- ds_msp/io/kalibr.py +131 -0
- ds_msp/ldc.py +193 -0
- ds_msp/model.py +460 -0
- ds_msp/models/__init__.py +27 -0
- ds_msp/models/double_sphere.py +115 -0
- ds_msp/models/ds_math.py +169 -0
- ds_msp/models/eucm.py +96 -0
- ds_msp/models/eucm_math.py +93 -0
- ds_msp/models/kb.py +105 -0
- ds_msp/models/kb_math.py +132 -0
- ds_msp/models/ocam.py +105 -0
- ds_msp/models/ocam_math.py +169 -0
- ds_msp/models/radtan.py +94 -0
- ds_msp/models/radtan_math.py +123 -0
- ds_msp/models/ucm.py +91 -0
- ds_msp/models/ucm_math.py +95 -0
- ds_msp/ops/__init__.py +6 -0
- ds_msp/ops/pose.py +50 -0
- ds_msp/ops/undistort.py +89 -0
- ds_msp/testing.py +204 -0
- ds_msp/utils.py +130 -0
- ds_msp-0.3.0.dist-info/METADATA +508 -0
- ds_msp-0.3.0.dist-info/RECORD +40 -0
- ds_msp-0.3.0.dist-info/WHEEL +5 -0
- ds_msp-0.3.0.dist-info/licenses/LICENSE +21 -0
- ds_msp-0.3.0.dist-info/top_level.txt +1 -0
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
|
+
|
ds_msp/adapt/__init__.py
ADDED
ds_msp/adapt/convert.py
ADDED
|
@@ -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
|
ds_msp/adapt/evaluate.py
ADDED
|
@@ -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
|
ds_msp/adapt/sampling.py
ADDED
|
@@ -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)
|
ds_msp/calib/__init__.py
ADDED
|
@@ -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
|
ds_msp/calib/targets.py
ADDED
|
@@ -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
|
ds_msp/core/__init__.py
ADDED
|
@@ -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"]
|