ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

YOLOv8-OBB实战:旋转验证码识别与角度回归完整方案

YOLOv8-OBB实战:旋转验证码识别与角度回归完整方案

上个月我接了一个内部自动化测试需求:某个网页的登录环节换了一种验证码,不是让输入字符,而是给出几张已经旋转过的图片,要求把其中那张“不正”的图转正。一开始我觉得这不就是个目标检测嘛,直接拿 YOLOv8 跑矩形框,结果物体倒是全框出来了,可它到底偏了多少度、哪张需要旋转,矩形框给不出半点信息——标注里根本没有角度,模型自然什么都学不到。换成 YOLOv8-OBB 之后,事情才真正变得可解:每个预测框除了位置和大小,还多了一个角度分量,旋转验证码识别才从“盲猜”变成“可计算”。

这篇文章把我在模拟项目X里踩过的坑和最终跑通的完整链路整理出来。内容覆盖数据合成、OBB 标注格式、YOLOv8-OBB 的训练配置、推理结果的角度还原,以及一个很多人第一次做时会忽略的 180 度方向歧义问题。适合已经会跑基础 YOLO、想上手旋转目标检测的开发者,也适合第一次接触 OBB 的新手。读完之后,你至少能自己造数据集、训练一个能实际回答旋转验证码问题的模型,而不是拿到一堆框之后不知道怎么转化成答案。

1. 旋转验证码的核心难点:为什么普通检测模型在这里失效

1.1 验证码要的答案不是“框在哪”,而是“转了多少”

普通目标检测解决的是“这张图里有哪些目标、分别在什么位置”。但旋转图片验证码的交互逻辑完全不同:它问的是“这张图相对正立方向偏了多少度”。也就是说,核心任务不是定位,而是角度回归。

我一开始犯的错就是把这两件事混为一谈。用 YOLOv8 检测人物、车辆这类目标,模型确实能找到物体,但找到之后我手里只有cx, cy, w, h四个数。这四个数里没有任何一个表示“这个物体是朝上还是朝左”,更算不出“它需要旋转多少度才能回正”。模型训练时监督信号里压根没有角度,推理时自然给不出来。

更麻烦的是,有的旋转验证码不是“一张图”,而是“多张图里有一张被旋转了”。这时候你需要的不只是定位,还要做“哪一张和其余不一致”的判断。这个判断一旦落到矩形框上,就只能靠长宽比猜测——比如一个竖长方形物体旋转 90 度后变成横长方形,长宽比确实变了,但如果物体本身接近正方形,或者旋转角度是 15 度、30 度这种小角度,矩形框几乎完全看不出变化。

所以旋转验证码的第一性原理是:模型必须学会预测连续角度,而不是分类“有没有物体”。这和普通检测是两条技术路线。

1.2 矩形框对旋转目标有两个先天缺陷

第一个缺陷是信息缺失。矩形框用x, y, w, h描述目标,它隐含的假设是“目标的主轴和图像坐标轴平行”。一旦目标旋转,这个假设就不成立。矩形框为了包住旋转目标,只能扩大面积,把大量背景也框进来,导致两个问题:

  • 模型被迫学习“带着背景识别目标”,特征被稀释;
  • 两个旋转目标如果挨得近,矩形框之间会产生大量无意义的重叠,NMS 阶段容易误删。

第二个缺陷是 IoU 失真。假设一个 100×50 的物体旋转 45 度,它的水平外接矩形大约是 106×106 左右(具体数值取决于长宽比)。预测框如果和真实框角度不一致,哪怕中心点完全重合,普通 IoU 也低得吓人。这意味着你用矩形框做旋转目标的检测时,mAP 天然就会很难看,而且这个难看不代表模型没找到目标,只是框的评价方式不匹配。

这两个缺陷叠加起来,结论很明确:旋转目标检测需要旋转的表示方式,不是一个用力塞进矩形框能解决的事。

