跳转至

十八、使用 ArUco 模块的 Android 摄像头校准和 AR

运行谷歌 Android 操作系统的移动设备数量超过了所有其他移动操作系统,近年来,它们拥有令人难以置信的计算能力,并配备了高质量的摄像头,这使得它们能够在最高水平上执行计算机视觉。 移动计算机视觉最受欢迎的应用之一是增强现实(AR)。 融合真实世界和虚拟世界在娱乐和游戏、医疗保健、工业和国防等领域都有应用。 移动 AR 的世界正在快速发展,每天都有新的引人注目的演示涌现,不可否认,它是移动硬件和软件开发的引擎。 在本章中,我们将学习如何使用 OpenCV 的 ArUcocontrib模块、Android 的 Camera2 API以及jMonkeyEngine 3D 游戏引擎在 Android 生态系统中从头开始实现 AR 应用。 然而,首先我们将使用 ArUco 的 ChArUco 校准板简单地校准我们的 Android 设备的摄像头,它为 OpenCV 的calib3d棋盘提供了一个更强大的替代方案。

本章将介绍以下主题:

  • 摄像机内参数的光理论介绍及标定过程
  • 利用 Camera2 接口和 ArUco 在 Android 系统中实现摄像机标定
  • 使用 jMonkeyEngine 和 ArUco 标记实现透视的 AR 世界

技术要求

本章使用的技术和软件如下:

随附的代码存储库中将提供这些组件的构建说明,以及实现本章中所示概念的代码。

要运行这些示例,需要一块打印的校准板。 电路板图像可以使用 ArUcocv::aruco::CharucoBoard::draw函数以编程方式生成,然后可以使用家用打印机打印。 如果将纸板粘在硬质表面,如纸板或塑料板上,效果最好。 打印电路板后,应精确测量电路板标记的大小(使用尺子或卡尺),以使校准结果更准确、更真实。

本章代码可通过giHub:https://github.com/PacktPublishing/Building-Computer-Vision-Projects-with-OpenCV4-and-CPlusPlus/tree/master/Chapter18访问。

增强现实与姿态估计

增强现实(AR)是汤姆·考德尔(Tom Caudell)在 20 世纪 90 年代初创造的一个概念。 他提出 AR 是从相机进行的现实世界渲染和计算机生成的图形的混合,这些图形可以流畅地融合在一起,创造出存在于现实世界中的虚拟物体的错觉。 在过去的几十年里,增强现实技术取得了长足的进步,从一项几乎没有真正应用的古怪技术,发展到了许多垂直领域的数十亿美元的行业:国防、制造、医疗、娱乐等。 但是,核心概念保持不变(在基于摄影机的 AR 中):在场景中的 3D 几何体之上注册图形。 因此,增强现实最终是关于从图像重建 3D 几何体,跟踪该几何体,以及注册到该几何体的 3D 图形渲染。 其他类型的增强现实使用的传感器与摄像头不同。 最著名的例子之一是在手机上使用陀螺仪和指南针进行增强现实,比如在精灵宝可梦 Go 应用中。

在过去,AR 主要基于使用基准标记,对比清晰(主要是黑白),通常是矩形印刷标记(请参阅下一节中此类标记的示例)。 使用它们的原因是很容易在图像中找到它们,因为它们的对比度很高,而且它们有四个(或更多)清晰的角,我们可以根据这些角来计算标记相对于相机的平面。 这是自 90 年代第一次 AR 应用以来的做法,至今在许多 AR 技术原型中仍是一种高度使用的方法。 本章将使用这种类型的 AR 检测,但是,如今 AR 技术已经转向其他 3D 几何重建方法,例如自然标记(非矩形,大多是非结构化的)、运动结构(SFM)映射和跟踪(也称为同时定位和映射)(SLAM)。

