[寒假项目] 用计算机视觉技术玩转桌游《德国心脏病》基于OpenCV模板匹配 Halli-Galli-Card-Detector项目手记

引言

这个想法最早是源自学长(手动@赵俊哲学长)带我们入坑的桌游《德国心脏病》(Halli-Galli-Game),在引入项目之前不妨稍稍了解一下其简单的游戏规则。(内心OS:原来还有这么有趣的桌游)

德国心脏病牌

游戏规则

参与游戏的所有玩家都会被平均分得一定数量的卡牌,每张牌上会出现不同种类和数量的水果。所有玩家不能注视自己的牌,而应随机地从自己的牌中抽取一张卡牌放在桌面上的指定位置,依次进行。当所有可见卡牌中的某一种水果总数为5或者5的倍数时请尽可能快的敲击铃铛,反应最快的玩家可以获得桌上所有的卡牌,当一名玩家没有卡牌时将被淘汰出局,坚持到最后的玩家为胜者。需要注意的有以下几点:

  1. 由于同一个玩家打出的卡牌在桌面上有遮挡关系,所以被遮挡的卡牌上的水果不计入总数。
  2. 以上只是最基础的玩法,目前市面上的牌组加入了稀奇古怪的道具牌,不在本文及本项目的讨论范围。

详细叙述项目之前我们先来整理一下思路,由于我也是CV方面的弱鸡一枚,具体算法跟应用背景全靠百度,实现全靠OpenCV,如果有大佬有更好的实现欢迎fork与交流。

下面的方法性能表现可能不佳,我随后会再用YOLOv3用不同的思路实现检测。

OpenCV思路

看看这副我斥巨资买来的德国心脏病实拍,第一个问题就是我们应该采用何种方式分割出每张卡片呢?
1605845316.jpg
在背景不那么复杂时,对初学者而言分割出图片中的矩形区域最好用的应该是cv2.findContours方法了。
cv2.findContours(image, mode, method[, contours[, hierarchy[, offset ]]])
顾名思义,findContours就是找边框,参数含义:

  • image-传入的二值图,请提前转成灰度再转为二值图
  • mode-可选宏变量:  
    cv2.RETR_EXTERNAL 表示只检测外轮廓

cv2.RETR_LIST 检测的轮廓不建立等级关系
cv2.RETR_CCOMP 建立两个等级的轮廓,上面的一层为外边界,里面的一层为内孔的边界信息。如果内孔内还有一个连通物体,这个物体的边界也在顶层。
cv2.RETR_TREE 建立一个等级树结构的轮廓。

  • method-可选宏变量:
    轮廓的近似办法

cv2.CHAIN_APPROX_NONE存储所有的轮廓点,相邻的两个点的像素位置差不超过1,即max(abs(x1-x2),abs(y2-y1))==1
cv2.CHAIN_APPROX_SIMPLE压缩水平方向,垂直方向,对角线方向的元素,只保留该方向的终点坐标,例如一个矩形轮廓只需4个点来保存轮廓信息
cv2.CHAIN_APPROX_TC89_L1,CV_CHAIN_APPROX_TC89_KCOS使用teh-Chinl chain 近似算法

模板匹配算法公式.png

返回两个值:contours:hierarchy。
contours是ndarray型,描绘了边框上点轮廓的集合。
hierarchy是可选返回值,其中的元素个数和轮廓个数相同,每个轮廓contours[i]对应4个hierarchy元素hierarchyi ~hierarchyi,分别表示后一个轮廓、前一个轮廓、父轮廓、内嵌轮廓的索引编号,如果没有对应项,则该值为负数。

具体的方法如果像我们这种自学弱鸡还是不要深究了,涉及到许多尚未接触到的数学与图形学知识,会用就好!
Talk is cheap, show me the code!
项目地址:https://github.com/MrZilinXiao/Halli-Galli-Card-Detector

预处理整幅图:

def preprocess_image(image):
    """Returns a grayed, blurred, and adaptively thresholded camera image."""

    gray = cv2.cvtColor(image,cv2.COLOR_BGR2GRAY)
    blur = cv2.GaussianBlur(gray,(5,5),0)
    img_w, img_h = np.shape(image)[:2]
    bkg_level = gray[int(img_h/100)][int(img_w/2)]
    thresh_level = bkg_level + BKG_THRESH #提前定义好的全局变量,这里取BKG_THRESH = 60
    retval, thresh = cv2.threshold(blur,thresh_level,255,cv2.THRESH_BINARY)
    return thresh

找边框:

def find_cards(thresh_image):
    """Finds all card-sized contours in a thresholded camera image.
    Returns the number of cards, and a list of card contours sorted
    from largest to smallest."""

    # 找边框并按边框面积大小逆序排序
    dummy,cnts,hier = cv2.findContours(thresh_image,cv2.RETR_TREE,cv2.CHAIN_APPROX_SIMPLE)
    index_sort = sorted(range(len(cnts)), key=lambda i : cv2.contourArea(cnts[i]),reverse=True)

    # 没有边框时返回空值
    if len(cnts) == 0:
        return [], []
    
    cnts_count = 0
    cnts_sort = []
    hier_sort = []
    cnt_is_card = np.zeros(len(cnts),dtype=int) #判断边框是否是卡牌的判断变量,先全部置0

    for i in index_sort:
        cnts_sort.append(cnts[i])
        hier_sort.append(hier[0][i])

    # 基于以下条件判断边框是否是卡片:
    # 1、比限定的最大的卡片小
    # 2、比限定的最小的卡片大
    # 3、没有父边框且有四个角落点
    # 如果找不到卡片请去掉1,2条件,Github中我去掉了,对不同分辨率的图片1,2条件需要另行修改

    for i in range(len(cnts_sort)):
        size = cv2.contourArea(cnts_sort[i])
        peri = cv2.arcLength(cnts_sort[i],True) #计算轮廓周长,True代表计算闭合曲线
        approx = cv2.approxPolyDP(cnts_sort[i],0.01*peri,True) #取近似轮廓
        
        if ((size < CARD_MAX_AREA) and (size > CARD_MIN_AREA)
            and (hier_sort[i][3] == -1) and (len(approx) == 4)):
            cnt_is_card[i] = 1
            cnts_count = cnts_count + 1

    return cnts_sort, cnt_is_card,cnts_count

