利用 iOS 14 Vision 的手势估测功能 实作无接触即可滑动的 Tinder App
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):
try observation.recognizedPoints(.thumb)
try observation.recognizedPoints(.indexFinger)
try observation.recognizedPoints(.middleFinger)
try observation.recognizedPoints(.ringFinger)
try observation.recognizedPoints(.littleFinger)
这裡也有一个手腕的标记点,位置就在手腕的中心点位置。它并不属于上述的任何群组,而是在 all群组之中。你可以透过下列方式获得它:
let wristPoints = try observation.recognizedPoints(.all)
我们拿到上述的标记点阵列后,就可以这样将每个点独立抽取出来:
guard let thumbTipPoint = thumbPoints[.thumbTip],
let indexTipPoint = indexFingerPoints[.indexTip],
let middleTipPoint = middleFingerPoints[.middleTip],
let ringTipPoint = ringFingerPoints[.ringTip],
let littleTipPoint = littleFingerPoints[.littleTip],
let wristPoint = wristPoints[.wrist]else {return}
thumbIP、thumbMP、thumbCMC 是可以在 thumb 群组中获取的其他标记点,这也适用于其他手指。

每个独立的标记点物件,都包含了它们在 AVFoundation 座标系统中的位置及 confidence 阀值 (threshold)。
接著,我们可以在点跟点之间找到距离或角度的资讯,来创建手势处理器。举例来说,在 Apple 的范例 App 中,他们计算拇指与食指指尖的距离,来创建一个捏 (pinch) 的手势。
开始动工
现在我们已经了解视觉手势请求的基础知识,可以开始深入研究如何实作了!
开启 Xcode 并创建一个新的 UIKit App,请确认你有将开发目标设定为 iOS 14,并在 Info.plist 设置 NSCameraUsageDescription 字串。

我在前一篇文章介绍过如何建立一个带有动画的 Tinder 样式卡片,现在可以直接参考当时的最终程式码。
同样地,你可以在这裡参考 StackContainerView.swift 类别的程式码,这个类别是用来储存多个 Tinder 卡片的。
利用 AVFoundation 设置相机
接下来,让我们利用 Apple 的 AVFoundation 框架来建立一个客制化相机。
以下是 ViewController.swift 档案的程式码:
class ViewController: UIViewController, HandSwiperDelegate{
//MARK: - Properties
var modelData = [DataModel(bgColor: .systemYellow),
DataModel(bgColor: .systemBlue),
DataModel(bgColor: .systemRed),
DataModel(bgColor: .systemTeal),
DataModel(bgColor: .systemOrange),
DataModel(bgColor: .brown)]
var stackContainer : StackContainerView!
var buttonStackView: UIStackView!
var leftButton : UIButton!, rightButton : UIButton!
var cameraView : CameraView!
//MARK: - Init
override func loadView() {
view = UIView()
stackContainer = StackContainerView()
view.addSubview(stackContainer)
configureStackContainer()
stackContainer.translatesAutoresizingMaskIntoConstraints = false
addButtons()
configureNavigationBarButtonItem()
addCameraView()
}
override func viewDidLoad() {
super.viewDidLoad()
title = "HandPoseSwipe"
stackContainer.dataSource = self
}
private let videoDataOutputQueue = DispatchQueue(label: "CameraFeedDataOutput", qos: .userInteractive)
private var cameraFeedSession: AVCaptureSession?
private var handPoseRequest = VNDetectHumanHandPoseRequest()
let message = UILabel()
var handDelegate : HandSwiperDelegate?
func addCameraView()
{
cameraView = CameraView()
self.handDelegate = self
view.addSubview(cameraView)
cameraView.translatesAutoresizingMaskIntoConstraints = false
cameraView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
cameraView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
cameraView.widthAnchor.constraint(equalToConstant: 150).isActive = true
cameraView.heightAnchor.constraint(equalToConstant: 150).isActive = true
}
//MARK: - Configurations
func configureStackContainer() {
stackContainer.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
stackContainer.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -60).isActive = true
stackContainer.widthAnchor.constraint(equalToConstant: 300).isActive = true
stackContainer.heightAnchor.constraint(equalToConstant: 400).isActive = true
}
func addButtons()
{
//full source of UI setup at the end of this article
}
@objc func onButtonPress(sender: UIButton){
UIView.animate(withDuration: 2.0,
delay: 0,
usingSpringWithDamping: CGFloat(0.20),
initialSpringVelocity: CGFloat(6.0),
options: UIView.AnimationOptions.allowUserInteraction,
animations: {
sender.transform = CGAffineTransform.identity
},
completion: { Void in() })
if let firstView = stackContainer.subviews.last as? TinderCardView{
if sender.tag == 0{
firstView.leftSwipeClicked(stackContainerView: stackContainer)
}
else{
firstView.rightSwipeClicked(stackContainerView: stackContainer)
}
}
}
func configureNavigationBarButtonItem() {
navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Reset", style: .plain, target: self, action: #selector(resetTapped))
}
@objc func resetTapped() {
stackContainer.reloadData()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
do {
if cameraFeedSession == nil {
cameraView.previewLayer.videoGravity = .resizeAspectFill
try setupAVSession()
cameraView.previewLayer.session = cameraFeedSession
}
cameraFeedSession?.startRunning()
} catch {
AppError.display(error, inViewController: self)
}
}
override func viewWillDisappear(_ animated: Bool) {
cameraFeedSession?.stopRunning()
super.viewWillDisappear(animated)
}
func setupAVSession() throws {
// Select a front facing camera, make an input.
guard let videoDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) else {
throw AppError.captureSessionSetup(reason: "Could not find a front facing camera.")
}
guard let deviceInput = try? AVCaptureDeviceInput(device: videoDevice) else {
throw AppError.captureSessionSetup(reason: "Could not create video device input.")
}
let session = AVCaptureSession()
session.beginConfiguration()
session.sessionPreset = AVCaptureSession.Preset.high
// Add a video input.
guard session.canAddInput(deviceInput) else {
throw AppError.captureSessionSetup(reason: "Could not add video device input to the session")
}
session.addInput(deviceInput)
let dataOutput = AVCaptureVideoDataOutput()
if session.canAddOutput(dataOutput) {
session.addOutput(dataOutput)
// Add a video data output.
dataOutput.alwaysDiscardsLateVideoFrames = true
dataOutput.videoSettings = [kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_420YpCbCr8BiPlanarFullRange)]
dataOutput.setSampleBufferDelegate(self, queue: videoDataOutputQueue)
} else {
throw AppError.captureSessionSetup(reason: "Could not add video data output to the session")
}
session.commitConfiguration()
cameraFeedSession = session
}
}
在上面的程式码中包含了许多步骤,让我们一一来分析:
CameraView是一个客制化的 UIView 类别,用来在画面上呈现相机的内容。之后我们会进一步讲解这个类别。- 我们会在
setupAVSession()设置前置相机镜头,并将它设置为AVCaptureSession的输入。 - 接著,我们在
AVCaptureVideoDataOutput上呼叫setSampleBufferDelegate。
而 ViewController 类别要遵循 HandSwiperDelegate 协定:
protocol HandSwiperDelegate {
func thumbsDown()
func thumbsUp()
}
当侦测到手势后,我们将会触发相对应的方法。现在,让我们来看看要如何在捕捉到的影像中执行视觉请求。
在捕捉到的影像中执行视觉手势请求
在以下程式码中,我们为上述的 ViewController 创建了一个扩展 (extension),而这个扩展遵循 AVCaptureVideoDataOutputSampleBufferDelegate 协定:
extension ViewController: AVCaptureVideoDataOutputSampleBufferDelegate {
public func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
var thumbTip: CGPoint?
var wrist: CGPoint?
let handler = VNImageRequestHandler(cmSampleBuffer: sampleBuffer, orientation: .up, options: [:])
do {
// Perform VNDetectHumanHandPoseRequest
try handler.perform([handPoseRequest])
guard let observation = handPoseRequest.results?.first else {
cameraView.showPoints([])
return
}
// Get points for all fingers
let thumbPoints = try observation.recognizedPoints(.thumb)
let wristPoints = try observation.recognizedPoints(.all)
let indexFingerPoints = try observation.recognizedPoints(.indexFinger)
let middleFingerPoints = try observation.recognizedPoints(.middleFinger)
let ringFingerPoints = try observation.recognizedPoints(.ringFinger)
let littleFingerPoints = try observation.recognizedPoints(.littleFinger)
// Extract individual points from Point groups.
guard let thumbTipPoint = thumbPoints[.thumbTip],
let indexTipPoint = indexFingerPoints[.indexTip],
let middleTipPoint = middleFingerPoints[.middleTip],
let ringTipPoint = ringFingerPoints[.ringTip],
let littleTipPoint = littleFingerPoints[.littleTip],
let wristPoint = wristPoints[.wrist]
else {
cameraView.showPoints([])
return
}
let confidenceThreshold: Float = 0.3
guard thumbTipPoint.confidence > confidenceThreshold &&
indexTipPoint.confidence > confidenceThreshold &&
middleTipPoint.confidence > confidenceThreshold &&
ringTipPoint.confidence > confidenceThreshold &&
littleTipPoint.confidence > confidenceThreshold &&
wristPoint.confidence > confidenceThreshold
else {
cameraView.showPoints([])
return
}
// Convert points from Vision coordinates to AVFoundation coordinates.
thumbTip = CGPoint(x: thumbTipPoint.location.x, y: 1 - thumbTipPoint.location.y)
wrist = CGPoint(x: wristPoint.location.x, y: 1 - wristPoint.location.y)
DispatchQueue.main.async {
self.processPoints([thumbTip, wrist])
}
} catch {
cameraFeedSession?.stopRunning()
let error = AppError.visionError(error: error)
DispatchQueue.main.async {
error.displayInViewController(self)
}
}
}
}
值得注意的是,VNObservation 所回传的标记点是属于 Vision 座标系统的。我们必须将它们转换成 UIKit 座标,才能将它们绘制在萤幕上。
因此,我们透过以下方式将它们转换为 AVFoundation 座标:
wrist = CGPoint(x: wristPoint.location.x, y: 1 - wristPoint.location.y)
接著,我们将会把这些标记点传递给 processPoints 函式。为了精简流程,这裡我们只用了拇指指尖与手腕两个标记点来侦测手势。
以下是 processPoints 函式的程式码:
func processPoints(_ points: [CGPoint?]) {
let previewLayer = cameraView.previewLayer
var pointsConverted: [CGPoint] = []
for point in points {
pointsConverted.append(previewLayer.layerPointConverted(fromCaptureDevicePoint: point!))
}
let thumbTip = pointsConverted[0]
let wrist = pointsConverted[pointsConverted.count - 1]
let yDistance = thumbTip.y - wrist.y
if(yDistance > 50){
if self.restingHand{
self.restingHand = false
self.handDelegate?.thumbsDown()
}
}else if(yDistance < -50){
if self.restingHand{
self.restingHand = false
self.handDelegate?.thumbsUp()
}
}
else{
self.restingHand = true
}
cameraView.showPoints(pointsConverted)
}
我们可以利用以下这行程式码,将 AVFoundation 座标转换为 UIKit 座标:
previewLayer.layerPointConverted(fromCaptureDevicePoint: point!)
最后,我们会依据两个标记点之间的绝对阈值距离,触发对推叠卡片往左或往右滑动的动作。
我们利用 cameraView.showPoints(pointsConverted),在 CameraView 子图层上绘制一条连接两个标记点的直线。
以下是 CameraView 类别的完整程式码:
import UIKit
import AVFoundation
class CameraView: UIView {
private var overlayThumbLayer = CAShapeLayer()
var previewLayer: AVCaptureVideoPreviewLayer {
return layer as! AVCaptureVideoPreviewLayer
}
override class var layerClass: AnyClass {
return AVCaptureVideoPreviewLayer.self
}
override init(frame: CGRect) {
super.init(frame: frame)
setupOverlay()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupOverlay()
}
override func layoutSublayers(of layer: CALayer) {
super.layoutSublayers(of: layer)
if layer == previewLayer {
overlayThumbLayer.frame = layer.bounds
}
}
private func setupOverlay() {
previewLayer.addSublayer(overlayThumbLayer)
}
func showPoints(_ points: [CGPoint]) {
guard let wrist: CGPoint = points.last else {
// Clear all CALayers
clearLayers()
return
}
let thumbColor = UIColor.green
drawFinger(overlayThumbLayer, Array(points[0...1]), thumbColor, wrist)
}
func drawFinger(_ layer: CAShapeLayer, _ points: [CGPoint], _ color: UIColor, _ wrist: CGPoint) {
let fingerPath = UIBezierPath()
for point in points {
fingerPath.move(to: point)
fingerPath.addArc(withCenter: point, radius: 5, startAngle: 0, endAngle: 2 * .pi, clockwise: true)
}
fingerPath.move(to: points[0])
fingerPath.addLine(to: points[points.count - 1])
layer.fillColor = color.cgColor
layer.strokeColor = color.cgColor
layer.lineWidth = 5.0
layer.lineCap = .round
CATransaction.begin()
CATransaction.setDisableActions(true)
layer.path = fingerPath.cgPath
CATransaction.commit()
}
func clearLayers() {
let emptyPath = UIBezierPath()
CATransaction.begin()
CATransaction.setDisableActions(true)
overlayThumbLayer.path = emptyPath.cgPath
CATransaction.commit()
}
}
最终成果
最终 App 的成果会是这样:

