added ML data acquisition

This commit is contained in:
2026-08-11 23:46:12 +02:00
parent 4f9f6bdb48
commit 8d124a26b1
11 changed files with 120 additions and 7 deletions
+83 -7
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import argparse
import csv
from datetime import datetime
import json
import math
from pathlib import Path
import queue
@@ -13,6 +14,7 @@ import statistics
import threading
import time
import tkinter as tk
from collections import deque
from show_apriltag_surface import SurfaceTagWindow, enable_windows_dpi_awareness, validate_args
from visualize_surface_gaze import GazeSample, SurfaceReceiver
@@ -26,6 +28,8 @@ TARGET_RADIUS = 80
HIT_RADIUS = 140
SAFE_LEFT, SAFE_RIGHT = 260, SCREEN_WIDTH - 260
SAFE_TOP, SAFE_BOTTOM = 180, SCREEN_HEIGHT - 180
DISPERSION_WINDOW = 5
MAX_CONTINUOUS_GAP_S = 0.5
def parse_args() -> argparse.Namespace:
@@ -38,6 +42,10 @@ def parse_args() -> argparse.Namespace:
help="physical screen diagonal in inches (default: 31.5)")
parser.add_argument("--viewing-distance", type=float, default=DEFAULT_VIEWING_DISTANCE_CM,
help="eye-to-screen distance in cm (default: 60)")
parser.add_argument("--subject-id", default="anon",
help="identifier for the person being recorded (default: anon)")
parser.add_argument("--session-tag", default="",
help="free-text label for this setup, e.g. laptop_50cm (default: none)")
parser.add_argument("--rounds", type=int, default=3,
help="number of complete 1-2-3-4 exercise rounds (default: 3)")
parser.add_argument("--animation-fps", type=int, default=120,
@@ -79,6 +87,7 @@ class ExerciseWindow(SurfaceTagWindow):
self.distractor: tuple[float, float] | None = None
self.correct_color = "#39e681"
self.hit_started: float | None = None
self.acquired = False
self.errors: list[float] = []
self.hits = 0
self.samples = 0
@@ -86,18 +95,40 @@ class ExerciseWindow(SurfaceTagWindow):
diagonal_px = math.hypot(SCREEN_WIDTH, SCREEN_HEIGHT)
self.cm_per_px = args.screen_diagonal * 2.54 / diagonal_px
self.history: deque[tuple[float, float, float]] = deque(maxlen=DISPERSION_WINDOW)
self.prev_sample: tuple[float, float, float] | None = None
self.prev_velocity_px_s: float | None = None
results_dir = Path(__file__).resolve().parent / "results"
results_dir.mkdir(exist_ok=True)
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
self.session_id = f"{args.subject_id}_{stamp}"
self.result_path = results_dir / f"gaze_exercises_{stamp}.csv"
self.result_file = self.result_path.open("w", newline="", encoding="utf-8")
self.writer = csv.writer(self.result_file)
self.writer.writerow([
"pupil_timestamp", "round", "block", "trial", "trial_elapsed_s",
"target_x", "target_y", "gaze_x", "gaze_y", "error_px",
"error_cm", "error_deg", "hit"
"subject_id", "session_id", "pupil_timestamp", "round", "block", "trial",
"trial_elapsed_s", "target_x", "target_y", "gaze_x", "gaze_y", "confidence",
"error_px", "error_cm", "error_deg", "hit", "velocity_px_s", "velocity_deg_s",
"accel_px_s2", "dispersion_px", "event_label"
])
meta_path = self.result_path.with_name(self.result_path.stem + "_meta.json")
meta_path.write_text(json.dumps({
"subject_id": args.subject_id,
"session_id": self.session_id,
"session_tag": args.session_tag,
"recorded_at": datetime.now().isoformat(timespec="seconds"),
"screen_diagonal_in": args.screen_diagonal,
"viewing_distance_cm": args.viewing_distance,
"confidence_threshold": args.confidence,
"rounds": args.rounds,
"animation_fps": args.animation_fps,
"screen_width_px": SCREEN_WIDTH,
"screen_height_px": SCREEN_HEIGHT,
"result_csv": self.result_path.name,
}, indent=2), encoding="utf-8")
self.target_item = self.canvas.create_oval(0, 0, 0, 0, state="hidden", tags="exercise")
self.target_center = self.canvas.create_oval(0, 0, 0, 0, state="hidden", tags="exercise")
self.distractor_item = self.canvas.create_oval(0, 0, 0, 0, state="hidden", tags="exercise")
@@ -171,6 +202,9 @@ class ExerciseWindow(SurfaceTagWindow):
self.errors.clear()
self.hits = self.samples = 0
self.block_started = self.trial_started = time.monotonic()
self.history.clear()
self.prev_sample = None
self.prev_velocity_px_s = None
self.new_trial()
def random_target(self) -> tuple[float, float]:
@@ -179,6 +213,7 @@ class ExerciseWindow(SurfaceTagWindow):
def new_trial(self) -> None:
self.trial_started = time.monotonic()
self.hit_started = None
self.acquired = False
self.distractor = None
if self.block == "fixation":
self.target = self.random_target()
@@ -266,11 +301,52 @@ class ExerciseWindow(SurfaceTagWindow):
self.errors.append(error)
self.samples += 1
self.hits += int(hit)
if hit and self.block != "pursuit":
self.acquired = True
velocity_px_s = accel_px_s2 = None
if self.prev_sample is not None:
prev_t, prev_x, prev_y = self.prev_sample
dt = sample.timestamp - prev_t
if 0 < dt <= MAX_CONTINUOUS_GAP_S:
dist_px = math.dist((prev_x, prev_y), gaze)
velocity_px_s = dist_px / dt
if self.prev_velocity_px_s is not None:
accel_px_s2 = (velocity_px_s - self.prev_velocity_px_s) / dt
self.prev_sample = (sample.timestamp, gaze[0], gaze[1])
self.prev_velocity_px_s = velocity_px_s
velocity_deg_s = None
if velocity_px_s is not None:
velocity_cm_s = velocity_px_s * self.cm_per_px
velocity_deg_s = math.degrees(
2.0 * math.atan2(velocity_cm_s / 2.0, self.args.viewing_distance)
)
self.history.append(gaze + (sample.timestamp,))
dispersion_px = None
if len(self.history) >= 2:
xs = [p[0] for p in self.history]
ys = [p[1] for p in self.history]
dispersion_px = (max(xs) - min(xs)) + (max(ys) - min(ys))
if self.block == "pursuit":
event_label = "pursuit"
else:
event_label = "fixation" if self.acquired else "saccade"
self.writer.writerow([
f"{sample.timestamp:.6f}", self.round, self.block, self.trial + 1,
f"{now - self.trial_started:.3f}", f"{self.target[0]:.1f}",
f"{self.target[1]:.1f}", f"{gaze[0]:.1f}", f"{gaze[1]:.1f}",
f"{error:.1f}", f"{error_cm:.3f}", f"{error_deg:.3f}", int(hit)
self.args.subject_id, self.session_id, f"{sample.timestamp:.6f}", self.round,
self.block, self.trial + 1, f"{now - self.trial_started:.3f}",
f"{self.target[0]:.1f}", f"{self.target[1]:.1f}", f"{gaze[0]:.1f}", f"{gaze[1]:.1f}",
f"{sample.confidence:.3f}", f"{error:.1f}", f"{error_cm:.3f}", f"{error_deg:.3f}",
int(hit),
"" if velocity_px_s is None else f"{velocity_px_s:.1f}",
"" if velocity_deg_s is None else f"{velocity_deg_s:.3f}",
"" if accel_px_s2 is None else f"{accel_px_s2:.1f}",
"" if dispersion_px is None else f"{dispersion_px:.1f}",
event_label,
])
def tick(self) -> None: