门将扑救反应速度评估案例
下面是一个完整的 Python 案例,展示如何通过视频分析评估门将扑救反应速度,核心思路是:追踪球的位置和门将手/身体位置,计算从球出现威胁到门将启动扑救的时间差。

评估指标设计
| 指标 | 说明 |
|---|---|
| 反应时间 (Reaction Time) | 球离开射门脚 → 门将重心开始移动的时间差 |
| 出手时间 (Initiation Time) | 球离开脚 → 门将手开始朝向球方向移动 |
| 位移启动延迟 | 球轨迹确定 → 门将身体位移 > 阈值 |
| 扑救成功率关联 | 反应时间与是否扑出的相关性 |
完整代码示例
环境依赖
pip install opencv-python numpy scipy pandas matplotlib ultralytics
球与门将追踪
import cv2
import numpy as np
from ultralytics import YOLO
from collections import deque
# 加载 YOLOv8 模型(球和 person 均可检测,也可以训练专用模型)
model = YOLO("yolov8n.pt")
def track_ball_and_keeper(video_path, fps=30):
"""
返回:
ball_positions: list[(frame_idx, x, y)]
keeper_positions: list[(frame_idx, x, y, w, h)] 门将整体框
"""
cap = cv2.VideoCapture(video_path)
ball_positions = []
keeper_positions = []
# 用颜色阈值辅助找球(足球多为白/黑,训练比赛可用颜色)
# 这里用 YOLO 的 sports ball 类 + 简单过滤
prev_ball = None
while cap.isOpened():
ret, frame = cap.read()
if not ret:
break
idx = int(cap.get(cv2.CAP_PROP_POS_FRAMES))
results = model(frame, verbose=False)[0]
ball_candidates = []
keeper_boxes = []
for box in results.boxes:
cls = int(box.cls[0])
conf = float(box.conf[0])
x1, y1, x2, y2 = map(int, box.xyxy[0])
cx, cy = (x1 + x2) // 2, (y1 + y2) // 2
if cls == 32 and conf > 0.3: # sports ball
ball_candidates.append((cx, cy, conf))
elif cls == 0: # person
# 只保留右侧半场(门将区域),可按实际场景过滤
keeper_boxes.append((x1, y1, x2, y2, conf))
# 选择最接近上一帧的球(防误检)
if ball_candidates:
if prev_ball is None:
ball = max(ball_candidates, key=lambda c: c[2])
else:
ball = min(ball_candidates,
key=lambda c: (c[0]-prev_ball[0])**2 + (c[1]-prev_ball[1])**2)
prev_ball = ball[:2]
ball_positions.append((idx, ball[0], ball[1]))
# 选择最大的 person 作为门将(通常最靠近球门)
if keeper_boxes:
biggest = max(keeper_boxes, key=lambda b: (b[2]-b[0])*(b[3]-b[1]))
keeper_positions.append((idx, *biggest[:4]))
cap.release()
return ball_positions, keeper_positions
计算反应时间
from scipy.signal import savgol_filter
def compute_reaction_time(ball_positions, keeper_positions, fps=30,
speed_thresh_kmh=80, keeper_move_thresh=6):
"""
1) 检测射门帧:球速突增超过阈值
2) 检测门将启动帧:门将质心横/纵位移速度超过阈值
3) 反应时间 = (启动帧 - 射门帧) / fps
"""
# --- 球的速度 ---
frames = np.array([p[0] for p in ball_positions])
xs = np.array([p[1] for p in ball_positions], dtype=float)
ys = np.array([p[2] for p in ball_positions], dtype=float)
# 平滑
if len(xs) > 7:
xs_s = savgol_filter(xs, 7, 2)
ys_s = savgol_filter(ys, 7, 2)
else:
xs_s, ys_s = xs, ys
# 像素速度 → 实际速度需标定(此处假设 1m ≈ 50px)
PX_PER_M = 50
vx = np.diff(xs_s) * fps / PX_PER_M # m/s
vy = np.diff(ys_s) * fps / PX_PER_M
speed_ms = np.hypot(vx, vy)
speed_kmh = speed_ms * 3.6
# 射门帧:球速首次>阈值
shot_idx = np.argmax(speed_kmh > speed_thresh_kmh)
if speed_kmh[shot_idx] < speed_thresh_kmh:
return None # 未检测到射门
# --- 门将质心位移速度 ---
kframes = np.array([k[0] for k in keeper_positions])
kcx = np.array([(k[1]+k[1]+k[3])/2 for k in keeper_positions]) # 质心x
kcy = np.array([(k[2]+k[2]+k[4])/2 for k in keeper_positions]) # 质心y
# 对齐帧
common = np.intersect1d(kframes, frames)
if len(common) < 5:
return None
# 门将质心速度(像素/帧)
d_move = np.hypot(np.diff(kcx), np.diff(kcy))
# 找到射门帧之后,门将启动帧
shot_frame = frames[shot_idx]
mask = kframes[:-1] >= shot_frame
if not mask.any():
return None
d_after = d_move[mask]
idx_after = np.where(mask)[0]
# 首次超过阈值
start_candidates = np.where(d_after > keeper_move_thresh)[0]
if len(start_candidates) == 0:
return None
start_idx = idx_after[start_candidates[0]]
start_frame = kframes[start_idx]
reaction_time_s = (start_frame - shot_frame) / fps
return {
"shot_frame": int(shot_frame),
"keeper_start_frame": int(start_frame),
"reaction_time_ms": round(reaction_time_s * 1000, 1),
"ball_speed_kmh": round(float(speed_kmh[shot_idx]), 1)
}
批量评估 + 可视化
import pandas as pd
import matplotlib.pyplot as plt
def evaluate_goalkeeper(videos, labels):
"""
videos: list of 视频路径
labels: list of True(扑出)/False(未扑出)
"""
rows = []
for vid, saved in zip(videos, labels):
ball_p, keeper_p = track_ball_and_keeper(vid)
res = compute_reaction_time(ball_p, keeper_p)
if res:
res["video"] = vid
res["saved"] = saved
rows.append(res)
df = pd.DataFrame(rows)
print(df.describe())
print("\n平均反应时间:", df["reaction_time_ms"].mean(), "ms")
# 反应时间 vs 是否扑出
saved_rt = df[df.saved]["reaction_time_ms"]
missed_rt = df[~df.saved]["reaction_time_ms"]
print(f"扑出时平均反应: {saved_rt.mean():.0f}ms")
print(f"未扑出平均反应: {missed_rt.mean():.0f}ms")
# 画图
fig, ax = plt.subplots(1, 2, figsize=(12, 4))
df.boxplot(column="reaction_time_ms", by="saved", ax=ax[0])
ax[0].set_title("反应时间 vs 扑救结果")
ax[1].scatter(df["ball_speed_kmh"], df["reaction_time_ms"],
c=df["saved"].map({True:"green",False:"red"}))
ax[1].set_xlabel("球速 (km/h)"); ax[1].set_ylabel("反应时间 (ms)")
plt.tight_layout(); plt.show()
return df
# 使用示例
if __name__ == "__main__":
videos = ["shot1.mp4", "shot2.mp4", "shot3.mp4"]
labels = [True, False, True]
df = evaluate_goalkeeper(videos, labels)
df.to_csv("goalkeeper_reaction.csv", index=False)
关键要点与注意事项
阈值选择
- 球速阈值:职业射门 80–120 km/h,青少年可降到 40–60 km/h
- 门将位移阈值:需按视频分辨率调整,通常质心位移 > 5–10 px/帧
- 需像素→米标定:用球门宽度(7.32m)标定 PX_PER_M
更精确的门将动作检测
# 用 MediaPipe / YOLO-pose 检测手腕关键点,比整体框更准 import mediapipe as mp # 追踪 wrist 关键点 → 计算手腕速度 → 更贴近"真实出手时机"
数据采集规范
- 固定机位(正对球门 / 侧面)
- 高帧率(>60fps 更精确,30fps 误差约 ±33ms)
- 排除遮挡帧
常见误差来源
| 来源 | 影响 | 改进 |
|---|---|---|
| 帧率低 | ±33ms 误差 | 用 120fps 高速摄像 |
| 球误检 | 速度突变 | 卡尔曼滤波平滑轨迹 |
| 标定不准 | 速度计算偏差 | 用球门+禁区线标定 |
| 门将判定帧主观 | 启动时刻 | 定义"位移>3%身宽"等客观规则 |
进阶方向
- 卡尔曼滤波跟踪球,减少误检导致的假射门帧
- 姿态估计(YOLO-pose)直接追踪门将手部启动
- 深度学习(LSTM / TCN)建模反应时间与扑救成功率关系
- 对比分析:不同站位、不同球速下的反应时间分布