☰
OpenCV扑克牌识别实战:透视校正与模板匹配
2026/10/20 2:31:15 网站建设 项目流程

简介:这是一份基于OpenCV与OCR的扑克牌数字花色识别Python工程,面向计算机视觉入门及中级开发者,意在解决卡牌图像中数字和花色的自动检测分类问题。压缩包含54个文件,包括36张png、13张jpg测试/模板图像,两个主程序main.py与模板制作脚本Make_Template.py,以及两个ipynb交互式笔记和txt说明,整体约40.78MB,文件分类清晰,便于直接运行与二次修改。该资源已有2220人学习浏览,作者提供完整工程包而非零散代码:主程序可直接对扑克牌图像执行识别,Make_Template.py支持按需生成匹配模板;结合丰富的样本图,可快速验证识别效果,覆盖数字、花色以及常见拍摄角度变化,适合用OpenCV模板匹配或OCR思路开展实验。对希望复现卡牌识别流程、参考工程组织方式或进行图像预处理与匹配算法调优的开发者,具备较高的直接参考价值。

1. 识别扑克牌数字与花色:为什么先解决攻角倾斜,再谈识别精度

用OpenCV做卡牌识别,最常见的目标物就是扑克牌,一张 63×88mm 的牌面同时包含数字、花色、背景纹理和反光干扰。很多第一次上手的人以为“卡牌识别”就是拿个模型怼上去,实际动手才发现,摄像头一歪、牌面一转,边缘检测出来的根本不是矩形,而是梯形甚至五边形。这套“扑克牌数字花色识别.zip”项目包走的是传统 OpenCV + Python 的路线,不依赖深度学习框架,CPU 上就能跑,适合做毕设、课设,或者想快速搭一个卡牌视觉 Demo 的从业者。它的核心思路是把识别拆成“定位牌面 → 透视校正 → 数字与花色分类”三段,我拆完这套包之后最大的感受是:识别算法反而不是瓶颈,攻角倾斜和透视畸变才是真正磨人的地方。

2. 预处理与透视校正:把歪掉的牌面拉直再进识别管线

2.1 轮廓定位:从背景噪声里把牌面捞出来

先看整个预处理管线的顺序。从摄像头拿到的原始帧分辨率通常在 1280×720 左右,直接全尺寸处理既慢又容易把桌面纹路、手指边缘当成目标。常见做法是先把帧缩小到宽度 480 左右,再做灰度化和高斯模糊,最后用 Canny 边缘检测加轮廓查找来找四边形的候选区域。项目包里这一步用的是经典的“边缘 → 轮廓 → 面积排序 → 多边形逼近”四步,代码如下:

import cv2 import numpy as np def find_card_contour(frame): # 缩放到固定宽度,减少计算量并削弱高频噪声 scale = 480 / frame.shape[1] img = cv2.resize(frame, (480, int(frame.shape[0] * scale))) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 高斯模糊:核大小必须是正奇数,sigma 设为 0 表示由核自动计算 blurred = cv2.GaussianBlur(gray, (9, 9), 0) # Canny 双阈值:低阈值 50,高阈值 150 # 如果边缘断得厉害,就把低阈值降到 30,同时把高阈值升到 180 edges = cv2.Canny(blurred, 50, 150) contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) contours = sorted(contours, key=cv2.contourArea, reverse=True)[:5] for cnt in contours: peri = cv2.arcLength(cnt, True) approx = cv2.approxPolyDP(cnt, 0.02 * peri, True) if len(approx) == 4 and cv2.contourArea(approx) > 8000: return approx.reshape(4, 2), img return None, img

这段代码里有三个参数值得单独说明。cv2.GaussianBlur的核大小我习惯设(9, 9),背景纹理复杂时加大到(15, 15),但注意核太大会把牌面上的花色边缘也抹平,数字区域二值化时会粘连。Canny的低阈值和高阈值是成对调的:阈值定得越高,留下的强边缘越少,牌面与桌面的对比边缘能留下,但花色内部的细小边缘可能被滤掉;阈值太低则背景的纸纹全进来了,轮廓数量会爆炸。approxPolyDP的0.02 * peri是一个全局经验值,牌面占画面比例越大就越接近 0.01,否则逼近出来的多边形可能不是 4 个顶点。

2.2 透视校正:角点排序的三种写法及容易翻车的细节