1.3 OBB 补上角度维度之后,任务才变回一个回归问题

OBB(Oriented Bounding Box,旋转边界框)的标签是cx, cy, w, h, θ,比普通矩形框多了一个角度 θ。这个 θ 在训练时参与回归,模型输出的每个框自带方向信息。

有了 θ 之后,旋转验证码的任务从“检测 + 盲猜角度”退化成“检测 + 读取角度”。检测部分依然用目标检测的思路,但判断“转了多少度”变成了直接读取模型输出的角度值,不需要额外推理。

我举个直观的例子。一个竖着的手机图标,矩形框只能告诉你“这里有一个竖着的图”,但 OBB 直接告诉你“这个框的旋转角是 28 度”。验证码如果要求“把图片转回正立”,你只需要把 28 度作为旋转量发给前端交互逻辑即可。如果要求“挑出角度错误的图”,你只需要比较所有检测框的角度值,找出离基准角最远的那个。

这就是我把方案锁定为 YOLOv8-OBB 的原因:它把角度纳入监督信号,让模型在训练时就学习“物体朝向”这个语义信息。后面所有复杂的角度转换、交互映射都建立在这个基础上。

提示:如果你用的是 YOLOv8n 普通版本,不管怎么调参都解决不了旋转验证码问题。这不是模型大小或数据量的锅,是表示方式本身缺了角度这一维。

2. 数据才是第一步:合成旋转样本与 OBB 标注的完整管线

2.1 为什么必须自己做一套数据生成器

旋转验证码的真实样本非常难收集。一是这类验证码通常只在特定业务场景出现,普通抓取很难批量拿到;二是就算拿到了,人工标注旋转角度非常不准确——人眼看 15 度和 18 度几乎没有区别,标注误差直接变成训练噪音;三是大量收集线上验证码图片用于自动化目的,本身就有合规风险。

所以我的选择是:写一个数据合成器,自己生成训练集和验证集。合成数据在旋转目标检测里不是“凑合方案”,反而有几个天然优势:

  • 旋转角度是程序生成的,标签精度到小数位,完全无误差;
  • 背景、干扰、遮挡可以随机组合,数据量无限扩展;
  • 每张图片的实例数量、目标大小、密集程度都可以主动控制。

代价是合成器和真实验证码之间存在“风格鸿沟”。如果验证码的背景是风景照,而你合成时只用纯色渐变,模型推理时就会水土不服。所以合成器里背景素材尽量多样化,最好保留一些真实照片级的纹理。后面第 5 节我会详细说过拟合的坑,这里先把生成管线讲清楚。

2.2 旋转渲染里的两个边界坑:越界裁剪与中心点漂移

写生成器第一步是准备素材:一张带透明通道(RGBA)的物体贴图,比如图标、交通标志、小动物剪影。第二步是把物体旋转任意角度,然后贴到背景图上。

这里有两个坑必须提前规避。

第一个坑是cv2.warpAffine直接旋转会裁剪。如果物体是 100×100 的正方形,旋转 45 度后四个角会超出原画布,直接用原尺寸输出就会切掉四个角。解决办法是先把画布扩展到物体对角线长度,再进行旋转,最后贴到背景上时再整体平移。

下面是我在模拟项目X里用的核心渲染逻辑:

import cv2 import numpy as np def rotate_sprite(sprite, angle_deg): """旋转 RGBA 贴图,返回完整无裁剪的旋转结果""" h, w = sprite.shape[:2] diag = int(np.ceil(np.sqrt(w * w + h * h))) cx, cy = w / 2.0, h / 2.0 M = cv2.getRotationMatrix2D((cx, cy), angle_deg, 1.0) # 把旋转中心平移到扩展画布中心,避免越界裁剪 M[0, 2] += diag / 2.0 - cx M[1, 2] += diag / 2.0 - cy rotated = cv2.warpAffine( sprite, M, (diag, diag), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_CONSTANT, borderValue=(0, 0, 0, 0) ) return rotated, M

第二个坑是中心点漂移。旋转后物体在扩大的画布里,它的中心不再是画布中心,而是原中心加上旋转矩阵的平移量。贴到背景时,你必须先算出旋转后物体的中心在贴图坐标系中的位置,再把它移动到背景的粘贴点。否则物体看起来会“抖”,而且 OBB 标签的中心点和渲染的物体中心对不上。

def paste_with_alpha(bg, sprite, paste_x, paste_y, angle_deg): rotated, M = rotate_sprite(sprite, angle_deg) rh, rw = rotated.shape[:2] # 原贴图中心经过旋转矩阵后的位置 center = np.array([sprite.shape[1]/2, sprite.shape[0]/2, 1.0]) new_center = M @ center # 让物体中心落在 (paste_x, paste_y) x0 = int(paste_x - new_center[0]) y0 = int(paste_y - new_center[1]) alpha = rotated[:, :, 3:] / 255.0 roi = bg[y0:y0+rh, x0:x0+rw] roi[:, :, :] = roi * (1 - alpha) + rotated[:, :, :3] * alpha return x0, y0, rotated

这一步的标签计算也要跟着改:物体在没有旋转之前有四个角点(0,0),(w,0),(w,h),(0,h),旋转并平移后,把四个角点经过同一个变换矩阵 M 变换,再叠加上背景的偏移量,就得到旋转矩形框在背景图上的四角坐标。这是 OBB 标注的来源。

2.3 一行行写对 OBB 标签,并用可视化兜底

YOLOv8-OBB 的标签文件是 txt,每一行格式是:

class_id x1 y1 x2 y2 x3 y3 x4 y4

四个点按物体轮廓顺序排列,坐标是相对图片宽度和高度归一化后的浮点数。没有置信度字段,没有额外属性,每个框一行。比如:

0 0.352 0.441 0.486 0.402 0.512 0.548 0.378 0.587

我第一次写生成器时就没注意点序,结果训练时 loss 怎么都降不下去。这里建议固定输出顺时针顺序,从左上角开始,这样后续可视化调试时心智负担最小。

一个非常值得做的操作是“标签可视化兜底”:生成完数据集后,随便抽几十张图,把标注的四个角点画出来,看是不是严丝合缝地贴合物体边缘。不要跳过这步,我犯过的错包括:透明通道没融合干净导致物体边缘发黑、旋转后中心偏移导致框和物体差半个身位、归一化除错宽度高度导致框全乱。这些错误如果不可视化,训练时很难发现,最后模型效果差都不知道是数据的问题还是模型的问题。

import cv2, numpy as np def draw_obb(img, label_line, color=(0, 255, 0)): parts = label_line.strip().split() pts = np.array([float(x) for x in parts[1:]], dtype=np.float32) pts = pts.reshape(4, 2) pts[:, 0] *= img.shape[1] pts[:, 1] *= img.shape[0] pts = pts.astype(np.int32) cv2.polylines(img, [pts], True, color, 2) return img

合成器最后输出的数据集结构是:

