Embedded HandbookApps
All sections › XIV. Machine learning on a microcontroller
STM32F407 · level intermediate · 32 min read
Open in the interactive handbook

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 whole course project: LIS3DSH on SPI1 at 50 Hz and ±4 g, the collection firmware sends 108-byte frames with CRC-16, on the computer reception, exploration and cleaning — left-handers, a label shift, lost frames, — windows of 64 with a hop of 16, two models — a forest of 10 trees on 21 integer features and a network of two convolutions with 869 parameters in int8, the choice on people 14–17, the recognition firmware: a 128-sample ring, a window, the network, a threshold of 0.9, two votes out of three and hysteresis; on six new people 192 gestures out of 192 and not a single false event in 16.5 minutes

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:

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

Hand-in checklist

  1. The budget and requirements are written before the code, and the result is checked against them line by line.
  2. 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.
  3. Metadata in every recording: person, hand, firmware, rate, scale.
  4. An exploration report: gaps, offsets, hand, label shift, class skew — with numbers.
  5. A split by people; thresholds and the model choice — on the tuning set, the test once.
  6. Two models and a comparison by events on the stream, not by windows.
  7. A bit-for-bit check of C against Python on all test windows.
  8. The final budget: flash, RAM, stack, instructions, energy — from the build and the run.
  9. 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".

Open in the interactive handbook