YakumoAkayi's picture
Agora board builds (Model Farm, verified on real boards)
6dcc0f2 verified
Raw History Blame Contribute Delete
28.1 kB
from __future__ import annotations
import time
from pathlib import Path
from typing import Any
import numpy as np
import portable_numpy_nmsfreecoder as portable_nms
from camera_grid_visualization import save_camera_grid_gif, save_camera_grid_summary, save_camera_grid_visualization
from utils import (
EXPECTED_TENSORS,
SHAPES,
as_encoder_img_feat,
elapsed_ms,
load_backbone_images,
load_json,
load_record,
normalize_rc,
rotate_prev_bev,
save_final_coordinates,
save_raw_outputs,
sha256_file,
stats,
)
QCS8550_SHA256 = {
"backbone": "9a7a5137a2ed3072cdc2c567822a2316b9501a591ffa204b17568f6c80d8844e",
"encoder_temporal": "25f0063fc7d1b125522d3ac9c53b26a9e270d504fc6c908ae5737ec78ad65b94",
"encoder_scene_start": "eed541fa4138a177432e2290b4131799d88534005789bef9962e2bbb9e008186",
"decoder": "f6888090751bf1ff6ecbaef966b3b1a6c97793784f921001b493893a8ce6196c",
}
IQ9_SHA256 = {
"backbone": "54c042b09eff47d99998762a375fe606bc3a83350ebf1085399384f50fe2dbfb",
"encoder_temporal": "b376fe6112794278a4b3f787b34e387d62cd81de00312021ca927448c4e9c253",
"encoder_scene_start": "8b0987fe7a21a79a4bc5975f3732b7d9c60611fadc813a96e438dfc85a59fb2e",
"decoder": "60952eaa1d35c648d5992a4f8ef2563410b8b6fe68e8ad6095f1231921ea0ff5",
}
QCS8625_SHA256 = {
"backbone": "552bb2f456638c19bfcd88d79e5c15a671fddca21077332c6e0cb1d1cd2641de",
"encoder_temporal": "046652a264dce3728b11433b1a3378e6c4b894e1286d007d940daec4ec5a2608",
"encoder_scene_start": "7071b3937d79191412db50688136f88f70c917a8bff6c0e8af649157555be592",
"decoder": "be322fead0610e0321ce165cc0c1d2686768b6c6e1b3241c8ef89f45f6d2bbc5",
}
IQ8_SHA256 = {
"backbone": "e7f75ca4ca7fda12ecdfbb14694a906165b70ecdc8e605b54174d82a042f25b1",
"encoder_temporal": "f79e2c208fe63eec8f30bf289310dcc429cf6b87a05dd3397aeda6d08aabaf74",
"encoder_scene_start": "62c6b62139525d238c447d65f47004f65bd9e2cd88beae5b580bdd7d80687f81",
"decoder": "814534559bf5ffd00f4316302b7804434e19a2420117ec9b5f46fe5dbce887f1",
}
MODEL_SHA256 = {
"QCS8550": QCS8550_SHA256,
"IQ9": IQ9_SHA256,
"QCS8625": QCS8625_SHA256,
"IQ8": IQ8_SHA256,
}
MODEL_NAMES = (
"backbone",
"encoder_temporal",
"encoder_scene_start",
"decoder",
)
NUSCENES_CLASSES = (
"car",
"truck",
"construction_vehicle",
"bus",
"trailer",
"barrier",
"motorcycle",
"bicycle",
"pedestrian",
"traffic_cone",
)
class BevFormerModel:
def __init__(
self,
backbone_model: str,
encoder_temporal_model: str,
encoder_scene_start_model: str,
decoder_model: str,
model_type: str = "QNN248",
expected_sha256: dict[str, str] | None = None,
):
try:
import aidlite
except ModuleNotFoundError as exc:
raise RuntimeError(
"AidLite Python runtime is not available in this environment. "
"Use --dry_run to demonstrate the package structure in a normal container, "
"or run without --dry_run on the board / Container B where "
"`python3 -c \"import aidlite\"` succeeds."
) from exc
if model_type.upper() != "QNN248":
raise ValueError("This demo is pinned to QNN248 contexts")
if (
int(aidlite.FrameworkType.TYPE_QNN248),
int(aidlite.ImplementType.TYPE_LOCAL),
int(aidlite.AccelerateType.TYPE_DSP),
) != (110, 3, 3):
raise RuntimeError("AidLite enum contract mismatch")
self.aidlite = aidlite
model_paths = {
"backbone": backbone_model,
"encoder_temporal": encoder_temporal_model,
"encoder_scene_start": encoder_scene_start_model,
"decoder": decoder_model,
}
self.model_platform = self.resolve_model_platform(model_paths)
self.expected_sha256 = expected_sha256 or MODEL_SHA256[self.model_platform]
self.interpreters: dict[str, Any] = {}
self.model_records: dict[str, Any] = {}
self.model_load_timing_ms: dict[str, float] = {}
model_load_start = time.perf_counter_ns()
for name in MODEL_NAMES:
path = model_paths[name]
load_start = time.perf_counter_ns()
interpreter, record = self._create_loaded_interpreter(name, str(path))
self.model_load_timing_ms[name] = elapsed_ms(load_start)
self.interpreters[name] = interpreter
self.model_records[name] = record
self.model_load_timing_ms["total"] = elapsed_ms(model_load_start)
def __del__(self):
for interpreter in reversed(list(getattr(self, "interpreters", {}).values())):
for method_name in ("destroy", "destory"):
if hasattr(interpreter, method_name):
try:
getattr(interpreter, method_name)()
except Exception:
pass
break
def _create_model(self, model_path: str) -> Any:
try:
return self.aidlite.Model.create_instance(model_path=model_path)
except TypeError:
return self.aidlite.Model.create_instance(model_path)
def _build_interpreter(self, model: Any, config: Any) -> Any:
for method_name in ("build_interpreter_from_model_and_config", "build_interpretper_from_model_and_config"):
if hasattr(self.aidlite.InterpreterBuilder, method_name):
method = getattr(self.aidlite.InterpreterBuilder, method_name)
try:
return method(model=model, config=config)
except TypeError:
return method(model, config)
raise RuntimeError("No supported AidLite InterpreterBuilder method")
@staticmethod
def infer_model_platform(model_path: str | Path) -> str:
parts = [part.upper() for part in Path(model_path).expanduser().parts]
matches = [platform for platform in MODEL_SHA256 if platform.upper() in parts]
if len(matches) != 1:
supported = ", ".join(MODEL_SHA256)
raise RuntimeError(f"Cannot infer model platform from path={model_path}. Expected one of: {supported}")
return matches[0]
@classmethod
def resolve_model_platform(cls, model_paths: dict[str, str | Path]) -> str:
platforms = {name: cls.infer_model_platform(path) for name, path in model_paths.items()}
unique = set(platforms.values())
if len(unique) != 1:
raise RuntimeError(f"Model platform mismatch: {platforms}")
return next(iter(unique))
@staticmethod
def _flatten_tensor_info(groups: Any) -> list[dict[str, Any]]:
records: list[dict[str, Any]] = []
if groups is None:
return records
for graph_index, group in enumerate(groups):
try:
tensors = list(group)
except TypeError:
tensors = [group]
for tensor_index, info in enumerate(tensors):
records.append({
"graph_index": graph_index,
"tensor_index": tensor_index,
"name": str(getattr(info, "name", "")),
"element_count": int(getattr(info, "element_count", -1)),
"shape": [int(v) for v in getattr(info, "shape", [])],
"element_type": str(getattr(info, "element_type", "")),
})
return records
def _create_loaded_interpreter(self, name: str, model_path: str) -> tuple[Any, dict[str, Any]]:
path = Path(model_path).expanduser().resolve()
if not path.is_file():
raise FileNotFoundError(path)
actual_sha = sha256_file(path)
expected_sha = self.expected_sha256[name]
if actual_sha != expected_sha:
raise RuntimeError(
f"{name} context SHA mismatch for {self.model_platform}: "
f"expected={expected_sha} actual={actual_sha}"
)
model = self._create_model(str(path))
config = self.aidlite.Config.create_instance()
if model is None or config is None:
raise RuntimeError(f"{name}: Model/Config creation failed")
config.framework_type = self.aidlite.FrameworkType.TYPE_QNN248
config.implement_type = self.aidlite.ImplementType.TYPE_LOCAL
config.accelerate_type = self.aidlite.AccelerateType.TYPE_DSP
config.qnn_shared_buffer = 0
interpreter = self._build_interpreter(model, config)
if interpreter is None:
raise RuntimeError(f"{name}: interpreter creation failed")
init_rc = normalize_rc(interpreter.init())
load_rc = normalize_rc(interpreter.load_model())
if init_rc != 0 or load_rc != 0:
raise RuntimeError(f"{name}: init/load failed init={init_rc} load={load_rc}")
inputs = self._flatten_tensor_info(interpreter.get_input_tensor_info())
outputs = self._flatten_tensor_info(interpreter.get_output_tensor_info())
actual_inputs = {item["name"]: item["element_count"] for item in inputs}
actual_outputs = {item["name"]: item["element_count"] for item in outputs}
expected = EXPECTED_TENSORS[name]
if actual_inputs != expected["inputs"] or actual_outputs != expected["outputs"]:
raise RuntimeError(f"{name}: tensor contract mismatch inputs={actual_inputs} outputs={actual_outputs}")
return interpreter, {
"name": name,
"platform": self.model_platform,
"path": str(path),
"sha256": actual_sha,
"inputs": inputs,
"outputs": outputs,
}
def _set_input(self, interpreter: Any, name: str, value: np.ndarray) -> float:
tensor = np.ascontiguousarray(value, dtype=np.float32)
start = time.perf_counter_ns()
rc = normalize_rc(interpreter.set_input_tensor(in_tensor_tag=name, input_data=tensor))
duration = elapsed_ms(start)
if rc != 0:
raise RuntimeError(f"set_input_tensor failed name={name} rc={rc}")
return duration
def _invoke(self, interpreter: Any, name: str) -> float:
start = time.perf_counter_ns()
rc = normalize_rc(interpreter.invoke())
duration = elapsed_ms(start)
if rc != 0:
raise RuntimeError(f"{name}: invoke failed rc={rc}")
return duration
def _get_output(self, interpreter: Any, name: str, shape: tuple[int, ...]) -> tuple[np.ndarray, float]:
start = time.perf_counter_ns()
value = interpreter.get_output_tensor(out_tensor_tag=name)
duration = elapsed_ms(start)
if value is None:
raise RuntimeError(f"get_output_tensor returned None name={name}")
array = np.asarray(value, dtype=np.float32).reshape(shape)
if not np.isfinite(array).all():
raise RuntimeError(f"{name}: non-finite output")
return np.ascontiguousarray(array, dtype=np.float32), duration
def run_frame(
self,
frame_index: int,
frame_manifest: dict[str, Any],
repo_root: str | Path,
previous_frame_bev_embed: np.ndarray | None,
check_image_sha: bool = False,
preprocess_workers: int = 6,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, dict[str, Any]]:
"""Run one BEVFormer frame on the board.
The QNN context tensor names still follow the exported model contract
such as ``can_bus``, ``lidar2img`` and ``prev_bev``. The Python
variable names below describe their deployment meaning more explicitly:
- scene-start frame: use the scene-start encoder; no previous BEV state.
- temporal frame: rotate the previous frame BEV state, then feed it as
the current frame ``prev_bev`` tensor.
"""
frame_start = time.perf_counter_ns()
is_scene_start_frame = bool(frame_manifest.get("is_scene_start", False))
asset_records = frame_manifest["assets"]
timing: dict[str, float] = {}
preprocess_start = time.perf_counter_ns()
padded_camera_tensor, image_preprocess = load_backbone_images(
asset_records,
repo_root,
check_image_sha=check_image_sha,
preprocess_workers=preprocess_workers,
)
timing["image_preprocess_ms"] = elapsed_ms(preprocess_start)
ego_motion_can_bus = load_record(asset_records["can_bus"], repo_root).astype(np.float32)
camera_lidar2img_matrices = load_record(asset_records["lidar2img"], repo_root).astype(np.float32)
timing["backbone_set_input_ms"] = self._set_input(
self.interpreters["backbone"],
"images",
padded_camera_tensor,
)
timing["backbone_invoke_ms"] = self._invoke(self.interpreters["backbone"], "backbone")
backbone_image_features, timing["backbone_get_output_ms"] = self._get_output(
self.interpreters["backbone"], "img_feat", SHAPES["img_feat"]
)
encoder_image_features = as_encoder_img_feat(backbone_image_features)
if is_scene_start_frame:
selected_encoder_name = "encoder_scene_start"
selected_encoder_route = "scene_start_reset_prev_bev"
selected_encoder = self.interpreters[selected_encoder_name]
timing["encoder_set_input_ms"] = 0.0
scene_start_inputs = (
("can_bus", ego_motion_can_bus),
("img_feat", encoder_image_features),
("lidar2img", camera_lidar2img_matrices),
)
for tensor_name, tensor_value in scene_start_inputs:
timing["encoder_set_input_ms"] += self._set_input(selected_encoder, tensor_name, tensor_value)
else:
if previous_frame_bev_embed is None:
raise RuntimeError(f"frame{frame_index:03d}: previous BEV state is missing for temporal frame")
ego_motion_shift = load_record(asset_records["shift"], repo_root).astype(np.float32)
ego_rotation_can_bus = load_record(asset_records["rotation_can_bus"], repo_root).astype(np.float32)
rotate_start = time.perf_counter_ns()
rotated_previous_bev_embed = rotate_prev_bev(previous_frame_bev_embed, ego_rotation_can_bus)
timing["prev_bev_rotate_ms"] = elapsed_ms(rotate_start)
selected_encoder_name = "encoder_temporal"
selected_encoder_route = "temporal_reuse_previous_bev"
selected_encoder = self.interpreters[selected_encoder_name]
timing["encoder_set_input_ms"] = 0.0
temporal_inputs = (
("can_bus", ego_motion_can_bus),
("img_feat", encoder_image_features),
("lidar2img", camera_lidar2img_matrices),
("shift", ego_motion_shift),
("prev_bev", rotated_previous_bev_embed),
)
for tensor_name, tensor_value in temporal_inputs:
timing["encoder_set_input_ms"] += self._set_input(selected_encoder, tensor_name, tensor_value)
timing["encoder_invoke_ms"] = self._invoke(selected_encoder, selected_encoder_name)
current_frame_bev_embed, timing["encoder_get_output_ms"] = self._get_output(
selected_encoder,
"bev_embed",
SHAPES["bev"],
)
decoder = self.interpreters["decoder"]
timing["decoder_set_input_ms"] = self._set_input(decoder, "bev_embed", current_frame_bev_embed)
timing["decoder_invoke_ms"] = self._invoke(decoder, "decoder")
decoder_cls_scores, timing["decoder_get_cls_ms"] = self._get_output(decoder, "cls_scores", SHAPES["decoder"])
decoder_bbox_preds, timing["decoder_get_bbox_ms"] = self._get_output(decoder, "bbox_preds", SHAPES["decoder"])
timing["qnn_invoke_ms"] = (
timing["backbone_invoke_ms"]
+ timing["encoder_invoke_ms"]
+ timing["decoder_invoke_ms"]
)
timing["frame_total_ms"] = elapsed_ms(frame_start)
frame_result = {
"frame_index": int(frame_index),
"sample_token": frame_manifest.get("sample_token"),
"frame_type": "scene_start" if is_scene_start_frame else "temporal",
"is_scene_start": is_scene_start_frame,
"encoder": selected_encoder_name,
"encoder_route": selected_encoder_route,
"image_preprocess": image_preprocess,
"timing_ms": timing,
"status": "PASS",
}
return current_frame_bev_embed, decoder_cls_scores, decoder_bbox_preds, frame_result
def run_manifest(
self,
manifest_path: str | Path,
repo_root: str | Path,
output_dir: str | Path,
nms_contract_path: str | Path,
frame_start: int = 0,
frame_count: int | None = None,
save_all_raw: bool = False,
visualize: bool = True,
vis_score_thr: float = 0.0,
vis_max_boxes: int = 80,
check_image_sha: bool = False,
preprocess_workers: int = 6,
) -> dict[str, Any]:
run_manifest_start = time.perf_counter_ns()
manifest_load_start = time.perf_counter_ns()
manifest = load_json(manifest_path)
nms_contract = load_json(nms_contract_path)
manifest_load_ms = elapsed_ms(manifest_load_start)
total_frames = int(manifest.get("total_frames", len(manifest["frames"])))
end = total_frames if frame_count is None else min(total_frames, frame_start + frame_count)
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
previous_frame_bev_embed: np.ndarray | None = None
frames: dict[str, Any] = {}
scene_start_count = 0
temporal_count = 0
frame_ms: list[float] = []
qnn_invoke_ms: list[float] = []
backbone_invoke_ms: list[float] = []
encoder_invoke_ms: list[float] = []
scene_start_encoder_invoke_ms: list[float] = []
temporal_encoder_invoke_ms: list[float] = []
decoder_invoke_ms: list[float] = []
image_preprocess_ms: list[float] = []
raw_save_ms: list[float] = []
nms_decode_ms: list[float] = []
final_coordinates_save_ms: list[float] = []
postprocess_ms: list[float] = []
visualization_ms: list[float] = []
final_outputs: dict[str, Any] | None = None
final_coordinates: dict[str, Any] = {}
visualizations: dict[str, Any] = {}
for frame_index in range(frame_start, end):
sample = f"sample_{frame_index:03d}"
current_frame_bev_embed, decoder_cls_scores, decoder_bbox_preds, frame_record = self.run_frame(
frame_index,
manifest["frames"][sample],
repo_root,
previous_frame_bev_embed,
check_image_sha=check_image_sha,
preprocess_workers=preprocess_workers,
)
previous_frame_bev_embed = np.ascontiguousarray(current_frame_bev_embed, dtype=np.float32)
frames[sample] = frame_record
frame_ms.append(frame_record["timing_ms"]["frame_total_ms"])
qnn_invoke_ms.append(frame_record["timing_ms"]["qnn_invoke_ms"])
backbone_invoke_ms.append(frame_record["timing_ms"]["backbone_invoke_ms"])
encoder_time = frame_record["timing_ms"]["encoder_invoke_ms"]
encoder_invoke_ms.append(encoder_time)
if frame_record["encoder"] == "encoder_scene_start":
scene_start_encoder_invoke_ms.append(encoder_time)
elif frame_record["encoder"] == "encoder_temporal":
temporal_encoder_invoke_ms.append(encoder_time)
decoder_invoke_ms.append(frame_record["timing_ms"]["decoder_invoke_ms"])
image_preprocess_ms.append(frame_record["timing_ms"].get("image_preprocess_ms", 0.0))
if frame_record["is_scene_start"]:
scene_start_count += 1
else:
temporal_count += 1
raw_save_value = 0.0
if save_all_raw or frame_index == end - 1:
raw_save_start = time.perf_counter_ns()
final_outputs = save_raw_outputs(output_path, frame_index, decoder_cls_scores, decoder_bbox_preds)
raw_save_value = elapsed_ms(raw_save_start)
raw_save_ms.append(raw_save_value)
nms_start = time.perf_counter_ns()
boxes, scores, labels = portable_nms.decode_numpy_nmsfreecoder(
decoder_cls_scores,
decoder_bbox_preds,
nms_contract,
)
nms_value = elapsed_ms(nms_start)
nms_decode_ms.append(nms_value)
final_save_start = time.perf_counter_ns()
final_coordinates[f"frame{frame_index:03d}"] = save_final_coordinates(
output_path,
frame_index,
boxes,
scores,
labels,
)
final_save_value = elapsed_ms(final_save_start)
final_coordinates_save_ms.append(final_save_value)
postprocess_ms.append(raw_save_value + nms_value + final_save_value)
visualization_value = 0.0
if visualize:
visualization_start = time.perf_counter_ns()
visualizations[f"frame{frame_index:03d}"] = save_camera_grid_visualization(
output_path,
frame_index,
manifest["frames"][sample],
repo_root,
boxes,
scores,
labels,
score_thr=vis_score_thr,
max_boxes=vis_max_boxes,
result_dir=output_path,
)
visualization_value = elapsed_ms(visualization_start)
visualization_ms.append(visualization_value)
frame_record["timing_ms"]["raw_save_ms"] = raw_save_value
frame_record["timing_ms"]["nms_decode_ms"] = nms_value
frame_record["timing_ms"]["final_coordinates_save_ms"] = final_save_value
frame_record["timing_ms"]["postprocess_ms"] = raw_save_value + nms_value + final_save_value
frame_record["timing_ms"]["visualization_ms"] = visualization_value
top_detections = []
for det_index in range(min(5, len(scores))):
label_id = int(labels[det_index])
class_name = NUSCENES_CLASSES[label_id] if label_id < len(NUSCENES_CLASSES) else str(label_id)
top_detections.append({
"box": [float(value) for value in boxes[det_index].tolist()],
"score": float(scores[det_index]),
"label": label_id,
"class_name": class_name,
})
frame_record["detections"] = {
"count": int(len(scores)),
"top": top_detections,
}
route_text = (
"first frame: reset prev_bev state"
if frame_record["encoder_route"] == "scene_start_reset_prev_bev"
else "later frame: previous bev_embed -> prev_bev"
)
print(f"Frame {frame_index:03d} | QNN={frame_record['timing_ms']['qnn_invoke_ms']:.1f} ms backbone={frame_record['timing_ms']['backbone_invoke_ms']:.1f} encoder={frame_record['timing_ms']['encoder_invoke_ms']:.1f} decoder={frame_record['timing_ms']['decoder_invoke_ms']:.1f}")
camera_grid_gif = None
camera_grid_summary = None
gif_ms = 0.0
if visualize and visualizations:
gif_start = time.perf_counter_ns()
ordered_records = [visualizations[key] for key in sorted(visualizations)]
camera_grid_gif = save_camera_grid_gif(ordered_records, output_path)
camera_grid_summary = save_camera_grid_summary(output_path, ordered_records, camera_grid_gif)
gif_ms = elapsed_ms(gif_start)
if camera_grid_gif:
print(f"GIF: {camera_grid_gif['path']}")
total_with_visualization_ms = elapsed_ms(run_manifest_start)
total_without_visualization_ms = (
manifest_load_ms
+ sum(frame_ms)
+ sum(postprocess_ms)
)
total_visualization_ms = sum(visualization_ms) + gif_ms
return {
"status": "PASS",
"manifest": str(Path(manifest_path).resolve()),
"nms_contract": str(Path(nms_contract_path).resolve()),
"repo_root": str(Path(repo_root).resolve()),
"frame_range": [int(frame_start), int(end - 1)] if end > frame_start else [],
"completed_frames": int(end - frame_start),
"scene_start_encoder_count": scene_start_count,
"temporal_encoder_count": temporal_count,
"model_platform": self.model_platform,
"models": self.model_records,
"model_load_timing_ms": self.model_load_timing_ms,
"end_to_end_timing_ms": {
"manifest_and_contract_load_ms": manifest_load_ms,
"complete_inference_no_visualization_ms": total_without_visualization_ms,
"visualization_total_ms": total_visualization_ms,
"complete_inference_with_visualization_ms": total_with_visualization_ms,
"camera_grid_gif_ms": gif_ms,
},
"timing_ms": stats(frame_ms),
"qnn_invoke_ms": stats(qnn_invoke_ms),
"component_invoke_ms": {
"backbone": stats(backbone_invoke_ms),
"encoder": stats(encoder_invoke_ms),
"encoder_scene_start": stats(scene_start_encoder_invoke_ms),
"encoder_temporal": stats(temporal_encoder_invoke_ms),
"decoder": stats(decoder_invoke_ms),
},
"per_bin_qnn_invoke_ms": {
"backbone_context.bin": stats(backbone_invoke_ms),
"scene_start_encoder_context.bin": stats(scene_start_encoder_invoke_ms),
"temporal_encoder_context.bin": stats(temporal_encoder_invoke_ms),
"decoder_context.bin": stats(decoder_invoke_ms),
},
"image_preprocess_ms": stats(image_preprocess_ms),
"postprocess_ms": stats(postprocess_ms),
"nms_decode_ms": stats(nms_decode_ms),
"raw_save_ms": stats(raw_save_ms),
"final_coordinates_save_ms": stats(final_coordinates_save_ms),
"visualization_ms": stats(visualization_ms),
"timing_contract": {
"frame_total_ms": "Per-frame inference path: image preprocessing plus model input/output and QNN invoke time; excludes NMS, result saving, and camera-grid visualization rendering.",
"qnn_invoke_ms": "Backbone + selected encoder + decoder invoke time only. Excludes preprocessing, tensor set/get, NMS, result saving, and visualization.",
"per_bin_qnn_invoke_ms": "Pure AidLite/QNN interpreter.invoke() time grouped by delivered .bin context file.",
"image_preprocess_ms": "Six-camera JPG decode, RGB conversion, normalization, resize, CHW conversion, and zero padding.",
"complete_inference_no_visualization_ms": "Manifest load plus all frame inference, NMS, and result saving; excludes PNG/GIF rendering.",
"complete_inference_with_visualization_ms": "Full run_manifest wall time including PNG/GIF rendering; excludes Python process startup and model loading, which are reported separately.",
},
"frames": frames,
"final_outputs": final_outputs,
"final_coordinates": final_coordinates,
"visualizations": visualizations,
"camera_grid_gif": camera_grid_gif,
"camera_grid_summary": camera_grid_summary,
}