235 lines
9.1 KiB
Python
235 lines
9.1 KiB
Python
"""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()
|