导航菜单

鉴黄检测

那个深夜的电话

“光影”上线第二周的一个深夜,我的手机响了。

“你好,我是 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%    安全 ❌ 误报!

问题太明显了

  1. 海滩泳装被误判为违规(Sexy 82.1%)——摄影师经常上传泳装人像,这是正常内容
  2. 日本漫画被误判为色情(Hentai 65.3%)——Hentai 分类对动漫内容的区分度很差
  3. 艺术裸体漏报了(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>

核心原则:宁可放过,不可错杀。对灰色地带使用”内容警告”而不是”直接删除”,既保护了创作者的自由,也尊重了浏览者的选择权。

搜索