feat: 色情图片识别(NudeNet);明暗档位改为5档(20%~100%)
This commit is contained in:
@@ -1 +1,2 @@
|
|||||||
.idea/
|
.idea/
|
||||||
|
media-info.db
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
- **非标分辨率归类**:按像素区间(每 1024px 一档)自动归入 1K ~ 19K / 普通 / 原图(原画质)目录
|
- **非标分辨率归类**:按像素区间(每 1024px 一档)自动归入 1K ~ 19K / 普通 / 原图(原画质)目录
|
||||||
- **方向区分**:横屏、竖屏、等比(正方形)分开整理,GIF 单独处理
|
- **方向区分**:横屏、竖屏、等比(正方形)分开整理,GIF 单独处理
|
||||||
- **EXIF 旋转修正**:手机/相机竖拍的图(文件里横着存、带 EXIF 旋转标记)自动识别并转正——找到 ffmpeg 时用 ffmpeg 把像素真正转正;找不到时至少按"显示方向"(宽高对调)归类,不会再误入横屏目录
|
- **EXIF 旋转修正**:手机/相机竖拍的图(文件里横着存、带 EXIF 旋转标记)自动识别并转正——找到 ffmpeg 时用 ffmpeg 把像素真正转正;找不到时至少按"显示方向"(宽高对调)归类,不会再误入横屏目录
|
||||||
- **明暗分类**:基于 ITU-R BT.601 亮度公式分析每张图片明暗程度,暗色(亮度 < 80)像素占比为 darkScore、亮色(亮度 > 175)像素占比为 lightScore;以两者差幅判定亮暗主导,在分辨率目录下追加 `dark_N` / `mid` / `bright_N` 子目录(N 为占比四舍五入到 10% 的档位)
|
- **明暗分类**:基于 ITU-R BT.601 亮度公式分析每张图片明暗程度,暗色(亮度 < 80)像素占比为 darkScore、亮色(亮度 > 175)像素占比为 lightScore;以两者差幅判定亮暗主导,在分辨率目录下追加 `dark_N` / `mid` / `bright_N` 子目录(N 为占比四舍五入到 20% 的档位,20/40/60/80/100 共 5 档)
|
||||||
- **音频归类**:音频文件单独归入「音频」目录
|
- **音频归类**:音频文件单独归入「音频」目录
|
||||||
- **异常兜底**:读取失败、不支持的格式自动归入「读取异常」「已忽略」目录
|
- **异常兜底**:读取失败、不支持的格式自动归入「读取异常」「已忽略」目录
|
||||||
- **自动整理**:无需人工干预,扫描 → 分析 → 移动一次完成,跨磁盘自动降级为复制后删除
|
- **自动整理**:无需人工干预,扫描 → 分析 → 移动一次完成,跨磁盘自动降级为复制后删除
|
||||||
@@ -104,7 +104,7 @@ npm install && npm run dev
|
|||||||
{uid}-图片_横屏_720P/dark_100 ← 1280×720,暗色主导
|
{uid}-图片_横屏_720P/dark_100 ← 1280×720,暗色主导
|
||||||
{uid}-图片_横屏_1D5KP/mid ← 1600×900,明暗均衡
|
{uid}-图片_横屏_1D5KP/mid ← 1600×900,明暗均衡
|
||||||
{uid}-图片_横屏_1080P/bright_100 ← 1920×1080,亮色主导
|
{uid}-图片_横屏_1080P/bright_100 ← 1920×1080,亮色主导
|
||||||
{uid}-图片_横屏_2DKP/dark_70 ← 2048×1080(影院 2K)
|
{uid}-图片_横屏_2DKP/dark_80 ← 2048×1080(影院 2K)
|
||||||
{uid}-图片_竖屏_... ← 竖屏按同样规则(普通 / 1K~19K / 原图)
|
{uid}-图片_竖屏_... ← 竖屏按同样规则(普通 / 1K~19K / 原图)
|
||||||
{uid}-图片_等比_... ← 等比(正方形)按同样规则
|
{uid}-图片_等比_... ← 等比(正方形)按同样规则
|
||||||
{uid}-图片_GIF ← 所有 GIF 图片
|
{uid}-图片_GIF ← 所有 GIF 图片
|
||||||
@@ -146,7 +146,7 @@ lightScore = 亮度 > 175 的像素占比
|
|||||||
```
|
```
|
||||||
|
|
||||||
- **判定规则**:`lightScore` 高于 `darkScore` 15 个百分点以上归 `bright_N` 子目录,反之归 `dark_N` 子目录;差幅不足 15% 视为中性归 `mid`(不强制二选一),明暗名作为分辨率目录下的子目录(如 `图片_横屏_1080P/bright_20`)
|
- **判定规则**:`lightScore` 高于 `darkScore` 15 个百分点以上归 `bright_N` 子目录,反之归 `dark_N` 子目录;差幅不足 15% 视为中性归 `mid`(不强制二选一),明暗名作为分辨率目录下的子目录(如 `图片_横屏_1080P/bright_20`)
|
||||||
- **档位命名**:N 为占比四舍五入到 10% 的档位(10~100),如 darkScore 0.70 → `dark_70`,表示占比命中 [N-5%, N+5%) 区间
|
- **档位命名**:N 为占比四舍五入到 20% 的档位(20~100,共 5 档),如 darkScore 0.70 → `dark_80`,表示占比命中 [N-10%, N+10%) 区间
|
||||||
- **透明处理**:完全透明的像素(alpha=0)不计入统计,全透明图片归 `-mid`
|
- **透明处理**:完全透明的像素(alpha=0)不计入统计,全透明图片归 `-mid`
|
||||||
- **性能**:大图(>100 万像素)先等比缩放为最长边 512px 的缩略图(纯内存)再统计明暗,内存占用与耗时大幅下降;超 4000 万像素的图片直接跳过明暗分析(按中性 mid 归类)避免 OOM
|
- **性能**:大图(>100 万像素)先等比缩放为最长边 512px 的缩略图(纯内存)再统计明暗,内存占用与耗时大幅下降;超 4000 万像素的图片直接跳过明暗分析(按中性 mid 归类)避免 OOM
|
||||||
|
|
||||||
@@ -163,6 +163,7 @@ lightScore = 亮度 > 175 的像素占比
|
|||||||
│ ├── store/ # SQLite 媒体信息持久化 + 查询/统计
|
│ ├── store/ # SQLite 媒体信息持久化 + 查询/统计
|
||||||
│ ├── service/ # 后台任务编排(扫描整理任务 + 状态/日志)
|
│ ├── service/ # 后台任务编排(扫描整理任务 + 状态/日志)
|
||||||
│ ├── config/ # Web 服务配置加载(application.properties)
|
│ ├── config/ # Web 服务配置加载(application.properties)
|
||||||
|
│ ├── nsfw/ # 色情图片识别(本机 python + NudeNet 本地服务)
|
||||||
│ ├── api/ # HTTP 接口层(统一响应 / 媒体列表 / 统计 / 扫描触发)
|
│ ├── api/ # HTTP 接口层(统一响应 / 媒体列表 / 统计 / 扫描触发)
|
||||||
│ └── digest/ # 文件 MD5 / 指纹采样
|
│ └── digest/ # 文件 MD5 / 指纹采样
|
||||||
├── frontend/ # 前端工程(Vue 2 + ElementUI + Vite,dist 内嵌进程序)
|
├── frontend/ # 前端工程(Vue 2 + ElementUI + Vite,dist 内嵌进程序)
|
||||||
@@ -181,6 +182,16 @@ lightScore = 亮度 > 175 的像素占比
|
|||||||
- ffprobe 内嵌(Windows),运行前释放到当前目录、结束后自动删除
|
- ffprobe 内嵌(Windows),运行前释放到当前目录、结束后自动删除
|
||||||
- EXIF 旋转转正需要 ffmpeg:配置 `ffmpeg.path` 指定路径,或让 ffmpeg 在 PATH 里;找不到 ffmpeg 时不转正像素,仍按显示方向归类。可用 `organize.rotateImages=false` 关闭
|
- EXIF 旋转转正需要 ffmpeg:配置 `ffmpeg.path` 指定路径,或让 ffmpeg 在 PATH 里;找不到 ffmpeg 时不转正像素,仍按显示方向归类。可用 `organize.rotateImages=false` 关闭
|
||||||
|
|
||||||
|
## 色情图片识别(可选)
|
||||||
|
|
||||||
|
基于本机 **Python + NudeNet** 的离线鉴黄,判为色情的图片移入 `图片_敏感_*` 分类目录(明暗子目录照常保留,如 `图片_敏感_横屏_1080P/bright_20`),便于事后人工复核。
|
||||||
|
|
||||||
|
- **开启**:`application.properties` 里设 `nsfw.enabled=true`(默认关闭)
|
||||||
|
- **依赖**:本机安装 Python(>=3.6)并 `pip install nudenet`(包自带 320n 模型,无需手动下载)
|
||||||
|
- **可调**:`nsfw.python`(python 路径,留空自动在 PATH 找)、`nsfw.threshold`(判定阈值,默认 0.5)
|
||||||
|
- **敏感类**:`FEMALE_GENITALIA_EXPOSED` / `MALE_GENITALIA_EXPOSED` / `ANUS_EXPOSED` / `FEMALE_BREAST_EXPOSED`,任一分数达标即判定
|
||||||
|
- **降级**:未装 python / nudenet 时打印警告并跳过识别,图片按普通流程整理,不影响其它功能
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Apache 2.0
|
Apache 2.0
|
||||||
|
|||||||
@@ -11,3 +11,10 @@ ffmpeg.path=
|
|||||||
organize.rotateImages=true
|
organize.rotateImages=true
|
||||||
# 探测/认类型/移动阶段的并发 worker 数(默认 4;0 表示用满全部 CPU 核数)
|
# 探测/认类型/移动阶段的并发 worker 数(默认 4;0 表示用满全部 CPU 核数)
|
||||||
organize.workers=4
|
organize.workers=4
|
||||||
|
# 色情图片识别(需本机安装 python 与 nudenet:pip install nudenet):
|
||||||
|
# true=开启,判为色情的图片移入「图片_敏感_*」目录(明暗子目录照常);false=关闭(默认)
|
||||||
|
nsfw.enabled=false
|
||||||
|
# python 可执行文件路径;留空自动在 PATH 里找 python/python3/py
|
||||||
|
nsfw.python=
|
||||||
|
# 判定阈值 0~1,敏感部位检测分高于此才算色情(默认 0.5)
|
||||||
|
nsfw.threshold=0.5
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ if [ -d frontend ] && [ -f frontend/package.json ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# 主程序(Web 服务 + 内嵌前端,页面点击触发扫描整理)
|
# 主程序(Web 服务 + 内嵌前端,页面点击触发扫描整理)
|
||||||
rm -f kenaito-media-picker.exe kenaito-media-picker-release.exe
|
#rm -f kenaito-media-picker.exe kenaito-media-picker-release.exe
|
||||||
go build -o kenaito-media-picker-release.exe .
|
go build -o kenaito-media-picker-release.exe .
|
||||||
./upx -o kenaito-media-picker.exe kenaito-media-picker-release.exe
|
#./upx -o kenaito-media-picker.exe kenaito-media-picker-release.exe
|
||||||
rm -f kenaito-media-picker-release.exe
|
#rm -f kenaito-media-picker-release.exe
|
||||||
|
|
||||||
echo "=== 构建完成"
|
echo "=== 构建完成"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
<li>与库中已有记录 <b>MD5 相同的重复文件</b>会被移动到「重复文件」目录</li>
|
<li>与库中已有记录 <b>MD5 相同的重复文件</b>会被移动到「重复文件」目录</li>
|
||||||
<li>无法读取/不支持的格式会被移动到「读取异常」「已忽略」目录</li>
|
<li>无法读取/不支持的格式会被移动到「读取异常」「已忽略」目录</li>
|
||||||
<li>扫描会自动跳过 <b>KMP已整理</b> 输出目录、<b>media-info.db</b> 数据库与 ffprobe 临时文件</li>
|
<li>扫描会自动跳过 <b>KMP已整理</b> 输出目录、<b>media-info.db</b> 数据库与 ffprobe 临时文件</li>
|
||||||
|
<li>若已配置开启色情识别(本机需装 <b>python + nudenet</b>),判为色情的图片会移入「图片_敏感」分类目录,可事后人工复核</li>
|
||||||
<li>整理期间请保持服务运行,结束后可在「媒体库」查看结果</li>
|
<li>整理期间请保持服务运行,结束后可在「媒体库」查看结果</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ import "fmt"
|
|||||||
const neutralMargin = 0.15
|
const neutralMargin = 0.15
|
||||||
|
|
||||||
// BrightnessName 根据暗色/亮色像素占比,给图片定"明暗等级",返回类似:
|
// BrightnessName 根据暗色/亮色像素占比,给图片定"明暗等级",返回类似:
|
||||||
// - "dark_70" → 暗色像素占 65%~75% 的暗图
|
// - "dark_80" → 暗色像素占 70%~90% 的暗图
|
||||||
// - "bright_40" → 亮色像素占 35%~45% 的亮图
|
// - "bright_40" → 亮色像素占 30%~50% 的亮图
|
||||||
// - "mid" → 明暗差不多,不好说谁多,归中性
|
// - "mid" → 明暗差不多,不好说谁多,归中性
|
||||||
//
|
//
|
||||||
// 判断规则(大白话):谁占得多、而且多出明显一截(超过 neutralMargin),
|
// 判断规则(大白话):谁占得多、而且多出明显一截(超过 neutralMargin),
|
||||||
// 就按谁命名,数字是占比取整到 10% 一档。
|
// 就按谁命名,数字是占比取整到 20% 一档。
|
||||||
func BrightnessName(darkScore, lightScore float64) string {
|
func BrightnessName(darkScore, lightScore float64) string {
|
||||||
switch {
|
switch {
|
||||||
case lightScore > darkScore+neutralMargin:
|
case lightScore > darkScore+neutralMargin:
|
||||||
@@ -24,13 +24,13 @@ func BrightnessName(darkScore, lightScore float64) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// brightnessTier 把占比(0~1)取整到"最接近的 10%"作为档位数字,范围 10~100。
|
// brightnessTier 把占比(0~1)取整到"最接近的 20%"作为档位数字,范围 20~100。
|
||||||
// 例:0.30~0.39 → "30";0.96 以上 → "100"。
|
// 例:0.30~0.50 → "40";0.96 以上 → "100"。
|
||||||
// 这样文件夹名里的数字不会出现"明明很少却挂个大数字"的情况。
|
// 这样文件夹名里的数字不会出现"明明很少却挂个大数字"的情况。
|
||||||
func brightnessTier(score float64) int {
|
func brightnessTier(score float64) int {
|
||||||
tier := int(score*100+5) / 10 * 10
|
tier := int(score*100+10) / 20 * 20
|
||||||
if tier < 10 {
|
if tier < 20 {
|
||||||
return 10
|
return 20
|
||||||
}
|
}
|
||||||
if tier > 100 {
|
if tier > 100 {
|
||||||
return 100
|
return 100
|
||||||
|
|||||||
@@ -58,10 +58,22 @@ func (o Orientation) String() string {
|
|||||||
// - 「图片_GIF」:GIF 动图单独放(不做明暗分类)
|
// - 「图片_GIF」:GIF 动图单独放(不做明暗分类)
|
||||||
// - 「图片_横屏_1080P/bright_20」:普通图片 = 方向 + 清晰度,明暗作为其子目录
|
// - 「图片_横屏_1080P/bright_20」:普通图片 = 方向 + 清晰度,明暗作为其子目录
|
||||||
func ImageDir(ext string, width, height int, darkScore, lightScore float64) string {
|
func ImageDir(ext string, width, height int, darkScore, lightScore float64) string {
|
||||||
if strings.EqualFold(ext, ".gif") {
|
return imageDir("图片", ext, width, height, darkScore, lightScore)
|
||||||
return "图片_GIF"
|
|
||||||
}
|
}
|
||||||
dir := resolutionDir("图片", ext, width, height)
|
|
||||||
|
// ImageDirSensitive 返回一张被判为色情的图片该进的文件夹路径,比如:
|
||||||
|
// - 「图片_敏感_GIF」:GIF 单独放(不做明暗分类)
|
||||||
|
// - 「图片_敏感_横屏_1080P/bright_20」:普通分类前加「_敏感」前缀,明暗仍是子目录
|
||||||
|
func ImageDirSensitive(ext string, width, height int, darkScore, lightScore float64) string {
|
||||||
|
return imageDir("图片_敏感", ext, width, height, darkScore, lightScore)
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageDir 拼出"类型 + 方向 + 清晰度 + 明暗子目录"这段路径。
|
||||||
|
func imageDir(kind, ext string, width, height int, darkScore, lightScore float64) string {
|
||||||
|
if strings.EqualFold(ext, ".gif") {
|
||||||
|
return kind + "_GIF"
|
||||||
|
}
|
||||||
|
dir := resolutionDir(kind, ext, width, height)
|
||||||
return dir + "/" + BrightnessName(darkScore, lightScore)
|
return dir + "/" + BrightnessName(darkScore, lightScore)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ import (
|
|||||||
// - FFmpegPath:ffmpeg 程序路径(用于把带 EXIF 旋转标记的图片转正);留空自动在 PATH 里找
|
// - FFmpegPath:ffmpeg 程序路径(用于把带 EXIF 旋转标记的图片转正);留空自动在 PATH 里找
|
||||||
// - RotateImages:整理时是否自动转正带 EXIF 旋转标记的图片(默认 true)
|
// - RotateImages:整理时是否自动转正带 EXIF 旋转标记的图片(默认 true)
|
||||||
// - Workers:探测/MIME/移动阶段的并发 worker 数(默认 4;设 0 表示用满全部 CPU 核数)
|
// - Workers:探测/MIME/移动阶段的并发 worker 数(默认 4;设 0 表示用满全部 CPU 核数)
|
||||||
|
// - NSFWEnabled:是否开启色情图片识别(本地 python + NudeNet,默认 false)
|
||||||
|
// - NSFWPython:python 可执行文件路径;留空自动在 PATH 里找
|
||||||
|
// - NSFWThreshold:判定阈值 0~1(默认 0.5)
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Port int
|
Port int
|
||||||
Dsn string
|
Dsn string
|
||||||
@@ -26,6 +29,9 @@ type Config struct {
|
|||||||
FFmpegPath string
|
FFmpegPath string
|
||||||
RotateImages bool
|
RotateImages bool
|
||||||
Workers int
|
Workers int
|
||||||
|
NSFWEnabled bool
|
||||||
|
NSFWPython string
|
||||||
|
NSFWThreshold float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// appConfig 当前生效的配置(程序运行期间一直不变)。
|
// appConfig 当前生效的配置(程序运行期间一直不变)。
|
||||||
@@ -39,7 +45,7 @@ func GetAppConfig() Config {
|
|||||||
// LoadAppConfig 从 application.properties 读配置;文件不存在就用默认值。
|
// LoadAppConfig 从 application.properties 读配置;文件不存在就用默认值。
|
||||||
func LoadAppConfig() {
|
func LoadAppConfig() {
|
||||||
// 先给一套默认值
|
// 先给一套默认值
|
||||||
appConfig = Config{Port: 8080, Dsn: "media-info.db", AutoOpen: true, RotateImages: true, Workers: 4}
|
appConfig = Config{Port: 8080, Dsn: "media-info.db", AutoOpen: true, RotateImages: true, Workers: 4, NSFWThreshold: 0.5}
|
||||||
|
|
||||||
p, err := properties.LoadFile("application.properties", properties.UTF8)
|
p, err := properties.LoadFile("application.properties", properties.UTF8)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -64,6 +70,16 @@ func LoadAppConfig() {
|
|||||||
appConfig.Workers = n
|
appConfig.Workers = n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 色情识别:默认关;python 路径留空自动在 PATH 找;阈值只认 0~1 之间的小数
|
||||||
|
appConfig.NSFWEnabled = p.GetBool("nsfw.enabled", false)
|
||||||
|
if v, ok := p.Get("nsfw.python"); ok && v != "" {
|
||||||
|
appConfig.NSFWPython = v
|
||||||
|
}
|
||||||
|
if v, ok := p.Get("nsfw.threshold"); ok && strings.TrimSpace(v) != "" {
|
||||||
|
if t, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil && t > 0 && t < 1 {
|
||||||
|
appConfig.NSFWThreshold = t
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WorkerCount 返回探测/MIME/移动阶段使用的并发 worker 数:
|
// WorkerCount 返回探测/MIME/移动阶段使用的并发 worker 数:
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
// Package nsfw 负责"色情图片识别":本机 Python + NudeNet 起一个本地 HTTP 服务,
|
||||||
|
// Go 侧把图片路径发给它,拿到"是否色情"和最高敏感分。
|
||||||
|
//
|
||||||
|
// 大白话理解:NudeNet 是开源里口碑不错的离线鉴黄模型(python 库,自带模型)。
|
||||||
|
// 我们不想让 Go 里掺一堆深度学习依赖,所以让 python 当"外援":
|
||||||
|
// - 扫描开始时拉起一个常驻的 python 小服务(模型只加载一次);
|
||||||
|
// - 探测图片时逐张把路径 POST 给它;
|
||||||
|
// - 扫描结束把服务停掉、清理临时脚本。
|
||||||
|
//
|
||||||
|
// 机器上没有 python 或没装 nudenet 时,Start 会报错,调用方跳过识别、
|
||||||
|
// 走默认整理流程——这个功能是纯附加的,不影响其它步骤。
|
||||||
|
package nsfw
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "embed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 内嵌的 python 服务脚本:用标准库起 HTTP 服务,负责加载模型、接收路径、返回结果。
|
||||||
|
// 不依赖 flask/fastapi,省去额外装包。
|
||||||
|
//
|
||||||
|
//go:embed server.py
|
||||||
|
var serverScript []byte
|
||||||
|
|
||||||
|
// 敏感类集合:检测到这些部位"暴露"且分数达标才算色情。
|
||||||
|
// 默认只取最明确的四类(生殖器/肛/女性乳房),避免把泳装、露背照误判。
|
||||||
|
var sensitiveClasses = map[string]bool{
|
||||||
|
"FEMALE_GENITALIA_EXPOSED": true,
|
||||||
|
"MALE_GENITALIA_EXPOSED": true,
|
||||||
|
"ANUS_EXPOSED": true,
|
||||||
|
"FEMALE_BREAST_EXPOSED": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectorStartTimeout 等待 python 服务就绪(模型加载)的超时时间。
|
||||||
|
// 首次加载模型可能要十几秒,给足余量。
|
||||||
|
const detectorStartTimeout = 60 * time.Second
|
||||||
|
|
||||||
|
// requestTimeout 单张图片检测的超时时间。检测很快(毫秒级),
|
||||||
|
// 万一卡住(模型没加载好/图太大)不至于拖死整个扫描。
|
||||||
|
const requestTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
// Detector 封装一个本地 python 服务的客户端。
|
||||||
|
// 多个探测协程会并发调 Detect,底层 HTTP 客户端线程安全;
|
||||||
|
// 服务端单线程处理,请求自己排队,不用额外加锁。
|
||||||
|
type Detector struct {
|
||||||
|
url string // 本地服务地址,如 http://127.0.0.1:54321
|
||||||
|
threshold float64 // 判定阈值 0~1
|
||||||
|
client *http.Client
|
||||||
|
cmd *exec.Cmd // python 进程句柄(Stop 时杀掉)
|
||||||
|
scriptDir string // 临时脚本所在目录(Stop 时清理)
|
||||||
|
mu sync.Mutex // 保护下面的关闭状态
|
||||||
|
stopped bool // 已经 Stop 过了
|
||||||
|
cancel context.CancelFunc
|
||||||
|
done chan struct{} // 进程退出时关闭(Wait 协程负责)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start 拉起本地 python 检测服务并等它就绪。
|
||||||
|
// - pythonPath:python 可执行文件路径;为空时在 PATH 里找 python/python3。
|
||||||
|
// - threshold:判定阈值 0~1。
|
||||||
|
//
|
||||||
|
// 找不到 python、装不了 nudenet、或服务起不来时返回错误(调用方跳过识别即可)。
|
||||||
|
func Start(pythonPath string, threshold float64) (*Detector, error) {
|
||||||
|
if threshold <= 0 || threshold >= 1 {
|
||||||
|
threshold = 0.5 // 阈值传得不合理就用默认
|
||||||
|
}
|
||||||
|
// 找 python
|
||||||
|
py := resolvePython(pythonPath)
|
||||||
|
if py == "" {
|
||||||
|
return nil, fmt.Errorf("未找到 python,敏感识别不可用")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先把内嵌的 server.py 写到临时目录
|
||||||
|
tmpDir, err := os.MkdirTemp("", "kmp-nsfw-*")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("创建临时目录失败: %v", err)
|
||||||
|
}
|
||||||
|
scriptPath := filepath.Join(tmpDir, "server.py")
|
||||||
|
if err := os.WriteFile(scriptPath, serverScript, 0644); err != nil {
|
||||||
|
_ = os.RemoveAll(tmpDir)
|
||||||
|
return nil, fmt.Errorf("写入检测脚本失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 找一个空闲端口
|
||||||
|
port, err := freePort()
|
||||||
|
if err != nil {
|
||||||
|
_ = os.RemoveAll(tmpDir)
|
||||||
|
return nil, fmt.Errorf("找空闲端口失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拉起服务。标准输出/错误接到程序自身的输出,方便看 python 报错。
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cmd := exec.CommandContext(ctx, py, scriptPath, fmt.Sprintf("%d", port))
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
cancel()
|
||||||
|
_ = os.RemoveAll(tmpDir)
|
||||||
|
return nil, fmt.Errorf("启动检测服务失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
d := &Detector{
|
||||||
|
url: fmt.Sprintf("http://127.0.0.1:%d", port),
|
||||||
|
threshold: threshold,
|
||||||
|
client: &http.Client{Timeout: requestTimeout},
|
||||||
|
cmd: cmd,
|
||||||
|
scriptDir: tmpDir,
|
||||||
|
cancel: cancel,
|
||||||
|
done: done,
|
||||||
|
}
|
||||||
|
// 后台等进程退出:ProcessState 要 Wait 之后才有值,这里用 done 通知,
|
||||||
|
// 这样 waitReady 能及时发现"python 没装 nudenet 直接退掉"的情况。
|
||||||
|
go func() {
|
||||||
|
_ = cmd.Wait()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 等服务就绪(模型加载完成)。就绪前先探测 /health。
|
||||||
|
if err := d.waitReady(); err != nil {
|
||||||
|
d.Stop()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitReady 轮询本地服务,直到能正常应答。
|
||||||
|
func (d *Detector) waitReady() error {
|
||||||
|
deadline := time.Now().Add(detectorStartTimeout)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
// 服务进程提前退了(多半是 python 没装 nudenet)直接报错
|
||||||
|
select {
|
||||||
|
case <-d.done:
|
||||||
|
return fmt.Errorf("检测服务提前退出,请确认已安装 nudenet (pip install nudenet)")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(http.MethodGet, d.url+"/health", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := d.client.Do(req)
|
||||||
|
if err == nil {
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("检测服务在 %v 内未就绪", detectorStartTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect 检测一张图片是否色情。返回:
|
||||||
|
// - score:命中的敏感类里最高分数(没有命中为 0)
|
||||||
|
// - nsfw:是否判定为色情(有敏感类且分数 >= 阈值)
|
||||||
|
// - err:检测失败(服务挂了/图读不出等),由调用方兜底按未命中处理
|
||||||
|
func (d *Detector) Detect(path string) (score float64, nsfw bool, err error) {
|
||||||
|
body, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"path": path,
|
||||||
|
"threshold": d.threshold,
|
||||||
|
})
|
||||||
|
resp, err := d.client.Post(d.url+"/detect", "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, err
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Ok bool `json:"ok"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
Nsfw bool `json:"nsfw"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &out); err != nil {
|
||||||
|
return 0, false, fmt.Errorf("解析检测结果失败: %v", err)
|
||||||
|
}
|
||||||
|
if !out.Ok {
|
||||||
|
return 0, false, fmt.Errorf("检测服务返回错误: %s", out.Error)
|
||||||
|
}
|
||||||
|
return out.Score, out.Nsfw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop 停掉检测服务并清理临时文件。可以安全地重复调用。
|
||||||
|
func (d *Detector) Stop() {
|
||||||
|
d.mu.Lock()
|
||||||
|
if d.stopped {
|
||||||
|
d.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.stopped = true
|
||||||
|
d.mu.Unlock()
|
||||||
|
|
||||||
|
if d.cancel != nil {
|
||||||
|
d.cancel()
|
||||||
|
}
|
||||||
|
if d.cmd != nil && d.cmd.Process != nil {
|
||||||
|
// 进程活着才 kill;kill 失败也无所谓,服务很快就没了
|
||||||
|
_ = d.cmd.Process.Kill()
|
||||||
|
}
|
||||||
|
if d.done != nil {
|
||||||
|
// 等 Wait 协程回收进程,避免僵尸进程;进程已死则立刻返回
|
||||||
|
<-d.done
|
||||||
|
}
|
||||||
|
if d.scriptDir != "" {
|
||||||
|
_ = os.RemoveAll(d.scriptDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolvePython 在配置值 / PATH 里找可用的 python 解释器。
|
||||||
|
// Windows 上常见 python / python3 / py;Linux/mac 常见 python3 / python。
|
||||||
|
func resolvePython(configured string) string {
|
||||||
|
candidates := []string{}
|
||||||
|
if strings.TrimSpace(configured) != "" {
|
||||||
|
candidates = append(candidates, strings.TrimSpace(configured))
|
||||||
|
}
|
||||||
|
if os.Getenv("KM_NSFW_PYTHON") != "" {
|
||||||
|
candidates = append(candidates, os.Getenv("KM_NSFW_PYTHON"))
|
||||||
|
}
|
||||||
|
candidates = append(candidates, "python", "python3", "py")
|
||||||
|
for _, c := range candidates {
|
||||||
|
if p, err := exec.LookPath(c); err == nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
// 配置给的是绝对路径:直接看文件在不在
|
||||||
|
if strings.ContainsAny(c, `\/`) {
|
||||||
|
if _, err := os.Stat(c); err == nil {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// freePort 找一个当前空闲的 TCP 端口(挑到立刻释放,竞态可接受)。
|
||||||
|
func freePort() (int, error) {
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
return ln.Addr().(*net.TCPAddr).Port, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""NudeNet 本地检测服务:由 Go 侧启动,常驻提供 /health 和 /detect。
|
||||||
|
|
||||||
|
只依赖 Python 标准库 + nudenet 包,模型在进程里只加载一次。
|
||||||
|
收到 /detect 就把图片路径丢给 NudeDetector,返回结果 JSON。
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
|
||||||
|
from nudenet import NudeDetector
|
||||||
|
|
||||||
|
# 只有这些"暴露部位"才算色情;其它如肚子/腋窝/脚踝暴露不判。
|
||||||
|
SENSITIVE = {
|
||||||
|
"FEMALE_GENITALIA_EXPOSED",
|
||||||
|
"MALE_GENITALIA_EXPOSED",
|
||||||
|
"ANUS_EXPOSED",
|
||||||
|
"FEMALE_BREAST_EXPOSED",
|
||||||
|
}
|
||||||
|
|
||||||
|
_detector = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_detector():
|
||||||
|
global _detector
|
||||||
|
if _detector is None:
|
||||||
|
_detector = NudeDetector()
|
||||||
|
return _detector
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def _send(self, obj, status=200):
|
||||||
|
data = json.dumps(obj, ensure_ascii=False).encode("utf-8")
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path == "/health":
|
||||||
|
self._send({"ok": True})
|
||||||
|
else:
|
||||||
|
self._send({"ok": False, "error": "not found"}, 404)
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
if self.path != "/detect":
|
||||||
|
return self._send({"ok": False, "error": "not found"}, 404)
|
||||||
|
length = int(self.headers.get("Content-Length", 0))
|
||||||
|
try:
|
||||||
|
body = json.loads(self.rfile.read(length) or b"{}")
|
||||||
|
path = body.get("path", "")
|
||||||
|
threshold = float(body.get("threshold", 0.5))
|
||||||
|
detections = get_detector().detect(path)
|
||||||
|
except Exception as exc: # noqa: BLE001 - 服务端任何异常都要回给 Go,别让进程崩
|
||||||
|
return self._send({"ok": False, "error": str(exc)})
|
||||||
|
|
||||||
|
max_score = 0.0
|
||||||
|
nsfw = False
|
||||||
|
for det in detections or []:
|
||||||
|
cls = det.get("class", "")
|
||||||
|
sc = float(det.get("score", 0) or 0)
|
||||||
|
if cls in SENSITIVE:
|
||||||
|
if sc > max_score:
|
||||||
|
max_score = sc
|
||||||
|
if sc >= threshold:
|
||||||
|
nsfw = True
|
||||||
|
self._send({"ok": True, "nsfw": nsfw, "score": max_score, "detections": detections})
|
||||||
|
|
||||||
|
def log_message(self, *args): # 不刷屏
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
port = int(sys.argv[1]) if len(sys.argv) > 1 else 0
|
||||||
|
HTTPServer(("127.0.0.1", port), Handler).serve_forever()
|
||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"kenaito-media-picker/internal/config"
|
"kenaito-media-picker/internal/config"
|
||||||
"kenaito-media-picker/internal/digest"
|
"kenaito-media-picker/internal/digest"
|
||||||
"kenaito-media-picker/internal/mover"
|
"kenaito-media-picker/internal/mover"
|
||||||
|
"kenaito-media-picker/internal/nsfw"
|
||||||
"kenaito-media-picker/internal/probe"
|
"kenaito-media-picker/internal/probe"
|
||||||
"kenaito-media-picker/internal/scanner"
|
"kenaito-media-picker/internal/scanner"
|
||||||
"kenaito-media-picker/internal/store"
|
"kenaito-media-picker/internal/store"
|
||||||
@@ -61,6 +62,7 @@ type Organizer struct {
|
|||||||
store *store.Store // 数据库;为 nil 表示不记录媒体信息
|
store *store.Store // 数据库;为 nil 表示不记录媒体信息
|
||||||
mover *mover.Mover // 负责实际搬文件的家伙
|
mover *mover.Mover // 负责实际搬文件的家伙
|
||||||
ctx context.Context // 中止信号;ctx 被取消时探测/归类立即停下
|
ctx context.Context // 中止信号;ctx 被取消时探测/归类立即停下
|
||||||
|
nsfw *nsfw.Detector // 色情检测器;为 nil 表示没启用,图片不检测
|
||||||
buckets map[string][]string // 目标目录 → 里面该放哪些文件(只记源路径)
|
buckets map[string][]string // 目标目录 → 里面该放哪些文件(只记源路径)
|
||||||
order []string // 目标目录的先后顺序(保证输出稳定、不来回跳)
|
order []string // 目标目录的先后顺序(保证输出稳定、不来回跳)
|
||||||
onProgress func(phase string, done, total int)
|
onProgress func(phase string, done, total int)
|
||||||
@@ -88,6 +90,12 @@ func (o *Organizer) SetProgress(fn func(phase string, done, total int)) {
|
|||||||
o.onProgress = fn
|
o.onProgress = fn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetNSFWDetector 设置色情检测器。传 nil 表示不检测(默认)。
|
||||||
|
// 设置了检测器后,图片探测时会额外做色情识别,命中的图片会归入「图片_敏感_*」目录。
|
||||||
|
func (o *Organizer) SetNSFWDetector(d *nsfw.Detector) {
|
||||||
|
o.nsfw = d
|
||||||
|
}
|
||||||
|
|
||||||
// report 上报进度。total 为 0 表示这一步没活干,就不用报了。
|
// report 上报进度。total 为 0 表示这一步没活干,就不用报了。
|
||||||
func (o *Organizer) report(phase string, done, total int) {
|
func (o *Organizer) report(phase string, done, total int) {
|
||||||
if o.onProgress != nil && total > 0 {
|
if o.onProgress != nil && total > 0 {
|
||||||
@@ -279,6 +287,8 @@ type imageProbeResult struct {
|
|||||||
size int64 // 文件大小
|
size int64 // 文件大小
|
||||||
darkScore float64 // 暗色像素占比(用于亮度分类)
|
darkScore float64 // 暗色像素占比(用于亮度分类)
|
||||||
lightScore float64 // 亮色像素占比
|
lightScore float64 // 亮色像素占比
|
||||||
|
nsfwScore float64 // 色情检测最高分(未检测为 0)
|
||||||
|
nsfw bool // 是否判定为色情(检测可用时才可能为 true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// probeImage 给一张图片做体检(并行阶段执行):探测尺寸 + 算 MD5 + 分析明暗。
|
// probeImage 给一张图片做体检(并行阶段执行):探测尺寸 + 算 MD5 + 分析明暗。
|
||||||
@@ -318,6 +328,17 @@ func (o *Organizer) probeImage(f scanner.File) imageProbeResult {
|
|||||||
r.darkScore, r.lightScore = dark, light
|
r.darkScore, r.lightScore = dark, light
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 色情识别(GIF 不做;没启用检测器就直接跳过)
|
||||||
|
if o.nsfw != nil && !strings.EqualFold(f.Ext, ".gif") {
|
||||||
|
score, hit, derr := o.nsfw.Detect(f.Path)
|
||||||
|
if derr != nil {
|
||||||
|
// 检测失败按"未命中"处理,不影响整理流程
|
||||||
|
fmt.Printf("=== 色情检测失败(按未命中归类): %s, 错误: %v \n", f.Path, derr)
|
||||||
|
} else {
|
||||||
|
r.nsfwScore = score
|
||||||
|
r.nsfw = hit
|
||||||
|
}
|
||||||
|
}
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,9 +410,14 @@ func (o *Organizer) ProcessImages(images []scanner.File) {
|
|||||||
dup++
|
dup++
|
||||||
fmt.Printf("=== 重复图片(同路径), 忽略: %s \n", f.Path)
|
fmt.Printf("=== 重复图片(同路径), 忽略: %s \n", f.Path)
|
||||||
default:
|
default:
|
||||||
// 正常图片:按分辨率/方向/明暗归入对应的分类目录
|
// 正常图片:按分辨率/方向/明暗归入对应的分类目录;
|
||||||
|
// 被判为色情的改去「图片_敏感_*」目录,明暗子目录照常保留
|
||||||
o.record(f, "image", r.res, r.md5Str, r.headMd5, r.tailMd5, r.size)
|
o.record(f, "image", r.res, r.md5Str, r.headMd5, r.tailMd5, r.size)
|
||||||
|
if r.nsfw {
|
||||||
|
o.add(f, classify.ImageDirSensitive(f.Ext, r.res.Width, r.res.Height, r.darkScore, r.lightScore))
|
||||||
|
} else {
|
||||||
o.add(f, classify.ImageDir(f.Ext, r.res.Width, r.res.Height, r.darkScore, r.lightScore))
|
o.add(f, classify.ImageDir(f.Ext, r.res.Width, r.res.Height, r.darkScore, r.lightScore))
|
||||||
|
}
|
||||||
success++
|
success++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"kenaito-media-picker/internal/config"
|
"kenaito-media-picker/internal/config"
|
||||||
|
"kenaito-media-picker/internal/nsfw"
|
||||||
"kenaito-media-picker/internal/organize"
|
"kenaito-media-picker/internal/organize"
|
||||||
"kenaito-media-picker/internal/probe"
|
"kenaito-media-picker/internal/probe"
|
||||||
"kenaito-media-picker/internal/scanner"
|
"kenaito-media-picker/internal/scanner"
|
||||||
@@ -492,7 +493,23 @@ func (s *ScanService) runScan(ctx context.Context, rootDir, destDir string) erro
|
|||||||
fmt.Printf("=== 使用 ffmpeg 转正图片EXIF旋转: %s\n", ffmpegPath)
|
fmt.Printf("=== 使用 ffmpeg 转正图片EXIF旋转: %s\n", ffmpegPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 第五步(可选):色情识别 ----------
|
||||||
|
// 用本机 python + NudeNet 起一个本地检测服务。没装 python/nudenet 时跳过,
|
||||||
|
// 不影响图片整理——检测结果只决定走普通目录还是「图片_敏感」目录。
|
||||||
|
nsfwDet := (*nsfw.Detector)(nil)
|
||||||
|
if cfg := config.GetAppConfig(); cfg.NSFWEnabled {
|
||||||
|
if d, err := nsfw.Start(cfg.NSFWPython, cfg.NSFWThreshold); err != nil {
|
||||||
|
fmt.Printf("=== 色情识别未启用(图片照常处理): %v\n", err)
|
||||||
|
} else {
|
||||||
|
nsfwDet = d
|
||||||
|
defer nsfwDet.Stop()
|
||||||
|
fmt.Println("=== 色情识别已启用(NudeNet)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
org := organize.New(pickerDir, ffprobePath, ffmpegPath, s.store, ctx)
|
org := organize.New(pickerDir, ffprobePath, ffmpegPath, s.store, ctx)
|
||||||
|
org.SetNSFWDetector(nsfwDet)
|
||||||
|
|
||||||
// 整个"整理"分两个阶段,界面上共用一条进度条:
|
// 整个"整理"分两个阶段,界面上共用一条进度条:
|
||||||
// 归类阶段:图片、视频/音频分两批处理。进度 = 已处理文件数 / 全部待整理文件数
|
// 归类阶段:图片、视频/音频分两批处理。进度 = 已处理文件数 / 全部待整理文件数
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user