近年来 AR 迅速崛起的另一个原因是移动计算的出现。 在过去,渲染 3D 图形和运行复杂的计算机视觉算法需要一台功能强大的 PC,而今天,即使是低端的移动设备也可以轻松地处理这两项任务。 与基于基准的增强现实相比,今天的移动 GPU 和 CPU 已经足够强大,可以处理要求更高的任务。 主要的移动操作系统开发商,如谷歌和苹果,已经提供了基于 SfM 和 SLAM 的 AR 工具包,带有惯性传感器融合,其运行速度高于实时。 AR 还被整合到其他移动设备中,如头戴式显示器、汽车,甚至配备摄像头的飞行无人机。

摄像机定标

在我们手头的视觉任务中,恢复场景中的几何图形,我们将使用针孔相机模型,它大大简化了我们先进的数码相机获取图像的方式。 针孔模型本质上描述了世界对象到相机图像中的像素的变换。 下图说明了此过程:

相机图像具有本地 2D 坐标框架(以像素为单位),而 3D 对象在世界上的位置是以任意长度单位(如毫米、米或英寸)描述的。 为了协调这两个坐标帧,针孔相机模型提供了两种变换:p垂直投影相机姿势。 摄影机姿势变换(在上图中表示为P)将对象的坐标与摄影机的局部坐标框对齐,例如,如果对象正好在 10 米之外的摄影机光轴前面,则其坐标在米尺度上变为 0、0、10。 姿势(刚性变换)由旋转R和平移t组件组成,并产生与摄影机局部坐标系对齐的新 3D 位置,如下所示:

其中W‘是 3D 点W齐次坐标,该坐标是通过将 1 加到矢量末尾而获得的。

下一步是将对齐的 3D 点投影到图像平面上。 直观地说,在上图中,我们可以看到对齐的 3D 点和 2D 像素点存在于相机中心的光线上,这施加了重叠的直角三角形(90 度)约束。 因此,这意味着如果我们知道z坐标和f系数,我们就可以通过除以z;来计算图像平面上的点(xiyi),这称为透视除。 首先,我们除以z以将点带到标准化坐标(距相机投影中心的距离为 1),然后将其乘以一个因子,该系数将真实相机的焦距与图像平面上的像素大小相关联。 最后,我们将相机投影中心(主点)的偏移量相加,以结束于像素位置:

在现实中,确定物体在图像中的位置不仅仅是焦距,还有更多的因素,例如镜头的畸变(径向畸变,桶形畸变),这涉及到非线性计算。 此投影变换通常用单个矩阵表示,称为摄像机内参数矩阵,通常由K表示:

摄像机标定的过程是求出K系数(以及畸变参数)的过程,这是计算机视觉中任何精确工作的基础步骤。 它通常是通过给定相关 3D 和 2D 点的测量的优化问题来完成的。 给定足够的对应图像点(xiyi)和 3D 点(uvw),可以构造如下的重投影成本函数:

这里的重新投影成本函数寻求最小化原始 2D 图像点和使用投影和姿势矩阵重新投影到场景上的 3D 图像之间的欧几里德距离:

K矩阵的近似值开始(例如,主点可以是图像的精确中心),我们可以通过建立过约束线性系统或诸如点-n-透视(PNP)之类的算法,以直接线性的方式估计P的值。 然后,我们可以使用关于K的参数的在L上的梯度迭代地进行,以使用诸如Levenberg-MarQuardt的梯度下降算法慢慢地改进它们直到收敛。 这些算法的详细内容超出了本章的范围;但是,它们是在 OpenCV 中实现的,用于摄像机校准。

用于平面重建的增强现实标记

使用 AR 基准标记是为了方便找到他们躺在上面的飞机来拍摄相机。 AR 标记通常有很强的角点或其他几何特征(例如,圆),这些特征不是很清楚,很容易被检测到。 2D 地标以探测器预先知道的方式排列,因此我们可以很容易地建立 2D-3D 点对应。 以下是 AR 基准标记的示例:

在本例中,有几种类型的二维地标。 在矩形标记中,这些是矩形和内部矩形的角点,而在二维码(中间)中,这些是三个大的方框矩形。 非矩形标记使用圆的中心作为 2D 位置。

