【Vision Pro】Hand Tracking でジェスチャーを検知する
初めに
今回は Apple Vision Pro の Hand Tracking を用いて、ユーザーのジェスチャーを検知する実装を行いたいと思います。ジェスチャーの検知ができれば、ユーザーが特定のジェスチャーをした際に何らかのフィードバックを与えることができるようになり、ユーザーとしてもより体験に没入できるようになります。
記事の対象者
- Swift 学習者
- Vision Pro でハンドジェスチャーを用いた実装をしたい方
目的
今回は先述の通り、Apple Vision Pro の Hand Tracking を用いて、ユーザーのジェスチャーを検知する実装を行いたいと思います。最終的には以下の動画のように伸ばしている指の本数をもとに足し算を行うような実装を行いたいと思います。
また、今回作成したプロジェクトは以下のGitHubにあげているので、よろしければご覧ください。
実装
実装は以下のステップで進めていきたいと思います。
- プロジェクトの作成、設定
- ViewModelの実装
- Viewの実装
- Appの実装
1. プロジェクトの作成、設定
プロジェクトを作成する際は以下のような項目を設定しておきます。
- Initial Scene: Window
- Immersive Space Renderer: RealityKit
- Immersive Space: Mixed
次にハンドトラッキングを使用するために、 info.plist に以下のような指定をしておきます。
これで、ハンドトラッキングが必要になった段階で自動的にユーザーの許可を求めるダイアログが表示されるようになります。
NSHandsTrackingUsageDescription: String: 指の動きを検知するためにハンドトラッキングを使用します
2. ViewModelの実装
まずは ViewModel を実装していきます。
ViewModel 側では、ハンドジェスチャーを使用するために必要な機能を実装します。
コード全文は長いので折りたたんでおきます。
ViewModelのコード全文
import SwiftUI
import RealityKit
import ARKit
enum HandGesture {
case notTracked
case closed
case custom(Int)
}
enum Hands {
case left
case right
}
enum Fingers {
case thumb
case index
case middle
case ring
case little
case wrist
}
enum JointType {
case tip
case pip
case dip
case mcp
}
@MainActor
class HandTrackingViewModel: ObservableObject {
@Published var leftHandGesture: HandGesture = .notTracked
@Published var rightHandGesture: HandGesture = .notTracked
@Published var displayedNumber: Int = 0
private let session = ARKitSession()
private var leftHandAnchor: HandAnchor?
private var rightHandAnchor: HandAnchor?
private var handTrackingProvider: HandTrackingProvider?
static let shared = HandTrackingViewModel()
init() {
handTrackingProvider = HandTrackingProvider()
}
func startHandTracking() async {
do {
try await session.run([handTrackingProvider!])
await handleHandUpdates()
} catch {
print("Failed to start session: \(error)")
}
}
private func handleHandUpdates() async {
for await update in handTrackingProvider!.anchorUpdates {
let handAnchor = update.anchor
guard handAnchor.isTracked else {
continue
}
if handAnchor.chirality == .left {
self.leftHandAnchor = handAnchor
self.leftHandGesture = self.determineHandGesture(hand: .left)
} else {
self.rightHandAnchor = handAnchor
self.rightHandGesture = self.determineHandGesture(hand: .right)
}
self.updateDisplayedNumber()
}
}
func determineHandGesture(hand: Hands) -> HandGesture {
let fingers: [Fingers] = [.thumb, .index, .middle, .ring, .little]
let extendedFingers = fingers.filter { isStraight(hand: hand, finger: $0) }
if extendedFingers.isEmpty {
return .closed
} else {
return .custom(extendedFingers.count)
}
}
private func updateDisplayedNumber() {
_ = displayedNumber
displayedNumber = [leftHandGesture, rightHandGesture].reduce(0) { total, gesture in
switch gesture {
case .custom(let count):
return total + count
default:
return total
}
}
}
private func extractPosition2D(_ transform: simd_float4x4?) -> CGPoint? {
guard let transform = transform else { return nil }
let position = transform.columns.3
return CGPoint(
x: CGFloat(position.x),
y: CGFloat(position.y)
)
}
private func isStraight(hand: Hands, finger: Fingers) -> Bool {
if finger == .thumb {
return isThumbExtended(hand: hand)
}
guard let tipPosition = extractPosition2D(jointPosition(hand: hand, finger: finger, joint: .tip)),
let secondPosition = extractPosition2D(jointPosition(hand: hand, finger: finger, joint: .pip)),
let posWrist = extractPosition2D(jointPosition(hand: hand, finger: .wrist, joint: .tip)) else {
return false
}
let tipToWristDistance = posWrist.distance(to: tipPosition)
let secondToWristDistance = posWrist.distance(to: secondPosition)
return secondToWristDistance < tipToWristDistance * 0.9
}
private func isThumbExtended(hand: Hands) -> Bool {
guard let thumbTipPosition = extractPosition2D(jointPosition(hand: hand, finger: .thumb, joint: .tip)),
let thumbIPPosition = extractPosition2D(jointPosition(hand: hand, finger: .thumb, joint: .pip)),
let thumbCMCPosition = extractPosition2D(jointPosition(hand: hand, finger: .thumb, joint: .mcp)) else {
return false
}
let distalSegmentLength = thumbIPPosition.distance(to: thumbTipPosition)
let proximalSegmentLength = thumbCMCPosition.distance(to: thumbIPPosition)
let extensionThreshold = 1.2
return distalSegmentLength > proximalSegmentLength * extensionThreshold
}
private func jointPosition(hand: Hands, finger: Fingers, joint: JointType) -> simd_float4x4? {
let anchor = hand == .left ? leftHandAnchor : rightHandAnchor
guard let skeleton = anchor?.handSkeleton else { return nil }
let jointName: HandSkeleton.JointName
switch (finger, joint) {
case (.thumb, .tip): jointName = .thumbTip
case (.thumb, .pip): jointName = .thumbIntermediateBase
case (.thumb, .mcp): jointName = .thumbIntermediateTip
case (.index, .tip): jointName = .indexFingerTip
case (.index, .pip): jointName = .indexFingerIntermediateBase
case (.middle, .tip): jointName = .middleFingerTip
case (.middle, .pip): jointName = .middleFingerIntermediateBase
case (.ring, .tip): jointName = .ringFingerTip
case (.ring, .pip): jointName = .ringFingerIntermediateBase
case (.little, .tip): jointName = .littleFingerTip
case (.little, .pip): jointName = .littleFingerIntermediateBase
case (.wrist, .tip): jointName = .wrist
default: return nil
}
return skeleton.joint(jointName).anchorFromJointTransform
}
}
extension CGPoint {
func distance(to point: CGPoint) -> CGFloat {
return sqrt(pow(x - point.x, 2) + pow(y - point.y, 2))
}
}
ViewModel の実装は以下に分けられます。
- ハンドジェスチャーに必要な enum の定義
- ViewModel内の変数と初期化処理の定義
- ハンドトラッキングの開始処理
- 手の動きの更新処理
- ハンドジェスチャーの検知
- それぞれの指が伸びているかどうかの判定処理
- CGPoint の extension
それぞれみていきます。
以下ではハンドジェスチャーに必要な enum の定義をしています。
それぞれの enum はコメントで書いている通りです。
// ハンドジェスチャーの状態
// トラックされていない or 手が閉じられている or 指が伸ばされている数
enum HandGesture {
case notTracked
case closed
case custom(Int)
}
// 左右の手の enum
enum Hands {
case left
case right
}
// それぞれのての指の enum
enum Fingers {
case thumb
case index
case middle
case ring
case little
case wrist
}
// それぞれの指の関節の enum
enum JointType {
case tip
case pip
case dip
case mcp
}
以下ではViewModel内の変数と初期化処理の定義を行っています。
初期状態で HandGesture
は .notTracked
、 displayedNumber
は 0
、 HandAnchor
はハンドトラッキングがされていないため、定義のみにしてあります。
shared
で HandTrackingViewModel()
を定義して外部からも使用しやすいように保持しておきます。
init()
では初期化処理として、ハンドトラッキングを行うための HandTrackingProvider
をインスタンス化しておきます。
@Published var leftHandGesture: HandGesture = .notTracked
@Published var rightHandGesture: HandGesture = .notTracked
@Published var displayedNumber: Int = 0
private let session = ARKitSession()
private var leftHandAnchor: HandAnchor?
private var rightHandAnchor: HandAnchor?
private var handTrackingProvider: HandTrackingProvider?
static let shared = HandTrackingViewModel()
init() {
handTrackingProvider = HandTrackingProvider()
}
以下ではハンドトラッキングの開始処理を記述しています。
ARKitSession
の run
メソッドで handTrackingProvider
を指定することでハンドトラッキングを有効化しています。
そのほかにも後述しますが、手の位置を認識して位置が更新された際に実行する handleHandUpdates
メソッドを実行しています。
func startHandTracking() async {
do {
try await session.run([handTrackingProvider!])
await handleHandUpdates()
} catch {
print("Failed to start session: \(error)")
}
}
以下のコードでは、手の動きの更新処理を実装しています。
手の動きの変化は handTrackingProvider
の anchorUpdates
から取得することができます。
anchorUpdates
からfor文で update を取り出して、その anchor
をもとに値の変更を行います。
handAnchor
の chirality
で右手左手の判定を行い、それぞれの handAnchor
を割り当てていきます。
private func handleHandUpdates() async {
for await update in handTrackingProvider!.anchorUpdates {
let handAnchor = update.anchor
guard handAnchor.isTracked else {
continue
}
if handAnchor.chirality == .left {
self.leftHandAnchor = handAnchor
self.leftHandGesture = self.determineHandGesture(hand: .left)
} else {
self.rightHandAnchor = handAnchor
self.rightHandGesture = self.determineHandGesture(hand: .right)
}
self.updateDisplayedNumber()
}
}
以下ではジェスチャーの判定と表示させる数字の更新のメソッドを実装しています。
determineHandGesture
メソッドでは、ジェスチャーの判定を行なっています。
後述の isStraight
を用いて特定の指が伸びているかどうかを判定し、その値に応じて HandGesture
の値を返しています。
updateDisplayedNumber
メソッドでは、ハンドジェスチャーに応じて画面に表示させる数字を更新しています。例えば、右手はグーで左手をパーにしておくと、伸びている指の本数は5本なので、 displayedNumber
が 5
に設定されます。
func determineHandGesture(hand: Hands) -> HandGesture {
let fingers: [Fingers] = [.thumb, .index, .middle, .ring, .little]
let extendedFingers = fingers.filter { isStraight(hand: hand, finger: $0) }
if extendedFingers.isEmpty {
return .closed
} else {
return .custom(extendedFingers.count)
}
}
private func updateDisplayedNumber() {
_ = displayedNumber
displayedNumber = [leftHandGesture, rightHandGesture].reduce(0) { total, gesture in
switch gesture {
case .custom(let count):
return total + count
default:
return total
}
}
}
以下ではそれぞれの指が伸びているかどうかの判定処理を実装しています。
extractPosition2D
メソッドでは simd_float4x4
形式の座標を CGPoint
に変換しています。
isStraight
メソッドでは親指以外の指に関して、各関節の位置を extractPosition2D
メソッドで取得し、その関節の間の距離から特定の指が伸びているかどうかを判定しています。
isThumbExtended
メソッドでは親指が伸びているかどうかを各関節の位置から判定しています。親指は他の指とは構造が異なるため、判定のメソッドも別で設けています。
jointPosition
メソッドでは、名前の通り各関節の位置を取得しています。このメソッドから得られた関節の位置をもとに isStraight
などの判定を行なっています。
private func extractPosition2D(_ transform: simd_float4x4?) -> CGPoint? {
guard let transform = transform else { return nil }
let position = transform.columns.3
return CGPoint(
x: CGFloat(position.x),
y: CGFloat(position.y)
)
}
private func isStraight(hand: Hands, finger: Fingers) -> Bool {
if finger == .thumb {
return isThumbExtended(hand: hand)
}
guard let tipPosition = extractPosition2D(jointPosition(hand: hand, finger: finger, joint: .tip)),
let secondPosition = extractPosition2D(jointPosition(hand: hand, finger: finger, joint: .pip)),
let posWrist = extractPosition2D(jointPosition(hand: hand, finger: .wrist, joint: .tip)) else {
return false
}
let tipToWristDistance = posWrist.distance(to: tipPosition)
let secondToWristDistance = posWrist.distance(to: secondPosition)
return secondToWristDistance < tipToWristDistance * 0.9
}
private func isThumbExtended(hand: Hands) -> Bool {
guard let thumbTipPosition = extractPosition2D(jointPosition(hand: hand, finger: .thumb, joint: .tip)),
let thumbIPPosition = extractPosition2D(jointPosition(hand: hand, finger: .thumb, joint: .pip)),
let thumbCMCPosition = extractPosition2D(jointPosition(hand: hand, finger: .thumb, joint: .mcp)) else {
return false
}
let distalSegmentLength = thumbIPPosition.distance(to: thumbTipPosition)
let proximalSegmentLength = thumbCMCPosition.distance(to: thumbIPPosition)
let extensionThreshold = 1.2
return distalSegmentLength > proximalSegmentLength * extensionThreshold
}
private func jointPosition(hand: Hands, finger: Fingers, joint: JointType) -> simd_float4x4? {
let anchor = hand == .left ? leftHandAnchor : rightHandAnchor
guard let skeleton = anchor?.handSkeleton else { return nil }
let jointName: HandSkeleton.JointName
switch (finger, joint) {
case (.thumb, .tip): jointName = .thumbTip
case (.thumb, .pip): jointName = .thumbIntermediateBase
case (.thumb, .mcp): jointName = .thumbIntermediateTip
case (.index, .tip): jointName = .indexFingerTip
case (.index, .pip): jointName = .indexFingerIntermediateBase
case (.middle, .tip): jointName = .middleFingerTip
case (.middle, .pip): jointName = .middleFingerIntermediateBase
case (.ring, .tip): jointName = .ringFingerTip
case (.ring, .pip): jointName = .ringFingerIntermediateBase
case (.little, .tip): jointName = .littleFingerTip
case (.little, .pip): jointName = .littleFingerIntermediateBase
case (.wrist, .tip): jointName = .wrist
default: return nil
}
return skeleton.joint(jointName).anchorFromJointTransform
}
最後に以下のコードでは CGPoint
の extension として distance
を設定しています。
このメソッドでは名前の通り CGPoint
の間の距離を返します。このメソッドを用いることで2点間の距離の測定が簡単になり、各関節の距離の測定などが楽になります。
extension CGPoint {
func distance(to point: CGPoint) -> CGFloat {
return sqrt(pow(x - point.x, 2) + pow(y - point.y, 2))
}
}
以下に distance
メソッドの例を提示します。
let point1 = CGPoint(x: 0, y: 0)
let point2 = CGPoint(x: 3, y: 4)
let distance = point1.distance(to: point2) // 結果は5.0
3. Viewの実装
次に View の実装を行います。
View の実装は以下の2ステップで行います。
- ContentView の編集
- ImmersiveView の編集
1. ContentView の編集
まずは ContentView
の編集を行います。
コードは以下の通りです。
import SwiftUI
import RealityKit
import RealityKitContent
struct HandTrackingCountContentView: View {
@StateObject private var viewModel = HandTrackingViewModel.shared
@State private var showImmersiveSpace = false
@State private var immersiveSpaceIsShown = false
@Environment(\.openImmersiveSpace) var openImmersiveSpace
@Environment(\.dismissImmersiveSpace) var dismissImmersiveSpace
var body: some View {
VStack {
HStack {
VStack {
Text("Left Hand")
Text("\(describeGesture(viewModel.leftHandGesture))")
.font(.title)
.padding()
HStack {
ForEach(0..<leftHandFingerCount, id: \.self) { _ in
Model3D(named: "Apple", bundle: realityKitContentBundle) { model in
model.resizable()
} placeholder: {
ProgressView()
}
.frame(width: 50, height: 50)
.frame(depth: 50)
}
}
}
.padding()
VStack {
Text("Right Hand")
Text("\(describeGesture(viewModel.rightHandGesture))")
.font(.title)
.padding()
HStack {
ForEach(0..<rightHandFingerCount, id: \.self) { _ in
Model3D(named: "Apple", bundle: realityKitContentBundle) { model in
model.resizable()
} placeholder: {
ProgressView()
}
.frame(width: 50, height: 50)
.frame(depth: 50)
}
}
}
.padding()
}
Text("Total")
Text("\(viewModel.displayedNumber)")
.font(.title)
HStack {
ForEach(0..<viewModel.displayedNumber, id: \.self) { _ in
Model3D(named: "Apple", bundle: realityKitContentBundle) { model in
model.resizable()
} placeholder: {
ProgressView()
}
.frame(width: 50, height: 50)
.frame(depth: 50)
}
}
Toggle("Hand Tracking", isOn: $showImmersiveSpace)
.toggleStyle(.button)
.padding(.top, 50)
}
.padding()
.onChange(of: showImmersiveSpace) { _, newValue in
Task {
if newValue {
switch await openImmersiveSpace(id: "HandTracking") {
case .opened:
immersiveSpaceIsShown = true
await viewModel.startHandTracking()
case .error, .userCancelled:
fallthrough
@unknown default:
immersiveSpaceIsShown = false
showImmersiveSpace = false
}
} else if immersiveSpaceIsShown {
await dismissImmersiveSpace()
immersiveSpaceIsShown = false
}
}
}
}
var leftHandFingerCount: Int {
if case .custom(let count) = viewModel.leftHandGesture {
return count
}
return 0
}
var rightHandFingerCount: Int {
if case .custom(let count) = viewModel.rightHandGesture {
return count
}
return 0
}
func describeGesture(_ gesture: HandGesture) -> String {
switch gesture {
case .notTracked:
return "Not tracked"
case .closed:
return "0"
case .custom(let count):
return "\(count)"
}
}
}
それぞれ詳しくみていきます。
以下では HandTrackingCountContentView
に必要な変数の定義を行っています。
具体的には、先程実装した HandTrackingViewModel
の shared
を viewModel
としたり、 ハンドトラッキングに必要な ImmersiveSpace
の State管理をするための変数定義をしたりしています。
@StateObject private var viewModel = HandTrackingViewModel.shared
@State private var showImmersiveSpace = false
@State private var immersiveSpaceIsShown = false
@Environment(\.openImmersiveSpace) var openImmersiveSpace
@Environment(\.dismissImmersiveSpace) var dismissImmersiveSpace
以下では左手の伸ばしている指の本数に応じて数字とリンゴのモデルを変える実装を行なっています。
具体的には、describeGesture(viewModel.leftHandGesture)
の部分で、 leftHandGesture
の状態に応じた表示を行い、 ForEach
の上限として leftHandFingerCount
を指定することで、左手の伸ばしている指の本数だけ Apple
という3Dモデルを表示させるようにしています。
なお、右手も同じような実装になっているため解説は割愛します。
VStack {
Text("Left Hand")
Text("\(describeGesture(viewModel.leftHandGesture))")
.font(.title)
.padding()
HStack {
ForEach(0..<leftHandFingerCount, id: \.self) { _ in
Model3D(named: "Apple", bundle: realityKitContentBundle) { model in
model.resizable()
} placeholder: {
ProgressView()
}
.frame(width: 50, height: 50)
.frame(depth: 50)
}
}
}
以下では左右の手で伸びている指の本数の合計値とリンゴのモデルを表示させています。
左右の合計値は viewModel.displayedNumber
で直接呼び出すことができます。
Text("Total")
Text("\(viewModel.displayedNumber)")
.font(.title)
HStack {
ForEach(0..<viewModel.displayedNumber, id: \.self) { _ in
Model3D(named: "Apple", bundle: realityKitContentBundle) { model in
model.resizable()
} placeholder: {
ProgressView()
}
.frame(width: 50, height: 50)
.frame(depth: 50)
}
}
以下では、 ImmersiveSpace を開いたり閉じたりするための Toggle ボタンを設置しています。
Toggle("Hand Tracking", isOn: $showImmersiveSpace)
.toggleStyle(.button)
.padding(.top, 50)
以下では先述の Toggle ボタンによって showImmersiveSpace
が切り替わった際の処理を記述しています。値が true の時は HandTracking
という名前の ImmersiveSpace
を開き、 viewModel.startHandTracking()
を実行することでハンドトラッキングを開始しています。
開く際にエラーが生じた場合、キャンセルされた場合、デフォルトの場合では immersiveSpaceIsShown
と showImmersiveSpace
の両方を false
に指定しています。
.onChange(of: showImmersiveSpace) { _, newValue in
Task {
if newValue {
switch await openImmersiveSpace(id: "HandTracking") {
case .opened:
immersiveSpaceIsShown = true
await viewModel.startHandTracking()
case .error, .userCancelled:
fallthrough
@unknown default:
immersiveSpaceIsShown = false
showImmersiveSpace = false
}
} else if immersiveSpaceIsShown {
await dismissImmersiveSpace()
immersiveSpaceIsShown = false
}
}
}
以下のコードでは左右の手のジェスチャーに応じて表示する数字を制御したり、ジェスチャーの状態を表すテキストを返したりするメソッドを定義しています。
var leftHandFingerCount: Int {
if case .custom(let count) = viewModel.leftHandGesture {
return count
}
return 0
}
var rightHandFingerCount: Int {
if case .custom(let count) = viewModel.rightHandGesture {
return count
}
return 0
}
func describeGesture(_ gesture: HandGesture) -> String {
switch gesture {
case .notTracked:
return "Not tracked"
case .closed:
return "0"
case .custom(let count):
return "\(count)"
}
}
これで ContentView 側の編集は完了です。
2. ImmersiveView の編集
次に ImmersiveView の編集を行います。
コードは以下の通りです。
以下では単純に「Hand Tracking」というテキストのみを表示させるビューを作成しています。 HandTrackin を行うためには ImmersiveSpace で行う必要があるかと思うので、特に重要な処理の実行は行なっていません。
import SwiftUI
import RealityKit
import ARKit
struct HandTrackingCountView: View {
var body: some View {
Text("Hand Tracking")
}
}
4. Appの実装
最後に App の実装を行います。
コードは以下の通りです。
ContentView と ImmersiveSpace のシンプルな作りで、 "HandTracking" という id で openImmersiveSpace
を実行することで、 HandTrackingCountView
が開くようになっています。
import SwiftUI
@main
struct HandTrackingSampleApp: App {
var body: some Scene {
WindowGroup {
HandTrackingCountContentView()
}
ImmersiveSpace(id: "HandTracking") {
HandTrackingCountView()
}
}
}
これで関連する実装は完了です。
「Apple」など自分で配置したいモデルを追加して、以上のコードで実行すると、先程紹介した以下の動画のようにトラッキングができているかと思います。
また、先述の通り今回作成したプロジェクトを以下で公開しているので、よろしければご覧ください。
まとめ
今回はARKitを用いてハンドトラッキングを実装しました。
ユーザーの手の情報自体は HandTrackingProvider
を ARKitSession
で実行するだけで取得できるので、取得だけだと簡単に実装できるかと思います。しかし、それぞれの関節の位置から手のジェスチャーを判定するのは難しかった印象があります。
さらに複雑なジェスチャーが必要な場合は手探りで実装する必要があるかと思います。
冒頭でも述べましたが、ユーザーの動きに合わせてアプリが変化していくことでよりユーザーにとって没入感のある体験を提供できると思うので、この辺りの実装に慣れていきたいところです。
参考
Discussion