1 什麽是相機標定
影響相機成像過程的因素有很多。一束光先是從光源出發,落在物體表面。而後它又經過物體表面的反射,進入相機,穿過鏡頭和光圈,最後落在感光芯片上。這個過程中,物體的形狀、方位、焦距、光圈數、感光芯片的大小、感光單元的分佈間距……許許多多參數會對最終的成像產生影響。
相機標定是求解相機參數的過程。相機的參數又分為內參和外參。 内參包括相機的焦距、感光單元的間距、鏡頭畸變係數等。外參是相機相對於其它外部事物的位姿。一旦外參和内參是確定的,那我們就能確切地知道外部世界的某一個點發出的光綫會落在圖像的哪個位置。因此,許多任務,例如雙目視覺、增強現實、立體匹配、單目3D目標檢測……都需要使用準確的相機內外參。
內外參常常是無法直接精確測得的。我們無法直接用尺子測量鏡頭到感光芯片的距離,更無法用普通工具測量芯片上感光單元之間的間距(這是微米級別的尺寸),而鏡頭的畸變模型還帶來更多更複雜的因素。要如何精確地得出這些數值呢?
首先,我們要建立相機的數學模型。 本文介紹常見的針孔相機模型。它用小孔成像來近似相機的成像過程。理解針孔相機模型,有助於學習其它相機模型,例如魚眼相機模型和線陣相機模型。
相機模型建立後,我們用相機拍攝特定標定物,根據標定物的參數及成像效果,可以間接推斷出相機的各項參數。
2 相機坐標系和世界坐標系

常見的相機坐標系如圖 1所示。相機鏡頭朝前時,x軸朝右,y軸朝下,z軸朝前。
當然,你也可以採用不一樣的坐標系設定。坐標系的定義不同不會影響相機模型和參數標定過程的數學本質,標定的方法也是一樣的。但採取“另類”的設計有時會帶來一些不必要的麻煩。
與相機坐標系相對的是世界坐標系。世界坐标系的定义根据问题的不同而不同。它可以定义在某张标定板上,也可以定义在某个传感器上。某点世界坐标系与相机坐标系上的坐标可以通过刚性变换来转换。假设
这里的向量
解释一下旋轉矩陣的計算。假設旋轉的過程是繞著z軸旋轉角度
所以,旋轉矩陣可以由3個旋轉角度決定。這三個角度被稱為歐拉角。
需要注意,旋轉角度、旋轉軸的順序不是唯一的。比如,也可以先繞
總而言之,六元組
3 針孔相機模型
3.1 透視投影
接下來介紹針孔相機模型。

我們忽略光線所具有的“波”的性質。我們僅需知道,“光是沿直線傳播的”。那麼,如圖 2所示物體上發出的光只能經過小孔落在成像平面上。經過小孔,上方的光綫落在下面,下方的光綫落在上面。小孔成像所成的是倒立的實像。
雖然小孔成像在物理上是這樣解釋的,但是,計算機從業者卻常用另外的圖示來表達這一過程。

在圖 3中,你可以看到對象物體和所成像都畫在小孔(又被稱爲主点)的同一側,並且直接繪製正像。你可能會問,這種事在物理上怎麽可能發生呢?
這在物理上當然是不合理的。之所以計算機的從業者這樣繪圖,只是因爲方便。這種畫法因爲能節省作圖空間,且避免了麻煩的坐標系旋轉而十分常見。通過避免坐標旋轉,可以簡化數學運算。
小孔成像的原理可以很容易用數學公式描述。假設相機坐標系中有一點
其中
在計算機視覺應用中,圖像會被離散化為以像素為單位的矩陣。要將
這裏
令
對於市面上的相機,一般都有
這兩個公式可以轉寫為方便的矩陣乘法的形式:
結合內參和外參,則成像點的計算公式是:
3.2 鏡頭畸變
對於大多數鏡頭而言,它們的畸變都可以被近似為徑向畸變。

徑向畸變:
這是Halcon庫採用的畸變公式。不同的程序庫或文獻可能使用不同的畸變公式。例如,OpenCV有一套更複雜的畸變描述方式。 它們的共同特征是用非線性函數來表示畸變。
昂貴的工業相機可以使用簡單的畸變模型,而便宜的網絡相機可能需要使用複雜的畸變模型。
4 標定
標定就是求解相機內外參的過程。對於針孔相機模型,我們有內參數
4.1 標定工具
我們使用張氏標定法來完成標定。需要使用標定板這一工具

