使用Depix进行马赛克的消除测试

02 | 03 | 2021

0. 前言

最近看到各种公众号都在推一个叫Depix的Github项目,用途是能够消除文字马赛克。
太长不看版:公众号有一定程度的夸大,但是还是要注意个人隐私的保护。

项目地址:https://github.com/beurtschipper/Depix

项目自带的Example如下:

这个项目的文档上说,只需要马赛克后的图像,马赛克图像上包含的字符的De Bruijn序列,就可以生成去马赛克的图像。(测试效果如上图所示)

接下来就测试一下。

1. 准备工作

1.1 环境搭建

首先,安装Python3和PIP3。

我这里在linux云端进行的测试,测试的Python环境是Python3,安装过程这里不再赘述。

运行项目需要环境pillow和image,输入命令使用pip进行安装:

1
pip3 install pillow
pip3 install image


如果下载速度过慢,则需要更改为国内源再测试。

1.2 例程运行

为了检测运行效果,先执行软件自带的例程进行测试:

1
python3 depix.py -p images/testimages/testimage3_pixels.png -s images/searchimages/debruinseq_notepad_Windows10_closeAndSpaced.png -o output.png


测试成功的话,同目录下会出现output.png,如果能正常打开并看到如上图所示的Hello from the other side,说明测试通过。

如果报错No Module named xxx,则说明运行环境没搭建好,需要先搜索缺少的module在哪个包里,再使用pip下载缺少的包。

2. 实际测试

按照项目网站上的说明,要去除文字上的马赛克,需要做如下准备:

  1. 将待解码图片中的马赛克部分单独截取下来作为一个单独的矩形图片。
  2. 生成一个De Bruijn sequence (德布鲁因序列),并使用该序列生成一张和待解码图片中的马赛克部分完全一致的字体、大小的图像。(这里后续详细说明)
  3. 调用脚本解马赛克。

详细说明如下。

2.1 待解码图片准备

在这里我们使用记事本截图+某聊天软件自带的马赛克功能。

马赛克的模糊度调低点,保证正好把文字全抹掉。

效果如下所示:

马赛克前:

马赛克后:

文字内容是998877665544。

2.2 De Bruijn sequence (德布鲁因序列)

2.2.1 德布鲁因序列介绍

德布鲁因序列,一般有两个属性:元素和阶。

元素:就是这个序列中有x个不同的字符。

阶:在x个不同的字符中抽出y个进行组合,这个y就是阶

如果某个x元素y阶的德布鲁因序列,把这个序列头尾相接。那么,在x中抽出y个字符组合出来的字符串都能在这个序列里找到,且只能找到一次。

Example: 只有3个元素(abc)2阶的德布鲁因序列为:a a b a c b b c c (用下文的在线生成器生成的序列,会在序列尾部增加一个和序列头部相同的字符) 把序列首尾相接组成一个环,在abc中任意抽出2个字符组成一个字符串,都能在这个序列环里找到,且只能找到一次。

2.2.2 德布鲁因序列生成

生成网站:https://damip.net/article-de-bruijn-sequence

只要输入我们要生成的序列中包含的字符和长度,就可以生成对应的序列。

在这里按照公众号介绍的算法原理,只需要生成2阶(Code length: 2)的序列就可以了。

(在这里生成的是3阶)

由于我们测试的马赛克字符中只有数字,那么Alphabet一栏只需要输入所有数字1234567890就好。

生成的序列如下:

注: x字符y阶的德布鲁因序列长度为:len=x^y。 即:10字符的3阶德布鲁因序列长度为10^3=1000,这里在线生成器为了实现和头尾相接相同的效果增加了y-1字符的长度,即1002

2.2.3 德布鲁因序列图像制作

得到了德布鲁因序列之后,就需要将序列转换成和待解码文字相同样式(字体大小颜色等,甚至最好使用一样的编辑器和截图工具)的文字图片。

由于待解码图像是记事本上的文字,因此我们只需要在同一个记事本上粘贴上述序列并截图即可。

生成的图像如下:

2.3 实际解码

2.3.1 解码命令

假设保存的德布鲁因序列图像名字为search.png,待解码图像名字toFind.png。

那么解码命令如下所示:

1
python3 depix.py -p toFind.png -s search.png -o op.png


2.3.2 解码效果分析

解码效果如下:

效果没公众号吹得那么厉害,只看到了第一个字符的一部分。

但是经过测试,多次解码后可以看到更多的数据。

将第一次生成的文件再次进行解码后效果如下:

写一个脚本进行循环解码:

1
2
3
4
5
6
7
#/bin/bash
for I in {1..50};do
    let op=$I+1
  python3 depix.py -p op$I.png -s search.png -o op$op.png
