Compare commits
6 Commits
a20b3701a6
...
fc9ab37841
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc9ab37841 | ||
|
|
e0a9d16484 | ||
|
|
d4adb530e0 | ||
|
|
5ae678ffe8 | ||
|
|
ac3548e77c | ||
|
|
11f2313b34 |
@@ -185,7 +185,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
||||
private func showReminder(_ event: ReminderEvent) {
|
||||
switch event {
|
||||
case .lookAwayTriggered(let countdownSeconds):
|
||||
let view = LookAwayReminderView(countdownSeconds: countdownSeconds) { [weak self] in
|
||||
let view = LookAwayReminderView(
|
||||
countdownSeconds: countdownSeconds,
|
||||
enforceModeService: EnforceModeService.shared
|
||||
) { [weak self] in
|
||||
self?.timerEngine?.dismissReminder()
|
||||
}
|
||||
windowManager.showReminderWindow(view, windowType: .overlay)
|
||||
|
||||
@@ -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.20
|
||||
static let enforceModeEyeBoxHeightFactor = 0.02
|
||||
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
|
||||
}
|
||||
268
Gaze/Services/Calibration/EnforceModeCalibrationService.swift
Normal file
268
Gaze/Services/Calibration/EnforceModeCalibrationService.swift
Normal file
@@ -0,0 +1,268 @@
|
||||
//
|
||||
// EnforceModeCalibrationService.swift
|
||||
// Gaze
|
||||
//
|
||||
// Created by Mike Freno on 2/1/26.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
import Combine
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class EnforceModeCalibrationService: ObservableObject {
|
||||
static let shared = EnforceModeCalibrationService()
|
||||
|
||||
@Published var isCalibrating = false
|
||||
@Published var isCollectingSamples = false
|
||||
@Published var currentStep: CalibrationStep = .eyeBox
|
||||
@Published var targetIndex = 0
|
||||
@Published var countdownProgress: Double = 1.0
|
||||
@Published var samplesCollected = 0
|
||||
|
||||
private var samples: [CalibrationSample] = []
|
||||
private let targets = CalibrationTarget.defaultTargets
|
||||
let settingsManager = SettingsManager.shared
|
||||
private let eyeTrackingService = EyeTrackingService.shared
|
||||
|
||||
private var countdownTimer: Timer?
|
||||
private var sampleTimer: Timer?
|
||||
private let countdownDuration: TimeInterval = 0.8
|
||||
private let preCountdownPause: TimeInterval = 0.8
|
||||
private let sampleInterval: TimeInterval = 0.02
|
||||
private let samplesPerTarget = 20
|
||||
private var windowController: NSWindowController?
|
||||
|
||||
func start() {
|
||||
samples.removeAll()
|
||||
targetIndex = 0
|
||||
currentStep = .eyeBox
|
||||
isCollectingSamples = false
|
||||
samplesCollected = 0
|
||||
countdownProgress = 1.0
|
||||
isCalibrating = true
|
||||
}
|
||||
|
||||
func presentOverlay() {
|
||||
guard windowController == nil else { return }
|
||||
guard let screen = NSScreen.main else { return }
|
||||
|
||||
start()
|
||||
|
||||
let window = KeyableWindow(
|
||||
contentRect: screen.frame,
|
||||
styleMask: [.borderless, .fullSizeContentView],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
|
||||
window.level = .screenSaver
|
||||
window.isOpaque = true
|
||||
window.backgroundColor = .black
|
||||
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||
window.acceptsMouseMovedEvents = true
|
||||
window.ignoresMouseEvents = false
|
||||
|
||||
let overlayView = EnforceModeCalibrationOverlayView()
|
||||
window.contentView = NSHostingView(rootView: overlayView)
|
||||
|
||||
windowController = NSWindowController(window: window)
|
||||
windowController?.showWindow(nil)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
func dismissOverlay() {
|
||||
windowController?.close()
|
||||
windowController = nil
|
||||
isCalibrating = false
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
stopCountdown()
|
||||
stopSampleCollection()
|
||||
isCalibrating = false
|
||||
}
|
||||
|
||||
func advance() {
|
||||
switch currentStep {
|
||||
case .eyeBox:
|
||||
currentStep = .targets
|
||||
// Start countdown immediately when transitioning to targets to avoid first point duplication
|
||||
startCountdown()
|
||||
case .targets:
|
||||
if targetIndex < targets.count - 1 {
|
||||
targetIndex += 1
|
||||
startCountdown()
|
||||
} else {
|
||||
finish()
|
||||
}
|
||||
case .complete:
|
||||
isCalibrating = false
|
||||
}
|
||||
}
|
||||
|
||||
func recordSample() {
|
||||
let debugState = eyeTrackingService.currentDebugSnapshot()
|
||||
guard let h = debugState.normalizedHorizontal,
|
||||
let v = debugState.normalizedVertical,
|
||||
let faceWidth = debugState.faceWidthRatio else {
|
||||
return
|
||||
}
|
||||
|
||||
let target = targets[targetIndex]
|
||||
samples.append(
|
||||
CalibrationSample(
|
||||
target: target,
|
||||
horizontal: h,
|
||||
vertical: v,
|
||||
faceWidthRatio: faceWidth
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func currentTarget() -> CalibrationTarget {
|
||||
targets[targetIndex]
|
||||
}
|
||||
|
||||
private func startCountdown() {
|
||||
stopCountdown()
|
||||
stopSampleCollection()
|
||||
|
||||
countdownProgress = 1.0
|
||||
let startTime = Date()
|
||||
countdownTimer = Timer.scheduledTimer(withTimeInterval: 0.02, repeats: true) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
let elapsed = Date().timeIntervalSince(startTime)
|
||||
// Pause before starting the countdown
|
||||
if elapsed < self.preCountdownPause {
|
||||
self.countdownProgress = 1.0
|
||||
return
|
||||
}
|
||||
|
||||
// Start the actual countdown after pause
|
||||
let countdownElapsed = elapsed - self.preCountdownPause
|
||||
let remaining = max(0, self.countdownDuration - countdownElapsed)
|
||||
self.countdownProgress = remaining / self.countdownDuration
|
||||
|
||||
if remaining <= 0 {
|
||||
self.stopCountdown()
|
||||
self.startSampleCollection()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopCountdown() {
|
||||
countdownTimer?.invalidate()
|
||||
countdownTimer = nil
|
||||
// Only reset to 1.0 when actually stopping, not during transitions
|
||||
// countdownProgress = 1.0
|
||||
}
|
||||
|
||||
private func startSampleCollection() {
|
||||
stopSampleCollection()
|
||||
samplesCollected = 0
|
||||
isCollectingSamples = true
|
||||
sampleTimer = Timer.scheduledTimer(withTimeInterval: sampleInterval, repeats: true) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.recordSample()
|
||||
self.samplesCollected += 1
|
||||
if self.samplesCollected >= self.samplesPerTarget {
|
||||
self.stopSampleCollection()
|
||||
self.advance()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopSampleCollection() {
|
||||
sampleTimer?.invalidate()
|
||||
sampleTimer = nil
|
||||
isCollectingSamples = false
|
||||
}
|
||||
|
||||
private func finish() {
|
||||
stopCountdown()
|
||||
stopSampleCollection()
|
||||
guard let calibration = CalibrationSample.makeCalibration(samples: samples) else {
|
||||
currentStep = .complete
|
||||
return
|
||||
}
|
||||
|
||||
settingsManager.settings.enforceModeCalibration = calibration
|
||||
currentStep = .complete
|
||||
}
|
||||
|
||||
var progress: Double {
|
||||
guard !targets.isEmpty else { return 0 }
|
||||
return Double(targetIndex) / Double(targets.count)
|
||||
}
|
||||
|
||||
var progressText: String {
|
||||
"\(min(targetIndex + 1, targets.count))/\(targets.count)"
|
||||
}
|
||||
}
|
||||
|
||||
enum CalibrationStep: String {
|
||||
case eyeBox
|
||||
case targets
|
||||
case complete
|
||||
}
|
||||
|
||||
struct CalibrationTarget: Identifiable, Sendable {
|
||||
let id = UUID()
|
||||
let x: CGFloat
|
||||
let y: CGFloat
|
||||
let label: String
|
||||
|
||||
static let defaultTargets: [CalibrationTarget] = [
|
||||
CalibrationTarget(x: 0.1, y: 0.1, label: "Top Left"),
|
||||
CalibrationTarget(x: 0.5, y: 0.1, label: "Top"),
|
||||
CalibrationTarget(x: 0.9, y: 0.1, label: "Top Right"),
|
||||
CalibrationTarget(x: 0.9, y: 0.5, label: "Right"),
|
||||
CalibrationTarget(x: 0.9, y: 0.9, label: "Bottom Right"),
|
||||
CalibrationTarget(x: 0.5, y: 0.9, label: "Bottom"),
|
||||
CalibrationTarget(x: 0.1, y: 0.9, label: "Bottom Left"),
|
||||
CalibrationTarget(x: 0.1, y: 0.5, label: "Left"),
|
||||
CalibrationTarget(x: 0.5, y: 0.5, label: "Center")
|
||||
]
|
||||
}
|
||||
|
||||
private struct CalibrationSample: Sendable {
|
||||
let target: CalibrationTarget
|
||||
let horizontal: Double
|
||||
let vertical: Double
|
||||
let faceWidthRatio: Double
|
||||
|
||||
static func makeCalibration(samples: [CalibrationSample]) -> EnforceModeCalibration? {
|
||||
guard !samples.isEmpty else { return nil }
|
||||
|
||||
let horizontalValues = samples.map { $0.horizontal }
|
||||
let verticalValues = samples.map { $0.vertical }
|
||||
let faceWidths = samples.map { $0.faceWidthRatio }
|
||||
|
||||
guard let minH = horizontalValues.min(),
|
||||
let maxH = horizontalValues.max(),
|
||||
let minV = verticalValues.min(),
|
||||
let maxV = verticalValues.max() else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let faceWidthMean = faceWidths.reduce(0, +) / Double(faceWidths.count)
|
||||
|
||||
return EnforceModeCalibration(
|
||||
createdAt: Date(),
|
||||
eyeBoxWidthFactor: SettingsManager.shared.settings.enforceModeEyeBoxWidthFactor,
|
||||
eyeBoxHeightFactor: SettingsManager.shared.settings.enforceModeEyeBoxHeightFactor,
|
||||
faceWidthRatio: faceWidthMean,
|
||||
horizontalMin: minH,
|
||||
horizontalMax: maxH,
|
||||
verticalMin: minV,
|
||||
verticalMax: maxV
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ class EnforceModeService: ObservableObject {
|
||||
private var faceDetectionTimer: Timer?
|
||||
private var trackingDebugTimer: Timer?
|
||||
private var trackingLapStats = TrackingLapStats()
|
||||
private var lastLookAwayTime: Date = .distantPast
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
@@ -183,7 +184,7 @@ class EnforceModeService: ObservableObject {
|
||||
gazeState: GazeState,
|
||||
faceDetected: Bool
|
||||
) -> ComplianceResult {
|
||||
guard faceDetected else { return .faceNotDetected }
|
||||
guard faceDetected else { return .compliant }
|
||||
switch gazeState {
|
||||
case .lookingAway:
|
||||
return .compliant
|
||||
@@ -242,11 +243,13 @@ class EnforceModeService: ObservableObject {
|
||||
|
||||
switch compliance {
|
||||
case .compliant:
|
||||
lastLookAwayTime = Date()
|
||||
userCompliedWithBreak = true
|
||||
case .notCompliant:
|
||||
userCompliedWithBreak = false
|
||||
case .faceNotDetected:
|
||||
userCompliedWithBreak = false
|
||||
lastLookAwayTime = Date()
|
||||
userCompliedWithBreak = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,11 +324,30 @@ class EnforceModeService: ObservableObject {
|
||||
|
||||
let timeSinceLastDetection = Date().timeIntervalSince(lastFaceDetectionTime)
|
||||
if timeSinceLastDetection > faceDetectionTimeout {
|
||||
logDebug("⏰ Person not detected for \(faceDetectionTimeout)s. Temporarily disabling enforce mode.")
|
||||
disableEnforceMode()
|
||||
logDebug("⏰ Person not detected for \(faceDetectionTimeout)s. Assuming look away.")
|
||||
lastLookAwayTime = Date()
|
||||
userCompliedWithBreak = true
|
||||
lastFaceDetectionTime = Date()
|
||||
}
|
||||
}
|
||||
|
||||
func shouldAdvanceLookAwayCountdown() -> Bool {
|
||||
guard isEnforceModeEnabled else { return true }
|
||||
guard isCameraActive else { return true }
|
||||
|
||||
if !eyeTrackingService.trackingResult.faceDetected {
|
||||
lastLookAwayTime = Date()
|
||||
return true
|
||||
}
|
||||
|
||||
if eyeTrackingService.trackingResult.gazeState == .lookingAway {
|
||||
lastLookAwayTime = Date()
|
||||
return true
|
||||
}
|
||||
|
||||
return Date().timeIntervalSince(lastLookAwayTime) <= 0.25
|
||||
}
|
||||
|
||||
// MARK: - Test Mode
|
||||
|
||||
func startTestMode() async {
|
||||
|
||||
@@ -100,6 +100,13 @@ final class CameraSessionManager: NSObject, ObservableObject {
|
||||
}
|
||||
session.addOutput(output)
|
||||
|
||||
if let connection = output.connection(with: .video) {
|
||||
if connection.isVideoMirroringSupported {
|
||||
connection.automaticallyAdjustsVideoMirroring = false
|
||||
connection.isVideoMirrored = true
|
||||
}
|
||||
}
|
||||
|
||||
self.captureSession = session
|
||||
self.videoOutput = output
|
||||
}
|
||||
|
||||
@@ -49,23 +49,58 @@ 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
|
||||
processor.setFaceWidthBaseline(calibration.faceWidthRatio)
|
||||
} else {
|
||||
horizontalThreshold = TrackingConfig.default.horizontalAwayThreshold * scale
|
||||
verticalThreshold = TrackingConfig.default.verticalAwayThreshold * scale
|
||||
baselineEnabled = TrackingConfig.default.baselineEnabled
|
||||
centerHorizontal = TrackingConfig.default.defaultCenterHorizontal
|
||||
centerVertical = TrackingConfig.default.defaultCenterVertical
|
||||
processor.resetBaseline()
|
||||
}
|
||||
|
||||
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
|
||||
eyeBoundsVerticalPaddingDown: TrackingConfig.default.eyeBoundsVerticalPaddingDown,
|
||||
eyeBoxWidthFactor: widthFactor,
|
||||
eyeBoxHeightFactor: heightFactor
|
||||
)
|
||||
|
||||
processor.updateConfig(config)
|
||||
@@ -93,6 +128,10 @@ class EyeTrackingService: NSObject, ObservableObject {
|
||||
debugState = EyeTrackingDebugState.empty
|
||||
}
|
||||
}
|
||||
|
||||
func currentDebugSnapshot() -> EyeTrackingDebugState {
|
||||
debugState
|
||||
}
|
||||
}
|
||||
|
||||
extension EyeTrackingService: CameraSessionDelegate {
|
||||
|
||||
@@ -68,7 +68,9 @@ public struct TrackingConfig: Sendable {
|
||||
faceWidthScaleMax: Double,
|
||||
eyeBoundsHorizontalPadding: Double,
|
||||
eyeBoundsVerticalPaddingUp: Double,
|
||||
eyeBoundsVerticalPaddingDown: Double
|
||||
eyeBoundsVerticalPaddingDown: Double,
|
||||
eyeBoxWidthFactor: Double,
|
||||
eyeBoxHeightFactor: Double
|
||||
) {
|
||||
self.horizontalAwayThreshold = horizontalAwayThreshold
|
||||
self.verticalAwayThreshold = verticalAwayThreshold
|
||||
@@ -86,6 +88,8 @@ public struct TrackingConfig: Sendable {
|
||||
self.eyeBoundsHorizontalPadding = eyeBoundsHorizontalPadding
|
||||
self.eyeBoundsVerticalPaddingUp = eyeBoundsVerticalPaddingUp
|
||||
self.eyeBoundsVerticalPaddingDown = eyeBoundsVerticalPaddingDown
|
||||
self.eyeBoxWidthFactor = eyeBoxWidthFactor
|
||||
self.eyeBoxHeightFactor = eyeBoxHeightFactor
|
||||
}
|
||||
|
||||
public let horizontalAwayThreshold: Double
|
||||
@@ -104,6 +108,8 @@ public struct TrackingConfig: Sendable {
|
||||
public let eyeBoundsHorizontalPadding: Double
|
||||
public let eyeBoundsVerticalPaddingUp: Double
|
||||
public let eyeBoundsVerticalPaddingDown: Double
|
||||
public let eyeBoxWidthFactor: Double
|
||||
public let eyeBoxHeightFactor: Double
|
||||
|
||||
public static let `default` = TrackingConfig(
|
||||
horizontalAwayThreshold: 0.08,
|
||||
@@ -121,6 +127,8 @@ public struct TrackingConfig: Sendable {
|
||||
faceWidthScaleMax: 1.4,
|
||||
eyeBoundsHorizontalPadding: 0.1,
|
||||
eyeBoundsVerticalPaddingUp: 0.9,
|
||||
eyeBoundsVerticalPaddingDown: 0.4
|
||||
eyeBoundsVerticalPaddingDown: 0.4,
|
||||
eyeBoxWidthFactor: 0.18,
|
||||
eyeBoxHeightFactor: 0.10
|
||||
)
|
||||
}
|
||||
|
||||
@@ -48,6 +48,11 @@ final class VisionGazeProcessor: @unchecked Sendable {
|
||||
faceWidthSmoothed = nil
|
||||
}
|
||||
|
||||
func setFaceWidthBaseline(_ value: Double) {
|
||||
faceWidthBaseline = value
|
||||
faceWidthSmoothed = value
|
||||
}
|
||||
|
||||
func process(analysis: VisionPipeline.FaceAnalysis) -> ObservationResult {
|
||||
guard analysis.faceDetected, let face = analysis.face?.value else {
|
||||
return ObservationResult(
|
||||
@@ -142,8 +147,12 @@ final class VisionGazeProcessor: @unchecked Sendable {
|
||||
pupilPoint = bounds.center
|
||||
}
|
||||
|
||||
let eyeBox = makeFaceRelativeEyeBox(
|
||||
center: bounds.center,
|
||||
faceWidth: face.boundingBox.size.width * imageSize.width
|
||||
)
|
||||
let paddedFrame = expandRect(
|
||||
CGRect(x: bounds.minX, y: bounds.minY, width: bounds.size.width, height: bounds.size.height),
|
||||
eyeBox,
|
||||
horizontalPadding: config.eyeBoundsHorizontalPadding,
|
||||
verticalPaddingUp: config.eyeBoundsVerticalPaddingUp,
|
||||
verticalPaddingDown: config.eyeBoundsVerticalPaddingDown
|
||||
@@ -238,6 +247,17 @@ final class VisionGazeProcessor: @unchecked Sendable {
|
||||
)
|
||||
}
|
||||
|
||||
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? {
|
||||
switch (left, right) {
|
||||
case let (left?, right?):
|
||||
@@ -313,9 +333,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
|
||||
}
|
||||
|
||||
@@ -11,6 +11,20 @@ import AVFoundation
|
||||
struct CameraPreviewView: NSViewRepresentable {
|
||||
let previewLayer: AVCaptureVideoPreviewLayer
|
||||
let borderColor: NSColor
|
||||
let showsBorder: Bool
|
||||
let cornerRadius: CGFloat
|
||||
|
||||
init(
|
||||
previewLayer: AVCaptureVideoPreviewLayer,
|
||||
borderColor: NSColor,
|
||||
showsBorder: Bool = true,
|
||||
cornerRadius: CGFloat = 12
|
||||
) {
|
||||
self.previewLayer = previewLayer
|
||||
self.borderColor = borderColor
|
||||
self.showsBorder = showsBorder
|
||||
self.cornerRadius = cornerRadius
|
||||
}
|
||||
|
||||
func makeNSView(context: Context) -> PreviewContainerView {
|
||||
let view = PreviewContainerView()
|
||||
@@ -22,6 +36,11 @@ struct CameraPreviewView: NSViewRepresentable {
|
||||
previewLayer.frame = view.bounds
|
||||
view.layer?.addSublayer(previewLayer)
|
||||
}
|
||||
|
||||
if let connection = previewLayer.connection, connection.isVideoMirroringSupported {
|
||||
connection.automaticallyAdjustsVideoMirroring = false
|
||||
connection.isVideoMirrored = true
|
||||
}
|
||||
|
||||
updateBorder(view: view, color: borderColor)
|
||||
|
||||
@@ -41,14 +60,23 @@ struct CameraPreviewView: NSViewRepresentable {
|
||||
// Same layer, just update frame
|
||||
previewLayer.frame = nsView.bounds
|
||||
}
|
||||
|
||||
if let connection = previewLayer.connection, connection.isVideoMirroringSupported {
|
||||
connection.automaticallyAdjustsVideoMirroring = false
|
||||
connection.isVideoMirrored = true
|
||||
}
|
||||
|
||||
updateBorder(view: nsView, color: borderColor)
|
||||
}
|
||||
|
||||
private func updateBorder(view: NSView, color: NSColor) {
|
||||
view.layer?.borderColor = color.cgColor
|
||||
view.layer?.borderWidth = 4
|
||||
view.layer?.cornerRadius = 12
|
||||
if showsBorder {
|
||||
view.layer?.borderColor = color.cgColor
|
||||
view.layer?.borderWidth = 4
|
||||
} else {
|
||||
view.layer?.borderWidth = 0
|
||||
}
|
||||
view.layer?.cornerRadius = cornerRadius
|
||||
view.layer?.masksToBounds = true
|
||||
}
|
||||
|
||||
|
||||
197
Gaze/Views/Components/EnforceModeCalibrationOverlayView.swift
Normal file
197
Gaze/Views/Components/EnforceModeCalibrationOverlayView.swift
Normal file
@@ -0,0 +1,197 @@
|
||||
//
|
||||
// EnforceModeCalibrationOverlayView.swift
|
||||
// Gaze
|
||||
//
|
||||
// Created by Mike Freno on 2/1/26.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct EnforceModeCalibrationOverlayView: View {
|
||||
@ObservedObject private var calibrationService = EnforceModeCalibrationService.shared
|
||||
@ObservedObject private var eyeTrackingService = EyeTrackingService.shared
|
||||
@Bindable private var settingsManager = SettingsManager.shared
|
||||
|
||||
@ObservedObject private var enforceModeService = EnforceModeService.shared
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
cameraBackground
|
||||
|
||||
switch calibrationService.currentStep {
|
||||
case .eyeBox:
|
||||
eyeBoxStep
|
||||
case .targets:
|
||||
targetStep
|
||||
case .complete:
|
||||
completionStep
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var eyeBoxStep: some View {
|
||||
ZStack {
|
||||
VStack(spacing: 20) {
|
||||
VStack(spacing: 16) {
|
||||
Text("Adjust Eye Box")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text(
|
||||
"Use the sliders to fit the boxes around your eyes. It need not be perfect."
|
||||
)
|
||||
.font(.callout)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
}
|
||||
.padding(.horizontal, 40)
|
||||
.frame(maxWidth: 520)
|
||||
.frame(maxWidth: .infinity, alignment: .top)
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Width")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
Slider(
|
||||
value: $settingsManager.settings.enforceModeEyeBoxWidthFactor,
|
||||
in: 0.12...0.25
|
||||
)
|
||||
|
||||
Text("Height")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
Slider(
|
||||
value: $settingsManager.settings.enforceModeEyeBoxHeightFactor,
|
||||
in: 0.01...0.10
|
||||
)
|
||||
}
|
||||
.padding(16)
|
||||
.background(.black.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.frame(maxWidth: 420)
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button("Cancel") {
|
||||
calibrationService.dismissOverlay()
|
||||
enforceModeService.stopTestMode()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button("Continue") {
|
||||
calibrationService.advance()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var targetStep: some View {
|
||||
ZStack {
|
||||
VStack(spacing: 10) {
|
||||
HStack {
|
||||
Text("Calibrating...")
|
||||
.foregroundStyle(.white)
|
||||
Spacer()
|
||||
Text(calibrationService.progressText)
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
}
|
||||
|
||||
ProgressView(value: calibrationService.progress)
|
||||
.progressViewStyle(.linear)
|
||||
.tint(.blue)
|
||||
}
|
||||
.padding()
|
||||
.background(Color.black.opacity(0.7))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
|
||||
targetDot
|
||||
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack(spacing: 12) {
|
||||
Button("Cancel") {
|
||||
calibrationService.dismissOverlay()
|
||||
enforceModeService.stopTestMode()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
}
|
||||
|
||||
private var completionStep: some View {
|
||||
VStack(spacing: 20) {
|
||||
Text("Calibration Complete")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.white)
|
||||
Text("Enforce Mode is ready to use.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
|
||||
Button("Done") {
|
||||
calibrationService.dismissOverlay()
|
||||
enforceModeService.stopTestMode()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
|
||||
private var targetDot: some View {
|
||||
GeometryReader { geometry in
|
||||
let target = calibrationService.currentTarget()
|
||||
let center = CGPoint(
|
||||
x: geometry.size.width * target.x,
|
||||
y: geometry.size.height * target.y
|
||||
)
|
||||
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.blue)
|
||||
.frame(width: 120, height: 120)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: CGFloat(calibrationService.countdownProgress))
|
||||
.stroke(Color.blue.opacity(0.8), lineWidth: 8)
|
||||
.frame(width: 160, height: 160)
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.linear(duration: 0.02), value: calibrationService.countdownProgress)
|
||||
}
|
||||
.position(center)
|
||||
.animation(.easeInOut(duration: 0.3), value: center)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
|
||||
private var cameraBackground: some View {
|
||||
ZStack {
|
||||
if let layer = eyeTrackingService.previewLayer {
|
||||
CameraPreviewView(
|
||||
previewLayer: layer,
|
||||
borderColor: .clear,
|
||||
showsBorder: false,
|
||||
cornerRadius: 0
|
||||
)
|
||||
.opacity(0.5)
|
||||
}
|
||||
|
||||
if calibrationService.currentStep == .eyeBox {
|
||||
GeometryReader { geometry in
|
||||
EyeTrackingDebugOverlayView(
|
||||
debugState: eyeTrackingService.debugState,
|
||||
viewSize: geometry.size
|
||||
)
|
||||
.opacity(0.8)
|
||||
}
|
||||
}
|
||||
|
||||
Color.black.opacity(0.35)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@
|
||||
// Created by Mike Freno on 1/30/26.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
import AVFoundation
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct EnforceModeSetupContent: View {
|
||||
@@ -14,6 +14,7 @@ struct EnforceModeSetupContent: View {
|
||||
@ObservedObject var cameraService = CameraAccessService.shared
|
||||
@ObservedObject var eyeTrackingService = EyeTrackingService.shared
|
||||
@ObservedObject var enforceModeService = EnforceModeService.shared
|
||||
@ObservedObject var calibrationService = EnforceModeCalibrationService.shared
|
||||
@Environment(\.isCompactLayout) private var isCompact
|
||||
|
||||
let presentation: SetupPresentation
|
||||
@@ -79,9 +80,7 @@ struct EnforceModeSetupContent: View {
|
||||
if enforceModeService.isEnforceModeEnabled {
|
||||
strictnessControlView
|
||||
}
|
||||
if enforceModeService.isCameraActive {
|
||||
trackingLapButton
|
||||
}
|
||||
calibrationActionView
|
||||
privacyInfoView
|
||||
}
|
||||
|
||||
@@ -120,7 +119,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 +138,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 +249,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 +350,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,13 +395,16 @@ struct EnforceModeSetupContent: View {
|
||||
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius))
|
||||
}
|
||||
|
||||
private var trackingLapButton: some View {
|
||||
private var calibrationActionView: some View {
|
||||
Button(action: {
|
||||
enforceModeService.logTrackingLap()
|
||||
calibrationService.presentOverlay()
|
||||
Task { @MainActor in
|
||||
await enforceModeService.startTestMode()
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "flag.checkered")
|
||||
Text("Lap Marker")
|
||||
Image(systemName: "target")
|
||||
Text("Calibrate Eye Tracking")
|
||||
.font(.headline)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -408,4 +413,6 @@ struct EnforceModeSetupContent: View {
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.regular)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -12,15 +12,23 @@ import SwiftUI
|
||||
struct LookAwayReminderView: View {
|
||||
let countdownSeconds: Int
|
||||
var onDismiss: () -> Void
|
||||
var enforceModeService: EnforceModeService?
|
||||
|
||||
@State private var remainingSeconds: Int
|
||||
@State private var remainingTime: TimeInterval
|
||||
@State private var timer: Timer?
|
||||
@State private var keyMonitor: Any?
|
||||
|
||||
init(countdownSeconds: Int, onDismiss: @escaping () -> Void) {
|
||||
init(
|
||||
countdownSeconds: Int,
|
||||
enforceModeService: EnforceModeService? = nil,
|
||||
onDismiss: @escaping () -> Void
|
||||
) {
|
||||
self.countdownSeconds = countdownSeconds
|
||||
self.enforceModeService = enforceModeService
|
||||
self.onDismiss = onDismiss
|
||||
self._remainingSeconds = State(initialValue: countdownSeconds)
|
||||
self._remainingTime = State(initialValue: TimeInterval(countdownSeconds))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -100,15 +108,21 @@ struct LookAwayReminderView: View {
|
||||
}
|
||||
|
||||
private var progress: CGFloat {
|
||||
CGFloat(remainingSeconds) / CGFloat(countdownSeconds)
|
||||
CGFloat(remainingTime) / CGFloat(countdownSeconds)
|
||||
}
|
||||
|
||||
private func startCountdown() {
|
||||
let timer = Timer(timeInterval: 1.0, repeats: true) { [self] _ in
|
||||
if remainingSeconds > 0 {
|
||||
remainingSeconds -= 1
|
||||
} else {
|
||||
let tickInterval: TimeInterval = 0.25
|
||||
let timer = Timer(timeInterval: tickInterval, repeats: true) { [self] _ in
|
||||
guard remainingTime > 0 else {
|
||||
dismiss()
|
||||
return
|
||||
}
|
||||
|
||||
let shouldAdvance = enforceModeService?.shouldAdvanceLookAwayCountdown() ?? true
|
||||
if shouldAdvance {
|
||||
remainingTime = max(0, remainingTime - tickInterval)
|
||||
remainingSeconds = max(0, Int(ceil(remainingTime)))
|
||||
}
|
||||
}
|
||||
RunLoop.current.add(timer, forMode: .common)
|
||||
|
||||
Reference in New Issue
Block a user