#!/usr/bin/env python3
"""Convert original GT JSON directly into the final converted JSON format."""

import argparse
import json
import shutil
import sys
import time
from dataclasses import dataclass
from functools import lru_cache
from multiprocessing import Pool, cpu_count
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence, Tuple

import cv2
import numpy as np
from pyquaternion import Quaternion

ArrayLike = np.ndarray
LabelRow = List[float]
JsonDict = Dict[str, Any]
ConvertedObject = Dict[str, Any]

CLASS_MAP_LEGACY = {
    0: "vehicle",
    1: "pedestrian",
    2: "bicycle",
    3: "rider",
    4: "roadblock",
    5: "head",
    6: "tsr",
    7: "guideboard",
    8: "plate",
    9: "wheel",
    10: "tl_border",
    11: "tl_wick",
    12: "tl_num",
    13: "tricycle",
}

CLASS_MAP = {
    "car": 0,
    "suv": 1,
    "pickup": 2,
    "medium_car": 3,
    "van": 4,
    "bus": 5,
    "truck": 6,
    "tanker": 6,
    "large_truck": 6,
    "construction_vehicle": 6,
    "special_vehicle": 7,
    "unknown": 8,
    "pedestrian": 9,
    "bicyclist": 10,
    "motorcyclist": 10,
    "bicycle": 11,
    "motorcycle": 11,
    "tricycle": 12,
    "tricyclist": 12,
    "traffic_sign": 13,
    "wheel": 14,
    "plate": 15,
    "face": 16,
}

CLASS_MAP_MODEL = {
    0: "car",
    1: "suv",
    2: "pickup",
    3: "medium_car",
    4: "van",
    5: "bus",
    6: "truck",
    7: "special_vehicle",
    8: "unknown",
    9: "pedestrian",
    10: "bicyclist",
    11: "bicycle",
    12: "tricycle",
    13: "traffic_sign",
    14: "wheel",
    15: "plate",
    16: "face",
}

FACE_3D_CLASS_IDS = set(range(0, 9))
COMPLETE_3D_CLASS_IDS = {9, 10, 11, 12}
SUPPORTED_3D_CLASS_IDS = FACE_3D_CLASS_IDS | COMPLETE_3D_CLASS_IDS

EMPTY_3D_ORI = ["-1.0"] * 13
EMPTY_3D_FACE = ["-1.0"] * 8
# DETECTION_CLASS_MAP = {
#     "vehicle": 0,
#     "head": 5,
#     "bicycle": 2,
#     "wheel": 9,
#     "plate": 8,
#     "rider": 3,
#     "pedestrian": 1,
#     "tsr": 6,
#     "guideboard": 7,
#     "roadpile": 4,
#     "waterbarrier": 4,
#     "tl_border": 10,
#     "tl_wick": 11,
#     "sphericalstone": 4,
#     "rickshaw": 0,
#     "roadblock": 4,
#     "cone": 4,
#     "crashbarrel": 4,
#     "tl_num": 12,
#     "carton": -1,
# }

DETECTION_CLASS_MAP = {
    "car": 0,
    "suv": 1,
    "pickup": 2,
    "medium_car": 3,
    "van": 4,
    "bus": 5,
    "truck": 6,
    "tanker": 6,
    "large_truck": 6,
    "construction_vehicle": 6,
    "special_vehicle": 7,
    "unknown": 8,
    "pedestrian": 9,
    "bicyclist": 10,
    "motorcyclist": 10,
    "bicycle": 11,
    "motorcycle": 11,
    "tricycle": 12,
    "tricyclist": 12,
    "traffic_sign": 13,
    "wheel": 14,
    "plate": 15,
    "face": 16,
}

OCCLUSION_MAP = {
    "no_occlusion": 0,
    "slight_occlusion": 1,
    "partial_occlusion": 2,
    "heavy_occlusion": 3,
}

@dataclass
class CameraSetup:
    ego_to_cam: np.ndarray
    cam_to_ego: np.ndarray
    cam_intrinsic: np.ndarray
    distortion_param: np.ndarray
    cam_rectify: np.ndarray
    orig_width: int
    orig_height: int


def stringify(values: Sequence[Any]) -> List[str]:
    return [str(value) for value in values]


def wrap_alpha(alpha: float) -> float:
    if alpha < -np.pi:
        alpha += 2 * np.pi
    if alpha > np.pi:
        alpha -= 2 * np.pi
    return alpha


def rotation_3d_in_axis(points: ArrayLike, angle: float, axis: int) -> ArrayLike:
    sin_angle = np.sin(angle)
    cos_angle = np.cos(angle)

    if axis == 0:
        rotation_matrix = np.array(
            [[1.0, 0.0, 0.0], [0.0, cos_angle, sin_angle], [0.0, -sin_angle, cos_angle]]
        )
    elif axis == 1:
        rotation_matrix = np.array(
            [[cos_angle, 0.0, -sin_angle], [0.0, 1.0, 0.0], [sin_angle, 0.0, cos_angle]]
        )
    elif axis == 2:
        rotation_matrix = np.array(
            [[cos_angle, sin_angle, 0.0], [-sin_angle, cos_angle, 0.0], [0.0, 0.0, 1.0]]
        )
    else:
        raise ValueError(f"axis should be 0, 1, or 2, got {axis}")

    return np.dot(points, rotation_matrix)


def get_rot_mat_and_trans(calib_dict: JsonDict) -> ArrayLike:
    sin_roll = np.sin(calib_dict["roll"] * np.pi / 180)
    cos_roll = np.cos(calib_dict["roll"] * np.pi / 180)
    sin_pitch = np.sin(calib_dict["pitch"] * np.pi / 180)
    cos_pitch = np.cos(calib_dict["pitch"] * np.pi / 180)
    sin_yaw = np.sin(calib_dict["yaw"] * np.pi / 180)
    cos_yaw = np.cos(calib_dict["yaw"] * np.pi / 180)

    rot = np.array(
        [
            [cos_pitch * cos_yaw, sin_roll * sin_pitch * cos_yaw - cos_roll * sin_yaw, sin_roll * sin_yaw + cos_roll * sin_pitch * cos_yaw],
            [cos_pitch * sin_yaw, cos_roll * cos_yaw + sin_roll * sin_pitch * sin_yaw, cos_roll * sin_pitch * sin_yaw - sin_roll * cos_yaw],
            [-sin_pitch, sin_roll * cos_pitch, cos_roll * cos_pitch],
        ]
    ).T

    rot_yxz = np.array([[0, -1, 0], [0, 0, -1], [1, 0, 0]])
    rot_ego2cam = np.matmul(rot_yxz, rot)
    translation = -1 * np.matmul(rot_ego2cam, np.array(calib_dict["pos"]))

    ego_to_cam = np.zeros((4, 4))
    ego_to_cam[:3, :3] = rot_ego2cam
    ego_to_cam[:3, 3] = translation
    ego_to_cam[3, 3] = 1
    return ego_to_cam


