IdleCloud commited on
Commit
5dd4a4e
·
1 Parent(s): 57942b0

Upgrade to PixAI Tagger v1.0 and prefer Index-Translate

Browse files
Files changed (10) hide show
  1. .gitignore +6 -0
  2. README.md +43 -2
  3. app.py +66 -104
  4. handler.py +105 -248
  5. image_inversion_job_api.py +8 -7
  6. requirements.txt +6 -5
  7. tests/test_app_v1.py +160 -0
  8. tests/test_handler.py +158 -0
  9. tests/test_translator.py +145 -0
  10. 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.35,
31
- "character_threshold": 0.85,
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
- if img.mode != "RGB":
125
- img = img.convert("RGB")
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
- # 新版 PixAI Tagger v0.9 模型配置
138
  # ------------------------------------------------------------------
139
- ASSETS_REPO_ID = os.environ.get("ASSETS_REPO_ID", "pixai-labs/pixai-tagger-v0.9")
140
- ASSETS_REVISION = os.environ.get("ASSETS_REVISION")
 
141
  ASSETS_HF_TOKEN = os.environ.get("HF_TOKEN")
142
- MODEL_DIR = os.environ.get("MODEL_DIR", "./assets")
143
 
144
  REQUIRED_FILES = [
145
- "model_v0.9.pth",
146
- "tags_v0.9_13k.json",
147
- "char_ip_map.json",
 
148
  ]
149
 
150
 
151
- def ensure_assets(repo_id: str, revision: Optional[str], target_dir: str) -> None:
152
- """下载受限标签模型资源,并复制到 handler 期望的本地目录。
153
 
154
  Args:
155
  repo_id: Hugging Face 模型仓库 ID。
156
  revision: 可选模型版本;为空时使用仓库默认版本。
157
- target_dir: handler 读取模型资源的本地目录。
158
 
159
  Returns:
160
- None: 文件已存在或下载复制完成后不返回数据。
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
- # 该受限仓库必须鉴权;token 只发往 Hub 下载端点,不参与任何 Job API 请求。
 
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
- if src.resolve() != dst.resolve():
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(MODEL_DIR)
203
  self.device = getattr(self.handler, "device", "unknown")
204
- print(f"✅ PixAI Tagger v0.9 加载成功,设备:{str(self.device).upper()}")
205
  except Exception as e:
206
- print(f"❌ PixAI Tagger v0.9 加载失败: {e}")
207
  raise RuntimeError(f"模型初始化失败: {e}") from e
208
 
209
  @staticmethod
210
  def _display_tag(tag: str) -> str:
211
  return str(tag).replace("_", " ")
212
 
213
- @staticmethod
214
- def _get_score(scores: dict, tag: str) -> float:
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
- return 0.0
 
 
 
236
 
237
- def predict(self, img: Image.Image, gen_th: float = 0.30, char_th: float = 0.85):
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
- feature_tags = out.get("feature", []) or []
269
- character_tags = out.get("character", []) or []
270
- ip_tags = out.get("ip", []) or []
271
-
272
- feature_scores = out.get("feature_scores", {}) or {}
273
- character_scores = out.get("character_scores", {}) or {}
274
-
275
- general = {
276
- self._display_tag(tag): self._get_score(feature_scores, tag)
277
- for tag in feature_tags
278
- }
279
- characters = {
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
- "general": list(general.keys()),
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.get("_params", params),
309
- "_timings": out.get("_timings", {}),
 
 
 
 
 
 
 
 
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: 已完成安全校验与 RGB 转换的输入图片。
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"**当前模型:pixai-labs/pixai-tagger-v0.9** | **{DEVICE_LABEL}**\n\n"
474
- "说明:新版模型不再返回评分标签,本页面已将原“评分标签”区域改为“IP 标签”。"
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.30,
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.85,
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;'>提示:角色标签由模型推断,建议保持较高阈值。</p>")
527
  out_char = gr.HTML(label="Character Tags")
528
  with gr.TabItem("🌐 IP 标签"):
529
- gr.Markdown("<p style='color:gray; font-size:small;'>提示:新版模型输出 IP 标签,但不返回评分标签/评分置信度。</p>")
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 安全抓取并转换为 RGB 的图片。
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
- def __init__(self, path: str):
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
- # Robust device selection: prefer CPU unless CUDA is truly usable
114
- force_cpu = os.environ.get("FORCE_CPU", "0") in {"1", "true", "TRUE", "yes", "on"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 Exception:
121
- self.device = "cpu"
122
- else:
123
- self.device = "cpu"
124
- self.model = load_model(str(weights_file), self.device)
125
- self.transform = transforms.Compose(
126
- [
127
- transforms.Resize((448, 448)),
128
- transforms.ToTensor(),
129
- transforms.Normalize(mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5]),
130
- ]
 
 
131
  )
132
  self.fetch_image_timeout = 5.0
133
- self.default_general_threshold = 0.3
134
- self.default_character_threshold = 0.85
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
- inputs = data.pop("inputs", data)
145
-
146
- fetch_start_time = time.time()
 
 
 
 
 
 
 
 
 
147
  if isinstance(inputs, Image.Image):
148
  image = inputs
149
- elif image_url := inputs.pop("url", None):
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
- elif image_base64_encoded := inputs.pop("image", None):
 
156
  image = Image.open(io.BytesIO(base64.b64decode(image_base64_encoded)))
 
157
  else:
158
- raise ValueError(f"No image or url provided: {data}")
159
- # remove alpha channel if it exists
160
- image = pil_to_rgb(image)
161
- fetch_time = time.time() - fetch_start_time
162
-
163
- parameters = data.pop("parameters", {})
164
- general_threshold = parameters.pop(
165
- "general_threshold", self.default_general_threshold
166
- )
167
- character_threshold = parameters.pop(
168
- "character_threshold", self.default_character_threshold
169
- )
170
- # Optional behavior controls
171
- mode = parameters.pop("mode", "threshold") # "threshold" | "topk"
172
- include_scores = bool(parameters.pop("include_scores", False))
173
- topk_general = int(parameters.pop("topk_general", 25))
174
- topk_character = int(parameters.pop("topk_character", 10))
175
-
176
- inference_start_time = time.time()
177
- with torch.inference_mode():
178
- # Preprocess image on CPU
179
- image_tensor = self.transform(image).unsqueeze(0)
180
- # Pin memory and use non_blocking transfer only when using CUDA
181
- if self.device == "cuda":
182
- image_tensor = image_tensor.pin_memory().to(self.device, non_blocking=True)
183
- else:
184
- image_tensor = image_tensor.to(self.device)
185
-
186
- # Run model on GPU
187
- probs = self.model(image_tensor)[0] # Get probs for the single image
188
-
189
- if mode == "topk":
190
- # Select top-k by category, independent of thresholds
191
- gen_slice = probs[: self.gen_tag_count]
192
- char_slice = probs[self.gen_tag_count :]
193
- k_gen = max(0, min(int(topk_general), self.gen_tag_count))
194
- k_char = max(0, min(int(topk_character), self.character_tag_count))
195
- gen_scores, gen_idx = (torch.tensor([]), torch.tensor([], dtype=torch.long))
196
- char_scores, char_idx = (torch.tensor([]), torch.tensor([], dtype=torch.long))
197
- if k_gen > 0:
198
- gen_scores, gen_idx = torch.topk(gen_slice, k_gen)
199
- if k_char > 0:
200
- char_scores, char_idx = torch.topk(char_slice, k_char)
201
- char_idx = char_idx + self.gen_tag_count
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
- if include_scores:
276
- out["feature_scores"] = gen_scores_out
277
- out["character_scores"] = char_scores_out
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.35, ge=0.0, le=1.0)
219
- character_threshold: float = Field(default=0.85, ge=0.0, le=1.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
- """校验远程内容并解码为脱离底层流的 RGB 图片。
457
 
