IK robustness: restore deleted DOF map + NaN/order/bounds guards (I3-A..E)#52
Open
sibocw wants to merge 5 commits into
Open
IK robustness: restore deleted DOF map + NaN/order/bounds guards (I3-A..E)#52sibocw wants to merge 5 commits into
sibocw wants to merge 5 commits into
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_outputandproduction/spotlight/keypoints3d.py::_joint_angles_dict2arrboth callnmf_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 raisedAttributeError.Restored
dof_name_lookup_canonical_to_nmfinneuromechfly/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 buildf"Angle_{leg}_{dof_name}"fromdict.keys(); I verified end-to-end againstseqikpy.leg_inverse_kinematics.LegInvKinSeq, which emits exactly thoseAngle_{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 toproduction/spotlight/keypoints3d.pywas required — restoring the constant alone unblocks both call sites.I3-D (recording aborts on one occluded keypoint) — commit 1
invkin.pyhadassert 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-legValueErrorinstead of silently corrupting the chain.I3-E (silent wrong indexing if keypoint orders differ) — commit 1
align_fwdkin_xyz_to_rawpred_xyzindexed both the raw and the constrained arrays using the raw file'skeypoints_order, even though the constrained array is built byfwdkin_world_xyz_append_antennaewith its own ordering — silently wrong if the two ever diverged. Added akeypoints_order_constrainedparameter (defaults tokeypoints_orderfor back-compat) and now look up each array in its own order. The call site inrun_inverse_kinematics.pypasses the constrained order explicitly.I3-C (bad-solve / chunk-boundary transients) — commit 1
seqikpy seeds frame
tfrom framet-1, and withparallel_over_time=Trueit 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:correctness_criticalflag and aseqikpy_kwargspassthrough onprocess_all, so a caller can run withparallel_over_time=False(still parallel over legs) for correctness-critical runs; anddetect_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_boundschange 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 deglower 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); pitchRIGHT = LEFT. Changed (degrees):RF_ThC_rollRF_CTr_pitchRM_ThC_yawRM_CTr_pitchRH_ThC_yawMid/hind
ThC_rollandCTr_rollalready 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:
flygymis not installed in this environment (it does not yet support Python 3.13), andneuromechfly/constants.pyimportsfrom flygym.anatomy import JointDOF, soconstants.pyandinvkin.pycannot be imported directly. The tests therefore exercise the static definitions viaastand run the actual source of the pure numpy helpers in an isolated numpy-only namespace (extracted viaast, not reimplemented). Full IK import/run integration testing is blocked untilflygymis available; I additionally verified I3-A by reading the call sites end-to-end and confirming the key names/order against seqikpy's emittedAngle_{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_boundsre-derived from data (I3-B) — supersedes the mirror-only commitcb4ba95A 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-fixedRF_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):
dof_anglesin every atomic-batch_labels.h5, named/ordered via the dataset'skeysattribute (6 legs × 7 DOFs = 42)._labels.h5files drawn withnumpy.random.default_rng(0).choice(…, 500, replace=False)from the sorted recursive glob ofbulk_data/…/atomic_batches/4variants/**/*_labels.h5, all 32 frames each → n = 16,000 frames.bound = (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).{leg}{dof}(e.g.RFThC_yaw) → bounds key{leg}_{dof}(e.g.RF_ThC_yaw) thatKinematicChainSeqconsumes.Reproduce exactly (verified to reproduce the committed dict bit-for-bit):
The same method + caveats are also documented in the comment above
nmf_boundsinconstants.py.Caveats: (a) this is the training RoM, not the anatomical RoM — widen toward NeuroMechFly limits if production saturates a bound; (b)
RH_ThC_rollreaches −202° in the source kinematics → flagged as angle-wrapping (upstreamdof_angleslikely 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.pyUnprojects 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-boundsregenerator above.What a human needs to verify (test instructions)
flygym+seqikpyinstalled and PR Data pipeline: fix pvio import break + loader resolution guard (I1-C) + extraction script (I2-C) #51's pvio fix present (itsinvkinimport goes throughatomic_batch):--emit-bounds; widen toward NeuroMechFly's anatomical RoM if a bound saturates in production.[ThC_yaw, ThC_pitch, ThC_roll, …]while ground-truthdof_anglesare[ThC_pitch, ThC_roll, ThC_yaw, …]; compare/consume by name (the saved array carriesdof_names_per_leg); ensure any NMF replay reads by name, not position.Coxa, Coxa_roll, Coxa_yaw, Femur, Femur_roll, Tibia, Tarsus1), e.g. viaskeleton.get_actuated_dofs_from_preset(ActuatedDOFPreset.LEGS_ACTIVE_ONLY).