from __future__ import annotations

import argparse
import json
import logging
import math
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable, Optional

import cv2
import numpy as np

try:
    import onnxruntime as ort
except ImportError:
    ort = None

try:
    import torch
except ImportError:
    torch = None

try:
    from dotenv import load_dotenv as _load_dotenv
except ImportError:
    def _load_dotenv(*args, **kwargs):
        return False

FILE = Path(__file__).resolve()
ROOT = FILE.parents[3]
if str(ROOT) not in sys.path:
    sys.path.append(str(ROOT))


def load_dotenv(*args, **kwargs):
    if args or kwargs:
        return _load_dotenv(*args, **kwargs)

    for candidate in (Path.cwd() / ".env", ROOT / ".env", FILE.parents[1] / ".env"):
        if candidate.is_file():
            return _load_dotenv(dotenv_path=candidate)
    return False

try:
    from .two_roi_types import (
        Decoded3DPrediction,
        Prediction3DAttrs,
        RawCameraCalib,
        ResizedCalib,
        SerializedFramePayload,
        SerializedMergedPayload,
        SerializedPredictionRecord,
        SerializedPredictionsPayload,
        SerializedROIPayload,
    )
    from .attribute_infer_utils import (
        attribute_result_to_json,
        draw_attribute_overlays,
        load_attribute_models,
        predict_attributes_for_detections,
    )
    from .two_roi_infer_utils import (
        GRID_BG_COLOR,
        PreparedROI,
        ROIModelSpec,
        add_two_roi_inference_args,
        annotate_panel_title,
        build_edge_prediction_artifacts,
        build_anchor_cache,
        build_roi_specs_from_args,
        class_name,
        decode_boxes_xyxy,
        denorm_preds_3d,
        denorm_preds_edge,
        draw_2d_boxes,
        draw_heading_lines,
        filter_prediction_rows,
        infer_model_imgsz,
        iter_case_images,
        load_camera4_calib,
        load_runtime_metadata,
        prepare_roi_image,
        resolve_case_calib_path,
        resolve_case_inputs,
        restore_depth_scale,
        postprocess_detections,
        select_topk_3d_metadata,
        select_topk_branch,
        serialize_array,
        serialize_scalar,
        wrapped_angle_diff_rad,
    )
    from .two_roi_3d_utils import (
        FACE_OFFSETS_41,
        back_project_2d_to_3d,
        decode_3d_prediction,
        decode_visible_face_edge_from_prediction,
        draw_3d_box,
        extract_3d_attrs_from_prediction,
        get_pred_cut_state,
        project_face_bottom_edge,
        project_partial_face_bottom_edge,
        rebuild_box_corners_for_visualization,
    )
    from ..adapters.pdcl_clip_export_utils import (
        run_clip_list_inference_exported,
        run_mcap_file_inference_exported,
    )
    from ..adapters.eventid_clip_resolver import (
        run_event_json_inference_exported,
    )
    from ..adapters.video_dir_inference_utils import (
        DEFAULT_CNCAP_PATH_PREFIX_DST,
        DEFAULT_CNCAP_PATH_PREFIX_SRC,
        build_case_output_rel_dir,
        collect_video_case_dirs,
        get_video_frame_id,
        iter_video_case_frames,
        load_path_list_from_json,
        read_video_frame_index,
        rewrite_path_prefix,
        resolve_video_case_paths,
    )
except ImportError:
    if str(FILE.parent) not in sys.path:
        sys.path.append(str(FILE.parent))
    if str(FILE.parents[1] / "adapters") not in sys.path:
        sys.path.append(str(FILE.parents[1] / "adapters"))
    from two_roi_types import (
        Decoded3DPrediction,
        Prediction3DAttrs,
        RawCameraCalib,
        ResizedCalib,
        SerializedFramePayload,
        SerializedMergedPayload,
        SerializedPredictionRecord,
        SerializedPredictionsPayload,
        SerializedROIPayload,
    )
    from attribute_infer_utils import (
        attribute_result_to_json,
        draw_attribute_overlays,
        load_attribute_models,
        predict_attributes_for_detections,
    )
    from two_roi_infer_utils import (
        GRID_BG_COLOR,
        PreparedROI,
        ROIModelSpec,
        add_two_roi_inference_args,
        annotate_panel_title,
        build_edge_prediction_artifacts,
        build_anchor_cache,
        build_roi_specs_from_args,
        class_name,
        decode_boxes_xyxy,
        denorm_preds_3d,
        denorm_preds_edge,
        draw_2d_boxes,
        draw_heading_lines,
        filter_prediction_rows,
        infer_model_imgsz,
        iter_case_images,
        load_camera4_calib,
        load_runtime_metadata,
        prepare_roi_image,
        resolve_case_calib_path,
        resolve_case_inputs,
        restore_depth_scale,
        postprocess_detections,
        select_topk_3d_metadata,
        select_topk_branch,
        serialize_array,
        serialize_scalar,
        wrapped_angle_diff_rad,
    )
    from two_roi_3d_utils import (
        FACE_OFFSETS_41,
        back_project_2d_to_3d,
        decode_3d_prediction,
        decode_visible_face_edge_from_prediction,
        draw_3d_box,
        extract_3d_attrs_from_prediction,
        get_pred_cut_state,
        project_face_bottom_edge,
        project_partial_face_bottom_edge,
        rebuild_box_corners_for_visualization,
    )
    from pdcl_clip_export_utils import (
        run_clip_list_inference_exported,
        run_mcap_file_inference_exported,
    )
    from eventid_clip_resolver import (
        run_event_json_inference_exported,
    )
    from video_dir_inference_utils import (
        DEFAULT_CNCAP_PATH_PREFIX_DST,
        DEFAULT_CNCAP_PATH_PREFIX_SRC,
        build_case_output_rel_dir,
        collect_video_case_dirs,
        get_video_frame_id,
        iter_video_case_frames,
        load_path_list_from_json,
        read_video_frame_index,
        rewrite_path_prefix,
        resolve_video_case_paths,
    )


LOGGER = logging.getLogger("two_roi_exported_onnx_infer")
if not LOGGER.handlers:
    logging.basicConfig(level=logging.INFO, format="%(message)s")


DEFAULT_EXPORTED_MODEL = ROOT / "runs" / "export" / "train_mono3d_two_roi_20260403-raw-fuse" / "merged_model.onnx"
DEFAULT_OUTPUT_ROOT = FILE.parents[1] / "visualization_exported_onnx"
# DEFAULT_ATTR_ROOT = ROOT.parent / "yolov5-3d" / "test_scripts" / "models_cls_released"
DEFAULT_ATTR_ROOT = ROOT / "runs" / "cls_models"
DEFAULT_CAR_ATTR_WEIGHTS = DEFAULT_ATTR_ROOT / "car_attribute_1_1_bgr_20260420_deploy_libtorchmodel.pt"
DEFAULT_PED_ATTR_WEIGHTS = DEFAULT_ATTR_ROOT / "ped_attribute_bgr_20260408_deploy_libtorchmodel.pt"
DEFAULT_TSR_ATTR_WEIGHTS = DEFAULT_ATTR_ROOT / "tsr_attribute_sigmastar_bgr_1_1_20260110_deploy_libtorchmodel.pt"
DEFAULT_TSR_LABEL_FILE = DEFAULT_ATTR_ROOT / "small_sign_classify_class_list.txt"
DEFAULT_ROI_MODEL_PATHS = {
    "roi0": str(ROOT / "runs" / "detect" / "mono3d_roi0_20260403_epoch99.pt"),
    "roi1": str(ROOT / "runs" / "detect" / "mono3d_roi1_20260403_epoch99.pt"),
}
RAW_OUTPUT_BASE_NAMES = {
    "roi0": (
        "roi0_boxes_head_raw",
        "roi0_scores_head_raw",
        "roi0_preds_3d_head_raw",
        "roi0_preds_3d_fake_head_raw",
        "roi0_preds_diff_head_raw",
    ),
    "roi1": (
        "roi1_boxes_head_raw",
        "roi1_scores_head_raw",
        "roi1_preds_3d_head_raw",
        "roi1_preds_3d_fake_head_raw",
        "roi1_preds_diff_head_raw",
    ),
}
RAW_OUTPUT_EDGE_NAMES = {
    "roi0": "roi0_preds_edge_head_raw",
    "roi1": "roi1_preds_edge_head_raw",
}
RAW_OUTPUT_LEGACY_BASE_NAMES = {
    "roi0": ("roi0_boxes_head_raw", "roi0_scores_head_raw", "roi0_preds_3d_head_raw"),
    "roi1": ("roi1_boxes_head_raw", "roi1_scores_head_raw", "roi1_preds_3d_head_raw"),
}
RAW_OUTPUT_NAMES = {
    roi_name: RAW_OUTPUT_BASE_NAMES[roi_name] + (RAW_OUTPUT_EDGE_NAMES[roi_name],)
    for roi_name in RAW_OUTPUT_BASE_NAMES
}
DEFAULT_INPUT_NAMES = ("roi0_input", "roi1_input")
TORCHSCRIPT_SUFFIXES = {".torchscript", ".ts", ".jit"}
CROSS_CLASS_MERGE_IOU_THRES = 0.8
CROSS_CLASS_ROI1_CONF_MARGIN = 0.03
VRU_MERGE_IOU_THRES = 0.3
VRU_MERGE_IN_MIN_AREA_THRES = 0.8
VRU_MERGE_SAME_ROI_IOU_THRES = 0.5
VRU_MERGE_SAME_ROI_IN_MIN_AREA_THRES = 0.85
VRU_MERGE_ROI1_CONF_MARGIN = 0.05
VRU_ASSOCIATION_CLASS_ID = -100
VRU_ASSOCIATION_TYPE_NAME = "vru"
VEHICLE_MERGE_CLASS_NAME_ALIASES = {
    "vehicle",
    "car",
    "suv",
    "pickup",
    "medium_car",
    "van",
    "bus",
    "truck",
    "tanker",
    "large_truck",
    "construction_vehicle",
    "special_vehicle",
    "unknown",
}
VRU_MERGE_CLASS_NAME_ALIASES = {
    "pedestrian",
    "bicyclist",
    "motorcyclist",
    "cyclist",
    "rider",
}
VIS_CLASS_NAME_ALIAS_GROUPS = {
    "ped": {"pedestrian", "person"},
    "person": {"pedestrian", "person"},
    "pedestrian": {"pedestrian", "person"},
    "rider": {"bicyclist", "motorcyclist", "cyclist", "tricyclist", "rider"},
    "cyclist": {"bicyclist", "motorcyclist", "cyclist", "tricyclist", "rider"},
    "biker": {"bicyclist", "motorcyclist", "cyclist", "rider"},
    "vru": {"pedestrian", "person", "bicyclist", "motorcyclist", "cyclist", "tricyclist", "rider"},
    "vehicle": set(VEHICLE_MERGE_CLASS_NAME_ALIASES) | {"vehicle", "tricycle"},
    "sign": {"traffic_sign", "trafficsign", "tsr", "roadsign", "road_sign", "small_sign", "sign"},
    "tsr": {"traffic_sign", "trafficsign", "tsr", "roadsign", "road_sign", "small_sign", "sign"},
}
DISTANCE_LABEL_MODE_CHOICES = ("depth", "box_depth", "euclidean", "xz", "lateral", "depth_lateral")
DISTANCE_LABEL_PANEL_CHOICES = ("2d", "3d", "all")
ALL_DISTANCE_LABEL_PANELS = frozenset({"2d", "3d"})
DISTANCE_LABEL_TEXT_COLORS = {
    "2d": (255, 255, 255),
    "3d": (255, 255, 255),
}


@dataclass
class ExportedROIModel:
    spec: ROIModelSpec
    names: dict[int, str]
    face_3d_classes: set[int]
    complete_3d_classes: set[int]
    fake_3d_classes: set[int]
    norm_scales_3d: dict[str, float]
    imgsz: tuple[int, int]
    anchor_points: np.ndarray
    stride_tensor: np.ndarray
    has_edge_head: bool


@dataclass
class ExportedInferenceContext:
    backend: str
    session: Optional[Any]
    torchscript_model: Optional[Any]
    input_names: tuple[str, str]
    output_names: tuple[str, ...]
    roi_models: list[ExportedROIModel]
    roi_crop_center_y_offsets_px: dict[str, float]
    roi_crop_center_y_offset_lookups: dict[str, "FrameAwareOffsetLookup"]
    classes: Optional[set[int]]
    vis_classes: Optional[set[int]]
    show_distance_label: bool
    distance_label_mode: str
    distance_label_panels: set[str]
    has_edge_head: bool
    edge_yaw_max_lateral_dist_m: float
    exported_model_path: str
    torch_device: Optional[str]
    attribute_models: Optional[Any]
    enable_cross_class_merge_prior: bool
    enable_vru_merge: bool


@dataclass
class ROISelectedPredictions:
    detections: np.ndarray
    preds_3d: np.ndarray
    preds_3d_fake: Optional[np.ndarray]
    preds_diff: Optional[np.ndarray]
    preds_edge: Optional[np.ndarray]
    anchors: np.ndarray
    strides: np.ndarray


@dataclass(frozen=True)
class FrameAwareOffsetLookup:
    default_offset_px: float
    frame_id_offsets: dict[int, float]
    frame_index_offsets: dict[int, float]
    frame_name_offsets: dict[str, float]

    def resolve(
        self,
        *,
        frame_index: int,
        frame_name: str,
        frame_meta: Optional[dict[str, Any]] = None,
    ) -> float:
        frame_id = get_video_frame_id(frame_meta) if frame_meta is not None else None
        if frame_id is not None and frame_id in self.frame_id_offsets:
            return float(self.frame_id_offsets[frame_id])
        if frame_index in self.frame_index_offsets:
            return float(self.frame_index_offsets[frame_index])
        if frame_name in self.frame_name_offsets:
            return float(self.frame_name_offsets[frame_name])
        frame_stem = Path(frame_name).stem
        if frame_stem in self.frame_name_offsets:
            return float(self.frame_name_offsets[frame_stem])
        return float(self.default_offset_px)


def _coerce_int_float_mapping(raw_value: Any, field_name: str) -> dict[int, float]:
    if raw_value in (None, ""):
        return {}
    if not isinstance(raw_value, dict):
        raise ValueError(f"{field_name} must be a JSON object mapping keys to float offsets.")
    result: dict[int, float] = {}
    for key, value in raw_value.items():
        result[int(key)] = float(value)
    return result


def _coerce_str_float_mapping(raw_value: Any, field_name: str) -> dict[str, float]:
    if raw_value in (None, ""):
        return {}
    if not isinstance(raw_value, dict):
        raise ValueError(f"{field_name} must be a JSON object mapping keys to float offsets.")
    return {str(key): float(value) for key, value in raw_value.items()}


def _load_frame_aware_offset_lookup(offset_map_path: str) -> FrameAwareOffsetLookup:
    offset_path = Path(offset_map_path).resolve()
    with offset_path.open("r", encoding="utf-8") as file:
        payload = json.load(file)

    if isinstance(payload, list):
        frame_id_offsets: dict[int, float] = {}
        frame_index_offsets: dict[int, float] = {}
        frame_name_offsets: dict[str, float] = {}
        for item in payload:
            if not isinstance(item, dict):
                raise ValueError(
                    f"Unsupported offset-map entry in {offset_path}: expected object items, got {type(item)!r}"
                )
            offset_value = float(item.get("offset_px", item.get("offset", 0.0)))
            if "frame_id" in item:
                frame_id_offsets[int(item["frame_id"])] = offset_value
            elif "frame_index" in item:
                frame_index_offsets[int(item["frame_index"])] = offset_value
            elif "frame_name" in item:
                frame_name_offsets[str(item["frame_name"])] = offset_value
            else:
                raise ValueError(
                    f"Unsupported offset-map entry in {offset_path}: each item needs frame_id, frame_index, or frame_name."
                )
        return FrameAwareOffsetLookup(
            default_offset_px=0.0,
            frame_id_offsets=frame_id_offsets,
            frame_index_offsets=frame_index_offsets,
            frame_name_offsets=frame_name_offsets,
        )

    if not isinstance(payload, dict):
        raise ValueError(
            f"Unsupported ROI crop-center offset map format in {offset_path}: expected object or list, got {type(payload)!r}"
        )

    has_structured_keys = any(
        key in payload
        for key in ("default_offset_px", "default_offset", "frame_id_offsets", "frame_index_offsets", "frame_name_offsets")
    )
    if has_structured_keys:
        return FrameAwareOffsetLookup(
            default_offset_px=float(payload.get("default_offset_px", payload.get("default_offset", 0.0))),
            frame_id_offsets=_coerce_int_float_mapping(payload.get("frame_id_offsets"), "frame_id_offsets"),
            frame_index_offsets=_coerce_int_float_mapping(payload.get("frame_index_offsets"), "frame_index_offsets"),
            frame_name_offsets=_coerce_str_float_mapping(payload.get("frame_name_offsets"), "frame_name_offsets"),
        )

    # Fallback: treat a flat JSON object as frame_id -> offset_px.
    return FrameAwareOffsetLookup(
        default_offset_px=0.0,
        frame_id_offsets=_coerce_int_float_mapping(payload, "flat_frame_id_offsets"),
        frame_index_offsets={},
        frame_name_offsets={},
    )


@dataclass
class DecodedPredictionView:
    decoded: Optional[Decoded3DPrediction]
    decoded_edge_heading: Optional[Decoded3DPrediction]
    center_uv: Optional[np.ndarray]
    yaw_rad: Optional[float]
    edge_yaw_rad: Optional[float]
    edge_yaw_confident: bool
    difficulty_label_lines: list[str]
    record: SerializedPredictionRecord


@dataclass
class MergedPredictionCandidate:
    confidence: float
    bbox_xyxy: np.ndarray
    roi_id: int
    cls_id: int
    record: SerializedPredictionRecord


@dataclass
class ExportedROIResult:
    prepared: PreparedROI
    names: dict[int, str]
    edge_head_available: bool
    detections: np.ndarray
    predictions: list[SerializedPredictionRecord]
    prediction_views: list[DecodedPredictionView]
    panel_2d: np.ndarray
    panel_3d: np.ndarray
    panel_3d_edge: np.ndarray


@dataclass
class ExportedFrameResult:
    payload: SerializedFramePayload
    grid: np.ndarray
    merged_grid: np.ndarray


def _roi_id_from_name(name: str) -> int:
    roi_name = str(name).strip().lower()
    if roi_name.startswith("roi"):
        roi_name = roi_name[3:]
    try:
        return int(roi_name or 0)
    except ValueError:
        return 0


def _normalize_class_filter_name(name: Any) -> str:
    normalized = re.sub(r"[^a-z0-9]+", "_", str(name or "").strip().lower())
    return normalized.strip("_")


def _build_class_aliases_by_id(class_map: dict[str, int]) -> dict[int, set[str]]:
    aliases_by_id: dict[int, set[str]] = {}
    for class_name, class_id in class_map.items():
        normalized = _normalize_class_filter_name(class_name)
        if normalized:
            aliases_by_id.setdefault(int(class_id), set()).add(normalized)
    return aliases_by_id


def _resolve_visualization_class_ids(
    class_map: dict[str, int],
    class_ids: Optional[Iterable[int]],
    class_names: Optional[Iterable[str]],
) -> Optional[set[int]]:
    requested_ids = [int(cls_id) for cls_id in class_ids or []]
    requested_names = [str(name).strip() for name in class_names or [] if str(name).strip()]
    if not requested_ids and not requested_names:
        return None

    resolved_ids = set(requested_ids)
    aliases_by_id = _build_class_aliases_by_id(class_map)
    unresolved_names: list[str] = []

    for requested_name in requested_names:
        normalized_name = _normalize_class_filter_name(requested_name)
        alias_candidates = {normalized_name}
        alias_candidates.update(VIS_CLASS_NAME_ALIAS_GROUPS.get(normalized_name, set()))
        matched_ids = {
            class_id
            for class_id, aliases in aliases_by_id.items()
            if aliases.intersection(alias_candidates)
        }
        if not matched_ids:
            unresolved_names.append(requested_name)
            continue
        resolved_ids.update(matched_ids)

    if unresolved_names:
        available_names = sorted({alias for aliases in aliases_by_id.values() for alias in aliases})
        supported_aliases = sorted(VIS_CLASS_NAME_ALIAS_GROUPS)
        raise ValueError(
            "Unknown --vis-class-names: "
            f"{', '.join(unresolved_names)}. "
            f"Available runtime names: {', '.join(available_names)}. "
            f"Supported group aliases: {', '.join(supported_aliases)}."
        )

    return resolved_ids


def _cls_id_matches_filter(cls_id: Any, classes: Optional[set[int]]) -> bool:
    if classes is None:
        return True
    try:
        return int(cls_id) in classes
    except (TypeError, ValueError):
        return False


def _resolve_distance_label_panels(panels: Optional[Iterable[str]]) -> set[str]:
    requested = [str(panel).strip().lower() for panel in panels or [] if str(panel).strip()]
    if not requested or "all" in requested:
        return set(ALL_DISTANCE_LABEL_PANELS)
    return {panel for panel in requested if panel in ALL_DISTANCE_LABEL_PANELS}


def _coerce_finite_xyz(values: Any) -> Optional[np.ndarray]:
    if values is None:
        return None
    arr = np.asarray(values, dtype=np.float64).reshape(-1)
    if arr.shape[0] < 3 or not np.isfinite(arr[:3]).all():
        return None
    return arr[:3]


def _z_distance_m(values: Any) -> Optional[float]:
    xyz = _coerce_finite_xyz(values)
    if xyz is None:
        return None
    return float(xyz[2])


def _lateral_distance_m(values: Any) -> Optional[float]:
    xyz = _coerce_finite_xyz(values)
    if xyz is None:
        return None
    return float(xyz[0])


def _euclidean_distance_m(values: Any) -> Optional[float]:
    xyz = _coerce_finite_xyz(values)
    if xyz is None:
        return None
    return float(np.linalg.norm(xyz))


def _xz_distance_m(values: Any) -> Optional[float]:
    xyz = _coerce_finite_xyz(values)
    if xyz is None:
        return None
    return float(math.hypot(float(xyz[0]), float(xyz[2])))


