From a20b3701a64eafa4572985655d67173c10352191 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Sat, 31 Jan 2026 23:34:07 -0500 Subject: [PATCH] tweaking --- Gaze/Models/AppSettings.swift | 3 + Gaze/Models/DefaultSettingsBuilder.swift | 2 + Gaze/Services/EnforceModeService.swift | 98 +++++++++++++++++++ .../EyeTracking/EyeTrackingService.swift | 36 +++++++ .../Services/EyeTracking/TrackingModels.swift | 62 +++++++++++- .../EyeTracking/VisionGazeProcessor.swift | 87 ++++++++++++++-- .../Components/EnforceModeSetupContent.swift | 73 ++++++++++++++ 7 files changed, 350 insertions(+), 11 deletions(-) diff --git a/Gaze/Models/AppSettings.swift b/Gaze/Models/AppSettings.swift index 39b5d66..7a7f816 100644 --- a/Gaze/Models/AppSettings.swift +++ b/Gaze/Models/AppSettings.swift @@ -42,6 +42,7 @@ struct AppSettings: Codable, Equatable, Hashable, Sendable { var subtleReminderSize: ReminderSize var smartMode: SmartModeSettings + var enforceModeStrictness: Double var hasCompletedOnboarding: Bool var launchAtLogin: Bool @@ -57,6 +58,7 @@ struct AppSettings: Codable, Equatable, Hashable, Sendable { userTimers: [UserTimer] = [], subtleReminderSize: ReminderSize = DefaultSettingsBuilder.subtleReminderSize, smartMode: SmartModeSettings = DefaultSettingsBuilder.smartMode, + enforceModeStrictness: Double = DefaultSettingsBuilder.enforceModeStrictness, hasCompletedOnboarding: Bool = DefaultSettingsBuilder.hasCompletedOnboarding, launchAtLogin: Bool = DefaultSettingsBuilder.launchAtLogin, playSounds: Bool = DefaultSettingsBuilder.playSounds @@ -70,6 +72,7 @@ struct AppSettings: Codable, Equatable, Hashable, Sendable { self.userTimers = userTimers self.subtleReminderSize = subtleReminderSize self.smartMode = smartMode + self.enforceModeStrictness = enforceModeStrictness self.hasCompletedOnboarding = hasCompletedOnboarding self.launchAtLogin = launchAtLogin self.playSounds = playSounds diff --git a/Gaze/Models/DefaultSettingsBuilder.swift b/Gaze/Models/DefaultSettingsBuilder.swift index 05f01ef..5ebcebe 100644 --- a/Gaze/Models/DefaultSettingsBuilder.swift +++ b/Gaze/Models/DefaultSettingsBuilder.swift @@ -16,6 +16,7 @@ struct DefaultSettingsBuilder { static let postureIntervalMinutes = 30 static let subtleReminderSize: ReminderSize = .medium static let smartMode: SmartModeSettings = .defaults + static let enforceModeStrictness = 0.4 static let hasCompletedOnboarding = false static let launchAtLogin = false static let playSounds = true @@ -31,6 +32,7 @@ struct DefaultSettingsBuilder { userTimers: [], subtleReminderSize: subtleReminderSize, smartMode: smartMode, + enforceModeStrictness: enforceModeStrictness, hasCompletedOnboarding: hasCompletedOnboarding, launchAtLogin: launchAtLogin, playSounds: playSounds diff --git a/Gaze/Services/EnforceModeService.swift b/Gaze/Services/EnforceModeService.swift index 73e79e4..ad416c4 100644 --- a/Gaze/Services/EnforceModeService.swift +++ b/Gaze/Services/EnforceModeService.swift @@ -31,6 +31,8 @@ class EnforceModeService: ObservableObject { private var timerEngine: TimerEngine? private var cancellables = Set() private var faceDetectionTimer: Timer? + private var trackingDebugTimer: Timer? + private var trackingLapStats = TrackingLapStats() // MARK: - Configuration @@ -83,6 +85,17 @@ class EnforceModeService: ObservableObject { self?.refreshEnforceModeState() } .store(in: &cancellables) + + $isCameraActive + .removeDuplicates() + .sink { [weak self] isActive in + if isActive { + self?.startTrackingDebugTimer() + } else { + self?.stopTrackingDebugTimer() + } + } + .store(in: &cancellables) } private func refreshEnforceModeState() { @@ -211,6 +224,7 @@ class EnforceModeService: ObservableObject { eyeTrackingService.stopEyeTracking() isCameraActive = false stopFaceDetectionTimer() + stopTrackingDebugTimer() userCompliedWithBreak = false } @@ -257,6 +271,48 @@ class EnforceModeService: ObservableObject { faceDetectionTimer = nil } + private func startTrackingDebugTimer() { + stopTrackingDebugTimer() + trackingDebugTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { [weak self] _ in + self?.logTrackingDebugSnapshot() + } + } + + private func stopTrackingDebugTimer() { + trackingDebugTimer?.invalidate() + trackingDebugTimer = nil + } + + private func logTrackingDebugSnapshot() { + guard isCameraActive else { return } + + let debugState = eyeTrackingService.debugState + let faceWidth = debugState.faceWidthRatio.map { String(format: "%.3f", $0) } ?? "-" + let horizontal = debugState.normalizedHorizontal.map { String(format: "%.3f", $0) } ?? "-" + let vertical = debugState.normalizedVertical.map { String(format: "%.3f", $0) } ?? "-" + + trackingLapStats.ingest( + faceWidth: debugState.faceWidthRatio, + horizontal: debugState.normalizedHorizontal, + vertical: debugState.normalizedVertical + ) + + logDebug( + "📊 Tracking | faceWidth=\(faceWidth) | h=\(horizontal) | v=\(vertical)", + category: "EyeTracking" + ) + } + + func logTrackingLap() { + logDebug("🏁 Tracking Lap", category: "EyeTracking") + logTrackingDebugSnapshot() + + if let summary = trackingLapStats.summaryString() { + logDebug("📈 Lap Stats | \(summary)", category: "EyeTracking") + } + trackingLapStats.reset() + } + private func checkFaceDetectionTimeout() { guard isCameraActive else { stopFaceDetectionTimer() @@ -295,3 +351,45 @@ class EnforceModeService: ObservableObject { isTestMode = false } } + +private struct TrackingLapStats { + private var faceWidthValues: [Double] = [] + private var horizontalValues: [Double] = [] + private var verticalValues: [Double] = [] + + mutating func ingest(faceWidth: Double?, horizontal: Double?, vertical: Double?) { + if let faceWidth { faceWidthValues.append(faceWidth) } + if let horizontal { horizontalValues.append(horizontal) } + if let vertical { verticalValues.append(vertical) } + } + + mutating func reset() { + faceWidthValues.removeAll(keepingCapacity: true) + horizontalValues.removeAll(keepingCapacity: true) + verticalValues.removeAll(keepingCapacity: true) + } + + func summaryString() -> String? { + guard !faceWidthValues.isEmpty || !horizontalValues.isEmpty || !verticalValues.isEmpty else { + return nil + } + + let faceWidth = stats(for: faceWidthValues) + let horizontal = stats(for: horizontalValues) + let vertical = stats(for: verticalValues) + + return "faceWidth[\(faceWidth)] h[\(horizontal)] v[\(vertical)]" + } + + private func stats(for values: [Double]) -> String { + guard let minValue = values.min(), let maxValue = values.max(), !values.isEmpty else { + return "-" + } + let mean = values.reduce(0, +) / Double(values.count) + return "min=\(format(minValue)) max=\(format(maxValue)) mean=\(format(mean))" + } + + private func format(_ value: Double) -> String { + String(format: "%.3f", value) + } +} diff --git a/Gaze/Services/EyeTracking/EyeTrackingService.swift b/Gaze/Services/EyeTracking/EyeTrackingService.swift index 1583f3c..3c2c300 100644 --- a/Gaze/Services/EyeTracking/EyeTrackingService.swift +++ b/Gaze/Services/EyeTracking/EyeTrackingService.swift @@ -19,6 +19,7 @@ class EyeTrackingService: NSObject, ObservableObject { private let cameraManager = CameraSessionManager() private let visionPipeline = VisionPipeline() private let processor: VisionGazeProcessor + private var cancellables = Set() var previewLayer: AVCaptureVideoPreviewLayer? { cameraManager.previewLayer @@ -33,6 +34,41 @@ class EyeTrackingService: NSObject, ObservableObject { self.processor = VisionGazeProcessor(config: config) super.init() cameraManager.delegate = self + setupSettingsObserver() + } + + private func setupSettingsObserver() { + SettingsManager.shared._settingsSubject + .receive(on: RunLoop.main) + .sink { [weak self] settings in + self?.applyStrictness(settings.enforceModeStrictness) + } + .store(in: &cancellables) + + applyStrictness(SettingsManager.shared.settings.enforceModeStrictness) + } + + private func applyStrictness(_ strictness: Double) { + let config = TrackingConfig( + horizontalAwayThreshold: 0.08, + verticalAwayThreshold: 0.12, + 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, + faceWidthSmoothing: TrackingConfig.default.faceWidthSmoothing, + faceWidthScaleMin: TrackingConfig.default.faceWidthScaleMin, + faceWidthScaleMax: 1.4, + eyeBoundsHorizontalPadding: TrackingConfig.default.eyeBoundsHorizontalPadding, + eyeBoundsVerticalPaddingUp: TrackingConfig.default.eyeBoundsVerticalPaddingUp, + eyeBoundsVerticalPaddingDown: TrackingConfig.default.eyeBoundsVerticalPaddingDown + ) + + processor.updateConfig(config) } func startEyeTracking() async throws { diff --git a/Gaze/Services/EyeTracking/TrackingModels.swift b/Gaze/Services/EyeTracking/TrackingModels.swift index 5b2e153..a3125b5 100644 --- a/Gaze/Services/EyeTracking/TrackingModels.swift +++ b/Gaze/Services/EyeTracking/TrackingModels.swift @@ -35,17 +35,59 @@ public struct EyeTrackingDebugState: Sendable { public let leftPupil: CGPoint? public let rightPupil: CGPoint? public let imageSize: CGSize? + public let faceWidthRatio: Double? + public let normalizedHorizontal: Double? + public let normalizedVertical: Double? public static let empty = EyeTrackingDebugState( leftEyeRect: nil, rightEyeRect: nil, leftPupil: nil, rightPupil: nil, - imageSize: nil + imageSize: nil, + faceWidthRatio: nil, + normalizedHorizontal: nil, + normalizedVertical: nil ) } public struct TrackingConfig: Sendable { + public init( + horizontalAwayThreshold: Double, + verticalAwayThreshold: Double, + minBaselineSamples: Int, + baselineSmoothing: Double, + baselineUpdateThreshold: Double, + minConfidence: Double, + eyeClosedThreshold: Double, + baselineEnabled: Bool, + defaultCenterHorizontal: Double, + defaultCenterVertical: Double, + faceWidthSmoothing: Double, + faceWidthScaleMin: Double, + faceWidthScaleMax: Double, + eyeBoundsHorizontalPadding: Double, + eyeBoundsVerticalPaddingUp: Double, + eyeBoundsVerticalPaddingDown: Double + ) { + self.horizontalAwayThreshold = horizontalAwayThreshold + self.verticalAwayThreshold = verticalAwayThreshold + self.minBaselineSamples = minBaselineSamples + self.baselineSmoothing = baselineSmoothing + self.baselineUpdateThreshold = baselineUpdateThreshold + self.minConfidence = minConfidence + self.eyeClosedThreshold = eyeClosedThreshold + self.baselineEnabled = baselineEnabled + self.defaultCenterHorizontal = defaultCenterHorizontal + self.defaultCenterVertical = defaultCenterVertical + self.faceWidthSmoothing = faceWidthSmoothing + self.faceWidthScaleMin = faceWidthScaleMin + self.faceWidthScaleMax = faceWidthScaleMax + self.eyeBoundsHorizontalPadding = eyeBoundsHorizontalPadding + self.eyeBoundsVerticalPaddingUp = eyeBoundsVerticalPaddingUp + self.eyeBoundsVerticalPaddingDown = eyeBoundsVerticalPaddingDown + } + public let horizontalAwayThreshold: Double public let verticalAwayThreshold: Double public let minBaselineSamples: Int @@ -56,10 +98,16 @@ public struct TrackingConfig: Sendable { public let baselineEnabled: Bool public let defaultCenterHorizontal: Double public let defaultCenterVertical: Double + public let faceWidthSmoothing: Double + public let faceWidthScaleMin: Double + public let faceWidthScaleMax: Double + public let eyeBoundsHorizontalPadding: Double + public let eyeBoundsVerticalPaddingUp: Double + public let eyeBoundsVerticalPaddingDown: Double public static let `default` = TrackingConfig( - horizontalAwayThreshold: 0.12, - verticalAwayThreshold: 0.18, + horizontalAwayThreshold: 0.08, + verticalAwayThreshold: 0.12, minBaselineSamples: 8, baselineSmoothing: 0.15, baselineUpdateThreshold: 0.08, @@ -67,6 +115,12 @@ public struct TrackingConfig: Sendable { eyeClosedThreshold: 0.18, baselineEnabled: true, defaultCenterHorizontal: 0.5, - defaultCenterVertical: 0.5 + defaultCenterVertical: 0.5, + faceWidthSmoothing: 0.12, + faceWidthScaleMin: 0.85, + faceWidthScaleMax: 1.4, + eyeBoundsHorizontalPadding: 0.1, + eyeBoundsVerticalPaddingUp: 0.9, + eyeBoundsVerticalPaddingDown: 0.4 ) } diff --git a/Gaze/Services/EyeTracking/VisionGazeProcessor.swift b/Gaze/Services/EyeTracking/VisionGazeProcessor.swift index 8e51571..ace8008 100644 --- a/Gaze/Services/EyeTracking/VisionGazeProcessor.swift +++ b/Gaze/Services/EyeTracking/VisionGazeProcessor.swift @@ -30,6 +30,8 @@ final class VisionGazeProcessor: @unchecked Sendable { } private let baselineModel = GazeBaselineModel() + private var faceWidthBaseline: Double? + private var faceWidthSmoothed: Double? private var config: TrackingConfig init(config: TrackingConfig) { @@ -42,6 +44,8 @@ final class VisionGazeProcessor: @unchecked Sendable { func resetBaseline() { baselineModel.reset() + faceWidthBaseline = nil + faceWidthSmoothed = nil } func process(analysis: VisionPipeline.FaceAnalysis) -> ObservationResult { @@ -84,13 +88,16 @@ final class VisionGazeProcessor: @unchecked Sendable { let eyesClosed = detectEyesClosed(left: leftEye, right: rightEye) let (horizontal, vertical) = normalizePupilPosition(left: leftEye, right: rightEye) + let faceWidthRatio = Double(face.boundingBox.size.width) + let distanceScale = updateDistanceScale(faceWidthRatio: faceWidthRatio) let confidence = calculateConfidence(leftEye: leftEye, rightEye: rightEye) let gazeState = decideGazeState( horizontal: horizontal, vertical: vertical, confidence: confidence, - eyesClosed: eyesClosed + eyesClosed: eyesClosed, + distanceScale: distanceScale ) let debugState = EyeTrackingDebugState( @@ -98,7 +105,10 @@ final class VisionGazeProcessor: @unchecked Sendable { rightEyeRect: rightEye?.frame, leftPupil: leftEye?.pupil, rightPupil: rightEye?.pupil, - imageSize: analysis.imageSize + imageSize: analysis.imageSize, + faceWidthRatio: faceWidthRatio, + normalizedHorizontal: horizontal, + normalizedVertical: vertical ) return ObservationResult( @@ -132,10 +142,17 @@ final class VisionGazeProcessor: @unchecked Sendable { pupilPoint = bounds.center } + let paddedFrame = expandRect( + CGRect(x: bounds.minX, y: bounds.minY, width: bounds.size.width, height: bounds.size.height), + horizontalPadding: config.eyeBoundsHorizontalPadding, + verticalPaddingUp: config.eyeBoundsVerticalPaddingUp, + verticalPaddingDown: config.eyeBoundsVerticalPaddingDown + ) + let normalizedPupil: CGPoint? if let pupilPoint { - let nx = clamp((pupilPoint.x - bounds.minX) / bounds.size.width) - let ny = clamp((pupilPoint.y - bounds.minY) / bounds.size.height) + let nx = clamp((pupilPoint.x - paddedFrame.minX) / paddedFrame.size.width) + let ny = clamp((pupilPoint.y - paddedFrame.minY) / paddedFrame.size.height) normalizedPupil = CGPoint(x: nx, y: ny) } else { normalizedPupil = nil @@ -146,7 +163,7 @@ final class VisionGazeProcessor: @unchecked Sendable { width: bounds.size.width, height: bounds.size.height, pupil: pupilPoint, - frame: CGRect(x: bounds.minX, y: bounds.minY, width: bounds.size.width, height: bounds.size.height), + frame: paddedFrame, normalizedPupil: normalizedPupil, hasPupilLandmarks: hasPupilLandmarks ) @@ -204,6 +221,23 @@ final class VisionGazeProcessor: @unchecked Sendable { min(1, max(0, value)) } + private func expandRect( + _ rect: CGRect, + horizontalPadding: Double, + verticalPaddingUp: Double, + verticalPaddingDown: Double + ) -> CGRect { + let dx = rect.width * CGFloat(horizontalPadding) + let up = rect.height * CGFloat(verticalPaddingUp) + let down = rect.height * CGFloat(verticalPaddingDown) + return CGRect( + x: rect.origin.x - dx, + y: rect.origin.y - up, + width: rect.width + (dx * 2), + height: rect.height + up + down + ) + } + private func averageCoordinate(left: CGFloat?, right: CGFloat?, fallback: Double?) -> Double? { switch (left, right) { case let (left?, right?): @@ -258,7 +292,8 @@ final class VisionGazeProcessor: @unchecked Sendable { horizontal: Double?, vertical: Double?, confidence: Double, - eyesClosed: Bool + eyesClosed: Bool, + distanceScale: Double ) -> GazeState { guard confidence >= config.minConfidence else { return .unknown } guard let horizontal, let vertical else { return .unknown } @@ -271,7 +306,21 @@ final class VisionGazeProcessor: @unchecked Sendable { let deltaH = abs(horizontal - baseline.horizontal) let deltaV = abs(vertical - baseline.vertical) - let away = deltaH > config.horizontalAwayThreshold || deltaV > config.verticalAwayThreshold + let thresholdH = config.horizontalAwayThreshold * distanceScale + let thresholdV = config.verticalAwayThreshold * distanceScale + + let lookingDown = vertical > baseline.vertical + let lookingUp = vertical < baseline.vertical + let verticalMultiplier: Double + if lookingDown { + verticalMultiplier = 1.2 + } else if lookingUp { + verticalMultiplier = 1.8 + } else { + verticalMultiplier = 1.0 + } + let verticalAway = deltaV > (thresholdV * verticalMultiplier) + let away = deltaH > thresholdH || verticalAway if config.baselineEnabled { if baseline.sampleCount < config.minBaselineSamples { @@ -294,4 +343,28 @@ final class VisionGazeProcessor: @unchecked Sendable { if !stable { return .unknown } return away ? .lookingAway : .lookingAtScreen } + + private func updateDistanceScale(faceWidthRatio: Double) -> Double { + let smoothed: Double + if let existing = faceWidthSmoothed { + smoothed = existing + (faceWidthRatio - existing) * config.faceWidthSmoothing + } else { + smoothed = faceWidthRatio + } + faceWidthSmoothed = smoothed + + if faceWidthBaseline == nil { + faceWidthBaseline = smoothed + return 1.0 + } + + let baseline = faceWidthBaseline ?? smoothed + guard baseline > 0 else { return 1.0 } + let ratio = baseline / max(0.0001, smoothed) + return clampDouble(ratio, min: config.faceWidthScaleMin, max: config.faceWidthScaleMax) + } + + private func clampDouble(_ value: Double, min: Double, max: Double) -> Double { + Swift.min(max, Swift.max(min, value)) + } } diff --git a/Gaze/Views/Components/EnforceModeSetupContent.swift b/Gaze/Views/Components/EnforceModeSetupContent.swift index e5894f1..5aea63f 100644 --- a/Gaze/Views/Components/EnforceModeSetupContent.swift +++ b/Gaze/Views/Components/EnforceModeSetupContent.swift @@ -76,6 +76,12 @@ struct EnforceModeSetupContent: View { eyeTrackingStatusView trackingConstantsView } + if enforceModeService.isEnforceModeEnabled { + strictnessControlView + } + if enforceModeService.isCameraActive { + trackingLapButton + } privacyInfoView } @@ -330,9 +336,76 @@ struct EnforceModeSetupContent: View { .font(.caption2) .foregroundStyle(.secondary) } + + if let faceWidth = eyeTrackingService.debugState.faceWidthRatio { + HStack(spacing: 12) { + Text("Face Width:") + .font(.caption2) + .foregroundStyle(.secondary) + Text(String(format: "%.3f", faceWidth)) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + if let horizontal = eyeTrackingService.debugState.normalizedHorizontal, + let vertical = eyeTrackingService.debugState.normalizedVertical { + HStack(spacing: 12) { + Text("Ratios:") + .font(.caption2) + .foregroundStyle(.secondary) + Text("H \(String(format: "%.3f", horizontal))") + .font(.caption2) + .foregroundStyle(.secondary) + Text("V \(String(format: "%.3f", vertical))") + .font(.caption2) + .foregroundStyle(.secondary) + } + } } } .padding(sectionPadding) .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius)) } + + private var strictnessControlView: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Tracking Strictness") + .font(headerFont) + + Slider( + value: $settingsManager.settings.enforceModeStrictness, + in: 0...1 + ) + .controlSize(.small) + + HStack { + Text("Lenient") + .font(.caption2) + .foregroundStyle(.secondary) + Spacer() + Text("Strict") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .padding(sectionPadding) + .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius)) + } + + private var trackingLapButton: some View { + Button(action: { + enforceModeService.logTrackingLap() + }) { + HStack { + Image(systemName: "flag.checkered") + Text("Lap Marker") + .font(.headline) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + .buttonStyle(.bordered) + .controlSize(.regular) + } }