upd
This commit is contained in:
@@ -10,5 +10,3 @@ ffmpeg.path=
|
|||||||
# 是否处理图片 EXIF 旋转(true=是,默认):有 ffmpeg 就物理转正像素,没有则按显示方向归类
|
# 是否处理图片 EXIF 旋转(true=是,默认):有 ffmpeg 就物理转正像素,没有则按显示方向归类
|
||||||
# false=完全不处理:图片按存储方向/尺寸归类,不做任何旋转相关改动
|
# false=完全不处理:图片按存储方向/尺寸归类,不做任何旋转相关改动
|
||||||
organize.rotateImages=false
|
organize.rotateImages=false
|
||||||
# 探测/认类型/移动阶段的并发 worker 数(默认 4;0 表示用满全部 CPU 核数)
|
|
||||||
organize.workers=8
|
|
||||||
|
|||||||
@@ -1,13 +1,47 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="settings-page">
|
<div class="settings-page">
|
||||||
<el-card class="box-card">
|
<el-card class="box-card">
|
||||||
|
<div slot="header" class="card-header">
|
||||||
|
<span>系统信息</span>
|
||||||
|
</div>
|
||||||
|
<el-row :gutter="20" class="system-info">
|
||||||
|
<el-col :span="8">
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">CPU 核心数</div>
|
||||||
|
<div class="info-value">{{ systemInfo.cpuCores }}</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">内存大小</div>
|
||||||
|
<div class="info-value">{{ systemInfo.memoryGB.toFixed(1) }} GB</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">推荐 Worker</div>
|
||||||
|
<div class="info-value">
|
||||||
|
Go {{ systemInfo.recommendedGo }} / 人类 {{ systemInfo.recommendedNsfw }} / 二次元 {{ systemInfo.recommendedAnime }}
|
||||||
|
<el-button type="text" size="mini" @click="applyRecommended">一键应用</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card class="box-card" style="margin-top: 20px">
|
||||||
<div slot="header" class="card-header">
|
<div slot="header" class="card-header">
|
||||||
<span>色情检测配置</span>
|
<span>色情检测配置</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-form :model="form" label-width="120px" class="settings-form">
|
<el-form :model="form" label-width="140px" class="settings-form">
|
||||||
|
<el-form-item label="Go 端 Worker 数">
|
||||||
|
<el-input-number v-model="form.goWorkers" :min="0" :max="64"></el-input-number>
|
||||||
|
<div class="form-tip">探测/MIME/移动的并发数,0 表示使用文件配置(当前推荐 {{ systemInfo.recommendedGo }})</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="检测类型">
|
<el-form-item label="检测类型">
|
||||||
<el-radio-group v-model="form.type" @change="handleTypeChange">
|
<el-radio-group v-model="form.type">
|
||||||
<el-radio label="">关闭</el-radio>
|
<el-radio label="">关闭</el-radio>
|
||||||
<el-radio label="human">人类 (NudeNet)</el-radio>
|
<el-radio label="human">人类 (NudeNet)</el-radio>
|
||||||
<el-radio label="anime">二次元 (WD Tagger)</el-radio>
|
<el-radio label="anime">二次元 (WD Tagger)</el-radio>
|
||||||
@@ -24,11 +58,21 @@
|
|||||||
<div class="form-tip">分数高于此值判定为色情(推荐 0.2-0.4)</div>
|
<div class="form-tip">分数高于此值判定为色情(推荐 0.2-0.4)</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item v-if="form.type === 'human'" label="推理 Worker 数">
|
||||||
|
<el-input-number v-model="form.humanWorkers" :min="1" :max="32"></el-input-number>
|
||||||
|
<div class="form-tip">并行推理的 worker 数量,越大越快但占用更多内存(每个 worker 约 300MB)</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item v-if="form.type === 'anime'" label="检测阈值">
|
<el-form-item v-if="form.type === 'anime'" label="检测阈值">
|
||||||
<el-slider v-model="form.animeThreshold" :min="0.1" :max="0.9" :step="0.05" show-input></el-slider>
|
<el-slider v-model="form.animeThreshold" :min="0.1" :max="0.9" :step="0.05" show-input></el-slider>
|
||||||
<div class="form-tip">explicit/questionable 分数高于此值判定为色情(推荐 0.5-0.7)</div>
|
<div class="form-tip">explicit/questionable 分数高于此值判定为色情(推荐 0.5-0.7)</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item v-if="form.type === 'anime'" label="推理 Worker 数">
|
||||||
|
<el-input-number v-model="form.animeWorkers" :min="1" :max="32"></el-input-number>
|
||||||
|
<div class="form-tip">并行推理的 worker 数量,越大越快但占用更多内存(每个 worker 约 1.5GB)</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" @click="saveConfig" :loading="saving">保存配置</el-button>
|
<el-button type="primary" @click="saveConfig" :loading="saving">保存配置</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -47,13 +91,24 @@ export default {
|
|||||||
form: {
|
form: {
|
||||||
type: '',
|
type: '',
|
||||||
humanThreshold: 0.5,
|
humanThreshold: 0.5,
|
||||||
animeThreshold: 0.5
|
animeThreshold: 0.5,
|
||||||
|
humanWorkers: 4,
|
||||||
|
animeWorkers: 2,
|
||||||
|
goWorkers: 0
|
||||||
|
},
|
||||||
|
systemInfo: {
|
||||||
|
cpuCores: 0,
|
||||||
|
memoryGB: 0,
|
||||||
|
recommendedGo: 0,
|
||||||
|
recommendedNsfw: 4,
|
||||||
|
recommendedAnime: 2
|
||||||
},
|
},
|
||||||
saving: false
|
saving: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
this.loadConfig()
|
this.loadConfig()
|
||||||
|
this.loadSystemInfo()
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async loadConfig() {
|
async loadConfig() {
|
||||||
@@ -63,13 +118,29 @@ export default {
|
|||||||
this.form.type = res.data.type || ''
|
this.form.type = res.data.type || ''
|
||||||
this.form.humanThreshold = res.data.humanThreshold || 0.5
|
this.form.humanThreshold = res.data.humanThreshold || 0.5
|
||||||
this.form.animeThreshold = res.data.animeThreshold || 0.5
|
this.form.animeThreshold = res.data.animeThreshold || 0.5
|
||||||
|
this.form.humanWorkers = res.data.humanWorkers || 4
|
||||||
|
this.form.animeWorkers = res.data.animeWorkers || 2
|
||||||
|
this.form.goWorkers = res.data.goWorkers || 0
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.$message.error('加载配置失败')
|
this.$message.error('加载配置失败')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
handleTypeChange() {
|
async loadSystemInfo() {
|
||||||
// 切换类型时不需要特殊处理
|
try {
|
||||||
|
const res = await request.get('/api/config/system')
|
||||||
|
if (res.data) {
|
||||||
|
this.systemInfo = res.data
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 忽略
|
||||||
|
}
|
||||||
|
},
|
||||||
|
applyRecommended() {
|
||||||
|
this.form.goWorkers = this.systemInfo.recommendedGo
|
||||||
|
this.form.humanWorkers = this.systemInfo.recommendedNsfw
|
||||||
|
this.form.animeWorkers = this.systemInfo.recommendedAnime
|
||||||
|
this.$message.success('已应用推荐值,请点击保存配置')
|
||||||
},
|
},
|
||||||
async saveConfig() {
|
async saveConfig() {
|
||||||
this.saving = true
|
this.saving = true
|
||||||
@@ -77,7 +148,10 @@ export default {
|
|||||||
await request.put('/api/config/nsfw', {
|
await request.put('/api/config/nsfw', {
|
||||||
type: this.form.type,
|
type: this.form.type,
|
||||||
humanThreshold: this.form.humanThreshold,
|
humanThreshold: this.form.humanThreshold,
|
||||||
animeThreshold: this.form.animeThreshold
|
animeThreshold: this.form.animeThreshold,
|
||||||
|
humanWorkers: this.form.humanWorkers,
|
||||||
|
animeWorkers: this.form.animeWorkers,
|
||||||
|
goWorkers: this.form.goWorkers
|
||||||
})
|
})
|
||||||
this.$message.success('配置已保存,下次扫描生效')
|
this.$message.success('配置已保存,下次扫描生效')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -100,6 +174,27 @@ export default {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.system-info {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
text-align: center;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #909399;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f3a6b;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-form {
|
.settings-form {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ const requestTimeout = 60 * time.Second
|
|||||||
type Detector struct {
|
type Detector struct {
|
||||||
url string
|
url string
|
||||||
threshold float64
|
threshold float64
|
||||||
|
workers int
|
||||||
client *http.Client
|
client *http.Client
|
||||||
cmd *exec.Cmd
|
cmd *exec.Cmd
|
||||||
scriptDir string
|
scriptDir string
|
||||||
@@ -59,10 +60,13 @@ type Detector struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start 拉起本地 python 检测服务并等它就绪。
|
// Start 拉起本地 python 检测服务并等它就绪。
|
||||||
func Start(pythonPath string, threshold float64) (*Detector, error) {
|
func Start(pythonPath string, threshold float64, workers int) (*Detector, error) {
|
||||||
if threshold <= 0 || threshold >= 1 {
|
if threshold <= 0 || threshold >= 1 {
|
||||||
threshold = 0.5
|
threshold = 0.5
|
||||||
}
|
}
|
||||||
|
if workers <= 0 {
|
||||||
|
workers = 2 // 默认 2 个 worker
|
||||||
|
}
|
||||||
py := resolvePython(pythonPath)
|
py := resolvePython(pythonPath)
|
||||||
if py == "" {
|
if py == "" {
|
||||||
return nil, fmt.Errorf("未找到 python,动漫敏感识别不可用")
|
return nil, fmt.Errorf("未找到 python,动漫敏感识别不可用")
|
||||||
@@ -85,6 +89,7 @@ func Start(pythonPath string, threshold float64) (*Detector, error) {
|
|||||||
|
|
||||||
d := &Detector{
|
d := &Detector{
|
||||||
threshold: threshold,
|
threshold: threshold,
|
||||||
|
workers: workers,
|
||||||
client: &http.Client{Timeout: requestTimeout},
|
client: &http.Client{Timeout: requestTimeout},
|
||||||
python: py,
|
python: py,
|
||||||
modelDir: modelDir,
|
modelDir: modelDir,
|
||||||
@@ -108,6 +113,7 @@ func (d *Detector) launch() error {
|
|||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
cmd := exec.CommandContext(ctx, d.python, filepath.Join(d.scriptDir, "server.py"), fmt.Sprintf("%d", port), d.modelDir)
|
cmd := exec.CommandContext(ctx, d.python, filepath.Join(d.scriptDir, "server.py"), fmt.Sprintf("%d", port), d.modelDir)
|
||||||
|
cmd.Env = append(os.Environ(), fmt.Sprintf("KM_ANIME_NSFW_WORKERS=%d", d.workers))
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stdout
|
cmd.Stderr = os.Stdout
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
@@ -256,10 +262,10 @@ func (d *Detector) detectOnce(path string) (score float64, nsfw bool, ratings An
|
|||||||
Nsfw bool `json:"nsfw"`
|
Nsfw bool `json:"nsfw"`
|
||||||
Score float64 `json:"score"`
|
Score float64 `json:"score"`
|
||||||
Ratings struct {
|
Ratings struct {
|
||||||
General float64 `json:"general"`
|
General float64 `json:"general"`
|
||||||
Sensitive float64 `json:"sensitive"`
|
Sensitive float64 `json:"sensitive"`
|
||||||
Questionable float64 `json:"questionable"`
|
Questionable float64 `json:"questionable"`
|
||||||
Explicit float64 `json:"explicit"`
|
Explicit float64 `json:"explicit"`
|
||||||
} `json:"ratings"`
|
} `json:"ratings"`
|
||||||
TopTags []map[string]interface{} `json:"top_tags"`
|
TopTags []map[string]interface{} `json:"top_tags"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,15 +8,19 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"kenaito-media-picker/internal/store"
|
"kenaito-media-picker/internal/store"
|
||||||
|
"kenaito-media-picker/internal/sysinfo"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 配置键名(存储在 AppConfig 表中)
|
// 配置键名(存储在 AppConfig 表中)
|
||||||
const (
|
const (
|
||||||
ConfigKeyNsfwType = "nsfw.type" // 检测类型:"" / "human" / "anime"
|
ConfigKeyNsfwType = "nsfw.type" // 检测类型:"" / "human" / "anime"
|
||||||
ConfigKeyNsfwThreshold = "nsfw.threshold" // 人类检测阈值
|
ConfigKeyNsfwThreshold = "nsfw.threshold" // 人类检测阈值
|
||||||
ConfigKeyAnimeNsfwThreshold = "anime.threshold" // 二次元检测阈值
|
ConfigKeyAnimeNsfwThreshold = "anime.threshold" // 二次元检测阈值
|
||||||
ConfigKeyScanDir = "scan.scanDir" // 扫描目录
|
ConfigKeyNsfwWorkers = "nsfw.workers" // 人类检测 worker 数量
|
||||||
ConfigKeyDestDir = "scan.destDir" // 存放目录
|
ConfigKeyAnimeNsfwWorkers = "anime.workers" // 二次元检测 worker 数量
|
||||||
|
ConfigKeyOrganizeWorkers = "organize.workers" // Go 端探测/MIME/移动 worker 数量
|
||||||
|
ConfigKeyScanDir = "scan.scanDir" // 扫描目录
|
||||||
|
ConfigKeyDestDir = "scan.destDir" // 存放目录
|
||||||
)
|
)
|
||||||
|
|
||||||
// ConfigController 配置相关接口。
|
// ConfigController 配置相关接口。
|
||||||
@@ -34,6 +38,9 @@ type NsfwConfig struct {
|
|||||||
Type string `json:"type"` // 检测类型:"" / "human" / "anime"
|
Type string `json:"type"` // 检测类型:"" / "human" / "anime"
|
||||||
HumanThreshold float64 `json:"humanThreshold"` // 人类检测阈值
|
HumanThreshold float64 `json:"humanThreshold"` // 人类检测阈值
|
||||||
AnimeThreshold float64 `json:"animeThreshold"` // 二次元检测阈值
|
AnimeThreshold float64 `json:"animeThreshold"` // 二次元检测阈值
|
||||||
|
HumanWorkers int `json:"humanWorkers"` // 人类检测 worker 数量
|
||||||
|
AnimeWorkers int `json:"animeWorkers"` // 二次元检测 worker 数量
|
||||||
|
GoWorkers int `json:"goWorkers"` // Go 端 worker 数量
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetNsfwConfig 处理 GET /api/config/nsfw:获取 NSFW 配置。
|
// GetNsfwConfig 处理 GET /api/config/nsfw:获取 NSFW 配置。
|
||||||
@@ -54,10 +61,34 @@ func (c *ConfigController) GetNsfwConfig(ctx *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
humanWorkers := 4
|
||||||
|
if v := c.store.GetConfig(ConfigKeyNsfwWorkers); v != "" {
|
||||||
|
if w, err := strconv.Atoi(v); err == nil && w > 0 {
|
||||||
|
humanWorkers = w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
animeWorkers := 2
|
||||||
|
if v := c.store.GetConfig(ConfigKeyAnimeNsfwWorkers); v != "" {
|
||||||
|
if w, err := strconv.Atoi(v); err == nil && w > 0 {
|
||||||
|
animeWorkers = w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
goWorkers := 0 // 0 表示使用文件配置或自动计算
|
||||||
|
if v := c.store.GetConfig(ConfigKeyOrganizeWorkers); v != "" {
|
||||||
|
if w, err := strconv.Atoi(v); err == nil && w >= 0 {
|
||||||
|
goWorkers = w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Success(ctx, NsfwConfig{
|
Success(ctx, NsfwConfig{
|
||||||
Type: nsfwType,
|
Type: nsfwType,
|
||||||
HumanThreshold: humanThreshold,
|
HumanThreshold: humanThreshold,
|
||||||
AnimeThreshold: animeThreshold,
|
AnimeThreshold: animeThreshold,
|
||||||
|
HumanWorkers: humanWorkers,
|
||||||
|
AnimeWorkers: animeWorkers,
|
||||||
|
GoWorkers: goWorkers,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +97,9 @@ type UpdateNsfwConfigReq struct {
|
|||||||
Type string `json:"type"` // 检测类型:"" / "human" / "anime"
|
Type string `json:"type"` // 检测类型:"" / "human" / "anime"
|
||||||
HumanThreshold float64 `json:"humanThreshold"` // 人类检测阈值
|
HumanThreshold float64 `json:"humanThreshold"` // 人类检测阈值
|
||||||
AnimeThreshold float64 `json:"animeThreshold"` // 二次元检测阈值
|
AnimeThreshold float64 `json:"animeThreshold"` // 二次元检测阈值
|
||||||
|
HumanWorkers int `json:"humanWorkers"` // 人类检测 worker 数量
|
||||||
|
AnimeWorkers int `json:"animeWorkers"` // 二次元检测 worker 数量
|
||||||
|
GoWorkers int `json:"goWorkers"` // Go 端 worker 数量(0 表示使用文件配置)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateNsfwConfig 处理 PUT /api/config/nsfw:更新 NSFW 配置。
|
// UpdateNsfwConfig 处理 PUT /api/config/nsfw:更新 NSFW 配置。
|
||||||
@@ -90,6 +124,17 @@ func (c *ConfigController) UpdateNsfwConfig(ctx *gin.Context) {
|
|||||||
req.AnimeThreshold = 0.5
|
req.AnimeThreshold = 0.5
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 校验 worker 数量
|
||||||
|
if req.HumanWorkers <= 0 {
|
||||||
|
req.HumanWorkers = 4
|
||||||
|
}
|
||||||
|
if req.AnimeWorkers <= 0 {
|
||||||
|
req.AnimeWorkers = 2
|
||||||
|
}
|
||||||
|
if req.GoWorkers < 0 {
|
||||||
|
req.GoWorkers = 0
|
||||||
|
}
|
||||||
|
|
||||||
// 保存配置
|
// 保存配置
|
||||||
if err := c.store.SaveConfig(ConfigKeyNsfwType, req.Type); err != nil {
|
if err := c.store.SaveConfig(ConfigKeyNsfwType, req.Type); err != nil {
|
||||||
Error(ctx, http.StatusInternalServerError, "保存配置失败:"+err.Error())
|
Error(ctx, http.StatusInternalServerError, "保存配置失败:"+err.Error())
|
||||||
@@ -103,10 +148,45 @@ func (c *ConfigController) UpdateNsfwConfig(ctx *gin.Context) {
|
|||||||
Error(ctx, http.StatusInternalServerError, "保存配置失败:"+err.Error())
|
Error(ctx, http.StatusInternalServerError, "保存配置失败:"+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := c.store.SaveConfig(ConfigKeyNsfwWorkers, strconv.Itoa(req.HumanWorkers)); err != nil {
|
||||||
|
Error(ctx, http.StatusInternalServerError, "保存配置失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := c.store.SaveConfig(ConfigKeyAnimeNsfwWorkers, strconv.Itoa(req.AnimeWorkers)); err != nil {
|
||||||
|
Error(ctx, http.StatusInternalServerError, "保存配置失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := c.store.SaveConfig(ConfigKeyOrganizeWorkers, strconv.Itoa(req.GoWorkers)); err != nil {
|
||||||
|
Error(ctx, http.StatusInternalServerError, "保存配置失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
Success(ctx, nil)
|
Success(ctx, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SystemInfo 系统信息响应。
|
||||||
|
type SystemInfo struct {
|
||||||
|
CPUCores int `json:"cpuCores"` // CPU 逻辑核心数
|
||||||
|
MemoryGB float64 `json:"memoryGB"` // 内存大小(GB)
|
||||||
|
RecommendedGo int `json:"recommendedGo"` // 推荐的 Go 端 worker 数
|
||||||
|
RecommendedNsfw int `json:"recommendedNsfw"` // 推荐的人类检测 worker 数
|
||||||
|
RecommendedAnime int `json:"recommendedAnime"` // 推荐的二次元检测 worker 数
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSystemInfo 处理 GET /api/config/system:获取系统信息和推荐配置。
|
||||||
|
func (c *ConfigController) GetSystemInfo(ctx *gin.Context) {
|
||||||
|
info := sysinfo.GetSystemInfo()
|
||||||
|
workers := sysinfo.CalculateWorkerConfig(info)
|
||||||
|
|
||||||
|
Success(ctx, SystemInfo{
|
||||||
|
CPUCores: info.CPUCores,
|
||||||
|
MemoryGB: info.MemoryGB,
|
||||||
|
RecommendedGo: workers.GoWorkers,
|
||||||
|
RecommendedNsfw: workers.NsfwWorkers,
|
||||||
|
RecommendedAnime: workers.AnimeNsfwWorkers,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateScanDirReq 更新扫描目录配置请求。
|
// UpdateScanDirReq 更新扫描目录配置请求。
|
||||||
type UpdateScanDirReq struct {
|
type UpdateScanDirReq struct {
|
||||||
ScanDir string `json:"scanDir"` // 扫描目录
|
ScanDir string `json:"scanDir"` // 扫描目录
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ func NewRouter(st *store.Store) *gin.Engine {
|
|||||||
cc := NewConfigController(st)
|
cc := NewConfigController(st)
|
||||||
apiGroup.GET("/config/nsfw", cc.GetNsfwConfig)
|
apiGroup.GET("/config/nsfw", cc.GetNsfwConfig)
|
||||||
apiGroup.PUT("/config/nsfw", cc.UpdateNsfwConfig)
|
apiGroup.PUT("/config/nsfw", cc.UpdateNsfwConfig)
|
||||||
|
apiGroup.GET("/config/system", cc.GetSystemInfo)
|
||||||
apiGroup.PUT("/config/scan-dir", cc.UpdateScanDir)
|
apiGroup.PUT("/config/scan-dir", cc.UpdateScanDir)
|
||||||
}
|
}
|
||||||
return r
|
return r
|
||||||
|
|||||||
@@ -81,3 +81,11 @@ func WorkerCount() int {
|
|||||||
}
|
}
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetWorkers 运行时更新 worker 数量(用于从数据库读取配置后覆盖文件配置)。
|
||||||
|
func SetWorkers(workers int) {
|
||||||
|
if workers < 0 {
|
||||||
|
workers = 0
|
||||||
|
}
|
||||||
|
appConfig.Workers = workers
|
||||||
|
}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ const requestTimeout = 30 * time.Second
|
|||||||
type Detector struct {
|
type Detector struct {
|
||||||
url string // 本地服务地址,如 http://127.0.0.1:54321
|
url string // 本地服务地址,如 http://127.0.0.1:54321
|
||||||
threshold float64 // 判定阈值 0~1
|
threshold float64 // 判定阈值 0~1
|
||||||
|
workers int // Python 推理 worker 数量
|
||||||
client *http.Client
|
client *http.Client
|
||||||
cmd *exec.Cmd // python 进程句柄(Stop 时杀掉)
|
cmd *exec.Cmd // python 进程句柄(Stop 时杀掉)
|
||||||
scriptDir string // 临时脚本所在目录(Stop 时清理)
|
scriptDir string // 临时脚本所在目录(Stop 时清理)
|
||||||
@@ -75,12 +76,16 @@ type Detector struct {
|
|||||||
// Start 拉起本地 python 检测服务并等它就绪。
|
// Start 拉起本地 python 检测服务并等它就绪。
|
||||||
// - pythonPath:python 可执行文件路径;为空时在 PATH 里找 python/python3。
|
// - pythonPath:python 可执行文件路径;为空时在 PATH 里找 python/python3。
|
||||||
// - threshold:判定阈值 0~1。
|
// - threshold:判定阈值 0~1。
|
||||||
|
// - workers:Python 推理 worker 数量;<=0 表示使用默认值(4)。
|
||||||
//
|
//
|
||||||
// 找不到 python、装不了 nudenet、或服务起不来时返回错误(调用方跳过识别即可)。
|
// 找不到 python、装不了 nudenet、或服务起不来时返回错误(调用方跳过识别即可)。
|
||||||
func Start(pythonPath string, threshold float64) (*Detector, error) {
|
func Start(pythonPath string, threshold float64, workers int) (*Detector, error) {
|
||||||
if threshold <= 0 || threshold >= 1 {
|
if threshold <= 0 || threshold >= 1 {
|
||||||
threshold = 0.5 // 阈值传得不合理就用默认
|
threshold = 0.5 // 阈值传得不合理就用默认
|
||||||
}
|
}
|
||||||
|
if workers <= 0 {
|
||||||
|
workers = 4 // 默认 4 个 worker
|
||||||
|
}
|
||||||
// 找 python
|
// 找 python
|
||||||
py := resolvePython(pythonPath)
|
py := resolvePython(pythonPath)
|
||||||
if py == "" {
|
if py == "" {
|
||||||
@@ -107,6 +112,7 @@ func Start(pythonPath string, threshold float64) (*Detector, error) {
|
|||||||
|
|
||||||
d := &Detector{
|
d := &Detector{
|
||||||
threshold: threshold,
|
threshold: threshold,
|
||||||
|
workers: workers,
|
||||||
client: &http.Client{Timeout: requestTimeout},
|
client: &http.Client{Timeout: requestTimeout},
|
||||||
python: py,
|
python: py,
|
||||||
modelDir: modelDir,
|
modelDir: modelDir,
|
||||||
@@ -135,6 +141,8 @@ func (d *Detector) launch() error {
|
|||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
cmd := exec.CommandContext(ctx, d.python, filepath.Join(d.scriptDir, "server.py"), fmt.Sprintf("%d", port), d.modelDir)
|
cmd := exec.CommandContext(ctx, d.python, filepath.Join(d.scriptDir, "server.py"), fmt.Sprintf("%d", port), d.modelDir)
|
||||||
|
// 设置环境变量,传递 worker 数量给 Python 服务
|
||||||
|
cmd.Env = append(os.Environ(), fmt.Sprintf("KM_NSFW_WORKERS=%d", d.workers))
|
||||||
// 标准输出/错误都接到程序自身的输出:
|
// 标准输出/错误都接到程序自身的输出:
|
||||||
// - 扫描期间 os.Stdout 会被接管进"运行日志",这样 python 的报错(崩溃原因)也能在前端看到
|
// - 扫描期间 os.Stdout 会被接管进"运行日志",这样 python 的报错(崩溃原因)也能在前端看到
|
||||||
// - 方便排查服务为什么挂掉
|
// - 方便排查服务为什么挂掉
|
||||||
|
|||||||
@@ -74,8 +74,9 @@ type ScanStatus struct {
|
|||||||
|
|
||||||
// 应用级配置键。目前只存了两个:上次使用的存放目录、色情检测服务是否真的起来了。
|
// 应用级配置键。目前只存了两个:上次使用的存放目录、色情检测服务是否真的起来了。
|
||||||
const (
|
const (
|
||||||
ConfigKeyScanDir = "scan.scanDir"
|
ConfigKeyScanDir = "scan.scanDir"
|
||||||
ConfigKeyDestDir = "scan.destDir"
|
ConfigKeyDestDir = "scan.destDir"
|
||||||
|
ConfigKeyOrganizeWorkers = "organize.workers"
|
||||||
)
|
)
|
||||||
|
|
||||||
// nsfwDetectorReadyKey 记录最近一次扫描时色情检测服务是否真的起来了(python+nudenet 可用)。
|
// nsfwDetectorReadyKey 记录最近一次扫描时色情检测服务是否真的起来了(python+nudenet 可用)。
|
||||||
@@ -625,6 +626,14 @@ func sanitizeLine(line string) string {
|
|||||||
// 3. 准备解码器:把内嵌的 ffprobe 程序释放到当前目录(用它读视频/音频信息)
|
// 3. 准备解码器:把内嵌的 ffprobe 程序释放到当前目录(用它读视频/音频信息)
|
||||||
// 4. 整理:给每个文件探测信息 → 判断是否重复 → 分到分类目录 → 搬进去
|
// 4. 整理:给每个文件探测信息 → 判断是否重复 → 分到分类目录 → 搬进去
|
||||||
func (s *ScanService) runScan(ctx context.Context, rootDir, destDir string) error {
|
func (s *ScanService) runScan(ctx context.Context, rootDir, destDir string) error {
|
||||||
|
// 从数据库读取 organize.workers 配置(支持运行时修改,立即生效)
|
||||||
|
if v := s.store.GetConfig(ConfigKeyOrganizeWorkers); v != "" {
|
||||||
|
if w, err := strconv.Atoi(v); err == nil && w >= 0 {
|
||||||
|
config.SetWorkers(w)
|
||||||
|
fmt.Printf("=== 使用数据库配置 organize.workers=%d\n", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 确定整理后文件的存放目录:统一都放进「KMP已整理」这一层——
|
// 确定整理后文件的存放目录:统一都放进「KMP已整理」这一层——
|
||||||
// 用户填了存放目录就放在它下面(存放目录/KMP已整理),没填就放在扫描目录下(扫描目录/KMP已整理)。
|
// 用户填了存放目录就放在它下面(存放目录/KMP已整理),没填就放在扫描目录下(扫描目录/KMP已整理)。
|
||||||
pickerDir := filepath.Join(rootDir, DefaultPickerDirName)
|
pickerDir := filepath.Join(rootDir, DefaultPickerDirName)
|
||||||
@@ -696,13 +705,19 @@ func (s *ScanService) runScan(ctx context.Context, rootDir, destDir string) erro
|
|||||||
threshold = t
|
threshold = t
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if d, err := nsfw.Start("", threshold); err != nil {
|
workers := 4
|
||||||
|
if v := s.store.GetConfig("nsfw.workers"); v != "" {
|
||||||
|
if w, err := strconv.Atoi(v); err == nil && w > 0 {
|
||||||
|
workers = w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d, err := nsfw.Start("", threshold, workers); err != nil {
|
||||||
fmt.Printf("=== 色情识别未启用(图片照常处理): %v\n", err)
|
fmt.Printf("=== 色情识别未启用(图片照常处理): %v\n", err)
|
||||||
} else {
|
} else {
|
||||||
nsfwDet = d
|
nsfwDet = d
|
||||||
defer nsfwDet.Stop()
|
defer nsfwDet.Stop()
|
||||||
s.setNsfwDetectorReady(true)
|
s.setNsfwDetectorReady(true)
|
||||||
fmt.Println("=== 色情识别已启用(NudeNet)")
|
fmt.Printf("=== 色情识别已启用(NudeNet, workers=%d)\n", workers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -715,12 +730,18 @@ func (s *ScanService) runScan(ctx context.Context, rootDir, destDir string) erro
|
|||||||
threshold = t
|
threshold = t
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if d, err := anime_nsfw.Start("", threshold); err != nil {
|
workers := 2
|
||||||
|
if v := s.store.GetConfig("anime.workers"); v != "" {
|
||||||
|
if w, err := strconv.Atoi(v); err == nil && w > 0 {
|
||||||
|
workers = w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d, err := anime_nsfw.Start("", threshold, workers); err != nil {
|
||||||
fmt.Printf("=== 动漫色情识别未启用(图片照常处理): %v\n", err)
|
fmt.Printf("=== 动漫色情识别未启用(图片照常处理): %v\n", err)
|
||||||
} else {
|
} else {
|
||||||
animeNsfwDet = d
|
animeNsfwDet = d
|
||||||
defer animeNsfwDet.Stop()
|
defer animeNsfwDet.Stop()
|
||||||
fmt.Println("=== 动漫色情识别已启用(WD Tagger v3)")
|
fmt.Printf("=== 动漫色情识别已启用(WD Tagger v3, workers=%d)\n", workers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
// Package sysinfo 提供系统资源检测和 worker 数量自动计算功能。
|
||||||
|
package sysinfo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SystemInfo 系统资源信息
|
||||||
|
type SystemInfo struct {
|
||||||
|
CPUCores int // CPU 逻辑核心数
|
||||||
|
MemoryGB float64 // 内存大小(GB)
|
||||||
|
MemoryMB int // 内存大小(MB)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSystemInfo 获取系统资源信息
|
||||||
|
func GetSystemInfo() SystemInfo {
|
||||||
|
info := SystemInfo{
|
||||||
|
CPUCores: runtime.NumCPU(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取内存信息
|
||||||
|
info.MemoryMB = getMemoryMB()
|
||||||
|
info.MemoryGB = float64(info.MemoryMB) / 1024.0
|
||||||
|
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMemoryMB 获取系统内存大小(MB)
|
||||||
|
func getMemoryMB() int {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
return getMemoryWindows()
|
||||||
|
case "linux":
|
||||||
|
return getMemoryLinux()
|
||||||
|
case "darwin":
|
||||||
|
return getMemoryDarwin()
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMemoryWindows 使用 wmic 获取 Windows 内存
|
||||||
|
func getMemoryWindows() int {
|
||||||
|
cmd := exec.Command("wmic", "computersystem", "get", "totalphysicalmemory", "/value")
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析输出:TotalPhysicalMemory=1234567890
|
||||||
|
for _, line := range strings.Split(string(output), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, "TotalPhysicalMemory=") {
|
||||||
|
value := strings.TrimPrefix(line, "TotalPhysicalMemory=")
|
||||||
|
if bytes, err := strconv.ParseInt(value, 10, 64); err == nil {
|
||||||
|
return int(bytes / 1024 / 1024)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMemoryLinux 从 /proc/meminfo 获取 Linux 内存
|
||||||
|
func getMemoryLinux() int {
|
||||||
|
cmd := exec.Command("cat", "/proc/meminfo")
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 MemTotal: 12345678 kB
|
||||||
|
for _, line := range strings.Split(string(output), "\n") {
|
||||||
|
if strings.HasPrefix(line, "MemTotal:") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) >= 2 {
|
||||||
|
if kb, err := strconv.ParseInt(fields[1], 10, 64); err == nil {
|
||||||
|
return int(kb / 1024)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMemoryDarwin 使用 sysctl 获取 macOS 内存
|
||||||
|
func getMemoryDarwin() int {
|
||||||
|
cmd := exec.Command("sysctl", "-n", "hw.memsize")
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
value := strings.TrimSpace(string(output))
|
||||||
|
if bytes, err := strconv.ParseInt(value, 10, 64); err == nil {
|
||||||
|
return int(bytes / 1024 / 1024)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkerConfig worker 配置
|
||||||
|
type WorkerConfig struct {
|
||||||
|
GoWorkers int // Go 端 worker 数量(探测/MIME/移动)
|
||||||
|
NsfwWorkers int // NudeNet Python 推理 worker 数量
|
||||||
|
AnimeNsfwWorkers int // WD Tagger Python 推理 worker 数量
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculateWorkerConfig 根据系统资源自动计算 worker 配置
|
||||||
|
// 规则:
|
||||||
|
// - Go worker: CPU核心数 - 2(留2核给Python推理和系统)
|
||||||
|
// - NudeNet worker: 因为模型小(~300MB),可以开更多,上限为 CPU核心数
|
||||||
|
// - WD Tagger worker: 因为模型大(~1.5GB),受内存限制,每个worker约需2GB
|
||||||
|
func CalculateWorkerConfig(info SystemInfo) WorkerConfig {
|
||||||
|
config := WorkerConfig{}
|
||||||
|
|
||||||
|
// Go worker: CPU核心数 - 2,最少为1
|
||||||
|
goWorkers := info.CPUCores - 2
|
||||||
|
if goWorkers < 1 {
|
||||||
|
goWorkers = 1
|
||||||
|
}
|
||||||
|
config.GoWorkers = goWorkers
|
||||||
|
|
||||||
|
// NudeNet worker: 模型小,可以开到 CPU核心数,但留一些给系统
|
||||||
|
nsfwWorkers := info.CPUCores
|
||||||
|
if nsfwWorkers > 12 {
|
||||||
|
nsfwWorkers = 12 // 上限12,避免过度并发
|
||||||
|
}
|
||||||
|
config.NsfwWorkers = nsfwWorkers
|
||||||
|
|
||||||
|
// WD Tagger worker: 受内存限制,每个worker约需2GB
|
||||||
|
// 预留4GB给系统和其他进程
|
||||||
|
availableMemoryGB := info.MemoryGB - 4
|
||||||
|
if availableMemoryGB < 0 {
|
||||||
|
availableMemoryGB = 0
|
||||||
|
}
|
||||||
|
animeWorkers := int(availableMemoryGB / 2)
|
||||||
|
if animeWorkers < 1 {
|
||||||
|
animeWorkers = 1
|
||||||
|
}
|
||||||
|
if animeWorkers > info.CPUCores {
|
||||||
|
animeWorkers = info.CPUCores // 不超过CPU核心数
|
||||||
|
}
|
||||||
|
if animeWorkers > 12 {
|
||||||
|
animeWorkers = 12 // 上限12
|
||||||
|
}
|
||||||
|
config.AnimeNsfwWorkers = animeWorkers
|
||||||
|
|
||||||
|
return config
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user