Files
kenaito-media-picker/internal/probe/brightness.go
T
odboy a667f7fa35 fix: 重复文件归类、图片探测与明暗分析健壮性
- organize/store: 按 MD5 去重,重复图片/视频/音频统一移入「重复文件」目录,不再覆盖已有记录
- probe/image: webp/bmp/psd/psb 专用解码失败后走 ffprobe 兜底,与通用路径一致
- probe/brightness: 大图内存缩放为缩略图再分析明暗,超4000万像素跳过分析防OOM
- README: 补充重复文件目录说明,修正 Go 版本为 1.25.0
2025-04-12 21:02:16 +08:00

113 lines
3.3 KiB
Go

package probe
import (
"image"
"os"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
_ "golang.org/x/image/bmp"
"golang.org/x/image/draw"
_ "golang.org/x/image/tiff"
_ "golang.org/x/image/webp"
)
const (
// brightnessDecodeLimit 明暗分析允许解码的最大像素数,超出时跳过分析(按 mid 归类),避免超大图 OOM
brightnessDecodeLimit = 40_000_000
// thumbnailMaxEdge 明暗分析时大图缩放后的最长边像素,缩小到该尺寸内再统计
thumbnailMaxEdge = 512
// thumbnailScaleThreshold 像素数超过该值才缩放为缩略图再统计
thumbnailScaleThreshold = 1_000_000
)
// AnalyzeBrightness 打开并解码图片,分析明暗程度。
// darkScore: 暗色像素(亮度 < 80)占比;lightScore: 亮色像素(亮度 > 175)占比。
// 超大图(超过 brightnessDecodeLimit 像素)直接跳过分析返回 mid;大图先缩放为缩略图再统计,降低内存与耗时。
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()
cfg, _, err := image.DecodeConfig(f)
if err != nil {
return 0, 0, err
}
if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width*cfg.Height > brightnessDecodeLimit {
return 0, 0, nil // 超大图跳过,由调用方按 mid 归类
}
if _, err := f.Seek(0, 0); err != nil {
return 0, 0, err
}
img, _, err := image.Decode(f)
if err != nil {
return 0, 0, err
}
if img.Bounds().Dx()*img.Bounds().Dy() > thumbnailScaleThreshold {
img = scaleToThumbnail(img, thumbnailMaxEdge)
}
dark, light := analyzeBrightness(img)
return dark, light, nil
}
// scaleToThumbnail 将大图等比缩放到最长边不超过 maxEdge 的缩略图(纯内存,不落盘)。
// 缩放是像素平均,整体亮度分布基本保留,用于粗粒度明暗分类足够准确。
func scaleToThumbnail(img image.Image, maxEdge int) image.Image {
srcBounds := img.Bounds()
srcW, srcH := srcBounds.Dx(), srcBounds.Dy()
if srcW <= 0 || srcH <= 0 {
return img
}
ratio := float64(maxEdge) / float64(max(srcW, srcH))
dstW, dstH := int(float64(srcW)*ratio), int(float64(srcH)*ratio)
if dstW < 1 {
dstW = 1
}
if dstH < 1 {
dstH = 1
}
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
draw.BiLinear.Scale(dst, dst.Bounds(), img, srcBounds, draw.Over, nil)
return dst
}
// analyzeBrightness 统计暗色/亮色像素占比(ITU-R BT.601 亮度公式)。
// 完全透明的像素(alpha=0)不计入统计。
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
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
r, g, b, a := img.At(x, y).RGBA()
if a == 0 {
continue
}
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)
}