feat: 降低 NSFW 检测阈值,显示更多检测结果

This commit is contained in:
2025-07-15 21:11:04 +08:00
parent f589592535
commit 8ac21e6941
4 changed files with 86 additions and 50 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ organize.rotateImages=false
# 探测/认类型/移动阶段的并发 worker 数(默认 4;0 表示用满全部 CPU 核数)
organize.workers=8
# 扫描完成后是否移动文件到分类目录(true=移动,默认;false=只归类不移动,文件留在原位)
organize.moveFiles=true
organize.moveFiles=false
# 色情图片识别(需本机安装 python 与 nudenet:pip install nudenet)
# true=开启,判为色情的图片移入「图片_敏感_*」目录(明暗子目录照常);false=关闭(默认)
# 模型:优先加载程序运行目录下 lib/nwfs/320n.onnx(推荐随程序分发,离线可用);
+10 -22
View File
@@ -2,35 +2,23 @@ package classify
import "fmt"
// neutralMargin 判定"中性图"的最小分差:
// 如果亮色占比和暗色占比相差不到这个值,就认为这张图不亮不暗,归为「mid」中性。
const neutralMargin = 0.15
// BrightnessName 根据暗色/亮色像素占比,给图片定"明暗等级",返回类似:
// - "dark_80" → 暗色像素占 70%~90% 的暗图
// - "bright_40" → 亮色像素占 30%~50% 的亮图
// - "mid" → 明暗差不多,不好说谁多,归中性
// - "dark_75" → 暗色像素占 50%~75% 的暗图
// - "bright" → 亮色像素比暗色多的亮图(不分子档位)
//
// 判断规则(大白话):谁占得多、而且多出明显一截(超过 neutralMargin),
// 就按谁命名,数字是占比取整到 20% 一档。
// 判断规则:亮色占比 > 暗色占比 → bright;否则按暗色占比取整到 25% 一档。
func BrightnessName(darkScore, lightScore float64) string {
switch {
case lightScore > darkScore+neutralMargin:
return fmt.Sprintf("bright_%d", brightnessTier(lightScore))
case darkScore > lightScore+neutralMargin:
return fmt.Sprintf("dark_%d", brightnessTier(darkScore))
default:
return "mid"
if lightScore > darkScore {
return "bright"
}
return fmt.Sprintf("dark_%d", brightnessTier(darkScore))
}
// brightnessTier 把占比(0~1)取整到"最接近的 20%"作为档位数字,范围 20~100。
// 例:0.30~0.50 → "40";0.96 以上 → "100"。
// 这样文件夹名里的数字不会出现"明明很少却挂个大数字"的情况。
// brightnessTier 把暗色占比(0~1)取整到最接近的 25% 档位,范围 25~100。
func brightnessTier(score float64) int {
tier := int(score*100+10) / 20 * 20
if tier < 20 {
return 20
tier := int(score*100+12) / 25 * 25
if tier < 25 {
return 25
}
if tier > 100 {
return 100
+2
View File
@@ -306,6 +306,8 @@ func (d *Detector) detectOnce(path string) (score float64, nsfw bool, err error)
classes = append(classes, fmt.Sprintf("%s:%.3f", cls, sc))
}
fmt.Printf("=== NSFW 检测详情 %s: [%s]\n", path, strings.Join(classes, ", "))
} else {
fmt.Printf("=== NSFW 检测详情 %s: [无检测结果]\n", path)
}
return out.Score, out.Nsfw, nil
}
+73 -27
View File
@@ -22,17 +22,17 @@ import json
import os
import shutil
import sys
import tempfile
import threading
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import cv2
import numpy as np
from nudenet import NudeDetector
from nudenet.nudenet import _read_image
# 只报 OpenCV 致命错误,别让 imread 的 WARN 刷屏(也避免误导排查)
try:
import cv2
cv2.setLogLevel(8) # LOG_LEVEL_ERROR
except Exception:
pass
@@ -145,6 +145,74 @@ def _new_detector():
return NudeDetector()
def _raw_detect(det, data):
"""直接调用 ONNX 推理,用 0.05 低阈值后处理(NudeNet 内置的 detect 用 0.2 太严格)。
绕过 NudeDetector.detect() 的硬编码 0.2 阈值,让低置信度的检测结果也能返回,
最终判定交给 Go 侧的用户配置阈值。
"""
mat = cv2.imdecode(np.frombuffer(data, np.uint8), -1)
if mat is None:
raise OSError("图片解码失败")
mat_c3 = cv2.cvtColor(mat, cv2.COLOR_RGBA2BGR)
ow, oh = mat_c3.shape[1], mat_c3.shape[0]
max_size = max(ow, oh)
x_pad = max_size - ow
y_pad = max_size - oh
x_ratio = max_size / ow
y_ratio = max_size / oh
mat_pad = cv2.copyMakeBorder(mat_c3, 0, y_pad, 0, x_pad, cv2.BORDER_CONSTANT)
blob = cv2.dnn.blobFromImage(mat_pad, 1 / 255.0, (det.input_width, det.input_height), (0, 0, 0), swapRB=True, crop=False)
outputs = det.onnx_session.run(None, {det.input_name: blob})
outs = np.transpose(np.squeeze(outputs[0]))
rows = outs.shape[0]
boxes, scores, class_ids = [], [], []
for i in range(rows):
cls_scores = outs[i][4:]
max_score = np.amax(cls_scores)
if max_score >= 0.05: # 低阈值:NudeNet 内置是 0.2,这里降到 0.05
class_id = int(np.argmax(cls_scores))
x, y, w, h = outs[i][0:4]
x, y = x - w / 2, y - h / 2
w_ = w * (ow + x_pad) / det.input_width
h_ = h * (oh + y_pad) / det.input_height
x = x * (ow + x_pad) / det.input_width
y = y * (oh + y_pad) / det.input_height
x, y = max(0, min(x, ow)), max(0, min(y, oh))
w_ = min(w_, ow - x)
h_ = min(h_, oh - y)
boxes.append([x, y, w_, h_])
scores.append(float(max_score))
class_ids.append(class_id)
indices = cv2.dnn.NMSBoxes(boxes, scores, 0.1, 0.45)
detections = []
for i in indices:
idx = i if isinstance(i, int) else i[0]
detections.append({
"class": __labels[class_ids[idx]],
"score": scores[idx],
"box": [int(boxes[idx][0]), int(boxes[idx][1]), int(boxes[idx][2]), int(boxes[idx][3])],
})
return detections
# NudeNet 的标签表(和 nudenet.nudenet.__labels 一致)
__labels = [
"FEMALE_GENITALIA_COVERED", "FACE_FEMALE", "BUTTOCKS_EXPOSED",
"FEMALE_BREAST_EXPOSED", "FEMALE_GENITALIA_EXPOSED", "MALE_BREAST_EXPOSED",
"ANUS_EXPOSED", "FEET_EXPOSED", "BELLY_COVERED", "FEET_COVERED",
"ARMPITS_COVERED", "ARMPITS_EXPOSED", "FACE_MALE", "BELLY_EXPOSED",
"MALE_GENITALIA_EXPOSED", "ANUS_COVERED", "FEMALE_BREAST_COVERED",
"BUTTOCKS_COVERED",
]
def _longpath(path):
"""Windows 下给绝对路径加 \\\\?\\ 前缀,让 Python 原生 open 也能处理超长路径。"""
if os.name != "nt":
@@ -171,39 +239,17 @@ def _read_bytes(path):
return data
def _detect_bytes(det, data, path):
"""把图片字节喂给 NudeNet。v3 直接吃 bytes;老版 v2 只收路径时落到临时短路径重试。
用线程自己的检测器实例推理,各实例互不干扰,可以真·并行。
"""
try:
return det.detect(data)
except (TypeError, AttributeError):
tmp = None
try:
fd, tmp = tempfile.mkstemp(suffix=os.path.splitext(path)[1] or ".jpg")
with os.fdopen(fd, "wb") as f:
f.write(data)
return det.detect(tmp)
finally:
if tmp:
try:
os.unlink(tmp)
except OSError:
pass
def _infer(path):
"""整个推理任务(读文件 + 检测),提交到固定线程池执行。
读文件在池外并行进行;推理用线程各自的独立检测器,N 个 worker 真·并行。
使用 _raw_detect 绕过 NudeNet 内置的 0.2 阈值,用 0.05 低阈值返回更多结果。
"""
data = _read_bytes(path)
det = getattr(_thread_local, "detector", None)
if det is None:
det = _new_detector()
_thread_local.detector = det
return _detect_bytes(det, data, path)
return _raw_detect(det, data)
class Handler(BaseHTTPRequestHandler):