"""Visualize CSV output produced by gaze_exercises.py.""" from __future__ import annotations import argparse import csv from dataclasses import dataclass import math from pathlib import Path import statistics try: import matplotlib.pyplot as plt except ImportError as exc: raise SystemExit( "Missing dependency. Run: python -m pip install -r requirements.txt" ) from exc SCREEN_WIDTH = 2560 SCREEN_HEIGHT = 1440 SCREEN_DIAGONAL_IN = 31.5 DEFAULT_VIEWING_DISTANCE_CM = 70.0 BLOCKS = ("fixation", "saccade", "colors", "pursuit") TITLES = { "fixation": "Steady fixation", "saccade": "Quick target acquisition", "colors": "Green target selection", "pursuit": "Smooth pursuit", } @dataclass(frozen=True) class Row: timestamp: float round: int block: str trial: int elapsed: float target_x: float target_y: float gaze_x: float gaze_y: float error: float hit: bool def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Plot gaze exercise results.") parser.add_argument( "csv_path", nargs="?", type=Path, help="result CSV; defaults to the newest file in results/", ) parser.add_argument("--save", type=Path, help="output PNG path") parser.add_argument("--no-show", action="store_true", help="save without opening a window") parser.add_argument("--screen-diagonal", type=float, default=SCREEN_DIAGONAL_IN, help="physical screen diagonal in inches (default: 31.5)") parser.add_argument("--viewing-distance", type=float, default=DEFAULT_VIEWING_DISTANCE_CM, help="eye-to-screen distance in cm (default: 60)") return parser.parse_args() def newest_result() -> Path: results = Path(__file__).resolve().parent / "results" files = list(results.glob("gaze_exercises_*.csv")) if not files: raise SystemExit("No gaze exercise CSV files found in results/") return max(files, key=lambda path: path.stat().st_mtime) def load_rows(path: Path) -> list[Row]: try: with path.open(newline="", encoding="utf-8-sig") as file: rows = [ Row( float(item["pupil_timestamp"]), int(item.get("round") or 1), item["block"], int(item["trial"]), float(item["trial_elapsed_s"]), float(item["target_x"]), float(item["target_y"]), float(item["gaze_x"]), float(item["gaze_y"]), float(item["error_px"]), item["hit"].strip().lower() in {"1", "true", "yes"}, ) for item in csv.DictReader(file) ] except (OSError, KeyError, TypeError, ValueError) as exc: raise SystemExit(f"Could not read {path}: {exc}") from exc if not rows: raise SystemExit(f"No data rows found in {path}") return rows def percentile(values: list[float], fraction: float) -> float: ordered = sorted(values) return ordered[round((len(ordered) - 1) * fraction)] def setup_screen_axis(axis, title: str) -> None: axis.set_title(title, fontweight="bold") axis.set_xlim(0, SCREEN_WIDTH) axis.set_ylim(SCREEN_HEIGHT, 0) axis.set_aspect("equal", adjustable="box") axis.set_facecolor("#111722") axis.grid(color="white", alpha=0.08, linewidth=0.6) axis.set_xlabel("screen x (px)") axis.set_ylabel("screen y (px)") def plot_screen_map(axis, block: str, rows: list[Row]) -> None: setup_screen_axis(axis, TITLES[block]) if not rows: axis.text(0.5, 0.5, "No data", transform=axis.transAxes, ha="center") return # Limit rendering cost without changing the statistics. stride = max(1, len(rows) // 1800) shown = rows[::stride] target_color = "#40e883" if block == "colors" else "#58a6ff" if block == "pursuit": axis.plot( [row.target_x for row in shown], [row.target_y for row in shown], color=target_color, linewidth=2.2, label="target path" ) axis.plot( [row.gaze_x for row in shown], [row.gaze_y for row in shown], color="#ffd43b", linewidth=1.0, alpha=0.65, label="gaze path" ) else: targets: dict[tuple[int, int], tuple[float, float]] = {} for row in rows: targets.setdefault((row.round, row.trial), (row.target_x, row.target_y)) axis.scatter( [point[0] for point in targets.values()], [point[1] for point in targets.values()], s=95, color=target_color, edgecolor="white", linewidth=1.2, zorder=3, label="target" ) axis.scatter( [row.gaze_x for row in shown], [row.gaze_y for row in shown], s=8, color="#ffd43b", alpha=0.28, edgecolors="none", label="gaze" ) axis.legend(loc="lower right", fontsize=8) def error_cm(error_px: float, diagonal_in: float) -> float: return error_px * diagonal_in * 2.54 / math.hypot(SCREEN_WIDTH, SCREEN_HEIGHT) def error_deg(error_px: float, diagonal_in: float, viewing_distance_cm: float) -> float: cm = error_cm(error_px, diagonal_in) return math.degrees(2.0 * math.atan2(cm / 2.0, viewing_distance_cm)) def make_dashboard(path: Path, rows: list[Row], diagonal_in: float, viewing_distance_cm: float): grouped = {block: [row for row in rows if row.block == block] for block in BLOCKS} figure, axes = plt.subplots(3, 2, figsize=(15, 13), constrained_layout=True) figure.suptitle(f"Gaze exercise results — {path.name}", fontsize=17, fontweight="bold") for axis, block in zip(axes.flat[:4], BLOCKS): plot_screen_map(axis, block, grouped[block]) error_axis = axes[2, 0] available = [block for block in BLOCKS if grouped[block]] error_sets = [[error_cm(row.error, diagonal_in) for row in grouped[block]] for block in available] boxes = error_axis.boxplot(error_sets, tick_labels=[TITLES[b] for b in available], patch_artist=True, showfliers=False) for box, color in zip(boxes["boxes"], ("#58a6ff", "#ff9f43", "#40e883", "#b084f5")): box.set_facecolor(color) box.set_alpha(0.75) hit_cm = error_cm(140, diagonal_in) error_axis.axhline(hit_cm, color="#d62728", linestyle="--", linewidth=1.3, label=f"hit radius: 140 px / {hit_cm:.2f} cm") error_axis.set_title("Physical-error distribution", fontweight="bold") error_axis.set_ylabel("distance from target (cm)") error_axis.tick_params(axis="x", rotation=15) error_axis.grid(axis="y", alpha=0.25) error_axis.legend(fontsize=8) metric_axis = axes[2, 1] means = [statistics.fmean(row.error for row in grouped[b]) for b in available] hit_rates = [100 * statistics.fmean(row.hit for row in grouped[b]) for b in available] positions = list(range(len(available))) bars = metric_axis.bar(positions, hit_rates, color=("#58a6ff", "#ff9f43", "#40e883", "#b084f5")) metric_axis.set_ylim(0, 105) metric_axis.set_ylabel("on-target samples (%)") metric_axis.set_xticks(positions, [TITLES[b] for b in available], rotation=15) metric_axis.set_title("Accuracy summary", fontweight="bold") metric_axis.grid(axis="y", alpha=0.25) for bar, rate, mean in zip(bars, hit_rates, means): metric_axis.text( bar.get_x() + bar.get_width() / 2, bar.get_height() + 1, (f"{rate:.0f}%\n{mean:.0f}px / {error_cm(mean, diagonal_in):.2f}cm\n" f"{error_deg(mean, diagonal_in, viewing_distance_cm):.2f}deg"), ha="center", va="bottom", fontsize=9 ) return figure, grouped def print_summary(path: Path, grouped: dict[str, list[Row]], diagonal_in: float, viewing_distance_cm: float) -> None: print(f"Results: {path}") print(f"Monitor: {SCREEN_WIDTH}x{SCREEN_HEIGHT}, {diagonal_in:g} in; viewing distance: {viewing_distance_cm:g} cm") print(f"{'block':<12} {'samples':>8} {'mean px':>9} {'mean cm':>9} {'mean deg':>9} {'hit %':>8}") for block in BLOCKS: rows = grouped[block] if not rows: continue errors = [row.error for row in rows] hit_rate = 100 * statistics.fmean(row.hit for row in rows) print( f"{block:<12} {len(rows):>8} {statistics.fmean(errors):>9.1f} " f"{error_cm(statistics.fmean(errors), diagonal_in):>9.3f} " f"{error_deg(statistics.fmean(errors), diagonal_in, viewing_distance_cm):>9.3f} " f"{hit_rate:>7.1f}%" ) def main() -> None: args = parse_args() if args.screen_diagonal <= 0 or args.viewing_distance <= 0: raise SystemExit("error: screen diagonal and viewing distance must be greater than 0") path = (args.csv_path or newest_result()).resolve() rows = load_rows(path) figure, grouped = make_dashboard(path, rows, args.screen_diagonal, args.viewing_distance) output = (args.save or path.with_name(f"{path.stem}_dashboard.png")).resolve() figure.savefig(output, dpi=160, facecolor="white") print_summary(path, grouped, args.screen_diagonal, args.viewing_distance) print(f"Dashboard saved: {output}") if not args.no_show: plt.show() if __name__ == "__main__": main()