def cam_img_kb(x: float, y: float, z: float, cam_intrinsic: ArrayLike, distort_param: ArrayLike) -> ArrayLike:
    normalized = np.array([x / z, y / z]).reshape(-1, 2)
    a = normalized[..., 0]
    b = normalized[..., 1]
    radius = np.sqrt(a * a + b * b)
    theta = np.arctan(radius)

    k1, k2, k3, k4 = distort_param[:4]
    theta_distorted = theta * (1 + k1 * theta**2 + k2 * theta**4 + k3 * theta**6 + k4 * theta**8)

    radius = np.where(radius == 0, 1e-8, radius)
    xp = theta_distorted / radius * a
    yp = theta_distorted / radius * b

    imgx = cam_intrinsic[0, 0] * xp + cam_intrinsic[0, 2]
    imgy = cam_intrinsic[1, 1] * yp + cam_intrinsic[1, 2]
    return np.round(np.concatenate((imgx.reshape((-1, 1)), imgy.reshape((-1, 1))), axis=-1)).astype(np.int32).reshape(-1)


def xyz_xy(x: float, y: float, distort_param: ArrayLike, intrinsic: ArrayLike) -> ArrayLike:
    radius = np.sqrt(x * x + y * y)
    radius_2 = radius * radius
    radius_4 = radius_2 * radius_2
    radius_6 = radius_4 * radius_2

    coeffs = np.zeros(12, dtype=np.float32)
    for idx in range(min(distort_param.shape[0], 12)):
        coeffs[idx] = distort_param[idx]

    radial_ratio = (1 + coeffs[0] * radius_2 + coeffs[1] * radius_4 + coeffs[4] * radius_6) / (
        1 + coeffs[5] * radius_2 + coeffs[6] * radius_4 + coeffs[7] * radius_6
    )
    result_x = x * radial_ratio + 2 * coeffs[2] * x * y + coeffs[3] * (radius_2 + 2 * x**2) + coeffs[8] * radius_2 + coeffs[9] * radius_4
    result_y = y * radial_ratio + 2 * coeffs[2] * (radius_2 + 2 * y**2) + coeffs[3] * x * y + coeffs[10] * radius_2 + coeffs[11] * radius_4
    image_coord = np.dot(intrinsic, np.array([result_x, result_y, 1])).T
    return image_coord[:2]


def vehicle_to_image(
    point_3d: ArrayLike,
    ego_to_cam: ArrayLike,
    distort_param: ArrayLike,
    cam_intrinsic: ArrayLike,
    image_width: int,
    image_height: int,
) -> Tuple[ArrayLike, ArrayLike]:
    point_in_camera = np.dot(ego_to_cam[:3, :3], point_3d) + ego_to_cam[:3, 3]
    if len(distort_param) == 5:
        point_in_image = xyz_xy(point_in_camera[0] / point_in_camera[2], point_in_camera[1] / point_in_camera[2], distort_param, cam_intrinsic)
    else:
        point_in_image = cam_img_kb(point_in_camera[0], point_in_camera[1], point_in_camera[2], cam_intrinsic, distort_param)

    point_in_image = point_in_image[0:2]
    point_in_image[0] = np.clip(point_in_image[0], 0, image_width - 1)
    point_in_image[1] = np.clip(point_in_image[1], 0, image_height - 1)
    return point_in_image, point_in_camera


def cam_corners_front_rear(pred3d: ArrayLike, face_type: str, pitch: float = 0) -> ArrayLike:
    dimensions = pred3d[3:6]
    corners_norm = np.stack(np.unravel_index(np.arange(8), [2] * 3), axis=1)
    corners_norm = corners_norm[[0, 1, 3, 2, 4, 5, 7, 6]]

    face_offsets = {
        "tail": [0, 0.5, 0.5],
        "front": [1, 0.5, 0.5],
        "right": [0.5, 0.5, 0],
        "left": [0.5, 0.5, 1],
        "whole": [0.5, 0.5, 0.5],
    }
    if face_type not in face_offsets:
        raise ValueError(f"Invalid face type: {face_type}")

    corners = dimensions.reshape([1, 3]) * (corners_norm - face_offsets[face_type]).reshape([8, 3])
    corners = rotation_3d_in_axis(corners, pred3d[6], axis=1)
    corners = rotation_3d_in_axis(corners, pitch * np.pi / 180.0, axis=0)
    corners += pred3d[:3].reshape(1, 3)
    return corners


def calculate_vector_angle(vector_a: ArrayLike, vector_b: ArrayLike) -> float:
    dot_product = np.dot(vector_a, vector_b)
    norm_a = np.linalg.norm(vector_a)
    norm_b = np.linalg.norm(vector_b)
    cosine = np.clip(dot_product / (norm_a * norm_b), -1.0, 1.0)
    return float(np.degrees(np.arccos(cosine)))


def angle_to_score(angle: float) -> float:
    normalized = (angle - 90) / 90
    normalized = min(max(0, normalized), 1)
    return float(np.sqrt(normalized))


def project_point(point_3d: ArrayLike, camera_setup: CameraSetup) -> Tuple[float, float]:
    if len(camera_setup.distortion_param) == 5:
        x, y = xyz_xy(
            point_3d[0] / point_3d[2],
            point_3d[1] / point_3d[2],
            camera_setup.distortion_param,
            camera_setup.cam_intrinsic,
        )
    else:
        x, y = cam_img_kb(
            point_3d[0],
            point_3d[1],
            point_3d[2],
            camera_setup.cam_intrinsic,
            camera_setup.distortion_param,
        )
    return x / camera_setup.orig_width, y / camera_setup.orig_height


def transform_point(point_3d: Sequence[float], transform: ArrayLike) -> ArrayLike:
    point_h = np.array([point_3d[0], point_3d[1], point_3d[2], 1.0], dtype=np.float64)
    transformed = transform @ point_h
    return transformed[:3]


def validate_transformed_ego_point(
    original_ego_point: Sequence[float],
    transformed_ego_point: Sequence[float],
    atol: float = 1e-3,
) -> Tuple[bool, List[float]]:
    diff = np.abs(np.array(original_ego_point, dtype=np.float64) - np.array(transformed_ego_point, dtype=np.float64))
    return bool(np.all(diff <= atol)), diff.tolist()


def normalize_angle(angle: float) -> float:
    while angle > np.pi:
        angle -= 2 * np.pi
    while angle < -np.pi:
        angle += 2 * np.pi
    return angle


def rad_to_deg(angle: float) -> float:
    return float(np.degrees(angle))


