news 2026/9/3 14:30:54

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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)乃至交互(Plotly),Python提供了一站式的解决方案。更重要的是,整个过程是透明、可定制、可复现的。你可以完全控制从数据清洗、滤波到最终图表渲染的每一个环节,并根据你的具体研究问题调整可视化参数,这是很多“黑箱”商业软件无法比拟的优势。接下来,我将拆解整个流程,从数据准备到两种核心图表的生成,并分享我在实际项目中积累的实操技巧和避坑指南。

2. 核心思路与工具选型:构建高效可复现的分析流水线

面对眼动数据可视化,一个清晰的顶层设计能让你事半功倍。我的核心思路是构建一条标准化的数据处理与可视化流水线,其流程可以概括为:原始数据接入 -> 数据清洗与预处理 -> 空间与时间维度分析 -> 可视化渲染 -> 结果解读。这条流水线的每个环节都依赖特定的Python工具,选型的理由在于它们的成熟度、性能以及彼此间无缝协作的能力。

2.1 数据处理基石:Pandas与NumPy

原始眼动数据可能来自Tobii、EyeLink、SMI等不同品牌的设备,导出格式多为CSV或TXT。Pandas的DataFrame是处理这类表格数据的绝佳容器。它不仅能轻松完成数据读取、列筛选、缺失值处理,其强大的分组、聚合和时间序列操作功能,对于按试次、被试或时间段分析数据至关重要。而NumPy则为底层数值计算提供支持,例如计算注视点之间的距离、持续时间统计等,其数组操作效率远高于纯Python循环。

注意:不同设备导出的数据列名可能不同。常见的必要列包括:timestamp(时间戳)、gaze_point_xgaze_point_y(注视点坐标,通常以像素或标准化坐标表示)、fixation_index(注视点索引,如果设备已在线识别)、pupil_diameter(瞳孔直径,可选)。在数据读取后,第一步永远是统一列名并理解其物理含义。

2.2 可视化核心:Matplotlib与Seaborn

Matplotlib是Python绘图的基石,功能强大但API较为底层。Seaborn基于Matplotlib,提供了更高级的统计图形接口和美观的默认样式,特别适合绘制热力图。对于注视点轨迹,我们需要精细控制每个点、每条线的样式,Matplotlib的灵活性是首选。对于热力图,Seaborn的heatmap函数可以一键生成,并轻松集成相关性矩阵或聚合统计量。此外,为了在静态图中展示时间序列,Matplotlib的动画模块(FuncAnimation)或简单的颜色渐变映射也是常用技巧。

2.3 交互与进阶:Plotly与自定义算法

如果你的分析报告需要交互式探索,例如鼠标悬停查看具体数据点、缩放局部热点区域,那么Plotly的expressgraph_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管理依赖是专业做法,内容大致如下:

