Undistort a fisheye image¶
Turn a distorted fisheye frame into a flat pinhole image, and control the field-of-view-vs-black-border trade-off with one knob.
This is a task recipe. For why a wide fisheye can't fit into a pinhole image without either cropping or leaving black borders, see Projection validity and FOV.
Prerequisites
ds_mspinstalled, plusopencv-pythonandnumpy(both come with it).- A calibrated camera — here a Double Sphere model with known intrinsics. If you still need to calibrate, start from the README usage.
- The snippets read
assets/test_image.jpg, which ships in the repo. Run them from the repo root, or point the path at your own fisheye frame.
Undistort an image in three calls¶
Build the camera, ask for a new pinhole intrinsic matrix, then remap. ds_msp.cv mirrors the
cv2.fisheye function
signatures, so it drops into existing OpenCV pipelines.
"""Undistort a fisheye frame in three calls: build the camera, estimate a new pinhole
matrix, remap.
``ds_msp.cv`` mirrors ``cv2.fisheye``'s function signatures (``estimateNewCameraMatrixFor
UndistortRectify``, ``undistortImage``), so it drops into existing OpenCV pipelines. Uses
the bundled real fisheye frame (``assets/test_image.jpg``) and a calibrated Double Sphere
camera.
"""
import os
import tempfile
import cv2
from ds_msp import DoubleSphereCamera
import ds_msp.cv as ds_cv
def main() -> None:
# A calibrated Double Sphere camera (1920x1080 fisheye).
cam = DoubleSphereCamera(fx=711.57, fy=711.24, cx=949.18, cy=518.81,
xi=0.183, alpha=0.809, width=1920, height=1080)
img = cv2.imread("assets/test_image.jpg") # (1080, 1920, 3) BGR
K, D = cam.K, cam.D # K: (3,3); D = [xi, alpha]
print(f"D = {D.tolist()}")
# balance=0.0 -> widest FOV (keeps the most scene; leaves black borders)
K_new = ds_cv.estimateNewCameraMatrixForUndistortRectify(K, D, (1920, 1080), balance=0.0)
img_undist = ds_cv.undistortImage(img, K, D, Knew=K_new) # (1080, 1920, 3)
out_path = os.path.join(tempfile.gettempdir(), "undistorted.jpg")
cv2.imwrite(out_path, img_undist)
print(img_undist.shape) # -> (1080, 1920, 3)
print(round(K_new[0, 0], 2)) # -> 284.56 (new focal length, px)
if __name__ == "__main__":
main()
You get a straight-line pinhole image: edges that curved in the fisheye are now straight.
The new focal length (284.56 px) is shorter than the original (711.57 px) because
balance=0.0 zooms out to keep the widest possible view.
Note
estimateNewCameraMatrixForUndistortRectify returns a single new matrix K_new with
fx_new == fy_new (it uses the average of fx and fy so nothing is stretched). Pass that
exact K_new to undistortImage via Knew= so the map and the matrix agree.
Or use the object API¶
Hold a DoubleSphereCamera already? undistort_image does the same job in one call and hands
back the matrix it chose. Called with K_new=None, it builds a balanced matrix at balance=0.5.
undistort_image takes no balance argument — passing one raises TypeError. To pick a
different balance instead, build the matrix with
estimateNewCameraMatrixForUndistortRectify(..., balance=...) and pass it as K_new=:
"""The object API: ``DoubleSphereCamera.undistort_image`` does the same job as the three
OpenCV-style calls, in one call, and hands back the ``K_new`` it chose.
Called with ``K_new=None`` it builds a balanced matrix at ``balance=0.5``. There is no
``balance=`` keyword on the method itself -- to pick a different balance, build the matrix
explicitly with ``estimateNewCameraMatrixForUndistortRectify(..., balance=...)`` and pass
it in as ``K_new=``.
"""
import cv2
from ds_msp import DoubleSphereCamera
import ds_msp.cv as ds_cv
def main() -> None:
cam = DoubleSphereCamera(fx=711.57, fy=711.24, cx=949.18, cy=518.81,
xi=0.183, alpha=0.809, width=1920, height=1080)
img = cv2.imread("assets/test_image.jpg")
# K_new=None -> built internally at balance=0.5
img_undist, K_new = cam.undistort_image(img)
print(img_undist.shape) # -> (1080, 1920, 3)
print(round(K_new[0, 0], 2)) # -> 426.84 (between the widest and tightest focals)
# cam.undistort_image(img, balance=0.3) raises TypeError -- no such keyword on the method
try:
cam.undistort_image(img, balance=0.3) # type: ignore[call-arg]
except TypeError as exc:
print(f"TypeError: {exc}")
# Pick a different balance by building the matrix explicitly and passing it as K_new=.
K_tight = ds_cv.estimateNewCameraMatrixForUndistortRectify(
cam.K, cam.D, (1920, 1080), balance=1.0)
img_tight, _ = cam.undistort_image(img, K_new=K_tight)
print(round(K_tight[0, 0], 2)) # -> 569.12 (tightest crop, no borders)
if __name__ == "__main__":
main()
The two APIs are equivalent:
- OpenCV-style functions slot straight into an existing
cv2.fisheyepipeline. - The object method is the shorter path when you already hold the camera.
Control the FOV-vs-border trade-off with balance¶
balance slides between two extremes of the same image:
- Lower
balancekeeps more of the scene, at the cost of black corners. - Higher
balancecrops in until the borders are gone.
balance |
New focal fx_new |
Black-border fraction | What you get |
|---|---|---|---|
0.0 |
284.56 px |
0.075 |
Widest FOV — the most scene, with black corners |
0.5 |
426.84 px |
0.001 |
Compromise (object-API default) |
1.0 |
569.12 px |
0.000 |
Tightest crop — no borders, least scene |
Measure the trade-off yourself¶
The "black-border fraction" is the share of output pixels that fell outside the fisheye's coverage and were filled with black. Measure it directly instead of trusting the table:
"""Measure the `balance` FOV-vs-border trade-off instead of taking it on faith.
`balance` slides between two extremes of the same rectified image: `0.0` keeps the widest
field of view at the cost of black corners, `1.0` crops in until the borders are gone. This
script builds all three reference points (`0.0`, `0.5`, `1.0`) and measures the
black-border fraction of each -- the share of output pixels that fell outside the fisheye's
coverage and were filled with black.
"""
import cv2
import numpy as np
from ds_msp import DoubleSphereCamera
import ds_msp.cv as ds_cv
def black_fraction(im: np.ndarray) -> float:
"""Fraction of pixels that are pure black (outside the fisheye coverage)."""
return float(np.mean(np.all(im == 0, axis=2)))
def main() -> None:
cam = DoubleSphereCamera(fx=711.57, fy=711.24, cx=949.18, cy=518.81,
xi=0.183, alpha=0.809, width=1920, height=1080)
img = cv2.imread("assets/test_image.jpg")
focals = {}
for balance in (0.0, 0.5, 1.0):
K_new = ds_cv.estimateNewCameraMatrixForUndistortRectify(
cam.K, cam.D, (1920, 1080), balance=balance)
und = ds_cv.undistortImage(img, cam.K, cam.D, Knew=K_new)
fx_new = K_new[0, 0]
frac = black_fraction(und)
focals[balance] = fx_new
print(f"balance={balance:.1f} fx_new={fx_new:.2f} px black_fraction={frac:.3f}")
# Focal length exactly doubles from balance=0.0 to balance=1.0.
print(f"fx_new(1.0) / fx_new(0.0) = {focals[1.0] / focals[0.0]:.2f}")
# balance=0.5 lands exactly at the midpoint of the two extremes.
midpoint = (focals[0.0] + focals[1.0]) / 2
print(f"midpoint(0.0, 1.0) = {midpoint:.2f} (balance=0.5 gives {focals[0.5]:.2f})")
if __name__ == "__main__":
main()
$ python3 -m docs_src.how_to.undistort_images.balance_tradeoff
balance=0.0 fx_new=284.56 px black_fraction=0.075
balance=0.5 fx_new=426.84 px black_fraction=0.001
balance=1.0 fx_new=569.12 px black_fraction=0.000
fx_new(1.0) / fx_new(0.0) = 2.00
midpoint(0.0, 1.0) = 426.84 (balance=0.5 gives 426.84)
Going from balance=0.0 to balance=1.0 drops the black-border fraction from 0.075 to
0.000. Over the same range the focal length exactly doubles: 284.56 px to 569.12 px.
balance interpolates linearly
balance=0.5's 426.84 px is exactly the midpoint of 284.56 and 569.12. balance
interpolates the focal linearly, so you can predict where any intermediate value lands
without computing it.
You trade visible scene for a clean frame — there's no value of balance that gives you both.
Troubleshooting: my undistorted image has black borders¶
Black borders are expected, not a bug. Tune balance to control them:
- Raise it toward
1.0to crop the empty corners away. - Lower it toward
0.0to keep more scene.
For why the trade-off is geometric — no value removes the borders without losing FOV — see Projection validity and FOV.
A missing image fails loudly, not silently
If cv2.imread returns None, undistortImage errors with AttributeError: 'NoneType'
object has no attribute 'shape' before you ever get to black_fraction. The image path
didn't resolve — run from the repo root so assets/test_image.jpg is found.
Try it yourself¶
Set balance=0.3 in estimateNewCameraMatrixForUndistortRectify. Before you run it, predict:
- Will the black-border fraction be closer to
0.075or to0.000? - Will
fx_newland between284.56and569.12?
Then wire the resulting K_new through undistortImage(img, cam.K, cam.D, Knew=K_new) (same
pattern as the setup snippet) and check your guess with black_fraction(...) on that result.
Next steps¶
- Why this works — Projection validity and FOV: why a > 180° FOV can't fit a pinhole and black borders are geometric, not a defect.
- The functions used here — source on GitHub:
ds_msp/cv.py(estimateNewCameraMatrixForUndistortRectify,undistortImage) andds_msp/model.py(DoubleSphereCamera.undistort_image,compute_K_new). The same algorithm, generalized to anyCameraModel, lives inds_msp/ops/undistort.py'sUndistorterclass — use it when you're working with the newer contract-based model classes inds_msp.modelsinstead ofDoubleSphereCamera. - Other recipes — back to the How-to guides.