From 8d124a26b19305a5e8bce1182752f8868b24d458 Mon Sep 17 00:00:00 2001 From: Vysyr Date: Tue, 11 Aug 2026 23:46:12 +0200 Subject: [PATCH] added ML data acquisition --- README.md | 37 ++++++++ gaze_exercises.py | 90 +++++++++++++++++-- .../gaze_exercises_20260712_181051.csv | 0 .../gaze_exercises_20260712_181220.csv | 0 .../gaze_exercises_20260712_181828.csv | 0 ...ze_exercises_20260712_181828_dashboard.png | 0 .../gaze_exercises_20260713_205454.csv | 0 ...ze_exercises_20260713_205454_dashboard.png | 0 .../gaze_exercises_20260713_211057.csv | 0 .../gaze_exercises_20260713_211323.csv | 0 ...ze_exercises_20260713_211323_dashboard.png | 0 11 files changed, 120 insertions(+), 7 deletions(-) rename {results => results_old}/gaze_exercises_20260712_181051.csv (100%) rename {results => results_old}/gaze_exercises_20260712_181220.csv (100%) rename {results => results_old}/gaze_exercises_20260712_181828.csv (100%) rename {results => results_old}/gaze_exercises_20260712_181828_dashboard.png (100%) rename {results => results_old}/gaze_exercises_20260713_205454.csv (100%) rename {results => results_old}/gaze_exercises_20260713_205454_dashboard.png (100%) rename {results => results_old}/gaze_exercises_20260713_211057.csv (100%) rename {results => results_old}/gaze_exercises_20260713_211323.csv (100%) rename {results => results_old}/gaze_exercises_20260713_211323_dashboard.png (100%) diff --git a/README.md b/README.md index 11e8bc1..608b948 100644 --- a/README.md +++ b/README.md @@ -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_.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 diff --git a/gaze_exercises.py b/gaze_exercises.py index bd1c61e..20688d8 100644 --- a/gaze_exercises.py +++ b/gaze_exercises.py @@ -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: diff --git a/results/gaze_exercises_20260712_181051.csv b/results_old/gaze_exercises_20260712_181051.csv similarity index 100% rename from results/gaze_exercises_20260712_181051.csv rename to results_old/gaze_exercises_20260712_181051.csv diff --git a/results/gaze_exercises_20260712_181220.csv b/results_old/gaze_exercises_20260712_181220.csv similarity index 100% rename from results/gaze_exercises_20260712_181220.csv rename to results_old/gaze_exercises_20260712_181220.csv diff --git a/results/gaze_exercises_20260712_181828.csv b/results_old/gaze_exercises_20260712_181828.csv similarity index 100% rename from results/gaze_exercises_20260712_181828.csv rename to results_old/gaze_exercises_20260712_181828.csv diff --git a/results/gaze_exercises_20260712_181828_dashboard.png b/results_old/gaze_exercises_20260712_181828_dashboard.png similarity index 100% rename from results/gaze_exercises_20260712_181828_dashboard.png rename to results_old/gaze_exercises_20260712_181828_dashboard.png diff --git a/results/gaze_exercises_20260713_205454.csv b/results_old/gaze_exercises_20260713_205454.csv similarity index 100% rename from results/gaze_exercises_20260713_205454.csv rename to results_old/gaze_exercises_20260713_205454.csv diff --git a/results/gaze_exercises_20260713_205454_dashboard.png b/results_old/gaze_exercises_20260713_205454_dashboard.png similarity index 100% rename from results/gaze_exercises_20260713_205454_dashboard.png rename to results_old/gaze_exercises_20260713_205454_dashboard.png diff --git a/results/gaze_exercises_20260713_211057.csv b/results_old/gaze_exercises_20260713_211057.csv similarity index 100% rename from results/gaze_exercises_20260713_211057.csv rename to results_old/gaze_exercises_20260713_211057.csv diff --git a/results/gaze_exercises_20260713_211323.csv b/results_old/gaze_exercises_20260713_211323.csv similarity index 100% rename from results/gaze_exercises_20260713_211323.csv rename to results_old/gaze_exercises_20260713_211323.csv diff --git a/results/gaze_exercises_20260713_211323_dashboard.png b/results_old/gaze_exercises_20260713_211323_dashboard.png similarity index 100% rename from results/gaze_exercises_20260713_211323_dashboard.png rename to results_old/gaze_exercises_20260713_211323_dashboard.png