Skip to content

IK robustness: restore deleted DOF map + NaN/order/bounds guards (I3-A..E)#52

Open
sibocw wants to merge 5 commits into
flygymv2from
claude/ik-robustness
Open

IK robustness: restore deleted DOF map + NaN/order/bounds guards (I3-A..E)#52
sibocw wants to merge 5 commits into
flygymv2from
claude/ik-robustness

Conversation

@sibocw

@sibocw sibocw commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

This work was implemented by Claude Code (Opus 4.8) upon user instruction.

This PR hardens the seqikpy-based inverse-kinematics path against the failures found in the audit (issue #48, findings I3-A through I3-E). It restores a deleted constant that currently crashes every IK save, makes NaN/occlusion handling graceful, fixes a silent keypoint-ordering hazard, makes the joint-limit table L/R consistent, and adds correctness tooling for the time-chunk seeding behavior.

Findings fixed

I3-A (CRASH — unblocks IK) — commit 1

run_inverse_kinematics.py::_save_seqikpy_output and production/spotlight/keypoints3d.py::_joint_angles_dict2arr both call nmf_constants.dof_name_lookup_canonical_to_nmf, which did not exist: it was introduced in 5f8b6ee and accidentally removed in 3c9449a ("compatibility with flygym v2") without updating the call sites, so saving IK output raised AttributeError.

Restored dof_name_lookup_canonical_to_nmf in neuromechfly/constants.py. The keys are the 7 canonical leg DOFs in the exact order the IK-save path and seqikpy expect — ThC_yaw, ThC_pitch, ThC_roll, CTr_pitch, CTr_roll, FTi_pitch, TiTa_pitch — and the values are the matching NeuroMechFly DOF names (Coxa_yaw, Coxa, Coxa_roll, Femur, Femur_roll, Tibia, Tarsus1). The call sites build f"Angle_{leg}_{dof_name}" from dict.keys(); I verified end-to-end against seqikpy.leg_inverse_kinematics.LegInvKinSeq, which emits exactly those Angle_{leg}_{canonical_dof} keys, so the lookups resolve and the DOF packing order of the saved (n_frames, 6, 7) array is correct. No edit to production/spotlight/keypoints3d.py was required — restoring the constant alone unblocks both call sites.

I3-D (recording aborts on one occluded keypoint) — commit 1

invkin.py had assert not np.isnan(data_block).any(), which aborted the entire recording if a single keypoint was NaN/occluded in any frame. Replaced with graceful handling: NaN gaps are linearly interpolated per leg over time (_interpolate_nan_frames, interior points interpolated, leading/trailing points edge-filled) and a warning logs the NaN count and the affected-frame count. seqikpy itself has no NaN handling, so this guarantees the solver receives finite input. A keypoint that is NaN for the entire recording is genuinely unrecoverable and now raises a clear per-leg ValueError instead of silently corrupting the chain.

I3-E (silent wrong indexing if keypoint orders differ) — commit 1

align_fwdkin_xyz_to_rawpred_xyz indexed both the raw and the constrained arrays using the raw file's keypoints_order, even though the constrained array is built by fwdkin_world_xyz_append_antennae with its own ordering — silently wrong if the two ever diverged. Added a keypoints_order_constrained parameter (defaults to keypoints_order for back-compat) and now look up each array in its own order. The call site in run_inverse_kinematics.py passes the constrained order explicitly.

I3-C (bad-solve / chunk-boundary transients) — commit 1

seqikpy seeds frame t from frame t-1, and with parallel_over_time=True it re-seeds each time chunk from the static initial angles and linearly blends chunks, which can introduce wrong transients at chunk boundaries. Modifying seqikpy internals is out of scope here, so at the poseforge level this PR:

  • adds a correctness_critical flag and a seqikpy_kwargs passthrough on process_all, so a caller can run with parallel_over_time=False (still parallel over legs) for correctness-critical runs; and
  • adds pure post-hoc detectors detect_large_joint_angle_jumps / log_large_joint_angle_jumps (default threshold 45 deg) that are run after each IK solve to flag and log large frame-to-frame joint-angle jumps without altering the saved angles.

Properly fixing the chunk-boundary blending inside seqikpy is left as future work.

I3-B (joint-limit L/R asymmetries) — commit 2, NEEDS BIOMECH REVIEW

Warning

nmf_bounds change needs maintainer / biomechanical (NeuroMechFly range-of-motion) review. The five values below were derived purely as L/R mirrors to remove inconsistencies the original author had flagged with # ? (including a physically implausible -270 deg lower bound on RF/RM CTr_pitch). They have not been validated against measured joint ranges. This is isolated in its own commit so it can be reverted independently.

Convention used: roll/yaw RIGHT = (-LEFT_hi, -LEFT_lo); pitch RIGHT = LEFT. Changed (degrees):

DOF before after rationale
RF_ThC_roll (-135, 10) (-90, 10) mirror of LF (-10, 90)
RF_CTr_pitch (-270, 10) (-180, 10) match LF; remove implausible -270
RM_ThC_yaw (-45, 45) (-90, 45) mirror of LM (-45, 90)
RM_CTr_pitch (-270, 10) (-180, 10) match LM; remove implausible -270
RH_ThC_yaw (-45, 45) (-90, 45) mirror of LH (-45, 90)

Mid/hind ThC_roll and CTr_roll already mirrored correctly and were left unchanged.

Testing

New tests/test_ik_robustness.py (37 tests, all passing): parametrized L/R mirror-consistency checks over every leg/DOF plus the implausible-bound guard (I3-B), the restored DOF map's keys/order/values and a check that the call sites still consume it (I3-A), and the real pure helpers for NaN interpolation (I3-D) and jump detection (I3-C).

Note

Testing limitation: flygym is not installed in this environment (it does not yet support Python 3.13), and neuromechfly/constants.py imports from flygym.anatomy import JointDOF, so constants.py and invkin.py cannot be imported directly. The tests therefore exercise the static definitions via ast and run the actual source of the pure numpy helpers in an isolated numpy-only namespace (extracted via ast, not reimplemented). Full IK import/run integration testing is blocked until flygym is available; I additionally verified I3-A by reading the call sites end-to-end and confirming the key names/order against seqikpy's emitted Angle_{leg}_{dof} keys.

Findings I3-A .. I3-E are from the audit. Refs #48

🤖 Generated with Claude Code


Update (Claude Code, upon user instruction) — data-derived bounds + IK verification

1. nmf_bounds re-derived from data (I3-B) — supersedes the mirror-only commit cb4ba95

A data-driven check found 10 of 42 of the prior hand-set / L-R-mirrored bounds were tighter than the actual range of motion exercised by the ground-truth dof_angles — including the mirror-fixed RF_ThC_roll (set to [-90°, 10°], but the data spans [-158.6°, 0.9°]). A bound tighter than the data is unreachable by the solver and forces a wrong solution, so bounds are now derived directly from the data.

How the bounds were derived (reproducible):

  1. Source — the ground-truth simulated dof_angles in every atomic-batch _labels.h5, named/ordered via the dataset's keys attribute (6 legs × 7 DOFs = 42).
  2. Sample — 500 _labels.h5 files drawn with numpy.random.default_rng(0).choice(…, 500, replace=False) from the sorted recursive glob of bulk_data/…/atomic_batches/4variants/**/*_labels.h5, all 32 frames each → n = 16,000 frames.
  3. Per DOFbound = (floor(min° − margin), ceil(max° + margin)), margin = 10°. Using min/max (not percentiles) guarantees no observed pose is clipped; the ±10° margin gives the least-squares solver headroom so the optimum doesn't sit on a boundary (an IK fragility flagged in the audit).
  4. Key mapping — ground-truth key {leg}{dof} (e.g. RFThC_yaw) → bounds key {leg}_{dof} (e.g. RF_ThC_yaw) that KinematicChainSeq consumes.

Reproduce exactly (verified to reproduce the committed dict bit-for-bit):

python scripts/verify_ik_selfconsistency.py --emit-bounds --n-batches 500 --seed 0 --margin-deg 10

The same method + caveats are also documented in the comment above nmf_bounds in constants.py.

Caveats: (a) this is the training RoM, not the anatomical RoM — widen toward NeuroMechFly limits if production saturates a bound; (b) RH_ThC_roll reaches −202° in the source kinematics → flagged as angle-wrapping (upstream dof_angles likely need unwrapping; the bound only contains it so IK can reproduce it); (c) bounds come out near-mirror where the data is, but are not forced symmetric.

2. scripts/verify_ik_selfconsistency.py

Unprojects the stored atomic-batch keypoints to 3D and compares IK-recovered angles to the stored ground-truth dof_angles (matched by name, wrapping-aware). Also provides the flygym-free --emit-bounds regenerator above.

What a human needs to verify (test instructions)

  • Run the end-to-end IK self-consistency check — needs flygym + seqikpy installed and PR Data pipeline: fix pvio import break + loader resolution guard (I1-C) + extraction script (I2-C) #51's pvio fix present (its invkin import goes through atomic_batch):
    python scripts/verify_ik_selfconsistency.py --n-batches 5 --max-frames 32
    
    Expect overall mean per-DOF angle error well under ~15° (the script prints a per-DOF MAE table and a PASS/FAIL on the mean). This is the single end-to-end confirmation of I3-A (restored DOF map + save path), I1-A (camera built at the correct sensor size), and the IK solve itself. A large error isolated to one DOF/leg localizes a mapping/bound bug.
  • Bounds vs anatomy — regenerate/compare with --emit-bounds; widen toward NeuroMechFly's anatomical RoM if a bound saturates in production.
  • DOF order — IK output packs [ThC_yaw, ThC_pitch, ThC_roll, …] while ground-truth dof_angles are [ThC_pitch, ThC_roll, ThC_yaw, …]; compare/consume by name (the saved array carries dof_names_per_leg); ensure any NMF replay reads by name, not position.
  • I3-A naming — confirm the restored canonical→NMF values match flygym v2's actuated DOF names (Coxa, Coxa_roll, Coxa_yaw, Femur, Femur_roll, Tibia, Tarsus1), e.g. via skeleton.get_actuated_dofs_from_preset(ActuatedDOFPreset.LEGS_ACTIVE_ONLY).

sibocw and others added 4 commits June 23, 2026 22:57
…C,D,E)

Addresses audit issue #48 findings I3-A, I3-C, I3-D, I3-E. (I3-B bounds fix
is committed separately so it can be reverted independently.)

I3-A (CRASH, unblocks IK): restore `dof_name_lookup_canonical_to_nmf` in
neuromechfly/constants.py. It was introduced in 5f8b6ee and accidentally
removed in 3c9449a while its two call sites
(run_inverse_kinematics.py::_save_seqikpy_output and
production/spotlight/keypoints3d.py::_joint_angles_dict2arr) were left intact,
raising AttributeError at IK save. Keys are the 7 canonical leg DOFs in the
order seqikpy/IK-save expect (ThC_yaw, ThC_pitch, ThC_roll, CTr_pitch,
CTr_roll, FTi_pitch, TiTa_pitch); values are the matching NMF DOF names.
Verified against seqikpy's emitted `Angle_{leg}_{dof}` keys.

I3-D: replace the hard `assert not np.isnan(data_block).any()` in
invkin._world_xyz_to_seqikpy_format (which aborted the whole recording on a
single occluded keypoint) with graceful per-leg NaN-gap interpolation over
time (`_interpolate_nan_frames`) plus a warning logging the count/location.
A keypoint that is NaN for the entire recording is unrecoverable and raises a
clear per-leg error instead of silently corrupting the chain.

I3-E: align_fwdkin_xyz_to_rawpred_xyz now takes `keypoints_order_constrained`
(default = raw order, back-compatible) and indexes the raw and constrained
arrays each by their own keypoint order, instead of indexing the constrained
array with the raw file's order. Call site updated to pass the constrained
order returned by fwdkin_world_xyz_append_antennae.

I3-C: add pure post-hoc detectors detect_large_joint_angle_jumps /
log_large_joint_angle_jumps and call them after each IK solve to flag likely
bad-solve propagation or chunk-boundary transients; expose a
`correctness_critical` flag and `seqikpy_kwargs` passthrough on process_all so
callers can set parallel_over_time=False for correctness-critical runs.

Tests: tests/test_ik_robustness.py exercises the real pure helpers and the
restored constant via ast/source extraction (flygym is not installed in this
env, so the modules cannot be imported directly).

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

Addresses audit issue #48 finding I3-B. Five joint-DOF bounds in
neuromechfly/constants.py were not consistent L/R mirrors of their counterpart
(flagged by the original author with "# ?"), including a physically implausible
-270 deg lower bound on RF/RM CTr_pitch. Made them exact mirrors using:
    roll/yaw : RIGHT = (-LEFT_hi, -LEFT_lo)
    pitch    : RIGHT =  LEFT

Changed (degrees):
  RF_ThC_roll : (-135,10) -> (-90,10)   mirror of LF (-10,90)
  RF_CTr_pitch: (-270,10) -> (-180,10)  match LF (remove implausible -270)
  RM_ThC_yaw  : (-45,45)  -> (-90,45)   mirror of LM (-45,90)
  RM_CTr_pitch: (-270,10) -> (-180,10)  match LM (remove implausible -270)
  RH_ThC_yaw  : (-45,45)  -> (-90,45)   mirror of LH (-45,90)

*** NEEDS BIOMECHANICAL / NMF RANGE-OF-MOTION REVIEW ***: these are mirror-
derived consistency values only and have NOT been validated against measured
NeuroMechFly joint ranges. Isolated in this commit so it can be reverted
independently. tests/test_ik_robustness.py gains parametrized L/R mirror checks
plus a guard that no bound exceeds +/-180 deg.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces the earlier L/R-mirror-only fix (commit cb4ba95) with bounds derived
directly from the ground-truth simulated dof_angles range of motion (n=16000
frames across 500 atomic batches) padded by +/-10 deg. A data-driven check
found 10/42 of the prior bounds (including the mirror-fixed RF_ThC_roll, set to
[-90,10] but observed [-158.6,0.9]) were tighter than the actual RoM and would
clip valid poses. Flags RH_ThC_roll as angle-wrapping (source RoM exceeds
+/-180 deg) for upstream review.

Refs #48 (I3-B).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
scripts/verify_ik_selfconsistency.py unprojects the stored atomic-batch
keypoint_pos to 3D, runs the IK, and compares recovered joint angles to the
stored ground-truth dof_angles (matched BY NAME, wrapping-aware) -- validating
I3-A (DOF map / save path), I1-A (camera sensor size), and the IK solve. Also
has a flygym-free --emit-bounds mode that regenerates the I3-B data-derived
nmf_bounds. Full run needs flygym + seqikpy (and PR #51's pvio fix); --emit-bounds
runs standalone.

Refs #48.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Expand the comment above nmf_bounds with the full reproducible method (source
data, 500-batch/16000-frame sample, floor/ceil ± 10 deg margin, key mapping) and
the exact regeneration command, plus the rationale and caveats.

Refs #48.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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