From 325e30db1dd0cdfb75df04de3a2dd646fa117e34 Mon Sep 17 00:00:00 2001 From: Thebinary110 Date: Fri, 24 Jul 2026 05:18:18 +0530 Subject: [PATCH] calib: expose calibrateHandEye and calibrateRobotWorldHandEye to Python Both functions are fully implemented and documented but were tagged CV_EXPORTS instead of CV_EXPORTS_W, so they were unreachable from Python (and Java/JS) bindings. They use only bindable types (InputArrayOfArrays, OutputArray, plain enums), so this is a pure binding-exposure fix, not a behavior change. Adds Python regression tests for both functions using synthetic hand-eye data that satisfies the documented AX=XB (calibrateHandEye) and AX=ZB (calibrateRobotWorldHandEye) relations, verifying the recovered transform matches the synthetic ground truth. Verified locally: built opencv_python3 bindings from this branch and confirmed cv2.calibrateHandEye / cv2.calibrateRobotWorldHandEye are now present and both new tests pass against synthetic ground-truth poses. --- modules/calib/include/opencv2/calib.hpp | 4 +- .../misc/python/test/test_calibration.py | 80 +++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/modules/calib/include/opencv2/calib.hpp b/modules/calib/include/opencv2/calib.hpp index e254dfc333..4405b59dca 100644 --- a/modules/calib/include/opencv2/calib.hpp +++ b/modules/calib/include/opencv2/calib.hpp @@ -1256,7 +1256,7 @@ A minimum of 2 motions with non parallel rotation axes are necessary to determin So at least 3 different poses are required, but it is strongly recommended to use many more poses. */ -CV_EXPORTS void calibrateHandEye( InputArrayOfArrays R_gripper2base, InputArrayOfArrays t_gripper2base, +CV_EXPORTS_W void calibrateHandEye( InputArrayOfArrays R_gripper2base, InputArrayOfArrays t_gripper2base, InputArrayOfArrays R_target2cam, InputArrayOfArrays t_target2cam, OutputArray R_cam2gripper, OutputArray t_cam2gripper, HandEyeCalibrationMethod method=CALIB_HAND_EYE_TSAI ); @@ -1399,7 +1399,7 @@ This problem is also known as solving the \f$\mathbf{A}\mathbf{X}=\mathbf{Z}\mat At least 3 measurements are required (input vectors size must be greater or equal to 3). */ -CV_EXPORTS void calibrateRobotWorldHandEye( InputArrayOfArrays R_world2cam, InputArrayOfArrays t_world2cam, +CV_EXPORTS_W void calibrateRobotWorldHandEye( InputArrayOfArrays R_world2cam, InputArrayOfArrays t_world2cam, InputArrayOfArrays R_base2gripper, InputArrayOfArrays t_base2gripper, OutputArray R_base2world, OutputArray t_base2world, OutputArray R_gripper2cam, OutputArray t_gripper2cam, diff --git a/modules/calib/misc/python/test/test_calibration.py b/modules/calib/misc/python/test/test_calibration.py index 8c3e2c6467..426a2654a1 100755 --- a/modules/calib/misc/python/test/test_calibration.py +++ b/modules/calib/misc/python/test/test_calibration.py @@ -92,5 +92,85 @@ class calibration_test(NewOpenCVTests): self.assertTrue(isinstance(dist, (float, np.floating))) self.assertGreaterEqual(dist, 0.0) + @staticmethod + def _random_rt(): + # random rotation (axis-angle) + random translation + axis = np.random.uniform(-1, 1, 3) + axis /= np.linalg.norm(axis) + angle = np.random.uniform(0.2, 1.0) * (1 if np.random.rand() > 0.5 else -1) + rvec = (axis * angle).astype(np.float64) + R, _ = cv.Rodrigues(rvec) + t = np.random.uniform(-100, 100, 3).astype(np.float64) + return R, t + + @staticmethod + def _to_T(R, t): + T = np.eye(4) + T[:3, :3] = R + T[:3, 3] = t + return T + + def test_calibrateHandEye(self): + # Ground truth camera-to-gripper transform (what calibrateHandEye should recover) + R_cam2gripper_gt, t_cam2gripper_gt = self._random_rt() + X = self._to_T(R_cam2gripper_gt, t_cam2gripper_gt) + + # Arbitrary fixed target-to-base transform (the calibration board doesn't move) + R_target2base, t_target2base = self._random_rt() + Wt = self._to_T(R_target2base, t_target2base) + + n_poses = 10 + Rs_gripper2base, ts_gripper2base = [], [] + Rs_target2cam, ts_target2cam = [], [] + for _ in range(n_poses): + R_g2b, t_g2b = self._random_rt() + G = self._to_T(R_g2b, t_g2b) + # Consistent synthetic data: Wt == G @ X @ T_target2cam for every pose + T_target2cam = np.linalg.inv(X) @ np.linalg.inv(G) @ Wt + + Rs_gripper2base.append(R_g2b) + ts_gripper2base.append(t_g2b) + Rs_target2cam.append(T_target2cam[:3, :3]) + ts_target2cam.append(T_target2cam[:3, 3]) + + R_cam2gripper, t_cam2gripper = cv.calibrateHandEye( + Rs_gripper2base, ts_gripper2base, Rs_target2cam, ts_target2cam) + + self.assertLess(cv.norm(R_cam2gripper - R_cam2gripper_gt, cv.NORM_L1), 1e-3) + self.assertLess(cv.norm(t_cam2gripper.flatten() - t_cam2gripper_gt, cv.NORM_L1), 1e-3) + + def test_calibrateRobotWorldHandEye(self): + # Ground truth outputs (what calibrateRobotWorldHandEye should recover) + R_base2world_gt, t_base2world_gt = self._random_rt() + X = self._to_T(R_base2world_gt, t_base2world_gt) + + R_gripper2cam_gt, t_gripper2cam_gt = self._random_rt() + Z = self._to_T(R_gripper2cam_gt, t_gripper2cam_gt) + + n_poses = 10 + Rs_world2cam, ts_world2cam = [], [] + Rs_base2gripper, ts_base2gripper = [], [] + for _ in range(n_poses): + R_b2g, t_b2g = self._random_rt() + B = self._to_T(R_b2g, t_b2g) + # Consistent synthetic data: A_i @ X == Z @ B_i => A_i = Z @ B_i @ inv(X) + A = Z @ B @ np.linalg.inv(X) + + Rs_world2cam.append(A[:3, :3]) + # NOTE: unlike calibrateHandEye, calibrateRobotWorldHandEye's Shah/Li + # solvers require translations as explicit (3,1) column vectors, not + # flat (3,) arrays -- passing (3,) raises a gemm shape-assertion error. + ts_world2cam.append(A[:3, 3].reshape(3, 1)) + Rs_base2gripper.append(R_b2g) + ts_base2gripper.append(t_b2g.reshape(3, 1)) + + R_base2world, t_base2world, R_gripper2cam, t_gripper2cam = cv.calibrateRobotWorldHandEye( + Rs_world2cam, ts_world2cam, Rs_base2gripper, ts_base2gripper) + + self.assertLess(cv.norm(R_base2world - R_base2world_gt, cv.NORM_L1), 1e-3) + self.assertLess(cv.norm(t_base2world.flatten() - t_base2world_gt, cv.NORM_L1), 1e-3) + self.assertLess(cv.norm(R_gripper2cam - R_gripper2cam_gt, cv.NORM_L1), 1e-3) + self.assertLess(cv.norm(t_gripper2cam.flatten() - t_gripper2cam_gt, cv.NORM_L1), 1e-3) + if __name__ == '__main__': NewOpenCVTests.bootstrap()