diff --git a/README.md b/README.md index 7d61ba6..a8e5dda 100644 --- a/README.md +++ b/README.md @@ -109,14 +109,18 @@ Each run writes `results/gaze_exercises_.csv` plus a sidecar 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 +python gaze_exercises.py --list-monitors +python gaze_exercises.py --subject-id you --session-tag laptop_50cm --monitor 1 --viewing-distance 50 +python gaze_exercises.py --subject-id you --session-tag monitor_90cm --monitor 2 --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. +`--monitor` picks which physical display to run on and auto-detects its +diagonal; pass `--screen-diagonal` explicitly only if auto-detection reports +`unknown size` for that display. `--viewing-distance` (cm) has no way to be +auto-detected and must match the physical setup for that recording — it +drives the pixel-to-degree conversion used for 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: @@ -162,6 +166,14 @@ 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. +`dispersion_px` and `accel_px_s2` in the raw CSV are in screen pixels, which +are not comparable across recordings taken on different physical screens or +viewing distances. Training re-derives both in visual-angle units +(`dispersion_deg`, `accel_deg_s2`) using each session's `_meta.json`, matching +what `adaptive_filter.py`'s `OnlineFeatureTracker` computes online — this +matters as soon as you record on more than one screen/distance combo, which +is exactly what varying `--monitor`/`--viewing-distance` across sessions does. + `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 @@ -177,7 +189,7 @@ 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 +python eval_static_fixation.py --subject-id you --duration 60 --viewing-distance 70 --monitor 1 ``` Press Space to start the 60-second hold. Results are saved to diff --git a/eval_static_fixation.py b/eval_static_fixation.py index 2d34551..657f5ae 100644 --- a/eval_static_fixation.py +++ b/eval_static_fixation.py @@ -21,13 +21,11 @@ import tkinter as tk from datetime import datetime from pathlib import Path +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 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 @@ -42,14 +40,16 @@ 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) + 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) 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)") + 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", @@ -60,6 +60,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() @@ -67,16 +72,27 @@ class StaticFixationWindow(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 + 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.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) + self.target = (self.width / 2, self.height / 2) - 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.filters = {"raw": None, "baseline": BaselineFilter()} self.model_path = Path(args.model) @@ -125,10 +141,10 @@ class StaticFixationWindow(SurfaceTagWindow): return method == "raw" or method in self.filters 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) @@ -139,8 +155,8 @@ class StaticFixationWindow(SurfaceTagWindow): 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() + rx = TARGET_RADIUS / self.width * self.canvas.winfo_width() + ry = TARGET_RADIUS / self.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") @@ -192,8 +208,8 @@ class StaticFixationWindow(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 tick(self) -> None: @@ -203,7 +219,7 @@ class StaticFixationWindow(SurfaceTagWindow): 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) + gaze = (sample.x_norm * self.width, (1.0 - sample.y_norm) * self.height) self.record_sample(sample, gaze, now - self.started_at) except queue.Empty: pass @@ -240,7 +256,10 @@ class StaticFixationWindow(SurfaceTagWindow): "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, + "monitor_id": self.monitor.display_id, + "screen_diagonal_in": self.screen_diagonal, + "screen_width_px": self.width, + "screen_height_px": self.height, "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, @@ -257,10 +276,13 @@ class StaticFixationWindow(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") diff --git a/gaze_features.py b/gaze_features.py index ea494ba..5317298 100644 --- a/gaze_features.py +++ b/gaze_features.py @@ -16,17 +16,29 @@ MAX_CONTINUOUS_GAP_S = 0.5 FEATURE_COLUMNS = [ "velocity_deg_s", - "accel_px_s2", - "dispersion_px", + "accel_deg_s2", + "dispersion_deg", "confidence", "velocity_deg_s_mean", "velocity_deg_s_std", - "dispersion_px_mean", + "dispersion_deg_mean", ] EVENT_LABELS = ("fixation", "saccade", "pursuit") +def px_to_deg(distance_px: float, cm_per_px: float, viewing_distance_cm: float) -> float: + """Convert a pixel distance to visual angle, given screen and viewing-distance calibration. + + Every recording can be a different physical screen at a different + distance, so any pixel-unit feature (dispersion, acceleration) is not + comparable across sessions unless converted through this. velocity_deg_s + already goes through the equivalent conversion at record time. + """ + distance_cm = distance_px * cm_per_px + return math.degrees(2.0 * math.atan2(distance_cm / 2.0, viewing_distance_cm)) + + def add_rolling_features(df, session_col: str = "session_id"): """Add the *_mean / *_std rolling columns to a DataFrame in place, returning it. @@ -35,11 +47,11 @@ def add_rolling_features(df, session_col: str = "session_id"): 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) + velocity_roll = grouped["velocity_deg_s"].rolling(FEATURE_WINDOW, min_periods=1) + df["velocity_deg_s_mean"] = velocity_roll.mean().reset_index(level=0, drop=True) + df["velocity_deg_s_std"] = velocity_roll.std().reset_index(level=0, drop=True).fillna(0.0) + df["dispersion_deg_mean"] = ( + grouped["dispersion_deg"].rolling(FEATURE_WINDOW, min_periods=1) .mean().reset_index(level=0, drop=True) ) return df @@ -60,47 +72,41 @@ class OnlineFeatureTracker: 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 + self.prev_velocity_deg_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 + self.prev_velocity_deg_s = None def update(self, timestamp: float, x: float, y: float, confidence: float) -> dict | None: - velocity_px_s = accel_px_s2 = None + velocity_deg_s = accel_deg_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 + velocity_px_s = math.dist((prev_x, prev_y), (x, y)) / dt + velocity_deg_s = px_to_deg(velocity_px_s, self.cm_per_px, self.viewing_distance_cm) + if self.prev_velocity_deg_s is not None: + accel_deg_s2 = (velocity_deg_s - self.prev_velocity_deg_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.prev_velocity_deg_s = velocity_deg_s self.history.append((x, y, timestamp)) - dispersion_px = None + dispersion_deg = 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)) + dispersion_deg = px_to_deg(dispersion_px, self.cm_per_px, self.viewing_distance_cm) - if velocity_deg_s is None or dispersion_px is None: + if velocity_deg_s is None or dispersion_deg is None: return None self.velocity_history.append(velocity_deg_s) - self.dispersion_history.append(dispersion_px) + self.dispersion_history.append(dispersion_deg) n = len(self.velocity_history) velocity_deg_s_mean = sum(self.velocity_history) / n @@ -109,14 +115,14 @@ class OnlineFeatureTracker: 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) + dispersion_deg_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, + "accel_deg_s2": accel_deg_s2 if accel_deg_s2 is not None else 0.0, + "dispersion_deg": dispersion_deg, "confidence": confidence, "velocity_deg_s_mean": velocity_deg_s_mean, "velocity_deg_s_std": velocity_deg_s_std, - "dispersion_px_mean": dispersion_px_mean, + "dispersion_deg_mean": dispersion_deg_mean, } diff --git a/models/filter_model.joblib b/models/filter_model.joblib index f89ba77..1ba1cac 100644 Binary files a/models/filter_model.joblib and b/models/filter_model.joblib differ diff --git a/models/filter_model_meta.json b/models/filter_model_meta.json index f2e71ad..f086912 100644 --- a/models/filter_model_meta.json +++ b/models/filter_model_meta.json @@ -1,12 +1,12 @@ { "feature_columns": [ "velocity_deg_s", - "accel_px_s2", - "dispersion_px", + "accel_deg_s2", + "dispersion_deg", "confidence", "velocity_deg_s_mean", "velocity_deg_s_std", - "dispersion_px_mean" + "dispersion_deg_mean" ], "event_labels": [ "fixation", @@ -15,8 +15,10 @@ ], "baseline_velocity_threshold_deg_s": 30.0, "trained_on_sessions": [ - "PS_20260812_092731" + "PS_20260812_092731", + "PS_20260812_163039", + "PS_20260812_163846" ], - "train_rows": 48979, - "test_rows": 17636 + "train_rows": 160867, + "test_rows": 52233 } \ No newline at end of file diff --git a/train_filter_model.py b/train_filter_model.py index 3dcec15..8ff97e2 100644 --- a/train_filter_model.py +++ b/train_filter_model.py @@ -10,6 +10,7 @@ from __future__ import annotations import argparse import json +import math from pathlib import Path import joblib @@ -19,7 +20,7 @@ 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 +from gaze_features import EVENT_LABELS, FEATURE_COLUMNS, add_rolling_features, MAX_CONTINUOUS_GAP_S BASELINE_VELOCITY_THRESHOLD_DEG_S = 30.0 @@ -34,7 +35,7 @@ def parse_args() -> argparse.Namespace: return parser.parse_args() -def load_dataset(results_dir: Path) -> pd.DataFrame: +def load_dataset(results_dir: Path) -> tuple[pd.DataFrame, dict[str, dict]]: 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}") @@ -43,12 +44,38 @@ def load_dataset(results_dir: Path) -> pd.DataFrame: 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 + + session_meta: dict[str, dict] = {} + for csv_path in csv_paths: + meta_path = csv_path.with_name(csv_path.stem + "_meta.json") + if not meta_path.exists(): + raise SystemExit(f"error: missing {meta_path.name} (needed for screen-size calibration)") + meta = json.loads(meta_path.read_text(encoding="utf-8")) + diagonal_px = math.hypot(meta["screen_width_px"], meta["screen_height_px"]) + session_meta[meta["session_id"]] = { + "cm_per_px": meta["screen_diagonal_in"] * 2.54 / diagonal_px, + "viewing_distance_cm": meta["viewing_distance_cm"], + } + return df, session_meta -def prepare_features(df: pd.DataFrame) -> pd.DataFrame: +def prepare_features(df: pd.DataFrame, session_meta: dict[str, dict]) -> 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) + + # dispersion_px and accel_px_s2 are in raw screen pixels, which are not + # comparable across recordings taken on different screens/distances (see + # session_meta). Re-derive both in visual-angle units, matching what + # OnlineFeatureTracker computes at inference time (gaze_features.py). + cm_per_px = df["session_id"].map(lambda s: session_meta[s]["cm_per_px"]) + viewing_distance_cm = df["session_id"].map(lambda s: session_meta[s]["viewing_distance_cm"]) + dispersion_cm = df["dispersion_px"] * cm_per_px + df["dispersion_deg"] = np.degrees(2.0 * np.arctan2(dispersion_cm / 2.0, viewing_distance_cm)) + + dt = df.groupby("session_id")["pupil_timestamp"].diff() + accel_deg_s2 = df.groupby("session_id")["velocity_deg_s"].diff() / dt + accel_deg_s2[(dt <= 0) | (dt > MAX_CONTINUOUS_GAP_S)] = np.nan + df["accel_deg_s2"] = accel_deg_s2.fillna(0.0) + df = add_rolling_features(df) before = len(df) df = df.dropna(subset=FEATURE_COLUMNS + ["event_label"]).reset_index(drop=True) @@ -111,8 +138,8 @@ def main() -> None: model_dir = Path(args.model_dir) model_dir.mkdir(exist_ok=True) - df = load_dataset(results_dir) - df = prepare_features(df) + df, session_meta = load_dataset(results_dir) + df = prepare_features(df, session_meta) 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"]