Embedded HandbookПриложения
Все разделы › XIV. Машинное обучение на микроконтроллере
STM32N657 · уровень средний · 46 мин чтения
Открыть в интерактивном справочнике

121в. STM32N6 и камера с нейросетью на батарейке: YOLO на NPU Neural-ART, малое потребление, уличный прибор

Раздел 121а разобрал OpenMV на STM32H7: кадр, пятна, маленькая сеть через TensorFlow Lite Micro. YOLOv8n на 3,2 МБ туда уже не помещалась — в таблице плат осталась строка OPENMV_N6. Здесь — этот камень: STM32N6 с ускорителем нейросетей Neural-ART, и то, ради чего его ставят в уличный прибор: камера спит, по датчику движения просыпается, снимает, находит на кадре людей, животных или машины и отправляет короткое сообщение.

В разделе три части. Первая — сам STM32N6 и путь кадра до NPU по документации ST и исходникам OpenMV. Вторая — свой код постобработки YOLO для Cortex-M55: выход сети int8 превращается в рамки, лишние рамки снимает NMS. Код прогнан на ПК, на Cortex-M55 в QEMU с Helium и без и сверен с независимым расчётом на numpy — в том числе на настоящем выходе модели yolov8n_192 из OpenMV на снимке человека. Третья — энергобюджет на 18650 и техническое задание на прибор.

Чего здесь нет. Платы с STM32N6 под рукой у раздела не было: токи взяты из даташита, время фаз и токи NPU, датчика и радио — оценки, и они помечены. Модель на NPU не запускалась — её выход получен интерпретатором TensorFlow Lite на ПК.

Опоры: даташит STM32N6x5/x7 — DS14791 Rev 11, справочник RM0486 Rev 4, заголовок CMSIS stm32n657xx.h и описание SVD из пакета поддержки STM32N6. Исходники OpenMV — github.com/openmv/openmv, коммит 62369938a94b: файлы плат и примеры — MIT; модуль ml, обёртка NPU lib/stai/stai_backend.c, постобработка ml.postprocessing и драйвер камеры — под некоммерческой лицензией OpenMV, поэтому здесь только пересказ со ссылкой на файл и строку; библиотека ST lib/stai/libstai — SLA0044. Квантование int8 — раздел 119, OpenMV и пул памяти — 121а, DCMI и DMA — 22б, кэш — 11б, режимы сна — 10 и 10а, Li-ion — 173б, LoRa и время в эфире — 89, 93, 94, обновление прошивки — 48, 48а–48г.

Как это называется в документации

Оригинал Перевод
NPU (neural processing unit), Neural-ART accelerator нейроускоритель, ускоритель нейросетей ST
CA (convolutional accelerator) блок свёртки в NPU
epoch эпоха: кусок сети, который NPU считает без процессора, до прерывания
stream engine, stream switch потоковый DMA ускорителя и коммутатор потоков
relocatable model перемещаемая модель: образ, который ставится по любому адресу
DCMIPP (pixel pipeline), ISP (image signal processor) конвейер точек камеры, обработка сигнала датчика
CSI-2 (camera serial interface) последовательный интерфейс камеры MIPI
MVE (M-profile vector extension), Helium векторные команды Cortex-M55
anchor якорь: точка сетки (YOLOv8) или заготовка рамки (YOLOv2)
objectness объектность: «здесь есть объект»
NMS (non-maximum suppression) подавление пересекающихся рамок
IoU (intersection over union) пересечение, делённое на объединение
class-agnostic NMS NMS без учёта класса
PIR (passive infrared) sensor пироэлектрический датчик движения
duty cycle доля времени в эфире

121в.1 STM32N6 коротко

STM32N6 — Cortex-M55 на 800 МГц с векторными командами Helium (MVE) и FPU половинной, одинарной и двойной точности. В линейке N6x7 рядом стоит NPU Neural-ART: до 1 ГГц, по даташиту — 600 Gops и 288 умножений с накоплением за такт (DS14791, с. 1). Наше «Gops» нигде не мерилось — это цифра ST. Внутри NPU (RM0486, табл. 108): 4 блока свёртки, 2 блока пулинга, 2 активации, 4 арифметических, 10 потоковых DMA со встроенным шифрованием, 2 шины AXI по 64 бита. Те же числа — в заголовке библиотеки ST, которую собирает OpenMV: ATON_CONVACC_NUM 4, ATON_STRENG_NUM 10 (lib/stai/libstai/include/ATON.h).

