ds-msp 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. ds_msp-0.3.0/LICENSE +21 -0
  2. ds_msp-0.3.0/PKG-INFO +508 -0
  3. ds_msp-0.3.0/README.md +478 -0
  4. ds_msp-0.3.0/ds_msp/__init__.py +47 -0
  5. ds_msp-0.3.0/ds_msp/adapt/__init__.py +7 -0
  6. ds_msp-0.3.0/ds_msp/adapt/convert.py +93 -0
  7. ds_msp-0.3.0/ds_msp/adapt/evaluate.py +54 -0
  8. ds_msp-0.3.0/ds_msp/adapt/sampling.py +24 -0
  9. ds_msp-0.3.0/ds_msp/calib/__init__.py +19 -0
  10. ds_msp-0.3.0/ds_msp/calib/bundle.py +147 -0
  11. ds_msp-0.3.0/ds_msp/calib/detect.py +99 -0
  12. ds_msp-0.3.0/ds_msp/calib/targets.py +99 -0
  13. ds_msp-0.3.0/ds_msp/core/__init__.py +14 -0
  14. ds_msp-0.3.0/ds_msp/core/contracts.py +120 -0
  15. ds_msp-0.3.0/ds_msp/core/pinhole.py +30 -0
  16. ds_msp-0.3.0/ds_msp/cv.py +296 -0
  17. ds_msp-0.3.0/ds_msp/io/__init__.py +17 -0
  18. ds_msp-0.3.0/ds_msp/io/kalibr.py +131 -0
  19. ds_msp-0.3.0/ds_msp/ldc.py +193 -0
  20. ds_msp-0.3.0/ds_msp/model.py +460 -0
  21. ds_msp-0.3.0/ds_msp/models/__init__.py +27 -0
  22. ds_msp-0.3.0/ds_msp/models/double_sphere.py +115 -0
  23. ds_msp-0.3.0/ds_msp/models/ds_math.py +169 -0
  24. ds_msp-0.3.0/ds_msp/models/eucm.py +96 -0
  25. ds_msp-0.3.0/ds_msp/models/eucm_math.py +93 -0
  26. ds_msp-0.3.0/ds_msp/models/kb.py +105 -0
  27. ds_msp-0.3.0/ds_msp/models/kb_math.py +132 -0
  28. ds_msp-0.3.0/ds_msp/models/ocam.py +105 -0
  29. ds_msp-0.3.0/ds_msp/models/ocam_math.py +169 -0
  30. ds_msp-0.3.0/ds_msp/models/radtan.py +94 -0
  31. ds_msp-0.3.0/ds_msp/models/radtan_math.py +123 -0
  32. ds_msp-0.3.0/ds_msp/models/ucm.py +91 -0
  33. ds_msp-0.3.0/ds_msp/models/ucm_math.py +95 -0
  34. ds_msp-0.3.0/ds_msp/ops/__init__.py +6 -0
  35. ds_msp-0.3.0/ds_msp/ops/pose.py +50 -0
  36. ds_msp-0.3.0/ds_msp/ops/undistort.py +89 -0
  37. ds_msp-0.3.0/ds_msp/testing.py +204 -0
  38. ds_msp-0.3.0/ds_msp/utils.py +130 -0
  39. ds_msp-0.3.0/ds_msp.egg-info/PKG-INFO +508 -0
  40. ds_msp-0.3.0/ds_msp.egg-info/SOURCES.txt +45 -0
  41. ds_msp-0.3.0/ds_msp.egg-info/dependency_links.txt +1 -0
  42. ds_msp-0.3.0/ds_msp.egg-info/requires.txt +7 -0
  43. ds_msp-0.3.0/ds_msp.egg-info/top_level.txt +1 -0
  44. ds_msp-0.3.0/pyproject.toml +85 -0
  45. ds_msp-0.3.0/setup.cfg +4 -0
  46. ds_msp-0.3.0/tests/test_ds_camera_cv.py +117 -0
  47. ds_msp-0.3.0/tests/test_robustness_and_ldc.py +193 -0
