Skip to content

Commit bf4e360

Browse files
committed
Improve gesture tracking responsiveness
1 parent 35143af commit bf4e360

7 files changed

Lines changed: 732 additions & 115 deletions

File tree

TrackpadAir/Helper/ImageHelper.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,18 @@ import Foundation
99
import AVFoundation
1010
import Cocoa
1111

12+
private let imageContext = CIContext(options: [.cacheIntermediates: false])
13+
1214
func NSImageFromSampleBuffer(_ sampleBuffer: CMSampleBuffer) -> NSImage? {
1315
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
1416
return nil
1517
}
1618

1719
let ciImage = CIImage(cvImageBuffer: pixelBuffer)
18-
let context = CIContext(options: nil)
1920
let width = CVPixelBufferGetWidth(pixelBuffer)
2021
let height = CVPixelBufferGetHeight(pixelBuffer)
2122

22-
guard let cgImage = context.createCGImage(ciImage, from: CGRect(x: 0, y: 0, width: width, height: height)) else {
23+
guard let cgImage = imageContext.createCGImage(ciImage, from: CGRect(x: 0, y: 0, width: width, height: height)) else {
2324
return nil
2425
}
2526

TrackpadAir/Manager/HandGestureProcessor.swift

Lines changed: 185 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -91,19 +91,180 @@ enum HandGestureState: String, CaseIterable, Identifiable {
9191
}
9292
}
9393

