Appearance
第五章 模块综合实战
课程导航
| 项目 | 内容 |
|---|---|
| 课时 | 3 小时 |
| 类型 | 全流程项目实战 |
| 前置知识 | OpenCV、目标检测、OCR、模型部署(第一章~第四章) |
一、学习目标
- 独立完成一个从数据标注到模型训练再到 ONNX 部署的完整 YOLO 项目
- 掌握证件 OCR 的检测 → 识别 → 结构化输出全流程
- 培养解决实际工程问题的能力(数据清洗、调参、错误分析)
- 形成可复用的 CV 项目模板
二、实战项目一:工业缺陷检测 YOLO 系统
2.1 项目概述
场景:在流水线上实时检测产品表面缺陷(划痕、凹坑、裂纹、脏污等)。
技术栈:YOLOv8 + Labelme/LLabelImg + ONNX + FastAPI
交付物:
- 标注好的数据集
- 训练好的 YOLOv8 模型
- ONNX 部署模型
- FastAPI 推理服务 + 可视化界面
2.2 阶段一:数据采集与标注
2.2.1 数据采集规范
| 项目 | 要求 |
|---|---|
| 图像数量 | 每类缺陷 ≥ 200 张 |
| 分辨率 | ≥ 640×640 |
| 光照条件 | 至少包含 3 种不同光照 |
| 角度变化 | 正拍 + 45° 侧拍 |
| 背景 | 含简单背景和复杂背景 |
2.2.2 使用 LabelImg 标注
bash
# 安装 LabelImg
pip install labelimg
# 启动
labelimg标注流程:
- 设置自动保存:
View → Auto Save - 选择 YOLO 格式:
View → Auto Save mode→ YOLO - 为每个缺陷绘制矩形框并指定类别
- 标注文件结构:
dataset/
├── images/
│ ├── train/ # 80%
│ │ ├── img_0001.jpg
│ │ └── ...
│ └── val/ # 20%
│ ├── img_0101.jpg
│ └── ...
└── labels/
├── train/
│ ├── img_0001.txt # YOLO 格式标注
│ └── ...
└── val/
├── img_0101.txt
└── ...YOLO 标注格式:
<class_id> <x_center> <y_center> <width> <height>
# 所有坐标归一化到 [0,1]2.2.3 数据增强(弥补小样本)
python
import albumentations as A
import cv2
import os
# 定义增强管线
transform = A.Compose([
A.HorizontalFlip(p=0.5),
A.VerticalFlip(p=0.3),
A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=0.5),
A.GaussianBlur(blur_limit=(3, 5), p=0.2),
A.RandomGamma(gamma_limit=(80, 120), p=0.3),
A.GaussNoise(var_limit=(10.0, 50.0), p=0.3),
A.Rotate(limit=30, p=0.5),
], bbox_params=A.BboxParams(
format='yolo',
label_fields=['class_labels']
))
# 对每个标注图像做 3 倍增强
def augment_dataset(img_dir, label_dir, output_dir, times=3):
os.makedirs(f"{output_dir}/images", exist_ok=True)
os.makedirs(f"{output_dir}/labels", exist_ok=True)
for img_name in os.listdir(img_dir):
if not img_name.endswith(('.jpg', '.png')):
continue
img_path = os.path.join(img_dir, img_name)
label_path = os.path.join(label_dir,
img_name.replace('.jpg', '.txt')
.replace('.png', '.txt'))
if not os.path.exists(label_path):
continue
image = cv2.imread(img_path)
h, w = image.shape[:2]
with open(label_path) as f:
boxes = []
classes = []
for line in f.read().strip().split('\n'):
if not line:
continue
parts = line.strip().split()
classes.append(int(parts[0]))
boxes.append([float(x) for x in parts[1:]])
for i in range(times):
augmented = transform(image=image,
bboxes=boxes,
class_labels=classes)
aug_img = augmented['image']
aug_boxes = augmented['bboxes']
aug_classes = augmented['class_labels']
new_name = f"{img_name.replace('.jpg','').replace('.png','')}_aug{i}.jpg"
cv2.imwrite(f"{output_dir}/images/{new_name}", aug_img)
with open(f"{output_dir}/labels/{new_name.replace('.jpg','.txt')}", 'w') as f:
for cls, box in zip(aug_classes, aug_boxes):
f.write(f"{cls} {' '.join(f'{b:.6f}' for b in box)}\n")
print("数据增强完成!")2.3 阶段二:模型训练
2.3.1 数据集配置
yaml
# data.yaml
train: ./dataset/images/train
val: ./dataset/images/val
nc: 4
names: ['scratch', 'dent', 'crack', 'stain']2.3.2 训练脚本
python
from ultralytics import YOLO
import torch
# ---------- 选择模型 ----------
# YOLOv8n(最快) / YOLOv8s / YOLOv8m / YOLOv8l / YOLOv8x(最准)
model = YOLO('yolov8s.pt') # 使用预训练权重
# ---------- 训练 ----------
results = model.train(
data='data.yaml',
epochs=200,
imgsz=640,
batch=16,
device='cuda' if torch.cuda.is_available() else 'cpu',
patience=30, # Early Stopping
lr0=0.01,
lrf=0.01, # 最终学习率 = lr0 * lrf
warmup_epochs=3,
augment=True,
mosaic=1.0, # Mosaic 增强概率
mixup=0.1, # MixUp 增强
copy_paste=0.1, # Copy-Paste
hsv_h=0.015, # 色调扰动
hsv_s=0.7, # 饱和度扰动
hsv_v=0.4, # 明度扰动
translate=0.1, # 平移
scale=0.5, # 缩放
fliplr=0.5, # 水平翻转
workers=4,
project='runs/train',
name='defect_detection',
exist_ok=True,
)
# ---------- 验证 ----------
metrics = model.val()
print(f"mAP@0.5: {metrics.box.map50:.4f}")
print(f"mAP@0.5:0.95: {metrics.box.map:.4f}")
print(f"Precision: {metrics.box.p:.4f}")
print(f"Recall: {metrics.box.r:.4f}")2.3.3 模型评估与调优
python
# ---------- 混淆矩阵与 PR 曲线 ----------
# 训练自动生成在 runs/train/defect_detection/ 目录
# - confusion_matrix.png
# - PR_curve.png
# - results.png(loss + metric 曲线)
# ---------- 调优策略 ----------
def tune_hyperparameters():
"""使用遗传算法调参"""
model = YOLO('yolov8s.pt')
result = model.tune(
data='data.yaml',
epochs=50,
iterations=50, # 遗传代数
optimizer='AdamW',
plots=True,
save=True,
val=True,
)
print("最佳超参数:", result)
# ---------- 测试集评估 ----------
model = YOLO('runs/train/defect_detection/weights/best.pt')
results = model('test_samples/', conf=0.25, iou=0.5)
for r in results:
r.save(filename=f"pred_{r.path.split('/')[-1]}")2.4 阶段三:模型导出与部署
2.4.1 导出 ONNX / TensorRT
python
from ultralytics import YOLO
model = YOLO('runs/train/defect_detection/weights/best.pt')
# 导出 ONNX
model.export(format='onnx', imgsz=640, dynamic=True) # best.onnx
# 导出 TensorRT(需 GPU)
model.export(format='engine', imgsz=640, half=True) # best.engine (FP16)2.4.2 FastAPI 推理服务(完整版)
python
# ---------- api_server.py ----------
from fastapi import FastAPI, File, UploadFile, HTTPException
from fastapi.responses import JSONResponse, HTMLResponse
from ultralytics import YOLO
import cv2
import numpy as np
import asyncio
from concurrent.futures import ThreadPoolExecutor
import uvicorn
import base64
app = FastAPI(title="工业缺陷检测服务")
executor = ThreadPoolExecutor(max_workers=4)
# 加载模型
MODEL_PATH = "best.engine" # 或 best.onnx
model = YOLO(MODEL_PATH)
# 类别颜色
COLORS = {
0: (0, 255, 0), # scratch - 绿色
1: (255, 0, 0), # dent - 蓝色
2: (0, 0, 255), # crack - 红色
3: (255, 255, 0), # stain - 青色
}
CLASS_NAMES = ['scratch', 'dent', 'crack', 'stain']
def detect_defects(image_bytes):
"""同步推理函数"""
nparr = np.frombuffer(image_bytes, np.uint8)
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
# 推理
results = model(img, conf=0.25, iou=0.5)[0]
# 解析结果
defects = []
if results.boxes is not None:
for box, cls, conf in zip(results.boxes.xyxy,
results.boxes.cls,
results.boxes.conf):
x1, y1, x2, y2 = map(int, box.tolist())
class_id = int(cls.item())
defects.append({
"class": CLASS_NAMES[class_id],
"confidence": round(float(conf), 4),
"bbox": [x1, y1, x2, y2],
})
# 画框
cv2.rectangle(img, (x1, y1), (x2, y2),
COLORS[class_id], 2)
label = f"{CLASS_NAMES[class_id]} {conf:.2f}"
cv2.putText(img, label, (x1, y1-5),
cv2.FONT_HERSHEY_SIMPLEX, 0.5,
COLORS[class_id], 2)
# 编码返回图像
_, buffer = cv2.imencode('.jpg', img)
img_base64 = base64.b64encode(buffer).decode('utf-8')
return {
"defects": defects,
"defect_count": len(defects),
"has_defect": len(defects) > 0,
"image": img_base64,
}
@app.post("/detect")
async def detect(file: UploadFile = File(...)):
image_bytes = await file.read()
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(executor, detect_defects, image_bytes)
return JSONResponse(result)
@app.get("/", response_class=HTMLResponse)
async def index():
return """
<!DOCTYPE html>
<html>
<head><title>缺陷检测</title></head>
<body>
<h2>工业缺陷检测系统</h2>
<input type="file" id="imageInput" accept="image/*">
<button onclick="detect()">检测</button>
<div id="result"></div>
<script>
async function detect() {
const file = document.getElementById('imageInput').files[0];
const formData = new FormData();
formData.append('file', file);
const resp = await fetch('/detect', {method:'POST', body:formData});
const data = await resp.json();
document.getElementById('result').innerHTML = `
<p>缺陷数量: ${data.defect_count}</p>
<img src="data:image/jpeg;base64,${data.image}" style="max-width:640px">
<pre>${JSON.stringify(data.defects, null, 2)}</pre>
`;
}
</script>
</body>
</html>
"""
if __name__ == "__main__":
uvicorn.run("api_server:app", host="0.0.0.0", port=8000, reload=False)2.5 阶段四:性能测试与上线检查清单
bash
# 压测
locust -f locustfile.py --host=http://localhost:8000 --users=10 --spawn-rate=1
# 检查指标
# - 单图延迟 < 50ms
# - QPS > 20(单 GPU)
# - mAP@0.5 > 0.85
# - 内存占用稳定,无泄漏生产环境检查清单:
- [ ] 模型已导出为 TensorRT FP16 引擎
- [ ] API 已添加认证和限流
- [ ] 部署了多副本(Docker Compose / K8s)
- [ ] 日志记录(推理时间、错误、图片保存策略)
- [ ] 设置了 Prometheus + Grafana 监控
- [ ] 有熔断和降级策略
三、实战项目二:证件 OCR 识别与信息提取系统
3.1 项目概述
场景:自动识别身份证、驾驶证、营业执照等证件中的关键字段信息。
技术栈:PaddleOCR + PP-Structure + OpenCV + FastAPI
交付物:
- 证件图像预处理管线
- OCR 检测与识别
- 字段结构化提取
- RESTful API 服务
3.2 阶段一:证件图像预处理
3.2.1 难点分析
| 问题 | 影响 | 解决方案 |
|---|---|---|
| 倾斜/透视 | 降低检测精度 | 透视矫正 |
| 反光/阴影 | 文字断裂 | CLAHE 增强 |
| 模糊 | 识别困难 | 去模糊/超分辨率 |
| 复杂背景 | 误检 | 轮廓定位裁剪 |
3.2.2 预处理管线
python
import cv2
import numpy as np
class DocumentPreprocessor:
"""证件图像预处理"""
@staticmethod
def correct_skew(image):
"""校正倾斜(霍夫变换)"""
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
edges = cv2.Canny(gray, 50, 150)
lines = cv2.HoughLines(edges, 1, np.pi/180, threshold=200)
if lines is None:
return image
angles = []
for rho, theta in lines[:, 0]:
angle = np.degrees(theta) - 90
if -45 < angle < 45:
angles.append(angle)
if not angles:
return image
median_angle = np.median(angles)
h, w = image.shape[:2]
center = (w // 2, h // 2)
M = cv2.getRotationMatrix2D(center, median_angle, 1.0)
return cv2.warpAffine(image, M, (w, h),
flags=cv2.INTER_CUBIC,
borderMode=cv2.BORDER_REPLICATE)
@staticmethod
def enhance_contrast(image):
"""CLAHE 增强对比度"""
lab = cv2.cvtColor(image, cv2.COLOR_BGR2LAB)
l, a, b = cv2.split(lab)
clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8))
l = clahe.apply(l)
enhanced = cv2.merge([l, a, b])
return cv2.cvtColor(enhanced, cv2.COLOR_LAB2BGR)
@staticmethod
def find_document_contour(image):
"""通过最大轮廓定位证件区域"""
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
gray = cv2.GaussianBlur(gray, (5, 5), 0)
# 自适应阈值 + 形态学操作
binary = cv2.adaptiveThreshold(
gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY_INV, 11, 2)
kernel = np.ones((5, 5), np.uint8)
binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel)
contours, _ = cv2.findContours(
binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
return image
# 找到最大轮廓
largest = max(contours, key=cv2.contourArea)
x, y, w, h = cv2.boundingRect(largest)
return image[y:y+h, x:x+w]
@staticmethod
def perspective_correction(image):
"""四点透视矫正"""
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
gray = cv2.GaussianBlur(gray, (5, 5), 0)
edges = cv2.Canny(gray, 50, 150)
# 检测四边形轮廓
contours, _ = cv2.findContours(
edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
contours = sorted(contours, key=cv2.contourArea, reverse=True)
for cnt in contours[:5]:
peri = cv2.arcLength(cnt, True)
approx = cv2.approxPolyDP(cnt, 0.02 * peri, True)
if len(approx) == 4:
# 四点排序:左上、右上、右下、左下
pts = approx.reshape(4, 2).astype(np.float32)
rect = np.zeros((4, 2), dtype=np.float32)
s = pts.sum(axis=1)
rect[0] = pts[np.argmin(s)] # 左上
rect[2] = pts[np.argmax(s)] # 右下
diff = np.diff(pts, axis=1)
rect[1] = pts[np.argmin(diff)] # 右上
rect[3] = pts[np.argmax(diff)] # 左下
(tl, tr, br, bl) = rect
width_a = np.linalg.norm(br - bl)
width_b = np.linalg.norm(tr - tl)
max_width = max(int(width_a), int(width_b))
height_a = np.linalg.norm(tr - br)
height_b = np.linalg.norm(tl - bl)
max_height = max(int(height_a), int(height_b))
dst = np.array([
[0, 0],
[max_width - 1, 0],
[max_width - 1, max_height - 1],
[0, max_height - 1],
], dtype=np.float32)
M = cv2.getPerspectiveTransform(rect, dst)
return cv2.warpPerspective(image, M,
(max_width, max_height))
return image
def process(self, image):
"""完整预处理管线"""
result = self.correct_skew(image)
result = self.enhance_contrast(result)
result = self.find_document_contour(result)
result = self.perspective_correction(result)
return result3.3 阶段二:OCR 识别与字段提取
3.3.1 证件 OCR 识别
python
from paddleocr import PaddleOCR
import re
import json
class IDCardOCR:
"""身份证 OCR 识别"""
def __init__(self):
self.ocr = PaddleOCR(
use_angle_cls=True,
lang='ch',
use_gpu=True,
det_db_thresh=0.3,
rec_batch_num=6,
)
def recognize(self, img_path):
"""识别证件图像,返回 OCR 结果列表"""
result = self.ocr.ocr(img_path, cls=True)
if not result or not result[0]:
return []
items = []
for line in result[0]:
box = line[0]
text, score = line[1]
items.append({
'text': text,
'confidence': score,
'box': box,
})
return items
def extract_idcard_fields(self, items):
"""从 OCR 结果中提取身份证字段"""
field_patterns = {
'姓名': r'(姓名|Name)\s*[::]\s*(.{2,4})',
'性别': r'(性别|Sex)\s*[::]\s*([男女])',
'民族': r'(民族|Nation)\s*[::]\s*([\u4e00-\u9fa5]+)',
'出生': r'(出生|Birth)\s*[::]*\s*(\d{4}[-年.]\d{1,2}[-月.]\d{1,2}[日]?)',
'住址': r'(住址|Address)\s*[::]\s*(.+)',
'身份证号': r'(\d{6}[- ]?\d{4}[- ]?\d{2}[- ]?\d{2}[- ]?\d{4})',
}
# 拼接所有文本
full_text = ''.join([item['text'] for item in items])
extracted = {}
for field, pattern in field_patterns.items():
match = re.search(pattern, full_text)
if match:
extracted[field] = match.group(2).strip()
# 再逐行扫描补充
for item in items:
text = item['text'].replace(' ', '')
# 身份证号
id_match = re.search(r'(\d{17}[\dXx])', text)
if id_match and '身份证号' not in extracted:
extracted['身份证号'] = id_match.group(1)
# 姓名(2-4个汉字单独一行)
if re.match(r'^[\u4e00-\u9fa5]{2,4}$', text) and '姓名' not in extracted:
extracted['姓名'] = text
return extracted3.3.2 营业执照字段提取
python
class BusinessLicenseOCR:
"""营业执照 OCR 识别"""
def __init__(self):
self.ocr = PaddleOCR(use_angle_cls=True, lang='ch')
def extract_fields(self, img_path):
items = self.recognize(img_path)
full_text = ''.join([item['text'] for item in items])
extracted = {}
patterns = {
'统一社会信用代码': r'(\d{18})',
'企业名称': r'(名称\s*[::]\s*(.+?)(?=\s*(?:类型|住所|地址)))',
'类型': r'(类型\s*[::]\s*(.+?)(?=\s*(?:住所|地址|法定代表人)))',
'住所': r'(住所\s*[::]\s*(.+?)(?=\s*(?:法定代表人|注册资本)))',
'法定代表人': r'(法定代表人\s*[::]\s*(.+?)(?=\s*(?:注册资本|成立日期)))',
'注册资本': r'(注册资本\s*[::]\s*(.+?)(?=\s*(?:成立日期|营业期限)))',
'成立日期': r'(成立日期\s*[::]\s*(.+?)(?=\s*(?:营业期限|经营范围)))',
'经营范围': r'(经营范围\s*[::]\s*(.+))',
}
for field, pattern in patterns.items():
match = re.search(pattern, full_text)
if match:
extracted[field] = match.group(2).strip()
return extracted
def recognize(self, img_path):
result = self.ocr.ocr(img_path, cls=True)
if not result or not result[0]:
return []
items = []
for line in result[0]:
items.append({
'text': line[1][0],
'confidence': line[1][1],
'box': line[0],
})
return items3.4 阶段三:结构化输出 API
python
# ---------- app.py(证件 OCR 服务)----------
from fastapi import FastAPI, File, UploadFile, HTTPException
from fastapi.responses import JSONResponse
import cv2
import numpy as np
import asyncio
import uvicorn
import json
from preprocessor import DocumentPreprocessor
from idcard_ocr import IDCardOCR
from license_ocr import BusinessLicenseOCR
app = FastAPI(title="证件 OCR 识别服务")
# 加载模型(单例)
preprocessor = DocumentPreprocessor()
idcard_ocr = IDCardOCR()
license_ocr = BusinessLicenseOCR()
@app.post("/ocr/idcard")
async def ocr_idcard(file: UploadFile = File(...)):
"""身份证 OCR 识别"""
image_bytes = await file.read()
nparr = np.frombuffer(image_bytes, np.uint8)
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
# 1. 预处理
processed = preprocessor.process(img)
cv2.imwrite('_processed.jpg', processed)
# 2. OCR 识别
items = idcard_ocr.recognize('_processed.jpg')
# 3. 字段提取
fields = idcard_ocr.extract_idcard_fields(items)
return JSONResponse({
"success": len(fields) > 0,
"fields": fields,
"ocr_raw": [
{"text": it['text'], "confidence": it['confidence']}
for it in items
],
})
@app.post("/ocr/business-license")
async def ocr_business_license(file: UploadFile = File(...)):
"""营业执照 OCR 识别"""
image_bytes = await file.read()
nparr = np.frombuffer(image_bytes, np.uint8)
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
# 1. 预处理
processed = preprocessor.process(img)
cv2.imwrite('_processed.jpg', processed)
# 2. OCR + 字段提取
fields = license_ocr.extract_fields('_processed.jpg')
return JSONResponse({
"success": len(fields) > 0,
"fields": fields,
})
@app.post("/ocr/general")
async def general_ocr(file: UploadFile = File(...)):
"""通用 OCR(返回全文)"""
image_bytes = await file.read()
nparr = np.frombuffer(image_bytes, np.uint8)
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
cv2.imwrite('_temp.jpg', img)
ocr = PaddleOCR(use_angle_cls=True, lang='ch')
result = ocr.ocr('_temp.jpg')
texts = []
for line in result[0]:
texts.append({
"text": line[1][0],
"confidence": line[1][1],
})
full_text = '\n'.join([t['text'] for t in texts])
return JSONResponse({
"full_text": full_text,
"items": texts,
})
if __name__ == "__main__":
uvicorn.run("app:app", host="0.0.0.0", port=8001, reload=False)3.5 阶段四:评估与优化
3.5.1 评估指标
python
def evaluate_ocr(y_true, y_pred):
"""评估字段级提取准确率"""
total = len(y_true)
correct = 0
partial = 0
for key, true_val in y_true.items():
pred_val = y_pred.get(key, '')
if pred_val == true_val:
correct += 1
elif true_val in pred_val or pred_val in true_val:
partial += 1
return {
"exact_match_accuracy": correct / total if total > 0 else 0,
"partial_match_accuracy": (correct + partial) / total if total > 0 else 0,
"correct_fields": correct,
"total_fields": total,
}
# 示例
y_true = {'姓名': '张三', '身份证号': '110101199001011234'}
y_pred = {'姓名': '张三', '身份证号': '11010119900101123X'}
print(evaluate_ocr(y_true, y_pred))3.5.2 常见问题与优化
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 身份证号错位 | 排版紧邻、字符粘连 | 增加检测框后处理膨胀 |
| 姓名多字/少字 | 生僻字字典缺失 | 自定义字典 rec_char_dict_path |
| 地址不完整 | 换行导致断句 | 按空间相邻合并检测框 |
| 反光区域漏识 | 过曝区域文字消失 | 多帧融合 / 偏振拍照 |
| 倾斜严重检测失败 | 预处理矫正不足 | 训练专用的文本检测模型 |
四、项目交付清单
4.1 缺陷检测项目交付
- [ ] 标注数据集(含增强版本)
- [ ] YOLOv8 训练日志与最佳权重
- [ ] ONNX / TensorRT 部署模型
- [ ] FastAPI 推理服务(含前端预览)
- [ ] Locust 压测报告
- [ ] Dockerfile + docker-compose.yml
4.2 证件 OCR 项目交付
- [ ] 预处理管线代码
- [ ] 字段提取规则与正则
- [ ] FastAPI OCR 服务
- [ ] 测试集评估报告
- [ ] API 文档(Swagger 自动生成)
五、小结
| 实战项目 | 核心技术 | 工程要点 |
|---|---|---|
| 工业缺陷检测 | YOLOv8 + 数据增强 + ONNX/TensorRT 部署 | 数据质量 > 模型结构;标注一致性至关重要 |
| 证件 OCR 识别 | PaddleOCR + 图像预处理 + 正则提取 | 预处理管线决定 OCR 上限;字段提取需容错设计 |
工程经验总结:
- 数据优先:花 70% 时间在数据标注和质量检查上
- 从简到繁:先跑通 Baseline,再逐步优化
- 可复现性:固定随机种子、记录超参数、版本管理模型
- 容错设计:API 层面做好异常处理和降级策略
- 持续监控:上线后持续收集bad case,迭代优化