749 lines
26 KiB
Python
749 lines
26 KiB
Python
|
"""
|
||
|
A set of functions and scripts to demonstrate camera calibration and
|
||
|
registration.
|
||
|
|
||
|
Notes:
|
||
|
* Parts of this module uses opencv calibration described here:
|
||
|
https://docs.opencv.org/4.0.0/d9/d0c/group__calib3d.html
|
||
|
* and the Chruco board described here:
|
||
|
https://docs.opencv.org/4.0.0/d0/d3c/classcv_1_1aruco_1_1CharucoBoard.html
|
||
|
|
||
|
Copyright (C) Microsoft Corporation. All rights reserved.
|
||
|
"""
|
||
|
|
||
|
# Standard imports.
|
||
|
import os
|
||
|
import fnmatch
|
||
|
import json
|
||
|
import math
|
||
|
import time
|
||
|
import warnings
|
||
|
|
||
|
from dataclasses import dataclass
|
||
|
from typing import Dict
|
||
|
from typing import List
|
||
|
from typing import Tuple
|
||
|
|
||
|
# 3rd party imports.
|
||
|
import cv2
|
||
|
import cv2.aruco as aruco
|
||
|
from cv2 import aruco_CharucoBoard
|
||
|
from cv2 import aruco_DetectorParameters as detect_params
|
||
|
import numpy as np
|
||
|
|
||
|
#-------------------------------------------------------------------------------
|
||
|
class CalibrationError(Exception):
|
||
|
"""Error during calibration.
|
||
|
"""
|
||
|
|
||
|
class RegistrationError(Exception):
|
||
|
"""Error during registration.
|
||
|
"""
|
||
|
|
||
|
#-------------------------------------------------------------------------------
|
||
|
@dataclass
|
||
|
class RTMatrix():
|
||
|
"""dataclass for containing rotation and translation between coordinates"""
|
||
|
|
||
|
rotation:List
|
||
|
translation:List
|
||
|
|
||
|
|
||
|
#-------------------------------------------------------------------------------
|
||
|
def write_json(json_file:str, data:dict)-> None:
|
||
|
"""Helper function for writing out json files.
|
||
|
|
||
|
Args:
|
||
|
json_file (str): full path
|
||
|
data (dict): Blob of data to write.
|
||
|
"""
|
||
|
with open(json_file, "w") as j:
|
||
|
json.dump(data, j, indent=4)
|
||
|
|
||
|
#-------------------------------------------------------------------------------
|
||
|
def r_as_matrix(rotation:np.array):
|
||
|
"""Convert a 3vec rotation array to a rotation matrix.
|
||
|
|
||
|
Args:
|
||
|
rotation (np.array): 3 vector array representing rotation.
|
||
|
|
||
|
Returns:
|
||
|
[np.array]: Rotation matrix.
|
||
|
"""
|
||
|
rmat = np.zeros(shape=(3,3))
|
||
|
cv2.Rodrigues(rotation, rmat)
|
||
|
return rmat
|
||
|
|
||
|
#-------------------------------------------------------------------------------
|
||
|
def read_opencv_calfile(calfile:str) -> Tuple[np.ndarray,
|
||
|
np.ndarray,
|
||
|
np.ndarray]:
|
||
|
"""Read a calibration file generated by opencv.
|
||
|
|
||
|
Args:
|
||
|
calfile (str): full path to an opencv calibration yaml file.
|
||
|
|
||
|
Raises:
|
||
|
ValueError: Function will explicitly fail when the cv2 calibration file
|
||
|
fails to open.
|
||
|
|
||
|
Returns:
|
||
|
Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||
|
k_matrix: The camera intrinsics matrix.
|
||
|
dist: the distortion matrix.
|
||
|
img_size: Image size as ndarray objects, in the order width, height.
|
||
|
"""
|
||
|
fs_calib = cv2.FileStorage(calfile, cv2.FILE_STORAGE_READ)
|
||
|
if not fs_calib.isOpened():
|
||
|
raise ValueError(f"failed to open {fs_calib}")
|
||
|
|
||
|
k_matrix = fs_calib.getNode("K").mat()
|
||
|
dist = fs_calib.getNode("dist").mat()
|
||
|
img_size = fs_calib.getNode("img_size").mat()
|
||
|
fs_calib.release()
|
||
|
return k_matrix, dist, img_size
|
||
|
|
||
|
#-------------------------------------------------------------------------------
|
||
|
def write_opencv_calfile(calfile:str,
|
||
|
k_matrix:np.array,
|
||
|
dist8:List,
|
||
|
img_size:np.array) -> None:
|
||
|
"""Write out an opencv calibration file for a single camera.
|
||
|
|
||
|
Args:
|
||
|
calfile (str): Full path of calibration file.
|
||
|
k_matrix (np.array): the camera intrinsics matrix.
|
||
|
dist8 (List): The distortion coefficients.
|
||
|
img_size (np.array): 2d array of the image size, in the order width, height.
|
||
|
"""
|
||
|
fs_calib = cv2.FileStorage(calfile, cv2.FILE_STORAGE_WRITE)
|
||
|
fs_calib.write("K", k_matrix)
|
||
|
fs_calib.write("dist", dist8)
|
||
|
fs_calib.write("img_size", img_size)
|
||
|
fs_calib.release()
|
||
|
|
||
|
#-------------------------------------------------------------------------------
|
||
|
def write_calibration_blob(calibrations:List[str],
|
||
|
rmat_b_to_a:np.array,
|
||
|
tvec_b_to_a:np.array,
|
||
|
out_dir:str):
|
||
|
"""Write all calibration and registration values to a json blob.
|
||
|
|
||
|
Args:
|
||
|
calibrations (List[str]): All calibration files.
|
||
|
rmat_b_to_a (np.array): Rotation matrix array.
|
||
|
tvec_b_to_a (np.array): Translation vector array.
|
||
|
out_dir (str): Ouput directory to place the calibration_blob.json file.
|
||
|
"""
|
||
|
|
||
|
blob = {"CalibrationInformation":{"Cameras":[]}}
|
||
|
|
||
|
for idx, calibration_file in enumerate(calibrations):
|
||
|
if idx == 0:
|
||
|
# RT for the camera used as the origin for all others.
|
||
|
reshape_r = [1,0,0,0,1,0,0,0,1]
|
||
|
reshape_t = [0,0,0]
|
||
|
else:
|
||
|
reshape_r = rmat_b_to_a.reshape(9, 1).squeeze(1).tolist()
|
||
|
reshape_t = tvec_b_to_a.squeeze(1).tolist()
|
||
|
|
||
|
camera_matrix, dist, img_size = read_opencv_calfile(calibration_file)
|
||
|
intrinsics = [camera_matrix[0][2]/img_size[0][0], #Px
|
||
|
camera_matrix[1][2]/img_size[1][0], #Py
|
||
|
camera_matrix[0][0]/img_size[0][0], #Fx
|
||
|
camera_matrix[1][1]/img_size[1][0], #Fy
|
||
|
dist[0][0], #K1
|
||
|
dist[1][0], #K2
|
||
|
dist[4][0], #K3
|
||
|
dist[5][0], #K4
|
||
|
dist[6][0], #K5
|
||
|
dist[7][0], #K6
|
||
|
0, #Cx always Zero. (BrownConrady)
|
||
|
0, #Cy always Zero. (BrownConrady)
|
||
|
dist[3][0], #P2/Tx
|
||
|
dist[2][0]] #P1/Ty
|
||
|
|
||
|
model_type = "CALIBRATION_LensDistortionModelBrownConrady"
|
||
|
intrinsics_data = {"ModelParameterCount":len(intrinsics),
|
||
|
"ModelParameters":intrinsics,
|
||
|
"ModelType":model_type}
|
||
|
|
||
|
extrinsics = {"Rotation":reshape_r, "Translation":reshape_t}
|
||
|
calibration = {"Intrinsics":intrinsics_data,
|
||
|
"Rt":extrinsics,
|
||
|
"SensorHeight":img_size[1].tolist(),
|
||
|
"SensorWidth":img_size[0].tolist()}
|
||
|
|
||
|
blob["CalibrationInformation"]["Cameras"].append(calibration)
|
||
|
|
||
|
os.makedirs(out_dir, exist_ok=True)
|
||
|
json_file = os.path.join(out_dir, "calibration_blob.json")
|
||
|
write_json(json_file, blob)
|
||
|
|
||
|
#-------------------------------------------------------------------------------
|
||
|
def read_board_parameters(json_file: str) -> Tuple[Dict, aruco_CharucoBoard]:
|
||
|
"""Read charuco board from a json file.
|
||
|
|
||
|
Args:
|
||
|
json_file (str): fullpath of the board json_file.
|
||
|
|
||
|
Returns:
|
||
|
Tuple[dict, aruco_CharucoBoard]:
|
||
|
target: Target data from json_file.
|
||
|
board: A single charuco board object.
|
||
|
"""
|
||
|
|
||
|
with open(json_file) as j_file:
|
||
|
targets = json.load(j_file)
|
||
|
|
||
|
target = targets["shapes"][0]
|
||
|
aruco_dict = aruco.Dictionary_get(target["aruco_dict_name"])
|
||
|
board = aruco.CharucoBoard_create(target["squares_x"],
|
||
|
target["squares_y"],
|
||
|
target["square_length"]/1000,
|
||
|
target["marker_length"]/1000,
|
||
|
aruco_dict)
|
||
|
|
||
|
return target, board
|
||
|
|
||
|
#-------------------------------------------------------------------------------
|
||
|
def get_image_points(board:aruco_CharucoBoard,
|
||
|
marker_ids:np.ndarray) -> np.ndarray:
|
||
|
"""
|
||
|
Generate markers 3d and 2d positions like getBoardObjectAndImagePoints but for
|
||
|
Charuco Parameters.
|
||
|
|
||
|
Args:
|
||
|
board (aruco_CharucoBoard): A board object from opencv.
|
||
|
marker_ids (np.ndarray): List of detected charuco marker Ids.
|
||
|
|
||
|
Returns:
|
||
|
np.ndarray: numpy array (n*1*3) markers 3d positions.
|
||
|
"""
|
||
|
|
||
|
object_points = board.chessboardCorners[marker_ids, :]
|
||
|
return object_points
|
||
|
|
||
|
#-------------------------------------------------------------------------------
|
||
|
def detect_markers(img: np.ndarray,
|
||
|
template: str,
|
||
|
params:detect_params = None) -> Tuple[List[np.ndarray],
|
||
|
List[np.ndarray],
|
||
|
aruco_CharucoBoard]:
|
||
|
"""Detect board markers.
|
||
|
|
||
|
Args:
|
||
|
img (np.ndarray): Board image.
|
||
|
template (str): fullpath of the board json_file.
|
||
|
params (aruco_DetectorParameters, optional): a cv2 object
|
||
|
aruco_DetectorParameters. Defaults to None.
|
||
|
|
||
|
Returns:
|
||
|
Tuple[List[np.ndarray], List[np.ndarray], aruco_CharucoBoard]:
|
||
|
charuco_corners: List of detected charuco marker corners.
|
||
|
charuco_ids: List of detected charuco marker Ids.
|
||
|
board: charucoboard object.
|
||
|
"""
|
||
|
# detect markers
|
||
|
_, board = read_board_parameters(template)
|
||
|
|
||
|
if params is None:
|
||
|
params = aruco.DetectorParameters_create()
|
||
|
params.cornerRefinementMethod = aruco.CORNER_REFINE_NONE
|
||
|
|
||
|
aruco_corners, aruco_ids, _ = aruco.detectMarkers(img,
|
||
|
board.dictionary,
|
||
|
None,
|
||
|
None,
|
||
|
params)
|
||
|
if len(aruco_corners) > 0:
|
||
|
_, charuco_corners, charuco_ids = aruco.interpolateCornersCharuco(
|
||
|
aruco_corners,
|
||
|
aruco_ids,
|
||
|
img,
|
||
|
board)
|
||
|
if charuco_corners is None:
|
||
|
charuco_corners = []
|
||
|
charuco_ids = []
|
||
|
warnings.warn("No charuco corners detected in image.")
|
||
|
else:
|
||
|
charuco_corners = []
|
||
|
charuco_ids = []
|
||
|
warnings.warn("No charuco corners detected in image.")
|
||
|
|
||
|
return charuco_corners, charuco_ids, board
|
||
|
|
||
|
#-------------------------------------------------------------------------------
|
||
|
def detect_markers_many_images(imgnames:List[str], template: str):
|
||
|
"""
|
||
|
Run detect_markers on a large set of png or jpeg images in a single directory,
|
||
|
with the assumption that all images are viewing the same board.
|
||
|
|
||
|
Args:
|
||
|
imgnames (List[str]):Full path to images.
|
||
|
template (str): Template file json of the board.
|
||
|
|
||
|
Raises:
|
||
|
CalibrationError: Not all image sizes are equal.
|
||
|
CalibrationError: Insufficient number of markers detected. Inspect images
|
||
|
for poor quality.
|
||
|
|
||
|
Returns:
|
||
|
[Tuple]: [List[List[np.ndarray]],
|
||
|
List[List[np.ndarray]],
|
||
|
List[List[np.ndarray]],
|
||
|
Tuple[np.array, np.array],
|
||
|
aruco_CharucoBoard]
|
||
|
ccorners_all: All chauco corners detected in every image.
|
||
|
cids_all: All charuco marker ids detected in every image.
|
||
|
p3d: Image points.
|
||
|
img_size: [width; height].
|
||
|
board: Charucoboard object.
|
||
|
"""
|
||
|
ccorners_all = []
|
||
|
cids_all = []
|
||
|
p3d = []
|
||
|
img_sizes_all = []
|
||
|
|
||
|
for imgfile in imgnames:
|
||
|
img = cv2.imread(imgfile, cv2.IMREAD_GRAYSCALE)
|
||
|
if img is not None:
|
||
|
ccorners, cids, board = detect_markers(img, template)
|
||
|
|
||
|
if len(ccorners) > 3:
|
||
|
ccorners_all.append(ccorners)
|
||
|
cids_all.append(cids)
|
||
|
m3d = get_image_points(board, cids)
|
||
|
p3d.append(m3d)
|
||
|
sizes = np.array([img.shape[1], img.shape[0]])
|
||
|
img_sizes_all.append(sizes)
|
||
|
|
||
|
# check all images sizes are identical.
|
||
|
rows_equal = [elem[0]==img_sizes_all[0][0] for elem in img_sizes_all]
|
||
|
cols_equal = [elem[1]==img_sizes_all[0][1] for elem in img_sizes_all]
|
||
|
|
||
|
if not all(rows_equal) or not all(cols_equal):
|
||
|
raise CalibrationError("Not all image sizes in data set are the same.")
|
||
|
|
||
|
img_size = (img_sizes_all[0][0], img_sizes_all[0][1])
|
||
|
return ccorners_all, cids_all, p3d, img_size, board
|
||
|
|
||
|
#-------------------------------------------------------------------------------
|
||
|
def estimate_pose(img: np.array,
|
||
|
template: str,
|
||
|
opencv_calfile: str) -> Tuple[bool,
|
||
|
np.ndarray,
|
||
|
np.ndarray]:
|
||
|
"""Estimate camera pose using board.
|
||
|
|
||
|
Args:
|
||
|
img (np.array): Board image.
|
||
|
template (str): fullpath of the board json_file.
|
||
|
opencv_calfile (str): fullpath of the opencv cal file.
|
||
|
|
||
|
Raises:
|
||
|
ValueError: Throw an error if the calibration file fails to load.
|
||
|
|
||
|
Returns:
|
||
|
Tuple[bool, np.ndarray, np.ndarray]: Returns success of calibration and
|
||
|
extrinsics.
|
||
|
retval: Return True of the optimizer converged.
|
||
|
rvec: rotation array
|
||
|
tvec: translation array 1*3
|
||
|
"""
|
||
|
|
||
|
k_matrix, dist, _ = read_opencv_calfile(opencv_calfile)
|
||
|
|
||
|
rvec = np.full((1, 3), 0.01)
|
||
|
tvec = np.full((1, 3), 0.01)
|
||
|
|
||
|
charuco_corners, charuco_ids, board = detect_markers(img, template)
|
||
|
if len(charuco_corners) > 0:
|
||
|
retval, rvec, tvec = aruco.estimatePoseCharucoBoard(charuco_corners,
|
||
|
charuco_ids,
|
||
|
board,
|
||
|
k_matrix,
|
||
|
dist,
|
||
|
rvec,
|
||
|
tvec)
|
||
|
else:
|
||
|
retval = False
|
||
|
rvec = []
|
||
|
tvec = []
|
||
|
return retval, rvec, tvec
|
||
|
|
||
|
#-------------------------------------------------------------------------------
|
||
|
def pose_as_dataclass(array_a:np.array,
|
||
|
template:str,
|
||
|
calib_a:str,
|
||
|
img_a:str)-> RTMatrix:
|
||
|
"""Get RT of camera to board.
|
||
|
|
||
|
Args:
|
||
|
array_a (np.array): Numpy array of the image.
|
||
|
template (str): Template to estimate pose.
|
||
|
calib_a (str): calibration file for this camera.
|
||
|
img_a (str): Path to image.
|
||
|
|
||
|
Raises:
|
||
|
RegistrationError: Failed to estimate pose of board.
|
||
|
|
||
|
Returns:
|
||
|
RTMatrix: Rotation and translation as a dataclass.
|
||
|
"""
|
||
|
|
||
|
[retval_a, rvec_a, tvec_a] = estimate_pose(array_a, template, calib_a)
|
||
|
if retval_a is False:
|
||
|
raise RegistrationError(f"Could not estimate pose for image @ {img_a}")
|
||
|
|
||
|
pose_a = RTMatrix(rvec_a, tvec_a)
|
||
|
return pose_a
|
||
|
|
||
|
#-------------------------------------------------------------------------------
|
||
|
def unproject(points:np.array, k_mat:np.array, dist:np.array) -> np.array:
|
||
|
"""
|
||
|
Take the 2D distorted markers in an image and unproject into normalized 3D
|
||
|
coordinates.
|
||
|
|
||
|
Args:
|
||
|
points (np.array): Location of markers in image.
|
||
|
k_mat (np.array): Camera Matrix.
|
||
|
dist (np.array): Distortion coefficients.
|
||
|
|
||
|
Returns:
|
||
|
np.array: 3D Normalized coordinates of markers after unprojection.
|
||
|
"""
|
||
|
|
||
|
principal_point = [k_mat[0][2], k_mat[1][2]]
|
||
|
focal_length = [k_mat[0][0], k_mat[1][1]]
|
||
|
|
||
|
term_criteria = (cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT,
|
||
|
400,
|
||
|
0.000001)
|
||
|
x_u = cv2.undistortPointsIter(points,
|
||
|
k_mat,
|
||
|
dist,
|
||
|
np.eye(3),
|
||
|
k_mat,
|
||
|
term_criteria)
|
||
|
|
||
|
rays = (x_u-principal_point)/focal_length
|
||
|
return rays
|
||
|
|
||
|
#-------------------------------------------------------------------------------
|
||
|
def registration_error(array_a:np.array,
|
||
|
template:str,
|
||
|
calib_a:str,
|
||
|
pose_b:RTMatrix,
|
||
|
rmat_b_to_a:np.array,
|
||
|
tvec_b_to_a:np.array) -> Tuple[float, float]:
|
||
|
"""
|
||
|
Calculate the registration error between two cameras. The registration error
|
||
|
is computed by taking the rotation and translation of the board to camera B
|
||
|
coordinates, applying the registration, then projecting into 2D camera A
|
||
|
coordinates, then calculating this against the reprojection error of the
|
||
|
detected markers from camera A.
|
||
|
|
||
|
camera B camera A (prj)
|
||
|
------------ ------------
|
||
|
| 3D points| -----> | 3D points|
|
||
|
------------ ------------
|
||
|
^ |
|
||
|
| |
|
||
|
| v camera A (detected)
|
||
|
------------ ------------ diff with ------------
|
||
|
| 3D board | | 2D points| <---------> | 2D points|
|
||
|
------------ ------------ ------------
|
||
|
|
||
|
Args:
|
||
|
array_a (np.array): Image from camera A.
|
||
|
template (str): Template of the board.
|
||
|
calib_a (str): Path to camera calibration file for camera A.
|
||
|
pose_b (RTMatrix): Rotation and translation of board to camera B.
|
||
|
rmat_b_to_a (np.array): Rotation matrix of camera B to camera A.
|
||
|
tvec_b_to_a (np.array): Translation vector of camera B to camera A.
|
||
|
|
||
|
Returns:
|
||
|
Tuple[float, float]: Root mean square of all reprojected points in pixels
|
||
|
and radians.
|
||
|
"""
|
||
|
|
||
|
# Get 2d image coordinates of markers detected in camera A.
|
||
|
corners_a, ids_a, board = detect_markers(array_a, template)
|
||
|
|
||
|
# 3D board points in coordinates of camera B.
|
||
|
points_board = board.chessboardCorners[ids_a, :]
|
||
|
squoze = points_board.squeeze(1)
|
||
|
markers_cam_b = np.matmul(r_as_matrix(pose_b.rotation),
|
||
|
squoze.transpose()) + pose_b.translation
|
||
|
|
||
|
# Multiply by registration to get markers 3D coordinates in camera A.
|
||
|
pts_in_cam_a = np.matmul(rmat_b_to_a, markers_cam_b) + tvec_b_to_a
|
||
|
|
||
|
# Registration computed 3D points to 2D image plane A.
|
||
|
k_mat, dist, _ = read_opencv_calfile(calib_a)
|
||
|
pts, _ = cv2.projectPoints(pts_in_cam_a,
|
||
|
np.eye(3),
|
||
|
np.zeros((3,1)),
|
||
|
k_mat,
|
||
|
dist)
|
||
|
|
||
|
# Express difference between measured and projected as radians.
|
||
|
angles = []
|
||
|
measured = unproject(corners_a, k_mat, dist)
|
||
|
prediction = unproject(pts, k_mat, dist)
|
||
|
|
||
|
for idx, elem in enumerate(measured):
|
||
|
meas = [elem[0][0], elem[0][1], 1]
|
||
|
pred = [prediction[idx][0][0], prediction[idx][0][1], 1]
|
||
|
|
||
|
dot_product = np.dot(meas, pred)
|
||
|
norm_a = np.linalg.norm(meas)
|
||
|
norm_b = np.linalg.norm(pred)
|
||
|
theta = math.acos(dot_product / (norm_a * norm_b))
|
||
|
angles.append(theta)
|
||
|
|
||
|
# Get Root Mean Square of measured points in camera A to registration
|
||
|
# calculated points.
|
||
|
num_points = pts.shape[0]
|
||
|
squares = [elem**2 for elem in angles]
|
||
|
rms_radians = math.sqrt(sum(squares)/num_points)
|
||
|
print(f"RMS (Radians): {rms_radians}")
|
||
|
|
||
|
rms_pixels = np.sqrt(np.sum((corners_a-pts)**2)/num_points)
|
||
|
return rms_pixels, rms_radians
|
||
|
|
||
|
#-------------------------------------------------------------------------------
|
||
|
def calibrate_camera(
|
||
|
imdir:str,
|
||
|
template:str,
|
||
|
init_calfile:str = None,
|
||
|
rms_thr:float = 1.0,
|
||
|
postfix:str = "",
|
||
|
min_detections:int = 30,
|
||
|
min_images:int = 30,
|
||
|
min_quality_images:int = 30,
|
||
|
per_view_threshold:int = 1
|
||
|
) -> Tuple[float,
|
||
|
np.array,
|
||
|
np.array,
|
||
|
np.array,
|
||
|
List,
|
||
|
List]:
|
||
|
""" Calibrate a camera using charuco detector and opencv bundler.
|
||
|
|
||
|
Args:
|
||
|
imdir (str): Image directory.
|
||
|
template (str): Fullpath of the board json_file.
|
||
|
init_calfile (str, optional): Calibration file. Defaults to None.
|
||
|
rms_thr (float, optional): Reprojection threshold. Defaults to 1.0.
|
||
|
postfix (str, optional): Calibration filename suffix. Defaults to "".
|
||
|
min_detections (int, optional): Required minimum number of detections.
|
||
|
Defaults to 30.
|
||
|
min_images (int, optional): Minimum number of images. Defaults to 30.
|
||
|
min_quality_images (int, optional): Minimum number of images with sufficient
|
||
|
reprojection quality. Defaults to 30.
|
||
|
per_view_threshold (int, optional): RMS reprojection error threshold to
|
||
|
distinguish quality images. Defaults to 1.
|
||
|
|
||
|
Raises:
|
||
|
CalibrationError: Not enough images for calibration.
|
||
|
CalibrationError: Not enough detections for calibration.
|
||
|
CalibrationError: Not enough images with low rms for calibration.
|
||
|
|
||
|
Returns:
|
||
|
Tuple[float, np.array, np.array, np.array, List, List]:
|
||
|
rms: the overall RMS re-projection error in pixels.
|
||
|
k_matrix: camera matrix.
|
||
|
dist: Lens distortion coeffs. OpenCV model with 8 distortion is equivalent
|
||
|
to Brown-Conrady model. (used in K4A).
|
||
|
img_size: [width; height].
|
||
|
rvecs: list of rotation vectors.
|
||
|
tvecs: list of translation vectors.
|
||
|
"""
|
||
|
|
||
|
# Check validity of initial calibration file.
|
||
|
if init_calfile is not None and os.path.exists(init_calfile) is False:
|
||
|
FileExistsError(f"Initial calibration does not exist: {init_calfile}")
|
||
|
|
||
|
# output cal file
|
||
|
calfile = os.path.join(imdir, f"calib{postfix}.yml")
|
||
|
|
||
|
imgnames = []
|
||
|
for file in os.listdir(imdir):
|
||
|
if fnmatch.fnmatch(file, "*.png") or fnmatch.fnmatch(file, "*.jpg"):
|
||
|
imgnames.append(os.path.join(imdir, file))
|
||
|
|
||
|
num_images = len(imgnames)
|
||
|
if num_images < min_images:
|
||
|
msg = f"Not Enough images. {num_images} found, {min_images} required."
|
||
|
raise CalibrationError(msg)
|
||
|
|
||
|
ccorners_all, cids_all, p3d, img_size, board = \
|
||
|
detect_markers_many_images(imgnames, template)
|
||
|
|
||
|
# check number of times corners were successfully detected.
|
||
|
num_det = len(ccorners_all)
|
||
|
if num_det < min_detections:
|
||
|
msg = f"Insufficent detections. {num_det} found, {min_detections} required."
|
||
|
raise CalibrationError(msg)
|
||
|
|
||
|
# initial calibration
|
||
|
if init_calfile is None:
|
||
|
# get image size of any image
|
||
|
k_matrix = cv2.initCameraMatrix2D(p3d, ccorners_all, img_size) # (w,h)
|
||
|
dist = np.zeros((8, 1), dtype=np.float32)
|
||
|
else:
|
||
|
k_matrix, dist, img_data = read_opencv_calfile(init_calfile)
|
||
|
img_arr = img_data.astype(int)
|
||
|
img_size = (img_arr[0][0], img_arr[1][0])
|
||
|
|
||
|
flags = cv2.CALIB_RATIONAL_MODEL + cv2.CALIB_USE_INTRINSIC_GUESS
|
||
|
criteria = cv2.TERM_CRITERIA_COUNT + cv2.TERM_CRITERIA_EPS, 100, 1e-6
|
||
|
|
||
|
start = time.perf_counter()
|
||
|
rms, k_matrix, dist, rvecs, tvecs, stdDeviationsIntrinsics, stdDeviationsExtrinsics, perViewErrors = \
|
||
|
aruco.calibrateCameraCharucoExtended(ccorners_all,
|
||
|
cids_all,
|
||
|
board,
|
||
|
img_size,
|
||
|
k_matrix,
|
||
|
dist,
|
||
|
flags=flags,
|
||
|
criteria=criteria)
|
||
|
|
||
|
# Check Quality of each image.
|
||
|
n_low_rms = [err[0] for err in perViewErrors if err[0] <= per_view_threshold]
|
||
|
num_good_images = len(n_low_rms)
|
||
|
|
||
|
# Report which indexes are failing in perViewErrors.
|
||
|
failing_idxs = []
|
||
|
[failing_idxs.append(str(index)) for (index, err) in enumerate(perViewErrors) if err[0] > per_view_threshold]
|
||
|
warning_failing_indexes = "Failing image indices: " + ", ".join(failing_idxs)
|
||
|
|
||
|
if len(failing_idxs) != 0:
|
||
|
warnings.warn(warning_failing_indexes)
|
||
|
|
||
|
if num_good_images < min_quality_images:
|
||
|
msg = f"Insufficent number of quality images. " \
|
||
|
f"{num_good_images} found, {min_quality_images} required."
|
||
|
raise CalibrationError(msg)
|
||
|
|
||
|
dist8 = dist[:8, :]
|
||
|
img_size_as_array = np.array([np.array([img_size[0]]),
|
||
|
np.array([img_size[1]])])
|
||
|
if rms < rms_thr:
|
||
|
print("calibrate_camera took {} sec".format(time.perf_counter()-start))
|
||
|
write_opencv_calfile(calfile, k_matrix, dist8, img_size_as_array)
|
||
|
write_json(os.path.join(imdir, "report.json"), {"RMS_pixels":rms})
|
||
|
else:
|
||
|
print("calibrate_camera failed \n")
|
||
|
|
||
|
return rms, k_matrix, dist, img_size, rvecs, tvecs, num_good_images
|
||
|
|
||
|
#-------------------------------------------------------------------------------
|
||
|
def register(img_a: str,
|
||
|
img_b: str,
|
||
|
template: str,
|
||
|
calib_a: str,
|
||
|
calib_b: str,
|
||
|
out_dir: str,
|
||
|
rms_threshold:float=1) -> Tuple[np.array, np.array, float]:
|
||
|
"""Get rotation and translation of camera b in terms of camera a.
|
||
|
|
||
|
Args:
|
||
|
img_a (str): Full path to image taken by camera A.
|
||
|
img_b (str): Full path to image taken by camera B.
|
||
|
template (str): Full path to template image of board.
|
||
|
calib_a (str): Full path to opencv calibration file of camera A.
|
||
|
calib_b (str): Full path to opencv calibration file of camera B.
|
||
|
out_dir (str): Output directory for full calibration blob.
|
||
|
rms_threshold (float): Threshold to fail RMS at in radians.
|
||
|
|
||
|
Raises:
|
||
|
FileExistsError: Raise if image file A is not found.
|
||
|
FileExistsError: Raise if image file B is not found.
|
||
|
FileExistsError: Raise if template file is not found.
|
||
|
FileExistsError: Raise if calibration parameters for camera A is not found.
|
||
|
FileExistsError: Raise if calibration parameters for camera B is not found.
|
||
|
RegistrationError: Raise if OpenCV fails to load image file A.
|
||
|
RegistrationError: Raise if OpenCV fails to load image file B.
|
||
|
RegistrationError: Raise if reprojection error is too large.
|
||
|
|
||
|
Returns:
|
||
|
Tuple[np.array, np.array, float]: Return Rotation Translation of camera B
|
||
|
to A, and rms.
|
||
|
rmat_b_to_a: Numpy array of the rotation matrix from B to A.
|
||
|
tmat_b_to_a: Numpy array of the translation vector from B to A.
|
||
|
rms: Reprojection error expressed as Root mean square of pixel diffs
|
||
|
between markers.
|
||
|
"""
|
||
|
|
||
|
# File exists checks.
|
||
|
if not os.path.exists(img_a):
|
||
|
raise FileExistsError(f"Image file not found for camera A @ {img_a}")
|
||
|
if not os.path.exists(img_b):
|
||
|
raise FileExistsError(f"Image file not found for camera B @ {img_b}")
|
||
|
if not os.path.exists(template):
|
||
|
raise FileExistsError(f"Board template parameters not found @ {template}")
|
||
|
if not os.path.exists(calib_a):
|
||
|
raise FileExistsError(f"Calib params for camera A not found @ {calib_a}")
|
||
|
if not os.path.exists(calib_b):
|
||
|
raise FileExistsError(f"Calib params for camera B not found @ {calib_b}")
|
||
|
|
||
|
array_a = cv2.imread(img_a)
|
||
|
array_b = cv2.imread(img_b)
|
||
|
|
||
|
# Check image was read by opencv.
|
||
|
if array_a is None:
|
||
|
raise RegistrationError(f"OpenCV could not interpret Camera A @ {img_a}")
|
||
|
if array_b is None:
|
||
|
raise RegistrationError(f"OpenCV could not interpret Camera B @ {img_b}")
|
||
|
|
||
|
# Get Rt of camera A to board.
|
||
|
pose_a = pose_as_dataclass(array_a, template, calib_a, img_a)
|
||
|
rmat_a = r_as_matrix(pose_a.rotation)
|
||
|
|
||
|
# Get Rt of camera B to board.
|
||
|
pose_b = pose_as_dataclass(array_b, template, calib_b, img_b)
|
||
|
rmat_b = r_as_matrix(pose_b.rotation)
|
||
|
|
||
|
# Get perspective of camera B to board.
|
||
|
rmat_b_to_a = np.matmul(rmat_a, rmat_b.transpose())
|
||
|
tvec_b_to_a = -np.matmul(rmat_b_to_a, pose_b.translation) + pose_a.translation
|
||
|
|
||
|
print(f"Translation camera B to A:\n{tvec_b_to_a}")
|
||
|
print(f"Rotation camera B to A:\n{rmat_b_to_a}")
|
||
|
|
||
|
# Find registration error.
|
||
|
print("Find forward reprojection error (camera B to camera A).")
|
||
|
(rms1_pixels, rms1_rad) = registration_error(array_a,
|
||
|
template,
|
||
|
calib_a,
|
||
|
pose_b,
|
||
|
rmat_b_to_a,
|
||
|
tvec_b_to_a)
|
||
|
if rms1_rad > rms_threshold:
|
||
|
raise RegistrationError("Registration error from A to B too large.")
|
||
|
|
||
|
# Find reverse registration error.
|
||
|
print("Find reverse reprojection error (camera A to camera B).")
|
||
|
(rms2_pixels, rms2_rad) = registration_error(array_b,
|
||
|
template,
|
||
|
calib_b,
|
||
|
pose_a,
|
||
|
rmat_b_to_a.transpose(),
|
||
|
tvec_b_to_a*-1)
|
||
|
if rms2_rad > rms_threshold:
|
||
|
raise RegistrationError("Registration error from B to A too large.")
|
||
|
|
||
|
# Write to calibration blob.
|
||
|
write_calibration_blob([calib_a, calib_b], rmat_b_to_a, tvec_b_to_a, out_dir)
|
||
|
rms_report = {"RMS_B_to_A_pixels": rms1_pixels,
|
||
|
"RMS_B_to_A_radians": rms1_rad,
|
||
|
"RMS_A_to_B_pixels": rms2_pixels,
|
||
|
"RMS_A_to_B_radians": rms2_rad}
|
||
|
write_json(os.path.join(out_dir, "report.json"), rms_report)
|
||
|
|
||
|
return rmat_b_to_a, tvec_b_to_a, rms1_pixels, rms1_rad, rms2_pixels, rms2_rad
|