【android opencv学习笔记】Day 37:相机标定原理

相机标定原理

相机标定是机器视觉的基石,它通过确定相机的内部参数、外部参数和畸变系数,建立三维世界与二维图像之间的精确映射关系。

标定结果可用于三维重建、SLAM、目标测量、增强现实等关键应用。

本文将从针孔相机模型、标定原理到Android NDK工程实现,带你完整掌握基于OpenCV的相机标定技术,并提供可直接发布的博文内容与可运行源码。


相机标定核心原理

1. 针孔相机模型与成像过程

相机的成像过程本质上是三维场景到二维图像的投影过程,这一过程可以通过针孔相机模型来描述:

  1. 薄透镜公式 :描述焦距 f、物距 do 和像距 di 的关系:
    1f=1do+1di\frac{1}{f} = \frac{1}{do} + \frac{1}{di}f1=do1+di1
  2. 相似三角形投影 :物体高度 ho 与图像高度 hi 满足:
    hi=f⋅hodohi = f \cdot \frac{ho}{do}hi=f⋅doho
  3. 齐次坐标下的投影矩阵 :将三维世界坐标 (X, Y, Z) 转换为图像像素坐标 (u, v)
    suv1=fx0u00fyv0001RTXYZ1s \begin{bmatrix} u \\ v \\ 1 \end{bmatrix} = \begin{bmatrix} f_x & 0 & u_0 \\ 0 & f_y & v_0 \\ 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} R & T \end{bmatrix} \begin{bmatrix} X \\ Y \\ Z \\ 1 \end{bmatrix}s uv1 = fx000fy0u0v01 RT XYZ1
    其中:
    • f_x, f_y:x、y方向的焦距(像素单位)
    • u_0, v_0:主点(光轴与图像平面的交点)
    • R, T:相机的旋转矩阵和平移向量(外部参数)

2. 镜头畸变模型

真实镜头存在畸变,其中最主要的是径向畸变:

  • 桶形畸变:图像边缘向外膨胀,常见于广角镜头;
  • 枕形畸变:图像边缘向内收缩,常见于长焦镜头。

OpenCV采用5参数畸变模型:

{xdistorted=x(1+k1r2+k2r4+k3r6)+2p1xy+p2(r2+2x2)ydistorted=y(1+k1r2+k2r4+k3r6)+p1(r2+2y2)+2p2xy\begin{cases} x_{distorted} = x(1 + k_1 r^2 + k_2 r^4 + k_3 r^6) + 2p_1xy + p_2(r^2 + 2x^2) \\ y_{distorted} = y(1 + k_1 r^2 + k_2 r^4 + k_3 r^6) + p_1(r^2 + 2y^2) + 2p_2xy \end{cases}{xdistorted=x(1+k1r2+k2r4+k3r6)+2p1xy+p2(r2+2x2)ydistorted=y(1+k1r2+k2r4+k3r6)+p1(r2+2y2)+2p2xy

其中 r = \sqrt{x^2 + y^2}k1, k2, k3 为径向畸变系数,p1, p2 为切向畸变系数。

3. 棋盘格标定原理

OpenCV相机标定采用棋盘格图案作为标定物,其核心流程如下:

  1. 采集标定图像:从不同角度拍摄棋盘格图像;
  2. 检测角点 :使用 findChessboardCorners 检测棋盘格内角点;
  3. 亚像素级优化 :使用 cornerSubPix 提高角点精度;
  4. 求解标定参数 :通过 calibrateCamera 函数求解相机内参、畸变系数和外参;
  5. 畸变校正 :使用 initUndistortRectifyMapremap 对图像进行去畸变处理。

OpenCV核心API解析

1. cv::findChessboardCorners:检测棋盘格角点

cpp 复制代码
bool findChessboardCorners(
    InputArray image,           // 输入灰度图像
    Size patternSize,          // 棋盘格内角点数量(cols, rows)
    OutputArray corners,       // 输出角点坐标
    int flags = CALIB_CB_ADAPTIVE_THRESH + CALIB_CB_NORMALIZE_IMAGE
);
  • patternSize:棋盘格内部交点数,例如6×4的棋盘格应设置为Size(6,4)
  • 返回值:true表示成功检测到角点。

2. cv::cornerSubPix:亚像素级角点优化

cpp 复制代码
void cornerSubPix(
    InputArray image,                  // 输入灰度图像
    InputOutputArray corners,          // 输入/输出角点坐标
    Size winSize,                      // 搜索窗口大小
    Size zeroZone,                     // 死区大小,(-1,-1)表示无死区
    TermCriteria criteria              // 迭代终止条件
);
  • 用于将角点坐标优化到亚像素级,提升标定精度。

真实世界里棋盘格角点、边缘交点,几乎不会刚好落在像素格子的整数交点上,而是落在两个像素之间 / 像素内部。

只用整数像素坐标:存在 0~1 像素的误差

亚像素:把角点定位到像素内部,把精度从「整像素」提升到「小数级」

  • Size(5,5) 窗口大小
    以当前角点为中心,取 5×5 范围的像素做计算。
    棋盘格场景:(5,5) / (7,7) 最常用;
    纹理复杂大图可适当调大。

3. cv::calibrateCamera:相机标定核心函数

cpp 复制代码
double calibrateCamera(
    InputArrayOfArrays objectPoints,   // 世界坐标系中的三维点
    InputArrayOfArrays imagePoints,    // 图像坐标系中的二维点
    Size imageSize,                    // 图像尺寸
    InputOutputArray cameraMatrix,     // 输出相机内参矩阵
    InputOutputArray distCoeffs,       // 输出畸变系数
    OutputArrayOfArrays rvecs,         // 输出旋转向量
    OutputArrayOfArrays tvecs,         // 输出平移向量
    int flags = 0,                     // 标定标志位
    TermCriteria criteria = TermCriteria(TermCriteria::COUNT + TermCriteria::EPS, 30, DBL_EPSILON)
);
  • 返回值:重投影误差,误差越小表示标定结果越准确。

  • cameraMatrix 相机内参矩阵

    输出 Mat,固定 3×3 矩阵

    输出结果示例:

plaintext 复制代码
[fx,  0, u0]
[ 0, fy, v0]
[ 0,  0,  1]

标定完成后,这组参数可以离线保存,后续直接复用,不用重复标定。

  • rvecs / tvecs 外参
    rvecs:每张标定图的旋转向量(罗德里格斯向量)
    tvecs:每张标定图的平移向量
    作用:描述相机相对于棋盘格的姿态,单相机畸变校正基本用不到;双目视觉、三维重建、AR 才会使用。

4. cv::initUndistortRectifyMap:计算畸变校正映射

cpp 复制代码
void initUndistortRectifyMap(
    InputArray cameraMatrix,           // 相机内参矩阵
    InputArray distCoeffs,             // 畸变系数
    InputArray R,                      // 可选矫正项(无)
    InputArray newCameraMatrix,        // 矫正后的相机矩阵
    Size size,                         // 图像尺寸
    int m1type,                        // 映射图类型(CV_32FC1/CV_32FC2)
    OutputArray map1,                  // 输出x方向映射图
    OutputArray map2                   // 输出y方向映射图
);
  • map1 / map2 两张映射表(核心输出)
    map1:存储每个像素对应的 X 方向目标坐标
    map2:存储每个像素对应的 Y 方向目标坐标
    两张表尺寸和原图一模一样,每个位置存一个坐标值。

理解:遍历原图每一个 (x,y),去 map1/map2 里查「这个像素应该放到新图的哪个位置」

5. cv::remap:图像畸变校正

cpp 复制代码
void remap(
    InputArray src,                    // 输入图像
    OutputArray dst,                   // 输出校正后图像
    InputArray map1,                   // x方向映射图
    InputArray map2,                   // y方向映射图
    int interpolation,                 // 插值方式
    int borderMode = BORDER_CONSTANT,  // 边界处理方式
    const Scalar& borderValue = Scalar() // 边界值
);
  • interpolation 插值方式(重点)
    映射坐标是小数(亚像素),一个位置会对应原图多个像素,需要插值算颜色。常用枚举:
插值类型 说明 适用场景
INTER_NEAREST 最近邻 取最近整数像素,速度最快,图像易出现锯齿 实时预览、对画质要求低
INTER_LINEAR 双线性插值 周围4个像素加权平均,画质平滑,速度均衡 默认首选,畸变校正标准用法(你的代码在用)
INTER_CUBIC 双三次插值 周围16个像素计算,画质最好,速度偏慢 高清图像、精细处理

你的代码:INTER_LINEAR,兼顾画质与速度,棋盘格/普通图像都适用。

  • borderMode 边界模式
    当映射坐标超出原图范围时的处理规则:
  • BORDER_CONSTANT:边界填充固定颜色(默认),校正后图像四周出现黑边就是这个原因
  • BORDER_REPLICATE:复制图像边缘像素向外延伸,无黑边
  • BORDER_WRAP:图像循环平铺,几乎不用在校正场景

Android完整工程实现

本工程基于 Android NDK + OpenCV,实现从棋盘格图像采集、角点检测、相机标定到畸变校正的完整流程。为简化演示,我们使用单张棋盘格图像进行角点检测和畸变校正演示,实际标定需多张图像。

1. 布局文件 activity_main.xml

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#f5f5f5">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        android:padding="10dp">

        <!-- 原始图片展示 -->
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="vertical"
            android:layout_marginBottom="10dp">
            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="原始棋盘格图像"
                android:textSize="16sp"
                android:textStyle="bold"/>
            <ImageView
                android:id="@+id/iv_origin"
                android:layout_width="match_parent"
                android:layout_height="220dp"
                android:scaleType="fitCenter"
                android:background="#ffffff"/>
        </LinearLayout>

        <!-- 角点检测结果 -->
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="vertical"
            android:layout_marginBottom="10dp">
            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="棋盘格角点检测结果"
                android:textSize="16sp"
                android:textStyle="bold"/>
            <ImageView
                android:id="@+id/iv_corners"
                android:layout_width="match_parent"
                android:layout_height="220dp"
                android:scaleType="fitCenter"
                android:background="#ffffff"/>
        </LinearLayout>

        <!-- 畸变校正结果 -->
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="vertical">
            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="畸变校正结果"
                android:textSize="16sp"
                android:textStyle="bold"/>
            <ImageView
                android:id="@+id/iv_undistorted"
                android:layout_width="match_parent"
                android:layout_height="220dp"
                android:scaleType="fitCenter"
                android:background="#ffffff"/>
        </LinearLayout>

    </LinearLayout>
</ScrollView>

2. 上层 Kotlin 代码 MainActivity.kt

kotlin 复制代码
package com.example.cameracalibration

import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Bundle
import android.widget.ImageView
import androidx.appcompat.app.AppCompatActivity

class MainActivity : AppCompatActivity() {

    companion object {
        init {
            System.loadLibrary("native-lib")
        }
    }

    /**
     * JNI原生方法:执行棋盘格角点检测与畸变校正
     * @param srcBitmap 输入原图Bitmap
     * @param outCorners 输出角点检测结果Bitmap
     * @param outUndistorted 输出畸变校正结果Bitmap
     */
    private external fun processCameraCalibration(
        srcBitmap: Bitmap,
        outCorners: Bitmap,
        outUndistorted: Bitmap
    )

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // 加载2048×2048棋盘格测试图片
        val srcBitmap = BitmapFactory.decodeResource(resources, R.drawable.chessboard_image)

        // 创建输出位图
        val cornersBitmap = Bitmap.createBitmap(srcBitmap.width, srcBitmap.height, Bitmap.Config.ARGB_8888)
        val undistortedBitmap = Bitmap.createBitmap(srcBitmap.width, srcBitmap.height, Bitmap.Config.ARGB_8888)

        // 调用原生算法
        processCameraCalibration(srcBitmap, cornersBitmap, undistortedBitmap)

        // 展示结果
        findViewById<ImageView>(R.id.iv_origin).setImageBitmap(srcBitmap)
        findViewById<ImageView>(R.id.iv_corners).setImageBitmap(cornersBitmap)
        findViewById<ImageView>(R.id.iv_undistorted).setImageBitmap(undistortedBitmap)
    }
}

3. 底层 C++ JNI 代码 native-lib.cpp

cpp 复制代码
#include <jni.h>
#include <opencv2/opencv.hpp>
#include <android/bitmap.h>
#include <vector>

using namespace cv;
using namespace std;

/**
 * Bitmap 转 OpenCV Mat(RGBA -> BGR)
 */
Mat bitmapToMat(JNIEnv *env, jobject bitmap) {
    AndroidBitmapInfo info;
    void* pixels = nullptr;
    AndroidBitmap_getInfo(env, bitmap, &info);
    AndroidBitmap_lockPixels(env, bitmap, &pixels);

    Mat rgba(info.height, info.width, CV_8UC4, pixels);
    Mat bgr;
    cvtColor(rgba, bgr, COLOR_RGBA2BGR);
    AndroidBitmap_unlockPixels(env, bitmap);
    return bgr;
}

/**
 * OpenCV Mat 转 Bitmap(支持灰度图和彩色图)
 */
void matToBitmap(JNIEnv *env, const Mat& srcMat, jobject dstBitmap) {
    AndroidBitmapInfo info;
    void* pixels = nullptr;
    AndroidBitmap_getInfo(env, dstBitmap, &info);
    AndroidBitmap_lockPixels(env, dstBitmap, &pixels);

    Mat rgba;
    if (srcMat.channels() == 1) {
        cvtColor(srcMat, rgba, COLOR_GRAY2RGBA);
    } else {
        cvtColor(srcMat, rgba, COLOR_BGR2RGBA);
    }
    memcpy(pixels, rgba.data, info.width * info.height * 4);
    AndroidBitmap_unlockPixels(env, dstBitmap);
}

/**
 * 相机标定核心类
 */
class CameraCalibrator {
public:
    vector<vector<Point3f>> objectPoints;
    vector<vector<Point2f>> imagePoints;
    Mat cameraMatrix;
    Mat distCoeffs;
    Size imageSize;
    Mat map1, map2;
    bool mustInitUndistort;

    CameraCalibrator() : mustInitUndistort(true) {}

    // 添加棋盘格角点对
    void addPoints(const vector<Point2f>& imageCorners, const vector<Point3f>& objectCorners) {
        imagePoints.push_back(imageCorners);
        objectPoints.push_back(objectCorners);
    }

    // 相机标定
    double calibrate(const Size& imageSize) {
        this->imageSize = imageSize;
        vector<Mat> rvecs, tvecs;
        mustInitUndistort = true;
        return calibrateCamera(objectPoints, imagePoints, imageSize, cameraMatrix, distCoeffs, rvecs, tvecs);
    }

    // 畸变校正
    Mat remap(const Mat& image) {
        Mat undistorted;
        if (mustInitUndistort) {
            initUndistortRectifyMap(cameraMatrix, distCoeffs, Mat(), Mat(), image.size(), CV_32FC1, map1, map2);
            mustInitUndistort = false;
        }
        cv::remap(image, undistorted, map1, map2, INTER_LINEAR);
        return undistorted;
    }
};

/**
 * 棋盘格角点检测与标定流程
 */
void cameraCalibrationProcess(const Mat& srcBgr, Mat& outCorners, Mat& outUndistorted) {
    // 1. 转灰度图
    Mat srcGray;
    cvtColor(srcBgr, srcGray, COLOR_BGR2GRAY);

    // 2. 棋盘格参数(6×4内角点)
    Size boardSize(6, 4);
    vector<Point2f> imageCorners;
    bool found = findChessboardCorners(srcGray, boardSize, imageCorners);

    // 3. 亚像素级角点优化
    if (found) {
        cornerSubPix(srcGray, imageCorners, Size(5, 5), Size(-1, -1), TermCriteria(TermCriteria::MAX_ITER + TermCriteria::EPS, 30, 0.1));
    }

    // 4. 绘制角点检测结果
    outCorners = srcBgr.clone();
    drawChessboardCorners(outCorners, boardSize, imageCorners, found);

    // 5. 生成棋盘格三维点坐标(Z=0平面)
    vector<Point3f> objectCorners;
    for (int i = 0; i < boardSize.height; i++) {
        for (int j = 0; j < boardSize.width; j++) {
            objectCorners.emplace_back(j, i, 0.0f);
        }
    }

    // 6. 相机标定(演示用,实际需多张图像)
    CameraCalibrator calibrator;
    if (found) {
        calibrator.addPoints(imageCorners, objectCorners);
        calibrator.calibrate(srcGray.size());
    }

    // 7. 畸变校正
    outUndistorted = calibrator.remap(srcBgr);
}

/**
 * JNI入口函数
 */
extern "C" JNIEXPORT void JNICALL
Java_com_example_cameracalibration_MainActivity_processCameraCalibration(
        JNIEnv *env, jobject thiz,
        jobject srcBitmap,
        jobject outCorners,
        jobject outUndistorted)
{
    // Bitmap 转 Mat
    Mat srcBgr = bitmapToMat(env, srcBitmap);
    Mat matCorners, matUndistorted;

    // 执行相机标定算法
    cameraCalibrationProcess(srcBgr, matCorners, matUndistorted);

    // 结果回传给上层Bitmap
    matToBitmap(env, matCorners, outCorners);
    matToBitmap(env, matUndistorted, outUndistorted);
}

拓展应用与进阶方向

1. 三维重建

利用相机标定结果,结合双目相机或单目相机运动,可实现三维场景重建。

2. 增强现实

通过相机内参矩阵和畸变系数,可将虚拟物体精确投影到真实场景中。

3. 目标测量

基于标定后的相机模型,可从二维图像中计算物体的实际尺寸,实现非接触式测量。


总结

  1. 核心流程:棋盘格图像采集 → 角点检测 → 亚像素优化 → 相机标定 → 畸变校正;
  2. 关键参数:相机内参矩阵、畸变系数是标定的核心输出,直接影响后续应用精度;
  3. 工程价值:源码基于Android NDK + OpenCV实现,可直接移植到工业视觉、AR、三维重建等项目中;
  4. 精度保障:实际应用中需严格按照标定流程采集图像,确保重投影误差满足要求。