Skip to main content
Qualcomm Linux · Edge AI · Deep Dive

The full manual pipeline: every command, every script, every design decision explained, from first photo to a C++ daemon running live on the Qualcomm NPU at 2 ms.


This is the no-shortcuts version. Every command is here. Every script is on the companion files page, copy them from there into the paths shown. Steps 1-6 run on a Mac and the board. Steps 7-9, the NPU part, need the free QAIRT (Qualcomm AI Runtime SDK) and an x86 Linux machine. The four moves, so you always know which machine you’re on:

What you need

Hardware: A computer for training — the guide uses a Mac with Apple Silicon (PyTorch MPS (Metal Performance Shaders) backend), but Linux or Windows with a CUDA GPU (Graphics Processing Unit) works the same way; just replace --device mps with --device cuda in the training commands. IQ-8275 EVK (QCS8300, Hexagon V75 NPU), aarch64 Qualcomm Linux, Python 3.x and onnxruntime preinstalled on the Qualcomm Linux image. A USB webcam for the live demo. An x86-64 Linux machine for the QAIRT SDK (NPU compiler is x86-only). Software: Python 3 and git on the Mac. The free QAIRT SDK v2.47.0.260601 on the x86 Linux box. To build the live C++ daemon: an aarch64 cross-compiler (aarch64-linux-gnu-g++-13). Scripts: All scripts referenced below are on the companion files page. Copy each one to the path shown in its header.

Step 1: Build the dataset

The dataset is the foundation of everything. You need photos of the paddle with a bounding box drawn on each one, plus background frames with no paddle. The tool for this is Edge Impulse Studio. Its Data acquisition tab lets you record images from a connected device, draw bounding boxes in the browser, and export in its own format. That’s how the ~670 images in this project were labeled, no external tool required. If you followed the Edge Impulse quickstart first, you already have this dataset — skip to the export step below. Export as Object Detection: in the Studio go to Dashboard → Export → Object Detection format. Choose the format that gives you, per split, a folder of images plus a bounding_boxes.labels JSON file. Aim for variety: different distances, lighting, angles, and rooms. Include ~15–20% background frames. You’ll end up with:
The boxes are in absolute pixels, with x,y at the top-left corner. That’s the convention training/labels.py expects.

Step 2: Mac environment

Create a working folder and copy the scripts into it:
Sanity-check the dataset. You should see 539 training and 124 testing images:
We use two separate venvs on purpose: Phase A (lean PyTorch) and Phase B (Ultralytics). Keeping them apart means Phase A stays reproducible after you install the heavier YOLO stack.

Step 3: Phase A, a CNN from scratch (the instructive baseline)

This builds a small network from nothing and trains it only on your paddle photos. It will underperform. That’s the point. It’s the control group that explains why Phase B works.

3.1 Preprocess

Decodes every JPEG once, resizes to 320×320, and caches the result as .npy arrays. The cache is memory-mapped at training time. Decoding 670 images 40 times per training run costs minutes; reading from a memory-mapped array is nearly free.

3.2 Train

Uses MPS (Metal GPU backend) when available, otherwise CPU. Watch the val_IoU column. That’s the honest measure (Intersection over Union: how much the predicted box overlaps the true one, 0 = miss, 1 = perfect). It tops out around 0.56. The best checkpoint goes to training/checkpoints/best.pt.

3.3 Export to ONNX

3.4 Watch it fail

Lean in close, dim the room. The CNN won’t find the paddle. Crop your face out of the frame and it suddenly works. Brighten the image and the score rises. The model memorized the training album: well-lit photos of people standing back. That’s the motivation for Phase B.

Step 4: Phase B, fine-tune YOLOv8n (the one that works)

4.1 Environment

4.2 Convert labels to YOLO format

4.3 Fine-tune

Downloads the COCO-pretrained YOLOv8n weights on the first run. Fine-tuning adds “paddle” as one more word to a model that already knows what “object in a hand” looks like. Result: yolo/runs/paddle/weights/best.pt with mAP@0.5 ≈ 0.979.

4.4 Export to ONNX

Exports with nms=False (NMS, Non-Maximum Suppression, lives in numpy not the graph) and dynamic=False (fixed 1x3x320x320, the NPU requires fixed shapes). These two flags are what make Steps 7-9 work cleanly.

Step 5: Live demo in a browser (Mac)

