background_remover.py 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. """Aliyun background-removal integration.
  2. Aliyun Marketplace product cmapi00069648 exposes:
  3. POST https://bgrem.market.alicloudapi.com/api/v1/bg-remove/submit
  4. POST https://bgrem.market.alicloudapi.com/api/v1/bg-remove/query
  5. The remote service requires a public image URL. Generated images from the
  6. current gpt-image-2 gateway are usually b64-only, so the pipeline uses remote
  7. matting only when a public URL is available. There is intentionally no local
  8. fallback because local matting was not clean enough for production assets.
  9. """
  10. import base64
  11. import http.client
  12. import io
  13. import json
  14. import time
  15. import uuid
  16. from urllib.parse import urlparse
  17. from PIL import Image, ImageFilter
  18. import config
  19. ALIYUN_APP_CODE = config.get("ALIYUN_BGREM_APP_CODE", "")
  20. HOST = "bgrem.market.alicloudapi.com"
  21. SUBMIT_PATH = "/api/v1/bg-remove/submit"
  22. QUERY_PATH = "/api/v1/bg-remove/query"
  23. def _json_post(path, payload, timeout=60):
  24. if not ALIYUN_APP_CODE:
  25. raise RuntimeError("missing ALIYUN_BGREM_APP_CODE")
  26. body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
  27. headers = {
  28. "Content-Type": "application/json; charset=utf-8",
  29. "Accept": "application/json",
  30. "Authorization": f"APPCODE {ALIYUN_APP_CODE}",
  31. "X-Ca-Nonce": uuid.uuid4().hex,
  32. }
  33. conn = http.client.HTTPSConnection(HOST, timeout=timeout)
  34. try:
  35. conn.request("POST", path, body=body, headers=headers)
  36. res = conn.getresponse()
  37. raw = res.read()
  38. res_headers = dict(res.getheaders())
  39. finally:
  40. conn.close()
  41. if res.status < 200 or res.status >= 300:
  42. msg = raw.decode("utf-8", "ignore") or res_headers.get("X-Ca-Error-Message", "")
  43. raise RuntimeError(f"HTTP {res.status}: {msg}")
  44. if not raw:
  45. raise RuntimeError("empty response")
  46. return json.loads(raw.decode("utf-8"))
  47. def _download(url, timeout=120):
  48. parsed = urlparse(url)
  49. path = parsed.path or "/"
  50. if parsed.query:
  51. path += "?" + parsed.query
  52. conn_cls = http.client.HTTPSConnection if parsed.scheme == "https" else http.client.HTTPConnection
  53. conn = conn_cls(parsed.hostname, parsed.port, timeout=timeout)
  54. try:
  55. conn.request("GET", path)
  56. res = conn.getresponse()
  57. raw = res.read()
  58. finally:
  59. conn.close()
  60. if res.status < 200 or res.status >= 300:
  61. raise RuntimeError(f"download HTTP {res.status}")
  62. return raw
  63. def _extract_result_key(data):
  64. payload = data.get("data", data)
  65. if isinstance(payload, str):
  66. try:
  67. payload = json.loads(payload)
  68. except Exception:
  69. return payload
  70. if isinstance(payload, dict):
  71. for key in ("result_key", "resultKey", "task_key", "taskKey"):
  72. if payload.get(key):
  73. return payload[key]
  74. tasks = payload.get("task_list") or payload.get("taskList")
  75. if isinstance(tasks, list) and tasks:
  76. return _extract_result_key(tasks[0])
  77. return None
  78. def _extract_result_url(data):
  79. payload = data.get("data", data)
  80. if isinstance(payload, str):
  81. try:
  82. payload = json.loads(payload)
  83. except Exception:
  84. return payload if payload.startswith(("http://", "https://")) else None
  85. if isinstance(payload, dict):
  86. results = payload.get("results")
  87. if isinstance(results, list) and results:
  88. return results[0]
  89. for key in ("url", "result", "result_url", "resultUrl", "image_url", "imageUrl", "alpha", "alpha_url"):
  90. val = payload.get(key)
  91. if isinstance(val, str) and val:
  92. return val
  93. tasks = payload.get("task_list") or payload.get("taskList") or payload.get("result_list") or payload.get("resultList")
  94. if isinstance(tasks, list) and tasks:
  95. return _extract_result_url(tasks[0])
  96. return None
  97. def _ensure_success(data):
  98. if data.get("success") is False:
  99. raise RuntimeError(data.get("message") or json.dumps(data, ensure_ascii=False)[:500])
  100. code = data.get("code")
  101. if code not in (None, 0, "0", 200, "200"):
  102. raise RuntimeError(data.get("message") or json.dumps(data, ensure_ascii=False)[:500])
  103. def _remote_remove_by_url(image_url, model_type="general", log=None, label="image"):
  104. submit_body = {"data": {"task_list": [{"model_type": model_type, "image": image_url}]}}
  105. if log:
  106. log(f"🧼 [{label}] 去背景:提交阿里云任务…")
  107. submit = _json_post(SUBMIT_PATH, submit_body, timeout=60)
  108. _ensure_success(submit)
  109. result_key = _extract_result_key(submit)
  110. if not result_key:
  111. raise RuntimeError(f"提交接口未返回 result_key: {json.dumps(submit, ensure_ascii=False)[:500]}")
  112. if log:
  113. log(f"🧼 [{label}] 去背景:任务已提交 result_key={result_key}")
  114. query_body = {"data": {"result_key": result_key}}
  115. last = None
  116. for i in range(40):
  117. time.sleep(1.5 if i else 0.3)
  118. data = _json_post(QUERY_PATH, query_body, timeout=60)
  119. _ensure_success(data)
  120. last = data
  121. result_url = _extract_result_url(data)
  122. if result_url:
  123. if log:
  124. log(f"🧼 [{label}] 去背景:任务完成,下载透明 PNG…")
  125. return Image.open(io.BytesIO(_download(result_url))).convert("RGBA")
  126. if log and i in (0, 3, 10, 20):
  127. log(f"🧼 [{label}] 去背景:等待任务结果…")
  128. raise RuntimeError(f"查询超时,最后返回: {json.dumps(last, ensure_ascii=False)[:500]}")
  129. def remove_background(img, log=None, label="image", enabled=True, image_url=None, model_type="general"):
  130. if not enabled:
  131. return img.convert("RGBA")
  132. if image_url and image_url.startswith(("http://", "https://")):
  133. try:
  134. out = _remote_remove_by_url(image_url, model_type=model_type, log=log, label=label)
  135. if log:
  136. log(f"✅ [{label}] 远端去背景完成:透明 PNG {out.size[0]}×{out.size[1]}")
  137. return out
  138. except Exception as e:
  139. if log:
  140. log(f"⚠️ [{label}] 远端去背景失败,保留原图:{e}")
  141. else:
  142. if log:
  143. log(f"ℹ️ [{label}] 去背景:当前图片没有公网 URL,跳过远端去背景并保留原图")
  144. return img.convert("RGBA")