mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-15 08:00:41 +02:00
ft-hands fits a skeleton to each hand's 21 landmarks after the 3D step
(track/skeleton.{h,cpp}):
- Bone lengths and palm shape are learned per side from clean stereo
frames (medians, then a slow running median), with the model's average
hand times Hand::scale, or the other side, as the prior.
- Each frame: the palm as a rigid body, then each finger and the thumb as
a chain with joint limits (Gauss-Newton), the chain bases allowed a few
mm off the rigid palm. Landmarks are weighted by their triangulation
residual and down-weighted along the line of sight.
- The pose (palm centre, palm rotation, joint angles) is filtered, not the
points; each finger's cutoff follows its own speed.
- An elbow from the arm model in frame-hands' notes/forearm.md.
The hands file goes to version 2: version 1's struct is unchanged and an
fh_hand_fit_t per hand follows it (joints as position + quaternion,
bone lengths, angles, elbow, pinch point, fit quality, head-frame
velocities). Version 1's points and capsules come from the skeleton
(HANDS_SKELETON=0 or --skeleton 0 for the old ones). The hand
recorder's reader accepts version 2.
ft-handreplay reports the fit (track/skeval.h): time, residual,
calibration, bone-length spread, jitter, tip lag, elbow, and with
--labels the distance to frame-hands' auto-labels. SetReader reads a
hand-recorder take's later parts (sets-2.bin, ...).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
168 lines
7.5 KiB
C++
168 lines
7.5 KiB
C++
// Unit tests for the skeleton fit (track/skeleton.h) on made-up hands: poses within the
|
|
// joint limits are posed with the forward kinematics, noise is added, and the fit has to give
|
|
// them back. No models, no recordings. make check (in the dev container) builds and runs it.
|
|
#include "../track/skeleton.h"
|
|
|
|
#include <chrono>
|
|
#include <cstdio>
|
|
#include <random>
|
|
|
|
using namespace skel;
|
|
|
|
namespace {
|
|
|
|
int failures = 0;
|
|
#define CHECK(c) \
|
|
do { \
|
|
if (!(c)) std::printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #c), ++failures; \
|
|
} while (0)
|
|
|
|
double lo_hi(std::mt19937 &rng, double lo, double hi) { return std::uniform_real_distribution<double>(lo, hi)(rng); }
|
|
|
|
// A random but plausible pose: angles well inside the limits, the palm somewhere in front.
|
|
void random_pose(std::mt19937 &rng, Rot &R, V3 &t, double q[kParams]) {
|
|
R = exp_rot({lo_hi(rng, -1.5, 1.5), lo_hi(rng, -1.5, 1.5), lo_hi(rng, -1.5, 1.5)});
|
|
t = {lo_hi(rng, -0.25, 0.25), lo_hi(rng, -0.4, 0.0), lo_hi(rng, -0.6, -0.25)};
|
|
const double D = M_PI / 180;
|
|
q[0] = lo_hi(rng, -30, 30) * D, q[1] = lo_hi(rng, -30, 30) * D, q[2] = lo_hi(rng, -15, 15) * D;
|
|
q[3] = lo_hi(rng, -10, 10) * D, q[4] = lo_hi(rng, 0, 60) * D, q[5] = lo_hi(rng, 0, 70) * D;
|
|
for (int f = 0; f < 4; ++f) {
|
|
double *a = q + 6 + 5 * f;
|
|
a[0] = lo_hi(rng, -15, 15) * D, a[1] = lo_hi(rng, -10, 80) * D, a[2] = lo_hi(rng, -8, 8) * D;
|
|
a[3] = lo_hi(rng, 0, 90) * D, a[4] = lo_hi(rng, 0, 60) * D;
|
|
}
|
|
}
|
|
|
|
V3 cross(V3 a, V3 b) { return {a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]}; }
|
|
|
|
} // namespace
|
|
|
|
int main() {
|
|
std::mt19937 rng(7);
|
|
std::normal_distribution<double> noise(0, 1);
|
|
const Shape s = Skeleton::canonical(1.2);
|
|
|
|
// rotations
|
|
for (int i = 0; i < 100; ++i) {
|
|
const V3 w{lo_hi(rng, -2, 2), lo_hi(rng, -2, 2), lo_hi(rng, -2, 2)};
|
|
if (norm(w) > 3.1) continue;
|
|
const V3 back = log_rot(exp_rot(w));
|
|
CHECK(norm(back - w) < 1e-9);
|
|
double q[4];
|
|
to_quat(exp_rot(w), q);
|
|
const double a = 2 * std::atan2(std::sqrt(q[0] * q[0] + q[1] * q[1] + q[2] * q[2]), std::fabs(q[3]));
|
|
CHECK(std::fabs(a - norm(w)) < 1e-9);
|
|
}
|
|
|
|
// the frames: right-handed, -Z along the bone, +Y the back of the hand
|
|
{
|
|
Rot R;
|
|
V3 t{0, 0, -0.4}, pts[21];
|
|
Rot fr[21];
|
|
double q[kParams] = {};
|
|
for (int side : {1, -1}) {
|
|
Skeleton::pose(s, side, R, t, q, pts, fr);
|
|
for (int i = 0; i < 21; ++i) {
|
|
CHECK(norm(cross(fr[i].col(1), fr[i].col(2)) - fr[i].col(0)) < 1e-9);
|
|
if (i % 4 != 0 && i < 20 && i != 0) {
|
|
const int child = i + 1;
|
|
CHECK(dot(unit(pts[child] - pts[i]), fr[i].col(2) * -1) > 0.999);
|
|
}
|
|
}
|
|
// palm down, fingers forward (-z): the back of the hand is +y; the index knuckle is
|
|
// on the thumb's side: -x on a right hand, +x on a left one
|
|
CHECK(fr[0].col(1)[1] > 0.99);
|
|
CHECK(side * pts[5][0] < 0);
|
|
// flexing a finger moves its tip toward the palm (-y)
|
|
double qb[kParams] = {};
|
|
qb[6 + 3] = 1.0; // the index PIP
|
|
V3 bent[21];
|
|
Skeleton::pose(s, side, R, t, qb, bent, fr);
|
|
CHECK(bent[8][1] < pts[8][1] - 0.01);
|
|
// abduction moves the index toward the thumb
|
|
double qa[kParams] = {};
|
|
qa[6] = 0.3;
|
|
Skeleton::pose(s, side, R, t, qa, bent, fr);
|
|
CHECK(side * (bent[8][0] - pts[8][0]) < -0.005);
|
|
}
|
|
}
|
|
|
|
// fits: noise-free poses come back exactly; noisy ones close to the truth
|
|
double sl[21], sd[21];
|
|
for (int i = 0; i < 21; ++i) sl[i] = 0.002, sd[i] = 0.007;
|
|
for (const double sigma : {0.0, 0.002}) {
|
|
double worst_rms = 0, sum_err = 0, sum_raw = 0, us = 0;
|
|
int n = 0;
|
|
for (int trial = 0; trial < 300; ++trial) {
|
|
const int side = trial % 2 ? 1 : -1;
|
|
Rot R0, R;
|
|
V3 t0, t;
|
|
double q0[kParams] = {}, q[kParams];
|
|
random_pose(rng, R0, t0, q0);
|
|
V3 truth[21], meas[21], fit[21];
|
|
Rot fr[21];
|
|
Skeleton::pose(s, side, R0, t0, q0, truth, fr);
|
|
// noise as stereo has it: 3.5 times as much along the line of sight
|
|
for (int i = 0; i < 21; ++i) {
|
|
const V3 d = unit(truth[i]), e = V3{noise(rng), noise(rng), noise(rng)} * sigma;
|
|
meas[i] = truth[i] + e + d * (2.5 * dot(e, d));
|
|
}
|
|
double rms = 0;
|
|
const auto a = std::chrono::steady_clock::now();
|
|
const double cost = Skeleton::solve(s, side, meas, sl, sd, {0, 0, 0}, nullptr, 4, R, t, q, &rms);
|
|
us += std::chrono::duration<double, std::micro>(std::chrono::steady_clock::now() - a).count();
|
|
CHECK(cost >= 0);
|
|
Skeleton::pose(s, side, R, t, q, fit, fr);
|
|
double err = 0, raw = 0;
|
|
for (int i = 0; i < 21; ++i) err += norm(fit[i] - truth[i]) / 21, raw += norm(meas[i] - truth[i]) / 21;
|
|
worst_rms = std::max(worst_rms, rms);
|
|
sum_err += err, sum_raw += raw, ++n;
|
|
}
|
|
std::printf("noise %.1f mm (x3.5 in depth): fit rms worst %.2f mm; mean error to the truth: fitted %.2f mm, measured %.2f mm; "
|
|
"%.1f us a fit\n", sigma * 1000, worst_rms * 1000, sum_err / n * 1000, sum_raw / n * 1000, us / n);
|
|
if (sigma == 0) CHECK(worst_rms < 0.002);
|
|
else CHECK(sum_err < sum_raw); // the bone lengths take noise out
|
|
}
|
|
|
|
// calibration: from noisy samples of one hand, the lengths settle on it
|
|
{
|
|
Skeleton sk;
|
|
const Shape truth = Skeleton::canonical(1.3);
|
|
Fit f;
|
|
int64_t ns = 1'000'000'000;
|
|
for (int k = 0; k < 120; ++k, ns += 66'000'000) {
|
|
Rot R0;
|
|
V3 t0, pts[21];
|
|
Rot fr[21];
|
|
double q0[kParams] = {};
|
|
random_pose(rng, R0, t0, q0);
|
|
for (int a = 6; a < kAngles; ++a) q0[a] *= 0.3; // fairly open, so calibration takes it
|
|
Skeleton::pose(truth, 1, R0, t0, q0, pts, fr);
|
|
for (V3 &p : pts) p = p + V3{noise(rng), noise(rng), noise(rng)} * 0.0015;
|
|
sk.update(f, pts, true, 0.95, 2, 0.0015, nullptr, {0, 0, 0}, 1.0, ns);
|
|
CHECK(f.valid);
|
|
}
|
|
const Shape got = sk.shape(1, 1.0);
|
|
double worst = 0;
|
|
for (int b = 0; b < 20; ++b) worst = std::max(worst, std::fabs(got.len[b] - truth.len[b]));
|
|
std::printf("calibration: settled %.2f, worst bone off %.2f mm\n", sk.calibration(1).settled(), worst * 1000);
|
|
CHECK(sk.calibration(1).settled() >= 1);
|
|
CHECK(worst < 0.002);
|
|
// the left hand borrows the right's lengths until it has its own
|
|
const Shape left = sk.shape(-1, 1.0);
|
|
CHECK(std::fabs(left.len[5] - got.len[5]) < 1e-9);
|
|
}
|
|
|
|
// the elbow: behind and below a hand held in front, 2.8 palms away
|
|
{
|
|
const V3 wrist{0.15, -0.25, -0.35}, knuckle{0.15, -0.22, -0.43};
|
|
const V3 e = Skeleton::elbow(wrist, knuckle, 1, 0.23, {0, 1, 0});
|
|
std::printf("elbow for a right hand in front: %+.3f %+.3f %+.3f\n", e[0], e[1], e[2]);
|
|
CHECK(std::fabs(norm(e - wrist) - 0.23) < 1e-6);
|
|
CHECK(e[1] < wrist[1] && e[2] > wrist[2]);
|
|
}
|
|
|
|
std::printf(failures ? "%d FAILED\n" : "skeleton tests passed\n", failures);
|
|
return failures ? 1 : 0;
|
|
}
|