我们经常在网上看到很炫酷的词云,有没有想过自己来实现生成一个词云呢?
看到词云时的所想所思?
当第一眼看到词云时,基于程序员的条件反射,一个大大的「How」字浮现在眼前:它是如何实现的?
通过仔细的观察,又发现一些现象:词语无重叠,而且词语之间的空隙被更小的词语所填满,并且以有趣的姿势交错在一起。
考虑了一会儿,我知道我又一次进入了未知领域,以我所知的方法,似乎没有一个能够轻易解决这个问题。
问题的关键所在?
首先,在图片上绘制文字对于Python来说肯定不是难事,
同样的,设置文字为不同颜色或不同大小也应该不是难事。
文字的位置、颜色、大小的随机也很简单,random 库就能实现。
那就只有一个关键的问题了,要如何做到词语之间紧密排布但又没有重叠呢?
初级试探?
假设先在一张白色的图片 A 上放一个黑色的词语 A,又在另一张同样大小的白色的图片 B 上放一个词语 B,把这两张图片进行对比,如果重叠,就把词语 B 换一个地方,重新对比;不重叠时,记下这个位置,词语 B 放到到图片 A 上……
图片是由一个一个像素组成的,比如一张 800 × 600 的图片,说明它是由 800 × 600 = 48万 个像素组成。每个像素中保存了一个颜色值,通过对比图片 A 和 B 的每一个像素,如果相同位置上,A 和 B 的像素都为黑色,那就意味这两个词语有重叠。
虽然还有一些不清楚的地方,不过这确实是一个可行的方案。
开始?
我们知道 python 有一个 wordcloud 库可以生成词云。
那要开始介绍 wordcloud 库吗?
是也不是。我更关心的是 wordcloud 库如何解决词语之间紧密排布但又没有重叠这一关键问题?
Wordcloud 探秘
从 Github 上可以轻易的下载到该库的代码。从入口函数开始,粗略的追踪了几个函数的调用,有几个地方引起了我的注意:
- 它使用了 Pillow 库,搜索发现这是一个 python 图像处理库。
- 它在一个
for word, freq in frequencies
循环中,将单词逐个放到图片上。 - 这个循环中,除了对字体进行设置外,还调用了一个 sample_position 方法来寻找放置的位置(没错,这就是问题的关键所在)。
- sample_position 很简单,它的实现在 query_integral_image.pyx 文件中,仅有 30 多行,它从图片左上角第一个像素开始,遍历所有像素,检查各个位置能否放置。integral_image指的是积分图像。
图像处理一角?
积分图?听起来似乎很复杂,但理解起来却特别简单。首先,积分图不是一张真实的图片,接下来……
我们还是来看图吧,像素值即该像素点有图则为1,否则为0:
如果点击最左边的图中的某个像素位置,就可以绘制图形。
积分图中,每个单元的值,等于原图此位置左上角所有像素值之和(橙框的值 = 蓝框中所有值之和)。
这个性质,能够帮助我们快速判断一个区域内有没有内容。为什么这么说呢?
快速重叠检测?
如果一个矩形区域内没有内容,说明这个区域内所有像素值之和为零。
根据积分图的特征,下图中,我们可以进行如下计算:
用大矩形(绿色)所有像素值之和,减去该大矩形中上方(青色)和左侧(紫色)两个矩形像素值之和(同时包括左上角的橙色的小矩形),所以再加上左上角小矩形(橙色)像素值之和,就得到了所求蓝色区域内像素值之和。
大矩形(绿色): 6
左侧矩形(紫色): 0
上方矩形(青色): 1
左上矩形(橙色): 0
目标矩形(蓝色): 6 - 0 - 1 + 0 = 5
这样,只需要进行 四次取值和一次运算 就能够判断某区域是否为空,比逐个像素检测快很多。
由于每个词语都能够被框在一个矩形中(宽度为 w,高度为 h),我们只需要对图片每个位置 (x,y) 进行计算,如果 (x,y) 到 (x + w - 1, y + h - 1) 这个矩形区域内没有内容,就能够放置这个词语。
至此,我们已经掌握了词云的第一个关键点:重叠检测方法。
词云布局方法?
我们已经能够判断新放置的词语是否和其它词语重叠,接下来的问题就是选择一种策略来放置新词语。
wordcloud 库使用了一个非常简单的随机布局方法:
- 从左上角第一个像素开始,判断在这个位置放置新词语会不会与已放置的内容重叠;
- 如果不重叠,将这个位置添加一个列表 list 中;
- 遍历图片所有像素,将所有可放置的位置都加到 list 中;
- 从 list 中随机选择一个位置放置这个词语。
这样的方法推翻了我们之前的观测,wordcloud 并没有做任何工作来保证词语之间紧密排布。但这并不困难,只要我们换一种方法:
- 选择一个初始位置 P;
- 对每一个单词,从位置 P 开始,稍微移动一点点距离,看能不能放下,如果不能,再稍微移动一点距离,直到能够放下。
不错,就是简单的贪心策略。
试着这样实现?
import random
import math
import numpy as np
from PIL import Image
from PIL import ImageDraw
from PIL import ImageFont
def find_position(img,size_x,size_y):
#返回给定轴上的累积和numpy.cumsum(a,axis = None,dtype = None,out = None )
#0为纵轴,1为横轴
#assarray()创建ndarray数组对象
integral = np.cumsum(np.cumsum(np.asarray(img),axis=1),axis=0)
for x in range(1,800-size_x):
for y in range(1,600-size_y):
#左上角小矩形+大矩形(矩阵行对应y,列对应x)
area = integral[y-1,x-1] + integral[y+size_y-1,x+size_x -1]
#减去左侧矩形和上方矩形
area -= integral[y-1,x+size_x-1] + integral[y+size_y-1,x-1]
#area为0,返回(x,y)
if not area:
return x,y
#返回为空
return None,None
#循环将词语绘制到图像上
def main():
#词语素材
words = ['作者','高度','浓缩','中国','西北','农村','历史',\
'变迁','过程','作品','思想','艺术','高度','统一','主人公','面对','困境','艰苦','奋斗','精神']
#新建画布对象,Image.new(mode,size,color=None)
img = Image.new('L',(800,600))
#新建画布绘画对象
draw = ImageDraw.Draw(img)
for word in words:
#字体大小随机
font_size = random.randint(50,150)
#字体为黑体
font = ImageFont.truetype('C:\Windows\Fonts\SIMHEI.TTF', font_size)
#计算文字矩形框的大小(宽x,高y)
box_size = draw.textsize(word,font=font)
#图像上找出文字放置的位置
x,y = find_position(img,box_size[0],box_size[1])
#存在x,则将词语放置在(x,y)处
if x:
draw.text((x,y),word,fill="white",font=font)
img.show()
if __name__ == '__main__':
main()
至此,我们已经理解 wordcloud 生成词云的基本原理。
明显的缺点?
当词语数量增多时,运行代码,久久未能得出结果。我意识到生成词云是一个需要极大计算量的工作。
wordcloud 使用了很多使代码变得难以理解的优化策略,并且将 find_position 函数转用 Cython 实现(转换为 C 语言)也在很大程度上说明了这个问题。
速度问题暂且不提,毕竟生成词云不是我的日常工作。但是通过实现上面的算法,我还发现另一个问题:矩形检测的积分图算法似乎不能很好的支持文字的旋转。
这件事很好理解,如果将一个单词旋转一定角度,那么它的外接矩形面积必然会比不旋转时候大,这就需要更大的矩形区域来放置这个词语,导致很多实际可以放置的位置却不能放置。
那么,有没有更好的策略呢?
更好的策略——四叉树
在 Wordcloud 算法中,每放置完一个词语,就需要重新计算一次积分图,下一个词语需要与整张图片进行重叠检测。
那有没有可能将每个词视为单独的实体,在放置新词时,检测它与其它每一个词语有没有重叠?
显然应该有。
有一种叫做 层次边界框(Hierarchical bounding boxes)的方法来快速实现两个词语间的重叠检测。
层次边界框这个词太拗口,我们换用「四叉树」来代指这种结构,它本质上也是一棵记录空间信息的四叉树。
四叉树的构建并不困难,将图片横纵各切一刀,平均分割为「左上、左下、右上、右下」四个区域,如果某个区域中有内容(此时可以用积分图算法判断),那么继续将这个区域分割为四个部分,直到区域的大小小于某个值。
四叉树每深一层,对形状的描述就越精确,每一次分隔,都能排除一些空白矩形区域,剩下的有像素的区域,都记录到了树中。
四叉树每深一层,对形状的描述就越精确,每一次分隔,都能排除一些空白矩形区域,剩下的有像素的区域,都记录到了树中。
后续内容等待更新