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'])}

案例{i}测量标注

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 端五个关键点

+

上传正面照,查看扶正裁切后的六个白点、四条虚线和厘米标注。

+
+
+ + + + +
+

请选择 JPG 或 PNG 图片。

+
+ +
+ + +