feat: 色情识别结果可视化与日志;超长路径修复;旋转开关、明暗5档等
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user