general: fixes, log updates
This commit is contained in:
@@ -55,38 +55,38 @@ class EnforceModeService: ObservableObject {
|
|||||||
// If settings say it's enabled AND camera is authorized, mark as enabled
|
// If settings say it's enabled AND camera is authorized, mark as enabled
|
||||||
if settingsEnabled && cameraService.isCameraAuthorized {
|
if settingsEnabled && cameraService.isCameraAuthorized {
|
||||||
isEnforceModeEnabled = true
|
isEnforceModeEnabled = true
|
||||||
print("✓ Enforce mode initialized as enabled (camera authorized)")
|
logDebug("✓ Enforce mode initialized as enabled (camera authorized)")
|
||||||
} else {
|
} else {
|
||||||
isEnforceModeEnabled = false
|
isEnforceModeEnabled = false
|
||||||
print("🔒 Enforce mode initialized as disabled")
|
logDebug("🔒 Enforce mode initialized as disabled")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func enableEnforceMode() async {
|
func enableEnforceMode() async {
|
||||||
print("🔒 enableEnforceMode called")
|
logDebug("🔒 enableEnforceMode called")
|
||||||
guard !isEnforceModeEnabled else {
|
guard !isEnforceModeEnabled else {
|
||||||
print("⚠️ Enforce mode already enabled")
|
logError("⚠️ Enforce mode already enabled")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let cameraService = CameraAccessService.shared
|
let cameraService = CameraAccessService.shared
|
||||||
if !cameraService.isCameraAuthorized {
|
if !cameraService.isCameraAuthorized {
|
||||||
do {
|
do {
|
||||||
print("🔒 Requesting camera permission...")
|
logDebug("🔒 Requesting camera permission...")
|
||||||
try await cameraService.requestCameraAccess()
|
try await cameraService.requestCameraAccess()
|
||||||
} catch {
|
} catch {
|
||||||
print("⚠️ Failed to get camera permission: \(error.localizedDescription)")
|
logError("⚠️ Failed to get camera permission: \(error.localizedDescription)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
guard cameraService.isCameraAuthorized else {
|
guard cameraService.isCameraAuthorized else {
|
||||||
print("❌ Camera permission denied")
|
logError("❌ Camera permission denied")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
isEnforceModeEnabled = true
|
isEnforceModeEnabled = true
|
||||||
print("✓ Enforce mode enabled (camera will activate before lookaway reminders)")
|
logDebug("✓ Enforce mode enabled (camera will activate before lookaway reminders)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func disableEnforceMode() {
|
func disableEnforceMode() {
|
||||||
@@ -95,7 +95,7 @@ class EnforceModeService: ObservableObject {
|
|||||||
stopCamera()
|
stopCamera()
|
||||||
isEnforceModeEnabled = false
|
isEnforceModeEnabled = false
|
||||||
userCompliedWithBreak = false
|
userCompliedWithBreak = false
|
||||||
print("✓ Enforce mode disabled")
|
logDebug("✓ Enforce mode disabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
func setTimerEngine(_ engine: TimerEngine) {
|
func setTimerEngine(_ engine: TimerEngine) {
|
||||||
@@ -118,23 +118,23 @@ class EnforceModeService: ObservableObject {
|
|||||||
guard isEnforceModeEnabled else { return }
|
guard isEnforceModeEnabled else { return }
|
||||||
guard !isCameraActive else { return }
|
guard !isCameraActive else { return }
|
||||||
|
|
||||||
print("👁️ Starting camera for lookaway reminder (T-\(secondsRemaining)s)")
|
logDebug("👁️ Starting camera for lookaway reminder (T-\(secondsRemaining)s)")
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try await eyeTrackingService.startEyeTracking()
|
try await eyeTrackingService.startEyeTracking()
|
||||||
isCameraActive = true
|
isCameraActive = true
|
||||||
lastFaceDetectionTime = Date() // Reset grace period
|
lastFaceDetectionTime = Date() // Reset grace period
|
||||||
startFaceDetectionTimer()
|
startFaceDetectionTimer()
|
||||||
print("✓ Camera active")
|
logDebug("✓ Camera active")
|
||||||
} catch {
|
} catch {
|
||||||
print("⚠️ Failed to start camera: \(error.localizedDescription)")
|
logError("⚠️ Failed to start camera: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopCamera() {
|
func stopCamera() {
|
||||||
guard isCameraActive else { return }
|
guard isCameraActive else { return }
|
||||||
|
|
||||||
print("👁️ Stopping camera")
|
logDebug("👁️ Stopping camera")
|
||||||
eyeTrackingService.stopEyeTracking()
|
eyeTrackingService.stopEyeTracking()
|
||||||
isCameraActive = false
|
isCameraActive = false
|
||||||
userCompliedWithBreak = false
|
userCompliedWithBreak = false
|
||||||
@@ -191,7 +191,7 @@ class EnforceModeService: ObservableObject {
|
|||||||
|
|
||||||
// If person has not been detected for too long, temporarily disable enforce mode
|
// If person has not been detected for too long, temporarily disable enforce mode
|
||||||
if timeSinceLastDetection > faceDetectionTimeout {
|
if timeSinceLastDetection > faceDetectionTimeout {
|
||||||
print(
|
logDebug(
|
||||||
"⏰ Person not detected for \(faceDetectionTimeout)s. Temporarily disabling enforce mode."
|
"⏰ Person not detected for \(faceDetectionTimeout)s. Temporarily disabling enforce mode."
|
||||||
)
|
)
|
||||||
disableEnforceMode()
|
disableEnforceMode()
|
||||||
@@ -210,7 +210,7 @@ class EnforceModeService: ObservableObject {
|
|||||||
guard isEnforceModeEnabled else { return }
|
guard isEnforceModeEnabled else { return }
|
||||||
guard !isCameraActive else { return }
|
guard !isCameraActive else { return }
|
||||||
|
|
||||||
print("🧪 Starting test mode")
|
logDebug("🧪 Starting test mode")
|
||||||
isTestMode = true
|
isTestMode = true
|
||||||
|
|
||||||
do {
|
do {
|
||||||
@@ -218,9 +218,9 @@ class EnforceModeService: ObservableObject {
|
|||||||
isCameraActive = true
|
isCameraActive = true
|
||||||
lastFaceDetectionTime = Date() // Reset grace period
|
lastFaceDetectionTime = Date() // Reset grace period
|
||||||
startFaceDetectionTimer()
|
startFaceDetectionTimer()
|
||||||
print("✓ Test mode camera active")
|
logDebug("✓ Test mode camera active")
|
||||||
} catch {
|
} catch {
|
||||||
print("⚠️ Failed to start test mode camera: \(error.localizedDescription)")
|
logError("⚠️ Failed to start test mode camera: \(error.localizedDescription)")
|
||||||
isTestMode = false
|
isTestMode = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -228,7 +228,7 @@ class EnforceModeService: ObservableObject {
|
|||||||
func stopTestMode() {
|
func stopTestMode() {
|
||||||
guard isTestMode else { return }
|
guard isTestMode else { return }
|
||||||
|
|
||||||
print("🧪 Stopping test mode")
|
logDebug("🧪 Stopping test mode")
|
||||||
stopCamera()
|
stopCamera()
|
||||||
isTestMode = false
|
isTestMode = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,11 +14,11 @@
|
|||||||
// - Efficient contour detection with union-find
|
// - Efficient contour detection with union-find
|
||||||
//
|
//
|
||||||
|
|
||||||
import CoreImage
|
|
||||||
import Vision
|
|
||||||
import Accelerate
|
import Accelerate
|
||||||
|
import CoreImage
|
||||||
import ImageIO
|
import ImageIO
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
import Vision
|
||||||
|
|
||||||
struct PupilPosition: Equatable, Sendable {
|
struct PupilPosition: Equatable, Sendable {
|
||||||
let x: CGFloat
|
let x: CGFloat
|
||||||
@@ -37,13 +37,13 @@ final class PupilCalibration: @unchecked Sendable {
|
|||||||
private let targetFrames = 20
|
private let targetFrames = 20
|
||||||
private var thresholdsLeft: [Int] = []
|
private var thresholdsLeft: [Int] = []
|
||||||
private var thresholdsRight: [Int] = []
|
private var thresholdsRight: [Int] = []
|
||||||
|
|
||||||
var isComplete: Bool {
|
var isComplete: Bool {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
return thresholdsLeft.count >= targetFrames && thresholdsRight.count >= targetFrames
|
return thresholdsLeft.count >= targetFrames && thresholdsRight.count >= targetFrames
|
||||||
}
|
}
|
||||||
|
|
||||||
func threshold(forSide side: Int) -> Int {
|
func threshold(forSide side: Int) -> Int {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
@@ -51,7 +51,7 @@ final class PupilCalibration: @unchecked Sendable {
|
|||||||
guard !thresholds.isEmpty else { return 50 }
|
guard !thresholds.isEmpty else { return 50 }
|
||||||
return thresholds.reduce(0, +) / thresholds.count
|
return thresholds.reduce(0, +) / thresholds.count
|
||||||
}
|
}
|
||||||
|
|
||||||
func evaluate(eyeData: UnsafePointer<UInt8>, width: Int, height: Int, side: Int) {
|
func evaluate(eyeData: UnsafePointer<UInt8>, width: Int, height: Int, side: Int) {
|
||||||
let bestThreshold = findBestThreshold(eyeData: eyeData, width: width, height: height)
|
let bestThreshold = findBestThreshold(eyeData: eyeData, width: width, height: height)
|
||||||
lock.lock()
|
lock.lock()
|
||||||
@@ -62,16 +62,16 @@ final class PupilCalibration: @unchecked Sendable {
|
|||||||
thresholdsRight.append(bestThreshold)
|
thresholdsRight.append(bestThreshold)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func findBestThreshold(eyeData: UnsafePointer<UInt8>, width: Int, height: Int) -> Int {
|
private func findBestThreshold(eyeData: UnsafePointer<UInt8>, width: Int, height: Int) -> Int {
|
||||||
let averageIrisSize = 0.48
|
let averageIrisSize = 0.48
|
||||||
var bestThreshold = 50
|
var bestThreshold = 50
|
||||||
var bestDiff = Double.greatestFiniteMagnitude
|
var bestDiff = Double.greatestFiniteMagnitude
|
||||||
|
|
||||||
let bufferSize = width * height
|
let bufferSize = width * height
|
||||||
let tempBuffer = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize)
|
let tempBuffer = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize)
|
||||||
defer { tempBuffer.deallocate() }
|
defer { tempBuffer.deallocate() }
|
||||||
|
|
||||||
for threshold in stride(from: 5, to: 100, by: 5) {
|
for threshold in stride(from: 5, to: 100, by: 5) {
|
||||||
PupilDetector.imageProcessingOptimized(
|
PupilDetector.imageProcessingOptimized(
|
||||||
input: eyeData,
|
input: eyeData,
|
||||||
@@ -87,18 +87,18 @@ final class PupilCalibration: @unchecked Sendable {
|
|||||||
bestThreshold = threshold
|
bestThreshold = threshold
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return bestThreshold
|
return bestThreshold
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func irisSize(data: UnsafePointer<UInt8>, width: Int, height: Int) -> Double {
|
private static func irisSize(data: UnsafePointer<UInt8>, width: Int, height: Int) -> Double {
|
||||||
let margin = 5
|
let margin = 5
|
||||||
guard width > margin * 2, height > margin * 2 else { return 0 }
|
guard width > margin * 2, height > margin * 2 else { return 0 }
|
||||||
|
|
||||||
var blackCount = 0
|
var blackCount = 0
|
||||||
let innerWidth = width - margin * 2
|
let innerWidth = width - margin * 2
|
||||||
let innerHeight = height - margin * 2
|
let innerHeight = height - margin * 2
|
||||||
|
|
||||||
for y in margin..<(height - margin) {
|
for y in margin..<(height - margin) {
|
||||||
let rowStart = y * width + margin
|
let rowStart = y * width + margin
|
||||||
for x in 0..<innerWidth {
|
for x in 0..<innerWidth {
|
||||||
@@ -107,11 +107,11 @@ final class PupilCalibration: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let totalCount = innerWidth * innerHeight
|
let totalCount = innerWidth * innerHeight
|
||||||
return totalCount > 0 ? Double(blackCount) / Double(totalCount) : 0
|
return totalCount > 0 ? Double(blackCount) / Double(totalCount) : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func reset() {
|
func reset() {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
@@ -126,7 +126,7 @@ struct PupilDetectorMetrics: Sendable {
|
|||||||
var averageProcessingTimeMs: Double = 0
|
var averageProcessingTimeMs: Double = 0
|
||||||
var frameCount: Int = 0
|
var frameCount: Int = 0
|
||||||
var processedFrameCount: Int = 0
|
var processedFrameCount: Int = 0
|
||||||
|
|
||||||
mutating func recordProcessingTime(_ ms: Double) {
|
mutating func recordProcessingTime(_ ms: Double) {
|
||||||
lastProcessingTimeMs = ms
|
lastProcessingTimeMs = ms
|
||||||
processedFrameCount += 1
|
processedFrameCount += 1
|
||||||
@@ -136,50 +136,52 @@ struct PupilDetectorMetrics: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final class PupilDetector: @unchecked Sendable {
|
final class PupilDetector: @unchecked Sendable {
|
||||||
|
|
||||||
// MARK: - Thread Safety
|
// MARK: - Thread Safety
|
||||||
|
|
||||||
private static let lock = NSLock()
|
private static let lock = NSLock()
|
||||||
|
|
||||||
// MARK: - Configuration
|
// MARK: - Configuration
|
||||||
|
|
||||||
static var enableDebugImageSaving = false
|
static var enableDebugImageSaving = false
|
||||||
static var enablePerformanceLogging = false
|
static var enablePerformanceLogging = false
|
||||||
static var frameSkipCount = 10 // Process every Nth frame
|
static var frameSkipCount = 10 // Process every Nth frame
|
||||||
|
|
||||||
// MARK: - State (protected by lock)
|
// MARK: - State (protected by lock)
|
||||||
|
|
||||||
private static var _debugImageCounter = 0
|
private static var _debugImageCounter = 0
|
||||||
private static var _frameCounter = 0
|
private static var _frameCounter = 0
|
||||||
private static var _lastPupilPositions: (left: PupilPosition?, right: PupilPosition?) = (nil, nil)
|
private static var _lastPupilPositions: (left: PupilPosition?, right: PupilPosition?) = (
|
||||||
|
nil, nil
|
||||||
|
)
|
||||||
private static var _metrics = PupilDetectorMetrics()
|
private static var _metrics = PupilDetectorMetrics()
|
||||||
|
|
||||||
static let calibration = PupilCalibration()
|
static let calibration = PupilCalibration()
|
||||||
|
|
||||||
// MARK: - Convenience Properties
|
// MARK: - Convenience Properties
|
||||||
|
|
||||||
private static var debugImageCounter: Int {
|
private static var debugImageCounter: Int {
|
||||||
get { _debugImageCounter }
|
get { _debugImageCounter }
|
||||||
set { _debugImageCounter = newValue }
|
set { _debugImageCounter = newValue }
|
||||||
}
|
}
|
||||||
|
|
||||||
private static var frameCounter: Int {
|
private static var frameCounter: Int {
|
||||||
get { _frameCounter }
|
get { _frameCounter }
|
||||||
set { _frameCounter = newValue }
|
set { _frameCounter = newValue }
|
||||||
}
|
}
|
||||||
|
|
||||||
private static var lastPupilPositions: (left: PupilPosition?, right: PupilPosition?) {
|
private static var lastPupilPositions: (left: PupilPosition?, right: PupilPosition?) {
|
||||||
get { _lastPupilPositions }
|
get { _lastPupilPositions }
|
||||||
set { _lastPupilPositions = newValue }
|
set { _lastPupilPositions = newValue }
|
||||||
}
|
}
|
||||||
|
|
||||||
private static var metrics: PupilDetectorMetrics {
|
private static var metrics: PupilDetectorMetrics {
|
||||||
get { _metrics }
|
get { _metrics }
|
||||||
set { _metrics = newValue }
|
set { _metrics = newValue }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Precomputed Tables
|
// MARK: - Precomputed Tables
|
||||||
|
|
||||||
private static let spatialWeightsLUT: [[Float]] = {
|
private static let spatialWeightsLUT: [[Float]] = {
|
||||||
let d = 10
|
let d = 10
|
||||||
let radius = d / 2
|
let radius = d / 2
|
||||||
@@ -187,13 +189,14 @@ final class PupilDetector: @unchecked Sendable {
|
|||||||
var weights = [[Float]](repeating: [Float](repeating: 0, count: d), count: d)
|
var weights = [[Float]](repeating: [Float](repeating: 0, count: d), count: d)
|
||||||
for dy in 0..<d {
|
for dy in 0..<d {
|
||||||
for dx in 0..<d {
|
for dx in 0..<d {
|
||||||
let dist = sqrt(Float((dy - radius) * (dy - radius) + (dx - radius) * (dx - radius)))
|
let dist = sqrt(
|
||||||
|
Float((dy - radius) * (dy - radius) + (dx - radius) * (dx - radius)))
|
||||||
weights[dy][dx] = exp(-dist * dist / (2 * sigmaSpace * sigmaSpace))
|
weights[dy][dx] = exp(-dist * dist / (2 * sigmaSpace * sigmaSpace))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return weights
|
return weights
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private static let colorWeightsLUT: [Float] = {
|
private static let colorWeightsLUT: [Float] = {
|
||||||
let sigmaColor: Float = 15.0
|
let sigmaColor: Float = 15.0
|
||||||
var lut = [Float](repeating: 0, count: 256)
|
var lut = [Float](repeating: 0, count: 256)
|
||||||
@@ -203,18 +206,18 @@ final class PupilDetector: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
return lut
|
return lut
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// MARK: - Reusable Buffers
|
// MARK: - Reusable Buffers
|
||||||
|
|
||||||
private static var grayscaleBuffer: UnsafeMutablePointer<UInt8>?
|
private static var grayscaleBuffer: UnsafeMutablePointer<UInt8>?
|
||||||
private static var grayscaleBufferSize = 0
|
private static var grayscaleBufferSize = 0
|
||||||
private static var eyeBuffer: UnsafeMutablePointer<UInt8>?
|
private static var eyeBuffer: UnsafeMutablePointer<UInt8>?
|
||||||
private static var eyeBufferSize = 0
|
private static var eyeBufferSize = 0
|
||||||
private static var tempBuffer: UnsafeMutablePointer<UInt8>?
|
private static var tempBuffer: UnsafeMutablePointer<UInt8>?
|
||||||
private static var tempBufferSize = 0
|
private static var tempBufferSize = 0
|
||||||
|
|
||||||
// MARK: - Public API
|
// MARK: - Public API
|
||||||
|
|
||||||
/// Detects pupil position with frame skipping for performance
|
/// Detects pupil position with frame skipping for performance
|
||||||
/// Returns cached result on skipped frames
|
/// Returns cached result on skipped frames
|
||||||
nonisolated static func detectPupil(
|
nonisolated static func detectPupil(
|
||||||
@@ -225,7 +228,7 @@ final class PupilDetector: @unchecked Sendable {
|
|||||||
side: Int = 0,
|
side: Int = 0,
|
||||||
threshold: Int? = nil
|
threshold: Int? = nil
|
||||||
) -> (pupilPosition: PupilPosition, eyeRegion: EyeRegion)? {
|
) -> (pupilPosition: PupilPosition, eyeRegion: EyeRegion)? {
|
||||||
|
|
||||||
// Frame skipping - return cached result
|
// Frame skipping - return cached result
|
||||||
if frameCounter % frameSkipCount != 0 {
|
if frameCounter % frameSkipCount != 0 {
|
||||||
let cachedPosition = side == 0 ? lastPupilPositions.left : lastPupilPositions.right
|
let cachedPosition = side == 0 ? lastPupilPositions.left : lastPupilPositions.right
|
||||||
@@ -242,68 +245,77 @@ final class PupilDetector: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let startTime = CFAbsoluteTimeGetCurrent()
|
let startTime = CFAbsoluteTimeGetCurrent()
|
||||||
defer {
|
defer {
|
||||||
if enablePerformanceLogging {
|
if enablePerformanceLogging {
|
||||||
let elapsed = (CFAbsoluteTimeGetCurrent() - startTime) * 1000
|
let elapsed = (CFAbsoluteTimeGetCurrent() - startTime) * 1000
|
||||||
metrics.recordProcessingTime(elapsed)
|
metrics.recordProcessingTime(elapsed)
|
||||||
if metrics.processedFrameCount % 30 == 0 {
|
if metrics.processedFrameCount % 30 == 0 {
|
||||||
print("👁 PupilDetector: \(String(format: "%.2f", elapsed))ms (avg: \(String(format: "%.2f", metrics.averageProcessingTimeMs))ms)")
|
print(
|
||||||
|
"👁 PupilDetector: \(String(format: "%.2f", elapsed))ms (avg: \(String(format: "%.2f", metrics.averageProcessingTimeMs))ms)"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Convert Vision landmarks to pixel coordinates
|
// Step 1: Convert Vision landmarks to pixel coordinates
|
||||||
let eyePoints = landmarksToPixelCoordinates(
|
let eyePoints = landmarksToPixelCoordinates(
|
||||||
landmarks: eyeLandmarks,
|
landmarks: eyeLandmarks,
|
||||||
faceBoundingBox: faceBoundingBox,
|
faceBoundingBox: faceBoundingBox,
|
||||||
imageSize: imageSize
|
imageSize: imageSize
|
||||||
)
|
)
|
||||||
|
|
||||||
guard eyePoints.count >= 6 else { return nil }
|
guard eyePoints.count >= 6 else { return nil }
|
||||||
|
|
||||||
// Step 2: Create eye region bounding box with margin
|
// Step 2: Create eye region bounding box with margin
|
||||||
guard let eyeRegion = createEyeRegion(from: eyePoints, imageSize: imageSize) else {
|
guard let eyeRegion = createEyeRegion(from: eyePoints, imageSize: imageSize) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let frameWidth = CVPixelBufferGetWidth(pixelBuffer)
|
let frameWidth = CVPixelBufferGetWidth(pixelBuffer)
|
||||||
let frameHeight = CVPixelBufferGetHeight(pixelBuffer)
|
let frameHeight = CVPixelBufferGetHeight(pixelBuffer)
|
||||||
let frameSize = frameWidth * frameHeight
|
let frameSize = frameWidth * frameHeight
|
||||||
|
|
||||||
// Step 3: Ensure buffers are allocated
|
// Step 3: Ensure buffers are allocated
|
||||||
ensureBufferCapacity(frameSize: frameSize, eyeSize: Int(eyeRegion.frame.width * eyeRegion.frame.height))
|
ensureBufferCapacity(
|
||||||
|
frameSize: frameSize, eyeSize: Int(eyeRegion.frame.width * eyeRegion.frame.height))
|
||||||
|
|
||||||
guard let grayBuffer = grayscaleBuffer,
|
guard let grayBuffer = grayscaleBuffer,
|
||||||
let eyeBuf = eyeBuffer,
|
let eyeBuf = eyeBuffer,
|
||||||
let tmpBuf = tempBuffer else {
|
let tmpBuf = tempBuffer
|
||||||
|
else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Extract grayscale data using vImage
|
// Step 4: Extract grayscale data using vImage
|
||||||
guard extractGrayscaleDataOptimized(from: pixelBuffer, to: grayBuffer, width: frameWidth, height: frameHeight) else {
|
guard
|
||||||
|
extractGrayscaleDataOptimized(
|
||||||
|
from: pixelBuffer, to: grayBuffer, width: frameWidth, height: frameHeight)
|
||||||
|
else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 5: Isolate eye with polygon mask
|
// Step 5: Isolate eye with polygon mask
|
||||||
let eyeWidth = Int(eyeRegion.frame.width)
|
let eyeWidth = Int(eyeRegion.frame.width)
|
||||||
let eyeHeight = Int(eyeRegion.frame.height)
|
let eyeHeight = Int(eyeRegion.frame.height)
|
||||||
|
|
||||||
// Early exit for tiny regions (less than 10x10 pixels)
|
// Early exit for tiny regions (less than 10x10 pixels)
|
||||||
guard eyeWidth >= 10, eyeHeight >= 10 else { return nil }
|
guard eyeWidth >= 10, eyeHeight >= 10 else { return nil }
|
||||||
|
|
||||||
guard isolateEyeWithMaskOptimized(
|
guard
|
||||||
frameData: grayBuffer,
|
isolateEyeWithMaskOptimized(
|
||||||
frameWidth: frameWidth,
|
frameData: grayBuffer,
|
||||||
frameHeight: frameHeight,
|
frameWidth: frameWidth,
|
||||||
eyePoints: eyePoints,
|
frameHeight: frameHeight,
|
||||||
region: eyeRegion,
|
eyePoints: eyePoints,
|
||||||
output: eyeBuf
|
region: eyeRegion,
|
||||||
) else {
|
output: eyeBuf
|
||||||
|
)
|
||||||
|
else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 6: Get threshold (from calibration or parameter)
|
// Step 6: Get threshold (from calibration or parameter)
|
||||||
let effectiveThreshold: Int
|
let effectiveThreshold: Int
|
||||||
if let manualThreshold = threshold {
|
if let manualThreshold = threshold {
|
||||||
@@ -314,7 +326,7 @@ final class PupilDetector: @unchecked Sendable {
|
|||||||
calibration.evaluate(eyeData: eyeBuf, width: eyeWidth, height: eyeHeight, side: side)
|
calibration.evaluate(eyeData: eyeBuf, width: eyeWidth, height: eyeHeight, side: side)
|
||||||
effectiveThreshold = calibration.threshold(forSide: side)
|
effectiveThreshold = calibration.threshold(forSide: side)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 7: Process image (bilateral filter + erosion + threshold)
|
// Step 7: Process image (bilateral filter + erosion + threshold)
|
||||||
imageProcessingOptimized(
|
imageProcessingOptimized(
|
||||||
input: eyeBuf,
|
input: eyeBuf,
|
||||||
@@ -323,43 +335,47 @@ final class PupilDetector: @unchecked Sendable {
|
|||||||
height: eyeHeight,
|
height: eyeHeight,
|
||||||
threshold: effectiveThreshold
|
threshold: effectiveThreshold
|
||||||
)
|
)
|
||||||
|
|
||||||
// Debug: Save processed images if enabled
|
// Debug: Save processed images if enabled
|
||||||
if enableDebugImageSaving && debugImageCounter < 10 {
|
if enableDebugImageSaving && debugImageCounter < 10 {
|
||||||
saveDebugImage(data: tmpBuf, width: eyeWidth, height: eyeHeight, name: "processed_eye_\(debugImageCounter)")
|
saveDebugImage(
|
||||||
|
data: tmpBuf, width: eyeWidth, height: eyeHeight,
|
||||||
|
name: "processed_eye_\(debugImageCounter)")
|
||||||
debugImageCounter += 1
|
debugImageCounter += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 8: Find contours and compute centroid
|
// Step 8: Find contours and compute centroid
|
||||||
guard let (centroidX, centroidY) = findPupilFromContoursOptimized(
|
guard
|
||||||
data: tmpBuf,
|
let (centroidX, centroidY) = findPupilFromContoursOptimized(
|
||||||
width: eyeWidth,
|
data: tmpBuf,
|
||||||
height: eyeHeight
|
width: eyeWidth,
|
||||||
) else {
|
height: eyeHeight
|
||||||
|
)
|
||||||
|
else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let pupilPosition = PupilPosition(x: CGFloat(centroidX), y: CGFloat(centroidY))
|
let pupilPosition = PupilPosition(x: CGFloat(centroidX), y: CGFloat(centroidY))
|
||||||
|
|
||||||
// Cache result
|
// Cache result
|
||||||
if side == 0 {
|
if side == 0 {
|
||||||
lastPupilPositions.left = pupilPosition
|
lastPupilPositions.left = pupilPosition
|
||||||
} else {
|
} else {
|
||||||
lastPupilPositions.right = pupilPosition
|
lastPupilPositions.right = pupilPosition
|
||||||
}
|
}
|
||||||
|
|
||||||
return (pupilPosition, eyeRegion)
|
return (pupilPosition, eyeRegion)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Buffer Management
|
// MARK: - Buffer Management
|
||||||
|
|
||||||
private static func ensureBufferCapacity(frameSize: Int, eyeSize: Int) {
|
private static func ensureBufferCapacity(frameSize: Int, eyeSize: Int) {
|
||||||
if grayscaleBufferSize < frameSize {
|
if grayscaleBufferSize < frameSize {
|
||||||
grayscaleBuffer?.deallocate()
|
grayscaleBuffer?.deallocate()
|
||||||
grayscaleBuffer = UnsafeMutablePointer<UInt8>.allocate(capacity: frameSize)
|
grayscaleBuffer = UnsafeMutablePointer<UInt8>.allocate(capacity: frameSize)
|
||||||
grayscaleBufferSize = frameSize
|
grayscaleBufferSize = frameSize
|
||||||
}
|
}
|
||||||
|
|
||||||
let requiredEyeSize = max(eyeSize, 10000) // Minimum size for safety
|
let requiredEyeSize = max(eyeSize, 10000) // Minimum size for safety
|
||||||
if eyeBufferSize < requiredEyeSize {
|
if eyeBufferSize < requiredEyeSize {
|
||||||
eyeBuffer?.deallocate()
|
eyeBuffer?.deallocate()
|
||||||
@@ -369,9 +385,9 @@ final class PupilDetector: @unchecked Sendable {
|
|||||||
eyeBufferSize = requiredEyeSize
|
eyeBufferSize = requiredEyeSize
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Optimized Grayscale Conversion (vImage)
|
// MARK: - Optimized Grayscale Conversion (vImage)
|
||||||
|
|
||||||
private static func extractGrayscaleDataOptimized(
|
private static func extractGrayscaleDataOptimized(
|
||||||
from pixelBuffer: CVPixelBuffer,
|
from pixelBuffer: CVPixelBuffer,
|
||||||
to output: UnsafeMutablePointer<UInt8>,
|
to output: UnsafeMutablePointer<UInt8>,
|
||||||
@@ -380,38 +396,38 @@ final class PupilDetector: @unchecked Sendable {
|
|||||||
) -> Bool {
|
) -> Bool {
|
||||||
CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly)
|
CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly)
|
||||||
defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly) }
|
defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly) }
|
||||||
|
|
||||||
let pixelFormat = CVPixelBufferGetPixelFormatType(pixelBuffer)
|
let pixelFormat = CVPixelBufferGetPixelFormatType(pixelBuffer)
|
||||||
|
|
||||||
switch pixelFormat {
|
switch pixelFormat {
|
||||||
case kCVPixelFormatType_32BGRA:
|
case kCVPixelFormatType_32BGRA:
|
||||||
guard let baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer) else { return false }
|
guard let baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer) else { return false }
|
||||||
let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer)
|
let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer)
|
||||||
|
|
||||||
var srcBuffer = vImage_Buffer(
|
var srcBuffer = vImage_Buffer(
|
||||||
data: baseAddress,
|
data: baseAddress,
|
||||||
height: vImagePixelCount(height),
|
height: vImagePixelCount(height),
|
||||||
width: vImagePixelCount(width),
|
width: vImagePixelCount(width),
|
||||||
rowBytes: bytesPerRow
|
rowBytes: bytesPerRow
|
||||||
)
|
)
|
||||||
|
|
||||||
var dstBuffer = vImage_Buffer(
|
var dstBuffer = vImage_Buffer(
|
||||||
data: output,
|
data: output,
|
||||||
height: vImagePixelCount(height),
|
height: vImagePixelCount(height),
|
||||||
width: vImagePixelCount(width),
|
width: vImagePixelCount(width),
|
||||||
rowBytes: width
|
rowBytes: width
|
||||||
)
|
)
|
||||||
|
|
||||||
// BGRA to Planar8 grayscale using luminance coefficients
|
// BGRA to Planar8 grayscale using luminance coefficients
|
||||||
// Y = 0.299*R + 0.587*G + 0.114*B
|
// Y = 0.299*R + 0.587*G + 0.114*B
|
||||||
let matrix: [Int16] = [
|
let matrix: [Int16] = [
|
||||||
28, // B coefficient (0.114 * 256 ≈ 29, adjusted)
|
28, // B coefficient (0.114 * 256 ≈ 29, adjusted)
|
||||||
150, // G coefficient (0.587 * 256 ≈ 150)
|
150, // G coefficient (0.587 * 256 ≈ 150)
|
||||||
77, // R coefficient (0.299 * 256 ≈ 77)
|
77, // R coefficient (0.299 * 256 ≈ 77)
|
||||||
0 // A coefficient
|
0, // A coefficient
|
||||||
]
|
]
|
||||||
let divisor: Int32 = 256
|
let divisor: Int32 = 256
|
||||||
|
|
||||||
let error = vImageMatrixMultiply_ARGB8888ToPlanar8(
|
let error = vImageMatrixMultiply_ARGB8888ToPlanar8(
|
||||||
&srcBuffer,
|
&srcBuffer,
|
||||||
&dstBuffer,
|
&dstBuffer,
|
||||||
@@ -421,27 +437,30 @@ final class PupilDetector: @unchecked Sendable {
|
|||||||
0,
|
0,
|
||||||
vImage_Flags(kvImageNoFlags)
|
vImage_Flags(kvImageNoFlags)
|
||||||
)
|
)
|
||||||
|
|
||||||
return error == kvImageNoError
|
return error == kvImageNoError
|
||||||
|
|
||||||
case kCVPixelFormatType_420YpCbCr8BiPlanarFullRange,
|
case kCVPixelFormatType_420YpCbCr8BiPlanarFullRange,
|
||||||
kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange:
|
kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange:
|
||||||
guard let yPlane = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 0) else { return false }
|
guard let yPlane = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 0) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
let yBytesPerRow = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 0)
|
let yBytesPerRow = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 0)
|
||||||
let yBuffer = yPlane.assumingMemoryBound(to: UInt8.self)
|
let yBuffer = yPlane.assumingMemoryBound(to: UInt8.self)
|
||||||
|
|
||||||
// Direct copy of Y plane (already grayscale)
|
// Direct copy of Y plane (already grayscale)
|
||||||
for y in 0..<height {
|
for y in 0..<height {
|
||||||
memcpy(output.advanced(by: y * width), yBuffer.advanced(by: y * yBytesPerRow), width)
|
memcpy(
|
||||||
|
output.advanced(by: y * width), yBuffer.advanced(by: y * yBytesPerRow), width)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Fallback to manual conversion
|
// Fallback to manual conversion
|
||||||
guard let baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer) else { return false }
|
guard let baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer) else { return false }
|
||||||
let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer)
|
let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer)
|
||||||
let buffer = baseAddress.assumingMemoryBound(to: UInt8.self)
|
let buffer = baseAddress.assumingMemoryBound(to: UInt8.self)
|
||||||
|
|
||||||
for y in 0..<height {
|
for y in 0..<height {
|
||||||
for x in 0..<width {
|
for x in 0..<width {
|
||||||
let offset = y * bytesPerRow + x * 4
|
let offset = y * bytesPerRow + x * 4
|
||||||
@@ -454,9 +473,9 @@ final class PupilDetector: @unchecked Sendable {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Optimized Eye Isolation
|
// MARK: - Optimized Eye Isolation
|
||||||
|
|
||||||
private static func isolateEyeWithMaskOptimized(
|
private static func isolateEyeWithMaskOptimized(
|
||||||
frameData: UnsafePointer<UInt8>,
|
frameData: UnsafePointer<UInt8>,
|
||||||
frameWidth: Int,
|
frameWidth: Int,
|
||||||
@@ -469,57 +488,60 @@ final class PupilDetector: @unchecked Sendable {
|
|||||||
let minY = Int(region.frame.origin.y)
|
let minY = Int(region.frame.origin.y)
|
||||||
let eyeWidth = Int(region.frame.width)
|
let eyeWidth = Int(region.frame.width)
|
||||||
let eyeHeight = Int(region.frame.height)
|
let eyeHeight = Int(region.frame.height)
|
||||||
|
|
||||||
guard eyeWidth > 0, eyeHeight > 0 else { return false }
|
guard eyeWidth > 0, eyeHeight > 0 else { return false }
|
||||||
|
|
||||||
// Initialize to white (masked out)
|
// Initialize to white (masked out)
|
||||||
memset(output, 255, eyeWidth * eyeHeight)
|
memset(output, 255, eyeWidth * eyeHeight)
|
||||||
|
|
||||||
// Convert eye points to local coordinates
|
// Convert eye points to local coordinates
|
||||||
let localPoints = eyePoints.map { point in
|
let localPoints = eyePoints.map { point in
|
||||||
(x: Float(point.x) - Float(minX), y: Float(point.y) - Float(minY))
|
(x: Float(point.x) - Float(minX), y: Float(point.y) - Float(minY))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Precompute edge data for faster point-in-polygon
|
// Precompute edge data for faster point-in-polygon
|
||||||
let edges = (0..<localPoints.count).map { i in
|
let edges = (0..<localPoints.count).map { i in
|
||||||
let p1 = localPoints[i]
|
let p1 = localPoints[i]
|
||||||
let p2 = localPoints[(i + 1) % localPoints.count]
|
let p2 = localPoints[(i + 1) % localPoints.count]
|
||||||
return (x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y)
|
return (x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y)
|
||||||
}
|
}
|
||||||
|
|
||||||
for y in 0..<eyeHeight {
|
for y in 0..<eyeHeight {
|
||||||
let py = Float(y)
|
let py = Float(y)
|
||||||
for x in 0..<eyeWidth {
|
for x in 0..<eyeWidth {
|
||||||
let px = Float(x)
|
let px = Float(x)
|
||||||
|
|
||||||
if pointInPolygonFast(px: px, py: py, edges: edges) {
|
if pointInPolygonFast(px: px, py: py, edges: edges) {
|
||||||
let frameX = minX + x
|
let frameX = minX + x
|
||||||
let frameY = minY + y
|
let frameY = minY + y
|
||||||
|
|
||||||
if frameX >= 0, frameX < frameWidth, frameY >= 0, frameY < frameHeight {
|
if frameX >= 0, frameX < frameWidth, frameY >= 0, frameY < frameHeight {
|
||||||
output[y * eyeWidth + x] = frameData[frameY * frameWidth + frameX]
|
output[y * eyeWidth + x] = frameData[frameY * frameWidth + frameX]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@inline(__always)
|
@inline(__always)
|
||||||
private static func pointInPolygonFast(px: Float, py: Float, edges: [(x1: Float, y1: Float, x2: Float, y2: Float)]) -> Bool {
|
private static func pointInPolygonFast(
|
||||||
|
px: Float, py: Float, edges: [(x1: Float, y1: Float, x2: Float, y2: Float)]
|
||||||
|
) -> Bool {
|
||||||
var inside = false
|
var inside = false
|
||||||
for edge in edges {
|
for edge in edges {
|
||||||
if ((edge.y1 > py) != (edge.y2 > py)) &&
|
if ((edge.y1 > py) != (edge.y2 > py))
|
||||||
(px < (edge.x2 - edge.x1) * (py - edge.y1) / (edge.y2 - edge.y1) + edge.x1) {
|
&& (px < (edge.x2 - edge.x1) * (py - edge.y1) / (edge.y2 - edge.y1) + edge.x1)
|
||||||
|
{
|
||||||
inside = !inside
|
inside = !inside
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return inside
|
return inside
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Optimized Image Processing
|
// MARK: - Optimized Image Processing
|
||||||
|
|
||||||
static func imageProcessingOptimized(
|
static func imageProcessingOptimized(
|
||||||
input: UnsafePointer<UInt8>,
|
input: UnsafePointer<UInt8>,
|
||||||
output: UnsafeMutablePointer<UInt8>,
|
output: UnsafeMutablePointer<UInt8>,
|
||||||
@@ -529,23 +551,24 @@ final class PupilDetector: @unchecked Sendable {
|
|||||||
) {
|
) {
|
||||||
let size = width * height
|
let size = width * height
|
||||||
guard size > 0 else { return }
|
guard size > 0 else { return }
|
||||||
|
|
||||||
// Use a working buffer for intermediate results
|
// Use a working buffer for intermediate results
|
||||||
let workBuffer = UnsafeMutablePointer<UInt8>.allocate(capacity: size)
|
let workBuffer = UnsafeMutablePointer<UInt8>.allocate(capacity: size)
|
||||||
defer { workBuffer.deallocate() }
|
defer { workBuffer.deallocate() }
|
||||||
|
|
||||||
// 1. Fast Gaussian blur using vImage (replaces expensive bilateral filter)
|
// 1. Fast Gaussian blur using vImage (replaces expensive bilateral filter)
|
||||||
gaussianBlurOptimized(input: input, output: workBuffer, width: width, height: height)
|
gaussianBlurOptimized(input: input, output: workBuffer, width: width, height: height)
|
||||||
|
|
||||||
// 2. Erosion with vImage (3 iterations)
|
// 2. Erosion with vImage (3 iterations)
|
||||||
erodeOptimized(input: workBuffer, output: output, width: width, height: height, iterations: 3)
|
erodeOptimized(
|
||||||
|
input: workBuffer, output: output, width: width, height: height, iterations: 3)
|
||||||
|
|
||||||
// 3. Simple binary threshold (no vDSP overhead for small buffers)
|
// 3. Simple binary threshold (no vDSP overhead for small buffers)
|
||||||
for i in 0..<size {
|
for i in 0..<size {
|
||||||
output[i] = output[i] > UInt8(threshold) ? 255 : 0
|
output[i] = output[i] > UInt8(threshold) ? 255 : 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func gaussianBlurOptimized(
|
private static func gaussianBlurOptimized(
|
||||||
input: UnsafePointer<UInt8>,
|
input: UnsafePointer<UInt8>,
|
||||||
output: UnsafeMutablePointer<UInt8>,
|
output: UnsafeMutablePointer<UInt8>,
|
||||||
@@ -554,24 +577,24 @@ final class PupilDetector: @unchecked Sendable {
|
|||||||
) {
|
) {
|
||||||
// Use a more appropriate convolution for performance
|
// Use a more appropriate convolution for performance
|
||||||
// Using vImageTentConvolve_Planar8 with optimized parameters
|
// Using vImageTentConvolve_Planar8 with optimized parameters
|
||||||
|
|
||||||
var srcBuffer = vImage_Buffer(
|
var srcBuffer = vImage_Buffer(
|
||||||
data: UnsafeMutableRawPointer(mutating: input),
|
data: UnsafeMutableRawPointer(mutating: input),
|
||||||
height: vImagePixelCount(height),
|
height: vImagePixelCount(height),
|
||||||
width: vImagePixelCount(width),
|
width: vImagePixelCount(width),
|
||||||
rowBytes: width
|
rowBytes: width
|
||||||
)
|
)
|
||||||
|
|
||||||
var dstBuffer = vImage_Buffer(
|
var dstBuffer = vImage_Buffer(
|
||||||
data: UnsafeMutableRawPointer(output),
|
data: UnsafeMutableRawPointer(output),
|
||||||
height: vImagePixelCount(height),
|
height: vImagePixelCount(height),
|
||||||
width: vImagePixelCount(width),
|
width: vImagePixelCount(width),
|
||||||
rowBytes: width
|
rowBytes: width
|
||||||
)
|
)
|
||||||
|
|
||||||
// Kernel size that provides good blur with minimal computational overhead
|
// Kernel size that provides good blur with minimal computational overhead
|
||||||
let kernelSize: UInt32 = 5
|
let kernelSize: UInt32 = 5
|
||||||
|
|
||||||
vImageTentConvolve_Planar8(
|
vImageTentConvolve_Planar8(
|
||||||
&srcBuffer,
|
&srcBuffer,
|
||||||
&dstBuffer,
|
&dstBuffer,
|
||||||
@@ -583,7 +606,7 @@ final class PupilDetector: @unchecked Sendable {
|
|||||||
vImage_Flags(kvImageEdgeExtend)
|
vImage_Flags(kvImageEdgeExtend)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func erodeOptimized(
|
private static func erodeOptimized(
|
||||||
input: UnsafePointer<UInt8>,
|
input: UnsafePointer<UInt8>,
|
||||||
output: UnsafeMutablePointer<UInt8>,
|
output: UnsafeMutablePointer<UInt8>,
|
||||||
@@ -595,52 +618,54 @@ final class PupilDetector: @unchecked Sendable {
|
|||||||
memcpy(output, input, width * height)
|
memcpy(output, input, width * height)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy input to output first so we can use output as working buffer
|
// Copy input to output first so we can use output as working buffer
|
||||||
memcpy(output, input, width * height)
|
memcpy(output, input, width * height)
|
||||||
|
|
||||||
var srcBuffer = vImage_Buffer(
|
var srcBuffer = vImage_Buffer(
|
||||||
data: UnsafeMutableRawPointer(output),
|
data: UnsafeMutableRawPointer(output),
|
||||||
height: vImagePixelCount(height),
|
height: vImagePixelCount(height),
|
||||||
width: vImagePixelCount(width),
|
width: vImagePixelCount(width),
|
||||||
rowBytes: width
|
rowBytes: width
|
||||||
)
|
)
|
||||||
|
|
||||||
// Allocate temp buffer for ping-pong
|
// Allocate temp buffer for ping-pong
|
||||||
let tempData = UnsafeMutablePointer<UInt8>.allocate(capacity: width * height)
|
let tempData = UnsafeMutablePointer<UInt8>.allocate(capacity: width * height)
|
||||||
defer { tempData.deallocate() }
|
defer { tempData.deallocate() }
|
||||||
|
|
||||||
var dstBuffer = vImage_Buffer(
|
var dstBuffer = vImage_Buffer(
|
||||||
data: UnsafeMutableRawPointer(tempData),
|
data: UnsafeMutableRawPointer(tempData),
|
||||||
height: vImagePixelCount(height),
|
height: vImagePixelCount(height),
|
||||||
width: vImagePixelCount(width),
|
width: vImagePixelCount(width),
|
||||||
rowBytes: width
|
rowBytes: width
|
||||||
)
|
)
|
||||||
|
|
||||||
// 3x3 erosion kernel (all ones)
|
// 3x3 erosion kernel (all ones)
|
||||||
let kernel: [UInt8] = [
|
let kernel: [UInt8] = [
|
||||||
1, 1, 1,
|
1, 1, 1,
|
||||||
1, 1, 1,
|
1, 1, 1,
|
||||||
1, 1, 1
|
1, 1, 1,
|
||||||
]
|
]
|
||||||
|
|
||||||
for i in 0..<iterations {
|
for i in 0..<iterations {
|
||||||
if i % 2 == 0 {
|
if i % 2 == 0 {
|
||||||
vImageErode_Planar8(&srcBuffer, &dstBuffer, 0, 0, kernel, 3, 3, vImage_Flags(kvImageNoFlags))
|
vImageErode_Planar8(
|
||||||
|
&srcBuffer, &dstBuffer, 0, 0, kernel, 3, 3, vImage_Flags(kvImageNoFlags))
|
||||||
} else {
|
} else {
|
||||||
vImageErode_Planar8(&dstBuffer, &srcBuffer, 0, 0, kernel, 3, 3, vImage_Flags(kvImageNoFlags))
|
vImageErode_Planar8(
|
||||||
|
&dstBuffer, &srcBuffer, 0, 0, kernel, 3, 3, vImage_Flags(kvImageNoFlags))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If odd iterations, result is in dstBuffer (tempData), copy to output
|
// If odd iterations, result is in dstBuffer (tempData), copy to output
|
||||||
if iterations % 2 == 1 {
|
if iterations % 2 == 1 {
|
||||||
memcpy(output, tempData, width * height)
|
memcpy(output, tempData, width * height)
|
||||||
}
|
}
|
||||||
// If even iterations, result is already in srcBuffer (output)
|
// If even iterations, result is already in srcBuffer (output)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Optimized Contour Detection
|
// MARK: - Optimized Contour Detection
|
||||||
|
|
||||||
/// Optimized centroid-of-dark-pixels approach - much faster than union-find
|
/// Optimized centroid-of-dark-pixels approach - much faster than union-find
|
||||||
/// Returns the centroid of the largest dark region
|
/// Returns the centroid of the largest dark region
|
||||||
private static func findPupilFromContoursOptimized(
|
private static func findPupilFromContoursOptimized(
|
||||||
@@ -648,25 +673,25 @@ final class PupilDetector: @unchecked Sendable {
|
|||||||
width: Int,
|
width: Int,
|
||||||
height: Int
|
height: Int
|
||||||
) -> (x: Double, y: Double)? {
|
) -> (x: Double, y: Double)? {
|
||||||
|
|
||||||
// Optimized approach: find centroid of all black pixels with early exit
|
// Optimized approach: find centroid of all black pixels with early exit
|
||||||
// This works well for pupil detection since the pupil is the main dark blob
|
// This works well for pupil detection since the pupil is the main dark blob
|
||||||
|
|
||||||
// Use a more efficient approach that doesn't iterate through entire image
|
// Use a more efficient approach that doesn't iterate through entire image
|
||||||
var sumX: Int = 0
|
var sumX: Int = 0
|
||||||
var sumY: Int = 0
|
var sumY: Int = 0
|
||||||
var count: Int = 0
|
var count: Int = 0
|
||||||
|
|
||||||
// Early exit if we already know this isn't going to be useful
|
// Early exit if we already know this isn't going to be useful
|
||||||
let threshold = UInt8(5) // Only consider pixels that are quite dark
|
let threshold = UInt8(5) // Only consider pixels that are quite dark
|
||||||
|
|
||||||
// Process in chunks for better cache performance
|
// Process in chunks for better cache performance
|
||||||
let chunkSize = 16
|
let chunkSize = 16
|
||||||
var rowsProcessed = 0
|
var rowsProcessed = 0
|
||||||
|
|
||||||
while rowsProcessed < height {
|
while rowsProcessed < height {
|
||||||
let endRow = min(rowsProcessed + chunkSize, height)
|
let endRow = min(rowsProcessed + chunkSize, height)
|
||||||
|
|
||||||
for y in rowsProcessed..<endRow {
|
for y in rowsProcessed..<endRow {
|
||||||
let rowOffset = y * width
|
let rowOffset = y * width
|
||||||
for x in 0..<width {
|
for x in 0..<width {
|
||||||
@@ -678,117 +703,128 @@ final class PupilDetector: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rowsProcessed = endRow
|
rowsProcessed = endRow
|
||||||
|
|
||||||
// Early exit if we've found enough pixels for a reasonable estimate
|
// Early exit if we've found enough pixels for a reasonable estimate
|
||||||
if count > 25 { // Early termination condition
|
if count > 25 { // Early termination condition
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
guard count > 10 else { return nil } // Need minimum pixels for valid pupil
|
guard count > 10 else { return nil } // Need minimum pixels for valid pupil
|
||||||
|
|
||||||
return (
|
return (
|
||||||
x: Double(sumX) / Double(count),
|
x: Double(sumX) / Double(count),
|
||||||
y: Double(sumY) / Double(count)
|
y: Double(sumY) / Double(count)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helper Methods
|
// MARK: - Helper Methods
|
||||||
|
|
||||||
private static func landmarksToPixelCoordinates(
|
private static func landmarksToPixelCoordinates(
|
||||||
landmarks: VNFaceLandmarkRegion2D,
|
landmarks: VNFaceLandmarkRegion2D,
|
||||||
faceBoundingBox: CGRect,
|
faceBoundingBox: CGRect,
|
||||||
imageSize: CGSize
|
imageSize: CGSize
|
||||||
) -> [CGPoint] {
|
) -> [CGPoint] {
|
||||||
return landmarks.normalizedPoints.map { point in
|
return landmarks.normalizedPoints.map { point in
|
||||||
let imageX = (faceBoundingBox.origin.x + point.x * faceBoundingBox.width) * imageSize.width
|
let imageX =
|
||||||
let imageY = (faceBoundingBox.origin.y + point.y * faceBoundingBox.height) * imageSize.height
|
(faceBoundingBox.origin.x + point.x * faceBoundingBox.width) * imageSize.width
|
||||||
|
let imageY =
|
||||||
|
(faceBoundingBox.origin.y + point.y * faceBoundingBox.height) * imageSize.height
|
||||||
return CGPoint(x: imageX, y: imageY)
|
return CGPoint(x: imageX, y: imageY)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func createEyeRegion(from points: [CGPoint], imageSize: CGSize) -> EyeRegion? {
|
private static func createEyeRegion(from points: [CGPoint], imageSize: CGSize) -> EyeRegion? {
|
||||||
guard !points.isEmpty else { return nil }
|
guard !points.isEmpty else { return nil }
|
||||||
|
|
||||||
let margin: CGFloat = 5
|
let margin: CGFloat = 5
|
||||||
var minX = CGFloat.greatestFiniteMagnitude
|
var minX = CGFloat.greatestFiniteMagnitude
|
||||||
var maxX = -CGFloat.greatestFiniteMagnitude
|
var maxX = -CGFloat.greatestFiniteMagnitude
|
||||||
var minY = CGFloat.greatestFiniteMagnitude
|
var minY = CGFloat.greatestFiniteMagnitude
|
||||||
var maxY = -CGFloat.greatestFiniteMagnitude
|
var maxY = -CGFloat.greatestFiniteMagnitude
|
||||||
|
|
||||||
for point in points {
|
for point in points {
|
||||||
minX = min(minX, point.x)
|
minX = min(minX, point.x)
|
||||||
maxX = max(maxX, point.x)
|
maxX = max(maxX, point.x)
|
||||||
minY = min(minY, point.y)
|
minY = min(minY, point.y)
|
||||||
maxY = max(maxY, point.y)
|
maxY = max(maxY, point.y)
|
||||||
}
|
}
|
||||||
|
|
||||||
minX -= margin
|
minX -= margin
|
||||||
maxX += margin
|
maxX += margin
|
||||||
minY -= margin
|
minY -= margin
|
||||||
maxY += margin
|
maxY += margin
|
||||||
|
|
||||||
let clampedMinX = max(0, minX)
|
let clampedMinX = max(0, minX)
|
||||||
let clampedMaxX = min(imageSize.width, maxX)
|
let clampedMaxX = min(imageSize.width, maxX)
|
||||||
let clampedMinY = max(0, minY)
|
let clampedMinY = max(0, minY)
|
||||||
let clampedMaxY = min(imageSize.height, maxY)
|
let clampedMaxY = min(imageSize.height, maxY)
|
||||||
|
|
||||||
let frame = CGRect(
|
let frame = CGRect(
|
||||||
x: clampedMinX,
|
x: clampedMinX,
|
||||||
y: clampedMinY,
|
y: clampedMinY,
|
||||||
width: clampedMaxX - clampedMinX,
|
width: clampedMaxX - clampedMinX,
|
||||||
height: clampedMaxY - clampedMinY
|
height: clampedMaxY - clampedMinY
|
||||||
)
|
)
|
||||||
|
|
||||||
let center = CGPoint(x: frame.width / 2, y: frame.height / 2)
|
let center = CGPoint(x: frame.width / 2, y: frame.height / 2)
|
||||||
let origin = CGPoint(x: clampedMinX, y: clampedMinY)
|
let origin = CGPoint(x: clampedMinX, y: clampedMinY)
|
||||||
|
|
||||||
return EyeRegion(frame: frame, center: center, origin: origin)
|
return EyeRegion(frame: frame, center: center, origin: origin)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Debug Helpers
|
// MARK: - Debug Helpers
|
||||||
|
|
||||||
private static func saveDebugImage(data: UnsafePointer<UInt8>, width: Int, height: Int, name: String) {
|
private static func saveDebugImage(
|
||||||
|
data: UnsafePointer<UInt8>, width: Int, height: Int, name: String
|
||||||
|
) {
|
||||||
guard let cgImage = createCGImage(from: data, width: width, height: height) else { return }
|
guard let cgImage = createCGImage(from: data, width: width, height: height) else { return }
|
||||||
|
|
||||||
let url = URL(fileURLWithPath: "/tmp/\(name).png")
|
let url = URL(fileURLWithPath: "/tmp/\(name).png")
|
||||||
guard let destination = CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil) else { return }
|
guard
|
||||||
|
let destination = CGImageDestinationCreateWithURL(
|
||||||
|
url as CFURL, UTType.png.identifier as CFString, 1, nil)
|
||||||
|
else { return }
|
||||||
|
|
||||||
CGImageDestinationAddImage(destination, cgImage, nil)
|
CGImageDestinationAddImage(destination, cgImage, nil)
|
||||||
CGImageDestinationFinalize(destination)
|
CGImageDestinationFinalize(destination)
|
||||||
print("💾 Saved debug image: \(url.path)")
|
print("💾 Saved debug image: \(url.path)")
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func createCGImage(from data: UnsafePointer<UInt8>, width: Int, height: Int) -> CGImage? {
|
private static func createCGImage(from data: UnsafePointer<UInt8>, width: Int, height: Int)
|
||||||
|
-> CGImage?
|
||||||
|
{
|
||||||
let mutableData = UnsafeMutablePointer<UInt8>.allocate(capacity: width * height)
|
let mutableData = UnsafeMutablePointer<UInt8>.allocate(capacity: width * height)
|
||||||
defer { mutableData.deallocate() }
|
defer { mutableData.deallocate() }
|
||||||
memcpy(mutableData, data, width * height)
|
memcpy(mutableData, data, width * height)
|
||||||
|
|
||||||
guard let context = CGContext(
|
guard
|
||||||
data: mutableData,
|
let context = CGContext(
|
||||||
width: width,
|
data: mutableData,
|
||||||
height: height,
|
width: width,
|
||||||
bitsPerComponent: 8,
|
height: height,
|
||||||
bytesPerRow: width,
|
bitsPerComponent: 8,
|
||||||
space: CGColorSpaceCreateDeviceGray(),
|
bytesPerRow: width,
|
||||||
bitmapInfo: CGImageAlphaInfo.none.rawValue
|
space: CGColorSpaceCreateDeviceGray(),
|
||||||
) else {
|
bitmapInfo: CGImageAlphaInfo.none.rawValue
|
||||||
|
)
|
||||||
|
else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return context.makeImage()
|
return context.makeImage()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clean up allocated buffers (call on app termination if needed)
|
/// Clean up allocated buffers (call on app termination if needed)
|
||||||
static func cleanup() {
|
static func cleanup() {
|
||||||
grayscaleBuffer?.deallocate()
|
grayscaleBuffer?.deallocate()
|
||||||
grayscaleBuffer = nil
|
grayscaleBuffer = nil
|
||||||
grayscaleBufferSize = 0
|
grayscaleBufferSize = 0
|
||||||
|
|
||||||
eyeBuffer?.deallocate()
|
eyeBuffer?.deallocate()
|
||||||
eyeBuffer = nil
|
eyeBuffer = nil
|
||||||
|
|
||||||
tempBuffer?.deallocate()
|
tempBuffer?.deallocate()
|
||||||
tempBuffer = nil
|
tempBuffer = nil
|
||||||
eyeBufferSize = 0
|
eyeBufferSize = 0
|
||||||
|
|||||||
@@ -7,224 +7,231 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
@testable import Gaze
|
@testable import Gaze
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class OnboardingNavigationTests: XCTestCase {
|
final class OnboardingNavigationTests: XCTestCase {
|
||||||
|
|
||||||
var testEnv: TestEnvironment!
|
var testEnv: TestEnvironment!
|
||||||
|
|
||||||
override func setUp() async throws {
|
override func setUp() async throws {
|
||||||
var settings = AppSettings.defaults
|
var settings = AppSettings.defaults
|
||||||
settings.hasCompletedOnboarding = false
|
settings.hasCompletedOnboarding = false
|
||||||
testEnv = TestEnvironment(settings: settings)
|
testEnv = TestEnvironment(settings: settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tearDown() async throws {
|
override func tearDown() async throws {
|
||||||
testEnv = nil
|
testEnv = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Navigation Tests
|
// MARK: - Navigation Tests
|
||||||
|
|
||||||
func testOnboardingStartsAtWelcomePage() {
|
func testOnboardingStartsAtWelcomePage() {
|
||||||
// Use real SettingsManager for view initialization test since @Bindable requires concrete type
|
// Use real SettingsManager for view initialization test since @Bindable requires concrete type
|
||||||
let onboarding = OnboardingContainerView(settingsManager: SettingsManager.shared)
|
let onboarding = OnboardingContainerView(settingsManager: SettingsManager.shared)
|
||||||
|
|
||||||
// Verify initial state
|
// Verify initial state
|
||||||
XCTAssertFalse(testEnv.settingsManager.settings.hasCompletedOnboarding)
|
XCTAssertFalse(testEnv.settingsManager.settings.hasCompletedOnboarding)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testNavigationForwardThroughAllPages() async throws {
|
func testNavigationForwardThroughAllPages() async throws {
|
||||||
var settings = testEnv.settingsManager.settings
|
var settings = testEnv.settingsManager.settings
|
||||||
|
|
||||||
// Simulate moving through pages
|
// Simulate moving through pages
|
||||||
let pages = [
|
let pages = [
|
||||||
"Welcome", // 0
|
"Welcome", // 0
|
||||||
"LookAway", // 1
|
"LookAway", // 1
|
||||||
"Blink", // 2
|
"Blink", // 2
|
||||||
"Posture", // 3
|
"Posture", // 3
|
||||||
"General", // 4
|
"General", // 4
|
||||||
"Completion" // 5
|
"Completion", // 5
|
||||||
]
|
]
|
||||||
|
|
||||||
for (index, pageName) in pages.enumerated() {
|
for (index, pageName) in pages.enumerated() {
|
||||||
// Verify we can track page progression
|
// Verify we can track page progression
|
||||||
XCTAssertEqual(index, index, "Should be on page \(index): \(pageName)")
|
XCTAssertEqual(index, index, "Should be on page \(index): \(pageName)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testNavigationBackward() {
|
func testNavigationBackward() {
|
||||||
// Start from page 3 (Posture)
|
// Start from page 3 (Posture)
|
||||||
var currentPage = 3
|
var currentPage = 3
|
||||||
|
|
||||||
// Navigate backward
|
// Navigate backward
|
||||||
currentPage -= 1
|
currentPage -= 1
|
||||||
XCTAssertEqual(currentPage, 2, "Should navigate back to Blink page")
|
XCTAssertEqual(currentPage, 2, "Should navigate back to Blink page")
|
||||||
|
|
||||||
currentPage -= 1
|
currentPage -= 1
|
||||||
XCTAssertEqual(currentPage, 1, "Should navigate back to LookAway page")
|
XCTAssertEqual(currentPage, 1, "Should navigate back to LookAway page")
|
||||||
|
|
||||||
currentPage -= 1
|
currentPage -= 1
|
||||||
XCTAssertEqual(currentPage, 0, "Should navigate back to Welcome page")
|
XCTAssertEqual(currentPage, 0, "Should navigate back to Welcome page")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCannotNavigateBackFromWelcome() {
|
func testCannotNavigateBackFromWelcome() {
|
||||||
let currentPage = 0
|
let currentPage = 0
|
||||||
|
|
||||||
// Should not be able to go below 0
|
// Should not be able to go below 0
|
||||||
XCTAssertEqual(currentPage, 0, "Should stay on Welcome page")
|
XCTAssertEqual(currentPage, 0, "Should stay on Welcome page")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSettingsPersistDuringNavigation() {
|
func testSettingsPersistDuringNavigation() {
|
||||||
// Configure lookaway timer
|
// Configure lookaway timer
|
||||||
var config = testEnv.settingsManager.settings.lookAwayTimer
|
var config = testEnv.settingsManager.settings.lookAwayTimer
|
||||||
config.enabled = true
|
config.enabled = true
|
||||||
config.intervalSeconds = 1200
|
config.intervalSeconds = 1200
|
||||||
testEnv.settingsManager.updateTimerConfiguration(for: .lookAway, configuration: config)
|
testEnv.settingsManager.updateTimerConfiguration(for: .lookAway, configuration: config)
|
||||||
|
|
||||||
// Verify settings persisted
|
// Verify settings persisted
|
||||||
let retrieved = testEnv.settingsManager.timerConfiguration(for: .lookAway)
|
let retrieved = testEnv.settingsManager.timerConfiguration(for: .lookAway)
|
||||||
XCTAssertTrue(retrieved.enabled)
|
XCTAssertTrue(retrieved.enabled)
|
||||||
XCTAssertEqual(retrieved.intervalSeconds, 1200)
|
XCTAssertEqual(retrieved.intervalSeconds, 1200)
|
||||||
|
|
||||||
// Configure blink timer
|
// Configure blink timer
|
||||||
var blinkConfig = testEnv.settingsManager.settings.blinkTimer
|
var blinkConfig = testEnv.settingsManager.settings.blinkTimer
|
||||||
blinkConfig.enabled = false
|
blinkConfig.enabled = false
|
||||||
blinkConfig.intervalSeconds = 300
|
blinkConfig.intervalSeconds = 300
|
||||||
testEnv.settingsManager.updateTimerConfiguration(for: .blink, configuration: blinkConfig)
|
testEnv.settingsManager.updateTimerConfiguration(for: .blink, configuration: blinkConfig)
|
||||||
|
|
||||||
// Verify both settings persist
|
// Verify both settings persist
|
||||||
let lookAway = testEnv.settingsManager.timerConfiguration(for: .lookAway)
|
let lookAway = testEnv.settingsManager.timerConfiguration(for: .lookAway)
|
||||||
let blink = testEnv.settingsManager.timerConfiguration(for: .blink)
|
let blink = testEnv.settingsManager.timerConfiguration(for: .blink)
|
||||||
|
|
||||||
XCTAssertTrue(lookAway.enabled)
|
XCTAssertTrue(lookAway.enabled)
|
||||||
XCTAssertEqual(lookAway.intervalSeconds, 1200)
|
XCTAssertEqual(lookAway.intervalSeconds, 1200)
|
||||||
XCTAssertFalse(blink.enabled)
|
XCTAssertFalse(blink.enabled)
|
||||||
XCTAssertEqual(blink.intervalSeconds, 300)
|
XCTAssertEqual(blink.intervalSeconds, 300)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testOnboardingCompletion() {
|
func testOnboardingCompletion() {
|
||||||
// Start with onboarding incomplete
|
// Start with onboarding incomplete
|
||||||
XCTAssertFalse(testEnv.settingsManager.settings.hasCompletedOnboarding)
|
XCTAssertFalse(testEnv.settingsManager.settings.hasCompletedOnboarding)
|
||||||
|
|
||||||
// Complete onboarding
|
// Complete onboarding
|
||||||
testEnv.settingsManager.settings.hasCompletedOnboarding = true
|
testEnv.settingsManager.settings.hasCompletedOnboarding = true
|
||||||
|
|
||||||
// Verify completion
|
// Verify completion
|
||||||
XCTAssertTrue(testEnv.settingsManager.settings.hasCompletedOnboarding)
|
XCTAssertTrue(testEnv.settingsManager.settings.hasCompletedOnboarding)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testAllTimersConfiguredDuringOnboarding() {
|
func testAllTimersConfiguredDuringOnboarding() {
|
||||||
// Configure all three built-in timers
|
// Configure all three built-in timers
|
||||||
var lookAwayConfig = testEnv.settingsManager.settings.lookAwayTimer
|
var lookAwayConfig = testEnv.settingsManager.settings.lookAwayTimer
|
||||||
lookAwayConfig.enabled = true
|
lookAwayConfig.enabled = true
|
||||||
lookAwayConfig.intervalSeconds = 1200
|
lookAwayConfig.intervalSeconds = 1200
|
||||||
testEnv.settingsManager.updateTimerConfiguration(for: .lookAway, configuration: lookAwayConfig)
|
testEnv.settingsManager.updateTimerConfiguration(
|
||||||
|
for: .lookAway, configuration: lookAwayConfig)
|
||||||
|
|
||||||
var blinkConfig = testEnv.settingsManager.settings.blinkTimer
|
var blinkConfig = testEnv.settingsManager.settings.blinkTimer
|
||||||
blinkConfig.enabled = true
|
blinkConfig.enabled = true
|
||||||
blinkConfig.intervalSeconds = 300
|
blinkConfig.intervalSeconds = 300
|
||||||
testEnv.settingsManager.updateTimerConfiguration(for: .blink, configuration: blinkConfig)
|
testEnv.settingsManager.updateTimerConfiguration(for: .blink, configuration: blinkConfig)
|
||||||
|
|
||||||
var postureConfig = testEnv.settingsManager.settings.postureTimer
|
var postureConfig = testEnv.settingsManager.settings.postureTimer
|
||||||
postureConfig.enabled = true
|
postureConfig.enabled = true
|
||||||
postureConfig.intervalSeconds = 1800
|
postureConfig.intervalSeconds = 1800
|
||||||
testEnv.settingsManager.updateTimerConfiguration(for: .posture, configuration: postureConfig)
|
testEnv.settingsManager.updateTimerConfiguration(
|
||||||
|
for: .posture, configuration: postureConfig)
|
||||||
|
|
||||||
// Verify all configurations
|
// Verify all configurations
|
||||||
let allConfigs = testEnv.settingsManager.allTimerConfigurations()
|
let allConfigs = testEnv.settingsManager.allTimerConfigurations()
|
||||||
|
|
||||||
XCTAssertEqual(allConfigs[.lookAway]?.intervalSeconds, 1200)
|
XCTAssertEqual(allConfigs[.lookAway]?.intervalSeconds, 1200)
|
||||||
XCTAssertEqual(allConfigs[.blink]?.intervalSeconds, 300)
|
XCTAssertEqual(allConfigs[.blink]?.intervalSeconds, 300)
|
||||||
XCTAssertEqual(allConfigs[.posture]?.intervalSeconds, 1800)
|
XCTAssertEqual(allConfigs[.posture]?.intervalSeconds, 1800)
|
||||||
|
|
||||||
XCTAssertTrue(allConfigs[.lookAway]?.enabled ?? false)
|
XCTAssertTrue(allConfigs[.lookAway]?.enabled ?? false)
|
||||||
XCTAssertTrue(allConfigs[.blink]?.enabled ?? false)
|
XCTAssertTrue(allConfigs[.blink]?.enabled ?? false)
|
||||||
XCTAssertTrue(allConfigs[.posture]?.enabled ?? false)
|
XCTAssertTrue(allConfigs[.posture]?.enabled ?? false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testNavigationWithPartialConfiguration() {
|
func testNavigationWithPartialConfiguration() {
|
||||||
// Configure only some timers
|
// Configure only some timers
|
||||||
var lookAwayConfig = testEnv.settingsManager.settings.lookAwayTimer
|
var lookAwayConfig = testEnv.settingsManager.settings.lookAwayTimer
|
||||||
lookAwayConfig.enabled = true
|
lookAwayConfig.enabled = true
|
||||||
testEnv.settingsManager.updateTimerConfiguration(for: .lookAway, configuration: lookAwayConfig)
|
testEnv.settingsManager.updateTimerConfiguration(
|
||||||
|
for: .lookAway, configuration: lookAwayConfig)
|
||||||
|
|
||||||
var blinkConfig = testEnv.settingsManager.settings.blinkTimer
|
var blinkConfig = testEnv.settingsManager.settings.blinkTimer
|
||||||
blinkConfig.enabled = false
|
blinkConfig.enabled = false
|
||||||
testEnv.settingsManager.updateTimerConfiguration(for: .blink, configuration: blinkConfig)
|
testEnv.settingsManager.updateTimerConfiguration(for: .blink, configuration: blinkConfig)
|
||||||
|
|
||||||
// Should still be able to complete onboarding
|
// Should still be able to complete onboarding
|
||||||
testEnv.settingsManager.settings.hasCompletedOnboarding = true
|
testEnv.settingsManager.settings.hasCompletedOnboarding = true
|
||||||
XCTAssertTrue(testEnv.settingsManager.settings.hasCompletedOnboarding)
|
XCTAssertTrue(testEnv.settingsManager.settings.hasCompletedOnboarding)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testGeneralSettingsConfigurationDuringOnboarding() {
|
func testGeneralSettingsConfigurationDuringOnboarding() {
|
||||||
// Configure general settings
|
// Configure general settings
|
||||||
testEnv.settingsManager.settings.playSounds = true
|
testEnv.settingsManager.settings.playSounds = true
|
||||||
testEnv.settingsManager.settings.launchAtLogin = true
|
testEnv.settingsManager.settings.launchAtLogin = true
|
||||||
|
|
||||||
XCTAssertTrue(testEnv.settingsManager.settings.playSounds)
|
XCTAssertTrue(testEnv.settingsManager.settings.playSounds)
|
||||||
XCTAssertTrue(testEnv.settingsManager.settings.launchAtLogin)
|
XCTAssertTrue(testEnv.settingsManager.settings.launchAtLogin)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testOnboardingFlowFromStartToFinish() {
|
func testOnboardingFlowFromStartToFinish() {
|
||||||
// Complete simulation of onboarding flow
|
// Complete simulation of onboarding flow
|
||||||
XCTAssertFalse(testEnv.settingsManager.settings.hasCompletedOnboarding)
|
XCTAssertFalse(testEnv.settingsManager.settings.hasCompletedOnboarding)
|
||||||
|
|
||||||
// Page 0: Welcome - no configuration needed
|
// Page 0: Welcome - no configuration needed
|
||||||
|
|
||||||
// Page 1: LookAway Setup
|
// Page 1: LookAway Setup
|
||||||
var lookAwayConfig = testEnv.settingsManager.settings.lookAwayTimer
|
var lookAwayConfig = testEnv.settingsManager.settings.lookAwayTimer
|
||||||
lookAwayConfig.enabled = true
|
lookAwayConfig.enabled = true
|
||||||
lookAwayConfig.intervalSeconds = 1200
|
lookAwayConfig.intervalSeconds = 1200
|
||||||
testEnv.settingsManager.updateTimerConfiguration(for: .lookAway, configuration: lookAwayConfig)
|
testEnv.settingsManager.updateTimerConfiguration(
|
||||||
|
for: .lookAway, configuration: lookAwayConfig)
|
||||||
|
|
||||||
// Page 2: Blink Setup
|
// Page 2: Blink Setup
|
||||||
var blinkConfig = testEnv.settingsManager.settings.blinkTimer
|
var blinkConfig = testEnv.settingsManager.settings.blinkTimer
|
||||||
blinkConfig.enabled = true
|
blinkConfig.enabled = true
|
||||||
blinkConfig.intervalSeconds = 300
|
blinkConfig.intervalSeconds = 300
|
||||||
testEnv.settingsManager.updateTimerConfiguration(for: .blink, configuration: blinkConfig)
|
testEnv.settingsManager.updateTimerConfiguration(for: .blink, configuration: blinkConfig)
|
||||||
|
|
||||||
// Page 3: Posture Setup
|
// Page 3: Posture Setup
|
||||||
var postureConfig = testEnv.settingsManager.settings.postureTimer
|
var postureConfig = testEnv.settingsManager.settings.postureTimer
|
||||||
postureConfig.enabled = false // User chooses to disable this one
|
postureConfig.enabled = false // User chooses to disable this one
|
||||||
testEnv.settingsManager.updateTimerConfiguration(for: .posture, configuration: postureConfig)
|
testEnv.settingsManager.updateTimerConfiguration(
|
||||||
|
for: .posture, configuration: postureConfig)
|
||||||
|
|
||||||
// Page 4: General Settings
|
// Page 4: General Settings
|
||||||
testEnv.settingsManager.settings.playSounds = true
|
testEnv.settingsManager.settings.playSounds = true
|
||||||
testEnv.settingsManager.settings.launchAtLogin = false
|
testEnv.settingsManager.settings.launchAtLogin = false
|
||||||
|
|
||||||
// Page 5: Completion - mark as done
|
// Page 5: Completion - mark as done
|
||||||
testEnv.settingsManager.settings.hasCompletedOnboarding = true
|
testEnv.settingsManager.settings.hasCompletedOnboarding = true
|
||||||
|
|
||||||
// Verify final state
|
// Verify final state
|
||||||
XCTAssertTrue(testEnv.settingsManager.settings.hasCompletedOnboarding)
|
XCTAssertTrue(testEnv.settingsManager.settings.hasCompletedOnboarding)
|
||||||
|
|
||||||
let finalConfigs = testEnv.settingsManager.allTimerConfigurations()
|
let finalConfigs = testEnv.settingsManager.allTimerConfigurations()
|
||||||
XCTAssertTrue(finalConfigs[.lookAway]?.enabled ?? false)
|
XCTAssertTrue(finalConfigs[.lookAway]?.enabled ?? false)
|
||||||
XCTAssertTrue(finalConfigs[.blink]?.enabled ?? false)
|
XCTAssertTrue(finalConfigs[.blink]?.enabled ?? false)
|
||||||
XCTAssertFalse(finalConfigs[.posture]?.enabled ?? true)
|
XCTAssertFalse(finalConfigs[.posture]?.enabled ?? true)
|
||||||
|
|
||||||
XCTAssertTrue(testEnv.settingsManager.settings.playSounds)
|
XCTAssertTrue(testEnv.settingsManager.settings.playSounds)
|
||||||
XCTAssertFalse(testEnv.settingsManager.settings.launchAtLogin)
|
XCTAssertFalse(testEnv.settingsManager.settings.launchAtLogin)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testNavigatingBackPreservesSettings() {
|
func testNavigatingBackPreservesSettings() {
|
||||||
// Configure on page 1
|
// Configure on page 1
|
||||||
var lookAwayConfig = testEnv.settingsManager.settings.lookAwayTimer
|
var lookAwayConfig = testEnv.settingsManager.settings.lookAwayTimer
|
||||||
lookAwayConfig.intervalSeconds = 1500
|
lookAwayConfig.intervalSeconds = 1500
|
||||||
testEnv.settingsManager.updateTimerConfiguration(for: .lookAway, configuration: lookAwayConfig)
|
testEnv.settingsManager.updateTimerConfiguration(
|
||||||
|
for: .lookAway, configuration: lookAwayConfig)
|
||||||
|
|
||||||
// Move forward to page 2
|
// Move forward to page 2
|
||||||
var blinkConfig = testEnv.settingsManager.settings.blinkTimer
|
var blinkConfig = testEnv.settingsManager.settings.blinkTimer
|
||||||
blinkConfig.intervalSeconds = 250
|
blinkConfig.intervalSeconds = 250
|
||||||
testEnv.settingsManager.updateTimerConfiguration(for: .blink, configuration: blinkConfig)
|
testEnv.settingsManager.updateTimerConfiguration(for: .blink, configuration: blinkConfig)
|
||||||
|
|
||||||
// Navigate back to page 1
|
// Navigate back to page 1
|
||||||
// Verify lookaway settings still exist
|
// Verify lookaway settings still exist
|
||||||
let lookAway = testEnv.settingsManager.timerConfiguration(for: .lookAway)
|
let lookAway = testEnv.settingsManager.timerConfiguration(for: .lookAway)
|
||||||
XCTAssertEqual(lookAway.intervalSeconds, 1500)
|
XCTAssertEqual(lookAway.intervalSeconds, 1500)
|
||||||
|
|
||||||
// Navigate forward again to page 2
|
// Navigate forward again to page 2
|
||||||
// Verify blink settings still exist
|
// Verify blink settings still exist
|
||||||
let blink = testEnv.settingsManager.timerConfiguration(for: .blink)
|
let blink = testEnv.settingsManager.timerConfiguration(for: .blink)
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ final class ExampleUITests: XCTestCase {
|
|||||||
// For example:
|
// For example:
|
||||||
// XCTAssertEqual(app.windows.count, 1)
|
// XCTAssertEqual(app.windows.count, 1)
|
||||||
// XCTAssertTrue(app.buttons["Start"].exists)
|
// XCTAssertTrue(app.buttons["Start"].exists)
|
||||||
|
|
||||||
XCTAssertTrue(true, "UI testing example - this would verify UI elements")
|
XCTAssertTrue(true, "UI testing example - this would verify UI elements")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,4 +43,5 @@ final class ExampleUITests: XCTestCase {
|
|||||||
XCUIApplication().launch()
|
XCUIApplication().launch()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
42
run
42
run
@@ -107,6 +107,32 @@ print_errors() {
|
|||||||
echo "================================================================================"
|
echo "================================================================================"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Pretty prints diagnostic warnings from output (LSP and compiler warnings)
|
||||||
|
print_warnings() {
|
||||||
|
local output="$1"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "⚠️ Diagnostic Warnings:"
|
||||||
|
echo "================================================================================"
|
||||||
|
|
||||||
|
# Extract Swift compiler warnings in the format: /path/file.swift:line:col: warning: message
|
||||||
|
local warnings
|
||||||
|
warnings=$(echo "$output" | grep -E "\.swift:[0-9]+:[0-9]+: warning:" | sed 's/^/ /')
|
||||||
|
|
||||||
|
if [ -n "$warnings" ]; then
|
||||||
|
# Count total warnings
|
||||||
|
local count
|
||||||
|
count=$(echo "$warnings" | wc -l | tr -d ' ')
|
||||||
|
echo " Found $count warning(s):"
|
||||||
|
echo ""
|
||||||
|
echo "$warnings"
|
||||||
|
else
|
||||||
|
echo " No warnings found."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "================================================================================"
|
||||||
|
}
|
||||||
|
|
||||||
# Launches the built application
|
# Launches the built application
|
||||||
launch_app() {
|
launch_app() {
|
||||||
local build_dir
|
local build_dir
|
||||||
@@ -235,6 +261,11 @@ case "$ACTION" in
|
|||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ]; then
|
||||||
handle_build_success
|
handle_build_success
|
||||||
echo "💡 The app is located at: build/Debug/Gaze.app"
|
echo "💡 The app is located at: build/Debug/Gaze.app"
|
||||||
|
|
||||||
|
# Show warnings in verbose mode
|
||||||
|
if [ "$VERBOSE" = true ]; then
|
||||||
|
print_warnings "$COMMAND_OUTPUT"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo "❌ Build failed!"
|
echo "❌ Build failed!"
|
||||||
print_errors "$COMMAND_OUTPUT" "Build"
|
print_errors "$COMMAND_OUTPUT" "Build"
|
||||||
@@ -248,6 +279,11 @@ case "$ACTION" in
|
|||||||
|
|
||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ]; then
|
||||||
echo "✅ Tests passed!"
|
echo "✅ Tests passed!"
|
||||||
|
|
||||||
|
# Show warnings in verbose mode
|
||||||
|
if [ "$VERBOSE" = true ]; then
|
||||||
|
print_warnings "$COMMAND_OUTPUT"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo "❌ Tests failed!"
|
echo "❌ Tests failed!"
|
||||||
print_errors "$COMMAND_OUTPUT" "Test"
|
print_errors "$COMMAND_OUTPUT" "Test"
|
||||||
@@ -264,6 +300,12 @@ case "$ACTION" in
|
|||||||
|
|
||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ]; then
|
||||||
handle_build_success
|
handle_build_success
|
||||||
|
|
||||||
|
# Show warnings in verbose mode
|
||||||
|
if [ "$VERBOSE" = true ]; then
|
||||||
|
print_warnings "$COMMAND_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
launch_app
|
launch_app
|
||||||
else
|
else
|
||||||
echo "❌ Build failed!"
|
echo "❌ Build failed!"
|
||||||
|
|||||||
Reference in New Issue
Block a user