remake of calibration

This commit is contained in:
Michael Freno
2026-02-01 01:02:19 -05:00
parent ac3548e77c
commit 5ae678ffe8
9 changed files with 520 additions and 54 deletions

View File

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

View File

@@ -17,8 +17,8 @@ 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 enforceModeEyeBoxWidthFactor = 0.20
static let enforceModeEyeBoxHeightFactor = 0.02
static let enforceModeCalibration: EnforceModeCalibration? = nil
static let hasCompletedOnboarding = false
static let launchAtLogin = false

View File

@@ -0,0 +1,262 @@
//
// 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 = 1.0
private let preCountdownPause: TimeInterval = 0.5
private let sampleInterval: TimeInterval = 0.1
private let samplesPerTarget = 12
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
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)
let countdownElapsed = max(0, elapsed - self.preCountdownPause)
if elapsed < self.preCountdownPause {
self.countdownProgress = 1.0
return
}
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
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 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 {

View File

@@ -72,12 +72,14 @@ class EyeTrackingService: NSObject, ObservableObject {
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(
@@ -126,6 +128,10 @@ class EyeTrackingService: NSObject, ObservableObject {
debugState = EyeTrackingDebugState.empty
}
}
func currentDebugSnapshot() -> EyeTrackingDebugState {
debugState
}
}
extension EyeTrackingService: CameraSessionDelegate {

View File

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

View File

@@ -0,0 +1,184 @@
//
// 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 {
Color.black.opacity(0.85)
.ignoresSafeArea()
switch calibrationService.currentStep {
case .eyeBox:
eyeBoxStep
case .targets:
targetStep
case .complete:
completionStep
}
}
}
private var eyeBoxStep: some View {
VStack(spacing: 24) {
Text("Adjust Eye Box")
.font(.title2)
.foregroundStyle(.white)
Text(
"Use the sliders to fit the boxes around your eyes. When it looks right, continue."
)
.font(.callout)
.multilineTextAlignment(.center)
.foregroundStyle(.white.opacity(0.8))
eyePreview
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()
.background(.white.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 12))
HStack(spacing: 12) {
Button("Cancel") {
calibrationService.dismissOverlay()
enforceModeService.stopTestMode()
}
.buttonStyle(.bordered)
Button("Continue") {
calibrationService.advance()
}
.buttonStyle(.borderedProminent)
}
}
.padding()
}
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, 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("You can close this window and start testing.")
.font(.callout)
.foregroundStyle(.white.opacity(0.8))
Button("Done") {
calibrationService.dismissOverlay()
enforceModeService.stopTestMode()
}
.buttonStyle(.borderedProminent)
}
}
private var eyePreview: some View {
ZStack {
if let layer = eyeTrackingService.previewLayer {
CameraPreviewView(previewLayer: layer, borderColor: NSColor.systemBlue)
.frame(height: 240)
}
GeometryReader { geometry in
EyeTrackingDebugOverlayView(
debugState: eyeTrackingService.debugState,
viewSize: geometry.size
)
}
}
.frame(height: 240)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
private var targetDot: some View {
GeometryReader { geometry in
let target = calibrationService.currentTarget()
Circle()
.fill(Color.blue)
.frame(width: 100, height: 100)
.position(
x: geometry.size.width * target.x,
y: geometry.size.height * target.y
)
.overlay(
Circle()
.trim(from: 0, to: CGFloat(calibrationService.countdownProgress))
.stroke(Color.blue.opacity(0.8), lineWidth: 6)
.frame(width: 140, height: 140)
.rotationEffect(.degrees(-90))
.animation(.linear(duration: 0.02), value: calibrationService.countdownProgress)
)
}
.ignoresSafeArea()
}
private var countdownRing: some View {
Circle()
.trim(from: 0, to: CGFloat(calibrationService.countdownProgress))
.stroke(Color.blue.opacity(0.8), lineWidth: 6)
.frame(width: 120, height: 120)
.rotationEffect(.degrees(-90))
}
}

View File

@@ -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,12 +80,7 @@ struct EnforceModeSetupContent: View {
if enforceModeService.isEnforceModeEnabled {
strictnessControlView
}
if isTestModeActive && enforceModeService.isCameraActive {
eyeBoxControlView
}
if enforceModeService.isCameraActive {
trackingLapButton
}
calibrationActionView
privacyInfoView
}
@@ -399,44 +395,16 @@ 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 {
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)
@@ -445,4 +413,6 @@ struct EnforceModeSetupContent: View {
.buttonStyle(.bordered)
.controlSize(.regular)
}
}

View File

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