From a667f7fa3583e9afe0cd9e404d497e5aa10fb0f2 Mon Sep 17 00:00:00 2001 From: Odboy Date: Sat, 12 Apr 2025 21:02:16 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=87=8D=E5=A4=8D=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=BD=92=E7=B1=BB=E3=80=81=E5=9B=BE=E7=89=87=E6=8E=A2=E6=B5=8B?= =?UTF-8?q?=E4=B8=8E=E6=98=8E=E6=9A=97=E5=88=86=E6=9E=90=E5=81=A5=E5=A3=AE?= =?UTF-8?q?=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - organize/store: 按 MD5 去重,重复图片/视频/音频统一移入「重复文件」目录,不再覆盖已有记录 - probe/image: webp/bmp/psd/psb 专用解码失败后走 ffprobe 兜底,与通用路径一致 - probe/brightness: 大图内存缩放为缩略图再分析明暗,超4000万像素跳过分析防OOM - README: 补充重复文件目录说明,修正 Go 版本为 1.25.0 --- README.md | 12 +++++- internal/organize/organize.go | 77 +++++++++++++++++++++++++++-------- internal/probe/brightness.go | 59 +++++++++++++++++++++++---- internal/probe/image.go | 39 +++++++++++++----- internal/store/store.go | 13 ++++++ 5 files changed, 162 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 3006404..be94114 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ sh build.sh ``` -- 依赖 Go 1.19+(`go.mod` 指定 1.19) +- 依赖 Go 1.25.0+(`go.mod` 指定 1.25.0) - 构建时内嵌 **Windows 版 ffprobe**:`internal/probe/bin/ffprobe.exe`,需提前放置(未入库,见 `.gitignore`) - `build.sh` 在 **Windows** 下 `go build` 后使用 `upx` 压缩,输出 `kenaito-media-picker.exe` - 编译所需工具(ffmpeg / upx)压缩包已放在 `doc/` 目录,按需解压使用(upx 放置于项目根目录 `upx`) @@ -81,6 +81,14 @@ kenaito-media-picker.exe 视频_已忽略 ← 非媒体文件或无法识别 ``` +### 重复文件 + +```text +重复文件 ← 与库中已有记录 MD5 相同的图片/视频/音频(不去重入库,单独存放) +``` + +> 文件按 MD5 去重:已入库的同一内容文件(不论图片、视频还是音频)统一移入「重复文件」目录。 + > 视频时长与分辨率通过内嵌 ffprobe 读取(处理时会临时释放到当前目录、完成后自动删除);凡 ffmpeg 支持的视频/音频格式均可识别。 ## 明暗评分算法 @@ -94,7 +102,7 @@ lightScore = 亮度 > 175 的像素占比 - **判定规则**:`lightScore` 高于 `darkScore` 15 个百分点以上归 `-bright_N`,反之归 `-dark_N`;差幅不足 15% 视为中性归 `-mid`(不强制二选一) - **档位命名**:N 为占比四舍五入到 10% 的档位(10~100),如 darkScore 0.70 → `dark_70`,表示占比命中 [N-5%, N+5%) 区间 - **透明处理**:完全透明的像素(alpha=0)不计入统计,全透明图片归 `-mid` -- **性能**:大图(>100 万像素)自动采样加速 +- **性能**:大图(>100 万像素)先等比缩放为最长边 512px 的缩略图(纯内存)再统计明暗,内存占用与耗时大幅下降;超 4000 万像素的图片直接跳过明暗分析(按中性 mid 归类)避免 OOM ## 目录结构 diff --git a/internal/organize/organize.go b/internal/organize/organize.go index 821af71..cdb4668 100644 --- a/internal/organize/organize.go +++ b/internal/organize/organize.go @@ -23,6 +23,7 @@ const ( dirVideoErr = "视频_读取异常" dirVideoIgnored = "视频_已忽略" dirAudio = "音频" + dirDuplicate = "重复文件" ) // Organizer 整理器:按目标目录收集文件,处理完成后统一建目录并移动 @@ -55,17 +56,19 @@ func (o *Organizer) add(f scanner.File, subDir string) { o.buckets[dir] = append(o.buckets[dir], f.Path) } -// record 将媒体信息写入数据库(大文件优先用指纹复用已入库的 MD5) -func (o *Organizer) record(f scanner.File, mediaType string, res *probe.ProbeResult) { +// calcFileInfo 读取文件大小并计算 MD5(大文件优先用指纹复用已入库的 MD5,跳过全量读取)。 +// store 为 nil 时直接返回空值(无需去重/入库)。 +func (o *Organizer) calcFileInfo(f scanner.File) (md5Str, headMd5, tailMd5 string, size int64) { + if o.store == nil { + return "", "", "", 0 + } st, err := os.Stat(f.Path) if err != nil { fmt.Printf("=== 读取文件信息失败: %s, 错误: %v\n", f.Path, err) - return + return "", "", "", 0 } - size := st.Size() + size = st.Size() - md5Str := "" - headMd5, tailMd5 := "", "" if size > digest.FingerprintThreshold { if head, tail, ferr := digest.CalcFileFingerprint(f.Path, size); ferr == nil { headMd5, tailMd5 = head, tail @@ -77,10 +80,26 @@ func (o *Organizer) record(f scanner.File, mediaType string, res *probe.ProbeRes if md5Str == "" { if md5Str, err = digest.CalcFileMd5(f.Path); err != nil { fmt.Printf("=== 计算MD5失败: %s, 错误: %v\n", f.Path, err) - return + return "", "", "", size } } + return md5Str, headMd5, tailMd5, size +} +// isDuplicate 判断文件是否与已入库记录重复(按 MD5) +func (o *Organizer) isDuplicate(md5Str string) bool { + if o.store == nil || md5Str == "" { + return false + } + _, ok := o.store.FindMediaByMd5(md5Str) + return ok +} + +// record 将媒体信息写入数据库 +func (o *Organizer) record(f scanner.File, mediaType string, res *probe.ProbeResult, md5Str, headMd5, tailMd5 string, size int64) { + if o.store == nil || md5Str == "" { + return + } codec := res.VideoCodec if mediaType == "audio" { codec = res.AudioCodec @@ -107,20 +126,22 @@ func (o *Organizer) record(f scanner.File, mediaType string, res *probe.ProbeRes } } -// ProcessImages 处理全部图片:探测尺寸并归类 +// ProcessImages 处理全部图片:去重 → 探测尺寸并归类 func (o *Organizer) ProcessImages(images []scanner.File) { - success, fail, ignore := 0, 0, 0 + success, fail, ignore, dup := 0, 0, 0, 0 for _, f := range images { switch { case strings.EqualFold(f.Ext, ".psd"): o.add(f, dirImagePSD) success++ continue - case !probe.IsSupportImage(f.Ext): + case !probe.IsSupportImage(f.Ext) && o.ffprobePath == "": + // 非白名单扩展名且无 ffprobe 兜底时直接忽略 o.add(f, dirImageIgnored) ignore++ continue } + res, err := probe.ProbeImage(f.Path, o.ffprobePath) if err != nil { o.add(f, dirImageErr) @@ -128,7 +149,15 @@ func (o *Organizer) ProcessImages(images []scanner.File) { fmt.Printf("=== 异常图片: %s \n", f.Path) continue } - o.record(f, "image", res) + + md5Str, headMd5, tailMd5, size := o.calcFileInfo(f) + if o.isDuplicate(md5Str) { + o.add(f, dirDuplicate) + dup++ + fmt.Printf("=== 重复图片: %s \n", f.Path) + continue + } + o.record(f, "image", res, md5Str, headMd5, tailMd5, size) darkScore, lightScore := 0.0, 0.0 if !strings.EqualFold(f.Ext, ".gif") { dark, light, berr := probe.AnalyzeBrightness(f.Path) @@ -141,12 +170,12 @@ func (o *Organizer) ProcessImages(images []scanner.File) { o.add(f, classify.ImageDir(f.Ext, res.Width, res.Height, darkScore, lightScore)) success++ } - fmt.Printf("=== 图片处理完毕,成功: %d, 读取异常: %d, 忽略: %d \n\n", success, fail, ignore) + fmt.Printf("=== 图片处理完毕,成功: %d, 读取异常: %d, 忽略: %d, 重复: %d \n\n", success, fail, ignore, dup) } -// ProcessMedia 处理媒体候选:ffprobe 判定视频/音频/不支持 +// ProcessMedia 处理媒体候选:去重 → ffprobe 判定视频/音频/不支持 func (o *Organizer) ProcessMedia(media []scanner.File) { - video, audio, fail, ignore := 0, 0, 0, 0 + video, audio, fail, ignore, dup := 0, 0, 0, 0, 0 for _, f := range media { res, err := probe.ProbeMediaFile(o.ffprobePath, f.Path) if err != nil { @@ -167,12 +196,26 @@ func (o *Organizer) ProcessMedia(media []scanner.File) { } switch { case res.HasVideo: + md5Str, headMd5, tailMd5, size := o.calcFileInfo(f) + if o.isDuplicate(md5Str) { + dup++ + o.add(f, dirDuplicate) + fmt.Printf("=== 重复媒体: %s \n", f.Path) + continue + } video++ - o.record(f, "video", res) + o.record(f, "video", res, md5Str, headMd5, tailMd5, size) o.add(f, classify.VideoDir(f.Ext, res.Width, res.Height)) case res.HasAudio: + md5Str, headMd5, tailMd5, size := o.calcFileInfo(f) + if o.isDuplicate(md5Str) { + dup++ + o.add(f, dirDuplicate) + fmt.Printf("=== 重复媒体: %s \n", f.Path) + continue + } audio++ - o.record(f, "audio", res) + o.record(f, "audio", res, md5Str, headMd5, tailMd5, size) o.add(f, dirAudio) default: // ffprobe 无音视频流:MIME 明确为媒体的归入忽略目录,其余原地不动 @@ -185,7 +228,7 @@ func (o *Organizer) ProcessMedia(media []scanner.File) { } } } - fmt.Printf("=== 媒体处理完毕,视频: %d, 音频: %d, 读取异常: %d, 忽略: %d \n\n", video, audio, fail, ignore) + fmt.Printf("=== 媒体处理完毕,视频: %d, 音频: %d, 读取异常: %d, 忽略: %d, 重复: %d \n\n", video, audio, fail, ignore, dup) } // Flush 将所有收集到的文件移动到对应目录,并回填归类去向 diff --git a/internal/probe/brightness.go b/internal/probe/brightness.go index 9b4ec48..d7700c2 100644 --- a/internal/probe/brightness.go +++ b/internal/probe/brightness.go @@ -2,7 +2,6 @@ package probe import ( "image" - "math" "os" _ "image/gif" @@ -10,27 +9,74 @@ import ( _ "image/png" _ "golang.org/x/image/bmp" + "golang.org/x/image/draw" _ "golang.org/x/image/tiff" _ "golang.org/x/image/webp" ) +const ( + // brightnessDecodeLimit 明暗分析允许解码的最大像素数,超出时跳过分析(按 mid 归类),避免超大图 OOM + brightnessDecodeLimit = 40_000_000 + // thumbnailMaxEdge 明暗分析时大图缩放后的最长边像素,缩小到该尺寸内再统计 + thumbnailMaxEdge = 512 + // thumbnailScaleThreshold 像素数超过该值才缩放为缩略图再统计 + thumbnailScaleThreshold = 1_000_000 +) + // AnalyzeBrightness 打开并解码图片,分析明暗程度。 // darkScore: 暗色像素(亮度 < 80)占比;lightScore: 亮色像素(亮度 > 175)占比。 +// 超大图(超过 brightnessDecodeLimit 像素)直接跳过分析返回 mid;大图先缩放为缩略图再统计,降低内存与耗时。 func AnalyzeBrightness(filePath string) (darkScore float64, lightScore float64, err error) { f, err := os.Open(filePath) if err != nil { return 0, 0, err } defer f.Close() + + cfg, _, err := image.DecodeConfig(f) + if err != nil { + return 0, 0, err + } + if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width*cfg.Height > brightnessDecodeLimit { + return 0, 0, nil // 超大图跳过,由调用方按 mid 归类 + } + if _, err := f.Seek(0, 0); err != nil { + return 0, 0, err + } img, _, err := image.Decode(f) if err != nil { return 0, 0, err } + + if img.Bounds().Dx()*img.Bounds().Dy() > thumbnailScaleThreshold { + img = scaleToThumbnail(img, thumbnailMaxEdge) + } dark, light := analyzeBrightness(img) return dark, light, nil } -// analyzeBrightness 统计暗色/亮色像素占比(ITU-R BT.601 亮度公式),大图自动采样加速。 +// scaleToThumbnail 将大图等比缩放到最长边不超过 maxEdge 的缩略图(纯内存,不落盘)。 +// 缩放是像素平均,整体亮度分布基本保留,用于粗粒度明暗分类足够准确。 +func scaleToThumbnail(img image.Image, maxEdge int) image.Image { + srcBounds := img.Bounds() + srcW, srcH := srcBounds.Dx(), srcBounds.Dy() + if srcW <= 0 || srcH <= 0 { + return img + } + ratio := float64(maxEdge) / float64(max(srcW, srcH)) + dstW, dstH := int(float64(srcW)*ratio), int(float64(srcH)*ratio) + if dstW < 1 { + dstW = 1 + } + if dstH < 1 { + dstH = 1 + } + dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH)) + draw.BiLinear.Scale(dst, dst.Bounds(), img, srcBounds, draw.Over, nil) + return dst +} + +// analyzeBrightness 统计暗色/亮色像素占比(ITU-R BT.601 亮度公式)。 // 完全透明的像素(alpha=0)不计入统计。 func analyzeBrightness(img image.Image) (darkScore float64, lightScore float64) { bounds := img.Bounds() @@ -40,13 +86,8 @@ func analyzeBrightness(img image.Image) (darkScore float64, lightScore float64) } darkCount, lightCount, sampleCount := 0, 0, 0 - sampleStep := 1 - if totalPixels > 1_000_000 { - sampleStep = int(math.Sqrt(float64(totalPixels) / 100_000)) - } - - for y := bounds.Min.Y; y < bounds.Max.Y; y += sampleStep { - for x := bounds.Min.X; x < bounds.Max.X; x += sampleStep { + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { r, g, b, a := img.At(x, y).RGBA() if a == 0 { continue diff --git a/internal/probe/image.go b/internal/probe/image.go index 3575412..7c1d2b9 100644 --- a/internal/probe/image.go +++ b/internal/probe/image.go @@ -32,35 +32,54 @@ func IsSupportImage(ext string) bool { } // ProbeImage 统一探测图片尺寸: -// 1. 特殊格式(webp/bmp/psd/psb)走专用解码器 -// 2. 其余支持格式走 Go 标准库 image.DecodeConfig +// 1. 特殊格式(webp/bmp/psd/psb)走专用解码器,失败后用 ffprobe 兜底 +// 2. 其余白名单格式走 Go 标准库 image.DecodeConfig // 3. 标准库失败时用 ffprobe 兜底(ffmpeg 支持的图片格式更广,如 tiff/svg 等) // -// ffprobePath 为空表示不启用 ffprobe 兜底;扩展名不属于支持格式时返回错误。 +// ffprobePath 为空表示不启用 ffprobe 兜底;非白名单扩展名(如 tiff)在 ffprobe 可用时兜底探测。 func ProbeImage(filePath, ffprobePath string) (*ProbeResult, error) { ext := strings.ToLower(filepath.Ext(filePath)) switch ext { case ".webp": - return probeWebp(filePath) + if res, err := probeWebp(filePath); err == nil { + return res, nil + } case ".bmp": - return probeBmp(filePath) + if res, err := probeBmp(filePath); err == nil { + return res, nil + } case ".psd", ".psb": - return probePsd(filePath) + if res, err := probePsd(filePath); err == nil { + return res, nil + } } if !IsSupportImage(ext) { + // 非白名单扩展名(tiff/svg 等):ffprobe 可用时兜底探测,否则视为不支持 + if res, err := probeByFFprobe(ffprobePath, filePath); err == nil { + return res, nil + } return nil, fmt.Errorf("不支持的图片格式: %s", ext) } if w, h, err := decodeConfig(filePath); err == nil { return &ProbeResult{Width: w, Height: h}, nil } - if ffprobePath != "" { - if res, err := ProbeMediaFile(ffprobePath, filePath); err == nil && res.HasVideo { - return res, nil - } + if res, err := probeByFFprobe(ffprobePath, filePath); err == nil { + return res, nil } return nil, fmt.Errorf("无法读取图片尺寸: %s", filePath) } +// probeByFFprobe 用 ffprobe 兜底探测图片尺寸;ffprobePath 为空或探测失败时返回错误 +func probeByFFprobe(ffprobePath, filePath string) (*ProbeResult, error) { + if ffprobePath == "" { + return nil, fmt.Errorf("未启用 ffprobe 兜底") + } + if res, err := ProbeMediaFile(ffprobePath, filePath); err == nil && res.HasVideo { + return res, nil + } + return nil, fmt.Errorf("ffprobe 兜底失败: %s", filePath) +} + // decodeConfig 用 Go 标准库读取图片宽高 func decodeConfig(filePath string) (int, int, error) { f, err := os.Open(filePath) diff --git a/internal/store/store.go b/internal/store/store.go index d13c796..91855be 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -104,6 +104,19 @@ func (s *Store) FindMediaByFingerprint(size int64, headMd5, tailMd5 string) (*Me return info, true } +// FindMediaByMd5 按文件MD5查找已入库记录(用于识别重复文件) +func (s *Store) FindMediaByMd5(md5 string) (*MediaInfo, bool) { + if s == nil || s.engine == nil || md5 == "" { + return nil, false + } + info := new(MediaInfo) + has, err := s.engine.Where("file_md5 = ?", md5).Get(info) + if err != nil || !has { + return nil, false + } + return info, true +} + // UpdateMediaDest 按源路径回填文件的归类目录与去向 func (s *Store) UpdateMediaDest(filePath, destDir string) error { if s == nil || s.engine == nil {