Explorar o código

Remove background removal pipeline

bang hai 2 semanas
pai
achega
a194c0cd5b
Modificáronse 10 ficheiros con 5 adicións e 364 borrados
  1. 0 165
      background_remover.py
  2. 2 2
      build_preview.py
  3. 0 103
      dechecker.py
  4. 1 2
      local_config.example.json
  5. 0 9
      pipeline.py
  6. 0 13
      providers.py
  7. 0 1
      requirements.txt
  8. 0 1
      server.py
  9. 2 66
      spine_builder.py
  10. 0 2
      web/index.html

+ 0 - 165
background_remover.py

@@ -1,165 +0,0 @@
-"""Aliyun background-removal integration.
-
-Aliyun Marketplace product cmapi00069648 exposes:
-  POST https://bgrem.market.alicloudapi.com/api/v1/bg-remove/submit
-  POST https://bgrem.market.alicloudapi.com/api/v1/bg-remove/query
-
-The remote service requires a public image URL. Generated images from the
-current gpt-image-2 gateway are usually b64-only, so the pipeline uses remote
-matting only when a public URL is available. There is intentionally no local
-fallback because local matting was not clean enough for production assets.
-"""
-
-import base64
-import http.client
-import io
-import json
-import time
-import uuid
-from urllib.parse import urlparse
-
-from PIL import Image, ImageFilter
-
-import config
-
-ALIYUN_APP_CODE = config.get("ALIYUN_BGREM_APP_CODE", "")
-HOST = "bgrem.market.alicloudapi.com"
-SUBMIT_PATH = "/api/v1/bg-remove/submit"
-QUERY_PATH = "/api/v1/bg-remove/query"
-
-
-def _json_post(path, payload, timeout=60):
-    if not ALIYUN_APP_CODE:
-        raise RuntimeError("missing ALIYUN_BGREM_APP_CODE")
-    body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
-    headers = {
-        "Content-Type": "application/json; charset=utf-8",
-        "Accept": "application/json",
-        "Authorization": f"APPCODE {ALIYUN_APP_CODE}",
-        "X-Ca-Nonce": uuid.uuid4().hex,
-    }
-    conn = http.client.HTTPSConnection(HOST, timeout=timeout)
-    try:
-        conn.request("POST", path, body=body, headers=headers)
-        res = conn.getresponse()
-        raw = res.read()
-        res_headers = dict(res.getheaders())
-    finally:
-        conn.close()
-    if res.status < 200 or res.status >= 300:
-        msg = raw.decode("utf-8", "ignore") or res_headers.get("X-Ca-Error-Message", "")
-        raise RuntimeError(f"HTTP {res.status}: {msg}")
-    if not raw:
-        raise RuntimeError("empty response")
-    return json.loads(raw.decode("utf-8"))
-
-
-def _download(url, timeout=120):
-    parsed = urlparse(url)
-    path = parsed.path or "/"
-    if parsed.query:
-        path += "?" + parsed.query
-    conn_cls = http.client.HTTPSConnection if parsed.scheme == "https" else http.client.HTTPConnection
-    conn = conn_cls(parsed.hostname, parsed.port, timeout=timeout)
-    try:
-        conn.request("GET", path)
-        res = conn.getresponse()
-        raw = res.read()
-    finally:
-        conn.close()
-    if res.status < 200 or res.status >= 300:
-        raise RuntimeError(f"download HTTP {res.status}")
-    return raw
-
-
-def _extract_result_key(data):
-    payload = data.get("data", data)
-    if isinstance(payload, str):
-        try:
-            payload = json.loads(payload)
-        except Exception:
-            return payload
-    if isinstance(payload, dict):
-        for key in ("result_key", "resultKey", "task_key", "taskKey"):
-            if payload.get(key):
-                return payload[key]
-        tasks = payload.get("task_list") or payload.get("taskList")
-        if isinstance(tasks, list) and tasks:
-            return _extract_result_key(tasks[0])
-    return None
-
-
-def _extract_result_url(data):
-    payload = data.get("data", data)
-    if isinstance(payload, str):
-        try:
-            payload = json.loads(payload)
-        except Exception:
-            return payload if payload.startswith(("http://", "https://")) else None
-    if isinstance(payload, dict):
-        results = payload.get("results")
-        if isinstance(results, list) and results:
-            return results[0]
-        for key in ("url", "result", "result_url", "resultUrl", "image_url", "imageUrl", "alpha", "alpha_url"):
-            val = payload.get(key)
-            if isinstance(val, str) and val:
-                return val
-        tasks = payload.get("task_list") or payload.get("taskList") or payload.get("result_list") or payload.get("resultList")
-        if isinstance(tasks, list) and tasks:
-            return _extract_result_url(tasks[0])
-    return None
-
-
-def _ensure_success(data):
-    if data.get("success") is False:
-        raise RuntimeError(data.get("message") or json.dumps(data, ensure_ascii=False)[:500])
-    code = data.get("code")
-    if code not in (None, 0, "0", 200, "200"):
-        raise RuntimeError(data.get("message") or json.dumps(data, ensure_ascii=False)[:500])
-
-
-def _remote_remove_by_url(image_url, model_type="general", log=None, label="image"):
-    submit_body = {"data": {"task_list": [{"model_type": model_type, "image": image_url}]}}
-    if log:
-        log(f"🧼 [{label}] 去背景:提交阿里云任务…")
-    submit = _json_post(SUBMIT_PATH, submit_body, timeout=60)
-    _ensure_success(submit)
-    result_key = _extract_result_key(submit)
-    if not result_key:
-        raise RuntimeError(f"提交接口未返回 result_key: {json.dumps(submit, ensure_ascii=False)[:500]}")
-    if log:
-        log(f"🧼 [{label}] 去背景:任务已提交 result_key={result_key}")
-
-    query_body = {"data": {"result_key": result_key}}
-    last = None
-    for i in range(40):
-        time.sleep(1.5 if i else 0.3)
-        data = _json_post(QUERY_PATH, query_body, timeout=60)
-        _ensure_success(data)
-        last = data
-        result_url = _extract_result_url(data)
-        if result_url:
-            if log:
-                log(f"🧼 [{label}] 去背景:任务完成,下载透明 PNG…")
-            return Image.open(io.BytesIO(_download(result_url))).convert("RGBA")
-        if log and i in (0, 3, 10, 20):
-            log(f"🧼 [{label}] 去背景:等待任务结果…")
-    raise RuntimeError(f"查询超时,最后返回: {json.dumps(last, ensure_ascii=False)[:500]}")
-
-
-def remove_background(img, log=None, label="image", enabled=True, image_url=None, model_type="general"):
-    if not enabled:
-        return img.convert("RGBA")
-    if image_url and image_url.startswith(("http://", "https://")):
-        try:
-            out = _remote_remove_by_url(image_url, model_type=model_type, log=log, label=label)
-            if log:
-                log(f"✅ [{label}] 远端去背景完成:透明 PNG {out.size[0]}×{out.size[1]}")
-            return out
-        except Exception as e:
-            if log:
-                log(f"⚠️  [{label}] 远端去背景失败,保留原图:{e}")
-    else:
-        if log:
-            log(f"ℹ️ [{label}] 去背景:当前图片没有公网 URL,跳过远端去背景并保留原图")
-    return img.convert("RGBA")