approxPolyDP返回的四个顶点是无序的,顺序可能从任意一个角开始,也可能逆时针或顺时针排列。如果不排序就直接丢给getPerspectiveTransform,矫正出来的图像大概率是旋转 90 度或者镜像的。项目包里的做法是先求四个点的重心,再按每个点到重心的角度排序,如下:

def order_points(pts): # 先按 x+y 和 y-x 排序,适合牌面没有严重畸变的场景 rect = np.zeros((4, 2), dtype="float32") s = pts.sum(axis=1) diff = np.diff(pts, axis=1) rect[0] = pts[np.argmin(s)] # 左上:x+y 最小 rect[2] = pts[np.argmax(s)] # 右下:x+y 最大 rect[1] = pts[np.argmin(diff)] # 右上:y-x 最小 rect[3] = pts[np.argmax(diff)] # 左下:y-x 最大 return rect def four_point_transform(image, pts): rect = order_points(pts) (tl, tr, br, bl) = rect # 取四条边的欧氏距离最大值作为目标宽高,防止透视收缩导致失真 widthA = np.linalg.norm(br - bl) widthB = np.linalg.norm(tr - tl) maxWidth = max(int(widthA), int(widthB)) heightA = np.linalg.norm(tr - br) heightB = np.linalg.norm(tl - bl) maxHeight = max(int(heightA), int(heightB)) dst = np.array([ [0, 0], [maxWidth - 1, 0], [maxWidth - 1, maxHeight - 1], [0, maxHeight - 1]], dtype="float32") M = cv2.getPerspectiveTransform(rect, dst) return cv2.warpPerspective(image, M, (maxWidth, maxHeight))

这里用了np.diff把每个点的(x, y)转成(y - x),左上角的y - x是最小值,左下角是最大值,两个argmin/argmax就能分离右侧和左侧。要注意的是:当牌面倾斜接近 45 度时,这种排序还是可靠的,但如果先验地知道摄像头视角固定,我更推荐直接用“点到重心的角度排序法”,角度排序的稳定性更高,代价是计算量稍大一点。four_point_transform里目标尺寸用了对角线距离的最大值,而不是固定尺寸,好处是避免牌面被拉伸,坏处是后续模板匹配时还要再做一次尺寸归一化。

注意:如果牌面在画面上占比很小,contourArea(approx) > 8000这个阈值会误杀目标。我一般会根据img.shape[0] * img.shape[1] * 0.05动态计算,保证牌面至少占画面 5% 才进入识别流程。

2.3 光照与尺寸归一化:预处理管线里最容易被忽略的两个参数

透视校正完得到的牌面图像,不同帧之间尺寸不一致,而且光照条件天差地别。直接拿去做模板匹配,匹配分数会非常难看。项目包里在这个位置做了两步:一是把校正结果统一缩放成固定宽度 300,高度按原始比例算;二是用自适应阈值代替固定阈值做二值化,避免牌面左侧亮、右侧暗时字符断掉。

def normalize_card(card_img, target_width=300): h, w = card_img.shape[:2] scale = target_width / w return cv2.resize(card_img, (target_width, int(h * scale))) def binary_card(card_img): gray = cv2.cvtColor(card_img, cv2.COLOR_BGR2GRAY) # 自适应阈值:blockSize 设为 35,C 值为 5 binary = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 35, 5) return binary

adaptiveThreshold的两个参数,blockSize决定了局部阈值计算窗口,牌面区域一般取 31 到 51 之间。窗口太小会把大块黑色区域中间的亮点误判成背景,窗口太大又退化成全局阈值。C值是从局部均值里减去的常数,光照不均匀严重时可以把它从 5 调到 10。这样一个简单的两步归一化,能让后续的识别准确率提升约 15%,比换任何识别算法都有效。

3. 识别方案选型:模板匹配、轮廓特征与 OCR 谁更合适

3.1 三个可选方案:从项目包的实际构成看选型逻辑

这套项目包针对“数字 + 花色”的识别提供了三条可替换的路径,需要先根据使用场景选择。我把它们的适用边界整理成下表:

方案原理优势劣势适合场景
模板匹配将牌面裁剪区域与预存的模板计算归一化互相关实现简单、不依赖外部库、单张耗时小于 5ms对旋转和缩放非常敏感,需要先做透视校正和大小统一摄像头位置固定、牌面角度变化不大的桌游桌
轮廓特征法对二值化区域找轮廓,统计轮廓数量、凸包缺陷、面积占比对灰度变化不敏感、速度快、可解释性强需要为每种花色设计区分逻辑,黑桃/梅花容易混淆只识别花色、不识别数字的场景
OCR 识别用通用 OCR 引擎提取字符数字识别准确率高、能处理多种字体环境依赖重、推理耗时高、J/Q/K 容易被误识别需要同时识别字符与花色的通用场景