Флеш-памяти нет. В расшифровке обозначения у N6 размер флеш-памяти «0 = 0–1 Kbytes» (DS14791, с. 262). После сброса работает ПЗУ: по выводам BOOT0, BOOT1 и слову OTP оно грузит прошивку с внешней памяти — XSPI NOR, HyperFlash, e.MMC или SD-карты (RM0486, 9.1.1) — или по USB и UART. Как это выглядит у OpenMV N6 (boards/OPENMV_N6/board_config.mk, строки 8–10): начало флеш-памяти — 0x70000000, прошивка — с 0x70080000, загрузчик OpenMV — в ОЗУ с 0x34180400. Карта внешней флеш-памяти на 32 МБ на XSPI2 при 200 МГц — в boot_config.h (строки 47–49 и 76–79): загрузчик 512 КБ, прошивка, файловая система, ROMFS с моделями с 0x800000. В пакете CMSIS для N6 «Flash» тоже начинается с 0x34180400 — это ОЗУ, куда ПЗУ кладёт первый загрузчик.

Память. ОЗУ на AXI — две части по 1 МБ и четыре по 448 КБ (SRAM1_AXI_SIZE … SRAM6_AXI_SIZE в stm32n657xx.h), плюс TCM 64 + 128 КБ и 8 КБ резервного ОЗУ. Четыре блока по 448 КБ с 0x34200000 — ОЗУ, которое отдают NPU: у OpenMV это пул на 1792 КБ (board_config.h, строки 172–174 и 187–188), у компилятора ST — пулы npuRAM3…npuRAM6 (lib/stai/scripts/stm32n6.mpool). Кэш NPU для внешней памяти (CACHEAXI) — 256 КБ (CACHEAXI_RAM_SIZE 0x40000UL). Веса модели живут во внешней флеш-памяти и читаются на месте, кадры и большие буферы у OpenMV — во внешней PSRAM на 64 МБ с 0x90000000 (строки 191–192, пул UMA на 31 МБ — строка 164).

Камера. У N6 две дороги для кадра (RM0486, 37.1): старый DCMI до 24 Мточек/с и DCMIPP — до 5 Мп при 30 кадрах/с, параллельно до 120 МГц или по CSI-2 до 2,5 Гбит/с на линию по двум линиям, с ISP (дебайер, экспозиция, баланс белого, контраст, битые точки) и двумя прикладными конвейерами с обрезкой и уменьшением. Выход ISP может идти в NPU по DMA (DS14791, с. 14). OpenMV N6 работает через DCMIPP: конвейер 1, такт датчика 24 МГц от TIM1, для датчиков MIPI — путь CSI-2 (board_config.h, строки 134 и 307; ports/stm32/omv_csi.c).

Видео и JPEG. Аппаратный JPEG и кодер H.264 до 1080p при 15 кадрах/с (DS14791, с. 1). В SVD это блоки JPEG по 0x48023000 и VENC («Video encoder») по 0x48005000; у OpenMV кодер подключён через drivers/vc8000 (H264EncInit). Для прибора на батарейке видео не нужно — нужна вырезка в JPEG.

STM32H743 (121а) STM32U585 (198х) STM32N657
ядро Cortex-M7, 480 МГц Cortex-M33, 160 МГц Cortex-M55, 800 МГц, Helium
нейросеть на ядре на ядре NPU, 600 Gops по ST
флеш-память 2 МБ внутри 2 МБ внутри нет, внешняя по XSPI
камера DCMI DCMI DCMI и DCMIPP с ISP, CSI-2
сон с часами раздел 10 Stop 2, Standby (10а) Standby 90,4 мкА при 1,8 В

