根据公司年会的要求,需要征集员工的照片制作笑脸照片墙,并且要用照片墙拼出一些图案。
在收照片之前,我给大家作出了标准示范,比如不能人太大,不能人太小,不能是背影,图片需要清晰,等等。
但是收集照片这种事情嘛,照片能收集齐了就谢天谢地了(最终收齐率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”:
我设定的蒙版规则是黑色表示镂空显示背景,白色表示填充显示照片,因为在程序中白色表示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 完成拼图
最终再把前面的环节都串起来就可以生成照片墙了!
整个程序的流程:
- 遍历文件夹符合格式的文件
- 创建随机列表生成器
- 选择蒙版图片
- 创建待布局矩形照片2点坐标迭代器
- 图片列表随机输出
- 读取记录文件
- 根据填充矩形转换人脸矩形区域比例
- 读图裁图缩图和贴图
- 生成唯一文件名并保存图片
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:
百亿:
流水线:
完整的代码包已经发在了CSDN,可以下载。其中包含图片示例,人脸位置已经标记完成,代码可以直接运行:
https://download.csdn.net/download/weixin_39804265/14969181
如有问题欢迎留言。