def _record_distance_value(record: SerializedPredictionRecord, mode: str) -> Optional[float]:
    field_name = {
        "depth": "depth_m",
        "box_depth": "box_depth_m",
        "euclidean": "euclidean_distance_m",
        "xz": "xz_distance_m",
        "lateral": "lateral_distance_m",
    }[mode]
    raw_value = record.get(field_name)
    if raw_value is not None:
        try:
            value = float(raw_value)
        except (TypeError, ValueError):
            value = None
        if value is not None and math.isfinite(value):
            return value

    if mode == "depth":
        return _z_distance_m(record.get("center_3d"))
    if mode == "box_depth":
        return _z_distance_m(record.get("box_center_xyz")) or _z_distance_m(record.get("center_3d"))
    if mode == "euclidean":
        return _euclidean_distance_m(record.get("center_3d"))
    if mode == "xz":
        return _xz_distance_m(record.get("center_3d"))
    if mode == "lateral":
        return _lateral_distance_m(record.get("center_3d"))
    return None


def _format_distance_label(value: Optional[float], mode: str) -> Optional[str]:
    if value is None or not math.isfinite(value):
        return None
    if mode != "lateral" and value <= 0.0:
        return None
    if mode == "lateral":
        return f"x={value:+.1f}m"
    label_prefix = {
        "depth": "z",
        "box_depth": "box_z",
        "euclidean": "r",
        "xz": "r_xz",
    }[mode]
    return f"{label_prefix}={value:.1f}m"


def _prediction_record_distance_label(record: SerializedPredictionRecord, mode: str) -> Optional[str]:
    if mode == "depth_lateral":
        labels = [
            _prediction_record_distance_label(record, component_mode)
            for component_mode in ("depth", "lateral")
        ]
        labels = [label for label in labels if label is not None]
        return " ".join(labels) if labels else None
    return _format_distance_label(_record_distance_value(record, mode), mode)


def _prediction_record_distance_label_lines(record: SerializedPredictionRecord, mode: str) -> list[str]:
    if mode == "depth_lateral":
        labels = []
        for component_mode in ("depth", "lateral"):
            label = _prediction_record_distance_label(record, component_mode)
            if label is not None:
                labels.append(label)
        return labels

    label = _prediction_record_distance_label(record, mode)
    return [] if label is None else [label]


def _prediction_views_to_distance_label_lines(
    prediction_views: Iterable["DecodedPredictionView"],
    mode: str,
) -> list[Optional[list[str]]]:
    lines: list[Optional[list[str]]] = []
    for view in prediction_views:
        labels = _prediction_record_distance_label_lines(view.record, mode)
        lines.append(None if not labels else labels)
    return lines


def _prediction_records_to_distance_label_lines(
    prediction_records: Iterable[SerializedPredictionRecord],
    mode: str,
) -> list[Optional[list[str]]]:
    lines: list[Optional[list[str]]] = []
    for record in prediction_records:
        labels = _prediction_record_distance_label_lines(record, mode)
        lines.append(None if not labels else labels)
    return lines


def _should_draw_distance_label(
    show_distance_label: bool,
    distance_label_panels: set[str],
    panel_name: str,
) -> bool:
    return bool(show_distance_label and panel_name in distance_label_panels)


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description=(
            "Run two-ROI inference for the exported raw-head merged ONNX/TorchScript model "
            "on one clip-export directory, video directory, eval set, or PDCL batch input."
        )
    )
    parser.add_argument("--case-dir", type=str, default="", help="Clip-export case directory containing images/ and calib/L2_calib/camera4.json")
    parser.add_argument("--video-case-dir", type=str, default="", help="Single video case directory containing sigmastar.1/camera4.bin and nearby camera4.json")
    parser.add_argument("--video-root-dir", type=str, default="", help="Root directory containing multiple video case subdirectories")
    parser.add_argument("--cncap-json-file", type=str, default="", help="CNCAP JSON file containing a list of sigmastar.1 paths under the values field")
    parser.add_argument("--cncap-values-key", type=str, default="values", help="Field name in --cncap-json-file that stores the sigmastar.1 paths")
    parser.add_argument(
        "--cncap-path-prefix-src",
        type=str,
        default=DEFAULT_CNCAP_PATH_PREFIX_SRC,
        help="Source path prefix in --cncap-json-file before rewriting to the local mounted path",
    )
    parser.add_argument(
        "--cncap-path-prefix-dst",
        type=str,
        default=DEFAULT_CNCAP_PATH_PREFIX_DST,
        help="Destination path prefix in --cncap-json-file after rewriting to the local mounted path",
    )
    parser.add_argument("--video-stride", type=int, default=1, help="Read every Nth frame from camera4.bin inputs")
    parser.add_argument("--frame-index-start", type=int, default=None, help="Inclusive frame-index lower bound for camera4.bin inputs.")
    parser.add_argument("--frame-index-end", type=int, default=None, help="Inclusive frame-index upper bound for camera4.bin inputs.")
    parser.add_argument("--frame-id-start", type=int, default=None, help="Inclusive frame-id lower bound for camera4.bin inputs.")
    parser.add_argument("--frame-id-end", type=int, default=None, help="Inclusive frame-id upper bound for camera4.bin inputs.")
    parser.add_argument("--target-frame-id", type=int, default=None, help="Issue frame id used to derive a local frame-id window.")
    parser.add_argument("--frame-before", type=int, default=0, help="Number of frames before --target-frame-id to include.")
    parser.add_argument("--frame-after", type=int, default=0, help="Number of frames after --target-frame-id to include.")
    parser.add_argument("--images-dir", type=str, default="", help="Input image directory when --case-dir is not used")
    parser.add_argument("--calib-file", type=str, default="", help="Input camera4.json path when --case-dir is not used")
    parser.add_argument(
        "--eval-dir",
        type=str,
        default="",
        help="Dataset root (<uuid>/images or <date>/<uuid>/images layout). Model is loaded once and reused across all cases.",
    )
    parser.add_argument(
        "--case-index-start",
        type=int,
        default=None,
        help="Inclusive start index within the sorted eval-dir case list. Only applies to --eval-dir mode.",
    )
    parser.add_argument(
        "--case-index-end",
        type=int,
        default=None,
        help="Exclusive end index within the sorted eval-dir case list. Only applies to --eval-dir mode.",
    )
    parser.add_argument(
        "--num-shards",
        type=int,
        default=1,
        help="Number of eval-dir shards. Only applies to --eval-dir mode; each worker should use a distinct --shard-index.",
    )
    parser.add_argument(
        "--shard-index",
        type=int,
        default=0,
        help="Zero-based shard index for --eval-dir mode. Must satisfy 0 <= --shard-index < --num-shards.",
    )
    parser.add_argument(
        "--shard-strategy",
        type=str,
        choices=("round_robin", "contiguous"),
        default="round_robin",
        help="How --eval-dir cases are partitioned across shards. round_robin balances uneven case lengths better.",
    )
    parser.add_argument(
        "--clip-list-file",
        type=str,
        default="",
        help="Optional text file: '<clip_id>' per line. Extra columns are ignored. Enables PDCL clip export + exported-model inference.",
    )
    parser.add_argument(
        "--mcap-file",
        type=str,
        default="",
        help="Optional local .mcap file. Enables direct mcap decode + exported-model inference without PDCL clip-id lookup.",
    )
    parser.add_argument(
        "--mcap-clip-id",
        type=str,
        default="",
        help="Optional task id used for --mcap-file outputs. Defaults to the mcap filename stem.",
    )
    parser.add_argument(
        "--mcap-date-name",
        type=str,
        default="",
        help="Optional date token used for --mcap-file output directory names. Defaults to a 14-digit filename stem when available.",
    )
    parser.add_argument(
        "--mcap-vehicle-name",
        type=str,
        default="",
        help="Optional vehicle token used for --mcap-file output directory names. Defaults to local_mcap.",
    )
    parser.add_argument(
        "--event-json-file",
        type=str,
        default="",
        help="Optional scene->event JSON file. Each record should contain an event id field that resolves to associated clips.",
    )
    parser.add_argument(
        "--scene",
        type=str,
        default="",
        help="Optional scene key to run from --event-json-file. Default: run all scenes.",
    )
    parser.add_argument(
        "--event-id-field",
        type=str,
        default="data_path",
        help="Field name in --event-json-file records that stores the event id.",
    )
    parser.add_argument(
        "--event-clip-ids-field",
        type=str,
        default="clips",
        help=(
            "Optional field name in --event-json-file records that stores clip-id lists directly. "
            "When present on a record, the script uses these clip ids and skips event-id lookup for that record."
        ),
    )
    parser.add_argument(
        "--condition-fields",
        nargs="*",
        default=[],
        help=(
            "Optional fields used to group --event-json-file records before selection, "
            "for example: --condition-fields 偏置 目标速度 自车速度"
        ),
    )
    parser.add_argument(
        "--max-records-per-condition",
        type=int,
        default=0,
        help="Max number of records kept for each unique --condition-fields combination in --event-json-file mode; 0 means all.",
    )
    parser.add_argument(
        "--condition-select-strategy",
        type=str,
        choices=("first",),
        default="first",
        help="Selection strategy used when --max-records-per-condition filters grouped --event-json-file records.",
    )
    parser.add_argument(
        "--selection-only",
        action="store_true",
        help="Resolve and select --event-json-file records, then save manifests without exporting clips or running inference.",
    )
    parser.add_argument(
        "--max-events",
        type=int,
        default=0,
        help="Max number of event records resolved from --event-json-file; 0 means all.",
    )
    parser.add_argument(
        "--event-request-timeout",
        type=float,
        default=60.0,
        help="HTTP timeout in seconds for event-id to clip-id resolution.",
    )
    parser.add_argument(
        "--event-request-retries",
        type=int,
        default=3,
        help="Max request attempts per event id when resolving uncached events.",
    )
    parser.add_argument(
        "--event-request-retry-backoff-sec",
        type=float,
        default=2.0,
        help="Base exponential backoff in seconds between event-id request retries.",
    )
    parser.add_argument(
        "--event-cache-file",
        type=str,
        default=str(FILE.parent / ".cache" / "event_clip_cache.json"),
        help="Local JSON cache file used for event-id to clip-id resolution.",
    )
    parser.add_argument(
        "--event-resolve-workers",
        type=int,
        default=4,
        help="Number of concurrent workers used to resolve uncached event ids.",
    )
    parser.add_argument(
        "--export-root",
        type=str,
        default=str(ROOT / "runs" / "pdcl_clip_exports"),
        help="Root directory used to save decoded clip frames and calibration in --clip-list-file mode.",
    )
    parser.add_argument(
        "--output-prefix",
        type=str,
        default="clip_export",
        help="Prefix used in each exported clip case directory name in --clip-list-file mode.",
    )
    parser.add_argument(
        "--camera-topic",
        type=str,
        default="camera4",
        help="Camera topic name in mcap for --clip-list-file or --mcap-file mode.",
    )
    parser.add_argument("--max-frames-per-clip", type=int, default=0, help="Max decoded frames per clip in --clip-list-file mode; 0 means all.")
    parser.add_argument("--skip-done", action="store_true", help="Skip clips already marked done in inference status file when using --clip-list-file.")
    parser.add_argument("--limit-clips", type=int, default=0, help="Limit number of clips in --clip-list-file mode; 0 means all.")
    parser.add_argument("--exported-model", type=str, default=str(DEFAULT_EXPORTED_MODEL), help="Merged raw-head exported model path (.onnx or .torchscript)")
    parser.add_argument(
        "--device",
        type=str,
        default="cuda",
        help="Optional inference device, e.g. cpu, cuda, cuda:0, cuda:1. Applies to TorchScript and guides ONNX provider selection.",
    )
    parser.add_argument(
        "--providers",
        nargs="*",
        default=None,
        help="Optional onnxruntime providers for ONNX models, e.g. --providers CUDAExecutionProvider CPUExecutionProvider",
    )
    parser.add_argument(
        "--enable-cross-class-merge-prior",
        action="store_true",
        help="Enable cross-class conflict resolution after same-class cross-ROI NMS for vehicle-group and pedestrian/rider-group detections.",
    )
    parser.add_argument(
        "--enable-vru-merge",
        action="store_true",
        help=(
            "Write an additional predictions/merge_vru view that treats pedestrian/rider detections "
            "as one VRU association group while preserving the original detection classes."
        ),
    )
    parser.add_argument("--enable-attr", action="store_true", help="Run optional attribute classifiers on merged detections.")
    parser.add_argument(
        "--car-attr-weights",
        type=str,
        default=str(DEFAULT_CAR_ATTR_WEIGHTS) if DEFAULT_CAR_ATTR_WEIGHTS.exists() else "",
        help="Vehicle attribute TorchScript weights.",
    )
    parser.add_argument(
        "--ped-attr-weights",
        type=str,
        default=str(DEFAULT_PED_ATTR_WEIGHTS) if DEFAULT_PED_ATTR_WEIGHTS.exists() else "",
        help="Pedestrian attribute TorchScript weights.",
    )
    parser.add_argument(
        "--tsr-attr-weights",
        type=str,
        default=str(DEFAULT_TSR_ATTR_WEIGHTS) if DEFAULT_TSR_ATTR_WEIGHTS.exists() else "",
        help="Traffic-sign attribute TorchScript weights.",
    )
    parser.add_argument(
        "--tsr-attr-label-file",
        type=str,
        default=str(DEFAULT_TSR_LABEL_FILE) if DEFAULT_TSR_LABEL_FILE.exists() else "",
        help="Optional TSR label file; falls back to raw class ids when omitted.",
    )
    parser.add_argument(
        "--attr-device",
        type=str,
        default=None,
        help="Device for attribute TorchScript models; defaults to --device.",
    )
    parser.add_argument(
        "--vehicle-attr-min-conf",
        type=float,
        default=0.25,
        help="Optional override for vehicle/tricycle attribute confidence gating. When left at the default, ROI detection confidence thresholds are used.",
    )
    parser.add_argument(
        "--vehicle-attr-min-side",
        type=float,
        default=10.0,
        help="Skip vehicle/tricycle attribute inference when max(box_w, box_h) is below this size.",
    )
    parser.add_argument(
        "--pedestrian-attr-min-conf",
        type=float,
        default=0.25,
        help="Optional override for pedestrian/rider attribute confidence gating. When left at the default, ROI detection confidence thresholds are used.",
    )
    parser.add_argument(
        "--pedestrian-attr-min-height",
        type=float,
        default=10.0,
        help="Skip pedestrian/rider attribute inference when box height is below this size.",
    )
    parser.add_argument(
        "--tsr-attr-min-conf",
        type=float,
        default=0.25,
        help="Optional override for TSR attribute confidence gating. When left at the default, ROI detection confidence thresholds are used.",
    )
    parser.add_argument(
        "--tsr-attr-min-side",
        type=float,
        default=10.0,
        help="Skip TSR attribute inference when max(box_w, box_h) is below this size.",
    )
    parser.add_argument(
        "--show-distance-label",
        action="store_true",
        help="Overlay per-object distance labels on visualization images.",
    )
    parser.add_argument(
        "--distance-label-mode",
        type=str,
        choices=DISTANCE_LABEL_MODE_CHOICES,
        default="depth",
        help="Distance metric used for --show-distance-label overlays.",
    )
    parser.add_argument(
        "--distance-label-panels",
        nargs="*",
        type=str,
        choices=DISTANCE_LABEL_PANEL_CHOICES,
        default=["all"],
        help="Panels that receive distance labels; defaults to all 2D and 3D panels.",
    )
    add_two_roi_inference_args(parser, DEFAULT_ROI_MODEL_PATHS)
    parser.add_argument(
        "--save-aggregate-predictions",
        action="store_true",
        help="Save aggregate predictions.json and predictions_merged.json in the case output root.",
    )
    parser.add_argument(
        "--skip-visualizations",
        action="store_true",
        help="Skip writing visualization images and only save per-frame predictions JSON outputs.",
    )
    parser.add_argument(
        "--output-dir",
        type=str,
        default=str(DEFAULT_OUTPUT_ROOT),
        help="Visualization output directory or root",
    )
    args = parser.parse_args()
    if args.frame_before < 0:
        parser.error("--frame-before must be greater than or equal to 0")
    if args.frame_after < 0:
        parser.error("--frame-after must be greater than or equal to 0")
    if args.max_records_per_condition < 0:
        parser.error("--max-records-per-condition must be greater than or equal to 0")
    if (
        args.frame_index_start is not None
        and args.frame_index_end is not None
        and args.frame_index_start > args.frame_index_end
    ):
        parser.error("--frame-index-start must be less than or equal to --frame-index-end")
    if (
        args.frame_id_start is not None
        and args.frame_id_end is not None
        and args.frame_id_start > args.frame_id_end
    ):
        parser.error("--frame-id-start must be less than or equal to --frame-id-end")
    return args


def resolve_video_frame_window(
    *,
    target_frame_id: Optional[int] = None,
    frame_before: int = 0,
    frame_after: int = 0,
    frame_index_start: Optional[int] = None,
    frame_index_end: Optional[int] = None,
    frame_id_start: Optional[int] = None,
    frame_id_end: Optional[int] = None,
) -> dict[str, Optional[int]]:
    resolved_frame_id_start = frame_id_start
    resolved_frame_id_end = frame_id_end
    if target_frame_id is not None:
        if resolved_frame_id_start is None:
            resolved_frame_id_start = max(0, target_frame_id - max(0, frame_before))
        if resolved_frame_id_end is None:
            resolved_frame_id_end = target_frame_id + max(0, frame_after)
    return {
        "target_frame_id": target_frame_id,
        "frame_index_start": frame_index_start,
        "frame_index_end": frame_index_end,
        "frame_id_start": resolved_frame_id_start,
        "frame_id_end": resolved_frame_id_end,
    }


def _default_providers() -> list[str]:
    if ort is None:
        raise ImportError("onnxruntime is required for ONNX inference but is not installed.")
    available = ort.get_available_providers()
    if "CUDAExecutionProvider" in available:
        return ["CUDAExecutionProvider", "CPUExecutionProvider"]
    return ["CPUExecutionProvider"]


def _normalize_device_arg(device: str) -> str:
    return str(device or "").strip().lower()


def _parse_cuda_device_id(device: str) -> int:
    normalized = _normalize_device_arg(device)
    if normalized in {"", "cuda"}:
        return 0
    match = re.fullmatch(r"cuda:(\d+)", normalized)
    if not match:
        raise ValueError(f"Unsupported device {device!r}. Expected cpu, cuda, or cuda:<index>.")
    return int(match.group(1))


def _resolve_torch_device(device: str) -> str:
    normalized = _normalize_device_arg(device)
    if not normalized:
        return "cuda" if torch is not None and torch.cuda.is_available() else "cpu"
    if normalized == "cpu":
        return "cpu"
    if normalized == "cuda":
        if torch is None or not torch.cuda.is_available():
            raise ValueError("Requested device 'cuda' but CUDA is not available in torch.")
        return "cuda"
    if re.fullmatch(r"cuda:\d+", normalized):
        if torch is None or not torch.cuda.is_available():
            raise ValueError(f"Requested device {device!r} but CUDA is not available in torch.")
        device_index = int(normalized.split(":", 1)[1])
        if torch.cuda.device_count() <= device_index:
            raise ValueError(
                f"Requested device {device!r} but only {torch.cuda.device_count()} CUDA device(s) are available."
            )
        return normalized
    raise ValueError(f"Unsupported device {device!r}. Expected cpu, cuda, or cuda:<index>.")


def _set_torch_default_cuda_device(device: str) -> None:
    normalized = _normalize_device_arg(device)
    if normalized == "cpu" or not normalized:
        return
    if torch is None or not torch.cuda.is_available():
        return

    if normalized == "cuda":
        device_index = 0
    elif re.fullmatch(r"cuda:\d+", normalized):
        device_index = int(normalized.split(":", 1)[1])
    else:
        return

    torch.cuda.set_device(device_index)


def _resolve_onnx_providers(device: str, providers: Optional[list[str]]) -> list[Any]:
    if ort is None:
        raise ImportError("onnxruntime is required for ONNX inference but is not installed.")

    if providers:
        return providers

    normalized = _normalize_device_arg(device)
    available = ort.get_available_providers()
    if not normalized:
        return _default_providers()
    if normalized == "cpu":
        if "CPUExecutionProvider" not in available:
            raise ValueError("Requested device 'cpu' but CPUExecutionProvider is not available in onnxruntime.")
        return ["CPUExecutionProvider"]
    if normalized == "cuda" or re.fullmatch(r"cuda:\d+", normalized):
        if "CUDAExecutionProvider" not in available:
            raise ValueError("Requested CUDA device for ONNX inference but CUDAExecutionProvider is not available.")
        device_id = _parse_cuda_device_id(normalized)
        providers_with_options: list[Any] = [("CUDAExecutionProvider", {"device_id": device_id})]
        if "CPUExecutionProvider" in available:
            providers_with_options.append("CPUExecutionProvider")
        return providers_with_options
    raise ValueError(f"Unsupported device {device!r}. Expected cpu, cuda, or cuda:<index>.")


def _parse_embedded_manifest(payload: Any) -> Optional[dict[str, Any]]:
    if payload in (None, "", b""):
        return None
    if isinstance(payload, bytes):
        payload = payload.decode("utf-8")
    manifest = json.loads(payload)
    return manifest if isinstance(manifest, dict) else None


def _load_export_manifest_sidecar(exported_model_path: Path) -> Optional[dict[str, Any]]:
    manifest_path = exported_model_path.with_suffix(".export.json")
    if not manifest_path.exists():
        return None
    with manifest_path.open("r", encoding="utf-8") as file:
        manifest = json.load(file)
    return manifest if isinstance(manifest, dict) else None


def _validate_export_manifest(exported_model_path: Path, manifest: Optional[dict[str, Any]]) -> None:
    if manifest is None:
        LOGGER.warning(f"Export manifest not found next to exported model: {exported_model_path.with_suffix('.export.json')}")
        return

    export_mode = str(manifest.get("export_mode", ""))
    if export_mode and export_mode != "raw_head_outputs":
        raise ValueError(
            f"{exported_model_path} reports export_mode={export_mode!r}, but this script expects raw_head_outputs. "
            "Please re-export the merged model in default raw-head mode."
        )


def _manifest_keep_edge_head(manifest: Optional[dict[str, Any]]) -> Optional[bool]:
    if manifest is None:
        return None

    keep_edge_head = manifest.get("keep_edge_head")
    if isinstance(keep_edge_head, bool):
        return keep_edge_head

    edge_head_mode = str(manifest.get("edge_head_mode", "")).strip().lower()
    if edge_head_mode == "keep":
        return True
    if edge_head_mode == "drop":
        return False
    return None


def _default_raw_output_names(keep_edge_head: bool = True) -> tuple[str, ...]:
    names = []
    for roi_name in ("roi0", "roi1"):
        names.extend(RAW_OUTPUT_BASE_NAMES[roi_name])
        if keep_edge_head:
            names.append(RAW_OUTPUT_EDGE_NAMES[roi_name])
    return tuple(names)


