GNM Head Studio — a parametric face rig for ComfyUI, and why the clay head isn't the point
Google released GNM (the Generative aNthropometric Model) — a parametric 3D head: you feed it identity, expression, pose and translation vectors and it spits out a mesh (17,821 vertices / 35,324 triangles). I wanted it as a ComfyUI node so I could drive ControlNet with exact head geometry, and eventually as a browser tool.

Most of the work wasn't the model. It was getting it to live next to ComfyUI without detonating either one. These are the notes.
The wall: "GNM needs TensorFlow"
Every reference to running GNM points you at TensorFlow. The official demos, the community ComfyUI pack (rethink-studios/ComfyUI-GNM), the identity/expression samplers — all TF. My first attempt died on exactly this, and I wrote it off as "GNM render needs TensorFlow, and TF doesn't fit in ComfyUI."
That belief was wrong, but the pain behind it is real.
Why TensorFlow and ComfyUI don't want to share a venv
ComfyUI is a PyTorch app. TensorFlow and PyTorch are two of the heaviest, most opinionated packages in the ecosystem, and dropping TF into a working ComfyUI venv breaks things in ways that are miserable to debug:
- protobuf. TF pins a narrow protobuf range. ComfyUI's ecosystem (onnx, ReActor, insightface, etc.) sits on a different protobuf (my install is on 5.29.6). Whichever one loses gets downgraded/upgraded, and something silently stops importing. protobuf version skew is the classic "why did my nodes disappear" bug.
- numpy ABI. TF wheels are built against a specific numpy ABI. GNM itself requires numpy ≥ 2 (it calls
np.linalg.cross). Some TF builds still want numpy < 2. You end up in an unsatisfiable corner. - CUDA / cuDNN duplication. Both torch and TF vendor their own CUDA libraries. Load both into one process and you can get symbol clashes, cuDNN version fights, or just a doubled VRAM/host footprint.
- Two frameworks, one GPU, one process. Even when it "installs," having TF and torch co-resident in the ComfyUI worker is fragile.
So the instinct — pip install tensorflow into the ComfyUI venv — is exactly the thing that wrecks the ComfyUI venv.
The actual fix: don't install TensorFlow at all
The key realization: GNM's shape model is backend-agnostic. The official github.com/google/GNM (package gnm.shape) is built on etils.enp and ships NumPy, JAX, PyTorch and TF subclasses. gnm_numpy and gnm_pytorch import zero TensorFlow. The model loads from gnm_head.npz (plain numpy arrays), not from the .h5 Keras files.
Which means the whole mesh pipeline — identity/expression blendshapes, the joint rig, linear-blend skinning — is just linear algebra. It runs in numpy/torch and pulls no protobuf, so it coexists with the main ComfyUI install untouched.
Install trick
# install the shape package WITHOUT its dependency list (which drags in TF)
pip install -e ./GNM-official/gnm/shape --no-deps
# then hand-install the base deps MINUS tensorflow
# numpy>=2 is REQUIRED (np.linalg.cross), plus etils, scipy, etc.
--no-deps is the whole game: it stops pip from resolving GNM's requirements.txt, which is what would otherwise pull TF back in.
Three tiny source patches to import TF-free
A couple of modules still import tensorflow at the top even though the render path never uses it. Three small edits make the import clean:
visualization/camera_conversions.py— wrapimport tensorflow as tfin a try/except with a local_TFStub. Only the unused*_tfhelpers need it.visualization/gnm_pyrender.py—PYOPENGL_PLATFORM = 'osmesa'→setdefault(..., 'egl')(GPU headless).visualization/render_gnm.py—epath.resource_path(_pkg).parent/...→epath.resource_path('gnm.shape')/'data'/'textures'(aMultiplexedPathhas no.parent).
The renderer detour: pyrender → nvdiffrast
GNM bundles a pyrender path. It fights numpy 2.x: PyOpenGL's glGenTextures throws "No array-type handler" under numpy ≥ 2, and since both GNM and ComfyUI need numpy 2, downgrading isn't an option. pyrender is simply the wrong renderer here.
I switched the node to nvdiffrast (torch/CUDA, numpy-agnostic — the same rasterizer ComfyUI-3D-Pack uses). Headless RasterizeCudaContext, feed it GNM's verts/tris/UVs, do look-at + perspective → clip space, interpolate normals, clay shade, dr.antialias, flip NDC y. Six views render in ~0.7s.
pip install --no-build-isolation -e ./nvdiffrast-src

Isolation: keep the main ComfyUI pristine
Even with the TF-free path, I didn't want GNM's deps mingling with ComfyUI's. So the node runs GNM in a separate venv (venv-tf-free, torch 2.6.0+cu124) and the ComfyUI node bridges to it by subprocess. The main ComfyUI .venv never changes — protobuf 5.29.6 stays exactly where it was.
To make it fast, a small warm server (stdlib HTTP) keeps the model + nvdiffrast context resident and answers render requests over a socket: cold start ~3.7s, warm ~0.15s/render. With ComfyUI's Auto Queue on, dragging a slider updates near-realtime.
Machine-specific paths are all env vars (GNM_VENV_PY, GNM_MODELS_DIR, GNM_COMMUNITY_PACK, GNM_PORT) so nothing hardcoded ships in the repo.
Net result: the GNM node adds a whole 3D head model to ComfyUI and the base install is byte-for-byte unaffected.
The one place TF is genuinely required — and how to quarantine it
There is one part of GNM that needs TensorFlow: the semantic samplers. The "give me a HAPPY expression" / "give me an Asian female identity" generators are Keras VAE decoders shipped as .h5 files (identity_decoder_model.h5, expression_decoder_model.h5). Those genuinely need tf.keras to load.
My rule: never at ComfyUI runtime. I run the samplers offline, once, in a throwaway venv to bake their output into data, then throw the venv away. The ComfyUI node and the browser app never touch TF.
Even offline, the decoders had their own gotchas:
- Keras 3 vs Keras 2. The
.h5files were saved with Keras 3. If you installtf-keras(legacy Keras 2) and setTF_USE_LEGACY_KERAS=1, loading fails withUnrecognized keyword arguments: ['optional']— thatoptionalkwarg onInputLayeris a Keras-3-ism. You must load with native Keras 3 (TF ≥ 2.16, no legacy flag). - Python 3.12. TF 2.15 doesn't support 3.12; you need TF ≥ 2.16 anyway, which lines up with the Keras 3 requirement. I used
tensorflow-cpu==2.17.*. - Missing
etils. The sampler module importsetils.epath; it's not pulled in bytensorflow-cpu, sopip install etilsseparately.
Recipe that actually loads them:
python3 -m venv /tmp/gnm-tf
/tmp/gnm-tf/bin/pip install "tensorflow-cpu==2.17.*" etils numpy scipy
# load WITHOUT TF_USE_LEGACY_KERAS -> native Keras 3
/tmp/gnm-tf/bin/python bake_samples.py # writes vectors to .npy, then delete the venv
And the browser version: zero TF, zero server
The payoff of understanding that GNM's shape model is linear: identity, expression and shape are perfectly linear blendshapes (verified 0.00% error vs. the real model), so I precomputed them into a compact linear model the browser reconstructs as mesh = neutral + Σ delta. Pose/gaze run live as linear-blend skinning in JS. The whole thing runs client-side in WebGL — no Python, no TF, no backend. The samplers' output got baked into the identity/expression presets the same offline way.

Takeaways
- "X needs TensorFlow" usually means "X's demo uses TensorFlow." Check whether the actual thing you need (here: the mesh math) has a numpy/torch path. GNM did.
--no-deps+ hand-pick the base deps is the cleanest way to keep a TF-flavored package from dragging TF into a torch venv.- Quarantine the irreducible TF in a throwaway venv and bake its output to data. Nothing that needs TF should be in the runtime hot path.
- Subprocess-bridge to a side venv keeps the host app (ComfyUI) pristine; a warm resident server buys back the performance.
- nvdiffrast over pyrender whenever numpy 2 is in play.