Skip to content

Face-Rig Slice A (#896): spike — NRICP + deformation transfer proven, ICT-FaceKit MIT - #897

Closed
fernandotonon wants to merge 1 commit into
masterfrom
feat/facerig-slice-a-spike-896
Closed

Face-Rig Slice A (#896): spike — NRICP + deformation transfer proven, ICT-FaceKit MIT#897
fernandotonon wants to merge 1 commit into
masterfrom
feat/facerig-slice-a-spike-896

Conversation

@fernandotonon

@fernandotonon fernandotonon commented Jul 14, 2026

Copy link
Copy Markdown
Owner

Part of epic #889. Closes #896. The de-risk spike for auto-generating ARKit blendshapes on any humanoid mesh — offline prototype + decision records, no app code.

Verdict: GO

Both algorithms proven on a real, different-topology head with a permissive (MIT) template. Slices B–G are an engineering port of a working prototype, not open research.

What was proven (scripts/spike-facerig.py, not shipped)

Fit the MIT ICT-FaceKit generic-neutral template (26,719 verts) to a 12,763-vert, 60%-decimated user head (genuinely different topology), then transfer ICT's ARKit expressions onto the user's topology:

step result
NRICP (Amberg 2007 optimal-step, pure numpy/scipy, no Wrap3D) surface fit mean 0.003% / max 0.59% of head diagonal
jawOpen transfer lower face ΔY −0.32 (drops), forehead
eyeBlinkLeft localized to the eye (1,328 verts) ✅
mouthSmileLeft / browInnerUp localized, plausible magnitudes ✅

It's a deterministic sparse-linear-algebra pipeline — no ML, no ONNX, no model download at inference (only the MIT template asset downloads on first use, like other bundled assets). Same class as GeodesicVoxelBind / QuadRetopo.

Deliverables

  • scripts/spike-facerig.py — the prototype (NRICP + deformation transfer + quality measurement + the ICT→ARKit name map).
  • docs/FACE_RIG_SPIKE.md — the implementable contract for Slices C/D: NRICP params + stiffness anneal, the deformation-transfer linear system, the OBJ multi-group gotcha, the humanoid/orientation/landmark risks, and the two quality upgrades the C++ port must make over the spike (full deformation-gradient transfer; optional landmark constraints).
  • THIRD_PARTY_AI_MODELS.md — ICT-FaceKit MIT entry (the "full model" USC tier rejected).

Acceptance criteria (from #896)

  • ICT-FaceKit licensing verdict in THIRD_PARTY_AI_MODELS.md (MIT — PASS).
  • Prototype produces ARKit shapes on a different-topology head with recorded quality numbers.
  • docs/FACE_RIG_SPIKE.md with the implementable NRICP + deformation-transfer contract and go/no-go.

Next: Slice B (#890) — bundle + host the ICT template.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added an offline face auto-rigging spike that generates ARKit-compatible facial blendshapes from a neutral head mesh.
    • Added export of generated expression meshes and a quality report for evaluation.
  • Documentation

    • Documented the ICT-FaceKit asset, rigging workflow, supported scope, quality metrics, risks, and recommended production improvements.
    • Added the face auto-rig spike results and go-forward recommendation.

…-FaceKit MIT

Slice A of epic #889 (auto-generate ARKit blendshapes on any humanoid mesh).
Offline feasibility prototype + decision records only; no app code.

- scripts/spike-facerig.py (NOT shipped): pure numpy/scipy prototype of the
  two algorithms Slices C/D will port to C++ — non-rigid ICP (Amberg 2007
  optimal-step, no Wrap3D) to fit the ICT-FaceKit template to an arbitrary
  neutral head, then deformation transfer (Sumner-Popovic 2004) of each ARKit
  expression onto the user topology.
- Proven on a real different-topology head (26,719-vert ICT template ->
  12,763-vert decimated user): NRICP surface fit mean 0.003% / max 0.59% of
  the head diagonal; transferred shapes anatomically correct (jawOpen drops
  the lower face ΔY -0.32 with the forehead still |Δ| 0.002; eyeBlink stays
  localized). GO.
- THIRD_PARTY_AI_MODELS.md: ICT-FaceKit MIT entry (template + 52 ARKit-named
  shapes, shared topology; 'full model' USC tier rejected; no ML/ONNX — a
  deterministic sparse solve).
- docs/FACE_RIG_SPIKE.md: full implementable contract (NRICP params +
  stiffness anneal, the deformation-transfer linear system, the ICT->ARKit
  name map, OBJ multi-group gotcha, humanoid/orientation/landmark risks) +
  the two quality upgrades for the C++ port (full deformation-gradient
  transfer, optional landmarks) + go/no-go.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds ICT-FaceKit licensing and face-rig spike documentation, plus an offline Python prototype that fits a template with NRICP, transfers expression deformations, exports ARKit targets, and records quality metrics.

Changes

Face auto-rig feasibility spike

Layer / File(s) Summary
Face-rig contract and licensing
.gitignore, THIRD_PARTY_AI_MODELS.md, docs/FACE_RIG_SPIKE.md
Documents the MIT ICT-FaceKit asset, ARKit expression mapping, NRICP and deformation-transfer contract, measured quality, limitations, and Go decision.
Template loading and NRICP fitting
scripts/spike-facerig.py
Adds shared-topology OBJ parsing, similarity alignment, sparse NRICP fitting, stiffness construction, affine application, and triangle projection helpers.
Blendshape transfer and reporting
scripts/spike-facerig.py
Transfers expression displacements to the user mesh, supports CLI options and optional ground-truth RMS checks, exports targets, and writes a JSON report.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CLI
  participant OBJLoader
  participant NRICP
  participant DeformationTransfer
  participant ReportWriter
  CLI->>OBJLoader: Load template and user OBJ meshes
  CLI->>NRICP: Fit template to user surface
  NRICP-->>CLI: Return fitted correspondence
  CLI->>DeformationTransfer: Transfer expression deformation
  DeformationTransfer-->>CLI: Return user-vertex morph deltas
  CLI->>ReportWriter: Export targets and JSON metrics
Loading

Possibly related issues

  • Issue 890: The PR implements the requested NRICP spike, deformation transfer, licensing documentation, and go/no-go report.
  • Issue 889: The PR delivers the face auto-rig epic’s feasibility spike and ICT-FaceKit licensing work.
  • Issue 892: The PR prototypes the NRICP template-to-user fitting required for the face-rig pipeline.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The spike and docs cover licensing, NRICP, deformation transfer, and Go/No-Go, but the summary only evidences one test head, not the requested 2-3 heads. Run and record the prototype on at least 2 neutral heads, then update the spike docs with those quality numbers and results.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and clearly reflects the spike, NRICP/deformation transfer, and ICT-FaceKit licensing work.
Description check ✅ Passed The description is detailed and covers summary, technical details, deliverables, and acceptance criteria, even if it doesn't mirror the template headings exactly.
Out of Scope Changes check ✅ Passed All changes support the face-rig spike, licensing record, or documentation; no unrelated code changes stand out.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/facerig-slice-a-spike-896

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 896e112460

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread scripts/spike-facerig.py
ev, _ = load_obj(ep)
# express the template expression through the SAME correspondence:
# correspondence of the expression = corr + (expr - neutral) template delta
expr_corr = corr + (ev - tv)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Scale expression deltas with the fitted head

When the user head is in a different scale/unit than the ICT template, this adds raw template-space expression deltas directly to the fitted user-space correspondence. The NRICP fit scales/transforms the neutral template onto uv, but (ev - tv) is never put through that fitted scale/local affine, so a mesh authored in meters vs centimeters will get blendshape motions that are orders of magnitude too large or too small while the fit report still looks normalized to the user diagonal. The spike should transform these deltas through the fitted correspondence/local affine before resampling them to user vertices.

Useful? React with 👍 / 👎.

Comment thread scripts/spike-facerig.py
# correspondence of the expression = corr + (expr - neutral) template delta
expr_corr = corr + (ev - tv)
user_delta = deformation_transfer(uv, uf, corr, expr_corr)
arkit = ICT_TO_ARKIT.get(stem, stem)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require complete ARKit name mapping

With the default --shapes path this processes every ICT expression OBJ, but any stem missing from this partial map falls back to the raw ICT filename. For the full FaceKit set, expressions such as additional mouth/nose/tongue shapes will therefore be exported/reported under non-FaceCap::kBlendshapeNames names instead of the canonical ARKit targets, so the spike does not actually prove or provide the promised 52-name contract for Slice B/D. Please either complete the map or fail loudly on unmapped stems.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
scripts/spike-facerig.py (1)

160-161: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove dead code and unused variables from prototyping.

Several functions and variables from earlier prototyping iterations remain in the code but are never used. Removing this vestigial code reduces unnecessary overhead and prevents the C++ implementers (Slices C/D) from accidentally porting dead math.

  • scripts/spike-facerig.py#L160-L161: remove the unused D matrix (which currently triggers a redundant csr_matrix allocation inside the inner loop).
  • scripts/spike-facerig.py#L144-L145: remove the unused G and kron variables.
  • scripts/spike-facerig.py#L92-L104: remove the unused umeyama rigid alignment function.
  • scripts/spike-facerig.py#L244-L251: remove the unused tri_frame function.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/spike-facerig.py` around lines 160 - 161, Remove the unused D sparse
matrix allocation inside the inner loop, and remove the unused G and kron
variables. Delete the unused umeyama rigid-alignment function and tri_frame
function; apply these removals at scripts/spike-facerig.py lines 160-161,
144-145, 92-104, and 244-251 respectively.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/FACE_RIG_SPIKE.md`:
- Line 91: Update the jawOpen row in the Markdown table to escape the pipe
characters around Δ, preserving the displayed “|Δ|” text while preventing
Markdown from treating them as column separators.

In `@scripts/spike-facerig.py`:
- Around line 154-156: Update the comment immediately above the target
assignment using closest_on_tris to describe an exact point-to-triangle
projection, and remove the centroid/nearest-vertex approximation wording. Keep
the implementation unchanged so the C++ port follows the exact projection
behavior.
- Around line 162-165: Hoist the constant build_data(Th) result and the
alpha-dependent build_stiffness(M, alpha) plus vstack assembly out of the
innermost iters_per loop. Construct Amat once per alpha before that loop, then
reuse it for every iteration while preserving the existing solve behavior.

---

Nitpick comments:
In `@scripts/spike-facerig.py`:
- Around line 160-161: Remove the unused D sparse matrix allocation inside the
inner loop, and remove the unused G and kron variables. Delete the unused
umeyama rigid-alignment function and tri_frame function; apply these removals at
scripts/spike-facerig.py lines 160-161, 144-145, 92-104, and 244-251
respectively.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f7f16672-44bb-4800-8439-f4b452fe4b65

📥 Commits

Reviewing files that changed from the base of the PR and between 5415810 and 896e112.

📒 Files selected for processing (4)
  • .gitignore
  • THIRD_PARTY_AI_MODELS.md
  • docs/FACE_RIG_SPIKE.md
  • scripts/spike-facerig.py

Comment thread docs/FACE_RIG_SPIKE.md
| ARKit shape | max displacement | verts moved | semantics check |
|---|---|---|---|
| NRICP surface fit | mean **0.003%** / max **0.59%** of diag | — | template lands on user surface |
| jawOpen | 9.0% | 6,771 | lower face ΔY −0.32 (drops), forehead |Δ| 0.002 (still) ✅ |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Escape the pipe characters to fix table rendering.

The pipe characters in |Δ| are interpreted as table column separators by Markdown, breaking the table rendering for this row.

📝 Proposed formatting fix
-| jawOpen | 9.0% | 6,771 | lower face ΔY −0.32 (drops), forehead |Δ| 0.002 (still) ✅ |
+| jawOpen | 9.0% | 6,771 | lower face ΔY −0.32 (drops), forehead \|Δ\| 0.002 (still) ✅ |
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
| jawOpen | 9.0% | 6,771 | lower face ΔY −0.32 (drops), forehead |Δ| 0.002 (still) ✅ |
| jawOpen | 9.0% | 6,771 | lower face ΔY −0.32 (drops), forehead \|Δ\| 0.002 (still) ✅ |
🧰 Tools
🪛 markdownlint-cli2 (0.23.0)

[warning] 91-91: Table column count
Expected: 4; Actual: 6; Too many cells, extra data will be missing

(MD056, table-column-count)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/FACE_RIG_SPIKE.md` at line 91, Update the jawOpen row in the Markdown
table to escape the pipe characters around Δ, preserving the displayed “|Δ|”
text while preventing Markdown from treating them as column separators.

Comment thread scripts/spike-facerig.py
Comment on lines +154 to +156
# closest point = project X onto that triangle (approx: centroid-nudged
# to nearest vertex of the tri for a cheap point-to-point target)
target = closest_on_tris(X, user_v, user_f, tri_idx)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Correct the misleading documentation about the projection method.

The comment explicitly states the projection is an approximation ("centroid-nudged to nearest vertex"), but the implementation of closest_on_tris performs the exact point-to-triangle projection mathematically.

It is critical to fix this comment so the C++ port (Slices C/D) doesn't mistakenly implement a cheap vertex-snap, which would severely degrade the reported sub-1% fit quality.

📝 Proposed comment fix
-            # closest point = project X onto that triangle (approx: centroid-nudged
-            # to nearest vertex of the tri for a cheap point-to-point target)
+            # closest point = project X exactly onto that triangle for an
+            # accurate point-to-surface target
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# closest point = project X onto that triangle (approx: centroid-nudged
# to nearest vertex of the tri for a cheap point-to-point target)
target = closest_on_tris(X, user_v, user_f, tri_idx)
# closest point = project X exactly onto that triangle for an
# accurate point-to-surface target
target = closest_on_tris(X, user_v, user_f, tri_idx)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/spike-facerig.py` around lines 154 - 156, Update the comment
immediately above the target assignment using closest_on_tris to describe an
exact point-to-triangle projection, and remove the centroid/nearest-vertex
approximation wording. Keep the implementation unchanged so the C++ port follows
the exact projection behavior.

Comment thread scripts/spike-facerig.py
Comment on lines +162 to +165
# data rows: for each vertex, th_i (1x4) times A_i (4x3) -> point
data_rows = build_data(Th) # (n, 4n)
stiff = build_stiffness(M, alpha) # (4E, 4n)
Amat = vstack([stiff, data_rows]).tocsr()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Hoist constant matrix constructions out of the inner loop.

The data matrix data_rows is entirely constant (derived from the resting template Th), and stiff only changes when alpha changes. Consequently, the entire left-hand side matrix Amat is constant for any given alpha.

Rebuilding these csr_matrix systems in the innermost iters_per loop causes massive unnecessary allocation and assembly overhead. Hoisting them is a critical optimization for both this script and the eventual C++ port to ensure they don't repeat the redundant assembly.

⚡ Proposed performance fix
+    data_rows = build_data(Th)                      # (n, 4n)
     for alpha in stiffness:
+        stiff = build_stiffness(M, alpha)               # (4E, 4n)
+        Amat = vstack([stiff, data_rows]).tocsr()
         for _ in range(iters_per):
             # data term: A_i * th_i ~= closest user point to current X_i
             _, tri_idx = tree.query(X)
             # closest point = project X onto that triangle (approx: centroid-nudged
             # to nearest vertex of the tri for a cheap point-to-point target)
             target = closest_on_tris(X, user_v, user_f, tri_idx)
 
             # Build sparse system:  [ alpha * (M kron G) ; D ] A = [ 0 ; target ]
             # A is stacked as (4n x 3). D picks th_i per row.
             D = csr_matrix((np.repeat(1.0, n),
                             (np.arange(n), np.arange(n))), shape=(n, n))
-            # data rows: for each vertex, th_i (1x4) times A_i (4x3) -> point
-            data_rows = build_data(Th)                      # (n, 4n)
-            stiff = build_stiffness(M, alpha)               # (4E, 4n)
-            Amat = vstack([stiff, data_rows]).tocsr()
             rhs = np.vstack([np.zeros((stiff.shape[0], 3)), target])
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# data rows: for each vertex, th_i (1x4) times A_i (4x3) -> point
data_rows = build_data(Th) # (n, 4n)
stiff = build_stiffness(M, alpha) # (4E, 4n)
Amat = vstack([stiff, data_rows]).tocsr()
data_rows = build_data(Th) # (n, 4n)
for alpha in stiffness:
stiff = build_stiffness(M, alpha) # (4E, 4n)
Amat = vstack([stiff, data_rows]).tocsr()
for _ in range(iters_per):
# data term: A_i * th_i ~= closest user point to current X_i
_, tri_idx = tree.query(X)
# closest point = project X onto that triangle (approx: centroid-nudged
# to nearest vertex of the tri for a cheap point-to-point target)
target = closest_on_tris(X, user_v, user_f, tri_idx)
# Build sparse system: [ alpha * (M kron G) ; D ] A = [ 0 ; target ]
# A is stacked as (4n x 3). D picks th_i per row.
D = csr_matrix((np.repeat(1.0, n),
(np.arange(n), np.arange(n))), shape=(n, n))
rhs = np.vstack([np.zeros((stiff.shape[0], 3)), target])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/spike-facerig.py` around lines 162 - 165, Hoist the constant
build_data(Th) result and the alpha-dependent build_stiffness(M, alpha) plus
vstack assembly out of the innermost iters_per loop. Construct Amat once per
alpha before that loop, then reuse it for every iteration while preserving the
existing solve behavior.

@sonarqubecloud

Copy link
Copy Markdown

fernandotonon added a commit that referenced this pull request Jul 18, 2026
- NonRigidICP: correspondence search now takes the K=4 nearest triangle
  CENTROIDS and picks by exact point-triangle distance — a single centroid
  winner mis-corresponds next to large/sliver triangles (PR #899).
- DeformationTransfer: reject malformed buffers (trailing floats, indices
  outside [0,N)) before dereferencing; anchor the translation gauge of
  EVERY connected component, not just vertex 0 — the ICT template is
  dozens of islands (eyeballs, corneas, teeth) and each needs its own
  anchor row + rhs (PR #900).
- FaceRigger: '--max-residual' now gates the MAX fit residual directly
  (it silently allowed 6x the supplied value); mean gated at a quarter of
  it. Healthy fits (max <= ~4%) pass the default 8% unchanged (PR #901).
- FaceRigAttach::extractGeometry: skip a sharedVertexData pool no submesh
  references — orphan vertices joined the fit with no triangles (PR #901).
- ArkitTemplate::ensureModelBlocking: a synchronous startDownload failure
  no longer blocks for the full 5-minute timeout ('done' guard, the LLM
  CLI pattern); Sentry breadcrumbs on download start/ok/fail (PR #898).
- export-arkit-template.py: document that tongueOut is deliberately
  absent — ICT-FaceKit has no tongue expression, 51 real shapes (PR #898).
- docs/FACE_RIG_SPIKE.md: escape |Δ| pipes that broke the results table
  (PR #897).

Already addressed by earlier commits (noted for the record): the
.arkit.json sidecar is now consumed on import (PR #903, commit e7abe01)
and re-rigging replaces existing same-named targets instead of stacking
(PR #902). FaceRig sources build into UnitTests via src/CMakeLists.txt —
the 34 FaceRig/NRICP/DT tests run green.

Verified: reference rig max residual 0.35% under the stricter gate;
anchored Rumba sim 3.27%.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@fernandotonon

Copy link
Copy Markdown
Owner Author

Consolidated into #903 (single epic PR, retargeted to master). Review findings from this PR are addressed there — see commit d234722d.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Face-Rig Slice A — Spike: NRICP feasibility + ICT-FaceKit template licensing due-diligence

1 participant