121е. A TinyML course project: your own model for a task from scratch to the firmware — gesture recognition with a wristband on the STM32F407
Part XIV took the steps one at a time: approaches — in 116 and 116а, how a network learns — in 116б, features and data — in 117 and 117а, training — in 118 and 118а, int8 — in 119, compression — in 119а, deployment — in 119б, a stream with smoothing for audio — in 121б. Here all the steps are done in a row on one task, as in a course project: a wristband recognises four gestures. Problem statement and budget, the collection firmware, recordings of 24 people, exploration and cleaning, two models and a choice, int8, inference in C, the recognition firmware with smoothing, a check on people the model has never seen, and the totals for flash, RAM, instructions and energy.
The wristband is an F4-Discovery board on a strap: it already carries the LIS3DSH
accelerometer on SPI1 (section 198л). All the files of the stand are in _verify/ml121g/;
pipe121g.py runs them in order: Python in the stm-ml:1
and stm-tf:1 images, C — gcc in stm-sys:1 and arm-none-eabi-gcc with QEMU in
stm-arm:1, sizes — armclang for the F407. The data are synthetic, the seed is
fixed: a rerun produces the same files down to the byte.
Background: a frame with a CRC and reception on the computer — 117а; the split by people — 118.3 and 118а.1; quantization, scale and rescaling — 119; the stream, ring and overlapping window in TFLM — 119б.5; a finite state machine instead of a model — 198г.
Terms used in the documentation
Term Meaning gesture recognition recognising gestures output data rate (ODR) how often the sensor delivers samples full scale the measurement limit, ±4 g anti-aliasing filter bandwidth the bandwidth of the filter against aliasing auto-increment (IF_ADD_INC) the register address grows by itself during a burst read data ready, overrun (ZYXDA, ZYXOR) a sample is ready; a sample was overwritten by a new one exploratory data analysis (EDA) exploring the data subject-wise split, leave-subjects-out splitting by people null class, background the "no gesture" class, background sliding window, hop a sliding window, its shift majority vote voting over several windows hysteresis switching on is hard, switching off is separate rejection threshold the "not sure" threshold false trigger a false event event-level metrics counting events rather than windows
121е.1 Problem statement, budget and project tree
The task: the wristband recognises four gestures — a swipe across the forearm, a wrist twist there and back, a double tap of a finger on the wristband and a circle with the wrist, — and treats everything else as the "no gesture" class: rest, typing, walking with an arm swing and "almost gestures" such as reaching for a cup. The answer is an event: one gesture — one event, without a burst of triggers.
The budget is written before the first line of code — it is what the model is chosen by later:
| Requirement | Target |
|---|---|
| Reaction after the end of a gesture | no later than 0.5 s |
| Missed gestures on a new person | no more than 5 % |
| False events | no more than one per 10 minutes |
| Flash for recognition | no more than 16 KB |
| RAM for recognition | no more than 4 KB |
| CPU time at 168 MHz | no more than 1 % |
The project tree — a convenient shape to hand it in; on the right are the files of the stand:
tinyml-bracelet/
fw_collect/ прошивка сбора: датчик, кадр, UART collect121g.c
data/ сырые записи и разметка, не правятся sensor.bin, scenario.txt (пишет sim121g.py)
host/ приём, разведка, чистка, окна prep121g.py
train/ модели, выбор, выгрузка в C cnn121g.py, train121g.py
fw_recog/ прошивка распознавания recog121g.c, model121g.h
test/ C против Python, команды в QEMU run121g.sh, bench121g.c
report/ сводки прогонов *121g.txt
The order of work goes from risks to comfort: first the collection firmware and reception with a byte-for-byte check, then exploration, and only then the models. The C-versus-Python check is set up at once, on the very first model: a mismatch of one rounding (121е.5) is visible only to it.
121е.2 The collection firmware: the sensor on SPI and a frame with a CRC
Setting up the sensor takes three register writes; the values come from lis3dsh.h of the
Keil pack STM32F4xx_DFP 2.17.1:
int lis_init(void)
{
if (lis_rd(LIS_WHO_AM_I) != 0x3Fu)
return -1;
lis_wr(LIS_CTRL6, CTRL6_ADD_INC);
lis_wr(LIS_CTRL5, CTRL5_4G_BW50);
lis_wr(LIS_CTRL4, CTRL4_50HZ_XYZ);
return lis_rd(LIS_CTRL4) == CTRL4_50HZ_XYZ && lis_rd(LIS_CTRL5) == CTRL5_4G_BW50 ? 0 : -2;
}
CTRL_REG4 = 0x57: 50 Hz (LIS3DSH_DATARATE_50 = 0x50) and three axes
(LIS3DSH_XYZ_ENABLE = 0x07). The scale is ±4 g, 0.12 mg per unit
(LIS3DSH_SENSITIVITY_0_12G). The BSP example sets ±2 g, 100 Hz and a bandwidth of
800 Hz; the project recordings have 191 samples where at least one axis exceeds
1966 mg — the limit of the ±2 g scale (0.06 mg × 32 768), — and there it would have clipped.
CTRL_REG5 = 0xC8 is built from the register description in the same header: the filter
bandwidth is bits 7:6, the scale is bits 5:3. The ready-made bandwidth constants do not
work for this (pitfalls 121е.7). CTRL_REG6 = 0x10 is the IF_ADD_INC bit:
the six bytes of X, Y, Z are read in one transfer, the address grows by itself:
spi_byte(LIS_RD | LIS_OUT_X_L); /* шесть байт подряд: X_L X_H Y_L Y_H Z_L Z_H */
v[i] = (int16_t)((uint16_t)b[2 * i] | (uint16_t)b[2 * i + 1] << 8);
Readiness is the ZYXDA bit of the STATUS register, the ZYXOR bit means a sample was overwritten
before the previous one was read; the firmware counts them. The frame uses the format of section 117а,
type 3: 16 samples × 3 axes × 2 bytes, 108 bytes per 0.32 s — 337.5 bytes
per second, 2.9 % of the 11 520 bytes per second of a UART at 115 200 baud. armclang
with -Os built the collection firmware into 914 bytes of code, of them collect — 400,
lis_init — 74.
On the computer the same collect() works with a sensor model: the model
answers the same SPI transfers — WHO_AM_I, register writes, STATUS,
six bytes with auto-increment — and serves the stream from sensor.bin. Over
24 recordings the firmware produced 12 248 frames, 1 324 944 bytes, overwritten
samples — 0. This is how the collection firmware is checked before it is flashed into the
board: everything that does not involve pins and clocks has already been run.
121е.3 Participants' recordings, exploration and cleaning
24 participants, each with their own tempo (0.8–1.25 of normal), force, hand tilt up to 25°, axis zero offset up to 40 mg, axis sensitivity spread of 3 % and noise; two of them — numbers 5 and 20 — wear the wristband on the left hand. A session is 8 repetitions of each gesture in random order, with 1.5–3.5 s of background between them, and at the end a minute of background only. In total 196 151 samples, 3923 s, 192 repetitions of each gesture. The split is by people: 0–13 — training, 14–17 — tuning, 18–23 — testing; there is a left-hander among the test people.
To give exploration something to find, the stand spoils the data the way real life spoils them. For three people bits flip in the UART — one per 1000 bytes, — for one, chunks of 40 bytes disappear. For participant 9 the gesture prompter was a second early.
What exploration found:
- Channel. By frame numbers 167 frames were lost, 53.4 s of recording; the CRC fired 156 times, the rest were eaten by damaged sync words. The clean 20 recordings matched the sensor stream sample for sample.
- Gravity. The magnitude of acceleration at rest ranges from 947 to 1050 mg: this is how the zero offset and the sensitivity spread look. No samples hit the limit of the ±4 g scale.
- Left hand. The average swipe on the Y axis is at least +473 mg for right-handers,
and −523 and −995 mg for the two left-handers: the Y axis is mirrored. The hand is recorded
in the metadata of the first frame (
hand=L) — exploration only confirmed it. - Labels. Cross-correlation of "activity × labels" gave a shift of 50 samples, 1.00 s, for participant 9 and 0 for everyone else.
Cleaning: for left-handers the Y axis changes sign (the firmware will do the same from the hand setting), the labels of participant 9 are shifted by 50 samples, windows with lost frames are thrown out. A window's label is a gesture if the window holds at least 80 % of its length; "no gesture" if the window touches no gesture at all; the remaining windows are ambiguous and do not go into training. The training people, with a hop of 4, gave 16 587 "no gesture" windows and 1302, 1150, 1316 and 966 gesture windows; ambiguous windows — 6202, windows with a gap — 1904. Both models correct the skew towards background with class weights.
121е.4 Two models and a choice
Forest (116а). The window features are 21 integers: for each axis the sum, the range, the sum of absolute differences, the number of jumps larger than 4000 units (480 mg in 20 ms — a tap), the spread, the difference between the window halves; and three pairwise covariances. All are integers, all are below 2²⁴: C computes them exactly as numpy does, and scikit-learn does not lose them when converting to float32. The size was chosen on people 14–17: 5 trees of depth 6 — 1456 correct windows out of 1467, 10 and 6 — 1455, 10 and 8 — 1462, 20 and 8 — 1464. The choice is 10 trees of depth 8: within two windows of the best, and half the size. The forest has 1018 nodes and 514 leaves.
Network (118а). Window 64 × 3 → Conv1D(8, 5, strides=2, ReLU) →
Conv1D(16, 5, strides=2, ReLU) → global maximum → Dense(5):
869 parameters. Early stopping on people 14–17 — 45 epochs, the best weights
from the 37th.
On 2446 unambiguous windows of six new people:
| Model | Correct of 2446 |
|---|---|
| Forest, scikit-learn | 2426 |
| Forest, leaves in units of 1/255, integer features | 2426 |
| Network, float32 | 2443 |
| Network, int8 | 2443 |
The integer forest answered like scikit-learn in all 2446 windows; the int8 network answered like float32 in all 2446.
An honest split. The same forest trained on shuffled windows is right on held-out windows, shuffled the same way, in 2443 cases out of 2446 — 17 more than on new people. When shuffled, the model saw neighbouring windows of the same people and measures memory rather than skill.
Left-hander. Without mirroring the Y axis the forest is right on left-hander 20 in 372 windows out of 394, with the mirror — in 394. If left-hander 5 is not mirrored in training either, the forest learns both hands and answers 394: but only because a left-hander was in the training set.
The choice is not made on windows: the device answers with events, not windows. The comparison is in 121е.6, on the stream.
121е.5 int8 and inference in C
The network was quantized by the TensorFlow Lite converter to full int8
(section 119), the representative set is 600 training windows. The file is
6144 bytes, the operators are EXPAND_DIMS, CONV_2D, RESHAPE, REDUCE_MAX,
FULLY_CONNECTED: Conv1D became CONV_2D with a height of 1. The input scale is
0.0177715234, the zero point −34: the input covers −1642 to 2813 mg.
Anything stronger clips: there are 32 such values out of 148 905 in the stream of the test
people.
Inference in C is our own, without a library (TFLM — section 119б): two convolutions, a maximum
and a fully connected layer in int8, rescaling as in the reference kernels of
TFLite. The reference is the TensorFlow Lite 2.21.0 interpreter with the
BUILTIN_REF kernels. In the convolution rescaling uses two roundings —
SaturatingRoundingDoublingHighMul, then RoundingDivideByPOT. In the
fully connected layer with a scale per output the reference rounds once,
in 64 bits:
static int8_t requant64(int32_t acc, int32_t m, int sh, int zo)
{
int ts = 31 - sh;
int64_t v = (((int64_t)acc * m + ((int64_t)1 << (ts - 1))) >> ts) + zo;
With two roundings there as well, the network outputs matched the reference in 3036 windows out of 3081, with one — in all 3081. The events did not change, but the bit-for-bit check showed the difference at once.
The forest in C is arrays of nodes: feature number, threshold, left and right child;
a leaf holds five fractions of 1/255 in bytes. A scikit-learn threshold is the midpoint
t between two integer values of a feature, and for an integer feature the comparison
x <= floor(t) gives exactly the same result. The answer is the sum of leaves over the trees:
n = f[RF_FEAT[n]] <= RF_THR[n] ? RF_LEFT[n] : RF_RIGHT[n];
121е.6 The stream, a new person and the final budget
The recognition firmware puts every sample into a 128-sample ring
(for a left-hander — with the Y axis mirrored), and every 16 samples takes a window of 64:
1.28 s, 75 % overlap — a smaller hop than in 119б, so that the reaction is
faster. A window's answer is the class with the largest output, or "no gesture" if the
confidence is below the threshold. For the network the confidence is a softmax over the int8 outputs without
expf: output differences go from 0 to −255, and their exp is a table:
sum += CNN_EXP[q[c] - q[k]];
return (uint64_t)65536 * 100 >= (uint64_t)CNN_TAU * sum ? c : 0;
Smoothing is voting and hysteresis: an event comes when a gesture has collected 2 votes out of the last 3 windows; switching off takes 2 windows in a row without it, and only then may the next event come:
if ((s->hist[0] == c) + (s->hist[1] == c) + (s->hist[2] == c) >= 2) {
s->off = d != s->state ? s->off + 1 : 0;
The thresholds were chosen on people 14–17 by the score "correct minus wrong class minus false", and among equals the strictest: 0.7 for the forest, 0.9 for the network. For the network a threshold anywhere from 0 to 0.9 did not change a single event on the tuning set: it is confident almost always — only 60 windows out of 3081 on the test set are below 0.9, for the forest — 515.
The test is six new people, 16.5 minutes of recording, 192 gestures, of them 6 minutes of background only. An event counts for a gesture if the end of the window came no later than 1.92 s after the end of the gesture; a second event for the same gesture is false:
| Model and processing | Correct | Wrong gesture | Missed | False |
|---|---|---|---|---|
| Forest, the answer of every window | 186 | 5 | 1 | 23 |
| Forest, 2 of 3 and hysteresis | 189 | 2 | 1 | 3 |
| Forest, the same and threshold 0.7 | 187 | 0 | 5 | 1 |
| Network, the answer of every window | 178 | 14 | 0 | 26 |
| Network, 2 of 3 and hysteresis | 192 | 0 | 0 | 1 |
| Network, the same and threshold 0.9 | 192 | 0 | 0 | 0 |
Without smoothing both models are right on 99 % of unambiguous windows and still give 23–26 false events: the window moves by 16 samples, one gesture falls into several windows in a row and splits into events, and the answers at the edges of a gesture give 5 and 14 "wrong gesture" events. Voting and hysteresis removed almost everything. On the tuning set the network scored 115, the forest 114, with 0 false events for both; the network was chosen — it is also smaller in flash. The network gets 32 gestures out of 32 on each of the six new people; the forest gets 29 on the worst. The network reacts on average 0.153 s after the end of a gesture, no later than 0.5 s.
The budget — sizes from the armclang object file (-O2), instructions from QEMU
netduinoplus2 with -icount, stack from -fstack-usage of arm-none-eabi-gcc:
| Item | Forest | Network |
|---|---|---|
| Flash: the model and its code | 13 016 bytes | 4372 bytes |
| Flash: ring, window, smoothing | 610 bytes | 610 bytes |
| RAM: the ring and the window on the stack | 1160 bytes | 1160 bytes |
| RAM: layer buffers | — | 640 bytes |
| Stack of the model function | 216 bytes | 96 bytes |
| Instructions per window | 6878 | 106 747 |
| Time at 168 MHz, at least | 40.9 µs | 635.4 µs |
| CPU share with a window every 0.32 s | 0.01 % | 0.20 % |
| Energy per window, core | 5.4 µJ | 83.9 µJ |
Of the network's 4372 bytes, 1024 are the exp table; the weights and scales are 960 bytes
in separate arrays, the other small arrays the compiler placed in the function's literal
pool. The ring and the window cost 67.8 instructions per sample, decision and
smoothing — 248 instructions per window. Time is instructions divided by 168 MHz:
there are no fewer cycles than instructions. Energy is 3.3 V · 40 mA · time: 40 mA is
the F407 current at 168 MHz with peripherals off, DS8626 rev. 9, table 20. All
the requirements of 121е.1 are met with margin: the tightest item is the reaction,
0.5 s exactly at the limit.
121е.7 Pitfalls
The BSP filter bandwidth constants switch on a different scale. In lis3dsh.h
LIS3DSH_FILTER_BW_40 = 0x08 — the same as LIS3DSH_FULLSCALE_4, while according to
the register description the bandwidth sits in bits 7:6 and the scale in bits 5:3.
FULLSCALE_4 | FILTER_BW_40 gives ±4 g and an 800 Hz bandwidth, FILTER_BW_50
(0x18) gives a ±8 g scale. Build the value from the bit description and read it back.
The BSP sample is off by 31 mg. LIS3DSH_ReadACC puts the bytes into
int8_t buffer[6] and computes (buffer[2*i+1] << 8) + buffer[2*i]:
a signed low byte. If it is above 0x7F, the result is 256 units too small. In the project
recordings 295 443 values out of 588 453 are spoiled this way — half of them, — with an error
of up to 31 mg. Assemble the sample as
in lis_rd_xyz, from unsigned bytes.
Shuffled windows. On shuffled windows the forest is right in 2443 cases out of 2446, on new people — in 2426. In a course project this looks like "the model works" until another person puts it on. Test only on people who were not in training, and choose thresholds and the model on a separate tuning group.
A per-window metric. 99 % correct windows and 23–26 false events in 16.5 minutes — the same model. Hand in an event count: correct, wrong gesture, missed, false — and separately the false ones in pure background.
Hand and orientation. Without mirroring the axis a left-hander loses 22 windows out of 394. The hand, the side it is worn on and the sensor axis belong in the recording metadata and in the device settings.
The labels drifted. The prompter was a second early — and the labels of participant 9 sit a second before the gestures: background gets into the "gesture". Cross-correlation of activity with labels gives the shift as one number per person: 50 samples for him and 0 for the others.
Checking C against Python at the end. Our own int8 kernel went wrong in 45 windows out of 3081 because of one rounding. The bit-for-bit check is set up on the first model, and it runs on every change.
The network's confidence threshold cuts almost nothing. The network's confidence is above 0.9 in 3021 windows out of 3081. The threshold is tuned on the tuning set and then checked for whether it changes anything at all; false events are removed by smoothing, not by the threshold.
Where the time goes. Not into the model: here each model is one script, and of the section's findings only one concerns the model itself — the network threshold. The rest are data, channel and checking: lost frames, left-handers, the label shift, the BSP constants and sample reading, shuffled windows, rounding in C. So what is expensive to find late is checked first: byte-for-byte reception, recording metadata, the split by people, the C-versus-Python check.
121е.8 Summary
- Wristband: LIS3DSH on SPI1, 50 Hz, ±4 g; a 108-byte frame with CRC-16 every 0.32 s, 337.5 bytes per second; the collection firmware is 914 bytes of code, checked against the sensor model: 12 248 frames, 0 overwritten samples.
- Data: 24 people, 3923 s, 192 repetitions of each gesture; exploration found 167 lost frames, two left-handers and a label shift of 1.00 s.
- Models: a 10 × 8 forest on 21 integer features — 2426 of 2446 windows; the network with 869 parameters — 2443 both in float32 and in int8; shuffled windows inflate the score by 17 windows.
- C against the reference: the forest and the network matched in all 3081 windows of the stream, the events — for all six people; with two roundings in the fully connected layer — 3036.
- On six new people: without smoothing 23–26 false events, with voting, hysteresis and the threshold the network gets 192 gestures out of 192 and not a single false one in 16.5 minutes; the reaction is 0.153 s on average.
- The network's budget: 4372 bytes of flash, 106 747 instructions per window, 0.20 % of the CPU, 83.9 µJ per window; the forest — 13 016 bytes and 6878 instructions.
Hand-in checklist
- The budget and requirements are written before the code, and the result is checked against them line by line.
- Collection firmware: the sensor registers are read back, the frame has a CRC and a number, reception on the computer matched the stream byte for byte.
- Metadata in every recording: person, hand, firmware, rate, scale.
- An exploration report: gaps, offsets, hand, label shift, class skew — with numbers.
- A split by people; thresholds and the model choice — on the tuning set, the test once.
- Two models and a comparison by events on the stream, not by windows.
- A bit-for-bit check of C against Python on all test windows.
- The final budget: flash, RAM, stack, instructions, energy — from the build and the run.
- One script repeats everything from scratch, and the rerun gives the same files.
Full sources
recog121g.c — the recognition firmware: ring, window, the forest and the int8 network, the "not sure" threshold, voting and hysteresis; checked against Python on the PC.
/* recog121g.c — раздел 121е: распознавание жестов браслетом на F407.
* Отсчёт датчика (int16 × 3) → кольцо на 128 отсчётов → каждые 16 отсчётов окно 64 (1,28 с,
* перекрытие 75 %) → модель: случайный лес на 21 целом признаке или свёрточная сеть int8 →
* ответ окна («нет жеста», если уверенность ниже порога) → сглаживание: событие — 2 голоса
* из 3 последних окон, выключение — 2 окна подряд без этого жеста (гистерезис).
* Модель — model121g.h (train121g.py). Арифметика целая, кроме входа сети (float32, как в Python).
* -DRECOG_HOST — сверка с Python на потоках проверочных участников (test121g.bin);
* -DRECOG_BENCH — счёт команд в QEMU (bench121g.c). Проверка — _verify/ml121g_check.py. */
#include <math.h>
#include <stdint.h>
#include <string.h>
#include "model121g.h"
#define N 64 /* отсчётов в окне */
#define HOP 16 /* сдвиг окна: 0,32 с */
#define RING 128 /* кольцо: степень двойки, больше окна на запас */
#define NC 5 /* 0 — нет жеста, 1 взмах, 2 поворот, 3 двойной стук, 4 круг */
#define NF 21
/* ── кольцо и окно ── */
typedef struct {
int16_t x[RING][3];
uint32_t head; /* сколько отсчётов пришло всего */
int left_hand; /* браслет на левой руке — зеркало оси Y */
} ring_t;
void ring_push(ring_t *r, const int16_t v[3])
{
int16_t *d = r->x[r->head & (RING - 1)];
d[0] = v[0];
d[1] = r->left_hand ? (int16_t)(v[1] == INT16_MIN ? INT16_MAX : -v[1]) : v[1];
d[2] = v[2];
r->head++;
}
int ring_window(const ring_t *r, int16_t w[N][3]) /* 1 — готово новое окно */
{
if (r->head < N || (r->head - N) % HOP)
return 0;
for (uint32_t i = 0; i < N; i++)
memcpy(w[i], r->x[(r->head - N + i) & (RING - 1)], sizeof w[i]);
return 1;
}
/* ── модель 1: признаки и лес ── */
static void features(const int16_t w[N][3], int32_t f[NF])
{
int64_t s[3] = { 0 }, q[3] = { 0 }, c[3] = { 0 };
for (int a = 0; a < 3; a++) {
int32_t mn = w[0][a], mx = w[0][a], d = 0, jumps = 0, half = 0;
for (int i = 0; i < N; i++) {
int32_t v = w[i][a];
s[a] += v;
q[a] += (int64_t)v * v;
half += i < N / 2 ? v : -v;
mn = v < mn ? v : mn;
mx = v > mx ? v : mx;
if (i) {
int32_t dv = v - w[i - 1][a];
dv = dv < 0 ? -dv : dv;
d += dv;
jumps += dv > 4000;
}
}
f[a] = (int32_t)s[a];
f[3 + a] = mx - mn;
f[6 + a] = d;
f[9 + a] = jumps;
f[12 + a] = (int32_t)((64 * q[a] - s[a] * s[a]) >> 18);
f[15 + a] = half;
}
for (int i = 0; i < N; i++) {
c[0] += (int64_t)w[i][0] * w[i][1];
c[1] += (int64_t)w[i][0] * w[i][2];
c[2] += (int64_t)w[i][1] * w[i][2];
}
f[18] = (int32_t)((64 * c[0] - s[0] * s[1]) >> 18);
f[19] = (int32_t)((64 * c[1] - s[0] * s[2]) >> 18);
f[20] = (int32_t)((64 * c[2] - s[1] * s[2]) >> 18);
}
void rf_window(const int16_t w[N][3], int32_t sum[NC])
{
int32_t f[NF];
features(w, f);
memset(sum, 0, NC * sizeof sum[0]);
for (int t = 0; t < RF_TREES; t++) {
unsigned n = RF_ROOT[t];
while (RF_FEAT[n] >= 0)
n = f[RF_FEAT[n]] <= RF_THR[n] ? RF_LEFT[n] : RF_RIGHT[n];
const uint8_t *p = &RF_LEAF[NC * RF_THR[n]];
for (int c = 0; c < NC; c++)
sum[c] += p[c];
}
}
/* ── модель 2: свёрточная сеть int8, пересчёт масштаба — как в эталонных ядрах TFLite ── */
static int32_t high_mul(int32_t a, int32_t b) /* SaturatingRoundingDoublingHighMul */
{
if (a == INT32_MIN && b == INT32_MIN)
return INT32_MAX;
int64_t ab = (int64_t)a * b;
int64_t nudge = ab >= 0 ? (1 << 30) : (1 - (1 << 30));
return (int32_t)((ab + nudge) / ((int64_t)1 << 31));
}
static int32_t div_pot(int32_t x, int e) /* RoundingDivideByPOT */
{
int32_t mask = (int32_t)((1u << e) - 1u), rem = x & mask, th = (mask >> 1) + (x < 0);
return (x >> e) + (rem > th);
}
static int8_t requant(int32_t acc, int32_t m, int sh, int zo, int lo)
{
int32_t v = div_pot(high_mul(acc * (1 << (sh > 0 ? sh : 0)), m), sh > 0 ? 0 : -sh) + zo;
return (int8_t)(v < lo ? lo : v > 127 ? 127 : v);
}
#ifndef FC_TWO_ROUNDINGS
/* у FULLY_CONNECTED с масштабом на каждый выход эталон TFLite 2.21 округляет один раз, в int64 */
static int8_t requant64(int32_t acc, int32_t m, int sh, int zo)
{
int ts = 31 - sh;
int64_t v = (((int64_t)acc * m + ((int64_t)1 << (ts - 1))) >> ts) + zo;
return (int8_t)(v < -128 ? -128 : v > 127 ? 127 : v);
}
#endif
/* Conv1D, шаг 2, без полей: вход [len][ci], веса [co][k][ci], выход [lo][co], ReLU */
static void conv(const int8_t *in, int len, int ci, const int8_t *wt, const int32_t *b, const int32_t *m,
const int8_t *sh, int co, int za, int zo, int8_t *out)
{
int lo = (len - 5) / 2 + 1;
for (int t = 0; t < lo; t++)
for (int c = 0; c < co; c++) {
int32_t acc = b[c];
const int8_t *x = in + 2 * t * ci, *k = wt + c * 5 * ci;
for (int j = 0; j < 5 * ci; j++)
acc += (x[j] - za) * k[j];
out[t * co + c] = requant(acc, m[c], sh[c], zo, zo);
}
}
void cnn_window(const int16_t w[N][3], int8_t q[NC])
{
static int8_t in[N * 3], h1[30 * 8], h2[13 * 16];
int8_t g[16];
for (int i = 0; i < N * 3; i++) {
float x = (float)w[i / 3][i % 3] / 8192.0f;
int32_t v = (int32_t)floorf(x / CNN_IN_S + 0.5f) + CNN_IN_ZP;
in[i] = (int8_t)(v < -128 ? -128 : v > 127 ? 127 : v);
}
conv(in, N, 3, CNN_W0, CNN_B0, CNN_M0, CNN_SH0, 8, CNN_ZA0, CNN_ZO0, h1);
conv(h1, 30, 8, CNN_W1, CNN_B1, CNN_M1, CNN_SH1, 16, CNN_ZA1, CNN_ZO1, h2);
for (int c = 0; c < 16; c++) { /* глобальный максимум: масштаб тот же */
int8_t mx = h2[c];
for (int t = 1; t < 13; t++)
mx = h2[t * 16 + c] > mx ? h2[t * 16 + c] : mx;
g[c] = mx;
}
for (int c = 0; c < NC; c++) {
int32_t acc = CNN_B2[c];
for (int j = 0; j < 16; j++)
acc += (g[j] - CNN_ZA2) * CNN_W2[c * 16 + j];
#ifdef FC_TWO_ROUNDINGS /* опыт: округлить как у свёртки */
q[c] = requant(acc, CNN_M2[c], CNN_SH2[c], CNN_OUT_ZP, -128);
#else
q[c] = requant64(acc, CNN_M2[c], CNN_SH2[c], CNN_OUT_ZP);
#endif
}
}
/* ── ответ окна с порогом «не уверен» ── */
int rf_decide(const int32_t s[NC])
{
int c = 0;
for (int k = 1; k < NC; k++)
if (s[k] > s[c])
c = k;
return 100 * s[c] >= RF_TAU * 255 * RF_TREES ? c : 0;
}
int cnn_decide(const int8_t q[NC]) /* p_max = 65536 / Σ exp(s·(q − q_max)) */
{
int c = 0;
uint32_t sum = 0;
for (int k = 1; k < NC; k++)
if (q[k] > q[c])
c = k;
for (int k = 0; k < NC; k++)
sum += CNN_EXP[q[c] - q[k]];
return (uint64_t)65536 * 100 >= (uint64_t)CNN_TAU * sum ? c : 0;
}
/* ── сглаживание: голосование и гистерезис ── */
typedef struct {
uint8_t hist[3], state, off;
} smooth_t;
int smooth_step(smooth_t *s, int d) /* жест, если событие, иначе 0 */
{
s->hist[0] = s->hist[1];
s->hist[1] = s->hist[2];
s->hist[2] = (uint8_t)d;
if (s->state == 0) {
for (int c = 1; c < NC; c++)
if ((s->hist[0] == c) + (s->hist[1] == c) + (s->hist[2] == c) >= 2) {
s->state = (uint8_t)c;
s->off = 0;
return c;
}
return 0;
}
s->off = d != s->state ? s->off + 1 : 0;
if (s->off >= 2)
s->state = 0;
return 0;
}
#ifdef RECOG_HOST
#include <stdio.h>
#include <stdlib.h>
static uint32_t rd32(FILE *f)
{
uint32_t v = 0;
if (fread(&v, 4, 1, f) != 1)
exit(3);
return v;
}
int main(int argc, char **argv)
{
FILE *f = fopen(argc > 1 ? argv[1] : "test121g.bin", "rb");
if (!f)
return 1;
uint32_t np = rd32(f), win = 0, rf_same = 0, cnn_same = 0, ev_same = 0, ev_n = 0;
for (uint32_t p = 0; p < np; p++) {
static ring_t r;
memset(&r, 0, sizeof r);
r.left_hand = (int)rd32(f);
uint32_t n = rd32(f);
int16_t *x = malloc(6u * n);
if (!x || fread(x, 6, n, f) != n)
return 2;
uint32_t nw = rd32(f);
int32_t *ers = malloc(4u * NC * nw);
int8_t *eq = malloc((size_t)NC * nw);
if (fread(ers, 4 * NC, nw, f) != nw || fread(eq, NC, nw, f) != nw)
return 2;
uint32_t ne[2] = { rd32(f), rd32(f) }, got[2] = { 0, 0 }, ok[2] = { 1, 1 };
uint32_t *ev = malloc(8u * (ne[0] + ne[1]) + 8u);
if (fread(ev, 8, ne[0] + ne[1], f) != ne[0] + ne[1])
return 2;
smooth_t sm[2];
memset(sm, 0, sizeof sm);
uint32_t k = 0;
for (uint32_t i = 0; i < n; i++) {
int16_t w[N][3];
ring_push(&r, &x[3 * i]);
if (!ring_window(&r, w))
continue;
int32_t s[NC];
int8_t q[NC];
rf_window(w, s);
cnn_window(w, q);
rf_same += memcmp(s, &ers[NC * k], sizeof s) == 0;
cnn_same += memcmp(q, &eq[NC * k], sizeof q) == 0;
int e[2] = { smooth_step(&sm[0], rf_decide(s)), smooth_step(&sm[1], cnn_decide(q)) };
for (int m = 0; m < 2; m++)
if (e[m]) {
uint32_t j = got[m]++, *want = &ev[2 * ((m ? ne[0] : 0) + j)];
ok[m] &= j < ne[m] && want[0] == k && want[1] == (uint32_t)e[m];
}
k++;
win++;
}
for (int m = 0; m < 2; m++) {
ev_same += ok[m] && got[m] == ne[m];
ev_n += got[m];
}
free(x);
free(ers);
free(eq);
free(ev);
}
printf("windows=%u\nrf_same=%u\ncnn_same=%u\nevents_same=%u,%u\nevents=%u\n", win, rf_same, cnn_same, ev_same, 2 * np,
ev_n);
return 0;
}
#endif
collect121g.c — the data collection firmware: LIS3DSH on SPI1, a frame with a CRC to the UART; on the PC — a sensor model.
/* collect121g.c — раздел 121е: прошивка сбора данных браслета на F4-Discovery.
* LIS3DSH на SPI1 (SCK PA5, MISO PA6, MOSI PA7, CS PE3): 50 Гц, ±4 g, три оси.
* Каждые 16 отсчётов (0,32 с) — кадр формата раздела 117а: A5 5A | тип | длина | номер |
* время, мкс | данные | CRC-16/CCITT-FALSE; первый кадр записи — строка метаданных.
* Регистры и биты — из lis3dsh.h пака Keil STM32F4xx_DFP 2.17.1.
* С -DCOLLECT_HOST вместо SPI и UART — модель датчика и файлы: прошивка читает те же
* регистры у модели, которая отдаёт записанный поток (sensor.bin от sim121g.py).
* Проверка — _verify/ml121g_check.py. */
#include <stddef.h>
#include <stdint.h>
#define LIS_WHO_AM_I 0x0Fu /* читается 0x3F (I_AM_LIS3DSH) */
#define LIS_CTRL4 0x20u /* ODR 7:4, BDU 3, ZEN YEN XEN 2:0 */
#define LIS_CTRL5 0x24u /* полоса фильтра 7:6, шкала 5:3 */
#define LIS_CTRL6 0x25u /* бит 4 IF_ADD_INC: адрес растёт сам при чтении подряд */
#define LIS_STATUS 0x27u /* бит 3 ZYXDA — готов отсчёт, бит 7 ZYXOR — отсчёт затёрт */
#define LIS_OUT_X_L 0x28u
#define LIS_RD 0x80u /* старший бит адреса — чтение */
#define CTRL4_50HZ_XYZ 0x57u /* LIS3DSH_DATARATE_50 | LIS3DSH_XYZ_ENABLE */
#define CTRL5_4G_BW50 0xC8u /* полоса 50 Гц: биты 7:6 = 11; ±4 g: биты 5:3 = 001 */
#define CTRL6_ADD_INC 0x10u
#define FR_SYNC0 0xA5u
#define FR_SYNC1 0x5Au
#define FR_META 1u
#define FR_ACC 3u /* данные — FR_N отсчётов × 3 оси × int16 */
#define FR_N 16u
#define FR_HEAD 10u
#define FR_MAX (FR_HEAD + 6u * FR_N + 2u)
#define T_FRAME_US 320000u /* 16 отсчётов при 50 Гц */
/* ── то, что даёт плата (ниже: регистры F407 или модель на компьютере) ── */
static void cs(int low);
static uint8_t spi_byte(uint8_t b);
static int wait_sample(void); /* 1 — датчик готов, 0 — поток кончился (только на компьютере) */
static void uart_write(const uint8_t *p, size_t n);
/* ── датчик ── */
static uint8_t lis_rd(uint8_t reg)
{
cs(1);
spi_byte(LIS_RD | reg);
uint8_t v = spi_byte(0);
cs(0);
return v;
}
static void lis_wr(uint8_t reg, uint8_t v)
{
cs(1);
spi_byte(reg);
spi_byte(v);
cs(0);
}
static void lis_rd_xyz(int16_t v[3])
{
uint8_t b[6];
cs(1);
spi_byte(LIS_RD | LIS_OUT_X_L); /* шесть байт подряд: X_L X_H Y_L Y_H Z_L Z_H */
for (int i = 0; i < 6; i++)
b[i] = spi_byte(0);
cs(0);
for (int i = 0; i < 3; i++)
v[i] = (int16_t)((uint16_t)b[2 * i] | (uint16_t)b[2 * i + 1] << 8);
}
int lis_init(void)
{
if (lis_rd(LIS_WHO_AM_I) != 0x3Fu)
return -1;
lis_wr(LIS_CTRL6, CTRL6_ADD_INC);
lis_wr(LIS_CTRL5, CTRL5_4G_BW50);
lis_wr(LIS_CTRL4, CTRL4_50HZ_XYZ);
return lis_rd(LIS_CTRL4) == CTRL4_50HZ_XYZ && lis_rd(LIS_CTRL5) == CTRL5_4G_BW50 ? 0 : -2;
}
/* ── кадр (формат раздела 117а) ── */
uint16_t crc16_ccitt(const uint8_t *p, size_t n)
{
uint16_t c = 0xFFFFu;
while (n--) {
c ^= (uint16_t)(*p++ << 8);
for (int i = 0; i < 8; i++)
c = (c & 0x8000u) ? (uint16_t)((c << 1) ^ 0x1021u) : (uint16_t)(c << 1);
}
return c;
}
static void put16(uint8_t *p, uint16_t v)
{
p[0] = (uint8_t)v;
p[1] = (uint8_t)(v >> 8);
}
size_t frame_seal(uint8_t *out, uint8_t type, uint16_t seq, uint32_t t_us, uint8_t len)
{
out[0] = FR_SYNC0;
out[1] = FR_SYNC1;
out[2] = type;
out[3] = len;
put16(out + 4, seq);
put16(out + 6, (uint16_t)t_us);
put16(out + 8, (uint16_t)(t_us >> 16));
put16(out + FR_HEAD + len, crc16_ccitt(out + 2, FR_HEAD - 2u + len));
return FR_HEAD + len + 2u;
}
/* ── запись: метаданные, затем кадры по 16 отсчётов; ZYXOR — отсчёт пропущен ── */
typedef struct {
uint32_t frames, samples, overruns;
} collect_stat;
int collect(const char *meta, collect_stat *st)
{
static uint8_t fr[FR_MAX];
uint16_t seq = 0;
uint8_t n = 0;
while (meta[n] && n < 6u * FR_N)
fr[FR_HEAD + n] = (uint8_t)meta[n], n++;
uart_write(fr, frame_seal(fr, FR_META, seq++, 0, n));
if (lis_init() != 0)
return -1;
uint32_t t_us = 0, k = 0;
while (wait_sample()) {
int16_t v[3];
if (lis_rd(LIS_STATUS) & 0x80u)
st->overruns++;
lis_rd_xyz(v);
for (int a = 0; a < 3; a++)
put16(fr + FR_HEAD + 6u * k + 2u * (unsigned)a, (uint16_t)v[a]);
st->samples++;
if (++k == FR_N) {
uart_write(fr, frame_seal(fr, FR_ACC, seq++, t_us, 6u * FR_N));
st->frames++;
t_us += T_FRAME_US;
k = 0;
}
}
return 0;
}
#ifdef COLLECT_HOST
/* ── компьютер: модель LIS3DSH на «шине» и файлы ── */
#include <stdio.h>
#include <stdlib.h>
static struct {
uint8_t reg[128], addr, first, sel;
const int16_t *xyz;
uint32_t n, i;
} lis;
static FILE *fout;
static void cs(int low)
{
lis.sel = (uint8_t)low;
lis.first = 1;
}
static uint8_t spi_byte(uint8_t b)
{
if (!lis.sel)
return 0xFF;
if (lis.first) { /* первый байт — адрес и направление */
lis.first = 0;
lis.addr = b;
return 0xFF;
}
uint8_t a = lis.addr & 0x7Fu, v = 0;
if (lis.addr & LIS_RD) {
v = lis.reg[a];
if (a == LIS_OUT_X_L + 5u) /* прочитан Z_H — отсчёт забран */
lis.reg[LIS_STATUS] &= (uint8_t)~0x88u;
} else
lis.reg[a] = b;
if (lis.reg[LIS_CTRL6] & CTRL6_ADD_INC)
lis.addr = (uint8_t)((lis.addr & LIS_RD) | ((a + 1u) & 0x7Fu));
return v;
}
static int wait_sample(void) /* датчик выдал следующий отсчёт */
{
if (lis.i >= lis.n)
return 0;
if (lis.reg[LIS_STATUS] & 0x08u)
lis.reg[LIS_STATUS] |= 0x80u;
for (int a = 0; a < 3; a++) {
uint16_t u = (uint16_t)lis.xyz[3 * lis.i + (uint32_t)a];
lis.reg[LIS_OUT_X_L + 2 * a] = (uint8_t)u;
lis.reg[LIS_OUT_X_L + 2 * a + 1] = (uint8_t)(u >> 8);
}
lis.reg[LIS_STATUS] |= 0x08u;
lis.i++;
return 1;
}
static void uart_write(const uint8_t *p, size_t n)
{
fwrite(p, 1, n, fout);
}
/* как пересчитывает отсчёт LIS3DSH_ReadACC из BSP: байты в int8_t, затем (hi << 8) + lo */
static int16_t bsp_mg(uint8_t lo, uint8_t hi)
{
int8_t b0 = (int8_t)lo, b1 = (int8_t)hi;
float f = (float)((b1 << 8) + b0) * 0.12f;
return (int16_t)f;
}
int main(int argc, char **argv)
{
FILE *fi = fopen(argc > 1 ? argv[1] : "sensor.bin", "rb");
if (!fi)
return 1;
uint8_t len;
char meta[256], name[512];
int rec = 0;
uint32_t frames = 0, samples = 0, overruns = 0, bytes = 0, bsp_bad = 0, bsp_n = 0, bsp_max = 0;
while (fread(&len, 1, 1, fi) == 1) {
uint32_t ns;
if (fread(meta, 1, len, fi) != len || fread(&ns, 4, 1, fi) != 1)
return 1;
meta[len] = 0;
int16_t *xyz = malloc(6u * ns);
if (!xyz || fread(xyz, 6, ns, fi) != ns)
return 1;
for (int a = 0; a < 128; a++)
lis.reg[a] = 0;
lis.reg[LIS_WHO_AM_I] = 0x3Fu;
lis.xyz = xyz;
lis.n = ns;
lis.i = 0;
snprintf(name, sizeof name, "%s/p%02d.bin", argc > 2 ? argv[2] : ".", rec++);
fout = fopen(name, "wb");
if (!fout)
return 1;
collect_stat st = { 0, 0, 0 };
if (collect(meta, &st) != 0)
return 2;
bytes += (uint32_t)ftell(fout);
fclose(fout);
frames += st.frames;
samples += st.samples;
overruns += st.overruns;
for (uint32_t i = 0; i < 3u * ns; i++) {
uint16_t u = (uint16_t)xyz[i];
int16_t good = (int16_t)((float)xyz[i] * 0.12f), bad = bsp_mg((uint8_t)u, (uint8_t)(u >> 8));
uint32_t d = (uint32_t)abs(good - bad);
bsp_n++;
bsp_bad += d != 0;
if (d > bsp_max)
bsp_max = d;
}
free(xyz);
}
printf("recs=%d\nframes=%u\nsamples=%u\noverruns=%u\nbytes=%u\nframe_bytes=%u\n", rec, frames, samples, overruns,
bytes, (unsigned)FR_MAX);
printf("bsp_bad=%u,%u\nbsp_max_mg=%u\n", bsp_bad, bsp_n, bsp_max);
return 0;
}
#else
/* ── F407: SPI1 и USART2 на регистрах (CMSIS stm32f407xx.h); такты — 168 МГц, APB1 42, APB2 84 ── */
#include "stm32f407xx.h"
static void cs(int low)
{
GPIOE->BSRR = low ? GPIO_BSRR_BR3 : GPIO_BSRR_BS3;
}
static uint8_t spi_byte(uint8_t b)
{
while (!(SPI1->SR & SPI_SR_TXE)) {
}
*(volatile uint8_t *)&SPI1->DR = b;
while (!(SPI1->SR & SPI_SR_RXNE)) {
}
return *(volatile uint8_t *)&SPI1->DR;
}
static int wait_sample(void) /* опрос ZYXDA: 50 раз в секунду, SPI на 5,25 МГц успевает с запасом */
{
while (!(lis_rd(LIS_STATUS) & 0x08u)) {
}
return 1;
}
static void uart_write(const uint8_t *p, size_t n)
{
while (n--) {
while (!(USART2->SR & USART_SR_TXE)) {
}
USART2->DR = *p++;
}
}
static void board_init(void)
{
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN | RCC_AHB1ENR_GPIOEEN;
RCC->APB2ENR |= RCC_APB2ENR_SPI1EN;
RCC->APB1ENR |= RCC_APB1ENR_USART2EN;
GPIOA->MODER |= (2u << 4) | (2u << 10) | (2u << 12) | (2u << 14); /* PA2, PA5…PA7 — альтернативная функция */
GPIOA->AFR[0] |= (7u << 8) | (5u << 20) | (5u << 24) | (5u << 28); /* USART2 — AF7, SPI1 — AF5 */
GPIOE->MODER |= 1u << 6; /* PE3 — выход CS */
GPIOE->BSRR = GPIO_BSRR_BS3;
SPI1->CR1 = SPI_CR1_MSTR | SPI_CR1_BR_1 | SPI_CR1_BR_0 | SPI_CR1_SSM | SPI_CR1_SSI | SPI_CR1_SPE; /* 84/16, режим 0 */
USART2->BRR = (42000000u + 115200u / 2u) / 115200u;
USART2->CR1 = USART_CR1_UE | USART_CR1_TE;
}
int main(void)
{
static collect_stat st;
board_init();
collect("dev=F407DISC;fw=1.0.0;sensor=LIS3DSH;fs=50;fsr=4;mg_lsb=0.12;person=p00;hand=R", &st);
for (;;) {
}
}
#endif
Next: section 122 "Integer types and conversions: promotion, sign, overflow".