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

121в. STM32N6 and a battery-powered camera with a neural network: YOLO on the Neural-ART NPU, low power, an outdoor device

Section 121а covered OpenMV on the STM32H7: a frame, blobs, a small network through TensorFlow Lite Micro. YOLOv8n at 3.2 MB no longer fit there — the board table was left with the OPENMV_N6 row. This section is about that chip: the STM32N6 with the Neural-ART neural network accelerator, and the reason it goes into an outdoor device: the camera sleeps, wakes up on a motion sensor, takes a picture, finds people, animals or cars in the frame and sends a short message.

The section has three parts. The first is the STM32N6 itself and the path of a frame to the NPU, based on ST documentation and the OpenMV sources. The second is our own YOLO post-processing code for the Cortex-M55: the int8 network output is turned into boxes, and NMS removes the extra boxes. The code was run on a PC and on a Cortex-M55 in QEMU, with Helium and without, and compared against an independent numpy calculation — including the real output of the yolov8n_192 model from OpenMV on a photo of a person. The third is the energy budget on an 18650 and a requirements specification for the device.

What is not here. The section had no STM32N6 board at hand: the currents are taken from the datasheet; the phase times and the NPU, sensor and radio currents are estimates, and they are marked as such. The model was not run on the NPU — its output was obtained with the TensorFlow Lite interpreter on a PC.

References: the STM32N6x5/x7 datasheet — DS14791 Rev 11, the reference manual RM0486 Rev 4, the CMSIS header stm32n657xx.h and the SVD description from the STM32N6 support pack. The OpenMV sources — github.com/openmv/openmv, commit 62369938a94b: board files and examples are MIT; the ml module, the NPU wrapper lib/stai/stai_backend.c, the ml.postprocessing post-processing and the camera driver are under the OpenMV non-commercial license, so here they are only paraphrased with a reference to the file and line; the ST library lib/stai/libstai is SLA0044. int8 quantization — section 119, OpenMV and the memory pool — 121а, DCMI and DMA — 22б, cache — 11б, sleep modes — 10 and 10а, Li-ion — 173б, LoRa and time on air — 89, 93, 94, firmware update — 48, 48а–48г.

Terms used in the documentation

Term Meaning
NPU (neural processing unit), Neural-ART accelerator a neural accelerator, ST's neural network accelerator
CA (convolutional accelerator) the convolution unit in the NPU
epoch a piece of the network that the NPU computes without the CPU, up to an interrupt
stream engine, stream switch the accelerator's streaming DMA and the stream crossbar
relocatable model a model image that can be placed at any address
DCMIPP (pixel pipeline), ISP (image signal processor) the camera pixel pipeline, processing of the sensor signal
CSI-2 (camera serial interface) the MIPI serial camera interface
MVE (M-profile vector extension), Helium the Cortex-M55 vector instructions
anchor a grid point (YOLOv8) or a box template (YOLOv2)
objectness "there is an object here"
NMS (non-maximum suppression) suppression of overlapping boxes
IoU (intersection over union) the intersection divided by the union
class-agnostic NMS NMS that ignores the class
PIR (passive infrared) sensor a pyroelectric motion sensor
duty cycle the share of time on air

121в.1 The STM32N6 in brief

The STM32N6 is a Cortex-M55 at 800 MHz with Helium vector instructions (MVE) and an FPU for half, single and double precision. In the N6x7 line it is joined by the Neural-ART NPU: up to 1 GHz, 600 Gops and 288 multiply-accumulate operations per cycle according to the datasheet (DS14791, p. 1). We never measured the "Gops" ourselves — this is ST's figure. Inside the NPU (RM0486, table 108): 4 convolution units, 2 pooling units, 2 activation units, 4 arithmetic units, 10 streaming DMAs with built-in encryption, 2 AXI buses of 64 bits each. The same numbers appear in the header of the ST library that OpenMV builds: ATON_CONVACC_NUM 4, ATON_STRENG_NUM 10 (lib/stai/libstai/include/ATON.h).

There is no flash memory. In the part number decoding for the N6, the flash memory size is "0 = 0–1 Kbytes" (DS14791, p. 262). After reset the ROM runs: depending on the BOOT0 and BOOT1 pins and an OTP word, it loads the firmware from external memory — XSPI NOR, HyperFlash, e.MMC or an SD card (RM0486, 9.1.1) — or over USB and UART. Here is how it looks on the OpenMV N6 (boards/OPENMV_N6/board_config.mk, lines 8–10): flash memory starts at 0x70000000, the firmware at 0x70080000, the OpenMV bootloader is in RAM at 0x34180400. The map of the external 32 MB flash memory on XSPI2 at 200 MHz is in boot_config.h (lines 47–49 and 76–79): a 512 KB bootloader, the firmware, the file system, ROMFS with models at 0x800000. In the CMSIS pack for the N6, "Flash" also starts at 0x34180400 — this is the RAM where the ROM places the first-stage bootloader.

Memory. The AXI RAM consists of two 1 MB parts and four 448 KB parts (SRAM1_AXI_SIZE … SRAM6_AXI_SIZE in stm32n657xx.h), plus TCM 64 + 128 KB and 8 KB of backup RAM. The four 448 KB blocks starting at 0x34200000 are the RAM given to the NPU: in OpenMV this is a 1792 KB pool (board_config.h, lines 172–174 and 187–188), in the ST compiler it is the pools npuRAM3…npuRAM6 (lib/stai/scripts/stm32n6.mpool). The NPU cache for external memory (CACHEAXI) is 256 KB (CACHEAXI_RAM_SIZE 0x40000UL). The model weights live in external flash memory and are read in place; frames and large buffers in OpenMV are in the external 64 MB PSRAM at 0x90000000 (lines 191–192, the 31 MB UMA pool — line 164).

Camera. The N6 has two paths for a frame (RM0486, 37.1): the older DCMI up to 24 Mpixel/s, and the DCMIPP — up to 5 MP at 30 frames/s, parallel up to 120 MHz or over CSI-2 up to 2.5 Gbit/s per lane on two lanes, with an ISP (demosaicing, exposure, white balance, contrast, bad pixels) and two application pipelines with cropping and downscaling. The ISP output can go to the NPU over DMA (DS14791, p. 14). The OpenMV N6 works through the DCMIPP: pipeline 1, a 24 MHz sensor clock from TIM1, and the CSI-2 path for MIPI sensors (board_config.h, lines 134 and 307; ports/stm32/omv_csi.c).

Video and JPEG. A hardware JPEG codec and an H.264 encoder up to 1080p at 15 frames/s (DS14791, p. 1). In the SVD these are the JPEG block at 0x48023000 and VENC ("Video encoder") at 0x48005000; in OpenMV the encoder is connected through drivers/vc8000 (H264EncInit). A battery-powered device does not need video — it needs a JPEG crop.

STM32H743 (121а) STM32U585 (198х) STM32N657
core Cortex-M7, 480 MHz Cortex-M33, 160 MHz Cortex-M55, 800 MHz, Helium
neural network on the core on the core NPU, 600 Gops per ST
flash memory 2 MB internal 2 MB internal none, external over XSPI
camera DCMI DCMI DCMI and DCMIPP with ISP, CSI-2
sleep with clock running section 10 Stop 2, Standby (10а) Standby 90.4 µA at 1.8 V

The cycle of an outdoor camera on the STM32N6: a PIR motion sensor or an RTC alarm wakes the chip from Standby through the WKUP pin; the ROM loads the firmware from external flash memory over XSPI; the image sensor delivers a frame through CSI-2 or the parallel port into the DCMIPP with ISP; a 192 by 192 pixel frame lands in AXI-SRAM; the Neural-ART NPU computes yolov8n_192 and outputs an int8 tensor of 5 rows of 756 anchors; the Cortex-M55 with Helium selects anchors by threshold directly in int8 codes, builds boxes and runs NMS; a 12-byte event goes out over LoRa at SF9 in 205.8 ms; the chip goes back to Standby at 90.4 µA at 1.8 V. Below — charge per day at 50 triggers: sleep 1.9 mAh, sensor and exposure 1.3, radio 0.5, boot, NPU and heartbeat 0.3 each, total 4.7 mAh; in Stop, sleep would eat 60.5 mAh per day


