119 lines
4.4 KiB
Python
119 lines
4.4 KiB
Python
|
|
#!/usr/bin/env python3
|
|||
|
|
"""
|
|||
|
|
gen_face.py – Run ONCE to generate assets/face_wire.png
|
|||
|
|
─────────────────────────────────────────────────────────
|
|||
|
|
Uses matplotlib's 3-D surface renderer to produce a wireframe face image
|
|||
|
|
that display.py loads. Run it once; the PNG is then part of your project.
|
|||
|
|
|
|||
|
|
python gen_face.py
|
|||
|
|
|
|||
|
|
Dependencies (dev machine only – NOT needed on the target device):
|
|||
|
|
sudo apt install python3-matplotlib # or: pip install matplotlib
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import os
|
|||
|
|
import math
|
|||
|
|
import argparse
|
|||
|
|
import numpy as np
|
|||
|
|
import matplotlib
|
|||
|
|
matplotlib.use('Agg') # off-screen — no display needed
|
|||
|
|
import matplotlib.pyplot as plt
|
|||
|
|
from mpl_toolkits.mplot3d import Axes3D # noqa: F401
|
|||
|
|
|
|||
|
|
# ── Face width profile ────────────────────────────────────────────────────────
|
|||
|
|
# (normalised_height, width_fraction) 0 = crown, 1 = chin
|
|||
|
|
# Widest point is at the cheekbones (~t=0.37), not the crown.
|
|||
|
|
# Crown and forehead are slightly narrower; jaw/chin taper below cheeks.
|
|||
|
|
_PROFILE = [
|
|||
|
|
(0.00, 0.82), # crown
|
|||
|
|
(0.10, 0.88), # upper skull
|
|||
|
|
(0.22, 0.94), # temples / forehead
|
|||
|
|
(0.37, 1.00), # cheekbones — widest
|
|||
|
|
(0.50, 0.96), # nose level
|
|||
|
|
(0.62, 0.84), # mouth level
|
|||
|
|
(0.73, 0.68), # jaw angle
|
|||
|
|
(0.83, 0.50), # lower jaw
|
|||
|
|
(0.92, 0.30), # chin
|
|||
|
|
(1.00, 0.08), # chin tip
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _face_r(v: float) -> float:
|
|||
|
|
"""Radial width at normalised height v ∈ [0, 1]."""
|
|||
|
|
for i in range(len(_PROFILE) - 1):
|
|||
|
|
t0, w0 = _PROFILE[i]
|
|||
|
|
t1, w1 = _PROFILE[i + 1]
|
|||
|
|
if t0 <= v <= t1:
|
|||
|
|
return w0 + (v - t0) / (t1 - t0) * (w1 - w0)
|
|||
|
|
return 0.0
|
|||
|
|
|
|||
|
|
|
|||
|
|
def face_surface(nu: int = 22, nv: int = 28):
|
|||
|
|
"""
|
|||
|
|
Build the face mesh arrays.
|
|||
|
|
Axis convention chosen to match matplotlib's default camera behaviour:
|
|||
|
|
X = left / right
|
|||
|
|
Y = depth (positive = toward viewer / front of face)
|
|||
|
|
Z = up / down (+1 = crown, −1 = chin)
|
|||
|
|
To view from the front: view_init(elev, azim=90) ← camera is at +Y
|
|||
|
|
"""
|
|||
|
|
u_vals = np.linspace(-math.pi / 2, math.pi / 2, nu) # front hemisphere
|
|||
|
|
v_vals = np.linspace(0.0, 1.0, nv) # crown → chin
|
|||
|
|
|
|||
|
|
X = np.zeros((nv, nu))
|
|||
|
|
Y = np.zeros((nv, nu))
|
|||
|
|
Z = np.zeros((nv, nu))
|
|||
|
|
|
|||
|
|
for j, v in enumerate(v_vals):
|
|||
|
|
r = _face_r(v)
|
|||
|
|
z = math.cos(math.pi * v) # +1 at crown, −1 at chin
|
|||
|
|
for i, u in enumerate(u_vals):
|
|||
|
|
X[j, i] = r * math.sin(u) # left/right
|
|||
|
|
Y[j, i] = r * math.cos(u) # depth (front = max)
|
|||
|
|
Z[j, i] = z # height
|
|||
|
|
|
|||
|
|
return X, Y, Z
|
|||
|
|
|
|||
|
|
|
|||
|
|
def generate(out_path: str = 'assets/face_wire.png',
|
|||
|
|
img_w: int = 400, img_h: int = 500,
|
|||
|
|
line_col: str = '#606060') -> None:
|
|||
|
|
dpi = 100
|
|||
|
|
fig = plt.figure(figsize=(img_w / dpi, img_h / dpi), facecolor='black')
|
|||
|
|
ax = fig.add_subplot(111, projection='3d', facecolor='black')
|
|||
|
|
|
|||
|
|
X, Y, Z = face_surface(nu=18, nv=22)
|
|||
|
|
ax.plot_wireframe(X, Y, Z,
|
|||
|
|
color=line_col,
|
|||
|
|
linewidth=0.85,
|
|||
|
|
rstride=1, cstride=1)
|
|||
|
|
|
|||
|
|
# azim=90 → camera at +Y, looking toward −Y (directly at face)
|
|||
|
|
# elev=-8 → camera slightly below horizontal = view from below,
|
|||
|
|
# shows chin/jaw, hides flat top of mesh — matches reference
|
|||
|
|
ax.view_init(elev=-8, azim=90)
|
|||
|
|
|
|||
|
|
ax.set_axis_off()
|
|||
|
|
# [x_width, y_depth, z_height]
|
|||
|
|
# Key: y_depth very small → face is flat/mask-like, not a globe
|
|||
|
|
ax.set_box_aspect([1.0, 0.28, 1.22])
|
|||
|
|
|
|||
|
|
plt.subplots_adjust(left=0, right=1, top=1, bottom=0)
|
|||
|
|
|
|||
|
|
os.makedirs(os.path.dirname(out_path) if os.path.dirname(out_path) else '.',
|
|||
|
|
exist_ok=True)
|
|||
|
|
fig.savefig(out_path, dpi=dpi, facecolor='black',
|
|||
|
|
bbox_inches='tight', pad_inches=0.02)
|
|||
|
|
plt.close(fig)
|
|||
|
|
print(f'Saved {out_path} ({img_w}×{img_h} px)')
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == '__main__':
|
|||
|
|
ap = argparse.ArgumentParser()
|
|||
|
|
ap.add_argument('--out', default='assets/face_wire.png')
|
|||
|
|
ap.add_argument('--width', type=int, default=400)
|
|||
|
|
ap.add_argument('--height', type=int, default=500)
|
|||
|
|
ap.add_argument('--color', default='#606060')
|
|||
|
|
args = ap.parse_args()
|
|||
|
|
generate(args.out, args.width, args.height, args.color)
|