给定标记上的 2D 点及其成对的 3D 坐标(以毫米为单位),我们可以使用上一节中介绍的原理为每一对编写以下公式:

请注意,由于标记是平坦的,并且不失一般性,它存在于地平面上,其z坐标为零,因此我们可以省略P矩阵的第三列。 我们只剩下一个 3x3 矩阵要找了。 注意,我们仍然可以恢复整个旋转矩阵;因为它是正交的,所以我们可以使用前两列通过叉积找到第三列:。 剩下的 3x3 矩阵是单应;它在一个平面(图像平面)和另一个平面(标记平面)之间转换。 我们可以通过构造齐次线性方程组来估计矩阵的值,如下所示:

它可以分解成下面的齐次方程组:

我们可以通过将A矩阵的奇异值分解V的最后一列作为解来解决这个问题,我们可以找到P。 这将只适用于平面标记,因为我们在前面的平坦度假设。 对于 3D 对象的校准,需要使用更多的线性系统仪器来恢复有效的正交旋转。 还存在其他算法,例如我们前面提到的透视-n-点(PnP)算法。 这就是我们创建增强现实效果所需的理论基础。 在下一章中,我们将开始在 Android 中构建一个应用来实现这些想法。

Android 操作系统中的摄像头访问

大多数(如果不是全部)运行 Android 的移动电话设备都配备了支持视频的摄像头,Android 操作系统提供了从摄像头访问原始数据流的 API。 在 Android 版本 5(API 级别 21)之前,Google 推荐使用较旧的 CameraAPI;然而,在最近的版本中,该 API 被弃用,取而代之的是新的 Camera2API,我们将使用该 API。 谷歌为安卓开发者提供了一个很好的使用 Camera2API 的示例指南:https://github.com/googlesamples/android-Camera2Basic。 在本节中,我们将只讲述几个重要的元素,完整的代码可以在附带的存储库中查看。

首先,使用摄像机需要用户权限。 在AndroidManifest.xml文件中,我们标记了以下内容:

    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>

我们还请求文件存储访问以保存中间数据或调试映像。 下一步是在应用启动后立即使用屏幕上的对话框请求用户的权限(如果之前尚未授予权限):

if (context.checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
    context.requestPermissions(new String[] { Manifest.permission.CAMERA }, REQUEST_PERMISSION_CODE);
    return; // break until next time, after user approves
}

请注意,需要一些进一步的检测来处理权限请求的返回。

查找并打开相机

接下来,我们尝试通过扫描设备上的可用摄像头列表来查找合适的后置摄像头。 如果相机是背面的,则会为其提供特征标志,如下所示:

CameraManager manager = (CameraManager) context.getSystemService(Context.CAMERA_SERVICE);
try {
    String camList[] = manager.getCameraIdList();
    mCameraID = camList[0]; // save as a class member - mCameraID
    for (String cameraID : camList) {
        CameraCharacteristics characteristics = manager.getCameraCharacteristics(cameraID);
        if(characteristics.get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_BACK) {
            mCameraID = cameraID;
            break;
        }
    }
    Log.i(LOGTAG, "Opening camera: " + mCameraID);
    CameraCharacteristics characteristics = manager.getCameraCharacteristics(mCameraID);
    manager.openCamera(mCameraID, mStateCallback, mBackgroundHandler);
} catch (...) {
    /* ... */
}

当相机打开时,我们查看可用的图像分辨率列表,并挑选一个合适的大小。 好的尺寸不会太大,所以计算不会太长,分辨率要与屏幕分辨率一致,这样才能覆盖整个屏幕:

final int width = 1280; // 1280x720 is a good wide-format size, but we can query the 
final int height = 720; // screen to see precisely what resolution it is.

CameraCharacteristics characteristics = manager.getCameraCharacteristics(mCameraID);
StreamConfigurationMap map = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP);
int bestWidth = 0, bestHeight = 0;
final float aspect = (float)width / height;
for (Size psize : map.getOutputSizes(ImageFormat.YUV_420_888)) {
    final int w = psize.getWidth(), h = psize.getHeight();
    // accept the size if it's close to our target and has similar aspect ratio
    if ( width >= w && height >= h &&
         bestWidth <= w && bestHeight <= h &&
         Math.abs(aspect - (float)w/h) < 0.2 ) 
    {
        bestWidth = w;
        bestHeight = h;
    }
}

