Vision 框架在 2017 年推出,目的是为了让行动 App 开发者轻松利用电脑视觉演算法。具体来说,Vision 框架中包含了许多预先训练好的深度学习模型,同时也能充当包裹器 (wrapper) 来快速执行你客制化的 Core ML 模型。

Apple 在 iOS 13 推出了文字辨识 (Text Recognition) 和 VisionKit 来增强 OCR 之后,现在将重点转向了 iOS 14 Vision 框架中的运动与动作分类上。

在之前的文章中,我们说过 Vision 框架可以做轮廓侦测 (Contour Detection)、光流请求 (Optical Flow Request),并提供一系列离线影片处理 (offline video processing) 的工具。不过更重要的是,我们现在可以进行手部与身体姿势估测 (Hand and Body Pose Estimation) ,这无疑为扩增实境 (augmented reality) 与电脑视觉带来了更多可能性。

在这篇文章中,我们会以手势估测功能来建构一个 iOS App,在无接触 (touchless) 的情况下 ,App 也能够感应手势。

我之前已经发表过一篇文章,展示如何使用 ML Kit 的脸部侦测 API,来建构无接触滑动的 iOS App。我觉得这个雏型 (prototype) 非常好用,可以整合到像是 Tinder 或 Bumble 等这种约会 App 中。不过,这种方式可能会因为持续眨眼和转动头部,而造成眼睛疲劳或头痛。

因此,我们简单地扩展这个范例,透过手势代替触摸,来往左或往右滑动。毕竟近年来说,使用手机来生活得更懒惰、或是练习社交距离也是合理的。在我们深入研究之前,先来看看如何在 iOS 14 中创建一个视觉手势请求。

视觉手势估测

这个新的 VNDetectHumanHandPoseRequest,是一个基于影像的视觉请求,用来侦测一个人的手势。在型别为 VNHumanHandPoseObservation 的实例当中,这个请求会在每隻手上回传 21 个标记点 (Landmark Point)。我们可以设定 maximumHandCount 数值,来控制在视觉处理过程之中,每张帧最多可以侦测的数量。

我们可以简单地在实例中如此使用列举 (enum),来获得每隻手指的标记点阵列 (array):

  1. try observation.recognizedPoints(.thumb)
  2. try observation.recognizedPoints(.indexFinger)
  3. try observation.recognizedPoints(.middleFinger)
  4. try observation.recognizedPoints(.ringFinger)
  5. try observation.recognizedPoints(.littleFinger)

这裡也有一个手腕的标记点,位置就在手腕的中心点位置。它并不属于上述的任何群组,而是在 all群组之中。你可以透过下列方式获得它:

let wristPoints = try observation.recognizedPoints(.all)

我们拿到上述的标记点阵列后,就可以这样将每个点独立抽取出来:

  1. guard let thumbTipPoint = thumbPoints[.thumbTip],
  2. let indexTipPoint = indexFingerPoints[.indexTip],
  3. let middleTipPoint = middleFingerPoints[.middleTip],
  4. let ringTipPoint = ringFingerPoints[.ringTip],
  5. let littleTipPoint = littleFingerPoints[.littleTip],
  6. let wristPoint = wristPoints[.wrist]else {return}

thumbIPthumbMPthumbCMC 是可以在 thumb 群组中获取的其他标记点,这也适用于其他手指。

每个独立的标记点物件,都包含了它们在 AVFoundation 座标系统中的位置及 confidence 阀值 (threshold)。

接著,我们可以在点跟点之间找到距离或角度的资讯,来创建手势处理器。举例来说,在 Apple 的范例 App 中,他们计算拇指与食指指尖的距离,来创建一个捏 (pinch) 的手势。

开始动工

现在我们已经了解视觉手势请求的基础知识,可以开始深入研究如何实作了!

开启 Xcode 并创建一个新的 UIKit App,请确认你有将开发目标设定为 iOS 14,并在 Info.plist 设置 NSCameraUsageDescription 字串。

我在前一篇文章介绍过如何建立一个带有动画的 Tinder 样式卡片,现在可以直接参考当时的最终程式码