结论
我们可以在许多情况下用到 Vision 新的手势估测请求,包括利用手势来进行自拍、绘制签名,甚至是辨识川普在演讲当中不同的手势。
你也可以将视觉请求与身体姿势请求串接在一起,用来建构更複杂的姿态。
你可以在 Github 储存库 参考这个专案的完整程式码。
这篇文章到此为止,感谢你的阅读!
文末推荐:iOS热门文集
利用 iOS 14 Vision 的手势估测功能 实作无接触即可滑动的 Tinder App的更多相关文章
- iOS 14.5 有啥新功能?Apple Watch 也能解锁 iPhone 了
转: iOS 14.5 有啥新功能?Apple Watch 也能解锁 iPhone 了 苹果今天发布了即将发布的 iOS 14.5 和 iPadOS 14.5 更新的第一个 Beta 版本,我们在其中 ...
- iOS利用Runtime自定义控制器POP手势动画
前言 苹果在iOS 7以后给导航控制器增加了一个Pop的手势,只要手指在屏幕边缘滑动,当前的控制器的视图就会跟随你的手指移动,当用户松手后,系统会判断手指拖动出来的大小来决定是否要执行控制器的Pop操 ...
- Python中利用函数装饰器实现备忘功能
Python中利用函数装饰器实现备忘功能 这篇文章主要介绍了Python中利用函数装饰器实现备忘功能,同时还降到了利用装饰器来检查函数的递归.确保参数传递的正确,需要的朋友可以参考下 " ...
- iOS中常用的手势
--前言 智能手机问世后的很长一段时间,各大手机厂商都在思考着智能手机应该怎么玩?也都在尝试着制定自己的一套操作方式.直到2007年乔布斯发布了iPhone手机,人们才认识到智能手机就应该这样玩. 真 ...
- Java 14带来了许多新功能
本文是作者翻译自java magazine的文章,我也将回持续的关注java的最新消息,即时和大家分享.如有翻译不准确的地方,欢迎大家留言,我将第一时间修改. Java 14包含比前两个发行版更多 ...
- IOS的七种手势
今天为大家介绍一下IOS 的七种手势,手势在开发中经常用到,所以就简单 通俗易懂的说下, 话不多说,直接看代码: // 初始化一个UIimageView UIImageView *imageView ...
- 李洪强iOS开发之添加手势
李洪强iOS开发之添加手势 02 - 添加手势
- ASP.net(C#)利用SQL Server实现注册和登陆功能
说说我现在吧,楼主现在从事的事IT行业,主攻DotNet技术:当然这次上博客园我也是有备而来,所有再次奉献鄙人拙作,以飨诸位,望诸位不吝赐教. 世界上大多数的工作都是熟练性的工种,编程也不例外,做久了 ...
- 利用MYSQL的函数实现用户登录功能,进出都是JSON(第二版)
利用MYSQL的函数实现用户登录功能,进出都是JSON(第二版) CREATE DEFINER=`root`@`%` FUNCTION `uc_session_login`( `reqjson` JS ...
随机推荐
- git中一些常见问题的解决
1. 解决: 先pull,执行git pull origin 分支名称:然后再执行 git push origin 分支名称 2.git报remote HTTP Basic Access denied ...
- c++通讯录管理系统
代码拷贝 #include<iostream> #include<string> #include<stdlib.h> #define MAX 1000 using ...
- XCTF.MISC 新手篇
目录 泪目 1.this_is_flag 2.pdf 3.如来十三掌 4.give_you_flag 5.坚持60s 6.gif 7.掀桌子 8.ext3 9.stegano 10.SimpleRAR ...
- Unity API笔记
1. 返回某范围的随机数 Random.Range method. static float Range(float min, float max); returns a float – return ...
- BUAA OS实验调试指南:从看懂到看开
一般的调试流程其实很简单:发现问题,稳定复现,确定临界条件,定位问题,修复问题,核查结果.迭代这个过程,形成一个闭环 老实说,OS的实验代码,开箱体验极差,程序跳来跳去,进了Lab4后还要考虑内核态切 ...
- 克鲁斯卡尔(Kruskal)算法(代码)
算法代码 C#代码 using System; using System.Linq; namespace Kruskal { class Program { static void Main(stri ...
- [Django框架之视图层]
[Django框架之视图层] 视图层 Django视图层, 视图就是Django项目下的views.py文件,它的内部是一系列的函数或者是类,用来专门处理客户端访问请求后处理请求并且返回相应的数据,相 ...
- [xml模块、hashlib模块、subprocess模块、os与sys模块、configparser模块]
[xml模块.hashlib模块.subprocess模块.os与sys模块.configparser模块] xml模块 XML:全称 可扩展标记语言,为了能够在不同的平台间继续数据的交换,使交换的数 ...
- SSM整合(ForestBlog为例)
SSM整合(ForestBlog为例) 原版本github地址 :https://github.com/saysky/ForestBlog 文中版本地址:https://github.com/Yans ...
- Ansible_使用文件模块将修改文件复制到受管主机
一.描述常用文件模块 1.常用文件模块 模块名称 模块说明 blockinfile 插入.更新或删除由可自定义标记线包围的多行文本块 copy 将文件从本地或远程计算机复制到受管主机上的某个位置.类似 ...