Files
adaptive_filtering/visualize_results.py

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()