ds_msp-0.3.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Black_and_White
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
ds_msp-0.3.0/PKG-INFO ADDED
@@ -0,0 +1,508 @@
1
+ Metadata-Version: 2.4
2
+ Name: ds_msp
3
+ Version: 0.3.0
4
+ Summary: Double Sphere (and multi-model) fisheye camera library with model conversion
5
+ Author: Munna-Manoj
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Munna-Manoj/DS-MSP
8
+ Project-URL: Repository, https://github.com/Munna-Manoj/DS-MSP
9
+ Project-URL: Issues, https://github.com/Munna-Manoj/DS-MSP/issues
10
+ Keywords: fisheye,camera-model,calibration,double-sphere,computer-vision,slam,opencv,kannala-brandt
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Scientific/Engineering :: Image Recognition
19
+ Classifier: Operating System :: OS Independent
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: numpy
24
+ Requires-Dist: opencv-python
25
+ Requires-Dist: scipy
26
+ Requires-Dist: pyyaml
27
+ Provides-Extra: calib
28
+ Requires-Dist: aprilgrid; extra == "calib"
29
+ Dynamic: license-file
30
+
31
+ # DS-MSP — Double Sphere & Multi-Model Fisheye Camera Library
32
+
33
+ [![CI](https://github.com/Munna-Manoj/DS-MSP/actions/workflows/ci.yml/badge.svg)](https://github.com/Munna-Manoj/DS-MSP/actions/workflows/ci.yml)
34
+ [![Python](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12-blue)](https://github.com/Munna-Manoj/DS-MSP)
35
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
36
+ ![Tests](https://img.shields.io/badge/tests-171%20passing-brightgreen)
37
+
38
+ A clean, tested, **OpenCV-compatible** camera library for wide-FOV (fisheye) lenses — built around the
39
+ **Double Sphere** model (Usenko et al. 2018) and a uniform multi-model layer, with analytic Jacobians,
40
+ calibration, model conversion, and hardware export. It doubles as a **guided, runnable course** in
41
+ wide-FOV camera geometry.
42
+
43
+ ![Fisheye rectification demo](assets/undistort_demo.gif)
44
+
45
+ > *A real fisheye frame (left) rectified to a pinhole view (right), sweeping the `balance` knob from
46
+ > widest-FOV to tightest-crop. The bent ceiling lines and curved checkerboard straighten out.*
47
+
48
+ > **Two ways in — pick yours:**
49
+ > - 🎓 **Learn the geometry** → start the runnable curriculum in **[`docs/learn/`](docs/learn/README.md)**.
50
+ > Each chapter prints a number you can verify; the **[🏆 capstone](docs/learn/capstone_calibrating_a_real_camera.md)**
51
+ > calibrates a real fisheye from TUM-VI footage and matches the *published* intrinsics to ~1 % (0.12 px median).
52
+ > - 🛠️ **Use the library** → jump to **[Installation](#installation)** and **[Quick start](#quick-start)**.
53
+
54
+ ---
55
+
56
+ ## Table of contents
57
+
58
+ - [Why DS-MSP](#why-ds-msp)
59
+ - [Installation](#installation)
60
+ - [Quick start](#quick-start)
61
+ - [Repository map](#repository-map)
62
+ - [Learn: the guided curriculum](#learn-the-guided-curriculum)
63
+ - [Using the library](#using-the-library)
64
+ - [Create a camera](#create-a-camera)
65
+ - [Project & unproject (+ analytic Jacobian)](#project--unproject--analytic-jacobian)
66
+ - [Undistort images](#undistort-images)
67
+ - [Robust PnP](#robust-pnp)
68
+ - [Multi-model support & conversion](#multi-model-support--conversion)
69
+ - [Hardware LDC export (TI Jacinto)](#hardware-ldc-export-ti-jacinto)
70
+ - [Calibration](#calibration)
71
+ - [Deep dive: FOV, validity & undistortion](#deep-dive-fov-validity--undistortion)
72
+ - [Accuracy & verification](#accuracy--verification)
73
+ - [FAQ](#faq)
74
+ - [Roadmap](#roadmap)
75
+ - [Credits](#credits)
76
+ - [License](#license)
77
+
78
+ ---
79
+
80
+ ## Why DS-MSP
81
+
82
+ Fisheye lenses capture a very wide field of view — often **> 180°** — by deliberately bending straight
83
+ lines. The familiar **pinhole** model can't describe that, and worse, its `X/Z` projection blows up as
84
+ rays approach 90°. DS-MSP implements the models that *can*, and does it carefully:
85
+
86
+ | | What you get |
87
+ | :-- | :-- |
88
+ | **Correct wide-FOV geometry** | Double Sphere with the exact `z > -w₂·d₁` half-space validity test — handles the full **> 180° FOV**, not the naive `z > 0` check that silently clips it. |
89
+ | **One interface, many models** | UCM, EUCM, Kannala-Brandt (= OpenCV fisheye), RadTan (= OpenCV pinhole), OCamCalib, Double Sphere — all behind a single `CameraModel` contract. |
90
+ | **Analytic Jacobians** | Exact closed-form derivatives (no autodiff, no finite differences) → faster, more robust calibration. KB & RadTan match OpenCV to ~1e-13. |
91
+ | **Model conversion** | Translate a calibration between models **without images or recalibration** (sample → unproject → LM refit). |
92
+ | **Calibration** | Generic Levenberg–Marquardt bundle adjustment for *any* model, with a robust (Cauchy) loss option. |
93
+ | **Ecosystem fluency** | Read/write **Kalibr** camchain YAML; OpenCV-style drop-in API; **TI Jacinto** LDC hardware mesh export. |
94
+ | **Verified, CI-tested** | 171 tests + import-linter layer checks + mypy, green on Python 3.10–3.12. |
95
+
96
+ ---
97
+
98
+ ## Installation
99
+
100
+ Requires **Python ≥ 3.10**.
101
+
102
+ ```bash
103
+ git clone https://github.com/Munna-Manoj/DS-MSP.git
104
+ cd DS-MSP
105
+
106
+ # core library (editable install)
107
+ pip install -e .
108
+
109
+ # …or with the AprilGrid detector used by the calibration capstone
110
+ pip install -e ".[calib]"
111
+ ```
112
+
113
+ Verify:
114
+
115
+ ```bash
116
+ python -c "import ds_msp; print('DS-MSP loaded:', ds_msp.__name__)"
117
+ ```
118
+
119
+ > Prefer isolation? `python -m venv .venv && source .venv/bin/activate` (or `uv venv`) first.
120
+
121
+ ---
122
+
123
+ ## Quick start
124
+
125
+ A camera model is just two maps — **project** (3D → 2D) and **unproject** (2D → 3D) — plus a handful of
126
+ intrinsics. They are exact inverses:
127
+
128
+ ```python
129
+ import numpy as np
130
+ from ds_msp import DoubleSphereCamera
131
+
132
+ # 6 intrinsics fully describe the lens (width/height are optional, only for image ops)
133
+ cam = DoubleSphereCamera(fx=711.57, fy=711.24, cx=949.18, cy=518.81, xi=0.183, alpha=0.809)
134
+
135
+ pts_3d = np.array([[0.0, 0.0, 1.0], [1.0, 1.0, 2.0]]) # camera-frame points (N, 3)
136
+ px, ok = cam.project(pts_3d) # -> (N, 2) pixels + (N,) validity mask
137
+ rays, ok = cam.unproject(px) # -> (N, 3) unit rays (inverse of project)
138
+ ```
139
+
140
+ **Want to see it on real data?** With the `[calib]` extra and the TUM-VI download
141
+ (`bash scripts/download_datasets.sh tumvi`), calibrate a real fisheye from scratch and match the
142
+ published reference:
143
+
144
+ ```bash
145
+ python examples/03_calibrate_tumvi_aprilgrid.py
146
+ ```
147
+
148
+ ---
149
+
150
+ ## Repository map
151
+
152
+ | Path | Contents |
153
+ | :-- | :-- |
154
+ | [`ds_msp/`](ds_msp) | The library: `core/` contracts → pure math → `models/` → services (`ops/`, `adapt/`, `io/`, `calib/`), plus `cv.py` (OpenCV-style API) and `ldc.py` (hardware export). |
155
+ | [`examples/`](examples) | Five runnable demos on real TUM-VI data (`01`–`05`) — round-trip precision, the calibration capstone, robust-loss A/B, model equivalence. |
156
+ | [`docs/learn/`](docs/learn/README.md) | The guided curriculum (start here to learn). |
157
+ | [`docs/`](docs) | [`MULTI_MODEL.md`](docs/MULTI_MODEL.md) (multi-model + conversion guide), [`ROADMAP.md`](docs/ROADMAP.md), [`WRITING_GUIDE.md`](docs/WRITING_GUIDE.md) (docs style guide). |
158
+ | [`datasets/`](datasets/README.md) | Data guide: what to download, where it goes, how to start. |
159
+ | [`tests/`](tests) | 171 tests (contract suite, analytic-Jacobian gradient checks, calibration). |
160
+
161
+ The library is **strictly layered** (enforced in CI by import-linter): `core` depends on nothing, the
162
+ service layers depend only on the contract — not on concrete models or each other — and the pure-math
163
+ modules are NumPy-only.
164
+
165
+ ```mermaid
166
+ graph TD
167
+ services["services: ops · adapt · calib · io<br/>(work on any model via the contract)"]
168
+ models["models: DoubleSphere · UCM · EUCM · KB · RadTan · OCam<br/>(value object + pure-NumPy *_math)"]
169
+ core["core: CameraModel contract · pinhole<br/>(dependency-free foundation)"]
170
+ services --> core
171
+ models -. implements .-> core
172
+ ```
173
+
174
+ *(Full diagram and design guarantees in [`docs/MULTI_MODEL.md`](docs/MULTI_MODEL.md#6-architecture--design-guarantees).)*
175
+
176
+ ---
177
+
178
+ ## Learn: the guided curriculum
179
+
180
+ If you want to *understand* wide-FOV geometry (not just call it), the **[`docs/learn/`](docs/learn/README.md)**
181
+ track teaches it on real public data — every chapter prints a number you can verify.
182
+
183
+ | # | Lesson | You'll be able to… |
184
+ | :-- | :-- | :-- |
185
+ | 1 | [Fisheye & camera models](docs/learn/01_fisheye_and_camera_models.md) | load a published calibration, prove project/unproject invert to ~1e-14 px, rectify a real frame |
186
+ | 2 | [The Double Sphere model](docs/learn/02_double_sphere_model.md) | derive DS from first principles and read it in code |
187
+ | 🏆 | [**Capstone: calibrate a real camera**](docs/learn/capstone_calibrating_a_real_camera.md) | detect AprilGrid corners, bundle-adjust, and **match TUM-VI's published intrinsics to ~1 %** |
188
+ | 🔬 | [Robust losses & evaluation](docs/learn/robust_losses_and_evaluation.md) | handle outliers without discarding data; why median/inlier RMS beat naive RMS |
189
+ | 🔬 | [Are two models the same camera?](docs/learn/are_two_models_the_same_camera.md) | prove DS `fx≈152` and KB `fx≈191` describe the same lens |
190
+
191
+ ---
192
+
193
+ ## Using the library
194
+
195
+ > Full multi-model cookbook (every operation, on every model) lives in
196
+ > **[`docs/MULTI_MODEL.md`](docs/MULTI_MODEL.md)**. The essentials:
197
+
198
+ ### Create a camera
199
+
200
+ The model needs **only the 6 intrinsics** for projection / unprojection / PnP. `width` and `height` are
201
+ optional — required *only* by image-level helpers (which raise a clear error if missing).
202
+
203
+ ```python
204
+ from ds_msp import DoubleSphereCamera
205
+
206
+ # (a) math-only — no meaningless image dimensions required
207
+ cam = DoubleSphereCamera(fx=711.57, fy=711.24, cx=949.18, cy=518.81, xi=0.183, alpha=0.809)
208
+
209
+ # (b) with dimensions, for image undistortion
210
+ cam = DoubleSphereCamera(711.57, 711.24, 949.18, 518.81, 0.183, 0.809, width=1920, height=1080)
211
+
212
+ # (c) from a calibration result
213
+ cam = DoubleSphereCamera.from_json("results/calibration_params.json")
214
+
215
+ K, D = cam.K, cam.D # 3×3 intrinsic matrix and [xi, alpha]
216
+ ```
217
+
218
+ > `alpha` is validated to `[0, 1]` at construction; keep `xi` in `[-1, 1]` for the well-posed domain.
219
+
220
+ ### Project & unproject (+ analytic Jacobian)
221
+
222
+ ```python
223
+ import numpy as np
224
+ pts_3d = np.array([[0, 0, 1], [1, 1, 2]], dtype=np.float64)
225
+
226
+ px, valid = cam.project(pts_3d) # (N,2) pixels + validity (correct half-space test)
227
+ rays, valid = cam.unproject(px) # (N,3) unit rays + validity
228
+
229
+ # Hot loops: allocation-free standalone functions + exact derivatives
230
+ from ds_msp import ds_project
231
+ from ds_msp.model import ds_project_jacobian
232
+
233
+ u, v, valid = ds_project(pts_3d, cam.fx, cam.fy, cam.cx, cam.cy, cam.xi, cam.alpha)
234
+ # J_point = d(u,v)/d(x,y,z), J_intr = d(u,v)/d(fx,fy,cx,cy,xi,alpha)
235
+ u, v, J_point, J_intr, valid = ds_project_jacobian(
236
+ pts_3d, cam.fx, cam.fy, cam.cx, cam.cy, cam.xi, cam.alpha)
237
+ ```
238
+
239
+ ### Undistort images
240
+
241
+ Drop-in OpenCV-style API (`ds_msp.cv` mirrors `cv2.fisheye`):
242
+
243
+ ```python
244
+ import cv2, ds_msp.cv as ds_cv
245
+
246
+ img = cv2.imread("assets/test_image.jpg")
247
+ K, D = cam.K, cam.D
248
+
249
+ # balance=0.0 -> widest FOV (more scene, black borders); balance=1.0 -> tightest crop
250
+ K_new = ds_cv.estimateNewCameraMatrixForUndistortRectify(K, D, (1920, 1080), balance=0.0)
251
+ img_undist = ds_cv.undistortImage(img, K, D, Knew=K_new)
252
+ ```
253
+
254
+ The object API is equivalent: `img_undist, K_new = cam.undistort_image(img)`.
255
+
256
+ ### Robust PnP
257
+
258
+ Standard PnP assumes a pinhole model and fails on raw fisheye points. The DS solver unprojects to rays
259
+ first, keeps the front-facing valid rays, then solves:
260
+
261
+ ```python
262
+ success, rvec, tvec = cam.solve_pnp(points_3d, points_2d) # object API
263
+ success, rvec, tvec = ds_cv.solvePnP(points_3d, points_2d, cam.K, cam.D) # OpenCV-style
264
+ ```
265
+
266
+ ### Multi-model support & conversion
267
+
268
+ Calibrate in one model and translate to another **without images or recalibration**:
269
+
270
+ ```python
271
+ import json
272
+ from ds_msp import DoubleSphereModel, KannalaBrandtModel, convert, Undistorter, solve_pnp
273
+
274
+ ds = DoubleSphereModel.from_dict(json.load(open("results/calibration_params.json")))
275
+
276
+ kb, report = convert(ds, KannalaBrandtModel, width=1920, height=1080) # DS -> OpenCV fisheye
277
+ print(report["rms_px"]) # sub-pixel agreement across the image
278
+
279
+ # every feature works on any model — swapping models is a one-line change
280
+ solve_pnp(kb, object_points, image_points)
281
+ img_rect, K_new = Undistorter(kb, 1920, 1080).undistort_image(img)
282
+ ```
283
+
284
+ Supported: **UCM, EUCM, Kannala-Brandt, RadTan, OCamCalib, Double Sphere** — all with analytic
285
+ Jacobians. You can also calibrate any model (`ds_msp.calib.calibrate`) and read/write **Kalibr YAML**
286
+ (`ds_msp.io`). Conversion design follows
287
+ [Fisheye-Calib-Adapter](https://github.com/eowjd0512/fisheye-calib-adapter) (see [Credits](#credits)).
288
+
289
+ ### Hardware LDC export (TI Jacinto)
290
+
291
+ Generate a displacement-mesh LUT for the on-chip Lens Distortion Correction engine (J7 / TDA4):
292
+
293
+ ```python
294
+ from ds_msp.ldc import TI_LDC_MeshGenerator
295
+
296
+ gen = TI_LDC_MeshGenerator(cam) # cam built with width/height
297
+ res = gen.generate_mesh_and_intrinsics(1920, 1080, downsample_factor=4, balance=0.5)
298
+ mesh_lut, K_new = res["mesh_lut"], res["K_new"] # int16 Q3 displacements + rectified intrinsics
299
+ ```
300
+
301
+ > **Best practice:** use the LDC image for the picture, but undistort *keypoints* with the closed-form
302
+ > `cam.undistort_points(pts, K_new)` at the **same `balance`**. The mesh point-inverse is exact at the
303
+ > center but diverges toward the periphery; sharing `K_new` keeps the two consistent to ~0.1 px.
304
+
305
+ ---
306
+
307
+ ## Calibration
308
+
309
+ Two paths, depending on your data:
310
+
311
+ **1 — The modern, generic calibrator** (`ds_msp.calib`) works for *any* model and is what the
312
+ [capstone](docs/learn/capstone_calibrating_a_real_camera.md) uses on real TUM-VI AprilGrid footage:
313
+
314
+ ```python
315
+ import glob
316
+ from ds_msp.calib import calibrate, AprilGridTarget, detect_aprilgrid
317
+ from ds_msp.models import KannalaBrandtModel
318
+
319
+ # 1. detect AprilGrid corners in your calibration frames
320
+ frames = sorted(glob.glob("datasets/tumvi/dataset-calib-cam1_512_16/mav0/cam0/data/*.png"))
321
+ detections = detect_aprilgrid(frames, family="t36h11")
322
+
323
+ # 2. turn tag detections into 3D<->2D correspondences (board geometry: 6x6, 88 mm, spacing 0.3)
324
+ target = AprilGridTarget(tag_rows=6, tag_cols=6, tag_size=0.088, tag_spacing=0.3)
325
+ X_world, keypoints, visibility = target.build_correspondences(detections)
326
+
327
+ # 3. bundle-adjust from a generic seed (analytic Jacobian + robust Cauchy loss)
328
+ seed = KannalaBrandtModel(fx=180, fy=180, cx=256, cy=256)
329
+ result = calibrate(seed, X_world, keypoints, visibility, loss="cauchy", f_scale=0.5)
330
+ print(result["rms_px"]) # -> ~0.18 px, matching TUM-VI's published calibration
331
+ ```
332
+
333
+ See the full walk-through in the **[calibration capstone](docs/learn/capstone_calibrating_a_real_camera.md)**.
334
+
335
+ **2 — The bundled Double Sphere script** calibrates from COCO-style checkerboard annotations:
336
+
337
+ ```bash
338
+ python calibrate.py # reads anns.json -> writes results/calibration_params.json
339
+ python validate.py # visual reprojection check -> results/visualizations/
340
+ ```
341
+
342
+ On the bundled data this converges to `fx≈711.6, fy≈711.2, cx≈949.2, cy≈518.8, xi≈0.183, alpha≈0.809`
343
+ at **0.64 px** RMS.
344
+
345
+ > **Parameter domain (important).** The optimizer constrains distortion to the *well-posed* Double
346
+ > Sphere range `α ∈ [0, 1]`, `ξ ∈ [-1, 1]` (matching basalt/Kalibr). Outside it the model becomes
347
+ > non-injective (projection folds back, so unprojection can't invert it); real fisheye lenses sit
348
+ > roughly in `ξ ∈ [-0.2, 0.6]`.
349
+
350
+ ---
351
+
352
+ ## Deep dive: FOV, validity & undistortion
353
+
354
+ *A common question: "Why are pixels missing from my undistorted image, even when I try to keep the whole image?"*
355
+
356
+ ### Undistortion modes
357
+
358
+ Verified on real data (`assets/test_image.jpg`, `assets/test_image_96.jpg`):
359
+
360
+ | Distorted | Undistorted (crop) | Undistorted (whole) | Undistorted (zoom) |
361
+ | :---: | :---: | :---: | :---: |
362
+ | ![Distorted](assets/result_distorted_11.jpg) | ![Crop](assets/result_undistort_crop_11.jpg) | ![Whole](assets/result_undistort_whole_11.jpg) | ![Zoom](assets/result_undistort_zoom_11.jpg) |
363
+ | ![Distorted](assets/result_distorted_96.jpg) | ![Crop](assets/result_undistort_crop_96.jpg) | ![Whole](assets/result_undistort_whole_96.jpg) | ![Zoom](assets/result_undistort_zoom_96.jpg) |
364
+
365
+ - **Crop (`balance=1.0`)** — keeps only center-valid pixels: no black borders, less FOV.
366
+ - **Whole (`balance=0.0`)** — keeps all pixels that map to the plane: full content, black borders.
367
+ - **Zoom (reduced focal)** — captures even more wide-angle content, shrinking the center.
368
+
369
+ ### Projection validity — the correct condition
370
+
371
+ The Double Sphere projection `π(x)` is **not** valid for all 3D points. The exact projectability test
372
+ (Usenko et al. 2018, Eq. 43–45), implemented in `ds_project`, is the half-space condition:
373
+
374
+ $$z > -w_2\, d_1, \qquad d_1 = \sqrt{x^2 + y^2 + z^2}$$
375
+
376
+ with the two helper terms
377
+
378
+ $$
379
+ w_1 =
380
+ \begin{cases}
381
+ \dfrac{\alpha}{1-\alpha} & \text{if } \alpha \le 0.5 \\
382
+ \dfrac{1-\alpha}{\alpha} & \text{if } \alpha > 0.5
383
+ \end{cases}
384
+ $$
385
+
386
+ $$
387
+ w_2 = \frac{w_1 + \xi}{\sqrt{2\, w_1 \xi + \xi^2 + 1}}
388
+ $$
389
+
390
+ This admits points with **`z ≤ 0`** (rays beyond 90°), which is exactly why the model supports a
391
+ **> 180° FOV**. A naive `z > 0` test — a common implementation mistake — rejects those rays and
392
+ silently caps the FOV below 180°; this library does **not** make that mistake.
393
+
394
+ <details>
395
+ <summary><b>Forward / inverse equations (for reference)</b></summary>
396
+
397
+ $$d_2 = \sqrt{x^2 + y^2 + (\xi d_1 + z)^2}, \qquad
398
+ \begin{bmatrix} u \\ v \end{bmatrix} =
399
+ \begin{bmatrix} f_x\, x / \big(\alpha d_2 + (1-\alpha)(\xi d_1 + z)\big) + c_x \\
400
+ f_y\, y / \big(\alpha d_2 + (1-\alpha)(\xi d_1 + z)\big) + c_y \end{bmatrix}$$
401
+
402
+ Unprojection is closed-form; with $m_x=(u-c_x)/f_x$, $m_y=(v-c_y)/f_y$, $r^2=m_x^2+m_y^2$ it is valid
403
+ for all $r^2$ when $\alpha \le 0.5$, and for $r^2 \le 1/(2\alpha-1)$ when $\alpha > 0.5$.
404
+
405
+ **Valid parameter domain:** $\alpha \in [0, 1]$, $\xi \in [-1, 1]$.
406
+
407
+ </details>
408
+
409
+ ### The FOV zones
410
+
411
+ ![FOV Zones Augmented](assets/fov_zones_augmented.jpg)
412
+
413
+ - **Green (frontal, `θ < 90°`)** — safe for standard pinhole projection.
414
+ - **Yellow (side/back, `90° ≤ θ < θ_limit`)** — valid in DS, but impossible to project into a single
415
+ pinhole image (`Z ≤ 0`): a pinhole plane is infinite at 90°, so these pixels have nowhere to go.
416
+ - **Red (`θ ≥ θ_limit`)** — mathematically invalid in DS.
417
+ - **White stars** — real keypoints, all safely inside the valid regions.
418
+
419
+ This is why undistortion can't keep a full > 180° FOV: those wide-angle pixels are not lost to a bug,
420
+ they are geometrically un-pinhole-able. *(Reference: [projection-failed region analysis](https://jseobyun.tistory.com/457?category=1170976).)*
421
+
422
+ ---
423
+
424
+ ## Accuracy & verification
425
+
426
+ Correctness is asserted with **numbers**, not screenshots (`tests/`, `verify_real_samples.py`):
427
+
428
+ | Check | Result |
429
+ | :-- | :-- |
430
+ | Inverse projection `K⁻¹` (all undistortion modes) | mean error **< 0.00003 px** ✅ |
431
+ | 3D reconstruction of checkerboard corners | mean position error **1.168 mm**; recovered square **20.01 cm** (target 20.00) ✅ |
432
+ | PnP + reprojection RMS (real `test_image` / `_96`) | **0.43 px** / **0.85 px** |
433
+ | Undistort: object API vs `cv.py` wrapper | identical |
434
+ | KB / RadTan vs OpenCV | match to **~1e-13** |
435
+
436
+ **Conclusion:** the undistorted images are geometrically accurate pinhole projections suitable for
437
+ precise 3D vision. Reproduce locally with `bash verify_all.sh` or `pytest`; for the accuracy/speed
438
+ numbers above, run **[`python benchmarks/benchmark.py`](benchmarks/)** (e.g. KB vs `cv2.fisheye` to
439
+ ~1e-13 px; analytic Jacobian ~28× faster per LM iteration than finite differences).
440
+
441
+ ---
442
+
443
+ ## FAQ
444
+
445
+ **My undistorted image has black borders?**
446
+ Normal for fisheye — a pinhole view can't capture the full > 180° FOV. Tune `balance` in
447
+ `estimateNewCameraMatrixForUndistortRectify` to trade border vs FOV.
448
+
449
+ **PnP fails or gives bad results?**
450
+ Use `cam.solve_pnp(...)` (or `ds_msp.cv.solvePnP`), not `cv2.solvePnP` — the latter assumes pinhole and
451
+ fails on raw fisheye points. You need ≥ 4 points that are in front of the camera after unprojection.
452
+
453
+ **What ranges are valid for `xi` and `alpha`?**
454
+ `alpha ∈ [0, 1]` (enforced at construction) and `xi ∈ [-1, 1]`. Beyond that the model is non-injective;
455
+ the calibrator constrains to this domain automatically. Real lenses sit in `xi ∈ [-0.2, 0.6]`.
456
+
457
+ **Do I have to pass `width` and `height`?**
458
+ No — only for image-level operations (undistortion maps / `compute_K_new`). Projection, unprojection,
459
+ Jacobians, and PnP need just the 6 intrinsics.
460
+
461
+ **How do I use this with ROS?**
462
+ Wrap `ds_msp.cv.undistortImage` in a node: subscribe to `image_raw`, undistort, publish `image_rect`.
463
+
464
+ ---
465
+
466
+ ## Roadmap
467
+
468
+ DS-MSP is actively growing from a camera library into a small perception toolkit (multi-camera &
469
+ camera-IMU calibration, visual odometry on public benchmarks, a C++/Ceres core, inference-only learned
470
+ 3D). See **[`docs/ROADMAP.md`](docs/ROADMAP.md)** for the build order and design rules.
471
+
472
+ ---
473
+
474
+ ## Credits
475
+
476
+ This project builds on excellent open-source work and research.
477
+
478
+ **Model conversion (the multi-model adapter)**
479
+ - **Fisheye-Calib-Adapter** — Sangjun Lee, *"Fisheye-Calib-Adapter: An Easy Tool for Fisheye Camera
480
+ Model Conversion"*, arXiv:2407.12405 (2024) ·
481
+ [github.com/eowjd0512/fisheye-calib-adapter](https://github.com/eowjd0512/fisheye-calib-adapter).
482
+ Our conversion design (sample → unproject with the source → linear-seed → nonlinear refine on pixel
483
+ reprojection error, per-model analytic Jacobians) and the set of supported models follow this work.
484
+
485
+ **Camera models**
486
+ - **Double Sphere** — V. Usenko, N. Demmel, D. Cremers, *"The Double Sphere Camera Model"*, 3DV 2018,
487
+ arXiv:1807.08957. Reference: [basalt-headers](https://gitlab.com/VladyslavUsenko/basalt-headers)
488
+ (half-space validity condition & analytic Jacobians follow it).
489
+ - **Kannala-Brandt** (equidistant) — J. Kannala, S. Brandt, 2006; cross-checked vs OpenCV `cv2.fisheye`.
490
+ - **Radial-Tangential (Brown-Conrady)** — D. C. Brown, 1966; cross-checked vs OpenCV `cv2.projectPoints`.
491
+ - **OCamCalib** — D. Scaramuzza et al. · **EUCM** — Khomutenko, Garcia, Martinet, 2016 ·
492
+ **UCM** — Geyer & Daniilidis / Mei & Rives.
493
+
494
+ **Calibration ecosystem & tooling**
495
+ - **Kalibr** — P. Furgale et al., [github.com/ethz-asl/kalibr](https://github.com/ethz-asl/kalibr)
496
+ (DS & EUCM contributed by V. Usenko). We follow Kalibr's `camchain` YAML format for interop.
497
+ - **[dscamera](https://github.com/matsuren/dscamera)** — Python DS utilities.
498
+ - **[Double Sphere explanation](https://jseobyun.tistory.com/455)** &
499
+ **[projection-failed region](https://jseobyun.tistory.com/457?category=1170976)** — clear write-ups.
500
+
501
+ **This codebase**
502
+ - **Muhammadjon Boboev** — original Python Double Sphere intrinsics calibration this project grew from.
503
+
504
+ ---
505
+
506
+ ## License
507
+
508
+ [MIT](LICENSE).