ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

EAST文本检测项目拆解:从倾斜文字到旋转文本框的实现

EAST文本检测项目拆解:从倾斜文字到旋转文本框的实现

简介:这是一份面向自然场景文本检测的EAST实现资源包,适合对OCR、场景文字定位感兴趣的开发者和学生动手实践。内容围绕EAST模型整理,包含可加载的h5模型权重、Python脚本、数据样本及相关标签说明,按模型训练和推理需求组织,便于对照博客说明把检测流程完整跑通。压缩包共2000余个文件,以jpg图片、npy数组和txt文本为主,另有py脚本、pyc、h5模型与xml等辅助文件,整体约461.7MB,可覆盖数据、训练权重和运行代码等多方面素材。已有352人学习下载,对刚接触EAST算法的读者来说,可直接通过样本和脚本观察数据组织方式,理解模型输入输出与训练细节,缩短环境搭建和代码调试时间。

1. 一个开源文本检测项目的完整拆解:从倾斜文字到旋转文本框的最后一公里

做自然场景OCR最头疼的从来不是识别,而是“先找到文字在哪”。尤其在街景、票据、工单照片这些场景里,文字行往往带着明显倾斜,横平竖直的目标检测框根本框不住内容。这份“旷世east文本检测.zip”资源,本质上是把EAST算法从论文变成了一份能直接跑的工程实现。你给它一张自然场景图片,它直接输出旋转后的四边形文本框坐标,刚好把文字行“包住”。对于要做营业执照识别、门店招牌文字采集、视频截图字幕检测的开发者,这个包解决的是OCR流水线里最容易被卡住的一环——倾斜文本定位。老手可以直接拿源码改训练集,新手能照着流程跑通推理,下面按我拆项目的顺序,从目录到代码逐一讲清楚。

2. 项目结构与技术选型:从目录看EAST的实现骨架

EAST这个名字指 Efficient and Accurate Scene Text Detector,核心思路是“端到端预测文本行几何形状”。拿到这份压缩包,我建议先不要看代码,先走一遍目录,弄清楚哪些文件管训练、哪些管推理,再深入到具体实现就不会迷路。

2.1 目录结构:训练、推理、后处理三块怎么分工

解压后第一层结构通常是这样的:

east-text-detection/ ├── src/ │ ├── model.py # 网络结构定义,特征提取与输出层 │ ├── loss.py # 训练损失函数 │ ├── data_generator.py # 从标注文件生成score map和geometry │ ├── decode.py # 推理后处理,把网络输出转成文本框 │ └── eval.py # 在验证集上计算检测精度 ├── config.py # 全局配置:输入尺寸、阈值、训练参数 ├── train.py # 训练入口 ├── run_inference.py # 单张图片推理入口 └── models/ # 预训练权重存放目录

你看完这份目录,其实已经把EAST的工程套路看明白了:model.py负责网络“算”,decode.py负责把网络的输出“翻译”成坐标,data_generator.py负责把人的标注“翻译”成网络能学的标签。整个项目最重的逻辑都压在这三个文件上,其余入口文件只是把这三块串起来。

在实际源码里,model.py的后半段往往包含特征融合层(Feature Merging)和输出层,decode.py里实现了连通域分析和NMS。我拆这类项目时习惯先跑通run_inference.py,再回头看训练,因为推理路径短、反馈快,能先确认模型“活着”。

2.2 数据表示:训练标签的四边形与score map

EAST训练时每个文本实例不是用一个矩形框表示,而是用一个带旋转角度的四边形表示。对应到输入图片,网络要学两个东西:一个是像素级的文本区域概率图(score map),另一个是每个像素到最近文本边界的几何距离(geometry),几何距离由四边距离加上旋转角度组成。

生成训练标签时,常见做法是把标注的四个顶点坐标转换成旋转矩形,再对文本区域做收缩(shrink),这样能避免相邻文本之间产生粘连的检测结果。我在data_generator.py里看到的关键代码是这样:

import numpy as np import cv2 def generate_rbox(quad, shrink_ratio=0.25): """ 根据四个顶点生成旋转矩形的score map填充区域 quad: [[x1,y1],[x2,y2],[x3,y3],[x4,y4]] """ # 先算原始多边形的面积和周长 area = cv2.contourArea(quad) perimeter = cv2.arcLength(quad, True) # 计算向内收缩的距离 shrink_dist = int(area * (1 - shrink_ratio * shrink_ratio) / perimeter) # 用approxPolyDP做多边形缩放,实现文本区域收缩 shrunk = cv2.approxPolyDP(quad, shrink_dist, True) return shrunk

注意这里有个参数容易被忽略:shrink_ratio。数值越小,收缩越少;数值越大,文本区域的边缘离真实边界越远。我一般会保留论文推荐的0.25,但训练数据里文字密集时,会把收缩调到0.4,以减少相近文本框互相干扰。cv2.approxPolyDP在这里没在干“近似多边形”的常规活,而是利用收缩距离对多边形顶点做简化,达到整体向内收缩的效果。

对于每个收缩后的多边形,再取最小外接旋转矩形cv2.minAreaRect,得到中心点、宽高和角度,一起写入geometry张量。这就是EAST里RBOX表示法的来源。

2.3 技术选型:为什么是VGG16骨干加特征融合

EAST的网络主体不是自己发明的结构,而是迁移了PVANet的思路:用VGG16的前几个卷积阶段提取多尺度特征,再通过自顶向下的特征融合逐层合并。这样做的直接好处是,浅层特征保留了小文本的细节,深层特征包含了语义信息,融合后能同时照顾到大招牌和小发票字。

项目源码里通常会在model.py里加载在ImageNet上预训练的VGG16权重,然后截掉全连接层,只保留卷积层。后面接的特征融合结构是一个简单的“反卷积加拼接”的循环:

# 特征融合层 def feature_merge(self, feature_list): # feature_list 是VGG16不同stage的输出 f = feature_list[-1] # 最深的特征,例如 1x1 通道数 512 for feature in reversed(feature_list[:-1]): # 先反卷积放大两倍 f = tf.keras.layers.Conv2DTranspose(128, (2, 2), strides=2, padding='same')(f) # 再按通道拼接浅层特征 f = tf.keras.layers.concatenate([f, feature], axis=-1) # 加一个1x1卷积和3x3卷积降低通道数 f = tf.keras.layers.Conv2D(128, 1, padding='same')(f) f = tf.keras.layers.Conv2D(128, 3, padding='same')(f) return f

这段代码看起来简单,但决定了整个模型的感受野。如果你后续要改模型结构,改的就是这一层:把反卷积换成上采样,或者把3x3卷积换成可变形卷积,都是在这里动手。模型最后的输出层有四个分支:score map、rbox的四个距离通道和角度通道。在源码里,这几个输出通常被合并成一个多输出的Tensor,后面接不同损失函数。

纯从“工程上够不够用”的角度看,VGG16已经算是偏重的骨架,但它的优点是权重好找、训练稳定。想提速的话可以换成MobileNetV3做骨干,只是精度会有一点损失,这个话题留给最后一章二次开发讲。

3. 核心模块拆解:一个请求从进入模型到文本框返回的完整链路

这一章我们把“黑匣子”打开。假设你有一张图片,现在要从run_inference.py进去,看它经过哪些环节最终返回几个带角度的坐标框。

3.1 推理入口:图像预处理把输入图片转成网络需要的张量

EAST对输入尺寸没有硬性要求,但训练时通常固定为[256, 256]、[384, 384]或者[512, 512]。推理时常见做法是保持原图宽高比,先把短边缩放到目标尺寸,然后做padding补成正方形,避免文字被拉伸变形。

def preprocess(image, target_size=384): """ 将输入图片等比缩放并补边到 target_size x target_size image: BGR格式,来自cv2.imread """ h, w = image.shape[:2] # 统一转成 RGB,避免通道顺序对结果造成玄学影响 image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) # 等比缩放 scale = target_size / max(h, w) new_h, new_w = int(h * scale), int(w * scale) resized = cv2.resize(image, (new_w, new_h)) # 补边到正方形 canvas = np.zeros((target_size, target_size, 3), dtype=np.float32) canvas[:new_h, :new_w, :] = resized # 归一化到 [0, 1] canvas = canvas / 255.0 # 扩展成 batch 维度,shape: (1, target_size, target_size, 3) return np.expand_dims(canvas, axis=0), scale

这段代码有三个值得注意的细节。第一,补边用的canvas一般用0填充,也就是黑色区域,这会让边界处没有文字但有一定响应;实际操作中如果不想要边界影响,可以用整个图片的均值像素填充。第二,归一化除以255之后,网络内部一般不会再减均值,很多复现版本会把归一化写在网络第一层里;如果你在预处理里做了,就不要再用tf.keras.applications.vgg16.preprocess_input,否则相当于做了两遍。第三,返回的scale是原图到输入图的缩放比例,后面恢复坐标要原样乘回去。

