feat: 色情识别结果可视化与日志;超长路径修复;旋转开关、明暗5档等

This commit is contained in:
2025-06-08 21:07:42 +08:00
parent c766a948da
commit d4f9dc362e
15 changed files with 477 additions and 34 deletions
+1
View File
@@ -1,2 +1,3 @@
.idea/ .idea/
media-info.db media-info.db
__pycache__/
+1 -1
View File
@@ -180,7 +180,7 @@ lightScore = 亮度 > 175 的像素占比
- 同一时间只允许一次扫描整理;重复点击会提示「正在运行中」 - 同一时间只允许一次扫描整理;重复点击会提示「正在运行中」
- GIF 图片单独归入「图片_GIF」;GIF 视频按方向归入「视频_*_GIF」 - GIF 图片单独归入「图片_GIF」;GIF 视频按方向归入「视频_*_GIF」
- ffprobe 内嵌(Windows),运行前释放到当前目录、结束后自动删除 - ffprobe 内嵌(Windows),运行前释放到当前目录、结束后自动删除
- EXIF 旋转转正需要 ffmpeg:配置 `ffmpeg.path` 指定路径,或让 ffmpeg 在 PATH 里;找不到 ffmpeg 时不转正像素,仍按显示方向归类。可用 `organize.rotateImages=false` 关闭 - EXIF 旋转转正需要 ffmpeg:配置 `ffmpeg.path` 指定路径,或让 ffmpeg 在 PATH 里;找不到 ffmpeg 时不转正像素,仍按显示方向归类。想完全关闭旋转处理(不转正、也不按显示方向调宽高,图片按存储方向原样归类)可在 `application.properties` 里设 `organize.rotateImages=false`
## 色情图片识别(可选) ## 色情图片识别(可选)
+8 -7
View File
@@ -7,14 +7,15 @@ server.autoOpen=true
database.dsn=media-info.db database.dsn=media-info.db
# ffmpeg 程序路径(用于把带 EXIF 旋转标记的图片转正);留空自动在 PATH 里找 # ffmpeg 程序路径(用于把带 EXIF 旋转标记的图片转正);留空自动在 PATH 里找
ffmpeg.path= ffmpeg.path=
# 整理时是否自动转正带 EXIF 旋转标记的图片(true=是,默认;false=只按显示方向归类不转正) # 是否处理图片 EXIF 旋转(true=是,默认):有 ffmpeg 就物理转正像素,没有则按显示方向归类
organize.rotateImages=true # false=完全不处理:图片按存储方向/尺寸归类,不做任何旋转相关改动
organize.rotateImages=false
# 探测/认类型/移动阶段的并发 worker 数(默认 4;0 表示用满全部 CPU 核数) # 探测/认类型/移动阶段的并发 worker 数(默认 4;0 表示用满全部 CPU 核数)
organize.workers=4 organize.workers=4
# 色情图片识别(需本机安装 python 与 nudenet:pip install nudenet): # 色情图片识别(需本机安装 python 与 nudenet:pip install nudenet),需要科技下载模型
# true=开启,判为色情的图片移入「图片_敏感_*」目录(明暗子目录照常);false=关闭(默认) # true=开启,判为色情的图片移入「图片_敏感_*」目录(明暗子目录照常);false=关闭(默认)
nsfw.enabled=false nsfw.enabled=true
# python 可执行文件路径;留空自动在 PATH 里找 python/python3/py # python 可执行文件路径;留空自动在 PATH 里找 python/python3/py
nsfw.python= nsfw.python=C:\\Users\\Administrator\\AppData\\Local\\Programs\\Python\\Python312\\python.exe
# 判定阈值 0~1,敏感部位检测分高于此才算色情(默认 0.5) # 判定阈值 0~1,敏感部位检测分高于此才算色情(默认 0.2,实测)
nsfw.threshold=0.5 nsfw.threshold=0.2
+4
View File
@@ -24,6 +24,10 @@
<i class="el-icon-film"></i> <i class="el-icon-film"></i>
<span slot="title">媒体库</span> <span slot="title">媒体库</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="/nsfw">
<i class="el-icon-view"></i>
<span slot="title">色情识别</span>
</el-menu-item>
</el-menu> </el-menu>
<div class="aside-footer">kenaito-media-picker</div> <div class="aside-footer">kenaito-media-picker</div>
</el-aside> </el-aside>
+2
View File
@@ -2,6 +2,7 @@ import Vue from 'vue'
import Router from 'vue-router' import Router from 'vue-router'
import Dashboard from '../views/Dashboard.vue' import Dashboard from '../views/Dashboard.vue'
import MediaList from '../views/MediaList.vue' import MediaList from '../views/MediaList.vue'
import NsfwList from '../views/NsfwList.vue'
import Scan from '../views/Scan.vue' import Scan from '../views/Scan.vue'
Vue.use(Router) Vue.use(Router)
@@ -11,6 +12,7 @@ export default new Router({
{path: '/', redirect: '/scan'}, {path: '/', redirect: '/scan'},
{path: '/dashboard', component: Dashboard}, {path: '/dashboard', component: Dashboard},
{path: '/media', component: MediaList}, {path: '/media', component: MediaList},
{path: '/nsfw', component: NsfwList},
{path: '/scan', component: Scan} {path: '/scan', component: Scan}
] ]
}) })
+183
View File
@@ -0,0 +1,183 @@
<template>
<div class="page-card">
<div class="toolbar">
<span class="title">色情识别</span>
<div class="toolbar-right">
<span class="hint" v-if="!nsfwConfigured">识别功能未开启:请在 application.properties 设 nsfw.enabled=true 并重新扫描</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="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="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="暂无记录。开启 nsfw.enabled 后重新扫描,判为色情的图片会出现在这里。"/>
</div>
</template>
<script>
import request from '../utils/request'
/**
* 色情识别页面。
* 展示最近一次开启色情识别的扫描判出的图片:检测分 + 完整路径 + 图片预览。
* 数据来自 /api/nsfw/list,预览走 /api/nsfw/preview(后端按路径读本地文件)。
*/
export default {
name: 'NsfwList',
data() {
return {
loading: false,
list: [],
nsfwConfigured: true // 后端没开启识别时列表为空,这里只做提示不做强校验
}
},
mounted() {
this.loadList()
},
methods: {
async loadList() {
this.loading = true
try {
const res = await request.get('/api/nsfw/list')
this.list = res.data.list || []
this.nsfwConfigured = (res.data.total || 0) > 0 || this.list.length > 0
} finally {
this.loading = false
}
},
previewUrl(path) {
if (!path) return ''
return '/api/nsfw/preview?path=' + encodeURIComponent(path)
},
// 检测分越接近 1 越"实锤":>=90% 红色,>=75% 橙色,否则灰色
scoreTag(row) {
if (row.score >= 0.9) return 'score-high'
if (row.score >= 0.75) return 'score-mid'
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;
}
.score {
font-weight: 700;
color: #909399;
}
.score-high {
color: #f56c6c;
}
.score-mid {
color: #e6a23c;
}
.muted {
color: #c0c4cc;
}
</style>
+75
View File
@@ -0,0 +1,75 @@
// 色情识别相关的两个接口:查记录、看图片预览。
// 前端「色情识别」页面用它展示每次扫描判出的色情图片(检测分 + 路径 + 预览),
// 方便用户核对识别结果准不准。只读数据库 + 读本地图片文件,不干别的活。
package api
import (
"net/http"
"os"
"strings"
"github.com/gin-gonic/gin"
"kenaito-media-picker/internal/store"
)
// NsfwController 色情识别相关接口的"窗口"。
type NsfwController struct {
store *store.Store // 数据库(只读)
}
// NewNsfwController 创建色情识别控制器,注入数据库。
func NewNsfwController(st *store.Store) *NsfwController {
return &NsfwController{store: st}
}
// nsfwView 列表里展示的一条记录:路径用"整理后位置",没有就用源路径。
type nsfwView struct {
Id int64 `json:"id"` // 主键
FileName string `json:"fileName"` // 文件名
Score float64 `json:"score"` // 检测分 0~1
Threshold float64 `json:"threshold"` // 判定阈值
Path string `json:"path"` // 预览/打开用的完整路径
CreatedAt string `json:"createdAt"` // 检测时间
}
// List 处理 GET /api/nsfw/list:查全部色情识别记录(按检测分从高到低)。
func (c *NsfwController) List(ctx *gin.Context) {
list, err := c.store.ListNsfw()
if err != nil {
Error(ctx, http.StatusInternalServerError, "查询色情识别记录失败:"+err.Error())
return
}
views := make([]nsfwView, 0, len(list))
for _, r := range list {
path := r.MovedPath
if path == "" {
path = r.FilePath
}
views = append(views, nsfwView{
Id: r.Id,
FileName: r.FileName,
Score: r.Score,
Threshold: r.Threshold,
Path: path,
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"),
})
}
Success(ctx, map[string]interface{}{"list": views, "total": len(views)})
}
// Preview 处理 GET /api/nsfw/preview?path=xxx:把指定图片文件返回给浏览器做预览。
// 这是本地单机工具,直接按路径读文件;文件不存在/已被移动时返回 404。
func (c *NsfwController) 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
}
// 交给 gin 读文件并带上正确的 Content-Type / 断点续传
ctx.File(path)
}
+5
View File
@@ -33,6 +33,11 @@ func NewRouter(st *store.Store) *gin.Engine {
apiGroup.POST("/media/list", mc.List) apiGroup.POST("/media/list", mc.List)
apiGroup.POST("/media/stats", mc.Stats) apiGroup.POST("/media/stats", mc.Stats)
// 色情识别(查看检测分 + 图片预览)
nc := NewNsfwController(st)
apiGroup.GET("/nsfw/list", nc.List)
apiGroup.GET("/nsfw/preview", nc.Preview)
// 扫描整理(开始 / 中止 / 查进度 / 实时进度) // 扫描整理(开始 / 中止 / 查进度 / 实时进度)
sc := NewScanController(service.NewScanService(st)) sc := NewScanController(service.NewScanService(st))
apiGroup.POST("/scan/start", sc.Start) apiGroup.POST("/scan/start", sc.Start)
+1 -1
View File
@@ -17,7 +17,7 @@ import (
// - Dsn:数据库文件叫什么(默认 media-info.db,放在程序目录下) // - Dsn:数据库文件叫什么(默认 media-info.db,放在程序目录下)
// - AutoOpen:启动后要不要自动打开浏览器 // - AutoOpen:启动后要不要自动打开浏览器
// - FFmpegPath:ffmpeg 程序路径(用于把带 EXIF 旋转标记的图片转正);留空自动在 PATH 里找 // - FFmpegPath:ffmpeg 程序路径(用于把带 EXIF 旋转标记的图片转正);留空自动在 PATH 里找
// - RotateImages:整理时是否自动转正带 EXIF 旋转标记的图片(默认 true) // - RotateImages:是否处理图片 EXIF 旋转(默认 true);false 则完全不处理,按存储方向/尺寸归类
// - Workers:探测/MIME/移动阶段的并发 worker 数(默认 4;设 0 表示用满全部 CPU 核数) // - Workers:探测/MIME/移动阶段的并发 worker 数(默认 4;设 0 表示用满全部 CPU 核数)
// - NSFWEnabled:是否开启色情图片识别(本地 python + NudeNet,默认 false) // - NSFWEnabled:是否开启色情图片识别(本地 python + NudeNet,默认 false)
// - NSFWPython:python 可执行文件路径;留空自动在 PATH 里找 // - NSFWPython:python 可执行文件路径;留空自动在 PATH 里找
+5
View File
@@ -216,6 +216,11 @@ 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 {
+65 -2
View File
@@ -2,14 +2,29 @@
"""NudeNet 本地检测服务:由 Go 侧启动,常驻提供 /health 和 /detect。 """NudeNet 本地检测服务:由 Go 侧启动,常驻提供 /health 和 /detect。
只依赖 Python 标准库 + nudenet 包,模型在进程里只加载一次。 只依赖 Python 标准库 + nudenet 包,模型在进程里只加载一次。
收到 /detect 就把图片路径丢给 NudeDetector,返回结果 JSON。 收到 /detect 就把图片喂给 NudeDetector,返回结果 JSON。
Windows 超长路径说明:OpenCV 的 imread 走老式 C API,路径超过 260 字符就
打不开(报 can't open/read file)。所以这里改用 Python 的 open() 读字节,
再把字节直接交给 NudeNet(v3 的 detect 支持 bytes)——从内存解码,彻底
绕开路径限制,同时兼容老版 v2(只收路径时落到临时短路径文件再试)。
""" """
import json import json
import os
import sys import sys
import tempfile
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
from nudenet import NudeDetector from nudenet import NudeDetector
# 只报 OpenCV 致命错误,别让 imread 的 WARN 刷屏(也避免误导排查)
try:
import cv2
cv2.setLogLevel(8) # LOG_LEVEL_ERROR
except Exception:
pass
# 只有这些"暴露部位"才算色情;其它如肚子/腋窝/脚踝暴露不判。 # 只有这些"暴露部位"才算色情;其它如肚子/腋窝/脚踝暴露不判。
SENSITIVE = { SENSITIVE = {
"FEMALE_GENITALIA_EXPOSED", "FEMALE_GENITALIA_EXPOSED",
@@ -28,6 +43,51 @@ def get_detector():
return _detector return _detector
def _longpath(path):
"""Windows 下给绝对路径加 \\\\?\\ 前缀,让 Python 原生 open 也能处理超长路径。"""
if os.name != "nt":
return path
if path.startswith("\\\\?\\"):
return path
if path.startswith("\\\\"):
# UNC 网络路径:\\server\share → \\?\UNC\server\share
return "\\\\?\\UNC\\" + path.lstrip("\\")
if os.path.isabs(path):
return "\\\\?\\" + os.path.normpath(path)
return path
def _read_bytes(path):
"""用 Python 读文件字节(不经过 OpenCV),读不了就抛异常由上层兜底。"""
lp = _longpath(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 _detect_bytes(det, data, path):
"""把图片字节喂给 NudeNet。v3 直接吃 bytes;老版 v2 只收路径时落到临时短路径重试。"""
try:
return det.detect(data)
except (TypeError, AttributeError):
tmp = None
try:
fd, tmp = tempfile.mkstemp(suffix=os.path.splitext(path)[1] or ".jpg")
with os.fdopen(fd, "wb") as f:
f.write(data)
return det.detect(tmp)
finally:
if tmp:
try:
os.unlink(tmp)
except OSError:
pass
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
def _send(self, obj, status=200): def _send(self, obj, status=200):
data = json.dumps(obj, ensure_ascii=False).encode("utf-8") data = json.dumps(obj, ensure_ascii=False).encode("utf-8")
@@ -51,7 +111,10 @@ class Handler(BaseHTTPRequestHandler):
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)) threshold = float(body.get("threshold", 0.5))
detections = get_detector().detect(path) if not path:
return self._send({"ok": False, "error": "缺少图片路径"})
data = _read_bytes(path)
detections = _detect_bytes(get_detector(), data, path)
except Exception as exc: # noqa: BLE001 - 服务端任何异常都要回给 Go,别让进程崩 except Exception as exc: # noqa: BLE001 - 服务端任何异常都要回给 Go,别让进程崩
return self._send({"ok": False, "error": str(exc)}) return self._send({"ok": False, "error": str(exc)})
+50 -1
View File
@@ -63,10 +63,12 @@ type Organizer struct {
mover *mover.Mover // 负责实际搬文件的家伙 mover *mover.Mover // 负责实际搬文件的家伙
ctx context.Context // 中止信号;ctx 被取消时探测/归类立即停下 ctx context.Context // 中止信号;ctx 被取消时探测/归类立即停下
nsfw *nsfw.Detector // 色情检测器;为 nil 表示没启用,图片不检测 nsfw *nsfw.Detector // 色情检测器;为 nil 表示没启用,图片不检测
rotateImages bool // 是否处理 EXIF 旋转(转正/按显示方向归类);false 则完全不动,按存储方向归类
buckets map[string][]string // 目标目录 → 里面该放哪些文件(只记源路径) buckets map[string][]string // 目标目录 → 里面该放哪些文件(只记源路径)
order []string // 目标目录的先后顺序(保证输出稳定、不来回跳) order []string // 目标目录的先后顺序(保证输出稳定、不来回跳)
onProgress func(phase string, done, total int) onProgress func(phase string, done, total int)
pending []*store.MediaInfo // 待写入数据库的媒体记录(攒一批再批量写) pending []*store.MediaInfo // 待写入数据库的媒体记录(攒一批再批量写)
pendingNsfw []store.NsfwRecord // 待写入数据库的色情识别记录(攒一批再批量写)
} }
// New 创建整理器。store 可以为 nil——数据库打不开时,整理照常进行,只是不记入库信息。 // New 创建整理器。store 可以为 nil——数据库打不开时,整理照常进行,只是不记入库信息。
@@ -79,6 +81,7 @@ func New(pickerDir, ffprobePath, ffmpegPath string, st *store.Store, ctx context
store: st, store: st,
mover: &mover.Mover{}, mover: &mover.Mover{},
ctx: ctx, ctx: ctx,
rotateImages: true, // 默认处理 EXIF 旋转;关掉用 SetRotateImages(false)
buckets: make(map[string][]string), buckets: make(map[string][]string),
} }
} }
@@ -96,6 +99,13 @@ func (o *Organizer) SetNSFWDetector(d *nsfw.Detector) {
o.nsfw = d o.nsfw = d
} }
// SetRotateImages 设置是否处理图片 EXIF 旋转(默认 true)。
// - true:带旋转标记的图片做转正处理(有 ffmpeg 就烧进像素,没有则按显示方向归类);
// - false:完全不动,图片按文件里存储的方向/尺寸归类,不做任何旋转相关处理。
func (o *Organizer) SetRotateImages(b bool) {
o.rotateImages = b
}
// report 上报进度。total 为 0 表示这一步没活干,就不用报了。 // report 上报进度。total 为 0 表示这一步没活干,就不用报了。
func (o *Organizer) report(phase string, done, total int) { func (o *Organizer) report(phase string, done, total int) {
if o.onProgress != nil && total > 0 { if o.onProgress != nil && total > 0 {
@@ -337,6 +347,10 @@ func (o *Organizer) probeImage(f scanner.File) imageProbeResult {
} else { } else {
r.nsfwScore = score r.nsfwScore = score
r.nsfw = hit r.nsfw = hit
// 命中的图片把"文件名 + 检测分"打进运行日志,方便用户盯着看是否判对
if hit {
fmt.Printf("=== 色情命中: %s, 检测分=%.2f (阈值=%.2f)\n", f.Path, score, o.nsfw.Threshold())
}
} }
} }
return r return r
@@ -353,6 +367,9 @@ func (o *Organizer) probeImage(f scanner.File) imageProbeResult {
// //
// 只有方向 5/6/7/8(90°/270° 旋转)才需要宽高对调;2/3/4 是镜像或 180°,横竖不变。 // 只有方向 5/6/7/8(90°/270° 旋转)才需要宽高对调;2/3/4 是镜像或 180°,横竖不变。
func (o *Organizer) correctImageRotation(f scanner.File, res *probe.ProbeResult) { func (o *Organizer) correctImageRotation(f scanner.File, res *probe.ProbeResult) {
if !o.rotateImages {
return // 配置关闭:完全不处理旋转,按存储方向/尺寸归类
}
ori := probe.ReadExifOrientation(f.Path) ori := probe.ReadExifOrientation(f.Path)
if ori == 0 { if ori == 0 {
return // 没有旋转标记,不用管 return // 没有旋转标记,不用管
@@ -380,6 +397,7 @@ 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)
results := runParallel(o.ctx, images, o.probeImage, func(done int) { results := runParallel(o.ctx, images, o.probeImage, func(done int) {
o.report(PhaseImages, done, total) o.report(PhaseImages, done, total)
@@ -415,6 +433,14 @@ func (o *Organizer) ProcessImages(images []scanner.File) {
o.record(f, "image", r.res, r.md5Str, r.headMd5, r.tailMd5, r.size) o.record(f, "image", r.res, r.md5Str, r.headMd5, r.tailMd5, r.size)
if r.nsfw { if r.nsfw {
o.add(f, classify.ImageDirSensitive(f.Ext, r.res.Width, r.res.Height, r.darkScore, r.lightScore)) o.add(f, classify.ImageDirSensitive(f.Ext, r.res.Width, r.res.Height, r.darkScore, r.lightScore))
// 攒一条色情识别记录,最后连同"搬去哪了"一起写进数据库
o.pendingNsfw = append(o.pendingNsfw, store.NsfwRecord{
FilePath: f.Path,
FileName: filepath.Base(f.Path),
Score: r.nsfwScore,
Threshold: o.nsfw.Threshold(),
})
nsfwCount++
} else { } else {
o.add(f, classify.ImageDir(f.Ext, r.res.Width, r.res.Height, r.darkScore, r.lightScore)) o.add(f, classify.ImageDir(f.Ext, r.res.Width, r.res.Height, r.darkScore, r.lightScore))
} }
@@ -422,6 +448,9 @@ func (o *Organizer) ProcessImages(images []scanner.File) {
} }
} }
fmt.Printf("=== 图片处理完毕,成功: %d, 读取异常: %d, 忽略: %d, 重复: %d \n\n", success, fail, ignore, dup) fmt.Printf("=== 图片处理完毕,成功: %d, 读取异常: %d, 忽略: %d, 重复: %d \n\n", success, fail, ignore, dup)
if o.nsfw != nil && nsfwCount > 0 {
fmt.Printf("=== 色情识别: 共命中 %d 张,已归入「图片_敏感_*」目录,可在【色情识别】页面查看并人工复核\n", nsfwCount)
}
} }
// ============================ 视频/音频整理 ============================ // ============================ 视频/音频整理 ============================
@@ -544,7 +573,27 @@ func (o *Organizer) Flush() {
// 全局并行搬完:不管文件分散在几个目录,所有文件同时开搬。 // 全局并行搬完:不管文件分散在几个目录,所有文件同时开搬。
// onProgress 收到的是全局累计已搬数,正好和总数对得上。 // onProgress 收到的是全局累计已搬数,正好和总数对得上。
o.mover.MoveAll(jobs, func(done int) { // moved 是"源路径 → 新位置"的对照表,色情记录要用它补上整理后的实际路径。
moved := o.mover.MoveAll(jobs, func(done int) {
o.report(PhaseMove, done, len(jobs)) o.report(PhaseMove, done, len(jobs))
}) })
// 把色情识别记录补上"整理后位置"(预览就用它),再批量写进数据库
if len(o.pendingNsfw) > 0 {
records := make([]*store.NsfwRecord, 0, len(o.pendingNsfw))
for i := range o.pendingNsfw {
if target, ok := moved[o.pendingNsfw[i].FilePath]; ok {
o.pendingNsfw[i].MovedPath = target
} else {
o.pendingNsfw[i].MovedPath = o.pendingNsfw[i].FilePath // 没搬走就用源路径
}
records = append(records, &o.pendingNsfw[i])
}
if o.store != nil {
if err := o.store.SaveNsfwBatch(records); err != nil {
fmt.Printf("=== 保存色情识别记录失败: %v\n", err)
}
}
o.pendingNsfw = nil
}
} }
+7
View File
@@ -505,11 +505,18 @@ func (s *ScanService) runScan(ctx context.Context, rootDir, destDir string) erro
nsfwDet = d nsfwDet = d
defer nsfwDet.Stop() defer nsfwDet.Stop()
fmt.Println("=== 色情识别已启用(NudeNet)") fmt.Println("=== 色情识别已启用(NudeNet)")
// 清掉上一次的色情识别记录,保证「色情识别」页面显示的是本次扫描的结果
if s.store != nil {
if err := s.store.ClearNsfw(); err != nil {
fmt.Printf("=== 清空旧色情识别记录失败: %v\n", err)
}
}
} }
} }
org := organize.New(pickerDir, ffprobePath, ffmpegPath, s.store, ctx) org := organize.New(pickerDir, ffprobePath, ffmpegPath, s.store, ctx)
org.SetNSFWDetector(nsfwDet) org.SetNSFWDetector(nsfwDet)
org.SetRotateImages(config.GetAppConfig().RotateImages)
// 整个"整理"分两个阶段,界面上共用一条进度条: // 整个"整理"分两个阶段,界面上共用一条进度条:
// 归类阶段:图片、视频/音频分两批处理。进度 = 已处理文件数 / 全部待整理文件数 // 归类阶段:图片、视频/音频分两批处理。进度 = 已处理文件数 / 全部待整理文件数
+45 -2
View File
@@ -63,6 +63,18 @@ type AppConfig struct {
Value string `xorm:"text 'value'"` // 配置值 Value string `xorm:"text 'value'"` // 配置值
} }
// NsfwRecord 一条"色情图片识别"的记录:哪张图、检测分多少、判了没有、现在在哪。
// 前端「色情识别」页面就是查这张表来展示每张图片的检测结果,方便人工复核准确性。
type NsfwRecord 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"` // 命中的敏感部位最高分 0~1
Threshold float64 `xorm:"real 'threshold'" json:"threshold"` // 判定阈值(>= 它才算色情)
MovedPath string `xorm:"text 'moved_path'" json:"movedPath"` // 整理后实际位置(预览用);没搬走时同源路径
CreatedAt time.Time `xorm:"created 'created_at'" json:"createdAt"` // 检测时间
}
// Open 打开(或第一次时创建)数据库,并保证表结构齐全。 // Open 打开(或第一次时创建)数据库,并保证表结构齐全。
func Open(dbPath string) (*Store, error) { func Open(dbPath string) (*Store, error) {
e, err := xorm.NewEngine("sqlite3", dbPath) e, err := xorm.NewEngine("sqlite3", dbPath)
@@ -72,8 +84,8 @@ func Open(dbPath string) (*Store, error) {
e.ShowSQL(false) e.ShowSQL(false)
// SQLite 同一时刻只允许一个写连接,这里限制成 1 个,避免并发写冲突 // SQLite 同一时刻只允许一个写连接,这里限制成 1 个,避免并发写冲突
e.SetMaxOpenConns(1) e.SetMaxOpenConns(1)
// 自动创建/补全两张表(媒体记录表、配置表) // 自动创建/补全三张表(媒体记录表、配置表、色情识别记录表)
if err := e.Sync(new(MediaInfo), new(AppConfig)); err != nil { if err := e.Sync(new(MediaInfo), new(AppConfig), new(NsfwRecord)); err != nil {
_ = e.Close() _ = e.Close()
return nil, err return nil, err
} }
@@ -123,6 +135,37 @@ func (s *Store) GetConfig(key string) string {
return cfg.Value return cfg.Value
} }
// ============================ 色情识别记录 ============================
// SaveNsfwBatch 批量保存色情识别记录(一次扫描攒一批,最后统一写入)。
func (s *Store) SaveNsfwBatch(records []*NsfwRecord) error {
if s == nil || s.engine == nil || len(records) == 0 {
return nil
}
_, err := s.engine.Insert(records)
return err
}
// ClearNsfw 清空色情识别记录。每次开启色情识别的扫描开始时调用,
// 保证「色情识别」页面看到的是最近一次扫描的结果。
func (s *Store) ClearNsfw() error {
if s == nil || s.engine == nil {
return nil
}
_, err := s.engine.Where("1 = 1").Delete(new(NsfwRecord))
return err
}
// ListNsfw 查全部色情识别记录,按检测分从高到低排(方便先看最"实锤"的)。
func (s *Store) ListNsfw() ([]NsfwRecord, error) {
if s == nil || s.engine == nil {
return nil, nil
}
list := make([]NsfwRecord, 0)
err := s.engine.Desc("score").Find(&list)
return list, err
}
// ============================ 媒体记录的新增/更新 ============================ // ============================ 媒体记录的新增/更新 ============================
// SaveMedia 保存/更新一条媒体记录:按 MD5 找,有就更新,没有就新增。 // SaveMedia 保存/更新一条媒体记录:按 MD5 找,有就更新,没有就新增。
+6 -1
View File
@@ -55,7 +55,12 @@ func main() {
addr := fmt.Sprintf(":%d", cfg.Port) addr := fmt.Sprintf(":%d", cfg.Port)
url := "http://localhost" + addr url := "http://localhost" + addr
fmt.Printf("=== Web 服务已启动: %s\n", url) fmt.Printf("=== Web 服务已启动: %s\n", url)
fmt.Printf("=== 数据库: %s\n", cfg.Dsn) // 数据库路径:相对路径转成绝对路径打印,方便用户知道账本在哪
dbAbs := cfg.Dsn
if abs, err := filepath.Abs(cfg.Dsn); err == nil {
dbAbs = abs
}
fmt.Printf("=== 数据库: %s\n", dbAbs)
fmt.Printf("=== 在页面上点击【开始扫描】执行媒体整理(会移动文件,注意备份)\n") fmt.Printf("=== 在页面上点击【开始扫描】执行媒体整理(会移动文件,注意备份)\n")
// 配置了自动打开浏览器的话,等服务起来后帮用户打开 // 配置了自动打开浏览器的话,等服务起来后帮用户打开
if cfg.AutoOpen { if cfg.AutoOpen {