""" 解析 face_picture_3dmax.obj,得到顶点 UV 和三角形索引。 OBJ 约定:v 顶点 / vt UV / vn 法线,f 三元组 v/vt/vn 索引(1-based)。 本服务只需要每个顶点的 UV 和三角形顶点索引——3D 顶点位置在 SDK 里也只是 占位(运行时被 MediaPipe landmark 覆盖),这里同样无需读 v。 ★ 与 vulkan/FaceApp.cpp::LoadOBJ 一致,UV 的 V 分量取 (1 - v),把 OBJ 的 bottom-up 约定翻成 top-down,与图像(OpenCV/Pillow)一致。 """ from dataclasses import dataclass from pathlib import Path import numpy as np @dataclass class FaceMesh: # (468, 2) float32, 每个顶点的 UV,V 已翻转,∈ [0,1]² uvs: np.ndarray # (852, 3) int32, 每个三角形的 3 个顶点索引(0-based,指向 uvs 第一维) triangles: np.ndarray def load_face_mesh(obj_path: str | Path) -> FaceMesh: obj_path = Path(obj_path) text = obj_path.read_text(encoding="utf-8", errors="ignore") pos_count = 0 uv_list: list[tuple[float, float]] = [] # OBJ 的 face 用 v/vt/vn,三个索引可能不同;但本工程的 obj 三者一一对齐 # (pos_index == uv_index == normal_index),所以我们只看 v 索引来索引 UV # 列表也成立。这里仍然显式校验以防换 obj 时出错。 triangles: list[tuple[int, int, int]] = [] for raw in text.splitlines(): line = raw.strip() if not line or line.startswith("#"): continue parts = line.split() tag = parts[0] if tag == "v": pos_count += 1 elif tag == "vt": u = float(parts[1]) v = float(parts[2]) uv_list.append((u, 1.0 - v)) # V 翻转 elif tag == "f": if len(parts) != 4: raise ValueError(f"only triangle faces supported, got: {line}") tri: list[int] = [] for token in parts[1:]: # 形如 "v/vt/vn" 或 "v//vn" 或 "v" seg = token.split("/") v_idx = int(seg[0]) - 1 # OBJ 1-based → 0-based vt_idx = int(seg[1]) - 1 if len(seg) > 1 and seg[1] else v_idx if v_idx != vt_idx: raise ValueError( f"this loader assumes pos_idx == uv_idx, " f"got v={v_idx + 1} vt={vt_idx + 1} in line: {line}" ) tri.append(v_idx) triangles.append((tri[0], tri[1], tri[2])) if pos_count != len(uv_list): raise ValueError( f"vertex count {pos_count} != uv count {len(uv_list)}; " "obj loader assumes 1:1 correspondence" ) uvs = np.asarray(uv_list, dtype=np.float32) tris = np.asarray(triangles, dtype=np.int32) return FaceMesh(uvs=uvs, triangles=tris)