Convert a calibration between camera models¶
Translate an already-calibrated camera from one model to another without images and without recalibrating — e.g. take a Double Sphere calibration and hand a downstream tool an OpenCV-ready Kannala-Brandt model instead.
Prerequisites - DS-MSP installed (
pip install ds-msp). - A source model you have already calibrated. This recipe uses the bundled sample calibration so it runs with no files; load your own at the end. - You want a different model's parameters, not new measurements. Conversion fits the target model to reproduce the source's geometry; it adds no new information.
Convert in one call¶
convert(source, target_class, width=..., height=...) returns the fitted target
model and a quality report. Pass the source instance and the target class:
"""How-to: convert a calibration between camera models, in one call.
`convert(source, target_class, width=..., height=...)` fits `target_class` to
reproduce `source`'s geometry -- no images, no recalibration. Runs on the bundled
Double Sphere sample calibration (`DoubleSphereModel.sample()`, no file needed),
converting it to Kannala-Brandt (OpenCV `cv2.fisheye`'s model).
"""
from ds_msp import DoubleSphereModel, KannalaBrandtModel, convert
def main() -> None:
ds = DoubleSphereModel.sample() # the bundled DS calibration (no file needed)
kb, report = convert(ds, KannalaBrandtModel, width=1920, height=1080)
print(f"rms_px={report['rms_px']:.5f}")
print(f"converged={report['converged']}")
if __name__ == "__main__":
main()
kb is a KannalaBrandtModel whose project/unproject reproduce the original
Double Sphere camera.
report["rms_px"] is the
RMS pixel disagreement between the two
models, sampled across the image. Here it reads 0.00021 px, far below one
pixel, so the conversion is effectively lossless.
Tip
You pass KannalaBrandtModel (the class), not an instance. The converter
constructs and fits the target itself.
Read the quality report¶
The report tells you whether the conversion is faithful and over what field of view. Always check it. Some conversions are lossy, and the report is how you catch that — a conversion never fails silently.
"""How-to: read every field of convert()'s quality report.
Repeats the same Double Sphere -> Kannala-Brandt conversion as
`convert_in_one_call.py`, then reads the rest of the report. Always check it --
some conversions are lossy, and the report is how you catch that; a conversion
never fails silently.
"""
from ds_msp import DoubleSphereModel, KannalaBrandtModel, convert
def main() -> None:
ds = DoubleSphereModel.sample()
kb, report = convert(ds, KannalaBrandtModel, width=1920, height=1080)
print(f"median_px={report['median_px']:.5f}") # typical pixel error
print(f"max_px={report['max_px']:.5f}") # worst-case pixel error
print(f"fov_covered_deg={report['fov_covered_deg']:.1f}")
print(f"source_model={report['source_model']!r}")
print(f"target_model={report['target_model']!r}")
if __name__ == "__main__":
main()
| Field | Meaning |
|---|---|
rms_px |
RMS pixel disagreement between source and target across the image. Your headline accuracy number. |
max_px |
Worst single-sample error — catches edge-of-image blow-ups. |
median_px |
Median pixel error. |
fov_covered_deg |
Full-angle FOV the fit covered. A narrow target shrinks this. |
converged |
True if the nonlinear least-squares refine (trust-region reflective) converged. |
source_model / target_model |
The two models' names. |
A conversion is trustworthy when converged is True and rms_px is small
relative to a pixel over the fov_covered_deg you care about.
Pick a target model¶
Any model in the library is a valid target. How well it reproduces the source depends on how expressive the target is. These numbers are from converting the bundled Double Sphere calibration (1920×1080):
| Target model | Class | rms_px |
Verdict |
|---|---|---|---|
| Kannala-Brandt | KannalaBrandtModel |
0.00021 | near-exact; OpenCV cv2.fisheye-ready |
| EUCM | EUCMModel |
0.014 | near-exact |
| UCM | UCMModel |
0.334 | lossy — UCM has 1 shape parameter (alpha) vs. DS/EUCM's 2 |
| RadTan @ 120° | RadTanModel |
0.768 | lossy — pinhole cannot hold a wide FOV |
Supported models: UCM, EUCM, Kannala-Brandt, RadTan, OCamCalib, Double Sphere. All implement the same contract, so any can be a source or a target.
Note
OCamCalib (class OCamModel) is supported but left out of the table above — it
converts well (rms_px ≈ 0.55) — the four rows shown are enough to make the
expressiveness point.
"""How-to: convert to a different target model -- same call, new class.
Any model in the library is a valid `target_class`. This converts the same
bundled Double Sphere calibration to EUCM instead of Kannala-Brandt.
"""
from ds_msp import DoubleSphereModel, EUCMModel, convert
def main() -> None:
ds = DoubleSphereModel.sample()
eucm, report = convert(ds, EUCMModel, width=1920, height=1080)
print(f"rms_px={report['rms_px']:.3f}")
if __name__ == "__main__":
main()
Restrict the FOV for narrow targets¶
Pinhole-style models (RadTan) cannot represent a >180° fisheye. Converting one without
limiting the field of view lets rays the target can never reproduce drag down the fit.
Pass max_fov_deg to fit and report only the representable region:
"""How-to: restrict the FOV before converting to a narrower target model.
Pinhole-style models (RadTan) cannot represent a >180 deg fisheye. Converting
one without limiting the field of view lets rays the target can never
reproduce drag down the fit. `max_fov_deg` fits and reports only the
representable cone instead.
"""
from ds_msp import DoubleSphereModel, RadTanModel, convert
def main() -> None:
ds = DoubleSphereModel.sample()
rt, report = convert(ds, RadTanModel, width=1920, height=1080, max_fov_deg=120.0)
print(f"rms_px={report['rms_px']:.3f}")
print(f"fov_covered_deg={report['fov_covered_deg']:.1f}")
if __name__ == "__main__":
main()
max_fov_deg is the full angle. The report now reflects the 120° cone the
pinhole model is meant to cover, instead of being dominated by unrepresentable
peripheral rays.
Tip
fov_covered_deg reads 119.9, just under the 120.0 requested. The fit samples a
discrete pixel grid, so the widest sampled ray lands a fraction of a degree inside
the cap rather than exactly on it.
Warning
A low rms_px over a restricted FOV does not mean the conversion is lossless — it
means it is faithful within that FOV. Check fov_covered_deg against the field
of view your application actually uses.
Use the converted model everywhere¶
The converted model is a first-class camera model. Every service in DS-MSP depends only on the model contract, so pose estimation, undistortion, and the rest work on it unchanged. Converting is a one-line swap in your pipeline:
"""How-to: use a converted model with the rest of DS-MSP -- e.g. solve_pnp.
Every DS-MSP service depends only on the CameraModel contract, so a converted
model works everywhere the source model did. Converting is a one-line swap in
a pipeline.
"""
import numpy as np
from ds_msp import DoubleSphereModel, KannalaBrandtModel, convert, solve_pnp
def main() -> None:
ds = DoubleSphereModel.sample()
kb, _ = convert(ds, KannalaBrandtModel, width=1920, height=1080) # DS -> OpenCV fisheye
object_points = np.array([[0, 0, 0], [0.1, 0, 0], # (N, 3) known 3D points, metres
[0, 0.1, 0], [0.1, 0.1, 0]], dtype=float)
image_points = np.array([[610, 480], [720, 470], # (N, 2) their pixels
[600, 590], [715, 580]], dtype=float)
ok, rvec, tvec = solve_pnp(kb, object_points, image_points) # same call as for the DS model
print(f"ok={ok}")
if __name__ == "__main__":
main()
Because KB and RadTan use exactly OpenCV's distortion conventions, the converted
model also plugs straight into OpenCV: kb.K and kb.distortion (shape (4,) for
KB) go directly into cv2.fisheye.*.
Load your own calibration¶
The recipe above used DoubleSphereModel.sample() so it runs with no files. To
convert a calibration you produced, load it with from_dict and pass your real
image size.
Warning
This snippet needs your own JSON file to run. results/calibration_params.json is
a placeholder path, not a bundled file — copy-pasting it as-is raises
FileNotFoundError until you point it at a real one. That is also why it stays a
hand-authored excerpt here rather than a docs_src/ file: docs_src/ files must
run unconditionally with no external input (see
docs_src/README.md),
and there is no such thing as a bundled fixture for your camera.
import json
from ds_msp import DoubleSphereModel, KannalaBrandtModel, convert
# point this at your own file; the JSON needs keys: fx, fy, cx, cy, xi, alpha
with open("results/calibration_params.json") as f:
ds = DoubleSphereModel.from_dict(json.load(f)) # dict with fx, fy, cx, cy, xi, alpha
# use your camera's real sensor width/height, not the 1920x1080 placeholder
kb, report = convert(ds, KannalaBrandtModel, width=1920, height=1080)
print(report["rms_px"])
from_dict expects the model's parameter names as keys (for Double Sphere:
fx, fy, cx, cy, xi, alpha). Each model exposes a matching to_dict to save back.
Pass your camera's actual width/height so the sampling and report cover the
real sensor.
Next steps¶
- Why two different models can describe the same camera — the intuition behind why conversion works at all: Are two models the same camera?
- The
adaptAPI (convert, the report fields, sampling): Multi-model library & conversion.
Recap: convert(source, target_class, width, height) fits a target model to an
existing calibration with no images, returns it plus a report, and the converted
model works with every DS-MSP feature. Check report["rms_px"], converged, and
fov_covered_deg; use max_fov_deg for narrow targets.
The sample→unproject→linear-seed→refine conversion design follows the public
Fisheye-Calib-Adapter work
(credited in the project README). Source:
ds_msp/adapt/convert.py,
ds_msp/adapt/evaluate.py.