
简介场景文本检测是OCR系统中的关键环节目标是精准定位图像中文字的位置。EASTEfficient and Accurate Scene Text Detector作为端到端文本检测算法通过卷积网络直接回归文本框顶点摒弃传统候选框流程在速度和精度上均有优异表现。其核心原理是融合多尺度特征图输出像素级文本概率与几何信息并利用Locality-Aware NMS高效合并候选框。该技术广泛应用于街景招牌识别、文档扫描、自动驾驶路牌解析等真实场景。本文从算法架构、损失函数设计到工程代码结构、训练推理细节再到常见调优技巧与二次开发管道系统梳理了EAST的完整落地路径为开发者提供一站式实战参考。1. 把街景里的文字精准抠出来这是一个能跑通的场景文本检测工程商场门口随手拍一张照片想让程序把招牌上的“XX火锅”四个字的位置框出来这正是 EAST 这类场景文本检测算法干的活。EAST 全称 Efficient and Accurate Scene Text Detector核心思路是用卷积网络直接回归文本框四个顶点坐标把传统方法里候选框、分类、细化那一整条流水线砍掉检测速度在 GPU 上能跑到十几帧以上精度在 ICDAR 榜单上也是一梯队水平。这份“旷世east文本检测.zip”不是论文复现的玩具压缩包里训练、推理、数据准备三块代码都齐了带着预训练权重和标注样本你拿一张图就能看到文本框被画出来。适合想用文本检测做落地项目的开发者也适合拿来找个经典模型练手的人。这篇笔记从原理讲到工程再讲怎么改全程按一线跑代码的视角来写。2. 算法与架构拆解从骨干网络到 Locality-Aware NMS 的完整链路2.1 骨干网络四层特征图的分辨率与通道设计EAST 跟传统文本检测最大的区别是“一步到位”——不搞 Region Proposal不搞后续分类精修而是让网络直接输出每个像素属于文本的概率以及该像素对应的文本框四个顶点坐标。整个网络因此分成三段骨干特征提取、特征融合、输出层。骨干网络常见做法是换 ResNet50 替代原论文里的 VGG16训练更快收敛更稳。取 res2 到 res5 四个 stage 的输出记作 C2C5通道数分别是 256、512、1024、2048。这里有个关键点只取到 res5 就停不要再往下走第六个 stage因为文本检测是像素级任务特征图分辨率太低会把小文字直接抹掉。原图输入 512×512 时C2 是 128×128C5 是 16×16往下信息越来越语义化但越来越粗糙所以需要特征融合把各层优势拼起来。import torch.nn as nn import torchvision.models as models class FeatureExtractor(nn.Module): def __init__(self): super().__init__() backbone models.resnet50(pretrainedTrue) # 将 ResNet50 拆成四个阶段分别输出 C2~C5 self.stage2 nn.Sequential(*list(backbone.children())[:5]) # 128x128, 256ch self.stage3 nn.Sequential(*list(backbone.children())[5:6]) # 64x64, 512ch self.stage4 nn.Sequential(*list(backbone.children())[6:7]) # 32x32, 1024ch self.stage5 nn.Sequential(*list(backbone.children())[7:8]) # 16x16, 2048ch这里把 pretrainedTrue 是因为文本检测的骨干直接吃 ImageNet 预训练权重能省大量训练时间。你在小数据集上从零训练很难收敛所以这个参数建议保持默认。四个 stage 里 stage2 负责细节纹理stage5 负责语义类别后续融合就是把浅层的“长什么样”和深层的“是什么”合并起来用。2.2 特征融合与输出层1/4 分辨率共享特征图两个预测分支特征融合模块是个轻量 FPN 变体方向是自顶向下逐步合并。C5 先上采样 2 倍跟 C4 做通道拼接后过 1×1 卷积压到 128 通道得到的结果再上采样跟 C3 拼再压到 128最后跟 C2 拼同样压到 128。这样最终拿到的共享特征图分辨率是原图的 1/4对 512×512 输入来说就是 128×128既保留了小文字的细节又带上了深层语义。class FeatureMerge(nn.Module): def __init__(self): super().__init__() self.upsample nn.Upsample(scale_factor2, modenearest) self.conv_h4 nn.Conv2d(2048 1024, 128, 1) # C5C4 - 128 self.conv_h3 nn.Conv2d(128 512, 128, 1) # H4C3 - 128 self.conv_h2 nn.Conv2d(128 256, 128, 1) # H3C2 - 128 def forward(self, c2, c3, c4, c5): h4 self.conv_h4(torch.cat([self.upsample(c5), c4], dim1)) h3 self.conv_h3(torch.cat([self.upsample(h4), c3], dim1)) h2 self.conv_h2(torch.cat([self.upsample(h3), c2], dim1)) return h21×1 卷积在这里不是省参数而是做通道对齐——把不同语义层次的通道数统一到 128后面输出层就不用管通道规模差异了。用 nearest 上采样而不是反卷积是因为文本边缘对振铃伪影很敏感反卷积容易在文字边界产生波纹影响后续几何回归。输出层是两个独立分支共用一个 128 通道的共享特征图。score 分支用 1×1 卷积输出单通道再接 sigmoid表示每个像素属于文本框内部的概率。geometry 分支有两种配置RBOX 输出 5 通道前四个是像素到上、右、下、左四条边的距离第五个是文本框的倾斜角QUAD 输出 8 通道直接回归四个顶点的相对坐标偏移。这个项目默认走 RBOX推理时拿距离和角度就能还原出旋转矩形框计算量小速度更快。2.3 损失函数Score 用 DiceGeometry 用 IoU为什么这么配文本检测里的正负样本是天然不平衡的——一张 512×512 图文字区域可能只占 5%剩下全是背景。score 分支如果直接上 BCE Loss网络会偷懒把所有像素预测成背景因为那样 loss 也很低。这里用 Dice Loss 是常见解法它按区域重叠比例计算损失对前景占比不敏感能逼着网络把注意力放在真正的文本像素上。几何分支的标签构造有个容易忽略的细节训练时正样本不是整个标注框而是把多边形向内收缩 0.3 倍后的区域。换句话说靠近文本边缘的像素不参与几何回归。原因是边缘像素既属于文本又属于背景它到底该回归到框的哪条边语义上是模糊的硬让它学只会让模型震荡。只有文本中心区域的像素才被要求准确回归四边距离和角度。def loss_fn(score_map, geo_map, score_gt, geo_gt, mask): # score 用 Dice Loss处理前景占比小的问题 inter (score_map * score_gt).sum() union (score_map * score_map).sum() (score_gt * score_gt).sum() dice_loss 1 - 2 * inter / (union 1e-6) # geometry 用 IoU Loss 角度惩罚只对 mask 覆盖的像素计算 a1 torch.max(geo_map[:, 0], geo_gt[:, 0]) torch.max(geo_map[:, 2], geo_gt[:, 2]) b1 torch.max(geo_map[:, 1], geo_gt[:, 1]) torch.max(geo_map[:, 3], geo_gt[:, 3]) area_intersect torch.min(geo_map[:, 0], geo_gt[:, 0]) * torch.min(geo_map[:, 1], geo_gt[:, 1]) area_union a1 * b1 iou_loss -torch.log((area_intersect 1e-6) / (area_union 1e-6)).mean() angle_loss 1 - torch.cos(geo_map[:, 4] - geo_gt[:, 4]).mean() return dice_loss iou_loss angle_lossIoU Loss 里对四边距离求交并面积比是希望预测出的矩形跟真实矩形尽量重叠比简单的 L1 距离更能直接反映检测框质量。角度惩罚之所以用 1-cos 而不是 L2是因为角度是周期性变量0° 和 360° 其实是一个方向直接用 L2 会让网络在这两个值之间来回横跳cos 距离天然把周期性问题抹平了。三次 loss 相加不需要调权重量级都在 1 左右直接加就行。2.4 Locality-Aware NMS把标准 NMS 从 O(n²) 降成 O(n)这是 EAST 的彩蛋推理阶段网络会稠密地输出大量候选框——score 图里每个超过阈值的像素都可能产生一个框一张图常见几千个候选。标准 NMS 要做两层循环两两算 IoU几千个框能让你卡到怀疑人生。EAST 的做法是 Locality-Aware NMS先按 y 坐标对候选框排序每行只跟相邻几行内的框做 IoU 合并跨行远处的框直接不比较因为文本行本来就是水平分布的。def locality_aware_nms(boxes, scores, threshold0.3): # boxes: (N, 4) 或 (N, 5)包含角度分数降序排序 order scores.argsort()[::-1] merged [] while order.size(0) 0: i order[0] # 只取与当前框 y 中心接近的候选做 NMS减少两两比较 close (boxes[order, 1] - boxes[i, 1]).abs() 0.1 * boxes[i, 3] sub_order order[close] # 加权合并而不是直接保留最高分框 weight torch.softmax(scores[sub_order], dim0) avg_box (boxes[sub_order].float() * weight[:, None]).sum(dim0) merged.append(avg_box) order order[~close] return merged逐行合并的精髓在于用加权平均代替“保留最高分、删除其余”的粗暴方式。同一行文字之间本来就有重叠加权平均能把相邻框的位置信息融合得更准相当于做了一个局部平滑。0.1×框高这个阈值是经验值设太小长文本框中间会断成两截设太大相邻两行文字会被误并成一个框。如果你发现检测结果里长文本被切成多段优先调大这个系数而不是去降 NMS 的 IoU 阈值。3. 工程结构拆解训练、推理、数据三块代码怎么组织3.1 目录结构拿到压缩包先认路别一上来就翻模型这份资源的目录组织很常规按工具职责分了三块拿到手先花两分钟过一遍后面找代码不会迷路。east-text-detect/ ├── configs/ │ └── east_config.py # 全局超参数学习率、图像尺寸、阈值 ├── data/ │ ├── dataset.py # 读取 ICDAR 标注在线生成 score/geo 目标 │ └── augment.py # 随机缩放、旋转、颜色抖动 ├── models/ │ ├── east.py # 网络结构骨干 特征融合 输出层 │ ├── loss.py # Dice IoU Angle 组合损失 │ └── nms.py # Locality-Aware NMS 实现 ├── tools/ │ ├── train.py # 训练入口支持多卡 │ ├── predict.py # 单图推理输出画框结果 │ └── evaluate.py # ICDAR 格式评测算 Precision/Recall └── weights/ └── pretrained.pth # 在 ICDAR 上训好的权重直接可用我会重点看三个文件models/east.py 决定网络结构怎么改data/dataset.py 决定训练数据怎么喂tools/predict.py 决定推理结果怎么出来。这三个文件各管一段改模型不动数据调数据不影响推理模块之间解耦得很干净。3.2 核心实现解读三个文件的职责边界models/east.py 里的 EAST 类把前面讲的三大段拼起来forward 返回 score 和 geometry 两个输出。注意它返回的是原始 logits 而不是 sigmoid 后的概率因为训练时 loss 内部会做 sigmoid推理时 predict.py 再单独做一次避免两头重复计算。data/dataset.py 里有整个项目最容易被忽略的一段逻辑——polygon 收缩。标注给的是四边形的四个顶点但训练目标要求的是“文本中心区域像素”到四边的距离。代码会先把原始多边形按 0.3 比例向中心收缩收缩后的区域里每个像素才被标记为正样本再逐个像素算到四条边的距离。这个过程用 shapely 库的 affinity.scale 和 distance 接口纯 numpy 手写很容易翻车。data/augment.py 里的随机旋转值得注意文本检测跟分类不一样图像旋转后标注框的四个顶点也得跟着旋转而且旋转后的坐标可能跑到图像外。实现上会先对标注多边形做同样的仿射变换再做一个裁剪把超出边界的部分去掉。这个文件建议保持默认开启场景文本检测对多方向文字鲁棒性要求很高不做旋转增强的话模型几乎学不会倾斜文本。3.3 数据准备ICDAR 标签怎么转成训练目标一个像素一个像素地算ICDAR 数据集每行标注是“x1,y1,x2,y2,x3,y3,x4,y4,text”九个字段逗号分隔。dataset.py 读进来后先解析成 shapely Polygon再做两件事算掩码、算几何目标。def make_target(polygon, image_shape, shrink_ratio0.3): # 1. 按比例收缩多边形得到 score 图的正样本区域 shrink polygon.scale( xfactshrink_ratio, yfactshrink_ratio, originpolygon.centroid ) score_map rasterize(shrink, image_shape) # 多边形内部填 1其余 0 # 2. 对每个正样本像素计算到原多边形四条边的距离 geo_map np.zeros((5, image_shape[0], image_shape[1])) for y, x in np.argwhere(score_map 0): d_top distance_to_line(polygon, top, (x, y)) d_right distance_to_line(polygon, right, (x, y)) d_bottom distance_to_line(polygon, bottom, (x, y)) d_left distance_to_line(polygon, left, (x, y)) angle polygon_angle(polygon) geo_map[:, y, x] [d_top, d_right, d_bottom, d_left, angle] return score_map, geo_map这里直接对每个正样本像素循环算距离一张图要跑几十万次循环所以代码里通常会用 scipy.ndimage.distance_transform_edt 批量算像素到多边形边的距离能快两个数量级。angle 计算用的是四边形较长那条边的方向角因为文本行的主方向是由长边决定的。你如果自己造数据最常犯的错是忘了 shrink直接把整个多边形当正样本那样训练出来的框会偏大一圈边缘定位不准。4. 从零跑通环境配置、训练启动与推理 Demo4.1 依赖环境torch、opencv、shapely 一个都不能少跑这份代码的环境要求不高Python 3.8 以上PyTorch 1.10 以上其余都是常规库。opencv 用来做图像读写、缩放和画框shapely 用来处理多边形几何计算比如算 IoU、收缩多边形numpy 和 torch 就不用多说了。pip install torch torchvision opencv-python shapely numpy这里提醒一下shapely 有两个兼容版本1.8 和 2.x 的 API 略有差异。2.x 里 scale 和 centroid 还是兼容的但有些旧的 rotation 方法被移除了。如果你用的是 shapely 2.x 且报模块找不到属性优先检查是不是代码里用了老 API而不是急着改代码——很多老项目问题都出在库版本升级上。4.2 推理 Demo一张图跑出文本框先验证资源可用拿到资源第一步是先跑推理确认预训练权重没损坏、环境没问题再考虑训练的事。项目自带的 predict.py 支持从命令行传图输出的图会画上检测到的文本框和置信度。python tools/predict.py \ --image demo/street.jpg \ --weight weights/pretrained.pth \ --mode RBOX \ --score-threshold 0.8score-threshold 是 score 图的置信度门槛0.8 意味着只有概率超过 80% 的像素才生成候选框。实际跑的时候建议先大胆调到 0.5 看全量候选框再逐步调高。如果检测结果里全是漏检而不是误检那就是阈值设太高了如果全是误检说明阈值太低。这个参数是性价比最高的“后悔药”比改网络结构快得多。predict.py 内部流程是固定的五步读图、缩放到 512×512、前向拿到 score 和 geometry、按 score 阈值筛像素、坐下采样还原到原图坐标。最后一步经常被新手忽略——网络输出是 1/4 分辨率每个像素对应的框坐标必须先乘 4 再映射回原图否则框的位置会整体偏左上。代码里已经处理好了你二次开发时如果自己写推理脚本千万别漏。4.3 训练启动小数据集微调的参数怎么设训练入口是 tools/train.py默认配置写在 configs/east_config.py 里。先给一张参数速查表后面解释哪些是能动的、哪些是尽量别动的。参数默认值作用建议image_size512训练时图像缩放边长小文字多就提到 640显存不够就降 384batch_size16单卡批量大小显存不够优先降这个别降分辨率lr1e-4Adam 初始学习率微调时可以降到 5e-5epoch50总训练轮数小数据集 30 轮就能看趋势score_weight1.0Dice Loss 权重误检多就调高到 2压制误检shrink_ratio0.3正样本收缩比例框偏大调小框偏小调大训练命令python tools/train.py \ --config configs/east_config.py \ --data-dir data/icdar2015 \ --weights weights/pretrained.pth \ --epochs 30注意 weights 参数传的是预训练权重不是训练日志恢复。如果你想从上一次训练中断的地方继续代码里另有 resume 参数两者千万别搞混——传错的话模型会从零开始而不是接着训练。想在一个小数据集上快速验证效果我一般会先把 pretrained 权重加载进来然后只训后面输出层那几个卷积骨干冻结不动跑 5 个 epoch 看 loss 有没有降。这一招能帮你快速判断数据标注有没有问题而不是一上来就全量训练然后发现 loss 不降黑匣子完全摸不透。5. 避坑指南我在 EAST 训练和调优中踩过的五个坑5.1 训练到一半 loss 变成 NaN模型参数全废现象第 20 个 epoch 之后 loss 突然变成 NaN梯度爆炸没有先兆。原因geometry 分支的 IoU Loss 里除了一项 area_union当预测框面积接近 0 时log(0) 直接炸掉。这个在训练初期最容易出现尤其是 batch 里恰好有一张几乎全黑、没有文本的图。解决给 area_union 加一个平滑项代码里改成 area_union 1e-6同时检查 score 分支是不是用了 BCE如果是确认输入是 logits 而不是概率。这两个地方修完NaN 基本不会再出现。5.2 检测框大面积重叠同一行文字被判成一坨现象一张街景图里一行招牌文字被输出十几个几乎重合的框边界糊成一片。原因score-threshold 设太低时每个像素都变成候选框而 Locality-Aware NMS 的按行合并依赖加权平均。如果这一行文字本身弯曲幅度大逐行合并的假设失效框就散不开。解决优先把 score-threshold 从 0.8 提到 0.85 或 0.9减少噪声像素其次检查 nms.py 里 close 判断阈值——0.1×框高对弯曲文本不够改成 0.25×框高能更好粘合同一行的框。注意两个参数要一起调单改一个效果都不理想。5.3 小字号文字全部漏检大招牌倒是很准现象检测结果里店铺大招牌框得很准但路牌、菜单上的小字一个都没框出来。原因训练时图像被缩放到 512×512很多小字号文字缩完后只有十几个像素骨干网络下采样四次后特征几乎消失。解决训练和推理都改成 640 或 768 分辨率。改 image_size 后 batch_size 要跟着降显存不够就把 batch_size 从 16 降到 8不要同时硬撑。这一招对小文字召回率的提升比调任何损失函数都明显。5.4 predict.py 跑一半 OpenCV 报错cv2.boxPoints 崩了现象推理时偶发报错 “angle out of range” 或旋转矩形绘制失败。原因网络输出的角度理论上在 [-π/4, π/4] 区间但训出来的模型偶尔会给出超出范围的预测值。OpenCV 对角度输入有严格范围限制越界直接抛异常。解决在 predict.py 里画框之前对裁剪后的角度做一次 clamp 限制在 [-π/2, π/2] 范围内同时对四边距离做 relu 截断负值。这是推理代码该有的防御性处理不丢准确率但能避免跑一半崩溃。5.5 召回率 90% 但精度只有 40%框多得离谱现象评测时召回不低但 precision 惨不忍睹背景区域被疯狂误检成文本。原因score-threshold 设太低背景像素也被当成了正样本。更隐蔽的原因是对比度强的非文本区域——比如窗户网格、砖墙纹理——score 图会给出中等强度响应。解决把 score 图可视化出来看背景误检区域是什么形状。如果是整块整块的高亮那说明模型过拟合了背景纹理需要加数据增强如果是零星亮点直接调高阈值把低置信度都滤掉。两者性质完全不同别盲目调一个参数。6. 二次开发把 EAST 接到文本识别上做成一个定位 识别 pipelineEAST 单独用只能拿到文本的位置要变成真正能“读字”的系统需要再接一个识别模型常见的做法是 CRNN CTC。接法很简单EAST 输出的旋转矩形框不能直接喂给识别网络得先把框里文字做透视矫正拉平成水平方向。def crop_and_warp(image, box): # box 是 EAST 输出的四个顶点顺序是左上、右上、右下、左下 width max(np.linalg.norm(box[1] - box[0]), np.linalg.norm(box[2] - box[3])) height max(np.linalg.norm(box[3] - box[0]), np.linalg.norm(box[2] - box[1])) dst np.array([[0, 0], [width - 1, 0], [width - 1, height - 1], [0, height - 1]], dtypenp.float32) matrix cv2.getPerspectiveTransform(box.astype(np.float32), dst) crop cv2.warpPerspective(image, matrix, (int(width), int(height))) return crop这个 warp 函数几乎每个接识别的项目都会用到透视变换能纠正侧拍带来的梯形畸变识别准确率能提升好几个百分点。做完矫正把 crop 缩放到识别模型要求的固定高度比如 32 像素宽度等比缩放再喂给 CRNN 走 CTC 解码就能出文字。轻量化改造方面我试过把骨干从 ResNet50 换成 MobileNetV3速度能快两到三倍精度掉得不多代价是特征融合层的通道数要跟着调——MobileNet 的 C2~C5 通道数和 ResNet 不一样融合层的输入维数必须同步改。想导出 ONNX 在端侧跑注意两点Upsample 用 nearest 模式是能导出的但 Locality-Aware NMS 里的循环导不出去常见做法是把网络输出原样导出 ONNXNMS 留在端侧框架里用 CPU 实现。从那以后我每次接手一个检测模型都强制走一遍“先跑通推理、再可视化中间结果、最后改结构”的流程这个顺序能替你省掉大量调参时间希望帮到你。本文还有配套的精品资源点击获取