同样地,你可以在这裡参考 StackContainerView.swift 类别的程式码,这个类别是用来储存多个 Tinder 卡片的。

利用 AVFoundation 设置相机

接下来,让我们利用 Apple 的 AVFoundation 框架来建立一个客制化相机。

以下是 ViewController.swift 档案的程式码:

  1. class ViewController: UIViewController, HandSwiperDelegate{
  2. //MARK: - Properties
  3. var modelData = [DataModel(bgColor: .systemYellow),
  4. DataModel(bgColor: .systemBlue),
  5. DataModel(bgColor: .systemRed),
  6. DataModel(bgColor: .systemTeal),
  7. DataModel(bgColor: .systemOrange),
  8. DataModel(bgColor: .brown)]
  9. var stackContainer : StackContainerView!
  10. var buttonStackView: UIStackView!
  11. var leftButton : UIButton!, rightButton : UIButton!
  12. var cameraView : CameraView!
  13. //MARK: - Init
  14. override func loadView() {
  15. view = UIView()
  16. stackContainer = StackContainerView()
  17. view.addSubview(stackContainer)
  18. configureStackContainer()
  19. stackContainer.translatesAutoresizingMaskIntoConstraints = false
  20. addButtons()
  21. configureNavigationBarButtonItem()
  22. addCameraView()
  23. }
  24. override func viewDidLoad() {
  25. super.viewDidLoad()
  26. title = "HandPoseSwipe"
  27. stackContainer.dataSource = self
  28. }
  29. private let videoDataOutputQueue = DispatchQueue(label: "CameraFeedDataOutput", qos: .userInteractive)
  30. private var cameraFeedSession: AVCaptureSession?
  31. private var handPoseRequest = VNDetectHumanHandPoseRequest()
  32. let message = UILabel()
  33. var handDelegate : HandSwiperDelegate?
  34. func addCameraView()
  35. {
  36. cameraView = CameraView()
  37. self.handDelegate = self
  38. view.addSubview(cameraView)
  39. cameraView.translatesAutoresizingMaskIntoConstraints = false
  40. cameraView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
  41. cameraView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
  42. cameraView.widthAnchor.constraint(equalToConstant: 150).isActive = true
  43. cameraView.heightAnchor.constraint(equalToConstant: 150).isActive = true
  44. }
  45. //MARK: - Configurations
  46. func configureStackContainer() {
  47. stackContainer.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
  48. stackContainer.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -60).isActive = true
  49. stackContainer.widthAnchor.constraint(equalToConstant: 300).isActive = true
  50. stackContainer.heightAnchor.constraint(equalToConstant: 400).isActive = true
  51. }
  52. func addButtons()
  53. {
  54. //full source of UI setup at the end of this article
  55. }
  56. @objc func onButtonPress(sender: UIButton){
  57. UIView.animate(withDuration: 2.0,
  58. delay: 0,
  59. usingSpringWithDamping: CGFloat(0.20),
  60. initialSpringVelocity: CGFloat(6.0),
  61. options: UIView.AnimationOptions.allowUserInteraction,
  62. animations: {
  63. sender.transform = CGAffineTransform.identity
  64. },
  65. completion: { Void in() })
  66. if let firstView = stackContainer.subviews.last as? TinderCardView{
  67. if sender.tag == 0{
  68. firstView.leftSwipeClicked(stackContainerView: stackContainer)
  69. }
  70. else{
  71. firstView.rightSwipeClicked(stackContainerView: stackContainer)
  72. }
  73. }
  74. }
  75. func configureNavigationBarButtonItem() {
  76. navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Reset", style: .plain, target: self, action: #selector(resetTapped))
  77. }
  78. @objc func resetTapped() {
  79. stackContainer.reloadData()
  80. }
  81. override func viewDidAppear(_ animated: Bool) {
  82. super.viewDidAppear(animated)
  83. do {
  84. if cameraFeedSession == nil {
  85. cameraView.previewLayer.videoGravity = .resizeAspectFill
  86. try setupAVSession()
  87. cameraView.previewLayer.session = cameraFeedSession
  88. }
  89. cameraFeedSession?.startRunning()
  90. } catch {
  91. AppError.display(error, inViewController: self)
  92. }
  93. }
  94. override func viewWillDisappear(_ animated: Bool) {
  95. cameraFeedSession?.stopRunning()
  96. super.viewWillDisappear(animated)
  97. }
  98. func setupAVSession() throws {
  99. // Select a front facing camera, make an input.
  100. guard let videoDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) else {
  101. throw AppError.captureSessionSetup(reason: "Could not find a front facing camera.")
  102. }
  103. guard let deviceInput = try? AVCaptureDeviceInput(device: videoDevice) else {
  104. throw AppError.captureSessionSetup(reason: "Could not create video device input.")
  105. }
  106. let session = AVCaptureSession()
  107. session.beginConfiguration()
  108. session.sessionPreset = AVCaptureSession.Preset.high
  109. // Add a video input.
  110. guard session.canAddInput(deviceInput) else {
  111. throw AppError.captureSessionSetup(reason: "Could not add video device input to the session")
  112. }
  113. session.addInput(deviceInput)
  114. let dataOutput = AVCaptureVideoDataOutput()
  115. if session.canAddOutput(dataOutput) {
  116. session.addOutput(dataOutput)
  117. // Add a video data output.
  118. dataOutput.alwaysDiscardsLateVideoFrames = true
  119. dataOutput.videoSettings = [kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_420YpCbCr8BiPlanarFullRange)]
  120. dataOutput.setSampleBufferDelegate(self, queue: videoDataOutputQueue)
  121. } else {
  122. throw AppError.captureSessionSetup(reason: "Could not add video data output to the session")
  123. }
  124. session.commitConfiguration()
  125. cameraFeedSession = session
  126. }
  127. }

