Compare commits

...

2 Commits

Author SHA1 Message Date
crosstyan 0bfcfe5901 fix: track full-motion scene bounds and ignore runtime files
Accumulate scene bounds from actual geometry and marker samples across the full trajectory instead of seeding Range3D with sentinel extrema and only joining against the first sample.

Why this loader change is necessary:

- Viewer camera framing and reference sizing derive from scene.initialBounds.

- The old code only incorporated sample==0 geometry and marker positions, so motions that travel away from the initial pose could be framed incorrectly.

- The old sentinel initialization depended on joining with artificial min/max values instead of real bounds.

What changed:

- Use first-real-bound initialization in scaledBounds(), transformBounds(), and buildCpuMesh().

- Accumulate scene.initialBounds across all trajectory samples in src/OpenSimLoader.cpp.

- Ignore generated runtime files imgui.ini and opensim.log in .gitignore.

Validation:

- Rebuilt successfully with: cmake --build build -j
2026-03-11 11:56:25 +08:00
crosstyan ab728c7e9a fix: use shortest-path rotation interpolation in playback
Switch viewer playback from Magnum Math::slerp() to Math::slerpShortestPath() when interpolating adjacent OpenSim frame orientations.

Why:

- Adjacent OpenSim quaternions can cross sign while representing nearly identical orientations.

- Non-shortest-path interpolation can create artificial long-arc spins between valid sampled poses.

- That makes playback exaggerate or invent visible bone flips that are not present in the sampled frame states.

What changed:

- Updated playback interpolation in src/ViewerApp.cpp to use shortest-path quaternion slerp.

- Added docs/motion-troubleshooting.md documenting the distinction between viewer interpolation artifacts and upstream IK discontinuities.

- Added a README pointer to the troubleshooting note.

Investigation log:

- Verified the viewer loads .mot through OpenSim state storage and renders PhysicalFrame transforms directly.

- Reproduced the target Sports2D/Pose2Sim/OpenSim clip and confirmed the .mot already contains large coordinate discontinuities and limit clamping, indicating upstream IK failure.

- Confirmed the viewer also had a separate interpolation issue due to non-shortest-path quaternion slerp.

Validation:

- Rebuilt with: cmake --build build -j

