feat(接口5): 发际线PNG生成(真实实现,替换Mock)

复用接口2 预览管线:gender 必填 → 该性别全部发际线叠加图(同接口2预览) +
最佳(order=1)发际线曲线的面部中间点坐标。无生发(不调 ComfyUI)。

- hairline/service.py: generate_hairline_pngs——N张发际线叠图 +
  best_center(面部中轴眉心x × order1曲线在该处的y)
- app.py: /hairline/generate 真实实现,新增 gender 必填(非法→1004),
  返回 hairline_images[].image_base64 + best_hairline_center_point;
  无人脸→1001;重活线程池
- 接口文档/OpenAPI: 接口5 新增 gender 入参 + 输出改 base64(网关改url)
- 测试: test_api 接口5(gender必填/N张/center点),44全绿

实测(5090): female 5张叠图 + center{x,y},~2.5s(无Flux)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
xsl
2026-06-15 00:20:56 +08:00
co-authored by Claude Opus 4.8
parent ce95a508c1
commit 38161d1b50
4 changed files with 114 additions and 18 deletions
+52 -12
View File
@@ -721,10 +721,15 @@ async def face_features(
---
**入参**:新增必填 `gender``male`/`female`),决定返回的发际线集合(female 5 / male 4)。
**返回说明**
- `hairline_images`:发际线 PNG 列表,数量 N 不固定,已按合适度**从高到低排序**(`order=1` 最合适)
- `best_hairline_center_point`:最合适发际线的面部中间点坐标,以**原图像素**为基准(左上角为原点,x 向右,y 向下)
- `hairline_images`:发际线叠加图列表(发际线曲线叠加在用户照片上,同接口2预览),
数量 = 该性别的发际线类型数,本期按贴图顺序 `order=1..N`(暂不计算合适度)。
worker 返回 `image_base64`,网关落盘后改写为 `image_url`。
- `best_hairline_center_point`:最佳(`order=1`)发际线曲线的**面部中间点**坐标,
以**原图像素**为基准(左上角为原点,x 向右,y 向下)。
""",
responses={
200: {
@@ -737,8 +742,8 @@ async def face_features(
"request_id": "mock-request-id",
"data": {
"hairline_images": [
{"image_url": SAMPLE_IMAGE_URL, "order": 1},
{"image_url": SAMPLE_IMAGE_URL, "order": 2},
{"image_base64": "iVBORw0KGgo...", "order": 1},
{"image_base64": "iVBORw0KGgo...", "order": 2},
],
"best_hairline_center_point": {"x": 540, "y": 430},
},
@@ -760,15 +765,50 @@ async def hairline_generate(
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG,≤ 1 MB"),
image_url: Optional[str] = Form(default=None, description="图片 URL"),
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"),
):
data = {
"hairline_images": [
{"image_url": SAMPLE_IMAGE_URL, "order": 1},
{"image_url": SAMPLE_IMAGE_URL, "order": 2},
],
"best_hairline_center_point": {"x": 540, "y": 430},
}
return ok(data)
if gender not in ("male", "female"):
return err(1004, "gender 必填且只能为 male / female")
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
if e is not None:
return e
if len(raw) > MAX_FILE_BYTES:
return err(1006, "文件超出 1 MB 限制")
image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
if image is None:
return err(1008, "图片格式不支持(仅 JPG / PNG)")
h, w = image.shape[:2]
short_side, long_side = min(w, h), max(w, h)
if short_side < MIN_SHORT_SIDE or long_side < MIN_LONG_SIDE:
return err(1002, "人像分辨率过低")
try:
from fastapi.concurrency import run_in_threadpool
from hairline.service import generate_hairline_pngs
res = await run_in_threadpool(generate_hairline_pngs, image, gender)
if res is None:
return err(1001, "无法识别人像")
hairline_images = []
for it in res["images"]:
_ok, png = cv2.imencode(".png", it["image_bgr"])
hairline_images.append({
"image_base64": base64.b64encode(png.tobytes()).decode(),
"order": it["order"],
})
c = res["best_center"]
data = {
"hairline_images": hairline_images,
"best_hairline_center_point": ({"x": c[0], "y": c[1]} if c else None),
}
return ok(data)
except Exception as ex: # noqa: BLE001
logger.exception("接口5 处理异常")
return err(1007, f"处理失败:{ex}")
# ---------------------------------------------------------------------------
+9 -5
View File
@@ -315,21 +315,25 @@
### 输入
图片参数见「通用约定 → 图片传参字段」。本接口无其他专属参数
图片参数见「通用约定 → 图片传参字段」。专属参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| gender | string | **是** | 性别:`male` / `female`。决定返回的发际线集合(female 5 / male 4 |
### 输出(data
| 字段 | 类型 | 说明 |
|------|------|------|
| hairline_images | object[] | N 张用户发际线 PNG,**数量 N 不固定**,已按合适度排序,元素见下表 |
| best_hairline_center_point | object | 最合适发际线的「面部中间点」坐标,原图像素:`{ "x": number, "y": number }` |
| hairline_images | object[] | N 张发际线叠加图(发际线曲线叠在用户照片上,同接口2预览),**数量 = 该性别发际线数**,本期按贴图顺序,元素见下表 |
| best_hairline_center_point | object | 最佳(order=1)发际线曲线的「面部中间点」坐标,原图像素:`{ "x": number, "y": number }` |
`hairline_images` 元素:
| 字段 | 类型 | 说明 |
|------|------|------|
| image_url | string | 发际线 PNG 图片 URL |
| order | int | 排序序号(1 = 最合适,依次递增 |
| image_url | string | 发际线叠加图 URLworker 返回 `image_base64`,网关落盘后改写为 url |
| order | int | 排序序号(本期固定 `1..N`,暂不计算合适度 |
### 响应示例(当前 Mock 返回值)
+33 -1
View File
@@ -16,7 +16,7 @@ from .face_landmarks import FaceLandmarker
from .face_parsing import FaceParser
from .hairline_2d import sample_hairline, smooth_hairline
from .lift_3d import lift_hairline_to_3d, build_middle_row, assemble_full
from .render import load_ext_mesh, load_texture_rgba, render_hairline_overlay
from .render import load_ext_mesh, load_texture_rgba, render_hairline_overlay, build_overlay_layer
from .mask import build_inpaint_mask, compose_comfy_rgba, mask_from_curve
from .marker_detect import detect_marker_hairline, path_to_curve_mask
@@ -161,6 +161,38 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str):
return results
def generate_hairline_pngs(image_bgr: np.ndarray, gender: str):
"""接口5:该性别全部发际线叠图(同接口2预览) + 最佳(order1)发际线曲线的面部中间点。
Returns: {"images":[{hairline_type,order,image_bgr}], "best_center":(x,y)};无人脸 None。
"""
if gender not in ("male", "female"):
raise ValueError(f"gender 必须是 male/female,收到 {gender!r}")
ctx = extract_context(image_bgr)
if ctx is None:
return None
h, w = image_bgr.shape[:2]
uv, ext_faces = load_ext_mesh()
lm = ctx["landmarks"]
# 面部中轴 x = 眉心(9/151 中点)
face_cx = float((lm[9, 0] + lm[151, 0]) / 2 * w)
textures = get_texture_map()[gender]
images, best_center = [], None
for order, (key, path) in enumerate(textures, start=1):
white = load_texture_rgba(path)
preview = render_hairline_overlay(image_bgr, ctx["points"], ext_faces, uv, white)
images.append({"hairline_type": key, "order": order, "image_bgr": preview})
if order == 1: # 最佳发际线曲线的中点(面部中轴处的发际线 y)
overlay = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white)
ys, xs = np.where(overlay[:, :, 3] > 40)
if xs.size:
near = np.abs(xs - face_cx) <= max(2, int(w * 0.02))
col_ys = ys[near] if near.any() else ys[np.argsort(np.abs(xs - face_cx))[:20]]
best_center = (int(round(face_cx)), int(round(float(col_ys.mean()))))
return {"images": images, "best_center": best_center}
def generate_grow_b(marked_bgr: np.ndarray, original_bgr: np.ndarray):
"""接口3:检测医生手绘发际线 → 遮罩 → 原图重画干净线 → ComfyUI 生发。
+20
View File
@@ -136,6 +136,26 @@ def test_growb_success(client, monkeypatch):
assert "best_hairline_image_url" not in d
HLGEN = "/api/v1/hairline/generate"
def test_hairline_gen_missing_gender_1004(client):
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
assert client.post(HLGEN, headers=H, files=files).json()["code"] == 1004
def test_hairline_gen_female(client):
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
body = client.post(HLGEN, headers=H, files=files, data={"gender": "female"}).json()
assert body["code"] == 0, body
d = body["data"]
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"
c = d["best_hairline_center_point"]
assert 0 <= c["x"] <= 682 and 0 <= c["y"] <= 811 # 落在原图范围内
assert "image_url" not in d["hairline_images"][0]
def test_success_structure(client):
r = _post(client, "frontal.jpg")
body = r.json()