在上面的程式码中包含了许多步骤,让我们一一来分析:

  • CameraView 是一个客制化的 UIView 类别,用来在画面上呈现相机的内容。之后我们会进一步讲解这个类别。
  • 我们会在 setupAVSession() 设置前置相机镜头,并将它设置为 AVCaptureSession 的输入。
  • 接著,我们在 AVCaptureVideoDataOutput 上呼叫 setSampleBufferDelegate

ViewController 类别要遵循 HandSwiperDelegate 协定:

  1. protocol HandSwiperDelegate {
  2. func thumbsDown()
  3. func thumbsUp()
  4. }

当侦测到手势后,我们将会触发相对应的方法。现在,让我们来看看要如何在捕捉到的影像中执行视觉请求。

在捕捉到的影像中执行视觉手势请求

在以下程式码中,我们为上述的 ViewController 创建了一个扩展 (extension),而这个扩展遵循 AVCaptureVideoDataOutputSampleBufferDelegate 协定:

  1. extension ViewController: AVCaptureVideoDataOutputSampleBufferDelegate {
  2. public func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
  3. var thumbTip: CGPoint?
  4. var wrist: CGPoint?
  5. let handler = VNImageRequestHandler(cmSampleBuffer: sampleBuffer, orientation: .up, options: [:])
  6. do {
  7. // Perform VNDetectHumanHandPoseRequest
  8. try handler.perform([handPoseRequest])
  9. guard let observation = handPoseRequest.results?.first else {
  10. cameraView.showPoints([])
  11. return
  12. }
  13. // Get points for all fingers
  14. let thumbPoints = try observation.recognizedPoints(.thumb)
  15. let wristPoints = try observation.recognizedPoints(.all)
  16. let indexFingerPoints = try observation.recognizedPoints(.indexFinger)
  17. let middleFingerPoints = try observation.recognizedPoints(.middleFinger)
  18. let ringFingerPoints = try observation.recognizedPoints(.ringFinger)
  19. let littleFingerPoints = try observation.recognizedPoints(.littleFinger)
  20. // Extract individual points from Point groups.
  21. guard let thumbTipPoint = thumbPoints[.thumbTip],
  22. let indexTipPoint = indexFingerPoints[.indexTip],
  23. let middleTipPoint = middleFingerPoints[.middleTip],
  24. let ringTipPoint = ringFingerPoints[.ringTip],
  25. let littleTipPoint = littleFingerPoints[.littleTip],
  26. let wristPoint = wristPoints[.wrist]
  27. else {
  28. cameraView.showPoints([])
  29. return
  30. }
  31. let confidenceThreshold: Float = 0.3
  32. guard thumbTipPoint.confidence > confidenceThreshold &&
  33. indexTipPoint.confidence > confidenceThreshold &&
  34. middleTipPoint.confidence > confidenceThreshold &&
  35. ringTipPoint.confidence > confidenceThreshold &&
  36. littleTipPoint.confidence > confidenceThreshold &&
  37. wristPoint.confidence > confidenceThreshold
  38. else {
  39. cameraView.showPoints([])
  40. return
  41. }
  42. // Convert points from Vision coordinates to AVFoundation coordinates.
  43. thumbTip = CGPoint(x: thumbTipPoint.location.x, y: 1 - thumbTipPoint.location.y)
  44. wrist = CGPoint(x: wristPoint.location.x, y: 1 - wristPoint.location.y)
  45. DispatchQueue.main.async {
  46. self.processPoints([thumbTip, wrist])
  47. }
  48. } catch {
  49. cameraFeedSession?.stopRunning()
  50. let error = AppError.visionError(error: error)
  51. DispatchQueue.main.async {
  52. error.displayInViewController(self)
  53. }
  54. }
  55. }
  56. }

