diff --git a/.gitignore b/.gitignore
index b86d202..0553936 100644
--- a/.gitignore
+++ b/.gitignore
@@ -82,3 +82,6 @@ static/facetest_report/
static/facetest_report.html
static/facetest_all_report/
static/facetest_all_report.html
+
+# 颞颧比批量测试生成的照片和报告
+static/face_ratio_cases/
diff --git a/app.py b/app.py
index 17fa12f..1a99e23 100644
--- a/app.py
+++ b/app.py
@@ -1585,6 +1585,49 @@ async def face_features(
return ok({"features": features})
+@app.post(
+ "/api/v1/face/b-end-points",
+ summary="72号接口:B 端五个关键点",
+ tags=["人脸分析"],
+)
+async def face_b_end_points(
+ 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"),
+):
+ """返回扶正裁切后的底图、六个白点标注层、坐标及距离。"""
+ from fastapi.concurrency import run_in_threadpool
+
+ from face_analysis.b_end_points import build_b_end_annotation
+ from face_analysis.special_points import NoFaceError, NonFrontalError
+ from hairline.framing import NoPortraitFaceError, decode_portrait
+
+ raw, error = await resolve_image_bytes(image_file, image_url, image_base64)
+ if error is not None:
+ return error
+ image = decode_portrait(raw)
+ if image is None:
+ return err(1008, "图片格式不支持(仅 JPG / PNG)")
+
+ try:
+ processed, overlay, data = await run_in_threadpool(build_b_end_annotation, image)
+ except (NoFaceError, NoPortraitFaceError):
+ return err(1001, "无法识别人像")
+ except NonFrontalError:
+ return err(1003, "角度问题,请上传正面照")
+ except ValueError as exc:
+ return err(1003, str(exc))
+ except Exception:
+ logger.exception("接口72 标注生成失败")
+ return err(1007, "标注生成失败")
+
+ data.update(
+ input_image_base64=_jpg_b64(processed),
+ annotated_image_base64=_rgba_png_b64(overlay),
+ )
+ return ok(data)
+
+
# ---------------------------------------------------------------------------
# 接口 5:发际线 PNG 生成(恢复 a3fddbb 历史版本)
# ---------------------------------------------------------------------------
@@ -1765,21 +1808,19 @@ async def _generate_hairline52_data(image, gender, hair_styles, use_mask=True,
compare_rules=False, normalize_image=True):
"""Share measurement coordinates between interface 52 and its comparison page."""
from fastapi.concurrency import run_in_threadpool
- from hairline.service import generate_hairline_pngs_52, get_landmarker
+ from hairline.service import generate_hairline_pngs_52
original = image
framing, normalized_landmarks, landmarks_override = None, None, None
if normalize_image:
- from hairline.framing import normalize_portrait
- from mediapipe.framework.formats import landmark_pb2
- landmarks = await run_in_threadpool(
- get_landmarker().detect, cv2.cvtColor(image, cv2.COLOR_BGR2RGB), include_iris=True)
- if landmarks is None:
+ from hairline.face_landmarks import to_landmark_list
+ from hairline.framing import NoPortraitFaceError, prepare_portrait
+
+ try:
+ image, normalized_landmarks, framing = await run_in_threadpool(prepare_portrait, image)
+ except NoPortraitFaceError:
return None, None
- image, normalized_landmarks, framing = await run_in_threadpool(normalize_portrait, image, landmarks)
- landmarks_override = landmark_pb2.NormalizedLandmarkList()
- for point in normalized_landmarks:
- landmarks_override.landmark.add(x=float(point[0]), y=float(point[1]), z=float(point[2]))
+ landmarks_override = to_landmark_list(normalized_landmarks)
fm, reference = None, None
try:
measured = await run_in_threadpool(_run_face_measure_data, image, landmarks_override=landmarks_override)
diff --git a/docs/接口文档.md b/docs/接口文档.md
index e806121..cd0d737 100644
--- a/docs/接口文档.md
+++ b/docs/接口文档.md
@@ -18,7 +18,9 @@
| 6 四庭七眼测量 v2 | POST | `/api/v1/face/measure-v2` |
| 2 C 端生发 | POST | `/api/v1/hair/grow` |
| 3 B 端生发 | POST | `/api/v1/hair/grow-b` |
+| 72 B 端五个关键点 | POST | `/api/v1/face/b-end-points` |
| 4 用户特征 | POST | `/api/v1/face/features` |
+| 42号接口-加两个特征点 | POST | `/api/v1/face/features-42` |
| 5 发际线 PNG 生成 | POST | `/api/v1/hairline/generate` |
| 52 发际线新规则与裁切 | POST | `/api/v1/hairline/generate-52` |
@@ -447,6 +449,89 @@ GPU worker的所有动态接口均启用IP限制:允许本机及`ip_access.jso
---
+## 接口 72:B 端五个关键点
+
+`POST /api/v1/face/b-end-points` 接受三选一图片参数 `image_file`、`image_url`、`image_base64`。复用接口 52 的扶正裁切与虹膜/眼宽厘米估算,返回坐标以处理后底图为准。
+
+| 点号 | 位置 | 标注 |
+|---|---|---|
+| 1 | MediaPipe 4 | 白点 |
+| 2 | HL center | 白点 |
+| 3 | HL center 向画面右侧水平偏移 2 cm | 白点 |
+| 4 | HL left | 白点 |
+| 5 | HL left left | 白点 |
+| 6 | MediaPipe 446 | 白点,兼作射线锚点 |
+
+六个白点的直径相同。标注层包含三条从起点向目标方向延伸并逐渐变淡的白色虚线(2→1、4→6、5→6),以及 2→3 白色虚线段。2→1 射线比默认可见长度短 15%,5→6 射线的绘制长度比默认长度多 30%,可见范围以图像边界为限。四个距离文字分别放在 2–1 中点左侧、2–3 中点上方、4–6 中点左侧和 5–6 中点右侧,距离保留一位小数,2–3 固定显示 `2.0 CM`。
+
+| `data` 字段 | 内容 |
+|---|---|
+| `input_image_url` | 扶正裁切后的底图 |
+| `annotated_image_url` | 与底图同尺寸的透明 PNG 标注层 |
+| `points` | 按字符串点号 `"1"` 到 `"6"` 索引的像素坐标 `{x, y}` |
+| `measurements_cm` | 四个距离,以 `2-1`、`2-3`、`4-6`、`5-6` 索引 |
+| `image_size` | 处理后尺寸 `{width, height}` |
+| `coordinate_space` | `processed_image` |
+| `px_per_cm` | 估算的每厘米像素数 |
+
+Worker 直连时图片字段使用 `_base64` 后缀。检测不到人脸返回 `1001`,非正面或标注所需点位无法取得时返回 `1003`,图片无法解码返回 `1008`。
+
+---
+
+## 42号接口-加两个特征点
+
+**请求** `POST /api/v1/face/features-42`,使用 `multipart/form-data`。
+
+| 请求字段 | 类型 | 说明 |
+|---|---|---|
+| `image_file` | 文件 | 上传 JPG 或 PNG |
+| `image_url` | 字符串 | 网关可访问的图片 URL |
+| `image_base64` | 字符串 | 图片 Base64,支持带 `data:image/…;base64,` 前缀 |
+
+以上三个字段必须且只能传一个。图片按 EXIF 方向解码后,先检查原图的人脸完整性和俯拍角度。俯拍使用头部姿态估算的正向 pitch,超过服务端环境变量 `FRONTAL_PITCH_THR`(默认 30°)时拦截。通过质检后复用接口 52 的扶正裁切规则,再用处理后的图片做面部特征分析。
+
+### 质检失败与异常响应
+
+以下业务响应的 HTTP 状态均为 `200`。
+
+| `code` | 情况 | `message` |
+|---|---|---|
+| `1001` | 原图未检测到人脸 | 无法识别人像,请上传包含完整人脸的照片 |
+| `1003` | 人脸关键点触及或超出图片边界 | 人脸不完整,请保留完整的脸部轮廓 |
+| `1003` | 估算的俯拍角度超过阈值 | 俯拍角度过大,请将相机调整至与脸部平视 |
+| `1003` | 扶正裁切失败,无法保留所需人脸关键点 | 人脸过于贴近边缘,无法扶正裁切 |
+| `1008` | URL 图片下载失败 | 图片下载失败(URL 不可达) |
+| `1008` | Base64 解码失败 | 图片格式不支持(base64 解码失败) |
+| `1008` | 图片无法读取 | 图片格式不支持(仅 JPG / PNG) |
+| `1007` | 图片字段未按三选一传入 | 图片参数错误:必须且只能传 image_file / image_url / image_base64 其中一个 |
+| `1007` | 图片预处理内部异常 | 图片预处理失败 |
+| `1001` | 质检通过,但视觉模型未识别到人脸 | 无法识别人像 |
+| `1007` | 视觉模型调用异常 | 分析服务异常:… |
+
+响应包含 `code`(整数业务码)、`message`(字符串提示)、`request_id`(字符串请求标识)和 `data`(失败时为 `null`)。三个 `1003` 情况可通过 `message` 区分,响应中没有独立的质检原因或角度字段。
+
+```json
+{
+ "code": 1003,
+ "message": "俯拍角度过大,请将相机调整至与脸部平视",
+ "request_id": "gw-xxxxxxxx",
+ "data": null
+}
+```
+
+### 成功响应
+
+`data.features` 仍为 JSON 字符串,保留接口 4 的六项特征,在其中新增:
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `zygoma_expansion` | boolean 或 null | 颧弓外扩 |
+| `temple_hollowing` | boolean 或 null | 太阳穴凹陷 |
+
+Ft 两端保持横坐标并取平均高度,Zy 为 234 到 454 的距离。只有 Ft ÷ Zy 严格小于 0.8 时两项同时为 `true`,否则同时为 `false`。所需点位缺失或无法测量时两项均为 `null`。网关测量复用接口 99 的 MediaPipe 与 BiSeNet 依赖,裁切还需要 Pillow。
+
+---
+
## 接口 5:发际线 PNG 生成接口
**说明**:入参同接口2(先选性别、再多选发型)。对每个选中发型返回 `middle` / `high` / `low` **三档**发际线叠图与**生发图**,并返回「最合适发际线」的面部中间点坐标。
diff --git a/face_analysis/b_end_points.py b/face_analysis/b_end_points.py
new file mode 100644
index 0000000..e71acc3
--- /dev/null
+++ b/face_analysis/b_end_points.py
@@ -0,0 +1,141 @@
+"""Interface 72: calibrated landmarks and a transparent annotation overlay."""
+
+import math
+
+import cv2
+import numpy as np
+
+from face_analysis.calibration import estimate_scale_factor, normalized_to_pixel
+from face_analysis.special_points import detect_special_points
+from hairline.face_landmarks import to_landmark_list
+from hairline.framing import prepare_portrait
+
+
+class AnnotationUnavailable(ValueError):
+ """The image does not contain the landmarks needed for all annotations."""
+
+
+def build_b_end_annotation(image):
+ image, normalized, _ = prepare_portrait(image)
+ height, width = image.shape[:2]
+ landmarks = to_landmark_list(normalized)
+ special = detect_special_points(image, landmarks_override=landmarks)["points"]
+
+ hairline_keys = ("hair_line_center", "hair_line_left", "hair_line_left_left")
+ if any(special[key] is None for key in hairline_keys):
+ raise AnnotationUnavailable("未找到完整发际线,请露出额头和两侧发际线后重拍")
+
+ px_per_cm = estimate_scale_factor(landmarks, width, height)
+ if not math.isfinite(px_per_cm) or px_per_cm <= 0:
+ raise AnnotationUnavailable("无法估算厘米比例,请使用清晰正面照")
+
+ center, left, left_left = (
+ (special[key]["x"], special[key]["y"]) for key in hairline_keys
+ )
+ points = {
+ 1: normalized_to_pixel(landmarks.landmark[4], width, height),
+ 2: center,
+ 3: (center[0] + 2 * px_per_cm, center[1]),
+ 4: left,
+ 5: left_left,
+ 6: normalized_to_pixel(landmarks.landmark[446], width, height),
+ }
+ if any(not (0 <= x < width and 0 <= y < height) for x, y in points.values()):
+ raise AnnotationUnavailable("扶正裁切后无法保留全部标注点,请使用留有边距的照片")
+
+ pairs = ((2, 1), (2, 3), (4, 6), (5, 6))
+ measurements = {
+ f"{start}-{end}": (2.0 if (start, end) == (2, 3)
+ else round(math.dist(points[start], points[end]) / px_per_cm, 1))
+ for start, end in pairs
+ }
+ overlay = _render(points, measurements, width, height)
+ data = {
+ "coordinate_space": "processed_image",
+ "image_size": {"width": width, "height": height},
+ "points": {str(index): {"x": x, "y": y} for index, (x, y) in points.items()},
+ "measurements_cm": measurements,
+ "px_per_cm": px_per_cm,
+ }
+ return image, overlay, data
+
+
+def _dashed_line(alpha, start, target, scale, *, ray, length_multiplier=1.0):
+ x, y = start
+ distance = math.dist(start, target)
+ if distance == 0:
+ return
+ dx, dy = (target[0] - x) / distance, (target[1] - y) / distance
+ length = distance
+ if ray:
+ height, width = alpha.shape
+ # Fade within a bounded distance, ending sooner when the ray hits an edge.
+ limits = []
+ if dx:
+ limits.append(((width - 1 if dx > 0 else 0) - x) / dx)
+ if dy:
+ limits.append(((height - 1 if dy > 0 else 0) - y) / dy)
+ edge_limit = min(limits)
+ length = min(min(2.5 * distance, edge_limit) * length_multiplier, edge_limit)
+
+ dash = max(4, round(7 * scale))
+ gap = max(3, round(5 * scale))
+ thickness = max(1, round(1.6 * scale))
+ if length <= 0:
+ return
+
+ # Separate layer so a faint crossing cannot overwrite a brighter ray.
+ layer = np.zeros_like(alpha)
+ for offset in range(0, math.ceil(length), dash + gap):
+ end = min(offset + dash, length)
+ opacity = round(255 * (1 - offset / length)) if ray else 255
+ cv2.line(
+ layer,
+ (round(x + dx * offset), round(y + dy * offset)),
+ (round(x + dx * end), round(y + dy * end)),
+ opacity, thickness, cv2.LINE_AA,
+ )
+ np.maximum(alpha, layer, out=alpha)
+
+
+def _render(points, measurements, width, height):
+ scale = max(0.65, min(width, height) / 650)
+ alpha = np.zeros((height, width), dtype=np.uint8)
+ _dashed_line(alpha, points[2], points[1], scale, ray=True, length_multiplier=0.85)
+ for start, end, ray in ((2, 3, False), (4, 6, True)):
+ _dashed_line(alpha, points[start], points[end], scale, ray=ray)
+ _dashed_line(alpha, points[5], points[6], scale, ray=True, length_multiplier=1.3)
+
+ shift = 4
+ subpixel = 1 << shift
+ radius = round(1.4 * max(2, round(4 * scale)) * subpixel)
+ for point in points.values():
+ center = tuple(round(value * subpixel) for value in point)
+ cv2.circle(alpha, center, radius, 255, -1, cv2.LINE_AA, shift=shift)
+
+ font = cv2.FONT_HERSHEY_SIMPLEX
+ font_scale = 0.53 * scale
+ thickness = max(1, round(scale))
+ gap = max(4, round(8 * scale))
+ for start, end, placement in ((2, 1, "left"), (2, 3, "above"),
+ (4, 6, "left"), (5, 6, "right")):
+ a, b = points[start], points[end]
+ x, y = (a[0] + b[0]) / 2, (a[1] + b[1]) / 2
+ unit = " CM" if (start, end) == (2, 3) else "cm"
+ label = f"{measurements[f'{start}-{end}']:.1f}{unit}"
+ (text_width, text_height), baseline = cv2.getTextSize(label, font, font_scale, thickness)
+ text_x = {
+ "left": x - text_width - gap,
+ "above": x - text_width / 2,
+ "right": x + gap,
+ }[placement]
+ text_y = y - gap if placement == "above" else y + text_height / 2
+ origin = (
+ max(0, min(width - text_width, round(text_x))),
+ max(text_height, min(height - baseline - 1, round(text_y))),
+ )
+ cv2.putText(alpha, label, origin, font, font_scale, 255, thickness, cv2.LINE_AA)
+
+ overlay = np.full((height, width, 4), 255, dtype=np.uint8)
+ overlay[:, :, 3] = alpha
+ return overlay
diff --git a/face_analysis/face_ratios.py b/face_analysis/face_ratios.py
new file mode 100644
index 0000000..e37a0d1
--- /dev/null
+++ b/face_analysis/face_ratios.py
@@ -0,0 +1,101 @@
+"""Framed Ft and Zy measurements shared by interface 42 and the ratio preview."""
+
+import math
+
+FT_POINTS = ("hair_line_right_right", "hair_line_left_left")
+ZY_POINTS = ("face_edge_234", "face_edge_454")
+
+
+class PortraitQualityError(ValueError):
+ """An input that must be rejected before feature analysis."""
+
+ def __init__(self, code, message):
+ super().__init__(message)
+ self.code = code
+
+
+def prepare_feature_portrait(image_bytes):
+ """Check the source image before applying interface 52 framing."""
+ import cv2
+ import numpy as np
+
+ from face_analysis.pose import PITCH_THRESHOLD, estimate_head_pose
+ from hairline.face_landmarks import get_landmarker, to_landmark_list
+ from hairline.framing import decode_portrait, normalize_portrait
+
+ image = decode_portrait(image_bytes)
+ if image is None:
+ raise PortraitQualityError(1008, "图片格式不支持(仅 JPG / PNG)")
+
+ landmarks = get_landmarker().detect(
+ cv2.cvtColor(image, cv2.COLOR_BGR2RGB), include_iris=True
+ )
+ if landmarks is None:
+ raise PortraitQualityError(1001, "无法识别人像,请上传包含完整人脸的照片")
+
+ h, w = image.shape[:2]
+ face_xy = landmarks[:468, :2] * [w, h]
+ if not np.isfinite(face_xy).all() or (
+ (face_xy <= 0).any() or (face_xy >= [w - 1, h - 1]).any()
+ ):
+ raise PortraitQualityError(1003, "人脸不完整,请保留完整的脸部轮廓")
+
+ pose = estimate_head_pose(to_landmark_list(landmarks), w, h)
+ # In the camera coordinate system, positive pitch points the face down.
+ if pose is not None and pose[1] > PITCH_THRESHOLD:
+ raise PortraitQualityError(1003, "俯拍角度过大,请将相机调整至与脸部平视")
+
+ try:
+ image, landmarks, _ = normalize_portrait(image, landmarks)
+ except ValueError as exc:
+ raise PortraitQualityError(1003, "人脸过于贴近边缘,无法扶正裁切") from exc
+ return image, landmarks
+
+
+def detect_framed_ratio_points(image):
+ """Use the interface 52 crop and return the endpoints in its coordinate space."""
+ from hairline.framing import prepare_portrait
+
+ image, landmarks, framing = prepare_portrait(image)
+ data = _detect_ratio_points(image, landmarks)
+ data.update(framing=framing, coordinate_space="processed_image")
+ return image, data
+
+
+def _detect_ratio_points(image, landmarks):
+ from hairline.face_landmarks import to_landmark_list
+
+ from .special_points import detect_special_points
+
+ data = detect_special_points(image, landmarks_override=to_landmark_list(landmarks))
+ points = data["points"]
+ data["ft_original_points"] = {key: points[key] for key in FT_POINTS}
+ a, b = (points[key] for key in FT_POINTS)
+ if a is not None and b is not None:
+ middle_y = (a["y"] + b["y"]) / 2
+ points[FT_POINTS[0]] = {**a, "y": middle_y}
+ points[FT_POINTS[1]] = {**b, "y": middle_y}
+
+ data["ft_alignment"] = "horizontal_at_mean_y"
+ return data
+
+
+def analyze_ratio_features(image, landmarks) -> dict[str, bool | None]:
+ """Return both labels from the same measurement of the processed image."""
+ from .special_points import NoFaceError, NonFrontalError
+
+ matched = None
+ try:
+ data = _detect_ratio_points(image, landmarks)
+ except (NoFaceError, NonFrontalError):
+ pass
+ else:
+ ft_left, ft_right, zy_left, zy_right = (
+ data["points"].get(key) for key in (*FT_POINTS, *ZY_POINTS)
+ )
+ if all(point is not None for point in (ft_left, ft_right, zy_left, zy_right)):
+ ft = abs(ft_left["x"] - ft_right["x"])
+ zy = math.hypot(zy_left["x"] - zy_right["x"], zy_left["y"] - zy_right["y"])
+ if ft > 0 and zy > 0:
+ matched = ft / zy < 0.8
+ return {"zygoma_expansion": matched, "temple_hollowing": matched}
diff --git a/face_analysis/special_points.py b/face_analysis/special_points.py
index ee14005..b5c116c 100644
--- a/face_analysis/special_points.py
+++ b/face_analysis/special_points.py
@@ -30,7 +30,7 @@ GROUPS = {
"mouth": [0, 37, 40, 67, 91, 84, 17, 405, 375, 409, 269],
"nose_midline": [9, 168, 197, 5, 4, 194, 278, 48, 437, 217],
"cheek": [101, 117, 123, 187, 205, 50, 330, 346, 352, 280, 425],
- "face_edge": [162, 234, 58, 136, 149, 152, 378, 365, 288, 401, 366, 389, 251],
+ "face_edge": [162, 234, 58, 136, 149, 152, 378, 365, 288, 401, 366, 389, 251, 454],
}
# 发际线点定义:字段名 → 参与计算的 (起点ID, 方向点ID) 射线组;单射线直接取交点,
@@ -87,7 +87,7 @@ def ray_hairline_intersection(p1, p2, hair_mask):
return None
-def detect_special_points(image_bgr):
+def detect_special_points(image_bgr, landmarks_override=None):
"""主入口:图片 → {字段名: {"x","y"} 或 None} + 元信息。
Returns: {"points": {...}, "image_size": {"width","height"},
@@ -99,7 +99,7 @@ def detect_special_points(image_bgr):
from face_analysis.pose import check_frontal_face
h, w = image_bgr.shape[:2]
- landmarks = detector.detect(image_bgr)
+ landmarks = landmarks_override if landmarks_override is not None else detector.detect(image_bgr)
if landmarks is None:
raise NoFaceError("未检出人脸")
if not check_frontal_face(landmarks, w, h):
diff --git a/face_features.py b/face_features.py
index 9ab463b..4754917 100644
--- a/face_features.py
+++ b/face_features.py
@@ -70,7 +70,7 @@ def get_client():
global _client, _client_key
key = _load_api_key()
if not key:
- raise RuntimeError("缺少火山方舟 API Key(设 ARK_API_KEY 或 worker_config.json.ark_api_key)")
+ raise RuntimeError("缺少火山方舟 API Key(设 ARK_API_KEY,或在 gateway/config.json / worker_config.json 中填写 ark_api_key)")
# client 未建、或 key 变了 → 重建
if _client is None or key != _client_key:
from volcenginesdkarkruntime import Ark
@@ -132,7 +132,10 @@ def analyze_features(image_bytes: bytes = None, image_url: str = None):
if not has_face(raw):
return None
# 只保留 6 个英文字段(doubao 缺某字段则跳过)
- return {en: raw[zh] for zh, en in _KEY_MAP.items() if zh in raw}
+ features = {en: raw[zh] for zh, en in _KEY_MAP.items() if zh in raw}
+ if "facial_age" not in features and "面部年龄区间" in raw:
+ features["facial_age"] = raw["面部年龄区间"]
+ return features
def has_face(features: dict) -> bool:
diff --git a/gateway/app.py b/gateway/app.py
index b96cc37..5e85e16 100644
--- a/gateway/app.py
+++ b/gateway/app.py
@@ -527,6 +527,12 @@ async def hair_grow_b(request: Request):
return await _proxy(request, "/api/v1/hair/grow-b")
+@app.post("/api/v1/face/b-end-points", summary="72号接口:B 端五个关键点", tags=["人脸分析"])
+async def face_b_end_points(request: Request):
+ """返回扶正裁切后的图片和透明标注层。"""
+ return await _proxy(request, "/api/v1/face/b-end-points")
+
+
@app.post("/api/v1/face/features", tags=["人脸分析"])
async def face_features(
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG)"),
@@ -534,6 +540,42 @@ async def face_features(
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带前缀)"),
):
"""接口4:用户特征分析 — 本机直接调豆包视觉模型,不经过 worker。"""
+ return await _face_features_response(image_file, image_url, image_base64)
+
+
+@app.post(
+ "/api/v1/face/features-42",
+ summary="42号接口-加两个特征点",
+ tags=["人脸分析"],
+)
+async def face_features_42(
+ 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(需带前缀)"),
+):
+ """沿用接口 4。图片参数 `image_file`、`image_url`、`image_base64` 必须且只能传一个。
+
+ 先检查原图人脸完整性和俯拍角度,再按接口 52 扶正裁切。俯拍角度超过服务端
+ `FRONTAL_PITCH_THR`(默认 30°)时拦截。
+
+ | 业务码 | 情况 |
+ |---|---|
+ | 1001 | 未检测到人脸 |
+ | 1003 | 人脸不完整、俯拍角度过大或无法扶正裁切 |
+ | 1008 | 图片 URL 下载失败、Base64 解码失败或图片无法读取 |
+ | 1007 | 图片参数错误、预处理内部异常或视觉模型调用失败 |
+
+ 业务失败使用 HTTP 200,返回 `code`、`message`、`request_id` 和 `data: null`。
+ 同一业务码下的具体原因见 `message`,响应不含独立的质检原因或角度字段。
+
+ 成功时 `data.features` 为 JSON 字符串,在接口 4 的字段上新增
+ `zygoma_expansion`(颧弓外扩)和 `temple_hollowing`(太阳穴凹陷)。
+ Ft ÷ Zy 小于 0.8 时两项均为 true,否则均为 false,无法测量时均为 null。
+ """
+ return await _face_features_response(image_file, image_url, image_base64, include_ratios=True)
+
+
+async def _face_features_response(image_file, image_url, image_base64, *, include_ratios=False):
import uuid as _uuid
# 三选一校验
@@ -562,6 +604,25 @@ async def face_features(
from fastapi.concurrency import run_in_threadpool
from face_features import analyze_features
+ ratio_features = {}
+ if include_ratios:
+ from face_analysis.face_ratios import PortraitQualityError
+
+ try:
+ img_bytes, ratio_features = await _prepare_features_42(img_bytes, image_url)
+ except PortraitQualityError as ex:
+ return JSONResponse(status_code=200, content={
+ "code": ex.code, "message": str(ex),
+ "request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
+ })
+ except Exception:
+ logger.exception("接口42 图片预处理失败")
+ return JSONResponse(status_code=200, content={
+ "code": 1007, "message": "图片预处理失败",
+ "request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
+ })
+ image_url = None
+
try:
feats = await run_in_threadpool(analyze_features, img_bytes, image_url)
except Exception as ex:
@@ -581,10 +642,42 @@ async def face_features(
"code": 0,
"message": "success",
"request_id": f"gw-{_uuid.uuid4().hex[:8]}",
- "data": {"features": json.dumps(feats, ensure_ascii=False)},
+ "data": {"features": json.dumps({**feats, **ratio_features}, ensure_ascii=False)},
})
+async def _prepare_features_42(image_bytes, image_url):
+ from fastapi.concurrency import run_in_threadpool
+
+ if image_url:
+ import httpx
+
+ try:
+ async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
+ response = await client.get(image_url)
+ response.raise_for_status()
+ image_bytes = response.content
+ except httpx.HTTPError as exc:
+ from face_analysis.face_ratios import PortraitQualityError
+
+ raise PortraitQualityError(1008, "图片下载失败(URL 不可达)") from exc
+
+ async with _if99_lock:
+ return await run_in_threadpool(_preprocess_and_measure_42, image_bytes)
+
+
+def _preprocess_and_measure_42(image_bytes):
+ import cv2
+ from face_analysis.face_ratios import analyze_ratio_features, prepare_feature_portrait
+
+ image, landmarks = prepare_feature_portrait(image_bytes)
+ success, encoded = cv2.imencode(".png", image)
+ if not success:
+ raise RuntimeError("处理后图片编码失败")
+ ratios = analyze_ratio_features(image, landmarks)
+ return encoded.tobytes(), ratios
+
+
@app.post("/api/v1/hairline/generate", tags=["人脸分析"])
async def hairline_generate(request: Request):
"""接口5:发际线PNG生成"""
diff --git a/hairline/face_landmarks.py b/hairline/face_landmarks.py
index d592338..8bad850 100644
--- a/hairline/face_landmarks.py
+++ b/hairline/face_landmarks.py
@@ -66,6 +66,25 @@ class FaceLandmarker:
pass
+def to_landmark_list(landmarks):
+ from mediapipe.framework.formats import landmark_pb2
+
+ result = landmark_pb2.NormalizedLandmarkList()
+ for x, y, z in landmarks:
+ result.landmark.add(x=float(x), y=float(y), z=float(z))
+ return result
+
+
+_landmarker = None
+
+
+def get_landmarker() -> FaceLandmarker:
+ global _landmarker
+ if _landmarker is None:
+ _landmarker = FaceLandmarker(static_image_mode=True)
+ return _landmarker
+
+
class SolutionsFaceLandmarker:
"""CPU-oriented fallback using mediapipe.solutions.face_mesh."""
diff --git a/hairline/framing.py b/hairline/framing.py
index cbaa50e..44ae769 100644
--- a/hairline/framing.py
+++ b/hairline/framing.py
@@ -216,3 +216,16 @@ def normalize_portrait(photo, landmarks, profile=None):
"reference_image_url": profile["reference_image_url"],
}
return image, moved, metadata
+
+
+class NoPortraitFaceError(ValueError):
+ """The portrait landmarker could not find a face."""
+
+
+def prepare_portrait(photo):
+ """Shared interface-52 preprocessing, including the same landmark model/profile."""
+ from .face_landmarks import get_landmarker
+ landmarks = get_landmarker().detect(cv2.cvtColor(photo, cv2.COLOR_BGR2RGB), include_iris=True)
+ if landmarks is None:
+ raise NoPortraitFaceError("未检出人脸")
+ return normalize_portrait(photo, landmarks)
diff --git a/hairline/service.py b/hairline/service.py
index 939263a..74434a8 100644
--- a/hairline/service.py
+++ b/hairline/service.py
@@ -12,7 +12,7 @@ import numpy as np
from . import constants as C
from . import comfyui
-from .face_landmarks import FaceLandmarker
+from .face_landmarks import get_landmarker
from .face_parsing import FaceParser
from .hairline_2d import (
smooth_hairline, sample_hairline, align_hairline_for_template,
@@ -115,18 +115,10 @@ _TEXTURE_DIRS = {
# torch 2.7.1+cu128 已支持 RTX 5090 (sm_120),SegFormer 走 GPU(~0.05s/张)
_SEG_DEVICE = os.getenv("SEG_DEVICE", "cuda")
-_landmarker = None
_parser = None
_texture_maps: dict = {} # {level: {gender: [(key, path)]}},按档位缓存
-def get_landmarker() -> FaceLandmarker:
- global _landmarker
- if _landmarker is None:
- _landmarker = FaceLandmarker(static_image_mode=True)
- return _landmarker
-
-
def get_parser() -> FaceParser:
global _parser
if _parser is None:
diff --git a/scripts/batch_face_ratios.py b/scripts/batch_face_ratios.py
new file mode 100644
index 0000000..5657797
--- /dev/null
+++ b/scripts/batch_face_ratios.py
@@ -0,0 +1,64 @@
+"""Run 20 local source-image cases against the ratio test server and build a report."""
+import base64
+import numpy as np
+import csv, hashlib, html, json, math
+from pathlib import Path
+import cv2
+import requests
+ROOT=Path(__file__).resolve().parents[1]
+OUT=ROOT/'static/face_ratio_cases'
+OUT.mkdir(exist_ok=True)
+files=sorted((ROOT/'image/girl_img').glob('*.jpg'),key=lambda p:int(p.stem.replace('girl','')))+[ROOT/'tests/fixtures/frontal.jpg',ROOT/'tests/fixtures/hard_longhair.jpg',ROOT/'image/hair_test.jpg']
+files += sorted((ROOT/'tests/output/hairline_height_compare/inputs').glob('case_*.png'), key=lambda p:int(p.stem.split('_')[-1]))
+keys=['hair_line_right_right','hair_line_left_left','face_edge_234','face_edge_454']
+rows=[];seen=set()
+for path in files:
+ image=cv2.imread(str(path))
+ if image is None:continue
+ digest=hashlib.sha256(image.tobytes()).hexdigest()
+ if digest in seen:continue
+ seen.add(digest)
+ idx=len(rows)+1
+ row={'case':idx,'source':str(path.relative_to(ROOT)),'ft_px':None,'zy_px':None,'ft_zy':None,'zy_ft':None,'zygoma_expansion':None,'temple_hollowing':None,'error':None}
+ try:
+ with path.open('rb') as f:r=requests.post('http://127.0.0.1:8190/detect',files={'image_file':(path.name,f,'image/jpeg')},timeout=180)
+ data=r.json();(OUT/f'{idx:02d}_response.json').write_text(json.dumps(data,ensure_ascii=False,indent=2))
+ if not r.ok:raise ValueError(data.get('detail',str(r.status_code)))
+ image=cv2.imdecode(np.frombuffer(base64.b64decode(data['input_image_base64'].split(',')[1]),np.uint8),cv2.IMREAD_COLOR)
+ row['framing']=data['framing']
+ points={k:data['points'].get(k) for k in keys};row['points']=points
+ for i in (0,2):
+ a,b=[points[keys[j]] for j in (i,i+1)]
+ if a and b:
+ length=math.hypot(a['x']-b['x'],a['y']-b['y']);row['ft_px' if i==0 else 'zy_px']=length
+ color=(140,50,240) if i==0 else (255,150,20)
+ cv2.line(image,(round(a['x']),round(a['y'])),(round(b['x']),round(b['y'])),color,3)
+ for j,p in ((i,a),(i+1,b)):
+ xy=(round(p['x']),round(p['y']));cv2.circle(image,xy,5,color,-1)
+ label=['HL RR','HL LL','234','454'][j]
+ cv2.putText(image,label,(max(0,xy[0]-30),max(18,xy[1]-12)),cv2.FONT_HERSHEY_SIMPLEX,.6,(0,0,0),4)
+ cv2.putText(image,label,(max(0,xy[0]-30),max(18,xy[1]-12)),cv2.FONT_HERSHEY_SIMPLEX,.6,(255,255,255),1)
+ if not row['ft_px'] or not row['zy_px']:raise ValueError('测量点缺失或距离为零')
+ row['ft_zy']=row['ft_px']/row['zy_px'];row['zy_ft']=row['zy_px']/row['ft_px']
+ matched = row['ft_zy'] < 0.8
+ row['zygoma_expansion'] = row['temple_hollowing'] = matched
+ except Exception as e:row['error']=str(e)
+ cv2.imwrite(str(OUT/f'{idx:02d}.jpg'),image)
+ rows.append(row);print(json.dumps(row,ensure_ascii=False),flush=True)
+
+(OUT/'results.json').write_text(json.dumps(rows,ensure_ascii=False,indent=2))
+with (OUT/'results.csv').open('w',encoding='utf-8-sig',newline='') as f:
+ writer=csv.DictWriter(f,fieldnames=[k for k in rows[0] if k!='points'],extrasaction='ignore');writer.writeheader();writer.writerows(rows)
+def fmt(v):return '—' if v is None else f'{v:.4f}'
+def status(v):return '无法判断' if v is None else ('命中' if v else '未命中')
+valid=sum(r['error'] is None for r in rows);hit=sum(r['zygoma_expansion'] is True for r in rows)
+head=f'''
{len(rows)}例颞颧比测试结果{len(rows)}例颞颧比/颧颞比测试结果
所有样本先使用接口52共用函数和配置扶正裁切,再测量。项目本地原始样本,按文件编号选择并剔除完全相同的图像;非人工标注验证集。共{len(rows)}例,有效{valid}例,无法计算{len(rows)-valid}例。颧弓外扩命中{hit}例,太阳穴凹陷命中{sum(r['temple_hollowing'] is True for r in rows)}例。
Ft:HL RR—HL LL(粉色,两端平均高度水平线);Zy:234—454(蓝色)。Ft ÷ Zy 小于 0.8 时,同时判定为颧弓外扩和太阳穴凹陷。缺失点不计入未命中。点击图片查看大图。
下载CSV · 完整JSON · 上传新照片测试
'''
+cards=[]
+for r in rows:
+ i=r['case'];cards.append(f'''
案例 {i:02d}
{html.escape(r['source'])}

Ft {fmt(r['ft_px'])} px · Zy {fmt(r['zy_px'])} px
| 颞颧比 Ft/Zy | {fmt(r['ft_zy'])} | <0.8 |
| 颧弓外扩 | {status(r['zygoma_expansion'])} |
| 颧颞比 Zy/Ft | {fmt(r['zy_ft'])} | 参考值 |
| 太阳穴凹陷 | {status(r['temple_hollowing'])} |
{html.escape(r['error'] or '')}
检测原始数据''')
+(OUT/'index.html').write_text(head+''.join(cards)+'''
''')
+print(f'DONE total={len(rows)} valid={valid} hit={hit}',flush=True)
diff --git a/scripts/face_ratios_test_server.py b/scripts/face_ratios_test_server.py
new file mode 100644
index 0000000..33e32d8
--- /dev/null
+++ b/scripts/face_ratios_test_server.py
@@ -0,0 +1,44 @@
+"""Local-only ratio experiment: python -m uvicorn scripts.face_ratios_test_server:app --port 8190."""
+import threading
+import base64
+from pathlib import Path
+
+import cv2
+from fastapi import FastAPI, UploadFile, File, HTTPException
+from fastapi.responses import FileResponse
+from fastapi.staticfiles import StaticFiles
+
+from face_analysis.face_ratios import detect_framed_ratio_points
+from face_analysis.special_points import NoFaceError, NonFrontalError
+
+static_dir = Path(__file__).resolve().parents[1] / "static"
+cases_dir = static_dir / "face_ratio_cases"
+cases_dir.mkdir(exist_ok=True)
+app = FastAPI()
+app.mount("/cases", StaticFiles(directory=cases_dir, html=True), name="cases")
+lock = threading.Lock()
+
+
+@app.get("/")
+def index():
+ return FileResponse(static_dir / "test_face_ratios.html")
+
+
+@app.post("/detect")
+def detect(image_file: UploadFile = File(...)):
+ raw = image_file.file.read(20 * 1024 * 1024 + 1)
+ if len(raw) > 20 * 1024 * 1024:
+ raise HTTPException(413, "图片不能超过20MB")
+ from hairline.framing import decode_portrait
+ image = decode_portrait(raw)
+ if image is None:
+ raise HTTPException(400, "无法读取图片")
+ try:
+ with lock:
+ image, data = detect_framed_ratio_points(image)
+ data["input_image_base64"] = (
+ "data:image/png;base64," + base64.b64encode(cv2.imencode(".png", image)[1]).decode()
+ )
+ return data
+ except (NoFaceError, NonFrontalError, ValueError) as exc:
+ raise HTTPException(422, str(exc)) from exc
diff --git a/static/test_face_ratios.html b/static/test_face_ratios.html
new file mode 100644
index 0000000..babb649
--- /dev/null
+++ b/static/test_face_ratios.html
@@ -0,0 +1,20 @@
+颞颧比与颧颞比测试
+
+查看批量案例与筛选结果
颞颧比与颧颞比测试
Ft:HL right right → HL left left(两端取平均高度,水平测量) · Zy:234 → 454
先按接口52统一参数扶正裁切,均按处理后图片二维直线距离计算。Ft ÷ Zy 小于 0.8 时,同时判定为颧弓外扩和太阳穴凹陷。
+
+粉色:Ft 蓝色:Zy。检测后可拖动端点校正,结果实时更新。
颧弓外扩
颞颧比 Ft ÷ Zy < 0.8
—
等待检测
太阳穴凹陷
判定条件 Ft ÷ Zy < 0.8(下方 Zy ÷ Ft 为参考值)
—
等待检测
+
\ No newline at end of file
diff --git a/static/test_interface42.html b/static/test_interface42.html
new file mode 100644
index 0000000..3d19156
--- /dev/null
+++ b/static/test_interface42.html
@@ -0,0 +1,211 @@
+
+
+
+
+
+接口42 — 用户特征分析与两项判定 测试页
+
+
+
+
+
🔬 接口42 — 用户特征分析与两项判定 测试
+
POST /api/v1/face/features-42 | 质检 → 扶正裁切 → 面部特征与两项判定 | data.features 为 JSON 字符串
+
+
+
+
+
JPG/PNG 正面照 | 检测人脸完整性和俯拍角度,通过后扶正裁切
+
+
+
+
+
+
+
+
+
+
diff --git a/static/test_interface72.html b/static/test_interface72.html
new file mode 100644
index 0000000..faf0a2e
--- /dev/null
+++ b/static/test_interface72.html
@@ -0,0 +1,128 @@
+
+
+
+
+
+72 号接口 · B 端五个关键点
+
+
+
+
+ 72 号接口 · B 端五个关键点
+ 上传正面照,查看扶正裁切后的六个白点、四条虚线和厘米标注。
+
+
+
+
+ 测量结果
+
+ 坐标(像素)
+
+ 点 6(MediaPipe 446)是射线锚点。六个白点的直径相同。
+
+
+
+
+
+