121г. A TinyML application pipeline: from task to device — stages, checkpoints and how Edge Impulse does it
Sections 116–121в took the parts one at a time: models, features, data, training, quantization, on-board inference, anomalies, audio, camera. Here they are assembled into one pipeline — from the requirements specification to a device on site and back. The main idea of the section: after each stage there is a checkpoint — a number from a run that is compared with the budget or with an acceptance threshold. If the checkpoint fails, go back, not forward.
The pipeline is shown twice. First — the way Edge Impulse built it:
the notions of an "impulse", a processing block, a learning block, the EON Compiler and
the C++ library with run_classifier() — from the open SDK. The section does not use
the Edge Impulse studio itself: it is a cloud service and requires an
account. Then — the same pipeline on our own, with one script
for the three main TinyML tasks: a keyword, vibration anomalies and
finding objects in a frame. For each — the budget, window, features, model,
bytes, instructions and acceptance metric.
About the data — honestly. All recordings are synthetic: speech is vowels with formants, vibration is rotation harmonics and bearing impacts, frames are light circles on an uneven background. The generator seed is fixed. The numbers of the section check the pipeline — that each checkpoint catches its own failure, that C answers like Python, that the model fits the budget. They say nothing about accuracy on real sensors.
References: approaches and metrics — section 116; features and CMSIS-DSP — 117;
data collection and exploration — 117а; splitting by recordings and normalization — 118;
architectures and export to .tflite — 118а; int8 and the Q31 multiplier — 119;
model optimization — 119а; TensorFlow Lite Micro on the board — 119б;
anomalies and the device's own norm — 120; frame and OpenMV — 121а; audio and
MFCC — 121б; a camera with a neural accelerator — 121в. Edge Impulse — the CMSIS pack
EdgeImpulse.EI-SDK versions 1.56.2 and 1.74.17. The STM32F407 current —
datasheet DS8626 Rev 9.
What it is called in the documentation
Term Meaning pipeline the stages from the task to the device checkpoint, quality gate a check with a threshold after a stage budget flash, RAM, cycles, energy, latency per answer impulse the window, the processing block and the learning block together processing block (DSP block) features from the raw window learning block classifier, regression, anomalies, detector window size, window increase the window and the window step MFE, MFCC, spectral analysis mel energies, mel cepstrum, spectral analysis FOMO (faster objects, more objects) a detector of object centers on a grid of cells EON Compiler a compiler of the model into code without an interpreter C++ library export export as a C++ library label noise labeling errors data drift the field data are not what the training saw acceptance criteria acceptance criteria
121г.1 The pipeline and checkpoints
The stages and what the checkpoint after each one checks:
| Stage | Checkpoint (CP) |
|---|---|
| 0. Task and budget | flash, RAM, instructions per answer, answers per second, acceptance metric — written down before the data |
| 1. Data collection | how many recordings and from how many groups (speakers, machines, sessions), no ADC saturation, the test groups do not overlap with training |
| 2. Labeling | audit: examples where the cross-validation model confidently disagrees with the label go to a human |
| 3. Signal processing | the number of features, all finite; the feature version is chosen on the test bench, not on the held-out set |
| 4. Training | it converged; a repeated run gives the same weights byte for byte |
| 5. Evaluation | the acceptance metric on recordings of groups the training never saw |
| 6. Quantization | int8 against float on the same recordings |
| 7. Export | weights and tables in a C header, bytes |
| 8. On-device check | C against Python on all held-out windows, instructions per answer, flash and RAM of the linked image |
| 9. Field | a monitor in operation, new recordings, retraining — and again from stage 1 |
The budget is part of the requirements specification; it is written before the data. In the section the target chip is the STM32F407: up to 1 MB of flash and 192 + 4 KB of RAM, a core up to 168 MHz (DS8626 Rev 9, p. 1). The energy per answer is computed from the Run-mode current: 40 mA at 168 MHz with peripherals disabled, a typical value at 25 °C and 3.3 V (DS8626 Rev 9, table 20). This is a lower-bound estimate: the time is taken as the number of instructions divided by 168 MHz, and there are always at least as many cycles on the chip as instructions.
| Task | Flash | RAM | Instructions per answer | Answers per second | Acceptance |
|---|---|---|---|---|---|
| Keyword | 65 536 B | 24 576 B | 4 200 000 | 4 | keyword recall ≥ 0.95, false ≤ 0.02 |
| Vibration anomalies | 16 384 B | 8192 B | 840 000 | 1 | false alarms ≤ 0.01, each fault ≥ 0.90 |
| Object centers | 32 768 B | 24 576 B | 8 400 000 | 1 | center F1 ≥ 0.90 |
4 200 000 instructions is 25 ms at 168 MHz, a tenth of a second for four answers; 840 000 — 5 ms; 8 400 000 — 50 ms.
121г.2 How Edge Impulse does it
Edge Impulse is a cloud studio: data are collected and labeled in it, the impulse
is defined, the model is trained and the result is exported. The studio requires an
account, so it was not run here. Something else is open — what
goes into the firmware: the SDK for inference on the device. It comes as the CMSIS pack
EdgeImpulse.EI-SDK; the description below follows its sources, with the file.
The pack's README says so: it holds the C++ implementation of processing blocks
and learning blocks, and a project from the studio is added through the "C++ Library" export.
The license changes from version to version. Version 1.56.2 of 2024-08-06
is entirely under Apache 2.0. In version 1.74.17 of 2025-08-22 the main code is
under BSD 3-Clause Clear, but 32 files of the dsp/ directory — among them
dsp/numpy_types.h with signal_t and dsp/spectral/feature.hpp — are under
the Edge Impulse terms of service: according to their header the files may be
used only with an active paid subscription. So below there is
only a retelling with references to the files of version 1.56.2; the SDK code is not
copied.
The impulse. The ei_impulse_t structure
(classifier/ei_model_types.h) describes one impulse as a whole:
the sampling frequency and interval (frequency, interval_ms), the window size in
samples (raw_sample_count, dsp_input_frame_size), the array of processing
blocks dsp_blocks and learning blocks learning_blocks, the number of
classes label_count and the anomaly flag has_anomaly. The window is what
the studio sets in milliseconds: the comment on total_length in
dsp/numpy_types.h calls it directly "window size in Studio, but in
samples". For a stream there are slice_size and slices_per_model_window:
run_classifier_continuous() computes the features of only the new slice of the
window and shifts the ones already computed — so RAM holds features, not
a second of raw audio.
Processing blocks are the extract_*_features() functions in
classifier/ei_run_dsp.h: spectral analysis, MFCC, MFE,
spectrogram, image and raw samples. MFCC takes from the settings the
frame length and stride, the number of filters and coefficients, the FFT length, the
frequency limits and the pre-emphasis coefficient, and after MFCC it calls cmvnw —
normalization of the coefficients over the window. The settings of each block live in
model-parameters/model_metadata.h, which the studio generates; it is not in
the pack.
Learning blocks. The classifier and regression are a neural network (regression is
the case of one output without anomalies, ei_run_classifier.h). K-means
anomalies are classifier/inferencing_engines/anomaly.h, and they are
simple: the features are normalized (standard_scaler), for each cluster the
distance to the center minus the cluster radius is computed
(sqrt(dist) - cluster->max_error), and the score is the smallest of them, starting
from 1000. The FOMO detector returns a grid of cells; each cell gets
label_count + 1 numbers, the zeroth is background (classifier/ei_fill_result_struct.h).
Cells above the threshold merge with neighbors of the same class, and the coordinates
are multiplied by the ratio of the frame width to the grid width.
EON Compiler. The usual path is the TensorFlow Lite Micro interpreter:
classifier/inferencing_engines/tflite_micro.h builds a MicroInterpreter
and, unless a custom EI_TFLITE_RESOLVER is set, takes AllOpsResolver —
all kernels at once. In section 119б the firmware with AllOpsResolver took
242 903 bytes against 56 791 with its own kernel set. The EON path is tflite_eon.h, with
EI_CLASSIFIER_COMPILED == 1: instead of parsing the model at run time —
ready functions model_init(), model_invoke(), model_reset(),
model_input(), model_output(). The model graph becomes code, and the
interpreter with its tables is not needed. The C inference in this section is
essentially the same: the layers are called in order, the weights are const arrays.
The C++ library and its input. There is one call:
run_classifier(signal_t *signal, ei_impulse_result_t *result, bool debug = false)—classifier/ei_run_classifier.h;signal_tis not a buffer but a functionget_data(offset, length, out_ptr)and a lengthtotal_length: the SDK itself asks for the window in pieces, and the data can live anywhere — in a DMA ring, in external memory, in int16;ei_impulse_result_tis theclassification[]array of "label, probability" pairs,anomaly, the boxesbounding_boxeswith their countbounding_boxes_countand the timetiming:dsp,classification,anomalyin milliseconds and the same in microseconds.
For one's own board one writes a port: ei_read_timer_us(), ei_printf(),
ei_malloc(), ei_free() — porting/ei_classifier_porting.h. The times
timing.dsp and timing.classification are the very split "processing
versus model" that the section measures below in QEMU.
121г.3 One script for three tasks
The pipeline on our own is run121e.py. It takes a task name (kws,
anom, vision or all) and goes through all the stages:
stages121e.pyin thestm-ml:1image (numpy 2.3.3, scikit-learn 1.7.2) — stages 0–7 and 9: data, labeling, features, training, evaluation, quantization, themodel_<задача>.hheader, field;- stage 8 —
impulse121e.c: the whole impulse in C, without libraries or an interpreter. gcc 12.2.0 (stm-sys:1) builds it on a PC, and it answers all held-out windows, and the answers are compared with Python; arm-none-eabi-gcc 12.2.1 (stm-arm:1) builds it for QEMU netduinoplus2 (STM32F405, Cortex-M4F), where the instructions for the processing block and for the model are counted separately; armclang (MDK 5.43) with armlink links an image for Cortex-M4 together with the library — that is flash and RAM.
Each checkpoint is one line of the report:
def kt(n, name, ok, text):
A repeated run gives the same model_<задача>.h and the same report byte for
byte — a seed at every random step, the versions in the image are pinned.
The networks have their own quantization, following the scheme of section 119: int8 weights with a per-tensor scale, int32 biases, int8 activations after ReLU, a shift with a Q31 multiplier and a single rounding — identical in Python and in C:
return (acc * m0 + (1 << (30 + sh))) >> (31 + sh)
return (int32_t)(((int64_t)acc * m0 + ((int64_t)1 << (30 + sh))) >> (31 + sh));
Summary of the three tasks:
| Keyword | Vibration anomalies | Object centers | |
|---|---|---|---|
| Window | 1 s, 8000 samples, 8 kHz | 0.25 s, 256 samples, 1024 Hz | a 32 × 32 frame, 1024 brightness values |
| Features | MFCC 31 × 10 = 310 | RMS + 5 bands = 6 | the frame itself |
| Model | 310 → 16 → 3, int8 | K-means, 4 clusters, float | 2 convolutions 3 × 3 and 1 × 1, int8 |
| Parameters | 5027 | 40 | 1282 |
| Metric on held-out data | recall 0.9833, false 0.0167 | false 0.0063, faults from 0.9458 | F1 0.9633 |
| C against Python | 360 of 360 | 1920 of 1920 | 800 of 800 |
| Instructions: processing + model | 1 431 634 + 25 345 | 41 440 + 315 | 1286 + 5 144 332 |
| At least, ms / µJ per answer | 8.67 / 1145 | 0.25 / 33 | 30.63 / 4043 |
| Flash / RAM + stack, B | 12 976 / 3612 + 112 | 7512 / 2088 + 88 | 4268 / 11 336 + 196 |
All three fit the budget. Next — one task per subsection.
121г.4 Keyword
The audio path and MFCC are covered in detail in section 121б; here it is shorter and
with a larger step. A 1 s window at 8 kHz, a frame of 256 samples (32 ms) without
overlap — 31 frames. From a frame: pre-emphasis 0.97, a Hamming window, an FFT
of 256 points, 20 mel bands from 50 to 4000 Hz, the natural logarithm,
DCT-II, 10 coefficients. In the Edge Impulse MFCC block, MFCC is followed by
the cmvnw normalization; here only its mean over the window is subtracted
from each coefficient. There are 310 features.
Three classes: the keyword "a-i", another word (five variants of similar vowels) and noise
(50 Hz hum, rustle, clicks, silence).
CP 1. 1440 windows from 48 speakers; 12 speakers are held out entirely — 360 windows; ADC saturations 0.
CP 2. 8 errors are planted on purpose in the training set: the keyword is labeled as "another word". Cross-validation by speakers in 5 folds raises the windows where the probability of the label is below 0.05:
flag = np.where(tr & (p_lab < 0.05))[0]
13 windows are raised, 7 of them are errors. This is the usual picture of an audit: some of the raised ones are hard but correct examples, and a human must decide, and one error went unnoticed.
CP 4–6. The 310 → 16 → 3 perceptron — 5027 parameters, converged in 321 epochs. On 12 new speakers the keyword recall is 0.9833, false triggers 0.0167, correct 353 of 360. After int8 — the same 353, and the int8 and float answers matched in all 360 windows.
CP 7–8. The int8 weights with biases are 5084 bytes; the processing tables are
a window of 256 numbers, FFT twiddle factors 2 × 128, 238 mel weights instead
of the full 20 × 129 matrix and a DCT of 200 numbers. C on the PC computed the features itself,
from the samples: of 111 600 int8 elements 1 differed, by one —
float32 in C against float64 in Python right at the rounding boundary. Answers — 360 of 360.
In QEMU the processing took 1 431 634 instructions, the model — 25 345: the features
are 56 times more expensive than the network. The linked image is 12 976 bytes of flash, of
which 552 are the library (logf, floorf); RAM 3612 bytes and stack 112.
CP 9. The field is a workshop with a fan: a 120 Hz hum and noise, 12 new speakers. There are no labels in the field, so the monitor looks at what is visible without them — the share of uncertain answers, where the highest probability is below 0.8: 0.2083 against 0.0389 on the held-out recordings. The recordings were labeled later: recall fell to 0.9333, false triggers rose to 0.0417 — acceptance is not passed. The recordings of 6 workshop speakers are labeled and added to training; on 6 others recall is 0.9500, false 0.0083, and on the old held-out set — 0.9917 and 0.0125: the old was not forgotten.
121г.5 Vibration anomalies
Anomaly detection in detail is section 120. Here it is the same block as in Edge Impulse: K-means on the norm, the score is the distance to the nearest center minus the cluster radius:
d = np.sqrt(((Z[:, None, :] - C[None]) ** 2).sum(axis=2)) - R[None]
d = sqrtf(d) - RAD[k];
The cluster radius is the distance to the farthest training window:
R = np.array([d[km.labels_ == k].max() for k in range(4)])
A window of 256 samples of the Z axis at 1024 Hz (0.25 s), range ±4 g. Machines — rotation at 23–27 Hz, each with its own amplitudes, resonance and mounting sensitivity. Faults: imbalance (1× grows), misalignment (2× grows) and bearing (impacts at a frequency 3.58 times the rotation frequency excite the resonance).
CP 1. 4000 windows from 46 machines: 28 training machines are labeled as normal — 1120 windows; a test bench of 6 machines with faults — 960 windows for choosing the features; 12 machines are held out — normal and three faults, 1920 windows.
CP 2. A machine with a worn bearing got into the "normal" set. Cross-validation by machines: a model trained on the others scores each machine; the median score of its windows is 27.0378, for the others — no higher than 0.3237. The machine is excluded. Without this step the model would consider the bearing impacts normal.
CP 3 — back to the features. Version 1 — RMS and 8 bands of 64 Hz, like an "out of the box" spectral block. On the test bench misalignment was found only in 0.2292 of the windows: 1× and 2× fell into one band of 4–64 Hz. Version 2 — RMS and 5 bands 4–32, 36–64, 68–128, 132–256 and 260–512 Hz, 6 features: on the test bench imbalance, misalignment and bearing — 1.0000. The features are chosen on the test bench; the held-out machines were not opened before that.
CP 4–5. 4 clusters, threshold 0.3989 — 99.5 % of the normal scores in cross-validation by machines. On 12 new machines false alarms 0.0063, imbalance 0.9458, misalignment and bearing 1.0000.
CP 6–8. There is nothing to quantize: the model is 40 float numbers, 160 bytes, and 24 subtractions with multiplication per window. C and Python agree in all 1920 windows, the score differs by no more than 1.91e-05. Processing — 41 440 instructions, model — 315; the image is 7512 bytes of flash, RAM 2088 and stack 88.
CP 9. A new site, the sensor on a bracket — the sensitivity is 1.5 times higher. In the first day of healthy operation an alarm in 0.3550 of the windows: the monitor here is the alarms themselves on machines known to be healthy. The first day is added to the norm: on the site false 0.0000, all three faults 1.0000. But the norm became wider, and on the old held-out machines imbalance was found only in 0.6146 of the windows, misalignment — in 0.5250. The conclusion of section 120 is the same: each installation site's own norm is better than one common norm for all.
121г.6 Object centers on a 32 × 32 frame
The FOMO task in miniature: a camera above a conveyor belt, a 32 × 32 grayscale frame, with 0 to 3 light round parts on it and light stripes — distractors that do not count as parts. The answer is an 8 × 8 grid of cells of 4 × 4 pixels: which cell holds a part's center. Frame and OpenMV — section 121а, a detector on a neural accelerator — 121в.
The network: a 3 × 3 convolution from 1 to 8 channels, ReLU, pool 2; a 3 × 3 convolution from 8 to 16, ReLU, pool 2; a 1 × 1 convolution from 16 to 2 classes per cell. 1282 parameters, 370 688 multiplications per frame. Training is in numpy, with our own backward pass; cells with a part weigh 6 times more than the background: there are few parts. Neighboring cells with a part merge into one, as in FOMO, the center is the mean of the cells; a center no farther than 4 pixels from a label counts as found.
CP 1. 3000 frames from 30 capture sessions (each with its own background, light, noise and contrast), parts 4519; training — 22 sessions, 2200 frames; held out 8 sessions, 800 frames. Saturated pixels 341 — less than 0.1 %.
CP 2. In 10 frames the labeler missed a part. A network trained on the other half of the sessions raises frames where it is confident (probability above 0.9) of a part farther than 4 pixels from any label: 42 frames raised, a miss in 6. The other 4 errors stayed in training.
CP 5–6. On 8 new sessions: found 1180, extra 46, missed 39, F1 0.9652. In int8 — 1181, 52 and 38, F1 0.9633; the int8 answer grid matched float in 766 frames of 800.
CP 8. The int8 input is the brightness minus 128, then only integers, so C repeats Python bit for bit: 800 frames of 800, not a single different cell. The "object in the cell" check in C is a comparison of two sums:
g_mask[i] = a1 > a0;
Processing — 1286 instructions, network — 5 144 332: almost 14 instructions per multiplication. The convolutions here are plain loops with an edge check; the CMSIS-NN kernels in section 119б gave 10.7 times fewer instructions than the reference ones (191 142 against 2 047 791). The image is 4268 bytes of flash, RAM 11 336 bytes: two activation buffers, 8192 and 2048 bytes, an input of 1024 bytes and an answer grid of 64.
CP 9 — not passed. Another line: the light is dimmer (contrast 0.6 of the former), the camera noise 1.3 times higher. F1 0.8794, 249 parts missed. Retraining on 4 sessions of the line, whose frames were taken three times, raised F1 on 4 other sessions only to 0.8961 — the 0.90 threshold is not reached; on the old held-out set 0.9686. The checkpoint honestly shows: data cannot cure what is not in the frame. The fix is on the device: light or exposure, then again from stage 1.
121г.7 Pitfalls
Where the pipeline breaks most often:
| Stage | Typical failure | What the checkpoint catches it with |
|---|---|---|
| 0. Budget | the specification is written after the model — and the model does not fit | the budget is written before the data, CP 8 checks against it |
| 1. Collection | all recordings from one bench, one speaker, one light | groups; the held-out groups do not appear in training |
| 2. Labeling | misses and swapped labels, a "normal" with a fault | a cross-validation audit: 7 of 8, 6 of 10, 1 of 1 |
| 3. Processing | the feature does not see the fault; on the device the feature is computed differently | choosing the version on the bench; C against Python on the features |
| 4. Training | the weights change from run to run | seed, pinned versions, header comparison |
| 5. Evaluation | windows of one recording in training and in the test (section 118) | splitting by groups |
| 6. Quantization | a scale from a sample without the rare class, two roundings (section 119) | int8 against float on the same windows |
| 7–8. Device | a forgotten normalization, weight order, an interpreter with all kernels | C on all held-out windows, image bytes |
| 9. Field | new noise, a new mounting, different light | a monitor without labels, retraining, repeating the pipeline |
The model is good, the device is not. The features on the device were computed by different code. The processing block is part of the model: the same algorithm, the same tables, a comparison of C with Python on all held-out windows, not on one.
The features cost more than the network. For the keyword the processing is 1 431 634
instructions, the network 25 345. Optimizing the network is pointless until the
processing block is counted; in a stream it is computed in slices, like
run_classifier_continuous().
The features were chosen on the held-out machines. After that they are no longer held out. For choosing — a test bench or cross-validation, the held-out set — once at the end.
The labeling audit raised a lot of extras. That is how it should be: 13 windows for 8 errors, 42 frames for 10. The audit does not fix labels itself — it gives a human a short list.
There are no labels in the field. The monitor is what is visible without them: the share of uncertain answers, alarms on machines known to be healthy. A labeled sample from the field is needed for the decision, not for watching.
Retrained at a new site — spoiled the old one. The common norm became wider, and faults on old machines hide. Each site's own norm or checking the retrained model on the old held-out set as well.
Data cannot fix physics. A dim frame after retraining — F1 0.8961 instead of 0.90. Light, sensor mounting, microphone — are fixed on the device.
121г.8 Summary
- The pipeline is ten stages with a checkpoint after each: a number from a run against the budget or the acceptance threshold; "!!" — back, not forward.
- Edge Impulse: impulse = window + processing blocks + learning blocks;
the EON Compiler turns the graph into code without an interpreter; the library is
run_classifier(signal_t *, ei_impulse_result_t *). The studio requires an account; in 1.74.17 some DSP files are only for a paid subscription, 1.56.2 is Apache 2.0. - Keyword: 310 MFCC features, a 310 → 16 → 3 network in int8 — recall 0.9833, false 0.0167; 1 456 979 instructions, 12 976 bytes of flash, RAM 3612 + 112.
- Anomalies: the features were redone on the bench — misalignment 0.2292 → 1.0000; on new machines false 0.0063; 41 755 instructions, 7512 bytes.
- Object centers: 1282 int8 parameters, F1 0.9633; C repeats Python bit for bit; 5 145 618 instructions, 4268 bytes, RAM 11 336 + 196.
- The labeling audit found 7 of 8, 6 of 10 and 1 of 1 planted errors.
- Field: a fan — uncertain answers 0.2083 instead of 0.0389, retraining brought false triggers back to 0.0083; a bracket — 0.3550 alarms on healthy machines; dim light — F1 0.8961, not curable by data.
- A lower-bound estimate at 168 MHz and 40 mA: 8.67 ms and 1145 µJ per answer for the keyword, 0.25 ms and 33 µJ for anomalies, 30.63 ms and 4043 µJ for the frame.
Full sources
The data generator and stages 0–7 and 9 (stages121e.py), the builds for the PC and
QEMU (host121e.sh, qemu121e.sh, bench121e.c, bench121e.ld) and
the program for armlink (main121e.c) are in _verify/ml121e/; below are
the C inference and the pipeline itself.
impulse121e.c — the whole impulse in C for three tasks.
/* Раздел 121г: импульс целиком — блок обработки и блок обучения — на C, без интерпретатора.
* TASK=1 — ключевое слово (MFCC + перцептрон int8), TASK=2 — аномалии вибрации (полосы + K-средних),
* TASK=3 — центры объектов на кадре 32 × 32 (свёрточная сеть int8, сетка 8 × 8).
* Таблицы и веса — model_<задача>.h из stages121e.py. С -DML_HOST main() читает test_<задача>.bin
* и сверяет ответы с Python; с -DML_BENCH файл включает bench121e.c (QEMU). */
#include <math.h>
#include <stdint.h>
#if TASK == 1
#include "model_kws.h"
#elif TASK == 2
#include "model_anom.h"
#else
#include "model_vision.h"
#endif
#if TASK != 2
/* Сдвиг множителем Q31 с одним округлением — тот же, что requant() в stages121e.py. */
static int32_t requant(int32_t acc, int64_t m0, int sh)
{
return (int32_t)(((int64_t)acc * m0 + ((int64_t)1 << (30 + sh))) >> (31 + sh));
}
#endif
#if TASK != 3
/* БПФ по основанию 2 на 256 точек на месте; TW_C/TW_S — cos и −sin угла 2πk/256. */
static void fft256(float *re, float *im)
{
int i, j = 0, len, k;
for (i = 1; i < 256; i++) {
int bit = 128;
for (; j & bit; bit >>= 1)
j ^= bit;
j ^= bit;
if (i < j) {
float t = re[i]; re[i] = re[j]; re[j] = t;
t = im[i]; im[i] = im[j]; im[j] = t;
}
}
for (len = 2; len <= 256; len <<= 1) {
int step = 256 / len;
for (i = 0; i < 256; i += len)
for (k = 0; k < len / 2; k++) {
float wr = TW_C[k * step], wi = TW_S[k * step];
float *ar = &re[i + k], *ai = &im[i + k], *br = &re[i + k + len / 2], *bi = &im[i + k + len / 2];
float xr = *br * wr - *bi * wi, xi = *br * wi + *bi * wr;
*br = *ar - xr; *bi = *ai - xi;
*ar += xr; *ai += xi;
}
}
}
static float g_re[256], g_im[256];
#endif
#if TASK == 1
static float g_cep[N_FEAT];
int8_t g_feat[N_FEAT];
/* Блок обработки: предыскажение, окно Хэмминга, БПФ, мел-полосы, ln, ДКП-II, вычитание среднего, int8. */
void dsp_run(const int16_t *x)
{
int f, i, m, k, off;
for (f = 0; f < N_FR; f++) {
for (i = 0; i < FRAME; i++) {
int n = f * FRAME + i;
float prev = n ? (float)x[n - 1] : 0.0f;
float v = n ? ((float)x[n] - 0.97f * prev) : (float)x[n];
g_re[i] = v * (1.0f / 32768.0f) * HAM[i];
g_im[i] = 0.0f;
}
fft256(g_re, g_im);
for (i = 0; i <= FRAME / 2; i++)
g_re[i] = g_re[i] * g_re[i] + g_im[i] * g_im[i]; /* мощность — на месте */
for (m = 0, off = 0; m < N_MEL; m++) {
float s = 0.0f;
for (i = 0; i < MEL_LN[m]; i++)
s += MEL_W[off + i] * g_re[MEL_ST[m] + i];
off += MEL_LN[m];
g_im[m] = logf(s + 1e-6f);
}
for (k = 0; k < N_CEP; k++) {
float s = 0.0f;
for (m = 0; m < N_MEL; m++)
s += DCTM[k * N_MEL + m] * g_im[m];
g_cep[f * N_CEP + k] = s;
}
}
for (k = 0; k < N_CEP; k++) {
float mean = 0.0f;
for (f = 0; f < N_FR; f++)
mean += g_cep[f * N_CEP + k];
mean /= N_FR;
for (f = 0; f < N_FR; f++) {
float q = floorf((g_cep[f * N_CEP + k] - mean) * IN_SCALE_INV + 0.5f);
g_feat[f * N_CEP + k] = (int8_t)(q > 127.0f ? 127 : q < -127.0f ? -127 : q);
}
}
}
/* Блок обучения: 310 → 16 (ReLU) → 3 в int8, ответ — номер наибольшего выхода. */
int nn_run(void)
{
int8_t h[N_HID];
int32_t o[N_CLS];
int i, j, best = 0;
for (j = 0; j < N_HID; j++) {
int32_t acc = B1[j];
for (i = 0; i < N_FEAT; i++)
acc += g_feat[i] * W1[i * N_HID + j];
acc = requant(acc, M1, SH1);
h[j] = (int8_t)(acc < 0 ? 0 : acc > 127 ? 127 : acc);
}
for (j = 0; j < N_CLS; j++) {
int32_t acc = B2[j];
for (i = 0; i < N_HID; i++)
acc += h[i] * W2[i * N_CLS + j];
o[j] = acc;
}
for (j = 1; j < N_CLS; j++)
if (o[j] > o[best])
best = j;
return best;
}
#elif TASK == 2
float g_feat[N_FEAT];
float g_score;
/* Блок обработки: СКЗ и log10 мощности в полосах спектра. */
void dsp_run(const int16_t *x)
{
float mean = 0.0f, ss = 0.0f;
int i, b;
for (i = 0; i < N_WIN; i++)
mean += x[i] * (1.0f / LSB_G);
mean /= N_WIN;
for (i = 0; i < N_WIN; i++) {
float y = x[i] * (1.0f / LSB_G) - mean;
ss += y * y;
g_re[i] = y * HANN[i];
g_im[i] = 0.0f;
}
g_feat[0] = sqrtf(ss / N_WIN);
fft256(g_re, g_im);
for (b = 0; b < N_BAND; b++) {
float s = 0.0f;
for (i = EDGE[b]; i < EDGE[b + 1]; i++)
s += g_re[i] * g_re[i] + g_im[i] * g_im[i];
g_feat[1 + b] = log10f(s + 1e-9f);
}
}
/* Блок аномалий как в Edge Impulse: нормировка, расстояние до центра минус радиус кластера, минимум. */
int nn_run(void)
{
float z[N_FEAT], best = 1000.0f;
int i, k;
for (i = 0; i < N_FEAT; i++)
z[i] = (g_feat[i] - MU[i]) / SD[i];
for (k = 0; k < N_CL; k++) {
float d = 0.0f;
for (i = 0; i < N_FEAT; i++) {
float t = z[i] - CEN[k * N_FEAT + i];
d += t * t;
}
d = sqrtf(d) - RAD[k];
if (d < best)
best = d;
}
g_score = best;
return best > THR;
}
#else
static int8_t g_in[IM * IM];
static int8_t g_a[IM * IM * 8]; /* выход свёртки 1 (32×32×8), затем свёртки 2 (16×16×16) */
static int8_t g_b[16 * 16 * 8]; /* после пула 1 (16×16×8), затем после пула 2 (8×8×16) */
uint8_t g_mask[GRID * GRID];
/* Блок обработки: яркость 0…255 → int8 со смещением 128 (масштаб 1/128). */
void dsp_run(const uint8_t *p)
{
int i;
for (i = 0; i < IM * IM; i++)
g_in[i] = (int8_t)(p[i] - 128);
}
/* Свёртка 3×3 с нулями по краю, HWC; веса [(dy·3+dx)·cin + c][cout], ReLU и сдвиг в int8. */
static void conv3(const int8_t *x, int n, int cin, int8_t *y, int cout, const int8_t *w, const int *b,
int64_t m0, int sh)
{
int r, c, k, dy, dx, ch;
for (r = 0; r < n; r++)
for (c = 0; c < n; c++)
for (k = 0; k < cout; k++) {
int32_t acc = b[k];
for (dy = 0; dy < 3; dy++)
for (dx = 0; dx < 3; dx++) {
int rr = r + dy - 1, cc = c + dx - 1;
if (rr < 0 || rr >= n || cc < 0 || cc >= n)
continue;
for (ch = 0; ch < cin; ch++)
acc += x[(rr * n + cc) * cin + ch] * w[((dy * 3 + dx) * cin + ch) * cout + k];
}
acc = requant(acc, m0, sh);
y[(r * n + c) * cout + k] = (int8_t)(acc < 0 ? 0 : acc > 127 ? 127 : acc);
}
}
static void pool2(const int8_t *x, int n, int ch, int8_t *y)
{
int r, c, k;
for (r = 0; r < n / 2; r++)
for (c = 0; c < n / 2; c++)
for (k = 0; k < ch; k++) {
int8_t m = x[((2 * r) * n + 2 * c) * ch + k], t;
t = x[((2 * r) * n + 2 * c + 1) * ch + k]; m = t > m ? t : m;
t = x[((2 * r + 1) * n + 2 * c) * ch + k]; m = t > m ? t : m;
t = x[((2 * r + 1) * n + 2 * c + 1) * ch + k]; m = t > m ? t : m;
y[(r * (n / 2) + c) * ch + k] = m;
}
}
/* Блок обучения: две свёртки с пулом и свёртка 1×1 на 2 класса; объект в клетке, если выход 1 больше выхода 0. */
int nn_run(void)
{
int i, c, found = 0;
conv3(g_in, IM, 1, g_a, 8, W1, B1, M1, SH1);
pool2(g_a, IM, 8, g_b);
conv3(g_b, IM / 2, 8, g_a, 16, W2, B2, M2, SH2);
pool2(g_a, IM / 2, 16, g_b);
for (i = 0; i < GRID * GRID; i++) {
int32_t a0 = B3[0], a1 = B3[1];
for (c = 0; c < 16; c++) {
a0 += g_b[i * 16 + c] * W3[c * 2];
a1 += g_b[i * 16 + c] * W3[c * 2 + 1];
}
g_mask[i] = a1 > a0;
found += g_mask[i];
}
return found;
}
#endif
#ifdef ML_HOST
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char **argv)
{
FILE *fp = fopen(argv[1], "rb");
int32_t hd[4];
int n, i, agree = 0;
if (!fp || argc < 2 || fread(hd, 4, 4, fp) != 4)
return 1;
n = hd[0];
#if TASK == 1
{
int16_t *x = malloc(sizeof(int16_t) * N_WIN);
int8_t *fq = malloc(N_FEAT), *cls = malloc(n);
int *mine = malloc(sizeof(int) * n), diff = 0, maxd = 0, same_feat = 0;
for (i = 0; i < n; i++) {
int j, d0 = diff;
if (fread(x, 2, N_WIN, fp) != N_WIN || fread(fq, 1, N_FEAT, fp) != N_FEAT)
return 1;
dsp_run(x);
for (j = 0; j < N_FEAT; j++) {
int d = abs(g_feat[j] - fq[j]);
diff += d != 0;
maxd = d > maxd ? d : maxd;
}
same_feat += diff == d0;
mine[i] = nn_run();
}
if (fread(cls, 1, n, fp) != (size_t)n)
return 1;
for (i = 0; i < n; i++)
agree += mine[i] == cls[i];
printf("agree=%d,%d\nfeat_diff=%d,%d\nfeat_maxdiff=%d\nwindows_same_feat=%d\n", agree, n, diff, n * N_FEAT, maxd, same_feat);
}
#elif TASK == 2
{
int16_t *x = malloc(sizeof(int16_t) * N_WIN);
float *sc = malloc(sizeof(float) * n), *mys = malloc(sizeof(float) * n), maxd = 0.0f;
int8_t *al = malloc(n);
int *mine = malloc(sizeof(int) * n);
for (i = 0; i < n; i++) {
if (fread(x, 2, N_WIN, fp) != N_WIN)
return 1;
dsp_run(x);
mine[i] = nn_run();
mys[i] = g_score;
}
if (fread(sc, 4, n, fp) != (size_t)n || fread(al, 1, n, fp) != (size_t)n)
return 1;
for (i = 0; i < n; i++) {
float d = fabsf(mys[i] - sc[i]);
maxd = d > maxd ? d : maxd;
agree += mine[i] == al[i];
}
printf("agree=%d,%d\nscore_maxdiff=%.2e\n", agree, n, (double)maxd);
}
#else
{
uint8_t *x = malloc(IM * IM), *mk = malloc((size_t)n * GRID * GRID);
uint8_t *mine = malloc((size_t)n * GRID * GRID);
int cells = 0;
for (i = 0; i < n; i++) {
if (fread(x, 1, IM * IM, fp) != IM * IM)
return 1;
dsp_run(x);
nn_run();
for (int j = 0; j < GRID * GRID; j++)
mine[i * GRID * GRID + j] = g_mask[j];
}
if (fread(mk, 1, (size_t)n * GRID * GRID, fp) != (size_t)n * GRID * GRID)
return 1;
for (i = 0; i < n; i++) {
int same = 1;
for (int j = 0; j < GRID * GRID; j++) {
int d = mine[i * GRID * GRID + j] != mk[i * GRID * GRID + j];
cells += d;
same &= !d;
}
agree += same;
}
printf("agree=%d,%d\ncells_diff=%d,%d\n", agree, n, cells, n * GRID * GRID);
}
#endif
fclose(fp);
return 0;
}
#elif defined(ML_BENCH)
#include "bench121e.c"
#endif
run121e.py — the pipeline: stages, device and the checkpoint report.
# -*- coding: utf-8 -*-
"""Раздел 121г: конвейер TinyML целиком — для любой из трёх задач все этапы и отчёт контрольных точек.
python run121e.py <kws|anom|vision|all> [каталог_вывода]
1. stages121e.py в образе stm-ml:1 — КТ 0–7 и 9: бюджет, сбор, разметка, обработка сигнала, обучение,
оценка на отложенных записях, квантование, выгрузка, поле;
2. КТ 8 — прибор: impulse121e.c, собранный gcc на ПК (stm-sys:1), против ответов Python на всех
отложенных окнах; команды на ответ в QEMU netduinoplus2 (stm-arm:1, -icount: команды, не такты);
флеш и ОЗУ — образ, связанный armclang/armlink для Cortex-M4 (с библиотекой: logf, sqrtf …);
время и энергия — оценка снизу: команды / 168 МГц и 40 мА · 3,3 В (DS8626 Rev 9, табл. 20).
Каталог armclang — переменная ARMCLANG_BIN, иначе ищется в PATH."""
import os
import re
import shutil
import subprocess
import sys
import tempfile
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
HERE = os.path.dirname(os.path.abspath(__file__))
H_ = HERE.replace("\\", "/")
BIN = os.environ.get("ARMCLANG_BIN") or os.path.dirname(shutil.which("armclang") or "")
TASKS = ["kws", "anom", "vision"]
NUM = {"kws": 1, "anom": 2, "vision": 3}
F_HZ, I_A, V_V = 168e6, 0.040, 3.3 # DS8626 Rev 9, табл. 20: 168 МГц, периферия выкл., тип.
def run(args, cwd=None, timeout=1800):
r = subprocess.run(args, capture_output=True, text=True, encoding="utf-8", errors="replace", cwd=cwd, timeout=timeout)
return r.returncode, (r.stdout + r.stderr).replace("\r\n", "\n")
def main():
which = sys.argv[1] if len(sys.argv) > 1 else "all"
tasks = TASKS if which == "all" else [which]
out = sys.argv[2] if len(sys.argv) > 2 else tempfile.mkdtemp(prefix="p121e_")
os.makedirs(out, exist_ok=True)
O_ = os.path.abspath(out).replace("\\", "/")
stages = {}
for t in tasks:
rc, o = run(["docker", "run", "--rm", "-v", H_ + ":/v:ro", "-v", O_ + ":/o", "-w", "/v", "stm-ml:1",
"python", "-I", "stages121e.py", t, "/o"])
if rc:
print("!! stages121e.py %s: %s" % (t, o[-800:]))
return 1
stages[t] = open(os.path.join(out, "stages_%s.txt" % t), encoding="utf-8").read()
# прибор: C на ПК против Python
_, host = run(["docker", "run", "--rm", "-v", H_ + ":/v:ro", "-v", O_ + ":/o", "stm-sys:1", "sh", "/v/host121e.sh"])
# команды в QEMU
_, qemu = run(["docker", "run", "--rm", "-v", H_ + ":/v:ro", "-v", O_ + ":/o", "stm-arm:1", "sh", "/v/qemu121e.sh"])
# флеш и ОЗУ: armclang + armlink
tmp = tempfile.mkdtemp(prefix="a121e_")
shutil.copy(os.path.join(HERE, "main121e.c"), tmp)
sizes = {}
for t in tasks:
cc = [BIN + "/armclang.exe", "--target=arm-arm-none-eabi", "-mcpu=cortex-m4", "-mfpu=fpv4-sp-d16", "-mfloat-abi=hard",
"-O2", "-Wall", "-Wextra", "-ffunction-sections", "-fdata-sections", "-DTASK=%d" % NUM[t], "-I" + out]
r1, o1 = run(cc + ["-c", "-o", os.path.join(tmp, t + ".o"), os.path.join(HERE, "impulse121e.c")])
r2, o2 = run(cc + ["-c", "-o", os.path.join(tmp, "m_" + t + ".o"), os.path.join(tmp, "main121e.c")])
r3, o3 = run([BIN + "/armlink.exe", "--cpu=Cortex-M4.fp.sp", "--ro-base=0x08000000", "--rw-base=0x20000000",
"--entry=main", "--no_startup", "--remove", "--info=sizes,totals", "-o", os.path.join(tmp, t + ".axf"),
os.path.join(tmp, t + ".o"), os.path.join(tmp, "m_" + t + ".o")])
_, oz = run([BIN + "/fromelf.exe", "--text", "-z", os.path.join(tmp, t + ".o")])
obj = re.search(r"^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+\d+\s+\S+\.o\s*$", oz, re.M)
lib = re.search(r"^\s*(\d+)\s+\d+\s+(\d+)\s+(\d+)\s+(\d+)\s+\d+\s+Library Totals", o3, re.M)
rom = re.search(r"Total ROM Size \(Code \+ RO Data \+ RW Data\)\s+(\d+)", o3)
ram = re.search(r"Total RW Size \(RW Data \+ ZI Data\)\s+(\d+)", o3)
warn = ("warning" in o1 + o2 + o3) or r1 or r2 or r3
sizes[t] = dict(obj=[int(obj.group(i)) for i in (1, 3, 5)] if obj else None, lib=int(lib.group(1)) if lib else -1,
rom=int(rom.group(1)) if rom else -1, ram=int(ram.group(1)) if ram else -1, warn=bool(warn))
shutil.rmtree(tmp, ignore_errors=True)
_, ver = run([BIN + "/armclang.exe", "--version"])
ver = ver.splitlines()[0] if ver else "?"
for t in tasks:
S = stages[t]
kv = dict(ln.split("=", 1) for ln in S.splitlines() if re.match(r"^\w+=", ln))
bud = [int(v) for v in kv["budget"].split(",")]
hb = host.split("== %s build=" % t)[1].split("\n== ")[0].split("\ngcc ")[0]
hk = dict(ln.split("=", 1) for ln in hb.splitlines() if re.match(r"^\w+=", ln))
qb = qemu.split("== %s build=" % t)[1].split("\n== ")[0]
cal = int(re.search(r"cal insn=2000000 ticks=(\d+)", qb).group(1))
tk = lambda k: int(re.search(k + r" ticks=(\d+)", qb).group(1))
ins = lambda v: int(round(v * 2000000.0 / cal))
i_dsp, i_nn = ins(tk("dsp")), ins(tk("nn"))
stack = int(re.search(r"stack bytes=(\d+)", qb).group(1))
z = sizes[t]
tot = i_dsp + i_nn
t_ms = tot / F_HZ * 1e3
e_uj = I_A * V_V * tot / F_HZ * 1e6
load = tot * bud[3] / F_HZ
ag = hk.get("agree", "0,1").split(",")
ok_c = hb.startswith("0") and ag[0] == ag[1]
ok8 = ok_c and z["rom"] <= bud[0] and z["ram"] + stack <= bud[1] and tot <= bud[2] and not z["warn"]
extra = {"kws": "признаки int8: разных %s из %s элементов, не больше чем на %s" % (
*hk.get("feat_diff", "?,?").split(","), hk.get("feat_maxdiff", "?")),
"anom": "оценка отличается не больше чем на %s" % hk.get("score_maxdiff", "?"),
"vision": "клеток сетки разошлось %s" % hk.get("cells_diff", "?,?").split(",")[0]}[t]
kt8 = ("КТ8 прибор: %s — C (gcc, ПК) и Python согласны в %s из %s ответов, %s; QEMU: обработка %d команд, "
"модель %d, всего %d (бюджет %d); не меньше %.2f мс и %.0f мкДж на ответ при 168 МГц, загрузка ядра "
"%.2f %%; armclang: флеш %d Б (бюджет %d), ОЗУ %d Б + стек %d Б (бюджет %d)"
% ("ok" if ok8 else "!!", ag[0], ag[1], extra, i_dsp, i_nn, tot, bud[2], t_ms, e_uj, 100 * load,
z["rom"], bud[0], z["ram"], stack, bud[1])).replace(".", ",")
rep = [ln for ln in S.splitlines() if ln.startswith("КТ")]
rep.insert(next(i for i, ln in enumerate(rep) if ln.startswith("КТ9")), kt8)
print("════ %s ════" % t)
print("\n".join(rep))
print("c_%s=%s" % (t, ",".join(ag)))
print("insn_%s=%d,%d,%d" % (t, i_dsp, i_nn, tot))
print("size_%s=%d,%d,%d,%d,%s" % (t, z["rom"], z["ram"], stack, z["lib"], ",".join(str(v) for v in z["obj"])))
print("est_%s=%.3f,%.1f,%.4f" % (t, t_ms, e_uj, load))
print("armclang=%s" % ver)
print("qemu=%s" % (qemu.strip().splitlines()[-1] if qemu.strip() else "?"))
print("gcc_host=%s" % (host.strip().splitlines()[-1] if host.strip() else "?"))
return 0
if __name__ == "__main__":
sys.exit(main())
Next: section 121д "Industrial TinyML cases: motor, pump and fan condition, leaks by sound, counting and detecting objects in a frame — what was solved, with what, and where it went wrong" — five shop-floor tasks from the problem statement to the field: what broke when the speed, the unit, the background or the light changed, and how it was fixed.