- feat: 新增续传表 scan_log,重扫跳过已归类文件(尺寸/头尾指纹校验) - fix: 色情识别记录批量入库超 SQLite 999 变量上限,改每 100 条一批 - perf: NudeNet 检测改为多线程推理池(每线程独立模型实例) - perf: 明暗分析 JPEG(YCbCr) 快路径;SQLite 读并发放开;加阶段耗时统计 - fix: 前端色情识别状态由后端如实上报(配置开关+检测服务是否起来) - feat: ffprobe 内嵌按平台拆分,Linux 构建回退系统 ffprobe,支持交叉编译 - docs: README 补充断点续传与 CentOS 交叉编译步骤 - chore: organize.workers 默认 8
201 lines
5.9 KiB
Go
201 lines
5.9 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 允许分析的最大像素数。超过就跳过分析(按中性归类),
|
|
// 防止超大图把内存吃爆。
|
|
brightnessDecodeLimit = 40_000_000
|
|
// thumbnailMaxEdge 大图缩放后的最长边像素。先缩小再统计,省内存省时间。
|
|
thumbnailMaxEdge = 256
|
|
// thumbnailScaleThreshold 像素数超过这个值才缩放成缩略图再统计。
|
|
thumbnailScaleThreshold = 250_000
|
|
)
|
|
|
|
// AnalyzeBrightness 打开一张图片,分析它偏暗还是偏亮。
|
|
// - darkScore:暗色像素(亮度 < 80)占多少比例
|
|
// - lightScore:亮色像素(亮度 > 175)占多少比例
|
|
//
|
|
// 超大图直接跳过(返回两个 0,调用方会按"中性"归类);
|
|
// 普通大图先缩小成缩略图再统计,既快又不失真(整体明暗分布基本保留)。
|
|
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 // 超大图,跳过
|
|
}
|
|
// 回到文件开头,重新完整解码
|
|
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)不算数。
|
|
// 为了快,常见的内存格式(NRGBA/RGBA/Gray)直接翻底层像素数组,
|
|
// 不常见的才用慢一点的通用方式。
|
|
func analyzeBrightness(img image.Image) (darkScore float64, lightScore float64) {
|
|
bounds := img.Bounds()
|
|
totalPixels := bounds.Dx() * bounds.Dy()
|
|
if totalPixels == 0 {
|
|
return 0, 0
|
|
}
|
|
|
|
var darkCount, lightCount, sampleCount int64
|
|
switch m := img.(type) {
|
|
case *image.NRGBA:
|
|
pix := m.Pix
|
|
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
|
offset := m.PixOffset(bounds.Min.X, y)
|
|
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
|
r, g, b, a := pix[offset], pix[offset+1], pix[offset+2], pix[offset+3]
|
|
if a == 0 {
|
|
offset += 4
|
|
continue
|
|
}
|
|
countBrightness(float64(r), float64(g), float64(b), &darkCount, &lightCount)
|
|
sampleCount++
|
|
offset += 4
|
|
}
|
|
}
|
|
case *image.RGBA:
|
|
pix := m.Pix
|
|
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
|
offset := m.PixOffset(bounds.Min.X, y)
|
|
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
|
// RGBA 是"预乘 alpha",完全不透明就用原值,
|
|
// 半透明的按比例还原出真实颜色分量再算亮度
|
|
a := pix[offset+3]
|
|
if a == 0 {
|
|
offset += 4
|
|
continue
|
|
}
|
|
r, g, b := pix[offset], pix[offset+1], pix[offset+2]
|
|
if a != 255 {
|
|
r = uint8(int(r) * 255 / int(a))
|
|
g = uint8(int(g) * 255 / int(a))
|
|
b = uint8(int(b) * 255 / int(a))
|
|
}
|
|
countBrightness(float64(r), float64(g), float64(b), &darkCount, &lightCount)
|
|
sampleCount++
|
|
offset += 4
|
|
}
|
|
}
|
|
case *image.Gray:
|
|
// 灰度图:像素本身就是亮度值
|
|
pix := m.Pix
|
|
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
|
offset := m.PixOffset(bounds.Min.X, y)
|
|
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
|
v := float64(pix[offset])
|
|
if v < 80 {
|
|
darkCount++
|
|
} else if v > 175 {
|
|
lightCount++
|
|
}
|
|
sampleCount++
|
|
offset++
|
|
}
|
|
}
|
|
case *image.YCbCr:
|
|
// JPEG 解码出来是 YCbCr:Y 平面就是亮度,直接翻数组。
|
|
// 这是最常见的照片格式,走这分支能避开 default 里逐像素 img.At().RGBA() 的慢路径。
|
|
pix := m.Y
|
|
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
|
offset := y*m.YStride + bounds.Min.X
|
|
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
|
v := float64(pix[offset])
|
|
if v < 80 {
|
|
darkCount++
|
|
} else if v > 175 {
|
|
lightCount++
|
|
}
|
|
sampleCount++
|
|
offset++
|
|
}
|
|
}
|
|
default:
|
|
// 其他格式:用通用的读法
|
|
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
|
|
}
|
|
countBrightness(float64(r>>8), float64(g>>8), float64(b>>8), &darkCount, &lightCount)
|
|
sampleCount++
|
|
}
|
|
}
|
|
}
|
|
|
|
if sampleCount == 0 {
|
|
return 0, 0
|
|
}
|
|
return float64(darkCount) / float64(sampleCount), float64(lightCount) / float64(sampleCount)
|
|
}
|
|
|
|
// countBrightness 按亮度公式(BT.601)判断一个像素偏暗还是偏亮,并计数。
|
|
func countBrightness(r, g, b float64, darkCount, lightCount *int64) {
|
|
brightness := 0.299*r + 0.587*g + 0.114*b
|
|
if brightness < 80 {
|
|
*darkCount++
|
|
} else if brightness > 175 {
|
|
*lightCount++
|
|
}
|
|
}
|