feat: 降低 NSFW 检测阈值,显示更多检测结果
This commit is contained in:
@@ -13,7 +13,7 @@ organize.rotateImages=false
|
|||||||
# 探测/认类型/移动阶段的并发 worker 数(默认 4;0 表示用满全部 CPU 核数)
|
# 探测/认类型/移动阶段的并发 worker 数(默认 4;0 表示用满全部 CPU 核数)
|
||||||
organize.workers=8
|
organize.workers=8
|
||||||
# 扫描完成后是否移动文件到分类目录(true=移动,默认;false=只归类不移动,文件留在原位)
|
# 扫描完成后是否移动文件到分类目录(true=移动,默认;false=只归类不移动,文件留在原位)
|
||||||
organize.moveFiles=true
|
organize.moveFiles=false
|
||||||
# 色情图片识别(需本机安装 python 与 nudenet:pip install nudenet)
|
# 色情图片识别(需本机安装 python 与 nudenet:pip install nudenet)
|
||||||
# true=开启,判为色情的图片移入「图片_敏感_*」目录(明暗子目录照常);false=关闭(默认)
|
# true=开启,判为色情的图片移入「图片_敏感_*」目录(明暗子目录照常);false=关闭(默认)
|
||||||
# 模型:优先加载程序运行目录下 lib/nwfs/320n.onnx(推荐随程序分发,离线可用);
|
# 模型:优先加载程序运行目录下 lib/nwfs/320n.onnx(推荐随程序分发,离线可用);
|
||||||
|
|||||||
@@ -2,35 +2,23 @@ package classify
|
|||||||
|
|
||||||
import "fmt"
|
import "fmt"
|
||||||
|
|
||||||
// neutralMargin 判定"中性图"的最小分差:
|
|
||||||
// 如果亮色占比和暗色占比相差不到这个值,就认为这张图不亮不暗,归为「mid」中性。
|
|
||||||
const neutralMargin = 0.15
|
|
||||||
|
|
||||||
// BrightnessName 根据暗色/亮色像素占比,给图片定"明暗等级",返回类似:
|
// BrightnessName 根据暗色/亮色像素占比,给图片定"明暗等级",返回类似:
|
||||||
// - "dark_80" → 暗色像素占 70%~90% 的暗图
|
// - "dark_75" → 暗色像素占 50%~75% 的暗图
|
||||||
// - "bright_40" → 亮色像素占 30%~50% 的亮图
|
// - "bright" → 亮色像素比暗色多的亮图(不分子档位)
|
||||||
// - "mid" → 明暗差不多,不好说谁多,归中性
|
|
||||||
//
|
//
|
||||||
// 判断规则(大白话):谁占得多、而且多出明显一截(超过 neutralMargin),
|
// 判断规则:亮色占比 > 暗色占比 → bright;否则按暗色占比取整到 25% 一档。
|
||||||
// 就按谁命名,数字是占比取整到 20% 一档。
|
|
||||||
func BrightnessName(darkScore, lightScore float64) string {
|
func BrightnessName(darkScore, lightScore float64) string {
|
||||||
switch {
|
if lightScore > darkScore {
|
||||||
case lightScore > darkScore+neutralMargin:
|
return "bright"
|
||||||
return fmt.Sprintf("bright_%d", brightnessTier(lightScore))
|
|
||||||
case darkScore > lightScore+neutralMargin:
|
|
||||||
return fmt.Sprintf("dark_%d", brightnessTier(darkScore))
|
|
||||||
default:
|
|
||||||
return "mid"
|
|
||||||
}
|
}
|
||||||
|
return fmt.Sprintf("dark_%d", brightnessTier(darkScore))
|
||||||
}
|
}
|
||||||
|
|
||||||
// brightnessTier 把占比(0~1)取整到"最接近的 20%"作为档位数字,范围 20~100。
|
// brightnessTier 把暗色占比(0~1)取整到最接近的 25% 档位,范围 25~100。
|
||||||
// 例:0.30~0.50 → "40";0.96 以上 → "100"。
|
|
||||||
// 这样文件夹名里的数字不会出现"明明很少却挂个大数字"的情况。
|
|
||||||
func brightnessTier(score float64) int {
|
func brightnessTier(score float64) int {
|
||||||
tier := int(score*100+10) / 20 * 20
|
tier := int(score*100+12) / 25 * 25
|
||||||
if tier < 20 {
|
if tier < 25 {
|
||||||
return 20
|
return 25
|
||||||
}
|
}
|
||||||
if tier > 100 {
|
if tier > 100 {
|
||||||
return 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))
|
classes = append(classes, fmt.Sprintf("%s:%.3f", cls, sc))
|
||||||
}
|
}
|
||||||
fmt.Printf("=== NSFW 检测详情 %s: [%s]\n", path, strings.Join(classes, ", "))
|
fmt.Printf("=== NSFW 检测详情 %s: [%s]\n", path, strings.Join(classes, ", "))
|
||||||
|
} else {
|
||||||
|
fmt.Printf("=== NSFW 检测详情 %s: [无检测结果]\n", path)
|
||||||
}
|
}
|
||||||
return out.Score, out.Nsfw, nil
|
return out.Score, out.Nsfw, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+73
-27
@@ -22,17 +22,17 @@ import json
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
|
||||||
import threading
|
import threading
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
from nudenet import NudeDetector
|
from nudenet import NudeDetector
|
||||||
|
from nudenet.nudenet import _read_image
|
||||||
|
|
||||||
# 只报 OpenCV 致命错误,别让 imread 的 WARN 刷屏(也避免误导排查)
|
# 只报 OpenCV 致命错误,别让 imread 的 WARN 刷屏(也避免误导排查)
|
||||||
try:
|
try:
|
||||||
import cv2
|
|
||||||
|
|
||||||
cv2.setLogLevel(8) # LOG_LEVEL_ERROR
|
cv2.setLogLevel(8) # LOG_LEVEL_ERROR
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -145,6 +145,74 @@ def _new_detector():
|
|||||||
return NudeDetector()
|
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):
|
def _longpath(path):
|
||||||
"""Windows 下给绝对路径加 \\\\?\\ 前缀,让 Python 原生 open 也能处理超长路径。"""
|
"""Windows 下给绝对路径加 \\\\?\\ 前缀,让 Python 原生 open 也能处理超长路径。"""
|
||||||
if os.name != "nt":
|
if os.name != "nt":
|
||||||
@@ -171,39 +239,17 @@ def _read_bytes(path):
|
|||||||
return data
|
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):
|
def _infer(path):
|
||||||
"""整个推理任务(读文件 + 检测),提交到固定线程池执行。
|
"""整个推理任务(读文件 + 检测),提交到固定线程池执行。
|
||||||
|
|
||||||
读文件在池外并行进行;推理用线程各自的独立检测器,N 个 worker 真·并行。
|
使用 _raw_detect 绕过 NudeNet 内置的 0.2 阈值,用 0.05 低阈值返回更多结果。
|
||||||
"""
|
"""
|
||||||
data = _read_bytes(path)
|
data = _read_bytes(path)
|
||||||
det = getattr(_thread_local, "detector", None)
|
det = getattr(_thread_local, "detector", None)
|
||||||
if det is None:
|
if det is None:
|
||||||
det = _new_detector()
|
det = _new_detector()
|
||||||
_thread_local.detector = det
|
_thread_local.detector = det
|
||||||
return _detect_bytes(det, data, path)
|
return _raw_detect(det, data)
|
||||||
|
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
|||||||
Reference in New Issue
Block a user