diff --git a/README.md b/README.md index 4fe43df..ac9f692 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,28 @@ -# Kenaito Wallpaper Organizer 🖼️ +# Kenaito Dark Wallpaper Finder 🖤 -一个自动扫描、分析并归类壁纸的命令行工具。它会递归扫描指定目录下的所有图片,分析每张图的**明暗程度**,并按**明暗等级 → 方向 → 分辨率**进行分类归档(移动或复制)。 +一个自动扫描、分析并整理暗色系壁纸的命令行工具。 + +程序会递归扫描指定目录下的所有图片,根据亮度算法计算每张图片的"黑暗程度"评分,然后按评分等级自动分类移动(或复制)到不同子目录中。 ## 功能特性 -- **智能明暗分析**:基于 ITU-R BT.601 亮度公式,同时统计暗色像素(亮度 < 80)与亮色像素(亮度 > 175)占比 -- **多级明暗分类**:暗色 `dark_100/95/85/75/65/55` 与亮色 `bright_85/75/65/55/45/35/25/15` 多档归档 -- **方向区分**:横屏 `horizontal` / 竖屏 `vertical` / 方形 `square` 子目录 -- **分辨率分档**:按长边区分 1K~8K 子目录 -- **特殊归置**:过小图(`fixed`)、分辨率不足(`other`)、异常文件(`error`) -- **多格式支持**:JPG/JPEG/JFIF/JPE、PNG/APNG、GIF、BMP/DIB、WebP、TIFF/TIF、PSD/PSB -- **并发处理**:默认 24 线程并行分析,大图自动采样加速 -- **UUID 命名**:归档后的文件统一重命名为随机 UUID,不保留原文件名 +- **智能暗色分析**:基于 ITU-R BT.601 亮度公式计算黑暗评分,纯黑加分,鲜艳色惩罚 +- **多格式支持**:JPG、PNG、GIF、BMP、WebP、TIFF +- **五级分类**:按 ≥95%、≥85%、≥75%、≥65%、≥55% 五个黑暗等级自动归档 +- **并发处理**:默认 24 线程并行分析,大图自动采样加速 +- **灵活配置**:所有参数均可通过命令行调整 ## 编译 ```bash # Linux / macOS -go build -o kenaito-wallpaper-organizer main.go +go build -o kenaito-dark-wallpaper-finder main.go # Windows -go build -o kenaito-wallpaper-organizer.exe main.go +go build -o kenaito-dark-wallpaper-finder.exe main.go ``` -或使用构建脚本: +或使用构建脚本: ```bash bash build.sh @@ -34,25 +33,26 @@ bash build.sh ```bash # 在壁纸目录下运行(默认移动模式,等待5秒确认) cd ~/Pictures/wallpapers -./kenaito-wallpaper-organizer +./kenaito-dark-wallpaper-finder # 用复制模式试运行(不会移动原文件) -./kenaito-wallpaper-organizer -copy -sleep 0 +./kenaito-dark-wallpaper-finder -copy -sleep 0 ``` -执行后,源目录下会生成如下结构: +执行后,源目录下会生成如下结构: -├── wallpapers_wallpapers/ -│ ├── dark_100/ -│ │ ├── horizontal/ → 1K/ 2K/ 4K/ … -│ │ ├── vertical/ → 1K/ 2K/ 4K/ … -│ │ └── square/ → 1K/ … -│ ├── bright_85/ # 与 dark_ 同构(15~85) -│ ├── psd/ # PSD/PSB,不做明暗分类 -│ ├── gif/ # GIF,不做明暗分类 -│ ├── fixed/ # 长边 < 1080 的过小图 -│ ├── other/ # 尺寸不足 min-width/min-height -│ └── error/ # 大小超限或解码失败的异常文件 +``` +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 +└── ... ``` ## 命令行参数 @@ -60,59 +60,93 @@ cd ~/Pictures/wallpapers | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `-source` | string | `.` | 源目录路径,递归扫描该目录下所有图片 | -| `-target` | string | 自动生成 | 目标目录基名,留空自动使用 `<源目录名>_wallpapers` | -| `-min-width` | int | `1080` | 图片最小宽度(像素),低于此值进入 other | -| `-min-height` | int | `720` | 图片最小高度(像素),低于此值进入 other | -| `-min-size` | int | `100` | 最小文件大小(KB),小于此值进入 error | -| `-max-size` | int | `100` | 最大文件大小(MB),大于此值进入 error | +| `-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` | 并发分析线程数 | +| `-parallel` | int | `24` | 并发分析线程数,根据 CPU 核心数调整 | | `-verbose` | bool | `true` | 详细输出,显示每个文件的处理过程 | -| `-sleep` | int | `5` | 执行前等待秒数,设为 0 则立即执行,等待期间可按 Ctrl+C 取消 | +| `-sleep` | int | `5` | 执行前等待秒数,设为 0 则立即执行。等待期间可按 Ctrl+C 取消 | ## 使用示例 +### 基础用法 + ```bash +# 扫描当前目录,移动暗色壁纸到子目录(默认行为) +./kenaito-dark-wallpaper-finder + # 扫描指定目录 -./kenaito-wallpaper-organizer -source ~/Downloads/wallpapers +./kenaito-dark-wallpaper-finder -source ~/Downloads/wallpapers # 复制模式(保留原文件不动) -./kenaito-wallpaper-organizer -source ~/Pictures -copy +./kenaito-dark-wallpaper-finder -source ~/Pictures -copy # 跳过等待,直接执行 -./kenaito-wallpaper-organizer -sleep 0 +./kenaito-dark-wallpaper-finder -sleep 0 +``` -# 只要 4K 以上壁纸 -./kenaito-wallpaper-organizer -min-width 3840 -min-height 2160 +### 自定义筛选条件 + +```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-wallpaper-organizer -parallel 48 -verbose=false +./kenaito-dark-wallpaper-finder -parallel 48 -verbose=false ``` -## 分类优先级 +### 组合使用 -1. **psd/gif**:PSD/PSB 归 `psd/`,GIF 归 `gif/`,均不做明暗分类 -2. **fixed**:长边 < 1080 的过小图片 -3. **other**:尺寸达标(≥1080)但低于 `min-width/min-height` 阈值的图片 -4. **dark_N / bright_N**:达标图按主导分判定亮暗——`lightScore > darkScore` 归明亮、否则归暗色,每张图必归亮暗之一(无 neutral) +```bash +# 扫描外接硬盘上的壁纸,复制模式,4K 以上,不等待,静默执行 +./kenaito-dark-wallpaper-finder \ + -source /Volumes/ExternalHD/Wallpapers \ + -target ~/Pictures/DarkWallpapers \ + -copy \ + -min-width 3840 \ + -min-height 2160 \ + -sleep 0 \ + -verbose=false +``` -所有正常分类(dark/bright/other)内部均按 方向 → 分辨率 分子目录。 +## 工作原理 -## 明暗评分算法 +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 (ITU-R BT.601) -darkScore = 亮度 < 80 的像素占比 -lightScore = 亮度 > 175 的像素占比 +brightness = 0.299×R + 0.587×G + 0.114×B +darkScore = (1 - brightness/255) ^ 0.6 ``` +- 纯黑像素(RGB < 40)额外加成 ×1.3 +- 鲜艳像素(任一通道 > 200)惩罚 ×0.7 + ## 注意事项 -- 默认使用**移动**模式,原文件会从源目录移走,首次使用建议加 `-copy` 试运行 -- 目标目录会被自动跳过,不会重复处理之前已整理的文件 -- UUID 命名,极端情况下重名会自动追加 `_(1)`、`_(2)` 等序号 -- 支持的图片格式依赖 Go 标准库 + `golang.org/x/image` + `github.com/oov/psd`:JPG/JPEG/JFIF/JPE、PNG/APNG、GIF、BMP/DIB、WebP、TIFF/TIF、PSD/PSB -- 无后缀名的图片会通过文件头(magic 字节)探测自动识别,不会被静默跳过 +- 默认使用**移动**模式,原文件会从源目录移走。首次使用建议加 `-copy` 试运行 +- 目标目录会被自动跳过,不会重复处理之前已整理的文件 +- 文件名冲突时自动追加序号 `_(1)`、`_(2)` 等 +- 支持的图片格式依赖 Go 标准库 + `golang.org/x/image`:JPG、PNG、GIF、BMP、WebP、TIFF ## License diff --git a/build.sh b/build.sh index 1574050..f0579e3 100644 --- a/build.sh +++ b/build.sh @@ -1,3 +1,3 @@ rm -f kenaito-wallpaper-organizer.exe # build -go build -o kenaito-wallpaper-organizer.exe main.go \ No newline at end of file +go build -o kenaito-wallpaper-organizer.exe main.go diff --git a/go.mod b/go.mod index e879626..767ac3f 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,3 @@ module kenaito-wallpaper-organizer go 1.21 require golang.org/x/image v0.16.0 - -require ( - github.com/gopherjs/gopherjs v1.21.0 // indirect - github.com/oov/psd v0.0.0-20260122084234-c463b6a89e2f // indirect -) diff --git a/go.sum b/go.sum index eec2996..c1e754b 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,2 @@ -github.com/gopherjs/gopherjs v1.21.0 h1:5HEGrz+XhpCchubMGzuyLuGoCTlL/yCT7sGsT5Se/dw= -github.com/gopherjs/gopherjs v1.21.0/go.mod h1:R2HIOen3IzYSzvmvkeD8WOfiLN9wueR/T5Y+6z326Ck= -github.com/oov/psd v0.0.0-20260122084234-c463b6a89e2f h1:67UmABhaufAdSFaRO/wcaTn0XgHWCirjwfFf21ZNPAc= -github.com/oov/psd v0.0.0-20260122084234-c463b6a89e2f/go.mod h1:GHI1bnmAcbp96z6LNfBJvtrjxhaXGkbsk967utPlvL8= golang.org/x/image v0.16.0 h1:9kloLAKhUufZhA12l5fwnx2NZW39/we1UhBesW433jw= golang.org/x/image v0.16.0/go.mod h1:ugSZItdV4nOxyqp56HmXwH0Ry0nBCpjnZdpDaIHdoPs= diff --git a/main.go b/main.go index b70bcf5..b52bc0d 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,6 @@ package main import ( - "crypto/rand" "flag" "fmt" "image" @@ -22,29 +21,23 @@ import ( _ "golang.org/x/image/bmp" _ "golang.org/x/image/tiff" _ "golang.org/x/image/webp" - - _ "github.com/oov/psd" ) // supportedExts 支持的图片格式 var supportedExts = map[string]bool{ - ".jpg": true, ".jpeg": true, ".jfif": true, ".jpe": true, - ".png": true, ".apng": true, - ".bmp": true, ".dib": true, ".webp": true, ".gif": true, + ".jpg": true, ".jpeg": true, ".png": true, + ".bmp": true, ".webp": true, ".gif": true, ".tiff": true, ".tif": true, - ".psd": true, ".psb": true, } // WallpaperInfo 存储壁纸信息 type WallpaperInfo struct { - Path string - DarkScore float64 // 黑暗程度评分(0-1,越低越暗) - LightScore float64 // 亮度程度评分(0-1,越高越亮) - AvgColor color.RGBA - Size int64 - Width int - Height int - Ext string // 检测到的扩展名(无后缀图片用) + Path string + DarkScore float64 // 黑暗程度评分(0-1,越高越暗) + AvgColor color.RGBA + Size int64 + Width int + Height int } // Config 配置结构 @@ -65,9 +58,9 @@ type Config struct { func DefaultConfig() Config { return Config{ SourceDir: ".", - MinWidth: 1080, - MinHeight: 720, - MinSizeKB: 50, + MinWidth: 1920, + MinHeight: 1080, + MinSizeKB: 100, MaxSizeMB: 100, CopyMode: false, Parallel: 24, @@ -102,17 +95,17 @@ func main() { // 如果目标目录为空,自动生成 if config.TargetDir == "" { - config.TargetDir = filepath.Join(absSource, baseDirName+"_wallpapers") + config.TargetDir = filepath.Join(absSource, baseDirName+"_dark_wallpapers") } else { if !filepath.IsAbs(config.TargetDir) { config.TargetDir = filepath.Join(absSource, config.TargetDir) } } - fmt.Println("🖼️ Kenaito Wallpaper Organizer 壁纸整理工具") + fmt.Println("🖤 黑色壁纸查找与移动工具") fmt.Println(strings.Repeat("=", 50)) fmt.Printf(" 源目录: %s\n", config.SourceDir) - fmt.Printf(" 目标目录: %s\n", config.TargetDir) + 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]) @@ -124,9 +117,7 @@ func main() { } // 黑暗阈值等级(从高到低) - darkThresholds := []int{100, 95, 85, 75, 65, 55} - // 亮色阈值等级(从高到低,15~85) - brightThresholds := []int{85, 75, 65, 55, 45, 35, 25, 15} + thresholds := []int{95, 85, 75, 65, 55} // 第一步:一次性扫描所有图片并分析 fmt.Println("\n🔍 开始扫描图片...") @@ -135,153 +126,57 @@ func main() { log.Fatalf("扫描失败: %v", err) } - fmt.Printf("\n✅ 扫描完成!共发现 %d 张图片(分辨率/大小不达标者将放入 other)\n", len(allWallpapers)) + fmt.Printf("\n✅ 扫描完成!共 %d 张图片满足分辨率和大小要求\n", len(allWallpapers)) if len(allWallpapers) == 0 { fmt.Println("❌ 未找到符合条件的图片") return } - // 第二步:分类并移动/复制,每张图片只归入一个目录 - // 优先级:分辨率不达标→ other;达标者按 暗色 → 亮色 → 中性 + // 第二步:按黑暗等级分类并移动到不同子目录(从高到低,每张图片只归入一个等级) + for _, threshold := range thresholds { + minScore := float64(threshold) / 100.0 + targetDir := filepath.Join(config.TargetDir, fmt.Sprintf("dark_%d", threshold)) - // 2.0 过小图片(长边<1080)放入独立的 fixed 目录;其余分辨率不达标的放入 other - conforming := make([]*WallpaperInfo, 0, len(allWallpapers)) - for i := range allWallpapers { - wp := &allWallpapers[i] - // 0.5 PSD/GIF 文件单独归置,不按明暗分类 - if isPSDOrGIF(wp.Path) { - dir := filepath.Join(config.TargetDir, psdGifDirName(wp.Path)) - fmt.Printf("[%s] %s → %s/\n", psdGifDirName(wp.Path), filepath.Base(wp.Path), psdGifDirName(wp.Path)) - if err := moveOrCopyFile(wp, dir, "", "", config.CopyMode); err != nil { - log.Printf("⚠️ 处理 %s 失败 %s: %v", psdGifDirName(wp.Path), wp.Path, err) - } + 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 } - // 过小图片单独归置,不按明暗/分辨率细分 - longEdge := wp.Width - if wp.Height > longEdge { - longEdge = wp.Height - } - if longEdge < 1080 { - fixedDir := filepath.Join(config.TargetDir, "fixed") - fmt.Printf("[fixed] 长边 %d 过小 → fixed/\n", longEdge) - if err := moveOrCopyFile(wp, fixedDir, "", "", config.CopyMode); err != nil { - log.Printf("⚠️ 处理 fixed 失败 %s: %v", wp.Path, err) - } - continue - } - if wp.Width < config.MinWidth || wp.Height < config.MinHeight { - otherDir := filepath.Join(config.TargetDir, "other") - fmt.Printf("[other] 分辨率不足 %dx%d → other/\n", wp.Width, wp.Height) - if err := moveOrCopyFile(wp, otherDir, directionName(wp.Width, wp.Height), resolutionTierName(wp.Width, wp.Height), config.CopyMode); err != nil { - log.Printf("⚠️ 处理 other 失败 %s: %v", wp.Path, err) - } - continue - } - conforming = append(conforming, wp) - } - // 第二步:按"主导分"判定亮/暗(不走多层筛选),每张图必归亮或暗之一 - darkSeconds, brightSeconds := 0, 0 - darkLowest := darkThresholds[len(darkThresholds)-1] - brightLowest := brightThresholds[len(brightThresholds)-1] - for _, wp := range conforming { - direction := directionName(wp.Width, wp.Height) - tier := resolutionTierName(wp.Width, wp.Height) + fmt.Printf(" 找到 %d 张符合条件的图片\n", len(filtered)) - if wp.LightScore > wp.DarkScore { - // 亮色主导:取 lightScore 命中的最高一档,不足最低档则归最低档 - picked := brightLowest - for _, t := range brightThresholds { - if wp.LightScore >= float64(t)/100.0 { - picked = t - break - } - } - targetDir := filepath.Join(config.TargetDir, fmt.Sprintf("bright_%d", picked)) - if err := moveOrCopyFile(wp, targetDir, direction, tier, config.CopyMode); err != nil { - log.Printf("⚠️ 移动失败 %s: %v", wp.Path, err) - } else { - brightSeconds++ - } - } else { - // 暗色主导(含两者皆 0 的中间灰)→ 命中最高 dark 档,不足归最低档 - picked := darkLowest - for _, t := range darkThresholds { - if wp.DarkScore >= float64(t)/100.0 { - picked = t - break - } - } - targetDir := filepath.Join(config.TargetDir, fmt.Sprintf("dark_%d", picked)) - if err := moveOrCopyFile(wp, targetDir, direction, tier, config.CopyMode); err != nil { - log.Printf("⚠️ 移动失败 %s: %v", wp.Path, err) - } else { - darkSeconds++ + 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) } } - } - if brightSeconds > 0 { - fmt.Printf("\n📁 亮色主导 → bright_N:移动 %d 张\n", brightSeconds) - } - if darkSeconds > 0 { - fmt.Printf("\n📁 暗色主导 → dark_N:移动 %d 张\n", darkSeconds) + allWallpapers = remaining } fmt.Printf("\n🎉 所有操作完成!文件已整理到: %s/\n", config.TargetDir) } -// SizeFilterError 表示文件大小不达标(min-size / max-size) -type SizeFilterError struct { - Path string - Reason string -} - -func (e *SizeFilterError) Error() string { return e.Reason } - -// DecodeError 表示文件无法打开或解码(损坏/非图片) -type DecodeError struct { - Path string - Reason string -} - -func (e *DecodeError) Error() string { return e.Reason } - -// moveToErrorDir 将处理异常的文件移动到 target/error 目录 -func moveToErrorDir(path string, config Config, err error) { - errDir := filepath.Join(config.TargetDir, "error") - if err := os.MkdirAll(errDir, 0755); err != nil { - log.Printf("⚠️ 创建 error 目录失败 %s: %v", errDir, err) - return - } - - ext := filepath.Ext(path) - dst := filepath.Join(errDir, newUUID()+ext) - // 文件名冲突:追加序号 - for i := 1; ; i++ { - if _, e := os.Stat(dst); os.IsNotExist(e) { - break - } - dst = filepath.Join(errDir, fmt.Sprintf("%s_(%d)%s", newUUID(), i, ext)) - } - - reason := "unknown" - switch err.(type) { - case *SizeFilterError: - reason = "size" - case *DecodeError: - reason = "decode" - } - - if mErr := moveFile(path, dst); mErr != nil { - log.Printf("⚠️ 移动文件到 error 目录失败 %s: %v", path, mErr) - return - } - fmt.Printf(" → error/%s: %s (%s)\n", reason, filepath.Base(dst), err.Error()) -} - -// analyzeImageFile 分析单个图片的黑暗/亮度程度(不做阈值过滤,返回完整信息) +// analyzeImageFile 分析单个图片的黑暗程度(不做阈值过滤) func analyzeImageFile(path string, config Config) (*WallpaperInfo, error) { fileInfo, err := os.Stat(path) if err != nil { @@ -290,53 +185,54 @@ func analyzeImageFile(path string, config Config) (*WallpaperInfo, error) { sizeKB := fileInfo.Size() / 1024 if sizeKB < config.MinSizeKB { - return nil, &SizeFilterError{Path: path, Reason: fmt.Sprintf("文件太小: %dKB < %dKB", sizeKB, config.MinSizeKB)} + return nil, fmt.Errorf("文件太小: %dKB < %dKB", sizeKB, config.MinSizeKB) } if fileInfo.Size() > config.MaxSizeMB*1024*1024 { - return nil, &SizeFilterError{Path: path, Reason: fmt.Sprintf("文件太大: %dMB > %dMB", fileInfo.Size()/1024/1024, config.MaxSizeMB)} + return nil, fmt.Errorf("文件太大: %dMB > %dMB", fileInfo.Size()/1024/1024, config.MaxSizeMB) } file, err := os.Open(path) if err != nil { - return nil, &DecodeError{Path: path, Reason: err.Error()} + return nil, err } defer file.Close() - img, imageName, err := image.Decode(file) + img, _, err := image.Decode(file) if err != nil { - return nil, &DecodeError{Path: path, Reason: err.Error()} + return nil, err } - darkScore, lightScore, avgColor, width, height := analyzeImage(img) + 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) + } return &WallpaperInfo{ - Path: path, - DarkScore: darkScore, - LightScore: lightScore, - AvgColor: avgColor, - Size: fileInfo.Size(), - Width: width, - Height: height, - Ext: detectExtension(path, imageName), + Path: path, + DarkScore: darkScore, + AvgColor: avgColor, + Size: fileInfo.Size(), + Width: width, + Height: height, }, nil } -// analyzeImage 分析图片的黑暗程度、亮度程度和尺寸 -// darkScore: 暗色像素(亮度 < 80)占比;lightScore: 亮色像素(亮度 > 175)占比 -func analyzeImage(img image.Image) (darkScore float64, lightScore float64, avgColor color.RGBA, width, height int) { +// 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, 0, color.RGBA{0, 0, 0, 255}, width, height + return 0, color.RGBA{0, 0, 0, 255}, width, height } var sumR, sumG, sumB uint64 darkCount := 0 - lightCount := 0 sampleCount := 0 // 大图采样以提高性能 @@ -355,29 +251,28 @@ func analyzeImage(img image.Image) (darkScore float64, lightScore float64, avgCo sumG += uint64(g8) sumB += uint64(b8) - // 亮度计算(ITU-R BT.601) + // 亮度计算(ITU-R BT.601),判断是否为暗色像素 brightness := 0.299*float64(r8) + 0.587*float64(g8) + 0.114*float64(b8) if brightness < 80 { darkCount++ - } else if brightness > 175 { - lightCount++ } sampleCount++ } } if sampleCount == 0 { - return 0, 0, color.RGBA{0, 0, 0, 255}, width, height + return 0, color.RGBA{0, 0, 0, 255}, width, height } + // 暗色像素占比作为黑暗评分(0-1),比平均亮度更准确: + // 一张 80% 黑色背景 + 20% 明亮主体的壁纸,评分 0.80,正确反映"暗色背景"特性 darkScore = float64(darkCount) / float64(sampleCount) - lightScore = float64(lightCount) / float64(sampleCount) avgR := uint8(sumR / uint64(sampleCount)) avgG := uint8(sumG / uint64(sampleCount)) avgB := uint8(sumB / uint64(sampleCount)) - return darkScore, lightScore, color.RGBA{avgR, avgG, avgB, 255}, width, height + return darkScore, color.RGBA{avgR, avgG, avgB, 255}, width, height } // scanAllImages 一次性扫描所有图片并分析黑暗程度 @@ -404,9 +299,8 @@ func scanAllImages(config Config) ([]WallpaperInfo, error) { wp, err := analyzeImageFile(path, config) if err != nil { if config.Verbose { - fmt.Printf("⚠️ 处理异常 %s: %v -> error/\n", filepath.Base(path), err) + fmt.Printf("跳过 %s: %v\n", filepath.Base(path), err) } - moveToErrorDir(path, config, err) continue } results <- wp @@ -443,8 +337,7 @@ func scanAllImages(config Config) ([]WallpaperInfo, error) { return nil } ext := strings.ToLower(filepath.Ext(path)) - // 支持的后缀直接处理;无后缀文件按文件内容探测是否为图片(避免静默跳过) - if supportedExts[ext] || (ext == "" && isImageByContent(path)) { + if supportedExts[ext] { processedCount++ jobs <- path } @@ -467,140 +360,55 @@ func scanAllImages(config Config) ([]WallpaperInfo, error) { return allWallpapers, err } -// resolutionTierName 根据图片较长边返回分辨率档位名称(如 "1K"、"4K"、"8K") -// 长边 < 1080 的过小图片单独归类为 "fixed" -func resolutionTierName(width, height int) string { - longEdge := width - if height > longEdge { - longEdge = height - } - // 过小图片单独归类 - if longEdge < 1080 { - return "fixed" - } - // 以标准横向分辨率作为各档位下限,取长边命中的最高档 - tiers := []struct { - name string - edge int - }{ - {"8K", 7680}, - {"7K", 7168}, - {"6K", 6144}, - {"5K", 5120}, - {"4K", 3840}, - {"3K", 3072}, - {"2K", 1920}, - {"1K", 1080}, - } - for _, t := range tiers { - if longEdge >= t.edge { - return t.name +// filterByDarkness 根据黑暗阈值筛选图片 +func filterByDarkness(wallpapers []WallpaperInfo, minDarkScore float64) []WallpaperInfo { + var filtered []WallpaperInfo + for _, wp := range wallpapers { + if wp.DarkScore >= minDarkScore { + filtered = append(filtered, wp) } } - return "1K" + return filtered } -// newUUID 通过 crypto/rand 生成的 32 位十六进制随机文件名(不含扩展名) -func newUUID() string { - const hexChars = "0123456789abcdef" - buf := make([]byte, 16) - if _, err := rand.Read(buf); err != nil { - // 极难发生;回退到时间戳保证唯一 - return fmt.Sprintf("%x", time.Now().UnixNano()) +// cleanFileName 清理文件名中的特殊字符 +func cleanFileName(name string) string { + replacements := map[string]string{ + " ": "_", "/": "_", "\\": "_", ":": "_", + "*": "", "?": "", "\"": "", "<": "", ">": "", "|": "", } - for i, b := range buf { - buf[i] = hexChars[b&0x0f] + result := name + for old, n := range replacements { + result = strings.ReplaceAll(result, old, n) } - return string(buf) + // 按 rune 截断,避免截断多字节 UTF-8 字符 + runes := []rune(result) + if len(runes) > 100 { + result = string(runes[:100]) + } + return result } -// isPSDOrGIF 判断扩展名是否为 PSD/PSB/GIF(单独归置,不参与明暗分类) -func isPSDOrGIF(path string) bool { - switch strings.ToLower(filepath.Ext(path)) { - case ".psd", ".psb", ".gif": - return true - } - return false -} - -// psdGifDirName 返回 PSD/GIF 对应的独立目录名(gif 用 gif,PSD/PSB 用 psd) -func psdGifDirName(path string) string { - if strings.ToLower(filepath.Ext(path)) == ".gif" { - return "gif" - } - return "psd" -} - -// formatExtensions 格式名到扩展名的映射(image.Image.Decode 返回的格式名) -var formatExtensions = map[string]string{ - "png": ".png", - "jpeg": ".jpg", - "gif": ".gif", - "bmp": ".bmp", - "tiff": ".tiff", - "webp": ".webp", - "psd": ".psd", - "psb": ".psb", -} - -// detectExtension 决定归档用扩展名:优先保留原后缀(如 .jfif),无后缀时按解码出的格式名补一个 -func detectExtension(path, imageName string) string { - if ext := strings.ToLower(filepath.Ext(path)); ext != "" { - return ext - } - if ext, ok := formatExtensions[imageName]; ok { - return ext - } - return "" -} - -// isImageByContent 通过读取文件头(magic 字节)判断是否为已注册的图片格式,用于无后缀文件 -func isImageByContent(path string) bool { - f, err := os.Open(path) - if err != nil { - return false - } - defer f.Close() - _, _, err = image.DecodeConfig(f) - return err == nil -} - -// directionName 返回图像的横竖方向(宽==高为 square,宽>高为 horizontal,否则 vertical) -func directionName(width, height int) string { - if width == height { - return "square" - } - if width > height { - return "horizontal" - } - return "vertical" -} - -// moveOrCopyFile 移动或复制文件到目标目录(direction 与 tier 为子目录,可为空) -func moveOrCopyFile(wp *WallpaperInfo, targetDir, direction, tier string, copyMode bool) error { - dir := targetDir - if direction != "" { - dir = filepath.Join(dir, direction) - } - if tier != "" { - dir = filepath.Join(dir, tier) - } - if err := os.MkdirAll(dir, 0755); err != nil { +// moveOrCopyFile 移动或复制文件到目标目录 +func moveOrCopyFile(wp *WallpaperInfo, targetDir string, copyMode bool) error { + if err := os.MkdirAll(targetDir, 0755); err != nil { return err } - ext := wp.Ext - if ext == "" { - ext = filepath.Ext(wp.Path) - } - newFilename := newUUID() + ext - dstPath := filepath.Join(dir, newFilename) + 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(targetDir, newFilename) // 文件名冲突:追加序号 if _, err := os.Stat(dstPath); err == nil { for i := 1; i < 100; i++ { - newFilename = fmt.Sprintf("%s_(%d)%s", newUUID(), i, ext) - dstPath = filepath.Join(dir, newFilename) + newFilename = fmt.Sprintf("%s_[%dx%d]_dark%.0f_(%d)%s", + cleanName, wp.Width, wp.Height, wp.DarkScore*100, i, ext) + dstPath = filepath.Join(targetDir, newFilename) if _, err := os.Stat(dstPath); os.IsNotExist(err) { break } @@ -641,3 +449,21 @@ func moveFile(src, dst string) error { } return os.Remove(src) } + +// moveWallpapers 将图片列表移动到指定目标目录 +func moveWallpapers(wallpapers []WallpaperInfo, targetDir string, copyMode bool) (int, error) { + movedCount := 0 + var firstErr error + + for _, wp := range wallpapers { + if err := moveOrCopyFile(&wp, targetDir, copyMode); err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + movedCount++ + } + + return movedCount, firstErr +}