feat: 二次元识别功能 + 模型目录整理(nwfs/wd-tagger 子目录),修复 wd-tagger 输入维度(NHWC)
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
// Package hentai 负责"二次元图片识别":本机 Python + wd-tagger 起一个本地 HTTP 服务,
|
||||
// Go 侧把图片路径发给它,拿到"是否二次元敏感"和分级信息。
|
||||
//
|
||||
// 大白话理解:wd-tagger 是专门针对二次元训练的分级模型,能区分 safe/questionable/explicit。
|
||||
// 我们让 python 当"外援":
|
||||
// - 扫描开始时拉起一个常驻的 python 小服务(模型只加载一次);
|
||||
// - 探测图片时逐张把路径 POST 给它;
|
||||
// - 扫描结束把服务停掉、清理临时脚本。
|
||||
//
|
||||
// 机器上没有 python 或没装 onnxruntime 时,Start 会报错,调用方跳过识别、
|
||||
// 走普通整理流程——这个功能是纯附加的,不影响其它步骤。
|
||||
package hentai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
// 内嵌的 python 服务脚本。
|
||||
//go:embed server.py
|
||||
var serverScript []byte
|
||||
|
||||
// detectorStartTimeout 等待 python 服务就绪(模型加载)的超时时间。
|
||||
// 首次启动若本地没有模型还要联网下载约 1.5GB,给足余量。
|
||||
const detectorStartTimeout = 600 * time.Second
|
||||
|
||||
// requestTimeout 单张图片检测的超时时间。
|
||||
const requestTimeout = 30 * time.Second
|
||||
|
||||
// Detector 封装一个本地 python 服务的客户端。
|
||||
type Detector struct {
|
||||
url string // 本地服务地址,如 http://127.0.0.1:54322
|
||||
threshold float64 // 判定阈值 0~1
|
||||
client *http.Client
|
||||
cmd *exec.Cmd // python 进程句柄(Stop 时杀掉)
|
||||
scriptDir string // 临时脚本所在目录(Stop 时清理)
|
||||
python string // python 可执行文件路径(服务重启时要用)
|
||||
modelDir string // 模型目录 lib(服务重启时要用)
|
||||
mu sync.Mutex // 保护 stopped / 进程句柄
|
||||
stopped bool // 已经 Stop 过了
|
||||
cancel context.CancelFunc
|
||||
done chan struct{} // 进程退出时关闭(Wait 协程负责)
|
||||
restartMu sync.Mutex // 串行化重启:同一时刻只一个 goroutine 重启,其余排队等
|
||||
}
|
||||
|
||||
// DetectResult 二次元检测的结果。
|
||||
type DetectResult struct {
|
||||
Hentai bool // 是否判定为二次元敏感(explicit 或 questionable 超过阈值)
|
||||
Score float64 // 最高非 safe 分数(explicit 或 questionable 中较高的)
|
||||
Rating string // 分级标签:safe / questionable / explicit
|
||||
Ratings map[string]float64 // 三个分级的原始分数
|
||||
TopTags []TagScore // 高置信度标签(最多 10 个)
|
||||
}
|
||||
|
||||
// TagScore 一个标签及其分数。
|
||||
type TagScore struct {
|
||||
Tag string `json:"tag"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
// Start 拉起本地 python 检测服务并等它就绪。
|
||||
// - pythonPath:python 可执行文件路径;为空时在 PATH 里找 python/python3。
|
||||
// - threshold:判定阈值 0~1。
|
||||
//
|
||||
// 找不到 python、装不了依赖、或服务起不来时返回错误(调用方跳过识别即可)。
|
||||
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-hentai-*")
|
||||
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)
|
||||
}
|
||||
|
||||
// 模型目录:程序运行目录下的 lib(和 application.properties / media-info.db 同一根目录)。
|
||||
modelDir := ""
|
||||
if wd, err := os.Getwd(); err == nil {
|
||||
modelDir = filepath.Join(wd, "lib")
|
||||
}
|
||||
|
||||
d := &Detector{
|
||||
threshold: threshold,
|
||||
client: &http.Client{Timeout: requestTimeout},
|
||||
python: py,
|
||||
modelDir: modelDir,
|
||||
scriptDir: tmpDir,
|
||||
}
|
||||
|
||||
// 拉起服务(同样一套逻辑也用于崩溃后的重启)
|
||||
if err := d.launch(); err != nil {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
return nil, err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// launch 启动(或重启)python 检测服务,并等它就绪(模型加载完成)。
|
||||
func (d *Detector) launch() error {
|
||||
// 先把上一次的进程收拾掉(首次启动时没有,直接跳过)
|
||||
d.teardown()
|
||||
|
||||
// 找一个空闲端口
|
||||
port, err := freePort()
|
||||
if err != nil {
|
||||
return fmt.Errorf("找空闲端口失败: %v", err)
|
||||
}
|
||||
|
||||
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.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stdout
|
||||
if err := cmd.Start(); err != nil {
|
||||
cancel()
|
||||
return fmt.Errorf("启动二次元检测服务失败: %v", err)
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
d.url = fmt.Sprintf("http://127.0.0.1:%d", port)
|
||||
d.cmd = cmd
|
||||
d.cancel = cancel
|
||||
d.done = done
|
||||
// 后台等进程退出
|
||||
go func() {
|
||||
_ = cmd.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// 等服务就绪(模型加载完成)。就绪前先探测 /health。
|
||||
if err := d.waitReady(); err != nil {
|
||||
d.teardown()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// teardown 停掉当前 python 进程并清理句柄(不删临时目录)。
|
||||
func (d *Detector) teardown() {
|
||||
if d.cancel != nil {
|
||||
d.cancel()
|
||||
d.cancel = nil
|
||||
}
|
||||
if d.cmd != nil && d.cmd.Process != nil {
|
||||
_ = d.cmd.Process.Kill()
|
||||
}
|
||||
if d.done != nil {
|
||||
select {
|
||||
case <-d.done:
|
||||
case <-time.After(3 * time.Second):
|
||||
}
|
||||
d.done = nil
|
||||
}
|
||||
d.cmd = nil
|
||||
}
|
||||
|
||||
// waitReady 轮询本地服务,直到能正常应答。
|
||||
func (d *Detector) waitReady() error {
|
||||
deadline := time.Now().Add(detectorStartTimeout)
|
||||
for time.Now().Before(deadline) {
|
||||
// 服务进程提前退了直接报错
|
||||
select {
|
||||
case <-d.done:
|
||||
return fmt.Errorf("二次元检测服务提前退出,请确认已安装 onnxruntime 和 Pillow (pip install onnxruntime Pillow numpy)")
|
||||
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(1 * time.Second) // 模型加载较慢,轮询间隔拉长
|
||||
}
|
||||
return fmt.Errorf("二次元检测服务在 %v 内未就绪", detectorStartTimeout)
|
||||
}
|
||||
|
||||
// Detect 检测一张图片是否为二次元敏感内容。
|
||||
// 服务失联时会串行化地重启服务并重试一次。
|
||||
func (d *Detector) Detect(path string) (DetectResult, error) {
|
||||
result, err := d.detectOnce(path)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
// 只有"发请求都失败"才走重启
|
||||
var ce *connError
|
||||
if !errors.As(err, &ce) {
|
||||
return DetectResult{}, err
|
||||
}
|
||||
|
||||
d.restartMu.Lock()
|
||||
defer d.restartMu.Unlock()
|
||||
|
||||
if d.isStopped() {
|
||||
return DetectResult{}, err
|
||||
}
|
||||
// 可能排在前面的 goroutine 已经把服务重启好了:先探活,活着就直接重试
|
||||
if d.healthy() {
|
||||
return d.detectOnce(path)
|
||||
}
|
||||
fmt.Printf("=== 二次元检测服务失联(%v), 正在重启…\n", ce.err)
|
||||
if rerr := d.launch(); rerr != nil {
|
||||
return DetectResult{}, fmt.Errorf("二次元检测服务重启失败: %v", rerr)
|
||||
}
|
||||
fmt.Println("=== 二次元检测服务已重启,继续识别")
|
||||
return d.detectOnce(path)
|
||||
}
|
||||
|
||||
// healthy 快速探活:本地服务是否还能正常应答。
|
||||
func (d *Detector) healthy() bool {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, d.url+"/health", nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
resp, err := d.client.Do(req)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return resp.StatusCode == http.StatusOK
|
||||
}
|
||||
|
||||
// isStopped 判断检测器是否已被 Stop(线程安全)。
|
||||
func (d *Detector) isStopped() bool {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
return d.stopped
|
||||
}
|
||||
|
||||
// detectOnce 发一次检测请求(不带重启逻辑)。
|
||||
func (d *Detector) detectOnce(path string) (DetectResult, 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 DetectResult{}, &connError{err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return DetectResult{}, err
|
||||
}
|
||||
var out struct {
|
||||
Ok bool `json:"ok"`
|
||||
Error string `json:"error"`
|
||||
Hentai bool `json:"hentai"`
|
||||
Score float64 `json:"score"`
|
||||
Rating string `json:"rating"`
|
||||
Ratings map[string]float64 `json:"ratings"`
|
||||
TopTags []TagScore `json:"topTags"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
return DetectResult{}, fmt.Errorf("解析检测结果失败: %v", err)
|
||||
}
|
||||
if !out.Ok {
|
||||
return DetectResult{}, fmt.Errorf("二次元检测服务返回错误: %s", out.Error)
|
||||
}
|
||||
return DetectResult{
|
||||
Hentai: out.Hentai,
|
||||
Score: out.Score,
|
||||
Rating: out.Rating,
|
||||
Ratings: out.Ratings,
|
||||
TopTags: out.TopTags,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// connError 标记"请求根本没发出去/连不上本地服务"的错误(服务失联)。
|
||||
type connError struct{ err error }
|
||||
|
||||
func (e *connError) Error() string { return e.err.Error() }
|
||||
func (e *connError) Unwrap() error { return e.err }
|
||||
|
||||
// Stop 停掉检测服务并清理临时文件。可以安全地重复调用。
|
||||
func (d *Detector) Stop() {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.stopped {
|
||||
return
|
||||
}
|
||||
d.stopped = true
|
||||
d.teardown()
|
||||
if d.scriptDir != "" {
|
||||
_ = os.RemoveAll(d.scriptDir)
|
||||
}
|
||||
}
|
||||
|
||||
// Threshold 返回当前使用的判定阈值(0~1)。给上层记录结果用。
|
||||
func (d *Detector) Threshold() float64 {
|
||||
return d.threshold
|
||||
}
|
||||
|
||||
// resolvePython 在配置值 / PATH 里找可用的 python 解释器。
|
||||
func resolvePython(configured string) string {
|
||||
candidates := []string{}
|
||||
if strings.TrimSpace(configured) != "" {
|
||||
candidates = append(candidates, strings.TrimSpace(configured))
|
||||
}
|
||||
if os.Getenv("KM_HENTAI_PYTHON") != "" {
|
||||
candidates = append(candidates, os.Getenv("KM_HENTAI_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,361 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""wd-tagger 本地检测服务:由 Go 侧启动,常驻提供 /health 和 /detect。
|
||||
|
||||
基于 SmilingWolf/wd-vit-large-tagger-v3 模型,专门用于二次元(anime/hentai)图片分级。
|
||||
输出 rating:safe / rating:questionable / rating:explicit 三个分级分数,
|
||||
以及若干高置信度标签(用于辅助判断内容类型)。
|
||||
|
||||
只依赖 Python 标准库 + onnxruntime + Pillow。
|
||||
模型与标签文件放在 <程序运行目录>/lib/wd-tagger/ 下,首次启动自动从 HuggingFace 下载。
|
||||
|
||||
推理并发策略与 nsfw/server.py 相同:每个 worker 线程各持一份独立 session 实例,
|
||||
避免 onnxruntime 同一实例并发推理崩溃的问题。
|
||||
"""
|
||||
import concurrent.futures
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
try:
|
||||
import numpy as np
|
||||
except ImportError:
|
||||
print("=== 错误: 缺少 numpy,请执行 pip install numpy", flush=True)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
import onnxruntime as ort
|
||||
except ImportError:
|
||||
print("=== 错误: 缺少 onnxruntime,请执行 pip install onnxruntime", flush=True)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
print("=== 错误: 缺少 Pillow,请执行 pip install Pillow", flush=True)
|
||||
sys.exit(1)
|
||||
|
||||
# 模型相关配置
|
||||
MODEL_DIR_NAME = "wd-tagger"
|
||||
MODEL_FILENAME = "model.onnx"
|
||||
TAGS_FILENAME = "selected_tags.csv"
|
||||
|
||||
# HuggingFace 下载地址(wd-vit-large-tagger-v3)
|
||||
HF_MODEL_URL = "https://huggingface.co/SmilingWolf/wd-vit-large-tagger-v3/resolve/main/model.onnx"
|
||||
HF_TAGS_URL = "https://huggingface.co/SmilingWolf/wd-vit-large-tagger-v3/resolve/main/selected_tags.csv"
|
||||
|
||||
# 模型输入尺寸
|
||||
INPUT_SIZE = 448
|
||||
|
||||
# ImageNet 归一化参数
|
||||
IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
|
||||
IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
|
||||
|
||||
# 我们关注的分级标签
|
||||
RATING_TAGS = {
|
||||
"rating:safe": "safe",
|
||||
"rating:questionable": "questionable",
|
||||
"rating:explicit": "explicit",
|
||||
}
|
||||
|
||||
# 模型目录由 Go 侧作为第二个参数传入
|
||||
MODEL_DIR = sys.argv[2] if len(sys.argv) > 2 else ""
|
||||
|
||||
# 推理并发数
|
||||
INFER_WORKERS = int(os.environ.get("KM_HENTAI_WORKERS", "4") or "4")
|
||||
if INFER_WORKERS < 1:
|
||||
INFER_WORKERS = 1
|
||||
|
||||
_executor = None
|
||||
_thread_local = threading.local()
|
||||
|
||||
# 标签表:启动时从 CSV 加载
|
||||
_tag_names = []
|
||||
_rating_indices = {} # tag名 -> 在输出数组中的索引
|
||||
|
||||
|
||||
def _looks_like_model(path):
|
||||
"""粗略校验文件是不是真 ONNX 模型。"""
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
head = f.read(4)
|
||||
if not head or head.startswith(b"<"):
|
||||
return False
|
||||
return os.path.getsize(path) > 1_000_000
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _download_file(url, local):
|
||||
"""带超时地下载文件。"""
|
||||
req = urllib.request.Request(url)
|
||||
with urllib.request.urlopen(req, timeout=60) as resp, open(local, "wb") as f:
|
||||
shutil.copyfileobj(resp, f)
|
||||
|
||||
|
||||
def _ensure_model(model_dir):
|
||||
"""确保模型和标签文件就绪,返回 (model_path, tags_path) 或 (None, None) 表示用包自带。"""
|
||||
if not model_dir:
|
||||
return None, None
|
||||
|
||||
wd_dir = os.path.join(model_dir, MODEL_DIR_NAME)
|
||||
model_path = os.path.join(wd_dir, MODEL_FILENAME)
|
||||
tags_path = os.path.join(wd_dir, TAGS_FILENAME)
|
||||
|
||||
# 检查模型文件
|
||||
if os.path.isfile(model_path) and _looks_like_model(model_path):
|
||||
print("=== 使用本地 wd-tagger 模型: %s" % model_path, flush=True)
|
||||
else:
|
||||
# 下载模型
|
||||
try:
|
||||
os.makedirs(wd_dir, exist_ok=True)
|
||||
print("=== 本地未找到 wd-tagger 模型, 正在下载: %s" % model_path, flush=True)
|
||||
_download_file(HF_MODEL_URL, model_path)
|
||||
if _looks_like_model(model_path):
|
||||
print("=== wd-tagger 模型下载完成: %s" % model_path, flush=True)
|
||||
else:
|
||||
print("=== 下载的模型文件无效, 删除", flush=True)
|
||||
try:
|
||||
os.remove(model_path)
|
||||
except OSError:
|
||||
pass
|
||||
return None, None
|
||||
except Exception as exc:
|
||||
print("=== wd-tagger 模型下载失败: %s" % exc, flush=True)
|
||||
return None, None
|
||||
|
||||
# 检查标签文件
|
||||
if os.path.isfile(tags_path):
|
||||
print("=== 使用本地标签文件: %s" % tags_path, flush=True)
|
||||
else:
|
||||
try:
|
||||
print("=== 下载标签文件: %s" % tags_path, flush=True)
|
||||
_download_file(HF_TAGS_URL, tags_path)
|
||||
print("=== 标签文件下载完成", flush=True)
|
||||
except Exception as exc:
|
||||
print("=== 标签文件下载失败: %s" % exc, flush=True)
|
||||
return None, None
|
||||
|
||||
return model_path, tags_path
|
||||
|
||||
|
||||
def _load_tags(tags_path):
|
||||
"""加载标签 CSV,建立 tag名 -> 索引 的映射。"""
|
||||
global _tag_names, _rating_indices
|
||||
_tag_names = []
|
||||
_rating_indices = {}
|
||||
|
||||
if not tags_path or not os.path.isfile(tags_path):
|
||||
print("=== 警告: 标签文件不存在,无法解析输出", flush=True)
|
||||
return
|
||||
|
||||
with open(tags_path, "r", encoding="utf-8") as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
name = row.get("name", "")
|
||||
_tag_names.append(name)
|
||||
if name in RATING_TAGS:
|
||||
_rating_indices[name] = len(_tag_names) - 1
|
||||
|
||||
print("=== 已加载 %d 个标签, 其中分级标签: %s" % (len(_tag_names), list(_rating_indices.keys())), flush=True)
|
||||
|
||||
|
||||
def _new_session(model_path):
|
||||
"""创建一份独立的 ONNX 推理 session。"""
|
||||
if model_path:
|
||||
return ort.InferenceSession(model_path, providers=["CPUExecutionProvider"])
|
||||
# 没有本地模型,无法推理
|
||||
raise RuntimeError("wd-tagger 模型不可用")
|
||||
|
||||
|
||||
def get_session():
|
||||
"""确保模型已就绪,返回一个推理 session(启动时调用)。"""
|
||||
global _session
|
||||
if "_session" not in dir() or _session is None:
|
||||
model_path, tags_path = _ensure_model(MODEL_DIR)
|
||||
_load_tags(tags_path)
|
||||
_session = _new_session(model_path)
|
||||
return _session
|
||||
|
||||
|
||||
_session = None
|
||||
|
||||
|
||||
def _preprocess(img_bytes):
|
||||
"""把图片字节预处理成模型输入的 numpy 数组。
|
||||
|
||||
步骤:
|
||||
1. 解码为 PIL Image
|
||||
2. 转为 RGB(如果有 alpha 通道)
|
||||
3. 缩放到 INPUT_SIZE x INPUT_SIZE(保持宽高比,用 padding 补齐)
|
||||
4. 归一化(ImageNet 参数)
|
||||
5. 转为 NHWC 格式的 float32 数组(模型期望 [batch, H, W, C])
|
||||
"""
|
||||
img = Image.open(io.BytesIO(img_bytes))
|
||||
if img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
|
||||
# 保持宽高比缩放,短边补白色
|
||||
w, h = img.size
|
||||
scale = INPUT_SIZE / max(w, h)
|
||||
new_w = int(w * scale)
|
||||
new_h = int(h * scale)
|
||||
img = img.resize((new_w, new_h), Image.LANCZOS)
|
||||
|
||||
# 创建白色背景,把缩放后的图贴到中间
|
||||
canvas = Image.new("RGB", (INPUT_SIZE, INPUT_SIZE), (255, 255, 255))
|
||||
paste_x = (INPUT_SIZE - new_w) // 2
|
||||
paste_y = (INPUT_SIZE - new_h) // 2
|
||||
canvas.paste(img, (paste_x, paste_y))
|
||||
|
||||
# 转 numpy 数组并归一化
|
||||
arr = np.array(canvas, dtype=np.float32) / 255.0
|
||||
arr = (arr - IMAGENET_MEAN) / IMAGENET_STD
|
||||
|
||||
# HWC -> NHWC (模型期望 [batch, height, width, channels])
|
||||
arr = np.expand_dims(arr, axis=0)
|
||||
return arr
|
||||
|
||||
|
||||
def _infer_bytes(session, img_bytes):
|
||||
"""用给定的 session 推理一张图片(字节),返回 (ratings_dict, top_tags_list)。"""
|
||||
inp = _preprocess(img_bytes)
|
||||
input_name = session.get_inputs()[0].name
|
||||
outputs = session.run(None, {input_name: inp})
|
||||
|
||||
# 输出是 [1, num_tags] 的分数数组
|
||||
scores = outputs[0][0] if len(outputs[0].shape) == 2 else outputs[0]
|
||||
|
||||
# 提取分级分数
|
||||
ratings = {}
|
||||
for tag_name, idx in _rating_indices.items():
|
||||
if idx < len(scores):
|
||||
rating_key = RATING_TAGS[tag_name]
|
||||
ratings[rating_key] = float(scores[idx])
|
||||
|
||||
# 提取 top 标签(排除分级标签,取分数最高的 10 个)
|
||||
top_tags = []
|
||||
non_rating_indices = [i for i in range(len(scores)) if i not in _rating_indices.values()]
|
||||
if non_rating_indices:
|
||||
top_indices = sorted(non_rating_indices, key=lambda i: scores[i], reverse=True)[:10]
|
||||
for idx in top_indices:
|
||||
if idx < len(_tag_names) and scores[idx] > 0.5:
|
||||
top_tags.append({"tag": _tag_names[idx], "score": float(scores[idx])})
|
||||
|
||||
return ratings, top_tags
|
||||
|
||||
|
||||
def _read_bytes(path):
|
||||
"""用 Python 读文件字节。"""
|
||||
if os.name == "nt":
|
||||
if path.startswith("\\\\?\\"):
|
||||
lp = path
|
||||
elif path.startswith("\\\\"):
|
||||
lp = "\\\\?\\UNC\\" + path.lstrip("\\")
|
||||
elif os.path.isabs(path):
|
||||
lp = "\\\\?\\" + os.path.normpath(path)
|
||||
else:
|
||||
lp = path
|
||||
else:
|
||||
lp = path
|
||||
|
||||
if not os.path.exists(lp):
|
||||
raise OSError("文件不存在: %s" % path)
|
||||
with open(lp, "rb") as f:
|
||||
data = f.read()
|
||||
if not data:
|
||||
raise OSError("空文件: %s" % path)
|
||||
return data
|
||||
|
||||
|
||||
def _infer(path):
|
||||
"""整个推理任务:读文件 + 预处理 + 推理。"""
|
||||
data = _read_bytes(path)
|
||||
session = getattr(_thread_local, "session", None)
|
||||
if session is None:
|
||||
# 每个 worker 线程各持一份独立 session
|
||||
model_path = os.path.join(MODEL_DIR, MODEL_DIR_NAME, MODEL_FILENAME) if MODEL_DIR else ""
|
||||
session = _new_session(model_path)
|
||||
_thread_local.session = session
|
||||
return _infer_bytes(session, data)
|
||||
|
||||
|
||||
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))
|
||||
if not path:
|
||||
return self._send({"ok": False, "error": "缺少图片路径"})
|
||||
# 丢给推理线程池
|
||||
ratings, top_tags = _executor.submit(_infer, path).result()
|
||||
except Exception as exc:
|
||||
return self._send({"ok": False, "error": str(exc)})
|
||||
|
||||
# 判断是否为 hentai:explicit 分数 >= 阈值 或 questionable 分数 >= 阈值
|
||||
explicit_score = ratings.get("explicit", 0.0)
|
||||
questionable_score = ratings.get("questionable", 0.0)
|
||||
safe_score = ratings.get("safe", 0.0)
|
||||
|
||||
# hentai 判定:explicit 或 questionable 分数超过阈值
|
||||
is_hentai = (explicit_score >= threshold) or (questionable_score >= threshold)
|
||||
|
||||
# 取最高的非 safe 分数作为"敏感分"
|
||||
max_score = max(explicit_score, questionable_score)
|
||||
|
||||
# 确定分级标签
|
||||
rating = "safe"
|
||||
if explicit_score >= questionable_score and explicit_score >= threshold:
|
||||
rating = "explicit"
|
||||
elif questionable_score >= threshold:
|
||||
rating = "questionable"
|
||||
|
||||
self._send({
|
||||
"ok": True,
|
||||
"hentai": is_hentai,
|
||||
"score": max_score,
|
||||
"rating": rating,
|
||||
"ratings": ratings,
|
||||
"topTags": top_tags,
|
||||
})
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
|
||||
class Server(ThreadingHTTPServer):
|
||||
request_queue_size = 128
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(sys.argv[1]) if len(sys.argv) > 1 else 0
|
||||
# 启动前先把模型准备好
|
||||
get_session()
|
||||
# 固定大小推理线程池
|
||||
_executor = concurrent.futures.ThreadPoolExecutor(max_workers=INFER_WORKERS)
|
||||
Server(("127.0.0.1", port), Handler).serve_forever()
|
||||
Reference in New Issue
Block a user