3.2 前向推理:拿到score map和geometry输出

把预处理后的张量喂给模型,EAST的输出分两部分:一是score map,形状是(1, H, W, 1),每个像素的值表示该位置属于文本区域的概率;二是geometry,形状是(1, H, W, 5),前四通道分别是该像素到文本区域上、右、下、左四条边的距离,第五通道是文本行的旋转角度。

def inference(model, image_tensor): # 模型返回两个输出:score_map 和 geometry score_map, geometry = model.predict(image_tensor) # 去掉 batch 维度,只保留单张图的结果 score_map = score_map[0, :, :, 0] geometry = geometry[0, :, :, :] return score_map, geometry

这里有一个容易误解的地方:网络输出的角度是弧度,不是角度制,范围约在[-π/4, π/4]。实际调试时,我们发现很多“边框转出来是歪的”不是模型问题,而是直接用np.degrees转角度后画框,却忘了后续构造旋转矩形的逻辑需要弧度值。这一层是纯计算,没太多参数可调,但输出之后一定要把score_map可视化保存一眼,如果一片黑,说明模型权重没有正确加载,后面再怎么调整后处理都白搭。

3.3 后处理:从概率图到旋转矩形框

后处理是EAST最核心的工程部分,论文里称为“Locality-Aware NMS”的前置解码。网络输出的score map是一个逐像素的概率图,不能直接用,需要先用阈值把它变成二值图,再找连通域。

def decode_boxes(score_map, geometry, score_thresh=0.8): """ 从 score_map 和 geometry 解码出旋转矩形框 """ # 1. 二值化 binary = (score_map > score_thresh).astype(np.uint8) # 2. 找连通域 num_labels, labels = cv2.connectedComponents(binary) boxes = [] for i in range(1, num_labels): # 3. 取当前连通域内的全部像素坐标 y, x = np.where(labels == i) if len(x) < 10: continue # 4. 对几何图按连通域取平均,得到稳定的四边距离和角度 d_top = np.mean(geometry[y, x, 0]) d_right = np.mean(geometry[y, x, 1]) d_bottom = np.mean(geometry[y, x, 2]) d_left = np.mean(geometry[y, x, 3]) angle = np.mean(geometry[y, x, 4]) # 5. 根据中心点和四边距离构造旋转矩形的四个顶点 cx = np.mean(x) cy = np.mean(y) boxes.append((cx, cy, d_left, d_right, d_top, d_bottom, angle)) return boxes

这段代码是“像素级检测”和“文本框级输出”之间的桥梁。第3步过滤少于10个像素的连通域,是为了去掉背景噪点;如果图片上有很多零散误检,可以把这个数字往上调。第4步取平均是一种工程上的近似处理,论文里推荐的做法是在连通域内部做回归,但平均法在大多数场景下都够稳。

第5步要注意:cx和cy是输入图像坐标系里的坐标,此时还没有还原到原图尺寸。如果你直接拿这些坐标画原图,框会画在小图上,位置对不上。必须用前面算的scale把每个坐标和距离都除以scale,才能映射回原图。

真实项目中,后处理往往还会对连通域做一次cv2.minAreaRect拟合,用来替代直接平均的偏转角度,这样对长文本行的角度更鲁棒。这也是很多人调“框总是不够贴边”时最先改的地方。

3.4 文本行合并与NMS:Locality-Aware NMS的落地写法

文本检测的难点不只是“找文字”,还有“找完文字后去重”。EAST原版论文提出了Locality-Aware NMS,实现思路是:先把方向大致相同的框按位置排序合并,再做标准NMS。因为在EAST输出里,如果直接对所有候选框做NMS,会对斜着的长文本框产生大量重复抑制。

def locality_aware_nms(boxes, iou_threshold=0.3): """ 简化版 Locality-Aware NMS: 先按角度分桶,再对每个桶内的框做加权合并 boxes: 每个元素是 (cx, cy, w, h, angle) """ if not boxes: return [] # 按角度分桶,每10度一个桶 buckets = {} for idx, b in enumerate(boxes): angle = int(np.degrees(b[4]) // 10) buckets.setdefault(angle, []).append(b) merged = [] for bucket in buckets.values(): # 对桶内框做加权平均,权重取score if not bucket: continue bucket = np.array(bucket) merged.append(bucket.mean(axis=0)) # 最后再做一次标准NMS keep = [] for b in merged: if not keep: keep.append(b) continue # 简单检查是否与已有框重叠 iou = compute_iou(b[:4], keep[-1][:4]) if iou < iou_threshold: keep.append(b) return keep

