Self-contained camera calibration for Go2Kin, adapted from the Caliscope project (BSD-2-Clause license, Mac Prible).
This module extracts the calibration algorithms from Caliscope and adapts them for Go2Kin's stack. Caliscope is a full-featured multi-camera calibration and motion capture application with a PySide6 GUI, mediapipe pose estimation, pyvista 3D visualization, numba JIT compilation, and pandera schema validation.
What was extracted:
- Charuco board detection and corner tracking
- Intrinsic calibration (single-camera lens calibration)
- Extrinsic calibration (multi-camera relative pose estimation)
- Bundle adjustment optimization
- Umeyama similarity transform for coordinate alignment
- Scale accuracy metrics
What was deliberately left out:
- PySide6 Qt GUI — replaced with tkinter (Go2Kin's existing framework)
- mediapipe pose estimation — not needed for camera calibration
- pyvista 3D visualization — replaced with matplotlib for camera position display
- numba JIT — replaced with pure numpy (see Dependency Decisions)
- pandera schema validation — replaced with simple column checks
- Motion capture pipeline (trackers, synchronizer, recording workflows)
- TOML persistence — replaced with JSON
All adapted code retains the BSD-2-Clause license from Caliscope. The triangulation function additionally carries the BSD-2-Clause license from Anipose (Lili Karashchuk).
code/calibration/
__init__.py Package marker with attribution
charuco.py Charuco board definition and image generation
charuco_tracker.py Corner detection using cv2.aruco.CharucoDetector
data_types.py Core data structures (6 classes)
frame_selector.py Smart frame selection for intrinsic calibration
intrinsic.py cv2.calibrateCamera wrapper
video_processor.py MP4 files -> ImagePoints bridge (NEW)
extrinsic.py PoseNetworkBuilder (PnP + pose estimation)
paired_pose_network.py Stereo pair graph with gap-filling
triangulation.py Pure-numpy DLT triangulation (NEW)
reprojection.py Reprojection error computation
reprojection_report.py Error report dataclass
bundle_adjustment.py PointDataBundle + scipy optimization
alignment.py Umeyama similarity transform
scale_accuracy.py Volumetric scale error metrics
calibrate.py High-level orchestrator (NEW)
persistence.py JSON save/load (NEW)
code/GUI/
calibration_tab.py tkinter Calibration tab (NEW)
Files marked NEW are original Go2Kin code, not adapted from Caliscope.
MP4 video file
|
v
video_processor.extract_charuco_points_from_video()
| For each sampled frame:
| charuco_tracker.CharucoTracker.get_points()
| -> cv2.aruco.CharucoDetector.detectBoard()
| -> cv2.cornerSubPix() (sub-pixel refinement)
| -> PointPacket (ids, img_loc, obj_loc)
|
v
ImagePoints (DataFrame: sync_index, cam_id, point_id, img_loc_x/y, obj_loc_x/y/z)
|
v
frame_selector.select_calibration_frames()
| Phase 1: Orientation anchors (8 tilt bins, min 4 required)
| Phase 2: Greedy spatial coverage (5x5 grid, edge/corner weights)
| Result: ~30 best frames
|
v
intrinsic.calibrate_intrinsics()
| cv2.calibrateCamera() or cv2.fisheye.calibrate()
|
v
IntrinsicCalibrationOutput
camera_matrix (3x3)
distortions (5 coeffs standard, 4 coeffs fisheye)
reprojection RMSE
Synced MP4 files (from audio sync)
|
v
video_processor.discover_synced_videos()
| Parse _GP{N}.mp4 filenames, skip stitched_videos.mp4
|
v
video_processor.extract_charuco_points_from_videos()
| Same sync_index = same moment in time across cameras
|
v
ImagePoints (multi-camera observations)
|
v
extrinsic.PoseNetworkBuilder
|
|-- .estimate_camera_to_object_poses()
| Per (cam_id, sync_index): cv2.solvePnP() on undistorted points
| Returns: dict[(cam_id, sync_index)] -> (R, t, rmse)
|
|-- .estimate_relative_poses()
| For each camera pair (A, B) at each common sync_index:
| T_B_A = T_B_obj @ inv(T_A_obj)
| Returns: dict[((cam_a, cam_b), sync_index)] -> StereoPair
|
|-- .filter_outliers()
| IQR-based rejection on:
| - Translation magnitude
| - Rotation angle (geodesic from median quaternion)
| Threshold: 1.5x IQR (default)
|
|-- .build()
| Quaternion averaging per pair (eigen decomposition of Q@Q^T)
| Translation averaging (mean)
| Stereo RMSE computation (triangulate + reproject)
|
v
paired_pose_network.PairedPoseNetwork
| Gap-filling: if pair (A,C) is missing but (A,X) and (X,C) exist,
| bridge through X: T_A_C = T_X_C @ T_A_X
| Iterative until no more gaps can be filled
|
|-- .apply_to(camera_array)
| Find largest connected component
| Try each camera as anchor, pick lowest total error
| Set anchor to identity, pose others relative to anchor
|
v
CameraArray (all cameras posed)
|
v
triangulation.triangulate_image_points()
| Undistort all points -> normalized coordinates
| Per sync_index: group by point_id, DLT via SVD
| Result: WorldPoints (sync_index, point_id, x/y/z_coord)
|
v
bundle_adjustment.PointDataBundle
| Maps image observations to world points
|
|-- .optimize()
| scipy.optimize.least_squares (method="trf")
| Sparse Jacobian pattern for efficiency
| Optimizes: camera extrinsics (6 params each) + 3D points
| Residuals computed in normalized coordinates
|
v
Optimized PointDataBundle
| Lower reprojection error, refined camera poses + 3D points
|
v (optional)
.align_to_object() or calibrate.set_origin()
| Umeyama similarity transform: source (triangulated) -> target (object coords)
| Finds rotation, translation, and scale
| Applies to both camera extrinsics and world points
|
v
Final calibrated CameraArray + WorldPoints in real-world coordinates
All defined in data_types.py unless noted otherwise.
Return value from CharucoTracker.get_points(). Contains:
point_id— unique corner IDs from the charuco boardimg_loc— (N, 2) pixel coordinates of detected cornersobj_loc— (N, 3) known 3D positions in the board's frame of reference
Intrinsic and extrinsic state for a single camera:
cam_id,size— identity and resolutionmatrix,distortions— intrinsic calibration (None if uncalibrated)rotation,translation— extrinsic pose (None if unposed)fisheye— whether to use fisheye distortion model- Key methods:
undistort_points(),undistort_frame(),extrinsics_to_vector()/extrinsics_from_vector()
Container for multiple CameraData objects. Provides:
posed_cameras— subset with valid extrinsicsposed_cam_id_to_index— deterministic camera-to-index mapping for optimizationnormalized_projection_matrices— 3x4 projection matrices for triangulationget_extrinsic_params()/update_extrinsic_params()— vectorize/devectorize for scipy
Validated DataFrame container for 2D observations:
- Required columns:
sync_index,cam_id,point_id,img_loc_x,img_loc_y - Optional columns:
obj_loc_x,obj_loc_y,obj_loc_z,frame_time - Returns copies of internal DataFrame (immutability)
fill_gaps()— linear interpolation of missing observations
Frozen dataclass wrapping a DataFrame of 3D points:
- Columns:
sync_index,point_id,x_coord,y_coord,z_coord,frame_time pointsproperty returns (N, 3) numpy arrayfill_gaps()— linear interpolation of trajectories
Relative transformation between two cameras:
rotation(3x3),translation(3,),error_scoreinverted()— returns the reverse transformlink(other)— chains transforms: (A->B).link(B->C) = A->C with summed error scores
Central optimization container binding cameras, 2D observations, and 3D points:
optimize()— bundle adjustment viascipy.optimize.least_squaresreprojection_report— cached pixel-space error metricsfilter_by_absolute_error()/filter_by_percentile_error()— outlier removal with safety floorsalign_to_object()— Umeyama alignment to known object coordinatescompute_volumetric_scale_accuracy()— distance error vs ground truth
Entry point: calibrate.run_intrinsic_calibration_from_video()
- Opens video with
cv2.VideoCapture - Samples every Nth frame (default: 5 fps sampling rate)
- Runs
CharucoTracker.get_points()on each frame - Builds
ImagePointsDataFrame withsync_index= sequential sample number
Smart selection avoids redundant frames. Two-phase approach:
Phase 1 — Orientation Anchors: Computes board tilt (pitch/roll) from detected corner positions. Divides tilt space into 8 radial bins. Selects one representative frame per occupied bin (the one with most corners). Requires minimum 4 bins occupied for adequate orientation diversity.
Phase 2 — Spatial Coverage: Divides image into a 5x5 grid. Weights edge and corner cells higher (they're more informative for distortion estimation). Greedily adds frames that improve coverage, up to target count (~30 frames).
- Extracts matched object/image point arrays per selected frame
- Calls
cv2.calibrateCamera()(standard) orcv2.fisheye.calibrate()(fisheye) - Returns
IntrinsicCalibrationOutputwith calibratedCameraDataand coverage report
Entry point: calibrate.run_extrinsic_calibration()
- Scans synced folder for
*.mp4files - Skips
stitched_videos.mp4andtimestamps.csv - Parses camera number from
_GP{N}.mp4suffix (regex:_GP(\d+)\.mp4$)
Same as intrinsic, but sync_index alignment is critical: because videos are pre-synchronized (via audio sync), the same sync_index across cameras represents the same physical moment. The same charuco board position is observed from multiple viewpoints.
For each (camera, sync_index) pair with sufficient corners (default: 4+):
- Pre-undistort all image points to normalized coordinates
cv2.solvePnP(SOLVEPNP_IPPE)with fallback toSOLVEPNP_ITERATIVE- Operates in normalized space (identity camera matrix, zero distortion)
- Returns rotation matrix R, translation vector t, and reprojection RMSE
For each camera pair (A, B) at each common sync_index:
T_B_A = T_B_obj @ inv(T_A_obj)
R_rel = R_b @ R_a^T
t_rel = R_b @ (-R_a^T @ t_a) + t_b
This gives the transformation from camera A's frame to camera B's frame.
Per camera pair, applies IQR-based filtering on two metrics:
- Translation magnitude: removes estimates where the baseline length is anomalous
- Rotation angle: computes geodesic distance from the median quaternion, removes outliers
Default threshold: 1.5x IQR. Pairs with fewer than 5 samples skip rejection.
- Rotations: quaternion averaging via eigen decomposition of the quaternion outer product matrix. Uses (w,x,y,z) convention internally.
- Translations: simple mean after outlier rejection
For each aggregated pair, verifies quality by:
- Triangulating common observations using the estimated relative pose
- Reprojecting to both cameras
- Computing RMS error of residuals
If cameras A and C never see the board simultaneously, their relative pose is unknown. The graph bridges through intermediate cameras:
T_A_C = T_X_C @ T_A_X (where X sees both A's and C's board positions)
Selects the bridge with lowest accumulated error score. Iterates until no more gaps can be filled.
- Finds the largest connected component of cameras
- Tries each camera as anchor (identity pose), poses all others relative to it
- Selects the anchor that yields lowest total error score
- Applies the configuration to the
CameraArray
DLT (Direct Linear Transform) via SVD:
- Groups observations by
(sync_index, point_id) - For each point seen by 2+ cameras: builds the 2N x 4 design matrix A, solves via SVD
- The last row of V^T gives the homogeneous solution
Joint optimization of camera extrinsics and 3D point positions:
- Method:
scipy.optimize.least_squareswith Trust Region Reflective (TRF) - Residuals: reprojection errors in normalized (undistorted) coordinates
- Parameters: [camera_rvec(3) + camera_tvec(3)] per camera + [x,y,z] per 3D point
- Jacobian: sparse pattern exploiting the bipartite structure (each observation depends on exactly 1 camera + 1 point)
- Scaling:
x_scale="jac"for automatic parameter scaling - Does NOT mutate the camera array during optimization — extrinsics are passed via override to avoid corrupting state with rejected trial values
Umeyama similarity transform aligns the reconstruction to real-world coordinates:
- Input: triangulated 3D points and their known object positions (from the charuco board)
- Finds optimal rotation R, translation t, and uniform scale s
target = s * (R @ source) + t- Camera extrinsics are transformed correctly, preserving orthonormal rotation matrices (scale applied to position, not to the rotation block)
| Package | Why |
|---|---|
opencv-contrib-python |
Replaces opencv-python. Superset that includes cv2.aruco for charuco detection. All existing Go2Kin code works unchanged. |
pandas |
ImagePoints and WorldPoints use DataFrames for groupby, merge, and filtering operations. Core to the data flow. |
| Package | Caliscope uses it for | Go2Kin replacement |
|---|---|---|
pandera |
DataFrame schema validation | Simple column-presence checks + type coercion in ImagePoints.__init__() |
numba |
JIT-compiled triangulation loop | Pure numpy (see below) |
PySide6 |
Qt GUI + board pixmap rendering | tkinter GUI + PIL for board images |
pyvista |
3D visualization | matplotlib 3D scatter plot |
rtoml |
TOML persistence | JSON via persistence.py |
mediapipe |
Pose estimation | Not needed (calibration only) |
The only numba-jitted function in the calibration path is triangulate_sync_index() — a DLT loop running SVD per point, grouped by point_id.
Performance: For calibration data (~hundreds of charuco observations), pure numpy takes ~1-2 seconds vs ~milliseconds with numba. This is negligible since calibration runs once per session.
How to add numba back (if ever needed for real-time triangulation):
- Add
numbatorequirements.txt - In
triangulation.py: add@jit(nopython=True, cache=True)decorator totriangulate_sync_index() - In
data_types.py: changenormalized_projection_matricesto returnnumba.typed.Dictinstead of plaindict - Change list return types to
numba.typed.List
The function interface is designed to be numba-compatible — it uses only numpy arrays, basic Python types, and dict lookups.
CameraArray.normalized_projection_matrices originally returned a numba.typed.Dict[int, np.ndarray]. Changed to a plain Python dict[int, np.ndarray]. Only used as a lookup table, not for JIT compilation.
ImagePointSchema and WorldPointSchema (pandera DataFrameModel classes) replaced with:
- Column presence validation:
missing = [c for c in REQUIRED if c not in df.columns] - Type coercion:
df[col].astype(int)/pd.to_numeric(df[col], errors="coerce") - Optional columns filled with NaN if absent
Charuco.board_pixmap() (returned QPixmap) replaced with Charuco.board_pil_image() (returns PIL.Image). Used for board preview in tkinter and saving to file.
Python 3.10 doesn't have typing.Self (3.11+). The PoseNetworkBuilder fluent methods use -> PoseNetworkBuilder: as the return annotation (valid with from __future__ import annotations).
Six classes from four different Caliscope files merged into one module:
| Class | Original Caliscope location |
|---|---|
PointPacket |
caliscope/packets.py |
CameraData |
caliscope/cameras/camera_array.py |
CameraArray |
caliscope/cameras/camera_array.py |
ImagePoints |
caliscope/core/point_data.py |
WorldPoints |
caliscope/core/point_data.py |
StereoPair |
caliscope/core/bootstrap_pose/stereopairs.py |
The rotate() method (arbitrary axis rotation) was removed as it's not needed for the calibration workflow. Coordinate alignment is handled entirely by align_to_object().
| Go2Kin file | Caliscope source | Adaptations |
|---|---|---|
charuco.py |
core/charuco.py |
Removed PySide6, added PIL image output, Go2Kin defaults |
charuco_tracker.py |
trackers/charuco_tracker.py |
Import path change only, removed abstract base class |
data_types.py |
cameras/camera_array.py + core/point_data.py + packets.py + bootstrap_pose/stereopairs.py |
Consolidated, removed numba/pandera |
frame_selector.py |
core/frame_selector.py |
Import path change only |
intrinsic.py |
core/calibrate_intrinsics.py |
Import path change only |
extrinsic.py |
core/bootstrap_pose/pose_network_builder.py |
Import paths, Self -> string annotation |
paired_pose_network.py |
core/bootstrap_pose/paired_pose_network.py |
Import path change only |
triangulation.py |
core/point_data.py (triangulate_sync_index) |
Rewritten without numba JIT, same algorithm |
reprojection.py |
core/reprojection.py |
Import path change only |
reprojection_report.py |
core/reprojection_report.py |
Import path change only |
bundle_adjustment.py |
core/point_data_bundle.py |
Import paths, removed rotate() method |
alignment.py |
core/alignment.py |
Import path change only |
scale_accuracy.py |
core/scale_accuracy.py |
Import path change only |
video_processor.py |
— | New: MP4 -> ImagePoints bridge |
calibrate.py |
— | New: high-level orchestrator |
persistence.py |
— | New: JSON save/load |
The charuco board is the calibration target. Its physical geometry must be accurately described for correct calibration.
| Parameter | Default | Effect |
|---|---|---|
columns |
5 | Number of columns on the board |
rows |
7 | Number of rows on the board |
square_size_overide_cm |
11.70 | Physical size of one chessboard square in cm. Critical for scale accuracy. |
dictionary |
DICT_4X4_50 |
ArUco marker dictionary. Must match the printed board. |
aruco_scale |
0.75 | Ratio of ArUco marker size to square size |
inverted |
False | Whether the board is white-on-black (True applies cv2.bitwise_not) |
board_height |
59.4 | Overall board height in cm (A1 short edge). Used only if square_size_overide_cm is None. |
board_width |
84.1 | Overall board width in cm (A1 long edge). Used only if square_size_overide_cm is None. |
-
square_size_overide_cmmust match the physical board. Measure the printed board after printing — printers don't always scale exactly. This value directly sets the real-world scale of the calibration. Wrong value = wrong 3D distances. -
Dictionary and aruco_scale must match the printed board. Mismatches cause detection failure or wrong corner assignments.
-
Board flatness matters. A warped board introduces systematic errors. Mount on rigid material (foamboard, MDF).
Designed for an A1 (59.4 x 84.1 cm) printed charuco board:
- 5 columns x 7 rows = 24 inner corners
- 11.70 cm squares with 0.75 aruco scale
- DICT_4X4_50 (small dictionary, fast detection)
Config persists in config/calibration/charuco_config.json.
Calibration is saved as JSON to config/calibration/calibration.json:
{
"charuco": {
"columns": 5,
"rows": 7,
"board_height": 59.4,
"board_width": 84.1,
"dictionary": "DICT_4X4_50",
"aruco_scale": 0.75,
"inverted": false,
"square_size_overide_cm": 11.70
},
"cameras": {
"1": {
"size": [1920, 1080],
"error": 0.42,
"matrix": [[...], [...], [...]],
"distortions": [...],
"rotation": [[...], [...], [...]],
"translation": [...],
"fisheye": false
}
}
}Numpy arrays are serialized as nested lists. None fields (uncalibrated parameters) are omitted from the JSON.
-
Numba JIT: Add
@jitback totriangulate_sync_index()if real-time triangulation is needed (e.g., live 3D preview). Interface already compatible — see Dependency Decisions. -
Fisheye model refinement: The fisheye path (
cv2.fisheye.calibrate) exists but is lightly tested. GoPro "Linear" mode uses standard distortion. "Wide" or "SuperView" modes may benefit from the fisheye model. -
Manual camera mapping: Currently requires
_GP{N}.mp4filename convention. A fallback dialog for manual file-to-camera assignment would help when filenames don't match. -
Progress reporting: The GUI progress callbacks are wired but minimal. Could add per-frame progress bars for long video processing.
-
Calibration quality visualization: The matplotlib 3D scatter plot shows camera positions. Could add reprojection error heatmaps, residual distributions, or board coverage visualizations.