diff --git a/Gaze/Services/EyeTracking/EyeTrackingService.swift b/Gaze/Services/EyeTracking/EyeTrackingService.swift index 3c2c300..474cc95 100644 --- a/Gaze/Services/EyeTracking/EyeTrackingService.swift +++ b/Gaze/Services/EyeTracking/EyeTrackingService.swift @@ -65,7 +65,8 @@ class EyeTrackingService: NSObject, ObservableObject { faceWidthScaleMax: 1.4, eyeBoundsHorizontalPadding: TrackingConfig.default.eyeBoundsHorizontalPadding, eyeBoundsVerticalPaddingUp: TrackingConfig.default.eyeBoundsVerticalPaddingUp, - eyeBoundsVerticalPaddingDown: TrackingConfig.default.eyeBoundsVerticalPaddingDown + eyeBoundsVerticalPaddingDown: TrackingConfig.default.eyeBoundsVerticalPaddingDown, + eyeBoundsSmoothing: TrackingConfig.default.eyeBoundsSmoothing ) processor.updateConfig(config) diff --git a/Gaze/Services/EyeTracking/TrackingModels.swift b/Gaze/Services/EyeTracking/TrackingModels.swift index a3125b5..aa88ca8 100644 --- a/Gaze/Services/EyeTracking/TrackingModels.swift +++ b/Gaze/Services/EyeTracking/TrackingModels.swift @@ -68,7 +68,8 @@ public struct TrackingConfig: Sendable { faceWidthScaleMax: Double, eyeBoundsHorizontalPadding: Double, eyeBoundsVerticalPaddingUp: Double, - eyeBoundsVerticalPaddingDown: Double + eyeBoundsVerticalPaddingDown: Double, + eyeBoundsSmoothing: Double ) { self.horizontalAwayThreshold = horizontalAwayThreshold self.verticalAwayThreshold = verticalAwayThreshold @@ -86,6 +87,7 @@ public struct TrackingConfig: Sendable { self.eyeBoundsHorizontalPadding = eyeBoundsHorizontalPadding self.eyeBoundsVerticalPaddingUp = eyeBoundsVerticalPaddingUp self.eyeBoundsVerticalPaddingDown = eyeBoundsVerticalPaddingDown + self.eyeBoundsSmoothing = eyeBoundsSmoothing } public let horizontalAwayThreshold: Double @@ -104,6 +106,7 @@ public struct TrackingConfig: Sendable { public let eyeBoundsHorizontalPadding: Double public let eyeBoundsVerticalPaddingUp: Double public let eyeBoundsVerticalPaddingDown: Double + public let eyeBoundsSmoothing: Double public static let `default` = TrackingConfig( horizontalAwayThreshold: 0.08, @@ -121,6 +124,7 @@ public struct TrackingConfig: Sendable { faceWidthScaleMax: 1.4, eyeBoundsHorizontalPadding: 0.1, eyeBoundsVerticalPaddingUp: 0.9, - eyeBoundsVerticalPaddingDown: 0.4 + eyeBoundsVerticalPaddingDown: 0.4, + eyeBoundsSmoothing: 0.2 ) } diff --git a/Gaze/Services/EyeTracking/VisionGazeProcessor.swift b/Gaze/Services/EyeTracking/VisionGazeProcessor.swift index ace8008..98790e1 100644 --- a/Gaze/Services/EyeTracking/VisionGazeProcessor.swift +++ b/Gaze/Services/EyeTracking/VisionGazeProcessor.swift @@ -32,6 +32,8 @@ final class VisionGazeProcessor: @unchecked Sendable { private let baselineModel = GazeBaselineModel() private var faceWidthBaseline: Double? private var faceWidthSmoothed: Double? + private var leftEyeFrameSmoothed: CGRect? + private var rightEyeFrameSmoothed: CGRect? private var config: TrackingConfig init(config: TrackingConfig) { @@ -46,6 +48,8 @@ final class VisionGazeProcessor: @unchecked Sendable { baselineModel.reset() faceWidthBaseline = nil faceWidthSmoothed = nil + leftEyeFrameSmoothed = nil + rightEyeFrameSmoothed = nil } func process(analysis: VisionPipeline.FaceAnalysis) -> ObservationResult { @@ -77,13 +81,15 @@ final class VisionGazeProcessor: @unchecked Sendable { eye: landmarks.leftEye, pupil: landmarks.leftPupil, face: face, - imageSize: analysis.imageSize + imageSize: analysis.imageSize, + smoothingRect: &leftEyeFrameSmoothed ) let rightEye = makeEyeObservation( eye: landmarks.rightEye, pupil: landmarks.rightPupil, face: face, - imageSize: analysis.imageSize + imageSize: analysis.imageSize, + smoothingRect: &rightEyeFrameSmoothed ) let eyesClosed = detectEyesClosed(left: leftEye, right: rightEye) @@ -126,7 +132,8 @@ final class VisionGazeProcessor: @unchecked Sendable { eye: VNFaceLandmarkRegion2D?, pupil: VNFaceLandmarkRegion2D?, face: VNFaceObservation, - imageSize: CGSize + imageSize: CGSize, + smoothingRect: inout CGRect? ) -> EyeObservation? { guard let eye else { return nil } @@ -142,8 +149,10 @@ final class VisionGazeProcessor: @unchecked Sendable { pupilPoint = bounds.center } + let rawFrame = CGRect(x: bounds.minX, y: bounds.minY, width: bounds.size.width, height: bounds.size.height) + let smoothedFrame = smoothRect(rawFrame, existing: &smoothingRect, smoothing: config.eyeBoundsSmoothing) let paddedFrame = expandRect( - CGRect(x: bounds.minX, y: bounds.minY, width: bounds.size.width, height: bounds.size.height), + smoothedFrame, horizontalPadding: config.eyeBoundsHorizontalPadding, verticalPaddingUp: config.eyeBoundsVerticalPaddingUp, verticalPaddingDown: config.eyeBoundsVerticalPaddingDown @@ -238,6 +247,26 @@ final class VisionGazeProcessor: @unchecked Sendable { ) } + private func smoothRect(_ rect: CGRect, existing: inout CGRect?, smoothing: Double) -> CGRect { + guard smoothing > 0, smoothing < 1 else { + existing = rect + return rect + } + + if let current = existing { + let newOriginX = current.origin.x + (rect.origin.x - current.origin.x) * smoothing + let newOriginY = current.origin.y + (rect.origin.y - current.origin.y) * smoothing + let newWidth = current.size.width + (rect.size.width - current.size.width) * smoothing + let newHeight = current.size.height + (rect.size.height - current.size.height) * smoothing + let updated = CGRect(x: newOriginX, y: newOriginY, width: newWidth, height: newHeight) + existing = updated + return updated + } + + existing = rect + return rect + } + private func averageCoordinate(left: CGFloat?, right: CGFloat?, fallback: Double?) -> Double? { switch (left, right) { case let (left?, right?):