From 8ac21e69416e39a8a7119dca27f0e697dd5c8f6a Mon Sep 17 00:00:00 2001 From: Odboy Date: Tue, 15 Jul 2025 21:11:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=99=8D=E4=BD=8E=20NSFW=20=E6=A3=80?= =?UTF-8?q?=E6=B5=8B=E9=98=88=E5=80=BC=EF=BC=8C=E6=98=BE=E7=A4=BA=E6=9B=B4?= =?UTF-8?q?=E5=A4=9A=E6=A3=80=E6=B5=8B=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- application.properties | 2 +- internal/classify/brightness.go | 32 ++++------ internal/nsfw/detector.go | 2 + internal/nsfw/server.py | 100 +++++++++++++++++++++++--------- 4 files changed, 86 insertions(+), 50 deletions(-) diff --git a/application.properties b/application.properties index c3403c8..384737f 100644 --- a/application.properties +++ b/application.properties @@ -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(推荐随程序分发,离线可用); diff --git a/internal/classify/brightness.go b/internal/classify/brightness.go index 7aba372..274dee4 100644 --- a/internal/classify/brightness.go +++ b/internal/classify/brightness.go @@ -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 diff --git a/internal/nsfw/detector.go b/internal/nsfw/detector.go index 76bb725..037cc8b 100644 --- a/internal/nsfw/detector.go +++ b/internal/nsfw/detector.go @@ -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 } diff --git a/internal/nsfw/server.py b/internal/nsfw/server.py index cb67220..57c4db2 100644 --- a/internal/nsfw/server.py +++ b/internal/nsfw/server.py @@ -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):