monitor selection, model prototype and 2 more recordings

This commit is contained in:
2026-08-12 16:48:44 +02:00
parent 8d124a26b1
commit 6a13b1ef04
17 changed files with 214117 additions and 25 deletions
+62
View File
@@ -18,6 +18,19 @@ Press `Esc` or `Q` to close it. For a preview in a normal resizable window:
python show_apriltag_surface.py --windowed
```
On a multi-monitor setup, list detected displays (Windows display number,
resolution, position, physical diagonal) and target one of them:
```powershell
python show_apriltag_surface.py --list-monitors
python show_apriltag_surface.py --monitor 2
```
`gaze_exercises.py` accepts the same `--monitor`/`--list-monitors` flags and
uses the selected monitor's resolution and EDID-reported diagonal
automatically, so `--screen-diagonal` only needs to be passed to override an
inaccurate or missing EDID value.
By default, every marker is flush with both adjacent screen edges. Useful
options include `--tag-size 240`, `--margin 32`, and
`--tag-ids 10 11 12 13`. The requested size is rounded down to a multiple of
@@ -126,6 +139,55 @@ CSV columns beyond the basic error metrics:
every row so CSVs from different subjects, screens, and distances can be
concatenated directly for training/evaluation.
## Training the adaptive filter
Once you have one or more recordings in `results/`:
```powershell
python train_filter_model.py
```
This concatenates every `gaze_exercises_*.csv`, builds causal windowed
features (`gaze_features.py`), splits by whole trial (never by row, to avoid
leaking adjacent-in-time samples between train/test), trains a
`RandomForestClassifier` predicting `event_label`, and compares it against a
fixed 30 deg/s velocity-threshold baseline. It prints per-class precision/
recall, a confusion matrix, a saccade-recovery detection-latency comparison,
and feature importances, then saves `models/filter_model.joblib` +
`models/filter_model_meta.json`.
With only one subject/session recorded, the split holds out unseen trials
within that session — it does not yet test cross-subject generalization.
Once more subjects are recorded, re-run training; group the split by
`subject_id` (leave-one-subject-out) instead of by trial for the real paper
evaluation.
`adaptive_filter.py` defines the real-time filters built on that model:
`BaselineFilter` (the existing fixed median+EMA filter), and
`MLAdaptiveFilter` in `mode="hard"` (discrete fixation/saccade/pursuit
switching that discards history on a detected saccade) or `mode="soft"`
(continuous smoothing strength scaled by predicted saccade probability).
## Held-out evaluation: static fixation
`gaze_exercises.py` is what the model trains on, so it can't validate
generalization by itself. `eval_static_fixation.py` runs a task shape the
model has never seen — one unmoving target held for a long duration — and
reports RMS error, mean error, and on-target accuracy % for raw gaze, the
fixed baseline filter, and both ML-adaptive modes side by side:
```powershell
python eval_static_fixation.py --subject-id you --duration 60 --viewing-distance 70 --screen-diagonal 31.5
```
Press Space to start the 60-second hold. Results are saved to
`results/eval_static_fixation_<timestamp>.csv` plus a `_meta.json` summary
report. If `models/filter_model.joblib` doesn't exist yet, it records raw +
baseline only and prints a warning — run `train_filter_model.py` first for
the ML columns. A simplified eyes-as-aim game is a planned second held-out
evaluation task, to test generalization to fast target-acquisition demands
closer to real use.
Visualize the newest result file with:
```powershell
+132
View File
@@ -0,0 +1,132 @@
"""Real-time gaze filters: the existing fixed median+EMA baseline, and two
ML-driven variants (hard state-switch, continuous confidence-weighted) built
on the classifier trained by train_filter_model.py.
"""
from __future__ import annotations
import json
import statistics
from collections import deque
from pathlib import Path
import joblib
from gaze_features import FEATURE_COLUMNS, OnlineFeatureTracker
FIXATION_ALPHA = 0.15
PURSUIT_ALPHA = 0.35
SACCADE_ALPHA = 0.9
SOFT_MIN_ALPHA = 0.15
SOFT_MAX_ALPHA = 0.9
MEDIAN_WINDOW = 5
class BaselineFilter:
"""Fixed median + EMA + dead-zone filter, matching visualize_surface_gaze.py."""
def __init__(self, smoothing: float = 0.18, median_window: int = 5, dead_zone: float = 3.0) -> None:
self.smoothing = smoothing
self.dead_zone = dead_zone
self.x_history: deque[float] = deque(maxlen=median_window)
self.y_history: deque[float] = deque(maxlen=median_window)
self.filtered: tuple[float, float] | None = None
def reset(self) -> None:
self.x_history.clear()
self.y_history.clear()
self.filtered = None
def update(self, timestamp: float, x: float, y: float, confidence: float) -> tuple[float, float]:
self.x_history.append(x)
self.y_history.append(y)
median_x = statistics.median(self.x_history)
median_y = statistics.median(self.y_history)
if self.filtered is None:
self.filtered = (median_x, median_y)
else:
prev_x, prev_y = self.filtered
fx = prev_x + self.smoothing * (median_x - prev_x)
fy = prev_y + self.smoothing * (median_y - prev_y)
if abs(fx - prev_x) < self.dead_zone:
fx = prev_x
if abs(fy - prev_y) < self.dead_zone:
fy = prev_y
self.filtered = (fx, fy)
return self.filtered
class MLAdaptiveFilter:
"""Classifier-driven filter. mode="hard" switches behavior by predicted class;
mode="soft" blends smoothing strength continuously with P(saccade)."""
def __init__(
self,
model_path: str | Path,
cm_per_px: float,
viewing_distance_cm: float,
mode: str = "soft",
) -> None:
if mode not in ("hard", "soft"):
raise ValueError("mode must be 'hard' or 'soft'")
self.mode = mode
self.model = joblib.load(model_path)
meta_path = Path(model_path).with_name(Path(model_path).stem + "_meta.json")
self.meta = json.loads(meta_path.read_text(encoding="utf-8")) if meta_path.exists() else {}
self.saccade_index = list(self.model.classes_).index("saccade")
self.tracker = OnlineFeatureTracker(cm_per_px, viewing_distance_cm)
self.x_history: deque[float] = deque(maxlen=MEDIAN_WINDOW)
self.y_history: deque[float] = deque(maxlen=MEDIAN_WINDOW)
self.filtered: tuple[float, float] | None = None
self.last_label: str | None = None
self.last_prob_saccade: float | None = None
def reset(self) -> None:
self.tracker.reset()
self.x_history.clear()
self.y_history.clear()
self.filtered = None
self.last_label = None
self.last_prob_saccade = None
def update(self, timestamp: float, x: float, y: float, confidence: float) -> tuple[float, float]:
features = self.tracker.update(timestamp, x, y, confidence)
if features is None:
# Not enough history yet for a prediction; pass raw through as median-of-one.
self.x_history.append(x)
self.y_history.append(y)
self.filtered = (x, y)
return self.filtered
row = [[features[c] for c in FEATURE_COLUMNS]]
proba = self.model.predict_proba(row)[0]
prob_saccade = float(proba[self.saccade_index])
label = self.model.classes_[proba.argmax()]
self.last_label = label
self.last_prob_saccade = prob_saccade
if self.mode == "hard":
if label == "saccade":
self.x_history.clear()
self.y_history.clear()
self.x_history.append(x)
self.y_history.append(y)
self.filtered = (x, y)
return self.filtered
alpha = FIXATION_ALPHA if label == "fixation" else PURSUIT_ALPHA
else:
alpha = SOFT_MIN_ALPHA + prob_saccade * (SOFT_MAX_ALPHA - SOFT_MIN_ALPHA)
self.x_history.append(x)
self.y_history.append(y)
median_x = statistics.median(self.x_history)
median_y = statistics.median(self.y_history)
if self.filtered is None:
self.filtered = (median_x, median_y)
else:
prev_x, prev_y = self.filtered
fx = prev_x + alpha * (median_x - prev_x)
fy = prev_y + alpha * (median_y - prev_y)
self.filtered = (fx, fy)
return self.filtered
+276
View File
@@ -0,0 +1,276 @@
"""Held-out evaluation exercise: stare at one static point and compare filters.
Unlike gaze_exercises.py (used to collect training data), this task never
appears in training: a single unmoving target held for a long duration. It
measures whether the trained classifier's behavior generalizes to a task
shape it has never seen, which is the actual claim an adaptive filter needs
to support. Run after train_filter_model.py has produced models/filter_model.joblib.
"""
from __future__ import annotations
import argparse
import csv
import json
import math
import queue
import statistics
import threading
import time
import tkinter as tk
from datetime import datetime
from pathlib import Path
from show_apriltag_surface import SurfaceTagWindow, enable_windows_dpi_awareness, validate_args
from visualize_surface_gaze import GazeSample, SurfaceReceiver
from adaptive_filter import BaselineFilter, MLAdaptiveFilter
SCREEN_WIDTH = 2560
SCREEN_HEIGHT = 1440
SCREEN_DIAGONAL_IN = 31.5
DEFAULT_VIEWING_DISTANCE_CM = 60.0
TARGET_RADIUS = 40
METHODS = ("raw", "baseline", "ml_hard", "ml_soft")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Static-fixation held-out evaluation: raw vs. fixed vs. ML-adaptive filters."
)
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=50020)
parser.add_argument("--surface", default="screen")
parser.add_argument("--confidence", type=float, default=0.9)
parser.add_argument("--screen-diagonal", type=float, default=SCREEN_DIAGONAL_IN)
parser.add_argument("--viewing-distance", type=float, default=DEFAULT_VIEWING_DISTANCE_CM)
parser.add_argument("--subject-id", default="anon")
parser.add_argument("--session-tag", default="")
parser.add_argument("--duration", type=float, default=60.0,
help="seconds to hold the static target (default: 60)")
parser.add_argument("--accuracy-radius", type=float, default=60.0,
help="pixel radius counted as 'on target' for accuracy % (default: 60)")
parser.add_argument("--model", default="models/filter_model.joblib",
help="path to trained classifier; omit ML columns if not found")
parser.add_argument("--show", choices=METHODS, default="ml_soft",
help="which filtered stream to display as the live cursor (default: ml_soft)")
parser.add_argument("--cursor", action="store_true", help="show the live cursor (hidden by default)")
parser.add_argument("--tag-ids", nargs=4, type=int, default=(0, 1, 2, 3))
parser.add_argument("--tag-size", type=int, default=200)
parser.add_argument("--margin", type=int, default=0)
parser.add_argument("--background", default="#10141c")
parser.add_argument("--windowed", action="store_true")
return parser.parse_args()
class StaticFixationWindow(SurfaceTagWindow):
def __init__(self, root: tk.Tk, args: argparse.Namespace) -> None:
super().__init__(root, args)
self.args = args
self.messages: queue.Queue = queue.Queue(maxsize=256)
self.stop_event = threading.Event()
self.receiver = SurfaceReceiver(
self.messages, self.stop_event, args.host, args.port, args.surface, args.confidence
)
self.receiver.start()
self.target = (SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2)
diagonal_px = math.hypot(SCREEN_WIDTH, SCREEN_HEIGHT)
self.cm_per_px = args.screen_diagonal * 2.54 / diagonal_px
self.filters = {"raw": None, "baseline": BaselineFilter()}
self.model_path = Path(args.model)
if self.model_path.exists():
self.filters["ml_hard"] = MLAdaptiveFilter(
self.model_path, self.cm_per_px, args.viewing_distance, mode="hard"
)
self.filters["ml_soft"] = MLAdaptiveFilter(
self.model_path, self.cm_per_px, args.viewing_distance, mode="soft"
)
else:
print(f"warning: model not found at {self.model_path}; "
"recording raw + baseline only (train_filter_model.py first for ML columns)")
self.errors: dict[str, list[float]] = {m: [] for m in METHODS if self._active(m)}
self.started_at: 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.result_path = results_dir / f"eval_static_fixation_{stamp}.csv"
self.result_file = self.result_path.open("w", newline="", encoding="utf-8")
self.writer = csv.writer(self.result_file)
header = ["subject_id", "pupil_timestamp", "elapsed_s", "target_x", "target_y", "confidence"]
for method in METHODS:
if self._active(method):
header += [f"{method}_x", f"{method}_y", f"{method}_error_px"]
self.writer.writerow(header)
self.target_item = self.canvas.create_oval(0, 0, 0, 0, state="hidden", tags="exercise")
self.gaze_item = self.canvas.create_oval(
0, 0, 0, 0, fill="#ffd43b", outline="white", width=2, state="hidden", tags="exercise"
)
self.heading = self.canvas.create_text(
0, 70, fill="white", font=("Segoe UI", 25, "bold"), tags="exercise"
)
self.instructions = self.canvas.create_text(
0, 125, fill="#d7deea", font=("Segoe UI", 16), justify="center", tags="exercise"
)
root.bind("<space>", self.on_space)
root.protocol("WM_DELETE_WINDOW", self.close)
self.show_welcome()
root.after(8, self.tick)
def _active(self, method: str) -> bool:
return method == "raw" or method in self.filters
def sx(self, x: float) -> float:
return x / SCREEN_WIDTH * self.canvas.winfo_width()
def sy(self, y: float) -> float:
return y / SCREEN_HEIGHT * self.canvas.winfo_height()
def draw(self, event: tk.Event) -> None:
super().draw(event)
self.canvas.coords(self.heading, event.width / 2, 65)
self.canvas.coords(self.instructions, event.width / 2, 115)
self.position_target()
self.canvas.tag_raise("exercise")
def position_target(self) -> None:
x, y = self.sx(self.target[0]), self.sy(self.target[1])
rx = TARGET_RADIUS / SCREEN_WIDTH * self.canvas.winfo_width()
ry = TARGET_RADIUS / SCREEN_HEIGHT * self.canvas.winfo_height()
self.canvas.coords(self.target_item, x - rx, y - ry, x + rx, y + ry)
self.canvas.itemconfigure(self.target_item, fill="#55aaff", outline="white", width=3, state="normal")
def show_welcome(self) -> None:
self.canvas.itemconfigure(self.heading, text="Static fixation evaluation")
self.canvas.itemconfigure(
self.instructions,
text=(f"Stare at the single dot for {self.args.duration:.0f} seconds.\n"
"Sit comfortably, keep your head natural, then press SPACE."),
)
def on_space(self, _event: tk.Event | None = None) -> None:
if self.started_at is None:
self.started_at = time.monotonic()
self.position_target()
self.set_text("Hold your gaze on the dot", "")
def set_text(self, heading: str, instructions: str) -> None:
self.canvas.itemconfigure(self.heading, text=heading)
self.canvas.itemconfigure(self.instructions, text=instructions)
def close(self, _event: tk.Event | None = None) -> None:
self.stop_event.set()
if not self.result_file.closed:
self.result_file.close()
super().close(_event)
def record_sample(self, sample: GazeSample, gaze: tuple[float, float], elapsed: float) -> None:
row = [self.args.subject_id, f"{sample.timestamp:.6f}", f"{elapsed:.3f}",
f"{self.target[0]:.1f}", f"{self.target[1]:.1f}", f"{sample.confidence:.3f}"]
display_pos = gaze
for method in METHODS:
if not self._active(method):
continue
if method == "raw":
pos = gaze
else:
pos = self.filters[method].update(sample.timestamp, gaze[0], gaze[1], sample.confidence)
error = math.dist(pos, self.target)
self.errors[method].append(error)
row += [f"{pos[0]:.1f}", f"{pos[1]:.1f}", f"{error:.1f}"]
if method == self.args.show:
display_pos = pos
self.writer.writerow(row)
self.position_circle(self.gaze_item, display_pos, 14)
if self.args.cursor:
self.canvas.itemconfigure(self.gaze_item, state="normal")
def position_circle(self, item: int, point: tuple[float, float], radius: int) -> None:
x, y = self.sx(point[0]), self.sy(point[1])
rx = radius / SCREEN_WIDTH * self.canvas.winfo_width()
ry = radius / SCREEN_HEIGHT * self.canvas.winfo_height()
self.canvas.coords(item, x - rx, y - ry, x + rx, y + ry)
def tick(self) -> None:
now = time.monotonic()
try:
while True:
kind, value = self.messages.get_nowait()
if kind == "gaze" and self.started_at is not None:
sample: GazeSample = value # type: ignore[assignment]
gaze = (sample.x_norm * SCREEN_WIDTH, (1.0 - sample.y_norm) * SCREEN_HEIGHT)
self.record_sample(sample, gaze, now - self.started_at)
except queue.Empty:
pass
if self.started_at is not None and now - self.started_at >= self.args.duration:
self.finish()
return
if self.root.winfo_exists():
self.root.after(8, self.tick)
def finish(self) -> None:
self.result_file.flush()
self.result_file.close()
report = {}
lines = []
for method in METHODS:
if not self._active(method):
continue
errs = self.errors[method]
if not errs:
continue
rms = math.sqrt(sum(e * e for e in errs) / len(errs))
mean = statistics.fmean(errs)
on_target = sum(1 for e in errs if e <= self.args.accuracy_radius) / len(errs) * 100
report[method] = {"rms_px": rms, "mean_px": mean, "accuracy_pct": on_target, "n": len(errs)}
lines.append(f"{method:<9} rms {rms:6.1f}px mean {mean:6.1f}px "
f"accuracy {on_target:5.1f}% (<= {self.args.accuracy_radius:.0f}px)")
meta_path = self.result_path.with_name(self.result_path.stem + "_meta.json")
meta_path.write_text(json.dumps({
"subject_id": self.args.subject_id,
"session_tag": self.args.session_tag,
"recorded_at": datetime.now().isoformat(timespec="seconds"),
"duration_s": self.args.duration,
"screen_diagonal_in": self.args.screen_diagonal,
"viewing_distance_cm": self.args.viewing_distance,
"accuracy_radius_px": self.args.accuracy_radius,
"model": str(self.model_path) if self.model_path.exists() else None,
"result_csv": self.result_path.name,
"report": report,
}, indent=2), encoding="utf-8")
summary = "\n".join(lines)
print(summary)
self.set_text("Done", summary + f"\n\nSaved to:\n{self.result_path}\n\nPress Esc to close")
self.canvas.itemconfigure(self.target_item, state="hidden")
self.stop_event.set()
def main() -> None:
args = parse_args()
validate_args(args)
if not 0 <= args.confidence <= 1:
raise SystemExit("error: --confidence must be between 0 and 1")
if args.screen_diagonal <= 0:
raise SystemExit("error: --screen-diagonal must be greater than 0")
if args.viewing_distance <= 0:
raise SystemExit("error: --viewing-distance must be greater than 0")
if args.duration <= 0:
raise SystemExit("error: --duration must be greater than 0")
enable_windows_dpi_awareness()
root = tk.Tk()
StaticFixationWindow(root, args)
root.mainloop()
if __name__ == "__main__":
main()
+46 -23
View File
@@ -16,18 +16,16 @@ import time
import tkinter as tk
from collections import deque
from monitor_utils import format_monitor_list
from show_apriltag_surface import SurfaceTagWindow, enable_windows_dpi_awareness, validate_args
from visualize_surface_gaze import GazeSample, SurfaceReceiver
SCREEN_WIDTH = 2560
SCREEN_HEIGHT = 1440
SCREEN_DIAGONAL_IN = 31.5
DEFAULT_VIEWING_DISTANCE_CM = 60.0
TARGET_RADIUS = 80
HIT_RADIUS = 140
SAFE_LEFT, SAFE_RIGHT = 260, SCREEN_WIDTH - 260
SAFE_TOP, SAFE_BOTTOM = 180, SCREEN_HEIGHT - 180
SAFE_MARGIN_X = 260
SAFE_MARGIN_Y = 180
DISPERSION_WINDOW = 5
MAX_CONTINUOUS_GAP_S = 0.5
@@ -38,8 +36,9 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--port", type=int, default=50020)
parser.add_argument("--surface", default="screen")
parser.add_argument("--confidence", type=float, default=0.9)
parser.add_argument("--screen-diagonal", type=float, default=SCREEN_DIAGONAL_IN,
help="physical screen diagonal in inches (default: 31.5)")
parser.add_argument("--screen-diagonal", type=float, default=None,
help="physical screen diagonal in inches "
"(default: auto-detected from the selected monitor)")
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",
@@ -57,6 +56,11 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--margin", type=int, default=0)
parser.add_argument("--background", default="#10141c")
parser.add_argument("--windowed", action="store_true")
parser.add_argument("--monitor", type=int, default=None,
help="Windows display number to use, e.g. 2 for \\\\.\\DISPLAY2 "
"(default: primary)")
parser.add_argument("--list-monitors", action="store_true",
help="print detected monitors (id, resolution, position, diagonal) and exit")
return parser.parse_args()
@@ -66,6 +70,21 @@ class ExerciseWindow(SurfaceTagWindow):
def __init__(self, root: tk.Tk, args: argparse.Namespace) -> None:
super().__init__(root, args)
self.args = args
self.width = self.monitor.width
self.height = self.monitor.height
self.safe_left, self.safe_right = SAFE_MARGIN_X, self.width - SAFE_MARGIN_X
self.safe_top, self.safe_bottom = SAFE_MARGIN_Y, self.height - SAFE_MARGIN_Y
self.pursuit_amp_x = min(760, self.width / 2 - SAFE_MARGIN_X)
self.pursuit_amp_y = min(390, self.height / 2 - SAFE_MARGIN_Y)
if args.screen_diagonal is not None:
self.screen_diagonal = args.screen_diagonal
elif self.monitor.diagonal_in is not None:
self.screen_diagonal = self.monitor.diagonal_in
else:
raise SystemExit(
"error: could not auto-detect the diagonal for this monitor; "
"pass --screen-diagonal explicitly"
)
self.frame_interval_ms = max(1, round(1000 / args.animation_fps))
self.messages: queue.Queue = queue.Queue(maxsize=256)
self.stop_event = threading.Event()
@@ -83,7 +102,7 @@ class ExerciseWindow(SurfaceTagWindow):
self.trial = 0
self.trial_started = 0.0
self.block_started = 0.0
self.target = (SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2)
self.target = (self.width / 2, self.height / 2)
self.distractor: tuple[float, float] | None = None
self.correct_color = "#39e681"
self.hit_started: float | None = None
@@ -92,8 +111,8 @@ class ExerciseWindow(SurfaceTagWindow):
self.hits = 0
self.samples = 0
self.summary_lines: list[str] = []
diagonal_px = math.hypot(SCREEN_WIDTH, SCREEN_HEIGHT)
self.cm_per_px = args.screen_diagonal * 2.54 / diagonal_px
diagonal_px = math.hypot(self.width, self.height)
self.cm_per_px = self.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
@@ -119,13 +138,14 @@ class ExerciseWindow(SurfaceTagWindow):
"session_id": self.session_id,
"session_tag": args.session_tag,
"recorded_at": datetime.now().isoformat(timespec="seconds"),
"screen_diagonal_in": args.screen_diagonal,
"monitor_id": self.monitor.display_id,
"screen_diagonal_in": self.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,
"screen_width_px": self.width,
"screen_height_px": self.height,
"result_csv": self.result_path.name,
}, indent=2), encoding="utf-8")
@@ -152,10 +172,10 @@ class ExerciseWindow(SurfaceTagWindow):
root.after(self.frame_interval_ms, self.tick)
def sx(self, x: float) -> float:
return x / SCREEN_WIDTH * self.canvas.winfo_width()
return x / self.width * self.canvas.winfo_width()
def sy(self, y: float) -> float:
return y / SCREEN_HEIGHT * self.canvas.winfo_height()
return y / self.height * self.canvas.winfo_height()
def draw(self, event: tk.Event) -> None:
super().draw(event)
@@ -208,7 +228,7 @@ class ExerciseWindow(SurfaceTagWindow):
self.new_trial()
def random_target(self) -> tuple[float, float]:
return self.rng.uniform(SAFE_LEFT, SAFE_RIGHT), self.rng.uniform(SAFE_TOP, SAFE_BOTTOM)
return self.rng.uniform(self.safe_left, self.safe_right), self.rng.uniform(self.safe_top, self.safe_bottom)
def new_trial(self) -> None:
self.trial_started = time.monotonic()
@@ -246,8 +266,8 @@ class ExerciseWindow(SurfaceTagWindow):
def position_circle(self, item: int, point: tuple[float, float], radius: int) -> None:
x, y = self.sx(point[0]), self.sy(point[1])
rx = radius / SCREEN_WIDTH * self.canvas.winfo_width()
ry = radius / SCREEN_HEIGHT * self.canvas.winfo_height()
rx = radius / self.width * self.canvas.winfo_width()
ry = radius / self.height * self.canvas.winfo_height()
self.canvas.coords(item, x - rx, y - ry, x + rx, y + ry)
def position_shapes(self) -> None:
@@ -273,8 +293,8 @@ class ExerciseWindow(SurfaceTagWindow):
if kind == "gaze":
sample: GazeSample = value # type: ignore[assignment]
gaze = (
sample.x_norm * SCREEN_WIDTH,
(1.0 - sample.y_norm) * SCREEN_HEIGHT,
sample.x_norm * self.width,
(1.0 - sample.y_norm) * self.height,
)
samples.append((sample, gaze))
else:
@@ -357,8 +377,8 @@ class ExerciseWindow(SurfaceTagWindow):
if self.block == "pursuit":
phase = elapsed / 10.0 * math.tau
self.target = (
SCREEN_WIDTH / 2 + 760 * math.sin(phase),
SCREEN_HEIGHT / 2 + 390 * math.sin(phase * 2),
self.width / 2 + self.pursuit_amp_x * math.sin(phase),
self.height / 2 + self.pursuit_amp_y * math.sin(phase * 2),
)
self.position_shapes()
for sample, gaze in gaze_samples:
@@ -414,10 +434,13 @@ class ExerciseWindow(SurfaceTagWindow):
def main() -> None:
args = parse_args()
if args.list_monitors:
print(format_monitor_list())
return
validate_args(args)
if not 0 <= args.confidence <= 1:
raise SystemExit("error: --confidence must be between 0 and 1")
if args.screen_diagonal <= 0:
if args.screen_diagonal is not None and args.screen_diagonal <= 0:
raise SystemExit("error: --screen-diagonal must be greater than 0")
if args.viewing_distance <= 0:
raise SystemExit("error: --viewing-distance must be greater than 0")
+122
View File
@@ -0,0 +1,122 @@
"""Causal feature computation shared by offline training and the real-time filter.
Both `train_filter_model.py` (batch, pandas) and `adaptive_filter.py` (online,
per-sample) must derive identical features from identical inputs, or a model
trained offline will not behave the same way when driven by live samples.
This module is the single source of truth for that feature set.
"""
from __future__ import annotations
import math
from collections import deque
FEATURE_WINDOW = 5
MAX_CONTINUOUS_GAP_S = 0.5
FEATURE_COLUMNS = [
"velocity_deg_s",
"accel_px_s2",
"dispersion_px",
"confidence",
"velocity_deg_s_mean",
"velocity_deg_s_std",
"dispersion_px_mean",
]
EVENT_LABELS = ("fixation", "saccade", "pursuit")
def add_rolling_features(df, session_col: str = "session_id"):
"""Add the *_mean / *_std rolling columns to a DataFrame in place, returning it.
Rolling windows are computed per session so one recording's trailing
samples never leak into the next. min_periods=1 keeps the first few
samples of a session usable (with a noisier estimate) instead of NaN.
"""
grouped = df.groupby(session_col, sort=False)
roll = grouped["velocity_deg_s"].rolling(FEATURE_WINDOW, min_periods=1)
df["velocity_deg_s_mean"] = roll.mean().reset_index(level=0, drop=True)
df["velocity_deg_s_std"] = roll.std().reset_index(level=0, drop=True).fillna(0.0)
df["dispersion_px_mean"] = (
grouped["dispersion_px"].rolling(FEATURE_WINDOW, min_periods=1)
.mean().reset_index(level=0, drop=True)
)
return df
class OnlineFeatureTracker:
"""Incrementally computes the same features as add_rolling_features, one raw sample at a time.
Mirrors the instantaneous velocity/accel/dispersion logic in
gaze_exercises.py's record_sample so a live filter sees the exact
features the model was trained on.
"""
def __init__(self, cm_per_px: float, viewing_distance_cm: float) -> None:
self.cm_per_px = cm_per_px
self.viewing_distance_cm = viewing_distance_cm
self.history: deque[tuple[float, float, float]] = deque(maxlen=FEATURE_WINDOW)
self.velocity_history: deque[float] = deque(maxlen=FEATURE_WINDOW)
self.dispersion_history: deque[float] = deque(maxlen=FEATURE_WINDOW)
self.prev_sample: tuple[float, float, float] | None = None
self.prev_velocity_px_s: float | None = None
def reset(self) -> None:
self.history.clear()
self.velocity_history.clear()
self.dispersion_history.clear()
self.prev_sample = None
self.prev_velocity_px_s = None
def update(self, timestamp: float, x: float, y: float, confidence: float) -> dict | None:
velocity_px_s = accel_px_s2 = None
if self.prev_sample is not None:
prev_t, prev_x, prev_y = self.prev_sample
dt = timestamp - prev_t
if 0 < dt <= MAX_CONTINUOUS_GAP_S:
dist_px = math.dist((prev_x, prev_y), (x, y))
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 = (timestamp, x, y)
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.viewing_distance_cm)
)
self.history.append((x, y, 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 velocity_deg_s is None or dispersion_px is None:
return None
self.velocity_history.append(velocity_deg_s)
self.dispersion_history.append(dispersion_px)
n = len(self.velocity_history)
velocity_deg_s_mean = sum(self.velocity_history) / n
if n >= 2:
variance = sum((v - velocity_deg_s_mean) ** 2 for v in self.velocity_history) / n
velocity_deg_s_std = math.sqrt(variance)
else:
velocity_deg_s_std = 0.0
dispersion_px_mean = sum(self.dispersion_history) / len(self.dispersion_history)
return {
"velocity_deg_s": velocity_deg_s,
"accel_px_s2": accel_px_s2 if accel_px_s2 is not None else 0.0,
"dispersion_px": dispersion_px,
"confidence": confidence,
"velocity_deg_s_mean": velocity_deg_s_mean,
"velocity_deg_s_std": velocity_deg_s_std,
"dispersion_px_mean": dispersion_px_mean,
}
Binary file not shown.
+22
View File
@@ -0,0 +1,22 @@
{
"feature_columns": [
"velocity_deg_s",
"accel_px_s2",
"dispersion_px",
"confidence",
"velocity_deg_s_mean",
"velocity_deg_s_std",
"dispersion_px_mean"
],
"event_labels": [
"fixation",
"pursuit",
"saccade"
],
"baseline_velocity_threshold_deg_s": 30.0,
"trained_on_sessions": [
"PS_20260812_092731"
],
"train_rows": 48979,
"test_rows": 17636
}
+64
View File
@@ -0,0 +1,64 @@
"""Detect connected monitors and resolve which one a window should target."""
from __future__ import annotations
import math
import re
from dataclasses import dataclass
from screeninfo import get_monitors
@dataclass(frozen=True)
class MonitorGeometry:
display_id: int
x: int
y: int
width: int
height: int
diagonal_in: float | None
is_primary: bool
def _display_number(name: str) -> int | None:
match = re.search(r"DISPLAY(\d+)", name, re.IGNORECASE)
return int(match.group(1)) if match else None
def list_monitors() -> list[MonitorGeometry]:
monitors = []
for m in get_monitors():
diagonal_in = None
if m.width_mm and m.height_mm:
diagonal_in = math.hypot(m.width_mm, m.height_mm) / 25.4
monitors.append(MonitorGeometry(
display_id=_display_number(m.name) or 0,
x=m.x, y=m.y, width=m.width, height=m.height,
diagonal_in=diagonal_in, is_primary=m.is_primary,
))
return sorted(monitors, key=lambda mon: mon.display_id)
def get_monitor(display_id: int | None) -> MonitorGeometry:
monitors = list_monitors()
if not monitors:
raise SystemExit("error: no monitors detected")
if display_id is None:
for mon in monitors:
if mon.is_primary:
return mon
return monitors[0]
for mon in monitors:
if mon.display_id == display_id:
return mon
available = ", ".join(str(mon.display_id) for mon in monitors)
raise SystemExit(f"error: --monitor {display_id} not found (available: {available})")
def format_monitor_list() -> str:
lines = []
for mon in list_monitors():
tag = " (primary)" if mon.is_primary else ""
diag = f'{mon.diagonal_in:.1f}"' if mon.diagonal_in else "unknown size"
lines.append(f" {mon.display_id}: {mon.width}x{mon.height} @ ({mon.x},{mon.y}) - {diag}{tag}")
return "\n".join(lines)
+4
View File
@@ -1,3 +1,7 @@
msgpack>=1.0,<2
matplotlib>=3.8,<4
pyzmq>=25,<28
pandas>=2.0,<3
scikit-learn>=1.4,<2
joblib>=1.3,<2
screeninfo>=0.8,<1
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,14 @@
{
"subject_id": "PS",
"session_id": "PS_20260812_092731",
"session_tag": "desk_70cm",
"recorded_at": "2026-08-12T09:27:31",
"screen_diagonal_in": 31.5,
"viewing_distance_cm": 70.0,
"confidence_threshold": 0.9,
"rounds": 3,
"animation_fps": 120,
"screen_width_px": 2560,
"screen_height_px": 1440,
"result_csv": "gaze_exercises_20260812_092731.csv"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,15 @@
{
"subject_id": "PS",
"session_id": "PS_20260812_163039",
"session_tag": "desk_54cm_monitor2",
"recorded_at": "2026-08-12T16:30:39",
"monitor_id": 3,
"screen_diagonal_in": 23.796762018639537,
"viewing_distance_cm": 54.0,
"confidence_threshold": 0.9,
"rounds": 3,
"animation_fps": 120,
"screen_width_px": 1920,
"screen_height_px": 1080,
"result_csv": "gaze_exercises_20260812_163039.csv"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,15 @@
{
"subject_id": "PS",
"session_id": "PS_20260812_163846",
"session_tag": "desk_112cm_monitor3",
"recorded_at": "2026-08-12T16:38:46",
"monitor_id": 2,
"screen_diagonal_in": 40.190275322725284,
"viewing_distance_cm": 112.0,
"confidence_threshold": 0.9,
"rounds": 3,
"animation_fps": 120,
"screen_width_px": 1920,
"screen_height_px": 1080,
"result_csv": "gaze_exercises_20260812_163846.csv"
}
+32 -2
View File
@@ -7,6 +7,8 @@ import sys
from pathlib import Path
import tkinter as tk
from monitor_utils import format_monitor_list, get_monitor
DEFAULT_TAG_IDS = (0, 1, 2, 3)
SOURCE_TAG_SIZE = 10
@@ -46,6 +48,17 @@ def parse_args() -> argparse.Namespace:
action="store_true",
help="open a resizable 1280x720 window instead of fullscreen",
)
parser.add_argument(
"--monitor",
type=int,
default=None,
help="Windows display number to use, e.g. 2 for \\\\.\\DISPLAY2 (default: primary)",
)
parser.add_argument(
"--list-monitors",
action="store_true",
help="print detected monitors (id, resolution, position, diagonal) and exit",
)
return parser.parse_args()
@@ -79,6 +92,7 @@ class SurfaceTagWindow:
def __init__(self, root: tk.Tk, args: argparse.Namespace) -> None:
self.root = root
self.args = args
self.monitor = get_monitor(getattr(args, "monitor", None))
self.images: list[tk.PhotoImage] = []
self.canvas = tk.Canvas(
root,
@@ -91,10 +105,23 @@ class SurfaceTagWindow:
root.title("Pupil Capture screen surface markers")
root.configure(background=args.background)
if args.windowed:
root.geometry("1280x720")
root.geometry(f"1280x720+{self.monitor.x + 40}+{self.monitor.y + 40}")
root.minsize(640, 480)
else:
root.attributes("-fullscreen", True)
# Tk's "-fullscreen" attribute picks the monitor nearest the window
# at the moment it's set, which can race the geometry move above and
# land back on the primary monitor. Pinning an undecorated, topmost
# window to the exact target rect is deterministic instead.
root.geometry(
f"{self.monitor.width}x{self.monitor.height}+{self.monitor.x}+{self.monitor.y}"
)
root.overrideredirect(True)
root.attributes("-topmost", True)
root.update_idletasks()
root.geometry(
f"{self.monitor.width}x{self.monitor.height}+{self.monitor.x}+{self.monitor.y}"
)
root.after(50, root.focus_force)
root.bind("<Escape>", self.close)
root.bind("q", self.close)
@@ -135,6 +162,9 @@ class SurfaceTagWindow:
def main() -> None:
args = parse_args()
if args.list_monitors:
print(format_monitor_list())
return
validate_args(args)
enable_windows_dpi_awareness()
root = tk.Tk()
+165
View File
@@ -0,0 +1,165 @@
"""Train a per-sample eye-movement classifier (fixation/saccade/pursuit) from
gaze_exercises.py recordings, for use by adaptive_filter.py.
Usage:
python train_filter_model.py
python train_filter_model.py --results-dir results --test-size 0.25
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import joblib
import numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report, confusion_matrix
from sklearn.model_selection import GroupShuffleSplit
from gaze_features import EVENT_LABELS, FEATURE_COLUMNS, add_rolling_features
BASELINE_VELOCITY_THRESHOLD_DEG_S = 30.0
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Train the adaptive-filter event classifier.")
parser.add_argument("--results-dir", default="results")
parser.add_argument("--model-dir", default="models")
parser.add_argument("--test-size", type=float, default=0.25,
help="fraction of trials held out for evaluation (default: 0.25)")
parser.add_argument("--random-state", type=int, default=42)
return parser.parse_args()
def load_dataset(results_dir: Path) -> pd.DataFrame:
csv_paths = sorted(p for p in results_dir.glob("gaze_exercises_*.csv"))
if not csv_paths:
raise SystemExit(f"error: no gaze_exercises_*.csv files found in {results_dir}")
frames = [pd.read_csv(p) for p in csv_paths]
df = pd.concat(frames, ignore_index=True)
print(f"Loaded {len(df)} rows from {len(csv_paths)} recording(s): "
f"{', '.join(p.name for p in csv_paths)}")
print("Sessions:", ", ".join(sorted(df['session_id'].unique())))
return df
def prepare_features(df: pd.DataFrame) -> pd.DataFrame:
df = df.sort_values(["session_id", "pupil_timestamp"]).reset_index(drop=True)
df["accel_px_s2"] = df["accel_px_s2"].fillna(0.0)
df = add_rolling_features(df)
before = len(df)
df = df.dropna(subset=FEATURE_COLUMNS + ["event_label"]).reset_index(drop=True)
dropped = before - len(df)
if dropped:
print(f"Dropped {dropped} rows missing features (first sample(s) of each block).")
df["trial_group"] = (
df["session_id"] + "_" + df["block"] + "_" +
df["round"].astype(str) + "_" + df["trial"].astype(str)
)
return df
def trial_level_split(df: pd.DataFrame, test_size: float, random_state: int):
splitter = GroupShuffleSplit(n_splits=1, test_size=test_size, random_state=random_state)
train_idx, test_idx = next(splitter.split(df, groups=df["trial_group"]))
train_df, test_df = df.iloc[train_idx], df.iloc[test_idx]
print(f"\nTrain: {len(train_df)} rows / {train_df['trial_group'].nunique()} trials")
print(f"Test: {len(test_df)} rows / {test_df['trial_group'].nunique()} trials")
print("Train block mix:", train_df["block"].value_counts().to_dict())
print("Test block mix: ", test_df["block"].value_counts().to_dict())
if df["session_id"].nunique() == 1:
print("\nNOTE: only one session/subject present. This split holds out unseen trials\n"
" within the same session - it does NOT test cross-subject generalization.\n"
" Re-run training once more subjects/sessions are recorded and switch to\n"
" leave-one-subject-out (group by subject_id instead of trial_group).")
return train_df, test_df
def baseline_predict(df: pd.DataFrame) -> pd.Series:
"""Classic velocity-threshold (I-VT style) baseline: no pursuit class."""
return np.where(df["velocity_deg_s"] > BASELINE_VELOCITY_THRESHOLD_DEG_S, "saccade", "fixation")
def detection_latency(df: pd.DataFrame, predicted: np.ndarray, label_name: str) -> None:
"""For each saccade block trial, measure ms between true and predicted onset of 'fixation'
(i.e. how late the filter would recognize the saccade has ended and settle back down)."""
work = df.copy()
work["predicted"] = predicted
deltas = []
for _, trial in work[work["block"] == "saccade"].groupby("trial_group"):
trial = trial.sort_values("pupil_timestamp")
true_onset = trial.loc[trial["event_label"] == "fixation", "pupil_timestamp"]
pred_onset = trial.loc[trial["predicted"] == "fixation", "pupil_timestamp"]
if true_onset.empty or pred_onset.empty:
continue
deltas.append((pred_onset.iloc[0] - true_onset.iloc[0]) * 1000.0)
if not deltas:
print(f"{label_name}: no comparable saccade trials found")
return
deltas = np.array(deltas)
print(f"{label_name}: fixation-recognition delay over {len(deltas)} saccade trials - "
f"mean {deltas.mean():+.0f} ms, median {np.median(deltas):+.0f} ms "
f"(positive = detected late)")
def main() -> None:
args = parse_args()
results_dir = Path(args.results_dir)
model_dir = Path(args.model_dir)
model_dir.mkdir(exist_ok=True)
df = load_dataset(results_dir)
df = prepare_features(df)
train_df, test_df = trial_level_split(df, args.test_size, args.random_state)
X_train, y_train = train_df[FEATURE_COLUMNS], train_df["event_label"]
X_test, y_test = test_df[FEATURE_COLUMNS], test_df["event_label"]
model = RandomForestClassifier(
n_estimators=200, max_depth=12, class_weight="balanced",
random_state=args.random_state, n_jobs=-1,
)
model.fit(X_train, y_train)
pred = model.predict(X_test)
print("\n=== Model (RandomForest) ===")
print(classification_report(y_test, pred, labels=list(EVENT_LABELS)))
print("Confusion matrix (rows=true, cols=pred), order", EVENT_LABELS)
print(confusion_matrix(y_test, pred, labels=list(EVENT_LABELS)))
print("\n=== Baseline (fixed velocity threshold, "
f"{BASELINE_VELOCITY_THRESHOLD_DEG_S:.0f} deg/s) ===")
baseline_pred = baseline_predict(test_df)
print(classification_report(y_test, baseline_pred, labels=list(EVENT_LABELS), zero_division=0))
print("\n=== Detection latency (lower is better; how late 'fixation' is recognized "
"after a saccade) ===")
detection_latency(test_df, pred, "Model")
detection_latency(test_df, baseline_pred, "Baseline")
importances = sorted(zip(FEATURE_COLUMNS, model.feature_importances_),
key=lambda kv: -kv[1])
print("\nFeature importances:")
for name, importance in importances:
print(f" {name:<22} {importance:.3f}")
model_path = model_dir / "filter_model.joblib"
joblib.dump(model, model_path)
meta_path = model_dir / "filter_model_meta.json"
meta_path.write_text(json.dumps({
"feature_columns": FEATURE_COLUMNS,
"event_labels": list(model.classes_),
"baseline_velocity_threshold_deg_s": BASELINE_VELOCITY_THRESHOLD_DEG_S,
"trained_on_sessions": sorted(df["session_id"].unique().tolist()),
"train_rows": len(train_df),
"test_rows": len(test_df),
}, indent=2), encoding="utf-8")
print(f"\nSaved model to {model_path}")
print(f"Saved metadata to {meta_path}")
if __name__ == "__main__":
main()