diff --git a/README.md b/README.md index 587ac53..4fe43df 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,11 @@ ## 功能特性 - **智能明暗分析**:基于 ITU-R BT.601 亮度公式,同时统计暗色像素(亮度 < 80)与亮色像素(亮度 > 175)占比 -- **五级明暗分类**:暗色 `dark_95/85/75/65/55` 与亮色 `bright_95/85/75/65/55` 五档归档 +- **多级明暗分类**:暗色 `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、PNG、GIF、BMP、WebP、TIFF +- **多格式支持**:JPG/JPEG/JFIF/JPE、PNG/APNG、GIF、BMP/DIB、WebP、TIFF/TIF、PSD/PSB - **并发处理**:默认 24 线程并行分析,大图自动采样加速 - **UUID 命名**:归档后的文件统一重命名为随机 UUID,不保留原文件名 @@ -43,12 +43,13 @@ cd ~/Pictures/wallpapers 执行后,源目录下会生成如下结构: ├── wallpapers_wallpapers/ -│ ├── dark_95/ +│ ├── dark_100/ │ │ ├── horizontal/ → 1K/ 2K/ 4K/ … │ │ ├── vertical/ → 1K/ 2K/ 4K/ … │ │ └── square/ → 1K/ … -│ ├── bright_95/ # 与 dark_ 同构 -│ ├── neutral/ # 明暗皆不达标但尺寸合格 +│ ├── bright_85/ # 与 dark_ 同构(15~85) +│ ├── psd/ # PSD/PSB,不做明暗分类 +│ ├── gif/ # GIF,不做明暗分类 │ ├── fixed/ # 长边 < 1080 的过小图 │ ├── other/ # 尺寸不足 min-width/min-height │ └── error/ # 大小超限或解码失败的异常文件 @@ -90,14 +91,12 @@ cd ~/Pictures/wallpapers ## 分类优先级 -1. **fixed**:长边 < 1080 的过小图片 -2. **other**:尺寸达标(≥1080)但低于 `min-width/min-height` 阈值的图片 -3. **dark_N**(从高到低,每张只归入最高档) -4. **bright_N**(同理,处理完 dark 后剩余图中再判亮色档) -5. **neutral**:上面均未命中的图片 -6. **error**:文件大小超限或解码失败的异常文件 +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/neutral/other)内部均按 方向 → 分辨率 分子目录。 +所有正常分类(dark/bright/other)内部均按 方向 → 分辨率 分子目录。 ## 明暗评分算法 @@ -112,7 +111,8 @@ lightScore = 亮度 > 175 的像素占比 - 默认使用**移动**模式,原文件会从源目录移走,首次使用建议加 `-copy` 试运行 - 目标目录会被自动跳过,不会重复处理之前已整理的文件 - UUID 命名,极端情况下重名会自动追加 `_(1)`、`_(2)` 等序号 -- 支持的图片格式依赖 Go 标准库 + `golang.org/x/image`:JPG、PNG、GIF、BMP、WebP、TIFF +- 支持的图片格式依赖 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 feab84f..b70bcf5 100644 --- a/main.go +++ b/main.go @@ -22,13 +22,17 @@ 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 存储壁纸信息 @@ -40,6 +44,7 @@ type WallpaperInfo struct { Size int64 Width int Height int + Ext string // 检测到的扩展名(无后缀图片用) } // Config 配置结构 @@ -62,7 +67,7 @@ func DefaultConfig() Config { SourceDir: ".", MinWidth: 1080, MinHeight: 720, - MinSizeKB: 100, + MinSizeKB: 50, MaxSizeMB: 100, CopyMode: false, Parallel: 24, @@ -119,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🔍 开始扫描图片...") @@ -142,6 +149,15 @@ func main() { 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 + } // 过小图片单独归置,不按明暗/分辨率细分 longEdge := wp.Width if wp.Height > longEdge { @@ -166,62 +182,51 @@ func main() { conforming = append(conforming, wp) } - // 第二步:暗色图片 → dark_N(从高到低,每张只归入最高档) - for _, threshold := range thresholds { - minScore := float64(threshold) / 100.0 - targetDir := filepath.Join(config.TargetDir, fmt.Sprintf("dark_%d", threshold)) - fmt.Printf("\n📁 处理暗色等级 ≥ %d%% 的图片\n", threshold) - moved := 0 - remaining := conforming[:0] - for _, wp := range conforming { - if wp.DarkScore >= minScore { - if err := moveOrCopyFile(wp, targetDir, directionName(wp.Width, wp.Height), resolutionTierName(wp.Width, wp.Height), config.CopyMode); err != nil { - log.Printf("⚠️ 移动失败 %s: %v", wp.Path, err) - } else { - moved++ - } - } else { - remaining = append(remaining, wp) - } - } - fmt.Printf(" ✅ 暗色 ≥ %d%%:移动 %d 张\n", threshold, moved) - conforming = remaining - } - - // 第三步:亮色图片 → bright_N(从高到低) - for _, threshold := range thresholds { - minScore := float64(threshold) / 100.0 - targetDir := filepath.Join(config.TargetDir, fmt.Sprintf("bright_%d", threshold)) - fmt.Printf("\n📁 处理亮色等级 ≥ %d%% 的图片\n", threshold) - moved := 0 - remaining := conforming[:0] - for _, wp := range conforming { - if wp.LightScore >= minScore { - if err := moveOrCopyFile(wp, targetDir, directionName(wp.Width, wp.Height), resolutionTierName(wp.Width, wp.Height), config.CopyMode); err != nil { - log.Printf("⚠️ 移动失败 %s: %v", wp.Path, err) - } else { - moved++ - } - } else { - remaining = append(remaining, wp) - } - } - fmt.Printf(" ✅ 亮色 ≥ %d%%:移动 %d 张\n", threshold, moved) - conforming = remaining - } - - // 第四步:剩余为中性图片 → neutral - moved := 0 - neutralDir := filepath.Join(config.TargetDir, "neutral") + // 第二步:按"主导分"判定亮/暗(不走多层筛选),每张图必归亮或暗之一 + darkSeconds, brightSeconds := 0, 0 + darkLowest := darkThresholds[len(darkThresholds)-1] + brightLowest := brightThresholds[len(brightThresholds)-1] for _, wp := range conforming { - if err := moveOrCopyFile(wp, neutralDir, directionName(wp.Width, wp.Height), resolutionTierName(wp.Width, wp.Height), config.CopyMode); err != nil { - log.Printf("⚠️ 移动失败 %s: %v", wp.Path, err) + 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 { - moved++ + // 暗色主导(含两者皆 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++ + } } } - if moved > 0 { - fmt.Printf("\n📁 处理中性色图片 → neutral/:移动 %d 张\n", moved) + 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) @@ -298,7 +303,7 @@ func analyzeImageFile(path string, config Config) (*WallpaperInfo, error) { } defer file.Close() - img, _, err := image.Decode(file) + img, imageName, err := image.Decode(file) if err != nil { return nil, &DecodeError{Path: path, Reason: err.Error()} } @@ -313,6 +318,7 @@ func analyzeImageFile(path string, config Config) (*WallpaperInfo, error) { Size: fileInfo.Size(), Width: width, Height: height, + Ext: detectExtension(path, imageName), }, nil } @@ -437,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 } @@ -507,6 +514,57 @@ func newUUID() string { return string(buf) } +// 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 { @@ -531,7 +589,10 @@ func moveOrCopyFile(wp *WallpaperInfo, targetDir, direction, tier string, copyMo return err } - ext := filepath.Ext(wp.Path) + ext := wp.Ext + if ext == "" { + ext = filepath.Ext(wp.Path) + } newFilename := newUUID() + ext dstPath := filepath.Join(dir, newFilename)