用Python标记识别人脸制作镂空图案的“笑脸”照片墙

在这里插入图片描述

根据公司年会的要求,需要征集员工的照片制作笑脸照片墙,并且要用照片墙拼出一些图案。

在收照片之前,我给大家作出了标准示范,比如不能人太大,不能人太小,不能是背影,图片需要清晰,等等。

但是收集照片这种事情嘛,照片能收集齐了就谢天谢地了(最终收齐率95%),全部照片符合要求是不太可能的。之后还要做后期的处理,比如将“人脸”的部分识别出来,只保留“笑脸”的部分。

一、收集照片

我使用微信的小程序“统计助手”收集照片,最后可以汇总导出Excel。照片不能直接导出,但是在Excel表格存储了超链接可以下载。

通过链接只能下载640像素宽度的缩略图,不过根据链接的格式很容易猜出原图的链接。写了一段程序就可以批量下载图片,并完成自动命名和分文件夹归类。

但是这篇文章的重点不是分析Excel的内容抓取和图片链接下载,所以怎么找照片就不赘述了。并且收集照片嘛,你手动收集也是一样的。
照片素材
总而言之,制作照片墙的条件是你先整来一大堆照片。

二、人脸捕捉

2.1 自动人脸识别

首先我尝试了Python的图像识别OpenCV库,使用自动识别的方法将人脸识别出来。

只是误识别率漏识别率感人。

实现代码参考:

import os
import cv2
import numpy as np

def imread(file): # 读取中文路径下的图片
    return cv2.imdecode(np.fromfile(file, np.uint8), -1)

def imwrite(file, im): # 写入中文路径下的图片
    cv2.imencode('.jpg', im)[1].tofile(file)

def MyWalk(path, exts=[]): # 遍历文件夹内符合格式的文件
    result = []
    for root, folders, files in os.walk(path):
        result += [os.path.join(root, file) for file in files if os.path.splitext(file)[1] in exts]
    return result

def SaveFaces(folder):
    os.makedirs(folder+'_face', exist_ok=1)
    for file in MyWalk(folder, ['.jpg', '.png']):
        fileroot = os.path.join(folder+'_face', os.path.splitext(os.path.basename(file))[0])
        img = imread(file)
        gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
        faces = face_cascade.detectMultiScale(gray, 1.5, 1, minSize=(50, 50))
        for j, (x, y, w, h) in enumerate(faces):
            img2 = img[y:y+h,x:x+w]
            imwrite('%s_%d.jpg'%(fileroot, j+1), img2)

folder = '表单统计'
xml = r'..\cv2\data\haarcascade_frontalface_default.xml' # 根据自身情况找一下找个文件的路径,通常在Python的对应库的目录下,没有的话也可以在网上下载
face_cascade = cv2.CascadeClassifier(xml)
SaveFaces(folder)

但是这个自动人脸识别有几个问题:

  • 识别的人脸框选范围太小,识别人物的辨识度不是很高,并且导致最终拼出来的图片导致整体都是黄色调,整体效果不佳

  • 误识别率和漏识别率太高,这种单位的活动通常都是重在参与,如果提交合格的照片最终却没在照片墙中展示。。那友谊的小船可是说翻就翻;

  • 并且有的人提交的是多人合影(比如抱着宝宝的),人工智能再智能也识别不出来“哪一个”是你需要的“人脸”啊。。

第一个问题或许还可以增加框选区域的范围来改善,但是还有后面的问题无法解决。

2.2 手动标记

不能人工智能,那就人工·智能,手动标记总是可以的。但是一张一张图片打开PS框选裁图我可不干,好几百张呢。而且要是领导不满意裁切效果,我这几百张脸不得从头裁一遍?(双关梗)

所以我需要一个自动化的工具,这个工具需要满足以下特性:

  • 手动选择裁切位置,但是鼠标点一下就能确定位置;
  • 裁切不直接切图保存,而是记录切图的坐标,减少存储空间,并且方便日后更改;
  • 如果有标记错误的坐标,可以容易操作修改、或者移除;
  • 自己写的图片浏览器不能自动缩放,所以要自己实现,并且获取鼠标位置的时候需要记录等效转换的坐标;
  • 遍历目标文件夹内的多个子文件夹内的所有符合格式的图片文件。

设计思路:

设计了一个MyPicture类,类属性包含当前图像、log文件路径、缩放系数、当前绘制矩形的4参数坐标,以及一些方法:

  • 键盘按键控制照片切换,按Esc退出(但未设置照片向前切换,因为懒)
  • 切换照片后自动检索是否存在记录矩形坐标log文件,等效坐标转换,并进行绘制
  • 鼠标左键按下记录起点坐标
  • 鼠标左键框选并在图片中实时预览
  • 鼠标左键抬起确认矩形终点坐标,等效坐标转换并转为整点形式,并创建同名的txt后缀的log文件
  • 鼠标右键按下删除log文件,并清空图片矩形绘图