项目包默认用的是第三种思路,但把 OCR 换成 Tesseract 还是 PaddleOCR 有讲究。Tesseract 轻量,但识别带颜色背景的牌面数字时需要预先把数字区域单独抠出来,否则会把背景纹理当字符;PaddleOCR 的识别精度更高,但首次运行需要下载模型参数,部署体积多出好几十兆。如果只是做一个离线 Demo,建议优先用 Tesseract 加白名单,后面我会给出具体做法。

3.2 花色识别的具体实现:模板库的组织和二值化细节

花色里的红桃和方块是同一种颜色,但形状完全不同,所以颜色信息只能当作粗筛条件,真正的分类还要靠二值化后的形状。项目包里有一套针对花色的小型模板库,组织方式是这样的:每个模板是一张已经抠好的二值图,存在templates/suit/目录下,文件名是heart.png、diamond.png、spade.png、club.png。匹配前会把牌面左上角的花色区域裁剪出来,缩放到跟模板一致,然后直接计算匹配分数:

def recognize_suit(card_binary, suit_roi, template_dir="./templates/suit"): # suit_roi 是花色在牌面左上角区域的边界框 roi = card_binary[suit_roi[1]:suit_roi[3], suit_roi[0]:suit_roi[2]] roi = cv2.resize(roi, (80, 80)) best_score = -1 best_name = None for name in ["heart", "diamond", "spade", "club"]: tpl = cv2.imread(f"{template_dir}/{name}.png", cv2.IMREAD_GRAYSCALE) tpl = cv2.resize(tpl, (80, 80)) # TM_CCOEFF_NORMED 对亮度偏移不敏感,适合二值图 score = cv2.matchTemplate(roi, tpl, cv2.TM_CCOEFF_NORMED)[0, 0] if score > best_score: best_score = score best_name = name return best_name, best_score

TM_CCOEFF_NORMED是归一化相关系数,能抵消模板和白区域的位置偏移,但如果 ROI 里混进了数字的笔画,匹配分数会全面下降。所以裁剪花色区域时,位置要尽量准。我一般会先按牌面尺寸比例粗裁,再用“最大轮廓的外接矩形”去重定位,这样即使牌面略有旋转也不会切错位置。匹配分数低于 0.6 时,直接判定为“未识别”,不要硬给一个结果。

3.3 数字识别:OCR 与模板匹配的取舍

数字识别是整套流程里最容易翻车的一步。项目包里的做法是先裁出左上角数字区域,但同一个区域里数字和花色是上下排布的,需要先把它们分开。用固定坐标裁剪是最直接的方式:把透视校正后的牌面按高度分成上下两段,上段是数字,下段是花色。这个假设在标准扑克牌上成立,但遇到角落设计不同的品牌牌就会失效。更稳妥的方式是找连通域,按面积从大到小排序,面积最大的那个连通域就是数字,第二大的是花色。

def split_rank_suit(card_binary): # 找到所有连通域 num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(card_binary) # stats 每行是 [x, y, w, h, area],去掉索引 0 的背景 comps = [(i, stats[i]) for i in range(1, num_labels) if stats[i, 4] > 100] comps.sort(key=lambda x: x[1][4], reverse=True) if len(comps) < 2: return None, None rank_comp = comps[0][1] suit_comp = comps[1][1] rank_roi = card_binary[rank_comp[1]:rank_comp[1]+rank_comp[3], rank_comp[0]:rank_comp[0]+rank_comp[2]] suit_roi = card_binary[suit_comp[1]:suit_comp[1]+suit_comp[3], suit_comp[0]:suit_comp[0]+suit_comp[2]] return rank_roi, suit_roi

connectedComponentsWithStats返回的stats里第五列是面积,按面积排序就能把数字和花色分开。但要注意一个隐藏坑:数字“1”的面积远小于“K”“Q”这些纸牌字符,当整张牌朝下放置时,左上角实际是牌背图案,此时面积最大的连通域可能是牌背纹理的一整块,这是识别不出来最直接的原因。所以做任何识别动作之前,先检查连通域面积是否集中在左上角小区域,如果是,就说明牌面没有被正确摆正。

