tweaking
This commit is contained in:
@@ -42,6 +42,7 @@ struct AppSettings: Codable, Equatable, Hashable, Sendable {
|
|||||||
var subtleReminderSize: ReminderSize
|
var subtleReminderSize: ReminderSize
|
||||||
|
|
||||||
var smartMode: SmartModeSettings
|
var smartMode: SmartModeSettings
|
||||||
|
var enforceModeStrictness: Double
|
||||||
|
|
||||||
var hasCompletedOnboarding: Bool
|
var hasCompletedOnboarding: Bool
|
||||||
var launchAtLogin: Bool
|
var launchAtLogin: Bool
|
||||||
@@ -57,6 +58,7 @@ struct AppSettings: Codable, Equatable, Hashable, Sendable {
|
|||||||
userTimers: [UserTimer] = [],
|
userTimers: [UserTimer] = [],
|
||||||
subtleReminderSize: ReminderSize = DefaultSettingsBuilder.subtleReminderSize,
|
subtleReminderSize: ReminderSize = DefaultSettingsBuilder.subtleReminderSize,
|
||||||
smartMode: SmartModeSettings = DefaultSettingsBuilder.smartMode,
|
smartMode: SmartModeSettings = DefaultSettingsBuilder.smartMode,
|
||||||
|
enforceModeStrictness: Double = DefaultSettingsBuilder.enforceModeStrictness,
|
||||||
hasCompletedOnboarding: Bool = DefaultSettingsBuilder.hasCompletedOnboarding,
|
hasCompletedOnboarding: Bool = DefaultSettingsBuilder.hasCompletedOnboarding,
|
||||||
launchAtLogin: Bool = DefaultSettingsBuilder.launchAtLogin,
|
launchAtLogin: Bool = DefaultSettingsBuilder.launchAtLogin,
|
||||||
playSounds: Bool = DefaultSettingsBuilder.playSounds
|
playSounds: Bool = DefaultSettingsBuilder.playSounds
|
||||||
@@ -70,6 +72,7 @@ struct AppSettings: Codable, Equatable, Hashable, Sendable {
|
|||||||
self.userTimers = userTimers
|
self.userTimers = userTimers
|
||||||
self.subtleReminderSize = subtleReminderSize
|
self.subtleReminderSize = subtleReminderSize
|
||||||
self.smartMode = smartMode
|
self.smartMode = smartMode
|
||||||
|
self.enforceModeStrictness = enforceModeStrictness
|
||||||
self.hasCompletedOnboarding = hasCompletedOnboarding
|
self.hasCompletedOnboarding = hasCompletedOnboarding
|
||||||
self.launchAtLogin = launchAtLogin
|
self.launchAtLogin = launchAtLogin
|
||||||
self.playSounds = playSounds
|
self.playSounds = playSounds
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ struct DefaultSettingsBuilder {
|
|||||||
static let postureIntervalMinutes = 30
|
static let postureIntervalMinutes = 30
|
||||||
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 hasCompletedOnboarding = false
|
static let hasCompletedOnboarding = false
|
||||||
static let launchAtLogin = false
|
static let launchAtLogin = false
|
||||||
static let playSounds = true
|
static let playSounds = true
|
||||||
@@ -31,6 +32,7 @@ struct DefaultSettingsBuilder {
|
|||||||
userTimers: [],
|
userTimers: [],
|
||||||
subtleReminderSize: subtleReminderSize,
|
subtleReminderSize: subtleReminderSize,
|
||||||
smartMode: smartMode,
|
smartMode: smartMode,
|
||||||
|
enforceModeStrictness: enforceModeStrictness,
|
||||||
hasCompletedOnboarding: hasCompletedOnboarding,
|
hasCompletedOnboarding: hasCompletedOnboarding,
|
||||||
launchAtLogin: launchAtLogin,
|
launchAtLogin: launchAtLogin,
|
||||||
playSounds: playSounds
|
playSounds: playSounds
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ class EnforceModeService: ObservableObject {
|
|||||||
private var timerEngine: TimerEngine?
|
private var timerEngine: TimerEngine?
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
private var faceDetectionTimer: Timer?
|
private var faceDetectionTimer: Timer?
|
||||||
|
private var trackingDebugTimer: Timer?
|
||||||
|
private var trackingLapStats = TrackingLapStats()
|
||||||
|
|
||||||
// MARK: - Configuration
|
// MARK: - Configuration
|
||||||
|
|
||||||
@@ -83,6 +85,17 @@ class EnforceModeService: ObservableObject {
|
|||||||
self?.refreshEnforceModeState()
|
self?.refreshEnforceModeState()
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
$isCameraActive
|
||||||
|
.removeDuplicates()
|
||||||
|
.sink { [weak self] isActive in
|
||||||
|
if isActive {
|
||||||
|
self?.startTrackingDebugTimer()
|
||||||
|
} else {
|
||||||
|
self?.stopTrackingDebugTimer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func refreshEnforceModeState() {
|
private func refreshEnforceModeState() {
|
||||||
@@ -211,6 +224,7 @@ class EnforceModeService: ObservableObject {
|
|||||||
eyeTrackingService.stopEyeTracking()
|
eyeTrackingService.stopEyeTracking()
|
||||||
isCameraActive = false
|
isCameraActive = false
|
||||||
stopFaceDetectionTimer()
|
stopFaceDetectionTimer()
|
||||||
|
stopTrackingDebugTimer()
|
||||||
userCompliedWithBreak = false
|
userCompliedWithBreak = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,6 +271,48 @@ class EnforceModeService: ObservableObject {
|
|||||||
faceDetectionTimer = nil
|
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() {
|
private func checkFaceDetectionTimeout() {
|
||||||
guard isCameraActive else {
|
guard isCameraActive else {
|
||||||
stopFaceDetectionTimer()
|
stopFaceDetectionTimer()
|
||||||
@@ -295,3 +351,45 @@ class EnforceModeService: ObservableObject {
|
|||||||
isTestMode = false
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class EyeTrackingService: NSObject, ObservableObject {
|
|||||||
private let cameraManager = CameraSessionManager()
|
private let cameraManager = CameraSessionManager()
|
||||||
private let visionPipeline = VisionPipeline()
|
private let visionPipeline = VisionPipeline()
|
||||||
private let processor: VisionGazeProcessor
|
private let processor: VisionGazeProcessor
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
var previewLayer: AVCaptureVideoPreviewLayer? {
|
var previewLayer: AVCaptureVideoPreviewLayer? {
|
||||||
cameraManager.previewLayer
|
cameraManager.previewLayer
|
||||||
@@ -33,6 +34,41 @@ class EyeTrackingService: NSObject, ObservableObject {
|
|||||||
self.processor = VisionGazeProcessor(config: config)
|
self.processor = VisionGazeProcessor(config: config)
|
||||||
super.init()
|
super.init()
|
||||||
cameraManager.delegate = self
|
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 {
|
func startEyeTracking() async throws {
|
||||||
|
|||||||
@@ -35,17 +35,59 @@ public struct EyeTrackingDebugState: Sendable {
|
|||||||
public let leftPupil: CGPoint?
|
public let leftPupil: CGPoint?
|
||||||
public let rightPupil: CGPoint?
|
public let rightPupil: CGPoint?
|
||||||
public let imageSize: CGSize?
|
public let imageSize: CGSize?
|
||||||
|
public let faceWidthRatio: Double?
|
||||||
|
public let normalizedHorizontal: Double?
|
||||||
|
public let normalizedVertical: Double?
|
||||||
|
|
||||||
public static let empty = EyeTrackingDebugState(
|
public static let empty = EyeTrackingDebugState(
|
||||||
leftEyeRect: nil,
|
leftEyeRect: nil,
|
||||||
rightEyeRect: nil,
|
rightEyeRect: nil,
|
||||||
leftPupil: nil,
|
leftPupil: nil,
|
||||||
rightPupil: nil,
|
rightPupil: nil,
|
||||||
imageSize: nil
|
imageSize: nil,
|
||||||
|
faceWidthRatio: nil,
|
||||||
|
normalizedHorizontal: nil,
|
||||||
|
normalizedVertical: nil
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct TrackingConfig: Sendable {
|
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 horizontalAwayThreshold: Double
|
||||||
public let verticalAwayThreshold: Double
|
public let verticalAwayThreshold: Double
|
||||||
public let minBaselineSamples: Int
|
public let minBaselineSamples: Int
|
||||||
@@ -56,10 +98,16 @@ public struct TrackingConfig: Sendable {
|
|||||||
public let baselineEnabled: Bool
|
public let baselineEnabled: Bool
|
||||||
public let defaultCenterHorizontal: Double
|
public let defaultCenterHorizontal: Double
|
||||||
public let defaultCenterVertical: 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(
|
public static let `default` = TrackingConfig(
|
||||||
horizontalAwayThreshold: 0.12,
|
horizontalAwayThreshold: 0.08,
|
||||||
verticalAwayThreshold: 0.18,
|
verticalAwayThreshold: 0.12,
|
||||||
minBaselineSamples: 8,
|
minBaselineSamples: 8,
|
||||||
baselineSmoothing: 0.15,
|
baselineSmoothing: 0.15,
|
||||||
baselineUpdateThreshold: 0.08,
|
baselineUpdateThreshold: 0.08,
|
||||||
@@ -67,6 +115,12 @@ public struct TrackingConfig: Sendable {
|
|||||||
eyeClosedThreshold: 0.18,
|
eyeClosedThreshold: 0.18,
|
||||||
baselineEnabled: true,
|
baselineEnabled: true,
|
||||||
defaultCenterHorizontal: 0.5,
|
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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ final class VisionGazeProcessor: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private let baselineModel = GazeBaselineModel()
|
private let baselineModel = GazeBaselineModel()
|
||||||
|
private var faceWidthBaseline: Double?
|
||||||
|
private var faceWidthSmoothed: Double?
|
||||||
private var config: TrackingConfig
|
private var config: TrackingConfig
|
||||||
|
|
||||||
init(config: TrackingConfig) {
|
init(config: TrackingConfig) {
|
||||||
@@ -42,6 +44,8 @@ final class VisionGazeProcessor: @unchecked Sendable {
|
|||||||
|
|
||||||
func resetBaseline() {
|
func resetBaseline() {
|
||||||
baselineModel.reset()
|
baselineModel.reset()
|
||||||
|
faceWidthBaseline = nil
|
||||||
|
faceWidthSmoothed = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func process(analysis: VisionPipeline.FaceAnalysis) -> ObservationResult {
|
func process(analysis: VisionPipeline.FaceAnalysis) -> ObservationResult {
|
||||||
@@ -84,13 +88,16 @@ final class VisionGazeProcessor: @unchecked Sendable {
|
|||||||
|
|
||||||
let eyesClosed = detectEyesClosed(left: leftEye, right: rightEye)
|
let eyesClosed = detectEyesClosed(left: leftEye, right: rightEye)
|
||||||
let (horizontal, vertical) = normalizePupilPosition(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 confidence = calculateConfidence(leftEye: leftEye, rightEye: rightEye)
|
||||||
let gazeState = decideGazeState(
|
let gazeState = decideGazeState(
|
||||||
horizontal: horizontal,
|
horizontal: horizontal,
|
||||||
vertical: vertical,
|
vertical: vertical,
|
||||||
confidence: confidence,
|
confidence: confidence,
|
||||||
eyesClosed: eyesClosed
|
eyesClosed: eyesClosed,
|
||||||
|
distanceScale: distanceScale
|
||||||
)
|
)
|
||||||
|
|
||||||
let debugState = EyeTrackingDebugState(
|
let debugState = EyeTrackingDebugState(
|
||||||
@@ -98,7 +105,10 @@ final class VisionGazeProcessor: @unchecked Sendable {
|
|||||||
rightEyeRect: rightEye?.frame,
|
rightEyeRect: rightEye?.frame,
|
||||||
leftPupil: leftEye?.pupil,
|
leftPupil: leftEye?.pupil,
|
||||||
rightPupil: rightEye?.pupil,
|
rightPupil: rightEye?.pupil,
|
||||||
imageSize: analysis.imageSize
|
imageSize: analysis.imageSize,
|
||||||
|
faceWidthRatio: faceWidthRatio,
|
||||||
|
normalizedHorizontal: horizontal,
|
||||||
|
normalizedVertical: vertical
|
||||||
)
|
)
|
||||||
|
|
||||||
return ObservationResult(
|
return ObservationResult(
|
||||||
@@ -132,10 +142,17 @@ final class VisionGazeProcessor: @unchecked Sendable {
|
|||||||
pupilPoint = bounds.center
|
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?
|
let normalizedPupil: CGPoint?
|
||||||
if let pupilPoint {
|
if let pupilPoint {
|
||||||
let nx = clamp((pupilPoint.x - bounds.minX) / bounds.size.width)
|
let nx = clamp((pupilPoint.x - paddedFrame.minX) / paddedFrame.size.width)
|
||||||
let ny = clamp((pupilPoint.y - bounds.minY) / bounds.size.height)
|
let ny = clamp((pupilPoint.y - paddedFrame.minY) / paddedFrame.size.height)
|
||||||
normalizedPupil = CGPoint(x: nx, y: ny)
|
normalizedPupil = CGPoint(x: nx, y: ny)
|
||||||
} else {
|
} else {
|
||||||
normalizedPupil = nil
|
normalizedPupil = nil
|
||||||
@@ -146,7 +163,7 @@ final class VisionGazeProcessor: @unchecked Sendable {
|
|||||||
width: bounds.size.width,
|
width: bounds.size.width,
|
||||||
height: bounds.size.height,
|
height: bounds.size.height,
|
||||||
pupil: pupilPoint,
|
pupil: pupilPoint,
|
||||||
frame: CGRect(x: bounds.minX, y: bounds.minY, width: bounds.size.width, height: bounds.size.height),
|
frame: paddedFrame,
|
||||||
normalizedPupil: normalizedPupil,
|
normalizedPupil: normalizedPupil,
|
||||||
hasPupilLandmarks: hasPupilLandmarks
|
hasPupilLandmarks: hasPupilLandmarks
|
||||||
)
|
)
|
||||||
@@ -204,6 +221,23 @@ final class VisionGazeProcessor: @unchecked Sendable {
|
|||||||
min(1, max(0, value))
|
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? {
|
private func averageCoordinate(left: CGFloat?, right: CGFloat?, fallback: Double?) -> Double? {
|
||||||
switch (left, right) {
|
switch (left, right) {
|
||||||
case let (left?, right?):
|
case let (left?, right?):
|
||||||
@@ -258,7 +292,8 @@ final class VisionGazeProcessor: @unchecked Sendable {
|
|||||||
horizontal: Double?,
|
horizontal: Double?,
|
||||||
vertical: Double?,
|
vertical: Double?,
|
||||||
confidence: Double,
|
confidence: Double,
|
||||||
eyesClosed: Bool
|
eyesClosed: Bool,
|
||||||
|
distanceScale: Double
|
||||||
) -> GazeState {
|
) -> GazeState {
|
||||||
guard confidence >= config.minConfidence else { return .unknown }
|
guard confidence >= config.minConfidence else { return .unknown }
|
||||||
guard let horizontal, let vertical 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 deltaH = abs(horizontal - baseline.horizontal)
|
||||||
let deltaV = abs(vertical - baseline.vertical)
|
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 config.baselineEnabled {
|
||||||
if baseline.sampleCount < config.minBaselineSamples {
|
if baseline.sampleCount < config.minBaselineSamples {
|
||||||
@@ -294,4 +343,28 @@ final class VisionGazeProcessor: @unchecked Sendable {
|
|||||||
if !stable { return .unknown }
|
if !stable { return .unknown }
|
||||||
return away ? .lookingAway : .lookingAtScreen
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,12 @@ struct EnforceModeSetupContent: View {
|
|||||||
eyeTrackingStatusView
|
eyeTrackingStatusView
|
||||||
trackingConstantsView
|
trackingConstantsView
|
||||||
}
|
}
|
||||||
|
if enforceModeService.isEnforceModeEnabled {
|
||||||
|
strictnessControlView
|
||||||
|
}
|
||||||
|
if enforceModeService.isCameraActive {
|
||||||
|
trackingLapButton
|
||||||
|
}
|
||||||
privacyInfoView
|
privacyInfoView
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,9 +336,76 @@ struct EnforceModeSetupContent: View {
|
|||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.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)
|
.padding(sectionPadding)
|
||||||
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius))
|
.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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user