+ 2 - 2
build_preview.py

@@ -1,6 +1,6 @@
-"""build_preview.py —— 用清洗后的素材打一个单文件 HTML 可玩预览。
+"""build_preview.py —— 用已生成素材打一个单文件 HTML 可玩预览。
 
-把 out/<game>/ 里的角色 + UI 美术(已去棋盘格、真透明)按比例内嵌成
+把 out/<game>/ 里的角色 + UI 美术按比例内嵌成
 base64,生成一个 index.html:手机竖屏布局、霓虹框包住果冻网格、可点 SPIN
 跑"同款聚 5 只就消除→连锁倍数"的玩法。纯前端、双击即玩,无需任何后端。
 

+ 0 - 103
dechecker.py

@@ -1,103 +0,0 @@
-"""dechecker.py —— 兜底用:把 AI 图里画死的"透明棋盘格/背景"抠掉,恢复真透明。
-
-重要:现在生图管线已直接向接口请求真·透明 PNG(providers + pipeline),
-正常情况下根本不需要本模块。只有当上游模型仍把背景画成棋盘格时,才作为
-最后兜底自动运行。
-
-关键改进(避免戳洞):只移除"与图像边缘相连"的背景(四周的棋盘格 + 透明边),
-角色内部的白色高光/亮点因为被角色包住、不与边缘相连,会被完整保留——
-不会再像以前那样把白色切成缺口。
-"""
-import os
-import sys
-import numpy as np
-from PIL import Image, ImageFilter
-
-
-def _border_outside(bg):
-    """返回与图像四边相连的背景区域(布尔)。优先用 cv2,没有则用 numpy 形态学重建。"""
-    try:
-        import cv2
-        n, labels = cv2.connectedComponents(bg.astype(np.uint8), connectivity=4)
-        border = set(labels[0, :]).union(labels[-1, :], labels[:, 0], labels[:, -1])
-        border.discard(0)
-        return np.isin(labels, list(border))
-    except Exception:
-        # numpy 兜底:从四边种子在 bg 内反复膨胀直到稳定(形态学重建)
-        seed = np.zeros_like(bg)
-        seed[0, :] = bg[0, :]; seed[-1, :] = bg[-1, :]
-        seed[:, 0] = bg[:, 0]; seed[:, -1] = bg[:, -1]
-        for _ in range(4000):
-            grown = seed | np.roll(seed, 1, 0) | np.roll(seed, -1, 0) \
-                         | np.roll(seed, 1, 1) | np.roll(seed, -1, 1)
-            grown &= bg
-            if np.array_equal(grown, seed):
-                break
-            seed = grown
-        return seed
-
-
-def clean(im):
-    """返回 (新图RGBA, 抹掉的像素数)。无棋盘/已透明的图基本无操作。"""
-    img = im.convert("RGBA")
-    arr = np.asarray(img).copy()
-    al = arr[:, :, 3]
-    rgb = arr[:, :, :3].astype(np.int16)
-    bright = rgb.max(2)
-    sat = bright - rgb.min(2)
-    light = (al >= 200) & (bright > 185) & (sat < 28)   # 棋盘格的白/浅灰方块
-    trans = al < 40
-    if light.mean() < 0.04:                              # 几乎没有浅色块 → 不是棋盘
-        return img, 0
-    bg = light | trans
-    outside = _border_outside(bg)
-    # 只在"边缘相连的浅色块"占比可观时才动手(避免误伤纯色背景插画)
-    if (outside & light).mean() < 0.03:
-        return img, 0
-    # 向内吃 2px 去掉灰边
-    m = outside
-    for _ in range(2):
-        m = (m | np.roll(m, 1, 0) | np.roll(m, -1, 0)
-               | np.roll(m, 1, 1) | np.roll(m, -1, 1))
-    removed = int(((al > 10) & m).sum())
-    a = al.copy(); a[m] = 0
-    arr[:, :, 3] = a
-    arr[m, 0] = 0; arr[m, 1] = 0; arr[m, 2] = 0
-    out = Image.fromarray(arr.astype(np.uint8), "RGBA")
-    out.putalpha(out.getchannel("A").filter(ImageFilter.GaussianBlur(1.0)))   # 柔边
-    return out, removed
-
-
-def declutter_img(img, feather=False):
-    out, removed = clean(img)
-    return out.convert("RGBA"), removed > 0
-
-
-def declutter_file(src, dst=None):
-    out, removed = clean(Image.open(src))
-    if removed > 0:
-        out.save(dst or src)
-    return removed > 0
-
-
-def _walk(paths):
-    for p in paths:
-        if os.path.isdir(p):
-            for root, _d, files in os.walk(p):
-                for f in files:
-                    if f.lower().endswith(".png"):
-                        yield os.path.join(root, f)
-        elif p.lower().endswith(".png"):
-            yield p
-
-
-if __name__ == "__main__":
-    n = 0
-    for f in _walk(sys.argv[1:] or ["."]):
-        try:
-            if declutter_file(f):
-                n += 1
-                print("cleaned:", f)
-        except Exception as e:
-            print("skip", f, e)
-    print(f"done. {n} file(s) cleaned.")

