Bladeren bron

Add per-part boss regeneration

bang 2 weken geleden
bovenliggende
commit
41a9e96197
5 gewijzigde bestanden met toevoegingen van 225 en 7 verwijderingen
  1. 21 0
      asset_quality.py
  2. 16 0
      exporter.py
  3. 12 0
      pipeline.py
  4. 137 0
      server.py
  5. 39 7
      web/index.html

+ 21 - 0
asset_quality.py

@@ -101,3 +101,24 @@ def boss_preview_quality(image_or_path):
     if largest < 0.74:
         return False, "关主最大主体占比过低,整体轮廓不完整。", report
     return True, "关主预览主体连贯。", report
+
+
+def boss_part_quality(part_id, image_or_path):
+    """Check an individual rigging part for obvious sheet bleed or bad crops."""
+    report = alpha_component_report(image_or_path)
+    if not report.get("ok"):
+        return False, "拆件没有有效 Alpha 内容。", report
+
+    pid = (part_id or "").lower()
+    allow_multi = any(token in pid for token in (
+        "splash", "burst", "crack", "coin", "spark", "debris", "fragment", "shadow"
+    ))
+    if allow_multi:
+        return True, "特效/碎片类拆件允许多块主体。", report
+
+    second = report.get("secondShare", 0)
+    sig_count = len(report.get("significantComponents", []))
+    if sig_count >= 2 and second >= 0.04:
+        return False, f"拆件包含 {sig_count} 个明显分离主体,疑似切进了相邻部件。", report
+
+    return True, "拆件主体干净。", report

+ 16 - 0
exporter.py

@@ -180,6 +180,22 @@ def _qa_report(lib, base, slot_src):
                     )
             if row.get("type") != "spine_parts":
                 _qa_add(report, "warning", "boss_not_parts", "关主不是 spine_parts,爆炸拆件动作会受限。", boss_id, "重新生成关主拆件。")
+            for part in row.get("parts") or []:
+                pfile = part.get("file", "")
+                ppath = os.path.join(base, pfile)
+                if not pfile or not os.path.isfile(ppath):
+                    _qa_add(report, "error", "missing_boss_part", f"关主拆件缺失:{part.get('id', '')}", boss_id, "重新生成关主拆件。")
+                    continue
+                ok, reason, detail = asset_quality.boss_part_quality(part.get("id", ""), ppath)
+                if not ok:
+                    _qa_add(
+                        report,
+                        "error",
+                        "bad_boss_part",
+                        f"关主拆件 {part.get('id', '')} 不可用:{reason}",
+                        boss_id,
+                        f"重新生成或重切关主拆件;最大主体 {detail.get('largestShare', 0):.0%},第二主体 {detail.get('secondShare', 0):.0%}。",
+                    )
             theme = (slot_config.get("theme") or {}).get("key", "")
             title = boss.get("title", "")
             if theme in ("jelly", "pirate_jelly") and ("恶魔" in title or "魔王" in title):

+ 12 - 0
pipeline.py

@@ -248,6 +248,18 @@ def run(manifest, out_root, creds=None, log=print, merge_existing=False):
                 spine_builder.build_parts_character(cid, part_images, chars_out, anims, parts,
                                                     write_preview=preview_img is None)
                 part_files = spine_builder.write_part_pngs_from_files(cid, chars_out)
