From 9809fc3fcff8d0db9f1a23f31c3765de8001c1eb Mon Sep 17 00:00:00 2001 From: Robertleoj Date: Sat, 30 May 2026 19:20:47 +0200 Subject: [PATCH 1/3] add initial guess --- AGENTS.md | 14 + cpp_src/calibrate.hpp | 8 +- cpp_src/cameramodels.hpp | 9 +- cpp_src/lut_max_cell_error.cpp | 2 +- cpp_src/lut_max_cell_error.hpp | 2 +- cpp_src/main.cpp | 111 +++-- cpp_src/matching_spline_model.cpp | 4 +- cpp_src/normalize_pinhole_splined.cpp | 2 +- cpp_src/pinhole_splined_fine_tune.cpp | 6 +- cpp_src/python_camera_functions.hpp | 4 +- cpp_src/seeded_normalize.cpp | 6 +- cpp_src/seeded_normalize.hpp | 2 +- src/lensboy/_logging.py | 10 +- src/lensboy/analysis/differencing.py | 17 +- src/lensboy/analysis/plots.py | 117 ++++-- src/lensboy/analysis/unproject_lut.py | 4 +- src/lensboy/calibration/calibrate.py | 415 +++++++++++++++---- src/lensboy/calibration/type_defs.py | 17 +- src/lensboy/camera_models/pinhole_splined.py | 33 +- src/lensboy/camera_models/unproject_lut.py | 6 +- src/lensboy/lensboy_bindings.pyi | 71 ++-- tests/test_integration.py | 159 +++++++ 22 files changed, 793 insertions(+), 226 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 65c8168..917a3cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,20 @@ Never modify the stubs. They are autogenerated, and your changes will just be ov Keep comments minimal, only add comments when the code needs explaining why it's doing something. +# Control flow + +Prefer early returns or early continues over nested conditionals when it keeps +the main path flatter and easier to read. Do not contort code just to avoid +nesting, but avoid pyramid-shaped control flow. + +Do not use ternary expressions. Use explicit `if`/`else` statements instead. + +# Frame transforms + +Name pose/transform variables exactly with the `A_from_B` convention, where the +transform maps coordinates from frame B into frame A. Avoid vague names like +`pose` for transforms unless the code is generic over frames. + # Plots Follow the color scheme of the existing plots in the plots.py file. diff --git a/cpp_src/calibrate.hpp b/cpp_src/calibrate.hpp index bdd1ad1..e605039 100644 --- a/cpp_src/calibrate.hpp +++ b/cpp_src/calibrate.hpp @@ -25,13 +25,13 @@ py::dict calibrate_opencv( py::dict get_matching_spline_distortion_model( std::vector& opencv_distortion_params, - PinholeSplinedConfig& model_config, + PinholeSplinedOptimizationConfig& model_config, double image_bound_x, double image_bound_y ); py::dict fine_tune_pinhole_splined( - PinholeSplinedConfig& model_config, + PinholeSplinedOptimizationConfig& model_config, PinholeSplinedIntrinsicsParameters& intrinsics_parameters, std::vector>& cameras_from_target, std::vector>& target_points, @@ -42,9 +42,9 @@ py::dict fine_tune_pinhole_splined( ); py::array_t normalize_pinhole_splined_points( - PinholeSplinedConfig& config, + PinholeSplinedModelDefinition& config, PinholeSplinedIntrinsicsParameters& intrinsics, py::array_t pixel_coords ); -} // namespace lensboy \ No newline at end of file +} // namespace lensboy diff --git a/cpp_src/cameramodels.hpp b/cpp_src/cameramodels.hpp index 4e52739..4fc3e4a 100644 --- a/cpp_src/cameramodels.hpp +++ b/cpp_src/cameramodels.hpp @@ -12,13 +12,16 @@ namespace py = pybind11; namespace lensboy { -struct PinholeSplinedConfig { +struct PinholeSplinedModelDefinition { uint32_t image_width; uint32_t image_height; double fov_deg_x; double fov_deg_y; uint32_t num_knots_x; uint32_t num_knots_y; +}; + +struct PinholeSplinedOptimizationConfig : PinholeSplinedModelDefinition { double smoothness_lambda; }; @@ -313,7 +316,7 @@ struct SplineMap { double y_scale = 0.0; explicit SplineMap( - const PinholeSplinedConfig& cfg + const PinholeSplinedModelDefinition& cfg ) { this->Nx = static_cast(cfg.num_knots_x); this->Ny = static_cast(cfg.num_knots_y); @@ -406,7 +409,7 @@ struct SplineMap { template void project_pinhole_splined( - PinholeSplinedConfig* config, + PinholeSplinedModelDefinition* config, const T* const pinhole_parameters, // fx, fy, cx, cy const T* const dx_grid, const T* const dy_grid, diff --git a/cpp_src/lut_max_cell_error.cpp b/cpp_src/lut_max_cell_error.cpp index 0a09d7d..f410c21 100644 --- a/cpp_src/lut_max_cell_error.cpp +++ b/cpp_src/lut_max_cell_error.cpp @@ -844,7 +844,7 @@ inline void project_pinhole_splined_n( } py::array_t max_cell_errors_pinhole_splined( - PinholeSplinedConfig& config, + PinholeSplinedModelDefinition& config, PinholeSplinedIntrinsicsParameters& intrinsics, py::array_t lut_xy_grid, int image_width, diff --git a/cpp_src/lut_max_cell_error.hpp b/cpp_src/lut_max_cell_error.hpp index 459d19d..0ec4d21 100644 --- a/cpp_src/lut_max_cell_error.hpp +++ b/cpp_src/lut_max_cell_error.hpp @@ -22,7 +22,7 @@ namespace py = pybind11; // interpolation_mode: 0=nearest, 1=bilinear, 2=bicubic. py::array_t max_cell_errors_pinhole_splined( - PinholeSplinedConfig& config, + PinholeSplinedModelDefinition& config, PinholeSplinedIntrinsicsParameters& intrinsics, py::array_t lut_xy_grid, int image_width, diff --git a/cpp_src/main.cpp b/cpp_src/main.cpp index 4e50648..2efa2fa 100644 --- a/cpp_src/main.cpp +++ b/cpp_src/main.cpp @@ -23,59 +23,110 @@ PYBIND11_MODULE( ) { m.doc() = "lensboy for camera calibration"; spdlog::flush_on(spdlog::level::trace); - py::class_(m, "PinholeSplinedConfig") + py::class_( + m, + "PinholeSplinedModelDefinition" + ) .def( - py::init< - uint32_t, - uint32_t, - double, - double, - uint32_t, - uint32_t, - double>(), + py::init(), py::arg("image_width"), py::arg("image_height"), py::arg("fov_deg_x"), py::arg("fov_deg_y"), py::arg("num_knots_x"), - py::arg("num_knots_y"), - py::arg("smoothness_lambda") + py::arg("num_knots_y") ) .def_readwrite( "image_width", - &lensboy::PinholeSplinedConfig::image_width + &lensboy::PinholeSplinedModelDefinition::image_width ) .def_readwrite( "image_height", - &lensboy::PinholeSplinedConfig::image_height + &lensboy::PinholeSplinedModelDefinition::image_height + ) + .def_readwrite( + "fov_deg_x", + &lensboy::PinholeSplinedModelDefinition::fov_deg_x + ) + .def_readwrite( + "fov_deg_y", + &lensboy::PinholeSplinedModelDefinition::fov_deg_y ) - .def_readwrite("fov_deg_x", &lensboy::PinholeSplinedConfig::fov_deg_x) - .def_readwrite("fov_deg_y", &lensboy::PinholeSplinedConfig::fov_deg_y) .def_readwrite( "num_knots_x", - &lensboy::PinholeSplinedConfig::num_knots_x + &lensboy::PinholeSplinedModelDefinition::num_knots_x ) .def_readwrite( "num_knots_y", - &lensboy::PinholeSplinedConfig::num_knots_y + &lensboy::PinholeSplinedModelDefinition::num_knots_y + ) + .def( + "__repr__", + [](const lensboy::PinholeSplinedModelDefinition& self) { + std::ostringstream oss; + oss << "PinholeSplinedModelDefinition(" + << "image_width=" << self.image_width + << ", image_height=" << self.image_height + << ", fov_deg_x=" << self.fov_deg_x + << ", fov_deg_y=" << self.fov_deg_y + << ", num_knots_x=" << self.num_knots_x + << ", num_knots_y=" << self.num_knots_y << ")"; + return oss.str(); + } + ); + + py::class_< + lensboy::PinholeSplinedOptimizationConfig, + lensboy::PinholeSplinedModelDefinition>( + m, + "PinholeSplinedOptimizationConfig" + ) + .def( + py::init([](uint32_t image_width, + uint32_t image_height, + double fov_deg_x, + double fov_deg_y, + uint32_t num_knots_x, + uint32_t num_knots_y, + double smoothness_lambda) { + lensboy::PinholeSplinedOptimizationConfig config; + config.image_width = image_width; + config.image_height = image_height; + config.fov_deg_x = fov_deg_x; + config.fov_deg_y = fov_deg_y; + config.num_knots_x = num_knots_x; + config.num_knots_y = num_knots_y; + config.smoothness_lambda = smoothness_lambda; + return config; + }), + py::arg("image_width"), + py::arg("image_height"), + py::arg("fov_deg_x"), + py::arg("fov_deg_y"), + py::arg("num_knots_x"), + py::arg("num_knots_y"), + py::arg("smoothness_lambda") ) .def_readwrite( "smoothness_lambda", - &lensboy::PinholeSplinedConfig::smoothness_lambda + &lensboy::PinholeSplinedOptimizationConfig::smoothness_lambda ) - .def("__repr__", [](const lensboy::PinholeSplinedConfig& self) { - std::ostringstream oss; - oss << "PinholeSplinedConfig(" - << "image_width=" << self.image_width - << ", image_height=" << self.image_height - << ", fov_deg_x=" << self.fov_deg_x - << ", fov_deg_y=" << self.fov_deg_y - << ", num_knots_x=" << self.num_knots_x - << ", num_knots_y=" << self.num_knots_y - << ", smoothness_lambda=" << self.smoothness_lambda << ")"; - return oss.str(); - }); + .def( + "__repr__", + [](const lensboy::PinholeSplinedOptimizationConfig& self) { + std::ostringstream oss; + oss << "PinholeSplinedOptimizationConfig(" + << "image_width=" << self.image_width + << ", image_height=" << self.image_height + << ", fov_deg_x=" << self.fov_deg_x + << ", fov_deg_y=" << self.fov_deg_y + << ", num_knots_x=" << self.num_knots_x + << ", num_knots_y=" << self.num_knots_y + << ", smoothness_lambda=" << self.smoothness_lambda << ")"; + return oss.str(); + } + ); py::class_( m, diff --git a/cpp_src/matching_spline_model.cpp b/cpp_src/matching_spline_model.cpp index abedfd6..18e69c8 100644 --- a/cpp_src/matching_spline_model.cpp +++ b/cpp_src/matching_spline_model.cpp @@ -141,7 +141,7 @@ struct DistortionError { py::dict get_matching_spline_distortion_model( std::vector& opencv_distortion_params, - PinholeSplinedConfig& model_config, + PinholeSplinedOptimizationConfig& model_config, double image_bound_x, double image_bound_y ) { @@ -393,4 +393,4 @@ py::dict get_matching_spline_distortion_model( return out; } -} // namespace lensboy \ No newline at end of file +} // namespace lensboy diff --git a/cpp_src/normalize_pinhole_splined.cpp b/cpp_src/normalize_pinhole_splined.cpp index 3ce1a6c..87d5930 100644 --- a/cpp_src/normalize_pinhole_splined.cpp +++ b/cpp_src/normalize_pinhole_splined.cpp @@ -160,7 +160,7 @@ static Vec2 normalize_single_point( } py::array_t normalize_pinhole_splined_points( - PinholeSplinedConfig& config, + PinholeSplinedModelDefinition& config, PinholeSplinedIntrinsicsParameters& intrinsics, py::array_t pixel_coords ) { diff --git a/cpp_src/pinhole_splined_fine_tune.cpp b/cpp_src/pinhole_splined_fine_tune.cpp index 971070d..7fe44ba 100644 --- a/cpp_src/pinhole_splined_fine_tune.cpp +++ b/cpp_src/pinhole_splined_fine_tune.cpp @@ -148,7 +148,7 @@ static bool any_cell_changed( static inline void BuildProblem( ceres::Problem& problem, - const PinholeSplinedConfig& cfg, + const PinholeSplinedOptimizationConfig& cfg, const SplineMap& map, const double* pinhole_params, double* dxp, @@ -412,7 +412,7 @@ static inline void BuildProblem( } py::dict fine_tune_pinhole_splined( - lensboy::PinholeSplinedConfig& model_config, + lensboy::PinholeSplinedOptimizationConfig& model_config, lensboy::PinholeSplinedIntrinsicsParameters& intrinsics_parameters, std::vector>& cameras_from_target, std::vector>& target_points, @@ -546,4 +546,4 @@ py::dict fine_tune_pinhole_splined( return out; } -} // namespace lensboy \ No newline at end of file +} // namespace lensboy diff --git a/cpp_src/python_camera_functions.hpp b/cpp_src/python_camera_functions.hpp index bf05c39..c589710 100644 --- a/cpp_src/python_camera_functions.hpp +++ b/cpp_src/python_camera_functions.hpp @@ -8,7 +8,7 @@ namespace lensboy { static py::array_t project_pinhole_splined_pywrapper( - lensboy::PinholeSplinedConfig& model_config, + lensboy::PinholeSplinedModelDefinition& model_config, lensboy::PinholeSplinedIntrinsicsParameters& intrinsics, py::array_t points_in_camera @@ -71,7 +71,7 @@ static py::array_t project_pinhole_splined_pywrapper( } static py::tuple make_undistortion_maps_pinhole_splined( - lensboy::PinholeSplinedConfig& model_config, + lensboy::PinholeSplinedModelDefinition& model_config, lensboy::PinholeSplinedIntrinsicsParameters& intrinsics, py::array_t pinhole_parameters, diff --git a/cpp_src/seeded_normalize.cpp b/cpp_src/seeded_normalize.cpp index f0ee279..da31bfa 100644 --- a/cpp_src/seeded_normalize.cpp +++ b/cpp_src/seeded_normalize.cpp @@ -330,7 +330,7 @@ template static inline void forward_splined( const T& normalized_x, const T& normalized_y, - PinholeSplinedConfig* config, + PinholeSplinedModelDefinition* config, const T* pinhole_params, const T* dx_grid, const T* dy_grid, @@ -414,7 +414,7 @@ struct SplineConstants { double fx, fy, cx, cy; explicit SplineConstants( - PinholeSplinedConfig* config, + PinholeSplinedModelDefinition* config, const double* pinhole_params ) : num_knots_x((int)config->num_knots_x), @@ -789,7 +789,7 @@ py::array_t seeded_normalize_splined( int seed_width, int seed_height, py::array_t query_pixels, - PinholeSplinedConfig& config, + PinholeSplinedModelDefinition& config, PinholeSplinedIntrinsicsParameters& params ) { auto seed_pixels_buffer = seed_pixels.request(); diff --git a/cpp_src/seeded_normalize.hpp b/cpp_src/seeded_normalize.hpp index c4995aa..8f408f4 100644 --- a/cpp_src/seeded_normalize.hpp +++ b/cpp_src/seeded_normalize.hpp @@ -23,7 +23,7 @@ py::array_t seeded_normalize_splined( int seed_width, int seed_height, py::array_t query_pixels, - PinholeSplinedConfig& config, + PinholeSplinedModelDefinition& config, PinholeSplinedIntrinsicsParameters& params ); diff --git a/src/lensboy/_logging.py b/src/lensboy/_logging.py index 7f78c6c..b7a0ba3 100644 --- a/src/lensboy/_logging.py +++ b/src/lensboy/_logging.py @@ -47,7 +47,11 @@ def _set_cpp_log_level(level: str) -> None: def _clamp(v: float, lo: float, hi: float) -> float: - return lo if v < lo else hi if v > hi else v + if v < lo: + return lo + if v > hi: + return hi + return v def _fmt_int(n: int) -> str: @@ -117,7 +121,9 @@ def set(self, n: int) -> None: return if n < 0: raise ValueError("n must be >= 0") - self.n = n if self.total <= 0 else min(n, self.total) + self.n = n + if self.total > 0: + self.n = min(n, self.total) self.draw(force=True) def draw(self, *, force: bool = False) -> None: diff --git a/src/lensboy/analysis/differencing.py b/src/lensboy/analysis/differencing.py index 106d6b2..e92da27 100644 --- a/src/lensboy/analysis/differencing.py +++ b/src/lensboy/analysis/differencing.py @@ -99,7 +99,9 @@ def find_matching_implied_transformation( ) optimize_translation = distance is not None - d = distance if distance is not None else 1.0 + d = 1.0 + if distance is not None: + d = distance # 1. Sample the imager in a disk around the center center = np.array([(model_a.image_width - 1) / 2, (model_a.image_height - 1) / 2]) @@ -115,7 +117,10 @@ def find_matching_implied_transformation( unit_vectors = rays_b / np.linalg.norm(rays_b, axis=1, keepdims=True) # (N, 3) # 4. Find R, t that maximizes sum_i v_i^T normalized(R @ p_i + t) - x0 = np.zeros(6 if optimize_translation else 3) + x0_size = 3 + if optimize_translation: + x0_size = 6 + x0 = np.zeros(x0_size) result = minimize( _objective, x0, @@ -124,7 +129,9 @@ def find_matching_implied_transformation( ) R = Rotation.from_rotvec(result.x[:3]).as_matrix() - t = result.x[3:6] if optimize_translation else np.zeros(3) + t = np.zeros(3) + if optimize_translation: + t = result.x[3:6] return Pose.from_rotmat_trans(rotmat=R, trans=t) @@ -160,7 +167,9 @@ def compute_projection_diff( model_a, model_b, distance=distance, radius=radius ) - d = distance if distance is not None else 1.0 + d = 1.0 + if distance is not None: + d = distance w, h = model_a.image_width, model_a.image_height aspect = w / h diff --git a/src/lensboy/analysis/plots.py b/src/lensboy/analysis/plots.py index 37aa37f..b1a078b 100644 --- a/src/lensboy/analysis/plots.py +++ b/src/lensboy/analysis/plots.py @@ -403,8 +403,12 @@ def plot_distortion_grid( x_half = fov_x_half * fov_fraction y_half = fov_y_half * fov_fraction elif ux_max is not None or uy_max is not None: - x_half = ux_max if ux_max is not None else fov_x_half - y_half = uy_max if uy_max is not None else fov_y_half + x_half = fov_x_half + if ux_max is not None: + x_half = ux_max + y_half = fov_y_half + if uy_max is not None: + y_half = uy_max else: x_half = fov_x_half y_half = fov_y_half @@ -706,8 +710,12 @@ def _plot_residuals( inlier_2d.append(fi.residuals[fi.inlier_mask]) outlier_2d.append(fi.residuals[~fi.inlier_mask]) - inlier_pts = np.concatenate(inlier_2d) if len(inlier_2d) > 0 else np.empty((0, 2)) - outlier_pts = np.concatenate(outlier_2d) if len(outlier_2d) > 0 else np.empty((0, 2)) + inlier_pts = np.empty((0, 2)) + if len(inlier_2d) > 0: + inlier_pts = np.concatenate(inlier_2d) + outlier_pts = np.empty((0, 2)) + if len(outlier_2d) > 0: + outlier_pts = np.concatenate(outlier_2d) all_pts = np.concatenate([inlier_pts, outlier_pts]) # (N, 2) if all_pts.shape[0] == 0: @@ -720,7 +728,9 @@ def _plot_residuals( mad = float(np.median(np.abs(inlier_vals - mu_1d))) sigma_1d = 1.4826 * mad - half = axis_range if axis_range is not None else n_sigma * sigma_1d + half = n_sigma * sigma_1d + if axis_range is not None: + half = axis_range lo = mu_1d - half hi = mu_1d + half bin_edges = np.linspace(lo, hi, bins + 1) @@ -794,7 +804,9 @@ def _plot_residuals( # --- Bottom-left: 2D scatter + Gaussian contours --- sigma_max = max(sigma_x, sigma_y) - grid_half = axis_range if axis_range is not None else n_sigma * sigma_max + grid_half = n_sigma * sigma_max + if axis_range is not None: + grid_half = axis_range gx = np.linspace(mu_2d[0] - grid_half, mu_2d[0] + grid_half, 400) gy = np.linspace(mu_2d[1] - grid_half, mu_2d[1] + grid_half, 400) GX, GY = np.meshgrid(gx, gy) @@ -825,7 +837,9 @@ def _plot_residuals( edgecolors="none", ) - lim = axis_range if axis_range is not None else n_sigma * sigma_max + lim = n_sigma * sigma_max + if axis_range is not None: + lim = axis_range ax_2d.set_xlim(mu_2d[0] - lim, mu_2d[0] + lim) ax_2d.set_ylim(mu_2d[1] - lim, mu_2d[1] + lim) ax_2d.set_aspect("equal", adjustable="box") @@ -909,8 +923,12 @@ def _plot_residual_vectors( positions.append(frame.detected_points_in_image) residuals.append(fi.residuals) - pos = np.concatenate(positions) if len(positions) > 0 else np.empty((0, 2)) - res = np.concatenate(residuals) if len(residuals) > 0 else np.empty((0, 2)) + pos = np.empty((0, 2)) + if len(positions) > 0: + pos = np.concatenate(positions) + res = np.empty((0, 2)) + if len(residuals) > 0: + res = np.concatenate(residuals) if pos.shape[0] == 0: return @@ -1025,8 +1043,12 @@ def _plot_residual_grid( positions.append(frame.detected_points_in_image[fi.inlier_mask]) residuals.append(fi.residuals[fi.inlier_mask]) - pos = np.concatenate(positions) if len(positions) > 0 else np.empty((0, 2)) - res = np.concatenate(residuals) if len(residuals) > 0 else np.empty((0, 2)) + pos = np.empty((0, 2)) + if len(positions) > 0: + pos = np.concatenate(positions) + res = np.empty((0, 2)) + if len(residuals) > 0: + res = np.concatenate(residuals) if pos.shape[0] == 0: return @@ -1471,7 +1493,9 @@ def _diag_colored_segments( colors = cmap(diag) return segs, colors - n_rows = 3 if image is not None else 2 + n_rows = 2 + if image is not None: + n_rows = 3 panel_w = 10 panel_h = panel_w * (H_in / W_in) fig, axes = plt.subplots( @@ -1691,7 +1715,9 @@ def _draw_frame_residuals( mags = np.linalg.norm(res, axis=1) inlier_mags = mags[mask] - vmax = float(np.max(inlier_mags)) if len(inlier_mags) > 0 else 1.0 + vmax = 1.0 + if len(inlier_mags) > 0: + vmax = float(np.max(inlier_mags)) norm = mcolors.Normalize(vmin=0, vmax=vmax) ax.set_facecolor(bg) @@ -1794,7 +1820,9 @@ def _plot_worst_residual_frames( mags = np.linalg.norm(fi.residuals, axis=1) if not include_outliers: mags = mags[fi.inlier_mask] - per_frame_mags[i] = float(np.sqrt(np.mean(mags**2))) if len(mags) > 0 else 0.0 + per_frame_mags[i] = 0.0 + if len(mags) > 0: + per_frame_mags[i] = float(np.sqrt(np.mean(mags**2))) ranked = sorted(per_frame_mags, key=lambda i: per_frame_mags[i], reverse=True) selected = ranked[:n] @@ -1824,8 +1852,11 @@ def _plot_worst_residual_frames( mags = np.linalg.norm(fi.residuals, axis=1) if not include_outliers: mags = mags[fi.inlier_mask] - worst = float(np.max(mags)) if len(mags) > 0 else 0.0 - mean = float(np.mean(mags)) if len(mags) > 0 else 0.0 + worst = 0.0 + mean = 0.0 + if len(mags) > 0: + worst = float(np.max(mags)) + mean = float(np.mean(mags)) subtitle = f"Frame {idx} (max={worst:.2f} px, mean={mean:.2f} px)" _draw_frame_residuals( @@ -1904,7 +1935,9 @@ def plot_projection_diff( w = model_a.image_width h = model_a.image_height - fit_radius: float = radius if radius is not None else min(w, h) * 0.4 + fit_radius: float = min(w, h) * 0.4 + if radius is not None: + fit_radius = radius pixels_a, _, diff, (ny_dense, nx_dense), pose = compute_projection_diff( model_a, model_b, radius=fit_radius, distance=distance @@ -1925,7 +1958,9 @@ def plot_projection_diff( panel_w = 10 aspect = h / w - nrows = 2 if show_grid else 1 + nrows = 1 + if show_grid: + nrows = 2 fig, axes = plt.subplots( nrows, 1, figsize=(panel_w, nrows * panel_w * aspect), constrained_layout=True ) @@ -1936,7 +1971,9 @@ def plot_projection_diff( ax_heat, ax_grid = axes fig.patch.set_facecolor(bg) - all_axes = [ax_heat] if ax_grid is None else [ax_heat, ax_grid] + all_axes = [ax_heat] + if ax_grid is not None: + all_axes = [ax_heat, ax_grid] for ax in all_axes: ax.set_facecolor(bg) ax.tick_params(colors=fg) @@ -1993,7 +2030,9 @@ def plot_projection_diff( ax_heat.set_aspect("equal", adjustable="box") ax_heat.set_xlabel("x [px]") ax_heat.set_ylabel("y [px]") - dist_str = "at infinity" if distance is None else f"at distance {distance}" + dist_str = f"at distance {distance}" + if distance is None: + dist_str = "at infinity" ax_heat.set_title(f"Projection difference {dist_str}") # --- Bottom panel: exaggerated deformation grid --- @@ -2017,7 +2056,9 @@ def plot_projection_diff( if diff_scale is None: # Use only points within heatmap_max visible = grid_diff_norms[grid_diff_norms <= heatmap_max] - median_visible = float(np.median(visible)) if len(visible) > 0 else 1.0 + median_visible = 1.0 + if len(visible) > 0: + median_visible = float(np.median(visible)) target_displacement = min(w, h) / 40 raw_scale = target_displacement / median_visible # Round to 1-2 significant digits @@ -2229,8 +2270,12 @@ def _plot_unproject_lut_error_heatmap( f"got {error_direction_xy.shape}." ) heatmap_height, heatmap_width = max_angular_error_deg.shape - valid_x_edge_sizes = {1} if heatmap_width == 1 else {heatmap_width + 1} - valid_y_edge_sizes = {1} if heatmap_height == 1 else {heatmap_height + 1} + valid_x_edge_sizes = {heatmap_width + 1} + if heatmap_width == 1: + valid_x_edge_sizes = {1} + valid_y_edge_sizes = {heatmap_height + 1} + if heatmap_height == 1: + valid_y_edge_sizes = {1} if x_edges.ndim != 1 or x_edges.size not in valid_x_edge_sizes: raise ValueError( f"cell_x_edges must have size {sorted(valid_x_edge_sizes)}, " @@ -2309,12 +2354,12 @@ def _plot_unproject_lut_error_heatmap( length_factor = np.full(int(np.sum(quiver_mask)), max_arrow_length) else: magnitudes = max_angular_error_deg[quiver_slice][quiver_mask] - mag_max = float(np.max(magnitudes)) if magnitudes.size > 0 else 0.0 - length_factor = ( - magnitudes * (max_arrow_length / mag_max) - if mag_max > 0.0 - else np.zeros_like(magnitudes) - ) + mag_max = 0.0 + if magnitudes.size > 0: + mag_max = float(np.max(magnitudes)) + length_factor = np.zeros_like(magnitudes) + if mag_max > 0.0: + length_factor = magnitudes * (max_arrow_length / mag_max) ax.quiver( center_x[quiver_slice][quiver_mask], @@ -2385,13 +2430,15 @@ def _plot_per_image_rms( continue valid_indices.append(i) norms = np.linalg.norm(fi.residuals, axis=1) - total_rms_list.append( - float(np.sqrt(np.mean(norms**2))) if len(norms) > 0 else 0.0 - ) + total_rms = 0.0 + if len(norms) > 0: + total_rms = float(np.sqrt(np.mean(norms**2))) + total_rms_list.append(total_rms) inlier_norms = norms[fi.inlier_mask] - inlier_rms_list.append( - float(np.sqrt(np.mean(inlier_norms**2))) if len(inlier_norms) > 0 else 0.0 - ) + inlier_rms = 0.0 + if len(inlier_norms) > 0: + inlier_rms = float(np.sqrt(np.mean(inlier_norms**2))) + inlier_rms_list.append(inlier_rms) n_valid = len(valid_indices) inlier_rms = np.array(inlier_rms_list) diff --git a/src/lensboy/analysis/unproject_lut.py b/src/lensboy/analysis/unproject_lut.py index 4046892..10234eb 100644 --- a/src/lensboy/analysis/unproject_lut.py +++ b/src/lensboy/analysis/unproject_lut.py @@ -6,6 +6,7 @@ import numpy as np +from lensboy import lensboy_bindings as lbb from lensboy.camera_models.unproject_lut import UnprojectLUT if TYPE_CHECKING: @@ -307,7 +308,6 @@ def _max_cell_errors_call( error_delta_x, error_delta_y]``. Cells are row-major over ``(cell_y, cell_x)``. """ - from lensboy import lensboy_bindings as lbb from lensboy.camera_models.opencv import OpenCV from lensboy.camera_models.pinhole_splined import PinholeSplined @@ -328,7 +328,7 @@ def _max_cell_errors_call( } if isinstance(model, PinholeSplined): return lbb.max_cell_errors_pinhole_splined( - config=model._cpp_config(), + config=model._cpp_model_definition(), intrinsics=model._cpp_params(), **common_kwargs, ) diff --git a/src/lensboy/calibration/calibrate.py b/src/lensboy/calibration/calibrate.py index d8ceaa7..14c4c5f 100644 --- a/src/lensboy/calibration/calibrate.py +++ b/src/lensboy/calibration/calibrate.py @@ -18,7 +18,7 @@ TargetWarp, WarpCoordinates, ) -from lensboy.camera_models.base_model import CameraModelConfig +from lensboy.camera_models.base_model import CameraModel, CameraModelConfig from lensboy.camera_models.opencv import OpenCV, OpenCVConfig from lensboy.camera_models.pinhole_splined import ( PinholeSplined, @@ -75,7 +75,7 @@ def _mad_sigma_1d(x: np.ndarray) -> float: med = np.median(x) mad = np.median(np.abs(x - med)) # 1.4826 = 1 / Phi^{-1}(0.75) (MAD->sigma for 1D normal) - return 1.4826 * mad + return float(1.4826 * mad) def _robust_sigma_xy(residuals: list[np.ndarray]) -> float: @@ -126,6 +126,12 @@ def _opencv_calibrate_inner( params = batch.intrinsics._params() mask = config.optimize_mask() intrinsics_param_optimize_mask = mask.tolist() + warp_coordinates_cpp = None + if warp_coordinates is not None: + warp_coordinates_cpp = warp_coordinates._to_cpp() + warp_coeffs_initial = [0.0] * 5 + if batch.warp_coeffs is not None: + warp_coeffs_initial = list(batch.warp_coeffs) result = lbb.calibrate_opencv( intrinsics_initial_value=params, @@ -133,12 +139,8 @@ def _opencv_calibrate_inner( cameras_from_target=[p._to_cpp() for p in batch.cameras_from_target], target_points=list(target_points), frames=[f._to_cpp() for f in batch.frames], - warp_coordinates=( - warp_coordinates._to_cpp() if warp_coordinates is not None else None - ), - warp_coeffs_initial=( - list(batch.warp_coeffs) if batch.warp_coeffs is not None else [0.0] * 5 - ), + warp_coordinates=warp_coordinates_cpp, + warp_coeffs_initial=warp_coeffs_initial, ) out_coeffs: tuple[float, float, float, float, float] | None = None @@ -377,29 +379,113 @@ def _solve_pnp_all_frames( poses.append(identity) solved.append(False) - mean_error = total_squared_error / total_points if total_points > 0 else float("inf") + mean_error = float("inf") + if total_points > 0: + mean_error = total_squared_error / total_points return poses, solved, mean_error +def _solve_pnp_all_frames_with_model( + model: OpenCV | PinholeSplined, + target_points: np.ndarray, + frames: list[Frame], +) -> tuple[list[Pose], list[bool], float]: + """Estimate camera-from-target transforms with normalized detections. + + Args: + model: Camera model used to normalize detected image points. + target_points: Calibration target 3D points, shape (N, 3). + frames: Detected calibration frames. + + Returns: + Tuple of camera-from-target transforms, per-frame solved mask, and mean + squared reprojection error. + """ + normalized_K = np.eye(3, dtype=np.float64) + zero_distortion = np.zeros(4, dtype=np.float64) + + cameras_from_target: list[Pose] = [] + solved: list[bool] = [] + total_squared_error = 0.0 + total_points = 0 + + for frame in frames: + obj_pts = np.ascontiguousarray( + target_points[frame.target_point_indices], dtype=np.float64 + ) + normalized_points_in_camera = model.normalize_points( + frame.detected_points_in_image + ) + normalized_xy = np.ascontiguousarray( + normalized_points_in_camera[:, :2], + dtype=np.float64, + ) + + if len(obj_pts) < 4: + cameras_from_target.append(Pose.identity()) + solved.append(False) + continue + + success, rvec, tvec = cv2.solvePnP( + obj_pts, + normalized_xy, + normalized_K, + zero_distortion, + ) + if not success: + cameras_from_target.append(Pose.identity()) + solved.append(False) + continue + + camera_from_target = Pose.from_rotvec_trans( + rotvec=rvec.flatten(), trans=tvec.flatten() + ) + cameras_from_target.append(camera_from_target) + points_in_cam = camera_from_target.apply(obj_pts) + projected = model.project_points(points_in_cam) + total_squared_error += float( + np.sum((projected - frame.detected_points_in_image) ** 2) + ) + total_points += len(obj_pts) + solved.append(True) + + mean_error = float("inf") + if total_points > 0: + mean_error = total_squared_error / total_points + return cameras_from_target, solved, mean_error + + def _get_initial_state_with_pnp( config: OpenCVConfig, target_points: np.ndarray, frames: list[Frame], + initial_camera_model: OpenCV | None = None, ) -> tuple[OpenCV, list[Pose], list[bool]]: """Estimate initial intrinsics and poses using PnP. - If ``config.initial_focal_length`` is set, uses that value directly. - Otherwise, sweeps log-spaced candidate focal lengths and picks the one - with the lowest total reprojection error. + If an initial model is supplied, uses it directly. Otherwise, uses + ``config.initial_focal_length`` when set. As a final fallback, sweeps + log-spaced candidate focal lengths and picks the one with the lowest + total reprojection error. Args: config: Camera model configuration. target_points: Calibration target 3D points, shape (N, 3). frames: Detected calibration frames. + initial_camera_model: Optional initial intrinsics to optimize from. Returns: Tuple of (initial intrinsics, poses, solved mask). """ + if initial_camera_model is not None: + cameras_from_target, solved, _ = _solve_pnp_all_frames( + initial_camera_model.K(), + target_points, + frames, + initial_camera_model.distortion_coeffs, + ) + return initial_camera_model, cameras_from_target, solved + cx = config.image_width / 2.0 cy = config.image_height / 2.0 @@ -448,7 +534,9 @@ def _make_warp_coordinates(target_points: np.ndarray) -> WarpCoordinates | None: centered = target_points - centroid _, s, Vt = np.linalg.svd(centered, full_matrices=False) - planarity_ratio = s[2] / s[1] if s[1] > 1e-10 else np.inf + planarity_ratio = np.inf + if s[1] > 1e-10: + planarity_ratio = s[2] / s[1] if planarity_ratio > _PLANARITY_RATIO_THRESHOLD: warn( "Target warp can only be estimated with a planar target " @@ -562,10 +650,15 @@ def _recover_failed_pnp( n_re_solved = sum(re_solved) log(f"Re-PnP solved {n_re_solved}/{len(frames)} frames") - poses: list[Pose | None] = [p if ok else None for p, ok in zip(re_poses, re_solved)] - inlier_masks: list[np.ndarray | None] = [ - np.ones(len(f), dtype=bool) if ok else None for f, ok in zip(frames, re_solved) - ] + poses: list[Pose | None] = [] + inlier_masks: list[np.ndarray | None] = [] + for frame, camera_from_target, ok in zip(frames, re_poses, re_solved): + if ok: + poses.append(camera_from_target) + inlier_masks.append(np.ones(len(frame), dtype=bool)) + continue + poses.append(None) + inlier_masks.append(None) return model, poses, inlier_masks @@ -575,6 +668,7 @@ def _opencv_calibrate( config: OpenCVConfig, outlier_threshold_stddevs: float | None, estimate_target_warp: bool, + initial_camera_model: OpenCV | None = None, ) -> CalibrationResult[OpenCV]: assert target_points.ndim == 2 and target_points.shape[1] == 3, ( f"Expected (N, 3) target_points, got {target_points.shape}" @@ -584,7 +678,7 @@ def _opencv_calibrate( ) log("Computing initial poses with PnP...") initial_intrinsics, initial_poses, pnp_solved = _get_initial_state_with_pnp( - config, target_points, frames + config, target_points, frames, initial_camera_model ) n_solved = sum(pnp_solved) @@ -593,12 +687,15 @@ def _opencv_calibrate( if n_failed > 0: log(f"{n_failed} frame(s) failed PnP, excluding from optimization") - poses: list[Pose | None] = [ - p if ok else None for p, ok in zip(initial_poses, pnp_solved) - ] - inlier_masks: list[np.ndarray | None] = [ - np.ones(len(f), dtype=bool) if ok else None for f, ok in zip(frames, pnp_solved) - ] + poses: list[Pose | None] = [] + inlier_masks: list[np.ndarray | None] = [] + for frame, camera_from_target, ok in zip(frames, initial_poses, pnp_solved): + if ok: + poses.append(camera_from_target) + inlier_masks.append(np.ones(len(frame), dtype=bool)) + continue + poses.append(None) + inlier_masks.append(None) warp_coordinates = None if estimate_target_warp: @@ -657,21 +754,25 @@ def optimize_fn(batch: _OptimizationBatch[OpenCV]) -> _OptimizationBatch[OpenCV] def _pinhole_splined_refine_inner( batch: _OptimizationBatch[PinholeSplined], + config: PinholeSplinedConfig, target_points: np.ndarray, warp_coordinates: WarpCoordinates | None, ) -> _OptimizationBatch[PinholeSplined]: + warp_coordinates_cpp = None + if warp_coordinates is not None: + warp_coordinates_cpp = warp_coordinates._to_cpp() + warp_coeffs_initial = [0.0] * 5 + if batch.warp_coeffs is not None: + warp_coeffs_initial = list(batch.warp_coeffs) + fine_tune_result = lbb.fine_tune_pinhole_splined( - model_config=batch.intrinsics._cpp_config(), + model_config=_pinhole_splined_cpp_optimization_config(batch.intrinsics, config), intrinsics_parameters=batch.intrinsics._cpp_params(), cameras_from_target=[pose._to_cpp() for pose in batch.cameras_from_target], target_points=list(target_points), frames=[f._to_cpp() for f in batch.frames], - warp_coordinates=( - warp_coordinates._to_cpp() if warp_coordinates is not None else None - ), - warp_coeffs_initial=( - list(batch.warp_coeffs) if batch.warp_coeffs is not None else [0.0] * 5 - ), + warp_coordinates=warp_coordinates_cpp, + warp_coeffs_initial=warp_coeffs_initial, ) out_coeffs: tuple[float, float, float, float, float] | None = None @@ -720,6 +821,30 @@ def _compute_fov_from_opencv( ) +def _pinhole_splined_cpp_optimization_config( + model: PinholeSplined, + config: PinholeSplinedConfig, +) -> lbb.PinholeSplinedOptimizationConfig: + """Build the C++ spline optimizer config from model geometry and fit config. + + Args: + model: Camera model providing the fitted FOV. + config: Calibration config providing optimizer settings. + + Returns: + C++ spline config for the optimizer. + """ + return lbb.PinholeSplinedOptimizationConfig( + config.image_width, + config.image_height, + model.fov_deg_x, + model.fov_deg_y, + config.num_knots_x, + config.num_knots_y, + config.smoothness_lambda, + ) + + def _select_covering_frames( frames: list[Frame], image_width: int, @@ -885,7 +1010,7 @@ def _estimate_spline_fov( coarse_nx = min(config.num_knots_x, 10) coarse_ny = min(config.num_knots_y, 8) - coarse_cpp_config = lbb.PinholeSplinedConfig( + coarse_cpp_config = lbb.PinholeSplinedOptimizationConfig( config.image_width, config.image_height, coarse_fov_x, @@ -917,7 +1042,6 @@ def _estimate_spline_fov( num_knots_y=coarse_ny, fov_deg_x=coarse_fov_x, fov_deg_y=coarse_fov_y, - smoothness_lambda=1.0, ) all_pnp = opencv_result.cameras_from_target @@ -933,6 +1057,14 @@ def _estimate_spline_fov( frames=solved_frames, warp_coeffs=None, ), + PinholeSplinedConfig( + image_height=config.image_height, + image_width=config.image_width, + num_knots_x=coarse_nx, + num_knots_y=coarse_ny, + fov_deg_xy=(coarse_fov_x, coarse_fov_y), + smoothness_lambda=1.0, + ), target_points, None, ) @@ -968,7 +1100,7 @@ def _build_initial_spline_model( Returns: Initial PinholeSplined model with knots matched to the OpenCV distortion. """ - cpp_config = lbb.PinholeSplinedConfig( + cpp_config = lbb.PinholeSplinedOptimizationConfig( config.image_width, config.image_height, fov_deg_x, @@ -1003,7 +1135,6 @@ def _build_initial_spline_model( num_knots_y=config.num_knots_y, fov_deg_x=fov_deg_x, fov_deg_y=fov_deg_y, - smoothness_lambda=config.smoothness_lambda, ) @@ -1013,6 +1144,7 @@ def _calibrate_pinhole_splined( config: PinholeSplinedConfig, outlier_threshold_stddevs: float | None, estimate_target_warp: bool, + initial_camera_model: PinholeSplined | None = None, ) -> CalibrationResult[PinholeSplined]: assert target_points.ndim == 2 and target_points.shape[1] == 3, ( f"Expected (N, 3) target_points, got {target_points.shape}" @@ -1021,57 +1153,68 @@ def _calibrate_pinhole_splined( f"Expected floating dtype for target_points, got {target_points.dtype}" ) - # Stage 1: Fit OpenCV seed on subsampled frames - opencv_result, sub_indices = _fit_opencv_seed(target_points, frames, config) - opencv_model = opencv_result.camera_model + if initial_camera_model is None: + # Stage 1: Fit OpenCV seed on subsampled frames + opencv_result, sub_indices = _fit_opencv_seed(target_points, frames, config) + opencv_model = opencv_result.camera_model - opencv_fov_x, opencv_fov_y = _compute_fov_from_opencv( - opencv_model, padding_fraction=0.0 - ) + opencv_fov_x, opencv_fov_y = _compute_fov_from_opencv( + opencv_model, padding_fraction=0.0 + ) - # Stage 2: Determine spline FOV - if config.fov_deg_xy is not None: - fov_deg_x, fov_deg_y = config.fov_deg_xy - log(f"Spline FOV (user-specified): {fov_deg_x:.1f}° x {fov_deg_y:.1f}°") - else: - fov_deg_x, fov_deg_y = _estimate_spline_fov( + # Stage 2: Determine spline FOV + if config.fov_deg_xy is not None: + fov_deg_x, fov_deg_y = config.fov_deg_xy + log(f"Spline FOV (user-specified): {fov_deg_x:.1f}° x {fov_deg_y:.1f}°") + else: + fov_deg_x, fov_deg_y = _estimate_spline_fov( + opencv_model, + opencv_result, + frames, + sub_indices, + target_points, + config, + opencv_fov_x, + opencv_fov_y, + ) + + # Stage 3: Build full spline model + prior_model = _build_initial_spline_model( opencv_model, - opencv_result, - frames, - sub_indices, - target_points, config, + fov_deg_x, + fov_deg_y, opencv_fov_x, opencv_fov_y, ) - # Stage 3: Build and fit full spline model - prior_model = _build_initial_spline_model( - opencv_model, - config, - fov_deg_x, - fov_deg_y, - opencv_fov_x, - opencv_fov_y, - ) + all_poses_pnp, pnp_solved_mask, _ = _solve_pnp_all_frames( + opencv_model.K(), + target_points, + frames, + dist_coeffs=opencv_model.distortion_coeffs, + ) + else: + prior_model = initial_camera_model + log("Using user-provided initial spline model") + all_poses_pnp, pnp_solved_mask, _ = _solve_pnp_all_frames_with_model( + prior_model, + target_points, + frames, + ) - # PnP for all frames using the opencv model - all_poses_pnp, pnp_solved_mask, _ = _solve_pnp_all_frames( - opencv_model.K(), - target_points, - frames, - dist_coeffs=opencv_model.distortion_coeffs, - ) n_solved = sum(pnp_solved_mask) log(f"PnP solved {n_solved}/{len(frames)} frames") - poses: list[Pose | None] = [ - p if ok else None for p, ok in zip(all_poses_pnp, pnp_solved_mask) - ] - inlier_masks: list[np.ndarray | None] = [ - np.ones(len(f), dtype=bool) if ok else None - for f, ok in zip(frames, pnp_solved_mask) - ] + poses: list[Pose | None] = [] + inlier_masks: list[np.ndarray | None] = [] + for frame, camera_from_target, ok in zip(frames, all_poses_pnp, pnp_solved_mask): + if ok: + poses.append(camera_from_target) + inlier_masks.append(np.ones(len(frame), dtype=bool)) + continue + poses.append(None) + inlier_masks.append(None) warp_coordinates = None if estimate_target_warp: @@ -1080,7 +1223,12 @@ def _calibrate_pinhole_splined( def optimize_fn( batch: _OptimizationBatch[PinholeSplined], ) -> _OptimizationBatch[PinholeSplined]: - return _pinhole_splined_refine_inner(batch, target_points, warp_coordinates) + return _pinhole_splined_refine_inner( + batch, + config, + target_points, + warp_coordinates, + ) state = _run_with_outlier_filtering( optimize_fn, @@ -1118,11 +1266,103 @@ def optimize_fn( ) +def _validate_opencv_initial_model_config( + config: OpenCVConfig, + initial_camera_model: OpenCV, +) -> None: + """Validate that an OpenCV config could have produced an initial model. + + Args: + config: Camera model configuration. + initial_camera_model: Initial model to optimize from. + """ + if config.image_width != initial_camera_model.image_width: + raise ValueError( + "Initial OpenCV model image_width is incompatible with config: " + f"{initial_camera_model.image_width} != {config.image_width}" + ) + if config.image_height != initial_camera_model.image_height: + raise ValueError( + "Initial OpenCV model image_height is incompatible with config: " + f"{initial_camera_model.image_height} != {config.image_height}" + ) + + disabled = ~config.included_distortion_coefficients + if not np.allclose(initial_camera_model.distortion_coeffs[disabled], 0.0): + disabled_indices = np.where(disabled)[0].tolist() + raise ValueError( + "Initial OpenCV model has non-zero distortion coefficients that " + f"are disabled by the config: {disabled_indices}" + ) + + +def _validate_spline_initial_model_config( + config: PinholeSplinedConfig, + initial_camera_model: PinholeSplined, +) -> None: + """Validate that a spline config could have produced an initial model. + + Args: + config: Camera model configuration. + initial_camera_model: Initial model to optimize from. + """ + if config.image_width != initial_camera_model.image_width: + raise ValueError( + "Initial spline model image_width is incompatible with config: " + f"{initial_camera_model.image_width} != {config.image_width}" + ) + if config.image_height != initial_camera_model.image_height: + raise ValueError( + "Initial spline model image_height is incompatible with config: " + f"{initial_camera_model.image_height} != {config.image_height}" + ) + + if initial_camera_model.num_knots_x != config.num_knots_x: + raise ValueError( + "Initial spline num_knots_x is incompatible with config: " + f"{initial_camera_model.num_knots_x} != {config.num_knots_x}" + ) + if initial_camera_model.num_knots_y != config.num_knots_y: + raise ValueError( + "Initial spline num_knots_y is incompatible with config: " + f"{initial_camera_model.num_knots_y} != {config.num_knots_y}" + ) + + expected_shape = (config.num_knots_y, config.num_knots_x) + if initial_camera_model.dx_grid.shape != expected_shape: + raise ValueError( + "Initial spline dx_grid shape is incompatible with config: " + f"{initial_camera_model.dx_grid.shape} != {expected_shape}" + ) + if initial_camera_model.dy_grid.shape != expected_shape: + raise ValueError( + "Initial spline dy_grid shape is incompatible with config: " + f"{initial_camera_model.dy_grid.shape} != {expected_shape}" + ) + + if config.fov_deg_xy is None: + return + + fov_deg_x, fov_deg_y = config.fov_deg_xy + if not np.isclose(initial_camera_model.fov_deg_x, fov_deg_x): + raise ValueError( + "Initial spline fov_deg_x is incompatible with config: " + f"{initial_camera_model.fov_deg_x} != {fov_deg_x}" + ) + if not np.isclose(initial_camera_model.fov_deg_y, fov_deg_y): + raise ValueError( + "Initial spline fov_deg_y is incompatible with config: " + f"{initial_camera_model.fov_deg_y} != {fov_deg_y}" + ) + + @overload def calibrate_camera( target_points: np.ndarray, frames: list[Frame], + *, camera_model_config: PinholeSplinedConfig, + initial_camera_model: PinholeSplined | None = None, estimate_target_warp: bool = True, outlier_threshold_stddevs: float | None = DEFAULT_OUTLIER_THRESHOLD, ) -> CalibrationResult[PinholeSplined]: ... @@ -1132,7 +1372,9 @@ def calibrate_camera( def calibrate_camera( target_points: np.ndarray, frames: list[Frame], + *, camera_model_config: OpenCVConfig, + initial_camera_model: OpenCV | None = None, estimate_target_warp: bool = True, outlier_threshold_stddevs: float | None = DEFAULT_OUTLIER_THRESHOLD, ) -> CalibrationResult[OpenCV]: ... @@ -1141,7 +1383,9 @@ def calibrate_camera( def calibrate_camera( target_points: np.ndarray, frames: list[Frame], + *, camera_model_config: CameraModelConfig, + initial_camera_model: CameraModel | None = None, estimate_target_warp: bool = True, outlier_threshold_stddevs: float | None = DEFAULT_OUTLIER_THRESHOLD, ) -> CalibrationResult: @@ -1154,6 +1398,7 @@ def calibrate_camera( target_points: 3D target point coordinates, shape (N, 3). frames: Per-image frames, one per calibration image. camera_model_config: Specifies the camera model to fit. + initial_camera_model: Optional initial model to optimize from. estimate_target_warp: Whether to estimate a Legendre-polynomial warp of the target to account for slight non-planarity. outlier_threshold_stddevs: Sigma threshold for outlier rejection. @@ -1169,21 +1414,41 @@ def calibrate_camera( f"Expected floating dtype for target_points, got {target_points.dtype}" ) if isinstance(camera_model_config, PinholeSplinedConfig): + if initial_camera_model is not None: + if not isinstance(initial_camera_model, PinholeSplined): + raise TypeError( + "initial_camera_model must be a PinholeSplined when " + "camera_model_config is a PinholeSplinedConfig" + ) + _validate_spline_initial_model_config( + camera_model_config, initial_camera_model + ) return _calibrate_pinhole_splined( target_points, frames, camera_model_config, outlier_threshold_stddevs, estimate_target_warp, + initial_camera_model, ) if isinstance(camera_model_config, OpenCVConfig): + if initial_camera_model is not None: + if not isinstance(initial_camera_model, OpenCV): + raise TypeError( + "initial_camera_model must be an OpenCV when " + "camera_model_config is an OpenCVConfig" + ) + _validate_opencv_initial_model_config( + camera_model_config, initial_camera_model + ) return _opencv_calibrate( target_points, frames, camera_model_config, outlier_threshold_stddevs, estimate_target_warp, + initial_camera_model, ) raise RuntimeError("Invalid config") diff --git a/src/lensboy/calibration/type_defs.py b/src/lensboy/calibration/type_defs.py index 5594c93..1651fff 100644 --- a/src/lensboy/calibration/type_defs.py +++ b/src/lensboy/calibration/type_defs.py @@ -209,19 +209,18 @@ def __repr__(self) -> str: sigma = self.residual_sigma_map() n_out = self.num_outliers() n_det = self.num_detections() + residual_line_suffix = "\n" + target_warp_line = "" + if self.target_warp is not None: + residual_line_suffix = ",\n" + target_warp_line = f" target_warp={self.target_warp!r}\n" return ( f"CalibrationResult(\n" f" model={model!r},\n" f" frames={len(self.frames)}, " f"detections={n_det}, outliers={n_out},\n" f" residual_sigma={sigma:.4f}px" - f"{',' if self.target_warp is not None else ''}\n" - + ( - f" target_warp={self.target_warp!r}\n" - if self.target_warp is not None - else "" - ) - + ")" + f"{residual_line_suffix}" + target_warp_line + ")" ) def residual_sigma_map(self) -> float: @@ -695,7 +694,9 @@ def plot_frame_residuals( fi = self.frame_diagnostics[index] if fi is None: raise ValueError(f"Frame {index} has no diagnostics (PnP failed)") - image = images[index] if images is not None else None + image = None + if images is not None: + image = images[index] return _plot_frame_residuals( self.frames[index], fi, diff --git a/src/lensboy/camera_models/pinhole_splined.py b/src/lensboy/camera_models/pinhole_splined.py index 422e482..f4dec26 100644 --- a/src/lensboy/camera_models/pinhole_splined.py +++ b/src/lensboy/camera_models/pinhole_splined.py @@ -87,8 +87,6 @@ class PinholeSplined(CameraModel): fov_deg_x: float fov_deg_y: float - smoothness_lambda: float = 1.0 - def __repr__(self) -> str: return ( f"PinholeSplined({self.image_width}x{self.image_height}, " @@ -187,15 +185,14 @@ def from_json(data: dict) -> PinholeSplined: def _camera_model_name() -> str: return "pinhole_splined" - def _cpp_config(self) -> lbb.PinholeSplinedConfig: - return lbb.PinholeSplinedConfig( + def _cpp_model_definition(self) -> lbb.PinholeSplinedModelDefinition: + return lbb.PinholeSplinedModelDefinition( self.image_width, self.image_height, self.fov_deg_x, self.fov_deg_y, self.num_knots_x, self.num_knots_y, - self.smoothness_lambda, ) def _cpp_params(self) -> lbb.PinholeSplinedIntrinsicsParameters: @@ -221,7 +218,7 @@ def normalize_points(self, pixel_coords: np.ndarray) -> np.ndarray: f"Expected (N, 2) array, got {pts.shape}" ) return lbb.normalize_pinhole_splined_points( - self._cpp_config(), + self._cpp_model_definition(), self._cpp_params(), pixel_coords=pts, ) @@ -269,7 +266,7 @@ def project_points( f"Expected floating dtype, got {points_in_cam.dtype}" ) return lbb.project_pinhole_splined_points( - self._cpp_config(), + self._cpp_model_definition(), self._cpp_params(), points_in_camera=points_in_cam, ) @@ -302,8 +299,12 @@ def get_pinhole_model_fov( Returns: Undistorted pinhole model with precomputed remap tables. """ - fov_x = target_fov_deg_x if target_fov_deg_x is not None else self.fov_deg_x - fov_y = target_fov_deg_y if target_fov_deg_y is not None else self.fov_deg_y + fov_x = self.fov_deg_x + if target_fov_deg_x is not None: + fov_x = target_fov_deg_x + fov_y = self.fov_deg_y + if target_fov_deg_y is not None: + fov_y = target_fov_deg_y if image_size_wh is None: image_size_wh = (self.image_width, self.image_height) @@ -344,10 +345,14 @@ def get_pinhole_model( Returns: Undistorted pinhole model with precomputed remap tables. """ - fx = fx if fx is not None else self.fx - fy = fy if fy is not None else self.fy - cx = cx if cx is not None else self.cx - cy = cy if cy is not None else self.cy + if fx is None: + fx = self.fx + if fy is None: + fy = self.fy + if cx is None: + cx = self.cx + if cy is None: + cy = self.cy if image_size_wh is None: image_size_wh = (self.image_width, self.image_height) @@ -355,7 +360,7 @@ def get_pinhole_model( pinhole_parameters = (fx, fy, cx, cy) map_x, map_y = lbb.make_undistortion_maps_pinhole_splined( - self._cpp_config(), + self._cpp_model_definition(), self._cpp_params(), np.array(pinhole_parameters, dtype=float), image_size_wh, diff --git a/src/lensboy/camera_models/unproject_lut.py b/src/lensboy/camera_models/unproject_lut.py index dbe7eab..f8f75a1 100644 --- a/src/lensboy/camera_models/unproject_lut.py +++ b/src/lensboy/camera_models/unproject_lut.py @@ -96,7 +96,9 @@ def _compute_seed_grid( # Seed grid: ~1 seed per 4 image pixels along the long axis w = camera_model.image_width h = camera_model.image_height - aspect = half_x / half_y if half_y > 0 else 1.0 + aspect = 1.0 + if half_y > 0: + aspect = half_x / half_y seed_long = max(w, h) // 4 if aspect >= 1.0: seed_width = seed_long @@ -187,7 +189,7 @@ def _seeded_normalize( seed_width, seed_height, query_pixels, - camera_model._cpp_config(), + camera_model._cpp_model_definition(), camera_model._cpp_params(), ) diff --git a/src/lensboy/lensboy_bindings.pyi b/src/lensboy/lensboy_bindings.pyi index 0ddc299..6a1ca99 100644 --- a/src/lensboy/lensboy_bindings.pyi +++ b/src/lensboy/lensboy_bindings.pyi @@ -6,9 +6,32 @@ import collections.abc import numpy import numpy.typing import typing -__all__: list[str] = ['PinholeSplinedConfig', 'PinholeSplinedIntrinsicsParameters', 'WarpCoordinates', 'add', 'calibrate_opencv', 'fine_tune_pinhole_splined', 'get_matching_spline_distortion_model', 'make_undistortion_maps_pinhole_splined', 'max_cell_errors_opencv', 'max_cell_errors_pinhole_splined', 'normalize_pinhole_splined_points', 'project_pinhole_splined_points', 'seeded_normalize_opencv', 'seeded_normalize_splined', 'set_log_level', 'warp_target_points'] -class PinholeSplinedConfig: - def __init__(self, image_width: typing.SupportsInt, image_height: typing.SupportsInt, fov_deg_x: typing.SupportsFloat, fov_deg_y: typing.SupportsFloat, num_knots_x: typing.SupportsInt, num_knots_y: typing.SupportsInt, smoothness_lambda: typing.SupportsFloat) -> None: +__all__: list[str] = ['PinholeSplinedIntrinsicsParameters', 'PinholeSplinedModelDefinition', 'PinholeSplinedOptimizationConfig', 'WarpCoordinates', 'add', 'calibrate_opencv', 'fine_tune_pinhole_splined', 'get_matching_spline_distortion_model', 'make_undistortion_maps_pinhole_splined', 'max_cell_errors_opencv', 'max_cell_errors_pinhole_splined', 'normalize_pinhole_splined_points', 'project_pinhole_splined_points', 'seeded_normalize_opencv', 'seeded_normalize_splined', 'set_log_level', 'warp_target_points'] +class PinholeSplinedIntrinsicsParameters: + def __init__(self, pinhole_parameters: typing.Annotated[numpy.typing.ArrayLike, numpy.float64], dx_grid: typing.Annotated[numpy.typing.ArrayLike, numpy.float64], dy_grid: typing.Annotated[numpy.typing.ArrayLike, numpy.float64]) -> None: + ... + def __repr__(self) -> str: + ... + @property + def dx_grid(self) -> numpy.typing.NDArray[numpy.float64]: + ... + @dx_grid.setter + def dx_grid(self, arg0: typing.Annotated[numpy.typing.ArrayLike, numpy.float64]) -> None: + ... + @property + def dy_grid(self) -> numpy.typing.NDArray[numpy.float64]: + ... + @dy_grid.setter + def dy_grid(self, arg0: typing.Annotated[numpy.typing.ArrayLike, numpy.float64]) -> None: + ... + @property + def pinhole_parameters(self) -> numpy.typing.NDArray[numpy.float64]: + ... + @pinhole_parameters.setter + def pinhole_parameters(self, arg0: typing.Annotated[numpy.typing.ArrayLike, numpy.float64]) -> None: + ... +class PinholeSplinedModelDefinition: + def __init__(self, image_width: typing.SupportsInt, image_height: typing.SupportsInt, fov_deg_x: typing.SupportsFloat, fov_deg_y: typing.SupportsFloat, num_knots_x: typing.SupportsInt, num_knots_y: typing.SupportsInt) -> None: ... def __repr__(self) -> str: ... @@ -48,34 +71,16 @@ class PinholeSplinedConfig: @num_knots_y.setter def num_knots_y(self, arg0: typing.SupportsInt) -> None: ... - @property - def smoothness_lambda(self) -> float: - ... - @smoothness_lambda.setter - def smoothness_lambda(self, arg0: typing.SupportsFloat) -> None: - ... -class PinholeSplinedIntrinsicsParameters: - def __init__(self, pinhole_parameters: typing.Annotated[numpy.typing.ArrayLike, numpy.float64], dx_grid: typing.Annotated[numpy.typing.ArrayLike, numpy.float64], dy_grid: typing.Annotated[numpy.typing.ArrayLike, numpy.float64]) -> None: +class PinholeSplinedOptimizationConfig(PinholeSplinedModelDefinition): + def __init__(self, image_width: typing.SupportsInt, image_height: typing.SupportsInt, fov_deg_x: typing.SupportsFloat, fov_deg_y: typing.SupportsFloat, num_knots_x: typing.SupportsInt, num_knots_y: typing.SupportsInt, smoothness_lambda: typing.SupportsFloat) -> None: ... def __repr__(self) -> str: ... @property - def dx_grid(self) -> numpy.typing.NDArray[numpy.float64]: - ... - @dx_grid.setter - def dx_grid(self, arg0: typing.Annotated[numpy.typing.ArrayLike, numpy.float64]) -> None: - ... - @property - def dy_grid(self) -> numpy.typing.NDArray[numpy.float64]: - ... - @dy_grid.setter - def dy_grid(self, arg0: typing.Annotated[numpy.typing.ArrayLike, numpy.float64]) -> None: - ... - @property - def pinhole_parameters(self) -> numpy.typing.NDArray[numpy.float64]: + def smoothness_lambda(self) -> float: ... - @pinhole_parameters.setter - def pinhole_parameters(self, arg0: typing.Annotated[numpy.typing.ArrayLike, numpy.float64]) -> None: + @smoothness_lambda.setter + def smoothness_lambda(self, arg0: typing.SupportsFloat) -> None: ... class WarpCoordinates: def __init__(self, target_from_warp_frame: typing.Annotated[numpy.typing.ArrayLike, numpy.float64, "[6, 1]"], x_scale: typing.SupportsFloat, y_scale: typing.SupportsFloat) -> None: @@ -106,23 +111,23 @@ def add(a: typing.SupportsInt, b: typing.SupportsInt) -> int: """ def calibrate_opencv(intrinsics_initial_value: collections.abc.Sequence[typing.SupportsFloat], intrinsics_param_optimize_mask: collections.abc.Sequence[bool], cameras_from_target: collections.abc.Sequence[typing.Annotated[numpy.typing.ArrayLike, numpy.float64, "[6, 1]"]], target_points: collections.abc.Sequence[typing.Annotated[numpy.typing.ArrayLike, numpy.float64, "[3, 1]"]], frames: collections.abc.Sequence[tuple[collections.abc.Sequence[typing.SupportsInt], collections.abc.Sequence[typing.Annotated[numpy.typing.ArrayLike, numpy.float64, "[2, 1]"]]]], warp_coordinates: WarpCoordinates | None = None, warp_coeffs_initial: typing.Annotated[collections.abc.Sequence[typing.SupportsFloat], "FixedSize(5)"] = [0.0, 0.0, 0.0, 0.0, 0.0]) -> dict: ... -def fine_tune_pinhole_splined(model_config: PinholeSplinedConfig, intrinsics_parameters: PinholeSplinedIntrinsicsParameters, cameras_from_target: collections.abc.Sequence[typing.Annotated[numpy.typing.ArrayLike, numpy.float64, "[6, 1]"]], target_points: collections.abc.Sequence[typing.Annotated[numpy.typing.ArrayLike, numpy.float64, "[3, 1]"]], frames: collections.abc.Sequence[tuple[collections.abc.Sequence[typing.SupportsInt], collections.abc.Sequence[typing.Annotated[numpy.typing.ArrayLike, numpy.float64, "[2, 1]"]]]], warp_coordinates: WarpCoordinates | None = None, warp_coeffs_initial: typing.Annotated[collections.abc.Sequence[typing.SupportsFloat], "FixedSize(5)"] = [0.0, 0.0, 0.0, 0.0, 0.0]) -> dict: +def fine_tune_pinhole_splined(model_config: PinholeSplinedOptimizationConfig, intrinsics_parameters: PinholeSplinedIntrinsicsParameters, cameras_from_target: collections.abc.Sequence[typing.Annotated[numpy.typing.ArrayLike, numpy.float64, "[6, 1]"]], target_points: collections.abc.Sequence[typing.Annotated[numpy.typing.ArrayLike, numpy.float64, "[3, 1]"]], frames: collections.abc.Sequence[tuple[collections.abc.Sequence[typing.SupportsInt], collections.abc.Sequence[typing.Annotated[numpy.typing.ArrayLike, numpy.float64, "[2, 1]"]]]], warp_coordinates: WarpCoordinates | None = None, warp_coeffs_initial: typing.Annotated[collections.abc.Sequence[typing.SupportsFloat], "FixedSize(5)"] = [0.0, 0.0, 0.0, 0.0, 0.0]) -> dict: ... -def get_matching_spline_distortion_model(opencv_distortion_params: collections.abc.Sequence[typing.SupportsFloat], model_config: PinholeSplinedConfig, image_bound_x: typing.SupportsFloat, image_bound_y: typing.SupportsFloat) -> dict: +def get_matching_spline_distortion_model(opencv_distortion_params: collections.abc.Sequence[typing.SupportsFloat], model_config: PinholeSplinedOptimizationConfig, image_bound_x: typing.SupportsFloat, image_bound_y: typing.SupportsFloat) -> dict: ... -def make_undistortion_maps_pinhole_splined(model_config: PinholeSplinedConfig, intrinsics: PinholeSplinedIntrinsicsParameters, pinhole_parameters: typing.Annotated[numpy.typing.ArrayLike, numpy.float64], image_size_wh: tuple[typing.SupportsInt, typing.SupportsInt]) -> tuple: +def make_undistortion_maps_pinhole_splined(model_config: PinholeSplinedModelDefinition, intrinsics: PinholeSplinedIntrinsicsParameters, pinhole_parameters: typing.Annotated[numpy.typing.ArrayLike, numpy.float64], image_size_wh: tuple[typing.SupportsInt, typing.SupportsInt]) -> tuple: ... def max_cell_errors_opencv(intrinsics: typing.Annotated[numpy.typing.ArrayLike, numpy.float64], lut_xy_grid: typing.Annotated[numpy.typing.ArrayLike, numpy.float64], image_width: typing.SupportsInt, image_height: typing.SupportsInt, interpolation_mode: typing.SupportsInt, max_iterations: typing.SupportsInt, gradient_tolerance: typing.SupportsFloat) -> numpy.typing.NDArray[numpy.float64]: ... -def max_cell_errors_pinhole_splined(config: PinholeSplinedConfig, intrinsics: PinholeSplinedIntrinsicsParameters, lut_xy_grid: typing.Annotated[numpy.typing.ArrayLike, numpy.float64], image_width: typing.SupportsInt, image_height: typing.SupportsInt, interpolation_mode: typing.SupportsInt, max_iterations: typing.SupportsInt, gradient_tolerance: typing.SupportsFloat) -> numpy.typing.NDArray[numpy.float64]: +def max_cell_errors_pinhole_splined(config: PinholeSplinedModelDefinition, intrinsics: PinholeSplinedIntrinsicsParameters, lut_xy_grid: typing.Annotated[numpy.typing.ArrayLike, numpy.float64], image_width: typing.SupportsInt, image_height: typing.SupportsInt, interpolation_mode: typing.SupportsInt, max_iterations: typing.SupportsInt, gradient_tolerance: typing.SupportsFloat) -> numpy.typing.NDArray[numpy.float64]: ... -def normalize_pinhole_splined_points(model_config: PinholeSplinedConfig, intrinsics: PinholeSplinedIntrinsicsParameters, pixel_coords: typing.Annotated[numpy.typing.ArrayLike, numpy.float64]) -> numpy.typing.NDArray[numpy.float64]: +def normalize_pinhole_splined_points(model_config: PinholeSplinedModelDefinition, intrinsics: PinholeSplinedIntrinsicsParameters, pixel_coords: typing.Annotated[numpy.typing.ArrayLike, numpy.float64]) -> numpy.typing.NDArray[numpy.float64]: ... -def project_pinhole_splined_points(model_config: PinholeSplinedConfig, intrinsics: PinholeSplinedIntrinsicsParameters, points_in_camera: typing.Annotated[numpy.typing.ArrayLike, numpy.float64]) -> numpy.typing.NDArray[numpy.float64]: +def project_pinhole_splined_points(model_config: PinholeSplinedModelDefinition, intrinsics: PinholeSplinedIntrinsicsParameters, points_in_camera: typing.Annotated[numpy.typing.ArrayLike, numpy.float64]) -> numpy.typing.NDArray[numpy.float64]: ... def seeded_normalize_opencv(seed_pixels: typing.Annotated[numpy.typing.ArrayLike, numpy.float64], seed_normals: typing.Annotated[numpy.typing.ArrayLike, numpy.float64], seed_width: typing.SupportsInt, seed_height: typing.SupportsInt, query_pixels: typing.Annotated[numpy.typing.ArrayLike, numpy.float64], intrinsics: typing.Annotated[numpy.typing.ArrayLike, numpy.float64]) -> numpy.typing.NDArray[numpy.float64]: ... -def seeded_normalize_splined(seed_pixels: typing.Annotated[numpy.typing.ArrayLike, numpy.float64], seed_normals: typing.Annotated[numpy.typing.ArrayLike, numpy.float64], seed_width: typing.SupportsInt, seed_height: typing.SupportsInt, query_pixels: typing.Annotated[numpy.typing.ArrayLike, numpy.float64], config: PinholeSplinedConfig, intrinsics: PinholeSplinedIntrinsicsParameters) -> numpy.typing.NDArray[numpy.float64]: +def seeded_normalize_splined(seed_pixels: typing.Annotated[numpy.typing.ArrayLike, numpy.float64], seed_normals: typing.Annotated[numpy.typing.ArrayLike, numpy.float64], seed_width: typing.SupportsInt, seed_height: typing.SupportsInt, query_pixels: typing.Annotated[numpy.typing.ArrayLike, numpy.float64], config: PinholeSplinedModelDefinition, intrinsics: PinholeSplinedIntrinsicsParameters) -> numpy.typing.NDArray[numpy.float64]: ... def set_log_level(level: str) -> None: ... diff --git a/tests/test_integration.py b/tests/test_integration.py index 6989ddf..bf172ec 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,6 +1,7 @@ """Integration tests using a real charuco dataset and synthetic data.""" from collections.abc import Callable +from dataclasses import replace from pathlib import Path import numpy as np @@ -77,6 +78,59 @@ def test_opencv_full14_explicit_focal_length() -> None: assert outlier_pct < 1.2, f"Too many outliers: {outlier_pct:.1f}%" +def test_opencv_calibrates_from_initial_model() -> None: + """Calibrate an OpenCV model starting from a previous calibrated model.""" + target_points, frames, img_h, img_w = load_test_dataset() + + config = lb.OpenCVConfig( + image_height=img_h, + image_width=img_w, + included_distortion_coefficients=lb.OpenCVConfig.FULL_14, + ) + baseline = lb.calibrate_camera(target_points, frames, camera_model_config=config) + initial_model = replace( + baseline.camera_model, + fx=baseline.camera_model.fx * 1.05, + fy=baseline.camera_model.fy * 0.95, + cx=baseline.camera_model.cx + 8.0, + cy=baseline.camera_model.cy - 6.0, + distortion_coeffs=baseline.camera_model.distortion_coeffs + + np.array( + [ + 0.02, + -0.01, + 0.001, + -0.001, + 0.005, + -0.002, + 0.001, + -0.001, + 0.0005, + -0.0005, + 0.0005, + -0.0005, + 0.0002, + -0.0002, + ], + dtype=np.float64, + ), + ) + result = lb.calibrate_camera( + target_points, + frames, + camera_model_config=config, + initial_camera_model=initial_model, + ) + + sigma = result.residual_sigma_map() + baseline_sigma = baseline.residual_sigma_map() + outlier_pct = (result.num_outliers() / result.num_detections()) * 100 + + assert sigma < 0.11, f"Residual sigma too high: {sigma:.3f}px" + assert sigma <= baseline_sigma + 0.01 + assert outlier_pct < 1.2, f"Too many outliers: {outlier_pct:.1f}%" + + def test_spline_30x20() -> None: """Calibrate a 30x20 spline model.""" target_points, frames, img_h, img_w = load_test_dataset() @@ -98,6 +152,50 @@ def test_spline_30x20() -> None: _check_frame_projections(result, target_points, frames) +def test_spline_calibrates_from_initial_model() -> None: + """Calibrate a spline model starting from a previous calibrated model.""" + target_points, frames, img_h, img_w = load_test_dataset() + + config = lb.PinholeSplinedConfig( + img_h, + img_w, + num_knots_x=30, + num_knots_y=20, + ) + baseline = lb.calibrate_camera(target_points, frames, camera_model_config=config) + initial_model = replace( + baseline.camera_model, + dx_grid=baseline.camera_model.dx_grid + + np.linspace( + -0.001, + 0.001, + baseline.camera_model.dx_grid.size, + dtype=np.float64, + ).reshape(baseline.camera_model.dx_grid.shape), + dy_grid=baseline.camera_model.dy_grid + + np.linspace( + 0.001, + -0.001, + baseline.camera_model.dy_grid.size, + dtype=np.float64, + ).reshape(baseline.camera_model.dy_grid.shape), + ) + result = lb.calibrate_camera( + target_points, + frames, + camera_model_config=config, + initial_camera_model=initial_model, + ) + + sigma = result.residual_sigma_map() + baseline_sigma = baseline.residual_sigma_map() + outlier_pct = result.num_outliers() / result.num_detections() * 100 + + assert sigma < 0.09, f"Residual sigma too high: {sigma:.3f}px" + assert sigma <= baseline_sigma + 0.01 + assert outlier_pct < 1.6, f"Too many outliers: {outlier_pct:.1f}%" + + def test_opencv_all_outliers_in_one_frame() -> None: """Calibration succeeds when one frame has all its points corrupted.""" target_points, frames, img_h, img_w = load_test_dataset() @@ -167,6 +265,67 @@ def test_opencv_distortion_mask() -> None: ) +def test_initial_opencv_model_must_match_config() -> None: + """Reject an initial OpenCV model with parameters outside the config.""" + target_points = np.zeros((4, 3), dtype=float) + config = lb.OpenCVConfig( + image_height=480, + image_width=640, + included_distortion_coefficients=lb.OpenCVConfig.NONE, + ) + initial_model = lb.OpenCV( + image_height=480, + image_width=640, + fx=300.0, + fy=300.0, + cx=320.0, + cy=240.0, + distortion_coeffs=np.array([0.1]), + ) + + with pytest.raises(ValueError, match="disabled by the config"): + lb.calibrate_camera( + target_points, + [], + camera_model_config=config, + initial_camera_model=initial_model, + ) + + +def test_initial_spline_model_must_match_config() -> None: + """Reject an initial spline model that the config could not produce.""" + target_points = np.zeros((4, 3), dtype=float) + config = lb.PinholeSplinedConfig( + image_height=480, + image_width=640, + num_knots_x=12, + num_knots_y=8, + fov_deg_xy=(90.0, 70.0), + ) + initial_model = lb.PinholeSplined( + image_height=480, + image_width=640, + fx=300.0, + fy=300.0, + cx=320.0, + cy=240.0, + dx_grid=np.zeros((8, 12), dtype=float), + dy_grid=np.zeros((8, 12), dtype=float), + num_knots_x=12, + num_knots_y=8, + fov_deg_x=91.0, + fov_deg_y=70.0, + ) + + with pytest.raises(ValueError, match="fov_deg_x"): + lb.calibrate_camera( + target_points, + [], + camera_model_config=config, + initial_camera_model=initial_model, + ) + + def _check_frame_projections( result: lb.CalibrationResult, target_points: np.ndarray, From 43fb01e871a017b3dcdfaff750159fd483357a00 Mon Sep 17 00:00:00 2001 From: Robertleoj Date: Sat, 30 May 2026 19:37:35 +0200 Subject: [PATCH 2/3] config is not kw-only --- src/lensboy/calibration/calibrate.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lensboy/calibration/calibrate.py b/src/lensboy/calibration/calibrate.py index 14c4c5f..a53f1fd 100644 --- a/src/lensboy/calibration/calibrate.py +++ b/src/lensboy/calibration/calibrate.py @@ -1360,8 +1360,8 @@ def _validate_spline_initial_model_config( def calibrate_camera( target_points: np.ndarray, frames: list[Frame], - *, camera_model_config: PinholeSplinedConfig, + *, initial_camera_model: PinholeSplined | None = None, estimate_target_warp: bool = True, outlier_threshold_stddevs: float | None = DEFAULT_OUTLIER_THRESHOLD, @@ -1372,8 +1372,8 @@ def calibrate_camera( def calibrate_camera( target_points: np.ndarray, frames: list[Frame], - *, camera_model_config: OpenCVConfig, + *, initial_camera_model: OpenCV | None = None, estimate_target_warp: bool = True, outlier_threshold_stddevs: float | None = DEFAULT_OUTLIER_THRESHOLD, @@ -1383,8 +1383,8 @@ def calibrate_camera( def calibrate_camera( target_points: np.ndarray, frames: list[Frame], - *, camera_model_config: CameraModelConfig, + *, initial_camera_model: CameraModel | None = None, estimate_target_warp: bool = True, outlier_threshold_stddevs: float | None = DEFAULT_OUTLIER_THRESHOLD, From f3e046ef1f62a6fc0f2a4fa5bfbc73224a5242c1 Mon Sep 17 00:00:00 2001 From: Robertleoj Date: Sat, 30 May 2026 19:48:39 +0200 Subject: [PATCH 3/3] bump version --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ce1f547..234594b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "scikit_build_core.build" [project] name = "lensboy" -version = "3.1.0" +version = "4.0.0" description = "lensboy for camera calibrations" authors = [{ name = "Robert Leo", email = "robert.leo.jonsson@gmail.com" }] diff --git a/uv.lock b/uv.lock index a3b7372..5444959 100644 --- a/uv.lock +++ b/uv.lock @@ -585,7 +585,7 @@ wheels = [ [[package]] name = "lensboy" -version = "3.1.0" +version = "4.0.0" source = { editable = "." } dependencies = [ { name = "numpy" },