121в.2 Frame, NPU and YOLO output

How the model gets into the NPU. The .tflite model is run through the ST Edge AI compiler: the OpenMV settings are in lib/stai/scripts/neuralart.json (a profile with --enable-epoch-controller, pools from stm32n6.mpool, on chip — up to 1024 KB). The result is a relocatable image: epoch code and weights. The network is computed in epochs — pieces that the NPU goes through on its own, from being set up by the CPU to an interrupt (RM0486, 20.1). What OpenMV does (lib/stai/stai_backend.c, paraphrased):

YOLOv8 output. The yolov8n_192.tflite model from OpenMV lib/models is 3228400 bytes, input 192×192×3 uint8. Inside it is int8; at the output the interpreter converts the last tensor to float. If you cut off this conversion, the NPU returns an int8 tensor of shape [1, 5, 756] with scale 0.00507508637 and zero point −128. 756 is the anchors of three grids 24×24 + 12×12 + 6×6 (strides of 8, 16, 32 pixels). Rows 0–3 are the box center and size as fractions of the input, row 4 is the score of the person class, already after the sigmoid. The scale is shared by all five rows: a threshold of 0.4 is code −49, a score of 0.9693 is code 63.

The second OpenMV model, yolo_lc_192.tflite (339448 bytes), is a YOLOv2 variant: output [1, 12, 12, 30] int8 with scale 0.176762134 and zero point 73: a 12×12 grid, 5 anchors of 6 numbers each (tx, ty, tw, th, objectness, class). Here everything is logits: you need a sigmoid and an exponential, and you need the anchor sizes the model was trained with.

Where to compute the post-processing. In OpenMV it is done in Python (ulab) in ml.postprocessing.ultralytics.YoloV8: the threshold is converted to codes and compared with the raw bytes (ultralytics.py, lines 108 and 116); only the anchors that pass are converted to float. NMS in OpenMV is "soft": the neighbors' scores are multiplied by exp(−IoU²/σ), classes are not distinguished (ml-core/ml/utils.py, lines 91 and 119). In a device without MicroPython this work is done by our own C function — it is below.


121в.3 Post-processing on the Cortex-M55

The task: get a few boxes out of 756 anchors (or 720 for YOLOv2). The rules of our code:

Threshold in codes:

/* Наименьший код q, у которого оценка уже не ниже порога. Сравнивать дальше можно
 * прямо байты выхода: перевод в float нужен только прошедшим. Недостижимый порог
 * даёт 127 — такой случай отсекает thr_reachable. */
int8_t yp_q_threshold(const yp_cfg_t *c, int logistic)
{
    for (int q = -128; q <= 127; q++) {
        float v = deq(c, q);
        if (logistic) v = sigm(v);
        if (v >= c->conf) return (int8_t)q;
    }
    return (int8_t)127;
}

Candidate list:

/* Кандидаты — по убыванию q; при равных раньше найденный выше. Полный список
 * вытесняет худшего: в памяти не больше YP_MAX_CAND рамок при любом шуме. */
static void put(yp_box_t *cand, int *cnt, const yp_box_t *b)
{
    int k = *cnt;
    if (k == YP_MAX_CAND) {
        if (b->q <= cand[k - 1].q) return;
        k--;
    }
    while (k > 0 && cand[k - 1].q < b->q) {
        cand[k] = cand[k - 1];
        k--;
    }
    cand[k] = *b;
    if (*cnt < YP_MAX_CAND) (*cnt)++;
}

The YOLOv8 decoder. On the M55 the loop goes 16 anchors at a time: vld1q_s8 loads 16 class scores, vmaxq_s8 takes the maximum over the classes, vcmpgeq_n_s8 compares with the threshold and gives a mask — one bit per anchor; only the set bits are processed. The tail (756 = 47 · 16 + 4) and the build without MVE go through an ordinary loop in the same order, so the result is identical:

static int8_t v8_best(const int8_t *out, int n, int nc, int i, int *best)
{
    const int8_t *s = out + 4 * n + i;
    int8_t q = s[0];
    *best = 0;
    for (int k = 1; k < nc; k++)
        if (s[k * n] > q) {
            q = s[k * n];
            *best = k;
        }
    return q;
}

static void v8_take(const int8_t *out, int n, int nc, int i, const yp_cfg_t *c,
                    yp_box_t *cand, int *cnt)
{
    int best;
    yp_box_t b;
    int8_t q = v8_best(out, n, nc, i, &best);
    if (!make_box(deq(c, out[i]), deq(c, out[n + i]), deq(c, out[2 * n + i]),
                  deq(c, out[3 * n + i]), c, &b))
        return;
    b.q = q;
    b.cls = (uint8_t)best;
    put(cand, cnt, &b);
}

int yp_decode_v8(const int8_t *out, int n, int nc, const yp_cfg_t *c,
                 yp_box_t *cand, int *n_above)
{
    int8_t t = yp_q_threshold(c, 0);
    int cnt = 0, above = 0, i = 0;
    if (!thr_reachable(c, 0, t)) {
        *n_above = 0;
        return 0;
    }
#ifdef YP_USE_MVE
    for (; i + 16 <= n; i += 16) {                  /* 16 якорей за проход */
        int8x16_t m = vld1q_s8(out + 4 * n + i);
        for (int k = 1; k < nc; k++)
            m = vmaxq_s8(m, vld1q_s8(out + (4 + k) * n + i));
        uint32_t p = vcmpgeq_n_s8(m, t);            /* бит на каждый байт-якорь */
        while (p) {
            int lane = __builtin_ctz(p);
            p &= p - 1;
            above++;
            v8_take(out, n, nc, i + lane, c, cand, &cnt);
        }
    }
#endif
    for (; i < n; i++) {
        int best;
        if (v8_best(out, n, nc, i, &best) >= t) {
            above++;
            v8_take(out, n, nc, i, c, cand, &cnt);
        }
    }
    *n_above = above;
    return cnt;
}

The inner loop over classes after arm-none-eabi-gcc 12.2.1 (-mcpu=cortex-m55 -O2), disassembled with -m armv8.1-m.main:

4a6:    ed93 7e00   vldrb.u8    q3, [r3, #0]
4aa:    dd0c        ble.n   4c6 <yp_decode_v8+0xc6>
4ac:    f10b 3eff   add.w   lr, fp, #4294967295 @ 0xffffffff
4b0:    f04e e001   dls lr, lr
4b4:    9b08        ldr r3, [sp, #32]
4b6:    4443        add r3, r8
4b8:    ed93 5e00   vldrb.u8    q2, [r3, #0]
4bc:    4453        add r3, sl
4be:    ef06 6644   vmax.s8 q3, q3, q2
4c2:    f00f c807   le  lr, 4b8 <yp_decode_v8+0xb8>
4c6:    9b05        ldr r3, [sp, #20]
4c8:    fe07 1f43   vcmp.s8 ge, q3, r3
4cc:    eefd 4a10   vmrs    r4, p0

dls/le is the M55 hardware loop; vcmp.s8 writes the mask to p0, and vmrs picks it up.

NMS and IoU in integers:

/* Пересечение больше доли num/den от объединения — в целых, без деления. */
static int iou_above(const yp_box_t *a, const yp_box_t *b, const yp_cfg_t *c)
{
    int ax1 = a->x + a->w, bx1 = b->x + b->w, ay1 = a->y + a->h, by1 = b->y + b->h;
    int ix = (ax1 < bx1 ? ax1 : bx1) - (a->x > b->x ? a->x : b->x);
    int iy = (ay1 < by1 ? ay1 : by1) - (a->y > b->y ? a->y : b->y);
    if (ix <= 0 || iy <= 0) return 0;
    int64_t inter = (int64_t)ix * iy;
    int64_t uni = (int64_t)a->w * a->h + (int64_t)b->w * b->h - inter;
    return inter * c->iou_den > uni * c->iou_num;
}

int yp_nms(yp_box_t *b, int n, const yp_cfg_t *c)
{
    int kept = 0;
    for (int i = 0; i < n; i++) {
        int drop = 0;
        for (int j = 0; j < kept && !drop; j++)
            if ((c->agnostic || b[j].cls == b[i].cls) && iou_above(&b[j], &b[i], c))
                drop = 1;
        if (!drop) b[kept++] = b[i];
    }
    return kept;
}

The exponential for YOLOv2 (the sigmoid is 1 / (1 + exp(−x))):

/* exp(x) = 2^m * exp(r), r = x - m*ln2, |r| <= 0,35; ряд до r^6. Только + - * и сдвиг
 * порядка — результат одинаков на любом процессоре с IEEE-754 float. */
float yp_exp(float x)
{
    if (x > 88.0f) x = 88.0f;
    if (x < -87.0f) x = -87.0f;
    float k = x * 1.44269504f;
    int m = (int)(k + (k >= 0.0f ? 0.5f : -0.5f));
    float r = x - (float)m * 0.693145752f;          /* ln2, старшая часть */
    r = r - (float)m * 1.42860677e-06f;             /* ln2, младшая часть */
    float p = 1.0f + r * (1.0f + r * (0.5f + r * (0.166666672f + r * (0.0416666679f
              + r * (0.00833333377f + r * 0.00138888892f)))));
    union { float f; uint32_t u; } s;
    s.u = (uint32_t)(m + 127) << 23;                /* 2^m */
    return p * s.f;
}

The YOLOv2 decoder (yp_decode_v2) works the same way: the objectness threshold is in codes, the class is the argmax of the raw logits (softmax does not change it), the center is (cell + sigmoid) / grid, the size is anchor · exp(t) / grid. It is given in full at the end of the section.


121в.4 Testing: synthetic data, the real model, the M55 in QEMU

The test sets are in gen_tensors.py: synthetic outputs with our own generator and the real yolov8n_192 output. The real one was obtained like this: the photo scripts/unittest/data/person.bmp from OpenMV (400×400, RGB565) was scaled down to 192×192 and run through the TensorFlow Lite 2.21.0 interpreter with the reference kernels; the int8 tensor before the conversion to float was taken. The best anchor has a score of 0.9693, a box covering the whole frame; 10 anchors are above the 0.4 threshold.

The same main_post.c was built with gcc 12.2.0 on the PC and arm-none-eabi-gcc 12.2.1 for the Cortex-M55 — with Helium and with -DYP_NO_MVE. Both M55 builds were run in QEMU 7.2 on the mps3-an547 machine (Cortex-M55), with output over semihosting. Output on the PC:

CASE s1_three kind=8 conf=40 agn=0 thr=-49 above=17 cand=17 kept=3
  BOX cls=0 q=45 x=44 y=58 w=29 h=79
  BOX cls=1 q=30 x=100 y=117 w=69 h=39
  BOX cls=2 q=10 x=93 y=48 w=22 h=19
CASE s2_overlap kind=8 conf=40 agn=0 thr=-49 above=11 cand=11 kept=2
  BOX cls=0 q=49 x=62 y=60 w=59 h=103
  BOX cls=1 q=20 x=67 y=55 w=57 h=90
CASE s2_overlap_agn kind=8 conf=40 agn=1 thr=-49 above=11 cand=11 kept=1
  BOX cls=0 q=49 x=62 y=60 w=59 h=103
CASE s3_flood kind=8 conf=40 agn=0 thr=-49 above=240 cand=64 kept=58
CASE s4_v2 kind=2 conf=50 agn=0 thr=73 above=6 cand=6 kept=2
  BOX cls=0 q=84 x=46 y=30 w=40 h=91
  BOX cls=1 q=81 x=104 y=109 w=79 h=48
CASE r1_person_40 kind=8 conf=40 agn=0 thr=-49 above=10 cand=10 kept=1
  BOX cls=0 q=63 x=0 y=0 w=190 h=190
CASE r1_person_25 kind=8 conf=25 agn=0 thr=-78 above=10 cand=10 kept=1
  BOX cls=0 q=63 x=0 y=0 w=190 h=190
DONE

Comparison:

What Against what Result
C output on the PC yolo_post.py — the same logic, every operation rounded to float32 matched line by line
C output on the PC Cortex-M55 in QEMU, with Helium and without matched line by line, both builds
C output on the PC numpy 2.3.3: the whole tensor in float, np.exp, IoU as a matrix 7 sets out of 7, boxes — a difference of 0 pixels
yp_exp math.exp on −20…20 in steps of 0.01 relative error up to 1.15·10⁻⁶

Code size of yolo_post.c for the Cortex-M55 (-O2): 2488 bytes with Helium and 2328 without; no data and no .bss. Of these, yp_decode_v8 is 404 and 414 bytes, yp_decode_v2 is 684, yp_nms is 374, yp_exp is 332. Instructions using the q vector registers: 0 in the build without Helium, 4 with Helium: two vldrb.u8, vmax.s8 and vcmp.s8 from the listing above. How many cycles this takes on a real M55 was not measured: QEMU does not count cycles.


121в.5 Energy budget on an 18650

The cycle: Standby → wake-up by PIR or RTC alarm → boot from external flash memory → sensor and exposure → NPU → post-processing → LoRa → Standby. Plus an hourly timer "heartbeat" without the camera. The model is energy121v.py; the current on the 1.8 V rail is converted to battery current through the converter efficiency: I_bat = I · 1.8 / (3.6 · 0.85).

What comes from the documentation (DS14791, 25 °C, typical, internal SMPS):

Mode Current at 1.8 V Where
Run at 800 MHz, all peripherals clocked 99.93 mA (IDDSMPS) table 40, p. 155
Sleep, 800 MHz, everything clocked 80.9 mA table 42, p. 157
Stop, SVOS low 3.60 mA + 273.4 µA + 362.7 µA = 4.24 mA table 44, p. 158
Standby, backup RAM and RTC on LSE 48.4 + 42 = 90.4 µA table 45, p. 159
the same at 85 °C 69.0 + 80 = 149 µA table 45, p. 159
from Standby to the first instruction 400 µs table 47, p. 160

The core at 800 MHz alone draws 85 mA from VDDCORE with the peripherals off and 172 mA with them on (table 34, p. 149). The datasheet does not give the NPU current during computation.

What is estimated (measure it on your own board): NPU current 200 mA at 1.8 V, boot 0.3 s, exposure 1.0 s (that is how long the OpenMV example time_lapse_photos.py waits for the sensor, line 55: snapshot(time=1000)), NPU 0.1 s, post-processing 0.05 s, image sensor 150 mW, external memory 20 mA, LoRa — 45 mA when transmitting and 12 mA when receiving for 0.2 s, LTE-M — 120 mA for 6 s, Wi-Fi — 180 mA for 2.5 s, PIR 15 µA, everything else in sleep 12 µA, 18650 — 3000 mAh, 90 % available at +20 °C and 60 % at −20 °C, self-discharge 2 % per month. The script itself prints all the assumptions with their sources.

The LoRa message is 12 bytes of event and 13 bytes of LoRaWAN overhead: at SF9 and 125 kHz this is 205.8 ms on air. The formula is from section 89, and the script checks it: 23 bytes at SF9 — 205.8 ms, as in the table of section 89; 63 bytes at SF7 — 118.0 ms, as in 48в.

Run results:

Value
whole-board sleep in Standby 80.2 µA from the battery, 1.92 mAh per day
the same when sleeping in Stop 2518.8 µA, 60.5 mAh per day
one trigger with LoRa 175.0 mA·s: sensor and exposure 96.6, radio 35.5, boot 21.2, NPU 18.8
one trigger with LTE-M / Wi-Fi 1145.1 / 708.5 mA·s
from PIR to the end of transmission 1.66 s

How many days the device will last:

Link, triggers per day mAh per day 1×18650, +20 °C 1×18650, −20 °C 2×18650, −20 °C
LoRa, 10 2.8 567 378 533
LoRa, 50 4.7 403 268 414
LoRa, 200 12.0 193 129 225
LTE-M, 10 5.5 362 241 381
LTE-M, 50 18.2 134 89 162
LTE-M, 200 65.9 40 27 52
Wi-Fi, 10 4.2 433 288 437
Wi-Fi, 50 12.1 191 128 223
Wi-Fi, 200 41.6 62 41 79

Conclusions from the model. Sleep only in Standby: Stop on the N6 costs milliamps: with LoRa at 50 triggers, one 18650 at +20 °C lasts 41 days instead of 403. At 50 triggers, sleep and exposure take two thirds of the daily charge; the NPU is a small share if the network computes for 0.1 s. The radio decides the choice of link: one trigger over LTE-M is 6.5 times more expensive than over LoRa. At low load the lifetime is limited by self-discharge — 2.0 mAh per day per cell.

Sun. A 2 W panel, 0.5 h of full sun per day in December, 5 h in June, efficiency 0.7 (estimates): an intake of 194 and 1944 mAh per day. The December intake would be enough for 3911 LoRa triggers per day — if you can use it. In frost you cannot: Li-ion is charged only within the allowed temperature range (173б), and in winter the panel's energy during the day is lost while the cell is cold. So the winter reserve is calculated without sun — the "−20 °C" column of the table.


121в.6 Requirements specification for the device

Abridged, following the structure of GOST 15.016 (specification for a product). The numbers in the requirements are the author's proposals; the references to the 121в.5 model are there to show where the margin comes from.

1. Name and purpose. "Outdoor AI camera on the STM32N6 with battery power". Detects people, animals and cars in the field of view, transmits an event (class, number of objects, box, time, charge) and, on request, a JPEG crop. It does not record video and does not store frames.

2. Basis and stages. Under a contract; the stages are in item 11.

3. Composition.

Unit Requirement
processor STM32N657 (N6x7 line with NPU); external XSPI NOR flash memory of 32 MB or more for the firmware, models and log
image sensor CSI-2 or parallel, global or rolling shutter, IR illumination at the customer's request
motion sensor PIR on the WKUP pin; the second wake-up source is the RTC alarm
communication LoRaWAN (main) and LTE-M or Wi-Fi (depending on the version)
power 1 or 2 protected 18650 cells; a solar charge controller with charging disabled by temperature (173б)
enclosure outdoor, with a glass window in front of the lens and a PIR window

4. Functional requirements.

No. Requirement Verification
F1 classes: person, animal, car; int8 model on the NPU during testing
F2 share of detected people in the zone up to 10 m in daytime — at least 0.9; false events — no more than 5 per day at the site field tests
F3 from PIR trigger to end of transmission — no more than 3 s (1.66 s per the model) measurement
F4 the score threshold and the NMS threshold are set remotely; per-class NMS command test
F5 hourly heartbeat: charge, temperature, counters server log
F6 the network output is checked at all thresholds: no more than 64 candidates, no memory allocation unit tests (121в.4)

5. Power and autonomy.

No. Requirement Where the margin comes from
P1 sleep only in Standby; board sleep current no more than 100 µA from the battery at +20 °C 80.2 µA per the model
P2 at least 365 days on 2×18650 at 50 LoRa triggers per day and −20 °C without sun 414 days per the model
P3 LTE-M version: at least 90 days on 2×18650 at 50 triggers and −20 °C 162 days per the model
P4 with a panel: operation without replacing the cells at 50 triggers per day all year round December intake 194 mAh with consumption 4.7
P5 charge measurement, a low-battery message 14 days before shutdown testing

6. Operating conditions. Air temperature from −40 to +50 °C when running on cells that allow this range; the chip has the industrial range −40…125 °C (DS14791, p. 262). Enclosure protection — IP66 per GOST 14254. No condensation on the glass: a pressure equalization vent.

7. Communication. LoRaWAN EU868 class A, the event message is 12 bytes; duty cycle no more than 1 % (89, 93). LTE-M with PSM mode. Messages are signed with the LoRaWAN network keys (94) or TLS.

8. Firmware and model updates. Our own bootloader or MCUboot with two slots and rollback (48, 48б), image signing (48а), encryption (48г). Over LoRa — only delta firmware updates (48в). The model is not updated over LoRa: 3228400 bytes is 67259 frames of 48 bytes, at SF7 with 1 % on air no faster than 9.2 days, at SF12 — 217.5 days. Models are updated over LTE-M, Wi-Fi or on site.

9. Data storage and privacy. A frame lives only in RAM until the end of the cycle. Only metadata goes out; a crop only at the operator's request. Images of people can be personal data (152-FZ): the "metadata only" mode is the default. Keys are in OTP and a protected area, debug is closed (DS14791: life cycle with RDP and a debug password).

10. Tests. Unit: post-processing on the PC, in QEMU M55 and on the board — matching output (121в.4). Energy: sleep current, trigger charge by phase, comparison with the 121в.5 model. Climatic: −40, −20, +50 °C, cycles. Enclosure protection IP66. Field: a month at a site with event labeling, detection and false-trigger rates.

11. Stages.

Stage Result
1. Mock-up N6 evaluation board, sensor, PIR, LoRa; current measurements of all phases, a refined 121в.5 model
2. Model training and quantization of our own classes, compiling for the NPU, epoch timing
3. Prototype board, enclosure, firmware with update and logging
4. Testing items 10; test reports
5. Documentation manual, test procedure, product passport

121в.7 Pitfalls


121в.8 Summary

Full sources

Directory _verify/n6cam121v/. To build on a PC: gcc -std=c11 -O2 -ffp-contract=off main_post.c yolo_post.c; for the M55 — as in run.sh. tensors.h is generated by python gen_tensors.py tensors.h.

yolo_post.h — the post-processing interface.

/* Постобработка детектора YOLO на Cortex-M55: выход сети int8 -> рамки -> NMS.
 * Свой код раздела 121в. Без malloc и без libm: одинаковый результат на ПК и на M55. */
#ifndef YOLO_POST_H
#define YOLO_POST_H

#include <stdint.h>

#define YP_MAX_CAND 64          /* сколько кандидатов держим до NMS: лучшие по оценке */

typedef struct {
    int16_t x, y, w, h;         /* рамка в точках входа сети */
    int8_t  q;                  /* оценка в кодах int8 выходного тензора */
    uint8_t cls;                /* номер класса */
} yp_box_t;

typedef struct {
    float   scale;              /* квантование выхода: значение = (q - zp) * scale */
    int32_t zp;
    int     in_w, in_h;         /* размер входа сети, точек */
    float   conf;               /* порог оценки, 0..1 */
    int     iou_num, iou_den;   /* порог NMS дробью: 45/100 = 0,45 */
    int     agnostic;           /* 1 — одно NMS на все классы, 0 — своё на каждый */
} yp_cfg_t;

/* YOLOv8 (выгрузка Ultralytics): out[(4 + nc) * n], строки cx, cy, w, h (доли входа),
 * дальше nc строк оценок классов (уже после сигмоиды); n — число якорей. */
int yp_decode_v8(const int8_t *out, int n, int nc, const yp_cfg_t *c,
                 yp_box_t *cand, int *n_above);

/* YOLOv2 и YOLO-LC: out[gh][gw][na * (5 + nc)]: tx, ty, tw, th, объектность, nc логитов.
 * anchors[2 * na] — ширина и высота якоря в клетках сетки. */
int yp_decode_v2(const int8_t *out, int gh, int gw, int na, int nc, const float *anchors,
                 const yp_cfg_t *c, yp_box_t *cand, int *n_above);

/* Жадное NMS на месте: рамки уже по убыванию q; возвращает, сколько осталось. */
int yp_nms(yp_box_t *b, int n, const yp_cfg_t *c);

int8_t yp_q_threshold(const yp_cfg_t *c, int logistic); /* порог в кодах int8 */
float  yp_exp(float x);                                 /* своя экспонента, без libm */

#endif

yolo_post.c — YOLOv8 and YOLOv2 decoders, threshold, candidates, NMS, exponential.

/* Постобработка YOLO: декодирование выхода int8 в рамки и NMS. Раздел 121в.
 * Собирается gcc на ПК и arm-none-eabi-gcc -mcpu=cortex-m55; на M55 поиск
 * кандидатов YOLOv8 идёт по 16 якорей за команду Helium (MVE). */
#include "yolo_post.h"

#if defined(__ARM_FEATURE_MVE) && !defined(YP_NO_MVE)
#include <arm_mve.h>
#define YP_USE_MVE 1
#endif

static float deq(const yp_cfg_t *c, int q) { return (float)(q - c->zp) * c->scale; }

/* exp(x) = 2^m * exp(r), r = x - m*ln2, |r| <= 0,35; ряд до r^6. Только + - * и сдвиг
 * порядка — результат одинаков на любом процессоре с IEEE-754 float. */
float yp_exp(float x)
{
    if (x > 88.0f) x = 88.0f;
    if (x < -87.0f) x = -87.0f;
    float k = x * 1.44269504f;
    int m = (int)(k + (k >= 0.0f ? 0.5f : -0.5f));
    float r = x - (float)m * 0.693145752f;          /* ln2, старшая часть */
    r = r - (float)m * 1.42860677e-06f;             /* ln2, младшая часть */
    float p = 1.0f + r * (1.0f + r * (0.5f + r * (0.166666672f + r * (0.0416666679f
              + r * (0.00833333377f + r * 0.00138888892f)))));
    union { float f; uint32_t u; } s;
    s.u = (uint32_t)(m + 127) << 23;                /* 2^m */
    return p * s.f;
}

static float sigm(float x) { return 1.0f / (1.0f + yp_exp(-x)); }

/* Наименьший код q, у которого оценка уже не ниже порога. Сравнивать дальше можно
 * прямо байты выхода: перевод в float нужен только прошедшим. Недостижимый порог
 * даёт 127 — такой случай отсекает thr_reachable. */
int8_t yp_q_threshold(const yp_cfg_t *c, int logistic)
{
    for (int q = -128; q <= 127; q++) {
        float v = deq(c, q);
        if (logistic) v = sigm(v);
        if (v >= c->conf) return (int8_t)q;
    }
    return (int8_t)127;
}

static int thr_reachable(const yp_cfg_t *c, int logistic, int8_t t)
{
    float v = deq(c, t);
    return (logistic ? sigm(v) : v) >= c->conf;
}

static float clampf(float v, float hi) { return v < 0.0f ? 0.0f : (v > hi ? hi : v); }

/* Центр и размер в долях входа -> рамка в точках; пустая рамка не нужна. */
static int make_box(float cx, float cy, float w, float h, const yp_cfg_t *c, yp_box_t *b)
{
    float W = (float)c->in_w, H = (float)c->in_h;
    float x0 = clampf((cx - w * 0.5f) * W, W), x1 = clampf((cx + w * 0.5f) * W, W);
    float y0 = clampf((cy - h * 0.5f) * H, H), y1 = clampf((cy + h * 0.5f) * H, H);
    int bw = (int)(x1 - x0), bh = (int)(y1 - y0);
    if (bw <= 0 || bh <= 0) return 0;
    b->x = (int16_t)(int)x0;
    b->y = (int16_t)(int)y0;
    b->w = (int16_t)bw;
    b->h = (int16_t)bh;
    return 1;
}

/* Кандидаты — по убыванию q; при равных раньше найденный выше. Полный список
 * вытесняет худшего: в памяти не больше YP_MAX_CAND рамок при любом шуме. */
static void put(yp_box_t *cand, int *cnt, const yp_box_t *b)
{
    int k = *cnt;
    if (k == YP_MAX_CAND) {
        if (b->q <= cand[k - 1].q) return;
        k--;
    }
    while (k > 0 && cand[k - 1].q < b->q) {
        cand[k] = cand[k - 1];
        k--;
    }
    cand[k] = *b;
    if (*cnt < YP_MAX_CAND) (*cnt)++;
}

static int8_t v8_best(const int8_t *out, int n, int nc, int i, int *best)
{
    const int8_t *s = out + 4 * n + i;
    int8_t q = s[0];
    *best = 0;
    for (int k = 1; k < nc; k++)
        if (s[k * n] > q) {
            q = s[k * n];
            *best = k;
        }
    return q;
}

static void v8_take(const int8_t *out, int n, int nc, int i, const yp_cfg_t *c,
                    yp_box_t *cand, int *cnt)
{
    int best;
    yp_box_t b;
    int8_t q = v8_best(out, n, nc, i, &best);
    if (!make_box(deq(c, out[i]), deq(c, out[n + i]), deq(c, out[2 * n + i]),
                  deq(c, out[3 * n + i]), c, &b))
        return;
    b.q = q;
    b.cls = (uint8_t)best;
    put(cand, cnt, &b);
}

int yp_decode_v8(const int8_t *out, int n, int nc, const yp_cfg_t *c,
                 yp_box_t *cand, int *n_above)
{
    int8_t t = yp_q_threshold(c, 0);
    int cnt = 0, above = 0, i = 0;
    if (!thr_reachable(c, 0, t)) {
        *n_above = 0;
        return 0;
    }
#ifdef YP_USE_MVE
    for (; i + 16 <= n; i += 16) {                  /* 16 якорей за проход */
        int8x16_t m = vld1q_s8(out + 4 * n + i);
        for (int k = 1; k < nc; k++)
            m = vmaxq_s8(m, vld1q_s8(out + (4 + k) * n + i));
        uint32_t p = vcmpgeq_n_s8(m, t);            /* бит на каждый байт-якорь */
        while (p) {
            int lane = __builtin_ctz(p);
            p &= p - 1;
            above++;
            v8_take(out, n, nc, i + lane, c, cand, &cnt);
        }
    }
#endif
    for (; i < n; i++) {
        int best;
        if (v8_best(out, n, nc, i, &best) >= t) {
            above++;
            v8_take(out, n, nc, i, c, cand, &cnt);
        }
    }
    *n_above = above;
    return cnt;
}

int yp_decode_v2(const int8_t *out, int gh, int gw, int na, int nc, const float *anchors,
                 const yp_cfg_t *c, yp_box_t *cand, int *n_above)
{
    int8_t t = yp_q_threshold(c, 1);
    int cnt = 0, above = 0, st = 5 + nc;
    if (!thr_reachable(c, 1, t)) {
        *n_above = 0;
        return 0;
    }
    for (int r = 0; r < gh; r++)
        for (int col = 0; col < gw; col++)
            for (int a = 0; a < na; a++) {
                const int8_t *p = out + ((r * gw + col) * na + a) * st;
                if (p[4] < t) continue;              /* объектность ниже порога */
                above++;
                int best = 0;
                for (int k = 1; k < nc; k++)         /* softmax не нужен: argmax тот же */
                    if (p[5 + k] > p[5 + best]) best = k;
                float cx = ((float)col + sigm(deq(c, p[0]))) / (float)gw;
                float cy = ((float)r + sigm(deq(c, p[1]))) / (float)gh;
                float w = anchors[2 * a] * yp_exp(deq(c, p[2])) / (float)gw;
                float h = anchors[2 * a + 1] * yp_exp(deq(c, p[3])) / (float)gh;
                yp_box_t b;
                if (!make_box(cx, cy, w, h, c, &b)) continue;
                b.q = p[4];
                b.cls = (uint8_t)best;
                put(cand, &cnt, &b);
            }
    *n_above = above;
    return cnt;
}

/* Пересечение больше доли num/den от объединения — в целых, без деления. */
static int iou_above(const yp_box_t *a, const yp_box_t *b, const yp_cfg_t *c)
{
    int ax1 = a->x + a->w, bx1 = b->x + b->w, ay1 = a->y + a->h, by1 = b->y + b->h;
    int ix = (ax1 < bx1 ? ax1 : bx1) - (a->x > b->x ? a->x : b->x);
    int iy = (ay1 < by1 ? ay1 : by1) - (a->y > b->y ? a->y : b->y);
    if (ix <= 0 || iy <= 0) return 0;
    int64_t inter = (int64_t)ix * iy;
    int64_t uni = (int64_t)a->w * a->h + (int64_t)b->w * b->h - inter;
    return inter * c->iou_den > uni * c->iou_num;
}

int yp_nms(yp_box_t *b, int n, const yp_cfg_t *c)
{
    int kept = 0;
    for (int i = 0; i < n; i++) {
        int drop = 0;
        for (int j = 0; j < kept && !drop; j++)
            if ((c->agnostic || b[j].cls == b[i].cls) && iou_above(&b[j], &b[i], c))
                drop = 1;
        if (!drop) b[kept++] = b[i];
    }
    return kept;
}

main_post.c — runs the test sets on the PC and on the M55.

/* Прогон постобработки на наборах из tensors.h. Один и тот же файл собирается
 * для ПК (вывод в stdout) и для Cortex-M55 в QEMU mps3-an547 (вывод полухостингом). */
#include <stdint.h>
#include "yolo_post.h"

typedef struct {
    const char *name;
    int kind;                   /* 8 — YOLOv8, 2 — YOLOv2/LC */
    const int8_t *data;
    int n, nc, gh, gw, na, conf_pct;
    yp_cfg_t cfg;
} tcase_t;

#include "tensors.h"

#ifdef __arm__
static void out(const char *s)
{
    register int r0 __asm__("r0") = 0x04;          /* SYS_WRITE0 */
    register const char *r1 __asm__("r1") = s;
    __asm__ volatile("bkpt 0xAB" : "+r"(r0) : "r"(r1) : "memory");
}
#else
#include <stdio.h>
static void out(const char *s) { fputs(s, stdout); }
#endif

static char line[160];
static int pos;

static void put_s(const char *s) { while (*s) line[pos++] = *s++; }

static void put_i(int v)
{
    char t[12];
    int k = 0;
    unsigned u = v < 0 ? (unsigned)(-v) : (unsigned)v;
    if (v < 0) line[pos++] = '-';
    do { t[k++] = (char)('0' + u % 10); u /= 10; } while (u);
    while (k) line[pos++] = t[--k];
}

static void kv(const char *k, int v) { put_s(" "); put_s(k); put_s("="); put_i(v); }
static void flush(void) { line[pos++] = '\n'; line[pos] = 0; out(line); pos = 0; }

int main(void)
{
    static yp_box_t cand[YP_MAX_CAND];
    for (unsigned i = 0; i < sizeof cases / sizeof cases[0]; i++) {
        const tcase_t *t = &cases[i];
        int above, n;
        if (t->kind == 8)
            n = yp_decode_v8(t->data, t->n, t->nc, &t->cfg, cand, &above);
        else
            n = yp_decode_v2(t->data, t->gh, t->gw, t->na, t->nc, anchors_v2, &t->cfg, cand, &above);
        int kept = yp_nms(cand, n, &t->cfg);
        put_s("CASE "); put_s(t->name); kv("kind", t->kind); kv("conf", t->conf_pct);
        kv("agn", t->cfg.agnostic); kv("thr", yp_q_threshold(&t->cfg, t->kind == 2));
        kv("above", above); kv("cand", n); kv("kept", kept);
        flush();
        for (int k = 0; k < kept; k++) {
            put_s("  BOX"); kv("cls", cand[k].cls); kv("q", cand[k].q); kv("x", cand[k].x);
            kv("y", cand[k].y); kv("w", cand[k].w); kv("h", cand[k].h);
            flush();
        }
    }
    put_s("DONE");
    flush();
    return 0;
}

m55_boot.c — startup on the Cortex-M55 in QEMU: vectors, FPU and Helium, exit.

/* Запуск на Cortex-M55 в QEMU mps3-an547: таблица векторов, FPU и MVE, .data/.bss, main, выход. */
#include <stdint.h>

extern int main(void);
extern uint32_t _sidata, _sdata, _edata, _sbss, _ebss, _estack;

static void semi_exit(void)
{
    register int r0 __asm__("r0") = 0x18;          /* SYS_EXIT */
    register int r1 __asm__("r1") = 0x20026;       /* ADP_Stopped_ApplicationExit */
    __asm__ volatile("bkpt 0xAB" : : "r"(r0), "r"(r1) : "memory");
    for (;;) {}
}

void Reset_Handler(void)
{
    *(volatile uint32_t *)0xE000ED88u |= 0xFu << 20; /* CPACR: CP10, CP11 — FPU и Helium */
    __asm__ volatile("dsb\n isb");
    for (uint32_t *s = &_sidata, *d = &_sdata; d < &_edata;) *d++ = *s++;
    for (uint32_t *d = &_sbss; d < &_ebss;) *d++ = 0;
    main();
    semi_exit();
}

static void Fault_Handler(void) { semi_exit(); }

__attribute__((section(".vectors"), used))
static const void *vectors[16] = {
    &_estack, Reset_Handler, Fault_Handler, Fault_Handler, Fault_Handler, Fault_Handler, Fault_Handler,
};

m55.ld — the mps3-an547 memory map.

/* QEMU mps3-an547: ITCM 512 КБ с 0x00000000 — код и константы, DTCM 512 КБ с 0x20000000 — данные и стек. */
MEMORY
{
  ITCM (rx)  : ORIGIN = 0x00000000, LENGTH = 512K
  DTCM (rwx) : ORIGIN = 0x20000000, LENGTH = 512K
}
ENTRY(Reset_Handler)
SECTIONS
{
  .text : { KEEP(*(.vectors)) *(.text*) *(.rodata*) . = ALIGN(4); } > ITCM
  .data : { _sdata = .; *(.data*) . = ALIGN(4); _edata = .; } > DTCM AT > ITCM
  _sidata = LOADADDR(.data);
  .bss (NOLOAD) : { _sbss = .; *(.bss*) *(COMMON) . = ALIGN(4); _ebss = .; } > DTCM
  _estack = ORIGIN(DTCM) + LENGTH(DTCM);
}

yolo_post.py — the same logic in Python with rounding to float32.

# -*- coding: utf-8 -*-
"""Та же постобработка YOLO, что в yolo_post.c, на Python: каждая операция с float
округляется до float32, поэтому вывод совпадает с C на ПК и на Cortex-M55 байт в байт.

python yolo_post.py   — печатает то же, что main_post.c"""
import struct
from gen_tensors import cases, ANCH_V2

MAX_CAND = 64


def f32(x):
    return struct.unpack("<f", struct.pack("<f", x))[0]


def s8(b):
    return b - 256 if b > 127 else b


def yp_exp(x):
    x = f32(x)
    x = 88.0 if x > 88.0 else (-87.0 if x < -87.0 else x)
    k = f32(x * f32(1.44269504))
    m = int(f32(k + (0.5 if k >= 0.0 else -0.5)))
    r = f32(x - f32(m * f32(0.693145752)))
    r = f32(r - f32(m * f32(1.42860677e-06)))
    p = f32(0.00138888892)
    for c in (0.00833333377, 0.0416666679, 0.166666672, 0.5, 1.0, 1.0):
        p = f32(f32(c) + f32(r * p))
    return f32(p * 2.0 ** m)


def sigm(x):
    return f32(1.0 / f32(1.0 + yp_exp(-x)))


def deq(c, q):
    return f32(f32(q - c["zp"]) * c["scale"])


def q_threshold(c, logistic):
    for q in range(-128, 128):
        v = deq(c, q)
        if logistic:
            v = sigm(v)
        if v >= c["conf"]:
            return q
    return 127


def reachable(c, logistic, t):
    v = deq(c, t)
    return (sigm(v) if logistic else v) >= c["conf"]


def make_box(cx, cy, w, h, c):
    W, H = f32(c["in_w"]), f32(c["in_h"])
    cl = lambda v, hi: 0.0 if v < 0.0 else (hi if v > hi else v)
    x0 = cl(f32(f32(cx - f32(w * 0.5)) * W), W)
    x1 = cl(f32(f32(cx + f32(w * 0.5)) * W), W)
    y0 = cl(f32(f32(cy - f32(h * 0.5)) * H), H)
    y1 = cl(f32(f32(cy + f32(h * 0.5)) * H), H)
    bw, bh = int(f32(x1 - x0)), int(f32(y1 - y0))
    if bw <= 0 or bh <= 0:
        return None
    return [int(x0), int(y0), bw, bh]


def put(cand, b):
    if len(cand) == MAX_CAND:
        if b["q"] <= cand[-1]["q"]:
            return
        cand.pop()
    k = len(cand)
    while k > 0 and cand[k - 1]["q"] < b["q"]:
        k -= 1
    cand.insert(k, b)


def decode_v8(d, n, nc, c):
    t = q_threshold(c, False)
    if not reachable(c, False, t):
        return [], 0
    cand, above = [], 0
    for i in range(n):
        q, best = s8(d[4 * n + i]), 0
        for k in range(1, nc):
            if s8(d[(4 + k) * n + i]) > q:
                q, best = s8(d[(4 + k) * n + i]), k
        if q < t:
            continue
        above += 1
        box = make_box(*(deq(c, s8(d[r * n + i])) for r in range(4)), c)
        if box:
            put(cand, dict(box=box, q=q, cls=best))
    return cand, above


def decode_v2(d, gh, gw, na, nc, anchors, c):
    t = q_threshold(c, True)
    if not reachable(c, True, t):
        return [], 0
    cand, above, st = [], 0, 5 + nc
    for r in range(gh):
        for col in range(gw):
            for a in range(na):
                p = [s8(v) for v in d[((r * gw + col) * na + a) * st:][:st]]
                if p[4] < t:
                    continue
                above += 1
                best = 0
                for k in range(1, nc):
                    if p[5 + k] > p[5 + best]:
                        best = k
                cx = f32(f32(col + sigm(deq(c, p[0]))) / gw)
                cy = f32(f32(r + sigm(deq(c, p[1]))) / gh)
                w = f32(f32(f32(anchors[2 * a]) * yp_exp(deq(c, p[2]))) / gw)
                h = f32(f32(f32(anchors[2 * a + 1]) * yp_exp(deq(c, p[3]))) / gh)
                box = make_box(cx, cy, w, h, c)
                if box:
                    put(cand, dict(box=box, q=p[4], cls=best))
    return cand, above


def iou_above(a, b, c):
    ix = min(a[0] + a[2], b[0] + b[2]) - max(a[0], b[0])
    iy = min(a[1] + a[3], b[1] + b[3]) - max(a[1], b[1])
    if ix <= 0 or iy <= 0:
        return False
    inter = ix * iy
    return inter * c["iou_den"] > (a[2] * a[3] + b[2] * b[3] - inter) * c["iou_num"]


def nms(cand, c):
    kept = []
    for b in cand:
        if not any((c["agnostic"] or k["cls"] == b["cls"]) and iou_above(k["box"], b["box"], c) for k in kept):
            kept.append(b)
    return kept


def run(cs):
    lines = []
    for t in cs:
        c = dict(t, scale=f32(t["scale"]), conf=f32(t["conf"] / 100.0))
        if t["kind"] == 8:
            cand, above = decode_v8(t["data"], t["n"], t["nc"], c)
        else:
            cand, above = decode_v2(t["data"], t["gh"], t["gw"], t["na"], t["nc"], ANCH_V2, c)
        kept = nms(cand, c)
        lines.append("CASE %s kind=%d conf=%d agn=%d thr=%d above=%d cand=%d kept=%d" % (
            t["name"], t["kind"], t["conf"], t["agnostic"], q_threshold(c, t["kind"] == 2), above, len(cand), len(kept)))
        for b in kept:
            lines.append("  BOX cls=%d q=%d x=%d y=%d w=%d h=%d" % ((b["cls"], b["q"]) + tuple(b["box"])))
    lines.append("DONE")
    return lines


if __name__ == "__main__":
    print("\n".join(run(cases())))

energy121v.py — the energy budget model.

# -*- coding: utf-8 -*-
"""Энергобюджет уличной AI-камеры на STM32N6 (раздел 121в). Только стандартная библиотека.

python energy121v.py

Цикл: Standby -> пробуждение по PIR или таймеру -> загрузка -> кадр -> NPU -> отправка -> Standby.
У каждого числа — источник: «DS» — даташит DS14791 Rev 11 (таблица, страница, условия),
«оценка» — числа в документах нет, взято допущение; его и надо мерить на своей плате."""

# ── Параметры: имя -> (значение, единица, источник) ─────────────────────────────────
P = {
    # STM32N6, токи при 1,8 В, 25 °C, типовые; SMPS внутренний
    "n6_run_ma":      (99.93, "мА", "DS табл. 40, с. 155: IDDSMPS, 800 МГц, все периферийные включены"),
    "n6_sleep_ma":    (80.9, "мА", "DS табл. 42, с. 157: IDDSMPS в Sleep, 800 МГц, все периферийные включены"),
    "n6_stop_ma":     (3.60 + 0.2734 + 0.3627, "мА", "DS табл. 44, с. 158: Stop SVOS low, IDDSMPS 3,60 мА + IDD 273,4 мкА + IDDA1V8 362,7 мкА"),
    "n6_stby_ua":     (48.4 + 42.0, "мкА", "DS табл. 45, с. 159: Standby, RAM резерва и RTC с LSE включены, IDD 48,4 + IDDA18ON 42"),
    "n6_stby85_ua":   (69.0 + 80.0, "мкА", "DS табл. 45, с. 159: то же при TJ = 85 °C: 69,0 + 80"),
    "n6_npu_ma":      (200.0, "мА", "оценка: тока NPU в вычислении в даташите нет"),
    "v_rail":         (1.8, "В", "DS табл. 25: VDD 1,8 В"),
    # фазы одного срабатывания
    "t_boot_s":       (0.30, "с", "оценка: ПЗУ грузит прошивку с внешней флеш-памяти (DS: из Standby до первой команды 400 мкс)"),
    "t_ae_s":         (1.0, "с", "пример OpenMV: snapshot(time=1000) — дать датчику подстроиться"),
    "t_npu_s":        (0.10, "с", "оценка: YOLOv8n 192×192 на NPU"),
    "t_post_s":       (0.05, "с", "оценка: постобработка, JPEG вырезки"),
    "sensor_mw":      (150.0, "мВт", "оценка: датчик изображения во время съёмки"),
    "flash_ma":       (20.0, "мА", "оценка: внешняя флеш-память и PSRAM при работе, 1,8 В"),
    # связь (токи от батареи)
    "lora_tx_ma":     (45.0, "мА", "оценка: модуль LoRa на передаче +14 дБм"),
    "lora_rx_ma":     (12.0, "мА", "оценка: модуль LoRa на приёме"),
    "lora_rx_s":      (0.2, "с", "оценка: окна приёма класса A"),
    "lte_ma":         (120.0, "мА", "оценка: модем LTE-M, средний ток сеанса"),
    "lte_s":          (6.0, "с", "оценка: выход из PSM, отправка вырезки 8 КБ"),
    "wifi_ma":        (180.0, "мА", "оценка: Wi-Fi, подключение и отправка"),
    "wifi_s":         (2.5, "с", "оценка: подключение к точке и отправка кадра"),
    # сон всей платы помимо N6 (от батареи)
    "pir_ua":         (15.0, "мкА", "оценка: пироэлектрический датчик движения"),
    "other_ua":       (12.0, "мкА", "оценка: стабилизатор, флеш в глубоком сне, LoRa во сне, делитель батареи"),
    # питание
    "eta":            (0.85, "", "оценка: КПД преобразователя батарея -> 1,8 В"),
    "v_bat":          (3.6, "В", "оценка: среднее напряжение Li-ion за разряд"),
    "cell_mah":       (3000.0, "мА·ч", "оценка: ёмкость одного 18650 по паспорту"),
    "self_pct_mon":   (2.0, "%/мес", "оценка: саморазряд элемента Li-ion"),
    "use_warm":       (0.90, "", "оценка: доступная доля ёмкости при +20 °C"),
    "use_cold":       (0.60, "", "оценка: доступная доля ёмкости при −20 °C"),
    "pv_w":           (2.0, "Вт", "оценка: солнечная панель"),
    "sun_h_dec":      (0.5, "ч/сут", "оценка: часов полного солнца в декабре"),
    "sun_h_jun":      (5.0, "ч/сут", "оценка: часов полного солнца в июне"),
    "pv_eta":         (0.70, "", "оценка: панель -> контроллер заряда -> элемент"),
    "hb_per_day":     (24, "раз/сут", "оценка: пульс по таймеру раз в час, без камеры"),
}
V = {k: v[0] for k, v in P.items()}


def lora_airtime_us(sf, bw, cr, payload):
    """Формула раздела 89: преамбула 8 символов, явный заголовок, CRC, DE при символе > 16 мс."""
    tsym = (1000000 << sf) // bw
    de = 1 if tsym > 16000 else 0
    num = 8 * payload - 4 * sf + 28 + 16
    den = 4 * (sf - 2 * de)
    blocks = (num + den - 1) // den if num > 0 else 0
    quarters = 4 * 8 + 17 + 4 * (8 + blocks * (cr + 4))
    return quarters * tsym // 4


def bat_ma(ma_rail):
    """Ток шины 1,8 В -> ток батареи через преобразователь."""
    return ma_rail * V["v_rail"] / (V["v_bat"] * V["eta"])


def event_mas(link):
    """Заряд одного срабатывания, мА·с от батареи, по фазам."""
    ph = {
        "загрузка": bat_ma(V["n6_run_ma"] + V["flash_ma"]) * V["t_boot_s"],
        "датчик и экспозиция": (bat_ma(V["n6_sleep_ma"]) + V["sensor_mw"] / (V["v_bat"] * V["eta"])) * V["t_ae_s"],
        "NPU": bat_ma(V["n6_run_ma"] + V["n6_npu_ma"] + V["flash_ma"]) * V["t_npu_s"],
        "постобработка": bat_ma(V["n6_run_ma"]) * V["t_post_s"],
    }
    if link == "lora":
        toa = lora_airtime_us(9, 125000, 1, 12 + 13) / 1e6
        ph["радио"] = V["lora_tx_ma"] * toa + V["lora_rx_ma"] * V["lora_rx_s"] + bat_ma(V["n6_run_ma"]) * (toa + V["lora_rx_s"])
    elif link == "lte":
        ph["радио"] = V["lte_ma"] * V["lte_s"] + bat_ma(V["n6_sleep_ma"]) * V["lte_s"]
    else:
        ph["радио"] = V["wifi_ma"] * V["wifi_s"] + bat_ma(V["n6_sleep_ma"]) * V["wifi_s"]
    return ph


def heartbeat_mas():
    toa = lora_airtime_us(9, 125000, 1, 4 + 13) / 1e6
    return bat_ma(V["n6_run_ma"] + V["flash_ma"]) * V["t_boot_s"] + V["lora_tx_ma"] * toa \
        + V["lora_rx_ma"] * V["lora_rx_s"] + bat_ma(V["n6_run_ma"]) * (toa + V["lora_rx_s"])


def sleep_ua(stop=False, hot=False):
    n6 = V["n6_stop_ma"] * 1000 if stop else (V["n6_stby85_ua"] if hot else V["n6_stby_ua"])
    return bat_ma(n6) + V["pir_ua"] + V["other_ua"]


def day_mah(events, link="lora", stop=False):
    ev = sum(event_mas(link).values())
    return (events * ev + V["hb_per_day"] * heartbeat_mas()) / 3600 + sleep_ua(stop) * 24 / 1000


def self_mah(n):
    """Саморазряд n элементов, мА·ч в сутки (месяц = 30 суток)."""
    return n * V["cell_mah"] * V["self_pct_mon"] / 100 / 30


def days(n, use, d):
    return n * V["cell_mah"] * V[use] / (d + self_mah(n))


def fmt(x, d=1):
    return ("%.*f" % (d, x)).replace(".", ",")


def main():
    print("== параметры")
    for k, (v, u, src) in P.items():
        print("PAR %s = %s %s | %s" % (k, fmt(v, 4).rstrip("0").rstrip(","), u, src))
    print("== радио")
    print("TOA sf9 23B = %s мс (раздел 89: 205,8)" % fmt(lora_airtime_us(9, 125000, 1, 23) / 1000))
    print("TOA sf7 63B = %s мс (раздел 48в: 118,0)" % fmt(lora_airtime_us(7, 125000, 1, 63) / 1000))
    print("TOA sf9 25B = %s мс — событие: 12 байт данных + 13 LoRaWAN" % fmt(lora_airtime_us(9, 125000, 1, 25) / 1000))
    model = 3228400
    frames = -(-model // 48)
    for sf in (7, 12):
        t = lora_airtime_us(sf, 125000, 1, 63) / 1e6 * frames
        print("MODEL sf%d frames=%d air=%s ч, при 1 %% эфира не быстрее %s сут" % (sf, frames, fmt(t / 3600), fmt(t * 100 / 86400)))
    react = V["t_boot_s"] + V["t_ae_s"] + V["t_npu_s"] + V["t_post_s"] + lora_airtime_us(9, 125000, 1, 25) / 1e6
    print("REACT от PIR до конца передачи %s с (загрузка + экспозиция + NPU + постобработка + эфир)" % fmt(react, 2))
    print("== сон")
    print("SLEEP standby = %s мкА от батареи (N6 %s мкА при 1,8 В -> %s мкА + PIR %s + прочее %s)" % (
        fmt(sleep_ua()), fmt(V["n6_stby_ua"]), fmt(bat_ma(V["n6_stby_ua"])), fmt(V["pir_ua"]), fmt(V["other_ua"])))
    print("SLEEP standby85 = %s мкА от батареи" % fmt(sleep_ua(hot=True)))
    print("SLEEP stop = %s мкА от батареи (N6 в Stop SVOS low: %s мА при 1,8 В)" % (fmt(sleep_ua(stop=True)), fmt(V["n6_stop_ma"], 3)))
    print("SELF 1x18650 %s мА·ч/сут, 2x18650 %s мА·ч/сут" % (fmt(self_mah(1), 1), fmt(self_mah(2), 1)))
    print("SLEEP day: standby %s мА·ч/сут, stop %s мА·ч/сут" % (fmt(sleep_ua() * 24 / 1000, 2), fmt(sleep_ua(stop=True) * 24 / 1000, 1)))
    print("== срабатывание")
    for link in ("lora", "lte", "wifi"):
        ph = event_mas(link)
        print("EVENT %s total=%s мА·с | %s" % (link, fmt(sum(ph.values())),
                                             "; ".join("%s %s" % (k, fmt(v)) for k, v in ph.items())))
    print("HEARTBEAT = %s мА·с" % fmt(heartbeat_mas()))
    print("== сутки и автономность")
    for link in ("lora", "lte", "wifi"):
        for ev in (10, 50, 200):
            d = day_mah(ev, link)
            cells = []
            for n in (1, 2):
                for tag, use in (("+20", "use_warm"), ("-20", "use_cold")):
                    cells.append("%dx18650 %s: %s сут" % (n, tag, fmt(days(n, use, d), 0)))
            print("DAY %s ev=%d %s мА·ч/сут | %s" % (link, ev, fmt(d, 1), "; ".join(cells)))
    d = day_mah(50, "lora", stop=True)
    print("DAY lora ev=50 STOP %s мА·ч/сут | 1x18650 +20: %s сут" % (fmt(d, 1), fmt(days(1, "use_warm", d), 0)))
    print("== солнце")
    for tag, h in (("dec", V["sun_h_dec"]), ("jun", V["sun_h_jun"])):
        gain = V["pv_w"] * h * V["pv_eta"] / V["v_bat"] * 1000
        print("PV %s приход %s мА·ч/сут; lora ev=50: баланс %s; lte ev=50: баланс %s" % (
            tag, fmt(gain, 0), fmt(gain - day_mah(50, "lora"), 0), fmt(gain - day_mah(50, "lte"), 0)))
    be = (V["pv_w"] * V["sun_h_dec"] * V["pv_eta"] / V["v_bat"] * 1000 - V["hb_per_day"] * heartbeat_mas() / 3600
          - sleep_ua() * 24 / 1000 - self_mah(1)) / (sum(event_mas("lora").values()) / 3600)
    print("PV dec breakeven lora 1x18650: %d срабатываний в сутки" % int(be))
    print("== рисунок")
    ph = event_mas("lora")
    day = {"сон": sleep_ua() * 24 / 1000, "пульс": V["hb_per_day"] * heartbeat_mas() / 3600}
    for k, v in ph.items():
        day[k] = 50 * v / 3600
    print("FIG " + "; ".join("%s=%s" % (k, fmt(v, 1)) for k, v in day.items()) + "; всего=%s" % fmt(sum(day.values()), 1))


if __name__ == "__main__":
    main()

Next: section 122 "Integer types and conversions: promotion, sign, overflow".

Open in the interactive handbook