Цикл уличной камеры на STM32N6: датчик движения PIR или будильник RTC будит кристалл из Standby через вывод WKUP; ПЗУ загружает прошивку с внешней флеш-памяти по XSPI; датчик изображения отдаёт кадр через CSI-2 или параллельный порт в DCMIPP с ISP; кадр 192 на 192 точки ложится в AXI-SRAM; NPU Neural-ART считает yolov8n_192 и выдаёт тензор int8 из 5 строк по 756 якорей; Cortex-M55 с Helium отбирает якоря по порогу прямо в кодах int8, строит рамки и делает NMS; 12 байт события уходят по LoRa на SF9 за 205,8 мс; кристалл снова засыпает в Standby на 90,4 мкА при 1,8 В. Ниже — заряд за сутки при 50 срабатываниях: сон 1,9 мА·ч, датчик и экспозиция 1,3, радио 0,5, загрузка, NPU и пульс по 0,3, всего 4,7 мА·ч; в Stop сон съел бы 60,5 мА·ч в сутки


121в.2 Кадр, NPU и выход YOLO

Как модель попадает в NPU. Модель .tflite прогоняет компилятор ST Edge AI: настройки OpenMV — lib/stai/scripts/neuralart.json (профиль с --enable-epoch-controller, пулы из stm32n6.mpool, на кристалле — до 1024 КБ). Получается перемещаемый образ: код эпох и веса. Сеть считается эпохами — кусками, которые NPU проходит сам, от настройки процессором до прерывания (RM0486, 20.1). Что делает OpenMV (lib/stai/stai_backend.c, пересказ):

Выход YOLOv8. Модель yolov8n_192.tflite из lib/models OpenMV — 3 228 400 байт, вход 192×192×3 uint8. Внутри — int8, на выходе интерпретатор переводит последний тензор в float. Если отрезать этот перевод, NPU отдаёт тензор int8 формы [1, 5, 756] с масштабом 0,00507508637 и нулевой точкой −128. 756 — якоря трёх сеток 24×24 + 12×12 + 6×6 (шаги 8, 16, 32 точки). Строки 0–3 — центр и размер рамки в долях входа, строка 4 — оценка класса person уже после сигмоиды. Масштаб общий на все пять строк: порог 0,4 — это код −49, оценка 0,9693 — код 63.

Вторая модель OpenMV, yolo_lc_192.tflite (339 448 байт), — вариант YOLOv2: выход [1, 12, 12, 30] int8 с масштабом 0,176762134 и нулевой точкой 73: сетка 12×12, 5 якорей по 6 чисел (tx, ty, tw, th, объектность, класс). Здесь всё — логиты: нужны сигмоида и экспонента, и нужны размеры якорей, с которыми модель учили.

Где считать постобработку. У OpenMV — на Python (ulab) в ml.postprocessing.ultralytics.YoloV8: порог переводится в коды и сравнивается с сырыми байтами (ultralytics.py, строки 108 и 116), в float переводятся только прошедшие якоря. NMS у OpenMV — «мягкое»: оценки соседей умножаются на exp(−IoU²/σ), классы не различаются (ml-core/ml/utils.py, строки 91 и 119). В приборе без MicroPython эту работу делает своя функция на C — она ниже.


121в.3 Постобработка на Cortex-M55

Задача: из 756 якорей (или 720 у YOLOv2) получить несколько рамок. Правила своего кода:

Порог в кодах:

