├── images ├── GAN_CNN.png ├── model_1.gif ├── model_2.gif ├── detection_result_at_baidu.png ├── image_at_different_degrees.jpg ├── detection_result_at_tencent.png ├── images_with_different_stripes.jpg └── leng_image_at_different_platforms.jpg ├── README.md └── image2gif.py /images/GAN_CNN.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangx404/anti-NSFW-detection-test/HEAD/images/GAN_CNN.png -------------------------------------------------------------------------------- /images/model_1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangx404/anti-NSFW-detection-test/HEAD/images/model_1.gif -------------------------------------------------------------------------------- /images/model_2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangx404/anti-NSFW-detection-test/HEAD/images/model_2.gif -------------------------------------------------------------------------------- /images/detection_result_at_baidu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangx404/anti-NSFW-detection-test/HEAD/images/detection_result_at_baidu.png -------------------------------------------------------------------------------- /images/image_at_different_degrees.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangx404/anti-NSFW-detection-test/HEAD/images/image_at_different_degrees.jpg -------------------------------------------------------------------------------- /images/detection_result_at_tencent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangx404/anti-NSFW-detection-test/HEAD/images/detection_result_at_tencent.png -------------------------------------------------------------------------------- /images/images_with_different_stripes.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangx404/anti-NSFW-detection-test/HEAD/images/images_with_different_stripes.jpg -------------------------------------------------------------------------------- /images/leng_image_at_different_platforms.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wangx404/anti-NSFW-detection-test/HEAD/images/leng_image_at_different_platforms.jpg -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # anti-NSFW-detection-test 2 | 一些尝试用于对抗色情图片检测算法的思路 3 | 4 | ## 背景 5 | 6 | 诸如新浪微博和tumblr之类的网站都会对用户上传的图片进行检测,以屏蔽掉某些不合时宜的(Not Suitable for Work)图片。尽管对于某些以此为生的媒体来说这无异于灭顶之灾,但对普通用户而言,类似的检测算法却影响甚小。即便如此,你仍然会感到不爽,尤其是某些时候你想要分享的性感图片也被屏蔽掉。所以使用了一些图片对新浪微博/Tencent AI/Baidu AI的检测算法进行了测试,得出了一些(可能)可行的对抗检测的思路。 7 | 8 | ## 一些对抗思路 9 | 10 | 1. 旋转。 11 | 12 | 因为基于CNN的检测算法通常是旋转不鲁棒的,所以可以通过将图片旋转一定的角度用于改变概率的预测值。 13 | 14 | 在早期时,某些平台的检测算法确实存在此问题,但是这一问题很快就被修复。在训练CNN模型时,只需要增加对图片的随机旋转增广就可以改善模型的旋转鲁棒性。 15 | 16 | 以Tencent AI的色情图片检测为例,将示例图片分别旋转90°和180°之后进行测试,porn的概率反而越来越大。这说明了这一种思路目前已经不太可行。 17 | 18 | ![不同旋转角度下的检测结果比对示意图](/images/image_at_different_degrees.jpg) 19 | 20 | 2. 拼接空白图片 21 | 22 | 多数情况下CNN对于输入图片的尺寸是有限制的,因此在对图片进行检测时一般会首先将其缩放为1:1的正方形图片,然后再进行检测。针对这样的图片预处理方法,我们可以将原始图片和一张较长的空白图片拼接在一起组成一张长宽比远大于1的图片。这样的图片在缩放成正方形后会偏离模型训练数据集的数据分布,因此可以起到对抗的作用。 23 | 24 | 然而这样方式也可以很快得到修复。对于长宽比大大偏离1:1的图片,我们只需要将图片切分为多张1:1的图片然后分别对其检测即可。 25 | 26 | 对于这样的对抗方式,从下面的示意图可以看出Baidu AI和Tencent AI可能因为处理方式不同而得出了截然不同的检测结果。 27 | 28 | ![不同平台下的长图检测结果对比示意图](/images/leng_image_at_different_platforms.jpg) 29 | 30 | P.S. 目前,新浪的算法对这种对抗方式也没有进行针对性改进。 31 | P.P.S. 将多张图片拼接在一起也可以对抗算法的检测,但是由于大部分平台会对长图进行大幅度的压缩,因此会有较为严重的质量损失,所以拼接空白图片可能更好。 32 | P.P.P.S. 这样的一种对抗思路应该不难想到,但是由于针对性的处理方法会增加数倍的计算量,这可能是平台没有进行改进的原因。 33 | 34 | 3. 转为gif图片 35 | 36 | 将一张空白的图片和一张目标图片分别作为gif图片的两帧,空白帧设置较短的时长,目标图片设置为较长的时长。这样得到的gif图片在点击查看时的效果和正常图片差别很小(缺点是图片的体积大大增加)。 37 | 38 | 这样的对抗方式理论上非常容易检测到,算法只需要将gif逐帧解析,逐帧分析计算,最后返回最大概率即可。 39 | 40 | 然而事实是,Baidu AI提供了针对gif色情图片的检测(其接口说明:该请求用于鉴定GIF图片的色情度,对于非gif接口,请使用图像审核组合接口。接口会对图片中每一帧进行识别,并返回所有检测结果中色情值最大的为结果。目前支持三个维度:色情、性感、正常。),Tencent AI不支持gif格式的图片,新浪微博也没有对此进行改进。 41 | 42 | ![带有空白帧的gif图片示意图](/images/model_1.gif) 43 | 44 | P.P.P.P.S. 正如之前提到的,对此类对抗行为针对性的改进会大大增加平台的计算开销。考虑到微博上有非常多的正常图片(搞笑gif或者视频转置的gif等等)均为gif格式,要对所有的gif进行类似的检测会增加很多成本。 45 | 46 | 4. 条纹化&gif化 47 | 48 | 根据一定的条纹间隔,将图片的横向的像素值交替设置为255/0。 49 | 50 | 除非在训练过程中也加入类似的图片增广手段,否则算法很难准确判断此类图片的类别。但这样的对抗手段存在的问题是,图片的观感会受到一定程度的影响。条纹宽度越宽,越不容易被检测到,但信息损失带来的观感下降也会越严重。为了缓解这一问题,可能需要将两张条纹化的图片叠加在一起组成gif以弥补这一缺陷。 51 | 52 | 条纹宽度为1时,Tencent AI的检测结果为正常;条纹宽度为16时,Baidu AI的检测结果也是正常。 53 | 54 | ![条纹宽度为1的图片在Tencent AI的检测结果](/images/detection_result_at_tencent.png) 55 | 56 | ![条纹宽度为16的图片在Baidu AI的检测结果](/images/detection_result_at_baidu.png) 57 | 58 | 将两张条纹化的图片帧拼接在一起组成gif后尽管观感略有提升,但是由于gif帧率限制导致的频闪会引起不适感。 59 | 60 | ![拼接条纹化图片得到的gif图片示意图](/images/model_2.gif) 61 | 62 | 5. 利用GAN进行对抗攻击 63 | 64 | 在之前的某些论文中,研究人员训练了一些生成模型用于攻击CNN分类模型。训练得到的模型通过向图片中添加一些人眼不敏感的噪音,从而改变了图片的分类概率。通过训练类似的模型,我们也可以通过向NSFW图片中添加噪音以改变其预测概率。 65 | 66 | 然而由于我们无法得到平台所使用的模型(即便是黑箱状态的),所以想要训练得到这样的一个模型是非常困难的。而且即便我们真的可以得到这样的模型,平台也可以通过重新训练得到新的模型来对抗我们的攻击。更为无解的问题是,即便我们能够得到所有的模型所对应的对抗模型,平台也只需要在模型之间进行随机切换就可以减轻对抗模型的攻击。 67 | 68 | ![一种对CNN分类模型进行攻击的示意图](/images/GAN_CNN.png) 69 | 70 | ## 一个脚本 71 | 72 | 在这里,我编写了一个简单的python脚本用于生成两种形式的gif,调用方法为:`python image2gif.py --mode 1/2 --image your_image_path`。 73 | 74 | 其中`--mode 1`代表生成第一种gif(添加空白帧),`--mode 2`代表生成第二种gif(条纹化之后进行拼接);`--image your_image_path`指定图片路径,生成的gif和原始图片位于统一路径当中。 75 | -------------------------------------------------------------------------------- /image2gif.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Created on Tue Mar 5 10:44:19 2019 5 | 6 | @author: wangx404 7 | """ 8 | 9 | import imageio 10 | from PIL import Image 11 | import argparse 12 | import os 13 | 14 | def parseArgs(): 15 | """ 16 | 辅助读取命令行参数。 17 | """ 18 | parser = argparse.ArgumentParser("transform image from jpg into gif format to anti-NSFW-detection algorithm") 19 | parser.add_argument("--mode", default=1, type=int, help="image transformation mode") 20 | parser.add_argument("--image", default=None, type=str, help="image path") 21 | 22 | args = parser.parse_args() 23 | return args 24 | 25 | 26 | # 两种思路,一是将图片和一张透明的png图片合成为一张gif 27 | # (但是某些平台的算法可以读取帧,并返回帧检测的最大概率) 28 | # 二是将图片剪裁为多个区块,对区块透明填充后进行合成 29 | 30 | def get_gif_name(image): 31 | """ 32 | 根据输入的image名称,获取gif的生成路径和名称。 33 | :parameter image: image path 34 | :return gif_name: gif file name 35 | """ 36 | image_path, image_file = os.path.split(image) 37 | image_prefix, _ = os.path.splitext(image_file) 38 | gif_name = image_prefix + ".gif" 39 | gif_name = os.path.join(image_path, gif_name) 40 | return gif_name 41 | 42 | 43 | def generate_blank_img(img): 44 | """ 45 | 根据源图片生成同样大小的空白帧(默认为黑色)。 46 | :parameter img: source image object, imageio.core.util.Image 47 | :return blank_img: blank image object, imageio.core.util.Image 48 | """ 49 | # 生成图片 50 | h, w, _ = img.shape 51 | blank_img = Image.new(mode="RGBA", size=(w, h)) 52 | blank_img.save("temp.png") 53 | # 读取成imageio的格式 54 | blank_img = imageio.imread("temp.png") 55 | os.remove("temp.png") 56 | return blank_img 57 | 58 | 59 | def generate_gif_1(image, duration=[0.01, 9.99]): 60 | """ 61 | 使用添加空白帧的方法生成gif图像。 62 | :parameter image: 源图片, str 63 | :parameter duration: 帧间隔,空白帧为0.01,源图片设置为9.99, list of float 64 | :return None: 65 | """ 66 | img = imageio.imread(image) 67 | bk_img = generate_blank_img(img) # background image 68 | img_list = [bk_img, img] # img object list 69 | gif_name = get_gif_name(image) # img & target gif file 70 | imageio.mimsave(gif_name, img_list, 'GIF', duration=duration) # generate gif 71 | 72 | 73 | def get_stripe_imgs(img, stripe_width=1): 74 | """ 75 | 获取得到两张条纹化的图像对象(此函数用于调试条纹宽度)。 76 | :parameter img: source image object, numpy.ndarray 77 | :return [img1, img2]: 条纹图像列表, list of numpy.ndarray 78 | """ 79 | h, w, _ = img.shape 80 | stripe_num = h // (stripe_width*2) 81 | img1 = img.copy() 82 | img2 = img.copy() 83 | 84 | for i in range(stripe_num): 85 | # 对第一张图片进行处理 86 | img1[i*2*stripe_width: (i*2+1)*stripe_width, :, :] = 255 87 | # 对第二章图片进行处理 88 | img2[(i*2+1)*stripe_width: (i*2+2)*stripe_width, :, :] = 255 89 | 90 | return [img1, img2] 91 | 92 | 93 | def generate_stripe_imgs(image, stripe_width=1): 94 | """ 95 | 生成两个条纹化图像(png格式) 96 | :parameter image: 源图像,str 97 | :parameter stripe_width: 条纹的宽度,int 98 | :return [img1, img2]: 条纹图像列表,list of imageio.core.util.Image 99 | """ 100 | img = Image.open(image) 101 | w, h = img.size 102 | img1 = Image.new(mode="RGBA", size=(w, h), color=(255,255,255)) 103 | img2 = Image.new(mode="RGBA", size=(w, h), color=(255,255,255)) 104 | 105 | stripe_num = h // (stripe_width*2) # 条纹周期数据 106 | for i in range(stripe_num): 107 | img_frag = img.crop((0, i*2*stripe_width, w, (i*2+1)*stripe_width)) # x, y, w, h 108 | img1.paste(img_frag, box=(0, i*2*stripe_width)) 109 | img_frag = img.crop((0, (i*2+1)*stripe_width, w, (i*2+2)*stripe_width)) 110 | img2.paste(img_frag, box=(0, (i*2+1)*stripe_width)) 111 | 112 | img1.save("temp.png") 113 | img1 = imageio.imread("temp.png") 114 | img2.save("temp.png") 115 | img2 = imageio.imread("temp.png") 116 | os.remove("temp.png") 117 | return [img1, img2] 118 | 119 | 120 | def generate_gif_2(image): 121 | """ 122 | 使用拼接条纹化图片的形式生成gif图像。 123 | :parameter image: source image, str 124 | :return None: 125 | """ 126 | gif_name = get_gif_name(image) 127 | img_list = generate_stripe_imgs(image) 128 | imageio.mimsave(gif_name, img_list, 'GIF', fps=200) 129 | 130 | 131 | if __name__ == "__main__": 132 | args = parseArgs() 133 | image = args.image 134 | if args.mode == 1: 135 | generate_gif_1(image) 136 | elif args.mode == 2: 137 | generate_gif_2(image) 138 | 139 | print("Image transformation finished.") --------------------------------------------------------------------------------