def transform_camera_yaw_to_ego_yaw(camera_yaw: float, cam_to_ego: ArrayLike) -> float:
    # Camera-frame yaw convention used here:
    # - yaw = 0 points along +x
    # - yaw = -pi/2 points along +z
    heading_cam = np.array([np.cos(camera_yaw), 0.0, -np.sin(camera_yaw)], dtype=np.float64)
    heading_ego = cam_to_ego[:3, :3] @ heading_cam
    return float(np.arctan2(heading_ego[1], heading_ego[0]))


def resolve_camera4_calib_file(calib_path: Path) -> Path:
    candidates = [
        calib_path / "camera4.json",
        calib_path / "L2_calib" / "camera4.json",
        calib_path / "calib" / "camera4.json",
        calib_path / "calib" / "L2_calib" / "camera4.json",
    ]

    if calib_path.name.startswith("annotations"):
        case_dir = calib_path.parent
        candidates.extend(
            [
                case_dir / "calib" / "camera4.json",
                case_dir / "calib" / "L2_calib" / "camera4.json",
                case_dir / "L2_calib" / "camera4.json",
            ]
        )

    seen = set()
    unique_candidates = []
    for candidate in candidates:
        if candidate not in seen:
            seen.add(candidate)
            unique_candidates.append(candidate)

    for candidate in unique_candidates:
        if candidate.exists():
            return candidate

    searched_paths = "\n".join(f"  - {candidate}" for candidate in unique_candidates)
    raise FileNotFoundError(
        "camera4 calibration file not found. Searched:\n"
        f"{searched_paths}"
    )


@lru_cache(maxsize=256)
def resolve_camera4_calib_file_cached(calib_path_str: str) -> str:
    return str(resolve_camera4_calib_file(Path(calib_path_str)))


def get_camera_family(anno_name: str) -> str:
    if "G1M2" in anno_name:
        return "G1M2"
    if "G1M3" in anno_name or "G1Q3" in anno_name:
        return "G1M3_OR_G1Q3"
    raise ValueError(f"Unsupported annotation type for camera setup: {anno_name}")


@lru_cache(maxsize=64)
def build_camera_setup_cached(calib_file_str: str, camera_family: str) -> CameraSetup:
    calib_file = Path(calib_file_str)
    with calib_file.open("r", encoding="utf-8") as f:
        calib_param = json.load(f)

    ego_to_cam = get_rot_mat_and_trans(calib_param)
    cam_intrinsic = np.array(
        [
            calib_param["focal_u"], 0, calib_param["cu"],
            0, calib_param["focal_v"], calib_param["cv"],
            0, 0, 1,
        ],
        dtype=np.float64,
    ).reshape(3, 3)
    distortion_param = np.array(calib_param["distort_coeffs"])

    if camera_family == "G1M2":
        orig_height, orig_width = 1080, 1920
        cam_rectify, _ = cv2.getOptimalNewCameraMatrix(
            cam_intrinsic, distortion_param, (orig_width, orig_height), 1, (orig_width, orig_height)
        )
    elif camera_family == "G1M3_OR_G1Q3":
        orig_height, orig_width = 1080, 1920
        cam_rectify = cv2.fisheye.estimateNewCameraMatrixForUndistortRectify(
            cam_intrinsic, distortion_param, [orig_width, orig_height], np.eye(3), balance=1
        )
    else:
        raise ValueError(f"Unsupported camera family: {camera_family}")

    return CameraSetup(
        ego_to_cam=ego_to_cam,
        cam_to_ego=np.linalg.inv(ego_to_cam),
        cam_intrinsic=cam_intrinsic,
        distortion_param=distortion_param,
        cam_rectify=cam_rectify,
        orig_width=orig_width,
        orig_height=orig_height,
    )


def build_camera_setup(calib_path: str, anno_name: str) -> CameraSetup:
    calib_file = resolve_camera4_calib_file_cached(str(Path(calib_path)))
    camera_family = get_camera_family(anno_name)
    return build_camera_setup_cached(calib_file, camera_family)


def build_2d_label(camera_mea: JsonDict, camera_setup: CameraSetup) -> Tuple[List[float], List[float]]:
    x1 = camera_mea["det_pt_x"]
    y1 = camera_mea["det_pt_y"]
    x2 = x1 + camera_mea["det_pt_width"]
    y2 = y1 + camera_mea["det_pt_height"]
    return [
        min((x1 + x2) / (2 * camera_setup.orig_width), 1),
        min((y1 + y2) / (2 * camera_setup.orig_height), 1),
        min((x2 - x1) / camera_setup.orig_width, 1),
        min((y2 - y1) / camera_setup.orig_height, 1),
    ], [x1, y1, x2, y2]


def compute_vehicle_rotation(ego_to_cam: ArrayLike, yaw_lidar: float) -> float:
    rotation_z = np.array(
        [
            [np.cos(yaw_lidar), -np.sin(yaw_lidar), 0],
            [np.sin(yaw_lidar), np.cos(yaw_lidar), 0],
            [0, 0, 1.0],
        ]
    )
    rotate_z = np.dot(ego_to_cam[:3, :3], rotation_z)
    orientation = Quaternion(matrix=rotate_z)
    return -orientation.yaw_pitch_roll[0]


def compute_projection_flags(
    corners: ArrayLike, anchor_front: ArrayLike, anchor_back: ArrayLike, camera_setup: CameraSetup
) -> Dict[str, Any]:
    rectify = camera_setup.cam_rectify
    width = camera_setup.orig_width
    height = camera_setup.orig_height

    x_left = corners[:, 0] >= -corners[:, 2] * rectify[0][2] / rectify[0][0]
    x_right = corners[:, 0] <= corners[:, 2] * (width - 1 - rectify[0][2]) / rectify[0][0]
    x_down = corners[:, 1] <= corners[:, 2] * (height - 1 - rectify[1][2]) / rectify[1][1]
    have_in = int(((x_left & x_right)[[0, 1, 4, 5]] == True).sum())

    x_front_1 = corners[4:8, 0] >= -corners[4:8, 2] * rectify[0][2] / rectify[0][0]
    x_front_2 = corners[4:8, 0] <= corners[4:8, 2] * (width - 1 - rectify[0][2]) / rectify[0][0]
    have_in_front = int(((x_front_1 & x_front_2)[[0, 1, 2, 3]] == True).sum())

    x_tail_1 = corners[0:4, 0] >= -corners[0:4, 2] * rectify[0][2] / rectify[0][0]
    x_tail_2 = corners[0:4, 0] <= corners[0:4, 2] * (width - 1 - rectify[0][2]) / rectify[0][0]
    have_in_tail = int(((x_tail_1 & x_tail_2)[[0, 1, 2, 3]] == True).sum())

    y_front = corners[4:8, 1] <= corners[4:8, 2] * (height - 5 - rectify[1][2]) / rectify[1][1]
    y_tail = corners[0:4, 1] <= corners[0:4, 2] * (height - 5 - rectify[1][2]) / rectify[1][1]
    y_front_center = bool(anchor_front[1] <= anchor_front[2] * (height - 5 - rectify[1][2]) / rectify[1][1])
    y_tail_center = bool(anchor_back[1] <= anchor_back[2] * (height - 5 - rectify[1][2]) / rectify[1][1])

    return {
        "x_down_visible": int(x_down.sum()),
        "have_in": have_in,
        "have_in_front": have_in_front,
        "have_in_tail": have_in_tail,
        "y_front_visible": int(y_front.sum()),
        "y_tail_visible": int(y_tail.sum()),
        "front_center_visible": y_front_center,
        "tail_center_visible": y_tail_center,
    }


