jakies pierdoly

This commit is contained in:
Vysyr committed 2026-09-25 15:39:26 +02:00
1 parent 6a13b1ef04
commit ae7dc34053
6 files changed
+136 -67

No files matched your search

+19 -7
View File
@@ -109,14 +109,18 @@ Each run writes `results/gaze_exercises_<timestamp>.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
+39 -17
View File
@@ -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")
+36 -30
View File
@@ -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,
}
Binary file not shown.
+8 -6
View File
@@ -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
}
+34 -7
View File
@@ -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"]