I built a four-keypoint YOLOv8n-Pose model to read analog meters on an NE301 AI camera. The model locates the pointer pivot, both ends of the scale, and the pointer tip; a small postprocessor then converts their angles into a 0-100 reading.
The floating-point pipeline produced spatially varying outputs. The first INT8 deployment did not. Export succeeded, quantization completed, and the STM32N6 Neural Processing Unit (NPU) package compiled, but related coordinates collapsed to the same values, confidence scores repeated, and one gauge became two detections.
Normalizing decoded coordinates only during export restored useful variation. Across three images, the normalized TFLite model reached a maximum pairwise keypoint difference of 0.3127, compared with approximately 0.000 before the fix. The device then returned one pose and completed the meter calculation with a reported value of 46.99.
That value is not an accuracy claim: the recorded frame has no independently labeled ground truth. It shows that the output stopped collapsing and that the complete pose-to-reading path ran on the device.
What You Will BuildBy following this project, you will assemble the reproducible parts of the same pipeline:
- Interpret an analog meter using four named pose keypoints.
- Recognize the output pattern of an INT8 collapse.
- Patch the YOLOv8-Pose export path so coordinates and confidence values use comparable ranges.
- Quantize the patched model and build an STM32N6 NPU package.
- Validate numerical agreement, spatial response, NPU deployability, and device output separately.
The attachment includes the export patch, quantization configuration, model packaging configuration, C and Python meter-reading code, editable diagrams, tests, and one recorded device result.
Before You StartYou need a trained four-keypoint YOLOv8n-Pose model, representative calibration images, Ultralytics v8.4.60, ST’s tflite_quant.py, the stedgeai toolchain, and an NE301 source tree. The tested model input is 256x256, with unsigned INT8 input and signed INT8 output.
The trained weights, calibration dataset, ST script, and proprietary toolchain are not redistributed. Use your own trained model and calibration images, then download the ST tooling from the source linked at the end of this project.
Step 1: Build a Four-Keypoint Meter ReaderThe application uses a custom YOLOv8n-Pose model to locate four landmarks:
center: the pointer pivotmin: the minimum scale endpointmax: the maximum scale endpointtip: the pointer tip
A bounding box alone cannot produce a reading. The postprocessor needs the angular relationship between these four points.
For each point, it calculates the angle around center:
angle(point) = atan2(point.y - center.y, point.x - center.x)It then measures how far the pointer tip lies along the scale arc:
ratio = angle_distance(min, tip) / angle_distance(min, max)
reading = value_min + ratio * (value_max - value_min)The implementation checks both counter-clockwise and complementary clockwise arcs, clamps the ratio to 0-1, and rejects degenerate geometry. The tested firmware maps the result to a 0-100 percentage range:
gauge_reading_compute(¢er, &min, &max, &tip,
0.0f, 100.0f, 0.0f, &result);The complete C implementation, Python reference, tests, and sample result are included in the attachment.
Expected result: given four valid keypoints, the postprocessor returns an arc ratio and a reading in the configured value range.
Step 2: Recognize the INT8 CollapseAfter exporting the model to INT8 TFLite and deploying it to the NE301, the output showed four failure signatures at the same time.
This abridged device JSON shows the first of two nearly duplicated poses. The second pose is omitted for readability:
{
"poses": [{
"confidence": 0.9993049502372742,
"keypoints": [
{"name": "center", "x": 0.55176347, "y": 0.55176347, "confidence": 0.99930495},
{"name": "max", "x": 0.69276971, "y": 0.69276971, "confidence": 0.99930495},
{"name": "min", "x": 0.41688794, "y": 0.69276971, "confidence": 0.99930495},
{"name": "tip", "x": 0.55176347, "y": 0.27588174, "confidence": 0.99930495}
]
}],
"pose_count": 2
}The useful diagnostic pattern was:
center.x == center.yandmax.x == max.y.- All four keypoint confidence values repeated as
0.99930495. - One physical gauge produced
pose_count = 2. - Different inputs produced almost the same spatial output.
No crash, NaN, or converter error pointed to the cause. That is why this failure is easy to misdiagnose as poor calibration or a weak model.
Expected result: before changing the model, confirm that the failure is a repeated-output pattern rather than a converter crash, missing operator, or postprocessing error.
Step 3: Normalize the Mixed-Scale OutputFor this four-keypoint model, YOLOv8-Pose exports a tensor with shape [1, 17, 1344]:
- Channels 0-3: bounding-box coordinates in the 0-256 range.
- Channel 4: class confidence in the 0-1 range.
- Channels 5-16: four keypoints whose coordinates are in the 0-256 range and whose confidence values are in the 0-1 range.
The quantization configuration uses per-channel quantization where supported inside the graph, but the final exported output tensor still has one scale and zero point. A single output scale dominated by 0-256 coordinates leaves very little resolution for 0-1 confidence channels.
As a simplified symmetric example:
output scale ~= 256 / 127 ~= 2.01
0.0 / 2.01 -> one INT8 level
0.5 / 2.01 -> the same or an adjacent level
1.0 / 2.01 -> the same or an adjacent levelThe exact scale depends on calibration and converter behavior. The important observation is the range mismatch, not the illustrative 2.01 value.
The fix used in this project normalizes decoded coordinates to 0-1 only during export. Training and validation keep their original coordinate behavior.
The three relevant changes are:
# Detect.__init__
self.norm = None
# Detect._get_decode_boxes
if self.export:
imgsz = self.stride[0] * shape[3]
self.norm = self.strides / float(imgsz)
dbox = self.decode_bboxes(self.dfl(x["boxes"]), self.anchors.unsqueeze(0))
dbox = dbox * (self.norm if (self.export and self.norm is not None) else self.strides)
# Pose.kpts_decode
norm = self.norm if self.norm is not None else self.strides
a = (y[:, :, :2] * 2.0 + (self.anchors - 0.5)) * normThe attachment provides head-normalization-v8.4.60.patch, which was dry-run against a clean Ultralytics v8.4.60 head.py.
Expected result: the exported bounding-box and keypoint coordinates are normalized to 0-1, while training and validation retain their original behavior.
Step 4: Export, Quantize, and Build the NPU PackageAfter applying the attachment patch to Ultralytics v8.4.60, export the trained weights at the recorded input size:
from ultralytics import YOLO
model = YOLO("best.pt")
model.export(format="saved_model", imgsz=256)Use the recorded ST quantization configurationThe project configuration is user_config_pose_gauge_norm.yaml:
model:
name: yolov8n_256
uc: pose_gauge
model_path: <workspace>/yolov8n_pose_gauge/weights/best_saved_model_norm
input_shape: [256, 256, 3]
quantization:
fake: False
quantization_type: per_channel
quantization_input_type: uint8
quantization_output_type: int8
calib_dataset_path: <workspace>/calib_gauge_pose_200/images
export_path: <ne301>/Model/weights
pre_processing:
rescaling: {scale: 255, offset: 0}Replace the placeholder paths, then run ST’s official script:
pip install hydra-core munch
python tflite_quant.py --config-name user_config_pose_gauge_norm.yamlRead quantization parameters from the generated modelDo not copy scale and zero point from an older JSON file. Read them from each generated TFLite artifact:
import tensorflow as tf
interpreter = tf.lite.Interpreter(model_path="yolov8n_256_quant_pc_ui_pose_gauge.tflite")
interpreter.allocate_tensors()
output = interpreter.get_output_details()[0]
scale = float(output["quantization_parameters"]["scales"][0])
zero_point = int(output["quantization_parameters"]["zero_points"][0])
print(scale, zero_point)The recorded fixed artifact produced scale = 0.00613071 and zero_point = -116. These are evidence values, not constants for future builds.
Place the TFLite and JSON files in ne301/Model/weights/, then set the model variables:
RELOC_CONFIG = yolov8_mpe@neural_art_reloc.json
MODEL_NAME = yolov8n_256_quant_pc_ui_pose_gauge
MODEL_FILE = $(WEIGHTS_DIR)/$(MODEL_NAME).tflite
MODEL_JSON = $(WEIGHTS_DIR)/$(MODEL_NAME).jsonFrom the NE301 repository root:
make model
make pkg-modelThe tested pipeline is:
best.pt
-> patched SavedModel export at 256x256
-> ST per-channel PTQ, uint8 input / int8 output
-> TFLite scale and zero point copied into model JSON
-> stedgeai relocation with yolov8_mpe
-> NE301 model packageExpected result: a generated TFLite model, matching scale and zero-point values in the model JSON, and an NE301 model package produced with the yolov8_mpe relocation configuration.
Verification used four distinct checks. They answer different questions and should not be treated as interchangeable.
Check 1: INT8 versus FP32 on the same imageThe project used a maximum absolute difference below 0.05 as its acceptance criterion. This is a project-specific threshold, not a universal YOLO benchmark.
test-meter-3: maximum absolute difference0.030(pass).test-meter-4: maximum absolute difference0.017(pass).meter-test: maximum absolute difference0.048(pass).
Comparing two different images is not a valid substitute for this check. Similar gauges can legitimately produce similar outputs.
Check 2: TFLite spatial response- Collapsed export: maximum pairwise keypoint difference across three images was approximately
0.000. - Normalized export: maximum pairwise keypoint difference across three images was
0.3127.
This check confirms that the fixed model responds to input geometry instead of returning nearly constant coordinates.
Check 3: NPU deployabilitystedgeai compilation confirms that the graph can be placed on the STM32N6 target. It does not, by itself, prove numerical correctness.
- C code generation:
23.6 seconds. network_rel.bin:3.18 MB.- npuRAM5:
98.21%(440 KB / 448 KB). - Pure hardware epochs:
118 / 133. - Mixed epochs:
14 / 133. - Software epochs:
1 / 133(Softmax).
The on-device output was no longer collapsed:
{
"poses": [{
"confidence": 0.9196,
"keypoints": [
{"name": "center", "x": 0.5088, "y": 0.4966, "confidence": 0.9932},
{"name": "max", "x": 0.6560, "y": 0.6928, "confidence": 0.9870},
{"name": "min", "x": 0.3127, "y": 0.6437, "confidence": 0.9870},
{"name": "tip", "x": 0.5088, "y": 0.2452, "confidence": 0.9932}
],
"reading": {"value": 46.99, "ratio": 0.4699, "direction": "ccw"}
}],
"pose_count": 1
}Running the included Python reference against this JSON produces:
pose[0] reading=46.99 ratio=0.470 dir=ccwThe included reference suite has 14 passing tests covering the recorded sample, range mapping, clockwise/counter-clockwise selection, low confidence, missing keypoints, degenerate arcs, and keypoint ordering.
This device frame does not have an independently labeled ground-truth reading. It proves that coordinate and confidence variation were restored and that the postprocessor completed; it is not an accuracy benchmark.
Expected result: the same-image comparisons pass the project threshold, different inputs produce different keypoint geometry, the graph compiles for the NPU, and the device returns one non-collapsed pose that the meter postprocessor can consume.
Troubleshooting and Limits- Coordinates or confidences repeat: inspect the exported output tensor ranges and confirm that the normalization patch was active during export.
- TFLite output looks valid but device output does not: confirm that
scale,zero_point,raw_output_scale, andraw_output_zero_pointmatch the generated TFLite file. - Relocation or runtime behavior is abnormal: confirm
RELOC_CONFIG = yolov8_mpe@neural_art_reloc.json; the object-detection relocation section is not the tested pose configuration. - Meter reading is missing: confirm all four named keypoints are present and not coincident with
center. - Rotated gauges perform worse: the recorded tests showed orientation sensitivity. Training-data imbalance is a possible cause, but it was not isolated in this experiment.
- Scope: this patch addresses the observed mixed-scale output collapse. It does not eliminate unrelated calibration, operator-support, or model-accuracy issues.
The important lesson was not simply that INT8 quantization can reduce accuracy. The model compiled and ran, yet its final output tensor no longer preserved enough useful variation for the application.
Three checks made the diagnosis repeatable:
- Inspect output patterns, not only converter and compiler status.
- Compare FP32 and INT8 outputs using the same images, then test whether different inputs produce different spatial outputs.
- Treat numerical agreement, NPU compilation, and an on-device result as separate gates.
For this model, export-time coordinate normalization aligned the coordinate and confidence ranges without changing training behavior. The resulting model passed the project’s three same-image checks, responded to changing input geometry, compiled for STM32N6, and completed the four-keypoint meter-reading path on the device.
The next useful experiment is a labeled evaluation set covering different meter orientations and pointer positions. That would measure reading accuracy; the current evidence establishes recovery from the observed collapse, not an accuracy benchmark.




Comments