From ac3548e77ca638c70f209c369bfc40b72967b6cc Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Sun, 1 Feb 2026 00:24:09 -0500 Subject: [PATCH] checkpoint --- Gaze/Models/AppSettings.swift | 9 ++++ Gaze/Models/DefaultSettingsBuilder.swift | 6 +++ Gaze/Models/EnforceModeCalibration.swift | 19 +++++++ .../EyeTracking/EyeTrackingService.swift | 44 +++++++++++++--- .../Services/EyeTracking/TrackingModels.swift | 12 +++-- .../EyeTracking/VisionGazeProcessor.swift | 52 +++++++------------ .../Components/EnforceModeSetupContent.swift | 47 +++++++++++++++-- 7 files changed, 141 insertions(+), 48 deletions(-) create mode 100644 Gaze/Models/EnforceModeCalibration.swift diff --git a/Gaze/Models/AppSettings.swift b/Gaze/Models/AppSettings.swift index 7a7f816..6141c29 100644 --- a/Gaze/Models/AppSettings.swift +++ b/Gaze/Models/AppSettings.swift @@ -43,6 +43,9 @@ struct AppSettings: Codable, Equatable, Hashable, Sendable { var smartMode: SmartModeSettings var enforceModeStrictness: Double + var enforceModeEyeBoxWidthFactor: Double + var enforceModeEyeBoxHeightFactor: Double + var enforceModeCalibration: EnforceModeCalibration? var hasCompletedOnboarding: Bool var launchAtLogin: Bool @@ -59,6 +62,9 @@ struct AppSettings: Codable, Equatable, Hashable, Sendable { subtleReminderSize: ReminderSize = DefaultSettingsBuilder.subtleReminderSize, smartMode: SmartModeSettings = DefaultSettingsBuilder.smartMode, enforceModeStrictness: Double = DefaultSettingsBuilder.enforceModeStrictness, + enforceModeEyeBoxWidthFactor: Double = DefaultSettingsBuilder.enforceModeEyeBoxWidthFactor, + enforceModeEyeBoxHeightFactor: Double = DefaultSettingsBuilder.enforceModeEyeBoxHeightFactor, + enforceModeCalibration: EnforceModeCalibration? = DefaultSettingsBuilder.enforceModeCalibration, hasCompletedOnboarding: Bool = DefaultSettingsBuilder.hasCompletedOnboarding, launchAtLogin: Bool = DefaultSettingsBuilder.launchAtLogin, playSounds: Bool = DefaultSettingsBuilder.playSounds @@ -73,6 +79,9 @@ struct AppSettings: Codable, Equatable, Hashable, Sendable { self.subtleReminderSize = subtleReminderSize self.smartMode = smartMode self.enforceModeStrictness = enforceModeStrictness + self.enforceModeEyeBoxWidthFactor = enforceModeEyeBoxWidthFactor + self.enforceModeEyeBoxHeightFactor = enforceModeEyeBoxHeightFactor + self.enforceModeCalibration = enforceModeCalibration self.hasCompletedOnboarding = hasCompletedOnboarding self.launchAtLogin = launchAtLogin self.playSounds = playSounds diff --git a/Gaze/Models/DefaultSettingsBuilder.swift b/Gaze/Models/DefaultSettingsBuilder.swift index 5ebcebe..084d58a 100644 --- a/Gaze/Models/DefaultSettingsBuilder.swift +++ b/Gaze/Models/DefaultSettingsBuilder.swift @@ -17,6 +17,9 @@ struct DefaultSettingsBuilder { static let subtleReminderSize: ReminderSize = .medium static let smartMode: SmartModeSettings = .defaults static let enforceModeStrictness = 0.4 + static let enforceModeEyeBoxWidthFactor = 0.18 + static let enforceModeEyeBoxHeightFactor = 0.10 + static let enforceModeCalibration: EnforceModeCalibration? = nil static let hasCompletedOnboarding = false static let launchAtLogin = false static let playSounds = true @@ -33,6 +36,9 @@ struct DefaultSettingsBuilder { subtleReminderSize: subtleReminderSize, smartMode: smartMode, enforceModeStrictness: enforceModeStrictness, + enforceModeEyeBoxWidthFactor: enforceModeEyeBoxWidthFactor, + enforceModeEyeBoxHeightFactor: enforceModeEyeBoxHeightFactor, + enforceModeCalibration: enforceModeCalibration, hasCompletedOnboarding: hasCompletedOnboarding, launchAtLogin: launchAtLogin, playSounds: playSounds diff --git a/Gaze/Models/EnforceModeCalibration.swift b/Gaze/Models/EnforceModeCalibration.swift new file mode 100644 index 0000000..3fd9909 --- /dev/null +++ b/Gaze/Models/EnforceModeCalibration.swift @@ -0,0 +1,19 @@ +// +// EnforceModeCalibration.swift +// Gaze +// +// Created by Mike Freno on 2/1/26. +// + +import Foundation + +struct EnforceModeCalibration: Codable, Equatable, Hashable, Sendable { + let createdAt: Date + let eyeBoxWidthFactor: Double + let eyeBoxHeightFactor: Double + let faceWidthRatio: Double + let horizontalMin: Double + let horizontalMax: Double + let verticalMin: Double + let verticalMax: Double +} diff --git a/Gaze/Services/EyeTracking/EyeTrackingService.swift b/Gaze/Services/EyeTracking/EyeTrackingService.swift index 474cc95..fbe1f6f 100644 --- a/Gaze/Services/EyeTracking/EyeTrackingService.swift +++ b/Gaze/Services/EyeTracking/EyeTrackingService.swift @@ -49,24 +49,56 @@ class EyeTrackingService: NSObject, ObservableObject { } private func applyStrictness(_ strictness: Double) { + let settings = SettingsManager.shared.settings + let widthFactor = settings.enforceModeEyeBoxWidthFactor + let heightFactor = settings.enforceModeEyeBoxHeightFactor + let calibration = settings.enforceModeCalibration + + let clamped = min(1, max(0, strictness)) + let scale = 1.6 - (0.8 * clamped) + + let horizontalThreshold: Double + let verticalThreshold: Double + let baselineEnabled: Bool + let centerHorizontal: Double + let centerVertical: Double + + if let calibration { + let halfWidth = max(0.01, (calibration.horizontalMax - calibration.horizontalMin) / 2) + let halfHeight = max(0.01, (calibration.verticalMax - calibration.verticalMin) / 2) + let marginScale = 0.15 + horizontalThreshold = halfWidth * (1.0 + marginScale) * scale + verticalThreshold = halfHeight * (1.0 + marginScale) * scale + baselineEnabled = false + centerHorizontal = (calibration.horizontalMin + calibration.horizontalMax) / 2 + centerVertical = (calibration.verticalMin + calibration.verticalMax) / 2 + } else { + horizontalThreshold = TrackingConfig.default.horizontalAwayThreshold * scale + verticalThreshold = TrackingConfig.default.verticalAwayThreshold * scale + baselineEnabled = TrackingConfig.default.baselineEnabled + centerHorizontal = TrackingConfig.default.defaultCenterHorizontal + centerVertical = TrackingConfig.default.defaultCenterVertical + } + let config = TrackingConfig( - horizontalAwayThreshold: 0.08, - verticalAwayThreshold: 0.12, + horizontalAwayThreshold: horizontalThreshold, + verticalAwayThreshold: verticalThreshold, minBaselineSamples: TrackingConfig.default.minBaselineSamples, baselineSmoothing: TrackingConfig.default.baselineSmoothing, baselineUpdateThreshold: TrackingConfig.default.baselineUpdateThreshold, minConfidence: TrackingConfig.default.minConfidence, eyeClosedThreshold: TrackingConfig.default.eyeClosedThreshold, - baselineEnabled: TrackingConfig.default.baselineEnabled, - defaultCenterHorizontal: TrackingConfig.default.defaultCenterHorizontal, - defaultCenterVertical: TrackingConfig.default.defaultCenterVertical, + baselineEnabled: baselineEnabled, + defaultCenterHorizontal: centerHorizontal, + defaultCenterVertical: centerVertical, faceWidthSmoothing: TrackingConfig.default.faceWidthSmoothing, faceWidthScaleMin: TrackingConfig.default.faceWidthScaleMin, faceWidthScaleMax: 1.4, eyeBoundsHorizontalPadding: TrackingConfig.default.eyeBoundsHorizontalPadding, eyeBoundsVerticalPaddingUp: TrackingConfig.default.eyeBoundsVerticalPaddingUp, eyeBoundsVerticalPaddingDown: TrackingConfig.default.eyeBoundsVerticalPaddingDown, - eyeBoundsSmoothing: TrackingConfig.default.eyeBoundsSmoothing + eyeBoxWidthFactor: widthFactor, + eyeBoxHeightFactor: heightFactor ) processor.updateConfig(config) diff --git a/Gaze/Services/EyeTracking/TrackingModels.swift b/Gaze/Services/EyeTracking/TrackingModels.swift index aa88ca8..9efb95f 100644 --- a/Gaze/Services/EyeTracking/TrackingModels.swift +++ b/Gaze/Services/EyeTracking/TrackingModels.swift @@ -69,7 +69,8 @@ public struct TrackingConfig: Sendable { eyeBoundsHorizontalPadding: Double, eyeBoundsVerticalPaddingUp: Double, eyeBoundsVerticalPaddingDown: Double, - eyeBoundsSmoothing: Double + eyeBoxWidthFactor: Double, + eyeBoxHeightFactor: Double ) { self.horizontalAwayThreshold = horizontalAwayThreshold self.verticalAwayThreshold = verticalAwayThreshold @@ -87,7 +88,8 @@ public struct TrackingConfig: Sendable { self.eyeBoundsHorizontalPadding = eyeBoundsHorizontalPadding self.eyeBoundsVerticalPaddingUp = eyeBoundsVerticalPaddingUp self.eyeBoundsVerticalPaddingDown = eyeBoundsVerticalPaddingDown - self.eyeBoundsSmoothing = eyeBoundsSmoothing + self.eyeBoxWidthFactor = eyeBoxWidthFactor + self.eyeBoxHeightFactor = eyeBoxHeightFactor } public let horizontalAwayThreshold: Double @@ -106,7 +108,8 @@ public struct TrackingConfig: Sendable { public let eyeBoundsHorizontalPadding: Double public let eyeBoundsVerticalPaddingUp: Double public let eyeBoundsVerticalPaddingDown: Double - public let eyeBoundsSmoothing: Double + public let eyeBoxWidthFactor: Double + public let eyeBoxHeightFactor: Double public static let `default` = TrackingConfig( horizontalAwayThreshold: 0.08, @@ -125,6 +128,7 @@ public struct TrackingConfig: Sendable { eyeBoundsHorizontalPadding: 0.1, eyeBoundsVerticalPaddingUp: 0.9, eyeBoundsVerticalPaddingDown: 0.4, - eyeBoundsSmoothing: 0.2 + eyeBoxWidthFactor: 0.18, + eyeBoxHeightFactor: 0.10 ) } diff --git a/Gaze/Services/EyeTracking/VisionGazeProcessor.swift b/Gaze/Services/EyeTracking/VisionGazeProcessor.swift index 98790e1..4054365 100644 --- a/Gaze/Services/EyeTracking/VisionGazeProcessor.swift +++ b/Gaze/Services/EyeTracking/VisionGazeProcessor.swift @@ -32,8 +32,6 @@ 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) { @@ -48,8 +46,6 @@ final class VisionGazeProcessor: @unchecked Sendable { baselineModel.reset() faceWidthBaseline = nil faceWidthSmoothed = nil - leftEyeFrameSmoothed = nil - rightEyeFrameSmoothed = nil } func process(analysis: VisionPipeline.FaceAnalysis) -> ObservationResult { @@ -81,15 +77,13 @@ final class VisionGazeProcessor: @unchecked Sendable { eye: landmarks.leftEye, pupil: landmarks.leftPupil, face: face, - imageSize: analysis.imageSize, - smoothingRect: &leftEyeFrameSmoothed + imageSize: analysis.imageSize ) let rightEye = makeEyeObservation( eye: landmarks.rightEye, pupil: landmarks.rightPupil, face: face, - imageSize: analysis.imageSize, - smoothingRect: &rightEyeFrameSmoothed + imageSize: analysis.imageSize ) let eyesClosed = detectEyesClosed(left: leftEye, right: rightEye) @@ -132,8 +126,7 @@ final class VisionGazeProcessor: @unchecked Sendable { eye: VNFaceLandmarkRegion2D?, pupil: VNFaceLandmarkRegion2D?, face: VNFaceObservation, - imageSize: CGSize, - smoothingRect: inout CGRect? + imageSize: CGSize ) -> EyeObservation? { guard let eye else { return nil } @@ -149,10 +142,12 @@ 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 eyeBox = makeFaceRelativeEyeBox( + center: bounds.center, + faceWidth: face.boundingBox.size.width * imageSize.width + ) let paddedFrame = expandRect( - smoothedFrame, + eyeBox, horizontalPadding: config.eyeBoundsHorizontalPadding, verticalPaddingUp: config.eyeBoundsVerticalPaddingUp, verticalPaddingDown: config.eyeBoundsVerticalPaddingDown @@ -247,24 +242,15 @@ 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 makeFaceRelativeEyeBox(center: CGPoint, faceWidth: CGFloat) -> CGRect { + let width = faceWidth * CGFloat(config.eyeBoxWidthFactor) + let height = faceWidth * CGFloat(config.eyeBoxHeightFactor) + return CGRect( + x: center.x - width / 2, + y: center.y - height / 2, + width: width, + height: height + ) } private func averageCoordinate(left: CGFloat?, right: CGFloat?, fallback: Double?) -> Double? { @@ -342,9 +328,9 @@ final class VisionGazeProcessor: @unchecked Sendable { let lookingUp = vertical < baseline.vertical let verticalMultiplier: Double if lookingDown { - verticalMultiplier = 1.2 + verticalMultiplier = 1.1 } else if lookingUp { - verticalMultiplier = 1.8 + verticalMultiplier = 1.4 } else { verticalMultiplier = 1.0 } diff --git a/Gaze/Views/Components/EnforceModeSetupContent.swift b/Gaze/Views/Components/EnforceModeSetupContent.swift index 5aea63f..edc197c 100644 --- a/Gaze/Views/Components/EnforceModeSetupContent.swift +++ b/Gaze/Views/Components/EnforceModeSetupContent.swift @@ -5,8 +5,8 @@ // Created by Mike Freno on 1/30/26. // -import AppKit import AVFoundation +import AppKit import SwiftUI struct EnforceModeSetupContent: View { @@ -79,6 +79,9 @@ struct EnforceModeSetupContent: View { if enforceModeService.isEnforceModeEnabled { strictnessControlView } + if isTestModeActive && enforceModeService.isCameraActive { + eyeBoxControlView + } if enforceModeService.isCameraActive { trackingLapButton } @@ -120,7 +123,6 @@ struct EnforceModeSetupContent: View { .controlSize(presentation.isCard ? .regular : .large) } - private var testModePreviewView: some View { VStack(spacing: 16) { let lookingAway = eyeTrackingService.trackingResult.gazeState == .lookingAway @@ -140,7 +142,9 @@ struct EnforceModeSetupContent: View { } } .frame(height: presentation.isCard ? 180 : (isCompact ? 200 : 300)) - .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius)) + .glassEffectIfAvailable( + GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius) + ) .onAppear { if cachedPreviewLayer == nil { cachedPreviewLayer = eyeTrackingService.previewLayer @@ -249,7 +253,8 @@ struct EnforceModeSetupContent: View { } .padding(sectionPadding) .glassEffectIfAvailable( - GlassStyle.regular.tint(.blue.opacity(0.1)), in: .rect(cornerRadius: sectionCornerRadius) + GlassStyle.regular.tint(.blue.opacity(0.1)), + in: .rect(cornerRadius: sectionCornerRadius) ) } @@ -349,7 +354,8 @@ struct EnforceModeSetupContent: View { } if let horizontal = eyeTrackingService.debugState.normalizedHorizontal, - let vertical = eyeTrackingService.debugState.normalizedVertical { + let vertical = eyeTrackingService.debugState.normalizedVertical + { HStack(spacing: 12) { Text("Ratios:") .font(.caption2) @@ -393,6 +399,37 @@ struct EnforceModeSetupContent: View { .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius)) } + private var eyeBoxControlView: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Eye Box Size") + .font(headerFont) + + VStack(alignment: .leading, spacing: 8) { + Text("Width") + .font(.caption2) + .foregroundStyle(.secondary) + + Slider( + value: $settingsManager.settings.enforceModeEyeBoxWidthFactor, + in: 0.12...0.25 + ) + .controlSize(.small) + + Text("Height") + .font(.caption2) + .foregroundStyle(.secondary) + + Slider( + value: $settingsManager.settings.enforceModeEyeBoxHeightFactor, + in: 0.02...0.05 + ) + .controlSize(.small) + } + } + .padding(sectionPadding) + .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius)) + } + private var trackingLapButton: some View { Button(action: { enforceModeService.logTrackingLap()