我们现在可以请求访问视频源了。 我们将请求访问来自摄像机的原始数据。 几乎所有的 Android 设备都将提供 YUV 420 流,因此以该格式为目标是一种好的做法;然而,我们需要一个转换步骤才能获得 RGB 数据,如下所示:

mImageReader = ImageReader.newInstance(mPreviewSize.getWidth(), mPreviewSize.getHeight(), ImageFormat.YUV_420_888, 2);
// The ImageAvailableListener will get a function call with each frame
mImageReader.setOnImageAvailableListener(mHandler, mBackgroundHandler);

mPreviewRequestBuilder = mCameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW);
mPreviewRequestBuilder.addTarget(mImageReader.getSurface());

mCameraDevice.createCaptureSession(Arrays.asList(mImageReader.getSurface()),
        new CameraCaptureSession.StateCallback() {
            @Override
            public void onConfigured( CameraCaptureSession cameraCaptureSession) {
                mCaptureSession = cameraCaptureSession;
                // ... setup auto-focus here
                mHandler.onCameraSetup(mPreviewSize); // notify interested parties
            }

            @Override
            public void onConfigureFailed(CameraCaptureSession cameraCaptureSession) {
                Log.e(LOGTAG, "createCameraPreviewSession failed");
            }
        }, mBackgroundHandler);

从现在开始,我们实现ImageReader.OnImageAvailableListener的类将在每一帧中被调用,我们可以访问像素:

@Override
public void onImageAvailable(ImageReader imageReader) {
    android.media.Image image = imageReader.acquireLatestImage();

    //such as getting a grayscale image by taking just the Y component (from YUV)
    mPreviewByteBufferGray.rewind();
    ByteBuffer buffer = image.getPlanes()[0].getBuffer();
    buffer.rewind();
    buffer.get(mPreviewByteBufferGray.array());

    image.close(); // release the image - Important!
}

此时,我们可以发送字节缓冲区以在 OpenCV 中进行处理。 接下来,我们将使用aruco模块开发摄像机校准过程。

使用 ArUco 进行摄像机校准

要像我们前面讨论的那样执行摄像机校准,我们必须获得相应的 2D-3D 点对。 有了 ArUco 标记检测,这项任务就变得简单了。 ArUco 提供了一个创建校准板的工具,这是一个由正方形和 AR 标记组成的网格,其中所有参数都是已知的:标记的数量、大小和位置。 我们可以使用家庭或办公室打印机打印这样的单板,打印图像由 ArUco API 提供:

Ptr<aruco::Dictionary> dict = aruco::Dictionary::get(aruco::DICT_ARUCO_ORIGINAL);
Ptr<aruco::GridBoard> board = aruco::GridBoard::create(
    10     /* N markers x */, 
    7      /* M markers y */, 
    14.0f  /* marker width (mm) */, 
    9.2f   /* marker separation (mm) */, 
    dict);
Mat boardImage;
board->draw({1000, 700}, boardImage, 25); // an image of 1000x700 pixels
cv::imwrite("ArucoBoard.png", boardImage);

下面是这样一个电路板图像的示例,它是前面代码的结果:

我们需要通过移动相机或电路板来获得电路板的多个视图。 将纸板粘贴在一块硬纸板或塑料上可以方便地在移动纸板时保持纸张平整,或者在移动相机时将纸板平放在桌子上。 我们可以实现一个非常简单的 Android UI 来捕获图像,只需三个按钮:捕获、校准和完成:

正如我们前面看到的,Capture 按钮只是获取灰度图像缓冲区,并调用本机 C++ 函数来检测 ArUco 标记并将它们保存到内存中:

extern "C"
JNIEXPORT jint JNICALL
Java_com_packt_masteringopencv4_opencvarucoar_CalibrationActivity_addCalibration8UImage(
    JNIEnv *env,
    jclass type,
    jbyteArray data_, // java: byte[] , a 8 uchar grayscale image buffer
    jint w,
    jint h) 
{
    jbyte *data = env->GetByteArrayElements(data_, NULL);
    Mat grayImage(h, w, CV_8UC1, data);

    vector< int > ids;
    vector< vector< Point2f > > corners, rejected;

    // detect markers
    aruco::detectMarkers(grayImage, dict, corners, ids, params, rejected);
    __android_log_print(ANDROID_LOG_DEBUG, LOGTAG, "found %d markers", ids.size());

    allCorners.push_back(corners);
    allIds.push_back(ids);
    allImgs.push_back(grayImage.clone());
    imgSize = grayImage.size();

    __android_log_print(ANDROID_LOG_DEBUG, LOGTAG, "%d captures", allImgs.size());

    env->ReleaseByteArrayElements(data_, data, 0);

    return allImgs.size(); // return the number of captured images so far
}

以下是使用上一个函数检测到的 ArUco 标记板的示例。 可以使用cv::aruco::drawDetectedMarkers来实现检测到的标记的可视化。 正确检测到的标记中的点将用于校准:

在获得足够的图像(来自不同视点的大约 10 个图像通常就足够了)之后,校准按钮调用另一个运行aruco::calibrateCameraAruco函数的本机函数,保存的点对应关系数组如下所示:

extern "C"
JNIEXPORT void JNICALL
Java_com_packt_masteringopencv4_opencvarucoar_CalibrationActivity_doCalibration(
    JNIEnv *env,
    jclass type) 
{
    vector< Mat > rvecs, tvecs;

    cameraMatrix = Mat::eye(3, 3, CV_64F);
    cameraMatrix.at< double >(0, 0) = 1.0;

    // prepare data for calibration: put all marker points in a single array
    vector< vector< Point2f > > allCornersConcatenated;
    vector< int > allIdsConcatenated;
    vector< int > markerCounterPerFrame;
    markerCounterPerFrame.reserve(allCorners.size());
    for (unsigned int i = 0; i < allCorners.size(); i++) {
        markerCounterPerFrame.push_back((int)allCorners[i].size());
        for (unsigned int j = 0; j < allCorners[i].size(); j++) {
            allCornersConcatenated.push_back(allCorners[i][j]);
            allIdsConcatenated.push_back(allIds[i][j]);
        }
    }

    // calibrate camera using aruco markers
    double arucoRepErr;
    arucoRepErr = aruco::calibrateCameraAruco(allCornersConcatenated, 
                                              allIdsConcatenated,
                                              markerCounterPerFrame, 
                                              board, imgSize, cameraMatrix,
                                              distCoeffs, rvecs, tvecs,                                                                                   CALIB_FIX_ASPECT_RATIO);

    __android_log_print(ANDROID_LOG_DEBUG, LOGTAG, "reprojection err: %.3f", arucoRepErr);
    stringstream ss;
    ss << cameraMatrix << endl << distCoeffs;
    __android_log_print(ANDROID_LOG_DEBUG, LOGTAG, "calibration: %s", ss.str().c_str());

    // save the calibration to file
    cv::FileStorage fs("/sdcard/calibration.yml", FileStorage::WRITE);
    fs.write("cameraMatrix", cameraMatrix);
    fs.write("distCoeffs", distCoeffs);
    fs.release();
}

Done(完成)按钮将使应用进入 AR 模式,在 AR 模式下,校准值用于姿势估计。

使用 jMonkeyEngine 实现的增强现实

校准好相机后,我们就可以继续执行 AR 应用了。 我们将使用jMonkeyEngine(JME)3D 呈现套件创建一个非常简单的应用,该应用只在标记顶部显示一个普通的 3D 框。 JME 的功能非常丰富,成熟的游戏都是使用它实现的(比如 Rise World);我们可以通过额外的工作将我们的 AR 应用扩展到真正的 AR 游戏中。 在阅读本章时,创建 JME 应用所需的代码比我们在这里看到的要广泛得多,完整的代码可以在本书的代码库中找到。