def make_face_entry(anchorpoint: ArrayLike, rotation_y: float, camera_setup: CameraSetup, score: float = 1) -> List[float]:
    alpha = wrap_alpha(-np.arctan2(anchorpoint[0], anchorpoint[2]) + rotation_y)
    xc, yc = project_point(anchorpoint, camera_setup)
    return [anchorpoint[0], anchorpoint[1], anchorpoint[2], alpha, xc, yc, score, 1]


def build_vehicle_face_values(
    corners: ArrayLike,
    box3d_cam: Sequence[float],
    rotation_y: float,
    single_lw: int,
    box2d: Sequence[float],
    anno_name: str,
    camera_setup: CameraSetup,
) -> Tuple[ArrayLike, bool]:
    anchor_front = np.mean(corners[4:8, :], axis=0)
    anchor_back = np.mean(corners[0:4, :], axis=0)
    anchor_left = np.mean((corners[1, :], corners[2, :], corners[5, :], corners[6, :]), axis=0)
    anchor_right = np.mean((corners[0, :], corners[4, :], corners[3, :], corners[7, :]), axis=0)
    anchors = [anchor_front, anchor_back, anchor_left, anchor_right]

    visibility = compute_projection_flags(corners, anchor_front, anchor_back, camera_setup)
    face_values = []

    if 1 <= visibility["have_in"] < 4:
        if visibility["front_center_visible"] or visibility["tail_center_visible"]:
            if visibility["have_in_tail"] < 4 and visibility["have_in_front"] == 4:
                face_values = [
                    make_face_entry(anchor_front, rotation_y, camera_setup, score=1),
                    [-1, -1, -1, -1, -1, -1, 0, -1],
                    [-1, -1, -1, -1, -1, -1, 0, -1],
                    [-1, -1, -1, -1, -1, -1, 0, -1],
                ]
            elif visibility["have_in_tail"] == 4 and visibility["have_in_front"] < 4:
                face_values = [
                    [-1, -1, -1, -1, -1, -1, 0, -1],
                    make_face_entry(anchor_back, rotation_y, camera_setup, score=1),
                    [-1, -1, -1, -1, -1, -1, 0, -1],
                    [-1, -1, -1, -1, -1, -1, 0, -1],
                ]
    else:
        if (
            "G1M2" in anno_name
            and visibility["x_down_visible"] < 8
            and rotation_y < 0
            and visibility["y_tail_visible"] < 4
            and visibility["front_center_visible"]
        ):
            face_values = [
                make_face_entry(anchor_front, rotation_y, camera_setup, score=1),
                [-1, -1, -1, -1, -1, -1, 0, -1],
                [-1, -1, -1, -1, -1, -1, 0, -1],
                [-1, -1, -1, -1, -1, -1, 0, -1],
            ]
        elif (
            "G1M2" in anno_name
            and visibility["x_down_visible"] < 8
            and rotation_y > 0
            and visibility["y_front_visible"] == 4
            and visibility["tail_center_visible"]
        ):
            face_values = [
                [-1, -1, -1, -1, -1, -1, 0, -1],
                make_face_entry(anchor_back, rotation_y, camera_setup, score=1),
                [-1, -1, -1, -1, -1, -1, 0, -1],
                [-1, -1, -1, -1, -1, -1, 0, -1],
            ]
        elif (rotation_y < 0 and not visibility["front_center_visible"]) or (
            rotation_y > 0 and not visibility["tail_center_visible"]
        ):
            face_values = []
        else:
            center_x, _, center_z = box3d_cam[:3]
            for anchor in anchors:
                angle = calculate_vector_angle(
                    np.array([anchor[0] - center_x, anchor[2] - center_z]),
                    np.array([anchor[0], anchor[2]]),
                )
                face_values.append(make_face_entry(anchor, rotation_y, camera_setup, score=angle_to_score(angle)))

            face_values = np.array(face_values)
            if single_lw == 1:
                face_values[0:2, 7] = -1
            elif single_lw == 2:
                face_values[2:4, 7] = -1

    face_values = np.array(face_values)
    side_margin = np.min((abs(box2d[0]), abs(camera_setup.orig_width - box2d[2])))
    if "G1M2" in anno_name or "D4Q" in anno_name:
        side_margin_threshold = 12
    elif "G1M3" in anno_name:
        side_margin_threshold = 70
    else:
        side_margin_threshold = 0

    should_keep_3d = True
    if len(face_values) != 0:
        if 1 <= visibility["have_in"] < 4:
            if side_margin > side_margin_threshold and 0.785 <= abs(rotation_y) <= 2.355:
                should_keep_3d = False
        elif not (np.sum(face_values[:, -1]) == -2 and np.sum(face_values[:, -2]) == 1):
            if side_margin < side_margin_threshold:
                should_keep_3d = False
    else:
        should_keep_3d = False

    return face_values, should_keep_3d