还有一些其他琐碎的很容易看懂的功能,直接看代码吧:

实现代码:

import os
import cv2
import numpy as np

SCREEN_WIDTH  = 1900
SCREEN_HEIGHT = 900

def MyWalk(path, exts=[]): # 遍历目标文件夹内所有符合格式的文件
    result = []
    for root, folders, files in os.walk(path):
        result += [os.path.join(root, file) for file in files if os.path.splitext(file)[1] in exts]
    return result

def imread(file): # 读取中文路径下的图片
    return cv2.imdecode(np.fromfile(file, np.uint8), -1)

def imwrite(file, im): # 写入中文路径下的图片
    cv2.imencode('.jpg', im)[1].tofile(file)

class MyPicture:
    def SetPicture(self, file):
        self.log = os.path.splitext(file)[0] + '.txt'
        img0 = imread(file)
        h, w, n = img0.shape
        self.k = k = min(SCREEN_WIDTH/w, SCREEN_HEIGHT/h)
        self.img = cv2.resize(img0, (int(w*k), int(h*k)))
        self.ReadLog()

    def ReadLog(self):
        if os.path.isfile(self.log):
            with open(self.log) as f:
                self.rect = [int(float(x) * self.k) for x in f.read().split(',')]
        else:
            self.rect = [0, 0, 0, 0]
        self.DrawRect(self.rect, (255, 0, 0))

    def SaveLog(self):
        with open(self.log, 'w') as f:
            f.write(','.join(str(int(x / self.k)) for x in self.rect))

    def OnMouse(self, evt, x, y, flag, param):
        # print((evt, flag))
        if evt == 0 and flag == 1:
            self.OnLeftDraw(x, y)
        elif evt == 1:
            self.OnLeftDown(x, y)
        elif evt == 4:
            self.OnLeftUp(x, y)
        elif evt == 2:
            self.OnRightDown()

    def OnLeftDraw(self, x, y):
        rect_temp = self.rect[:2] + [x, y]
        self.DrawRect(rect_temp, (0, 255, 0))

    def OnLeftDown(self, x, y):
        self.rect[:2] = [x, y]

    def OnLeftUp(self, x, y):
        self.rect[2:] = [x, y]
        self.DrawRect(self.rect, (255, 0, 0))
        self.SaveLog()

    def OnRightDown(self):
        self.DrawRect((0, 0, 0, 0), (255, 0, 0))
        if os.path.isfile(self.log):
            os.remove(self.log)

    def DrawRect(self, rect, bgr):
        img2 = self.img.copy()
        cv2.rectangle(img2, tuple(rect[:2]), tuple(rect[2:]), bgr, 2)
        cv2.imshow('lsx', img2)

folder = '表单统计'

pic = MyPicture()
cv2.namedWindow('lsx')
cv2.setMouseCallback('lsx', pic.OnMouse)

for filename in MyWalk(folder, ['.jpg']):
    print(filename)
    pic.SetPicture(filename)
    if 27 == cv2.waitKey(0): # Esc to quit.
        break
cv2.destroyAllWindows()

由于我征集的照片中要求每张照片中只有一个主体,我只需要在一张照片中圈出至多一个人脸(如果照片不符合要求则是0张人脸),所以我只在log文件中记录了一个矩形的坐标。
识别人脸(手动)
不过如果想要圈出多张人脸也是可以的,自己改一改代码就好啦。

最终180张人脸大概几分钟就圈完了吧?我还检查了几遍。

三、照片墙拼图

3.1 随机队列

制作公司的照片墙和不同于网上随便找来的照片,需要保证每一个提交合格照片的参与者都能上墙

但是如果按顺序排列又会降低观感和娱乐性,所以需要找到一种可以保证所有照片都能上墙,但是又有一定随机性的打乱方法。

那么很显然,就是random.shuffle方法了,此方法可以将列表打乱。从列表中逐一取出元素不放回,列表取空后重置并再次打乱即可。

我写了一个MyList类来实现此功能,其中成员属性li记录了待取出数组的备份,属性方法pop实现了从打乱了的列表中取出一个元素不放回,并且取空重置且打乱。

class MyList:
    def __init__(self, li):
        self.li  = li[:]
        self.li2 = []

    def pop(self):
        if not self.li2:
            self.li2 = self.li[:]
            random.shuffle(self.li2)
        return self.li2.pop()