值得注意的是,VNObservation 所回传的标记点是属于 Vision 座标系统的。我们必须将它们转换成 UIKit 座标,才能将它们绘制在萤幕上。

因此,我们透过以下方式将它们转换为 AVFoundation 座标:

wrist = CGPoint(x: wristPoint.location.x, y: 1 - wristPoint.location.y)

接著,我们将会把这些标记点传递给 processPoints 函式。为了精简流程,这裡我们只用了拇指指尖与手腕两个标记点来侦测手势。

以下是 processPoints 函式的程式码:

  1. func processPoints(_ points: [CGPoint?]) {
  2. let previewLayer = cameraView.previewLayer
  3. var pointsConverted: [CGPoint] = []
  4. for point in points {
  5. pointsConverted.append(previewLayer.layerPointConverted(fromCaptureDevicePoint: point!))
  6. }
  7. let thumbTip = pointsConverted[0]
  8. let wrist = pointsConverted[pointsConverted.count - 1]
  9. let yDistance = thumbTip.y - wrist.y
  10. if(yDistance > 50){
  11. if self.restingHand{
  12. self.restingHand = false
  13. self.handDelegate?.thumbsDown()
  14. }
  15. }else if(yDistance < -50){
  16. if self.restingHand{
  17. self.restingHand = false
  18. self.handDelegate?.thumbsUp()
  19. }
  20. }
  21. else{
  22. self.restingHand = true
  23. }
  24. cameraView.showPoints(pointsConverted)
  25. }

我们可以利用以下这行程式码,将 AVFoundation 座标转换为 UIKit 座标:

  1. previewLayer.layerPointConverted(fromCaptureDevicePoint: point!)

最后,我们会依据两个标记点之间的绝对阈值距离,触发对推叠卡片往左或往右滑动的动作。

我们利用 cameraView.showPoints(pointsConverted),在 CameraView 子图层上绘制一条连接两个标记点的直线。

