Spaces:
Running
Running
IdleCloud commited on
Commit ·
5dd4a4e
1
Parent(s): 57942b0
Upgrade to PixAI Tagger v1.0 and prefer Index-Translate
Browse files- .gitignore +6 -0
- README.md +43 -2
- app.py +66 -104
- handler.py +105 -248
- image_inversion_job_api.py +8 -7
- requirements.txt +6 -5
- tests/test_app_v1.py +160 -0
- tests/test_handler.py +158 -0
- tests/test_translator.py +145 -0
- translator.py +114 -12
.gitignore
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
.venv/
|
| 4 |
+
assets/
|
| 5 |
+
assets-v1.0/
|
| 6 |
+
.cache/
|
README.md
CHANGED
|
@@ -14,6 +14,47 @@ hf_oauth: true
|
|
| 14 |
|
| 15 |
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
## 自定义任务 API
|
| 18 |
|
| 19 |
该 Space 在保留原 Gradio 页面功能的同时提供受共享密钥保护的异步标签分析接口:
|
|
@@ -27,8 +68,8 @@ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-
|
|
| 27 |
```json
|
| 28 |
{
|
| 29 |
"input_image_url": "https://example.com/input.png",
|
| 30 |
-
"general_threshold": 0.
|
| 31 |
-
"character_threshold": 0.
|
| 32 |
"show_confidence": true,
|
| 33 |
"show_general": true,
|
| 34 |
"show_character": true,
|
|
|
|
| 14 |
|
| 15 |
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
| 16 |
|
| 17 |
+
## 模型与翻译
|
| 18 |
+
|
| 19 |
+
默认使用 [PixAI Tagger v1.0](https://huggingface.co/pixai-labs/pixai-tagger-v1.0),
|
| 20 |
+
模型权重、配置与官方推理代码固定在 `9fe10addf9326e292da8a85a98ea74cd91b41771`。
|
| 21 |
+
采用官方 1008 × 1008 等比缩放和补边预处理,透明背景合成为白色。
|
| 22 |
+
|
| 23 |
+
- 页面及任务 API 的默认阈值:通用 `0.17`、角色 `0.27`;请求中显式设置的阈值仍会生效。
|
| 24 |
+
- IP 标签使用模型 `copyright` 类别及其真实置信度,默认阈值 `0.24`。
|
| 25 |
+
- 原有三个展示类别及任务 API 六个顶层返回字段保持兼容。
|
| 26 |
+
- 六类原始标签及分数保存在页面“推理元数据”与任务结果的 `metadata.tags` 中:
|
| 27 |
+
`general`、`character`、`copyright`、`style`、`meta`、`rating`。
|
| 28 |
+
后三类使用官方推荐阈值 `0.15`、`0.17`、`0.41`。
|
| 29 |
+
|
| 30 |
+
翻译默认调用 [Index-Translate 官方公开 API](https://github.com/bilibili/Index-Translate):
|
| 31 |
+
`https://index-translate.bilibili.com/v1/chat/completions`,模型 `Index-Translate-35B-A3B`,默认无需密钥。
|
| 32 |
+
标签分批翻译,使用编号核对译文,保持标签与翻译一一对应。Index 请求失败或译文校验失败时,整组标签按顺序回退到原腾讯云账号池、百度账号池,全部失败时保留原文。
|
| 33 |
+
|
| 34 |
+
可选翻译配置:
|
| 35 |
+
|
| 36 |
+
| Variable / Secret | 默认值 / 用途 |
|
| 37 |
+
| --- | --- |
|
| 38 |
+
| `INDEX_TRANSLATE_URL` | 上述完整接口地址 |
|
| 39 |
+
| `INDEX_TRANSLATE_MODEL` | `Index-Translate-35B-A3B` |
|
| 40 |
+
| `INDEX_TRANSLATE_TIMEOUT` | `30` 秒 |
|
| 41 |
+
| `INDEX_TRANSLATE_API_KEY`(Secret) | 仅接入需要鉴权的兼容服务时设置 |
|
| 42 |
+
|
| 43 |
+
原 `TENCENT_CREDENTIALS_JSON`、`TENCENT_SECRET_ID`、`TENCENT_SECRET_KEY`、
|
| 44 |
+
`BAIDU_CREDENTIALS_JSON` 等凭据配置继续用于回退,应保存在 Space Secrets 中。
|
| 45 |
+
HF 模型下载令牌不会发送给翻译服务。
|
| 46 |
+
|
| 47 |
+
升级部署检查:
|
| 48 |
+
|
| 49 |
+
1. 安装更新后的 `requirements.txt`,保留现有 Job API Secrets 和图片域名白名单。
|
| 50 |
+
2. 如设置过 `ASSETS_REPO_ID` / `ASSETS_REVISION`,更新为上述 v1.0 仓库和提交;旧版资源不能用于新 handler。
|
| 51 |
+
`MODEL_DIR` 现在作为 Hub 缓存目录,默认 `./assets-v1.0`;运行用户必须可写。
|
| 52 |
+
3. 首次启动需下载约 1.95 GB 权重。CPU 可运行,页面和 Job API 共用一个模型实例及推理锁;请预留下载空间和启动时间。
|
| 53 |
+
4. 等 Space 构建完成,确认启动日志显示 v1.0 加载成功,再测试页面图片反推和 `/api/jobs` 创建/轮询。
|
| 54 |
+
5. 如需回滚,将 Space 代码恢复到升级前提交 `57942b0f23e74896acf5f1588e8a7d4810bc284f`,并恢复原模型变量,再等待重新构建。
|
| 55 |
+
|
| 56 |
+
本地关键测试:`python -m unittest discover -s tests -v`。
|
| 57 |
+
|
| 58 |
## 自定义任务 API
|
| 59 |
|
| 60 |
该 Space 在保留原 Gradio 页面功能的同时提供受共享密钥保护的异步标签分析接口:
|
|
|
|
| 68 |
```json
|
| 69 |
{
|
| 70 |
"input_image_url": "https://example.com/input.png",
|
| 71 |
+
"general_threshold": 0.17,
|
| 72 |
+
"character_threshold": 0.27,
|
| 73 |
"show_confidence": true,
|
| 74 |
"show_general": true,
|
| 75 |
"show_character": true,
|
app.py
CHANGED
|
@@ -5,7 +5,6 @@ os.environ.setdefault("HF_HUB_DISABLE_IMPLICIT_TOKEN", "1")
|
|
| 5 |
|
| 6 |
import json
|
| 7 |
import time
|
| 8 |
-
import shutil
|
| 9 |
import threading
|
| 10 |
import warnings
|
| 11 |
from html import escape
|
|
@@ -121,10 +120,8 @@ def validate_and_open_image(image_path: str) -> Image.Image:
|
|
| 121 |
try:
|
| 122 |
with Image.open(image_path) as img:
|
| 123 |
img.load()
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
else:
|
| 127 |
-
img = img.copy()
|
| 128 |
except Image.DecompressionBombWarning:
|
| 129 |
raise ImageValidationError("图片在解码阶段触发像素炸弹保护,已拒绝处理。")
|
| 130 |
except Exception as e:
|
|
@@ -134,57 +131,53 @@ def validate_and_open_image(image_path: str) -> Image.Image:
|
|
| 134 |
|
| 135 |
|
| 136 |
# ------------------------------------------------------------------
|
| 137 |
-
#
|
| 138 |
# ------------------------------------------------------------------
|
| 139 |
-
ASSETS_REPO_ID = os.environ.get("ASSETS_REPO_ID", "pixai-labs/pixai-tagger-
|
| 140 |
-
|
|
|
|
| 141 |
ASSETS_HF_TOKEN = os.environ.get("HF_TOKEN")
|
| 142 |
-
MODEL_DIR = os.environ.get("MODEL_DIR", "./assets")
|
| 143 |
|
| 144 |
REQUIRED_FILES = [
|
| 145 |
-
"
|
| 146 |
-
"
|
| 147 |
-
"
|
|
|
|
| 148 |
]
|
| 149 |
|
| 150 |
|
| 151 |
-
def ensure_assets(repo_id: str, revision: Optional[str], target_dir: str) ->
|
| 152 |
-
"""下载
|
| 153 |
|
| 154 |
Args:
|
| 155 |
repo_id: Hugging Face 模型仓库 ID。
|
| 156 |
revision: 可选模型版本;为空时使用仓库默认版本。
|
| 157 |
-
target_dir:
|
| 158 |
|
| 159 |
Returns:
|
| 160 |
-
|
| 161 |
"""
|
| 162 |
target = Path(target_dir)
|
| 163 |
target.mkdir(parents=True, exist_ok=True)
|
| 164 |
|
| 165 |
-
missing = [fname for fname in REQUIRED_FILES if not (target / fname).exists()]
|
| 166 |
-
if not missing:
|
| 167 |
-
return
|
| 168 |
-
|
| 169 |
snapshot_path = snapshot_download(
|
| 170 |
repo_id=repo_id,
|
| 171 |
revision=revision,
|
| 172 |
allow_patterns=REQUIRED_FILES,
|
| 173 |
-
|
|
|
|
| 174 |
token=ASSETS_HF_TOKEN or False,
|
| 175 |
)
|
| 176 |
|
| 177 |
for fname in REQUIRED_FILES:
|
| 178 |
src = Path(snapshot_path) / fname
|
| 179 |
-
dst = target / fname
|
| 180 |
-
|
| 181 |
if not src.exists():
|
| 182 |
raise FileNotFoundError(
|
| 183 |
f"模型资源缺失:'{fname}' 未在 {repo_id} @ {revision or 'default'} 中找到。"
|
| 184 |
)
|
| 185 |
|
| 186 |
-
|
| 187 |
-
shutil.copyfile(src, dst)
|
| 188 |
|
| 189 |
|
| 190 |
# ------------------------------------------------------------------
|
|
@@ -198,48 +191,28 @@ class Tagger:
|
|
| 198 |
|
| 199 |
def _load_model_and_labels(self) -> None:
|
| 200 |
try:
|
| 201 |
-
ensure_assets(ASSETS_REPO_ID, ASSETS_REVISION, MODEL_DIR)
|
| 202 |
-
self.handler = EndpointHandler(
|
| 203 |
self.device = getattr(self.handler, "device", "unknown")
|
| 204 |
-
print(f"✅ PixAI Tagger
|
| 205 |
except Exception as e:
|
| 206 |
-
print(f"❌ PixAI Tagger
|
| 207 |
raise RuntimeError(f"模型初始化失败: {e}") from e
|
| 208 |
|
| 209 |
@staticmethod
|
| 210 |
def _display_tag(tag: str) -> str:
|
| 211 |
return str(tag).replace("_", " ")
|
| 212 |
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
"""
|
| 216 |
-
handler 通常以原始 tag 作为分数字典 key。
|
| 217 |
-
这里额外兼容空格/下划线两种写法,避免 key 不一致时取不到分数。
|
| 218 |
-
"""
|
| 219 |
-
if not isinstance(scores, dict):
|
| 220 |
-
return 0.0
|
| 221 |
-
|
| 222 |
-
candidates = [
|
| 223 |
-
tag,
|
| 224 |
-
str(tag).replace("_", " "),
|
| 225 |
-
str(tag).replace(" ", "_"),
|
| 226 |
-
]
|
| 227 |
-
|
| 228 |
-
for key in candidates:
|
| 229 |
-
if key in scores:
|
| 230 |
-
try:
|
| 231 |
-
return float(scores[key])
|
| 232 |
-
except Exception:
|
| 233 |
-
return 0.0
|
| 234 |
|
| 235 |
-
|
|
|
|
|
|
|
|
|
|
| 236 |
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
返回结构保持原 app.py 的 UI 处理习惯:
|
| 240 |
-
- general:通用/特征标签,带置信度
|
| 241 |
-
- characters:角色标签,带置信度
|
| 242 |
-
- ips:IP 标签,新模型不返回评分标签,因此原 ratings 改为 ips,且 IP 不展示伪造置信度
|
| 243 |
"""
|
| 244 |
if self.handler is None:
|
| 245 |
raise RuntimeError("模型未成功加载,无法进行预测。")
|
|
@@ -265,48 +238,37 @@ class Tagger:
|
|
| 265 |
out = self.handler(data)
|
| 266 |
latency = round(time.time() - started, 4)
|
| 267 |
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
self._display_tag(tag): self._get_score(character_scores, tag)
|
| 281 |
-
for tag in character_tags
|
| 282 |
-
}
|
| 283 |
-
|
| 284 |
-
# IP 标签没有评分,使用 None 表示“不显示置信度”
|
| 285 |
-
ips = {
|
| 286 |
-
self._display_tag(tag): None
|
| 287 |
-
for tag in ip_tags
|
| 288 |
-
}
|
| 289 |
-
|
| 290 |
-
general = dict(sorted(general.items(), key=lambda kv: kv[1], reverse=True))
|
| 291 |
-
characters = dict(sorted(characters.items(), key=lambda kv: kv[1], reverse=True))
|
| 292 |
-
|
| 293 |
-
res = {
|
| 294 |
-
"general": general,
|
| 295 |
-
"characters": characters,
|
| 296 |
-
"ips": ips,
|
| 297 |
-
}
|
| 298 |
-
|
| 299 |
tag_categories_for_translation = {
|
| 300 |
-
|
| 301 |
-
"characters": list(characters.keys()),
|
| 302 |
-
"ips": list(ips.keys()),
|
| 303 |
}
|
| 304 |
|
| 305 |
raw_meta = {
|
|
|
|
|
|
|
| 306 |
"device": str(self.device),
|
| 307 |
"latency_s_total": latency,
|
| 308 |
-
"_params": out
|
| 309 |
-
"_timings": out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
}
|
| 311 |
|
| 312 |
return res, tag_categories_for_translation, raw_meta
|
|
@@ -337,7 +299,7 @@ def predict_tags_with_shared_slot(
|
|
| 337 |
"""在共享单推理门闩内调用现有标签模型。
|
| 338 |
|
| 339 |
Args:
|
| 340 |
-
image: 已完成安全校验
|
| 341 |
general_threshold: 通用标签阈值。
|
| 342 |
character_threshold: 角色标签阈值。
|
| 343 |
|
|
@@ -470,8 +432,8 @@ with gr.Blocks(theme=gr.themes.Soft(), title="AI 图像标签分析器", css=cus
|
|
| 470 |
gr.Markdown("# 🖼️ AI 图像标签分析器")
|
| 471 |
gr.Markdown(
|
| 472 |
"上传图片自动识别标签,支持中英文显示和一键复制。"
|
| 473 |
-
f"**当前模型:
|
| 474 |
-
"
|
| 475 |
)
|
| 476 |
|
| 477 |
state_res = gr.State({})
|
|
@@ -480,7 +442,7 @@ with gr.Blocks(theme=gr.themes.Soft(), title="AI 图像标签分析器", css=cus
|
|
| 480 |
|
| 481 |
with gr.Row():
|
| 482 |
with gr.Column(scale=1):
|
| 483 |
-
img_in = gr.Image(type="filepath", label="上传图片", height=300)
|
| 484 |
|
| 485 |
btn = gr.Button("🚀 开始分析", variant="primary", elem_classes=["btn-analyze-container"])
|
| 486 |
|
|
@@ -488,7 +450,7 @@ with gr.Blocks(theme=gr.themes.Soft(), title="AI 图像标签分析器", css=cus
|
|
| 488 |
gen_slider = gr.Slider(
|
| 489 |
0,
|
| 490 |
1,
|
| 491 |
-
value=0.
|
| 492 |
step=0.01,
|
| 493 |
label="通用标签阈值",
|
| 494 |
info="越高 → 标签更少更准",
|
|
@@ -496,15 +458,15 @@ with gr.Blocks(theme=gr.themes.Soft(), title="AI 图像标签分析器", css=cus
|
|
| 496 |
char_slider = gr.Slider(
|
| 497 |
0,
|
| 498 |
1,
|
| 499 |
-
value=0.
|
| 500 |
step=0.01,
|
| 501 |
label="角色标签阈值",
|
| 502 |
-
info="推荐
|
| 503 |
)
|
| 504 |
show_tag_scores = gr.Checkbox(
|
| 505 |
True,
|
| 506 |
label="在列表中显示标签置信度",
|
| 507 |
-
info="IP 标签
|
| 508 |
)
|
| 509 |
|
| 510 |
with gr.Accordion("📊 标签汇总设置", open=True):
|
|
@@ -523,10 +485,10 @@ with gr.Blocks(theme=gr.themes.Soft(), title="AI 图像标签分析器", css=cus
|
|
| 523 |
with gr.TabItem("🏷️ 通用标签"):
|
| 524 |
out_general = gr.HTML(label="General Tags")
|
| 525 |
with gr.TabItem("👤 角色标签"):
|
| 526 |
-
gr.Markdown("<p style='color:gray; font-size:small;'>提示:角色标签由模型推断,
|
| 527 |
out_char = gr.HTML(label="Character Tags")
|
| 528 |
with gr.TabItem("🌐 IP 标签"):
|
| 529 |
-
gr.Markdown("<p style='color:gray; font-size:small;'>提示:
|
| 530 |
out_ip = gr.HTML(label="IP Tags")
|
| 531 |
|
| 532 |
gr.Markdown("### 标签汇总结果")
|
|
@@ -858,7 +820,7 @@ def execute_image_inversion_job(
|
|
| 858 |
|
| 859 |
Args:
|
| 860 |
payload: 已通过公开 Schema 校验的标签分析参数。
|
| 861 |
-
input_image: 已由任务 API 安全抓取并
|
| 862 |
job_id: 当前异步任务标识,仅用于隔离任务上下文。
|
| 863 |
|
| 864 |
Returns:
|
|
|
|
| 5 |
|
| 6 |
import json
|
| 7 |
import time
|
|
|
|
| 8 |
import threading
|
| 9 |
import warnings
|
| 10 |
from html import escape
|
|
|
|
| 120 |
try:
|
| 121 |
with Image.open(image_path) as img:
|
| 122 |
img.load()
|
| 123 |
+
# 保留透明通道,让模型官方预处理统一合成白底。
|
| 124 |
+
img = img.copy()
|
|
|
|
|
|
|
| 125 |
except Image.DecompressionBombWarning:
|
| 126 |
raise ImageValidationError("图片在解码阶段触发像素炸弹保护,已拒绝处理。")
|
| 127 |
except Exception as e:
|
|
|
|
| 131 |
|
| 132 |
|
| 133 |
# ------------------------------------------------------------------
|
| 134 |
+
# PixAI Tagger v1.0 模型配置
|
| 135 |
# ------------------------------------------------------------------
|
| 136 |
+
ASSETS_REPO_ID = os.environ.get("ASSETS_REPO_ID", "pixai-labs/pixai-tagger-v1.0")
|
| 137 |
+
# 固定模型权重和自定义推理代码,避免上游 main 更新后混用不同版本。
|
| 138 |
+
ASSETS_REVISION = os.environ.get("ASSETS_REVISION", "9fe10addf9326e292da8a85a98ea74cd91b41771")
|
| 139 |
ASSETS_HF_TOKEN = os.environ.get("HF_TOKEN")
|
| 140 |
+
MODEL_DIR = os.environ.get("MODEL_DIR", "./assets-v1.0")
|
| 141 |
|
| 142 |
REQUIRED_FILES = [
|
| 143 |
+
"config.json",
|
| 144 |
+
"preprocessor_config.json",
|
| 145 |
+
"tagger_pipeline.py",
|
| 146 |
+
"model.safetensors",
|
| 147 |
]
|
| 148 |
|
| 149 |
|
| 150 |
+
def ensure_assets(repo_id: str, revision: Optional[str], target_dir: str) -> str:
|
| 151 |
+
"""下载同一版本的模型、配置和官方推理代码,返回本地快照路径。
|
| 152 |
|
| 153 |
Args:
|
| 154 |
repo_id: Hugging Face 模型仓库 ID。
|
| 155 |
revision: 可选模型版本;为空时使用仓库默认版本。
|
| 156 |
+
target_dir: Hugging Face 模型缓存目录。
|
| 157 |
|
| 158 |
Returns:
|
| 159 |
+
已完整下载的模型快照目录。
|
| 160 |
"""
|
| 161 |
target = Path(target_dir)
|
| 162 |
target.mkdir(parents=True, exist_ok=True)
|
| 163 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
snapshot_path = snapshot_download(
|
| 165 |
repo_id=repo_id,
|
| 166 |
revision=revision,
|
| 167 |
allow_patterns=REQUIRED_FILES,
|
| 168 |
+
cache_dir=str(target),
|
| 169 |
+
# token 仅用于 Hub 下载;模型加载与翻译请求均不传递此凭据。
|
| 170 |
token=ASSETS_HF_TOKEN or False,
|
| 171 |
)
|
| 172 |
|
| 173 |
for fname in REQUIRED_FILES:
|
| 174 |
src = Path(snapshot_path) / fname
|
|
|
|
|
|
|
| 175 |
if not src.exists():
|
| 176 |
raise FileNotFoundError(
|
| 177 |
f"模型资源缺失:'{fname}' 未在 {repo_id} @ {revision or 'default'} 中找到。"
|
| 178 |
)
|
| 179 |
|
| 180 |
+
return snapshot_path
|
|
|
|
| 181 |
|
| 182 |
|
| 183 |
# ------------------------------------------------------------------
|
|
|
|
| 191 |
|
| 192 |
def _load_model_and_labels(self) -> None:
|
| 193 |
try:
|
| 194 |
+
snapshot_path = ensure_assets(ASSETS_REPO_ID, ASSETS_REVISION, MODEL_DIR)
|
| 195 |
+
self.handler = EndpointHandler(snapshot_path)
|
| 196 |
self.device = getattr(self.handler, "device", "unknown")
|
| 197 |
+
print(f"✅ PixAI Tagger v1.0 加载成功,设备:{str(self.device).upper()}")
|
| 198 |
except Exception as e:
|
| 199 |
+
print(f"❌ PixAI Tagger v1.0 加载失败: {e}")
|
| 200 |
raise RuntimeError(f"模型初始化失败: {e}") from e
|
| 201 |
|
| 202 |
@staticmethod
|
| 203 |
def _display_tag(tag: str) -> str:
|
| 204 |
return str(tag).replace("_", " ")
|
| 205 |
|
| 206 |
+
def predict(self, img: Image.Image, gen_th: float = 0.17, char_th: float = 0.27):
|
| 207 |
+
"""运行 v1.0 反推并生成页面和任务 API 共用的标签结果。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
|
| 209 |
+
Args:
|
| 210 |
+
img: 已校验的 PIL 图片,透明背景由官方预处理处理。
|
| 211 |
+
gen_th: 通用标签的置信度阈值。
|
| 212 |
+
char_th: 角色标签的置信度阈值。
|
| 213 |
|
| 214 |
+
Returns:
|
| 215 |
+
三类展示标签、对应翻译顺序,以及包含六类原始标签的元数据。
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
"""
|
| 217 |
if self.handler is None:
|
| 218 |
raise RuntimeError("模型未成功加载,无法进行预测。")
|
|
|
|
| 238 |
out = self.handler(data)
|
| 239 |
latency = round(time.time() - started, 4)
|
| 240 |
|
| 241 |
+
# 保持页面和既有任务 API 的三个展示类别,IP 使用 v1.0 的真实置信度。
|
| 242 |
+
res = {}
|
| 243 |
+
for display_category, model_category in (
|
| 244 |
+
("general", "feature"), ("characters", "character"), ("ips", "ip")
|
| 245 |
+
):
|
| 246 |
+
res[display_category] = {
|
| 247 |
+
self._display_tag(tag): score
|
| 248 |
+
for tag, score in sorted(
|
| 249 |
+
out[f"{model_category}_scores"].items(),
|
| 250 |
+
key=lambda item: item[1], reverse=True,
|
| 251 |
+
)
|
| 252 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
tag_categories_for_translation = {
|
| 254 |
+
category: list(tags) for category, tags in res.items()
|
|
|
|
|
|
|
| 255 |
}
|
| 256 |
|
| 257 |
raw_meta = {
|
| 258 |
+
"model": ASSETS_REPO_ID,
|
| 259 |
+
"revision": ASSETS_REVISION,
|
| 260 |
"device": str(self.device),
|
| 261 |
"latency_s_total": latency,
|
| 262 |
+
"_params": out["_params"],
|
| 263 |
+
"_timings": out["_timings"],
|
| 264 |
+
"tags": {
|
| 265 |
+
"general": out["feature_scores"],
|
| 266 |
+
"character": out["character_scores"],
|
| 267 |
+
"copyright": out["ip_scores"],
|
| 268 |
+
"style": out["style_scores"],
|
| 269 |
+
"meta": out["meta_scores"],
|
| 270 |
+
"rating": out["rating_scores"],
|
| 271 |
+
},
|
| 272 |
}
|
| 273 |
|
| 274 |
return res, tag_categories_for_translation, raw_meta
|
|
|
|
| 299 |
"""在共享单推理门闩内调用现有标签模型。
|
| 300 |
|
| 301 |
Args:
|
| 302 |
+
image: 已完成安全校验的输入图片。
|
| 303 |
general_threshold: 通用标签阈值。
|
| 304 |
character_threshold: 角色标签阈值。
|
| 305 |
|
|
|
|
| 432 |
gr.Markdown("# 🖼️ AI 图像标签分析器")
|
| 433 |
gr.Markdown(
|
| 434 |
"上传图片自动识别标签,支持中英文显示和一键复制。"
|
| 435 |
+
f"**当前模型:{ASSETS_REPO_ID}** | **{DEVICE_LABEL}**\n\n"
|
| 436 |
+
"通用、角色和 IP 标签支持中英文展示;风格、元数据和评级标签可在下方“推理元数据”的 tags 中查看。"
|
| 437 |
)
|
| 438 |
|
| 439 |
state_res = gr.State({})
|
|
|
|
| 442 |
|
| 443 |
with gr.Row():
|
| 444 |
with gr.Column(scale=1):
|
| 445 |
+
img_in = gr.Image(type="filepath", image_mode=None, label="上传图片", height=300)
|
| 446 |
|
| 447 |
btn = gr.Button("🚀 开始分析", variant="primary", elem_classes=["btn-analyze-container"])
|
| 448 |
|
|
|
|
| 450 |
gen_slider = gr.Slider(
|
| 451 |
0,
|
| 452 |
1,
|
| 453 |
+
value=0.17,
|
| 454 |
step=0.01,
|
| 455 |
label="通用标签阈值",
|
| 456 |
info="越高 → 标签更少更准",
|
|
|
|
| 458 |
char_slider = gr.Slider(
|
| 459 |
0,
|
| 460 |
1,
|
| 461 |
+
value=0.27,
|
| 462 |
step=0.01,
|
| 463 |
label="角色标签阈值",
|
| 464 |
+
info="v1.0 推荐 0.27;提高阈值可减少误识别",
|
| 465 |
)
|
| 466 |
show_tag_scores = gr.Checkbox(
|
| 467 |
True,
|
| 468 |
label="在列表中显示标签置信度",
|
| 469 |
+
info="通用、角色和 IP 标签均显示模型置信度。",
|
| 470 |
)
|
| 471 |
|
| 472 |
with gr.Accordion("📊 标签汇总设置", open=True):
|
|
|
|
| 485 |
with gr.TabItem("🏷️ 通用标签"):
|
| 486 |
out_general = gr.HTML(label="General Tags")
|
| 487 |
with gr.TabItem("👤 角色标签"):
|
| 488 |
+
gr.Markdown("<p style='color:gray; font-size:small;'>提示:角色标签由模型推断,默认使用 v1.0 推荐阈值 0.27。</p>")
|
| 489 |
out_char = gr.HTML(label="Character Tags")
|
| 490 |
with gr.TabItem("🌐 IP 标签"):
|
| 491 |
+
gr.Markdown("<p style='color:gray; font-size:small;'>提示:IP 标签来自 v1.0 的版权/作品类别,支持真实置信度。</p>")
|
| 492 |
out_ip = gr.HTML(label="IP Tags")
|
| 493 |
|
| 494 |
gr.Markdown("### 标签汇总结果")
|
|
|
|
| 820 |
|
| 821 |
Args:
|
| 822 |
payload: 已通过公开 Schema 校验的标签分析参数。
|
| 823 |
+
input_image: 已由任务 API 安全抓取并解码的图片。
|
| 824 |
job_id: 当前异步任务标识,仅用于隔离任务上下文。
|
| 825 |
|
| 826 |
Returns:
|
handler.py
CHANGED
|
@@ -1,6 +1,5 @@
|
|
| 1 |
import base64
|
| 2 |
import io
|
| 3 |
-
import json
|
| 4 |
import logging
|
| 5 |
import os
|
| 6 |
import time
|
|
@@ -8,272 +7,130 @@ from pathlib import Path
|
|
| 8 |
from typing import Any
|
| 9 |
|
| 10 |
import requests
|
| 11 |
-
import timm
|
| 12 |
import torch
|
| 13 |
-
import torchvision.transforms as transforms
|
| 14 |
from PIL import Image
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
class TaggingHead(torch.nn.Module):
|
| 18 |
-
def __init__(self, input_dim, num_classes):
|
| 19 |
-
super().__init__()
|
| 20 |
-
self.input_dim = input_dim
|
| 21 |
-
self.num_classes = num_classes
|
| 22 |
-
self.head = torch.nn.Sequential(torch.nn.Linear(input_dim, num_classes))
|
| 23 |
-
|
| 24 |
-
def forward(self, x):
|
| 25 |
-
logits = self.head(x)
|
| 26 |
-
probs = torch.nn.functional.sigmoid(logits)
|
| 27 |
-
return probs
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
def get_tags(tags_file: Path) -> tuple[dict[str, int], int, int]:
|
| 31 |
-
with tags_file.open("r", encoding="utf-8") as f:
|
| 32 |
-
tag_info = json.load(f)
|
| 33 |
-
tag_map = tag_info["tag_map"]
|
| 34 |
-
tag_split = tag_info["tag_split"]
|
| 35 |
-
gen_tag_count = tag_split["gen_tag_count"]
|
| 36 |
-
character_tag_count = tag_split["character_tag_count"]
|
| 37 |
-
return tag_map, gen_tag_count, character_tag_count
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
def get_character_ip_mapping(mapping_file: Path):
|
| 41 |
-
with mapping_file.open("r", encoding="utf-8") as f:
|
| 42 |
-
mapping = json.load(f)
|
| 43 |
-
return mapping
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
def get_encoder():
|
| 47 |
-
base_model_repo = "hf_hub:SmilingWolf/wd-eva02-large-tagger-v3"
|
| 48 |
-
encoder = timm.create_model(base_model_repo, pretrained=False)
|
| 49 |
-
encoder.reset_classifier(0)
|
| 50 |
-
return encoder
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
def get_decoder():
|
| 54 |
-
decoder = TaggingHead(1024, 13461)
|
| 55 |
-
return decoder
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
def get_model():
|
| 59 |
-
encoder = get_encoder()
|
| 60 |
-
decoder = get_decoder()
|
| 61 |
-
model = torch.nn.Sequential(encoder, decoder)
|
| 62 |
-
return model
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
def load_model(weights_file, device):
|
| 66 |
-
model = get_model()
|
| 67 |
-
states_dict = torch.load(weights_file, map_location=device, weights_only=True)
|
| 68 |
-
model.load_state_dict(states_dict)
|
| 69 |
-
model.to(device)
|
| 70 |
-
model.eval()
|
| 71 |
-
return model
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
def pure_pil_alpha_to_color_v2(
|
| 75 |
-
image: Image.Image, color: tuple[int, int, int] = (255, 255, 255)
|
| 76 |
-
) -> Image.Image:
|
| 77 |
-
"""
|
| 78 |
-
Convert a PIL image with an alpha channel to a RGB image.
|
| 79 |
-
This is a workaround for the fact that the model expects a RGB image, but the image may have an alpha channel.
|
| 80 |
-
This function will convert the image to a RGB image, and fill the alpha channel with the given color.
|
| 81 |
-
The alpha channel is the 4th channel of the image.
|
| 82 |
-
"""
|
| 83 |
-
image.load() # needed for split()
|
| 84 |
-
background = Image.new("RGB", image.size, color)
|
| 85 |
-
background.paste(image, mask=image.split()[3]) # 3 is the alpha channel
|
| 86 |
-
return background
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
def pil_to_rgb(image: Image.Image) -> Image.Image:
|
| 90 |
-
if image.mode == "RGBA":
|
| 91 |
-
image = pure_pil_alpha_to_color_v2(image)
|
| 92 |
-
elif image.mode == "P":
|
| 93 |
-
image = pure_pil_alpha_to_color_v2(image.convert("RGBA"))
|
| 94 |
-
else:
|
| 95 |
-
image = image.convert("RGB")
|
| 96 |
-
return image
|
| 97 |
|
| 98 |
|
| 99 |
class EndpointHandler:
|
| 100 |
-
|
| 101 |
-
repo_path = Path(path)
|
| 102 |
-
assert repo_path.is_dir(), f"Model directory not found: {repo_path}"
|
| 103 |
-
weights_file = repo_path / "model_v0.9.pth"
|
| 104 |
-
tags_file = repo_path / "tags_v0.9_13k.json"
|
| 105 |
-
mapping_file = repo_path / "char_ip_map.json"
|
| 106 |
-
if not weights_file.exists():
|
| 107 |
-
raise FileNotFoundError(f"Model file not found: {weights_file}")
|
| 108 |
-
if not tags_file.exists():
|
| 109 |
-
raise FileNotFoundError(f"Tags file not found: {tags_file}")
|
| 110 |
-
if not mapping_file.exists():
|
| 111 |
-
raise FileNotFoundError(f"Mapping file not found: {mapping_file}")
|
| 112 |
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
if not force_cpu and torch.cuda.is_available():
|
| 116 |
try:
|
| 117 |
-
# Probe that CUDA can actually be used (driver present)
|
| 118 |
torch.zeros(1).to("cuda")
|
| 119 |
self.device = "cuda"
|
| 120 |
-
except
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
self.
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
|
|
|
|
|
|
| 131 |
)
|
| 132 |
self.fetch_image_timeout = 5.0
|
| 133 |
-
self.default_general_threshold = 0.
|
| 134 |
-
self.default_character_threshold = 0.
|
| 135 |
-
|
| 136 |
-
tag_map, self.gen_tag_count, self.character_tag_count = get_tags(tags_file)
|
| 137 |
-
|
| 138 |
-
# Invert the tag_map for efficient index-to-tag lookups
|
| 139 |
-
self.index_to_tag_map = {v: k for k, v in tag_map.items()}
|
| 140 |
-
|
| 141 |
-
self.character_ip_mapping = get_character_ip_mapping(mapping_file)
|
| 142 |
|
| 143 |
def __call__(self, data: dict[str, Any]) -> dict[str, Any]:
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
if isinstance(inputs, Image.Image):
|
| 148 |
image = inputs
|
| 149 |
-
elif image_url := inputs.
|
| 150 |
-
with requests.get(
|
| 151 |
-
image_url, stream=True, timeout=self.fetch_image_timeout
|
| 152 |
-
) as res:
|
| 153 |
res.raise_for_status()
|
| 154 |
image = Image.open(res.raw)
|
| 155 |
-
|
|
|
|
| 156 |
image = Image.open(io.BytesIO(base64.b64decode(image_base64_encoded)))
|
|
|
|
| 157 |
else:
|
| 158 |
-
raise ValueError(
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
)
|
| 170 |
-
|
| 171 |
-
mode =
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
# Merge for unified post-processing
|
| 204 |
-
combined_indices = torch.cat((gen_idx, char_idx)).cpu()
|
| 205 |
-
combined_scores = torch.cat((gen_scores, char_scores)).cpu()
|
| 206 |
-
else:
|
| 207 |
-
# Perform thresholding directly on the GPU
|
| 208 |
-
general_mask = probs[: self.gen_tag_count] > general_threshold
|
| 209 |
-
character_mask = probs[self.gen_tag_count :] > character_threshold
|
| 210 |
-
|
| 211 |
-
# Get the indices of positive tags on the GPU
|
| 212 |
-
general_indices = general_mask.nonzero(as_tuple=True)[0]
|
| 213 |
-
character_indices = (
|
| 214 |
-
character_mask.nonzero(as_tuple=True)[0] + self.gen_tag_count
|
| 215 |
-
)
|
| 216 |
-
|
| 217 |
-
# Combine indices and move the small result tensor to the CPU
|
| 218 |
-
combined_indices = torch.cat((general_indices, character_indices)).cpu()
|
| 219 |
-
combined_scores = probs[combined_indices].detach().float().cpu()
|
| 220 |
-
|
| 221 |
-
inference_time = time.time() - inference_start_time
|
| 222 |
-
|
| 223 |
-
post_process_start_time = time.time()
|
| 224 |
-
|
| 225 |
-
cur_gen_tags = []
|
| 226 |
-
cur_char_tags = []
|
| 227 |
-
gen_scores_out: dict[str, float] = {}
|
| 228 |
-
char_scores_out: dict[str, float] = {}
|
| 229 |
-
|
| 230 |
-
# Use the efficient pre-computed map for lookups
|
| 231 |
-
for pos, i in enumerate(combined_indices):
|
| 232 |
-
idx = int(i.item())
|
| 233 |
-
tag = self.index_to_tag_map[idx]
|
| 234 |
-
if idx < self.gen_tag_count:
|
| 235 |
-
cur_gen_tags.append(tag)
|
| 236 |
-
if include_scores:
|
| 237 |
-
score = float(combined_scores[pos].item())
|
| 238 |
-
gen_scores_out[tag] = score
|
| 239 |
-
else:
|
| 240 |
-
cur_char_tags.append(tag)
|
| 241 |
-
if include_scores:
|
| 242 |
-
score = float(combined_scores[pos].item())
|
| 243 |
-
char_scores_out[tag] = score
|
| 244 |
-
|
| 245 |
-
ip_tags = []
|
| 246 |
-
for tag in cur_char_tags:
|
| 247 |
-
if tag in self.character_ip_mapping:
|
| 248 |
-
ip_tags.extend(self.character_ip_mapping[tag])
|
| 249 |
-
ip_tags = sorted(set(ip_tags))
|
| 250 |
-
post_process_time = time.time() - post_process_start_time
|
| 251 |
-
|
| 252 |
-
logging.info(
|
| 253 |
-
f"Timing - Fetch: {fetch_time:.3f}s, Inference: {inference_time:.3f}s, Post-process: {post_process_time:.3f}s, Total: {fetch_time + inference_time + post_process_time:.3f}s"
|
| 254 |
-
)
|
| 255 |
-
|
| 256 |
-
out: dict[str, Any] = {
|
| 257 |
-
"feature": cur_gen_tags,
|
| 258 |
-
"character": cur_char_tags,
|
| 259 |
-
"ip": ip_tags,
|
| 260 |
-
"_timings": {
|
| 261 |
-
"fetch_s": round(fetch_time, 4),
|
| 262 |
-
"inference_s": round(inference_time, 4),
|
| 263 |
-
"post_process_s": round(post_process_time, 4),
|
| 264 |
-
"total_s": round(fetch_time + inference_time + post_process_time, 4),
|
| 265 |
-
},
|
| 266 |
-
"_params": {
|
| 267 |
-
"mode": mode,
|
| 268 |
-
"general_threshold": general_threshold,
|
| 269 |
-
"character_threshold": character_threshold,
|
| 270 |
-
"topk_general": topk_general,
|
| 271 |
-
"topk_character": topk_character,
|
| 272 |
-
},
|
| 273 |
}
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
|
|
|
|
|
|
|
|
|
| 279 |
return out
|
|
|
|
| 1 |
import base64
|
| 2 |
import io
|
|
|
|
| 3 |
import logging
|
| 4 |
import os
|
| 5 |
import time
|
|
|
|
| 7 |
from typing import Any
|
| 8 |
|
| 9 |
import requests
|
|
|
|
| 10 |
import torch
|
|
|
|
| 11 |
from PIL import Image
|
| 12 |
+
from transformers import pipeline
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
|
| 15 |
class EndpointHandler:
|
| 16 |
+
"""加载 PixAI Tagger v1.0,并兼容应用现有的标签返回字段。"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
+
def __init__(self, path: str):
|
| 19 |
+
"""从已经下载完整的本地模型目录初始化官方推理流程。
|
| 20 |
+
|
| 21 |
+
Args:
|
| 22 |
+
path: 包含配置、预处理器、官方模型代码和 safetensors 权重的目录。
|
| 23 |
+
"""
|
| 24 |
+
repo_path = Path(path).resolve()
|
| 25 |
+
for filename in (
|
| 26 |
+
"config.json",
|
| 27 |
+
"preprocessor_config.json",
|
| 28 |
+
"tagger_pipeline.py",
|
| 29 |
+
"model.safetensors",
|
| 30 |
+
):
|
| 31 |
+
if not (repo_path / filename).is_file():
|
| 32 |
+
raise FileNotFoundError(f"PixAI Tagger v1.0 文件不存在:{repo_path / filename}")
|
| 33 |
+
|
| 34 |
+
force_cpu = os.environ.get("FORCE_CPU", "0").lower() in {"1", "true", "yes", "on"}
|
| 35 |
+
self.device = "cpu"
|
| 36 |
if not force_cpu and torch.cuda.is_available():
|
| 37 |
try:
|
|
|
|
| 38 |
torch.zeros(1).to("cuda")
|
| 39 |
self.device = "cuda"
|
| 40 |
+
except (RuntimeError, AssertionError):
|
| 41 |
+
logging.warning("CUDA 初始化失败,改用 CPU 加载 PixAI Tagger v1.0")
|
| 42 |
+
|
| 43 |
+
# v1.0 的等比缩放、补边和透明背景处理必须使用随模型发布的处理器。
|
| 44 |
+
# 代码和权重均从同一个本地快照读取,避免初始化时混入其他版本。
|
| 45 |
+
self.tagger = pipeline(
|
| 46 |
+
task="tagger",
|
| 47 |
+
model=str(repo_path),
|
| 48 |
+
image_processor=str(repo_path),
|
| 49 |
+
trust_remote_code=True,
|
| 50 |
+
token=False,
|
| 51 |
+
model_kwargs={"local_files_only": True},
|
| 52 |
+
device=self.device,
|
| 53 |
)
|
| 54 |
self.fetch_image_timeout = 5.0
|
| 55 |
+
self.default_general_threshold = 0.17
|
| 56 |
+
self.default_character_threshold = 0.27
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
def __call__(self, data: dict[str, Any]) -> dict[str, Any]:
|
| 59 |
+
"""识别图片并返回六类标签、可选置信度和推理耗时。
|
| 60 |
+
|
| 61 |
+
Args:
|
| 62 |
+
data: inputs 接收 PIL 图片、url 或 base64 image;parameters 控制
|
| 63 |
+
通用与角色阈值、threshold/topk 模式、标签数量和置信度输出。
|
| 64 |
+
|
| 65 |
+
Returns:
|
| 66 |
+
feature 对应 general,ip 对应 copyright;character、style、meta、
|
| 67 |
+
rating 保留官方分类名称,另含 _params 和 _timings 元数据。
|
| 68 |
+
"""
|
| 69 |
+
inputs = data.get("inputs", data)
|
| 70 |
+
fetch_start_time = time.perf_counter()
|
| 71 |
if isinstance(inputs, Image.Image):
|
| 72 |
image = inputs
|
| 73 |
+
elif image_url := inputs.get("url"):
|
| 74 |
+
with requests.get(image_url, stream=True, timeout=self.fetch_image_timeout) as res:
|
|
|
|
|
|
|
| 75 |
res.raise_for_status()
|
| 76 |
image = Image.open(res.raw)
|
| 77 |
+
image.load()
|
| 78 |
+
elif image_base64_encoded := inputs.get("image"):
|
| 79 |
image = Image.open(io.BytesIO(base64.b64decode(image_base64_encoded)))
|
| 80 |
+
image.load()
|
| 81 |
else:
|
| 82 |
+
raise ValueError("No image or url provided")
|
| 83 |
+
fetch_time = time.perf_counter() - fetch_start_time
|
| 84 |
+
|
| 85 |
+
parameters = data.get("parameters", {})
|
| 86 |
+
general_threshold = float(parameters.get("general_threshold", self.default_general_threshold))
|
| 87 |
+
character_threshold = float(parameters.get("character_threshold", self.default_character_threshold))
|
| 88 |
+
mode = parameters.get("mode", "threshold")
|
| 89 |
+
if mode not in {"threshold", "topk"}:
|
| 90 |
+
raise ValueError("mode 必须为 threshold 或 topk")
|
| 91 |
+
include_scores = bool(parameters.get("include_scores", False))
|
| 92 |
+
topk_general = max(0, int(parameters.get("topk_general", 25)))
|
| 93 |
+
topk_character = max(0, int(parameters.get("topk_character", 10)))
|
| 94 |
+
thresholds = {"general": general_threshold, "character": character_threshold}
|
| 95 |
+
if mode == "topk":
|
| 96 |
+
# 官方流程只有阈值筛选;先取齐这两类概率,再按各自数量截取。
|
| 97 |
+
thresholds = {"general": -1.0, "character": -1.0}
|
| 98 |
+
|
| 99 |
+
inference_start_time = time.perf_counter()
|
| 100 |
+
results = self.tagger(image, threshold=thresholds)["results"]
|
| 101 |
+
inference_time = time.perf_counter() - inference_start_time
|
| 102 |
+
|
| 103 |
+
post_process_start_time = time.perf_counter()
|
| 104 |
+
out: dict[str, Any] = {}
|
| 105 |
+
for source_category, output_category in (
|
| 106 |
+
("general", "feature"),
|
| 107 |
+
("character", "character"),
|
| 108 |
+
("copyright", "ip"),
|
| 109 |
+
("style", "style"),
|
| 110 |
+
("meta", "meta"),
|
| 111 |
+
("rating", "rating"),
|
| 112 |
+
):
|
| 113 |
+
scores = sorted(results[source_category].items(), key=lambda item: item[1], reverse=True)
|
| 114 |
+
if mode == "topk" and source_category in {"general", "character"}:
|
| 115 |
+
limit = topk_general if source_category == "general" else topk_character
|
| 116 |
+
scores = scores[:limit]
|
| 117 |
+
out[output_category] = [tag for tag, _ in scores]
|
| 118 |
+
if include_scores:
|
| 119 |
+
out[f"{output_category}_scores"] = {tag: float(score) for tag, score in scores}
|
| 120 |
+
post_process_time = time.perf_counter() - post_process_start_time
|
| 121 |
+
|
| 122 |
+
out["_timings"] = {
|
| 123 |
+
"fetch_s": round(fetch_time, 4),
|
| 124 |
+
"inference_s": round(inference_time, 4),
|
| 125 |
+
"post_process_s": round(post_process_time, 4),
|
| 126 |
+
"total_s": round(fetch_time + inference_time + post_process_time, 4),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
}
|
| 128 |
+
out["_params"] = {
|
| 129 |
+
"mode": mode,
|
| 130 |
+
"general_threshold": general_threshold,
|
| 131 |
+
"character_threshold": character_threshold,
|
| 132 |
+
"topk_general": topk_general,
|
| 133 |
+
"topk_character": topk_character,
|
| 134 |
+
}
|
| 135 |
+
logging.info("PixAI Tagger v1.0 耗时:%s", out["_timings"])
|
| 136 |
return out
|
image_inversion_job_api.py
CHANGED
|
@@ -215,8 +215,8 @@ class ImageInversionJobRequest(BaseModel):
|
|
| 215 |
)
|
| 216 |
|
| 217 |
input_image_url: str = Field(min_length=1, max_length=4096)
|
| 218 |
-
general_threshold: float = Field(default=0.
|
| 219 |
-
character_threshold: float = Field(default=0.
|
| 220 |
show_confidence: bool = True
|
| 221 |
show_general: bool = True
|
| 222 |
show_character: bool = True
|
|
@@ -453,7 +453,7 @@ def decode_image_bytes(
|
|
| 453 |
content_type: str,
|
| 454 |
settings: ImageInversionJobSettings,
|
| 455 |
) -> Image.Image:
|
| 456 |
-
"""校验远程内容并解码为脱离底层流的
|
| 457 |
|
| 458 |
Args:
|
| 459 |
image_bytes: 在字节上限内完整读取的图片内容。
|
|
@@ -461,7 +461,7 @@ def decode_image_bytes(
|
|
| 461 |
settings: 图片字节、像素及单边尺寸限制。
|
| 462 |
|
| 463 |
Returns:
|
| 464 |
-
完成 EXIF 方向修正并复制到内存的
|
| 465 |
"""
|
| 466 |
if not image_bytes or len(image_bytes) > settings.max_image_bytes:
|
| 467 |
raise ImageSourceError(
|
|
@@ -496,7 +496,8 @@ def decode_image_bytes(
|
|
| 496 |
"The remote image exceeds the configured dimension limit.",
|
| 497 |
)
|
| 498 |
source_image.load()
|
| 499 |
-
|
|
|
|
| 500 |
except ImageSourceError:
|
| 501 |
raise
|
| 502 |
except (
|
|
@@ -547,7 +548,7 @@ def _fetch_remote_image_inner(
|
|
| 547 |
settings: 图片白名单、超时、体积、像素与尺寸边界。
|
| 548 |
|
| 549 |
Returns:
|
| 550 |
-
可直接交给标签模型的
|
| 551 |
"""
|
| 552 |
started_at = time.monotonic()
|
| 553 |
validated_url = validate_image_url(url, settings)
|
|
@@ -679,7 +680,7 @@ def fetch_remote_image(
|
|
| 679 |
settings: 白名单、硬超时、字节、像素与尺寸边界。
|
| 680 |
|
| 681 |
Returns:
|
| 682 |
-
在总期限内完成验证、EXIF 转正
|
| 683 |
"""
|
| 684 |
if not IMAGE_FETCH_SLOTS.acquire(blocking=False):
|
| 685 |
raise ImageSourceError(
|
|
|
|
| 215 |
)
|
| 216 |
|
| 217 |
input_image_url: str = Field(min_length=1, max_length=4096)
|
| 218 |
+
general_threshold: float = Field(default=0.17, ge=0.0, le=1.0)
|
| 219 |
+
character_threshold: float = Field(default=0.27, ge=0.0, le=1.0)
|
| 220 |
show_confidence: bool = True
|
| 221 |
show_general: bool = True
|
| 222 |
show_character: bool = True
|
|
|
|
| 453 |
content_type: str,
|
| 454 |
settings: ImageInversionJobSettings,
|
| 455 |
) -> Image.Image:
|
| 456 |
+
"""校验远程内容并解码为脱离底层流的图片,保留透明通道。
|
| 457 |
|
| 458 |
Args:
|
| 459 |
image_bytes: 在字节上限内完整读取的图片内容。
|
|
|
|
| 461 |
settings: 图片字节、像素及单边尺寸限制。
|
| 462 |
|
| 463 |
Returns:
|
| 464 |
+
完成 EXIF 方向修正并复制到内存的 PIL 图片。
|
| 465 |
"""
|
| 466 |
if not image_bytes or len(image_bytes) > settings.max_image_bytes:
|
| 467 |
raise ImageSourceError(
|
|
|
|
| 496 |
"The remote image exceeds the configured dimension limit.",
|
| 497 |
)
|
| 498 |
source_image.load()
|
| 499 |
+
# 保留透明通道,交给模型官方预处理合成白底。
|
| 500 |
+
return ImageOps.exif_transpose(source_image).copy()
|
| 501 |
except ImageSourceError:
|
| 502 |
raise
|
| 503 |
except (
|
|
|
|
| 548 |
settings: 图片白名单、超时、体积、像素与尺寸边界。
|
| 549 |
|
| 550 |
Returns:
|
| 551 |
+
可直接交给标签模型且保留透明通道的 PIL 图片。
|
| 552 |
"""
|
| 553 |
started_at = time.monotonic()
|
| 554 |
validated_url = validate_image_url(url, settings)
|
|
|
|
| 680 |
settings: 白名单、硬超时、字节、像素与尺寸边界。
|
| 681 |
|
| 682 |
Returns:
|
| 683 |
+
在总期限内完成验证、EXIF 转正并保留透明通道的 PIL 图片。
|
| 684 |
"""
|
| 685 |
if not IMAGE_FETCH_SLOTS.acquire(blocking=False):
|
| 686 |
raise ImageSourceError(
|
requirements.txt
CHANGED
|
@@ -1,13 +1,14 @@
|
|
| 1 |
gradio==5.29.0
|
| 2 |
fastapi>=0.115.2,<1
|
| 3 |
-
huggingface_hub>=0.
|
| 4 |
numpy
|
| 5 |
pandas
|
| 6 |
-
Pillow
|
| 7 |
onnxruntime
|
| 8 |
-
torch
|
| 9 |
-
torchvision
|
| 10 |
-
timm
|
|
|
|
| 11 |
requests
|
| 12 |
httpx==0.28.1
|
| 13 |
pydantic>=2,<2.12
|
|
|
|
| 1 |
gradio==5.29.0
|
| 2 |
fastapi>=0.115.2,<1
|
| 3 |
+
huggingface_hub>=0.34,<1.0
|
| 4 |
numpy
|
| 5 |
pandas
|
| 6 |
+
Pillow>=10
|
| 7 |
onnxruntime
|
| 8 |
+
torch>=2.6,<3
|
| 9 |
+
torchvision>=0.21,<1
|
| 10 |
+
timm==1.0.27
|
| 11 |
+
transformers==4.57.6
|
| 12 |
requests
|
| 13 |
httpx==0.28.1
|
| 14 |
pydantic>=2,<2.12
|
tests/test_app_v1.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import ast
|
| 2 |
+
import io
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
import tempfile
|
| 6 |
+
import time
|
| 7 |
+
import unittest
|
| 8 |
+
from html import escape
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from types import SimpleNamespace
|
| 11 |
+
from unittest.mock import Mock
|
| 12 |
+
|
| 13 |
+
from PIL import Image
|
| 14 |
+
|
| 15 |
+
from image_inversion_job_api import (
|
| 16 |
+
ImageInversionJobRequest,
|
| 17 |
+
ImageInversionJobSettings,
|
| 18 |
+
decode_image_bytes,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class AppV1IntegrationTests(unittest.TestCase):
|
| 23 |
+
def setUp(self):
|
| 24 |
+
"""隔离加载应用定义,为集成测试准备六类模型结果。"""
|
| 25 |
+
# 只加载本次集成涉及的实际业务定义,避免测试时启动 Gradio 或下载权重。
|
| 26 |
+
app_path = Path(__file__).resolve().parents[1] / "app.py"
|
| 27 |
+
self.tree = ast.parse(app_path.read_text(encoding="utf-8"))
|
| 28 |
+
definition_names = {
|
| 29 |
+
"Tagger", "ImageValidationError", "_format_size", "validate_and_open_image",
|
| 30 |
+
"format_tags_html", "generate_summary_text_content", "execute_image_inversion_job",
|
| 31 |
+
}
|
| 32 |
+
constant_names = {
|
| 33 |
+
"MAX_UPLOAD_BYTES", "MAX_IMAGE_SIDE", "MAX_IMAGE_PIXELS",
|
| 34 |
+
"MAX_DECOMPRESSED_BYTES", "ALLOWED_IMAGE_FORMATS", "ASSETS_REPO_ID",
|
| 35 |
+
"ASSETS_REVISION",
|
| 36 |
+
}
|
| 37 |
+
definitions = [
|
| 38 |
+
node for node in ast.walk(self.tree)
|
| 39 |
+
if (
|
| 40 |
+
isinstance(node, (ast.ClassDef, ast.FunctionDef))
|
| 41 |
+
and node.name in definition_names
|
| 42 |
+
) or (
|
| 43 |
+
isinstance(node, ast.Assign)
|
| 44 |
+
and any(isinstance(target, ast.Name) and target.id in constant_names for target in node.targets)
|
| 45 |
+
)
|
| 46 |
+
]
|
| 47 |
+
self.scope = {
|
| 48 |
+
"Image": Image, "ImageInversionJobRequest": ImageInversionJobRequest,
|
| 49 |
+
"os": os, "time": time, "json": json, "escape": escape,
|
| 50 |
+
}
|
| 51 |
+
exec(compile(ast.Module(body=definitions, type_ignores=[]), str(app_path), "exec"), self.scope)
|
| 52 |
+
self.tagger = self.scope["Tagger"].__new__(self.scope["Tagger"])
|
| 53 |
+
self.tagger.device = "cpu"
|
| 54 |
+
self.handler_output = {
|
| 55 |
+
"feature_scores": {"blue_eyes": 0.71, "long_hair": 0.98},
|
| 56 |
+
"character_scores": {"test_character": 0.84},
|
| 57 |
+
"ip_scores": {"test_series": 0.76},
|
| 58 |
+
"style_scores": {"anime": 0.89},
|
| 59 |
+
"meta_scores": {"highres": 0.91},
|
| 60 |
+
"rating_scores": {"general": 0.99},
|
| 61 |
+
"_params": {"general_threshold": 0.17, "character_threshold": 0.27},
|
| 62 |
+
"_timings": {"inference_s": 0.125},
|
| 63 |
+
}
|
| 64 |
+
self.tagger.handler = Mock(return_value=self.handler_output)
|
| 65 |
+
self.scope["tagger_instance"] = self.tagger
|
| 66 |
+
|
| 67 |
+
def test_predict_preserves_all_categories_and_real_ip_confidence(self):
|
| 68 |
+
"""验证完整类别元数据和真实 IP 置信度。"""
|
| 69 |
+
image = Image.new("RGBA", (32, 32), (10, 20, 30, 0))
|
| 70 |
+
result, translation_order, metadata = self.tagger.predict(image)
|
| 71 |
+
|
| 72 |
+
self.assertEqual(list(result), ["general", "characters", "ips"])
|
| 73 |
+
self.assertEqual(result["general"], {"long hair": 0.98, "blue eyes": 0.71})
|
| 74 |
+
self.assertEqual(translation_order["general"], ["long hair", "blue eyes"])
|
| 75 |
+
self.assertEqual(result["ips"], {"test series": 0.76})
|
| 76 |
+
self.assertEqual(metadata["tags"], {
|
| 77 |
+
"general": self.handler_output["feature_scores"],
|
| 78 |
+
"character": self.handler_output["character_scores"],
|
| 79 |
+
"copyright": self.handler_output["ip_scores"],
|
| 80 |
+
"style": self.handler_output["style_scores"],
|
| 81 |
+
"meta": self.handler_output["meta_scores"],
|
| 82 |
+
"rating": self.handler_output["rating_scores"],
|
| 83 |
+
})
|
| 84 |
+
call_data = self.tagger.handler.call_args.args[0]
|
| 85 |
+
self.assertIs(call_data["inputs"], image)
|
| 86 |
+
self.assertTrue(call_data["parameters"]["include_scores"])
|
| 87 |
+
self.assertEqual(call_data["parameters"]["general_threshold"], 0.17)
|
| 88 |
+
self.assertEqual(call_data["parameters"]["character_threshold"], 0.27)
|
| 89 |
+
|
| 90 |
+
def test_job_keeps_six_fields_and_translation_alignment(self):
|
| 91 |
+
"""验证任务接口保持六个字段且翻译不串位。"""
|
| 92 |
+
self.scope["translate_texts"] = Mock(return_value=["长发", "蓝眼睛", "测试角色", "测试作品"])
|
| 93 |
+
payload = ImageInversionJobRequest(input_image_url="https://example.com/image.png")
|
| 94 |
+
result = self.scope["execute_image_inversion_job"](payload, Image.new("RGB", (32, 32)), "job-id")
|
| 95 |
+
|
| 96 |
+
self.assertEqual(set(result), {
|
| 97 |
+
"status_markdown", "general_tags_html", "character_tags_html",
|
| 98 |
+
"ip_tags_html", "summary_text", "metadata",
|
| 99 |
+
})
|
| 100 |
+
self.scope["translate_texts"].assert_called_once_with(
|
| 101 |
+
["long hair", "blue eyes", "test character", "test series"],
|
| 102 |
+
src_lang="auto", tgt_lang="zh",
|
| 103 |
+
)
|
| 104 |
+
self.assertIn("长发", result["general_tags_html"])
|
| 105 |
+
self.assertIn("测试角色", result["character_tags_html"])
|
| 106 |
+
self.assertIn("测试作品", result["ip_tags_html"])
|
| 107 |
+
self.assertIn("0.760", result["ip_tags_html"])
|
| 108 |
+
self.assertIn("test series/*测试作品*/", result["summary_text"])
|
| 109 |
+
self.assertEqual(len(result["metadata"]["tags"]), 6)
|
| 110 |
+
|
| 111 |
+
# 整个翻译调用异常也不能影响标签和新增元数据的交付。
|
| 112 |
+
self.scope["translate_texts"].side_effect = RuntimeError("translation unavailable")
|
| 113 |
+
untranslated = self.scope["execute_image_inversion_job"](payload, Image.new("RGB", (32, 32)), "job-id")
|
| 114 |
+
self.assertEqual(set(untranslated), set(result))
|
| 115 |
+
self.assertIn("test series", untranslated["summary_text"])
|
| 116 |
+
self.assertNotIn("测试作品", untranslated["ip_tags_html"])
|
| 117 |
+
self.assertEqual(untranslated["metadata"]["tags"], result["metadata"]["tags"])
|
| 118 |
+
|
| 119 |
+
def test_ui_and_api_defaults_match_v1_thresholds(self):
|
| 120 |
+
"""验证页面和任务接口的默认阈值与 v1.0 推荐值一致。"""
|
| 121 |
+
slider_nodes = [
|
| 122 |
+
node for node in ast.walk(self.tree)
|
| 123 |
+
if isinstance(node, ast.Assign)
|
| 124 |
+
and any(isinstance(target, ast.Name) and target.id in {"gen_slider", "char_slider"} for target in node.targets)
|
| 125 |
+
]
|
| 126 |
+
self.scope["gr"] = SimpleNamespace(Slider=lambda *args, **kwargs: kwargs)
|
| 127 |
+
exec(compile(ast.Module(body=slider_nodes, type_ignores=[]), "app-sliders", "exec"), self.scope)
|
| 128 |
+
payload = ImageInversionJobRequest(input_image_url="https://example.com/image.png")
|
| 129 |
+
|
| 130 |
+
self.assertEqual(self.scope["gen_slider"]["value"], 0.17)
|
| 131 |
+
self.assertEqual(self.scope["char_slider"]["value"], 0.27)
|
| 132 |
+
self.assertEqual(payload.general_threshold, 0.17)
|
| 133 |
+
self.assertEqual(payload.character_threshold, 0.27)
|
| 134 |
+
|
| 135 |
+
def test_upload_and_job_decode_preserve_transparency(self):
|
| 136 |
+
"""验证上传和远程解码均将透明背景留给官方处理器。"""
|
| 137 |
+
settings = ImageInversionJobSettings(api_key="x" * 32, allowed_hosts=frozenset({"example.com"}))
|
| 138 |
+
for mode in ("RGBA", "P"):
|
| 139 |
+
with self.subTest(mode=mode):
|
| 140 |
+
image = Image.new("RGBA", (32, 32), (10, 20, 30, 0))
|
| 141 |
+
if mode == "P":
|
| 142 |
+
image = image.convert("P")
|
| 143 |
+
buffer = io.BytesIO()
|
| 144 |
+
image.save(buffer, format="PNG")
|
| 145 |
+
decoded = decode_image_bytes(buffer.getvalue(), "image/png", settings)
|
| 146 |
+
self.assertEqual(decoded.convert("RGBA").getpixel((0, 0))[3], 0)
|
| 147 |
+
|
| 148 |
+
# 仅创建并显式删除当前测试的单个文件,不创建需递归清理的目录。
|
| 149 |
+
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as source:
|
| 150 |
+
source.write(buffer.getvalue())
|
| 151 |
+
source_path = Path(source.name).resolve()
|
| 152 |
+
try:
|
| 153 |
+
uploaded = self.scope["validate_and_open_image"](str(source_path))
|
| 154 |
+
self.assertEqual(uploaded.convert("RGBA").getpixel((0, 0))[3], 0)
|
| 155 |
+
finally:
|
| 156 |
+
source_path.unlink()
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
if __name__ == "__main__":
|
| 160 |
+
unittest.main()
|
tests/test_handler.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""不下载模型权重,验证 v1.0 推理适配层与官方 pipeline 的调用契约。"""
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
import importlib.util
|
| 5 |
+
import io
|
| 6 |
+
import os
|
| 7 |
+
import sys
|
| 8 |
+
import unittest
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from types import SimpleNamespace
|
| 11 |
+
from unittest.mock import MagicMock, Mock, patch
|
| 12 |
+
|
| 13 |
+
from PIL import Image
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class EndpointHandlerTest(unittest.TestCase):
|
| 17 |
+
"""覆盖标签映射、真实分数、预处理委托和本地加载边界。"""
|
| 18 |
+
|
| 19 |
+
@classmethod
|
| 20 |
+
def setUpClass(cls):
|
| 21 |
+
"""隔离导入推理依赖,让适配层测试可在没有 GPU 和权重时运行。"""
|
| 22 |
+
spec = importlib.util.spec_from_file_location(
|
| 23 |
+
"pixai_handler_test", Path(__file__).resolve().parents[1] / "handler.py"
|
| 24 |
+
)
|
| 25 |
+
cls.module = importlib.util.module_from_spec(spec)
|
| 26 |
+
cls.torch = SimpleNamespace(
|
| 27 |
+
cuda=SimpleNamespace(is_available=Mock(return_value=False)),
|
| 28 |
+
zeros=Mock(),
|
| 29 |
+
)
|
| 30 |
+
with patch.dict(
|
| 31 |
+
sys.modules,
|
| 32 |
+
{"torch": cls.torch, "transformers": SimpleNamespace(pipeline=Mock())},
|
| 33 |
+
):
|
| 34 |
+
spec.loader.exec_module(cls.module)
|
| 35 |
+
|
| 36 |
+
def setUp(self):
|
| 37 |
+
"""构造各类别独立的分数,避免将版权或风格误当成通用标签。"""
|
| 38 |
+
self.results = {
|
| 39 |
+
"general": {"blue_hair": 0.7, "1girl": 0.99, "hat": 0.2},
|
| 40 |
+
"character": {"character_b": 0.3, "character_a": 0.9},
|
| 41 |
+
"copyright": {"original": 0.64},
|
| 42 |
+
"style": {"style_a": 0.6},
|
| 43 |
+
"meta": {"highres": 0.8},
|
| 44 |
+
"rating": {"rating:g": 0.88},
|
| 45 |
+
}
|
| 46 |
+
self.pipe = Mock(return_value={"results": self.results})
|
| 47 |
+
self.factory = Mock(return_value=self.pipe)
|
| 48 |
+
self.torch.cuda.is_available.return_value = False
|
| 49 |
+
self.torch.zeros.reset_mock()
|
| 50 |
+
self.torch.zeros.return_value.to.side_effect = None
|
| 51 |
+
self.model_path = Path(__file__).resolve().parents[1]
|
| 52 |
+
with patch.object(Path, "is_file", return_value=True), patch.object(
|
| 53 |
+
self.module, "pipeline", self.factory
|
| 54 |
+
):
|
| 55 |
+
self.handler = self.module.EndpointHandler(str(self.model_path))
|
| 56 |
+
|
| 57 |
+
def test_local_snapshot_and_official_processor(self):
|
| 58 |
+
"""本地四文件快照与官方自定义任务共同决定模型和预处理版本。"""
|
| 59 |
+
self.factory.assert_called_once_with(
|
| 60 |
+
task="tagger",
|
| 61 |
+
model=str(self.model_path),
|
| 62 |
+
image_processor=str(self.model_path),
|
| 63 |
+
trust_remote_code=True,
|
| 64 |
+
token=False,
|
| 65 |
+
model_kwargs={"local_files_only": True},
|
| 66 |
+
device="cpu",
|
| 67 |
+
)
|
| 68 |
+
image = Image.new("RGBA", (80, 40), (255, 0, 0, 0))
|
| 69 |
+
data = {"inputs": image, "parameters": {"include_scores": True}}
|
| 70 |
+
result = self.handler(data)
|
| 71 |
+
self.pipe.assert_called_once_with(
|
| 72 |
+
image, threshold={"general": 0.17, "character": 0.27}
|
| 73 |
+
)
|
| 74 |
+
self.assertEqual(image.mode, "RGBA")
|
| 75 |
+
self.assertEqual(image.size, (80, 40))
|
| 76 |
+
self.assertIs(data["inputs"], image)
|
| 77 |
+
self.assertEqual(data["parameters"], {"include_scores": True})
|
| 78 |
+
self.assertEqual(result["feature"], ["1girl", "blue_hair", "hat"])
|
| 79 |
+
self.assertEqual(result["feature_scores"], self.results["general"])
|
| 80 |
+
for category in ("character", "style", "meta", "rating"):
|
| 81 |
+
self.assertEqual(result[f"{category}_scores"], self.results[category])
|
| 82 |
+
self.assertEqual(result["ip"], ["original"])
|
| 83 |
+
self.assertEqual(result["ip_scores"], {"original": 0.64})
|
| 84 |
+
self.assertEqual(result["_params"]["general_threshold"], 0.17)
|
| 85 |
+
self.assertEqual(set(result["_timings"]), {"fetch_s", "inference_s", "post_process_s", "total_s"})
|
| 86 |
+
|
| 87 |
+
def test_custom_thresholds_and_optional_scores(self):
|
| 88 |
+
"""阈值交由官方流程筛选,关闭分数时不泄漏分数字段。"""
|
| 89 |
+
image = Image.new("RGB", (32, 32))
|
| 90 |
+
result = self.handler(
|
| 91 |
+
{"inputs": image, "parameters": {"general_threshold": 0.4, "character_threshold": 0.6}}
|
| 92 |
+
)
|
| 93 |
+
self.pipe.assert_called_once_with(image, threshold={"general": 0.4, "character": 0.6})
|
| 94 |
+
self.assertFalse(any(name.endswith("_scores") for name in result))
|
| 95 |
+
|
| 96 |
+
def test_topk_ranks_categories_independently(self):
|
| 97 |
+
"""Top-K 不先经过默认阈值,且保留版权等其他分类的原始分数。"""
|
| 98 |
+
image = Image.new("RGB", (32, 32))
|
| 99 |
+
result = self.handler(
|
| 100 |
+
{
|
| 101 |
+
"inputs": image,
|
| 102 |
+
"parameters": {"mode": "topk", "topk_general": 2, "topk_character": 1, "include_scores": True},
|
| 103 |
+
}
|
| 104 |
+
)
|
| 105 |
+
self.pipe.assert_called_once_with(image, threshold={"general": -1.0, "character": -1.0})
|
| 106 |
+
self.assertEqual(result["feature"], ["1girl", "blue_hair"])
|
| 107 |
+
self.assertEqual(result["character"], ["character_a"])
|
| 108 |
+
self.assertEqual(result["character_scores"], {"character_a": 0.9})
|
| 109 |
+
self.assertEqual(result["ip_scores"], {"original": 0.64})
|
| 110 |
+
zero_result = self.handler(
|
| 111 |
+
{"inputs": image, "parameters": {"mode": "topk", "topk_general": 0, "topk_character": 0}}
|
| 112 |
+
)
|
| 113 |
+
self.assertEqual(zero_result["feature"], [])
|
| 114 |
+
self.assertEqual(zero_result["character"], [])
|
| 115 |
+
|
| 116 |
+
def test_base64_and_remote_images_are_decoded_before_inference(self):
|
| 117 |
+
"""URL 图片在响应关闭前解码,base64 和透明图片也交由官方处理器。"""
|
| 118 |
+
image_bytes = io.BytesIO()
|
| 119 |
+
Image.new("RGBA", (80, 40), (0, 255, 0, 0)).save(image_bytes, format="PNG")
|
| 120 |
+
image_content = image_bytes.getvalue()
|
| 121 |
+
self.handler({"inputs": {"image": base64.b64encode(image_content).decode()}})
|
| 122 |
+
decoded_image = self.pipe.call_args.args[0]
|
| 123 |
+
self.assertEqual((decoded_image.mode, decoded_image.size), ("RGBA", (80, 40)))
|
| 124 |
+
|
| 125 |
+
response = MagicMock()
|
| 126 |
+
response.raw = io.BytesIO(image_content)
|
| 127 |
+
response.__enter__.return_value = response
|
| 128 |
+
response.__exit__.side_effect = lambda *_: response.raw.close()
|
| 129 |
+
with patch.object(self.module.requests, "get", return_value=response) as get:
|
| 130 |
+
self.handler({"inputs": {"url": "https://example.org/image.png"}})
|
| 131 |
+
get.assert_called_once_with("https://example.org/image.png", stream=True, timeout=5.0)
|
| 132 |
+
response.raise_for_status.assert_called_once_with()
|
| 133 |
+
self.assertTrue(response.raw.closed)
|
| 134 |
+
self.assertEqual(self.pipe.call_args.args[0].getpixel((0, 0)), (0, 255, 0, 0))
|
| 135 |
+
|
| 136 |
+
def test_incomplete_snapshot_fails_before_model_loading(self):
|
| 137 |
+
"""缺失新版本预处理器时直接报错,避免加载旧版或远程补齐混用。"""
|
| 138 |
+
with patch.object(
|
| 139 |
+
Path, "is_file", autospec=True, side_effect=lambda path: path.name != "preprocessor_config.json"
|
| 140 |
+
), patch.object(self.module, "pipeline") as factory:
|
| 141 |
+
with self.assertRaisesRegex(FileNotFoundError, "preprocessor_config.json"):
|
| 142 |
+
self.module.EndpointHandler(str(self.model_path))
|
| 143 |
+
factory.assert_not_called()
|
| 144 |
+
|
| 145 |
+
def test_cuda_probe_failure_uses_cpu(self):
|
| 146 |
+
"""存在 CUDA 接口但驱动不能工作时,仍能使用 CPU 加载。"""
|
| 147 |
+
self.torch.cuda.is_available.return_value = True
|
| 148 |
+
self.torch.zeros.return_value.to.side_effect = RuntimeError("driver unavailable")
|
| 149 |
+
with patch.dict(os.environ, {"FORCE_CPU": "0"}), patch.object(
|
| 150 |
+
Path, "is_file", return_value=True
|
| 151 |
+
), patch.object(self.module, "pipeline", self.factory):
|
| 152 |
+
handler = self.module.EndpointHandler(str(self.model_path))
|
| 153 |
+
self.assertEqual(handler.device, "cpu")
|
| 154 |
+
self.assertEqual(self.factory.call_args.kwargs["device"], "cpu")
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
if __name__ == "__main__":
|
| 158 |
+
unittest.main()
|
tests/test_translator.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import unittest
|
| 3 |
+
from unittest.mock import Mock, patch
|
| 4 |
+
|
| 5 |
+
import requests
|
| 6 |
+
|
| 7 |
+
import translator
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class TranslatorTests(unittest.TestCase):
|
| 11 |
+
def test_index_preserves_order_and_does_not_call_legacy_providers(self):
|
| 12 |
+
response = Mock()
|
| 13 |
+
response.json.return_value = {
|
| 14 |
+
"choices": [{
|
| 15 |
+
"finish_reason": "stop",
|
| 16 |
+
"message": {"content": '{"2":"蓝眼睛","0":"长发","1":"长发"}'},
|
| 17 |
+
}]
|
| 18 |
+
}
|
| 19 |
+
with (
|
| 20 |
+
patch.object(translator.requests, "post", return_value=response) as post,
|
| 21 |
+
patch.object(translator, "INDEX_TRANSLATE_API_KEY", ""),
|
| 22 |
+
patch.object(translator, "_translate_with_tencent_pool") as tencent,
|
| 23 |
+
patch.object(translator, "_translate_with_baidu_pool") as baidu,
|
| 24 |
+
):
|
| 25 |
+
result = translator.translate_texts(["long_hair", "long_hair", "blue_eyes"])
|
| 26 |
+
|
| 27 |
+
self.assertEqual(result, ["长发", "长发", "蓝眼睛"])
|
| 28 |
+
tencent.assert_not_called()
|
| 29 |
+
baidu.assert_not_called()
|
| 30 |
+
self.assertEqual(post.call_args.args, (translator.INDEX_TRANSLATE_URL,))
|
| 31 |
+
payload = post.call_args.kwargs["json"]
|
| 32 |
+
self.assertEqual(payload["model"], translator.INDEX_TRANSLATE_MODEL)
|
| 33 |
+
self.assertEqual(payload["chat_template_kwargs"], {"enable_thinking": False})
|
| 34 |
+
self.assertEqual(payload["temperature"], 0)
|
| 35 |
+
self.assertEqual(
|
| 36 |
+
post.call_args.kwargs["headers"],
|
| 37 |
+
{"User-Agent": "Index-Translate-Client/1.0"},
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
def test_index_batches_by_item_count_and_text_length(self):
|
| 41 |
+
for texts, batch_sizes in [
|
| 42 |
+
([f"tag_{index}" for index in range(33)], [32, 1]),
|
| 43 |
+
(["a" * 1000, "b" * 1000, "c" * 1000], [2, 1]),
|
| 44 |
+
]:
|
| 45 |
+
with self.subTest(batch_sizes=batch_sizes):
|
| 46 |
+
responses = []
|
| 47 |
+
expected = []
|
| 48 |
+
for batch_number, size in enumerate(batch_sizes):
|
| 49 |
+
translations = {str(index): f"译文{batch_number}-{index}" for index in range(size)}
|
| 50 |
+
expected.extend(translations.values())
|
| 51 |
+
response = Mock()
|
| 52 |
+
response.json.return_value = {
|
| 53 |
+
"choices": [{
|
| 54 |
+
"finish_reason": "stop",
|
| 55 |
+
"message": {"content": json.dumps(translations)},
|
| 56 |
+
}]
|
| 57 |
+
}
|
| 58 |
+
responses.append(response)
|
| 59 |
+
with (
|
| 60 |
+
patch.object(translator.requests, "post", side_effect=responses) as post,
|
| 61 |
+
patch.object(translator, "INDEX_TRANSLATE_API_KEY", "test-key"),
|
| 62 |
+
):
|
| 63 |
+
self.assertEqual(translator.translate_texts(texts), expected)
|
| 64 |
+
self.assertEqual(post.call_count, 2)
|
| 65 |
+
self.assertEqual(post.call_args.kwargs["headers"]["Authorization"], "Bearer test-key")
|
| 66 |
+
sources = [
|
| 67 |
+
json.loads(call.kwargs["json"]["messages"][0]["content"].split("〖源文〗\n")[1].split("\n\n")[0])
|
| 68 |
+
for call in post.call_args_list
|
| 69 |
+
]
|
| 70 |
+
self.assertEqual([len(source) for source in sources], batch_sizes)
|
| 71 |
+
self.assertEqual([value for source in sources for value in source.values()], texts)
|
| 72 |
+
|
| 73 |
+
def test_invalid_index_responses_fall_back_without_misaligning_tags(self):
|
| 74 |
+
cases = [
|
| 75 |
+
("not json", "stop"),
|
| 76 |
+
('{"0":"长发"}', "stop"),
|
| 77 |
+
('{"0":"长发","0":"蓝眼睛"}', "stop"),
|
| 78 |
+
('{"0":"长发","1":"蓝眼睛","1":"眼睛"}', "stop"),
|
| 79 |
+
('{"0":"长发","2":"蓝眼睛"}', "stop"),
|
| 80 |
+
('{"0":"长发","1":""}', "stop"),
|
| 81 |
+
('{"0":"长发","1":null}', "stop"),
|
| 82 |
+
('{"0":"长发","1":"蓝眼睛"}', "length"),
|
| 83 |
+
(None, "stop"),
|
| 84 |
+
]
|
| 85 |
+
for content, finish_reason in cases:
|
| 86 |
+
with self.subTest(content=content, finish_reason=finish_reason):
|
| 87 |
+
response = Mock()
|
| 88 |
+
response.json.return_value = {
|
| 89 |
+
"choices": [{"finish_reason": finish_reason, "message": {"content": content}}]
|
| 90 |
+
}
|
| 91 |
+
with (
|
| 92 |
+
patch.object(translator.requests, "post", return_value=response),
|
| 93 |
+
patch.object(translator, "_translate_with_tencent_pool", return_value=["腾讯长发", "腾讯蓝眼睛"]) as tencent,
|
| 94 |
+
patch.object(translator, "_translate_with_baidu_pool") as baidu,
|
| 95 |
+
):
|
| 96 |
+
result = translator.translate_texts(["long_hair", "blue_eyes"], "en", "zh")
|
| 97 |
+
self.assertEqual(result, ["腾讯长发", "腾讯蓝眼睛"])
|
| 98 |
+
tencent.assert_called_once_with(["long_hair", "blue_eyes"], "en", "zh")
|
| 99 |
+
baidu.assert_not_called()
|
| 100 |
+
|
| 101 |
+
def test_index_http_and_transport_errors_use_existing_fallback_chain(self):
|
| 102 |
+
for error in [requests.Timeout(), requests.HTTPError("429")]:
|
| 103 |
+
with self.subTest(error=type(error).__name__):
|
| 104 |
+
with (
|
| 105 |
+
patch.object(translator.requests, "post", side_effect=error),
|
| 106 |
+
patch.object(translator, "_translate_with_tencent_pool", return_value=None) as tencent,
|
| 107 |
+
patch.object(translator, "_translate_with_baidu_pool", return_value=["百度长发"]) as baidu,
|
| 108 |
+
):
|
| 109 |
+
self.assertEqual(translator.translate_texts(["long_hair"]), ["百度长发"])
|
| 110 |
+
tencent.assert_called_once()
|
| 111 |
+
baidu.assert_called_once()
|
| 112 |
+
|
| 113 |
+
def test_later_index_batch_failure_retranslates_all_inputs_in_order(self):
|
| 114 |
+
texts = [f"tag_{index}" for index in range(33)]
|
| 115 |
+
response = Mock()
|
| 116 |
+
response.json.return_value = {
|
| 117 |
+
"choices": [{
|
| 118 |
+
"finish_reason": "stop",
|
| 119 |
+
"message": {"content": json.dumps({str(index): "Index译文" for index in range(32)})},
|
| 120 |
+
}]
|
| 121 |
+
}
|
| 122 |
+
expected = [f"腾讯译文{index}" for index in range(33)]
|
| 123 |
+
with (
|
| 124 |
+
patch.object(translator.requests, "post", side_effect=[response, requests.Timeout()]),
|
| 125 |
+
patch.object(translator, "_translate_with_tencent_pool", return_value=expected) as tencent,
|
| 126 |
+
):
|
| 127 |
+
self.assertEqual(translator.translate_texts(texts), expected)
|
| 128 |
+
tencent.assert_called_once_with(texts, "auto", "zh")
|
| 129 |
+
|
| 130 |
+
def test_incomplete_legacy_results_return_originals_and_empty_inputs_do_not_call_api(self):
|
| 131 |
+
for result in (None, [], ["缺少一项"]):
|
| 132 |
+
with self.subTest(result=result):
|
| 133 |
+
with (
|
| 134 |
+
patch.object(translator.requests, "post", side_effect=requests.Timeout()),
|
| 135 |
+
patch.object(translator, "_translate_with_tencent_pool", return_value=result),
|
| 136 |
+
patch.object(translator, "_translate_with_baidu_pool", return_value=result),
|
| 137 |
+
):
|
| 138 |
+
self.assertEqual(translator.translate_texts(["long_hair", "blue_eyes"]), ["long_hair", "blue_eyes"])
|
| 139 |
+
with patch.object(translator.requests, "post") as post:
|
| 140 |
+
self.assertEqual(translator.translate_texts([]), [])
|
| 141 |
+
post.assert_not_called()
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
if __name__ == "__main__":
|
| 145 |
+
unittest.main()
|
translator.py
CHANGED
|
@@ -1,7 +1,12 @@
|
|
| 1 |
"""
|
| 2 |
translator.py
|
| 3 |
-
腾讯云
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
------------------------------------------------------------------
|
| 6 |
TENCENT_CREDENTIALS_JSON 形如:
|
| 7 |
[
|
|
@@ -23,6 +28,13 @@ from typing import List, Sequence, Optional, Dict, Any
|
|
| 23 |
|
| 24 |
import requests
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
_tencent_creds_list = json.loads(os.environ.get("TENCENT_CREDENTIALS_JSON", "[]"))
|
| 27 |
|
| 28 |
# 兼容旧的单账号配置
|
|
@@ -44,6 +56,89 @@ _baidu_creds_list = json.loads(os.environ.get("BAIDU_CREDENTIALS_JSON", "[]"))
|
|
| 44 |
_baidu_idx: int = 0
|
| 45 |
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
# 腾讯云翻译逻辑 (批量接口 TextTranslateBatch)
|
| 48 |
def _sign(key: bytes, msg: str) -> bytes:
|
| 49 |
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
|
|
@@ -227,20 +322,27 @@ def _translate_with_baidu_pool(texts: Sequence[str], src="auto", tgt="zh") -> Op
|
|
| 227 |
def translate_texts(texts: Sequence[str],
|
| 228 |
src_lang: str = "auto",
|
| 229 |
tgt_lang: str = "zh") -> List[str]:
|
| 230 |
-
"""
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
"""
|
| 236 |
if not texts:
|
| 237 |
return []
|
| 238 |
|
| 239 |
-
|
| 240 |
-
|
|
|
|
|
|
|
| 241 |
|
| 242 |
# 2. 失败降级到百度
|
| 243 |
-
if out is None:
|
| 244 |
out = _translate_with_baidu_pool(texts, src_lang, tgt_lang)
|
| 245 |
-
|
| 246 |
-
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
translator.py
|
| 3 |
+
Index-Translate 优先,失败后依次回退腾讯云和百度翻译。
|
| 4 |
+
Index 官方公开接口无需密钥;可在 HF 空间设置以下可选环境变量:
|
| 5 |
+
INDEX_TRANSLATE_URL 完整的 OpenAI Chat Completions 接口地址
|
| 6 |
+
INDEX_TRANSLATE_MODEL 默认 Index-Translate-35B-A3B
|
| 7 |
+
INDEX_TRANSLATE_API_KEY 自部署接口需要鉴权时配置
|
| 8 |
+
INDEX_TRANSLATE_TIMEOUT 单次请求超时秒数,默认 30
|
| 9 |
+
旧翻译接口继续使用以下环境变量:
|
| 10 |
------------------------------------------------------------------
|
| 11 |
TENCENT_CREDENTIALS_JSON 形如:
|
| 12 |
[
|
|
|
|
| 28 |
|
| 29 |
import requests
|
| 30 |
|
| 31 |
+
INDEX_TRANSLATE_URL = os.environ.get(
|
| 32 |
+
"INDEX_TRANSLATE_URL", "https://index-translate.bilibili.com/v1/chat/completions"
|
| 33 |
+
)
|
| 34 |
+
INDEX_TRANSLATE_MODEL = os.environ.get("INDEX_TRANSLATE_MODEL", "Index-Translate-35B-A3B")
|
| 35 |
+
INDEX_TRANSLATE_API_KEY = os.environ.get("INDEX_TRANSLATE_API_KEY", "")
|
| 36 |
+
INDEX_TRANSLATE_TIMEOUT = float(os.environ.get("INDEX_TRANSLATE_TIMEOUT", "30"))
|
| 37 |
+
|
| 38 |
_tencent_creds_list = json.loads(os.environ.get("TENCENT_CREDENTIALS_JSON", "[]"))
|
| 39 |
|
| 40 |
# 兼容旧的单账号配置
|
|
|
|
| 56 |
_baidu_idx: int = 0
|
| 57 |
|
| 58 |
|
| 59 |
+
def _translate_with_index(texts: Sequence[str], src="auto", tgt="zh") -> Optional[List[str]]:
|
| 60 |
+
"""通过 Index-Translate 分批翻译,响应不完整时交给旧接口处理。
|
| 61 |
+
|
| 62 |
+
Args:
|
| 63 |
+
texts: 按顺序排列的待翻译标签。
|
| 64 |
+
src: 源语言代码,auto 表示自动识别。
|
| 65 |
+
tgt: 目标语言代码或语言名称。
|
| 66 |
+
|
| 67 |
+
Returns:
|
| 68 |
+
与输入逐项对应的译文;请求或响应校验失败时返回 None。
|
| 69 |
+
"""
|
| 70 |
+
languages = {"zh": "中文", "en": "英语", "ja": "日语", "ko": "韩语"}
|
| 71 |
+
source_language = "" if src == "auto" else languages.get(src, src)
|
| 72 |
+
target_language = languages.get(tgt, tgt)
|
| 73 |
+
headers = {"User-Agent": "Index-Translate-Client/1.0"}
|
| 74 |
+
if INDEX_TRANSLATE_API_KEY:
|
| 75 |
+
headers["Authorization"] = f"Bearer {INDEX_TRANSLATE_API_KEY}"
|
| 76 |
+
|
| 77 |
+
# 控制生成长度,避免大量标签挤满输出预算而丢失后半段译文。
|
| 78 |
+
chunks = []
|
| 79 |
+
chunk = []
|
| 80 |
+
char_count = 0
|
| 81 |
+
for text in texts:
|
| 82 |
+
if chunk and (len(chunk) >= 32 or char_count + len(text) > 2000):
|
| 83 |
+
chunks.append(chunk)
|
| 84 |
+
chunk = []
|
| 85 |
+
char_count = 0
|
| 86 |
+
chunk.append(text)
|
| 87 |
+
char_count += len(text)
|
| 88 |
+
if chunk:
|
| 89 |
+
chunks.append(chunk)
|
| 90 |
+
|
| 91 |
+
results = []
|
| 92 |
+
try:
|
| 93 |
+
for chunk in chunks:
|
| 94 |
+
# 用编号保持标签身份,模型调整 JSON 键顺序也不会造成译文错位。
|
| 95 |
+
source = {str(index): text for index, text in enumerate(chunk)}
|
| 96 |
+
prompt = (
|
| 97 |
+
f"请将以下{source_language}图像标签翻译成{target_language},并且严格遵循所有约束要求。\n\n"
|
| 98 |
+
f"〖源文〗\n{json.dumps(source, ensure_ascii=False)}\n\n"
|
| 99 |
+
"〖约束要求〗\n"
|
| 100 |
+
"1. 〖硬性要求〗 保留 JSON 对象的所有 key,不得增删、合并或调整 key 对应的条目,仅翻译各 value。\n"
|
| 101 |
+
"2. 〖注意〗 每个 value 是独立的图像标签,按动漫图像标签语境翻译。\n\n"
|
| 102 |
+
"请以相同的 JSON 格式输出翻译结果,key 保持不变,value 为对应译文。只输出 JSON,不要有任何额外说明。"
|
| 103 |
+
)
|
| 104 |
+
response = requests.post(
|
| 105 |
+
INDEX_TRANSLATE_URL,
|
| 106 |
+
headers=headers,
|
| 107 |
+
json={
|
| 108 |
+
"model": INDEX_TRANSLATE_MODEL,
|
| 109 |
+
"messages": [{"role": "user", "content": prompt}],
|
| 110 |
+
"temperature": 0,
|
| 111 |
+
"max_tokens": 2048,
|
| 112 |
+
"stream": False,
|
| 113 |
+
"chat_template_kwargs": {"enable_thinking": False},
|
| 114 |
+
},
|
| 115 |
+
timeout=INDEX_TRANSLATE_TIMEOUT,
|
| 116 |
+
)
|
| 117 |
+
response.raise_for_status()
|
| 118 |
+
choice = response.json()["choices"][0]
|
| 119 |
+
if choice["finish_reason"] != "stop":
|
| 120 |
+
raise ValueError("Index-Translate 未完整生成译文")
|
| 121 |
+
content = choice["message"]["content"]
|
| 122 |
+
if not isinstance(content, str) or not content.lstrip().startswith("{"):
|
| 123 |
+
raise ValueError("Index-Translate 未返回 JSON 对象")
|
| 124 |
+
|
| 125 |
+
# 保留原始键值对才能发现重复编号,防止 json.loads 静默覆盖错误结果。
|
| 126 |
+
pairs = json.loads(content, object_pairs_hook=list)
|
| 127 |
+
translated = dict(pairs)
|
| 128 |
+
if len(pairs) != len(source) or set(translated) != set(source):
|
| 129 |
+
raise ValueError("Index-Translate 返回的标签编号不完整或重复")
|
| 130 |
+
for key, text in source.items():
|
| 131 |
+
value = translated[key]
|
| 132 |
+
if not isinstance(value, str) or (text.strip() and not value.strip()):
|
| 133 |
+
raise ValueError("Index-Translate 返回了空译文或非文本译文")
|
| 134 |
+
results.append(value.strip())
|
| 135 |
+
except (requests.RequestException, ValueError, KeyError, IndexError, TypeError) as error:
|
| 136 |
+
# 不输出请求正文或鉴权信息;失败时沿用已有翻译服务。
|
| 137 |
+
print(f"[translator] Index-Translate failed ({type(error).__name__}); falling back to Tencent/Baidu.")
|
| 138 |
+
return None
|
| 139 |
+
return results
|
| 140 |
+
|
| 141 |
+
|
| 142 |
# 腾讯云翻译逻辑 (批量接口 TextTranslateBatch)
|
| 143 |
def _sign(key: bytes, msg: str) -> bytes:
|
| 144 |
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
|
|
|
|
| 322 |
def translate_texts(texts: Sequence[str],
|
| 323 |
src_lang: str = "auto",
|
| 324 |
tgt_lang: str = "zh") -> List[str]:
|
| 325 |
+
"""优先使用 Index-Translate,失败后依次尝试腾讯云、百度和原文。
|
| 326 |
+
|
| 327 |
+
Args:
|
| 328 |
+
texts: 按顺序排列的待翻译标签。
|
| 329 |
+
src_lang: 源语言代码,auto 表示自动识别。
|
| 330 |
+
tgt_lang: 目标语言代码。
|
| 331 |
+
|
| 332 |
+
Returns:
|
| 333 |
+
与输入等长且逐项对应的译文,所有翻译接口失败时返回原文。
|
| 334 |
"""
|
| 335 |
if not texts:
|
| 336 |
return []
|
| 337 |
|
| 338 |
+
out = _translate_with_index(texts, src_lang, tgt_lang)
|
| 339 |
+
|
| 340 |
+
if out is None:
|
| 341 |
+
out = _translate_with_tencent_pool(texts, src_lang, tgt_lang)
|
| 342 |
|
| 343 |
# 2. 失败降级到百度
|
| 344 |
+
if out is None or len(out) != len(texts):
|
| 345 |
out = _translate_with_baidu_pool(texts, src_lang, tgt_lang)
|
| 346 |
+
|
| 347 |
+
# 旧接口也不能把缺项结果传给上层,否则标签与译文会逐项错位。
|
| 348 |
+
return out if out is not None and len(out) == len(texts) else list(texts)
|