Face Warp (TPS smooth)
Reshape a Face Without Triangle Seams
- image
- src_landmarks
- dst_landmarks
- warped
Liquify-style face reshaping has two implementations, and the difference shows up the moment you move a jaw more than a few pixels. Triangle-based warps (Delaunay over the landmark mesh) treat each triangle as its own little affine patch - fast, honest, and prone to visible kinks along triangle edges when a large move makes neighbouring triangles disagree. Thin-plate-spline warps fit one smooth surface through all your control points, so there's no boundary to see.
Face Warp (TPS smooth) (JHPixelProFaceWarpTPS) is the second kind, and it runs on the GPU.
The short maths
A TPS is the surface a thin metal plate takes if you pin it at your control points: it passes through every point and stays as flat as possible in between. The pack fits it on the output→input mapping (kernel r² log r plus an affine part), evaluates it exactly on a 4 px grid, upsamples that displacement field bicubically, then resamples the image bilinearly. With smoothness = 0, control points land within about 0.6 px of their targets on a real 1536×2048 portrait.
Because the field is global and smooth, there are no seams - and, equally, no hard "everything outside the mesh stays untouched" guarantee. That's what anchor_border is for.
Inputs and outputs
image, src_landmarks, dst_landmarks - both LANDMARKS, i.e. you feed the mesh from Face Landmarks twice: once as the original, once as the edited/matched version.
max_points (16–468, default 468) - how many landmark pairs the spline uses. Pairs that actually moved (>0.5 px) are always kept; the rest is filled half with the unmoved pairs nearest the edit and half with evenly spaced ones. Lower it on CPU if you're waiting.
anchor_border (default on) - adds pinning pairs at the corners plus three per edge, so the background doesn't swim when you move the face.
smoothness (0–20, default 1) - and this one deserves the paragraph.
Why smoothness defaults to 1 and not 0
If you're warping within one photo - dragging a jaw, nudging an eye - you want 0, and every point lands exactly where you put it.
But the common case in a ComfyUI graph is matching a face to another photo's landmarks, and those target points carry detector noise. On the pack's own sample, two detections of the same face in a slightly zoomed copy differ by 13–17 px median. Trying to honour that exactly folds the image - ripples, creases, the "melted face" look. smoothness = 1 regularises the spline enough to absorb that noise while still landing a clean edit within ~1 px. If you still see ripples, raise it. If you need surgical precision, set it to 0.
Output: warped. Single image, no mask.
Practical use
Move groups of neighbouring landmarks together. Yanking a couple of isolated points produces wide, rubbery distortion - the math is doing what you asked, you just asked for something ugly. Slimming a jaw or lifting an eyelid is a coordinated push of 5–10 points, not one hero vertex.
And warp before skin retouching. Blemish removal and smoothing on texture you're about to resample is wasted work; do the geometry, then retouch the final shape.
Missing faces come back as NaN landmark slots, and fewer than 3 valid pairs returns the image unchanged - no exception, no warning beyond that. So if nothing happened, count your valid pairs first.
Install
cd ComfyUI/custom_nodes
git clone https://github.com/jetthuangai/ComfyUI-JH-PixelPro.git
cd ComfyUI-JH-PixelPro
pip install -r requirements.txt # the Python that runs ComfyUI
ComfyUI Manager: search ComfyUI-JH-PixelPro → Install → restart. Node is under Add Node → ComfyUI-JH-PixelPro → face. Deps: kornia>=0.7, mediapipe>=0.10.33, opencv-python-headless>=4.8, scipy>=1.10, PyTorch from ComfyUI. No downloads. Note the dependency chain for landmarks: this node does nothing useful without Face Landmarks, which needs MediaPipe (and downloads its own ~5 MB face_landmarker.task into ComfyUI/models/mediapipe/ on first run).
Where it gets used, honestly
This is the node for subtle, believable work: a slightly narrower jaw, eyes a touch larger, an expression matched between two frames of the same shoot. It is not an identity-transfer tool - there's no embedding, no ArcFace, no recognition anywhere in this pipeline. If you want the identity of person B on person A, you're in InstantID/PuLID territory and this node is the wrong instrument.
The comparison that matters is against the pack's own Delaunay Face Warp. Pick Delaunay when the change must stay strictly inside the mesh, or when every one of 468 points must be honoured locally. Pick TPS for gentle natural edits, and for GPU speed - about 19 ms versus 0.85 s on CPU at 1536×2048 with 468 points.
Pack: Apache-2.0, JH (@jetthuangai), v1.10.0, 64 nodes, bilingual docs. No Reddit footprint to speak of, which for a warp node means no consensus "these are the good slider values" - and that's fine, because this node's best feature is that its defaults are already the conservative choice.
Inputs (6)
| Name | Type | Default | Description |
|---|---|---|---|
| image | IMAGE | — | |
| src_landmarks | LANDMARKS | — | |
| dst_landmarks | LANDMARKS | — | |
| max_points | INT | 46816–468 | Maximum landmark pairs used for the spline. Moved landmarks are always kept; the rest is filled with evenly spaced unmoved ones. |
| anchor_border | BOOLEAN | true | Pin points along the image border so the background does not swim. |
| smoothnessopt | FLOAT | 1.00–20 | 0 = every landmark lands exactly. 1 (default) absorbs detection noise when target landmarks come from another photo (avoids ripples/folds); clean edits stay within ~1 px. Raise if you still see ripples. |
Outputs (1)
| Name | Type | Description |
|---|---|---|
| warped | IMAGE | — |