鉴黄检测
那个深夜的电话
“光影”上线第二周的一个深夜,我的手机响了。
“你好,我是 XX 区网安大队的。有人举报你的平台上有涉黄图片,请立即处理。”
我打开后台一看,一个刚注册的用户上传了 17 张图片,其中 12 张是色情图片。它们已经在线上存在了 3 个小时——被 200 多个用户浏览过。
我花 10 分钟手动删除了所有违规图片,然后坐在椅子上发了 5 分钟的呆。
如果我当时在睡觉呢?如果这个用户上传了 100 张呢?
作为平台方,我要对用户发布的内容负责。但我总不能 24 小时盯着后台。
我需要自动化审核。
第一版:开源模型 NSFW.js
我首先找到了 NSFW.js——一个基于 TensorFlow.js 的开源鉴黄模型,可以在浏览器端或 Node.js 端运行。
// 安装 nsfwjs
// npm install @nsfwfilter/nsfwjs
import nsfwjs from 'nsfwjs';
import { createCanvas, loadImage } from 'canvas';
class NSFWDetector {
private model: any;
async init() {
// 加载预训练模型(~4MB)
this.model = await nsfwjs.load();
}
async detect(imagePath: string): Promise<{
safe: boolean;
scores: Record<string, number>;
}> {
const img = await loadImage(imagePath);
const canvas = createCanvas(img.width, img.height);
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
const predictions = await this.model.classify(canvas);
// NSFW.js 的 5 个分类:
// - Drawing: 绘画/插画
// - Hentai: 动漫色情
// - Neutral: 中性内容
// - Porn: 色情
// - Sexy: 性感(但不完全是色情)
const scores: Record<string, number> = {};
for (const pred of predictions) {
scores[pred.className] = pred.probability;
}
const pornScore = scores['Porn'] || 0;
const hentaiScore = scores['Hentai'] || 0;
const sexyScore = scores['Sexy'] || 0;
// 阈值判断
const isUnsafe = pornScore > 0.6 || hentaiScore > 0.6 || sexyScore > 0.8;
return {
safe: !isUnsafe,
scores,
};
}
}
// 测试
const detector = new NSFWDetector();
await detector.init();
const testImages = [
'test_photo_landscape.jpg', // 风景照
'test_photo_portrait.jpg', // 人像
'test_photo_beach.jpg', // 海滩泳装
'test_photo_nsfw.jpg', // 色情图片
'test_art_nude.jpg', // 艺术裸体
'test_manga.jpg', // 日本漫画
];
for (const img of testImages) {
const result = await detector.detect(img);
console.log(`${img}: ${result.safe ? '✅ 安全' : '❌ 违规'}`);
console.log(` Porn: ${(result.scores['Porn'] * 100).toFixed(1)}%`);
console.log(` Hentai: ${(result.scores['Hentai'] * 100).toFixed(1)}%`);
console.log(` Sexy: ${(result.scores['Sexy'] * 100).toFixed(1)}%`);
}测试结果:
NSFW.js 测试结果
图片 判定 Porn Hentai Sexy 实际
─────────────────────────────────────────────────────────────────
风景照 ✅ 安全 0.1% 0.0% 0.2% 安全 ✅
人像 ✅ 安全 2.3% 0.1% 15.4% 安全 ✅
海滩泳装 ❌ 违规 3.5% 0.2% 82.1% 安全 ❌ 误报!
色情图片 ❌ 违规 96.2% 0.3% 2.1% 违规 ✅
艺术裸体 ✅ 安全 42.1% 0.5% 38.2% 灰色 ⚠️ 漏报
日本漫画(正常) ❌ 违规 1.2% 65.3% 3.2% 安全 ❌ 误报!问题太明显了:
- 海滩泳装被误判为违规(Sexy 82.1%)——摄影师经常上传泳装人像,这是正常内容
- 日本漫画被误判为色情(Hentai 65.3%)——Hentai 分类对动漫内容的区分度很差
- 艺术裸体漏报了(Porn 42.1%,没超过 60% 阈值)——这其实应该至少进入人工审核
准确率统计(200 张测试集):
NSFW.js 准确率
实际安全 实际违规
判定安全 85 12 ← 12 张漏报
判定违规 15 88 ← 15 张误报
准确率: 86.5%
误报率: 15.0% ← 15% 的正常图片被错误拦截
漏报率: 12.0% ← 12% 的违规图片没被发现
对于一个内容平台,12% 的漏报率意味着每天 1000 张上传中有 3~5 张违规图片会漏网。调整阈值:两头为难
我试了调整阈值:
# 不同阈值的权衡
threshold_tests = [
# (porn_threshold, sexy_threshold)
(0.4, 0.6), # 严格
(0.6, 0.8), # 中等(当前)
(0.8, 0.9), # 宽松
]
for porn_t, sexy_t in threshold_tests:
# 统计准确率
results = test_with_threshold(porn_t, sexy_t, test_set)
print(f"阈值 Porn>{porn_t}, Sexy>{sexy_t}:")
print(f" 准确率: {results['accuracy']:.1f}%")
print(f" 误报率: {results['false_positive']:.1f}%")
print(f" 漏报率: {results['false_negative']:.1f}%")结果:
阈值 Porn>0.4, Sexy>0.6 (严格):
准确率: 82.0%
误报率: 25.0% ← 太高!用户正常的泳装照被拦截
漏报率: 3.0%
阈值 Porn>0.6, Sexy>0.8 (中等):
准确率: 86.5%
误报率: 15.0%
漏报率: 12.0%
阈值 Porn>0.8, Sexy>0.9 (宽松):
准确率: 83.5%
误报率: 3.0%
漏报率: 30.0% ← 太高!大量违规图片漏网严格模式误报太高,宽松模式漏报太高,中间的也不理想。
这就是开源模型的问题——训练数据有限、模型较小、对边缘场景(泳装、艺术照、动漫)的区分度不够。
第二版:云 API
研究了一圈,我发现国内的云服务商都提供了图片内容审核 API,背后的模型用海量标注数据训练,准确率远超开源方案。
我对比了三家主流服务商:
# 云 API 鉴黄对比测试
import hashlib
import hmac
import base64
import time
import requests
import json
class AliyunGreenDetector:
"""阿里云内容安全 API"""
def __init__(self, access_key, secret_key):
self.access_key = access_key
self.secret_key = secret_key
self.endpoint = 'green.cn-beijing.aliyuncs.com'
def detect(self, image_url: str) -> dict:
"""调用阿里云图片审核"""
# 构建请求
body = {
'tasks': [{
'dataId': str(uuid.uuid4()),
'url': image_url,
}],
'scenes': ['porn'], # porn = 鉴黄
'bizType': 'guangying',
}
headers = self._sign_request('POST', '/green/image/scan', body)
response = requests.post(
f'https://{self.endpoint}/green/image/scan',
headers=headers,
json=body,
)
result = response.json()
if result['code'] == 200:
task_result = result['data']['results'][0]
return {
'label': task_result['label'], # porn/sexy/normal
'confidence': task_result['rate'], # 0~100
'review': task_result.get('suggestion') == 'review',
}
return {'label': 'error', 'confidence': 0}
class TencentIMS:
"""腾讯云图片审核 API"""
def __init__(self, secret_id, secret_key):
self.secret_id = secret_id
self.secret_key = secret_key
def detect(self, image_url: str) -> dict:
"""调用腾讯云图片审核"""
from tencentcloud.common import credential
from tencentcloud.ims.v20201229 import ims_client, models
cred = credential.Credential(self.secret_id, self.secret_key)
client = ims_client.ImsClient(cred, 'ap-beijing')
req = models.ImageModerationRequest()
req.FileUrl = image_url
req Scenes = ['PORN']
resp = client.ImageModeration(req)
data = json.loads(resp.to_json_string())
return {
'label': data.get('Suggestion', 'Unknown'), # Block/Review/Pass
'confidence': data.get('Label', '').get('PornInfo', {}).get('Score', 0),
'review': data.get('Suggestion') == 'Review',
}
# 三家对比测试
test_results = {
'阿里云': [],
'腾讯云': [],
'百度云': [],
}
detectors = {
'阿里云': AliyunGreenDetector(ak, sk),
'腾讯云': TencentIMS(sid, skey),
'百度云': BaiduAipDetector(app_id, api_key, secret),
}
for name, detector in detectors.items():
correct = 0
total = len(test_set)
false_positives = 0
false_negatives = 0
for image in test_set:
result = detector.detect(image['url'])
predicted_unsafe = result['label'] in ('porn', 'Block', 'sexy')
actual_unsafe = image['label'] == 'unsafe'
if predicted_unsafe == actual_unsafe:
correct += 1
elif predicted_unsafe and not actual_unsafe:
false_positives += 1
else:
false_negatives += 1
test_results[name] = {
'accuracy': correct / total * 100,
'false_positive_rate': false_positives / total * 100,
'false_negative_rate': false_negatives / total * 100,
}结果:
三家云 API 鉴黄准确率对比(200 张测试集)
服务商 准确率 误报率 漏报率 平均响应时间 单价
─────────────────────────────────────────────────────────────────────
阿里云 97.5% 1.5% 1.0% 200ms 1.5元/千次
腾讯云 96.0% 2.5% 1.5% 250ms 1.2元/千次
百度云 95.5% 2.0% 2.5% 300ms 1.0元/千次
NSFW.js 86.5% 15.0% 12.0% 150ms 免费
云 API 的准确率全面碾压开源模型。
误报率从 15% 降到 1.5%,漏报率从 12% 降到 1%。成本分析
# 月度成本估算
daily_uploads = 500 # 每天 500 张上传
monthly_uploads = daily_uploads * 30 # = 15,000 张/月
# 阿里云
aliyun_price_per_1k = 1.5 # 元/千次
aliyun_monthly = monthly_uploads / 1000 * aliyun_price_per_1k
# = 22.5 元/月
# 腾讯云
tencent_price_per_1k = 1.2
tencent_monthly = monthly_uploads / 1000 * tencent_price_per_1k
# = 18 元/月
# 如果图片需要多维度审核(色情+暴力+政治)
# 每张图片调用 3 个场景
aliyun_multi = aliyun_monthly * 3 # = 67.5 元/月结论:每个月不到 100 块钱就能获得 97%+ 的准确率。这笔钱绝对值得花。
最终方案:阿里云 + 人工复核
我选了阿里云内容安全,原因是准确率最高(特别是对中文场景的优化),而且误报率最低——对于摄影社区来说,误报比漏报更伤用户体验。
class ImageAuditService:
"""图片审核服务——集成阿里云"""
def __init__(self):
self.detector = AliyunGreenDetector(
settings.ALIYUN_ACCESS_KEY,
settings.ALIYUN_SECRET_KEY,
)
def audit(self, photo_id: str, image_url: str) -> dict:
"""审核单张图片"""
result = self.detector.detect(image_url)
label = result.get('label', 'normal')
confidence = result.get('confidence', 0)
if label == 'porn' and confidence >= 90:
# 高置信度色情 → 直接拦截
action = 'reject'
elif label in ('porn', 'sexy') and confidence >= 60:
# 中置信度 → 进入人工审核
action = 'review'
else:
# 低风险 → 放行
action = 'pass'
# 记录审核结果
db.insert('photo_audits', {
'photo_id': photo_id,
'label': label,
'confidence': confidence,
'action': action,
'audited_at': datetime.now(),
})
# 更新图片状态
if action == 'reject':
db.update('photos', {'status': 'rejected'}, {'id': photo_id})
# 从 CDN 移除
self._remove_from_cdn(photo_id)
elif action == 'review':
db.update('photos', {'status': 'pending_review'}, {'id': photo_id})
# 通知人工审核队列
self._notify_human_reviewers(photo_id)
else:
# pass,图片正常展示
pass
return {
'photo_id': photo_id,
'action': action,
'label': label,
'confidence': confidence,
}
def _remove_from_cdn(self, photo_id):
"""从 CDN 移除违规图片"""
photo = db.query('photos', {'id': photo_id})
if photo:
# 刷新 CDN 缓存
cdn_client.refresh_object_caches(
f'https://cdn.guangying.com/{photo["object_key"]}'
)
def _notify_human_reviewers(self, photo_id):
"""通知人工审核员"""
queue.publish('human_review', {'photo_id': photo_id})集成到异步处理流程中:
# 在 Worker 的处理流程中增加审核步骤
class ImageProcessWorker:
def _process_one(self, task):
photo_id = task['photo_id']
object_key = task['object_key']
# 第一步:审核(在处理之前!)
image_url = f'https://guangying-images.oss-cn-beijing.aliyuncs.com/{object_key}'
audit_result = audit_service.audit(photo_id, image_url)
if audit_result['action'] == 'reject':
# 违规图片,不处理,直接拒绝
return
# 第二步:处理(压缩、生成衍生图)
# ... 之前的处理逻辑 ...审核放在处理之前——违规图片不需要浪费计算资源去压缩和生成缩略图。
本节小结
✅ 我学到了什么:
- 开源鉴黄模型(NSFW.js)对边缘场景(泳装、动漫、艺术照)的区分度不够
- 云 API 用海量标注数据训练,准确率从 86% 提升到 97%+
- 每月不到 100 元就能获得专业级的审核能力
- 审核应该放在处理之前——违规图片不值得浪费计算资源
⚠️ 踩过的坑:
- NSFW.js 的 Hentai 分类对日本动漫误报率极高
- 阈值调整解决不了根本问题——模型能力上限在那里
- 审核回调有延迟(200~300ms),但不影响异步处理
🎯 下一步:鉴黄只是审核的第一维度。敏感文字、暴力血腥、政治敏感——多维度的审核策略怎么设计?
我的思考
思考 1
云 API 的鉴黄准确率是 97%,但还有 1% 的漏报。如果有人故意生成”对抗样本”(对 AI 审核进行欺骗的图片),如何防范?
对抗样本是 AI 审核的已知弱点。攻击者可以通过给图片添加人眼不可见的噪声来欺骗分类模型。
多层防御策略:
第一层:AI 审核(云 API)
→ 拦截 97% 的违规内容
第二层:图片指纹库
→ 维护已知违规图片的感知哈希(pHash)库
→ 即使攻击者修改了图片,pHash 仍能检测到相似性
第三层:用户行为分析
→ 新注册用户连续上传 5 张图片 → 提高审核阈值
→ 之前被拒绝过的用户 → 所有上传强制人工审核
第四层:社区举报
→ 用户举报按钮
→ 3 次举报自动隐藏 + 触发人工审核
第五层:定期巡检
→ 每天随机抽检 5% 的已通过图片
→ 使用最新模型重新审核# 图片指纹库
import imagehash
from PIL import Image
class ImageFingerprintDB:
def check_similar(self, image_url: str) -> bool:
"""检查是否与已知违规图片相似"""
img = Image.open(requests.get(image_url, stream=True).raw)
phash = imagehash.phash(img)
# 与指纹库中的哈希比较
for banned_hash in self.get_all_banned_hashes():
if phash - banned_hash < 10: # 汉明距离阈值
return True
return False思考 2
对于摄影社区来说,人体艺术摄影和色情图片的边界非常模糊。你会怎么处理这种灰色地带?
这是内容审核最棘手的问题之一。纯靠 AI 很难区分”艺术”和”色情”。
我的策略:三层分级 + 用户自选
层级 1:明确色情 → AI 直接拦截(porn score > 90)
层级 2:灰色地带 → AI 转人工审核
层级 3:艺术/人体 → 允许展示,但需要标记具体做法:
# 灰色地带处理
class GreyAreaHandler:
def handle(self, audit_result, photo_id):
if audit_result['confidence'] in range(60, 90):
# 灰色地带:可能的艺术照,也可能擦边
# 1. 检查上传者身份
uploader = get_uploader_info(photo_id)
if uploader.get('is_verified_photographer'):
# 认证摄影师 → 宽松处理,但加标签
tag_as('artistic_nude', photo_id)
enable_content_warning(photo_id)
else:
# 普通用户 → 人工审核
send_to_human_review(photo_id, priority='high')
def enable_content_warning(self, photo_id):
"""启用内容警告——用户点击才显示"""
db.update('photos', {
'content_warning': True,
'warning_text': '此照片包含可能引起不适的内容'
}, {'id': photo_id})前端展示:
<!-- 内容警告遮罩 -->
<div class="photo-card" *ngIf="photo.content_warning">
<div class="blur-overlay" (click)="reveal()">
<p>⚠️ {{ photo.warning_text }}</p>
<button>点击查看</button>
</div>
<img [src]="photo.url" [class.blurred]="!revealed">
</div>核心原则:宁可放过,不可错杀。对灰色地带使用”内容警告”而不是”直接删除”,既保护了创作者的自由,也尊重了浏览者的选择权。