94-
class HandGestureProcessor {
94+
struct HandGestureUpdate {
95+
let gesture: HandGestureState
96+
let fingerTips: FingerTips?
97+
let didTransition: Bool
98+
}
99+
100+
final class HandGestureProcessor {
101+
struct Configuration {
102+
let smoothingAlpha: CGFloat
103+
let activationThreshold: CGFloat
104+
let releaseThreshold: CGFloat
105+
let confirmationFrames: Int
106+
let missingFramesBeforeReset: Int
107+
108+
static let balanced = Configuration(
109+
smoothingAlpha: 0.55,
110+
activationThreshold: 0.34,
111+
releaseThreshold: 0.46,
112+
confirmationFrames: 2,
113+
missingFramesBeforeReset: 2
114+
)
115+
}
116+
117+
private let configuration: Configuration
118+
private var filteredFrame: HandPoseFrame?
119+
private var stableGesture: HandGestureState = .none
120+
private var candidateGesture: HandGestureState?
121+
private var candidateFrameCount = 0
122+
private var missingFrameCount = 0
95123

96-
static let threshold: Double = 6
124+
init(configuration: Configuration = .balanced) {
125+
self.configuration = configuration
126+
}
127+
128+
func process(frame: HandPoseFrame?) -> HandGestureUpdate {
129+
guard let frame else {
130+
return processMissingFrame()
131+
}
132+
133+
missingFrameCount = 0
134+
let smoothedFrame = smooth(frame)
135+
filteredFrame = smoothedFrame
136+
137+
let detectedGesture: HandGestureState
138+
if stableGesture != .none,
139+
Self.matches(
140+
stableGesture,
141+
frame: smoothedFrame,
142+
threshold: configuration.releaseThreshold
143+
) {
144+
detectedGesture = stableGesture
145+
} else {
146+
detectedGesture = Self.classify(
147+
frame: smoothedFrame,
148+
threshold: configuration.activationThreshold
149+
)
150+
}
97151

98-
static func process(fingerTips: FingerTips) -> HandGestureState {
152+
let didTransition = confirm(detectedGesture)
153+
return HandGestureUpdate(
154+
gesture: stableGesture,
155+
fingerTips: smoothedFrame.fingerTips,
156+
didTransition: didTransition
157+
)
158+
}
159+
160+
func reset() {
161+
filteredFrame = nil
162+
stableGesture = .none
163+
candidateGesture = nil
164+
candidateFrameCount = 0
165+
missingFrameCount = 0
166+
}
167+
168+
static func classify(
169+
frame: HandPoseFrame,
170+
threshold: CGFloat = Configuration.balanced.activationThreshold
171+
) -> HandGestureState {
99172
for definition in GestureDefinition.priorityOrdered {
100-
if definition.matches(fingerTips: fingerTips, threshold: threshold) {
173+
if definition.matches(frame: frame, threshold: threshold) {
101174
return definition.state
102175
}
103176
}
104-
105177
return .none
106178
}
179+
180+
private static func matches(
181+
_ gesture: HandGestureState,
182+
frame: HandPoseFrame,
183+
threshold: CGFloat
184+
) -> Bool {
185+
guard let definition = GestureDefinition.priorityOrdered.first(where: {
186+
$0.state == gesture
187+
}) else {
188+
return false
189+
}
190+
return definition.matches(frame: frame, threshold: threshold)
191+
}
192+
193+
private func processMissingFrame() -> HandGestureUpdate {
194+
missingFrameCount += 1
195+
guard missingFrameCount >= configuration.missingFramesBeforeReset else {
196+
return HandGestureUpdate(
197+
gesture: stableGesture,
198+
fingerTips: filteredFrame?.fingerTips,
199+
didTransition: false
200+
)
201+
}
202+
203+
let didTransition = stableGesture != .none
204+
filteredFrame = nil
205+
stableGesture = .none
206+
candidateGesture = nil
207+
candidateFrameCount = 0
208+
return HandGestureUpdate(
209+
gesture: .none,
210+
fingerTips: nil,
211+
didTransition: didTransition
212+
)
213+
}
214+
215+
private func confirm(_ detectedGesture: HandGestureState) -> Bool {
216+
guard detectedGesture != stableGesture else {
217+
candidateGesture = nil
218+
candidateFrameCount = 0
219+
return false
220+
}
221+
222+
if candidateGesture == detectedGesture {
223+
candidateFrameCount += 1
224+
} else {
225+
candidateGesture = detectedGesture
226+
candidateFrameCount = 1
227+
}
228+
229+
guard candidateFrameCount >= configuration.confirmationFrames else {
230+
return false
231+
}
232+
233+
stableGesture = detectedGesture
234+
candidateGesture = nil
235+
candidateFrameCount = 0
236+
return true
237+
}
238+
239+
private func smooth(_ frame: HandPoseFrame) -> HandPoseFrame {
240+
guard let previous = filteredFrame else {
241+
return frame
242+
}
243+
244+
return HandPoseFrame(
245+
fingerTips: FingerTips(
246+
thumb: smooth(previous.fingerTips.thumb, frame.fingerTips.thumb),
247+
index: smooth(previous.fingerTips.index, frame.fingerTips.index),
248+
middle: smooth(previous.fingerTips.middle, frame.fingerTips.middle),
249+
ring: smooth(previous.fingerTips.ring, frame.fingerTips.ring),
250+
little: smooth(previous.fingerTips.little, frame.fingerTips.little)
251+
),
252+
handScale: interpolate(previous.handScale, frame.handScale),
253+
confidence: frame.confidence,
254+
aspectRatio: frame.aspectRatio
255+
)
256+
}
257+
258+
private func smooth(_ previous: CGPoint, _ current: CGPoint) -> CGPoint {
259+
CGPoint(
260+
x: interpolate(previous.x, current.x),
261+
y: interpolate(previous.y, current.y)
262+
)
263+
}
264+
265+
private func interpolate(_ previous: CGFloat, _ current: CGFloat) -> CGFloat {
266+
previous + configuration.smoothingAlpha * (current - previous)
267+
}
107268
}
108269

109270
private struct GestureDefinition {
@@ -142,25 +303,36 @@ private struct GestureDefinition {
142303
self.excludedPairs = excludedPairs
143304
}
144305

145-
func matches(fingerTips: FingerTips, threshold: Double) -> Bool {
306+
func matches(frame: HandPoseFrame, threshold: CGFloat) -> Bool {
307+
let distanceThreshold = frame.handScale * threshold
146308
let requiredPairsMatch = requiredPairs.allSatisfy {
147-
distance(fingerTips: fingerTips, pair: $0) < threshold
309+
distance(
310+
fingerTips: frame.fingerTips,
311+
pair: $0,
312+
aspectRatio: frame.aspectRatio
313+
) < distanceThreshold
148314
}
149315
let excludedPairsMatch = excludedPairs.allSatisfy {
150-
distance(fingerTips: fingerTips, pair: $0) >= threshold
316+
distance(
317+
fingerTips: frame.fingerTips,
318+
pair: $0,
319+
aspectRatio: frame.aspectRatio
320+
) >= distanceThreshold
151321
}
152322

153323
return requiredPairsMatch && excludedPairsMatch
154324
}
155325

156326
private func distance(
157327
fingerTips: FingerTips,
158-
pair: (FingerTipKeyPath, FingerTipKeyPath)
328+
pair: (FingerTipKeyPath, FingerTipKeyPath),
329+
aspectRatio: CGFloat
159330
) -> Double {
160-
CoordinateHelper.distance(
161-
p1: pair.0.point(in: fingerTips),
162-
p2: pair.1.point(in: fingerTips)
163-
)
331+
let p1 = pair.0.point(in: fingerTips)
332+
let p2 = pair.1.point(in: fingerTips)
333+
let dx = (p1.x - p2.x) * aspectRatio
334+
let dy = p1.y - p2.y
335+
return hypot(dx, dy)
164336
}
165337
}
166338

0 commit comments

Comments
 (0)