def build_3d_label(
    label_lidar: Optional[Sequence[Any]],
    label_det_cls: int,
    box2d: Sequence[float],
    confidence: float,
    anno_name: str,
    camera_setup: CameraSetup,
    warn_on_inconsistent: bool = True,
) -> Tuple[LabelRow, Dict[str, List[str]]]:
    empty_ego_fields = {
        "3d_ori_ego": EMPTY_3D_ORI.copy(),
        "3d_ori_ego_from_cam": EMPTY_3D_ORI.copy(),
        "3d_front_ego": EMPTY_3D_FACE.copy(),
        "3d_back_ego": EMPTY_3D_FACE.copy(),
        "3d_left_ego": EMPTY_3D_FACE.copy(),
        "3d_right_ego": EMPTY_3D_FACE.copy(),
        "3d_ori_ego_consistent": False,
        "3d_ori_ego_diff": stringify([-1.0, -1.0, -1.0]),
        "3d_ori_ego_yaw_diff": "-1.0",
    }
    supports_face_3d = label_det_cls in FACE_3D_CLASS_IDS
    if label_lidar is None or label_det_cls not in SUPPORTED_3D_CLASS_IDS:
        return [-1, confidence], empty_ego_fields

    lidar_values = [float(label_lidar[i]) for i in range(1, 8)]
    length_3d, width_3d, height_3d = lidar_values[3], lidar_values[4], lidar_values[5]
    single_lw = int(float(label_lidar[11]))
    yaw_lidar = lidar_values[6]
    rotation_y = compute_vehicle_rotation(camera_setup.ego_to_cam, lidar_values[6])
    transformed_yaw_lidar = transform_camera_yaw_to_ego_yaw(rotation_y, camera_setup.cam_to_ego)
    ego_fields = {
        "3d_ori_ego": stringify([
            lidar_values[0],
            lidar_values[1],
            lidar_values[2],
            length_3d,
            height_3d,
            width_3d,
            rad_to_deg(transformed_yaw_lidar),
            -1.0,
            -1.0,
            -1.0,
            -1.0,
            -1.0,
            single_lw if supports_face_3d else -1,
        ]),
        "3d_front_ego": EMPTY_3D_FACE.copy(),
        "3d_back_ego": EMPTY_3D_FACE.copy(),
        "3d_left_ego": EMPTY_3D_FACE.copy(),
        "3d_right_ego": EMPTY_3D_FACE.copy(),
    }

    campt2d, campt3d = vehicle_to_image(
        np.array(lidar_values[:3]),
        camera_setup.ego_to_cam,
        camera_setup.distortion_param,
        camera_setup.cam_intrinsic,
        camera_setup.orig_width,
        camera_setup.orig_height,
    )
    campt2d_bottom, _ = vehicle_to_image(
        np.array([lidar_values[0], lidar_values[1], lidar_values[2] - 0.5 * height_3d]),
        camera_setup.ego_to_cam,
        camera_setup.distortion_param,
        camera_setup.cam_intrinsic,
        camera_setup.orig_width,
        camera_setup.orig_height,
    )
    transformed_ori_ego = transform_point(campt3d[:3], camera_setup.cam_to_ego)
    is_consistent, ego_diff = validate_transformed_ego_point(lidar_values[:3], transformed_ori_ego)
    yaw_diff = abs(normalize_angle(transformed_yaw_lidar - yaw_lidar))
    yaw_consistent = yaw_diff <= 1e-3
    ego_fields["3d_ori_ego_from_cam"] = stringify([
        transformed_ori_ego[0],
        transformed_ori_ego[1],
        transformed_ori_ego[2],
        length_3d,
        height_3d,
        width_3d,
        rad_to_deg(transformed_yaw_lidar),
        -1.0,
        -1.0,
        -1.0,
        -1.0,
        -1.0,
        single_lw if supports_face_3d else -1,
    ])
    ego_fields["3d_ori_ego_consistent"] = is_consistent and yaw_consistent
    ego_fields["3d_ori_ego_diff"] = stringify(ego_diff)
    ego_fields["3d_ori_ego_yaw_diff"] = str(rad_to_deg(yaw_diff))
    if warn_on_inconsistent and not (is_consistent and yaw_consistent):
        print(
            "Warning: transformed 3d_ori_ego is inconsistent with original ego pose. "
            f"xyz_diff={ego_diff}, yaw_diff_deg={rad_to_deg(yaw_diff)}, "
            f"lidar_xyz={lidar_values[:3]}, transformed_xyz={transformed_ori_ego.tolist()}, "
            f"lidar_yaw_deg={rad_to_deg(yaw_lidar)}, transformed_yaw_deg={rad_to_deg(transformed_yaw_lidar)}"
        )

    alpha = wrap_alpha(-np.arctan2(campt3d[0], campt3d[2]) + rotation_y)
    label_row = [
        campt3d[0], campt3d[1], campt3d[2],
        length_3d, height_3d, width_3d,
        rotation_y,
        campt2d[0] / camera_setup.orig_width,
        campt2d[1] / camera_setup.orig_height,
        campt2d_bottom[0] / camera_setup.orig_width,
        campt2d_bottom[1] / camera_setup.orig_height,
        alpha,
    ]

    if not supports_face_3d:
        return label_row + [-1, confidence], ego_fields

    box3d_cam = [campt3d[0], campt3d[1], campt3d[2], length_3d, height_3d, width_3d, rotation_y]
    corners = cam_corners_front_rear(np.array(box3d_cam), "whole", pitch=0)
    face_values, should_keep_3d = build_vehicle_face_values(
        corners, box3d_cam, rotation_y, single_lw, box2d, anno_name, camera_setup
    )

    label_row.append(single_lw)
    if not should_keep_3d:
        return [-1, confidence], empty_ego_fields

    for face in face_values:
        for value in face:
            label_row.append(value)

    face_names = ("3d_front_ego", "3d_back_ego", "3d_left_ego", "3d_right_ego")
    for face_name, face in zip(face_names, face_values):
        score = face[6]
        is_occ = face[7]
        if face[0] == -1:
            ego_fields[face_name] = stringify([-1.0, -1.0, -1.0, -1.0, -1.0, -1.0, score, is_occ])
            continue
        ego_point = transform_point(face[:3], camera_setup.cam_to_ego)
        ego_fields[face_name] = stringify([
            ego_point[0],
            ego_point[1],
            ego_point[2],
            -1.0,
            -1.0,
            -1.0,
            score,
            is_occ,
        ])

    label_row.append(confidence)
    return label_row, ego_fields


def build_label_row(
    obj: JsonDict,
    anno_name: str,
    camera_setup: CameraSetup,
    warn_on_inconsistent: bool = True,
) -> Optional[Tuple[LabelRow, Dict[str, List[str]]]]:
    camera_mea = obj["camera_mea"]
    confidence = camera_mea["confidence"]
    coarse_label = camera_mea["cls"]
    raw_label_name = camera_mea["subcls"] if coarse_label == "vehicle" else coarse_label
    # DETECTION_CLASS_MAP normalizes raw aliases to the canonical evaluator class id.
    label_det_cls = DETECTION_CLASS_MAP.get(raw_label_name, -1)
    if label_det_cls < 0:
        return None

    label_name = CLASS_MAP_MODEL.get(label_det_cls)
    if label_name is None:
        raise ValueError(f"Missing canonical label name for class id {label_det_cls} from raw label '{raw_label_name}'")
    if CLASS_MAP.get(label_name) != label_det_cls:
        raise ValueError(
            f"Inconsistent class mapping for raw label '{raw_label_name}': "
            f"canonical label '{label_name}' maps to {CLASS_MAP.get(label_name)}, expected {label_det_cls}"
        )

    occlusion = camera_mea.get("occlusion", "no_occlusion")
    occlusion = OCCLUSION_MAP.get(occlusion, 0)

    label_2d, box2d = build_2d_label(camera_mea, camera_setup)
    row = [label_name] + label_2d

    label_3d, ego_fields = build_3d_label(
        obj.get("lidar_mea"),
        label_det_cls,
        box2d,
        confidence,
        anno_name,
        camera_setup,
        warn_on_inconsistent=warn_on_inconsistent,
    )
    return row + label_3d + [occlusion], ego_fields


