简介:面向OCR与计算机视觉开发者的AdvancedEAST自然场景文本检测资源包,适合需要复现EAST算法、训练或调试文本检测模型的研究人员及工程师。压缩包约461.7MB,文件条目显示为2000,实际内含2000个npy特征数据、4019张jpg检测样本图、千余个txt标注文件,以及Python源码、h5模型权重、xml与png等辅助内容;其中h5权重覆盖3t256与3T736两种输入尺寸,配合py脚本可完成训练与推理流程。资源已跑通,测试图片、特征数据、标注信息按目录组织,便于对照博客说明进行实验复现、精度调试与二次开发。已有350余人浏览学习,适合希望绕开环境配置与数据准备环节、直接上手EAST文本检测项目的开发者。
1. EAST文本检测:解压后第一个下午,我把它跑通了
三个月前有个票据识别的需求,文件里密密麻麻印着手写体数字,常规检测模型框得歪歪扭扭。我翻出一个吃灰的文本检测工程包,名字就是“旷世east文本检测.zip”。EAST全称Efficient and Accurate Scene Text Detector,是经典的全卷积文本检测方案,不靠候选框而是直接回归文本框坐标和旋转角度,当年在ICDAR 2015上刷新了精度纪录。这个资源包适合正在做OCR落地的开发者、毕设里需要场景文字检测的在校生,以及想给文档自动化加一层“定位能力”的从业者。拆包后不用重头造轮子,改改路径就能跑出框来。这篇就按我实际拆包的顺序写:模型结构怎么理解、推理脚本怎么改参数、检测框如何接到下游识别、还有我从踩坑里总结出的几个典型问题。
2. 拆开EAST的推理链路:四通道输出和两个关键阈值
2.1 模型到底输出了什么——score_map与geo_map
EAST的网络骨干一般是VGG16的前几层,后续接特征金字塔式的合并分支,最终输出两个张量:一个是score_map,尺寸是输入图H/4、W/4,每个像素点表示该位置属于文本区域的概率;另一个是geo_map,在我看到的这个包里的RBOX版本是H/4、W/4、5通道,前四个值分别代表像素到文本框上、右、下、左边界的像素距离,第五个值表示文本框相对水平轴的角度。
为什么会有这些通道?因为EAST不做“先框候选、再分类”的两阶段流程,它让模型直接学习每个像素到其所属文本框四条边的距离,再由后处理把逐像素的距离信息拼回完整文本框。score_threshold和nms这几个词在包里的配置文件中高频出现,前者决定哪些像素算“文本区域”,后者决定相邻的框会不会被合并成同一个,两个阈值配合不好,结果就是要么满屏碎片框,要么一条框吞掉两三行文字。
2.2 先把demo跑起来:pb模型加载与预处理
解压这个包后,通常能看到三个东西:一个.pb或.ckpt模型文件、一段推理脚本、一个测试图片文件夹。我习惯先把模型文件单独拿出来,写个最短的加载脚本验证环境,而不是直接跑包的demo,这样能把“模型加载失败”和“后处理写错”两类问题分开。
import cv2 import numpy as np import tensorflow.compat.v1 as tf tf.disable_v2_behavior() def load_frozen_graph(pb_path): graph = tf.Graph() with graph.as_default(): graph_def = tf.GraphDef() with open(pb_path, "rb") as f: graph_def.ParseFromString(f.read()) tf.import_graph_def(graph_def, name="") return graph pb_path = "./model/east_model.pb" graph = load_frozen_graph(pb_path) sess = tf.Session(graph=graph) input_tensor = graph.get_tensor_by_name("input_images:0") score_tensor = graph.get_tensor_by_name("score:0") geo_tensor = graph.get_tensor_by_name("geometry:0") img = cv2.imread("./test/001.jpg") h, w = img.shape[:2] target = 1280 scale = target / max(h, w) new_w = int(w * scale // 32 * 32) new_h = int(h * scale // 32 * 32) resized = cv2.resize(img, (new_w, new_h)) input_arr = (resized / 127.5 - 1.0).astype(np.float32) input_arr = np.expand_dims(input_arr, axis=0) score, geo = sess.run([score_tensor, geo_tensor], feed_dict={input_tensor: input_arr}) score = score[0, :, :, 0] geo = geo[0] print("score map shape:", score.shape) print("geo map shape:", geo.shape)这段代码最容易被忽略的是两个地方:一是模型输入张量的命名。原repo和各类衍生包里命名大概率是input_images:0、score:0、geometry:0,但我在某公司的改进版本里见过input_image:0这样的单数命名,加载前先用tf.io.gfile或者graph.get_operations()把节点名打出来看一眼最稳。二是预处理归一化,EAST常见做法是像素值除以127.5再减1,映射到-1到1区间;如果用ImageNet的mean/std方式,输出score会整体偏低。另外宽高强转子32的整倍数,是因为网络里有多层步长为2的下采样,输入尺寸不是32的倍数时,输出张量尺寸和几何通道的对应对不上,推理不报错但结果会错位。
2.3 逐像素转旋转框:后处理的实现与参数说明
模型输出的score_map和geo_map都是原图的1/4,需要把尺寸放大回去,同时把每个像素点还原成一个带角度的矩形框。EAST的原始做法是对每个置信度足够高的像素都构建一个RBOX,之后用Locality-Aware NMS合并相邻框。我习惯写一个简化但效果稳定的版本:
def pixel_to_rbox(score_map, geo_map, score_threshold=0.8): proposals = [] h, w = score_map.shape for y in range(h): for x in range(w): if score_map[y, x] < score_threshold: continue top = geo_map[y, x, 0] right = geo_map[y, x, 1] bottom = geo_map[y, x, 2] left = geo_map[y, x, 3] angle = geo_map[y, x, 4] / np.pi * 180 center_x = x * 4 + ((right - left) / 2) center_y = y * 4 + ((bottom - top) / 2) box_w = right + left box_h = bottom + top proposals.append({ "score": float(score_map[y, x]), "cx": center_x, "cy": center_y, "w": box_w * 4, "h": box_h * 4, "angle": angle }) return proposals参数解析:score_threshold在0.7到0.9之间,票据类图像我用0.75,因为手写体边缘score不高但中心区域稳定;自然街景图可以上调到0.85。center_x之所以是x * 4 + ((right - left) / 2),是因为输出尺寸是原图的1/4,先把像素坐标映射回原图的中心,再加上几何通道给出的中心偏移。box_w我乘了4,把1/4尺度下的距离还原回原图尺度,这是包内代码容易漏的点。
拿到这些带角度的矩形后不要直接画上去,还需要一步NMS。旋转框的NMS比水平框麻烦,最简单的方案是把每个RBOX转成四边形,然后计算交并比:
def rotated_iou(prop_a, prop_b): from shapely.geometry import Polygon box_a = cv2.boxPoints(((prop_a["cx"], prop_a["cy"]), (prop_a["w"], prop_a["h"]), prop_a["angle"])) box_b = cv2.boxPoints(((prop_b["cx"], prop_b["cy"]), (prop_b["w"], prop_b["h"]), prop_b["angle"])) poly_a = Polygon(box_a) poly_b = Polygon(box_b) inter = poly_a.intersection(poly_b).area union = poly_a.area + poly_b.area - inter return inter / union if union > 0 else 0NMS的IOU阈值一般取0.2到0.5。EAST原始论文里的Locality-Aware NMS实际上做了加权合并,而我们这里用的是标准贪心抑制:按score从高到低排序,保留当前最高分框,把与它IOU超过阈值的全部丢弃,重复到没有剩余框为止。阈值低合并且少,阈值高框更碎片化。这个包的后处理脚本如果跑出来框很多且重叠严重,可以怀疑IOU阈值被设成了默认0.7这类偏大值,改成0.3再说。
3. 把检测框接进OCR流水线:从四边形到一行文字的完整工程化
3.1 检测框的二次校正与裁剪
很多开发者跑通了EAST的框,也把框画出来了,但一接到识别模型上就发现识别率崩掉。原因不外乎两个:框带着小角度倾斜,直接水平裁剪会切到上下行文字;或者框的四条边并不完全贴合文字基线。EAST输出的框已经是旋转框,但角度是相对水平轴的,对于接近水平的文本问题不大,对于倾斜45度以上的文本,必须做一次旋转校正再送识别。
我一般会对每个检测框做这一步校正:
def crop_text_line(img, quad, pad=8): pts = np.array(quad, dtype=np.float32) rect = cv2.minAreaRect(pts) (cx, cy), (w, h), angle = rect if w < h: angle -= 90 w, h = h, w matrix = cv2.getRotationMatrix2D((cx, cy), angle, 1.0) rotated = cv2.warpAffine(img, matrix, (img.shape[1], img.shape[0]), flags=cv2.INTER_CUBIC) x = int(cx - w / 2) - pad y = int(cy - h / 2) - pad x2 = int(cx + w / 2) + pad y2 = int(cy + h / 2) + pad x = max(0, x); y = max(0, y) x2 = min(img.shape[1], x2); y2 = min(img.shape[0], y2) return rotated[y:y2, x:x2]这段代码里angle -= 90的判断很关键。cv2.minAreaRect在w < h时返回的角度语义和EAST输出的角度可能相差90度,如果不做交换,裁出来的图会是竖着的。pad=8的留白是为了让识别模型看到文字边缘一点点背景,避免字符贴边被截断。
3.2 坐标逆映射:必须保留预处理时的缩放比
EAST推理时图像经过一次resize,网络输出的坐标是基于缩放后图像的。很多翻车现场就是这里出的:后处理结果画在resize后的图上完全正确,一画到原图上就整体偏移。原因是把resize后的坐标直接当作原图坐标用了。
正确的做法是在预处理时把缩放系数记下来,后处理所有框都除以这个系数:
def map_boxes_to_original(quads, scale_x, scale_y): mapped = [] for quad in quads: mapped_quad = [] for (px, py) in quad: orig_x = int(px / scale_x) orig_y = int(py / scale_y) mapped_quad.append((orig_x, orig_y)) mapped.append(mapped_quad) return mapped这里的scale_x = new_w / original_w,scale_y = new_h / original_h。注意整个流水线里只要ratio在任意一步被重新计算过,就要立即确认它是“原图到网络输入”的比值,而不是反过来。我习惯把scale和原图尺寸放在同一个dict里,跟着整个流程走,这样排查问题时不用回头猜。类似的问题还出现在归一化时是否带astype(np.float32)上,uint8数组减去127.5会直接溢出成uint8,score_map会变成全黑或全白,这类bug不报错但极难发现。
3.3 工程化处理长文本与倾斜文本的几个操作习惯
长文本是EAST落地最容易翻车的地方。输入图被缩到1280边长后,一行五六十个字在score_map里可能只剩十几个像素宽,检测框时断时续。解决办法通常有两个方向:一是把target从1280提到1920甚至2560,代价是显存和推理时间上升;二是做分块检测,把长图按高度切成上下两块重叠区域,各自检测后根据坐标偏移合并结果。
重叠分块合并时要注意一个规则:两块区域内的检测框如果IOU超过0.5,保留score高或者面积大的一方,否则两个都保留。直接按像素坐标拼接而不处理重叠框,会在拼接处出现双框。倾斜文本方面,建议把检测输出统一转成四个点而不是矩形角度,这样下游不论接CRNN还是其他识别模型,都只需要处理多边形裁切逻辑,不需要理解旋转角度语义。
我在接OCR链路时还有一个习惯:检测框画到原图上之后,先人工扫一遍“误检区”。EAST对纹理强的背景很容易给出高score,比如印刷品上的花纹、票据的底纹、金属表面反光。针对这类场景,我会在脚本里加一条过滤规则:框宽高比小于某个值的丢弃,因为正常文本行宽高比基本都大于1.5;框面积小于原图面积万分之一的丢弃。这两条规则不能解决所有问题,但能把误检框数量压缩掉六成以上。
4. 排查遇到的五个常见坑:从加载失败到框偏移、文本断裂
这个包的推理脚本看似简单,实际跑起来总会遇到几个固定问题。以下问题按我踩坑的真实顺序排列,每条都给出现象、原因和解决,直接对号入座即可。
问题1:加载pb模型时提示“Op type not registered”或直接进程崩溃。现象:运行import_graph_def时报Op type not registered '...',或者TensorFlow直接CRASH。大部分原因是模型是用旧版TensorFlow或GPU自定义op导出的,而当前环境版本过新或缺少对应的CUDA库。解决:把环境的TensorFlow固定在模型导出时的版本区间,一般图里只要没有自定义op,用1.14到2.4的兼容v1接口都能跑;如果模型是某公司魔改的版本,看包里有没有附带的.so算子库,有的话必须在导入图之前先tf.load_op_library加载它。
问题2:框全部漂浮在文字上方或下方,位置整齐错位半行。现象:score高、框大小也对,但框和文字不贴合,整体平移了半个字高。原因:geo_map四个通道的边界距离含义在预处理时被理解错了。EAST原始定义里,四个距离分别对应像素到上、右、下、左边界,但有的训练脚本输出顺序是左、下、右、上,也就是没按论文来。解决:先把某个像素位置的四个距离打印出来,对照文字中心到四条边的真实像素间距,确认顺序后再继续。这个问题在视觉上不容易排查,因为方向顺序反了之后框依然是方的,只是位置整体偏移。
问题3:检测框角度完全相反,横排文字画成竖框。现象:裁切后的图片被旋转了90度或180度,文字竖起来了。原因有两个层面:一是geo_map的角度是弧度制,后处理里直接输给cv2.minAreaRect用,cv2默认角度语义是角度制且范围在-90到0,两者的坐标系方向可能差90度;二是某些衍生包的模型用顺时针角度训练,原始论文用逆时针角度,导致预测角度和实际相差正负90x。解决:把geo_map[..., 4]乘以180/pi之后,先画一个简单框测试,如果不匹配就加或减90度校准一次。
问题4:长文本被检测成两段甚至三段,中间断开。现象:一行文字框出来是断开的,比如“身份证号”被拆成“身份证”和“号”两个框。原因:图像resize到较小尺度后文字间距在score_map上只有1到2个像素,上下文的score连不起来。解决:优先把target从1280调到1920;如果显存不够,保持target不变,在NMS之前增加一步dilate操作,用cv2.dilate(score_map, np.ones((3,3)))把相邻文本像素连通起来,注意dilate的迭代次数不要超过2,否则会把两行文字也黏在一起。这一步对长文本检测极其有效。
问题5:训练或微调时loss不降,检测框全图乱飞。现象:使用包内训练脚本跑自己的数据,几轮过后score_map一片空白,检测结果全是背景误检。原因:标签生成错误,polygon标注没有正确转成score_map和geo_map。我在检查时发现最常见的原因是多边形向内收缩时使用了固定缩进值而不是按周长比例收缩,导致score_map上的文本区域比实际文字小很多甚至缩没了。解决:先用可视化脚本分别存score_map和geo_map,叠加到原图上肉眼检查,再开始训练。这一步能省掉至少三天的盲目调参时间。
排查以上问题时的通用想法是:先怀疑文件路径,再怀疑通道顺序,最后怀疑张量形状,尽量把模型输出拆成小段可视化验证。很多所谓“模型效果差”的问题最终都是预处理和后处理步骤写错了,而不是算法本身不够好。
5. 进阶用法:用自己的标注数据微调EAST并冻结导出模型
5.1 把polygon标注转成score/geo标签
跑通推理只是开始,实际落地时几乎都要用业务数据再微调模型。微调的第一步,是把标注的四边形转成模型训练标签。EAST的标签生成分两步:score_map是原始标注框向内收缩约0.3倍后的区域,geo_map计算每个像素到收缩后框四条边的距离以及旋转角度。
def shrink_polygon(poly, shrink_ratio=0.3): poly = np.array(poly, dtype=np.float32) area = cv2.contourArea(poly) perimeter = cv2.arcLength(poly, True) offset = shrink_ratio * perimeter / 2 offset = min(offset, area * 0.5) rect = cv2.minAreaRect(poly) center = np.array(rect[0], dtype=np.float32) directions = np.array([ [1, 0], [0, 1], [-1, 0], [0, -1] ], dtype=np.float32) new_poly = [] for p in poly: dist = cv2.pointPolygonTest(poly, tuple(p), True) norm = np.linalg.norm(p - center) if norm < 1e-8: new_poly.append(p) continue new_p = p + offset * (center - p) / norm new_poly.append(new_p) return np.array(new_poly, dtype=np.float32)这里的shrink_ratio=0.3是原始论文里的常用值,适合理想标注;如果自己的数据标注很贴近文字边缘,收缩比可以降到0.2,但score_map会更窄,训练收敛速度变慢。生成位置后,train脚本还要为每个像素算距离字段并填充geo_map,这块逻辑在包内有现成实现,建议直接复用而不是重写,重写时最容易弄错的是角度通道的坐标系方向,务必与模型预测时的解码保持一致。
5.2 训练脚本里必须留意的几个超参与工程细节
微调时的训练参数直接参考原repo的配置即可,输入尺寸512x512,batch size 8,初始学习率1e-4,训练轮数根据数据量,一般5万张以内跑300个epoch能稳定。重点留意三个工程细节。
第一个是数据增强不要乱加颜色变换,EAST对文本的本质判断依赖边缘纹理,cv2.cvtColor转灰度再转回或者加入强对比度变换都会让score震荡。只保留随机旋转、随机缩放、随机裁剪即可。第二个是class平衡问题,自然图像里背景像素远多于文本像素,纯二值交叉熵会让模型学会全部预测背景。用原论文的dice loss分支处理score_map,geo损失继续用IOU loss加角度loss,两项的权重比建议保持1:1。第三个是训练时shuffle的粒度,不要按图片打乱,要按文本行的实例打乱,否则同一张图反复出现会让模型把这张图背下来,验证集表现虚高。
5.3 验证流程与pb导出检查清单
训练完成后冻结成pb是一个高频操作,用一个稳定的导出模板能少踩很多坑:
python freeze_graph.py \ --input_graph ./checkpoint/graph.pbtxt \ --input_checkpoint ./checkpoint/east_model.ckpt \ --output_node_names "score,geometry" \ --output_graph ./model/east_frozen.pb导出时务必确认两个输出节点名和推理脚本里get_tensor_by_name的名字一致,这个包最常见的反馈就是导出时多写了一个conv_out节点,导致推理脚本找不到geometry。冻结完第一时间做精度对比:导出的pb在测试集上的检测框应该和训练时保存的checkpoint输出完全一致,不一致说明某些op没有被正确冻结,查是否有训练专用的op混进了推理图。
验证流程我有一套固定的顺序:先可视化score_map确认文本区域亮斑,再可视化geo_map的四个距离通道,看边缘是否平滑连续,最后在原图上画预测框,手动和标注对比。三步检查都通过,模型才算真正进入可用状态。最后把检测框按3.1节的校正逻辑接到识别模型,跑通全链路后记录一张“不同threshold配准表”:什么样的图像清晰度配什么样的score阈值,什么样的版面复杂度配什么样的IOU阈值。这个习惯帮我解决了很多未知数据的首版测试问题。从那以后我每次换数据集,都强制走一遍“score阈值扫描→IOU阈值扫描→框裁剪验证”的流程,半小时内就能确定最优参数,希望这篇拆解记录也能帮你在自己的项目上少走这几趟弯路。
本文还有配套的精品资源,点击获取