Skip to content

Commit e80ad5c

Browse files
committed
har_trees: Some inital code around computing orientation
1 parent 837219d commit e80ad5c

File tree

1 file changed

+250
-0
lines changed

1 file changed

+250
-0
lines changed

examples/har_trees/basic.py

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
2+
import array
3+
import math
4+
5+
DATA_TYPECODE = 'h' # int16
6+
7+
8+
def feature_names() -> list[str]:
9+
cls = Feature
10+
11+
features = {} # index -> name
12+
for key, value in cls.__dict__.items():
13+
if key.startswith('__') or callable(value):
14+
continue
15+
if features.get(value):
16+
raise ValueError(f"Duplicated value {value}")
17+
features[value] = key
18+
19+
names = []
20+
for idx, value in enumerate(sorted(features.keys())):
21+
if idx != value:
22+
raise ValueError(f'Holes in enum')
23+
24+
names.append(features[value])
25+
26+
return names
27+
28+
class Feature:
29+
orient_w = 0
30+
orient_x = 1
31+
orient_y = 2
32+
orient_z = 3
33+
sma = 4
34+
mag_rms = 5
35+
x_rms = 6
36+
y_rms = 7
37+
z_rms = 8
38+
39+
N_FEATURES = 9
40+
41+
42+
def compute_sma(x_data : array.array, y_data : array.array, z_data : array.array) -> float:
43+
"""Signal Magnitude Area (SMA)"""
44+
45+
n = len(x_data)
46+
sma_sum = 0.0
47+
for x, y, z in zip(x_data, y_data, z_data):
48+
sma_sum += abs(x) + abs(y) + abs(z)
49+
50+
sma = sma_sum / n
51+
return sma
52+
53+
def compute_magnitude_rms(x_data : array.array, y_data : array.array, z_data : array.array) -> float:
54+
55+
n = len(x_data)
56+
agg = 0.0
57+
for x, y, z in zip(x_data, y_data, z_data):
58+
agg += (x*x) + (y*y) + (z*z)
59+
60+
rms = math.sqrt(agg / n)
61+
return rms
62+
63+
def compute_rms(data : array.array) -> tuple[float]:
64+
65+
n = len(data)
66+
agg = 0.0
67+
for v in data:
68+
agg += (v*v)
69+
70+
rms = math.sqrt(agg / n)
71+
return rms
72+
73+
def compute_pitch_roll(x : float, y : float, z : float) -> tuple[float]:
74+
"""In degrees"""
75+
76+
roll = round(math.degrees(math.atan2(y, z)), 2)
77+
denominator = math.sqrt(y * y + z * z)
78+
pitch = round(math.degrees(math.atan2(-x, denominator)), 2)
79+
80+
return pitch, roll
81+
82+
83+
def calculate_features_xyz(xyz : tuple[array.array]) -> array.array:
84+
85+
xo, yo, zo = xyz
86+
87+
if not (len(xo) == len(yo) == len(zo)):
88+
raise ValueError("Input data lists must have the same length.")
89+
90+
window_length = len(xo)
91+
92+
# Output array
93+
feature_data = array.array('f', (0 for i in range(N_FEATURES)))
94+
95+
96+
# Gravity separation
97+
# FIXME: replace mean with a low-pass filter at 0.5 Hz. Say Chebychev 2 order
98+
# and to subtract the continues values.
99+
# !! need to be able to initialize IIR filter state to first sample
100+
gravity_x = sum(xo) / window_length
101+
gravity_y = sum(yo) / window_length
102+
gravity_z = sum(zo) / window_length
103+
104+
linear_x = array.array(DATA_TYPECODE, (0 for _ in range(window_length)))
105+
linear_y = array.array(DATA_TYPECODE, (0 for _ in range(window_length)))
106+
linear_z = array.array(DATA_TYPECODE, (0 for _ in range(window_length)))
107+
108+
109+
for i in range(window_length):
110+
i = int(i) # XXX ??
111+
linear_x[i] = int(xo[i] - gravity_x)
112+
linear_y[i] = int(yo[i] - gravity_y)
113+
linear_z[i] = int(zo[i] - gravity_z)
114+
115+
# Orientation
116+
# normalize gravity vector
117+
#gravity_magnitude = math.sqrt(gravity_x*gravity_x + gravity_y*gravity_y + gravity_z*gravity_z)
118+
#ox = gravity_x/gravity_magnitude
119+
#oy = gravity_y/gravity_magnitude
120+
#oz = gravity_z/gravity_magnitude
121+
#print(ox, oy, oz)
122+
123+
#roll, pitch = compute_pitch_roll(ox, oy, oz)
124+
#feature_data[Feature.pitch] = pitch
125+
#feature_data[Feature.roll] = roll
126+
127+
orientation_q = tilt_quaternion_from_accel(gravity_x, gravity_y, gravity_z)
128+
feature_data[Feature.orient_w] = orientation_q[0]
129+
feature_data[Feature.orient_x] = orientation_q[1]
130+
feature_data[Feature.orient_y] = orientation_q[2]
131+
feature_data[Feature.orient_z] = orientation_q[3]
132+
133+
134+
# Overall motion
135+
#feature_data[Feature.sma] = compute_sma(linear_x, linear_y, linear_z)
136+
#feature_data[Feature.mag_rms] = compute_magnitude_rms(linear_x, linear_y, linear_z)
137+
138+
# Per-axis motion
139+
#feature_data[Feature.x_rms] = compute_rms(linear_x)
140+
#feature_data[Feature.y_rms] = compute_rms(linear_y)
141+
#feature_data[Feature.z_rms] = compute_rms(linear_z)
142+
143+
print(orientation_q)
144+
145+
146+
return feature_data
147+
148+
149+
def normalize(v):
150+
mag = math.sqrt(sum(c*c for c in v))
151+
if mag == 0:
152+
return (0.0, 0.0, 0.0)
153+
return tuple(c / mag for c in v)
154+
155+
def dot(v1, v2):
156+
return sum(a*b for a, b in zip(v1, v2))
157+
158+
def cross(v1, v2):
159+
return (
160+
v1[1]*v2[2] - v1[2]*v2[1],
161+
v1[2]*v2[0] - v1[0]*v2[2],
162+
v1[0]*v2[1] - v1[1]*v2[0]
163+
)
164+
165+
def tilt_quaternion_from_accel(a_x, a_y, a_z):
166+
# Gravity vector measured by accelerometer (assumed already low-pass filtered)
167+
g = normalize((a_x, a_y, a_z))
168+
169+
# Reference "down" vector in world space
170+
ref = (0.0, 0.0, 1.0)
171+
172+
# Compute axis and angle between vectors
173+
axis = cross(ref, g)
174+
axis_mag = math.sqrt(sum(c*c for c in axis))
175+
176+
if axis_mag < 1e-6:
177+
# Vectors are nearly aligned or opposite
178+
if dot(ref, g) > 0.999:
179+
# Identity rotation (device is flat, facing up)
180+
return (1.0, 0.0, 0.0, 0.0)
181+
else:
182+
# 180° rotation around X or Y (choose arbitrary orthogonal axis)
183+
return (0.0, 1.0, 0.0, 0.0) # Flip around X
184+
185+
axis = normalize(axis)
186+
angle = math.acos(max(-1.0, min(1.0, dot(ref, g)))) # Clamp dot product to avoid domain error
187+
188+
# Convert axis-angle to quaternion
189+
half_angle = angle / 2
190+
sin_half = math.sin(half_angle)
191+
q_w = math.cos(half_angle)
192+
q_x = axis[0] * sin_half
193+
q_y = axis[1] * sin_half
194+
q_z = axis[2] * sin_half
195+
196+
return (q_w, q_x, q_y, q_z)
197+
198+
199+
def test_compute():
200+
201+
window_length = 50
202+
203+
x = array.array(DATA_TYPECODE, (0 for _ in range(window_length)))
204+
y = array.array(DATA_TYPECODE, (0 for _ in range(window_length)))
205+
z = array.array(DATA_TYPECODE, (0 for _ in range(window_length)))
206+
207+
features = calculate_features_xyz((x, y, z))
208+
209+
names = feature_names()
210+
assert len(names) == len(features)
211+
212+
213+
def test_pitch_rotation():
214+
215+
print("\n--- Simulating Pitch (X-axis) Rotation ---")
216+
# Rotate around X-axis (pitch)
217+
for angle_deg in range(-90, 91, 15):
218+
rad = math.radians(angle_deg)
219+
# Simulated gravity vector for X-axis rotation
220+
a_x = math.sin(rad)
221+
a_y = 0
222+
a_z = math.cos(rad)
223+
224+
a_xn, a_yn, a_zn = normalize(a_x, a_y, a_z)
225+
pitch, roll = compute_pitch_roll(a_xn, a_yn, a_zn)
226+
227+
print(f"{angle_deg:6} | {a_xn:+.2f} {a_yn:+.2f} {a_zn:+.2f} | {roll:+6.1f}° {pitch:+6.1f}°")
228+
229+
def test_roll_rotation():
230+
231+
print("\n--- Simulating Roll (Y-axis) Rotation ---")
232+
for angle_deg in range(-90, 91, 15):
233+
rad = math.radians(angle_deg)
234+
a_x = 0
235+
a_y = math.sin(rad)
236+
a_z = math.cos(rad)
237+
238+
a_xn, a_yn, a_zn = normalize(a_x, a_y, a_z)
239+
pitch, roll = compute_pitch_roll(a_xn, a_yn, a_zn)
240+
241+
print(f"{angle_deg:6} | {a_xn:+.2f} {a_yn:+.2f} {a_zn:+.2f} | {roll:+6.1f}° {pitch:+6.1f}°")
242+
243+
244+
if __name__ == '__main__':
245+
246+
#test_compute()
247+
test_pitch_rotation()
248+
test_roll_rotation()
249+
250+
print('PASS')

0 commit comments

Comments
 (0)