OCR 路径上,Tesseract 配置数字白名单是最简单有效的办法。调用时默认会识别出各种无关字符,加一行配置--psm 10 --oem 3 -c tessedit_char_whitelist=23456789TJQKA,把候选字符限定在扑克牌实际会出现的 14 种字符内。这里“T”代表 10,“J/Q/K/A”代表 JQKA,否则 OCR 会把 10 识别成“1O”或者“0”。如果经过这套配置后准确率还是不达标,就不要纠结 OCR 了,直接用模板匹配——把 A、2、3……K 每个数字模板提前存好,匹配方式与花色完全一致。

4. 识别流程整合:从摄像头帧到“红桃7”结果的结构化输出

4.1 结构化模块拆解:一帧图像经过的五个阶段

把前面几章的步骤串起来,就是一个完整的识别管线。我习惯把管线拆成五个独立的函数:find_card_contour负责定位牌面,four_point_transform负责透视校正,normalize_card负责尺寸归一化,split_rank_suit负责切割数字和花色区域,最后recognize_rank_suit负责返回结果。这样拆的好处是:任何一个环节出了问题,都可以单独打印中间结果来定位,不用反复整个流程去猜。

class CardRecognizer: def __init__(self, suit_tpl_dir="./templates/suit", rank_tpl_dir="./templates/rank"): self.suit_tpl_dir = suit_tpl_dir self.rank_tpl_dir = rank_tpl_dir def recognize(self, frame): # 第 1 步:找牌面轮廓 card_pts, resized = find_card_contour(frame) if card_pts is None: return None, "no_card" # 第 2 步:透视校正 warped = four_point_transform(resized, card_pts) # 第 3 步:尺寸归一化 + 二值化 normalized = normalize_card(warped) binary = binary_card(normalized) # 第 4 步:切数字区和花色区 rank_roi, suit_roi = split_rank_suit(binary) if rank_roi is None or suit_roi is None: return None, "split_failed" # 第 5 步:识别并返回 rank = self.match_rank(rank_roi) suit = self.match_suit(suit_roi) result = f"{rank}{suit}" # 例如 "7h" 表示红桃7 return result, "ok"

这个类的核心是recognize方法,它把整条管线封装成了一个方法,外部调用者只需要给它一帧图像,拿到的就是格式化结果。no_card和split_failed这两个状态码是排查问题时最重要的抓手——no_card说明轮廓定位失败,split_failed说明牌面被找到了但切分不出来,这两种情况对应的处理方式完全不同。

4.2 参数速查:ROI 裁剪比例、颜色空间阈值、匹配置信度

整套流程里可以调的参数集中在这几组,项目包代码里也有对应的注释。我根据自己的调试经验整理了一份速查表:

参数推荐值作用调参信号
find_card_contour缩放宽度480控制整体计算量牌面太小导致定位失败时加大到 640
Canny低/高阈值50 / 150控制边缘强度边缘断裂时低阈值降到 30,噪声多时高阈值升到 200
approxPolyDPepsilon0.02控制多边形逼近精度逼近结果不是四边形或顶点抖动时改成 0.01
adaptiveThresholdblockSize35局部阈值窗口大小字符断笔画时调小到 25,背景反光时调大到 51
connectedComponentsWithStats面积下限100过滤噪点连通域切出多余小块时提高到 200
matchTemplate置信度阈值0.6判定是否识别成功频繁误判时提高到 0.7,拒识率变高时降到 0.5

这组参数之间不是独立的。比如把approxPolyDP的 epsilon 从 0.02 改成 0.01,牌面轮廓定位更精准,但 Canny 产生的锯齿边缘容易被当成新的顶点,四边形逼近反而失败。所以我调参的顺序是固定的:先从 Canny 入手,保证边缘干净;再调 epsilon,保证四边形稳定;最后才调二值化和模板匹配的阈值。

4.3 一个最小可运行的识别脚本

下面这段代码是整套管线的最终落地形态,它可以跑在单张图片上,也可以直接跑在摄像头视频流上。这里给出的是单张图片版本,方便调试时一步步打印中间结果:

import sys if __name__ == "__main__": image_path = sys.argv[1] if len(sys.argv) > 1 else "./test_cards/7h.jpg" frame = cv2.imread(image_path) recognizer = CardRecognizer() result, status = recognizer.recognize(frame) print(f"识别结果: {result}") print(f"状态: {status}") # 调试时把中间结果拼在一张图里,一眼看清是哪一步出了问题 debug = np.hstack([ cv2.resize(frame, (160, 240)), cv2.resize(cv2.cvtColor(binary_card(frame), cv2.COLOR_GRAY2BGR), (160, 240)), cv2.resize(binary_card(four_point_transform(frame, find_card_contour(frame)[0])), (160, 240)) ]) cv2.imshow("debug", debug) cv2.waitKey(0)

调试代码用了np.hstack把原始帧、二值化结果、透视校正后的二值化结果横向拼接在一起。这一步在调参时非常重要:如果我看到第二块图里牌面和背景糊在一起,就说明二值化参数有问题;如果第三块图里数字区域切割明显偏了,就说明轮廓定位阶段出了问题。把中间结果可视化,能避免你在一个已经错误的流程上反复试参数,浪费大量时间。

5. 避坑与常见问题排查:这几个翻车点逐条拆解

5.1 预处理阶段的四个坑

第一个坑是背景复杂时找不到牌面轮廓。现象是find_card_contour返回None,程序直接退出了。原因多半是 Canny 的低阈值设得太高,把牌面和桌面之间那条对比度不强的边缘滤掉了;也可能是桌面本身有纹路,轮廓数量太多,面积排序后前五个候选里根本没有牌面。解决方法是先降低 Canny 低阈值到 30,再观察contours的前五项面积,如果面积都很小,说明边缘检测阶段就已经丢了目标,需要重新处理光照而不是调轮廓参数。

第二个坑是透视校正后图像出现严重的拉伸变形。现象是牌面变成扁长条或者宽高比明显不对。原因是four_point_transform里用了对角线欧氏距离的最大值作为目标宽高,但四个角点里混进了一个离群值,比如桌面的某个反光点也被当成顶点。解决方式是给approxPolyDP加一个限制条件:math.fabs(cv2.contourArea(approx) / peri) < 0.3,要求四边形不能太狭长;同时把面积排序的候选数量从 5 增加到 10,提高找到正确轮廓的概率。

第三个坑是花色区域和数字区域切反。现象是返回的rank认成了花色名,suit认成了数字。原因是split_rank_suit按面积从大到小排序时,牌面上数字是“1”,面积比花色小得多,于是第一大连通域是花色,第二是数字。解决方法是改用位置规则:在左上角区域里,先按 y 坐标排序,取上半部分作为数字(连通域中心 y 值小的优先),而不是按面积排序。这个坑在识别“1”的时候 100% 触发,是最容易踩的一个。

5.2 识别阶段的三个坑

第四个坑是黑桃和梅花互相误判。现象是黑桃 K 被识别成梅花 K。原因是在二值化后的图像里,黑桃的“柄”和梅花的三片叶子的轮廓数量不一样,但当你把整个花色区域缩放到 80×80 时,细节被抹掉,模板匹配分数接近,区分度不足。解决办法是把模板尺寸从 80×80 加到 120×120,同时把二值化后的 ROI 进行形态学膨胀两次,让花瓣的轮廓更完整,再做匹配。我在项目里用这个方法把黑桃/梅花的区分度从 0.7 附近拉到 0.85 以上。

第五个坑是 OCR 把“Q”识别成“O”或者“0”。现象是数字返回结果里出现O、0这类非法字符。原因是 Tesseract 的字符白名单没有限制到位,或者--psm参数选错了。解决方法是强制用--psm 10(单字符模式),同时白名单设为23456789TJQKA。如果还是不行,就用模板匹配做一次交叉验证:数字区域同时跑 OCR 和模板匹配,取置信度高的那个结果。

第六个坑是大王小王无法识别。现象是大小王经常被判定为“no_card”或者“split_failed”。原因是大小王牌面的左上角往往不是标准数字加花色结构,而是星形和皇冠符号,连通域的面积和形状都与普通牌不同。解决办法是单独检测:先判断整张牌的主色调,红色是红大王,黑色是黑小王;然后裁剪牌面中央区域,用专门的星形和皇冠模板匹配。不要试图把大小王塞进普通牌的识别流程里,那不是修 bug,是重新设计一个分支。

6. 把识别做扎实:归一化模板、批量验证与组合投票技巧

6.1 归一化模板库:让匹配不再依赖特定牌面大小