def extract_object_id(obj: JsonDict, pure_2d_counter: int) -> Tuple[int, int]:
    lidar_mea = obj.get("lidar_mea")
    if lidar_mea is not None:
        return int(lidar_mea[8]), pure_2d_counter
    return -pure_2d_counter, pure_2d_counter + 1


def generate_label_rows(
    calib_path: str,
    anno_path: str,
    warn_on_inconsistent: bool = True,
) -> List[ConvertedObject]:
    anno_path = Path(anno_path)
    if not anno_path.exists():
        raise FileNotFoundError(f"Annotation file not found: {anno_path}")

    camera_setup = build_camera_setup(calib_path, anno_path.name)
    with anno_path.open("r", encoding="utf-8") as f:
        annotations = json.load(f)["asso_list"]

    converted_objects: List[ConvertedObject] = []
    pure_2d_counter = 1
    for obj in annotations:
        row_and_ego = build_label_row(
            obj,
            anno_path.name,
            camera_setup,
            warn_on_inconsistent=warn_on_inconsistent,
        )
        if row_and_ego is not None:
            row, ego_fields = row_and_ego
            object_id, pure_2d_counter = extract_object_id(obj, pure_2d_counter)
            converted_objects.append({"id": object_id, "parts": row, "ego_fields": ego_fields})
    return converted_objects


def parse_label_list(
    parts: Sequence[float],
    object_id: int,
    ego_fields: Optional[Dict[str, List[str]]] = None,
    img_width: int = 1920,
    img_height: int = 1080,
) -> Optional[JsonDict]:
    """Convert one in-memory label row into the target JSON object."""
    if len(parts) < 6:
        return None

    label = parts[0]
    x_norm, y_norm, w_norm, h_norm = parts[1:5]
    x_center_px = x_norm * img_width
    y_center_px = y_norm * img_height
    w_px = w_norm * img_width
    h_px = h_norm * img_height
    class_id = CLASS_MAP.get(label)
    if class_id is None:
        raise ValueError(f"Unknown standardized label: {label}")

    result = {
        "id": str(object_id),
        "type": class_id,
        "type_name": CLASS_MAP_MODEL.get(class_id, label),
        "box2d": stringify([
            x_center_px - w_px / 2,
            y_center_px - h_px / 2,
            x_center_px + w_px / 2,
            y_center_px + h_px / 2,
        ]),
        "occlusion": int(parts[-1])
    }
    ego_fields = ego_fields or {
        "3d_ori_ego": EMPTY_3D_ORI.copy(),
        "3d_ori_ego_from_cam": EMPTY_3D_ORI.copy(),
        "3d_front_ego": EMPTY_3D_FACE.copy(),
        "3d_back_ego": EMPTY_3D_FACE.copy(),
        "3d_left_ego": EMPTY_3D_FACE.copy(),
        "3d_right_ego": EMPTY_3D_FACE.copy(),
        "3d_ori_ego_consistent": False,
        "3d_ori_ego_diff": stringify([-1.0, -1.0, -1.0]),
        "3d_ori_ego_yaw_diff": "-1.0",
    }

    if len(parts) <= 7 or parts[5] == -1:
        result["3d_ori"] = EMPTY_3D_ORI.copy()
        result["3d_front"] = EMPTY_3D_FACE.copy()
        result["3d_back"] = EMPTY_3D_FACE.copy()
        result["3d_left"] = EMPTY_3D_FACE.copy()
        result["3d_right"] = EMPTY_3D_FACE.copy()
        result["3d_ori_ego"] = ego_fields["3d_ori_ego"]
        result["3d_ori_ego_from_cam"] = ego_fields["3d_ori_ego_from_cam"]
        result["3d_front_ego"] = ego_fields["3d_front_ego"]
        result["3d_back_ego"] = ego_fields["3d_back_ego"]
        result["3d_left_ego"] = ego_fields["3d_left_ego"]
        result["3d_right_ego"] = ego_fields["3d_right_ego"]
        result["3d_ori_ego_consistent"] = ego_fields["3d_ori_ego_consistent"]
        result["3d_ori_ego_diff"] = ego_fields["3d_ori_ego_diff"]
        result["3d_ori_ego_yaw_diff"] = ego_fields["3d_ori_ego_yaw_diff"]
        return result

    x3d_ori, y3d_ori, z3d_ori = parts[5:8]
    l3d, h3d, w3d = parts[8:11]
    rot_y = parts[11]
    xc_ori, yc_ori = parts[12:14]
    xc_ori_d, yc_ori_d = parts[14:16]
    alpha_ori = parts[16]
    roi_type = int(parts[17]) if len(parts) > 17 else 0

    result["3d_ori"] = stringify([
        x3d_ori,
        y3d_ori,
        z3d_ori,
        l3d,
        h3d,
        w3d,
        rot_y,
        xc_ori * img_width,
        yc_ori * img_height,
        xc_ori_d * img_width,
        yc_ori_d * img_height,
        alpha_ori,
        roi_type,
    ])

    if len(parts) >= 50:
        face_names = ("3d_front", "3d_back", "3d_left", "3d_right")
        start = 18
        for name in face_names:
            face = parts[start:start + 8]
            result[name] = stringify([
                face[0],
                face[1],
                face[2],
                face[3],
                face[4] * img_width,
                face[5] * img_height,
                face[6],
                face[7],
            ])
            start += 8
    else:
        result["3d_front"] = EMPTY_3D_FACE.copy()
        result["3d_back"] = EMPTY_3D_FACE.copy()
        result["3d_left"] = EMPTY_3D_FACE.copy()
        result["3d_right"] = EMPTY_3D_FACE.copy()

    result["3d_ori_ego"] = ego_fields["3d_ori_ego"]
    result["3d_ori_ego_from_cam"] = ego_fields["3d_ori_ego_from_cam"]
    result["3d_front_ego"] = ego_fields["3d_front_ego"]
    result["3d_back_ego"] = ego_fields["3d_back_ego"]
    result["3d_left_ego"] = ego_fields["3d_left_ego"]
    result["3d_right_ego"] = ego_fields["3d_right_ego"]
    result["3d_ori_ego_consistent"] = ego_fields["3d_ori_ego_consistent"]
    result["3d_ori_ego_diff"] = ego_fields["3d_ori_ego_diff"]
    result["3d_ori_ego_yaw_diff"] = ego_fields["3d_ori_ego_yaw_diff"]

    return result


