Lucida is a Python toolkit for precision camera geometry, single-view calibration, and multi-view rig calibration. It features a backend-agnostic geometry kernel (NumPy/JAX) and supports robust Bundle Adjustment (JAX only).
Create a camera model from specifications, project 3D points, and visualise rays.
import numpy as np
from lucida import CameraModel, Intrinsics, Extrinsics
# Define Intrinsics (from physical specs)
intrinsics = Intrinsics.from_specs(
image_size=(1920, 1080),
focal=35.0, # 35mm lens
sensor='Full frame', # 36x24mm sensor
distortion_model='standard'
)
# Define Extrinsics (Camera at origin, looking forward)
extrinsics = Extrinsics(
tvec=[0, 0, 0],
rvec=[0, 0, 0],
convention='c2w' # Camera-to-World
)
# Create camera model
cam = CameraModel(intrinsics, extrinsics, name="cam_01")
# Project 3D points
points_3d = np.array([
[0, 0, 1000], # 1m in front
[500, 200, 2000]
])
pixels, valid_mask = cam.project(points_3d)
print(f"Projected Pixels:\n{pixels}")
# Raycast (back-projection)
origins, directions = cam.raycast(pixels)Generate SVG files for Charuco or Chessboards to print.
from lucida.calibration import CharucoBoard
# Define a 5x7 Charuco board with 30mm squares
board = CharucoBoard(
rows=5,
cols=7,
square_length=30.0, # in millimetres
marker_size=4 # Aruco marker size (in squares)
)
# Generate SVG for printing
svg_content = board.to_svg()
with open("calibration_target.svg", "w") as f:
f.write(svg_content)Calibrate a single camera using the calibration board. Note that detection and calibration logic are decoupled.
import cv2
from lucida import CameraModel, Intrinsics, Extrinsics
from lucida.calibration import MonocularCalibrationTool, CharucoBoard, CharucoDetector
# Setup camera (with a guess), and the board
cam = CameraModel(
Intrinsics.from_specs((1920, 1080), focal=50, sensor='APS-C'),
Extrinsics()
)
board = CharucoBoard(rows=10, cols=7, square_length=25.0)
# Init detection and solver separately
detector = CharucoDetector(board)
tool = MonocularCalibrationTool(cam, board)
# Feed it frames
# (mock loop, in reality, read from cv2.VideoCapture)
for i in range(50):
frame = cv2.imread(f"data/calib_img_{i}.jpg")
# Detect (stateless)
detection = detector.detect(frame, K=cam.K, D=cam.D)
if detection.valid:
# Register (stateful)
# Returns True only if the frame improved calibration coverage
accepted = tool.register_detection(detection.image_points)
if accepted:
print(f"Frame {i} accepted. Current coverage: {tool.current_coverage:.1f}%")
# Run calibration
if tool.compute_intrinsics():
print(f"Calibration successful! RMS: {cam.intrinsics.rms:.4f}")
cam.save("calibrated_camera.toml")Calibrate relative poses between multiple cameras.
from lucida import CameraRig
from lucida.calibration import MultiviewCalibrationTool, CharucoDetector
# Load a rig with rough initial guesses
rig = CameraRig.load("initial_rig.toml")
board = CharucoBoard(rows=5, cols=7, square_length=30.0)
# Init tools
detector = CharucoDetector(board)
tool = MultiviewCalibrationTool(rig, board, anchor_cam="cam_01")
# Feed synchronized frames
# (mock example with a frame_dict = { 'cam_01': img1, 'cam_02': img2 ... } )
for idx, frame_dict in enumerate(synchronized_stream):
for cam_name, img in frame_dict.items():
# Detect (stateless)
cam_idx = rig.get_index(cam_name)
detection = detector.detect(img, K=rig[cam_idx].K, D=rig[cam_idx].D)
if detection.valid:
# Buffer for stereo matching
# Returns True if PnP solved and frame buffered
tool.register_detection(cam_idx, idx, detection.image_points)
# Optimize geometry
success = tool.refine()
if success:
print("Bundle Adjustment complete.")
rig.save("optimized_rig.toml")Since the Detector classes are stateless, you can offload image processing to worker threads while keeping the Calibration Tool in the main thread.
import queue
import threading
def worker(input_queue, output_queue, detector):
"""Consumes images, produces detections."""
while True:
frame_data = input_queue.get()
if frame_data is None: break
img = frame_data['image']
# Heavy image processing happens here, releasing the GIL (well, mostly)
det = detector.detect(img)
output_queue.put({'det': det, 'orig': frame_data})
######
# Main thread
detector = CharucoDetector(board)
tool = MonocularCalibrationTool(cam, board)
# setup queues, start worker, etc ...
while True:
try:
result = out_queue.get_nowait()
detection = result['det']
# Visualisation (always draw, even if rejected by tool)
if detection.valid:
draw_points(result['orig']['image'], detection.image_points)
# Calibration Logic (lightweight)
if detection.valid:
if tool.register_detection(detection.image_points):
print("Added sample!")
except queue.Empty:
pass