Files
hair/app.py
T
2026-06-13 12:11:31 +08:00

555 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""旷视五接口 - 第一版(Mock 实现)
当前为 Mock 服务,所有接口返回固定示例值,不做真实算法计算。
图片字段统一返回 https://hair.xiangsilian.com/static/sample.jpg。
"""
import json
from typing import Any, List, Optional
from fastapi import FastAPI, File, Form, UploadFile
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
# ---------------------------------------------------------------------------
# App & 全局常量
# ---------------------------------------------------------------------------
BASE_URL = "https://hair.xiangsilian.com"
SAMPLE_IMAGE_URL = f"{BASE_URL}/static/sample.jpg"
app = FastAPI(
title="旷视五接口",
version="0.1.0",
description="""
## 概述
本服务提供五个人像分析接口,当前为 **Mock 第一版**:
- 接口已全部上线,传任意合法参数均可正常响应
- 所有字段返回固定示例值,图片字段统一指向示例图片
- 真实算法逻辑后续接入,字段结构不变
## 图片传参说明
每个接口的图片参数均支持三种方式,**严格互斥,必须且只能选其一**:
| 方式 | 字段名 | 说明 |
|------|--------|------|
| 文件上传 | `image_file` | `multipart/form-data`,单文件 ≤ 1 MB |
| URL | `image_url` | 图片的完整 HTTP/HTTPS 地址 |
| base64 | `image_base64` | 需携带前缀,如 `data:image/jpeg;base64,xxxx` |
> 传 0 个或同时传多个,均返回错误码 `1007`。
## 图片要求
- 格式:**JPG / PNG**
- 分辨率:最低 1080×1920,最大 4000×5000
- 人脸数量:仅支持**单人**,多人返回错误码 `1005`
- 文件大小:≤ 1 MB(文件上传方式)
## 统一响应结构
```json
{
"code": 0,
"message": "success",
"request_id": "唯一请求标识",
"data": {}
}
```
`code = 0` 表示成功,非 0 表示失败,`message` 为具体原因。
## 错误码一览
| code | 说明 |
|------|------|
| 1001 | 无法识别人像 |
| 1002 | 人像分辨率过低 |
| 1003 | 非正面照 / 角度过大 |
| 1004 | 性别标签无法判定 |
| 1005 | 检测到多张人脸(仅支持单人)|
| 1006 | 文件超出 1 MB 限制 |
| 1007 | 图片参数错误(未传或同时传多个)|
| 1008 | 图片格式不支持(仅 JPG / PNG)|
""",
)
app.mount("/static", StaticFiles(directory="static"), name="static")
# ---------------------------------------------------------------------------
# 通用响应模型
# ---------------------------------------------------------------------------
def ok(data: Any) -> dict:
return {
"code": 0,
"message": "success",
"request_id": "mock-request-id",
"data": data,
}
def err(code: int, message: str) -> dict:
return {
"code": code,
"message": message,
"request_id": "mock-request-id",
"data": None,
}
# ---------------------------------------------------------------------------
# 通用图片请求 BodyJSON 方式,用于 url / base64
# ---------------------------------------------------------------------------
_image_fields_desc = (
"图片传参方式严格互斥,必须且只能选其一:\n"
"- **image_file**multipart/form-data 上传,≤ 1 MB\n"
"- **image_url**(完整 HTTP/HTTPS 地址)\n"
"- **image_base64**(需携带前缀,如 `data:image/jpeg;base64,xxxx`\n\n"
"同时传多个或一个都不传,均返回错误码 `1007`。"
)
class ImageJsonBody(BaseModel):
image_url: Optional[str] = Field(
default=None,
description="图片 URL(与 image_base64 二选一,不可同时传)",
examples=["https://hair.xiangsilian.com/static/sample.jpg"],
)
image_base64: Optional[str] = Field(
default=None,
description="图片 base64,需带前缀,如 `data:image/jpeg;base64,xxxx`",
examples=["data:image/jpeg;base64,/9j/4AAQSkZJRgAB..."],
)
model_config = {
"json_schema_extra": {
"examples": [
{
"summary": "使用 URL 传图",
"value": {"image_url": "https://hair.xiangsilian.com/static/sample.jpg"},
},
{
"summary": "使用 base64 传图",
"value": {"image_base64": "data:image/jpeg;base64,/9j/4AAQSkZJRgAB..."},
},
]
}
}
# ---------------------------------------------------------------------------
# 接口 1:四庭七眼测量标注
# ---------------------------------------------------------------------------
@app.post(
"/api/v1/face/measure",
summary="接口1 四庭七眼测量标注",
tags=["人脸分析"],
description=f"""
输入用户正面照,返回:
- 标注好四庭七眼数据的 **PNG 图片**(仅标注图层,不含人物)
- 四庭(顶庭/上庭/中庭/下庭)各段**厘米数值及占比**
- 七眼(眼宽/脸宽/两眼间距)**厘米数值及占比**
- 五个关键分界点的**原图像素坐标**(头顶/发际线/眉心/鼻翼下缘/下巴尖)
{_image_fields_desc}
图片同时支持 `multipart/form-data` 文件上传(字段名 `image_file`),或 JSON Body 传 `image_url` / `image_base64`。
---
**坐标说明**:所有坐标以原图像素为基准,原点为图片左上角,x 向右,y 向下。
**标注图片 UI 规范**(真实版本生效):
- 字体/线条颜色:`#FFFFFF 100%`
- 四庭数值在图片**左侧**呈现,七眼间距**上下穿插**展示
- 字体:PingFangSC-Regular 10pt,线宽 1pt
- 横线/竖线渐变消失,虚线两侧带箭头
""",
responses={
200: {
"description": "成功",
"content": {
"application/json": {
"example": {
"code": 0,
"message": "success",
"request_id": "mock-request-id",
"data": {
"annotated_image_url": SAMPLE_IMAGE_URL,
"face_total_height_cm": 13.76,
"four_courts": {
"top_court_cm": 3.44,
"upper_court_cm": 3.44,
"middle_court_cm": 3.44,
"lower_court_cm": 3.44,
"ratios": {
"top_court": 0.25,
"upper_court": 0.25,
"middle_court": 0.25,
"lower_court": 0.25,
},
},
"seven_eyes": {
"eye_width_cm": 3.44,
"face_width_cm": 24.08,
"inter_eye_distance_cm": 3.44,
"ratios": {"eye_width": 0.143, "inter_eye_distance": 0.143},
},
"landmarks": {
"hair_top": {"x": 540, "y": 120},
"hairline": {"x": 540, "y": 430},
"brow_center": {"x": 540, "y": 740},
"nose_bottom": {"x": 540, "y": 1050},
"chin_tip": {"x": 540, "y": 1360},
},
},
}
}
},
},
400: {
"description": "参数错误 / 图片识别失败",
"content": {
"application/json": {
"examples": {
"图片参数错误": {"value": {"code": 1007, "message": "图片参数错误:必须且只能传 image_file / image_url / image_base64 其中一个", "request_id": "x", "data": None}},
"无法识别人像": {"value": {"code": 1001, "message": "无法识别人像", "request_id": "x", "data": None}},
"多张人脸": {"value": {"code": 1005, "message": "检测到多张人脸,仅支持单人照片", "request_id": "x", "data": None}},
}
}
},
},
},
)
async def face_measure(
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG,≤ 1 MB"),
image_url: Optional[str] = Form(default=None, description="图片 URL"),
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
):
data = {
"annotated_image_url": SAMPLE_IMAGE_URL,
"face_total_height_cm": 13.76,
"four_courts": {
"top_court_cm": 3.44,
"upper_court_cm": 3.44,
"middle_court_cm": 3.44,
"lower_court_cm": 3.44,
"ratios": {
"top_court": 0.25,
"upper_court": 0.25,
"middle_court": 0.25,
"lower_court": 0.25,
},
},
"seven_eyes": {
"eye_width_cm": 3.44,
"face_width_cm": 24.08,
"inter_eye_distance_cm": 3.44,
"ratios": {"eye_width": 0.143, "inter_eye_distance": 0.143},
},
"landmarks": {
"hair_top": {"x": 540, "y": 120},
"hairline": {"x": 540, "y": 430},
"brow_center": {"x": 540, "y": 740},
"nose_bottom": {"x": 540, "y": 1050},
"chin_tip": {"x": 540, "y": 1360},
},
}
return ok(data)
# ---------------------------------------------------------------------------
# 接口 2C 端生发
# ---------------------------------------------------------------------------
@app.post(
"/api/v1/hair/grow",
summary="接口2 C端生发",
tags=["生发"],
description=f"""
输入用户正面照,返回多个生发方案,每个方案包含:
- 生发后效果图 URL
- 对应的发际线形(如花瓣形、波浪形)
- 合适度排序(order=1 最优)
{_image_fields_desc}
图片同时支持 `multipart/form-data` 文件上传(字段名 `image_file`)。
---
**beauty_enabled**:是否对生发后图片开启美颜,默认 `false`。
""",
responses={
200: {
"description": "成功",
"content": {
"application/json": {
"example": {
"code": 0,
"message": "success",
"request_id": "mock-request-id",
"data": {
"results": [
{"image_url": SAMPLE_IMAGE_URL, "hairline_type": "花瓣形", "order": 1},
{"image_url": SAMPLE_IMAGE_URL, "hairline_type": "波浪形", "order": 2},
]
},
}
}
},
},
400: {
"description": "参数错误 / 图片识别失败",
"content": {
"application/json": {
"examples": {
"图片参数错误": {"value": {"code": 1007, "message": "图片参数错误:必须且只能传 image_file / image_url / image_base64 其中一个", "request_id": "x", "data": None}},
"非正面照": {"value": {"code": 1003, "message": "角度问题,请上传正面照", "request_id": "x", "data": None}},
}
}
},
},
},
)
async def hair_grow(
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG,≤ 1 MB"),
image_url: Optional[str] = Form(default=None, description="图片 URL"),
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
beauty_enabled: bool = Form(default=False, description="是否开启美颜效果,默认 false"),
):
data = {
"results": [
{"image_url": SAMPLE_IMAGE_URL, "hairline_type": "花瓣形", "order": 1},
{"image_url": SAMPLE_IMAGE_URL, "hairline_type": "波浪形", "order": 2},
]
}
return ok(data)
# ---------------------------------------------------------------------------
# 接口 3B 端生发
# ---------------------------------------------------------------------------
@app.post(
"/api/v1/hair/grow-b",
summary="接口3 B端生发(医生/操作端)",
tags=["生发"],
description="""
医生/操作端在用户原图上**手动划线标注**目标发际线后,上传划线图与原图,返回:
- 最合适的发际线图片 URL
- 生发后效果图 URL
- 发际线形
**划线图片**:字段名前缀为 `marked_image_`,同样支持文件/URL/base64 三选一。
**原始用户照片**:字段名前缀为 `original_image_`,同样支持文件/URL/base64 三选一,**必填**。
> 两组图片的传参方式可以不同,例如划线图用 URL,原图用 base64,但各自组内严格互斥。
""",
responses={
200: {
"description": "成功",
"content": {
"application/json": {
"example": {
"code": 0,
"message": "success",
"request_id": "mock-request-id",
"data": {
"best_hairline_image_url": SAMPLE_IMAGE_URL,
"hair_growth_image_url": SAMPLE_IMAGE_URL,
"hairline_type": "花瓣形",
},
}
}
},
},
400: {
"description": "参数错误",
"content": {
"application/json": {
"example": {"code": 1007, "message": "图片参数错误", "request_id": "x", "data": None}
}
},
},
},
)
async def hair_grow_b(
marked_image_file: Optional[UploadFile] = File(default=None, description="划线图片文件(JPG/PNG,≤ 1 MB"),
marked_image_url: Optional[str] = Form(default=None, description="划线图片 URL"),
marked_image_base64: Optional[str] = Form(default=None, description="划线图片 base64"),
original_image_file: Optional[UploadFile] = File(default=None, description="原始用户照片文件(JPG/PNG,≤ 1 MB"),
original_image_url: Optional[str] = Form(default=None, description="原始用户照片 URL"),
original_image_base64: Optional[str] = Form(default=None, description="原始用户照片 base64"),
):
data = {
"best_hairline_image_url": SAMPLE_IMAGE_URL,
"hair_growth_image_url": SAMPLE_IMAGE_URL,
"hairline_type": "花瓣形",
}
return ok(data)
# ---------------------------------------------------------------------------
# 接口 4:用户特征
# ---------------------------------------------------------------------------
@app.post(
"/api/v1/face/features",
summary="接口4 用户特征分析",
tags=["人脸分析"],
description=f"""
输入用户照片,返回 N 个用户面部特征字段。
{_image_fields_desc}
图片同时支持 `multipart/form-data` 文件上传(字段名 `image_file`)。
---
**返回格式**`data.features` 为一个 **JSON 字符串**(不是对象),需要在客户端 `JSON.parse()` 后使用。
当前优先返回字段:
| 字段 | 类型 | 说明 |
|------|------|------|
| face_shape | string | 脸形,如"鹅蛋脸" |
| eyebrow_shape | string | 眉形,如"柳叶眉" |
| facial_age | int | 面部年龄 |
| dynamic_static_type | string | 动静类型,如"静态" |
| gender | string | 性别,如"女" |
| gene_style | object | 面部特征对应的基因风格 |
> 字段后续可随时增删,不固定,客户端按需取用即可。
""",
responses={
200: {
"description": "成功",
"content": {
"application/json": {
"example": {
"code": 0,
"message": "success",
"request_id": "mock-request-id",
"data": {
"features": '{"face_shape": "鹅蛋脸", "eyebrow_shape": "柳叶眉", "facial_age": 26, "dynamic_static_type": "静态", "gender": "女", "gene_style": {"label": "面部特征标签", "style": "基因风格示例"}}',
},
}
}
},
},
400: {
"description": "参数错误 / 图片识别失败",
"content": {
"application/json": {
"example": {"code": 1001, "message": "无法识别人像", "request_id": "x", "data": None}
}
},
},
},
)
async def face_features(
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG,≤ 1 MB"),
image_url: Optional[str] = Form(default=None, description="图片 URL"),
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
):
features = json.dumps(
{
"face_shape": "鹅蛋脸",
"eyebrow_shape": "柳叶眉",
"facial_age": 26,
"dynamic_static_type": "静态",
"gender": "女",
"gene_style": {"label": "面部特征标签", "style": "基因风格示例"},
},
ensure_ascii=False,
)
return ok({"features": features})
# ---------------------------------------------------------------------------
# 接口 5:发际线 PNG 生成
# ---------------------------------------------------------------------------
@app.post(
"/api/v1/hairline/generate",
summary="接口5 发际线PNG生成",
tags=["人脸分析"],
description=f"""
输入用户照片,返回 N 张用户发际线的 PNG 图片,并标注最合适发际线的面部中间点坐标。
{_image_fields_desc}
图片同时支持 `multipart/form-data` 文件上传(字段名 `image_file`)。
---
**返回说明**
- `hairline_images`:发际线 PNG 列表,数量 N 不固定,已按合适度**从高到低排序**(`order=1` 最合适)
- `best_hairline_center_point`:最合适发际线的面部中间点坐标,以**原图像素**为基准(左上角为原点,x 向右,y 向下)
""",
responses={
200: {
"description": "成功",
"content": {
"application/json": {
"example": {
"code": 0,
"message": "success",
"request_id": "mock-request-id",
"data": {
"hairline_images": [
{"image_url": SAMPLE_IMAGE_URL, "order": 1},
{"image_url": SAMPLE_IMAGE_URL, "order": 2},
],
"best_hairline_center_point": {"x": 540, "y": 430},
},
}
}
},
},
400: {
"description": "参数错误 / 图片识别失败",
"content": {
"application/json": {
"example": {"code": 1002, "message": "人像分辨率过低", "request_id": "x", "data": None}
}
},
},
},
)
async def hairline_generate(
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG,≤ 1 MB"),
image_url: Optional[str] = Form(default=None, description="图片 URL"),
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
):
data = {
"hairline_images": [
{"image_url": SAMPLE_IMAGE_URL, "order": 1},
{"image_url": SAMPLE_IMAGE_URL, "order": 2},
],
"best_hairline_center_point": {"x": 540, "y": 430},
}
return ok(data)
# ---------------------------------------------------------------------------
# 健康检查
# ---------------------------------------------------------------------------
@app.get("/health", include_in_schema=False)
async def health():
return {"status": "ok"}
@app.get("/", include_in_schema=False)
async def index():
return {"service": "旷视五接口", "version": "0.1.0", "docs": f"{BASE_URL}/docs"}