说明
Scanning and Detecting 3D Objects的使用及注意点
ARKit的物体检测功能可用于3D检测. 比如识别一个小玩具或Nefertiti奈费尔提蒂(公元前14世纪埃及王后)的雕像.
不过在检测之前,需要先对物体进行扫瞄.
如何获得一个物体?
扫瞄物体类似于建立世界地图.可使用“Scanning and Detecting 3D Objects”示例程序.物体扫瞄的质量会影响物体检测的质量.
打开“Scanning and Detecting 3D Objects”示例程序,点击开始扫瞄物体,会自动生成并不断调整边界盒子.你也可以手动拖动来调整边界盒子的大小,或者用双指旋转盒子以避免个别部位突出边界.
接着是对各个面的扫瞄,希望从哪个面进行识别,就对哪个面进行扫瞄,可以离近点以获得更好的扫瞄效果.
最后可以拖动调整物体坐标原点.
为了检测扫瞄效果,可以将app切换到"检测"模式,试试能不能检测到刚才扫瞄的物体.
试着将摄像头对准别处,再移动回来,看不能顺利检测到物体;
试着将物体移动到其他地方,比如灯光不同的地方,测试能否重新检测到物体;
如果不能顺利识别,应调整重新进行扫瞄.
物体扫瞄
如果对效果满意,就可以分享到Xcode中,以供检测识别.
适合追踪的物体
适合用于追踪的物体应有以下特点:
- 刚性物体
- 纹理细节丰富
- 无表面反射
- 无透明效果
物体追踪API
API类似于图片追踪
// Load Objects from Assets
let objects = ARReferenceObjects.referenceObjects(inGroupNamed: "Object", bundle: Bundle.main)
guard let objects = objects else {
print("Error loading objects")
return
}
// Create a session configuration
let configuration = ARWorldTrackingConfiguration()
configuration.detectionObjects = objects
// Run the session
let session = ARSession() session.run(configuration)
获取结果
检测结果也是在代理方法中获取
func session(_ session: ARSession, didUpdate anchors: [ARAnchor]) {
for anchor in anchors {
if let objectAnchor = anchor as? ARObjectAnchor {
let objectName = objectAnchor.referenceObject.name ?? ""
print("Object found: \(objectName)")
}
}
}
物体追踪vs.世界地图重定位
你可能会觉得物体追踪
和世界地图重定位
有点类似,但其实它们还是有一些不同的.
在物体追踪
中最重要的是给出识别到的物体,在世界坐标系中的位置;而在世界地图重定位
中则是给出摄像机在世界地图中的位置;
另外,物体追踪
中可以识别多个物体.最适用于识别追踪放在桌面上或家具上的物体,因为下面的物体可提供有效支持有助识别.
代码解析
下面我们来看看整个项目的代码,先从代码整体逻辑开始.
整体逻辑
主要的类,及其主要作用如下
各个类之间的逻辑如下
可以看到以
ViewController
类为流程控制中枢,以State
来控制整个流程,利用其Set方法来自动处理相应刷新.
enum State {
case startARSession
case notReady
case scanning
case testing
}
我们可以看到最主要的流程是Scan流程,由Scan
类来管理.在这个流程:
- 我们要根据ARSession的回调,来不断更新被扫瞄物体,被扫瞄的点云,还有其坐标轴和边界盒;
- 要根据手势的拖动,来不断更新被扫瞄物体,被扫瞄的点云,还有其坐标轴和边界盒;
而
Scan
类中,也是依靠一个State
来控制扫瞄流程中的各个步骤的,同样也利用Set方法来自动处理相应刷新.
enum State {
case ready
case defineBoundingBox
case scanning
case adjustingOrigin
}
其次还有TestRun流程,但相应要简单一些,没有了复杂的3D UI处理,也没有再使用State
来控制流程.
几个有意思的方法
BoundingBox
类中的fitOverPointCloud
方法
告诉我们应该如何过滤识别出的特征点,太远的点不要,太稀疏的点不要.最后转换坐标系并更新边界盒的大小:
func fitOverPointCloud(_ pointCloud: ARPointCloud, focusPoint: float3?) {
var filteredPoints: [vector_float3] = []
for point in pointCloud.points {
if let focus = focusPoint {
// Skip this point if it is more than maxDistanceToFocusPoint meters away from the focus point.
// 如果该点距离焦点大于maxDistanceToFocusPoint,忽略该点.
let distanceToFocusPoint = length(point - focus)
if distanceToFocusPoint > maxDistanceToFocusPoint {
continue
}
}
// Skip this point if it is an outlier (not at least 3 other points closer than 3 cm)
// 如果是异常点,跳过该点(某个点周围3cm内至少要有3个点的其他点,否则不要该点)
var nearbyPoints = 0
for otherPoint in pointCloud.points {
if distance(point, otherPoint) < 0.03 {
nearbyPoints += 1
if nearbyPoints >= 3 {
filteredPoints.append(point)
break
}
}
}
}
guard !filteredPoints.isEmpty else { return }
var localMin = -extent / 2
var localMax = extent / 2
for point in filteredPoints {
// The bounding box is in local coordinates, so convert point to local, too.
// 边界盒是在本地坐标系中,所以也需要将点转换到本地坐标系中.
let localPoint = self.simdConvertPosition(point, from: nil)
localMin = min(localMin, localPoint)
localMax = max(localMax, localPoint)
}
// Update the position & extent of the bounding box based on the new min & max values.
// 根据最新的最小值&最大值,更新边界盒的位置和面积.
self.simdPosition += (localMax + localMin) / 2
self.extent = localMax - localMin
}
BoundingBox
类中的updateCapturingProgress
方法
展示了如何计算各个图块内已经识别出的特征点的数目,如果已经足够多特征点了,图块变为黄色,识别下一个图块.该方法还考虑了性能问题:
func updateCapturingProgress() {
guard let camera = sceneView.pointOfView, !self.contains(camera.simdWorldPosition) else { return }
frameCounter += 1
// Add new hit test rays at a lower frame rate to keep the list of previous rays
// at a reasonable size.
// 添加新的低帧率命中测试射线,以将先前射线列表保持在合理的大小.(每一帧都发射一个射线的话,射线列表就太多了)
if frameCounter % 20 == 0 {
frameCounter = 0
// Create a new hit test ray. A line segment defined by its start and end point
// is used to hit test against bounding box tiles. The ray's length allows for
// intersections if the user is no more than five meters away from the bounding box.
// 创建一个新的命中测试射线.该线段起点是发出点(相机),终点则是命中边界盒图块的点.射线的长度决定了能否交互:如果用户距离边界盒超过五米,则不允许交互.
let currentRay = Ray(from: camera, length: 5.0)
// Only remember the ray if it hit the bounding box,
// and the hit location is significantly different from all previous hit locations.
// 只有命中边界盒的射线才会被记录下来,并且命中位置必须明显不同与先前的其他命中位置.
if let (_, hitLocation) = tile(hitBy: currentRay) {
if isHitLocationDifferentFromPreviousRayHitTests(hitLocation) {
cameraRaysAndHitLocations.append((ray: currentRay, hitLocation: hitLocation))
}
}
}
// Update tiles at a frame rate that provides a trade-off between responsiveness and performance.
// 以低帧率更新图块,以在响应和性能之间取得平衡.
guard frameCounter % 10 == 0, !isUpdatingCapturingProgress else { return }
self.isUpdatingCapturingProgress = true
var capturedTiles: [Tile] = []
// Perform hit tests with all previous rays.
// 用以前所有的射线,执行命中测试.
for hitTest in self.cameraRaysAndHitLocations {
if let (tile, _) = self.tile(hitBy: hitTest.ray) {
capturedTiles.append(tile)
tile.isCaptured = true
}
}
for (_, side) in self.sides {
side.tiles.forEach {
if !capturedTiles.contains($0) {
$0.isCaptured = false
}
}
}
// Update the opacity of all tiles.
// 更新所有图块的不透明度.
for (_, side) in self.sides {
side.tiles.forEach { $0.updateVisualization() }
}
// Update scan percentage for all sides, except the bottom
// 更新所有面的扫瞄进度,除了底面.
var sum: Float = 0
for (pos, side) in self.sides where pos != .bottom {
sum += side.completion / 5.0
}
let progressPercentage: Int = min(Int(floor(sum * 100)), 100)
if self.progressPercentage != progressPercentage {
self.progressPercentage = progressPercentage
NotificationCenter.default.post(name: BoundingBox.scanPercentageChangedNotification,
object: self,
userInfo: [BoundingBox.scanPercentageUserInfoKey: progressPercentage])
}
self.isUpdatingCapturingProgress = false
}
Snapping
中的自动捕捉对齐/自动吸附功能
Snapping
中对BoundingBox
和ObjectOrigin
添加了类扩展,提供了距离足够近时,自动捕捉对齐的功能,ObjectOrigin
中还可以处理旋转角度的捕捉对齐.以BoundingBox
的自动对齐水平面方法为例:
extension BoundingBox {
func snapToHorizontalPlane() {
// Snap to align with horizontal plane if y-position is close enough
// 如果y方向非常接近,则按水平平面捕捉对齐.
let snapThreshold: Float = 0.01
var isWithinSnapThreshold = false
let bottomY = simdWorldPosition.y - extent.y / 2
guard let currentFrame = ViewController.instance!.sceneView.session.currentFrame else { return }
for anchor in currentFrame.anchors where anchor is ARPlaneAnchor {
let distanceFromHorizontalPlane = abs(bottomY - anchor.transform.position.y)
if distanceFromHorizontalPlane < snapThreshold {
isWithinSnapThreshold = true
self.simdWorldPosition.y = anchor.transform.position.y + extent.y / 2
// Provide haptic feedback when reaching the snapThreshold for the first time
// 当第一次达到snapThreshold时,提供触觉反馈.
if !isSnappedToHorizontalPlane {
isSnappedToHorizontalPlane = true
playHapticFeedback()
}
}
}
if !isWithinSnapThreshold {
isSnappedToHorizontalPlane = false
}
}
}
其他小技巧
Utilities
中有不少矩阵转换操作,非常便利.其中dragPlaneTransform
方法特地添加了大量注释,解释这样构造平面的好处,非常值得参考学习.ObjectOriginAxis
中,为了让用户更容易选中并操作坐标轴,特地对坐标轴的几何体进行了放大处理,使其更容易选中.BoundingBoxSide
中,边界盒的每个面上,有多少个图块是在setupTiles
方法中动态计算出来的.
最后
代码github.com/XanderXu/Sc…及README.md文件翻译已发布于github.