以下是 CameraView 类别的完整程式码:

  1. import UIKit
  2. import AVFoundation
  3. class CameraView: UIView {
  4. private var overlayThumbLayer = CAShapeLayer()
  5. var previewLayer: AVCaptureVideoPreviewLayer {
  6. return layer as! AVCaptureVideoPreviewLayer
  7. }
  8. override class var layerClass: AnyClass {
  9. return AVCaptureVideoPreviewLayer.self
  10. }
  11. override init(frame: CGRect) {
  12. super.init(frame: frame)
  13. setupOverlay()
  14. }
  15. required init?(coder: NSCoder) {
  16. super.init(coder: coder)
  17. setupOverlay()
  18. }
  19. override func layoutSublayers(of layer: CALayer) {
  20. super.layoutSublayers(of: layer)
  21. if layer == previewLayer {
  22. overlayThumbLayer.frame = layer.bounds
  23. }
  24. }
  25. private func setupOverlay() {
  26. previewLayer.addSublayer(overlayThumbLayer)
  27. }
  28. func showPoints(_ points: [CGPoint]) {
  29. guard let wrist: CGPoint = points.last else {
  30. // Clear all CALayers
  31. clearLayers()
  32. return
  33. }
  34. let thumbColor = UIColor.green
  35. drawFinger(overlayThumbLayer, Array(points[0...1]), thumbColor, wrist)
  36. }
  37. func drawFinger(_ layer: CAShapeLayer, _ points: [CGPoint], _ color: UIColor, _ wrist: CGPoint) {
  38. let fingerPath = UIBezierPath()
  39. for point in points {
  40. fingerPath.move(to: point)
  41. fingerPath.addArc(withCenter: point, radius: 5, startAngle: 0, endAngle: 2 * .pi, clockwise: true)
  42. }
  43. fingerPath.move(to: points[0])
  44. fingerPath.addLine(to: points[points.count - 1])
  45. layer.fillColor = color.cgColor
  46. layer.strokeColor = color.cgColor
  47. layer.lineWidth = 5.0
  48. layer.lineCap = .round
  49. CATransaction.begin()
  50. CATransaction.setDisableActions(true)
  51. layer.path = fingerPath.cgPath
  52. CATransaction.commit()
  53. }
  54. func clearLayers() {
  55. let emptyPath = UIBezierPath()
  56. CATransaction.begin()
  57. CATransaction.setDisableActions(true)
  58. overlayThumbLayer.path = emptyPath.cgPath
  59. CATransaction.commit()
  60. }
  61. }

最终成果

最终 App 的成果会是这样:

结论

我们可以在许多情况下用到 Vision 新的手势估测请求,包括利用手势来进行自拍、绘制签名,甚至是辨识川普在演讲当中不同的手势。

你也可以将视觉请求与身体姿势请求串接在一起,用来建构更複杂的姿态。

你可以在 Github 储存库 参考这个专案的完整程式码。

这篇文章到此为止,感谢你的阅读!

文末推荐:iOS热门文集