pandas>=1.4.0 numpy>=1.21.0 matplotlib>=3.5.0 seaborn>=0.11.0 plotly>=5.10.0 scipy>=1.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_width=1920, screen_height=1080): """ 加载并清洗眼动数据。 假设数据包含列:'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(drop=True)

实操心得:速度阈值(如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_threshold=30, min_duration=100): """ 使用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, start=1)} 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_path=None, image_size=None): """ 绘制注视点轨迹图。 :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-', color='red', linewidth=1.5, markersize=4, alpha=0.7, label='Gaze 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, s=sizes, c=range(len(fixations_df)), cmap='viridis', alpha=0.6, edgecolors='black', linewidth=0.5, zorder=5) # 4. 添加起始点标记 ax.scatter(x_coords[0], y_coords[0], s=200, marker='^', color='green', edgecolors='darkgreen', linewidth=2, zorder=10, label='Start') ax.scatter(x_coords[-1], y_coords[-1], s=200, marker='s', color='blue', edgecolors='darkblue', linewidth=2, zorder=10, label='End') # 5. 添加注视点序号 for i, row in fixations_df.iterrows(): ax.annotate(str(i+1), (row['fixation_x'], row['fixation_y']), xytext=(5, 5), textcoords='offset points', fontsize=9, color='darkred') # 6. 美化图表 ax.set_xlabel('X Coordinate (pixels)') ax.set_ylabel('Y Coordinate (pixels)') ax.set_title('Fixation Trajectory with Duration Encoding', fontsize=14, pad=20) ax.legend(loc='upper right') # 添加颜色条表示时间顺序 cbar = plt.colorbar(scatter, ax=ax, shrink=0.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, interval=500): """ 创建动态注视点轨迹动画。 :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-', linewidth=1.5, markersize=6, alpha=0.7) scatter = ax.scatter([], [], s=[], c=[], cmap='viridis', alpha=0.6, edgecolors='k', zorder=5) start_marker = ax.scatter([], [], s=200, marker='^', color='green', zorder=10) 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[:frame+1] 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(i+1), (row['fixation_x'], row['fixation_y']), xytext=(5,5), textcoords='offset points', fontsize=8) text_annotations.append(ann) ax.set_title(f'Fixation Trajectory (Frame {frame+1}/{len(fixations_df)})', fontsize=12) return line, scatter, start_marker, *text_annotations dur_min, dur_max = fixations_df['duration'].min(), fixations_df['duration'].max() ani = FuncAnimation(fig, update, frames=len(fixations_df), init_func=init, interval=interval, blit=False, repeat_delay=2000) plt.tight_layout() # 保存动画 # ani.save('fixation_trajectory.gif', writer='pillow', fps=2) 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_factor=0.2, sigma=20): """ 生成基于核密度估计的热力图。 :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_method='scott') # 先使用自动带宽 # 手动设置协方差矩阵(影响平滑度) 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_method='scott') 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], alpha=0.7) # 背景半透明 # 使用Seaborn的heatmap叠加密度,设置透明度 # 需要将Z的坐标轴与图像对齐(imshow的extent是左下右上) heatmap = sns.heatmap(Z, cmap='jet', # 或 'hot', 'coolwarm', 'viridis' alpha=0.6, # 热图透明度 cbar=True, cbar_kws={'label': 'Attention Density'}, square=False, # 根据网格比例调整 ax=ax, zorder=5) # 调整热力图位置和范围以匹配背景图 heatmap.set_xlim(0, w) heatmap.set_ylim(h, 0) heatmap.set_xticks([]) heatmap.set_yticks([]) # 6. 可选:叠加注视点位置 ax.scatter(x, y, s=10, color='white', edgecolors='black', linewidth=0.5, alpha=0.7, zorder=10, label='Fixation Centers') ax.set_title('Gaze Heatmap (Gaussian KDE)', fontsize=16, pad=20) ax.legend(loc='upper 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, k=50): """ 使用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_neighbors=k+1).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, sigma=global_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, sigma=25, normalize=True): """ 生成多被试聚合热力图。 :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(n=min(5000, len(combined_df)), weights='weight', replace=True, random_state=42) # 调用之前的generate_heatmap函数,传入采样后的数据 fig, ax, Z = generate_heatmap(sampled_df, background_img_path, img_size, sigma=sigma) ax.set_title(f'Aggregated Gaze Heatmap (N={len(data_list)} Participants)', fontsize=16) return fig, ax, Z

6.2 生成专业报告与图表导出

分析完成后,需要将结果整合成可发布的图表。Matplotlib支持多种格式和高DPI导出。

