From 86b9885c90fc6d546d62bd220a813d8943d82b29 Mon Sep 17 00:00:00 2001 From: Skreg <85214856+shyama7004@users.noreply.github.com> Date: Mon, 18 Aug 2025 21:58:08 +0530 Subject: [PATCH] Merge pull request #27651 from shyama7004:minor-fixes Fix empty intrinsics handling and minor calibration improvements #27651 Closes: #27650 Minor changes: * Filter out `""` in `files_with_intrinsics`. * Match the list length with the number of cameras. * Fix unpacking from `cv.calibrateMultiviewExtended`. * Adjust visualisation to ensure correct shapes. * Save `cam_ids` as a list in YAML. * Removed typos.
Pytest ```py import json import numpy as np import importlib.util import sys import pytest spec = importlib.util.spec_from_file_location("mv", "multiview_calibration.py") mv = importlib.util.module_from_spec(spec) sys.modules["mv"] = mv spec.loader.exec_module(mv) def test_insideImageMask_boundaries(): # w = 10, h = 10, valid x,y: [0..9] pts = np.array([[0, 5, 9, -1, 10], [0, 5, 9, 5, 5]], dtype=np.int32) m = mv.insideImageMask(pts, 10, 10) assert m.tolist() == [True, True, True, False, False] def test_intrinsics_empty_ignored_and_forwarded(tmp_path, monkeypatch): cam1 = tmp_path / "cam1.txt" cam2 = tmp_path / "cam2.txt" cam1.write_text("\n") cam2.write_text("\n") opened = [] class FakeNode: def __init__(self, name): self.name = name def mat(self): if self.name in ("camera_matrix", "cameraMatrix"): return np.eye(3, dtype=np.float32) if self.name in ("dist_coeffs", "distortion_coefficients"): return np.zeros((1, 5), dtype=np.float32) return None class FakeFS: def __init__(self, filename, flags=None): opened.append(filename) def getNode(self, key): return FakeNode(key) monkeypatch.setattr(mv.cv, "FileStorage", FakeFS) captured = {} def fake_calibrateFromPoints(pattern_points, image_points, image_sizes, models, image_names, find_intrinsics_in_python, Ks=None, distortions=None): captured["Ks"] = Ks captured["dist"] = distortions return dict( Rs=[np.zeros((3, 1)) for _ in models], Ts=[np.zeros((3, 1)) for _ in models], Ks=Ks, distortions=distortions, rvecs0=[np.zeros((3, 1))], tvecs0=[np.zeros((3, 1))], errors_per_frame=np.ones((len(models), 1), dtype=np.float32), output_pairs=[], image_points=image_points, models=models, image_sizes=image_sizes, pattern_points=pattern_points, detection_mask=np.ones((len(models), 1), np.uint8), image_names=image_names, ) monkeypatch.setattr(mv, "calibrateFromPoints", fake_calibrateFromPoints) out = mv.calibrateFromImages( files_with_images=[str(cam1), str(cam2)], grid_size=[3, 2], pattern_type="checkerboard", models=[0, 0], dist_m=0.1, winsize=(5, 5), points_json_file="", debug_corners=False, RESIZE_IMAGE=False, # <- match real signature name here find_intrinsics_in_python=False, is_parallel_detection=False, cam_ids=["1", "2"], files_with_intrinsics=["", str(tmp_path / "intr.yml")], board_dict_path=None, ) assert "" not in opened assert captured["Ks"] is not None and len(captured["Ks"]) == 2 assert captured["dist"] is not None and len(captured["dist"]) == 2 def test_visualize_converts_rvec_to_R_and_t_to_col(monkeypatch): called = {} def fake_plotCamerasPosition(Rs, Ts, image_sizes, pairs, pattern, frame_idx, cam_ids, detection_mask): called["R_shapes"] = [R.shape for R in Rs] called["T_shapes"] = [T.shape for T in Ts] def fake_plotProjection(points_2d, pattern_points, rvec0, tvec0, rvec1, tvec1, K, dist_coeff, model, *rest): called["tvec1_shape"] = tvec1.shape called["K_shape"] = K.shape monkeypatch.setattr(mv.plt, "savefig", lambda *a, **k: None) monkeypatch.setattr(mv.plt, "close", lambda *a, **k: None) monkeypatch.setattr(mv, "plotCamerasPosition", fake_plotCamerasPosition) monkeypatch.setattr(mv, "plotProjection", fake_plotProjection) detection_mask = np.ones((1, 1), np.uint8) Rs = [np.zeros((3, 1), dtype=np.float32)] # rvec Ts = [np.array([[0., 0., 0.]], dtype=np.float32)] # row 1x3 Ks = [np.eye(3, dtype=np.float32)] distortions = [np.zeros((1, 5), dtype=np.float32)] models = [0] image_points = [[np.array([[10., 10.]], dtype=np.float32)]] errors_per_frame = np.array([[1.0]], dtype=np.float32) rvecs0 = [np.zeros((3, 1), dtype=np.float32)] tvecs0 = [np.zeros((3, 1), dtype=np.float32)] pattern_points = np.array([[0., 0., 0.]], dtype=np.float32) image_sizes = [(100, 100)] output_pairs = [] image_names = None cam_ids = ["0"] mv.visualizeResults( detection_mask, Rs, Ts, Ks, distortions, models, image_points, errors_per_frame, rvecs0, tvecs0, pattern_points, image_sizes, output_pairs, image_names, cam_ids ) assert called["R_shapes"] == [(3, 3)] assert called["T_shapes"] == [(3, 1)] assert called["tvec1_shape"] == (3, 1) assert called["K_shape"] == (3, 3) ```
### Pull Request Readiness Checklist See details at https://github.com/opencv/opencv/wiki/How_to_contribute#making-a-good-pull-request - [x] I agree to contribute to the project under Apache 2 License. - [x] To the best of my knowledge, the proposed patch is not based on a code under GPL or another license that is incompatible with OpenCV - [x] The PR is proposed to the proper branch - [x] There is a reference to the original bug report and related work - [ ] There is accuracy test, performance test and test data in opencv_extra repository, if applicable Patch to opencv_extra has the same branch name. - [ ] The feature is well documented and sample code can be built with the project CMake --- samples/python/multiview_calibration.py | 83 ++++++++++++++++--------- 1 file changed, 54 insertions(+), 29 deletions(-) diff --git a/samples/python/multiview_calibration.py b/samples/python/multiview_calibration.py index 350ffa52c5..b534884705 100644 --- a/samples/python/multiview_calibration.py +++ b/samples/python/multiview_calibration.py @@ -18,9 +18,10 @@ import matplotlib.pyplot as plt import numpy as np import yaml import math +import warnings def insideImageMask(pts, w, h): - return np.logical_and(np.logical_and(pts[0] < w, pts[1] < h), np.logical_and(pts[0] > 0, pts[1] > 0)) + return (pts[0] >= 0) & (pts[0] <= w - 1) & (pts[1] >= 0) & (pts[1] <= h - 1) def read_gt_rig(file, num_cameras, num_frames): Ks_gt = [] @@ -236,7 +237,7 @@ def plotDetection(image_sizes, image_points): # [plot_detection] -def showUndistorted(image_points, Ks, distortions, image_names): +def showUndistorted(image_points, Ks, distortions, image_names, cam_ids): detection_mask = getDetectionMask(image_points) for cam in range(len(image_points)): detected_imgs = np.where(detection_mask[cam])[0] @@ -422,7 +423,7 @@ def calibrateFromPoints( start_time = time.time() # try: # [multiview_calib] - rmse, Rs, Ts, Ks, distortions, rvecs0, tvecs0, errors_per_frame, output_pairs = \ + rmse, Ks, distortions, Rs, Ts, output_pairs, rvecs0, tvecs0, errors_per_frame = \ cv.calibrateMultiviewExtended( objPoints=pattern_points_all, imagePoints=image_points, @@ -472,7 +473,10 @@ def calibrateFromPoints( def visualizeResults(detection_mask, Rs, Ts, Ks, distortions, models, image_points, errors_per_frame, rvecs0, tvecs0, pattern_points, image_sizes, output_pairs, image_names, cam_ids): - rvecs = [cv.Rodrigues(R)[0] for R in Rs] + def _as_rvec(x): + x = np.asarray(x) + return cv.Rodrigues(x)[0] if x.shape == (3, 3) else x.reshape(3, 1) + rvecs = [_as_rvec(R) for R in Rs] errors = errors_per_frame[errors_per_frame > 0] detection_mask_idxs = np.stack(np.where(detection_mask)) # 2 x M, first row is camera idx, second is frame idx @@ -485,7 +489,9 @@ def visualizeResults(detection_mask, Rs, Ts, Ks, distortions, models, R_frame = cv.Rodrigues(rvecs0[frame_idx])[0] pattern_frame = (R_frame @ pattern_points.T + tvecs0[frame_idx]).T - plotCamerasPosition(Rs, Ts, image_sizes, output_pairs, pattern_frame, frame_idx, cam_ids, detection_mask) + R_mats = [cv.Rodrigues(rv)[0] for rv in rvecs] # 3x3 each + T_cols = [np.asarray(t).reshape(3,1) for t in Ts] # 3x1 each + plotCamerasPosition(R_mats, T_cols, image_sizes, output_pairs, pattern_frame, frame_idx, cam_ids, detection_mask) save_file = 'cam_poses.png' print('Saving:', save_file) @@ -500,13 +506,14 @@ def visualizeResults(detection_mask, Rs, Ts, Ks, distortions, models, image = cv.cvtColor(cv.imread(image_names[cam_idx][frame_idx]), cv.COLOR_BGR2RGB) mask = insideImageMask(image_points[cam_idx][frame_idx].T, image_sizes[cam_idx][0], image_sizes[cam_idx][1]) + tvec_cam = np.asarray(Ts[cam_idx]).reshape(3,1) plotProjection( image_points[cam_idx][frame_idx][mask], pattern_points[mask], rvecs0[frame_idx], tvecs0[frame_idx], rvecs[cam_idx], - Ts[cam_idx], + tvec_cam, Ks[cam_idx], distortions[cam_idx], models[cam_idx], @@ -517,7 +524,7 @@ def visualizeResults(detection_mask, Rs, Ts, Ks, distortions, models, ) plot(detection_mask_idxs[0, pos], detection_mask_idxs[1, pos]) - showUndistorted(image_points, Ks, distortions, image_names) + showUndistorted(image_points, Ks, distortions, image_names, cam_ids) # plt.show() plotDetection(image_sizes, image_points) @@ -561,7 +568,7 @@ def saveToFile(path_to_save, **kwargs): if key == 'image_names': save_file.write('image_names', list(np.array(kwargs['image_names']).reshape(-1))) elif key == 'cam_ids': - save_file.write('cam_ids', ','.join(cam_ids)) + save_file.write('cam_ids', list(kwargs['cam_ids'])) elif key == 'distortions': value = kwargs[key] save_file.write('distortions', np.concatenate([x.reshape([-1,]) for x in value],axis=0)) @@ -641,13 +648,13 @@ def compareGT(gt_file, detection_mask, Rs, Ts, Ks, distortions, models, err_dist_max[cam] = np.max(errs_pt) err_dist_median[cam] = np.median(errs_pt) - print("Distrotion error (mean, median):\n", " ".join([f'(%.4f, %.4f)' % (err_dist_mean[i], err_dist_median[i]) for i in range(len(cam_ids))])) + print("Distortion error (mean, median):\n", " ".join([f'(%.4f, %.4f)' % (err_dist_mean[i], err_dist_median[i]) for i in range(len(cam_ids))])) print("Extrinsics error (R, C):\n", " ".join([f'(%.4f, %.4f)' % (err_r[i], err_c[i]) for i in range(len(cam_ids))])) print("Rotation error (mean, median):", f'(%.4f, %.4f)' % (np.mean(err_r), np.median(err_r))) print("Position error (mean, median):", f'(%.4f, %.4f)' % (np.mean(err_c), np.median(err_c))) if len(rvecs0_gt) > 0: - # conver all things with respect to the first frame + # convert all things with respect to the first frame R0 = [] for frame in range(0, len(rvecs0_gt)): if rvecs0[frame] is not None: @@ -895,21 +902,39 @@ def calibrateFromImages(files_with_images, grid_size, pattern_type, models, Ks = None distortions = None if files_with_intrinsics: - # Read camera instrinsic matrices (Ks) and dictortions - Ks, distortions = [], [] - for cam_idx, filename in enumerate(files_with_intrinsics): - print("Reading intrinsics from", filename) - storage = cv.FileStorage(filename, cv.FileStorage_READ) - # tries to read output of interactive-calibration tool (yaml) and camera calibration tutorial (xml) - camera_matrix = storage.getNode('cameraMatrix').mat() - if camera_matrix is None: - camera_matrix = storage.getNode('camera_matrix').mat() - dist_coeffs = storage.getNode('dist_coeffs').mat() - if dist_coeffs is None: - dist_coeffs = storage.getNode('distortion_coefficients').mat() - Ks.append(camera_matrix) - distortions.append(dist_coeffs) - find_intrinsics_in_python = True + # Read camera intrinsic matrices (Ks) and distortions + files_with_intrinsics = [ + f for f in files_with_intrinsics if isinstance(f, str) and f.strip() + ] + + if files_with_intrinsics: + Ks, distortions = [], [] + for filename in files_with_intrinsics: + print("Reading intrinsics from", filename) + storage = cv.FileStorage(filename, cv.FileStorage_READ) + camera_matrix = storage.getNode('cameraMatrix').mat() + if camera_matrix is None: + camera_matrix = storage.getNode('camera_matrix').mat() + dist_coeffs = storage.getNode('dist_coeffs').mat() + if dist_coeffs is None: + dist_coeffs = storage.getNode('distortion_coefficients').mat() + Ks.append(camera_matrix) + distortions.append(dist_coeffs) + + num_cams = len(files_with_images) + if len(Ks) == 1 and num_cams > 1: + warnings.warn(f"single intrinsics/distortion provided, applying same to all {num_cams} cameras.") + Ks = [Ks[0] for _ in range(num_cams)] + distortions = [distortions[0] for _ in range(num_cams)] + elif len(Ks) < num_cams: + warnings.warn(f"only {len(Ks)} intrinsics provided, repeating last to reach {num_cams}.") + Ks += [Ks[-1]] * (num_cams - len(Ks)) + distortions += [distortions[-1]] * (num_cams - len(distortions)) + elif len(Ks) > num_cams: + warnings.warn(f"{len(Ks)} intrinsics provided, truncating to first {num_cams}.") + Ks = Ks[:num_cams] + distortions = distortions[:num_cams] + find_intrinsics_in_python = True return calibrateFromPoints( pattern, @@ -936,7 +961,7 @@ def calibrateFromJSON(json_file, find_intrinsics_in_python): images_names = data['images_names'] if 'images_names' in data else None return calibrateFromPoints( - np.array(data['object_points'], dtype=np.float32).T, + np.array(data['object_points'], dtype=np.float32), data['image_points'], data['image_sizes'], data['models'], @@ -950,7 +975,7 @@ def calibrateFromJSON(json_file, find_intrinsics_in_python): if __name__ == '__main__': parser = argparse.ArgumentParser() parser.add_argument('--json_file', type=str, default=None, help="json file with all data. Must have keys: 'object_points', 'image_points', 'image_sizes', 'is_fisheye'") - parser.add_argument('--filenames', type=str, default=None, help='Txt files containg image lists, e.g., cam_1.txt,cam_2.txt,...,cam_N.txt for N cameras') + parser.add_argument('--filenames', type=str, default=None, help='Txt files containing image lists, e.g., cam_1.txt,cam_2.txt,...,cam_N.txt for N cameras') parser.add_argument('--pattern_size', type=str, default=None, help='pattern size: width,height') parser.add_argument('--pattern_type', type=str, default=None, help='supported: checkerboard, circles, acircles, charuco') parser.add_argument('--is_fisheye', type=str, default=None, help='is_ mask, e.g., 0,1,...') @@ -963,7 +988,7 @@ if __name__ == '__main__': parser.add_argument('--path_to_visualize', type=str, default='', help='path to results pickle file needed to run visualization') parser.add_argument('--visualize', required=False, action='store_true', help='visualization flag. If set, only runs visualization but path_to_visualize must be provided') parser.add_argument('--resize_image_detection', required=False, action='store_true', help='If set, an image will be resized to speed-up corners detection') - parser.add_argument('--intrinsics', type=str, default='', help='YAML or XML files with each camera intrinsics') + parser.add_argument('--intrinsics', type=str, default=None, help='YAML or XML files with each camera intrinsics') parser.add_argument('--gt_file', type=str, default=None, help="ground truth") parser.add_argument('--board_dict_path', type=str, default=None, help="path to parameters of board dictionary") @@ -1006,7 +1031,7 @@ if __name__ == '__main__': RESIZE_IMAGE=params.resize_image_detection, find_intrinsics_in_python=params.find_intrinsics_in_python, cam_ids=cam_ids, - files_with_intrinsics=[x.strip() for x in params.intrinsics.split(',')], + files_with_intrinsics=[x.strip() for x in (params.intrinsics or "").split(',') if x.strip()], board_dict_path=params.board_dict_path, ) output['cam_ids'] = cam_ids