Spaces:
Running
Running
| """Reactive Gradio demo for the GNM (Generative aNthropometric Model) head model. | |
| Replicates the interactive slider demo from | |
| `gnm/shape/demos/gnm_head_demo.ipynb` in the upstream repo | |
| (https://github.com/google/GNM). Moving any slider re-evaluates the GNM | |
| mesh-generating function and updates a live 3D viewer. | |
| GNM Head is a pure-NumPy parametric statistical model of the human head. | |
| Evaluating it is a few matrix multiplies (~40 ms on CPU), so this Space runs | |
| happily on `cpu-basic` with no GPU. | |
| The 3D viewer is a small custom Three.js component (not `gr.Model3D`): the | |
| scene, mesh and camera are created *once* and every slider change only streams | |
| the new vertex positions, which are written straight into the existing | |
| geometry's position buffer. The mesh therefore morphs in place — the glTF is | |
| never reloaded/remounted, so the camera the user set is preserved and there is | |
| no reload flicker. | |
| """ | |
| import csv | |
| import json | |
| import re | |
| import sys | |
| import numpy as np | |
| # gr.Examples caches each example's outputs via Gradio's CSVLogger, which | |
| # round-trips the cached values through csv.reader. Our cached output field is | |
| # the flat mesh vertex array — a ~53k-float JSON string, far larger than | |
| # Python's default csv field-size limit (131072 bytes). Raise the limit at | |
| # startup (before any Examples caching runs) so csv.reader can parse it and | |
| # clicking a cached example doesn't throw `_csv.Error: field larger than field | |
| # limit`. | |
| csv.field_size_limit(sys.maxsize) | |
| import gradio as gr | |
| from scipy.spatial.transform import Rotation | |
| from gnm.shape import gnm_numpy | |
| from gnm.shape.visualization import vertex_colors as vertex_colors_module | |
| # Categorical / keyword expression sampling (mirrors the upstream GNM demo, | |
| # gnm/shape/README.md#demo-1). `semantic_sampler.ExpressionSampler` is a small | |
| # TensorFlow CVAE decoder that maps a named expression class (HAPPY, SURPRISE, | |
| # WINK_LEFT, ...) to a full 383-dim expression-blendshape vector, and can blend | |
| # several classes together. Loading is guarded so a TF/model-load hiccup only | |
| # hides the preset control instead of taking the whole Space down. | |
| try: | |
| from gnm.shape import semantic_sampler as _semantic_sampler | |
| _EXPR_SAMPLER = _semantic_sampler.ExpressionSampler() | |
| _EXPRESSION_ENUM = _semantic_sampler.Expression | |
| _SAMPLER_ERROR = "" | |
| except Exception as _exc: # pragma: no cover - defensive | |
| _semantic_sampler = None | |
| _EXPR_SAMPLER = None | |
| _EXPRESSION_ENUM = None | |
| _SAMPLER_ERROR = repr(_exc) | |
| # --------------------------------------------------------------------------- | |
| # Load the GNM head model once at module scope (fast, CPU-only). | |
| # --------------------------------------------------------------------------- | |
| GNM = gnm_numpy.GNM.from_local( | |
| version=gnm_numpy.GNMMajorVersion.V3, | |
| variant=gnm_numpy.GNMVariant.HEAD, | |
| ) | |
| # Mesh topology used by the upstream viewer: all triangles except the outer | |
| # eye shells, so the irises/scleras are visible. | |
| TRIANGLES = np.asarray(GNM.triangles_group("~eye_exteriors")).astype(np.int32) | |
| # Per-vertex colors highlighting eyes, teeth, tongue (matches the notebook). | |
| VERTEX_COLORS = vertex_colors_module.get_vertex_colors(gnm_np=GNM) | |
| if "pupils" in GNM.vertex_group_names: | |
| VERTEX_COLORS[GNM.vertex_group_indices("pupils")] = 0.0 | |
| VERTEX_COLORS = np.clip(VERTEX_COLORS, 0.0, 1.0) | |
| NUM_VERTICES = VERTEX_COLORS.shape[0] | |
| def _find_component_indices(names, region, suffix=r"_[0-9][0-9][0-9]"): | |
| """Indices of basis components whose name matches `<region><suffix>`.""" | |
| regex = re.compile(f"{region}{suffix}") | |
| return [i for i, n in enumerate(names) if regex.match(n)] | |
| # --------------------------------------------------------------------------- | |
| # Which basis components each slider group controls (mirrors the notebook). | |
| # --------------------------------------------------------------------------- | |
| _id_names = list(GNM.identity_names) | |
| _ex_names = list(GNM.expression_names) | |
| IDENTITY_INDICES = _find_component_indices(_id_names, "head")[:10] | |
| _tongue_mean = _find_component_indices(_ex_names, "tongue_mean", suffix="") | |
| _tongue = _find_component_indices(_ex_names, "tongue") | |
| _all_tongue = (_tongue_mean + _tongue) | |
| # Each group's key is the region/feature it actually affects in the model. | |
| # Names mirror the upstream `gnm_head_demo.ipynb`, which calls the | |
| # `lower_face_region_*` components the "mouth" group (the notebook variable is | |
| # literally `mouth_indices` and its docs list the exposed regions as | |
| # "left eye, right eye, mouth"). | |
| EXPRESSION_GROUPS = { | |
| "Left eye": _find_component_indices(_ex_names, "left_eye_region")[:3], | |
| "Right eye": _find_component_indices(_ex_names, "right_eye_region")[:3], | |
| "Mouth": _find_component_indices(_ex_names, "lower_face_region")[:7], | |
| "Tongue": _all_tongue[:4], | |
| "Pupil dilation": _find_component_indices(_ex_names, "pupils")[:1], | |
| } | |
| # Flat ordered list of expression indices exposed as sliders. | |
| EXPRESSION_INDICES = [i for g in EXPRESSION_GROUPS.values() for i in g] | |
| # Pose slider limits (degrees), as in the notebook. Labels name the actual | |
| # rotation each axis produces on this Y-up / +Z-forward head: | |
| # X rotation = pitch (nod up/down), Y = yaw (turn left/right), | |
| # Z = roll (tilt sideways). Gaze rotates the eyeballs only. | |
| POSE_LIMITS = { | |
| "neck": { | |
| "Pitch (nod)": 90, | |
| "Yaw (turn)": 90, | |
| "Roll (tilt)": 90, | |
| }, | |
| "head": { | |
| "Pitch (nod)": 45, | |
| "Yaw (turn)": 45, | |
| "Roll (tilt)": 15, | |
| }, | |
| "gaze": { | |
| "Pitch (up/down)": 25, | |
| "Yaw (left/right)": 30, | |
| "Vergence (cross-eye)": 10, | |
| }, | |
| } | |
| NUM_IDENTITY = len(IDENTITY_INDICES) # 10 | |
| NUM_EXPRESSION = len(EXPRESSION_INDICES) # 16 | |
| NUM_POSE = 9 # neck(3) + head(3) + gaze(3) | |
| NUM_TRANSLATION = 3 | |
| TOTAL_SLIDERS = NUM_IDENTITY + NUM_EXPRESSION + NUM_POSE + NUM_TRANSLATION | |
| # --------------------------------------------------------------------------- | |
| # Direct-manipulation zones. | |
| # | |
| # The viewer lets you click-and-drag *on the head itself* to drive the sliders: | |
| # grab the jaw and pull down to open the mouth, grab an eyeball to aim the | |
| # gaze, grab the head shell to turn it, etc. To do that, every mesh vertex is | |
| # tagged with an interaction "zone", and the viewer maps a drag over a zone to | |
| # the matching slider change. The tags are derived from GNM's own semantic | |
| # vertex groups (chin_region, left_orbital_region, left_eye, ...), so the | |
| # mapping tracks the real anatomy of the model. | |
| # --------------------------------------------------------------------------- | |
| ( | |
| ZONE_HEAD, # 0 - skull / forehead / nose: rotate the head | |
| ZONE_LEFT_EYE, # 1 - left lid+brow skin: open/close left eye | |
| ZONE_RIGHT_EYE, # 2 - right lid+brow skin: open/close right eye | |
| ZONE_LEFT_GAZE, # 3 - left eyeball: aim gaze | |
| ZONE_RIGHT_GAZE, # 4 - right eyeball: aim gaze | |
| ZONE_MOUTH_OPEN, # 5 - centre lips + chin: drag to open / close the jaw | |
| ZONE_TONGUE, # 6 - tongue / teeth: push the tongue out | |
| ZONE_MOUTH_SMILE, # 7 - lip corners: drag outward to smile / stretch | |
| ZONE_EAR, # 8 - ears: drag to pull them in / out (free sculpt) | |
| ZONE_CHEEK, # 9 - cheeks: drag to stretch the face (free sculpt) | |
| ) = range(10) | |
| _zones = np.zeros(NUM_VERTICES, dtype=np.int16) # default: ZONE_HEAD | |
| # Neutral vertex positions, for splitting the mouth into centre (open) vs | |
| # corner (smile) sub-zones by x-coordinate. | |
| _NEUTRAL = np.asarray(GNM(None, None, None, None)) | |
| def _tag_zone(regions, zone): | |
| """Tag every vertex of the named GNM vertex groups with `zone`.""" | |
| for r in regions: | |
| if r in GNM.vertex_group_names: | |
| _zones[np.asarray(GNM.vertex_group_indices(r))] = zone | |
| # Applied low-priority first; later tags win where regions overlap. Eyeballs | |
| # (gaze) are tagged last so grabbing the eye always aims it rather than blinks. | |
| _tag_zone( | |
| ["upper_lip", "lower_lip", "mouth_sock", | |
| "upper_lip_region", "lower_lip_region", "chin_region"], | |
| ZONE_MOUTH_OPEN, | |
| ) | |
| _tag_zone(["tongue", "gums", "teeth"], ZONE_TONGUE) | |
| _tag_zone( | |
| ["left_brow_region", "left_orbital_region", "left_infraorbital_region"], | |
| ZONE_LEFT_EYE, | |
| ) | |
| _tag_zone( | |
| ["right_brow_region", "right_orbital_region", "right_infraorbital_region"], | |
| ZONE_RIGHT_EYE, | |
| ) | |
| _tag_zone(["left_eye"], ZONE_LEFT_GAZE) | |
| _tag_zone(["right_eye"], ZONE_RIGHT_GAZE) | |
| # Ears and cheeks are free-sculpt handles (drag to reshape geometrically). | |
| _tag_zone(["ears"], ZONE_EAR) | |
| _tag_zone( | |
| ["left_cheek_region", "right_cheek_region", | |
| "left_zygomatic_region", "right_zygomatic_region"], | |
| ZONE_CHEEK, | |
| ) | |
| # Split the mouth: lip vertices near the corners (|x| beyond ~half the mouth | |
| # half-width) become the SMILE zone; the central lips + chin stay OPEN. This | |
| # gives two separately-highlighted mouth areas that each do one thing. | |
| _lip_groups = ["upper_lip", "lower_lip", "upper_lip_region", "lower_lip_region"] | |
| _lip_idx = np.unique(np.concatenate( | |
| [np.asarray(GNM.vertex_group_indices(g)) for g in _lip_groups | |
| if g in GNM.vertex_group_names] | |
| )) | |
| _corner_x = 0.6 * np.abs(_NEUTRAL[_lip_idx, 0]).max() # corner threshold | |
| _corner_verts = _lip_idx[np.abs(_NEUTRAL[_lip_idx, 0]) >= _corner_x] | |
| _zones[_corner_verts] = ZONE_MOUTH_SMILE | |
| ZONES_JSON = json.dumps(_zones.tolist(), separators=(",", ":")) | |
| # Start offset of each slider group within its category's flat slider list | |
| # (the assembled slider lists are in this canonical order). | |
| _EXPR_SLOT_START = {} | |
| _c = 0 | |
| for _name, _g in EXPRESSION_GROUPS.items(): | |
| _EXPR_SLOT_START[_name] = _c | |
| _c += len(_g) | |
| _POSE_SLOT_START = {} | |
| _c = 0 | |
| for _joint, _lim in POSE_LIMITS.items(): | |
| _POSE_SLOT_START[_joint] = _c | |
| _c += len(_lim) | |
| # Which specific slider each drag gesture drives, addressed by the registry | |
| # key baked into that slider (see `_vslider`). Measured directions on this | |
| # model (see the calibration in the module docstring / presets): | |
| # mouth slot 0 (lower_face_region_000): +raises/closes, -drops jaw open | |
| # left/right eye slot 0: + raises brow / widens, - lowers / closes | |
| # tongue slot +1 (tongue_000): + pushes the tongue forward/out | |
| # head pose: pitch = nod, yaw = turn; gaze pose: pitch/yaw aim the eyes | |
| VIEWER_CFG = { | |
| "leftEyeKey": f"ex_{_EXPR_SLOT_START['Left eye']}", | |
| "rightEyeKey": f"ex_{_EXPR_SLOT_START['Right eye']}", | |
| "mouthKey": f"ex_{_EXPR_SLOT_START['Mouth']}", | |
| # lower_face_region_001: negative widens + lifts the mouth corners (smile / | |
| # stretch), positive puckers. Horizontal mouth drags map to this. | |
| "smileKey": f"ex_{_EXPR_SLOT_START['Mouth'] + 1}", | |
| "tongueKey": f"ex_{_EXPR_SLOT_START['Tongue'] + 1}", | |
| "headPitchKey": f"pose_{_POSE_SLOT_START['head'] + 0}", | |
| "headYawKey": f"pose_{_POSE_SLOT_START['head'] + 1}", | |
| "gazePitchKey": f"pose_{_POSE_SLOT_START['gaze'] + 0}", | |
| "gazeYawKey": f"pose_{_POSE_SLOT_START['gaze'] + 1}", | |
| "txKey": "tr_0", | |
| "tyKey": "tr_1", | |
| } | |
| VIEWER_CFG_JSON = json.dumps(VIEWER_CFG, separators=(",", ":")) | |
| # --------------------------------------------------------------------------- | |
| # Parameter assembly + mesh generation. | |
| # --------------------------------------------------------------------------- | |
| def _build_rotations(pose_vals): | |
| """Port of the notebook's pose->axis-angle conversion. | |
| pose_vals is a flat list of 9 degrees: | |
| [neck X,Y,Z, head X,Y,Z, gaze X, gaze Y, gaze Vergence]. | |
| """ | |
| neck = pose_vals[0:3] | |
| head = pose_vals[3:6] | |
| gaze_x, gaze_y, verg = pose_vals[6:9] | |
| rotations_euler = np.zeros((GNM.num_joints, 3)) | |
| # Joint 0 = neck, joint 1 = head. | |
| rotations_euler[0] = np.radians(neck) | |
| rotations_euler[1] = np.radians(head) | |
| # Both eyes (joints 2, 3) share gaze X/Y; vergence splits them apart. | |
| rotations_euler[2, 0] = np.radians(gaze_x) | |
| rotations_euler[3, 0] = np.radians(gaze_x) | |
| rotations_euler[2, 1] = np.radians(gaze_y) + np.radians(verg) | |
| rotations_euler[3, 1] = np.radians(gaze_y) - np.radians(verg) | |
| return np.array( | |
| [Rotation.from_euler("XYZ", r).as_rotvec() for r in rotations_euler] | |
| ) | |
| def _compute_vertices(slider_values) -> np.ndarray: | |
| """Evaluate the GNM head from the flat slider vector -> (N, 3) vertices.""" | |
| vals = list(slider_values) | |
| idx = 0 | |
| id_vals = vals[idx:idx + NUM_IDENTITY]; idx += NUM_IDENTITY | |
| ex_vals = vals[idx:idx + NUM_EXPRESSION]; idx += NUM_EXPRESSION | |
| pose_vals = vals[idx:idx + NUM_POSE]; idx += NUM_POSE | |
| trans_vals = vals[idx:idx + NUM_TRANSLATION] | |
| identity = np.zeros(GNM.identity_dim) | |
| for i, v in zip(IDENTITY_INDICES, id_vals): | |
| identity[i] = float(v) | |
| expression = np.zeros(GNM.expression_dim) | |
| for i, v in zip(EXPRESSION_INDICES, ex_vals): | |
| expression[i] = float(v) | |
| rotations = _build_rotations([float(v) for v in pose_vals]) | |
| translation = np.array([float(v) for v in trans_vals]) | |
| return np.asarray(GNM(identity, expression, rotations, translation)) | |
| def positions_json(*slider_values) -> str: | |
| """Return the GNM head vertices as a compact JSON list of floats. | |
| Args: | |
| slider_values: flat sequence of slider values in this order: | |
| 10 identity, 16 expression, 9 pose (degrees), 3 translation. | |
| Returns: | |
| JSON string ``[x0, y0, z0, x1, y1, z1, ...]`` (flat, one entry per | |
| vertex coordinate). This is streamed to the Three.js viewer, which | |
| writes it straight into the existing geometry buffer, morphing the | |
| mesh in place with no reload. | |
| """ | |
| vertices = _compute_vertices(slider_values) | |
| flat = np.round(vertices.reshape(-1), 5).tolist() | |
| return json.dumps(flat, separators=(",", ":")) | |
| # --------------------------------------------------------------------------- | |
| # Sculpt (free-pull) — drag a point on the mesh to reshape the head. | |
| # | |
| # The bind-pose is linear in identity: vertex_v = template_v + Σ_k id_k · B[k,v] | |
| # (B = vertex_identity_basis). So the Jacobian of the grabbed vertex w.r.t. the | |
| # 10 exposed head-shape sliders is a constant (3, 10) matrix. Given a world- | |
| # space drag `d` at vertex `vi`, we solve the (under-determined) least-squares | |
| # `A·Δ ≈ d` for the smallest slider change Δ that moves that point toward the | |
| # drag, add it to the sliders the drag started from, and clip to range. Because | |
| # the basis is rich, the min-norm solution stays reasonably local (pulling an | |
| # ear pokes the ear; pulling a cheek widens the face). | |
| # --------------------------------------------------------------------------- | |
| _ID_JAC = np.asarray(GNM.vertex_identity_basis)[IDENTITY_INDICES].astype(np.float64) # (10, V, 3) | |
| # Gain for the sculpt gradient step (see sculpt_pull). Chosen so a moderate | |
| # Alt-drag produces a clear but un-maxed reshape. | |
| _SCULPT_GAIN = 6000.0 | |
| def sculpt_pull(payload, *slider_values): | |
| """Reshape the head by nudging the shape sliders toward a drag. | |
| `payload` is a JSON string ``{"vi": int, "d": [dx,dy,dz], "base": [10]}`` | |
| set by the viewer during an Alt-drag: `vi` is the grabbed vertex, `d` the | |
| cumulative world drag, `base` the identity slider values at drag start. | |
| The 10 exposed identity modes are *global* head-shape PCA directions, so a | |
| single point can't be pulled to an arbitrary place (that inverse is | |
| ill-conditioned). Instead we take a bounded gradient step: move each shape | |
| slider proportionally to how strongly it pushes the grabbed vertex along | |
| the drag (``Δ_k = gain · B[k, vi] · d``). Well-covered regions (cheeks, | |
| brow, jaw) respond strongly; areas the basis barely controls (nose tip) | |
| move a little — it never blows up. Returns the new identity slider values. | |
| """ | |
| cur = [float(v) for v in slider_values[:NUM_IDENTITY]] | |
| if not payload: | |
| return cur | |
| try: | |
| p = json.loads(payload) | |
| vi = int(p["vi"]) | |
| d = np.asarray(p["d"], dtype=np.float64).reshape(3) | |
| base = np.asarray(p.get("base", cur), dtype=np.float64).reshape(-1)[:NUM_IDENTITY] | |
| except Exception: | |
| return cur | |
| if not (0 <= vi < _ID_JAC.shape[1]): | |
| return cur | |
| step = _SCULPT_GAIN * (_ID_JAC[:, vi, :] @ d) # (10,) gradient of vertex·drag | |
| new = np.clip(base + step, -3.0, 3.0) | |
| new = np.round(new / 0.05) * 0.05 # snap to the slider step | |
| return [float(x) for x in new] | |
| # --------------------------------------------------------------------------- | |
| # Categorical / keyword expression presets. | |
| # | |
| # Each entry in the `Expression` enum (SURPRISE, HAPPY, WINK_LEFT, ...) is a | |
| # named expression the CVAE can sample. The sampler returns a full 383-dim | |
| # expression vector; the Space only exposes 18 of those components as sliders | |
| # (EXPRESSION_INDICES), so applying a preset reads the sampled vector at those | |
| # 18 slots and sets the corresponding expression sliders. Identity, pose and | |
| # translation are left untouched, and because we write into the very same | |
| # slider components, manual per-slider tweaking keeps working afterwards. | |
| # --------------------------------------------------------------------------- | |
| def _pretty_expr_name(name: str) -> str: | |
| """`WINK_LEFT` -> `Wink Left` for a friendly dropdown label.""" | |
| return name.replace("_", " ").title() | |
| if _EXPRESSION_ENUM is not None: | |
| # Ordered list of (display label, enum member) for the dropdown. | |
| EXPRESSION_PRESETS = [ | |
| (_pretty_expr_name(m.name), m) for m in _EXPRESSION_ENUM | |
| ] | |
| _LABEL_TO_EXPR = {label: member for label, member in EXPRESSION_PRESETS} | |
| EXPRESSION_PRESET_LABELS = [label for label, _ in EXPRESSION_PRESETS] | |
| else: | |
| EXPRESSION_PRESETS = [] | |
| _LABEL_TO_EXPR = {} | |
| EXPRESSION_PRESET_LABELS = [] | |
| def _sample_expression_vector(labels): | |
| """Return a full 383-dim expression vector for the selected preset label(s). | |
| A single label is sampled directly; multiple labels are blended with equal | |
| weight (mirrors the reference demo's `blend_expressions`). Sampling is | |
| stochastic (a fresh latent draw each call), exactly like the upstream demo. | |
| """ | |
| members = [_LABEL_TO_EXPR[l] for l in labels if l in _LABEL_TO_EXPR] | |
| if not members or _EXPR_SAMPLER is None: | |
| return None | |
| if len(members) == 1: | |
| vec = _EXPR_SAMPLER.sample_expression(members[0], num_samples=1)[0] | |
| else: | |
| vec = _EXPR_SAMPLER.blend_expressions({m: 1.0 for m in members}) | |
| return np.asarray(vec).reshape(-1) | |
| def apply_expression_preset(labels, strength, *slider_values): | |
| """Set/blend the expression sliders toward a named keyword expression. | |
| Args: | |
| labels: selected preset label(s) from the dropdown (str or list). One | |
| label is sampled; several are blended together. | |
| strength: 0..1 blend factor. The expression sliders move from their | |
| current values toward the sampled target by this fraction | |
| (1.0 = snap fully to the preset, 0.0 = no change), so a preset | |
| adjusts the face toward the expression without locking the sliders. | |
| slider_values: the full current slider vector (identity, expression, | |
| pose, translation) — used to read the current expression values so | |
| the blend starts from wherever the user currently is. | |
| Returns: | |
| A list of the new expression slider values (one per exposed expression | |
| component). Only the expression sliders are updated. | |
| """ | |
| if isinstance(labels, str): | |
| labels = [labels] if labels else [] | |
| labels = list(labels or []) | |
| # Current expression slider values (the expression block of all_sliders). | |
| cur = [ | |
| float(v) | |
| for v in slider_values[NUM_IDENTITY:NUM_IDENTITY + NUM_EXPRESSION] | |
| ] | |
| vec = _sample_expression_vector(labels) | |
| if vec is None: | |
| # Nothing selected (or sampler unavailable): leave sliders unchanged. | |
| return cur | |
| target = [float(vec[i]) for i in EXPRESSION_INDICES] | |
| s = float(strength) | |
| blended = [ | |
| max(-3.0, min(3.0, c + s * (t - c))) for c, t in zip(cur, target) | |
| ] | |
| # Round to the slider step (0.05) so the readouts look clean. | |
| blended = [round(v / 0.05) * 0.05 for v in blended] | |
| return blended | |
| # Static geometry: faces + colors are constant across every evaluation, so | |
| # they are baked once into the viewer's init JS (the topology never changes, | |
| # only the vertex positions do). | |
| _FACES_JSON = json.dumps(TRIANGLES.reshape(-1).tolist(), separators=(",", ":")) | |
| _COLORS_JSON = json.dumps( | |
| np.round(VERTEX_COLORS.reshape(-1), 4).tolist(), separators=(",", ":") | |
| ) | |
| # Three.js is loaded as a classic UMD build exposing the global `THREE`. (The | |
| # Gradio 6 `head=` injector drops the `type` attribute, so ES-module / | |
| # importmap script tags don't work there — a classic global build does.) | |
| VIEWER_HEAD = ( | |
| '<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/build/' | |
| 'three.min.js"></script>' | |
| ) | |
| # The template is a *static* canvas that never references ${value}, so Gradio's | |
| # DOM-diffing renderer keeps the exact same <canvas> element (and its WebGL | |
| # context) across every value update — the scene is built once and never | |
| # remounted. | |
| VIEWER_TEMPLATE = ( | |
| '<div id="gnm-wrap" style="width:100%;height:560px;border-radius:8px;' | |
| 'overflow:hidden;background:#f0f0f0;position:relative;">' | |
| '<canvas id="gnm-canvas" style="width:100%;height:100%;display:block;">' | |
| "</canvas>" | |
| # Hover hint: shows what dragging the region under the cursor will do. | |
| '<div id="gnm-hint" style="position:absolute;top:10px;left:10px;' | |
| "padding:4px 10px;border-radius:6px;background:rgba(0,0,0,0.62);" | |
| "color:#fff;font-size:13px;font-weight:600;letter-spacing:.2px;" | |
| 'pointer-events:none;opacity:0;transition:opacity .12s;z-index:5;">' | |
| "</div>" | |
| # Reset-shaping button: appears once you free-sculpt (ear/cheek/Alt-drag), | |
| # since those geometric tweaks have no slider to reset. Clears the sculpt. | |
| '<button id="gnm-clear-sculpt" style="position:absolute;top:10px;' | |
| "right:10px;display:none;padding:4px 10px;border-radius:6px;border:none;" | |
| "background:rgba(0,0,0,0.62);color:#fff;font-size:12px;font-weight:600;" | |
| 'cursor:pointer;z-index:6;">↺ Reset shaping</button>' | |
| "</div>" | |
| ) | |
| # js_on_load runs exactly once on mount. It builds a persistent Three.js scene, | |
| # camera and (hand-rolled) orbit controls, then registers a `watch('value')` | |
| # callback. Every slider change sets the component value to a new flat vertex | |
| # array; the callback writes it straight into the existing geometry's position | |
| # buffer — the mesh morphs in place, the camera is never touched, and nothing | |
| # is reloaded/remounted (no camera reset, no blink). | |
| VIEWER_JS_ON_LOAD = """ | |
| (function () { | |
| const FACES = __FACES__; | |
| const COLORS = __COLORS__; | |
| const ZONES = __ZONES__; // per-vertex interaction zone id | |
| const CFG = __CFG__; // gesture -> slider-registry-key map | |
| // GNM head geometry (meters): Y-up / +Z-forward, centered ~ (0, 0.24, 0). | |
| const TARGET = [0, 0.24, 0.02]; | |
| // Zone ids (mirror the Python ZONE_* constants). | |
| const Z_HEAD=0, Z_LEYE=1, Z_REYE=2, Z_LGAZE=3, Z_RGAZE=4, Z_MOUTH=5, | |
| Z_TONGUE=6, Z_SMILE=7, Z_EAR=8, Z_CHEEK=9; | |
| // Zones whose plain drag free-sculpts the geometry (none now — ears & cheeks | |
| // drive head-shape sliders instead; Alt+drag still free-sculpts anywhere). | |
| const SCULPT_ZONES = {}; | |
| // Drag sensitivities (slider-units per pixel of drag). | |
| const HEAD_G = 0.35; // degrees / px (head rotation) | |
| const GAZE_G = 0.18; // degrees / px (eye gaze) | |
| const EXPR_G = 0.022; // blendshape units / px (eyes, mouth, tongue) | |
| const TRANS_G = 0.0016; // meters / px (shift-drag translate) | |
| const ID_G = 0.02; // head-shape units / px (ear / cheek -> identity) | |
| const SCULPT_G = 1.0; // sculpt drag is sent as real-world meters; the | |
| // response gain lives server-side in _SCULPT_GAIN | |
| // What a drag over each zone does: a friendly label + the slider(s) it | |
| // drives. axis 'x' = horizontal drag, 'y' = vertical drag (up is positive | |
| // because screen-y grows downward and we negate it). sign flips direction. | |
| function zoneSpec(z) { | |
| switch (z) { | |
| case Z_HEAD: return { label: "↺ Rotate head · drag to turn / nod", | |
| t: [ {key: CFG.headYawKey, axis:'x', gain: HEAD_G, sign: +1}, | |
| {key: CFG.headPitchKey, axis:'y', gain: HEAD_G, sign: -1} ] }; | |
| // The eyeball sits slightly behind the lids, so from the front a click | |
| // lands on the lid/brow. That eye zone therefore does both: drag ↕ to | |
| // open/close *this* eye, drag ↔ to glance (gaze yaw, both eyes). Orbit | |
| // round to grab the eyeball itself (Z_*GAZE) for full up/down aiming. | |
| case Z_LEYE: return { label: "👁 Left eye · ↕ open / close · ↔ glance", | |
| t: [ {key: CFG.leftEyeKey, axis:'y', gain: EXPR_G, sign: +1}, | |
| {key: CFG.gazeYawKey, axis:'x', gain: GAZE_G, sign: +1} ] }; | |
| case Z_REYE: return { label: "👁 Right eye · ↕ open / close · ↔ glance", | |
| t: [ {key: CFG.rightEyeKey, axis:'y', gain: EXPR_G, sign: +1}, | |
| {key: CFG.gazeYawKey, axis:'x', gain: GAZE_G, sign: +1} ] }; | |
| case Z_LGAZE: | |
| case Z_RGAZE: return { label: "👀 Gaze · drag to aim the eyes", | |
| t: [ {key: CFG.gazeYawKey, axis:'x', gain: GAZE_G, sign: +1}, | |
| {key: CFG.gazePitchKey, axis:'y', gain: GAZE_G, sign: -1} ] }; | |
| // Centre of the mouth / chin: vertical drag only — pull the jaw down to | |
| // open, push up to close. | |
| case Z_MOUTH: return { label: "🗣 Jaw · drag down to open the mouth", | |
| t: [ {key: CFG.mouthKey, axis:'y', gain: EXPR_G, sign: +1} ] }; | |
| // Lip corners: horizontal drag only — pull a corner outward to smile / | |
| // stretch, push inward to pucker (outwardX resolves the side). | |
| case Z_SMILE: return { label: "😀 Mouth corner · pull out to smile / stretch", | |
| t: [ {key: CFG.smileKey, axis:'x', gain: 0.02, outwardX: true} ] }; | |
| case Z_TONGUE:return { label: "👅 Tongue · drag down to stick it out", | |
| t: [ {key: CFG.tongueKey, axis:'y', gain: EXPR_G, sign: -1} ] }; | |
| // Ears & cheeks drive head-shape sliders (Head shape 8 / 7). outwardXPos: | |
| // pulling the ear out raises id_7 (ears out); outwardX: pulling the cheek | |
| // out lowers id_6 (fuller face). Both resolve their side from the grab. | |
| case Z_EAR: return { label: "👂 Ear · drag in / out (Head shape 8)", | |
| t: [ {key: "id_7", axis:'x', gain: ID_G, outwardXPos: true} ] }; | |
| case Z_CHEEK: return { label: "🫦 Cheek · drag to stretch the face (Head shape 7)", | |
| t: [ {key: "id_6", axis:'x', gain: ID_G, outwardX: true} ] }; | |
| } | |
| return { label: "", t: [] }; | |
| } | |
| // Highlight tints per zone (RGB 0..1). Head is not tinted (it's most of the | |
| // mesh) — only the feature zones light up on hover. | |
| const HL = { | |
| 1:[1.0,0.55,0.05], 2:[1.0,0.55,0.05], 3:[0.10,0.60,1.0], | |
| 4:[0.10,0.60,1.0], 5:[1.0,0.32,0.34], 6:[0.92,0.20,0.62], | |
| 7:[0.20,0.80,0.45], // smile corners — distinct green from the red jaw | |
| 8:[0.65,0.45,1.0], // ears — purple | |
| 9:[1.0,0.75,0.15], // cheeks — amber | |
| }; | |
| let renderer, scene, camera, geometry, mesh, colorAttr, clearBtn; | |
| // Persistent free-sculpt offsets (world space), added on top of the GNM | |
| // mesh every frame so a pulled ear / stretched cheek survives slider morphs. | |
| let sculptOffsets = null, lastFlat = null; | |
| // Show the "Reset shaping" button only when some sculpt offset is non-zero | |
| // (those geometric tweaks aren't tied to any slider, so this is their reset). | |
| function hasSculpt() { | |
| if (!sculptOffsets) return false; | |
| for (let i = 0; i < sculptOffsets.length; i++) if (sculptOffsets[i] !== 0) return true; | |
| return false; | |
| } | |
| function updateClearBtn() { | |
| if (clearBtn) clearBtn.style.display = hasSculpt() ? "block" : "none"; | |
| } | |
| const BASE_COLORS = new Float32Array(COLORS); // pristine per-vertex colors | |
| // Vertices grouped by zone, for fast hover recolor. | |
| const zoneVerts = {}; | |
| for (let i = 0; i < ZONES.length; i++) { | |
| (zoneVerts[ZONES[i]] || (zoneVerts[ZONES[i]] = [])).push(i); | |
| } | |
| // Spherical camera around TARGET. Front-facing default: on +Z axis. | |
| let camState = { radius: 0.75, theta: 0.0, phi: Math.PI / 2 }; | |
| function applyCamera() { | |
| const [tx, ty, tz] = TARGET; | |
| const r = camState.radius, t = camState.theta, p = camState.phi; | |
| camera.position.set( | |
| tx + r * Math.sin(p) * Math.sin(t), | |
| ty + r * Math.cos(p), | |
| tz + r * Math.sin(p) * Math.cos(t) | |
| ); | |
| camera.lookAt(tx, ty, tz); | |
| } | |
| // Write lastFlat (+ any sculpt offsets) into the position buffer. | |
| function applyBuffer() { | |
| if (!geometry || !lastFlat) return; | |
| const n = lastFlat.length; | |
| let attr = geometry.getAttribute("position"); | |
| if (!attr || attr.array.length !== n) { | |
| attr = new THREE.BufferAttribute(new Float32Array(n), 3); | |
| geometry.setAttribute("position", attr); | |
| } | |
| const a = attr.array; | |
| if (sculptOffsets && sculptOffsets.length === n) { | |
| for (let i = 0; i < n; i++) a[i] = lastFlat[i] + sculptOffsets[i]; | |
| } else { | |
| a.set(lastFlat); | |
| } | |
| attr.needsUpdate = true; | |
| geometry.computeVertexNormals(); | |
| geometry.computeBoundingSphere(); | |
| geometry.computeBoundingBox(); | |
| } | |
| function setPositions(flat) { | |
| lastFlat = flat; | |
| if (!sculptOffsets && flat.length) sculptOffsets = new Float32Array(flat.length); | |
| applyBuffer(); | |
| } | |
| // ---- Slider registry access (set by each vertical slider on mount). ----- | |
| function reg(k) { | |
| return (window.__GNM && window.__GNM.reg) ? window.__GNM.reg[k] : null; | |
| } | |
| function getV(k) { const r = reg(k); return r ? parseFloat(r.get()) : 0; } | |
| function setV(k, v) { const r = reg(k); if (r) r.set(v); } | |
| // ---- Always-on region colour map + hover highlight ---------------------- | |
| // Every mapped zone is permanently tinted toward its HL colour at FAINT | |
| // strength (the "control map"); the hovered zone is boosted to BRIGHT. | |
| const FAINT = 0.32, BRIGHT = 0.78; | |
| let hlZone = -1; | |
| // Tint a zone's vertices toward its map colour by `strength` (0 = base). | |
| function tintZone(z, strength) { | |
| const vs = zoneVerts[z], c = HL[z]; if (!vs || !c) return; | |
| const a = colorAttr.array, s = strength, b = 1 - strength; | |
| for (let n = 0; n < vs.length; n++) { | |
| const i = vs[n] * 3; | |
| a[i] = b * BASE_COLORS[i] + s * c[0]; | |
| a[i+1] = b * BASE_COLORS[i+1] + s * c[1]; | |
| a[i+2] = b * BASE_COLORS[i+2] + s * c[2]; | |
| } | |
| colorAttr.needsUpdate = true; | |
| } | |
| // Paint the whole control map at FAINT strength (call once the colour | |
| // attribute exists). | |
| function initColorMap() { | |
| for (const zk in HL) tintZone(Number(zk), FAINT); | |
| } | |
| function setHighlight(z) { | |
| if (z === hlZone) return; | |
| if (hlZone >= 0 && HL[hlZone]) tintZone(hlZone, FAINT); // back to map | |
| hlZone = (z >= 0 && HL[z]) ? z : -1; | |
| if (hlZone >= 0) tintZone(hlZone, BRIGHT); | |
| } | |
| // ---- Slider glow: light up the control(s) a hovered region drives ------- | |
| const TAB_OF = { id: 0, ex: 1, pose: 2, tr: 3 }; | |
| let glowing = []; | |
| function clearGlow() { | |
| for (const el of glowing) el.classList.remove("gnm-ctl-glow"); | |
| glowing = []; | |
| } | |
| function glowZone(z) { | |
| clearGlow(); | |
| const sp = zoneSpec(z); | |
| const tabBtns = document.querySelectorAll('button[role="tab"]'); | |
| const seenTabs = {}; | |
| for (const t of (sp.t || [])) { | |
| const r = reg(t.key); | |
| if (r && r.el) { r.el.classList.add("gnm-ctl-glow"); glowing.push(r.el); } | |
| const ti = TAB_OF[String(t.key).split("_")[0]]; | |
| if (ti != null && tabBtns[ti] && !seenTabs[ti]) { | |
| seenTabs[ti] = 1; | |
| tabBtns[ti].classList.add("gnm-ctl-glow"); glowing.push(tabBtns[ti]); | |
| } | |
| } | |
| } | |
| function build() { | |
| const wrap = element.querySelector("#gnm-wrap"); | |
| const canvas = element.querySelector("#gnm-canvas"); | |
| const hint = element.querySelector("#gnm-hint"); | |
| clearBtn = element.querySelector("#gnm-clear-sculpt"); | |
| if (clearBtn) { | |
| clearBtn.addEventListener("click", () => { | |
| if (sculptOffsets) sculptOffsets.fill(0); | |
| applyBuffer(); | |
| updateClearBtn(); | |
| }); | |
| } | |
| if (!wrap || !canvas || !window.THREE) return false; | |
| const W = wrap.clientWidth || 600, H = wrap.clientHeight || 560; | |
| renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: true }); | |
| renderer.setPixelRatio(window.devicePixelRatio || 1); | |
| renderer.setSize(W, H, false); | |
| renderer.toneMapping = THREE.ACESFilmicToneMapping; | |
| renderer.toneMappingExposure = 0.85; | |
| scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0xf0f0f0); | |
| camera = new THREE.PerspectiveCamera(35, W / H, 0.01, 100); | |
| applyCamera(); | |
| scene.add(new THREE.AmbientLight(0xffffff, 0.40)); | |
| const keyL = new THREE.DirectionalLight(0xffffff, 0.55); | |
| keyL.position.set(0.5, 1.0, 1.5); scene.add(keyL); | |
| const fill = new THREE.DirectionalLight(0xffffff, 0.20); | |
| fill.position.set(-0.8, 0.2, 0.5); scene.add(fill); | |
| geometry = new THREE.BufferGeometry(); | |
| geometry.setIndex(new THREE.BufferAttribute(new Uint32Array(FACES), 1)); | |
| colorAttr = new THREE.BufferAttribute(new Float32Array(COLORS), 3); | |
| geometry.setAttribute("color", colorAttr); | |
| initColorMap(); // paint the always-on region map | |
| const mat = new THREE.MeshStandardMaterial({ | |
| vertexColors: true, roughness: 0.75, metalness: 0.0, | |
| side: THREE.DoubleSide, | |
| }); | |
| mesh = new THREE.Mesh(geometry, mat); | |
| scene.add(mesh); | |
| // ---- Raycasting: screen point -> mesh hit -> interaction zone. -------- | |
| const raycaster = new THREE.Raycaster(); | |
| const ndc = new THREE.Vector2(); | |
| function pick(clientX, clientY) { | |
| const rect = canvas.getBoundingClientRect(); | |
| ndc.x = ((clientX - rect.left) / rect.width) * 2 - 1; | |
| ndc.y = -((clientY - rect.top) / rect.height) * 2 + 1; | |
| raycaster.setFromCamera(ndc, camera); | |
| const hits = raycaster.intersectObject(mesh, false); | |
| if (!hits.length) return null; | |
| const f = hits[0].face; | |
| // Prefer a feature zone if any of the face's 3 verts carries one. | |
| let z = ZONES[f.a]; | |
| if (z === Z_HEAD && ZONES[f.b] !== Z_HEAD) z = ZONES[f.b]; | |
| if (z === Z_HEAD && ZONES[f.c] !== Z_HEAD) z = ZONES[f.c]; | |
| // Nearest of the face's 3 vertices to the hit point (for sculpting). | |
| const P = hits[0].point, pos = geometry.getAttribute("position"); | |
| let vi = f.a, best = Infinity; | |
| for (const vv of [f.a, f.b, f.c]) { | |
| const dx = pos.getX(vv) - P.x, dy = pos.getY(vv) - P.y, | |
| dz = pos.getZ(vv) - P.z, dd = dx * dx + dy * dy + dz * dz; | |
| if (dd < best) { best = dd; vi = vv; } | |
| } | |
| return { zone: z, point: P, vi: vi }; | |
| } | |
| function showHint(text) { | |
| if (!hint) return; | |
| hint.textContent = text || ""; | |
| hint.style.opacity = text ? "1" : "0"; | |
| } | |
| // ---- Interaction state machine ---------------------------------------- | |
| // mode: null | 'orbit' | 'manip' | 'trans' | 'sculpt' | |
| let mode = null, spec = null, startX = 0, startY = 0, startVals = null; | |
| let orbitLastX = 0, orbitLastY = 0, pendingApply = null; | |
| let brush = null; // { list:[{i,w}], base: Float32Array } for a sculpt drag | |
| // Meters of world space per screen pixel at the head, for the current | |
| // camera — turns a sculpt drag (pixels) into a world displacement so the | |
| // grabbed point tracks the cursor. | |
| function metersPerPixel() { | |
| const h = canvas.clientHeight || 560; | |
| const fov = (camera.fov || 35) * Math.PI / 180; | |
| return (2 * camState.radius * Math.tan(fov / 2)) / h; | |
| } | |
| // Sculpt brush: free-form pull of the surface under the cursor. All | |
| // vertices within SCULPT_R of the grabbed point move with the drag, | |
| // weighted by a smooth falloff, so pulling a cheek stretches the face and | |
| // pulling an ear pushes it in/out. Offsets persist across mesh morphs. | |
| const SCULPT_R = 0.05; // brush radius (meters) | |
| function beginSculpt(hit, e) { | |
| const pos = geometry.getAttribute("position"); | |
| const gx = pos.getX(hit.vi), gy = pos.getY(hit.vi), gz = pos.getZ(hit.vi); | |
| const list = []; | |
| for (let v = 0; v < ZONES.length; v++) { | |
| const dx = pos.getX(v) - gx, dy = pos.getY(v) - gy, dz = pos.getZ(v) - gz; | |
| const dist = Math.sqrt(dx * dx + dy * dy + dz * dz); | |
| if (dist < SCULPT_R) { | |
| const t = 1 - dist / SCULPT_R; | |
| list.push({ i: v, w: t * t * (3 - 2 * t) }); // smoothstep falloff | |
| } | |
| } | |
| // Snapshot the current offsets so the drag is absolute (drag back undoes). | |
| const base = new Float32Array(list.length * 3); | |
| for (let n = 0; n < list.length; n++) { | |
| const j = list[n].i * 3; | |
| base[n * 3] = sculptOffsets[j]; | |
| base[n * 3 + 1] = sculptOffsets[j + 1]; | |
| base[n * 3 + 2] = sculptOffsets[j + 2]; | |
| } | |
| brush = { list, base }; | |
| startX = e.clientX; startY = e.clientY; | |
| mode = 'sculpt'; | |
| // Keep the zone highlight lit while sculpting a feature (ear/cheek); use | |
| // its label if it has one, else a generic sculpt hint. | |
| const sp = zoneSpec(hit.zone); | |
| if (SCULPT_ZONES[hit.zone]) setHighlight(hit.zone); else setHighlight(-1); | |
| showHint(sp.label || "🫳 Sculpt · drag to stretch / pull the surface"); | |
| } | |
| function beginManip(z, hit, e) { | |
| const base = zoneSpec(z); | |
| // Resolve any `outwardX` target to a concrete sign from the grab point: | |
| // grabbing the +x side and dragging +x (or -x side dragging -x) both | |
| // count as pulling "outward". World +x maps to screen-right in the | |
| // front view, so screen dx and world x share a direction here. | |
| const gx = (hit && hit.point) ? hit.point.x : 0; | |
| spec = { | |
| label: base.label, | |
| t: base.t.map((t) => { | |
| if (t.outwardX) return { ...t, sign: (gx >= 0 ? -1 : 1) }; // outward -> negative | |
| if (t.outwardXPos) return { ...t, sign: (gx >= 0 ? 1 : -1) }; // outward -> positive | |
| return t; | |
| }), | |
| }; | |
| startX = e.clientX; startY = e.clientY; startVals = {}; | |
| for (const t of spec.t) startVals[t.key] = getV(t.key); | |
| mode = 'manip'; | |
| setHighlight(z); | |
| glowZone(z); | |
| showHint(spec.label); | |
| } | |
| canvas.addEventListener("contextmenu", (e) => e.preventDefault()); | |
| canvas.addEventListener("pointerdown", (e) => { | |
| try { canvas.setPointerCapture(e.pointerId); } catch (err) {} | |
| const rightBtn = (e.button === 2); | |
| if (e.shiftKey && !rightBtn) { // Shift+drag = translate | |
| mode = 'trans'; | |
| spec = { t: [ {key: CFG.txKey, axis:'x', gain: TRANS_G, sign: +1}, | |
| {key: CFG.tyKey, axis:'y', gain: TRANS_G, sign: +1} ] }; | |
| startX = e.clientX; startY = e.clientY; startVals = {}; | |
| for (const t of spec.t) startVals[t.key] = getV(t.key); | |
| showHint("✚ Move head · drag to reposition"); | |
| e.preventDefault(); return; | |
| } | |
| const hit = rightBtn ? null : pick(e.clientX, e.clientY); | |
| if (hit && (e.altKey || e.metaKey || SCULPT_ZONES[hit.zone])) { | |
| beginSculpt(hit, e); // ear/cheek, or Alt+drag | |
| } else if (hit) { // grab the head itself | |
| beginManip(hit.zone, hit, e); | |
| } else { // empty space / RMB = orbit | |
| mode = 'orbit'; orbitLastX = e.clientX; orbitLastY = e.clientY; | |
| } | |
| e.preventDefault(); | |
| }); | |
| window.addEventListener("pointermove", (e) => { | |
| if (mode === 'orbit') { | |
| const dx = e.clientX - orbitLastX, dy = e.clientY - orbitLastY; | |
| orbitLastX = e.clientX; orbitLastY = e.clientY; | |
| camState.theta -= dx * 0.01; | |
| camState.phi = Math.max(0.05, Math.min(Math.PI - 0.05, | |
| camState.phi - dy * 0.01)); | |
| applyCamera(); | |
| return; | |
| } | |
| if (mode === 'manip' || mode === 'trans') { | |
| const dx = e.clientX - startX, dy = e.clientY - startY; | |
| // Coalesce to one apply per animation frame (caps server round-trips). | |
| pendingApply = () => { | |
| for (const t of spec.t) { | |
| const raw = (t.axis === 'x') ? dx : -dy; | |
| setV(t.key, startVals[t.key] + raw * t.gain * t.sign); | |
| } | |
| }; | |
| e.preventDefault(); | |
| return; | |
| } | |
| if (mode === 'sculpt') { | |
| const dx = e.clientX - startX, dy = e.clientY - startY; | |
| pendingApply = () => { | |
| if (!brush || !sculptOffsets) return; | |
| // Screen drag -> world pull in the camera plane (right = +x screen, | |
| // up = -y screen), so the grabbed point follows the cursor. | |
| camera.updateMatrixWorld(); | |
| const m = camera.matrixWorld.elements; | |
| const k = metersPerPixel() * SCULPT_G; | |
| const wx = (m[0] * dx - m[4] * dy) * k; | |
| const wy = (m[1] * dx - m[5] * dy) * k; | |
| const wz = (m[2] * dx - m[6] * dy) * k; | |
| const list = brush.list, base = brush.base; | |
| for (let n = 0; n < list.length; n++) { | |
| const j = list[n].i * 3, w = list[n].w; | |
| sculptOffsets[j] = base[n * 3] + w * wx; | |
| sculptOffsets[j + 1] = base[n * 3 + 1] + w * wy; | |
| sculptOffsets[j + 2] = base[n * 3 + 2] + w * wz; | |
| } | |
| applyBuffer(); | |
| updateClearBtn(); | |
| }; | |
| e.preventDefault(); | |
| return; | |
| } | |
| // Hovering (no button held): brighten the zone under the cursor and | |
| // glow the control(s) it drives. | |
| if (e.buttons === 0) { | |
| const hit = pick(e.clientX, e.clientY); | |
| if (hit) { | |
| showHint(zoneSpec(hit.zone).label); | |
| setHighlight(hit.zone); | |
| glowZone(hit.zone); | |
| canvas.style.cursor = "grab"; | |
| } else { | |
| showHint(""); setHighlight(-1); clearGlow(); | |
| canvas.style.cursor = "default"; | |
| } | |
| } | |
| }); | |
| function endDrag() { | |
| if (pendingApply) { pendingApply(); pendingApply = null; } | |
| mode = null; spec = null; startVals = null; brush = null; | |
| setHighlight(-1); showHint(""); clearGlow(); | |
| } | |
| window.addEventListener("pointerup", endDrag); | |
| window.addEventListener("pointercancel", endDrag); | |
| canvas.addEventListener("wheel", (e) => { | |
| e.preventDefault(); | |
| camState.radius = Math.max(0.2, Math.min(3.0, | |
| camState.radius * (1 + e.deltaY * 0.001))); | |
| applyCamera(); | |
| }, { passive: false }); | |
| // Slider-driving drags round-trip to the server per update; firing every | |
| // frame backs up the queue and the mesh lags behind the cursor. Throttle | |
| // them to send only the *latest* value at a sustainable rate (no backlog = | |
| // real-time feel). Client-side sculpt has no round-trip, so it stays at | |
| // full framerate. | |
| let lastSlideT = 0; | |
| const SLIDE_MS = 40; | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| if (pendingApply) { | |
| const throttled = (mode === 'manip' || mode === 'trans'); | |
| if (!throttled || (performance.now() - lastSlideT) >= SLIDE_MS) { | |
| pendingApply(); pendingApply = null; | |
| if (throttled) lastSlideT = performance.now(); | |
| } | |
| } | |
| renderer.render(scene, camera); | |
| } | |
| animate(); | |
| new ResizeObserver(() => { | |
| const w = wrap.clientWidth || W, h = wrap.clientHeight || H; | |
| camera.aspect = w / h; camera.updateProjectionMatrix(); | |
| renderer.setSize(w, h, false); | |
| }).observe(wrap); | |
| return true; | |
| } | |
| function updateFromValue() { | |
| const v = props.value; | |
| if (!v) return; | |
| let flat; | |
| try { flat = (typeof v === "string") ? JSON.parse(v) : v; } | |
| catch (e) { return; } | |
| if (flat && flat.length) setPositions(flat); | |
| } | |
| (function ready() { | |
| if (!window.THREE || !build()) { setTimeout(ready, 40); return; } | |
| updateFromValue(); | |
| })(); | |
| watch("value", updateFromValue); | |
| })(); | |
| """ | |
| VIEWER_JS_ON_LOAD = ( | |
| VIEWER_JS_ON_LOAD | |
| .replace("__FACES__", _FACES_JSON) | |
| .replace("__COLORS__", _COLORS_JSON) | |
| .replace("__ZONES__", ZONES_JSON) | |
| .replace("__CFG__", VIEWER_CFG_JSON) | |
| .replace("__NUM_IDENTITY__", str(NUM_IDENTITY)) | |
| ) | |
| # Hidden "sculpt bus": a zero-size custom component whose value carries the | |
| # free-pull payload. The viewer writes to it via window.__GNM.sculptSet during | |
| # an Alt-drag, which fires its .change() -> sculpt_pull -> identity sliders. | |
| SCULPT_BUS_JS = """ | |
| (function () { | |
| window.__GNM = window.__GNM || {}; | |
| window.__GNM.sculptSet = function (v) { props.value = v; }; | |
| // Gradio only mounts the *active* tab's contents, so sliders in the other | |
| // tabs never register on the __GNM bus and the 3D head can't drive them. | |
| // Mount every tab once (click through them), then return to the first — the | |
| // rendered panels stay in the DOM (hidden), so all sliders stay registered. | |
| (function mountTabs(tries) { | |
| const tabs = Array.from(document.querySelectorAll('button[role="tab"]')); | |
| if (tabs.length < 2) { | |
| if ((tries || 0) < 60) setTimeout(function () { mountTabs((tries || 0) + 1); }, 100); | |
| return; | |
| } | |
| let i = 0; | |
| (function step() { | |
| if (i < tabs.length) { tabs[i].click(); i++; setTimeout(step, 120); } | |
| else { tabs[0].click(); } | |
| })(); | |
| })(); | |
| })(); | |
| """ | |
| # --------------------------------------------------------------------------- | |
| # UI | |
| # --------------------------------------------------------------------------- | |
| CSS = """ | |
| #col-container { max-width: 1200px; margin: 0 auto; } | |
| .dark .gradio-container { color: var(--body-text-color); } | |
| /* Glow applied (from the 3D viewer) to the slider(s) / tab a hovered head | |
| region drives, so the region <-> control correspondence is visible. */ | |
| .gnm-ctl-glow { | |
| outline: 2px solid var(--color-accent, #ff7c00) !important; | |
| outline-offset: 1px; | |
| border-radius: 6px; | |
| box-shadow: 0 0 10px 1px var(--color-accent, #ff7c00); | |
| transition: box-shadow .1s, outline-color .1s; | |
| } | |
| /* All slider groups are always expanded (they must stay mounted so the 3D | |
| viewer can drive every one of them). To keep the page short and the head | |
| always visible beside them, the controls column scrolls *internally* and | |
| the viewer stays pinned next to it. */ | |
| #gnm-main-row { align-items: flex-start !important; } | |
| #gnm-controls-col { | |
| max-height: calc(100vh - 24px); | |
| overflow-y: auto; | |
| padding-right: 6px; | |
| } | |
| #gnm-viewer-col { | |
| position: sticky; | |
| top: 8px; | |
| } | |
| /* Compact grouped sections. Each feature/parameter group (Head Shape, Left | |
| Eye, Mouth, Neck, Gaze, Translation, ...) is rendered as its own bordered | |
| section with a header row (group label + per-group Reset) and its sliders | |
| laid out side-by-side underneath (each slider is now a custom VERTICAL | |
| slider, so several fit in one row instead of being stacked). */ | |
| .gnm-group { | |
| border: 1px solid var(--border-color-primary); | |
| border-radius: 8px; | |
| padding: 8px 10px 8px 10px; | |
| margin-bottom: 10px; | |
| background: var(--block-background-fill); | |
| } | |
| .gnm-group-header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| margin-bottom: 0px; | |
| gap: 8px; | |
| } | |
| /* Collapse the residual gap the Gradio column inserts between the header row | |
| and the sliders row (tightening the title-to-sliders vertical space further | |
| than the previous pass). */ | |
| .gnm-group > .gnm-group-header { margin-bottom: -6px !important; } | |
| .gnm-group > .gnm-sliders { margin-top: -6px !important; } | |
| .gnm-group-title { | |
| font-weight: 600; | |
| font-size: 0.95rem; | |
| margin: 0; | |
| } | |
| /* Sliders row: wrap the vertical sliders side-by-side within the group. */ | |
| .gnm-sliders { | |
| flex-wrap: wrap !important; | |
| gap: 0px !important; | |
| align-items: flex-start !important; | |
| } | |
| /* Each vertical slider is a narrow HTML block; let it size to its content | |
| rather than expanding to fill the row. */ | |
| .gnm-vslider { | |
| flex: 0 0 auto !important; | |
| min-width: 0 !important; | |
| width: auto !important; | |
| border: none !important; | |
| padding: 0 !important; | |
| background: transparent !important; | |
| } | |
| /* Small, unobtrusive per-group reset button. */ | |
| .gnm-reset-btn { | |
| flex: 0 0 auto !important; | |
| min-width: 0 !important; | |
| width: auto !important; | |
| padding: 2px 10px !important; | |
| font-size: 0.8rem !important; | |
| line-height: 1.4 !important; | |
| } | |
| /* --- Custom vertical slider look (matches native Gradio slider styling) --- */ | |
| .vslider-root { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| width: 50px; | |
| padding: 0px 0px 2px 0px; | |
| user-select: none; | |
| -webkit-user-select: none; | |
| box-sizing: border-box; | |
| } | |
| .vslider-label { | |
| font-size: 0.72rem; | |
| line-height: 1.05; | |
| text-align: center; | |
| color: var(--body-text-color); | |
| min-height: 2.1em; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| margin-bottom: 4px; | |
| overflow-wrap: anywhere; | |
| } | |
| .vslider-value { | |
| font-size: 0.72rem; | |
| font-variant-numeric: tabular-nums; | |
| color: var(--body-text-color-subdued); | |
| margin-bottom: 4px; | |
| } | |
| .vslider-track { | |
| position: relative; | |
| width: 6px; | |
| height: 150px; | |
| border-radius: 3px; | |
| background: var(--slider-color, var(--neutral-200, #d1d5db)); | |
| cursor: pointer; | |
| touch-action: none; | |
| } | |
| .dark .vslider-track { | |
| background: var(--neutral-600, #4b5563); | |
| } | |
| /* Filled portion (from bottom up to the handle). */ | |
| .vslider-fill { | |
| position: absolute; | |
| left: 0; | |
| bottom: 0; | |
| width: 100%; | |
| border-radius: 3px; | |
| background: var(--slider-color, var(--color-accent, #ff7c00)); | |
| } | |
| .vslider-handle { | |
| position: absolute; | |
| left: 50%; | |
| width: 18px; | |
| height: 18px; | |
| border-radius: 50%; | |
| background: var(--color-accent, #ff7c00); | |
| border: 2px solid var(--background-fill-primary, #fff); | |
| box-shadow: 0 1px 3px rgba(0,0,0,0.3); | |
| transform: translate(-50%, 50%); | |
| cursor: grab; | |
| } | |
| .vslider-handle:active { cursor: grabbing; } | |
| .vslider-minmax { | |
| font-size: 0.62rem; | |
| color: var(--body-text-color-subdued); | |
| margin-top: 3px; | |
| } | |
| /* --- Keyword-expression preset control --------------------------------- | |
| Restyle the (native Gradio) preset dropdown + strength slider so they | |
| match the look of the custom vertical sliders / group panels: same | |
| compact fonts, subdued labels, thin accent-colored track, tight spacing | |
| and matching borders — instead of the default bulky Gradio widget look. | |
| The Apply button already reuses the `.gnm-reset-btn` class, so it renders | |
| identically to the per-group ↺ Reset buttons. */ | |
| .gnm-preset-row { | |
| gap: 8px !important; | |
| align-items: flex-end !important; | |
| margin-top: 2px; | |
| } | |
| /* Strip the default Gradio block chrome (border/background/padding) from the | |
| dropdown and slider so they sit flush inside the group panel like the | |
| vertical sliders do. */ | |
| .gnm-preset-dropdown, | |
| .gnm-preset-strength { | |
| border: none !important; | |
| background: transparent !important; | |
| padding: 0 !important; | |
| box-shadow: none !important; | |
| min-width: 0 !important; | |
| } | |
| /* Compact, subdued field labels to match `.vslider-label`. */ | |
| .gnm-preset-dropdown label span:not(.token-remove), | |
| .gnm-preset-strength label span { | |
| font-size: 0.72rem !important; | |
| font-weight: 600 !important; | |
| color: var(--body-text-color) !important; | |
| margin-bottom: 4px !important; | |
| } | |
| /* Dropdown box: thin border + rounded corners matching the group panels. */ | |
| .gnm-preset-dropdown .wrap, | |
| .gnm-preset-dropdown input { | |
| font-size: 0.72rem !important; | |
| min-height: 0 !important; | |
| } | |
| .gnm-preset-dropdown .wrap { | |
| border: 1px solid var(--border-color-primary) !important; | |
| border-radius: 6px !important; | |
| background: var(--block-background-fill) !important; | |
| padding: 2px 4px !important; | |
| } | |
| /* Selected-token chips use the accent color the sliders/handles use. */ | |
| .gnm-preset-dropdown .token { | |
| font-size: 0.68rem !important; | |
| background: var(--color-accent, #ff7c00) !important; | |
| color: #fff !important; | |
| border-radius: 4px !important; | |
| padding: 1px 6px !important; | |
| } | |
| /* Strength slider: thin accent-colored track like `.vslider-track`/`fill`. */ | |
| .gnm-preset-strength input[type="range"] { | |
| accent-color: var(--color-accent, #ff7c00) !important; | |
| height: 6px !important; | |
| } | |
| .gnm-preset-strength .head, | |
| .gnm-preset-strength .tab-like, | |
| .gnm-preset-strength input[type="number"] { | |
| font-size: 0.72rem !important; | |
| font-variant-numeric: tabular-nums !important; | |
| color: var(--body-text-color-subdued) !important; | |
| } | |
| """ | |
| INTRO = """ | |
| # 🧬 GNM — Direct-Manipulation Head | |
| **GNM Head** is a state-of-the-art parametric 3D statistical model of the human head from [google/GNM](https://github.com/google/GNM), with disentangled control over **identity**, **expression**, **pose** and **translation**. Move the sliders, or **grab the 3D head directly** — hover any part to see what it does. Based on the [notebook demo](https://github.com/google/GNM/blob/main/gnm/shape/README.md#demo). | |
| """ | |
| # --------------------------------------------------------------------------- | |
| # Custom VERTICAL slider component. | |
| # | |
| # gr.Slider only renders horizontally, so we build a drop-in vertical slider as | |
| # an interactive custom HTML component (gr.HTML with html_template + js_on_load). | |
| # It behaves like a native slider — it has a label, honours min/max/step, has a | |
| # draggable handle and a live numeric value readout — but it is oriented | |
| # vertically so several sliders sit side-by-side within a group. Its component | |
| # value is the plain numeric slider value, so it plugs straight into the same | |
| # `.change()` / reset / Examples wiring the native sliders used. | |
| # | |
| # html_template is a *static* markup skeleton (it does NOT reference ${value}, | |
| # so Gradio keeps the same DOM element across value updates); js_on_load wires | |
| # the drag interaction (writes `props.value` -> fires `.change()`) and a | |
| # `watch('value')` callback that repaints the handle/readout when the value is | |
| # set externally (reset buttons, Examples, initial load). | |
| # --------------------------------------------------------------------------- | |
| VSLIDER_TEMPLATE = ( | |
| '<div class="vslider-root">' | |
| '<div class="vslider-label">__LABEL__</div>' | |
| '<div class="vslider-value">0</div>' | |
| '<div class="vslider-track">' | |
| '<div class="vslider-fill"></div>' | |
| '<div class="vslider-handle"></div>' | |
| "</div>" | |
| '<div class="vslider-minmax">__MIN__ … __MAX__</div>' | |
| "</div>" | |
| ) | |
| VSLIDER_JS_ON_LOAD = """ | |
| (function () { | |
| const MIN = __MIN__, MAX = __MAX__, STEP = __STEP__; | |
| const KEY = "__KEY__"; | |
| const root = element.querySelector(".vslider-root"); | |
| const track = element.querySelector(".vslider-track"); | |
| const fill = element.querySelector(".vslider-fill"); | |
| const handle = element.querySelector(".vslider-handle"); | |
| const valEl = element.querySelector(".vslider-value"); | |
| if (!root || !track || !handle) return; | |
| function decimals() { | |
| const s = String(STEP); | |
| const i = s.indexOf("."); | |
| return i < 0 ? 0 : (s.length - i - 1); | |
| } | |
| const DEC = decimals(); | |
| function clampSnap(v) { | |
| if (STEP > 0) v = Math.round((v - MIN) / STEP) * STEP + MIN; | |
| v = Math.min(MAX, Math.max(MIN, v)); | |
| // Kill floating-point fuzz from the snap. | |
| return parseFloat(v.toFixed(Math.max(DEC, 6))); | |
| } | |
| function fmt(v) { return Number(v).toFixed(DEC); } | |
| // Paint the handle / fill / readout for a value WITHOUT firing change. | |
| function paint(v) { | |
| v = Math.min(MAX, Math.max(MIN, Number(v))); | |
| const frac = (MAX > MIN) ? (v - MIN) / (MAX - MIN) : 0; // 0=bottom,1=top | |
| const h = track.clientHeight; | |
| handle.style.bottom = (frac * h) + "px"; | |
| fill.style.height = (frac * h) + "px"; | |
| if (valEl) valEl.textContent = fmt(clampSnap(v)); | |
| } | |
| function currentValue() { | |
| let v = props.value; | |
| if (v === null || v === undefined || v === "") return MIN < 0 && MAX > 0 ? 0 : MIN; | |
| v = parseFloat(v); | |
| if (isNaN(v)) return MIN < 0 && MAX > 0 ? 0 : MIN; | |
| return v; | |
| } | |
| // Map a pointer Y position to a value (top of track = MAX, bottom = MIN). | |
| function valueFromEvent(clientY) { | |
| const rect = track.getBoundingClientRect(); | |
| let frac = (rect.bottom - clientY) / rect.height; | |
| frac = Math.min(1, Math.max(0, frac)); | |
| return clampSnap(MIN + frac * (MAX - MIN)); | |
| } | |
| let dragging = false; | |
| function setFromPointer(clientY) { | |
| const nv = valueFromEvent(clientY); | |
| paint(nv); | |
| if (nv !== parseFloat(props.value)) { | |
| props.value = nv; // fires .change() -> recompute mesh | |
| } | |
| } | |
| track.addEventListener("pointerdown", (e) => { | |
| dragging = true; | |
| try { track.setPointerCapture(e.pointerId); } catch (err) {} | |
| setFromPointer(e.clientY); | |
| e.preventDefault(); | |
| }); | |
| track.addEventListener("pointermove", (e) => { | |
| if (!dragging) return; | |
| setFromPointer(e.clientY); | |
| e.preventDefault(); | |
| }); | |
| function endDrag(e) { | |
| if (!dragging) return; | |
| dragging = false; | |
| try { track.releasePointerCapture(e.pointerId); } catch (err) {} | |
| } | |
| track.addEventListener("pointerup", endDrag); | |
| track.addEventListener("pointercancel", endDrag); | |
| // Repaint when the value is set from Python (reset / Examples / load). | |
| watch("value", () => paint(currentValue())); | |
| // Initial paint (handle geometry needs the track laid out first). | |
| (function ready() { | |
| if (!track.clientHeight) { setTimeout(ready, 30); return; } | |
| paint(currentValue()); | |
| })(); | |
| // Repaint when the track gains size — e.g. when this slider's tab becomes | |
| // visible after having been driven while hidden (its handle would otherwise | |
| // be stuck at the wrong position). Guarded so a zero-size (hidden) pass is | |
| // ignored. | |
| if (window.ResizeObserver) { | |
| new ResizeObserver(() => { | |
| if (track.clientHeight) paint(currentValue()); | |
| }).observe(track); | |
| } | |
| // Expose this slider on a global registry so the 3D viewer can drive it | |
| // when the user drags directly on the head. `set(v)` snaps/paints the value | |
| // and writes props.value, which fires the same .change() a manual drag does | |
| // (so the mesh recomputes through the exact same reactive path). | |
| if (KEY) { | |
| window.__GNM = window.__GNM || {}; | |
| window.__GNM.reg = window.__GNM.reg || {}; | |
| window.__GNM.reg[KEY] = { | |
| set(v) { | |
| const nv = clampSnap(v); | |
| paint(nv); | |
| if (nv !== parseFloat(props.value)) props.value = nv; | |
| return nv; | |
| }, | |
| get() { return currentValue(); }, | |
| min: MIN, max: MAX, step: STEP, | |
| el: root, // so the viewer can glow this slider when its region is hovered | |
| }; | |
| } | |
| })(); | |
| """ | |
| def _js_num(x): | |
| """Format a Python number as a JS numeric literal.""" | |
| return repr(float(x)) | |
| def _vslider(label, minimum, maximum, value=0.0, step=None, key=None): | |
| """Create a custom vertical slider (drop-in replacement for gr.Slider). | |
| Returns a gr.HTML component whose value is the numeric slider value. The | |
| per-instance label / min / max / step are baked into its template and JS. | |
| If `key` is given, the slider registers itself under that key in the | |
| global `window.__GNM.reg` so the 3D viewer can drive it via direct | |
| manipulation on the head. | |
| """ | |
| step_val = step if step is not None else 0.0 | |
| tmpl = ( | |
| VSLIDER_TEMPLATE | |
| .replace("__LABEL__", str(label)) | |
| .replace("__MIN__", _fmt_bound(minimum)) | |
| .replace("__MAX__", _fmt_bound(maximum)) | |
| ) | |
| js = ( | |
| VSLIDER_JS_ON_LOAD | |
| .replace("__MIN__", _js_num(minimum)) | |
| .replace("__MAX__", _js_num(maximum)) | |
| .replace("__STEP__", _js_num(step_val)) | |
| .replace("__KEY__", str(key) if key is not None else "") | |
| ) | |
| return gr.HTML( | |
| value=float(value), | |
| html_template=tmpl, | |
| js_on_load=js, | |
| elem_classes="gnm-vslider", | |
| container=False, | |
| ) | |
| def _fmt_bound(x): | |
| """Pretty-print a min/max bound for the template (drop trailing .0).""" | |
| xf = float(x) | |
| return str(int(xf)) if xf == int(xf) else str(xf) | |
| # Backwards-compatible alias: everything downstream builds sliders via _slider. | |
| _slider = _vslider | |
| # Collects (reset_button, [sliders]) pairs so each group's reset is wired to | |
| # only its own sliders after the full slider list exists. | |
| GROUP_RESETS = [] | |
| def _group_section(title, build_sliders): | |
| """Render one feature/parameter group as a compact, bordered section. | |
| The section has a header (group label + a per-group ↺ Reset button) and its | |
| (vertical) sliders laid out side-by-side underneath. `build_sliders` is a zero-arg | |
| callable that creates and returns the group's list of sliders (in order). | |
| The per-group reset button is registered in GROUP_RESETS and wired later to | |
| reset only this group's sliders back to their defaults. | |
| """ | |
| with gr.Column(elem_classes="gnm-group"): | |
| with gr.Row(elem_classes="gnm-group-header"): | |
| gr.HTML(f'<span class="gnm-group-title">{title}</span>') | |
| reset = gr.Button( | |
| "↺ Reset", variant="secondary", size="sm", | |
| elem_classes="gnm-reset-btn", scale=0, min_width=0, | |
| ) | |
| with gr.Row(elem_classes="gnm-sliders"): | |
| group_sliders = build_sliders() | |
| GROUP_RESETS.append((reset, group_sliders)) | |
| return group_sliders | |
| with gr.Blocks(title="GNM Head") as demo: | |
| with gr.Column(elem_id="col-container"): | |
| gr.Markdown(INTRO) | |
| with gr.Row(elem_id="gnm-main-row"): | |
| # --- Controls ------------------------------------------------- | |
| with gr.Column(scale=3, elem_id="gnm-controls-col"): | |
| identity_sliders = [] | |
| expression_sliders = [] | |
| pose_sliders = [] | |
| translation_sliders = [] | |
| with gr.Tabs(): | |
| with gr.Tab("Identity"): | |
| gr.Markdown( | |
| "First 10 components of the linear **identity** " | |
| "basis (head shape). Typical range −3 … +3." | |
| ) | |
| def _build_head_shape(): | |
| sliders = [] | |
| for k in range(NUM_IDENTITY): | |
| sliders.append( | |
| _slider( | |
| f"Head shape {k + 1}", | |
| -3.0, 3.0, 0.0, 0.05, | |
| key=f"id_{k}", | |
| ) | |
| ) | |
| return sliders | |
| identity_sliders = _group_section( | |
| "Head Shape", _build_head_shape | |
| ) | |
| with gr.Tab("Expression"): | |
| gr.Markdown( | |
| "Selected **expression** blendshape components " | |
| "per region. Typical range −3 … +3." | |
| ) | |
| # --- Categorical (keyword) expression presets ------- | |
| # A dropdown of named expressions (happy, surprise, | |
| # wink, ...) sampled from the GNM semantic CVAE. Picking | |
| # one (or several, to blend) and hitting Apply sets the | |
| # expression sliders below toward that expression and | |
| # morphs the mesh live. It only *sets* slider values — | |
| # you can keep tweaking each slider by hand afterwards. | |
| expr_preset_dropdown = None | |
| expr_preset_strength = None | |
| expr_preset_apply = None | |
| if EXPRESSION_PRESET_LABELS: | |
| # Styled to match the rest of the custom UI: the | |
| # whole control sits in a `gnm-group` bordered panel | |
| # with a `gnm-group-title` header (like every other | |
| # group), the dropdown + strength slider carry | |
| # `gnm-preset-*` classes so scoped CSS gives them the | |
| # same compact fonts/borders/colors as the custom | |
| # vertical sliders, and the Apply button is built | |
| # exactly like the per-group `↺ Reset` buttons. | |
| with gr.Column(elem_classes="gnm-group"): | |
| gr.HTML( | |
| '<span class="gnm-group-title">' | |
| "Keyword expressions</span>" | |
| ) | |
| with gr.Row( | |
| elem_classes="gnm-preset-row", | |
| equal_height=True, | |
| ): | |
| expr_preset_dropdown = gr.Dropdown( | |
| choices=EXPRESSION_PRESET_LABELS, | |
| value=["Happy"], | |
| label="Expression preset(s)", | |
| multiselect=True, | |
| scale=3, | |
| elem_classes="gnm-preset-dropdown", | |
| ) | |
| expr_preset_strength = gr.Slider( | |
| minimum=0.0, maximum=1.0, value=1.0, | |
| step=0.05, label="Strength", scale=2, | |
| elem_classes="gnm-preset-strength", | |
| ) | |
| expr_preset_apply = gr.Button( | |
| "Apply expression", | |
| variant="secondary", size="sm", | |
| elem_classes="gnm-reset-btn", | |
| scale=0, min_width=0, | |
| ) | |
| def _make_expr_builder(group_name, group_idx): | |
| start = _EXPR_SLOT_START[group_name] | |
| def _build_expr(): | |
| sliders = [] | |
| for j in range(len(group_idx)): | |
| # Single-component groups (e.g. pupil | |
| # dilation) don't need a component number. | |
| label = ( | |
| group_name | |
| if len(group_idx) == 1 | |
| else f"{group_name} {j + 1}" | |
| ) | |
| sliders.append( | |
| _slider( | |
| label, -3.0, 3.0, 0.0, 0.05, | |
| key=f"ex_{start + j}", | |
| ) | |
| ) | |
| return sliders | |
| return _build_expr | |
| # Place the "Left eye" and "Right eye" groups side by | |
| # side in a single row (equal width). | |
| _eye_groups = ["Left eye", "Right eye"] | |
| with gr.Row(): | |
| for group_name in _eye_groups: | |
| group_idx = EXPRESSION_GROUPS[group_name] | |
| with gr.Column(min_width=0): | |
| expression_sliders += _group_section( | |
| group_name, | |
| _make_expr_builder( | |
| group_name, group_idx | |
| ), | |
| ) | |
| # "Tongue" and "Pupil dilation" also share a single row, | |
| # but with an unequal split: Tongue gets 2/3 of the | |
| # width, Pupil dilation gets 1/3. | |
| _paired_groups = ["Tongue", "Pupil dilation"] | |
| _paired_scales = {"Tongue": 2, "Pupil dilation": 1} | |
| # Render the remaining stacked groups (Mouth) as before, | |
| # skipping the eye-row and paired-row groups. | |
| for group_name, group_idx in EXPRESSION_GROUPS.items(): | |
| if group_name in _eye_groups or group_name in _paired_groups: | |
| continue | |
| expression_sliders += _group_section( | |
| group_name, | |
| _make_expr_builder(group_name, group_idx), | |
| ) | |
| with gr.Row(): | |
| for group_name in _paired_groups: | |
| group_idx = EXPRESSION_GROUPS[group_name] | |
| with gr.Column( | |
| scale=_paired_scales[group_name], | |
| min_width=0, | |
| ): | |
| expression_sliders += _group_section( | |
| group_name, | |
| _make_expr_builder( | |
| group_name, group_idx | |
| ), | |
| ) | |
| with gr.Tab("Pose"): | |
| gr.Markdown( | |
| "Joint **rotations** in degrees (neck / head) and " | |
| "**gaze** direction (both eyes locked, with a " | |
| "vergence control)." | |
| ) | |
| def _make_pose_builder(limits, start): | |
| def _build_pose(): | |
| sliders = [] | |
| for i, (name, limit) in enumerate( | |
| limits.items() | |
| ): | |
| sliders.append( | |
| _slider( | |
| name, -limit, limit, 0, 1, | |
| key=f"pose_{start + i}", | |
| ) | |
| ) | |
| return sliders | |
| return _build_pose | |
| # Place the "Neck" and "Head" groups side by side in a | |
| # single row (equal width), mirroring the Left/Right eye | |
| # layout. "Gaze" stays as its own stacked group. | |
| _paired_pose = ["neck", "head"] | |
| with gr.Row(): | |
| for joint in _paired_pose: | |
| limits = POSE_LIMITS[joint] | |
| with gr.Column(min_width=0): | |
| pose_sliders += _group_section( | |
| joint.capitalize(), | |
| _make_pose_builder( | |
| limits, _POSE_SLOT_START[joint] | |
| ), | |
| ) | |
| for joint, limits in POSE_LIMITS.items(): | |
| if joint in _paired_pose: | |
| continue | |
| pose_sliders += _group_section( | |
| joint.capitalize(), | |
| _make_pose_builder( | |
| limits, _POSE_SLOT_START[joint] | |
| ), | |
| ) | |
| with gr.Tab("Translation"): | |
| gr.Markdown("Global **translation** of the whole head (meters).") | |
| def _build_translation(): | |
| sliders = [] | |
| for i, d in enumerate([ | |
| "X (left/right)", | |
| "Y (up/down)", | |
| "Z (forward/back)", | |
| ]): | |
| sliders.append( | |
| _slider( | |
| d, -0.2, 0.2, 0.0, 0.01, | |
| key=f"tr_{i}", | |
| ) | |
| ) | |
| return sliders | |
| translation_sliders = _group_section( | |
| "Translation", _build_translation | |
| ) | |
| # --- Viewer --------------------------------------------------- | |
| # The custom Three.js viewer IS the output component. Its value is | |
| # the flat vertex-position array; `watch('value')` in the viewer's | |
| # js_on_load morphs the persistent mesh in place on every update. | |
| with gr.Column(scale=2, elem_id="gnm-viewer-col"): | |
| viewer = gr.HTML( | |
| value="", | |
| html_template=VIEWER_TEMPLATE, | |
| js_on_load=VIEWER_JS_ON_LOAD, | |
| head=VIEWER_HEAD, | |
| label="GNM head mesh", | |
| elem_id="gnm-viewer", | |
| ) | |
| # Hidden channel for the Alt-drag sculpt payload. | |
| sculpt_bus = gr.HTML( | |
| value="", | |
| html_template='<div style="display:none"></div>', | |
| js_on_load=SCULPT_BUS_JS, | |
| container=False, | |
| elem_id="gnm-sculpt-bus", | |
| ) | |
| all_sliders = ( | |
| identity_sliders | |
| + expression_sliders | |
| + pose_sliders | |
| + translation_sliders | |
| ) | |
| # Reactive: any slider change recomputes the vertices in Python and | |
| # sets the viewer's value to the new flat position array. The viewer | |
| # writes it straight into the existing geometry buffer — the mesh | |
| # morphs in place, the camera is untouched, and nothing is reloaded or | |
| # remounted (no camera reset, no blink). | |
| for s in all_sliders: | |
| s.change( | |
| fn=positions_json, | |
| inputs=all_sliders, | |
| outputs=viewer, | |
| show_progress="hidden", | |
| queue=False, # run off the queue -> lower latency for live drags | |
| ) | |
| # Sculpt (Alt-drag): the viewer writes a free-pull payload into the | |
| # hidden sculpt bus; here we solve the identity sliders that move the | |
| # grabbed vertex toward the drag, set them (which repaints the Head | |
| # Shape sliders), then re-render the mesh. | |
| sculpt_bus.change( | |
| fn=sculpt_pull, | |
| inputs=[sculpt_bus] + all_sliders, | |
| outputs=identity_sliders, | |
| show_progress="hidden", | |
| ).then( | |
| fn=positions_json, inputs=all_sliders, outputs=viewer, | |
| show_progress="hidden", | |
| ) | |
| # Per-group Reset: each group has its own ↺ Reset button that resets | |
| # ONLY that group's sliders back to their defaults, then recomputes the | |
| # mesh from the full (partially-reset) slider vector. There is no | |
| # single global reset. | |
| def _make_group_reset(sliders): | |
| defaults = [s.value for s in sliders] | |
| def _reset_group(): | |
| return defaults if len(defaults) != 1 else defaults[0] | |
| return _reset_group | |
| for reset_btn, group_sliders in GROUP_RESETS: | |
| reset_btn.click( | |
| _make_group_reset(group_sliders), | |
| inputs=None, | |
| outputs=group_sliders, | |
| ).then( | |
| fn=positions_json, inputs=all_sliders, outputs=viewer, | |
| show_progress="hidden", | |
| ) | |
| # Keyword-expression presets: Apply samples/blends the selected named | |
| # expression(s) from the CVAE and sets ONLY the expression sliders | |
| # toward it (by the chosen strength), then morphs the mesh. Because it | |
| # writes into the same expression slider components, per-slider manual | |
| # editing continues to work exactly as before. | |
| if expr_preset_apply is not None: | |
| expr_preset_apply.click( | |
| fn=apply_expression_preset, | |
| inputs=[expr_preset_dropdown, expr_preset_strength] | |
| + all_sliders, | |
| outputs=expression_sliders, | |
| show_progress="hidden", | |
| ).then( | |
| fn=positions_json, inputs=all_sliders, outputs=viewer, | |
| show_progress="hidden", | |
| ) | |
| # Presets: full rows (every slider gets a concrete value, no None). | |
| # Offsets into the flat slider vector: | |
| _EX0 = NUM_IDENTITY # expression start | |
| _PO0 = NUM_IDENTITY + NUM_EXPRESSION # pose start | |
| _TR0 = NUM_IDENTITY + NUM_EXPRESSION + NUM_POSE # translation start | |
| def _preset(identity=None, expression=None, pose=None, translation=None): | |
| row = [0.0] * TOTAL_SLIDERS | |
| for i, v in (identity or {}).items(): | |
| row[i] = v | |
| for i, v in (expression or {}).items(): | |
| row[_EX0 + i] = v | |
| for i, v in (pose or {}).items(): | |
| row[_PO0 + i] = v | |
| for i, v in (translation or {}).items(): | |
| row[_TR0 + i] = v | |
| return row | |
| # Expression slider slots (flat order): left eye 0-2, right eye 3-5, | |
| # mouth 6-12, tongue 13-16 (13=tongue_mean, 14-16=tongue_000..002), | |
| # pupil dilation 17. Expression sliders are clamped to [-3, 3]. | |
| # Measured directions on this model: | |
| # mouth slot 6 negative -> jaw drops / mouth opens | |
| # tongue slot 14 positive + tongue_mean slot 13 negative -> tongue out | |
| # eye slot 0 (left) / slot 3 (right): positive -> eye wide, negative -> closed | |
| presets = [ | |
| # 1) Open mouth with the tongue sticking out. | |
| _preset(expression={6: -3.0, 13: -3.0, 14: 3.0}), | |
| # 2) Surprised: eyes wide open + mouth dropped open. | |
| _preset(expression={0: 3.0, 3: 3.0, 6: -3.0}), | |
| # 3) Wink: left eye closed, right eye left neutral. | |
| _preset(expression={0: -3.0}), | |
| ] | |
| # Per-example captions so each preset is clearly identified in the UI | |
| # (otherwise gr.Examples renders as a bare unlabeled table of raw slider | |
| # values). `example_labels` shows a friendly caption per example row. | |
| preset_labels = [ | |
| "Open mouth + tongue out", | |
| "Surprised", | |
| "Wink", | |
| ] | |
| gr.Examples( | |
| label="Presets (click to load)", | |
| examples=presets, | |
| example_labels=preset_labels, | |
| inputs=all_sliders, | |
| outputs=viewer, | |
| fn=positions_json, | |
| cache_examples=True, | |
| cache_mode="lazy", | |
| ) | |
| # On load: render the neutral face. The viewer's js_on_load has already | |
| # built the scene/camera; setting the value here draws the first mesh | |
| # (and every subsequent slider change morphs it in place). | |
| demo.load( | |
| fn=positions_json, inputs=all_sliders, outputs=viewer, | |
| show_progress="hidden", | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch(mcp_server=True, theme=gr.themes.Citrus(), css=CSS) | |