feat: 降低 NSFW 检测阈值,显示更多检测结果
This commit is contained in:
@@ -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(推荐随程序分发,离线可用);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user