458
  Args:
459
  image_bytes: 在字节上限内完整读取的图片内容。
@@ -461,7 +461,7 @@ def decode_image_bytes(
461
  settings: 图片字节、像素及单边尺寸限制。
462
 
463
  Returns:
464
- 完成 EXIF 方向修正并复制到内存的 RGB PIL 图片。
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
- return ImageOps.exif_transpose(source_image).convert("RGB").copy()
 
500
  except ImageSourceError:
501
  raise
502
  except (
@@ -547,7 +548,7 @@ def _fetch_remote_image_inner(
547
  settings: 图片白名单、超时、体积、像素与尺寸边界。
548
 
549
  Returns:
550
- 可直接交给标签模型的 RGB PIL 图片。
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 转正和 RGB 转换的 PIL 图片。
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.24.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
- 腾讯云 (批量接口+动态分包+多账号轮询) + 百度翻译 API 封装
4
- ⚠️ 需在 HF 空间的 “Variables” 页设置以下环境变量
 
 
 
 
 
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
- 1. 尝试腾讯云 (批量接口 + 多账号轮询)
233
- 2. 失败降级到百度云
234
- 3. 还失败返回原文
 
 
 
 
235
  """
236
  if not texts:
237
  return []
238
 
239
- # 1. 优先尝试腾讯云
240
- out = _translate_with_tencent_pool(texts, src_lang, tgt_lang)
 
 
241
 
242
  # 2. 失败降级到百度
243
- if out is None:
244
  out = _translate_with_baidu_pool(texts, src_lang, tgt_lang)
245
-
246
- return out or list(texts)
 
 
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)