This commit is contained in:
Michael Freno
2026-01-29 17:37:05 -05:00
parent 817f391305
commit 8631fa7207
8 changed files with 158 additions and 248 deletions

View File

@@ -1,29 +0,0 @@
//
// CalibrationSampleCollector.swift
// Gaze
//
// Created by Mike Freno on 1/29/26.
//
import Foundation
struct CalibrationSampleCollector {
mutating func addSample(
to data: inout CalibrationData,
step: CalibrationStep,
leftRatio: Double?,
rightRatio: Double?,
leftVertical: Double?,
rightVertical: Double?,
faceWidthRatio: Double?
) {
let sample = GazeSample(
leftRatio: leftRatio,
rightRatio: rightRatio,
leftVerticalRatio: leftVertical,
rightVerticalRatio: rightVertical,
faceWidthRatio: faceWidthRatio
)
data.addSample(sample, for: step)
}
}

View File

@@ -1,54 +0,0 @@
//
// CalibrationWindowManager.swift
// Gaze
//
// Manages the fullscreen calibration overlay window.
//
import AppKit
import SwiftUI
@MainActor
final class CalibrationWindowManager {
static let shared = CalibrationWindowManager()
private var windowController: NSWindowController?
private init() {}
func showCalibrationOverlay() {
guard let screen = NSScreen.main else { return }
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 = CalibrationOverlayView {
self.dismissCalibrationOverlay()
}
window.contentView = NSHostingView(rootView: overlayView)
windowController = NSWindowController(window: window)
windowController?.showWindow(nil)
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
print("🎯 Calibration overlay window opened")
}
func dismissCalibrationOverlay() {
windowController?.close()
windowController = nil
print("🎯 Calibration overlay window closed")
}
}

View File