標定板是黑白相間的方格子,這種被稱為“棋盤格標定板”。材質有A4紙的、鋁合金的,也有玻璃制的。材質決定了標定板的精度,也影響標定精度。如果你的應用場景對標定精度的要求很高,你可能需要選擇一種昂貴的材料,而不是簡單地用A4紙打印一張標定板。
我們檢測的是棋盤格標定板上的角點。角點的個數最好是奇數個x偶數個。圖案最好不是旋轉對稱的。
也有圓形圖案的標定板。但我更喜歡棋盤格圖案,因為圓形的投影中心不一定是圓形的中心,這會給檢測帶來麻煩。另外,角點檢測也比邊緣檢測準確,後者容易受光照影響而發生偏移。橢圓擬合的準確性依賴於邊緣檢測,因此拍照時必須控制好光照。
4.2 標定流程
標定時,我們從多個方位拍攝標定板,產生若干圖像(例如15張,30張)。
拍好照片後,可以使用廣為人知、影響深遠的張氏標定法[2]求解相機內外參。
4.3 內外參的閉式解
先從簡單的情況著手——假設不存在畸變,此時問題有閉式解。
我們需要做的只是求解線性變換:
注意標定板是平面,所以
令
又令
由於R是單位正交陣,所以
再令
設
假設我們拍攝了n張圖片,那麼將n個這樣的公式堆疊起來,就得到
一旦
接下來,各張圖像的外參也可依次求得。
總結一下本小節的內容:假設相機沒有畸變,那麼可以先求得每張圖像對應的單應性變換矩陣
4.4 求相機的畸變係數
上一節,我們求解了相機的內外參。但這種解法存在幾個問題:
- 我們假設相機沒有畸變;
- 我們通過最小二乘來求解問題。但這裡應用的最小二乘所最小化的距離度量沒有實際的物理意義。
所以,張氏標定法用上一節所介紹的方法求得的解作為初始解,用LM算法繼續優化該解。目標函數為:
5 思考題
一張標定板最少應該有多少個點?至少應當拍攝多少張標定板圖像?
標定板的點數至少應該使得
當然,實際上為了獲得較好的精度,我們會適當取多一些點,多幾張圖片。實踐中,如果點數、圖片數不夠多,解的精度是很差的。
6 參考文獻
7 附錄
7.1 標定算法的Python實現
OpenCV/Halcon等庫提供了標定用的函數,可以非常方便的用Python或者C++調用。儘管如此,不同的程序庫使用的相機模型有略微的差別。只有真正了解相機模型,理解每一個參數,才能正確地使用它們。
參考Halcon的資料[1] ,我用Python實現了一份標定算法。經測試,標定結果同Halcon的標定算法所產生的結果一致(誤差小於0.01)。這份代碼對於理解Halcon的相機模型、標定原理可能有幫助,僅供參考。
展開代碼
# -*- coding: utf-8 -*-
import numpy as np
import cv2 as cv
= np.sin, np.cos
sin, cos
# 旋转矩阵分解为欧拉角
# 该函数只能得到其中一种特解
# 参考:https://www.geometrictools.com/Documentation/EulerAngles.pdf
def rotate_matrix_factorize(mat):
assert(mat.shape == (3, 3))
= np.finfo(mat.dtype).eps
eps = mat.flatten()
r00, r01, r02, r10, r11, r12, r20, r21, r22 if np.abs(r02 - 1) < eps:
# 标定板正好垂直于成像平面,
# 分解方式不唯一,
# 随便返回一个特解算了
return 0, np.pi / 2, np.arctan2(r10, r11)
# TODO: 把这种标定板剔除掉比较好
elif np.abs(r01 + 1) < eps:
# 如同上一种情况
return 0, -np.pi / 2, np.arctan2(r10, r11)
else:
return np.arctan2(-r12, r22), np.arcsin(r02), np.arctan2(-r01, r00)
# 由投影变换矩阵和内参估计外参
def estimate_outer_params(cx, cy, fx, fy, mat_h):
# 内参矩阵
= np.matrix([[fx, 0, cx],
mat_m 0, fy, cy],
[0, 0, 1]])
[= 1 / np.linalg.norm(np.matmul(mat_m.I, mat_h[:, 0]))
lamb # 旋转矩阵
= lamb * np.matmul(mat_m.I, mat_h[:, 0])
r1 = lamb * np.matmul(mat_m.I, mat_h[:, 1])
r2 = np.cross(r1, r2)
r3 = np.array([r1, r2, r3]).reshape((3, 3)).T
mat_r # 旋转矩阵要强制转化为正交的
= np.linalg.svd(mat_r)
u, s, vh = np.matmul(np.matmul(u, np.eye(3, 3)), vh)
mat_r # 从旋转矩阵取旋转角度
= rotate_matrix_factorize(mat_r)
alpha, beta, gamma # 位移向量
= np.array(lamb * np.matmul(mat_m.I, mat_h[:, 2].reshape(3, 1)))
t return alpha, beta, gamma, t
def predict1(points, cx, cy, sx, sy, f, kappa, outer_params):
= outer_params.shape[0]
scene_num = points.shape[0]
pt_num assert(outer_params.shape == (scene_num, 6))
assert(points.shape == (pt_num, 2))
= points.T # shape: (2, pt_num)
points_transpose = np.empty((3, pt_num * scene_num))
points_camera
# 变换到相机坐标系
for i in range(scene_num):
= outer_params[i]
alpha, beta, gamma, tx, ty, tz
# 绕z轴旋转。
= np.matmul([
points_rotate_z -np.sin(gamma)],
[np.cos(gamma),
[np.sin(gamma), np.cos(gamma)]
],
points_transpose)assert(points_rotate_z.shape == (2, pt_num))
# 绕y轴旋转
= np.matmul([
points_rotate_y 0],
[np.cos(beta), 0, 1],
[-np.sin(beta), 0]
[
],
points_rotate_z)assert(points_rotate_y.shape == (3, pt_num))
# 绕x轴旋转
= np.matmul([
points_rotate_x 1, 0, 0],
[0, np.cos(alpha), -np.sin(alpha)],
[0, np.sin(alpha), np.cos(alpha)]
[
], points_rotate_y)
# 平移到相机坐标系
* pt_num: (i + 1) * pt_num] = points_rotate_x + [[tx], [ty], [tz]]
points_camera[:, i
# 投影到成像平面
= points_camera[:2, :] / points_camera[2, :] * f
points_image_plane assert(points_image_plane.shape == (2, pt_num * scene_num))
# 畸变
= np.sum(np.power(points_image_plane, 2), axis=0)
r2 assert(r2.shape == (pt_num * scene_num,))
= 2 / (1 + np.sqrt(1 - 4 * kappa * r2))
ratio = ratio * points_image_plane
points_distort assert(points_distort.shape == (2, pt_num * scene_num))
= np.sum(np.power(points_distort, 2), axis=0)
r2d assert(r2d.shape == (pt_num * scene_num,))
# 到图像坐标系
= [[1 / sx], [1 / sy]] * points_distort + [[cx], [cy]]
points_image assert(points_image.shape == (2, pt_num * scene_num))
# 雅可比矩阵,左起5列对应内参cx, cy, sx, f, kappa,往右每6列对应一组外参
# 上半部分对应标定点的横坐标,下半部分对应纵坐标
= np.empty((2 * pt_num * scene_num, 5 + scene_num * 6))
jac
# 横坐标对cx的偏导数
* scene_num, 0] = 1
jac[:pt_num # 纵坐标对cx的偏导数
* scene_num:, 0] = 0
jac[pt_num
# 对cy的偏导数
* scene_num, 1] = 0
jac[:pt_num * scene_num:, 1] = 1
jac[pt_num
# 对sx的偏导数
* scene_num, 2] = -points_distort[0, :] / sx ** 2
jac[:pt_num * scene_num:, 2] = 0
jac[pt_num
# 对f求偏导数
= 2 * kappa * points_distort[0, :] ** 2 / np.sqrt(1 - 4 * kappa * r2) + ratio
d_ud_u = 2 * kappa * points_image_plane[0, :] * points_distort[1, :] ** 2 / points_image_plane[1, :] / np.sqrt(1 - 4 * kappa * r2)
d_ud_v = d_ud_v
d_vd_u = 2 * kappa * points_distort[1, :] ** 2 / np.sqrt(1 - 4 * kappa * r2) + ratio
d_vd_v
* scene_num, 3] = (1 / sx * (d_ud_u * points_camera[0, :] / points_camera[2, :]
jac[:pt_num + d_ud_v * points_camera[1, :] / points_camera[2, :])).flatten()
* scene_num:, 3] = (1 / sy * (d_vd_u * points_camera[0, :] / points_camera[2, :]
jac[pt_num + d_vd_v * points_camera[1, :] / points_camera[2, :])).flatten()
# 对kappa的偏导数
* scene_num, 4] = (1 / sx * points_image_plane[0] / np.sqrt(1 - 4 * kappa * r2) * r2d).flatten()
jac[:pt_num * scene_num:, 4] = (1 / sy * points_image_plane[1] / np.sqrt(1 - 4 * kappa * r2) * r2d).flatten()
jac[pt_num
# 对外参求偏导数
# shape: (pt_num * scene_num,)
= 1 / sx * d_ud_u * f / points_camera[2, :]
d_c_xc = 1 / sx * d_ud_v * f / points_camera[2, :]
d_c_yc = -1 / sx * (d_ud_u * f * points_camera[0, :] / points_camera[2, :] ** 2
d_c_zc + d_ud_v * f * points_camera[1, :] / points_camera[2, :] ** 2)
= 1 / sy * d_vd_u * f / points_camera[2, :]
d_r_xc = 1 / sy * d_vd_v * f / points_camera[2, :]
d_r_yc = -1 / sy * (d_vd_u * f * points_camera[0, :] / points_camera[2, :] ** 2
d_r_zc + d_vd_v * f * points_camera[1, :] / points_camera[2, :] ** 2)
for scene in range(scene_num):
= d_c_xc[scene * pt_num:(scene + 1) * pt_num]
d_c_xc_slice = d_c_yc[scene * pt_num:(scene + 1) * pt_num]
d_c_yc_slice = d_c_zc[scene * pt_num:(scene + 1) * pt_num]
d_c_zc_slice = d_r_xc[scene * pt_num:(scene + 1) * pt_num]
d_r_xc_slice = d_r_yc[scene * pt_num:(scene + 1) * pt_num]
d_r_yc_slice = d_r_zc[scene * pt_num:(scene + 1) * pt_num]
d_r_zc_slice = jac[scene * pt_num:(scene + 1) * pt_num, :]
jac_slice1 = jac[(scene_num + scene) * pt_num:(scene_num + scene + 1) * pt_num, :]
jac_slice2 = outer_params[scene]
alpha, beta, gamma, _, _, _
# xc = [cos(beta) * cos(gamma) * x - cos(beta) * sin(gamma) * y],
# yc = [x * (cos(alpha) * sin(gamma) + cos(gamma) * sin(alpha) * sin(beta))
# + y * (cos(alpha) * cos(gamma) - sin(alpha) * sin(beta) * sin(gamma))],
# zc = [x * (-cos(alpha) * cos(gamma) * sin(beta) + sin(alpha) * sin(gamma))
# + y * (cos(alpha) * sin(beta) * sin(gamma) + cos(gamma) * sin(alpha))]
= 0
d_xc_alpha = -sin(beta) * cos(gamma) * points_transpose[0, :] \
d_xc_beta + sin(beta) * sin(gamma) * points_transpose[1, :]
= -cos(beta) * sin(gamma) * points_transpose[0, :] \
d_xc_gamma - cos(beta) * cos(gamma) * points_transpose[1, :]
= points_transpose[0, :] * (-sin(alpha) * sin(gamma) + cos(gamma) * cos(alpha) * sin(beta)) \
d_yc_alpha + points_transpose[1, :] * (-sin(alpha) * cos(gamma) - cos(alpha) * sin(beta) * sin(gamma))
= points_transpose[0, :] * cos(gamma) * sin(alpha) * cos(beta) \
d_yc_beta - points_transpose[1, :] * sin(alpha) * cos(beta) * sin(gamma)
= points_transpose[0, :] * (cos(alpha) * cos(gamma) - sin(gamma) * sin(alpha) * sin(beta)) \
d_yc_gamma + points_transpose[1, :] * (-cos(alpha) * sin(gamma) - sin(alpha) * sin(beta) * cos(gamma))
= points_transpose[0, :] * (sin(alpha) * cos(gamma) * sin(beta) + cos(alpha) * sin(gamma)) \
d_zc_alpha + points_transpose[1, :] * (-sin(alpha) * sin(beta) * sin(gamma) + cos(gamma) * cos(alpha))
= points_transpose[0, :] * -cos(alpha) * cos(gamma) * cos(beta) \
d_zc_beta + points_transpose[1, :] * cos(alpha) * cos(beta) * sin(gamma)
= points_transpose[0, :] * (cos(alpha) * sin(gamma) * sin(beta) + sin(alpha) * cos(gamma)) \
d_zc_gamma + points_transpose[1, :] * (cos(alpha) * sin(beta) * cos(gamma) - sin(gamma) * sin(alpha))
# alpha
5 + scene * 6] = d_c_xc_slice * d_xc_alpha + d_c_yc_slice * d_yc_alpha + d_c_zc_slice * d_zc_alpha
jac_slice1[:, 5 + scene * 6] = d_r_xc_slice * d_xc_alpha + d_r_yc_slice * d_yc_alpha + d_r_zc_slice * d_zc_alpha
jac_slice2[:,
# beta
6 + scene * 6] = d_c_xc_slice * d_xc_beta + d_c_yc_slice * d_yc_beta + d_c_zc_slice * d_zc_beta
jac_slice1[:, 6 + scene * 6] = d_r_xc_slice * d_xc_beta + d_r_yc_slice * d_yc_beta + d_r_zc_slice * d_zc_beta
jac_slice2[:,
# gamma
7 + scene * 6] = d_c_xc_slice * d_xc_gamma + d_c_yc_slice * d_yc_gamma + d_c_zc_slice * d_zc_gamma
jac_slice1[:, 7 + scene * 6] = d_r_xc_slice * d_xc_gamma + d_r_yc_slice * d_yc_gamma + d_r_zc_slice * d_zc_gamma
jac_slice2[:,
# tx
8 + scene * 6] = d_c_xc_slice
jac_slice1[:, 8 + scene * 6] = d_r_xc_slice
jac_slice2[:,
# ty
9 + scene * 6] = d_c_yc_slice
jac_slice1[:, 9 + scene * 6] = d_r_yc_slice
jac_slice2[:,
# tz
10 + scene * 6] = d_c_zc_slice
jac_slice1[:, 10 + scene * 6] = d_r_zc_slice
jac_slice2[:, return points_image, jac
def calibrate(grids, params):
= params['pattern size']
pattern_size = params['cell width']
cell_width = grids.shape[0]
image_count
# 随机显示一张标定板检测结果
# import random
# show_grid(pattern_size, imgs, grids, random.randint(0, len(imgs) - 1))
# 计算世界坐标系中的标定板坐标
= (np.arange(pattern_size[0] * pattern_size[1]) % pattern_size[0]) * cell_width
grid_x = np.asarray(np.arange(pattern_size[0] * pattern_size[1]) / pattern_size[0], np.int) * cell_width
grid_y = np.empty((pattern_size[0] * pattern_size[1], 2))
grid_world 0] = grid_x
grid_world[:, 1] = grid_y
grid_world[:,
# 提取内参初始值
= np.float32(np.tile(np.hstack((grid_world, np.zeros((grid_world.shape[0], 1)))), (image_count, 1)))\
obj_points 0] * pattern_size[1], 3)
.reshape(image_count, pattern_size[= np.array(cv.initCameraMatrix2D(
camera_matrix
obj_points,
np.float32(grids),1280, 1024)
(
))= camera_matrix.flatten()
fx, _, cx, _, fy, cy, _, _, _ = 4.8e-3
sy = fy * sy
f = f / fx
sx = 0
kappa = np.empty((image_count, 6))
outer_params for i in range(image_count):
= cv.findHomography(obj_points[i], grids[i])
mat_h, _ = estimate_outer_params(cx, cy, f / sx, f / sy, mat_h)
alpha, beta, gamma, t = [alpha, beta, gamma, t[0], t[1], t[2]]
outer_params[i, :]
= 100
lamb = 0.1
g1 = 1.1
g2 = 1e-1
i1 = 0.99
i2 = np.eye(5 + 6 * image_count)
mat_i for i in range(100):
= predict1(grid_world, cx, cy, sx, sy, f, kappa, outer_params)
points_predict, jac assert(points_predict.shape == (2, pattern_size[0] * pattern_size[1] * image_count))
= points_predict - grids.reshape(pattern_size[0] * pattern_size[1] * image_count, 2).T
delta assert(delta.shape == (2, pattern_size[0] * pattern_size[1] * image_count))
= np.mean(np.sum(delta ** 2, axis=0))
error print('平均误差:', np.sqrt(error))
# 更新内外参
= np.matmul(jac.T, jac)
mat_a = -np.matmul(np.mat(mat_a + lamb * mat_i).I,
step 0] * pattern_size[1] * image_count * 2, 1)))
np.matmul(jac.T, delta.reshape(pattern_size[assert(step.size == 5 + 6 * outer_params.shape[0])
= np.array(step).flatten()
step = np.array([cx, cy, sx, f, kappa]) + step[:5]
cx, cy, sx, f, kappa += step[5:].reshape(outer_params.shape[0], 6)
outer_params
# 更新LM算法的参数
= predict1(grid_world, cx, cy, sx, sy, f, kappa, outer_params)
points_predict2, _ = points_predict2 - grids.reshape(pattern_size[0] * pattern_size[1] * image_count, 2).T
delta2 = np.mean(np.sum(delta2 ** 2, axis=0))
error2 = points_predict + np.matmul(jac, step.reshape(5 + 6 * image_count, 1)) \
points_predict3 2, pattern_size[0] * pattern_size[0] * image_count)
.reshape(= points_predict3 - grids.reshape(pattern_size[0] * pattern_size[1] * image_count, 2).T
delta3 = np.mean(np.sum(delta3 ** 2, axis=0))
error3 = (error - error2) / (error - error3)
p = g2 * lamb if p < i1 else \
lamb if i1 <= p < i2 else\
lamb * lamb
g1
print(
'''用LM法优化得到:
cx: %f px;
cy: %f px;
sx: %f um;
sy: %f um;
f: %f mm;
kappa: %f 1/m^2;
''' % (cx, cy, sx * 1e3, sy * 1e3, f, kappa * 1e6)
)
def main():
# 初始参数
# 实际距离全部以毫米为单位
= {
params 'pattern size': (7, 7), # 标记圆的个数
'cell width': 25, # 标记点间距
'sx': 4.8e-3,
"sy": 4.8e-3
}= params['pattern size']
pattern_size
# 列出文件夹下的所有图片
= './test_images_1'
directory = []
files
import sys
import os
for file in os.listdir(directory):
= os.path.join(directory, file)
path if os.path.isfile(path) and path.endswith('.bmp'):
files.append(path)if len(files) < 2:
print("至少需要两份标定文件!", sys.stderr)
# exit(128)
# 读取图片
= []
imgs for file in files:
file, cv.IMREAD_GRAYSCALE))
imgs.append(cv.imread(
# 检测圆点
= np.zeros((len(imgs), params['pattern size'][0] * params['pattern size'][1], 2))
grids
# 有兩種方法可以讀取坐標:
# 1. 直接從halcon的識別結果中讀取坐標(halcon的檢測結果更準);
# 2. 或者調用opencv的檢測算法讀取坐標。
# 注釋掉你不用的那個方法。
'''
# 直接取halcon识别结果
for i, file in enumerate(files):
with open(file + '.d', 'r') as file_halcon_result:
lines = file_halcon_result.readlines()
for j, line in enumerate(lines):
coord = line.strip().split(' ')
if len(coord) != 2:
continue
grids[i, j, 0] = float(coord[0])
grids[i, j, 1] = float(coord[1])
'''
# 从图片提取圆点
for i, img in enumerate(imgs):
= cv.findCirclesGrid(img, pattern_size)
success, centers if not success:
return {
'success': False,
'msg': '标定板无法识别',
'error code 1': 123,
'error code 2': i
}assert(centers.shape == (pattern_size[0] * pattern_size[1], 1, 2))
= centers.reshape(pattern_size[0] * pattern_size[1], 2)
grids[i, :, :]
calibrate(grids, params)
if __name__ == '__main__':
main()
7.2 版權信息
圖像: