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` 最合适)
|
- `hairline_images`:发际线叠加图列表(发际线曲线叠加在用户照片上,同接口2预览),
|
||||||
- `best_hairline_center_point`:最合适发际线的面部中间点坐标,以**原图像素**为基准(左上角为原点,x 向右,y 向下)
|
数量 = 该性别的发际线类型数,本期按贴图顺序 `order=1..N`(暂不计算合适度)。
|
||||||
|
worker 返回 `image_base64`,网关落盘后改写为 `image_url`。
|
||||||
|
- `best_hairline_center_point`:最佳(`order=1`)发际线曲线的**面部中间点**坐标,
|
||||||
|
以**原图像素**为基准(左上角为原点,x 向右,y 向下)。
|
||||||
""",
|
""",
|
||||||
responses={
|
responses={
|
||||||
200: {
|
200: {
|
||||||
@@ -737,8 +742,8 @@ async def face_features(
|
|||||||
"request_id": "mock-request-id",
|
"request_id": "mock-request-id",
|
||||||
"data": {
|
"data": {
|
||||||
"hairline_images": [
|
"hairline_images": [
|
||||||
{"image_url": SAMPLE_IMAGE_URL, "order": 1},
|
{"image_base64": "iVBORw0KGgo...", "order": 1},
|
||||||
{"image_url": SAMPLE_IMAGE_URL, "order": 2},
|
{"image_base64": "iVBORw0KGgo...", "order": 2},
|
||||||
],
|
],
|
||||||
"best_hairline_center_point": {"x": 540, "y": 430},
|
"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_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG,≤ 1 MB)"),
|
||||||
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
||||||
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
|
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
|
||||||
|
gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"),
|
||||||
):
|
):
|
||||||
|
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 = {
|
data = {
|
||||||
"hairline_images": [
|
"hairline_images": hairline_images,
|
||||||
{"image_url": SAMPLE_IMAGE_URL, "order": 1},
|
"best_hairline_center_point": ({"x": c[0], "y": c[1]} if c else None),
|
||||||
{"image_url": SAMPLE_IMAGE_URL, "order": 2},
|
|
||||||
],
|
|
||||||
"best_hairline_center_point": {"x": 540, "y": 430},
|
|
||||||
}
|
}
|
||||||
return ok(data)
|
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)
|
### 输出(data)
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| hairline_images | object[] | N 张用户发际线 PNG,**数量 N 不固定**,已按合适度排序,元素见下表 |
|
| hairline_images | object[] | N 张发际线叠加图(发际线曲线叠在用户照片上,同接口2预览),**数量 = 该性别发际线数**,本期按贴图顺序,元素见下表 |
|
||||||
| best_hairline_center_point | object | 最合适发际线的「面部中间点」坐标,原图像素:`{ "x": number, "y": number }` |
|
| best_hairline_center_point | object | 最佳(order=1)发际线曲线的「面部中间点」坐标,原图像素:`{ "x": number, "y": number }` |
|
||||||
|
|
||||||
`hairline_images` 元素:
|
`hairline_images` 元素:
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| image_url | string | 发际线 PNG 图片 URL |
|
| image_url | string | 发际线叠加图 URL(worker 返回 `image_base64`,网关落盘后改写为 url) |
|
||||||
| order | int | 排序序号(1 = 最合适,依次递增) |
|
| order | int | 排序序号(本期固定 `1..N`,暂不计算合适度) |
|
||||||
|
|
||||||
### 响应示例(当前 Mock 返回值)
|
### 响应示例(当前 Mock 返回值)
|
||||||
|
|
||||||
|
|||||||
+33
-1
@@ -16,7 +16,7 @@ from .face_landmarks import FaceLandmarker
|
|||||||
from .face_parsing import FaceParser
|
from .face_parsing import FaceParser
|
||||||
from .hairline_2d import sample_hairline, smooth_hairline
|
from .hairline_2d import sample_hairline, smooth_hairline
|
||||||
from .lift_3d import lift_hairline_to_3d, build_middle_row, assemble_full
|
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 .mask import build_inpaint_mask, compose_comfy_rgba, mask_from_curve
|
||||||
from .marker_detect import detect_marker_hairline, path_to_curve_mask
|
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
|
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):
|
def generate_grow_b(marked_bgr: np.ndarray, original_bgr: np.ndarray):
|
||||||
"""接口3:检测医生手绘发际线 → 遮罩 → 原图重画干净线 → ComfyUI 生发。
|
"""接口3:检测医生手绘发际线 → 遮罩 → 原图重画干净线 → ComfyUI 生发。
|
||||||
|
|
||||||
|
|||||||
@@ -136,6 +136,26 @@ def test_growb_success(client, monkeypatch):
|
|||||||
assert "best_hairline_image_url" not in d
|
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):
|
def test_success_structure(client):
|
||||||
r = _post(client, "frontal.jpg")
|
r = _post(client, "frontal.jpg")
|
||||||
body = r.json()
|
body = r.json()
|
||||||
|
|||||||
Reference in New Issue
Block a user