+ 1 - 2
local_config.example.json

@@ -2,6 +2,5 @@
   "ANIM_STUDIO_BASE_URL": "https://x.long.bid/v1",
   "ANIM_STUDIO_API_KEY": "replace-with-your-key",
   "ANIM_STUDIO_IMAGE_MODEL": "gpt-image-2",
-  "ANIM_STUDIO_TEXT_MODEL": "gpt-5.4-mini",
-  "ALIYUN_BGREM_APP_CODE": "replace-with-your-app-code"
+  "ANIM_STUDIO_TEXT_MODEL": "gpt-5.4-mini"
 }

+ 0 - 9
pipeline.py

@@ -9,7 +9,6 @@ import providers
 import spine_builder
 import particle_builder
 import tween_builder
-import background_remover
 
 HERE = os.path.dirname(os.path.abspath(__file__))
 
@@ -33,7 +32,6 @@ def run(manifest, out_root, creds=None, log=print):
         "ui": [],
         "ui_art": [],
     }
-    remove_bg_enabled = bool(creds.get("remove_bg", False))
     total_steps = len(manifest.get("characters", [])) + len(manifest.get("ui_art", [])) + len(manifest.get("vfx", [])) + (1 if manifest.get("ui", []) else 0)
     done_steps = 0
 
