最近我把一个和日志/视频分析相关的 OCR 训练流程单独整理成了一个 workspace。这个项目的目标很明确:从视频画面里自动截取时间戳区域,生成可训练的数据集,然后训练一个只识别时间戳格式的小型 OCR 模型,最后导出 ONNX,方便后续在其他工程里直接推理。

相比通用 OCR,这类任务的特点是字符集非常小、格式非常固定、目标区域也比较稳定。所以与其直接依赖一个大而全的 OCR 模型,不如把问题收敛成“固定时间戳识别”:先用图像规则把 ROI 找出来,再用轻量 CRNN 做序列识别。

项目结构

当前目录大致如下:

ocr/
├── README.md
├── requirements-training.txt
├── setup_training_env.sh
├── yolov8n.pt
└── pythonai/
    ├── orc.py
    ├── train_crnn.py
    ├── export_crnn_onnx.py
    ├── fix_labels.py
    ├── timestamp_crnn_best.onnx
    ├── debug_ocr/
    ├── runs/
    ├── runs_second/
    └── runs_time/

几个核心文件的职责:

  • orc.py:从视频帧中检测黄色时间戳区域,裁剪 ROI,并生成训练样本和标签。
  • train_crnn.py:训练 CRNN + CTC 时间戳识别模型。
  • export_crnn_onnx.py:把 PyTorch checkpoint 导出成 ONNX。
  • fix_labels.py:根据文件名里的毫秒时间修正 labels.csv
  • requirements-training.txt:训练和数据采集依赖。

整体流程可以概括成:

输入视频

逐帧读取

OpenCV 检测黄色时间戳区域

PaddleOCR 校验 ROI

保存 ROI 图片

生成 labels.csv

训练 CRNN + CTC

导出 ONNX

运行时推理

为什么不直接用通用 OCR

这个场景识别的是类似下面这样的时间戳:

2026-05-07 16:46:59.170

训练时会归一化成:

05-07 16:46:59

也就是说,模型最终只需要识别:

ALPHABET = "0123456789-: "

字符集很小,输出格式固定,识别目标也总是视频画面中的时间戳。这种情况下,通用 OCR 的优势反而没有那么明显。专用模型的好处是:

  • 训练目标更单一,模型更容易收敛;
  • 字符集小,CTC 解码更稳定;
  • 输入尺寸固定,部署链路更简单;
  • 可以导出 ONNX,方便在桌面端、服务端或移动端集成。

数据采集:从视频中自动裁剪时间戳 ROI

orc.py 的核心思路是:先用 OpenCV 根据颜色和形态学特征找到疑似时间戳区域,再用 PaddleOCR 做一次校验,避免把其他黄色 UI 元素误收进数据集。

1. 构建黄色区域 mask

时间戳在视频里是黄色文本,所以第一步通过 HSV 和 BGR 两套规则合并 mask:

def build_yellow_mask(frame):
    hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)

    hsv_mask = cv2.inRange(
        hsv,
        np.array([12, 35, 80]),
        np.array([70, 255, 255])
    )

    b, g, r = cv2.split(frame)

    bgr_mask = (
        (r > 110) &
        (g > 110) &
        (b < 210) &
        ((r.astype(np.int16) + g.astype(np.int16)) > (b.astype(np.int16) * 2))
    ).astype(np.uint8) * 255

    mask = cv2.bitwise_or(hsv_mask, bgr_mask)

后面再通过开运算和横向闭运算,把碎字符连接成更完整的文本区域:

kernel_small = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel_small, iterations=1)

kernel_line = cv2.getStructuringElement(cv2.MORPH_RECT, (21, 3))
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel_line, iterations=2)

2. 根据几何特征筛选候选框

时间戳通常是横向长条,所以候选框过滤主要看宽度、高度、宽高比和面积:

if bw < int(w * 0.18):
    continue

if bh < int(h * 0.025):
    continue

if ratio < 4.5:
    continue

if area < 1000:
    continue

if bh > int(h * 0.20):
    continue

这一步可以快速排除掉图标、按钮、高亮块等非文本目标。

3. 用 PaddleOCR 做二次验证

