ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

YOLOv8+Streamlit实战:球员和足球检测跟踪全流程

YOLOv8+Streamlit实战:球员和足球检测跟踪全流程 简介这是一套面向计算机视觉学习者与体育数据分析爱好者的Python实战资源围绕YOLOv8与Streamlit构建足球场景下的检测与跟踪方案可解决球员、裁判、足球及球场关键点的识别与位置估计问题。压缩包共67个文件约380.97MB包含pt模型权重、yaml数据集配置、py与ipynb源码脚本、mp4演示视频、jpg与png示意图及csv、json等数据文件覆盖从模型推理到可视化展示的完整链路。已有421人学习下载。资源核心价值在于提供可运行的Streamlit多Tab交互页面涵盖使用说明、团队颜色、模型超参数与检测模块并附带战术地图绘制、球队颜色聚类、球员与球位置估计等实战代码配合演示视频与工作流示意图便于读者快速复现足球分析流程并理解YOLOv8在体育场景中的落地方式。1. 球员和足球检测跟踪为什么YOLOv8Streamlit是当前最省事的组合足球视频分析里最让人头疼的不是模型精度不够而是从检测到跟踪再到可视化链路太长。你拿YOLOv8跑完一帧得到一堆边界框但球场上22个人加一个足球框和框之间没有身份关联下一帧谁是谁完全对不上。传统做法是检测归检测、跟踪归跟踪、前端归前端三套代码三套依赖调通就要一整天。而YOLOv8Streamlit这个组合恰好把这条链路压缩到了两个核心依赖YOLOv8负责检测和内置跟踪Streamlit负责把视频流和统计结果直接渲染成可交互的Web页面。你不需要写Flask路由不需要配前端构建工具一个Python脚本就能跑起来。这篇文章面向的是想快速搭出球员和足球检测跟踪原型的开发者不管你是做体育数据分析、比赛剪辑辅助还是单纯想跑通一个多目标跟踪的完整案例下面的步骤和参数都能直接抄。我会把环境配置、检测跟踪代码、Streamlit界面、参数调优和踩坑记录全部拆开讲清楚让你在本地能复现出一个可用的系统。2. 环境搭建与YOLOv8基础检测从零跑通第一帧2.1 依赖安装与版本选择YOLOv8的官方包是ultralytics它把模型定义、训练、推理、跟踪全部封装在一个pip包里。Python版本建议3.8到3.11太低不支持新的类型注解太高有些依赖还没跟上。我一般用conda建一个干净环境避免和系统里的其他包打架。conda create -n football_track python3.10 -y conda activate football_track pip install ultralytics streamlit opencv-python numpy pandas这四行命令装完核心依赖就齐了。ultralytics会自动拉取torch和torchvision如果你有NVIDIA显卡并且装了CUDA它会装GPU版本如果没有默认装CPU版本也能跑只是帧率会低一些。这里有个血泪经验不要手动去装torch再装ultralytics版本冲突的概率很高让ultralytics自己解析依赖最稳。装完之后用一行命令验证yolo checks这个命令会打印出你的Python版本、torch版本、CUDA是否可用、以及当前ultralytics的版本号。如果CUDA那一栏显示不可用但你确实有显卡大概率是torch装成了CPU版需要去pytorch官网找对应CUDA版本的安装命令重装torch。CPU版本跑YOLOv8n在640分辨率下大概每帧80到120毫秒对于离线视频分析够用实时流就吃力了。2.2 用YOLOv8做球员和足球的零样本检测YOLOv8的预训练权重是在COCO数据集上训练的COCO的80个类别里正好有person和sports ball这两个类person对应球员sports ball对应足球。这意味着你不需要训练就能直接检测这是这个方案能快速跑通的关键前提。from ultralytics import YOLO import cv2 # 加载YOLOv8n预训练权重n是最小版本速度最快 model YOLO(yolov8n.pt) # 打开视频文件 cap cv2.VideoCapture(match_clip.mp4) # 只保留person和sports ball两个类别 target_classes [0, 32] while cap.isOpened(): ret, frame cap.read() if not ret: break # 推理conf设0.3过滤低置信度框iou设0.5做NMS results model(frame, conf0.3, iou0.5, classestarget_classes, verboseFalse) # 在帧上画框 annotated results[0].plot() cv2.imshow(Detection, annotated) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()这段代码的逻辑很直白逐帧读取视频送进模型推理把结果画在帧上显示。几个参数需要解释一下。conf0.3是置信度阈值低于0.3的框直接丢掉足球在远景里像素少置信度经常在0.3到0.5之间设太高会漏检设太低会误检。iou0.5是NMS的IoU阈值两个人重叠时IoU超过0.5的框会被合并球场上球员密集这个值可以适当调到0.6让框保留更多。classes[0, 32]是COCO的类别索引0是person32是sports ball只检测这两类能减少计算量也能避免把裁判的哨子误认成球。verboseFalse关掉每帧的日志输出不然终端会刷屏。如果你手头没有足球视频可以用YOLO自带的示例或者随便找一个包含人物的视频先验证流程。检测跑通之后你会看到球员身上有绿色框足球上有橙色框但这时候还没有身份ID下一帧同一个人可能换一个框的颜色这就是为什么需要跟踪。2.3 模型选型n/s/m/l/x怎么挑YOLOv8提供了五个尺寸的模型从n到x参数量和精度递增。对于球员和足球检测这个任务我的建议是如果你用GPU选yolov8s或yolov8m如果只有CPU老老实实用yolov8n。原因在于足球在画面里通常只占几十个像素模型太大反而容易过拟合到训练集的球的纹理上泛化到不同比赛用球时表现不一定更好。yolov8n在COCO上对sports ball的AP大约在30左右yolov8m能到40出头但推理时间翻了三倍。实际做跟踪时检测框稍微抖一点跟踪算法能通过运动模型平滑掉所以检测精度不是唯一决定因素。模型参数量CPU单帧耗时GPU单帧耗时适合场景yolov8n3.2M80-120ms5-8msCPU环境、快速原型yolov8s11.2M200-300ms8-12msGPU环境、平衡精度速度yolov8m25.9M500-700ms15-25msGPU环境、追求精度yolov8l43.7M1.2-1.5s30-45ms离线分析、不计时间yolov8x68.2M2-3s50-80ms离线分析、极致精度这张表里的耗时是我在i7-12700和RTX 3060上实测的大致范围不同硬件会有浮动。选模型的原则是先跑通再优化不要一上来就上大模型等流程通了再换模型对比效果。3. 多目标跟踪让每一帧的球员都有稳定ID3.1 YOLOv8内置跟踪器的工作原理ultralytics从8.0版本开始内置了跟踪功能底层用的是ByteTrack算法。ByteTrack的核心思想是不光用高分检测框做匹配还把低分检测框也利用起来。传统跟踪只保留置信度高于阈值的框低于阈值的直接扔掉但那些低分框里其实有很多是被遮挡的球员扔掉就丢了。ByteTrack把高分框先和已有轨迹匹配剩下的高分框和低分框一起再和未匹配的轨迹做第二次匹配这样能找回不少被遮挡的目标。from ultralytics import YOLO model YOLO(yolov8n.pt) # 使用内置的bytetrack跟踪器 results model.track( sourcematch_clip.mp4, trackerbytetrack.yaml, conf0.3, iou0.5, classes[0, 32], persistTrue, # 保持跟踪状态跨帧 verboseFalse )model.track()和model()的区别在于track()会在内部维护轨迹状态每一帧的输出里多了一个id字段。persistTrue是关键参数它告诉跟踪器不要每帧重置状态否则每帧都会重新分配ID跟踪就失效了。trackerbytetrack.yaml指定用ByteTrackultralytics还提供了botsort.yamlBoT-SORT在ByteTrack基础上加了相机运动补偿如果你的视频是固定机位ByteTrack就够了如果是手持或者无人机拍摄BoT-SORT更稳。3.2 提取跟踪结果并做简单统计拿到跟踪结果后你可以做很多有意思的统计比如每个球员的跑动距离、足球的控球时间、球员的热区图。下面这段代码演示怎么把跟踪结果解析成结构化数据import cv2 import numpy as np from ultralytics import YOLO from collections import defaultdict model YOLO(yolov8n.pt) cap cv2.VideoCapture(match_clip.mp4) # 存储每个ID的历史位置 track_history defaultdict(list) # 存储每个ID的类别 track_class {} frame_idx 0 while cap.isOpened(): ret, frame cap.read() if not ret: break results model.track(frame, persistTrue, conf0.3, iou0.5, classes[0, 32], verboseFalse) if results[0].boxes.id is not None: boxes results[0].boxes.xywh.cpu().numpy() ids results[0].boxes.id.int().cpu().numpy() clss results[0].boxes.cls.int().cpu().numpy() for box, tid, cls in zip(boxes, ids, clss): x, y, w, h box track_history[tid].append((float(x), float(y))) track_class[tid] int(cls) # 在帧上画ID和类别 label fID:{tid} {Player if cls 0 else Ball} cv2.putText(frame, label, (int(x - w/2), int(y - h/2 - 5)), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1) cv2.imshow(Tracking, frame) if cv2.waitKey(1) 0xFF ord(q): break frame_idx 1 cap.release() cv2.destroyAllWindows() # 打印每个ID的轨迹长度 for tid, positions in track_history.items(): cls_name Player if track_class[tid] 0 else Ball print(fID {tid} ({cls_name}): {len(positions)} frames tracked)这段代码里track_history用defaultdict(list)存每个ID的中心点坐标序列track_class存每个ID对应的类别。xywh是中心点加宽高的格式取前两个值就是中心坐标。results[0].boxes.id可能为None比如第一帧还没有分配ID所以要先判断。这个统计逻辑虽然简单但它是后续做跑动距离、速度估算、传球网络分析的基础。足球的ID通常比球员的ID更不稳定因为球小、运动快、经常被遮挡后面避坑章节会专门讲怎么处理。3.3 跟踪器参数怎么调ByteTrack在ultralytics里的配置文件是bytetrack.yaml你可以复制一份出来改参数。关键参数有三个track_high_thresh、track_low_thresh、new_track_thresh。track_high_thresh是第一次匹配用的置信度阈值默认0.5track_low_thresh是第二次匹配的阈值默认0.1new_track_thresh是新建轨迹的阈值默认0.6。对于足球场景我一般会把track_low_thresh降到0.05让更多低分框参与第二次匹配因为球员被遮挡时置信度会掉得很厉害。new_track_thresh可以提到0.7避免足球的误检产生大量碎片轨迹。# 自定义bytetrack_football.yaml tracker_type: bytetrack track_high_thresh: 0.5 track_low_thresh: 0.05 new_track_thresh: 0.7 track_buffer: 60 match_thresh: 0.8 fuse_score: Truetrack_buffer60表示轨迹丢失后保留60帧再删除足球被踢到空中或者被球员挡住时这个缓冲能防止ID切换。match_thresh0.8是匹配时的IoU阈值设高一点让匹配更严格。fuse_scoreTrue会把检测置信度和IoU融合起来算匹配代价通常能提升匹配质量。改完参数后在model.track()里把tracker指向你的yaml文件路径就行。4. Streamlit界面把检测跟踪结果变成可交互的Web应用4.1 Streamlit的核心交互模式Streamlit的编程模型很简单你写一个Python脚本从上到下执行每次用户交互点按钮、拖滑块整个脚本重新跑一遍。这意味着你不能把视频帧存在全局变量里跨交互保持得用st.session_state来存。对于视频处理这种耗时操作还要用st.cache_data或st.cache_resource做缓存不然每次调参数都重新处理一遍视频等得让人想砸键盘。import streamlit as st import cv2 import tempfile import os from ultralytics import YOLO st.set_page_config(page_title球员和足球检测跟踪, layoutwide) st.title(球员和足球检测跟踪系统) # 侧边栏参数 conf_thresh st.sidebar.slider(置信度阈值, 0.1, 0.9, 0.3, 0.05) iou_thresh st.sidebar.slider(IoU阈值, 0.1, 0.9, 0.5, 0.05) model_size st.sidebar.selectbox(模型尺寸, [yolov8n.pt, yolov8s.pt, yolov8m.pt]) uploaded_file st.file_uploader(上传足球视频, type[mp4, avi, mov]) if uploaded_file is not None: # 保存上传的视频到临时文件 tfile tempfile.NamedTemporaryFile(deleteFalse, suffix.mp4) tfile.write(uploaded_file.read()) tfile.close() if st.button(开始检测跟踪): model YOLO(model_size) cap cv2.VideoCapture(tfile.name) stframe st.empty() stats_placeholder st.empty() track_ids set() frame_count 0 while cap.isOpened(): ret, frame cap.read() if not ret: break results model.track(frame, persistTrue, confconf_thresh, iouiou_thresh, classes[0, 32], verboseFalse) annotated results[0].plot() if results[0].boxes.id is not None: ids results[0].boxes.id.int().cpu().numpy() track_ids.update(ids.tolist()) stframe.image(annotated, channelsBGR, use_container_widthTrue) frame_count 1 stats_placeholder.metric(已处理帧数, frame_count, f当前ID数: {len(track_ids)}) cap.release() os.unlink(tfile.name) st.success(f处理完成共 {frame_count} 帧出现 {len(track_ids)} 个跟踪目标)这段代码搭出了一个完整的交互界面侧边栏调参数上传视频点按钮开始处理页面上实时显示带框的帧和统计数字。st.empty()创建占位符每帧更新它的内容实现视频播放效果。st.metric显示处理进度和当前出现的ID总数。注意stframe.image的use_container_widthTrue让画面自适应宽度不然大分辨率视频会溢出。4.2 处理长视频时的性能优化上面的代码有个问题每处理一帧就更新一次页面Streamlit的通信开销会让整体速度慢很多。对于超过一分钟的视频我一般会跳帧处理比如每三帧处理一帧中间帧用上一帧的结果。另外把视频写入临时文件再处理比直接读内存更稳因为OpenCV的VideoCapture需要文件路径不支持直接读字节流。# 跳帧处理每3帧处理1帧 frame_skip 3 frame_count 0 while cap.isOpened(): ret, frame cap.read() if not ret: break frame_count 1 if frame_count % frame_skip ! 0: continue results model.track(frame, persistTrue, confconf_thresh, iouiou_thresh, classes[0, 32], verboseFalse) annotated results[0].plot() stframe.image(annotated, channelsBGR, use_container_widthTrue)跳帧的代价是跟踪的连续性变差因为跟踪器依赖帧间运动信息跳帧后运动模型不准。折中方案是跳帧但把persist保持住让跟踪器用上一帧的轨迹预测当前位置。如果视频本身帧率是30fps跳3帧后相当于10fps对于足球这种快速运动场景10fps的跟踪勉强能用但足球的ID切换会明显增多。4.3 把跟踪统计结果可视化除了在视频上画框Streamlit还能画图表。比如把每个球员的跑动轨迹画成热力图或者统计每个ID出现的帧数做成柱状图。这些图表能让分析结果更直观。import pandas as pd import plotly.express as px # 假设track_history是前面收集的字典 data [] for tid, positions in track_history.items(): cls_name Player if track_class[tid] 0 else Ball data.append({ track_id: tid, class: cls_name, frames: len(positions), avg_x: np.mean([p[0] for p in positions]), avg_y: np.mean([p[1] for p in positions]) }) df pd.DataFrame(data) fig px.scatter(df, xavg_x, yavg_y, colorclass, sizeframes, hover_data[track_id], title目标平均位置分布) st.plotly_chart(fig, use_container_widthTrue)这段代码用Plotly画散点图每个点代表一个跟踪目标横纵坐标是它在画面中的平均位置点的大小是跟踪帧数。球员通常集中在画面中下部足球的位置更分散。这个图能帮你快速判断跟踪是否合理如果足球的平均位置出现在观众席上那肯定是误检。5. 避坑与排查球员和足球跟踪里最容易翻车的五个地方5.1 足球ID频繁切换现象足球的跟踪ID每隔几帧就变一次一个进球过程能产生几十个不同的ID。原因足球在画面中像素少检测置信度波动大经常低于track_high_thresh导致轨迹中断后重新分配ID。解决把track_low_thresh降到0.05让低分框参与第二次匹配把track_buffer提到90以上给足球更多时间重新匹配如果还是不行考虑单独为足球训练一个检测模型提高小目标的检测稳定性。5.2 球员被遮挡后ID丢失现象两名球员交叉跑位时被挡住的那个球员ID消失重新出现后换了新ID。原因ByteTrack依赖检测框做匹配完全遮挡时没有检测框轨迹进入丢失状态超过track_buffer后就被删除。解决增大track_buffer到120帧给遮挡留出足够恢复时间如果相机是移动的换BoT-SORT跟踪器它有相机运动补偿能更准地预测遮挡期间的位置。5.3 Streamlit页面白屏或卡死现象上传视频后点开始页面一直转圈或者直接白屏。原因Streamlit的脚本重跑机制导致视频处理阻塞了主线程或者临时文件路径在重跑后失效。解决把视频处理逻辑放在st.cache_data装饰的函数里用文件哈希做缓存键临时文件用tempfile.NamedTemporaryFile(deleteFalse)创建处理完手动删除如果视频很长加一个进度条st.progress()让用户知道还在跑。5.4 CPU环境下帧率过低现象用yolov8n在CPU上跑每帧要100多毫秒一分钟的视频要处理好几分钟。原因YOLOv8默认输入分辨率是640CPU做卷积运算慢。解决把imgsz参数降到416或320速度能提升一倍多代价是足球这种小目标的检测率会下降或者用OpenVINO做推理加速ultralytics支持export成OpenVINO格式在Intel CPU上能快2到3倍。# 导出OpenVINO格式 model YOLO(yolov8n.pt) model.export(formatopenvino, imgsz416) # 加载OpenVINO模型推理 ov_model YOLO(yolov8n_openvino_model/) results ov_model(frame, conf0.3, classes[0, 32])5.5 误检观众席上的人现象画面边缘的观众被检测成球员产生大量无意义的跟踪ID。原因COCO的person类包含所有人观众和球员在检测器眼里没有区别。解决用画面区域过滤只保留球场范围内的检测框或者根据跟踪轨迹的移动模式过滤观众基本不动球员跑动距离大跑动距离低于阈值的ID可以剔除。# 只保留画面下半部分的检测框假设球场在下方 h, w frame.shape[:2] for box, tid in zip(boxes, ids): x, y, bw, bh box if y h * 0.4: # 画面上方40%区域忽略 continue # 处理有效检测6. 进阶技巧用跟踪轨迹做球员跑动距离估算跑动距离是足球分析里最基础的指标用跟踪轨迹就能估算。核心思路是把每个球员的像素位移累加再乘以像素到实际距离的转换系数。转换系数需要标定最简单的方法是在球场上找一个已知长度的参照物比如球门宽度是7.32米量出它在画面里的像素宽度就能算出每像素对应多少米。import numpy as np # 假设已经收集了track_history和track_class # 标定球门宽度7.32米在画面中占200像素 pixels_per_meter 200 / 7.32 # 视频帧率 fps 30 for tid, positions in track_history.items(): if track_class[tid] ! 0: # 只算球员 continue total_distance 0 for i in range(1, len(positions)): dx positions[i][0] - positions[i-1][0] dy positions[i][1] - positions[i-1][1] pixel_dist np.sqrt(dx**2 dy**2) total_distance pixel_dist / pixels_per_meter # 只统计跟踪帧数足够的轨迹 if len(positions) fps * 5: # 至少跟踪5秒 print(f球员 {tid}: 跑动距离约 {total_distance:.1f} 米)这段代码的逻辑是逐帧计算相邻位置之间的欧氏距离累加后除以标定系数。有几个细节要注意像素坐标的y轴向下但算距离时平方后符号不影响跟踪轨迹如果有跳变ID切换导致的坐标突变会产生虚假的大距离所以要先做异常值过滤比如单帧位移超过画面宽度10%的点直接跳过。另外这个估算假设相机是固定机位且没有透视畸变如果相机有俯仰角画面不同位置的像素到米的转换系数不一样需要做透视校正那就复杂了一般原型阶段不做这么细。跑动距离算出来之后你可以把它和真实比赛数据对比如果偏差在20%以内说明跟踪质量可以接受。如果偏差很大先检查标定系数对不对再检查跟踪ID有没有频繁切换。我自己的习惯是每换一个视频源先跑前100帧把跟踪结果可视化出来看一遍确认ID稳定了再跑全视频。这个习惯帮我省了很多后悔药因为跟踪参数不对的话跑完整个视频再发现ID全是乱的那才叫翻车。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进