This commit is contained in:
Michael Freno
2026-01-27 12:50:23 -05:00
parent b1996c8324
commit a5602d9829
9 changed files with 198 additions and 139 deletions

View File

@@ -2,7 +2,7 @@
// AdaptiveLayout.swift // AdaptiveLayout.swift
// Gaze // Gaze
// //
// Created by Claude on 1/19/26. // Created by Mike Freno on 1/19/26.
// //
import SwiftUI import SwiftUI

View File

@@ -2,7 +2,7 @@
// TestingEnvironment.swift // TestingEnvironment.swift
// Gaze // Gaze
// //
// Created by OpenCode on 1/13/26. // Created by Mike Freno on 1/13/26.
// //
import Foundation import Foundation
@@ -26,7 +26,8 @@ enum TestingEnvironment {
/// Check if running in any test mode (unit tests or UI tests) /// Check if running in any test mode (unit tests or UI tests)
static var isAnyTestMode: Bool { static var isAnyTestMode: Bool {
return isUITesting || ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil return isUITesting
|| ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
} }
#if DEBUG #if DEBUG

View File

@@ -2,7 +2,7 @@
// ScreenCapturePermissionManager.swift // ScreenCapturePermissionManager.swift
// Gaze // Gaze
// //
// Created by ChatGPT on 1/14/26. // Created by Mike Freno on 1/14/26.
// //
import AppKit import AppKit

View File

@@ -2,7 +2,7 @@
// GazeOverlayView.swift // GazeOverlayView.swift
// Gaze // Gaze
// //
// Created by Claude on 1/16/26. // Created by Mike Freno on 1/16/26.
// //
import SwiftUI import SwiftUI

View File

@@ -2,7 +2,7 @@
// PupilOverlayView.swift // PupilOverlayView.swift
// Gaze // Gaze
// //
// Created by Claude on 1/16/26. // Created by Mike Freno on 1/16/26.
// //
import SwiftUI import SwiftUI

View File

@@ -2,7 +2,7 @@
// UserTimerOverlayReminderView.swift // UserTimerOverlayReminderView.swift
// Gaze // Gaze
// //
// Created by OpenCode on 1/11/26. // Created by Mike Freno on 1/11/26.
// //
import AppKit import AppKit

View File

@@ -2,7 +2,7 @@
// UserTimerReminderView.swift // UserTimerReminderView.swift
// Gaze // Gaze
// //
// Created by OpenCode on 1/11/26. // Created by Mike Freno on 1/11/26.
// //
import SwiftUI import SwiftUI

View File

@@ -2,12 +2,13 @@
// PupilDetectorTests.swift // PupilDetectorTests.swift
// GazeTests // GazeTests
// //
// Created by Claude on 1/16/26. // Created by Mike Freno on 1/16/26.
// //
import XCTest
import CoreVideo import CoreVideo
import Vision import Vision
import XCTest
@testable import Gaze @testable import Gaze
final class PupilDetectorTests: XCTestCase { final class PupilDetectorTests: XCTestCase {
@@ -103,7 +104,9 @@ final class PupilDetectorTests: XCTestCase {
shouldInterpolate: false, shouldInterpolate: false,
intent: .defaultIntent intent: .defaultIntent
) { ) {
if let dest = CGImageDestinationCreateWithURL(url as CFURL, "public.png" as CFString, 1, nil) { if let dest = CGImageDestinationCreateWithURL(
url as CFURL, "public.png" as CFString, 1, nil)
{
CGImageDestinationAddImage(dest, cgImage, nil) CGImageDestinationAddImage(dest, cgImage, nil)
CGImageDestinationFinalize(dest) CGImageDestinationFinalize(dest)
print("💾 Saved synthetic test input to: \(url.path)") print("💾 Saved synthetic test input to: \(url.path)")
@@ -159,7 +162,9 @@ final class PupilDetectorTests: XCTestCase {
shouldInterpolate: false, shouldInterpolate: false,
intent: .defaultIntent intent: .defaultIntent
) { ) {
if let dest = CGImageDestinationCreateWithURL(url as CFURL, "public.png" as CFString, 1, nil) { if let dest = CGImageDestinationCreateWithURL(
url as CFURL, "public.png" as CFString, 1, nil)
{
CGImageDestinationAddImage(dest, cgImage, nil) CGImageDestinationAddImage(dest, cgImage, nil)
CGImageDestinationFinalize(dest) CGImageDestinationFinalize(dest)
print("💾 Saved synthetic binary image to: \(url.path)") print("💾 Saved synthetic binary image to: \(url.path)")

View File

@@ -2,12 +2,13 @@
// VideoGazeTests.swift // VideoGazeTests.swift
// GazeTests // GazeTests
// //
// Created by Claude on 1/16/26. // Created by Mike Freno on 1/16/26.
// //
import XCTest
import AVFoundation import AVFoundation
import Vision import Vision
import XCTest
@testable import Gaze @testable import Gaze
final class VideoGazeTests: XCTestCase { final class VideoGazeTests: XCTestCase {
@@ -34,19 +35,32 @@ final class VideoGazeTests: XCTestCase {
XCTFail("Video file not found at: \(projectPath)") XCTFail("Video file not found at: \(projectPath)")
return return
} }
let stats = try await processVideo(at: URL(fileURLWithPath: projectPath), expectLookingAway: true) let stats = try await processVideo(
at: URL(fileURLWithPath: projectPath), expectLookingAway: true)
// For outer video, most frames should detect gaze outside center // For outer video, most frames should detect gaze outside center
let nonCenterRatio = Double(stats.nonCenterFrames) / Double(max(1, stats.pupilDetectedFrames)) let nonCenterRatio =
log("🎯 OUTER video: \(String(format: "%.1f%%", nonCenterRatio * 100)) frames detected as non-center (expected: >50%)") Double(stats.nonCenterFrames) / Double(max(1, stats.pupilDetectedFrames))
log(" H-range: \(String(format: "%.3f", stats.minH)) to \(String(format: "%.3f", stats.maxH))") log(
log(" V-range: \(String(format: "%.3f", stats.minV)) to \(String(format: "%.3f", stats.maxV))") "🎯 OUTER video: \(String(format: "%.1f%%", nonCenterRatio * 100)) frames detected as non-center (expected: >50%)"
log(" Face width: \(String(format: "%.3f", stats.avgFaceWidth)) (range: \(String(format: "%.3f", stats.minFaceWidth))-\(String(format: "%.3f", stats.maxFaceWidth)))") )
log(
" H-range: \(String(format: "%.3f", stats.minH)) to \(String(format: "%.3f", stats.maxH))"
)
log(
" V-range: \(String(format: "%.3f", stats.minV)) to \(String(format: "%.3f", stats.maxV))"
)
log(
" Face width: \(String(format: "%.3f", stats.avgFaceWidth)) (range: \(String(format: "%.3f", stats.minFaceWidth))-\(String(format: "%.3f", stats.maxFaceWidth)))"
)
attachLogs() attachLogs()
// At least 50% should be detected as non-center when looking away // At least 50% should be detected as non-center when looking away
XCTAssertGreaterThan(nonCenterRatio, 0.5, "Looking away video should have >50% non-center detections. Log:\n\(logLines.joined(separator: "\n"))") XCTAssertGreaterThan(
nonCenterRatio, 0.5,
"Looking away video should have >50% non-center detections. Log:\n\(logLines.joined(separator: "\n"))"
)
} }
/// Process the inner video (looking at screen) - should detect "looking at screen" /// Process the inner video (looking at screen) - should detect "looking at screen"
@@ -58,19 +72,31 @@ final class VideoGazeTests: XCTestCase {
XCTFail("Video file not found at: \(projectPath)") XCTFail("Video file not found at: \(projectPath)")
return return
} }
let stats = try await processVideo(at: URL(fileURLWithPath: projectPath), expectLookingAway: false) let stats = try await processVideo(
at: URL(fileURLWithPath: projectPath), expectLookingAway: false)
// For inner video, most frames should detect gaze at center // For inner video, most frames should detect gaze at center
let centerRatio = Double(stats.centerFrames) / Double(max(1, stats.pupilDetectedFrames)) let centerRatio = Double(stats.centerFrames) / Double(max(1, stats.pupilDetectedFrames))
log("🎯 INNER video: \(String(format: "%.1f%%", centerRatio * 100)) frames detected as center (expected: >50%)") log(
log(" H-range: \(String(format: "%.3f", stats.minH)) to \(String(format: "%.3f", stats.maxH))") "🎯 INNER video: \(String(format: "%.1f%%", centerRatio * 100)) frames detected as center (expected: >50%)"
log(" V-range: \(String(format: "%.3f", stats.minV)) to \(String(format: "%.3f", stats.maxV))") )
log(" Face width: \(String(format: "%.3f", stats.avgFaceWidth)) (range: \(String(format: "%.3f", stats.minFaceWidth))-\(String(format: "%.3f", stats.maxFaceWidth)))") log(
" H-range: \(String(format: "%.3f", stats.minH)) to \(String(format: "%.3f", stats.maxH))"
)
log(
" V-range: \(String(format: "%.3f", stats.minV)) to \(String(format: "%.3f", stats.maxV))"
)
log(
" Face width: \(String(format: "%.3f", stats.avgFaceWidth)) (range: \(String(format: "%.3f", stats.minFaceWidth))-\(String(format: "%.3f", stats.maxFaceWidth)))"
)
attachLogs() attachLogs()
// At least 50% should be detected as center when looking at screen // At least 50% should be detected as center when looking at screen
XCTAssertGreaterThan(centerRatio, 0.5, "Looking at screen video should have >50% center detections. Log:\n\(logLines.joined(separator: "\n"))") XCTAssertGreaterThan(
centerRatio, 0.5,
"Looking at screen video should have >50% center detections. Log:\n\(logLines.joined(separator: "\n"))"
)
} }
struct VideoStats { struct VideoStats {
@@ -98,7 +124,9 @@ final class VideoGazeTests: XCTestCase {
log("\n" + String(repeating: "=", count: 60)) log("\n" + String(repeating: "=", count: 60))
log("Processing video: \(url.lastPathComponent)") log("Processing video: \(url.lastPathComponent)")
log("Expected behavior: \(expectLookingAway ? "LOOKING AWAY (non-center)" : "LOOKING AT SCREEN (center)")") log(
"Expected behavior: \(expectLookingAway ? "LOOKING AWAY (non-center)" : "LOOKING AT SCREEN (center)")"
)
log(String(repeating: "=", count: 60)) log(String(repeating: "=", count: 60))
let asset = AVURLAsset(url: url) let asset = AVURLAsset(url: url)
@@ -113,7 +141,9 @@ final class VideoGazeTests: XCTestCase {
let size = try await track.load(.naturalSize) let size = try await track.load(.naturalSize)
let frameRate = try await track.load(.nominalFrameRate) let frameRate = try await track.load(.nominalFrameRate)
log("Size: \(Int(size.width))x\(Int(size.height)), FPS: \(String(format: "%.1f", frameRate))") log(
"Size: \(Int(size.width))x\(Int(size.height)), FPS: \(String(format: "%.1f", frameRate))"
)
let reader = try AVAssetReader(asset: asset) let reader = try AVAssetReader(asset: asset)
let outputSettings: [String: Any] = [ let outputSettings: [String: Any] = [
@@ -173,8 +203,12 @@ final class VideoGazeTests: XCTestCase {
let face = observations.first, let face = observations.first,
let landmarks = face.landmarks, let landmarks = face.landmarks,
let leftEye = landmarks.leftEye, let leftEye = landmarks.leftEye,
let rightEye = landmarks.rightEye else { let rightEye = landmarks.rightEye
log(String(format: "%5d | %5.1fs | NO | - | - | -", frameIndex, timeSeconds)) else {
log(
String(
format: "%5d | %5.1fs | NO | - | - | -",
frameIndex, timeSeconds))
continue continue
} }
@@ -205,8 +239,10 @@ final class VideoGazeTests: XCTestCase {
imageSize: imageSize, imageSize: imageSize,
side: 0 side: 0
) { ) {
leftHRatio = calculateHorizontalRatio(pupilPosition: leftResult.pupilPosition, eyeRegion: leftResult.eyeRegion) leftHRatio = calculateHorizontalRatio(
leftVRatio = calculateVerticalRatio(pupilPosition: leftResult.pupilPosition, eyeRegion: leftResult.eyeRegion) pupilPosition: leftResult.pupilPosition, eyeRegion: leftResult.eyeRegion)
leftVRatio = calculateVerticalRatio(
pupilPosition: leftResult.pupilPosition, eyeRegion: leftResult.eyeRegion)
} }
if let rightResult = PupilDetector.detectPupil( if let rightResult = PupilDetector.detectPupil(
@@ -216,12 +252,15 @@ final class VideoGazeTests: XCTestCase {
imageSize: imageSize, imageSize: imageSize,
side: 1 side: 1
) { ) {
rightHRatio = calculateHorizontalRatio(pupilPosition: rightResult.pupilPosition, eyeRegion: rightResult.eyeRegion) rightHRatio = calculateHorizontalRatio(
rightVRatio = calculateVerticalRatio(pupilPosition: rightResult.pupilPosition, eyeRegion: rightResult.eyeRegion) pupilPosition: rightResult.pupilPosition, eyeRegion: rightResult.eyeRegion)
rightVRatio = calculateVerticalRatio(
pupilPosition: rightResult.pupilPosition, eyeRegion: rightResult.eyeRegion)
} }
if let lh = leftHRatio, let rh = rightHRatio, if let lh = leftHRatio, let rh = rightHRatio,
let lv = leftVRatio, let rv = rightVRatio { let lv = leftVRatio, let rv = rightVRatio
{
stats.pupilDetectedFrames += 1 stats.pupilDetectedFrames += 1
let avgH = (lh + rh) / 2.0 let avgH = (lh + rh) / 2.0
let avgV = (lv + rv) / 2.0 let avgV = (lv + rv) / 2.0
@@ -238,23 +277,35 @@ final class VideoGazeTests: XCTestCase {
} else { } else {
stats.nonCenterFrames += 1 stats.nonCenterFrames += 1
} }
log(String(format: "%5d | %5.1fs | YES | %.2f / %.2f | %.2f / %.2f | %@ %@", log(
frameIndex, timeSeconds, lh, rh, lv, rv, direction.rawValue, String(describing: direction))) String(
format: "%5d | %5.1fs | YES | %.2f / %.2f | %.2f / %.2f | %@ %@",
frameIndex, timeSeconds, lh, rh, lv, rv, direction.rawValue,
String(describing: direction)))
} else { } else {
log(String(format: "%5d | %5.1fs | YES | PUPIL FAIL | PUPIL FAIL | -", frameIndex, timeSeconds)) log(
String(
format: "%5d | %5.1fs | YES | PUPIL FAIL | PUPIL FAIL | -",
frameIndex, timeSeconds))
} }
} }
log(String(repeating: "=", count: 75)) log(String(repeating: "=", count: 75))
log("Summary: \(stats.totalFrames) frames sampled, \(stats.faceDetectedFrames) with face, \(stats.pupilDetectedFrames) with pupils") log(
"Summary: \(stats.totalFrames) frames sampled, \(stats.faceDetectedFrames) with face, \(stats.pupilDetectedFrames) with pupils"
)
log("Center frames: \(stats.centerFrames), Non-center: \(stats.nonCenterFrames)") log("Center frames: \(stats.centerFrames), Non-center: \(stats.nonCenterFrames)")
log("Face width: avg=\(String(format: "%.3f", stats.avgFaceWidth)), range=\(String(format: "%.3f", stats.minFaceWidth)) to \(String(format: "%.3f", stats.maxFaceWidth))") log(
"Face width: avg=\(String(format: "%.3f", stats.avgFaceWidth)), range=\(String(format: "%.3f", stats.minFaceWidth)) to \(String(format: "%.3f", stats.maxFaceWidth))"
)
log("Processing complete\n") log("Processing complete\n")
return stats return stats
} }
private func calculateHorizontalRatio(pupilPosition: PupilPosition, eyeRegion: EyeRegion) -> Double { private func calculateHorizontalRatio(pupilPosition: PupilPosition, eyeRegion: EyeRegion)
-> Double
{
// pupilPosition.y controls horizontal gaze due to image orientation // pupilPosition.y controls horizontal gaze due to image orientation
let pupilY = Double(pupilPosition.y) let pupilY = Double(pupilPosition.y)
let eyeHeight = Double(eyeRegion.frame.height) let eyeHeight = Double(eyeRegion.frame.height)
@@ -265,7 +316,9 @@ final class VideoGazeTests: XCTestCase {
return max(0.0, min(1.0, ratio)) return max(0.0, min(1.0, ratio))
} }
private func calculateVerticalRatio(pupilPosition: PupilPosition, eyeRegion: EyeRegion) -> Double { private func calculateVerticalRatio(pupilPosition: PupilPosition, eyeRegion: EyeRegion)
-> Double
{
// pupilPosition.x controls vertical gaze due to image orientation // pupilPosition.x controls vertical gaze due to image orientation
let pupilX = Double(pupilPosition.x) let pupilX = Double(pupilPosition.x)
let eyeWidth = Double(eyeRegion.frame.width) let eyeWidth = Double(eyeRegion.frame.width)