Add motion tracking and calibration features

This commit is contained in:
Nik Rozman committed 2026-10-14 09:37:41 +02:00
1 parent 4aa369ca73
commit aab61a2b7b
13 files changed
+735 -169

No files matched your search

+267
View File
@@ -0,0 +1,267 @@
#include "motion.h"
#include "buttons.h"
#include <math.h>
// Tuning
// Stillness / bias tracking: the gyro is "at rest" when every axis stays inside
// STILL_PP_LSB peak-to-peak for a whole window. Hand tremor alone is far above this,
// so the bias only updates when the remote is put down.
static const int STILL_WIN = 104; // samples (0.5 s)
static const int STILL_PP_LSB = 16; // 0.56 dps
static const int STILL_WINS_UPDATE = 2; // 1 s at rest before updating
static const int STILL_WINS_JUMP = 6; // 3 s at rest if the bias moved a lot
static const float BIAS_JUMP_RAD = 0.5f * (PI / 180.0f);
static const float BIAS_UPDATE_K = 0.3f;
// Gravity estimate: gyro-propagated, pulled toward the accelerometer with this
// time constant, only while |a| is close to 1 g (no hand acceleration).
static const float GRAV_TAU_S = 1.0f;
static const float GRAV_GATE_G = 0.15f;
static const float ROLL_MIN_HORIZ = 0.25f; // below this the remote points ~straight up/down
static const float ROLL_REANCHOR_UP = 0.5f; // button face within 60 deg of up: gravity is unambiguous
// Speed-adaptive smoothing (One Euro style): cutoff = MIN + BETA * speed.
static const float SMOOTH_MIN_HZ = 1.5f;
static const float SMOOTH_BETA = 20.0f; // Hz per rad/s
static const float SMOOTH_MAX_HZ = 40.0f;
static const float IDLE_RATE_RAD = 0.06f; // sleep/idle detection, raw rate
static const unsigned long CLICK_FREEZE_MS = 120; // hold cursor around button edges
static const float TEST_RATE_RAD = 0.5f;
static const int TEST_SEG_SAMPLES = (int)IMU_ODR_HZ; // 1 s per segment
// State
static float bias[3] = {0, 0, 0};
static bool biasValid = false;
static float up[3] = {0, 0, 1};
static bool upSeeded = false;
static float rollC = 1.0f, rollS = 0.0f;
static bool rollFromGravity = true; // next roll is taken from gravity as-is (no continuity)
static float filtX = 0, filtY = 0; // smoothed yaw/pitch, rad/s
static float accX = 0, accY = 0; // cursor counts not yet sent
static bool moved = false;
static int winN = 0;
static int32_t winSum[3];
static int16_t winMin[3], winMax[3];
static int stillWins = 0;
static int testSample = -1;
static MotionDebug dbg = {};
static void resetWindow() {
winN = 0;
for (int i = 0; i < 3; i++) { winSum[i] = 0; winMin[i] = 32767; winMax[i] = -32768; }
}
static void publishBiasStats() {
#ifdef FEATURE_TELEMETRY
statBiasRms = sqrtf((bias[0]*bias[0] + bias[1]*bias[1] + bias[2]*bias[2]) / 3.0f);
#endif
}
void motionReset() {
filtX = filtY = 0;
accX = accY = 0;
upSeeded = false;
rollFromGravity = true;
stillWins = 0;
resetWindow();
}
bool motionApplyCalibration(const float b[3], const float a[3], bool still) {
bool apply = still || !biasValid;
if (apply) {
for (int i = 0; i < 3; i++) bias[i] = b[i];
biasValid = still;
publishBiasStats();
}
motionReset();
float an = sqrtf(a[0]*a[0] + a[1]*a[1] + a[2]*a[2]);
if (an > 0.5f) {
for (int i = 0; i < 3; i++) up[i] = a[i] / an;
upSeeded = true;
}
return apply;
}
// Continuous bias tracking while the remote is at rest.
static void trackBias(const ImuRaw& r) {
const int16_t g[3] = {r.gx, r.gy, r.gz};
for (int i = 0; i < 3; i++) {
winSum[i] += g[i];
if (g[i] < winMin[i]) winMin[i] = g[i];
if (g[i] > winMax[i]) winMax[i] = g[i];
}
if (++winN < STILL_WIN) return;
int pp = 0;
float mean[3], jump = 0;
for (int i = 0; i < 3; i++) {
pp = max(pp, winMax[i] - winMin[i]);
mean[i] = (float)winSum[i] / winN * GYRO_RAD_PER_LSB;
jump = max(jump, fabsf(mean[i] - bias[i]));
}
resetWindow();
bool still = pp <= STILL_PP_LSB;
dbg.still = still;
stillWins = still ? stillWins + 1 : 0;
if (stillWins >= STILL_WINS_UPDATE) rollFromGravity = true; // put down: re-take roll from gravity
int needed = (biasValid && jump > BIAS_JUMP_RAD) ? STILL_WINS_JUMP : STILL_WINS_UPDATE;
if (stillWins < needed) return;
float k = biasValid ? BIAS_UPDATE_K : 1.0f;
for (int i = 0; i < 3; i++) bias[i] += k * (mean[i] - bias[i]);
biasValid = true;
calTempC = cachedTempC; // temperature compensation is relative to the last bias update
dbg.biasUpdates++;
publishBiasStats();
}
static void updateGravity(const float w[3], const float a[3]) {
float an = sqrtf(a[0]*a[0] + a[1]*a[1] + a[2]*a[2]);
if (!upSeeded) {
if (an < 0.5f) return;
for (int i = 0; i < 3; i++) up[i] = a[i] / an;
upSeeded = true;
return;
}
// A world-fixed vector seen from the body rotates as du/dt = u x w.
float ux = up[0] + (up[1]*w[2] - up[2]*w[1]) * IMU_DT;
float uy = up[1] + (up[2]*w[0] - up[0]*w[2]) * IMU_DT;
float uz = up[2] + (up[0]*w[1] - up[1]*w[0]) * IMU_DT;
float err = fabsf(an - 1.0f);
if (an > 0.1f && err < GRAV_GATE_G) {
float k = (IMU_DT / GRAV_TAU_S) * (1.0f - err / GRAV_GATE_G);
ux += k * (a[0]/an - ux);
uy += k * (a[1]/an - uy);
uz += k * (a[2]/an - uz);
}
float n = sqrtf(ux*ux + uy*uy + uz*uz);
if (n > 1e-6f) { up[0] = ux/n; up[1] = uy/n; up[2] = uz/n; }
}
void motionProcess(const ImuRaw& r, unsigned long nowMs) {
dbg.samples++;
#ifdef FEATURE_AUTO_RECAL
if (cfg.featureFlags & FLAG_AUTO_RECAL_ENABLED) trackBias(r);
#endif
float w[3] = { r.gx * GYRO_RAD_PER_LSB - bias[0],
r.gy * GYRO_RAD_PER_LSB - bias[1],
r.gz * GYRO_RAD_PER_LSB - bias[2] };
#ifdef FEATURE_TEMP_COMPENSATION
if (cfg.featureFlags & FLAG_TEMP_COMP_ENABLED) {
float corr = TEMP_COMP_COEFF_DPS_C * (cachedTempC - calTempC) * (PI / 180.0f);
w[1] -= corr; w[2] -= corr;
}
#endif
const float a[3] = { r.ax * ACCEL_G_PER_LSB, r.ay * ACCEL_G_PER_LSB, r.az * ACCEL_G_PER_LSB };
updateGravity(w, a);
// Roll compensation. Body frame in the enclosure: +X toward the wrist (tip is -X),
// +Y right, +Z out of the button face. Twisting the remote is a rotation about X,
// i.e. of the (gy, gz) plane. Rotate it back using gravity:
// yaw = w . n, n = (0, uy, uz)/|.| (de-rolled "up")
// pitch = w . r, r = (0, uz, -uy)/|.| (horizontal, across the pointing axis)
// Held flat this is exactly yaw=gz, pitch=gy. Roll rate (gx) never reaches the cursor.
//
// Gravity alone can't tell "tilted back past vertical" from "held upside down":
// both put the button face below the horizon. Stay on the branch closest to the
// previous roll, so tilting through vertical never flips the axes, while a real
// twist (always continuous) is still followed. The branch is re-taken from gravity
// whenever tracking restarts, the remote is put down, or the button face points
// roughly up: that pose is never ambiguous, so whatever happened during odd
// movements, a normal grip always tracks normally again.
// Near vertical gravity says nothing about twist, so there the twist is followed by
// integrating the gyro's rate around the pointing axis; otherwise spinning the
// remote while it points up would leave it on the wrong branch afterwards.
float horiz = sqrtf(up[1]*up[1] + up[2]*up[2]);
if (horiz > ROLL_MIN_HORIZ) {
float c = up[2] / horiz, s = up[1] / horiz;
bool anchored = rollFromGravity || up[2] > ROLL_REANCHOR_UP;
if (!anchored && c*rollC + s*rollS < 0.0f) { c = -c; s = -s; }
rollC = c; rollS = s;
rollFromGravity = false;
} else {
float d = w[0] * IMU_DT, cd = cosf(d), sd = sinf(d);
float c = rollC*cd - rollS*sd, s = rollS*cd + rollC*sd;
float n = sqrtf(c*c + s*s);
rollC = c / n; rollS = s / n;
}
float yaw = w[1]*rollS + w[2]*rollC;
float pitch = w[1]*rollC - w[2]*rollS;
if (testSample >= 0) {
int seg = testSample / TEST_SEG_SAMPLES;
yaw = pitch = 0;
if (seg == 0) yaw = -TEST_RATE_RAD; // cursor right
else if (seg == 1) yaw = TEST_RATE_RAD; // cursor left
else if (seg == 2) pitch = -TEST_RATE_RAD; // cursor down
else if (seg == 3) pitch = TEST_RATE_RAD; // cursor up
if (++testSample >= 5 * TEST_SEG_SAMPLES) testSample = -1; // 5th second lets the filter settle
}
dbg.yaw = yaw; dbg.pitch = pitch;
if (yaw*yaw + pitch*pitch > IDLE_RATE_RAD*IDLE_RATE_RAD || testSample >= 0) moved = true;
// Speed-adaptive low-pass: tremor and sensor noise are removed at low speed,
// while fast movements pass with almost no lag.
float sPrev = sqrtf(filtX*filtX + filtY*filtY);
float fc = min(SMOOTH_MIN_HZ + SMOOTH_BETA * sPrev, SMOOTH_MAX_HZ);
float alpha = 1.0f / (1.0f + 1.0f / (2.0f * PI * fc * IMU_DT));
filtX += alpha * (yaw - filtX);
filtY += alpha * (pitch - filtY);
// Radial soft dead zone: gain s^2/(s^2+dz^2) is continuous (no jump when motion
// starts) and acts on the speed, so diagonal motion keeps its direction.
float s = sqrtf(filtX*filtX + filtY*filtY);
float dz = cfg.deadZone;
float g = (s > 1e-9f) ? (s*s) / (s*s + dz*dz) : 0.0f;
float sv = s * g;
dbg.speed = sv;
if (sv < 1e-9f) return;
// Curve and acceleration act on speed, expressed as counts per 10 ms so existing
// settings keep their feel, then scale both axes equally.
float dref = cfg.sensitivity * sv * 0.01f;
float m = applyAcceleration(applyCurve(dref));
float scale = (m / dref) * cfg.sensitivity * IMU_DT * g;
float cx = -filtX * scale;
float cy = -filtY * scale;
if (cfg.axisFlip & 0x01) cx = -cx;
if (cfg.axisFlip & 0x02) cy = -cy;
#ifdef FEATURE_PHYSICAL_BUTTONS
// Pressing a button twists the remote slightly; hold the cursor still around the click.
if (btnLastEdgeMs && (nowMs - btnLastEdgeMs) < CLICK_FREEZE_MS && testSample < 0) return;
#endif
accX += cx; accY += cy;
}
bool motionTakeCounts(int8_t& dx, int8_t& dy) {
int ix = constrain((int)accX, -127, 127);
int iy = constrain((int)accY, -127, 127);
if (ix == 0 && iy == 0) return false;
accX -= ix; accY -= iy;
dx = (int8_t)ix; dy = (int8_t)iy;
return true;
}
void motionReturnCounts(int8_t dx, int8_t dy) { accX += dx; accY += dy; }
bool motionConsumeMoved() { bool m = moved; moved = false; return m; }
void motionStartTest() { testSample = 0; filtX = filtY = 0; accX = accY = 0; }
bool motionTestActive() { return testSample >= 0; }
const MotionDebug& motionDebug() {
for (int i = 0; i < 3; i++) { dbg.up[i] = up[i]; dbg.bias[i] = bias[i]; }
dbg.biasValid = biasValid;
dbg.rollDeg = atan2f(rollS, rollC) * (180.0f / PI);
return dbg;
}