Adding a New Pipeline Problem Type
This chapter is a guide for contributors working inside the vision-calibration-pipeline crate. ProblemType is sealed, so new problem types are added to the crate itself rather than from downstream code; to build a pipeline of your own from your own crate, see Compose Your Own Pipeline. The laserline device module (laserline_device/) is a good template.
Module Structure
Create a new folder under crates/vision-calibration-pipeline/src/:
my_problem/
├── mod.rs # Module re-exports
├── problem.rs # ProblemType implementation + Config/Export
├── state.rs # Intermediate state type (crate-private)
└── steps.rs # Step functions + pipeline function
Step 1: Define the State (state.rs)
The state holds intermediate results between steps. It stays crate-private; users see typed step results instead.
#![allow(unused)] fn main() { #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub(crate) struct MyState { // Initialization results pub initial_intrinsics: Option<FxFyCxCy<Real>>, pub initial_poses: Option<Vec<Iso3>>, // Optimization results pub final_cost: Option<f64>, pub mean_reproj_error: Option<f64>, } }
The state must implement Default (empty state) and Clone + Serialize + Deserialize (for checkpointing).
Step 2: Define the Problem Type (problem.rs)
#![allow(unused)] fn main() { use crate::Error; use crate::session::{InvalidationPolicy, ProblemState, ProblemType}; pub struct MyProblem; #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct MyConfig { pub solver: SolverConfig, pub init: IntrinsicsInitConfig, } // `MyInput`, `MyEstimate` and `MyExport` are ordinary // `Clone + Serialize + DeserializeOwned + Debug` types. The output is the // final solve's estimate; an input that is a shared data type is an alias // (`pub type MyInput = PlanarDataset;`). impl ProblemState for MyProblem { type State = MyState; // defined in state.rs } impl ProblemType for MyProblem { type Config = MyConfig; type Input = MyInput; type Output = MyEstimate; type Export = MyExport; fn name() -> &'static str { "my_problem_v1" } fn schema_version() -> u32 { 1 } fn validate_input(input: &MyInput) -> Result<(), Error> { if input.views.len() < 3 { return Err(Error::InsufficientData { need: 3, got: input.views.len() }); } Ok(()) } fn on_input_change() -> InvalidationPolicy { InvalidationPolicy::CLEAR_COMPUTED } fn on_config_change() -> InvalidationPolicy { InvalidationPolicy::KEEP_ALL } fn export( input: &MyInput, output: &MyEstimate, _config: &MyConfig, ) -> Result<MyExport, Error> { // Build the user-facing export (attach per-feature residuals, etc.). todo!() } } }
Shared config groups (SolverConfig, IntrinsicsInitConfig, RobotPoseConfig, HandeyeInitConfig, RigConfig) live in common/config.rs; embed them as named fields rather than re-declaring the same settings flat.
Step 3: Implement Step Functions (steps.rs)
Each step takes Option<<Stage><Action>Options> (reuse the shared ones in common/mod.rs; None keeps the session config) and returns a typed <Stage><Action>Result, so callers never need to read the state:
#![allow(unused)] fn main() { use crate::Error; use crate::common::{IntrinsicsInitOptions, IntrinsicsOptimizeOptions}; use crate::session::CalibrationSession; use super::problem::MyProblem; #[derive(Debug, Clone)] #[non_exhaustive] pub struct IntrinsicsInitResult { pub intrinsics: FxFyCxCy<Real>, pub poses: Vec<Iso3>, } pub fn step_init( session: &mut CalibrationSession<MyProblem>, opts: Option<IntrinsicsInitOptions>, ) -> Result<IntrinsicsInitResult, Error> { let opts = opts.unwrap_or_default(); let input = session.require_input()?; let config = &session.config; // Run linear initialization let intrinsics = /* ... */; let poses = /* ... */; // Store the results in the problem's crate-private state // (the session's `state` field, in-crate access only): // initial_intrinsics = Some(intrinsics); // initial_poses = Some(poses.clone()); session.log_success("init"); Ok(IntrinsicsInitResult { intrinsics, poses }) } pub fn step_optimize( session: &mut CalibrationSession<MyProblem>, opts: Option<IntrinsicsOptimizeOptions>, ) -> Result<IntrinsicsOptimizeResult, Error> { let input = session.require_input()?; // Require initialization // Read the initialization results back from the crate-private state, // failing with a typed error when `step_init` has not run: // .ok_or_else(|| Error::not_available("initial intrinsics (call step_init first)"))? let init_k = /* state.initial_intrinsics */; // Build and solve the optimization problem, then store the output let output: MyEstimate = /* ... */; session.set_output(output); session.log_success_with_notes("optimize", "cost=..., reproj_err=..."); Ok(/* IntrinsicsOptimizeResult { .. } */) } /// Convenience pipeline function: the same signature in every problem. pub fn run_calibration(session: &mut CalibrationSession<MyProblem>) -> Result<(), Error> { step_init(session, None)?; step_optimize(session, None)?; Ok(()) } }
Add step_*_with_seed variants when the problem supports manual seeds, following planar_intrinsics/steps.rs: the seed is a <Stage>ManualInit and comes before the options.
Step 4: Module Re-exports (mod.rs)
#![allow(unused)] fn main() { mod problem; mod state; mod steps; pub use problem::{MyConfig, MyEstimate, MyExport, MyInput, MyProblem}; pub use steps::{ IntrinsicsInitOptions, IntrinsicsInitResult, IntrinsicsOptimizeOptions, IntrinsicsOptimizeResult, run_calibration, step_init, step_optimize, }; }
Step 5: Register in the Pipeline Crate
In crates/vision-calibration-pipeline/src/lib.rs:
#![allow(unused)] fn main() { pub mod my_problem; }
Step 6: Wire into the Facade Crate
In crates/vision-calibration/src/lib.rs, list the public items explicitly (the facade curates its surface):
#![allow(unused)] fn main() { pub mod my_problem { pub use vision_calibration_pipeline::my_problem::{ IntrinsicsInitOptions, IntrinsicsInitResult, IntrinsicsOptimizeOptions, IntrinsicsOptimizeResult, MyConfig, MyEstimate, MyExport, MyInput, MyProblem, run_calibration, step_init, step_optimize, }; } }
Do not add new workflows to the prelude by default; keep the prelude minimal for planar hello-world usage.
Add the Python binding in vision-calibration-py: one session_class! invocation in src/session.rs (the problem type, its steps with their option and result types), a python/vision_calibration/<module>.py with the session class and the seed and result dataclasses, a run_<module> runner, and an entry per new facade item in scripts/binding_parity.toml. The parity check fails until every facade item is bound or marked Rust-only with a reason.
Testing
Write a test next to the module (it can read the crate-private state) or an integration test using only the public API:
#![allow(unused)] fn main() { #[test] fn my_problem_session_workflow() -> Result<(), Error> { let input = make_synthetic_input(); // local test helper let mut session = CalibrationSession::<MyProblem>::new(); session.set_input(input)?; let init = step_init(&mut session, None)?; assert!(!init.poses.is_empty()); step_optimize(&mut session, None)?; assert!(session.output().is_some()); // Test JSON round-trip let json = session.to_json()?; let restored = CalibrationSession::<MyProblem>::from_json(&json)?; assert!(restored.output().is_some()); Ok(()) } }