# IMX219 Camera Calibration Workflow Full step-by-step calibration procedure for the 4× IMX219 160° fisheye CSI cameras on saltybot (cam0=front, cam1=right, cam2=rear, cam3=left). ## Hardware | Camera | Position | Yaw | Topic | |--------|----------|-------|------------------------------| | cam0 | Front | 0° | /camera/front/image_raw | | cam1 | Right | -90° | /camera/right/image_raw | | cam2 | Rear | 180° | /camera/rear/image_raw | | cam3 | Left | 90° | /camera/left/image_raw | Mount geometry: radius=5cm from robot centre, height=30cm, 90° angular spacing. ## Prerequisites - OpenCV with fisheye support: `pip3 install opencv-python numpy pyyaml` - For ROS2 mode: ROS2 Humble, cv_bridge, image_transport - Printed checkerboard: 8×6 inner corners, 25mm squares (A3 paper recommended) - Generator: `python3 -c "import cv2; board=cv2.aruco.CharucoBoard((9,7),0.025,0.0175,cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_6X6_250)); cv2.imwrite('board.png', board.generateImage((700,500)))"` - Or download from: https://calib.io/pages/camera-calibration-pattern-generator ## Step 1 — Print and Prepare the Checkerboard - Print the 8×6 inner-corner checkerboard at exactly 25mm per square - Mount flat on a rigid board (cardboard or foamcore) — no warping allowed - Measure actual square size with calipers, update `--square-size` if different ## Step 2 — Start Cameras ```bash # On the Jetson, in the saltybot docker container: ros2 launch saltybot_cameras cameras.launch.py # Verify all 4 topics are publishing: ros2 topic list | grep camera ros2 topic hz /camera/front/image_raw ``` ## Step 3 — Capture Calibration Images (30 per camera) Run for each camera in turn. Move the board to many different positions and angles within the field of view — especially corners and edges. ```bash # Option A: ROS2 mode (recommended on Jetson, requires running cameras) ros2 run saltybot_calibration capture_calibration_images.py \ --ros --camera front --n-images 30 \ --output-dir /mnt/nvme/saltybot/calibration/raw ros2 run saltybot_calibration capture_calibration_images.py \ --ros --camera right --n-images 30 \ --output-dir /mnt/nvme/saltybot/calibration/raw ros2 run saltybot_calibration capture_calibration_images.py \ --ros --camera rear --n-images 30 \ --output-dir /mnt/nvme/saltybot/calibration/raw ros2 run saltybot_calibration capture_calibration_images.py \ --ros --camera left --n-images 30 \ --output-dir /mnt/nvme/saltybot/calibration/raw # Option B: All cameras sequentially (prompts between cameras) ros2 run saltybot_calibration capture_calibration_images.py \ --ros --camera all --n-images 30 \ --output-dir /mnt/nvme/saltybot/calibration/raw # Option C: Standalone (no ROS2, uses /dev/videoX directly) python3 capture_calibration_images.py \ --camera front --n-images 30 \ --device /dev/video0 \ --output-dir /mnt/nvme/saltybot/calibration/raw ``` ### Capture Tips for Fisheye Lenses - Cover the entire FoV — include corners and edges (where distortion is strongest) - Use 5+ different orientations of the board (tilt, rotate, skew) - Keep the board fully visible (no partial views) - Good lighting, no motion blur - Aim for 20-30 images with varied positions ## Step 4 — Compute Intrinsics per Camera ```bash for cam in front right rear left; do ros2 run saltybot_calibration calibrate_camera.py \ --images-dir /mnt/nvme/saltybot/calibration/raw/cam_${cam} \ --output /mnt/nvme/saltybot/calibration/${cam}/camera_info.yaml \ --camera-name camera_${cam} \ --board-size 8x6 \ --square-size 0.025 \ --image-size 640x480 \ --show-reprojection done ``` ## Step 5 — Verify Reprojection Error Target: RMS reprojection error < 1.5 px per camera. If RMS > 1.5 px: 1. Remove outlier images (highest per-image error from table) 2. Recapture with more varied board positions 3. Check board is truly flat and square size is correct 4. Try `--image-size 1280x720` if capturing at higher resolution ```bash # Quick check — view undistorted preview ros2 run saltybot_calibration preview_undistorted.py \ --image /mnt/nvme/saltybot/calibration/raw/cam_front/frame_0000.png \ --calibration /mnt/nvme/saltybot/calibration/front/camera_info.yaml ``` ## Step 6 — Compute Extrinsics (Mechanical Mode) Uses known mount geometry (radius=5cm, height=30cm, 90° spacing) to compute analytically exact camera-to-base_link transforms. ```bash ros2 run saltybot_calibration calibrate_extrinsics.py \ --mode mechanical \ --mount-radius 0.05 \ --mount-height 0.30 \ --output /mnt/nvme/saltybot/calibration/extrinsics.yaml \ --generate-launch \ ~/ros2_ws/src/saltybot_bringup/launch/static_transforms.launch.py ``` If mechanical dimensions differ from defaults, update `--mount-radius` and `--mount-height` from your actual measurements. ## Step 7 — Generate Static Transform Launch File (Already done by --generate-launch in Step 6, but can regenerate independently:) ```bash ros2 run saltybot_calibration generate_static_transforms.py \ --extrinsics /mnt/nvme/saltybot/calibration/extrinsics.yaml \ --output ~/ros2_ws/src/saltybot_bringup/launch/static_transforms.launch.py ``` ## Step 8 — Update surround_vision_params.yaml Edit `jetson/config/surround_vision_params.yaml` (or the equivalent in your saltybot_surround package) to point camera_info_url at the new calibration files: ```yaml camera_info_urls: front: 'file:///mnt/nvme/saltybot/calibration/front/camera_info.yaml' right: 'file:///mnt/nvme/saltybot/calibration/right/camera_info.yaml' rear: 'file:///mnt/nvme/saltybot/calibration/rear/camera_info.yaml' left: 'file:///mnt/nvme/saltybot/calibration/left/camera_info.yaml' ``` ## Step 9 — Restart Nodes ```bash # Restart camera and surround nodes to pick up new calibration ros2 lifecycle set /saltybot_cameras shutdown ros2 launch saltybot_bringup cameras.launch.py ros2 launch saltybot_surround surround.launch.py ``` ## Output Files After full calibration, you should have: ``` /mnt/nvme/saltybot/calibration/ front/camera_info.yaml # intrinsics: K, D (equidistant) right/camera_info.yaml rear/camera_info.yaml left/camera_info.yaml extrinsics.yaml # base_link -> camera_*_optical_frame ``` Template files are in `jetson/calibration/` (tracked in git) with placeholder IMX219 values (fx=229px @ 640×480). Replace with calibrated values. ## Distortion Model Reference The IMX219 160° fisheye uses the **equidistant** (Kannala-Brandt) model: ``` r(theta) = k1*theta + k2*theta^3 + k3*theta^5 + k4*theta^7 ``` where theta is the angle of incidence. OpenCV's `cv2.fisheye` module implements this model with 4 coefficients [k1, k2, k3, k4]. This is different from the `plumb_bob` (radial/tangential) model used for standard lenses — do not mix them up.