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:
@@ -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
@@ -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 | 发际线叠加图 URL(worker 返回 `image_base64`,网关落盘后改写为 url) |
|
||||
| order | int | 排序序号(本期固定 `1..N`,暂不计算合适度) |
|
||||
|
||||
### 响应示例(当前 Mock 返回值)
|
||||
|
||||
|
||||
+33
-1
@@ -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 生发。
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user