def _required_raw_output_names() -> tuple[str, ...]:
    names = []
    for roi_name in ("roi0", "roi1"):
        names.extend(RAW_OUTPUT_LEGACY_BASE_NAMES[roi_name])
    return tuple(names)


def _missing_required_raw_output_names(output_names: Iterable[str]) -> list[str]:
    available = {str(name) for name in output_names}
    return [name for name in _required_raw_output_names() if name not in available]


def _has_any_edge_raw_outputs(output_names: Iterable[str]) -> bool:
    available = {str(name) for name in output_names}
    return any(name in available for name in RAW_OUTPUT_EDGE_NAMES.values())


def _infer_raw_output_names_from_output_count(output_count: int) -> Optional[tuple[str, ...]]:
    for base_names in (RAW_OUTPUT_BASE_NAMES, RAW_OUTPUT_LEGACY_BASE_NAMES):
        for keep_edge_head in (False, True):
            candidate = []
            for roi_name in ("roi0", "roi1"):
                candidate.extend(base_names[roi_name])
                if keep_edge_head:
                    candidate.append(RAW_OUTPUT_EDGE_NAMES[roi_name])
            if len(candidate) == int(output_count):
                return tuple(candidate)
    return None


def _resolve_input_names_from_onnx(session: Any) -> tuple[str, str]:
    names = [item.name for item in session.get_inputs()]
    if len(names) != 2:
        raise ValueError(f"Expected exactly 2 ONNX inputs, got {names}")

    lower_map = {name.lower(): name for name in names}
    roi0_name = next((name for key, name in lower_map.items() if "roi0" in key), None)
    roi1_name = next((name for key, name in lower_map.items() if "roi1" in key), None)
    if roi0_name and roi1_name:
        return roi0_name, roi1_name
    return names[0], names[1]


def _resolve_model_backend(exported_model_path: Path) -> str:
    suffix = exported_model_path.suffix.lower()
    if suffix == ".onnx":
        return "onnx"
    if suffix in TORCHSCRIPT_SUFFIXES:
        return "torchscript"
    raise ValueError(
        f"Unsupported exported model suffix {exported_model_path.suffix!r}. Expected .onnx or .torchscript."
    )


def _resolve_manifest_input_names(manifest: Optional[dict[str, Any]]) -> tuple[str, str]:
    if manifest is None:
        return DEFAULT_INPUT_NAMES
    input_names = manifest.get("input_names", [])
    if isinstance(input_names, list) and len(input_names) == 2:
        return str(input_names[0]), str(input_names[1])
    return DEFAULT_INPUT_NAMES


def _resolve_output_names(manifest: Optional[dict[str, Any]], session: Optional[Any]) -> tuple[str, ...]:
    manifest_output_names = None if manifest is None else manifest.get("output_names")
    if isinstance(manifest_output_names, list) and manifest_output_names:
        return tuple(str(name) for name in manifest_output_names)
    if session is not None:
        return tuple(item.name for item in session.get_outputs())
    keep_edge_head = _manifest_keep_edge_head(manifest)
    return _default_raw_output_names(keep_edge_head=True if keep_edge_head is None else keep_edge_head)


def _resolve_input_size_wh(spec: ROIModelSpec, input_name: str, manifest: Optional[dict[str, Any]]) -> tuple[int, int]:
    if spec.imgsz is not None:
        return spec.imgsz
    manifest_sizes = None if manifest is None else manifest.get("input_sizes_wh")
    if isinstance(manifest_sizes, dict):
        size = manifest_sizes.get(input_name)
        if isinstance(size, (list, tuple)) and len(size) == 2:
            return int(size[0]), int(size[1])
    return infer_model_imgsz(spec.model_path, spec.imgsz)


def _load_torchscript_model(exported_model_path: Path, device: str = "") -> tuple[Any, Optional[dict[str, Any]], str]:
    if torch is None:
        raise ImportError("torch is required for TorchScript inference but is not installed.")
    resolved_device = _resolve_torch_device(device)
    _set_torch_default_cuda_device(resolved_device)
    extra_files = {"config.txt": ""}
    LOGGER.info(f"Loading TorchScript model from {exported_model_path}")
    LOGGER.info(f"Using torch device: {resolved_device}")
    model = torch.jit.load(str(exported_model_path), map_location=resolved_device, _extra_files=extra_files)
    model.eval()
    manifest = _parse_embedded_manifest(extra_files.get("config.txt"))
    return model, manifest, resolved_device


def build_exported_inference_context(args: argparse.Namespace) -> ExportedInferenceContext:
    exported_model_path = Path(args.exported_model).resolve()
    backend = _resolve_model_backend(exported_model_path)
    config_path = args.data_config or args.roi0_data or args.roi1_data
    runtime_metadata = load_runtime_metadata(config_path)
    vis_classes = _resolve_visualization_class_ids(
        runtime_metadata.class_map,
        getattr(args, "vis_classes", None),
        getattr(args, "vis_class_names", None),
    )
    roi_specs = build_roi_specs_from_args(args)
    roi_conf_by_id = {
        _roi_id_from_name(spec.name): float(spec.conf)
        for spec in roi_specs
    }

    sidecar_manifest = _load_export_manifest_sidecar(exported_model_path)
    session = None
    torchscript_model = None
    torch_device = None
    manifest = sidecar_manifest

    if backend == "onnx":
        if ort is None:
            raise ImportError("onnxruntime is required for ONNX inference but is not installed.")
        providers = _resolve_onnx_providers(args.device, args.providers)
        LOGGER.info(f"Loading ONNX model from {exported_model_path}")
        LOGGER.info(f"Using onnxruntime providers: {providers}")
        session = ort.InferenceSession(str(exported_model_path), providers=providers)
        input_names = _resolve_input_names_from_onnx(session)
    else:
        torchscript_model, embedded_manifest, torch_device = _load_torchscript_model(exported_model_path, args.device)
        if manifest is None:
            manifest = embedded_manifest
        input_names = _resolve_manifest_input_names(manifest)

    _validate_export_manifest(exported_model_path, manifest)
    output_names = _resolve_output_names(manifest, session)
    edge_output_names = {name for name in output_names if name in RAW_OUTPUT_EDGE_NAMES.values()}
    missing_required_outputs = _missing_required_raw_output_names(output_names)
    if missing_required_outputs:
        raise ValueError(
            f"{exported_model_path} is missing required raw-head outputs {missing_required_outputs}. "
            "The exported model must provide roi{0,1}_{boxes,scores,preds_3d}_head_raw tensors."
        )
    if not _has_any_edge_raw_outputs(output_names):
        LOGGER.info("Exported model does not include edge-head outputs; edge-yaw reconstruction will be disabled.")
    attribute_models = load_attribute_models(
        enable_attr=bool(getattr(args, "enable_attr", False)),
        class_names=runtime_metadata.names,
        roi_conf_by_id=roi_conf_by_id,
        car_weights=getattr(args, "car_attr_weights", None),
        ped_weights=getattr(args, "ped_attr_weights", None),
        tsr_weights=getattr(args, "tsr_attr_weights", None),
        tsr_label_file=getattr(args, "tsr_attr_label_file", None),
        device=getattr(args, "attr_device", None) or args.device,
        vehicle_min_conf=(
            None
            if float(getattr(args, "vehicle_attr_min_conf", 0.25)) == 0.25
            else float(getattr(args, "vehicle_attr_min_conf", 0.25))
        ),
        vehicle_min_side=float(getattr(args, "vehicle_attr_min_side", 10.0)),
        pedestrian_min_conf=(
            None
            if float(getattr(args, "pedestrian_attr_min_conf", 0.25)) == 0.25
            else float(getattr(args, "pedestrian_attr_min_conf", 0.25))
        ),
        pedestrian_min_height=float(getattr(args, "pedestrian_attr_min_height", 10.0)),
        tsr_min_conf=(
            None
            if float(getattr(args, "tsr_attr_min_conf", 0.25)) == 0.25
            else float(getattr(args, "tsr_attr_min_conf", 0.25))
        ),
        tsr_min_side=float(getattr(args, "tsr_attr_min_side", 10.0)),
    )

    roi_models = []
    for spec, input_name in zip(roi_specs, input_names):
        imgsz = _resolve_input_size_wh(spec, input_name, manifest)
        anchor_points, stride_tensor = build_anchor_cache(imgsz)
        roi_models.append(
            ExportedROIModel(
                spec=spec,
                names=runtime_metadata.names,
                face_3d_classes=runtime_metadata.face_3d_classes,
                complete_3d_classes=runtime_metadata.complete_3d_classes,
                fake_3d_classes=runtime_metadata.fake_3d_classes,
                norm_scales_3d=runtime_metadata.norm_scales_3d,
                imgsz=imgsz,
                anchor_points=anchor_points,
                stride_tensor=stride_tensor,
                has_edge_head=RAW_OUTPUT_EDGE_NAMES[spec.name.lower()] in edge_output_names,
            )
        )

    roi_crop_center_y_offsets_px = {
        roi_name: float(getattr(args, f"{roi_name}_crop_center_y_offset_px", 0.0))
        for roi_name in ("roi0", "roi1")
    }
    roi_crop_center_y_offset_lookups: dict[str, FrameAwareOffsetLookup] = {}
    for roi_name in ("roi0", "roi1"):
        offset_map_path = str(getattr(args, f"{roi_name}_crop_center_y_offset_map", "") or "").strip()
        if not offset_map_path:
            continue
        roi_crop_center_y_offset_lookups[roi_name] = _load_frame_aware_offset_lookup(offset_map_path)
    distance_label_panels = _resolve_distance_label_panels(getattr(args, "distance_label_panels", None))

    if vis_classes is not None:
        resolved_vis_names = [
            f"{cls_id}:{runtime_metadata.names.get(cls_id, str(cls_id))}"
            for cls_id in sorted(vis_classes)
        ]
        LOGGER.info("Visualization-only class filter enabled: %s", resolved_vis_names)
    if bool(getattr(args, "show_distance_label", False)):
        LOGGER.info(
            "Distance-label overlay enabled: mode=%s panels=%s",
            getattr(args, "distance_label_mode", "depth"),
            sorted(distance_label_panels),
        )

    return ExportedInferenceContext(
        backend=backend,
        session=session,
        torchscript_model=torchscript_model,
        input_names=input_names,
        output_names=output_names,
        roi_models=roi_models,
        roi_crop_center_y_offsets_px=roi_crop_center_y_offsets_px,
        roi_crop_center_y_offset_lookups=roi_crop_center_y_offset_lookups,
        classes=None if args.classes is None else set(int(cls_id) for cls_id in args.classes),
        vis_classes=vis_classes,
        show_distance_label=bool(getattr(args, "show_distance_label", False)),
        distance_label_mode=str(getattr(args, "distance_label_mode", "depth")),
        distance_label_panels=distance_label_panels,
        has_edge_head=bool(edge_output_names),
        edge_yaw_max_lateral_dist_m=float(args.edge_yaw_max_lateral_dist),
        exported_model_path=str(exported_model_path),
        torch_device=torch_device,
        attribute_models=attribute_models,
        enable_cross_class_merge_prior=bool(getattr(args, "enable_cross_class_merge_prior", False)),
        enable_vru_merge=bool(getattr(args, "enable_vru_merge", False)),
    )


def _image_to_onnx_input(image_bgr: np.ndarray) -> np.ndarray:
    image_rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
    array = np.ascontiguousarray(image_rgb.transpose(2, 0, 1), dtype=np.float32) / 256.0
    return array[None, ...]


def _image_to_torch_input(image_bgr: np.ndarray, device: str) -> Any:
    if torch is None:
        raise ImportError("torch is required for TorchScript inference but is not installed.")
    return torch.from_numpy(_image_to_onnx_input(image_bgr)).to(device=device)


def _run_onnx_frame(
    context: ExportedInferenceContext,
    prepared_rois: list[tuple[ExportedROIModel, PreparedROI]],
) -> dict[str, np.ndarray]:
    if len(prepared_rois) != len(context.input_names):
        raise ValueError(f"Expected {len(context.input_names)} prepared ROIs, got {len(prepared_rois)}")

    inputs = {
        input_name: _image_to_onnx_input(prepared.image)
        for input_name, (_, prepared) in zip(context.input_names, prepared_rois)
    }
    outputs = context.session.run(list(context.output_names), inputs)
    return dict(zip(context.output_names, outputs))


def _to_numpy_output(value: Any) -> np.ndarray:
    if torch is not None and isinstance(value, torch.Tensor):
        return value.detach().cpu().numpy()
    if isinstance(value, np.ndarray):
        return value
    return np.asarray(value)


def _run_torchscript_frame(
    context: ExportedInferenceContext,
    prepared_rois: list[tuple[ExportedROIModel, PreparedROI]],
) -> dict[str, np.ndarray]:
    if context.torchscript_model is None:
        raise ValueError("TorchScript model is not loaded.")
    if context.torch_device is None:
        raise ValueError("TorchScript device is not set.")
    if len(prepared_rois) != len(context.input_names):
        raise ValueError(f"Expected {len(context.input_names)} prepared ROIs, got {len(prepared_rois)}")

    inputs = tuple(_image_to_torch_input(prepared.image, context.torch_device) for _, prepared in prepared_rois)
    with torch.inference_mode():
        outputs = context.torchscript_model(*inputs)

    if isinstance(outputs, dict):
        output_map = {str(name): _to_numpy_output(value) for name, value in outputs.items()}
    else:
        if not isinstance(outputs, (tuple, list)):
            outputs = (outputs,)
        resolved_output_names = context.output_names
        if len(outputs) != len(resolved_output_names):
            inferred_output_names = _infer_raw_output_names_from_output_count(len(outputs))
            if inferred_output_names is None:
                raise ValueError(
                    f"Expected {len(context.output_names)} TorchScript outputs, got {len(outputs)}. "
                    "Please keep the export manifest next to the model or re-export with raw-head outputs."
                )
            LOGGER.warning(
                "TorchScript output count %d does not match the resolved output contract (%d); "
                "falling back to the built-in raw-head no-edge/with-edge naming contract.",
                len(outputs),
                len(resolved_output_names),
            )
            resolved_output_names = inferred_output_names
        output_map = {
            output_name: _to_numpy_output(output)
            for output_name, output in zip(resolved_output_names, outputs)
        }

    missing_outputs = _missing_required_raw_output_names(output_map)
    if missing_outputs:
        raise KeyError(f"Missing TorchScript outputs: {missing_outputs}")
    return output_map


def _run_exported_frame(
    context: ExportedInferenceContext,
    prepared_rois: list[tuple[ExportedROIModel, PreparedROI]],
) -> dict[str, np.ndarray]:
    if context.backend == "onnx":
        return _run_onnx_frame(context, prepared_rois)
    if context.backend == "torchscript":
        return _run_torchscript_frame(context, prepared_rois)
    raise ValueError(f"Unsupported backend: {context.backend}")


def _decode_exported_raw_outputs(
    roi_model: ExportedROIModel,
    raw_output_map: dict[str, np.ndarray],
) -> ROISelectedPredictions:
    roi_name = roi_model.spec.name.lower()
    full_output_names = RAW_OUTPUT_BASE_NAMES[roi_name]
    legacy_output_names = RAW_OUTPUT_LEGACY_BASE_NAMES[roi_name]
    required_output_names = full_output_names if all(name in raw_output_map for name in full_output_names) else legacy_output_names
    missing_outputs = [name for name in legacy_output_names if name not in raw_output_map]
    if missing_outputs:
        raise KeyError(f"Missing required {roi_name} raw outputs: {missing_outputs}")

    raw_boxes = raw_output_map[required_output_names[0]]
    raw_scores = raw_output_map[required_output_names[1]]
    raw_preds_3d = raw_output_map[required_output_names[2]]
    raw_preds_3d_fake = raw_output_map.get(full_output_names[3])
    raw_preds_diff = raw_output_map.get(full_output_names[4])
    raw_preds_edge = raw_output_map.get(RAW_OUTPUT_EDGE_NAMES[roi_name])
    decoded_boxes = decode_boxes_xyxy(
        np.ascontiguousarray(raw_boxes, dtype=np.float32),
        roi_model.anchor_points,
        roi_model.stride_tensor,
    )
    detections, topk_idx = postprocess_detections(
        decoded_boxes,
        np.ascontiguousarray(raw_scores, dtype=np.float32),
        roi_model.spec.max_det,
    )
    preds_3d = denorm_preds_3d(np.ascontiguousarray(raw_preds_3d, dtype=np.float32), roi_model.norm_scales_3d)
    preds_3d_fake = None
    if raw_preds_3d_fake is not None:
        preds_3d_fake = denorm_preds_3d(np.ascontiguousarray(raw_preds_3d_fake, dtype=np.float32), roi_model.norm_scales_3d)
    preds_diff = None if raw_preds_diff is None else np.ascontiguousarray(raw_preds_diff, dtype=np.float32)
    preds_edge = None
    if raw_preds_edge is not None:
        preds_edge = denorm_preds_edge(np.ascontiguousarray(raw_preds_edge, dtype=np.float32), roi_model.norm_scales_3d)
    preds_3d_selected, anchors_selected, strides_selected = select_topk_3d_metadata(
        preds_3d,
        roi_model.anchor_points,
        roi_model.stride_tensor,
        topk_idx,
    )
    preds_3d_fake_selected = None if preds_3d_fake is None else select_topk_branch(preds_3d_fake, topk_idx)
    preds_diff_selected = None if preds_diff is None else select_topk_branch(preds_diff, topk_idx)
    preds_edge_selected = None if preds_edge is None else select_topk_branch(preds_edge, topk_idx)
    return ROISelectedPredictions(
        detections[0],
        preds_3d_selected[0],
        None if preds_3d_fake_selected is None else preds_3d_fake_selected[0],
        None if preds_diff_selected is None else preds_diff_selected[0],
        None if preds_edge_selected is None else preds_edge_selected[0],
        anchors_selected[0],
        strides_selected[0],
    )


def _filter_roi_predictions(
    roi_model: ExportedROIModel,
    prepared: PreparedROI,
    predictions: ROISelectedPredictions,
    classes: Optional[set[int]],
) -> ROISelectedPredictions:
    preds_3d, preds_edge = restore_depth_scale(
        predictions.preds_3d,
        predictions.preds_edge,
        float(prepared.calib.get("depth_scale", 1.0)),
    )
    preds_3d_fake = None
    if predictions.preds_3d_fake is not None:
        preds_3d_fake, _ = restore_depth_scale(
            predictions.preds_3d_fake,
            None,
            float(prepared.calib.get("depth_scale", 1.0)),
        )
    detections, preds_3d, preds_edge, anchors, strides = filter_prediction_rows(
        predictions.detections,
        preds_3d,
        preds_edge,
        predictions.anchors,
        predictions.strides,
        conf_thres=roi_model.spec.conf,
        max_det=roi_model.spec.max_det,
        classes=classes,
    )
    keep_count = len(detections)
    preds_3d_fake = None if preds_3d_fake is None else preds_3d_fake[:keep_count]
    preds_diff = None if predictions.preds_diff is None else predictions.preds_diff[:keep_count]
    return ROISelectedPredictions(detections, preds_3d, preds_3d_fake, preds_diff, preds_edge, anchors, strides)


def _iter_prediction_rows(
    predictions: ROISelectedPredictions,
) -> Iterable[tuple[np.ndarray, np.ndarray, Optional[np.ndarray], Optional[np.ndarray], Optional[np.ndarray], np.ndarray, float]]:
    pred_edges: Iterable[Optional[np.ndarray]]
    if predictions.preds_edge is None:
        pred_edges = [None] * len(predictions.detections)
    else:
        pred_edges = predictions.preds_edge

    pred_3d_fake_rows: Iterable[Optional[np.ndarray]]
    if predictions.preds_3d_fake is None:
        pred_3d_fake_rows = [None] * len(predictions.detections)
    else:
        pred_3d_fake_rows = predictions.preds_3d_fake

    pred_diff_rows: Iterable[Optional[np.ndarray]]
    if predictions.preds_diff is None:
        pred_diff_rows = [None] * len(predictions.detections)
    else:
        pred_diff_rows = predictions.preds_diff

    for detection, pred_3d, pred_3d_fake, pred_diff, pred_edge, anchor_xy, stride in zip(
        predictions.detections,
        predictions.preds_3d,
        pred_3d_fake_rows,
        pred_diff_rows,
        pred_edges,
        predictions.anchors.transpose(1, 0),
        predictions.strides,
    ):
        yield detection, pred_3d, pred_3d_fake, pred_diff, pred_edge, anchor_xy, float(stride)


def _roi_bbox_to_original_image(
    bbox_xyxy: np.ndarray,
    crop_bounds: tuple[int, int, int, int],
    roi_img_w: int,
    roi_img_h: int,
) -> list[float]:
    """Convert bbox_xyxy from resized-ROI space back to original image space."""
    crop_x1, crop_y1, crop_x2, crop_y2 = crop_bounds
    scale_x = (crop_x2 - crop_x1) / roi_img_w
    scale_y = (crop_y2 - crop_y1) / roi_img_h
    return [
        float(bbox_xyxy[0]) * scale_x + crop_x1,
        float(bbox_xyxy[1]) * scale_y + crop_y1,
        float(bbox_xyxy[2]) * scale_x + crop_x1,
        float(bbox_xyxy[3]) * scale_y + crop_y1,
    ]


def _roi_point_to_original_image(
    point_uv: np.ndarray,
    crop_bounds: tuple[int, int, int, int],
    roi_img_w: int,
    roi_img_h: int,
) -> np.ndarray:
    crop_x1, crop_y1, crop_x2, crop_y2 = crop_bounds
    scale_x = (crop_x2 - crop_x1) / roi_img_w
    scale_y = (crop_y2 - crop_y1) / roi_img_h
    return np.asarray(
        [
            float(point_uv[0]) * scale_x + crop_x1,
            float(point_uv[1]) * scale_y + crop_y1,
        ],
        dtype=np.float32,
    )


def _original_bbox_to_roi_image(
    bbox_xyxy: np.ndarray,
    crop_bounds: tuple[int, int, int, int],
    roi_img_w: int,
    roi_img_h: int,
) -> list[float]:
    crop_x1, crop_y1, crop_x2, crop_y2 = crop_bounds
    scale_x = roi_img_w / (crop_x2 - crop_x1)
    scale_y = roi_img_h / (crop_y2 - crop_y1)
    return [
        (float(bbox_xyxy[0]) - crop_x1) * scale_x,
        (float(bbox_xyxy[1]) - crop_y1) * scale_y,
        (float(bbox_xyxy[2]) - crop_x1) * scale_x,
        (float(bbox_xyxy[3]) - crop_y1) * scale_y,
    ]


def _decode_edge_points_3d(points_2d: Any, depths: Any, calib: Any) -> Optional[np.ndarray]:
    if points_2d is None or depths is None:
        return None
    points_2d_arr = np.asarray(points_2d, dtype=np.float32)
    depths_arr = np.asarray(depths, dtype=np.float32).reshape(-1)
    if points_2d_arr.ndim != 2 or points_2d_arr.shape[1] != 2 or points_2d_arr.shape[0] != depths_arr.shape[0]:
        return None

    points_3d = []
    for point_2d, depth in zip(points_2d_arr, depths_arr):
        point_3d = back_project_2d_to_3d((float(point_2d[0]), float(point_2d[1])), float(depth), calib)
        if point_3d is None:
            return None
        points_3d.append(point_3d)
    if not points_3d:
        return None
    return np.asarray(points_3d, dtype=np.float32)


def _build_all_edge_predictions(
    pred_3d: np.ndarray,
    pred_edge: Optional[np.ndarray],
    anchor_xy: np.ndarray,
    stride: float,
    calib: Any,
    selected_face_types: tuple[int, ...],
    selected_face_is_partial: tuple[bool, ...],
) -> list[dict[str, Any]]:
    selection_by_face: dict[int, list[dict[str, Any]]] = {}
    for order, face_type in enumerate(selected_face_types):
        selection_by_face.setdefault(int(face_type), []).append(
            {
                "selected_order": int(order),
                "is_partial": bool(selected_face_is_partial[order]) if order < len(selected_face_is_partial) else False,
            }
        )

    edge_predictions = []
    for face_type, off in enumerate(FACE_OFFSETS_41):
        decoded_edge = decode_visible_face_edge_from_prediction(pred_edge, face_type, anchor_xy, stride)
        points_2d = None if decoded_edge is None else np.asarray(decoded_edge.points_2d, dtype=np.float32)
        depths = None if decoded_edge is None else np.asarray(decoded_edge.depths, dtype=np.float32)
        points_3d = _decode_edge_points_3d(points_2d, depths, calib)
        selection_entries = selection_by_face.get(int(face_type), [])
        edge_predictions.append(
            {
                "face_type": int(face_type),
                "face_name": _FACE_TYPE_NAMES.get(int(face_type), str(face_type)),
                "visibility_score": serialize_scalar(float(pred_3d[off + 5])),
                "points_2d": serialize_array(points_2d),
                "depths": serialize_array(depths),
                "points_3d": serialize_array(points_3d),
                "selected": bool(selection_entries),
                "selected_orders": [int(entry["selected_order"]) for entry in selection_entries],
                "selected_is_partial": [bool(entry["is_partial"]) for entry in selection_entries],
            }
        )
    return edge_predictions


def _build_edge_selection_payload(
    edge_selection: dict[str, Any],
    selected_face_types: tuple[int, ...],
    selected_face_is_partial: tuple[bool, ...],
) -> dict[str, Any]:
    return {
        "face_types": serialize_array(selected_face_types),
        "face_names": serialize_array([_FACE_TYPE_NAMES.get(int(face_type), str(face_type)) for face_type in selected_face_types]),
        "face_is_partial": serialize_array(selected_face_is_partial),
        "edge_points_2d": serialize_array(edge_selection.get("edge_points_2d")),
        "edge_points_3d": serialize_array(edge_selection.get("edge_points_3d")),
        "two_face_eligible": bool(edge_selection.get("two_face_eligible")),
        "lateral_distance_m": serialize_scalar(edge_selection.get("lateral_distance_m")),
        "lateral_ok": bool(edge_selection.get("lateral_ok")),
        "cut_side_visible_length_m": serialize_scalar(edge_selection.get("cut_side_visible_length_m")),
        "cut_side_visible_length_ratio": serialize_scalar(edge_selection.get("cut_side_visible_length_ratio")),
        "cut_side_visible_ratio_ok": bool(edge_selection.get("cut_side_visible_ratio_ok")),
        "is_valid": bool(edge_selection.get("is_valid")),
    }


def _build_prediction_view(
    roi_model: ExportedROIModel,
    prepared: PreparedROI,
    detection: np.ndarray,
    pred_3d: np.ndarray,
    pred_3d_fake: Optional[np.ndarray],
    pred_diff: Optional[np.ndarray],
    pred_edge: Optional[np.ndarray],
    anchor_xy: np.ndarray,
    stride: float,
    edge_yaw_max_lateral_dist_m: float,
    roi_id: int = 0,
    cam_to_ego: Optional[np.ndarray] = None,
) -> DecodedPredictionView:
    bbox_xyxy = detection[:4].astype(np.float32)
    cls_id = int(detection[5])
    is_face_3d_class = cls_id in roi_model.face_3d_classes
    is_complete_3d_class = cls_id in roi_model.complete_3d_classes
    is_3d_class = is_face_3d_class or is_complete_3d_class
    pred_3d_for_decode = pred_3d_fake if cls_id in roi_model.fake_3d_classes and pred_3d_fake is not None else pred_3d
    edge_head_available = bool(roi_model.has_edge_head)
    use_edge_predictions = bool(edge_head_available and is_face_3d_class and pred_edge is not None)
    img_h, img_w = prepared.image.shape[:2]
    original_bbox_xyxy = _roi_bbox_to_original_image(bbox_xyxy, prepared.crop_bounds, img_w, img_h)

    decoded = decode_3d_prediction(
        pred_3d_for_decode,
        anchor_xy,
        stride,
        prepared.calib,
        img_w,
        img_h,
        roi_model.face_3d_classes,
        roi_model.complete_3d_classes,
        cls_id,
        pred_edge_60=pred_edge,
        bbox_xyxy=bbox_xyxy,
    )
    face_type_for_attrs = None if decoded is None else decoded.visible_face_type
    attr_face_type = int(face_type_for_attrs) if is_face_3d_class and face_type_for_attrs is not None else None
    pred_attrs = (
        extract_3d_attrs_from_prediction(
            pred_3d_for_decode,
            anchor_xy,
            stride,
            prepared.calib,
            face_type=attr_face_type,
            pred_edge_60=pred_edge,
        )
        if is_3d_class
        else None
    )
    visible_face_types = () if decoded is None or decoded.visible_face_types is None else tuple(decoded.visible_face_types)
    if use_edge_predictions:
        edge_artifacts = build_edge_prediction_artifacts(
            base_decoded=decoded,
            pred_41=pred_3d_for_decode,
            pred_edge_60=pred_edge,
            anchor_xy=anchor_xy,
            stride=stride,
            bbox_xyxy=bbox_xyxy,
            calib=prepared.calib,
            img_w=img_w,
            img_h=img_h,
            whole_attrs=pred_attrs,
            edge_yaw_max_lateral_dist_m=edge_yaw_max_lateral_dist_m,
        )
        edge_selection = edge_artifacts["edge_selection"]
        edge_box = edge_artifacts["edge_box"]
        decoded_edge_heading = edge_artifacts["heading_decoded"]
        edge_yaw = edge_artifacts["edge_yaw"]
        edge_confident = edge_artifacts["edge_confident"]
    else:
        edge_selection = {
            "yaw": float("nan"),
            "face_types": (),
            "face_is_partial": (),
            "edge_points_2d": None,
            "edge_points_3d": None,
            "lateral_distance_m": None,
            "lateral_ok": False,
            "two_face_eligible": False,
            "is_valid": False,
        }
        edge_box = None
        decoded_edge_heading, edge_yaw, edge_confident = None, float("nan"), False

    selected_face_types = tuple(int(face_type) for face_type in edge_selection.get("face_types", ()))
    selected_face_is_partial = tuple(bool(flag) for flag in edge_selection.get("face_is_partial", ()))
    selected_edge_points_2d = edge_selection.get("edge_points_2d")
    all_edge_predictions = None
    edge_selection_payload = None
    if use_edge_predictions:
        all_edge_predictions = _build_all_edge_predictions(
            pred_3d,
            pred_edge,
            anchor_xy,
            stride,
            prepared.calib,
            selected_face_types,
            selected_face_is_partial,
        )
        edge_selection_payload = _build_edge_selection_payload(
            edge_selection,
            selected_face_types,
            selected_face_is_partial,
        )
    direct_box_selected_edges_2d = _project_selected_face_edges_from_box(
        None if decoded is None else decoded.corners_3d,
        selected_face_types,
        selected_face_is_partial,
        prepared.calib,
        img_w,
        img_h,
    )
    edge_box_face_types = tuple(int(face_type) for face_type in (edge_box or {}).get("face_types", ()) or selected_face_types)
    edge_box_selected_edges_2d = _project_selected_face_edges_from_box(
        None if decoded_edge_heading is None else decoded_edge_heading.corners_3d,
        edge_box_face_types,
        selected_face_is_partial,
        prepared.calib,
        img_w,
        img_h,
    )
    direct_edge_fit = _selected_edge_residual_stats(selected_edge_points_2d, direct_box_selected_edges_2d)
    edge_edge_fit = _selected_edge_residual_stats(selected_edge_points_2d, edge_box_selected_edges_2d)

    yaw_rad = None if pred_attrs is None else serialize_scalar(pred_attrs.yaw)
    edge_yaw_rad = serialize_scalar(edge_yaw)
    center_uv = None if pred_attrs is None else np.asarray(pred_attrs.uv, dtype=np.float32)
    center_3d = None if pred_attrs is None else pred_attrs.center
    dims = None if pred_attrs is None else pred_attrs.dims
    cut_cls = int(get_pred_cut_state(pred_3d))
    edge_vs_reg_yaw_rad = wrapped_angle_diff_rad(edge_yaw_rad, yaw_rad)
    box_center_3d = _decoded_box_center_3d(decoded) if is_3d_class else None
    depth_m = None if pred_attrs is None else serialize_scalar(pred_attrs.depth)
    box_depth_m = serialize_scalar(_z_distance_m(box_center_3d))
    lateral_distance_m = serialize_scalar(_lateral_distance_m(center_3d))
    euclidean_distance_m = serialize_scalar(_euclidean_distance_m(center_3d))
    xz_distance_m = serialize_scalar(_xz_distance_m(center_3d))
    xyzlhwyaw = _serialize_xyzlhwyaw_values(center_3d, dims, yaw_rad) if is_3d_class else None
    box_center_xyz = _serialize_xyz_values(box_center_3d) if is_3d_class else None
    center_3d_ego, yaw_ego_deg = _cam_pose_to_ego(center_3d, yaw_rad, cam_to_ego) if is_3d_class else (None, None)
    xyzlhwyaw_ego = _serialize_xyzlhwyaw_values(center_3d_ego, dims, yaw_ego_deg) if is_3d_class else None
    box_center_xyz_ego = _serialize_xyz_values(_cam_point_to_ego(box_center_3d, cam_to_ego)) if is_3d_class else None
    difficulty_logit = None
    difficulty_prob = None
    difficulty_label = None
    difficulty_name = None
    difficulty_label_lines: list[str] = []
    if pred_diff is not None:
        diff_arr = np.asarray(pred_diff, dtype=np.float32).reshape(-1)
        if diff_arr.size >= 1 and np.isfinite(diff_arr[0]):
            difficulty_logit = float(diff_arr[0])
            difficulty_prob = float(1.0 / (1.0 + np.exp(-difficulty_logit)))
            difficulty_label = int(difficulty_prob >= 0.5)
            difficulty_name = "hard" if difficulty_label == 1 else "easy"
            difficulty_label_lines = [f"diff hard_prob:{difficulty_prob:.2f}"]
    record: SerializedPredictionRecord = {
        "bbox_xyxy": serialize_array(bbox_xyxy),
        "confidence": float(detection[4]),
        "cls_id": cls_id,
        "cls_name": class_name(roi_model.names, cls_id),
        **({"difficulty_logit": difficulty_logit} if difficulty_logit is not None else {}),
        **({"difficulty_prob": difficulty_prob} if difficulty_prob is not None else {}),
        **({"difficulty_label": difficulty_label} if difficulty_label is not None else {}),
        **({"difficulty_name": difficulty_name} if difficulty_name is not None else {}),
        "edge_head_available": edge_head_available,
        **({"xyzlhwyaw": xyzlhwyaw} if xyzlhwyaw is not None else {}),
        **({"xyzlhwyaw_ego": xyzlhwyaw_ego} if xyzlhwyaw_ego is not None else {}),
        **({"box_center_xyz": box_center_xyz} if box_center_xyz is not None else {}),
        **({"box_center_xyz_ego": box_center_xyz_ego} if box_center_xyz_ego is not None else {}),
        **({"depth_m": depth_m} if depth_m is not None else {}),
        **({"box_depth_m": box_depth_m} if box_depth_m is not None else {}),
        **({"lateral_distance_m": lateral_distance_m} if lateral_distance_m is not None else {}),
        **({"euclidean_distance_m": euclidean_distance_m} if euclidean_distance_m is not None else {}),
        **({"xz_distance_m": xz_distance_m} if xz_distance_m is not None else {}),
        **({"yaw_rad": yaw_rad} if yaw_rad is not None else {}),
        **({"center_uv": serialize_array(center_uv)} if center_uv is not None else {}),
        **({"center_3d": serialize_array(center_3d)} if center_3d is not None else {}),
        **({"dims": serialize_array(dims)} if dims is not None else {}),
        "cut_cls": cut_cls,
        "roi_id": roi_id,
        "visible_face_type": None if decoded is None else serialize_scalar(decoded.visible_face_type),
        "visible_face_count": len(visible_face_types),
        "visible_face_types": None if decoded is None else serialize_array(visible_face_types),
        "crop_bounds": list(prepared.crop_bounds),
        "original_bbox_xyxy": original_bbox_xyxy,
    }
    if edge_head_available:
        record.update(
            {
                "edge_yaw_rad": edge_yaw_rad,
                "edge_yaw_confident": bool(edge_confident),
                "edge_yaw_lateral_distance_m": serialize_scalar(edge_selection.get("lateral_distance_m")),
                "edge_yaw_lateral_ok": bool(edge_selection.get("lateral_ok")),
                "edge_yaw_two_face_eligible": bool(edge_selection.get("two_face_eligible")),
                "edge_yaw_selected_face_types": serialize_array(selected_face_types),
                "edge_yaw_selected_face_is_partial": serialize_array(selected_face_is_partial),
                "edge_vs_reg_yaw_rad": serialize_scalar(edge_vs_reg_yaw_rad),
                "selected_edge_direct_box_fit_available": bool(direct_edge_fit["available"]),
                "selected_edge_direct_box_fit_mean_px": serialize_scalar(direct_edge_fit["mean_px"]),
                "selected_edge_direct_box_fit_max_px": serialize_scalar(direct_edge_fit["max_px"]),
                "selected_edge_direct_box_fit_per_face_mean_px": serialize_array(direct_edge_fit["per_face_mean_px"]),
                "selected_edge_edgeyaw_box_fit_available": bool(edge_edge_fit["available"]),
                "selected_edge_edgeyaw_box_fit_mean_px": serialize_scalar(edge_edge_fit["mean_px"]),
                "selected_edge_edgeyaw_box_fit_max_px": serialize_scalar(edge_edge_fit["max_px"]),
                "selected_edge_edgeyaw_box_fit_per_face_mean_px": serialize_array(edge_edge_fit["per_face_mean_px"]),
                "selected_edge_fit_gain_px": serialize_scalar(
                    None
                    if direct_edge_fit["mean_px"] is None or edge_edge_fit["mean_px"] is None
                    else float(direct_edge_fit["mean_px"]) - float(edge_edge_fit["mean_px"])
                ),
                "edge_box_center_3d": None if edge_box is None else serialize_array(edge_box["center"]),
                "edge_box_dims": None if edge_box is None else serialize_array(edge_box["dims"]),
                "edge_box_length_m": serialize_scalar(None if edge_box is None else edge_box.get("side_length_m")),
                "edge_box_width_m": serialize_scalar(None if edge_box is None else edge_box.get("width_m")),
                "edge_box_mode": None if edge_box is None else str(edge_box.get("mode")),
                "edge_box_length_source": None if edge_box is None else edge_box.get("length_source"),
                "edge_box_width_source": None if edge_box is None else edge_box.get("width_source"),
            }
        )
        if is_face_3d_class:
            record.update(
                {
                    "all_edge_predictions": all_edge_predictions,
                    "edge_selection": edge_selection_payload,
                }
            )

    return DecodedPredictionView(
        decoded=decoded,
        decoded_edge_heading=decoded_edge_heading,
        center_uv=center_uv,
        yaw_rad=yaw_rad,
        edge_yaw_rad=edge_yaw_rad,
        edge_yaw_confident=bool(edge_confident),
        difficulty_label_lines=difficulty_label_lines,
        record=record,
    )


def _draw_decoded_prediction(
    panel: np.ndarray,
    prepared: PreparedROI,
    decoded: Optional[Decoded3DPrediction],
    center_uv: Optional[np.ndarray],
    heading_lines: list[tuple[str, tuple[int, int, int]]],
) -> None:
    if decoded is None or decoded.corners_3d is None:
        return

    draw_3d_box(
        panel,
        decoded.corners_3d,
        prepared.calib,
        decoded.face_center_2d,
        decoded.face_color,
        edge_points_2d=decoded.edge_points_2d,
        edge_color=(0, 255, 0),
        thickness=1,
    )
    if heading_lines:
        draw_heading_lines(panel, center_uv, heading_lines)


def _render_3d_panel(
    prepared: PreparedROI,
    prediction_views: list[DecodedPredictionView],
    use_edge_heading: bool,
    show_distance_label: bool,
    distance_label_mode: str,
) -> np.ndarray:
    panel = prepared.image.copy()
    yaw_label = "edge" if use_edge_heading else "reg"
    yaw_color = (0, 255, 0) if use_edge_heading else (0, 255, 255)

    for view in prediction_views:
        decoded = view.decoded_edge_heading if use_edge_heading else view.decoded
        yaw_rad = view.edge_yaw_rad if use_edge_heading else view.yaw_rad
        if use_edge_heading and not view.edge_yaw_confident:
            continue
        heading_lines = []
        if yaw_rad is not None:
            heading_lines.append((f"{yaw_label} {math.degrees(yaw_rad):.1f}", yaw_color))
        if show_distance_label:
            for distance_label in _prediction_record_distance_label_lines(view.record, distance_label_mode):
                heading_lines.append((distance_label, DISTANCE_LABEL_TEXT_COLORS["3d"]))
        _draw_decoded_prediction(panel, prepared, decoded, view.center_uv, heading_lines)

    return panel


def _edge_batch_list(edge_points_2d: Any) -> list[np.ndarray]:
    if edge_points_2d is None:
        return []
    arr = np.asarray(edge_points_2d, dtype=np.float32)
    if arr.ndim == 2:
        return [arr]
    if arr.ndim == 3:
        return [arr[index] for index in range(arr.shape[0])]
    return []


def _project_selected_face_edges_from_box(
    corners_3d: Any,
    face_types: tuple[int, ...],
    face_is_partial: tuple[bool, ...],
    calib: RawCameraCalib,
    img_w: int,
    img_h: int,
) -> Optional[np.ndarray]:
    if corners_3d is None:
        return None
    projected_edges = []
    for face_type, is_partial in zip(face_types, face_is_partial):
        if bool(is_partial):
            _, points_2d = project_partial_face_bottom_edge(corners_3d, int(face_type), calib, img_w, img_h, num_samples=5)
        else:
            _, points_2d = project_face_bottom_edge(corners_3d, int(face_type), calib, num_samples=5)
        if points_2d is None:
            return None
        projected_edges.append(np.asarray(points_2d, dtype=np.float32))
    if not projected_edges:
        return None
    if len(projected_edges) == 1:
        return projected_edges[0]
    return np.stack(projected_edges, axis=0)


def _selected_edge_residual_stats(reference_edge_points_2d: Any, projected_edge_points_2d: Any) -> dict[str, Any]:
    reference_batches = _edge_batch_list(reference_edge_points_2d)
    projected_batches = _edge_batch_list(projected_edge_points_2d)
    if not reference_batches or len(reference_batches) != len(projected_batches):
        return {"available": False, "mean_px": None, "max_px": None, "per_face_mean_px": None}

    per_face_mean = []
    all_residuals = []
    for ref_points, proj_points in zip(reference_batches, projected_batches):
        if ref_points.shape != proj_points.shape or ref_points.ndim != 2 or ref_points.shape[1] != 2:
            return {"available": False, "mean_px": None, "max_px": None, "per_face_mean_px": None}
        residuals = np.linalg.norm(np.asarray(ref_points, dtype=np.float32) - np.asarray(proj_points, dtype=np.float32), axis=1)
        per_face_mean.append(float(np.mean(residuals)))
        all_residuals.append(residuals)

    flat = np.concatenate(all_residuals, axis=0) if all_residuals else np.zeros((0,), dtype=np.float32)
    if flat.size == 0:
        return {"available": False, "mean_px": None, "max_px": None, "per_face_mean_px": None}
    return {
        "available": True,
        "mean_px": float(np.mean(flat)),
        "max_px": float(np.max(flat)),
        "per_face_mean_px": [float(value) for value in per_face_mean],
    }


def _run_single_roi_exported(
    roi_model: ExportedROIModel,
    prepared: PreparedROI,
    raw_output_map: dict[str, np.ndarray],
    classes: Optional[set[int]],
    edge_yaw_max_lateral_dist_m: float,
    cam_to_ego: Optional[np.ndarray] = None,
) -> ExportedROIResult:
    roi_id = _roi_id_from_name(roi_model.spec.name)
    predictions = _decode_exported_raw_outputs(roi_model, raw_output_map)
    predictions = _filter_roi_predictions(roi_model, prepared, predictions, classes)
    prediction_views = [
        _build_prediction_view(
            roi_model,
            prepared,
            detection,
            pred_3d,
            pred_3d_fake,
            pred_diff,
            pred_edge,
            anchor_xy,
            stride,
            edge_yaw_max_lateral_dist_m,
            roi_id=roi_id,
            cam_to_ego=cam_to_ego,
        )
        for detection, pred_3d, pred_3d_fake, pred_diff, pred_edge, anchor_xy, stride in _iter_prediction_rows(predictions)
    ]
    placeholder_panel = prepared.image.copy()

    return ExportedROIResult(
        prepared=prepared,
        names=roi_model.names,
        edge_head_available=roi_model.has_edge_head,
        detections=predictions.detections,
        predictions=[view.record for view in prediction_views],
        prediction_views=prediction_views,
        panel_2d=placeholder_panel.copy(),
        panel_3d=placeholder_panel.copy(),
        panel_3d_edge=placeholder_panel.copy(),
    )


_FACE_TYPE_NAMES: dict[int, str] = {0: "front", 1: "tail", 2: "left", 3: "right"}


def _serialize_xyz_values(values: Any) -> Optional[list[str]]:
    if values is None:
        return None
    arr = np.asarray(values, dtype=np.float64).reshape(-1)
    if arr.shape[0] < 3 or not np.isfinite(arr[:3]).all():
        return None
    return [str(float(arr[0])), str(float(arr[1])), str(float(arr[2]))]


def _serialize_xyzlhwyaw_values(
    center_3d: Any,
    dims: Any,
    yaw_value: Optional[float],
) -> Optional[list[str]]:
    center_arr = None if center_3d is None else np.asarray(center_3d, dtype=np.float64).reshape(-1)
    dims_arr = None if dims is None else np.asarray(dims, dtype=np.float64).reshape(-1)
    if center_arr is None or dims_arr is None or yaw_value is None:
        return None
    if center_arr.shape[0] < 3 or dims_arr.shape[0] < 3:
        return None
    yaw_float = float(yaw_value)
    if not np.isfinite(center_arr[:3]).all() or not np.isfinite(dims_arr[:3]).all() or not math.isfinite(yaw_float):
        return None
    return [
        str(float(center_arr[0])),
        str(float(center_arr[1])),
        str(float(center_arr[2])),
        str(float(dims_arr[0])),
        str(float(dims_arr[1])),
        str(float(dims_arr[2])),
        str(yaw_float),
    ]


def _decoded_box_center_3d(decoded: Optional[Decoded3DPrediction]) -> Optional[np.ndarray]:
    if decoded is None or decoded.corners_3d is None:
        return None
    corners = np.asarray(decoded.corners_3d, dtype=np.float64)
    if corners.shape != (8, 3) or not np.isfinite(corners).all():
        return None
    return corners.mean(axis=0).astype(np.float32)


def _raw_calib_angle_to_degrees(raw_calib: Optional[RawCameraCalib], key: str) -> Optional[float]:
    if raw_calib is None:
        return None
    value = raw_calib.get(key)
    if value is None:
        return None
    angle = float(value)
    if not math.isfinite(angle):
        return None
    angle_unit = str(raw_calib.get("angle_unit", "degrees")).strip().lower()
    if angle_unit == "radians":
        return float(math.degrees(angle))
    return angle


def _build_cam_to_ego_transform(raw_calib: Optional[RawCameraCalib]) -> Optional[np.ndarray]:
    if raw_calib is None:
        return None

    roll_deg = _raw_calib_angle_to_degrees(raw_calib, "roll")
    pitch_deg = _raw_calib_angle_to_degrees(raw_calib, "pitch")
    yaw_deg = _raw_calib_angle_to_degrees(raw_calib, "yaw")
    pos = raw_calib.get("pos")
    if isinstance(pos, dict):
        pos = [pos.get("x", 0.0), pos.get("y", 0.0), pos.get("z", 0.0)]
    if roll_deg is None or pitch_deg is None or yaw_deg is None or pos is None:
        return None

    pos_arr = np.asarray(pos, dtype=np.float64).reshape(-1)
    if pos_arr.shape[0] < 3 or not np.isfinite(pos_arr[:3]).all():
        return None

    roll_rad = math.radians(roll_deg)
    pitch_rad = math.radians(pitch_deg)
    yaw_rad = math.radians(yaw_deg)
    su, cu = math.sin(roll_rad), math.cos(roll_rad)
    sv, cv = math.sin(pitch_rad), math.cos(pitch_rad)
    sw, cw = math.sin(yaw_rad), math.cos(yaw_rad)

    rot = np.array(
        [
            [cv * cw, su * sv * cw - cu * sw, su * sw + cu * sv * cw],
            [cv * sw, cu * cw + su * sv * sw, cu * sv * sw - su * cw],
            [-sv, su * cv, cu * cv],
        ],
        dtype=np.float64,
    ).T
    rot_yxz = np.array([[0.0, -1.0, 0.0], [0.0, 0.0, -1.0], [1.0, 0.0, 0.0]], dtype=np.float64)
    rot_ego_to_cam = rot_yxz @ rot
    trans_ego_to_cam = -1.0 * (rot_ego_to_cam @ pos_arr[:3])

    ego_to_cam = np.eye(4, dtype=np.float64)
    ego_to_cam[:3, :3] = rot_ego_to_cam
    ego_to_cam[:3, 3] = trans_ego_to_cam
    return np.linalg.inv(ego_to_cam)


def _cam_point_to_ego(point_3d: Any, cam_to_ego: Optional[np.ndarray]) -> Optional[np.ndarray]:
    if cam_to_ego is None or point_3d is None:
        return None
    point_arr = np.asarray(point_3d, dtype=np.float64).reshape(-1)
    if point_arr.shape[0] < 3 or not np.isfinite(point_arr[:3]).all():
        return None
    point_h = np.array([point_arr[0], point_arr[1], point_arr[2], 1.0], dtype=np.float64)
    transformed = cam_to_ego @ point_h
    return transformed[:3].astype(np.float32)


def _cam_pose_to_ego(
    center_3d: Any,
    yaw_rad: Optional[float],
    cam_to_ego: Optional[np.ndarray],
) -> tuple[Optional[np.ndarray], Optional[float]]:
    center_ego = _cam_point_to_ego(center_3d, cam_to_ego)
    if center_ego is None or yaw_rad is None or cam_to_ego is None:
        return center_ego, None

    yaw_float = float(yaw_rad)
    if not math.isfinite(yaw_float):
        return center_ego, None

    heading_cam = np.array([math.cos(yaw_float), 0.0, -math.sin(yaw_float)], dtype=np.float64)
    heading_ego = cam_to_ego[:3, :3] @ heading_cam
    yaw_ego_deg = float(np.degrees(np.arctan2(heading_ego[1], heading_ego[0])))
    return center_ego, yaw_ego_deg


def _build_prediction_json_entry(record: SerializedPredictionRecord) -> dict[str, Any]:
    xyzlhwyaw = record.get("xyzlhwyaw")
    if xyzlhwyaw is None:
        xyzlhwyaw = _serialize_xyzlhwyaw_values(record.get("center_3d"), record.get("dims"), record.get("yaw_rad"))
    xyzlhwyaw_ego = record.get("xyzlhwyaw_ego")
    box_center_xyz = record.get("box_center_xyz")
    box_center_xyz_ego = record.get("box_center_xyz_ego")

    bbox_xyxy = record.get("original_bbox_xyxy") or record.get("bbox_xyxy")
    box2d = None if bbox_xyxy is None else [str(float(v)) for v in bbox_xyxy]
    visible_face_type = record.get("visible_face_type")
    face_cls = None if visible_face_type is None else _FACE_TYPE_NAMES.get(int(visible_face_type))
    cut_cls = record.get("cut_cls")
    roi_id = record.get("roi_id", 0)
    edge_yaw_rad = record.get("edge_yaw_rad")
    edge_vs_reg_yaw_rad = record.get("edge_vs_reg_yaw_rad")
    edge_head_available = bool(record.get("edge_head_available", True))

    entry = {
        "type": str(record["cls_id"]),
        "type_name": record["cls_name"],
        "score": str(record["confidence"]),
        "roi_id": str(roi_id),
        "box2d": box2d,
        "face_cls": face_cls,
        "cut_cls": str(cut_cls) if cut_cls is not None else None,
        "edge_head_available": edge_head_available,
    }
    if xyzlhwyaw is not None:
        entry["xyzlhwyaw"] = xyzlhwyaw
    if xyzlhwyaw_ego is not None:
        entry["xyzlhwyaw_ego"] = xyzlhwyaw_ego
    if box_center_xyz is not None:
        entry["box_center_xyz"] = box_center_xyz
    if box_center_xyz_ego is not None:
        entry["box_center_xyz_ego"] = box_center_xyz_ego
    if edge_head_available:
        entry["edge_yaw_rad"] = str(float(edge_yaw_rad)) if edge_yaw_rad is not None else None
        entry["edge_yaw_confident"] = record.get("edge_yaw_confident")
        entry["edge_vs_reg_yaw_rad"] = str(float(edge_vs_reg_yaw_rad)) if edge_vs_reg_yaw_rad is not None else None
    if edge_head_available and (record.get("all_edge_predictions") is not None or record.get("edge_selection") is not None):
        entry["edge3d"] = {
            "all_edges": record.get("all_edge_predictions"),
            "selection": record.get("edge_selection"),
        }
    if record.get("attribute") is not None:
        entry["attribute"] = record.get("attribute")
    for field_name in (
        "association_group",
        "association_class_id",
        "association_type_name",
        "raw_cls_id",
        "raw_cls_name",
    ):
        if record.get(field_name) is not None:
            entry[field_name] = record.get(field_name)
    return entry


def _build_prediction_records_json(prediction_records: Iterable[SerializedPredictionRecord]) -> dict[str, Any]:
    output: dict[str, Any] = {}
    for det_index, record in enumerate(prediction_records):
        output[str(det_index)] = _build_prediction_json_entry(record)
    return output


def _build_per_frame_json_format(frame_payload: "SerializedFramePayload") -> dict[str, Any]:
    return _build_prediction_records_json(
        record
        for roi_payload in frame_payload.get("rois", {}).values()
        for record in roi_payload.get("predictions", [])
    )


def _build_per_frame_roi_json_format(frame_payload: "SerializedFramePayload", roi_name: str) -> dict[str, Any]:
    roi_payload = frame_payload.get("rois", {}).get(str(roi_name).lower()) or {}
    return _build_prediction_records_json(roi_payload.get("predictions", []))


def _build_per_frame_merged_json_format(frame_payload: "SerializedFramePayload") -> dict[str, Any]:
    merged_payload = frame_payload.get("merged") or {}
    return _build_prediction_records_json(merged_payload.get("predictions", []))


def _build_per_frame_vru_merged_json_format(frame_payload: "SerializedFramePayload") -> dict[str, Any]:
    merged_payload = frame_payload.get("merged_vru") or {}
    return _build_prediction_records_json(merged_payload.get("predictions", []))


def _build_full_image_calib(raw_calib: RawCameraCalib, image_w: int, image_h: int) -> ResizedCalib:
    return {
        "fx": float(raw_calib.get("focal_u", image_w)) if raw_calib else float(image_w),
        "fy": float(raw_calib.get("focal_v", image_h)) if raw_calib else float(image_h),
        "cx": float(raw_calib.get("cu", image_w / 2.0)) if raw_calib else float(image_w / 2.0),
        "cy": float(raw_calib.get("cv", image_h / 2.0)) if raw_calib else float(image_h / 2.0),
        "distort_coeffs": list(raw_calib.get("distort_coeffs", [])) if raw_calib else [],
        "depth_scale": 1.0,
    }


def _bbox_from_prediction_record(record: SerializedPredictionRecord) -> Optional[np.ndarray]:
    bbox_xyxy = record.get("original_bbox_xyxy") or record.get("bbox_xyxy")
    if bbox_xyxy is None:
        return None
    bbox = np.asarray(bbox_xyxy, dtype=np.float32).reshape(-1)
    if bbox.shape != (4,) or not np.isfinite(bbox).all():
        return None
    return bbox


def _iou_func(box1: np.ndarray, box2: np.ndarray) -> float:
    intersection = [
        max(float(box1[0]), float(box2[0])),
        max(float(box1[1]), float(box2[1])),
        min(float(box1[2]), float(box2[2])),
        min(float(box1[3]), float(box2[3])),
    ]
    if intersection[0] > intersection[2] or intersection[1] > intersection[3]:
        return 0.0
    area = (intersection[2] - intersection[0] + 1.0) * (intersection[3] - intersection[1] + 1.0)
    area1 = (float(box1[2]) - float(box1[0]) + 1.0) * (float(box1[3]) - float(box1[1]) + 1.0)
    area2 = (float(box2[2]) - float(box2[0]) + 1.0) * (float(box2[3]) - float(box2[1]) + 1.0)
    return area / float(area1 + area2 - area + 1e-7)


def _intersection_over_min_area(box1: np.ndarray, box2: np.ndarray) -> float:
    intersection = [
        max(float(box1[0]), float(box2[0])),
        max(float(box1[1]), float(box2[1])),
        min(float(box1[2]), float(box2[2])),
        min(float(box1[3]), float(box2[3])),
    ]
    if intersection[0] > intersection[2] or intersection[1] > intersection[3]:
        return 0.0
    area = (intersection[2] - intersection[0] + 1.0) * (intersection[3] - intersection[1] + 1.0)
    area1 = (float(box1[2]) - float(box1[0]) + 1.0) * (float(box1[3]) - float(box1[1]) + 1.0)
    area2 = (float(box2[2]) - float(box2[0]) + 1.0) * (float(box2[3]) - float(box2[1]) + 1.0)
    min_area = min(area1, area2)
    if min_area <= 0.0:
        return 0.0
    return area / float(min_area + 1e-7)


def _iou_func_in(box1: np.ndarray, box2: tuple[int, int, int, int]) -> float:
    intersection = [
        max(float(box1[0]), float(box2[0])),
        max(float(box1[1]), float(box2[1])),
        min(float(box1[2]), float(box2[2])),
        min(float(box1[3]), float(box2[3])),
    ]
    if intersection[0] > intersection[2] or intersection[1] > intersection[3]:
        return 0.0
    area = (intersection[2] - intersection[0] + 1.0) * (intersection[3] - intersection[1] + 1.0)
    area1 = (float(box1[2]) - float(box1[0]) + 1.0) * (float(box1[3]) - float(box1[1]) + 1.0)
    return area / float(area1 + 1e-7)


def _normalize_merge_class_name(name: Any) -> str:
    return re.sub(r"[^a-z0-9]+", "_", str(name or "").strip().lower()).strip("_")


def _candidate_class_name(candidate: MergedPredictionCandidate) -> str:
    return str(candidate.record.get("cls_name", candidate.cls_id))


def _candidate_merge_group(candidate: MergedPredictionCandidate) -> Optional[str]:
    normalized_name = _normalize_merge_class_name(_candidate_class_name(candidate))
    if normalized_name in VEHICLE_MERGE_CLASS_NAME_ALIASES:
        return "vehicle"
    if normalized_name in VRU_MERGE_CLASS_NAME_ALIASES:
        return "vru"
    return None


def _record_merge_group(record: SerializedPredictionRecord) -> Optional[str]:
    normalized_name = _normalize_merge_class_name(record.get("cls_name", record.get("cls_id")))
    if normalized_name in VEHICLE_MERGE_CLASS_NAME_ALIASES:
        return "vehicle"
    if normalized_name in VRU_MERGE_CLASS_NAME_ALIASES:
        return "vru"
    return None


def _candidate_is_vru(candidate: MergedPredictionCandidate) -> bool:
    return _candidate_merge_group(candidate) == "vru"


def _candidate_is_unknown_vehicle(candidate: MergedPredictionCandidate) -> bool:
    return _candidate_merge_group(candidate) == "vehicle" and _normalize_merge_class_name(_candidate_class_name(candidate)) == "unknown"


def _candidate_is_roi1_edge_dominated(
    candidate: MergedPredictionCandidate,
    roi_bounds_by_id: dict[int, tuple[int, int, int, int]],
) -> bool:
    roi_bounds = roi_bounds_by_id.get(candidate.roi_id)
    if roi_bounds is None:
        return False
    iou_i_roi = _iou_func_in(candidate.bbox_xyxy, roi_bounds)
    return bool(
        candidate.roi_id >= 1
        and iou_i_roi > 0.90
        and (
            abs(float(candidate.bbox_xyxy[0]) - roi_bounds[0]) <= 10.0
            or abs(float(candidate.bbox_xyxy[2]) - roi_bounds[2]) <= 10.0
            or abs(float(candidate.bbox_xyxy[3]) - roi_bounds[3]) <= 10.0
        )
    )


def _is_cross_class_merge_conflict(
    left: MergedPredictionCandidate,
    right: MergedPredictionCandidate,
) -> bool:
    if left.roi_id == right.roi_id or left.cls_id == right.cls_id:
        return False
    left_group = _candidate_merge_group(left)
    if left_group is None or left_group != _candidate_merge_group(right):
        return False
    return _iou_func(left.bbox_xyxy, right.bbox_xyxy) > CROSS_CLASS_MERGE_IOU_THRES


def _choose_cross_class_merge_winner(
    left: MergedPredictionCandidate,
    right: MergedPredictionCandidate,
    roi_bounds_by_id: dict[int, tuple[int, int, int, int]],
) -> MergedPredictionCandidate:
    left_is_unknown = _candidate_is_unknown_vehicle(left)
    right_is_unknown = _candidate_is_unknown_vehicle(right)
    if left_is_unknown != right_is_unknown:
        return right if left_is_unknown else left

    left_edge_dominated = _candidate_is_roi1_edge_dominated(left, roi_bounds_by_id)
    right_edge_dominated = _candidate_is_roi1_edge_dominated(right, roi_bounds_by_id)

    if left.roi_id != right.roi_id:
        roi1_candidate = left if left.roi_id > right.roi_id else right
        other_candidate = right if roi1_candidate is left else left
        roi1_edge_dominated = left_edge_dominated if roi1_candidate is left else right_edge_dominated
        if not roi1_edge_dominated and roi1_candidate.confidence >= other_candidate.confidence - CROSS_CLASS_ROI1_CONF_MARGIN:
            return roi1_candidate

    if left.confidence != right.confidence:
        return left if left.confidence > right.confidence else right
    if left_edge_dominated != right_edge_dominated:
        return right if left_edge_dominated else left
    if left.roi_id != right.roi_id:
        return left if left.roi_id > right.roi_id else right
    return left


def _resolve_cross_class_merge_candidates(
    candidates: list[MergedPredictionCandidate],
    roi_bounds_by_id: dict[int, tuple[int, int, int, int]],
) -> list[MergedPredictionCandidate]:
    objects = sorted(candidates, key=lambda candidate: candidate.confidence, reverse=True)
    suppressed = np.zeros((len(objects),), dtype=bool)

    for i in range(len(objects)):
        if suppressed[i]:
            continue
        for j in range(i + 1, len(objects)):
            if suppressed[j]:
                continue
            if not _is_cross_class_merge_conflict(objects[i], objects[j]):
                continue
            winner = _choose_cross_class_merge_winner(objects[i], objects[j], roi_bounds_by_id)
            if winner is objects[i]:
                suppressed[j] = True
                continue
            suppressed[i] = True
            break

    return [objects[index] for index in range(len(objects)) if not suppressed[index]]


def _is_vru_merge_conflict(
    left: MergedPredictionCandidate,
    right: MergedPredictionCandidate,
) -> bool:
    if not _candidate_is_vru(left) or not _candidate_is_vru(right):
        return False
    iou = _iou_func(left.bbox_xyxy, right.bbox_xyxy)
    in_min_area = _intersection_over_min_area(left.bbox_xyxy, right.bbox_xyxy)
    if left.roi_id == right.roi_id:
        return bool(
            iou > VRU_MERGE_SAME_ROI_IOU_THRES
            or in_min_area > VRU_MERGE_SAME_ROI_IN_MIN_AREA_THRES
        )
    return bool(iou > VRU_MERGE_IOU_THRES or in_min_area > VRU_MERGE_IN_MIN_AREA_THRES)


def _choose_vru_merge_winner(
    left: MergedPredictionCandidate,
    right: MergedPredictionCandidate,
    roi_bounds_by_id: dict[int, tuple[int, int, int, int]],
) -> MergedPredictionCandidate:
    left_edge_dominated = _candidate_is_roi1_edge_dominated(left, roi_bounds_by_id)
    right_edge_dominated = _candidate_is_roi1_edge_dominated(right, roi_bounds_by_id)

    if left.roi_id != right.roi_id:
        higher_roi_candidate = left if left.roi_id > right.roi_id else right
        other_candidate = right if higher_roi_candidate is left else left
        higher_roi_edge_dominated = left_edge_dominated if higher_roi_candidate is left else right_edge_dominated
        if (
            not higher_roi_edge_dominated
            and higher_roi_candidate.confidence >= other_candidate.confidence - VRU_MERGE_ROI1_CONF_MARGIN
        ):
            return higher_roi_candidate

    if left_edge_dominated != right_edge_dominated:
        return right if left_edge_dominated else left
    if left.confidence != right.confidence:
        return left if left.confidence > right.confidence else right
    if left.roi_id != right.roi_id:
        return left if left.roi_id > right.roi_id else right
    return left


def _resolve_vru_merge_candidates(
    candidates: list[MergedPredictionCandidate],
    roi_bounds_by_id: dict[int, tuple[int, int, int, int]],
) -> list[MergedPredictionCandidate]:
    objects = sorted(candidates, key=lambda candidate: candidate.confidence, reverse=True)
    suppressed = np.zeros((len(objects),), dtype=bool)

    for i in range(len(objects)):
        if suppressed[i]:
            continue
        for j in range(i + 1, len(objects)):
            if suppressed[j]:
                continue
            if not _is_vru_merge_conflict(objects[i], objects[j]):
                continue
            winner = _choose_vru_merge_winner(objects[i], objects[j], roi_bounds_by_id)
            if winner is objects[i]:
                suppressed[j] = True
                continue
            suppressed[i] = True
            break

    return [objects[index] for index in range(len(objects)) if not suppressed[index]]


def _annotate_vru_prediction_record(record: SerializedPredictionRecord) -> SerializedPredictionRecord:
    annotated = dict(record)
    annotated.setdefault("raw_cls_id", annotated.get("cls_id"))
    annotated.setdefault("raw_cls_name", annotated.get("cls_name"))
    annotated["association_group"] = VRU_ASSOCIATION_TYPE_NAME
    annotated["association_class_id"] = VRU_ASSOCIATION_CLASS_ID
    annotated["association_type_name"] = VRU_ASSOCIATION_TYPE_NAME
    return annotated


def _nms_for_scale_candidates(
    candidates: list[MergedPredictionCandidate],
    roi_bounds_by_id: dict[int, tuple[int, int, int, int]],
) -> list[MergedPredictionCandidate]:
    objects = sorted(candidates, key=lambda candidate: candidate.confidence, reverse=True)
    obj_len = len(objects)
    merge = np.ones((obj_len,), np.int32) * (-1)

    for i in range(obj_len):
        ibox = objects[i].bbox_xyxy
        iroi = objects[i].roi_id
        roi_bounds = roi_bounds_by_id.get(iroi)
        if roi_bounds is None:
            continue
        iou_i_roi = _iou_func_in(ibox, roi_bounds)
        if (
            iou_i_roi > 0.90
            and (
                abs(float(ibox[0]) - roi_bounds[0]) <= 10.0
                or abs(float(ibox[2]) - roi_bounds[2]) <= 10.0
                or abs(float(ibox[3]) - roi_bounds[3]) <= 10.0
            )
            and iroi >= 1
        ):
            merge[i] = 1

    for i in range(obj_len):
        if merge[i] >= 0:
            continue
        ibox = objects[i].bbox_xyxy
        iroi = objects[i].roi_id
        for j in range(i + 1, obj_len):
            if merge[j] >= 0:
                continue
            jbox = objects[j].bbox_xyxy
            jroi = objects[j].roi_id
            if iroi == jroi:
                continue

            roi_bounds = roi_bounds_by_id.get(max(iroi, jroi))
            if roi_bounds is None:
                continue

            iou = _iou_func(ibox, jbox)
            iou_in = _iou_func_in(jbox, ibox)
            iou_i_roi = _iou_func_in(ibox, roi_bounds)
            iou_j_roi = _iou_func_in(jbox, roi_bounds)

            if iou > 0.3:
                if iroi > jroi:
                    if iou_j_roi < iou_i_roi and iou_j_roi < 0.99:
                        merge[i] = 1
                    else:
                        merge[j] = 1
                else:
                    if iou_i_roi < iou_j_roi and iou_i_roi < 0.99:
                        merge[j] = 1
                    else:
                        merge[i] = 1
            elif iou_in > 0.8 and jroi != 0:
                merge[j] = 1

    return [objects[i] for i in range(obj_len) if merge[i] < 0]


def _build_frame_merged_payload(
    roi_results: list[ExportedROIResult],
    enable_cross_class_merge_prior: bool = False,
) -> SerializedMergedPayload:
    edge_head_available = any(bool(roi_result.edge_head_available) for roi_result in roi_results)
    roi_bounds_by_id: dict[int, tuple[int, int, int, int]] = {}
    grouped_candidates: dict[int, list[MergedPredictionCandidate]] = {}
    passthrough_records: list[SerializedPredictionRecord] = []

    for roi_result in sorted(roi_results, key=lambda result: _roi_id_from_name(result.prepared.name)):
        default_roi_id = _roi_id_from_name(roi_result.prepared.name)
        roi_bounds_by_id[default_roi_id] = tuple(int(v) for v in roi_result.prepared.crop_bounds)
        for record in roi_result.predictions:
            bbox_xyxy = _bbox_from_prediction_record(record)
            cls_id = record.get("cls_id")
            confidence = record.get("confidence")
            try:
                roi_id = int(record.get("roi_id", default_roi_id))
            except (TypeError, ValueError):
                roi_id = default_roi_id

            if bbox_xyxy is None or cls_id is None or confidence is None:
                passthrough_records.append(dict(record))
                continue

            grouped_candidates.setdefault(int(cls_id), []).append(
                MergedPredictionCandidate(
                    confidence=float(confidence),
                    bbox_xyxy=bbox_xyxy,
                    roi_id=roi_id,
                    cls_id=int(cls_id),
                    record=dict(record),
                )
            )

    merged_candidates: list[MergedPredictionCandidate] = []
    for cls_id in sorted(grouped_candidates):
        kept_candidates = _nms_for_scale_candidates(grouped_candidates[cls_id], roi_bounds_by_id)
        merged_candidates.extend(kept_candidates)

    if enable_cross_class_merge_prior:
        merged_candidates = _resolve_cross_class_merge_candidates(merged_candidates, roi_bounds_by_id)
    merged_predictions: list[SerializedPredictionRecord] = [candidate.record for candidate in merged_candidates]

    merged_predictions.extend(passthrough_records)
    merged_predictions.sort(key=lambda record: float(record.get("confidence", 0.0)), reverse=True)
    roi_bounds_payload = {
        f"roi{roi_id}": list(roi_bounds_by_id[roi_id])
        for roi_id in sorted(roi_bounds_by_id)
    }
    return {
        "method": (
            "cross_roi_nms_for_scale+cross_class_prior"
            if enable_cross_class_merge_prior
            else "cross_roi_nms_for_scale"
        ),
        "edge_head_available": edge_head_available,
        "roi_bounds": roi_bounds_payload,
        "predictions": merged_predictions,
    }


def _build_frame_vru_merged_payload(
    roi_results: list[ExportedROIResult],
    base_merged_payload: SerializedMergedPayload,
) -> SerializedMergedPayload:
    edge_head_available = any(bool(roi_result.edge_head_available) for roi_result in roi_results)
    roi_bounds_by_id: dict[int, tuple[int, int, int, int]] = {}
    vru_candidates: list[MergedPredictionCandidate] = []
    vru_passthrough_records: list[SerializedPredictionRecord] = []

    for roi_result in sorted(roi_results, key=lambda result: _roi_id_from_name(result.prepared.name)):
        default_roi_id = _roi_id_from_name(roi_result.prepared.name)
        roi_bounds_by_id[default_roi_id] = tuple(int(v) for v in roi_result.prepared.crop_bounds)
        for record in roi_result.predictions:
            if _record_merge_group(record) != "vru":
                continue

            bbox_xyxy = _bbox_from_prediction_record(record)
            cls_id = record.get("cls_id")
            confidence = record.get("confidence")
            try:
                roi_id = int(record.get("roi_id", default_roi_id))
            except (TypeError, ValueError):
                roi_id = default_roi_id

            if bbox_xyxy is None or cls_id is None or confidence is None:
                vru_passthrough_records.append(_annotate_vru_prediction_record(record))
                continue

            vru_candidates.append(
                MergedPredictionCandidate(
                    confidence=float(confidence),
                    bbox_xyxy=bbox_xyxy,
                    roi_id=roi_id,
                    cls_id=int(cls_id),
                    record=dict(record),
                )
            )

    kept_vru_records = [
        _annotate_vru_prediction_record(candidate.record)
        for candidate in _resolve_vru_merge_candidates(vru_candidates, roi_bounds_by_id)
    ]
    base_non_vru_records = [
        dict(record)
        for record in base_merged_payload.get("predictions", [])
        if _record_merge_group(record) != "vru"
    ]
    merged_predictions: list[SerializedPredictionRecord] = [
        *base_non_vru_records,
        *kept_vru_records,
        *vru_passthrough_records,
    ]
    merged_predictions.sort(key=lambda record: float(record.get("confidence", 0.0)), reverse=True)
    roi_bounds_payload = {
        f"roi{roi_id}": list(roi_bounds_by_id[roi_id])
        for roi_id in sorted(roi_bounds_by_id)
    }
    return {
        "method": "cross_roi_nms_for_scale+vru_association",
        "edge_head_available": edge_head_available,
        "roi_bounds": roi_bounds_payload,
        "predictions": merged_predictions,
    }


def _prediction_records_to_detections(prediction_records: Iterable[SerializedPredictionRecord]) -> np.ndarray:
    detections = []
    for record in prediction_records:
        bbox_xyxy = _bbox_from_prediction_record(record)
        cls_id = record.get("cls_id")
        confidence = record.get("confidence")
        if bbox_xyxy is None or cls_id is None or confidence is None:
            continue
        detections.append(
            [
                float(bbox_xyxy[0]),
                float(bbox_xyxy[1]),
                float(bbox_xyxy[2]),
                float(bbox_xyxy[3]),
                float(confidence),
                float(cls_id),
            ]
        )
    if not detections:
        return np.empty((0, 6), dtype=np.float32)
    return np.asarray(detections, dtype=np.float32)


def _prediction_records_to_detections_and_attributes(
    prediction_records: Iterable[SerializedPredictionRecord],
) -> tuple[np.ndarray, list[Any], list[SerializedPredictionRecord]]:
    detections = []
    attribute_results = []
    kept_prediction_records = []
    for record in prediction_records:
        bbox_xyxy = _bbox_from_prediction_record(record)
        cls_id = record.get("cls_id")
        confidence = record.get("confidence")
        if bbox_xyxy is None or cls_id is None or confidence is None:
            continue
        detections.append(
            [
                float(bbox_xyxy[0]),
                float(bbox_xyxy[1]),
                float(bbox_xyxy[2]),
                float(bbox_xyxy[3]),
                float(confidence),
                float(cls_id),
            ]
        )
        attribute_results.append(record.get("attribute"))
        kept_prediction_records.append(record)
    if not detections:
        return np.empty((0, 6), dtype=np.float32), [], []
    return np.asarray(detections, dtype=np.float32), attribute_results, kept_prediction_records


def _prediction_record_attribute_cache_key(
    record: SerializedPredictionRecord,
) -> Optional[tuple[int, int, int, int, int]]:
    bbox_xyxy = _bbox_from_prediction_record(record)
    cls_id = record.get("cls_id")
    if bbox_xyxy is None or cls_id is None:
        return None
    return (int(cls_id), *(int(round(float(value))) for value in bbox_xyxy))


def _build_attribute_inputs_from_prediction_records(
    prediction_records: list[SerializedPredictionRecord],
    attr_cache: dict[tuple[int, int, int, int, int], Any],
    update_existing: bool = False,
) -> tuple[list[dict[str, Any]], list[int], list[Optional[tuple[int, int, int, int, int]]]]:
    attr_inputs = []
    record_indices = []
    cache_keys = []
    for record_index, record in enumerate(prediction_records):
        cache_key = _prediction_record_attribute_cache_key(record)
        if not update_existing and record.get("attribute") is not None:
            if cache_key is not None and cache_key not in attr_cache:
                attr_cache[cache_key] = record.get("attribute")
            continue
        if not update_existing and cache_key is not None and cache_key in attr_cache:
            cached_attr = attr_cache[cache_key]
            if cached_attr is not None:
                record["attribute"] = cached_attr
            else:
                record.pop("attribute", None)
            continue

        bbox_xyxy = _bbox_from_prediction_record(record)
        cls_id = record.get("cls_id")
        confidence = record.get("confidence")
        if bbox_xyxy is None or cls_id is None or confidence is None:
            continue
        attr_inputs.append(
            {
                "cls_id": int(cls_id),
                "score": float(confidence),
                "bbox": [float(v) for v in bbox_xyxy],
                "roi_id": record.get("roi_id"),
            }
        )
        record_indices.append(record_index)
        cache_keys.append(cache_key)
    return attr_inputs, record_indices, cache_keys


def _apply_attribute_inference_to_prediction_records(
    prediction_records: list[SerializedPredictionRecord],
    image_bgr: np.ndarray,
    attribute_models: Optional[Any],
    attr_cache: Optional[dict[tuple[int, int, int, int, int], Any]] = None,
    update_existing: bool = False,
) -> dict[tuple[int, int, int, int, int], Any]:
    if attr_cache is None:
        attr_cache = {}
    if attribute_models is None:
        return attr_cache
    if not prediction_records:
        return attr_cache

    attr_inputs, record_indices, cache_keys = _build_attribute_inputs_from_prediction_records(
        prediction_records,
        attr_cache,
        update_existing=update_existing,
    )
    if not attr_inputs:
        return attr_cache

    attribute_results = predict_attributes_for_detections(attribute_models, image_bgr, attr_inputs)
    for record_index, cache_key, attribute_result in zip(record_indices, cache_keys, attribute_results):
        attribute_json = attribute_result_to_json(attribute_result)
        if attribute_json is not None:
            prediction_records[record_index]["attribute"] = attribute_json
        else:
            prediction_records[record_index].pop("attribute", None)
        if cache_key is not None:
            attr_cache[cache_key] = attribute_json
    return attr_cache


def _roi_attribute_results_for_visualization(roi_result: ExportedROIResult) -> list[Any]:
    roi_h, roi_w = roi_result.prepared.image.shape[:2]
    crop_bounds = roi_result.prepared.crop_bounds
    attribute_results = []
    for record in roi_result.predictions:
        attribute = record.get("attribute")
        if not isinstance(attribute, dict):
            attribute_results.append(None)
            continue
        visual_attribute = dict(attribute)
        reg_box = visual_attribute.get("reg_box")
        if reg_box is not None:
            reg_box_arr = np.asarray(reg_box, dtype=np.float32).reshape(-1)
            if reg_box_arr.shape == (4,) and np.isfinite(reg_box_arr).all():
                visual_attribute["reg_box"] = _original_bbox_to_roi_image(
                    reg_box_arr,
                    roi_result.prepared.crop_bounds,
                    roi_w,
                    roi_h,
                )
        attribute_results.append(visual_attribute)
    return attribute_results


def _render_roi_result_visualization(
    roi_result: ExportedROIResult,
    vis_classes: Optional[set[int]],
    show_distance_label: bool,
    distance_label_mode: str,
    distance_label_panels: set[str],
) -> None:
    keep_indices = [
        index
        for index, view in enumerate(roi_result.prediction_views)
        if _cls_id_matches_filter(view.record.get("cls_id"), vis_classes)
    ]
    if vis_classes is None:
        detections = roi_result.detections
        prediction_views = roi_result.prediction_views
        attribute_results = _roi_attribute_results_for_visualization(roi_result)
    else:
        detection_index = np.asarray(keep_indices, dtype=np.int64)
        detections = roi_result.detections[detection_index] if keep_indices else roi_result.detections[:0]
        prediction_views = [roi_result.prediction_views[index] for index in keep_indices]
        all_attribute_results = _roi_attribute_results_for_visualization(roi_result)
        attribute_results = [all_attribute_results[index] for index in keep_indices]

    distance_label_lines = None
    if _should_draw_distance_label(show_distance_label, distance_label_panels, "2d"):
        distance_label_lines = _prediction_views_to_distance_label_lines(prediction_views, distance_label_mode)
    extra_label_lines = []
    for index, view in enumerate(prediction_views):
        lines = []
        if distance_label_lines is not None and index < len(distance_label_lines):
            lines.extend(distance_label_lines[index])
        lines.extend(view.difficulty_label_lines)
        extra_label_lines.append(lines)
    panel_2d = draw_2d_boxes(
        roi_result.prepared.image,
        detections,
        roi_result.names,
        extra_label_lines=extra_label_lines,
    )
    panel_2d = draw_attribute_overlays(panel_2d, detections, attribute_results)
    roi_result.panel_2d = annotate_panel_title(panel_2d, f"{roi_result.prepared.name} 2D")
    roi_result.panel_3d = annotate_panel_title(
        _render_3d_panel(
            roi_result.prepared,
            prediction_views,
            use_edge_heading=False,
            show_distance_label=_should_draw_distance_label(show_distance_label, distance_label_panels, "3d"),
            distance_label_mode=distance_label_mode,
        ),
        f"{roi_result.prepared.name} 3D",
    )
    if roi_result.edge_head_available:
        roi_result.panel_3d_edge = annotate_panel_title(
            _render_3d_panel(
                roi_result.prepared,
                prediction_views,
                use_edge_heading=True,
                show_distance_label=_should_draw_distance_label(show_distance_label, distance_label_panels, "3d"),
                distance_label_mode=distance_label_mode,
            ),
            f"{roi_result.prepared.name} 3D EdgeRecon (1+ face)",
        )
    else:
        roi_result.panel_3d_edge = annotate_panel_title(
            roi_result.prepared.image.copy(),
            f"{roi_result.prepared.name} 3D Edge Disabled",
        )


def _apply_attribute_inference_to_roi_result(
    roi_result: ExportedROIResult,
    image_bgr: np.ndarray,
    attribute_models: Optional[Any],
    attr_cache: Optional[dict[tuple[int, int, int, int, int], Any]] = None,
) -> dict[tuple[int, int, int, int, int], Any]:
    attr_cache = _apply_attribute_inference_to_prediction_records(
        roi_result.predictions,
        image_bgr,
        attribute_models,
        attr_cache=attr_cache,
    )
    return attr_cache


def _apply_attribute_inference_to_merged_payload(
    merged_payload: SerializedMergedPayload,
    image_bgr: np.ndarray,
    attribute_models: Optional[Any],
    attr_cache: Optional[dict[tuple[int, int, int, int, int], Any]] = None,
) -> dict[tuple[int, int, int, int, int], Any]:
    prediction_records = merged_payload.get("predictions")
    if not isinstance(prediction_records, list):
        return {} if attr_cache is None else attr_cache
    return _apply_attribute_inference_to_prediction_records(
        prediction_records,
        image_bgr,
        attribute_models,
        attr_cache=attr_cache,
    )


def _prediction_center_uv_in_original_image(
    record: SerializedPredictionRecord,
    roi_results_by_id: dict[int, ExportedROIResult],
) -> Optional[np.ndarray]:
    center_uv = record.get("center_uv")
    if center_uv is None:
        return None
    center_uv_arr = np.asarray(center_uv, dtype=np.float32).reshape(-1)
    if center_uv_arr.shape != (2,) or not np.isfinite(center_uv_arr).all():
        return None
    try:
        roi_id = int(record.get("roi_id", 0))
    except (TypeError, ValueError):
        roi_id = 0
    roi_result = roi_results_by_id.get(roi_id)
    if roi_result is None:
        return None
    roi_h, roi_w = roi_result.prepared.image.shape[:2]
    return _roi_point_to_original_image(center_uv_arr, roi_result.prepared.crop_bounds, roi_w, roi_h)


def _record_cls_id(record: SerializedPredictionRecord) -> Optional[int]:
    cls_id = record.get("cls_id")
    if cls_id is None:
        return None
    try:
        return int(cls_id)
    except (TypeError, ValueError):
        return None


def _record_is_3d(record: SerializedPredictionRecord, all_3d_classes: set[int]) -> bool:
    cls_id = _record_cls_id(record)
    return cls_id is not None and cls_id in all_3d_classes


def _rebuild_record_corners_3d(record: SerializedPredictionRecord) -> Optional[np.ndarray]:
    center_3d = record.get("center_3d")
    dims = record.get("dims")
    yaw_rad = record.get("yaw_rad")
    if center_3d is None or dims is None or yaw_rad is None:
        return None
    center_3d_arr = np.asarray(center_3d, dtype=np.float32).reshape(-1)
    if center_3d_arr.shape != (3,) or not np.isfinite(center_3d_arr).all():
        return None
    visible_face_type = record.get("visible_face_type")
    if visible_face_type is None:
        return rebuild_box_corners_for_visualization(
            None,
            dims,
            float(yaw_rad),
            box_center_3d=center_3d_arr,
        )
    return rebuild_box_corners_for_visualization(
        None,
        dims,
        float(yaw_rad),
        visible_face_type=int(visible_face_type),
        face_center_3d=center_3d_arr,
    )


def _rebuild_record_edge_corners_3d(record: SerializedPredictionRecord) -> Optional[np.ndarray]:
    edge_box_center_3d = record.get("edge_box_center_3d")
    edge_box_dims = record.get("edge_box_dims")
    edge_yaw_rad = record.get("edge_yaw_rad")
    if edge_box_center_3d is None or edge_box_dims is None or edge_yaw_rad is None:
        return None
    box_center_arr = np.asarray(edge_box_center_3d, dtype=np.float32).reshape(-1)
    if box_center_arr.shape != (3,) or not np.isfinite(box_center_arr).all():
        return None
    return rebuild_box_corners_for_visualization(
        None,
        edge_box_dims,
        float(edge_yaw_rad),
        box_center_3d=box_center_arr,
    )


def _edge_points_in_original_image(
    record: SerializedPredictionRecord,
    roi_results_by_id: dict[int, ExportedROIResult],
) -> Optional[np.ndarray]:
    edge_selection = record.get("edge_selection")
    if not isinstance(edge_selection, dict):
        return None
    edge_points_2d = edge_selection.get("edge_points_2d")
    if edge_points_2d is None:
        return None
    try:
        roi_id = int(record.get("roi_id", 0))
    except (TypeError, ValueError):
        roi_id = 0
    roi_result = roi_results_by_id.get(roi_id)
    if roi_result is None:
        return None
    points_arr = np.asarray(edge_points_2d, dtype=np.float32)
    if points_arr.ndim not in (2, 3) or points_arr.shape[-1] != 2 or not np.isfinite(points_arr).all():
        return None
    roi_h, roi_w = roi_result.prepared.image.shape[:2]
    transformed = points_arr.copy()
    crop_bounds = roi_result.prepared.crop_bounds
    if points_arr.ndim == 2:
        for idx in range(points_arr.shape[0]):
            transformed[idx] = _roi_point_to_original_image(points_arr[idx], crop_bounds, roi_w, roi_h)
        return transformed
    for batch_idx in range(points_arr.shape[0]):
        for point_idx in range(points_arr.shape[1]):
            transformed[batch_idx, point_idx] = _roi_point_to_original_image(points_arr[batch_idx, point_idx], crop_bounds, roi_w, roi_h)
    return transformed


def _draw_merged_3d_predictions(
    image_bgr: np.ndarray,
    prediction_records: Iterable[SerializedPredictionRecord],
    names: dict[int, str],
    full_image_calib: ResizedCalib,
    roi_results_by_id: dict[int, ExportedROIResult],
    all_3d_classes: set[int],
    show_distance_label: bool,
    distance_label_mode: str,
) -> np.ndarray:
    panel = image_bgr.copy()
    for record in prediction_records:
        if not _record_is_3d(record, all_3d_classes):
            continue
        corners_3d = _rebuild_record_corners_3d(record)
        if corners_3d is None:
            continue
        draw_3d_box(panel, corners_3d, full_image_calib, thickness=1)
        yaw_rad = record.get("yaw_rad")
        center_uv = _prediction_center_uv_in_original_image(record, roi_results_by_id)
        heading_lines = []
        if yaw_rad is not None:
            cls_name = str(record.get("cls_name", class_name(names, int(record.get("cls_id", -1)))))
            heading_lines.append((f"{cls_name} {math.degrees(float(yaw_rad)):.1f}", (0, 255, 255)))
        if show_distance_label:
            for distance_label in _prediction_record_distance_label_lines(record, distance_label_mode):
                heading_lines.append((distance_label, DISTANCE_LABEL_TEXT_COLORS["3d"]))
        if heading_lines and center_uv is not None:
            draw_heading_lines(panel, center_uv, heading_lines)
    return panel


def _draw_merged_edge_predictions(
    image_bgr: np.ndarray,
    prediction_records: Iterable[SerializedPredictionRecord],
    full_image_calib: ResizedCalib,
    roi_results_by_id: dict[int, ExportedROIResult],
    edge_3d_classes: set[int],
    show_distance_label: bool,
    distance_label_mode: str,
) -> np.ndarray:
    panel = image_bgr.copy()
    for record in prediction_records:
        cls_id = _record_cls_id(record)
        if cls_id is None or cls_id not in edge_3d_classes:
            continue
        if not bool(record.get("edge_yaw_confident")):
            continue
        corners_3d = _rebuild_record_edge_corners_3d(record)
        if corners_3d is None:
            continue
        edge_points_2d = _edge_points_in_original_image(record, roi_results_by_id)
        draw_3d_box(panel, corners_3d, full_image_calib, edge_points_2d=edge_points_2d, thickness=1)
        edge_yaw_rad = record.get("edge_yaw_rad")
        center_uv = _prediction_center_uv_in_original_image(record, roi_results_by_id)
        heading_lines = []
        if edge_yaw_rad is not None:
            heading_lines.append((f"edge {math.degrees(float(edge_yaw_rad)):.1f}", (0, 255, 0)))
        if show_distance_label:
            for distance_label in _prediction_record_distance_label_lines(record, distance_label_mode):
                heading_lines.append((distance_label, DISTANCE_LABEL_TEXT_COLORS["3d"]))
        if heading_lines and center_uv is not None:
            draw_heading_lines(panel, center_uv, heading_lines)
    return panel


