refactor: 移除二次元识别功能,增强 NSFW 检测

This commit is contained in:
2025-07-12 21:04:52 +08:00
parent 2778393e9d
commit f589592535
17 changed files with 43 additions and 12217 deletions
-1
View File
@@ -1,4 +1,3 @@
.idea/
media-info.db
__pycache__/
lib/wd-tagger/model.onnx
+4 -12
View File
@@ -2,7 +2,7 @@
appname=kenaito-media-picker
server.port=8080
# 启动后自动打开浏览器(false 则不自动打开)
server.autoOpen=true
server.autoOpen=false
# 媒体信息数据库路径;留空或相对路径时相对服务启动目录(如 media-info.db)
database.dsn=media-info.db
# ffmpeg 程序路径(用于把带 EXIF 旋转标记的图片转正);留空自动在 PATH 里找
@@ -12,23 +12,15 @@ ffmpeg.path=
organize.rotateImages=false
# 探测/认类型/移动阶段的并发 worker 数(默认 4;0 表示用满全部 CPU 核数)
organize.workers=8
# 扫描完成后是否移动文件到分类目录(true=移动,默认;false=只归类不移动,文件留在原位)
organize.moveFiles=true
# 色情图片识别(需本机安装 python 与 nudenet:pip install nudenet)
# true=开启,判为色情的图片移入「图片_敏感_*」目录(明暗子目录照常);false=关闭(默认)
# 模型:优先加载程序运行目录下 lib/nwfs/320n.onnx(推荐随程序分发,离线可用);
# 本地没有会自动下载到该位置,下载失败再回退用 nudenet 自带模型
# NSFW是用来识别真人图片的
nsfw.enabled=false
nsfw.enabled=true
# python 可执行文件路径;留空自动在 PATH 里找 python/python3/py
nsfw.python=C:\\Users\\Administrator\\AppData\\Local\\Programs\\Python\\Python312\\python.exe
# 判定阈值 0~1,敏感部位检测分高于此才算色情(默认 0.2,实测)
nsfw.threshold=0.2
# 二次元图片识别(需本机安装 python 与 onnxruntime、Pillow、numpy:pip install onnxruntime Pillow numpy)
# true=开启,判为二次元敏感(questionable/explicit)的图片移入「图片_二次元_*」目录;false=关闭(默认)
# 模型:首次启动会自动从 HuggingFace 下载 wd-vit-large-tagger-v3 到 lib/wd-tagger/(约 1.5GB)
# Hentai是用来识别二次元/动漫风格图片的
hentai.enabled=true
# python 可执行文件路径;留空自动在 PATH 里找 python/python3/py(和 nsfw.python 共用同一个即可)
hentai.python=C:\\Users\\Administrator\\AppData\\Local\\Programs\\Python\\Python312\\python.exe
# 判定阈值 0~1,questionable 或 explicit 分数高于此才算二次元敏感(默认 0.5)
hentai.threshold=0.5
-4
View File
@@ -28,10 +28,6 @@
<i class="el-icon-view"></i>
<span slot="title">色情识别</span>
</el-menu-item>
<el-menu-item index="/hentai">
<i class="el-icon-picture"></i>
<span slot="title">二次元识别</span>
</el-menu-item>
</el-menu>
<div class="aside-footer">kenaito-media-picker</div>
</el-aside>
-2
View File
@@ -3,7 +3,6 @@ import Router from 'vue-router'
import Dashboard from '../views/Dashboard.vue'
import MediaList from '../views/MediaList.vue'
import NsfwList from '../views/NsfwList.vue'
import HentaiList from '../views/HentaiList.vue'
import Scan from '../views/Scan.vue'
Vue.use(Router)
@@ -14,7 +13,6 @@ export default new Router({
{path: '/dashboard', component: Dashboard},
{path: '/media', component: MediaList},
{path: '/nsfw', component: NsfwList},
{path: '/hentai', component: HentaiList},
{path: '/scan', component: Scan}
]
})
-255
View File
@@ -1,255 +0,0 @@
<template>
<div class="page-card">
<div class="toolbar">
<span class="title">二次元识别</span>
<div class="toolbar-right">
<span class="hint" v-if="!hentaiEnabled">识别功能未开启:请在 application.properties 设 hentai.enabled=true 并重新扫描</span>
<span class="hint" v-else-if="!hentaiDetector">识别已配置开启,但最近一次扫描时检测服务没起来:请确认本机已安装 python 和 onnxruntime/Pillow/numpy,再重新扫描</span>
<el-button type="primary" size="small" icon="el-icon-refresh" @click="loadList">刷新</el-button>
</div>
</div>
<div class="tip-bar">
<i class="el-icon-view"></i>
这是最近一次开启二次元识别的扫描判出的图片,按检测分从高到低排列。点缩略图可放大预览,用来核对识别结果准不准。
</div>
<el-table :data="list" v-loading="loading" border stripe>
<el-table-column label="预览" width="110" align="center">
<template slot-scope="scope">
<el-image
class="thumb"
:src="previewUrl(scope.row.path)"
:preview-src-list="[previewUrl(scope.row.path)]"
fit="cover"
lazy
style="width: 84px; height: 84px; border-radius: 6px; border: 1px solid #e6efff">
<div slot="error" class="thumb-error">
<i class="el-icon-picture-outline"></i>
</div>
</el-image>
</template>
</el-table-column>
<el-table-column prop="fileName" label="文件名" min-width="140" show-overflow-tooltip>
<template slot-scope="scope">
<span class="file-name" :title="scope.row.path">{{ scope.row.fileName }}</span>
</template>
</el-table-column>
<el-table-column label="分级" width="120" align="center">
<template slot-scope="scope">
<span class="rating" :class="'rating-' + scope.row.rating">{{ ratingLabel(scope.row.rating) }}</span>
</template>
</el-table-column>
<el-table-column label="检测分" width="160" align="center">
<template slot-scope="scope">
<span class="score" :class="scoreTag(scope.row)">{{ (scope.row.score * 100).toFixed(1) }}%</span>
</template>
</el-table-column>
<el-table-column label="阈值" width="100" align="center">
<template slot-scope="scope">
<span class="muted">{{ (scope.row.threshold * 100).toFixed(1) }}%</span>
</template>
</el-table-column>
<el-table-column label="标签" min-width="200" show-overflow-tooltip>
<template slot-scope="scope">
<span class="tags-text">{{ formatTags(scope.row.topTags) }}</span>
</template>
</el-table-column>
<el-table-column label="完整路径" min-width="240" show-overflow-tooltip>
<template slot-scope="scope">
<span class="path-text">{{ scope.row.path }}</span>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="检测时间" width="180" align="center">
<template slot-scope="scope">
<span class="muted">{{ scope.row.createdAt }}</span>
</template>
</el-table-column>
</el-table>
<el-empty
v-if="!loading && list.length === 0"
:description="emptyDesc"/>
</div>
</template>
<script>
import request from '../utils/request'
/**
* 二次元识别页面。
* 展示最近一次开启二次元识别的扫描判出的图片:分级 + 检测分 + 标签 + 完整路径 + 图片预览。
* 数据来自 /api/hentai/list,预览走 /api/hentai/preview(后端按路径读本地文件)。
*/
export default {
name: 'HentaiList',
data() {
return {
loading: false,
list: [],
hentaiEnabled: true, // 配置里是否开了 hentai.enabled
hentaiDetector: true, // 最近一次扫描时检测服务是否真的起来了
hentaiConfigured: true // 上两者都满足才算"识别可用"
}
},
computed: {
// 空列表时的提示文案
emptyDesc() {
if (this.hentaiConfigured) {
return '识别已开启,最近一次扫描没有判出二次元敏感图片。'
}
return '暂无记录。开启 hentai.enabled 并确保检测服务可用后重新扫描,判为二次元敏感的图片会出现在这里。'
}
},
mounted() {
this.loadList()
},
methods: {
async loadList() {
this.loading = true
try {
const res = await request.get('/api/hentai/list')
this.list = res.data.list || []
// 后端真实上报:配置是否开启、检测服务上次是否真的起来了
this.hentaiEnabled = res.data.hentaiEnabled !== false
this.hentaiDetector = res.data.hentaiDetector === true
this.hentaiConfigured = this.hentaiEnabled && this.hentaiDetector
} finally {
this.loading = false
}
},
previewUrl(path) {
if (!path) return ''
return '/api/hentai/preview?path=' + encodeURIComponent(path)
},
// 分级标签翻译
ratingLabel(rating) {
const map = {
'safe': '安全',
'questionable': '可疑',
'explicit': '明确'
}
return map[rating] || rating || '未知'
},
// 检测分越接近 1 越"实锤":>=90% 红色,>=75% 橙色,否则灰色
scoreTag(row) {
if (row.score >= 0.9) return 'score-high'
if (row.score >= 0.75) return 'score-mid'
return ''
},
// 格式化标签:把 JSON 数组转成可读字符串
formatTags(topTags) {
if (!topTags) return ''
try {
const tags = JSON.parse(topTags)
if (!Array.isArray(tags)) return ''
return tags.map(t => `${t.tag}(${(t.score * 100).toFixed(0)}%)`).join(', ')
} catch (e) {
return ''
}
}
}
}
</script>
<style scoped>
.toolbar-right {
display: flex;
align-items: center;
gap: 12px;
}
.hint {
color: #e6a23c;
font-size: 12px;
}
.tip-bar {
display: flex;
align-items: center;
gap: 8px;
background: #f8fbff;
border: 1px solid #e6efff;
border-radius: 8px;
padding: 10px 14px;
color: #4a5a78;
font-size: 13px;
margin-bottom: 16px;
}
.tip-bar .el-icon-view {
color: #1677ff;
flex-shrink: 0;
}
.thumb {
display: block;
cursor: zoom-in;
}
.thumb-error {
width: 84px;
height: 84px;
display: flex;
align-items: center;
justify-content: center;
background: #f5f7fa;
color: #c0c4cc;
font-size: 26px;
border-radius: 6px;
}
.file-name {
color: #1f3a6b;
}
.path-text {
color: #606266;
font-size: 12px;
word-break: break-all;
}
.tags-text {
color: #606266;
font-size: 12px;
}
.rating {
font-weight: 700;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
}
.rating-safe {
color: #67c23a;
background: #f0f9ff;
}
.rating-questionable {
color: #e6a23c;
background: #fdf6ec;
}
.rating-explicit {
color: #f56c6c;
background: #fef0f0;
}
.score {
font-weight: 700;
color: #909399;
}
.score-high {
color: #f56c6c;
}
.score-mid {
color: #e6a23c;
}
.muted {
color: #c0c4cc;
}
</style>
-121
View File
@@ -1,121 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"os"
"strings"
"github.com/gin-gonic/gin"
"kenaito-media-picker/internal/config"
"kenaito-media-picker/internal/store"
)
// hentaiDetectorReadyKey 记录最近一次扫描时二次元检测服务是否真的起来了。
const hentaiDetectorReadyKey = "hentai.detectorReady"
// HentaiController 二次元识别相关接口的控制器。
type HentaiController struct {
store *store.Store
}
// NewHentaiController 创建二次元识别控制器。
func NewHentaiController(st *store.Store) *HentaiController {
return &HentaiController{store: st}
}
// hentaiView 列表里展示的一条记录。
type hentaiView struct {
Id int64 `json:"id"`
FileName string `json:"fileName"`
Score float64 `json:"score"`
Rating string `json:"rating"`
Threshold float64 `json:"threshold"`
Path string `json:"path"`
TopTags string `json:"topTags"`
CreatedAt string `json:"createdAt"`
}
// List 处理 GET /api/hentai/list:查全部二次元识别记录(按检测分从高到低)。
func (c *HentaiController) List(ctx *gin.Context) {
list, err := c.store.ListHentai()
if err != nil {
Error(ctx, http.StatusInternalServerError, "查询二次元识别记录失败:"+err.Error())
return
}
views := make([]hentaiView, 0, len(list))
for _, r := range list {
path := r.MovedPath
if path == "" {
path = r.FilePath
}
views = append(views, hentaiView{
Id: r.Id,
FileName: r.FileName,
Score: r.Score,
Rating: r.Rating,
Threshold: r.Threshold,
Path: path,
TopTags: r.TopTags,
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"),
})
}
Success(ctx, map[string]interface{}{
"list": views,
"total": len(views),
"hentaiEnabled": config.GetAppConfig().HentaiEnabled,
"hentaiDetector": c.store == nil || c.store.GetConfig(hentaiDetectorReadyKey) != "0",
})
}
// Preview 处理 GET /api/hentai/preview?path=xxx:把指定图片文件返回给浏览器做预览。
func (c *HentaiController) Preview(ctx *gin.Context) {
path := strings.TrimSpace(ctx.Query("path"))
if path == "" {
Error(ctx, http.StatusBadRequest, "缺少 path 参数")
return
}
if _, err := os.Stat(path); err != nil {
Error(ctx, http.StatusNotFound, "图片不存在或已被移动: "+path)
return
}
ctx.File(path)
}
// Tags 处理 GET /api/hentai/tags?id=xxx:查某条记录的标签详情。
func (c *HentaiController) Tags(ctx *gin.Context) {
id := ctx.Query("id")
if id == "" {
Error(ctx, http.StatusBadRequest, "缺少 id 参数")
return
}
var idInt int64
if _, err := json.Number(id).Int64(); err != nil {
Error(ctx, http.StatusBadRequest, "id 格式错误")
return
}
idInt, _ = json.Number(id).Int64()
record, err := c.store.GetHentaiById(idInt)
if err != nil {
Error(ctx, http.StatusInternalServerError, "查询记录失败:"+err.Error())
return
}
if record == nil {
Error(ctx, http.StatusNotFound, "记录不存在")
return
}
var tags []map[string]interface{}
if record.TopTags != "" {
if err := json.Unmarshal([]byte(record.TopTags), &tags); err != nil {
tags = nil
}
}
Success(ctx, map[string]interface{}{
"id": record.Id,
"topTags": tags,
})
}
-6
View File
@@ -38,12 +38,6 @@ func NewRouter(st *store.Store) *gin.Engine {
apiGroup.GET("/nsfw/list", nc.List)
apiGroup.GET("/nsfw/preview", nc.Preview)
// 二次元识别(查看分级结果 + 图片预览 + 标签详情)
hc := NewHentaiController(st)
apiGroup.GET("/hentai/list", hc.List)
apiGroup.GET("/hentai/preview", hc.Preview)
apiGroup.GET("/hentai/tags", hc.Tags)
// 扫描整理(开始 / 中止 / 查进度 / 实时进度)
sc := NewScanController(service.NewScanService(st))
apiGroup.POST("/scan/start", sc.Start)
-7
View File
@@ -68,13 +68,6 @@ func ImageDirSensitive(ext string, width, height int, darkScore, lightScore floa
return imageDir("图片_敏感", ext, width, height, darkScore, lightScore)
}
// ImageDirHentai 返回一张被判为二次元敏感的图片该进的文件夹路径,比如:
// - 「图片_二次元_GIF」:GIF 单独放(不做明暗分类)
// - 「图片_二次元_横屏_1080P/bright_20」:普通分类前加「_二次元」前缀,明暗仍是子目录
func ImageDirHentai(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") {
+5 -17
View File
@@ -19,12 +19,10 @@ import (
// - FFmpegPath:ffmpeg 程序路径(用于把带 EXIF 旋转标记的图片转正);留空自动在 PATH 里找
// - RotateImages:是否处理图片 EXIF 旋转(默认 true);false 则完全不处理,按存储方向/尺寸归类
// - Workers:探测/MIME/移动阶段的并发 worker 数(默认 4;设 0 表示用满全部 CPU 核数)
// - MoveFiles:扫描完成后是否移动文件到分类目录(默认 true)
// - NSFWEnabled:是否开启色情图片识别(本地 python + NudeNet,默认 false)
// - NSFWPython:python 可执行文件路径;留空自动在 PATH 里找
// - NSFWThreshold:判定阈值 0~1(默认 0.5)
// - HentaiEnabled:是否开启二次元图片识别(本地 python + wd-tagger,默认 false)
// - HentaiPython:python 可执行文件路径;留空自动在 PATH 里找
// - HentaiThreshold:判定阈值 0~1(默认 0.5)
type Config struct {
Port int
Dsn string
@@ -32,12 +30,10 @@ type Config struct {
FFmpegPath string
RotateImages bool
Workers int
MoveFiles bool
NSFWEnabled bool
NSFWPython string
NSFWThreshold float64
HentaiEnabled bool
HentaiPython string
HentaiThreshold float64
}
// appConfig 当前生效的配置(程序运行期间一直不变)。
@@ -51,7 +47,7 @@ func GetAppConfig() Config {
// LoadAppConfig 从 application.properties 读配置;文件不存在就用默认值。
func LoadAppConfig() {
// 先给一套默认值
appConfig = Config{Port: 8080, Dsn: "media-info.db", AutoOpen: true, RotateImages: true, Workers: 4, NSFWThreshold: 0.5, HentaiThreshold: 0.5}
appConfig = Config{Port: 8080, Dsn: "media-info.db", AutoOpen: true, RotateImages: true, Workers: 4, MoveFiles: true, NSFWThreshold: 0.5}
p, err := properties.LoadFile("application.properties", properties.UTF8)
if err != nil {
@@ -76,6 +72,8 @@ func LoadAppConfig() {
appConfig.Workers = n
}
}
// 扫描完成后是否移动文件:默认 true
appConfig.MoveFiles = p.GetBool("organize.moveFiles", true)
// 色情识别:默认关;python 路径留空自动在 PATH 找;阈值只认 0~1 之间的小数
appConfig.NSFWEnabled = p.GetBool("nsfw.enabled", false)
if v, ok := p.Get("nsfw.python"); ok && v != "" {
@@ -86,16 +84,6 @@ func LoadAppConfig() {
appConfig.NSFWThreshold = t
}
}
// 二次元识别:默认关;python 路径留空自动在 PATH 找;阈值只认 0~1 之间的小数
appConfig.HentaiEnabled = p.GetBool("hentai.enabled", false)
if v, ok := p.Get("hentai.python"); ok && v != "" {
appConfig.HentaiPython = v
}
if v, ok := p.Get("hentai.threshold"); ok && strings.TrimSpace(v) != "" {
if t, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil && t > 0 && t < 1 {
appConfig.HentaiThreshold = t
}
}
}
// WorkerCount 返回探测/MIME/移动阶段使用的并发 worker 数:
-359
View File
@@ -1,359 +0,0 @@
// Package hentai 负责"二次元图片识别":本机 Python + wd-tagger 起一个本地 HTTP 服务,
// Go 侧把图片路径发给它,拿到"是否二次元敏感"和分级信息。
//
// 大白话理解:wd-tagger 是专门针对二次元训练的分级模型,能区分 safe/questionable/explicit。
// 我们让 python 当"外援":
// - 扫描开始时拉起一个常驻的 python 小服务(模型只加载一次);
// - 探测图片时逐张把路径 POST 给它;
// - 扫描结束把服务停掉、清理临时脚本。
//
// 机器上没有 python 或没装 onnxruntime 时,Start 会报错,调用方跳过识别、
// 走普通整理流程——这个功能是纯附加的,不影响其它步骤。
package hentai
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
_ "embed"
)
// 内嵌的 python 服务脚本。
//go:embed server.py
var serverScript []byte
// detectorStartTimeout 等待 python 服务就绪(模型加载)的超时时间。
// 首次启动若本地没有模型还要联网下载约 1.5GB,给足余量。
const detectorStartTimeout = 600 * time.Second
// requestTimeout 单张图片检测的超时时间。
const requestTimeout = 120 * time.Second
// Detector 封装一个本地 python 服务的客户端。
type Detector struct {
url string // 本地服务地址,如 http://127.0.0.1:54322
threshold float64 // 判定阈值 0~1
client *http.Client
cmd *exec.Cmd // python 进程句柄(Stop 时杀掉)
scriptDir string // 临时脚本所在目录(Stop 时清理)
python string // python 可执行文件路径(服务重启时要用)
modelDir string // 模型目录 lib(服务重启时要用)
mu sync.Mutex // 保护 stopped / 进程句柄
stopped bool // 已经 Stop 过了
cancel context.CancelFunc
done chan struct{} // 进程退出时关闭(Wait 协程负责)
restartMu sync.Mutex // 串行化重启:同一时刻只一个 goroutine 重启,其余排队等
}
// DetectResult 二次元检测的结果。
type DetectResult struct {
Hentai bool // 是否判定为二次元敏感(explicit 或 questionable 超过阈值)
Score float64 // 最高非 safe 分数(explicit 或 questionable 中较高的)
Rating string // 分级标签:safe / questionable / explicit
Ratings map[string]float64 // 三个分级的原始分数
TopTags []TagScore // 高置信度标签(最多 10 个)
}
// TagScore 一个标签及其分数。
type TagScore struct {
Tag string `json:"tag"`
Score float64 `json:"score"`
}
// Start 拉起本地 python 检测服务并等它就绪。
// - pythonPath:python 可执行文件路径;为空时在 PATH 里找 python/python3。
// - threshold:判定阈值 0~1。
//
// 找不到 python、装不了依赖、或服务起不来时返回错误(调用方跳过识别即可)。
func Start(pythonPath string, threshold float64) (*Detector, error) {
if threshold <= 0 || threshold >= 1 {
threshold = 0.5 // 阈值传得不合理就用默认
}
// 找 python
py := resolvePython(pythonPath)
if py == "" {
return nil, fmt.Errorf("未找到 python,二次元识别不可用")
}
// 先把内嵌的 server.py 写到临时目录
tmpDir, err := os.MkdirTemp("", "kmp-hentai-*")
if err != nil {
return nil, fmt.Errorf("创建临时目录失败: %v", err)
}
scriptPath := filepath.Join(tmpDir, "server.py")
if err := os.WriteFile(scriptPath, serverScript, 0644); err != nil {
_ = os.RemoveAll(tmpDir)
return nil, fmt.Errorf("写入检测脚本失败: %v", err)
}
// 模型目录:程序运行目录下的 lib(和 application.properties / media-info.db 同一根目录)。
modelDir := ""
if wd, err := os.Getwd(); err == nil {
modelDir = filepath.Join(wd, "lib")
}
d := &Detector{
threshold: threshold,
client: &http.Client{Timeout: requestTimeout},
python: py,
modelDir: modelDir,
scriptDir: tmpDir,
}
// 拉起服务(同样一套逻辑也用于崩溃后的重启)
if err := d.launch(); err != nil {
_ = os.RemoveAll(tmpDir)
return nil, err
}
return d, nil
}
// launch 启动(或重启)python 检测服务,并等它就绪(模型加载完成)。
func (d *Detector) launch() error {
// 先把上一次的进程收拾掉(首次启动时没有,直接跳过)
d.teardown()
// 找一个空闲端口
port, err := freePort()
if err != nil {
return fmt.Errorf("找空闲端口失败: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cmd := exec.CommandContext(ctx, d.python, filepath.Join(d.scriptDir, "server.py"), fmt.Sprintf("%d", port), d.modelDir)
// 标准输出/错误都接到程序自身的输出
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stdout
if err := cmd.Start(); err != nil {
cancel()
return fmt.Errorf("启动二次元检测服务失败: %v", err)
}
done := make(chan struct{})
d.url = fmt.Sprintf("http://127.0.0.1:%d", port)
d.cmd = cmd
d.cancel = cancel
d.done = done
// 后台等进程退出
go func() {
_ = cmd.Wait()
close(done)
}()
// 等服务就绪(模型加载完成)。就绪前先探测 /health。
if err := d.waitReady(); err != nil {
d.teardown()
return err
}
return nil
}
// teardown 停掉当前 python 进程并清理句柄(不删临时目录)。
func (d *Detector) teardown() {
if d.cancel != nil {
d.cancel()
d.cancel = nil
}
if d.cmd != nil && d.cmd.Process != nil {
_ = d.cmd.Process.Kill()
}
if d.done != nil {
select {
case <-d.done:
case <-time.After(3 * time.Second):
}
d.done = nil
}
d.cmd = nil
}
// waitReady 轮询本地服务,直到能正常应答。
func (d *Detector) waitReady() error {
deadline := time.Now().Add(detectorStartTimeout)
for time.Now().Before(deadline) {
// 服务进程提前退了直接报错
select {
case <-d.done:
return fmt.Errorf("二次元检测服务提前退出,请确认已安装 onnxruntime 和 Pillow (pip install onnxruntime Pillow numpy)")
default:
}
req, err := http.NewRequest(http.MethodGet, d.url+"/health", nil)
if err != nil {
return err
}
resp, err := d.client.Do(req)
if err == nil {
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return nil
}
}
time.Sleep(1 * time.Second) // 模型加载较慢,轮询间隔拉长
}
return fmt.Errorf("二次元检测服务在 %v 内未就绪", detectorStartTimeout)
}
// Detect 检测一张图片是否为二次元敏感内容。
// 服务失联时会串行化地重启服务并重试一次。
func (d *Detector) Detect(path string) (DetectResult, error) {
result, err := d.detectOnce(path)
if err == nil {
return result, nil
}
// 只有"发请求都失败"才走重启
var ce *connError
if !errors.As(err, &ce) {
return DetectResult{}, err
}
d.restartMu.Lock()
defer d.restartMu.Unlock()
if d.isStopped() {
return DetectResult{}, err
}
// 可能排在前面的 goroutine 已经把服务重启好了:先探活,活着就直接重试
if d.healthy() {
return d.detectOnce(path)
}
fmt.Printf("=== 二次元检测服务失联(%v), 正在重启…\n", ce.err)
if rerr := d.launch(); rerr != nil {
return DetectResult{}, fmt.Errorf("二次元检测服务重启失败: %v", rerr)
}
fmt.Println("=== 二次元检测服务已重启,继续识别")
return d.detectOnce(path)
}
// healthy 快速探活:本地服务是否还能正常应答。
func (d *Detector) healthy() bool {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, d.url+"/health", nil)
if err != nil {
return false
}
resp, err := d.client.Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode == http.StatusOK
}
// isStopped 判断检测器是否已被 Stop(线程安全)。
func (d *Detector) isStopped() bool {
d.mu.Lock()
defer d.mu.Unlock()
return d.stopped
}
// detectOnce 发一次检测请求(不带重启逻辑)。
func (d *Detector) detectOnce(path string) (DetectResult, error) {
body, _ := json.Marshal(map[string]interface{}{
"path": path,
"threshold": d.threshold,
})
resp, err := d.client.Post(d.url+"/detect", "application/json", bytes.NewReader(body))
if err != nil {
return DetectResult{}, &connError{err}
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return DetectResult{}, err
}
var out struct {
Ok bool `json:"ok"`
Error string `json:"error"`
Hentai bool `json:"hentai"`
Score float64 `json:"score"`
Rating string `json:"rating"`
Ratings map[string]float64 `json:"ratings"`
TopTags []TagScore `json:"topTags"`
}
if err := json.Unmarshal(data, &out); err != nil {
return DetectResult{}, fmt.Errorf("解析检测结果失败: %v", err)
}
if !out.Ok {
return DetectResult{}, fmt.Errorf("二次元检测服务返回错误: %s", out.Error)
}
return DetectResult{
Hentai: out.Hentai,
Score: out.Score,
Rating: out.Rating,
Ratings: out.Ratings,
TopTags: out.TopTags,
}, nil
}
// connError 标记"请求根本没发出去/连不上本地服务"的错误(服务失联)。
type connError struct{ err error }
func (e *connError) Error() string { return e.err.Error() }
func (e *connError) Unwrap() error { return e.err }
// Stop 停掉检测服务并清理临时文件。可以安全地重复调用。
func (d *Detector) Stop() {
d.mu.Lock()
defer d.mu.Unlock()
if d.stopped {
return
}
d.stopped = true
d.teardown()
if d.scriptDir != "" {
_ = os.RemoveAll(d.scriptDir)
}
}
// Threshold 返回当前使用的判定阈值(0~1)。给上层记录结果用。
func (d *Detector) Threshold() float64 {
return d.threshold
}
// resolvePython 在配置值 / PATH 里找可用的 python 解释器。
func resolvePython(configured string) string {
candidates := []string{}
if strings.TrimSpace(configured) != "" {
candidates = append(candidates, strings.TrimSpace(configured))
}
if os.Getenv("KM_HENTAI_PYTHON") != "" {
candidates = append(candidates, os.Getenv("KM_HENTAI_PYTHON"))
}
candidates = append(candidates, "python", "python3", "py")
for _, c := range candidates {
if p, err := exec.LookPath(c); err == nil {
return p
}
// 配置给的是绝对路径:直接看文件在不在
if strings.ContainsAny(c, `\/`) {
if _, err := os.Stat(c); err == nil {
return c
}
}
}
return ""
}
// freePort 找一个当前空闲的 TCP 端口。
func freePort() (int, error) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, err
}
defer ln.Close()
return ln.Addr().(*net.TCPAddr).Port, nil
}
-357
View File
@@ -1,357 +0,0 @@
# -*- coding: utf-8 -*-
"""wd-tagger 本地检测服务:由 Go 侧启动,常驻提供 /health 和 /detect。
基于 SmilingWolf/wd-vit-large-tagger-v3 模型,专门用于二次元(anime/hentai)图片分级。
输出 rating:safe / rating:questionable / rating:explicit 三个分级分数,
以及若干高置信度标签(用于辅助判断内容类型)。
只依赖 Python 标准库 + onnxruntime + Pillow。
模型与标签文件放在 <程序运行目录>/lib/wd-tagger/ 下,首次启动自动从 HuggingFace 下载。
推理并发策略与 nsfw/server.py 相同:每个 worker 线程各持一份独立 session 实例,
避免 onnxruntime 同一实例并发推理崩溃的问题。
"""
import concurrent.futures
import csv
import io
import json
import os
import shutil
import sys
import tempfile
import threading
import urllib.request
import zipfile
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
try:
import numpy as np
except ImportError:
print("=== 错误: 缺少 numpy,请执行 pip install numpy", flush=True)
sys.exit(1)
try:
import onnxruntime as ort
except ImportError:
print("=== 错误: 缺少 onnxruntime,请执行 pip install onnxruntime", flush=True)
sys.exit(1)
try:
from PIL import Image
except ImportError:
print("=== 错误: 缺少 Pillow,请执行 pip install Pillow", flush=True)
sys.exit(1)
# 模型相关配置
MODEL_DIR_NAME = "wd-tagger"
MODEL_FILENAME = "model.onnx"
TAGS_FILENAME = "selected_tags.csv"
# HuggingFace 下载地址(wd-vit-tagger-v3,轻量版,约 340MB)
HF_MODEL_URL = "https://huggingface.co/SmilingWolf/wd-vit-tagger-v3/resolve/main/model.onnx"
HF_TAGS_URL = "https://huggingface.co/SmilingWolf/wd-vit-tagger-v3/resolve/main/selected_tags.csv"
# 模型输入尺寸
INPUT_SIZE = 448
# ImageNet 归一化参数
IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
# 我们关注的分级标签(wd-tagger 格式:直接是 safe/questionable/explicit)
RATING_TAGS = {
"safe": "safe",
"questionable": "questionable",
"explicit": "explicit",
}
# 模型目录由 Go 侧作为第二个参数传入
MODEL_DIR = sys.argv[2] if len(sys.argv) > 2 else ""
# 推理并发数(ViT-Large 模型较大,默认 2 避免 CPU 争抢)
INFER_WORKERS = int(os.environ.get("KM_HENTAI_WORKERS", "2") or "2")
if INFER_WORKERS < 1:
INFER_WORKERS = 1
_executor = None
_thread_local = threading.local()
# 标签表:启动时从 CSV 加载
_tag_names = []
_rating_indices = {} # tag名 -> 在输出数组中的索引
def _looks_like_model(path):
"""粗略校验文件是不是真 ONNX 模型。"""
try:
with open(path, "rb") as f:
head = f.read(4)
if not head or head.startswith(b"<"):
return False
return os.path.getsize(path) > 1_000_000
except OSError:
return False
def _download_file(url, local):
"""带超时地下载文件。"""
req = urllib.request.Request(url)
with urllib.request.urlopen(req, timeout=60) as resp, open(local, "wb") as f:
shutil.copyfileobj(resp, f)
def _ensure_model(model_dir):
"""确保模型和标签文件就绪,返回 (model_path, tags_path) 或 (None, None) 表示用包自带。"""
if not model_dir:
return None, None
wd_dir = os.path.join(model_dir, MODEL_DIR_NAME)
model_path = os.path.join(wd_dir, MODEL_FILENAME)
tags_path = os.path.join(wd_dir, TAGS_FILENAME)
# 检查模型文件
if os.path.isfile(model_path) and _looks_like_model(model_path):
print("=== 使用本地 wd-tagger 模型: %s" % model_path, flush=True)
else:
# 下载模型
try:
os.makedirs(wd_dir, exist_ok=True)
print("=== 本地未找到 wd-tagger 模型, 正在下载: %s" % model_path, flush=True)
_download_file(HF_MODEL_URL, model_path)
if _looks_like_model(model_path):
print("=== wd-tagger 模型下载完成: %s" % model_path, flush=True)
else:
print("=== 下载的模型文件无效, 删除", flush=True)
try:
os.remove(model_path)
except OSError:
pass
return None, None
except Exception as exc:
print("=== wd-tagger 模型下载失败: %s" % exc, flush=True)
return None, None
# 检查标签文件
if os.path.isfile(tags_path):
print("=== 使用本地标签文件: %s" % tags_path, flush=True)
else:
try:
print("=== 下载标签文件: %s" % tags_path, flush=True)
_download_file(HF_TAGS_URL, tags_path)
print("=== 标签文件下载完成", flush=True)
except Exception as exc:
# 标签文件下载失败不影响模型运行,只是无法解析输出标签
print("=== 警告: 标签文件下载失败,将无法解析输出标签: %s" % exc, flush=True)
tags_path = "" # 标记标签文件不可用,但仍返回模型路径
return model_path, tags_path
def _load_tags(tags_path):
"""加载标签 CSV,建立 tag名 -> 索引 的映射。"""
global _tag_names, _rating_indices
_tag_names = []
_rating_indices = {}
if not tags_path or not os.path.isfile(tags_path):
print("=== 警告: 标签文件不存在,无法解析输出", flush=True)
return
with open(tags_path, "r", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
name = row.get("name", "")
_tag_names.append(name)
if name in RATING_TAGS:
_rating_indices[name] = len(_tag_names) - 1
print("=== 已加载 %d 个标签, 其中分级标签: %s" % (len(_tag_names), list(_rating_indices.keys())), flush=True)
def _new_session(model_path):
"""创建一份独立的 ONNX 推理 session。"""
if model_path:
return ort.InferenceSession(model_path, providers=["CPUExecutionProvider"])
# 没有本地模型,无法推理
raise RuntimeError("wd-tagger 模型不可用")
def get_session():
"""确保模型已就绪,返回一个推理 session(启动时调用)。"""
global _session
if "_session" not in dir() or _session is None:
model_path, tags_path = _ensure_model(MODEL_DIR)
if not model_path:
raise RuntimeError("wd-tagger 模型不可用")
if not tags_path:
raise RuntimeError("wd-tagger 标签文件不可用,无法解析输出。请手动下载 %s 到 %s" % (TAGS_FILENAME, os.path.join(MODEL_DIR, MODEL_DIR_NAME)))
_load_tags(tags_path)
_session = _new_session(model_path)
return _session
_session = None
def _preprocess(img_bytes):
"""把图片字节预处理成模型输入的 numpy 数组。
步骤:
1. 解码为 PIL Image
2. 转为 RGB(如果有 alpha 通道)
3. 直接 resize 到 INPUT_SIZE x INPUT_SIZE(官方推荐方式)
4. 归一化(ImageNet 参数)
5. 转为 NHWC 格式的 float32 数组(模型期望 [batch, H, W, C])
"""
img = Image.open(io.BytesIO(img_bytes))
if img.mode != "RGB":
img = img.convert("RGB")
# 直接 resize 到目标尺寸(wd-tagger 官方推荐)
img = img.resize((INPUT_SIZE, INPUT_SIZE), Image.LANCZOS)
# 转 numpy 数组并归一化
arr = np.array(img, dtype=np.float32) / 255.0
arr = (arr - IMAGENET_MEAN) / IMAGENET_STD
# HWC -> NHWC (模型期望 [batch, height, width, channels])
arr = np.expand_dims(arr, axis=0)
return arr
def _infer_bytes(session, img_bytes):
"""用给定的 session 推理一张图片(字节),返回 (ratings_dict, top_tags_list)。"""
inp = _preprocess(img_bytes)
input_name = session.get_inputs()[0].name
outputs = session.run(None, {input_name: inp})
# 输出是 [1, num_tags] 的 logits 数组,需要 sigmoid 转换
logits = outputs[0][0] if len(outputs[0].shape) == 2 else outputs[0]
scores = 1.0 / (1.0 + np.exp(-logits)) # sigmoid 激活
# 提取分级分数
ratings = {}
for tag_name, idx in _rating_indices.items():
if idx < len(scores):
rating_key = RATING_TAGS[tag_name]
ratings[rating_key] = float(scores[idx])
# 提取 top 标签(排除分级标签,取分数最高的 10 个)
top_tags = []
non_rating_indices = [i for i in range(len(scores)) if i not in _rating_indices.values()]
if non_rating_indices:
top_indices = sorted(non_rating_indices, key=lambda i: scores[i], reverse=True)[:10]
for idx in top_indices:
if idx < len(_tag_names) and scores[idx] > 0.5:
top_tags.append({"tag": _tag_names[idx], "score": float(scores[idx])})
return ratings, top_tags
def _read_bytes(path):
"""用 Python 读文件字节。"""
if os.name == "nt":
if path.startswith("\\\\?\\"):
lp = path
elif path.startswith("\\\\"):
lp = "\\\\?\\UNC\\" + path.lstrip("\\")
elif os.path.isabs(path):
lp = "\\\\?\\" + os.path.normpath(path)
else:
lp = path
else:
lp = path
if not os.path.exists(lp):
raise OSError("文件不存在: %s" % path)
with open(lp, "rb") as f:
data = f.read()
if not data:
raise OSError("空文件: %s" % path)
return data
def _infer(path):
"""整个推理任务:读文件 + 预处理 + 推理。"""
data = _read_bytes(path)
session = getattr(_thread_local, "session", None)
if session is None:
# 每个 worker 线程各持一份独立 session
model_path = os.path.join(MODEL_DIR, MODEL_DIR_NAME, MODEL_FILENAME) if MODEL_DIR else ""
session = _new_session(model_path)
_thread_local.session = session
return _infer_bytes(session, data)
class Handler(BaseHTTPRequestHandler):
def _send(self, obj, status=200):
data = json.dumps(obj, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_GET(self):
if self.path == "/health":
self._send({"ok": True})
else:
self._send({"ok": False, "error": "not found"}, 404)
def do_POST(self):
if self.path != "/detect":
return self._send({"ok": False, "error": "not found"}, 404)
length = int(self.headers.get("Content-Length", 0))
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": "缺少图片路径"})
# 丢给推理线程池
ratings, top_tags = _executor.submit(_infer, path).result()
except Exception as exc:
return self._send({"ok": False, "error": str(exc)})
# 判断是否为 hentai:explicit 分数 >= 阈值 或 questionable 分数 >= 阈值
explicit_score = ratings.get("explicit", 0.0)
questionable_score = ratings.get("questionable", 0.0)
safe_score = ratings.get("safe", 0.0)
# hentai 判定:explicit 或 questionable 分数超过阈值
is_hentai = (explicit_score >= threshold) or (questionable_score >= threshold)
# 取最高的非 safe 分数作为"敏感分"
max_score = max(explicit_score, questionable_score)
# 确定分级标签
rating = "safe"
if explicit_score >= questionable_score and explicit_score >= threshold:
rating = "explicit"
elif questionable_score >= threshold:
rating = "questionable"
self._send({
"ok": True,
"hentai": is_hentai,
"score": max_score,
"rating": rating,
"ratings": ratings,
"topTags": top_tags,
})
def log_message(self, *args):
pass
class Server(ThreadingHTTPServer):
request_queue_size = 128
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 0
# 启动前先把模型准备好
get_session()
# 固定大小推理线程池
_executor = concurrent.futures.ThreadPoolExecutor(max_workers=INFER_WORKERS)
Server(("127.0.0.1", port), Handler).serve_forever()
+15 -4
View File
@@ -285,10 +285,11 @@ func (d *Detector) detectOnce(path string) (score float64, nsfw bool, err error)
return 0, false, err
}
var out struct {
Ok bool `json:"ok"`
Error string `json:"error"`
Nsfw bool `json:"nsfw"`
Score float64 `json:"score"`
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, fmt.Errorf("解析检测结果失败: %v", err)
@@ -296,6 +297,16 @@ func (d *Detector) detectOnce(path string) (score float64, nsfw bool, err error)
if !out.Ok {
return 0, false, fmt.Errorf("检测服务返回错误: %s", out.Error)
}
// 打印所有检测到的类别和分数(调试用)
if len(out.Detections) > 0 {
var classes []string
for _, det := range out.Detections {
cls, _ := det["class"].(string)
sc, _ := det["score"].(float64)
classes = append(classes, fmt.Sprintf("%s:%.3f", cls, sc))
}
fmt.Printf("=== NSFW 检测详情 %s: [%s]\n", path, strings.Join(classes, ", "))
}
return out.Score, out.Nsfw, nil
}
+7 -5
View File
@@ -37,12 +37,14 @@ try:
except Exception:
pass
# 只有这些"暴露部位"才算色情;其它如肚子/腋窝/脚踝暴露不判。
# 只有这些"暴露部位"才算色情;其它如腋窝/脚踝暴露不判。
SENSITIVE = {
"FEMALE_GENITALIA_EXPOSED",
"MALE_GENITALIA_EXPOSED",
"ANUS_EXPOSED",
"FEMALE_BREAST_EXPOSED",
"FEMALE_GENITALIA_EXPOSED", # 女性生殖器暴露
"MALE_GENITALIA_EXPOSED", # 男性生殖器暴露
"ANUS_EXPOSED", # 肛门暴露
"FEMALE_BREAST_EXPOSED", # 女性乳房暴露
"BUTTOCKS_EXPOSED", # 臀部暴露
"BELLY_EXPOSED", # 腹部/腰部暴露(比基尼、露脐装等)
}
# 模型文件与官方下载地址
+5 -88
View File
@@ -12,7 +12,6 @@ package organize
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
@@ -24,7 +23,6 @@ import (
"kenaito-media-picker/internal/classify"
"kenaito-media-picker/internal/config"
"kenaito-media-picker/internal/digest"
"kenaito-media-picker/internal/hentai"
"kenaito-media-picker/internal/mover"
"kenaito-media-picker/internal/nsfw"
"kenaito-media-picker/internal/probe"
@@ -73,27 +71,22 @@ type Organizer struct {
mover *mover.Mover // 负责实际搬文件的家伙
ctx context.Context // 中止信号;ctx 被取消时探测/归类立即停下
nsfw *nsfw.Detector // 色情检测器;为 nil 表示没启用,图片不检测
hentai *hentai.Detector // 二次元检测器;为 nil 表示没启用
rotateImages bool // 是否处理 EXIF 旋转(转正/按显示方向归类);false 则完全不动,按存储方向归类
buckets map[string][]string // 目标目录 → 里面该放哪些文件(只记源路径)
order []string // 目标目录的先后顺序(保证输出稳定、不来回跳)
onProgress func(phase string, done, total int)
pending []*store.MediaInfo // 待写入数据库的媒体记录(攒一批再批量写)
pendingNsfw []store.NsfwRecord // 待写入数据库的色情识别记录(攒一批再批量写)
pendingHentai []store.HentaiRecord // 待写入数据库的二次元识别记录(攒一批再批量写)
resume map[string]store.ScanLog // 续传表:源路径 → 上次归类结果(重扫跳过重新探测)
resumeSkip int32 // 本次续传跳过的文件数(并行累计)
pendingScan []store.ScanLog // 待写入续传表的记录(攒一批再批量写)
nsfwChecked int32 // 色情识别已检查的图片数(并行累计,只涨不跌)
nsfwHits int32 // 色情识别命中的图片数(并行累计)
hentaiChecked int32 // 二次元识别已检查的图片数(并行累计)
hentaiHits int32 // 二次元识别命中的图片数(并行累计)
// 阶段耗时统计(并行 worker 累计,单位纳秒,只用于日志诊断瓶颈)
tImageProbe int64 // 图片尺寸探测总耗时
tImgMd5 int64 // 图片 MD5/指纹计算总耗时
tBright int64 // 明暗分析总耗时
tNsfw int64 // 色情识别总耗时
tHentai int64 // 二次元识别总耗时
tMediaProbe int64 // 媒体探测(ffprobe)总耗时
tMediaMd5 int64 // 媒体(视频/音频) MD5/指纹计算总耗时
}
@@ -136,12 +129,6 @@ func (o *Organizer) SetNSFWDetector(d *nsfw.Detector) {
o.nsfw = d
}
// SetHentaiDetector 设置二次元检测器。传 nil 表示不检测(默认)。
// 设置了检测器后,图片探测时会额外做二次元识别,命中的图片会归入「图片_二次元_*」目录。
func (o *Organizer) SetHentaiDetector(d *hentai.Detector) {
o.hentai = d
}
// SetRotateImages 设置是否处理图片 EXIF 旋转(默认 true)。
// - true:带旋转标记的图片做转正处理(有 ffmpeg 就烧进像素,没有则按显示方向归类);
// - false:完全不动,图片按文件里存储的方向/尺寸归类,不做任何旋转相关处理。
@@ -405,10 +392,6 @@ type imageProbeResult struct {
lightScore float64 // 亮色像素占比
nsfwScore float64 // 色情检测最高分(未检测为 0)
nsfw bool // 是否判定为色情(检测可用时才可能为 true)
hentaiScore float64 // 二次元检测最高分(未检测为 0)
hentai bool // 是否判定为二次元敏感
hentaiRating string // 二次元分级标签:safe / questionable / explicit
hentaiTags string // 高置信度标签(JSON 数组字符串)
}
// probeImage 给一张图片做体检(并行阶段执行):探测尺寸 + 算 MD5 + 分析明暗。
@@ -475,8 +458,9 @@ func (o *Organizer) probeImage(f scanner.File) imageProbeResult {
checked := atomic.AddInt32(&o.nsfwChecked, 1)
if hit {
atomic.AddInt32(&o.nsfwHits, 1)
// 命中的图片把"文件名 + 检测分"打进运行日志,方便用户盯着看是否判对
fmt.Printf("=== 色情命中: %s, 检测分=%.2f (阈值=%.2f)\n", f.Path, score, o.nsfw.Threshold())
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 {
@@ -484,33 +468,6 @@ func (o *Organizer) probeImage(f scanner.File) imageProbeResult {
}
}
}
// 二次元识别(GIF 不做;没启用检测器就直接跳过)
if o.hentai != nil && !strings.EqualFold(f.Ext, ".gif") {
hentaiStart := time.Now()
result, derr := o.hentai.Detect(f.Path)
atomic.AddInt64(&o.tHentai, int64(time.Since(hentaiStart)))
if derr != nil {
fmt.Printf("=== 二次元检测失败(按未命中归类): %s, 错误: %v \n", f.Path, derr)
} else {
r.hentaiScore = result.Score
r.hentai = result.Hentai
r.hentaiRating = result.Rating
// 把标签序列化成 JSON 字符串存起来
if len(result.TopTags) > 0 {
if tagsJSON, err := json.Marshal(result.TopTags); err == nil {
r.hentaiTags = string(tagsJSON)
}
}
checked := atomic.AddInt32(&o.hentaiChecked, 1)
if result.Hentai {
atomic.AddInt32(&o.hentaiHits, 1)
fmt.Printf("=== 二次元命中: %s, 分级=%s, 检测分=%.2f (阈值=%.2f)\n", f.Path, result.Rating, result.Score, o.hentai.Threshold())
}
if checked%nsfwProgressInterval == 0 {
fmt.Printf("=== 二次元识别进度: 已检查 %d 张, 命中 %d 张\n", checked, atomic.LoadInt32(&o.hentaiHits))
}
}
}
return r
}
@@ -556,15 +513,11 @@ func (o *Organizer) ProcessImages(images []scanner.File) {
// 统计结果,最后打一行总结给用户看
success, fail, ignore, dup := 0, 0, 0, 0
nsfwCount := 0 // 命中的色情图片数
hentaiCount := 0 // 命中的二次元图片数
total := len(images)
phaseStart := time.Now()
if o.nsfw != nil {
fmt.Printf("=== 色情识别: 共 %d 张图片待检测(GIF跳过), 每 %d 张报一次进度\n", total, nsfwProgressInterval)
}
if o.hentai != nil {
fmt.Printf("=== 二次元识别: 共 %d 张图片待检测(GIF跳过), 每 %d 张报一次进度\n", total, nsfwProgressInterval)
}
results := runParallel(o.ctx, images, o.probeImage, func(done int) {
o.report(PhaseImages, done, total)
})
@@ -600,7 +553,6 @@ func (o *Organizer) ProcessImages(images []scanner.File) {
default:
// 正常图片:按分辨率/方向/明暗归入对应的分类目录;
// 被判为色情的改去「图片_敏感_*」目录,明暗子目录照常保留
// 被判为二次元敏感的改去「图片_二次元_*」目录
o.record(f, "image", r.res, r.md5Str, r.headMd5, r.tailMd5, r.size)
if r.nsfw {
o.addClassified(f, classify.ImageDirSensitive(f.Ext, r.res.Width, r.res.Height, r.darkScore, r.lightScore), r.size, r.headMd5, r.tailMd5)
@@ -612,18 +564,6 @@ func (o *Organizer) ProcessImages(images []scanner.File) {
Threshold: o.nsfw.Threshold(),
})
nsfwCount++
} else if r.hentai {
o.addClassified(f, classify.ImageDirHentai(f.Ext, r.res.Width, r.res.Height, r.darkScore, r.lightScore), r.size, r.headMd5, r.tailMd5)
// 攒一条二次元识别记录
o.pendingHentai = append(o.pendingHentai, store.HentaiRecord{
FilePath: f.Path,
FileName: filepath.Base(f.Path),
Score: r.hentaiScore,
Rating: r.hentaiRating,
Threshold: o.hentai.Threshold(),
TopTags: r.hentaiTags,
})
hentaiCount++
} else {
o.addClassified(f, classify.ImageDir(f.Ext, r.res.Width, r.res.Height, r.darkScore, r.lightScore), r.size, r.headMd5, r.tailMd5)
}
@@ -636,19 +576,15 @@ func (o *Organizer) ProcessImages(images []scanner.File) {
fmt.Printf("=== 续传跳过: %d 个文件(上次已归类,直接复用)\n", skip)
}
fmt.Printf("=== 图片处理完毕,成功: %d, 读取异常: %d, 忽略: %d, 重复: %d \n\n", success, fail, ignore, dup)
fmt.Printf("=== 图片归类耗时: 实际 %.1fs, 其中(并行累计) 探测 %.1fs, MD5 %.1fs, 明暗 %.1fs, 色情 %.1fs, 二次元 %.1fs\n",
fmt.Printf("=== 图片归类耗时: 实际 %.1fs, 其中(并行累计) 探测 %.1fs, MD5 %.1fs, 明暗 %.1fs, 色情 %.1fs\n",
time.Since(phaseStart).Seconds(),
time.Duration(atomic.LoadInt64(&o.tImageProbe)).Seconds(),
time.Duration(atomic.LoadInt64(&o.tImgMd5)).Seconds(),
time.Duration(atomic.LoadInt64(&o.tBright)).Seconds(),
time.Duration(atomic.LoadInt64(&o.tNsfw)).Seconds(),
time.Duration(atomic.LoadInt64(&o.tHentai)).Seconds())
time.Duration(atomic.LoadInt64(&o.tNsfw)).Seconds())
if o.nsfw != nil && nsfwCount > 0 {
fmt.Printf("=== 色情识别: 共命中 %d 张,已归入「图片_敏感_*」目录,可在【色情识别】页面查看并人工复核\n", nsfwCount)
}
if o.hentai != nil && hentaiCount > 0 {
fmt.Printf("=== 二次元识别: 共命中 %d 张,已归入「图片_二次元_*」目录,可在【二次元识别】页面查看并人工复核\n", hentaiCount)
}
}
// ============================ 视频/音频整理 ============================
@@ -821,23 +757,4 @@ func (o *Organizer) Flush() {
}
o.pendingNsfw = nil
}
// 把二次元识别记录补上"整理后位置"(预览就用它),再批量写进数据库
if len(o.pendingHentai) > 0 {
records := make([]*store.HentaiRecord, 0, len(o.pendingHentai))
for i := range o.pendingHentai {
if target, ok := moved[o.pendingHentai[i].FilePath]; ok {
o.pendingHentai[i].MovedPath = target
} else {
o.pendingHentai[i].MovedPath = o.pendingHentai[i].FilePath // 没搬走就用源路径
}
records = append(records, &o.pendingHentai[i])
}
if o.store != nil {
if err := o.store.SaveHentaiBatch(records); err != nil {
fmt.Printf("=== 保存二次元识别记录失败: %v\n", err)
}
}
o.pendingHentai = nil
}
}
+5 -40
View File
@@ -24,7 +24,6 @@ import (
"time"
"kenaito-media-picker/internal/config"
"kenaito-media-picker/internal/hentai"
"kenaito-media-picker/internal/nsfw"
"kenaito-media-picker/internal/organize"
"kenaito-media-picker/internal/probe"
@@ -77,9 +76,6 @@ const ConfigKeyDestDir = "scan.destDir"
// 配置开了但检测器没起来(没装 python/模型下载失败等)也能区分出来,前端据此提示。
const nsfwDetectorReadyKey = "nsfw.detectorReady"
// hentaiDetectorReadyKey 记录最近一次扫描时二次元检测服务是否真的起来了。
const hentaiDetectorReadyKey = "hentai.detectorReady"
// setNsfwDetectorReady 把"色情检测服务是否真的起来了"记进数据库(AppConfig)。
func (s *ScanService) setNsfwDetectorReady(ready bool) {
if s.store == nil {
@@ -92,18 +88,6 @@ func (s *ScanService) setNsfwDetectorReady(ready bool) {
_ = s.store.SaveConfig(nsfwDetectorReadyKey, v)
}
// setHentaiDetectorReady 把"二次元检测服务是否真的起来了"记进数据库(AppConfig)。
func (s *ScanService) setHentaiDetectorReady(ready bool) {
if s.store == nil {
return
}
v := "0"
if ready {
v = "1"
}
_ = s.store.SaveConfig(hentaiDetectorReadyKey, v)
}
// DefaultPickerDirName 用户没指定存放目录时,默认在扫描目录下新建这个文件夹装整理好的文件。
const DefaultPickerDirName = "KMP已整理"
@@ -549,31 +533,8 @@ func (s *ScanService) runScan(ctx context.Context, rootDir, destDir string) erro
}
}
// ---------- 第六步(可选):二次元识别 ----------
// 用本机 python + wd-tagger 起一个本地检测服务。没装依赖时跳过,
// 不影响图片整理——检测结果只决定走普通目录还是「图片_二次元」目录。
hentaiDet := (*hentai.Detector)(nil)
s.setHentaiDetectorReady(false)
if cfg := config.GetAppConfig(); cfg.HentaiEnabled {
if d, err := hentai.Start(cfg.HentaiPython, cfg.HentaiThreshold); err != nil {
fmt.Printf("=== 二次元识别未启用(图片照常处理): %v\n", err)
} else {
hentaiDet = d
defer hentaiDet.Stop()
s.setHentaiDetectorReady(true)
fmt.Println("=== 二次元识别已启用(wd-tagger)")
// 清掉上一次的二次元识别记录,保证「二次元识别」页面显示的是本次扫描的结果
if s.store != nil {
if err := s.store.ClearHentai(); err != nil {
fmt.Printf("=== 清空旧二次元识别记录失败: %v\n", err)
}
}
}
}
org := organize.New(pickerDir, ffprobePath, ffmpegPath, s.store, ctx)
org.SetNSFWDetector(nsfwDet)
org.SetHentaiDetector(hentaiDet)
org.SetRotateImages(config.GetAppConfig().RotateImages)
// 整个"整理"分两个阶段,界面上共用一条进度条:
@@ -611,7 +572,11 @@ func (s *ScanService) runScan(ctx context.Context, rootDir, destDir string) erro
}
// 移动:把文件搬进各自的分类目录,并记录去向
org.Flush()
if config.GetAppConfig().MoveFiles {
org.Flush()
} else {
fmt.Println("=== 配置 organize.moveFiles=false,跳过文件移动")
}
// 把进度条顶满 100%,再打印"整理完成"
s.setProgress(stageMove, totalWork, totalWork)
+2 -77
View File
@@ -75,20 +75,6 @@ type NsfwRecord struct {
CreatedAt time.Time `xorm:"created 'created_at'" json:"createdAt"` // 检测时间
}
// HentaiRecord 一条"二次元图片识别"的记录:哪张图、分级结果、标签、现在在哪。
// 前端「二次元识别」页面查这张表展示检测结果,方便人工复核。
type HentaiRecord struct {
Id int64 `xorm:"pk autoincr 'id'" json:"id"` // 自增主键
FilePath string `xorm:"text 'file_path'" json:"filePath"` // 检测时的源路径
FileName string `xorm:"varchar(255) 'file_name'" json:"fileName"` // 文件名(含扩展名)
Score float64 `xorm:"real 'score'" json:"score"` // 最高非 safe 分数 0~1
Rating string `xorm:"varchar(32) 'rating'" json:"rating"` // 分级标签:safe / questionable / explicit
Threshold float64 `xorm:"real 'threshold'" json:"threshold"` // 判定阈值
TopTags string `xorm:"text 'top_tags'" json:"topTags"` // 高置信度标签(JSON 数组)
MovedPath string `xorm:"text 'moved_path'" json:"movedPath"` // 整理后实际位置(预览用)
CreatedAt time.Time `xorm:"created 'created_at'" json:"createdAt"` // 检测时间
}
// ScanLog 一条"续传"记录:某个源文件上次被归类到了哪个目录。
// 用途:扫描中断/出错后重跑时,已经归类过的文件直接复用上次的目标目录,
// 不再重新探测尺寸 / 算 MD5 / 分析明暗 / 色情识别,省下几小时的重扫时间。
@@ -120,8 +106,8 @@ func Open(dbPath string) (*Store, error) {
_ = e.Close()
return nil, err
}
// 自动创建/补全五张表(媒体记录表、配置表、色情识别记录表、二次元识别记录表、续传表)
if err := e.Sync(new(MediaInfo), new(AppConfig), new(NsfwRecord), new(HentaiRecord), new(ScanLog)); err != nil {
// 自动创建/补全四张表(媒体记录表、配置表、色情识别记录表、续传表)
if err := e.Sync(new(MediaInfo), new(AppConfig), new(NsfwRecord), new(ScanLog)); err != nil {
_ = e.Close()
return nil, err
}
@@ -209,67 +195,6 @@ func (s *Store) ClearNsfw() error {
return err
}
// ============================ 二次元识别记录 ============================
// SaveHentaiBatch 批量保存二次元识别记录(一次扫描攒一批,最后统一写入)。
func (s *Store) SaveHentaiBatch(records []*HentaiRecord) error {
if s == nil || s.engine == nil || len(records) == 0 {
return nil
}
const chunkSize = 100
sess := s.engine.NewSession()
defer sess.Close()
if err := sess.Begin(); err != nil {
return err
}
for i := 0; i < len(records); i += chunkSize {
end := i + chunkSize
if end > len(records) {
end = len(records)
}
if _, err := sess.Insert(records[i:end]); err != nil {
_ = sess.Rollback()
return err
}
}
return sess.Commit()
}
// ClearHentai 清空二次元识别记录。每次开启二次元识别的扫描开始时调用。
func (s *Store) ClearHentai() error {
if s == nil || s.engine == nil {
return nil
}
_, err := s.engine.Where("1 = 1").Delete(new(HentaiRecord))
return err
}
// ListHentai 查全部二次元识别记录,按检测分从高到低排。
func (s *Store) ListHentai() ([]HentaiRecord, error) {
if s == nil || s.engine == nil {
return nil, nil
}
list := make([]HentaiRecord, 0)
err := s.engine.Desc("score").Find(&list)
return list, err
}
// GetHentaiById 按 ID 查一条二次元识别记录。
func (s *Store) GetHentaiById(id int64) (*HentaiRecord, error) {
if s == nil || s.engine == nil {
return nil, nil
}
record := new(HentaiRecord)
has, err := s.engine.ID(id).Get(record)
if err != nil {
return nil, err
}
if !has {
return nil, nil
}
return record, nil
}
// ============================ 续传表 ============================
// LoadScanLog 把全部续传记录读出来,按源路径建索引,供扫描时快速判断"这个文件上次归到哪了"。
File diff suppressed because it is too large Load Diff