feat: 色情识别结果可视化与日志;超长路径修复;旋转开关、明暗5档等
This commit is contained in:
@@ -1,2 +1,3 @@
|
|||||||
.idea/
|
.idea/
|
||||||
media-info.db
|
media-info.db
|
||||||
|
__pycache__/
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|
||||||
## 色情图片识别(可选)
|
## 色情图片识别(可选)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 里找
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+66
-3
@@ -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)})
|
||||||
|
|
||||||
@@ -73,4 +136,4 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
port = int(sys.argv[1]) if len(sys.argv) > 1 else 0
|
port = int(sys.argv[1]) if len(sys.argv) > 1 else 0
|
||||||
HTTPServer(("127.0.0.1", port), Handler).serve_forever()
|
HTTPServer(("127.0.0.1", port), Handler).serve_forever()
|
||||||
@@ -56,30 +56,33 @@ const (
|
|||||||
//
|
//
|
||||||
// 这样设计是为了避免"边分类边搬"导致目录反复开关、文件搬来搬去,效率更高。
|
// 这样设计是为了避免"边分类边搬"导致目录反复开关、文件搬来搬去,效率更高。
|
||||||
type Organizer struct {
|
type Organizer struct {
|
||||||
pickerDir string // 整理后的文件都放到这个根目录下
|
pickerDir string // 整理后的文件都放到这个根目录下
|
||||||
ffprobePath string // ffprobe 程序路径;为空表示没有可用解码器
|
ffprobePath string // ffprobe 程序路径;为空表示没有可用解码器
|
||||||
ffmpegPath string // ffmpeg 程序路径;为空表示没有转正 EXIF 旋转图片的能力
|
ffmpegPath string // ffmpeg 程序路径;为空表示没有转正 EXIF 旋转图片的能力
|
||||||
store *store.Store // 数据库;为 nil 表示不记录媒体信息
|
store *store.Store // 数据库;为 nil 表示不记录媒体信息
|
||||||
mover *mover.Mover // 负责实际搬文件的家伙
|
mover *mover.Mover // 负责实际搬文件的家伙
|
||||||
ctx context.Context // 中止信号;ctx 被取消时探测/归类立即停下
|
ctx context.Context // 中止信号;ctx 被取消时探测/归类立即停下
|
||||||
nsfw *nsfw.Detector // 色情检测器;为 nil 表示没启用,图片不检测
|
nsfw *nsfw.Detector // 色情检测器;为 nil 表示没启用,图片不检测
|
||||||
buckets map[string][]string // 目标目录 → 里面该放哪些文件(只记源路径)
|
rotateImages bool // 是否处理 EXIF 旋转(转正/按显示方向归类);false 则完全不动,按存储方向归类
|
||||||
order []string // 目标目录的先后顺序(保证输出稳定、不来回跳)
|
buckets map[string][]string // 目标目录 → 里面该放哪些文件(只记源路径)
|
||||||
onProgress func(phase string, done, total int)
|
order []string // 目标目录的先后顺序(保证输出稳定、不来回跳)
|
||||||
pending []*store.MediaInfo // 待写入数据库的媒体记录(攒一批再批量写)
|
onProgress func(phase string, done, total int)
|
||||||
|
pending []*store.MediaInfo // 待写入数据库的媒体记录(攒一批再批量写)
|
||||||
|
pendingNsfw []store.NsfwRecord // 待写入数据库的色情识别记录(攒一批再批量写)
|
||||||
}
|
}
|
||||||
|
|
||||||
// New 创建整理器。store 可以为 nil——数据库打不开时,整理照常进行,只是不记入库信息。
|
// New 创建整理器。store 可以为 nil——数据库打不开时,整理照常进行,只是不记入库信息。
|
||||||
// ctx 可以为 nil——不提供中止能力,任务跑到完为止。
|
// ctx 可以为 nil——不提供中止能力,任务跑到完为止。
|
||||||
func New(pickerDir, ffprobePath, ffmpegPath string, st *store.Store, ctx context.Context) *Organizer {
|
func New(pickerDir, ffprobePath, ffmpegPath string, st *store.Store, ctx context.Context) *Organizer {
|
||||||
return &Organizer{
|
return &Organizer{
|
||||||
pickerDir: pickerDir,
|
pickerDir: pickerDir,
|
||||||
ffprobePath: ffprobePath,
|
ffprobePath: ffprobePath,
|
||||||
ffmpegPath: ffmpegPath,
|
ffmpegPath: ffmpegPath,
|
||||||
store: st,
|
store: st,
|
||||||
mover: &mover.Mover{},
|
mover: &mover.Mover{},
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
buckets: make(map[string][]string),
|
rotateImages: true, // 默认处理 EXIF 旋转;关掉用 SetRotateImages(false)
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 找,有就更新,没有就新增。
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user