checkpoint

This commit is contained in:
Michael Freno
2026-02-01 00:24:09 -05:00
parent 11f2313b34
commit ac3548e77c
7 changed files with 141 additions and 48 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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
)
}

View File

@@ -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
}

View File

@@ -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()