357 lines
12 KiB
Python
357 lines
12 KiB
Python
# -*- 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-tagger-v3,轻量版,约 340MB)
|
||
HF_MODEL_URL = "https://huggingface.co/SmilingWolf/wd-vit-tagger-v3/resolve/main/model.onnx"
|
||
HF_TAGS_URL = "https://huggingface.co/SmilingWolf/wd-vit-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 ""
|
||
|
||
# 推理并发数(ViT-Large 模型较大,默认 2 避免 CPU 争抢)
|
||
INFER_WORKERS = int(os.environ.get("KM_HENTAI_WORKERS", "2") or "2")
|
||
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)
|
||
tags_path = "" # 标记标签文件不可用,但仍返回模型路径
|
||
|
||
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)
|
||
if not model_path:
|
||
raise RuntimeError("wd-tagger 模型不可用")
|
||
if not tags_path:
|
||
raise RuntimeError("wd-tagger 标签文件不可用,无法解析输出。请手动下载 %s 到 %s" % (TAGS_FILENAME, os.path.join(MODEL_DIR, MODEL_DIR_NAME)))
|
||
_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. 直接 resize 到 INPUT_SIZE x INPUT_SIZE(官方推荐方式)
|
||
4. 归一化(ImageNet 参数)
|
||
5. 转为 NHWC 格式的 float32 数组(模型期望 [batch, H, W, C])
|
||
"""
|
||
img = Image.open(io.BytesIO(img_bytes))
|
||
if img.mode != "RGB":
|
||
img = img.convert("RGB")
|
||
|
||
# 直接 resize 到目标尺寸(wd-tagger 官方推荐)
|
||
img = img.resize((INPUT_SIZE, INPUT_SIZE), Image.LANCZOS)
|
||
|
||
# 转 numpy 数组并归一化
|
||
arr = np.array(img, 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()
|