From 554b64a9161d71d1a5c7bffc5e83ce30ef9717db Mon Sep 17 00:00:00 2001 From: xsl Date: Sun, 14 Jun 2026 20:31:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=8E=A5=E5=8F=A32):=20C=E7=AB=AF?= =?UTF-8?q?=E7=94=9F=E5=8F=91=E5=8F=91=E9=99=85=E7=BA=BF=E9=A2=84=E8=A7=88?= =?UTF-8?q?(=E7=9C=9F=E5=AE=9E=E5=AE=9E=E7=8E=B0=EF=BC=8C=E6=9B=BF?= =?UTF-8?q?=E6=8D=A2Mock)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第一步:按性别把发际线类型贴图渲染到照片,输出 N 张发际线叠加预览图。 - hairline/render.py: 解析 face_ext.obj(502 UV + 64 ribbon扩展面) + OpenCV 逐三角 仿射 warp 渲染器;关键修复——face_ext.obj 是 OBJ序,用 INDEX_MAP_468 把 MP序 502点重排成 OBJ序后再投影,否则 ribbon 会错贴到中脸 - hairline/service.py: FaceLandmarker+SegFormer 单例 + 性别贴图映射(扫描去空格) + generate_previews 管线(female5/male4) - 集成点修复: face_landmarks DEFAULT_MODEL_PATH 改 hairline/models/; constants HF_FACE_PARSER_MODEL 改本地路径(离线) - app.py: /api/v1/hair/grow 接真实实现,gender 必填(非法→1004),返回 results[].image_base64(不落盘),校验/鉴权同接口1;lifespan 预热接口2单例; 补 logging.basicConfig - 依赖: transformers==4.45.2;SegFormer 权重走 hf-mirror 下载(见 OFFLINE_ASSETS) - 测试: tests/test_hairline.py(mesh/重排/贴图映射) + test_api 接口2用例,31 全绿 注:SegFormer 受 5090/torch 限制走 CPU(~2.5s/张),换 cu128 可 SEG_DEVICE=cuda。 Co-Authored-By: Claude Opus 4.8 --- OFFLINE_ASSETS.md | 10 ++- app.py | 79 +++++++++++++++++++----- hairline/constants.py | 7 ++- hairline/face_landmarks.py | 3 +- hairline/render.py | 107 ++++++++++++++++++++++++++++++++ hairline/service.py | 123 +++++++++++++++++++++++++++++++++++++ requirements.txt | 4 ++ tests/test_api.py | 21 +++++++ tests/test_hairline.py | 31 ++++++++++ 9 files changed, 365 insertions(+), 20 deletions(-) create mode 100644 hairline/render.py create mode 100644 hairline/service.py create mode 100644 tests/test_hairline.py diff --git a/OFFLINE_ASSETS.md b/OFFLINE_ASSETS.md index d5ba364..6009544 100644 --- a/OFFLINE_ASSETS.md +++ b/OFFLINE_ASSETS.md @@ -19,10 +19,14 @@ |------|------|------| | model.safetensors | `hairline/models/face-parsing/model.safetensors` | 338,580,732 B (~323MB) | -下载命令: +下载命令(国内用 hf-mirror 镜像,快很多): ```bash +# 国内镜像(推荐) curl -L -o hairline/models/face-parsing/model.safetensors \ - "https://huggingface.co/jonathandinu/face-parsing/resolve/main/model.safetensors" + "https://hf-mirror.com/jonathandinu/face-parsing/resolve/main/model.safetensors" +# 官方源 +# curl -L -o hairline/models/face-parsing/model.safetensors \ +# "https://huggingface.co/jonathandinu/face-parsing/resolve/main/model.safetensors" ``` sha256 校验: @@ -77,7 +81,7 @@ model.safetensors https://huggingface.co/jonathandinu/face-parsing/resol 模型已就位,但**内网机还需要 Python 依赖的离线 wheel 包**,否则 `pip install` 在内网无法联网安装。这部分**与目标机的操作系统、Python 版本、CUDA 版本强相关**,需确认后单独打包: -- **worker(GPU 机)**:`mediapipe` / `opencv-python` / `numpy<2` / `Pillow` / **`torch`+`torchvision` 的 CUDA 版**(按 GPU 的 CUDA 版本选 cu118/cu121 等)+ FastAPI/uvicorn 全家桶。 +- **worker(GPU 机)**:`mediapipe` / `opencv-python` / `numpy<2` / `Pillow` / **`torch`+`torchvision` 的 CUDA 版**(按 GPU 的 CUDA 版本选 cu118/cu121 等)/ `transformers`(接口2 SegFormer)+ FastAPI/uvicorn 全家桶。 - **网关机**:很轻,只需 FastAPI/uvicorn/httpx 等代理依赖,**不需要 torch/mediapipe**。 > 架构已拆分(见 `docs/系统架构-网关与高性能后端.md`):算法依赖只装在 worker,网关保持轻量。 diff --git a/app.py b/app.py index 2f52c95..1aab43b 100644 --- a/app.py +++ b/app.py @@ -19,6 +19,8 @@ from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field +logging.basicConfig(level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s") logger = logging.getLogger("hair.worker") # --------------------------------------------------------------------------- @@ -68,6 +70,14 @@ async def lifespan(_app: FastAPI): except Exception as e: # noqa: BLE001 # 方案 B 不可用(如 torch 缺失):降级为方案 A only,不阻塞服务 logger.warning("头发分割不可用,接口1 将走方案A兜底:%s", e) + # 接口2(C端生发)单例预热:FaceLandmarker + SegFormer + mesh/贴图映射 + try: + from hairline.service import get_landmarker, get_parser, get_texture_map + from hairline.render import load_ext_mesh + get_landmarker(); get_parser(); load_ext_mesh(); get_texture_map() + logger.info("接口2 发际线管线就绪") + except Exception as e: # noqa: BLE001 + logger.warning("接口2 发际线管线初始化失败(该接口将返回错误):%s", e) _STATE["ready"] = True yield @@ -415,10 +425,11 @@ async def face_measure( summary="接口2 C端生发", tags=["生发"], description=f""" -输入用户正面照,返回多个生发方案,每个方案包含: -- 生发后效果图 URL -- 对应的发际线形(如花瓣形、波浪形) -- 合适度排序(order=1 最优) +输入用户正面照 + **性别**,返回该性别对应的多张「建议发际线预览图」(本期为 +**发际线曲线叠加在原照片上的预览图**,非最终文生图生发图)。每个方案包含: +- 预览图(worker 返回 `image_base64`,网关落盘后改写为 `image_url`) +- 发际线类型 `hairline_type`(英文 key) +- 顺序 `order`(本期固定 `1..N`,不排序) {_image_fields_desc} @@ -426,7 +437,12 @@ async def face_measure( --- -**beauty_enabled**:是否对生发后图片开启美颜,默认 `false`。 +- **gender**(必填):`male` / `female`。决定返回的贴图集合(female 5 张 / male 4 张)。 + 非法或缺失返回 `1004`。 +- **beauty_enabled**:本期保留但不生效。 + +`hairline_type` 取值:`ellipse` / `flower` / `heart` / `straight` / `wave`(female), +`ellipse` / `m` / `straight` / `inverse_arc`(male)。 """, responses={ 200: { @@ -439,8 +455,8 @@ async def face_measure( "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}, + {"image_base64": "iVBORw0KGgo...", "hairline_type": "ellipse", "order": 1}, + {"image_base64": "iVBORw0KGgo...", "hairline_type": "flower", "order": 2}, ] }, } @@ -464,15 +480,48 @@ 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"), + gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"), + beauty_enabled: bool = Form(default=False, description="是否开启美颜(本期不生效)"), ): - data = { - "results": [ - {"image_url": SAMPLE_IMAGE_URL, "hairline_type": "花瓣形", "order": 1}, - {"image_url": SAMPLE_IMAGE_URL, "hairline_type": "波浪形", "order": 2}, - ] - } - return ok(data) + # 1. gender 必填校验(非法/缺失 → 1004) + if gender not in ("male", "female"): + return err(1004, "gender 必填且只能为 male / female") + + # 2. 三选一取图 + raw, e = await resolve_image_bytes(image_file, image_url, image_base64) + if e is not None: + return e + if len(raw) > MAX_FILE_BYTES: + return err(1006, "文件超出 1 MB 限制") + + image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR) + if image is None: + 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: + from hairline.service import generate_previews + + previews = generate_previews(image, gender) # 无人脸 → None + if previews is None: + return err(1001, "无法识别人像") + + results = [] + for p in previews: + ok_enc, png = cv2.imencode(".png", p["image_bgr"]) + results.append({ + "image_base64": base64.b64encode(png.tobytes()).decode(), + "hairline_type": p["hairline_type"], + "order": p["order"], + }) + return ok({"results": results}) + except Exception as ex: # noqa: BLE001 + logger.exception("接口2 处理异常") + return err(1007, f"处理失败:{ex}") # --------------------------------------------------------------------------- diff --git a/hairline/constants.py b/hairline/constants.py index ecafb05..196ac4f 100644 --- a/hairline/constants.py +++ b/hairline/constants.py @@ -162,4 +162,9 @@ PARSE_NECK_L = 16 PARSE_NECK = 17 PARSE_CLOTH = 18 -HF_FACE_PARSER_MODEL = "jonathandinu/face-parsing" +# 内网/离线:指向本地权重目录(transformers from_pretrained 支持本地路径)。 +# 在线 id 为 "jonathandinu/face-parsing",权重已放到 hairline/models/face-parsing/。 +import os as _os +HF_FACE_PARSER_MODEL = _os.path.join( + _os.path.dirname(_os.path.abspath(__file__)), "models", "face-parsing" +) diff --git a/hairline/face_landmarks.py b/hairline/face_landmarks.py index 77fe3b7..04300e0 100644 --- a/hairline/face_landmarks.py +++ b/hairline/face_landmarks.py @@ -11,8 +11,9 @@ from __future__ import annotations import os import numpy as np +# 模型在 hairline/models/ 下(模块即在 hairline/ 根),故只取一层 dirname。 DEFAULT_MODEL_PATH = os.path.join( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + os.path.dirname(os.path.abspath(__file__)), "models", "face_landmarker.task", ) diff --git a/hairline/render.py b/hairline/render.py new file mode 100644 index 0000000..5ac8d7a --- /dev/null +++ b/hairline/render.py @@ -0,0 +1,107 @@ +"""接口2 渲染器:把发际线类型贴图按 502 点 mesh 贴到照片上(OpenCV 逐三角仿射 warp)。 + +原理(技术方案 §4):face_ext.obj 的 502 顶点里,[468..485) 中间行 + [485..502) 发际线行 +与 17 个 MP 顶部锚点连成 ~64 个 ribbon 三角形,其 UV 落在贴图顶部条带(发际线曲线所在)。 +只 warp 这些扩展三角形,即可把贴图里的发际线曲线贴到额头/发际线区域,且天然只画在 ribbon 区。 +""" +from __future__ import annotations +import os + +import cv2 +import numpy as np +from PIL import Image + +from .obj_io import read_obj +from ._index_map_data import INDEX_MAP_468 + +_MESH_PATH = os.path.join(os.path.dirname(__file__), "mesh", "face_ext.obj") +_N_MP = 468 + +_mesh_cache = None +_INDEX_MAP = np.asarray(INDEX_MAP_468, dtype=np.int64) # OBJ顶点i → MP点索引 + + +def mp_order_to_obj_order(points502_mp: np.ndarray) -> np.ndarray: + """把 MP 顺序的 502 点重排成 face_ext.obj 的顶点顺序。 + + extract_hairline 输出为 MP 顺序:[0..468) MP / [468..485) middle / [485..502) hairline。 + 而 face_ext.obj 的前 468 顶点经 INDEX_MAP_468 重排(obj_i → mp_i);扩展顶点 + [468..502) 两侧同序,直接对应。 + """ + out = np.empty_like(points502_mp) + out[:_N_MP] = points502_mp[_INDEX_MAP] # obj[0..468) = mp[INDEX_MAP] + out[_N_MP:] = points502_mp[_N_MP:] # 扩展行同序 + return out + + +def load_ext_mesh(obj_path: str = _MESH_PATH): + """解析 face_ext.obj,返回 (uv502, ext_faces)。结果缓存。 + + - uv502: (502, 2) float32,每个顶点的 UV(V_raw,V=1 对应贴图顶部)。 + - ext_faces: list[(i,j,k)],仅保留顶点索引含 ≥468 的扩展三角形(ribbon)。 + obj 中 v 与 vt 一一对应(face 用相同索引),故按位置索引取 UV。 + """ + global _mesh_cache + if _mesh_cache is not None: + return _mesh_cache + + mesh = read_obj(obj_path) + n_v = len(mesh.positions) + uv = np.zeros((n_v, 2), dtype=np.float32) + for face in mesh.faces: + for pi, ti, _ni in face: + if ti >= 0 and pi >= 0: + uv[pi] = mesh.texcoords[ti] + + ext_faces = [] + for face in mesh.faces: + idx = [pi for (pi, _t, _n) in face] + if max(idx) >= _N_MP: # 含扩展顶点 → ribbon 三角形 + ext_faces.append(tuple(idx)) + + _mesh_cache = (uv, ext_faces) + return _mesh_cache + + +def load_texture_rgba(path: str) -> np.ndarray: + """读发际线贴图为 (H, W, 4) uint8 RGBA。""" + return np.array(Image.open(path).convert("RGBA")) + + +def render_hairline_overlay(photo_bgr: np.ndarray, + points502_norm: np.ndarray, + ext_faces, + uv502: np.ndarray, + texture_rgba: np.ndarray) -> np.ndarray: + """把 texture_rgba 的发际线曲线渲染到 photo_bgr 上,返回 BGR 预览图。 + + points502_norm: (502, 3) 归一化坐标(x,y ∈ [0,1]),**MP 顺序**(extract_hairline 输出)。 + """ + H, W = photo_bgr.shape[:2] + TH, TW = texture_rgba.shape[:2] + pts_obj = mp_order_to_obj_order(points502_norm) # MP序 → OBJ序 + img_xy = pts_obj[:, :2] * np.array([W, H], dtype=np.float32) # (502,2) + + overlay = np.zeros((H, W, 4), np.float32) # 累积曲线层 RGBA + tex = texture_rgba.astype(np.float32) + for (i, j, k) in ext_faces: + dst = img_xy[[i, j, k]].astype(np.float32) + # UV → 贴图像素;flipY:贴图 y = (1 - v_raw) * TH(与 head3d Three.js flipY=true 一致) + src = np.array([[uv502[v][0] * TW, (1.0 - uv502[v][1]) * TH] for v in (i, j, k)], + dtype=np.float32) + # 退化三角形(投影到一条线)跳过,避免 getAffineTransform 奇异 + if cv2.contourArea(dst.astype(np.int32)) < 1.0: + continue + M = cv2.getAffineTransform(src, dst) + warped = cv2.warpAffine(tex, M, (W, H), flags=cv2.INTER_LINEAR, + borderMode=cv2.BORDER_CONSTANT, borderValue=(0, 0, 0, 0)) + tri_mask = np.zeros((H, W), np.uint8) + cv2.fillConvexPoly(tri_mask, dst.astype(np.int32), 255) + sel = tri_mask > 0 + overlay[sel] = warped[sel] + + # alpha 合成(RGBA→BGR:贴图 RGB 顺序需反成 BGR) + a = overlay[:, :, 3:4] / 255.0 + rgb = overlay[:, :, :3][..., ::-1] # RGB→BGR + out = photo_bgr.astype(np.float32) * (1.0 - a) + rgb * a + return np.clip(out, 0, 255).astype(np.uint8) diff --git a/hairline/service.py b/hairline/service.py new file mode 100644 index 0000000..b66c6a5 --- /dev/null +++ b/hairline/service.py @@ -0,0 +1,123 @@ +"""接口2 服务层:模型单例 + 性别贴图映射 + 「照片→N 张发际线预览图」管线。 + +把 head3d 的 extract_hairline 步骤包成单例复用(避免每请求重建模型),再按性别 +对每张贴图调 render.render_hairline_overlay 生成预览图。 +""" +from __future__ import annotations +import glob +import os + +import cv2 +import numpy as np + +from . import constants as C +from .face_landmarks import FaceLandmarker +from .face_parsing import FaceParser +from .hairline_2d import sample_hairline, smooth_hairline +from .lift_3d import lift_hairline_to_3d, build_middle_row, assemble_full +from .render import load_ext_mesh, load_texture_rgba, render_hairline_overlay + +_TEXTURE_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "hairline_texture") + +# ⚠️ 本 worker 是 RTX 5090(sm_120),torch 2.2.2(cu121) 只编到 sm_90,CUDA 跑算子会报 +# "no kernel image"。SegFormer 默认走 CPU(~2.5s/张)。换 torch cu128 后可设 SEG_DEVICE=cuda。 +_SEG_DEVICE = os.getenv("SEG_DEVICE", "cpu") + +_landmarker = None +_parser = None +_texture_map = None + + +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: + _parser = FaceParser(device=_SEG_DEVICE) + return _parser + + +def _gender_key(stem: str): + """文件名 stem → (gender, key);非 girl_/man_ 前缀返回 (None, None)。""" + if stem.startswith("girl_"): + return "female", stem[5:].replace(" ", "").strip() + if stem.startswith("man_"): + return "male", stem[4:].replace(" ", "").strip() + return None, None + + +def get_texture_map() -> dict: + """扫描 hairline_texture/ 建 {gender: [(key, path)]},按 key 排序、缓存。 + + 文件名规范化去空格(如 `man_ inverse_arc.png` → key `inverse_arc`)。 + """ + global _texture_map + if _texture_map is not None: + return _texture_map + mapping: dict[str, list] = {"female": [], "male": []} + for path in sorted(glob.glob(os.path.join(_TEXTURE_DIR, "*.png"))): + stem = os.path.splitext(os.path.basename(path))[0] + gender, key = _gender_key(stem) + if gender: + mapping[gender].append((key, path)) + for g in mapping: + mapping[g].sort(key=lambda kp: kp[0]) + _texture_map = mapping + return _texture_map + + +def extract_502(image_bgr: np.ndarray): + """照片(BGR) → (points502 MP序, valid17)。无人脸返回 (None, None)。""" + rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB) + landmarks = get_landmarker().detect(rgb) + if landmarks is None: + return None, None + parse_map = get_parser().parse(rgb) + hairline_2d, valid = sample_hairline(landmarks, parse_map) + hairline_2d = smooth_hairline(hairline_2d, valid) + hairline_3d = lift_hairline_to_3d(landmarks, hairline_2d) + middle_3d = build_middle_row(landmarks, hairline_3d) + points = assemble_full(landmarks, middle_3d, hairline_3d) + return points, valid + + +def generate_previews(image_bgr: np.ndarray, gender: str): + """生成该性别全部发际线预览图。 + + Returns: list[dict],每项 {"hairline_type": key, "image_bgr": ndarray, "order": 1..N}。 + 无人脸返回 None。gender 必须是 male/female。 + """ + if gender not in ("male", "female"): + raise ValueError(f"gender 必须是 male/female,收到 {gender!r}") + points, _valid = extract_502(image_bgr) + if points is None: + return None + + uv, ext_faces = load_ext_mesh() + results = [] + for order, (key, path) in enumerate(get_texture_map()[gender], start=1): + tex = load_texture_rgba(path) + preview = render_hairline_overlay(image_bgr, points, ext_faces, uv, tex) + results.append({"hairline_type": key, "image_bgr": preview, "order": order}) + return results + + +if __name__ == "__main__": + import sys + g = sys.argv[2] if len(sys.argv) > 2 else "female" + img = cv2.imread(sys.argv[1] if len(sys.argv) > 1 else "tests/fixtures/frontal.jpg") + os.makedirs("tests/output", exist_ok=True) + print("texture map:", {k: [kp[0] for kp in v] for k, v in get_texture_map().items()}) + res = generate_previews(img, g) + if res is None: + print("无人脸") + sys.exit(1) + for r in res: + out = f"tests/output/preview_{g}_{r['hairline_type']}.png" + cv2.imwrite(out, r["image_bgr"]) + print(f" order={r['order']} type={r['hairline_type']} -> {out}") diff --git a/requirements.txt b/requirements.txt index 808a996..0e9ce3c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,5 +18,9 @@ numpy==1.26.4 # 必须 <2,否则 mediapipe 0.10.x import 崩溃 torch==2.2.2 # 当前在 5090 上仅 CPU 可用;GPU 需 cu128(≥2.7) torchvision==0.17.2 +# 接口2:C端生发(发际线预览) +# MediaPipe Tasks(FaceLandmarker) 用已装的 mediapipe;新增 SegFormer 人脸分割: +transformers==4.45.2 # SegFormer 人脸分割(jonathandinu/face-parsing,本地权重) + # 测试 pytest==8.3.3 diff --git a/tests/test_api.py b/tests/test_api.py index 4989bf0..f5638cb 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -72,6 +72,27 @@ def test_corrupt_1008(client): assert r.json()["code"] == 1008 +GROW = "/api/v1/hair/grow" + + +def test_grow_missing_gender_1004(client): + files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")} + r = client.post(GROW, headers=H, files=files) + assert r.json()["code"] == 1004 + + +def test_grow_female_returns_5(client): + files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")} + r = client.post(GROW, headers=H, files=files, data={"gender": "female"}) + body = r.json() + assert body["code"] == 0, body + results = body["data"]["results"] + assert [x["hairline_type"] for x in results] == ["ellipse", "flower", "heart", "straight", "wave"] + assert [x["order"] for x in results] == [1, 2, 3, 4, 5] + assert base64.b64decode(results[0]["image_base64"])[:8] == b"\x89PNG\r\n\x1a\n" + assert "image_url" not in results[0] + + def test_success_structure(client): r = _post(client, "frontal.jpg") body = r.json() diff --git a/tests/test_hairline.py b/tests/test_hairline.py new file mode 100644 index 0000000..0be9679 --- /dev/null +++ b/tests/test_hairline.py @@ -0,0 +1,31 @@ +"""接口2 单元测试:mesh 解析 / MP→OBJ 重排 / 性别贴图映射(不需 SegFormer)。""" +import numpy as np + +from hairline.render import load_ext_mesh, mp_order_to_obj_order +from hairline.service import get_texture_map +from hairline import constants as C + + +def test_ext_mesh(): + uv, ext_faces = load_ext_mesh() + assert uv.shape == (502, 2) + assert len(ext_faces) == 64 # ribbon 扩展三角形 + # 扩展面至少含一个 ≥468 的顶点 + assert all(max(f) >= 468 for f in ext_faces) + + +def test_mp_to_obj_anchor_mapping(): + """OBJ 序重排后,ribbon 引用的 obj 锚点应映射回 MP_TOP_ANCHORS。""" + pts = np.zeros((502, 3), np.float32) + pts[:468, 0] = np.arange(468) # 用 x 编码 MP 索引 + obj = mp_order_to_obj_order(pts) + _uv, ext_faces = load_ext_mesh() + obj_anchor_ids = sorted({pi for f in ext_faces for pi in f if pi < 468}) + mapped = sorted(int(obj[i, 0]) for i in obj_anchor_ids) # 还原成 MP 索引 + assert mapped == sorted(C.MP_TOP_ANCHORS) + + +def test_texture_map(): + m = get_texture_map() + assert [k for k, _ in m["female"]] == ["ellipse", "flower", "heart", "straight", "wave"] + assert [k for k, _ in m["male"]] == ["ellipse", "inverse_arc", "m", "straight"]