feat: 重命名工具为 kenaito-wallpaper-organizer 并增加明暗/方向/分辨率/UUID 归档
This commit is contained in:
@@ -1,28 +1,29 @@
|
|||||||
# Kenaito Dark Wallpaper Finder 🖤
|
# Kenaito Wallpaper Organizer 🖼️
|
||||||
|
|
||||||
一个自动扫描、分析并整理暗色系壁纸的命令行工具。
|
一个自动扫描、分析并归类壁纸的命令行工具。它会递归扫描指定目录下的所有图片,分析每张图的**明暗程度**,并按**明暗等级 → 方向 → 分辨率**进行分类归档(移动或复制)。
|
||||||
|
|
||||||
程序会递归扫描指定目录下的所有图片,根据亮度算法计算每张图片的"黑暗程度"评分,然后按评分等级自动分类移动(或复制)到不同子目录中。
|
|
||||||
|
|
||||||
## 功能特性
|
## 功能特性
|
||||||
|
|
||||||
- **智能暗色分析**:基于 ITU-R BT.601 亮度公式计算黑暗评分,纯黑加分,鲜艳色惩罚
|
- **智能明暗分析**:基于 ITU-R BT.601 亮度公式,同时统计暗色像素(亮度 < 80)与亮色像素(亮度 > 175)占比
|
||||||
- **多格式支持**:JPG、PNG、GIF、BMP、WebP、TIFF
|
- **五级明暗分类**:暗色 `dark_95/85/75/65/55` 与亮色 `bright_95/85/75/65/55` 五档归档
|
||||||
- **五级分类**:按 ≥95%、≥85%、≥75%、≥65%、≥55% 五个黑暗等级自动归档
|
- **方向区分**:横屏 `horizontal` / 竖屏 `vertical` / 方形 `square` 子目录
|
||||||
- **并发处理**:默认 24 线程并行分析,大图自动采样加速
|
- **分辨率分档**:按长边区分 1K~8K 子目录
|
||||||
- **灵活配置**:所有参数均可通过命令行调整
|
- **特殊归置**:过小图(`fixed`)、分辨率不足(`other`)、异常文件(`error`)
|
||||||
|
- **多格式支持**:JPG、PNG、GIF、BMP、WebP、TIFF
|
||||||
|
- **并发处理**:默认 24 线程并行分析,大图自动采样加速
|
||||||
|
- **UUID 命名**:归档后的文件统一重命名为随机 UUID,不保留原文件名
|
||||||
|
|
||||||
## 编译
|
## 编译
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Linux / macOS
|
# Linux / macOS
|
||||||
go build -o kenaito-dark-wallpaper-finder main.go
|
go build -o kenaito-wallpaper-organizer main.go
|
||||||
|
|
||||||
# Windows
|
# Windows
|
||||||
go build -o kenaito-dark-wallpaper-finder.exe main.go
|
go build -o kenaito-wallpaper-organizer.exe main.go
|
||||||
```
|
```
|
||||||
|
|
||||||
或使用构建脚本:
|
或使用构建脚本:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash build.sh
|
bash build.sh
|
||||||
@@ -33,26 +34,24 @@ bash build.sh
|
|||||||
```bash
|
```bash
|
||||||
# 在壁纸目录下运行(默认移动模式,等待5秒确认)
|
# 在壁纸目录下运行(默认移动模式,等待5秒确认)
|
||||||
cd ~/Pictures/wallpapers
|
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/
|
├── wallpapers_dark_wallpapers/
|
||||||
│ ├── dark_95/ ← 黑暗度 ≥ 95%
|
│ ├── dark_95/
|
||||||
│ │ ├── photo1_[2560x1440]_dark97.jpg
|
│ │ ├── horizontal/ → 1K/ 2K/ 4K/ …
|
||||||
│ │ └── ...
|
│ │ ├── vertical/ → 1K/ 2K/ 4K/ …
|
||||||
│ ├── dark_85/ ← 黑暗度 ≥ 85%
|
│ │ └── square/ → 1K/ …
|
||||||
│ ├── dark_75/ ← 黑暗度 ≥ 75%
|
│ ├── bright_95/ # 与 dark_ 同构
|
||||||
│ ├── dark_65/ ← 黑暗度 ≥ 65%
|
│ ├── neutral/ # 明暗皆不达标但尺寸合格
|
||||||
│ └── dark_55/ ← 黑暗度 ≥ 55%
|
│ ├── fixed/ # 长边 < 1080 的过小图
|
||||||
├── bright_wallpaper.jpg
|
│ ├── other/ # 尺寸不足 min-width/min-height
|
||||||
└── ...
|
│ └── error/ # 大小超限或解码失败的异常文件
|
||||||
```
|
```
|
||||||
|
|
||||||
## 命令行参数
|
## 命令行参数
|
||||||
@@ -60,93 +59,60 @@ wallpapers/
|
|||||||
| 参数 | 类型 | 默认值 | 说明 |
|
| 参数 | 类型 | 默认值 | 说明 |
|
||||||
|------|------|--------|------|
|
|------|------|--------|------|
|
||||||
| `-source` | string | `.` | 源目录路径,递归扫描该目录下所有图片 |
|
| `-source` | string | `.` | 源目录路径,递归扫描该目录下所有图片 |
|
||||||
| `-target` | string | 自动生成 | 目标目录基名,留空则自动使用 `<源目录名>_dark_wallpapers` |
|
| `-target` | string | 自动生成 | 目标目录基名,留空自动使用 `<源目录名>_dark_wallpapers` |
|
||||||
| `-min-width` | int | `1920` | 图片最小宽度(像素),低于此值跳过 |
|
| `-min-width` | int | `1080` | 图片最小宽度(像素),低于此值进入 other |
|
||||||
| `-min-height` | int | `1080` | 图片最小高度(像素),低于此值跳过 |
|
| `-min-height` | int | `720` | 图片最小高度(像素),低于此值进入 other |
|
||||||
| `-min-size` | int | `100` | 最小文件大小(KB),小于此值跳过 |
|
| `-min-size` | int | `100` | 最小文件大小(KB),小于此值进入 error |
|
||||||
| `-max-size` | int | `100` | 最大文件大小(MB),大于此值跳过(排除异常大文件) |
|
| `-max-size` | int | `100` | 最大文件大小(MB),大于此值进入 error |
|
||||||
| `-copy` | bool | `false` | 复制模式,设为 true 则复制原文件而非移动 |
|
| `-copy` | bool | `false` | 复制模式,设为 true 则复制原文件而非移动 |
|
||||||
| `-parallel` | int | `24` | 并发分析线程数,根据 CPU 核心数调整 |
|
| `-parallel` | int | `24` | 并发分析线程数 |
|
||||||
| `-verbose` | bool | `true` | 详细输出,显示每个文件的处理过程 |
|
| `-verbose` | bool | `true` | 详细输出,显示每个文件的处理过程 |
|
||||||
| `-sleep` | int | `5` | 执行前等待秒数,设为 0 则立即执行。等待期间可按 Ctrl+C 取消 |
|
| `-sleep` | int | `5` | 执行前等待秒数,设为 0 则立即执行,等待期间可按 Ctrl+C 取消 |
|
||||||
|
|
||||||
## 使用示例
|
## 使用示例
|
||||||
|
|
||||||
### 基础用法
|
|
||||||
|
|
||||||
```bash
|
```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
|
||||||
```
|
|
||||||
|
|
||||||
### 自定义筛选条件
|
# 只要 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-dark-wallpaper-finder -parallel 48 -verbose=false
|
./kenaito-wallpaper-organizer -parallel 48 -verbose=false
|
||||||
```
|
```
|
||||||
|
|
||||||
### 组合使用
|
## 分类优先级
|
||||||
|
|
||||||
```bash
|
1. **fixed**:长边 < 1080 的过小图片
|
||||||
# 扫描外接硬盘上的壁纸,复制模式,4K 以上,不等待,静默执行
|
2. **other**:尺寸达标(≥1080)但低于 `min-width/min-height` 阈值的图片
|
||||||
./kenaito-dark-wallpaper-finder \
|
3. **dark_N**(从高到低,每张只归入最高档)
|
||||||
-source /Volumes/ExternalHD/Wallpapers \
|
4. **bright_N**(同理,处理完 dark 后剩余图中再判亮色档)
|
||||||
-target ~/Pictures/DarkWallpapers \
|
5. **neutral**:上面均未命中的图片
|
||||||
-copy \
|
6. **error**:文件大小超限或解码失败的异常文件
|
||||||
-min-width 3840 \
|
|
||||||
-min-height 2160 \
|
|
||||||
-sleep 0 \
|
|
||||||
-verbose=false
|
|
||||||
```
|
|
||||||
|
|
||||||
## 工作原理
|
所有正常分类(dark/bright/neutral/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
|
brightness = 0.299×R + 0.587×G + 0.114×B (ITU-R BT.601)
|
||||||
darkScore = (1 - brightness/255) ^ 0.6
|
darkScore = 亮度 < 80 的像素占比
|
||||||
|
lightScore = 亮度 > 175 的像素占比
|
||||||
```
|
```
|
||||||
|
|
||||||
- 纯黑像素(RGB < 40)额外加成 ×1.3
|
|
||||||
- 鲜艳像素(任一通道 > 200)惩罚 ×0.7
|
|
||||||
|
|
||||||
## 注意事项
|
## 注意事项
|
||||||
|
|
||||||
- 默认使用**移动**模式,原文件会从源目录移走。首次使用建议加 `-copy` 试运行
|
- 默认使用**移动**模式,原文件会从源目录移走,首次使用建议加 `-copy` 试运行
|
||||||
- 目标目录会被自动跳过,不会重复处理之前已整理的文件
|
- 目标目录会被自动跳过,不会重复处理之前已整理的文件
|
||||||
- 文件名冲突时自动追加序号 `_(1)`、`_(2)` 等
|
- UUID 命名,极端情况下重名会自动追加 `_(1)`、`_(2)` 等序号
|
||||||
- 支持的图片格式依赖 Go 标准库 + `golang.org/x/image`:JPG、PNG、GIF、BMP、WebP、TIFF
|
- 支持的图片格式依赖 Go 标准库 + `golang.org/x/image`:JPG、PNG、GIF、BMP、WebP、TIFF
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
rm -f kenaito-dark-wallpaper-finder.exe
|
rm -f kenaito-wallpaper-organizer.exe
|
||||||
# build
|
# build
|
||||||
go build -o kenaito-dark-wallpaper-finder.exe main.go
|
go build -o kenaito-wallpaper-organizer.exe main.go
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
module kenaito-media-group-tool
|
module kenaito-wallpaper-organizer
|
||||||
|
|
||||||
go 1.21
|
go 1.21
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
@@ -33,7 +34,8 @@ var supportedExts = map[string]bool{
|
|||||||
// WallpaperInfo 存储壁纸信息
|
// WallpaperInfo 存储壁纸信息
|
||||||
type WallpaperInfo struct {
|
type WallpaperInfo struct {
|
||||||
Path string
|
Path string
|
||||||
DarkScore float64 // 黑暗程度评分(0-1,越高越暗)
|
DarkScore float64 // 黑暗程度评分(0-1,越低越暗)
|
||||||
|
LightScore float64 // 亮度程度评分(0-1,越高越亮)
|
||||||
AvgColor color.RGBA
|
AvgColor color.RGBA
|
||||||
Size int64
|
Size int64
|
||||||
Width int
|
Width int
|
||||||
@@ -58,8 +60,8 @@ type Config struct {
|
|||||||
func DefaultConfig() Config {
|
func DefaultConfig() Config {
|
||||||
return Config{
|
return Config{
|
||||||
SourceDir: ".",
|
SourceDir: ".",
|
||||||
MinWidth: 1920,
|
MinWidth: 1080,
|
||||||
MinHeight: 1080,
|
MinHeight: 720,
|
||||||
MinSizeKB: 100,
|
MinSizeKB: 100,
|
||||||
MaxSizeMB: 100,
|
MaxSizeMB: 100,
|
||||||
CopyMode: false,
|
CopyMode: false,
|
||||||
@@ -102,7 +104,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("🖤 黑色壁纸查找与移动工具")
|
fmt.Println("🖼️ Kenaito Wallpaper Organizer 壁纸整理工具")
|
||||||
fmt.Println(strings.Repeat("=", 50))
|
fmt.Println(strings.Repeat("=", 50))
|
||||||
fmt.Printf(" 源目录: %s\n", config.SourceDir)
|
fmt.Printf(" 源目录: %s\n", config.SourceDir)
|
||||||
fmt.Printf(" 目标目录: %s(按黑暗等级分子目录)\n", config.TargetDir)
|
fmt.Printf(" 目标目录: %s(按黑暗等级分子目录)\n", config.TargetDir)
|
||||||
@@ -126,57 +128,155 @@ func main() {
|
|||||||
log.Fatalf("扫描失败: %v", err)
|
log.Fatalf("扫描失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("\n✅ 扫描完成!共 %d 张图片满足分辨率和大小要求\n", len(allWallpapers))
|
fmt.Printf("\n✅ 扫描完成!共发现 %d 张图片(分辨率/大小不达标者将放入 other)\n", len(allWallpapers))
|
||||||
|
|
||||||
if len(allWallpapers) == 0 {
|
if len(allWallpapers) == 0 {
|
||||||
fmt.Println("❌ 未找到符合条件的图片")
|
fmt.Println("❌ 未找到符合条件的图片")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 第二步:按黑暗等级分类并移动到不同子目录(从高到低,每张图片只归入一个等级)
|
// 第二步:分类并移动/复制,每张图片只归入一个目录
|
||||||
|
// 优先级:分辨率不达标→ other;达标者按 暗色 → 亮色 → 中性
|
||||||
|
|
||||||
|
// 2.0 过小图片(长边<1080)放入独立的 fixed 目录;其余分辨率不达标的放入 other
|
||||||
|
conforming := make([]*WallpaperInfo, 0, len(allWallpapers))
|
||||||
|
for i := range allWallpapers {
|
||||||
|
wp := &allWallpapers[i]
|
||||||
|
// 过小图片单独归置,不按明暗/分辨率细分
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第二步:暗色图片 → dark_N(从高到低,每张只归入最高档)
|
||||||
for _, threshold := range thresholds {
|
for _, threshold := range thresholds {
|
||||||
minScore := float64(threshold) / 100.0
|
minScore := float64(threshold) / 100.0
|
||||||
targetDir := filepath.Join(config.TargetDir, fmt.Sprintf("dark_%d", threshold))
|
targetDir := filepath.Join(config.TargetDir, fmt.Sprintf("dark_%d", threshold))
|
||||||
|
fmt.Printf("\n📁 处理暗色等级 ≥ %d%% 的图片\n", threshold)
|
||||||
fmt.Printf("\n%s\n", strings.Repeat("=", 60))
|
moved := 0
|
||||||
fmt.Printf("📁 处理黑暗等级 ≥ %d%% 的图片\n", threshold)
|
remaining := conforming[:0]
|
||||||
fmt.Printf(" 目标目录: %s\n", targetDir)
|
for _, wp := range conforming {
|
||||||
fmt.Println(strings.Repeat("=", 60))
|
if wp.DarkScore >= minScore {
|
||||||
|
if err := moveOrCopyFile(wp, targetDir, directionName(wp.Width, wp.Height), resolutionTierName(wp.Width, wp.Height), config.CopyMode); err != nil {
|
||||||
filtered := filterByDarkness(allWallpapers, minScore)
|
log.Printf("⚠️ 移动失败 %s: %v", wp.Path, err)
|
||||||
|
} else {
|
||||||
if len(filtered) == 0 {
|
moved++
|
||||||
fmt.Printf(" ⚠️ 没有符合黑暗等级 ≥ %d%% 的图片\n", threshold)
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
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)
|
remaining = append(remaining, wp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
allWallpapers = remaining
|
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")
|
||||||
|
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)
|
||||||
|
} else {
|
||||||
|
moved++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if moved > 0 {
|
||||||
|
fmt.Printf("\n📁 处理中性色图片 → neutral/:移动 %d 张\n", moved)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("\n🎉 所有操作完成!文件已整理到: %s/\n", config.TargetDir)
|
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) {
|
func analyzeImageFile(path string, config Config) (*WallpaperInfo, error) {
|
||||||
fileInfo, err := os.Stat(path)
|
fileInfo, err := os.Stat(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -185,33 +285,30 @@ func analyzeImageFile(path string, config Config) (*WallpaperInfo, error) {
|
|||||||
|
|
||||||
sizeKB := fileInfo.Size() / 1024
|
sizeKB := fileInfo.Size() / 1024
|
||||||
if sizeKB < config.MinSizeKB {
|
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 {
|
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)
|
file, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, &DecodeError{Path: path, Reason: err.Error()}
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
img, _, err := image.Decode(file)
|
img, _, err := image.Decode(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, &DecodeError{Path: path, Reason: err.Error()}
|
||||||
}
|
}
|
||||||
|
|
||||||
darkScore, avgColor, width, height := analyzeImage(img)
|
darkScore, lightScore, 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{
|
return &WallpaperInfo{
|
||||||
Path: path,
|
Path: path,
|
||||||
DarkScore: darkScore,
|
DarkScore: darkScore,
|
||||||
|
LightScore: lightScore,
|
||||||
AvgColor: avgColor,
|
AvgColor: avgColor,
|
||||||
Size: fileInfo.Size(),
|
Size: fileInfo.Size(),
|
||||||
Width: width,
|
Width: width,
|
||||||
@@ -219,20 +316,21 @@ func analyzeImageFile(path string, config Config) (*WallpaperInfo, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// analyzeImage 分析图片的黑暗程度和尺寸
|
// analyzeImage 分析图片的黑暗程度、亮度程度和尺寸
|
||||||
// 使用暗色像素占比(亮度 < 80 的像素比例)作为评分,比平均亮度更能准确反映暗色背景壁纸
|
// darkScore: 暗色像素(亮度 < 80)占比;lightScore: 亮色像素(亮度 > 175)占比
|
||||||
func analyzeImage(img image.Image) (darkScore float64, avgColor color.RGBA, width, height int) {
|
func analyzeImage(img image.Image) (darkScore float64, lightScore float64, avgColor color.RGBA, width, height int) {
|
||||||
bounds := img.Bounds()
|
bounds := img.Bounds()
|
||||||
width = bounds.Dx()
|
width = bounds.Dx()
|
||||||
height = bounds.Dy()
|
height = bounds.Dy()
|
||||||
totalPixels := width * height
|
totalPixels := width * height
|
||||||
|
|
||||||
if totalPixels == 0 {
|
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
|
var sumR, sumG, sumB uint64
|
||||||
darkCount := 0
|
darkCount := 0
|
||||||
|
lightCount := 0
|
||||||
sampleCount := 0
|
sampleCount := 0
|
||||||
|
|
||||||
// 大图采样以提高性能
|
// 大图采样以提高性能
|
||||||
@@ -251,28 +349,29 @@ func analyzeImage(img image.Image) (darkScore float64, avgColor color.RGBA, widt
|
|||||||
sumG += uint64(g8)
|
sumG += uint64(g8)
|
||||||
sumB += uint64(b8)
|
sumB += uint64(b8)
|
||||||
|
|
||||||
// 亮度计算(ITU-R BT.601),判断是否为暗色像素
|
// 亮度计算(ITU-R BT.601)
|
||||||
brightness := 0.299*float64(r8) + 0.587*float64(g8) + 0.114*float64(b8)
|
brightness := 0.299*float64(r8) + 0.587*float64(g8) + 0.114*float64(b8)
|
||||||
if brightness < 80 {
|
if brightness < 80 {
|
||||||
darkCount++
|
darkCount++
|
||||||
|
} else if brightness > 175 {
|
||||||
|
lightCount++
|
||||||
}
|
}
|
||||||
sampleCount++
|
sampleCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if sampleCount == 0 {
|
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)
|
darkScore = float64(darkCount) / float64(sampleCount)
|
||||||
|
lightScore = float64(lightCount) / float64(sampleCount)
|
||||||
|
|
||||||
avgR := uint8(sumR / uint64(sampleCount))
|
avgR := uint8(sumR / uint64(sampleCount))
|
||||||
avgG := uint8(sumG / uint64(sampleCount))
|
avgG := uint8(sumG / uint64(sampleCount))
|
||||||
avgB := uint8(sumB / 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 一次性扫描所有图片并分析黑暗程度
|
// scanAllImages 一次性扫描所有图片并分析黑暗程度
|
||||||
@@ -299,8 +398,9 @@ func scanAllImages(config Config) ([]WallpaperInfo, error) {
|
|||||||
wp, err := analyzeImageFile(path, config)
|
wp, err := analyzeImageFile(path, config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if config.Verbose {
|
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
|
continue
|
||||||
}
|
}
|
||||||
results <- wp
|
results <- wp
|
||||||
@@ -360,55 +460,86 @@ func scanAllImages(config Config) ([]WallpaperInfo, error) {
|
|||||||
return allWallpapers, err
|
return allWallpapers, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// filterByDarkness 根据黑暗阈值筛选图片
|
// resolutionTierName 根据图片较长边返回分辨率档位名称(如 "1K"、"4K"、"8K")
|
||||||
func filterByDarkness(wallpapers []WallpaperInfo, minDarkScore float64) []WallpaperInfo {
|
// 长边 < 1080 的过小图片单独归类为 "fixed"
|
||||||
var filtered []WallpaperInfo
|
func resolutionTierName(width, height int) string {
|
||||||
for _, wp := range wallpapers {
|
longEdge := width
|
||||||
if wp.DarkScore >= minDarkScore {
|
if height > longEdge {
|
||||||
filtered = append(filtered, wp)
|
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 清理文件名中的特殊字符
|
// newUUID 通过 crypto/rand 生成的 32 位十六进制随机文件名(不含扩展名)
|
||||||
func cleanFileName(name string) string {
|
func newUUID() string {
|
||||||
replacements := map[string]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 i, b := range buf {
|
||||||
for old, n := range replacements {
|
buf[i] = hexChars[b&0x0f]
|
||||||
result = strings.ReplaceAll(result, old, n)
|
|
||||||
}
|
}
|
||||||
// 按 rune 截断,避免截断多字节 UTF-8 字符
|
return string(buf)
|
||||||
runes := []rune(result)
|
|
||||||
if len(runes) > 100 {
|
|
||||||
result = string(runes[:100])
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// moveOrCopyFile 移动或复制文件到目标目录
|
// directionName 返回图像的横竖方向(宽==高为 square,宽>高为 horizontal,否则 vertical)
|
||||||
func moveOrCopyFile(wp *WallpaperInfo, targetDir string, copyMode bool) error {
|
func directionName(width, height int) string {
|
||||||
if err := os.MkdirAll(targetDir, 0755); err != nil {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
ext := filepath.Ext(wp.Path)
|
ext := filepath.Ext(wp.Path)
|
||||||
baseName := strings.TrimSuffix(filepath.Base(wp.Path), ext)
|
newFilename := newUUID() + ext
|
||||||
cleanName := cleanFileName(baseName)
|
dstPath := filepath.Join(dir, newFilename)
|
||||||
|
|
||||||
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 {
|
if _, err := os.Stat(dstPath); err == nil {
|
||||||
for i := 1; i < 100; i++ {
|
for i := 1; i < 100; i++ {
|
||||||
newFilename = fmt.Sprintf("%s_[%dx%d]_dark%.0f_(%d)%s",
|
newFilename = fmt.Sprintf("%s_(%d)%s", newUUID(), i, ext)
|
||||||
cleanName, wp.Width, wp.Height, wp.DarkScore*100, i, ext)
|
dstPath = filepath.Join(dir, newFilename)
|
||||||
dstPath = filepath.Join(targetDir, newFilename)
|
|
||||||
if _, err := os.Stat(dstPath); os.IsNotExist(err) {
|
if _, err := os.Stat(dstPath); os.IsNotExist(err) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -449,21 +580,3 @@ func moveFile(src, dst string) error {
|
|||||||
}
|
}
|
||||||
return os.Remove(src)
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user