@@ -1,52 +1,45 @@
//
// CalibrationManager.swift
// CalibratorService.swift
// Gaze
//
// Created by Mike Freno on 1/15/26.
// Created by Mike Freno on 1/29/26.
//
import Combine
import Foundation
import AppKit
import SwiftUI
@MainActor
class CalibrationManager: ObservableObject {
static let shared = CalibrationManager()
// MARK: - Published Properties
final class CalibratorService: ObservableObject {
static let shared = CalibratorService()
@Published var isCalibrating = false
@Published var isCollectingSamples = false // True when actively collecting (after countdown)
@Published var isCollectingSamples = false
@Published var currentStep: CalibrationStep?
@Published var currentStepIndex = 0
@Published var samplesCollected = 0
@Published var calibrationData = CalibrationData()
// MARK: - Configuration
private let samplesPerStep = 30 // Collect 30 samples per calibration point (~1 second at 30fps)
private let samplesPerStep = 30
private let userDefaultsKey = "eyeTrackingCalibration"
private let calibrationSteps: [CalibrationStep] = [
.center,
.left,
.right,
.farLeft,
.farRight,
.up,
.down,
.topLeft,
.topRight
]
private let flowController: CalibrationFlowController
private var sampleCollector = CalibrationSampleCollector()
// MARK: - Initialization
private var windowController: NSWindowController?
private init() {
self.flowController = CalibrationFlowController(
samplesPerStep: samplesPerStep,
calibrationSteps: calibrationSteps
calibrationSteps: [
.center,
.left,
.right,
.farLeft,
.farRight,
.up,
.down,
.topLeft,
.topRight
]
)
loadCalibration()
bindFlowController()
@@ -63,8 +56,6 @@ class CalibrationManager: ObservableObject {
.assign(to: &$samplesCollected)
}
// MARK: - Calibration Flow
func startCalibration() {
print("🎯 Starting calibration...")
isCalibrating = true
@@ -72,7 +63,6 @@ class CalibrationManager: ObservableObject {
calibrationData = CalibrationData()
}
/// Reset state for a new calibration attempt (clears isComplete flag from previous calibration)
func resetForNewCalibration() {
print("🔄 Resetting for new calibration...")
calibrationData = CalibrationData()
@@ -94,15 +84,14 @@ class CalibrationManager: ObservableObject {
) {
guard isCalibrating, isCollectingSamples, let step = currentStep else { return }
sampleCollector.addSample(
to: &calibrationData,
step: step,
let sample = GazeSample(
leftRatio: leftRatio,
rightRatio: rightRatio,
leftVertical: leftVertical,
rightVertical: rightVertical,
leftVerticalRatio: leftVertical,
rightVerticalRatio: rightVertical,
faceWidthRatio: faceWidthRatio
)
calibrationData.addSample(sample, for: step)
if flowController.markSampleCollected() {
advanceToNextStep()
@@ -118,13 +107,48 @@ class CalibrationManager: ObservableObject {
}
func skipStep() {
// Allow skipping optional steps (up, down, diagonals)
guard isCalibrating, let step = currentStep else { return }
print("⏭️ Skipping calibration step: \(step.displayName)")
advanceToNextStep()
}
func showCalibrationOverlay() {
guard let screen = NSScreen.main else { return }
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 = CalibrationOverlayView {
self.dismissCalibrationOverlay()
}
window.contentView = NSHostingView(rootView: overlayView)
windowController = NSWindowController(window: window)
windowController?.showWindow(nil)
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
print("🎯 Calibration overlay window opened")
}
func dismissCalibrationOverlay() {
windowController?.close()
windowController = nil
print("🎯 Calibration overlay window closed")
}
func finishCalibration() {
print("✓ Calibration complete, calculating thresholds...")
@@ -150,8 +174,6 @@ class CalibrationManager: ObservableObject {
CalibrationState.shared.reset()
}
// MARK: - Persistence
private func saveCalibration() {
do {
let encoder = JSONEncoder()
@@ -189,14 +211,10 @@ class CalibrationManager: ObservableObject {
func clearCalibration() {
UserDefaults.standard.removeObject(forKey: userDefaultsKey)
calibrationData = CalibrationData()
CalibrationState.shared.reset()
print("🗑️ Calibration data cleared")
}
// MARK: - Validation
func isCalibrationValid() -> Bool {
guard calibrationData.isComplete,
let thresholds = calibrationData.computedThresholds,
@@ -210,15 +228,12 @@ class CalibrationManager: ObservableObject {
return !isCalibrationValid()
}
// MARK: - Apply Calibration
private func applyCalibration() {
guard let thresholds = calibrationData.computedThresholds else {
print("⚠️ No thresholds to apply")
return
}
// Push to thread-safe state for background processing
CalibrationState.shared.setThresholds(thresholds)
CalibrationState.shared.setComplete(true)
@@ -230,8 +245,6 @@ class CalibrationManager: ObservableObject {
print(" Screen Bounds: [\(String(format: "%.2f", thresholds.screenRightBound))..\(String(format: "%.2f", thresholds.screenLeftBound))] x [\(String(format: "%.2f", thresholds.screenTopBound))..\(String(format: "%.2f", thresholds.screenBottomBound))]")
}
// MARK: - Statistics
func getCalibrationSummary() -> String {
guard calibrationData.isComplete else {
return "No calibration data"
@@ -252,8 +265,6 @@ class CalibrationManager: ObservableObject {
return summary
}
// MARK: - Progress
var progress: Double {
flowController.progress
}
@@ -261,4 +272,22 @@ class CalibrationManager: ObservableObject {
var progressText: String {
flowController.progressText
}
func submitSampleToBridge(
leftRatio: Double,
rightRatio: Double,
leftVertical: Double? = nil,
rightVertical: Double? = nil,
faceWidthRatio: Double = 0
) {
Task { @MainActor in
collectSample(
leftRatio: leftRatio,
rightRatio: rightRatio,
leftVertical: leftVertical,
rightVertical: rightVertical,
faceWidthRatio: faceWidthRatio
)
}
}
}

View File

@@ -1,36 +0,0 @@
//
// CalibrationBridge.swift
// Gaze
//
// Thread-safe calibration access for eye tracking.
//
import Foundation
final class CalibrationBridge: @unchecked Sendable {
nonisolated var thresholds: GazeThresholds? {
CalibrationState.shared.thresholds
}
nonisolated var isComplete: Bool {
CalibrationState.shared.isComplete
}
nonisolated func submitSample(
leftRatio: Double,
rightRatio: Double,
leftVertical: Double?,
rightVertical: Double?,
faceWidthRatio: Double
) {
Task { @MainActor in
CalibrationManager.shared.collectSample(
leftRatio: leftRatio,
rightRatio: rightRatio,
leftVertical: leftVertical,
rightVertical: rightVertical,
faceWidthRatio: faceWidthRatio
)
}
}
}

View File

@@ -44,7 +44,6 @@ class EyeTrackingService: NSObject, ObservableObject {
private let cameraManager = CameraSessionManager()
private let visionPipeline = VisionPipeline()
private let debugAdapter = EyeDebugStateAdapter()
private let calibrationBridge = CalibrationBridge()
private let gazeDetector: GazeDetector
var previewLayer: AVCaptureVideoPreviewLayer? {
@@ -135,10 +134,11 @@ class EyeTrackingService: NSObject, ObservableObject {
debugImageSize = debugAdapter.imageSize
}
nonisolated private func updateGazeConfiguration() {
@MainActor
private func updateGazeConfiguration() {
let configuration = GazeDetector.Configuration(
thresholds: calibrationBridge.thresholds,
isCalibrationComplete: calibrationBridge.isComplete,
thresholds: CalibrationState.shared.thresholds,
isCalibrationComplete: CalibratorService.shared.isCalibrating || CalibrationState.shared.isComplete,
eyeClosedEnabled: EyeTrackingConstants.eyeClosedEnabled,
eyeClosedThreshold: EyeTrackingConstants.eyeClosedThreshold,
yawEnabled: EyeTrackingConstants.yawEnabled,
@@ -173,8 +173,8 @@ extension EyeTrackingService: CameraSessionDelegate {
let rightRatio = result.rightPupilRatio,
let faceWidth = result.faceWidthRatio {
Task { @MainActor in
guard CalibrationManager.shared.isCalibrating else { return }
calibrationBridge.submitSample(
guard CalibratorService.shared.isCalibrating else { return }
CalibratorService.shared.submitSampleToBridge(
leftRatio: leftRatio,
rightRatio: rightRatio,
leftVertical: result.leftVerticalRatio,

View File

@@ -10,7 +10,7 @@ import Combine
import SwiftUI
struct CalibrationOverlayView: View {
@StateObject private var calibrationManager = CalibrationManager.shared
@StateObject private var calibratorService = CalibratorService.shared
@StateObject private var eyeTrackingService = EyeTrackingService.shared
@StateObject private var viewModel = CalibrationOverlayViewModel()
@@ -33,10 +33,10 @@ struct CalibrationOverlayView: View {
errorView(error)
} else if !viewModel.cameraStarted {
startingCameraView
} else if calibrationManager.isCalibrating {
} else if calibratorService.isCalibrating {
calibrationContentView(screenSize: geometry.size)
} else if viewModel.calibrationStarted
&& calibrationManager.calibrationData.isComplete
&& calibratorService.calibrationData.isComplete
{
// Only show completion if we started calibration this session AND it completed
completionView
@@ -48,15 +48,15 @@ struct CalibrationOverlayView: View {
}
.task {
await viewModel.startCamera(
eyeTrackingService: eyeTrackingService, calibrationManager: calibrationManager)
eyeTrackingService: eyeTrackingService, calibratorService: calibratorService)
}
.onDisappear {
viewModel.cleanup(
eyeTrackingService: eyeTrackingService, calibrationManager: calibrationManager)
eyeTrackingService: eyeTrackingService, calibratorService: calibratorService)
}
.onChange(of: calibrationManager.currentStep) { oldStep, newStep in
.onChange(of: calibratorService.currentStep) { oldStep, newStep in
if newStep != nil && oldStep != newStep {
viewModel.startStepCountdown(calibrationManager: calibrationManager)
viewModel.startStepCountdown(calibratorService: calibratorService)
}
}
}
@@ -110,7 +110,7 @@ struct CalibrationOverlayView: View {
Spacer()
}
if let step = calibrationManager.currentStep {
if let step = calibratorService.currentStep {
calibrationTarget(for: step, screenSize: screenSize)
}
@@ -119,7 +119,7 @@ struct CalibrationOverlayView: View {
HStack {
cancelButton
Spacer()
if !calibrationManager.isCollectingSamples {
if !calibratorService.isCollectingSamples {
skipButton
}
}
@@ -146,11 +146,11 @@ struct CalibrationOverlayView: View {
Text("Calibrating...")
.foregroundStyle(.white)
Spacer()
Text(calibrationManager.progressText)
Text(calibratorService.progressText)
.foregroundStyle(.white.opacity(0.7))
}
ProgressView(value: calibrationManager.progress)
ProgressView(value: calibratorService.progress)
.progressViewStyle(.linear)
.tint(.blue)
}
@@ -198,29 +198,29 @@ struct CalibrationOverlayView: View {
value: viewModel.isCountingDown)
// Progress ring when collecting
if calibrationManager.isCollectingSamples {
if calibratorService.isCollectingSamples {
Circle()
.trim(from: 0, to: CGFloat(calibrationManager.samplesCollected) / 30.0)
.trim(from: 0, to: CGFloat(calibratorService.samplesCollected) / 30.0)
.stroke(Color.green, lineWidth: 4)
.frame(width: 90, height: 90)
.rotationEffect(.degrees(-90))
.animation(
.linear(duration: 0.1), value: calibrationManager.samplesCollected)
.linear(duration: 0.1), value: calibratorService.samplesCollected)
}
// Inner circle
Circle()
.fill(calibrationManager.isCollectingSamples ? Color.green : Color.blue)
.fill(calibratorService.isCollectingSamples ? Color.green : Color.blue)
.frame(width: 60, height: 60)
.animation(
.easeInOut(duration: 0.3), value: calibrationManager.isCollectingSamples)
.easeInOut(duration: 0.3), value: calibratorService.isCollectingSamples)
// Countdown number or collecting indicator
if viewModel.isCountingDown && viewModel.countdownValue > 0 {
Text("\(viewModel.countdownValue)")
.font(.system(size: 36, weight: .bold))
.foregroundStyle(.white)
} else if calibrationManager.isCollectingSamples {
} else if calibratorService.isCollectingSamples {
Image(systemName: "eye.fill")
.font(.system(size: 24, weight: .bold))
.foregroundStyle(.white)
@@ -241,7 +241,7 @@ struct CalibrationOverlayView: View {
private func instructionText(for step: CalibrationStep) -> String {
if viewModel.isCountingDown && viewModel.countdownValue > 0 {
return "Get ready..."
} else if calibrationManager.isCollectingSamples {
} else if calibratorService.isCollectingSamples {
return "Look at the target"
} else {
return step.instructionText
@@ -252,7 +252,7 @@ struct CalibrationOverlayView: View {
private var skipButton: some View {
Button {
viewModel.skipCurrentStep(calibrationManager: calibrationManager)
viewModel.skipCurrentStep(calibratorService: calibratorService)
} label: {
Text("Skip")
.foregroundStyle(.white)
@@ -267,7 +267,7 @@ struct CalibrationOverlayView: View {
private var cancelButton: some View {
Button {
viewModel.cleanup(
eyeTrackingService: eyeTrackingService, calibrationManager: calibrationManager)
eyeTrackingService: eyeTrackingService, calibratorService: calibratorService)
onDismiss()
} label: {
HStack(spacing: 6) {
@@ -369,7 +369,7 @@ class CalibrationOverlayViewModel: ObservableObject {
private var lastFaceDetectedTime: Date = .distantPast
private let faceDetectionDebounce: TimeInterval = 0.5 // 500ms debounce
func startCamera(eyeTrackingService: EyeTrackingService, calibrationManager: CalibrationManager)
func startCamera(eyeTrackingService: EyeTrackingService, calibratorService: CalibratorService)
async
{
do {
@@ -382,10 +382,10 @@ class CalibrationOverlayViewModel: ObservableObject {
try? await Task.sleep(for: .seconds(0.5))
// Reset any previous calibration data before starting fresh
calibrationManager.resetForNewCalibration()
calibrationManager.startCalibration()
calibratorService.resetForNewCalibration()
calibratorService.startCalibration()
calibrationStarted = true
startStepCountdown(calibrationManager: calibrationManager)
startStepCountdown(calibratorService: calibratorService)
} catch {
showError = "Failed to start camera: \(error.localizedDescription)"
}
@@ -411,28 +411,28 @@ class CalibrationOverlayViewModel: ObservableObject {
}
}
func cleanup(eyeTrackingService: EyeTrackingService, calibrationManager: CalibrationManager) {
func cleanup(eyeTrackingService: EyeTrackingService, calibratorService: CalibratorService) {
countdownTask?.cancel()
countdownTask = nil
faceDetectionCancellable?.cancel()
faceDetectionCancellable = nil
isCountingDown = false
if calibrationManager.isCalibrating {
calibrationManager.cancelCalibration()
if calibratorService.isCalibrating {
calibratorService.cancelCalibration()
}
eyeTrackingService.stopEyeTracking()
}
func skipCurrentStep(calibrationManager: CalibrationManager) {
func skipCurrentStep(calibratorService: CalibratorService) {
countdownTask?.cancel()
countdownTask = nil
isCountingDown = false
calibrationManager.skipStep()
calibratorService.skipStep()
}
func startStepCountdown(calibrationManager: CalibrationManager) {
func startStepCountdown(calibratorService: CalibratorService) {
countdownTask?.cancel()
countdownTask = nil
countdownValue = 1
@@ -446,7 +446,7 @@ class CalibrationOverlayViewModel: ObservableObject {
// Done counting, start collecting
isCountingDown = false
countdownValue = 0
calibrationManager.startCollectingSamples()
calibratorService.startCollectingSamples()
}
}
}

View File

@@ -8,7 +8,7 @@
import SwiftUI
struct EyeTrackingCalibrationView: View {
@StateObject private var calibrationManager = CalibrationManager.shared
@StateObject private var calibratorService = CalibratorService.shared
@Environment(\.dismiss) private var dismiss
var body: some View {
@@ -46,12 +46,12 @@ struct EyeTrackingCalibrationView: View {
}
.padding(.vertical, 20)
if calibrationManager.calibrationData.isComplete {
if calibratorService.calibrationData.isComplete {
VStack(spacing: 10) {
Text("Last calibration:")
.font(.caption)
.foregroundStyle(.gray)
Text(calibrationManager.getCalibrationSummary())
Text(calibratorService.getCalibrationSummary())
.font(.caption)
.multilineTextAlignment(.center)
.foregroundStyle(.gray)
@@ -86,7 +86,7 @@ struct EyeTrackingCalibrationView: View {
// Small delay to allow sheet dismissal animation
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
CalibrationWindowManager.shared.showCalibrationOverlay()
CalibratorService.shared.showCalibrationOverlay()
}
}
}

View File

@@ -23,7 +23,7 @@ struct EnforceModeSetupView: View {
@State private var isViewActive = false
@State private var showAdvancedSettings = false
@State private var showCalibrationWindow = false
@ObservedObject var calibrationManager = CalibrationManager.shared
@ObservedObject var calibratorService = CalibratorService.shared
private var cameraHardwareAvailable: Bool {
cameraService.hasCameraHardware
@@ -155,13 +155,13 @@ struct EnforceModeSetupView: View {
.font(.headline)
}
if calibrationManager.calibrationData.isComplete {
if calibratorService.calibrationData.isComplete {
VStack(alignment: .leading, spacing: 8) {
Text(calibrationManager.getCalibrationSummary())
Text(calibratorService.getCalibrationSummary())
.font(.caption)
.foregroundStyle(.secondary)
if calibrationManager.needsRecalibration() {
if calibratorService.needsRecalibration() {
Label(
"Calibration expired - recalibration recommended",
systemImage: "exclamationmark.triangle.fill"
@@ -186,7 +186,7 @@ struct EnforceModeSetupView: View {
HStack {
Image(systemName: "target")
Text(
calibrationManager.calibrationData.isComplete
calibratorService.calibrationData.isComplete
? "Recalibrate" : "Run Calibration")
}
.frame(maxWidth: .infinity)