fixations.csv). The code uses a panorama image captured in VRpandasnumpymatplotlibPillow (usually included with Anaconda; used for reading images and writing image files)
fixations.csv (required)aoi_transitions_dwell.py in the same folder (or a folder of your choice and pass paths on the command line).fixations.csv: start_timestamp: fixation start time (used for temporal order) duration: fixation duration in milliseconds (used for dwell-time totals) norm_pos_x, norm_pos_y: normalized fixation coordinates (used for AOI classification) confidence: fixation confidence (used to optionally filter low-quality fixations) fixations.csvAOI or Elsewhere--image file when you run it.fixations.csv in the same folder as the script and run:python aoi_transitions_dwell.py --fixations "fixations.csv"python aoi_transitions_dwell.py --fixations "fixations.csv" --aoi-left 2933 --aoi-right 3210 --aoi-top 873 --aoi-bottom 1158aoi_membership_timeline.pngaoi_dwell_time.pngaoi_transition_matrix.pngaoi_membership_ribbon.pngaoi_cumulative_dwell.pngaoi_duration_strip.png
aoi_transitions_dwell.py. The script uses fixation timestamps, durations, and normalized fixation coordinates to compute AOI dwell and transition behavior.
"""
@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
# Default avatar AOI rectangle in panorama pixel space (original orientation)
IMG_W = 4096
IMG_H = 2048
AOI_X_LEFT = 2933
AOI_X_RIGHT = 3210
AOI_Y_TOP = 873
AOI_Y_BOTTOM = 1158
def parse_args():
script_dir = Path(__file__).resolve().parent
parser = argparse.ArgumentParser(
description="Analyze AOI dwell time and transitions from Pupil Labs fixations.csv."
)
parser.add_argument("--fixations", type=Path, default=script_dir / "fixations.csv", help="Path to fixations.csv")
parser.add_argument("--image", type=Path, default=None, help="Optional panorama image for overlay plots")
parser.add_argument("--out-dir", type=Path, default=script_dir / "outputs_tutorial4", help="Output directory")
parser.add_argument("--confidence-threshold", type=float, default=0.5, help="Minimum fixation confidence")
parser.add_argument("--max-fixations", type=int, default=200, help="Maximum number of fixations")
parser.add_argument("--img-width", type=int, default=IMG_W, help="Panorama width in pixels")
parser.add_argument("--img-height", type=int, default=IMG_H, help="Panorama height in pixels")
parser.add_argument("--aoi-left", type=float, default=AOI_X_LEFT, help="AOI left x in px")
parser.add_argument("--aoi-right", type=float, default=AOI_X_RIGHT, help="AOI right x in px")
parser.add_argument("--aoi-top", type=float, default=AOI_Y_TOP, help="AOI top y in px")
parser.add_argument("--aoi-bottom", type=float, default=AOI_Y_BOTTOM, help="AOI bottom y in px")
return parser.parse_args()
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 required columns in fixations.csv: {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 is not None and len(df) > max_fixations:
df = df.iloc[:max_fixations].copy()
return df
def fixations_to_pixels(df: pd.DataFrame, w: int, h: int) -> Tuple[np.ndarray, np.ndarray]:
x = np.clip(df["norm_pos_x"].to_numpy(dtype=float), 0.0, 1.0) * (w - 1)
y = (1.0 - np.clip(df["norm_pos_y"].to_numpy(dtype=float), 0.0, 1.0)) * (h - 1)
return x, y
def aoi_calibration_shift(df: pd.DataFrame, img_w: int, img_h: int, left: float, right: float, top: float, bottom: float) -> Tuple[float, float]:
norm_x = np.clip(df["norm_pos_x"].to_numpy(dtype=float), 0.0, 1.0)
norm_y_display = 1.0 - np.clip(df["norm_pos_y"].to_numpy(dtype=float), 0.0, 1.0)
aoi_center_x = ((left + right) / 2.0) / float(img_w)
aoi_center_y = 1.0 - ((top + bottom) / 2.0) / float(img_h)
return aoi_center_x - float(np.median(norm_x)), aoi_center_y - float(np.median(norm_y_display))
def classify_regions(x: np.ndarray, y: np.ndarray, left: float, right: float, top: float, bottom: float) -> np.ndarray:
in_aoi = (x >= left) & (x <= right) & (y >= top) & (y <= bottom)
return np.where(in_aoi, "AOI", "Elsewhere")
def transition_matrix(labels: np.ndarray) -> tuple[np.ndarray, list[str]]:
states = ["AOI", "Elsewhere"]
idx = {s: i for i, s in enumerate(states)}
M = np.zeros((2, 2), dtype=int)
for a, b in zip(labels[:-1], labels[1:]):
M[idx[a], idx[b]] += 1
return M, states
def transition_counts(labels: np.ndarray) -> dict[tuple[str, str], int]:
keys = [("AOI", "AOI"), ("AOI", "Elsewhere"), ("Elsewhere", "AOI"), ("Elsewhere", "Elsewhere")]
out = {k: 0 for k in keys}
for a, b in zip(labels[:-1], labels[1:]):
out[(a, b)] += 1
return out
def plot_membership_timeline(labels: np.ndarray, out_path: Path):
seq = (labels == "AOI").astype(int)
x = np.arange(len(seq))
fig, ax = plt.subplots(figsize=(12, 3.8))
ax.step(x, seq, where="post", linewidth=2.0)
ax.set_yticks([0, 1])
ax.set_yticklabels(["Elsewhere", "AOI"])
ax.set_xlabel("Fixation order")
ax.set_ylabel("Region")
ax.set_title("AOI membership timeline")
ax.grid(alpha=0.25)
fig.tight_layout()
fig.savefig(out_path, dpi=180)
plt.close(fig)
def plot_membership_ribbon(labels: np.ndarray, durations_ms: np.ndarray, out_path: Path):
fig, ax = plt.subplots(figsize=(12, 2.6))
durations_s = np.clip(durations_ms.astype(float) / 1000.0, 0.0, None)
starts = np.r_[0.0, np.cumsum(durations_s[:-1])]
colors = np.where(labels == "AOI", "#ef553b", "#636efa")
for s, d, c in zip(starts, durations_s, colors):
ax.broken_barh([(s, d)], (0.1, 0.8), facecolors=c, edgecolors="white", linewidth=0.35)
ax.set_ylim(0, 1)
ax.set_yticks([])
ax.set_xlabel("Time (seconds)")
ax.set_title("AOI ribbon timeline (duration-weighted)")
ax.grid(axis="x", alpha=0.2)
fig.tight_layout()
fig.savefig(out_path, dpi=180)
plt.close(fig)
def plot_dwell_time(labels: np.ndarray, durations_ms: np.ndarray, out_path: Path):
dwell_aoi = float(np.sum(durations_ms[labels == "AOI"]))
dwell_else = float(np.sum(durations_ms[labels == "Elsewhere"]))
vals_s = np.array([dwell_aoi, dwell_else], dtype=float) / 1000.0
fig, ax = plt.subplots(figsize=(7.2, 4.8))
bars = ax.bar(["AOI", "Elsewhere"], vals_s, color=["#ef553b", "#636efa"], alpha=0.95)
for b in bars:
h = b.get_height()
ax.text(b.get_x() + b.get_width() / 2.0, h, f"{h:.2f}s", ha="center", va="bottom", fontsize=10)
ax.set_title("Total dwell time by region")
ax.set_ylabel("Seconds")
ax.grid(axis="y", alpha=0.25)
fig.tight_layout()
fig.savefig(out_path, dpi=180)
plt.close(fig)
def plot_cumulative_dwell(labels: np.ndarray, durations_ms: np.ndarray, out_path: Path):
dur_s = durations_ms.astype(float) / 1000.0
t = np.r_[0.0, np.cumsum(dur_s)]
cum_aoi = np.r_[0.0, np.cumsum(np.where(labels == "AOI", dur_s, 0.0))]
cum_else = np.r_[0.0, np.cumsum(np.where(labels == "Elsewhere", dur_s, 0.0))]
fig, ax = plt.subplots(figsize=(10.8, 4.8))
ax.plot(t, cum_aoi, color="#ef553b", linewidth=2.2, label="AOI")
ax.plot(t, cum_else, color="#636efa", linewidth=2.2, label="Elsewhere")
ax.set_title("Cumulative dwell time")
ax.set_xlabel("Time (seconds)")
ax.set_ylabel("Accumulated dwell (seconds)")
ax.grid(alpha=0.25)
ax.legend(frameon=False)
fig.tight_layout()
fig.savefig(out_path, dpi=180)
plt.close(fig)
def plot_transition_matrix(M: np.ndarray, states: list[str], out_path: Path):
fig, ax = plt.subplots(figsize=(6.8, 5.6))
im = ax.imshow(M, cmap="Blues")
ax.set_xticks(range(len(states)))
ax.set_yticks(range(len(states)))
ax.set_xticklabels(states)
ax.set_yticklabels(states)
ax.set_xlabel("To")
ax.set_ylabel("From")
ax.set_title("AOI transition matrix")
for i in range(M.shape[0]):
for j in range(M.shape[1]):
ax.text(j, i, str(int(M[i, j])), ha="center", va="center", color="black")
fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
fig.subplots_adjust(left=0.20, right=0.92, bottom=0.12, top=0.90)
fig.savefig(out_path, dpi=180, bbox_inches="tight", pad_inches=0.03)
plt.close(fig)
def plot_duration_strip(labels: np.ndarray, durations_ms: np.ndarray, out_path: Path):
# Bar chart: average fixation duration by sequence bins and region.
n = len(labels)
bins = min(6, max(3, n // 3 if n >= 9 else 3))
edges = np.linspace(0, n, bins + 1).astype(int)
centers = np.arange(bins, dtype=float)
aoi_means = np.full(bins, np.nan, dtype=float)
else_means = np.full(bins, np.nan, dtype=float)
dur = durations_ms.astype(float)
for i in range(bins):
lo, hi = edges[i], edges[i + 1]
if hi <= lo:
continue
lab = labels[lo:hi]
d = dur[lo:hi]
if np.any(lab == "AOI"):
aoi_means[i] = float(np.mean(d[lab == "AOI"]))
if np.any(lab == "Elsewhere"):
else_means[i] = float(np.mean(d[lab == "Elsewhere"]))
fig, ax = plt.subplots(figsize=(11.2, 4.8))
w = 0.38
x1 = centers - w / 2.0
x2 = centers + w / 2.0
# Replace NaN with 0 for plotting; keep legend and labels clear.
aoi_plot = np.nan_to_num(aoi_means, nan=0.0)
else_plot = np.nan_to_num(else_means, nan=0.0)
ax.bar(x1, aoi_plot, width=w, color="#ef553b", alpha=0.9, label="AOI")
ax.bar(x2, else_plot, width=w, color="#636efa", alpha=0.9, label="Elsewhere")
tick_labels = [f"{edges[i]}-{max(edges[i+1]-1, edges[i])}" for i in range(bins)]
ax.set_xticks(centers)
ax.set_xticklabels(tick_labels)
ax.set_title("Average fixation duration by sequence bin and region")
ax.set_xlabel("Fixation index range")
ax.set_ylabel("Duration (ms)")
ax.grid(axis="y", alpha=0.25)
ax.legend(frameon=False)
fig.tight_layout()
fig.savefig(out_path, dpi=180)
plt.close(fig)
def plot_sankey_like(transitions: dict[tuple[str, str], int], out_path: Path):
# Lightweight Sankey-style figure using thick curved links.
fig, ax = plt.subplots(figsize=(9.2, 5.8))
ax.set_xlim(0, 1)
ax.set_ylim(0, 1)
ax.axis("off")
# Node positions
xL, xR = 0.22, 0.78
yA, yE = 0.72, 0.28
node_w, node_h = 0.08, 0.14
ax.add_patch(plt.Rectangle((xL - node_w / 2, yA - node_h / 2), node_w, node_h, color="#ef553b", alpha=0.9))
ax.add_patch(plt.Rectangle((xL - node_w / 2, yE - node_h / 2), node_w, node_h, color="#636efa", alpha=0.9))
ax.add_patch(plt.Rectangle((xR - node_w / 2, yA - node_h / 2), node_w, node_h, color="#ef553b", alpha=0.9))
ax.add_patch(plt.Rectangle((xR - node_w / 2, yE - node_h / 2), node_w, node_h, color="#636efa", alpha=0.9))
ax.text(xL, 0.92, "From", ha="center", va="center", fontsize=11)
ax.text(xR, 0.92, "To", ha="center", va="center", fontsize=11)
ax.text(xL, yA, "AOI", ha="center", va="center", color="white", fontsize=10, fontweight="bold")
ax.text(xL, yE, "Elsewhere", ha="center", va="center", color="white", fontsize=10, fontweight="bold")
ax.text(xR, yA, "AOI", ha="center", va="center", color="white", fontsize=10, fontweight="bold")
ax.text(xR, yE, "Elsewhere", ha="center", va="center", color="white", fontsize=10, fontweight="bold")
vals = np.array(
[
transitions[("AOI", "AOI")],
transitions[("AOI", "Elsewhere")],
transitions[("Elsewhere", "AOI")],
transitions[("Elsewhere", "Elsewhere")],
],
dtype=float,
)
max_v = max(float(vals.max()), 1.0)
def link(y0: float, y1: float, value: float, color: str):
lw = 2.0 + 16.0 * (value / max_v)
ax.annotate(
"",
xy=(xR - node_w / 2, y1),
xytext=(xL + node_w / 2, y0),
arrowprops=dict(
arrowstyle="-",
linewidth=lw,
color=color,
alpha=0.42,
connectionstyle="arc3,rad=0.0",
),
)
ax.text(0.5, (y0 + y1) / 2.0 + (0.03 if y0 > y1 else -0.03), f"{int(value)}", ha="center", va="center", fontsize=9)
link(yA, yA, transitions[("AOI", "AOI")], "#ef553b")
link(yA, yE, transitions[("AOI", "Elsewhere")], "#a855f7")
link(yE, yA, transitions[("Elsewhere", "AOI")], "#10b981")
link(yE, yE, transitions[("Elsewhere", "Elsewhere")], "#636efa")
ax.set_title("Sankey-style AOI transitions", pad=12)
fig.tight_layout()
fig.savefig(out_path, dpi=180)
plt.close(fig)
def plot_transition_network(transitions: dict[tuple[str, str], int], out_path: Path):
fig, ax = plt.subplots(figsize=(7.2, 5.8))
ax.set_xlim(0, 1)
ax.set_ylim(0, 1)
ax.axis("off")
pos = {"AOI": (0.3, 0.5), "Elsewhere": (0.7, 0.5)}
node_color = {"AOI": "#ef553b", "Elsewhere": "#636efa"}
for n, (x, y) in pos.items():
circ = plt.Circle((x, y), 0.095, color=node_color[n], alpha=0.92)
ax.add_patch(circ)
ax.text(x, y, n, ha="center", va="center", color="white", fontweight="bold")
max_v = max(float(max(transitions.values())), 1.0)
pairs = [("AOI", "AOI"), ("AOI", "Elsewhere"), ("Elsewhere", "AOI"), ("Elsewhere", "Elsewhere")]
for a, b in pairs:
v = float(transitions[(a, b)])
lw = 1.5 + 10.0 * (v / max_v)
if a == b:
x, y = pos[a]
loop = plt.Circle((x, y + 0.17), 0.06, fill=False, linewidth=lw, color="#94a3b8", alpha=0.7)
ax.add_patch(loop)
ax.text(x, y + 0.27, f"{int(v)}", ha="center", va="center", fontsize=9)
else:
xa, ya = pos[a]
xb, yb = pos[b]
rad = 0.2 if a == "AOI" else -0.2
ax.annotate(
"",
xy=(xb, yb),
xytext=(xa, ya),
arrowprops=dict(arrowstyle="->", linewidth=lw, color="#475569", alpha=0.75, connectionstyle=f"arc3,rad={rad}"),
)
ax.text(0.5, 0.63 if a == "AOI" else 0.37, f"{int(v)}", ha="center", va="center", fontsize=9)
ax.set_title("Transition network graph")
fig.tight_layout()
fig.savefig(out_path, dpi=180)
plt.close(fig)
def draw_aoi_rect(ax, left: float, right: float, top: float, bottom: float):
ax.add_patch(
plt.Rectangle(
(left, top),
right - left,
bottom - top,
fill=False,
edgecolor="white",
linewidth=2.2,
linestyle="--",
)
)
def main():
args = parse_args()
fix_path = args.fixations.expanduser().resolve()
out_dir = args.out_dir.expanduser().resolve()
out_dir.mkdir(parents=True, exist_ok=True)
if not fix_path.exists():
raise FileNotFoundError(f"Missing fixations file: {fix_path}")
df = load_fixations(fix_path, args.confidence_threshold, args.max_fixations)
left = min(args.aoi_left, args.aoi_right)
right = max(args.aoi_left, args.aoi_right)
top = min(args.aoi_top, args.aoi_bottom)
bottom = max(args.aoi_top, args.aoi_bottom)
shift_x, shift_y = aoi_calibration_shift(df, args.img_width, args.img_height, left, right, top, bottom)
x, y = fixations_to_pixels(df, args.img_width, args.img_height)
x = np.clip(x + shift_x * (args.img_width - 1), 0.0, args.img_width - 1)
y = np.clip(y + shift_y * (args.img_height - 1), 0.0, args.img_height - 1)
labels = classify_regions(
x=x,
y=y,
left=left,
right=right,
top=top,
bottom=bottom,
)
M, states = transition_matrix(labels)
transitions = transition_counts(labels)
durations_ms = df["duration"].to_numpy(dtype=float)
plot_membership_timeline(labels, out_dir / "aoi_membership_timeline.png")
plot_membership_ribbon(labels, durations_ms, out_dir / "aoi_membership_ribbon.png")
plot_dwell_time(labels, durations_ms, out_dir / "aoi_dwell_time.png")
plot_cumulative_dwell(labels, durations_ms, out_dir / "aoi_cumulative_dwell.png")
plot_transition_matrix(M, states, out_dir / "aoi_transition_matrix.png")
plot_duration_strip(labels, durations_ms, out_dir / "aoi_duration_strip.png")
# Sankey-style output intentionally omitted for this tutorial version.
# We intentionally skip panorama fixation map here because this visualization
# is already covered in previous tutorials.
print(f"Fixations used: {len(df):,}")
print(f"AOI fixations: {int(np.sum(labels == 'AOI')):,}")
print(f"Outputs saved to: {out_dir}")
if __name__ == "__main__":
main()