+                bad_parts = []
+                for part in part_files:
+                    ok, reason, detail = asset_quality.boss_part_quality(
+                        part["id"],
+                        os.path.join(base_out, part["file"]),
+                    )
+                    if not ok:
+                        bad_parts.append(
+                            f"{part['id']}:{reason} 最大主体 {detail.get('largestShare', 0):.0%},第二主体 {detail.get('secondShare', 0):.0%}"
+                        )
+                if bad_parts:
+                    raise RuntimeError("Boss 拆件质量不合格:" + ";".join(bad_parts[:4]))
                 if preview_img is not None:
                     os.makedirs(chars_out, exist_ok=True)
                     spine_builder.trim_to_content(preview_img, pad=16).save(

+ 137 - 0
server.py

@@ -37,6 +37,8 @@ import slot_workflow
 import spine_builder
 import providers
 import config
+import baidu_segment
+import asset_quality
 
 HERE = os.path.dirname(os.path.abspath(__file__))
 OUT_ROOT = os.path.join(HERE, "out")
@@ -108,6 +110,13 @@ def _load_library(game):
     return lib
 
 
+def _save_library(game, lib):
+    library_path = os.path.join(OUT_ROOT, game, "library.json")
+    os.makedirs(os.path.dirname(library_path), exist_ok=True)
+    with open(library_path, "w", encoding="utf-8") as f:
+        json.dump(lib, f, ensure_ascii=False, indent=2)
+
+
 def _spine_size_from_json(path):
     try:
         data = json.load(open(path, encoding="utf-8"))
@@ -399,6 +408,105 @@ def _run_retry_missing_job(job_id, game, groups, creds):
         _set_job(job_id, status="error", ok=False, error=str(e), updatedAt=time.time())
 
 
+def _transparent_prompt(extra):
+    return ", ".join([
+        extra,
+        "生成纯透明背景 PNG,真实 Alpha 通道,不要棋盘格,不要白底,不要阴影。",
+    ])
+
+
+def _has_alpha(img):
+    alpha = img.convert("RGBA").getchannel("A")
+    return alpha.getextrema()[0] == 0
+
+
+def _generate_alpha_image(creds, prompt, size, label, log):
+    img = providers.generate(creds["provider"], prompt, creds["api_key"],
+                             creds.get("base_url", "https://api.openai.com/v1"),
+                             creds.get("model", "gpt-image-2"), size)
+    if _has_alpha(img):
+        return img
+    log(f"🧠 [{label}] 模型没有真实 Alpha,改用百度智能抠图兜底…")
+    fixed = baidu_segment.remove_background(img, label=label, log=log)
+    if _has_alpha(fixed):
+        return fixed
+    raise RuntimeError("图片没有真实 Alpha,抠图后仍不合格")
+
+
+def _run_retry_boss_part_job(job_id, game, boss_id, part_id, creds):
+    _set_job(job_id, status="running", logs=[], game=game, startedAt=time.time(), updatedAt=time.time())
+    try:
+        lib = _load_library(game)
+        manifest = _manifest_from_library(lib)
+        base = os.path.join(OUT_ROOT, game)
+        chars_out = os.path.join(base, "characters")
+        boss = next((c for c in manifest.get("characters", [])
+                     if c.get("id") == boss_id or c.get("role") == "boss"), None)
+        if not boss or boss.get("type") != "spine_parts":
+            raise RuntimeError(f"找不到可拆件关主:{boss_id}")
+        parts = boss.get("parts") or []
+        target = next((p for p in parts if p.get("id") == part_id), None)
+        if not target:
+            raise RuntimeError(f"找不到关主拆件:{part_id}")
+
+        _append_job_log(job_id, f"重生关主拆件:{boss_id}/{part_id}")
+        part_images = {}
+        for part in parts:
+            pid = part["id"]
+            ppath = os.path.join(chars_out, f"{boss_id}_parts", f"{pid}.png")
+            if pid != part_id:
+                if not os.path.isfile(ppath):
+                    raise RuntimeError(f"缺少现有拆件,无法局部重建:{pid}")
+                from PIL import Image
+                part_images[pid] = Image.open(ppath).convert("RGBA")
+
+        style = manifest.get("style", "")
+        base_prompt = ", ".join(x for x in [
+            boss.get("prompt", ""),
+            target.get("prompt", ""),
+            style,
+            _transparent_prompt("single separated rigging part only, centered, no text, no other body parts"),
+        ] if x)
+        correction = ""
+        last_reason = ""
+        for attempt in range(1, 4):
+            if attempt > 1:
+                _append_job_log(job_id, f"🔁 [{part_id}] 拆件不合格,重新生成(第 {attempt}/3 次)…")
+            prompt = base_prompt if not correction else ", ".join([base_prompt, correction])
+            img = _generate_alpha_image(creds, prompt, target.get("size", boss.get("size", creds.get("size", "1024x1024"))),
+                                        f"{boss_id}/{part_id}", lambda m: _append_job_log(job_id, m))
+            ok, reason, detail = asset_quality.boss_part_quality(part_id, img)
+            if ok:
+                _append_job_log(job_id, f"✅ [{part_id}] 拆件质量通过:最大主体 {detail.get('largestShare', 0):.0%}")
+                part_images[part_id] = img
+                break
+            last_reason = reason
+            _append_job_log(job_id, f"⚠️ [{part_id}] 拆件质量失败:{reason}")
+            correction = (
+                "这不是干净的单个拆件。请只生成这一件,不要包含任何其他身体部位、碎片、武器边缘或相邻格内容。"
+            )
+        if part_id not in part_images:
+            raise RuntimeError(f"拆件重生失败:{last_reason}")
+
+        spine_builder.build_parts_character(boss_id, part_images, chars_out, boss.get("animations", ["idle"]), parts,
+                                            write_preview=True)
+        part_files = spine_builder.write_part_pngs_from_files(boss_id, chars_out)
+        row = next((c for c in lib.get("characters", []) if c.get("id") == boss_id), None)
+        if row:
+            row["parts"] = part_files
+            row["preview"] = f"characters/{boss_id}_preview.png"
+            files = [f"characters/{boss_id}.json", f"characters/{boss_id}.atlas", f"characters/{boss_id}.png",
+                     f"characters/{boss_id}_preview.png"]
+            files.extend([p["file"] for p in part_files])
+            row["files"] = files
+        _save_library(game, lib)
+        _set_job(job_id, status="done", ok=True, game=game, updatedAt=time.time())
+    except Exception as e:
+        traceback.print_exc()
+        _append_job_log(job_id, f"❌ 拆件重生失败: {e}")
+        _set_job(job_id, status="error", ok=False, error=str(e), updatedAt=time.time())
+
+
 def _split_lines(value):
     if isinstance(value, list):
         return [str(x).strip() for x in value if str(x).strip()]
@@ -715,6 +823,8 @@ class Handler(BaseHTTPRequestHandler):
             return self._post_open_folder()
         if route == "/api/retry-task":
             return self._post_retry_task()
+        if route == "/api/retry-boss-part":
+            return self._post_retry_boss_part()
         if route == "/api/retry-missing":
             return self._post_retry_missing()
         if route == "/api/delete":
@@ -861,6 +971,33 @@ class Handler(BaseHTTPRequestHandler):
         threading.Thread(target=_run_retry_job, args=(job_id, game, kind, task_ids, creds), daemon=True).start()
         return self._send(200, {"ok": True, "jobId": job_id, "game": game})
 
+    def _post_retry_boss_part(self):
+        try:
+            data = self._read_json_body()
+        except Exception as e:
+            return self._send(400, {"ok": False, "error": f"请求体不是合法 JSON: {e}"})
+        game = (data.get("game") or "").strip()
+        boss_id = (data.get("bossId") or data.get("id") or "").strip()
+        part_id = (data.get("partId") or "").strip()
+        if not game or game not in list_games():
+            return self._send(400, {"ok": False, "error": f"无效的 game: {game!r}"})
+        if not boss_id or not part_id:
+            return self._send(400, {"ok": False, "error": "缺少 bossId 或 partId"})
+        creds = {
+            "provider": data.get("provider", "OpenAI 兼容接口"),
+            "api_key": (data.get("api_key") or DEFAULT_API_KEY).strip(),
+            "base_url": (data.get("base_url") or DEFAULT_BASE_URL).strip(),
+            "model": (data.get("model") or DEFAULT_IMAGE_MODEL).strip(),
+            "size": data.get("size", "1024x1024"),
+        }
+        job_id = uuid.uuid4().hex
+        with JOBS_LOCK:
+            JOBS[job_id] = {"status": "queued", "ok": None, "logs": ["拆件重生任务已创建,等待开始…"],
+                            "game": game, "createdAt": time.time(), "updatedAt": time.time()}
+        threading.Thread(target=_run_retry_boss_part_job,
+                         args=(job_id, game, boss_id, part_id, creds), daemon=True).start()
+        return self._send(200, {"ok": True, "jobId": job_id, "game": game})
+
     def _post_retry_missing(self):
         try:
             data = self._read_json_body()

+ 39 - 7
web/index.html

@@ -98,6 +98,7 @@
   .part-card img{width:100%;height:92px;object-fit:contain;background:
         repeating-conic-gradient(#241a3d 0 25%, #2c2150 0 50%) 0/18px 18px;border-radius:7px}
   .part-card .meta{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:5px}
+  .part-card button{width:100%;padding:7px 8px;margin-top:7px;font-size:12px}
   code{background:#160f29;padding:1px 6px;border-radius:5px;font-size:12px}
   a{color:var(--accent2)}
   @media(max-width:760px){.parts-grid{grid-template-columns:1fr}}
@@ -255,11 +256,7 @@
       <button class="ghost" id="partsClose">关闭</button>
     </div>
     <div class="parts-grid">
-      <div class="parts-box">
-        <div class="name">完整预览</div>
-        <img id="partsPreview" alt="完整预览">
-      </div>
-      <div class="parts-box">
+      <div class="parts-box" style="grid-column:1/-1">
         <div class="name">拆件图 / Atlas</div>
         <img id="partsAtlas" alt="拆件图">
       </div>
@@ -277,6 +274,7 @@ const $ = s => document.querySelector(s);
 let LIB = {characters:[],vfx:[],ui:[]}, ASSET="", ASSET_VER=Date.now(), TAB="chars";
 const animTargets = []; // {el, anim, start}
 const ACTIVE_TASKS = new Set();
+let CURRENT_PARTS = null;
 const esc = s => String(s ?? '').replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));
 const tasksFor = kind => ((LIB.tasks&&LIB.tasks[kind]) || []);
 const assetUrl = path => ASSET + path + (path.includes('?')?'&':'?') + 'v=' + ASSET_VER;
@@ -290,13 +288,13 @@ function markTasksRunning(items, running=true){
 }
 
 function openPartsModal(c, t){
+  CURRENT_PARTS = {asset:c, task:t};
   $('#partsTitle').textContent = `${t.chineseName} · 拆件检查`;
-  $('#partsPreview').src = assetUrl(c.preview || c.png);
   $('#partsAtlas').src = assetUrl(c.png);
   const parts = c.parts || [];
   $('#partsCount').textContent = parts.length ? `已拆出 ${parts.length} 个单独 PNG,可逐个检查。` : '没有单独拆件 PNG;请重新生成该关主。';
   $('#partsList').innerHTML = parts.length
-    ? parts.map(p=>`<div class="part-card"><img src="${assetUrl(p.file)}" alt="${esc(p.id)}"><div class="meta">${esc(p.id)} · ${esc(p.w||'?')}×${esc(p.h||'?')}</div></div>`).join('')
+    ? parts.map(p=>`<div class="part-card"><img src="${assetUrl(p.file)}" alt="${esc(p.id)}"><div class="meta">${esc(p.id)} · ${esc(p.w||'?')}×${esc(p.h||'?')}</div><button class="ghost part-retry-btn" data-boss="${esc(c.id)}" data-part="${esc(p.id)}">重生该拆件</button></div>`).join('')
     : '<div class="empty">缺少单独拆件文件。</div>';
   $('#partsModal').classList.add('open');
 }
@@ -588,6 +586,40 @@ $('#view').addEventListener('click', async e=>{
 });
 $('#partsClose').onclick=()=>$('#partsModal').classList.remove('open');
 $('#partsModal').onclick=e=>{ if(e.target.id==='partsModal') $('#partsModal').classList.remove('open'); };
+$('#partsList').addEventListener('click', async e=>{
+  const btn=e.target.closest('.part-retry-btn');
+  if(!btn) return;
+  const game=$('#gameSel').value;
+  if(!game||game==='(暂无)'){ opMsg('没有可重生的资源库',false); return; }
+  btn.disabled=true;
+  btn.textContent='重生中…';
+  const item={kind:'characters',id:btn.dataset.boss};
+  markTasksRunning([item], true);
+  render();
+  opMsg(`正在重生拆件 ${btn.dataset.boss}/${btn.dataset.part}…`);
+  const log=$('#log'); log.style.display='block'; log.textContent=`重生拆件 ${btn.dataset.boss}/${btn.dataset.part} 任务创建中…`;
+  try{
+    const r=await fetch('/api/retry-boss-part',{method:'POST',headers:{'Content-Type':'application/json'},
+      body:JSON.stringify({
+        game, bossId:btn.dataset.boss, partId:btn.dataset.part,
+        provider:$('#provider').value, api_key:$('#apiKey').value,
+        base_url:$('#baseUrl').value, model:$('#model').value, size:$('#size').value })});
+    const d=await r.json();
+    if(!d.ok || !d.jobId){
+      log.textContent='❌ '+(d.error||'拆件重生失败');
+      markTasksRunning([item], false); render();
+      btn.disabled=false; btn.textContent='重生该拆件';
+      return;
+    }
+    await pollJob(d.jobId, log, btn, [item]);
+    const row=tasksFor('characters').find(t=>t.id===btn.dataset.boss);
+    if(row && row.asset) openPartsModal(row.asset, row);
+  }catch(err){
+    log.textContent='请求失败: '+err;
+    markTasksRunning([item], false); render();
+    btn.disabled=false; btn.textContent='重生该拆件';
+  }
+});
 $('#gameSel').onchange=e=>loadLibrary(e.target.value);
 function currentManifestGame(){
   try{ return JSON.parse($('#manifest').value||'{}').game || ''; }catch(e){ return ''; }