def _concat_panels_horizontally(panels: list[np.ndarray]) -> np.ndarray:
    if not panels:
        return np.empty((0, 0, 3), dtype=np.uint8)
    row_h = max(panel.shape[0] for panel in panels)
    row_w = sum(panel.shape[1] for panel in panels)
    row_canvas = np.full((row_h, row_w, 3), GRID_BG_COLOR, dtype=np.uint8)
    x0 = 0
    for panel in panels:
        panel_h, panel_w = panel.shape[:2]
        y0 = max(0, (row_h - panel_h) // 2)
        row_canvas[y0 : y0 + panel_h, x0 : x0 + panel_w] = panel
        x0 += panel_w
    return row_canvas


def _build_merged_visualization(
    image_bgr: np.ndarray,
    merged_payload: SerializedMergedPayload,
    roi_results: list[ExportedROIResult],
    names: dict[int, str],
    raw_calib: RawCameraCalib,
    face_3d_classes: set[int],
    complete_3d_classes: set[int],
    vis_classes: Optional[set[int]] = None,
    show_distance_label: bool = False,
    distance_label_mode: str = "depth",
    distance_label_panels: Optional[set[str]] = None,
) -> np.ndarray:
    distance_label_panels = set(distance_label_panels or ())
    edge_head_available = bool(merged_payload.get("edge_head_available", True))
    prediction_records = [
        record
        for record in merged_payload.get("predictions", [])
        if _cls_id_matches_filter(record.get("cls_id"), vis_classes)
    ]
    detections, attribute_results, kept_prediction_records = _prediction_records_to_detections_and_attributes(prediction_records)
    roi_results_by_id = {
        _roi_id_from_name(roi_result.prepared.name): roi_result
        for roi_result in roi_results
    }
    image_h, image_w = image_bgr.shape[:2]
    full_image_calib = _build_full_image_calib(raw_calib, image_w, image_h)
    all_3d_classes = set(face_3d_classes) | set(complete_3d_classes)
    distance_label_lines = None
    if _should_draw_distance_label(show_distance_label, distance_label_panels, "2d"):
        distance_label_lines = _prediction_records_to_distance_label_lines(kept_prediction_records, distance_label_mode)
    panel_2d = draw_2d_boxes(
        image_bgr,
        detections,
        names,
        extra_label_lines=distance_label_lines,
    )
    panel_2d = draw_attribute_overlays(panel_2d, detections, attribute_results)
    panel_2d = annotate_panel_title(panel_2d, "Merged 2D")
    panel_3d = annotate_panel_title(
        _draw_merged_3d_predictions(
            image_bgr,
            prediction_records,
            names,
            full_image_calib,
            roi_results_by_id,
            all_3d_classes,
            show_distance_label=_should_draw_distance_label(show_distance_label, distance_label_panels, "3d"),
            distance_label_mode=distance_label_mode,
        ),
        "Merged 3D",
    )
    if edge_head_available:
        panel_3d_edge = annotate_panel_title(
            _draw_merged_edge_predictions(
                image_bgr,
                prediction_records,
                full_image_calib,
                roi_results_by_id,
                set(face_3d_classes),
                show_distance_label=_should_draw_distance_label(show_distance_label, distance_label_panels, "3d"),
                distance_label_mode=distance_label_mode,
            ),
            "Merged 3D EdgeRecon (1+ face)",
        )
    else:
        panel_3d_edge = annotate_panel_title(image_bgr.copy(), "Merged 3D Edge Disabled")
    return _concat_panels_horizontally([panel_2d, panel_3d, panel_3d_edge])


def _build_merged_predictions_payload(
    predictions_payload: SerializedPredictionsPayload,
    merged_key: str = "merged",
) -> SerializedPredictionsPayload:
    return {
        "case_name": predictions_payload["case_name"],
        "images_dir": predictions_payload["images_dir"],
        "calib_file": predictions_payload["calib_file"],
        "exported_model_path": predictions_payload["exported_model_path"],
        "edge_head_available": bool(predictions_payload.get("edge_head_available", True)),
        "edge_yaw_max_lateral_dist_m": predictions_payload["edge_yaw_max_lateral_dist_m"],
        "frames": [
            {
                "frame_index": frame_payload["frame_index"],
                "frame_name": frame_payload["frame_name"],
                **({"merged": frame_payload[merged_key]} if merged_key in frame_payload else {}),
            }
            for frame_payload in predictions_payload["frames"]
        ],
    }


def _assemble_grid(roi_results: list[ExportedROIResult]) -> np.ndarray:
    row_images = []
    for roi_result in roi_results:
        panels = [roi_result.panel_2d, roi_result.panel_3d, roi_result.panel_3d_edge]
        row_h = max(panel.shape[0] for panel in panels)
        row_w = sum(panel.shape[1] for panel in panels)
        row_canvas = np.full((row_h, row_w, 3), GRID_BG_COLOR, dtype=np.uint8)
        x0 = 0
        for panel in panels:
            panel_h, panel_w = panel.shape[:2]
            y0 = max(0, (row_h - panel_h) // 2)
            row_canvas[y0 : y0 + panel_h, x0 : x0 + panel_w] = panel
            x0 += panel_w
        row_images.append(row_canvas)

    grid_h = sum(row.shape[0] for row in row_images)
    grid_w = max(row.shape[1] for row in row_images)
    grid = np.full((grid_h, grid_w, 3), GRID_BG_COLOR, dtype=np.uint8)
    y0 = 0
    for row in row_images:
        row_h, row_w = row.shape[:2]
        x0 = max(0, (grid_w - row_w) // 2)
        grid[y0 : y0 + row_h, x0 : x0 + row_w] = row
        y0 += row_h
    return grid


def _resolve_output_dir(output_dir: str | Path, case_name: str) -> Path:
    resolved_output_dir = Path(output_dir)
    if resolved_output_dir == DEFAULT_OUTPUT_ROOT:
        return resolved_output_dir / case_name
    return resolved_output_dir


def _prepare_frame_rois(
    context: ExportedInferenceContext,
    image_bgr: np.ndarray,
    raw_calib: RawCameraCalib,
    frame_index: int,
    frame_name: str,
    frame_meta: Optional[dict[str, Any]] = None,
) -> list[tuple[ExportedROIModel, PreparedROI]]:
    prepared_rois: list[tuple[ExportedROIModel, PreparedROI]] = []
    for roi_model in context.roi_models:
        roi_name = roi_model.spec.name.lower()
        crop_center_y_offset_px = float(context.roi_crop_center_y_offsets_px.get(roi_name, 0.0))
        offset_lookup = context.roi_crop_center_y_offset_lookups.get(roi_name)
        if offset_lookup is not None:
            crop_center_y_offset_px += offset_lookup.resolve(
                frame_index=frame_index,
                frame_name=frame_name,
                frame_meta=frame_meta,
            )
        prepared_rois.append(
            (
                roi_model,
                prepare_roi_image(
                    image_bgr,
                    raw_calib,
                    roi_model.spec,
                    roi_model.imgsz,
                    crop_center_y_offset_px=crop_center_y_offset_px,
                ),
            )
        )
    return prepared_rois


def _build_frame_roi_payload(
    roi_result: ExportedROIResult,
    edge_yaw_max_lateral_dist_m: float,
) -> SerializedROIPayload:
    prepared = roi_result.prepared
    return {
        "crop_bounds": list(prepared.crop_bounds),
        "vp_x": prepared.vp_x,
        "vp_y": prepared.vp_y,
        "crop_center_x": prepared.crop_center_x,
        "crop_center_y": prepared.crop_center_y,
        "crop_center_y_offset_px": prepared.crop_center_y - prepared.vp_y,
        "edge_head_available": bool(roi_result.edge_head_available),
        "edge_yaw_max_lateral_dist_m": edge_yaw_max_lateral_dist_m,
        "calib": {key: serialize_scalar(value) for key, value in prepared.calib.items()},
        "predictions": roi_result.predictions,
    }


def _process_frame(
    context: ExportedInferenceContext,
    image_path: Path,
    raw_calib: RawCameraCalib,
    frame_index: int,
) -> ExportedFrameResult:
    image_bgr = cv2.imread(str(image_path), cv2.IMREAD_COLOR)
    if image_bgr is None:
        raise FileNotFoundError(f"Failed to read image: {image_path}")
    return _process_frame_image(
        context=context,
        image_bgr=image_bgr,
        raw_calib=raw_calib,
        frame_index=frame_index,
        frame_name=image_path.name,
    )


def _process_frame_image(
    context: ExportedInferenceContext,
    image_bgr: np.ndarray,
    raw_calib: RawCameraCalib,
    frame_index: int,
    frame_name: str,
    frame_meta: Optional[dict[str, Any]] = None,
) -> ExportedFrameResult:
    if image_bgr is None:
        raise FileNotFoundError(f"Failed to read image data for frame {frame_name}")

    prepared_rois = _prepare_frame_rois(
        context,
        image_bgr,
        raw_calib,
        frame_index=frame_index,
        frame_name=frame_name,
        frame_meta=frame_meta,
    )
    raw_output_map = _run_exported_frame(context, prepared_rois)
    cam_to_ego = _build_cam_to_ego_transform(raw_calib)

    roi_results = []
    attr_cache: dict[tuple[int, int, int, int, int], Any] = {}
    frame_payload: SerializedFramePayload = {
        "frame_index": frame_index,
        "frame_name": frame_name,
        "rois": {},
    }
    if frame_meta:
        frame_payload["frame_info"] = frame_meta
    for roi_model, prepared in prepared_rois:
        roi_result = _run_single_roi_exported(
            roi_model,
            prepared,
            raw_output_map=raw_output_map,
            classes=context.classes,
            edge_yaw_max_lateral_dist_m=context.edge_yaw_max_lateral_dist_m,
            cam_to_ego=cam_to_ego,
        )
        attr_cache = _apply_attribute_inference_to_roi_result(
            roi_result,
            image_bgr,
            context.attribute_models,
            attr_cache=attr_cache,
        )
        _render_roi_result_visualization(
            roi_result,
            context.vis_classes,
            show_distance_label=context.show_distance_label,
            distance_label_mode=context.distance_label_mode,
            distance_label_panels=context.distance_label_panels,
        )
        roi_results.append(roi_result)
        frame_payload["rois"][roi_model.spec.name.lower()] = _build_frame_roi_payload(
            roi_result,
            context.edge_yaw_max_lateral_dist_m,
        )

    frame_payload["merged"] = _build_frame_merged_payload(
        roi_results,
        enable_cross_class_merge_prior=context.enable_cross_class_merge_prior,
    )
    _apply_attribute_inference_to_merged_payload(
        frame_payload["merged"],
        image_bgr,
        context.attribute_models,
        attr_cache=attr_cache,
    )
    if context.enable_vru_merge:
        frame_payload["merged_vru"] = _build_frame_vru_merged_payload(
            roi_results,
            frame_payload["merged"],
        )
        _apply_attribute_inference_to_merged_payload(
            frame_payload["merged_vru"],
            image_bgr,
            context.attribute_models,
            attr_cache=attr_cache,
        )
    merged_grid = _build_merged_visualization(
        image_bgr,
        frame_payload["merged"],
        roi_results,
        context.roi_models[0].names if context.roi_models else {},
        raw_calib,
        context.roi_models[0].face_3d_classes if context.roi_models else set(),
        context.roi_models[0].complete_3d_classes if context.roi_models else set(),
        vis_classes=context.vis_classes,
        show_distance_label=context.show_distance_label,
        distance_label_mode=context.distance_label_mode,
        distance_label_panels=context.distance_label_panels,
    )
    return ExportedFrameResult(payload=frame_payload, grid=_assemble_grid(roi_results), merged_grid=merged_grid)


def _prepare_case_output_layout(
    case_name: str,
    output_dir: str | Path,
    save_visualizations: bool = True,
) -> tuple[Path, dict[str, Path] | None, dict[str, Path]]:
    resolved_output_dir = _resolve_output_dir(output_dir, case_name)
    vis_frame_dirs: dict[str, Path] | None = None
    if save_visualizations:
        vis_root_dir = resolved_output_dir / "visualizations"
        vis_frame_dirs = {
            "roi0_roi1": vis_root_dir / "roi0_roi1",
            "merge": vis_root_dir / "merge",
        }
    json_root_dir = resolved_output_dir / "predictions"
    json_frame_dirs = {
        "roi0": json_root_dir / "roi0",
        "roi1": json_root_dir / "roi1",
        "merge": json_root_dir / "merge",
        "merge_vru": json_root_dir / "merge_vru",
    }
    if vis_frame_dirs is not None:
        for frame_dir in vis_frame_dirs.values():
            frame_dir.mkdir(parents=True, exist_ok=True)
    for frame_dir in json_frame_dirs.values():
        frame_dir.mkdir(parents=True, exist_ok=True)
    return resolved_output_dir, vis_frame_dirs, json_frame_dirs


def _save_frame_outputs(
    frame_result: ExportedFrameResult,
    frame_stem: str,
    vis_frame_dirs: dict[str, Path] | None,
    json_frame_dirs: dict[str, Path],
) -> None:
    if vis_frame_dirs is not None:
        grid_path = vis_frame_dirs["roi0_roi1"] / f"{frame_stem}.jpg"
        if not cv2.imwrite(str(grid_path), frame_result.grid):
            raise IOError(f"Failed to write visualization image: {grid_path}")

        merged_grid_path = vis_frame_dirs["merge"] / f"{frame_stem}.jpg"
        if not cv2.imwrite(str(merged_grid_path), frame_result.merged_grid):
            raise IOError(f"Failed to write merged visualization image: {merged_grid_path}")

        frame_result.payload["visualization"] = str(grid_path)
        frame_result.payload.setdefault("merged", {})["visualization"] = str(merged_grid_path)

    for roi_name in ("roi0", "roi1"):
        frame_json_path = json_frame_dirs[roi_name] / f"{frame_stem}.json"
        with frame_json_path.open("w", encoding="utf-8") as file:
            json.dump(_build_per_frame_roi_json_format(frame_result.payload, roi_name), file, indent=2, ensure_ascii=False)

    frame_json_merged_path = json_frame_dirs["merge"] / f"{frame_stem}.json"
    with frame_json_merged_path.open("w", encoding="utf-8") as file:
        json.dump(_build_per_frame_merged_json_format(frame_result.payload), file, indent=2, ensure_ascii=False)

    if "merged_vru" in frame_result.payload and "merge_vru" in json_frame_dirs:
        frame_json_merged_vru_path = json_frame_dirs["merge_vru"] / f"{frame_stem}.json"
        with frame_json_merged_vru_path.open("w", encoding="utf-8") as file:
            json.dump(_build_per_frame_vru_merged_json_format(frame_result.payload), file, indent=2, ensure_ascii=False)


def run_case_inference_exported(
    context: ExportedInferenceContext,
    case_dir: str = "",
    images_dir: str = "",
    calib_file: str = "",
    output_dir: str | Path = DEFAULT_OUTPUT_ROOT,
    glob_pattern: str = "*.png",
    max_images: int = 0,
    save_aggregate_predictions: bool = False,
    save_visualizations: bool = True,
) -> dict[str, Any]:
    images_dir_path, calib_path, resolved_case_dir = resolve_case_inputs(case_dir=case_dir, images_dir=images_dir, calib_file=calib_file)
    raw_calib = load_camera4_calib(calib_path)

    case_name = resolved_case_dir.name if resolved_case_dir is not None else images_dir_path.name
    output_dir, vis_frame_dirs, json_frame_dirs = _prepare_case_output_layout(
        case_name,
        output_dir,
        save_visualizations=save_visualizations,
    )
    vis_root_dir = None if vis_frame_dirs is None else output_dir / "visualizations"
    json_root_dir = output_dir / "predictions"

    image_paths = list(iter_case_images(images_dir_path, glob_pattern=glob_pattern, max_images=max_images))
    if not image_paths:
        raise FileNotFoundError(f"No images found in {images_dir_path} matching {glob_pattern!r}")

    predictions_payload: SerializedPredictionsPayload = {
        "case_name": case_name,
        "images_dir": str(images_dir_path),
        "calib_file": str(calib_path),
        "exported_model_path": context.exported_model_path,
        "edge_head_available": bool(context.has_edge_head),
        "edge_yaw_max_lateral_dist_m": context.edge_yaw_max_lateral_dist_m,
        "frames": [],
    }

    for frame_index, image_path in enumerate(image_paths):
        frame_result = _process_frame(context, image_path, raw_calib, frame_index)
        _save_frame_outputs(frame_result, image_path.stem, vis_frame_dirs, json_frame_dirs)
        predictions_payload["frames"].append(frame_result.payload)

    predictions_path: Optional[Path] = None
    merged_predictions_path: Optional[Path] = None
    merged_vru_predictions_path: Optional[Path] = None
    if save_aggregate_predictions:
        predictions_path = output_dir / "predictions.json"
        with predictions_path.open("w", encoding="utf-8") as file:
            json.dump(predictions_payload, file, indent=2, ensure_ascii=False)

        merged_predictions_path = output_dir / "predictions_merged.json"
        with merged_predictions_path.open("w", encoding="utf-8") as file:
            json.dump(_build_merged_predictions_payload(predictions_payload), file, indent=2, ensure_ascii=False)

        if any("merged_vru" in frame_payload for frame_payload in predictions_payload["frames"]):
            merged_vru_predictions_path = output_dir / "predictions_merged_vru.json"
            with merged_vru_predictions_path.open("w", encoding="utf-8") as file:
                json.dump(_build_merged_predictions_payload(predictions_payload, merged_key="merged_vru"), file, indent=2, ensure_ascii=False)

    return {
        "case_name": case_name,
        "output_dir": str(output_dir),
        "vis_dir": None if vis_root_dir is None else str(vis_root_dir),
        "vis_roi0_roi1_dir": None if vis_frame_dirs is None else str(vis_frame_dirs["roi0_roi1"]),
        "vis_merge_dir": None if vis_frame_dirs is None else str(vis_frame_dirs["merge"]),
        "json_dir": str(json_root_dir),
        "json_roi0_dir": str(json_frame_dirs["roi0"]),
        "json_roi1_dir": str(json_frame_dirs["roi1"]),
        "json_merge_dir": str(json_frame_dirs["merge"]),
        "json_merge_vru_dir": str(json_frame_dirs["merge_vru"]),
        "predictions_path": None if predictions_path is None else str(predictions_path),
        "predictions_merged_path": None if merged_predictions_path is None else str(merged_predictions_path),
        "predictions_merged_vru_path": None if merged_vru_predictions_path is None else str(merged_vru_predictions_path),
        "num_frames": len(image_paths),
    }


def run_video_case_inference_exported(
    context: ExportedInferenceContext,
    video_case_dir: str | Path,
    output_dir: str | Path = DEFAULT_OUTPUT_ROOT,
    max_frames: int = 0,
    video_stride: int = 1,
    save_aggregate_predictions: bool = False,
    save_visualizations: bool = True,
    frame_index_start: Optional[int] = None,
    frame_index_end: Optional[int] = None,
    frame_id_start: Optional[int] = None,
    frame_id_end: Optional[int] = None,
    target_frame_id: Optional[int] = None,
    frame_before: int = 0,
    frame_after: int = 0,
) -> dict[str, Any]:
    case_dir, video_path, calib_path = resolve_video_case_paths(video_case_dir)
    raw_calib = load_camera4_calib(calib_path)
    frame_index_payload = read_video_frame_index(video_path)
    frame_window = resolve_video_frame_window(
        target_frame_id=target_frame_id,
        frame_before=frame_before,
        frame_after=frame_after,
        frame_index_start=frame_index_start,
        frame_index_end=frame_index_end,
        frame_id_start=frame_id_start,
        frame_id_end=frame_id_end,
    )

    case_name = case_dir.name
    output_dir, vis_frame_dirs, json_frame_dirs = _prepare_case_output_layout(
        case_name,
        output_dir,
        save_visualizations=save_visualizations,
    )
    predictions_payload: SerializedPredictionsPayload = {
        "case_name": case_name,
        "images_dir": str(video_path.parent),
        "calib_file": str(calib_path),
        "exported_model_path": context.exported_model_path,
        "edge_head_available": bool(context.has_edge_head),
        "edge_yaw_max_lateral_dist_m": context.edge_yaw_max_lateral_dist_m,
        "frames": [],
        "input_mode": "video_case",
        "video_path": str(video_path),
        "target_frame_id": frame_window["target_frame_id"],
        "requested_frame_index_start": frame_window["frame_index_start"],
        "requested_frame_index_end": frame_window["frame_index_end"],
        "requested_frame_id_start": frame_window["frame_id_start"],
        "requested_frame_id_end": frame_window["frame_id_end"],
    }

    num_frames = 0
    processed_frame_index_min: Optional[int] = None
    processed_frame_index_max: Optional[int] = None
    processed_frame_id_min: Optional[int] = None
    processed_frame_id_max: Optional[int] = None
    for frame_index, image_bgr, frame_name, frame_info in iter_video_case_frames(
        video_path,
        frame_index_payload=frame_index_payload,
        frame_stride=video_stride,
        max_frames=max_frames,
        frame_index_start=frame_window["frame_index_start"],
        frame_index_end=frame_window["frame_index_end"],
        frame_id_start=frame_window["frame_id_start"],
        frame_id_end=frame_window["frame_id_end"],
    ):
        frame_result = _process_frame_image(
            context=context,
            image_bgr=image_bgr,
            raw_calib=raw_calib,
            frame_index=frame_index,
            frame_name=frame_name,
            frame_meta=frame_info,
        )
        _save_frame_outputs(frame_result, Path(frame_name).stem, vis_frame_dirs, json_frame_dirs)
        predictions_payload["frames"].append(frame_result.payload)
        num_frames += 1
        processed_frame_index_min = frame_index if processed_frame_index_min is None else min(processed_frame_index_min, frame_index)
        processed_frame_index_max = frame_index if processed_frame_index_max is None else max(processed_frame_index_max, frame_index)
        frame_id_value = get_video_frame_id(frame_info)
        if frame_id_value is not None:
            processed_frame_id_min = frame_id_value if processed_frame_id_min is None else min(processed_frame_id_min, frame_id_value)
            processed_frame_id_max = frame_id_value if processed_frame_id_max is None else max(processed_frame_id_max, frame_id_value)

    if num_frames <= 0:
        raise FileNotFoundError(f"No readable frames found in video file: {video_path}")

    predictions_path: Optional[Path] = None
    merged_predictions_path: Optional[Path] = None
    merged_vru_predictions_path: Optional[Path] = None
    if save_aggregate_predictions:
        predictions_path = output_dir / "predictions.json"
        with predictions_path.open("w", encoding="utf-8") as file:
            json.dump(predictions_payload, file, indent=2, ensure_ascii=False)

        merged_predictions_path = output_dir / "predictions_merged.json"
        with merged_predictions_path.open("w", encoding="utf-8") as file:
            json.dump(_build_merged_predictions_payload(predictions_payload), file, indent=2, ensure_ascii=False)

        if any("merged_vru" in frame_payload for frame_payload in predictions_payload["frames"]):
            merged_vru_predictions_path = output_dir / "predictions_merged_vru.json"
            with merged_vru_predictions_path.open("w", encoding="utf-8") as file:
                json.dump(_build_merged_predictions_payload(predictions_payload, merged_key="merged_vru"), file, indent=2, ensure_ascii=False)

    vis_root_dir = None if vis_frame_dirs is None else output_dir / "visualizations"
    json_root_dir = output_dir / "predictions"
    return {
        "case_name": case_name,
        "video_case_dir": str(case_dir),
        "video_path": str(video_path),
        "output_dir": str(output_dir),
        "vis_dir": None if vis_root_dir is None else str(vis_root_dir),
        "vis_roi0_roi1_dir": None if vis_frame_dirs is None else str(vis_frame_dirs["roi0_roi1"]),
        "vis_merge_dir": None if vis_frame_dirs is None else str(vis_frame_dirs["merge"]),
        "json_dir": str(json_root_dir),
        "json_roi0_dir": str(json_frame_dirs["roi0"]),
        "json_roi1_dir": str(json_frame_dirs["roi1"]),
        "json_merge_dir": str(json_frame_dirs["merge"]),
        "json_merge_vru_dir": str(json_frame_dirs["merge_vru"]),
        "predictions_path": None if predictions_path is None else str(predictions_path),
        "predictions_merged_path": None if merged_predictions_path is None else str(merged_predictions_path),
        "predictions_merged_vru_path": None if merged_vru_predictions_path is None else str(merged_vru_predictions_path),
        "num_frames": num_frames,
        "target_frame_id": frame_window["target_frame_id"],
        "requested_frame_index_start": frame_window["frame_index_start"],
        "requested_frame_index_end": frame_window["frame_index_end"],
        "requested_frame_id_start": frame_window["frame_id_start"],
        "requested_frame_id_end": frame_window["frame_id_end"],
        "processed_frame_index_min": processed_frame_index_min,
        "processed_frame_index_max": processed_frame_index_max,
        "processed_frame_id_min": processed_frame_id_min,
        "processed_frame_id_max": processed_frame_id_max,
    }


def run_video_root_inference_exported(
    context: ExportedInferenceContext,
    video_root_dir: str | Path,
    output_dir: str | Path = DEFAULT_OUTPUT_ROOT,
    max_frames: int = 0,
    video_stride: int = 1,
    save_visualizations: bool = True,
    frame_index_start: Optional[int] = None,
    frame_index_end: Optional[int] = None,
    frame_id_start: Optional[int] = None,
    frame_id_end: Optional[int] = None,
    target_frame_id: Optional[int] = None,
    frame_before: int = 0,
    frame_after: int = 0,
) -> dict[str, Any]:
    root_dir = Path(video_root_dir).resolve()
    case_dirs = collect_video_case_dirs(root_dir)
    output_root = Path(output_dir).resolve()
    success, failed = 0, 0
    failed_cases: list[str] = []
    total = len(case_dirs)

    for i, case_dir in enumerate(case_dirs, 1):
        rel = case_dir.relative_to(root_dir)
        case_output_dir = output_root / rel
        print(f"[{i}/{total}] {rel}")
        try:
            run_video_case_inference_exported(
                context=context,
                video_case_dir=case_dir,
                output_dir=case_output_dir,
                max_frames=max_frames,
                video_stride=video_stride,
                save_visualizations=save_visualizations,
                frame_index_start=frame_index_start,
                frame_index_end=frame_index_end,
                frame_id_start=frame_id_start,
                frame_id_end=frame_id_end,
                target_frame_id=target_frame_id,
                frame_before=frame_before,
                frame_after=frame_after,
            )
            success += 1
        except Exception as exc:
            failed += 1
            failed_cases.append(str(rel))
            print(f"  WARNING: inference failed for {rel}: {exc}")

    print(f"\nDone. Success: {success}, Failed: {failed}")
    if failed_cases:
        print("Failed cases:")
        for c in failed_cases:
            print(f"  {c}")
    return {"video_root_dir": str(root_dir), "output_dir": str(output_root), "total": total, "success": success, "failed": failed}


def run_cncap_json_inference_exported(
    context: ExportedInferenceContext,
    *,
    cncap_json_file: str | Path,
    output_dir: str | Path = DEFAULT_OUTPUT_ROOT,
    max_frames: int = 0,
    video_stride: int = 1,
    save_visualizations: bool = True,
    frame_index_start: Optional[int] = None,
    frame_index_end: Optional[int] = None,
    frame_id_start: Optional[int] = None,
    frame_id_end: Optional[int] = None,
    target_frame_id: Optional[int] = None,
    frame_before: int = 0,
    frame_after: int = 0,
    values_key: str = "values",
    path_prefix_src: str = DEFAULT_CNCAP_PATH_PREFIX_SRC,
    path_prefix_dst: str = DEFAULT_CNCAP_PATH_PREFIX_DST,
) -> dict[str, Any]:
    json_path = Path(cncap_json_file).resolve()
    raw_paths = load_path_list_from_json(json_path, values_key=values_key)
    output_root = Path(output_dir).resolve()
    output_root.mkdir(parents=True, exist_ok=True)

    success, failed, skipped_duplicates = 0, 0, 0
    records: list[dict[str, Any]] = []
    seen_case_dirs: dict[str, int] = {}
    total = len(raw_paths)

    for i, raw_value in enumerate(raw_paths, 1):
        mapped_value = rewrite_path_prefix(raw_value, path_prefix_src, path_prefix_dst)
        case_dir_path = Path(mapped_value)
        case_output_dir: Optional[Path] = None
        resolved_case_dir: Optional[Path] = None
        resolved_video_path: Optional[Path] = None
        resolved_calib_path: Optional[Path] = None

        print(f"[{i}/{total}] {raw_value}")
        try:
            resolved_case_dir, resolved_video_path, resolved_calib_path = resolve_video_case_paths(case_dir_path)
            dedupe_key = str(resolved_case_dir)
            case_output_dir = output_root / build_case_output_rel_dir(resolved_case_dir)
            if dedupe_key in seen_case_dirs:
                skipped_duplicates += 1
                records.append(
                    {
                        "record_index": i - 1,
                        "raw_value": raw_value,
                        "mapped_value": mapped_value,
                        "case_dir": str(resolved_case_dir),
                        "video_path": str(resolved_video_path),
                        "calib_path": str(resolved_calib_path),
                        "output_dir": str(case_output_dir),
                        "status": "skipped_duplicate",
                        "duplicate_of_record_index": seen_case_dirs[dedupe_key],
                    }
                )
                print(f"  SKIP: duplicate case {resolved_case_dir}")
                continue

            seen_case_dirs[dedupe_key] = i - 1
            result = run_video_case_inference_exported(
                context=context,
                video_case_dir=resolved_case_dir,
                output_dir=case_output_dir,
                max_frames=max_frames,
                video_stride=video_stride,
                save_visualizations=save_visualizations,
                frame_index_start=frame_index_start,
                frame_index_end=frame_index_end,
                frame_id_start=frame_id_start,
                frame_id_end=frame_id_end,
                target_frame_id=target_frame_id,
                frame_before=frame_before,
                frame_after=frame_after,
            )
            success += 1
            records.append(
                {
                    "record_index": i - 1,
                    "raw_value": raw_value,
                    "mapped_value": mapped_value,
                    "case_dir": str(resolved_case_dir),
                    "video_path": str(resolved_video_path),
                    "calib_path": str(resolved_calib_path),
                    "output_dir": str(case_output_dir),
                    "status": "success",
                    "result": result,
                }
            )
        except Exception as exc:
            failed += 1
            records.append(
                {
                    "record_index": i - 1,
                    "raw_value": raw_value,
                    "mapped_value": mapped_value,
                    "case_dir": str(resolved_case_dir) if resolved_case_dir is not None else "",
                    "video_path": str(resolved_video_path) if resolved_video_path is not None else "",
                    "calib_path": str(resolved_calib_path) if resolved_calib_path is not None else "",
                    "output_dir": str(case_output_dir) if case_output_dir is not None else "",
                    "status": "failed",
                    "error": str(exc),
                }
            )
            print(f"  WARNING: inference failed for {mapped_value}: {exc}")

    manifest_path = output_root / "_status" / "cncap_manifest.json"
    manifest_path.parent.mkdir(parents=True, exist_ok=True)
    manifest_payload = {
        "cncap_json_file": str(json_path),
        "values_key": values_key,
        "path_prefix_src": path_prefix_src,
        "path_prefix_dst": path_prefix_dst,
        "total_records": total,
        "success": success,
        "failed": failed,
        "skipped_duplicates": skipped_duplicates,
        "records": records,
    }
    with manifest_path.open("w", encoding="utf-8") as file:
        json.dump(manifest_payload, file, indent=2, ensure_ascii=False)

    print(f"\nDone. Success: {success}, Failed: {failed}, Skipped duplicates: {skipped_duplicates}")
    return {
        "cncap_json_file": str(json_path),
        "output_dir": str(output_root),
        "manifest_path": str(manifest_path),
        "total": total,
        "success": success,
        "failed": failed,
        "skipped_duplicates": skipped_duplicates,
    }


def _collect_eval_case_dirs(eval_dir: str | Path) -> list[Path]:
    eval_path = Path(eval_dir).resolve()
    if not eval_path.is_dir():
        raise FileNotFoundError(f"Eval directory not found: {eval_path}")

    case_dirs: list[Path] = []

    def try_add_case_dir(case_dir: Path) -> None:
        if not (case_dir / "images").is_dir():
            return
        try:
            resolve_case_calib_path(case_dir)
        except FileNotFoundError:
            return
        case_dirs.append(case_dir)

    for first_level_dir in sorted(path for path in eval_path.iterdir() if path.is_dir()):
        if (first_level_dir / "images").is_dir():
            try_add_case_dir(first_level_dir)
            continue

        for second_level_dir in sorted(path for path in first_level_dir.iterdir() if path.is_dir()):
            try_add_case_dir(second_level_dir)

    if not case_dirs:
        raise FileNotFoundError(f"No case directories found under {eval_path}")
    return case_dirs


def run_evalset_inference_exported(
    context: ExportedInferenceContext,
    eval_dir: str | Path,
    output_dir: str | Path = DEFAULT_OUTPUT_ROOT,
    glob_pattern: str = "*.png",
    max_images: int = 0,
    save_aggregate_predictions: bool = False,
    save_visualizations: bool = True,
    case_index_start: int | None = None,
    case_index_end: int | None = None,
    num_shards: int = 1,
    shard_index: int = 0,
    shard_strategy: str = "round_robin",
) -> dict[str, Any]:
    """Run inference over all cases in a mined eval dataset, loading the model only once.

    Expected layout: eval_dir/<case_uuid>/images/ or eval_dir/<date>/<case_uuid>/images/,
    with calib/L2_calib/camera4.json or calib/camera4.json under each case directory.
    Output mirrors the same relative structure under output_dir.
    """
    eval_path = Path(eval_dir).resolve()
    case_dirs = _collect_eval_case_dirs(eval_path)
    if num_shards < 1:
        raise ValueError(f"num_shards must be >= 1, got {num_shards}")
    if shard_index < 0 or shard_index >= num_shards:
        raise ValueError(f"shard_index must satisfy 0 <= shard_index < num_shards, got {shard_index} / {num_shards}")

    total_cases = len(case_dirs)
    if case_index_start is None:
        case_index_start = 0
    if case_index_end is None:
        case_index_end = total_cases
    if case_index_start < 0:
        raise ValueError(f"case_index_start must be >= 0, got {case_index_start}")
    if case_index_end < 0:
        raise ValueError(f"case_index_end must be >= 0, got {case_index_end}")
    if case_index_start > case_index_end:
        raise ValueError(
            f"case_index_start must be <= case_index_end, got {case_index_start} > {case_index_end}"
        )
    if case_index_start > total_cases:
        raise ValueError(
            f"case_index_start must be <= total case count {total_cases}, got {case_index_start}"
        )
    if case_index_end > total_cases:
        raise ValueError(
            f"case_index_end must be <= total case count {total_cases}, got {case_index_end}"
        )

    selected_case_dirs = case_dirs[case_index_start:case_index_end]
    if num_shards == 1:
        assigned_case_dirs = selected_case_dirs
    elif shard_strategy == "round_robin":
        assigned_case_dirs = selected_case_dirs[shard_index::num_shards]
    elif shard_strategy == "contiguous":
        selected_total = len(selected_case_dirs)
        start = selected_total * shard_index // num_shards
        end = selected_total * (shard_index + 1) // num_shards
        assigned_case_dirs = selected_case_dirs[start:end]
    else:
        raise ValueError(f"Unsupported shard_strategy: {shard_strategy}")

    output_root = Path(output_dir).resolve()
    success, failed = 0, 0
    failed_cases: list[str] = []
    total = len(assigned_case_dirs)

    if num_shards > 1:
        print(
            f"Eval shard {shard_index + 1}/{num_shards} using {shard_strategy}: "
            f"assigned {total} of {len(selected_case_dirs)} selected cases"
        )
    if case_index_start != 0 or case_index_end != total_cases:
        print(
            f"Eval case slice: [{case_index_start}, {case_index_end}) "
            f"-> {len(selected_case_dirs)} selected of {total_cases} total cases"
        )
    if not assigned_case_dirs:
        print("No cases assigned to this shard, skipping inference.")
        return {
            "eval_dir": str(eval_path),
            "output_dir": str(output_root),
            "total": 0,
            "total_all": total_cases,
            "selected_total": len(selected_case_dirs),
            "case_index_start": case_index_start,
            "case_index_end": case_index_end,
            "success": 0,
            "failed": 0,
            "num_shards": num_shards,
            "shard_index": shard_index,
            "shard_strategy": shard_strategy,
        }

    for i, case_dir in enumerate(assigned_case_dirs, 1):
        rel = case_dir.relative_to(eval_path)
        case_output_dir = output_root / rel
        print(f"[{i}/{total}] {rel}")
        try:
            run_case_inference_exported(
                context=context,
                case_dir=str(case_dir),
                output_dir=case_output_dir,
                glob_pattern=glob_pattern,
                max_images=max_images,
                save_aggregate_predictions=save_aggregate_predictions,
                save_visualizations=save_visualizations,
            )
            success += 1
        except Exception as exc:
            failed += 1
            failed_cases.append(str(rel))
            print(f"  WARNING: inference failed for {rel}: {exc}")

    print(f"\nDone. Success: {success}, Failed: {failed}")
    if failed_cases:
        print("Failed cases:")
        for c in failed_cases:
            print(f"  {c}")
    return {
        "eval_dir": str(eval_path),
        "output_dir": str(output_root),
        "total": total,
        "total_all": total_cases,
        "selected_total": len(selected_case_dirs),
        "case_index_start": case_index_start,
        "case_index_end": case_index_end,
        "success": success,
        "failed": failed,
        "num_shards": num_shards,
        "shard_index": shard_index,
        "shard_strategy": shard_strategy,
    }


def main() -> None:
    args = parse_args()
    if args.event_json_file:
        context = None if args.selection_only else build_exported_inference_context(args)
        run_event_json_inference_exported(
            context=context,
            args=args,
            load_env=load_dotenv,
            run_case_inference=run_case_inference_exported,
        )
        return
    context = build_exported_inference_context(args)
    if args.clip_list_file:
        run_clip_list_inference_exported(
            context=context,
            args=args,
            load_env=load_dotenv,
            run_case_inference=run_case_inference_exported,
        )
        return
    if args.mcap_file:
        run_mcap_file_inference_exported(
            context=context,
            args=args,
            load_env=load_dotenv,
            run_case_inference=run_case_inference_exported,
        )
        return
    if args.cncap_json_file:
        run_cncap_json_inference_exported(
            context=context,
            cncap_json_file=args.cncap_json_file,
            output_dir=args.output_dir,
            max_frames=args.max_images,
            video_stride=args.video_stride,
            save_visualizations=not args.skip_visualizations,
            frame_index_start=args.frame_index_start,
            frame_index_end=args.frame_index_end,
            frame_id_start=args.frame_id_start,
            frame_id_end=args.frame_id_end,
            target_frame_id=args.target_frame_id,
            frame_before=args.frame_before,
            frame_after=args.frame_after,
            values_key=args.cncap_values_key,
            path_prefix_src=args.cncap_path_prefix_src,
            path_prefix_dst=args.cncap_path_prefix_dst,
        )
        return
    if args.video_root_dir:
        run_video_root_inference_exported(
            context=context,
            video_root_dir=args.video_root_dir,
            output_dir=args.output_dir,
            max_frames=args.max_images,
            video_stride=args.video_stride,
            save_visualizations=not args.skip_visualizations,
            frame_index_start=args.frame_index_start,
            frame_index_end=args.frame_index_end,
            frame_id_start=args.frame_id_start,
            frame_id_end=args.frame_id_end,
            target_frame_id=args.target_frame_id,
            frame_before=args.frame_before,
            frame_after=args.frame_after,
        )
        return
    if args.video_case_dir:
        result = run_video_case_inference_exported(
            context=context,
            video_case_dir=args.video_case_dir,
            output_dir=args.output_dir,
            max_frames=args.max_images,
            video_stride=args.video_stride,
            save_aggregate_predictions=args.save_aggregate_predictions,
            save_visualizations=not args.skip_visualizations,
            frame_index_start=args.frame_index_start,
            frame_index_end=args.frame_index_end,
            frame_id_start=args.frame_id_start,
            frame_id_end=args.frame_id_end,
            target_frame_id=args.target_frame_id,
            frame_before=args.frame_before,
            frame_after=args.frame_after,
        )
        if result.get("vis_dir"):
            print(f"Saved {result['num_frames']} visualizations under {result['vis_dir']}")
            print(f"Per-frame ROI0/ROI1 visualizations: {result['vis_roi0_roi1_dir']}")
            print(f"Per-frame merged visualizations: {result['vis_merge_dir']}")
        else:
            print("Skipped visualization image export; only predictions JSON outputs were written.")
        if (
            result.get("requested_frame_index_start") is not None
            or result.get("requested_frame_index_end") is not None
        ):
            print(
                "Requested frame-index range: "
                f"[{result['requested_frame_index_start'] if result.get('requested_frame_index_start') is not None else '-inf'}, "
                f"{result['requested_frame_index_end'] if result.get('requested_frame_index_end') is not None else '+inf'}]"
            )
        if (
            result.get("requested_frame_id_start") is not None
            or result.get("requested_frame_id_end") is not None
        ):
            print(
                "Requested frame-id range: "
                f"[{result['requested_frame_id_start'] if result.get('requested_frame_id_start') is not None else '-inf'}, "
                f"{result['requested_frame_id_end'] if result.get('requested_frame_id_end') is not None else '+inf'}]"
            )
        print(f"Per-frame ROI0 predictions JSON: {result['json_roi0_dir']}")
        print(f"Per-frame ROI1 predictions JSON: {result['json_roi1_dir']}")
        print(f"Per-frame merged predictions JSON: {result['json_merge_dir']}")
        if result.get("json_merge_vru_dir"):
            print(f"Per-frame VRU merged predictions JSON: {result['json_merge_vru_dir']}")
        if result.get("predictions_path"):
            print(f"Aggregate predictions JSON: {result['predictions_path']}")
        if result.get("predictions_merged_path"):
            print(f"Aggregate merged predictions JSON: {result['predictions_merged_path']}")
        if result.get("predictions_merged_vru_path"):
            print(f"Aggregate VRU merged predictions JSON: {result['predictions_merged_vru_path']}")
        return
    if args.eval_dir:
        run_evalset_inference_exported(
            context=context,
            eval_dir=args.eval_dir,
            output_dir=args.output_dir,
            glob_pattern=args.glob,
            max_images=args.max_images,
            save_aggregate_predictions=args.save_aggregate_predictions,
            save_visualizations=not args.skip_visualizations,
            case_index_start=args.case_index_start,
            case_index_end=args.case_index_end,
            num_shards=args.num_shards,
            shard_index=args.shard_index,
            shard_strategy=args.shard_strategy,
        )
        return
    result = run_case_inference_exported(
        context=context,
        case_dir=args.case_dir,
        images_dir=args.images_dir,
        calib_file=args.calib_file,
        output_dir=args.output_dir,
        glob_pattern=args.glob,
        max_images=args.max_images,
        save_aggregate_predictions=args.save_aggregate_predictions,
        save_visualizations=not args.skip_visualizations,
    )
    if result.get("vis_dir"):
        print(f"Saved {result['num_frames']} visualizations under {result['vis_dir']}")
        print(f"Per-frame ROI0/ROI1 visualizations: {result['vis_roi0_roi1_dir']}")
        print(f"Per-frame merged visualizations: {result['vis_merge_dir']}")
    else:
        print("Skipped visualization image export; only predictions JSON outputs were written.")
    print(f"Per-frame ROI0 predictions JSON: {result['json_roi0_dir']}")
    print(f"Per-frame ROI1 predictions JSON: {result['json_roi1_dir']}")
    print(f"Per-frame merged predictions JSON: {result['json_merge_dir']}")
    if result.get("json_merge_vru_dir"):
        print(f"Per-frame VRU merged predictions JSON: {result['json_merge_vru_dir']}")
    if result.get("predictions_path"):
        print(f"Aggregate predictions JSON: {result['predictions_path']}")
    if result.get("predictions_merged_path"):
        print(f"Aggregate merged predictions JSON: {result['predictions_merged_path']}")
    if result.get("predictions_merged_vru_path"):
        print(f"Aggregate VRU merged predictions JSON: {result['predictions_merged_vru_path']}")


if __name__ == "__main__":
    main()
