Pairwise Attention Comparison with Scanpath GIF in Python using Pupil Labs Eye Tracking

This guide explains how to compare two participants by mapping fixations onto a panorama image and exporting an animated GIF with dual scanpaths and cumulative fixation time.

By the end, you will generate:
- attention_comparison.gif (dual scanpath replay + cumulative fixation time)
- pair_summary.csv (compact two-row summary)

Note. This code works with Pupil Labs fixations.csv exports. It uses median gaze of both participants → image center.

Download example fixations (participant A)
Download example fixations (participant B)

Download Panorama Image

Requirements

- Anaconda Python Development Environment
- Two fixations.csv files
- PanoramaSc7.jpg under EyeTracking/ (or pass --panorama)
- Python packages:
  - pandas
  - numpy
  - matplotlib
  - Pillow

Setup

1. Install Anaconda if needed.
2. Open Spyder, Jupyter Notebook, or VS Code.
3. Put your data in reach:
  - two fixations.csv paths (participant A and B)
  - PanoramaSc7.jpg in EyeTracking/ or pass --panorama
4. Default outputs go to outputs_tutorial5_pair_sc7/ next to the script unless you pass --out-dir.

To use Spyder, install Anaconda, run it, and launch Spyder. If you see Install instead of Launch for Spyder, install Spyder first.

Step 1 Choose the two datasets

Required arguments:
- --fixations-a — participant A fixations.csv
- --fixations-b — participant B fixations.csv

Optional: --label-a / --label-b (defaults: Participant A / Participant B).

Important columns in fixations.csv: start_timestamp, duration, norm_pos_x, norm_pos_y; confidence is used when present to filter low-quality rows.

Step 2 Calibration and mapping

The script computes a combined shift so the median normalized gaze of both tracks aligns with the image center (0.5, 0.5 in display-normalized space). Fixations are then drawn on the panorama with scanpath polylines, time-ordered scatter (size reflects fixation duration), and a lower panel of cumulative fixation time versus fixation index.

Step 3 Build the GIF and summary

1. Load both fixation tables and the panorama
2. Apply the combined median-to-center alignment
3. Render sampled frames and write attention_comparison.gif
4. Save pair_summary.csv with fixation counts and timing stats per participant

Learning outcome: you can compare where and how long two people looked in the same scene, in time.

Step 4 Run the script

Adjust paths to your exports:
Optional: --panorama PATH, --out-dir PATH, --gif-fps N, --gif-frames N, --confidence-threshold, --max-fixations.

Expected output files (default folder):
- attention_comparison.gif
- pair_summary.csv

Step 5 Code

