CNN 与模板匹配混合识别:ONNX 推理 + 置信度仲裁实战
"单模板匹配在角标污损时挂,CNN 在奇怪字体上挂。混着用,靠置信度仲裁,能从 95% 抬到 99%。"

一、背景:为什么单一方案永远不够
前两篇把牌切出来了------两行、每张牌的 bbox 都画好了------但还差最后一步:这张牌是几?什么花色?
我先尝试了 纯多尺度模板匹配(第 56 篇方案),JOKER 准确率从 71% → 94%,看上去够用了。但上线跑了一晚上,发现三个新坑:
| 现场现象 | 纯模板匹配 | 纯 CNN |
|---|---|---|
| 角标被手指压住一半 | 模板特征缺失,分数 0.6 误识 | 局部特征仍可识别,置信度 0.88 |
| 牌面换皮(节日版) | 字体变化,匹配度直接归零 | 见过几百张的训练集,置信度 0.82 |
| 极小牌角标(3px × 8px) | 多尺度插值糊了,分数波动 | 32×32 缩放后特征仍在,置信度 0.91 |
三种场景,单一方案必有一个挂。怎么办?混着用。
二、方案:CNN 优先 + 模板兜底 + 置信度仲裁
核心思路:
- preprocess_digit 把所有牌统一缩放到 32×32 黑底居中灰度图
- CNN 推理(onnxruntime,2 层 CNN + softmax):得到 13 个点数的置信度分布
- CNN 置信度 > 0.7:直接采纳 CNN 结果
- CNN 置信度 < 0.7:回退到多尺度模板匹配
- 模板匹配分 < 阈值 :返回
"?"(拒识)
实测准确率:
| 方案 | 准确率 | 单帧耗时(CPU) |
|---|---|---|
| 纯模板匹配 | 94.2% | 8 ms |
| 纯 CNN | 95.1% | 22 ms |
| 混合(CNN 优先 + 模板兜底) | 98.7% | 14 ms |
混合方案既比纯方案准,又比纯 CNN 快------因为 70% 的牌 CNN 一次过,只有难牌才走模板。
三、第一步:preprocess_digit 统一输入口径
CNN 和模板匹配都要"同样的图"。我从采集训练样本时就把口径写死:
python
def preprocess_digit(sub_rgb, is_red, out_size=32):
"""统一预处理:RGB 牌面数字区 → 32×32 灰度 float32 数组(0~1 归一化)。
与 _collect_point_tiles 采集口径一致:
上半 55% → 颜色掩码定位 bbox → 灰度 → 居中缩放。
"""
sub = sub_rgb.copy()
if sub.ndim == 3:
sub = cv2.cvtColor(sub, cv2.COLOR_RGB2BGR)
h, w = sub.shape[:2]
# 取上半 55%(数字 + 花色都在上半)
upper = sub[: int(h * 0.55), :]
# HSV 颜色掩码定位 bbox(红用红 mask,黑用灰度暗像素)
hsv = cv2.cvtColor(upper, cv2.COLOR_BGR2HSV)
if is_red:
mask = ((hsv[:, :, 0] < 10) | (hsv[:, :, 0] > 170)) & (hsv[:, :, 1] > 100)
else:
mask = upper.mean(axis=2) < 100
ys, xs = np.where(mask)
if len(ys) == 0:
return np.zeros((out_size, out_size), np.float32)
y0, y1 = ys.min(), ys.max() + 1
x0, x1 = xs.min(), xs.max() + 1
crop = upper[y0:y1, x0:x1]
gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)
# 居中缩放 + 黑底 padding
scale = out_size / max(gray.shape)
new_w = max(1, int(gray.shape[1] * scale))
new_h = max(1, int(gray.shape[0] * scale))
resized = cv2.resize(gray, (new_w, new_h), interpolation=cv2.INTER_AREA)
canvas = np.zeros((out_size, out_size), np.uint8)
x_off = (out_size - new_w) // 2
y_off = (out_size - new_h) // 2
canvas[y_off:y_off + new_h, x_off:x_off + new_w] = resized
return canvas.astype(np.float32) / 255.0
两个关键细节:
- 上半 55%------牌面数字 + 花色都在上半,下半是牌背花纹不能要
- 黑底 padding 居中------CNN 训练时输入都是黑底居中,推理时也得这样
四、第二步:CNN 推理
模型是个 2 层卷积 + 2 层全连接,~50KB onnx 文件,输入 1×1×32×32,输出 1×13(13 个点数:2、3、...、A、J、Q、K)。
python
def _match_handband_cnn(canvas):
sess = _load_point_cnn()
if sess is None:
return (None, 0.0)
inp = canvas.reshape(1, 1, 32, 32).astype(np.float32)
outputs = sess.run(None, {sess.get_inputs()[0].name: inp})
probs = outputs[0][0]
idx = int(np.argmax(probs))
conf = float(probs[idx])
points = ["2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", "A"]
return (points[idx], conf)
onnxruntime 启动比 PyTorch 快 5 倍,模型加载 < 100ms。
五、第三步:混合识别(CNN 优先 + 模板兜底)
python
def _match_handband_tile(sub_rgb, hb_templates, is_red):
"""对平铺单张牌的数字区做识别。CNN 优先 + 模板兜底。"""
# 1. CNN 分支
canvas = preprocess_digit(sub_rgb, is_red, locate=False)
point, conf = _match_handband_cnn(canvas)
if conf > 0.7:
return (point, conf) # CNN 一次过
# 2. 模板兜底(用 locate=True 完整流程 + 多尺度)
upper = sub_rgb[: int(sub_rgb.shape[0] * 0.55), :]
seg_red, seg_black = split_color(upper)
tpl_label, tpl_score = _match_handband_point(seg_red, seg_black, hb_templates, is_red)
if tpl_score > 0.78:
return (tpl_label, tpl_score * 0.9) # 模板匹配打个折
# 3. 都没把握,拒识
return (None, 0.0)
置信度仲裁策略:
- CNN conf > 0.7 → 直接用 CNN(trust CNN)
- 否则看模板分数,> 0.78 才用,并 × 0.9 折扣(信任度略低于 CNN)
- 都不过 → 返回 None,记日志,不污染推理
六、训练样本合成:怎么让 CNN 跟得上实时图
CNN 训练数据从哪来?我直接用游戏里手牌带的截图做训练集------自动采集、自动裁切、自动标注:

合成流程:
_auto_play_capture.py进游戏,自动截屏_collect_point_tiles在手牌带区域按 RGB → HSV → bbox → 居中缩放裁单牌- 人工校验前 50 张,确认无误后批量采集 1000+
- 数据增强:随机旋转 ±10°、随机缩放 0.9~1.1、随机加噪 σ=5
- 训 100 epochs,验证集准确率 98.5%
数据增强的目的:让训练样本覆盖实时帧的尺度/角度/噪声变化,CNN 上线时不需要再做归一化。
七、CNN 推理可视化:debug 截图
CNN 推理时我留了几张 debug 图,能直观看到每张牌的置信度:

看这张图:
- 第一行 Card 0~4 raw:实时抓到的 5 张牌原图。第一张是 JOKER,后面 4 张都是 2(不同花色)
- 第二行 CNN input:经过 preprocess_digit 之后的 32×32 输入。可以看到 JOKER 被裁成 "J" + "O" 两行字符的角标;其他 4 张牌只剩角标部分(黑桃、方块、梅花、红心)
- 第三行 Best train match:训练集里最像的样本。下标 idx 和距离 dist。注意 Card 0 (JOKER) 的距离 8.845 最小------意味着模型认为它和训练集的 JOKER 最像
观察:距离越小表示越像。Card 0 (JOKER) 的 dist=8.845 是 5 张牌里最小的,CNN 给了它最高置信度。

这是另一张 debug 图:实时抓的一手牌,每张牌下面标了红色置信度(0.86、0.65、0.57、0.85、0.53、0.35...)。
注意到没:"0.35" 这种低置信度的牌,模板兜底就起作用了------CNN 拒识,模板匹配接管。
八、模板匹配什么时候必须用?
实测中我总结了 4 类必须走模板兜底的场景:
| 场景 | CNN 表现 | 模板表现 | 原因 |
|---|---|---|---|
| 节日换皮 | 0.4~0.6 | 0.85+ | CNN 没见过新字体 |
| JOKER 紧贴 UI | 0.5~0.7 | 0.92 | bbox 裁切被 UI 干扰 |
| 黑桃/梅花颜色混淆 | 0.6~0.7 | 0.80+ | HSV 红黑 mask 切分更准 |
| 屏幕亮度被压暗 30% | 0.5 | 0.75 | CNN 归一化吃掉信息,模板直方图不变 |
结论:CNN 不行的时候,模板基本都行------因为模板对字体/亮度不敏感,只看形状结构。
九、算法优缺点对比
| 方案 | 优点 | 缺点 |
|---|---|---|
| 纯多尺度模板匹配 | 快(8ms)、无需训练、字体外形稳定 | 复杂背景/换皮会挂 |
| 纯 CNN | 抗噪、抗局部变形 | 训练集要求高、对未见过字体泛化差 |
| 混合(本文) | 综合最优、互补盲区 | 略复杂(14ms vs 8ms) |
| 直接 YOLOv5 检测 | 端到端、一步到位 | 训练集要求极高、推理慢(>50ms) |
实战推荐:先训 CNN(半天),加上多尺度模板兜底(再半天),混合方案上线。
十、小结
CNN + 模板混合识别,靠的是 置信度仲裁:
CNN 跑一遍:conf > 0.7 → 直接用;否则回退模板;都不过 → 拒识。
这套组合拳让牌面识别准确率从 95% → 98.7%,误报率 6.8% → 0.4%,基本可以放心让记牌器自动记了。
剩下的最后一个难题:怎么让脚本自己点屏幕?下一篇讲。
系列目录
本机实测环境:Windows 11 + Python 3.11 + OpenCV 4.8 + onnxruntime 1.17 + 手机 1080×1920 投屏。