fix: 重复文件归类、图片探测与明暗分析健壮性
- organize/store: 按 MD5 去重,重复图片/视频/音频统一移入「重复文件」目录,不再覆盖已有记录 - probe/image: webp/bmp/psd/psb 专用解码失败后走 ffprobe 兜底,与通用路径一致 - probe/brightness: 大图内存缩放为缩略图再分析明暗,超4000万像素跳过分析防OOM - README: 补充重复文件目录说明,修正 Go 版本为 1.25.0
This commit is contained in:
@@ -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
|
||||
|
||||
## 目录结构
|
||||
|
||||
|
||||
@@ -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 "", "", "", 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 将所有收集到的文件移动到对应目录,并回填归类去向
|
||||
|
||||
@@ -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
|
||||
|
||||
+28
-9
@@ -32,33 +32,52 @@ 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 := 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("无法读取图片尺寸: %s", filePath)
|
||||
return nil, fmt.Errorf("ffprobe 兜底失败: %s", filePath)
|
||||
}
|
||||
|
||||
// decodeConfig 用 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 {
|
||||
|
||||
Reference in New Issue
Block a user