The server opens the webcam, runs each frame through the detector, draws the box, and streams MJPEG to the browser. The same --model npu flag will plug in the NPU engine later without changing the server code.

Step 6: Run on the board’s CPU

The board already has onnxruntime installed. This gets you roughly 24 fps on the CPU, your baseline before the NPU.
If the USB camera disappears from lsusb and /dev/video26 is gone, a hot-replug won’t bring it back. Reboot the board.

Step 7: Convert the model for the NPU

This step runs on the x86-64 Linux machine. The NPU compiler is x86-only. Copy these scripts from the companion files page to an npu/ folder on your x86 box:

7.0: Install the QAIRT SDK (once)

Pick a working directory with ~4 GB free. Export it as QW. The env.sh file derives all other paths from it automatically when this variable is set:
Create the Python venv (virtualenv, since the built-in python3 -m venv is broken on many Ubuntu setups without sudo):
Install the exact dependency versions that work (these pins are real failures, not guesses):
Stage the LLVM runtime libs the SDK’s native tools need (a clean Ubuntu doesn’t ship them):
If $QW is exported in your shell, you don’t edit anything. The env.sh file derives all paths from it automatically:
The full env.sh is on the companion files page.

7.1: Copy model and calibration data to the x86 box

Generate calibration data on the Mac first:
Then copy to the x86 box:

7.2: ONNX to floating-point DLC

7.3: Quantize to A16W8

This is the subtle part. Quantizing everything to INT8 (8-bit Integer) collapses the confidence scores to zero. Bounding-box coordinates are large numbers (like 400 pixels), confidence scores are tiny (0.87), and the same 8-bit scale can’t represent both. The fix is A16W8: keep weights at 8-bit (compact) but let activations use 16-bit precision to protect the score. See Part 6 of the story for the full explanation.
The generated best_a16w8_htpv75.bin is the context binary, compiled ahead of time for the HTP (Hexagon Tensor Processor) V75. Copy it to the board:

Step 8: Run on the NPU

8.1: One-shot test

The board already has the QNN runtime in /usr/lib. You don’t need to copy any .so files from the SDK. Copy only the context binary (done above) and run_npu16.sh from the companion files page. run_npu16.sh expects a test input at /home/weston/npu/emeet2_input.raw — a raw float32 NCHW tensor (shape 1×3×320×320). Generate it from any JPEG on the Mac using the same letterbox preprocessing that the model was trained with:
Then run the one-shot test:
The ~150-200 ms you see per call here includes process-startup overhead. That disappears with the resident daemon.

8.2: Live stream via the resident C++ daemon

For real-time use you want the model resident: loaded once, processing frames forever. The daemon holds the QNN context in memory, reads frames from a FIFO pipe, runs inference, and streams MJPEG over HTTP. Building it requires an aarch64 cross-compiler on the x86 box. Set R in npu/env.sh to the cross-compiler root (the dir that contains usr/bin/aarch64-linux-gnu-g++-13). The daemon is a small overlay on top of the QNN SDK SampleApp. Create the three overlay files from the SDK, then apply the diffs from the companion files page — Daemon C++ source section:
Apply the diffs from the companion page to those three files, then build and copy the daemon:
On the board:
server.py --model npu launches the daemon as a subprocess automatically and communicates with it via two FIFOs: /tmp/npu_cmd.fifo (commands) and /tmp/npu_resp.fifo (responses). No separate daemon startup step needed. YOLOv8n running live via the resident NPU daemon on the IQ-8275 EVK

Step 9: Benchmark CPU vs NPU honestly

What to expect: The raw NPU math is 84x faster. The end-to-end win is 1.7x. Before the in-memory path was implemented, the NPU version was slower than CPU end-to-end. 307,200 float-to-int16 conversions happening one number at a time cost 30 ms. Fixing that (convert the whole frame with a vectorized call) is what produced the real-world win. A benchmark that only measures the chip math is not a real benchmark.

Source files

All the scripts referenced above are on the companion files page with copy buttons.
The daemon C++ source files (npu/daemon/main.cpp, QnnSampleApp.cpp, QnnSampleApp.hpp) are not embedded on the companion page because they partially derive from the Qualcomm QNN SDK SampleApp. The exact modifications are documented as diffs in the companion files page — Daemon C++ source section. Apply them to a clean SDK SampleApp checkout using build_daemon.sh.
For the Edge Impulse path (no QAIRT SDK, 2 ms in an afternoon), see Paddle detection with Edge Impulse.