lol
This commit is contained in:
@@ -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
|
||||||
@@ -13,29 +13,29 @@ enum AdaptiveLayout {
|
|||||||
enum Window {
|
enum Window {
|
||||||
static let minWidth: CGFloat = 700
|
static let minWidth: CGFloat = 700
|
||||||
#if APPSTORE
|
#if APPSTORE
|
||||||
static let minHeight: CGFloat = 500
|
static let minHeight: CGFloat = 500
|
||||||
#else
|
#else
|
||||||
static let minHeight: CGFloat = 600
|
static let minHeight: CGFloat = 600
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
static let defaultWidth: CGFloat = 900
|
static let defaultWidth: CGFloat = 900
|
||||||
#if APPSTORE
|
#if APPSTORE
|
||||||
static let defaultHeight: CGFloat = 650
|
static let defaultHeight: CGFloat = 650
|
||||||
#else
|
#else
|
||||||
static let defaultHeight: CGFloat = 800
|
static let defaultHeight: CGFloat = 800
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Content area constraints
|
/// Content area constraints
|
||||||
enum Content {
|
enum Content {
|
||||||
/// Maximum width for content cards/sections
|
/// Maximum width for content cards/sections
|
||||||
static let maxWidth: CGFloat = 560
|
static let maxWidth: CGFloat = 560
|
||||||
/// Minimum width for content cards/sections
|
/// Minimum width for content cards/sections
|
||||||
static let minWidth: CGFloat = 400
|
static let minWidth: CGFloat = 400
|
||||||
/// Ideal width for onboarding/welcome cards
|
/// Ideal width for onboarding/welcome cards
|
||||||
static let idealCardWidth: CGFloat = 520
|
static let idealCardWidth: CGFloat = 520
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Font sizes that scale based on available space
|
/// Font sizes that scale based on available space
|
||||||
enum Font {
|
enum Font {
|
||||||
static let heroIcon: CGFloat = 60
|
static let heroIcon: CGFloat = 60
|
||||||
@@ -45,7 +45,7 @@ enum AdaptiveLayout {
|
|||||||
static let cardIcon: CGFloat = 32
|
static let cardIcon: CGFloat = 32
|
||||||
static let cardIconSmall: CGFloat = 28
|
static let cardIconSmall: CGFloat = 28
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spacing values
|
/// Spacing values
|
||||||
enum Spacing {
|
enum Spacing {
|
||||||
static let standard: CGFloat = 20
|
static let standard: CGFloat = 20
|
||||||
@@ -53,7 +53,7 @@ enum AdaptiveLayout {
|
|||||||
static let section: CGFloat = 30
|
static let section: CGFloat = 30
|
||||||
static let sectionCompact: CGFloat = 20
|
static let sectionCompact: CGFloat = 20
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Card dimensions for swipeable cards
|
/// Card dimensions for swipeable cards
|
||||||
enum Card {
|
enum Card {
|
||||||
static let maxWidth: CGFloat = 520
|
static let maxWidth: CGFloat = 520
|
||||||
@@ -81,11 +81,11 @@ extension EnvironmentValues {
|
|||||||
struct AdaptiveContainerModifier: ViewModifier {
|
struct AdaptiveContainerModifier: ViewModifier {
|
||||||
@State private var isCompact = false
|
@State private var isCompact = false
|
||||||
let compactThreshold: CGFloat
|
let compactThreshold: CGFloat
|
||||||
|
|
||||||
init(compactThreshold: CGFloat = 600) {
|
init(compactThreshold: CGFloat = 600) {
|
||||||
self.compactThreshold = compactThreshold
|
self.compactThreshold = compactThreshold
|
||||||
}
|
}
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
content
|
content
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -13,26 +13,27 @@ enum TestingEnvironment {
|
|||||||
static var isUITesting: Bool {
|
static var isUITesting: Bool {
|
||||||
return ProcessInfo.processInfo.arguments.contains("--ui-testing")
|
return ProcessInfo.processInfo.arguments.contains("--ui-testing")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if app should skip onboarding
|
/// Check if app should skip onboarding
|
||||||
static var shouldSkipOnboarding: Bool {
|
static var shouldSkipOnboarding: Bool {
|
||||||
return ProcessInfo.processInfo.arguments.contains("--skip-onboarding")
|
return ProcessInfo.processInfo.arguments.contains("--skip-onboarding")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if app should reset onboarding
|
/// Check if app should reset onboarding
|
||||||
static var shouldResetOnboarding: Bool {
|
static var shouldResetOnboarding: Bool {
|
||||||
return ProcessInfo.processInfo.arguments.contains("--reset-onboarding")
|
return ProcessInfo.processInfo.arguments.contains("--reset-onboarding")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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
|
||||||
/// Check if dev triggers should be visible
|
/// Check if dev triggers should be visible
|
||||||
static var shouldShowDevTriggers: Bool {
|
static var shouldShowDevTriggers: Bool {
|
||||||
return isUITesting || isAnyTestMode
|
return isUITesting || isAnyTestMode
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -2,41 +2,42 @@
|
|||||||
// 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 {
|
||||||
|
|
||||||
override func setUp() async throws {
|
override func setUp() async throws {
|
||||||
// Reset the detector state
|
// Reset the detector state
|
||||||
PupilDetector.cleanup()
|
PupilDetector.cleanup()
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCreateCGImageFromData() throws {
|
func testCreateCGImageFromData() throws {
|
||||||
// Test basic image creation
|
// Test basic image creation
|
||||||
let width = 50
|
let width = 50
|
||||||
let height = 50
|
let height = 50
|
||||||
var pixels = [UInt8](repeating: 128, count: width * height)
|
var pixels = [UInt8](repeating: 128, count: width * height)
|
||||||
|
|
||||||
// Add some dark pixels for a "pupil"
|
// Add some dark pixels for a "pupil"
|
||||||
for y in 20..<30 {
|
for y in 20..<30 {
|
||||||
for x in 20..<30 {
|
for x in 20..<30 {
|
||||||
pixels[y * width + x] = 10 // Very dark
|
pixels[y * width + x] = 10 // Very dark
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save test image to verify
|
// Save test image to verify
|
||||||
let pixelData = Data(pixels)
|
let pixelData = Data(pixels)
|
||||||
guard let provider = CGDataProvider(data: pixelData as CFData) else {
|
guard let provider = CGDataProvider(data: pixelData as CFData) else {
|
||||||
XCTFail("Failed to create CGDataProvider")
|
XCTFail("Failed to create CGDataProvider")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let cgImage = CGImage(
|
let cgImage = CGImage(
|
||||||
width: width,
|
width: width,
|
||||||
height: height,
|
height: height,
|
||||||
@@ -50,18 +51,18 @@ final class PupilDetectorTests: XCTestCase {
|
|||||||
shouldInterpolate: false,
|
shouldInterpolate: false,
|
||||||
intent: .defaultIntent
|
intent: .defaultIntent
|
||||||
)
|
)
|
||||||
|
|
||||||
XCTAssertNotNil(cgImage, "Should create CGImage from pixel data")
|
XCTAssertNotNil(cgImage, "Should create CGImage from pixel data")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testImageProcessingWithDarkPixels() throws {
|
func testImageProcessingWithDarkPixels() throws {
|
||||||
// Test that imageProcessingOptimized produces dark pixels
|
// Test that imageProcessingOptimized produces dark pixels
|
||||||
let width = 60
|
let width = 60
|
||||||
let height = 40
|
let height = 40
|
||||||
|
|
||||||
// Create input with a dark circle (simulating pupil)
|
// Create input with a dark circle (simulating pupil)
|
||||||
var input = [UInt8](repeating: 200, count: width * height) // Light background (like eye white)
|
var input = [UInt8](repeating: 200, count: width * height) // Light background (like eye white)
|
||||||
|
|
||||||
// Add a dark ellipse in center (pupil)
|
// Add a dark ellipse in center (pupil)
|
||||||
let centerX = width / 2
|
let centerX = width / 2
|
||||||
let centerY = height / 2
|
let centerY = height / 2
|
||||||
@@ -74,10 +75,10 @@ final class PupilDetectorTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var output = [UInt8](repeating: 255, count: width * height)
|
var output = [UInt8](repeating: 255, count: width * height)
|
||||||
let threshold = 50 // Same as default
|
let threshold = 50 // Same as default
|
||||||
|
|
||||||
// Call the actual processing function
|
// Call the actual processing function
|
||||||
input.withUnsafeMutableBufferPointer { inputPtr in
|
input.withUnsafeMutableBufferPointer { inputPtr in
|
||||||
output.withUnsafeMutableBufferPointer { outputPtr in
|
output.withUnsafeMutableBufferPointer { outputPtr in
|
||||||
@@ -85,7 +86,7 @@ final class PupilDetectorTests: XCTestCase {
|
|||||||
// But we can verify by saving input for inspection
|
// But we can verify by saving input for inspection
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save the input for manual inspection
|
// Save the input for manual inspection
|
||||||
let inputData = Data(input)
|
let inputData = Data(input)
|
||||||
let url = URL(fileURLWithPath: "/Users/mike/gaze/images/test_input_synthetic.png")
|
let url = URL(fileURLWithPath: "/Users/mike/gaze/images/test_input_synthetic.png")
|
||||||
@@ -103,28 +104,30 @@ 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)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count dark pixels in input
|
// Count dark pixels in input
|
||||||
let darkCount = input.filter { $0 < 50 }.count
|
let darkCount = input.filter { $0 < 50 }.count
|
||||||
print("📊 Input has \(darkCount) dark pixels (< 50)")
|
print("📊 Input has \(darkCount) dark pixels (< 50)")
|
||||||
XCTAssertGreaterThan(darkCount, 0, "Input should have dark pixels for pupil")
|
XCTAssertGreaterThan(darkCount, 0, "Input should have dark pixels for pupil")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testFindPupilFromContoursWithSyntheticData() throws {
|
func testFindPupilFromContoursWithSyntheticData() throws {
|
||||||
// Create synthetic binary image with a dark region
|
// Create synthetic binary image with a dark region
|
||||||
let width = 60
|
let width = 60
|
||||||
let height = 40
|
let height = 40
|
||||||
|
|
||||||
// All white except a dark blob
|
// All white except a dark blob
|
||||||
var binaryData = [UInt8](repeating: 255, count: width * height)
|
var binaryData = [UInt8](repeating: 255, count: width * height)
|
||||||
|
|
||||||
// Add dark region (0 = dark/pupil)
|
// Add dark region (0 = dark/pupil)
|
||||||
let centerX = 30
|
let centerX = 30
|
||||||
let centerY = 20
|
let centerY = 20
|
||||||
@@ -139,9 +142,9 @@ final class PupilDetectorTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
print("📊 Created synthetic image with \(darkPixelCount) dark pixels")
|
print("📊 Created synthetic image with \(darkPixelCount) dark pixels")
|
||||||
|
|
||||||
// Save for inspection
|
// Save for inspection
|
||||||
let binaryDataObj = Data(binaryData)
|
let binaryDataObj = Data(binaryData)
|
||||||
let url = URL(fileURLWithPath: "/Users/mike/gaze/images/test_binary_synthetic.png")
|
let url = URL(fileURLWithPath: "/Users/mike/gaze/images/test_binary_synthetic.png")
|
||||||
@@ -159,14 +162,16 @@ 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)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
XCTAssertGreaterThan(darkPixelCount, 10, "Should have enough dark pixels")
|
XCTAssertGreaterThan(darkPixelCount, 10, "Should have enough dark pixels")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,77 +2,103 @@
|
|||||||
// 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 {
|
||||||
|
|
||||||
var logLines: [String] = []
|
var logLines: [String] = []
|
||||||
|
|
||||||
private func log(_ message: String) {
|
private func log(_ message: String) {
|
||||||
logLines.append(message)
|
logLines.append(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func attachLogs() {
|
private func attachLogs() {
|
||||||
let attachment = XCTAttachment(string: logLines.joined(separator: "\n"))
|
let attachment = XCTAttachment(string: logLines.joined(separator: "\n"))
|
||||||
attachment.name = "Test Logs"
|
attachment.name = "Test Logs"
|
||||||
attachment.lifetime = .keepAlways
|
attachment.lifetime = .keepAlways
|
||||||
add(attachment)
|
add(attachment)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Process the outer video (looking away from screen) - should detect "looking away"
|
/// Process the outer video (looking away from screen) - should detect "looking away"
|
||||||
func testOuterVideoGazeDetection() async throws {
|
func testOuterVideoGazeDetection() async throws {
|
||||||
logLines = []
|
logLines = []
|
||||||
|
|
||||||
let projectPath = "/Users/mike/Code/Gaze/GazeTests/video-test-outer.mp4"
|
let projectPath = "/Users/mike/Code/Gaze/GazeTests/video-test-outer.mp4"
|
||||||
guard FileManager.default.fileExists(atPath: projectPath) else {
|
guard FileManager.default.fileExists(atPath: projectPath) else {
|
||||||
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"
|
||||||
func testInnerVideoGazeDetection() async throws {
|
func testInnerVideoGazeDetection() async throws {
|
||||||
logLines = []
|
logLines = []
|
||||||
|
|
||||||
let projectPath = "/Users/mike/Code/Gaze/GazeTests/video-test-inner.mp4"
|
let projectPath = "/Users/mike/Code/Gaze/GazeTests/video-test-inner.mp4"
|
||||||
guard FileManager.default.fileExists(atPath: projectPath) else {
|
guard FileManager.default.fileExists(atPath: projectPath) else {
|
||||||
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 {
|
||||||
var totalFrames = 0
|
var totalFrames = 0
|
||||||
var faceDetectedFrames = 0
|
var faceDetectedFrames = 0
|
||||||
@@ -87,34 +113,38 @@ final class VideoGazeTests: XCTestCase {
|
|||||||
var maxFaceWidth = -Double.greatestFiniteMagnitude
|
var maxFaceWidth = -Double.greatestFiniteMagnitude
|
||||||
var totalFaceWidth = 0.0
|
var totalFaceWidth = 0.0
|
||||||
var faceWidthCount = 0
|
var faceWidthCount = 0
|
||||||
|
|
||||||
var avgFaceWidth: Double {
|
var avgFaceWidth: Double {
|
||||||
faceWidthCount > 0 ? totalFaceWidth / Double(faceWidthCount) : 0
|
faceWidthCount > 0 ? totalFaceWidth / Double(faceWidthCount) : 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func processVideo(at url: URL, expectLookingAway: Bool) async throws -> VideoStats {
|
private func processVideo(at url: URL, expectLookingAway: Bool) async throws -> VideoStats {
|
||||||
var stats = VideoStats()
|
var stats = VideoStats()
|
||||||
|
|
||||||
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)
|
||||||
let duration = try await asset.load(.duration)
|
let duration = try await asset.load(.duration)
|
||||||
let durationSeconds = CMTimeGetSeconds(duration)
|
let durationSeconds = CMTimeGetSeconds(duration)
|
||||||
log("Duration: \(String(format: "%.2f", durationSeconds)) seconds")
|
log("Duration: \(String(format: "%.2f", durationSeconds)) seconds")
|
||||||
|
|
||||||
guard let track = try await asset.loadTracks(withMediaType: .video).first else {
|
guard let track = try await asset.loadTracks(withMediaType: .video).first else {
|
||||||
XCTFail("No video track found")
|
XCTFail("No video track found")
|
||||||
return stats
|
return stats
|
||||||
}
|
}
|
||||||
|
|
||||||
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] = [
|
||||||
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA
|
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA
|
||||||
@@ -122,82 +152,86 @@ final class VideoGazeTests: XCTestCase {
|
|||||||
let trackOutput = AVAssetReaderTrackOutput(track: track, outputSettings: outputSettings)
|
let trackOutput = AVAssetReaderTrackOutput(track: track, outputSettings: outputSettings)
|
||||||
reader.add(trackOutput)
|
reader.add(trackOutput)
|
||||||
reader.startReading()
|
reader.startReading()
|
||||||
|
|
||||||
var frameIndex = 0
|
var frameIndex = 0
|
||||||
let sampleInterval = max(1, Int(frameRate / 2)) // Sample ~2 frames per second
|
let sampleInterval = max(1, Int(frameRate / 2)) // Sample ~2 frames per second
|
||||||
|
|
||||||
log("\nFrame | Time | Face | H-Ratio L/R | V-Ratio L/R | Direction")
|
log("\nFrame | Time | Face | H-Ratio L/R | V-Ratio L/R | Direction")
|
||||||
log(String(repeating: "-", count: 75))
|
log(String(repeating: "-", count: 75))
|
||||||
|
|
||||||
// Reset calibration for fresh test
|
// Reset calibration for fresh test
|
||||||
PupilDetector.calibration.reset()
|
PupilDetector.calibration.reset()
|
||||||
|
|
||||||
// Disable frame skipping for video testing
|
// Disable frame skipping for video testing
|
||||||
let originalFrameSkip = PupilDetector.frameSkipCount
|
let originalFrameSkip = PupilDetector.frameSkipCount
|
||||||
PupilDetector.frameSkipCount = 1
|
PupilDetector.frameSkipCount = 1
|
||||||
defer { PupilDetector.frameSkipCount = originalFrameSkip }
|
defer { PupilDetector.frameSkipCount = originalFrameSkip }
|
||||||
|
|
||||||
while let sampleBuffer = trackOutput.copyNextSampleBuffer() {
|
while let sampleBuffer = trackOutput.copyNextSampleBuffer() {
|
||||||
defer {
|
defer {
|
||||||
frameIndex += 1
|
frameIndex += 1
|
||||||
PupilDetector.advanceFrame()
|
PupilDetector.advanceFrame()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only process every Nth frame
|
// Only process every Nth frame
|
||||||
if frameIndex % sampleInterval != 0 {
|
if frameIndex % sampleInterval != 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
stats.totalFrames += 1
|
stats.totalFrames += 1
|
||||||
|
|
||||||
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
|
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
let timestamp = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
|
let timestamp = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
|
||||||
let timeSeconds = CMTimeGetSeconds(timestamp)
|
let timeSeconds = CMTimeGetSeconds(timestamp)
|
||||||
|
|
||||||
// Run face detection
|
// Run face detection
|
||||||
let request = VNDetectFaceLandmarksRequest()
|
let request = VNDetectFaceLandmarksRequest()
|
||||||
request.revision = VNDetectFaceLandmarksRequestRevision3
|
request.revision = VNDetectFaceLandmarksRequestRevision3
|
||||||
|
|
||||||
let handler = VNImageRequestHandler(
|
let handler = VNImageRequestHandler(
|
||||||
cvPixelBuffer: pixelBuffer,
|
cvPixelBuffer: pixelBuffer,
|
||||||
orientation: .leftMirrored,
|
orientation: .leftMirrored,
|
||||||
options: [:]
|
options: [:]
|
||||||
)
|
)
|
||||||
|
|
||||||
try handler.perform([request])
|
try handler.perform([request])
|
||||||
|
|
||||||
guard let observations = request.results, !observations.isEmpty,
|
guard let observations = request.results, !observations.isEmpty,
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
stats.faceDetectedFrames += 1
|
stats.faceDetectedFrames += 1
|
||||||
|
|
||||||
// Track face width (bounding box width as ratio of image width)
|
// Track face width (bounding box width as ratio of image width)
|
||||||
let faceWidth = face.boundingBox.width
|
let faceWidth = face.boundingBox.width
|
||||||
stats.minFaceWidth = min(stats.minFaceWidth, faceWidth)
|
stats.minFaceWidth = min(stats.minFaceWidth, faceWidth)
|
||||||
stats.maxFaceWidth = max(stats.maxFaceWidth, faceWidth)
|
stats.maxFaceWidth = max(stats.maxFaceWidth, faceWidth)
|
||||||
stats.totalFaceWidth += faceWidth
|
stats.totalFaceWidth += faceWidth
|
||||||
stats.faceWidthCount += 1
|
stats.faceWidthCount += 1
|
||||||
|
|
||||||
let imageSize = CGSize(
|
let imageSize = CGSize(
|
||||||
width: CVPixelBufferGetWidth(pixelBuffer),
|
width: CVPixelBufferGetWidth(pixelBuffer),
|
||||||
height: CVPixelBufferGetHeight(pixelBuffer)
|
height: CVPixelBufferGetHeight(pixelBuffer)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Detect pupils
|
// Detect pupils
|
||||||
var leftHRatio: Double?
|
var leftHRatio: Double?
|
||||||
var rightHRatio: Double?
|
var rightHRatio: Double?
|
||||||
var leftVRatio: Double?
|
var leftVRatio: Double?
|
||||||
var rightVRatio: Double?
|
var rightVRatio: Double?
|
||||||
|
|
||||||
if let leftResult = PupilDetector.detectPupil(
|
if let leftResult = PupilDetector.detectPupil(
|
||||||
in: pixelBuffer,
|
in: pixelBuffer,
|
||||||
eyeLandmarks: leftEye,
|
eyeLandmarks: leftEye,
|
||||||
@@ -205,10 +239,12 @@ 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(
|
||||||
in: pixelBuffer,
|
in: pixelBuffer,
|
||||||
eyeLandmarks: rightEye,
|
eyeLandmarks: rightEye,
|
||||||
@@ -216,62 +252,79 @@ 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
|
||||||
|
|
||||||
// Track min/max ranges
|
// Track min/max ranges
|
||||||
stats.minH = min(stats.minH, avgH)
|
stats.minH = min(stats.minH, avgH)
|
||||||
stats.maxH = max(stats.maxH, avgH)
|
stats.maxH = max(stats.maxH, avgH)
|
||||||
stats.minV = min(stats.minV, avgV)
|
stats.minV = min(stats.minV, avgV)
|
||||||
stats.maxV = max(stats.maxV, avgV)
|
stats.maxV = max(stats.maxV, avgV)
|
||||||
|
|
||||||
let direction = GazeDirection.from(horizontal: avgH, vertical: avgV)
|
let direction = GazeDirection.from(horizontal: avgH, vertical: avgV)
|
||||||
if direction == .center {
|
if direction == .center {
|
||||||
stats.centerFrames += 1
|
stats.centerFrames += 1
|
||||||
} 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)
|
||||||
|
|
||||||
guard eyeHeight > 0 else { return 0.5 }
|
guard eyeHeight > 0 else { return 0.5 }
|
||||||
|
|
||||||
let ratio = pupilY / eyeHeight
|
let ratio = pupilY / eyeHeight
|
||||||
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)
|
||||||
|
|
||||||
guard eyeWidth > 0 else { return 0.5 }
|
guard eyeWidth > 0 else { return 0.5 }
|
||||||
|
|
||||||
let ratio = pupilX / eyeWidth
|
let ratio = pupilX / eyeWidth
|
||||||
return max(0.0, min(1.0, ratio))
|
return max(0.0, min(1.0, ratio))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user