From 9eb7b9aa62e46d27cd285202c83e8d6a5fcc256d Mon Sep 17 00:00:00 2001 From: Odboy Date: Fri, 17 Jan 2025 21:11:47 +0800 Subject: [PATCH] Merge remote-tracking branch 'origin/master' # Conflicts: # build.sh --- README.md | 144 +++++++----------- go.mod | 5 + go.sum | 4 + main.go | 428 ++++++++++++++++++++++++++++++++++++++---------------- 4 files changed, 365 insertions(+), 216 deletions(-) diff --git a/README.md b/README.md index ac9f692..4fe43df 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,29 @@ -# Kenaito Dark Wallpaper Finder 🖤 +# Kenaito Wallpaper Organizer 🖼️ -一个自动扫描、分析并整理暗色系壁纸的命令行工具。 - -程序会递归扫描指定目录下的所有图片,根据亮度算法计算每张图片的"黑暗程度"评分,然后按评分等级自动分类移动(或复制)到不同子目录中。 +一个自动扫描、分析并归类壁纸的命令行工具。它会递归扫描指定目录下的所有图片,分析每张图的**明暗程度**,并按**明暗等级 → 方向 → 分辨率**进行分类归档(移动或复制)。 ## 功能特性 -- **智能暗色分析**:基于 ITU-R BT.601 亮度公式计算黑暗评分,纯黑加分,鲜艳色惩罚 -- **多格式支持**:JPG、PNG、GIF、BMP、WebP、TIFF -- **五级分类**:按 ≥95%、≥85%、≥75%、≥65%、≥55% 五个黑暗等级自动归档 -- **并发处理**:默认 24 线程并行分析,大图自动采样加速 -- **灵活配置**:所有参数均可通过命令行调整 +- **智能明暗分析**:基于 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,不保留原文件名 ## 编译 ```bash # Linux / macOS -go build -o kenaito-dark-wallpaper-finder main.go +go build -o kenaito-wallpaper-organizer main.go # Windows -go build -o kenaito-dark-wallpaper-finder.exe main.go +go build -o kenaito-wallpaper-organizer.exe main.go ``` -或使用构建脚本: +或使用构建脚本: ```bash bash build.sh @@ -33,26 +34,25 @@ bash build.sh ```bash # 在壁纸目录下运行(默认移动模式,等待5秒确认) cd ~/Pictures/wallpapers -./kenaito-dark-wallpaper-finder +./kenaito-wallpaper-organizer # 用复制模式试运行(不会移动原文件) -./kenaito-dark-wallpaper-finder -copy -sleep 0 +./kenaito-wallpaper-organizer -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 -└── ... +├── 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/ # 大小超限或解码失败的异常文件 ``` ## 命令行参数 @@ -60,93 +60,59 @@ wallpapers/ | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `-source` | string | `.` | 源目录路径,递归扫描该目录下所有图片 | -| `-target` | string | 自动生成 | 目标目录基名,留空则自动使用 `<源目录名>_dark_wallpapers` | -| `-min-width` | int | `1920` | 图片最小宽度(像素),低于此值跳过 | -| `-min-height` | int | `1080` | 图片最小高度(像素),低于此值跳过 | -| `-min-size` | int | `100` | 最小文件大小(KB),小于此值跳过 | -| `-max-size` | int | `100` | 最大文件大小(MB),大于此值跳过(排除异常大文件) | +| `-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 | | `-copy` | bool | `false` | 复制模式,设为 true 则复制原文件而非移动 | -| `-parallel` | int | `24` | 并发分析线程数,根据 CPU 核心数调整 | +| `-parallel` | int | `24` | 并发分析线程数 | | `-verbose` | bool | `true` | 详细输出,显示每个文件的处理过程 | -| `-sleep` | int | `5` | 执行前等待秒数,设为 0 则立即执行。等待期间可按 Ctrl+C 取消 | +| `-sleep` | int | `5` | 执行前等待秒数,设为 0 则立即执行,等待期间可按 Ctrl+C 取消 | ## 使用示例 -### 基础用法 - ```bash -# 扫描当前目录,移动暗色壁纸到子目录(默认行为) -./kenaito-dark-wallpaper-finder - # 扫描指定目录 -./kenaito-dark-wallpaper-finder -source ~/Downloads/wallpapers +./kenaito-wallpaper-organizer -source ~/Downloads/wallpapers # 复制模式(保留原文件不动) -./kenaito-dark-wallpaper-finder -source ~/Pictures -copy +./kenaito-wallpaper-organizer -source ~/Pictures -copy # 跳过等待,直接执行 -./kenaito-dark-wallpaper-finder -sleep 0 -``` +./kenaito-wallpaper-organizer -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 +# 只要 4K 以上壁纸 +./kenaito-wallpaper-organizer -min-width 3840 -min-height 2160 # 高配机器,关闭详细输出加速 -./kenaito-dark-wallpaper-finder -parallel 48 -verbose=false +./kenaito-wallpaper-organizer -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. **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) -## 工作原理 +所有正常分类(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 -darkScore = (1 - brightness/255) ^ 0.6 +brightness = 0.299×R + 0.587×G + 0.114×B (ITU-R BT.601) +darkScore = 亮度 < 80 的像素占比 +lightScore = 亮度 > 175 的像素占比 ``` -- 纯黑像素(RGB < 40)额外加成 ×1.3 -- 鲜艳像素(任一通道 > 200)惩罚 ×0.7 - ## 注意事项 -- 默认使用**移动**模式,原文件会从源目录移走。首次使用建议加 `-copy` 试运行 -- 目标目录会被自动跳过,不会重复处理之前已整理的文件 -- 文件名冲突时自动追加序号 `_(1)`、`_(2)` 等 -- 支持的图片格式依赖 Go 标准库 + `golang.org/x/image`:JPG、PNG、GIF、BMP、WebP、TIFF +- 默认使用**移动**模式,原文件会从源目录移走,首次使用建议加 `-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 字节)探测自动识别,不会被静默跳过 ## License diff --git a/go.mod b/go.mod index 767ac3f..e879626 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,8 @@ 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 c1e754b..eec2996 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,6 @@ +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 b52bc0d..b70bcf5 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "crypto/rand" "flag" "fmt" "image" @@ -21,23 +22,29 @@ 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, ".png": true, - ".bmp": true, ".webp": true, ".gif": true, + ".jpg": true, ".jpeg": true, ".jfif": true, ".jpe": true, + ".png": true, ".apng": true, + ".bmp": true, ".dib": true, ".webp": true, ".gif": true, ".tiff": true, ".tif": true, + ".psd": true, ".psb": true, } // WallpaperInfo 存储壁纸信息 type WallpaperInfo struct { - Path string - DarkScore float64 // 黑暗程度评分(0-1,越高越暗) - AvgColor color.RGBA - Size int64 - Width int - Height int + Path string + DarkScore float64 // 黑暗程度评分(0-1,越低越暗) + LightScore float64 // 亮度程度评分(0-1,越高越亮) + AvgColor color.RGBA + Size int64 + Width int + Height int + Ext string // 检测到的扩展名(无后缀图片用) } // Config 配置结构 @@ -58,9 +65,9 @@ type Config struct { func DefaultConfig() Config { return Config{ SourceDir: ".", - MinWidth: 1920, - MinHeight: 1080, - MinSizeKB: 100, + MinWidth: 1080, + MinHeight: 720, + MinSizeKB: 50, MaxSizeMB: 100, CopyMode: false, Parallel: 24, @@ -95,17 +102,17 @@ func main() { // 如果目标目录为空,自动生成 if config.TargetDir == "" { - config.TargetDir = filepath.Join(absSource, baseDirName+"_dark_wallpapers") + config.TargetDir = filepath.Join(absSource, baseDirName+"_wallpapers") } else { if !filepath.IsAbs(config.TargetDir) { config.TargetDir = filepath.Join(absSource, config.TargetDir) } } - fmt.Println("🖤 黑色壁纸查找与移动工具") + fmt.Println("🖼️ Kenaito Wallpaper Organizer 壁纸整理工具") 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]) @@ -117,7 +124,9 @@ func main() { } // 黑暗阈值等级(从高到低) - thresholds := []int{95, 85, 75, 65, 55} + darkThresholds := []int{100, 95, 85, 75, 65, 55} + // 亮色阈值等级(从高到低,15~85) + brightThresholds := []int{85, 75, 65, 55, 45, 35, 25, 15} // 第一步:一次性扫描所有图片并分析 fmt.Println("\n🔍 开始扫描图片...") @@ -126,57 +135,153 @@ func main() { log.Fatalf("扫描失败: %v", err) } - fmt.Printf("\n✅ 扫描完成!共 %d 张图片满足分辨率和大小要求\n", len(allWallpapers)) + fmt.Printf("\n✅ 扫描完成!共发现 %d 张图片(分辨率/大小不达标者将放入 other)\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)) + // 第二步:分类并移动/复制,每张图片只归入一个目录 + // 优先级:分辨率不达标→ other;达标者按 暗色 → 亮色 → 中性 - 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) + // 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) + } continue } - - fmt.Printf(" 找到 %d 张符合条件的图片\n", len(filtered)) - - movedCount, moveErr := moveWallpapers(filtered, targetDir, config.CopyMode) - if moveErr != nil { - log.Printf("⚠️ 移动部分文件时出错: %v", moveErr) + // 过小图片单独归置,不按明暗/分辨率细分 + longEdge := wp.Width + if wp.Height > longEdge { + longEdge = wp.Height } - - fmt.Printf(" ✓ 成功移动/复制 %d 张图片\n", movedCount) - - // 从待处理列表中移除本轮已处理的图片,避免后续低阈值重复处理 - processed := make(map[string]struct{}, len(filtered)) - for _, wp := range filtered { - processed[wp.Path] = struct{}{} + 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 } - remaining := make([]WallpaperInfo, 0, len(allWallpapers)) - for _, wp := range allWallpapers { - if _, ok := processed[wp.Path]; !ok { - remaining = append(remaining, wp) + 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) + + 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++ } } - allWallpapers = remaining + } + if brightSeconds > 0 { + fmt.Printf("\n📁 亮色主导 → bright_N:移动 %d 张\n", brightSeconds) + } + if darkSeconds > 0 { + fmt.Printf("\n📁 暗色主导 → dark_N:移动 %d 张\n", darkSeconds) } fmt.Printf("\n🎉 所有操作完成!文件已整理到: %s/\n", config.TargetDir) } -// analyzeImageFile 分析单个图片的黑暗程度(不做阈值过滤) +// 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 分析单个图片的黑暗/亮度程度(不做阈值过滤,返回完整信息) func analyzeImageFile(path string, config Config) (*WallpaperInfo, error) { fileInfo, err := os.Stat(path) if err != nil { @@ -185,54 +290,53 @@ func analyzeImageFile(path string, config Config) (*WallpaperInfo, error) { sizeKB := fileInfo.Size() / 1024 if sizeKB < config.MinSizeKB { - return nil, fmt.Errorf("文件太小: %dKB < %dKB", sizeKB, config.MinSizeKB) + return nil, &SizeFilterError{Path: path, Reason: fmt.Sprintf("文件太小: %dKB < %dKB", sizeKB, config.MinSizeKB)} } if fileInfo.Size() > config.MaxSizeMB*1024*1024 { - return nil, fmt.Errorf("文件太大: %dMB > %dMB", fileInfo.Size()/1024/1024, config.MaxSizeMB) + return nil, &SizeFilterError{Path: path, Reason: fmt.Sprintf("文件太大: %dMB > %dMB", fileInfo.Size()/1024/1024, config.MaxSizeMB)} } file, err := os.Open(path) if err != nil { - return nil, err + return nil, &DecodeError{Path: path, Reason: err.Error()} } defer file.Close() - img, _, err := image.Decode(file) + img, imageName, err := image.Decode(file) if err != nil { - return nil, err + return nil, &DecodeError{Path: path, Reason: err.Error()} } - 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) - } + darkScore, lightScore, avgColor, width, height := analyzeImage(img) return &WallpaperInfo{ - Path: path, - DarkScore: darkScore, - AvgColor: avgColor, - Size: fileInfo.Size(), - Width: width, - Height: height, + Path: path, + DarkScore: darkScore, + LightScore: lightScore, + AvgColor: avgColor, + Size: fileInfo.Size(), + Width: width, + Height: height, + Ext: detectExtension(path, imageName), }, nil } -// analyzeImage 分析图片的黑暗程度和尺寸 -// 使用暗色像素占比(亮度 < 80 的像素比例)作为评分,比平均亮度更能准确反映暗色背景壁纸 -func analyzeImage(img image.Image) (darkScore float64, avgColor color.RGBA, width, height int) { +// analyzeImage 分析图片的黑暗程度、亮度程度和尺寸 +// darkScore: 暗色像素(亮度 < 80)占比;lightScore: 亮色像素(亮度 > 175)占比 +func analyzeImage(img image.Image) (darkScore float64, lightScore 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 + return 0, 0, color.RGBA{0, 0, 0, 255}, width, height } var sumR, sumG, sumB uint64 darkCount := 0 + lightCount := 0 sampleCount := 0 // 大图采样以提高性能 @@ -251,28 +355,29 @@ func analyzeImage(img image.Image) (darkScore float64, avgColor color.RGBA, widt 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, color.RGBA{0, 0, 0, 255}, width, height + return 0, 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, color.RGBA{avgR, avgG, avgB, 255}, width, height + return darkScore, lightScore, color.RGBA{avgR, avgG, avgB, 255}, width, height } // scanAllImages 一次性扫描所有图片并分析黑暗程度 @@ -299,8 +404,9 @@ func scanAllImages(config Config) ([]WallpaperInfo, error) { wp, err := analyzeImageFile(path, config) if err != nil { if config.Verbose { - fmt.Printf("跳过 %s: %v\n", filepath.Base(path), err) + fmt.Printf("⚠️ 处理异常 %s: %v -> error/\n", filepath.Base(path), err) } + moveToErrorDir(path, config, err) continue } results <- wp @@ -337,7 +443,8 @@ func scanAllImages(config Config) ([]WallpaperInfo, error) { return nil } ext := strings.ToLower(filepath.Ext(path)) - if supportedExts[ext] { + // 支持的后缀直接处理;无后缀文件按文件内容探测是否为图片(避免静默跳过) + if supportedExts[ext] || (ext == "" && isImageByContent(path)) { processedCount++ jobs <- path } @@ -360,55 +467,140 @@ func scanAllImages(config Config) ([]WallpaperInfo, error) { 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) +// 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 } } - return filtered + return "1K" } -// cleanFileName 清理文件名中的特殊字符 -func cleanFileName(name string) string { - replacements := map[string]string{ - " ": "_", "/": "_", "\\": "_", ":": "_", - "*": "", "?": "", "\"": "", "<": "", ">": "", "|": "", +// 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()) } - result := name - for old, n := range replacements { - result = strings.ReplaceAll(result, old, n) + for i, b := range buf { + buf[i] = hexChars[b&0x0f] } - // 按 rune 截断,避免截断多字节 UTF-8 字符 - runes := []rune(result) - if len(runes) > 100 { - result = string(runes[:100]) - } - return result + return string(buf) } -// moveOrCopyFile 移动或复制文件到目标目录 -func moveOrCopyFile(wp *WallpaperInfo, targetDir string, copyMode bool) error { - if err := os.MkdirAll(targetDir, 0755); err != nil { +// 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 { 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(targetDir, newFilename) + ext := wp.Ext + if ext == "" { + ext = filepath.Ext(wp.Path) + } + newFilename := newUUID() + ext + dstPath := filepath.Join(dir, 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(targetDir, newFilename) + newFilename = fmt.Sprintf("%s_(%d)%s", newUUID(), i, ext) + dstPath = filepath.Join(dir, newFilename) if _, err := os.Stat(dstPath); os.IsNotExist(err) { break } @@ -449,21 +641,3 @@ 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 -}