五、将图像跟踪与 3D 渲染相结合
本章的目标是将图像跟踪与 3D 渲染相结合。 我们将修改现有的图像跟踪器,以使其完全确定目标在 3D 中的位置和旋转。 然后,使用 Android SDK 的 OpenGL ES 实现,我们将在跟踪图像的前面绘制一个彩色 3D 立方体。 这是增强现实(AR)的情况,这意味着我们正在将虚拟对象(立方体)叠加到真实场景的特定部分上。
OpenGL(开放图形库)是一种标准化的,独立于语言,独立于平台的 API,用于通常使用 GPU 渲染 2D 和 3D 图形。 OpenGL 帮助开发人员从虚拟相机的角度定义形状(几何形状),表面(材料)和灯光的外观。 像 OpenCV 一样,OpenGL 在矩阵上执行计算。 例如,这些矩阵可以存储颜色数据或空间数据(包括位置和旋转)。 OpenGL ES(适用于嵌入式系统的 OpenGL)是 OpenGL 的子集,设计用于资源相对受限的设备,例如智能手机和平板电脑。
注意
可以从作者的网站下载本章的完整 Eclipse 项目。 该项目有两个版本:
OpenCV 3.x 的版本位于这个页面。
位于这个页面的 OpenCV 2.x 版本。
将文件添加到项目
在本章中,我们将修改现有的ImageDetectionFilter
类。 我们还将为以下新类和接口添加文件:
com.nummist.secondsight.ARCubeRenderer
:此类表示位于跟踪的真实对象前面的彩色立方体的渲染逻辑。 该类实现了 Android 标准库中的GLSurfaceView.Renderer
接口。 投影矩阵由CameraProjectionAdapter
实例确定,而立方体的姿势矩阵由ARFilter
实例确定,如稍后所述。com.nummist.secondsight.adapters.CameraProjectionAdapter
:此类表示物理相机与投影矩阵之间的关系。 其他类可以采用 OpenCV 或 OpenGL 格式获取投影矩阵。com.nummist.secondsight.filters.ar.ARFilter
:此接口表示过滤器,该过滤器以 OpenGL 矩阵形式捕获实际对象的位置和旋转。 我们将修改ImageDetectionFilter
以实现此接口。com.nummist.secondsight.filters.ar.NoneARFilter
:此类表示不执行任何操作的过滤器。 它扩展了NoneFilter
类并实现了ARFilter
接口。 当我们要关闭过滤但仍具有符合ARFilter
接口的对象时,可以使用NoneARFilter
。
这些类型一起支持虚拟 3D 环境的渲染,该环境与真实摄像机和场景的某些属性一致。
定义ARFilter
接口
给定源图像,我们先前的过滤器仅生成了目标图像。 现在,我们还希望生成有关源图像中可能可见的物体的姿势(位置和旋转)的数据。 出于 OpenGL 的目的,姿势表示为 16 个浮点数的数组,表示4 x 4
转换矩阵。 因此,我们可以如下定义ARFilter
接口:
注意
如果您不熟悉在 3D 几何图形中使用向量代数和矩阵代数,则可能会发现本章的某些部分难以理解。 粗略地说,您可以将变换矩阵想象成一个表格,其中包含基于 3D 位置的三个坐标和 3D 旋转的三个角度的三角函数的值。 通过矩阵乘法可以连续应用两个变换。 有关这些主题的入门知识,请参见在线书籍书《3D 计算机图形学的向量数学》。
public interface ARFilter extends Filter {
public float[] getGLPose();
}
当姿势矩阵未知时,getGLPose()
应该返回null
。
ARFilter
接口的最基本实现是NoneARFilter
类。 NoneARFilter
实际上没有找到姿势矩阵。 相反,getGLPose()
方法始终返回null
,如下面的代码所示:
public class NoneARFilter extends NoneFilter implements ARFilter {
@Override
public float[] getGLPose() {
return null;
}
}
NoneARFilter
类类似于其父类NoneFilter
的,只是其他过滤器的便捷替代。 当我们要关闭过滤但仍具有符合ARFilter
接口的对象时,可以使用NoneARFilter
。
在CameraProjectionAdapter
中构建投影矩阵
这是观光者的练习。 选择一张著名照片,该照片是在可识别的位置拍摄的,该位置今天看起来仍然很相似。 前往该站点并对其进行探索,直到您知道摄影师如何设置照片为止。 相机在哪里放置以及如何旋转?
如果找到答案并确定答案,则必须已经知道摄影师使用了哪个镜头(对于变焦镜头,是哪个变焦设置)。 没有这些信息,您就不可能将可行的摄像机姿势缩小到一个真实姿势。
当试图确定被摄物体相对于单眼(单镜头)相机的姿态时,我们面临着类似的问题。 为了找到独特的解决方案,我们首先需要知道相机的水平和垂直视场以及水平和垂直分辨率(以像素为单位)。
幸运的是,我们可以通过android.hardware.Camera.Parameters
类获取此数据。 我们的CameraProjectionAdapter
类将允许客户端代码提供Camera.Parameters
对象,然后获取 OpenCV 或 OpenGL 格式的投影矩阵。
注意
不幸的是,在某些设备上,Camera.Parameters
提供的数据具有误导性或完全错误。
在具有变焦镜头的设备上,水平和垂直视场可能基于镜头的最宽(1x)变焦设置。 有关基于当前缩放设置查找视野的建议,请参见 StackOverflow 帖子。
在某些设备上,视野报告为 360 度或其他不正确的值。 例如,Sony Xperia Arc 可能报告 360 度的视野。
作为依赖Camera.Parameters
的替代方法,我们可以要求用户在运行时校准摄像机。 OpenCV 提供了校准功能,要求用户拍摄棋盘的一系列照片。 我们不会在本书中介绍这些功能,但您可以在官方文档中了解它们,或其他 OpenCV 书籍,例如 RobertLaganière 的《OpenCV 2 计算机视觉应用编程手册》(Packt Publishing)。
CameraProjectionAdapter
作为成员变量,存储构造投影矩阵所需的所有数据。 它还存储矩阵本身和boolean
标志,以指示矩阵是否脏(在下一次客户端代码获取它们时是否需要重构它们)。 让我们编写以下有关类和成员变量的声明:
// Use the deprecated Camera class.
@SuppressWarnings("deprecation")
public class CameraProjectionAdapter {
float mFOVY = 45f; // equivalent in 35mm photography: 28mm lens
float mFOVX = 60f; // equivalent in 35mm photography: 28mm lens
int mHeightPx = 480;
int mWidthPx = 640;
float mNear = 0.1f;
float mFar = 10f;
final float[] mProjectionGL = new float[16];
boolean mProjectionDirtyGL = true;
MatOfDouble mProjectionCV;
boolean mProjectionDirtyCV = true;
请注意,如果客户端代码未能提供Camera.Parameters
实例,我们将采用一些默认值。 还要注意,mNear
和mFar
变量存储和剪切距离的近和远,这表示 OpenGL 相机不会渲染接近或接近的任何东西。 比这些各自的距离更远。 我们可以根据摄像机的规格和当前图像大小来设置一些变量,如以下方法所示:
public void setCameraParameters(
final Parameters cameraParameters, final Size imageSize) {
mFOVY = cameraParameters.getVerticalViewAngle();
mFOVX = cameraParameters.getHorizontalViewAngle();
mHeightPx = imageSizeimageSize.height;
mWidthPx = imageSizeimageSize.width;
mProjectionDirtyGL = true;
mProjectionDirtyCV = true;
}
作为获取图像长宽比的便捷方法,让我们提供以下方法:
public float getAspectRatio() {
return (float)mWidthPx / (float)mHeightPx;
}
对于近和远剪切距离,我们只需要一个简单的设置器,就可以实现如下:
public void setClipDistances(float near, float far) {
mNear = near;
mFar = far;
mProjectionDirtyGL = true;
}
由于裁剪距离仅与 OpenGL 有关,因此我们仅为 OpenGL 矩阵设置脏标志。
接下来,让我们考虑一下 OpenGL 投影矩阵的获取器。 如果矩阵很脏,我们将其重建。 为了构造投影矩阵,OpenGL 提供了一个称为frustumM(float[] m, int offset, float left, float right, float bottom, float top, float near, float far)
的函数。 前两个参数是应在其中存储矩阵数据的数组和偏移量。 其余参数描述视图平截头体的边缘,这是相机可以看到的空间区域。 尽管您可能会认为该区域是圆锥形的,但实际上它是一个截顶的金字塔。 圆锥体的末端由于近乎修剪,远处修剪以及用户屏幕的矩形形状而丢失。 这是视锥内部的视锥的可视化效果:
基于剪切距离和视场,我们可以使用简单的三角函数找到视锥的其他测量值,如以下实现所示:
public float[] getProjectionGL() {
if (mProjectionDirtyGL) {
final float right =
(float)Math.tan(0.5f * mFOVX * Math.PI / 180f) * mNear;
// Calculate vertical bounds based on horizontal bounds
// and the image's aspect ratio. Some aspect ratios will
// be crop modes that do not use the full vertical FOV
// reported by Camera.Paremeters.
final float top = right / getAspectRatio();
Matrix.frustumM(mProjectionGL, 0,
-right, right, -top, top, mNear, mFar);
mProjectionDirtyGL = false;
}
return mProjectionGL;
}
OpenCV 投影矩阵的获取器稍微复杂些,因为该库没有提供类似的辅助函数来构造矩阵。 因此,我们必须了解 OpenCV 投影矩阵的内容并自行构建。 它具有以下3 x 3
格式:
focusLengthXInPixels |
0 | centerXInPixels |
---|---|---|
0 | focusLengthYInPixels |
centerYInPixels |
0 | 0 | 1 |
我们将假设一个对称的镜头系统和一个正方形像素的传感器。 受这些约束的影响,矩阵简化为以下格式:
focusLengthInPixels |
0 | 0.5 * widthInPixels |
---|---|---|
0 | focusLengthInPixels |
0.5 * heightInPixels |
0 | 0 | 1 |
焦距是镜头无限远对焦时相机传感器与镜头系统光学中心之间的距离。 出于 OpenCV 的目的,焦距以像素相关单位表示。 名义上,我们可以将物理尺寸归因于像素。 我们可以通过将相机传感器的宽度或高度除以其水平或垂直分辨率来做到这一点。 但是,由于我们不知道传感器的任何物理尺寸,因此我们改用三角函数来确定与像素有关的焦距。 实现如下:
public MatOfDouble getProjectionCV() {
if (mProjectionDirtyCV) {
if (mProjectionCV == null) {
mProjectionCV = new MatOfDouble();
mProjectionCV.create(3, 3, CvType.CV_64FC1);
}
// Calculate focal length using the aspect ratio of the
// FOV values reported by Camera.Parameters. This is not
// necessarily the same as the image's current aspect
// ratio, which might be a crop mode.
final float fovAspectRatio = mFOVX / mFOVY;
double diagonalPx = Math.sqrt(
(Math.pow(mWidthPx, 2.0) +
Math.pow(mWidthPx / fovAspectRaio, 2.0)));
double diagonalFOV = Math.sqrt(
(Math.pow(mFOVX, 2.0) +
Math.pow(mFOVY, 2.0)));
double focalLengthPx = diagonalPx /
(2.0 * Math.tan(0.5 * diagonalFOV * Math.PI / 180f));
mProjectionCV.put(0, 0, focalLengthPx);
mProjectionCV.put(0, 1, 0.0);
mProjectionCV.put(0, 2, 0.5 * mWidthPx);
mProjectionCV.put(1, 0, 0.0);
mProjectionCV.put(1, 1, focalLengthPx);
mProjectionCV.put(1, 2, 0.5 * mHeightPx);
mProjectionCV.put(2, 0, 0.0);
mProjectionCV.put(2, 1, 0.0);
mProjectionCV.put(2, 2, 1.0);
}
return mProjectionCV;
}
}
客户端代码可以通过将实例化来使用CameraProjectionAdapter
,只要活动摄像机或图像尺寸发生变化,就调用setCameraParameters
,并且当 OpenGL 或 OpenCV 计算需要投影矩阵时,就调用getProjectionGL
和getProjectionCV
。 。
为 3D 跟踪修改ImageDetectionFilter
对于 3D 跟踪,除了mSceneCorners
变量外,ImageDetectionFilter
需要与相同的所有成员变量。 我们还需要几个新变量来存储有关目标姿势的计算。 此外,该类需要实现ARFilter
接口。 让我们修改ImageDetectionFilter
如下:
public class ImageDetectionFilter implements ARFilter {
// ...
// The reference image's corner coordinates, in 3D, in real
// units.
private final MatOfPoint3f mReferenceCorners3D =
new MatOfPoint3f();
// Good corner coordinates detected in the scene, in // pixels.
private final MatOfPoint2f mSceneCorners2D =
new MatOfPoint2f();
// Distortion coefficients of the camera's lens.
// Assume no distortion.
private final MatOfDouble mDistCoeffs =
new MatOfDouble(0.0, 0.0, 0.0, 0.0);
// An adaptor that provides the camera's projection matrix.
private final CameraProjectionAdapter mCameraProjectionAdapter;
// The Euler angles of the detected target.
private final MatOfDouble mRVec = new MatOfDouble();
// The XYZ coordinates of the detected target.
private final MatOfDouble mTVec = new MatOfDouble();
// The rotation matrix of the detected target.
private final MatOfDouble mRotation = new MatOfDouble();
// The OpenGL pose matrix of the detected target.
private final float[] mGLPose = new float[16];
// Whether the target is currently detected.
private boolean mTargetFound = false;
构造器需要两个新参数。 其中的一个是CameraProjectionAdapter
的实例,我们将其存储在成员变量中。 另一个是代表打印图像较小尺寸(横向图像的高度或纵向图像的宽度)尺寸的数字,我们用它来计算所跟踪对象的 3D 边界。 我们可以选择任意度量单位,但是在其他地方,当指定近片段距离,远片段距离和立方体的比例时,必须使用相同的单位。 例如,如果我们指定打印图像的尺寸为 1.0,而多维数据集的比例为 0.5,则该多维数据集将是打印图像较小尺寸的一半。 在以下代码中可以看到新的参数及其用法:
public ImageDetectionFilter(final Context context,
final int referenceImageResourceID,
final CameraProjectionAdapter cameraProjectionAdapter,
final double realSize)
throws IOException {
// ...
// Compute the image's width and height in real units, based
// on the specified real size of the image's smaller
// dimension.
final double aspectRatio = (double)referenceImageGray.cols()
/(double)referenceImageGray.rows();
final double halfRealWidth;
final double halfRealHeight;
if (referenceImageGray.cols() > referenceImageGray.rows()) {
halfRealHeight = 0.5f * realSize;
halfRealWidth = halfRealHeight * aspectRatio;
} else {
halfRealWidth = 0.5f * realSize;
halfRealHeight = halfRealWidth / aspectRatio;
}
// Define the printed image so that it normally lies in the
// xy plane (like a painting or poster on a wall).
// That is, +z normally points out of the page toward the
// viewer.
mReferenceCorners3D.fromArray(
new Point3(-halfRealWidth, -halfRealHeight, 0.0),
new Point3( halfRealWidth, -halfRealHeight, 0.0),
new Point3( halfRealWidth, halfRealHeight, 0.0),
new Point3(-halfRealWidth, halfRealHeight, 0.0));
mCameraProjectionAdapter = cameraProjectionAdapter;
}
为了满足ARFilter
接口,我们需要为 OpenGL 姿势矩阵实现获取器。 当目标丢失时,此获取器应返回null
,因为我们没有有关姿势的有效数据。 我们可以如下实现获取器:
@Override
public float[] getGLPose() {
return (mTargetFound ? mGLPose : null);
}
让我们将findSceneCorners
方法重命名为findPose
。 为了反映此名称更改,apply
方法的实现更改如下:
@Override
public void apply(final Mat src, final Mat dst) {
// Convert the scene to grayscale.
Imgproc.cvtColor(src, mGraySrc, Imgproc.COLOR_RGBA2GRAY);
// Detect the scene features, compute their descriptors,
// and match the scene descriptors to reference descriptors.
mFeatureDetector.detect(mGraySrc, mSceneKeypoints);
mDescriptorExtractor.compute(mGraySrc, mSceneKeypoints,
mSceneDescriptors);
mDescriptorMatcher.match(mSceneDescriptors,
mReferenceDescriptors, mMatches);
// Attempt to find the target image's 3D pose in the // scene.
findPose();
// If the pose has not been found, draw a thumbnail of the
// target image.
draw(src, dst);
}
findPose
的实现涵盖了旧findSceneCorners
方法之外的一些其他步骤。 找到角点后,我们从CameraProjectionAdapter
实例获得一个 OpenCV 投影矩阵。 接下来,我们根据匹配的角点和投影来求解目标的位置和旋转。 大多数计算由称为Calib3d.solvePnP(MatOfPoint3f objectPoints, MatOfPoint2f imagePoints, Mat cameraMatrix, MatOfDouble distCoeffs, Mat rvec, Mat tvec)
的 OpenCV 函数完成。 此函数将位置和旋转结果放在两个单独的向量中。 与 OpenGL 相比,OpenCV 中的 y 和 z 轴反转。 角度的方向也被反转。 因此,我们需要将向量的某些分量乘以 -1。 我们使用另一个称为Calib3d.Rodrigues(Mat src, Mat dst)
的 OpenCV 函数将旋转向量转换为矩阵。 最后,我们手动转换生成的旋转矩阵并将向量定位到适合 OpenGL 的float[16]
数组中。 代码如下:
private void findPose() {
// ...
if (minDist > 50.0) {
// The target is completely lost.
mTargetFound = false;
return;
} else if (minDist > 25.0) {
// The target is lost but maybe it is still close.
// Keep using any previously found pose.
return;
}
// ...
// Convert the scene corners to integer format, as required
// by the Imgproc.isContourConvex function.
mCandidateSceneCorners.convertTo(mIntSceneCorners,
CvType.CV_32S);
// Check whether the corners form a convex polygon. If not,
// (that is, if the corners form a concave polygon), the
// detection result is invalid because no real perspective
// can make the corners of a rectangular image look like a
// concave polygon!
if (!Imgproc.isContourConvex(mIntSceneCorners)) {
return;
}
double[] sceneCorner0 = mCandidateSceneCorners.get(0, 0);
double[] sceneCorner1 = mCandidateSceneCorners.get(1, 0);
double[] sceneCorner2 = mCandidateSceneCorners.get(2, 0);
double[] sceneCorner3 = mCandidateSceneCorners.get(3, 0);
mSceneCorners2D.fromArray(
new Point(sceneCorner0[0], sceneCorner0[1]),
new Point(sceneCorner1[0], sceneCorner1[1]),
new Point(sceneCorner2[0], sceneCorner2[1]),
new Point(sceneCorner3[0], sceneCorner3[1]));
MatOfDouble projection =
mCameraProjectionAdapter.getProjectionCV();
// Find the target's Euler angles and XYZ coordinates.
Calib3d.solvePnP(mReferenceCorners3D mSceneCorners2D,
projection, mDistCoeffs, mRVec, mTVec);
// Positive y is up in OpenGL, down in OpenCV.
// Positive z is backward in OpenGL, forward in OpenCV.
// Positive angles are counter-clockwise in OpenGL,
// clockwise in OpenCV.
// Thus, x angles are negated but y and z angles are
// double-negated (that is, unchanged).
// Meanwhile, y and z positions are negated.
double[] rVecArray = mRVec.toArray();
rVecArray[0] *= -1.0; // negate x angle
mRVec.fromArray(rVecArray);
// Convert the Euler angles to a 3x3 rotation matrix.
Calib3d.Rodrigues(mRVec, mRotation);
double[] tVecArray = mTVec.toArray();
// OpenCV's matrix format is transposed, relative to
// OpenGL's matrix format.
mGLPose[0] = (float)mRotation.get(0, 0)[0];
mGLPose[1] = (float)mRotation.get(0, 1)[0];
mGLPose[2] = (float)mRotation.get(0, 2)[0];
mGLPose[3] = 0f;
mGLPose[4] = (float)mRotation.get(1, 0)[0];
mGLPose[5] = (float)mRotation.get(1, 1)[0];
mGLPose[6] = (float)mRotation.get(1, 2)[0];
mGLPose[7] = 0f;
mGLPose[8] = (float)mRotation.get(2, 0)[0];
mGLPose[9] = (float)mRotation.get(2, 1)[0];
mGLPose[10] = (float)mRotation.get(2, 2)[0];
mGLPose[11] = 0f;
mGLPose[12] = (float)tVecArray[0];
mGLPose[13] = -(float)tVecArray[1]; // negate y position
mGLPose[14] = -(float)tVecArray[2]; // negate z position
mGLPose[15] = 1f;
mTargetFound = true;
}
最后,让我们通过删除围绕跟踪图像绘制绿色边框的代码来修改我们的draw
方法。 (相反,ARCubeRenderer
类将负责在跟踪的图像前面绘制一个多维数据集。)在删除不需要的代码之后,我们将使用draw
方法的以下实现:
protected void draw(Mat src, Mat dst) {
if (dst != src) {
src.copyTo(dst);
}
if (!mTargetFound) {
// The target has not been found.
// Draw a thumbnail of the target in the upper-left
// corner so that the user knows what it is.
// Compute the thumbnail's larger dimension as half the
// video frame's smaller dimension.
int height = mReferenceImage.height();
int width = mReferenceImage.width();
int maxDimension = Math.min(dst.width(),
dst.height()) / 2;
double aspectRatio = width / (double)height;
if (height > width) {
height = maxDimension;
width = (int)(height * aspectRatio);
} else {
width = maxDimension;
height = (int)(width / aspectRatio);
}
// Select the region of interest (ROI) where the thumbnail
// will be drawn.
Mat dstROI = dst.submat(0, height, 0, width);
// Copy a resized reference image into the ROI.
Imgproc.resize(mReferenceImage, dstROI, dstROI.size(),
0.0, 0.0, Imgproc.INTER_AREA);
}
}
}
接下来,我们来看如何使用 OpenGL 渲染立方体。
在ARCubeRenderer
中渲染多维数据集
Android 提供了一个名为GLSurfaceView
的类,它是由 OpenGL 绘制的小部件。 绘制逻辑通过名为GLSurfaceView.Renderer
的接口封装,我们将在ARCubeRenderer
中实现该接口。 该接口需要以下方法:
onDrawFrame(GL10 gl)
:这被称为绘制当前帧。 在这里,我们还将配置 OpenGL 透视图和视口(其在屏幕上的绘制区域),因为ARCubeRenderer
和CameraProjectionAdapter
的接口可能允许视角以逐帧方式改变。onSurfaceChanged(GL10 gl, int width, int height)
:当表面尺寸更改时调用。 出于我们的目的,此方法仅需要将宽度和高度存储在成员变量中。onSurfaceCreated(GL10 gl, EGLConfig config)
:在创建曲面或重新创建时调用。 通常,此方法配置我们以后不会更改的所有 OpenGL 设置。 换句话说,这些设置与透视图和绘制内容无关。
作为参数传递的GL10
实例提供对标准 OpenGL ES 1.0 功能的访问。 基本上,我们对两种 OpenGL 功能感兴趣:将矩阵转换应用于 3D 顶点,然后根据转换后的顶点绘制三角形。 我们的立方体将有 8 个顶点和 12 个三角形(6 个正方形,每个正方形 2 个三角形)。 我们将为每个顶点指定一种颜色,并将三角形描述为一系列顶点索引(每个三角形三个顶点索引)。
顶点,顶点颜色和三角形均存储在ByteBuffer
实例中。 由于我们仅支持一种样式的多维数据集,因此我们将使用ByteBuffer
的静态实例,以便多个ARCubeRenderer
实例可以共享它们。 作为成员变量,我们还希望ARFilter
提供多维数据集的姿势矩阵,CameraProjectionAdapter
提供投影矩阵,以及允许客户端代码调整多维数据集大小的比例。 ARCubeRenderer
及其变量的声明如下:
public class ARCubeRenderer implements GLSurfaceView.Renderer {
public ARFilter filter;
public CameraProjectionAdapter cameraProjectionAdapter;
public float scale = 1f;
private int mSurfaceWidth;
private int mSurfaceHeight;
private static final ByteBuffer VERTICES;
private static final ByteBuffer COLORS;
private static final ByteBuffer TRIANGLES;
由于顶点,颜色和三角形是static
变量,因此我们在static
块中对其进行初始化。 对于每个缓冲区,我们必须指定所需的字节数。 顶点占用96
个字节(8 个顶点,每个顶点 3 个浮点,每个浮点 4 个字节)。 我们为 2 个单位宽的立方体指定顶点。 填充缓冲区后,我们将其指针倒回到第一个索引。 代码如下:
static {
VERTICES = ByteBuffer.allocateDirect(96);
VERTICES.order(ByteOrder.nativeOrder());
VERTICES.asFloatBuffer().put(new float[] {
// Front.
-0.5f, -0.5f, 0.5f,
0.5f, -0.5f, 0.5f,
0.5f, 0.5f, 0.5f,
-0.5f, 0.5f, 0.5f,
// Back.
-0.5f, -0.5f, -0.5f,
0.5f, -0.5f, -0.5f,
0.5f, 0.5f, -0.5f,
-0.5f, 0.5f, -0.5f
});
VERTICES.position(0);
顶点颜色占用 32 个字节(每个顶点 8 个顶点,4 字节 RGBA 颜色)。 我们为每个顶点指定不同的颜色,如以下代码所示:
COLORS = ByteBuffer.allocateDirect(32);
final byte maxColor = (byte)255;
COLORS.put(new byte[] {
// Front.
maxColor, 0, 0, maxColor, // red
maxColor, maxColor, 0, maxColor, // yellow
maxColor, maxColor, 0, maxColor, // yellow
maxColor, 0, 0, maxColor, // red
// Back.
0, maxColor, 0, maxColor, // green
0, 0, maxColor, maxColor, // blue
0, 0, maxColor, maxColor, // blue
0, maxColor, 0, maxColor // green
});
COLORS.position(0);
三角形占用 36 个字节(12 个三角形,每个三角形 3 个顶点索引)。 从三角形的背面看时,OpenGL 要求我们以逆时针顺序指定每个三角形的顶点索引。 此排序称为反时钟绕组,在以下代码中可以看到:
TRIANGLES = ByteBuffer.allocateDirect(36);
TRIANGLES.put(new byte[] {
// Front.
0, 1, 2, 2, 3, 0,
3, 2, 6, 6, 7, 3,
7, 6, 5, 5, 4, 7,
// Back.
4, 5, 1, 1, 0, 4,
4, 0, 3, 3, 7, 4,
1, 5, 6, 6, 2, 1
});
TRIANGLES.position(0);
}
创建GLSurfaceView
的实例时,我们必须将其配置为使用完全透明的背景色,以便基础视频可见。 我们还必须指定我们希望 OpenGL 执行背面剔除,这意味着仅当三角形面对观察者时才会绘制三角形。 由于我们的立方体是不透明的,因此我们不希望 OpenGL 在其内部绘制! 此初始配置在onSurfaceCreated
方法中实现,如下所示:
@Override
public void onSurfaceCreated(final GL10 gl,
final EGLConfig config) {
gl.glClearColor(0f, 0f, 0f, 0f); // transparent
gl.glEnable(GL10.GL_CULL_FACE);
}
调整GLSurfaceView
实例的大小后,我们将记录新的大小,如以下代码所示:
@Override
public void onSurfaceChanged(final GL10 gl, final int width,
final int height) {
mSurfaceWidth = width;
mSurfaceHeight = height;
}
}
当绘制到GLSurfaceView
的实例时,我们首先清除所有先前的内容(将其替换为我们先前指定的完全透明的背景色)。 然后,我们检查投影矩阵和姿势矩阵是否可用。 如果可用,我们告诉 OpenGL 调整视口的大小,使用投影和姿势矩阵,最后移动和缩放立方体,以便在跟踪图像的前面有一个适当大小的立方体。 然后,我们向 OpenGL 提供的顶点和顶点颜色,并告诉它绘制三角形。 实现如下:
@Override
public void onDrawFrame(final GL10 gl) {
gl.glClear(GL10.GL_COLOR_BUFFER_BIT |
GL10.GL_DEPTH_BUFFER_BIT);
if (filter == null) {
return;
}
if (cameraProjectionAdapter == null) {
return;
}
float[] pose = filter.getGLPose();
if (pose == null) {
return;
}
final int adjustedWidth = (int)(mSurfaceHeight *
cameraProjectionAdapter.getAspectRatio());
final int marginX = (mSurfaceWidth - adjustedWidth) / 2;
gl.glViewport(marginX, 0, adjustedWidth, mSurfaceHeight);
gl.glMatrixMode(GL10.GL_PROJECTION);
float[] projection =
cameraProjectionAdapter.getProjectionGL();
gl.glLoadMatrixf(projection, 0);
gl.glMatrixMode(GL10.GL_MODELVIEW);
gl.glLoadMatrixf(pose, 0);
gl.glScalef(scale, scale, scale);
// Move the cube forward so that it is not halfway inside
// the image.
gl.glTranslatef(0f, 0f, 0.5f);
gl.glEnableClientState(GL10.GL_VERTEX_ARRAY);
gl.glEnableClientState(GL10.GL_COLOR_ARRAY);
gl.glVertexPointer(3, GL11.GL_FLOAT, 0, VERTICES);
gl.glColorPointer(4, GL11.GL_UNSIGNED_BYTE, 0, COLORS);
gl.glEnableClientState(GL10.GL_VERTEX_ARRAY);
gl.glEnableClientState(GL10.GL_COLOR_ARRAY);
gl.glDrawElements(GL10.GL_TRIANGLES, 36,
GL10.GL_UNSIGNED_BYTE, TRIANGLES);
gl.glDisableClientState(GL10.GL_VERTEX_ARRAY);
gl.glDisableClientState(GL10.GL_COLOR_ARRAY);
}
现在,我们准备好将 3D 跟踪和渲染集成到我们的应用中。
向CameraActivity
添加 3D 跟踪和渲染
我们需要对CameraActivity
进行一些更改,以使其与我们对ImageDetectionFilter
的更改以及与ARFilter
提供的新接口保持一致。 我们还需要修改活动的布局,使其包含GLSurfaceView
。 此GLSurfaceView
的适配器为ARCubeRenderer
。 ImageDetectionFilter
和ARCubeRenderer
方法将使用CameraProjectionAdapter
来协调其投影矩阵。
首先,让我们对CameraActivity
的成员变量进行以下更改:
// The filters.
private ARFilter[] mImageDetectionFilters;
private Filter[] mCurveFilters;
private Filter[] mMixerFilters;
private Filter[] mConvolutionFilters;
// ...
// The camera view.
private CameraBridgeViewBase mCameraView;
// An adapter between the video camera and projection // matrix.
private CameraProjectionAdapter mCameraProjectionAdapter;
// The renderer for 3D augmentations.
private ARCubeRenderer mARRenderer;
与往常一样,一旦 OpenCV 库被加载,我们就需要创建过滤器。 唯一的变化是我们需要将CameraProjectionAdapter
的实例传递给ImageDetectionFilter
的每个构造器,并且我们需要使用NoneARFilter
代替NoneFilter
。 代码如下:
public void onManagerConnected(final int status) {
switch (status) {
case LoaderCallbackInterface.SUCCESS:
Log.d(TAG, "OpenCV loaded successfully");
mCameraView.enableView();
mBgr = new Mat();
final ARFilter starryNight;
try {
// Define The Starry Night to be 1.0 units // tall
starryNight = new ImageDetectionFilter(
CameraActivity.this,
R.drawable.starry_night,
mCameraProjectionAdapter, 1.0);
} catch (IOException e) {
Log.e(TAG, "Failed to load drawable: " +
"starry_night");
e.printStackTrace();
break;
}
final ARFilter akbarHunting;
try {
// Define Akbar Hunting with Cheetahs to be 1.0
// units wide.
akbarHunting = new ImageDetectionFilter(
CameraActivity.this,
R.drawable.akbar_hunting_with_cheetahs,
mCameraProjectionAdapter, 1.0);
} catch (IOException e) {
Log.e(TAG, "Failed to load drawable: " +
"akbar_hunting_with_cheetahs");
e.printStackTrace();
break;
}
mImageDetectionFilters = new ARFilter[] {
new NoneARFilter(),
starryNight,
akbarHunting
};
// ...
}
}
其余更改属于onCreate
方法,在这里我们应该创建并配置GLSurfaceView
,ARCubeRenderer
和CameraProjectionAdapter
的实例。 该实现包括一些样板代码,以将GLSurfaceView
的实例覆盖在JavaCameraView
的实例之上。 这两个视图包含在名为FrameLayout
的标准 Android 布局小部件内。 设置布局后,我们需要一个Camera
实例和一个Camera.Parameters
实例,以便进行剩余的配置。 如前几章所述,Camera
实例是通过静态方法Camera.open
获得的。 代码如下:
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// ...
final FrameLayout layout = new FrameLayout(this);
layout.setLayoutParams(new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT));
setContentView(layout);
mCameraView = new JavaCameraView(this, mCameraIndex);
mCameraView.setCvCameraViewListener(this);
mCameraView.setLayoutParams(
new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT));
layout.addView(mCameraView);
GLSurfaceView glSurfaceView =
new GLSurfaceView(this);
glSurfaceView.getHolder().setFormat(
PixelFormat.TRANSPARENT);
glSurfaceView.setEGLConfigChooser(8, 8, 8, 8, 0, 0);
glSurfaceView.setZOrderOnTop(true);
glSurfaceView.setLayoutParams(new
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT));
layout.addView(glSurfaceView);
mCameraProjectionAdapter =
new CameraProjectionAdapter();
mARRenderer = new ARCubeRenderer();
mARRenderer.cameraProjectionAdapter =
mCameraProjectionAdapter;
// Earlier, we defined the printed image's size as
// 1.0 unit.
// Define the cube to be half this size.
mARRenderer.scale = 0.5f;
glSurfaceView.setRenderer(mARRenderer);
final Camera camera;
if (Build.VERSION.SDK_INT >=
Build.VERSION_CODES.GINGERBREAD) {
CameraInfo cameraInfo = new CameraInfo();
Camera.getCameraInfo(mCameraIndex, cameraInfo);
mIsCameraFrontFacing = (cameraInfo.facing ==
CameraInfo.CAMERA_FACING_FRONT);
mNumCameras = Camera.getNumberOfCameras();
camera = Camera.open(mCameraIndex);
} else { // pre-Gingerbread
// Assume there is only 1 camera and it is rear-facing.
mIsCameraFrontFacing = false;
mNumCameras = 1;
camera = Camera.open();
}
final Parameters parameters = camera.getParameters();
camera.release();
mSupportedImageSizes = parameters.getSupportedPreviewSizes();
final Size size = mSupportedImageSizes.get(mImageSizeIndex);
mCameraProjectionAdapter.setCameraParameters(
parameters, size);
// Earlier, we defined the printed image's size as // 1.0 unit.
// Leave the near and far clip distances at their default
// values, which are 0.1 (one-tenth the image size)
// and 10.0 (ten times the image size).
mCameraView.setMaxFrameSize(size.width, size.height);
mCameraView.setCvCameraViewListener(this);
}
就这样! 运行并测试 Second Sight。 激活ImageDetectionFilter
和实例中的实例时,将适当的打印图像保留在相机的前面,您应该会在图像前面看到一个彩色的立方体。 例如,请参见以下屏幕截图:
如果视频太不稳定(由于图像处理缓慢)或多维数据集没有紧密粘在跟踪图像的中心,请选择较低的相机分辨率,然后重试。 我们的参考图像分辨率较低(较大尺寸约为 600 像素),这对于低相机分辨率(例如800x600
或640x480
)应该是最佳的。 最后,请记住,您可能需要将 Android 设备保持一两秒钟才能使相机自动对焦在目标上。
了解有关 Android 3D 图形的更多信息
当然,在 3D 图形的世界中,绘制立方体类似于打印Hello World
。 只是一个基本演示。 尽管我们介绍了网格,变换和透视图,但还有许多其他主题我们根本没有涉及,例如照明,材料(逼真的表面)以及从 3D 艺术包中导入艺术家的作品。 为了更深入地了解 Android 上的 3D 图形,请阅读以下书籍:
- 《用于 Android 的 Pro OpenGL ES》 (Apress),作者:Mike Smithwick 和 Mayank Verma。 本书涵盖了 Android 的 OpenGL ES Java API。
- 《OpenGL ES 2.0 编程指南》(Addison-Wesley),作者:Aaftab Munshi,Dan Ginsburg 和 Dave Shreiner。 本书涵盖了用于 OpenGL ES 的跨平台 C++ API。
- Jens Grubert 和 Raphael Grasset 博士为 Android 应用开发开发的《增强现实》(Packt Publishing)。 本书介绍了如何使用 jMonkeyEngine(跨平台 Java 游戏引擎)在跟踪的真实图像上叠加 3D 图形。
关于 Android 游戏开发的书籍也很多,其中可能包括 3D 图形的良好介绍。
总结
现在,我们对 OpenCV 的 Java 接口和 Android SDK 进行了介绍。 我们介绍了 OpenCV 的几种主要用途,包括捕获相机输入,对图像应用效果,以 2D 和 3D 跟踪图像以及与 OpenGL 集成以增强现实渲染。
利用到目前为止所获得的知识,您可以继续使用 Java 开发其他 OpenCV 应用,无论是针对 Android 还是其他平台。 您可能还希望探索 OpenCV 的 C++ 版本,该版本也是跨平台的并且可以与 Android NDK 交互。 为了让您有一种选择的感觉,在下一章和最后一章中,我们将 Second Sight 的一部分转换为 C++,并通过称为 Java 本机接口(JNI)的互操作性层将此代码称为 C++ 代码。