added progress from previous repo host
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user