feat: 新增42号人脸特征与72号B端关键点接口
This commit is contained in:
@@ -82,3 +82,6 @@ static/facetest_report/
|
||||
static/facetest_report.html
|
||||
static/facetest_all_report/
|
||||
static/facetest_all_report.html
|
||||
|
||||
# 颞颧比批量测试生成的照片和报告
|
||||
static/face_ratio_cases/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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` **三档**发际线叠图与**生发图**,并返回「最合适发际线」的面部中间点坐标。
|
||||
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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):
|
||||
|
||||
+5
-2
@@ -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:
|
||||
|
||||
+94
-1
@@ -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生成"""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+1
-9
@@ -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:
|
||||
|
||||
@@ -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'''<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{len(rows)}例颞颧比测试结果</title><style>body{{font:15px system-ui;background:#f3f5f9;color:#203047;margin:0;padding:26px}}main{{max-width:1400px;margin:auto}}.grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(290px,1fr));gap:18px}}article{{background:white;padding:16px;border-radius:12px}}img{{width:100%;height:340px;object-fit:contain;background:#eef1f5}}td{{padding:7px 2px}}p{{line-height:1.7}}.error{{color:#ad3426}}</style><main><h1>{len(rows)}例颞颧比/颧颞比测试结果</h1><p>所有样本先使用接口52共用函数和配置扶正裁切,再测量。项目本地原始样本,按文件编号选择并剔除完全相同的图像;非人工标注验证集。共{len(rows)}例,有效{valid}例,无法计算{len(rows)-valid}例。颧弓外扩命中{hit}例,太阳穴凹陷命中{sum(r['temple_hollowing'] is True for r in rows)}例。</p><p>Ft:HL RR—HL LL(粉色,两端平均高度水平线);Zy:234—454(蓝色)。Ft ÷ Zy 小于 0.8 时,同时判定为颧弓外扩和太阳穴凹陷。缺失点不计入未命中。点击图片查看大图。</p><p><a href="results.csv">下载CSV</a> · <a href="results.json">完整JSON</a> · <a href="http://localhost:8190/">上传新照片测试</a></p><section style="position:sticky;top:0;background:#f3f5f9;padding:14px 0;z-index:2"><label>按结果筛选 <select id="filter" style="padding:9px;font:inherit"><option value="all">全部</option><option value="zygoma">颧弓外扩命中</option><option value="temple">太阳穴凹陷命中</option><option value="neither">两项均未命中</option><option value="error">无法计算</option></select></label> <label>搜索案例 <input id="search" placeholder="编号或文件名" style="padding:9px;font:inherit"></label> <span id="count"></span></section><div class="grid">'''
|
||||
cards=[]
|
||||
for r in rows:
|
||||
i=r['case'];cards.append(f'''<article data-zygoma="{str(r['zygoma_expansion'] is True).lower()}" data-temple="{str(r['temple_hollowing'] is True).lower()}" data-error="{str(r['error'] is not None).lower()}" data-search="{i:02d} {html.escape(r['source'],quote=True)}"><h2>案例 {i:02d}</h2><p>{html.escape(r['source'])}</p><a href="{i:02d}.jpg" target="_blank"><img loading="lazy" src="{i:02d}.jpg" alt="案例{i}测量标注"></a><p>Ft {fmt(r['ft_px'])} px · Zy {fmt(r['zy_px'])} px</p><table><tr><td>颞颧比 Ft/Zy</td><td>{fmt(r['ft_zy'])}</td><td><0.8</td></tr><tr><td>颧弓外扩</td><td>{status(r['zygoma_expansion'])}</td></tr><tr><td>颧颞比 Zy/Ft</td><td>{fmt(r['zy_ft'])}</td><td>参考值</td></tr><tr><td>太阳穴凹陷</td><td>{status(r['temple_hollowing'])}</td></tr></table><p class="error">{html.escape(r['error'] or '')}</p><a href="{i:02d}_response.json">检测原始数据</a></article>''')
|
||||
(OUT/'index.html').write_text(head+''.join(cards)+'''</div></main><script>
|
||||
const filter=document.getElementById('filter'), search=document.getElementById('search'), cards=[...document.querySelectorAll('article')];
|
||||
function apply(){let n=0;for(const c of cards){const d=c.dataset,f=filter.value;const match=f==='all'||f==='zygoma'&&d.zygoma==='true'||f==='temple'&&d.temple==='true'||f==='error'&&d.error==='true'||f==='neither'&&d.error==='false'&&d.zygoma==='false'&&d.temple==='false';const show=match&&d.search.toLowerCase().includes(search.value.trim().toLowerCase());c.hidden=!show;if(show)n++}document.getElementById('count').textContent=`显示 ${n} / ${cards.length} 例`}
|
||||
filter.onchange=apply;search.oninput=apply;apply();
|
||||
</script>''')
|
||||
print(f'DONE total={len(rows)} valid={valid} hit={hit}',flush=True)
|
||||
@@ -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
|
||||
@@ -0,0 +1,20 @@
|
||||
<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>颞颧比与颧颞比测试</title>
|
||||
<style>body{font:15px system-ui;background:#f4f6fa;color:#203047;margin:0}main{max-width:1200px;margin:auto;padding:28px}h1{font-size:25px}section,article{background:white;padding:20px;border-radius:12px;margin:16px 0}button,input{font:inherit;padding:9px}button{cursor:pointer} .grid{display:grid;grid-template-columns:minmax(0,2fr) minmax(260px,1fr);gap:20px}canvas{max-width:100%;height:auto;touch-action:none}pre{overflow:auto;max-height:300px;font-size:12px}.muted{color:#66758a;line-height:1.7}.value{font-size:28px;font-weight:700}#status{white-space:pre-wrap} @media(max-width:750px){.grid{display:block}}</style>
|
||||
<main><p><a href="/cases/">查看批量案例与筛选结果</a></p><h1>颞颧比与颧颞比测试</h1><p class="muted">Ft:HL right right → HL left left(两端取平均高度,水平测量) · Zy:234 → 454<br>先按接口52统一参数扶正裁切,均按处理后图片二维直线距离计算。Ft ÷ Zy 小于 0.8 时,同时判定为颧弓外扩和太阳穴凹陷。</p>
|
||||
<section><input aria-label="选择人像" type="file" id="file" accept="image/jpeg,image/png"><button id="run">开始检测</button> <button id="reset" disabled>恢复自动点位</button><p id="status" role="status">请选择单人正面照。</p></section>
|
||||
<div class="grid"><section><canvas id="canvas"></canvas><p class="muted">粉色:Ft 蓝色:Zy。检测后可拖动端点校正,结果实时更新。</p></section><div><article><h2>颧弓外扩</h2><p>颞颧比 Ft ÷ Zy < 0.8</p><div class="value" id="r1">—</div><p id="s1">等待检测</p></article><article><h2>太阳穴凹陷</h2><p>判定条件 Ft ÷ Zy < 0.8(下方 Zy ÷ Ft 为参考值)</p><div class="value" id="r2">—</div><p id="s2">等待检测</p></article><p id="lengths"></p></div></div><section><h2>测量数据</h2><pre id="json">暂无数据</pre></section></main>
|
||||
<script>
|
||||
const $=id=>document.getElementById(id),keys=['hair_line_right_right','hair_line_left_left','face_edge_234','face_edge_454'],labels=['HL right right','HL left left','234','454'];
|
||||
let img=null,points={},original={},drag=null,manual=false,busy=false;
|
||||
function dist(a,b){return a&&b&&[a.x,a.y,b.x,b.y].every(Number.isFinite)?Math.hypot(a.x-b.x,a.y-b.y):null}
|
||||
function update(){const left=points[keys[0]],right=points[keys[1]];if(left&&right){const y=(left.y+right.y)/2;left.y=right.y=y}const ft=dist(points[keys[0]],points[keys[1]]),zy=dist(points[keys[2]],points[keys[3]]),valid=ft>0&&zy>0;const a=valid?ft/zy:null,b=valid?zy/ft:null;
|
||||
const matched = valid ? a < 0.8 : null;
|
||||
$('r1').textContent=valid?a.toFixed(4):'—';$('r2').textContent=valid?b.toFixed(4):'—';$('s1').textContent=valid?(matched?'命中':'未命中'):'无法计算:点位缺失或距离为零';$('s2').textContent=valid?(matched?'命中':'未命中'):'无法计算:点位缺失或距离为零';$('lengths').textContent=`Ft:${ft?.toFixed(2)??'缺失'} px Zy:${zy?.toFixed(2)??'缺失'} px`;
|
||||
$('json').textContent=JSON.stringify({source:manual?'manual':'automatic',points,ft_px:ft,zy_px:zy,temporal_zygomatic_ratio:a,zygomatic_temporal_ratio:b,zygoma_expansion:matched,temple_hollowing:matched},null,2);draw()}
|
||||
function draw(){const c=$('canvas'),ctx=c.getContext('2d');ctx.clearRect(0,0,c.width,c.height);if(!img)return;ctx.drawImage(img,0,0,c.width,c.height);const scale=c.width/img.naturalWidth;for(let i=0;i<4;i++){const p=points[keys[i]],q=points[keys[i^1]],color=i<2?'#ed397a':'#1685ff';if(!p)continue;ctx.strokeStyle=color;ctx.fillStyle=color;ctx.lineWidth=3;if(i%2===0&&q){ctx.beginPath();ctx.moveTo(p.x*scale,p.y*scale);ctx.lineTo(q.x*scale,q.y*scale);ctx.stroke()}ctx.beginPath();ctx.arc(p.x*scale,p.y*scale,7,0,Math.PI*2);ctx.fill();ctx.font='bold 14px sans-serif';const t=labels[i],x=Math.max(2,Math.min(c.width-ctx.measureText(t).width-4,p.x*scale+10)),y=Math.max(17,p.y*scale-12);ctx.strokeStyle='#172033';ctx.lineWidth=4;ctx.strokeText(t,x,y);ctx.fillStyle='white';ctx.fillText(t,x,y)}}
|
||||
function position(e){const r=$('canvas').getBoundingClientRect();return{x:(e.clientX-r.left)/r.width*img.naturalWidth,y:(e.clientY-r.top)/r.height*img.naturalHeight}}
|
||||
$('canvas').onpointerdown=e=>{if(!img||busy)return;const p=position(e),radius=18*img.naturalWidth/$('canvas').getBoundingClientRect().width;drag=keys.find(k=>points[k]&&dist(points[k],p)<radius);if(drag)$('canvas').setPointerCapture(e.pointerId)};
|
||||
$('canvas').onpointermove=e=>{if(!drag)return;const p=position(e);points[drag]={x:Math.max(0,Math.min(img.naturalWidth-1,p.x)),y:Math.max(0,Math.min(img.naturalHeight-1,p.y))};manual=true;update()};$('canvas').onpointerup=$('canvas').onpointercancel=()=>drag=null;
|
||||
$('reset').onclick=()=>{points=structuredClone(original);manual=false;update()};
|
||||
$('run').onclick=async()=>{const file=$('file').files[0];if(!file)return;busy=true;$('run').disabled=true;$('reset').disabled=true;points={};original={};manual=false;img=null;update();$('status').textContent='正在检测人脸与发际线…';let url;try{url=URL.createObjectURL(file);const next=new Image();next.src=url;await next.decode();img=next;$('canvas').width=Math.min(1000,img.naturalWidth);$('canvas').height=Math.round($('canvas').width*img.naturalHeight/img.naturalWidth);draw();const form=new FormData();form.append('image_file',file);const response=await fetch('/detect',{method:'POST',body:form});const data=await response.json();if(!response.ok)throw Error(data.detail||'检测失败');const processed=new Image();processed.src=data.input_image_base64;await processed.decode();img=processed;$('canvas').width=Math.min(1000,img.naturalWidth);$('canvas').height=Math.round($('canvas').width*img.naturalHeight/img.naturalWidth);points=Object.fromEntries(keys.map(k=>[k,data.points[k]??null]));original=structuredClone(points);update();$('reset').disabled=false;$('status').textContent='检测完成。可拖动点位验证公式;发际线点受刘海和头发遮挡影响。'}catch(e){$('status').textContent='检测失败:'+e.message}finally{if(url)URL.revokeObjectURL(url);busy=false;$('run').disabled=false}};
|
||||
</script></html>
|
||||
@@ -0,0 +1,211 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>接口42 — 用户特征分析与两项判定 测试页</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; }
|
||||
|
||||
.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.5; min-width: 0; }
|
||||
.col-side { flex: 1; min-width: 0; }
|
||||
|
||||
/* 特征表格 */
|
||||
.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: 200px; }
|
||||
.feat-table tr:hover td { background: #f8fafc; }
|
||||
.feat-table .highlight td { background: #eff6ff; }
|
||||
|
||||
/* 图片预览 */
|
||||
.img-preview { text-align: center; }
|
||||
.img-preview .thumb { background: #222; border-radius: 8px; overflow: hidden; display: inline-block; line-height: 0; }
|
||||
.img-preview .thumb img { max-width: 100%; max-height: 280px; object-fit: contain; }
|
||||
|
||||
.json-panel { max-height: 550px; 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; }
|
||||
.count-badge { background: #dbeafe; color: #1e40af; padding: 2px 10px; border-radius: 10px; font-size: 12px; font-weight: 700; }
|
||||
|
||||
@media (max-width: 800px) { .results-layout { flex-direction: column; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🔬 接口42 — 用户特征分析与两项判定 测试</h1>
|
||||
<p class="subtitle">POST /api/v1/face/features-42 | 质检 → 扶正裁切 → 面部特征与两项判定 | data.features 为 JSON 字符串</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 正面照 | 检测人脸完整性和俯拍角度,通过后扶正裁切</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 img-preview">
|
||||
<div class="thumb" id="imgPreview"><span style="color:#9ca3af;padding:40px">—</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 英文优先字段 -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span>⭐ 英文优先字段</span><span id="countBadge" class="count-badge hidden"></span></div>
|
||||
<div class="card-body" style="padding:0">
|
||||
<table class="feat-table" id="enTable"></table>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 全部字段 -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span>📋 全部特征字段</span></div>
|
||||
<div class="card-body" style="padding:0;max-height:500px;overflow:auto">
|
||||
<table class="feat-table" id="allTable"></table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-side">
|
||||
<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 EN_KEYS = ['face_shape','eyebrow_shape','facial_age','dynamic_static_type','gender','gene_style','zygoma_expansion','temple_hollowing'];
|
||||
const EN_LABELS = {
|
||||
face_shape:'脸型', eyebrow_shape:'眉形', facial_age:'面部年龄',
|
||||
dynamic_static_type:'动静类型', gender:'性别', gene_style:'基因风格',
|
||||
zygoma_expansion:'颧弓外扩', temple_hollowing:'太阳穴凹陷'
|
||||
};
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
function setStatus(t, type) { const b=$('statusBar'); b.textContent=t; b.className='status '+type; }
|
||||
|
||||
async function submitTest() {
|
||||
const f = $('imageFile').files[0];
|
||||
if (!f) { setStatus('请选择图片', 'error'); return; }
|
||||
|
||||
$('imgPreview').innerHTML = '<img src="'+URL.createObjectURL(f)+'" alt="preview">';
|
||||
$('submitBtn').disabled = true; $('submitBtn').textContent = '⏳ 分析中...';
|
||||
setStatus('正在质检、扶正裁切并分析特征…', 'info'); $('resultsArea').classList.add('hidden');
|
||||
|
||||
$('enTable').textContent = '';
|
||||
$('allTable').textContent = '';
|
||||
$('countBadge').classList.add('hidden');
|
||||
const fd = new FormData(); fd.append('image_file', f);
|
||||
const _reqStart = performance.now();
|
||||
try {
|
||||
const r = await fetch(API_BASE + '/api/v1/face/features-42', { method:'POST', body:fd });
|
||||
const json = await r.json();
|
||||
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
||||
$('jsonContent').textContent = JSON.stringify(json, null, 2);
|
||||
$('resultsArea').classList.remove('hidden');
|
||||
|
||||
if (json.code === 0) {
|
||||
const feats = JSON.parse(json.data.features);
|
||||
setStatus('✅ 分析完成 (' + _elapsed + 's) — ' + Object.keys(feats).length + ' 项特征', 'success');
|
||||
renderFeatures(feats);
|
||||
} else {
|
||||
setStatus('❌ (' + _elapsed + 's) code=' + json.code + ' ' + json.message, 'error');
|
||||
}
|
||||
} catch(e) {
|
||||
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
||||
setStatus('❌ (' + _elapsed + 's) ' + e.message, 'error'); $('resultsArea').classList.remove('hidden'); }
|
||||
finally { $('submitBtn').disabled = false; $('submitBtn').textContent = '🚀 分析特征'; }
|
||||
}
|
||||
|
||||
function displayValue(value, key) {
|
||||
const label = value === true ? (EN_LABELS[key] ?? String(value)) : value === false ? ''
|
||||
: value === null ? '无法测量' : String(value);
|
||||
const element = document.createElement('span');
|
||||
element.textContent = label;
|
||||
return element.innerHTML;
|
||||
}
|
||||
|
||||
function renderFeatures(feats) {
|
||||
// 英文优先字段
|
||||
let enHtml = '';
|
||||
EN_KEYS.forEach(k => {
|
||||
if (feats[k] !== undefined) {
|
||||
enHtml += '<tr class="highlight"><td>'+displayValue(k)+'</td><td>'+EN_LABELS[k]+'</td><td>'+displayValue(feats[k], k)+'</td></tr>';
|
||||
}
|
||||
});
|
||||
$('enTable').innerHTML = enHtml;
|
||||
|
||||
// 全部字段
|
||||
const allKeys = Object.keys(feats);
|
||||
let allHtml = '';
|
||||
allKeys.forEach(k => {
|
||||
const isEn = EN_KEYS.includes(k);
|
||||
allHtml += '<tr'+(isEn?' class="highlight"':'')+'><td>'+displayValue(k)+'</td><td>'+(isEn?EN_LABELS[k]||'':'')+'</td><td>'+displayValue(feats[k], k)+'</td></tr>';
|
||||
});
|
||||
$('allTable').innerHTML = allHtml;
|
||||
|
||||
$('countBadge').textContent = allKeys.length + ' 项';
|
||||
$('countBadge').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function clearResults() {
|
||||
$('resultsArea').classList.add('hidden'); $('statusBar').className='status hidden';
|
||||
$('imageFile').value=''; $('jsonContent').textContent='';
|
||||
$('imgPreview').innerHTML='<span style="color:#9ca3af;padding:40px">—</span>';
|
||||
$('enTable').innerHTML=''; $('allTable').innerHTML='';
|
||||
$('countBadge').classList.add('hidden');
|
||||
}
|
||||
|
||||
function copyJson() {
|
||||
navigator.clipboard.writeText($('jsonContent').textContent).then(() => {
|
||||
const b = event.target, o = b.textContent; b.textContent = '✅ 已复制'; setTimeout(() => b.textContent = o, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const dz = $('imageFile');
|
||||
dz.addEventListener('dragover', e => { e.preventDefault(); dz.style.borderColor='#2563eb'; });
|
||||
dz.addEventListener('dragleave', () => dz.style.borderColor='#ddd');
|
||||
dz.addEventListener('drop', e => { e.preventDefault(); dz.style.borderColor='#ddd'; if(e.dataTransfer.files.length){ dz.files=e.dataTransfer.files; setStatus('已选择: '+e.dataTransfer.files[0].name, 'info'); } });
|
||||
dz.addEventListener('change', () => { if(dz.files.length) setStatus('已选择: '+dz.files[0].name, 'info'); });
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,128 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>72 号接口 · B 端五个关键点</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: #eef2f5; color: #192d3d; font: 16px/1.55 system-ui, sans-serif; }
|
||||
main { width: min(1040px, 100%); margin: auto; padding: 26px 18px; }
|
||||
h1 { margin: 0; font-size: clamp(24px, 4vw, 35px); }
|
||||
h2 { margin: 0 0 10px; font-size: 19px; }
|
||||
p { margin: 8px 0; }
|
||||
.note { color: #4f6577; }
|
||||
.controls, .panel { background: white; border-radius: 12px; padding: 18px; margin-top: 16px; }
|
||||
form { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
|
||||
button { background: #156a72; color: white; border: 0; border-radius: 7px; padding: 9px 15px; font: inherit; cursor: pointer; }
|
||||
button:disabled { opacity: .55; cursor: wait; }
|
||||
input { max-width: 100%; }
|
||||
#status[data-error="true"] { color: #a22629; }
|
||||
.results { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); gap: 16px; }
|
||||
[hidden] { display: none; }
|
||||
.photo { display: grid; border-radius: 8px; overflow: hidden; background: #1b2c37; }
|
||||
.photo img { grid-area: 1 / 1; width: 100%; display: block; }
|
||||
.panel a { color: #156a72; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 8px 0 20px; font-variant-numeric: tabular-nums; }
|
||||
th, td { text-align: left; border-bottom: 1px solid #e6edf2; padding: 5px 7px; }
|
||||
@media(max-width: 680px) { .results { grid-template-columns: 1fr; gap: 0; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>72 号接口 · B 端五个关键点</h1>
|
||||
<p class="note">上传正面照,查看扶正裁切后的六个白点、四条虚线和厘米标注。</p>
|
||||
<section class="controls">
|
||||
<form id="form">
|
||||
<label for="file">人脸照片</label>
|
||||
<input id="file" type="file" accept="image/jpeg,image/png" required>
|
||||
<button id="submit" type="submit">生成标注</button>
|
||||
<button id="sample" type="button">测试样例</button>
|
||||
</form>
|
||||
<p id="status" role="status">请选择 JPG 或 PNG 图片。</p>
|
||||
</section>
|
||||
<div id="results" class="results" hidden>
|
||||
<section class="panel">
|
||||
<h2>标注预览</h2>
|
||||
<div class="photo">
|
||||
<img id="base" alt="扶正裁切后的底图">
|
||||
<img id="overlay" alt="白点、虚线及距离标注">
|
||||
</div>
|
||||
<p><a id="download" download="interface72-overlay.png">下载透明标注层</a></p>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>测量结果</h2>
|
||||
<table><thead><tr><th>点位</th><th>距离</th></tr></thead><tbody id="distances"></tbody></table>
|
||||
<h2>坐标(像素)</h2>
|
||||
<table><thead><tr><th>点号</th><th>X</th><th>Y</th></tr></thead><tbody id="points"></tbody></table>
|
||||
<p class="note">点 6(MediaPipe 446)是射线锚点。六个白点的直径相同。</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
const byId = (id) => document.getElementById(id);
|
||||
const status = byId("status");
|
||||
const results = byId("results");
|
||||
const submit = byId("submit");
|
||||
const sample = byId("sample");
|
||||
|
||||
function setRows(id, rows) {
|
||||
const body = byId(id);
|
||||
body.replaceChildren();
|
||||
for (const values of rows) {
|
||||
const row = body.insertRow();
|
||||
for (const value of values) row.insertCell().textContent = value;
|
||||
}
|
||||
}
|
||||
|
||||
function imageSource(data, field, mime) {
|
||||
return data[field + "_url"] || ("data:" + mime + ";base64," + data[field + "_base64"]);
|
||||
}
|
||||
|
||||
async function runAnnotation(loadFile) {
|
||||
submit.disabled = sample.disabled = true;
|
||||
results.hidden = true;
|
||||
status.dataset.error = "false";
|
||||
status.textContent = "正在扶正裁切并生成标注…";
|
||||
try {
|
||||
const file = await loadFile();
|
||||
const form = new FormData();
|
||||
form.append("image_file", file, file.name || "sample.jpg");
|
||||
const response = await fetch("/api/v1/face/b-end-points", { method: "POST", body: form });
|
||||
const { code, message, data } = await response.json();
|
||||
if (!response.ok || code !== 0) throw new Error(message || "请求失败");
|
||||
|
||||
byId("base").src = imageSource(data, "input_image", "image/jpeg");
|
||||
const overlay = imageSource(data, "annotated_image", "image/png");
|
||||
byId("overlay").src = overlay;
|
||||
byId("download").href = overlay;
|
||||
setRows("distances", Object.entries(data.measurements_cm).map(([key, value]) => [
|
||||
key, value.toFixed(1) + " cm"
|
||||
]));
|
||||
setRows("points", Object.entries(data.points).map(([key, point]) => [
|
||||
key, point.x.toFixed(1), point.y.toFixed(1)
|
||||
]));
|
||||
results.hidden = false;
|
||||
status.textContent = "标注完成。";
|
||||
} catch (error) {
|
||||
status.dataset.error = "true";
|
||||
status.textContent = error.message;
|
||||
} finally {
|
||||
submit.disabled = sample.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
byId("form").addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const file = byId("file").files[0];
|
||||
if (file) runAnnotation(() => file);
|
||||
});
|
||||
|
||||
sample.addEventListener("click", () => runAnnotation(async () => {
|
||||
const response = await fetch("/static/sample.jpg");
|
||||
if (!response.ok) throw new Error("无法读取样例图片");
|
||||
return response.blob();
|
||||
}));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user