Correcting a Spinning Collar: Orientation Fusion at the Edge
A pet collar is the worst-behaved sensor mount in consumer hardware. It rotates, flips, rides up the neck, and settles at a different angle every time the dog shakes off after a nap. Yet the very first thing our activity pipeline must do is count steps from the inertial sensor riding on that collar, and a step counter that assumes "up is up" falls apart the moment the device twists ninety degrees. At Hoomanely, we treat the collar's unpredictable orientation as a solved problem, not an excuse for noisy data. This post walks through how we recover a stable, gravity-aligned signal from an arbitrarily mounted inertial sensor using a closed-form solution to a sixty-year-old aerospace problem, and why that bit of linear algebra is the quiet foundation under every daily step count we report.
The problem: raw axes are meaningless
When a pedometer runs on a phone in your pocket, it can lean on a comforting assumption: the device is roughly upright most of the time, so the vertical axis carries most of the walking signal. A collar grants no such luxury.
The inertial sensor measures acceleration along its own three axes, X, Y, Z fixed to the circuit board. But the board's relationship to the dog's body changes constantly. The same trot can show up mostly on the Z axis for one dog, mostly on X for another, and smeared across all three after the collar slips.
If you naively take the magnitude of the acceleration vector to dodge the orientation problem, you throw away the directional structure that makes gait detectable in the first place. Footfalls become indistinguishable from a vigorous head-scratch. The orientation problem is not a nuisance to filter out, it is the first computation in the pipeline.

The approach: borrow from spacecraft
The cleanest way to make the signal orientation-independent is not to filter harder, it is to rotate the data into a canonical frame before we analyze it. We want every sample expressed as if the sensor had always been mounted in one ideal posture, with gravity pointing down a known axis.
Gravity is the gift that makes this possible. A roughly stationary sensor always feels a 1 g pull toward the earth, regardless of how the collar is twisted. If we can estimate that gravity direction in the sensor's own frame, we know exactly how the sensor is tilted, and therefore exactly how to rotate the data to undo that tilt.
Finding the single rotation that best aligns a set of measured vectors to a set of reference vectors is a classic problem in aerospace attitude determination, formalized by Grace Wahba in 1965. Wahba's problem asks for the rotation matrix that minimizes the alignment error between two sets of paired vectors. Spacecraft use it to align star-tracker readings; we use it to align a dog's collar. The elegant part is that it has a closed-form solution via the singular value decomposition (SVD), no iterative solver, no gradient descent, just one matrix factorization.

Four moves from raw to aligned
Step 1: isolate gravity with a very low-pass filter. Walking, scratching, and bumps all live above roughly 1 Hz. Gravity is, by definition, the part of the signal that barely changes. We run a 4th-order zero-phase low-pass at 0.1 Hz over each axis to extract a clean gravity estimate per sample.
b, a = _butter_lp(cfg.gravity_lp_hz, cfg.fs, order=cfg.zc_filter_order)
g_est = filtfilt(b, a, accel, axis=0)
norms = np.linalg.norm(g_est, axis=1)
valid = np.abs(norms - 1.0) < cfg.sphere_tol_gStep 2: reject samples where gravity is a lie. During a hard bounce or a sudden direction change, the measured vector is dominated by motion, not gravity, so its magnitude drifts away from 1 g. We keep only samples whose estimated gravity sits within a tolerance of unit length, and then we go one step further: we drop any sample whose surrounding 10-second window straddles a posture change.
# 10 s posture-transition window: only accept samples whose surrounding
# window has a high fraction of in-tolerance gravity.
win = max(int(cfg.posture_window_s * cfg.fs), 1)
if win > 1:
kernel = np.ones(win) / win
accept_rate = np.convolve(valid.astype(float), kernel, mode="same")
valid = valid & (accept_rate > 0.8)That convolution is a cheap moving average over the boolean "is this gravity trustworthy" flag. If fewer than 80% of the samples in the window are trustworthy, we treat the whole neighborhood as a transition (the dog lying down, standing up, reorienting) and exclude it from the rotation fit.
Step 3: solve for the rotation. With a set of trustworthy gravity vectors and a single target direction (we want gravity to map to the +Z axis), we solve Wahba's problem in closed form. The whole solver is six lines:
if weights is None:
weights = np.ones(len(v))
B = (weights[:, None, None] * (w[:, :, None] @ v[:, None, :])).sum(axis=0)
U, _, Vt = np.linalg.svd(B)
M = np.diag([1.0, 1.0, np.linalg.det(U) * np.linalg.det(Vt)])
return U @ M @ VtThe B matrix accumulates the outer products of each measured vector with its target. The SVD factors it, and the M diagonal, using the determinants of U and Vt, guarantees the result is a proper rotation rather than a reflection. That single line is what stops the math from accidentally mirroring the signal, a subtle bug that would silently corrupt every downstream metric.
Step 4: project into the canonical frame. Finally, we apply the rotation to the entire stream and pull out the now-stable dorso-ventral component, the up-and-down through the dog's body axis where footfalls show up most cleanly.
target = np.tile(np.asarray(cfg.target_axis, dtype=float), (valid.sum(), 1))
# Each gravity vector should map to +Z; normalise to unit length first.
v = g_est[valid] / norms[valid, None]
R = _solve_wahba_svd(v, target)
rotated = accel @ R.T
return rotated[:, 2]Notice the guard built into the broader function: if too few valid gravity samples survive the gate, we fall back to the identity rotation rather than fit a wild transform to noise. A bad rotation is worse than no rotation, so the conservative default protects the metric when the dog simply hasn't held still.
The results
Once the signal is in the canonical frame, everything downstream gets simpler and more honest. The dorso-ventral axis carries a clean, repeatable gait waveform whether the collar sat at twelve o'clock or rolled to three o'clock overnight. The step-detection stages that follow, zero-crossing detection, a stride-frequency band gate, and a periodicity check, operate on a signal that means the same thing for every dog and every mounting angle.
Crucially, this stage is deterministic and cheap. There's no learned model to drift, no per-device calibration ritual for the pet parent to perform, and no orientation assumptions baked into the hardware design. The collar can be clipped on however it lands, and the firmware-side capture plus this analysis stage absorb the messiness for us.
Why it matters at Hoomanely
Activity is one of the most powerful early-warning signals in that picture. A dog that quietly walks less each day may be developing joint pain, cardiac issues, or simply aging faster than its breed should. But that insight is only as trustworthy as the step count underneath it, and a step count built on un-corrected, orientation-dependent data would generate false alarms and miss real declines.
Orientation fusion is therefore not a nice-to-have flourish. It is the layer that makes the rest of the pipeline credible. Getting the very first transform right, before any AI ever sees the data, is what that principle looks like in firmware.

Key takeaways
Raw sensor axes on a freely-mounted device are meaningless without orientation correction. Gravity gives you a free, always-available reference direction to solve for tilt. Wahba's problem has a closed-form SVD solution, no iterative solver needed. Reject samples during posture transitions, since fitting a rotation on unstable gravity estimates corrupts everything downstream. And fall back to identity rotation when data is insufficient, a bad rotation is worse than no rotation.