pupil_fixation_overlay.gif) combining fixations and pupil diameter over time
pupil_fixation_summary.csv)
fixations.csv and pupil_positions.csv. Time is shown as seconds since recording start for readability.fixations.csv, pupil_positions.csv)pandasnumpymatplotlibPillow (usually included with Anaconda; used for reading images and writing the GIF)
fixations.csv (required)pupil_positions.csv (required)fixations.csv: start_timestamp, duration (fixation timing)norm_pos_x, norm_pos_y (normalized gaze position for the fixation)confidence (optional filter)pupil_positions.csv: pupil_timestamp, diameterconfidence (optional filter)fixations.csv and pupil_positions.csvpupil_fixation_overlay.gif (animated figure)pupil_fixation_summary.csv (numeric summary)outputs folder by default.
fixations.csv and pupil_positions.csv in the same folder as the script and run the code.outputs folder):pupil_fixation_overlay.gifpupil_fixation_summary.csv--no-stimulus-markers, --trial-duration-sec, --stimulus-duration-sec, --n-trials, --confidence-threshold
pupil_timestamp and smoothed diameter for the right panel, and optional trial-based stimulus bands when enabled.
"""
@author: Fjorda
"""
from __future__ import annotations
import argparse
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from PIL import Image
SCRIPT_DIR = Path(__file__).resolve().parent
DEFAULT_FIX = SCRIPT_DIR / "fixations.csv"
DEFAULT_PUPIL = SCRIPT_DIR / "pupil_positions.csv"
DEFAULT_OUT = SCRIPT_DIR / "outputs"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Pupil size + fixation overlay GIF (Pupil Labs exports)")
parser.add_argument("--fixations", type=Path, default=DEFAULT_FIX, help="Path to fixations.csv")
parser.add_argument("--pupil", type=Path, default=DEFAULT_PUPIL, help="Path to pupil_positions.csv")
parser.add_argument(
"--image",
type=Path,
default=None,
help="Background image for panorama mode (required when --fixation-view panorama).",
)
parser.add_argument("--label", type=str, default="Session", help="Title label (e.g. recording name)")
parser.add_argument("--out-dir", type=Path, default=DEFAULT_OUT)
parser.add_argument("--confidence-threshold", type=float, default=0.6)
parser.add_argument("--max-fixations", type=int, default=180)
parser.add_argument("--fps", type=int, default=10)
parser.add_argument("--window-sec", type=float, default=0.4)
parser.add_argument("--interval-sec", type=float, default=2.0)
parser.add_argument(
"--time-max-sec",
type=float,
default=None,
help="Pupil x-axis max (s). Default: snap to end of last trial (see --time-padding-sec).",
)
parser.add_argument(
"--time-padding-sec",
type=float,
default=0.0,
help="Extra seconds after trial snap (default 0: right edge at last trial boundary).",
)
parser.add_argument(
"--fixation-view",
choices=("graph", "panorama"),
default="graph",
help="graph: 2D norm_pos_x vs norm_pos_y (no image). panorama: overlay on --image.",
)
parser.add_argument(
"--trial-duration-sec",
type=float,
default=2.3,
help="Trial length (s); optional stimulus bands repeat every this interval.",
)
parser.add_argument(
"--stimulus-duration-sec",
type=float,
default=1.15,
help="Stimulus duration at the start of each trial (s).",
)
parser.add_argument(
"--n-trials",
type=int,
default=50,
help="Maximum trial index for stimulus markers (upper cap).",
)
parser.add_argument(
"--no-stimulus-markers",
action="store_true",
help="Disable stimulus window highlights and trial-based fixation colors.",
)
return parser.parse_args()
def _resolve_existing(path: Path, name: str) -> Path:
p = path.expanduser().resolve()
if not p.is_file():
raise FileNotFoundError(f"{name} not found: {p}")
return p
def load_fixations(path: Path, conf_thr: float, max_fixations: int) -> pd.DataFrame:
df = pd.read_csv(path)
required = {"start_timestamp", "duration", "norm_pos_x", "norm_pos_y"}
missing = required - set(df.columns)
if missing:
raise ValueError(f"Missing fixation columns: {sorted(missing)}")
if "confidence" in df.columns:
df = df[df["confidence"] >= conf_thr].copy()
df = df.dropna(subset=["start_timestamp", "duration", "norm_pos_x", "norm_pos_y"]).copy()
df = df.sort_values("start_timestamp").reset_index(drop=True)
if max_fixations and len(df) > max_fixations:
df = df.iloc[:max_fixations].copy()
return df
def load_pupil(path: Path, conf_thr: float) -> pd.DataFrame:
df = pd.read_csv(path)
required = {"pupil_timestamp", "diameter"}
missing = required - set(df.columns)
if missing:
raise ValueError(f"Missing pupil columns: {sorted(missing)}")
if "confidence" in df.columns:
df = df[df["confidence"] >= conf_thr].copy()
# Keep one estimate per timestamp; prefer 3d estimate when available.
if "method" in df.columns:
method_rank = df["method"].astype(str).str.contains("pye3d", case=False, na=False).astype(int)
df = df.assign(_method_rank=method_rank).sort_values(["pupil_timestamp", "_method_rank"], ascending=[True, False])
df = df.drop_duplicates(subset=["pupil_timestamp"], keep="first").drop(columns=["_method_rank"])
else:
df = df.drop_duplicates(subset=["pupil_timestamp"], keep="first")
df = df.dropna(subset=["pupil_timestamp", "diameter"]).copy()
df = df.sort_values("pupil_timestamp").reset_index(drop=True)
return df
def _smooth_pupil(df: pd.DataFrame, window_sec: float) -> pd.DataFrame:
out = df.copy()
t = out["pupil_timestamp"].to_numpy(dtype=float)
dt = float(np.median(np.diff(t))) if len(t) >= 2 else 1.0 / 120.0
samples = int(max(3, round(max(window_sec, 0.01) / max(dt, 1e-6))))
if samples % 2 == 0:
samples += 1
out["diameter_smooth"] = out["diameter"].rolling(window=samples, center=True, min_periods=1).median()
return out
def _px_from_norm(fix: pd.DataFrame, w: int, h: int) -> tuple[np.ndarray, np.ndarray]:
x = np.clip(fix["norm_pos_x"].to_numpy(dtype=float), 0.0, 1.0) * (w - 1)
y = (1.0 - np.clip(fix["norm_pos_y"].to_numpy(dtype=float), 0.0, 1.0)) * (h - 1)
return x, y
def _norm_xy_for_graph(fix: pd.DataFrame) -> tuple[np.ndarray, np.ndarray]:
"""Normalized gaze in [0,1]²; x left→right, y bottom→top (screen-like)."""
nx = np.clip(fix["norm_pos_x"].to_numpy(dtype=float), 0.0, 1.0)
ny = np.clip(fix["norm_pos_y"].to_numpy(dtype=float), 0.0, 1.0)
return nx, ny
def _pupil_x_max_auto(
t_anim_end: float,
t_end: float,
trial_duration_sec: float,
n_trials: int,
show_stimulus: bool,
time_padding_sec: float,
) -> float:
"""End x-axis at the end of the trial that contains the last fixation (no empty tail after last trial)."""
if trial_duration_sec > 0 and show_stimulus:
k = int(np.ceil(t_anim_end / trial_duration_sec))
k = int(np.clip(k, 1, max(n_trials, 1)))
trial_end = float(k * trial_duration_sec)
return max(trial_end + max(time_padding_sec, 0.0), 1.0)
return max(t_end + max(time_padding_sec, 0.0), 1.0)
def _pupil_mean_stimulus_on_off(
p_rel: np.ndarray,
p_smoothed: np.ndarray,
t_window_end: float,
trial_duration_sec: float,
stimulus_duration_sec: float,
) -> tuple[float | None, int, float | None, int]:
"""Mean smoothed pupil in [0, t_window_end] for stimulus-on vs stimulus-off samples (same windows as green bands)."""
if len(p_rel) == 0:
return None, 0, None, 0
m = p_rel <= t_window_end + 1e-9
t = p_rel[m]
d = p_smoothed[m]
if trial_duration_sec <= 0:
return None, 0, float(np.mean(d)), int(len(d))
end_w = min(max(stimulus_duration_sec, 0.0), trial_duration_sec)
if end_w <= 0:
return None, 0, float(np.mean(d)), int(len(d))
phase = np.mod(t, trial_duration_sec)
on = phase < end_w
n_on = int(np.sum(on))
n_off = int(np.sum(~on))
mean_on = float(np.mean(d[on])) if n_on else None
mean_off = float(np.mean(d[~on])) if n_off else None
return mean_on, n_on, mean_off, n_off
def _save_gif(frames: list[Image.Image], out_path: Path, fps: int) -> None:
if not frames:
raise ValueError("No frames generated")
duration_ms = int(1000 / max(1, fps))
frames[0].save(
out_path,
save_all=True,
append_images=frames[1:],
loop=0,
duration=[duration_ms] * len(frames),
)
def build_gif(
fix: pd.DataFrame,
pupil: pd.DataFrame,
bg_rgb: np.ndarray | None,
label: str,
out_path: Path,
fps: int,
interval_sec: float,
time_max_sec: float | None,
time_padding_sec: float,
fixation_view: str,
trial_duration_sec: float,
stimulus_duration_sec: float,
n_trials: int,
show_stimulus: bool,
) -> dict:
if fixation_view == "panorama" and bg_rgb is None:
raise ValueError("panorama mode requires a background image")
if fixation_view == "graph":
fx, fy = _norm_xy_for_graph(fix)
else:
h, w = bg_rgb.shape[0], bg_rgb.shape[1]
fx, fy = _px_from_norm(fix, w=w, h=h)
f_dur = fix["duration"].to_numpy(dtype=float)
f_ts = fix["start_timestamp"].to_numpy(dtype=float)
f_end = f_ts + (f_dur / 1000.0)
p_ts = pupil["pupil_timestamp"].to_numpy(dtype=float)
p_d = pupil["diameter_smooth"].to_numpy(dtype=float)
p_raw = pupil["diameter"].to_numpy(dtype=float)
if len(f_ts) == 0 or len(p_ts) == 0:
raise ValueError("Not enough filtered data. Lower confidence threshold or check files.")
t0 = float(min(f_ts[0], p_ts[0]))
f_rel = f_ts - t0
f_end_rel = f_end - t0
p_rel = p_ts - t0
# Pupil panel only shows data through the last fixation in this GIF (same as animation end).
t_anim_end = float(f_end_rel[-1])
_mask = p_rel <= t_anim_end + 1e-6
p_rel = p_rel[_mask]
p_d = p_d[_mask]
p_raw = p_raw[_mask]
n_p = len(p_rel)
if n_p == 0:
raise ValueError("No pupil samples up to last fixation end; check data alignment.")
fig = plt.figure(figsize=(13.5, 7.2))
gs = fig.add_gridspec(1, 2, width_ratios=[1.0, 1.0], wspace=0.1)
ax_scene = fig.add_subplot(gs[0, 0])
ax_pupil = fig.add_subplot(gs[0, 1])
# Extra left margin so the fixation panel y-axis label stays visible; equal column widths.
# Tight bottom margin: two-line stim. on/off means sit just under the pupil axis.
fig.subplots_adjust(left=0.09, right=0.97, bottom=0.155, top=0.93, wspace=0.1)
if fixation_view == "graph":
ax_scene.set_facecolor("#f4f4f6")
ax_scene.set_xlim(0, 1)
ax_scene.set_ylim(0, 1)
ax_scene.set_aspect("equal")
ax_scene.set_xlabel("norm_pos_x (left → right)")
ax_scene.set_ylabel("norm_pos_y (bottom → top)")
ax_scene.grid(True, alpha=0.35)
ax_scene.set_title(
"Fixations in normalized gaze space\n(colored by trial #)"
if show_stimulus
else "Fixations in normalized gaze space",
fontsize=11,
)
ax_scene.margins(x=0.01, y=0.01)
(path_line,) = ax_scene.plot([], [], color="#888888", linewidth=1.0, alpha=0.55, zorder=1)
sc = ax_scene.scatter([], [], s=[], c=[], cmap="turbo", alpha=0.92, edgecolors="white", linewidths=0.35, zorder=2)
current = ax_scene.scatter([], [], s=[], c="#ffffff", alpha=0.95, edgecolors="black", linewidths=0.7, zorder=3)
else:
h, w = bg_rgb.shape[0], bg_rgb.shape[1]
ax_scene.imshow(bg_rgb, extent=[0, w, h, 0], aspect="equal")
ax_scene.set_xlim(0, w)
ax_scene.set_ylim(h, 0)
ax_scene.set_axis_off()
ax_scene.set_title(label, fontsize=11)
path_line = None
sc = ax_scene.scatter([], [], s=[], c=[], cmap="turbo", alpha=0.92, edgecolors="white", linewidths=0.35)
current = ax_scene.scatter([], [], s=[], c="#ffffff", alpha=0.95, edgecolors="black", linewidths=0.7)
ax_pupil.plot(p_rel, p_raw, color="#97a7c3", linewidth=1.0, alpha=0.35, label="Raw")
ax_pupil.plot(p_rel, p_d, color="#1f4e79", linewidth=2.0, alpha=0.95, label="Smoothed")
vline = ax_pupil.axvline(p_rel[0], color="#ff6b00", linewidth=1.8, alpha=0.9)
marker = ax_pupil.scatter([p_rel[0]], [p_d[0]], s=42, c="#ff6b00", zorder=5)
active_fix = ax_pupil.axvspan(f_rel[0], f_end_rel[0], color="#ff6b00", alpha=0.12, label="Current fixation")
ax_pupil.set_xlabel("Time since start (s)")
ax_pupil.set_ylabel("Pupil diameter")
ax_pupil.grid(alpha=0.25)
t_end = float(max(p_rel[-1], t_anim_end))
if time_max_sec is None:
x_max = _pupil_x_max_auto(
t_anim_end=t_anim_end,
t_end=t_end,
trial_duration_sec=trial_duration_sec,
n_trials=n_trials,
show_stimulus=show_stimulus,
time_padding_sec=time_padding_sec,
)
else:
x_max = max(float(time_max_sec), t_end, 1.0)
ax_pupil.set_xlim(0.0, x_max)
if show_stimulus and trial_duration_sec > 0:
end_w = min(stimulus_duration_sec, trial_duration_sec)
for j in range(n_trials):
t0w = j * trial_duration_sec
if t0w >= x_max or t0w >= t_anim_end:
break
t1w = min(t0w + end_w, x_max, t_anim_end)
ax_pupil.axvspan(
t0w,
t1w,
alpha=0.22,
color="#2ca02c",
zorder=0,
label="Stimulus shown" if j == 0 else None,
)
step = max(interval_sec, 0.5)
interval_edges = np.arange(0.0, x_max + step, step)
for edge in interval_edges[1:-1]:
ax_pupil.axvline(edge, color="#d0d7e2", linewidth=0.8, alpha=0.45, linestyle="--")
if len(interval_edges) >= 2:
mids = []
vals = []
for i in range(len(interval_edges) - 1):
s = interval_edges[i]
e = interval_edges[i + 1]
mask = (p_rel >= s) & (p_rel < e if i < len(interval_edges) - 2 else p_rel <= e)
if np.any(mask):
mids.append((s + e) / 2.0)
vals.append(float(np.mean(p_d[mask])))
if mids:
ax_pupil.plot(mids, vals, color="#6b8e23", linewidth=1.8, marker="o", markersize=3.5, alpha=0.9, label=f"{step:g}s interval mean")
ax_pupil.legend(loc="upper right", frameon=False, fontsize=8)
mean_on: float | None
n_on: int
mean_off: float | None
n_off: int
pupil_stats_figtext: str | None = None
if show_stimulus and trial_duration_sec > 0:
mean_on, n_on, mean_off, n_off = _pupil_mean_stimulus_on_off(
p_rel, p_d, t_anim_end, trial_duration_sec, stimulus_duration_sec
)
on_s = f"{mean_on:.2f}" if mean_on is not None else "—"
off_s = f"{mean_off:.2f}" if mean_off is not None else "—"
pupil_stats_figtext = (
f"Mean pupil diam. (smoothed), stim. on = {on_s}\n"
f"Mean pupil diam. (smoothed), stim. off = {off_s}"
)
else:
mean_on, n_on, mean_off, n_off = None, 0, float(np.mean(p_d)), int(len(p_d))
if pupil_stats_figtext is not None:
bb = ax_pupil.get_position()
fig.text(
bb.x0 + 0.5 * bb.width,
bb.y0 - 0.068,
pupil_stats_figtext,
transform=fig.transFigure,
ha="center",
va="top",
fontsize=9,
color="#222222",
linespacing=1.08,
)
if show_stimulus and trial_duration_sec > 0:
trial_idx = np.floor(f_rel / trial_duration_sec).astype(int) + 1
trial_idx = np.clip(trial_idx, 1, max(n_trials, 1))
color_vals = trial_idx.astype(float)
cmax = float(max(trial_idx.max(), 1))
else:
color_vals = np.arange(len(fix), dtype=float)
cmax = max(len(fix) - 1, 1)
size = 28 + ((f_dur - np.min(f_dur)) / max(np.ptp(f_dur), 1e-9)) * 150
frames: list[Image.Image] = []
for k in range(1, len(fix) + 1):
t = f_rel[k - 1]
p_i = int(np.searchsorted(p_rel, t, side="right") - 1)
p_i = max(0, min(p_i, n_p - 1))
sc.set_offsets(np.c_[fx[:k], fy[:k]])
sc.set_sizes(size[:k])
sc.set_array(color_vals[:k])
sc.set_clim(1.0 if show_stimulus and trial_duration_sec > 0 else 0.0, cmax)
current.set_offsets(np.array([[fx[k - 1], fy[k - 1]]], dtype=float))
if fixation_view == "graph":
current.set_sizes(np.array([max(120.0, size[k - 1] * 0.65)], dtype=float))
if k >= 2:
path_line.set_data(fx[:k], fy[:k])
elif k == 1:
path_line.set_data([fx[0]], [fy[0]])
else:
current.set_sizes(np.array([max(80.0, size[k - 1] * 0.5)], dtype=float))
vline.set_xdata([p_rel[p_i], p_rel[p_i]])
marker.set_offsets(np.array([[p_rel[p_i], p_d[p_i]]], dtype=float))
active_fix.set_xy(
np.array(
[
[f_rel[k - 1], 0],
[f_rel[k - 1], 1],
[f_end_rel[k - 1], 1],
[f_end_rel[k - 1], 0],
[f_rel[k - 1], 0],
],
dtype=float,
)
)
active_fix.set_transform(ax_pupil.get_xaxis_transform())
title_extra = ""
if show_stimulus and trial_duration_sec > 0:
tr = int(np.floor(p_rel[p_i] / trial_duration_sec)) + 1
tr = int(np.clip(tr, 1, max(n_trials, 1)))
title_extra = f" | trial {tr}"
ax_pupil.set_title(
f"t={p_rel[p_i]:.2f}s | pupil={p_d[p_i]:.2f} | fixation={f_dur[k - 1]:.0f} ms{title_extra}",
fontsize=10,
)
fig.canvas.draw()
fw, fh = fig.canvas.get_width_height()
arr = np.frombuffer(fig.canvas.buffer_rgba(), dtype=np.uint8).reshape(fh, fw, 4)
frames.append(Image.fromarray(arr[..., :3]))
_save_gif(frames, out_path, fps=fps)
plt.close(fig)
return {
"label": label,
"n_fixations": int(len(fix)),
"mean_fixation_duration_ms": float(np.mean(f_dur)) if len(f_dur) else 0.0,
"total_fixation_time_s": float(np.sum(f_dur) / 1000.0) if len(f_dur) else 0.0,
"pupil_mean": float(np.mean(p_d)) if len(p_d) else 0.0,
"pupil_std": float(np.std(p_d)) if len(p_d) else 0.0,
"pupil_min": float(np.min(p_d)) if len(p_d) else 0.0,
"pupil_max": float(np.max(p_d)) if len(p_d) else 0.0,
"pupil_stats_window_end_s": t_anim_end,
"pupil_mean_stimulus_shown": mean_on,
"pupil_n_stimulus_shown": n_on,
"pupil_mean_stimulus_not_shown": mean_off,
"pupil_n_stimulus_not_shown": n_off,
}
def main() -> None:
args = parse_args()
fix_path = _resolve_existing(args.fixations, "Fixations file")
pupil_path = _resolve_existing(args.pupil, "Pupil file")
image_path: Path | None = None
bg: np.ndarray | None = None
if args.fixation_view == "panorama":
image_path = _resolve_existing(args.image, "Image file")
bg = np.array(Image.open(image_path).convert("RGB"))
out_dir = args.out_dir.expanduser().resolve()
out_dir.mkdir(parents=True, exist_ok=True)
fix = load_fixations(fix_path, conf_thr=args.confidence_threshold, max_fixations=args.max_fixations)
pupil = load_pupil(pupil_path, conf_thr=args.confidence_threshold)
pupil = _smooth_pupil(pupil, window_sec=args.window_sec)
summary = build_gif(
fix=fix,
pupil=pupil,
bg_rgb=bg,
label=args.label,
out_path=out_dir / "pupil_fixation_overlay.gif",
fps=args.fps,
interval_sec=args.interval_sec,
time_max_sec=args.time_max_sec,
time_padding_sec=args.time_padding_sec,
fixation_view=args.fixation_view,
trial_duration_sec=args.trial_duration_sec,
stimulus_duration_sec=args.stimulus_duration_sec,
n_trials=args.n_trials,
show_stimulus=not args.no_stimulus_markers,
)
pd.DataFrame([summary]).to_csv(out_dir / "pupil_fixation_summary.csv", index=False)
print(f"Outputs saved to: {out_dir}")
print(f"Fixation view: {args.fixation_view}")
print(f"Fixations used: {fix_path}")
print(f"Pupil used: {pupil_path}")
if image_path is not None:
print(f"Image used: {image_path}")
else:
print("Image used: none (graph mode)")
if __name__ == "__main__":
main()
pupil_fixation_overlay.gif to review the animation.