Files
xsl db32fa12e5 feat(接口2): 移植head3d发际线管线 + 接口2实现方案文档
- 从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张发际线贴图入库
2026-06-14 16:59:39 +08:00

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")