"""旷视五接口 - 第一版(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, } # --------------------------------------------------------------------------- # 通用图片请求 Body(JSON 方式,用于 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) # --------------------------------------------------------------------------- # 接口 2:C 端生发 # --------------------------------------------------------------------------- @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) # --------------------------------------------------------------------------- # 接口 3:B 端生发 # --------------------------------------------------------------------------- @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"}