ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

YOLOv8改进实战:中国象棋棋子检测与部署全流程解析

YOLOv8改进实战:中国象棋棋子检测与部署全流程解析 简介这是一套面向计算机视觉学习者和目标检测开发者的中国象棋棋子检测系统源码包。系统基于改进YOLOv8算法配合一套标注数据集完成从训练到推理的完整流程重点解决棋盘棋子自动识别与棋局分析问题并支持目标检测与实例分割模型的自适应加载。压缩包共27个文件整体约2.99MB包含Python训练/验证/预测脚本、前端界面程序、标注图像示例以及README与说明文档便于按模块学习与实际部署。目前已有231人学习。通过源码可掌握YOLOv8改进思路、数据标注组织方式、模型自适应加载等技术要点Web前端展示了实时识别效果与交互分析能力有助于快速搭建自己的棋子检测应用也可为发刊或竞赛提供创新参考。1. 先拆清一件事改进YOLOv8的象棋棋子检测这套源码到底给了什么把一套“能跑通、能发刊、能部署到前端”的YOLOv8改进项目拆开看难点从来不在训练本身而在从棋盘照片到检测结果之间的那一长串链路数据集怎么标、类别顺序怎么定、改进点怎么选、训练完怎么把检测和分割两种权重统一封装、最后怎么接到Web页面。这套中国象棋棋子检测源码正好把这条链路完整走了一遍。它解决的问题很具体输入一张棋盘照片或者一段视频输出每个棋子的位置、类别红帅黑将、车马炮士相兵卒和置信度同时支持检测框与实例分割两种模式并且模型加载时能自动识别权重类型。适合正在做小目标检测、想找一套完整标注训练部署案例参考的从业者也适合要把检测模型接到Web前端展示的研究生。下面按我拆包时的顺序把数据、训练、排错、部署和验证逐一讲透。2. 数据集是这套资源的命根子14类棋子的标注规范与格式转换脚本象棋棋子和通用目标检测数据集最大的差别在于类别多且形态相似。14个类别里“车、马、炮”在红黑双方各有一份外形几乎一样只靠颜色区分。这就导致数据集质量比模型结构更影响最终精度。这套源码里给出的标注模板和转换脚本解决的正是这个痛点。2.1 棋子类别定义与标注顺序顺序错了后面全乱拿到数据后第一件事不是打开训练脚本而是确认类别顺序。YOLO格式的标签文件里每一行开头那个整数就是类别ID它必须和训练时的data.yaml严格对应。这套标注里采用的类别顺序如下表类别ID标签名对应棋子备注0red_shuai红帅红色方最重要棋子样本量少1red_shi红仕九宫格内活动2red_xiang红相田字格活动3red_ma红马与黑马外形近似4red_ju红车与黑车外形近似5red_pao红炮与黑炮外形近似6red_bing红兵数量多样本充足7black_jiang黑将对应红帅8black_shi黑士对应红仕9black_xiang黑象对应红相10black_ma黑马与红马外形近似11black_ju黑车与红车外形近似12black_pao黑炮与红炮外形近似13black_zu黑卒对应红兵这个顺序有一个隐藏规则前7个是红方后7个是黑方同种棋子相差7个ID。这样设计有实际好处——前端画框时只需要判断cls_id 7就能确定棋子颜色不用再查映射表。如果你自己重新标注强烈建议沿用这个顺序否则后面改类别ID要重新转一遍标签。标注工具我用Labelme比较多因为它同时支持矩形框和多边形分割标注一份标注文件既能转成yolo检测格式又能转成yolo分割格式。注意标注时不要只标棋子本体要把棋子底座的边缘也包进去因为象棋棋子在棋盘上有阴影只标文字区域会导致模型对光影变化极其敏感。2.2 Labelme标注文件批量转YOLOdetect与segment两种格式一次讲清Labelme保存的是JSON文件不能直接喂给YOLOv8需要转换成YOLO格式的txt。检测和分割两种任务转换逻辑不同检测格式每行class_id cx cy w h四个值都是归一化后的分割格式每行class_id x1 y1 x2 y2 ...多边形顶点归一化不再有宽高下面是转换脚本的核心部分import json import os import numpy as np from glob import glob CLASS_NAMES [red_shuai, red_shi, red_xiang, red_ma, red_ju, red_pao, red_bing, black_jiang, black_shi, black_xiang, black_ma, black_ju, black_pao, black_zu] def labelme2yolo(json_path, output_dir, task_typedetect): 将Labelme的JSON标注转换为YOLO格式txt task_type: detect 或 segment with open(json_path, r, encodingutf-8) as f: data json.load(f) img_width data[imageWidth] img_height data[imageHeight] lines [] for shape in data[shapes]: label shape[label] if label not in CLASS_NAMES: continue cls_id CLASS_NAMES.index(label) points np.array(shape[points], dtypenp.float32) if task_type detect: # 矩形框模式: 取多边形外接矩形 x1, y1 points.min(axis0) x2, y2 points.max(axis0) cx ((x1 x2) / 2) / img_width cy ((y1 y2) / 2) / img_height w (x2 - x1) / img_width h (y2 - y1) / img_height lines.append(f{cls_id} {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}) else: # 分割模式: 输出归一化多边形顶点 norm_points points / [img_width, img_height] flat norm_points.flatten() line f{cls_id} .join([f{v:.6f} for v in flat]) lines.append(line) base_name os.path.splitext(os.path.basename(json_path))[0] out_path os.path.join(output_dir, base_name .txt) with open(out_path, w, encodingutf-8) as f: f.write(\n.join(lines)) print(f转换完成: {base_name}, 共 {len(lines)} 个目标) # 批量处理示例 for json_file in glob(labels_json/*.json): labelme2yolo(json_file, labels_yolo_detect, task_typedetect)代码里有一个容易忽略的点detect模式我没有直接读shape_type为rectangle的矩形坐标而是取多边形顶点的最小外接矩形。这是因为很多标注者在Labelme里画的是多边形即使目测是矩形四个顶点也可能有轻微偏差。用points.min/max可以保证框一定包住整个棋子不会因为手动标注的误差把棋子边缘切掉。分割模式的归一化要注意多边形顶点数和顺序必须和标注时一致YOLO分割标签内部会自动处理顶点顺序但顶点数量不限几十个点都可以。转换后建议抽查几个txt文件用代码画回图上确认位置没有漂移。2.3 数据集划分与类别分布检查先别急着训练转换完标签后我习惯先写一段脚本看看类别分布。象棋数据集最容易出现的问题就是类别极度不平衡红兵黑卒各5个任何局面下数量都最多而红帅黑将每局只有1个加上实战照片里帅将往往被棋子遮挡样本量可能只有兵的十分之一。import os from collections import Counter label_dir labels_yolo_detect counter Counter() for txt_file in os.listdir(label_dir): with open(os.path.join(label_dir, txt_file), r, encodingutf-8) as f: for line in f: cls_id int(line.strip().split()[0]) counter[cls_id] 1 class_names [红帅, 红仕, 红相, 红马, 红车, 红炮, 红兵, 黑将, 黑士, 黑象, 黑马, 黑车, 黑炮, 黑卒] print(类别分布:) for i in range(14): print(f {class_names[i]} (ID{i}): {counter.get(i, 0)}) # 按7:2:1划分数据集 import random random.seed(42) files os.listdir(label_dir) random.shuffle(files) n len(files) train_files files[:int(n * 0.7)] val_files files[int(n * 0.7):int(n * 0.9)] test_files files[int(n * 0.9):] for split, split_files in [(train, train_files), (val, val_files), (test, test_files)]: os.makedirs(fimages/{split}, exist_okTrue) os.makedirs(flabels/{split}, exist_okTrue) for f in split_files: img_name f.replace(.txt, .jpg) # 这里需要把对应图片移动/拷贝到 images/{split} 目录 print(f{split}: {img_name})这里我固定了random.seed(42)保证每次划分结果一致。划分比例用的是7:2:1而不是更常见的8:1:1因为象棋数据集通常总量不大测试集留10%用于最终验证比较保守。类别分布脚本输出的数值要仔细看如果某个类别的样本数不足另一个类别的5%训练时就要考虑类别权重或者欠采样。我拆的这套资源里数据已经做过一轮筛选14个类别基本覆盖了不同棋盘材质、不同光照和不同拍摄角度但如果你要自己扩充数据优先补帅将这类稀缺样本而不是继续拍兵卒。3. 改进YOLOv8训练从发刊笔记里挑创新点到超参怎么设才不翻车资源里附带了一份整理好的发刊改进点笔记号称70个创新方向。我拆完之后说实话真正适合“中国象棋棋子检测”这个场景的也就三类注意力机制、检测头改进、损失函数调整。无脑堆创新点只会让训练时间翻倍精度还未必涨。3.1 70个改进点笔记里象棋场景真正吃香的只有三类象棋棋子的特点是小、密、遮挡严重。一张棋盘照片里至少20个棋子每个棋子可能只有40×40像素且相邻棋子间距极小。针对这个场景我实际验证过有效的改进方向就三种第一是注意力模块。在Backbone的C2f模块后面插入CACoordinate Attention或者EMA能让模型更关注棋子与棋盘背景的对比度差异而不是被木纹纹理带偏。SE注意力对通道维度建模效果也有但不如CA明显因为棋子的区分度主要在空间位置和颜色上。第二是检测头层面加一个小目标检测头P2。YOLOv8默认从P3开始检测最小感受野对应8倍下采样。如果你的棋盘照片里棋子小于16×16像素P2层4倍下采样能把小棋子召回率拉高一大截。代价是推理速度下降20%左右但对象棋这种离线分析场景完全值得。第三是损失函数换成SIoU或者Wise-IoU。默认的CIoU在棋子密集场景下对框的定位精度不够SIoU考虑了角度损失框会更贴合棋子的圆形轮廓。不过要注意换了损失函数后训练收敛速度会变慢需要把epoch适当加大。我在模型结构里实际只加了CA注意力模块和P2检测头两个改动训练速度还能接受mAP提升也比堆砌一堆改进明显。发刊笔记里那些替换Backbone到GhostNet、换SPPF为ASPP之类的方案效果有但训练资源要求高普通单卡跑起来吃力。3.2 训练配置与启动一张参数表和一个能直接跑的入口训练入口用的是Ultralytics官方API但参数做过一轮针对小目标场景的调整from ultralytics import YOLO # 基于COCO预训练的yolov8s-seg权重启动比从头训练收敛快得多 model YOLO(yolov8s-seg.pt) model.train( datachess.yaml, # 数据集配置文件 epochs200, # 文本资源里推荐的最优epoch数 imgsz640, # 输入分辨率象棋棋子小不建议低于640 batch16, # 单卡显存不够就降到8 patience30, # 验证集连续30轮不涨就早停 optimizerSGD, # SGD在小数据集上比AdamW稳定性好 lr00.01, # 初始学习率 lrf0.01, # 最终学习率衰减到初始的1% weight_decay0.0005, warmup_epochs5.0, mosaic1.0, # 训练前10轮建议关掉后面再开 mixup0.1, # 加一点点mixup太多会模糊棋子细节 close_mosaic10, # 最后10轮关闭mosaic让模型适应真实分布 box7.5, # 框损失权重 cls0.5, # 类别损失权重类别不平衡时适当调高 dfl1.5, # DFL损失权重 device0, # 用单卡训练 )这里每个参数都值得展开说。close_mosaic10是我强烈建议加上的参数如果从头到尾开mosaic棋盘照片会被切成四块拼接棋子会被截断成碎片模型学到的是残缺的棋子特征验证集上mAP虚高一到真实视频就露馅。batch16对应大约8GB显存如果你的显卡只有6GB把batch降到8同时把imgsz降到480否则会直接OOM。chess.yaml的内容长这样path: ./chess_dataset train: images/train val: images/val test: images/test names: 0: red_shuai 1: red_shi 2: red_xiang 3: red_ma 4: red_ju 5: red_pao 6: red_bing 7: black_jiang 8: black_shi 9: black_xiang 10: black_ma 11: black_ju 12: black_pao 13: black_zu如果数据集的标注文件是矩形框就用YOLO(yolov8s.pt)如果标注文件是多边形顶点就必须用YOLO(yolov8s-seg.pt)。权重类型和标注格式不匹配训练会直接在数据加载阶段报错。3.3 训练过程怎么看损失曲线、mAP与过拟合判断训练跑起来之后不要只盯着终端刷新的loss数值那个是训练集损失不能反映泛化能力。我一般看三个东西第一是results.csv里的验证集指标。Ultralytics每轮训练都会写一个results.csv里面包含metrics/mAP50(B)、metrics/mAP50-95(B)。如果mAP50在80轮之后还在明显上涨说明模型还没榨干不要急着停如果mAP50连续20轮不涨大概率是学习率太低或者模型容量到头了。第二是训练损失和验证损失之间的gap。训练集box_loss一直在降但验证集box_loss在第100轮左右开始反弹这就是过拟合前兆。对策是提前停掉或者加augmentTrue里的随机旋转和HSV扰动。第三是PR曲线。训练完跑一遍model.val()会生成混淆矩阵和PR曲线。重点关注红色和黑色同种棋子的混淆程度比如红马被识别成黑马的比例。如果混淆严重说明模型没学会用颜色区分这时候去检查数据集里是不是红黑棋子的背景色差太小。我在训练这套象棋数据时第130轮左右mAP50就稳定在0.93以上但继续跑到200轮发现mAP50-95还在缓慢爬升说明模型在高IoU阈值下的定位精度还在改善。最终测试集上mAP50实测能达到0.96左右这个水平已经能支撑实际的棋盘局面识别了。4. 训练与部署的常见问题排查五个坑帮你省掉一周调试时间这个章节记录我拆这套源码时实际遇到的五个问题。每个都是先看到现象再追原因最后给出解决方式。其中一半问题不仔细看日志根本发现不了。4.1 现象训练loss一直降mAP却在0.3上下波动这是最迷惑人的一种情况终端打印的loss曲线漂亮得不行但验证集mAP就是上不去。我先怀疑是标注格式错了检查了data.yaml的类别顺序和标签文件里的class_id后发现有个class_id超出了14的范围。原因Labelme标注时出现了空标签或者自定义标签转换脚本里用if label not in CLASS_NAMES: continue跳过了但这个跳过发生在写文件时导致某些图片的txt文件只有几行和图片里的实际目标数量对不上。后续训练时数据加载器按txt里最大class_id分配张量空间某个class_id15的脏数据让整个类别映射错位。解决给转换脚本加一行过滤后的数量统计并手动检查所有txt文件中的最大class_id是否小于14。我写了个十行的排查脚本扫一遍全部标签文件把异常文件单独列出来删除。import os label_dir labels_yolo for f in os.listdir(label_dir): with open(os.path.join(label_dir, f), r) as fh: for line in fh: cls_id int(line.split()[0]) if cls_id 14: print(f异常文件: {f}, 非法类别ID: {cls_id})4.2 现象红色棋子几乎全部漏检训练完成后测试红方棋子检测率远低于黑方红帅和红兵大量漏检。我先以为是颜色问题模型对红色不敏感后来发现是标注数据分布的问题。原因数据集里红色棋子的照片大多是在红色木纹棋盘上拍摄的棋子文字区域和棋盘背景的对比度太低而黑色棋子恰好是高对比度。模型学到的特征里把“深色背景上的亮色区域”当成了强响应模式所以黑棋检测效果好红棋特征被背景噪声淹没了。解决在训练配置里加hsv_h0.02HSV色相扰动虽然轻微但能让模型不过度依赖颜色的绝对数值同时扩充了一部分红方棋子在浅色背景棋盘上的照片。如果你的场景里棋盘颜色固定直接用原始配置也行但凡是多场景部署H和S扰动必须加上。4.3 现象多尺度mosaic增强后棋子变成“碎块”训练时开着mosaic验证集mAP尚可但拿到真实棋盘照片上一测棋子边缘出现大量相互重叠的预测框而且置信度都在0.7以上。原因mosaic把四张图缩放到同一个尺寸再拼接棋子原本40×40像素缩放后可能只剩20×20如果这时候还开了scale0.5部分棋子会被裁切到拼接边界上模型在训练时见过大量“半颗棋子”的样本推理时遇到完整棋子反而倾向输出多个候选框。解决分两个阶段训练——前100轮开mosaic快速收敛最后20轮用close_mosaic10关闭增强让模型微调。这套源码里的训练脚本默认就是这么配的我直接沿用了这个策略效果明显改善。4.4 现象加载分割权重报形状不匹配的错训练的是yolov8s-seg.pt推理时用了YOLO(best.pt)加载结果报RuntimeError: shape mismatch具体是某个卷积层的权重维度对不上。原因检测模型输出头的通道数是(4 num_classes) * num_anchors分割模型多了掩膜分支输出通道数完全不同。Ultralytics在第一次加载权重时会根据模型结构初始化如果权重文件是检测头而实例化方式默认建了分割模型就会出现权重维度对不上。解决不要手动指定模型结构直接用model YOLO(best.pt)让Ultralytics根据权重文件头部的yaml信息自动判断是检测还是分割任务。这也是这套资源里“模型自适应加载”的核心逻辑下面第5章会展开讲。4.5 现象ONNX导出后推理结果和本地完全不一致导出ONNX后在服务端用onnxruntime推理输出的框位置和正确结果差很远有些框甚至跑到图像外面去了。原因YOLOv8的导出默认带NMS层但onnxruntime对NMS的支持依赖具体版本部分版本不执行NMS输出是稠密的原始预测。原始预测里包含了大量低置信度候选框直接取topk就完全混乱了。解决导出时加nmsFalse导出纯模型结构在服务端自己实现置信度过滤和NMS逻辑。model.export(formatonnx, imgsz640, opset12, simplifyTrue, nmsFalse)opset和简化导出建议搭配使用。opset太高部署环境里旧版onnxruntime可能不支持某些算子simplifyTrue能去掉计算图里的冗余节点推理速度会快5%-10%。5. 检测与分割模型自适应加载推理类封装和Web前端对接实践训练好权重后下一个问题是我手上的best.pt到底是检测模型还是分割模型如果项目里同时存在两套训练产出的权重手动区分容易出错。这套源码提供了一个自适应加载的封装类我把它拆出来讲清楚因为这是新手最容易卡住的环节。5.1 为什么需要自适应加载检测权重的输出是框分割权重输出是掩膜YOLOv8的检测模型和分割模型输出张量维度不同。检测模型的输出是[batch, num_anchors * num_classes 4]每个锚点位置上预测类别概率和边界框偏移分割模型在检测头之外挂了一条掩膜分支输出是[batch, num_anchors * (num_classes 4) mask_dim]需要用原型掩膜和掩膜系数做矩阵乘法才能还原像素级分割结果。直接读取模型的model[-1].type属性就能判断头类型检测头是Detect分割头是Segment。这是Ultralytics内部结构的公开约定不需要额外去猜测权重文件名。import cv2 import numpy as np import torch from ultralytics import YOLO class ChessDetector: 自适应加载检测或分割模型统一推理入口 支持两种任务输出 - detect: 返回 xyxy 框 - segment: 返回 xyxy 框 多边形掩膜点 def __init__(self, weight_path, conf_thres0.35, iou_thres0.45): self.model YOLO(weight_path) # 关键一步读取最后一个module的type属性 head_type self.model.model[-1].type self.is_segment head_type Segment self.conf conf_thres self.iou iou_thres print(f模型加载完成任务类型: {实例分割 if self.is_segment else 目标检测}) # 类别显示名前7个为红方 self.class_names [red_shuai, red_shi, red_xiang, red_ma, red_ju, red_pao, red_bing, black_jiang, black_shi, black_xiang, black_ma, black_ju, black_pao, black_zu] def infer(self, image): 输入BGR图像返回归一化后的检测结果 results self.model.predict( image, confself.conf, iouself.iou, verboseFalse )[0] output { boxes: results.boxes.xyxy.cpu().numpy(), classes: results.boxes.cls.cpu().numpy().astype(int), conf: results.boxes.conf.cpu().numpy(), } if self.is_segment and results.masks is not None: # 分割模型返回掩膜多边形点格式为 [n, k, 2] output[masks] [poly.cpu().numpy() for poly in results.masks.xy] return output # 使用示例 detector ChessDetector(best.pt) # 自动判断是detect还是segment img cv2.imread(board_photo.jpg) result detector.infer(img) print(f检测到 {len(result[boxes])} 个棋子)这段代码的逻辑核心只有两个点一是通过model.model[-1].type拿到任务类型二是根据任务类型决定是否解析results.masks。conf_thres和iou_thres我分别在构造器和predict调用里都做了参数传递便于在Web服务里按请求动态调整阈值。5.2 Flask推理接口与Web前端Canvas绘制把后端结果画到棋盘照片上有了推理封装类Web对接就变成了常规的POST请求处理。前端上传一张棋盘照片后端解码、推理、返回JSON前端用Canvas画框。这里给出后端接口的核心代码from flask import Flask, request, jsonify import base64 import cv2 import numpy as np app Flask(__name__) detector ChessDetector(best.pt) app.route(/api/detect, methods[POST]) def detect(): 请求体: {image: data:image/jpeg;base64,...} 返回: {boxes: [[x1,y1,x2,y2],...], classes: [0,1,...], conf: [0.96,0.88,...], masks: [[[x,y],...],...] (分割模式才有)} data request.get_json() img_b64 data[image].split(,)[-1] # 去掉data:image前缀 raw base64.b64decode(img_b64) arr np.frombuffer(raw, np.uint8) frame cv2.imdecode(arr, cv2.IMREAD_COLOR) if frame is None: return jsonify({error: 图片解码失败}), 400 result detector.infer(frame) resp { boxes: result[boxes].tolist(), classes: result[classes].tolist(), conf: result[conf].tolist(), } if masks in result: resp[masks] [poly.tolist() for poly in result[masks]] return jsonify(resp) if __name__ __main__: app.run(host0.0.0.0, port8000, debugFalse)接口里注意split(,)[-1]这一步前端上传的base64字符串通常带data:image/jpeg;base64,前缀不截掉的话base64.b64decode会报错。cv2.imdecode解码失败时返回None这在接口里必须显式处理否则后续推理调用会直接抛空指针异常。前端画框部分用Canvas实现这里给出核心绘制函数function drawResults(canvasId, image, result) { const canvas document.getElementById(canvasId); const ctx canvas.getContext(2d); canvas.width image.width; canvas.height image.height; ctx.drawImage(image, 0, 0); const labels [红帅,红仕,红相,红马,红车,红炮,红兵, 黑将,黑士,黑象,黑马,黑车,黑炮,黑卒]; const boxes result.boxes; const classes result.classes; const conf result.conf; for (let i 0; i boxes.length; i) { const [x1, y1, x2, y2] boxes[i]; const color classes[i] 7 ? #e74c3c : #2c3e50; ctx.strokeStyle color; ctx.lineWidth 3; ctx.strokeRect(x1, y1, x2 - x1, y2 - y1); ctx.fillStyle color; ctx.font bold 18px sans-serif; ctx.fillText(labels[classes[i]] conf[i].toFixed(2), x1, y1 - 8); } }颜色判断直接复用classes[i] 7这个规则这就是2.1节里说的“同种棋子相差7个ID”设计的好处前端不需要维护红黑两套映射表。边框宽3像素是经过实测的太细在低分辨率图上看不清太粗会遮挡相邻棋子。6. 验证闭环用一段真实棋盘视频检验模型稳定性模型在单张测试图上mAP再高也不代表真实场景能用。我拆完这套源码之后自己写了一段视频逐帧验证脚本专门用来检查棋盘视频里模型输出是否稳定。这个方法很简单但能发现单张图片测试发现不了的问题闪烁误检。所谓闪烁就是某一帧在某个位置检测到棋子下一帧这个框消失再下一帧又出现。原因是模型对特定棋子的置信度刚好卡在0.35阈值附近光照变化或者手机抖动让置信度在阈值上下跳动。单张图测试永远不会暴露这个问题只有按时间序列逐帧推理才能看到。import cv2 import numpy as np def iou(box1, box2): 计算两个框的IoU x1 max(box1[0], box2[0]) y1 max(box1[1], box2[1]) x2 min(box1[2], box2[2]) y2 min(box1[3], box2[3]) inter max(0, x2 - x1) * max(0, y2 - y1) area1 (box1[2] - box1[0]) * (box1[3] - box1[1]) area2 (box2[2] - box2[0]) * (box2[3] - box2[1]) return inter / (area1 area2 - inter 1e-6) def verify_video(video_path, detector, conf_threshold0.35): cap cv2.VideoCapture(video_path) fps cap.get(cv2.CAP_PROP_FPS) total_frames 0 flicker_frames 0 prev_detections None while True: ret, frame cap.read() if not ret: break result detector.infer(frame) boxes result[boxes] total_frames 1 if prev_detections is not None and len(prev_detections) 0: matched 0 for curr_box in boxes: for prev_box in prev_detections: if iou(curr_box, prev_box) 0.3: matched 1 break # 当前帧能匹配上的目标少于上一帧的60%记为闪烁 if matched len(prev_detections) * 0.6: flicker_frames 1 prev_detections boxes cap.release() return total_frames, flicker_frames, flicker_frames / total_frames这个脚本的逻辑是相邻两帧之间同一棋子的检测框IoU应该大于0.3因为视频帧率通常是30fps棋子不会瞬间移动。如果上一帧检测到10个棋子当前帧只能匹配上6个那说明有4个框发生了闪烁。闪烁率超过10%就说明阈值设太高了——把conf_threshold从0.35降到0.3通常会明显改善。我拿着这套验证逻辑测了一段实战对弈的手机录像发现红车的检测框在棋子移动时经常抖动。原因不是模型不准而是手机拍摄时手部微抖导致棋盘上棋子边缘的纹理在帧间变化置信度在0.33到0.36之间波动。把前端检测阈值从0.35降到0.3之后闪烁率从14%降到3%以下。从那以后我每次训练完模型都不会只盯测试集mAP而是强制走一遍这段视频逐帧验证统计闪烁率后再决定要不要调阈值。这个习惯帮我避免了至少三次把不稳定的模型部署到演示页面的尴尬。如果你也要把精力花在“提升模型稳定性”而不是“刷高mAP”上建议直接把这个脚本融进自己的工作流。希望能帮到你。本文还有配套的精品资源点击获取
返回列表