但是有的照片墙中的拼图“像素数”较少,收集的照片多于可用的“像素位置”,那有什么办法能解决呢。。果不其然,有同事向我发出了质疑:
聊天记录
那当然是没有办法解决了,但是取出不放回的pop方法可以保证在多张拼图中所有的照片都能够被展示到

3.2 计算四点坐标的迭代器

最终投射的大屏幕分辨率是1920×1080,也就是16:9的比例。很显然,布置成为16×9的照片墙是很容易的,但是有些时候16×9的像素格子并不方便拼出目标图案,需要增加或减少“像素数”。

比如19×7,但是1920不能整除19,1080也不能整除7。

如果每个“像素”的宽度取1920/19的整数值(101),高度取1080/7的整数值(154),又会导致多个像素拼满全图后,整体的宽度不足铺满整个屏幕(101×19=1919,154×7=1078)。

所以我写了一个迭代器,以近似的方式计算出平铺屏幕后各像素格的最接近矩形尺寸:

def PositionIter(width, rows, cols):
    for r in range(rows):
        y1 = int(height/rows*r)
        y2 = int(height/rows*(r+1))
        for c in range(cols):
            x1 = int(width/cols*c)
            x2 = int(width/cols*(c+1))
            yield x1, x2, y1, y2

3.3 矩形比例转换

屏幕被分割成了像素网格状,每一块“像素”都是正方形或者长方形,由于裁切整除的问题,每一块“像素”的长宽比例可能都是不完全相同的

并且在2.2节手动标记的人脸范围各不相同,如果裁切矩形和目标格子的长宽比例基本一致还好,拉伸填充不会产生太大的违和感。但是如果原图比较细长,但却要填到方形的格子里;或者是原本正方形的裁切区域,被填充到了细长条的格子里,那违和感就很严重了。

为了尽可能减少比例变形的失真,我首先根据3.2节的迭代器计算出目标格子的长宽,然后读取2.2节中标记人脸log文件的矩形坐标,在基本保证原有裁切风格的前提下,将裁切范围的长宽比例替换为目标格子的长宽比例

一张图片的裁切比例转换有多种的方式,比如扩大裁切、缩小裁切、保证面积不变裁切、保证周长不变裁切

我这里采用的是保证周长不变裁切,举例来说比如一个原本20×10的方框,可以替换为18×12的方框,被裁切方框的长宽之和保持不变。

实现代码:

def ConvertRect(rect=(10,20,210,120), wh=(200,100)):
    x1, y1, x2, y2 = rect          # 原方框
    x0, y0 = (x1+x2)/2, (y1+y2)/2  # 中心
    w, h = wh                      # 目标长宽比
    # 等周长变换
    L = abs(x1-x2)+abs(y1-y2)      # 周长
    w1, h1 = L*w/(w+h), L*h/(w+h)  # 新长宽
    # 返回新方框
    return (max(0,int(x0-w1/2)), max(0,int(y0-h1/2)),
            int(x0+w1/2), int(y0+h1/2))

但是这里存在一个问题,经过转换的矩形坐标是有可能超出图像的边界范围的。裁切到图像范围之外的部位,不会像PS软件一样自动填充背景色。

作为简单的处理,我将坐标越界(负数值)的部分统一设定为边界值(零)。但是这样导致裁切出的图像不符合待填充位置的长宽比例,后面拉伸填充会造成图片变形。

更合理的方式是首先满足“周长不变”的转换条件,然后进行缩图,直到裁切边缘不会超出原图的范围为止。

不过我懒得改了,超出边缘的情况也比较少,我就不适配了。

3.4 蒙版图片

照片墙中的“像素”数量并不是越多越好,如果画面中的“像素”数太多,照片墙重复的照片就会更多;如果“像素”数太少,那么一张照片墙中上墙的照片人数太少。

如果达到最理想的效果,一张照片正好用完所有的照片是最合适的。(或者你的照片很多很多,通过几张拼图把照片全部用完也是可以的)

我这里有180张照片,分解一下即为宽高18×10像素。18×10是非常小的画布,直接打开图画板就可以创作了。为了避免眼睛看瞎,可以把图画板放大到最大倍数再用铅笔创作。

比如拼一个“666”:
蒙版_666
我设定的蒙版规则是黑色表示镂空显示背景,白色表示填充显示照片,因为在程序中白色表示255(是),黑色表示0(非)。当然如果你觉得看着难受,在逻辑里反过来也是一样的。

接下来读取蒙版图片,在3.2节的函数迭代输出前,判断当前输出行列的对应蒙版图片像素是否为黑色,如果是则跳过,否则产生迭代输出,进行下一步运算。

修改3.2节的四点坐标迭代器函数,增加读取蒙版图片作为输入,读取蒙版图片的宽高并作为目标输出拼图的行数和列数。

实现代码:

def PositionIter(width, height, mask):
    mask = imread(mask)
    rows, cols, _ = mask.shape
    for r in range(rows):
        y1 = int(height/rows*r)
        y2 = int(height/rows*(r+1))
        for c in range(cols):
            x1 = int(width/cols*c)
            x2 = int(width/cols*(c+1))
            if mask[r][c][0]:
                yield x1, x2, y1, y2

需要注意的是,我不能先平铺铺满18×10的阵列,然后再将图案盖住已有的照片,因为这样将导致被遮住的图片无法保证一定在其他位置出现过

所以在迭代器中跳过需要留白的“像素”,不产生迭代输出。这样已有的照片就不会被显示的图案“挡住”,才能保证每一张参与者提交的照片都能出现在照片墙中。

3.5 读写中文路径图片

OpenCV默认不能读取和保存中文路径下的图片,借助numpy库可以实现在中文路径存取:

def imread(file):
    return cv2.imdecode(np.fromfile(file, np.uint8), -1)

def imwrite(file, im):
    cv2.imencode('.jpg', im)[1].tofile(file)

3.6 遍历文件夹内的图片

当使用2.2节中的人脸识别标记后,文件夹内会自动生成txt格式的log文件,如果再用os.listdir或os.walk函数遍历文件夹,还要排除不符合图片格式的文件。这里写了一个方法可以方便遍历文件夹内符合格式的文件:

def MyWalk(path, exts=[]):
    result = []
    for root, folders, files in os.walk(path):
        result += [os.path.join(root, file) for file in files if os.path.splitext(file)[1] in exts]
    return result

3.7 唯一文件名控制器

每张图片的布局都是随机生成的,每一次的布局就像猴子敲出的莎士比亚短诗一样可遇而可不求,直到曾经文件被覆盖的时候才悔不当初。

为了避免不小心文件重复命名把以前的文件抹掉,并且方便多图片的批量生成,设计了一个避免文件名重复的封装函数:

def UniqueFile(file):
    root, ext = os.path.splitext(file)
    cnt = 1
    while os.path.exists(file):
        file = '%s_%d%s'%(root, cnt, ext)
        cnt += 1
    return file

3.8 完成拼图

最终再把前面的环节都串起来就可以生成照片墙了!

整个程序的流程:

  1. 遍历文件夹符合格式的文件
  2. 创建随机列表生成器
  3. 选择蒙版图片
  4. 创建待布局矩形照片2点坐标迭代器
  5. 图片列表随机输出
  6. 读取记录文件
  7. 根据填充矩形转换人脸矩形区域比例
  8. 读图裁图缩图和贴图
  9. 生成唯一文件名并保存图片
def MakePictureWall(files, mask, bg_color=(255,255,255)):
    img_all = np.zeros((height, width, 3), np.uint8)
    img_all[:,:] = bg_color # 填充背景颜色
    for x1, x2, y1, y2 in PositionIter(width, height, mask): # 生成可分配像素位置
        w = x2 - x1
        h = y2 - y1

        log = ''
        while not os.path.isfile(log): # 跳过不存在对应log文件的jpg图片
            file = files.pop() # 随机选取照片
            log = os.path.splitext(file)[0] + '.txt'

        img = imread(file)
        with open(log) as f:
            rect1 = [int(x) for x in f.read().split(',')]

        x1c, y1c, x2c, y2c = ConvertRect(rect1, (w,h)) # 按可分配方框调整原方框大小
        img_crop = img[y1c:y2c,x1c:x2c] # 裁切图片
        img_crop_s = cv2.resize(img_crop, (w,h), interpolation=cv2.INTER_CUBIC) # 缩小图像
        img_all[y1:y2,x1:x2] = img_crop_s

    p = '_' + os.path.splitext(os.path.basename(mask))[0]
    path = UniqueFile(folder+p+'.jpg')
    imwrite(path, img_all)

if __name__ == '__main__':
    width  = 1920
    height = 1080
    folder = '表单统计'
    files = MyList(MyWalk(folder, ['.jpg']))
    MakePictureWall(files, 'mask_666.png')

四、最终效果

最后列举几个生成的例子,不过为了保护个人隐私,我就不使用同事们的照片了。这些网上找到的照片,标记人脸之后生成的照片墙:

666:
照片墙_666

百亿:
照片墙_百亿

流水线:
照片墙_流水线

完整的代码包已经发在了CSDN,可以下载。其中包含图片示例,人脸位置已经标记完成,代码可以直接运行:

https://download.csdn.net/download/weixin_39804265/14969181

如有问题欢迎留言。

在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/weixin_39804265/article/details/113094736