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