diff --git a/README.md b/README.md index 7f5e31d..ae950f4 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ - **标准分辨率归类**:720P、1080P、2K、2.5K、影院 2K、DCI 4K、8K 及带鱼屏(UltraWide 2K/3K/4K)等标准规格单独建目录 - **非标分辨率归类**:按像素区间(每 1024px 一档)自动归入 1K ~ 19K / 普通 / 原图(原画质)目录 - **方向区分**:横屏、竖屏、等比(正方形)分开整理,GIF 单独处理 +- **明暗分类**:基于 ITU-R BT.601 亮度公式分析每张图片明暗程度,暗色(亮度 < 80)像素占比为 darkScore、亮色(亮度 > 175)像素占比为 lightScore,按主导分在目录名末尾追加 `-dark_100` ~ `-dark_55` / `-bright_85` ~ `-bright_15` 明暗等级 - **音频归类**:音频文件单独归入「音频」目录 - **异常兜底**:读取失败、不支持的格式自动归入「读取异常」「已忽略」目录 - **自动整理**:无需人工干预,扫描 → 分析 → 移动一次完成,跨磁盘自动降级为复制后删除 @@ -54,26 +55,26 @@ kenaito-media-picker.exe ### 图片 ```text -{uid}-图片_横屏_720P ← 1280×720 -{uid}-图片_横屏_1D5KP ← 1600×900 -{uid}-图片_横屏_1080P ← 1920×1080 -{uid}-图片_横屏_2DKP ← 2048×1080(影院 2K) -{uid}-图片_横屏_2D5KP ← 2560×1440(2.5K) -{uid}-图片_横屏_ultraWide2KP ← 2560×1080(带鱼屏) -{uid}-图片_横屏_ultraWide3KP ← 3440×1440(带鱼屏) -{uid}-图片_横屏_ultraWide4KP ← 5120×2160(带鱼屏) -{uid}-图片_横屏_4KP ← 3840×2160 -{uid}-图片_横屏_4KHP ← 4096×2160(DCI 4K) -{uid}-图片_横屏_8KP ← 7680×4320 -{uid}-图片_横屏_1K ~ 19K ← 非标规格按像素区间(每 1024px 一档) -{uid}-图片_横屏_普通 ← 像素不足 1024 -{uid}-图片_横屏_原图 ← 像素 ≥ 20480 -{uid}-图片_竖屏_... ← 竖屏按同样规则(普通 / 1K~19K / 原图) -{uid}-图片_等比_... ← 等比(正方形)按同样规则 -{uid}-图片_GIF ← 所有 GIF 图片 -{uid}-图片_PSD ← 所有 PSD 文件 -{uid}-图片_读取异常 ← 无法读取尺寸的图片 -{uid}-图片_已忽略 ← 不支持格式的图片 +{uid}-图片_横屏_720P-dark_100 ← 1280×720,暗色主导 +{uid}-图片_横屏_1D5KP-dark_95 ← 1600×900 +{uid}-图片_横屏_1080P-bright_85 ← 1920×1080,亮色主导 +{uid}-图片_横屏_2DKP-dark_75 ← 2048×1080(影院 2K) +{uid}-图片_横屏_2D5KP-bright_65 ← 2560×1440(2.5K) +{uid}-图片_横屏_ultraWide2KP-dark_55 ← 2560×1080(带鱼屏) +{uid}-图片_横屏_ultraWide3KP-bright_45 ← 3440×1440(带鱼屏) +{uid}-图片_横屏_ultraWide4KP-dark_85 ← 5120×2160(带鱼屏) +{uid}-图片_横屏_4KP-bright_35 ← 3840×2160 +{uid}-图片_横屏_4KHP-dark_65 ← 4096×2160(DCI 4K) +{uid}-图片_横屏_8KP-bright_25 ← 7680×4320 +{uid}-图片_横屏_1K ~ 19K-bright_15 ← 非标规格按像素区间(每 1024px 一档) +{uid}-图片_横屏_普通-dark_100 ← 像素不足 1024 +{uid}-图片_横屏_原图-bright_85 ← 像素 ≥ 20480 +{uid}-图片_竖屏_... ← 竖屏按同样规则(普通 / 1K~19K / 原图) +{uid}-图片_等比_... ← 等比(正方形)按同样规则 +{uid}-图片_GIF ← 所有 GIF 图片 +{uid}-图片_PSD ← 所有 PSD 文件 +{uid}-图片_读取异常 ← 无法读取尺寸的图片 +{uid}-图片_已忽略 ← 不支持格式的图片 ``` ### 视频 / 音频 @@ -92,6 +93,16 @@ kenaito-media-picker.exe > 视频时长与分辨率通过内嵌 ffprobe 读取(处理时会临时释放到当前目录、完成后自动删除);凡 ffmpeg 支持的视频/音频格式均可识别。 +## 明暗评分算法 + +```text +brightness = 0.299×R + 0.587×G + 0.114×B (ITU-R BT.601) +darkScore = 亮度 < 80 的像素占比 +lightScore = 亮度 > 175 的像素占比 +``` + +亮色主导(`lightScore > darkScore`)归 `-bright_N`,否则归 `-dark_N`;每张图必归亮暗之一。暗色档位 55/65/75/85/95/100,亮色档位 15/25/35/45/55/65/75/85,取评分命中的最高一档(不足最低档归最低档)。大图(>100 万像素)自动采样加速。 + ## 目录结构 ```text @@ -99,7 +110,7 @@ kenaito-media-picker.exe ├── internal/ │ ├── scanner/ # 文件扫描、MIME 读取、图片/媒体候选过滤 │ ├── probe/ # 媒体探测:图片尺寸 + ffprobe 解析 + 内嵌解码器释放 -│ ├── classify/ # 纯函数归类规则(方向 + 标准分辨率 + K档) +│ ├── classify/ # 纯函数归类规则(方向 + 标准分辨率 + K档 + 明暗等级) │ ├── organize/ # 整理编排:探测→归类→收集→移动→记录去向 │ ├── mover/ # 文件移动(rename / 跨盘 copy+delete) │ ├── store/ # SQLite 媒体信息持久化 diff --git a/internal/classify/brightness.go b/internal/classify/brightness.go new file mode 100644 index 0000000..3bcdebe --- /dev/null +++ b/internal/classify/brightness.go @@ -0,0 +1,35 @@ +package classify + +import "fmt" + +// 暗色阈值等级(从高到低) +var darkThresholds = []int{100, 95, 85, 75, 65, 55} + +// 亮色阈值等级(从高到低) +var brightThresholds = []int{85, 75, 65, 55, 45, 35, 25, 15} + +// BrightnessName 按主导分判定明暗等级,返回如 "dark_100" / "bright_85"。 +// 亮色主导(lightScore > darkScore)归 bright,否则归 dark,每张图必归亮暗之一。 +func BrightnessName(darkScore, lightScore float64) string { + darkLowest := darkThresholds[len(darkThresholds)-1] + brightLowest := brightThresholds[len(brightThresholds)-1] + + if lightScore > darkScore { + picked := brightLowest + for _, t := range brightThresholds { + if lightScore >= float64(t)/100.0 { + picked = t + break + } + } + return fmt.Sprintf("bright_%d", picked) + } + picked := darkLowest + for _, t := range darkThresholds { + if darkScore >= float64(t)/100.0 { + picked = t + break + } + } + return fmt.Sprintf("dark_%d", picked) +} diff --git a/internal/classify/classify.go b/internal/classify/classify.go index 2a1c8f9..fb6d066 100644 --- a/internal/classify/classify.go +++ b/internal/classify/classify.go @@ -35,12 +35,14 @@ func (o Orientation) String() string { } } -// ImageDir 返回图片归类子目录名,如 "图片_横屏_1080P" / "图片_GIF" -func ImageDir(ext string, width, height int) string { +// ImageDir 返回图片归类子目录名,如 "图片_横屏_1080P-dark_100" / "图片_GIF"。 +// darkScore/lightScore 用于明暗分类(GIF 不做明暗分类,忽略这两项)。 +func ImageDir(ext string, width, height int, darkScore, lightScore float64) string { if strings.EqualFold(ext, ".gif") { return "图片_GIF" } - return resolutionDir("图片", ext, width, height) + dir := resolutionDir("图片", ext, width, height) + return dir + "-" + BrightnessName(darkScore, lightScore) } // VideoDir 返回视频归类子目录名,如 "视频_竖屏_4k" / "视频_横屏_GIF" diff --git a/internal/organize/organize.go b/internal/organize/organize.go index f8caef6..e747715 100644 --- a/internal/organize/organize.go +++ b/internal/organize/organize.go @@ -129,7 +129,16 @@ func (o *Organizer) ProcessImages(images []scanner.File) { continue } o.record(f, "image", res) - o.add(f, classify.ImageDir(f.Ext, res.Width, res.Height)) + darkScore, lightScore := 0.0, 0.0 + if !strings.EqualFold(f.Ext, ".gif") { + dark, light, berr := probe.AnalyzeBrightness(f.Path) + if berr != nil { + fmt.Printf("=== 明暗分析失败(按默认dark_55归类): %s \n", f.Path) + } else { + darkScore, lightScore = dark, light + } + } + o.add(f, classify.ImageDir(f.Ext, res.Width, res.Height, darkScore, lightScore)) success++ } fmt.Printf("=== 图片处理完毕,成功: %d, 读取异常: %d, 忽略: %d \n\n", success, fail, ignore) diff --git a/internal/probe/brightness.go b/internal/probe/brightness.go new file mode 100644 index 0000000..dcfc863 --- /dev/null +++ b/internal/probe/brightness.go @@ -0,0 +1,67 @@ +package probe + +import ( + "image" + "math" + "os" + + _ "image/gif" + _ "image/jpeg" + _ "image/png" + + _ "golang.org/x/image/bmp" + _ "golang.org/x/image/tiff" + _ "golang.org/x/image/webp" +) + +// AnalyzeBrightness 打开并解码图片,分析明暗程度。 +// darkScore: 暗色像素(亮度 < 80)占比;lightScore: 亮色像素(亮度 > 175)占比。 +func AnalyzeBrightness(filePath string) (darkScore float64, lightScore float64, err error) { + f, err := os.Open(filePath) + if err != nil { + return 0, 0, err + } + defer f.Close() + img, _, err := image.Decode(f) + if err != nil { + return 0, 0, err + } + dark, light := analyzeBrightness(img) + return dark, light, nil +} + +// analyzeBrightness 统计暗色/亮色像素占比(ITU-R BT.601 亮度公式),大图自动采样加速 +func analyzeBrightness(img image.Image) (darkScore float64, lightScore float64) { + bounds := img.Bounds() + totalPixels := bounds.Dx() * bounds.Dy() + if totalPixels == 0 { + return 0, 0 + } + + darkCount, lightCount, sampleCount := 0, 0, 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) + 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 + } + return float64(darkCount) / float64(sampleCount), float64(lightCount) / float64(sampleCount) +}