1 import sys 2 import os 3 import _io 4 from collections import namedtuple 5 from PIL import Image 6 7 class Nude(object): 8 9 Skin = namedtuple("Skin", "id skin region x y") 10 11 def __init__(self, path_or_image): 12 # 若 path_or_image 为 Image.Image 类型的实例,直接赋值 13 if isinstance(path_or_image, Image.Image): 14 self.image = path_or_image 15 # 若 path_or_image 为 str 类型的实例,打开图片 16 elif isinstance(path_or_image, str): 17 self.image = Image.open(path_or_image) 18 19 # 获得图片所有颜色通道 20 bands = self.image.getbands() 21 # 判断是否为单通道图片(也即灰度图),是则将灰度图转换为 RGB 图 22 if len(bands) == 1: 23 # 新建相同大小的 RGB 图像 24 new_img = Image.new("RGB", self.image.size) 25 # 拷贝灰度图 self.image 到 RGB图 new_img.paste (PIL 自动进行颜色通道转换) 26 new_img.paste(self.image) 27 f = self.image.filename 28 # 替换 self.image 29 self.image = new_img 30 self.image.filename = f 31 32 # 存储对应图像所有像素的全部 Skin 对象 33 self.skin_map = [] 34 # 检测到的皮肤区域,元素的索引即为皮肤区域号,元素都是包含一些 Skin 对象的列表 35 self.detected_regions = [] 36 # 元素都是包含一些 int 对象(区域号)的列表 37 # 这些元素中的区域号代表的区域都是待合并的区域 38 self.merge_regions = [] 39 # 整合后的皮肤区域,元素的索引即为皮肤区域号,元素都是包含一些 Skin 对象的列表 40 self.skin_regions = [] 41 # 最近合并的两个皮肤区域的区域号,初始化为 -1 42 self.last_from, self.last_to = -1, -1 43 # 色情图像判断结果 44 self.result = None 45 # 处理得到的信息 46 self.message = None 47 # 图像宽高 48 self.width, self.height = self.image.size 49 # 图像总像素 50 self.total_pixels = self.width * self.height 51 52 def resize(self, maxwidth=1000, maxheight=1000): 53 """ 54 基于最大宽高按比例重设图片大小, 55 注意:这可能影响检测算法的结果 56 57 如果没有变化返回 0 58 原宽度大于 maxwidth 返回 1 59 原高度大于 maxheight 返回 2 60 原宽高大于 maxwidth, maxheight 返回 3 61 62 maxwidth - 图片最大宽度 63 maxheight - 图片最大高度 64 传递参数时都可以设置为 False 来忽略 65 """ 66 # 存储返回值 67 ret = 0 68 if max 69 if self.width > max 70 wpercent = (maxwidth / self.width) 71 hsize = int((self.height * wpercent)) 72 fname = self.image.filename 73 # Image.LANCZOS 是重采样滤波器,用于抗锯齿 74 self.image = self.image.resize((maxwidth, hsize), Image.LANCZOS) 75 self.image.filename = fname 76 self.width, self.height = self.image.size 77 self.total_pixels = self.width * self.height 78 ret += 1 79 if maxheight: 80 if self.height > maxheight: 81 hpercent = (maxheight / float(self.height)) 82 wsize = int((float(self.width) * float(hpercent))) 83 fname = self.image.filename 84 self.image = self.image.resize((wsize, maxheight), Image.LANCZOS) 85 self.image.filename = fname 86 self.width, self.height = self.image.size 87 self.total_pixels = self.width * self.height 88 ret += 2 89 return ret 90 91 # 分析函数 92 def parse(self): 93 # 如果已有结果,返回本对象 94 if self.result is not None: 95 return self 96 # 获得图片所有像素数据 97 pixels = self.image.load() 98 # 遍历每个像素 99 for y in range(self.height): 100 for x in range(self.width): 101 # 得到像素的 RGB 三个通道的值 102 # [x, y] 是 [(x,y)] 的简便写法 103 r = pixels[x, y][0] # red 104 g = pixels[x, y][1] # green 105 b = pixels[x, y][2] # blue 106 # 判断当前像素是否为肤色像素 107 isSkin = True if self._classify_skin(r, g, b) else False 108 # 给每个像素分配唯一 id 值(1, 2, 3...height*width) 109 # 注意 x, y 的值从零开始 110 _id = x + y * self.width + 1 111 # 为每个像素创建一个对应的 Skin 对象,并添加到 self.skin_map 中 112 self.skin_map.append(self.Skin(_id, isSkin, None, x, y)) 113 # 若当前像素不为肤色像素,跳过此次循环 114 if not isSkin: 115 continue 116 117 # 设左上角为原点,相邻像素为符号 *,当前像素为符号 ^,那么相互位置关系通常如下图 118 # *** 119 # *^ 120 121 # 存有相邻像素索引的列表,存放顺序为由大到小,顺序改变有影响 122 # 注意 _id 是从 1 开始的,对应的索引则是 _id-1 123 check_indexes = [_id - 2, # 当前像素左方的像素 124 _id - self.width - 2, # 当前像素左上方的像素 125 _id - self.width - 1, # 当前像素的上方的像素 126 _id - self.width] # 当前像素右上方的像素 127 # 用来记录相邻像素中肤色像素所在的区域号,初始化为 -1 128 region = -1 129 # 遍历每一个相邻像素的索引 130 for index in check_indexes: 131 # 尝试索引相邻像素的 Skin 对象,没有则跳出循环 132 try: 133 self.skin_map[index] 134 except IndexError: 135 break 136 # 相邻像素若为肤色像素: 137 if self.skin_map[index].skin: 138 # 若相邻像素与当前像素的 region 均为有效值,且二者不同,且尚未添加相同的合并任务 139 if (self.skin_map[index].region != None and 140 region != None and region != -1 and 141 self.skin_map[index].region != region and 142 self.last_from != region and 143 self.last_to != self.skin_map[index].region) : 144 # 那么这添加这两个区域的合并任务 145 self._add_merge(region, self.skin_map[index].region) 146 # 记录此相邻像素所在的区域号 147 region = self.skin_map[index].region 148 # 遍历完所有相邻像素后,若 region 仍等于 -1,说明所有相邻像素都不是肤色像素 149 if region == -1: 150 # 更改属性为新的区域号,注意元祖是不可变类型,不能直接更改属性 151 _skin = self.skin_map[_id - 1]._replace(region=len(self.detected_regions)) 152 self.skin_map[_id - 1] = _skin 153 # 将此肤色像素所在区域创建为新区域 154 self.detected_regions.append([self.skin_map[_id - 1]]) 155 # region 不等于 -1 的同时不等于 None,说明有区域号为有效值的相邻肤色像素 156 elif region != None: 157 # 将此像素的区域号更改为与相邻像素相同 158 _skin = self.skin_map[_id - 1]._replace(region=region) 159 self.skin_map[_id - 1] = _skin 160 # 向这个区域的像素列表中添加此像素 161 self.detected_regions[region].append(self.skin_map[_id - 1]) 162 # 完成所有区域合并任务,合并整理后的区域存储到 self.skin_regions 163 self._merge(self.detected_regions, self.merge_regions) 164 # 分析皮肤区域,得到判定结果 165 self._analyse_regions() 166 return self 167 168 169 # self.merge_regions 的元素都是包含一些 int 对象(区域号)的列表 170 # self.merge_regions 的元素中的区域号代表的区域都是待合并的区域 171 # 这个方法便是将两个待合并的区域号添加到 self.merge_regions 中 172 def _add_merge(self, _from, _to): 173 # 两个区域号赋值给类属性 174 self.last_from = _from 175 self.last_to = _to 176 177 # 记录 self.merge_regions 的某个索引值,初始化为 -1 178 from_index = -1 179 # 记录 self.merge_regions 的某个索引值,初始化为 -1 180 to_index = -1 181 182 183 # 遍历每个 self.merge_regions 的元素 184 for index, region in enumerate(self.merge_regions): 185 # 遍历元素中的每个区域号 186 for r_index in region: 187 if r_index == _from: 188 from_index = index 189 if r_index == _to: 190 to_index = index 191 192 # 若两个区域号都存在于 self.merge_regions 中 193 if from_index != -1 and to_index != -1: 194 # 如果这两个区域号分别存在于两个列表中 195 # 那么合并这两个列表 196 if from_index != to_index: 197 self.merge_regions[from_index].extend(self.merge_regions[to_index]) 198 del(self.merge_regions[to_index]) 199 return 200 201 # 若两个区域号都不存在于 self.merge_regions 中 202 if from_index == -1 and to_index == -1: 203 # 创建新的区域号列表 204 self.merge_regions.append([_from, _to]) 205 return 206 # 若两个区域号中有一个存在于 self.merge_regions 中 207 if from_index != -1 and to_index == -1: 208 # 将不存在于 self.merge_regions 中的那个区域号 209 # 添加到另一个区域号所在的列表 210 self.merge_regions[from_index].append(_to) 211 return 212 # 若两个待合并的区域号中有一个存在于 self.merge_regions 中 213 if from_index == -1 and to_index != -1: 214 # 将不存在于 self.merge_regions 中的那个区域号 215 # 添加到另一个区域号所在的列表 216 self.merge_regions[to_index].append(_from) 217 return 218 219 # 合并该合并的皮肤区域 220 def _merge(self, detected_regions, merge_regions): 221 # 新建列表 new_detected_regions 222 # 其元素将是包含一些代表像素的 Skin 对象的列表 223 # new_detected_regions 的元素即代表皮肤区域,元素索引为区域号 224 new_detected_regions = [] 225 226 # 将 merge_regions 中的元素中的区域号代表的所有区域合并 227 for index, region in enumerate(merge_regions): 228 try: 229 new_detected_regions[index] 230 except IndexError: 231 new_detected_regions.append([]) 232 for r_index in region: 233 new_detected_regions[index].extend(detected_regions[r_index]) 234 detected_regions[r_index] = [] 235 236 # 添加剩下的其余皮肤区域到 new_detected_regions 237 for region in detected_regions: 238 if len(region) > 0: 239 new_detected_regions.append(region) 240 241 # 清理 new_detected_regions 242 self._clear_regions(new_detected_regions) 243 244 # 皮肤区域清理函数 245 # 只保存像素数大于指定数量的皮肤区域 246 def _clear_regions(self, detected_regions): 247 for region in detected_regions: 248 if len(region) > 30: 249 self.skin_regions.append(region) 250 251 # 分析区域 252 def _analyse_regions(self): 253 # 如果皮肤区域小于 3 个,不是色情 254 if len(self.skin_regions) < 3: 255 self.message = "Less than 3 skin regions ({_skin_regions_size})".format( 256 _skin_regions_size=len(self.skin_regions)) 257 self.result = False 258 return self.result 259 260 # 为皮肤区域排序 261 self.skin_regions = sorted(self.skin_regions, key=lambda s: len(s), 262 reverse=True) 263 264 # 计算皮肤总像素数 265 total_skin = float(sum([len(skin_region) for skin_region in self.skin_regions])) 266 267 # 如果皮肤区域与整个图像的比值小于 15%,那么不是色情图片 268 if total_skin / self.total_pixels * 100 < 15: 269 self.message = "Total skin percentage lower than 15 ({:.2f})".format(total_skin / self.total_pixels * 100) 270 self.result = False 271 return self.result 272 273 # 如果最大皮肤区域小于总皮肤面积的 45%,不是色情图片 274 if len(self.skin_regions[0]) / total_skin * 100 < 45: 275 self.message = "The biggest region contains less than 45 ({:.2f})".format(len(self.skin_regions[0]) / total_skin * 100) 276 self.result = False 277 return self.result 278 279 # 皮肤区域数量超过 60个,不是色情图片 280 if len(self.skin_regions) > 60: 281 self.message = "More than 60 skin regions ({})".format(len(self.skin_regions)) 282 self.result = False 283 return self.result 284 285 # 其它情况为色情图片 286 self.message = "Nude!!" 287 self.result = True 288 return self.result 289 290 # 基于像素的肤色检测技术 291 def _classify_skin(self, r, g, b): 292 # 根据RGB值判定 293 rgb_classifier = r > 95 and 294 g > 40 and g < 100 and 295 b > 20 and 296 max([r, g, b]) - min([r, g, b]) > 15 and 297 abs(r - g) > 15 and 298 r > g and 299 r > b 300 # 根据处理后的 RGB 值判定 301 nr, ng, nb = self._to_normalized(r, g, b) 302 norm_rgb_classifier = nr / ng > 1.185 and 303 float(r * b) / ((r + g + b) ** 2) > 0.107 and 304 float(r * g) / ((r + g + b) ** 2) > 0.112 305 306 # HSV 颜色模式下的判定 307 h, s, v = self._to_hsv(r, g, b) 308 hsv_classifier = h > 0 and 309 h < 35 and 310 s > 0.23 and 311 s < 0.68 312 313 # YCbCr 颜色模式下的判定 314 y, cb, cr = self._to_ycbcr(r, g, b) 315 ycbcr_classifier = 97.5 <= cb <= 142.5 and 134 <= cr <= 176 316 317 # 效果不是很好,还需改公式 318 # return rgb_classifier or norm_rgb_classifier or hsv_classifier or ycbcr_classifier 319 return ycbcr_classifier 320 321 def _to_normalized(self, r, g, b): 322 if r == 0: 323 r = 0.0001 324 if g == 0: 325 g = 0.0001 326 if b == 0: 327 b = 0.0001 328 _sum = float(r + g + b) 329 return [r / _sum, g / _sum, b / _sum] 330 331 def _to_ycbcr(self, r, g, b): 332 # 公式来源: 333 # http://stackoverflow.com/questions/19459831/rgb-to-ycbcr-conversion-problems 334 y = .299*r + .587*g + .114*b 335 cb = 128 - 0.168736*r - 0.331364*g + 0.5*b 336 cr = 128 + 0.5*r - 0.418688*g - 0.081312*b 337 return y, cb, cr 338 339 def _to_hsv(self, r, g, b): 340 h = 0 341 _sum = float(r + g + b) 342 _max = float(max([r, g, b])) 343 _min = float(min([r, g, b])) 344 diff = float(_max - _min) 345 if _sum == 0: 346 _sum = 0.0001 347 348 if _max == r: 349 if diff == 0: 350 h = sys.maxsize 351 else: 352 h = (g - b) / diff 353 elif _max == g: 354 h = 2 + ((g - r) / diff) 355 else: 356 h = 4 + ((r - g) / diff) 357 358 h *= 60 359 if h < 0: 360 h += 360 361 362 return [h, 1.0 - (3.0 * (_min / _sum)), (1.0 / 3.0) * _max] 363 364 def inspect(self): 365 _image = '{} {} {}×{}'.format(self.image.filename, self.image.format, self.width, self.height) 366 return "{_image}: result={_result} message='{_message}'".format(_image=_image, _result=self.result, _message=self.message) 367 368 # 将在源文件目录生成图片文件,将皮肤区域可视化 369 def showSkinRegions(self): 370 # 未得出结果时方法返回 371 if self.result is None: 372 return 373 # 皮肤像素的 ID 的集合 374 skinIdSet = set() 375 # 将原图做一份拷贝 376 simage = self.image 377 # 加载数据 378 simageData = simage.load() 379 380 # 将皮肤像素的 id 存入 skinIdSet 381 for sr in self.skin_regions: 382 for pixel in sr: 383 skinIdSet.add(pixel.id) 384 # 将图像中的皮肤像素设为白色,其余设为黑色 385 for pixel in self.skin_map: 386 if pixel.id not in skinIdSet: 387 simageData[pixel.x, pixel.y] = 0, 0, 0 388 else: 389 simageData[pixel.x, pixel.y] = 255, 255, 255 390 # 源文件绝对路径 391 filePath = os.path.abspath(self.image.filename) 392 # 源文件所在目录 393 fileDirectory = os.path.dirname(filePath) + '/' 394 # 源文件的完整文件名 395 fileFullName = os.path.basename(filePath) 396 # 分离源文件的完整文件名得到文件名和扩展名 397 fileName, fileExtName = os.path.splitext(fileFullName) 398 # 保存图片 399 simage.save('{}{}_{}{}'.format(fileDirectory, fileName,'Nude' if self.result else 'Normal', fileExtName)) 400 401 if __name__ == "__main__": 402 import argparse 403 404 parser = argparse.ArgumentParser(description='Detect nudity in images.') 405 parser.add_argument('files', metavar='image', nargs='+', 406 help='Images you wish to test') 407 parser.add_argument('-r', '--resize', action='store_true', 408 help='Reduce image size to increase speed of scanning') 409 parser.add_argument('-v', '--visualization', action='store_true', 410 help='Generating areas of skin image') 411 412 args = parser.parse_args() 413 414 for fname in args.files: 415 if os.path.isfile(fname): 416 n = Nude(fname) 417 if args.resize: 418 n.resize(maxheight=800, maxwidth=600) 419 n.parse() 420 if args.visualization: 421 n.showSkinRegions() 422 print(n.result, n.inspect()) 423 else: 424 print(fname, "is not a file")
上面代码文件名为nude.py,则运行下面命令即可开始检测。 $ python3 nude.py -v 0.jpg 1.jpg
运行环境:
安装python及相关$ sudo update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.4 70 --slave /usr/bin/python3m python3m /usr/bin/python3.4m
$ sudo apt-get install python3-dev python3-setuptools
安装pillow依赖包
$ sudo apt-get install libtiff5-dev libjpeg8-dev zlib1g-dev libfreetype6-dev liblcms2-dev libwebp-dev tcl8.6-dev tk8.6-dev python-tk
安装pip3(
pip3
是 Python3 的软件包管理系统,使用它来安装 Python3 的模块非常简便)$ sudo apt-get install python3-pip
安装pillow
$ sudo pip3 install Pillow
程序原理:
本程序根据颜色(肤色)找出图片中皮肤的区域,然后通过一些条件判断是否为色情图片
程序的关键步骤如下
- 遍历每个像素,检测像素颜色是否为肤色
- 将相邻的肤色像素归为一个皮肤区域,得到若干个皮肤区域
- 剔除像素数量极少的皮肤区域
我们定义非色情图片的判定规则如下(满足任意一个判定为真):
- 皮肤区域的个数小于 3 个
- 皮肤区域的像素与图像所有像素的比值小于 15%
- 最大皮肤区域小于总皮肤面积的 45%
- 皮肤区域数量超过60个
这些规则你可以尝试更改,直到程序效果让你满意为止
关于像素肤色判定这方面,公式可以在网上找到很多,但世界上不可能有正确率 100% 的公式
你可以用自己找到的公式,在程序完成后慢慢调试