فهرست منبع

Reject fragmented boss previews

bang 2 هفته پیش
والد
کامیت
cf0d7e0140
3فایلهای تغییر یافته به همراه146 افزوده شده و 4 حذف شده
  1. 103 0
      asset_quality.py
  2. 16 1
      exporter.py
  3. 27 3
      pipeline.py

+ 103 - 0
asset_quality.py

@@ -0,0 +1,103 @@
+"""Lightweight image quality checks for generated game assets."""
+
+from collections import deque
+
+
+def _as_rgba_image(image_or_path):
+    from PIL import Image
+
+    if isinstance(image_or_path, str):
+        return Image.open(image_or_path).convert("RGBA")
+    return image_or_path.convert("RGBA")
+
+
+def alpha_component_report(image_or_path, max_size=320, threshold=24):
+    """Return connected-component stats for non-transparent pixels."""
+    img = _as_rgba_image(image_or_path)
+    if max(img.size) > max_size:
+        scale = max_size / max(img.size)
+        img = img.resize((max(1, int(img.width * scale)), max(1, int(img.height * scale))))
+    alpha = img.getchannel("A")
+    data = bytearray(1 if v > threshold else 0 for v in alpha.getdata())
+    w, h = img.size
+    opaque = sum(data)
+    if opaque == 0:
+        return {
+            "ok": False,
+            "reason": "empty_alpha",
+            "size": [w, h],
+            "opaque": 0,
+            "components": [],
+            "significantComponents": [],
+            "largestShare": 0,
+        }
+
+    visited = bytearray(w * h)
+    components = []
+    for start, is_opaque in enumerate(data):
+        if not is_opaque or visited[start]:
+            continue
+        q = deque([start])
+        visited[start] = 1
+        count = 0
+        min_x = w
+        min_y = h
+        max_x = 0
+        max_y = 0
+        while q:
+            idx = q.popleft()
+            y, x = divmod(idx, w)
+            count += 1
+            min_x = min(min_x, x)
+            min_y = min(min_y, y)
+            max_x = max(max_x, x)
+            max_y = max(max_y, y)
+            for nx, ny in ((x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)):
+                if nx < 0 or ny < 0 or nx >= w or ny >= h:
+                    continue
+                nidx = ny * w + nx
+                if data[nidx] and not visited[nidx]:
+                    visited[nidx] = 1
+                    q.append(nidx)
+        components.append({
+            "pixels": count,
+            "share": round(count / max(1, opaque), 4),
+            "bbox": [min_x, min_y, max_x + 1, max_y + 1],
+        })
+
+    components.sort(key=lambda c: c["pixels"], reverse=True)
+    significant = [
+        c for c in components
+        if c["pixels"] >= max(60, int(opaque * 0.06))
+    ]
+    return {
+        "ok": True,
+        "size": [w, h],
+        "opaque": opaque,
+        "opaqueRatio": round(opaque / max(1, w * h), 4),
+        "components": components[:12],
+        "significantComponents": significant,
+        "largestShare": round(components[0]["pixels"] / max(1, opaque), 4),
+        "secondShare": round(components[1]["pixels"] / max(1, opaque), 4) if len(components) > 1 else 0,
+    }
+
+
+def boss_preview_quality(image_or_path):
+    """Check whether a boss preview reads as one assembled character."""
+    report = alpha_component_report(image_or_path)
+    if not report.get("ok"):
+        return False, "预览图没有有效 Alpha 主体。", report
+
+    sig_count = len(report["significantComponents"])
+    largest = report["largestShare"]
+    second = report["secondShare"]
+    opaque_ratio = report["opaqueRatio"]
+    if opaque_ratio < 0.035:
+        return False, "关主体量太小,预览图主体不清晰。", report
+    if sig_count >= 3:
+        return False, f"关主被拆成 {sig_count} 个明显分离的大块,无法识别为完整角色。", report
+    if sig_count >= 2 and second >= 0.12:
+        return False, "关主存在明显分离的大部件,像拆件表而不是完整角色。", report
+    if largest < 0.74:
+        return False, "关主最大主体占比过低,整体轮廓不完整。", report
+    return True, "关主预览主体连贯。", report

+ 16 - 1
exporter.py

@@ -16,6 +16,8 @@ import math
 import os
 import shutil
 
+import asset_quality
+
 HERE = os.path.dirname(os.path.abspath(__file__))
 
 
@@ -164,7 +166,18 @@ def _qa_report(lib, base, slot_src):
         else:
             preview = row.get("preview")
             if not preview or not os.path.isfile(os.path.join(base, preview)):