为了降低误采样,项目里还引入了 PaddleOCR 判断候选 ROI 是否真的像时间戳:

def is_timestamp_text(text):
    text = clean_text(text)

    patterns = [
        r"\d{4}[-.\s]?\d{2}[-.\s]?\d{2}\s+\d{2}[:.\s]?\d{2}[:.\s]?\d{2}(?:[.:]\d{1,3})?",
        r"\d{4}[-.\s]?\d{2}[-.\s]?\d{2}.*?\d{2}[:.\s]?\d{2}[:.\s]?\d{2}",
        r"\d{8}.*?\d{6}",
    ]

这里还做了一些 OCR 常见误识别修正,比如把 O/o/Q/D 归一成 0,把 I/l/|/! 归一成 1,把 _ 归一成 -。这类清洗很实用,因为它不追求最终识别结果,而只是判断“这个区域是不是时间戳”。

4. 保存 ROI 和标签

检测成功后,代码会把 ROI 放大 2 倍,并加白边保存:

roi = cv2.resize(
    roi,
    None,
    fx=2.0,
    fy=2.0,
    interpolation=cv2.INTER_CUBIC
)

roi = cv2.copyMakeBorder(
    roi,
    8,
    8,
    8,
    8,
    cv2.BORDER_CONSTANT,
    value=[255, 255, 255]
)

标签则根据视频帧对应的 time_ms 和一个基准时间生成:

BASE_TIMESTAMP = datetime.strptime(
    "2026-05-07 16:46:37.190",
    "%Y-%m-%d %H:%M:%S.%f"
)

def make_label_from_video_time(time_ms):
    dt = BASE_TIMESTAMP + timedelta(milliseconds=int(time_ms))
    return dt.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]

最终得到的数据集形式是:

roi_dataset/
├── images/
│   ├── xxx_frame_00000001_33ms.png
│   └── xxx_frame_00000002_66ms.png
└── labels.csv

labels.csv

image,label
xxx_frame_00000001_33ms.png,2026-05-07 16:46:37.223
xxx_frame_00000002_66ms.png,2026-05-07 16:46:37.256

模型训练:CRNN + CTC

训练入口是 train_crnn.py。模型输入统一 resize 到:

IMG_H = 64
IMG_W = 512

标签格式会被归一化:

def normalize_label(label: str) -> str:
    label = label.strip()

    if "." in label:
        label = label.split(".")[0]

    if len(label) >= 19 and label[4] == "-":
        label = label[5:]

    return label

也就是:

2026-05-07 16:46:59.170

会变成:

05-07 16:46:59

这样做有两个目的:

  • 年份固定时没有必要让模型学习年份;
  • 毫秒对画面质量要求更高,先去掉可以降低训练难度。

CRNN 结构

模型前半部分是 CNN,用来提取图像特征;后半部分是双向 LSTM,用来建模文本序列:

self.rnn = nn.LSTM(
    input_size=512 * 4,
    hidden_size=256,
    num_layers=2,
    bidirectional=True,
    batch_first=False,
)

self.fc = nn.Linear(512, num_classes)

输出交给 CTC Loss:

criterion = nn.CTCLoss(
    blank=BLANK_INDEX,
    zero_infinity=True
)

CTC 的好处是不用逐字符标注位置,只需要整行文本标签。对于这种固定文本行识别任务,它比检测每个字符框要简单很多。

数据增强

项目里做了几类轻量增强:

if random.random() < 0.2:
    img = cv2.GaussianBlur(img, (3, 3), 0)

if random.random() < 0.2:
    noise = np.random.normal(0, 5, img.shape).astype(np.uint8)
    img = cv2.add(img, noise)

if random.random() < 0.3:
    alpha = random.uniform(0.9, 1.15)
    beta = random.randint(-10, 10)
    img = cv2.convertScaleAbs(img, alpha=alpha, beta=beta)

分别模拟模糊、噪声和亮度变化。视频帧里的时间戳往往会受到压缩、缩放、抖动影响,这类增强能提升模型对真实画面的适应能力。

训练环境和启动方式

依赖文件:

torch==2.9.0
torchvision==0.24.0
tensorboard==2.20.0
opencv-python==4.10.0.84
Pillow==12.2.0
numpy==1.26.4
Flask==3.1.3
paddleocr==2.9.0
paddlepaddle==3.0.0

初始化环境:

cd ~/Ai/ocr
./setup_training_env.sh
source .venv-training/bin/activate

启动数据采集服务:

cd ~Ai/ocr/pythonai
python orc.py

采集视频帧:

curl "http://localhost:5001/collect_video_all_frames?path=/path/to/video.mp4"

查看数据集路径:

curl "http://localhost:5001/dataset_path"

开始训练:

cd ~/Ai/ocr/pythonai
python train_crnn.py

训练过程中会写入 TensorBoard 日志:

tensorboard --logdir runs

导出 ONNX

训练完成后,最佳模型会保存为:

pythonai/timestamp_crnn_best.pt

导出 ONNX:

cd ~/Ai/ocr/pythonai
python export_crnn_onnx.py

脚本会从 checkpoint 中读取:

  • alphabet
  • img_h
  • img_w
  • model.state_dict

然后导出:

pythonai/timestamp_crnn_best.onnx

导出时设置了动态 batch:

dynamic_axes={
    "image": {0: "batch"},
    "logits": {1: "batch"},
}

这对后续服务化推理比较友好,可以一次处理多张 ROI。

这个项目里比较关键的工程点

1. 先做 ROI,再做 OCR

不要一上来就把整张视频帧丢给识别模型。整帧里干扰太多,训练数据也难构造。先把问题拆成:

定位时间戳区域 -> 识别时间戳文本

后面的模型会简单很多。

2. 用通用 OCR 辅助造数据,而不是最终依赖它

PaddleOCR 在这里不是最终方案,而是数据采集阶段的过滤器。它负责帮我们判断候选区域是不是时间戳,最终识别还是交给专用 CRNN。

这个思路很适合小场景模型训练:先用成熟工具提高数据生产效率,再训练一个更贴合业务的轻量模型。

3. 标签格式要主动降难度

原始标签是:

2026-05-07 16:46:59.170

训练标签是:

05-07 16:46:59

去掉年份和毫秒后,模型学习目标更短,误差空间更小。如果后面数据量足够,再恢复毫秒也不迟。

4. 保留调试样本很重要

项目里有 debug_ocr/mask_debug/reject/ 这类目录。它们看起来像中间产物,但对排查问题很关键:

  • mask_debug 可以看颜色阈值是否合适;
  • reject 可以看哪些帧被漏掉;
  • images 可以检查 ROI 裁剪是否稳定;
  • labels.csv 可以检查时间换算是否正确。

OCR 项目里,数据问题通常比模型问题更常见。能把中间结果保存下来,后面调参会轻松很多。

后续可以优化的方向

这个项目已经跑通了从视频采集到 ONNX 导出的闭环,后面还可以继续增强:

  • 增加推理脚本,用 ONNX Runtime 验证导出模型;
  • BASE_TIMESTAMP 改成接口参数,适配不同视频;
  • labels.csv 做去重和一致性校验;
  • 增加编辑距离指标,不只统计 exact match;
  • 将 ROI 检测参数配置化,适配不同颜色和分辨率;
  • 如果时间戳位置固定,可以加入位置先验,减少误检;
  • 为训练集/验证集划分生成固定清单,保证实验可复现。

总结

这个 OCR workspace 的核心价值不是模型结构有多复杂,而是把一个具体业务问题拆成了可落地的工程流程:

视频帧 -> 黄色时间戳 ROI -> 自动标签 -> CRNN 训练 -> ONNX 导出

对于固定格式、固定区域、固定字符集的 OCR 任务,专用小模型往往比通用 OCR 更容易部署,也更容易针对业务数据持续优化。这个项目后续只要继续补齐数据清洗、ONNX 推理验证和参数配置化,就可以成为一个比较完整的时间戳识别训练工具链。

Logo

免费领 150 小时云算力,进群参与显卡、AI PC 幸运抽奖

更多推荐