ROS 2 helpers for hand-eye calibration with an OAK 4 Pro AF mounted on a UR10e end effector.
Build the package from the workspace root:
cd /home/rosmatch/colcon_ws
source /opt/ros/jazzy/setup.bash
colcon build --packages-select oak_camera_calibration --symlink-install
source install/setup.bashIf ROS prints warnings about switching between one and jazzy, start a fresh
terminal or clear the old ROS environment before sourcing Jazzy:
unset ROS_DISTRO ROS_VERSION ROS_PYTHON_VERSION ROS_ROOT ROS_PACKAGE_PATH
unset AMENT_PREFIX_PATH COLCON_PREFIX_PATH CMAKE_PREFIX_PATH
unset LD_LIBRARY_PATH PYTHONPATH
source /opt/ros/jazzy/setup.bash
source /home/rosmatch/colcon_ws/install/setup.bashStart the OAK via the installed DepthAI ROS 2 V3 driver. The default parent
frame is the right UR TCP, mur620/UR10_r/tool0:
ros2 launch oak_camera_calibration oak4_pro_af_4k.launch.pyThis uses config/oak4_pro_af_4k.yaml and requests a 3840x2160 RGB stream.
It also publishes the calibrated camera TF from mur620/UR10_r/tool0 to the
DepthAI oak base frame. The direct hand-eye result is documented in
config/mur620_ur10_r_oak_handeye.yaml as
mur620/UR10_r/tool0 -> oak_rgb_camera_optical_frame; the launch defaults are
the equivalent tool0 -> oak parameters expected by the DepthAI driver.
The higher-resolution profile is also available:
ros2 launch oak_camera_calibration oak4_pro_af_rgb.launch.pySave one image together with the factory calibration published as CameraInfo:
ros2 run oak_camera_calibration capture_oak_snapshotBy default the snapshot tool listens to:
/oak/rgb/image_raw/oak/rgb/camera_info
and writes to ~/oak_handeye_samples. It ignores the first frames while
auto-exposure and autofocus settle, skips very dark frames, then stores the
sharpest frame from a short burst. It writes the raw image, the received
CameraInfo, image metrics, and a rectified PNG computed from that
CameraInfo. For hand-eye calibration it is usually best to keep the raw image
plus distortion coefficients and let the calibration/detection code use both
explicitly.
You can override topics or output path like this:
ros2 run oak_camera_calibration capture_oak_snapshot --ros-args \
-p image_topic:=/oak/rgb/image_raw \
-p camera_info_topic:=/oak/rgb/camera_info \
-p output_dir:=~/oak_handeye_samples \
-p save_rectified:=trueUseful snapshot parameters while tuning image quality:
ros2 run oak_camera_calibration capture_oak_snapshot --ros-args \
-p warmup_frames:=20 \
-p select_frames:=20 \
-p min_mean_intensity:=10.0 \
-p timeout_sec:=60.0If images remain blurry after warmup, inspect live focus first:
ros2 run rqt_image_view rqt_image_view /oak/rgb/image_rawFor calibration captures, fixed lighting and a fixed working distance are preferable. Once the target distance is known, manual focus via the DepthAI driver parameters can be tested instead of relying on autofocus.
For repeated hand-eye samples, keep the camera driver running and start the GUI:
ros2 run oak_camera_calibration oak_sample_guiThe GUI uses the 10x7 ArUco GridBoard with DICT_4X4_250, 30 mm markers, and
10 mm separation. It detects the board with a downscaled full-image pass, then
refines inside an ROI so the full 8k image is never passed directly to
cv2.aruco.detectMarkers().
By default each saved sample also stores the current TF from base_link to tool0.
If your UR TCP frame has a different name, pass it explicitly:
ros2 run oak_camera_calibration oak_sample_gui --ros-args \
-p robot_base_frame:=base_link \
-p robot_tcp_frame:=tool0The HUD shows whether this TF is available. With the default
require_robot_pose:=true and require_detection_pose:=true, pressing s only
writes a sample if the image, CameraInfo, marker pose, and robot TF are all
available.
Keys:
s: save one sampled: toggle marker detectionqorEsc: close the GUI
Samples are appended to ~/oak_handeye_samples as sample_000, sample_001,
and so on. Closing the GUI never deletes samples; delete files manually only
when you really want to discard them.
Each sample contains:
- raw PNG
- optional rectified PNG
- annotated PNG
CameraInfoYAML- JSON metadata with board model, marker IDs, ROI, pose, reprojection error, and the UR base-to-TCP transform
After capturing multiple poses, compute the camera transform relative to the TCP:
ros2 run oak_camera_calibration compute_handeye --samples-dir ~/oak_handeye_samplesThe tool uses OpenCV's eye-in-hand calibration and prints tcp <- camera,
per-sample translational/rotational consistency, and the marker reprojection
error. Samples without robot TF are skipped. Large residuals are a good hint
that a sample should be inspected and deleted manually before recomputing.
For a guided hardware session, start the robot first, then launch the OAK driver and the semi-auto GUI together:
ros2 launch oak_camera_calibration mur620_oak_handeye.launch.pyThe launch defaults are for the OAK mounted on the right arm:
arm:=rcamera_launch_file:=oak4_pro_af_4k.launch.pycamera_parent_frame:=mur620/UR10_r/tool0- calibrated camera driver TF:
camera_cam_pos_x:=0.0068564203,camera_cam_pos_y:=-0.0892312561,camera_cam_pos_z:=0.1018930213,camera_cam_roll:=-3.0221294847,camera_cam_pitch:=-1.5221462887,camera_cam_yaw:=-1.7026932502 robot_base_frame:=mur620/UR10_r/base_linkrobot_tcp_frame:=mur620/UR10_r/tool0action_name:=/mur620/jparse_move_rjog_twist_topic:=/mur620/jparse_velocity_controller_r/twist_cmdjog_frame:=UR10_r/base_linkjog_gui_enabled:=truemove_enabled:=falsekeyboard_jog_enabled:=falsedictionary:=DICT_4X4_250board_id_order:=column_majorsquares_x:=6squares_y:=9square_length_m:=0.065marker_length_m:=0.048use_camera_tf_initial_guess:=truecamera_look_axis:=plus_zcamera_roll_reference:=currenttarget_max_camera_z_above_start_m:=0.01max_linear_velocity:=0.025max_angular_velocity:=0.10target_max_tcp_delta_m:=0.25target_max_camera_delta_m:=0.30target_min_camera_delta_m:=0.04target_max_rotation_deg:=35.0split_target_motion:=truesplit_rotation_step_deg:=25.0center_camera_xy_only:=truedraw_board_center_overlay:=truetarget_pattern:=spiral_hemispherehemisphere_axis_source:=board_normalsamples:=18sphere_polar_span_deg:=50.0sphere_spiral_turns:=1.25
The session estimates the board pose from the first usable view, generates a
3D spiral on a hemisphere around the board center, and writes the same
sample_*.json files used by compute_handeye. By default it writes to
~/oak_charuco_6x9_column_major_handeye_samples so older samples captured with
the previous wrong board geometry are not mixed into the solve.
If the camera driver is already running, keep it running and start only the session:
ros2 launch oak_camera_calibration mur620_oak_handeye.launch.py launch_camera:=falseWhen the generated poses look plausible, enable automatic J-PARSE motion:
ros2 launch oak_camera_calibration mur620_oak_handeye.launch.py move_enabled:=trueWith split_target_motion:=true, automatic target moves first drive to the
new TCP position while keeping the current orientation, then rotate toward the
final target in smaller split_rotation_step_deg increments.
The initial camera pose is treated as the top of the hemisphere. Proposed
camera targets are filtered so they do not move more than
target_max_camera_z_above_start_m above that start height.
The z GUI action computes the lateral offset between the optical axis and the
estimated board center, keeps the current camera orientation and height, and
moves only in base XY by default. Set center_camera_xy_only:=false to allow
the full image-plane correction.
If a proposed target moves to a plausible position but rotates the camera away
from the board, press v in the GUI and then n to regenerate the target. If
that fixes the sign, restart with camera_look_axis:=minus_z.
The target orientation keeps the selected optical axis pointed at the board
center and uses camera_roll_reference to choose the roll around that view
axis. The default current keeps the current/start image roll, which is useful
for the wide OAK image sensor. Useful alternatives are
board_x_to_camera_x, minus_board_x_to_camera_x, board_y_to_camera_y, and
minus_board_y_to_camera_y.
In the GUI, use:
n: propose the next sphere target and show TCP/camera deltasg: send the shown target to J-PARSE ifmove_enabled:=truez: center the current camera view over the board center with lateral motionp: point the current camera orientation at the board center without translatingb: move back to the first valid TCP pose captured after session startc: save the current samplev: flip the camera look axis for target generation, then pressnagainr: cycle the camera roll reference, then pressnagain- manual Cartesian jog is handled by the separate
mur620 robot jogwindow m: toggle translation/rotation jog modeqorEsc: close the GUI
When a ChArUco pose is available, the live image overlays board geometry: magenta marks the board center, blue marks the board origin, cyan marks the image center, orange marks the assumed full board outline, green marks the inner ChArUco-corner area, and the yellow line shows the pixel offset to center.
In the separate jog GUI, hold the on-screen buttons to jog the robot. Keyboard
fallbacks are arrow keys for XY, PgUp/PgDn for Z, m to toggle rotation
mode, i/k, j/l, u/o for direct rotation, the house button or h to move
to the stored home pose, space or . to stop, and q to close only the jog
window. The default home pose is stored in
config/mur620_ur10_r_home.yaml.
The terminal prompts before every automatic move and before every saved sample.
After at least four usable samples it updates the current tcp <- camera
estimate and stores the latest session state in semi_auto_session_state.yaml.
The saved ChArUco samples can be solved explicitly with:
ros2 run oak_camera_calibration compute_handeye \
--samples-dir ~/oak_charuco_6x9_column_major_handeye_samples