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
+68 -19
View File
@@ -56,30 +56,33 @@ const (
//
// 这样设计是为了避免"边分类边搬"导致目录反复开关、文件搬来搬去,效率更高。
type Organizer struct {
pickerDir string // 整理后的文件都放到这个根目录下
ffprobePath string // ffprobe 程序路径;为空表示没有可用解码器
ffmpegPath string // ffmpeg 程序路径;为空表示没有转正 EXIF 旋转图片的能力
store *store.Store // 数据库;为 nil 表示不记录媒体信息
mover *mover.Mover // 负责实际搬文件的家伙
ctx context.Context // 中止信号;ctx 被取消时探测/归类立即停下
nsfw *nsfw.Detector // 色情检测器;为 nil 表示没启用,图片不检测
buckets map[string][]string // 目标目录 → 里面该放哪些文件(只记源路径)
order []string // 目标目录的先后顺序(保证输出稳定、不来回跳)
onProgress func(phase string, done, total int)
pending []*store.MediaInfo // 待写入数据库的媒体记录(攒一批再批量写)
pickerDir string // 整理后的文件都放到这个根目录下
ffprobePath string // ffprobe 程序路径;为空表示没有可用解码器
ffmpegPath string // ffmpeg 程序路径;为空表示没有转正 EXIF 旋转图片的能力
store *store.Store // 数据库;为 nil 表示不记录媒体信息
mover *mover.Mover // 负责实际搬文件的家伙
ctx context.Context // 中止信号;ctx 被取消时探测/归类立即停下
nsfw *nsfw.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 // 待写入数据库的色情识别记录(攒一批再批量写)
}
// New 创建整理器。store 可以为 nil——数据库打不开时,整理照常进行,只是不记入库信息。
// ctx 可以为 nil——不提供中止能力,任务跑到完为止。
func New(pickerDir, ffprobePath, ffmpegPath string, st *store.Store, ctx context.Context) *Organizer {
return &Organizer{
pickerDir: pickerDir,
ffprobePath: ffprobePath,
ffmpegPath: ffmpegPath,
store: st,
mover: &mover.Mover{},
ctx: ctx,
buckets: make(map[string][]string),
pickerDir: pickerDir,
ffprobePath: ffprobePath,
ffmpegPath: ffmpegPath,
store: st,
mover: &mover.Mover{},
ctx: ctx,
rotateImages: true, // 默认处理 EXIF 旋转;关掉用 SetRotateImages(false)
buckets: make(map[string][]string),
}
}
@@ -96,6 +99,13 @@ func (o *Organizer) SetNSFWDetector(d *nsfw.Detector) {
o.nsfw = d
}
// SetRotateImages 设置是否处理图片 EXIF 旋转(默认 true)。
// - true:带旋转标记的图片做转正处理(有 ffmpeg 就烧进像素,没有则按显示方向归类);
// - false:完全不动,图片按文件里存储的方向/尺寸归类,不做任何旋转相关处理。
func (o *Organizer) SetRotateImages(b bool) {
o.rotateImages = b
}
// report 上报进度。total 为 0 表示这一步没活干,就不用报了。
func (o *Organizer) report(phase string, done, total int) {
if o.onProgress != nil && total > 0 {
@@ -337,6 +347,10 @@ func (o *Organizer) probeImage(f scanner.File) imageProbeResult {
} else {
r.nsfwScore = score
r.nsfw = hit
// 命中的图片把"文件名 + 检测分"打进运行日志,方便用户盯着看是否判对
if hit {
fmt.Printf("=== 色情命中: %s, 检测分=%.2f (阈值=%.2f)\n", f.Path, score, o.nsfw.Threshold())
}
}
}
return r
@@ -353,6 +367,9 @@ func (o *Organizer) probeImage(f scanner.File) imageProbeResult {
//
// 只有方向 5/6/7/8(90°/270° 旋转)才需要宽高对调;2/3/4 是镜像或 180°,横竖不变。
func (o *Organizer) correctImageRotation(f scanner.File, res *probe.ProbeResult) {
if !o.rotateImages {
return // 配置关闭:完全不处理旋转,按存储方向/尺寸归类
}
ori := probe.ReadExifOrientation(f.Path)
if ori == 0 {
return // 没有旋转标记,不用管
@@ -380,6 +397,7 @@ func (o *Organizer) correctImageRotation(f scanner.File, res *probe.ProbeResult)
func (o *Organizer) ProcessImages(images []scanner.File) {
// 统计结果,最后打一行总结给用户看
success, fail, ignore, dup := 0, 0, 0, 0
nsfwCount := 0 // 命中的色情图片数
total := len(images)
results := runParallel(o.ctx, images, o.probeImage, func(done int) {
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)
if r.nsfw {
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 {
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)
if o.nsfw != nil && nsfwCount > 0 {
fmt.Printf("=== 色情识别: 共命中 %d 张,已归入「图片_敏感_*」目录,可在【色情识别】页面查看并人工复核\n", nsfwCount)
}
}
// ============================ 视频/音频整理 ============================
@@ -544,7 +573,27 @@ func (o *Organizer) Flush() {
// 全局并行搬完:不管文件分散在几个目录,所有文件同时开搬。
// onProgress 收到的是全局累计已搬数,正好和总数对得上。
o.mover.MoveAll(jobs, func(done int) {
// moved 是"源路径 → 新位置"的对照表,色情记录要用它补上整理后的实际路径。
moved := o.mover.MoveAll(jobs, func(done int) {
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
}
}