ds_msp.mvg¶
Multi-view geometry on bearing vectors — essential matrix estimation, RANSAC relative pose, cheirality/decomposition, and triangulation, all ray-based so they work identically for any central camera. See Two-view geometry on rays.
ds_msp.mvg ¶
Multi-view geometry on bearing vectors.
A fisheye measures rays, not pixels on a plane, so two-view geometry for a wide-FOV
camera is done on unit bearing vectors — cam.unproject(pixels) — and never touches a
pinhole. The calibrated epipolar constraint f2ᵀ E f1 = 0 holds for any central
model, so the eight-point algorithm, pose recovery, and triangulation below work the same
for Double Sphere, UCM, EUCM, Kannala-Brandt, … — that is the whole point.
This layer is pure NumPy and model-agnostic: feed it rays, get relative pose and 3D points.
angular_reprojection_error ¶
angular_reprojection_error(f1: ndarray, f2: ndarray, R: ndarray, t: ndarray, X: ndarray) -> np.ndarray
Per-point reprojection error in degrees: max of the two view angles.
View 1 predicts direction X; view 2 predicts R @ X + t. Each predicted direction is
compared, as an angle, to the corresponding observed ray — the model-free, FOV-independent
error metric :func:refine_two_view minimizes (see the tangent-plane construction in
:func:refine_two_view's docstring).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
f1
|
(N, 3) ndarray
|
Observed unit (or non-unit; renormalized internally) bearing vectors in camera 1 and camera 2. |
required |
f2
|
(N, 3) ndarray
|
Observed unit (or non-unit; renormalized internally) bearing vectors in camera 1 and camera 2. |
required |
R
|
(3, 3), (3,) ndarray
|
Relative pose, camera 1 to camera 2: |
required |
t
|
(3, 3), (3,) ndarray
|
Relative pose, camera 1 to camera 2: |
required |
X
|
(N, 3) ndarray
|
Candidate 3D points in the camera-1 frame (e.g. from :func: |
required |
Returns:
| Type | Description |
|---|---|
(N,) ndarray
|
Per-point error in degrees: |
Source code in ds_msp/mvg/bundle.py
decompose_essential ¶
The four (R, t) candidates consistent with an essential matrix.
Every essential matrix factors as E = [t]_× R for exactly two rotations and two signs
of t (Hartley & Zisserman, §9.6.2, Result 9.19) — ambiguous without an extra
cheirality test, which :func:recover_pose applies to pick the one physical solution.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
E
|
(3, 3) ndarray
|
Essential matrix, e.g. from :func: |
required |
Returns:
| Type | Description |
|---|---|
list of (ndarray, ndarray)
|
The four |
Examples:
>>> import numpy as np
>>> from ds_msp.mvg import essential_from_rays, decompose_essential
>>> rng = np.random.default_rng(0)
>>> X1 = rng.uniform(-2, 2, (10, 3)) + np.array([0, 0, 5])
>>> X2 = X1 + np.array([1.0, 0.0, 0.0])
>>> f1 = X1 / np.linalg.norm(X1, axis=1, keepdims=True)
>>> f2 = X2 / np.linalg.norm(X2, axis=1, keepdims=True)
>>> E = essential_from_rays(f1, f2)
>>> candidates = decompose_essential(E)
>>> len(candidates)
4
Source code in ds_msp/mvg/two_view.py
epipolar_residual ¶
Algebraic epipolar residual f2ᵀ E f1 for each ray correspondence.
This is the raw algebraic constraint, not an angle — use :func:~ds_msp.mvg.sampson_residual
for a threshold that is meaningful in radians (e.g. for RANSAC).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
E
|
(3, 3) ndarray
|
Essential matrix, e.g. from :func: |
required |
f1
|
(N, 3) ndarray
|
Unit (or non-unit; renormalized internally) bearing vectors in camera 1 and camera 2, one correspondence per row. |
required |
f2
|
(N, 3) ndarray
|
Unit (or non-unit; renormalized internally) bearing vectors in camera 1 and camera 2, one correspondence per row. |
required |
Returns:
| Type | Description |
|---|---|
(N,) ndarray
|
|
Examples:
>>> import numpy as np
>>> from ds_msp.mvg import essential_from_rays, epipolar_residual
>>> rng = np.random.default_rng(0)
>>> X1 = rng.uniform(-2, 2, (10, 3)) + np.array([0, 0, 5]) # points in front of camera 1
>>> X2 = X1 + np.array([1.0, 0.0, 0.0]) # camera 2 shifted along +x
>>> f1 = X1 / np.linalg.norm(X1, axis=1, keepdims=True)
>>> f2 = X2 / np.linalg.norm(X2, axis=1, keepdims=True)
>>> E = essential_from_rays(f1, f2)
>>> float(np.abs(epipolar_residual(E, f1, f2)).max()) < 1e-10
True
Source code in ds_msp/mvg/two_view.py
essential_from_rays ¶
Essential matrix from >=8 ray correspondences (eight-point on bearing vectors).
Solves f2ᵀ E f1 = 0 in the least-squares (smallest-singular-vector) sense — the
eight-point algorithm (Hartley & Zisserman, §11.3) applied to unit bearing vectors instead
of pixels — then projects the raw least-squares solution onto the essential manifold
(singular values forced to (1, 1, 0)).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
f1
|
(N, 3) ndarray
|
Unit (or non-unit; renormalized internally) bearing vectors in camera 1 and camera 2,
one correspondence per row, |
required |
f2
|
(N, 3) ndarray
|
Unit (or non-unit; renormalized internally) bearing vectors in camera 1 and camera 2,
one correspondence per row, |
required |
normalize
|
bool
|
Apply spherical whitening (see the module-private |
False
|
Returns:
| Type | Description |
|---|---|
(3, 3) ndarray
|
Essential matrix |
Raises:
| Type | Description |
|---|---|
ValueError
|
If fewer than 8 correspondences are given, or if |
Examples:
>>> import numpy as np
>>> from ds_msp.mvg import essential_from_rays, epipolar_residual
>>> rng = np.random.default_rng(0)
>>> X1 = rng.uniform(-2, 2, (10, 3)) + np.array([0, 0, 5])
>>> X2 = X1 + np.array([1.0, 0.0, 0.0])
>>> f1 = X1 / np.linalg.norm(X1, axis=1, keepdims=True)
>>> f2 = X2 / np.linalg.norm(X2, axis=1, keepdims=True)
>>> E = essential_from_rays(f1, f2)
>>> E.shape
(3, 3)
>>> np.round(np.linalg.svd(E, compute_uv=False), 6)
array([1., 1., 0.])
Source code in ds_msp/mvg/two_view.py
estimate_relative_pose ¶
estimate_relative_pose(f1: ndarray, f2: ndarray, *, threshold: float = 0.005, max_iters: int = 1000, seed: int = 0, refine: bool = True, robust_kernel: str = 'none', robust_scale: float | str = 'auto')
End-to-end robust two-view relative pose from ray correspondences.
Ties the RANSAC consensus and angular-BA pieces into one call: RANSAC consensus over the eight-point gives an
outlier-free inlier set and a well-conditioned initial (R₀, t₀) (a far better seed than a
single least-squares eight-point on contaminated data, especially at large rotation where one
bad ray skews the essential matrix); the inliers are triangulated and handed to the
manifold-correct :func:refine_two_view for a final geometric (angular) bundle adjustment.
This is the estimator :mod:ds_msp.vo composes into a trajectory
(:func:ds_msp.vo.estimate_trajectory), one consecutive frame pair at a time.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
f1
|
(N, 3) ndarray
|
Observed unit (or non-unit; renormalized internally) bearing vectors in camera 1 and
camera 2, |
required |
f2
|
(N, 3) ndarray
|
Observed unit (or non-unit; renormalized internally) bearing vectors in camera 1 and
camera 2, |
required |
threshold
|
float
|
RANSAC inlier cutoff on the Sampson angle (radians); forwarded to
:func: |
0.005
|
max_iters
|
int
|
RANSAC iteration budget; forwarded to :func: |
1000
|
seed
|
int
|
RNG seed for RANSAC sampling. |
0
|
refine
|
bool
|
Run :func: |
True
|
robust_kernel
|
str
|
IRLS kernel passed to :func: |
"none"
|
robust_scale
|
float or str
|
Inlier scale for |
"auto"
|
Returns:
| Name | Type | Description |
|---|---|---|
R |
(3, 3) ndarray
|
Rotation, camera 1 to camera 2. |
t |
(3,) ndarray
|
Unit-length translation direction. |
X |
(N_inliers, 3) ndarray
|
Triangulated 3D points (camera-1 frame), inliers only. |
inliers |
(N,) bool ndarray
|
Boolean inlier mask over the input correspondences. |
Source code in ds_msp/mvg/bundle.py
ransac_relative_pose ¶
ransac_relative_pose(f1: ndarray, f2: ndarray, *, threshold: float = 0.005, max_iters: int = 1000, confidence: float = 0.999, seed: int = 0, refine: bool = True) -> Tuple[np.ndarray, np.ndarray, np.ndarray]
Robust (R, t) from ray correspondences via RANSAC over the eight-point.
Repeatedly samples 8 correspondences, fits :func:~ds_msp.mvg.essential_from_rays, and
scores every correspondence with :func:sampson_residual; keeps the largest inlier
consensus and (optionally) re-fits on it. The iteration budget is adaptive: once the best
inlier fraction seen implies an all-inlier 8-sample has probably already been drawn (at
the given confidence), remaining iterations are skipped.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
f1
|
(N, 3) ndarray
|
Unit (or non-unit; renormalized internally) bearing vectors in camera 1 and camera 2,
|
required |
f2
|
(N, 3) ndarray
|
Unit (or non-unit; renormalized internally) bearing vectors in camera 1 and camera 2,
|
required |
threshold
|
float
|
Inlier cutoff on the Sampson angle (radians); |
0.005
|
max_iters
|
int
|
Maximum RANSAC iterations (upper bound; adaptive stopping usually exits sooner). |
1000
|
confidence
|
float
|
Target probability of having drawn at least one all-inlier 8-sample; controls the adaptive early stop. |
0.999
|
seed
|
int
|
Seed for the internal |
0
|
refine
|
bool
|
Re-fit the essential matrix on all inliers, with spherical whitening
( |
True
|
Returns:
| Name | Type | Description |
|---|---|---|
R |
(3, 3) ndarray
|
Rotation mapping camera 1 to camera 2 ( |
t |
(3,) ndarray
|
Unit-length translation direction. |
inliers |
(N,) bool ndarray
|
Consensus inlier mask over the input correspondences. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If fewer than 8 correspondences are given. |
RuntimeError
|
If no 8-point sample ever reaches an 8-correspondence consensus (degenerate or all-outlier data). |
Examples:
Recovering rotation to ~0.11 deg from 30%-corrupted ray matches (full walkthrough with
the naive-vs-RANSAC comparison table: Chapter 8, §5):
>>> import numpy as np
>>> from ds_msp.mvg import ransac_relative_pose
>>> def rot_err_deg(A, B):
... return np.degrees(np.arccos(np.clip((np.trace(A.T @ B) - 1) / 2, -1, 1)))
>>> rng = np.random.default_rng(3)
>>> R_true = np.eye(3) # no rotation, for a self-contained deterministic example
>>> t_true = np.array([0.1, 0.0, 0.0])
>>> X1 = np.column_stack([rng.uniform(-2, 2, 120), rng.uniform(-2, 2, 120),
... rng.uniform(2, 8, 120)])
>>> X2 = (R_true @ X1.T).T + t_true
>>> f1 = X1 / np.linalg.norm(X1, axis=1, keepdims=True)
>>> f2 = X2 / np.linalg.norm(X2, axis=1, keepdims=True)
>>> rng2 = np.random.default_rng(4)
>>> outlier = rng2.random(120) < 0.30
>>> f2[outlier] = rng2.standard_normal((int(outlier.sum()), 3))
>>> f2 /= np.linalg.norm(f2, axis=1, keepdims=True)
>>> R, t, inliers = ransac_relative_pose(f1, f2, threshold=0.005, seed=0)
>>> bool(rot_err_deg(R_true, R) < 1.0)
True
>>> int(inliers.sum()) >= 80
True
Source code in ds_msp/mvg/ransac.py
54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 | |
recover_pose ¶
recover_pose(f1: ndarray, f2: ndarray, E: Optional[ndarray] = None) -> Tuple[np.ndarray, np.ndarray, np.ndarray]
Relative pose (R, t) and triangulated points from ray correspondences.
Computes E (if not supplied), enumerates the four :func:decompose_essential
candidates, and via cheirality picks the one with the most :func:triangulate_rays points
in front of both cameras.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
f1
|
(N, 3) ndarray
|
Unit (or non-unit; renormalized internally) bearing vectors in camera 1 and camera 2,
|
required |
f2
|
(N, 3) ndarray
|
Unit (or non-unit; renormalized internally) bearing vectors in camera 1 and camera 2,
|
required |
E
|
(3, 3) ndarray
|
A precomputed essential matrix (e.g. re-fit on a RANSAC inlier set by
:func: |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
R |
(3, 3) ndarray
|
Rotation mapping camera 1 to camera 2 ( |
t |
(3,) ndarray
|
Unit-length translation direction; its scale is unobservable from two views. |
X |
(N, 3) ndarray
|
Triangulated 3D points, in the camera-1 frame. |
Examples:
Exact recovery on noise-free rays (see
Chapter 8 for the full walkthrough, including a
real Double Sphere camera round-trip asserted to < 1e-3 degrees rotation error):
>>> import numpy as np
>>> from ds_msp.mvg import recover_pose
>>> rng = np.random.default_rng(1)
>>> R_true = np.eye(3)
>>> t_true = np.array([1.0, 0.0, 0.0])
>>> X1 = rng.uniform(-2, 2, (40, 3)) + np.array([0, 0, 5])
>>> X2 = (R_true @ X1.T).T + t_true
>>> f1 = X1 / np.linalg.norm(X1, axis=1, keepdims=True)
>>> f2 = X2 / np.linalg.norm(X2, axis=1, keepdims=True)
>>> R, t, X = recover_pose(f1, f2)
>>> bool(np.allclose(R, R_true, atol=1e-8))
True
Source code in ds_msp/mvg/two_view.py
refine_two_view ¶
refine_two_view(f1: ndarray, f2: ndarray, R0: ndarray, t0: ndarray, X0: ndarray, *, max_nfev: int = 100, robust_kernel: str = 'none', robust_scale: float | str = 1.0) -> Tuple[np.ndarray, np.ndarray, np.ndarray]
Nonlinear refinement of (R, t, X) minimizing the tangent-plane angular residual.
Manifold-correct, in-house solver. Driven by :func:ds_msp.core.optimize.lm_solve,
which re-bases the rotation every accepted step (R ← R · exp([δω]_×), δω reset to 0)
instead of letting a fixed-base perturbation drift toward the ‖δω‖ = π singularity — so it
stays stable at large inter-frame rotation where a flat axis-angle / fixed-base solve wobbles.
The Jacobian is analytic (the angular residual differentiated through the tangent basis),
replacing the old finite-difference scipy path that stalled at large rotation.
Camera 1 is fixed at the identity (reference frame) and t is kept unit-length, pinning the
7-DOF similarity gauge (the angular error is scale-invariant). The translation gauge makes the
δt block rank-deficient by design; the solver's damped-Cholesky floor absorbs it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
f1
|
(N, 3) ndarray
|
Observed unit (or non-unit; renormalized internally) bearing vectors in camera 1 and camera 2. |
required |
f2
|
(N, 3) ndarray
|
Observed unit (or non-unit; renormalized internally) bearing vectors in camera 1 and camera 2. |
required |
R0
|
(3, 3), (3,) ndarray
|
Initial relative pose (e.g. from :func: |
required |
t0
|
(3, 3), (3,) ndarray
|
Initial relative pose (e.g. from :func: |
required |
X0
|
(N, 3) ndarray
|
Initial 3D points in the camera-1 frame (e.g. from
:func: |
required |
max_nfev
|
int
|
Maximum solver iterations, forwarded to :func: |
100
|
robust_kernel
|
str
|
IRLS down-weighting kernel for mismatched correspondences (e.g. |
"none"
|
robust_scale
|
float or str
|
Inlier scale for |
1.0
|
Returns:
| Name | Type | Description |
|---|---|---|
R |
(3, 3) ndarray
|
Refined rotation, camera 1 to camera 2. |
t |
(3,) ndarray
|
Refined unit-length translation direction. |
X |
(N, 3) ndarray
|
Refined 3D points, in the camera-1 frame. |
Source code in ds_msp/mvg/bundle.py
70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 | |
relative_pose ¶
Convenience: (R, t) from ray correspondences (eight-point + cheirality).
sampson_residual ¶
Symmetric angular epipolar distance per correspondence (radians, small-angle).
First-order (Sampson) approximation of how far each ray pair is from satisfying
f2ᵀ E f1 = 0, with the gradient taken in the tangent planes of the unit rays so the
result is an angle in radians, not the unitless algebraic residual of
:func:~ds_msp.mvg.epipolar_residual. This is the FOV-independent scoring function
:func:ransac_relative_pose thresholds against — a fixed radian cutoff means the same
angular tolerance at the image centre and at the rim of a wide-FOV lens, unlike a pixel
threshold.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
E
|
(3, 3) ndarray
|
Essential matrix candidate. |
required |
f1
|
(N, 3) ndarray
|
Unit (or non-unit; renormalized internally) bearing vectors in camera 1 and camera 2. |
required |
f2
|
(N, 3) ndarray
|
Unit (or non-unit; renormalized internally) bearing vectors in camera 1 and camera 2. |
required |
Returns:
| Type | Description |
|---|---|
(N,) ndarray
|
Non-negative Sampson angle (radians) per correspondence; small for a correct |
Source code in ds_msp/mvg/ransac.py
triangulate_rays ¶
triangulate_rays(f1: ndarray, f2: ndarray, R: ndarray, t: ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]
Midpoint triangulation of ray pairs under pose (R, t).
For each correspondence, finds the two points (one on ray 1, one on ray 2, expressed in camera-1 frame) that minimize the distance between the rays, and returns their midpoint — the closed-form linear method (see e.g. Hartley & Sturm, Triangulation, CVIU 1997, §2.1).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
f1
|
(N, 3) ndarray
|
Unit (or non-unit; renormalized internally) bearing vectors in camera 1 and camera 2. |
required |
f2
|
(N, 3) ndarray
|
Unit (or non-unit; renormalized internally) bearing vectors in camera 1 and camera 2. |
required |
R
|
(3, 3), (3,) ndarray
|
Relative pose mapping a point from camera 1 to camera 2: |
required |
t
|
(3, 3), (3,) ndarray
|
Relative pose mapping a point from camera 1 to camera 2: |
required |
Returns:
| Name | Type | Description |
|---|---|---|
X |
(N, 3) ndarray
|
Triangulated 3D points, in the camera-1 frame. |
depth1 |
(N,) ndarray
|
Signed distance of each point along |
depth2 |
(N,) ndarray
|
Signed distance of each point along |
Notes
Near-parallel ray pairs (|f1 · d2| -> 1, i.e. almost no parallax) make the 2x2 linear
system nearly singular; the denominator is floored at 1e-12 rather than raising, so the
result degrades gracefully (a poorly-conditioned but finite point) instead of dividing by
zero.