checkpoint
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
19
Gaze/Models/EnforceModeCalibration.swift
Normal file
19
Gaze/Models/EnforceModeCalibration.swift
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user