Seeding with Prior Knowledge
By default every workflow initializes itself with the closed-form solvers described in Why Linear Initialization Matters: Zhang's method for intrinsics, a DLT for rig extrinsics, Tsai-Lenz (or another closed-form method) for hand-eye. That is the right behaviour when the data is well conditioned. In industrial settings you often have better priors:
- datasheet focal lengths or principal points;
- factory-measured Scheimpflug tilts;
- laser-plane geometry from mechanical drawings;
- a previously calibrated rig that new data should only nudge.
Manual initialization lets you seed any subset of these and leaves the rest to the automatic path and the non-linear optimizer.
The Seed Types
For every problem type and every initialization stage there are two things:
- a
step_*_with_seedfunction that takes a*ManualInitstruct before its options; - a typed
*ManualInitstruct whose fields are allOption<T>.Nonemeans "auto-initialize this group";Some(value)means "use this value verbatim, do not auto-initialize".
#![allow(unused)] fn main() { // Automatic path step_init(&mut session, None)?; step_optimize(&mut session, None)?; // Seeded path let mut seed = IntrinsicsManualInit::default(); seed.intrinsics = Some(nominal_camera_k); // distortion: None -> auto-initialized (zeros when intrinsics are seeded) // sensor: None -> tilt seed, Scheimpflug cameras only (a typed error otherwise) // poses: None -> recovered from homographies using the seeded intrinsics step_init_with_seed(&mut session, seed, None)?; step_optimize(&mut session, None)?; }
The seed structs are #[non_exhaustive]: build one with Default::default() and assign the fields you want, as above. Struct-update syntax (..Default::default()) does not compile outside the defining crate.
After step_*_with_seed returns, the session state is fully initialized, with the same postcondition as the automatic path; step_optimize does not need to know which fields came from where. The session log records the source of every stage, for example:
[intrinsics_init_all] initialized 2 cameras (manual: per_cam_intrinsics, per_cam_distortion)
[rig_init] ref_cam=0, 20 views (manual: cam_se3_rig, rig_se3_target)
Walkthrough: Replaying a Calibration on a Rig
The manual_init example seeds the rig stage of the stereo calibration from a previous run. It loads the corners committed in regression/observations/stereo_rig.json and runs the pipeline three times:
cargo run -p vision-calibration --example manual_init --release
1. Run the automatic pipeline once
#![allow(unused)] fn main() { use vision_calibration::prelude::*; use vision_calibration::rig_extrinsics::{ IntrinsicsManualInit, RigExtrinsicsProblem, RigManualInit, step_intrinsics_init_all_with_seed, step_intrinsics_optimize_all, step_rig_init_with_seed, step_rig_optimize, }; let mut session_a = CalibrationSession::<RigExtrinsicsProblem>::new(); session_a.set_input(load_input()?)?; step_intrinsics_init_all_with_seed(&mut session_a, IntrinsicsManualInit::default(), None)?; let intr_opt_a = step_intrinsics_optimize_all(&mut session_a, None)?; let rig_init_a = step_rig_init_with_seed(&mut session_a, RigManualInit::default(), None)?; step_rig_optimize(&mut session_a, None)?; }
IntrinsicsManualInit::default() and RigManualInit::default() have all fields None, so this is exactly the automatic path: the same as run_calibration.
2. Capture what the stages produced
The values live in the typed results the steps return:
#![allow(unused)] fn main() { use vision_calibration::core::{BrownConrady5, DistortionParams}; let cameras_a = intr_opt_a.cameras.clone(); // Vec<CameraParams> let cam_se3_rig_a = rig_init_a.initial_cam_se3_rig.clone(); // per-camera T_C_R let rig_se3_target_a = rig_init_a.initial_rig_se3_target.clone(); // per-view T_R_T let per_cam_k: Vec<_> = cameras_a.iter().map(|c| c.intrinsics).collect(); let per_cam_dist: Vec<BrownConrady5<f64>> = cameras_a .iter() .map(|c| c.distortion.clone()) .collect(); }
3. Replay them on a fresh session
#![allow(unused)] fn main() { let mut session_b = CalibrationSession::<RigExtrinsicsProblem>::new(); session_b.set_input(load_input()?)?; let mut intrinsics_seed = IntrinsicsManualInit::default(); intrinsics_seed.per_cam_intrinsics = Some(per_cam_k); intrinsics_seed.per_cam_distortion = Some(per_cam_dist); step_intrinsics_init_all_with_seed(&mut session_b, intrinsics_seed, None)?; // Skip step_intrinsics_optimize_all: the seed is already optimized. let mut rig_seed = RigManualInit::default(); rig_seed.cam_se3_rig = Some(cam_se3_rig_a); rig_seed.rig_se3_target = Some(rig_se3_target_a); step_rig_init_with_seed(&mut session_b, rig_seed, None)?; step_rig_optimize(&mut session_b, None)?; }
On the stereo dataset the replay reproduces the first run exactly (|Δ reproj| = 0 px); the example asserts this. A third run seeds the intrinsics with a 10 % focal-length error and a 50 px principal-point offset and still converges to the same 0.2496 px solution, which shows that a rough seed is enough for the optimizer.
Variations
Partial seeding
When intrinsics are seeded but distortion is not, distortion starts at zero in the camera's distortion model: the automatic distortion fit is coupled to the iterative intrinsics estimation and does not run when intrinsics are seeded.
A distortion seed is a DistortionParams of the camera's own model: its kind must equal camera.model.distortion (a Kannala-Brandt seed for a fisheye camera, a Rational seed for a rational one). It is used as given, never converted; a seed of another kind is a typed error naming the field, raised before any work. In JSON it is the tagged form {"type": "brown_conrady5", "k1": -0.1, ...}.
When intrinsics are seeded but poses are not, the poses are recovered from the per-view homographies using the seeded intrinsics, not automatically estimated ones. This keeps the geometric chain consistent with the seed.
Seeding from datasheet values
For a rig whose cameras share one optical design:
#![allow(unused)] fn main() { use vision_calibration::core::{BrownConrady5, DistortionParams, FxFyCxCy}; let datasheet_k = FxFyCxCy { fx: 1800.0, fy: 1800.0, cx: 360.0, cy: 270.0 }; let nominal_distortion = DistortionParams::BrownConrady5 { params: BrownConrady5 { k1: -0.1, ..Default::default() }, }; let mut seed = IntrinsicsManualInit::default(); seed.per_cam_intrinsics = Some(vec![datasheet_k; num_cameras]); seed.per_cam_distortion = Some(vec![nominal_distortion; num_cameras]); step_intrinsics_init_all_with_seed(&mut session, seed, None)?; }
This unblocks scenarios where Zhang's method fails on borderline data, for example strongly tilted Scheimpflug rigs. For those, also set per_cam_sensors to the nominal mount tilts: a seeded tilt is used verbatim and skips the linear stage's tilt sweep. A non-zero tilt for a camera with an identity sensor is an error; a zero tilt is accepted. RigHandeyeConfig::manual_init takes the same per_cam_sensors. The single-camera seeds (planar_intrinsics::IntrinsicsManualInit, single_cam_handeye::IntrinsicsManualInit and laserline_device::DeviceManualInit) take one tilt in sensor, with the same rule.
The device_seed module derives these seeds from a device description (focal length in millimetres, pixel pitch, mount tilt); see Device Specifications.
Coupling rule (rig stage)
cam_se3_rig and rig_se3_target are geometrically coupled. The rig stage's RigManualInit requires both or neither, and enforces it at runtime with a typed Error::InvalidInput.
Hand-eye coupling
RigHandeye has three initialization stages (intrinsics, rig, hand-eye), each with its own *ManualInit. The hand-eye stage's mode_target_pose depends on the mounting mode:
EyeInHand:handeye = T_G_R,mode_target_pose = T_B_T.EyeToHand:handeye = T_R_B,mode_target_pose = T_G_T.
If you seed handeye but not mode_target_pose, the latter is derived from the chain.
Staged rig laser calibration
RigHandeyeLaserline has no steps: run_calibration runs rig hand-eye, fits each camera's laser plane against that frozen geometry, then refines everything jointly. run_calibration_with_seed takes a JointManualInit that replaces either warm start:
rig_handeye: aRigHandeyeExportof the same views, for example from aRigHandeyeProblemsession you ran (and inspected) first. The rig hand-eye stage is skipped; its cameras, extrinsics, hand-eye transform, target poses and robot-pose corrections start the joint solve.planes_cam: one laser plane per camera, in camera frame, for example from the mechanical drawing. The plane stage refines them instead of fitting its starting planes linearly.
#![allow(unused)] fn main() { use vision_calibration::rig_handeye_laserline::{JointManualInit, run_calibration_with_seed}; // `rig_export` is the export of a rig hand-eye session on the same views, // with `config.handeye` as its config. let mut seed = JointManualInit::default(); seed.rig_handeye = Some(rig_export); seed.planes_cam = Some(design_planes); // Vec<LaserPlane>, one per camera run_calibration_with_seed(&mut joint_session, seed)?; }
With the warm start a rig hand-eye session computes from the joint session's input and config.handeye, the result is identical to run_calibration. The export must have the input's camera count, each camera in the configured model, one target pose per view, and the configured hand-eye mode with its transforms; the seeded planes must be one per camera. Anything else is an Error::InvalidInput, raised before any solve.
In Python
The seeds are dataclasses in the problem's module, with the Rust names and fields; a field left None is estimated. The datasheet seed above:
import vision_calibration as vc
from vision_calibration import rig_extrinsics
seed = rig_extrinsics.IntrinsicsManualInit(
per_cam_intrinsics=[vc.PinholeIntrinsics(fx=1800.0, fy=1800.0, cx=360.0, cy=270.0)] * 2,
per_cam_distortion=[vc.BrownConradyDistortion(k1=-0.1, k2=0.0, k3=0.0, p1=0.0, p2=0.0, iters=8)] * 2,
)
init = session.step_intrinsics_init_all_with_seed(seed)
print(session.log[-1].notes) # the init log names the manual and the estimated parts
Distortion seeds are the distortion dataclasses of the camera model (BrownConradyDistortion, KannalaBrandt4Distortion, …). A seed that does not fit the camera model (a tilt for an untilted camera, a distortion of another kind, one rig pose without the other) raises ValueError.
The staged rig laser calibration takes the rig hand-eye session's export as it is:
from vision_calibration import rig_handeye_laserline
rig_export = rig_session.export() # a RigHandeyeSession on the same views
joint = vc.RigHandeyeLaserlineSession()
joint.set_input(dataset)
joint.run_calibration_with_seed(
rig_handeye_laserline.JointManualInit(rig_handeye=rig_export, planes_cam=design_planes)
)
See Also
- Step Functions vs Pipeline Functions: the full list of
*_with_seedvariants. - Per-Feature Residuals: inspect per-corner errors once the calibration finishes.
- The example this chapter follows:
crates/vision-calibration/examples/manual_init.rs.