代码写到这里,你应该发现了不对的地方,尽管我们算出了边框,但是OpenCV对在指定模板中找图像的方法有absdiff和matchTemplate,前者是在二值图中找出两个大小相同的图像简单的计算像素点的重合关系,但在《德国心脏病》中水果的位置没有固定的模式,这种方法没用;而matchTemplate则有一点机器学习的影子了:采用滑动窗口比较相似度,可以返回方形包围框,自然也可以用来计数,但是性能的话……难以用言语表述,先写着试试吧,性能不佳我稍后还会用其他方法复现我的思路。

预处理每张卡片:

def preprocess_card(contour, image): # just return a single card image
    """Uses contour to find information about the query card."""
    qCard = Query_card()

    qCard.contour = contour

    # 找边界并估计重心位置
    peri = cv2.arcLength(contour,True)
    approx = cv2.approxPolyDP(contour,0.01*peri,True)
    pts = np.float32(approx)
    qCard.corner_pts = pts

    # 找出卡片包围矩形
    x,y,w,h = cv2.boundingRect(contour)
    qCard.width, qCard.height = w, h

    # 找出中点坐标
    average = np.sum(pts, axis=0)/len(pts)
    cent_x = int(average[0][0])
    cent_y = int(average[0][1])
    qCard.center = [cent_x, cent_y]

    # 使用透视变换将卡的图像提取成200x300的直立图像
    qCard.warp = flattener(image, pts, w, h) # flattener函数详见Github
    return qCard.warp

下面就是核心的模板匹配代码了,由于同一张卡很可能出现多个同种水果,所以我们需要设定一个阈值进行多对象匹配,匹配效果不理想时可以尝试调整阈值。同时模板匹配不适用于旋转的图像,需要注意在flattener函数中进行透视变换处理。
从模板匹配滑动窗口的方式可以知道,模板中物体的大小必须要和原图中对应物体的大小相同,否则就无法正确找到。这要求我们在制作模板时首先将单张卡片缩放为需要的大小(本项目是200x300),再截取出需要的水果,这样就保证了匹配时模板和待匹配图案大小的一致。

def find_fruits_in_each_card(qCard, train_fruits_images):  # 0~3 ['yellow', 'red', 'green', 'purple']
    img = qCard.warp

    for i in range(0,4):
        h,w = train_fruits_images[i].img.shape[:2]
        res = cv2.matchTemplate(img, train_fruits_images[i].img, cv2.TM_CCOEFF_NORMED)
        locs = np.where(res >= 0.7)  # 准确度
        f = set()
        for pt in zip(*locs[::-1]):
            right_bottom = (pt[0] + w, pt[1] + h)
            cv2.rectangle(img, pt, right_bottom, (0, 0, 255), 2)
            sensitivity = 100
            f.add((round(pt[0]/sensitivity), round(pt[1]/sensitivity)))
        if i == 0:
            yellowCount = len(f)
        elif i == 1:
            redCount = len(f)
        elif i == 2:
            greenCount = len(f)
        elif i == 3:
            purpleCount = len(f)
            # 数水果
    cv2.namedWindow("Rect", cv2.WINDOW_NORMAL)
    cv2.imshow("Rect", img)
    return yellowCount, redCount, greenCount, purpleCount

由于我的摄像头还在路上,先用手机拍照缩放后进行了单张图片的测试,效果还行,如图。

demo.png

检测模块做完了,后面就是一些逻辑问题:其实对这个游戏检测是老大难的问题,逻辑就是水果数量是5的倍数时提示嘛,没啥要详细写的,各位在GitHub里的Detector.py里看就好了。

小总结

从Demo中可以看出,程序对倾斜的卡牌能够较好的识别,不过由于转为灰度图后的草莓与葡萄像素点比较相似,难以区分(可能也是我训练的模板太烂?)。而且这段程序仅支持纯色背景,背景稍微复杂就会错误地出现许多方框,给程序运行效率与准确度带来干扰,这是OpenCV找方框方法的不足造成的。

小问题

单幅图像上检测可能看不出来,但用于视频流检测时,前帧与后帧的检测个数一直在变化,对于这种试错需要代价的游戏(错误地按铃需要惩罚一张牌)简直就是噩梦,此外灰度图的模板匹配受光线影响极大,基本上要与训练集光线条件相同的环境才能取得较好的效果。

geili.jpg

老师啊,能不能再给力一点啊?

还记得博主拿去参加五粮春杯的目标检测嘛?有的人可能觉得玩个卡牌游戏上深度学习就是大炮打蚊子,但限于本人处理CV问题只会用库的水平,深度学习方法别说还真是挺实用的。寒假内我会争取再用YOLOv3-tiny训练一个可用的模型再实现一次本项目,敬请期待!

TODO LIST

  1. 目标检测算法再实现一遍
  2. 录个小视频展示成果
  3. 尝试整理思路用视频记录
Last modification:January 23rd, 2019 at 10:26 pm
If you think my article is useful to you, please feel free to appreciate

Leave a Comment