datasets/ ├── images/ │ ├── train/*.jpg │ └── val/*.jpg └── labels/ ├── train/*.txt └── val/*.txt

每张 jpg 对应一个同名 txt。我离线生成了 3200 张训练图、400 张验证图,每张包含 3~8 个随机旋转的目标,旋转角度覆盖 0°~360°。背景随机选纯色渐变、条纹纹理、模糊照片,再随机叠加几条干扰线和噪点,模拟验证码常见的“防识别”元素。

3. 跑通训练:YOLOv8-OBB 的配置、命令与效果评估

3.1 准备目录与 yaml:OBB 数据集长什么样

环境只需要ultralytics和opencv-python,直接用 pip 安装即可。数据集准备好之后,需要写一个 yaml 描述文件:

path: datasets/rot_captcha train: images/train val: images/val names: 0: target

这个字段的含义和普通 YOLO 完全一致,只是模型结构换成 OBB 版本。类别不需要多,因为旋转验证码通常只关心“这张图是否是目标物体”,类别都归成target就好。如果你想同时区分“物体 A”和“物体 B”,也可以多写几个类别,但要注意每个类别的样本量要均衡。

3.2 训练命令和超参选择的真实考量

我使用的训练命令如下:

yolo train data=rot_captcha.yaml model=yolov8n-obb.pt epochs=300 imgsz=640 batch=16

几个参数的选择理由:

  • 模型选择yolov8n-obb.pt:n 版本最快,先跑通流程、验证数据无误,再换更大的模型。OBB 任务比普通检测难一点,但验证码场景目标通常不多,n 级别的容量已经够用。
  • imgsz=640:验证码目标相对较小,如果训练图里物体太密集,可以改 1024。但要先估算显存,640 在 8GB 显卡上 batch 16 是可以跑的。
  • epochs=300:旋转角度回归比普通分类收敛慢,150 轮可能不够,300 轮更稳妥。观察 loss 曲线如果没有明显下降趋势,再决定是否提前停止。
  • batch=16:小起步。如果你的显卡只有 6GB,降到 8;如果显存充裕,调大有利于稳定收敛。

我第一轮训练用了 150 epochs,验证集 OBB mAP50 只有 0.62 左右,加到 300 轮之后 mAP50 到了 0.93。原因是角度回归任务在后期需要较多轮次去精修边框和角度,前面只是在学“物体在哪里”。

3.3 不看 mAP 猜效果:角度误差才是旋转任务的关键指标

训练完别急着看 mAP,那是检测框的指标,对旋转验证码来说最核心的指标是角度误差。你真正关心的是:预测出来的角度和真实角度差多少度。如果平均误差在 5 度以内,说明模型真的学会了;如果平均误差 20 度以上,mAP 再高也白搭。

可以自己写一个验证脚本,遍历验证集,读取每张图的真实标注角度和模型预测角度,然后统计:

  • MAE(平均绝对角度误差,注意角度周期的 wrap);
  • 误差超过 10 度的样本占比;
  • 检测召回率。
import numpy as np from scipy.spatial.transform import Rotation def angle_error(pred_deg, gt_deg): diff = np.abs((pred_deg - gt_deg + 180) % 360 - 180) return diff errors = [] for pred_angle, gt_angle in results: errors.append(angle_error(pred_angle, gt_angle)) print("MAE:", np.mean(errors)) print(">10度占比:", np.mean(np.array(errors) > 10))

我在模拟项目X上跑出来的典型结果是:检测召回 99% 左右,MAE 约 2.8 度,大于 10 度的样本占比约 2%。这个水平已经足够支撑后续验证码交互逻辑。

这里还有一个容易翻车的点:角度周期。模型预测 179 度和真实 -179 度是同一个物理角度,但直接相减会得到 358 度的假误差。一定要用上面那个 wrap 公式做周期性校正,否则你的验证集 MAE 会莫名其妙变得很高。

4. 从模型输出到验证码答案:角度还原、180度歧义与交互映射

4.1 从预测结果里把“原始角度”还原出来

训练完成后,加载best.pt做推理,代码如下:

from ultralytics import YOLO model = YOLO("runs/obb/train/weights/best.pt") results = model.predict("demo.jpg", conf=0.25) for r in results: if r.obb is None: continue obb = r.obb xywhr = obb.xywhr.cpu().numpy() # [cx, cy, w, h, angle_rad] cls = obb.cls.cpu().numpy() conf = obb.conf.cpu().numpy()

angle_rad是弧度制的角度,但不同版本对这个角度的取值约定不完全一样,有的返回[-pi/2, 0),有的返回[0, pi)。为了避免踩坑,我做了一步“自己动手还原角度”:直接取 OBB 输出里的四个角点坐标,然后计算主轴方向。

def get_orientation_angle(obb_result): pts = obb_result.xyxyxyxy[0].cpu().numpy() # shape (4, 2) edges = np.roll(pts, -1, axis=0) - pts lengths = np.linalg.norm(edges, axis=1) longest_idx = np.argmax(lengths) longest_edge = edges[longest_idx] angle = np.degrees(np.arctan2(longest_edge[1], longest_edge[0])) return angle % 180 # 主轴方向只有 180 度周期

这样无论库内部怎么定义角度范围,我拿到的都是稳定的主轴角度。注意这里取模 180,因为 OBB 框没有头尾,180 度和 0 度在框的层面上完全一样。

4.2 180 度歧义:OBB 的物理边界和绕开方案

这是旋转验证码识别里最容易被忽略、也是影响最大的一个坑:OBB 只能告诉你物体的主轴方向,不能告诉你头在哪边。

举个例子。一个长方形的火箭图标,竖直状态和旋转 180 度后的竖直状态,OBB 输出是完全一样的——宽度、高度、中心点、主轴角度全部相同。但从验证码的语义上,一个是“正立”,一个是“倒立”,完全是两个答案。如果你直接拿 OBB 角度去判断正反,所有 180 度旋转的样本都会判错。

解决办法有两种:

第一种是业务层面绕开。如果验证码本身只要求“找出与其余图片方向不同的那张”,并且物体本身没有强烈的头尾语义,比如圆形徽标、符号类图标,那 0 度和 180 度在业务上是等价的,OBB 就够用。

第二种是增加一个朝向分类器。在检测到 OBB 框之后,把框内区域裁剪出来,送进一个小型二分类网络,判断“正立”还是“倒立”。这个分类器的输入是旋转到水平后的物体图像,输出 0/1。因为检测已经解决了“物体在哪”,朝向分类只需要解决“头朝上还是朝下”,任务简单,用一个很小的 CNN 就能跑到 98% 以上。

我在模拟项目X里的做法是:OBB 输出主轴角度,朝向分类器输出 0 或者 1。最终旋转角度 = 主轴角度 + (朝向分类器给的 0 或 180 度修正)。这个组合方案比纯 OBB 直接判断准确率高很多,而且两个模型都轻量,验证码这种单张图场景毫秒级就能完成。

4.3 把检测结果翻译成验证码交互:一个可运行的后处理片段

最后一步是把角度还原成验证码的“答案”。假设业务规则是“找出所有相对正立方向误差超过 15 度的目标,并返回它们的中心点坐标”,后处理可以这样写:

def answer_from_results(results, upright_ref=90.0, angle_thresh=15.0): answers = [] for r in results: if r.obb is None: continue obb = r.obb cxcy = obb.xywhr[:, :2].cpu().numpy() for i, box in enumerate(cxcy): angle = get_orientation_angle(obb[i:i+1]) # 如果使用了朝向分类器,这里需要根据分类结果加上 0 或 180 # angle = angle + flip_offset err = abs(angle - upright_ref) % 180 if err > angle_thresh: answers.append(box.tolist()) return answers

如果你做的是“转正”类验证码,那answers输出的就是需要转正的目标位置和角度;如果做的是“点击错误项”,那answers就是需要点击的坐标列表。把这两类弄清楚,模型部分就彻底闭环了。

注意:这里我说的都是模型层处理。真实部署时还要考虑验证码前端交互逻辑,比如鼠标拖动角度映射成旋转量、点击坐标的页面坐标系转换,那些属于自动化脚本层面,不在模型训练范围内。

5. 把准确率从“能用”提到“好用”:调参顺序与典型翻车

5.1 合成数据过拟合:模型把背景背下来了怎么办

我在模拟项目A里第一次遇到的问题是:训练集 mAP50 已经接近 0.98,验证集也很好,但一上真实风格验证码就崩。原因很简单,合成器里的背景种类太固定,模型学会了“看到绿色渐变背景就知道有物体”,而不是“看到目标特征才检测目标”。

解决思路有两个方向。第一是让合成器“无限生成”:训练时每轮动态生成新样本,背景、干扰、位置全部随机,模型永远见不到重复图片。第二是准备一个和训练风格完全不同的“留出验证集”,比如训练用纯色背景,留出验证用真实风景照片背景。这样模型过没过拟合,一眼就能看出来。

我后来把合成器改成动态模式,训练过程每轮数据都不一样,过拟合问题明显缓解。代价是训练时间变长,但旋转验证码样本量本来就不大,几万张动态生成图对 CPU 合成来说压力也不大。

5.2 标签边界问题:归一化、坐标顺序与角度周期

OBB 标签的几个边界问题,都是实验室里不会教、但实际会卡你很久的细节:

  • 归一化坐标如果出现负数或者大于 1 的数,YOLO 训练会警告甚至直接忽略该样本。合成物体贴到背景边缘时,很容易被裁掉一部分,导致角点越界。我的处理方式是检测到越界就重新选一个粘贴点,而不是强行保留这个实例。
  • 四个角点的坐标顺序必须一致。如果训练集里有的顺时针有的逆时针,模型会遇到“同一个框学出两种表示”的冲突,loss 很难收敛。
  • 角度周期问题前面已经提过,验证脚本里必须用 wrap 公式计算误差,不能用朴素差值。

5.3 增强策略怎么开才不会帮倒忙

YOLOv8-OBB 支持训练时数据增强,但不是所有增强都适合旋转任务。我的实测经验:

  • 值得开:亮度、对比度、饱和度、轻微模糊、噪声。这些增强可以提升模型对验证码干扰元素的鲁棒性。
  • 谨慎开:随机旋转增强。理由是 OBB 已经包含角度标签,模型本来就需要学习任意角度,如果增强阶段再把图片旋转一个随机角度,相当于强迫模型对“旋转后的旋转”保持一致性,虽然理论上没问题,但实际上会干扰角度参数的学习稳定性。我会把旋转增强关掉或限制在小角度范围。
  • 不要开:随机裁剪。裁剪可能把 OBB 框切掉一部分,导致标签越界。

Ultralytics 的增强参数在训练命令里通过hsv_h、hsv_s、degrees、translate等字段控制。我常用的一组配置是:关闭 degrees,适度开启hsv_s和hsv_v,translate保持默认。

5.4 翻车排查清单与推荐调参顺序

最后给一张排查表,我每次遇到问题都会按这个顺序查一遍。

现象可能原因验证方法
loss 不降标签四点顺序不一致可视化标注,检查点序
数据里有大量空白/乱标框抽检训练图,看物体和框是否贴合
mAP 高但角度误差大角度周期 wrap 没做修正验证脚本误差公式
训练数据角度分布不均衡检查合成器角度是否均匀覆盖 0~360
训练集很好,真实样本崩合成背景风格过单一增加背景多样性,动态生成
检测漏检多目标太小提高imgsz,或增强时控制目标最小尺寸
正立和倒立分不清没有朝向分类器加二分类网络做 0/180 度修正

调参顺序我建议是:先修正标签和数据 → 再调模型大小 → 再调增强参数 → 最后调后处理阈值。不要一上来就换大模型,数据有毛病,模型再大也只是把错误记得更牢。

我在模拟项目X里最终用的配置是:动态合成器 + YOLOv8s-OBB + 300 epochs + 朝向二分类修正,验证集整体成功率在 96% 左右。日常维护中最有用的一条经验是:先花半天时间把合成器和可视化脚本写好,再开始训练。磨刀不误砍柴工,这个顺序能帮你省掉后面无数轮“数据不对重新训练”的折腾。

RELATED READING

延伸阅读

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