@@ -77,9 +75,6 @@ def run(manifest, out_root, creds=None, log=print):
                                              creds.get("base_url", "https://api.openai.com/v1"),
                                              creds.get("model", "gpt-image-2"),
                                              part.get("size", c.get("size", creds.get("size", "1024x1024"))))
-                    pimg = background_remover.remove_background(pimg, log=log, label=f"{cid}/{part_id}",
-                                                               enabled=remove_bg_enabled,
-                                                               image_url=pimg.info.get("source_url"))
                     part_images[part_id] = pimg
                 spine_builder.build_parts_character(cid, part_images, chars_out, anims, parts)
                 w, h = 1000, 1000
@@ -94,8 +89,6 @@ def run(manifest, out_root, creds=None, log=print):
                                          creds.get("base_url", "https://api.openai.com/v1"),
                                          creds.get("model", "gpt-image-2"),
                                          c.get("size", creds.get("size", "1024x1024")))
-                img = background_remover.remove_background(img, log=log, label=cid, enabled=remove_bg_enabled,
-                                                           image_url=img.info.get("source_url"))
                 spine_builder.build_character(cid, img, chars_out, anims)
                 w, h = spine_builder.trim_to_content(img).size
                 files = [f"characters/{cid}.json", f"characters/{cid}.atlas", f"characters/{cid}.png"]
@@ -132,8 +125,6 @@ def run(manifest, out_root, creds=None, log=print):
                                      creds.get("base_url", "https://api.openai.com/v1"),
                                      creds.get("model", "gpt-image-2"),
                                      a.get("size", creds.get("size", "1024x1024")))