done
echo done.

解码51次后的效果:

看来算法还是有一定效果的。

但是多次解码后,输出的图像和这次的图像相差无几,说明这就是算法的极限了。

2.4 简单分析

首先我们打开主要的脚本文件depix.py。从代码上看,函数需要三个参数:待解码图片、德布鲁因序列图片和输出图片,并把待解码图片、德布鲁因序列图片的路径分别赋给pixelatedImagePath、searchImagePath 两个变量:

1
2
3
4
5
6
7
parser = argparse.ArgumentParser(description = usage)
parser.add_argument('-p', '--pixelimage', help = 'Path to image with pixelated rectangle', required=True) #待解码图片,必须
parser.add_argument('-s', '--searchimage', help = 'Path to image with patterns to search', required=True) #德布鲁因序列图片,必须
parser.add_argument('-o', '--outputimage', help = 'Path to output image', nargs='?', default='output.png') #输出图片,非必须,默认为output.png
args = parser.parse_args()
pixelatedImagePath = args.pixelimage
searchImagePath = args.searchimage

由于已经知道了该方法的原理是通过将德布鲁因序列图片用相同的形式打码,之后再和原图进行比对,找出相同的图块,那么只要我们跟踪这个序列图片做了什么操作,就可以知道这个算法的适用范围。

跟踪searchImagePath这个变量,发现其在下文中作为LoadedImage函数的参数,将图片载入给了变量searchImage,而searchImage这个变量的首次调用是在findRectangleMatches函数中:

1
2
3
4
5
logging.info("Loading search image from %s" % searchImagePath)
searchImage = LoadedImage(searchImagePath) # 载入德布鲁因序列图像
# 省略
logging.info("Finding matches in search image")
rectangleMatches = findRectangleMatches(rectangeSizeOccurences, pixelatedSubRectanges, searchImage) #首次调用

这个调用它的函数在depixlib文件夹下的functions.py,定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
def findRectangleMatches(rectangeSizeOccurences, pixelatedSubRectanges, searchImage):
    rectangleMatches = {}
    for rectangeSizeOccurence in rectangeSizeOccurences:
        rectangleSize = rectangeSizeOccurence
        rectangleWidth = rectangleSize[0]
        rectangleHeight = rectangleSize[1]
        pixelsInRectangle = rectangleWidth*rectangleHeight
        # logging.info('For rectangle size {}x{}'.format(rectangleWidth, rectangleHeight))
        # filter out the desired rectangle size
        matchingRectangles = []
        for colorRectange in pixelatedSubRectanges:
            if (colorRectange.width, colorRectange.height) == rectangleSize:
                matchingRectangles.append(colorRectange)
        for x in range(searchImage.width - rectangleWidth):
            for y in range(searchImage.height - rectangleHeight):
                r = g = b = 0
                matchData = []
                for xx in range(rectangleWidth):
                    for yy in range(rectangleHeight):
                        newPixel = searchImage.imageData[x+xx][y+yy]
                        rr,gg,bb = newPixel
                        matchData.append(newPixel)
                        r += rr
                        g += gg
                        b += bb
                averageColor = (int(r / pixelsInRectangle), int(g / pixelsInRectangle), int(b / pixelsInRectangle))
                for matchingRectangle in matchingRectangles:
                    if (matchingRectangle.x,matchingRectangle.y) not in rectangleMatches:
                        rectangleMatches[(matchingRectangle.x,matchingRectangle.y)] = []
                    if matchingRectangle.color == averageColor:
                        newRectangleMatch = RectangleMatch(x, y, matchData)
                        rectangleMatches[(matchingRectangle.x,matchingRectangle.y)].append(newRectangleMatch)
            # if x % 64 == 0:
            #   logging.info('Scanning in searchImage: {}/{}'.format(x, searchImage.width - rectangleWidth))
    return rectangleMatches

从代码中的xy循环中,我们可以看出该函数将德布鲁因序列图像划分成了rectangleWidth * rectangleHeight 大小的小方块,并对小方块中的颜色取平均值。这就是项目中提到的linear box filter马赛克。

简单说明一下,linear box filter马赛克的打码方式是:

  1. 1. 将待打码的图像按照像素分割成一系列的正方形小区域
  2. 2. 将每块区域的所有像素点的颜色取平均值,得出每个区域的平均颜色
  3. 3. 使用每块区域的平均颜色填充这个区域,实现马赛克效果 因此,如果每一块区域的面积越大,图像就越模糊,反之则越清晰

接下来我们来看这个rectangleWidth * rectangleHeight是怎么得出的。

取消下面代码的注释。

1
logging.info('For rectangle size {}x{}'.format(rectangleWidth, rectangleHeight))

再次运行后,发现这里输出了待解码图片中的每一个马赛克方格的大小,说明在解马赛克的过程中,该脚本会计算出待解码图片中每个马赛克方格的大小,再根据这个大小对德布鲁因序列图片进行马赛克处理。

输出结果:

使用画图计算出的马赛克大小:

至于这个像素大小的计算方式,需要向上追溯到对待解码图片的处理逻辑。

脚本读取了待解码图片后,首先会先查找所有的相同颜色的矩形方块,并将找到的同色矩形方块存入list:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
pixelatedImage = LoadedImage(pixelatedImagePath) # 待解码图片读取
#...
pixelatedSubRectanges = findSameColorSubRectangles(pixelatedImage, pixelatedRectange) #同色矩形区域寻找
# ...
def findSameColorSubRectangles(pixelatedImage, rectangle):  #函数定义,在functions.py中
    sameColorRectanges = []
    x = rectangle.x
    maxx = rectangle.x + rectangle.width + 1
    maxy = rectangle.y + rectangle.height + 1
    while x < maxx:
        y = rectangle.y
        while y < maxy:
            sameColorRectange = findSameColorRectangle(pixelatedImage, (x, y), (maxx, maxy))
            if sameColorRectange == False:
                continue
            # logging.info("Found rectangle at (%s, %s) with size (%s,%s) and color %s" % (x, y, sameColorRectange.width,sameColorRectange.height,sameColorRectange.color))
            sameColorRectanges.append(sameColorRectange)
            y += sameColorRectange.height
        x += sameColorRectange.width
    return sameColorRectanges

接下来,程序会移除掉待解码图片里的无用色块:

1
2
3
4
5
6
7
8
9
pixelatedSubRectanges = removeMootColorRectangles(pixelatedSubRectanges) #移除无用色块

def removeMootColorRectangles(colorRectanges):   #函数定义
    pixelatedSubRectanges = []
    for colorRectange in colorRectanges:
            if colorRectange.color in [(0,0,0),(255,255,255)]:  #如果颜色是纯白/纯黑,跳过该色块
                continue
            pixelatedSubRectanges.append(colorRectange)   #将色块附加到pixelatedSubRectanges这个list中
    return pixelatedSubRectanges

从这里看出,程序对无用色块(背景色)的判断逻辑是,只要是纯白/纯黑,就会跳过。这里得出两个推论:

  1. 该算法在纯色的背景下表现较好
  2. 如果需要在其他背景色下解马赛克,需要在这里修改对应背景色的RGB值

接下来,从色块中找出每个方块的大小的出现次数:

1
2
3
4
5
6
7
8
9
def findRectangleSizeOccurences(colorRectanges):
    rectangeSizeOccurences = {}
    for colorRectange in colorRectanges:
        size = (colorRectange.width, colorRectange.height)
        if size in rectangeSizeOccurences:
            rectangeSizeOccurences[size] += 1
        else:
            rectangeSizeOccurences[size] = 1
    return rectangeSizeOccurences

这里的方块大小,就是前面findRectangleMatches中对德布鲁因序列处理的方块大小。

接下来的处理逻辑就是对德布鲁因序列图片打码,再对各种色块进行匹配的流程,后续再进一步分析。

3. 总结

后续再次对去马赛克效果进行多次测试,发现该脚本的适用范围是有限的。

从测试结果和算法上来看,这个算法有如下的局限性。

  1.  这个算法的原理是将德布鲁因序列图用相同的马赛克形式进行打码,之后再将打码的序列图像和待解码图像进行对比,查找可能的文字序列。这个原理在很多公众号上都有描述,这里就不再展开。结合上面的分析能推断出,下列情况可以增加算法解码的难度:
    1. 马赛克形式和算法中的马赛克算法不相同
    2. 马赛克模糊度提高(即取平均值的色块大小增加)
    3. 马赛克文字的背景颜色尽量不是纯色
  2. 对马赛克的文字进行多次打码,也会增加破解的难度。
  3. 从解码过程中可知,解码必须要生成一个包含待解码文字中所有字符的德布鲁因序列。因此,我们必须要了解待解码文字中包含了什么字符。这也限制了该方法更适用于英文、数字等字符序列的解码。像中文就……

事实上,在多次尝试后发现成功解码的仅有此文中这一次,且文中的例子也只能解出其中的几个数字。

但是不能因此就放松对个人隐私的保护。在发布图像时,建议使用多重马赛克/马赛克+涂抹等方式保护个人信息,进一步增加安全性。