/* Наименьший код 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;
}

Список кандидатов:

/* Кандидаты — по убыванию 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)++;
}

Декодер YOLOv8. На M55 цикл идёт по 16 якорей: vld1q_s8 грузит 16 оценок класса, vmaxq_s8 берёт максимум по классам, vcmpgeq_n_s8 сравнивает с порогом и даёт маску — по биту на якорь; разбираются только поднятые биты. Хвост (756 = 47 · 16 + 4) и сборка без MVE идут обычным циклом в том же порядке, поэтому результат одинаков:

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;
}

Внутренний цикл по классам после arm-none-eabi-gcc 12.2.1 (-mcpu=cortex-m55 -O2), дизассемблер с -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 — аппаратный цикл M55, vcmp.s8 пишет маску в p0, её забирает vmrs.

NMS и IoU в целых:

/* Пересечение больше доли 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;
}

Экспонента для YOLOv2 (сигмоида — 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;
}

Декодер YOLOv2 (yp_decode_v2) устроен так же: порог по объектности в кодах, класс — argmax сырых логитов (softmax его не меняет), центр — (клетка + сигмоида) / сетка, размер — якорь · exp(t) / сетка. Он целиком — в конце раздела.


121в.4 Проверка: синтетика, настоящая модель, M55 в QEMU

Наборы — gen_tensors.py: синтетические выходы со своим генератором и настоящий выход yolov8n_192. Настоящий получен так: снимок scripts/unittest/data/person.bmp из OpenMV (400×400, RGB565) уменьшен до 192×192 и прогнан интерпретатором TensorFlow Lite 2.21.0 со справочными ядрами; взят тензор int8 перед переводом в float. Лучший якорь — оценка 0,9693, рамка во весь кадр; выше порога 0,4 — 10 якорей.

Один и тот же main_post.c собран gcc 12.2.0 на ПК и arm-none-eabi-gcc 12.2.1 для Cortex-M55 — с Helium и с -DYP_NO_MVE. Обе сборки M55 запущены в QEMU 7.2 на машине mps3-an547 (Cortex-M55), вывод — полухостингом. Вывод на ПК:

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

Сверка:

Что С чем Итог
вывод C на ПК yolo_post.py — та же логика, каждая операция округлена до float32 совпал построчно
вывод C на ПК Cortex-M55 в QEMU, с Helium и без совпал построчно, обе сборки
вывод C на ПК numpy 2.3.3: тензор целиком в float, np.exp, IoU матрицей 7 наборов из 7, рамки — расхождение 0 точек
yp_exp math.exp на −20…20 с шагом 0,01 относительная ошибка до 1,15·10⁻⁶

Размер кода yolo_post.c для Cortex-M55 (-O2): 2488 байт с Helium и 2328 без; данных и .bss нет. Из них yp_decode_v8 — 404 и 414 байт, yp_decode_v2 — 684, yp_nms — 374, yp_exp — 332. Команд с векторными регистрами q в сборке без Helium — 0, с Helium — 4: две vldrb.u8, vmax.s8 и vcmp.s8 из листинга выше. Сколько тактов это занимает на настоящем M55 — не мерилось: QEMU такты не считает.


121в.5 Энергобюджет на 18650

Цикл: Standby → пробуждение по PIR или будильнику RTC → загрузка с внешней флеш-памяти → датчик и экспозиция → NPU → постобработка → LoRa → Standby. Плюс раз в час «пульс» по таймеру без камеры. Модель — energy121v.py; ток шины 1,8 В переводится в ток батареи через КПД преобразователя: I_бат = I · 1,8 / (3,6 · 0,85).

Что из документации (DS14791, 25 °C, типовые, SMPS внутри):

Режим Ток при 1,8 В Где
работа 800 МГц, все периферийные блоки тактируются 99,93 мА (IDDSMPS) табл. 40, с. 155
Sleep, 800 МГц, всё тактируется 80,9 мА табл. 42, с. 157
Stop, SVOS low 3,60 мА + 273,4 мкА + 362,7 мкА = 4,24 мА табл. 44, с. 158
Standby, резервное ОЗУ и RTC с LSE 48,4 + 42 = 90,4 мкА табл. 45, с. 159
то же при 85 °C 69,0 + 80 = 149 мкА табл. 45, с. 159
из Standby до первой команды 400 мкс табл. 47, с. 160

Ядро на 800 МГц само берёт 85 мА от VDDCORE при выключенной периферии и 172 мА при включённой (табл. 34, с. 149). Тока NPU во время вычисления в даташите нет.

Что — оценка (мерить на своей плате): ток NPU 200 мА при 1,8 В, загрузка 0,3 с, экспозиция 1,0 с (столько ждёт датчик пример OpenMV time_lapse_photos.py, строка 55: snapshot(time=1000)), NPU 0,1 с, постобработка 0,05 с, датчик изображения 150 мВт, внешняя память 20 мА, LoRa — 45 мА на передаче и 12 мА на приёме 0,2 с, LTE-M — 120 мА 6 с, Wi-Fi — 180 мА 2,5 с, PIR 15 мкА, прочее во сне 12 мкА, 18650 — 3000 мА·ч, доступно 90 % при +20 °C и 60 % при −20 °C, саморазряд 2 % в месяц. Все допущения с источником печатает сам скрипт.

Сообщение LoRa — 12 байт события и 13 служебных LoRaWAN: на SF9 при 125 кГц это 205,8 мс в эфире. Формула — из раздела 89, и скрипт её проверяет: 23 байта на SF9 — 205,8 мс, как в таблице раздела 89; 63 байта на SF7 — 118,0 мс, как в 48в.

Результаты прогона:

Значение
сон всей платы в Standby 80,2 мкА от батареи, 1,92 мА·ч в сутки
то же, если спать в Stop 2518,8 мкА, 60,5 мА·ч в сутки
одно срабатывание с LoRa 175,0 мА·с: датчик и экспозиция 96,6, радио 35,5, загрузка 21,2, NPU 18,8
одно срабатывание с LTE-M / Wi-Fi 1145,1 / 708,5 мА·с
от PIR до конца передачи 1,66 с

Сколько суток проживёт прибор:

Связь, срабатываний в сутки мА·ч в сутки 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

Выводы модели. Спать можно только в Standby: Stop у N6 стоит миллиамперы: на LoRa при 50 срабатываниях один 18650 при +20 °C продержится 41 сут. вместо 403. При 50 срабатываниях сон и экспозиция — две трети суточного заряда; NPU — малая доля, если сеть считает 0,1 с. Радио решает выбор связи: одно срабатывание по LTE-M в 6,5 раза дороже, чем по LoRa. На малой нагрузке срок упирается в саморазряд — 2,0 мА·ч в сутки на элемент.

Солнце. Панель 2 Вт, в декабре 0,5 ч полного солнца в сутки, в июне 5 ч, КПД 0,7 (оценки): приход 194 и 1944 мА·ч в сутки. Декабрьского прихода хватило бы на 3911 срабатываний LoRa в сутки — если его можно взять. На морозе нельзя: Li-ion заряжают только в разрешённом диапазоне температур (173б), и зимой днём энергия панели пропадает, пока элемент холодный. Поэтому зимний запас считают без солнца — столбец «−20 °C» таблицы.


121в.6 Техническое задание на прибор

Сокращённо, по структуре ГОСТ 15.016 (ТЗ на изделие). Числа требований — предложения автора; ссылки на модель 121в.5 — чтобы было видно, откуда запас.

1. Наименование и назначение. «Уличная AI-камера на STM32N6 с питанием от батареи». Обнаруживает людей, животных и машины в поле зрения, передаёт событие (класс, число объектов, рамку, время, заряд) и по запросу — вырезку в JPEG. Видео не ведёт, кадры не хранит.

2. Основание и стадии. По договору; стадии — в п. 11.

3. Состав.

Узел Требование
вычислитель STM32N657 (линия N6x7 с NPU); внешняя флеш-память XSPI NOR от 32 МБ под прошивку, модели и журнал
датчик изображения CSI-2 или параллельный, глобальный или скользящий затвор, ИК-подсветка по требованию заказчика
датчик движения PIR на вывод WKUP; второй источник пробуждения — будильник RTC
связь LoRaWAN (основная) и LTE-M или Wi-Fi (по исполнению)
питание 1 или 2 элемента 18650 с защитой; контроллер заряда от панели с запретом заряда по температуре (173б)
корпус уличный, со стеклом перед объективом и окном PIR

4. Функциональные требования.

№ Требование Проверка
Ф1 классы: человек, животное, машина; модель int8 на NPU на испытаниях
Ф2 доля обнаруженных людей в зоне до 10 м днём — не ниже 0,9; ложных событий — не больше 5 в сутки на объекте полевые испытания
Ф3 от срабатывания PIR до конца передачи — не больше 3 с (по модели — 1,66 с) замер
Ф4 порог оценки и порог NMS задаются удалённо; NMS по классам проверка команд
Ф5 пульс раз в час: заряд, температура, счётчики журнал сервера
Ф6 выход сети проверяется на всех порогах: не больше 64 кандидатов, без выделения памяти модульные тесты (121в.4)

5. Питание и автономность.

№ Требование Откуда запас
П1 сон — только Standby; ток платы во сне не больше 100 мкА от батареи при +20 °C по модели 80,2 мкА
П2 не меньше 365 сут. на 2×18650 при 50 срабатываниях LoRa в сутки и −20 °C без солнца по модели 414 сут.
П3 исполнение с LTE-M: не меньше 90 сут. на 2×18650 при 50 срабатываниях и −20 °C по модели 162 сут.
П4 с панелью: работа без замены элементов при 50 срабатываниях в сутки круглый год приход декабря 194 мА·ч при расходе 4,7
П5 измерение заряда, сообщение о разряде за 14 суток до отключения испытания

6. Условия эксплуатации. Температура воздуха от −40 до +50 °C при работе от элементов, допускающих этот диапазон; кристалл — промышленный диапазон −40…125 °C (DS14791, с. 262). Защита корпуса — IP66 по ГОСТ 14254. Без конденсата на стекле: клапан выравнивания давления.

7. Связь. LoRaWAN EU868 класса A, сообщение события — 12 байт; доля времени в эфире — не больше 1 % (89, 93). LTE-M — с режимом PSM. Сообщения подписаны ключами сети LoRaWAN (94) или TLS.

8. Обновление прошивки и моделей. Свой загрузчик или MCUboot с двумя слотами и откатом (48, 48б), подпись образа (48а), шифрование (48г). По LoRa — только разностные обновления прошивки (48в). Модель по LoRa не обновляется: 3 228 400 байт — 67259 кадров по 48 байт, на SF7 при 1 % эфира не быстрее 9,2 сут., на SF12 — 217,5 сут. Модели — по LTE-M, Wi-Fi или на месте.

9. Хранение данных и приватность. Кадр живёт только в ОЗУ до конца цикла. Наружу — метаданные; вырезка — только по запросу оператора. Изображения людей могут быть персональными данными (152-ФЗ): режим «только метаданные» — по умолчанию. Ключи — в OTP и защищённой области, отладка закрыта (DS14791: жизненный цикл с RDP и паролем отладки).

10. Испытания. Модульные: постобработка на ПК, в QEMU M55 и на плате — совпадение вывода (121в.4). Энергетические: ток сна, заряд срабатывания по фазам, сравнение с моделью 121в.5. Климатические: −40, −20, +50 °C, циклы. Защита корпуса IP66. Полевые: месяц на объекте с разметкой событий, доли обнаружений и ложных срабатываний.

11. Этапы.

Этап Результат
1. Макет отладочная плата N6, датчик, PIR, LoRa; замер токов всех фаз, уточнённая модель 121в.5
2. Модель обучение и квантование своих классов, компиляция под NPU, время эпох
3. Опытный образец плата, корпус, прошивка с обновлением и журналом
4. Испытания пп. 10; протоколы
5. Документация руководство, методика испытаний, паспорт

121в.7 Грабли


121в.8 Итоги

Полные исходники

Каталог _verify/n6cam121v/. Собрать на ПК: gcc -std=c11 -O2 -ffp-contract=off main_post.c yolo_post.c, для M55 — как в run.sh. tensors.h создаёт python gen_tensors.py tensors.h.

yolo_post.h — интерфейс постобработки.

/* Постобработка детектора 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 и YOLOv2, порог, кандидаты, NMS, экспонента.

/* Постобработка 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 — прогон наборов на ПК и на 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 — запуск на Cortex-M55 в QEMU: векторы, FPU и Helium, выход.

/* Запуск на 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 — карта памяти mps3-an547.

/* 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 — та же логика на Python с округлением до 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 — модель энергобюджета.

# -*- 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()

Дальше: раздел 122 «Целые типы и преобразования: продвижение, знак, переполнение».

Открыть в интерактивном справочнике