added ML data acquisition
This commit is contained in:
@@ -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
@@ -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.
|
Reference in New Issue
Block a user