上一个教程 : 使用方形棋盘标定相机
下一个教程 : 纹理对象的实时姿态估计
原作者 | Bernát Gábor |
---|---|
兼容性 | OpenCV >= 4.0 |
相机的历史由来已久。然而,随着 20 世纪末廉价针孔摄像头的问世,摄像头在我们的日常生活中变得司空见惯。不幸的是,这种廉价也带来了代价:明显的失真。幸运的是,这些都是常量,通过标定和一些重映射,我们可以纠正这种情况。此外,通过标定,您还可以确定相机的自然单位(像素)与实际单位(例如毫米)之间的关系。
原理
对于失真,OpenCV 会考虑径向和切向因子。径向因子使用以下公式计算:
x
d
i
s
t
o
r
t
e
d
=
x
(
1
+
k
1
r
2
+
k
2
r
4
+
k
3
r
6
)
y
d
i
s
t
o
r
t
e
d
=
y
(
1
+
k
1
r
2
+
k
2
r
4
+
k
3
r
6
)
x_{distorted} = x( 1 + k_1 r^2 + k_2 r^4 + k_3 r^6) \\ y_{distorted} = y( 1 + k_1 r^2 + k_2 r^4 + k_3 r^6)
xdistorted=x(1+k1r2+k2r4+k3r6)ydistorted=y(1+k1r2+k2r4+k3r6)
因此,对于位于 (x,y) 坐标处的未失真像素点,其在失真图像上的位置将为
(
x
d
i
s
t
o
r
t
e
d
y
d
i
s
t
o
r
t
e
d
)
(x_{distorted} y_{distorted})
(xdistortedydistorted)径向失真表现为 "桶形 "或 "鱼眼 "效果。
切向畸变产生的原因是取像镜头与成像平面不完全平行。它可以用以下公式表示
x
d
i
s
t
o
r
t
e
d
=
x
+
[
2
p
1
x
y
+
p
2
(
r
2
+
2
x
2
)
]
y
d
i
s
t
o
r
t
e
d
=
y
+
[
p
1
(
r
2
+
2
y
2
)
+
2
p
2
x
y
]
x_{distorted} = x + [ 2p_1xy + p_2(r^2+2x^2)] \\ y_{distorted} = y + [ p_1(r^2+ 2y^2)+ 2p_2xy]
xdistorted=x+[2p1xy+p2(r2+2x2)]ydistorted=y+[p1(r2+2y2)+2p2xy]
因此,我们有五个失真参数,在 OpenCV 中,这些参数以一行矩阵五列的形式显示:
d
i
s
t
o
r
t
i
o
n
_
c
o
e
f
f
i
c
i
e
n
t
s
=
(
k
1
k
2
p
1
p
2
k
3
)
distortion\_coefficients=(k_1 \hspace{10pt} k_2 \hspace{10pt} p_1 \hspace{10pt} p_2 \hspace{10pt} k_3)
distortion_coefficients=(k1k2p1p2k3)
现在,我们使用以下公式进行单位转换:
[
x
y
w
]
=
[
f
x
0
c
x
0
f
y
c
y
0
0
1
]
[
X
Y
Z
]
\left [ \begin{matrix} x \\ y \\ w \end{matrix} \right ] = \left [ \begin{matrix} f_x & 0 & c_x \\ 0 & f_y & c_y \\ 0 & 0 & 1 \end{matrix} \right ] \left [ \begin{matrix} X \\ Y \\ Z \end{matrix} \right ]
xyw
=
fx000fy0cxcy1
XYZ
这里,w 的存在是因为使用了同向坐标系(w=Z)。未知参数是 fx 和 fy(摄像机焦距)以及 (cx,cy),后者是以像素坐标表示的光学中心。如果两个轴都使用一个给定长宽比(通常为 1)的共同焦距,则 fy=fx∗a 而在上式中,我们将得到一个单一焦距 f。虽然无论使用哪种分辨率的摄像机,畸变系数都是相同的,但这些畸变系数应根据标定分辨率与当前分辨率一起进行缩放。
确定这两个矩阵的过程就是标定。这些参数的计算是通过基本几何方程完成的。所使用的方程取决于所选择的标定对象。目前,OpenCV 支持三种类型的标定对象:
- 经典黑白棋盘
- 黑白棋盘图案
- 对称圆图案
- 不对称圆图案
基本上,您需要用相机拍摄这些图案的快照,然后让 OpenCV 找到它们。每个找到的图案都会产生一个新方程。要解方程,您至少需要预定数量的图案快照才能形成一个拟合良好的方程系统。棋盘图案的快照数较多,而圆形图案的快照数较少。例如,理论上棋盘图案至少需要两个快照。但实际上,我们的输入图像中存在大量噪声,因此要想取得好的结果,可能至少需要 10 张不同位置的输入图案快照。
目标
示例应用程序将
- 确定失真矩阵
- 确定相机矩阵
- 从摄像机、视频和图像文件列表中获取输入信息
- 从 XML/YAML 文件中读取配置
- 将结果保存到 XML/YAML 文件中
- 计算重投影误差
源代码
您也可以在 OpenCV 源代码库的 samples/cpp/tutorial_code/calib3d/camera_calibration/
文件夹中找到源代码,或从此处下载。要使用该程序,请使用 -h
参数运行它。程序有一个重要参数:配置文件名。如果没有参数,程序将尝试打开名为 "default.xml "的配置文件。下面是一个 XML 格式的配置文件示例。在配置文件中,您可以选择使用摄像头作为输入、视频文件或图像列表。如果选择最后一种,则需要创建一个配置文件,在其中枚举要使用的图像。下面是一个例子。需要记住的重要一点是,必须使用应用程序工作目录的绝对路径或相对路径指定图片。你可以在上面提到的示例目录中找到所有这些内容。
应用程序启动时会读取配置文件中的设置。虽然这是重要的一部分,但与本教程的主题–摄像机标定–无关。因此,我选择不在这里发布这部分的代码。关于如何完成此操作的技术背景,请参阅使用 XML 和 YAML 文件进行文件输入和输出 教程。
说明
- 读取设置
Settings s;
const string inputSettingsFile = parser.get<string>(0);
FileStorage fs(inputSettingsFile, FileStorage::READ); // 读取设置
if (!fs.isOpened())
{
cout << "Could not open the configuration file: \"" << inputSettingsFile << "\"" << endl;
parser.printMessage();
return -1;
}
fs["Settings"] >> s;
fs.release(); // 关闭设置文件
为此,我使用了简单的 OpenCV 类输入操作。读取文件后,我还使用了一个额外的后处理函数来检查输入的有效性。只有当所有输入都正确时,goodInput 变量才会为真。
2. 获取下一个输入,如果失败或我们有足够多的输入–标定
在此之后,我们会有一个大循环,进行以下操作:从图像列表、摄像头或视频文件中获取下一张图像。如果失败或有足够的图像,则运行标定过程。如果有图像,我们将跳出循环,否则将通过从检测模式切换到标定模式,使剩余帧不失真(如果设置了选项)。
for(;;)
{
Mat view;
bool blinkOutput = false;
view = s.nextImage();
//----- 如果没有更多图像或图像数量足够,则停止标定并显示结果 -------------
if( mode == CAPTURING && imagePoints.size() >= (size_t)s.nrFrames )
{
if(runCalibrationAndSave(s, imageSize, cameraMatrix, distCoeffs, imagePoints, grid_width、
release_object))
mode = CALIBRATED;
else
mode = DETECTION;
}
if(view.empty()) // 如果没有更多图像,停止循环
{
// 如果尚未达到标定阈值,则现在进行标定
if( mode != CALIBRATED && !imagePoints.empty() )
runCalibrationAndSave(s, imageSize, cameraMatrix, distCoeffs, imagePoints, grid_width、
release_object);
break;
}
对于某些摄像机,我们可能需要翻转输入图像。在此我们也会这样做。
- 查找当前输入中的模式
我上面提到的方程式的形成旨在找出输入中的主要模式:对于国际象棋棋盘来说,主要模式是方格的角,而对于圆来说,主要模式是圆本身。ChArUco 棋盘等同于国际象棋棋盘,但四角使用 ArUco 标记。这些标记的位置将形成结果并写入 pointBuf 向量。
vector<Point2f> pointBuf;
bool found;
int chessBoardFlags = CALIB_CB_ADAPTIVE_THRESH | CALIB_CB_NORMALIZE_IMAGE;
if(!s.useFisheye) {
// 快速检查在鱼眼等高失真情况下错误失败
chessBoardFlags |= CALIB_CB_FAST_CHECK;
}
switch( s.calibrationPattern ) // 在输入格式上查找特征点
{
case Settings::CHESSBOARD:
found = findChessboardCorners( view, s.boardSize, pointBuf, chessBoardFlags);
break;
case Settings::CHARUCOBOARD:
ch_detector.detectBoard( view, pointBuf, markerIds);
found = pointBuf.size() == (size_t)((s.boardSize.height - 1)*(s.boardSize.width - 1));
break;
case Settings::CIRCLES_GRID:
found = findCirclesGrid( view, s.boardSize, pointBuf );
break;
case Settings::ASYMMETRIC_CIRCLES_GRID:
found = findCirclesGrid( view, s.boardSize, pointBuf, CALIB_CB_ASYMMETRIC_GRID );
break;
default:
found = false;
break;
}
根据输入图案的类型,您可以使用 cv::findChessboardCorners 或 cv::findCirclesGrid 函数或 cv::aruco::CharucoDetector::detectBoard 方法。cv::findChessboardCorners 和 cv::findCirclesGrid 都会返回一个布尔变量,说明是否在输入中找到了图案(我们只需要考虑那些为真的图像)。CharucoDetector::detectBoard
可以检测部分可见的图案,并返回可见内角的坐标和 ID。
注意事项
棋盘、圆网格和 ChArUco
的棋盘大小和匹配点数量是不同的。所有与棋盘相关的算法都将内角的数量作为棋盘的宽度和高度。圆网格的棋盘大小只是两个网格尺寸的圆的数量。ChArUco
的棋盘大小是以方格为单位定义的,但检测结果是内角列表,因此在两个尺寸上都小 1。
同样,对于摄像头,我们只有在输入延迟时间过后才会拍摄摄像头图像。这样做是为了让用户可以移动棋盘,获得不同的图像。相似的图像会产生相似的方程,而相似的方程在标定步骤中会形成问题,因此标定会失败。对于正方形图像,角的位置只是近似值。我们可以通过调用 cv::cornerSubPix 函数来改善这一问题。(winSize
用于控制搜索窗口的边长。winSize
可以通过命令行参数--winSize=<number>
来更改)。这将产生更好的标定结果。之后,我们将有效输入结果添加到 imagePoints 向量中,将所有方程收集到一个容器中。最后,为了实现可视化反馈,我们将使用 cv::findChessboardCorners 函数在输入图像上绘制找到的点。
if (found) // 如果成功找到点、
{
// 提高找到的棋盘角的坐标精度
if( s.calibrationPattern == Settings::CHESSBOARD)
{
Mat viewGray;
cvtColor(view, viewGray, COLOR_BGR2GRAY);
cvtColor(view,viewGray,COLOR_BGR2GRAY); cornerSubPix(viewGray,pointBuf,Size(winSize,winSize)、
size(-1,-1),TermCriteria(TermCriteria::EPS+TermCriteria::COUNT,30,0.0001 ));
}
if( mode == CAPTURING && // 摄像机只在延迟时间后采集新样本
(!s.inputCapture.isOpened() || clock() - prevTimestamp > s.delay*1e-3*CLOCKS_PER_SEC) )
{
imagePoints.push_back(pointBuf);
prevTimestamp = clock();
blinkOutput = s.inputCapture.isOpened();
}
// 绘制边角。
if(s.calibrationPattern == Settings::CHARUCOBOARD)
drawChessboardCorners( view, cv::Size(s.boardSize.width-1, s.boardSize.height-1), Mat(pointBuf), found );
else
drawChessboardCorners( view, s.boardSize, Mat(pointBuf), found );
}
- 向用户显示状态和结果,以及应用程序的命令行控制
这部分显示图像上的文本输出。
string msg = (mode == CAPTURING) ? "100/100" :
mode == CALIBRATED ? "Calibrated" : "Press 'g' to start";
int baseLine = 0;
Size textSize = getTextSize(msg, 1, 1, 1, &baseLine);
Point textOrigin(view.cols - 2*textSize.width - 10, view.rows - 2*baseLine - 10);
if( mode == CAPTURING )
{
if( s.showUndistorted)
msg = cv::format( "%d/%d Undist", (int)imagePoints.size(), s.nrFrames );
else
msg = cv::format( "%d/%d", (int)imagePoints.size(), s.nrFrames );
}
putText( view, msg, textOrigin, 1, 1, mode == CALIBRATED ? GREEN : RED);
if( blinkOutput )
bitwise_not(view, view);
如果我们进行了标定并获得了带有畸变系数的相机矩阵,我们可能需要使用 cv::undistort 函数来标定图像:
if( mode == CALIBRATED && s.showUndistorted )
{
Mat temp = view.clone();
if (s.useFisheye)
{
Mat newCamMat;
fisheye::estimateNewCameraMatrixForUndistortRectify(cameraMatrix, distCoeffs, imageSize、
matx33d::eye(),newCamMat,1);
cv::fisheye::undistortImage(temp, view, cameraMatrix, distCoeffs, newCamMat);
}
else
undistort(temp, view, cameraMatrix, distCoeffs);
}
然后,我们将显示图像并等待输入键,如果输入键为 u,我们将切换失真消除;如果输入键为 g,我们将重新开始检测过程;最后,如果输入键为 ESC,我们将退出应用程序:
imshow("Image View", view);
char key = (char)waitKey(s.inputCapture.isOpened() ? 50 : s.delay);
if( key == ESC_KEY )
break;
if( key == 'u' && mode == CALIBRATED )
s.showUndistorted = !s.showUndistorted;
if( s.inputCapture.isOpened() && key == 'g' )
{
mode = CAPTURING;
imagePoints.clear();
}
- 同时显示图像的失真消除
在处理图像列表时,不可能在循环内消除变形。因此,必须在循环之后进行。利用这一点,现在我将扩展 cv::undistort 函数,它实际上首先调用 cv::initUndistortRectifyMap 来查找变换矩阵,然后使用 cv::remap 函数执行变换。由于标定成功后只需进行一次映射计算,因此使用这种扩展形式可以加快应用程序的运行速度:
if( s.inputType == Settings::IMAGE_LIST && s.showUndistorted && !cameraMatrix.empty())
{
Mat view, rview, map1, map2;
if(s.useFisheye)
{
Mat newCamMat;
fisheye::estimateNewCameraMatrixForUndistortRectify(cameraMatrix, distCoeffs, imageSize、
matx33d::eye(),newCamMat,1);
fisheye::initUndistortRectifyMap(cameraMatrix, distCoeffs, Matx33d::eye(), newCamMat, imageSize、
CV_16SC2, map1, map2);
}
else
{
initUndistortRectifyMap(
cameraMatrix,distCoeffs,Mat()、
getOptimalNewCameraMatrix(cameraMatrix, distCoeffs, imageSize, 1, imageSize, 0), imageSize、
CV_16SC2,map1,map2);
}
for(size_t i = 0; i < s.imageList.size(); i++ )
{
view = imread(s.imageList[i], IMREAD_COLOR);
if(view.empty())
continue;
remap(view、rview、map1、map2、INTER_LINEAR);
imshow("图像视图",rview);
char c = (char)waitKey();
if( c == ESC_KEY || c == 'q' || c == 'Q' )
break;
}
}
标定和保存
由于标定只需对每台摄像机进行一次,因此在标定成功后将其保存是合理的。这样以后就可以将这些值加载到程序中。因此,我们首先进行标定,如果标定成功,我们会将结果保存到 OpenCV 风格的 XML 或 YAML 文件中,具体取决于您在配置文件中提供的扩展名。
因此,在第一个函数中,我们只是将这两个过程分开。因为我们要保存许多标定变量,所以我们将在这里创建这些变量,并将它们同时传递给标定和保存函数。同样,我不会展示保存部分,因为这部分与标定几乎没有共同之处。请查看源文件,了解如何保存和保存什么:
bool runCalibrationAndSave(Settings& s, Size imageSize, Mat& cameraMatrix, Mat& distCoeffs、
vector<vector<Point2f> > imagePoints, float grid_width, bool release_object)
{
vector<Mat> rvecs, tvecs;
vector<float> reprojErrs;
double totalAvgErr = 0;
vector<Point3f> newObjPoints;
bool ok = runCalibration(s、imageSize、cameraMatrix、distCoeffs、imagePoints、rvecs、tvecs、reprojErrs、
totalAvgErr、newObjPoints、grid_width、release_object);
cout << (ok ? "标定成功":"标定失败")
<< ". avg re projection error = " << totalAvgErr << endl;
if(ok)
saveCameraParams(s, imageSize, cameraMatrix, distCoeffs, rvecs, tvecs, reprojErrs, imagePoints、
totalAvgErr、newObjPoints);
return ok;
}
我们使用 cv::calibrateCameraRO 函数进行标定。该函数的参数如下
- 对象点。这是一个 Point3f 向量,用于描述每张输入图像的图案外观。如果是平面图案(如棋盘),则只需将所有 Z 坐标设为零即可。这是这些重要点的集合。由于我们对所有输入图像都使用一个图案,因此只需计算一次,然后将其乘以所有其他输入视图即可。我们使用函数 calcBoardCornerPositions 计算角点的位置:
static void calcBoardCornerPositions(Size boardSize, float squareSize, vector<Point3f>& corners、
Settings::Pattern patternType /*= Settings::CHESSBOARD*/)
{
corners.clear();
switch(patternType)
{
case Settings::CHESSBOARD:
case Settings::CIRCLES_GRID:
for (int i = 0; i < boardSize.height; ++i) {
for (int j = 0; j < boardSize.width; ++j) {
corners.push_back(Point3f(j*squareSize, i*squareSize, 0));
}
}
break;
case Settings::CHARUCOBOARD:
for (int i = 0; i < boardSize.height - 1; ++i) {
for (int j = 0; j < boardSize.width - 1; ++j) {
corners.push_back(Point3f(j*squareSize, i*squareSize, 0));
}
}
break;
case Settings::ASYMMETRIC_CIRCLES_GRID:
for (int i = 0; i < boardSize.height; i++) {
for (int j = 0; j < boardSize.width; j++) {
corners.push_back(Point3f((2 * j + i % 2)*squareSize, i*squareSize, 0));
}
}
break;
default:
break;
}
}
然后乘以
vector<vector<Point3f> > objectPoints(1);
calcBoardCornerPositions(s.boardSize, s.squareSize, objectPoints[0], s.calibrationPattern);
objectPoints[0][s.boardSize.width - 1].x = objectPoints[0][0].x + grid_width;
newObjPoints = objectPoints[0];
objectPoints.resize(imagePoints.size(),objectPoints[0]);
注释
如果您的标定板是不准确、未经测量的大致平面目标(使用现成打印机在纸上打印的棋盘图案是最方便的标定目标,但它们大多不够准确),则可以使用 [239] 中的一种方法来显著提高估计的摄像机本征参数的准确性。如果提供命令行参数-d=<number>
,就会调用这种新的标定方法。在上述代码片段中,grid_width
实际上是-d=<number>
设置的值。它是图案网格点左上角(0,0,0)和右上角(s.squareSize*(s.boardSize.width-1),0,0)之间的距离。应使用直尺或游标卡尺精确测量。标定后,newObjPoints 将更新为对象点的细化 3D 坐标。
- 图像点。这是一个 Point2f 向量,每个输入图像都包含重要点的坐标(棋盘的角和圆形图案的圆心)。我们已经从 cv::findChessboardCorners 或 cv::findCirclesGrid 函数中收集了这些数据。我们只需将其传递下去即可。
- 从摄像头、视频文件或图像中获取的图像大小。
- 要固定的对象点的索引。我们将其设置为-1,以请求标准标定方法。如果使用新的对象释放方法,则将其设置为标定板网格右上角点的索引。详细说明请参阅 cv::calibrateCameraRO。
int iFixedPoint = -1;
if (release_object)
iFixedPoint = s.boardSize.width-1;
- 摄像机矩阵。如果我们使用了固定宽高比选项,则需要设置 fx:
cameraMatrix = Mat::eye(3, 3, CV_64F);
if( !s.useFisheye && s.flag & CALIB_FIX_ASPECT_RATIO )
cameraMatrix.at<double>(0,0) = s.aspectRatio;
- 失真系数矩阵。初始化为零。
distCoeffs = Mat::zeros(8, 1, CV_64F);
- 对于所有视图,函数将计算旋转和平移矢量,将对象点(在模型坐标空间中给定)转换为图像点(在世界坐标空间中给定)。第 7 和第 8 个参数是矩阵的输出向量,其中第 i 个位置包含第 i 个对象点到第 i 个图像点的旋转和平移向量。
- 标定模式点的更新输出向量。标准标定方法忽略此参数。
- 最后一个参数是标志。您需要在此指定一些选项,如固定焦距的长宽比、假设切向畸变为零或固定主点。这里我们使用 CALIB_USE_LU 来加快标定速度。
rms = calibrateCameraRO(objectPoints、imagePoints、imageSize、iFixedPoint、
cameraMatrix,distCoeffs,rvecs,tvecs,newObjPoints、
s.flag|CALIB_USE_LU);
- 该函数返回平均重投影误差。这个数字可以很好地估计找到的参数的精度。该误差应尽可能接近于零。给定本征矩阵、变形矩阵、旋转矩阵和平移矩阵后,我们可以使用 cv::projectPoints 首先将对象点转换为图像点,从而计算出一个视图的误差。然后,我们计算转换后得到的结果与角/圆查找算法之间的绝对值。为了找出平均误差,我们计算所有标定图像计算误差的算术平均值。
static double computeReprojectionErrors( const vector<vector<Point3f> >& objectPoints,
const vector<vector<Point2f> >& imagePoints、
const vector<Mat>& rvecs, const vector<Mat>& tvecs、
const Mat& cameraMatrix , const Mat& distCoeffs、
vector<float>& perViewErrors, bool fisheye)
{
vector<Point2f> imagePoints2;
size_t totalPoints = 0;
double totalErr = 0, err;
perViewErrors.resize(objectPoints.size());
for(size_t i = 0; i < objectPoints.size(); ++i )
{
if (fisheye)
{
fisheye::projectPoints(objectPoints[i], imagePoints2, rvecs[i], tvecs[i], cameraMatrix、
distCoeffs);
}
else
{
projectPoints(objectPoints[i], rvecs[i], tvecs[i], cameraMatrix, distCoeffs, imagePoints2);
}
err = norm(imagePoints[i], imagePoints2, NORM_L2);
size_t n = objectPoints[i].size();
perViewErrors[i] = (float) std::sqrt(err*err/n);
totalErr += err*err;
totalPoints += n;
}
return std::sqrt(totalErr/totalPoints);
}
结果
假设输入的棋盘尺寸大小为 9 X 6。。我使用 AXIS IP 摄像机创建了几张棋盘快照,并将其保存到 VID5 目录中。我将其放在工作目录的 images/CameraCalibration
文件夹中,并创建了以下 VID5.XML
文件,其中说明了要使用的图像:
<?xml version="1.0"?>
<opencv_storage>
<images>
images/CameraCalibration/VID5/xx1.jpg
images/CameraCalibration/VID5/xx2.jpg
images/CameraCalibration/VID5/xx3.jpg
images/CameraCalibration/VID5/xx4.jpg
images/CameraCalibration/VID5/xx5.jpg
images/CameraCalibration/VID5/xx6.jpg
images/CameraCalibration/VID5/xx7.jpg
images/CameraCalibration/VID5/xx8.jpg
</images>
</opencv_storage>
然后将 images/CameraCalibration/VID5/VID5.XML
作为配置文件的输入。下面是应用程序运行时发现的棋盘图案:
应用失真消除后,我们得到了
将输入宽度设为 4,高度设为 11,同样可以制作出不对称的圆形图案。这一次,我使用了一个实时摄像头,指定其 ID(“1”)作为输入。下面是检测到的图案的样子:
在这两种情况下,您都可以在指定的输出 XML/YAML 文件中找到摄像机和畸变系数矩阵:
<camera_matrix type_id="opencv-matrix">
<rows>3</rows>
<cols>3</cols>
<dt>d</dt>
<data>
6.5746697944293521e+002 0. 3.1950000000000000e+002 0.
6.5746697944293521e+002 2.3950000000000000e+002 0. 0. 1.</data></camera_matrix>
<distortion_coefficients type_id="opencv-matrix">
<rows>5</rows>
<cols>1</cols>
<dt>d</dt>
<data>
-4.1802327176423804e-001 5.0715244063187526e-001 0. 0.
-5.7843597214487474e-001</data></distortion_coefficients>
将这些值作为常量添加到您的程序中,调用 cv::initUndistortRectifyMap 和 cv::remap 函数来消除失真,并享受廉价和低质量相机的无失真输入。
您可以在 YouTube 上观看运行时实例。