Use the script file. It reads fixations.csv, aligns gaze using the combined median, renders dual scanpaths on PanoramaSc7.jpg, and writes the GIF plus a two-row CSV summary.
                                        
                                            """
                                            @author: Fjorda
                                            """

                                            from __future__ import annotations

                                            import argparse
                                            from pathlib import Path
                                            from typing import Tuple

                                            import matplotlib.pyplot as plt
                                            import numpy as np
                                            import pandas as pd
                                            from PIL import Image

                                            # Legend / cumulative lines / scanpath polylines (single source of truth).
                                            PARTICIPANT_A_ACCENT = "#ff6b00"
                                            PARTICIPANT_B_ACCENT = "#0080ff"


                                            def save_pair_gif_frames(frames: list, out_path: Path, duration_ms: int) -> None:
                                                """Write GIF; disable dithering; explicit per-frame duration for stable playback."""
                                                n = len(frames)
                                                if n == 0:
                                                    raise ValueError("save_pair_gif_frames: empty frame list")
                                                dur = max(1, int(round(duration_ms)))
                                                duration_list = [dur] * n
                                                kw: dict = dict(save_all=True, append_images=frames[1:], loop=0, duration=duration_list)
                                                try:
                                                    kw["dither"] = Image.Dither.NONE
                                                except AttributeError:
                                                    pass
                                                try:
                                                    frames[0].save(out_path, **kw)
                                                except TypeError:
                                                    frames[0].save(
                                                        out_path,
                                                        save_all=True,
                                                        append_images=frames[1:],
                                                        loop=0,
                                                        duration=duration_list,
                                                    )


                                            def load_fixations(path: Path, conf_threshold: 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 required columns in {path}: {sorted(missing)}")

                                                if "confidence" in df.columns:
                                                    df = df[df["confidence"] >= conf_threshold].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 map_to_panorama_pixels(
                                                fix: pd.DataFrame, W: int, H: int, shift_x: float, shift_y: float
                                            ) -> Tuple[np.ndarray, np.ndarray]:
                                                norm_x = np.clip(fix["norm_pos_x"].to_numpy(dtype=float), 0.0, 1.0)
                                                norm_y_display = 1.0 - np.clip(fix["norm_pos_y"].to_numpy(dtype=float), 0.0, 1.0)

                                                img_x = np.clip(norm_x + shift_x, 0.0, 1.0)
                                                img_y = np.clip(norm_y_display + shift_y, 0.0, 1.0)

                                                px_x = img_x * (W - 1)
                                                px_y = img_y * (H - 1)
                                                return px_x, px_y

                                            SCRIPT_DIR = Path(__file__).resolve().parent
                                            EYETRACKING_DIR = SCRIPT_DIR.parent

                                            PANORAMA_CANDIDATES = [
                                                EYETRACKING_DIR / "PanoramaSc7.jpg",
                                                EYETRACKING_DIR / "PanoramaSc_7.jpg",
                                                SCRIPT_DIR / "PanoramaSc7.jpg",
                                                EYETRACKING_DIR / "Plots" / "PanoramaSc7.jpg",
                                            ]

                                            IMG_CENTER_NORM_X = 0.5
                                            IMG_CENTER_NORM_Y = 0.5


                                            def parse_args() -> argparse.Namespace:
                                                parser = argparse.ArgumentParser(
                                                    description="Tutorial: scanpath GIF on Panorama"
                                                )
                                                parser.add_argument("--fixations-a", type=Path, required=True, help="First fixations.csv (e.g. Sc7StreetPedestrian)")
                                                parser.add_argument("--fixations-b", type=Path, required=True, help="Second fixations.csv")
                                                parser.add_argument("--label-a", type=str, default="Participant A")
                                                parser.add_argument("--label-b", type=str, default="Participant B")
                                                parser.add_argument(
                                                    "--panorama",
                                                    type=Path,
                                                    default=None,
                                                    help="Panorama image",
                                                )
                                                parser.add_argument(
                                                    "--out-dir",
                                                    type=Path,
                                                    default=SCRIPT_DIR / "outputs_tutorial5_pair_sc7",
                                                    help="Output folder (default: Tutorials/outputs_tutorial5_pair_sc7)",
                                                )
                                                parser.add_argument("--confidence-threshold", type=float, default=0.5)
                                                parser.add_argument("--max-fixations", type=int, default=220)
                                                parser.add_argument("--gif-fps", type=int, default=4)
                                                parser.add_argument("--gif-frames", type=int, default=80, help="Number of GIF frames (sampled)")
                                                return parser.parse_args()


                                            def find_panorama(explicit: Path | None) -> Path:
                                                if explicit is not None:
                                                    p = explicit.expanduser().resolve()
                                                    if not p.is_file():
                                                        raise FileNotFoundError(f"Panorama not found: {p}")
                                                    return p
                                                for cand in PANORAMA_CANDIDATES:
                                                    if cand.is_file():
                                                        return cand.resolve()
                                                raise FileNotFoundError(
                                                    "PanoramaSc7.jpg not found. Place it under EyeTracking/ or pass --panorama PATH. "
                                                    f"Tried: {', '.join(str(c) for c in PANORAMA_CANDIDATES)}"
                                                )


                                            def sc7_combined_shift(fix_a: pd.DataFrame, fix_b: pd.DataFrame) -> Tuple[float, float]:
                                                """Align combined median gaze to image center."""
                                                parts_x: list[np.ndarray] = []
                                                parts_y: list[np.ndarray] = []
                                                for fix in (fix_a, fix_b):
                                                    nx = np.clip(fix["norm_pos_x"].to_numpy(dtype=float), 0.0, 1.0)
                                                    ny_disp = 1.0 - np.clip(fix["norm_pos_y"].to_numpy(dtype=float), 0.0, 1.0)
                                                    parts_x.append(nx)
                                                    parts_y.append(ny_disp)
                                                all_x = np.concatenate(parts_x) if parts_x else np.array([], dtype=float)
                                                all_y = np.concatenate(parts_y) if parts_y else np.array([], dtype=float)
                                                if all_x.size == 0:
                                                    return 0.0, 0.0
                                                med_x = float(np.median(all_x))
                                                med_y = float(np.median(all_y))
                                                return IMG_CENTER_NORM_X - med_x, IMG_CENTER_NORM_Y - med_y


                                            def build_track_sc7(
                                                fix: pd.DataFrame, label: str, W: int, H: int, shift_x: float, shift_y: float
                                            ) -> dict:
                                                px_x, px_y = map_to_panorama_pixels(fix, W=W, H=H, shift_x=shift_x, shift_y=shift_y)
                                                dur_ms = fix["duration"].to_numpy(dtype=float)
                                                cum_time_s = np.cumsum(dur_ms) / 1000.0
                                                return {
                                                    "label": label,
                                                    "x": px_x,
                                                    "y": px_y,
                                                    "dur_ms": dur_ms,
                                                    "cum_time_s": cum_time_s,
                                                    "total_fix_time_s": float(np.sum(dur_ms) / 1000.0) if len(dur_ms) else 0.0,
                                                    "mean_dur_ms": float(np.mean(dur_ms)) if len(dur_ms) else 0.0,
                                                    "n": len(fix),
                                                }


                                            def _update_scanpath_mono(
                                                x_seq: np.ndarray,
                                                y_seq: np.ndarray,
                                                durations: np.ndarray,
                                                scat,
                                            ) -> None:
                                                n = int(len(x_seq))
                                                if n == 0:
                                                    scat.set_offsets(np.empty((0, 2), dtype=float))
                                                    scat.set_alpha(0.0)
                                                    return

                                                scat.set_alpha(0.92)
                                                scat.set_edgecolor("none")
                                                scat.set_linewidths(0)

                                                order = np.arange(n, dtype=float)
                                                d = np.asarray(durations, dtype=float)
                                                dur_scaled = (d - d.min()) / max(d.max() - d.min(), 1e-9)
                                                sizes = 20.0 + dur_scaled * 160.0
                                                scat.set_offsets(np.c_[x_seq, y_seq])
                                                scat.set_array(order)
                                                scat.set_sizes(sizes)
                                                scat.set_clim(0, max(n - 1, 1))


                                            def build_pair_gif_sc7(
                                                track_a: dict,
                                                track_b: dict,
                                                panorama_rgb: np.ndarray,
                                                out_path: Path,
                                                fps: int,
                                                gif_frames: int,
                                            ) -> None:
                                                W = panorama_rgb.shape[1]
                                                H = panorama_rgb.shape[0]

                                                bg_rgb = (1.0, 1.0, 1.0)
                                                xA, yA, xB, yB = track_a["x"], track_a["y"], track_b["x"], track_b["y"]
                                                nA, nB = track_a["n"], track_b["n"]

                                                start_k = 1
                                                max_n = max(nA, nB)
                                                frame_stop = max_n
                                                frame_count = min(gif_frames, max(1, frame_stop - start_k + 1))
                                                frame_idxs = np.unique(np.linspace(start_k, frame_stop, num=frame_count, dtype=int))

                                                max_cum = float(
                                                    max(
                                                        track_a["cum_time_s"][-1] if nA else 0.0,
                                                        track_b["cum_time_s"][-1] if nB else 0.0,
                                                    )
                                                )
                                                y_pad = max(0.1, 0.07 * max(1e-9, max_cum))

                                                fig = plt.figure(figsize=(13.2, 8.0))
                                                gs = fig.add_gridspec(2, 2, height_ratios=[2.1, 1.0], wspace=0.015, hspace=0.22)
                                                ax_a = fig.add_subplot(gs[0, 0])
                                                ax_b = fig.add_subplot(gs[0, 1])
                                                ax_line = fig.add_subplot(gs[1, :])

                                                fig.patch.set_facecolor(bg_rgb)
                                                panel_bg = "#0f0f14"
                                                ax_a.set_facecolor(panel_bg)
                                                ax_b.set_facecolor(panel_bg)
                                                ax_line.set_facecolor(bg_rgb)
                                                fig.subplots_adjust(left=0.005, right=0.995, bottom=0.12, top=0.98, wspace=0.015, hspace=0.22)

                                                badge_a = dict(facecolor="#241208", edgecolor="none", boxstyle="round,pad=0.35")
                                                badge_b = dict(facecolor="#0c1828", edgecolor="none", boxstyle="round,pad=0.35")
                                                for ax, track, badge, txt in [
                                                    (ax_a, track_a, badge_a, "#fff0e6"),
                                                    (ax_b, track_b, badge_b, "#e6f4ff"),
                                                ]:
                                                    ax.imshow(panorama_rgb, extent=[0, W, H, 0], aspect="equal", interpolation="bilinear")
                                                    ax.set_xlim(0, W)
                                                    ax.set_ylim(H, 0)
                                                    ax.set_aspect("equal")
                                                    ax.set_axis_off()
                                                    ax.text(
                                                        0.01,
                                                        0.99,
                                                        str(track["label"]),
                                                        transform=ax.transAxes,
                                                        color=txt,
                                                        fontsize=12,
                                                        ha="left",
                                                        va="top",
                                                        bbox=badge,
                                                        zorder=50,
                                                    )

                                                # Scanpath polylines — same accent hexes as legend (alpha only for path under scatter).
                                                (path_a,) = ax_a.plot(
                                                    [],
                                                    [],
                                                    color=PARTICIPANT_A_ACCENT,
                                                    linewidth=1.15,
                                                    alpha=0.55,
                                                    solid_capstyle="round",
                                                    antialiased=False,
                                                    zorder=28,
                                                )
                                                (path_b,) = ax_b.plot(
                                                    [],
                                                    [],
                                                    color=PARTICIPANT_B_ACCENT,
                                                    linewidth=1.15,
                                                    alpha=0.55,
                                                    solid_capstyle="round",
                                                    antialiased=False,
                                                    zorder=28,
                                                )

                                                cmap_a = plt.get_cmap("turbo")
                                                cmap_b = plt.get_cmap("plasma")
                                                scat_a = ax_a.scatter(
                                                    [0],
                                                    [0],
                                                    s=[0],
                                                    c=[0],
                                                    cmap=cmap_a,
                                                    vmin=0,
                                                    vmax=1,
                                                    alpha=0.0,
                                                    edgecolors="none",
                                                    linewidths=0,
                                                    antialiased=False,
                                                    zorder=35,
                                                )
                                                scat_b = ax_b.scatter(
                                                    [0],
                                                    [0],
                                                    s=[0],
                                                    c=[0],
                                                    cmap=cmap_b,
                                                    vmin=0,
                                                    vmax=1,
                                                    alpha=0.0,
                                                    edgecolors="none",
                                                    linewidths=0,
                                                    antialiased=False,
                                                    zorder=35,
                                                )

                                                ax_line.set_xlabel("Fixation index", labelpad=8)
                                                ax_line.set_ylabel("Cumulative fixation time (s)")
                                                ax_line.grid(alpha=0.25)
                                                ax_line.set_xlim(0, max_n)
                                                ax_line.set_ylim(0.0, max_cum + y_pad)

                                                (cumA_line,) = ax_line.plot(
                                                    [], [], color=PARTICIPANT_A_ACCENT, linewidth=2.6, label=track_a["label"], antialiased=False
                                                )
                                                (cumB_line,) = ax_line.plot(
                                                    [], [], color=PARTICIPANT_B_ACCENT, linewidth=2.6, label=track_b["label"], antialiased=False
                                                )
                                                leg = ax_line.legend(loc="upper left", frameon=False)
                                                for h, c in zip(leg.get_lines(), (PARTICIPANT_A_ACCENT, PARTICIPANT_B_ACCENT)):
                                                    h.set_color(c)
                                                    h.set_linewidth(2.6)
                                                    h.set_antialiased(False)

                                                durA = track_a["dur_ms"]
                                                durB = track_b["dur_ms"]

                                                frames = []
                                                for k in frame_idxs:
                                                    n1 = min(k, nA)
                                                    n2 = min(k, nB)

                                                    if n1 >= 2:
                                                        path_a.set_data(xA[:n1], yA[:n1])
                                                    elif n1 == 1:
                                                        path_a.set_data([xA[0]], [yA[0]])
                                                    else:
                                                        path_a.set_data([], [])

                                                    if n2 >= 2:
                                                        path_b.set_data(xB[:n2], yB[:n2])
                                                    elif n2 == 1:
                                                        path_b.set_data([xB[0]], [yB[0]])
                                                    else:
                                                        path_b.set_data([], [])

                                                    _update_scanpath_mono(xA[:n1], yA[:n1], durA[:n1], scat_a)
                                                    _update_scanpath_mono(xB[:n2], yB[:n2], durB[:n2], scat_b)

                                                    xs1 = np.arange(n1, dtype=float)
                                                    xs2 = np.arange(n2, dtype=float)
                                                    cumA_line.set_data(xs1, track_a["cum_time_s"][:n1])
                                                    cumB_line.set_data(xs2, track_b["cum_time_s"][:n2])

                                                    fig.canvas.draw()
                                                    w, h = fig.canvas.get_width_height()
                                                    arr = np.frombuffer(fig.canvas.buffer_rgba(), dtype=np.uint8).reshape(h, w, 4)
                                                    frames.append(Image.fromarray(arr[..., :3]))

                                                duration_ms = int(1000 / max(1, fps))
                                                save_pair_gif_frames(frames, out_path, duration_ms)
                                                plt.close(fig)


                                            def main() -> None:
                                                args = parse_args()
                                                out_dir = args.out_dir.expanduser().resolve()
                                                out_dir.mkdir(parents=True, exist_ok=True)

                                                pan_path = find_panorama(args.panorama)
                                                panorama_rgb = np.array(Image.open(pan_path).convert("RGB"))
                                                H, W = panorama_rgb.shape[0], panorama_rgb.shape[1]

                                                fix_a = load_fixations(
                                                    args.fixations_a.expanduser().resolve(), args.confidence_threshold, args.max_fixations
                                                )
                                                fix_b = load_fixations(
                                                    args.fixations_b.expanduser().resolve(), args.confidence_threshold, args.max_fixations
                                                )

                                                shift_x, shift_y = sc7_combined_shift(fix_a, fix_b)
                                                track_a = build_track_sc7(fix_a, args.label_a, W=W, H=H, shift_x=shift_x, shift_y=shift_y)
                                                track_b = build_track_sc7(fix_b, args.label_b, W=W, H=H, shift_x=shift_x, shift_y=shift_y)

                                                build_pair_gif_sc7(
                                                    track_a=track_a,
                                                    track_b=track_b,
                                                    panorama_rgb=panorama_rgb,
                                                    out_path=out_dir / "attention_comparison.gif",
                                                    fps=args.gif_fps,
                                                    gif_frames=args.gif_frames,
                                                )

                                                pd.DataFrame(
                                                    [
                                                        {
                                                            "label": track_a["label"],
                                                            "n_fixations": track_a["n"],
                                                            "total_fixation_time_s": track_a["total_fix_time_s"],
                                                            "mean_fix_duration_ms": track_a["mean_dur_ms"],
                                                        },
                                                        {
                                                            "label": track_b["label"],
                                                            "n_fixations": track_b["n"],
                                                            "total_fixation_time_s": track_b["total_fix_time_s"],
                                                            "mean_fix_duration_ms": track_b["mean_dur_ms"],
                                                        },
                                                    ]
                                                ).to_csv(out_dir / "pair_summary.csv", index=False)

                                                print(f"Outputs saved to: {out_dir}")
                                                print(f"Panorama used: {pan_path}")


                                            if __name__ == "__main__":
                                                main()


                                            
                                        
                                    
After you run the script, open attention_comparison.gif to review the pairwise scanpath animation and pair_summary.csv for numeric comparison. Example assets below use the same paths as on this site (replace with your generated files when you upload).

Conclusions

  • The GIF makes temporal differences in where people look (and total fixation time) easier to compare than static plots alone.
  • It uses combined median-to-image-center alignment, matching the analysis narrative.
  • This workflow is scanpath and cumulative time focused.