-            img = background_remover.remove_background(img, log=log, label=aid, enabled=remove_bg_enabled and transparent,
-                                                       image_url=img.info.get("source_url"))
             os.makedirs(ui_art_out, exist_ok=True)
             img.save(os.path.join(ui_art_out, f"{aid}.png"))
             library["ui_art"].append({"id": aid, "file": f"ui_art/{aid}.png",

+ 0 - 13
providers.py

@@ -218,23 +218,10 @@ def gen_image_openai(prompt, api_key, base_url="https://api.openai.com/v1",
     else:
         raise RuntimeError(f"返回里既无 b64_json/base64/image 也无 url:{_short_json(data)}")
     img = Image.open(io.BytesIO(raw)).convert("RGBA")
-    img = _auto_declutter(img)
     img.info["source_url"] = source_url or ""
     return img
 
 
-def _auto_declutter(img):
-    """统一出口:若模型把"透明背景"画成了灰白棋盘格(真像素、alpha 全 255),
-    自动抠成真透明。对本就透明 / 无棋盘格的图(角色、整张背景)是无操作,
-    所以可以无差别地套在每张生成图上;失败时绝不影响主流程。"""
-    try:
-        import dechecker
-        cleaned, changed = dechecker.declutter_img(img)
-        return cleaned if changed else img
-    except Exception:
-        return img
-
-
 PROVIDERS = {"OpenAI 兼容接口": gen_image_openai}
 
 

+ 0 - 1
requirements.txt

@@ -1,6 +1,5 @@
 # 必需的第三方库(网站后端用标准库,无需 web 框架)
 Pillow>=9.0.0
-numpy>=1.21.0       # dechecker 去棋盘格用(恢复 AI 图被画死的"透明背景")
 certifi>=2023.0.0   # 提供 CA 证书,解决 macOS SSL CERTIFICATE_VERIFY_FAILED
 
 # 可选:python app.py 那个极简表单界面才需要;网站(server.py)不需要

+ 0 - 1
server.py

@@ -420,7 +420,6 @@ class Handler(BaseHTTPRequestHandler):
             "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"),
-            "remove_bg": bool(data.get("remove_bg", False)),
         }
         logs = []
         if data.get("async", True):

+ 2 - 66
spine_builder.py

@@ -100,70 +100,6 @@ ANIM_FACTORY = {
 
 # ---------- 图片处理 ----------
 
-def remove_bg(img, bright=200, sat=38):
-    """自动去背:把"亮且低饱和(白/浅灰/奶油)且与四边连通"的像素抠成透明。
-    用边界连通传播,所以果冻身上被颜色包住的白高光/白眼睛不会被误删。
-    纯 numpy 实现,不依赖 scipy;没装 numpy 时退回 PIL 泛洪。"""
-    img = img.convert("RGBA")
-    try:
-        import numpy as np
-        arr = np.array(img)
-        rgb = arr[:, :, :3].astype(np.int16)
-        mx = rgb.max(axis=2); mn = rgb.min(axis=2)
-        bgcand = (mn >= bright) & ((mx - mn) <= sat)        # 背景候选:亮 + 低饱和
-        # 从靠近四边的一圈带里取种子(最外 1~2px 常是透明黑边,要往里一点)
-        H, W = bgcand.shape
-        band = max(10, H // 80)
-        seed = np.zeros_like(bgcand)
-        seed[:band, :] = True; seed[-band:, :] = True
-        seed[:, :band] = True; seed[:, -band:] = True
-        bg = bgcand & seed
-        for _ in range(2000):                                # 从四边向内连通传播
-            prev = int(bg.sum())
-            new = bg.copy()
-            new[1:, :] |= bg[:-1, :]; new[:-1, :] |= bg[1:, :]
-            new[:, 1:] |= bg[:, :-1]; new[:, :-1] |= bg[:, 1:]
-            new &= bgcand
-            bg = new
-            if int(bg.sum()) == prev:
-                break
-        # 羽化一圈,柔化果冻边缘的白色硬边
-        edge = bg.copy()
-        edge[1:, :] |= bg[:-1, :]; edge[:-1, :] |= bg[1:, :]
-        edge[:, 1:] |= bg[:, :-1]; edge[:, :-1] |= bg[:, 1:]
-        fringe = edge & ~bg & (mn >= bright - 30)
-        arr[bg, 3] = 0
-        arr[fringe, 3] = (arr[fringe, 3] * 0.35).astype(arr.dtype)
-        return Image.fromarray(arr, "RGBA")
-    except Exception:
-        return _floodfill_bg(img, 236)
-
-
-def _floodfill_bg(img, thresh):
-    """纯 PIL 兜底:先把透明区垫白(否则透明边会被当种子),再从四边泛洪近白背景。"""
-    from PIL import ImageDraw
-    w, h = img.size
-    rgb = Image.new("RGB", (w, h), (255, 255, 255))
-    rgb.paste(img.convert("RGB"), mask=img.split()[3])   # 用 alpha 贴,透明处保持白
-    tol = 255 - thresh + 10
-    seeds = [(0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1),
-             (w // 2, 0), (w // 2, h - 1), (0, h // 2), (w - 1, h // 2)]
-    for s in seeds:
-        ImageDraw.floodfill(rgb, s, (255, 0, 255), thresh=tol)
-    try:
-        import numpy as np
-        mask = (np.array(rgb) == (255, 0, 255)).all(axis=2)
-        arr = np.array(img.convert("RGBA"))
-        arr[mask, 3] = 0
-        return Image.fromarray(arr, "RGBA")
-    except Exception:
-        out = img.convert("RGBA"); po, pr = out.load(), rgb.load()
-        for y in range(h):
-            for x in range(w):
-                if pr[x, y] == (255, 0, 255):
-                    r, g, b, _ = po[x, y]; po[x, y] = (r, g, b, 0)
-        return out
-
 
 def trim_to_content(img, pad=4):
     """裁掉透明边,留一点 padding。返回裁好的 RGBA。"""
@@ -290,7 +226,7 @@ def anim_data(animations):
 def build_character(char_id, image, out_dir, animations):
     """主入口:image(PIL RGBA) -> out_dir/<id>.{json,atlas,png}。返回 png 路径。"""
     os.makedirs(out_dir, exist_ok=True)
-    img = trim_to_content(remove_bg(image))   # 先自动去白底,再裁透明边
+    img = trim_to_content(image.convert("RGBA"))
     w, h = img.size
 
     png_name = f"{char_id}.png"
@@ -420,7 +356,7 @@ def build_parts_character(char_id, part_images, out_dir, animations, layout_part
     atlas_h = 0
     for part in layout_parts:
         pid = part["id"]
-        img = trim_to_content(remove_bg(part_images[pid]), pad=2)
+        img = trim_to_content(part_images[pid].convert("RGBA"), pad=2)
         w, h = img.size
         packed.append({**part, "img": img, "x_atlas": x, "y_atlas": 0, "w": w, "h": h})
         x += w + 2

+ 0 - 2
web/index.html

@@ -190,7 +190,6 @@
           <input id="model" value="gpt-image-2"></div>
         <div class="field"><label>尺寸</label>
           <select id="size"><option>1024x1024</option><option>1024x1536</option><option>1536x1024</option></select></div>
-        <div class="field"><label><input type="checkbox" id="removeBg"> 生图后去背景(较慢)</label></div>
         <button id="startBtn">▶ 开始生成</button>
         <div class="log" id="log" style="display:none"></div>
       </div>
@@ -631,7 +630,6 @@ $('#startBtn').onclick=async()=>{
       body:JSON.stringify({
         provider:$('#provider').value, api_key:$('#apiKey').value,
         base_url:$('#baseUrl').value, model:$('#model').value, size:$('#size').value,
-        remove_bg:$('#removeBg').checked,
         manifest:$('#manifest').value, async:true })});
     const d=await r.json();
     if(!d.ok || !d.jobId){