这段代码做了一次很取巧的工程简化:先把角度接近的框合并成一个大框,再按顺序做IoU判断。因为EAST输出的框本身已经很接近真实文本行,同一个文本行上的像素会被连通域合并成一个框,所以实际候选框数量不多,标准NMS足以应付。

compute_iou需要处理旋转矩形,如果你用普通矩形的IoU计算会漏掉很多重合区域。常规做法是把旋转矩形的四个顶点转成多边形,再用cv2.intersectConvexConvex求交面积。源码里如果没有这个函数,二次开发时要补上,否则检测长文本时会明显感觉“框到一半就没了”。

4. 跑通训练与推理:常见问题排查与避坑记录

推理能跑通是第一步,真正要落地还得自己训练。EAST训练流程不算复杂,但很多开发者第一次跑时会翻车,这一章我把从数据准备到训练参数的路走一遍,再把踩过的坑集中列出。

4.1 训练数据准备:从ICDAR格式转成EAST标签

网上下载的文本检测数据大多是ICDAR风格标注:每行表示一个文本行,四个顶点坐标用逗号分隔。EAST训练不能直接用这个格式,需要先转成score map和geometry。

常见的数据生成流程是把原图缩放到训练尺寸,同时标注也按同样比例缩放,然后在data_generator.py里调用生成RBOX的函数。下面是一段数据加载的伪代码:

def load_annotation(annotation_path): """ 解析 ICDAR 格式标注 每行格式: x1,y1,x2,y2,x3,y3,x4,y4,text """ quads = [] with open(annotation_path, 'r', encoding='utf-8') as f: for line in f: parts = line.strip().split(',') if len(parts) < 8: continue pts = np.array(parts[:8], dtype=np.float32).reshape(4, 2) quads.append(pts) return quads

这里要特别提醒:很多标注文件里文本内容可能包含逗号,比如“Hello,world”。如果按,直接split,会把后面的文本内容也切碎。常见做法是先解析前8个数字,然后把剩余部分当成文本内容,不要用整行做split。这是训练数据预处理里最隐蔽的bug之一。

生成标签时,每个文本多边形都要独立生成一个RBOX通道区域,多个文本重叠时,score map取最大值,geometry只对当前文本区域覆盖。如果两个文本挨得很近,收缩可以避免它们重叠。

4.2 训练脚本和关键超参数:让训练不翻车

训练入口train.py一般负责加载预训练VGG16权重、配置优化器和损失函数。EAST的损失由三部分组成:score map用类平衡交叉熵(因为背景像素远多于文本像素),geometry用IoU损失,角度用余弦损失。常见实现里,总损失是把这三项加权相加。

参数推荐值说明
input_size384越大精度越高,显存占用也越高
batch_size8显存不足时先降到4
learning_rate1e-4用Adam优化器时建议从1e-4起步
max_epochs200模型在100轮后开始收敛明显
lambda_geo1.0geometry损失的权重
lambda_angle10.0角度损失权重,调大能让框更贴边
score_thresh0.8推理时的score阈值
nms_thresh0.3NMS的IoU阈值

我在实际训练中习惯把lambda_angle拉高到10,因为这个值在原版论文里是10,但很多复现版本里被错写成1。角度损失权重不够时,训练出来的框“转得不够”,倾斜长文本行的检测框会明显往里收,看起来像是框小了,其实是角度没学准。

训练时如果你发现loss下降很慢,把输入尺寸从256调到384,大多数情况下都能改善。代价是训练时间大约变成原来的2倍,显存占用升高,但文本检测精度提升更明显。

4.3 具体踩坑记录:现象、原因、解决

这一节是根据我自己在这类项目上的血泪经验整理出来的,每条都是真实能复现的坑。

现象一:训练过程中loss突然变成NaN

原因通常有两个:一个是学习率太高,导致梯度爆炸;另一个是geometry里出现距离为0的情况,导致IoU损失分母为0。最开始我以为是网络结构写错了,排查了三天,最后定位到是数据生成时文本区域收缩过度,某些短文本被收缩成空区域,标签里出现空白mask。