- Relaunched the viewer successfully against the problematic .osim/.mot pair after the fix.
2026-03-11 11:54:41 +08:00
5 changed files with 84 additions and 19 deletions
+4
View File
@@ -45,3 +45,7 @@ compile_commands.json
Makefile Makefile
cmake_install.cmake cmake_install.cmake
Testing/ Testing/
# runtime files
imgui.ini
opensim.log
+4
View File
@@ -31,3 +31,7 @@ Sample run:
/home/crosstyan/Code/opensim-core/OpenSim/Moco/Test/walk_gait1018_subject01.osim \ /home/crosstyan/Code/opensim-core/OpenSim/Moco/Test/walk_gait1018_subject01.osim \
/home/crosstyan/Code/opensim-core/OpenSim/Moco/Test/walk_gait1018_state_reference.mot /home/crosstyan/Code/opensim-core/OpenSim/Moco/Test/walk_gait1018_state_reference.mot
``` ```
## Notes
- Motion troubleshooting notes: [`docs/motion-troubleshooting.md`](docs/motion-troubleshooting.md)
+45
View File
@@ -0,0 +1,45 @@
# Motion Troubleshooting
## Bone Flips During Playback
The viewer loads the `.mot` file through OpenSim, converts it to model states,
and renders each body using `PhysicalFrame::getTransformInGround()`. It does
not solve IK, rebuild bones from markers, or apply any custom joint logic.
Because of that, a visible flip can come from two different places:
1. The upstream motion data already contains a discontinuity.
2. Playback interpolation between two valid sampled poses introduces an
artificial long-arc rotation.
## Viewer Behavior
Playback now uses shortest-path quaternion interpolation between sampled OpenSim
poses. This avoids false in-between spins when adjacent quaternions have
opposite signs but represent nearly the same orientation.
If a flip is still visible after this fix, the sampled OpenSim motion itself is
already discontinuous.
## Upstream IK Failures
For Sports2D / Pose2Sim / OpenSim pipelines, broken IK usually shows up as one
or more of the following:
- sudden large frame-to-frame jumps in joint coordinates
- joint values snapping to model limits
- marker RMS staying low while anatomically implausible body orientations appear
- left/right ambiguity or bad depth reconstruction in the source TRC
Sports2D ultimately delegates IK generation to Pose2Sim, which then calls
`opensim.InverseKinematicsTool(...)`. If the generated `_ik.mot` already
contains large discontinuities, the viewer is only displaying that failure.
## Practical Check
To separate viewer issues from upstream IK issues:
1. Load the `.osim` and `.mot` in the viewer.
2. Scrub near the suspicious frame while paused.
3. If the sampled pose itself is wrong, the IK output is broken upstream.
4. If only the in-between motion looks wrong, interpolation is the likely cause.
+27 -18
View File
@@ -44,9 +44,8 @@ Color4 toColor(const OpenSim::Appearance& appearance) {
} }
Range3D scaledBounds(const Range3D& bounds, const Vector3& scale) { Range3D scaledBounds(const Range3D& bounds, const Vector3& scale) {
bool hasBounds = false;
Range3D out; Range3D out;
out.min() = Vector3{std::numeric_limits<Float>::max()};
out.max() = Vector3{-std::numeric_limits<Float>::max()};
for(const Vector3& corner: { for(const Vector3& corner: {
Vector3{bounds.min().x(), bounds.min().y(), bounds.min().z()}, Vector3{bounds.min().x(), bounds.min().y(), bounds.min().z()},
Vector3{bounds.min().x(), bounds.min().y(), bounds.max().z()}, Vector3{bounds.min().x(), bounds.min().y(), bounds.max().z()},
@@ -57,15 +56,17 @@ Range3D scaledBounds(const Range3D& bounds, const Vector3& scale) {
Vector3{bounds.max().x(), bounds.max().y(), bounds.min().z()}, Vector3{bounds.max().x(), bounds.max().y(), bounds.min().z()},
Vector3{bounds.max().x(), bounds.max().y(), bounds.max().z()}, Vector3{bounds.max().x(), bounds.max().y(), bounds.max().z()},
}) { }) {
out = Math::join(out, Range3D::fromSize(corner*scale, {})); const Range3D point = Range3D::fromSize(corner*scale, {});
if(hasBounds) out = Math::join(out, point);
else out = point;
hasBounds = true;
} }
return out; return out;
} }
Range3D transformBounds(const Range3D& bounds, const Matrix4& transform) { Range3D transformBounds(const Range3D& bounds, const Matrix4& transform) {
bool hasBounds = false;
Range3D out; Range3D out;
out.min() = Vector3{std::numeric_limits<Float>::max()};
out.max() = Vector3{-std::numeric_limits<Float>::max()};
for(const Vector3& corner: { for(const Vector3& corner: {
Vector3{bounds.min().x(), bounds.min().y(), bounds.min().z()}, Vector3{bounds.min().x(), bounds.min().y(), bounds.min().z()},
Vector3{bounds.min().x(), bounds.min().y(), bounds.max().z()}, Vector3{bounds.min().x(), bounds.min().y(), bounds.max().z()},
@@ -76,7 +77,10 @@ Range3D transformBounds(const Range3D& bounds, const Matrix4& transform) {
Vector3{bounds.max().x(), bounds.max().y(), bounds.min().z()}, Vector3{bounds.max().x(), bounds.max().y(), bounds.min().z()},
Vector3{bounds.max().x(), bounds.max().y(), bounds.max().z()}, Vector3{bounds.max().x(), bounds.max().y(), bounds.max().z()},
}) { }) {
out = Math::join(out, Range3D::fromSize(transform.transformPoint(corner), {})); const Range3D point = Range3D::fromSize(transform.transformPoint(corner), {});
if(hasBounds) out = Math::join(out, point);
else out = point;
hasBounds = true;
} }
return out; return out;
} }
@@ -114,15 +118,17 @@ std::shared_ptr<CpuMesh> buildCpuMesh(const SimTK::PolygonalMesh& mesh) {
} }
} }
bool hasBounds = false;
Range3D bounds; Range3D bounds;
bounds.min() = Vector3{std::numeric_limits<Float>::max()};
bounds.max() = Vector3{-std::numeric_limits<Float>::max()};
cpu->vertices.reserve(positions.size()); cpu->vertices.reserve(positions.size());
for(std::size_t i = 0; i != positions.size(); ++i) { for(std::size_t i = 0; i != positions.size(); ++i) {
const Vector3 normal = normals[i].isZero() ? Vector3::yAxis() : normals[i].normalized(); const Vector3 normal = normals[i].isZero() ? Vector3::yAxis() : normals[i].normalized();
cpu->vertices.push_back({positions[i], normal}); cpu->vertices.push_back({positions[i], normal});
bounds = Math::join(bounds, Range3D::fromSize(positions[i], {})); const Range3D point = Range3D::fromSize(positions[i], {});
if(hasBounds) bounds = Math::join(bounds, point);
else bounds = point;
hasBounds = true;
} }
cpu->bounds = bounds; cpu->bounds = bounds;
return cpu; return cpu;
@@ -302,8 +308,7 @@ LoadedScene loadScene(const std::string& modelPath,
} }
for(MarkerTrack& marker: scene.markers) marker.positions.reserve(trajectory.getSize()); for(MarkerTrack& marker: scene.markers) marker.positions.reserve(trajectory.getSize());
scene.initialBounds.min() = Vector3{std::numeric_limits<Float>::max()}; bool hasSceneBounds = false;
scene.initialBounds.max() = Vector3{-std::numeric_limits<Float>::max()};
for(std::size_t sample = 0; sample != trajectory.getSize(); ++sample) { for(std::size_t sample = 0; sample != trajectory.getSize(); ++sample) {
const SimTK::State& state = trajectory[sample]; const SimTK::State& state = trajectory[sample];
@@ -317,19 +322,23 @@ LoadedScene loadScene(const std::string& modelPath,
scene.frames[i].translations.push_back(translation); scene.frames[i].translations.push_back(translation);
scene.frames[i].rotations.push_back(rotation); scene.frames[i].rotations.push_back(rotation);
if(sample == 0) { const Matrix4 world = frameMatrix(translation, rotation);
const Matrix4 world = frameMatrix(translation, rotation); for(const GeometrySpec& geometry: scene.frames[i].geometries) {
for(const GeometrySpec& geometry: scene.frames[i].geometries) { const Range3D scaled = scaledBounds(geometry.mesh->bounds, geometry.scale);
const Range3D scaled = scaledBounds(geometry.mesh->bounds, geometry.scale); const Range3D worldBounds = transformBounds(scaled, world);
scene.initialBounds = Math::join(scene.initialBounds, transformBounds(scaled, world)); if(hasSceneBounds) scene.initialBounds = Math::join(scene.initialBounds, worldBounds);
} else scene.initialBounds = worldBounds;
hasSceneBounds = true;
} }
} }
for(std::size_t i = 0; i != markerPtrs.size(); ++i) { for(std::size_t i = 0; i != markerPtrs.size(); ++i) {
const Vector3 position = toVector3(markerPtrs[i]->getLocationInGround(state)); const Vector3 position = toVector3(markerPtrs[i]->getLocationInGround(state));
scene.markers[i].positions.push_back(position); scene.markers[i].positions.push_back(position);
if(sample == 0) scene.initialBounds = Math::join(scene.initialBounds, Range3D::fromSize(position, {})); const Range3D point = Range3D::fromSize(position, {});
if(hasSceneBounds) scene.initialBounds = Math::join(scene.initialBounds, point);
else scene.initialBounds = point;
hasSceneBounds = true;
} }
} }
+4 -1
View File
@@ -343,7 +343,10 @@ void ViewerApp::updateSceneAtCurrentTime() {
for(std::size_t i = 0; i != _frames.size(); ++i) { for(std::size_t i = 0; i != _frames.size(); ++i) {
const Vector3 translation = Math::lerp(_sceneData.frames[i].translations[sample], _sceneData.frames[i].translations[next], alpha); const Vector3 translation = Math::lerp(_sceneData.frames[i].translations[sample], _sceneData.frames[i].translations[next], alpha);
const Quaternion rotation = Math::slerp(_sceneData.frames[i].rotations[sample], _sceneData.frames[i].rotations[next], alpha).normalized(); const Quaternion rotation = Math::slerpShortestPath(
_sceneData.frames[i].rotations[sample],
_sceneData.frames[i].rotations[next],
alpha).normalized();
_frames[i].object->setTransformation(makeFrameMatrix(translation, rotation)); _frames[i].object->setTransformation(makeFrameMatrix(translation, rotation));
} }