def export_visualizations(fixations_df, heatmap_matrix, output_dir='./output'): """ 导出所有可视化结果。 """ import os os.makedirs(output_dir, exist_ok=True) # 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, dpi=300, bbox_inches='tight') 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, dpi=300, bbox_inches='tight', transparent=False) # 背景不透明 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 数据与图像对不齐

  • 问题:绘制的注视点全部偏移到图像角落或完全错位。
  • 排查
    1. 检查坐标映射:确认屏幕分辨率(screen_width,screen_height)和刺激材料尺寸(stim_width,stim_height)是否输入正确。确保映射函数中的乘除顺序无误。
    2. 检查坐标原点:计算机图形学中,图像坐标原点通常在左上角(0,0),而某些绘图库或数据可能使用左下角。Matplotlib的imshowextent参数[left, right, bottom, top]决定了图像在坐标轴中的位置,注意bottomtop的设置(通常bottomheighttop0以实现原点在左上角)。
    3. 验证原始数据:用简单的散点图(不带背景)绘制原始gaze_xgaze_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(散度阈值)算法,或者使用开源库如pymovementseyekit,它们实现了更鲁棒的算法。

7.4 性能问题:处理大数据集时速度慢

  • 问题:当处理高频采样(如500Hz)的长时程数据或多个被试数据时,核密度估计计算非常耗时。
  • 优化策略
    1. 下采样:在计算热力图前,对注视点坐标进行网格下采样,或者使用downscale_factor大幅降低评估网格的分辨率。
    2. 近似计算:使用更快的近似KDE方法,例如通过scipy.ndimage.gaussian_filter对二值化的注视点矩阵进行高斯滤波,这在很多情况下是足够的近似,且速度快得多。
    3. 并行处理:如果分析多个独立被试,使用multiprocessingjoblib库进行并行计算。
    4. 使用专用库:探索如KDEpy这样的库,它提供了更高效的算法实现。

7.5 可视化图表不够美观或信息过载

  • 问题:生成的图看起来不专业,或者元素太多难以阅读。
  • 设计建议
    • 颜色映射:热力图慎用彩虹色(jet),虽然对比强,但可能扭曲数据感知。推荐使用感知均匀的色图,如viridisplasmamagma(适用于黑白打印时仍有区分度)。在Seaborn中,cmap='rocket'cmap='mako'也是很好的选择。
    • 简化轨迹图:如果注视点过多,轨迹图会变成一团乱麻。可以尝试:1)只绘制前N个或后N个注视点;2)用透明度(alpha)表示时间远近;3)完全移除连线,只用带序号的散点。
    • 分面绘制:对于多试次或多被试对比,使用plt.subplots创建多个子图,保持坐标轴范围一致,便于比较。
    • 添加比例尺和指北针:如果背景是真实场景图,添加比例尺(如“200像素”)和方向指示会提升专业性。

掌握这些排查技巧,你就能从容应对大部分分析过程中遇到的挑战,确保最终的可视化结果既准确又具有洞察力。整个Python实现流程虽然涉及多个步骤,但一旦构建成模块化的脚本,就可以成为你分析眼动数据的强大、灵活且可复用的武器库。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/3 14:24:21

SpringBoot校园二手电动车交易系统:毕业设计实战与源码解析

简介&#xff1a;这是一套面向计算机专业本科生的Java毕业设计实战资源&#xff0c;基于Spring Boot快速开发校园二手电动车交易平台&#xff0c;解决高校学生间闲置电动车高效流转与安全交易的实际需求。资源包含完整前后端源码、MySQL建库脚本、详细说明文档及毕业论文&#…

作者头像 李华
网站建设 2026/9/3 14:23:08

游戏高光时刻自动检测:从事件感知到内容生成的技术实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/3 14:22:53

python的图论工业场景模拟第五十篇:介度中心性与物流网络关键枢纽识别,任务:计算节点介度中心性,高频被路过的节点是物流瓶颈点,图建模说明:无向/有向图,nx.betweenness_central

介度中心性与物流网络关键枢纽识别&#xff1a;找出那条"必经之路"上的瓶颈"工厂物流 AGV 路径规划做完后&#xff0c;现场反馈&#xff1a;某段通道天天堵车&#xff0c;AGV 排队等通行&#xff0c;产线经常因为物料迟到而停线。我一开始以为是调度算法的问题&…

作者头像 李华
网站建设 2026/9/3 14:16:14

计量经济学与Stata应用:从核心概念到实战建模的完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华