Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a177dc2583 | ||
|
|
a9439e4975 |
@@ -30,14 +30,6 @@ logger = logging.getLogger("hair.worker")
|
|||||||
BASE_URL = "https://hair.xiangsilian.com"
|
BASE_URL = "https://hair.xiangsilian.com"
|
||||||
SAMPLE_IMAGE_URL = f"{BASE_URL}/static/sample.jpg"
|
SAMPLE_IMAGE_URL = f"{BASE_URL}/static/sample.jpg"
|
||||||
|
|
||||||
# 分辨率门槛(短边/长边,方向无关),可由环境变量覆盖(技术方案 §8.3)
|
|
||||||
MIN_SHORT_SIDE = int(os.getenv("MIN_SHORT_SIDE", "600"))
|
|
||||||
MIN_LONG_SIDE = int(os.getenv("MIN_LONG_SIDE", "800"))
|
|
||||||
# 文件大小上限(字节),可由环境变量覆盖;设为 0 表示不限制
|
|
||||||
MAX_FILE_BYTES = int(os.getenv("MAX_FILE_BYTES", "1000000")) # 默认 1 MB
|
|
||||||
if MAX_FILE_BYTES <= 0:
|
|
||||||
MAX_FILE_BYTES = float("inf")
|
|
||||||
|
|
||||||
# 运行期状态:模型就绪标志(/health 据此返回 200/503)
|
# 运行期状态:模型就绪标志(/health 据此返回 200/503)
|
||||||
_STATE = {"ready": False}
|
_STATE = {"ready": False}
|
||||||
|
|
||||||
@@ -103,7 +95,7 @@ app = FastAPI(
|
|||||||
|
|
||||||
| 方式 | 字段名 | 说明 |
|
| 方式 | 字段名 | 说明 |
|
||||||
|------|--------|------|
|
|------|--------|------|
|
||||||
| 文件上传 | `image_file` | `multipart/form-data`,单文件 ≤ 1 MB |
|
| 文件上传 | `image_file` | `multipart/form-data`,单文件上传 |
|
||||||
| URL | `image_url` | 图片的完整 HTTP/HTTPS 地址 |
|
| URL | `image_url` | 图片的完整 HTTP/HTTPS 地址 |
|
||||||
| base64 | `image_base64` | 需携带前缀,如 `data:image/jpeg;base64,xxxx` |
|
| base64 | `image_base64` | 需携带前缀,如 `data:image/jpeg;base64,xxxx` |
|
||||||
|
|
||||||
@@ -112,9 +104,9 @@ app = FastAPI(
|
|||||||
## 图片要求
|
## 图片要求
|
||||||
|
|
||||||
- 格式:**JPG / PNG**
|
- 格式:**JPG / PNG**
|
||||||
- 分辨率:最低 1080×1920,最大 4000×5000
|
- 分辨率:不限制
|
||||||
- 人脸数量:仅支持**单人**,多人返回错误码 `1005`
|
- 人脸数量:仅支持**单人**,多人返回错误码 `1005`
|
||||||
- 文件大小:≤ 1 MB(文件上传方式)
|
- 文件大小:不限制
|
||||||
|
|
||||||
## 统一响应结构
|
## 统一响应结构
|
||||||
|
|
||||||
@@ -134,11 +126,9 @@ app = FastAPI(
|
|||||||
| code | 说明 |
|
| code | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 1001 | 无法识别人像 |
|
| 1001 | 无法识别人像 |
|
||||||
| 1002 | 人像分辨率过低 |
|
|
||||||
| 1003 | 非正面照 / 角度过大 |
|
| 1003 | 非正面照 / 角度过大 |
|
||||||
| 1004 | 性别标签无法判定 |
|
| 1004 | 性别标签无法判定 |
|
||||||
| 1005 | 检测到多张人脸(仅支持单人)|
|
| 1005 | 检测到多张人脸(仅支持单人)|
|
||||||
| 1006 | 文件超出 1 MB 限制 |
|
|
||||||
| 1007 | 图片参数错误(未传或同时传多个)|
|
| 1007 | 图片参数错误(未传或同时传多个)|
|
||||||
| 1008 | 图片格式不支持(仅 JPG / PNG)|
|
| 1008 | 图片格式不支持(仅 JPG / PNG)|
|
||||||
""",
|
""",
|
||||||
@@ -202,7 +192,7 @@ async def resolve_image_bytes(image_file, image_url, image_base64):
|
|||||||
"""三选一取图,返回 (raw_bytes, error_response)。
|
"""三选一取图,返回 (raw_bytes, error_response)。
|
||||||
|
|
||||||
严格互斥:传 0 个或多个 → 1007;URL 下载失败/base64 解码失败 → 1008。
|
严格互斥:传 0 个或多个 → 1007;URL 下载失败/base64 解码失败 → 1008。
|
||||||
大小校验放到上层(统一 1006),此处只负责取到字节。
|
不限制文件大小,此处只负责取到字节。
|
||||||
"""
|
"""
|
||||||
provided = [x for x in (image_file, image_url, image_base64) if x]
|
provided = [x for x in (image_file, image_url, image_base64) if x]
|
||||||
if len(provided) != 1:
|
if len(provided) != 1:
|
||||||
@@ -280,7 +270,7 @@ def _parse_hair_styles(raw: Optional[str], max_styles: int) -> Optional[list[int
|
|||||||
|
|
||||||
_image_fields_desc = (
|
_image_fields_desc = (
|
||||||
"图片传参方式严格互斥,必须且只能选其一:\n"
|
"图片传参方式严格互斥,必须且只能选其一:\n"
|
||||||
"- **image_file**(multipart/form-data 上传,≤ 1 MB)\n"
|
"- **image_file**(multipart/form-data 上传)\n"
|
||||||
"- **image_url**(完整 HTTP/HTTPS 地址)\n"
|
"- **image_url**(完整 HTTP/HTTPS 地址)\n"
|
||||||
"- **image_base64**(需携带前缀,如 `data:image/jpeg;base64,xxxx`)\n\n"
|
"- **image_base64**(需携带前缀,如 `data:image/jpeg;base64,xxxx`)\n\n"
|
||||||
"同时传多个或一个都不传,均返回错误码 `1007`。"
|
"同时传多个或一个都不传,均返回错误码 `1007`。"
|
||||||
@@ -331,20 +321,12 @@ async def _face_measure_impl(image_file, image_url, image_base64, variant="v1"):
|
|||||||
if e is not None:
|
if e is not None:
|
||||||
return None, e
|
return None, e
|
||||||
|
|
||||||
# 2. 大小校验(≤ 1MB)
|
# 2. 解码(不限制文件大小 / 分辨率)
|
||||||
if len(raw) > MAX_FILE_BYTES:
|
|
||||||
return None, err(1006, "文件超出 1 MB 限制")
|
|
||||||
|
|
||||||
# 3. 解码
|
|
||||||
image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
|
image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
|
||||||
if image is None:
|
if image is None:
|
||||||
return None, err(1008, "图片格式不支持(仅 JPG / PNG)")
|
return None, err(1008, "图片格式不支持(仅 JPG / PNG)")
|
||||||
|
|
||||||
# 4. 分辨率(短边/长边,方向无关,可配置门槛)
|
|
||||||
h, w = image.shape[:2]
|
h, w = image.shape[:2]
|
||||||
short_side, long_side = min(w, h), max(w, h)
|
|
||||||
if short_side < MIN_SHORT_SIDE or long_side < MIN_LONG_SIDE:
|
|
||||||
return None, err(1002, "人像分辨率过低")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from face_analysis.detector import detector
|
from face_analysis.detector import detector
|
||||||
@@ -362,17 +344,24 @@ async def _face_measure_impl(image_file, image_url, image_base64, variant="v1"):
|
|||||||
return None, err(1003, "角度问题,请上传正面照")
|
return None, err(1003, "角度问题,请上传正面照")
|
||||||
head_pose = estimate_head_pose(landmarks, w, h)
|
head_pose = estimate_head_pose(landmarks, w, h)
|
||||||
|
|
||||||
# 7. 头发分割(方案 B),失败传 None 由 measure 内部回退方案 A
|
# 7. 头发/耳朵分割(方案 B,单次推理),失败传 None 由 measure 内部回退方案 A。
|
||||||
|
# 传人脸包围盒 → 先按人脸裁剪再分割(全身/街拍等脸偏小的图也能稳出耳朵)。
|
||||||
hair_mask = None
|
hair_mask = None
|
||||||
|
ear_mask = None
|
||||||
try:
|
try:
|
||||||
from face_analysis.hair_segmenter import get_segmenter
|
from face_analysis.hair_segmenter import get_segmenter
|
||||||
hair_mask = get_segmenter().segment_hair(image)
|
from face_analysis.calibration import normalized_to_pixel
|
||||||
|
pxs = [normalized_to_pixel(p, w, h) for p in landmarks.landmark]
|
||||||
|
face_box = (min(p[0] for p in pxs), min(p[1] for p in pxs),
|
||||||
|
max(p[0] for p in pxs), max(p[1] for p in pxs))
|
||||||
|
hair_mask, ear_mask = get_segmenter().segment_hair_and_ears(image, face_box=face_box)
|
||||||
except Exception as seg_e: # noqa: BLE001
|
except Exception as seg_e: # noqa: BLE001
|
||||||
logger.warning("头发分割失败,回退方案A:%s", seg_e)
|
logger.warning("头发/耳朵分割失败,回退方案A:%s", seg_e)
|
||||||
|
|
||||||
# 8. 测量 + 标注图
|
# 8. 测量 + 标注图(hair_mask 定发际线/头顶;ear_mask 定人头最左/最右竖线)
|
||||||
result = measure_face(landmarks, hair_mask, w, h, head_pose=head_pose)
|
result = measure_face(landmarks, hair_mask, w, h, head_pose=head_pose)
|
||||||
annotated = create_annotated_image(image, result, hair_mask=hair_mask, variant=variant)
|
annotated = create_annotated_image(
|
||||||
|
image, result, ear_mask=ear_mask, hair_mask=hair_mask, variant=variant)
|
||||||
buf = BytesIO()
|
buf = BytesIO()
|
||||||
annotated.save(buf, format="PNG")
|
annotated.save(buf, format="PNG")
|
||||||
|
|
||||||
@@ -481,7 +470,7 @@ async def _face_measure_impl(image_file, image_url, image_base64, variant="v1"):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def face_measure(
|
async def face_measure(
|
||||||
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG,≤ 1 MB)"),
|
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG)"),
|
||||||
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
||||||
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
|
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
|
||||||
):
|
):
|
||||||
@@ -580,7 +569,7 @@ async def face_measure(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def face_measure_v2(
|
async def face_measure_v2(
|
||||||
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG,≤ 1 MB)"),
|
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG)"),
|
||||||
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
||||||
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
|
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
|
||||||
):
|
):
|
||||||
@@ -653,7 +642,7 @@ async def face_measure_v2(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def hair_grow(
|
async def hair_grow(
|
||||||
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG,≤ 1 MB)"),
|
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG)"),
|
||||||
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
||||||
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
|
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
|
||||||
gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"),
|
gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"),
|
||||||
@@ -676,18 +665,11 @@ async def hair_grow(
|
|||||||
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
|
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
|
||||||
if e is not None:
|
if e is not None:
|
||||||
return e
|
return e
|
||||||
if len(raw) > MAX_FILE_BYTES:
|
|
||||||
return err(1006, "文件超出 1 MB 限制")
|
|
||||||
|
|
||||||
image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
|
image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
|
||||||
if image is None:
|
if image is None:
|
||||||
return err(1008, "图片格式不支持(仅 JPG / PNG)")
|
return err(1008, "图片格式不支持(仅 JPG / PNG)")
|
||||||
|
|
||||||
h, w = image.shape[:2]
|
|
||||||
short_side, long_side = min(w, h), max(w, h)
|
|
||||||
if short_side < MIN_SHORT_SIDE or long_side < MIN_LONG_SIDE:
|
|
||||||
return err(1002, "人像分辨率过低")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from fastapi.concurrency import run_in_threadpool
|
from fastapi.concurrency import run_in_threadpool
|
||||||
from hairline.service import generate_grow_results
|
from hairline.service import generate_grow_results
|
||||||
@@ -774,7 +756,7 @@ _WORKFLOW2_PATH = os.path.join(os.path.dirname(__file__), "add_hair2.json")
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def hair_grow_v2(
|
async def hair_grow_v2(
|
||||||
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG,≤ 1 MB)"),
|
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG)"),
|
||||||
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
||||||
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
|
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
|
||||||
gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"),
|
gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"),
|
||||||
@@ -797,18 +779,11 @@ async def hair_grow_v2(
|
|||||||
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
|
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
|
||||||
if e is not None:
|
if e is not None:
|
||||||
return e
|
return e
|
||||||
if len(raw) > MAX_FILE_BYTES:
|
|
||||||
return err(1006, "文件超出 1 MB 限制")
|
|
||||||
|
|
||||||
image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
|
image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
|
||||||
if image is None:
|
if image is None:
|
||||||
return err(1008, "图片格式不支持(仅 JPG / PNG)")
|
return err(1008, "图片格式不支持(仅 JPG / PNG)")
|
||||||
|
|
||||||
h, w = image.shape[:2]
|
|
||||||
short_side, long_side = min(w, h), max(w, h)
|
|
||||||
if short_side < MIN_SHORT_SIDE or long_side < MIN_LONG_SIDE:
|
|
||||||
return err(1002, "人像分辨率过低")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from fastapi.concurrency import run_in_threadpool
|
from fastapi.concurrency import run_in_threadpool
|
||||||
from hairline.service import generate_grow_results
|
from hairline.service import generate_grow_results
|
||||||
@@ -876,7 +851,7 @@ async def hair_grow_v2(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def hair_grow_b(
|
async def hair_grow_b(
|
||||||
marked_image_file: Optional[UploadFile] = File(default=None, description="划线图片文件(JPG/PNG,≤ 1 MB)"),
|
marked_image_file: Optional[UploadFile] = File(default=None, description="划线图片文件(JPG/PNG)"),
|
||||||
marked_image_url: Optional[str] = Form(default=None, description="划线图片 URL"),
|
marked_image_url: Optional[str] = Form(default=None, description="划线图片 URL"),
|
||||||
marked_image_base64: Optional[str] = Form(default=None, description="划线图片 base64"),
|
marked_image_base64: Optional[str] = Form(default=None, description="划线图片 base64"),
|
||||||
use_mask: bool = Form(default=True, description="是否画发际线(测试对比用)。false 时跳过划线检测、直接送划线图"),
|
use_mask: bool = Form(default=True, description="是否画发际线(测试对比用)。false 时跳过划线检测、直接送划线图"),
|
||||||
@@ -886,18 +861,11 @@ async def hair_grow_b(
|
|||||||
marked_raw, e = await resolve_image_bytes(marked_image_file, marked_image_url, marked_image_base64)
|
marked_raw, e = await resolve_image_bytes(marked_image_file, marked_image_url, marked_image_base64)
|
||||||
if e is not None:
|
if e is not None:
|
||||||
return e
|
return e
|
||||||
if len(marked_raw) > MAX_FILE_BYTES:
|
|
||||||
return err(1006, "文件超出 1 MB 限制")
|
|
||||||
|
|
||||||
marked = cv2.imdecode(np.frombuffer(marked_raw, np.uint8), cv2.IMREAD_COLOR)
|
marked = cv2.imdecode(np.frombuffer(marked_raw, np.uint8), cv2.IMREAD_COLOR)
|
||||||
if marked is None:
|
if marked is None:
|
||||||
return err(1008, "图片格式不支持(仅 JPG / PNG)")
|
return err(1008, "图片格式不支持(仅 JPG / PNG)")
|
||||||
|
|
||||||
h, w = marked.shape[:2]
|
|
||||||
short_side, long_side = min(w, h), max(w, h)
|
|
||||||
if short_side < MIN_SHORT_SIDE or long_side < MIN_LONG_SIDE:
|
|
||||||
return err(1002, "人像分辨率过低")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from fastapi.concurrency import run_in_threadpool
|
from fastapi.concurrency import run_in_threadpool
|
||||||
from hairline.service import generate_grow_b
|
from hairline.service import generate_grow_b
|
||||||
@@ -978,7 +946,7 @@ async def hair_grow_b(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def face_features(
|
async def face_features(
|
||||||
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG,≤ 1 MB)"),
|
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG)"),
|
||||||
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
||||||
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
|
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
|
||||||
):
|
):
|
||||||
@@ -1045,14 +1013,14 @@ async def face_features(
|
|||||||
"description": "参数错误 / 图片识别失败",
|
"description": "参数错误 / 图片识别失败",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"example": {"code": 1002, "message": "人像分辨率过低", "request_id": "x", "data": None}
|
"example": {"code": 1001, "message": "无法识别人像", "request_id": "x", "data": None}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def hairline_generate(
|
async def hairline_generate(
|
||||||
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG,≤ 1 MB)"),
|
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG)"),
|
||||||
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
||||||
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
|
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
|
||||||
gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"),
|
gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"),
|
||||||
@@ -1063,18 +1031,11 @@ async def hairline_generate(
|
|||||||
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
|
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
|
||||||
if e is not None:
|
if e is not None:
|
||||||
return e
|
return e
|
||||||
if len(raw) > MAX_FILE_BYTES:
|
|
||||||
return err(1006, "文件超出 1 MB 限制")
|
|
||||||
|
|
||||||
image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
|
image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
|
||||||
if image is None:
|
if image is None:
|
||||||
return err(1008, "图片格式不支持(仅 JPG / PNG)")
|
return err(1008, "图片格式不支持(仅 JPG / PNG)")
|
||||||
|
|
||||||
h, w = image.shape[:2]
|
|
||||||
short_side, long_side = min(w, h), max(w, h)
|
|
||||||
if short_side < MIN_SHORT_SIDE or long_side < MIN_LONG_SIDE:
|
|
||||||
return err(1002, "人像分辨率过低")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from fastapi.concurrency import run_in_threadpool
|
from fastapi.concurrency import run_in_threadpool
|
||||||
from hairline.service import generate_hairline_pngs
|
from hairline.service import generate_hairline_pngs
|
||||||
|
|||||||
+63
-23
@@ -5,6 +5,7 @@
|
|||||||
- 四庭水平分界线:numpy 向量化渐变消失(中间亮、两侧渐隐)。
|
- 四庭水平分界线:numpy 向量化渐变消失(中间亮、两侧渐隐)。
|
||||||
- 纵向竖线 8 条:人头最左 + 左脸颊/左眼外/内角/右眼内/外角/右脸颊 + 人头最右,
|
- 纵向竖线 8 条:人头最左 + 左脸颊/左眼外/内角/右眼内/外角/右脸颊 + 人头最右,
|
||||||
把头宽切 7 段(七眼),段宽数值上下交替(上 3 / 下 4),带虚线双箭头。
|
把头宽切 7 段(七眼),段宽数值上下交替(上 3 / 下 4),带虚线双箭头。
|
||||||
|
人头最左/最右取自耳朵分割外缘,看不到耳朵则省略该侧(最少 6 点 5 段)。
|
||||||
- 四庭:图片左侧,「名」上「数值」下两行换行(不带 cm),带竖向虚线双箭头。
|
- 四庭:图片左侧,「名」上「数值」下两行换行(不带 cm),带竖向虚线双箭头。
|
||||||
- 五条横线右侧标名:头顶/发际线/眉心/鼻翼下缘/下巴尖。
|
- 五条横线右侧标名:头顶/发际线/眉心/鼻翼下缘/下巴尖。
|
||||||
- 单位 cm 统一标在底部「单位cm」。
|
- 单位 cm 统一标在底部「单位cm」。
|
||||||
@@ -116,38 +117,77 @@ _LINE_NAMES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _head_edges_from_mask(hair_mask, y0, y1, left_cheek_x, right_cheek_x, min_gap):
|
def _grow_outward(start_col, fg_band, direction, limit):
|
||||||
"""从头发分割掩膜取人头最左/最右 x(仅在脸纵向范围 [y0,y1] 内统计)。
|
"""从 start_col 沿 direction(+1 右 / -1 左) 在前景带 fg_band 内逐列外扩。
|
||||||
|
|
||||||
返回 (head_left_x, head_right_x);某侧无掩膜/向内噪声,或离脸颊线过近
|
用于回收被误标成「头发」的外耳轮廓:耳朵被头发遮挡时,外耳轮廓那一圈常被
|
||||||
(间距 < min_gap)则该侧为 None —— 即与脸颊线太近时只保留脸颊线。
|
分割并入头发类,故耳朵掩膜外缘会偏内。这里把外缘沿紧邻的前景(耳∪发)向外
|
||||||
|
延伸,最多 limit 列;一旦下一列无前景(背景间隙)立即停止,绝不窜到分离的
|
||||||
|
那缕头发上。返回外扩后的列号。
|
||||||
"""
|
"""
|
||||||
if hair_mask is None:
|
w = fg_band.shape[1]
|
||||||
|
c = int(start_col)
|
||||||
|
for _ in range(int(limit)):
|
||||||
|
nc = c + direction
|
||||||
|
if not (0 <= nc < w) or not fg_band[:, nc].any():
|
||||||
|
break
|
||||||
|
c = nc
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def _ear_edges_from_mask(ear_mask, hair_mask, y0, y1, left_cheek_x, right_cheek_x,
|
||||||
|
face_center_x):
|
||||||
|
"""从耳朵分割掩膜取人头最左/最右 x(仅在脸纵向范围 [y0,y1] 内统计)。
|
||||||
|
|
||||||
|
左线 = 脸中线左侧耳朵像素的最左列;右线 = 右侧耳朵像素的最右列;再沿紧邻的
|
||||||
|
前景(耳∪发)按脸宽自适应外扩,回收被误标成头发的外耳轮廓(见 _grow_outward)。
|
||||||
|
某侧耳朵不可见(被头发/侧脸遮挡 → 掩膜为空),或外缘未越过对应脸颊线(非真实
|
||||||
|
头宽边缘)时该侧返回 None —— 即「看不到耳朵就不画这条线」。
|
||||||
|
"""
|
||||||
|
if ear_mask is None:
|
||||||
return None, None
|
return None, None
|
||||||
m = np.asarray(hair_mask)
|
m = np.asarray(ear_mask)
|
||||||
if m.ndim == 3:
|
if m.ndim == 3:
|
||||||
m = m[..., 0]
|
m = m[..., 0]
|
||||||
|
m = m > 0
|
||||||
h = m.shape[0]
|
h = m.shape[0]
|
||||||
y0 = max(0, int(y0)); y1 = min(h - 1, int(y1))
|
y0 = max(0, int(y0)); y1 = min(h - 1, int(y1))
|
||||||
if y1 <= y0:
|
if y1 <= y0:
|
||||||
return None, None
|
return None, None
|
||||||
band = m[y0:y1 + 1] > 0
|
ear_band = m[y0:y1 + 1]
|
||||||
cols = np.where(band.any(axis=0))[0]
|
cols = np.where(ear_band.any(axis=0))[0]
|
||||||
if cols.size == 0:
|
if cols.size == 0:
|
||||||
return None, None
|
return None, None
|
||||||
hl, hr = float(cols.min()), float(cols.max())
|
# 前景带 = 耳∪发(外耳轮廓常被误标为发),外扩上限按脸宽自适应
|
||||||
# 仅当确实在脸颊外侧、且与脸颊线间距足够大时才采用
|
fg_band = ear_band
|
||||||
return (hl if (left_cheek_x - hl) >= min_gap else None,
|
if hair_mask is not None:
|
||||||
hr if (hr - right_cheek_x) >= min_gap else None)
|
hm = np.asarray(hair_mask)
|
||||||
|
if hm.ndim == 3:
|
||||||
|
hm = hm[..., 0]
|
||||||
|
fg_band = ear_band | (hm[y0:y1 + 1] > 0)
|
||||||
|
grow = max(2, round(max(1.0, right_cheek_x - left_cheek_x) * 0.045))
|
||||||
|
|
||||||
|
left_cols = cols[cols < face_center_x]
|
||||||
|
right_cols = cols[cols > face_center_x]
|
||||||
|
# 左/右耳外缘(外扩后),且必须在对应脸颊线外侧(否则视为残缺/噪声,只保留脸颊线)
|
||||||
|
head_l = head_r = None
|
||||||
|
if right_cols.size:
|
||||||
|
edge = _grow_outward(right_cols.max(), fg_band, +1, grow)
|
||||||
|
head_r = float(edge) if edge >= right_cheek_x else None
|
||||||
|
if left_cols.size:
|
||||||
|
edge = _grow_outward(left_cols.min(), fg_band, -1, grow)
|
||||||
|
head_l = float(edge) if edge <= left_cheek_x else None
|
||||||
|
return head_l, head_r
|
||||||
|
|
||||||
|
|
||||||
def create_annotated_image(image_bgr, measure_result, hair_mask=None, variant="v1"):
|
def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=None,
|
||||||
|
variant="v1"):
|
||||||
"""生成标注图层 PNG(透明底 RGBA,尺寸同原图)。返回 PIL.Image。
|
"""生成标注图层 PNG(透明底 RGBA,尺寸同原图)。返回 PIL.Image。
|
||||||
|
|
||||||
布局(对齐 img1.png 版本5):
|
布局(对齐 img1.png 版本5):
|
||||||
- 纵向竖线:人头最左 + 七眼 6 点 + 人头最右,切 7 段(七眼)。人头最左/最右
|
- 纵向竖线:人头最左 + 七眼 6 点 + 人头最右,切 7 段(七眼)。人头最左/最右
|
||||||
取自头发分割掩膜的轮廓(方案 B);无掩膜(方案 A 兜底)时省略头部端线,
|
取自耳朵分割掩膜的外缘(方案 B,BiSeNet 类 7/8);耳朵不可见(被头发/侧脸
|
||||||
只画 6 点 5 段。
|
遮挡 → 掩膜空)或无掩膜时省略该侧端线,只画对应脸颊线。
|
||||||
- 横向 5 条分界线:头顶/发际线/眉心/鼻翼下缘/下巴尖,右侧标名。
|
- 横向 5 条分界线:头顶/发际线/眉心/鼻翼下缘/下巴尖,右侧标名。
|
||||||
- 四庭(顶/上/中/下庭)在左侧:名 + 数值两行换行(无 cm),竖向虚线双箭头。
|
- 四庭(顶/上/中/下庭)在左侧:名 + 数值两行换行(无 cm),竖向虚线双箭头。
|
||||||
- 七眼段宽数值上下交替(上 3 / 下 4,无 cm),横向虚线双箭头。
|
- 七眼段宽数值上下交替(上 3 / 下 4,无 cm),横向虚线双箭头。
|
||||||
@@ -186,12 +226,11 @@ def create_annotated_image(image_bgr, measure_result, hair_mask=None, variant="v
|
|||||||
if variant == "v6":
|
if variant == "v6":
|
||||||
xs = sorted(base_xs) # 接口6:仅七眼 6 点,不画人头最左/最右端线
|
xs = sorted(base_xs) # 接口6:仅七眼 6 点,不画人头最左/最右端线
|
||||||
else:
|
else:
|
||||||
# 人头最左/最右:取自头发分割掩膜(方案B),脸纵向范围内统计;无掩膜则省略。
|
# 人头最左/最右:取自耳朵分割掩膜外缘(方案B,类7/8),脸纵向范围内统计。
|
||||||
# 与脸颊线间距 < 脸宽×8% 视为太近,只保留脸颊线(不画头部端线)。
|
# 看不到耳朵(被头发/侧脸遮挡 → 掩膜空)或外缘未越过脸颊线则省略该侧端线。
|
||||||
face_w = pts["right_cheek"][0] - pts["left_cheek"][0]
|
lcx, rcx = pts["left_cheek"][0], pts["right_cheek"][0]
|
||||||
min_gap = max(1.0, face_w * 0.08)
|
head_l, head_r = _ear_edges_from_mask(
|
||||||
head_l, head_r = _head_edges_from_mask(
|
ear_mask, hair_mask, ys[0], ys[-1], lcx, rcx, (lcx + rcx) / 2)
|
||||||
hair_mask, ys[0], ys[-1], pts["left_cheek"][0], pts["right_cheek"][0], min_gap)
|
|
||||||
head_xs = [x for x in (head_l, head_r) if x is not None]
|
head_xs = [x for x in (head_l, head_r) if x is not None]
|
||||||
xs = sorted(base_xs + head_xs) # 自左向右(6 或 7/8 点)
|
xs = sorted(base_xs + head_xs) # 自左向右(6 或 7/8 点)
|
||||||
|
|
||||||
@@ -304,15 +343,16 @@ if __name__ == "__main__":
|
|||||||
print("未检出人脸")
|
print("未检出人脸")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
mask = None
|
mask = None
|
||||||
|
ears = None
|
||||||
try:
|
try:
|
||||||
from face_analysis.hair_segmenter import get_segmenter
|
from face_analysis.hair_segmenter import get_segmenter
|
||||||
mask = get_segmenter().segment_hair(img)
|
mask, ears = get_segmenter().segment_hair_and_ears(img)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
print(f"[warn] 分割不可用,回退方案 A:{e}")
|
print(f"[warn] 分割不可用,回退方案 A:{e}")
|
||||||
|
|
||||||
result = measure_face(lms, mask, w, h)
|
result = measure_face(lms, mask, w, h)
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
canvas = create_annotated_image(img, result, hair_mask=mask)
|
canvas = create_annotated_image(img, result, ear_mask=ears, hair_mask=mask)
|
||||||
dt = time.time() - t0
|
dt = time.time() - t0
|
||||||
os.makedirs(os.path.dirname(out), exist_ok=True)
|
os.makedirs(os.path.dirname(out), exist_ok=True)
|
||||||
canvas.save(out)
|
canvas.save(out)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"""方案 B:BiSeNet 头发分割 + 发际线/头顶定位。
|
"""方案 B:BiSeNet 头发/耳朵分割 + 发际线/头顶定位。
|
||||||
|
|
||||||
加载 face-parsing BiSeNet(19 类,hair=17),对整图做像素级语义分割得到头发
|
加载 face-parsing BiSeNet(CelebAMask-HQ 19 类,hair=17、l_ear=7、r_ear=8),对整图
|
||||||
mask,再沿面部中轴线扫描得到真实发际线与头顶。GPU 可用时走 CUDA,否则 CPU。
|
做像素级语义分割:得到头发 mask 用于沿面部中轴线扫描真实发际线与头顶,并得到耳朵
|
||||||
|
mask 供标注图取人头最左/最右竖线(耳朵外缘)。GPU 可用时走 CUDA,否则 CPU。
|
||||||
单例加载权重,避免每请求重载。详见技术方案 §1.4 / §4.0。
|
单例加载权重,避免每请求重载。详见技术方案 §1.4 / §4.0。
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
@@ -15,6 +16,7 @@ import numpy as np
|
|||||||
|
|
||||||
_WEIGHTS = os.path.join(os.path.dirname(__file__), "weights", "79999_iter.pth")
|
_WEIGHTS = os.path.join(os.path.dirname(__file__), "weights", "79999_iter.pth")
|
||||||
HAIR_CLASS = 17 # CelebAMask-HQ 19 类中 hair 的索引
|
HAIR_CLASS = 17 # CelebAMask-HQ 19 类中 hair 的索引
|
||||||
|
EAR_CLASSES = (7, 8) # 7=l_ear / 8=r_ear(类名以人为参照,图像左右另行判定,不依赖类名)
|
||||||
N_CLASSES = 19
|
N_CLASSES = 19
|
||||||
_INPUT_SIZE = 512 # BiSeNet 推理输入边长
|
_INPUT_SIZE = 512 # BiSeNet 推理输入边长
|
||||||
|
|
||||||
@@ -59,8 +61,8 @@ class HairSegmenter:
|
|||||||
transforms.Normalize((0.485, 0.456, 0.406), (0.229, 0.224, 0.225)),
|
transforms.Normalize((0.485, 0.456, 0.406), (0.229, 0.224, 0.225)),
|
||||||
])
|
])
|
||||||
|
|
||||||
def segment_hair(self, image_bgr):
|
def _parse(self, image_bgr):
|
||||||
"""返回 hair_mask(H×W bool,True=头发),尺寸同输入原图。"""
|
"""整图语义分割,返回原图尺寸的类别图(H×W int,值为 0–18 类别号)。"""
|
||||||
torch = self._torch
|
torch = self._torch
|
||||||
h, w = image_bgr.shape[:2]
|
h, w = image_bgr.shape[:2]
|
||||||
rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
|
rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
|
||||||
@@ -70,10 +72,49 @@ class HairSegmenter:
|
|||||||
with torch.no_grad():
|
with torch.no_grad():
|
||||||
out = self.net(inp)[0] # 主输出 (1, C, 512, 512)
|
out = self.net(inp)[0] # 主输出 (1, C, 512, 512)
|
||||||
parsing = out.squeeze(0).argmax(0).cpu().numpy() # (512, 512) 类别图
|
parsing = out.squeeze(0).argmax(0).cpu().numpy() # (512, 512) 类别图
|
||||||
hair_small = (parsing == HAIR_CLASS).astype(np.uint8)
|
|
||||||
# 还原到原图尺寸(最近邻保持类别边界)
|
# 还原到原图尺寸(最近邻保持类别边界)
|
||||||
hair_mask = cv2.resize(hair_small, (w, h), interpolation=cv2.INTER_NEAREST)
|
return cv2.resize(parsing.astype(np.int32), (w, h),
|
||||||
return hair_mask.astype(bool)
|
interpolation=cv2.INTER_NEAREST)
|
||||||
|
|
||||||
|
def segment_hair(self, image_bgr):
|
||||||
|
"""返回 hair_mask(H×W bool,True=头发),尺寸同输入原图。"""
|
||||||
|
return self._parse(image_bgr) == HAIR_CLASS
|
||||||
|
|
||||||
|
def _parse_face_cropped(self, image_bgr, face_box):
|
||||||
|
"""按人脸框裁剪后再分割,结果映射回原图尺寸(裁剪外填背景 0)。
|
||||||
|
|
||||||
|
BiSeNet 在 CelebAMask-HQ「紧裁对齐人脸」上训练,整张大场景图(全身/街拍,
|
||||||
|
脸只占一小块、背景复杂)会严重欠分割、丢耳朵。先按人脸放大裁剪,让脸接近
|
||||||
|
训练分布,耳朵/头发分割明显更稳。裁剪含足够上/侧边距以纳入发顶与双耳。
|
||||||
|
"""
|
||||||
|
h, w = image_bgr.shape[:2]
|
||||||
|
x0, y0, x1, y1 = face_box
|
||||||
|
fw, fh = max(1.0, x1 - x0), max(1.0, y1 - y0)
|
||||||
|
cx0 = int(max(0, x0 - fw * 0.8)); cx1 = int(min(w, x1 + fw * 0.8))
|
||||||
|
cy0 = int(max(0, y0 - fh * 1.0)); cy1 = int(min(h, y1 + fh * 0.5))
|
||||||
|
if cx1 - cx0 < 2 or cy1 - cy0 < 2:
|
||||||
|
return self._parse(image_bgr)
|
||||||
|
full = np.zeros((h, w), dtype=np.int32)
|
||||||
|
full[cy0:cy1, cx0:cx1] = self._parse(image_bgr[cy0:cy1, cx0:cx1])
|
||||||
|
return full
|
||||||
|
|
||||||
|
def segment_hair_and_ears(self, image_bgr, face_box=None):
|
||||||
|
"""单次推理返回 (hair_mask, ear_mask),均为 H×W bool,尺寸同原图。
|
||||||
|
|
||||||
|
ear_mask = 左耳(7) ∪ 右耳(8);耳朵被头发/侧脸遮挡时对应区域天然为空,
|
||||||
|
正好用于「看不到耳朵就不画线」的判定。两类合并、左右按图像位置另判,
|
||||||
|
不依赖以人为参照的类名(详见 EAR_CLASSES 注释)。
|
||||||
|
|
||||||
|
face_box=(x0,y0,x1,y1)(人脸关键点包围盒像素坐标)给定时先按人脸裁剪再
|
||||||
|
分割(见 _parse_face_cropped),整张大场景图也能稳定分出耳朵;不给则整图分割。
|
||||||
|
"""
|
||||||
|
if face_box is None:
|
||||||
|
parsing = self._parse(image_bgr)
|
||||||
|
else:
|
||||||
|
parsing = self._parse_face_cropped(image_bgr, face_box)
|
||||||
|
hair_mask = parsing == HAIR_CLASS
|
||||||
|
ear_mask = np.isin(parsing, EAR_CLASSES)
|
||||||
|
return hair_mask, ear_mask
|
||||||
|
|
||||||
|
|
||||||
_segmenter = None
|
_segmenter = None
|
||||||
|
|||||||
+3
-10
@@ -218,7 +218,7 @@ def _proxy(request: Request, path: str):
|
|||||||
|
|
||||||
# 声明各接口的 form 参数用于 OpenAPI schema(实际转发直接读 Request)
|
# 声明各接口的 form 参数用于 OpenAPI schema(实际转发直接读 Request)
|
||||||
_MEASURE_FORMS = {
|
_MEASURE_FORMS = {
|
||||||
"image_file": {"type": "file", "description": "上传图片文件(JPG/PNG,≤ 1 MB)"},
|
"image_file": {"type": "file", "description": "上传图片文件(JPG/PNG)"},
|
||||||
"image_url": {"type": "string", "description": "图片 URL"},
|
"image_url": {"type": "string", "description": "图片 URL"},
|
||||||
"image_base64": {"type": "string", "description": "图片 base64(需带前缀)"},
|
"image_base64": {"type": "string", "description": "图片 base64(需带前缀)"},
|
||||||
}
|
}
|
||||||
@@ -261,7 +261,7 @@ async def hair_grow_b(request: Request):
|
|||||||
|
|
||||||
@app.post("/api/v1/face/features", tags=["人脸分析"])
|
@app.post("/api/v1/face/features", tags=["人脸分析"])
|
||||||
async def face_features(
|
async def face_features(
|
||||||
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG,≤ 1 MB)"),
|
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG)"),
|
||||||
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
||||||
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带前缀)"),
|
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带前缀)"),
|
||||||
):
|
):
|
||||||
@@ -278,14 +278,7 @@ async def face_features(
|
|||||||
|
|
||||||
img_bytes = None
|
img_bytes = None
|
||||||
if image_file:
|
if image_file:
|
||||||
raw = await image_file.read()
|
img_bytes = await image_file.read()
|
||||||
# TODO: 临时取消限制,后续恢复
|
|
||||||
# if len(raw) > 1_000_000:
|
|
||||||
# return JSONResponse(status_code=200, content={
|
|
||||||
# "code": 1006, "message": "文件超出 1 MB 限制",
|
|
||||||
# "request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
|
|
||||||
# })
|
|
||||||
img_bytes = raw
|
|
||||||
elif image_base64:
|
elif image_base64:
|
||||||
b64 = image_base64
|
b64 = image_base64
|
||||||
if "," in b64:
|
if "," in b64:
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ User=xsl
|
|||||||
WorkingDirectory=/home/xsl/hair
|
WorkingDirectory=/home/xsl/hair
|
||||||
# 鉴权密码:优先 worker_config.json;也可在此用环境变量覆盖
|
# 鉴权密码:优先 worker_config.json;也可在此用环境变量覆盖
|
||||||
# Environment=WORKER_ACCEPT_PASSWORDS=your-strong-secret
|
# Environment=WORKER_ACCEPT_PASSWORDS=your-strong-secret
|
||||||
# 分辨率门槛(可选,默认 600/800)
|
|
||||||
# Environment=MIN_SHORT_SIDE=600
|
|
||||||
# Environment=MIN_LONG_SIDE=800
|
|
||||||
ExecStart=/home/xsl/hair/venv/bin/uvicorn app:app --host 0.0.0.0 --port 8187
|
ExecStart=/home/xsl/hair/venv/bin/uvicorn app:app --host 0.0.0.0 --port 8187
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=3
|
RestartSec=3
|
||||||
|
|||||||
@@ -18,13 +18,6 @@ LOG_FILE="worker.log"
|
|||||||
HOST="${HOST:-0.0.0.0}"
|
HOST="${HOST:-0.0.0.0}"
|
||||||
PORT="${PORT:-8187}"
|
PORT="${PORT:-8187}"
|
||||||
|
|
||||||
# ---- 临时取消上传图片限制(分辨率 + 文件大小)----
|
|
||||||
# 0 = 不限制。要恢复默认(600/800/1MB),删掉下面三行即可。
|
|
||||||
export MIN_SHORT_SIDE="${MIN_SHORT_SIDE:-0}"
|
|
||||||
export MIN_LONG_SIDE="${MIN_LONG_SIDE:-0}"
|
|
||||||
export MAX_FILE_BYTES="${MAX_FILE_BYTES:-0}"
|
|
||||||
# ------------------------------------------------
|
|
||||||
|
|
||||||
if [ ! -x "$VENV_UVICORN" ]; then
|
if [ ! -x "$VENV_UVICORN" ]; then
|
||||||
echo "找不到 $VENV_UVICORN,请先创建 venv 并安装依赖(见 requirements.txt)" >&2
|
echo "找不到 $VENV_UVICORN,请先创建 venv 并安装依赖(见 requirements.txt)" >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -64,11 +64,3 @@ def build_synthetic_landmarks(px_per_cm=50.0, W=1000, H=1000):
|
|||||||
lm[474] = _LM(X(cx + ie / 2 + ew / 2 - d / 2), eye_y)
|
lm[474] = _LM(X(cx + ie / 2 + ew / 2 - d / 2), eye_y)
|
||||||
lm[476] = _LM(X(cx + ie / 2 + ew / 2 + d / 2), eye_y)
|
lm[476] = _LM(X(cx + ie / 2 + ew / 2 + d / 2), eye_y)
|
||||||
return [lm[i] for i in range(478)], gt
|
return [lm[i] for i in range(478)], gt
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def oversize_file(tmp_path):
|
|
||||||
"""1006 用例:>1MB 的占位文件(无需合法图片,只看字节数)。"""
|
|
||||||
p = tmp_path / "oversize.bin"
|
|
||||||
p.write_bytes(b"\x00" * 1_100_000)
|
|
||||||
return p
|
|
||||||
|
|||||||
@@ -52,17 +52,6 @@ def test_param_multiple_provided_1007(client):
|
|||||||
assert r.json()["code"] == 1007
|
assert r.json()["code"] == 1007
|
||||||
|
|
||||||
|
|
||||||
def test_oversize_1006(client, oversize_file):
|
|
||||||
files = {"image_file": ("oversize.bin", open(oversize_file, "rb"), "application/octet-stream")}
|
|
||||||
r = client.post(URL, headers=H, files=files)
|
|
||||||
assert r.json()["code"] == 1006
|
|
||||||
|
|
||||||
|
|
||||||
def test_lowres_1002(client):
|
|
||||||
r = _post(client, "lowres.png")
|
|
||||||
assert r.json()["code"] == 1002
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_face_1001(client):
|
def test_no_face_1001(client):
|
||||||
r = _post(client, "landscape.jpg")
|
r = _post(client, "landscape.jpg")
|
||||||
assert r.json()["code"] == 1001
|
assert r.json()["code"] == 1001
|
||||||
|
|||||||
Reference in New Issue
Block a user