perf(图片): 接口2/3/5 返回 JPG(体积~9×↓),接口1 标注图仍 PNG(透明)

- app.py: 接口2(预览+生发)/3(生发)/5(发际线叠图) 编码改 JPG(质量90,env JPG_QUALITY);
  接口1 annotated_image 含透明仍 PNG。_png_to_jpg_b64 把 ComfyUI 的 PNG 重编码为 JPG(无法解码则透传)
- gateway/forward.py: 落盘按内容嗅探扩展名(PNG头→.png 否则.jpg),原先硬编码 .png
- 测试/文档同步;实测接口5 一张 59KB(JPG) vs 548KB(PNG)。pytest 44 全绿

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
xsl
2026-06-15 23:35:24 +08:00
co-authored by Claude Opus 4.8
parent e3c67fc8cf
commit 4c7681b338
4 changed files with 36 additions and 17 deletions
+23 -8
View File
@@ -226,6 +226,24 @@ async def resolve_image_bytes(image_file, image_url, image_base64):
return None, err(1008, "base64 解码失败") return None, err(1008, "base64 解码失败")
# 接口2/3/5 返回不透明照片,用 JPG 显著减小体积(接口1 标注图含透明,仍用 PNG)
_JPG_QUALITY = int(os.getenv("JPG_QUALITY", "90"))
def _jpg_b64(bgr) -> str:
"""BGR 图 → JPG base64。"""
_ok, buf = cv2.imencode(".jpg", bgr, [cv2.IMWRITE_JPEG_QUALITY, _JPG_QUALITY])
return base64.b64encode(buf.tobytes()).decode()
def _png_to_jpg_b64(png_bytes) -> str:
"""ComfyUI 返回的 PNG 字节 → 重编码为 JPG base64;无法解码则原样透传。"""
img = cv2.imdecode(np.frombuffer(png_bytes, np.uint8), cv2.IMREAD_COLOR)
if img is None:
return base64.b64encode(png_bytes).decode()
return _jpg_b64(img)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 通用图片请求 BodyJSON 方式,用于 url / base64 # 通用图片请求 BodyJSON 方式,用于 url / base64
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -514,12 +532,10 @@ async def hair_grow(
results = [] results = []
for p in items: for p in items:
_ok, png = cv2.imencode(".png", p["image_bgr"])
grown_b64 = (base64.b64encode(p["grown_png"]).decode()
if p["grown_png"] else None)
results.append({ results.append({
"image_base64": base64.b64encode(png.tobytes()).decode(), "image_base64": _jpg_b64(p["image_bgr"]), # 预览图 JPG
"grown_image_base64": grown_b64, "grown_image_base64": (_png_to_jpg_b64(p["grown_png"]) # 生发图 JPG
if p["grown_png"] else None),
"hairline_type": p["hairline_type"], "hairline_type": p["hairline_type"],
"order": p["order"], "order": p["order"],
}) })
@@ -602,7 +618,7 @@ async def hair_grow_b(
if res["status"] == "no_line": if res["status"] == "no_line":
return err(1001, "未检测到发际线划线,请确认划线图额头有清晰的手绘发际线") return err(1001, "未检测到发际线划线,请确认划线图额头有清晰的手绘发际线")
grown_b64 = base64.b64encode(res["grown_png"]).decode() if res["grown_png"] else None grown_b64 = _png_to_jpg_b64(res["grown_png"]) if res["grown_png"] else None # 生发图 JPG
data = { data = {
"hair_growth_image_base64": grown_b64, "hair_growth_image_base64": grown_b64,
"hairline_type": "custom", "hairline_type": "custom",
@@ -783,9 +799,8 @@ async def hairline_generate(
hairline_images = [] hairline_images = []
for it in res["images"]: for it in res["images"]:
_ok, png = cv2.imencode(".png", it["image_bgr"])
hairline_images.append({ hairline_images.append({
"image_base64": base64.b64encode(png.tobytes()).decode(), "image_base64": _jpg_b64(it["image_bgr"]), # 发际线叠图 JPG
"order": it["order"], "order": it["order"],
}) })
c = res["best_center"] c = res["best_center"]
+2
View File
@@ -35,6 +35,8 @@
| 4 | (网关本机产出,无图片字段,`features` 为 JSON 字符串) | — | | 4 | (网关本机产出,无图片字段,`features` 为 JSON 字符串) | — |
> 实现建议:递归遍历 data,凡 key 以 `_base64` 结尾就落盘改 `_url`,自动覆盖嵌套/新增字段。 > 实现建议:递归遍历 data,凡 key 以 `_base64` 结尾就落盘改 `_url`,自动覆盖嵌套/新增字段。
> **图片格式**:接口1 标注图含透明用 **PNG**;接口2/3/5 是不透明照片用 **JPG**(小很多,~9×)。
> 网关落盘按内容嗅探扩展名(PNG 头→`.png`,否则 `.jpg`)。
### 错误码 ### 错误码
+3 -2
View File
@@ -102,8 +102,9 @@ def rewrite_base64_to_url(
if key.endswith("_base64") and isinstance(value, str): if key.endswith("_base64") and isinstance(value, str):
img_bytes = _decode_base64_value(value) img_bytes = _decode_base64_value(value)
if img_bytes is not None: if img_bytes is not None:
# 生成文件名并落盘 # 按内容嗅探扩展名:PNG(接口1标注图,含透明) / JPEG(接口2/3/5 照片)
filename = f"{uuid.uuid4().hex}.png" ext = "png" if img_bytes[:8] == b"\x89PNG\r\n\x1a\n" else "jpg"
filename = f"{uuid.uuid4().hex}.{ext}"
filepath = Path(static_dir) / filename filepath = Path(static_dir) / filename
filepath.write_bytes(img_bytes) filepath.write_bytes(img_bytes)
+8 -7
View File
@@ -82,9 +82,10 @@ def test_grow_missing_gender_1004(client):
assert r.json()["code"] == 1004 assert r.json()["code"] == 1004
_PNG_1x1 = base64.b64decode( # mock ComfyUI 输出:一张合法 PNGworker 会把它重编码成 JPG
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" import cv2 as _cv2
) import numpy as _np
_PNG_1x1 = _cv2.imencode(".png", _np.full((8, 8, 3), 200, _np.uint8))[1].tobytes()
def test_grow_female_returns_5(client, monkeypatch): def test_grow_female_returns_5(client, monkeypatch):
@@ -99,8 +100,8 @@ def test_grow_female_returns_5(client, monkeypatch):
results = body["data"]["results"] results = body["data"]["results"]
assert [x["hairline_type"] for x in results] == ["ellipse", "flower", "heart", "straight", "wave"] assert [x["hairline_type"] for x in results] == ["ellipse", "flower", "heart", "straight", "wave"]
assert [x["order"] for x in results] == [1, 2, 3, 4, 5] assert [x["order"] for x in results] == [1, 2, 3, 4, 5]
assert base64.b64decode(results[0]["image_base64"])[:8] == b"\x89PNG\r\n\x1a\n" assert base64.b64decode(results[0]["image_base64"])[:3] == b"\xff\xd8\xff" # JPEG
assert base64.b64decode(results[0]["grown_image_base64"])[:8] == b"\x89PNG\r\n\x1a\n" assert base64.b64decode(results[0]["grown_image_base64"])[:3] == b"\xff\xd8\xff" # JPEG
assert "image_url" not in results[0] assert "image_url" not in results[0]
@@ -126,7 +127,7 @@ def test_growb_success(client, monkeypatch):
assert body["code"] == 0, body assert body["code"] == 0, body
d = body["data"] d = body["data"]
assert d["hairline_type"] == "custom" assert d["hairline_type"] == "custom"
assert base64.b64decode(d["hair_growth_image_base64"])[:8] == b"\x89PNG\r\n\x1a\n" assert base64.b64decode(d["hair_growth_image_base64"])[:3] == b"\xff\xd8\xff" # JPEG
assert "best_hairline_image_base64" not in d # 已去掉该字段 assert "best_hairline_image_base64" not in d # 已去掉该字段
assert "best_hairline_image_url" not in d assert "best_hairline_image_url" not in d
@@ -145,7 +146,7 @@ def test_hairline_gen_female(client):
assert body["code"] == 0, body assert body["code"] == 0, body
d = body["data"] d = body["data"]
assert [x["order"] for x in d["hairline_images"]] == [1, 2, 3, 4, 5] assert [x["order"] for x in d["hairline_images"]] == [1, 2, 3, 4, 5]
assert base64.b64decode(d["hairline_images"][0]["image_base64"])[:8] == b"\x89PNG\r\n\x1a\n" assert base64.b64decode(d["hairline_images"][0]["image_base64"])[:3] == b"\xff\xd8\xff" # JPEG
c = d["best_hairline_center_point"] c = d["best_hairline_center_point"]
assert 0 <= c["x"] <= 682 and 0 <= c["y"] <= 811 # 落在原图范围内 assert 0 <= c["x"] <= 682 and 0 <= c["y"] <= 811 # 落在原图范围内
assert "image_url" not in d["hairline_images"][0] assert "image_url" not in d["hairline_images"][0]