feat: 目录层级变更

This commit is contained in:
2025-09-24 21:19:55 +08:00
parent 7792f9281c
commit 72c9d67d9b
12 changed files with 156 additions and 317 deletions
+16 -9
View File
@@ -97,10 +97,9 @@
<el-table-column label="色情检测" width="140" align="center">
<template slot-scope="scope">
<div v-if="scope.row.nsfwScore > 0">
<el-tag :type="scope.row.nsfwScore >= scope.row.nsfwThreshold ? 'danger' : 'warning'" size="small">
{{ (scope.row.nsfwScore * 100).toFixed(1) }}%
<el-tag :type="getNsfwLevelType(scope.row.nsfwScore)" size="small">
{{ getNsfwLevelName(scope.row.nsfwScore) }} {{ (scope.row.nsfwScore * 100).toFixed(1) }}%
</el-tag>
<div class="nsfw-threshold">阈值: {{ (scope.row.nsfwThreshold * 100).toFixed(0) }}%</div>
<div v-if="scope.row.nsfwDetections" class="nsfw-threshold">{{ formatNsfwDetections(scope.row.nsfwDetections) }}</div>
</div>
<span v-else class="muted">-</span>
@@ -109,18 +108,14 @@
<el-table-column label="动漫色情检测" width="140" align="center">
<template slot-scope="scope">
<div v-if="scope.row.animeNsfwScore > 0">
<el-tag :type="scope.row.animeNsfwScore >= scope.row.animeNsfwThreshold ? 'danger' : 'warning'" size="small">
{{ (scope.row.animeNsfwScore * 100).toFixed(1) }}%
<el-tag :type="getNsfwLevelType(scope.row.animeNsfwScore)" size="small">
{{ getNsfwLevelName(scope.row.animeNsfwScore) }} {{ (scope.row.animeNsfwScore * 100).toFixed(1) }}%
</el-tag>
<div class="nsfw-threshold">阈值: {{ (scope.row.animeNsfwThreshold * 100).toFixed(0) }}%</div>
</div>
<div v-else-if="scope.row.animeNsfwRatings">
<el-tag type="info" size="small">安全</el-tag>
<div class="nsfw-threshold">{{ formatAnimeRatings(scope.row.animeNsfwRatings) }}</div>
</div>
<div v-else-if="scope.row.animeNsfwThreshold > 0">
<el-tag type="info" size="small">安全</el-tag>
</div>
<span v-else class="muted">-</span>
</template>
</el-table-column>
@@ -312,6 +307,18 @@ export default {
this.$message.error('复制失败')
})
},
getNsfwLevelName(score) {
if (score < 0.25) return '安全'
if (score < 0.5) return '低俗'
if (score < 0.75) return '擦边'
return '色情'
},
getNsfwLevelType(score) {
if (score < 0.25) return 'success'
if (score < 0.5) return 'warning'
if (score < 0.75) return ''
return 'danger'
},
formatAnimeRatings(ratingsJson) {
if (!ratingsJson) return ''
try {
-16
View File
@@ -53,21 +53,11 @@
</div>
</el-form-item>
<el-form-item v-if="form.type === 'human'" label="检测阈值">
<el-slider v-model="form.humanThreshold" :min="0.1" :max="0.9" :step="0.05" show-input></el-slider>
<div class="form-tip">分数高于此值判定为色情(推荐 0.2-0.4)</div>
</el-form-item>
<el-form-item v-if="form.type === 'human'" label="推理 Worker 数">
<el-input-number v-model="form.humanWorkers" :min="1" :max="32"></el-input-number>
<div class="form-tip">并行推理的 worker 数量,越大越快但占用更多内存(每个 worker 约 300MB)</div>
</el-form-item>
<el-form-item v-if="form.type === 'anime'" label="检测阈值">
<el-slider v-model="form.animeThreshold" :min="0.1" :max="0.9" :step="0.05" show-input></el-slider>
<div class="form-tip">explicit/questionable 分数高于此值判定为色情(推荐 0.5-0.7)</div>
</el-form-item>
<el-form-item v-if="form.type === 'anime'" label="推理 Worker 数">
<el-input-number v-model="form.animeWorkers" :min="1" :max="32"></el-input-number>
<div class="form-tip">并行推理的 worker 数量,越大越快但占用更多内存(每个 worker 约 1.5GB)</div>
@@ -90,8 +80,6 @@ export default {
return {
form: {
type: '',
humanThreshold: 0.5,
animeThreshold: 0.5,
humanWorkers: 4,
animeWorkers: 2,
goWorkers: 0
@@ -116,8 +104,6 @@ export default {
const res = await request.get('/api/config/nsfw')
if (res.data) {
this.form.type = res.data.type || ''
this.form.humanThreshold = res.data.humanThreshold || 0.5
this.form.animeThreshold = res.data.animeThreshold || 0.5
this.form.humanWorkers = res.data.humanWorkers || 4
this.form.animeWorkers = res.data.animeWorkers || 2
this.form.goWorkers = res.data.goWorkers || 0
@@ -147,8 +133,6 @@ export default {
try {
await request.put('/api/config/nsfw', {
type: this.form.type,
humanThreshold: this.form.humanThreshold,
animeThreshold: this.form.animeThreshold,
humanWorkers: this.form.humanWorkers,
animeWorkers: this.form.animeWorkers,
goWorkers: this.form.goWorkers
+15 -27
View File
@@ -45,7 +45,6 @@ const requestTimeout = 60 * time.Second
// Detector 封装一个本地 python 服务的客户端。
type Detector struct {
url string
threshold float64
workers int
client *http.Client
cmd *exec.Cmd
@@ -60,10 +59,7 @@ type Detector struct {
}
// Start 拉起本地 python 检测服务并等它就绪。
func Start(pythonPath string, threshold float64, workers int) (*Detector, error) {
if threshold <= 0 || threshold >= 1 {
threshold = 0.5
}
func Start(pythonPath string, workers int) (*Detector, error) {
if workers <= 0 {
workers = 2 // 默认 2 个 worker
}
@@ -88,7 +84,6 @@ func Start(pythonPath string, threshold float64, workers int) (*Detector, error)
}
d := &Detector{
threshold: threshold,
workers: workers,
client: &http.Client{Timeout: requestTimeout},
python: py,
@@ -189,33 +184,32 @@ type AnimeRatings struct {
Explicit float64 `json:"explicit"`
}
// Detect 检测一张动漫图片是否色情。返回:
// Detect 检测一张动漫图片的色情评分。返回:
// - score:max(explicit, questionable) 的 sigmoid 分数(若 G 最高则为 0)
// - nsfw:是否判定为色情(分数 >= 阈值)
// - ratings:四个 rating 的原始分数
// - err:检测失败
func (d *Detector) Detect(path string) (score float64, nsfw bool, ratings AnimeRatings, err error) {
score, nsfw, ratings, err = d.detectOnce(path)
func (d *Detector) Detect(path string) (score float64, ratings AnimeRatings, err error) {
score, ratings, err = d.detectOnce(path)
if err == nil {
return score, nsfw, ratings, nil
return score, ratings, nil
}
var ce *connError
if !errors.As(err, &ce) {
return score, nsfw, ratings, err
return score, ratings, err
}
d.restartMu.Lock()
defer d.restartMu.Unlock()
if d.isStopped() {
return 0, false, ratings, err
return 0, ratings, err
}
if d.healthy() {
return d.detectOnce(path)
}
fmt.Printf("=== 动漫检测服务失联(%v), 正在重启…\n", ce.err)
if rerr := d.launch(); rerr != nil {
return 0, false, ratings, fmt.Errorf("动漫检测服务重启失败: %v", rerr)
return 0, ratings, fmt.Errorf("动漫检测服务重启失败: %v", rerr)
}
fmt.Println("=== 动漫检测服务已重启,继续识别")
return d.detectOnce(path)
@@ -242,24 +236,22 @@ func (d *Detector) isStopped() bool {
return d.stopped
}
func (d *Detector) detectOnce(path string) (score float64, nsfw bool, ratings AnimeRatings, err error) {
func (d *Detector) detectOnce(path string) (score float64, ratings AnimeRatings, err error) {
body, _ := json.Marshal(map[string]interface{}{
"path": path,
"threshold": d.threshold,
"path": path,
})
resp, err := d.client.Post(d.url+"/detect", "application/json", bytes.NewReader(body))
if err != nil {
return 0, false, ratings, &connError{err}
return 0, ratings, &connError{err}
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return 0, false, ratings, err
return 0, ratings, err
}
var out struct {
Ok bool `json:"ok"`
Error string `json:"error"`
Nsfw bool `json:"nsfw"`
Score float64 `json:"score"`
Ratings struct {
General float64 `json:"general"`
@@ -270,10 +262,10 @@ func (d *Detector) detectOnce(path string) (score float64, nsfw bool, ratings An
TopTags []map[string]interface{} `json:"top_tags"`
}
if err := json.Unmarshal(data, &out); err != nil {
return 0, false, ratings, fmt.Errorf("解析检测结果失败: %v", err)
return 0, ratings, fmt.Errorf("解析检测结果失败: %v", err)
}
if !out.Ok {
return 0, false, ratings, fmt.Errorf("检测服务返回错误: %s", out.Error)
return 0, ratings, fmt.Errorf("检测服务返回错误: %s", out.Error)
}
ratings = AnimeRatings{
General: out.Ratings.General,
@@ -284,7 +276,7 @@ func (d *Detector) detectOnce(path string) (score float64, nsfw bool, ratings An
// 打印 rating 详情(调试用)
fmt.Printf("=== AnimeNSFW 检测详情 %s: [G=%.3f S=%.3f Q=%.3f E=%.3f]\n",
path, ratings.General, ratings.Sensitive, ratings.Questionable, ratings.Explicit)
return out.Score, out.Nsfw, ratings, nil
return out.Score, ratings, nil
}
type connError struct{ err error }
@@ -305,10 +297,6 @@ func (d *Detector) Stop() {
}
}
func (d *Detector) Threshold() float64 {
return d.threshold
}
func resolvePython(configured string) string {
candidates := []string{}
if strings.TrimSpace(configured) != "" {
+2 -48
View File
@@ -265,76 +265,30 @@ class Handler(BaseHTTPRequestHandler):
try:
body = json.loads(self.rfile.read(length) or b"{}")
path = body.get("path", "")
threshold = float(body.get("threshold", 0.5))
if not path:
return self._send({"ok": False, "error": "缺少图片路径"})
result = _executor.submit(_infer, path).result()
except Exception as exc:
return self._send({"ok": False, "error": str(exc)})
# NSFW 判定逻辑:
# 只返回评分,不做阈值判定
# WD Tagger v3 的 rating 是独立 sigmoid(不归一化),四个值可以同时很高。
# 对于某些动漫图,rating 没有区分度(都在 0.5-0.6 之间)。
#
# 判定策略:
# 1. 检查 top_tags 中是否有明确的色情标签(nude、genitals 等)
# 2. 如果有,判定为 NSFW
# 3. 否则,用 rating 辅助判断(explicit - general >= 0.1)
ratings = result["ratings"]
top_tags = result["top_tags"]
explicit = ratings["explicit"]
questionable = ratings["questionable"]
general = ratings["general"]
# 打印 top_tags 到日志(调试用)
if top_tags:
tag_str = ", ".join([f"{t['tag']}:{t['score']:.2f}" for t in top_tags[:5]])
print(f"=== AnimeNSFW 标签: {tag_str}", flush=True)
# 定义明确的色情标签
NSFW_TAGS = {
"nude", "naked", "genitals", "penis", "vagina", "breasts",
"nipples", "areola", "pubic_hair", "anus", "testicles",
"erection", "ejaculation", "orgasm", "masturbation",
"fellatio", "cunnilingus", "intercourse", "sex",
"pussy", "cock", "dick", "boobs", "tits", "ass",
"butt", "thighs", "legs", "feet", "toes",
}
# 检查 top_tags 中是否有明确的色情标签
nsfw_by_tags = False
nsfw_tag_score = 0.0
for tag_info in top_tags:
tag_name = tag_info["tag"].lower()
tag_score = tag_info["score"]
# 检查标签名是否包含色情关键词
for nsfw_keyword in NSFW_TAGS:
if nsfw_keyword in tag_name and tag_score > 0.5:
nsfw_by_tags = True
nsfw_tag_score = max(nsfw_tag_score, tag_score)
break
if nsfw_by_tags:
break
# 判定逻辑
# nsfw_score 始终返回 max(explicit, questionable) 的最高分,
# 这样 Go 侧可以通过 score > 0 判断检测器是否运行过。
# nsfw 标记才表示是否真正判定为色情。
# nsfw_score 返回 max(explicit, questionable) 的最高分
nsfw_score = max(explicit, questionable)
if nsfw_by_tags:
# 标签检测到明确的色情内容
nsfw = True
elif nsfw_score >= threshold and (nsfw_score - general) >= 0.1:
# rating 辅助判断:最高分 >= 阈值 且 明显高于 general
nsfw = True
else:
nsfw = False
self._send({
"ok": True,
"nsfw": nsfw,
"score": nsfw_score,
"ratings": ratings,
"top_tags": result["top_tags"],
+18 -56
View File
@@ -13,14 +13,12 @@ import (
// 配置键名(存储在 AppConfig 表中)
const (
ConfigKeyNsfwType = "nsfw.type" // 检测类型:"" / "human" / "anime"
ConfigKeyNsfwThreshold = "nsfw.threshold" // 人类检测阈值
ConfigKeyAnimeNsfwThreshold = "anime.threshold" // 二次元检测阈值
ConfigKeyNsfwWorkers = "nsfw.workers" // 人类检测 worker 数量
ConfigKeyAnimeNsfwWorkers = "anime.workers" // 二次元检测 worker 数量
ConfigKeyOrganizeWorkers = "organize.workers" // Go 端探测/MIME/移动 worker 数量
ConfigKeyScanDir = "scan.scanDir" // 扫描目录
ConfigKeyDestDir = "scan.destDir" // 存放目录
ConfigKeyNsfwType = "nsfw.type" // 检测类型:"" / "human" / "anime"
ConfigKeyNsfwWorkers = "nsfw.workers" // 人类检测 worker 数量
ConfigKeyAnimeNsfwWorkers = "anime.workers" // 二次元检测 worker 数量
ConfigKeyOrganizeWorkers = "organize.workers" // Go 端探测/MIME/移动 worker 数量
ConfigKeyScanDir = "scan.scanDir" // 扫描目录
ConfigKeyDestDir = "scan.destDir" // 存放目录
)
// ConfigController 配置相关接口。
@@ -35,32 +33,16 @@ func NewConfigController(st *store.Store) *ConfigController {
// NsfwConfig NSFW 配置响应。
type NsfwConfig struct {
Type string `json:"type"` // 检测类型:"" / "human" / "anime"
HumanThreshold float64 `json:"humanThreshold"` // 人类检测阈值
AnimeThreshold float64 `json:"animeThreshold"` // 二次元检测阈值
HumanWorkers int `json:"humanWorkers"` // 人类检测 worker 数量
AnimeWorkers int `json:"animeWorkers"` // 二次元检测 worker 数量
GoWorkers int `json:"goWorkers"` // Go 端 worker 数量
Type string `json:"type"` // 检测类型:"" / "human" / "anime"
HumanWorkers int `json:"humanWorkers"` // 人类检测 worker 数量
AnimeWorkers int `json:"animeWorkers"` // 二次元检测 worker 数量
GoWorkers int `json:"goWorkers"` // Go 端 worker 数量
}
// GetNsfwConfig 处理 GET /api/config/nsfw:获取 NSFW 配置。
func (c *ConfigController) GetNsfwConfig(ctx *gin.Context) {
nsfwType := c.store.GetConfig(ConfigKeyNsfwType)
humanThreshold := 0.5
if v := c.store.GetConfig(ConfigKeyNsfwThreshold); v != "" {
if t, err := strconv.ParseFloat(v, 64); err == nil && t > 0 && t < 1 {
humanThreshold = t
}
}
animeThreshold := 0.5
if v := c.store.GetConfig(ConfigKeyAnimeNsfwThreshold); v != "" {
if t, err := strconv.ParseFloat(v, 64); err == nil && t > 0 && t < 1 {
animeThreshold = t
}
}
humanWorkers := 4
if v := c.store.GetConfig(ConfigKeyNsfwWorkers); v != "" {
if w, err := strconv.Atoi(v); err == nil && w > 0 {
@@ -83,23 +65,19 @@ func (c *ConfigController) GetNsfwConfig(ctx *gin.Context) {
}
Success(ctx, NsfwConfig{
Type: nsfwType,
HumanThreshold: humanThreshold,
AnimeThreshold: animeThreshold,
HumanWorkers: humanWorkers,
AnimeWorkers: animeWorkers,
GoWorkers: goWorkers,
Type: nsfwType,
HumanWorkers: humanWorkers,
AnimeWorkers: animeWorkers,
GoWorkers: goWorkers,
})
}
// UpdateNsfwConfigReq 更新 NSFW 配置请求。
type UpdateNsfwConfigReq struct {
Type string `json:"type"` // 检测类型:"" / "human" / "anime"
HumanThreshold float64 `json:"humanThreshold"` // 人类检测阈值
AnimeThreshold float64 `json:"animeThreshold"` // 二次元检测阈值
HumanWorkers int `json:"humanWorkers"` // 人类检测 worker 数量
AnimeWorkers int `json:"animeWorkers"` // 二次元检测 worker 数量
GoWorkers int `json:"goWorkers"` // Go 端 worker 数量(0 表示使用文件配置)
Type string `json:"type"` // 检测类型:"" / "human" / "anime"
HumanWorkers int `json:"humanWorkers"` // 人类检测 worker 数量
AnimeWorkers int `json:"animeWorkers"` // 二次元检测 worker 数量
GoWorkers int `json:"goWorkers"` // Go 端 worker 数量(0 表示使用文件配置)
}
// UpdateNsfwConfig 处理 PUT /api/config/nsfw:更新 NSFW 配置。
@@ -116,14 +94,6 @@ func (c *ConfigController) UpdateNsfwConfig(ctx *gin.Context) {
return
}
// 校验阈值范围
if req.HumanThreshold <= 0 || req.HumanThreshold >= 1 {
req.HumanThreshold = 0.5
}
if req.AnimeThreshold <= 0 || req.AnimeThreshold >= 1 {
req.AnimeThreshold = 0.5
}
// 校验 worker 数量
if req.HumanWorkers <= 0 {
req.HumanWorkers = 4
@@ -140,14 +110,6 @@ func (c *ConfigController) UpdateNsfwConfig(ctx *gin.Context) {
Error(ctx, http.StatusInternalServerError, "保存配置失败:"+err.Error())
return
}
if err := c.store.SaveConfig(ConfigKeyNsfwThreshold, strconv.FormatFloat(req.HumanThreshold, 'f', 2, 64)); err != nil {
Error(ctx, http.StatusInternalServerError, "保存配置失败:"+err.Error())
return
}
if err := c.store.SaveConfig(ConfigKeyAnimeNsfwThreshold, strconv.FormatFloat(req.AnimeThreshold, 'f', 2, 64)); err != nil {
Error(ctx, http.StatusInternalServerError, "保存配置失败:"+err.Error())
return
}
if err := c.store.SaveConfig(ConfigKeyNsfwWorkers, strconv.Itoa(req.HumanWorkers)); err != nil {
Error(ctx, http.StatusInternalServerError, "保存配置失败:"+err.Error())
return
+28 -14
View File
@@ -2,26 +2,40 @@ package classify
import "fmt"
// BrightnessName 根据暗色/亮色像素占比,给图片定"明暗等级",返回类似:
// - "dark_75" → 暗色像素占 50%~75% 的暗图
// - "bright" → 亮色像素比暗色多的亮图(不分子档位)
// BrightnessName 根据暗色像素占比,给图片定"明暗等级",返回类似:
// - "dark_25" → 暗色像素占 0%~33% 的偏亮图
// - "dark_50" → 暗色像素占 33%~66% 的中间调
// - "dark_75" → 暗色像素占 66%~100% 的暗图
//
// 判断规则:亮色占比 > 暗色占比 → bright;否则按暗色占比取整到 25% 一档。
// 三档按 33%/66% 等分。
func BrightnessName(darkScore, lightScore float64) string {
if lightScore > darkScore {
return "bright"
}
return fmt.Sprintf("dark_%d", brightnessTier(darkScore))
}
// brightnessTier 把暗色占比(0~1)取整到最接近的 25% 档位,范围 25~100。
// brightnessTier 把暗色占比(0~1)映射到三档:25/50/75。
// 分界点:33% 和 66%。
func brightnessTier(score float64) int {
tier := int(score*100+12) / 25 * 25
if tier < 25 {
switch {
case score < 0.33:
return 25
case score < 0.66:
return 50
default:
return 75
}
}
// NsfwLevelName 根据色情评分(0~1)返回中文分档名称。
// 四档:安全(0~0.25)/ 低俗(0.25~0.5)/ 擦边(0.5~0.75)/ 色情(0.75~1)。
func NsfwLevelName(score float64) string {
switch {
case score < 0.25:
return "安全"
case score < 0.5:
return "低俗"
case score < 0.75:
return "擦边"
default:
return "色情"
}
if tier > 100 {
return 100
}
return tier
}
+12 -20
View File
@@ -3,10 +3,10 @@
// 大白话理解,就是定分类命名规则:
// - 画面是横的还是竖的?(横屏 / 竖屏 / 正方形)
// - 清晰度算哪个档?(720P / 1080P / 4K…;不标准的按长边大小给个约数,如 5K)
// - 图片还要看是暗调还是亮调(dark / mid / bright),作为子目录
// - 图片还要看是暗调还是亮调(dark_25 / dark_50 / dark_75),以及色情评分档(安全 / 低俗 / 擦边 / 色情)
//
// 最终拼出一个文件夹名,比如:
// - 「图片_横屏_1080P/bright_20」 一张横向、1080P、偏亮的图片
// - 「图片_横屏_1080P-dark_25-安全」 一张横向、1080P、偏亮、安全的图片
// - 「视频_竖屏_4k」 一个竖向、4K 的视频
//
// 这个包全是"纯函数"——不依赖任何状态,给什么算什么,好理解也好测。
@@ -55,26 +55,18 @@ func (o Orientation) String() string {
// ============================ 归类目录名 ============================
// ImageDir 返回一张图片该进的文件夹路径,比如:
// - 「图片_GIF」:GIF 动图单独放(不做明暗分类)
// - 「图片_横屏_1080P/bright_20」:普通图片 = 方向 + 清晰度,明暗作为其子目录
func ImageDir(ext string, width, height int, darkScore, lightScore float64) string {
return imageDir("图片", ext, width, height, darkScore, lightScore)
}
// ImageDirSensitive 返回一张被判为色情的图片该进的文件夹路径,比如:
// - 「图片_敏感_GIF」:GIF 单独放(不做明暗分类)
// - 「图片_敏感_横屏_1080P/bright_20」:普通分类前加「_敏感」前缀,明暗仍是子目录
func ImageDirSensitive(ext string, width, height int, darkScore, lightScore float64) string {
return imageDir("图片_敏感", ext, width, height, darkScore, lightScore)
}
// imageDir 拼出"类型 + 方向 + 清晰度 + 明暗子目录"这段路径。
func imageDir(kind, ext string, width, height int, darkScore, lightScore float64) string {
// - 「图片_GIF」:GIF 动图单独放(不做明暗/色情分类)
// - 「图片_横屏_1080P-dark_25-安全」:普通图片 = 方向 + 清晰度 + 明暗 + 色情评分档
func ImageDir(ext string, width, height int, darkScore, lightScore, nsfwScore float64, nsfwEnabled bool) string {
if strings.EqualFold(ext, ".gif") {
return kind + "_GIF"
return "图片_GIF"
}
dir := resolutionDir(kind, ext, width, height)
return dir + "/" + BrightnessName(darkScore, lightScore)
dir := resolutionDir("图片", ext, width, height)
result := dir + "-" + BrightnessName(darkScore, lightScore)
if nsfwEnabled {
result += "-" + NsfwLevelName(nsfwScore)
}
return result
}
// VideoDir 返回一个视频该进的文件夹名,比如「视频_竖屏_4k」「视频_横屏_GIF」。
+17 -31
View File
@@ -58,9 +58,8 @@ const requestTimeout = 30 * time.Second
// 多个探测协程会并发调 Detect,底层 HTTP 客户端线程安全;
// 服务端用固定大小线程池并行推理(每个 worker 一份独立模型实例),请求不必排队。
type Detector struct {
url string // 本地服务地址,如 http://127.0.0.1:54321
threshold float64 // 判定阈值 0~1
workers int // Python 推理 worker 数量
url string // 本地服务地址,如 http://127.0.0.1:54321
workers int // Python 推理 worker 数量
client *http.Client
cmd *exec.Cmd // python 进程句柄(Stop 时杀掉)
scriptDir string // 临时脚本所在目录(Stop 时清理)
@@ -75,14 +74,10 @@ type Detector struct {
// Start 拉起本地 python 检测服务并等它就绪。
// - pythonPath:python 可执行文件路径;为空时在 PATH 里找 python/python3。
// - threshold:判定阈值 0~1。
// - workers:Python 推理 worker 数量;<=0 表示使用默认值(4)。
//
// 找不到 python、装不了 nudenet、或服务起不来时返回错误(调用方跳过识别即可)。
func Start(pythonPath string, threshold float64, workers int) (*Detector, error) {
if threshold <= 0 || threshold >= 1 {
threshold = 0.5 // 阈值传得不合理就用默认
}
func Start(pythonPath string, workers int) (*Detector, error) {
if workers <= 0 {
workers = 4 // 默认 4 个 worker
}
@@ -111,7 +106,6 @@ func Start(pythonPath string, threshold float64, workers int) (*Detector, error)
}
d := &Detector{
threshold: threshold,
workers: workers,
client: &http.Client{Timeout: requestTimeout},
python: py,
@@ -222,32 +216,31 @@ func (d *Detector) waitReady() error {
return fmt.Errorf("检测服务在 %v 内未就绪", detectorStartTimeout)
}
// Detect 检测一张图片是否色情。返回:
// Detect 检测一张图片的色情评分。返回:
// - score:命中的敏感类里最高分数(没有命中为 0)
// - nsfw:是否判定为色情(有敏感类且分数 >= 阈值)
// - detections:所有检测到的目标(类别+分数+位置)
// - err:检测失败(服务挂了/图读不出等),由调用方兜底按未命中处理
//
// 服务失联(连接被拒,多半是 python 进程在处理某张图时崩了)时,会串行化地
// 重启服务并重试一次:多个并发 worker 里只有一个负责重启,其余排队,等重启完
// 也重试自己的图——保证服务短暂挂掉不会漏掉一整批图片。
func (d *Detector) Detect(path string) (score float64, nsfw bool, detections []NsfwDetection, err error) {
score, nsfw, detections, err = d.detectOnce(path)
func (d *Detector) Detect(path string) (score float64, detections []NsfwDetection, err error) {
score, detections, err = d.detectOnce(path)
if err == nil {
return score, nsfw, detections, nil
return score, detections, nil
}
// 只有"发请求都失败"(连接被拒/重置,说明服务失联)才走重启;
// 服务正常返回的明确错误(如某张图读不了)不重启。
var ce *connError
if !errors.As(err, &ce) {
return score, nsfw, detections, err
return score, detections, err
}
d.restartMu.Lock()
defer d.restartMu.Unlock()
if d.isStopped() {
return 0, false, nil, err
return 0, nil, err
}
// 可能排在前面的 goroutine 已经把服务重启好了:先探活,活着就直接重试,别反复重启
if d.healthy() {
@@ -255,7 +248,7 @@ func (d *Detector) Detect(path string) (score float64, nsfw bool, detections []N
}
fmt.Printf("=== 色情检测服务失联(%v), 正在重启…\n", ce.err)
if rerr := d.launch(); rerr != nil {
return 0, false, nil, fmt.Errorf("检测服务重启失败: %v", rerr)
return 0, nil, fmt.Errorf("检测服务重启失败: %v", rerr)
}
fmt.Println("=== 色情检测服务已重启,继续识别")
return d.detectOnce(path)
@@ -285,33 +278,31 @@ func (d *Detector) isStopped() bool {
}
// detectOnce 发一次检测请求(不带重启逻辑)。
func (d *Detector) detectOnce(path string) (score float64, nsfw bool, detections []NsfwDetection, err error) {
func (d *Detector) detectOnce(path string) (score float64, detections []NsfwDetection, err error) {
body, _ := json.Marshal(map[string]interface{}{
"path": path,
"threshold": d.threshold,
"path": path,
})
resp, err := d.client.Post(d.url+"/detect", "application/json", bytes.NewReader(body))
if err != nil {
// 打上"连接失败"标记,供 Detect 判断是否要重启
return 0, false, nil, &connError{err}
return 0, nil, &connError{err}
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return 0, false, nil, err
return 0, nil, err
}
var out struct {
Ok bool `json:"ok"`
Error string `json:"error"`
Nsfw bool `json:"nsfw"`
Score float64 `json:"score"`
Detections []map[string]interface{} `json:"detections"`
}
if err := json.Unmarshal(data, &out); err != nil {
return 0, false, nil, fmt.Errorf("解析检测结果失败: %v", err)
return 0, nil, fmt.Errorf("解析检测结果失败: %v", err)
}
if !out.Ok {
return 0, false, nil, fmt.Errorf("检测服务返回错误: %s", out.Error)
return 0, nil, fmt.Errorf("检测服务返回错误: %s", out.Error)
}
// 解析检测详情
detections = make([]NsfwDetection, 0, len(out.Detections))
@@ -337,7 +328,7 @@ func (d *Detector) detectOnce(path string) (score float64, nsfw bool, detections
} else {
fmt.Printf("=== NSFW 检测详情 %s: [无检测结果]\n", path)
}
return out.Score, out.Nsfw, detections, nil
return out.Score, detections, nil
}
// connError 标记"请求根本没发出去/连不上本地服务"的错误(服务失联)。
@@ -360,11 +351,6 @@ func (d *Detector) Stop() {
}
}
// Threshold 返回当前使用的判定阈值(0~1)。给上层记录结果用。
func (d *Detector) Threshold() float64 {
return d.threshold
}
// resolvePython 在配置值 / PATH 里找可用的 python 解释器。
// Windows 上常见 python / python3 / py;Linux/mac 常见 python3 / python。
func resolvePython(configured string) string {
+1 -5
View File
@@ -284,7 +284,6 @@ class Handler(BaseHTTPRequestHandler):
try:
body = json.loads(self.rfile.read(length) or b"{}")
path = body.get("path", "")
threshold = float(body.get("threshold", 0.5))
if not path:
return self._send({"ok": False, "error": "缺少图片路径"})
# 丢给推理线程池:读文件并行、推理由 N 个 worker 并行处理
@@ -293,16 +292,13 @@ class Handler(BaseHTTPRequestHandler):
return self._send({"ok": False, "error": str(exc)})
max_score = 0.0
nsfw = False
for det in detections or []:
cls = det.get("class", "")
sc = float(det.get("score", 0) or 0)
if cls in SENSITIVE:
if sc > max_score:
max_score = sc
if sc >= threshold:
nsfw = True
self._send({"ok": True, "nsfw": nsfw, "score": max_score, "detections": detections})
self._send({"ok": True, "score": max_score, "detections": detections})
def log_message(self, *args): # 不刷屏
pass
+16 -46
View File
@@ -83,9 +83,7 @@ type Organizer struct {
resumeSkip int32 // 本次续传跳过的文件数(并行累计)
pendingScan []store.ScanLog // 待写入续传表的记录(攒一批再批量写)
nsfwChecked int32 // 色情识别已检查的图片数(并行累计,只涨不跌)
nsfwHits int32 // 色情识别命中的图片数(并行累计)
animeNsfwChecked int32 // 动漫色情识别已检查的图片数
animeNsfwHits int32 // 动漫色情识别命中的图片数
// 阶段耗时统计(并行 worker 累计,单位纳秒,只用于日志诊断瓶颈)
tImageProbe int64 // 图片尺寸探测总耗时
tImgMd5 int64 // 图片 MD5/指纹计算总耗时
@@ -405,10 +403,8 @@ type imageProbeResult struct {
darkScore float64 // 暗色像素占比(用于亮度分类)
lightScore float64 // 亮色像素占比
nsfwScore float64 // 色情检测最高分(未检测为 0)
nsfw bool // 是否判定为色情(检测可用时才可能为 true)
nsfwDetections []nsfw.NsfwDetection // NudeNet 检测详情
animeNsfwScore float64 // 动漫色情检测最高分(未检测为 0)
animeNsfw bool // 是否判定为动漫色情
animeNsfwRatings anime_nsfw.AnimeRatings // WD Tagger 四个 rating 的原始分数
}
@@ -465,48 +461,36 @@ func (o *Organizer) probeImage(f scanner.File) imageProbeResult {
// 色情识别(GIF 不做;没启用检测器就直接跳过)
if o.nsfw != nil && !strings.EqualFold(f.Ext, ".gif") {
nsfwStart := time.Now()
score, hit, detections, derr := o.nsfw.Detect(f.Path)
score, detections, derr := o.nsfw.Detect(f.Path)
atomic.AddInt64(&o.tNsfw, int64(time.Since(nsfwStart)))
if derr != nil {
// 检测失败按"未命中"处理,不影响整理流程
fmt.Printf("=== 色情检测失败(按未命中归类): %s, 错误: %v \n", f.Path, derr)
// 检测失败不影响整理流程
fmt.Printf("=== 色情检测失败: %s, 错误: %v \n", f.Path, derr)
} else {
r.nsfwScore = score
r.nsfw = hit
r.nsfwDetections = detections
checked := atomic.AddInt32(&o.nsfwChecked, 1)
if hit {
atomic.AddInt32(&o.nsfwHits, 1)
fmt.Printf("=== 色情命中: %s, 检测分=%.4f (阈值=%.4f)\n", f.Path, score, o.nsfw.Threshold())
} else {
fmt.Printf("=== 色情未命中: %s, 检测分=%.4f (阈值=%.4f)\n", f.Path, score, o.nsfw.Threshold())
}
fmt.Printf("=== 色情检测: %s, 评分=%.4f\n", f.Path, score)
// 每固定张数打一行进度,让前端能看到识别在推进(不逐张刷屏)
if checked%nsfwProgressInterval == 0 {
fmt.Printf("=== 色情识别进度: 已检查 %d 张, 命中 %d 张\n", checked, atomic.LoadInt32(&o.nsfwHits))
fmt.Printf("=== 色情识别进度: 已检查 %d 张\n", checked)
}
}
}
// 动漫色情识别(GIF 不做;没启用检测器就直接跳过)
if o.animeNsfw != nil && !strings.EqualFold(f.Ext, ".gif") {
animeStart := time.Now()
score, hit, ratings, derr := o.animeNsfw.Detect(f.Path)
score, ratings, derr := o.animeNsfw.Detect(f.Path)
atomic.AddInt64(&o.tAnimeNsfw, int64(time.Since(animeStart)))
if derr != nil {
fmt.Printf("=== 动漫色情检测失败(按未命中归类): %s, 错误: %v \n", f.Path, derr)
fmt.Printf("=== 动漫色情检测失败: %s, 错误: %v \n", f.Path, derr)
} else {
r.animeNsfwScore = score
r.animeNsfw = hit
r.animeNsfwRatings = ratings
checked := atomic.AddInt32(&o.animeNsfwChecked, 1)
if hit {
atomic.AddInt32(&o.animeNsfwHits, 1)
fmt.Printf("=== 动漫色情命中: %s, 检测分=%.4f (阈值=%.4f)\n", f.Path, score, o.animeNsfw.Threshold())
} else {
fmt.Printf("=== 动漫色情未命中: %s, 检测分=%.4f (阈值=%.4f)\n", f.Path, score, o.animeNsfw.Threshold())
}
fmt.Printf("=== 动漫色情检测: %s, 评分=%.4f\n", f.Path, score)
if checked%nsfwProgressInterval == 0 {
fmt.Printf("=== 动漫色情识别进度: 已检查 %d 张, 命中 %d 张\n", checked, atomic.LoadInt32(&o.animeNsfwHits))
fmt.Printf("=== 动漫色情识别进度: 已检查 %d 张\n", checked)
}
}
}
@@ -554,7 +538,6 @@ func (o *Organizer) correctImageRotation(f scanner.File, res *probe.ProbeResult)
func (o *Organizer) ProcessImages(images []scanner.File) {
// 统计结果,最后打一行总结给用户看
success, fail, ignore, dup := 0, 0, 0, 0
nsfwCount := 0 // 命中的色情图片数
total := len(images)
phaseStart := time.Now()
if o.nsfw != nil {
@@ -596,13 +579,11 @@ func (o *Organizer) ProcessImages(images []scanner.File) {
dup++
fmt.Printf("=== 重复图片(同路径), 忽略: %s \n", f.Path)
default:
// 正常图片:按分辨率/方向/明暗归入对应的分类目录;
// 被判为色情的改去「图片_敏感_*」目录,明暗子目录照常保留
// 正常图片:按分辨率/方向/明暗/色情评分归入对应的分类目录
info := o.record(f, "image", r.res, r.md5Str, r.headMd5, r.tailMd5, r.size)
// 检测过的图片都记录分数(不管是否命中阈值),方便后续调整阈值重新判定
// 检测过的图片都记录分数
if info != nil && r.nsfwScore > 0 {
info.NsfwScore = r.nsfwScore
info.NsfwThreshold = o.nsfw.Threshold()
if len(r.nsfwDetections) > 0 {
if detJSON, err := json.Marshal(r.nsfwDetections); err == nil {
info.NsfwDetections = string(detJSON)
@@ -611,20 +592,16 @@ func (o *Organizer) ProcessImages(images []scanner.File) {
}
if info != nil && (r.animeNsfwScore > 0 || r.animeNsfwRatings.General > 0) {
info.AnimeNsfwScore = r.animeNsfwScore
info.AnimeNsfwThreshold = o.animeNsfw.Threshold()
if ratingsJSON, err := json.Marshal(r.animeNsfwRatings); err == nil {
info.AnimeNsfwRatings = string(ratingsJSON)
}
}
// 真人色情或动漫色情任一命中,都归入敏感目录
if r.nsfw || r.animeNsfw {
o.addClassified(f, classify.ImageDirSensitive(f.Ext, r.res.Width, r.res.Height, r.darkScore, r.lightScore), r.size, r.headMd5, r.tailMd5)
if r.nsfw {
nsfwCount++
}
} else {
o.addClassified(f, classify.ImageDir(f.Ext, r.res.Width, r.res.Height, r.darkScore, r.lightScore), r.size, r.headMd5, r.tailMd5)
nsfwEnabled := o.nsfw != nil || o.animeNsfw != nil
nsfwScore := r.nsfwScore
if r.animeNsfwScore > nsfwScore {
nsfwScore = r.animeNsfwScore
}
o.addClassified(f, classify.ImageDir(f.Ext, r.res.Width, r.res.Height, r.darkScore, r.lightScore, nsfwScore, nsfwEnabled), r.size, r.headMd5, r.tailMd5)
success++
}
}
@@ -641,13 +618,6 @@ func (o *Organizer) ProcessImages(images []scanner.File) {
time.Duration(atomic.LoadInt64(&o.tBright)).Seconds(),
time.Duration(atomic.LoadInt64(&o.tNsfw)).Seconds(),
time.Duration(atomic.LoadInt64(&o.tAnimeNsfw)).Seconds())
animeNsfwHits := atomic.LoadInt32(&o.animeNsfwHits)
if o.nsfw != nil && nsfwCount > 0 {
fmt.Printf("=== 色情识别: 共命中 %d 张,已归入「图片_敏感_*」目录,可在【媒体库】页面查看并通过<色情标记>人工复核\n", nsfwCount)
}
if o.animeNsfw != nil && animeNsfwHits > 0 {
fmt.Printf("=== 动漫色情识别: 共命中 %d 张,已归入「图片_敏感_*」目录,可在【媒体库】页面查看并通过<色情标记>人工复核\n", animeNsfwHits)
}
}
// ============================ 视频/音频整理 ============================
+2 -14
View File
@@ -699,19 +699,13 @@ func (s *ScanService) runScan(ctx context.Context, rootDir, destDir string) erro
nsfwDet := (*nsfw.Detector)(nil)
s.setNsfwDetectorReady(false)
if nsfwType == "human" {
threshold := 0.5
if v := s.store.GetConfig("nsfw.threshold"); v != "" {
if t, err := strconv.ParseFloat(v, 64); err == nil && t > 0 && t < 1 {
threshold = t
}
}
workers := 4
if v := s.store.GetConfig("nsfw.workers"); v != "" {
if w, err := strconv.Atoi(v); err == nil && w > 0 {
workers = w
}
}
if d, err := nsfw.Start("", threshold, workers); err != nil {
if d, err := nsfw.Start("", workers); err != nil {
fmt.Printf("=== 色情识别未启用(图片照常处理): %v\n", err)
} else {
nsfwDet = d
@@ -724,19 +718,13 @@ func (s *ScanService) runScan(ctx context.Context, rootDir, destDir string) erro
// ---------- 第六步(可选):动漫色情识别 ----------
animeNsfwDet := (*anime_nsfw.Detector)(nil)
if nsfwType == "anime" {
threshold := 0.5
if v := s.store.GetConfig("anime.threshold"); v != "" {
if t, err := strconv.ParseFloat(v, 64); err == nil && t > 0 && t < 1 {
threshold = t
}
}
workers := 2
if v := s.store.GetConfig("anime.workers"); v != "" {
if w, err := strconv.Atoi(v); err == nil && w > 0 {
workers = w
}
}
if d, err := anime_nsfw.Start("", threshold, workers); err != nil {
if d, err := anime_nsfw.Start("", workers); err != nil {
fmt.Printf("=== 动漫色情识别未启用(图片照常处理): %v\n", err)
} else {
animeNsfwDet = d
+26 -28
View File
@@ -31,31 +31,29 @@ func init() {
// MediaInfo 一条媒体文件的记录:文件本身的信息——路径、名字、大小、类型、宽高、编码、格式等。
type MediaInfo struct {
Id int64 `xorm:"pk autoincr 'id'" json:"id"`
FileMd5 string `xorm:"char(32) index 'file_md5'" json:"fileMd5"` // 文件 MD5,内容相同则相同,用来判断是不是同一个文件
FilePath string `xorm:"text 'file_path'" json:"filePath"` // 扫描时的源路径(在哪发现的)
FileName string `xorm:"varchar(255) 'file_name'" json:"fileName"` // 文件名(含扩展名)
FileExt string `xorm:"varchar(32) 'file_ext'" json:"fileExt"` // 扩展名(如 .jpg)
FileSize int64 `xorm:"bigint 'file_size'" json:"fileSize"` // 文件大小(字节)
MimeType string `xorm:"varchar(128) 'mime_type'" json:"mimeType"` // MIME 类型(如 image/jpeg)
MediaType string `xorm:"varchar(16) index 'media_type'" json:"mediaType"` // 大类型:image / video / audio
Width int `xorm:"int index 'width'" json:"width"` // 宽(像素)
Height int `xorm:"int index 'height'" json:"height"` // 高(像素)
Duration float64 `xorm:"real 'duration'" json:"duration"` // 时长(秒)
Codec string `xorm:"varchar(64) 'codec'" json:"codec"` // 编码,如 h264 / hevc
Format string `xorm:"varchar(64) 'format'" json:"format"` // 容器格式,如 mp4 / mkv
RawInfo string `xorm:"text 'raw_info'" json:"rawInfo"` // ffprobe 读出的原始信息(JSON),留底备用
HeadMd5 string `xorm:"char(32) index('idx_fp') 'head_md5'" json:"headMd5"` // 文件头 256KB 的 MD5(指纹用)
TailMd5 string `xorm:"char(32) index('idx_fp') 'tail_md5'" json:"tailMd5"` // 文件尾 256KB 的 MD5(指纹用)
NsfwScore float64 `xorm:"real 'nsfw_score'" json:"nsfwScore"` // NSFW 检测分 0~1(0 表示未检测或非图片)
NsfwThreshold float64 `xorm:"real 'nsfw_threshold'" json:"nsfwThreshold"` // NSFW 判定阈值
NsfwDetections string `xorm:"text 'nsfw_detections'" json:"nsfwDetections"` // NudeNet 检测详情 JSON(类别+分数+位置)
AnimeNsfwScore float64 `xorm:"real 'anime_nsfw_score'" json:"animeNsfwScore"` // 动漫 NSFW 检测分 0~1(0 表示未检测或非动漫)
AnimeNsfwThreshold float64 `xorm:"real 'anime_nsfw_threshold'" json:"animeNsfwThreshold"` // 动漫 NSFW 判定阈值
AnimeNsfwRatings string `xorm:"text 'anime_nsfw_ratings'" json:"animeNsfwRatings"` // WD Tagger 四个 rating 的 JSON(G/S/Q/E)
ManualNsfw string `xorm:"varchar(1) 'manual_nsfw'" json:"manualNsfw"` // 手动标记是否色情:Y/N,默认 N
CreatedAt time.Time `xorm:"created 'created_at'" json:"createdAt"` // 首次入库时间
UpdatedAt time.Time `xorm:"updated 'updated_at'" json:"updatedAt"` // 最近更新时间
Id int64 `xorm:"pk autoincr 'id'" json:"id"`
FileMd5 string `xorm:"char(32) index 'file_md5'" json:"fileMd5"` // 文件 MD5,内容相同则相同,用来判断是不是同一个文件
FilePath string `xorm:"text 'file_path'" json:"filePath"` // 扫描时的源路径(在哪发现的)
FileName string `xorm:"varchar(255) 'file_name'" json:"fileName"` // 文件名(含扩展名)
FileExt string `xorm:"varchar(32) 'file_ext'" json:"fileExt"` // 扩展名(如 .jpg)
FileSize int64 `xorm:"bigint 'file_size'" json:"fileSize"` // 文件大小(字节)
MimeType string `xorm:"varchar(128) 'mime_type'" json:"mimeType"` // MIME 类型(如 image/jpeg)
MediaType string `xorm:"varchar(16) index 'media_type'" json:"mediaType"` // 大类型:image / video / audio
Width int `xorm:"int index 'width'" json:"width"` // 宽(像素)
Height int `xorm:"int index 'height'" json:"height"` // 高(像素)
Duration float64 `xorm:"real 'duration'" json:"duration"` // 时长(秒)
Codec string `xorm:"varchar(64) 'codec'" json:"codec"` // 编码,如 h264 / hevc
Format string `xorm:"varchar(64) 'format'" json:"format"` // 容器格式,如 mp4 / mkv
RawInfo string `xorm:"text 'raw_info'" json:"rawInfo"` // ffprobe 读出的原始信息(JSON),留底备用
HeadMd5 string `xorm:"char(32) index('idx_fp') 'head_md5'" json:"headMd5"` // 文件头 256KB 的 MD5(指纹用)
TailMd5 string `xorm:"char(32) index('idx_fp') 'tail_md5'" json:"tailMd5"` // 文件尾 256KB 的 MD5(指纹用)
NsfwScore float64 `xorm:"real 'nsfw_score'" json:"nsfwScore"` // NSFW 检测分 0~1(0 表示未检测或非图片)
NsfwDetections string `xorm:"text 'nsfw_detections'" json:"nsfwDetections"` // NudeNet 检测详情 JSON(类别+分数+位置)
AnimeNsfwScore float64 `xorm:"real 'anime_nsfw_score'" json:"animeNsfwScore"` // 动漫 NSFW 检测分 0~1(0 表示未检测或非动漫)
AnimeNsfwRatings string `xorm:"text 'anime_nsfw_ratings'" json:"animeNsfwRatings"` // WD Tagger 四个 rating 的 JSON(G/S/Q/E)
ManualNsfw string `xorm:"varchar(1) 'manual_nsfw'" json:"manualNsfw"` // 手动标记是否色情:Y/N,默认 N
CreatedAt time.Time `xorm:"created 'created_at'" json:"createdAt"` // 首次入库时间
UpdatedAt time.Time `xorm:"updated 'updated_at'" json:"updatedAt"` // 最近更新时间
}
// Store 数据库访问的封装。整个程序共用一个实例,通过它读写数据库。
@@ -358,7 +356,7 @@ type MediaQuery struct {
Codec string // 编码
Format string // 容器格式
Resolution string // 分辨率档位(如 1080P / 5K / 普通 / 原图)
NsfwOnly bool // 只看色情图片(nsfw_score >= nsfw_threshold 且 threshold > 0)
NsfwOnly bool // 只看有检测分的图片(nsfw_score > 0 或 anime_nsfw_score > 0)
Page int // 第几页(从 1 开始)
PageSize int // 每页几条
SortField string // 按哪个字段排序:id / fileSize / width / duration / createdAt
@@ -457,9 +455,9 @@ func buildMediaCond(q MediaQuery) (string, []interface{}) {
}
}
}
// 色情筛选:只看检测分 >= 阈值的图片
// 色情筛选:只看有检测分的图片(nsfw_score > 0)
if q.NsfwOnly {
appendCond("nsfw_score >= nsfw_threshold AND nsfw_threshold > 0")
appendCond("nsfw_score > 0 OR anime_nsfw_score > 0")
}
if cond == "" {
return "", nil