Files
JARVIS/gen_face.py
David Rice 8c8d9a6d47 Changes
2026-04-16 10:52:14 +01:00

119 lines
4.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)