首先,我们需要配置 JME 以显示覆盖的 3D 图形后面的相机视图。 我们将创建一个纹理来存储 RGB 图像像素,并创建一个四边形来显示纹理。 四边形将由正交摄影机(无透视)渲染,因为它是没有深度的简单 2D 图像。

下面的代码将创建一个Quad,这是一个简单的平面四顶点 3D 对象,它将保存摄影机视图纹理并将其拉伸以覆盖整个屏幕。 然后,一个Texture2D对象将被附加到Quad,这样我们就可以在新图像到达时替换它。 最后,我们将创建一个具有正交投影的Camera,并将纹理Quad附加到它:

// A quad to show the background texture
Quad videoBGQuad = new Quad(1, 1, true);
mBGQuad = new Geometry("quad", videoBGQuad);
final float newWidth = (float)screenWidth / (float)screenHeight;
final float sizeFactor = 0.825f;

// Center the Quad in the middle of the screen.
mBGQuad.setLocalTranslation(-sizeFactor / 2.0f * newWidth, -sizeFactor / 2.0f, 0.f);

// Scale (stretch) the width of the Quad to cover the wide screen.
mBGQuad.setLocalScale(sizeFactor * newWidth, sizeFactor, 1);

// Create a new texture which will hold the Android camera preview frame pixels.
Material BGMat = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
mCameraTexture = new Texture2D();
BGMat.setTexture("ColorMap", mCameraTexture);
mBGQuad.setMaterial(BGMat);

// Create a custom virtual camera with orthographic projection
Camera videoBGCam = cam.clone();
videoBGCam.setParallelProjection(true);
// Create a custom viewport and attach the quad
ViewPort videoBGVP = renderManager.createMainView("VideoBGView", videoBGCam);
videoBGVP.attachScene(mBGQuad);

接下来,我们设置一个虚拟的透视图Camera来显示图形增强。 重要的是要使用我们早先获得的校准参数,以便虚拟和真实相机对齐。 我们使用校准中的焦距参数来设置新的Camera对象的锥体(查看梯形),方法是将其转换为视野(FOV)角度(以度为单位):

Camera fgCam = new Camera(settings.getWidth(), settings.getHeight());
fgCam.setLocation(new Vector3f(0f, 0f, 0f));
fgCam.lookAtDirection(Vector3f.UNIT_Z.negateLocal(), Vector3f.UNIT_Y);

// intrinsic parameters
final float f = getCalibrationFocalLength();

// set up a perspective camera using the calibration parameter
final float fovy = (float)Math.toDegrees(2.0f * (float)Math.atan2(mHeightPx, 2.0f * f));
final float aspect = (float) mWidthPx / (float) mHeightPx;
fgCam.setFrustumPerspective(fovy, aspect, fgCamNear, fgCamFar);

摄影机位于原点,面向-z方向,并向上指向y轴,以匹配 OpenCV 姿势估计算法中的坐标帧。

最后,运行的 demo 展示了背景图像上的虚拟立方体,精确地覆盖了 AR 标记:

简略的 / 概括的 / 简易判罪的 / 简易的

本章介绍了计算机视觉中的两个关键问题:摄像机标定和摄像机/物体姿态估计。 我们了解了在实践中实现这些概念的理论背景,以及使用arucoConrib 模块在 OpenCV 中实现这些概念的过程。 最后,我们构建了一个 Android 应用,该应用在本地函数中运行 ArUco 代码来校准相机,然后检测 AR 标记。 我们使用 jMonkeyEngine 3D 渲染引擎创建了一个使用 ArUco 校准和检测的非常简单的增强现实应用。

在下一章中,我们将了解如何在 iOS 应用环境中使用 OpenCV 来构建全景拼接应用。 在移动环境中使用 OpenCV 是 OpenCV 的一个非常流行的特性,因为该库为 Android 和 iOS 提供了预先构建的二进制文件和版本。



回到顶部