解决方法是给IoU损失的分母加一个极小值1e-6,同时把初始学习率降到1e-4。对于数据端,在生成标签时检查收缩后的多边形面积,如果小于5个像素,直接丢弃该样本。

现象二:推理时一个框都检测不到

这个问题出现在第一次跑通模型时,score map输出全黑,或者全是低置信度值。原因很可能不是模型坏了,而是推理图片被放到了完全不匹配的尺寸。有些实现训练用的是[256, 256],推理时如果你传一张[512, 512]的照片,网络未必能泛化到这么大的尺度。

另一个常见原因是score阈值设得过高。原作者给的阈值是0.8,但实际模型输出概率偏低时,0.5是更稳妥的起点。我建议先用0.3跑一张图,确认能看到大量候选框,再逐步抬高阈值。

现象三:检测框能框住文本,但位置整体偏移

这种“差点意思”的表现最常见原因是后处理坐标没有按缩放比例还原。前面预处理把原图缩放并补边,后处理里如果只对cx和cy乘了scale,却忘了把四边距离d_left等也乘上scale,就会出现框中心对、大小不对的诡异效果。

解决方法是把所有几何量放在同一个坐标空间里处理:先在网络输出坐标系里构造完整的旋转矩形顶点,再用一个统一的矩阵变换映射回原图坐标。

现象四:长文本行被截成好几段

EAST的score map对单个文本行应该是一个完整的连通域,但实际训练不足时,行内可能有断裂。常见原因是训练标签的收缩比例不对,文本行中心区域过窄,网络学到的是“只有文字中心是正样本”。增加训练轮数、调小收缩比例都能改善。

如果不想重新训练,可以在后处理里做一个合并步骤:检查相邻两个旋转框的距离,如果中心点距离小于某个阈值,把两个框合并成一个大的旋转矩形。这个操作在工程里叫“文本线合并”,很多OCR项目都会加这一层。

现象五:正弦波动的角度导致NMS把好框误删

EAST输出的角度范围有限,但当文本本身接近水平时,模型的输出角度会在0附近抖动。NMS里如果直接按IoU比较,两个抖动角度不同的框可能只因为旋转角差异大,就变成“看起来不重叠”,导致同一个文本行留下两个框。

解决方式是在计算IoU之前先把候选框按角度排序,角度相邻的框先合并,再做IoU过滤。这也是Locality-Aware NMS的核心动机之一,不要为了省事跳过它。

5. 二次开发:把EAST输出接到自己的OCR流水线里

检测模型一般只负责“找到文字”,真正做识别还需要把检测到的旋转矩形区域送给OCR识别引擎。从EAST拿到的是带角度的四边形顶点,不能直接cv2.crop,因为你想要的是一个正立的长方形文本块。这里的关键一步是用仿射变换把旋转矩形“掰正”。

def crop_rotated_box(image, quad): """ 根据四个顶点裁剪出正立的文本区域 quad: [[x1,y1],[x2,y2],[x3,y3],[x4,y4]] """ # 计算四条边的长度,确定宽高 width = max(np.linalg.norm(quad[0] - quad[1]), np.linalg.norm(quad[2] - quad[3])) height = max(np.linalg.norm(quad[1] - quad[2]), np.linalg.norm(quad[3] - quad[0])) # 目标矩形的四个顶点,长边在上 dst = np.array([[0, 0], [width - 1, 0], [width - 1, height - 1], [0, height - 1]], dtype=np.float32) matrix = cv2.getPerspectiveTransform(quad.astype(np.float32), dst) crop = cv2.warpPerspective(image, matrix, (int(width), int(height))) return crop

这段代码有三个工程细节。第一,宽高的计算用到了四个顶点的距离,而不是简单取横纵坐标差值,因为旋转后横纵坐标差值和真实长度并不一致。第二,目标矩形的顶点顺序必须和源四边形顶点顺序一致,否则会把文本块倒过来;我见过很多次“识别全乱”都是顶点顺序没统一。第三,调用warpPerspective比getAffineTransform更稳,因为四边形本身可能不是严格的矩形,透视变换能矫正更多形变。

拿这份资源做完改造后,我的个人习惯是:每次把检测器接入新业务时,先把中间结果可视化保存出来,观察旋转框是否正确贴合文本,再进行下一步识别。这样做看起来像是多了一道无用功,但好过在识别结果里莫名其妙地乱猜。从那以后,我每次都会强制跑一遍“检测框可视化再进识别服务”的流程,宁可多花一分钟看图,也不愿意上线后花一小时看错的识别日志。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

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