模板匹配最大的敌人是尺寸不一致。我在项目包里看到的模板文件尺寸各不相同,有的牌面来自 300px 宽度的校正图,有的来自 480px 宽度的截图。直接拿这些模板去匹配,分数会很低。解决方案是建立一套统一的模板生成流程:手动截取每张牌的数字区域和花色区域,统一缩放到 100×100 的灰度图,存成.npy数组,运行时一次性加载。这样做还有一个好处:你可以对不同摆放角度的牌各生成一张模板,匹配时对同一字符的多个模板取最高分。

def build_template_library(sample_images): library = {} for suit_name in ["h", "d", "s", "c"]: for rank_name in ["2", "3", "4", "5", "6", "7", "8", "9", "T", "J", "Q", "K", "A"]: key = f"{rank_name}{suit_name}" # sample_images[key] 是预先裁剪好的标准区域 normalized = cv2.resize(sample_images[key], (100, 100)) library[key] = normalized np.save("templates/normalized.npy", library) return library

模板库的维护注意点:匹配分数最高并不等于识别正确。当牌面被剪裁的位置偏移了 5 个像素,matchTemplate的分数会下降非常快,很容易误判成其他字符。所以我会在模板匹配之前加一道“重心对齐”的预处理。具体做法是:把数字区域二值化后找连通域,计算其重心坐标,然后平移图像让重心移动到中心。这个操作能把匹配分数从 0.5 以下直接拉到 0.9,效果立竿见影。

6.2 批量验证脚本:让识别结果不再是“感觉还行”

单张图片调试通过后,真正检验项目可靠性的方式是用一批测试图跑一遍,输出每张图的识别结果和耗时。项目包里有几十张抓拍的牌面图像,我写了一个简单的批量测试脚本,把所有测试图的结果汇总到一张表格里,自动标出哪些图识别失败:

import glob, time, json def batch_test(test_dir="./test_cards"): images = sorted(glob.glob(f"{test_dir}/*.jpg")) recognizer = CardRecognizer() report = [] for img_path in images: frame = cv2.imread(img_path) start = time.time() result, status = recognizer.recognize(frame) cost_ms = int((time.time() - start) * 1000) report.append({ "file": img_path, "result": result, "status": status, "cost_ms": cost_ms }) fail_count = sum(1 for r in report if r["status"] != "ok") total = len(report) print(f"总样本数: {total}, 失败: {fail_count}, 成功率: {(total-fail_count)/total:.1%}") with open("report.json", "w", encoding="utf-8") as f: json.dump(report, f, ensure_ascii=False, indent=2) return report

这个脚本的价值在于把“能跑”变成“可量化”。我见过很多从业者做完 Demo 后只说“效果还行”,但拿不出一个准确率数字。有了批量验证脚本,你就可以回答客户或者导师三个问题:识别成功率是多少?失败样本长什么样?当前耗时是否满足实时性要求?

6.3 组合投票与帧间稳态:单帧 90%,连续 5 帧 100%

如果需要稳定输出,而不是单张调试,组合投票是最有效的兜底方案。摄像头对着牌面时,连续 5 帧的识别结果不可能每一帧都错,利用这一点可以大幅提升可靠性:

from collections import Counter class StableRecognizer: def __init__(self, recognizer, buffer_size=5): self.recognizer = recognizer self.buffer = [] self.buffer_size = buffer_size def push_frame(self, frame): result, status = self.recognizer.recognize(frame) if status == "ok": self.buffer.append(result) if len(self.buffer) > self.buffer_size: self.buffer.pop(0) # 返回缓冲区内出现次数最多的结果 if len(self.buffer) == self.buffer_size: most_common = Counter(self.buffer).most_common(1)[0] return most_common[0], most_common[1] / len(self.buffer) return None, 0.0

这里的实现用了计数器的众数统计,连续 5 帧中至少 3 帧给出同样结果就采纳。如果 5 帧的结果全部不一致,就清空缓冲区,等待下一轮。这套机制还有一个隐藏好处:它天然过滤掉了手抖带来的模糊帧。如果某一帧因为运动模糊导致数字切偏了,那一帧的结果会被其他 4 帧的正确结果“投票”压下去,不会跳出一个奇怪的识别结果影响上层逻辑。

经过这套组合拳之后,单帧识别精度可能只有 85% 左右,但连续 5 帧投票后的准确率能稳定在 95% 以上。从那以后我每次写卡牌识别相关的项目,都会强制走一遍“模板归一化 + 批量验证 + 帧间投票”这三个流程。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询