Skip to content

fix(spa): current-frame Procrustes, working live IK, model-scale fit, UI polish#61

Merged
HugoFara merged 5 commits into
talmolab:mainfrom
HugoFara:fix-procrustes-align-current-frame
Jun 3, 2026
Merged

fix(spa): current-frame Procrustes, working live IK, model-scale fit, UI polish#61
HugoFara merged 5 commits into
talmolab:mainfrom
HugoFara:fix-procrustes-align-current-frame

Conversation

@HugoFara

@HugoFara HugoFara commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator

Bundle of standalone-mode fixes found while recording the demo video (#59).

Commits

  • aba9f5d fix(ui) — keep the mapping-row delete × clear of the overlay scrollbar so it stays clickable.

Before:

image

After:

image
  • dcbe286 fix(align) — fit Procrustes auto-alignment on the current frame instead of the time-averaged mean pose. The mean collapses when the animal turns (per-frame rotation std ~77°), producing a wildly oversized/mis-rotated fit. Applied to both the in-browser localApi and the FastAPI backend.
  • f653b82 feat(spa) — gate backend-only toolbar buttons (Load STAC H5, Load ACM) in standalone mode so they're visibly disabled rather than silent dead ends.
  • 2996f9d fix(ik)the important one: read mj_jacBody's output via a heap-allocated DoubleBuffer instead of a plain Float64Array. Embind never writes back into a JS-owned typed array, so the Jacobian was always zero → zero gradient → joints never articulated (the solver returned the seed pose, err ~30mm). Live IK now actually bends the skeleton.

Before:

image

After:

Screenshot From 2026-06-03 17-13-04
  • bd3e6f6 fix(spa) — make the model-scale slider meaningfully re-fit in standalone: scale the model about the origin (invertible by IK), divide IK targets by modelScale, scale the error-line endpoints, fix the click-to-offset handler, and clear the warm-start cache on scale change. No-op at the default modelScale = 1.

Verification

Each algorithm change was checked against a faithful Python port run on the bundled rat data (mujoco 3.8.1): single-frame fit gives rmse ~2.46 mm vs the mean-pose 3.18 mm; the IK port articulates to ~35° max joint where the buggy path returned the seed; model-scale fit is best at 1x and degrades at 2x/0.5x as expected. Frontend typecheck passes.

Known parity gaps (not blocking)

  • Backend q_opt ignores scale_factor (model-scale fix is standalone-only).
  • OffsetGizmo dragging at non-unit modelScale not yet revisited.

HugoFara added 5 commits June 3, 2026 16:20
The per-row x sat at the right edge with only 4px padding, so an overlay
scrollbar floated on top of it and it couldn't be clicked. Reserve a 10px
gutter on the scroll area, enlarge the button hit target, and use a proper
multiplication-sign glyph.
The global rigid+scale alignment fit its transform on the per-keypoint mean
pose averaged over the whole clip. When the animal walks or turns, that mean
collapses toward the centroid (bbox diag 0.74 vs a real per-frame ~2.17 on the
bundled rat demo), so Procrustes picks a hugely inflated scale (~23x instead of
~9x) and a meaningless rotation. Applied to real full-size frames, the
keypoints rendered several times too big and at the wrong angle. The s>10 bbox
fallback didn't help — it fits the same collapsed mean.

Fit instead on a single representative pose: the frame the user is viewing
(frameIndex), picking the nearest gap-free frame and falling back to the legacy
time-mean only when no frame has a complete mapped-keypoint set. Since IK
re-solves per frame (trunk Procrustes seed), this global step is just the
coarse display alignment. Fixed in both the browser (localApi) and backend
(alignment.py) paths.
Load STAC H5 has no in-browser path, so it's disabled + dimmed whenever the
backend is unreachable. Load ACM is gated on the loaded species: it stays
enabled for a bundled demo model (rat) and disables for non-demo models in
standalone, where it would otherwise load mismatched rat keypoints. Threads a
session-only modelHasDemoData flag through setXmlData, derived from the bundled
species table via xmlHasDemoData/hasBundledDemo.
jacobianIk passed a plain Float64Array as the mj_jacBody out-parameter. The
@mujoco/mujoco binding copies a plain JS array *in* but never writes it *back*
(mjtNum* out-params must be a heap-allocated DoubleBuffer read via GetView()),
so the Jacobian read as all-zeros. That zeroed the gradient, so the joints-only
refinement never moved a single DOF — the solver placed the root via the JS
trunk-Procrustes seed and then ran 25 no-op iterations, returning the seed
pose. Visibly: the model reoriented as a whole but never bent, and IK error sat
at the seed-only value (~30mm) instead of converging (~15mm).

Allocate the Jacobian on the WASM heap, read it through GetView(), free with
delete(). This is the only such misuse — mOptOffsets reads heap views already.
modelScale was applied only as a render-group scale on the model mesh, about
the model centre. The IK, keypoints, and error lines ignored it, so dragging
the slider grew the model but left it fitted (and error-lined) at the old size
— it visibly detached from the keypoint cloud and the error lines went stale.

Make modelScale scale the model about the ORIGIN (outermost group) so it's
invertible by the solver, then:
  - localApi.runQuickStac divides the IK targets by modelScale (fit the native
    model to keypoints/modelScale; the ×modelScale render lands the bodies back
    on the fixed cloud) and reports error in rendered/world space;
  - ikRunner passes modelScale through;
  - ErrorLines scales the body+offset endpoint by modelScale so lines connect
    to the rendered body and the error is the true rendered distance (and
    recomputes on slider change);
  - MuJoCoModel's click-to-set-offset divides the world hit point back to the
    native frame;
  - setModelScale clears the warm-start cache so the next auto-IK cold-starts
    and re-seeds the root (joints-only warm refine can't recover a big scale
    jump).

The slider is now a genuine fit control: changing it re-fits and the error
reflects how well a model of that size matches the data. No-op at the default
modelScale=1. Standalone-only — the backend q_opt also ignores scale_factor,
so backend-mode model scaling remains a separate (untouched) parity gap.
@HugoFara HugoFara merged commit 693c5e9 into talmolab:main Jun 3, 2026
3 checks passed
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.

1 participant