Export a TI Jacinto LDC displacement mesh¶
Generate a displacement-mesh lookup table (LUT) that the on-chip LDC engine on a TI Jacinto J7 / TDA4 SoC can read.
The hardware then undistorts each fisheye frame for you.
This is a task recipe — no theory. If you want to undistort on the CPU/GPU instead of on the SoC, see Undistort a fisheye image.
Prerequisites
ds_mspinstalled (numpycomes with it).- A calibrated
DoubleSphereCamera.width/heighton the model are not required by the mesh generator — it uses theoutput_width/output_heightarguments you pass togenerate_mesh_and_intrinsics. If you still need to calibrate, start from the README usage. - The output of this recipe is a NumPy array you flash to the SoC; this page does not cover the board-side flashing toolchain.
Generate the mesh in five lines¶
Build the camera, wrap it in TI_LDC_MeshGenerator, and ask for a mesh at the output
resolution you want on-chip. You get back the quantized mesh and the rectified intrinsics
K_new that describe the undistorted image the mesh produces.
# Code above omitted 👆
import numpy as np
from ds_msp import DoubleSphereCamera
from ds_msp.ldc import TI_LDC_MeshGenerator
def main() -> None:
# A calibrated Double Sphere camera (1920x1080 fisheye).
# width/height are optional here -- the mesh generator ignores them. They matter
# only if you also call cam.compute_K_new() / cam.get_undistortion_maps().
cam = DoubleSphereCamera(fx=711.57, fy=711.24, cx=949.18, cy=518.81,
xi=0.183, alpha=0.809, width=1920, height=1080)
gen = TI_LDC_MeshGenerator(cam)
res = gen.generate_mesh_and_intrinsics(1920, 1080, downsample_factor=4, balance=0.5)
mesh_lut = res["mesh_lut"] # (69, 121, 2) int16 -- Q3 (h, v) displacements
K_new = res["K_new"] # (3, 3) rectified pinhole intrinsics
print(mesh_lut.shape, mesh_lut.dtype) # -> (69, 121, 2) int16
print(round(float(K_new[0, 0]), 2)) # -> 426.84 (new focal length, px)
# Code below omitted 👇
👀 Full file preview
"""Export a TI Jacinto LDC displacement mesh from a calibrated Double Sphere camera.
One continuous pipeline, shown incrementally on the how-to page: build the camera
(`cam`) -> wrap it in the mesh generator (`gen`) -> generate the mesh dict (`res`) ->
read the Q3 `mesh_lut` values -> sweep `downsample_factor` -> undistort keypoints with
the closed form at the same `K_new`. No external data -- the intrinsics are a
representative 1920x1080 fisheye calibration.
"""
import numpy as np
from ds_msp import DoubleSphereCamera
from ds_msp.ldc import TI_LDC_MeshGenerator
def main() -> None:
# A calibrated Double Sphere camera (1920x1080 fisheye).
# width/height are optional here -- the mesh generator ignores them. They matter
# only if you also call cam.compute_K_new() / cam.get_undistortion_maps().
cam = DoubleSphereCamera(fx=711.57, fy=711.24, cx=949.18, cy=518.81,
xi=0.183, alpha=0.809, width=1920, height=1080)
gen = TI_LDC_MeshGenerator(cam)
res = gen.generate_mesh_and_intrinsics(1920, 1080, downsample_factor=4, balance=0.5)
mesh_lut = res["mesh_lut"] # (69, 121, 2) int16 -- Q3 (h, v) displacements
K_new = res["K_new"] # (3, 3) rectified pinhole intrinsics
print(mesh_lut.shape, mesh_lut.dtype) # -> (69, 121, 2) int16
print(round(float(K_new[0, 0]), 2)) # -> 426.84 (new focal length, px)
# What the dict contains -- continues from `res`.
print(list(res.keys()))
print(res["config"]["mesh_size"])
print(res["config"]["downsample_factor"])
# Read the Q3 fixed-point format -- continues from `mesh_lut`. Each node holds
# two int16 values (h, v) in Q3: pixels * 8, rounded. Divide by 8 to recover px.
node = mesh_lut[34, 60] # the node at output pixel (960, 544)
print(node) # -> (h, v) in Q3 units
print(node / 8.0) # -> displacement in pixels
print(int(mesh_lut.min()), int(mesh_lut.max())) # Q3 range across the whole mesh
# Trade mesh size against accuracy -- continues from `gen`.
for m in (3, 4, 5):
r = gen.generate_mesh_and_intrinsics(1920, 1080, downsample_factor=m, balance=0.5)
print(m, 2**m, r["mesh_lut"].shape)
# Undistort keypoints with the closed form, not the mesh -- continues from
# `cam` and `K_new` (the SAME K_new the mesh above was generated with).
pts = np.array([[960.0, 540.0], # (N, 2) distorted fisheye keypoints, px
[1400.0, 800.0],
[600.0, 300.0]])
und, valid = cam.undistort_points(pts, K_new) # und: (N, 2) px, rectified frame
print(und.round(2))
print(valid)
if __name__ == "__main__":
main()
You now have everything the SoC needs: the displacement mesh and the matrix K_new that
defines the undistorted image it will output.
The grid is (69, 121, 2) because the generator samples one mesh node every
2**downsample_factor = 16 output pixels, plus a one-node border, across the 1920x1080
frame.
Note
generate_mesh_and_intrinsics(output_width, output_height, ...) takes the output
(undistorted) resolution. It can differ from the sensor resolution — the dimensions on cam
describe the input fisheye, the arguments describe the on-chip output.
What the dictionary contains¶
generate_mesh_and_intrinsics returns one dict. These are the keys and their shapes for the
call above.
| Key | Type / shape | What it is |
|---|---|---|
mesh_lut |
(69, 121, 2) int16 |
Q3 fixed-point (h, v) displacements — the array you flash to the LDC. |
mesh_lut_float |
(69, 121, 2) float64 |
The same displacements before quantization (for verification on the host). |
K_new |
(3, 3) float64 |
Rectified pinhole intrinsics of the undistorted output image. |
config |
dict |
The call parameters, the resulting mesh_size, and the source DS intrinsics — a self-describing record to flash alongside the mesh. |
# Code above omitted 👆
import numpy as np
from ds_msp import DoubleSphereCamera
from ds_msp.ldc import TI_LDC_MeshGenerator
def main() -> None:
# A calibrated Double Sphere camera (1920x1080 fisheye).
# width/height are optional here -- the mesh generator ignores them. They matter
# only if you also call cam.compute_K_new() / cam.get_undistortion_maps().
cam = DoubleSphereCamera(fx=711.57, fy=711.24, cx=949.18, cy=518.81,
xi=0.183, alpha=0.809, width=1920, height=1080)
gen = TI_LDC_MeshGenerator(cam)
res = gen.generate_mesh_and_intrinsics(1920, 1080, downsample_factor=4, balance=0.5)
mesh_lut = res["mesh_lut"] # (69, 121, 2) int16 -- Q3 (h, v) displacements
K_new = res["K_new"] # (3, 3) rectified pinhole intrinsics
print(mesh_lut.shape, mesh_lut.dtype) # -> (69, 121, 2) int16
print(round(float(K_new[0, 0]), 2)) # -> 426.84 (new focal length, px)
# What the dict contains -- continues from `res`.
print(list(res.keys()))
print(res["config"]["mesh_size"])
print(res["config"]["downsample_factor"])
# Code below omitted 👇
👀 Full file preview
"""Export a TI Jacinto LDC displacement mesh from a calibrated Double Sphere camera.
One continuous pipeline, shown incrementally on the how-to page: build the camera
(`cam`) -> wrap it in the mesh generator (`gen`) -> generate the mesh dict (`res`) ->
read the Q3 `mesh_lut` values -> sweep `downsample_factor` -> undistort keypoints with
the closed form at the same `K_new`. No external data -- the intrinsics are a
representative 1920x1080 fisheye calibration.
"""
import numpy as np
from ds_msp import DoubleSphereCamera
from ds_msp.ldc import TI_LDC_MeshGenerator
def main() -> None:
# A calibrated Double Sphere camera (1920x1080 fisheye).
# width/height are optional here -- the mesh generator ignores them. They matter
# only if you also call cam.compute_K_new() / cam.get_undistortion_maps().
cam = DoubleSphereCamera(fx=711.57, fy=711.24, cx=949.18, cy=518.81,
xi=0.183, alpha=0.809, width=1920, height=1080)
gen = TI_LDC_MeshGenerator(cam)
res = gen.generate_mesh_and_intrinsics(1920, 1080, downsample_factor=4, balance=0.5)
mesh_lut = res["mesh_lut"] # (69, 121, 2) int16 -- Q3 (h, v) displacements
K_new = res["K_new"] # (3, 3) rectified pinhole intrinsics
print(mesh_lut.shape, mesh_lut.dtype) # -> (69, 121, 2) int16
print(round(float(K_new[0, 0]), 2)) # -> 426.84 (new focal length, px)
# What the dict contains -- continues from `res`.
print(list(res.keys()))
print(res["config"]["mesh_size"])
print(res["config"]["downsample_factor"])
# Read the Q3 fixed-point format -- continues from `mesh_lut`. Each node holds
# two int16 values (h, v) in Q3: pixels * 8, rounded. Divide by 8 to recover px.
node = mesh_lut[34, 60] # the node at output pixel (960, 544)
print(node) # -> (h, v) in Q3 units
print(node / 8.0) # -> displacement in pixels
print(int(mesh_lut.min()), int(mesh_lut.max())) # Q3 range across the whole mesh
# Trade mesh size against accuracy -- continues from `gen`.
for m in (3, 4, 5):
r = gen.generate_mesh_and_intrinsics(1920, 1080, downsample_factor=m, balance=0.5)
print(m, 2**m, r["mesh_lut"].shape)
# Undistort keypoints with the closed form, not the mesh -- continues from
# `cam` and `K_new` (the SAME K_new the mesh above was generated with).
pts = np.array([[960.0, 540.0], # (N, 2) distorted fisheye keypoints, px
[1400.0, 800.0],
[600.0, 300.0]])
und, valid = cam.undistort_points(pts, K_new) # und: (N, 2) px, rectified frame
print(und.round(2))
print(valid)
if __name__ == "__main__":
main()
Read the Q3 fixed-point format¶
Each mesh node holds two int16 values — the horizontal and vertical displacement — in
Q3
fixed point.
The LDC hardware reads these integers and divides by 8 internally.
To recover a node's displacement in pixels, divide by 8. Read a displacement this way: to
fill output pixel p, sample the input fisheye at p + delta. Displacements grow toward the
corners.
# Code above omitted 👆
import numpy as np
from ds_msp import DoubleSphereCamera
from ds_msp.ldc import TI_LDC_MeshGenerator
def main() -> None:
# A calibrated Double Sphere camera (1920x1080 fisheye).
# width/height are optional here -- the mesh generator ignores them. They matter
# only if you also call cam.compute_K_new() / cam.get_undistortion_maps().
cam = DoubleSphereCamera(fx=711.57, fy=711.24, cx=949.18, cy=518.81,
xi=0.183, alpha=0.809, width=1920, height=1080)
gen = TI_LDC_MeshGenerator(cam)
res = gen.generate_mesh_and_intrinsics(1920, 1080, downsample_factor=4, balance=0.5)
mesh_lut = res["mesh_lut"] # (69, 121, 2) int16 -- Q3 (h, v) displacements
K_new = res["K_new"] # (3, 3) rectified pinhole intrinsics
print(mesh_lut.shape, mesh_lut.dtype) # -> (69, 121, 2) int16
print(round(float(K_new[0, 0]), 2)) # -> 426.84 (new focal length, px)
# What the dict contains -- continues from `res`.
print(list(res.keys()))
print(res["config"]["mesh_size"])
print(res["config"]["downsample_factor"])
# Read the Q3 fixed-point format -- continues from `mesh_lut`. Each node holds
# two int16 values (h, v) in Q3: pixels * 8, rounded. Divide by 8 to recover px.
node = mesh_lut[34, 60] # the node at output pixel (960, 544)
print(node) # -> (h, v) in Q3 units
print(node / 8.0) # -> displacement in pixels
print(int(mesh_lut.min()), int(mesh_lut.max())) # Q3 range across the whole mesh
# Code below omitted 👇
👀 Full file preview
"""Export a TI Jacinto LDC displacement mesh from a calibrated Double Sphere camera.
One continuous pipeline, shown incrementally on the how-to page: build the camera
(`cam`) -> wrap it in the mesh generator (`gen`) -> generate the mesh dict (`res`) ->
read the Q3 `mesh_lut` values -> sweep `downsample_factor` -> undistort keypoints with
the closed form at the same `K_new`. No external data -- the intrinsics are a
representative 1920x1080 fisheye calibration.
"""
import numpy as np
from ds_msp import DoubleSphereCamera
from ds_msp.ldc import TI_LDC_MeshGenerator
def main() -> None:
# A calibrated Double Sphere camera (1920x1080 fisheye).
# width/height are optional here -- the mesh generator ignores them. They matter
# only if you also call cam.compute_K_new() / cam.get_undistortion_maps().
cam = DoubleSphereCamera(fx=711.57, fy=711.24, cx=949.18, cy=518.81,
xi=0.183, alpha=0.809, width=1920, height=1080)
gen = TI_LDC_MeshGenerator(cam)
res = gen.generate_mesh_and_intrinsics(1920, 1080, downsample_factor=4, balance=0.5)
mesh_lut = res["mesh_lut"] # (69, 121, 2) int16 -- Q3 (h, v) displacements
K_new = res["K_new"] # (3, 3) rectified pinhole intrinsics
print(mesh_lut.shape, mesh_lut.dtype) # -> (69, 121, 2) int16
print(round(float(K_new[0, 0]), 2)) # -> 426.84 (new focal length, px)
# What the dict contains -- continues from `res`.
print(list(res.keys()))
print(res["config"]["mesh_size"])
print(res["config"]["downsample_factor"])
# Read the Q3 fixed-point format -- continues from `mesh_lut`. Each node holds
# two int16 values (h, v) in Q3: pixels * 8, rounded. Divide by 8 to recover px.
node = mesh_lut[34, 60] # the node at output pixel (960, 544)
print(node) # -> (h, v) in Q3 units
print(node / 8.0) # -> displacement in pixels
print(int(mesh_lut.min()), int(mesh_lut.max())) # Q3 range across the whole mesh
# Trade mesh size against accuracy -- continues from `gen`.
for m in (3, 4, 5):
r = gen.generate_mesh_and_intrinsics(1920, 1080, downsample_factor=m, balance=0.5)
print(m, 2**m, r["mesh_lut"].shape)
# Undistort keypoints with the closed form, not the mesh -- continues from
# `cam` and `K_new` (the SAME K_new the mesh above was generated with).
pts = np.array([[960.0, 540.0], # (N, 2) distorted fisheye keypoints, px
[1400.0, 800.0],
[600.0, 300.0]])
und, valid = cam.undistort_points(pts, K_new) # und: (N, 2) px, rectified frame
print(und.round(2))
print(valid)
if __name__ == "__main__":
main()
The integer range of this mesh runs from -3046 to 2873 Q3 units — roughly -381 px to
+359 px.
Warning
A much wider FOV produces larger displacements, which can push Q3 values past the int16
range (-32768..32767) and wrap silently to the wrong sign. After generating a mesh for an
aggressive FOV, check mesh_lut.min() and mesh_lut.max() stay inside that range. If they
sit near the limits, raise balance to crop the periphery before flashing.
Trade mesh size against accuracy with downsample_factor¶
downsample_factor is the power-of-two spacing between mesh nodes: the generator samples one
node every 2**downsample_factor output pixels.
- A smaller factor stores more nodes — a denser, more accurate mesh.
- A larger factor stores fewer — a smaller LUT the hardware bilinearly interpolates between.
downsample_factor |
Node spacing | Mesh shape (for 1920x1080) |
Nodes |
|---|---|---|---|
3 |
8 px | (136, 241, 2) |
denser, larger LUT |
4 |
16 px | (69, 121, 2) |
balanced (the default) |
5 |
32 px | (35, 61, 2) |
coarser, smaller LUT |
# Code above omitted 👆
import numpy as np
from ds_msp import DoubleSphereCamera
from ds_msp.ldc import TI_LDC_MeshGenerator
def main() -> None:
# A calibrated Double Sphere camera (1920x1080 fisheye).
# width/height are optional here -- the mesh generator ignores them. They matter
# only if you also call cam.compute_K_new() / cam.get_undistortion_maps().
cam = DoubleSphereCamera(fx=711.57, fy=711.24, cx=949.18, cy=518.81,
xi=0.183, alpha=0.809, width=1920, height=1080)
gen = TI_LDC_MeshGenerator(cam)
res = gen.generate_mesh_and_intrinsics(1920, 1080, downsample_factor=4, balance=0.5)
mesh_lut = res["mesh_lut"] # (69, 121, 2) int16 -- Q3 (h, v) displacements
K_new = res["K_new"] # (3, 3) rectified pinhole intrinsics
print(mesh_lut.shape, mesh_lut.dtype) # -> (69, 121, 2) int16
print(round(float(K_new[0, 0]), 2)) # -> 426.84 (new focal length, px)
# What the dict contains -- continues from `res`.
print(list(res.keys()))
print(res["config"]["mesh_size"])
print(res["config"]["downsample_factor"])
# Read the Q3 fixed-point format -- continues from `mesh_lut`. Each node holds
# two int16 values (h, v) in Q3: pixels * 8, rounded. Divide by 8 to recover px.
node = mesh_lut[34, 60] # the node at output pixel (960, 544)
print(node) # -> (h, v) in Q3 units
print(node / 8.0) # -> displacement in pixels
print(int(mesh_lut.min()), int(mesh_lut.max())) # Q3 range across the whole mesh
# Trade mesh size against accuracy -- continues from `gen`.
for m in (3, 4, 5):
r = gen.generate_mesh_and_intrinsics(1920, 1080, downsample_factor=m, balance=0.5)
print(m, 2**m, r["mesh_lut"].shape)
# Code below omitted 👇
👀 Full file preview
"""Export a TI Jacinto LDC displacement mesh from a calibrated Double Sphere camera.
One continuous pipeline, shown incrementally on the how-to page: build the camera
(`cam`) -> wrap it in the mesh generator (`gen`) -> generate the mesh dict (`res`) ->
read the Q3 `mesh_lut` values -> sweep `downsample_factor` -> undistort keypoints with
the closed form at the same `K_new`. No external data -- the intrinsics are a
representative 1920x1080 fisheye calibration.
"""
import numpy as np
from ds_msp import DoubleSphereCamera
from ds_msp.ldc import TI_LDC_MeshGenerator
def main() -> None:
# A calibrated Double Sphere camera (1920x1080 fisheye).
# width/height are optional here -- the mesh generator ignores them. They matter
# only if you also call cam.compute_K_new() / cam.get_undistortion_maps().
cam = DoubleSphereCamera(fx=711.57, fy=711.24, cx=949.18, cy=518.81,
xi=0.183, alpha=0.809, width=1920, height=1080)
gen = TI_LDC_MeshGenerator(cam)
res = gen.generate_mesh_and_intrinsics(1920, 1080, downsample_factor=4, balance=0.5)
mesh_lut = res["mesh_lut"] # (69, 121, 2) int16 -- Q3 (h, v) displacements
K_new = res["K_new"] # (3, 3) rectified pinhole intrinsics
print(mesh_lut.shape, mesh_lut.dtype) # -> (69, 121, 2) int16
print(round(float(K_new[0, 0]), 2)) # -> 426.84 (new focal length, px)
# What the dict contains -- continues from `res`.
print(list(res.keys()))
print(res["config"]["mesh_size"])
print(res["config"]["downsample_factor"])
# Read the Q3 fixed-point format -- continues from `mesh_lut`. Each node holds
# two int16 values (h, v) in Q3: pixels * 8, rounded. Divide by 8 to recover px.
node = mesh_lut[34, 60] # the node at output pixel (960, 544)
print(node) # -> (h, v) in Q3 units
print(node / 8.0) # -> displacement in pixels
print(int(mesh_lut.min()), int(mesh_lut.max())) # Q3 range across the whole mesh
# Trade mesh size against accuracy -- continues from `gen`.
for m in (3, 4, 5):
r = gen.generate_mesh_and_intrinsics(1920, 1080, downsample_factor=m, balance=0.5)
print(m, 2**m, r["mesh_lut"].shape)
# Undistort keypoints with the closed form, not the mesh -- continues from
# `cam` and `K_new` (the SAME K_new the mesh above was generated with).
pts = np.array([[960.0, 540.0], # (N, 2) distorted fisheye keypoints, px
[1400.0, 800.0],
[600.0, 300.0]])
und, valid = cam.undistort_points(pts, K_new) # und: (N, 2) px, rectified frame
print(und.round(2))
print(valid)
if __name__ == "__main__":
main()
balance is the same field-of-view knob as in CPU undistortion:
balance=0.0keeps the widest scene, with black corners.balance=1.0crops in until the borders are gone.
It sets K_new — at balance=0.5 here, fx_new = 426.84 px. See
Undistort a fisheye image for how balance trades FOV against borders.
Undistort keypoints with the closed form, not the mesh¶
Use the mesh for the picture. Undistort keypoints with the closed-form
cam.undistort_points(pts, K_new) at the same K_new — that is, the same balance.
The mesh's point-inverse is exact at the center and diverges toward the periphery. Sharing
K_new keeps the image pipeline and the keypoint pipeline on the same rectified frame.
# Code above omitted 👆
import numpy as np
from ds_msp import DoubleSphereCamera
from ds_msp.ldc import TI_LDC_MeshGenerator
def main() -> None:
# A calibrated Double Sphere camera (1920x1080 fisheye).
# width/height are optional here -- the mesh generator ignores them. They matter
# only if you also call cam.compute_K_new() / cam.get_undistortion_maps().
cam = DoubleSphereCamera(fx=711.57, fy=711.24, cx=949.18, cy=518.81,
xi=0.183, alpha=0.809, width=1920, height=1080)
gen = TI_LDC_MeshGenerator(cam)
res = gen.generate_mesh_and_intrinsics(1920, 1080, downsample_factor=4, balance=0.5)
mesh_lut = res["mesh_lut"] # (69, 121, 2) int16 -- Q3 (h, v) displacements
K_new = res["K_new"] # (3, 3) rectified pinhole intrinsics
print(mesh_lut.shape, mesh_lut.dtype) # -> (69, 121, 2) int16
print(round(float(K_new[0, 0]), 2)) # -> 426.84 (new focal length, px)
# What the dict contains -- continues from `res`.
print(list(res.keys()))
print(res["config"]["mesh_size"])
print(res["config"]["downsample_factor"])
# Read the Q3 fixed-point format -- continues from `mesh_lut`. Each node holds
# two int16 values (h, v) in Q3: pixels * 8, rounded. Divide by 8 to recover px.
node = mesh_lut[34, 60] # the node at output pixel (960, 544)
print(node) # -> (h, v) in Q3 units
print(node / 8.0) # -> displacement in pixels
print(int(mesh_lut.min()), int(mesh_lut.max())) # Q3 range across the whole mesh
# Trade mesh size against accuracy -- continues from `gen`.
for m in (3, 4, 5):
r = gen.generate_mesh_and_intrinsics(1920, 1080, downsample_factor=m, balance=0.5)
print(m, 2**m, r["mesh_lut"].shape)
# Undistort keypoints with the closed form, not the mesh -- continues from
# `cam` and `K_new` (the SAME K_new the mesh above was generated with).
pts = np.array([[960.0, 540.0], # (N, 2) distorted fisheye keypoints, px
[1400.0, 800.0],
[600.0, 300.0]])
und, valid = cam.undistort_points(pts, K_new) # und: (N, 2) px, rectified frame
print(und.round(2))
print(valid)
# Code below omitted 👇
👀 Full file preview
"""Export a TI Jacinto LDC displacement mesh from a calibrated Double Sphere camera.
One continuous pipeline, shown incrementally on the how-to page: build the camera
(`cam`) -> wrap it in the mesh generator (`gen`) -> generate the mesh dict (`res`) ->
read the Q3 `mesh_lut` values -> sweep `downsample_factor` -> undistort keypoints with
the closed form at the same `K_new`. No external data -- the intrinsics are a
representative 1920x1080 fisheye calibration.
"""
import numpy as np
from ds_msp import DoubleSphereCamera
from ds_msp.ldc import TI_LDC_MeshGenerator
def main() -> None:
# A calibrated Double Sphere camera (1920x1080 fisheye).
# width/height are optional here -- the mesh generator ignores them. They matter
# only if you also call cam.compute_K_new() / cam.get_undistortion_maps().
cam = DoubleSphereCamera(fx=711.57, fy=711.24, cx=949.18, cy=518.81,
xi=0.183, alpha=0.809, width=1920, height=1080)
gen = TI_LDC_MeshGenerator(cam)
res = gen.generate_mesh_and_intrinsics(1920, 1080, downsample_factor=4, balance=0.5)
mesh_lut = res["mesh_lut"] # (69, 121, 2) int16 -- Q3 (h, v) displacements
K_new = res["K_new"] # (3, 3) rectified pinhole intrinsics
print(mesh_lut.shape, mesh_lut.dtype) # -> (69, 121, 2) int16
print(round(float(K_new[0, 0]), 2)) # -> 426.84 (new focal length, px)
# What the dict contains -- continues from `res`.
print(list(res.keys()))
print(res["config"]["mesh_size"])
print(res["config"]["downsample_factor"])
# Read the Q3 fixed-point format -- continues from `mesh_lut`. Each node holds
# two int16 values (h, v) in Q3: pixels * 8, rounded. Divide by 8 to recover px.
node = mesh_lut[34, 60] # the node at output pixel (960, 544)
print(node) # -> (h, v) in Q3 units
print(node / 8.0) # -> displacement in pixels
print(int(mesh_lut.min()), int(mesh_lut.max())) # Q3 range across the whole mesh
# Trade mesh size against accuracy -- continues from `gen`.
for m in (3, 4, 5):
r = gen.generate_mesh_and_intrinsics(1920, 1080, downsample_factor=m, balance=0.5)
print(m, 2**m, r["mesh_lut"].shape)
# Undistort keypoints with the closed form, not the mesh -- continues from
# `cam` and `K_new` (the SAME K_new the mesh above was generated with).
pts = np.array([[960.0, 540.0], # (N, 2) distorted fisheye keypoints, px
[1400.0, 800.0],
[600.0, 300.0]])
und, valid = cam.undistort_points(pts, K_new) # und: (N, 2) px, rectified frame
print(und.round(2))
print(valid)
if __name__ == "__main__":
main()
$ python3 -m docs_src.how_to.export_ldc_mesh.mesh_pipeline
(69, 121, 2) int16
426.84
['mesh_lut', 'mesh_lut_float', 'K_new', 'config']
(69, 121, 2)
4
[ -87 -156]
[-10.875 -19.5 ]
-3046 2873
3 8 (136, 241, 2)
4 16 (69, 121, 2)
5 32 (35, 61, 2)
[[ 967.68 555.05]
[1427.98 832.03]
[ 657.04 350.07]]
[ True True True]
That last block is the full pipeline output — the same command as every stage above, run once, start to finish.
Why share K_new¶
Measured against the closed-form result over keypoints spread across the frame:
- the mesh point-inverse agrees to a median of ~0.08 px,
- and to ~0.05 px in the central region (radius
< 300 px).
It diverges sharply toward the periphery. Out at the corners (here, roughly r > 600 px from
the principal point) the disagreement reached ~80 px in this configuration.
So use the mesh to render the image, and the closed form for any coordinate you need
precisely: PnP, feature tracks, reprojection. Both must use the same K_new.
Warning
Do not undistort keypoints by inverting the displacement mesh. It is accurate only near the
center. cam.undistort_points is exact everywhere a ray is recoverable, and its second return
value flags points that are not.
Troubleshooting: a camera method raises about image dimensions¶
width/height on the DoubleSphereCamera are not used by TI_LDC_MeshGenerator — the
mesh is sized from the explicit output_width/output_height arguments. The camera's own
image-level helpers do need them: cam.compute_K_new() and cam.get_undistortion_maps() both
raise ValueError without them.
"""Troubleshooting: `width`/`height` are optional for the mesh generator, but not for
image-level camera helpers.
`TI_LDC_MeshGenerator` sizes its mesh from the explicit `output_width`/`output_height`
arguments to `generate_mesh_and_intrinsics`, so it never needs `cam.width`/`cam.height`.
`DoubleSphereCamera.compute_K_new()` and `get_undistortion_maps()` are image-level helpers
that do need them, and raise `ValueError` without them.
"""
from ds_msp import DoubleSphereCamera
from ds_msp.ldc import TI_LDC_MeshGenerator
def main() -> None:
cam = DoubleSphereCamera(711.57, 711.24, 949.18, 518.81, 0.183, 0.809) # no width/height
# Fine -- the mesh generator uses its own output_width/output_height arguments.
gen = TI_LDC_MeshGenerator(cam)
res = gen.generate_mesh_and_intrinsics(1920, 1080)
print(res["mesh_lut"].shape) # -> (69, 121, 2) works without cam.width/cam.height
# Raises -- cam.compute_K_new() needs the sensor dimensions.
try:
cam.compute_K_new()
except ValueError as exc:
print(f"ValueError: {exc}")
# Fix: supply width and height on the camera if you also call image-level ops.
cam_sized = DoubleSphereCamera(711.57, 711.24, 949.18, 518.81, 0.183, 0.809,
width=1920, height=1080)
K = cam_sized.compute_K_new() # now works
print(round(float(K[0, 0]), 2)) # -> 426.84, same balance=0.5 default as generate_mesh_and_intrinsics
if __name__ == "__main__":
main()
Try it yourself¶
Re-run the generator with downsample_factor=5. Before you run it, predict two things: will
the mesh shape have more or fewer nodes than (69, 121, 2), and will K_new change?
Run it, then open the answer.
Answer
The node count drops to (35, 61, 2) — a coarser grid stores fewer nodes. K_new is
unchanged: it depends on balance, not on the node spacing. So you can shrink the LUT without
re-deriving the rectified frame your keypoint pipeline shares.
Next steps¶
- Undistort on the CPU/GPU instead —
Undistort a fisheye image: the software path with the same
balanceknob, for hosts without an LDC engine. - The code used here — source on GitHub:
ds_msp/ldc.py(TI_LDC_MeshGenerator.generate_mesh_and_intrinsics) andds_msp/model.py(DoubleSphereCamera.undistort_points). - Other recipes — back to the How-to guides.