Files
kenaito-wallpaper-organizer/main.go
T
2024-06-08 21:13:51 +08:00

470 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"flag"
"fmt"
"image"
"image/color"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io/fs"
"log"
"math"
"os"
"path/filepath"
"sort"
"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,
".tiff": true, ".tif": true,
}
// WallpaperInfo 存储壁纸信息
type WallpaperInfo struct {
Path string
DarkScore float64 // 黑暗程度评分(0-1,越高越暗)
AvgColor color.RGBA
Size int64
Width int
Height int
}
// Config 配置结构
type Config struct {
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: ".",
MinWidth: 1920,
MinHeight: 1080,
MinSizeKB: 100,
MaxSizeMB: 100,
CopyMode: false,
Parallel: 24,
Verbose: true,
SleepSeconds: 5,
}
}
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
}
sizeKB := fileInfo.Size() / 1024
if sizeKB < config.MinSizeKB {
return nil, fmt.Errorf("文件太小: %dKB < %dKB", sizeKB, config.MinSizeKB)
}
if fileInfo.Size() > config.MaxSizeMB*1024*1024 {
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)
}
return &WallpaperInfo{
Path: path,
DarkScore: darkScore,
AvgColor: avgColor,
Size: fileInfo.Size(),
Width: width,
Height: height,
}, 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(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(targetDir, newFilename)
if _, err := os.Stat(dstPath); os.IsNotExist(err) {
break
}
}
}
if copyMode {
return copyFile(wp.Path, dstPath)
}
return moveFile(wp.Path, dstPath)
}
// copyFile 复制文件
func copyFile(src, dst string) error {
srcFile, err := os.Open(src)
if err != nil {
return err
}
defer srcFile.Close()
dstFile, err := os.Create(dst)
if err != nil {
return err
}
defer dstFile.Close()
_, err = dstFile.ReadFrom(srcFile)
return err
}
// moveFile 移动文件(优先 rename,跨分区时复制后删除)
func moveFile(src, dst string) error {
if err := os.Rename(src, dst); err == nil {
return nil
}
if err := copyFile(src, dst); err != nil {
return err
}
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
}