diff --git a/README.md b/README.md index 9c1f714..ac9f692 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,153 @@ -# kenaito-dark-wallpaper-finder +# Kenaito Dark Wallpaper Finder 🖤 -#### 介绍 -当壁纸数量达到一个量级的时候,人工筛选黑色调的壁纸过于的繁杂,一个好的算法+自动化是最佳的解决方案 +一个自动扫描、分析并整理暗色系壁纸的命令行工具。 -#### 执行结果 -![1](./doc/QQ20251221-093651.png) +程序会递归扫描指定目录下的所有图片,根据亮度算法计算每张图片的"黑暗程度"评分,然后按评分等级自动分类移动(或复制)到不同子目录中。 -![1](./doc/QQ20251221-100004.png) +## 功能特性 + +- **智能暗色分析**:基于 ITU-R BT.601 亮度公式计算黑暗评分,纯黑加分,鲜艳色惩罚 +- **多格式支持**:JPG、PNG、GIF、BMP、WebP、TIFF +- **五级分类**:按 ≥95%、≥85%、≥75%、≥65%、≥55% 五个黑暗等级自动归档 +- **并发处理**:默认 24 线程并行分析,大图自动采样加速 +- **灵活配置**:所有参数均可通过命令行调整 + +## 编译 + +```bash +# Linux / macOS +go build -o kenaito-dark-wallpaper-finder main.go + +# Windows +go build -o kenaito-dark-wallpaper-finder.exe main.go +``` + +或使用构建脚本: + +```bash +bash build.sh +``` + +## 快速开始 + +```bash +# 在壁纸目录下运行(默认移动模式,等待5秒确认) +cd ~/Pictures/wallpapers +./kenaito-dark-wallpaper-finder + +# 用复制模式试运行(不会移动原文件) +./kenaito-dark-wallpaper-finder -copy -sleep 0 +``` + +执行后,源目录下会生成如下结构: + +``` +wallpapers/ +├── wallpapers_dark_wallpapers/ +│ ├── dark_95/ ← 黑暗度 ≥ 95% +│ │ ├── photo1_[2560x1440]_dark97.jpg +│ │ └── ... +│ ├── dark_85/ ← 黑暗度 ≥ 85% +│ ├── dark_75/ ← 黑暗度 ≥ 75% +│ ├── dark_65/ ← 黑暗度 ≥ 65% +│ └── dark_55/ ← 黑暗度 ≥ 55% +├── bright_wallpaper.jpg +└── ... +``` + +## 命令行参数 + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `-source` | string | `.` | 源目录路径,递归扫描该目录下所有图片 | +| `-target` | string | 自动生成 | 目标目录基名,留空则自动使用 `<源目录名>_dark_wallpapers` | +| `-min-width` | int | `1920` | 图片最小宽度(像素),低于此值跳过 | +| `-min-height` | int | `1080` | 图片最小高度(像素),低于此值跳过 | +| `-min-size` | int | `100` | 最小文件大小(KB),小于此值跳过 | +| `-max-size` | int | `100` | 最大文件大小(MB),大于此值跳过(排除异常大文件) | +| `-copy` | bool | `false` | 复制模式,设为 true 则复制原文件而非移动 | +| `-parallel` | int | `24` | 并发分析线程数,根据 CPU 核心数调整 | +| `-verbose` | bool | `true` | 详细输出,显示每个文件的处理过程 | +| `-sleep` | int | `5` | 执行前等待秒数,设为 0 则立即执行。等待期间可按 Ctrl+C 取消 | + +## 使用示例 + +### 基础用法 + +```bash +# 扫描当前目录,移动暗色壁纸到子目录(默认行为) +./kenaito-dark-wallpaper-finder + +# 扫描指定目录 +./kenaito-dark-wallpaper-finder -source ~/Downloads/wallpapers + +# 复制模式(保留原文件不动) +./kenaito-dark-wallpaper-finder -source ~/Pictures -copy + +# 跳过等待,直接执行 +./kenaito-dark-wallpaper-finder -sleep 0 +``` + +### 自定义筛选条件 + +```bash +# 只要 4K 壁纸(3840x2160 以上) +./kenaito-dark-wallpaper-finder -min-width 3840 -min-height 2160 + +# 只处理 10MB 以上的大图 +./kenaito-dark-wallpaper-finder -min-size 10240 + +# 只处理 500KB ~ 5MB 之间的图片 +./kenaito-dark-wallpaper-finder -min-size 500 -max-size 5 +``` + +### 性能调优 + +```bash +# 低配机器,减少并发数 +./kenaito-dark-wallpaper-finder -parallel 4 + +# 高配机器,关闭详细输出加速 +./kenaito-dark-wallpaper-finder -parallel 48 -verbose=false +``` + +### 组合使用 + +```bash +# 扫描外接硬盘上的壁纸,复制模式,4K 以上,不等待,静默执行 +./kenaito-dark-wallpaper-finder \ + -source /Volumes/ExternalHD/Wallpapers \ + -target ~/Pictures/DarkWallpapers \ + -copy \ + -min-width 3840 \ + -min-height 2160 \ + -sleep 0 \ + -verbose=false +``` + +## 工作原理 + +1. **扫描阶段**:递归遍历源目录,识别支持格式的图片文件,按分辨率和文件大小初步筛选 +2. **分析阶段**:多线程解码图片,对大图自动采样(>100万像素时按 sqrt(N/10万) 步长采样),通过 ITU-R BT.601 亮度公式计算黑暗评分 +3. **分类阶段**:按五个阈值等级(95%、85%、75%、65%、55%)筛选,移动到对应子目录。每个文件只归入其最高匹配的等级目录 + +### 黑暗评分算法 + +``` +brightness = 0.299×R + 0.587×G + 0.114×B +darkScore = (1 - brightness/255) ^ 0.6 +``` + +- 纯黑像素(RGB < 40)额外加成 ×1.3 +- 鲜艳像素(任一通道 > 200)惩罚 ×0.7 + +## 注意事项 + +- 默认使用**移动**模式,原文件会从源目录移走。首次使用建议加 `-copy` 试运行 +- 目标目录会被自动跳过,不会重复处理之前已整理的文件 +- 文件名冲突时自动追加序号 `_(1)`、`_(2)` 等 +- 支持的图片格式依赖 Go 标准库 + `golang.org/x/image`:JPG、PNG、GIF、BMP、WebP、TIFF + +## License + +MIT diff --git a/doc/QQ20251221-093651.png b/doc/QQ20251221-093651.png deleted file mode 100644 index 65454fe..0000000 Binary files a/doc/QQ20251221-093651.png and /dev/null differ diff --git a/doc/QQ20251221-100004.png b/doc/QQ20251221-100004.png deleted file mode 100644 index 2f8b4f4..0000000 Binary files a/doc/QQ20251221-100004.png and /dev/null differ diff --git a/go.mod b/go.mod index e16e641..729d663 100644 --- a/go.mod +++ b/go.mod @@ -2,14 +2,4 @@ module kenaito-media-group-tool go 1.21 -require ( - github.com/gabriel-vasile/mimetype v1.4.3 - github.com/redmask-hb/GoSimplePrint v0.0.0-20210302075413-3a3af92bcb7d - github.com/satori/go.uuid v1.2.0 - golang.org/x/image v0.16.0 -) - -require ( - golang.org/x/net v0.17.0 // indirect - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect -) +require golang.org/x/image v0.16.0 diff --git a/go.sum b/go.sum index f923da1..c1e754b 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,2 @@ -github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= -github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/redmask-hb/GoSimplePrint v0.0.0-20210302075413-3a3af92bcb7d h1:h/hohIqMUCML2Rp9BXXAu0I3ZR68d7eqMHLPNq7N2tg= -github.com/redmask-hb/GoSimplePrint v0.0.0-20210302075413-3a3af92bcb7d/go.mod h1:LiYo3EFlYfk46Re4zgQysMo6yO3/kAXslB2fyMdl+uw= -github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= -github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +golang.org/x/image v0.16.0 h1:9kloLAKhUufZhA12l5fwnx2NZW39/we1UhBesW433jw= golang.org/x/image v0.16.0/go.mod h1:ugSZItdV4nOxyqp56HmXwH0Ry0nBCpjnZdpDaIHdoPs= -golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= -golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/main.go b/main.go index 1114fe4..b52bc0d 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "flag" "fmt" "image" "image/color" @@ -13,13 +14,16 @@ import ( "os" "path/filepath" "sort" - "strconv" "strings" "sync" "time" + + _ "golang.org/x/image/bmp" + _ "golang.org/x/image/tiff" + _ "golang.org/x/image/webp" ) -// 支持的图片格式 +// supportedExts 支持的图片格式 var supportedExts = map[string]bool{ ".jpg": true, ".jpeg": true, ".png": true, ".bmp": true, ".webp": true, ".gif": true, @@ -38,37 +42,142 @@ type WallpaperInfo struct { // Config 配置结构 type Config struct { - SourceDir string // 源目录,如果是当前目录,设置为 "." - TargetDir string // 移动或复制到的目标目录,不建议设置为 ".",因为那不是脱了裤子放屁吗 - DarkThreshold float64 // 黑暗程度评分(0-1,越高越暗) - MinWidth int // 图片最小宽度 - MinHeight int // 图片最大宽度 - MinSizeKB int64 // 文件大小最小值 - MaxSizeMB int64 // 文件大小最大值 - CopyMode bool // true=复制, false=移动 - Parallel int // 并发数 - Verbose bool // 是否打印日志 + SourceDir string // 源目录 + TargetDir string // 目标目录基名(自动按黑暗等级创建子目录) + MinWidth int // 图片最小宽度 + MinHeight int // 图片最小高度 + MinSizeKB int64 // 文件大小最小值(KB) + MaxSizeMB int64 // 文件大小最大值(MB) + CopyMode bool // true=复制, false=移动 + Parallel int // 并发数 + Verbose bool // 是否打印详细日志 + SleepSeconds int // 执行前等待秒数(0=不等待) } -// 默认配置 +// DefaultConfig 默认配置 func DefaultConfig() Config { return Config{ - SourceDir: ".", - TargetDir: "./dark_wallpapers", - DarkThreshold: 0.75, - MinWidth: 1920, - MinHeight: 1080, - MinSizeKB: 100, // 最小100KB - MaxSizeMB: 100, // 最大100MB - CopyMode: false, // 默认移动 - Parallel: 24, // i5-12490f配置 - Verbose: true, + SourceDir: ".", + MinWidth: 1920, + MinHeight: 1080, + MinSizeKB: 100, + MaxSizeMB: 100, + CopyMode: false, + Parallel: 24, + Verbose: true, + SleepSeconds: 5, } } -// 处理单个图片文件 -func processImageFile(path string, config Config) (*WallpaperInfo, error) { - // 检查文件大小 +func main() { + config := DefaultConfig() + + // 命令行参数 + flag.StringVar(&config.SourceDir, "source", config.SourceDir, "源目录路径") + flag.StringVar(&config.TargetDir, "target", "", "目标目录基名(留空则自动用源目录名)") + flag.IntVar(&config.MinWidth, "min-width", config.MinWidth, "图片最小宽度(像素)") + flag.IntVar(&config.MinHeight, "min-height", config.MinHeight, "图片最小高度(像素)") + flag.Int64Var(&config.MinSizeKB, "min-size", config.MinSizeKB, "最小文件大小(KB)") + flag.Int64Var(&config.MaxSizeMB, "max-size", config.MaxSizeMB, "最大文件大小(MB)") + flag.BoolVar(&config.CopyMode, "copy", config.CopyMode, "复制模式(默认移动)") + flag.IntVar(&config.Parallel, "parallel", config.Parallel, "并发数") + flag.BoolVar(&config.Verbose, "verbose", config.Verbose, "详细输出") + flag.IntVar(&config.SleepSeconds, "sleep", config.SleepSeconds, "执行前等待秒数(0=不等待)") + flag.Parse() + + // 检查源目录 + if _, err := os.Stat(config.SourceDir); os.IsNotExist(err) { + log.Fatalf("源目录不存在: %s", config.SourceDir) + } + + absSource, _ := filepath.Abs(config.SourceDir) + baseDirName := filepath.Base(absSource) + + // 如果目标目录为空,自动生成 + if config.TargetDir == "" { + config.TargetDir = filepath.Join(absSource, baseDirName+"_dark_wallpapers") + } else { + if !filepath.IsAbs(config.TargetDir) { + config.TargetDir = filepath.Join(absSource, config.TargetDir) + } + } + + fmt.Println("🖤 黑色壁纸查找与移动工具") + fmt.Println(strings.Repeat("=", 50)) + fmt.Printf(" 源目录: %s\n", config.SourceDir) + fmt.Printf(" 目标目录: %s(按黑暗等级分子目录)\n", config.TargetDir) + fmt.Printf(" 最小分辨率: %dx%d\n", config.MinWidth, config.MinHeight) + fmt.Printf(" 文件大小: %dKB ~ %dMB\n", config.MinSizeKB, config.MaxSizeMB) + fmt.Printf(" 模式: %s\n", map[bool]string{true: "复制", false: "移动"}[config.CopyMode]) + fmt.Println(strings.Repeat("=", 50)) + + if config.SleepSeconds > 0 { + fmt.Printf("\n⏳ 程序将在 %d 秒后开始执行(Ctrl+C 取消)...\n", config.SleepSeconds) + time.Sleep(time.Duration(config.SleepSeconds) * time.Second) + } + + // 黑暗阈值等级(从高到低) + thresholds := []int{95, 85, 75, 65, 55} + + // 第一步:一次性扫描所有图片并分析 + fmt.Println("\n🔍 开始扫描图片...") + allWallpapers, err := scanAllImages(config) + if err != nil { + log.Fatalf("扫描失败: %v", err) + } + + fmt.Printf("\n✅ 扫描完成!共 %d 张图片满足分辨率和大小要求\n", len(allWallpapers)) + + if len(allWallpapers) == 0 { + fmt.Println("❌ 未找到符合条件的图片") + return + } + + // 第二步:按黑暗等级分类并移动到不同子目录(从高到低,每张图片只归入一个等级) + for _, threshold := range thresholds { + minScore := float64(threshold) / 100.0 + targetDir := filepath.Join(config.TargetDir, fmt.Sprintf("dark_%d", threshold)) + + fmt.Printf("\n%s\n", strings.Repeat("=", 60)) + fmt.Printf("📁 处理黑暗等级 ≥ %d%% 的图片\n", threshold) + fmt.Printf(" 目标目录: %s\n", targetDir) + fmt.Println(strings.Repeat("=", 60)) + + filtered := filterByDarkness(allWallpapers, minScore) + + if len(filtered) == 0 { + fmt.Printf(" ⚠️ 没有符合黑暗等级 ≥ %d%% 的图片\n", threshold) + continue + } + + fmt.Printf(" 找到 %d 张符合条件的图片\n", len(filtered)) + + movedCount, moveErr := moveWallpapers(filtered, targetDir, config.CopyMode) + if moveErr != nil { + log.Printf("⚠️ 移动部分文件时出错: %v", moveErr) + } + + fmt.Printf(" ✓ 成功移动/复制 %d 张图片\n", movedCount) + + // 从待处理列表中移除本轮已处理的图片,避免后续低阈值重复处理 + processed := make(map[string]struct{}, len(filtered)) + for _, wp := range filtered { + processed[wp.Path] = struct{}{} + } + remaining := make([]WallpaperInfo, 0, len(allWallpapers)) + for _, wp := range allWallpapers { + if _, ok := processed[wp.Path]; !ok { + remaining = append(remaining, wp) + } + } + allWallpapers = remaining + } + + fmt.Printf("\n🎉 所有操作完成!文件已整理到: %s/\n", config.TargetDir) +} + +// analyzeImageFile 分析单个图片的黑暗程度(不做阈值过滤) +func analyzeImageFile(path string, config Config) (*WallpaperInfo, error) { fileInfo, err := os.Stat(path) if err != nil { return nil, err @@ -83,32 +192,23 @@ func processImageFile(path string, config Config) (*WallpaperInfo, error) { return nil, fmt.Errorf("文件太大: %dMB > %dMB", fileInfo.Size()/1024/1024, config.MaxSizeMB) } - // 打开图片文件 file, err := os.Open(path) if err != nil { return nil, err } defer file.Close() - // 解码图片 img, _, err := image.Decode(file) if err != nil { return nil, err } - // 分析图片 darkScore, avgColor, width, height := analyzeImage(img) - // 检查分辨率 if width < config.MinWidth || height < config.MinHeight { return nil, fmt.Errorf("分辨率不足: %dx%d < %dx%d", width, height, config.MinWidth, config.MinHeight) } - // 检查黑暗程度 - if darkScore < config.DarkThreshold { - return nil, fmt.Errorf("不够黑暗: %.2f < %.2f", darkScore, config.DarkThreshold) - } - return &WallpaperInfo{ Path: path, DarkScore: darkScore, @@ -119,70 +219,209 @@ func processImageFile(path string, config Config) (*WallpaperInfo, error) { }, nil } -// 移动或复制文件到目标目录 -func moveOrCopyFile(wp *WallpaperInfo, config Config) error { - // 创建目标目录 - err := os.MkdirAll(config.TargetDir, 0755) - if err != nil { +// analyzeImage 分析图片的黑暗程度和尺寸 +// 使用暗色像素占比(亮度 < 80 的像素比例)作为评分,比平均亮度更能准确反映暗色背景壁纸 +func analyzeImage(img image.Image) (darkScore float64, avgColor color.RGBA, width, height int) { + bounds := img.Bounds() + width = bounds.Dx() + height = bounds.Dy() + totalPixels := width * height + + if totalPixels == 0 { + return 0, color.RGBA{0, 0, 0, 255}, width, height + } + + var sumR, sumG, sumB uint64 + darkCount := 0 + sampleCount := 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 { + r, g, b, _ := img.At(x, y).RGBA() + r8 := uint8(r >> 8) + g8 := uint8(g >> 8) + b8 := uint8(b >> 8) + sumR += uint64(r8) + sumG += uint64(g8) + sumB += uint64(b8) + + // 亮度计算(ITU-R BT.601),判断是否为暗色像素 + brightness := 0.299*float64(r8) + 0.587*float64(g8) + 0.114*float64(b8) + if brightness < 80 { + darkCount++ + } + sampleCount++ + } + } + + if sampleCount == 0 { + return 0, color.RGBA{0, 0, 0, 255}, width, height + } + + // 暗色像素占比作为黑暗评分(0-1),比平均亮度更准确: + // 一张 80% 黑色背景 + 20% 明亮主体的壁纸,评分 0.80,正确反映"暗色背景"特性 + darkScore = float64(darkCount) / float64(sampleCount) + + avgR := uint8(sumR / uint64(sampleCount)) + avgG := uint8(sumG / uint64(sampleCount)) + avgB := uint8(sumB / uint64(sampleCount)) + + return darkScore, color.RGBA{avgR, avgG, avgB, 255}, width, height +} + +// scanAllImages 一次性扫描所有图片并分析黑暗程度 +func scanAllImages(config Config) ([]WallpaperInfo, error) { + var allWallpapers []WallpaperInfo + var mu sync.Mutex + + jobs := make(chan string, 100) + results := make(chan *WallpaperInfo, 100) + + // 预计算目标目录的绝对路径,遍历时跳过 + absTarget, _ := filepath.Abs(config.TargetDir) + + // 启动 worker 池 + var workerWg sync.WaitGroup + for i := 0; i < config.Parallel; i++ { + workerWg.Add(1) + go func(workerID int) { + defer workerWg.Done() + for path := range jobs { + if config.Verbose { + fmt.Printf("Worker %d 处理: %s\n", workerID, filepath.Base(path)) + } + wp, err := analyzeImageFile(path, config) + if err != nil { + if config.Verbose { + fmt.Printf("跳过 %s: %v\n", filepath.Base(path), err) + } + continue + } + results <- wp + } + }(i) + } + + // 结果收集 goroutine(用 WaitGroup 保证完成) + var resultWg sync.WaitGroup + resultWg.Add(1) + go func() { + defer resultWg.Done() + for wp := range results { + mu.Lock() + allWallpapers = append(allWallpapers, *wp) + mu.Unlock() + } + }() + + // 遍历源目录,发送任务 + var processedCount int + err := filepath.WalkDir(config.SourceDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + // 跳过目标目录(避免重复处理之前的结果) + if absTarget != "" { + absPath, _ := filepath.Abs(path) + if absPath == absTarget || strings.HasPrefix(absPath, absTarget+string(os.PathSeparator)) { + return filepath.SkipDir + } + } + return nil + } + ext := strings.ToLower(filepath.Ext(path)) + if supportedExts[ext] { + processedCount++ + jobs <- path + } + return nil + }) + + close(jobs) + workerWg.Wait() + close(results) + resultWg.Wait() // 等待结果收集完成后再排序 + + // 按黑暗程度排序(从高到低) + sort.Slice(allWallpapers, func(i, j int) bool { + return allWallpapers[i].DarkScore > allWallpapers[j].DarkScore + }) + + fmt.Printf(" 扫描文件总数: %d\n", processedCount) + fmt.Printf(" 符合分辨率/大小要求: %d\n", len(allWallpapers)) + + return allWallpapers, err +} + +// filterByDarkness 根据黑暗阈值筛选图片 +func filterByDarkness(wallpapers []WallpaperInfo, minDarkScore float64) []WallpaperInfo { + var filtered []WallpaperInfo + for _, wp := range wallpapers { + if wp.DarkScore >= minDarkScore { + filtered = append(filtered, wp) + } + } + return filtered +} + +// cleanFileName 清理文件名中的特殊字符 +func cleanFileName(name string) string { + replacements := map[string]string{ + " ": "_", "/": "_", "\\": "_", ":": "_", + "*": "", "?": "", "\"": "", "<": "", ">": "", "|": "", + } + result := name + for old, n := range replacements { + result = strings.ReplaceAll(result, old, n) + } + // 按 rune 截断,避免截断多字节 UTF-8 字符 + runes := []rune(result) + if len(runes) > 100 { + result = string(runes[:100]) + } + return result +} + +// moveOrCopyFile 移动或复制文件到目标目录 +func moveOrCopyFile(wp *WallpaperInfo, targetDir string, copyMode bool) error { + if err := os.MkdirAll(targetDir, 0755); err != nil { return err } - // 生成目标文件名(保留原文件名,添加黑暗评分) ext := filepath.Ext(wp.Path) baseName := strings.TrimSuffix(filepath.Base(wp.Path), ext) - - // 清理文件名中的特殊字符 cleanName := cleanFileName(baseName) - // 添加分辨率信息 newFilename := fmt.Sprintf("%s_[%dx%d]_dark%.0f%s", cleanName, wp.Width, wp.Height, wp.DarkScore*100, ext) - dstPath := filepath.Join(config.TargetDir, newFilename) + dstPath := filepath.Join(targetDir, newFilename) - // 检查目标文件是否已存在 + // 文件名冲突:追加序号 if _, err := os.Stat(dstPath); err == nil { - // 文件已存在,添加序号 for i := 1; i < 100; i++ { newFilename = fmt.Sprintf("%s_[%dx%d]_dark%.0f_(%d)%s", cleanName, wp.Width, wp.Height, wp.DarkScore*100, i, ext) - dstPath = filepath.Join(config.TargetDir, newFilename) + dstPath = filepath.Join(targetDir, newFilename) if _, err := os.Stat(dstPath); os.IsNotExist(err) { break } } } - if config.CopyMode { - // 复制文件 + if copyMode { return copyFile(wp.Path, dstPath) - } else { - // 移动文件 - return moveFile(wp.Path, dstPath) } + return moveFile(wp.Path, dstPath) } -// 清理文件名 -func cleanFileName(name string) string { - // 替换或移除特殊字符 - replacements := map[string]string{ - " ": "_", "/": "_", "\\": "_", ":": "_", - "*": "", "?": "", "\"": "", "<": "", ">": "", "|": "", - } - - result := name - for old, n := range replacements { - result = strings.ReplaceAll(result, old, n) - } - - // 限制长度 - if len(result) > 100 { - result = result[:100] - } - - return result -} - -// 复制文件 +// copyFile 复制文件 func copyFile(src, dst string) error { srcFile, err := os.Open(src) if err != nil { @@ -200,432 +439,24 @@ func copyFile(src, dst string) error { return err } -// 移动文件 +// moveFile 移动文件(优先 rename,跨分区时复制后删除) func moveFile(src, dst string) error { - // 先尝试重命名(如果在同一分区) - err := os.Rename(src, dst) - if err == nil { + if err := os.Rename(src, dst); err == nil { return nil } - - // 如果重命名失败,则复制后删除 if err := copyFile(src, dst); err != nil { return err } - - // 删除原文件 return os.Remove(src) } -// 显示结果摘要 -func printSummary(wallpapers []WallpaperInfo, config Config) { - if len(wallpapers) == 0 { - fmt.Println("❌ 未找到符合条件的黑色壁纸") - return - } - - fmt.Println("\n🎯 找到的黑色调壁纸:") - fmt.Println(strings.Repeat("=", 60)) - - for i, wp := range wallpapers { - if i >= 20 { // 最多显示20个 - fmt.Printf("... 还有 %d 个未显示\n", len(wallpapers)-20) - break - } - - action := "移动到" - if config.CopyMode { - action = "复制到" - } - - fmt.Printf("%2d. %s\n", i+1, filepath.Base(wp.Path)) - fmt.Printf(" 原路径: %s\n", wp.Path) - fmt.Printf(" %s: %s\n", action, config.TargetDir) - fmt.Printf(" 黑暗程度: %.1f%%\n", wp.DarkScore*100) - fmt.Printf(" 平均颜色: RGB(%3d, %3d, %3d)\n", wp.AvgColor.R, wp.AvgColor.G, wp.AvgColor.B) - fmt.Printf(" 分辨率: %d × %d\n", wp.Width, wp.Height) - fmt.Printf(" 文件大小: %.1f MB\n", float64(wp.Size)/1024/1024) - - // 黑暗程度可视化 - bars := int(wp.DarkScore * 20) - fmt.Printf(" [") - for j := 0; j < 20; j++ { - if j < bars { - fmt.Printf("█") - } else { - fmt.Printf(" ") - } - } - fmt.Printf("]\n\n") - } -} - -// 保存结果到日志文件 -func saveResultsToLog(wallpapers []WallpaperInfo, config Config) { - logFile := filepath.Join(config.TargetDir, "dark_wallpapers_log.txt") - file, err := os.Create(logFile) - if err != nil { - log.Printf("无法创建日志文件: %v", err) - return - } - defer file.Close() - - fmt.Fprintf(file, "黑色壁纸处理日志 - %s\n", time.Now().Format("2006-01-02 15:04:05")) - fmt.Fprintf(file, "源目录: %s\n", config.SourceDir) - fmt.Fprintf(file, "目标目录: %s\n", config.TargetDir) - fmt.Fprintf(file, "黑暗阈值: %.2f\n", config.DarkThreshold) - fmt.Fprintf(file, "找到壁纸数量: %d\n\n", len(wallpapers)) - - for i, wp := range wallpapers { - fmt.Fprintf(file, "%d. %s\n", i+1, filepath.Base(wp.Path)) - fmt.Fprintf(file, " 黑暗程度: %.1f%%\n", wp.DarkScore*100) - fmt.Fprintf(file, " 分辨率: %dx%d\n", wp.Width, wp.Height) - fmt.Fprintf(file, " 大小: %.2f MB\n", float64(wp.Size)/1024/1024) - fmt.Fprintf(file, " 原路径: %s\n", wp.Path) - fmt.Fprintf(file, " 目标: %s/%s\n\n", config.TargetDir, filepath.Base(wp.Path)) - } - - fmt.Printf("📝 详细日志已保存到: %s\n", logFile) -} - -// 分析图片的黑暗程度和尺寸 -func analyzeImage(img image.Image) (darkScore float64, avgColor color.RGBA, width, height int) { - bounds := img.Bounds() - width = bounds.Dx() - height = bounds.Dy() - totalPixels := width * height - - if totalPixels == 0 { - return 0, color.RGBA{0, 0, 0, 255}, width, height - } - - var sumR, sumG, sumB uint64 - - // 采样分析(对于大图片进行采样以提高性能) - sampleStep := 1 - if totalPixels > 1000000 { // 超过100万像素则采样 - sampleStep = int(math.Sqrt(float64(totalPixels) / 100000)) - } - - sampleCount := 0 - for y := bounds.Min.Y; y < bounds.Max.Y; y += sampleStep { - for x := bounds.Min.X; x < bounds.Max.X; x += sampleStep { - r, g, b, _ := img.At(x, y).RGBA() - sumR += uint64(r >> 8) - sumG += uint64(g >> 8) - sumB += uint64(b >> 8) - sampleCount++ - } - } - - if sampleCount == 0 { - return 0, color.RGBA{0, 0, 0, 255}, width, height - } - - avgR := uint8(sumR / uint64(sampleCount)) - avgG := uint8(sumG / uint64(sampleCount)) - avgB := uint8(sumB / uint64(sampleCount)) - - // 计算黑暗程度评分 - brightness := 0.299*float64(avgR) + 0.587*float64(avgG) + 0.114*float64(avgB) - darkScore = 1.0 - (brightness / 255.0) - - // 增强黑色权重 - darkScore = math.Pow(darkScore, 0.6) - - // 如果平均颜色值很低,进一步提高评分 - if avgR < 40 && avgG < 40 && avgB < 40 { - darkScore = math.Min(1.0, darkScore*1.3) - } - - // 惩罚太鲜艳的颜色 - if avgR > 200 || avgG > 200 || avgB > 200 { - darkScore *= 0.7 - } - - return darkScore, color.RGBA{avgR, avgG, avgB, 255}, width, height -} - -// findAndMoveDarkWallpapers 查找并移动黑色壁纸(单次扫描版本) -func findAndMoveDarkWallpapers(config Config) ([]WallpaperInfo, error) { - var darkWallpapers []WallpaperInfo - var mu sync.Mutex - var wg sync.WaitGroup - - // 创建worker池 - jobs := make(chan string, 100) - results := make(chan *WallpaperInfo, 100) - errors := make(chan error, 100) - - // 启动worker - for i := 0; i < config.Parallel; i++ { - wg.Add(1) - go func(workerID int) { - defer wg.Done() - for path := range jobs { - if config.Verbose { - fmt.Printf("Worker %d 处理: %s\n", workerID, filepath.Base(path)) - } - - wp, err := processImageFile(path, config) - if err != nil { - if config.Verbose { - fmt.Printf("跳过 %s: %v\n", filepath.Base(path), err) - } - continue - } - - results <- wp - } - }(i) - } - - // 收集结果并移动文件 - var processedCount int - var movedCount int - go func() { - for wp := range results { - mu.Lock() - darkWallpapers = append(darkWallpapers, *wp) - mu.Unlock() - - // 移动文件 - if err := moveOrCopyFile(wp, config); err != nil { - errors <- fmt.Errorf("移动文件失败 %s: %v", wp.Path, err) - continue - } - - movedCount++ - if config.Verbose { - fmt.Printf("✓ 已处理: %s (黑暗度: %.1f%%)\n", - filepath.Base(wp.Path), wp.DarkScore*100) - } - } - }() - - // 收集错误 - var processErrors []error - go func() { - for err := range errors { - processErrors = append(processErrors, err) - } - }() - - // 遍历源目录,发送任务 - err := filepath.WalkDir(config.SourceDir, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return nil - } - - if d.IsDir() { - // 跳过目标目录 - if filepath.Clean(path) == filepath.Clean(config.TargetDir) { - return filepath.SkipDir - } - return nil - } - - ext := strings.ToLower(filepath.Ext(path)) - if supportedExts[ext] { - processedCount++ - jobs <- path - } - - return nil - }) - - close(jobs) - wg.Wait() - close(results) - close(errors) - - // 按黑暗程度排序 - sort.Slice(darkWallpapers, func(i, j int) bool { - return darkWallpapers[i].DarkScore > darkWallpapers[j].DarkScore - }) - - // 打印统计信息 - fmt.Printf("\n📊 处理完成统计:\n") - fmt.Printf(" 扫描文件总数: %d\n", processedCount) - fmt.Printf(" 找到黑色壁纸: %d\n", len(darkWallpapers)) - fmt.Printf(" 成功移动/复制: %d\n", movedCount) - if len(processErrors) > 0 { - fmt.Printf(" 错误数量: %d\n", len(processErrors)) - } - - if len(processErrors) > 0 && config.Verbose { - fmt.Println("\n⚠️ 错误列表:") - for _, err := range processErrors { - fmt.Printf(" %v\n", err) - } - } - - return darkWallpapers, err -} - -func main() { - // 配置 - config := DefaultConfig() - config.SourceDir = "." - - // 检查源目录是否存在 - if _, err := os.Stat(config.SourceDir); os.IsNotExist(err) { - log.Fatalf("源目录不存在: %s", config.SourceDir) - return - } - - rootDir, _ := os.Getwd() - baseDirName := filepath.Base(rootDir) - - fmt.Println("🖤 黑色壁纸查找与移动工具") - fmt.Println(strings.Repeat("=", 40)) - fmt.Println("整理完成的文件在上一级目录中,请勿整理磁盘根目录文件,若无疑问,程序将在 5s 后执行") - fmt.Println(strings.Repeat("=", 40)) - time.Sleep(time.Second * 5) - - // 定义多个黑暗阈值等级 - targetDarkThresholds := []int{95, 85, 75, 65, 55} - - // 第一步:一次性扫描所有图片,分析黑暗程度 - fmt.Println("\n🔍 开始扫描图片...") - allWallpapers, err := scanAllImages(config) - if err != nil { - log.Fatalf("扫描失败: %v", err) - } - - fmt.Printf("\n✅ 扫描完成!共找到 %d 张符合条件的图片\n", len(allWallpapers)) - - // 第二步:根据黑暗等级分类并移动到不同目录 - for _, threshold := range targetDarkThresholds { - darkThreshold := float64(threshold) / 100.0 // 转换为小数形式(如 0.95) - targetDir := "../" + baseDirName + "_dark_wallpapers_" + strconv.Itoa(threshold) - - fmt.Printf("\n" + strings.Repeat("=", 60) + "\n") - fmt.Printf("📁 处理黑暗等级 ≥ %d%% 的图片\n", threshold) - fmt.Printf(" 目标目录: %s\n", targetDir) - fmt.Println(strings.Repeat("=", 60)) - - // 筛选出符合当前黑暗阈值的图片 - filteredWallpapers := filterWallpapersByDarkness(allWallpapers, darkThreshold) - - if len(filteredWallpapers) == 0 { - fmt.Printf(" ⚠️ 没有符合黑暗等级 ≥ %d%% 的图片\n", threshold) - continue - } - - fmt.Printf(" 找到 %d 张符合条件的图片\n", len(filteredWallpapers)) - - // 移动这些图片到对应目录 - movedCount, err := moveWallpapersToTarget(filteredWallpapers, targetDir, config.CopyMode) - if err != nil { - log.Printf("⚠️ 移动部分文件时出错: %v", err) - } - - fmt.Printf(" ✓ 成功移动/复制 %d 张图片\n", movedCount) - } - - fmt.Println("\n🎉 所有操作完成!") -} - -// scanAllImages 一次性扫描所有图片并分析黑暗程度 -func scanAllImages(config Config) ([]WallpaperInfo, error) { - var allWallpapers []WallpaperInfo - var mu sync.Mutex - var wg sync.WaitGroup - - // 创建worker池 - jobs := make(chan string, 100) - results := make(chan *WallpaperInfo, 100) - - // 启动worker - for i := 0; i < config.Parallel; i++ { - wg.Add(1) - go func(workerID int) { - defer wg.Done() - for path := range jobs { - if config.Verbose { - fmt.Printf("Worker %d 处理: %s\n", workerID, filepath.Base(path)) - } - - wp, err := processImageFile(path, config) - if err != nil { - // 静默跳过不符合条件的图片 - continue - } - - results <- wp - } - }(i) - } - - // 收集结果 - go func() { - for wp := range results { - mu.Lock() - allWallpapers = append(allWallpapers, *wp) - mu.Unlock() - } - }() - - // 遍历源目录,发送任务 - var processedCount int - err := filepath.WalkDir(config.SourceDir, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return nil - } - - if d.IsDir() { - return nil - } - - ext := strings.ToLower(filepath.Ext(path)) - if supportedExts[ext] { - processedCount++ - jobs <- path - } - - return nil - }) - - close(jobs) - wg.Wait() - close(results) - - // 按黑暗程度排序(从高到低) - sort.Slice(allWallpapers, func(i, j int) bool { - return allWallpapers[i].DarkScore > allWallpapers[j].DarkScore - }) - - fmt.Printf(" 扫描文件总数: %d\n", processedCount) - fmt.Printf(" 符合条件的图片: %d\n", len(allWallpapers)) - - return allWallpapers, err -} - -// filterWallpapersByDarkness 根据黑暗阈值筛选图片 -func filterWallpapersByDarkness(wallpapers []WallpaperInfo, minDarkScore float64) []WallpaperInfo { - var filtered []WallpaperInfo - for _, wp := range wallpapers { - if wp.DarkScore >= minDarkScore { - filtered = append(filtered, wp) - } - } - return filtered -} - -// moveWallpapersToTarget 将图片移动到指定目标目录 -func moveWallpapersToTarget(wallpapers []WallpaperInfo, targetDir string, copyMode bool) (int, error) { +// moveWallpapers 将图片列表移动到指定目标目录 +func moveWallpapers(wallpapers []WallpaperInfo, targetDir string, copyMode bool) (int, error) { movedCount := 0 var firstErr error for _, wp := range wallpapers { - // 创建临时配置用于移动文件 - tempConfig := Config{ - TargetDir: targetDir, - CopyMode: copyMode, - } - - if err := moveOrCopyFile(&wp, tempConfig); err != nil { + if err := moveOrCopyFile(&wp, targetDir, copyMode); err != nil { if firstErr == nil { firstErr = err }