Pupil Diameter and Fixation Overlay in Python (Pupil Labs)

This guide shows how to combine fixation events and a pupil diameter time series from Pupil Labs exports into one synchronized view: fixation location (or overlay on an image) updates together with pupil size over time.

By the end, you will generate:
- An animated GIF (pupil_fixation_overlay.gif) combining fixations and pupil diameter over time
- A small summary table (pupil_fixation_summary.csv)

Note. This workflow expects Pupil Labs CSV exports, especially fixations.csv and pupil_positions.csv. Time is shown as seconds since recording start for readability.

Download fixations.csv
Download pupil_positions.csv

Requirements

- Anaconda Python Development Environment
- CSV files exported from Pupil Labs (fixations.csv, pupil_positions.csv)
- Optional background image for panorama overlay mode
- Python packages:
  - pandas
  - numpy
  - matplotlib
  - Pillow (usually included with Anaconda; used for reading images and writing the GIF)

Setup

1. Install Anaconda if needed.
2. Open Spyder, Jupyter Notebook, or VS Code.
3. Put the files in your working folder:
  - fixations.csv (required)
  - pupil_positions.csv (required)
  - optional: a panorama image file (for panorama overlay mode)
4. Save the script in the same folder (or a folder of your choice and pass paths on the command line).

To use Spyder, install Anaconda, run it, and launch Spyder. If you see Install instead of Launch for Spyder, install Spyder first. Create a new file in Spyder and save it in the same directory as your data when you run the examples below.

Step 1 Data

We start with fixations and pupil samples exported by Pupil Labs.

Important variables from fixations.csv:
- start_timestamp, duration (fixation timing)
- norm_pos_x, norm_pos_y (normalized gaze position for the fixation)
- confidence (optional filter)

Important variables from pupil_positions.csv:
- pupil_timestamp, diameter
- confidence (optional filter)

Step 2 Build the dual-panel animation

In this step, you:
1. Load fixations.csv and pupil_positions.csv
2. Filter low-confidence samples where available
3. Align both streams to a common start time
4. Smooth pupil diameter and draw fixations in graph or panorama coordinates
5. Render one frame per fixation and save a GIF

This links where the observer looks with how pupil size evolves over the same interval.

Step 3 Generate outputs

1. Write pupil_fixation_overlay.gif (animated figure)
2. Write pupil_fixation_summary.csv (numeric summary)

Outputs are saved to your outputs folder by default.

Step 4 Run the Script

Include fixations.csv and pupil_positions.csv in the same folder as the script and run the code.

Expected output files (saved automatically to your outputs folder):
- pupil_fixation_overlay.gif
- pupil_fixation_summary.csv

Optional flags: --no-stimulus-markers, --trial-duration-sec, --stimulus-duration-sec, --n-trials, --confidence-threshold

Step 5 Code

The script uses fixation timestamps and normalized gaze positions for the left panel, 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()


                                            
                                        
                                    
After you run the script, open pupil_fixation_overlay.gif to review the animation.

Conclusions

  • Fixation plus pupil overlay relates spatial looking patterns to pupil dynamics on one timeline.
  • GIF output is easy to embed in slides and reports.
  • Optional stimulus timing and trial colors support blocked designs; turn them off for other paradigms.