-                _qa_add(report, "warning", "missing_boss_preview", "关主缺少完整预览图,页面会误把 atlas 当角色。", boss_id, "重新生成关主,或让系统补完整 preview。")
+                _qa_add(report, "error", "missing_boss_preview", "关主缺少完整预览图,页面会误把 atlas 当角色。", boss_id, "重新生成关主,或让系统补完整 preview。")
+            else:
+                ok, reason, detail = asset_quality.boss_preview_quality(os.path.join(base, preview))
+                if not ok:
+                    _qa_add(
+                        report,
+                        "error",
+                        "bad_boss_preview",
+                        f"关主预览不可用:{reason}",
+                        boss_id,
+                        f"重新生成完整关主预览;当前最大主体占比 {detail.get('largestShare', 0):.0%},明显分离大块 {len(detail.get('significantComponents', []))} 个。",
+                    )
             if row.get("type") != "spine_parts":
                 _qa_add(report, "warning", "boss_not_parts", "关主不是 spine_parts,爆炸拆件动作会受限。", boss_id, "重新生成关主拆件。")
             theme = (slot_config.get("theme") or {}).get("key", "")
@@ -827,6 +840,8 @@ def export(game, out_root, log=print):
         for item in qa["items"][:6]:
             if item["level"] in ("error", "warning"):
                 log(f"🧪 [{item['level']}] {item['target']} · {item['message']}")
+        if qa["summary"]["error"]:
+            raise RuntimeError(f"导出 QA 未通过:{qa['summary']['error']} 个错误。请查看 {os.path.join(pack, 'QAReport.md')}")
     else:
         log("🧪 QA:通过,未发现错误或警告")
     src_tween = os.path.join(base, "ui", "TweenPresets.ts")

+ 27 - 3
pipeline.py

@@ -10,6 +10,7 @@ import spine_builder
 import particle_builder
 import tween_builder
 import baidu_segment
+import asset_quality
 
 HERE = os.path.dirname(os.path.abspath(__file__))
 
@@ -105,6 +106,26 @@ def run(manifest, out_root, creds=None, log=print, merge_existing=False):
             return fixed
         raise RuntimeError("百度智能抠图返回结果仍没有真实 Alpha 透明通道")
 
+    def generate_boss_preview_checked(cid, base_prompt, size):
+        correction = ""
+        last_reason = ""
+        for attempt in range(1, 4):
+            prompt = base_prompt if not correction else ", ".join([base_prompt, correction])
+            if attempt > 1:
+                log(f"🔁 [{cid}/preview] 关主预览不合格,重新生成完整角色(第 {attempt}/3 次)…")
+            img = generate_checked(f"{cid}/preview", prompt, size, True)
+            ok, reason, report = asset_quality.boss_preview_quality(img)
+            if ok:
+                log(f"✅ [{cid}/preview] 关主完整性检查通过:最大主体 {report['largestShare']:.0%}")
+                return img
+            last_reason = reason
+            log(f"⚠️ [{cid}/preview] 关主完整性检查失败:{reason}")
+            correction = (
+                "这不是完整关主角色。请重新生成一个单体完整角色:所有身体、头、手、脚、武器必须连接在同一个主体轮廓内,"
+                "不要拆件图、不要 sprite sheet、不要分离的靴子/武器/金币袋/碎片,只有一个居中站立的完整关主。"
+            )
+        raise RuntimeError(f"关主预览不合格:{last_reason}")
+
     def boss_config():
         boss = manifest.get("slot_config", {}).get("boss", {})
         return boss if boss.get("enabled", False) else {}
@@ -184,10 +205,13 @@ def run(manifest, out_root, creds=None, log=print, merge_existing=False):
                         )
                     ] if x)
                     log(f"🖼  [{cid}] 生成完整 Boss 预览图…")
-                    preview_img = generate_checked(f"{cid}/preview", preview_prompt,
-                                                   c.get("size", creds.get("size", "1024x1024")), True)
+                    preview_img = generate_boss_preview_checked(
+                        cid,
+                        preview_prompt,
+                        c.get("size", creds.get("size", "1024x1024")),
+                    )
                 except Exception as e:
-                    log(f"⚠️ [{cid}] 完整预览图生成失败,稍后使用拆件拼装预览兜底:{e}")
+                    raise RuntimeError(f"完整 Boss 预览图生成失败:{e}")
                 sheet_cfg = c.get("spriteSheet") or {}
                 use_sheet = c.get("partGeneration") == "sprite_sheet" or sheet_cfg.get("enabled")
                 if use_sheet: