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
+37
View File
@@ -89,6 +89,43 @@ median or smoothing filter is applied, and every received sample that meets the
the target, gaze coordinates, pixel error, and whether gaze was on target. During
every color trial, green is the target and red is the distractor.
### Data collection for ML-based adaptive filtering
Each run writes `results/gaze_exercises_<timestamp>.csv` plus a sidecar
`..._meta.json` recording subject, session, screen, and distance metadata. Tag
every recording with `--subject-id` and, when varying setup, `--session-tag`:
```powershell
python gaze_exercises.py --subject-id you --session-tag laptop_50cm --screen-diagonal 15.6 --viewing-distance 50
python gaze_exercises.py --subject-id you --session-tag monitor_90cm --screen-diagonal 27 --viewing-distance 90
```
`--screen-diagonal` (inches) and `--viewing-distance` (cm) must match the
physical setup for that recording — they drive the pixel-to-degree conversion
used for both the logged error and velocity columns, so an incorrect value
silently skews every derived feature in that session.
CSV columns beyond the basic error metrics:
- `confidence` — Pupil Capture's per-sample confidence (already thresholded
by `--confidence`, but the value itself is kept for weighting/filtering).
- `velocity_px_s`, `velocity_deg_s` — instantaneous gaze speed between
consecutive samples; blank when the gap since the previous sample exceeds
0.5s (block/trial boundaries).
- `accel_px_s2` — change in `velocity_px_s` between consecutive samples.
- `dispersion_px` — spread (bounding-box width + height) of the last 5 raw
samples; low during fixation/pursuit, spikes during saccades.
- `event_label` — coarse ground truth derived from task design: `saccade`
until gaze first lands within the hit radius of the trial's target, then
`fixation` (or always `pursuit` during the pursuit block). This is a block
design label, not a precise per-sample velocity-threshold classification —
refine saccade on/offset from `velocity_deg_s` during post-processing if
the paper needs tighter boundaries.
`subject_id` and `session_id` (subject + recording timestamp) are included on
every row so CSVs from different subjects, screens, and distances can be
concatenated directly for training/evaluation.
Visualize the newest result file with:
```powershell
+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:
Can't render this file because it is too large.
Can't render this file because it is too large.
Can't render this file because it is too large.