From 57d1a876cc80b69ad6d489f37e87fe8399ac5e72 Mon Sep 17 00:00:00 2001 From: xsl Date: Tue, 23 Jun 2026 23:26:40 +0800 Subject: [PATCH] save code --- app.py | 67 ++++++++++++++++++++++++------------- docs/实现说明.md | 7 ++-- docs/接口文档.md | 39 +++++++++++---------- docs/网关待改动.md | 10 +++--- face_analysis/annotation.py | 53 +++++++++++++++++++---------- gateway/app.py | 6 ++++ 6 files changed, 116 insertions(+), 66 deletions(-) diff --git a/app.py b/app.py index e967948..4010d47 100644 --- a/app.py +++ b/app.py @@ -320,8 +320,12 @@ class ImageJsonBody(BaseModel): # --------------------------------------------------------------------------- -async def _face_measure_impl(image_file, image_url, image_base64): - """接口1/6 共用实现:四庭七眼测量 + 标注图生成。返回 (ok_dict, err_dict)。""" +async def _face_measure_impl(image_file, image_url, image_base64, variant="v1"): + """接口1/6 共用实现:四庭七眼测量 + 标注图生成。返回 (ok_dict, err_dict)。 + + variant="v1"(接口1):完整四庭七眼 + 头部端线。 + variant="v6"(接口6):去掉头顶/顶庭、不画人头最左/最右端线、数据去顶庭。 + """ # 1. 三选一取图 raw, e = await resolve_image_bytes(image_file, image_url, image_base64) if e is not None: @@ -368,12 +372,25 @@ async def _face_measure_impl(image_file, image_url, image_base64): # 8. 测量 + 标注图 result = measure_face(landmarks, hair_mask, w, h, head_pose=head_pose) - annotated = create_annotated_image(image, result, hair_mask=hair_mask) + annotated = create_annotated_image(image, result, hair_mask=hair_mask, variant=variant) buf = BytesIO() annotated.save(buf, format="PNG") # 9. 拆分架构:返回 base64,不落盘不拼 URL(落盘改 URL 由网关完成) data = result.to_response() + if variant == "v6": + # 接口6:数据去顶庭(重算三庭比例与总高,移除顶庭/头顶字段) + vd = result.vertical + base_px = vd["upper_court_px"] + vd["middle_court_px"] + vd["lower_court_px"] + data["four_courts"]["ratios"] = { + "upper_court": round(vd["upper_court_px"] / base_px, 3), + "middle_court": round(vd["middle_court_px"] / base_px, 3), + "lower_court": round(vd["lower_court_px"] / base_px, 3), + } + data["four_courts"].pop("top_court_cm", None) + data["face_total_height_cm"] = round( + result.upper_cm + result.middle_cm + result.lower_cm, 2) + data["landmarks"].pop("hair_top", None) data["annotated_image_base64"] = base64.b64encode(buf.getvalue()).decode() return ok(data), None except Exception as ex: # noqa: BLE001 @@ -401,10 +418,11 @@ async def _face_measure_impl(image_file, image_url, image_base64): **坐标说明**:所有坐标以原图像素为基准,原点为图片左上角,x 向右,y 向下。 **标注图片 UI 规范**(真实版本生效): -- 字体/线条颜色:`#FFFFFF 100%` -- 四庭数值在图片**左侧**呈现,七眼间距**上下穿插**展示 -- 字体:PingFangSC-Regular 10pt,线宽 1pt -- 横线/竖线渐变消失,虚线两侧带箭头 +- 字体/线条/箭头颜色:`#FFFFFF 100%`,透明底 +- 字号/线宽/虚线/箭头按图片短边自适应缩放 +- 四庭数值(名+数值两行,不带 cm)在图片**左侧**呈现,七眼段宽**上下穿插**展示,底部标「单位cm」 +- 横线/竖线渐变消失并略超出端点;段宽/庭高用虚线 + 实心三角双箭头标示 +- 竖线含人头最左/最右端线(取自头发分割轮廓),共 8 线 7 段 """, responses={ 200: { @@ -483,11 +501,16 @@ async def face_measure( description=f""" 输入用户正面照,返回: - 标注好四庭七眼数据的 **PNG 图片**(仅标注图层,不含人物) -- 四庭(顶庭/上庭/中庭/下庭)各段**厘米数值及占比** +- 三庭(上庭/中庭/下庭)各段**厘米数值及占比**(**不含顶庭**) - 七眼(眼宽/脸宽/两眼间距)**厘米数值及占比** -- 五个关键分界点的**原图像素坐标**(头顶/发际线/眉心/鼻翼下缘/下巴尖) +- 四个关键分界点的**原图像素坐标**(发际线/眉心/鼻翼下缘/下巴尖) -功能与接口1 完全一致,复刻实现。 +基于接口1 的变体,与接口1 的差异: +- **去顶庭**:不画头顶横线、不返回顶庭数据;`face_total_height_cm` 为三庭之和 +- **竖线范围**:纵向竖线从发际线画到下巴尖(接口1 为头顶→下巴尖) +- **不画人头最左/最右端线**:仅七眼 6 点共 5 段标尺,不取头发轮廓端线(接口1 为 8 线 7 段) + +其余(箭头/虚线/字体/单位cm/七眼数据)与接口1 一致。 {_image_fields_desc} @@ -498,10 +521,10 @@ async def face_measure( **坐标说明**:所有坐标以原图像素为基准,原点为图片左上角,x 向右,y 向下。 **标注图片 UI 规范**(真实版本生效): -- 字体/线条颜色:`#FFFFFF 100%` -- 四庭数值在图片**左侧**呈现,七眼间距**上下穿插**展示 -- 字体:PingFangSC-Regular 10pt,线宽 1pt -- 横线/竖线渐变消失,虚线两侧带箭头 +- 字体/线条/箭头颜色:`#FFFFFF 100%`,透明底 +- 字号/线宽/虚线/箭头按图片短边自适应缩放 +- 三庭数值(名+数值两行,不带 cm)在图片**左侧**呈现,七眼段宽**上下穿插**展示,底部标「单位cm」 +- 段宽/庭高用虚线 + 实心三角双箭头标示 """, responses={ 200: { @@ -514,17 +537,15 @@ async def face_measure( "request_id": "mock-request-id", "data": { "annotated_image_url": SAMPLE_IMAGE_URL, - "face_total_height_cm": 13.76, + "face_total_height_cm": 10.32, "four_courts": { - "top_court_cm": 3.44, "upper_court_cm": 3.44, "middle_court_cm": 3.44, "lower_court_cm": 3.44, "ratios": { - "top_court": 0.25, - "upper_court": 0.25, - "middle_court": 0.25, - "lower_court": 0.25, + "upper_court": 0.333, + "middle_court": 0.333, + "lower_court": 0.333, }, }, "seven_eyes": { @@ -534,7 +555,6 @@ async def face_measure( "ratios": {"eye_width": 0.143, "inter_eye_distance": 0.143}, }, "landmarks": { - "hair_top": {"x": 540, "y": 120}, "hairline": {"x": 540, "y": 430}, "brow_center": {"x": 540, "y": 740}, "nose_bottom": {"x": 540, "y": 1050}, @@ -564,8 +584,9 @@ async def face_measure_v2( image_url: Optional[str] = Form(default=None, description="图片 URL"), image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"), ): - """接口6:四庭七眼测量标注 v2(复刻接口1)""" - ok_data, err_data = await _face_measure_impl(image_file, image_url, image_base64) + """接口6:四庭七眼测量标注 v2(复刻接口1,去顶庭 + 去头部端线)""" + ok_data, err_data = await _face_measure_impl( + image_file, image_url, image_base64, variant="v6") return ok_data if ok_data is not None else err_data diff --git a/docs/实现说明.md b/docs/实现说明.md index a7422d2..6c341f5 100644 --- a/docs/实现说明.md +++ b/docs/实现说明.md @@ -56,9 +56,10 @@ 失败回退比例推算(方案A,`hairline_source` 透出)。标注图 numpy 向量化渐变线 + 思源黑体。返回 `annotated_image_base64`。 - 门槛可配:`MIN_SHORT_SIDE`/`MIN_LONG_SIDE`(默认600/800)、姿态阈值 `FRONTAL_*_THR`(默认30°)。 -### 接口6 四庭七眼测量 v2 `/api/v1/face/measure-v2`(worker)—— 复刻接口1 -- **做什么**:与接口 1 完全一致(正面照 → 四庭七眼 cm 与占比 + 5 个关键点 + 标注 PNG)。 -- **怎么实现**:与接口 1 共用 `_face_measure_impl()`,零额外逻辑。对外路径 `/api/v1/face/measure-v2`。 +### 接口6 四庭七眼测量 v2 `/api/v1/face/measure-v2`(worker)—— 接口1 的去顶庭变体 +- **做什么**:基于接口 1,**去顶庭**:不画头顶横线、不返回顶庭数据(`four_courts` 仅上/中/下庭,`landmarks` 无 `hair_top`,`face_total_height_cm` 为三庭之和)。 +- **与接口1 的标注差异**(`create_annotated_image(variant="v6")`):①竖线范围改为发际线→下巴尖;②不画人头最左/最右端线(仅七眼 6 点 5 段,接口1 为 8 线 7 段);③左侧只标上/中/下庭。箭头/虚线/字体等与接口1 一致。 +- **怎么实现**:与接口 1 共用 `_face_measure_impl(variant="v6")`;v6 时标注走变体分支、数据由 app.py 边界删顶庭字段并重算三庭比例。 - **网关改动**:新增路由 `POST /api/v1/face/measure-v2`,转发到 worker 同路径;base64→URL 改写无需改动。 ### 接口2 C端生发 `/api/v1/hair/grow`(worker)—— 预览 + 生发图 diff --git a/docs/接口文档.md b/docs/接口文档.md index 8566155..47829dc 100644 --- a/docs/接口文档.md +++ b/docs/接口文档.md @@ -133,13 +133,12 @@ | 项目 | 要求 | |------|------| -| 字体及线颜色 | `#FFFFFF` 100% | -| 数值排布 | 四庭数值统一在图片**左侧**呈现;七眼间距**上下穿插**展示 | -| 字体 | PingFangSC-Regular,字号 10pt | -| 线宽 | 横线、竖线、虚线均为 1pt | -| 线样式 | 横线、竖线渐变消失;虚线两侧呈现箭头 | - -> **【待确认】** 标注图片需提供设计稿后才能最终确定样式。 +| 颜色 | 字体及所有线/箭头 `#FFFFFF` 100%,透明底 | +| 尺寸 | 字号/线宽/虚线/箭头按图片**短边自适应缩放**(非固定 pt) | +| 横线 | 5 条分界线(头顶/发际线/眉心/鼻翼下缘/下巴尖),两端渐变消失并略超出最外侧竖线;线名在线**右上方** | +| 竖线 | 人头最左 + 七眼 6 点 + 人头最右(最外两条取自头发分割轮廓),两端渐变消失并略超出头顶/下巴 | +| 数值排布 | 四庭数值(名 + 数值两行,**不带 cm**)统一在图片**左侧**呈现;七眼段宽**上下穿插**展示;底部统一标「单位cm」 | +| 线样式 | 段宽/庭高用**虚线 + 实心三角双箭头**标示(箭头尖端落在虚线两端) | ### 响应示例(当前 Mock 返回值) @@ -179,7 +178,12 @@ ## 接口 6:四庭七眼测量 v2 接口 -**说明**:功能与[接口 1](#接口-1四庭七眼测量标注接口)完全一致,复刻实现。输入用户正面照,返回四庭七眼测量数据和标注 PNG。 +**说明**:基于[接口 1](#接口-1四庭七眼测量标注接口)的变体。与接口 1 的差异: + +- **去顶庭**:不画头顶横线、不返回顶庭数据。`four_courts` 仅含上/中/下庭,`landmarks` 无 `hair_top`,`face_total_height_cm` 为三庭之和(不含顶庭)。 +- **竖线范围**:纵向竖线从**发际线**画到**下巴尖**(接口 1 为头顶→下巴尖)。 +- **不画人头最左/最右端线**:仅画七眼 6 点(左脸颊/左眼外角/左眼内角/右眼内角/右眼外角/右脸颊)共 5 段标尺,不取头发轮廓的头部端线(接口 1 会多出最左/最右 2 条头部端线、共 7 段)。 +- 其余(实心三角箭头、虚线样式、字体、单位cm、七眼数据)与接口 1 一致。 **请求**:`POST /api/v1/face/measure-v2` @@ -189,15 +193,13 @@ ### 输出(data) -与接口 1 完全相同。详见[接口 1 输出](#接口-1四庭七眼测量标注接口)。 - | 字段 | 类型 | 说明 | |------|------|------| | annotated_image_url | string | 标注图层 PNG URL(透明底,仅标注线/文字,不含人物) | -| face_total_height_cm | number | 面部总高度(cm) | -| four_courts | object | 四庭数据(顶庭/上庭/中庭/下庭,各含 cm 与 ratio) | +| face_total_height_cm | number | 面部总高度(cm)= 上庭 + 中庭 + 下庭(**不含顶庭**) | +| four_courts | object | 三庭数据(上/中/下庭,各含 cm 与 ratio;**无顶庭**) | | seven_eyes | object | 七眼数据(眼宽/脸宽/两眼间距,各含 cm 与 ratio) | -| landmarks | object | 五个关键点像素坐标 | +| landmarks | object | 四个关键点像素坐标(发际线/眉心/鼻翼下缘/下巴尖) | ### 响应示例 @@ -208,18 +210,19 @@ "request_id": "mock-request-id", "data": { "annotated_image_url": "https://hair.xiangsilian.com/static/sample.jpg", - "face_total_height_cm": 13.76, + "face_total_height_cm": 10.32, "four_courts": { - "top_court_cm": 3.44, "upper_court_cm": 3.44, "middle_court_cm": 3.44, "lower_court_cm": 3.44, - "ratios": { "top_court": 0.25, "upper_court": 0.25, "middle_court": 0.25, "lower_court": 0.25 } + "upper_court_cm": 3.44, "middle_court_cm": 3.44, "lower_court_cm": 3.44, + "ratios": { "upper_court": 0.333, "middle_court": 0.333, "lower_court": 0.333 } }, "seven_eyes": { "eye_width_cm": 3.44, "face_width_cm": 24.08, "inter_eye_distance_cm": 3.44, "ratios": { "eye_width": 0.143, "inter_eye_distance": 0.143 } }, "landmarks": { - "hair_top": { "x": 540, "y": 120 }, "hairline": { "x": 540, "y": 430 }, - "brow_center": { "x": 540, "y": 740 }, "nose_bottom": { "x": 540, "y": 1050 }, + "hairline": { "x": 540, "y": 430 }, + "brow_center": { "x": 540, "y": 740 }, + "nose_bottom": { "x": 540, "y": 1050 }, "chin_tip": { "x": 540, "y": 1360 } } } diff --git a/docs/网关待改动.md b/docs/网关待改动.md index 1cdbe1c..9136956 100644 --- a/docs/网关待改动.md +++ b/docs/网关待改动.md @@ -113,25 +113,25 @@ async def hair_grow_v2(request: Request): --- -## 6. 🔲【新增】接口6 四庭七眼测量 v2(`/api/v1/face/measure-v2`) +## 6. ✅【已完成】接口6 四庭七眼测量 v2(`/api/v1/face/measure-v2`) -**背景**:worker 侧已新增接口 6,功能与接口 1 完全一致(复刻),共用同一实现。 +**背景**:worker 侧接口 6 是接口 1 的**去顶庭变体**(不再完全一致):不画头顶横线/不返回顶庭数据、竖线范围发际线→下巴尖、不画人头最左/最右端线。worker 侧与接口 1 共用 `_face_measure_impl(variant="v6")`。 -**网关需新增一个路由**: +**网关已新增路由**(`gateway/app.py` 已实现,网关机器 pull 后生效): ```python # gateway/app.py @app.post("/api/v1/face/measure-v2", tags=["人脸分析"]) async def face_measure_v2(request: Request): - """接口6:四庭七眼测量 v2(复刻接口1)""" + """接口6:四庭七眼测量 v2(去顶庭 + 去头部端线)""" return await _proxy(request, "/api/v1/face/measure-v2") ``` **无需额外改动**: - 入参:与接口 1 完全相同(image_file/url/base64 三选一) - 出参:`annotated_image_base64` → 经现有 `rewrite_base64_to_url` 自动改写为 `annotated_image_url` -- worker 侧与接口 1 共用 `_face_measure_impl()`,逻辑零差异 +- worker 侧 v6 差异(去顶庭字段、变体标注)由 `_face_measure_impl` 内部处理,网关透明转发 --- diff --git a/face_analysis/annotation.py b/face_analysis/annotation.py index dd7b335..3cff2f9 100644 --- a/face_analysis/annotation.py +++ b/face_analysis/annotation.py @@ -141,7 +141,7 @@ def _head_edges_from_mask(hair_mask, y0, y1, left_cheek_x, right_cheek_x, min_ga hr if (hr - right_cheek_x) >= min_gap else None) -def create_annotated_image(image_bgr, measure_result, hair_mask=None): +def create_annotated_image(image_bgr, measure_result, hair_mask=None, variant="v1"): """生成标注图层 PNG(透明底 RGBA,尺寸同原图)。返回 PIL.Image。 布局(对齐 img1.png 版本5): @@ -152,6 +152,10 @@ def create_annotated_image(image_bgr, measure_result, hair_mask=None): - 四庭(顶/上/中/下庭)在左侧:名 + 数值两行换行(无 cm),竖向虚线双箭头。 - 七眼段宽数值上下交替(上 3 / 下 4,无 cm),横向虚线双箭头。 - 底部统一标「单位cm」。 + + variant="v6"(接口6):去掉头顶横线与顶庭(只画发际线/眉心/鼻翼下缘/下巴尖 4 条 + 横线 + 上/中/下庭),竖线纵向范围改为发际线→下巴尖,且不画人头最左/最右端线 + (仅七眼 6 点 5 段,不取头部端)。 """ h, w = image_bgr.shape[:2] v = measure_result.vertical @@ -169,21 +173,27 @@ def create_annotated_image(image_bgr, measure_result, hair_mask=None): buf = np.zeros((h, w, 4), dtype=np.uint8) - order = ["hair_top", "hairline", "brow_center", "nose_bottom", "chin_tip"] + if variant == "v6": + order = ["hairline", "brow_center", "nose_bottom", "chin_tip"] + else: + order = ["hair_top", "hairline", "brow_center", "nose_bottom", "chin_tip"] ys = [v[name][1] for name in order] pts = measure_result.eyes["points"] seven_keys = ["left_cheek", "left_outer", "left_inner", "right_inner", "right_outer", "right_cheek"] base_xs = [pts[k][0] for k in seven_keys] - # 人头最左/最右:取自头发分割掩膜(方案B),脸纵向范围内统计;无掩膜则省略。 - # 与脸颊线间距 < 脸宽×8% 视为太近,只保留脸颊线(不画头部端线)。 - face_w = pts["right_cheek"][0] - pts["left_cheek"][0] - min_gap = max(1.0, face_w * 0.08) - head_l, head_r = _head_edges_from_mask( - hair_mask, ys[0], ys[-1], pts["left_cheek"][0], pts["right_cheek"][0], min_gap) - head_xs = [x for x in (head_l, head_r) if x is not None] - xs = sorted(base_xs + head_xs) # 自左向右(6 或 7/8 点) + if variant == "v6": + xs = sorted(base_xs) # 接口6:仅七眼 6 点,不画人头最左/最右端线 + else: + # 人头最左/最右:取自头发分割掩膜(方案B),脸纵向范围内统计;无掩膜则省略。 + # 与脸颊线间距 < 脸宽×8% 视为太近,只保留脸颊线(不画头部端线)。 + face_w = pts["right_cheek"][0] - pts["left_cheek"][0] + min_gap = max(1.0, face_w * 0.08) + head_l, head_r = _head_edges_from_mask( + hair_mask, ys[0], ys[-1], pts["left_cheek"][0], pts["right_cheek"][0], min_gap) + head_xs = [x for x in (head_l, head_r) if x is not None] + xs = sorted(base_xs + head_xs) # 自左向右(6 或 7/8 点) # 人脸/人头包围盒 fx0, fx1 = xs[0], xs[-1] @@ -191,14 +201,17 @@ def create_annotated_image(image_bgr, measure_result, hair_mask=None): face_cx = (fx0 + fx1) / 2 over = max(6, round(s * 0.030)) # 线超出包围盒的长度(参考图风格) face_half = (fx1 - fx0) / 2 + over # 横线超出最外侧竖线一点 + # v6 竖线纵向范围 = 发际线→下巴尖(不超出);v1 = 头顶→下巴尖并两端超出一点 + v_top = fy0 if variant == "v6" else fy0 - over + v_bot = fy1 if variant == "v6" else fy1 + over - # --- 1. 横向 5 条分界线(渐变,覆盖头宽并超出一点) --- + # --- 1. 横向分界线(渐变,覆盖头宽并超出一点) --- for cy in ys: draw_gradient_horizontal_line(buf, face_cx, cy, half_length=face_half, width=line_w) - # --- 2. 纵向竖线(渐变,超出头顶/下巴一点) --- + # --- 2. 纵向竖线(渐变,覆盖 v_top→v_bot) --- for vx in xs: - draw_gradient_vertical_line(buf, vx, fy0 - over, fy1 + over, width=line_w) + draw_gradient_vertical_line(buf, vx, v_top, v_bot, width=line_w) canvas = Image.fromarray(buf, mode="RGBA") draw = ImageDraw.Draw(canvas) @@ -214,11 +227,17 @@ def create_annotated_image(image_bgr, measure_result, hair_mask=None): draw.text((x, max(2, ys[i] - th - name_gap)), text, fill=LINE_COLOR, font=font) # --- 3b. 左侧四庭:名 + 数值两行(无 cm)+ 竖向虚线双箭头 --- - court_cm = [measure_result.top_cm, measure_result.upper_cm, - measure_result.middle_cm, measure_result.lower_cm] - court_name = ["顶庭", "上庭", "中庭", "下庭"] + if variant == "v6": + court_cm = [measure_result.upper_cm, measure_result.middle_cm, measure_result.lower_cm] + court_name = ["上庭", "中庭", "下庭"] + n_court = 3 + else: + court_cm = [measure_result.top_cm, measure_result.upper_cm, + measure_result.middle_cm, measure_result.lower_cm] + court_name = ["顶庭", "上庭", "中庭", "下庭"] + n_court = 4 arrow_x = max(arrow_size + 1, fx0 - pad) # 竖箭头所在 x(脸左侧,贴近最左竖线) - for i in range(4): + for i in range(n_court): y_a, y_b = ys[i], ys[i + 1] # 竖向虚线双箭头,覆盖该庭高度(略收一点避免压到横线) inset = min(arrow_size, (y_b - y_a) * 0.12) diff --git a/gateway/app.py b/gateway/app.py index 446f78c..9d95d09 100644 --- a/gateway/app.py +++ b/gateway/app.py @@ -240,6 +240,12 @@ async def face_measure(request: Request): return await _proxy(request, "/api/v1/face/measure") +@app.post("/api/v1/face/measure-v2", tags=["人脸分析"]) +async def face_measure_v2(request: Request): + """接口6:四庭七眼测量标注 v2(去顶庭 + 去头部端线)""" + return await _proxy(request, "/api/v1/face/measure-v2") + + @app.post("/api/v1/hair/grow", tags=["生发"]) async def hair_grow(request: Request): """接口2:C端生发"""