- 从head3d复制发际线检测管线到 hairline/ 包:MediaPipe Tasks + SegFormer分割 + 17锚点射线检测 + 502点mesh(face_ext.obj)+UV - 复制模型:face_landmarker.task(3.7MB)、SegFormer config/preprocessor (model.safetensors 340MB 单独下载中) - 新增 docs/接口2-C端生发-技术实现方案.md:第一步=发际线曲线叠加预览图, 新增gender必填参数,按性别贴图数量输出(female5/male4),hairline_type英文key, 服务端cv2逐三角形warp渲染器(head3d只有浏览器端Three.js渲染) - 接口文档.md 接口2章节同步:gender参数、输出语义、错误码说明 - hairline_texture/ 9张发际线贴图入库
98 lines
3.8 KiB
Python
98 lines
3.8 KiB
Python
"""Minimal Wavefront OBJ reader/writer.
|
|
|
|
Tailored to the project's face.obj:
|
|
- Latin-1 / GB-encoded comments allowed (we tolerate undecodable bytes).
|
|
- Vertices written with 4 decimal places (matches existing file).
|
|
- Preserves comment/mtllib/group lines if requested.
|
|
"""
|
|
from __future__ import annotations
|
|
from dataclasses import dataclass, field
|
|
from typing import Sequence
|
|
|
|
|
|
@dataclass
|
|
class ObjMesh:
|
|
"""1-indexed in OBJ files; stored 0-indexed internally."""
|
|
positions: list[tuple[float, float, float]] = field(default_factory=list)
|
|
texcoords: list[tuple[float, float]] = field(default_factory=list)
|
|
normals: list[tuple[float, float, float]] = field(default_factory=list)
|
|
# Each face is a list of 3 (pos_idx, uv_idx, normal_idx) tuples, 0-indexed.
|
|
# -1 means "absent".
|
|
faces: list[list[tuple[int, int, int]]] = field(default_factory=list)
|
|
header_lines: list[str] = field(default_factory=list) # comments / mtllib
|
|
|
|
def n_v(self) -> int: return len(self.positions)
|
|
def n_vt(self) -> int: return len(self.texcoords)
|
|
def n_vn(self) -> int: return len(self.normals)
|
|
def n_f(self) -> int: return len(self.faces)
|
|
|
|
|
|
def read_obj(path: str) -> ObjMesh:
|
|
"""Read an OBJ file, ignoring undecodable bytes in comments."""
|
|
with open(path, "rb") as f:
|
|
raw = f.read()
|
|
text = raw.decode("latin-1", errors="replace")
|
|
mesh = ObjMesh()
|
|
|
|
for line in text.splitlines():
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
if line.startswith("#") or line.startswith("mtllib") or line.startswith("o ") or line.startswith("g ") or line.startswith("s "):
|
|
mesh.header_lines.append(line)
|
|
continue
|
|
parts = line.split()
|
|
kind = parts[0]
|
|
if kind == "v":
|
|
mesh.positions.append((float(parts[1]), float(parts[2]), float(parts[3])))
|
|
elif kind == "vt":
|
|
mesh.texcoords.append((float(parts[1]), float(parts[2])))
|
|
elif kind == "vn":
|
|
mesh.normals.append((float(parts[1]), float(parts[2]), float(parts[3])))
|
|
elif kind == "f":
|
|
verts = []
|
|
for spec in parts[1:]:
|
|
seg = spec.split("/")
|
|
pi = int(seg[0]) - 1 if seg[0] else -1
|
|
ti = int(seg[1]) - 1 if len(seg) > 1 and seg[1] else -1
|
|
ni = int(seg[2]) - 1 if len(seg) > 2 and seg[2] else -1
|
|
verts.append((pi, ti, ni))
|
|
# Triangulate fan if quad/n-gon (shouldn't happen for our mesh)
|
|
for k in range(1, len(verts) - 1):
|
|
mesh.faces.append([verts[0], verts[k], verts[k + 1]])
|
|
# ignore everything else
|
|
return mesh
|
|
|
|
|
|
def write_obj(path: str, mesh: ObjMesh, header: Sequence[str] | None = None) -> None:
|
|
"""Write an OBJ file with the project's conventions (1-indexed, 4dp)."""
|
|
out: list[str] = []
|
|
if header is not None:
|
|
out.extend(header)
|
|
else:
|
|
out.append("# head3d extended face mesh")
|
|
out.append(f"# vertices={mesh.n_v()} texcoords={mesh.n_vt()} normals={mesh.n_vn()} faces={mesh.n_f()}")
|
|
|
|
out.append("")
|
|
for x, y, z in mesh.positions:
|
|
out.append(f"v {x:.4f} {y:.4f} {z:.4f}")
|
|
out.append("")
|
|
for u, v in mesh.texcoords:
|
|
out.append(f"vt {u:.4f} {v:.4f} 0.0000")
|
|
out.append("")
|
|
for nx, ny, nz in mesh.normals:
|
|
out.append(f"vn {nx:.4f} {ny:.4f} {nz:.4f}")
|
|
out.append("")
|
|
for face in mesh.faces:
|
|
toks = []
|
|
for pi, ti, ni in face:
|
|
a = str(pi + 1)
|
|
b = str(ti + 1) if ti >= 0 else ""
|
|
c = str(ni + 1) if ni >= 0 else ""
|
|
toks.append(f"{a}/{b}/{c}")
|
|
out.append("f " + " ".join(toks))
|
|
|
|
with open(path, "w", encoding="utf-8", newline="\r\n") as f:
|
|
f.write("\n".join(out))
|
|
f.write("\n")
|