Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f68049f001 | ||
|
|
7725e461eb | ||
|
|
7d8dfd93b8 | ||
|
|
9379fbb8f8 | ||
|
|
c20606c003 | ||
|
|
1ca033f25a | ||
|
|
87ff2c15d0 | ||
|
|
c536e4ccb1 | ||
|
|
9fb5b486c0 | ||
|
|
a748dfd1e5 | ||
|
|
587876f8a0 | ||
|
|
81c7a0b55c | ||
|
|
f870c20f7f |
@@ -53,3 +53,32 @@ static/report_hairline_v2.zip
|
|||||||
# local_test 运行期日志 / pid(不入 git)
|
# local_test 运行期日志 / pid(不入 git)
|
||||||
local_test/hair_service.log
|
local_test/hair_service.log
|
||||||
local_test/hair_service.pid
|
local_test/hair_service.pid
|
||||||
|
|
||||||
|
# benchmark 原始输出(含结果图+原图,体积大,不入 git)
|
||||||
|
benchmark_out/
|
||||||
|
|
||||||
|
# benchmark 部署的 HTML 报告(图片 base64 内嵌,体积大,不入 git)
|
||||||
|
static/hairstyle_thumbs/
|
||||||
|
|
||||||
|
# 网关运行期日志(不入 git)
|
||||||
|
gateway.log
|
||||||
|
|
||||||
|
# 工作流备份文件(不入 git)
|
||||||
|
*.json.bak.*
|
||||||
|
|
||||||
|
# 脸型测试素材(人像照片,体积大,不入 git;仅保留 6 张基准标注图)
|
||||||
|
face/test_img/脸型测试集合/
|
||||||
|
face/test_img/girl/
|
||||||
|
face/test_img/man/
|
||||||
|
|
||||||
|
# 脸型特征缓存(由 face/dump_features.py 生成,可随时重跑)
|
||||||
|
face/cache/
|
||||||
|
|
||||||
|
# 脸型报告输出(标注图体积大,不入 git)
|
||||||
|
static/face_shape_report/
|
||||||
|
static/face_shape_report.html
|
||||||
|
static/facetest_report/
|
||||||
|
static/facetest_report.html
|
||||||
|
static/facetest_all_report/
|
||||||
|
static/facetest_all_report.html
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
"60": {
|
"60": {
|
||||||
"class_type": "JjkText",
|
"class_type": "JjkText",
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"text": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
"text": "填充遮罩区域的头发"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"22": {
|
"22": {
|
||||||
|
|||||||
@@ -82,7 +82,8 @@ model.safetensors https://huggingface.co/jonathandinu/face-parsing/resol
|
|||||||
模型已就位,但**内网机还需要 Python 依赖的离线 wheel 包**,否则 `pip install` 在内网无法联网安装。这部分**与目标机的操作系统、Python 版本、CUDA 版本强相关**,需确认后单独打包:
|
模型已就位,但**内网机还需要 Python 依赖的离线 wheel 包**,否则 `pip install` 在内网无法联网安装。这部分**与目标机的操作系统、Python 版本、CUDA 版本强相关**,需确认后单独打包:
|
||||||
|
|
||||||
- **worker(GPU 机)**:`mediapipe` / `opencv-python` / `numpy<2` / `Pillow` / **`torch`+`torchvision` 的 CUDA 版**(按 GPU 的 CUDA 版本选 cu118/cu121 等)/ `transformers`(接口2 SegFormer)+ FastAPI/uvicorn 全家桶。
|
- **worker(GPU 机)**:`mediapipe` / `opencv-python` / `numpy<2` / `Pillow` / **`torch`+`torchvision` 的 CUDA 版**(按 GPU 的 CUDA 版本选 cu118/cu121 等)/ `transformers`(接口2 SegFormer)+ FastAPI/uvicorn 全家桶。
|
||||||
- **网关机**:很轻,只需 FastAPI/uvicorn/httpx 等代理依赖,**不需要 torch/mediapipe**。
|
- **网关机**:FastAPI/uvicorn/httpx 等代理依赖 + **接口4 现需 `mediapipe`/`opencv-python`/`numpy<2`**(脸型本机计算),
|
||||||
|
仍**不需要 torch**(无 GPU 推理需求)。
|
||||||
|
|
||||||
> 架构已拆分(见 `docs/实现说明.md`):算法依赖只装在 worker,网关保持轻量。
|
> 架构已拆分(见 `docs/实现说明.md`):算法依赖只装在 worker,网关保持轻量。
|
||||||
|
|
||||||
|
|||||||
@@ -410,7 +410,7 @@
|
|||||||
},
|
},
|
||||||
"60": {
|
"60": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"text": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
"text": "填充遮罩区域的头发"
|
||||||
},
|
},
|
||||||
"class_type": "JjkText",
|
"class_type": "JjkText",
|
||||||
"_meta": {
|
"_meta": {
|
||||||
|
|||||||
@@ -381,7 +381,7 @@
|
|||||||
},
|
},
|
||||||
"60": {
|
"60": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"text": "补充遮罩内区域的头发,区域内填充满头发,不要保留皮肤,发际线下移填充头发。自然的头发生长方向,逼真的头发质感,自然发质。"
|
"text": "填充遮罩区域的头发"
|
||||||
},
|
},
|
||||||
"class_type": "JjkText",
|
"class_type": "JjkText",
|
||||||
"_meta": {
|
"_meta": {
|
||||||
@@ -684,7 +684,7 @@
|
|||||||
},
|
},
|
||||||
"87": {
|
"87": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"text": "去掉头发接缝的黄色痕迹,头发完美融合,保持发型不变,发色不变。其他不变。",
|
"text": "填充遮罩区域的头发",
|
||||||
"clip": [
|
"clip": [
|
||||||
"78",
|
"78",
|
||||||
0
|
0
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""旷视五接口 — worker 侧(高性能 GPU 后端)。
|
"""旷视五接口 — worker 侧(高性能 GPU 后端)。
|
||||||
|
|
||||||
接口 1(四庭七眼测量)已为**真实算法实现**(见 face_analysis 包);接口 2~5 仍为 Mock。
|
接口 1 等算法在 worker;接口4(用户特征)已迁到网关本机(调豆包),worker 同路径只返回明确错误、无 Mock。
|
||||||
拆分架构:worker 跑算法、返回 `annotated_image_base64`(不落盘、不拼 URL,由网关完成)。
|
拆分架构:worker 跑算法、返回 `annotated_image_base64`(不落盘、不拼 URL,由网关完成)。
|
||||||
worker 对 `/api/*` 校验内网鉴权头 `X-Internal-Token`,`/health` 供网关探测不校验。
|
worker 对 `/api/*` 校验内网鉴权头 `X-Internal-Token`,`/health` 供网关探测不校验。
|
||||||
"""
|
"""
|
||||||
@@ -362,21 +362,36 @@ def _run_face_measure_data(image, variant="v1"):
|
|||||||
logger.warning("头发/耳朵分割失败,回退方案A:%s", seg_e)
|
logger.warning("头发/耳朵分割失败,回退方案A:%s", seg_e)
|
||||||
|
|
||||||
result = measure_face(landmarks, hair_mask, w, h, head_pose=head_pose)
|
result = measure_face(landmarks, hair_mask, w, h, head_pose=head_pose)
|
||||||
|
discarded = result.hairline_discarded
|
||||||
data = result.to_response()
|
data = result.to_response()
|
||||||
|
vd = result.vertical
|
||||||
if variant == "v6":
|
if variant == "v6":
|
||||||
vd = result.vertical
|
if discarded:
|
||||||
base_px = vd["upper_court_px"] + vd["middle_court_px"] + vd["lower_court_px"]
|
# 发际线弃用:接口6 的上庭也依赖发际线,一并置 null;只保留中/下庭。
|
||||||
# 接口6 是三庭:去掉顶庭相关字段(top_court_cm / ratios.top_court / landmarks.hair_top)
|
base_px = vd["middle_court_px"] + vd["lower_court_px"]
|
||||||
data["four_courts"]["ratios"] = {
|
data["four_courts"]["upper_court_cm"] = None
|
||||||
"upper_court": round(vd["upper_court_px"] / base_px, 3),
|
data["four_courts"]["ratios"] = {
|
||||||
"middle_court": round(vd["middle_court_px"] / base_px, 3),
|
"upper_court": None,
|
||||||
"lower_court": round(vd["lower_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(
|
data["four_courts"].pop("top_court_cm", None)
|
||||||
result.upper_cm + result.middle_cm + result.lower_cm, 2)
|
data["face_total_height_cm"] = round(
|
||||||
# 注:landmarks.hair_top 保留返回(供前端/下游定位头顶),但顶庭数值、
|
result.middle_cm + result.lower_cm, 2)
|
||||||
# 占比、标注图仍按三庭处理,显示效果不变。
|
data["landmarks"]["hairline"] = None
|
||||||
|
else:
|
||||||
|
base_px = vd["upper_court_px"] + vd["middle_court_px"] + vd["lower_court_px"]
|
||||||
|
# 接口6 是三庭:去掉顶庭相关字段(top_court_cm / ratios.top_court / landmarks.hair_top)
|
||||||
|
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)
|
||||||
|
# 注:landmarks.hair_top 保留返回(供前端/下游定位头顶),但顶庭数值、
|
||||||
|
# 占比、标注图仍按三庭处理,显示效果不变。
|
||||||
|
|
||||||
# 七眼段宽度(cm)。eye1=左耳外段 eye2=左脸颊 eye3=左眼 eye4=两眼间距 eye5=右眼 eye6=右脸颊 eye7=右耳外段。
|
# 七眼段宽度(cm)。eye1=左耳外段 eye2=左脸颊 eye3=左眼 eye4=两眼间距 eye5=右眼 eye6=右脸颊 eye7=右耳外段。
|
||||||
# eye2~eye6(5段)只用内部分点,接口1/6 共用;eye1/eye7 需耳朵分割端线,仅接口1 有。
|
# eye2~eye6(5段)只用内部分点,接口1/6 共用;eye1/eye7 需耳朵分割端线,仅接口1 有。
|
||||||
@@ -391,11 +406,14 @@ def _run_face_measure_data(image, variant="v1"):
|
|||||||
data["seven_eyes"][f"eye{i + 2}"] = (
|
data["seven_eyes"][f"eye{i + 2}"] = (
|
||||||
None if (a is None or b is None) else round((b - a) / pc, 2))
|
None if (a is None or b is None) else round((b - a) / pc, 2))
|
||||||
if variant != "v6":
|
if variant != "v6":
|
||||||
# 接口1 额外算 eye1/eye7(左/右耳外段),需耳朵分割端线
|
# 接口1 额外算 eye1/eye7(左/右耳外段),需耳朵分割端线。
|
||||||
|
# 竖向范围:发际线弃用时用眉心做上界(hair_top 不可靠),否则用头顶。
|
||||||
from face_analysis.annotation import _ear_edges_from_mask
|
from face_analysis.annotation import _ear_edges_from_mask
|
||||||
|
top_y = (vd["brow_center"][1] if discarded
|
||||||
|
else vd["hair_top"][1])
|
||||||
head_l, head_r = _ear_edges_from_mask(
|
head_l, head_r = _ear_edges_from_mask(
|
||||||
ear_mask, hair_mask,
|
ear_mask, hair_mask,
|
||||||
result.vertical["hair_top"][1], result.vertical["chin_tip"][1],
|
top_y, vd["chin_tip"][1],
|
||||||
lcx, rcx, (lcx + rcx) / 2)
|
lcx, rcx, (lcx + rcx) / 2)
|
||||||
data["seven_eyes"]["eye1"] = (
|
data["seven_eyes"]["eye1"] = (
|
||||||
None if (head_l is None) else round((lcx - head_l) / pc, 2))
|
None if (head_l is None) else round((lcx - head_l) / pc, 2))
|
||||||
@@ -657,10 +675,11 @@ async def face_measure_v2(
|
|||||||
- 发际线类型 `hairline_type`(英文 key)
|
- 发际线类型 `hairline_type`(英文 key)
|
||||||
- 顺序 `order`(本期固定 `1..N`,不排序)
|
- 顺序 `order`(本期固定 `1..N`,不排序)
|
||||||
|
|
||||||
> **female 走「换发型」模式**:生发图 `grown_image_base64` 由换发型(change_hair)
|
> **female 走「换发型」模式**:1..5 的生发图 `grown_image_base64` 由换发型(change_hair)
|
||||||
> + Flux-2 整帧重绘(= 接口12 final 管线,整帧美颜+整帧重绘)生成,其余参数用固化默认值。
|
> + Flux-2 整帧重绘(= 接口12 final 管线,整帧美颜+整帧重绘)生成,其余参数用固化默认值。
|
||||||
> **male 仍走原生发(ComfyUI add_hair)管线**。入参与返回结构不变。
|
> **6/7(bigflower/clasicalflower)与 male 一样走原生发(ComfyUI add_hair)管线**。
|
||||||
> female 依赖 change_hair 与 ComfyUI(:8188) 均在跑。
|
> **male 全部走原生发(ComfyUI add_hair)管线**。入参与返回结构不变。
|
||||||
|
> female 1..5 依赖 change_hair 与 ComfyUI(:8188) 均在跑。
|
||||||
|
|
||||||
{_image_fields_desc}
|
{_image_fields_desc}
|
||||||
|
|
||||||
@@ -668,15 +687,15 @@ async def face_measure_v2(
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
- **gender**(必填):`male` / `female`。决定返回的贴图集合(female 5 张 / male 4 张)。
|
- **gender**(必填):`male` / `female`。决定返回的贴图集合(female 7 张 / male 6 张)。
|
||||||
非法或缺失返回 `1004`。
|
非法或缺失返回 `1004`。
|
||||||
- **hair_style**(必填):发型序号,**逗号分隔多选**(如 `1,2,3`),最多不超过该性别的预设数量。
|
- **hair_style**(必填):发型序号,**逗号分隔多选**(如 `1,2,3`),最多不超过该性别的预设数量。
|
||||||
`female`:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave;
|
`female`:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave, 6=bigflower, 7=clasicalflower;
|
||||||
`male`:1=ellipse, 2=inverse_arc, 3=m, 4=straight。越界/非法返回 `1007`。
|
`male`:1=ellipse, 2=inverse_arc, 3=m, 4=straight, 5=heart, 6=Softpetal。越界/非法返回 `1007`。
|
||||||
- **beauty_enabled**:本期保留但不生效。
|
- **beauty_enabled**:本期保留但不生效。
|
||||||
|
|
||||||
`hairline_type` 取值:`ellipse` / `flower` / `heart` / `straight` / `wave`(female),
|
`hairline_type` 取值:`ellipse` / `flower` / `heart` / `straight` / `wave` / `bigflower` / `clasicalflower`(female),
|
||||||
`ellipse` / `m` / `straight` / `inverse_arc`(male)。
|
`ellipse` / `m` / `straight` / `inverse_arc` / `heart` / `Softpetal`(male)。
|
||||||
""",
|
""",
|
||||||
responses={
|
responses={
|
||||||
200: {
|
200: {
|
||||||
@@ -715,17 +734,17 @@ async def hair_grow(
|
|||||||
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(必填)"),
|
gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"),
|
||||||
hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填),如 1,2,3。female:1-5 male:1-4"),
|
hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填),如 1,2,3。female:1-7 male:1-6"),
|
||||||
beauty_enabled: bool = Form(default=False, description="是否开启美颜(本期不生效)"),
|
beauty_enabled: bool = Form(default=False, description="是否开启美颜(本期不生效)"),
|
||||||
use_mask: bool = Form(default=True, description="是否启用 inpaint 遮罩(测试对比用)。false 时用干净原图生成(空遮罩,不烧模板线)"),
|
use_mask: bool = Form(default=True, description="是否启用 inpaint 遮罩(测试对比用)。false 时用干净原图生成(空遮罩,不烧模板线)"),
|
||||||
prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
prompt: str = Form(default="填充遮罩区域的头发", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
||||||
):
|
):
|
||||||
# 1. gender 必填校验(非法/缺失 → 1004)
|
# 1. gender 必填校验(非法/缺失 → 1004)
|
||||||
if gender not in ("male", "female"):
|
if gender not in ("male", "female"):
|
||||||
return err(1004, "gender 必填且只能为 male / female")
|
return err(1004, "gender 必填且只能为 male / female")
|
||||||
|
|
||||||
# 2. hair_style 必填校验(解析逗号分隔,越界 → 1007)
|
# 2. hair_style 必填校验(解析逗号分隔,越界 → 1007)
|
||||||
max_styles = {"female": 5, "male": 4}[gender]
|
max_styles = {"female": 7, "male": 6}[gender]
|
||||||
hair_styles = _parse_hair_styles(hair_style, max_styles)
|
hair_styles = _parse_hair_styles(hair_style, max_styles)
|
||||||
if hair_styles is None:
|
if hair_styles is None:
|
||||||
return err(1007, f"hair_style 必填且为 1..{max_styles} 的整数(逗号分隔),收到 {hair_style!r}")
|
return err(1007, f"hair_style 必填且为 1..{max_styles} 的整数(逗号分隔),收到 {hair_style!r}")
|
||||||
@@ -747,7 +766,8 @@ async def hair_grow(
|
|||||||
if gender == "female":
|
if gender == "female":
|
||||||
from hairline.service import generate_grow_results_swap
|
from hairline.service import generate_grow_results_swap
|
||||||
items = await run_in_threadpool(
|
items = await run_in_threadpool(
|
||||||
generate_grow_results_swap, image, hair_styles, _V2_FINAL_DEFAULTS)
|
generate_grow_results_swap, image, hair_styles, _V2_FINAL_DEFAULTS,
|
||||||
|
prompt=prompt)
|
||||||
else:
|
else:
|
||||||
from hairline.service import generate_grow_results
|
from hairline.service import generate_grow_results
|
||||||
items = await run_in_threadpool(
|
items = await run_in_threadpool(
|
||||||
@@ -770,6 +790,149 @@ async def hair_grow(
|
|||||||
return err(1007, f"处理失败:{ex}")
|
return err(1007, f"处理失败:{ex}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 调试接口:接口2 女性生发 分步计时
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@app.post(
|
||||||
|
"/api/v1/debug/grow-timing",
|
||||||
|
summary="调试-接口2女性生发分步计时",
|
||||||
|
tags=["调试"],
|
||||||
|
include_in_schema=False,
|
||||||
|
)
|
||||||
|
async def debug_grow_timing(
|
||||||
|
image_file: Optional[UploadFile] = File(default=None),
|
||||||
|
image_url: Optional[str] = Form(default=None),
|
||||||
|
image_base64: Optional[str] = Form(default=None),
|
||||||
|
hair_style: str = Form(default="2", description="发型序号(花瓣=2),逗号分隔多选"),
|
||||||
|
webui_steps: Optional[int] = Form(default=None, description="swapHair webui img2img 采样步数,None=服务端默认(15),可填10/15/20/25对比"),
|
||||||
|
redraw_max_side: Optional[int] = Form(default=None, description="ComfyUI重绘分辨率(长边像素)。None=默认896;0=原图不缩;其他如640/768/1024"),
|
||||||
|
redraw_prompt: Optional[str] = Form(default=None, description="ComfyUI重绘提示词,None=默认'填充遮罩区域的头发'"),
|
||||||
|
):
|
||||||
|
"""单图跑接口2女性生发,返回每个步骤的耗时 + 结果图,用于定位性能瓶颈。
|
||||||
|
|
||||||
|
步骤拆分:
|
||||||
|
1. extract_context:人脸关键点检测 + 头发分割 + 发际线几何
|
||||||
|
2. [每个发型] generate_hairline_redraw:
|
||||||
|
2a. compute_mask:发际线遮罩计算
|
||||||
|
2b. _call_swap:调 change_hair 换发型(内含 webui SD1.5 推理,远程或本机)
|
||||||
|
2c. _composite:接缝融合(多频段/羽化)
|
||||||
|
3. [每个发型] _call_local_redraw:调本机 ComfyUI 用 Flux.2 重绘
|
||||||
|
"""
|
||||||
|
import time as _time
|
||||||
|
from fastapi.concurrency import run_in_threadpool
|
||||||
|
|
||||||
|
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
|
||||||
|
if e is not None:
|
||||||
|
return e
|
||||||
|
image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
|
||||||
|
if image is None:
|
||||||
|
return err(1008, "图片格式不支持(仅 JPG / PNG)")
|
||||||
|
|
||||||
|
try:
|
||||||
|
max_styles = 7
|
||||||
|
hair_styles = _parse_hair_styles(hair_style, max_styles)
|
||||||
|
if hair_styles is None:
|
||||||
|
return err(1007, f"hair_style 必须为 1..{max_styles}")
|
||||||
|
|
||||||
|
t_total0 = _time.perf_counter()
|
||||||
|
timings = {"total_ms": 0, "extract_context_ms": 0, "per_hairstyle": []}
|
||||||
|
|
||||||
|
# 步骤1: extract_context
|
||||||
|
t0 = _time.perf_counter()
|
||||||
|
from hairline.service import extract_context as _ec, _call_local_redraw, _REDRAW_MAX_SIDE # noqa
|
||||||
|
from face_analysis.hairline_grow import generate_hairline_redraw, NoFaceError # noqa
|
||||||
|
from face_analysis.head_mask import SEGFORMER_HAIR # noqa
|
||||||
|
ctx = await run_in_threadpool(_ec, image)
|
||||||
|
timings["extract_context_ms"] = int((_time.perf_counter() - t0) * 1000)
|
||||||
|
if ctx is None:
|
||||||
|
return err(1001, "无法识别人像")
|
||||||
|
|
||||||
|
hair_mask_reuse = (ctx["parse_map"] == SEGFORMER_HAIR)
|
||||||
|
h, w = image.shape[:2]
|
||||||
|
eff_side = _REDRAW_MAX_SIDE if redraw_max_side is None else redraw_max_side
|
||||||
|
redraw_img, hair_mask_redraw = image, hair_mask_reuse
|
||||||
|
downscale_info = None
|
||||||
|
if eff_side > 0 and max(h, w) > eff_side:
|
||||||
|
from hairline.service import _downscale_max_side
|
||||||
|
redraw_img, _rs = _downscale_max_side(image, eff_side)
|
||||||
|
_nh, _nw = redraw_img.shape[:2]
|
||||||
|
if hair_mask_redraw is not None:
|
||||||
|
hair_mask_redraw = cv2.resize(hair_mask_reuse.astype(np.uint8), (_nw, _nh),
|
||||||
|
interpolation=cv2.INTER_NEAREST).astype(bool)
|
||||||
|
downscale_info = {"from": f"{w}x{h}", "to": f"{_nw}x{_nh}", "max_side": eff_side}
|
||||||
|
|
||||||
|
textures_map = None
|
||||||
|
from hairline.service import get_texture_map, _FEMALE_KEY_TO_CHANG, load_texture_rgba, build_overlay_layer, load_ext_mesh
|
||||||
|
textures = get_texture_map()["female"]
|
||||||
|
items = [(s, textures[s - 1]) for s in hair_styles]
|
||||||
|
|
||||||
|
for order, (key, white_path) in items:
|
||||||
|
hs_t0 = _time.perf_counter()
|
||||||
|
entry = {"hairline_type": key, "order": order}
|
||||||
|
chang_id = _FEMALE_KEY_TO_CHANG.get(key)
|
||||||
|
entry["chang_id"] = chang_id
|
||||||
|
entry["ok"] = False
|
||||||
|
entry["error"] = None
|
||||||
|
entry["grown_b64"] = None
|
||||||
|
if chang_id is None:
|
||||||
|
entry["error"] = f"无对应 chang_id"
|
||||||
|
timings["per_hairstyle"].append(entry)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
# 2a/2b/2c: generate_hairline_redraw (内部含 mask+swap+blend)
|
||||||
|
t0 = _time.perf_counter()
|
||||||
|
data = await run_in_threadpool(
|
||||||
|
generate_hairline_redraw, redraw_img, chang_id,
|
||||||
|
hair_mask=hair_mask_redraw, webui_steps=webui_steps, **_V2_FINAL_DEFAULTS)
|
||||||
|
t_redraw_pipeline = _time.perf_counter() - t0
|
||||||
|
_tm = data.get("timings_ms") or {}
|
||||||
|
entry["mask_ms"] = _tm.get("mask", 0)
|
||||||
|
entry["swap_ms"] = _tm.get("swap", 0)
|
||||||
|
entry["blend_ms"] = _tm.get("blend", 0)
|
||||||
|
entry["redraw_pipeline_ms"] = int(t_redraw_pipeline * 1000)
|
||||||
|
|
||||||
|
steps = data.get("steps") or {}
|
||||||
|
final_b64 = steps.get("final_base64") or ""
|
||||||
|
mask_b64 = steps.get("redraw_band_mask_base64") or ""
|
||||||
|
if not final_b64 or not mask_b64:
|
||||||
|
entry["error"] = f"final/遮罩缺失(final={len(final_b64)} mask={len(mask_b64)})"
|
||||||
|
timings["per_hairstyle"].append(entry)
|
||||||
|
continue
|
||||||
|
if final_b64.startswith("data:"):
|
||||||
|
final_b64 = final_b64.split(",", 1)[1]
|
||||||
|
if mask_b64.startswith("data:"):
|
||||||
|
mask_b64 = mask_b64.split(",", 1)[1]
|
||||||
|
|
||||||
|
# 3: ComfyUI 重绘
|
||||||
|
t0 = _time.perf_counter()
|
||||||
|
# max_side: 0 或 None 都让 _call_local_redraw 用默认逻辑(外层已控制分辨率)
|
||||||
|
_ms = redraw_max_side if redraw_max_side is not None and redraw_max_side > 0 else None
|
||||||
|
grown_png = await run_in_threadpool(
|
||||||
|
_call_local_redraw,
|
||||||
|
base64.b64decode(final_b64), base64.b64decode(mask_b64),
|
||||||
|
max_side=_ms, prompt=redraw_prompt)
|
||||||
|
entry["comfyui_redraw_ms"] = int((_time.perf_counter() - t0) * 1000)
|
||||||
|
if grown_png:
|
||||||
|
entry["grown_b64"] = "data:image/jpeg;base64," + _png_to_jpg_b64(grown_png)
|
||||||
|
entry["ok"] = True
|
||||||
|
else:
|
||||||
|
entry["error"] = "ComfyUI 重绘返回空"
|
||||||
|
except NoFaceError:
|
||||||
|
entry["error"] = "未检出人脸"
|
||||||
|
except Exception as ex: # noqa: BLE001
|
||||||
|
entry["error"] = str(ex)[:150]
|
||||||
|
entry["hairstyle_total_ms"] = int((_time.perf_counter() - hs_t0) * 1000)
|
||||||
|
timings["per_hairstyle"].append(entry)
|
||||||
|
|
||||||
|
timings["total_ms"] = int((_time.perf_counter() - t_total0) * 1000)
|
||||||
|
timings["downscale"] = downscale_info
|
||||||
|
timings["image_size"] = f"{w}x{h}"
|
||||||
|
return ok(timings)
|
||||||
|
except Exception as ex: # noqa: BLE001
|
||||||
|
logger.exception("debug/grow-timing 异常")
|
||||||
|
return err(1007, f"处理失败:{ex}")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 接口 7:C 端生发 v2 —— 已弃用(add_hair2.json 用 Klein-9b 大模型,会把常驻的
|
# 接口 7:C 端生发 v2 —— 已弃用(add_hair2.json 用 Klein-9b 大模型,会把常驻的
|
||||||
# Klein-4b/Flux 挤出显存,导致接口2/3/5 耗时抖动;且业务已不再调用)。
|
# Klein-4b/Flux 挤出显存,导致接口2/3/5 耗时抖动;且业务已不再调用)。
|
||||||
@@ -790,6 +953,7 @@ async def hair_grow_v2():
|
|||||||
"/api/v1/hair/grow-b",
|
"/api/v1/hair/grow-b",
|
||||||
summary="接口3 B端生发(医生/操作端)",
|
summary="接口3 B端生发(医生/操作端)",
|
||||||
tags=["生发"],
|
tags=["生发"],
|
||||||
|
deprecated=True,
|
||||||
description="""
|
description="""
|
||||||
医生/操作端在用户照片上**手动用马克笔划线标注**目标发际线后,**只需上传这一张划线图**,返回:
|
医生/操作端在用户照片上**手动用马克笔划线标注**目标发际线后,**只需上传这一张划线图**,返回:
|
||||||
- 生发后效果图(系统检测划线 → 据此生成「植发 3 个月」效果)
|
- 生发后效果图(系统检测划线 → 据此生成「植发 3 个月」效果)
|
||||||
@@ -829,36 +993,11 @@ async def hair_grow_b(
|
|||||||
marked_image_url: Optional[str] = Form(default=None, description="划线图片 URL"),
|
marked_image_url: Optional[str] = Form(default=None, description="划线图片 URL"),
|
||||||
marked_image_base64: Optional[str] = Form(default=None, description="划线图片 base64"),
|
marked_image_base64: Optional[str] = Form(default=None, description="划线图片 base64"),
|
||||||
use_mask: bool = Form(default=True, description="是否画发际线(测试对比用)。false 时跳过划线检测、直接送划线图"),
|
use_mask: bool = Form(default=True, description="是否画发际线(测试对比用)。false 时跳过划线检测、直接送划线图"),
|
||||||
prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
prompt: str = Form(default="填充遮罩区域的头发", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
||||||
):
|
):
|
||||||
# 划线图三选一取图(只需这一张)
|
"""接口3 已屏蔽:调用直接返回错误,不再执行生发/ComfyUI 逻辑。
|
||||||
marked_raw, e = await resolve_image_bytes(marked_image_file, marked_image_url, marked_image_base64)
|
保留路由(含原参数签名)避免老客户端裸 404,multipart 入参仍被接受但不处理。"""
|
||||||
if e is not None:
|
return err(1007, "接口3(/api/v1/hair/grow-b)已屏蔽,暂不提供服务")
|
||||||
return e
|
|
||||||
|
|
||||||
marked = cv2.imdecode(np.frombuffer(marked_raw, np.uint8), cv2.IMREAD_COLOR)
|
|
||||||
if marked is None:
|
|
||||||
return err(1008, "图片格式不支持(仅 JPG / PNG)")
|
|
||||||
|
|
||||||
try:
|
|
||||||
from fastapi.concurrency import run_in_threadpool
|
|
||||||
from hairline.service import generate_grow_b
|
|
||||||
|
|
||||||
res = await run_in_threadpool(generate_grow_b, marked, use_mask, prompt)
|
|
||||||
if res["status"] == "no_face":
|
|
||||||
return err(1001, "无法识别人像")
|
|
||||||
if res["status"] == "no_line":
|
|
||||||
return err(1001, "未检测到发际线划线,请确认划线图额头有清晰的手绘发际线")
|
|
||||||
|
|
||||||
grown_b64 = _png_to_jpg_b64(res["grown_png"]) if res["grown_png"] else None # 生发图 JPG
|
|
||||||
data = {
|
|
||||||
"hair_growth_image_base64": grown_b64,
|
|
||||||
"hairline_type": "custom",
|
|
||||||
}
|
|
||||||
return ok(data)
|
|
||||||
except Exception as ex: # noqa: BLE001
|
|
||||||
logger.exception("接口3 处理异常")
|
|
||||||
return err(1007, f"处理失败:{ex}")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -867,56 +1006,28 @@ async def hair_grow_b(
|
|||||||
|
|
||||||
@app.post(
|
@app.post(
|
||||||
"/api/v1/face/features",
|
"/api/v1/face/features",
|
||||||
summary="接口4 用户特征分析",
|
summary="接口4 用户特征分析(仅网关)",
|
||||||
tags=["人脸分析"],
|
tags=["人脸分析"],
|
||||||
description=f"""
|
description="""
|
||||||
输入用户照片,返回 N 个用户面部特征字段。
|
**本接口不在 worker 实现。** 请调用网关(本机默认 `http://127.0.0.1:8080`)的同路径;
|
||||||
|
网关本机调火山方舟豆包视觉模型,不转发到 worker。
|
||||||
|
|
||||||
{_image_fields_desc}
|
直接打 worker(如 `:8187`)会返回错误,避免误用假数据。
|
||||||
|
|
||||||
图片同时支持 `multipart/form-data` 文件上传(字段名 `image_file`)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
由**火山方舟 豆包视觉模型**分析,返回**固定 6 个英文字段**。
|
|
||||||
|
|
||||||
**返回格式**:`data.features` 为一个 **JSON 字符串**(不是对象),需要在客户端 `JSON.parse()` 后使用。
|
|
||||||
|
|
||||||
| 字段 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| face_shape | 脸形(如"鹅蛋脸") |
|
|
||||||
| eyebrow_shape | 眉形(如"平眉") |
|
|
||||||
| facial_age | 面部年龄(区间,如"18-25岁") |
|
|
||||||
| dynamic_static_type | 动静类型("静态型"/"动态型") |
|
|
||||||
| gender | 性别("男"/"女") |
|
|
||||||
| gene_style | 基因风格(如"自然型") |
|
|
||||||
|
|
||||||
> 无人脸返回 `1001`。
|
|
||||||
""",
|
""",
|
||||||
responses={
|
responses={
|
||||||
200: {
|
200: {
|
||||||
"description": "成功",
|
"description": "worker 不提供本接口",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"example": {
|
"example": {
|
||||||
"code": 0,
|
"code": 1007,
|
||||||
"message": "success",
|
"message": "接口4 仅在网关实现,请访问网关(本机默认 :8080),worker 不提供本接口",
|
||||||
"request_id": "mock-request-id",
|
"request_id": "mock-request-id",
|
||||||
"data": {
|
"data": None,
|
||||||
"features": '{"face_shape":"鹅蛋脸","eyebrow_shape":"平眉","facial_age":"18-25岁","dynamic_static_type":"静态型","gender":"女","gene_style":"少年型"}',
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
400: {
|
|
||||||
"description": "参数错误 / 图片识别失败",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"example": {"code": 1001, "message": "无法识别人像", "request_id": "x", "data": None}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def face_features(
|
async def face_features(
|
||||||
@@ -924,16 +1035,12 @@ async def face_features(
|
|||||||
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, 前缀)"),
|
||||||
):
|
):
|
||||||
# ⚠️ 接口4 已迁到**网关本机**实现(直接调豆包视觉模型,见 gateway/app.py)。
|
# 接口4 只在网关实现(gateway/app.py → face_features.analyze_features)。
|
||||||
# 网关不会把本接口转发到 worker,故此处仅留 Mock 占位、保持 worker 无外网依赖。
|
# 不再返回 Mock 成功数据,避免本机打 :8187 时被假结果误导。
|
||||||
features = json.dumps(
|
return err(
|
||||||
{
|
1007,
|
||||||
"face_shape": "鹅蛋脸", "eyebrow_shape": "平眉", "facial_age": "18-25岁",
|
"接口4 仅在网关实现,请访问网关(本机默认 :8080),worker 不提供本接口",
|
||||||
"dynamic_static_type": "静态型", "gender": "女", "gene_style": "少年型",
|
|
||||||
},
|
|
||||||
ensure_ascii=False,
|
|
||||||
)
|
)
|
||||||
return ok({"features": features})
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -955,9 +1062,10 @@ async def face_features(
|
|||||||
---
|
---
|
||||||
|
|
||||||
**入参**(同接口2:先选性别,再多选发型):
|
**入参**(同接口2:先选性别,再多选发型):
|
||||||
- 必填 `gender`(`male`/`female`),决定发型集合(female 5 / male 4)。
|
- 必填 `gender`(`male`/`female`),决定发型集合(female 7 / male 6)。
|
||||||
- 必填 `hair_style`(发型序号,逗号分隔如 `1,2,3`),决定返回哪些发际线类型。缺失/越界/非法返回 `1007`。
|
- 必填 `hair_style`(发型序号,逗号分隔如 `1,2,3`),决定返回哪些发际线类型。缺失/越界/非法返回 `1007`。
|
||||||
`female`:1=ellipse,2=flower,3=heart,4=straight,5=wave;`male`:1=ellipse,2=inverse_arc,3=m,4=straight。
|
`female`:1=ellipse,2=flower,3=heart,4=straight,5=wave,6=bigflower,7=clasicalflower;
|
||||||
|
`male`:1=ellipse,2=inverse_arc,3=m,4=straight,5=heart,6=Softpetal。
|
||||||
- 可选 `use_mask` / `prompt`:同接口2 的生发控制参数。
|
- 可选 `use_mask` / `prompt`:同接口2 的生发控制参数。
|
||||||
注:生发黑模板固定取 `hairline_texture_black/`(middle 档),即三档叠图分别用各自贴图、但生发目标固定 middle。
|
注:生发黑模板固定取 `hairline_texture_black/`(middle 档),即三档叠图分别用各自贴图、但生发目标固定 middle。
|
||||||
- 可选 `generate_grow_image`(默认 `true`):是否生成生发效果图(ComfyUI 生发,全流程最耗时)。
|
- 可选 `generate_grow_image`(默认 `true`):是否生成生发效果图(ComfyUI 生发,全流程最耗时)。
|
||||||
@@ -1037,16 +1145,16 @@ async def hairline_generate(
|
|||||||
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(必填)"),
|
gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"),
|
||||||
hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填,如 1,2,3)。female:1-5 male:1-4"),
|
hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填,如 1,2,3)。female:1-7 male:1-6"),
|
||||||
use_mask: bool = Form(default=True, description="生发是否启用 inpaint 遮罩(同接口2,测试对比用)"),
|
use_mask: bool = Form(default=True, description="生发是否启用 inpaint 遮罩(同接口2,测试对比用)"),
|
||||||
prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜", description="ComfyUI 提示词(同接口2),会替换工作流节点60的文本"),
|
prompt: str = Form(default="填充遮罩区域的头发", description="ComfyUI 提示词(同接口2),会替换工作流节点60的文本"),
|
||||||
generate_grow_image: bool = Form(default=True, description="是否生成生发效果图(ComfyUI 生发,最耗时)。默认 true 出图;false 时跳过生发,各发型 grown_image 恒为 null,仅返回三档发际线叠图与中心点"),
|
generate_grow_image: bool = Form(default=True, description="是否生成生发效果图(ComfyUI 生发,最耗时)。默认 true 出图;false 时跳过生发,各发型 grown_image 恒为 null,仅返回三档发际线叠图与中心点"),
|
||||||
):
|
):
|
||||||
if gender not in ("male", "female"):
|
if gender not in ("male", "female"):
|
||||||
return err(1004, "gender 必填且只能为 male / female")
|
return err(1004, "gender 必填且只能为 male / female")
|
||||||
|
|
||||||
# hair_style 必填(同接口2):解析逗号分隔,缺失/越界/非法 → 1007
|
# hair_style 必填(同接口2):解析逗号分隔,缺失/越界/非法 → 1007
|
||||||
max_styles = {"female": 5, "male": 4}[gender]
|
max_styles = {"female": 7, "male": 6}[gender]
|
||||||
hair_styles = _parse_hair_styles(hair_style, max_styles)
|
hair_styles = _parse_hair_styles(hair_style, max_styles)
|
||||||
if hair_styles is None:
|
if hair_styles is None:
|
||||||
return err(1007, f"hair_style 必填且为 1..{max_styles} 的整数(逗号分隔),收到 {hair_style!r}")
|
return err(1007, f"hair_style 必填且为 1..{max_styles} 的整数(逗号分隔),收到 {hair_style!r}")
|
||||||
@@ -1433,7 +1541,7 @@ async def hairline_grow_v2(
|
|||||||
inpainting_fill: int = Form(default=1, description="change_hair服务端重绘填充:0=保留原图 | 1=噪声 | 2=纯色 | 3=潜变量。默认 1"),
|
inpainting_fill: int = Form(default=1, description="change_hair服务端重绘填充:0=保留原图 | 1=噪声 | 2=纯色 | 3=潜变量。默认 1"),
|
||||||
mask_blur: int = Form(default=11, description="change_hair服务端遮罩边缘模糊像素,默认 11"),
|
mask_blur: int = Form(default=11, description="change_hair服务端遮罩边缘模糊像素,默认 11"),
|
||||||
mask_dilate_scale: float = Form(default=1.0, description="change_hair服务端遮罩膨胀缩放,默认 1.0"),
|
mask_dilate_scale: float = Form(default=1.0, description="change_hair服务端遮罩膨胀缩放,默认 1.0"),
|
||||||
comfyui_prompt: Optional[str] = Form(default=None, description="Flux-2 重绘提示词,None 用默认「填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜」"),
|
comfyui_prompt: Optional[str] = Form(default=None, description="Flux-2 重绘提示词,None 用默认「填充遮罩区域的头发」"),
|
||||||
beauty_alpha: float = Form(default=0.6, description="redraw_band 版 band 外的全脸美颜融入强度(0=band外无美颜纯用final,1≈整帧版),默认 0.6"),
|
beauty_alpha: float = Form(default=0.6, description="redraw_band 版 band 外的全脸美颜融入强度(0=band外无美颜纯用final,1≈整帧版),默认 0.6"),
|
||||||
band_lo_mult: float = Form(default=0.5, description="重绘带外推倍率下限(相对 hairline_push_cm,内轮廓=0×、原外推线=1.0×),默认 0.5"),
|
band_lo_mult: float = Form(default=0.5, description="重绘带外推倍率下限(相对 hairline_push_cm,内轮廓=0×、原外推线=1.0×),默认 0.5"),
|
||||||
band_hi_mult: float = Form(default=1.5, description="重绘带外推倍率上限(相对 hairline_push_cm),默认 1.5"),
|
band_hi_mult: float = Form(default=1.5, description="重绘带外推倍率上限(相对 hairline_push_cm),默认 1.5"),
|
||||||
@@ -1599,7 +1707,7 @@ async def hairline_grow_v2_final_v2(
|
|||||||
async def api_redraw(
|
async def api_redraw(
|
||||||
image_file: UploadFile = File(..., description="人物图片(JPG/PNG)"),
|
image_file: UploadFile = File(..., description="人物图片(JPG/PNG)"),
|
||||||
mask_file: UploadFile = File(..., description="遮罩图片(PNG,支持红/白/alpha 格式)"),
|
mask_file: UploadFile = File(..., description="遮罩图片(PNG,支持红/白/alpha 格式)"),
|
||||||
prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜",
|
prompt: str = Form(default="填充遮罩区域的头发",
|
||||||
description="ComfyUI 提示词"),
|
description="ComfyUI 提示词"),
|
||||||
):
|
):
|
||||||
image_bytes = await image_file.read()
|
image_bytes = await image_file.read()
|
||||||
@@ -1644,6 +1752,84 @@ async def download_hairline_log(rid: Optional[str] = None, tail: int = 500):
|
|||||||
return PlainTextResponse("".join(lines), media_type="text/plain; charset=utf-8")
|
return PlainTextResponse("".join(lines), media_type="text/plain; charset=utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 调试:MediaPipe 脸型分类(face/face_shape_classifier.py,非接口4 豆包)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@app.post(
|
||||||
|
"/api/v1/debug/face-shape",
|
||||||
|
summary="调试 单张脸型分类(MediaPipe)",
|
||||||
|
tags=["调试"],
|
||||||
|
description="""
|
||||||
|
离线脸型分类调试接口(`face/face_shape_classifier.py`),**不是**接口4 的豆包视觉分析。
|
||||||
|
|
||||||
|
上传正面照 → MediaPipe 468 点 → 7 类脸型评分 + 特征标注图。
|
||||||
|
""",
|
||||||
|
include_in_schema=True,
|
||||||
|
)
|
||||||
|
async def debug_face_shape(
|
||||||
|
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG)"),
|
||||||
|
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
||||||
|
image_base64: Optional[str] = Form(default=None, description="图片 base64"),
|
||||||
|
):
|
||||||
|
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
|
||||||
|
if e:
|
||||||
|
return e
|
||||||
|
try:
|
||||||
|
nparr = np.frombuffer(raw, np.uint8)
|
||||||
|
bgr = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
||||||
|
if bgr is None:
|
||||||
|
return err(1008, "图片格式不支持(仅 JPG / PNG)")
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return err(1008, "图片格式不支持(仅 JPG / PNG)")
|
||||||
|
|
||||||
|
def _jsonable(obj):
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return {k: _jsonable(v) for k, v in obj.items()}
|
||||||
|
if isinstance(obj, (list, tuple)):
|
||||||
|
return [_jsonable(v) for v in obj]
|
||||||
|
if hasattr(obj, "item"):
|
||||||
|
return obj.item()
|
||||||
|
if isinstance(obj, (float, int, str, bool)) or obj is None:
|
||||||
|
return obj
|
||||||
|
return obj
|
||||||
|
|
||||||
|
from fastapi.concurrency import run_in_threadpool
|
||||||
|
|
||||||
|
try:
|
||||||
|
from face.face_shape_classifier import classify_from_image
|
||||||
|
|
||||||
|
result = await run_in_threadpool(
|
||||||
|
classify_from_image, bgr, True, True,
|
||||||
|
)
|
||||||
|
except ValueError as ex:
|
||||||
|
return err(1001, str(ex) or "无法识别人像")
|
||||||
|
except Exception as ex: # noqa: BLE001
|
||||||
|
return err(1007, f"脸型分类失败:{ex}")
|
||||||
|
|
||||||
|
details = result.get("details") or {}
|
||||||
|
ranked = [
|
||||||
|
{"shape": name, "score": round(float(score), 2)}
|
||||||
|
for name, score in (details.get("ranked") or [])
|
||||||
|
]
|
||||||
|
annotated = result.pop("annotated", None)
|
||||||
|
h, w = bgr.shape[:2]
|
||||||
|
data = {
|
||||||
|
"face_shape": result["face_shape"],
|
||||||
|
"display": result["display"],
|
||||||
|
"confidence": round(float(result["confidence"]), 4),
|
||||||
|
"is_mixed": bool(details.get("is_mixed")),
|
||||||
|
"second_shape": details.get("second_shape"),
|
||||||
|
"score_gap": round(float(details["score_gap"]), 2) if details.get("score_gap") is not None else None,
|
||||||
|
"ranked": ranked,
|
||||||
|
"features": _jsonable(result.get("features") or {}),
|
||||||
|
"zscores": _jsonable(details.get("zscores") or {}),
|
||||||
|
"image_size": {"width": w, "height": h},
|
||||||
|
"annotated_image_base64": _jpg_b64(annotated) if annotated is not None else None,
|
||||||
|
}
|
||||||
|
return ok(data)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 健康检查
|
# 健康检查
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ def call_iface2(image_path, hair_style):
|
|||||||
with open(image_path, "rb") as f:
|
with open(image_path, "rb") as f:
|
||||||
img_data = f.read()
|
img_data = f.read()
|
||||||
fields = {"gender": "female", "hair_style": str(hair_style),
|
fields = {"gender": "female", "hair_style": str(hair_style),
|
||||||
"prompt": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"}
|
"prompt": "填充遮罩区域的头发"}
|
||||||
body, boundary = _multipart(
|
body, boundary = _multipart(
|
||||||
fields, {"image_file": (os.path.basename(image_path), img_data, "image/jpeg")})
|
fields, {"image_file": (os.path.basename(image_path), img_data, "image/jpeg")})
|
||||||
req = urllib.request.Request(f"{API_BASE}/api/v1/hair/grow", data=body, method="POST")
|
req = urllib.request.Request(f"{API_BASE}/api/v1/hair/grow", data=body, method="POST")
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ def call_iface2_female(image_path, hair_style):
|
|||||||
with open(image_path, "rb") as f:
|
with open(image_path, "rb") as f:
|
||||||
img_data = f.read()
|
img_data = f.read()
|
||||||
fields = {"gender": "female", "hair_style": str(hair_style),
|
fields = {"gender": "female", "hair_style": str(hair_style),
|
||||||
"prompt": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"}
|
"prompt": "填充遮罩区域的头发"}
|
||||||
body, boundary = _multipart(
|
body, boundary = _multipart(
|
||||||
fields, {"image_file": (os.path.basename(image_path), img_data, "image/jpeg")})
|
fields, {"image_file": (os.path.basename(image_path), img_data, "image/jpeg")})
|
||||||
req = urllib.request.Request(f"{API_BASE}/api/v1/hair/grow", data=body, method="POST")
|
req = urllib.request.Request(f"{API_BASE}/api/v1/hair/grow", data=body, method="POST")
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ def call_api2(image_path, gender, hair_style="1"):
|
|||||||
with open(image_path, "rb") as f:
|
with open(image_path, "rb") as f:
|
||||||
img_data = f.read()
|
img_data = f.read()
|
||||||
fields = {"gender": gender, "hair_style": hair_style, "use_mask": "0",
|
fields = {"gender": gender, "hair_style": hair_style, "use_mask": "0",
|
||||||
"prompt": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"}
|
"prompt": "填充遮罩区域的头发"}
|
||||||
body, boundary = _multipart(fields, {"image_file": (os.path.basename(image_path), img_data, "image/jpeg")})
|
body, boundary = _multipart(fields, {"image_file": (os.path.basename(image_path), img_data, "image/jpeg")})
|
||||||
req = urllib.request.Request(f"{API_BASE}/api/v1/hair/grow", data=body, method="POST")
|
req = urllib.request.Request(f"{API_BASE}/api/v1/hair/grow", data=body, method="POST")
|
||||||
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
|
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
|
||||||
@@ -107,7 +107,7 @@ def call_api3(image_path):
|
|||||||
"""接口3:B端生发(use_mask=False,直接送图)"""
|
"""接口3:B端生发(use_mask=False,直接送图)"""
|
||||||
with open(image_path, "rb") as f:
|
with open(image_path, "rb") as f:
|
||||||
img_data = f.read()
|
img_data = f.read()
|
||||||
fields = {"use_mask": "true", "prompt": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"}
|
fields = {"use_mask": "true", "prompt": "填充遮罩区域的头发"}
|
||||||
body, boundary = _multipart(fields, {"marked_image_file": (os.path.basename(image_path), img_data, "image/jpeg")})
|
body, boundary = _multipart(fields, {"marked_image_file": (os.path.basename(image_path), img_data, "image/jpeg")})
|
||||||
req = urllib.request.Request(f"{API_BASE}/api/v1/hair/grow-b", data=body, method="POST")
|
req = urllib.request.Request(f"{API_BASE}/api/v1/hair/grow-b", data=body, method="POST")
|
||||||
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
|
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
|
||||||
|
|||||||
@@ -68,7 +68,7 @@
|
|||||||
按 `face_ext.obj` 的 UV 把发际线贴图渲染到额头(预览)。生发:黑贴图渲染遮罩 → 调本机 **ComfyUI 8182** 的
|
按 `face_ext.obj` 的 UV 把发际线贴图渲染到额头(预览)。生发:黑贴图渲染遮罩 → 调本机 **ComfyUI 8182** 的
|
||||||
`add_hair.json`(Flux-2) 出图。**关键坑**:obj 是重排序,需 `INDEX_MAP_468` 把 MP 序→OBJ 序。
|
`add_hair.json`(Flux-2) 出图。**关键坑**:obj 是重排序,需 `INDEX_MAP_468` 把 MP 序→OBJ 序。
|
||||||
返回 `results[].image_base64` + `grown_image_base64`。
|
返回 `results[].image_base64` + `grown_image_base64`。
|
||||||
- `hair_style` 映射:female 1=ellipse 2=flower 3=heart 4=straight 5=wave;male 1=ellipse 2=inverse_arc 3=m 4=straight。
|
- `hair_style` 映射:female 1=ellipse 2=flower 3=heart 4=straight 5=wave 6=bigflower 7=clasicalflower;male 1=ellipse 2=inverse_arc 3=m 4=straight 5=heart 6=Softpetal。female 1..5 走「换发型(change_hair)」+Flux-2 重绘管线;female 6/7 与 male 全部走原生发(ComfyUI add_hair)管线。
|
||||||
|
|
||||||
### 接口7 C端生发 v2 `/api/v1/hair/grow-v2`(worker)—— 接口2同款,add_hair2 工作流
|
### 接口7 C端生发 v2 `/api/v1/hair/grow-v2`(worker)—— 接口2同款,add_hair2 工作流
|
||||||
- **做什么**:与接口 2 完全一致(正面照 + `gender` + `hair_style` 逗号分隔多选 → N 组预览+生发图)。
|
- **做什么**:与接口 2 完全一致(正面照 + `gender` + `hair_style` 逗号分隔多选 → N 组预览+生发图)。
|
||||||
@@ -85,8 +85,10 @@
|
|||||||
### 接口4 用户特征 `/api/v1/face/features`(**网关本机**)
|
### 接口4 用户特征 `/api/v1/face/features`(**网关本机**)
|
||||||
- **做什么**:照片 → 几十项面部特征(脸型/眉形/肤色/三庭五眼/四季色彩季型/量感/基因风格/性别…)。`data.features` 是 JSON 字符串。
|
- **做什么**:照片 → 几十项面部特征(脸型/眉形/肤色/三庭五眼/四季色彩季型/量感/基因风格/性别…)。`data.features` 是 JSON 字符串。
|
||||||
- **怎么实现**(`gateway/`,逻辑参考 worker `face_features.py` / `/home/xsl/fuyan`):调**火山方舟 豆包视觉模型**
|
- **怎么实现**(`gateway/`,逻辑参考 worker `face_features.py` / `/home/xsl/fuyan`):调**火山方舟 豆包视觉模型**
|
||||||
`doubao-seed-1-6-vision`(OpenAI 兼容,base64 data URI 喂图),解析 JSON + 映射 6 个英文优先字段并保留全部中文。
|
`doubao-seed-2-0-lite-260428`(OpenAI 兼容,base64 data URI 喂图),解析眉形/年龄/动静/性别/基因风格 5 项。
|
||||||
无人脸→1001。**唯一调外网的接口**:网关需可达 `ark.cn-beijing.volces.com`,API Key 走网关配置(不入 git)。
|
**`face_shape`(脸型)改为本机 `face/face_shape_classifier.py`(MediaPipe)计算并覆盖豆包结果**。
|
||||||
|
无人脸→1001。网关需可达 `ark.cn-beijing.volces.com`,API Key 走网关配置(不入 git)。
|
||||||
|
⚠️ 因此**网关机不再是纯轻量代理**,需额外安装 `mediapipe`/`opencv-python`/`numpy<2`(见 `requirements.txt`)。
|
||||||
|
|
||||||
### 接口5 发际线PNG生成 `/api/v1/hairline/generate`(worker)
|
### 接口5 发际线PNG生成 `/api/v1/hairline/generate`(worker)
|
||||||
- **做什么**:入参同接口2(`gender` + 多选 `hair_style` 必填)。对每个选中发型 → `middle`/`high`/`low` 三档发际线叠图 + 生发图 + 首个选中发型的面部中间点坐标。
|
- **做什么**:入参同接口2(`gender` + 多选 `hair_style` 必填)。对每个选中发型 → `middle`/`high`/`low` 三档发际线叠图 + 生发图 + 首个选中发型的面部中间点坐标。
|
||||||
@@ -108,10 +110,12 @@
|
|||||||
- `worker_config.json`(不入 git):`accept_passwords`(鉴权) + 鉴权头 `X-Internal-Token`。
|
- `worker_config.json`(不入 git):`accept_passwords`(鉴权) + 鉴权头 `X-Internal-Token`。
|
||||||
|
|
||||||
**网关机**
|
**网关机**
|
||||||
- 很轻:FastAPI/uvicorn/httpx + **接口4 的 `volcengine-python-sdk[ark]`**(或直接 httpx 调,OpenAI 兼容)。
|
- FastAPI/uvicorn/httpx + **接口4 的 `volcengine-python-sdk[ark]`**(或直接 httpx 调,OpenAI 兼容)。
|
||||||
- 不装 torch/mediapipe/opencv。配置 `gateway/config.json`(不入 git):`workers` 列表、`shared_password`、
|
- ⚠️ 接口4 `face_shape` 改本机 MediaPipe 计算后,网关机**也需要装** `mediapipe`/`opencv-python`/`numpy<2`
|
||||||
|
(不再是"网关不装 torch/mediapipe/opencv",只是仍不需要 torch/transformers/scikit-image 等重依赖)。
|
||||||
|
- 配置 `gateway/config.json`(不入 git):`workers` 列表、`shared_password`、
|
||||||
`ark` 的 api_key/base_url/model、`public_base_url`、超时(**生发接口慢,`request_timeout_seconds` 调大 ≥120s**)。
|
`ark` 的 api_key/base_url/model、`public_base_url`、超时(**生发接口慢,`request_timeout_seconds` 调大 ≥120s**)。
|
||||||
- 托管 `/static/annotations/`(落盘的图),定期清理。
|
- 托管 `/static/annotations/`(落盘的图),永久保留,不自动清理。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -265,7 +265,7 @@
|
|||||||
| 参数 | 类型 | 必填 | 说明 |
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| gender | string | **是** | 性别:`male` / `female`。决定使用的发际线贴图集合 |
|
| gender | string | **是** | 性别:`male` / `female`。决定使用的发际线贴图集合 |
|
||||||
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),最多不超过该性别的预设数。female:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave;male:1=ellipse, 2=inverse_arc, 3=m, 4=straight。越界/非法返回 `1007` |
|
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),最多不超过该性别的预设数。female:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave, 6=bigflower, 7=clasicalflower;male:1=ellipse, 2=inverse_arc, 3=m, 4=straight, 5=heart, 6=Softpetal。越界/非法返回 `1007` |
|
||||||
| beauty_enabled | bool | 否 | 生发图是否带美颜效果,默认 false(当前阶段不生效) |
|
| beauty_enabled | bool | 否 | 生发图是否带美颜效果,默认 false(当前阶段不生效) |
|
||||||
| use_mask | bool | 否 | 是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 |
|
| use_mask | bool | 否 | 是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 |
|
||||||
| prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发,加一点美颜」,会替换工作流节点 60 的文本 |
|
| prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发,加一点美颜」,会替换工作流节点 60 的文本 |
|
||||||
@@ -278,7 +278,7 @@
|
|||||||
|------|------|------|
|
|------|------|------|
|
||||||
| image_url | string | 发际线曲线**透明 PNG** URL(仅白色发际线曲线,透明底,**不含人物**,需前端叠加原图显示) |
|
| image_url | string | 发际线曲线**透明 PNG** URL(仅白色发际线曲线,透明底,**不含人物**,需前端叠加原图显示) |
|
||||||
| grown_image_url | string | **生发后图片** URL(ComfyUI/Flux「植发 3 个月」效果图,完整人像照片) |
|
| grown_image_url | string | **生发后图片** URL(ComfyUI/Flux「植发 3 个月」效果图,完整人像照片) |
|
||||||
| hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`(female),`ellipse`/`m`/`straight`/`inverse_arc`(male) |
|
| hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`/`bigflower`/`clasicalflower`(female),`ellipse`/`m`/`straight`/`inverse_arc`/`heart`/`Softpetal`(male) |
|
||||||
| order | int | 排序序号(当前阶段固定 `1..N`,按贴图顺序,暂不计算合适度) |
|
| order | int | 排序序号(当前阶段固定 `1..N`,按贴图顺序,暂不计算合适度) |
|
||||||
|
|
||||||
> ⚠️ 生发图由本机 ComfyUI(Flux-2,端口 8182)生成,**一次请求生成指定发型的 1 张、同步返回**。
|
> ⚠️ 生发图由本机 ComfyUI(Flux-2,端口 8182)生成,**一次请求生成指定发型的 1 张、同步返回**。
|
||||||
@@ -352,7 +352,7 @@
|
|||||||
|
|
||||||
## 接口 4:用户特征接口
|
## 接口 4:用户特征接口
|
||||||
|
|
||||||
**说明**:输入用户照片,由**火山方舟 豆包视觉模型**(`doubao-seed-1-6-vision`)分析,输出一大批面部特征。
|
**说明**:输入用户照片,由**火山方舟 豆包视觉模型**(`doubao-seed-2-0-lite-260428`)分析,输出一大批面部特征。
|
||||||
|
|
||||||
**请求**:`POST /api/v1/face/features`
|
**请求**:`POST /api/v1/face/features`
|
||||||
|
|
||||||
@@ -405,8 +405,8 @@
|
|||||||
|
|
||||||
| 参数 | 类型 | 必填 | 说明 |
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| gender | string | **是** | 性别:`male` / `female`。决定发型集合(female 5 / male 4)。缺失/非法返回 `1004` |
|
| gender | string | **是** | 性别:`male` / `female`。决定发型集合(female 7 / male 6)。缺失/非法返回 `1004` |
|
||||||
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),决定返回哪些发际线类型。female:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave;male:1=ellipse, 2=inverse_arc, 3=m, 4=straight。缺失/越界/非法返回 `1007` |
|
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),决定返回哪些发际线类型。female:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave, 6=bigflower, 7=clasicalflower;male:1=ellipse, 2=inverse_arc, 3=m, 4=straight, 5=heart, 6=Softpetal。缺失/越界/非法返回 `1007` |
|
||||||
| use_mask | bool | 否 | 生发是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 |
|
| use_mask | bool | 否 | 生发是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 |
|
||||||
| prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发,加一点美颜」,会替换工作流节点 60 的文本 |
|
| prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发,加一点美颜」,会替换工作流节点 60 的文本 |
|
||||||
| generate_grow_image | bool | 否 | 是否生成生发效果图(ComfyUI 生发,全流程最耗时),默认 `true`。传 `false` 时跳过生发,各发型 `grown_image_*` 恒为 `null`,仅返回三档发际线叠图与中心点,可大幅降低耗时 |
|
| generate_grow_image | bool | 否 | 是否生成生发效果图(ComfyUI 生发,全流程最耗时),默认 `true`。传 `false` 时跳过生发,各发型 `grown_image_*` 恒为 `null`,仅返回三档发际线叠图与中心点,可大幅降低耗时 |
|
||||||
@@ -427,7 +427,7 @@
|
|||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`(female),`ellipse`/`m`/`straight`/`inverse_arc`(male) |
|
| hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`/`bigflower`/`clasicalflower`(female),`ellipse`/`m`/`straight`/`inverse_arc`/`heart`/`Softpetal`(male) |
|
||||||
| image_middle_url | string | middle 档发际线曲线**透明 PNG** URL(仅曲线,透明底,**不含人物**,需叠加原图显示) |
|
| image_middle_url | string | middle 档发际线曲线**透明 PNG** URL(仅曲线,透明底,**不含人物**,需叠加原图显示) |
|
||||||
| image_high_url | string | high 档发际线曲线**透明 PNG** URL(同上,high 档曲线) |
|
| image_high_url | string | high 档发际线曲线**透明 PNG** URL(同上,high 档曲线) |
|
||||||
| image_low_url | string | low 档发际线曲线**透明 PNG** URL(同上,low 档曲线) |
|
| image_low_url | string | low 档发际线曲线**透明 PNG** URL(同上,low 档曲线) |
|
||||||
|
|||||||
@@ -0,0 +1,506 @@
|
|||||||
|
"""
|
||||||
|
build_dataset_report.py
|
||||||
|
对任意图片目录(可含多层子目录)批量预测脸型并生成 HTML 报告。
|
||||||
|
保留图片原始所属的子目录名作为「分组」,在报告中按分组展示与统计。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
./venv/bin/python face/build_dataset_report.py --src <图片目录> [--sample 50] [--seed 42]
|
||||||
|
|
||||||
|
示例:
|
||||||
|
./venv/bin/python face/build_dataset_report.py \
|
||||||
|
--src face/test_img/脸型测试集合 --sample 50 --name 脸型测试集合
|
||||||
|
|
||||||
|
输出:
|
||||||
|
static/<slug>_report.html
|
||||||
|
static/<slug>_report/images/*.jpg
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import html
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import unicodedata
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from face.face_shape_classifier import classify_from_image # noqa: E402
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp", ".bmp"}
|
||||||
|
MAX_IMAGE_SIDE = 900
|
||||||
|
JPEG_QUALITY = 88
|
||||||
|
|
||||||
|
SHAPE_ORDER = ["圆形脸", "心形脸", "菱形脸", "鹅蛋脸", "方形脸", "长形脸", "瓜子脸"]
|
||||||
|
SHAPE_COLORS = {
|
||||||
|
"圆形脸": "#e67e22",
|
||||||
|
"心形脸": "#e74c3c",
|
||||||
|
"菱形脸": "#9b59b6",
|
||||||
|
"鹅蛋脸": "#27ae60",
|
||||||
|
"方形脸": "#2980b9",
|
||||||
|
"长形脸": "#16a085",
|
||||||
|
"瓜子脸": "#c0392b",
|
||||||
|
"检测失败": "#7f8c8d",
|
||||||
|
}
|
||||||
|
# 数据集分组名与分类器脸型口径的近似对应(仅用于交叉表高亮参考,非严格标签)
|
||||||
|
TAXONOMY_EQUIV = {
|
||||||
|
"方形脸": "方形脸",
|
||||||
|
"长形脸": "长形脸",
|
||||||
|
"瓜子脸": "瓜子脸",
|
||||||
|
"标准脸": "鹅蛋脸",
|
||||||
|
"娃娃脸": "圆形脸",
|
||||||
|
}
|
||||||
|
|
||||||
|
FEATURE_KEYS = [
|
||||||
|
"face_width",
|
||||||
|
"face_height",
|
||||||
|
"jaw_angle",
|
||||||
|
"taper_ratio",
|
||||||
|
"forehead_ratio",
|
||||||
|
"cheekbone_ratio",
|
||||||
|
"jaw_ratio",
|
||||||
|
"chin_ratio",
|
||||||
|
"chin_sharpness",
|
||||||
|
"width_uniformity",
|
||||||
|
"face_curve_score",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def natural_key(text: str):
|
||||||
|
parts = re.split(r"(\d+)", text)
|
||||||
|
return [int(p) if p.isdigit() else p for p in parts]
|
||||||
|
|
||||||
|
|
||||||
|
def collect_images(src: Path) -> List[Path]:
|
||||||
|
return sorted(
|
||||||
|
(p for p in src.rglob("*") if p.suffix.lower() in IMAGE_SUFFIXES),
|
||||||
|
key=lambda p: natural_key(str(p.relative_to(src))),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def group_of(path: Path, src: Path) -> str:
|
||||||
|
"""图片相对根目录的父目录名;直接位于根目录则记为「根目录」。"""
|
||||||
|
rel = path.relative_to(src).parent
|
||||||
|
return str(rel) if str(rel) != "." else "(根目录)"
|
||||||
|
|
||||||
|
|
||||||
|
def ascii_slug(text: str, fallback: str) -> str:
|
||||||
|
"""生成安全的 ASCII 文件名片段(中文目录名转拼音不可靠,直接编号兜底)。"""
|
||||||
|
norm = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()
|
||||||
|
norm = re.sub(r"[^A-Za-z0-9_-]+", "_", norm).strip("_")
|
||||||
|
return norm or fallback
|
||||||
|
|
||||||
|
|
||||||
|
def stratified_sample(
|
||||||
|
images: List[Path], src: Path, total: int, seed: int, min_per_group: int
|
||||||
|
) -> List[Path]:
|
||||||
|
"""
|
||||||
|
按分组分层抽样:先保证每组至少 min_per_group 张,剩余名额按组大小比例分配。
|
||||||
|
小分组(如只有 3 张的梨形脸)在纯随机抽样下几乎必然缺席,分层可保证覆盖。
|
||||||
|
"""
|
||||||
|
rng = random.Random(seed)
|
||||||
|
buckets: Dict[str, List[Path]] = defaultdict(list)
|
||||||
|
for p in images:
|
||||||
|
buckets[group_of(p, src)].append(p)
|
||||||
|
|
||||||
|
groups = sorted(buckets, key=natural_key)
|
||||||
|
quota = {g: min(min_per_group, len(buckets[g])) for g in groups}
|
||||||
|
|
||||||
|
remaining = total - sum(quota.values())
|
||||||
|
if remaining > 0:
|
||||||
|
spare = {g: len(buckets[g]) - quota[g] for g in groups}
|
||||||
|
pool = sum(spare.values())
|
||||||
|
if pool > 0:
|
||||||
|
# 按剩余可选量比例分配,再把取整误差补给最大的分组
|
||||||
|
extra = {g: int(remaining * spare[g] / pool) for g in groups}
|
||||||
|
for g in sorted(groups, key=lambda g: -spare[g]):
|
||||||
|
if sum(extra.values()) >= remaining:
|
||||||
|
break
|
||||||
|
if extra[g] < spare[g]:
|
||||||
|
extra[g] += 1
|
||||||
|
for g in groups:
|
||||||
|
quota[g] += min(extra[g], spare[g])
|
||||||
|
|
||||||
|
chosen: List[Path] = []
|
||||||
|
for g in groups:
|
||||||
|
chosen.extend(rng.sample(buckets[g], min(quota[g], len(buckets[g]))))
|
||||||
|
return chosen
|
||||||
|
|
||||||
|
|
||||||
|
def analyze(
|
||||||
|
src: Path, sample: int, seed: int, img_dir: Path, min_per_group: int
|
||||||
|
) -> List[Dict]:
|
||||||
|
all_images = collect_images(src)
|
||||||
|
if not all_images:
|
||||||
|
raise SystemExit(f"目录中没有图片: {src}")
|
||||||
|
|
||||||
|
if sample and sample < len(all_images):
|
||||||
|
if min_per_group > 0:
|
||||||
|
chosen = stratified_sample(all_images, src, sample, seed, min_per_group)
|
||||||
|
else:
|
||||||
|
chosen = random.Random(seed).sample(all_images, sample)
|
||||||
|
chosen.sort(key=lambda p: natural_key(str(p.relative_to(src))))
|
||||||
|
else:
|
||||||
|
chosen = all_images
|
||||||
|
|
||||||
|
mode = f"分层抽样,每组至少 {min_per_group} 张" if min_per_group > 0 else "纯随机抽样"
|
||||||
|
print(f"共发现 {len(all_images)} 张图片,本次测试 {len(chosen)} 张({mode},seed={seed})\n")
|
||||||
|
|
||||||
|
if img_dir.exists():
|
||||||
|
shutil.rmtree(img_dir)
|
||||||
|
img_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
group_slugs: Dict[str, str] = {}
|
||||||
|
rows: List[Dict] = []
|
||||||
|
|
||||||
|
for idx, path in enumerate(chosen, 1):
|
||||||
|
group = group_of(path, src)
|
||||||
|
if group not in group_slugs:
|
||||||
|
group_slugs[group] = ascii_slug(group, f"g{len(group_slugs) + 1}")
|
||||||
|
out_name = f"{group_slugs[group]}_{idx:03d}.jpg"
|
||||||
|
|
||||||
|
item = {
|
||||||
|
"index": idx,
|
||||||
|
"group": group,
|
||||||
|
"file": path.name,
|
||||||
|
"rel_path": str(path.relative_to(src)),
|
||||||
|
# 相对 static/ 的路径(报告 HTML 也放在 static/ 根下)
|
||||||
|
"img_src": f"{img_dir.relative_to(ROOT / 'static').as_posix()}/{out_name}",
|
||||||
|
"ok": False,
|
||||||
|
"predicted": None,
|
||||||
|
"display": None,
|
||||||
|
"confidence": None,
|
||||||
|
"score": None,
|
||||||
|
"top3": [],
|
||||||
|
"features": {},
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = classify_from_image(path, return_details=True, return_annotated=True)
|
||||||
|
annotated = result["annotated"]
|
||||||
|
h, w = annotated.shape[:2]
|
||||||
|
if max(h, w) > MAX_IMAGE_SIDE:
|
||||||
|
scale = MAX_IMAGE_SIDE / max(h, w)
|
||||||
|
annotated = cv2.resize(
|
||||||
|
annotated, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA
|
||||||
|
)
|
||||||
|
cv2.imwrite(str(img_dir / out_name), annotated, [int(cv2.IMWRITE_JPEG_QUALITY), JPEG_QUALITY])
|
||||||
|
|
||||||
|
item.update(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"predicted": result["face_shape"],
|
||||||
|
"display": result["display"],
|
||||||
|
"confidence": result["confidence"],
|
||||||
|
"score": result["details"]["ranked"][0][1],
|
||||||
|
"top3": result["details"]["ranked"][:3],
|
||||||
|
"features": {k: result["features"][k] for k in FEATURE_KEYS},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 - 报告需要汇总所有失败样本
|
||||||
|
img = cv2.imread(str(path))
|
||||||
|
if img is not None:
|
||||||
|
h, w = img.shape[:2]
|
||||||
|
if max(h, w) > MAX_IMAGE_SIDE:
|
||||||
|
scale = MAX_IMAGE_SIDE / max(h, w)
|
||||||
|
img = cv2.resize(img, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA)
|
||||||
|
cv2.imwrite(str(img_dir / out_name), img, [int(cv2.IMWRITE_JPEG_QUALITY), JPEG_QUALITY])
|
||||||
|
item["error"] = str(exc)
|
||||||
|
|
||||||
|
rows.append(item)
|
||||||
|
print(f"[{idx:3d}/{len(chosen)}] [{group}] {path.name} -> {item['display'] or 'ERR: ' + str(item['error'])}")
|
||||||
|
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def bar_chart(counter: Counter) -> str:
|
||||||
|
if not counter:
|
||||||
|
return "<p class='muted'>无数据</p>"
|
||||||
|
total = sum(counter.values())
|
||||||
|
parts = []
|
||||||
|
order = [s for s in SHAPE_ORDER if counter.get(s)] + [
|
||||||
|
s for s in counter if s not in SHAPE_ORDER
|
||||||
|
]
|
||||||
|
for shape in order:
|
||||||
|
n = counter[shape]
|
||||||
|
color = SHAPE_COLORS.get(shape, "#7f8c8d")
|
||||||
|
pct = n / total * 100
|
||||||
|
parts.append(
|
||||||
|
f"<div class='bar-row'><span class='bar-label'>{html.escape(shape)}</span>"
|
||||||
|
f"<div class='bar-track'><div class='bar-fill' style='width:{pct:.1f}%;background:{color}'></div></div>"
|
||||||
|
f"<span class='bar-num'>{n}({pct:.0f}%)</span></div>"
|
||||||
|
)
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_feat(key: str, value: float) -> str:
|
||||||
|
if key in {"face_width", "face_height"}:
|
||||||
|
return f"{value:.1f}px"
|
||||||
|
if key == "jaw_angle":
|
||||||
|
return f"{value:.1f}°"
|
||||||
|
return f"{value:.3f}"
|
||||||
|
|
||||||
|
|
||||||
|
def card(item: Dict) -> str:
|
||||||
|
group_tag = f"<span class='group-tag'>{html.escape(item['group'])}</span>"
|
||||||
|
if not item["ok"]:
|
||||||
|
return f"""
|
||||||
|
<article class="card error">
|
||||||
|
<a class="img-link" href="{html.escape(item['img_src'])}" target="_blank">
|
||||||
|
<img src="{html.escape(item['img_src'])}" alt="{html.escape(item['file'])}" loading="lazy"/>
|
||||||
|
</a>
|
||||||
|
<div class="body">
|
||||||
|
<div class="meta"><h3>{html.escape(item['file'])}</h3>{group_tag}</div>
|
||||||
|
<p class="badge bad">检测失败</p>
|
||||||
|
<p class="muted">{html.escape(item['error'] or '')}</p>
|
||||||
|
</div>
|
||||||
|
</article>"""
|
||||||
|
|
||||||
|
color = SHAPE_COLORS.get(item["predicted"], "#34495e")
|
||||||
|
top3 = "".join(
|
||||||
|
f"<li><span>{html.escape(name)}</span><b>{score:.1f}</b></li>" for name, score in item["top3"]
|
||||||
|
)
|
||||||
|
feat_html = "".join(
|
||||||
|
f"<tr><td>{html.escape(k)}</td><td>{html.escape(fmt_feat(k, v))}</td></tr>"
|
||||||
|
for k, v in item["features"].items()
|
||||||
|
)
|
||||||
|
return f"""
|
||||||
|
<article class="card">
|
||||||
|
<a class="img-link" href="{html.escape(item['img_src'])}" target="_blank" title="点击查看大图标注">
|
||||||
|
<img src="{html.escape(item['img_src'])}" alt="{html.escape(item['file'])}" loading="lazy"/>
|
||||||
|
</a>
|
||||||
|
<div class="body">
|
||||||
|
<div class="meta"><h3>{html.escape(item['file'])}</h3>{group_tag}</div>
|
||||||
|
<p class="path muted">{html.escape(item['rel_path'])}</p>
|
||||||
|
<p class="badge" style="background:{color}">{html.escape(item['display'])}</p>
|
||||||
|
<p class="conf">匹配度 {item['score']:.1f} · 置信度 {item['confidence']:.3f}</p>
|
||||||
|
<ul class="scores">{top3}</ul>
|
||||||
|
<details>
|
||||||
|
<summary>标注特征数值</summary>
|
||||||
|
<table>{feat_html}</table>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</article>"""
|
||||||
|
|
||||||
|
|
||||||
|
CSS = """
|
||||||
|
:root { --bg:#f3efe6; --ink:#1c1915; --muted:#6b645a; --card:#fffdf8; --line:#e2d8c8; --accent:#0f6b5c; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin:0; font-family:"PingFang SC","Noto Sans SC","Segoe UI",sans-serif; color:var(--ink);
|
||||||
|
background: radial-gradient(1200px 600px at 10% -10%, #ffe8c8 0%, transparent 55%),
|
||||||
|
radial-gradient(900px 500px at 100% 0%, #d9f2ea 0%, transparent 50%), var(--bg);
|
||||||
|
}
|
||||||
|
header { padding:40px 24px 20px; max-width:1320px; margin:0 auto; }
|
||||||
|
header h1 { margin:0 0 8px; font-size:clamp(1.8rem,3vw,2.4rem); }
|
||||||
|
header p { margin:4px 0; color:var(--muted); }
|
||||||
|
.legend-box { max-width:1320px; margin:0 auto 20px; padding:0 24px; }
|
||||||
|
.legend-box .inner { background:var(--card); border:1px solid var(--line); border-radius:14px;
|
||||||
|
padding:14px 16px; font-size:.9rem; line-height:1.55; }
|
||||||
|
.legend-box code { background:#efe7da; padding:1px 6px; border-radius:4px; font-size:.84rem; }
|
||||||
|
.swatch { display:inline-block; width:10px; height:10px; border-radius:2px; margin-right:4px; vertical-align:middle; }
|
||||||
|
.stats { display:grid; grid-template-columns:repeat(auto-fit,minmax(280px,1fr)); gap:16px;
|
||||||
|
max-width:1320px; margin:0 auto 28px; padding:0 24px; }
|
||||||
|
.stat { background:var(--card); border:1px solid var(--line); border-radius:16px; padding:16px 18px; }
|
||||||
|
.stat h2 { margin:0 0 12px; font-size:1rem; }
|
||||||
|
.bar-row { display:grid; grid-template-columns:72px 1fr 80px; gap:8px; align-items:center; margin:6px 0; font-size:.86rem; }
|
||||||
|
.bar-track { height:8px; background:#efe7da; border-radius:999px; overflow:hidden; }
|
||||||
|
.bar-fill { height:100%; border-radius:999px; }
|
||||||
|
.bar-num { color:var(--muted); text-align:right; }
|
||||||
|
section { max-width:1320px; margin:0 auto 36px; padding:0 24px; }
|
||||||
|
section h2 { margin:0 0 14px; font-size:1.3rem; border-left:4px solid var(--accent); padding-left:10px; }
|
||||||
|
section h2 small { color:var(--muted); font-weight:400; font-size:.8rem; margin-left:8px; }
|
||||||
|
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(270px,1fr)); gap:16px; }
|
||||||
|
.card { background:var(--card); border:1px solid var(--line); border-radius:18px; overflow:hidden;
|
||||||
|
display:flex; flex-direction:column; box-shadow:0 8px 24px rgba(60,40,10,.05); }
|
||||||
|
.card.error { opacity:.9; }
|
||||||
|
.img-link { display:block; }
|
||||||
|
.card img { width:100%; aspect-ratio:3/4; object-fit:cover; background:#ddd; display:block; }
|
||||||
|
.card .body { padding:14px; }
|
||||||
|
.meta { display:flex; justify-content:space-between; align-items:baseline; gap:8px; }
|
||||||
|
.meta h3 { margin:0; font-size:.95rem; word-break:break-all; }
|
||||||
|
.group-tag { font-size:.72rem; color:var(--accent); background:#e7f6f2; padding:2px 8px;
|
||||||
|
border-radius:999px; white-space:nowrap; }
|
||||||
|
.path { font-size:.72rem; margin:4px 0 0; word-break:break-all; }
|
||||||
|
.badge { display:inline-block; margin:10px 0 4px; color:#fff; padding:6px 10px; border-radius:999px;
|
||||||
|
font-weight:600; font-size:.92rem; }
|
||||||
|
.badge.bad { background:#c0392b; }
|
||||||
|
.conf { margin:0 0 8px; color:var(--muted); font-size:.85rem; }
|
||||||
|
.scores { list-style:none; padding:0; margin:0 0 8px; }
|
||||||
|
.scores li { display:flex; justify-content:space-between; padding:4px 0; border-bottom:1px dashed var(--line); font-size:.86rem; }
|
||||||
|
details { margin-top:8px; }
|
||||||
|
summary { cursor:pointer; color:var(--accent); font-size:.86rem; }
|
||||||
|
table { width:100%; border-collapse:collapse; margin-top:8px; font-size:.8rem; }
|
||||||
|
td { padding:3px 0; border-bottom:1px solid var(--line); }
|
||||||
|
td:last-child { text-align:right; font-variant-numeric:tabular-nums; }
|
||||||
|
.muted { color:var(--muted); }
|
||||||
|
.cross-wrap { overflow-x:auto; background:var(--card); border:1px solid var(--line);
|
||||||
|
border-radius:16px; padding:14px 16px; }
|
||||||
|
table.cross { border-collapse:collapse; width:100%; font-size:.88rem; }
|
||||||
|
table.cross th, table.cross td { padding:7px 10px; text-align:center; border-bottom:1px solid var(--line);
|
||||||
|
white-space:nowrap; }
|
||||||
|
table.cross thead th { background:#efe7da; font-weight:600; position:sticky; top:0; }
|
||||||
|
table.cross th.rowh { text-align:left; font-weight:600; }
|
||||||
|
table.cross th.rowh small { color:var(--muted); font-weight:400; }
|
||||||
|
table.cross td.num { font-variant-numeric:tabular-nums; }
|
||||||
|
table.cross td.hit { background:#d8f3e4; color:#0f6b5c; font-weight:700; font-variant-numeric:tabular-nums; }
|
||||||
|
table.cross td.zero { color:#cfc6b6; }
|
||||||
|
footer { max-width:1320px; margin:0 auto; padding:8px 24px 40px; color:var(--muted); font-size:.85rem; }
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def cross_table(by_group: Dict[str, List[Dict]]) -> str:
|
||||||
|
"""原始分组 × 预测脸型 交叉表,对角线(口径对应的格子)高亮。"""
|
||||||
|
cols = SHAPE_ORDER + ["检测失败"]
|
||||||
|
head = "".join(f"<th>{html.escape(c)}</th>" for c in cols)
|
||||||
|
body = []
|
||||||
|
for group, items in sorted(by_group.items(), key=lambda kv: natural_key(kv[0])):
|
||||||
|
counts = Counter(i["predicted"] if i["ok"] else "检测失败" for i in items)
|
||||||
|
equiv = TAXONOMY_EQUIV.get(group)
|
||||||
|
cells = []
|
||||||
|
for c in cols:
|
||||||
|
n = counts.get(c, 0)
|
||||||
|
if n == 0:
|
||||||
|
cells.append("<td class='zero'>·</td>")
|
||||||
|
continue
|
||||||
|
cls = "hit" if c == equiv else "num"
|
||||||
|
cells.append(f"<td class='{cls}'>{n}</td>")
|
||||||
|
label = html.escape(group)
|
||||||
|
if equiv:
|
||||||
|
label += f" <small>≈{html.escape(equiv)}</small>"
|
||||||
|
body.append(f"<tr><th class='rowh'>{label}</th>{''.join(cells)}<th>{len(items)}</th></tr>")
|
||||||
|
return (
|
||||||
|
"<div class='cross-wrap'><table class='cross'>"
|
||||||
|
f"<thead><tr><th>原始分组 \\ 预测</th>{head}<th>合计</th></tr></thead>"
|
||||||
|
f"<tbody>{''.join(body)}</tbody></table></div>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_html(rows: List[Dict], name: str, src: Path, seed: int, img_dir_name: str) -> str:
|
||||||
|
overall = Counter(r["predicted"] if r["ok"] else "检测失败" for r in rows)
|
||||||
|
by_group: Dict[str, List[Dict]] = defaultdict(list)
|
||||||
|
for r in rows:
|
||||||
|
by_group[r["group"]].append(r)
|
||||||
|
|
||||||
|
group_stats = "".join(
|
||||||
|
f"<div class='stat'><h2>{html.escape(g)} <span class='muted'>({len(items)} 张)</span></h2>"
|
||||||
|
f"{bar_chart(Counter(i['predicted'] if i['ok'] else '检测失败' for i in items))}</div>"
|
||||||
|
for g, items in sorted(by_group.items(), key=lambda kv: natural_key(kv[0]))
|
||||||
|
)
|
||||||
|
|
||||||
|
sections = "".join(
|
||||||
|
f"<section><h2>{html.escape(g)}<small>{len(items)} 张</small></h2>"
|
||||||
|
f"<div class='grid'>{''.join(card(i) for i in items)}</div></section>"
|
||||||
|
for g, items in sorted(by_group.items(), key=lambda kv: natural_key(kv[0]))
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
n_ok = sum(1 for r in rows if r["ok"])
|
||||||
|
n_kind = len([s for s in SHAPE_ORDER if overall.get(s)])
|
||||||
|
|
||||||
|
return f"""<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<title>{html.escape(name)} — 脸型分类报告</title>
|
||||||
|
<style>{CSS}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>{html.escape(name)} — 脸型分类报告</h1>
|
||||||
|
<p>z 分数原型匹配分类 · 照片上标注 face_width / face_height 及各比例特征</p>
|
||||||
|
<p>生成时间:{html.escape(now)} · 抽样 {len(rows)} 张(随机种子 {seed})· 成功 {n_ok} 张 ·
|
||||||
|
覆盖 {n_kind} 种脸型 · 共 {len(by_group)} 个原始分组 · 点击图片看大图</p>
|
||||||
|
<p class="muted">来源目录:{html.escape(str(src))}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="legend-box">
|
||||||
|
<div class="inner">
|
||||||
|
<b>图上标注说明</b><br/>
|
||||||
|
<span class="swatch" style="background:#00dc78"></span><code>face_width</code> 颧骨宽度
|
||||||
|
<span class="swatch" style="background:#28b4ff"></span><code>face_height</code> 额头顶→下巴
|
||||||
|
<span class="swatch" style="background:#ff5a00"></span><code>jaw_angle</code> 下巴到左右下颌角夹角
|
||||||
|
<span class="swatch" style="background:#ffc828"></span><code>taper_ratio</code> 额头→下巴收窄
|
||||||
|
<span class="swatch" style="background:#28a0ff"></span><code>forehead / jaw / chin ratio</code> 各级宽度比
|
||||||
|
<span class="swatch" style="background:#b4ff50"></span><code>face_curve_score</code> 下颌中点→下巴
|
||||||
|
右侧柱状条示意 <code>width_uniformity</code>;左上角是完整数值图例。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>原始分组 × 预测脸型 对照<small>数据集分组本身是脸型标签,但命名口径与分类器不同</small></h2>
|
||||||
|
{cross_table(by_group)}
|
||||||
|
<p class="muted" style="margin-top:10px;font-size:.85rem">
|
||||||
|
绿色格子表示预测结果与该分组的对应口径一致(标准脸≈鹅蛋脸、娃娃脸≈圆形脸,方形/长形/瓜子同名直接对应)。
|
||||||
|
「梨形脸」「混合脸」在分类器的 7 分类里没有对应项,不作一致性判断。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat"><h2>总体脸型分布({len(rows)} 张)</h2>{bar_chart(overall)}</div>
|
||||||
|
{group_stats}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sections}
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
分类实现:face/face_shape_classifier.py · 报告生成:face/build_dataset_report.py ·
|
||||||
|
图片目录:static/{html.escape(img_dir_name)}/
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser(description="批量脸型预测并生成 HTML 报告")
|
||||||
|
ap.add_argument("--src", required=True, help="图片根目录(可含子目录)")
|
||||||
|
ap.add_argument("--sample", type=int, default=50, help="随机抽样张数,0 表示全部")
|
||||||
|
ap.add_argument("--seed", type=int, default=42, help="随机种子")
|
||||||
|
ap.add_argument("--name", default=None, help="报告标题,默认取目录名")
|
||||||
|
ap.add_argument("--slug", default="dataset", help="输出文件名前缀(ASCII)")
|
||||||
|
ap.add_argument(
|
||||||
|
"--min-per-group",
|
||||||
|
type=int,
|
||||||
|
default=2,
|
||||||
|
help="分层抽样时每个分组至少抽几张,0 表示纯随机抽样",
|
||||||
|
)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
src = Path(args.src).expanduser().resolve()
|
||||||
|
if not src.is_dir():
|
||||||
|
raise SystemExit(f"目录不存在: {src}")
|
||||||
|
|
||||||
|
name = args.name or src.name
|
||||||
|
img_dir_name = f"{args.slug}_report"
|
||||||
|
img_dir = ROOT / "static" / img_dir_name / "images"
|
||||||
|
out_html = ROOT / "static" / f"{args.slug}_report.html"
|
||||||
|
|
||||||
|
rows = analyze(src, args.sample, args.seed, img_dir, args.min_per_group)
|
||||||
|
out_html.write_text(
|
||||||
|
build_html(rows, name, src, args.seed, img_dir_name), encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
overall = Counter(r["predicted"] if r["ok"] else "检测失败" for r in rows)
|
||||||
|
total = sum(overall.values())
|
||||||
|
print(f"\n写入 {out_html}")
|
||||||
|
print("=== 总体脸型分布 ===")
|
||||||
|
for shape in SHAPE_ORDER + ["检测失败"]:
|
||||||
|
n = overall.get(shape, 0)
|
||||||
|
if n:
|
||||||
|
print(f" {shape}: {n:3d} ({n / total * 100:4.1f}%) {'#' * n}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
"""
|
||||||
|
build_report.py
|
||||||
|
对 face/test_img/girl 与 face/test_img/man 下的照片批量预测脸型,
|
||||||
|
在照片上标注 face_width / face_height 等特征,并生成 HTML 报告。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
./venv/bin/python face/build_report.py
|
||||||
|
输出:
|
||||||
|
static/face_shape_report.html
|
||||||
|
static/face_shape_report/images/*.jpg
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from collections import Counter
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from face.face_shape_classifier import classify_from_image # noqa: E402
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SRC_DIRS = {
|
||||||
|
"女": ROOT / "face/test_img/girl",
|
||||||
|
"男": ROOT / "face/test_img/man",
|
||||||
|
}
|
||||||
|
OUT_DIR = ROOT / "static/face_shape_report"
|
||||||
|
IMG_DIR = OUT_DIR / "images"
|
||||||
|
OUT_HTML = ROOT / "static/face_shape_report.html"
|
||||||
|
|
||||||
|
MAX_IMAGE_SIDE = 900
|
||||||
|
JPEG_QUALITY = 90
|
||||||
|
|
||||||
|
SHAPE_ORDER = ["圆形脸", "心形脸", "菱形脸", "鹅蛋脸", "方形脸", "长形脸", "瓜子脸"]
|
||||||
|
SHAPE_COLORS = {
|
||||||
|
"圆形脸": "#e67e22",
|
||||||
|
"心形脸": "#e74c3c",
|
||||||
|
"菱形脸": "#9b59b6",
|
||||||
|
"鹅蛋脸": "#27ae60",
|
||||||
|
"方形脸": "#2980b9",
|
||||||
|
"长形脸": "#16a085",
|
||||||
|
"瓜子脸": "#c0392b",
|
||||||
|
}
|
||||||
|
FEATURE_KEYS = [
|
||||||
|
"face_width",
|
||||||
|
"face_height",
|
||||||
|
"jaw_angle",
|
||||||
|
"taper_ratio",
|
||||||
|
"forehead_ratio",
|
||||||
|
"cheekbone_ratio",
|
||||||
|
"jaw_ratio",
|
||||||
|
"chin_ratio",
|
||||||
|
"chin_sharpness",
|
||||||
|
"width_uniformity",
|
||||||
|
"face_curve_score",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def natural_key(path: Path):
|
||||||
|
m = re.search(r"(\d+)", path.stem)
|
||||||
|
return (0, int(m.group(1))) if m else (1, path.stem)
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_all() -> tuple[List[Dict], Dict[str, Counter]]:
|
||||||
|
if IMG_DIR.exists():
|
||||||
|
shutil.rmtree(IMG_DIR)
|
||||||
|
IMG_DIR.mkdir(parents=True)
|
||||||
|
|
||||||
|
rows: List[Dict] = []
|
||||||
|
summary = {"女": Counter(), "男": Counter(), "all": Counter()}
|
||||||
|
|
||||||
|
for gender, src in SRC_DIRS.items():
|
||||||
|
prefix = "girl" if gender == "女" else "man"
|
||||||
|
paths = sorted(
|
||||||
|
[p for p in src.iterdir() if p.suffix.lower() in {".jpg", ".jpeg", ".png", ".webp"}],
|
||||||
|
key=natural_key,
|
||||||
|
)
|
||||||
|
for path in paths:
|
||||||
|
m = re.search(r"(\d+)", path.stem)
|
||||||
|
out_name = f"{prefix}_{int(m.group(1)) if m else 0:02d}.jpg"
|
||||||
|
dest = IMG_DIR / out_name
|
||||||
|
|
||||||
|
item = {
|
||||||
|
"gender": gender,
|
||||||
|
"file": path.name,
|
||||||
|
"img_src": f"face_shape_report/images/{out_name}",
|
||||||
|
"ok": False,
|
||||||
|
"predicted": None,
|
||||||
|
"display": None,
|
||||||
|
"confidence": None,
|
||||||
|
"score": None,
|
||||||
|
"top3": [],
|
||||||
|
"features": {},
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
result = classify_from_image(path, return_details=True, return_annotated=True)
|
||||||
|
annotated = result["annotated"]
|
||||||
|
h, w = annotated.shape[:2]
|
||||||
|
if max(h, w) > MAX_IMAGE_SIDE:
|
||||||
|
scale = MAX_IMAGE_SIDE / max(h, w)
|
||||||
|
annotated = cv2.resize(
|
||||||
|
annotated, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA
|
||||||
|
)
|
||||||
|
cv2.imwrite(str(dest), annotated, [int(cv2.IMWRITE_JPEG_QUALITY), JPEG_QUALITY])
|
||||||
|
|
||||||
|
item.update(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"predicted": result["face_shape"],
|
||||||
|
"display": result["display"],
|
||||||
|
"confidence": result["confidence"],
|
||||||
|
"score": result["details"]["ranked"][0][1],
|
||||||
|
"top3": result["details"]["ranked"][:3],
|
||||||
|
"features": {k: result["features"][k] for k in FEATURE_KEYS},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
summary[gender][result["face_shape"]] += 1
|
||||||
|
summary["all"][result["face_shape"]] += 1
|
||||||
|
except Exception as exc: # noqa: BLE001 - 报告需要汇总所有失败
|
||||||
|
img = cv2.imread(str(path))
|
||||||
|
if img is not None:
|
||||||
|
cv2.imwrite(str(dest), img, [int(cv2.IMWRITE_JPEG_QUALITY), JPEG_QUALITY])
|
||||||
|
item["error"] = str(exc)
|
||||||
|
summary[gender]["检测失败"] += 1
|
||||||
|
summary["all"]["检测失败"] += 1
|
||||||
|
|
||||||
|
rows.append(item)
|
||||||
|
print(f"[{gender}] {path.name} -> {item['display'] or 'ERR ' + str(item['error'])}")
|
||||||
|
|
||||||
|
return rows, summary
|
||||||
|
|
||||||
|
|
||||||
|
def count_table(counter: Counter) -> str:
|
||||||
|
if not counter:
|
||||||
|
return "<p class='muted'>无数据</p>"
|
||||||
|
total = sum(counter.values())
|
||||||
|
parts = []
|
||||||
|
for shape, n in sorted(counter.items(), key=lambda x: (-x[1], x[0])):
|
||||||
|
color = SHAPE_COLORS.get(shape, "#7f8c8d")
|
||||||
|
pct = n / total * 100
|
||||||
|
parts.append(
|
||||||
|
f"<div class='bar-row'><span class='bar-label'>{html.escape(shape)}</span>"
|
||||||
|
f"<div class='bar-track'><div class='bar-fill' style='width:{pct:.1f}%;background:{color}'></div></div>"
|
||||||
|
f"<span class='bar-num'>{n}({pct:.0f}%)</span></div>"
|
||||||
|
)
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_feat(key: str, value: float) -> str:
|
||||||
|
if key in {"face_width", "face_height"}:
|
||||||
|
return f"{value:.1f}px"
|
||||||
|
if key == "jaw_angle":
|
||||||
|
return f"{value:.1f}°"
|
||||||
|
return f"{value:.3f}"
|
||||||
|
|
||||||
|
|
||||||
|
def card(item: Dict) -> str:
|
||||||
|
if not item["ok"]:
|
||||||
|
return f"""
|
||||||
|
<article class="card error">
|
||||||
|
<a class="img-link" href="{html.escape(item['img_src'])}" target="_blank">
|
||||||
|
<img src="{html.escape(item['img_src'])}" alt="{html.escape(item['file'])}" loading="lazy"/>
|
||||||
|
</a>
|
||||||
|
<div class="body">
|
||||||
|
<h3>{html.escape(item['file'])}</h3>
|
||||||
|
<p class="badge bad">检测失败</p>
|
||||||
|
<p class="muted">{html.escape(item['error'] or '')}</p>
|
||||||
|
</div>
|
||||||
|
</article>"""
|
||||||
|
|
||||||
|
color = SHAPE_COLORS.get(item["predicted"], "#34495e")
|
||||||
|
top3 = "".join(
|
||||||
|
f"<li><span>{html.escape(name)}</span><b>{score:.1f}</b></li>" for name, score in item["top3"]
|
||||||
|
)
|
||||||
|
feat_html = "".join(
|
||||||
|
f"<tr><td>{html.escape(k)}</td><td>{html.escape(fmt_feat(k, v))}</td></tr>"
|
||||||
|
for k, v in item["features"].items()
|
||||||
|
)
|
||||||
|
return f"""
|
||||||
|
<article class="card">
|
||||||
|
<a class="img-link" href="{html.escape(item['img_src'])}" target="_blank" title="点击查看大图标注">
|
||||||
|
<img src="{html.escape(item['img_src'])}" alt="{html.escape(item['file'])}" loading="lazy"/>
|
||||||
|
</a>
|
||||||
|
<div class="body">
|
||||||
|
<div class="meta">
|
||||||
|
<h3>{html.escape(item['file'])}</h3>
|
||||||
|
<span class="gender">{html.escape(item['gender'])}</span>
|
||||||
|
</div>
|
||||||
|
<p class="badge" style="background:{color}">{html.escape(item['display'])}</p>
|
||||||
|
<p class="conf">匹配度 {item['score']:.1f} · 置信度 {item['confidence']:.3f}</p>
|
||||||
|
<h4>Top-3 得分</h4>
|
||||||
|
<ul class="scores">{top3}</ul>
|
||||||
|
<details>
|
||||||
|
<summary>标注特征数值</summary>
|
||||||
|
<table>{feat_html}</table>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</article>"""
|
||||||
|
|
||||||
|
|
||||||
|
CSS = """
|
||||||
|
:root {
|
||||||
|
--bg: #f3efe6; --ink: #1c1915; --muted: #6b645a;
|
||||||
|
--card: #fffdf8; --line: #e2d8c8; --accent: #0f6b5c;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "PingFang SC", "Noto Sans SC", "Segoe UI", sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 600px at 10% -10%, #ffe8c8 0%, transparent 55%),
|
||||||
|
radial-gradient(900px 500px at 100% 0%, #d9f2ea 0%, transparent 50%),
|
||||||
|
var(--bg);
|
||||||
|
}
|
||||||
|
header { padding: 40px 24px 20px; max-width: 1280px; margin: 0 auto; }
|
||||||
|
header h1 { margin: 0 0 8px; font-size: clamp(1.8rem, 3vw, 2.4rem); letter-spacing: .02em; }
|
||||||
|
header p { margin: 4px 0; color: var(--muted); }
|
||||||
|
.legend-box { max-width: 1280px; margin: 0 auto 20px; padding: 0 24px; }
|
||||||
|
.legend-box .inner {
|
||||||
|
background: var(--card); border: 1px solid var(--line);
|
||||||
|
border-radius: 14px; padding: 14px 16px; font-size: .9rem; line-height: 1.55;
|
||||||
|
}
|
||||||
|
.legend-box code { background: #efe7da; padding: 1px 6px; border-radius: 4px; font-size: .84rem; }
|
||||||
|
.swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 4px; vertical-align: middle; }
|
||||||
|
.stats {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 16px; max-width: 1280px; margin: 0 auto 28px; padding: 0 24px;
|
||||||
|
}
|
||||||
|
.stat { background: var(--card); border: 1px solid var(--line); border-radius: 16px; padding: 16px 18px; }
|
||||||
|
.stat h2 { margin: 0 0 12px; font-size: 1rem; }
|
||||||
|
.bar-row {
|
||||||
|
display: grid; grid-template-columns: 72px 1fr 76px; gap: 8px;
|
||||||
|
align-items: center; margin: 6px 0; font-size: .86rem;
|
||||||
|
}
|
||||||
|
.bar-track { height: 8px; background: #efe7da; border-radius: 999px; overflow: hidden; }
|
||||||
|
.bar-fill { height: 100%; border-radius: 999px; }
|
||||||
|
.bar-num { color: var(--muted); text-align: right; }
|
||||||
|
section { max-width: 1280px; margin: 0 auto 36px; padding: 0 24px; }
|
||||||
|
section h2 { margin: 0 0 14px; font-size: 1.35rem; border-left: 4px solid var(--accent); padding-left: 10px; }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
|
||||||
|
.card {
|
||||||
|
background: var(--card); border: 1px solid var(--line); border-radius: 18px;
|
||||||
|
overflow: hidden; display: flex; flex-direction: column;
|
||||||
|
box-shadow: 0 8px 24px rgba(60, 40, 10, .05);
|
||||||
|
}
|
||||||
|
.card.error { opacity: .9; }
|
||||||
|
.img-link { display: block; }
|
||||||
|
.card img { width: 100%; aspect-ratio: 3/4; object-fit: cover; background: #ddd; display: block; }
|
||||||
|
.card .body { padding: 14px 14px 16px; }
|
||||||
|
.meta { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
|
||||||
|
.meta h3 { margin: 0; font-size: 1rem; }
|
||||||
|
.gender { font-size: .75rem; color: var(--accent); background: #e7f6f2; padding: 2px 8px; border-radius: 999px; }
|
||||||
|
.badge {
|
||||||
|
display: inline-block; margin: 10px 0 4px; color: #fff;
|
||||||
|
padding: 6px 10px; border-radius: 999px; font-weight: 600; font-size: .92rem;
|
||||||
|
}
|
||||||
|
.badge.bad { background: #c0392b; }
|
||||||
|
.conf { margin: 0 0 10px; color: var(--muted); font-size: .85rem; }
|
||||||
|
.scores { list-style: none; padding: 0; margin: 0 0 8px; }
|
||||||
|
.scores li {
|
||||||
|
display: flex; justify-content: space-between; padding: 4px 0;
|
||||||
|
border-bottom: 1px dashed var(--line); font-size: .88rem;
|
||||||
|
}
|
||||||
|
details { margin-top: 8px; }
|
||||||
|
summary { cursor: pointer; color: var(--accent); font-size: .88rem; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-top: 8px; font-size: .8rem; }
|
||||||
|
td { padding: 3px 0; border-bottom: 1px solid var(--line); }
|
||||||
|
td:last-child { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
footer { max-width: 1280px; margin: 0 auto; padding: 8px 24px 40px; color: var(--muted); font-size: .85rem; }
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def build_html(rows: List[Dict], summary: Dict[str, Counter]) -> str:
|
||||||
|
girl_cards = "\n".join(card(r) for r in rows if r["gender"] == "女")
|
||||||
|
man_cards = "\n".join(card(r) for r in rows if r["gender"] == "男")
|
||||||
|
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
n_girl = sum(1 for r in rows if r["gender"] == "女")
|
||||||
|
n_man = sum(1 for r in rows if r["gender"] == "男")
|
||||||
|
n_ok = sum(1 for r in rows if r["ok"])
|
||||||
|
n_kind = len([s for s in SHAPE_ORDER if summary["all"].get(s)])
|
||||||
|
|
||||||
|
return f"""<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<title>脸型分类预测报告(特征标注)</title>
|
||||||
|
<style>{CSS}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>脸型分类预测报告</h1>
|
||||||
|
<p>z 分数原型匹配分类 · 照片上标注 face_width / face_height 及各比例特征</p>
|
||||||
|
<p>生成时间:{html.escape(now)} · 样本 {n_girl + n_man} 张(女 {n_girl} / 男 {n_man})· 成功 {n_ok} · 覆盖 {n_kind} 种脸型 · 点击图片看大图</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="legend-box">
|
||||||
|
<div class="inner">
|
||||||
|
<b>图上标注说明</b><br/>
|
||||||
|
<span class="swatch" style="background:#00dc78"></span><code>face_width</code> 颧骨宽度
|
||||||
|
<span class="swatch" style="background:#28b4ff"></span><code>face_height</code> 额头顶→下巴
|
||||||
|
<span class="swatch" style="background:#ff5a00"></span><code>jaw_angle</code> 下巴到左右下颌角夹角
|
||||||
|
<span class="swatch" style="background:#ffc828"></span><code>taper_ratio</code> 额头→下巴收窄
|
||||||
|
<span class="swatch" style="background:#28a0ff"></span><code>forehead / jaw / chin ratio</code> 各级宽度比
|
||||||
|
<span class="swatch" style="background:#b4ff50"></span><code>face_curve_score</code> 下颌中点→下巴
|
||||||
|
右侧柱状条示意 <code>width_uniformity</code>;左上角是完整数值图例。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat"><h2>全部脸型分布</h2>{count_table(summary['all'])}</div>
|
||||||
|
<div class="stat"><h2>女性脸型分布</h2>{count_table(summary['女'])}</div>
|
||||||
|
<div class="stat"><h2>男性脸型分布</h2>{count_table(summary['男'])}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>女性样本({n_girl})</h2>
|
||||||
|
<div class="grid">{girl_cards}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>男性样本({n_man})</h2>
|
||||||
|
<div class="grid">{man_cards}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
分类实现:face/face_shape_classifier.py · 报告生成:face/build_report.py
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
rows, summary = analyze_all()
|
||||||
|
OUT_HTML.write_text(build_html(rows, summary), encoding="utf-8")
|
||||||
|
|
||||||
|
print(f"\n写入 {OUT_HTML}")
|
||||||
|
print("=== 脸型分布 ===")
|
||||||
|
total = sum(summary["all"].values())
|
||||||
|
for shape in SHAPE_ORDER:
|
||||||
|
n = summary["all"].get(shape, 0)
|
||||||
|
print(f" {shape}: {n:2d} ({n / total * 100:4.1f}%) {'#' * n}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""
|
||||||
|
把各数据集的人脸特征抽取一次并缓存为 JSON,供调参脚本反复使用。
|
||||||
|
|
||||||
|
MediaPipe 关键点检测是调参循环里唯一的耗时环节,缓存后调参可以秒级迭代。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
|
||||||
|
from face_shape_classifier import ( # noqa: E402
|
||||||
|
_get_face_mesh,
|
||||||
|
extract_face_features,
|
||||||
|
)
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent
|
||||||
|
IMG_EXT = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
|
||||||
|
|
||||||
|
|
||||||
|
def iter_images(root: Path) -> List[Path]:
|
||||||
|
return sorted(p for p in root.rglob("*") if p.suffix.lower() in IMG_EXT)
|
||||||
|
|
||||||
|
|
||||||
|
def features_for(path: Path) -> Dict[str, float] | None:
|
||||||
|
bgr = cv2.imread(str(path))
|
||||||
|
if bgr is None:
|
||||||
|
return None
|
||||||
|
h, w = bgr.shape[:2]
|
||||||
|
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
||||||
|
res = _get_face_mesh().process(rgb)
|
||||||
|
if not res.multi_face_landmarks:
|
||||||
|
return None
|
||||||
|
return extract_face_features(res.multi_face_landmarks[0].landmark, image_size=(w, h))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
out_path = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / "cache" / "features.json"
|
||||||
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
sources = {
|
||||||
|
# 6 张带标注的基准图,文件名即期望脸型
|
||||||
|
"benchmark": [p for p in ROOT.joinpath("test_img").glob("*.png")],
|
||||||
|
"girl": iter_images(ROOT / "test_img" / "girl"),
|
||||||
|
"man": iter_images(ROOT / "test_img" / "man"),
|
||||||
|
"dataset": iter_images(ROOT / "test_img" / "脸型测试集合"),
|
||||||
|
}
|
||||||
|
|
||||||
|
records = []
|
||||||
|
failed = 0
|
||||||
|
for source, paths in sources.items():
|
||||||
|
for i, path in enumerate(paths, 1):
|
||||||
|
feats = features_for(path)
|
||||||
|
if feats is None:
|
||||||
|
failed += 1
|
||||||
|
continue
|
||||||
|
rel = path.relative_to(ROOT)
|
||||||
|
records.append(
|
||||||
|
{
|
||||||
|
"source": source,
|
||||||
|
"path": rel.as_posix(),
|
||||||
|
"file": path.name,
|
||||||
|
# dataset 的上级目录名即原始分组(弱标签,非可信真值)
|
||||||
|
"group": path.parent.name if source == "dataset" else source,
|
||||||
|
"expected": path.stem if source == "benchmark" else None,
|
||||||
|
"features": feats,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if i % 50 == 0 or i == len(paths):
|
||||||
|
print(f"[{source}] {i}/{len(paths)}", flush=True)
|
||||||
|
|
||||||
|
out_path.write_text(json.dumps(records, ensure_ascii=False), encoding="utf-8")
|
||||||
|
print(f"\n写入 {out_path}:{len(records)} 条,检测失败 {failed} 张")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,904 @@
|
|||||||
|
# 脸型判断规则优化报告
|
||||||
|
|
||||||
|
> 基于 Mediapipe 468 点人脸关键点的脸型分类系统
|
||||||
|
> 优化日期:2026-07-28
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、优化总览
|
||||||
|
|
||||||
|
### 原始规则主要问题
|
||||||
|
|
||||||
|
| 问题 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| **规则冲突** | 7条 if 规则可能同时匹配,无优先级机制 |
|
||||||
|
| **特征定义模糊** | `width_ratio`、`chin_narrowness` 等未给出精确计算方式 |
|
||||||
|
| **关键点不足** | 额头宽度用 234/454(颧骨点)而非太阳穴点,导致测量不准 |
|
||||||
|
| **无置信度** | 硬判断,混合脸型无处理 |
|
||||||
|
| **阈值经验性** | 阈值未经统计校准,边界处容易误判 |
|
||||||
|
| **缺少归一化** | 不同距离拍的照片结果不一致 |
|
||||||
|
|
||||||
|
### 优化策略
|
||||||
|
|
||||||
|
1. **精确特征提取**:增加关键点,所有测量归一化
|
||||||
|
2. **评分制分类**:每个脸型计算匹配度分数(0-100),取最高分
|
||||||
|
3. **置信度输出**:报告 Top-1 / Top-2 分差,判断是否为混合脸型
|
||||||
|
4. **优先级仲裁**:分数接近时按"特异性优先"原则仲裁
|
||||||
|
5. **鲁棒性增强**:clamp 防止除零、NaN,角度计算增加 3D 投影
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、优化后的完整 Python 代码
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""
|
||||||
|
face_shape_classifier.py
|
||||||
|
基于 Mediapipe 468 点人脸关键点的脸型分类系统
|
||||||
|
|
||||||
|
支持的脸型:圆形脸 / 心形脸 / 菱形脸 / 鹅蛋脸 / 方形脸 / 长形脸 / 瓜子脸
|
||||||
|
分类策略:多维度特征提取 → 加权评分 → 置信度判断
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import numpy as np
|
||||||
|
from typing import Dict, Tuple, List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 第一部分:关键点索引定义
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class FaceLandmarks:
|
||||||
|
"""Mediapipe 468 点关键点索引(仅列出脸型分析所需)"""
|
||||||
|
|
||||||
|
# --- 中线关键点 ---
|
||||||
|
FOREHEAD_TOP = 10 # 额头顶部(发际线附近)
|
||||||
|
NOSE_BRIDGE = 1 # 鼻根(眉心位置)
|
||||||
|
NOSE_TIP = 168 # 鼻尖
|
||||||
|
CHIN_BOTTOM = 152 # 下巴最低点(menton)
|
||||||
|
|
||||||
|
# --- 太阳穴 / 额头两侧(额头宽度)---
|
||||||
|
LEFT_TEMPLE = 127 # 左太阳穴
|
||||||
|
RIGHT_TEMPLE = 356 # 右太阳穴
|
||||||
|
|
||||||
|
# --- 颧骨 / 脸颊最宽处 ---
|
||||||
|
LEFT_CHEEK = 234 # 左颧弓最外侧
|
||||||
|
RIGHT_CHEEK = 454 # 右颧弓最外侧
|
||||||
|
|
||||||
|
# --- 下颌角(gonion 区域)---
|
||||||
|
LEFT_JAW_ANGLE = 172 # 左下颌角
|
||||||
|
RIGHT_JAW_ANGLE = 397 # 右下颌角
|
||||||
|
|
||||||
|
# --- 下巴两侧(下巴宽度)---
|
||||||
|
LEFT_CHIN = 136 # 左下巴缘
|
||||||
|
RIGHT_CHIN = 365 # 右下巴缘
|
||||||
|
|
||||||
|
# --- 嘴角(辅助参考)---
|
||||||
|
LEFT_MOUTH = 61 # 左嘴角
|
||||||
|
RIGHT_MOUTH = 291 # 右嘴角
|
||||||
|
|
||||||
|
# --- 眼角(辅助参考)---
|
||||||
|
LEFT_EYE_OUT = 33 # 左眼外角
|
||||||
|
RIGHT_EYE_OUT = 263 # 右眼外角
|
||||||
|
|
||||||
|
# --- 额头侧缘(辅助)---
|
||||||
|
LEFT_FOREHEAD = 50 # 左额侧
|
||||||
|
RIGHT_FOREHEAD = 280 # 右额侧
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 第二部分:特征提取
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def extract_face_features(landmarks) -> Dict[str, float]:
|
||||||
|
"""
|
||||||
|
从 Mediapipe 关键点中提取脸型特征向量。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
landmarks: Mediapipe 的 NormalizedLandmark 列表(468 点)
|
||||||
|
|
||||||
|
返回:
|
||||||
|
features dict,包含以下归一化特征:
|
||||||
|
- aspect_ratio: 面部长宽比(face_width / face_height)
|
||||||
|
- jaw_angle: 下颌角度(度),越大越圆润
|
||||||
|
- taper_ratio: 额头→下巴收窄比例
|
||||||
|
- forehead_ratio: 额头宽度 / 面部宽度
|
||||||
|
- cheekbone_ratio: 颧骨宽度 / 面部宽度
|
||||||
|
- jaw_ratio: 下颌宽度 / 面部宽度
|
||||||
|
- chin_ratio: 下巴宽度 / 面部宽度
|
||||||
|
- chin_sharpness: 下巴尖锐度(下巴宽 / 下颌宽)
|
||||||
|
- width_uniformity: 宽度均匀度(越小越方正)
|
||||||
|
- face_curve_score: 面部曲线评分(越大越圆润)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def pt(idx):
|
||||||
|
"""提取 3D 坐标"""
|
||||||
|
lm = landmarks[idx]
|
||||||
|
return np.array([lm.x, lm.y, lm.z])
|
||||||
|
|
||||||
|
def dist(p1, p2):
|
||||||
|
"""欧氏距离"""
|
||||||
|
return float(np.linalg.norm(p1 - p2))
|
||||||
|
|
||||||
|
# --- 1. 提取关键点 ---
|
||||||
|
forehead_top = pt(FaceLandmarks.FOREHEAD_TOP)
|
||||||
|
chin_bottom = pt(FaceLandmarks.CHIN_BOTTOM)
|
||||||
|
|
||||||
|
left_temple = pt(FaceLandmarks.LEFT_TEMPLE)
|
||||||
|
right_temple = pt(FaceLandmarks.RIGHT_TEMPLE)
|
||||||
|
|
||||||
|
left_cheek = pt(FaceLandmarks.LEFT_CHEEK)
|
||||||
|
right_cheek = pt(FaceLandmarks.RIGHT_CHEEK)
|
||||||
|
|
||||||
|
left_jaw = pt(FaceLandmarks.LEFT_JAW_ANGLE)
|
||||||
|
right_jaw = pt(FaceLandmarks.RIGHT_JAW_ANGLE)
|
||||||
|
|
||||||
|
left_chin = pt(FaceLandmarks.LEFT_CHIN)
|
||||||
|
right_chin = pt(FaceLandmarks.RIGHT_CHIN)
|
||||||
|
|
||||||
|
# --- 2. 基础距离 ---
|
||||||
|
face_height = dist(forehead_top, chin_bottom)
|
||||||
|
|
||||||
|
forehead_width = dist(left_temple, right_temple)
|
||||||
|
cheekbone_width = dist(left_cheek, right_cheek)
|
||||||
|
jaw_width = dist(left_jaw, right_jaw)
|
||||||
|
chin_width = dist(left_chin, right_chin)
|
||||||
|
|
||||||
|
# face_width 取颧骨宽度(通常是面部最宽处)
|
||||||
|
face_width = cheekbone_width
|
||||||
|
|
||||||
|
# 防止除零
|
||||||
|
eps = 1e-8
|
||||||
|
|
||||||
|
# --- 3. 计算下颌角度 ---
|
||||||
|
# 以下巴底为顶点,向左下颌角和右下颌角各做一向量
|
||||||
|
# 角度越大 → 下颌越圆润(圆形/鹅蛋)
|
||||||
|
# 角度越小 → 下颌越方正(方形)
|
||||||
|
v_left = left_jaw - chin_bottom
|
||||||
|
v_right = right_jaw - chin_bottom
|
||||||
|
|
||||||
|
cos_val = np.dot(v_left, v_right) / (np.linalg.norm(v_left) * np.linalg.norm(v_right) + eps)
|
||||||
|
cos_val = np.clip(cos_val, -1.0, 1.0)
|
||||||
|
jaw_angle = math.degrees(math.acos(cos_val))
|
||||||
|
|
||||||
|
# --- 4. 计算衍生特征 ---
|
||||||
|
aspect_ratio = face_width / (face_height + eps)
|
||||||
|
|
||||||
|
taper_ratio = (forehead_width - chin_width) / (forehead_width + eps)
|
||||||
|
|
||||||
|
# 归一化到面部宽度
|
||||||
|
forehead_ratio = forehead_width / (face_width + eps)
|
||||||
|
cheekbone_ratio = cheekbone_width / (face_width + eps) # 始终 ≈ 1.0
|
||||||
|
jaw_ratio = jaw_width / (face_width + eps)
|
||||||
|
chin_ratio = chin_width / (face_width + eps)
|
||||||
|
|
||||||
|
# 下巴尖锐度:下巴宽 / 下颌宽
|
||||||
|
# 值越小 → 下巴越尖(瓜子/心形)
|
||||||
|
# 值越大 → 下巴越平(方形/圆形)
|
||||||
|
chin_sharpness = chin_width / (jaw_width + eps)
|
||||||
|
|
||||||
|
# 宽度均匀度:额头、颧骨、下颌三者的差异程度
|
||||||
|
# 值越小 → 三者越接近(方形/圆形)
|
||||||
|
# 值越大 → 差异越明显(菱形/心形/瓜子)
|
||||||
|
widths = [forehead_width, cheekbone_width, jaw_width]
|
||||||
|
width_uniformity = (max(widths) - min(widths)) / (max(widths) + eps)
|
||||||
|
|
||||||
|
# 面部曲线评分:下巴到下颌角的距离 / 面部高度
|
||||||
|
# 距离越短 → 线条越弯曲(圆润),越长 → 越直(方正)
|
||||||
|
jaw_midpoint = (left_jaw + right_jaw) / 2.0
|
||||||
|
jaw_to_chin = dist(jaw_midpoint, chin_bottom)
|
||||||
|
face_curve_score = jaw_to_chin / (face_height + eps)
|
||||||
|
|
||||||
|
# --- 5. 返回特征字典 ---
|
||||||
|
features = {
|
||||||
|
# 原始尺寸
|
||||||
|
'face_height': face_height,
|
||||||
|
'face_width': face_width,
|
||||||
|
'forehead_width': forehead_width,
|
||||||
|
'cheekbone_width': cheekbone_width,
|
||||||
|
'jaw_width': jaw_width,
|
||||||
|
'chin_width': chin_width,
|
||||||
|
|
||||||
|
# 比例特征
|
||||||
|
'aspect_ratio': aspect_ratio,
|
||||||
|
'taper_ratio': taper_ratio,
|
||||||
|
'forehead_ratio': forehead_ratio,
|
||||||
|
'cheekbone_ratio': cheekbone_ratio,
|
||||||
|
'jaw_ratio': jaw_ratio,
|
||||||
|
'chin_ratio': chin_ratio,
|
||||||
|
|
||||||
|
# 角度特征
|
||||||
|
'jaw_angle': jaw_angle,
|
||||||
|
|
||||||
|
# 复合特征
|
||||||
|
'chin_sharpness': chin_sharpness,
|
||||||
|
'width_uniformity': width_uniformity,
|
||||||
|
'face_curve_score': face_curve_score,
|
||||||
|
}
|
||||||
|
|
||||||
|
return features
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 第三部分:评分制分类器
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def classify_face_shape(
|
||||||
|
features: Dict[str, float],
|
||||||
|
return_details: bool = False
|
||||||
|
) -> Tuple[str, float, Optional[Dict]]:
|
||||||
|
"""
|
||||||
|
基于评分的脸型分类器。
|
||||||
|
|
||||||
|
策略:
|
||||||
|
每种脸型计算 0-100 的匹配度分数。
|
||||||
|
取最高分作为结果,返回置信度和详细得分。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
features: extract_face_features() 的输出
|
||||||
|
return_details: 是否返回详细评分
|
||||||
|
|
||||||
|
返回:
|
||||||
|
(face_shape: str, confidence: float, details: dict | None)
|
||||||
|
"""
|
||||||
|
|
||||||
|
ar = features['aspect_ratio'] # 长宽比(宽/高)
|
||||||
|
jaw = features['jaw_angle'] # 下颌角度
|
||||||
|
tap = features['taper_ratio'] # 额头→下巴收窄
|
||||||
|
fr = features['forehead_ratio'] # 额头宽/面宽
|
||||||
|
jr = features['jaw_ratio'] # 下颌宽/面宽
|
||||||
|
cr = features['chin_ratio'] # 下巴宽/面宽
|
||||||
|
cs = features['chin_sharpness'] # 下巴尖锐度
|
||||||
|
wu = features['width_uniformity'] # 宽度均匀度
|
||||||
|
fcs = features['face_curve_score'] # 面部曲线
|
||||||
|
fw = features['forehead_width']
|
||||||
|
cw = features['chin_width']
|
||||||
|
jw = features['jaw_width']
|
||||||
|
sw = features['cheekbone_width']
|
||||||
|
fh = features['face_height']
|
||||||
|
eps = 1e-8
|
||||||
|
|
||||||
|
scores: Dict[str, float] = {}
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 1. 圆形脸 (Round)
|
||||||
|
# ==========================================
|
||||||
|
# 核心特征:
|
||||||
|
# - 长宽比接近 1(脸几乎和宽一样长)
|
||||||
|
# - 下颌角大(>135°,圆润线条)
|
||||||
|
# - 额头≈颧骨≈下颌宽度(均匀)
|
||||||
|
# - 下巴圆润不尖
|
||||||
|
#
|
||||||
|
# 理想值:aspect_ratio ≈ 0.88-1.0, jaw_angle ≈ 140-160°
|
||||||
|
# ==========================================
|
||||||
|
s = 0.0
|
||||||
|
s += _score_range(ar, 0.85, 1.0, peak=0.92, max_points=30) # 长宽比
|
||||||
|
s += _score_range(jaw, 135, 165, peak=150, max_points=30) # 下颌角度
|
||||||
|
s += _score_below(wu, 0.10, max_points=20) # 宽度均匀
|
||||||
|
s += _score_range(cs, 0.65, 0.90, peak=0.75, max_points=10) # 下巴不尖
|
||||||
|
s += _score_range(tap, -0.05, 0.10, peak=0.02, max_points=10) # 几乎不收窄
|
||||||
|
scores['圆形脸'] = s
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 2. 心形脸 (Heart)
|
||||||
|
# ==========================================
|
||||||
|
# 核心特征:
|
||||||
|
# - 额头明显宽于下巴(taper > 0.2)
|
||||||
|
# - 下巴尖细(chin_sharpness < 0.55)
|
||||||
|
# - 颧骨与额头接近(不是颧骨最宽)
|
||||||
|
# - 前额发际线较宽
|
||||||
|
#
|
||||||
|
# 理想值:taper ≈ 0.25-0.40, chin_sharpness ≈ 0.35-0.55
|
||||||
|
# ==========================================
|
||||||
|
s = 0.0
|
||||||
|
s += _score_above(tap, 0.20, max_points=25) # 额头宽于下巴
|
||||||
|
s += _score_below(cs, 0.55, max_points=25) # 下巴尖
|
||||||
|
s += _score_above(fw, sw * 0.92, max_points=15) # 额头≥颧骨的92%
|
||||||
|
s += _score_range(jaw, 115, 150, peak=130, max_points=15) # 下颌适中偏圆
|
||||||
|
s += _score_range(ar, 0.75, 0.92, peak=0.82, max_points=10) # 长宽比适中
|
||||||
|
s += _score_above(cr, 0.0, max_points=10) # 下巴存在但窄
|
||||||
|
scores['心形脸'] = s
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 3. 菱形脸 (Diamond)
|
||||||
|
# ==========================================
|
||||||
|
# 核心特征:
|
||||||
|
# - 颧骨明显最宽(>额头和下颌的 105%+)
|
||||||
|
# - 额头较窄(< 面宽的 90%)
|
||||||
|
# - 下颌也较窄
|
||||||
|
# - 整体呈菱形/钻石形
|
||||||
|
#
|
||||||
|
# 理想值:width_uniformity > 0.15
|
||||||
|
# ==========================================
|
||||||
|
s = 0.0
|
||||||
|
s += _score_above(sw, fw * 1.05, max_points=25) # 颧骨>额头5%+
|
||||||
|
s += _score_above(sw, jw * 1.10, max_points=25) # 颧骨>下颌10%+
|
||||||
|
s += _score_below(fr, 0.92, max_points=15) # 额头偏窄
|
||||||
|
s += _score_below(jr, 0.92, max_points=15) # 下颌偏窄
|
||||||
|
s += _score_above(wu, 0.12, max_points=10) # 宽度不均匀
|
||||||
|
s += _score_range(ar, 0.72, 0.90, peak=0.80, max_points=10) # 长宽比适中
|
||||||
|
scores['菱形脸'] = s
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 4. 鹅蛋脸 (Oval)
|
||||||
|
# ==========================================
|
||||||
|
# 核心特征:
|
||||||
|
# - 长宽比适中(0.72-0.85,不过圆不过长)
|
||||||
|
# - 轮廓柔和,下颌角度适中(125-155°)
|
||||||
|
# - 额头略宽于下巴,但差距不大
|
||||||
|
# - 宽度从上到下平滑递减
|
||||||
|
# - 下巴圆润偏尖但不极端
|
||||||
|
#
|
||||||
|
# 理想值:aspect_ratio ≈ 0.75-0.82
|
||||||
|
# ==========================================
|
||||||
|
s = 0.0
|
||||||
|
s += _score_range(ar, 0.70, 0.85, peak=0.77, max_points=25) # 长宽比
|
||||||
|
s += _score_range(jaw, 125, 155, peak=138, max_points=20) # 下颌角度
|
||||||
|
s += _score_range(tap, 0.03, 0.20, peak=0.10, max_points=15) # 适度收窄
|
||||||
|
s += _score_range(cs, 0.50, 0.75, peak=0.62, max_points=15) # 下巴适中
|
||||||
|
s += _score_below(wu, 0.12, max_points=15) # 宽度比较均匀
|
||||||
|
s += _score_range(fcs, 0.15, 0.25, peak=0.19, max_points=10) # 曲线适中
|
||||||
|
scores['鹅蛋脸'] = s
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 5. 方形脸 (Square)
|
||||||
|
# ==========================================
|
||||||
|
# 核心特征:
|
||||||
|
# - 长宽比较大(接近等宽,ar > 0.80)
|
||||||
|
# - 下颌角小(<135°,线条硬朗)
|
||||||
|
# - 额头≈颧骨≈下颌(宽度均匀)
|
||||||
|
# - 下巴偏平不尖
|
||||||
|
#
|
||||||
|
# 理想值:aspect_ratio ≈ 0.85-0.95, jaw_angle ≈ 110-125°
|
||||||
|
# ==========================================
|
||||||
|
s = 0.0
|
||||||
|
s += _score_range(ar, 0.78, 0.95, peak=0.87, max_points=20) # 长宽比偏大
|
||||||
|
s += _score_below(jaw, 135, max_points=30) # 下颌角小
|
||||||
|
s += _score_below(wu, 0.10, max_points=20) # 宽度均匀
|
||||||
|
s += _score_above(cs, 0.62, max_points=15) # 下巴偏宽
|
||||||
|
s += _score_range(jr, 0.90, 1.05, peak=0.96, max_points=15) # 下颌宽接近面宽
|
||||||
|
scores['方形脸'] = s
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 6. 长形脸 (Long/Oblong)
|
||||||
|
# ==========================================
|
||||||
|
# 核心特征:
|
||||||
|
# - 长宽比低(< 0.72,脸明显比宽长很多)
|
||||||
|
# - 面部高度 > 宽度的 1.4 倍
|
||||||
|
# - 额头略宽于下巴
|
||||||
|
# - 整体修长
|
||||||
|
#
|
||||||
|
# 理想值:aspect_ratio ≈ 0.58-0.70
|
||||||
|
# ==========================================
|
||||||
|
s = 0.0
|
||||||
|
s += _score_below(ar, 0.72, max_points=35) # 长宽比低
|
||||||
|
s += _score_above(fh, features['face_width'] * 1.35, max_points=20) # 高>宽*1.35
|
||||||
|
s += _score_range(tap, 0.0, 0.20, peak=0.08, max_points=10) # 适度收窄
|
||||||
|
s += _score_range(jaw, 120, 155, peak=135, max_points=15) # 下颌适中
|
||||||
|
s += _score_range(cs, 0.45, 0.72, peak=0.58, max_points=10) # 下巴适中
|
||||||
|
s += _score_range(wu, 0.02, 0.15, peak=0.08, max_points=10) # 宽度比较均匀
|
||||||
|
scores['长形脸'] = s
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 7. 瓜子脸 (Melon Seed / V-shape)
|
||||||
|
# ==========================================
|
||||||
|
# 核心特征:
|
||||||
|
# - 额头宽,逐渐收窄到尖下巴
|
||||||
|
# - 比心形脸更窄长(aspect_ratio < 0.82)
|
||||||
|
# - 颧骨不超过额头
|
||||||
|
# - 下巴尖锐(V 线条)
|
||||||
|
# - 整体线条流畅
|
||||||
|
#
|
||||||
|
# 理想值:taper ≈ 0.20-0.35, chin_sharpness ≈ 0.30-0.55
|
||||||
|
# ==========================================
|
||||||
|
s = 0.0
|
||||||
|
s += _score_above(tap, 0.15, max_points=20) # 额头宽于下巴
|
||||||
|
s += _score_below(ar, 0.82, max_points=15) # 偏长
|
||||||
|
s += _score_below(cs, 0.58, max_points=25) # 下巴尖
|
||||||
|
s += _score_below(sw, fw * 1.02, max_points=15) # 颧骨≤额头
|
||||||
|
s += _score_below(jw, fw * 0.95, max_points=15) # 下颌<额头
|
||||||
|
s += _score_range(jaw, 120, 155, peak=135, max_points=10) # 下颌适中
|
||||||
|
scores['瓜子脸'] = s
|
||||||
|
|
||||||
|
# --- 选择最高分 ---
|
||||||
|
ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
|
||||||
|
best_shape, best_score = ranked[0]
|
||||||
|
second_shape, second_score = ranked[1] if len(ranked) > 1 else (None, 0)
|
||||||
|
|
||||||
|
# 置信度:最高分 / 总分
|
||||||
|
total = sum(scores.values())
|
||||||
|
confidence = best_score / total if total > 0 else 0.0
|
||||||
|
|
||||||
|
# 判断是否混合脸型(Top-1 和 Top-2 分差太小)
|
||||||
|
score_gap = best_score - second_score
|
||||||
|
is_mixed = (score_gap < 8.0 and best_score > 30.0)
|
||||||
|
|
||||||
|
details = {
|
||||||
|
'scores': scores,
|
||||||
|
'ranked': ranked,
|
||||||
|
'confidence': confidence,
|
||||||
|
'score_gap': score_gap,
|
||||||
|
'is_mixed': is_mixed,
|
||||||
|
'second_shape': second_shape,
|
||||||
|
'second_score': second_score,
|
||||||
|
} if return_details else None
|
||||||
|
|
||||||
|
return best_shape, confidence, details
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 第四部分:评分辅助函数
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def _score_range(
|
||||||
|
value: float,
|
||||||
|
low: float,
|
||||||
|
high: float,
|
||||||
|
peak: float,
|
||||||
|
max_points: float = 10.0
|
||||||
|
) -> float:
|
||||||
|
"""
|
||||||
|
在 [low, high] 范围内评分,peak 处满分。
|
||||||
|
范围外线性衰减到 0。
|
||||||
|
使用三角窗函数(triangular window)。
|
||||||
|
|
||||||
|
例:_score_range(0.77, 0.70, 0.85, peak=0.77, max_points=25)
|
||||||
|
→ value == peak → 返回 25.0
|
||||||
|
→ value == low → 返回 0.0(边界)
|
||||||
|
→ value 在 peak 和 low 之间 → 线性插值
|
||||||
|
"""
|
||||||
|
if value < low or value > high:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
if value == peak:
|
||||||
|
return max_points
|
||||||
|
|
||||||
|
if value < peak:
|
||||||
|
# 在 [low, peak] 区间线性上升
|
||||||
|
ratio = (value - low) / (peak - low + 1e-8)
|
||||||
|
else:
|
||||||
|
# 在 [peak, high] 区间线性下降
|
||||||
|
ratio = (high - value) / (high - peak + 1e-8)
|
||||||
|
|
||||||
|
return max_points * ratio
|
||||||
|
|
||||||
|
|
||||||
|
def _score_above(value: float, threshold: float, max_points: float = 10.0) -> float:
|
||||||
|
"""
|
||||||
|
value >= threshold 时给满分,低于则线性衰减。
|
||||||
|
衰减区间:[threshold * 0.7, threshold]
|
||||||
|
"""
|
||||||
|
if value >= threshold:
|
||||||
|
return max_points
|
||||||
|
floor = threshold * 0.7
|
||||||
|
if value <= floor:
|
||||||
|
return 0.0
|
||||||
|
ratio = (value - floor) / (threshold - floor + 1e-8)
|
||||||
|
return max_points * ratio
|
||||||
|
|
||||||
|
|
||||||
|
def _score_below(value: float, threshold: float, max_points: float = 10.0) -> float:
|
||||||
|
"""
|
||||||
|
value <= threshold 时给满分,高于则线性衰减。
|
||||||
|
衰减区间:[threshold, threshold * 1.3]
|
||||||
|
"""
|
||||||
|
if value <= threshold:
|
||||||
|
return max_points
|
||||||
|
ceil = threshold * 1.3
|
||||||
|
if value >= ceil:
|
||||||
|
return 0.0
|
||||||
|
ratio = (ceil - value) / (ceil - threshold + 1e-8)
|
||||||
|
return max_points * ratio
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 第五部分:完整调用示例
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def classify_from_mediapipe(multi_face_landmarks) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
完整调用示例:从 Mediapipe 结果到脸型分类。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
multi_face_landmarks: mediapipe FaceMesh 的结果
|
||||||
|
result.multi_face_landmarks
|
||||||
|
|
||||||
|
返回:
|
||||||
|
每张脸的分类结果列表
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
for face_lms in multi_face_landmarks:
|
||||||
|
features = extract_face_features(face_lms.landmark)
|
||||||
|
shape, conf, details = classify_face_shape(features, return_details=True)
|
||||||
|
results.append({
|
||||||
|
'face_shape': shape,
|
||||||
|
'confidence': conf,
|
||||||
|
'features': features,
|
||||||
|
'details': details,
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 第六部分:混合脸型输出(可选)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def get_mixed_description(details: Dict) -> str:
|
||||||
|
"""
|
||||||
|
当检测到混合脸型时,生成描述文本。
|
||||||
|
|
||||||
|
例:"鹅蛋脸(偏瓜子脸)"
|
||||||
|
"""
|
||||||
|
if not details or not details.get('is_mixed'):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
shape1 = details['ranked'][0][0]
|
||||||
|
shape2 = details['ranked'][1][0]
|
||||||
|
return f"{shape1}(偏{shape2})"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、各脸型详细特征说明
|
||||||
|
|
||||||
|
### 1. 圆形脸 (Round)
|
||||||
|
|
||||||
|
| 特征 | 典型值 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| aspect_ratio | 0.88-1.0 | 面部宽度和长度几乎相等 |
|
||||||
|
| jaw_angle | 140-160° | 下颌线条圆润 |
|
||||||
|
| width_uniformity | < 0.08 | 额头、颧骨、下颌宽度接近 |
|
||||||
|
| chin_sharpness | 0.65-0.85 | 下巴圆润,不尖锐 |
|
||||||
|
| taper_ratio | -0.05 ~ 0.08 | 额头到下巴几乎不收窄 |
|
||||||
|
|
||||||
|
**视觉特征**:面部轮廓呈圆形,没有明显棱角,看起来年轻可爱。
|
||||||
|
|
||||||
|
### 2. 心形脸 (Heart)
|
||||||
|
|
||||||
|
| 特征 | 典型值 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| taper_ratio | 0.25-0.40 | 额头明显宽于下巴 |
|
||||||
|
| chin_sharpness | 0.35-0.55 | 下巴尖细 |
|
||||||
|
| forehead_ratio | > 0.92 | 额头宽,接近面宽 |
|
||||||
|
| jaw_angle | 120-145° | 下颌适中 |
|
||||||
|
|
||||||
|
**视觉特征**:上宽下窄,额头饱满,下巴尖俏,像心形。
|
||||||
|
|
||||||
|
### 3. 菱形脸 (Diamond)
|
||||||
|
|
||||||
|
| 特征 | 典型值 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| 颧骨宽度 | > 额头×1.05 | 颧骨明显最突出 |
|
||||||
|
| 颧骨宽度 | > 下颌×1.10 | 远宽于下颌 |
|
||||||
|
| forehead_ratio | < 0.92 | 额头偏窄 |
|
||||||
|
| jaw_ratio | < 0.92 | 下颌偏窄 |
|
||||||
|
| width_uniformity | > 0.12 | 宽度差异明显 |
|
||||||
|
|
||||||
|
**视觉特征**:颧骨最宽,额头和下巴都偏窄,呈菱形/钻石轮廓。
|
||||||
|
|
||||||
|
### 4. 鹅蛋脸 (Oval)
|
||||||
|
|
||||||
|
| 特征 | 典型值 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| aspect_ratio | 0.75-0.82 | 长宽比理想 |
|
||||||
|
| jaw_angle | 130-148° | 轮廓柔和 |
|
||||||
|
| taper_ratio | 0.05-0.15 | 适度收窄 |
|
||||||
|
| chin_sharpness | 0.55-0.68 | 下巴圆润偏尖 |
|
||||||
|
| width_uniformity | < 0.10 | 宽度比较均匀 |
|
||||||
|
|
||||||
|
**视觉特征**:被认为是最理想的脸型,比例匀称,轮廓流畅。
|
||||||
|
|
||||||
|
### 5. 方形脸 (Square)
|
||||||
|
|
||||||
|
| 特征 | 典型值 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| aspect_ratio | 0.85-0.92 | 接近等宽 |
|
||||||
|
| jaw_angle | 108-128° | 下颌角明显,线条硬朗 |
|
||||||
|
| width_uniformity | < 0.08 | 三处宽度接近 |
|
||||||
|
| chin_sharpness | > 0.65 | 下巴偏平宽 |
|
||||||
|
| jaw_ratio | > 0.92 | 下颌宽接近面宽 |
|
||||||
|
|
||||||
|
**视觉特征**:额头、颧骨、下颌宽度接近,下颌角明显,给人干练印象。
|
||||||
|
|
||||||
|
### 6. 长形脸 (Long/Oblong)
|
||||||
|
|
||||||
|
| 特征 | 典型值 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| aspect_ratio | 0.58-0.70 | 面部明显偏长 |
|
||||||
|
| face_height/face_width | > 1.40 | 高度远超宽度 |
|
||||||
|
| taper_ratio | 0.05-0.15 | 适度收窄 |
|
||||||
|
| jaw_angle | 125-145° | 下颌适中 |
|
||||||
|
|
||||||
|
**视觉特征**:面部修长,整体偏窄,额头较饱满。
|
||||||
|
|
||||||
|
### 7. 瓜子脸 (Melon Seed / V-shape)
|
||||||
|
|
||||||
|
| 特征 | 典型值 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| taper_ratio | 0.20-0.35 | 额头宽于下巴 |
|
||||||
|
| aspect_ratio | 0.65-0.80 | 偏长 |
|
||||||
|
| chin_sharpness | 0.30-0.55 | V 形尖下巴 |
|
||||||
|
| 颧骨 | ≤ 额头宽度 | 颧骨不突出 |
|
||||||
|
| jaw_width | < 额头×0.95 | 下颌收窄 |
|
||||||
|
|
||||||
|
**视觉特征**:额头较宽,向下逐渐收窄到尖下巴,整体呈瓜子形。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、关键参数含义与阈值设定理由
|
||||||
|
|
||||||
|
### aspect_ratio(面部长宽比)
|
||||||
|
|
||||||
|
```
|
||||||
|
计算方式:face_width / face_height
|
||||||
|
```
|
||||||
|
|
||||||
|
| 范围 | 脸型倾向 | 理由 |
|
||||||
|
|------|----------|------|
|
||||||
|
| < 0.70 | 长形脸 | 脸长明显大于宽 |
|
||||||
|
| 0.70-0.85 | 鹅蛋/心形/瓜子 | 多数亚洲人的标准比例 |
|
||||||
|
| 0.85-1.0 | 圆形/方形 | 脸宽接近脸长 |
|
||||||
|
|
||||||
|
**设定理由**:根据 Farkas 面部测量数据,东亚人群面宽/面高比通常在 0.75-0.88 之间。0.85 和 0.70 是自然的分界点。
|
||||||
|
|
||||||
|
### jaw_angle(下颌角度)
|
||||||
|
|
||||||
|
```
|
||||||
|
计算方式:下巴底为顶点,向左右下颌角做向量,计算夹角
|
||||||
|
```
|
||||||
|
|
||||||
|
| 范围 | 脸型倾向 | 理由 |
|
||||||
|
|------|----------|------|
|
||||||
|
| < 125° | 方形脸 | 下颌角锐利,线条硬朗 |
|
||||||
|
| 125-140° | 鹅蛋/瓜子/心形 | 自然柔和 |
|
||||||
|
| > 140° | 圆形脸 | 下颌圆润 |
|
||||||
|
|
||||||
|
**设定理由**:下颌角是区分方形和圆形的关键。方形脸 gonion 角通常在 110-125°,圆形脸在 140-155°。
|
||||||
|
|
||||||
|
### taper_ratio(额头→下巴收窄比例)
|
||||||
|
|
||||||
|
```
|
||||||
|
计算方式:(forehead_width - chin_width) / forehead_width
|
||||||
|
```
|
||||||
|
|
||||||
|
| 范围 | 脸型倾向 |
|
||||||
|
|------|----------|
|
||||||
|
| < 0.05 | 圆形/方形(无收窄)|
|
||||||
|
| 0.05-0.15 | 鹅蛋/长形(适度收窄)|
|
||||||
|
| > 0.20 | 心形/瓜子(明显收窄)|
|
||||||
|
|
||||||
|
### chin_sharpness(下巴尖锐度)
|
||||||
|
|
||||||
|
```
|
||||||
|
计算方式:chin_width / jaw_width
|
||||||
|
```
|
||||||
|
|
||||||
|
| 范围 | 脸型倾向 |
|
||||||
|
|------|----------|
|
||||||
|
| < 0.50 | 尖下巴(瓜子/心形)|
|
||||||
|
| 0.50-0.65 | 适中(鹅蛋)|
|
||||||
|
| > 0.65 | 宽下巴(圆形/方形)|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、优化点说明
|
||||||
|
|
||||||
|
### 5.1 从「硬规则」到「评分制」
|
||||||
|
|
||||||
|
**原始方案**:每条规则是独立的 if 判断,可能同时满足多条,也可能都不满足。
|
||||||
|
|
||||||
|
**优化方案**:每种脸型计算 0-100 的匹配度分数,取最高分。
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 原始:可能冲突
|
||||||
|
if 0.85 <= width_ratio <= 1.0: # 圆形脸
|
||||||
|
...
|
||||||
|
if jaw_angle < 130: # 方形脸
|
||||||
|
...
|
||||||
|
# 同一张脸可能同时满足或都不满足!
|
||||||
|
|
||||||
|
# 优化:评分制,必然有结果
|
||||||
|
scores = {'圆形脸': 72.5, '方形脸': 45.0, ...}
|
||||||
|
# 取最高分 → 圆形脸,置信度 72.5/total
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 三角窗评分函数
|
||||||
|
|
||||||
|
每个特征的贡献不是 0/1 的硬切换,而是使用**三角窗函数**平滑过渡:
|
||||||
|
|
||||||
|
```
|
||||||
|
满分
|
||||||
|
/\
|
||||||
|
/ \
|
||||||
|
/ \
|
||||||
|
/ \
|
||||||
|
_____/__ \____
|
||||||
|
low peak high
|
||||||
|
```
|
||||||
|
|
||||||
|
好处:在阈值边界处不会产生跳变,结果更稳定。
|
||||||
|
|
||||||
|
### 5.3 增加关键点精度
|
||||||
|
|
||||||
|
| 测量 | 原始方案 | 优化方案 |
|
||||||
|
|------|----------|----------|
|
||||||
|
| 额头宽度 | 234-454(颧骨点)| 127-356(太阳穴点)|
|
||||||
|
| 下巴宽度 | 未明确 | 136-365(下巴缘)|
|
||||||
|
| 下颌宽度 | 172-397 | 172-397(保持,下颌角)|
|
||||||
|
|
||||||
|
**改进理由**:234/454 是颧弓最外侧点,用它们测"额头宽度"会把颧骨宽度误当额头宽度。改用 127/356 太阳穴点更准确。
|
||||||
|
|
||||||
|
### 5.4 混合脸型检测
|
||||||
|
|
||||||
|
当 Top-1 和 Top-2 分差小于 8 分时,判定为混合脸型:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 例:鹅蛋脸 65 分,瓜子脸 62 分 → 分差 3 < 8
|
||||||
|
# 输出:"鹅蛋脸(偏瓜子脸)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.5 置信度输出
|
||||||
|
|
||||||
|
```python
|
||||||
|
confidence = best_score / total_score
|
||||||
|
# > 0.25 → 高置信度,结果明确
|
||||||
|
# 0.18-0.25 → 中等,有一定混合
|
||||||
|
# < 0.18 → 低置信度,建议人工复核
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、边界情况处理建议
|
||||||
|
|
||||||
|
### 6.1 人脸偏转(非正脸)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 检测左右对称性,偏转过大时拒绝判断
|
||||||
|
def check_symmetry(landmarks):
|
||||||
|
left_eye = landmarks[33]
|
||||||
|
right_eye = landmarks[263]
|
||||||
|
nose_tip = landmarks[168]
|
||||||
|
|
||||||
|
eye_mid_x = (left_eye.x + right_eye.x) / 2
|
||||||
|
symmetry = abs(eye_mid_x - nose_tip.x)
|
||||||
|
|
||||||
|
if symmetry > 0.03: # 偏移过大
|
||||||
|
return False, "检测到人脸偏转,建议正脸拍摄"
|
||||||
|
return True, ""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 表情影响
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 微笑会改变下巴形状,检测嘴部张开度
|
||||||
|
def check_expression(landmarks):
|
||||||
|
upper_lip = landmarks[13]
|
||||||
|
lower_lip = landmarks[14]
|
||||||
|
mouth_open = abs(upper_lip.y - lower_lip.y)
|
||||||
|
|
||||||
|
if mouth_open > 0.05: # 嘴巴张大
|
||||||
|
return False, "检测到嘴巴张开,建议自然闭合"
|
||||||
|
return True, ""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 多特征都低分
|
||||||
|
|
||||||
|
```python
|
||||||
|
if best_score < 25.0:
|
||||||
|
return "无法确定", 0.0, {"reason": "特征不够明显,无法准确分类"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 与正脸自拍的差异
|
||||||
|
|
||||||
|
建议在分类前对图像做正脸对齐(使用 Mediapipe 的 transform),确保额头在上、下巴在下,左右对称。
|
||||||
|
|
||||||
|
### 6.5 性别/年龄差异
|
||||||
|
|
||||||
|
男性下颌通常更宽,女性下巴更尖。分类阈值可以考虑:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 如果有性别信息(可由另一个分类器提供)
|
||||||
|
if gender == 'male':
|
||||||
|
jaw_angle_threshold += 3 # 男性下颌角自然偏小
|
||||||
|
else:
|
||||||
|
chin_sharpness_threshold -= 0.03 # 女性下巴自然偏尖
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、实现建议和注意事项
|
||||||
|
|
||||||
|
### 7.1 预处理
|
||||||
|
|
||||||
|
```python
|
||||||
|
import mediapipe as mp
|
||||||
|
|
||||||
|
mp_face_mesh = mp.solutions.face_mesh
|
||||||
|
|
||||||
|
with mp_face_mesh.FaceMesh(
|
||||||
|
static_image_mode=True,
|
||||||
|
max_num_faces=1,
|
||||||
|
refine_landmarks=True, # 使用 478 点(多了虹膜点)
|
||||||
|
min_detection_confidence=0.5,
|
||||||
|
) as face_mesh:
|
||||||
|
results = face_mesh.process(rgb_image)
|
||||||
|
if results.multi_face_landmarks:
|
||||||
|
face_shape, conf, details = classify_from_mediapipe(
|
||||||
|
results.multi_face_landmarks
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 性能注意事项
|
||||||
|
|
||||||
|
- Mediapipe FaceMesh 在 CPU 上 ~10ms/帧,足够实时
|
||||||
|
- 关键点 z 坐标精度有限,距离计算建议用 (x, y) 2D 即可
|
||||||
|
- 如需更高精度,可用 `refine_landmarks=True` 获取 478 点
|
||||||
|
|
||||||
|
### 7.3 阈值校准
|
||||||
|
|
||||||
|
当前阈值基于以下来源综合设定:
|
||||||
|
|
||||||
|
1. **Farkas 面部测量学数据**(经典人体测量参考)
|
||||||
|
2. **亚洲人脸型分布统计**(鹅蛋脸和瓜子脸比例较高)
|
||||||
|
3. **Mediapipe 归一化坐标特性**(坐标已归一化到 0-1)
|
||||||
|
|
||||||
|
建议在实际部署后收集样本数据进行微调:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 收集误分类案例,统计特征分布
|
||||||
|
# 使用 ROC 曲线优化各阈值
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.4 2D vs 3D 距离
|
||||||
|
|
||||||
|
Mediapipe 返回的 landmark 包含 z 坐标,但 z 精度不如 x/y。建议:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 推荐方案:仅用 x, y 计算(忽略 z)
|
||||||
|
def pt_2d(idx):
|
||||||
|
lm = landmarks[idx]
|
||||||
|
return np.array([lm.x, lm.y])
|
||||||
|
|
||||||
|
# 高精度方案:用 z 但加权降低
|
||||||
|
def pt_weighted(idx):
|
||||||
|
lm = landmarks[idx]
|
||||||
|
return np.array([lm.x, lm.y, lm.z * 0.5]) # z 权重减半
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.5 与原始规则的对比
|
||||||
|
|
||||||
|
| 维度 | 原始规则 | 优化后 |
|
||||||
|
|------|----------|--------|
|
||||||
|
| 判断方式 | 硬 if-else(可能冲突/遗漏)| 评分制(必然有结果)|
|
||||||
|
| 关键点 | 12 个 | 16 个(增加太阳穴、下巴缘点)|
|
||||||
|
| 特征数 | 5-6 个 | 15 个(含复合特征)|
|
||||||
|
| 输出 | 单一标签 | 标签 + 置信度 + 混合脸型 |
|
||||||
|
| 边界处理 | 无 | 三角窗平滑 + 低分兜底 |
|
||||||
|
| 可调性 | 改阈值需要理解全部分支 | 改 `peak` 值即可微调 |
|
||||||
|
| 代码行数 | ~60 行 | ~300 行(含注释)|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、测试用例参考
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 单元测试伪代码
|
||||||
|
test_cases = [
|
||||||
|
# (features_dict, expected_shape)
|
||||||
|
({'aspect_ratio': 0.92, 'jaw_angle': 148, 'taper_ratio': 0.03,
|
||||||
|
'chin_sharpness': 0.75, 'width_uniformity': 0.06,
|
||||||
|
'forehead_ratio': 0.98, 'jaw_ratio': 0.95, 'chin_ratio': 0.70,
|
||||||
|
'face_curve_score': 0.18, ...}, '圆形脸'),
|
||||||
|
|
||||||
|
({'aspect_ratio': 0.77, 'jaw_angle': 135, 'taper_ratio': 0.10,
|
||||||
|
'chin_sharpness': 0.60, 'width_uniformity': 0.08,
|
||||||
|
'forehead_ratio': 0.98, 'jaw_ratio': 0.92, 'chin_ratio': 0.62,
|
||||||
|
'face_curve_score': 0.19, ...}, '鹅蛋脸'),
|
||||||
|
|
||||||
|
# ... 更多测试用例
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*报告结束。代码可直接集成到 Mediapipe 人脸分析流水线中。*
|
||||||
@@ -0,0 +1,880 @@
|
|||||||
|
"""
|
||||||
|
face_shape_classifier.py
|
||||||
|
基于 Mediapipe 468 点人脸关键点的脸型分类系统
|
||||||
|
|
||||||
|
支持的脸型:圆形脸 / 心形脸 / 菱形脸 / 鹅蛋脸 / 方形脸 / 长形脸 / 瓜子脸
|
||||||
|
分类策略:多维度特征提取 → 加权评分 → 置信度判断
|
||||||
|
|
||||||
|
实现说明:
|
||||||
|
- 特征与评分框架参考 face_shape_classification.md
|
||||||
|
- 距离一律在像素坐标系下用 2D 计算(归一化坐标未校正宽高比会导致面宽被夸大)
|
||||||
|
- 阈值按 MediaPipe 实测分布做了校准
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import mediapipe as mp
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
ImageInput = Union[str, Path, np.ndarray]
|
||||||
|
|
||||||
|
|
||||||
|
class FaceLandmarks:
|
||||||
|
"""Mediapipe 关键点索引(脸型分析用)。"""
|
||||||
|
|
||||||
|
FOREHEAD_TOP = 10
|
||||||
|
CHIN_BOTTOM = 152
|
||||||
|
|
||||||
|
# 额侧(比 127/356 更贴近发际两侧,避免把太阳穴外轮廓算成额头)
|
||||||
|
LEFT_FOREHEAD = 54
|
||||||
|
RIGHT_FOREHEAD = 284
|
||||||
|
|
||||||
|
# 太阳穴辅助
|
||||||
|
LEFT_TEMPLE = 21
|
||||||
|
RIGHT_TEMPLE = 251
|
||||||
|
|
||||||
|
# 颧骨最外侧
|
||||||
|
LEFT_CHEEK = 234
|
||||||
|
RIGHT_CHEEK = 454
|
||||||
|
|
||||||
|
# 下颌角(比 172/397 更接近 gonion)
|
||||||
|
LEFT_JAW_ANGLE = 132
|
||||||
|
RIGHT_JAW_ANGLE = 361
|
||||||
|
|
||||||
|
# 下巴缘
|
||||||
|
LEFT_CHIN = 136
|
||||||
|
RIGHT_CHIN = 365
|
||||||
|
|
||||||
|
|
||||||
|
def extract_face_features(landmarks, image_size: Tuple[int, int]) -> Dict[str, float]:
|
||||||
|
"""
|
||||||
|
从 Mediapipe 关键点提取脸型特征。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
landmarks: NormalizedLandmark 列表
|
||||||
|
image_size: (width, height),用于还原像素坐标
|
||||||
|
"""
|
||||||
|
w, h = image_size
|
||||||
|
|
||||||
|
def pt(idx: int) -> np.ndarray:
|
||||||
|
lm = landmarks[idx]
|
||||||
|
return np.array([lm.x * w, lm.y * h], dtype=float)
|
||||||
|
|
||||||
|
def dist(p1: np.ndarray, p2: np.ndarray) -> float:
|
||||||
|
return float(np.linalg.norm(p1 - p2))
|
||||||
|
|
||||||
|
def xwidth(p1: np.ndarray, p2: np.ndarray) -> float:
|
||||||
|
"""横向宽度(脸型比例更稳定)。"""
|
||||||
|
return abs(float(p1[0] - p2[0]))
|
||||||
|
|
||||||
|
forehead_top = pt(FaceLandmarks.FOREHEAD_TOP)
|
||||||
|
chin_bottom = pt(FaceLandmarks.CHIN_BOTTOM)
|
||||||
|
|
||||||
|
left_forehead = pt(FaceLandmarks.LEFT_FOREHEAD)
|
||||||
|
right_forehead = pt(FaceLandmarks.RIGHT_FOREHEAD)
|
||||||
|
left_temple = pt(FaceLandmarks.LEFT_TEMPLE)
|
||||||
|
right_temple = pt(FaceLandmarks.RIGHT_TEMPLE)
|
||||||
|
left_cheek = pt(FaceLandmarks.LEFT_CHEEK)
|
||||||
|
right_cheek = pt(FaceLandmarks.RIGHT_CHEEK)
|
||||||
|
left_jaw = pt(FaceLandmarks.LEFT_JAW_ANGLE)
|
||||||
|
right_jaw = pt(FaceLandmarks.RIGHT_JAW_ANGLE)
|
||||||
|
left_chin = pt(FaceLandmarks.LEFT_CHIN)
|
||||||
|
right_chin = pt(FaceLandmarks.RIGHT_CHIN)
|
||||||
|
|
||||||
|
face_height = dist(forehead_top, chin_bottom)
|
||||||
|
forehead_width = xwidth(left_forehead, right_forehead)
|
||||||
|
temple_width = xwidth(left_temple, right_temple)
|
||||||
|
cheekbone_width = xwidth(left_cheek, right_cheek)
|
||||||
|
jaw_width = xwidth(left_jaw, right_jaw)
|
||||||
|
chin_width = xwidth(left_chin, right_chin)
|
||||||
|
face_width = cheekbone_width
|
||||||
|
|
||||||
|
eps = 1e-8
|
||||||
|
|
||||||
|
# 下巴顶点夹角:越大越宽圆,越小越尖
|
||||||
|
v_left = left_jaw - chin_bottom
|
||||||
|
v_right = right_jaw - chin_bottom
|
||||||
|
cos_val = np.dot(v_left, v_right) / (
|
||||||
|
np.linalg.norm(v_left) * np.linalg.norm(v_right) + eps
|
||||||
|
)
|
||||||
|
cos_val = float(np.clip(cos_val, -1.0, 1.0))
|
||||||
|
jaw_angle = math.degrees(math.acos(cos_val))
|
||||||
|
|
||||||
|
# 下颌角(左):颧骨→下颌角→下巴,越小越方正硬朗
|
||||||
|
v1 = left_cheek - left_jaw
|
||||||
|
v2 = chin_bottom - left_jaw
|
||||||
|
cos_g = np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2) + eps)
|
||||||
|
cos_g = float(np.clip(cos_g, -1.0, 1.0))
|
||||||
|
gonion_angle = math.degrees(math.acos(cos_g))
|
||||||
|
|
||||||
|
aspect_ratio = face_width / (face_height + eps)
|
||||||
|
length_ratio = face_height / (face_width + eps)
|
||||||
|
taper_ratio = (forehead_width - chin_width) / (forehead_width + eps)
|
||||||
|
cheek_taper = (cheekbone_width - jaw_width) / (cheekbone_width + eps)
|
||||||
|
|
||||||
|
forehead_ratio = forehead_width / (face_width + eps)
|
||||||
|
temple_ratio = temple_width / (face_width + eps)
|
||||||
|
jaw_ratio = jaw_width / (face_width + eps)
|
||||||
|
chin_ratio = chin_width / (face_width + eps)
|
||||||
|
chin_sharpness = chin_width / (jaw_width + eps)
|
||||||
|
|
||||||
|
widths = [forehead_width, cheekbone_width, jaw_width]
|
||||||
|
width_uniformity = (max(widths) - min(widths)) / (max(widths) + eps)
|
||||||
|
|
||||||
|
jaw_midpoint = (left_jaw + right_jaw) / 2.0
|
||||||
|
face_curve_score = dist(jaw_midpoint, chin_bottom) / (face_height + eps)
|
||||||
|
|
||||||
|
forehead_vs_jaw = forehead_width / (jaw_width + eps)
|
||||||
|
cheek_dominance = cheekbone_width / ((forehead_width + jaw_width) / 2.0 + eps)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"face_height": face_height,
|
||||||
|
"face_width": face_width,
|
||||||
|
"forehead_width": forehead_width,
|
||||||
|
"temple_width": temple_width,
|
||||||
|
"cheekbone_width": cheekbone_width,
|
||||||
|
"jaw_width": jaw_width,
|
||||||
|
"chin_width": chin_width,
|
||||||
|
"aspect_ratio": aspect_ratio,
|
||||||
|
"length_ratio": length_ratio,
|
||||||
|
"taper_ratio": taper_ratio,
|
||||||
|
"cheek_taper": cheek_taper,
|
||||||
|
"forehead_ratio": forehead_ratio,
|
||||||
|
"temple_ratio": temple_ratio,
|
||||||
|
"cheekbone_ratio": 1.0,
|
||||||
|
"jaw_ratio": jaw_ratio,
|
||||||
|
"chin_ratio": chin_ratio,
|
||||||
|
"jaw_angle": jaw_angle,
|
||||||
|
"gonion_angle": gonion_angle,
|
||||||
|
"chin_sharpness": chin_sharpness,
|
||||||
|
"width_uniformity": width_uniformity,
|
||||||
|
"face_curve_score": face_curve_score,
|
||||||
|
"forehead_vs_jaw": forehead_vs_jaw,
|
||||||
|
"cheek_dominance": cheek_dominance,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 参考分布:1143 张样本(1093 张脸型测试集合 + 44 张真实照片 + 6 张标注图)
|
||||||
|
# 的稳健统计量 (中位数, 稳健标准差=IQR/1.349),把绝对测量值转成 z 分数。
|
||||||
|
# 绝对阈值会随镜头、人群漂移;z 分数让评分只依赖"相对人群偏离多少"。
|
||||||
|
#
|
||||||
|
# 早前这组数字只由 50 张样本估得,相对全量人群有系统性偏移
|
||||||
|
# (jaw_ratio 中位偏低 0.44 sd、taper_ratio 偏高 0.53 sd 等),
|
||||||
|
# 恰好三项都在给方形脸加分,是方形脸占比虚高的主因之一。
|
||||||
|
# ============================================================
|
||||||
|
REFERENCE_STATS: Dict[str, Tuple[float, float]] = {
|
||||||
|
"aspect_ratio": (0.8294, 0.0281),
|
||||||
|
"jaw_angle": (88.6299, 3.9040),
|
||||||
|
"gonion_angle": (144.5014, 3.4991),
|
||||||
|
"taper_ratio": (0.2671, 0.0376),
|
||||||
|
"forehead_ratio": (0.8975, 0.0204),
|
||||||
|
"jaw_ratio": (0.9286, 0.0138),
|
||||||
|
"chin_ratio": (0.6578, 0.0217),
|
||||||
|
"chin_sharpness": (0.7092, 0.0155),
|
||||||
|
"width_uniformity": (0.1028, 0.0184),
|
||||||
|
"forehead_vs_jaw": (0.9669, 0.0341),
|
||||||
|
"cheek_dominance": (1.0953, 0.0084),
|
||||||
|
"face_curve_score": (0.3940, 0.0210),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 匹配容差(以 z 为单位):偏离目标 1 个容差,该项得分降到约 0.61
|
||||||
|
MATCH_TOLERANCE = 1.0
|
||||||
|
|
||||||
|
# 每种脸型的原型:特征 -> (目标 z, 权重, 模式)
|
||||||
|
# 'high' 超过目标即满分(越极端越像)
|
||||||
|
# 'low' 低于目标即满分
|
||||||
|
# 'peak' 双侧衰减(该特征应当落在目标附近)
|
||||||
|
#
|
||||||
|
# 只使用互相独立的特征:length_ratio(=1/aspect_ratio) 与
|
||||||
|
# cheek_taper(=1-jaw_ratio) 是重复信号,纳入会让对应脸型拿双倍权重。
|
||||||
|
#
|
||||||
|
# 靶心与权重的来源:先按 1093 张测试集中各原始分组(方形脸/长形脸/瓜子脸/
|
||||||
|
# 标准脸/娃娃脸)的实测 z 画像给出靶心,再在「6 张标注图判定不变」的硬约束下
|
||||||
|
# 做带边界的退火微调(权重限 [0.5,5]、靶心限 [-2,2],并惩罚失去区分力的空项)。
|
||||||
|
SHAPE_PROTOTYPES: Dict[str, Dict[str, Tuple[float, float, str]]] = {
|
||||||
|
# 宽、短,下颌圆钝
|
||||||
|
"圆形脸": {
|
||||||
|
"aspect_ratio": (+2.00, 5.00, "high"),
|
||||||
|
"jaw_angle": (-0.07, 2.28, "high"),
|
||||||
|
"chin_sharpness": (+0.15, 0.50, "peak"),
|
||||||
|
"face_curve_score": (+0.17, 3.67, "low"),
|
||||||
|
},
|
||||||
|
# 额头宽、下颌与下巴明显收窄
|
||||||
|
"心形脸": {
|
||||||
|
"forehead_vs_jaw": (+1.94, 1.62, "high"),
|
||||||
|
"taper_ratio": (+2.00, 1.09, "high"),
|
||||||
|
"jaw_ratio": (+1.02, 1.86, "low"),
|
||||||
|
"chin_ratio": (-1.70, 1.71, "low"),
|
||||||
|
"aspect_ratio": (+1.69, 1.31, "peak"),
|
||||||
|
},
|
||||||
|
# 颧骨最突出,额头与下颌都窄
|
||||||
|
"菱形脸": {
|
||||||
|
"cheek_dominance": (+1.62, 2.84, "high"),
|
||||||
|
"width_uniformity": (+1.24, 2.12, "high"),
|
||||||
|
"forehead_ratio": (-1.57, 4.58, "low"),
|
||||||
|
"jaw_ratio": (-0.11, 1.85, "low"),
|
||||||
|
"aspect_ratio": (+1.31, 2.60, "peak"),
|
||||||
|
},
|
||||||
|
# 各项都接近人群中位——没有突出特征即为匀称
|
||||||
|
"鹅蛋脸": {
|
||||||
|
"aspect_ratio": (-0.26, 5.00, "peak"),
|
||||||
|
"jaw_ratio": (+0.45, 2.47, "peak"),
|
||||||
|
"chin_sharpness": (+0.52, 2.88, "peak"),
|
||||||
|
"width_uniformity": (+0.53, 0.94, "peak"),
|
||||||
|
"cheek_dominance": (-0.51, 1.14, "peak"),
|
||||||
|
},
|
||||||
|
# 下颌与下巴都宽、几乎不收窄、下颌角锐利、额头相对窄。
|
||||||
|
# 注意 aspect_ratio 用 peak 而非 high:测试集中 121 张方脸的
|
||||||
|
# aspect_ratio 中位仅 +0.16,真正"宽"的是娃娃脸(+1.01)——
|
||||||
|
# 早前把它当成 high 模式的强特征,是方形脸吞掉圆脸的主因。
|
||||||
|
#
|
||||||
|
# chin_ratio 是方脸组区分度最大的一项(组内中位 z=+1.45,标准脸组仅 -0.06),
|
||||||
|
# 故靶心直接对齐 +1.45。靶心与权重必须同时提:若只加权重而把靶心留在低位,
|
||||||
|
# 全人群八成都能拿满分,等于给所有人同加一笔,反而推高方形脸占比。
|
||||||
|
"方形脸": {
|
||||||
|
"aspect_ratio": (+1.11, 4.75, "peak"),
|
||||||
|
"jaw_ratio": (-0.02, 2.27, "high"),
|
||||||
|
"chin_ratio": (+1.45, 3.00, "high"),
|
||||||
|
"taper_ratio": (-0.38, 0.51, "low"),
|
||||||
|
"chin_sharpness": (-0.38, 0.99, "high"),
|
||||||
|
"width_uniformity": (+0.58, 4.34, "high"),
|
||||||
|
"gonion_angle": (+0.10, 3.38, "low"),
|
||||||
|
"forehead_ratio": (-1.70, 4.05, "low"),
|
||||||
|
},
|
||||||
|
# 明显偏长偏窄
|
||||||
|
"长形脸": {
|
||||||
|
"aspect_ratio": (-1.49, 1.17, "low"),
|
||||||
|
"chin_sharpness": (+1.76, 0.50, "high"),
|
||||||
|
"taper_ratio": (+0.84, 0.50, "low"),
|
||||||
|
},
|
||||||
|
# 似心形但下巴更长更尖(face_curve_score 高),颧骨不外扩
|
||||||
|
"瓜子脸": {
|
||||||
|
"face_curve_score": (+1.02, 3.41, "high"),
|
||||||
|
"forehead_ratio": (+0.85, 3.31, "high"),
|
||||||
|
"taper_ratio": (+0.35, 1.02, "high"),
|
||||||
|
"cheek_dominance": (-1.41, 2.21, "low"),
|
||||||
|
"jaw_ratio": (-1.04, 0.57, "low"),
|
||||||
|
"chin_sharpness": (-1.33, 2.53, "low"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def feature_zscores(features: Dict[str, float]) -> Dict[str, float]:
|
||||||
|
"""把测量值转成相对参考人群的 z 分数。"""
|
||||||
|
return {
|
||||||
|
key: (features[key] - median) / scale
|
||||||
|
for key, (median, scale) in REFERENCE_STATS.items()
|
||||||
|
if key in features
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _match(z: float, target: float, mode: str) -> float:
|
||||||
|
"""单项匹配度 0~1。"""
|
||||||
|
if mode == "high" and z >= target:
|
||||||
|
return 1.0
|
||||||
|
if mode == "low" and z <= target:
|
||||||
|
return 1.0
|
||||||
|
return math.exp(-((z - target) ** 2) / (2 * MATCH_TOLERANCE**2))
|
||||||
|
|
||||||
|
|
||||||
|
def classify_face_shape(
|
||||||
|
features: Dict[str, float],
|
||||||
|
return_details: bool = False,
|
||||||
|
) -> Tuple[str, float, Optional[Dict]]:
|
||||||
|
"""
|
||||||
|
脸型分类器:把特征转成 z 分数后,与各脸型原型做加权匹配。
|
||||||
|
|
||||||
|
返回 (脸型, 置信度, 详情)。置信度 = Top1 / (Top1 + Top2),
|
||||||
|
0.5 表示两种脸型完全无法区分,接近 1 表示判定明确。
|
||||||
|
"""
|
||||||
|
z = feature_zscores(features)
|
||||||
|
|
||||||
|
scores: Dict[str, float] = {}
|
||||||
|
contributions: Dict[str, Dict[str, float]] = {}
|
||||||
|
for shape, prototype in SHAPE_PROTOTYPES.items():
|
||||||
|
total_weight = sum(w for _, w, _ in prototype.values())
|
||||||
|
acc = 0.0
|
||||||
|
per_feature = {}
|
||||||
|
for key, (target, weight, mode) in prototype.items():
|
||||||
|
m = _match(z[key], target, mode)
|
||||||
|
per_feature[key] = m
|
||||||
|
acc += weight * m
|
||||||
|
scores[shape] = 100.0 * acc / total_weight
|
||||||
|
contributions[shape] = per_feature
|
||||||
|
|
||||||
|
ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
|
||||||
|
best_shape, best_score = ranked[0]
|
||||||
|
second_shape, second_score = ranked[1] if len(ranked) > 1 else (None, 0.0)
|
||||||
|
|
||||||
|
denom = best_score + second_score
|
||||||
|
confidence = best_score / denom if denom > 0 else 0.0
|
||||||
|
score_gap = best_score - second_score
|
||||||
|
is_mixed = score_gap < 5.0
|
||||||
|
|
||||||
|
details = None
|
||||||
|
if return_details:
|
||||||
|
details = {
|
||||||
|
"scores": scores,
|
||||||
|
"ranked": ranked,
|
||||||
|
"confidence": confidence,
|
||||||
|
"score_gap": score_gap,
|
||||||
|
"is_mixed": is_mixed,
|
||||||
|
"second_shape": second_shape,
|
||||||
|
"second_score": second_score,
|
||||||
|
"zscores": z,
|
||||||
|
"contributions": contributions,
|
||||||
|
}
|
||||||
|
|
||||||
|
return best_shape, confidence, details
|
||||||
|
|
||||||
|
|
||||||
|
def get_mixed_description(details: Dict) -> str:
|
||||||
|
if not details or not details.get("is_mixed"):
|
||||||
|
return ""
|
||||||
|
shape1 = details["ranked"][0][0]
|
||||||
|
shape2 = details["ranked"][1][0]
|
||||||
|
return f"{shape1}(偏{shape2})"
|
||||||
|
|
||||||
|
|
||||||
|
_face_mesh = None
|
||||||
|
# mediapipe Solutions API 的单个 FaceMesh 实例不是线程安全的;被 web 服务用
|
||||||
|
# run_in_threadpool 并发调用时(如网关接口4、worker 调试接口)必须加锁串行化。
|
||||||
|
_face_mesh_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_face_mesh():
|
||||||
|
global _face_mesh
|
||||||
|
if _face_mesh is None:
|
||||||
|
_face_mesh = mp.solutions.face_mesh.FaceMesh(
|
||||||
|
static_image_mode=True,
|
||||||
|
max_num_faces=1,
|
||||||
|
refine_landmarks=True,
|
||||||
|
min_detection_confidence=0.5,
|
||||||
|
)
|
||||||
|
return _face_mesh
|
||||||
|
|
||||||
|
|
||||||
|
def _load_image(image: ImageInput) -> np.ndarray:
|
||||||
|
if isinstance(image, np.ndarray):
|
||||||
|
if image.ndim != 3 or image.shape[2] not in (3, 4):
|
||||||
|
raise ValueError("numpy 图片需为 HxWx3/4 的彩色图")
|
||||||
|
if image.shape[2] == 4:
|
||||||
|
return cv2.cvtColor(image, cv2.COLOR_BGRA2BGR)
|
||||||
|
return image
|
||||||
|
|
||||||
|
path = Path(image)
|
||||||
|
img = cv2.imread(str(path))
|
||||||
|
if img is None:
|
||||||
|
raise FileNotFoundError(f"无法读取图片: {path}")
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def _landmark_points(landmarks, image_size: Tuple[int, int]) -> Dict[str, Tuple[int, int]]:
|
||||||
|
"""提取标注用像素点。"""
|
||||||
|
w, h = image_size
|
||||||
|
|
||||||
|
def xy(idx: int) -> Tuple[int, int]:
|
||||||
|
lm = landmarks[idx]
|
||||||
|
return int(round(lm.x * w)), int(round(lm.y * h))
|
||||||
|
|
||||||
|
left_jaw = xy(FaceLandmarks.LEFT_JAW_ANGLE)
|
||||||
|
right_jaw = xy(FaceLandmarks.RIGHT_JAW_ANGLE)
|
||||||
|
return {
|
||||||
|
"forehead_top": xy(FaceLandmarks.FOREHEAD_TOP),
|
||||||
|
"chin_bottom": xy(FaceLandmarks.CHIN_BOTTOM),
|
||||||
|
"left_forehead": xy(FaceLandmarks.LEFT_FOREHEAD),
|
||||||
|
"right_forehead": xy(FaceLandmarks.RIGHT_FOREHEAD),
|
||||||
|
"left_cheek": xy(FaceLandmarks.LEFT_CHEEK),
|
||||||
|
"right_cheek": xy(FaceLandmarks.RIGHT_CHEEK),
|
||||||
|
"left_jaw": left_jaw,
|
||||||
|
"right_jaw": right_jaw,
|
||||||
|
"left_chin": xy(FaceLandmarks.LEFT_CHIN),
|
||||||
|
"right_chin": xy(FaceLandmarks.RIGHT_CHIN),
|
||||||
|
"jaw_mid": (
|
||||||
|
int(round((left_jaw[0] + right_jaw[0]) / 2)),
|
||||||
|
int(round((left_jaw[1] + right_jaw[1]) / 2)),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _put_text_cn(
|
||||||
|
img: np.ndarray,
|
||||||
|
text: str,
|
||||||
|
org: Tuple[int, int],
|
||||||
|
color: Tuple[int, int, int],
|
||||||
|
font_size: int = 18,
|
||||||
|
) -> None:
|
||||||
|
"""在图上绘制中文/英文混合文字(Pillow)。"""
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
|
||||||
|
pil = Image.fromarray(rgb)
|
||||||
|
draw = ImageDraw.Draw(pil)
|
||||||
|
|
||||||
|
font_paths = [
|
||||||
|
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
|
||||||
|
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||||
|
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
||||||
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||||
|
]
|
||||||
|
font = None
|
||||||
|
for fp in font_paths:
|
||||||
|
if Path(fp).exists():
|
||||||
|
try:
|
||||||
|
font = ImageFont.truetype(fp, font_size)
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if font is None:
|
||||||
|
font = ImageFont.load_default()
|
||||||
|
|
||||||
|
x, y = org
|
||||||
|
# 阴影提升可读性
|
||||||
|
draw.text((x + 1, y + 1), text, font=font, fill=(0, 0, 0))
|
||||||
|
draw.text((x, y), text, font=font, fill=(color[2], color[1], color[0]))
|
||||||
|
img[:] = cv2.cvtColor(np.array(pil), cv2.COLOR_RGB2BGR)
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_h_line(
|
||||||
|
img: np.ndarray,
|
||||||
|
p1: Tuple[int, int],
|
||||||
|
p2: Tuple[int, int],
|
||||||
|
color: Tuple[int, int, int],
|
||||||
|
label: str,
|
||||||
|
thickness: int = 2,
|
||||||
|
label_above: bool = True,
|
||||||
|
) -> None:
|
||||||
|
"""画横向宽度线 + 端点 + 标签。"""
|
||||||
|
y = int(round((p1[1] + p2[1]) / 2))
|
||||||
|
x1, x2 = min(p1[0], p2[0]), max(p1[0], p2[0])
|
||||||
|
cv2.line(img, (x1, y), (x2, y), color, thickness, cv2.LINE_AA)
|
||||||
|
cv2.circle(img, (x1, y), 4, color, -1, cv2.LINE_AA)
|
||||||
|
cv2.circle(img, (x2, y), 4, color, -1, cv2.LINE_AA)
|
||||||
|
# 端点小竖线
|
||||||
|
tick = max(6, thickness * 3)
|
||||||
|
cv2.line(img, (x1, y - tick), (x1, y + tick), color, thickness, cv2.LINE_AA)
|
||||||
|
cv2.line(img, (x2, y - tick), (x2, y + tick), color, thickness, cv2.LINE_AA)
|
||||||
|
mid = ((x1 + x2) // 2, y - 8 if label_above else y + 4)
|
||||||
|
_put_text_cn(img, label, mid, color, font_size=max(14, img.shape[0] // 55))
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_v_line(
|
||||||
|
img: np.ndarray,
|
||||||
|
p1: Tuple[int, int],
|
||||||
|
p2: Tuple[int, int],
|
||||||
|
color: Tuple[int, int, int],
|
||||||
|
label: str,
|
||||||
|
thickness: int = 2,
|
||||||
|
) -> None:
|
||||||
|
"""画纵向高度线 + 端点 + 标签。"""
|
||||||
|
x = int(round((p1[0] + p2[0]) / 2))
|
||||||
|
y1, y2 = min(p1[1], p2[1]), max(p1[1], p2[1])
|
||||||
|
cv2.line(img, (x, y1), (x, y2), color, thickness, cv2.LINE_AA)
|
||||||
|
cv2.circle(img, (x, y1), 4, color, -1, cv2.LINE_AA)
|
||||||
|
cv2.circle(img, (x, y2), 4, color, -1, cv2.LINE_AA)
|
||||||
|
tick = max(6, thickness * 3)
|
||||||
|
cv2.line(img, (x - tick, y1), (x + tick, y1), color, thickness, cv2.LINE_AA)
|
||||||
|
cv2.line(img, (x - tick, y2), (x + tick, y2), color, thickness, cv2.LINE_AA)
|
||||||
|
_put_text_cn(
|
||||||
|
img,
|
||||||
|
label,
|
||||||
|
(x + 8, (y1 + y2) // 2),
|
||||||
|
color,
|
||||||
|
font_size=max(14, img.shape[0] // 55),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def annotate_face_features(
|
||||||
|
image: ImageInput,
|
||||||
|
landmarks=None,
|
||||||
|
features: Optional[Dict[str, float]] = None,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
在原图上标注 face_width / face_height 及文档中的关键比例特征。
|
||||||
|
|
||||||
|
返回 BGR 标注图。
|
||||||
|
"""
|
||||||
|
bgr = _load_image(image).copy()
|
||||||
|
h, w = bgr.shape[:2]
|
||||||
|
|
||||||
|
if landmarks is None:
|
||||||
|
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
||||||
|
with _face_mesh_lock:
|
||||||
|
results = _get_face_mesh().process(rgb)
|
||||||
|
if not results.multi_face_landmarks:
|
||||||
|
raise ValueError("未检测到人脸关键点")
|
||||||
|
landmarks = results.multi_face_landmarks[0].landmark
|
||||||
|
|
||||||
|
if features is None:
|
||||||
|
features = extract_face_features(landmarks, image_size=(w, h))
|
||||||
|
|
||||||
|
pts = _landmark_points(landmarks, (w, h))
|
||||||
|
overlay = bgr.copy()
|
||||||
|
fs = max(14, h // 55)
|
||||||
|
thick = max(2, h // 400)
|
||||||
|
|
||||||
|
# ---- 尺寸主轴 ----
|
||||||
|
# face_height: 额头顶 → 下巴底
|
||||||
|
_draw_v_line(
|
||||||
|
overlay,
|
||||||
|
pts["forehead_top"],
|
||||||
|
pts["chin_bottom"],
|
||||||
|
(40, 180, 255),
|
||||||
|
f"face_height {features['face_height']:.0f}px",
|
||||||
|
thickness=thick + 1,
|
||||||
|
)
|
||||||
|
# face_width (= cheekbone): 左颧 → 右颧
|
||||||
|
_draw_h_line(
|
||||||
|
overlay,
|
||||||
|
pts["left_cheek"],
|
||||||
|
pts["right_cheek"],
|
||||||
|
(0, 220, 120),
|
||||||
|
f"face_width {features['face_width']:.0f}px",
|
||||||
|
thickness=thick + 1,
|
||||||
|
label_above=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- 各级宽度(forehead / jaw / chin)----
|
||||||
|
# 略微错开 y,避免完全重叠
|
||||||
|
fh_y = pts["left_forehead"][1]
|
||||||
|
_draw_h_line(
|
||||||
|
overlay,
|
||||||
|
(pts["left_forehead"][0], fh_y),
|
||||||
|
(pts["right_forehead"][0], fh_y),
|
||||||
|
(255, 160, 40),
|
||||||
|
f"forehead ratio={features['forehead_ratio']:.3f}",
|
||||||
|
thickness=thick,
|
||||||
|
label_above=True,
|
||||||
|
)
|
||||||
|
jy = pts["left_jaw"][1]
|
||||||
|
_draw_h_line(
|
||||||
|
overlay,
|
||||||
|
(pts["left_jaw"][0], jy),
|
||||||
|
(pts["right_jaw"][0], jy),
|
||||||
|
(80, 120, 255),
|
||||||
|
f"jaw ratio={features['jaw_ratio']:.3f}",
|
||||||
|
thickness=thick,
|
||||||
|
label_above=False,
|
||||||
|
)
|
||||||
|
cy = pts["left_chin"][1]
|
||||||
|
_draw_h_line(
|
||||||
|
overlay,
|
||||||
|
(pts["left_chin"][0], cy),
|
||||||
|
(pts["right_chin"][0], cy),
|
||||||
|
(220, 80, 220),
|
||||||
|
f"chin ratio={features['chin_ratio']:.3f}",
|
||||||
|
thickness=thick,
|
||||||
|
label_above=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# cheekbone_ratio(相对 face_width,恒为 1.0)写在颧骨线旁
|
||||||
|
cheek_mid = (
|
||||||
|
(pts["left_cheek"][0] + pts["right_cheek"][0]) // 2,
|
||||||
|
pts["left_cheek"][1] + max(18, h // 40),
|
||||||
|
)
|
||||||
|
_put_text_cn(
|
||||||
|
overlay,
|
||||||
|
f"cheekbone_ratio={features['cheekbone_ratio']:.3f}",
|
||||||
|
cheek_mid,
|
||||||
|
(0, 200, 100),
|
||||||
|
font_size=fs,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- jaw_angle:下巴 → 左右下颌角 ----
|
||||||
|
cv2.line(overlay, pts["chin_bottom"], pts["left_jaw"], (0, 90, 255), thick, cv2.LINE_AA)
|
||||||
|
cv2.line(overlay, pts["chin_bottom"], pts["right_jaw"], (0, 90, 255), thick, cv2.LINE_AA)
|
||||||
|
cv2.circle(overlay, pts["chin_bottom"], 5, (0, 90, 255), -1, cv2.LINE_AA)
|
||||||
|
# 角度弧
|
||||||
|
v1 = np.array(pts["left_jaw"], dtype=float) - np.array(pts["chin_bottom"], dtype=float)
|
||||||
|
v2 = np.array(pts["right_jaw"], dtype=float) - np.array(pts["chin_bottom"], dtype=float)
|
||||||
|
a1 = math.degrees(math.atan2(-v1[1], v1[0]))
|
||||||
|
a2 = math.degrees(math.atan2(-v2[1], v2[0]))
|
||||||
|
# OpenCV ellipse 角度:从 x 轴顺时针;atan2 转一下
|
||||||
|
start_ang = -a1
|
||||||
|
end_ang = -a2
|
||||||
|
if end_ang < start_ang:
|
||||||
|
start_ang, end_ang = end_ang, start_ang
|
||||||
|
radius = max(28, int(0.08 * features["face_height"]))
|
||||||
|
cv2.ellipse(
|
||||||
|
overlay,
|
||||||
|
pts["chin_bottom"],
|
||||||
|
(radius, radius),
|
||||||
|
0,
|
||||||
|
start_ang,
|
||||||
|
end_ang,
|
||||||
|
(0, 90, 255),
|
||||||
|
thick,
|
||||||
|
cv2.LINE_AA,
|
||||||
|
)
|
||||||
|
_put_text_cn(
|
||||||
|
overlay,
|
||||||
|
f"jaw_angle {features['jaw_angle']:.1f}°",
|
||||||
|
(pts["chin_bottom"][0] + radius + 4, pts["chin_bottom"][1] - radius),
|
||||||
|
(0, 90, 255),
|
||||||
|
font_size=fs,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- taper_ratio:额头两端 → 下巴两端(收窄示意)----
|
||||||
|
cv2.line(
|
||||||
|
overlay,
|
||||||
|
pts["left_forehead"],
|
||||||
|
pts["left_chin"],
|
||||||
|
(40, 200, 255),
|
||||||
|
max(1, thick - 1),
|
||||||
|
cv2.LINE_AA,
|
||||||
|
)
|
||||||
|
cv2.line(
|
||||||
|
overlay,
|
||||||
|
pts["right_forehead"],
|
||||||
|
pts["right_chin"],
|
||||||
|
(40, 200, 255),
|
||||||
|
max(1, thick - 1),
|
||||||
|
cv2.LINE_AA,
|
||||||
|
)
|
||||||
|
taper_anchor = (
|
||||||
|
pts["left_forehead"][0] - max(10, w // 30),
|
||||||
|
(pts["left_forehead"][1] + pts["left_chin"][1]) // 2,
|
||||||
|
)
|
||||||
|
_put_text_cn(
|
||||||
|
overlay,
|
||||||
|
f"taper_ratio={features['taper_ratio']:.3f}",
|
||||||
|
taper_anchor,
|
||||||
|
(40, 200, 255),
|
||||||
|
font_size=fs,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- chin_sharpness:下巴宽 vs 下颌宽 ----
|
||||||
|
_put_text_cn(
|
||||||
|
overlay,
|
||||||
|
f"chin_sharpness={features['chin_sharpness']:.3f} (chin/jaw)",
|
||||||
|
(pts["left_chin"][0], pts["left_chin"][1] + max(16, h // 45)),
|
||||||
|
(220, 80, 220),
|
||||||
|
font_size=fs,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- width_uniformity:三宽差异 ----
|
||||||
|
widths = [
|
||||||
|
("F", features["forehead_width"], (255, 160, 40)),
|
||||||
|
("C", features["cheekbone_width"], (0, 220, 120)),
|
||||||
|
("J", features["jaw_width"], (80, 120, 255)),
|
||||||
|
]
|
||||||
|
# 右侧小柱状示意
|
||||||
|
panel_x = min(w - max(90, w // 8), max(pts["right_cheek"][0] + 20, w - max(100, w // 7)))
|
||||||
|
panel_y = max(40, pts["forehead_top"][1])
|
||||||
|
max_w = max(x[1] for x in widths) + 1e-8
|
||||||
|
bar_h = max(10, h // 60)
|
||||||
|
gap = max(4, h // 120)
|
||||||
|
for i, (name, val, color) in enumerate(widths):
|
||||||
|
bw = int((val / max_w) * max(50, w // 10))
|
||||||
|
y0 = panel_y + i * (bar_h + gap)
|
||||||
|
cv2.rectangle(overlay, (panel_x, y0), (panel_x + bw, y0 + bar_h), color, -1, cv2.LINE_AA)
|
||||||
|
_put_text_cn(overlay, name, (panel_x + bw + 4, y0 - 2), color, font_size=max(12, fs - 2))
|
||||||
|
_put_text_cn(
|
||||||
|
overlay,
|
||||||
|
f"width_uniformity={features['width_uniformity']:.3f}",
|
||||||
|
(panel_x, panel_y + 3 * (bar_h + gap) + 2),
|
||||||
|
(230, 230, 230),
|
||||||
|
font_size=fs,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- face_curve_score:下颌中点 → 下巴 ----
|
||||||
|
cv2.line(
|
||||||
|
overlay,
|
||||||
|
pts["jaw_mid"],
|
||||||
|
pts["chin_bottom"],
|
||||||
|
(180, 255, 80),
|
||||||
|
thick,
|
||||||
|
cv2.LINE_AA,
|
||||||
|
)
|
||||||
|
cv2.circle(overlay, pts["jaw_mid"], 4, (180, 255, 80), -1, cv2.LINE_AA)
|
||||||
|
curve_label_pos = (
|
||||||
|
pts["jaw_mid"][0] + 6,
|
||||||
|
pts["jaw_mid"][1] - max(8, h // 80),
|
||||||
|
)
|
||||||
|
_put_text_cn(
|
||||||
|
overlay,
|
||||||
|
f"face_curve_score={features['face_curve_score']:.3f}",
|
||||||
|
curve_label_pos,
|
||||||
|
(180, 255, 80),
|
||||||
|
font_size=fs,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 半透明叠回原图,再叠一层实线标注更清晰:直接用 overlay
|
||||||
|
# 左侧参数图例
|
||||||
|
legend = [
|
||||||
|
("face_width / face_height", (0, 220, 120)),
|
||||||
|
(f"jaw_angle={features['jaw_angle']:.1f}°", (0, 90, 255)),
|
||||||
|
(f"taper_ratio={features['taper_ratio']:.3f}", (40, 200, 255)),
|
||||||
|
(f"forehead_ratio={features['forehead_ratio']:.3f}", (255, 160, 40)),
|
||||||
|
(f"cheekbone_ratio={features['cheekbone_ratio']:.3f}", (0, 200, 100)),
|
||||||
|
(f"jaw_ratio={features['jaw_ratio']:.3f}", (80, 120, 255)),
|
||||||
|
(f"chin_ratio={features['chin_ratio']:.3f}", (220, 80, 220)),
|
||||||
|
(f"chin_sharpness={features['chin_sharpness']:.3f}", (220, 80, 220)),
|
||||||
|
(f"width_uniformity={features['width_uniformity']:.3f}", (230, 230, 230)),
|
||||||
|
(f"face_curve_score={features['face_curve_score']:.3f}", (180, 255, 80)),
|
||||||
|
]
|
||||||
|
box_h = 12 + len(legend) * (fs + 6)
|
||||||
|
box_w = max(220, w // 3)
|
||||||
|
cv2.rectangle(overlay, (8, 8), (8 + box_w, 8 + box_h), (20, 20, 20), -1)
|
||||||
|
cv2.rectangle(overlay, (8, 8), (8 + box_w, 8 + box_h), (90, 90, 90), 1)
|
||||||
|
for i, (text, color) in enumerate(legend):
|
||||||
|
_put_text_cn(overlay, text, (16, 14 + i * (fs + 6)), color, font_size=fs)
|
||||||
|
|
||||||
|
return overlay
|
||||||
|
|
||||||
|
|
||||||
|
def classify_from_image(
|
||||||
|
image: ImageInput,
|
||||||
|
return_details: bool = True,
|
||||||
|
return_annotated: bool = False,
|
||||||
|
) -> Dict:
|
||||||
|
"""
|
||||||
|
从图片判断脸型。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
image: 图片路径,或 OpenCV BGR numpy 数组
|
||||||
|
return_details: 是否返回特征与各脸型得分
|
||||||
|
return_annotated: 是否同时返回特征标注图(BGR)
|
||||||
|
"""
|
||||||
|
bgr = _load_image(image)
|
||||||
|
h, w = bgr.shape[:2]
|
||||||
|
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
||||||
|
with _face_mesh_lock:
|
||||||
|
results = _get_face_mesh().process(rgb)
|
||||||
|
|
||||||
|
if not results.multi_face_landmarks:
|
||||||
|
raise ValueError("未检测到人脸关键点")
|
||||||
|
|
||||||
|
landmarks = results.multi_face_landmarks[0].landmark
|
||||||
|
features = extract_face_features(landmarks, image_size=(w, h))
|
||||||
|
shape, conf, details = classify_face_shape(features, return_details=True)
|
||||||
|
|
||||||
|
display = get_mixed_description(details) or shape
|
||||||
|
result = {
|
||||||
|
"face_shape": shape,
|
||||||
|
"confidence": conf,
|
||||||
|
"display": display,
|
||||||
|
}
|
||||||
|
if return_details:
|
||||||
|
result["features"] = features
|
||||||
|
result["details"] = details
|
||||||
|
if return_annotated:
|
||||||
|
result["annotated"] = annotate_face_features(
|
||||||
|
bgr, landmarks=landmarks, features=features
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def classify_from_mediapipe(
|
||||||
|
multi_face_landmarks,
|
||||||
|
image_size: Tuple[int, int],
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""从 Mediapipe FaceMesh 结果批量分类。image_size=(width, height)。"""
|
||||||
|
results = []
|
||||||
|
for face_lms in multi_face_landmarks:
|
||||||
|
features = extract_face_features(face_lms.landmark, image_size=image_size)
|
||||||
|
shape, conf, details = classify_face_shape(features, return_details=True)
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"face_shape": shape,
|
||||||
|
"confidence": conf,
|
||||||
|
"display": get_mixed_description(details) or shape,
|
||||||
|
"features": features,
|
||||||
|
"details": details,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def run_test_images(test_dir: Union[str, Path, None] = None) -> List[Dict]:
|
||||||
|
"""用 test_img 做回归测试;文件名(不含扩展名)为期望脸型。"""
|
||||||
|
if test_dir is None:
|
||||||
|
test_dir = Path(__file__).resolve().parent / "test_img"
|
||||||
|
test_dir = Path(test_dir)
|
||||||
|
|
||||||
|
image_paths = sorted(
|
||||||
|
p
|
||||||
|
for p in test_dir.iterdir()
|
||||||
|
if p.suffix.lower() in {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
|
||||||
|
)
|
||||||
|
if not image_paths:
|
||||||
|
raise FileNotFoundError(f"测试目录无图片: {test_dir}")
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for path in image_paths:
|
||||||
|
expected = path.stem
|
||||||
|
try:
|
||||||
|
result = classify_from_image(path, return_details=True)
|
||||||
|
predicted = result["face_shape"]
|
||||||
|
display = result["display"]
|
||||||
|
conf = result["confidence"]
|
||||||
|
top3 = result["details"]["ranked"][:3]
|
||||||
|
ok = predicted == expected
|
||||||
|
error = None
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
predicted = display = conf = None
|
||||||
|
top3 = []
|
||||||
|
ok = False
|
||||||
|
error = str(exc)
|
||||||
|
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"file": path.name,
|
||||||
|
"expected": expected,
|
||||||
|
"predicted": predicted,
|
||||||
|
"display": display,
|
||||||
|
"confidence": conf,
|
||||||
|
"top3": top3,
|
||||||
|
"correct": ok,
|
||||||
|
"error": error,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _print_test_report(rows: List[Dict]) -> None:
|
||||||
|
correct = sum(1 for r in rows if r["correct"])
|
||||||
|
total = len(rows)
|
||||||
|
|
||||||
|
print("=" * 72)
|
||||||
|
print("脸型分类测试结果")
|
||||||
|
print("=" * 72)
|
||||||
|
for r in rows:
|
||||||
|
status = "✓" if r["correct"] else "✗"
|
||||||
|
if r["error"]:
|
||||||
|
print(f"{status} {r['file']}")
|
||||||
|
print(f" 期望: {r['expected']}")
|
||||||
|
print(f" 错误: {r['error']}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
top3_str = ", ".join(f"{name}:{score:.1f}" for name, score in r["top3"])
|
||||||
|
print(f"{status} {r['file']}")
|
||||||
|
print(f" 期望: {r['expected']}")
|
||||||
|
print(f" 预测: {r['display']} (conf={r['confidence']:.3f})")
|
||||||
|
print(f" Top3: {top3_str}")
|
||||||
|
|
||||||
|
print("-" * 72)
|
||||||
|
print(f"准确率: {correct}/{total} = {correct / total:.1%}")
|
||||||
|
print("=" * 72)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
|
||||||
|
if len(sys.argv) > 1 and sys.argv[1] not in {"--test", "-t"}:
|
||||||
|
out = classify_from_image(sys.argv[1], return_details=True)
|
||||||
|
print(f"脸型: {out['display']}")
|
||||||
|
print(f"置信度: {out['confidence']:.3f}")
|
||||||
|
print("各脸型得分:")
|
||||||
|
for name, score in out["details"]["ranked"]:
|
||||||
|
print(f" {name}: {score:.1f}")
|
||||||
|
else:
|
||||||
|
report = run_test_images()
|
||||||
|
_print_test_report(report)
|
||||||
|
After Width: | Height: | Size: 655 KiB |
|
After Width: | Height: | Size: 658 KiB |
|
After Width: | Height: | Size: 656 KiB |
|
After Width: | Height: | Size: 649 KiB |
|
After Width: | Height: | Size: 650 KiB |
|
After Width: | Height: | Size: 651 KiB |
@@ -203,7 +203,7 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
|
|||||||
|
|
||||||
# --- 自适应尺寸:字号/线宽/虚线/箭头按短边缩放 ---
|
# --- 自适应尺寸:字号/线宽/虚线/箭头按短边缩放 ---
|
||||||
s = min(w, h)
|
s = min(w, h)
|
||||||
font_size = max(11, round(s * 0.026)) # 字体更小
|
font_size = max(12, round(s * 0.030)) # 字号上调一档
|
||||||
line_w = max(1, round(s * 0.0022))
|
line_w = max(1, round(s * 0.0022))
|
||||||
dash_len = max(4, round(s * 0.008))
|
dash_len = max(4, round(s * 0.008))
|
||||||
gap_len = max(2, round(dash_len * 0.7)) # 虚线更稠密(间隙<划线)
|
gap_len = max(2, round(dash_len * 0.7)) # 虚线更稠密(间隙<划线)
|
||||||
@@ -213,7 +213,11 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
|
|||||||
|
|
||||||
buf = np.zeros((h, w, 4), dtype=np.uint8)
|
buf = np.zeros((h, w, 4), dtype=np.uint8)
|
||||||
|
|
||||||
if variant == "v6":
|
# 发际线弃用(hairline_discarded):保留头顶横线,去掉发际线横线,
|
||||||
|
# 也不标顶/上庭(缺发际线作边界,算不出)。横线 = 头顶/眉心/鼻翼下缘/下巴尖。
|
||||||
|
if getattr(measure_result, "hairline_discarded", False):
|
||||||
|
order = ["hair_top", "brow_center", "nose_bottom", "chin_tip"]
|
||||||
|
elif variant == "v6":
|
||||||
order = ["hairline", "brow_center", "nose_bottom", "chin_tip"]
|
order = ["hairline", "brow_center", "nose_bottom", "chin_tip"]
|
||||||
else:
|
else:
|
||||||
order = ["hair_top", "hairline", "brow_center", "nose_bottom", "chin_tip"]
|
order = ["hair_top", "hairline", "brow_center", "nose_bottom", "chin_tip"]
|
||||||
@@ -256,6 +260,18 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
|
|||||||
draw = ImageDraw.Draw(canvas)
|
draw = ImageDraw.Draw(canvas)
|
||||||
font = _load_font(font_size)
|
font = _load_font(font_size)
|
||||||
|
|
||||||
|
# --- 2b. 每条横线在「中间线段」(两内眼角之间)中点画原点突出 ---
|
||||||
|
# 注意:原点不在整条线的中点 face_cx,而在被竖线切出的中间段(左内眼角↔右内眼角)
|
||||||
|
# 的正中,即脸的竖直中轴附近、两内眼角连线中点。
|
||||||
|
li_x = pts["left_inner"][0]
|
||||||
|
ri_x = pts["right_inner"][0]
|
||||||
|
dot_cx = (li_x + ri_x) / 2
|
||||||
|
dot_r = max(2, round(s * 0.0045)) # 原点半径,与 arrow_size 同档自适应
|
||||||
|
for cy in ys:
|
||||||
|
x0, y0 = dot_cx - dot_r, cy - dot_r
|
||||||
|
x1, y1 = dot_cx + dot_r, cy + dot_r
|
||||||
|
draw.ellipse((x0, y0, x1, y1), fill=LINE_COLOR)
|
||||||
|
|
||||||
# --- 3a. 横线右侧:线名(头顶/发际线/眉心/鼻翼下缘/下巴尖),文字在线上方 ---
|
# --- 3a. 横线右侧:线名(头顶/发际线/眉心/鼻翼下缘/下巴尖),文字在线上方 ---
|
||||||
name_x = fx1 + pad
|
name_x = fx1 + pad
|
||||||
name_gap = max(2, round(pad * 1.6)) # 文字底部到线的间距(再上移)
|
name_gap = max(2, round(pad * 1.6)) # 文字底部到线的间距(再上移)
|
||||||
@@ -266,18 +282,27 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
|
|||||||
draw.text((x, max(2, ys[i] - th - name_gap)), text, fill=LINE_COLOR, font=font)
|
draw.text((x, max(2, ys[i] - th - name_gap)), text, fill=LINE_COLOR, font=font)
|
||||||
|
|
||||||
# --- 3b. 左侧四庭:名 + 数值两行(无 cm)+ 竖向虚线双箭头 ---
|
# --- 3b. 左侧四庭:名 + 数值两行(无 cm)+ 竖向虚线双箭头 ---
|
||||||
if variant == "v6":
|
# court_start:庭段在 order 里的起始索引。发际线弃用时 order 首位是头顶(无下界发际线,
|
||||||
|
# 顶/上庭不标),中庭从眉心开始 → 跳过 order[0]。
|
||||||
|
if getattr(measure_result, "hairline_discarded", False):
|
||||||
|
court_cm = [measure_result.middle_cm, measure_result.lower_cm]
|
||||||
|
court_name = ["中庭", "下庭"]
|
||||||
|
n_court = 2
|
||||||
|
court_start = 1
|
||||||
|
elif variant == "v6":
|
||||||
court_cm = [measure_result.upper_cm, measure_result.middle_cm, measure_result.lower_cm]
|
court_cm = [measure_result.upper_cm, measure_result.middle_cm, measure_result.lower_cm]
|
||||||
court_name = ["上庭", "中庭", "下庭"]
|
court_name = ["上庭", "中庭", "下庭"]
|
||||||
n_court = 3
|
n_court = 3
|
||||||
|
court_start = 0
|
||||||
else:
|
else:
|
||||||
court_cm = [measure_result.top_cm, measure_result.upper_cm,
|
court_cm = [measure_result.top_cm, measure_result.upper_cm,
|
||||||
measure_result.middle_cm, measure_result.lower_cm]
|
measure_result.middle_cm, measure_result.lower_cm]
|
||||||
court_name = ["顶庭", "上庭", "中庭", "下庭"]
|
court_name = ["顶庭", "上庭", "中庭", "下庭"]
|
||||||
n_court = 4
|
n_court = 4
|
||||||
|
court_start = 0
|
||||||
arrow_x = max(arrow_size + 1, fx0 - pad) # 竖箭头所在 x(脸左侧,贴近最左竖线)
|
arrow_x = max(arrow_size + 1, fx0 - pad) # 竖箭头所在 x(脸左侧,贴近最左竖线)
|
||||||
for i in range(n_court):
|
for i in range(n_court):
|
||||||
y_a, y_b = ys[i], ys[i + 1]
|
y_a, y_b = ys[court_start + i], ys[court_start + i + 1]
|
||||||
# 竖向虚线双箭头,覆盖该庭高度(略收一点避免压到横线)
|
# 竖向虚线双箭头,覆盖该庭高度(略收一点避免压到横线)
|
||||||
inset = min(arrow_size, (y_b - y_a) * 0.12)
|
inset = min(arrow_size, (y_b - y_a) * 0.12)
|
||||||
draw_dashed_line_with_arrows(
|
draw_dashed_line_with_arrows(
|
||||||
|
|||||||
@@ -1107,7 +1107,7 @@ def generate_hairline_redraw(image_bgr, hairline_id, is_hr=False, seg_model="seg
|
|||||||
"hairline_id": hairline_id,
|
"hairline_id": hairline_id,
|
||||||
"blend_method": blend_method,
|
"blend_method": blend_method,
|
||||||
"hairline_push_cm": round(float(hairline_push_cm), 2),
|
"hairline_push_cm": round(float(hairline_push_cm), 2),
|
||||||
"comfyui_prompt": comfyui_prompt or "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜",
|
"comfyui_prompt": comfyui_prompt or "填充遮罩区域的头发",
|
||||||
"beauty_alpha": beauty_alpha,
|
"beauty_alpha": beauty_alpha,
|
||||||
"px_per_cm": round(float(px_per_cm), 4),
|
"px_per_cm": round(float(px_per_cm), 4),
|
||||||
"mask_pixels": mask_viz["mask_pixels"],
|
"mask_pixels": mask_viz["mask_pixels"],
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from face_analysis.calibration import (
|
|||||||
estimate_scale_factor, normalized_to_pixel, pixel_distance, _lm_list,
|
estimate_scale_factor, normalized_to_pixel, pixel_distance, _lm_list,
|
||||||
)
|
)
|
||||||
from face_analysis.face_mesh_landmarks import (
|
from face_analysis.face_mesh_landmarks import (
|
||||||
GLABELLA_9, GLABELLA_151, NOSE_BOTTOM, CHIN_TIP,
|
GLABELLA_9, NOSE_BOTTOM, CHIN_TIP,
|
||||||
LEFT_EYE_OUTER, LEFT_EYE_INNER, RIGHT_EYE_INNER, RIGHT_EYE_OUTER,
|
LEFT_EYE_OUTER, LEFT_EYE_INNER, RIGHT_EYE_INNER, RIGHT_EYE_OUTER,
|
||||||
LEFT_CHEEK, RIGHT_CHEEK, LEFT_POSITION, RIGHT_POSITION,
|
LEFT_CHEEK, RIGHT_CHEEK, LEFT_POSITION, RIGHT_POSITION,
|
||||||
)
|
)
|
||||||
@@ -24,10 +24,8 @@ _TOP_RATIO = 0.22 / 0.28 # 顶庭 ÷ 中庭(≈ 0.786)
|
|||||||
|
|
||||||
|
|
||||||
def _brow_center(lm, w, h):
|
def _brow_center(lm, w, h):
|
||||||
"""眉心 = 索引 9 / 151 中点。"""
|
"""眉心 = 索引 9(眉间上点)。"""
|
||||||
g9 = normalized_to_pixel(lm[GLABELLA_9], w, h)
|
return normalized_to_pixel(lm[GLABELLA_9], w, h)
|
||||||
g151 = normalized_to_pixel(lm[GLABELLA_151], w, h)
|
|
||||||
return (g9[0] + g151[0]) / 2, (g9[1] + g151[1]) / 2
|
|
||||||
|
|
||||||
|
|
||||||
def estimate_vertical_landmarks(landmarks, image_width, image_height):
|
def estimate_vertical_landmarks(landmarks, image_width, image_height):
|
||||||
@@ -144,9 +142,22 @@ def measure_seven_eyes(landmarks, image_width, image_height):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def pt_or_none(vertical, name):
|
||||||
|
"""vertical dict 的点 → {"x","y"},值为 None 时返回 None。"""
|
||||||
|
v = vertical.get(name)
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
return {"x": int(round(v[0])), "y": int(round(v[1]))}
|
||||||
|
|
||||||
|
|
||||||
class MeasureResult:
|
class MeasureResult:
|
||||||
"""测量结果,提供 to_response() 输出与接口文档同构的 data 字段。"""
|
"""测量结果,提供 to_response() 输出与接口文档同构的 data 字段。"""
|
||||||
|
|
||||||
|
# 发际线弃用阈值:发际线离头顶(顶庭)< 此值时判定分割不可靠,弃用发际线。
|
||||||
|
# hairline 与 hair_top 几乎重合(如稀疏头发中轴漏检只剩一小撮),说明发际线
|
||||||
|
# 定位无意义 → 顶/上庭置 null、标注图不画头顶/发际线。
|
||||||
|
HAIRLINE_DISCARD_TOP_CM = 0.7
|
||||||
|
|
||||||
def __init__(self, vertical, eyes, px_per_cm, hairline_source, head_pose,
|
def __init__(self, vertical, eyes, px_per_cm, hairline_source, head_pose,
|
||||||
landmarks=None, image_width=None, image_height=None):
|
landmarks=None, image_width=None, image_height=None):
|
||||||
self.vertical = vertical
|
self.vertical = vertical
|
||||||
@@ -164,7 +175,14 @@ class MeasureResult:
|
|||||||
self.upper_cm = vertical["upper_court_px"] / px_per_cm
|
self.upper_cm = vertical["upper_court_px"] / px_per_cm
|
||||||
self.middle_cm = vertical["middle_court_px"] / px_per_cm
|
self.middle_cm = vertical["middle_court_px"] / px_per_cm
|
||||||
self.lower_cm = vertical["lower_court_px"] / px_per_cm
|
self.lower_cm = vertical["lower_court_px"] / px_per_cm
|
||||||
self.face_total_cm = self.top_cm + self.upper_cm + self.middle_cm + self.lower_cm
|
# 发际线弃用判定:顶庭(头顶→发际线)过小视为发际线贴近头顶、不可靠。
|
||||||
|
# 弃用时 hairline_source 改为 "discarded",face_total 只算中庭+下庭。
|
||||||
|
self.hairline_discarded = self.top_cm < self.HAIRLINE_DISCARD_TOP_CM
|
||||||
|
if self.hairline_discarded:
|
||||||
|
self.hairline_source = "discarded"
|
||||||
|
self.face_total_cm = self.middle_cm + self.lower_cm
|
||||||
|
else:
|
||||||
|
self.face_total_cm = self.top_cm + self.upper_cm + self.middle_cm + self.lower_cm
|
||||||
|
|
||||||
# 七眼厘米
|
# 七眼厘米
|
||||||
self.eye_width_cm = eyes["eye_width_px"] / px_per_cm
|
self.eye_width_cm = eyes["eye_width_px"] / px_per_cm
|
||||||
@@ -172,46 +190,77 @@ class MeasureResult:
|
|||||||
self.inter_eye_cm = eyes["inter_eye_distance_px"] / px_per_cm
|
self.inter_eye_cm = eyes["inter_eye_distance_px"] / px_per_cm
|
||||||
|
|
||||||
def to_response(self):
|
def to_response(self):
|
||||||
total_px = (self.vertical["top_court_px"] + self.vertical["upper_court_px"]
|
# 发际线弃用:顶/上庭相关字段置 null(保留键),ratio 分母只算中下庭;
|
||||||
+ self.vertical["middle_court_px"] + self.vertical["lower_court_px"])
|
# landmarks.hair_top/hairline 置 null。否则按四庭正常输出。
|
||||||
fw_px = self.eyes["face_width_px"]
|
if self.hairline_discarded:
|
||||||
|
base_px = (self.vertical["middle_court_px"] + self.vertical["lower_court_px"])
|
||||||
def pt(name):
|
data = {
|
||||||
x, y = self.vertical[name]
|
"face_total_height_cm": round(self.face_total_cm, 2),
|
||||||
return {"x": int(round(x)), "y": int(round(y))}
|
"four_courts": {
|
||||||
|
"top_court_cm": None,
|
||||||
data = {
|
"upper_court_cm": None,
|
||||||
"face_total_height_cm": round(self.face_total_cm, 2),
|
"middle_court_cm": round(self.middle_cm, 2),
|
||||||
"four_courts": {
|
"lower_court_cm": round(self.lower_cm, 2),
|
||||||
"top_court_cm": round(self.top_cm, 2),
|
"ratios": {
|
||||||
"upper_court_cm": round(self.upper_cm, 2),
|
"top_court": None,
|
||||||
"middle_court_cm": round(self.middle_cm, 2),
|
"upper_court": None,
|
||||||
"lower_court_cm": round(self.lower_cm, 2),
|
"middle_court": round(self.vertical["middle_court_px"] / base_px, 3),
|
||||||
"ratios": {
|
"lower_court": round(self.vertical["lower_court_px"] / base_px, 3),
|
||||||
"top_court": round(self.vertical["top_court_px"] / total_px, 3),
|
},
|
||||||
"upper_court": round(self.vertical["upper_court_px"] / total_px, 3),
|
|
||||||
"middle_court": round(self.vertical["middle_court_px"] / total_px, 3),
|
|
||||||
"lower_court": round(self.vertical["lower_court_px"] / total_px, 3),
|
|
||||||
},
|
},
|
||||||
},
|
"seven_eyes": {
|
||||||
"seven_eyes": {
|
"eye_width_cm": round(self.eye_width_cm, 2),
|
||||||
"eye_width_cm": round(self.eye_width_cm, 2),
|
"face_width_cm": round(self.face_width_cm, 2),
|
||||||
"face_width_cm": round(self.face_width_cm, 2),
|
"inter_eye_distance_cm": round(self.inter_eye_cm, 2),
|
||||||
"inter_eye_distance_cm": round(self.inter_eye_cm, 2),
|
"ratios": {
|
||||||
"ratios": {
|
"eye_width": round(self.eyes["eye_width_px"] / self.eyes["face_width_px"], 3),
|
||||||
"eye_width": round(self.eyes["eye_width_px"] / fw_px, 3),
|
"inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / self.eyes["face_width_px"], 3),
|
||||||
"inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / fw_px, 3),
|
},
|
||||||
},
|
},
|
||||||
},
|
"landmarks": {
|
||||||
"landmarks": {
|
"hair_top": None,
|
||||||
"hair_top": pt("hair_top"),
|
"hairline": None,
|
||||||
"hairline": pt("hairline"),
|
"brow_center": pt_or_none(self.vertical, "brow_center"),
|
||||||
"brow_center": pt("brow_center"),
|
"nose_bottom": pt_or_none(self.vertical, "nose_bottom"),
|
||||||
"nose_bottom": pt("nose_bottom"),
|
"chin_tip": pt_or_none(self.vertical, "chin_tip"),
|
||||||
"chin_tip": pt("chin_tip"),
|
},
|
||||||
},
|
"hairline_source": self.hairline_source,
|
||||||
"hairline_source": self.hairline_source,
|
}
|
||||||
}
|
else:
|
||||||
|
total_px = (self.vertical["top_court_px"] + self.vertical["upper_court_px"]
|
||||||
|
+ self.vertical["middle_court_px"] + self.vertical["lower_court_px"])
|
||||||
|
data = {
|
||||||
|
"face_total_height_cm": round(self.face_total_cm, 2),
|
||||||
|
"four_courts": {
|
||||||
|
"top_court_cm": round(self.top_cm, 2),
|
||||||
|
"upper_court_cm": round(self.upper_cm, 2),
|
||||||
|
"middle_court_cm": round(self.middle_cm, 2),
|
||||||
|
"lower_court_cm": round(self.lower_cm, 2),
|
||||||
|
"ratios": {
|
||||||
|
"top_court": round(self.vertical["top_court_px"] / total_px, 3),
|
||||||
|
"upper_court": round(self.vertical["upper_court_px"] / total_px, 3),
|
||||||
|
"middle_court": round(self.vertical["middle_court_px"] / total_px, 3),
|
||||||
|
"lower_court": round(self.vertical["lower_court_px"] / total_px, 3),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"seven_eyes": {
|
||||||
|
"eye_width_cm": round(self.eye_width_cm, 2),
|
||||||
|
"face_width_cm": round(self.face_width_cm, 2),
|
||||||
|
"inter_eye_distance_cm": round(self.inter_eye_cm, 2),
|
||||||
|
"ratios": {
|
||||||
|
"eye_width": round(self.eyes["eye_width_px"] / self.eyes["face_width_px"], 3),
|
||||||
|
"inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / self.eyes["face_width_px"], 3),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"landmarks": {
|
||||||
|
"hair_top": pt_or_none(self.vertical, "hair_top"),
|
||||||
|
"hairline": pt_or_none(self.vertical, "hairline"),
|
||||||
|
"brow_center": pt_or_none(self.vertical, "brow_center"),
|
||||||
|
"nose_bottom": pt_or_none(self.vertical, "nose_bottom"),
|
||||||
|
"chin_tip": pt_or_none(self.vertical, "chin_tip"),
|
||||||
|
},
|
||||||
|
"hairline_source": self.hairline_source,
|
||||||
|
}
|
||||||
# left/right_position:mediapipe 21/251 号定位点(原图像素,与 landmarks 同坐标系)。
|
# left/right_position:mediapipe 21/251 号定位点(原图像素,与 landmarks 同坐标系)。
|
||||||
# landmarks 缺省(如测试直构 MeasureResult)时不输出,保持向后兼容。
|
# landmarks 缺省(如测试直构 MeasureResult)时不输出,保持向后兼容。
|
||||||
if self.landmarks is not None and self.w and self.h:
|
if self.landmarks is not None and self.w and self.h:
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"""接口4:用户面部特征分析(调用火山方舟 豆包视觉模型 doubao-seed-1-6-vision)。
|
"""接口4:用户面部特征分析。
|
||||||
|
|
||||||
算法来源:/home/xsl/fuyan(FaceArk.py)。worker 把图片以 base64 data URI 传给方舟
|
- 眉形 / 面部年龄 / 动静类型 / 性别 / 基因风格:火山方舟豆包视觉模型
|
||||||
多模态模型,模型返回一大堆人脸特征 JSON;本模块解析后映射出接口4 的英文优先字段
|
- face_shape(脸型):本机 MediaPipe 分类(face/face_shape_classifier.py)覆盖,
|
||||||
(face_shape 等),并保留 doubao 返回的全部中文字段。
|
不用豆包结果
|
||||||
|
|
||||||
⚠️ 这是**唯一调外网云模型**的接口(其余接口全本地)。API Key 走配置/环境变量,不入 git。
|
⚠️ 仍依赖外网豆包(其余 5 项)。API Key 走配置/环境变量,不入 git。
|
||||||
|
网关本机需可 import face 包(opencv + mediapipe)。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -16,11 +17,10 @@ import os
|
|||||||
logger = logging.getLogger("hair.worker")
|
logger = logging.getLogger("hair.worker")
|
||||||
|
|
||||||
ARK_BASE_URL = os.getenv("ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3")
|
ARK_BASE_URL = os.getenv("ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3")
|
||||||
ARK_MODEL = os.getenv("ARK_MODEL", "doubao-seed-1-6-vision-250815")
|
ARK_MODEL = os.getenv("ARK_MODEL", "doubao-seed-2-0-lite-260428")
|
||||||
|
|
||||||
# doubao 中文键 → 接口4 英文优先字段(仅保留这 6 项)
|
# doubao 中文键 → 接口4 英文字段(脸型不走豆包,见 _local_face_shape)
|
||||||
_KEY_MAP = {
|
_KEY_MAP = {
|
||||||
"脸型": "face_shape",
|
|
||||||
"眉形": "eyebrow_shape",
|
"眉形": "eyebrow_shape",
|
||||||
"面部年龄": "facial_age",
|
"面部年龄": "facial_age",
|
||||||
"动静类型": "dynamic_static_type",
|
"动静类型": "dynamic_static_type",
|
||||||
@@ -28,11 +28,11 @@ _KEY_MAP = {
|
|||||||
"基因风格": "gene_style",
|
"基因风格": "gene_style",
|
||||||
}
|
}
|
||||||
|
|
||||||
# 仅请求接口4 需要的 6 个字段(+「图片是否有人脸」用于 1001 判定,不进最终输出)
|
# 豆包只问 5 项 + 是否有人脸;脸型由本地分类器给出
|
||||||
_PROMPT = (
|
_PROMPT = (
|
||||||
"分析一下图片告诉我以下特征,只要答案,格式为json字符串,"
|
"分析一下图片告诉我以下特征,只要答案,格式为json字符串,"
|
||||||
"图片是否有人脸(有人/没人) "
|
"图片是否有人脸(有人/没人) "
|
||||||
"脸型(圆形脸/心形脸/菱形脸/鹅蛋脸/方形脸/长形脸/瓜子脸) 眉形 "
|
"眉形 "
|
||||||
"面部年龄(给出区间年龄) 动静类型(静态型/动态型) 性别(男/女) "
|
"面部年龄(给出区间年龄) 动静类型(静态型/动态型) 性别(男/女) "
|
||||||
"基因风格(戏剧型/睿智型/自然型/古典型/优雅型/浪漫型/前卫型/少女型/少年型)"
|
"基因风格(戏剧型/睿智型/自然型/古典型/优雅型/浪漫型/前卫型/少女型/少年型)"
|
||||||
)
|
)
|
||||||
@@ -42,12 +42,13 @@ _client_key: str | None = None # _client 构建时使用的 api_key,用
|
|||||||
|
|
||||||
|
|
||||||
def _load_api_key() -> str | None:
|
def _load_api_key() -> str | None:
|
||||||
"""ARK_API_KEY 环境变量优先,否则读 worker_config.json / gateway/config.json 的 ark_api_key。"""
|
"""ARK_API_KEY 环境变量优先;否则优先 gateway/config.json(接口4 已迁网关),
|
||||||
|
再回退 worker_config.json(兼容旧配置)。"""
|
||||||
key = os.getenv("ARK_API_KEY")
|
key = os.getenv("ARK_API_KEY")
|
||||||
if key:
|
if key:
|
||||||
return key
|
return key
|
||||||
base = os.path.dirname(__file__)
|
base = os.path.dirname(__file__)
|
||||||
for cfg_name in ("worker_config.json", "gateway/config.json"):
|
for cfg_name in ("gateway/config.json", "worker_config.json"):
|
||||||
cfg = os.path.join(base, cfg_name)
|
cfg = os.path.join(base, cfg_name)
|
||||||
if os.path.isfile(cfg):
|
if os.path.isfile(cfg):
|
||||||
try:
|
try:
|
||||||
@@ -97,10 +98,47 @@ def _image_to_url(image_bytes: bytes = None, image_url: str = None) -> str:
|
|||||||
return f"data:image/{fmt};base64," + base64.b64encode(image_bytes).decode()
|
return f"data:image/{fmt};base64," + base64.b64encode(image_bytes).decode()
|
||||||
|
|
||||||
|
|
||||||
def analyze_features(image_bytes: bytes = None, image_url: str = None):
|
def _resolve_image_bytes(image_bytes: bytes = None, image_url: str = None) -> bytes:
|
||||||
"""调 doubao 视觉模型分析人脸特征。
|
"""本地分类器用:优先已有字节;仅有 URL 时下载。"""
|
||||||
|
if image_bytes:
|
||||||
|
return image_bytes
|
||||||
|
if not image_url:
|
||||||
|
raise ValueError("缺少图片数据")
|
||||||
|
if image_url.startswith("data:"):
|
||||||
|
# data URI
|
||||||
|
b64 = image_url.split(",", 1)[1] if "," in image_url else image_url
|
||||||
|
return base64.b64decode(b64)
|
||||||
|
import httpx
|
||||||
|
with httpx.Client(timeout=15.0, follow_redirects=True) as client:
|
||||||
|
r = client.get(image_url)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.content
|
||||||
|
|
||||||
Returns: dict —— 仅含接口4 的 6 个英文字段(face_shape/eyebrow_shape/facial_age/
|
|
||||||
|
def _local_face_shape(image_bytes: bytes = None, image_url: str = None) -> str:
|
||||||
|
"""MediaPipe 脸型分类,返回 display(含混合脸型描述)或主脸型。"""
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
from face.face_shape_classifier import classify_from_image
|
||||||
|
|
||||||
|
raw = _resolve_image_bytes(image_bytes, image_url)
|
||||||
|
bgr = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
|
||||||
|
if bgr is None:
|
||||||
|
raise ValueError("图片格式不支持,无法解码")
|
||||||
|
result = classify_from_image(bgr, return_details=True, return_annotated=False)
|
||||||
|
shape = result.get("display") or result["face_shape"]
|
||||||
|
logger.info(
|
||||||
|
"local face_shape=%s conf=%.3f",
|
||||||
|
shape,
|
||||||
|
float(result.get("confidence") or 0),
|
||||||
|
)
|
||||||
|
return shape
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_features(image_bytes: bytes = None, image_url: str = None):
|
||||||
|
"""豆包分析 5 项特征 + 本机 MediaPipe 覆盖 face_shape。
|
||||||
|
|
||||||
|
Returns: dict —— 6 个英文字段(face_shape/eyebrow_shape/facial_age/
|
||||||
dynamic_static_type/gender/gene_style);**无人脸返回 None**(调用方据此判 1001)。
|
dynamic_static_type/gender/gene_style);**无人脸返回 None**(调用方据此判 1001)。
|
||||||
"""
|
"""
|
||||||
url = _image_to_url(image_bytes, image_url)
|
url = _image_to_url(image_bytes, image_url)
|
||||||
@@ -131,8 +169,17 @@ def analyze_features(image_bytes: bytes = None, image_url: str = None):
|
|||||||
raise RuntimeError(f"豆包模型返回格式异常,无法解析为 JSON:{text[:200]}") from e
|
raise RuntimeError(f"豆包模型返回格式异常,无法解析为 JSON:{text[:200]}") from e
|
||||||
if not has_face(raw):
|
if not has_face(raw):
|
||||||
return None
|
return None
|
||||||
# 只保留 6 个英文字段(doubao 缺某字段则跳过)
|
# 豆包 5 项 + 本地脸型覆盖
|
||||||
return {en: raw[zh] for zh, en in _KEY_MAP.items() if zh in raw}
|
feats = {en: raw[zh] for zh, en in _KEY_MAP.items() if zh in raw}
|
||||||
|
try:
|
||||||
|
feats["face_shape"] = _local_face_shape(image_bytes, image_url)
|
||||||
|
except ValueError as e:
|
||||||
|
logger.warning("本地脸型分类未检测到人脸: %s", e)
|
||||||
|
return None
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.exception("本地脸型分类失败")
|
||||||
|
raise RuntimeError(f"本地脸型分类失败:{e}") from e
|
||||||
|
return feats
|
||||||
|
|
||||||
|
|
||||||
def has_face(features: dict) -> bool:
|
def has_face(features: dict) -> bool:
|
||||||
|
|||||||
@@ -4,13 +4,10 @@
|
|||||||
不跑任何算法(无 torch/mediapipe/opencv 依赖)。
|
不跑任何算法(无 torch/mediapipe/opencv 依赖)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from io import BytesIO
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -67,32 +64,15 @@ async def lifespan(app: FastAPI):
|
|||||||
else:
|
else:
|
||||||
app.state._pool_shutdown = None
|
app.state._pool_shutdown = None
|
||||||
|
|
||||||
# 确保标注图目录存在
|
# 确保标注图目录存在(永久保留,不做定期清理)
|
||||||
static_dir = Path(cfg["static_dir"])
|
static_dir = Path(cfg["static_dir"])
|
||||||
static_dir.mkdir(parents=True, exist_ok=True)
|
static_dir.mkdir(parents=True, exist_ok=True)
|
||||||
logger.info("标注图目录: %s", static_dir)
|
logger.info("标注图目录: %s(永久保留,不自动清理)", static_dir)
|
||||||
|
|
||||||
# 启动定期清理任务(阶段四)
|
|
||||||
cleanup_shutdown = asyncio.Event()
|
|
||||||
cleanup_task = asyncio.create_task(
|
|
||||||
_cleanup_loop(static_dir, cfg, cleanup_shutdown)
|
|
||||||
)
|
|
||||||
app.state._cleanup_shutdown = cleanup_shutdown
|
|
||||||
app.state._cleanup_task = cleanup_task
|
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# 关闭
|
# 关闭
|
||||||
logger.info("网关关闭中...")
|
logger.info("网关关闭中...")
|
||||||
# 停止清理任务
|
|
||||||
if app.state._cleanup_shutdown:
|
|
||||||
app.state._cleanup_shutdown.set()
|
|
||||||
if app.state._cleanup_task:
|
|
||||||
app.state._cleanup_task.cancel()
|
|
||||||
try:
|
|
||||||
await app.state._cleanup_task
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
if app.state._pool_shutdown:
|
if app.state._pool_shutdown:
|
||||||
await app.state._pool_shutdown()
|
await app.state._pool_shutdown()
|
||||||
logger.info("网关已关闭")
|
logger.info("网关已关闭")
|
||||||
@@ -132,51 +112,6 @@ def _get_pool_status_safe():
|
|||||||
return {"total": 0, "healthy": 0, "busy": 0}
|
return {"total": 0, "healthy": 0, "busy": 0}
|
||||||
|
|
||||||
|
|
||||||
async def _cleanup_loop(annotations_dir: Path, cfg: dict, shutdown: asyncio.Event):
|
|
||||||
"""定期清理 static/annotations/ 中过期的标注图文件。
|
|
||||||
|
|
||||||
配置项(可选,在 config.json 中设定):
|
|
||||||
- cleanup.interval_minutes: 清理间隔,默认 60
|
|
||||||
- cleanup.max_age_hours: 文件保留时长(小时),默认 24
|
|
||||||
"""
|
|
||||||
cleanup_cfg = cfg.get("cleanup", {})
|
|
||||||
interval_s = cleanup_cfg.get("interval_minutes", 60) * 60
|
|
||||||
max_age_s = cleanup_cfg.get("max_age_hours", 24) * 3600
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"清理任务启动 | 间隔=%dmin | 保留=%dh | 目录=%s",
|
|
||||||
interval_s // 60, max_age_s // 3600, annotations_dir,
|
|
||||||
)
|
|
||||||
|
|
||||||
while not shutdown.is_set():
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(shutdown.wait(), timeout=interval_s)
|
|
||||||
break # shutdown
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
pass # 正常到时,执行清理
|
|
||||||
|
|
||||||
now = time.time()
|
|
||||||
deleted = 0
|
|
||||||
for f in annotations_dir.iterdir():
|
|
||||||
if f.name == ".gitkeep":
|
|
||||||
continue
|
|
||||||
if not f.is_file():
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
age_s = now - f.stat().st_mtime
|
|
||||||
if age_s > max_age_s:
|
|
||||||
f.unlink()
|
|
||||||
deleted += 1
|
|
||||||
logger.debug("清理过期文件: %s (age=%.1fh)", f.name, age_s / 3600)
|
|
||||||
except Exception:
|
|
||||||
logger.warning("清理文件失败: %s", f.name, exc_info=True)
|
|
||||||
|
|
||||||
if deleted:
|
|
||||||
logger.info("清理完成: 删除 %d 个过期文件", deleted)
|
|
||||||
|
|
||||||
logger.info("清理任务已停止")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/gateway-health", include_in_schema=False)
|
@app.get("/gateway-health", include_in_schema=False)
|
||||||
async def gateway_health():
|
async def gateway_health():
|
||||||
"""网关自身健康检查(区别于 worker 的 /health)。"""
|
"""网关自身健康检查(区别于 worker 的 /health)。"""
|
||||||
@@ -539,10 +474,15 @@ async def hair_grow(request: Request):
|
|||||||
return await _proxy(request, "/api/v1/hair/grow")
|
return await _proxy(request, "/api/v1/hair/grow")
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/hair/grow-b", tags=["生发"])
|
@app.post("/api/v1/hair/grow-b", tags=["生发"], deprecated=True)
|
||||||
async def hair_grow_b(request: Request):
|
async def hair_grow_b(request: Request):
|
||||||
"""接口3:B端生发"""
|
"""接口3:B端生发(已屏蔽)"""
|
||||||
return await _proxy(request, "/api/v1/hair/grow-b")
|
# 在网关层直接拦截,不转发到 worker 池——对所有上游 worker 立即生效。
|
||||||
|
import uuid as _uuid
|
||||||
|
return JSONResponse(status_code=200, content={
|
||||||
|
"code": 1007, "message": "接口3(/api/v1/hair/grow-b)已屏蔽,暂不提供服务",
|
||||||
|
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/face/features", tags=["人脸分析"])
|
@app.post("/api/v1/face/features", tags=["人脸分析"])
|
||||||
@@ -552,7 +492,7 @@ async def face_features(
|
|||||||
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(需带前缀)"),
|
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带前缀)"),
|
||||||
):
|
):
|
||||||
"""接口4:用户特征分析 — 本机直接调豆包视觉模型,不经过 worker。"""
|
"""接口4:用户特征分析 — 豆包 5 项 + 本机 MediaPipe 脸型(face/),不经过 worker。"""
|
||||||
import uuid as _uuid
|
import uuid as _uuid
|
||||||
|
|
||||||
# 三选一校验
|
# 三选一校验
|
||||||
|
|||||||
@@ -22,9 +22,5 @@
|
|||||||
"request_timeout_seconds": 600,
|
"request_timeout_seconds": 600,
|
||||||
"retry_on_failure": true,
|
"retry_on_failure": true,
|
||||||
"max_retries": 1
|
"max_retries": 1
|
||||||
},
|
|
||||||
"cleanup": {
|
|
||||||
"interval_minutes": 60,
|
|
||||||
"max_age_hours": 24
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,10 +31,6 @@ DEFAULTS = {
|
|||||||
"retry_on_failure": True,
|
"retry_on_failure": True,
|
||||||
"max_retries": 1,
|
"max_retries": 1,
|
||||||
},
|
},
|
||||||
"cleanup": {
|
|
||||||
"interval_minutes": 60,
|
|
||||||
"max_age_hours": 24,
|
|
||||||
},
|
|
||||||
"request_log": {
|
"request_log": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"log_file": "gateway/request_log.jsonl",
|
"log_file": "gateway/request_log.jsonl",
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
设计原则(与用户约定):
|
设计原则(与用户约定):
|
||||||
- 入参/出参日志里**绝不内嵌图片 base64**。图片统一存盘后用 URL 引用,保持日志短小。
|
- 入参/出参日志里**绝不内嵌图片 base64**。图片统一存盘后用 URL 引用,保持日志短小。
|
||||||
- 入参图片(image_file / *_base64)当前网关不存盘——这里补存到 static_dir(in_ 前缀),
|
- 入参图片(image_file / *_base64)补存到 static_dir(in_ 前缀),永久保留;
|
||||||
复用现有 24h 清理循环自动回收;url 输入图本身就在远端,不重新下载,直接记 URL。
|
url 输入图本身就在远端,不重新下载,直接记 URL。
|
||||||
- 出参响应在 forward.py 已把 base64 改写成 *_url(无大图),记录完整 data,
|
- 出参响应在 forward.py 已把 base64 改写成 *_url(无大图),记录完整 data,
|
||||||
但用递归摘要器截断超长结构(landmarks、长字符串),单条上限 ~8KB。
|
但用递归摘要器截断超长结构(landmarks、长字符串),单条上限 ~8KB。
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ def save_image_bytes(
|
|||||||
"""把图片字节存盘并返回公网 URL。失败返回 None(不抛异常)。
|
"""把图片字节存盘并返回公网 URL。失败返回 None(不抛异常)。
|
||||||
|
|
||||||
存到 static_dir/{prefix}{uuid}.{ext},URL = {public_base_url}/static/annotations/{file}。
|
存到 static_dir/{prefix}{uuid}.{ext},URL = {public_base_url}/static/annotations/{file}。
|
||||||
与 forward.py 的 rewrite_base64_to_url 落盘路径/URL 规则一致,可被同一清理循环回收。
|
与 forward.py 的 rewrite_base64_to_url 落盘路径/URL 规则一致;文件永久保留,不自动清理。
|
||||||
"""
|
"""
|
||||||
if not data:
|
if not data:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -410,7 +410,7 @@
|
|||||||
},
|
},
|
||||||
"60": {
|
"60": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"text": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
"text": "填充遮罩区域的头发"
|
||||||
},
|
},
|
||||||
"class_type": "JjkText",
|
"class_type": "JjkText",
|
||||||
"_meta": {
|
"_meta": {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from . import comfyui
|
|||||||
|
|
||||||
logger = logging.getLogger("hair.worker")
|
logger = logging.getLogger("hair.worker")
|
||||||
|
|
||||||
_DEFAULT_PROMPT = "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
_DEFAULT_PROMPT = "填充遮罩区域的头发"
|
||||||
_REPO = os.path.dirname(os.path.dirname(__file__))
|
_REPO = os.path.dirname(os.path.dirname(__file__))
|
||||||
_REPAINT_WORKFLOW = os.path.join(_REPO, "0716add-hair-api.json")
|
_REPAINT_WORKFLOW = os.path.join(_REPO, "0716add-hair-api.json")
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ def run_redraw(image_bytes: bytes, mask_bytes: bytes,
|
|||||||
Args:
|
Args:
|
||||||
image_bytes: 人物图片字节(JPG/PNG)
|
image_bytes: 人物图片字节(JPG/PNG)
|
||||||
mask_bytes: 遮罩图片字节(支持红/白/alpha 遮罩格式)
|
mask_bytes: 遮罩图片字节(支持红/白/alpha 遮罩格式)
|
||||||
prompt: 提示词,None 用默认 "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
prompt: 提示词,None 用默认 "填充遮罩区域的头发"
|
||||||
timeout: ComfyUI 超时秒数
|
timeout: ComfyUI 超时秒数
|
||||||
front: True 时任务插到 ComfyUI 队列最前(接口2 时延敏感路径用)
|
front: True 时任务插到 ComfyUI 队列最前(接口2 时延敏感路径用)
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ import logging
|
|||||||
logger = logging.getLogger("hair.worker")
|
logger = logging.getLogger("hair.worker")
|
||||||
|
|
||||||
# 接口2 女性发型 key → change_hair hair_id(chang_*)映射:换发型+Flux-2 整帧重绘用。
|
# 接口2 女性发型 key → change_hair hair_id(chang_*)映射:换发型+Flux-2 整帧重绘用。
|
||||||
# 与接口12 final 的 5 型一一对应。
|
# 与接口12 final 的 5 型一一对应。female 6/7(bigflower/clasicalflower)无对应 LoRA,
|
||||||
|
# 走与男性一致的原生生发(ComfyUI add_hair)管线,故不在本表。
|
||||||
_FEMALE_KEY_TO_CHANG = {
|
_FEMALE_KEY_TO_CHANG = {
|
||||||
"ellipse": "chang_tuoyuan", # 椭圆
|
"ellipse": "chang_tuoyuan", # 椭圆
|
||||||
"flower": "chang_huaban", # 花瓣
|
"flower": "chang_huaban", # 花瓣
|
||||||
@@ -36,6 +37,16 @@ _FEMALE_KEY_TO_CHANG = {
|
|||||||
"wave": "chang_bolang", # 波浪
|
"wave": "chang_bolang", # 波浪
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 发际线贴图显式顺序表:决定 hair_style 序号(1-indexed)。
|
||||||
|
# 不再依赖文件名字母序——字母序会因新增/重命名文件而错位,破坏现有前端/客户端取值。
|
||||||
|
# key 须与 _gender_key 派生结果一致(已去空格):如 "inverse_arc"(源 man_ inverse_arc.png)、
|
||||||
|
# "Softpetal"(源 man_Soft petal.png,大写 S 保留)。表外未知 key 兜底排到末尾。
|
||||||
|
_HAIRSTYLE_ORDER = {
|
||||||
|
"female": ["ellipse", "flower", "heart", "straight", "wave",
|
||||||
|
"bigflower", "clasicalflower"], # 1..7
|
||||||
|
"male": ["ellipse", "inverse_arc", "m", "straight", "heart", "Softpetal"], # 1..6
|
||||||
|
}
|
||||||
|
|
||||||
_REPO = os.path.dirname(os.path.dirname(__file__))
|
_REPO = os.path.dirname(os.path.dirname(__file__))
|
||||||
_TEXTURE_DIR = os.path.join(_REPO, "hairline_texture")
|
_TEXTURE_DIR = os.path.join(_REPO, "hairline_texture")
|
||||||
_BLACK_TEXTURE_DIR = os.path.join(_REPO, "hairline_texture_black")
|
_BLACK_TEXTURE_DIR = os.path.join(_REPO, "hairline_texture_black")
|
||||||
@@ -89,10 +100,13 @@ def _gender_key(stem: str):
|
|||||||
|
|
||||||
|
|
||||||
def get_texture_map(level: str = "middle") -> dict:
|
def get_texture_map(level: str = "middle") -> dict:
|
||||||
"""扫描指定档位贴图目录建 {gender: [(key, path)]},按 key 排序、按档位缓存。
|
"""扫描指定档位贴图目录建 {gender: [(key, path)]},按显式顺序表排序、按档位缓存。
|
||||||
|
|
||||||
level:middle(默认) / high / low,分别对应 hairline_texture[/_high|/_low]。
|
level:middle(默认) / high / low,分别对应 hairline_texture[/_high|/_low]。
|
||||||
文件名规范化去空格(如 `man_ inverse_arc.png` → key `inverse_arc`)。
|
文件名规范化去空格(如 `man_ inverse_arc.png` → key `inverse_arc`)。
|
||||||
|
|
||||||
|
排序依据 _HAIRSTYLE_ORDER:表内 key 按表序、表外未知 key 兜底排到末尾(再按字母序),
|
||||||
|
保证新增/重命名文件不会打乱现有 hair_style 序号。
|
||||||
"""
|
"""
|
||||||
if level not in _TEXTURE_DIRS:
|
if level not in _TEXTURE_DIRS:
|
||||||
raise ValueError(f"hairline_level 必须是 middle/high/low,收到 {level!r}")
|
raise ValueError(f"hairline_level 必须是 middle/high/low,收到 {level!r}")
|
||||||
@@ -106,7 +120,9 @@ def get_texture_map(level: str = "middle") -> dict:
|
|||||||
if gender:
|
if gender:
|
||||||
mapping[gender].append((key, path))
|
mapping[gender].append((key, path))
|
||||||
for g in mapping:
|
for g in mapping:
|
||||||
mapping[g].sort(key=lambda kp: kp[0])
|
order = _HAIRSTYLE_ORDER.get(g, [])
|
||||||
|
idx = {k: i for i, k in enumerate(order)}
|
||||||
|
mapping[g].sort(key=lambda kp: (idx.get(kp[0], len(idx)), kp[0]))
|
||||||
_texture_maps[level] = mapping
|
_texture_maps[level] = mapping
|
||||||
return mapping
|
return mapping
|
||||||
|
|
||||||
@@ -163,7 +179,7 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T
|
|||||||
workflow_path: str | None = None):
|
workflow_path: str | None = None):
|
||||||
"""指定发际线类型:发际线透明叠图(白线 RGBA) + 生发图(ComfyUI)。
|
"""指定发际线类型:发际线透明叠图(白线 RGBA) + 生发图(ComfyUI)。
|
||||||
|
|
||||||
hair_styles(1-indexed 列表):指定生成哪几张发际线(按贴图排序)。female: 1..5,male: 1..4。
|
hair_styles(1-indexed 列表):指定生成哪几张发际线(按贴图排序)。female: 1..7,male: 1..6。
|
||||||
为 None 时生成全部(兼容旧调用)。
|
为 None 时生成全部(兼容旧调用)。
|
||||||
use_mask(默认 True):是否启用 inpaint 遮罩,用于测试对比(同接口3)。
|
use_mask(默认 True):是否启用 inpaint 遮罩,用于测试对比(同接口3)。
|
||||||
False 时用**干净原图 + 空遮罩**送 ComfyUI(不烧黑色模板线)。
|
False 时用**干净原图 + 空遮罩**送 ComfyUI(不烧黑色模板线)。
|
||||||
@@ -227,15 +243,22 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T
|
|||||||
|
|
||||||
|
|
||||||
def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | None,
|
def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | None,
|
||||||
redraw_defaults: dict):
|
redraw_defaults: dict,
|
||||||
"""接口2 女性专用:发际线透明叠图(同 generate_grow_results)+ 换发型重绘图。
|
redraw_max_side: int | None = None,
|
||||||
|
unet_name: str | None = None,
|
||||||
|
prompt: str | None = None):
|
||||||
|
"""接口2 女性专用:发际线透明叠图(同 generate_grow_results)+ 生发图。
|
||||||
|
|
||||||
grown 图来源(新流程):对每个选中发型把 female key 映射到 change_hair 的 chang_* hair_id,
|
生发图来源按发型分两路:
|
||||||
调 face_analysis.hairline_grow.generate_hairline_redraw(= 接口12 final 管线,参数用
|
- 换发型(chang_* 表内,1..5):female key → change_hair 的 chang_* hair_id,调
|
||||||
redraw_defaults)拿到 ④ final(接缝融合基底)+ ⑤-② 纯红遮罩 PNG,再**后端直接调
|
face_analysis.hairline_grow.generate_hairline_redraw(= 接口12 final 管线,参数用
|
||||||
ComfyUI**(0716add-hair-api.json 工作流)完成发际线带重绘,重绘结果作为生发图。
|
redraw_defaults)拿到 ④ final(接缝融合基底)+ ⑤-② 纯红遮罩 PNG,再**后端直接调
|
||||||
|
ComfyUI**(0716add-hair-api.json 工作流)完成发际线带重绘。
|
||||||
|
- 原生生发(表外,6/7 bigflower/clasicalflower 等):无对应 change_hair LoRA,改走与男性
|
||||||
|
一致的原生生发(ComfyUI add_hair),由 _grow_native_one 完成(黑模板 + inpaint 遮罩)。
|
||||||
|
|
||||||
overlay 仍是发际线曲线透明层(与 generate_grow_results 完全一致)。
|
overlay 仍是发际线曲线透明层(与 generate_grow_results 完全一致)。
|
||||||
|
prompt 仅用于原生生发分支(换发型分支的提示词由 redraw 流程内部固定)。
|
||||||
Returns: list[dict] {"hairline_type","order","overlay","grown_png"(jpg bytes 或 None)};
|
Returns: list[dict] {"hairline_type","order","overlay","grown_png"(jpg bytes 或 None)};
|
||||||
无人脸返回 None。单个发型换发型/重绘失败时 grown_png=None,不抛异常。
|
无人脸返回 None。单个发型换发型/重绘失败时 grown_png=None,不抛异常。
|
||||||
"""
|
"""
|
||||||
@@ -265,7 +288,10 @@ def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | N
|
|||||||
grown_png = None
|
grown_png = None
|
||||||
chang_id = _FEMALE_KEY_TO_CHANG.get(key)
|
chang_id = _FEMALE_KEY_TO_CHANG.get(key)
|
||||||
if chang_id is None:
|
if chang_id is None:
|
||||||
logger.warning("接口2 换发型:female key=%s 无对应 chang_id,跳过生发图", key)
|
# 无对应 change_hair LoRA(如 bigflower/clasicalflower)→ 走与男性一致的原生生发
|
||||||
|
logger.info("接口2 女 key=%s 无 chang_id,走原生生发(ComfyUI add_hair)", key)
|
||||||
|
grown_png = _grow_native_one(image_bgr, ctx, white_path,
|
||||||
|
prompt=prompt, unet_name=unet_name)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
data = generate_hairline_redraw(image_bgr, chang_id, hair_mask=hair_mask_reuse, **redraw_defaults)
|
data = generate_hairline_redraw(image_bgr, chang_id, hair_mask=hair_mask_reuse, **redraw_defaults)
|
||||||
@@ -321,6 +347,32 @@ def _grow_from_texture(image_bgr: np.ndarray, ctx: dict, white_path: str | None,
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _grow_native_one(image_bgr: np.ndarray, ctx: dict, white_path: str,
|
||||||
|
prompt: str | None = None, unet_name: str | None = None):
|
||||||
|
"""对单个发际线做原生生发(ComfyUI add_hair),与男性 generate_grow_results 一致。
|
||||||
|
|
||||||
|
接口2 女性新发型(bigflower/clasicalflower 等)无对应 change_hair LoRA,改走此路径:
|
||||||
|
黑模板 → build_inpaint_mask → 限边(_prep_comfy_input)→ comfyui.run(front=True)。
|
||||||
|
失败返回 None,不抛异常。结果按限边前原图尺寸放大回原尺寸(仅展示对齐)。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
h, w = image_bgr.shape[:2]
|
||||||
|
black = load_texture_rgba(_black_texture_path(white_path))
|
||||||
|
marked, mask = build_inpaint_mask(
|
||||||
|
image_bgr, ctx["landmarks"], ctx["parse_map"], ctx["points"], black)
|
||||||
|
m_s, msk_s, gsc = _prep_comfy_input(marked, mask)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
compose_comfy_rgba(m_s, msk_s).save(buf, format="PNG", compress_level=1)
|
||||||
|
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前
|
||||||
|
grown_png = comfyui.run(buf.getvalue(), prompt=prompt, front=True, unet_name=unet_name)
|
||||||
|
if gsc < 1.0 and grown_png:
|
||||||
|
grown_png = _upscale_png_to(grown_png, w, h)
|
||||||
|
return grown_png
|
||||||
|
except Exception as e: # noqa: BLE001 单张失败不拖垮整请求
|
||||||
|
logger.warning("接口2 女原生生发图失败:%s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def generate_hairline_pngs(image_bgr: np.ndarray, gender: str,
|
def generate_hairline_pngs(image_bgr: np.ndarray, gender: str,
|
||||||
hair_styles: list[int], use_mask: bool = True,
|
hair_styles: list[int], use_mask: bool = True,
|
||||||
prompt: str | None = None,
|
prompt: str | None = None,
|
||||||
|
|||||||
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.8 KiB |
@@ -206,7 +206,7 @@ def generate():
|
|||||||
return jsonify({"error": msg}), 400
|
return jsonify({"error": msg}), 400
|
||||||
image_file = request.files["image"]
|
image_file = request.files["image"]
|
||||||
mask_file = request.files["mask"]
|
mask_file = request.files["mask"]
|
||||||
prompt_text = request.form.get("prompt", "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜")
|
prompt_text = request.form.get("prompt", "填充遮罩区域的头发")
|
||||||
log.info(
|
log.info(
|
||||||
"收到请求: image=%s mask=%s prompt=%r",
|
"收到请求: image=%s mask=%s prompt=%r",
|
||||||
image_file.filename, mask_file.filename, prompt_text,
|
image_file.filename, mask_file.filename, prompt_text,
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ def prep_and_upload():
|
|||||||
|
|
||||||
|
|
||||||
def run_once(fname, model, dtype, steps):
|
def run_once(fname, model, dtype, steps):
|
||||||
wf = A.build_workflow(fname, "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜")
|
wf = A.build_workflow(fname, "填充遮罩区域的头发")
|
||||||
wf["16"]["inputs"]["unet_name"] = model
|
wf["16"]["inputs"]["unet_name"] = model
|
||||||
wf["16"]["inputs"]["weight_dtype"] = dtype
|
wf["16"]["inputs"]["weight_dtype"] = dtype
|
||||||
wf["1"]["inputs"]["steps"] = steps
|
wf["1"]["inputs"]["steps"] = steps
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ def upload(scale=1.0):
|
|||||||
|
|
||||||
|
|
||||||
def run(fname, steps):
|
def run(fname, steps):
|
||||||
wf = A.build_workflow(fname, "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜")
|
wf = A.build_workflow(fname, "填充遮罩区域的头发")
|
||||||
wf["16"]["inputs"]["unet_name"] = MODEL
|
wf["16"]["inputs"]["unet_name"] = MODEL
|
||||||
wf["16"]["inputs"]["weight_dtype"] = DTYPE
|
wf["16"]["inputs"]["weight_dtype"] = DTYPE
|
||||||
wf["1"]["inputs"]["steps"] = steps
|
wf["1"]["inputs"]["steps"] = steps
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ SEED = 123456789
|
|||||||
imgs = []
|
imgs = []
|
||||||
labels = []
|
labels = []
|
||||||
for steps in [2, 3, 4, 6]:
|
for steps in [2, 3, 4, 6]:
|
||||||
wf = A.build_workflow(fname, "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜", seed=SEED)
|
wf = A.build_workflow(fname, "填充遮罩区域的头发", seed=SEED)
|
||||||
wf["16"]["inputs"]["unet_name"] = MODEL
|
wf["16"]["inputs"]["unet_name"] = MODEL
|
||||||
wf["16"]["inputs"]["weight_dtype"] = DTYPE
|
wf["16"]["inputs"]["weight_dtype"] = DTYPE
|
||||||
wf["1"]["inputs"]["steps"] = steps
|
wf["1"]["inputs"]["steps"] = steps
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ button { padding: 10px 24px; border: none; border-radius: 6px; cursor: pointer;
|
|||||||
</div>
|
</div>
|
||||||
<div class="controls" style="margin-top:16px">
|
<div class="controls" style="margin-top:16px">
|
||||||
<label>提示词:</label>
|
<label>提示词:</label>
|
||||||
<input type="text" id="promptInput" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜">
|
<input type="text" id="promptInput" value="填充遮罩区域的头发">
|
||||||
</div>
|
</div>
|
||||||
<div style="text-align:center; margin-top:16px">
|
<div style="text-align:center; margin-top:16px">
|
||||||
<button class="btn-generate" id="generateBtn" disabled>🚀 生成</button>
|
<button class="btn-generate" id="generateBtn" disabled>🚀 生成</button>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ resp = requests.post(
|
|||||||
"image": ("original.jpg", img_data, "image/jpeg"),
|
"image": ("original.jpg", img_data, "image/jpeg"),
|
||||||
"mask": ("mask.png", mask_data, "image/png"),
|
"mask": ("mask.png", mask_data, "image/png"),
|
||||||
},
|
},
|
||||||
data={"prompt": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"},
|
data={"prompt": "填充遮罩区域的头发"},
|
||||||
timeout=600,
|
timeout=600,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -26,8 +26,11 @@ transformers==4.45.2 # SegFormer 人脸分割(jonathandinu/face-parsing,
|
|||||||
# ⚠️ 必须 0.24.x —— 0.25+ 强依赖 numpy>=2,会顶掉 mediapipe 需要的 numpy<2
|
# ⚠️ 必须 0.24.x —— 0.25+ 强依赖 numpy>=2,会顶掉 mediapipe 需要的 numpy<2
|
||||||
scikit-image==0.24.0 # route_through_array(黑帽响应图上的 Dijkstra 最小路径)
|
scikit-image==0.24.0 # route_through_array(黑帽响应图上的 Dijkstra 最小路径)
|
||||||
|
|
||||||
# 接口4:用户特征(火山方舟 豆包视觉模型)—— 已迁到**网关**实现,worker 不需要。
|
# 接口4:用户特征(火山方舟 豆包视觉模型 + 本机 MediaPipe 脸型覆盖)—— 已迁到**网关**实现,worker 不需要。
|
||||||
# 网关机装:volcengine-python-sdk[ark](from volcenginesdkarkruntime import Ark);API Key 走配置不入 git
|
# 网关机装:volcengine-python-sdk[ark](from volcenginesdkarkruntime import Ark);API Key 走配置不入 git
|
||||||
|
# ⚠️ face_shape 字段改为本机 face/face_shape_classifier.py 计算(覆盖豆包结果),
|
||||||
|
# 因此网关机现在也需要 mediapipe==0.10.14 + opencv-python==4.10.0.84 + numpy==1.26.4
|
||||||
|
# (不再是"网关不需要 mediapipe",见 docs/实现说明.md 需同步更新)
|
||||||
|
|
||||||
# 测试
|
# 测试
|
||||||
pytest==8.3.3
|
pytest==8.3.3
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>脸型分类调试 — MediaPipe</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; }
|
||||||
|
.container { max-width: 1200px; margin: 0 auto; padding: 24px; }
|
||||||
|
h1 { font-size: 22px; margin-bottom: 6px; }
|
||||||
|
.subtitle { color: #888; font-size: 13px; margin-bottom: 24px; }
|
||||||
|
.subtitle code { background: #eef2ff; color: #3730a3; padding: 1px 6px; border-radius: 4px; font-size: 12px; }
|
||||||
|
|
||||||
|
.card { background: #fff; border-radius: 12px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 20px; }
|
||||||
|
.card-header { font-weight: 700; font-size: 14px; padding: 14px 18px; border-bottom: 1px solid #f0f0f0; background: #fafafa; display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.card-body { padding: 18px; }
|
||||||
|
|
||||||
|
.upload-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.file-input { flex: 1; min-width: 200px; }
|
||||||
|
.file-input input[type=file] { width: 100%; padding: 8px; border: 2px dashed #ddd; border-radius: 8px; cursor: pointer; }
|
||||||
|
.btn { padding: 10px 28px; border: none; border-radius: 8px; font-size: 15px; cursor: pointer; font-weight: 600; transition: .2s; }
|
||||||
|
.btn-primary { background: #2563eb; color: #fff; }
|
||||||
|
.btn-primary:hover { background: #1d4ed8; }
|
||||||
|
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
|
||||||
|
.btn-sm { padding: 6px 14px; font-size: 13px; }
|
||||||
|
.btn-outline { background: #fff; border: 1px solid #d1d5db; color: #374151; }
|
||||||
|
.btn-outline:hover { background: #f9fafb; }
|
||||||
|
.hint { font-size: 12px; color: #9ca3af; margin-top: 8px; }
|
||||||
|
|
||||||
|
.status { padding: 10px 16px; border-radius: 8px; font-size: 14px; margin-bottom: 16px; display: none; }
|
||||||
|
.status.info { background: #dbeafe; color: #1e40af; display: block; }
|
||||||
|
.status.error { background: #fee2e2; color: #991b1b; display: block; }
|
||||||
|
.status.success { background: #d1fae5; color: #065f46; display: block; }
|
||||||
|
|
||||||
|
.results-layout { display: flex; gap: 24px; }
|
||||||
|
.col-main { flex: 1.4; min-width: 0; }
|
||||||
|
.col-side { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
.verdict { display: flex; gap: 16px; flex-wrap: wrap; align-items: stretch; }
|
||||||
|
.verdict-item { flex: 1; min-width: 140px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 14px 16px; }
|
||||||
|
.verdict-item .label { font-size: 11px; color: #64748b; text-transform: uppercase; letter-spacing: .4px; margin-bottom: 6px; }
|
||||||
|
.verdict-item .value { font-size: 20px; font-weight: 700; color: #0f172a; }
|
||||||
|
.verdict-item .value.accent { color: #2563eb; }
|
||||||
|
.badge-mixed { display: inline-block; margin-left: 8px; background: #fef3c7; color: #92400e; font-size: 11px; padding: 2px 8px; border-radius: 10px; font-weight: 700; vertical-align: middle; }
|
||||||
|
|
||||||
|
.img-preview { text-align: center; background: #222; border-radius: 8px; overflow: hidden; min-height: 200px; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.img-preview img { max-width: 100%; max-height: 560px; object-fit: contain; display: block; }
|
||||||
|
.img-preview .placeholder { color: #9ca3af; padding: 40px; font-size: 14px; }
|
||||||
|
|
||||||
|
.bar-list { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.bar-row { display: grid; grid-template-columns: 72px 1fr 52px; gap: 10px; align-items: center; font-size: 13px; }
|
||||||
|
.bar-row .name { font-weight: 600; color: #334155; }
|
||||||
|
.bar-row .track { height: 10px; background: #e2e8f0; border-radius: 999px; overflow: hidden; }
|
||||||
|
.bar-row .fill { height: 100%; background: #94a3b8; border-radius: 999px; }
|
||||||
|
.bar-row.top .fill { background: #2563eb; }
|
||||||
|
.bar-row.second .fill { background: #60a5fa; }
|
||||||
|
.bar-row .score { text-align: right; font-variant-numeric: tabular-nums; color: #475569; }
|
||||||
|
|
||||||
|
.feat-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
|
.feat-table th, .feat-table td { text-align: left; padding: 8px 12px; border-bottom: 1px solid #f1f5f9; }
|
||||||
|
.feat-table th { background: #f8fafc; font-weight: 700; color: #475569; font-size: 11px; text-transform: uppercase; letter-spacing: .3px; }
|
||||||
|
.feat-table td:first-child { font-weight: 600; color: #1e293b; width: 180px; }
|
||||||
|
.feat-table tr:hover td { background: #f8fafc; }
|
||||||
|
|
||||||
|
.json-panel { max-height: 520px; overflow: auto; }
|
||||||
|
.json-content { padding: 14px 16px; font-family: "SF Mono", "Fira Code", monospace; font-size: 12px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; }
|
||||||
|
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
@media (max-width: 800px) { .results-layout { flex-direction: column; } }
|
||||||
|
</style>
|
||||||
|
<script src="/static/img_downscale.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>脸型分类调试(MediaPipe)</h1>
|
||||||
|
<p class="subtitle">
|
||||||
|
POST <code>/api/v1/debug/face-shape</code>
|
||||||
|
| 本地 <code>face/face_shape_classifier.py</code>(7 类)
|
||||||
|
| 非接口4 豆包分析
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="upload-row">
|
||||||
|
<div class="file-input"><input type="file" id="imageFile" accept="image/jpeg,image/png,.jpg,.jpeg,.png"></div>
|
||||||
|
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">分析脸型</button>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="clearResults()">清除</button>
|
||||||
|
</div>
|
||||||
|
<div class="hint">JPG/PNG 正面照 | 走本机 worker(MediaPipe),约 1s 内</div>
|
||||||
|
<div id="statusBar" class="status hidden"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="results-layout hidden" id="resultsArea">
|
||||||
|
<div class="col-main">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><span>判定结果</span></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="verdict" id="verdictBox"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><span>特征标注图</span></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="img-preview" id="imgPreview"><span class="placeholder">—</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><span>各脸型得分</span></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="bar-list" id="scoreBars"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-side">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><span>几何特征</span></div>
|
||||||
|
<div class="card-body" style="padding:0;max-height:360px;overflow:auto">
|
||||||
|
<table class="feat-table" id="featTable"></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><span>原始 JSON</span><button class="btn btn-outline btn-sm" onclick="copyJson()">复制</button></div>
|
||||||
|
<div class="json-panel"><pre class="json-content" id="jsonContent"></pre></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API_BASE = window.location.origin;
|
||||||
|
const TOKEN = 'dev-shared-secret-2026';
|
||||||
|
|
||||||
|
const FEAT_LABELS = {
|
||||||
|
face_height: '脸高 (px)',
|
||||||
|
face_width: '脸宽 (px)',
|
||||||
|
forehead_width: '额宽 (px)',
|
||||||
|
cheekbone_width: '颧宽 (px)',
|
||||||
|
jaw_width: '下颌宽 (px)',
|
||||||
|
chin_width: '下巴宽 (px)',
|
||||||
|
aspect_ratio: '长宽比 (宽/高)',
|
||||||
|
forehead_ratio: '额宽/面宽',
|
||||||
|
cheekbone_ratio: '颧宽/面宽',
|
||||||
|
jaw_ratio: '下颌宽/面宽',
|
||||||
|
chin_ratio: '下巴宽/面宽',
|
||||||
|
chin_sharpness: '下巴尖锐度',
|
||||||
|
taper_ratio: '额头→下巴收窄',
|
||||||
|
jaw_angle: '下颌角 (°)',
|
||||||
|
width_uniformity: '宽度均匀度',
|
||||||
|
face_curve_score: '面部曲线分',
|
||||||
|
};
|
||||||
|
|
||||||
|
function $(id) { return document.getElementById(id); }
|
||||||
|
function setStatus(t, type) {
|
||||||
|
const b = $('statusBar');
|
||||||
|
b.textContent = t;
|
||||||
|
b.className = 'status ' + type;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearResults() {
|
||||||
|
$('resultsArea').classList.add('hidden');
|
||||||
|
$('statusBar').className = 'status hidden';
|
||||||
|
$('imageFile').value = '';
|
||||||
|
$('jsonContent').textContent = '';
|
||||||
|
$('imgPreview').innerHTML = '<span class="placeholder">—</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitTest() {
|
||||||
|
let f = $('imageFile').files[0];
|
||||||
|
if (!f) { setStatus('请选择图片', 'error'); return; }
|
||||||
|
if (window.downscaleImageFile) f = await window.downscaleImageFile(f);
|
||||||
|
|
||||||
|
$('submitBtn').disabled = true;
|
||||||
|
$('submitBtn').textContent = '分析中...';
|
||||||
|
setStatus('调用 MediaPipe 脸型分类...', 'info');
|
||||||
|
$('resultsArea').classList.add('hidden');
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('image_file', f);
|
||||||
|
const t0 = performance.now();
|
||||||
|
try {
|
||||||
|
const r = await fetch(API_BASE + '/api/v1/debug/face-shape', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-Internal-Token': TOKEN },
|
||||||
|
body: fd,
|
||||||
|
});
|
||||||
|
const json = await r.json();
|
||||||
|
const elapsed = ((performance.now() - t0) / 1000).toFixed(2);
|
||||||
|
$('jsonContent').textContent = JSON.stringify(json, null, 2);
|
||||||
|
$('resultsArea').classList.remove('hidden');
|
||||||
|
|
||||||
|
if (json.code === 0) {
|
||||||
|
setStatus('完成 (' + elapsed + 's)', 'success');
|
||||||
|
renderResult(json.data);
|
||||||
|
} else {
|
||||||
|
setStatus('(' + elapsed + 's) code=' + json.code + ' ' + json.message, 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setStatus('请求失败: ' + e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
$('submitBtn').disabled = false;
|
||||||
|
$('submitBtn').textContent = '分析脸型';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResult(data) {
|
||||||
|
const mixed = data.is_mixed
|
||||||
|
? '<span class="badge-mixed">混合 · 次选 ' + (data.second_shape || '—') + '</span>'
|
||||||
|
: '';
|
||||||
|
$('verdictBox').innerHTML =
|
||||||
|
'<div class="verdict-item"><div class="label">脸型</div><div class="value accent">' +
|
||||||
|
esc(data.display || data.face_shape) + mixed + '</div></div>' +
|
||||||
|
'<div class="verdict-item"><div class="label">置信度</div><div class="value">' +
|
||||||
|
(data.confidence * 100).toFixed(1) + '%</div></div>' +
|
||||||
|
'<div class="verdict-item"><div class="label">分差 score_gap</div><div class="value">' +
|
||||||
|
(data.score_gap == null ? '—' : data.score_gap) + '</div></div>' +
|
||||||
|
'<div class="verdict-item"><div class="label">尺寸</div><div class="value" style="font-size:16px">' +
|
||||||
|
(data.image_size ? data.image_size.width + '×' + data.image_size.height : '—') + '</div></div>';
|
||||||
|
|
||||||
|
if (data.annotated_image_base64) {
|
||||||
|
$('imgPreview').innerHTML =
|
||||||
|
'<img src="data:image/jpeg;base64,' + data.annotated_image_base64 + '" alt="annotated">';
|
||||||
|
} else {
|
||||||
|
$('imgPreview').innerHTML = '<span class="placeholder">无标注图</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
const ranked = data.ranked || [];
|
||||||
|
const maxScore = ranked.length ? Math.max.apply(null, ranked.map(function (x) { return x.score; })) : 100;
|
||||||
|
$('scoreBars').innerHTML = ranked.map(function (row, i) {
|
||||||
|
const cls = i === 0 ? ' top' : (i === 1 ? ' second' : '');
|
||||||
|
const pct = maxScore > 0 ? (100 * row.score / maxScore) : 0;
|
||||||
|
return '<div class="bar-row' + cls + '">' +
|
||||||
|
'<div class="name">' + esc(row.shape) + '</div>' +
|
||||||
|
'<div class="track"><div class="fill" style="width:' + pct.toFixed(1) + '%"></div></div>' +
|
||||||
|
'<div class="score">' + Number(row.score).toFixed(1) + '</div>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const feats = data.features || {};
|
||||||
|
const keys = Object.keys(feats);
|
||||||
|
let html = '<tr><th>特征</th><th>值</th></tr>';
|
||||||
|
keys.forEach(function (k) {
|
||||||
|
const label = FEAT_LABELS[k] || k;
|
||||||
|
const v = feats[k];
|
||||||
|
const text = typeof v === 'number' ? (Number.isInteger(v) ? v : v.toFixed(4)) : String(v);
|
||||||
|
html += '<tr><td>' + esc(label) + '</td><td>' + esc(String(text)) + '</td></tr>';
|
||||||
|
});
|
||||||
|
$('featTable').innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s).replace(/[&<>"']/g, function (c) {
|
||||||
|
return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyJson() {
|
||||||
|
const t = $('jsonContent').textContent;
|
||||||
|
if (!t) return;
|
||||||
|
navigator.clipboard.writeText(t).then(function () {
|
||||||
|
setStatus('已复制 JSON', 'success');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="upload-row" style="margin-top:10px">
|
<div class="upload-row" style="margin-top:10px">
|
||||||
<label style="font-size:13px;font-weight:600;white-space:nowrap">重绘提示词</label>
|
<label style="font-size:13px;font-weight:600;white-space:nowrap">重绘提示词</label>
|
||||||
<input type="text" id="localTestPrompt" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
<input type="text" id="localTestPrompt" value="填充遮罩区域的头发" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
||||||
</div>
|
</div>
|
||||||
<div class="upload-row" style="margin-top:10px">
|
<div class="upload-row" style="margin-top:10px">
|
||||||
<label style="font-size:13px;font-weight:600;white-space:nowrap">X-Internal-Token</label>
|
<label style="font-size:13px;font-weight:600;white-space:nowrap">X-Internal-Token</label>
|
||||||
@@ -354,7 +354,7 @@ function dataUriToBlob(dataUri) {
|
|||||||
|
|
||||||
async function runLocalRedraw() {
|
async function runLocalRedraw() {
|
||||||
if (!_finalDataUri || !_maskDataUri) { setStatus('缺少 final 或遮罩,请先生成', 'error'); return; }
|
if (!_finalDataUri || !_maskDataUri) { setStatus('缺少 final 或遮罩,请先生成', 'error'); return; }
|
||||||
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜';
|
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发';
|
||||||
const btn = $('redrawBtn');
|
const btn = $('redrawBtn');
|
||||||
btn.disabled = true; btn.textContent = '⏳ 重绘中...';
|
btn.disabled = true; btn.textContent = '⏳ 重绘中...';
|
||||||
flog('===== 调后端重绘 =====', 'info');
|
flog('===== 调后端重绘 =====', 'info');
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="upload-row" style="margin-top:10px">
|
<div class="upload-row" style="margin-top:10px">
|
||||||
<label style="font-size:13px;font-weight:600;white-space:nowrap">重绘提示词</label>
|
<label style="font-size:13px;font-weight:600;white-space:nowrap">重绘提示词</label>
|
||||||
<input type="text" id="localTestPrompt" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
<input type="text" id="localTestPrompt" value="填充遮罩区域的头发" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
||||||
</div>
|
</div>
|
||||||
<div class="params">
|
<div class="params">
|
||||||
<div class="pf">
|
<div class="pf">
|
||||||
@@ -169,7 +169,7 @@ async function submitTest() {
|
|||||||
|
|
||||||
async function runLocalRedraw() {
|
async function runLocalRedraw() {
|
||||||
if (!_finalDataUri || !_maskDataUri) { setStatus('缺少 final 或遮罩,请先生成', 'error'); return; }
|
if (!_finalDataUri || !_maskDataUri) { setStatus('缺少 final 或遮罩,请先生成', 'error'); return; }
|
||||||
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜';
|
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发';
|
||||||
const btn = $('redrawBtn');
|
const btn = $('redrawBtn');
|
||||||
btn.disabled = true; btn.textContent = '⏳ 重绘中...';
|
btn.disabled = true; btn.textContent = '⏳ 重绘中...';
|
||||||
|
|
||||||
|
|||||||
@@ -107,7 +107,7 @@
|
|||||||
<div class="hint">JPG/PNG | 生发图生成较慢(数十秒~数分钟),请耐心等待</div>
|
<div class="hint">JPG/PNG | 生发图生成较慢(数十秒~数分钟),请耐心等待</div>
|
||||||
<div style="margin-top:10px;display:flex;align-items:center;gap:8px">
|
<div style="margin-top:10px;display:flex;align-items:center;gap:8px">
|
||||||
<label style="font-size:13px;font-weight:600;color:#374151;white-space:nowrap">💬 提示词</label>
|
<label style="font-size:13px;font-weight:600;color:#374151;white-space:nowrap">💬 提示词</label>
|
||||||
<input type="text" id="promptInput" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
|
<input type="text" id="promptInput" value="填充遮罩区域的头发" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
|
||||||
</div>
|
</div>
|
||||||
<div id="statusBar" class="status hidden"></div>
|
<div id="statusBar" class="status hidden"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -237,12 +237,16 @@ const HAIR_STYLES = {
|
|||||||
{ value:'3', label:'heart 心形' },
|
{ value:'3', label:'heart 心形' },
|
||||||
{ value:'4', label:'straight 直线形' },
|
{ value:'4', label:'straight 直线形' },
|
||||||
{ value:'5', label:'wave 波浪形' },
|
{ value:'5', label:'wave 波浪形' },
|
||||||
|
{ value:'6', label:'bigflower 大花瓣型' },
|
||||||
|
{ value:'7', label:'clasicalflower 古典花瓣型' },
|
||||||
],
|
],
|
||||||
male: [
|
male: [
|
||||||
{ value:'1', label:'ellipse 椭圆形' },
|
{ value:'1', label:'ellipse 椭圆形' },
|
||||||
{ value:'2', label:'inverse_arc 倒弧形' },
|
{ value:'2', label:'inverse_arc 倒弧形' },
|
||||||
{ value:'3', label:'m M形' },
|
{ value:'3', label:'m M形' },
|
||||||
{ value:'4', label:'straight 直线形' },
|
{ value:'4', label:'straight 直线形' },
|
||||||
|
{ value:'5', label:'heart 桃心形' },
|
||||||
|
{ value:'6', label:'Softpetal 柔和花瓣形' },
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -94,7 +94,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="upload-group" style="margin-top:14px">
|
<div class="upload-group" style="margin-top:14px">
|
||||||
<div class="label">💬 提示词(prompt)</div>
|
<div class="label">💬 提示词(prompt)</div>
|
||||||
<input type="text" id="promptInput" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="width:100%;padding:8px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:14px;max-width:500px">
|
<input type="text" id="promptInput" value="填充遮罩区域的头发" style="width:100%;padding:8px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:14px;max-width:500px">
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top:14px;display:flex;gap:12px;align-items:center">
|
<div style="margin-top:14px;display:flex;gap:12px;align-items:center">
|
||||||
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交</button>
|
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交</button>
|
||||||
@@ -175,7 +175,7 @@ async function submitTest() {
|
|||||||
$('hairlineType').style.display = 'inline-block';
|
$('hairlineType').style.display = 'inline-block';
|
||||||
}
|
}
|
||||||
|
|
||||||
const grownSrc = d.hair_growth_image_url || d.hair_growth_image_base64;
|
const grownSrc = d.hair_growth_image_url || (d.hair_growth_image_base64 ? 'data:image/jpeg;base64,' + d.hair_growth_image_base64 : '');
|
||||||
if (grownSrc) {
|
if (grownSrc) {
|
||||||
$('blendTop').src = grownSrc;
|
$('blendTop').src = grownSrc;
|
||||||
$('blendTop').style.display = 'block';
|
$('blendTop').style.display = 'block';
|
||||||
|
|||||||
@@ -101,7 +101,7 @@
|
|||||||
<div class="file-input"><input type="file" id="imageFile" accept="image/jpeg,image/png,.jpg,.jpeg,.png"></div>
|
<div class="file-input"><input type="file" id="imageFile" accept="image/jpeg,image/png,.jpg,.jpeg,.png"></div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>性别</label>
|
<label>性别</label>
|
||||||
<select id="gender" onchange="onGenderChange()"><option value="female" selected>👩 Female(5种)</option><option value="male">👨 Male(4种)</option></select>
|
<select id="gender" onchange="onGenderChange()"><option value="female" selected>👩 Female(7种)</option><option value="male">👨 Male(6种)</option></select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="align-items:flex-start">
|
<div class="form-group" style="align-items:flex-start">
|
||||||
<label style="padding-top:3px">发型 *</label>
|
<label style="padding-top:3px">发型 *</label>
|
||||||
@@ -174,12 +174,16 @@ const HAIR_STYLES = {
|
|||||||
{ value:'3', label:'3. heart 心形' },
|
{ value:'3', label:'3. heart 心形' },
|
||||||
{ value:'4', label:'4. straight 直线' },
|
{ value:'4', label:'4. straight 直线' },
|
||||||
{ value:'5', label:'5. wave 波浪' },
|
{ value:'5', label:'5. wave 波浪' },
|
||||||
|
{ value:'6', label:'6. bigflower 大花瓣型' },
|
||||||
|
{ value:'7', label:'7. clasicalflower 古典花瓣型' },
|
||||||
],
|
],
|
||||||
male: [
|
male: [
|
||||||
{ value:'1', label:'1. ellipse 椭圆' },
|
{ value:'1', label:'1. ellipse 椭圆' },
|
||||||
{ value:'2', label:'2. inverse_arc 倒弧' },
|
{ value:'2', label:'2. inverse_arc 倒弧' },
|
||||||
{ value:'3', label:'3. m M形' },
|
{ value:'3', label:'3. m M形' },
|
||||||
{ value:'4', label:'4. straight 直线' },
|
{ value:'4', label:'4. straight 直线' },
|
||||||
|
{ value:'5', label:'5. heart 桃心形' },
|
||||||
|
{ value:'6', label:'6. Softpetal 柔和花瓣形' },
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,7 @@
|
|||||||
<div class="hint">JPG/PNG | 生发图生成较慢(数十秒~数分钟),请耐心等待 | 工作流: add_hair2.json</div>
|
<div class="hint">JPG/PNG | 生发图生成较慢(数十秒~数分钟),请耐心等待 | 工作流: add_hair2.json</div>
|
||||||
<div style="margin-top:10px;display:flex;align-items:center;gap:8px">
|
<div style="margin-top:10px;display:flex;align-items:center;gap:8px">
|
||||||
<label style="font-size:13px;font-weight:600;color:#374151;white-space:nowrap">💬 提示词</label>
|
<label style="font-size:13px;font-weight:600;color:#374151;white-space:nowrap">💬 提示词</label>
|
||||||
<input type="text" id="promptInput" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
|
<input type="text" id="promptInput" value="填充遮罩区域的头发" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
|
||||||
</div>
|
</div>
|
||||||
<div id="statusBar" class="status hidden"></div>
|
<div id="statusBar" class="status hidden"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||