a panorama image and exporting an animated GIF with dual scanpaths and cumulative fixation time.
attention_comparison.gif (dual scanpath replay + cumulative fixation time)
pair_summary.csv (compact two-row summary)
fixations.csv exports. It uses median gaze of both participants → image center.
fixations.csv files PanoramaSc7.jpg under EyeTracking/ (or pass --panorama)pandasnumpymatplotlibPillow
fixations.csv paths (participant A and B)PanoramaSc7.jpg in EyeTracking/ or pass --panoramaoutputs_tutorial5_pair_sc7/ next to the script unless you pass --out-dir.--fixations-a — participant A fixations.csv--fixations-b — participant B fixations.csv--label-a / --label-b (defaults: Participant A / Participant B).fixations.csv: start_timestamp, duration, norm_pos_x, norm_pos_y; confidence is used when present to filter low-quality rows.
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.
attention_comparison.gifpair_summary.csv with fixation counts and timing stats per participant--panorama PATH, --out-dir PATH, --gif-fps N, --gif-frames N, --confidence-threshold, --max-fixations.attention_comparison.gifpair_summary.csv
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()
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).