Python实现眼动数据可视化:从注视点轨迹到热力图的完整实战指南

Python实现眼动数据可视化:从注视点轨迹到热力图的完整实战指南 简介本资源是一套面向认知科学、人机交互与用户体验研究者的Python眼动数据分析工具包聚焦注视点轨迹绘制与热图生成两大核心任务解决眼动原始数据难以直观呈现、视觉行为模式难量化的问题。压缩包共5个文件3个核心Python模块、1份依赖说明、1份项目文档总大小仅17KB轻量高效gaze_visualizer.py负责轨迹动画与多维热图渲染eyetracking_analyzer.py实现注视检测、AOI划分与扫视识别main.py提供开箱即用的分析流程。已有126人下载学习适合具备基础Python能力的研究者或工程师快速开展眼动数据可视化实践——无需从零编码可直接加载CSV/TSV格式眼动数据一键生成带时间轴的注视路径动画、自适应平滑热图及注视时长分布统计图表并支持多被试对比与兴趣区域定量分析。1. 项目概述从数据到洞察眼动追踪可视化的核心价值眼动追踪技术听起来像是实验室里的高端玩意儿但如今它正快速渗透到用户体验研究、广告效果评估、心理学实验乃至游戏交互设计等众多领域。简单来说它记录的是人眼在观察一个界面、一张图片或一段视频时视线焦点的移动轨迹。然而原始的眼动数据只是一系列带有时间戳的坐标点X Y对于非专业人士而言这无异于天书。这就是数据可视化登场的时候——它的使命是将这些冰冷的坐标转化为直观、易懂的视觉故事让“眼睛在看哪里”这个核心问题一目了然。这个项目的核心就是使用Python这一强大的数据科学工具链实现眼动数据的两种经典可视化注视点轨迹图和热力图。轨迹图如同给视线画出了一条“行走路径”清晰展示视觉搜索的顺序和路径热力图则像给界面做了一次“热度扫描”用颜色深浅直观呈现哪些区域吸引了最多的注意力。对于产品经理、交互设计师、市场研究员或者心理学学生来说掌握这套从原始数据到可视化洞察的完整流程意味着你能够独立完成一次专业的眼动数据分析而无需依赖昂贵或封闭的商业软件。我之所以选择Python来实现是因为它的生态足够强大且灵活。从数据处理Pandas NumPy到科学计算SciPy再到可视化Matplotlib Seaborn乃至交互PlotlyPython提供了一站式的解决方案。更重要的是整个过程是透明、可定制、可复现的。你可以完全控制从数据清洗、滤波到最终图表渲染的每一个环节并根据你的具体研究问题调整可视化参数这是很多“黑箱”商业软件无法比拟的优势。接下来我将拆解整个流程从数据准备到两种核心图表的生成并分享我在实际项目中积累的实操技巧和避坑指南。2. 核心思路与工具选型构建高效可复现的分析流水线面对眼动数据可视化一个清晰的顶层设计能让你事半功倍。我的核心思路是构建一条标准化的数据处理与可视化流水线其流程可以概括为原始数据接入 - 数据清洗与预处理 - 空间与时间维度分析 - 可视化渲染 - 结果解读。这条流水线的每个环节都依赖特定的Python工具选型的理由在于它们的成熟度、性能以及彼此间无缝协作的能力。2.1 数据处理基石Pandas与NumPy原始眼动数据可能来自Tobii、EyeLink、SMI等不同品牌的设备导出格式多为CSV或TXT。Pandas的DataFrame是处理这类表格数据的绝佳容器。它不仅能轻松完成数据读取、列筛选、缺失值处理其强大的分组、聚合和时间序列操作功能对于按试次、被试或时间段分析数据至关重要。而NumPy则为底层数值计算提供支持例如计算注视点之间的距离、持续时间统计等其数组操作效率远高于纯Python循环。注意不同设备导出的数据列名可能不同。常见的必要列包括timestamp时间戳、gaze_point_x、gaze_point_y注视点坐标通常以像素或标准化坐标表示、fixation_index注视点索引如果设备已在线识别、pupil_diameter瞳孔直径可选。在数据读取后第一步永远是统一列名并理解其物理含义。2.2 可视化核心Matplotlib与SeabornMatplotlib是Python绘图的基石功能强大但API较为底层。Seaborn基于Matplotlib提供了更高级的统计图形接口和美观的默认样式特别适合绘制热力图。对于注视点轨迹我们需要精细控制每个点、每条线的样式Matplotlib的灵活性是首选。对于热力图Seaborn的heatmap函数可以一键生成并轻松集成相关性矩阵或聚合统计量。此外为了在静态图中展示时间序列Matplotlib的动画模块FuncAnimation或简单的颜色渐变映射也是常用技巧。2.3 交互与进阶Plotly与自定义算法如果你的分析报告需要交互式探索例如鼠标悬停查看具体数据点、缩放局部热点区域那么Plotly的express或graph_objects模块是理想选择。它可以生成HTML格式的交互图表直接嵌入网页或Jupyter Notebook中。在算法层面对于原始注视点数据的清洗可能需要用到基于速度或散度的算法如I-VT I-DT来识别真正的“注视点”这可以借助SciPy的信号处理功能或自行实现。2.4 环境搭建与项目结构一个清晰的项目结构有助于维护和复现。我通常这样组织eyetracking_visualization/ ├── data/ │ ├── raw/ # 存放原始数据文件 │ └── processed/ # 存放清洗后的数据 ├── notebooks/ # Jupyter Notebook用于探索性分析 ├── src/ # 源代码模块 │ ├── data_loader.py # 数据加载与清洗函数 │ ├── visualization.py # 轨迹图、热图绘制函数 │ └── analysis.py # 统计分析函数 ├── config.yaml # 配置文件如屏幕分辨率、阈值参数 └── requirements.txt # 项目依赖包列表使用requirements.txt管理依赖是专业做法内容大致如下pandas1.4.0 numpy1.21.0 matplotlib3.5.0 seaborn0.11.0 plotly5.10.0 scipy1.8.0 jupyter通过pip install -r requirements.txt即可一键搭建环境。这种结构化的方式尤其适合团队协作或需要多次运行的分析项目。3. 数据预处理实战清洗、映射与注视点识别拿到原始数据后直接绘图往往得到的是杂乱无章的噪音。高质量的可视化始于干净的数据。预处理主要包括三个步骤数据清洗、坐标映射以及关键的注视点事件识别。3.1 数据清洗处理缺失值与异常点眼动仪可能因眨眼、头部移动或跟踪丢失而产生无效数据。这些点通常表现为坐标值为NaN、0或超出屏幕物理范围。import pandas as pd import numpy as np def load_and_clean_data(filepath, screen_width1920, screen_height1080): 加载并清洗眼动数据。 假设数据包含列timestamp, gaze_x, gaze_y df pd.read_csv(filepath) # 1. 处理缺失值直接删除或插值谨慎使用 df_clean df.dropna(subset[gaze_x, gaze_y]) # 2. 剔除明显异常点坐标超出屏幕范围或为0 valid_x (df_clean[gaze_x] 0) (df_clean[gaze_x] screen_width) valid_y (df_clean[gaze_y] 0) (df_clean[gaze_y] screen_height) df_clean df_clean[valid_x valid_y] # 3. 基于速度的简单滤波可选移除瞬时跳跃点 # 计算逐点速度像素/毫秒 df_clean[delta_x] df_clean[gaze_x].diff() df_clean[delta_y] df_clean[gaze_y].diff() df_clean[delta_t] df_clean[timestamp].diff() df_clean[velocity] np.sqrt(df_clean[delta_x]**2 df_clean[delta_y]**2) / df_clean[delta_t] df_clean df_clean[df_clean[velocity] 100] # 设置一个经验速度阈值 return df_clean.reset_index(dropTrue)实操心得速度阈值如100像素/毫秒需要根据你的数据采样率和屏幕尺寸进行校准。一个经验法则是超过这个速度的眼动更可能是扫视saccade过程中的数据或噪音而非有效的注视。可以先绘制速度分布直方图来观察并确定阈值。3.2 坐标映射从数据空间到图像空间你的刺激材料如图片、网页截图和眼动数据可能存在于不同的坐标空间。例如眼动数据可能是屏幕分辨率坐标0-1920 0-1080而你的背景图可能是另一尺寸。必须将注视点坐标精确映射到背景图上。def map_coordinates(df, stim_width, stim_height, screen_width, screen_height): 将注视点坐标从屏幕空间线性映射到刺激材料空间。 假设刺激材料已等比例适配屏幕常见情况。 # 线性映射 df[mapped_x] df[gaze_x] * (stim_width / screen_width) df[mapped_y] df[gaze_y] * (stim_height / screen_height) return df如果刺激材料在屏幕上的位置有偏移例如居中显示但四周有黑边则需要更复杂的映射考虑偏移量offset。关键在于记录实验呈现时的准确布局参数。3.3 注视点识别从连续采样点到有意义事件原始数据是高速采样的凝视点我们需要将其聚类成具有心理学意义的“注视点”——即视线在空间上相对稳定停留的一段时间。这里介绍一种最常用的速度阈值算法I-VT。from scipy import signal import matplotlib.pyplot as plt def identify_fixations_ivt(df, velocity_threshold30, min_duration100): 使用I-VT算法识别注视点。 :param velocity_threshold: 速度阈值单位像素/毫秒 :param min_duration: 最小注视持续时间单位毫秒 :return: 添加了fixation_id列的DataFrame # 计算速度沿用清洗时的速度或重新计算 if velocity not in df.columns: # ... 计算速度代码 ... pass # 二值化低于阈值为潜在注视点1高于为扫视0 df[is_fixation_candidate] df[velocity] velocity_threshold # 找到连续为True的片段 df[fixation_group] (df[is_fixation_candidate] ! df[is_fixation_candidate].shift()).cumsum() df.loc[~df[is_fixation_candidate], fixation_group] -1 # 将非候选点标记为-1 # 计算每个候选片段的持续时间 fixation_groups df[df[fixation_group] ! -1].groupby(fixation_group) group_durations fixation_groups[timestamp].apply(lambda x: x.max() - x.min()) # 筛选出持续时间大于最小阈值的片段并分配注视点ID valid_groups group_durations[group_durations min_duration].index fixation_id_map {old_id: new_id for new_id, old_id in enumerate(valid_groups, start1)} df[fixation_id] df[fixation_group].map(fixation_id_map) # 计算每个注视点的平均坐标和开始、结束时间 fixations_summary df[df[fixation_id].notna()].groupby(fixation_id).agg({ mapped_x: mean, mapped_y: mean, timestamp: [min, max] }).round(2) fixations_summary.columns [fixation_x, fixation_y, start_time, end_time] fixations_summary[duration] fixations_summary[end_time] - fixations_summary[start_time] return df, fixations_summary这个函数最终输出两个结果1标记了每个采样点所属注视点ID的原始数据框2一个注视点摘要表包含每个注视点的中心坐标、起止时间和持续时间。这个摘要表是后续可视化的直接输入。4. 注视点轨迹图实现绘制视觉的“足迹”注视点轨迹图通过按时间顺序连接注视点中心直观展示视觉扫描路径。它揭示了观察者的注意转移顺序、回视行为等模式。4.1 基础静态轨迹图绘制我们使用Matplotlib以刺激材料为背景绘制注视点序列。import matplotlib.pyplot as plt from matplotlib.patches import Circle import matplotlib.cm as cm def plot_fixation_trajectory(fixations_df, background_image_pathNone, image_sizeNone): 绘制注视点轨迹图。 :param fixations_df: 注视点摘要DataFrame需包含fixation_x, fixation_y, duration, 且按时间排序 :param background_image_path: 背景图片路径 :param image_size: 背景图片尺寸宽高用于设置坐标轴范围 fig, ax plt.subplots(figsize(12, 8)) # 1. 设置背景 if background_image_path: img plt.imread(background_image_path) if image_size: ax.imshow(img, extent[0, image_size[0], image_size[1], 0]) # 注意y轴方向 else: ax.imshow(img) ax.set_xlim(0, image_size[0] if image_size else img.shape[1]) ax.set_ylim(image_size[1] if image_size else img.shape[0], 0) # 原点在左上角 else: # 如果没有背景图根据数据范围设置坐标轴 ax.set_xlim(fixations_df[fixation_x].min() - 50, fixations_df[fixation_x].max() 50) ax.set_ylim(fixations_df[fixation_y].max() 50, fixations_df[fixation_y].min() - 50) # Y轴反向 ax.set_aspect(equal) # 2. 绘制注视点及连线 # 按时间顺序绘制连线 x_coords fixations_df[fixation_x].values y_coords fixations_df[fixation_y].values ax.plot(x_coords, y_coords, o-, colorred, linewidth1.5, markersize4, alpha0.7, labelGaze Path) # 3. 用散点大小表示注视持续时间 # 将持续时间映射到点的大小范围例如50-400平方像素 dur_min, dur_max fixations_df[duration].min(), fixations_df[duration].max() if dur_max dur_min: sizes 50 350 * (fixations_df[duration] - dur_min) / (dur_max - dur_min) else: sizes 200 # 如果所有持续时间相同使用默认大小 scatter ax.scatter(x_coords, y_coords, ssizes, crange(len(fixations_df)), cmapviridis, alpha0.6, edgecolorsblack, linewidth0.5, zorder5) # 4. 添加起始点标记 ax.scatter(x_coords[0], y_coords[0], s200, marker^, colorgreen, edgecolorsdarkgreen, linewidth2, zorder10, labelStart) ax.scatter(x_coords[-1], y_coords[-1], s200, markers, colorblue, edgecolorsdarkblue, linewidth2, zorder10, labelEnd) # 5. 添加注视点序号 for i, row in fixations_df.iterrows(): ax.annotate(str(i1), (row[fixation_x], row[fixation_y]), xytext(5, 5), textcoordsoffset points, fontsize9, colordarkred) # 6. 美化图表 ax.set_xlabel(X Coordinate (pixels)) ax.set_ylabel(Y Coordinate (pixels)) ax.set_title(Fixation Trajectory with Duration Encoding, fontsize14, pad20) ax.legend(locupper right) # 添加颜色条表示时间顺序 cbar plt.colorbar(scatter, axax, shrink0.8) cbar.set_label(Fixation Sequence) plt.tight_layout() return fig, ax这段代码生成了一张信息丰富的轨迹图连线表示视线移动顺序点的大小编码了注视持续时间点越大看的时间越长颜色从紫到黄的变化表示时间先后顺序绿色三角和蓝色方块分别标记了起点和终点旁边的数字是注视点的序号。4.2 进阶动态轨迹图与交互式探索静态图有时难以表现时间流。我们可以用Matplotlib的动画功能制作动态轨迹图让注视点按时间顺序依次出现。from matplotlib.animation import FuncAnimation def create_animated_trajectory(fixations_df, background_image_path, image_size, interval500): 创建动态注视点轨迹动画。 :param interval: 每帧间隔毫秒可关联注视点实际持续时间。 fig, ax plt.subplots(figsize(10, 7)) img plt.imread(background_image_path) ax.imshow(img, extent[0, image_size[0], image_size[1], 0]) ax.set_xlim(0, image_size[0]) ax.set_ylim(image_size[1], 0) line, ax.plot([], [], ro-, linewidth1.5, markersize6, alpha0.7) scatter ax.scatter([], [], s[], c[], cmapviridis, alpha0.6, edgecolorsk, zorder5) start_marker ax.scatter([], [], s200, marker^, colorgreen, zorder10) text_annotations [] def init(): line.set_data([], []) scatter.set_offsets(np.empty((0, 2))) scatter.set_sizes([]) start_marker.set_offsets(np.empty((0, 2))) for ann in text_annotations: ann.remove() text_annotations.clear() return line, scatter, start_marker def update(frame): # frame代表当前绘制的注视点索引0到n current_data fixations_df.iloc[:frame1] x current_data[fixation_x].values y current_data[fixation_y].values # 更新连线 line.set_data(x, y) # 更新散点所有已出现的点 offsets np.column_stack((x, y)) scatter.set_offsets(offsets) # 更新散点大小基于持续时间 sizes 50 350 * (current_data[duration] - dur_min) / (dur_max - dur_min) scatter.set_sizes(sizes) # 更新散点颜色序列 scatter.set_array(np.arange(len(current_data))) # 更新起点标记 if frame 0: start_marker.set_offsets([[x[0], y[0]]]) # 更新序号标注 for ann in text_annotations: ann.remove() text_annotations.clear() for i, row in current_data.iterrows(): ann ax.annotate(str(i1), (row[fixation_x], row[fixation_y]), xytext(5,5), textcoordsoffset points, fontsize8) text_annotations.append(ann) ax.set_title(fFixation Trajectory (Frame {frame1}/{len(fixations_df)}), fontsize12) return line, scatter, start_marker, *text_annotations dur_min, dur_max fixations_df[duration].min(), fixations_df[duration].max() ani FuncAnimation(fig, update, frameslen(fixations_df), init_funcinit, intervalinterval, blitFalse, repeat_delay2000) plt.tight_layout() # 保存动画 # ani.save(fixation_trajectory.gif, writerpillow, fps2) plt.show() return ani这个动画能更生动地再现视觉搜索过程。参数interval控制每个注视点显示的时长可以设置为固定值也可以与注视点的实际持续时间相关联实现更真实的重放。5. 热力图生成可视化注意力的“密度场”如果说轨迹图描绘的是视觉路径那么热力图呈现的就是注意力的分布密度。它通过高斯核密度估计将离散的注视点通常加权了持续时间转化为一个连续的密度表面并用颜色梯度表示关注度高低。5.1 基于高斯核密度估计的热力图我们使用scipy.stats.gaussian_kde来计算二维核密度。注视点的持续时间可以作为权重表示某些点贡献了更多的“热度”。from scipy import stats import seaborn as sns def generate_heatmap(fixations_df, background_image_path, image_size, downscale_factor0.2, sigma20): 生成基于核密度估计的热力图。 :param downscale_factor: 热力图网格下采样因子用于平衡精度与计算速度。0.2表示使用原图20%的分辨率。 :param sigma: 高斯核的标准差带宽控制平滑程度。值越大热图越平滑、分散。 # 1. 准备数据 x fixations_df[fixation_x].values y fixations_df[fixation_y].values # 使用持续时间作为权重 weights fixations_df[duration].values if duration in fixations_df.columns else None # 2. 创建评估网格 h, w image_size[1], image_size[0] # 下采样网格以减少计算量 grid_h, grid_w int(h * downscale_factor), int(w * downscale_factor) X, Y np.mgrid[0:h:complex(0, grid_h), 0:w:complex(0, grid_w)] # complex步长创建网格点 positions np.vstack([Y.ravel(), X.ravel()]) # 注意np.mgrid返回的维度顺序是(Y, X)需要转置 # 3. 计算核密度估计 # 将数据堆叠(2, N) 数组 values np.vstack([x, y]) # 调整带宽矩阵。这里使用一个标量sigma意味着各向同性高斯核。 # 对于眼动数据通常x和y方向使用相同的带宽。 bandwidth_matrix np.eye(2) * (sigma ** 2) # 使用自定义协方差矩阵创建KDE对象 kde stats.gaussian_kde(values, bw_methodscott) # 先使用自动带宽 # 手动设置协方差矩阵影响平滑度 kde.covariance bandwidth_matrix kde.inv_cov np.linalg.inv(kde.covariance) if weights is not None: # 如果提供权重需要手动计算加权密度gaussian_kde本身不支持权重参数 # 这里采用一种简化方法将每个点按其权重重复近似 # 注意对于大数据集这可能效率低下。更严谨的做法是实现加权KDE。 weights_normalized weights / weights.sum() repeated_indices np.repeat(np.arange(len(x)), (weights_normalized * 100).astype(int)) # 放大权重并取整 if len(repeated_indices) 0: values_weighted np.vstack([x[repeated_indices], y[repeated_indices]]) kde stats.gaussian_kde(values_weighted, bw_methodscott) kde.covariance bandwidth_matrix kde.inv_cov np.linalg.inv(kde.covariance) # 4. 在网格位置上评估KDE Z np.reshape(kde(positions).T, X.shape) # 5. 绘制热力图 fig, ax plt.subplots(figsize(14, 10)) # 显示背景图 img plt.imread(background_image_path) ax.imshow(img, extent[0, w, h, 0], alpha0.7) # 背景半透明 # 使用Seaborn的heatmap叠加密度设置透明度 # 需要将Z的坐标轴与图像对齐imshow的extent是左下右上 heatmap sns.heatmap(Z, cmapjet, # 或 hot, coolwarm, viridis alpha0.6, # 热图透明度 cbarTrue, cbar_kws{label: Attention Density}, squareFalse, # 根据网格比例调整 axax, zorder5) # 调整热力图位置和范围以匹配背景图 heatmap.set_xlim(0, w) heatmap.set_ylim(h, 0) heatmap.set_xticks([]) heatmap.set_yticks([]) # 6. 可选叠加注视点位置 ax.scatter(x, y, s10, colorwhite, edgecolorsblack, linewidth0.5, alpha0.7, zorder10, labelFixation Centers) ax.set_title(Gaze Heatmap (Gaussian KDE), fontsize16, pad20) ax.legend(locupper right) plt.tight_layout() return fig, ax, Z这个函数生成了叠加在背景图上的半透明热力图。颜色越暖如红色、黄色表示该区域受到的注视密度越高。参数sigma是关键它控制高斯核的宽度。sigma值太小热图会呈现为分散的斑点值太大则会过度平滑丢失细节。通常需要根据屏幕分辨率和研究目的进行调试。5.2 优化与变体自适应带宽与AOI分析有时固定的sigma可能不适用于所有场景。我们可以使用更先进的自适应带宽方法或者在计算热图前先定义兴趣区AOI进行分区分析。def adaptive_heatmap(fixations_df, background_img_path, img_size, k50): 使用k近邻距离自适应确定局部带宽。 :param k: 用于计算局部密度的近邻数。 from sklearn.neighbors import NearestNeighbors x fixations_df[fixation_x].values.reshape(-1, 1) y fixations_df[fixation_y].values.reshape(-1, 1) coords np.hstack([x, y]) # 计算每个点到其第k个近邻的距离作为局部带宽的估计 nbrs NearestNeighbors(n_neighborsk1).fit(coords) # 1 因为包含自身 distances, _ nbrs.kneighbors(coords) local_bandwidth distances[:, -1] # 第k个近邻的距离 # 创建一个网格简化版实际计算需对每个点用不同带宽的核计算量较大 # 此处为示意更高效的实现可能需要使用其他库如KDEpy或近似算法 print(自适应带宽计算完成局部带宽范围, local_bandwidth.min(), local_bandwidth.max()) # ... 后续可使用每个点的local_bandwidth进行加权或分区域KDE ... # 作为简化我们可以取中位数作为全局带宽 global_sigma np.median(local_bandwidth) print(f建议的全局sigma值{global_sigma:.2f}) return generate_heatmap(fixations_df, background_img_path, img_size, sigmaglobal_sigma) def aoi_based_analysis(fixations_df, aoi_list): 兴趣区分析。 :param aoi_list: 每个AOI是一个字典如{name: Logo, x: 100, y: 150, width: 200, height: 80} results [] for aoi in aoi_list: name aoi[name] x_min, y_min aoi[x], aoi[y] x_max, y_max x_min aoi[width], y_min aoi[height] # 判断注视点是否落在AOI内 inside ((fixations_df[fixation_x] x_min) (fixations_df[fixation_x] x_max) (fixations_df[fixation_y] y_min) (fixations_df[fixation_y] y_max)) aoi_fixations fixations_df[inside] total_fixations len(fixations_df) aoi_count len(aoi_fixations) aoi_total_duration aoi_fixations[duration].sum() if duration in aoi_fixations.columns else np.nan results.append({ AOI: name, Fixation_Count: aoi_count, Fixation_Count_Percent: (aoi_count / total_fixations * 100) if total_fixations 0 else 0, Total_Duration: aoi_total_duration, Average_Duration: aoi_total_duration / aoi_count if aoi_count 0 else np.nan }) return pd.DataFrame(results)AOI分析能够提供更精确的量化指标例如“Logo区域获得了多少比例的注视次数”、“按钮区域的总注视时长是多少”这对于A/B测试或设计元素的效果评估非常有用。6. 实战整合与结果输出从单被试到多被试聚合在实际研究中我们往往需要分析多个被试的数据并生成聚合热力图或对比轨迹图。6.1 多被试数据聚合热图将多个被试的注视点数据合并可以生成反映群体注意力模式的“总览图”。关键在于数据的对齐确保所有数据映射到同一坐标空间和标准化。def aggregate_heatmap(data_list, background_img_path, img_size, sigma25, normalizeTrue): 生成多被试聚合热力图。 :param data_list: 包含多个被试注视点DataFrame的列表。 :param normalize: 是否对每个被试的贡献进行归一化避免个别被试数据量过大主导热图。 all_x, all_y, all_weights [], [], [] for i, df in enumerate(data_list): x df[fixation_x].values y df[fixation_y].values weights df[duration].values if duration in df.columns else np.ones_like(x) if normalize: weights weights / weights.sum() # 归一化使每个被试总权重为1 all_x.append(x) all_y.append(y) all_weights.append(weights) # 合并所有数据 combined_x np.concatenate(all_x) combined_y np.concatenate(all_y) combined_weights np.concatenate(all_weights) # 使用加权KDE通过重复样本近似 # 为提升效率这里采用采样策略 combined_df pd.DataFrame({fixation_x: combined_x, fixation_y: combined_y, weight: combined_weights}) # 按权重进行重采样近似 sampled_df combined_df.sample(nmin(5000, len(combined_df)), weightsweight, replaceTrue, random_state42) # 调用之前的generate_heatmap函数传入采样后的数据 fig, ax, Z generate_heatmap(sampled_df, background_img_path, img_size, sigmasigma) ax.set_title(fAggregated Gaze Heatmap (N{len(data_list)} Participants), fontsize16) return fig, ax, Z6.2 生成专业报告与图表导出分析完成后需要将结果整合成可发布的图表。Matplotlib支持多种格式和高DPI导出。def export_visualizations(fixations_df, heatmap_matrix, output_dir./output): 导出所有可视化结果。 import os os.makedirs(output_dir, exist_okTrue) # 1. 保存注视点轨迹图 fig_traj, _ plot_fixation_trajectory(fixations_df, background_image_path, image_size) traj_path os.path.join(output_dir, fixation_trajectory.png) fig_traj.savefig(traj_path, dpi300, bbox_inchestight) plt.close(fig_traj) print(f轨迹图已保存至{traj_path}) # 2. 保存热力图 fig_heat, ax_heat, Z generate_heatmap(fixations_df, background_image_path, image_size) heat_path os.path.join(output_dir, gaze_heatmap.png) fig_heat.savefig(heat_path, dpi300, bbox_inchestight, transparentFalse) # 背景不透明 plt.close(fig_heat) # 3. 保存热力图数据矩阵可用于其他软件进一步分析 np.savetxt(os.path.join(output_dir, heatmap_matrix.csv), Z, delimiter,) # 4. 保存注视点统计数据 stats fixations_df.describe().T stats_path os.path.join(output_dir, fixation_statistics.csv) stats.to_csv(stats_path) print(f统计数据已保存至{stats_path}) # 5. 生成简易文本报告 report_path os.path.join(output_dir, analysis_report.txt) with open(report_path, w) as f: f.write(f眼动数据分析报告\n) f.write(f*40 \n) f.write(f总注视点数量{len(fixations_df)}\n) f.write(f总注视时长{fixations_df[duration].sum():.0f} ms\n) f.write(f平均注视时长{fixations_df[duration].mean():.0f} ms\n) f.write(f注视点空间标准差 (X){fixations_df[fixation_x].std():.1f} px\n) f.write(f注视点空间标准差 (Y){fixations_df[fixation_y].std():.1f} px\n) f.write(f热力图峰值坐标近似{np.unravel_index(Z.argmax(), Z.shape)}\n) print(f报告已保存至{report_path})通过这样一套流程你就拥有了从原始数据到出版级图表和统计报告的完整能力。导出的高分辨率PNG图片可以直接用于论文或演示文稿CSV格式的中间数据也方便与其他工具如R SPSS进行交互。7. 常见问题与排查技巧实录在实际操作中你一定会遇到各种预料之外的情况。下面是我在多个项目中总结的典型问题及其解决方案。7.1 数据与图像对不齐问题绘制的注视点全部偏移到图像角落或完全错位。排查检查坐标映射确认屏幕分辨率screen_width,screen_height和刺激材料尺寸stim_width,stim_height是否输入正确。确保映射函数中的乘除顺序无误。检查坐标原点计算机图形学中图像坐标原点通常在左上角(0,0)而某些绘图库或数据可能使用左下角。Matplotlib的imshow的extent参数[left, right, bottom, top]决定了图像在坐标轴中的位置注意bottom和top的设置通常bottom是heighttop是0以实现原点在左上角。验证原始数据用简单的散点图不带背景绘制原始gaze_x和gaze_y看其范围是否与宣称的屏幕分辨率匹配。有时数据可能是归一化的0-1需要反归一化。7.2 热力图过于稀疏或过度平滑问题热力图要么是几个孤立的亮点要么是一片模糊没有清晰的焦点。解决调整sigma带宽这是最关键的参数。可以先尝试一个经验值如屏幕宽度的1/50到1/20。然后根据效果微调。技巧绘制不同sigma值如10 25 50 100的热力图进行对比选择能清晰显示热点又不失细节的那个。检查数据量如果单个被试的注视点很少如少于20个热力图必然稀疏。考虑聚合多个试次或多个被试的数据。使用自适应带宽如上面提到的adaptive_heatmap函数对于数据分布不均匀的情况效果更好。下采样网格downscale_factor太小如0.05会导致网格太粗糙丢失细节太大如0.8则计算缓慢。0.1到0.3是一个合理的范围。7.3 注视点识别算法效果不佳问题I-VT算法将很多扫视点误判为注视点或者把长注视拆成了多个短注视。调参与优化速度阈值velocity_threshold这是区分注视和扫视的关键。可以通过绘制所有采样点的速度分布直方图来观察。通常速度分布会呈现双峰一个低峰注视一个高峰扫视。阈值应设在两峰之间的谷底附近。最小注视持续时间min_duration通常设置在50-200毫秒。低于此值可能是噪音或微眼跳。可以结合领域知识调整。考虑使用更复杂的算法对于高速或噪音大的数据可以尝试I-DT散度阈值算法或者使用开源库如pymovements、eyekit它们实现了更鲁棒的算法。7.4 性能问题处理大数据集时速度慢问题当处理高频采样如500Hz的长时程数据或多个被试数据时核密度估计计算非常耗时。优化策略下采样在计算热力图前对注视点坐标进行网格下采样或者使用downscale_factor大幅降低评估网格的分辨率。近似计算使用更快的近似KDE方法例如通过scipy.ndimage.gaussian_filter对二值化的注视点矩阵进行高斯滤波这在很多情况下是足够的近似且速度快得多。并行处理如果分析多个独立被试使用multiprocessing或joblib库进行并行计算。使用专用库探索如KDEpy这样的库它提供了更高效的算法实现。7.5 可视化图表不够美观或信息过载问题生成的图看起来不专业或者元素太多难以阅读。设计建议颜色映射热力图慎用彩虹色jet虽然对比强但可能扭曲数据感知。推荐使用感知均匀的色图如viridis、plasma、magma适用于黑白打印时仍有区分度。在Seaborn中cmaprocket或cmapmako也是很好的选择。简化轨迹图如果注视点过多轨迹图会变成一团乱麻。可以尝试1只绘制前N个或后N个注视点2用透明度alpha表示时间远近3完全移除连线只用带序号的散点。分面绘制对于多试次或多被试对比使用plt.subplots创建多个子图保持坐标轴范围一致便于比较。添加比例尺和指北针如果背景是真实场景图添加比例尺如“200像素”和方向指示会提升专业性。掌握这些排查技巧你就能从容应对大部分分析过程中遇到的挑战确保最终的可视化结果既准确又具有洞察力。整个Python实现流程虽然涉及多个步骤但一旦构建成模块化的脚本就可以成为你分析眼动数据的强大、灵活且可复用的武器库。本文还有配套的精品资源点击获取