board_test
birdnet_stm32.deploy.board_test
¶
Board test: standalone firmware on STM32N6570-DK.
Full on-device pipeline — nothing is precomputed on the host: 1. Deploy model: stedgeai generate → patch NPU_Validation project → n6_loader build + flash. 2. Firmware on board: read WAV from SD card → frontend-specific preprocessing (raw normalization, STFT, or STFT + mel) → NPU inference → UART results. 3. This script captures UART output and parses per-file predictions. 4. Optionally, the same .tflite runs on the host over local copies of the SD card's WAV files, through the host's own evaluation preprocessing, and each file's board result is checked against it. That comparison is the point of the test: a model that ships has to compute on the device what it computes on the host.
Requires: - USB-connected STM32N6570-DK with an SD card containing audio/ WAV files. - pyserial (pip install pyserial).
BoardTestConfig
dataclass
¶
Configuration for standalone on-board inference tests.
Attributes:
| Name | Type | Description |
|---|---|---|
deploy_cfg |
DeployConfig
|
Base deployment configuration (paths to tools, model, etc.). |
model_config_path |
str
|
Path to the _model_config.json file. |
labels_path |
str
|
Path to the _labels.txt file. |
serial_port |
str
|
Serial port for UART capture (e.g. /dev/ttyACM0). |
top_k |
int
|
Number of top predictions to show per file. |
score_threshold |
float
|
Minimum score to display. |
timeout |
int
|
Maximum seconds to wait for firmware to finish. |
host_audio_dir |
str
|
Local copy of the SD card's audio/ folder. When set, the host scores the same files and the board is checked against it. |
parity_tolerance |
float
|
Margin within which a different top-1 is a tie, and within which a host score counts as borderline to the detection threshold. Larger score differences are flagged, not failed. |
detection_threshold |
float
|
Score at which the device reports a detection. |
Source code in birdnet_stm32/deploy/board_test.py
load_model_config(config_path)
¶
Load model configuration from JSON.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
config_path
|
str
|
Path to _model_config.json. |
required |
Returns:
| Type | Description |
|---|---|
dict
|
Dict with model configuration. |
Source code in birdnet_stm32/deploy/board_test.py
load_labels(labels_path)
¶
Load class labels from a labels.txt file.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
labels_path
|
str
|
Path to _labels.txt (one label per line). |
required |
Returns:
| Type | Description |
|---|---|
list[str]
|
List of label strings. |
Source code in birdnet_stm32/deploy/board_test.py
generated_network_name(network_c)
¶
The C name stedgeai gave the network: Default (X-CUBE-AI 10.2) or network (ST Edge AI Core 4.0).
Source code in birdnet_stm32/deploy/board_test.py
patch_project(project, model_cfg, num_classes, labels, nn_name='Default')
¶
Copy firmware sources into the NPU_Validation project and patch build files.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
project
|
Path
|
Path to the NPU_Validation project root. |
required |
model_cfg
|
dict
|
Model configuration dict from _model_config.json. |
required |
num_classes
|
int
|
Number of output classes. |
required |
labels
|
list[str]
|
Ordered list of class label strings. If empty, generates
placeholder |
required |
Returns a list of .bak paths that must be passed to restore_project.
Source code in birdnet_stm32/deploy/board_test.py
restore_project(backups)
¶
Undo all project patches by restoring .bak files and removing new files.
For each entry in backups:
- If it is a directory (FatFs), remove it entirely.
- If it is a .bak path and the .bak exists, move it back over the original.
- If it is a .bak path but no .bak was created (file was new), delete
the original that we copied in.
Source code in birdnet_stm32/deploy/board_test.py
parse_serial_output(lines, labels, top_k=5, threshold=0.01)
¶
Parse firmware UART output into structured results.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
lines
|
list[str]
|
Raw serial lines captured from the board. |
required |
labels
|
list[str]
|
Class label list (for reference; firmware already prints names). |
required |
top_k
|
int
|
Max detections per file to keep. |
5
|
threshold
|
float
|
Minimum score fraction (0–1) to include. |
0.01
|
Returns:
| Type | Description |
|---|---|
dict
|
Dict with 'results' (list of per-file dicts), 'processed', 'errors', |
dict
|
'raw_lines'. |
Source code in birdnet_stm32/deploy/board_test.py
516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 | |
firmware_top_k(scores, k, threshold)
¶
Return the class indices the firmware would print, in its order.
A replica of print_top_k in firmware/Src/main.c: a partial selection
sort that swaps only on a strictly greater score, stopping at the first
score below threshold. INT8 outputs tie often -- several classes can
saturate at the same code -- so the tie order has to be the firmware's,
not numpy's.
Source code in birdnet_stm32/deploy/board_test.py
host_reference_scores(model_path, model_cfg, audio_dir, board_files)
¶
Score the board's files on the host with the same .tflite.
Each file goes through the host's evaluation preprocessing
(make_chunks_for_file), and its first chunk is scored -- the chunk the
firmware reads. Using the host pipeline rather than a re-implementation of
the firmware is deliberate: a firmware frontend that diverges from what the
model was evaluated with has to show up as a parity failure.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model_path
|
str
|
The .tflite deployed to the board. |
required |
model_cfg
|
dict
|
Its model config. |
required |
audio_dir
|
str | Path
|
Local copy of the SD card's audio/ folder. |
required |
board_files
|
list[str]
|
File names as the board printed them (FAT may upper-case). |
required |
Returns:
| Type | Description |
|---|---|
dict[str, ndarray]
|
Board file name -> [num_classes] host scores. |
Source code in birdnet_stm32/deploy/board_test.py
load_truth(audio_dir)
¶
Read file -> true_species from a manifest.csv beside or above audio_dir.
Source code in birdnet_stm32/deploy/board_test.py
output_grid_parity_allowance(model_path, model_cfg, base=0.05)
¶
Widen the parity allowance by what a logit model's output grid costs.
base (0.05) covers the board-versus-host frontend disagreement, measured
at 0.031-0.036 on the 1.4 releases, whose INT8 output was a probability on a
1/256 grid -- 0.004 steps, negligible beside it.
A logit model's output grid is uniform in logits, not in probability: one
step of scale spans scale * p * (1 - p) in probability, at most
scale / 4 at p = 0.5. At the release scale of ~0.0996 that is 0.025, six
times the old step, so the same physical disagreement reads larger after the
sigmoid. The allowance grows by exactly that quarter-step rather than by a
number chosen to fit the measurement.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model_path
|
str
|
The .tflite deployed to the board. |
required |
model_cfg
|
dict
|
Its model config, which records |
required |
base
|
float
|
Allowance for everything that is not the output grid. |
0.05
|
Returns:
| Type | Description |
|---|---|
float
|
|
Source code in birdnet_stm32/deploy/board_test.py
int8_output_step(model_path)
¶
Quantization step of the last INT8 tensor before the float output.
The released models keep float32 I/O, so the output tensor itself carries no
quantization; the grid the scores actually sit on is the INT8 tensor that
DEQUANTIZE reads. Returns None when there is no such tensor.
Source code in birdnet_stm32/deploy/board_test.py
compare_board_host(board_results, host_scores, labels, *, top_k, threshold, tolerance, detection_threshold=0.5, truth=None)
¶
Check that the board reports the same detections as the host.
A file agrees when both hold:
- the board's top-1 is the host's top-1, or a label the host scores within
toleranceof its own top-1 (a tie, not a disagreement); - board and host make the same call at
detection_threshold-- unless the host's score is withintoleranceof the threshold, where INT8 rounding on the NPU can legitimately tip it (flaggedborderline).
Score differences above tolerance that change neither are flagged
drift but do not fail the file. A broken model fails on top-1.
Returns:
| Type | Description |
|---|---|
dict
|
Dict with per-file |
Source code in birdnet_stm32/deploy/board_test.py
749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 | |
print_parity(parity)
¶
Print the per-file host x board comparison and its verdict.
Source code in birdnet_stm32/deploy/board_test.py
run_board_test(cfg)
¶
Execute the full on-board inference test.
Steps: 1. stedgeai generate → produce NPU binary from the TFLite model. 2. Patch NPU_Validation project with our firmware sources. 3. n6_loader: copy network.c, build firmware, flash, run. 4. Capture UART output until '=== DONE ===' marker. 5. Restore the NPU_Validation project to its original state. 6. Parse and report results.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
BoardTestConfig
|
Board test configuration. |
required |
Returns:
| Type | Description |
|---|---|
dict
|
Dict with 'results', 'processed', 'errors', 'raw_lines', 'labels'. |
Source code in birdnet_stm32/deploy/board_test.py
867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 | |