def convert_json_to_json(
    input_json: str,
    output_json: str,
    calib_path: str,
    img_width: int = 1920,
    img_height: int = 1080,
    verbose: bool = True,
    warn_on_inconsistent: bool = True,
) -> None:
    input_path = Path(input_json)
    output_path = Path(output_json)
    calib_dir = Path(calib_path)

    if not input_path.exists():
        raise FileNotFoundError(f"Input file not found: {input_path}")
    if not calib_dir.exists():
        raise FileNotFoundError(f"Calibration path not found: {calib_dir}")

    converted_objects = generate_label_rows(
        str(calib_dir),
        str(input_path),
        warn_on_inconsistent=warn_on_inconsistent,
    )
    json_data = {}
    for idx, converted_object in enumerate(converted_objects):
        obj_data = parse_label_list(
            converted_object["parts"],
            converted_object["id"],
            converted_object.get("ego_fields"),
            img_width,
            img_height,
        )
        if obj_data is not None:
            json_data[str(idx)] = obj_data

    output_path.parent.mkdir(parents=True, exist_ok=True)
    with output_path.open("w", encoding="utf-8") as f:
        json.dump(json_data, f, indent=4)

    if verbose:
        print(f"Converted {len(json_data)} objects from {input_path} to {output_path}")
        print(f"Calibration path used: {calib_dir}")
        print(f"Image dimensions used: {img_width}x{img_height}")


def find_annotation_files(json_root: str) -> List[Path]:
    root_path = Path(json_root)
    if not root_path.exists():
        raise FileNotFoundError(f"JSON root path not found: {root_path}")

    annotation_files = sorted(root_path.rglob("annotations/*.json"))
    return annotation_files


def convert_case_annotation(
    annotation_file: Path,
    img_width: int = 1920,
    img_height: int = 1080,
) -> Tuple[Path, bool, str]:
    try:
        annotations_dir = annotation_file.parent
        case_dir = annotations_dir.parent
        calib_dir = case_dir / "calib"
        output_dir = case_dir / "labels_json"
        output_file = output_dir / f"{annotation_file.stem}.json"

        if not calib_dir.exists():
            return annotation_file, False, f"Missing calib directory: {calib_dir}"

        convert_json_to_json(
            str(annotation_file),
            str(output_file),
            str(calib_dir),
            img_width,
            img_height,
            verbose=False,
            warn_on_inconsistent=False,
        )
        return annotation_file, True, "处理成功"
    except Exception as exc:
        return annotation_file, False, str(exc)


def convert_case_annotation_worker(args: Tuple[Path, int, int]) -> Tuple[Path, bool, str]:
    annotation_file, img_width, img_height = args
    return convert_case_annotation(
        annotation_file,
        img_width=img_width,
        img_height=img_height,
    )


def convert_case_chunk_worker(args: Tuple[Tuple[str, ...], int, int]) -> List[Tuple[Path, bool, str]]:
    annotation_files, img_width, img_height = args
    results: List[Tuple[Path, bool, str]] = []
    for annotation_file_str in annotation_files:
        results.append(
            convert_case_annotation(
                Path(annotation_file_str),
                img_width=img_width,
                img_height=img_height,
            )
        )
    return results


def get_case_name(annotation_file: Path) -> str:
    return annotation_file.parent.parent.name


def build_case_stats(annotation_files: Sequence[Path]) -> Tuple[List[str], Dict[str, Dict[str, int]]]:
    case_order: List[str] = []
    case_stats: Dict[str, Dict[str, int]] = {}
    for annotation_file in annotation_files:
        case_name = get_case_name(annotation_file)
        if case_name not in case_stats:
            case_order.append(case_name)
            case_stats[case_name] = {"total": 0, "done": 0, "success": 0, "fail": 0, "last_update": -1}
        case_stats[case_name]["total"] += 1
    return case_order, case_stats


def build_case_chunks(annotation_files: Sequence[Path], chunk_size: int) -> List[Tuple[str, ...]]:
    chunk_size = max(1, chunk_size)
    chunks: List[Tuple[str, ...]] = []
    current_case_name: Optional[str] = None
    current_chunk: List[str] = []

    for annotation_file in annotation_files:
        case_name = get_case_name(annotation_file)
        if current_chunk and (case_name != current_case_name or len(current_chunk) >= chunk_size):
            chunks.append(tuple(current_chunk))
            current_chunk = []

        current_case_name = case_name
        current_chunk.append(str(annotation_file))

    if current_chunk:
        chunks.append(tuple(current_chunk))

    return chunks


def format_progress_bar(completed: int, total: int, width: int = 24) -> str:
    if total <= 0:
        total = 1
    filled = int(width * completed / total)
    if completed > 0 and filled == 0:
        filled = 1
    filled = min(filled, width)
    return "#" * filled + "-" * (width - filled)


def fit_terminal_line(text: str, width: int) -> str:
    if width <= 0:
        return text
    if len(text) <= width:
        return text
    if width <= 3:
        return text[:width]
    return text[: width - 3] + "..."


