fix: almost ready for fine tuning
This commit is contained in:
@@ -62,4 +62,20 @@ enum EyeTrackingConstants: Sendable {
|
|||||||
static let pixelGazeMinRatio: Double = 0.35 // Looking right threshold
|
static let pixelGazeMinRatio: Double = 0.35 // Looking right threshold
|
||||||
static let pixelGazeMaxRatio: Double = 0.65 // Looking left threshold
|
static let pixelGazeMaxRatio: Double = 0.65 // Looking left threshold
|
||||||
static let pixelGazeEnabled: Bool = true
|
static let pixelGazeEnabled: Bool = true
|
||||||
|
|
||||||
|
// MARK: - Screen Boundary Detection (New)
|
||||||
|
|
||||||
|
/// Forgiveness margin for the "gray area" around the screen edge.
|
||||||
|
/// 0.05 means the safe zone is extended by 5% of the range on each side.
|
||||||
|
/// If in the gray area, we assume the user is Looking Away (success).
|
||||||
|
static let boundaryForgivenessMargin: Double = 0.05
|
||||||
|
|
||||||
|
/// Distance sensitivity factor.
|
||||||
|
/// 1.0 = Linear scaling (face width 50% smaller -> eye movement expected to be 50% smaller)
|
||||||
|
/// > 1.0 = More aggressive scaling
|
||||||
|
static let distanceSensitivity: Double = 1.0
|
||||||
|
|
||||||
|
/// Minimum confidence required for a valid pupil detection before updating the gaze average.
|
||||||
|
/// Helps filter out blinks or noisy frames.
|
||||||
|
static let minimumGazeConfidence: Int = 3 // consecutive valid frames
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,15 +45,15 @@ enum CalibrationStep: String, Codable, CaseIterable {
|
|||||||
case .farLeft:
|
case .farLeft:
|
||||||
return "Look as far left as comfortable"
|
return "Look as far left as comfortable"
|
||||||
case .left:
|
case .left:
|
||||||
return "Look to the left"
|
return "Look to the left edge of the screen"
|
||||||
case .farRight:
|
case .farRight:
|
||||||
return "Look as far right as comfortable"
|
return "Look as far right as comfortable"
|
||||||
case .right:
|
case .right:
|
||||||
return "Look to the right"
|
return "Look to the right edge of the screen"
|
||||||
case .up:
|
case .up:
|
||||||
return "Look up"
|
return "Look to the top edge of the screen"
|
||||||
case .down:
|
case .down:
|
||||||
return "Look down"
|
return "Look to the bottom edge of the screen"
|
||||||
case .topLeft:
|
case .topLeft:
|
||||||
return "Look to the top left corner"
|
return "Look to the top left corner"
|
||||||
case .topRight:
|
case .topRight:
|
||||||
@@ -70,42 +70,75 @@ struct GazeSample: Codable {
|
|||||||
let leftRatio: Double?
|
let leftRatio: Double?
|
||||||
let rightRatio: Double?
|
let rightRatio: Double?
|
||||||
let averageRatio: Double
|
let averageRatio: Double
|
||||||
|
let leftVerticalRatio: Double?
|
||||||
|
let rightVerticalRatio: Double?
|
||||||
|
let averageVerticalRatio: Double
|
||||||
|
let faceWidthRatio: Double? // For distance scaling (face width / image width)
|
||||||
let timestamp: Date
|
let timestamp: Date
|
||||||
|
|
||||||
init(leftRatio: Double?, rightRatio: Double?) {
|
init(leftRatio: Double?, rightRatio: Double?, leftVerticalRatio: Double? = nil, rightVerticalRatio: Double? = nil, faceWidthRatio: Double? = nil) {
|
||||||
self.leftRatio = leftRatio
|
self.leftRatio = leftRatio
|
||||||
self.rightRatio = rightRatio
|
self.rightRatio = rightRatio
|
||||||
|
self.leftVerticalRatio = leftVerticalRatio
|
||||||
|
self.rightVerticalRatio = rightVerticalRatio
|
||||||
|
self.faceWidthRatio = faceWidthRatio
|
||||||
|
|
||||||
// Calculate average from available ratios
|
// Calculate average horizontal ratio
|
||||||
if let left = leftRatio, let right = rightRatio {
|
if let left = leftRatio, let right = rightRatio {
|
||||||
self.averageRatio = (left + right) / 2.0
|
self.averageRatio = (left + right) / 2.0
|
||||||
} else {
|
} else {
|
||||||
self.averageRatio = leftRatio ?? rightRatio ?? 0.5
|
self.averageRatio = leftRatio ?? rightRatio ?? 0.5
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate average vertical ratio
|
||||||
|
if let left = leftVerticalRatio, let right = rightVerticalRatio {
|
||||||
|
self.averageVerticalRatio = (left + right) / 2.0
|
||||||
|
} else {
|
||||||
|
self.averageVerticalRatio = leftVerticalRatio ?? rightVerticalRatio ?? 0.5
|
||||||
|
}
|
||||||
|
|
||||||
self.timestamp = Date()
|
self.timestamp = Date()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct GazeThresholds: Codable {
|
struct GazeThresholds: Codable {
|
||||||
let minLeftRatio: Double // Looking left threshold (e.g., 0.65)
|
// Horizontal Thresholds
|
||||||
let maxRightRatio: Double // Looking right threshold (e.g., 0.35)
|
let minLeftRatio: Double // Looking left (≥ value)
|
||||||
let centerMin: Double // Center range minimum
|
let maxRightRatio: Double // Looking right (≤ value)
|
||||||
let centerMax: Double // Center range maximum
|
|
||||||
|
// Vertical Thresholds
|
||||||
|
let minUpRatio: Double // Looking up (≤ value, typically < 0.5)
|
||||||
|
let maxDownRatio: Double // Looking down (≥ value, typically > 0.5)
|
||||||
|
|
||||||
|
// Screen Bounds (Calibration Zone)
|
||||||
|
// Defines the rectangle of pupil ratios that correspond to looking AT the screen
|
||||||
|
let screenLeftBound: Double
|
||||||
|
let screenRightBound: Double
|
||||||
|
let screenTopBound: Double
|
||||||
|
let screenBottomBound: Double
|
||||||
|
|
||||||
|
// Reference Data for Distance Scaling
|
||||||
|
let referenceFaceWidth: Double // Average face width during calibration
|
||||||
|
|
||||||
var isValid: Bool {
|
var isValid: Bool {
|
||||||
// Ensure thresholds don't overlap
|
// Basic sanity checks
|
||||||
return maxRightRatio < centerMin &&
|
return maxRightRatio < minLeftRatio &&
|
||||||
centerMin < centerMax &&
|
minUpRatio < maxDownRatio &&
|
||||||
centerMax < minLeftRatio
|
screenRightBound < screenLeftBound && // Assuming lower ratio = right
|
||||||
|
screenTopBound < screenBottomBound // Assuming lower ratio = up
|
||||||
}
|
}
|
||||||
|
|
||||||
static var defaultThresholds: GazeThresholds {
|
static var defaultThresholds: GazeThresholds {
|
||||||
GazeThresholds(
|
GazeThresholds(
|
||||||
minLeftRatio: 0.65,
|
minLeftRatio: 0.65,
|
||||||
maxRightRatio: 0.35,
|
maxRightRatio: 0.35,
|
||||||
centerMin: 0.40,
|
minUpRatio: 0.40,
|
||||||
centerMax: 0.60
|
maxDownRatio: 0.60,
|
||||||
|
screenLeftBound: 0.60,
|
||||||
|
screenRightBound: 0.40,
|
||||||
|
screenTopBound: 0.45,
|
||||||
|
screenBottomBound: 0.55,
|
||||||
|
referenceFaceWidth: 0.0 // 0.0 means unused/uncalibrated
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,62 +170,95 @@ struct CalibrationData: Codable {
|
|||||||
func averageRatio(for step: CalibrationStep) -> Double? {
|
func averageRatio(for step: CalibrationStep) -> Double? {
|
||||||
let stepSamples = getSamples(for: step)
|
let stepSamples = getSamples(for: step)
|
||||||
guard !stepSamples.isEmpty else { return nil }
|
guard !stepSamples.isEmpty else { return nil }
|
||||||
|
return stepSamples.reduce(0.0) { $0 + $1.averageRatio } / Double(stepSamples.count)
|
||||||
let sum = stepSamples.reduce(0.0) { $0 + $1.averageRatio }
|
|
||||||
return sum / Double(stepSamples.count)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func standardDeviation(for step: CalibrationStep) -> Double? {
|
func averageVerticalRatio(for step: CalibrationStep) -> Double? {
|
||||||
let stepSamples = getSamples(for: step)
|
let stepSamples = getSamples(for: step)
|
||||||
guard stepSamples.count > 1, let mean = averageRatio(for: step) else { return nil }
|
guard !stepSamples.isEmpty else { return nil }
|
||||||
|
return stepSamples.reduce(0.0) { $0 + $1.averageVerticalRatio } / Double(stepSamples.count)
|
||||||
|
}
|
||||||
|
|
||||||
let variance = stepSamples.reduce(0.0) { sum, sample in
|
func averageFaceWidth(for step: CalibrationStep) -> Double? {
|
||||||
let diff = sample.averageRatio - mean
|
let stepSamples = getSamples(for: step)
|
||||||
return sum + (diff * diff)
|
let validSamples = stepSamples.compactMap { $0.faceWidthRatio }
|
||||||
} / Double(stepSamples.count - 1)
|
guard !validSamples.isEmpty else { return nil }
|
||||||
|
return validSamples.reduce(0.0, +) / Double(validSamples.count)
|
||||||
return sqrt(variance)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
mutating func calculateThresholds() {
|
mutating func calculateThresholds() {
|
||||||
// Need at least center, left, and right samples
|
// We need Center, Left, Right, Up, Down samples for a full calibration
|
||||||
guard let centerMean = averageRatio(for: .center),
|
// Fallback: If corners (TopLeft, etc.) are available, use them to reinforce bounds
|
||||||
let leftMean = averageRatio(for: .left),
|
|
||||||
let rightMean = averageRatio(for: .right) else {
|
|
||||||
print("⚠️ Insufficient calibration data to calculate thresholds")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let centerStdDev = standardDeviation(for: .center) ?? 0.05
|
let centerH = averageRatio(for: .center) ?? 0.5
|
||||||
|
let centerV = averageVerticalRatio(for: .center) ?? 0.5
|
||||||
|
|
||||||
// Calculate center range (mean ± 0.5 * std_dev)
|
// 1. Horizontal Bounds
|
||||||
let centerMin = max(0.0, centerMean - 0.5 * centerStdDev)
|
// If specific Left/Right steps missing, try corners
|
||||||
let centerMax = min(1.0, centerMean + 0.5 * centerStdDev)
|
let leftH = averageRatio(for: .left) ?? averageRatio(for: .topLeft) ?? averageRatio(for: .bottomLeft) ?? (centerH + 0.15)
|
||||||
|
let rightH = averageRatio(for: .right) ?? averageRatio(for: .topRight) ?? averageRatio(for: .bottomRight) ?? (centerH - 0.15)
|
||||||
|
|
||||||
// Calculate left threshold (midpoint between center and left extremes)
|
// 2. Vertical Bounds
|
||||||
let minLeftRatio = centerMax + (leftMean - centerMax) * 0.5
|
let upV = averageVerticalRatio(for: .up) ?? averageVerticalRatio(for: .topLeft) ?? averageVerticalRatio(for: .topRight) ?? (centerV - 0.15)
|
||||||
|
let downV = averageVerticalRatio(for: .down) ?? averageVerticalRatio(for: .bottomLeft) ?? averageVerticalRatio(for: .bottomRight) ?? (centerV + 0.15)
|
||||||
|
|
||||||
// Calculate right threshold (midpoint between center and right extremes)
|
// 3. Face Width Reference (average of all center samples)
|
||||||
let maxRightRatio = centerMin - (centerMin - rightMean) * 0.5
|
let refFaceWidth = averageFaceWidth(for: .center) ?? 0.0
|
||||||
|
|
||||||
// Validate and adjust if needed
|
// 4. Compute Boundaries with Margin
|
||||||
var thresholds = GazeThresholds(
|
// "Screen Bound" is exactly where the user looked.
|
||||||
minLeftRatio: min(0.95, max(0.55, minLeftRatio)),
|
// We set thresholds slightly BEYOND that to detect "Looking Away".
|
||||||
maxRightRatio: max(0.05, min(0.45, maxRightRatio)),
|
|
||||||
centerMin: centerMin,
|
// Note: Assuming standard coordinates where:
|
||||||
centerMax: centerMax
|
// Horizontal: 0.0 (Right) -> 1.0 (Left)
|
||||||
|
// Vertical: 0.0 (Up) -> 1.0 (Down)
|
||||||
|
|
||||||
|
// Thresholds for "Looking Away"
|
||||||
|
// Looking Left = Ratio > Screen Left Edge
|
||||||
|
let lookLeftThreshold = leftH + 0.05
|
||||||
|
// Looking Right = Ratio < Screen Right Edge
|
||||||
|
let lookRightThreshold = rightH - 0.05
|
||||||
|
|
||||||
|
// Looking Up = Ratio < Screen Top Edge
|
||||||
|
let lookUpThreshold = upV - 0.05
|
||||||
|
// Looking Down = Ratio > Screen Bottom Edge
|
||||||
|
let lookDownThreshold = downV + 0.05
|
||||||
|
|
||||||
|
let thresholds = GazeThresholds(
|
||||||
|
minLeftRatio: lookLeftThreshold,
|
||||||
|
maxRightRatio: lookRightThreshold,
|
||||||
|
minUpRatio: lookUpThreshold,
|
||||||
|
maxDownRatio: lookDownThreshold,
|
||||||
|
screenLeftBound: leftH,
|
||||||
|
screenRightBound: rightH,
|
||||||
|
screenTopBound: upV,
|
||||||
|
screenBottomBound: downV,
|
||||||
|
referenceFaceWidth: refFaceWidth
|
||||||
)
|
)
|
||||||
|
|
||||||
// Ensure no overlap
|
|
||||||
if !thresholds.isValid {
|
|
||||||
print("⚠️ Computed thresholds overlap, using defaults")
|
|
||||||
thresholds = GazeThresholds.defaultThresholds
|
|
||||||
}
|
|
||||||
|
|
||||||
self.computedThresholds = thresholds
|
self.computedThresholds = thresholds
|
||||||
print("✓ Calibration thresholds calculated:")
|
print("✓ Calibration thresholds calculated:")
|
||||||
print(" Left: ≥\(String(format: "%.3f", thresholds.minLeftRatio))")
|
print(" H-Range: \(String(format: "%.3f", rightH)) to \(String(format: "%.3f", leftH))")
|
||||||
print(" Center: \(String(format: "%.3f", thresholds.centerMin))-\(String(format: "%.3f", thresholds.centerMax))")
|
print(" V-Range: \(String(format: "%.3f", upV)) to \(String(format: "%.3f", downV))")
|
||||||
print(" Right: ≤\(String(format: "%.3f", thresholds.maxRightRatio))")
|
print(" Ref Face Width: \(String(format: "%.3f", refFaceWidth))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Thread-safe storage for active calibration thresholds
|
||||||
|
/// Allows non-isolated code (video processing) to read thresholds without hitting MainActor
|
||||||
|
class CalibrationState: @unchecked Sendable {
|
||||||
|
static let shared = CalibrationState()
|
||||||
|
private let queue = DispatchQueue(label: "com.gaze.calibrationState", attributes: .concurrent)
|
||||||
|
private var _thresholds: GazeThresholds?
|
||||||
|
private var _isComplete: Bool = false
|
||||||
|
|
||||||
|
var thresholds: GazeThresholds? {
|
||||||
|
get { queue.sync { _thresholds } }
|
||||||
|
set { queue.async(flags: .barrier) { self._thresholds = newValue } }
|
||||||
|
}
|
||||||
|
|
||||||
|
var isComplete: Bool {
|
||||||
|
get { queue.sync { _isComplete } }
|
||||||
|
set { queue.async(flags: .barrier) { self._isComplete = newValue } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,10 +56,16 @@ class CalibrationManager: ObservableObject {
|
|||||||
calibrationData = CalibrationData()
|
calibrationData = CalibrationData()
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectSample(leftRatio: Double?, rightRatio: Double?) {
|
func collectSample(leftRatio: Double?, rightRatio: Double?, leftVertical: Double? = nil, rightVertical: Double? = nil, faceWidthRatio: Double? = nil) {
|
||||||
guard isCalibrating, let step = currentStep else { return }
|
guard isCalibrating, let step = currentStep else { return }
|
||||||
|
|
||||||
let sample = GazeSample(leftRatio: leftRatio, rightRatio: rightRatio)
|
let sample = GazeSample(
|
||||||
|
leftRatio: leftRatio,
|
||||||
|
rightRatio: rightRatio,
|
||||||
|
leftVerticalRatio: leftVertical,
|
||||||
|
rightVerticalRatio: rightVertical,
|
||||||
|
faceWidthRatio: faceWidthRatio
|
||||||
|
)
|
||||||
calibrationData.addSample(sample, for: step)
|
calibrationData.addSample(sample, for: step)
|
||||||
samplesCollected += 1
|
samplesCollected += 1
|
||||||
|
|
||||||
@@ -116,6 +122,10 @@ class CalibrationManager: ObservableObject {
|
|||||||
currentStepIndex = 0
|
currentStepIndex = 0
|
||||||
samplesCollected = 0
|
samplesCollected = 0
|
||||||
calibrationData = CalibrationData()
|
calibrationData = CalibrationData()
|
||||||
|
|
||||||
|
// Reset thread-safe state
|
||||||
|
CalibrationState.shared.isComplete = false
|
||||||
|
CalibrationState.shared.thresholds = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Persistence
|
// MARK: - Persistence
|
||||||
@@ -157,6 +167,11 @@ class CalibrationManager: ObservableObject {
|
|||||||
func clearCalibration() {
|
func clearCalibration() {
|
||||||
UserDefaults.standard.removeObject(forKey: userDefaultsKey)
|
UserDefaults.standard.removeObject(forKey: userDefaultsKey)
|
||||||
calibrationData = CalibrationData()
|
calibrationData = CalibrationData()
|
||||||
|
|
||||||
|
// Reset thread-safe state
|
||||||
|
CalibrationState.shared.isComplete = false
|
||||||
|
CalibrationState.shared.thresholds = nil
|
||||||
|
|
||||||
print("🗑️ Calibration data cleared")
|
print("🗑️ Calibration data cleared")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,13 +206,16 @@ private func applyCalibration() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: EyeTrackingConstants are static properties that should not be modified.
|
// Push to thread-safe state for background processing
|
||||||
// Any calibrated values should be used separately in the logic, not stored back to the constants.
|
CalibrationState.shared.thresholds = thresholds
|
||||||
// This is a placeholder for future implementation if dynamic threshold updates are needed.
|
CalibrationState.shared.isComplete = true
|
||||||
|
|
||||||
print("✓ Applied calibrated thresholds:")
|
print("✓ Applied calibrated thresholds:")
|
||||||
print(" Looking left: ≥\(String(format: "%.3f", thresholds.minLeftRatio))")
|
print(" Looking left: ≥\(String(format: "%.3f", thresholds.minLeftRatio))")
|
||||||
print(" Looking right: ≤\(String(format: "%.3f", thresholds.maxRightRatio))")
|
print(" Looking right: ≤\(String(format: "%.3f", thresholds.maxRightRatio))")
|
||||||
|
print(" Looking up: ≤\(String(format: "%.3f", thresholds.minUpRatio))")
|
||||||
|
print(" Looking down: ≥\(String(format: "%.3f", thresholds.maxDownRatio))")
|
||||||
|
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
|
// MARK: - Statistics
|
||||||
@@ -214,9 +232,9 @@ private func applyCalibration() {
|
|||||||
var summary = "Calibrated: \(dateFormatter.string(from: calibrationData.calibrationDate))\n"
|
var summary = "Calibrated: \(dateFormatter.string(from: calibrationData.calibrationDate))\n"
|
||||||
|
|
||||||
if let thresholds = calibrationData.computedThresholds {
|
if let thresholds = calibrationData.computedThresholds {
|
||||||
summary += "Left threshold: \(String(format: "%.3f", thresholds.minLeftRatio))\n"
|
summary += "H-Range: \(String(format: "%.3f", thresholds.screenRightBound)) to \(String(format: "%.3f", thresholds.screenLeftBound))\n"
|
||||||
summary += "Right threshold: \(String(format: "%.3f", thresholds.maxRightRatio))\n"
|
summary += "V-Range: \(String(format: "%.3f", thresholds.screenTopBound)) to \(String(format: "%.3f", thresholds.screenBottomBound))\n"
|
||||||
summary += "Center range: \(String(format: "%.3f", thresholds.centerMin)) - \(String(format: "%.3f", thresholds.centerMax))"
|
summary += "Ref Face Width: \(String(format: "%.3f", thresholds.referenceFaceWidth))"
|
||||||
}
|
}
|
||||||
|
|
||||||
return summary
|
return summary
|
||||||
|
|||||||
@@ -470,21 +470,68 @@ class EyeTrackingService: NSObject, ObservableObject {
|
|||||||
if let leftRatio = leftGazeRatio,
|
if let leftRatio = leftGazeRatio,
|
||||||
let rightRatio = rightGazeRatio
|
let rightRatio = rightGazeRatio
|
||||||
{
|
{
|
||||||
|
let faceWidth = face.boundingBox.width
|
||||||
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
if CalibrationManager.shared.isCalibrating {
|
if CalibrationManager.shared.isCalibrating {
|
||||||
CalibrationManager.shared.collectSample(
|
CalibrationManager.shared.collectSample(
|
||||||
leftRatio: leftRatio,
|
leftRatio: leftRatio,
|
||||||
rightRatio: rightRatio
|
rightRatio: rightRatio,
|
||||||
|
leftVertical: leftVerticalRatio,
|
||||||
|
rightVertical: rightVerticalRatio,
|
||||||
|
faceWidthRatio: faceWidth
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let avgRatio = (leftRatio + rightRatio) / 2.0
|
let avgH = (leftRatio + rightRatio) / 2.0
|
||||||
let lookingRight = avgRatio <= EyeTrackingConstants.pixelGazeMinRatio
|
// Use 0.5 as default for vertical if not available
|
||||||
let lookingLeft = avgRatio >= EyeTrackingConstants.pixelGazeMaxRatio
|
let avgV = (leftVerticalRatio != nil && rightVerticalRatio != nil)
|
||||||
|
? (leftVerticalRatio! + rightVerticalRatio!) / 2.0
|
||||||
|
: 0.5
|
||||||
|
|
||||||
|
// Use Calibrated Thresholds from thread-safe state
|
||||||
|
if let thresholds = CalibrationState.shared.thresholds,
|
||||||
|
CalibrationState.shared.isComplete {
|
||||||
|
|
||||||
|
// 1. Distance Scaling
|
||||||
|
let currentFaceWidth = face.boundingBox.width
|
||||||
|
let refFaceWidth = thresholds.referenceFaceWidth
|
||||||
|
|
||||||
|
var distanceScale = 1.0
|
||||||
|
if refFaceWidth > 0 && currentFaceWidth > 0 {
|
||||||
|
distanceScale = refFaceWidth / currentFaceWidth
|
||||||
|
distanceScale = 1.0 + (distanceScale - 1.0) * EyeTrackingConstants.distanceSensitivity
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Normalize Gaze
|
||||||
|
let centerH = (thresholds.screenLeftBound + thresholds.screenRightBound) / 2.0
|
||||||
|
let centerV = (thresholds.screenTopBound + thresholds.screenBottomBound) / 2.0
|
||||||
|
|
||||||
|
let deltaH = (avgH - centerH) * distanceScale
|
||||||
|
let deltaV = (avgV - centerV) * distanceScale
|
||||||
|
|
||||||
|
let normalizedH = centerH + deltaH
|
||||||
|
let normalizedV = centerV + deltaV
|
||||||
|
|
||||||
|
// 3. Boundary Check
|
||||||
|
let margin = EyeTrackingConstants.boundaryForgivenessMargin
|
||||||
|
|
||||||
|
let isLookingLeft = normalizedH > (thresholds.screenLeftBound + margin)
|
||||||
|
let isLookingRight = normalizedH < (thresholds.screenRightBound - margin)
|
||||||
|
let isLookingUp = normalizedV < (thresholds.screenTopBound - margin)
|
||||||
|
let isLookingDown = normalizedV > (thresholds.screenBottomBound + margin)
|
||||||
|
|
||||||
|
eyesLookingAway = isLookingLeft || isLookingRight || isLookingUp || isLookingDown
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Fallback to default constants
|
||||||
|
let lookingRight = avgH <= EyeTrackingConstants.pixelGazeMinRatio
|
||||||
|
let lookingLeft = avgH >= EyeTrackingConstants.pixelGazeMaxRatio
|
||||||
eyesLookingAway = lookingRight || lookingLeft
|
eyesLookingAway = lookingRight || lookingLeft
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
result.lookingAway = poseLookingAway || eyesLookingAway
|
result.lookingAway = poseLookingAway || eyesLookingAway
|
||||||
return result
|
return result
|
||||||
@@ -621,6 +668,8 @@ class EyeTrackingService: NSObject, ObservableObject {
|
|||||||
{
|
{
|
||||||
var leftGazeRatio: Double? = nil
|
var leftGazeRatio: Double? = nil
|
||||||
var rightGazeRatio: Double? = nil
|
var rightGazeRatio: Double? = nil
|
||||||
|
var leftVerticalRatio: Double? = nil
|
||||||
|
var rightVerticalRatio: Double? = nil
|
||||||
|
|
||||||
// Detect left pupil (side = 0)
|
// Detect left pupil (side = 0)
|
||||||
if let leftResult = PupilDetector.detectPupil(
|
if let leftResult = PupilDetector.detectPupil(
|
||||||
@@ -634,6 +683,10 @@ class EyeTrackingService: NSObject, ObservableObject {
|
|||||||
pupilPosition: leftResult.pupilPosition,
|
pupilPosition: leftResult.pupilPosition,
|
||||||
eyeRegion: leftResult.eyeRegion
|
eyeRegion: leftResult.eyeRegion
|
||||||
)
|
)
|
||||||
|
leftVerticalRatio = calculateVerticalRatioSync(
|
||||||
|
pupilPosition: leftResult.pupilPosition,
|
||||||
|
eyeRegion: leftResult.eyeRegion
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect right pupil (side = 1)
|
// Detect right pupil (side = 1)
|
||||||
@@ -648,6 +701,10 @@ class EyeTrackingService: NSObject, ObservableObject {
|
|||||||
pupilPosition: rightResult.pupilPosition,
|
pupilPosition: rightResult.pupilPosition,
|
||||||
eyeRegion: rightResult.eyeRegion
|
eyeRegion: rightResult.eyeRegion
|
||||||
)
|
)
|
||||||
|
rightVerticalRatio = calculateVerticalRatioSync(
|
||||||
|
pupilPosition: rightResult.pupilPosition,
|
||||||
|
eyeRegion: rightResult.eyeRegion
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CRITICAL: Connect to CalibrationManager
|
// CRITICAL: Connect to CalibrationManager
|
||||||
@@ -655,37 +712,114 @@ class EyeTrackingService: NSObject, ObservableObject {
|
|||||||
let leftRatio = leftGazeRatio,
|
let leftRatio = leftGazeRatio,
|
||||||
let rightRatio = rightGazeRatio
|
let rightRatio = rightGazeRatio
|
||||||
{
|
{
|
||||||
|
// Calculate face width ratio for distance estimation
|
||||||
|
let faceWidthRatio = face.boundingBox.width
|
||||||
|
|
||||||
CalibrationManager.shared.collectSample(
|
CalibrationManager.shared.collectSample(
|
||||||
leftRatio: leftRatio,
|
leftRatio: leftRatio,
|
||||||
rightRatio: rightRatio
|
rightRatio: rightRatio,
|
||||||
|
leftVertical: leftVerticalRatio,
|
||||||
|
rightVertical: rightVerticalRatio,
|
||||||
|
faceWidthRatio: faceWidthRatio
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine looking away using calibrated thresholds
|
// Determine looking away using calibrated thresholds
|
||||||
if let leftRatio = leftGazeRatio, let rightRatio = rightGazeRatio {
|
if let leftRatio = leftGazeRatio, let rightRatio = rightGazeRatio {
|
||||||
let avgRatio = (leftRatio + rightRatio) / 2.0
|
let avgH = (leftRatio + rightRatio) / 2.0
|
||||||
let lookingRight = avgRatio <= EyeTrackingConstants.pixelGazeMinRatio
|
// Use 0.5 as default for vertical if not available (though it should be)
|
||||||
let lookingLeft = avgRatio >= EyeTrackingConstants.pixelGazeMaxRatio
|
let avgV = (leftVerticalRatio != nil && rightVerticalRatio != nil)
|
||||||
eyesLookingAway = lookingRight || lookingLeft
|
? (leftVerticalRatio! + rightVerticalRatio!) / 2.0
|
||||||
|
: 0.5
|
||||||
|
|
||||||
|
// Use Calibrated Thresholds if available
|
||||||
|
// Use thread-safe state instead of accessing CalibrationManager.shared (MainActor)
|
||||||
|
if let thresholds = CalibrationState.shared.thresholds,
|
||||||
|
CalibrationState.shared.isComplete {
|
||||||
|
|
||||||
|
// 1. Distance Scaling
|
||||||
|
// If current face is SMALLER than reference, user is FURTHER away.
|
||||||
|
// Eyes move LESS for same screen angle. We need to SCALE UP the deviation.
|
||||||
|
let currentFaceWidth = face.boundingBox.width
|
||||||
|
let refFaceWidth = thresholds.referenceFaceWidth
|
||||||
|
|
||||||
|
var distanceScale = 1.0
|
||||||
|
if refFaceWidth > 0 && currentFaceWidth > 0 {
|
||||||
|
// Simple linear scaling: scale = ref / current
|
||||||
|
// e.g. Ref=0.5, Current=0.25 (further) -> Scale=2.0
|
||||||
|
distanceScale = refFaceWidth / currentFaceWidth
|
||||||
|
|
||||||
|
// Apply sensitivity tuning
|
||||||
|
distanceScale = 1.0 + (distanceScale - 1.0) * EyeTrackingConstants.distanceSensitivity
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Normalize Gaze (Center Relative)
|
||||||
|
// We assume ~0.5 is center. We scale the delta from 0.5.
|
||||||
|
// Note: This is an approximation. A better way uses the calibrated center.
|
||||||
|
let centerH = (thresholds.screenLeftBound + thresholds.screenRightBound) / 2.0
|
||||||
|
let centerV = (thresholds.screenTopBound + thresholds.screenBottomBound) / 2.0
|
||||||
|
|
||||||
|
let deltaH = (avgH - centerH) * distanceScale
|
||||||
|
let deltaV = (avgV - centerV) * distanceScale
|
||||||
|
|
||||||
|
let normalizedH = centerH + deltaH
|
||||||
|
let normalizedV = centerV + deltaV
|
||||||
|
|
||||||
|
// 3. Boundary Check with Margin
|
||||||
|
// "Forgiveness" expands the safe zone (screen bounds).
|
||||||
|
// If you are IN the margin, you are considered ON SCREEN (Safe).
|
||||||
|
// Looking Away means passing the (Bound + Margin).
|
||||||
|
|
||||||
|
let margin = EyeTrackingConstants.boundaryForgivenessMargin
|
||||||
|
|
||||||
|
// Check Left (Higher Ratio)
|
||||||
|
// Screen Left is e.g. 0.7. Looking Left > 0.7.
|
||||||
|
// To look away, must exceed (0.7 + margin).
|
||||||
|
let isLookingLeft = normalizedH > (thresholds.screenLeftBound + margin)
|
||||||
|
|
||||||
|
// Check Right (Lower Ratio)
|
||||||
|
// Screen Right is e.g. 0.3. Looking Right < 0.3.
|
||||||
|
// To look away, must be less than (0.3 - margin).
|
||||||
|
let isLookingRight = normalizedH < (thresholds.screenRightBound - margin)
|
||||||
|
|
||||||
|
// Check Up (Lower Ratio, usually)
|
||||||
|
let isLookingUp = normalizedV < (thresholds.screenTopBound - margin)
|
||||||
|
|
||||||
|
// Check Down (Higher Ratio, usually)
|
||||||
|
let isLookingDown = normalizedV > (thresholds.screenBottomBound + margin)
|
||||||
|
|
||||||
|
eyesLookingAway = isLookingLeft || isLookingRight || isLookingUp || isLookingDown
|
||||||
|
|
||||||
if shouldLog {
|
if shouldLog {
|
||||||
print(
|
print("👁️ CALIBRATED GAZE: AvgH=\(String(format: "%.2f", avgH)) AvgV=\(String(format: "%.2f", avgV)) DistScale=\(String(format: "%.2f", distanceScale))")
|
||||||
"👁️ PIXEL GAZE: L=\(String(format: "%.3f", leftRatio)) R=\(String(format: "%.3f", rightRatio)) Avg=\(String(format: "%.3f", avgRatio)) Away=\(eyesLookingAway)"
|
print(" NormH=\(String(format: "%.2f", normalizedH)) NormV=\(String(format: "%.2f", normalizedV)) Away=\(eyesLookingAway)")
|
||||||
)
|
print(" Bounds: H[\(String(format: "%.2f", thresholds.screenRightBound))-\(String(format: "%.2f", thresholds.screenLeftBound))] V[\(String(format: "%.2f", thresholds.screenTopBound))-\(String(format: "%.2f", thresholds.screenBottomBound))]")
|
||||||
print(
|
|
||||||
" Thresholds: Min=\(String(format: "%.3f", EyeTrackingConstants.pixelGazeMinRatio)) Max=\(String(format: "%.3f", EyeTrackingConstants.pixelGazeMaxRatio))"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
if shouldLog {
|
// Fallback to default constants
|
||||||
print("⚠️ Pixel pupil detection failed for one or both eyes")
|
let lookingRight = avgH <= EyeTrackingConstants.pixelGazeMinRatio
|
||||||
}
|
let lookingLeft = avgH >= EyeTrackingConstants.pixelGazeMaxRatio
|
||||||
|
eyesLookingAway = lookingRight || lookingLeft
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update debug values
|
// Update debug values
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
debugLeftPupilRatio = leftGazeRatio
|
debugLeftPupilRatio = leftGazeRatio
|
||||||
debugRightPupilRatio = rightGazeRatio
|
debugRightPupilRatio = rightGazeRatio
|
||||||
|
debugLeftVerticalRatio = leftVerticalRatio
|
||||||
|
debugRightVerticalRatio = rightVerticalRatio
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldLog && !CalibrationState.shared.isComplete {
|
||||||
|
print(
|
||||||
|
"👁️ RAW GAZE: L=\(String(format: "%.3f", leftRatio)) R=\(String(format: "%.3f", rightRatio)) Avg=\(String(format: "%.3f", avgH)) Away=\(eyesLookingAway)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if shouldLog {
|
||||||
|
print("⚠️ Pixel pupil detection failed for one or both eyes")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if shouldLog {
|
if shouldLog {
|
||||||
|
|||||||
@@ -747,10 +747,23 @@ final class PupilDetector: @unchecked Sendable {
|
|||||||
let size = width * height
|
let size = width * height
|
||||||
guard size > 0 else { return }
|
guard size > 0 else { return }
|
||||||
|
|
||||||
// SIMPLIFIED: Skip blur to avoid contaminating dark pupil pixels with bright mask pixels
|
// 1. Apply Gaussian Blur (reduces noise)
|
||||||
// Apply binary threshold directly to input
|
// We reuse tempBuffer for intermediate steps if available, or just output
|
||||||
|
// Note: gaussianBlurOptimized writes from input -> output
|
||||||
|
gaussianBlurOptimized(input: input, output: output, width: width, height: height)
|
||||||
|
|
||||||
|
// 2. Apply Erosion (expands dark regions)
|
||||||
|
// Python: cv2.erode(kernel, iterations=3)
|
||||||
|
// This helps connect broken parts of the pupil
|
||||||
|
// Note: erodeOptimized processes in-place on output if input==output
|
||||||
|
erodeOptimized(input: output, output: output, width: width, height: height, iterations: 3)
|
||||||
|
|
||||||
|
// 3. Binary Threshold
|
||||||
for i in 0..<size {
|
for i in 0..<size {
|
||||||
output[i] = input[i] > UInt8(threshold) ? 255 : 0
|
// Python: cv2.threshold(..., cv2.THRESH_BINARY)
|
||||||
|
// Pixels > threshold become 255 (white), others 0 (black)
|
||||||
|
// So Pupil is BLACK (0)
|
||||||
|
output[i] = output[i] > UInt8(threshold) ? 255 : 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -851,47 +864,103 @@ final class PupilDetector: @unchecked Sendable {
|
|||||||
|
|
||||||
// MARK: - Optimized Contour Detection
|
// MARK: - Optimized Contour Detection
|
||||||
|
|
||||||
/// Optimized centroid-of-dark-pixels approach - much faster than union-find
|
/// Finds the largest connected component of dark pixels and returns its centroid
|
||||||
/// Returns the centroid of the largest dark region
|
/// This is much more robust than averaging all dark pixels, as it ignores shadows/noise
|
||||||
private nonisolated static func findPupilFromContoursOptimized(
|
private nonisolated static func findPupilFromContoursOptimized(
|
||||||
data: UnsafePointer<UInt8>,
|
data: UnsafePointer<UInt8>,
|
||||||
width: Int,
|
width: Int,
|
||||||
height: Int
|
height: Int
|
||||||
) -> (x: Double, y: Double)? {
|
) -> (x: Double, y: Double)? {
|
||||||
|
let size = width * height
|
||||||
|
|
||||||
// Optimized approach: find centroid of all black pixels
|
// 1. Threshold pass: Identify all dark pixels (0)
|
||||||
// This works well for pupil detection since the pupil is the main dark blob
|
// We use a visited array to track processed pixels for flood fill
|
||||||
|
// Using a flat bool array for performance
|
||||||
|
var visited = [Bool](repeating: false, count: size)
|
||||||
|
|
||||||
var sumX: Int = 0
|
var maxBlobSize = 0
|
||||||
var sumY: Int = 0
|
var maxBlobSumX = 0
|
||||||
var count: Int = 0
|
var maxBlobSumY = 0
|
||||||
|
|
||||||
// After binary thresholding, pixels are 0 (black/pupil) or 255 (white/background)
|
// 2. Iterate through pixels to find connected components
|
||||||
// Use threshold of 128 to catch any pixels that are closer to black
|
|
||||||
let threshold = UInt8(128)
|
|
||||||
|
|
||||||
// Process entire image to get accurate centroid
|
|
||||||
for y in 0..<height {
|
for y in 0..<height {
|
||||||
let rowOffset = y * width
|
let rowOffset = y * width
|
||||||
for x in 0..<width {
|
for x in 0..<width {
|
||||||
if data[rowOffset + x] < threshold {
|
let idx = rowOffset + x
|
||||||
sumX += x
|
|
||||||
sumY += y
|
// If it's a dark pixel (0) and not visited, start a flood fill
|
||||||
count += 1
|
if data[idx] == 0 && !visited[idx] {
|
||||||
|
var currentBlobSize = 0
|
||||||
|
var currentBlobSumX = 0
|
||||||
|
var currentBlobSumY = 0
|
||||||
|
|
||||||
|
// Stack for DFS/BFS (using array as stack is fast in Swift)
|
||||||
|
var stack: [Int] = [idx]
|
||||||
|
visited[idx] = true
|
||||||
|
|
||||||
|
while let currentIdx = stack.popLast() {
|
||||||
|
let cx = currentIdx % width
|
||||||
|
let cy = currentIdx / width
|
||||||
|
|
||||||
|
currentBlobSize += 1
|
||||||
|
currentBlobSumX += cx
|
||||||
|
currentBlobSumY += cy
|
||||||
|
|
||||||
|
// Check 4 neighbors
|
||||||
|
// Right
|
||||||
|
if cx + 1 < width {
|
||||||
|
let nIdx = currentIdx + 1
|
||||||
|
if data[nIdx] == 0 && !visited[nIdx] {
|
||||||
|
visited[nIdx] = true
|
||||||
|
stack.append(nIdx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Left
|
||||||
|
if cx - 1 >= 0 {
|
||||||
|
let nIdx = currentIdx - 1
|
||||||
|
if data[nIdx] == 0 && !visited[nIdx] {
|
||||||
|
visited[nIdx] = true
|
||||||
|
stack.append(nIdx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Down
|
||||||
|
if cy + 1 < height {
|
||||||
|
let nIdx = currentIdx + width
|
||||||
|
if data[nIdx] == 0 && !visited[nIdx] {
|
||||||
|
visited[nIdx] = true
|
||||||
|
stack.append(nIdx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Up
|
||||||
|
if cy - 1 >= 0 {
|
||||||
|
let nIdx = currentIdx - width
|
||||||
|
if data[nIdx] == 0 && !visited[nIdx] {
|
||||||
|
visited[nIdx] = true
|
||||||
|
stack.append(nIdx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if enableDiagnosticLogging && count < 5 {
|
// Check if this is the largest blob so far
|
||||||
logDebug("👁 PupilDetector: Dark pixel count = \(count) (need >= 5)")
|
if currentBlobSize > maxBlobSize {
|
||||||
|
maxBlobSize = currentBlobSize
|
||||||
|
maxBlobSumX = currentBlobSumX
|
||||||
|
maxBlobSumY = currentBlobSumY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Minimum 5 pixels for valid pupil (reduced from 10 for small eye regions)
|
if enableDiagnosticLogging && maxBlobSize < 5 {
|
||||||
guard count >= 5 else { return nil }
|
logDebug("👁 PupilDetector: Largest blob size = \(maxBlobSize) (need >= 5)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimum 5 pixels for valid pupil
|
||||||
|
guard maxBlobSize >= 5 else { return nil }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
x: Double(sumX) / Double(count),
|
x: Double(maxBlobSumX) / Double(maxBlobSize),
|
||||||
y: Double(sumY) / Double(count)
|
y: Double(maxBlobSumY) / Double(maxBlobSize)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user