利用 iOS 14 Vision 的手势估测功能 实作无接触即可滑动的 Tinder App的更多相关文章

  1. iOS 14.5 有啥新功能?Apple Watch 也能解锁 iPhone 了

    转: iOS 14.5 有啥新功能?Apple Watch 也能解锁 iPhone 了 苹果今天发布了即将发布的 iOS 14.5 和 iPadOS 14.5 更新的第一个 Beta 版本,我们在其中 ...

  2. iOS利用Runtime自定义控制器POP手势动画

    前言 苹果在iOS 7以后给导航控制器增加了一个Pop的手势,只要手指在屏幕边缘滑动,当前的控制器的视图就会跟随你的手指移动,当用户松手后,系统会判断手指拖动出来的大小来决定是否要执行控制器的Pop操 ...

  3. Python中利用函数装饰器实现备忘功能

    Python中利用函数装饰器实现备忘功能 这篇文章主要介绍了Python中利用函数装饰器实现备忘功能,同时还降到了利用装饰器来检查函数的递归.确保参数传递的正确,需要的朋友可以参考下   " ...

  4. iOS中常用的手势

    --前言 智能手机问世后的很长一段时间,各大手机厂商都在思考着智能手机应该怎么玩?也都在尝试着制定自己的一套操作方式.直到2007年乔布斯发布了iPhone手机,人们才认识到智能手机就应该这样玩. 真 ...

  5. Java 14带来了许多新功能

    本文是作者翻译自java magazine的文章,我也将回持续的关注java的最新消息,即时和大家分享.如有翻译不准确的地方,欢迎大家留言,我将第一时间修改.   Java 14包含比前两个发行版更多 ...

  6. IOS的七种手势

    今天为大家介绍一下IOS 的七种手势,手势在开发中经常用到,所以就简单 通俗易懂的说下, 话不多说,直接看代码: // 初始化一个UIimageView UIImageView *imageView ...

  7. 李洪强iOS开发之添加手势

    李洪强iOS开发之添加手势 02 - 添加手势

  8. ASP.net(C#)利用SQL Server实现注册和登陆功能

    说说我现在吧,楼主现在从事的事IT行业,主攻DotNet技术:当然这次上博客园我也是有备而来,所有再次奉献鄙人拙作,以飨诸位,望诸位不吝赐教. 世界上大多数的工作都是熟练性的工种,编程也不例外,做久了 ...

  9. 利用MYSQL的函数实现用户登录功能,进出都是JSON(第二版)

    利用MYSQL的函数实现用户登录功能,进出都是JSON(第二版) CREATE DEFINER=`root`@`%` FUNCTION `uc_session_login`( `reqjson` JS ...

随机推荐

  1. 21.Quick QML-FileDialog、FolderDialog对话框

    1.FileDialog介绍 Qt Quick中的FileDialog文件对话框支持的平台有: 笔者使用的是Qt 5.8以上的版本,模块是import Qt.labs.platform 1.1. 它的 ...

  2. Django(2)python虚拟环境virtualenvwrapper

    python虚拟环境 虚拟环境(virtual environment),它是一个虚拟化,从电脑独立开辟出来的环境.通俗的来讲,虚拟环境就是借助虚拟机来把一部分内容独立出来,我们把这部分独立出来的东西 ...

  3. Java对象内存分布

    [deerhang] 创建对象的四种方式:new关键字.反射.Object.clone().unsafe方法 new和反射是通过调用构造器创建对象的,创建对象的时候使用invokespecial指令 ...

  4. 推荐一个不得不知道的 Visual Studio 快捷键

    不得不说,Visual Studio 内置了很多非常棒的快捷键,借助于这些快捷键我们甚至不需要再使用鼠标,就可以快速高效的编写代码,因此学习和熟悉这些快捷键是值得的. 其中有一个快捷键是我非常喜欢,也 ...

  5. PE文件中的输入表

    前言 PE文件中的输入表含有三个重要结构IID,IDT,IAT.PE文件为需要加载的DLL文件创建一个IID结构,一个DLL与一个IID对应.IDT是输入名称表,IAT输入地址表,在没有绑定输入的情况 ...

  6. SE_Work0_回顾与展望

    项目 内容 课程:北航-2020-春-软件工程 博客园班级博客 要求:阅读推荐博客并回答问题 热身作业阅读部分要求 我在这个课程的目标是 提升团队管理及合作能力,开发一项满意的工程项目 这个作业在哪个 ...

  7. synchronized运行原理以及优化

    线程安全问题 线程不安全: 当多线程并发访问临界资源时(可共享的对象),如果破坏原子操作,可能会造成数据不一致. 临界资源:共享资源(同一对象),一次仅允许一个线程使用,才可以保证其正确性. 原子操作 ...

  8. mysql 索引十连问| 剑指 offer - mysql

    以下是结合网上及此前面试时遇到的一些关于mysql索引的面试题. 若对mysql索引不太了解可先翻阅相关文章 大白话 mysql 之深入浅出索引原理 - 上 大白话 mysql 之深入浅出索引原理 - ...

  9. [Qt] 信号和槽

    信号与槽:是一种对象间的通信机制 观察者模式:当某个事件发生之后,比如,按钮检测到自己被点击了一下,它就会发出一个信号(signal).这种发出是没有目的的,类似广播.如果有对象对这个信号感兴趣,它就 ...

  10. vmware安装ubuntu ,一直处于end kernel panic - not syncing : corrupted stack end detected inside scheduler

    vmware安装ubuntu ,一直处于end kernel panic - not syncing : corrupted stack end detected inside scheduler y ...