monitor selection, model prototype and 2 more recordings
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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.
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user