Compare commits

..

6 Commits

Author SHA1 Message Date
Michael Freno
fc9ab37841 bit smoother 2026-02-01 11:29:49 -05:00
Michael Freno
e0a9d16484 mild 2026-02-01 10:22:47 -05:00
Michael Freno
d4adb530e0 meh 2026-02-01 02:13:32 -05:00
Michael Freno
5ae678ffe8 remake of calibration 2026-02-01 01:02:19 -05:00
Michael Freno
ac3548e77c checkpoint 2026-02-01 00:24:09 -05:00
Michael Freno
11f2313b34 christ 2026-01-31 23:49:06 -05:00
14 changed files with 684 additions and 37 deletions

View File

@@ -185,7 +185,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
private func showReminder(_ event: ReminderEvent) { private func showReminder(_ event: ReminderEvent) {
switch event { switch event {
case .lookAwayTriggered(let countdownSeconds): 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() self?.timerEngine?.dismissReminder()
} }
windowManager.showReminderWindow(view, windowType: .overlay) windowManager.showReminderWindow(view, windowType: .overlay)

View File

@@ -43,6 +43,9 @@ struct AppSettings: Codable, Equatable, Hashable, Sendable {
var smartMode: SmartModeSettings var smartMode: SmartModeSettings
var enforceModeStrictness: Double var enforceModeStrictness: Double
var enforceModeEyeBoxWidthFactor: Double
var enforceModeEyeBoxHeightFactor: Double
var enforceModeCalibration: EnforceModeCalibration?
var hasCompletedOnboarding: Bool var hasCompletedOnboarding: Bool
var launchAtLogin: Bool var launchAtLogin: Bool
@@ -59,6 +62,9 @@ struct AppSettings: Codable, Equatable, Hashable, Sendable {
subtleReminderSize: ReminderSize = DefaultSettingsBuilder.subtleReminderSize, subtleReminderSize: ReminderSize = DefaultSettingsBuilder.subtleReminderSize,
smartMode: SmartModeSettings = DefaultSettingsBuilder.smartMode, smartMode: SmartModeSettings = DefaultSettingsBuilder.smartMode,
enforceModeStrictness: Double = DefaultSettingsBuilder.enforceModeStrictness, enforceModeStrictness: Double = DefaultSettingsBuilder.enforceModeStrictness,
enforceModeEyeBoxWidthFactor: Double = DefaultSettingsBuilder.enforceModeEyeBoxWidthFactor,
enforceModeEyeBoxHeightFactor: Double = DefaultSettingsBuilder.enforceModeEyeBoxHeightFactor,
enforceModeCalibration: EnforceModeCalibration? = DefaultSettingsBuilder.enforceModeCalibration,
hasCompletedOnboarding: Bool = DefaultSettingsBuilder.hasCompletedOnboarding, hasCompletedOnboarding: Bool = DefaultSettingsBuilder.hasCompletedOnboarding,
launchAtLogin: Bool = DefaultSettingsBuilder.launchAtLogin, launchAtLogin: Bool = DefaultSettingsBuilder.launchAtLogin,
playSounds: Bool = DefaultSettingsBuilder.playSounds playSounds: Bool = DefaultSettingsBuilder.playSounds
@@ -73,6 +79,9 @@ struct AppSettings: Codable, Equatable, Hashable, Sendable {
self.subtleReminderSize = subtleReminderSize self.subtleReminderSize = subtleReminderSize
self.smartMode = smartMode self.smartMode = smartMode
self.enforceModeStrictness = enforceModeStrictness self.enforceModeStrictness = enforceModeStrictness
self.enforceModeEyeBoxWidthFactor = enforceModeEyeBoxWidthFactor
self.enforceModeEyeBoxHeightFactor = enforceModeEyeBoxHeightFactor
self.enforceModeCalibration = enforceModeCalibration
self.hasCompletedOnboarding = hasCompletedOnboarding self.hasCompletedOnboarding = hasCompletedOnboarding
self.launchAtLogin = launchAtLogin self.launchAtLogin = launchAtLogin
self.playSounds = playSounds self.playSounds = playSounds

View File

@@ -17,6 +17,9 @@ struct DefaultSettingsBuilder {
static let subtleReminderSize: ReminderSize = .medium static let subtleReminderSize: ReminderSize = .medium
static let smartMode: SmartModeSettings = .defaults static let smartMode: SmartModeSettings = .defaults
static let enforceModeStrictness = 0.4 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 hasCompletedOnboarding = false
static let launchAtLogin = false static let launchAtLogin = false
static let playSounds = true static let playSounds = true
@@ -33,6 +36,9 @@ struct DefaultSettingsBuilder {
subtleReminderSize: subtleReminderSize, subtleReminderSize: subtleReminderSize,
smartMode: smartMode, smartMode: smartMode,
enforceModeStrictness: enforceModeStrictness, enforceModeStrictness: enforceModeStrictness,
enforceModeEyeBoxWidthFactor: enforceModeEyeBoxWidthFactor,
enforceModeEyeBoxHeightFactor: enforceModeEyeBoxHeightFactor,
enforceModeCalibration: enforceModeCalibration,
hasCompletedOnboarding: hasCompletedOnboarding, hasCompletedOnboarding: hasCompletedOnboarding,
launchAtLogin: launchAtLogin, launchAtLogin: launchAtLogin,
playSounds: playSounds 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

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

View File

@@ -33,6 +33,7 @@ class EnforceModeService: ObservableObject {
private var faceDetectionTimer: Timer? private var faceDetectionTimer: Timer?
private var trackingDebugTimer: Timer? private var trackingDebugTimer: Timer?
private var trackingLapStats = TrackingLapStats() private var trackingLapStats = TrackingLapStats()
private var lastLookAwayTime: Date = .distantPast
// MARK: - Configuration // MARK: - Configuration
@@ -183,7 +184,7 @@ class EnforceModeService: ObservableObject {
gazeState: GazeState, gazeState: GazeState,
faceDetected: Bool faceDetected: Bool
) -> ComplianceResult { ) -> ComplianceResult {
guard faceDetected else { return .faceNotDetected } guard faceDetected else { return .compliant }
switch gazeState { switch gazeState {
case .lookingAway: case .lookingAway:
return .compliant return .compliant
@@ -242,11 +243,13 @@ class EnforceModeService: ObservableObject {
switch compliance { switch compliance {
case .compliant: case .compliant:
lastLookAwayTime = Date()
userCompliedWithBreak = true userCompliedWithBreak = true
case .notCompliant: case .notCompliant:
userCompliedWithBreak = false userCompliedWithBreak = false
case .faceNotDetected: case .faceNotDetected:
userCompliedWithBreak = false lastLookAwayTime = Date()
userCompliedWithBreak = true
} }
} }
@@ -321,11 +324,30 @@ class EnforceModeService: ObservableObject {
let timeSinceLastDetection = Date().timeIntervalSince(lastFaceDetectionTime) let timeSinceLastDetection = Date().timeIntervalSince(lastFaceDetectionTime)
if timeSinceLastDetection > faceDetectionTimeout { if timeSinceLastDetection > faceDetectionTimeout {
logDebug("⏰ Person not detected for \(faceDetectionTimeout)s. Temporarily disabling enforce mode.") logDebug("⏰ Person not detected for \(faceDetectionTimeout)s. Assuming look away.")
disableEnforceMode() 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 // MARK: - Test Mode
func startTestMode() async { func startTestMode() async {

View File

@@ -100,6 +100,13 @@ final class CameraSessionManager: NSObject, ObservableObject {
} }
session.addOutput(output) session.addOutput(output)
if let connection = output.connection(with: .video) {
if connection.isVideoMirroringSupported {
connection.automaticallyAdjustsVideoMirroring = false
connection.isVideoMirrored = true
}
}
self.captureSession = session self.captureSession = session
self.videoOutput = output self.videoOutput = output
} }

View File

@@ -49,23 +49,58 @@ class EyeTrackingService: NSObject, ObservableObject {
} }
private func applyStrictness(_ strictness: Double) { 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( let config = TrackingConfig(
horizontalAwayThreshold: 0.08, horizontalAwayThreshold: horizontalThreshold,
verticalAwayThreshold: 0.12, verticalAwayThreshold: verticalThreshold,
minBaselineSamples: TrackingConfig.default.minBaselineSamples, minBaselineSamples: TrackingConfig.default.minBaselineSamples,
baselineSmoothing: TrackingConfig.default.baselineSmoothing, baselineSmoothing: TrackingConfig.default.baselineSmoothing,
baselineUpdateThreshold: TrackingConfig.default.baselineUpdateThreshold, baselineUpdateThreshold: TrackingConfig.default.baselineUpdateThreshold,
minConfidence: TrackingConfig.default.minConfidence, minConfidence: TrackingConfig.default.minConfidence,
eyeClosedThreshold: TrackingConfig.default.eyeClosedThreshold, eyeClosedThreshold: TrackingConfig.default.eyeClosedThreshold,
baselineEnabled: TrackingConfig.default.baselineEnabled, baselineEnabled: baselineEnabled,
defaultCenterHorizontal: TrackingConfig.default.defaultCenterHorizontal, defaultCenterHorizontal: centerHorizontal,
defaultCenterVertical: TrackingConfig.default.defaultCenterVertical, defaultCenterVertical: centerVertical,
faceWidthSmoothing: TrackingConfig.default.faceWidthSmoothing, faceWidthSmoothing: TrackingConfig.default.faceWidthSmoothing,
faceWidthScaleMin: TrackingConfig.default.faceWidthScaleMin, faceWidthScaleMin: TrackingConfig.default.faceWidthScaleMin,
faceWidthScaleMax: 1.4, faceWidthScaleMax: 1.4,
eyeBoundsHorizontalPadding: TrackingConfig.default.eyeBoundsHorizontalPadding, eyeBoundsHorizontalPadding: TrackingConfig.default.eyeBoundsHorizontalPadding,
eyeBoundsVerticalPaddingUp: TrackingConfig.default.eyeBoundsVerticalPaddingUp, eyeBoundsVerticalPaddingUp: TrackingConfig.default.eyeBoundsVerticalPaddingUp,
eyeBoundsVerticalPaddingDown: TrackingConfig.default.eyeBoundsVerticalPaddingDown eyeBoundsVerticalPaddingDown: TrackingConfig.default.eyeBoundsVerticalPaddingDown,
eyeBoxWidthFactor: widthFactor,
eyeBoxHeightFactor: heightFactor
) )
processor.updateConfig(config) processor.updateConfig(config)
@@ -93,6 +128,10 @@ class EyeTrackingService: NSObject, ObservableObject {
debugState = EyeTrackingDebugState.empty debugState = EyeTrackingDebugState.empty
} }
} }
func currentDebugSnapshot() -> EyeTrackingDebugState {
debugState
}
} }
extension EyeTrackingService: CameraSessionDelegate { extension EyeTrackingService: CameraSessionDelegate {

View File

@@ -68,7 +68,9 @@ public struct TrackingConfig: Sendable {
faceWidthScaleMax: Double, faceWidthScaleMax: Double,
eyeBoundsHorizontalPadding: Double, eyeBoundsHorizontalPadding: Double,
eyeBoundsVerticalPaddingUp: Double, eyeBoundsVerticalPaddingUp: Double,
eyeBoundsVerticalPaddingDown: Double eyeBoundsVerticalPaddingDown: Double,
eyeBoxWidthFactor: Double,
eyeBoxHeightFactor: Double
) { ) {
self.horizontalAwayThreshold = horizontalAwayThreshold self.horizontalAwayThreshold = horizontalAwayThreshold
self.verticalAwayThreshold = verticalAwayThreshold self.verticalAwayThreshold = verticalAwayThreshold
@@ -86,6 +88,8 @@ public struct TrackingConfig: Sendable {
self.eyeBoundsHorizontalPadding = eyeBoundsHorizontalPadding self.eyeBoundsHorizontalPadding = eyeBoundsHorizontalPadding
self.eyeBoundsVerticalPaddingUp = eyeBoundsVerticalPaddingUp self.eyeBoundsVerticalPaddingUp = eyeBoundsVerticalPaddingUp
self.eyeBoundsVerticalPaddingDown = eyeBoundsVerticalPaddingDown self.eyeBoundsVerticalPaddingDown = eyeBoundsVerticalPaddingDown
self.eyeBoxWidthFactor = eyeBoxWidthFactor
self.eyeBoxHeightFactor = eyeBoxHeightFactor
} }
public let horizontalAwayThreshold: Double public let horizontalAwayThreshold: Double
@@ -104,6 +108,8 @@ public struct TrackingConfig: Sendable {
public let eyeBoundsHorizontalPadding: Double public let eyeBoundsHorizontalPadding: Double
public let eyeBoundsVerticalPaddingUp: Double public let eyeBoundsVerticalPaddingUp: Double
public let eyeBoundsVerticalPaddingDown: Double public let eyeBoundsVerticalPaddingDown: Double
public let eyeBoxWidthFactor: Double
public let eyeBoxHeightFactor: Double
public static let `default` = TrackingConfig( public static let `default` = TrackingConfig(
horizontalAwayThreshold: 0.08, horizontalAwayThreshold: 0.08,
@@ -121,6 +127,8 @@ public struct TrackingConfig: Sendable {
faceWidthScaleMax: 1.4, faceWidthScaleMax: 1.4,
eyeBoundsHorizontalPadding: 0.1, eyeBoundsHorizontalPadding: 0.1,
eyeBoundsVerticalPaddingUp: 0.9, eyeBoundsVerticalPaddingUp: 0.9,
eyeBoundsVerticalPaddingDown: 0.4 eyeBoundsVerticalPaddingDown: 0.4,
eyeBoxWidthFactor: 0.18,
eyeBoxHeightFactor: 0.10
) )
} }

View File

@@ -48,6 +48,11 @@ final class VisionGazeProcessor: @unchecked Sendable {
faceWidthSmoothed = nil faceWidthSmoothed = nil
} }
func setFaceWidthBaseline(_ value: Double) {
faceWidthBaseline = value
faceWidthSmoothed = value
}
func process(analysis: VisionPipeline.FaceAnalysis) -> ObservationResult { func process(analysis: VisionPipeline.FaceAnalysis) -> ObservationResult {
guard analysis.faceDetected, let face = analysis.face?.value else { guard analysis.faceDetected, let face = analysis.face?.value else {
return ObservationResult( return ObservationResult(
@@ -142,8 +147,12 @@ final class VisionGazeProcessor: @unchecked Sendable {
pupilPoint = bounds.center pupilPoint = bounds.center
} }
let eyeBox = makeFaceRelativeEyeBox(
center: bounds.center,
faceWidth: face.boundingBox.size.width * imageSize.width
)
let paddedFrame = expandRect( let paddedFrame = expandRect(
CGRect(x: bounds.minX, y: bounds.minY, width: bounds.size.width, height: bounds.size.height), eyeBox,
horizontalPadding: config.eyeBoundsHorizontalPadding, horizontalPadding: config.eyeBoundsHorizontalPadding,
verticalPaddingUp: config.eyeBoundsVerticalPaddingUp, verticalPaddingUp: config.eyeBoundsVerticalPaddingUp,
verticalPaddingDown: config.eyeBoundsVerticalPaddingDown 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? { private func averageCoordinate(left: CGFloat?, right: CGFloat?, fallback: Double?) -> Double? {
switch (left, right) { switch (left, right) {
case let (left?, right?): case let (left?, right?):
@@ -313,9 +333,9 @@ final class VisionGazeProcessor: @unchecked Sendable {
let lookingUp = vertical < baseline.vertical let lookingUp = vertical < baseline.vertical
let verticalMultiplier: Double let verticalMultiplier: Double
if lookingDown { if lookingDown {
verticalMultiplier = 1.2 verticalMultiplier = 1.1
} else if lookingUp { } else if lookingUp {
verticalMultiplier = 1.8 verticalMultiplier = 1.4
} else { } else {
verticalMultiplier = 1.0 verticalMultiplier = 1.0
} }

View File

@@ -11,6 +11,20 @@ import AVFoundation
struct CameraPreviewView: NSViewRepresentable { struct CameraPreviewView: NSViewRepresentable {
let previewLayer: AVCaptureVideoPreviewLayer let previewLayer: AVCaptureVideoPreviewLayer
let borderColor: NSColor 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 { func makeNSView(context: Context) -> PreviewContainerView {
let view = PreviewContainerView() let view = PreviewContainerView()
@@ -23,6 +37,11 @@ struct CameraPreviewView: NSViewRepresentable {
view.layer?.addSublayer(previewLayer) view.layer?.addSublayer(previewLayer)
} }
if let connection = previewLayer.connection, connection.isVideoMirroringSupported {
connection.automaticallyAdjustsVideoMirroring = false
connection.isVideoMirrored = true
}
updateBorder(view: view, color: borderColor) updateBorder(view: view, color: borderColor)
return view return view
@@ -42,13 +61,22 @@ struct CameraPreviewView: NSViewRepresentable {
previewLayer.frame = nsView.bounds previewLayer.frame = nsView.bounds
} }
if let connection = previewLayer.connection, connection.isVideoMirroringSupported {
connection.automaticallyAdjustsVideoMirroring = false
connection.isVideoMirrored = true
}
updateBorder(view: nsView, color: borderColor) updateBorder(view: nsView, color: borderColor)
} }
private func updateBorder(view: NSView, color: NSColor) { private func updateBorder(view: NSView, color: NSColor) {
if showsBorder {
view.layer?.borderColor = color.cgColor view.layer?.borderColor = color.cgColor
view.layer?.borderWidth = 4 view.layer?.borderWidth = 4
view.layer?.cornerRadius = 12 } else {
view.layer?.borderWidth = 0
}
view.layer?.cornerRadius = cornerRadius
view.layer?.masksToBounds = true view.layer?.masksToBounds = true
} }

View 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()
}
}

View File

@@ -5,8 +5,8 @@
// Created by Mike Freno on 1/30/26. // Created by Mike Freno on 1/30/26.
// //
import AppKit
import AVFoundation import AVFoundation
import AppKit
import SwiftUI import SwiftUI
struct EnforceModeSetupContent: View { struct EnforceModeSetupContent: View {
@@ -14,6 +14,7 @@ struct EnforceModeSetupContent: View {
@ObservedObject var cameraService = CameraAccessService.shared @ObservedObject var cameraService = CameraAccessService.shared
@ObservedObject var eyeTrackingService = EyeTrackingService.shared @ObservedObject var eyeTrackingService = EyeTrackingService.shared
@ObservedObject var enforceModeService = EnforceModeService.shared @ObservedObject var enforceModeService = EnforceModeService.shared
@ObservedObject var calibrationService = EnforceModeCalibrationService.shared
@Environment(\.isCompactLayout) private var isCompact @Environment(\.isCompactLayout) private var isCompact
let presentation: SetupPresentation let presentation: SetupPresentation
@@ -79,9 +80,7 @@ struct EnforceModeSetupContent: View {
if enforceModeService.isEnforceModeEnabled { if enforceModeService.isEnforceModeEnabled {
strictnessControlView strictnessControlView
} }
if enforceModeService.isCameraActive { calibrationActionView
trackingLapButton
}
privacyInfoView privacyInfoView
} }
@@ -120,7 +119,6 @@ struct EnforceModeSetupContent: View {
.controlSize(presentation.isCard ? .regular : .large) .controlSize(presentation.isCard ? .regular : .large)
} }
private var testModePreviewView: some View { private var testModePreviewView: some View {
VStack(spacing: 16) { VStack(spacing: 16) {
let lookingAway = eyeTrackingService.trackingResult.gazeState == .lookingAway let lookingAway = eyeTrackingService.trackingResult.gazeState == .lookingAway
@@ -140,7 +138,9 @@ struct EnforceModeSetupContent: View {
} }
} }
.frame(height: presentation.isCard ? 180 : (isCompact ? 200 : 300)) .frame(height: presentation.isCard ? 180 : (isCompact ? 200 : 300))
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius)) .glassEffectIfAvailable(
GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius)
)
.onAppear { .onAppear {
if cachedPreviewLayer == nil { if cachedPreviewLayer == nil {
cachedPreviewLayer = eyeTrackingService.previewLayer cachedPreviewLayer = eyeTrackingService.previewLayer
@@ -249,7 +249,8 @@ struct EnforceModeSetupContent: View {
} }
.padding(sectionPadding) .padding(sectionPadding)
.glassEffectIfAvailable( .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, if let horizontal = eyeTrackingService.debugState.normalizedHorizontal,
let vertical = eyeTrackingService.debugState.normalizedVertical { let vertical = eyeTrackingService.debugState.normalizedVertical
{
HStack(spacing: 12) { HStack(spacing: 12) {
Text("Ratios:") Text("Ratios:")
.font(.caption2) .font(.caption2)
@@ -393,13 +395,16 @@ struct EnforceModeSetupContent: View {
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius)) .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius))
} }
private var trackingLapButton: some View { private var calibrationActionView: some View {
Button(action: { Button(action: {
enforceModeService.logTrackingLap() calibrationService.presentOverlay()
Task { @MainActor in
await enforceModeService.startTestMode()
}
}) { }) {
HStack { HStack {
Image(systemName: "flag.checkered") Image(systemName: "target")
Text("Lap Marker") Text("Calibrate Eye Tracking")
.font(.headline) .font(.headline)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@@ -408,4 +413,6 @@ struct EnforceModeSetupContent: View {
.buttonStyle(.bordered) .buttonStyle(.bordered)
.controlSize(.regular) .controlSize(.regular)
} }
} }

View File

@@ -12,15 +12,23 @@ import SwiftUI
struct LookAwayReminderView: View { struct LookAwayReminderView: View {
let countdownSeconds: Int let countdownSeconds: Int
var onDismiss: () -> Void var onDismiss: () -> Void
var enforceModeService: EnforceModeService?
@State private var remainingSeconds: Int @State private var remainingSeconds: Int
@State private var remainingTime: TimeInterval
@State private var timer: Timer? @State private var timer: Timer?
@State private var keyMonitor: Any? @State private var keyMonitor: Any?
init(countdownSeconds: Int, onDismiss: @escaping () -> Void) { init(
countdownSeconds: Int,
enforceModeService: EnforceModeService? = nil,
onDismiss: @escaping () -> Void
) {
self.countdownSeconds = countdownSeconds self.countdownSeconds = countdownSeconds
self.enforceModeService = enforceModeService
self.onDismiss = onDismiss self.onDismiss = onDismiss
self._remainingSeconds = State(initialValue: countdownSeconds) self._remainingSeconds = State(initialValue: countdownSeconds)
self._remainingTime = State(initialValue: TimeInterval(countdownSeconds))
} }
var body: some View { var body: some View {
@@ -100,15 +108,21 @@ struct LookAwayReminderView: View {
} }
private var progress: CGFloat { private var progress: CGFloat {
CGFloat(remainingSeconds) / CGFloat(countdownSeconds) CGFloat(remainingTime) / CGFloat(countdownSeconds)
} }
private func startCountdown() { private func startCountdown() {
let timer = Timer(timeInterval: 1.0, repeats: true) { [self] _ in let tickInterval: TimeInterval = 0.25
if remainingSeconds > 0 { let timer = Timer(timeInterval: tickInterval, repeats: true) { [self] _ in
remainingSeconds -= 1 guard remainingTime > 0 else {
} else {
dismiss() 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) RunLoop.current.add(timer, forMode: .common)