def render_case_progress_lines(
    case_order: Sequence[str],
    case_stats: Dict[str, Dict[str, int]],
    processed_files: int,
    total_files: int,
    active_case_window: int,
) -> List[str]:
    terminal_width = shutil.get_terminal_size(fallback=(120, 24)).columns
    total_bar_width = 20
    case_bar_width = 16
    case_name_width = min(40, max(16, terminal_width // 3))
    case_order_rank = {case_name: idx for idx, case_name in enumerate(case_order)}
    completed_cases = sum(1 for case_name in case_order if case_stats[case_name]["done"] == case_stats[case_name]["total"])
    lines = [
        fit_terminal_line(
            f"总进度 [{format_progress_bar(processed_files, total_files, width=total_bar_width)}] "
            f"{processed_files}/{total_files} frames | {completed_cases}/{len(case_order)} cases",
            terminal_width - 1,
        )
    ]

    active_case_names = [
        case_name
        for case_name in case_order
        if case_stats[case_name]["done"] < case_stats[case_name]["total"]
    ]
    active_case_names.sort(
        key=lambda case_name: (
            case_stats[case_name]["last_update"],
            -case_order_rank[case_name],
        ),
        reverse=True,
    )

    summary_line = None
    if len(active_case_names) > active_case_window:
        if active_case_window > 1:
            hidden_count = len(active_case_names) - (active_case_window - 1)
            visible_case_names = active_case_names[: active_case_window - 1]
            summary_line = fit_terminal_line(f"… 还有 {hidden_count} 个活跃 case 未显示", terminal_width - 1)
        else:
            visible_case_names = []
            summary_line = fit_terminal_line(f"… 共有 {len(active_case_names)} 个活跃 case", terminal_width - 1)
    else:
        visible_case_names = active_case_names[:active_case_window]

    for case_name in visible_case_names:
        stats = case_stats[case_name]
        status = "⏳"
        case_name_display = fit_terminal_line(case_name, case_name_width).ljust(case_name_width)
        lines.append(
            fit_terminal_line(
                f"{status} {case_name_display} "
                f"[{format_progress_bar(stats['done'], stats['total'], width=case_bar_width)}] "
                f"{stats['done']:>4}/{stats['total']:<4} | 成功:{stats['success']:>4} 失败:{stats['fail']:>4}",
                terminal_width - 1,
            )
        )

    if summary_line is not None:
        lines.append(summary_line)

    if len(lines) == 1:
        lines.append(fit_terminal_line("当前无活跃 case", terminal_width - 1))

    while len(lines) < active_case_window + 1:
        lines.append("")
    return lines


def print_case_progress(
    lines: Sequence[str],
    previous_line_count: int,
) -> int:
    if sys.stdout.isatty():
        for _ in range(previous_line_count):
            sys.stdout.write("\x1b[1A\x1b[2K")
        for idx, line in enumerate(lines):
            sys.stdout.write(line)
            sys.stdout.write("\n" if idx < len(lines) - 1 else "\n")
        sys.stdout.flush()
        return len(lines)

    sys.stdout.write("\r" + lines[0])
    sys.stdout.flush()
    return 1


def batch_convert_json_root(
    json_root: str,
    img_width: int = 1920,
    img_height: int = 1080,
    workers: Optional[int] = None,
    active_case_window: int = 6,
) -> None:
    annotation_files = find_annotation_files(json_root)
    total_files = len(annotation_files)

    if total_files == 0:
        print("⚠️ 未找到任何 annotations/*.json 文件，程序退出")
        return

    print(f"📌 共发现 {total_files} 个JSON标注文件待处理")
    print(f"📂 输入根目录: {json_root}")

    case_order, case_stats = build_case_stats(annotation_files)
    total_cases = len(case_order)
    results = []
    num_workers = workers if workers is not None else min(cpu_count(), total_files)
    num_workers = max(1, min(num_workers, total_files))
    active_case_window = max(1, active_case_window)
    progress_line_count = 0
    progress_refresh_interval = 0.2
    last_progress_render_time = 0.0

    def maybe_render_progress(processed_count: int, force: bool = False) -> None:
        nonlocal progress_line_count, last_progress_render_time
        now = time.monotonic()
        if not force and processed_count < total_files and (now - last_progress_render_time) < progress_refresh_interval:
            return
        progress_line_count = print_case_progress(
            render_case_progress_lines(
                case_order,
                case_stats,
                processed_count,
                total_files,
                active_case_window,
            ),
            progress_line_count,
        )
        last_progress_render_time = now

    if num_workers == 1:
        print("🚀 使用单进程处理")
        for idx, annotation_file in enumerate(annotation_files, start=1):
            file_path, success, msg = convert_case_annotation(
                annotation_file,
                img_width=img_width,
                img_height=img_height,
            )
            results.append((file_path, success, msg))
            case_name = get_case_name(file_path)
            case_stats[case_name]["done"] += 1
            case_stats[case_name]["last_update"] = idx
            if success:
                case_stats[case_name]["success"] += 1
            else:
                case_stats[case_name]["fail"] += 1
            maybe_render_progress(idx, force=idx == total_files)
    else:
        print(f"🚀 启动 {num_workers} 个进程并行处理")
        chunk_size = max(4, min(32, total_files // max(1, num_workers * 4)))
        case_chunks = build_case_chunks(annotation_files, chunk_size)
        worker_args = [
            (case_chunk, img_width, img_height)
            for case_chunk in case_chunks
        ]
        with Pool(processes=num_workers) as pool:
            processed_count = 0
            for chunk_results in pool.imap_unordered(convert_case_chunk_worker, worker_args, chunksize=1):
                for result in chunk_results:
                    file_path, success, msg = result
                    results.append(result)
                    processed_count += 1
                    case_name = get_case_name(file_path)
                    case_stats[case_name]["done"] += 1
                    case_stats[case_name]["last_update"] = processed_count
                    if success:
                        case_stats[case_name]["success"] += 1
                    else:
                        case_stats[case_name]["fail"] += 1
                maybe_render_progress(processed_count, force=processed_count == total_files)

    if not sys.stdout.isatty():
        print()

    success_count = sum(1 for _, success, _ in results if success)
    fail_count = total_files - success_count
    print("\n" + "=" * 60)
    print(f"📊 处理完成 | 成功：{success_count} | 失败：{fail_count} | Cases：{total_cases}")
    if fail_count > 0:
        failed_files = [str(file_path) for file_path, success, _ in results if not success]
        print(f"❌ 失败文件列表：{failed_files}")


def main():
    parser = argparse.ArgumentParser(
        description="Convert original GT JSON directly to the final converted JSON format"
    )
    parser.add_argument("input_json", nargs="?", help="Input original GT JSON file path")
    parser.add_argument("output_json", nargs="?", help="Output converted JSON file path")
    parser.add_argument(
        "--json-root",
        # default="/data1/dongying/Mono3d/G1Q3/dataset_for_evaluation/OP_KPI_SCENE",
        help="Dataset root path. The script will recursively find case/annotations_20260320/*.json, use sibling case/calib, and write outputs to case/labels_20260320",
    )
    parser.add_argument(
        "--calib-path",
        help="Calibration directory for single-file mode; defaults to the input file parent directory",
    )
    parser.add_argument(
        "--image-width",
        type=int,
        default=1920,
        help="Image width for pixel conversion (default: 1920)",
    )
    parser.add_argument(
        "--image-height",
        type=int,
        default=1080,
        help="Image height for pixel conversion (default: 1080)",
    )
    parser.add_argument(
        "--workers",
        type=int,
        default=4,
        help="Number of worker processes for batch mode. Defaults to min(cpu_count(), total_files). Use 1 to disable multiprocessing.",
    )
    parser.add_argument(
        "--active-case-window",
        type=int,
        default=6,
        help="Number of recently updated active cases to display in batch progress output.",
    )
    args = parser.parse_args()

    if args.json_root:
        if args.input_json or args.output_json:
            parser.error("Batch mode with --json-root does not accept input_json/output_json.")
        batch_convert_json_root(
            args.json_root,
            img_width=args.image_width,
            img_height=args.image_height,
            workers=args.workers,
            active_case_window=args.active_case_window,
        )
        return

    if not args.input_json or not args.output_json:
        parser.error("Single-file mode requires input_json and output_json, or use --json-root for batch mode.")

    input_path = Path(args.input_json)
    if args.calib_path:
        calib_path = Path(args.calib_path)
    elif input_path.parent.name.startswith("annotations"):
        calib_path = input_path.parent.parent / "calib"
    else:
        calib_path = input_path.parent
    convert_json_to_json(
        args.input_json,
        args.output_json,
        str(calib_path),
        args.image_width,
        args.image_height,
    )


if __name__ == "__main__":
    main()
