单目3D初始代码

This commit is contained in:
zhao.zhu
2026-06-24 09:35:46 +08:00
commit 04a5895b6b
1153 changed files with 340700 additions and 0 deletions

View File

@@ -0,0 +1 @@
"""Batch PDCL inference package."""

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
# 文件模式(预计算预测结果)
python tools/pdcl_inference/analyze_val_two_roi_badcases.py \
--config eval_tools/configs/eval_config_yolov26s-roi1.yaml \
--analyze-rois roi1 \
--output-root /data1/dongying/Mono3d/G1Q3/model_inference/KPI/DL_KPI_SCENE/report_20260403

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,498 @@
import argparse
import io
import json
import os
import sys
import traceback
from pathlib import Path
from typing import Iterable, List, Optional, Tuple
import cv2
import numpy as np
FILE = Path(__file__).resolve()
ROOT = FILE.parents[2]
if str(ROOT) not in sys.path:
sys.path.append(str(ROOT))
# Match the reference PDCL exporter behavior while still allowing callers to override
# credentials from their shell environment or `.env`.
os.environ.setdefault("STS_UID", "dis-uploader")
os.environ.setdefault("STS_SECRET_KEY", "277310cc09724d315514a79701fecb0f")
try:
from dotenv import load_dotenv
except ImportError:
def load_dotenv(*args, **kwargs):
return False
from pdcl_pyclip.decoder_struct import StructDecoder
from pdcl_pyclip.msg_camera import VideoMessage
from pdcl_pyclip.reader import ClipReader
from tools.pdcl_inference.pdcl_clip_service import PDCLClipService
from tools.pdcl_inference.pipeline_types import ClipTask, TaskResult
from tools.pdcl_inference.status_store import StatusStore
CALIB_ATTACHMENT_NAMES = (
"sigmastar.1/calibs/camera4.json",
"test_data/calibs/camera4.json",
"calibs/camera4.json",
)
def validate_pdcl_auth_env() -> None:
required_vars = ["STS_UID", "STS_SECRET_KEY"]
missing = [name for name in required_vars if not os.getenv(name)]
if missing:
raise ValueError(
"Missing required PDCL auth env vars: "
+ ", ".join(missing)
+ ". Please export them in shell or set them in .env before running."
)
def _plane_to_ndarray(plane) -> np.ndarray:
stride = plane.line_size
height = plane.height
width = plane.width
array = np.frombuffer(plane, dtype=np.uint8)
if stride == width:
return array.reshape(height, width)
return array.reshape(height, stride)[:, :width]
def _yuvj420p_to_nv12(
y_plane: np.ndarray,
u_plane: np.ndarray,
v_plane: np.ndarray,
) -> Tuple[np.ndarray, np.ndarray]:
uv_height = u_plane.shape[0]
uv_width = u_plane.shape[1]
uv_plane = np.zeros((uv_height, uv_width * 2), dtype=np.uint8)
uv_plane[:, 0::2] = u_plane
uv_plane[:, 1::2] = v_plane
return y_plane.copy(), uv_plane
def _h265_payload_to_bgr(payload: bytes) -> np.ndarray:
try:
import av
except ImportError as exc:
raise ImportError("PyAV is required for mcap decoding. Please install: pip install av") from exc
container = av.open(io.BytesIO(payload))
for frame in container.decode(video=0):
y_plane = _plane_to_ndarray(frame.planes[0])
u_plane = _plane_to_ndarray(frame.planes[1])
v_plane = _plane_to_ndarray(frame.planes[2])
y_nv12, uv_nv12 = _yuvj420p_to_nv12(y_plane, u_plane, v_plane)
yuv_image = np.concatenate((y_nv12, uv_nv12), axis=0)
return cv2.cvtColor(yuv_image, cv2.COLOR_YUV2BGR_NV12)
raise ValueError("decode failed: no video frame in payload")
def resolve_calib_file_for_clip(clip_path: str) -> Optional[Path]:
base = Path(clip_path).resolve()
search_dir = base.parent
candidates = []
for _ in range(4):
candidates.extend(
[
search_dir / "calibs" / "camera4.json",
search_dir / "test_data" / "calibs" / "camera4.json",
search_dir / "L2_calib" / "camera4.json",
search_dir / "calib" / "L2_calib" / "camera4.json",
]
)
parent = search_dir.parent
if parent == search_dir:
break
search_dir = parent
for calib_path in candidates:
if calib_path.exists():
return calib_path
return None
def load_embedded_calib_payload(clip_path: str) -> Optional[dict]:
reader = ClipReader(clip_path)
for attachment in reader.iter_attachments():
if attachment.name in CALIB_ATTACHMENT_NAMES:
return json.loads(attachment.data.decode("utf-8"))
return None
def load_calib_payload_for_clip(
clip_path: str,
calib_file_override: str = "",
) -> Tuple[dict, str]:
if calib_file_override:
calib_src = Path(calib_file_override).resolve()
if not calib_src.exists():
raise FileNotFoundError(f"Calibration file {calib_src} does not exist.")
with calib_src.open("r", encoding="utf-8") as file:
return json.load(file), f"override:{calib_src}"
calib_src = resolve_calib_file_for_clip(clip_path)
if calib_src is not None:
with calib_src.open("r", encoding="utf-8") as file:
return json.load(file), f"file:{calib_src}"
embedded_calib = load_embedded_calib_payload(clip_path)
if embedded_calib is not None:
return embedded_calib, "mcap_attachment"
raise FileNotFoundError(
"Calibration file camera4.json not found near clip and not found in mcap attachments. "
"Please provide --calib-file explicitly."
)
def decode_mcap_to_images(
clip_uuid: str,
clip_path: str,
output_dir: Path,
camera_topic: str,
max_frames: int,
) -> Tuple[int, Optional[dict]]:
output_dir.mkdir(parents=True, exist_ok=True)
saved = 0
camera4_json = load_embedded_calib_payload(clip_path)
for frame_name, image in iter_decoded_clip_frames(
clip_uuid=clip_uuid,
clip_path=clip_path,
camera_topic=camera_topic,
max_frames=max_frames,
):
if not cv2.imwrite(str(output_dir / frame_name), image):
raise IOError(f"Failed to write image: {output_dir / frame_name}")
saved += 1
return saved, camera4_json
def iter_decoded_clip_frames(
clip_uuid: str,
clip_path: str,
camera_topic: str,
max_frames: int,
) -> Iterable[Tuple[str, np.ndarray]]:
reader = ClipReader(clip_path)
struct_decoder = StructDecoder()
decoded = 0
for schema, channel, msg in reader.iter_messages(topics=[camera_topic]):
if schema.encoding != "struct":
continue
data = struct_decoder.decode(schema, channel, msg)
if not isinstance(data, VideoMessage):
continue
frame_name = f"{clip_uuid}_{data.frame_id}.png"
yield frame_name, _h265_payload_to_bgr(data.payload)
decoded += 1
if max_frames > 0 and decoded >= max_frames:
break
def save_calib_for_clip(
clip_path: str,
calib_output_path: Path,
calib_file_override: str,
embedded_calib: Optional[dict],
) -> str:
calib_output_path.parent.mkdir(parents=True, exist_ok=True)
if calib_file_override:
calib_src = Path(calib_file_override).resolve()
if not calib_src.exists():
raise FileNotFoundError(f"Calibration file {calib_src} does not exist.")
calib_output_path.write_bytes(calib_src.read_bytes())
return f"override:{calib_src}"
calib_src = resolve_calib_file_for_clip(clip_path)
if calib_src is not None:
calib_output_path.write_bytes(calib_src.read_bytes())
return f"file:{calib_src}"
if embedded_calib is None:
raise FileNotFoundError(
"Calibration file camera4.json not found near clip and not found in mcap attachments. "
"Please provide --calib-file explicitly."
)
with open(calib_output_path, "w") as file:
json.dump(embedded_calib, file, indent=2, ensure_ascii=False)
return "mcap_attachment"
def build_calib_summary(calib_payload: Optional[dict]) -> dict:
"""Return a compact summary that works for flat and combined camera4 calib JSON files."""
if not isinstance(calib_payload, dict):
return {
"format": "unknown",
"has_distortion": False,
}
intrinsics = calib_payload
extrinsics = None
calib_format = "flat_camera4"
if "intrinsics" in calib_payload:
intrinsics = calib_payload.get("intrinsics", {}).get("camera4.json", {}) or {}
extrinsics = calib_payload.get("extrinsics", {}).get("camera4.json", {}) or {}
calib_format = "combined_calibration"
distort_coeffs = intrinsics.get("distort_coeffs", calib_payload.get("distort_coeffs", [])) or []
return {
"format": calib_format,
"focal_u": intrinsics.get("focal_u"),
"focal_v": intrinsics.get("focal_v"),
"cu": intrinsics.get("cu"),
"cv": intrinsics.get("cv"),
"pitch": calib_payload.get("pitch", extrinsics.get("rpy", [None, None, None])[1] if extrinsics else None),
"yaw": calib_payload.get("yaw", extrinsics.get("rpy", [None, None, None])[2] if extrinsics else None),
"image_width": calib_payload.get("image_width", calib_payload.get("img_width")),
"image_height": calib_payload.get("image_height", calib_payload.get("img_height")),
"distort_coeff_count": len(distort_coeffs),
"has_distortion": bool(distort_coeffs),
}
def parse_clip_list(clip_list_file: str, vehicle_name: str) -> List[ClipTask]:
tasks: List[ClipTask] = []
with open(clip_list_file, "r") as file:
for line in file:
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split()
clip_uuid = parts[0]
date_name = parts[1] if len(parts) >= 2 else "unknown_date"
item_vehicle_name = parts[2] if len(parts) >= 3 else vehicle_name
clip_path = PDCLClipService.get_clip_path(clip_uuid)
if clip_path is None:
print(f"Skip clip_id={clip_uuid}: no files found")
continue
tasks.append(
ClipTask(
clip_uuid=clip_uuid,
date_name=date_name,
vehicle_name=item_vehicle_name,
clip_path=clip_path,
)
)
return tasks
def build_case_dir_name(prefix: str, clip_task: ClipTask) -> str:
safe_clip_uuid = clip_task.clip_uuid.replace("/", "_")
return f"{prefix}_{clip_task.vehicle_name}_{clip_task.date_name}_{safe_clip_uuid}"
def save_manifest(
output_dir: Path,
clip_task: ClipTask,
frame_count: int,
calib_source: str,
camera_topic: str,
calib_summary: Optional[dict] = None,
) -> None:
manifest = {
"clip_uuid": clip_task.clip_uuid,
"date_name": clip_task.date_name,
"vehicle_name": clip_task.vehicle_name,
"clip_path": clip_task.clip_path,
"camera_topic": camera_topic,
"frame_count": frame_count,
"calib_source": calib_source,
"calib_summary": calib_summary,
}
with open(output_dir / "manifest.json", "w") as file:
json.dump(manifest, file, indent=2, ensure_ascii=False)
def save_calib_summary(output_dir: Path, calib_source: str, calib_summary: dict) -> None:
payload = {
"calib_source": calib_source,
"calib_summary": calib_summary,
}
with open(output_dir / "calib_summary.json", "w") as file:
json.dump(payload, file, indent=2, ensure_ascii=False)
def export_one_clip(
clip_task: ClipTask,
args: argparse.Namespace,
status: StatusStore,
) -> TaskResult:
task_id = clip_task.task_id
if args.skip_done and status.is_done(task_id):
info = status.get(task_id) or {}
return TaskResult(task_id=task_id, success=True, message=f"skip done: {info.get('detail', '')}")
case_dir = Path(args.output_root) / build_case_dir_name(args.output_prefix, clip_task)
images_dir = case_dir / "images"
calib_path = case_dir / "calib" / "L2_calib" / "camera4.json"
status.mark_running(task_id, f"running source={clip_task.clip_path}")
try:
frame_count, embedded_calib = decode_mcap_to_images(
clip_uuid=clip_task.clip_uuid,
clip_path=clip_task.clip_path,
output_dir=images_dir,
camera_topic=args.camera_topic,
max_frames=args.max_frames_per_clip,
)
calib_source = save_calib_for_clip(
clip_path=clip_task.clip_path,
calib_output_path=calib_path,
calib_file_override=args.calib_file,
embedded_calib=embedded_calib,
)
with open(calib_path, "r", encoding="utf-8") as file:
calib_payload = json.load(file)
calib_summary = build_calib_summary(calib_payload)
save_manifest(
output_dir=case_dir,
clip_task=clip_task,
frame_count=frame_count,
calib_source=calib_source,
camera_topic=args.camera_topic,
calib_summary=calib_summary,
)
save_calib_summary(case_dir, calib_source=calib_source, calib_summary=calib_summary)
status.mark_done(task_id, str(case_dir))
return TaskResult(task_id=task_id, success=True, message=f"ok frames={frame_count}", output_dir=str(case_dir))
except Exception as exc:
message = f"{type(exc).__name__}: {exc}"
status.mark_failed(task_id, message)
err_log = Path(args.output_root) / "_status" / "errors.log"
err_log.parent.mkdir(parents=True, exist_ok=True)
with open(err_log, "a") as file:
file.write(f"[{task_id}] {message}\n{traceback.format_exc()}\n")
return TaskResult(task_id=task_id, success=False, message=message)
def save_run_manifest(args: argparse.Namespace, clip_tasks: List[ClipTask]) -> None:
manifest_path = Path(args.output_root) / "_status" / "run_manifest.json"
manifest_path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"clip_list_file": args.clip_list_file,
"output_root": args.output_root,
"camera_topic": args.camera_topic,
"max_frames_per_clip": args.max_frames_per_clip,
"num_tasks": len(clip_tasks),
"tasks": [
{
"task_id": task.task_id,
"clip_uuid": task.clip_uuid,
"date_name": task.date_name,
"vehicle_name": task.vehicle_name,
"clip_path": task.clip_path,
}
for task in clip_tasks
],
}
with open(manifest_path, "w") as file:
json.dump(payload, file, indent=2, ensure_ascii=False)
def parse_opt() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Batch decode PDCL MCAP clips to images and save matching camera calibration"
)
parser.add_argument(
"--clip-list-file",
type=str,
default=str(FILE.parent / "clips_6286.txt"),
help="Path to text file: '<clip_id> [date] [vehicle_name]' per line",
)
parser.add_argument(
"--vehicle-name",
type=str,
default="G1M3",
help="Default vehicle name when clip-list does not include a third column",
)
parser.add_argument(
"--output-root",
type=str,
default=str(ROOT / "runs" / "pdcl_clip_exports"),
help="Root directory used to save decoded frames and calibration",
)
parser.add_argument(
"--output-prefix",
type=str,
default="clip_export",
help="Prefix used in each clip output directory name",
)
parser.add_argument("--camera-topic", type=str, default="camera4", help="Camera topic name in mcap")
parser.add_argument(
"--max-frames-per-clip",
type=int,
default=0,
help="Max frames decoded for each clip; 0 means all",
)
parser.add_argument(
"--calib-file",
type=str,
default="",
help="Optional override path to camera4.json for all exported clips",
)
parser.add_argument("--skip-done", action="store_true", help="Skip clips already marked done in status file")
parser.add_argument("--limit-clips", type=int, default=0, help="Limit number of clips; 0 means all")
return parser.parse_args()
def main() -> None:
load_dotenv()
validate_pdcl_auth_env()
args = parse_opt()
os.makedirs(args.output_root, exist_ok=True)
clip_tasks = parse_clip_list(args.clip_list_file, args.vehicle_name)
if args.limit_clips > 0:
clip_tasks = clip_tasks[: args.limit_clips]
if not clip_tasks:
print("No clip tasks discovered. Exit.")
return
status_path = Path(args.output_root) / "_status" / "task_status.json"
status = StatusStore(status_path)
save_run_manifest(args, clip_tasks)
print(f"Discovered {len(clip_tasks)} clips.")
print(f"Output root: {args.output_root}")
print(f"Status file: {status_path}")
success = 0
fail = 0
for idx, clip_task in enumerate(clip_tasks, start=1):
print(
f"[{idx}/{len(clip_tasks)}] clip_id={clip_task.clip_uuid} "
f"source={clip_task.clip_path}"
)
result = export_one_clip(clip_task, args, status)
if result.success:
success += 1
else:
fail += 1
print(f" -> {result.message}")
print("\nBatch export done.")
print(f"success={success}, fail={fail}")
print(f"status_summary={status.summary()}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
# Launcher for batch exporting PDCL MCAP clips to frames + calibration.
#
# Usage:
# bash tools/pdcl_inference/export_mcap_frames_by_clip_id.sh
#
# Optional env overrides:
# CLIP_LIST_FILE, OUTPUT_ROOT, OUTPUT_PREFIX,
# CAMERA_TOPIC, MAX_FRAMES_PER_CLIP, CALIB_FILE, EXTRA_ARGS
CLIP_LIST_FILE="${CLIP_LIST_FILE:-tools/pdcl_inference/clips_6284.txt}"
OUTPUT_ROOT="${OUTPUT_ROOT:-/data1/dongying/Mono3d/G1M3/cases_pdcl_clips}"
OUTPUT_PREFIX="${OUTPUT_PREFIX:-}"
CAMERA_TOPIC="${CAMERA_TOPIC:-camera4}"
MAX_FRAMES_PER_CLIP="${MAX_FRAMES_PER_CLIP:-0}"
CALIB_FILE="${CALIB_FILE:-}"
EXTRA_ARGS="${EXTRA_ARGS:-}"
python tools/pdcl_inference/export_mcap_frames_by_clip_id.py \
--clip-list-file "${CLIP_LIST_FILE}" \
--output-root "${OUTPUT_ROOT}" \
--output-prefix "${OUTPUT_PREFIX}" \
--camera-topic "${CAMERA_TOPIC}" \
--max-frames-per-clip "${MAX_FRAMES_PER_CLIP}" \
${CALIB_FILE:+--calib-file "${CALIB_FILE}"} \
--skip-done \
${EXTRA_ARGS}

View File

@@ -0,0 +1,695 @@
import requests
import json,glob
import os
import tarfile
import av
import io
import cv2
import numpy as np
import pandas as pd
from typing import Dict, List, Any
from enum import IntEnum
from datetime import datetime, timezone, timedelta
import multiprocessing
from multiprocessing import Pool
from dotenv import load_dotenv
load_dotenv()
from pdcl_dss import DSSClient, Raw, Group, Clip
from pdcl_pyclip.reader import ClipReader
from pdcl_pyclip.msg_camera import VideoMessage
from pdcl_pyclip.decoder_struct import StructDecoder
from pdcl_pyclip.decoder_protobuf import ProtobufDecoder
os.environ['STS_UID'] = 'dis-uploader'
os.environ['STS_SECRET_KEY'] = '277310cc09724d315514a79701fecb0f'
client = DSSClient()
GET_JSON_BY_UKEY_API_URL = (
"https://d.minieye.tech/dis-api/dashboard/calib-file-manage/json-files-by-ukey"
)
BEIJING_TZ = timezone(timedelta(hours=8))
det_cls_map = { 'kVehicle':0,
'kPed':1,
'kBike':2,
'kCyclist':3,
'kCone':4,
'kRoadBarrier':4,
'kPedHead':5,
'kSmallTrafficSign': 6,
'kWarningTriangle' :6,
'kBigTrafficSign':7,
'kVehiclePlate' :8,
'kVehicleWheel':9,
'kTrafficLight':10,
'kTrafficLightBulb' :11,
'kTrafficLightDigit':12
}
posecls = {
'kInvalid' : 0, # 背景
'kLeftTail' : 1 , # 左侧同向车
'kMidTail' : 2, # 中间同向车
'kRightTail' : 3, # 右侧同向车
'kLeftHead' : 4, # 左侧对向车
'kMidHead' : 5, # 中间对向车
'kRightHead' : 6, # 右侧对向车
'kLeftSide' : 7, # 朝左横向车
'kRightSide' : 8, # 朝右横向车
'kLeftCutIn' : 9, # 左侧切入
'kRightCutIn' : 10, # 右侧切入
'kLeftCutOut' : 11, # 左侧切出
'kRightCutOut' : 12, # 右侧切出
'kOccluded' : 13, # 车头遮挡/车尾遮挡/横向遮挡 (deprecated)
'kSide' : 14, # 横向车(不知道朝左,还是朝右)
'kOccludedTail' : 15, # 车尾遮挡
'kOccludedHead' : 16, # 车头遮挡
'kOccludedSide' : 17, # 横向遮挡
'kUnknownPose' : 20 # unknown pose
}
subcls = {
'kNegative' : 0, # 背景
'kBus' : 1, # 大巴
'kCar' : 2, # 小轿车,suv
'kMiniBus' : 3, # 面包车
'kBucketTruck' : 4, # 斗卡
'kContainerTruck' : 5, # 箱卡
'kTricycle' : 6, # 三轮车
'kTanker' : 7, # 油罐车,晒水车(车身带有圆形,椭圆形,半圆形的罐)
'kCementTankTruck' : 8, # 水泥罐车
'kPickup' : 9, # 皮卡
'kSedimentTruck' : 10, # 渣土车
'kIveco' : 11, # 依维柯
'kSpecialCar' : 12, # 异型车
'kCityAuto' : 13, # 市政车
'kVehicleUnknown' : 14, # 未知车辆
'kWrecker' : 15, # 小型拖车,清障车
'kFlatTransporter' : 16,# 大型平板拖车
'kTractorHead' : 17, # 卡车车头,牵引车车头
'kSpecialFlatTransporter' : 18, # 大型特种拖车(相对于大型平板拖车会多挡板)
'kCarCarrier' : 19, # 轿运车
# 盖布车(车身上的货物全部被布盖着,同时对车辆尾部进行了遮挡)
'kTruckWithTrap' : 20,
'kEngineeringVehicle' : 21, # 工程车
'kSweeper' : 22, # 扫地车
'kRoadServiceCar' : 23, # 道路维修车
'kSanitationTruck' : 24, # 环卫车
'kGarbageTruck' : 25, # 垃圾车
}
def timestamp_to_ymd(timestamp_ms: int) -> str:
"""
将毫秒时间戳转换为北京时间 YYYYMMDD 格式
例如1712345678900 -> '20250406'
"""
try:
timestamp_s = timestamp_ms / 1000
utc_time = datetime.fromtimestamp(timestamp_s, tz=timezone.utc)
beijing_time = utc_time.astimezone(BEIJING_TZ)
return beijing_time.strftime('%Y%m%d%H%M%S')
except Exception as e:
raise ValueError(f"无效毫秒时间戳 {timestamp_ms!r}: {e}") from e
def exist_product_in_group(group_meta, product_type):
'''
判断group是否存在某种真值产物
'''
if group_meta['failReason'] == '' and group_meta['products'] is not None:
with Group(group_meta['id']) as group:
if group.get_product(product_type) is not None:
return True
return False
def plane_to_ndarray(plane):
"""
将 VideoPlane 对象转换为 NumPy 数组
"""
# 获取平面的步长stride和行数
stride = plane.line_size
height = plane.height
width = plane.width
# 根据步长创建数组
# 如果步长等于宽度,可以直接重塑
if stride == width:
array = np.frombuffer(plane, dtype=np.uint8).reshape(height, width)
else:
# 如果步长不等于宽度,需要处理填充字节
array = np.frombuffer(plane, dtype=np.uint8)
# 取每行的前width个字节
array = array.reshape(height, stride)[:, :width]
return array
def yuvj420p_to_nv12(y_plane, u_plane, v_plane):
"""
将 YUVJ420P3个平面转换为 NV122个平面格式。
参数:
y_plane (np.ndarray): 亮度Y平面形状为 (height, width), dtype=uint8
u_plane (np.ndarray): 色度U平面形状为 (height\\\\2, width\\\\2), dtype=uint8
v_plane (np.ndarray): 色度V平面形状为 (height\\\\2, width\\\\2), dtype=uint8
返回:
tuple: (y_plane_nv12, uv_plane_nv12)
- y_plane_nv12: 亮度平面,与输入相同
- uv_plane_nv12: 交织的UV平面形状为 (height\\\\2, width)
"""
# 1. Y平面直接复制保持不变
y_plane_nv12 = y_plane.copy()
# 2. 获取尺寸信息
uv_height = u_plane.shape[0] # height \\\\ 2
uv_width = u_plane.shape[1] # width \\\\ 2
# 3. 创建目标UV平面宽度是原UV宽度的2倍因为要交错存放U和V
uv_plane_nv12 = np.zeros((uv_height, uv_width * 2), dtype=np.uint8)
# 4. 将U和V平面数据交错地填入目标UV平面
# UV平面的偶数索引位置放U奇数索引位置放V
uv_plane_nv12[:, 0::2] = u_plane # 所有行第0,2,4,...列 = U
uv_plane_nv12[:, 1::2] = v_plane # 所有行第1,3,5,...列 = V
return y_plane_nv12, uv_plane_nv12
def nv12_to_bgr_manual(y_plane, uv_plane):
"""
手动将 NV12 转换为 BGR
"""
yuv_image = np.concatenate((y_plane, uv_plane), axis=0)
bgr_image = cv2.cvtColor(yuv_image, cv2.COLOR_YUV2BGR_NV12) # 使用OpenCV进行YUV到BGR的转换
return bgr_image
def cam_img_kb(x,y,z,cam_intrinsic,distort_param):
# objp=np.array([x/z,y/z,1]).reshape(1,-1,3)
# rvec = np.array([[[0., 0., 0.]]])
# tvec = np.array([[[0., 0., 0.]]])
# image_coord, _ = cv2.fisheye.projectPoints(objp, rvec, tvec,cam_intrinsic, distort_param)
# image_coord = image_coord.reshape(-1)
# return image_coord
camxyz = np.array([x/z, y/z]).reshape(-1,2)
a = camxyz[...,0]
b = camxyz[...,1]
r = np.sqrt(a * a + b * b)
theta = np.arctan(r)
k1 = distort_param[0]
k2 = distort_param[1]
k3 = distort_param[2]
k4 = distort_param[3]
thetaD = theta * (1 + k1 * theta**2 + k2 * theta**4 + k3 * theta**6 + k4 * theta**8)
xp = thetaD / r * a
yp = thetaD / r * b
fx = cam_intrinsic[0,0]
fy = cam_intrinsic[1,1]
cx = cam_intrinsic[0,2]
cy = cam_intrinsic[1,2]
imgx = (fx * xp + cx)
imgy = (fy * yp + cy)
# mask_2d = (imgx>=0)&(imgx<3840)&(imgy>=0)&(imgy<2160)
pix_point = np.round(np.concatenate((imgx.reshape((-1,1)),imgy.reshape((-1,1))),axis=-1))
# pix_point = pix_point[mask_2d].astype(np.int32)
pix_point = pix_point.astype(np.int32)
return pix_point.reshape(-1)
def xyz_xy(x,y,distort_param,K):
r = np.sqrt(x * x + y * y)
r_2 = r * r
r_4 = r_2 * r_2
r_6 = r_4 * r_2
m_distort_coeffs = distort_param
coeffs = np.zeros(12,dtype=np.float32)
for i in range(min(m_distort_coeffs.shape[0],12)):
coeffs[i] = m_distort_coeffs[i]
radial_ratio = (1 + coeffs[0] * r_2 + coeffs[1] * r_4 + coeffs[4] * r_6) / (1 + coeffs[5] * r_2 + coeffs[6] * r_4 + coeffs[7] * r_6)
res_x = x * radial_ratio + 2 * coeffs[2] * x * y + coeffs[3] * (r_2 + 2 * pow(x, 2)) + coeffs[8] * r_2 + coeffs[9] * r_4
res_y = y * radial_ratio + 2 * coeffs[2] * (r_2 + 2 * pow(y, 2)) + coeffs[3] * x * y + coeffs[10] * r_2 + coeffs[11] * r_4
image_coord = np.dot(K,np.array([res_x,res_y,1])).T
return image_coord
def Lidar2OrigImg(pts3d, lidar_to_cam, distort_param, K, origW, origH):
pts3d_cam = np.dot(lidar_to_cam[:3, :3],[pts3d[0],pts3d[1],pts3d[2]]) + lidar_to_cam[:3, 3]
if len(distort_param)==5:
pts2d_cam = xyz_xy(pts3d_cam[0] /pts3d_cam[2], pts3d_cam[1] / pts3d_cam[2], distort_param, K)
else:
pts2d_cam = cam_img_kb(pts3d_cam[0],pts3d_cam[1],pts3d_cam[2],K,distort_param)
# pts2d_cam = cam_img_kb(pts3d_cam[0],pts3d_cam[1],pts3d_cam[2],K,distort_param)
# pts2d_cam = xyz_xy(pts3d_cam[0] / pts3d_cam[2],pts3d_cam[1] / pts3d_cam[2],distort_param,K)
pts2d_cam = pts2d_cam[0:2]
pts2d_cam[0] = np.clip(pts2d_cam[0],0,origW-1)
pts2d_cam[1] = np.clip(pts2d_cam[1],0,origH-1)
return pts2d_cam, pts3d_cam
def show_group_uuid(raw_id: str) -> None:
'''
通过 raw_id 列出所有通过清洗的 group_uuid
'''
with Raw(raw_id) as raw:
group_uuid_list = raw.list_group_ukeys()
for group_uuid in group_uuid_list:
print(group_uuid)
def call_api(body: Dict[str, str], url: str) -> Dict[str, Any]:
"""
调用标定文件管理API
Args:
body: 请求体参数包含plate_number
url: API接口地址
Returns:
API返回的完整响应数据
"""
headers = {
"User-Agent": "Apifox/1.0.0 (https://apifox.com)",
"Content-Type": "application/json",
"Accept": "*/*",
"Host": "d.minieye.tech",
"Connection": "keep-alive",
}
try:
response = requests.post(url, headers=headers, json=body)
response.raise_for_status() # 检查HTTP错误
return response.json()
except requests.exceptions.RequestException as e:
print(f"请求失败: {e}")
return {}
except json.JSONDecodeError as e:
print(f"JSON解析失败: {e}")
return {}
def wirite_calib_by_ukey(ukey: str, output_path: str) -> None:
"""
通过 ukey 获取标定信息并写入本地文件
"""
full_info = call_api(ukey, GET_JSON_BY_UKEY_API_URL)["json_files_content"]
for key, value in full_info.items():
result_dir = os.path.join(output_path, os.path.dirname(key))
os.makedirs(result_dir, exist_ok=True)
with open(os.path.join(output_path, key), 'w') as fw:
json.dump(value, fw, indent=4)
def get_calib_info(group_uuid: str, output_path: str) -> None:
'''
通过 group_uuid 获取标定信息
'''
with Group(group_uuid) as group:
calib_ukey = group.meta.get('calib_filepath', None)
if calib_ukey is None:
print(f"未找到标定信息 for group_uuid: {group_uuid}")
return
ukey = {"ukey": calib_ukey}
wirite_calib_by_ukey(ukey, output_path)
def getRoadCurve(textpath):
Curuuids = []
textlists = open(textpath,'r').readlines()
for text in textlists:
context = text.strip('\n').split(' ')
uuid = context[0]
frameid = context[1]
couvature = float(context[2])
if abs(couvature) < 150:
if uuid not in Curuuids:
Curuuids.append(uuid)
return Curuuids
def find_index_by_timestamp(df: pd.DataFrame, camera_id: str, timestamp_ns: int) -> int:
json_index = df[abs(df[f'{camera_id}'] - timestamp_ns) < 1e-3].index[0]
return json_index
def parse_video(car_name: str, date_name: str, groupuuid: str, json_list: List, clip_paths: List, camera_id: str, df: pd.DataFrame, output_path: str) -> Dict:
'''
解析视频流,保存为图片
'''
print(f" 开始解析视频并保存图片...")
def h265_to_image(frame):
container = av.open(io.BytesIO(frame))
for frame_yuvj420p in container.decode(video=0):
y_plane = plane_to_ndarray(frame_yuvj420p.planes[0])
u_plane = plane_to_ndarray(frame_yuvj420p.planes[1])
v_plane = plane_to_ndarray(frame_yuvj420p.planes[2])
# 转换为 NV12
y_nv12, uv_nv12 = yuvj420p_to_nv12(y_plane, u_plane, v_plane)
frame = nv12_to_bgr_manual(y_nv12, uv_nv12)
return frame
raise ValueError("解码失败")
image_path = os.path.join(output_path, 'images')
os.makedirs(image_path, exist_ok=True)
struct_decoder = StructDecoder()
saved_count = 0
for clip_path in clip_paths:
reader = ClipReader(clip_path)
for schema, channel, msg in reader.iter_messages(topics=[camera_id]):
if schema.encoding == "struct":
data = struct_decoder.decode(schema, channel, msg)
if not isinstance(data, VideoMessage):
continue
camera_ts_ns = msg.log_time
json_index = find_index_by_timestamp(df, camera_id, camera_ts_ns)
if json_list is not None:
if json_index not in json_list:
continue
frame = data.payload
img = h265_to_image(frame)
# filepath = os.path.join(image_path, f"{json_index:06d}.jpg")
filepath = image_path + '/' + '%s_%s_%s_%06d_%d.png'%(car_name, date_name, groupuuid, json_index,df['camera4_frame_id'][json_index])
# 使用OpenCV保存图片
cv2.imwrite(str(filepath), img)
saved_count += 1
print(f" 保存了 {saved_count} 张图片到 {image_path}")
def get_clip_paths(group_uuid: str) -> List[str]:
'''
获取 group 下的所有 clip 路径
'''
clip_path_list = []
with Group(group_uuid) as group:
for clip_ukey in group.list_clip_ukeys():
clip = Clip(clip_ukey)
files, raw_files = clip.list_files()
cache_file_path = clip.get_cache_path(files[0])
clip_path_list.append(cache_file_path)
return clip_path_list
class NumericType(IntEnum):
UNKNOWN = 0
UINT8 = 1
INT8 = 2
UINT16 = 3
INT16 = 4
UINT32 = 5
INT32 = 6
FLOAT32 = 7
FLOAT64 = 8
def get_frame_timestamps_data_from_tar(group_uuid: str) -> pd.DataFrame:
with Group(group_uuid) as group:
# 获取时间同步表
with group.open('clean_data_archive.tar', mode="rb") as f:
tar = tarfile.open(fileobj=f)
csv_obj = tar.extractfile(tar.getmember("frame_timestamps.csv"))
df = pd.read_csv(csv_obj)
return df
def load_point_cloud(json_index, df, clip_path_list):
# 类型映射表
type_mapping = {
NumericType.FLOAT32: 'f4', NumericType.FLOAT64: 'f8',
NumericType.UINT8: 'u1', NumericType.UINT16: 'u2', NumericType.UINT32: 'u4',
NumericType.INT8: 'i1', NumericType.INT16: 'i2', NumericType.INT32: 'i4'
}
# 根据时间戳搜索点云数据
protobuf_decoder = ProtobufDecoder()
lidar_ts_ns = df.loc[json_index, 'Pandar128']
start_ts_ns = lidar_ts_ns - 1e9
end_ts_ns = lidar_ts_ns + 1e9
for clip_path in clip_path_list:
reader = ClipReader(clip_path)
for schema, channel, msg in reader.iter_messages(topics=['Pandar128'], start_time=start_ts_ns, end_time=end_ts_ns):
if schema.encoding == "protobuf":
data = protobuf_decoder.decode(schema, channel, msg)
frame_ts = msg.log_time
if abs(frame_ts - lidar_ts_ns) < 1e3:
fields = data.fields
# 1. 计算每个点的长度和类型
stride = 0
dtype_list = []
for field in fields:
dtype = type_mapping[field.type]
field_size = np.dtype(dtype).itemsize
stride = max(stride, field.offset + field_size)
dtype_list.append((field.name, type_mapping[field.type]))
# 2. 解析二进制数据
num_points = len(data.data) // stride
data = np.frombuffer(data.data, dtype=np.dtype(dtype_list), count=num_points)
return np.vstack([data['x'], data['y'], data['z'], data['intensity']]).T
raise Exception("未搜索到点云")
def parse_truth_data(group_uuid: str, curvelists: list, output_path: str, car_name: str, date_name: str, df: pd.DataFrame) -> None:
'''
解析各类真值数据
'''
def untar_file(group_uuid: str, output_path: str, tar_filename: str, product_type: str, car_name: str, date_name: str, df: pd.DataFrame) -> None:
with Group(group_uuid) as group:
if group.get_product(product_type) is not None:
with group.open(tar_filename, mode="rb") as f:
tar = tarfile.open(fileobj=f)
# 只提取JSON文件和calib文件跳过depth_map_fg
for member in tar.getmembers():
# 跳过深度图文件夹
if 'depth_map_fg' in member.name:
continue
# 优先判断calib文件因为calib文件也可能是.json格式
if 'calib' in member.name:
# 修改提取路径将calib文件放到calib/目录
calib_filename = os.path.basename(member.name)
calib_dir = os.path.join(output_path, 'calib')
os.makedirs(calib_dir, exist_ok=True)
# 提取文件内容并保存
file_obj = tar.extractfile(member)
if file_obj:
with open(os.path.join(calib_dir, calib_filename), 'wb') as f_out:
f_out.write(file_obj.read())
# 提取annotations JSON文件到annotations文件夹
elif 'camera_radar_lidar_jsons' in member.name and member.name.endswith('.json'):
# 提取文件名中的json_index
json_filename = os.path.basename(member.name)
try:
# 从文件名中提取json_index (例如 "000001.json" -> 1)
json_index = int(json_filename[:-5]) # 去掉.json后缀
# 获取对应的frame_id
frame_id = df['camera4_frame_id'][json_index]
# 使用和图片相同的命名格式
new_filename = '%s_%s_%s_%06d_%d.json' % (car_name, date_name, group_uuid, json_index, frame_id)
annotations_dir = os.path.join(output_path, 'annotations')
os.makedirs(annotations_dir, exist_ok=True)
# 提取文件内容并保存
file_obj = tar.extractfile(member)
if file_obj:
with open(os.path.join(annotations_dir, new_filename), 'wb') as f_out:
f_out.write(file_obj.read())
except (ValueError, KeyError, IndexError) as e:
print(f"警告: 无法处理文件 {member.name}, 错误: {e}")
else:
print(f"Group {group_uuid} does not have product {product_type}.")
def gen_valid_file(group_uuid: str, curvelists: list, output_path: str, tar_filename: str, product_type: str) -> None:
valid_list = []
with Group(group_uuid) as group:
if group.get_product(product_type) is not None:
with group.open(tar_filename, mode="rb") as f:
with tarfile.open(fileobj=f, mode="r|*") as tar:
for member in tar:
# print("读取", member.name)
if 'calib/calib_pandar128_to_cam.json' in member.name:
fr = tar.extractfile(member)
calib_param = json.load(fr)
lidar_to_cam = np.array(calib_param['camera4']["Extrinsic"])
cam_intrinsic = np.array(calib_param['camera4']["Intrinsic"])
distortion_param = np.array(calib_param['camera4']["Distortion"])
if 'camera_radar_lidar_jsons' not in member.name or '.json' not in member.name:
continue
##########
valid_list.append(member.name)
# ##############curve####
# if len(curvelists) != 0:
# if group_uuid in curvelists:
# valid_list.append(member.name)
# continue
# #############
# fr = tar.extractfile(member)
# objs = json.load(fr)['asso_list']
# valid_obj = 0
# for obj in objs:
# label_2d = obj['camera_mea']
# label_det_cls = det_cls_map[label_2d['cls']]
# pose = posecls.get(label_2d['pose'])
# sublabel = subcls.get(label_2d['subcls'])
# if label_det_cls != 0:
# continue
# label_lidar = obj['lidar_mea']
# if label_lidar is None:
# continue
# obj_lidar = [float(label_lidar[1]),float(label_lidar[2]),float(label_lidar[3]),float(label_lidar[4]),float(label_lidar[5]),float(label_lidar[6]),float(label_lidar[7])]
# cam2d,cam3dpoint = Lidar2OrigImg(np.array([obj_lidar[0],obj_lidar[1],obj_lidar[2]]), lidar_to_cam, distortion_param, cam_intrinsic, 1920, 1080)
# if abs(cam3dpoint[0]) < 15 and abs(cam3dpoint[2]) < 80 and obj_lidar[3] > 5.5: ####大车
# valid_obj += 1
# valid_list.append(member.name)
# break ##
# # elif abs(cam3dpoint[0]) < 8 and abs(cam3dpoint[2]) < 8 : ####大车切车
# # valid_obj += 1
# # valid_list.append(member.name)
# # break ##
else:
print(f"Group {group_uuid} does not have product {product_type}.")
return valid_list
# product_type_list = [
# 'auto-2d-box-label',
# 'auto-3d-box-label',
# '2d-3d-association',
# 'static-map'
# ]
product_type_list = [
'2d-3d-association'
]
for product_type in product_type_list:
tar_filename = f"{product_type}_archive.tar"
valid_list = gen_valid_file(group_uuid, curvelists, output_path, tar_filename, product_type)
if len(valid_list) != 0:
untar_file(group_uuid, output_path, tar_filename, product_type, car_name, date_name, df)
return valid_list
def gettraindatagroupuuid(path):
used_uuids = []
imglists = glob.glob(path + '*/*/images/*.png')
imglists.sort()
for img in imglists:
name = os.path.basename(img)
co = name.split('_')
uuid = co[3]
if uuid not in used_uuids:
used_uuids.append(uuid)
return used_uuids
def process_single_uuid(args):
"""
封装单个UUID的完整处理流程作为多进程执行的任务函数
:param args: (group_uuid, uuid_date_dict, vehicle_number)
:return: 处理结果(成功/失败),便于后续统计
"""
group_uuid, uuid_date_dict, vehicle_number = args
# existpath = '/mnt/hfs/xdzhu/G1M3_PDCL/0105_1616/'
# used_uuids = os.listdir(existpath)
# existpath2 = '/mnt/hfs/xdzhu/G1M3_PDCL/0105_1616_2/'
# used_uuids2 = os.listdir(existpath2)
# if group_uuid not in used_uuids and group_uuid not in used_uuids2:
# 1. 初始化变量(原循环内的逻辑)
curvelists = [] # 每个进程独立初始化,避免多进程共享冲突
date = uuid_date_dict[group_uuid][0] # 取该UUID的第一个日期
output_path = os.path.join(output, group_uuid) # 更规范的路径拼接
# 2. 获取时间同步表和clip路径需要在parse_truth_data之前获取df
df = get_frame_timestamps_data_from_tar(group_uuid)
clip_path_list = get_clip_paths(group_uuid)
# 3. 解析真值数据传递vehicle_number, date, df参数以便重命名JSON文件
valid_list = parse_truth_data(group_uuid, curvelists, output_path, vehicle_number, date, df)
# 4. 无有效数据则跳过
if len(valid_list) == 0:
print(f"UUID {group_uuid} 无有效数据,跳过")
return (group_uuid, "success (no valid data)")
# 5. 处理有效列表修复原代码重复用i、追加元素的问题
valid_list.sort()
valid_ids = [] # 单独定义ID列表避免污染原valid_list
for file_path in valid_list:
name = os.path.basename(file_path)
# 提取ID假设文件名格式为 "xxx.id.json" 之类,-5 对应后缀长度)
try:
id = int(name[0:-5])
valid_ids.append(id)
except ValueError:
print(f"UUID {group_uuid} 文件名 {name} 解析ID失败跳过")
continue
# 6. 执行图像解码
parse_video(vehicle_number, date, group_uuid, valid_ids, clip_path_list, 'camera4', df, output_path)
print(f"UUID {group_uuid} 处理完成")
return (group_uuid, "success")
if __name__ == "__main__":
product_type = '2d-3d-association' # 真值产物类型
project_name = 'G1M3_0630' # 项目名称
CAR = 'G1M3_0630' #'G1M3_AFS1616' #'G1M3_0877' #'G1M3_AFS1616' #'G1M3_FDL2232'
output = '/data/zschen/dm3d/'
# output = '/home/xdzhu/Data/test0877/'
groupuuidpath = '/data/zschen/dm3d/0630_total_20260111.txt'
textlists = open(groupuuidpath,'r').readlines()
textlists.sort()
uuid_date = {}
group_uuidlists = []
for text in textlists:
context = text.strip('\n').split(' ')
date = context[1]
# if date[0:8] == '20251129':
# continue
if context[0] not in group_uuidlists:
group_uuidlists.append(context[0])
if context[0] not in uuid_date:
uuid_date[context[0]] = []
uuid_date[context[0]].append(date)
task_list = [(uuid, uuid_date, CAR) for uuid in group_uuidlists]
cpu_count = multiprocessing.cpu_count()
process_num = max(1, cpu_count - 2) # 至少1个进程
# process_num = 1
print(f"开始多进程处理,共 {len(group_uuidlists)} 个UUID进程数: {process_num}")
# 创建进程池并执行
with Pool(processes=process_num) as pool:
# 映射任务到进程池,返回结果列表
results = pool.map(process_single_uuid, task_list)
# 3. 统计处理结果
success_count = 0
fail_count = 0
try:
for uuid, res in results:
if res.startswith("success"):
success_count += 1
else:
fail_count += 1
except:
pass
print(f"\n处理完成!总计: {len(group_uuidlists)} | 成功: {success_count} | 失败: {fail_count}")
# 可选打印失败的UUID详情
try:
fail_details = [(uuid, res) for uuid, res in results if not res.startswith("success")]
if fail_details:
print("失败详情:")
for uuid, res in fail_details:
print(f" {uuid}: {res}")
except:
pass

View File

@@ -0,0 +1,658 @@
"""
按 group_id 提取真值产物(标注 JSON + 标定文件,可选保存图片)。
用法:
python extract_gt_by_group_id.py \
--group_ids <uuid1> <uuid2> ... \
--output /data/output_dir \
[--car_name G1M3_0630] \
[--product_type 2d-3d-association] \
[--camera_id camera4] \
[--image_suffix png] \
[--annotation_stride 2] \
[--skip_images] \
[--workers 4]
或者通过文本文件批量输入(每行: group_uuid [date] [car_name]
python extract_gt_by_group_id.py \
--uuid_file /data/group_ids.txt \
--output /data/output_dir
"""
import argparse
import io
import os
import tarfile
from datetime import datetime, timezone, timedelta
from math import ceil
from multiprocessing import Pool
from typing import Dict, List, Optional, Tuple
import numpy as np
import pandas as pd
from dotenv import load_dotenv
load_dotenv()
from pdcl_dss import Group
os.environ['STS_UID'] = 'dis-uploader'
os.environ['STS_SECRET_KEY'] = '277310cc09724d315514a79701fecb0f'
BEIJING_TZ = timezone(timedelta(hours=8))
DEFAULT_CAR_NAME = 'unknown'
DEFAULT_DATE_NAME = '00000000000000'
DEFAULT_IMAGE_SUFFIX = 'png'
DEFAULT_ANNOTATION_STRIDE = 1
SUPPORTED_IMAGE_SUFFIXES = {'png', 'jpg', 'jpeg'}
# ---------------------------------------------------------------------------
# 视频解码工具
# ---------------------------------------------------------------------------
def _plane_to_ndarray(plane) -> np.ndarray:
stride = plane.line_size
height = plane.height
width = plane.width
arr = np.frombuffer(plane, dtype=np.uint8)
if stride == width:
return arr.reshape(height, width)
return arr.reshape(height, stride)[:, :width]
def _yuvj420p_to_bgr(frame) -> np.ndarray:
"""将 av YUVJ420P 帧转换为 BGR ndarray。"""
import cv2
y = _plane_to_ndarray(frame.planes[0])
u = _plane_to_ndarray(frame.planes[1])
v = _plane_to_ndarray(frame.planes[2])
h, w = y.shape
uv = np.zeros((h // 2, w), dtype=np.uint8)
uv[:, 0::2] = u
uv[:, 1::2] = v
yuv = np.concatenate([y, uv], axis=0)
return cv2.cvtColor(yuv, cv2.COLOR_YUV2BGR_NV12)
def _h265_bytes_to_bgr(frame_bytes: bytes) -> np.ndarray:
import av
container = av.open(io.BytesIO(frame_bytes))
for av_frame in container.decode(video=0):
return _yuvj420p_to_bgr(av_frame)
raise ValueError("H.265 解码失败")
# ---------------------------------------------------------------------------
# PDCL 数据读取
# ---------------------------------------------------------------------------
def get_frame_timestamps(group_uuid: str) -> pd.DataFrame:
"""从 clean_data_archive.tar 中读取 frame_timestamps.csv。"""
with Group(group_uuid) as group:
with group.open('clean_data_archive.tar', mode='rb') as f:
tar = tarfile.open(fileobj=f)
csv_obj = tar.extractfile(tar.getmember('frame_timestamps.csv'))
return pd.read_csv(csv_obj)
def get_group_meta(group_uuid: str) -> Dict:
"""读取 group 元数据。"""
with Group(group_uuid) as group:
return dict(group.meta)
def get_clip_paths(group_uuid: str) -> List[str]:
"""获取 group 下所有 clip 的本地缓存路径。"""
from pdcl_dss import Clip
paths = []
with Group(group_uuid) as group:
for clip_ukey in group.list_clip_ukeys():
clip = Clip(clip_ukey)
files, _ = clip.list_files()
paths.append(clip.get_cache_path(files[0]))
return paths
def _find_index_by_timestamp(df: pd.DataFrame, camera_id: str, ts_ns: int) -> int:
return df[abs(df[camera_id] - ts_ns) < 1e-3].index[0]
def _build_timestamp_index(df: pd.DataFrame, camera_id: str) -> Dict[int, int]:
if camera_id not in df.columns:
return {}
timestamp_index: Dict[int, int] = {}
for json_index, value in df[camera_id].items():
if pd.isna(value):
continue
timestamp_index[int(value)] = int(json_index)
return timestamp_index
def _find_index_by_timestamp_fast(
timestamp_index: Dict[int, int],
df: pd.DataFrame,
camera_id: str,
ts_ns: int,
) -> int:
json_index = timestamp_index.get(int(ts_ns))
if json_index is not None:
return json_index
return _find_index_by_timestamp(df, camera_id, ts_ns)
def _is_missing_car_name(car_name: str) -> bool:
return not car_name or car_name == DEFAULT_CAR_NAME
def _is_missing_date_name(date_name: str) -> bool:
return not date_name or date_name == DEFAULT_DATE_NAME
def _format_ms_to_date_name(timestamp_ms: int) -> str:
dt = datetime.fromtimestamp(timestamp_ms / 1000, tz=timezone.utc).astimezone(BEIJING_TZ)
return dt.strftime('%Y%m%d%H%M%S')
def _get_frame_id_column(camera_id: str) -> str:
return f'{camera_id}_frame_id'
def _is_target_camera_annotation_member(member_name: str, camera_id: str) -> bool:
normalized_name = member_name.replace('\\', '/')
target_fragment = f'/{camera_id}/camera_radar_lidar_jsons/'
return normalized_name.endswith('.json') and target_fragment in f'/{normalized_name}'
def normalize_image_suffix(image_suffix: str) -> str:
normalized = image_suffix.strip().lower().lstrip('.')
if normalized not in SUPPORTED_IMAGE_SUFFIXES:
supported = ', '.join(sorted(SUPPORTED_IMAGE_SUFFIXES))
raise ValueError(f'不支持的 image_suffix: {image_suffix}. 支持: {supported}')
return normalized
def normalize_annotation_stride(annotation_stride: int) -> int:
if annotation_stride < 1:
raise ValueError(f'annotation_stride 必须 >= 1当前为: {annotation_stride}')
return annotation_stride
def _get_frame_timestamp_ns(df: pd.DataFrame, camera_id: str, json_index: int) -> Optional[int]:
try:
value = df.at[json_index, camera_id]
except (KeyError, ValueError):
return None
if pd.isna(value):
return None
return int(value)
def _build_frame_basename(
car_name: str,
date_name: str,
group_uuid: str,
json_index: int,
camera_id: str,
df: pd.DataFrame,
) -> str:
parts = [car_name, date_name, group_uuid, f'{json_index:06d}']
frame_id_col = _get_frame_id_column(camera_id)
if frame_id_col in df.columns:
frame_id = df.at[json_index, frame_id_col]
if not pd.isna(frame_id):
parts.append(str(int(frame_id)))
frame_timestamp_ns = _get_frame_timestamp_ns(df, camera_id, json_index)
if frame_timestamp_ns is not None:
parts.append(str(frame_timestamp_ns))
return '_'.join(parts)
def _infer_car_name(group_meta: Dict) -> Tuple[str, str]:
for key in ('plate_number', 'vehicle_name', 'car_name', 'plateNumber', 'vehicleName', 'carName'):
value = str(group_meta.get(key, '')).strip()
if value:
return value, key
project_name = str(group_meta.get('project_name', '')).strip()
if project_name:
return project_name, 'project_name'
return DEFAULT_CAR_NAME, 'default'
def _infer_date_name(group_meta: Dict, df: pd.DataFrame, camera_id: str) -> Tuple[str, str]:
collection_time = group_meta.get('collection_time')
if isinstance(collection_time, (int, float)) and collection_time > 0:
return _format_ms_to_date_name(int(collection_time)), 'collection_time'
candidate_columns = [camera_id] if camera_id in df.columns else []
candidate_columns.extend(col for col in df.columns if col not in candidate_columns)
for col in candidate_columns:
col_name = str(col)
if col_name.startswith('Unnamed:'):
continue
if any(token in col_name for token in ('frame_id', 'clip_uuid', 'point_count', 'speed')):
continue
values = pd.to_numeric(df[col], errors='coerce').dropna()
if values.empty:
continue
timestamp_ns = int(values.iloc[0])
dt = datetime.fromtimestamp(timestamp_ns / 1e9, tz=timezone.utc).astimezone(BEIJING_TZ)
return dt.strftime('%Y%m%d%H%M%S'), f'frame_timestamps:{col_name}'
return DEFAULT_DATE_NAME, 'default'
def resolve_group_identifiers(
group_uuid: str,
car_name: str,
date_name: str,
df: pd.DataFrame,
camera_id: str,
) -> Tuple[str, str, List[str]]:
"""
仅在 car_name/date 为缺省值时,尝试从 group 元数据和时间戳中自动补全。
"""
resolved_car_name = car_name
resolved_date_name = date_name
messages: List[str] = []
need_car_name = _is_missing_car_name(car_name)
need_date_name = _is_missing_date_name(date_name)
if not need_car_name and not need_date_name:
return resolved_car_name, resolved_date_name, messages
group_meta = get_group_meta(group_uuid)
if need_car_name:
resolved_car_name, source = _infer_car_name(group_meta)
messages.append(f'[{group_uuid}] 自动补全 car_name={resolved_car_name} (source={source})')
if need_date_name:
resolved_date_name, source = _infer_date_name(group_meta, df, camera_id)
messages.append(f'[{group_uuid}] 自动补全 date={resolved_date_name} (source={source})')
return resolved_car_name, resolved_date_name, messages
# ---------------------------------------------------------------------------
# 图片保存
# ---------------------------------------------------------------------------
def save_images(
group_uuid: str,
car_name: str,
date_name: str,
valid_ids: Optional[List[int]],
clip_paths: List[str],
camera_id: str,
df: pd.DataFrame,
timestamp_index: Dict[int, int],
output_path: str,
image_suffix: str,
) -> int:
"""
解析视频流并保存为指定后缀图片。
Args:
valid_ids: 要保存的 json_index 列表;若为 None 则保存所有帧。
Returns:
保存的图片数量。
"""
import cv2
from pdcl_pyclip.decoder_struct import StructDecoder
from pdcl_pyclip.msg_camera import VideoMessage
from pdcl_pyclip.reader import ClipReader
image_dir = os.path.join(output_path, 'images')
os.makedirs(image_dir, exist_ok=True)
valid_set = set(valid_ids) if valid_ids is not None else None
struct_decoder = StructDecoder()
saved = 0
for clip_path in clip_paths:
reader = ClipReader(clip_path)
for schema, channel, msg in reader.iter_messages(topics=[camera_id]):
if schema.encoding != 'struct':
continue
data = struct_decoder.decode(schema, channel, msg)
if not isinstance(data, VideoMessage):
continue
json_index = _find_index_by_timestamp_fast(timestamp_index, df, camera_id, msg.log_time)
if valid_set is not None and json_index not in valid_set:
continue
frame_timestamp_ns = _get_frame_timestamp_ns(df, camera_id, json_index)
if frame_timestamp_ns is not None and msg.log_time != frame_timestamp_ns:
print(
f" [WARN] 帧 {json_index} 时间戳不一致: "
f"msg.log_time={msg.log_time}, df[{camera_id}]={frame_timestamp_ns}"
)
try:
img = _h265_bytes_to_bgr(data.payload)
except Exception as e:
print(f" [WARN] 帧 {json_index} 解码失败: {e}")
continue
fname = (
f'{_build_frame_basename(car_name, date_name, group_uuid, json_index, camera_id, df)}'
f'.{image_suffix}'
)
if not cv2.imwrite(os.path.join(image_dir, fname), img):
print(f" [WARN] 帧 {json_index} 写图失败: {fname}")
continue
saved += 1
return saved
# ---------------------------------------------------------------------------
# 标注 / 标定文件解包
# ---------------------------------------------------------------------------
def extract_annotations_and_calib(
group_uuid: str,
car_name: str,
date_name: str,
product_type: str,
camera_id: str,
df: pd.DataFrame,
output_path: str,
annotation_stride: int = DEFAULT_ANNOTATION_STRIDE,
) -> List[int]:
"""
从真值产物 tar 包中提取标注 JSON 和标定文件。
Returns:
实际保存的有效帧 json_index 列表(仅目标 camera_id 下的帧)。
"""
tar_filename = f'{product_type}_archive.tar'
valid_ids: List[int] = []
candidate_annotation_count = 0
saved_annotation_names = set()
with Group(group_uuid) as group:
if group.get_product(product_type) is None:
print(f" [WARN] Group {group_uuid} 不含产物 {product_type},跳过")
return valid_ids
calib_dir = os.path.join(output_path, 'calib')
annotations_dir = os.path.join(output_path, 'annotations')
os.makedirs(calib_dir, exist_ok=True)
os.makedirs(annotations_dir, exist_ok=True)
with group.open(tar_filename, mode='rb') as f:
with tarfile.open(fileobj=f, mode='r|*') as tar:
for member in tar:
if 'depth_map_fg' in member.name:
continue
file_obj = tar.extractfile(member)
if file_obj is None:
continue
# ── 标定文件 ─────────────────────────────────────────
if 'calib' in member.name:
calib_fname = os.path.basename(member.name)
with open(os.path.join(calib_dir, calib_fname), 'wb') as fw:
fw.write(file_obj.read())
continue
# ── 标注 JSON ─────────────────────────────────────────
if _is_target_camera_annotation_member(member.name, camera_id):
json_fname = os.path.basename(member.name)
try:
json_index = int(json_fname[:-5])
except ValueError:
print(f" [WARN] 无法解析帧号: {member.name}")
continue
candidate_annotation_count += 1
if (candidate_annotation_count - 1) % annotation_stride != 0:
continue
try:
new_fname = (
f'{_build_frame_basename(car_name, date_name, group_uuid, json_index, camera_id, df)}.json'
)
except (KeyError, IndexError, ValueError):
new_fname = f'{car_name}_{date_name}_{group_uuid}_{json_index:06d}.json'
if new_fname in saved_annotation_names:
print(f" [WARN] 重复标注输出名,跳过覆盖: {new_fname} <- {member.name}")
continue
with open(os.path.join(annotations_dir, new_fname), 'wb') as fw:
fw.write(file_obj.read())
saved_annotation_names.add(new_fname)
valid_ids.append(json_index)
print(
f" [INFO] annotations({camera_id}) 保存 {len(valid_ids)} / "
f"{candidate_annotation_count} (annotation_stride={annotation_stride})"
)
return valid_ids
# ---------------------------------------------------------------------------
# 单个 group 的完整处理流程
# ---------------------------------------------------------------------------
def process_group(
group_uuid: str,
car_name: str,
date_name: str,
output_root: str,
product_type: str = '2d-3d-association',
camera_id: str = 'camera4',
image_suffix: str = DEFAULT_IMAGE_SUFFIX,
annotation_stride: int = DEFAULT_ANNOTATION_STRIDE,
skip_images: bool = False,
) -> Tuple[str, str]:
"""
处理单个 group提取标注、标定文件并按需保存对应图片。
Returns:
(group_uuid, status_message)
"""
output_path = os.path.join(output_root, group_uuid)
try:
df = get_frame_timestamps(group_uuid)
car_name, date_name, messages = resolve_group_identifiers(
group_uuid, car_name, date_name, df, camera_id
)
print(
f"[{group_uuid}] 开始处理 "
f"(car={car_name}, date={date_name}, "
f"annotation_stride={annotation_stride}, skip_images={skip_images})"
)
for message in messages:
print(message)
# 1. 提取标注 + 标定,获取有效帧列表
valid_ids = extract_annotations_and_calib(
group_uuid,
car_name,
date_name,
product_type,
camera_id,
df,
output_path,
annotation_stride,
)
if not valid_ids:
print(f"[{group_uuid}] 无有效标注帧,跳过后续处理")
return group_uuid, 'success (no valid frames)'
valid_ids.sort()
if skip_images:
print(f"[{group_uuid}] 共 {len(valid_ids)} 个有效帧,按要求跳过图片保存")
return group_uuid, f'success ({len(valid_ids)} annotations only)'
print(f"[{group_uuid}] 共 {len(valid_ids)} 个有效帧,开始保存图片")
# 2. 保存对应帧图片
timestamp_index = _build_timestamp_index(df, camera_id)
clip_paths = get_clip_paths(group_uuid)
n_saved = save_images(
group_uuid, car_name, date_name,
valid_ids, clip_paths, camera_id, df, timestamp_index, output_path, image_suffix
)
print(f"[{group_uuid}] 完成,保存图片 {n_saved}")
return group_uuid, f'success ({n_saved} images)'
except Exception as e:
import traceback
print(f"[{group_uuid}] 处理失败: {e}\n{traceback.format_exc()}")
return group_uuid, f'failed: {e}'
# ---------------------------------------------------------------------------
# 多进程入口
# ---------------------------------------------------------------------------
def _worker(args):
return process_group(*args)
def run(
tasks: List[Tuple[str, str, str]], # [(group_uuid, car_name, date_name), ...]
output_root: str,
product_type: str,
camera_id: str,
image_suffix: str,
annotation_stride: int,
skip_images: bool,
workers: int,
):
if not tasks:
print('没有需要处理的 group')
return
full_tasks = [
(
uuid,
car,
date,
output_root,
product_type,
camera_id,
image_suffix,
annotation_stride,
skip_images,
)
for uuid, car, date in tasks
]
actual_workers = max(1, min(workers, len(full_tasks)))
if actual_workers == 1:
results = [_worker(t) for t in full_tasks]
else:
chunksize = max(1, ceil(len(full_tasks) / (actual_workers * 4)))
with Pool(processes=actual_workers) as pool:
results = list(pool.imap_unordered(_worker, full_tasks, chunksize=chunksize))
success = sum(1 for _, r in results if r.startswith('success'))
failed = sum(1 for _, r in results if r.startswith('failed'))
print(f"\n完成!总计 {len(results)} 成功 {success} 失败 {failed} workers {actual_workers}")
for uuid, res in results:
if res.startswith('failed'):
print(f" FAILED {uuid}: {res}")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_uuid_file(filepath: str) -> List[Tuple[str, str, str]]:
"""
解析 UUID 文本文件,每行格式:
group_uuid [date] [car_name]
缺省 date/car_name 时会在处理时自动尝试补全。
"""
tasks = []
seen = set()
duplicate_count = 0
with open(filepath) as f:
for line in f:
parts = line.strip().split()
if not parts:
continue
uuid = parts[0]
date = parts[1] if len(parts) > 1 else DEFAULT_DATE_NAME
car_name = parts[2] if len(parts) > 2 else DEFAULT_CAR_NAME
task = (uuid, car_name, date)
if task in seen:
duplicate_count += 1
continue
seen.add(task)
tasks.append(task)
if duplicate_count:
print(f"[uuid_file] 跳过重复 group_uuid {duplicate_count}")
return tasks
def main():
parser = argparse.ArgumentParser(description='按 group_id 提取 PDCL 真值产物')
src = parser.add_mutually_exclusive_group(required=True)
src.add_argument('--group_ids', nargs='+', metavar='UUID',
help='直接指定一个或多个 group UUID')
src.add_argument('--uuid_file', metavar='FILE',
help='UUID 列表文件(每行: uuid [date] [car_name]')
parser.add_argument('--output', required=True, help='输出根目录')
parser.add_argument('--car_name', default=DEFAULT_CAR_NAME,
help='车辆标识;默认会尝试从 group 元数据自动推断')
parser.add_argument('--date', default=DEFAULT_DATE_NAME,
help='日期字符串;默认会尝试从 collection_time 或时间戳自动推断')
parser.add_argument('--product_type', default='2d-3d-association',
help='真值产物类型(默认: 2d-3d-association')
parser.add_argument('--camera_id', default='camera4', help='相机 topic 名称')
parser.add_argument('--image_suffix', default=DEFAULT_IMAGE_SUFFIX,
help='输出图像后缀,支持 png/jpg/jpeg默认: png')
parser.add_argument('--annotation_stride', type=int, default=DEFAULT_ANNOTATION_STRIDE,
help='标注保存抽样步长1 为全保存2 为 2 抽 1同时图片也会与保留标注对齐')
parser.add_argument('--skip_images', action='store_true',
help='只提取 annotations/calib不解码或保存图片帧')
parser.add_argument('--workers', type=int, default=4, help='并行进程数')
args = parser.parse_args()
image_suffix = normalize_image_suffix(args.image_suffix)
annotation_stride = normalize_annotation_stride(args.annotation_stride)
if args.uuid_file:
tasks = parse_uuid_file(args.uuid_file)
else:
tasks = [(uuid, args.car_name, args.date) for uuid in args.group_ids]
os.makedirs(args.output, exist_ok=True)
run(
tasks,
args.output,
args.product_type,
args.camera_id,
image_suffix,
annotation_stride,
args.skip_images,
args.workers,
)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,64 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
PROJECT_ROOT=$(cd "${SCRIPT_DIR}/../.." && pwd)
LIST_DIR=${LIST_DIR:-/mnt/mono3d/ydong_data/D01_group_list}
LIST_PATTERN=${LIST_PATTERN:-*.txt}
OUTPUT_ROOT=${OUTPUT_ROOT:-/mnt/mono3d/ydong_data/Mono3d_camera2_gt_by_group_id}
NUM_WORKERS=${NUM_WORKERS:-32}
PRODUCT_TYPE=${PRODUCT_TYPE:-2d-3d-association}
CAMERA_ID=${CAMERA_ID:-camera2}
IMAGE_SUFFIX=${IMAGE_SUFFIX:-jpg}
ANNOTATION_STRIDE=${ANNOTATION_STRIDE:-2}
SKIP_IMAGES=${SKIP_IMAGES:-0}
PYTHON_BIN=${PYTHON_BIN:-python}
LOG_DIR=${LOG_DIR:-${OUTPUT_ROOT}/_logs/extract_gt_by_group_id}
if [[ ! -d "${LIST_DIR}" ]]; then
echo "[ERROR] LIST_DIR 不存在: ${LIST_DIR}" >&2
exit 1
fi
mkdir -p "${OUTPUT_ROOT}" "${LOG_DIR}"
mapfile -t LIST_FILES < <(find "${LIST_DIR}" -maxdepth 1 -type f -name "${LIST_PATTERN}" | sort)
if [[ ${#LIST_FILES[@]} -eq 0 ]]; then
echo "[ERROR] 在 ${LIST_DIR} 下未找到匹配 ${LIST_PATTERN} 的列表文件" >&2
exit 1
fi
RUN_TAG=$(date +%Y%m%d_%H%M%S)
MERGED_UUID_FILE="${LOG_DIR}/merged_group_list_${RUN_TAG}.txt"
RUN_LOG="${LOG_DIR}/extract_gt_by_group_id_${RUN_TAG}.log"
awk 'NF && $1 !~ /^#/ && !seen[$1]++ { print }' "${LIST_FILES[@]}" > "${MERGED_UUID_FILE}"
TOTAL_LISTS=${#LIST_FILES[@]}
TOTAL_GROUPS=$(wc -l < "${MERGED_UUID_FILE}")
echo "[INFO] list 文件数: ${TOTAL_LISTS}"
echo "[INFO] 合并后 group 数: ${TOTAL_GROUPS}"
echo "[INFO] workers: ${NUM_WORKERS}"
echo "[INFO] image suffix: ${IMAGE_SUFFIX}"
echo "[INFO] annotation stride: ${ANNOTATION_STRIDE}"
echo "[INFO] skip images: ${SKIP_IMAGES}"
echo "[INFO] merged uuid file: ${MERGED_UUID_FILE}"
echo "[INFO] run log: ${RUN_LOG}"
PY_ARGS=()
if [[ "${SKIP_IMAGES}" == "1" ]]; then
PY_ARGS+=(--skip_images)
fi
"${PYTHON_BIN}" "${PROJECT_ROOT}/tools/pdcl_inference/extract_gt_by_group_id.py" \
--uuid_file "${MERGED_UUID_FILE}" \
--output "${OUTPUT_ROOT}" \
--product_type "${PRODUCT_TYPE}" \
--camera_id "${CAMERA_ID}" \
--image_suffix "${IMAGE_SUFFIX}" \
--annotation_stride "${ANNOTATION_STRIDE}" \
--workers "${NUM_WORKERS}" \
"${PY_ARGS[@]}" | tee "${RUN_LOG}"

View File

@@ -0,0 +1,6 @@
python tools/pdcl_inference/extract_gt_by_group_id.py \
--group_ids 019c2ef5-6ca9-76b0-bc6b-b95bead302e1 \
--output /mnt/mono3d/ydong_data/Mono3d_camera2_gt_by_group_id_stride_2_jpg \
--annotation_stride 2 \
--camera_id "camera2" \
--image_suffix "jpg"

View File

@@ -0,0 +1,957 @@
"""File-based prediction bundle for analyze_val_two_roi_badcases.py.
Reads precomputed detection JSON files and GT JSON files (eval_tools format)
and converts them to the same internal format used by run_roi_analysis().
Key differences from live-inference mode:
- Predictions are loaded from JSON rather than run through the model.
- GT is parsed from JSON (absolute pixel coords) and converted to lb_2d/lb_3d.
- PreparedROI is built from calibration + roi_config; depth_scale is forced to
1.0 because JSON z3d values are already metric (de-normalized).
- EdgeYaw artifacts (edge_selection, edge_box, edge_heading_decoded) are not
available from JSON, so they are set to None/False/nan.
"""
from __future__ import annotations
import math
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterator, Optional
import cv2
import numpy as np
import yaml
FILE = Path(__file__).resolve()
ROOT = FILE.parents[2]
if str(ROOT) not in sys.path:
sys.path.append(str(ROOT))
from ultralytics.data.ground3d_augment import (
adjust_calib_for_roi_crop,
build_final_resized_calib,
compute_centered_roi_bounds,
)
from tools.pdcl_inference.two_roi_inference import (
PreparedROI,
_compute_vanishing_point_xy,
_resize_ground3d_image_in_steps,
load_camera4_calib,
)
from eval_tools.evaluator.parser import DetectionParser, GroundTruthParser
from eval_tools.class_config import (
CLASS_NAMES,
COMPLETE_3D_CLASSES as _EVAL_COMPLETE_3D,
FACE_3D_CLASSES as _EVAL_FACE_3D,
)
# lb_3d face offsets: front=10, rear=18, left=26, right=34
_FACE_NAME_TO_OFFSET: dict[str, int] = {"front": 10, "back": 18, "left": 26, "right": 34}
# ---------------------------------------------------------------------------
# ROI spec for file-based mode
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class FilePredROISpec:
"""Minimal ROI spec for file-based mode.
Has the same attribute interface as ROIModelSpec so that visualization
helpers (_prepare_roi_image etc.) can accept it directly.
"""
name: str
roi_size: tuple[int, int] # (width, height) full ROI before bottom/right trim
crop_center_mode: str # "cxvy" (roi0) or "vxvy" (roi1)
virtual_fx: float = 1.0 # placeholder; depth_scale is overridden to 1.0
imgsz: Optional[tuple[int, int]] = None
conf: float = 0.25
max_det: int = 300
model_path: str = "" # unused in file mode
# ---------------------------------------------------------------------------
# Bundle dataclass
# ---------------------------------------------------------------------------
@dataclass
class FilePredBundle:
"""Holds configuration for one ROI in file-based mode.
The attributes mirror the ones that run_roi_analysis() accesses on a
LoadedROIModel so the analysis loop can treat both interchangeably.
"""
spec: FilePredROISpec
names: dict[int, str]
face_3d_classes: set[int]
complete_3d_classes: set[int]
imgsz: tuple[int, int]
# File I/O config
det_path: Path
gt_path: Path
path_depth: int
det_subdir: Optional[str]
det_roi_filter: Optional[str]
det_format: str
gt_format: str
# Calibration / ROI
calib_root: Path
roi_bottom_offset: int
roi_right_offset: int
# Evaluation thresholds
conf_threshold: float
img_width: int
img_height: int
min_box_size: float
# Optional image root for visualization (images not required for metrics)
image_root: Optional[Path] = None
# Per-case calibration cache keyed by (level1_name or "", case_name).
# Populated lazily by load_frame(); eliminates repeated disk reads for
# frames that belong to the same case (same camera4.json).
calib_cache: dict = field(default_factory=dict)
# ---------------------------------------------------------------------------
# Factory
# ---------------------------------------------------------------------------
def build_file_pred_bundle(eval_cfg: dict, roi_name: str) -> FilePredBundle:
"""Build a FilePredBundle from a loaded eval YAML config.
Args:
eval_cfg: Dict loaded from an eval_tools config YAML.
roi_name: "roi0" or "roi1".
Returns:
FilePredBundle ready for use in run_roi_analysis().
"""
dataset_cfg = eval_cfg.get("dataset", {})
roi_gt_cfg = eval_cfg.get("roi_gt", {})
model_cfg = eval_cfg.get("model", {})
image_cfg = eval_cfg.get("image", {})
classes_cfg = eval_cfg.get("classes", {})
metrics_2d = eval_cfg.get("metrics_2d", {})
det_path = Path(str(dataset_cfg["det_path"]))
gt_path = Path(str(dataset_cfg["gt_path"]))
path_depth = int(dataset_cfg.get("path_depth", 1))
det_subdir = dataset_cfg.get("det_subdir") or None
_drf = dataset_cfg.get("det_roi_filter")
det_roi_filter = str(_drf) if _drf is not None else None
det_format = str(dataset_cfg.get("det_format", "auto"))
gt_format = str(dataset_cfg.get("gt_format", "auto"))
img_width = int(image_cfg.get("width", 1920))
img_height = int(image_cfg.get("height", 1080))
input_size = int(model_cfg.get("input_size", 768))
min_box_at_input = float(model_cfg.get("min_box_size_at_input_scale", 8))
roi_config = roi_gt_cfg.get("roi_config")
if roi_config is None:
raise ValueError("eval config missing roi_gt.roi_config")
roi_bottom_offset = int(roi_gt_cfg.get("roi_bottom_offset", 0))
roi_right_offset = int(roi_gt_cfg.get("roi_right_offset", 0))
calib_root_str = roi_gt_cfg.get("calib_root") or str(gt_path)
calib_root = Path(calib_root_str)
# --- Derive ROI crop size (before bottom/right trim) ---
if isinstance(roi_config, (list, tuple)):
if len(roi_config) == 2:
full_roi_w = int(roi_config[0])
full_roi_h = int(roi_config[1])
elif len(roi_config) == 4:
full_roi_w = int(roi_config[2]) - int(roi_config[0])
full_roi_h = int(roi_config[3]) - int(roi_config[1])
else:
raise ValueError(f"roi_config must have 2 or 4 elements: {roi_config}")
else:
raise ValueError(f"Unsupported roi_config type: {type(roi_config)}")
# Effective ROI size after offsets (what the model sees)
eff_roi_w = full_roi_w - roi_right_offset
eff_roi_h = full_roi_h - roi_bottom_offset
# Model input size (width from config; height derived from ROI aspect ratio)
model_input_w = input_size
model_input_h = round(input_size * eff_roi_h / eff_roi_w)
imgsz = (model_input_w, model_input_h)
# Crop center mode
crop_center_mode = "vxvy" if roi_name.lower() == "roi1" else "cxvy"
# Min box size at original image scale
min_box_size = min_box_at_input * eff_roi_w / input_size
# Class names
class_names_cfg = classes_cfg.get("class_names") or {}
names: dict[int, str] = (
{int(k): str(v) for k, v in class_names_cfg.items()}
if class_names_cfg
else dict(CLASS_NAMES)
)
# face / complete 3D classes from eval config (or defaults from class_config.py)
face_3d_cls = set(int(x) for x in classes_cfg.get("3d_classes", _EVAL_FACE_3D))
complete_3d_cls = set(int(x) for x in classes_cfg.get("complete_3d_classes", _EVAL_COMPLETE_3D))
# eval configs typically only list vehicle-like 3D classes; pedestrian etc.
# might be absent. Keep complete_3d_cls as whatever the config says.
conf = float(metrics_2d.get("conf_threshold", 0.25))
roi_spec = FilePredROISpec(
name=roi_name.upper(),
roi_size=(full_roi_w, full_roi_h),
crop_center_mode=crop_center_mode,
virtual_fx=1.0,
imgsz=imgsz,
conf=conf,
max_det=300,
)
# Optional image root (users may add an 'image_root' key to the eval config)
image_root_str = eval_cfg.get("image_root") or dataset_cfg.get("image_root")
image_root = Path(image_root_str) if image_root_str else None
return FilePredBundle(
spec=roi_spec,
names=names,
face_3d_classes=face_3d_cls,
complete_3d_classes=complete_3d_cls,
imgsz=imgsz,
det_path=det_path,
gt_path=gt_path,
path_depth=path_depth,
det_subdir=det_subdir,
det_roi_filter=det_roi_filter,
det_format=det_format,
gt_format=gt_format,
calib_root=calib_root,
roi_bottom_offset=roi_bottom_offset,
roi_right_offset=roi_right_offset,
conf_threshold=conf,
img_width=img_width,
img_height=img_height,
min_box_size=min_box_size,
image_root=image_root,
)
# ---------------------------------------------------------------------------
# Frame scanning
# ---------------------------------------------------------------------------
def iter_frames(bundle: FilePredBundle) -> Iterator[dict]:
"""Yield one dict per matched (det, gt) frame pair.
Returns:
dict with keys: case_name, frame_stem, det_file, gt_file, level1_name.
"""
det_root = bundle.det_path
gt_root = bundle.gt_path
if bundle.path_depth == 1:
case_dirs = [(d, d.name, None) for d in sorted(det_root.iterdir()) if d.is_dir()]
elif bundle.path_depth == 2:
case_dirs = []
for lvl1 in sorted(det_root.iterdir()):
if not lvl1.is_dir():
continue
for case_dir in sorted(lvl1.iterdir()):
if case_dir.is_dir():
case_dirs.append((case_dir, case_dir.name, lvl1.name))
else:
raise ValueError(f"Unsupported path_depth: {bundle.path_depth}")
for det_case_dir, case_name, level1_name in case_dirs:
# --- Locate detection files ---
det_results_dir, det_glob = _resolve_det_dir(
det_case_dir, bundle.det_subdir, bundle.det_roi_filter, bundle.det_format
)
if det_results_dir is None:
continue
# --- Corresponding GT case dir ---
if bundle.path_depth == 1:
gt_case_dir = gt_root / case_name
else:
gt_case_dir = gt_root / level1_name / case_name
if not gt_case_dir.exists():
continue
gt_results_dir, gt_glob = _resolve_gt_dir(gt_case_dir, bundle.gt_format)
if gt_results_dir is None:
continue
# --- Match det files to GT files by stem ---
det_files = {p.stem: p for p in sorted(det_results_dir.glob(det_glob))}
gt_files = {p.stem: p for p in sorted(gt_results_dir.glob(gt_glob))}
common_stems = sorted(det_files.keys() & gt_files.keys())
for stem in common_stems:
yield {
"case_name": case_name,
"frame_stem": stem,
"det_file": det_files[stem],
"gt_file": gt_files[stem],
"level1_name": level1_name,
}
def _resolve_det_dir(
det_case_dir: Path,
det_subdir: Optional[str],
det_roi_filter: Optional[str],
det_format: str,
) -> tuple[Optional[Path], str]:
"""Return (det_results_dir, glob_pattern) or (None, '') if not found."""
candidates: list[Path] = []
if det_subdir:
cand = (
Path(det_subdir) if Path(det_subdir).is_absolute()
else det_case_dir / det_subdir
)
candidates.append(cand)
if det_roi_filter is not None:
candidates += [
det_case_dir / "json_results" / f"roi{det_roi_filter}",
det_case_dir / "predictions" / f"roi{det_roi_filter}",
]
candidates += [
det_case_dir / "json_results",
det_case_dir / "predictions",
]
if det_format in ("json", "auto"):
for cand in dict.fromkeys(str(c) for c in candidates):
cand_path = Path(cand)
if cand_path.exists() and list(cand_path.glob("*.json")):
return cand_path, "*.json"
if det_format in ("txt", "auto"):
txt_dir = det_case_dir / "txt_results"
if txt_dir.exists() and list(txt_dir.glob("*.txt")):
return txt_dir, "*.txt"
return None, ""
def _resolve_gt_dir(gt_case_dir: Path, gt_format: str) -> tuple[Optional[Path], str]:
"""Return (gt_results_dir, glob_pattern) or (None, '') if not found."""
if gt_format in ("json", "auto"):
for subdir in ("labels_json", "json_results"):
d = gt_case_dir / subdir
if d.exists() and list(d.glob("*.json")):
return d, "*.json"
if gt_format in ("txt", "auto"):
for subdir in ("labels", "txt_results"):
d = gt_case_dir / subdir
if d.exists() and list(d.glob("*.txt")):
return d, "*.txt"
return None, ""
# ---------------------------------------------------------------------------
# Per-frame loader
# ---------------------------------------------------------------------------
def load_frame(
bundle: FilePredBundle,
frame_info: dict,
) -> dict:
"""Load and convert one frame's GT + predictions.
Returns a dict with keys matching what run_roi_analysis() expects::
{
"image_bgr": np.ndarray | None,
"image_path": Path,
"label_path": Path,
"gt": dict[str, Any],
"predictions": list[dict],
"prepared": PreparedROI,
}
When the original image is not found, image_bgr is None and prepared.image
is a zero-filled placeholder (metrics still work; visualization is skipped).
"""
case_name = frame_info["case_name"]
frame_stem = frame_info["frame_stem"]
det_file = Path(frame_info["det_file"])
gt_file = Path(frame_info["gt_file"])
level1_name = frame_info.get("level1_name")
# ----- 1. Load calibration (cached per case to avoid repeated disk reads) -----
_cache_key = (level1_name or "", case_name)
if _cache_key not in bundle.calib_cache:
bundle.calib_cache[_cache_key] = _load_calib_for_case(bundle.calib_root, case_name, level1_name)
raw_calib = bundle.calib_cache[_cache_key]
# ----- 2. Compute ROI crop bounds -----
if raw_calib is not None:
vp_x, vp_y = _compute_vanishing_point_xy(
raw_calib, bundle.img_width, bundle.img_height
)
else:
# Fall back to image center when calibration is missing
vp_x = bundle.img_width / 2.0
vp_y = bundle.img_height / 2.0
roi_full_w, roi_full_h = bundle.spec.roi_size
eff_roi_w = roi_full_w - bundle.roi_right_offset
eff_roi_h = roi_full_h - bundle.roi_bottom_offset
crop_center_x = (
vp_x if bundle.spec.crop_center_mode == "vxvy"
else bundle.img_width / 2.0
)
crop_bounds = compute_centered_roi_bounds(
bundle.img_width, bundle.img_height,
min(roi_full_w, bundle.img_width),
min(roi_full_h, bundle.img_height),
crop_center_x, vp_y,
)
# Apply bottom / right offsets
cx1, cy1, cx2, cy2 = crop_bounds
cx2 = cx2 - bundle.roi_right_offset
cy2 = cy2 - bundle.roi_bottom_offset
eff_crop_bounds = (cx1, cy1, cx2, cy2)
# ----- 3. Build file-mode PreparedROI -----
model_input_w, model_input_h = bundle.imgsz
calib_after_crop = adjust_calib_for_roi_crop(
raw_calib, bundle.img_width, bundle.img_height, eff_crop_bounds
)
# Set virtual_fx = fx_final so that depth_scale = 1.0 (z3d stored as metric)
fx_final = calib_after_crop["focal_u"] * model_input_w / (cx2 - cx1)
roi_calib = build_final_resized_calib(
calib_after_crop["focal_u"],
calib_after_crop["focal_v"],
calib_after_crop["cu"],
calib_after_crop["cv"],
calib_after_crop["src_w"],
calib_after_crop["src_h"],
model_input_w,
model_input_h,
virtual_fx=fx_final, # depth_scale = fx_final / fx_final = 1.0
distort_coeffs=calib_after_crop["distort_coeffs"],
)
# Sanity: depth_scale should be 1.0 (allow tiny float error)
assert abs(roi_calib.get("depth_scale", 1.0) - 1.0) < 1e-4, roi_calib["depth_scale"]
# ----- 4. Try to load original image -----
image_bgr: Optional[np.ndarray] = None
image_path_guess = _guess_image_path(bundle, case_name, frame_stem, level1_name)
if image_path_guess is not None and image_path_guess.exists():
image_bgr = cv2.imread(str(image_path_guess), cv2.IMREAD_COLOR)
if image_bgr is not None:
# Produce the ROI-cropped and resized image
cropped = image_bgr[cy1:cy2, cx1:cx2]
roi_image = _resize_ground3d_image_in_steps(
cropped, (model_input_w, model_input_h)
)
else:
roi_image = np.zeros((model_input_h, model_input_w, 3), dtype=np.uint8)
prepared = PreparedROI(
name=bundle.spec.name,
image=roi_image,
crop_bounds=eff_crop_bounds,
calib=roi_calib,
vp_x=float(vp_x),
vp_y=float(vp_y),
crop_center_x=float(crop_center_x),
crop_center_y=float(vp_y),
)
# ----- 5. Load and convert GT -----
gt = _load_gt(
gt_file=gt_file,
bundle=bundle,
raw_calib=raw_calib,
eff_crop_bounds=eff_crop_bounds,
model_input_w=model_input_w,
model_input_h=model_input_h,
)
# ----- 6. Load and convert predictions -----
predictions = _load_predictions(
det_file=det_file,
bundle=bundle,
eff_crop_bounds=eff_crop_bounds,
model_input_w=model_input_w,
model_input_h=model_input_h,
roi_calib=roi_calib,
)
# Determine canonical image_path and label_path for record keeping
image_path = image_path_guess if image_path_guess is not None else (
bundle.gt_path / (frame_info.get("level1_name") or "") / case_name / f"{frame_stem}.png"
)
label_path = gt_file
return {
"image_bgr": image_bgr,
"image_path": image_path,
"label_path": label_path,
"gt": gt,
"predictions": predictions,
"prepared": prepared,
}
# ---------------------------------------------------------------------------
# GT conversion
# ---------------------------------------------------------------------------
def _load_gt(
gt_file: Path,
bundle: FilePredBundle,
raw_calib: Optional[dict],
eff_crop_bounds: tuple,
model_input_w: int,
model_input_h: int,
) -> dict:
"""Parse GT JSON and return the lb_2d / lb_3d / boxes_xyxy / classes dict."""
gt_parser = GroundTruthParser(min_box_size=0, coord_system="camera")
raw_annotations = gt_parser.parse_file(
str(gt_file), bundle.img_width, bundle.img_height
)
# Clip annotations to the effective ROI crop bounds (same bounds used for
# PreparedROI calib, ensuring coordinate conversion consistency).
cx1, cy1, cx2, cy2 = eff_crop_bounds
roi_w = cx2 - cx1
roi_h = cy2 - cy1
annotations: list[dict] = []
for raw_ann in raw_annotations:
x1o, y1o, x2o, y2o = raw_ann["bbox_2d"]
# Clip to ROI bounds
nx1 = max(x1o, cx1)
ny1 = max(y1o, cy1)
nx2 = min(x2o, cx2)
ny2 = min(y2o, cy2)
if nx2 <= nx1 or ny2 <= ny1:
continue
ann = dict(raw_ann)
ann["bbox_2d"] = [nx1, ny1, nx2, ny2]
annotations.append(ann)
scale_x = model_input_w / roi_w
scale_y = model_input_h / roi_h
# Min box size threshold in model input pixels
min_box_model_px = bundle.min_box_size * scale_x # same scale_x as roi→model
bboxes_list: list[np.ndarray] = []
cls_list: list[float] = []
diff_list: list[float] = []
lb3d_list: list[np.ndarray] = []
for ann in annotations:
label = ann["label"]
# Filter to known classes
if label not in bundle.names:
continue
# Convert bbox from original image coords to model input coords
x1o, y1o, x2o, y2o = ann["bbox_2d"]
x1m = (x1o - cx1) * scale_x
y1m = (y1o - cy1) * scale_y
x2m = (x2o - cx1) * scale_x
y2m = (y2o - cy1) * scale_y
bw = x2m - x1m
bh = y2m - y1m
if bw < min_box_model_px or bh < min_box_model_px:
continue
# Normalized xywh for lb_2d (w.r.t. model input size)
xcn = (x1m + x2m) * 0.5 / model_input_w
ycn = (y1m + y2m) * 0.5 / model_input_h
wn = bw / model_input_w
hn = bh / model_input_h
bboxes_list.append(np.array([xcn, ycn, wn, hn], dtype=np.float32))
cls_list.append(float(label))
diff_list.append(1.0) # difficulty weight default
# Build 42-dim lb_3d row
row = _build_lb3d_row(
ann=ann,
label=label,
raw_calib=raw_calib,
cx1=cx1, cy1=cy1, roi_w=roi_w, roi_h=roi_h,
face_3d_classes=bundle.face_3d_classes,
complete_3d_classes=bundle.complete_3d_classes,
)
lb3d_list.append(row) # always append (NaN row if 3D not available)
n = len(bboxes_list)
if n == 0:
lb_2d = {
"cls": np.zeros((0, 1), dtype=np.float32),
"bboxes": np.zeros((0, 4), dtype=np.float32),
"difficulties": np.zeros((0, 1), dtype=np.float32),
"segments": [], "keypoints": None,
"normalized": True, "bbox_format": "xywh",
}
lb_3d = np.full((0, 42), np.nan, dtype=np.float32)
boxes_xyxy = np.zeros((0, 4), dtype=np.float32)
classes = np.zeros((0,), dtype=np.int32)
return {
"lb_2d": lb_2d, "lb_3d": lb_3d,
"boxes_xyxy": boxes_xyxy, "classes": classes,
}
bboxes_arr = np.stack(bboxes_list, axis=0) # (n, 4)
cls_arr = np.array(cls_list, dtype=np.float32).reshape(-1, 1)
diff_arr = np.array(diff_list, dtype=np.float32).reshape(-1, 1)
lb3d_arr = np.stack(lb3d_list, axis=0) # (n, 42)
lb_2d = {
"cls": cls_arr,
"bboxes": bboxes_arr,
"difficulties": diff_arr,
"segments": [], "keypoints": None,
"normalized": True, "bbox_format": "xywh",
}
# boxes_xyxy in model input pixel space
xc_px = bboxes_arr[:, 0] * model_input_w
yc_px = bboxes_arr[:, 1] * model_input_h
w_px = bboxes_arr[:, 2] * model_input_w
h_px = bboxes_arr[:, 3] * model_input_h
boxes_xyxy = np.stack([xc_px - w_px/2, yc_px - h_px/2,
xc_px + w_px/2, yc_px + h_px/2], axis=1).astype(np.float32)
classes = cls_arr.reshape(-1).astype(np.int32)
return {
"lb_2d": lb_2d, "lb_3d": lb3d_arr,
"boxes_xyxy": boxes_xyxy, "classes": classes,
}
def _build_lb3d_row(
ann: dict,
label: int,
raw_calib: Optional[dict],
cx1: int, cy1: int, roi_w: int, roi_h: int,
face_3d_classes: set[int],
complete_3d_classes: set[int],
) -> np.ndarray:
"""Build a 42-dim lb_3d row from a JSON GT annotation dict.
z3d values are stored as metric (depth_scale = 1.0 in PreparedROI calib).
xc/yc values are ROI-relative normalized (01 within the crop).
"""
row = np.full(42, np.nan, dtype=np.float32)
d3info = ann.get("3d_info")
if d3info is None:
return row
if label not in face_3d_classes and label not in complete_3d_classes:
return row
center = d3info["center"] # [x3d, y3d, z3d] metric
dims = d3info["dimensions"] # [l, h, w]
rot_y = d3info["rotation"] # radians
x3d, y3d, z3d = center
if not (math.isfinite(z3d) and z3d > 0):
return row
row[0:3] = center
row[3:6] = dims
row[6] = rot_y
# Project 3D center into original image → ROI-relative normalized
if raw_calib is not None:
fx0 = float(raw_calib.get("focal_u", 1.0))
fy0 = float(raw_calib.get("focal_v", 1.0))
cx0 = float(raw_calib.get("cu", 0.0))
cy0 = float(raw_calib.get("cv", 0.0))
u_orig = fx0 * x3d / z3d + cx0
v_orig = fy0 * y3d / z3d + cy0
else:
u_orig = 0.0
v_orig = 0.0
row[7] = (u_orig - cx1) / roi_w
row[8] = (v_orig - cy1) / roi_h
row[9] = 0.0 # alpha approximation
# Face data for face_3d_classes
if label in face_3d_classes and d3info.get("faces"):
for face_name, foffset in _FACE_NAME_TO_OFFSET.items():
face_data = d3info["faces"].get(face_name)
if face_data is None or len(face_data) < 8:
continue
# JSON face data: [x3d, y3d, z3d, alpha, xc_abs_px, yc_abs_px, score, is_visible]
fz3d = float(face_data[2])
if not (math.isfinite(fz3d) and fz3d > 0):
continue
row[foffset + 0] = float(face_data[0]) # face x3d
row[foffset + 1] = float(face_data[1]) # face y3d
row[foffset + 2] = fz3d # face z3d (metric)
row[foffset + 3] = float(face_data[3]) # alpha
# xc/yc in JSON are absolute pixel coords → ROI-relative normalized
xc_abs = float(face_data[4])
yc_abs = float(face_data[5])
row[foffset + 4] = (xc_abs - cx1) / roi_w
row[foffset + 5] = (yc_abs - cy1) / roi_h
row[foffset + 6] = float(face_data[6]) # visibility score
row[foffset + 7] = float(face_data[7]) # is_visible
return row
# ---------------------------------------------------------------------------
# Prediction conversion
# ---------------------------------------------------------------------------
def _load_predictions(
det_file: Path,
bundle: FilePredBundle,
eff_crop_bounds: tuple,
model_input_w: int,
model_input_h: int,
roi_calib: dict,
) -> list[dict]:
"""Parse detection JSON and convert to the prediction list format."""
det_parser = DetectionParser(min_box_size=0, coord_system="camera")
raw_dets = det_parser.parse_file(str(det_file))
cx1, cy1, cx2, cy2 = eff_crop_bounds
roi_w = cx2 - cx1
roi_h = cy2 - cy1
scale_x = model_input_w / roi_w
scale_y = model_input_h / roi_h
predictions = []
for det in raw_dets:
label = det["label"]
conf = float(det.get("confidence", 0.0))
roi_id = det.get("roi_id")
# Skip by confidence threshold
if conf < bundle.conf_threshold:
continue
# Skip by roi_id filter
if bundle.det_roi_filter is not None:
if roi_id is None or _norm_roi_id(roi_id) != _norm_roi_id(bundle.det_roi_filter):
continue
# Skip classes not in the configured class_names
if label not in bundle.names:
continue
# Convert bbox from original image coords to model input coords
x1o, y1o, x2o, y2o = det["bbox_2d"]
x1m = (x1o - cx1) * scale_x
y1m = (y1o - cy1) * scale_y
x2m = (x2o - cx1) * scale_x
y2m = (y2o - cy1) * scale_y
bbox_xyxy = np.array([x1m, y1m, x2m, y2m], dtype=np.float32)
d3info = det.get("3d_info")
decoded = None
attrs = None
if d3info is not None:
xyzlhwyaw = [
d3info["center"][0], d3info["center"][1], d3info["center"][2],
d3info["dimensions"][0], d3info["dimensions"][1], d3info["dimensions"][2],
d3info["rotation"],
]
decoded, attrs = _build_decoded_and_attrs_from_xyzlhwyaw(
xyzlhwyaw=xyzlhwyaw,
face_type_name=d3info.get("face_type"),
cls_id=label,
calib=roi_calib,
img_w=model_input_w,
img_h=model_input_h,
face_3d_classes=bundle.face_3d_classes,
complete_3d_classes=bundle.complete_3d_classes,
)
predictions.append({
"bbox_xyxy": bbox_xyxy,
"confidence": conf,
"cls_id": label,
# 3D fields
"attrs": attrs,
"decoded": decoded,
# Edge artifacts not available from precomputed JSON
"edge_selection": None,
"edge_box": None,
"edge_heading_decoded": None,
"edge_yaw": float("nan"),
"edge_confident": False,
})
return predictions
def _build_decoded_and_attrs_from_xyzlhwyaw(
xyzlhwyaw: list,
face_type_name: Optional[str],
cls_id: int,
calib: dict,
img_w: int,
img_h: int,
face_3d_classes: set[int],
complete_3d_classes: set[int],
) -> tuple[Optional[dict], Optional[dict]]:
"""Build 'decoded' and 'attrs' dicts from an xyzlhwyaw list + calibration.
These mimic the output of decode_3d_prediction() and
extract_3d_attrs_from_prediction() but use the stored metric values
directly instead of decoding from raw prediction tensors.
"""
from ultralytics.utils.plotting_3d import (
FACE_COLORS,
reconstruct_3d_box_from_face,
reconstruct_3d_box_from_whole,
collect_face_bottom_edges,
)
if len(xyzlhwyaw) < 7:
return None, None
x3d, y3d, z3d = [float(v) for v in xyzlhwyaw[:3]]
l, h, w = [float(v) for v in xyzlhwyaw[3:6]]
rot_y = float(xyzlhwyaw[6])
dims = np.array([l, h, w], dtype=np.float32)
if not (math.isfinite(z3d) and z3d > 0):
return None, None
center = np.array([x3d, y3d, z3d], dtype=np.float32)
fx = calib["fx"]
fy = calib["fy"]
cx_c = calib["cx"]
cy_c = calib["cy"]
# 2D projection of center
u = fx * (x3d / z3d) + cx_c
v = fy * (y3d / z3d) + cy_c
# --- Map face_type_name to integer ---
_FACE_TYPE_MAP = {"front": 0, "rear": 1, "back": 1, "left": 2, "right": 3}
face_type_int: Optional[int] = None
if face_type_name is not None:
face_type_int = _FACE_TYPE_MAP.get(str(face_type_name).lower())
# --- Build corners_3d ---
corners_3d = None
face_center_2d = None
face_color = None
visible_face_type = face_type_int
visible_face_types: tuple[int, ...] = ()
if cls_id in face_3d_classes and face_type_int is not None:
# Use face-based 3D reconstruction
corners_3d = reconstruct_3d_box_from_face(
(u, v), z3d, dims, rot_y, face_type_int, calib
)
if corners_3d is not None:
face_center_2d = (u, v)
face_color = FACE_COLORS[face_type_int]
visible_face_types = (face_type_int,)
elif cls_id in complete_3d_classes or cls_id in face_3d_classes:
corners_3d = reconstruct_3d_box_from_whole(
(u, v), z3d, dims, rot_y, calib
)
visible_face_type = None
edge_points_3d, edge_points_2d = None, None
if corners_3d is not None and visible_face_types:
edge_points_3d, edge_points_2d = collect_face_bottom_edges(
corners_3d, list(visible_face_types), calib, num_samples=5
)
decoded: Optional[dict] = None
if corners_3d is not None:
decoded = {
"corners_3d": corners_3d,
"face_center_2d": face_center_2d,
"face_color": face_color,
"visible_face_type": visible_face_type,
"visible_face_types": visible_face_types,
"edge_points_2d": edge_points_2d,
"edge_points_3d": edge_points_3d,
}
attrs: Optional[dict] = {
"center": center,
"depth": float(z3d),
"dims": dims,
"yaw": rot_y,
"yaw_deg": math.degrees(rot_y),
"uv": np.array([u, v], dtype=np.float32),
"visible_face_type": visible_face_type,
"face_center": (
center
if cls_id in face_3d_classes and face_type_int is not None
else None
),
}
return decoded, attrs
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _load_calib_for_case(
calib_root: Path,
case_name: str,
level1_name: Optional[str],
) -> Optional[dict]:
"""Look for a camera4.json calibration file under the case directory."""
case_dir = calib_root / level1_name / case_name if level1_name else calib_root / case_name
candidates = [
case_dir / "calib" / "L2_calib" / "camera4.json",
case_dir / "calib" / "camera4.json",
case_dir / "calibration.json",
]
for path in candidates:
if path.exists():
try:
return load_camera4_calib(path)
except Exception as exc:
print(f"Warning: could not load calib {path}: {exc}")
return None
def _guess_image_path(
bundle: FilePredBundle,
case_name: str,
frame_stem: str,
level1_name: Optional[str],
) -> Optional[Path]:
"""Try to find the original image for a given frame."""
roots_to_try: list[Path] = []
if bundle.image_root is not None:
roots_to_try.append(bundle.image_root)
roots_to_try.append(bundle.gt_path)
for root in roots_to_try:
case_dir = root / level1_name / case_name if level1_name else root / case_name
for subdir in ("images", ""):
base = case_dir / subdir if subdir else case_dir
for ext in (".png", ".jpg", ".jpeg"):
candidate = base / f"{frame_stem}{ext}"
if candidate.exists():
return candidate
return None
def _norm_roi_id(roi_id) -> str:
"""Normalize 'roi0'/'0'/0 → '0'."""
s = str(roi_id).strip().lower()
if s.startswith("roi"):
s = s[3:]
return s or "0"

View File

@@ -0,0 +1,22 @@
import os
import requests
url = "https://d.minieye.tech/ess-api/dashoard/event/meta_info"
query = {
"event_id":"019d4e3a-78d3-7dc4-4e33-e871f9c3e15a" # 所需查询的eventid
}
try:
response = requests.post(
url=url,
json=query
)
if response.status_code == 200:
result = response.json()
data = result["associated_clip_list"]
print(f"Data: {data}")
else:
print(f"Request failed with status code: {response.status_code}")
except Exception as e:
print(f"An error occurred: {e}")

View File

@@ -0,0 +1,486 @@
"""
从 AEB 场地计划表或 aeb_rawid.json 读取 rawid查询每个 rawid 对应的
clip ukey 列表,并保存为可直接用于批量推理的单个 aeb_clips*.json。
默认输入:
tools/pdcl_inference/G1Q3项目AEB场地计划表.xlsx
默认读取 "实际测试进展""复测case" 两个 sheet。
兼容旧流程:
也支持通过 --input 读取已生成的 aeb_rawid.json。
依赖:
pip install pdcl_dss -i https://pypi.minieye.tech/
默认输出命名:
未指定 --output 时,按 Excel 中 "CVE数据" 列的最小/最大时间自动命名,
例如 aeb_clips-20260322152509_to_20260410183944.json
输出格式:
{
"summary": {
"total_scenarios": 123,
"total_rawids": 456,
"scenario_total_rawids": {
"CPFA-25-6.5-20": 12
}
},
"scenarios": {
"CPFA-25-6.5-20": [
{
"sheet": "实际测试进展",
"场景": "CPFA",
"偏置": "25",
"目标速度": "6.5",
"自车速度": "20",
"CVE数据": "20260323113612",
"rawid": "ADAS_...",
"clips": ["clip_ukey1", ...]
}
]
}
}
"""
import argparse
import json
import os
import re
import sys
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import pandas as pd
FILE = Path(__file__).resolve()
ROOT = FILE.parents[2]
if str(ROOT) not in sys.path:
sys.path.append(str(ROOT))
DEFAULT_XLSX = os.path.join(os.path.dirname(__file__), "G1Q3项目AEB场地计划表.xlsx")
DEFAULT_SHEET_NAME = "实际测试进展"
DEFAULT_RETEST_SHEET_NAME = "复测case"
DEFAULT_SHEET_NAMES = [DEFAULT_SHEET_NAME, DEFAULT_RETEST_SHEET_NAME]
DEFAULT_OUTPUT_PREFIX = "aeb_clips"
os.environ.setdefault("STS_UID", "dis-uploader")
os.environ.setdefault("STS_SECRET_KEY", "277310cc09724d315514a79701fecb0f")
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
def _to_str(val):
"""将单元格值转换为字符串NaN 返回 None。"""
if pd.isna(val):
return None
if isinstance(val, float) and val == int(val):
return str(int(val))
return str(val).strip()
def _build_column_index(header: List[object]) -> Dict[str, int]:
"""根据表头名称构建列索引映射。"""
header_map = {}
for idx, value in enumerate(header):
name = _to_str(value)
if name:
header_map[name] = idx
required_columns = ["场景", "偏置", "目标速度", "自车速度", "rawid", "CVE数据"]
missing = [name for name in required_columns if name not in header_map]
if missing:
raise ValueError(f"Excel 缺少必需列: {missing};实际表头: {list(header_map.keys())}")
return header_map
def _normalize_cve_timestamp(val: Optional[str]) -> Optional[str]:
"""将 CVE 数据列清洗为可用于命名的时间串。"""
if not val:
return None
digits = re.sub(r"\D", "", val)
if len(digits) >= 14:
return digits[:14]
if len(digits) >= 8:
return digits[:8]
return None
def _forward_fill_series(series: pd.Series) -> pd.Series:
"""仅对当前列做简单前向填充,避免 pandas 对 object 列的告警。"""
filled = []
last_valid = None
for value in series.tolist():
if not pd.isna(value):
last_valid = value
filled.append(value)
else:
filled.append(last_valid)
return pd.Series(filled, index=series.index, dtype=object)
def _build_scenario_key(
scenario: Optional[str],
offset: Optional[str],
target_speed: Optional[str],
ego_speed: Optional[str],
) -> str:
"""构造完整工况键:场景-偏置-目标速度-自车速度。"""
parts = [
scenario or "未知工况",
offset or "未知偏置",
target_speed or "未知目标速度",
ego_speed or "未知自车速度",
]
return "-".join(parts)
def _cluster_records(records: List[dict], fallback_scenario: Optional[str] = None) -> Dict[str, List[dict]]:
"""按完整工况键聚类记录。"""
clustered: Dict[str, List[dict]] = {}
for record in records:
scenario = _to_str(record.get("场景")) or fallback_scenario or "未知工况"
offset = _to_str(record.get("偏置"))
target_speed = _to_str(record.get("目标速度"))
ego_speed = _to_str(record.get("自车速度"))
scenario_key = _build_scenario_key(scenario, offset, target_speed, ego_speed)
normalized_record = dict(record)
normalized_record["场景"] = scenario
clustered.setdefault(scenario_key, []).append(normalized_record)
return clustered
def _merge_clustered_records(grouped_records: List[Dict[str, List[dict]]]) -> Dict[str, List[dict]]:
"""合并多个分组结果。"""
merged: Dict[str, List[dict]] = {}
for grouped in grouped_records:
for scenario_key, records in grouped.items():
merged.setdefault(scenario_key, []).extend(records)
return merged
def parse_excel_sheet(xlsx_source, sheet_name: str = DEFAULT_SHEET_NAME) -> Dict[str, List[dict]]:
"""解析单个 Excel sheet并按完整工况键聚类 rawid 记录。"""
df = pd.read_excel(xlsx_source, sheet_name=sheet_name, header=None)
header = list(df.iloc[0])
column_index = _build_column_index(header)
data = df.iloc[1:].copy().reset_index(drop=True)
for name in ["场景", "偏置", "目标速度", "自车速度"]:
data.iloc[:, column_index[name]] = _forward_fill_series(
data.iloc[:, column_index[name]]
)
records: List[dict] = []
for _, row in data.iterrows():
rawid = _to_str(row.iloc[column_index["rawid"]])
if rawid is None:
continue
record = {
"sheet": sheet_name,
"场景": _to_str(row.iloc[column_index["场景"]]) or "未知工况",
"偏置": _to_str(row.iloc[column_index["偏置"]]),
"目标速度": _to_str(row.iloc[column_index["目标速度"]]),
"自车速度": _to_str(row.iloc[column_index["自车速度"]]),
"CVE数据": _to_str(row.iloc[column_index["CVE数据"]]),
"rawid": rawid,
}
records.append(record)
return _cluster_records(records)
def parse_excel(
xlsx_path: str,
sheet_names: Optional[List[str]] = None,
allow_missing_sheets: bool = False,
) -> Dict[str, List[dict]]:
"""解析一个或多个 Excel sheet并合并为按完整工况键聚类的 rawid 记录。"""
requested_sheet_names = sheet_names or DEFAULT_SHEET_NAMES
excel = pd.ExcelFile(xlsx_path)
available_sheet_names = set(excel.sheet_names)
missing_sheet_names = [
sheet_name
for sheet_name in requested_sheet_names
if sheet_name not in available_sheet_names
]
if missing_sheet_names and not allow_missing_sheets:
raise ValueError(
f"Excel 缺少 sheet: {missing_sheet_names};实际 sheet: {excel.sheet_names}"
)
if missing_sheet_names:
print(
f"警告Excel 缺少 sheet {missing_sheet_names},已跳过;"
f"实际 sheet: {excel.sheet_names}"
)
grouped_records = []
for sheet_name in requested_sheet_names:
if sheet_name not in available_sheet_names:
continue
grouped_records.append(parse_excel_sheet(excel, sheet_name=sheet_name))
if not grouped_records:
raise ValueError(
f"Excel 未找到任何可解析 sheet期望 sheet: {requested_sheet_names}"
f"实际 sheet: {excel.sheet_names}"
)
return _merge_clustered_records(grouped_records)
def load_rawid_json(json_path: str) -> Dict[str, List[dict]]:
"""读取旧格式 aeb_rawid.json。"""
with open(json_path, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
raise ValueError(f"输入 JSON 顶层必须是 dict实际: {type(data).__name__}")
if "scenarios" in data:
scenario_data = data["scenarios"]
if not isinstance(scenario_data, dict):
raise ValueError("输入 JSON 的 scenarios 字段必须是 dict")
data = scenario_data
grouped_records = []
for scenario_name, records in data.items():
if not isinstance(records, list):
raise ValueError(
f"场景 {scenario_name} 对应的数据必须是 list实际: {type(records).__name__}"
)
grouped_records.append(_cluster_records(records, fallback_scenario=_to_str(scenario_name)))
return _merge_clustered_records(grouped_records)
def get_clip_ukeys_from_raw(raw_id: str) -> List[str]:
"""通过 raw_id 获取关联的 clip ukey 列表。"""
from pdcl_dss import Raw
with Raw(raw_id) as raw:
return raw.list_clip_ukeys()
def get_cve_time_range(rawid_data: Dict[str, List[dict]]) -> Optional[Tuple[str, str]]:
"""从记录中提取 CVE 数据时间范围。"""
timestamps = []
for records in rawid_data.values():
for record in records:
timestamp = _normalize_cve_timestamp(_to_str(record.get("CVE数据")))
if timestamp:
timestamps.append(timestamp)
if not timestamps:
return None
return min(timestamps), max(timestamps)
def resolve_output_path(
output_arg: Optional[str],
source_path: str,
rawid_data: Dict[str, List[dict]],
) -> str:
"""解析最终输出路径;未显式指定时按 CVE 时间范围自动命名。"""
if output_arg:
return output_arg
cve_time_range = get_cve_time_range(rawid_data)
if cve_time_range is None:
filename = f"{DEFAULT_OUTPUT_PREFIX}.json"
else:
start, end = cve_time_range
suffix = start if start == end else f"{start}_to_{end}"
filename = f"{DEFAULT_OUTPUT_PREFIX}-{suffix}.json"
return os.path.join(os.path.dirname(source_path), filename)
def build_output_payload(scenario_records: Dict[str, List[dict]]) -> dict:
"""构造包含 summary 和 scenarios 的最终输出。"""
total_rawids = sum(len(records) for records in scenario_records.values())
return {
"summary": {
"total_scenarios": len(scenario_records),
"total_rawids": total_rawids,
"scenario_total_rawids": {
scenario: len(records)
for scenario, records in scenario_records.items()
},
},
"scenarios": scenario_records,
}
def build_clips_manifest(rawid_data: Dict[str, List[dict]]) -> Dict[str, List[dict]]:
"""为每条 rawid 记录补充 clips 字段。"""
result: Dict[str, List[dict]] = {}
clip_cache: Dict[str, List[str]] = {}
total_rawids = sum(
1
for records in rawid_data.values()
for record in records
if record.get("rawid")
)
processed = 0
for scenario, records in rawid_data.items():
result[scenario] = []
print(f"\n=== 工况: {scenario} ({len(records)} 条 rawid) ===")
for rec in records:
raw_id = rec.get("rawid")
if not raw_id:
print(" 跳过缺少 rawid 的记录")
continue
processed += 1
print(f" [{processed}/{total_rawids}] {raw_id} ...", end=" ", flush=True)
if raw_id in clip_cache:
clips = clip_cache[raw_id]
print(f"复用缓存,找到 {len(clips)} 个 clip")
else:
try:
clips = get_clip_ukeys_from_raw(raw_id)
clip_cache[raw_id] = clips
print(f"找到 {len(clips)} 个 clip")
except Exception as e:
clips = []
clip_cache[raw_id] = clips
print(f"失败: {e}")
entry = dict(rec)
entry["clips"] = clips
result[scenario].append(entry)
return result
def print_summary(
result: Dict[str, List[dict]],
output_path: str,
cve_time_range: Optional[Tuple[str, str]] = None,
) -> None:
total_rawids = sum(len(records) for records in result.values())
total_clips = sum(
len(entry.get("clips", []))
for records in result.values()
for entry in records
)
print(f"\n已保存至:{output_path}")
if cve_time_range:
start, end = cve_time_range
if start == end:
print(f"CVE数据时间{start}")
else:
print(f"CVE数据时间范围{start} ~ {end}")
print(f"{len(result)} 个工况,{total_rawids} 条 rawid{total_clips} 个 clip")
def parse_args():
parser = argparse.ArgumentParser(
description="从 AEB 表格或 aeb_rawid.json 直接生成 aeb_clips.json。"
)
parser.add_argument(
"--xlsx",
default=None,
help=(
"Excel 文件路径;未指定且 --input 也未指定时,默认读取脚本同目录下的 "
"G1Q3项目AEB场地计划表.xlsx"
),
)
parser.add_argument(
"--sheet-name",
default=None,
help=(
"Excel 单个 sheet 名称;指定后只读取该 sheet。"
f"未指定时默认读取:{', '.join(DEFAULT_SHEET_NAMES)}"
),
)
parser.add_argument(
"--sheet-names",
default=None,
help=(
"Excel 多个 sheet 名称,用英文逗号分隔;"
f"未指定时默认读取:{', '.join(DEFAULT_SHEET_NAMES)}"
),
)
parser.add_argument(
"--input",
default=None,
help="兼容旧流程:输入 aeb_rawid.json 文件路径",
)
parser.add_argument(
"--output",
default=None,
help="输出 JSON 文件路径;未指定时自动按 CVE数据 时间范围命名",
)
return parser.parse_args()
def main():
args = parse_args()
if args.xlsx and args.input:
raise ValueError("--xlsx 和 --input 不能同时指定,请二选一。")
if args.sheet_name and args.sheet_names:
raise ValueError("--sheet-name 和 --sheet-names 不能同时指定,请二选一。")
if args.input:
print(f"读取 rawid JSON{args.input}")
rawid_data = load_rawid_json(args.input)
source_path = args.input
else:
xlsx_path = args.xlsx or DEFAULT_XLSX
print(f"读取 Excel{xlsx_path}")
if args.sheet_name:
sheet_names = [args.sheet_name]
allow_missing_sheets = False
elif args.sheet_names:
sheet_names = [
sheet_name.strip()
for sheet_name in args.sheet_names.split(",")
if sheet_name.strip()
]
if not sheet_names:
raise ValueError("--sheet-names 至少需要指定一个有效 sheet 名称")
allow_missing_sheets = False
else:
sheet_names = DEFAULT_SHEET_NAMES
allow_missing_sheets = True
print(f"读取 sheet{', '.join(sheet_names)}")
rawid_data = parse_excel(
xlsx_path,
sheet_names=sheet_names,
allow_missing_sheets=allow_missing_sheets,
)
source_path = xlsx_path
output_path = resolve_output_path(args.output, source_path, rawid_data)
cve_time_range = get_cve_time_range(rawid_data)
total_rawids = sum(len(records) for records in rawid_data.values())
print(f"待查询 {len(rawid_data)} 个工况,{total_rawids} 条 rawid 记录")
result = build_clips_manifest(rawid_data)
output_payload = build_output_payload(result)
with open(output_path, "w", encoding="utf-8") as f:
json.dump(output_payload, f, ensure_ascii=False, indent=2)
print_summary(result, output_path, cve_time_range=cve_time_range)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,59 @@
import re
from functools import lru_cache
from pathlib import Path
from typing import Optional, Sequence
from pdcl_dss import Clip
class PDCLClipService:
"""Resolve clip_uuid to local clip cache path."""
@staticmethod
@lru_cache(maxsize=4096)
def get_clip_path(clip_uuid: str) -> Optional[str]:
clip = Clip(clip_uuid)
files, _ = clip.list_files()
if not files:
return None
return clip.get_cache_path(files[0])
@staticmethod
def _extract_timestamp_token(text: str) -> Optional[str]:
if not text:
return None
patterns = (
r"(20\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})",
r"(20\d{2})[-_](\d{2})[-_](\d{2})[-_](\d{2})[-_](\d{2})[-_](\d{2})",
)
for pattern in patterns:
match = re.search(pattern, text)
if match:
return "".join(match.groups())
return None
@staticmethod
@lru_cache(maxsize=4096)
def get_clip_sort_key(clip_uuid: str) -> tuple[int, str, str]:
clip_path = PDCLClipService.get_clip_path(clip_uuid)
candidates = []
if clip_path:
path = Path(clip_path)
candidates.extend([path.name, str(path)])
candidates.append(clip_uuid)
for candidate in candidates:
timestamp = PDCLClipService._extract_timestamp_token(candidate)
if timestamp:
return (0, timestamp, clip_uuid)
return (1, clip_uuid, clip_uuid)
@staticmethod
def sort_clip_uuids(clip_uuids: Sequence[str]) -> list[str]:
unique_clips = [
clip_uuid
for clip_uuid in dict.fromkeys(clip_uuids).keys()
if clip_uuid
]
return sorted(unique_clips, key=PDCLClipService.get_clip_sort_key)

View File

@@ -0,0 +1,56 @@
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
@dataclass(frozen=True)
class ClipTask:
clip_uuid: str
date_name: str
vehicle_name: str
clip_path: str
@property
def task_id(self) -> str:
return self.clip_uuid
@dataclass(frozen=True)
class RawIDTask:
scenario_key: str
scenario_name: str
raw_id: str
cve_data: Optional[str]
offset: Optional[str]
target_speed: Optional[str]
ego_speed: Optional[str]
clips: tuple[str, ...]
@property
def task_id(self) -> str:
return self.raw_id
@dataclass
class TaskResult:
task_id: str
success: bool
message: str
output_dir: Optional[str] = None
@dataclass(frozen=True)
class VideoCaseTask:
input_path: str
case_dir: str
video_path: str
calib_path: str
output_rel_dir: str
@property
def task_id(self) -> str:
return self.case_dir
@property
def case_name(self) -> str:
return Path(self.case_dir).name

View File

@@ -0,0 +1,265 @@
"""
Read calibration parameters for each clip in clip_list.txt and save to a CSV table.
Usage:
python tools/pdcl_inference/read_calib_from_clips.py \
--clip-list-file tools/pdcl_inference/clip_list.txt \
--output calib_table.csv
Calibration source priority (same as run_batch_merged_infer.py):
1. Nearby camera4.json found by resolve_calib_file_for_clip()
2. camera4.json embedded as MCAP attachment
"""
import argparse
import json
import math
import os
import sys
import traceback
from pathlib import Path
from typing import List, Optional
import pandas as pd
FILE = Path(__file__).resolve()
ROOT = FILE.parents[2]
if str(ROOT) not in sys.path:
sys.path.append(str(ROOT))
os.environ["STS_UID"] = "dis-uploader"
os.environ["STS_SECRET_KEY"] = "277310cc09724d315514a79701fecb0f"
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
from tools.pdcl_inference.pdcl_clip_service import PDCLClipService
# ---------------------------------------------------------------------------
# Calibration helpers (mirrors run_batch_merged_infer.py logic)
# ---------------------------------------------------------------------------
CALIB_ATTACHMENT_NAMES = (
"sigmastar.1/calibs/camera4.json",
"test_data/calibs/camera4.json",
"calibs/camera4.json",
)
def resolve_calib_file_for_clip(clip_path: str) -> Optional[Path]:
"""Search for camera4.json in directories near the clip file (same as run_batch_merged_infer.py)."""
base = Path(clip_path).resolve()
search_dir = base.parent
candidates = []
for _ in range(4):
candidates.extend([
search_dir / "calibs" / "camera4.json",
search_dir / "test_data" / "calibs" / "camera4.json",
search_dir / "L2_calib" / "camera4.json",
search_dir / "calib" / "L2_calib" / "camera4.json",
])
parent = search_dir.parent
if parent == search_dir:
break
search_dir = parent
for p in candidates:
if p.exists():
return p
return None
def read_calib_from_mcap_attachment(clip_path: str) -> Optional[dict]:
"""Extract camera4.json from MCAP attachments."""
try:
from pdcl_pyclip.reader import ClipReader
except ImportError:
return None
try:
reader = ClipReader(clip_path)
for attachment in reader.iter_attachments():
if attachment.name in CALIB_ATTACHMENT_NAMES:
return json.loads(attachment.data.decode("utf-8"))
except Exception:
pass
return None
def get_calib_for_clip(clip_path: str) -> tuple[Optional[dict], str]:
"""Return (calib_dict, source_description). Returns (None, error_msg) on failure."""
# Priority 1: nearby file
nearby = resolve_calib_file_for_clip(clip_path)
if nearby is not None:
try:
with open(nearby) as f:
return json.load(f), f"file:{nearby}"
except Exception as e:
pass # fall through to MCAP attachment
# Priority 2: MCAP attachment
if clip_path.lower().endswith(".mcap"):
calib = read_calib_from_mcap_attachment(clip_path)
if calib is not None:
return calib, "mcap_attachment"
return None, "not_found"
# ---------------------------------------------------------------------------
# Clip list parser (same as run_batch_merged_infer.py)
# ---------------------------------------------------------------------------
def parse_clip_list(clip_list_file: str) -> List[dict]:
tasks = []
with open(clip_list_file) as f:
for line in f:
line = line.strip()
if not line:
continue
parts = line.split()
clip_uuid = parts[0]
date_name = parts[1] if len(parts) >= 2 else ""
tasks.append({"clip_uuid": clip_uuid, "date_name": date_name})
return tasks
# ---------------------------------------------------------------------------
# Calibration dict → flat record
# ---------------------------------------------------------------------------
def flatten_calib(calib: dict) -> dict:
coeffs = calib.get("distort_coeffs", [])
pos = calib.get("pos", [])
fx = calib.get("focal_u")
fy = calib.get("focal_v")
cx = calib.get("cu")
cy = calib.get("cv")
pitch = calib.get("pitch") # degrees
yaw = calib.get("yaw", 0.0) # degrees
# Vanishing point (same formula as dataloaders3d.py __getitem__):
# vanish_x = cx + fx * tan(yaw_deg * pi/180)
# vanish_y = cy - fy * tan(pitch_deg * pi/180)
if None not in (fx, fy, cx, cy, pitch):
vanish_x = cx + fx * math.tan(yaw * math.pi / 180)
vanish_y = cy - fy * math.tan(pitch * math.pi / 180)
else:
vanish_x = vanish_y = None
return {
"focal_u": fx,
"focal_v": fy,
"cu": cx,
"cv": cy,
"pitch": pitch,
"yaw": yaw,
"roll": calib.get("roll"),
"fov": calib.get("fov"),
"pos_x": pos[0] if len(pos) > 0 else None,
"pos_y": pos[1] if len(pos) > 1 else None,
"pos_z": pos[2] if len(pos) > 2 else None,
"vanish_x": vanish_x,
"vanish_y": vanish_y,
"k1": coeffs[0] if len(coeffs) > 0 else None,
"k2": coeffs[1] if len(coeffs) > 1 else None,
"k3": coeffs[2] if len(coeffs) > 2 else None,
"k4": coeffs[3] if len(coeffs) > 3 else None,
"img_width": calib.get("image_width", calib.get("img_width")),
"img_height": calib.get("image_height", calib.get("img_height")),
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(description="Extract calibration params from PDCL clips to CSV")
parser.add_argument("--clip-list-file", type=str,
default=str(FILE.parent / "clip_list.txt"),
help="Path to clip list text file")
parser.add_argument("--output", type=str, default="calib_table.csv",
help="Output CSV path")
args = parser.parse_args()
tasks = parse_clip_list(args.clip_list_file)
print(f"Found {len(tasks)} clips in {args.clip_list_file}")
rows = []
for idx, task in enumerate(tasks, start=1):
clip_uuid = task["clip_uuid"]
date_name = task["date_name"]
print(f"[{idx}/{len(tasks)}] clip_uuid={clip_uuid} ...", end=" ", flush=True)
row = {
"clip_uuid": clip_uuid,
"date_name": date_name,
"clip_path": "",
"calib_source": "",
"error": "",
}
try:
clip_path = PDCLClipService.get_clip_path(clip_uuid)
if clip_path is None:
row["error"] = "clip_not_found"
print("SKIP (clip not found)")
rows.append(row)
continue
row["clip_path"] = clip_path
calib, source = get_calib_for_clip(clip_path)
row["calib_source"] = source
if calib is None:
row["error"] = "calib_not_found"
print("SKIP (calib not found)")
else:
row.update(flatten_calib(calib))
print(f"OK ({source})")
except Exception as exc:
row["error"] = f"{type(exc).__name__}: {exc}"
print(f"ERROR: {exc}")
traceback.print_exc()
rows.append(row)
df = pd.DataFrame(rows)
# Reorder columns for readability
lead_cols = ["clip_uuid", "date_name",
"focal_u", "focal_v", "cu", "cv",
"pitch", "yaw", "roll", "fov",
"pos_x", "pos_y", "pos_z",
"vanish_x", "vanish_y",
"k1", "k2", "k3", "k4",
"img_width", "img_height", "calib_source", "clip_path", "error"]
present = [c for c in lead_cols if c in df.columns]
extra = [c for c in df.columns if c not in present]
df = df[present + extra]
out_path = Path(args.output)
out_path.parent.mkdir(parents=True, exist_ok=True)
df.to_csv(out_path, index=False)
print(f"\nSaved {len(df)} rows → {out_path}")
# Also save Excel if openpyxl is available
try:
xlsx_path = out_path.with_suffix(".xlsx")
df.to_excel(xlsx_path, index=False)
print(f"Saved Excel → {xlsx_path}")
except Exception:
pass
# Print summary table to console
print("\n" + df.to_string(index=False))
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,75 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
PYTHON_BIN="${PYTHON_BIN:-python}"
# Launcher for batch exporting PDCL clips and running two-ROI inference.
#
# Usage:
# bash tools/pdcl_inference/run_batch_two_roi_infer.sh
#
# Common env overrides:
# CLIP_LIST_FILE, EXPORT_ROOT, VISUALIZATION_ROOT
#
# Optional env overrides:
# OUTPUT_PREFIX, CAMERA_TOPIC, MAX_FRAMES_PER_CLIP, CALIB_FILE,
# ROI0_MODEL, ROI1_MODEL, DEVICE, LIMIT_CLIPS, SKIP_DONE, HALF, EXTRA_ARGS
CLIP_LIST_FILE="${CLIP_LIST_FILE:-${SCRIPT_DIR}/clips_aeb.txt}"
EXPORT_ROOT="${EXPORT_ROOT:-${SCRIPT_DIR}/clip_exports}"
OUTPUT_PREFIX="${OUTPUT_PREFIX:-clip_export}"
CAMERA_TOPIC="${CAMERA_TOPIC:-camera4}"
MAX_FRAMES_PER_CLIP="${MAX_FRAMES_PER_CLIP:-0}"
CALIB_FILE="${CALIB_FILE:-}"
VISUALIZATION_ROOT="${VISUALIZATION_ROOT:-${SCRIPT_DIR}/visualization_aeb_20260403}"
ROI0_MODEL="${ROI0_MODEL:-${PROJECT_ROOT}/runs/detect/mono3d_roi0_20260403_epoch99.pt}"
ROI1_MODEL="${ROI1_MODEL:-${PROJECT_ROOT}/runs/detect/mono3d_roi1_20260403_epoch99.pt}"
DEVICE="${DEVICE:-0}"
LIMIT_CLIPS="${LIMIT_CLIPS:-0}"
SKIP_DONE="${SKIP_DONE:-1}"
HALF="${HALF:-0}"
EXTRA_ARGS="${EXTRA_ARGS:-}"
CMD=(
"${PYTHON_BIN}" "${PROJECT_ROOT}/tools/pdcl_inference/run_batch_two_roi_infer.py"
--clip-list-file "${CLIP_LIST_FILE}"
--export-root "${EXPORT_ROOT}"
--output-prefix "${OUTPUT_PREFIX}"
--camera-topic "${CAMERA_TOPIC}"
--max-frames-per-clip "${MAX_FRAMES_PER_CLIP}"
--visualization-root "${VISUALIZATION_ROOT}"
--roi0-model "${ROI0_MODEL}"
--roi1-model "${ROI1_MODEL}"
--device "${DEVICE}"
)
if [[ -n "${CALIB_FILE}" ]]; then
CMD+=(--calib-file "${CALIB_FILE}")
fi
if [[ "${LIMIT_CLIPS}" != "0" ]]; then
CMD+=(--limit-clips "${LIMIT_CLIPS}")
fi
if [[ "${SKIP_DONE}" == "1" ]]; then
CMD+=(--skip-done)
fi
if [[ "${HALF}" == "1" ]]; then
CMD+=(--half)
fi
if [[ -n "${EXTRA_ARGS}" ]]; then
# shellcheck disable=SC2206
EXTRA_ARR=(${EXTRA_ARGS})
CMD+=("${EXTRA_ARR[@]}")
fi
CMD+=("$@")
"${CMD[@]}"
# /data1/dongying/Mono3d/G1Q3/feishu_project/downloaded_issue_data/issue_6841968325\pdcl_01\20260227163613

View File

@@ -0,0 +1,181 @@
from __future__ import annotations
import argparse
import glob
import json
import subprocess
import sys
from pathlib import Path
from typing import Any
FILE = Path(__file__).resolve()
ROOT = FILE.parents[2]
if str(ROOT) not in sys.path:
sys.path.append(str(ROOT))
from tools.pdcl_inference.run_batch_two_roi_infer import (
DEFAULT_OUTPUT_ROOT,
parse_rawid_tasks,
)
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Select the latest N raw_ids for each scenario from an AEB manifest and invoke run_batch_two_roi_infer.py."
)
parser.add_argument(
"--aeb-json",
type=str,
default=str(FILE.parent / "aeb*.json"),
help="AEB manifest JSON path or glob pattern",
)
parser.add_argument(
"--output-root",
type=str,
default=str(DEFAULT_OUTPUT_ROOT / "latest_two_rawids_per_scenario"),
help="Output root forwarded to run_batch_two_roi_infer.py",
)
parser.add_argument(
"--num-rawids-per-scenario",
type=int,
default=2,
help="Keep the latest N raw_ids for each scenario",
)
parser.add_argument(
"batch_args",
nargs=argparse.REMAINDER,
help="Extra args forwarded to run_batch_two_roi_infer.py; prefix them with '--'",
)
return parser.parse_args(argv)
def resolve_aeb_json(path_or_glob: str) -> Path:
candidate = Path(path_or_glob)
if candidate.is_file():
return candidate.resolve()
matches = sorted(Path(path) for path in glob.glob(path_or_glob))
if not matches:
raise FileNotFoundError(f"No AEB manifest matched: {path_or_glob}")
return max(matches, key=lambda path: path.stat().st_mtime).resolve()
def load_manifest(path: Path) -> dict[str, Any]:
with path.open("r", encoding="utf-8") as file:
payload = json.load(file)
if not isinstance(payload, dict):
raise ValueError(f"{path} 顶层必须是 JSON object实际: {type(payload).__name__}")
return payload
def filter_manifest_by_rawids(payload: dict[str, Any], selected_rawids: set[str]) -> dict[str, Any]:
scenarios = payload.get("scenarios", payload)
if not isinstance(scenarios, dict):
raise ValueError("AEB manifest 的 scenarios 字段必须是 dict")
filtered_scenarios = {}
for scenario_key, records in scenarios.items():
if not isinstance(records, list):
continue
filtered_records = []
for record in records:
if not isinstance(record, dict):
continue
raw_id = str(record.get("rawid", "")).strip()
if raw_id and raw_id in selected_rawids:
filtered_records.append(record)
if filtered_records:
filtered_scenarios[scenario_key] = filtered_records
summary = {
"total_scenarios": len(filtered_scenarios),
"total_rawids": sum(len(records) for records in filtered_scenarios.values()),
"scenario_total_rawids": {
scenario_key: len(records)
for scenario_key, records in filtered_scenarios.items()
},
}
return {
"summary": summary,
"scenarios": filtered_scenarios,
}
def normalize_batch_args(batch_args: list[str]) -> list[str]:
if batch_args and batch_args[0] == "--":
return batch_args[1:]
return batch_args
def select_latest_rawids_per_scenario(tasks, num_rawids_per_scenario: int):
selected_tasks = []
grouped_tasks = {}
for task in tasks:
grouped_tasks.setdefault(task.scenario_key, []).append(task)
for scenario_key in sorted(grouped_tasks):
scenario_tasks = sorted(
grouped_tasks[scenario_key],
key=lambda task: (task.cve_data or "", task.raw_id),
reverse=True,
)[:num_rawids_per_scenario]
selected_tasks.extend(scenario_tasks)
return selected_tasks
def main(argv: list[str] | None = None) -> None:
args = parse_args(argv)
batch_args = normalize_batch_args(args.batch_args)
manifest_path = resolve_aeb_json(args.aeb_json)
tasks = parse_rawid_tasks(str(manifest_path))
if not tasks:
raise ValueError(f"{manifest_path} 中没有可用于推理的 raw_id + clips 记录")
if args.num_rawids_per_scenario <= 0:
raise ValueError("--num-rawids-per-scenario 必须大于 0")
selected_tasks = select_latest_rawids_per_scenario(
tasks,
num_rawids_per_scenario=args.num_rawids_per_scenario,
)
selected_rawids = {task.raw_id for task in selected_tasks}
if not selected_rawids:
raise ValueError("没有选出任何 raw_id")
output_root = Path(args.output_root).resolve()
status_dir = output_root / "_status"
status_dir.mkdir(parents=True, exist_ok=True)
selected_manifest = filter_manifest_by_rawids(load_manifest(manifest_path), selected_rawids)
selected_manifest_path = status_dir / "latest_two_rawids_per_scenario.json"
with selected_manifest_path.open("w", encoding="utf-8") as file:
json.dump(selected_manifest, file, indent=2, ensure_ascii=False)
print(f"Selected manifest: {manifest_path}")
print(f"Selected latest {args.num_rawids_per_scenario} raw_ids for each scenario:")
for task in sorted(selected_tasks, key=lambda task: (task.scenario_key, task.cve_data or "", task.raw_id)):
print(
f" - scenario={task.scenario_key} raw_id={task.raw_id} "
f"cve={task.cve_data or 'n/a'} clips={len(task.clips)}"
)
print(f"Filtered manifest saved to: {selected_manifest_path}")
batch_script = FILE.parent / "run_batch_two_roi_infer.py"
cmd = [
sys.executable,
str(batch_script),
"--rawid-json",
str(selected_manifest_path),
"--output-root",
str(output_root),
*batch_args,
]
print("Running:")
print(" ".join(cmd))
subprocess.run(cmd, check=True)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,68 @@
import argparse
import sys
from pathlib import Path
FILE = Path(__file__).resolve()
ROOT = FILE.parents[2]
if str(ROOT) not in sys.path:
sys.path.append(str(ROOT))
from tools.pdcl_inference.run_batch_two_roi_infer import (
add_inference_args,
build_two_roi_inference_context_from_args,
)
from tools.pdcl_inference.two_roi_inference import (
DEFAULT_VISUALIZATION_ROOT,
run_case_inference,
run_video_case_inference,
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run two-ROI Detect3D inference on one exported PDCL case or image directory.")
parser.add_argument("--case-dir", type=str, default="", help="Case directory containing images/ and calib/L2_calib/camera4.json")
parser.add_argument("--video-case-dir", type=str, default="", help="Video case path or camera4.bin path with nearby camera4.json")
parser.add_argument("--video-stride", type=int, default=1, help="Read every Nth frame from camera4.bin inputs")
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("--glob", type=str, default="*.png", help="Image glob inside the case images directory")
parser.add_argument("--max-images", type=int, default=0, help="Maximum number of images/frames to process; 0 means all")
parser.add_argument(
"--output-dir",
type=str,
default=str(DEFAULT_VISUALIZATION_ROOT),
help="Visualization output directory or root",
)
add_inference_args(parser)
return parser.parse_args()
def main() -> None:
args = parse_args()
context = build_two_roi_inference_context_from_args(args)
output_dir = Path(args.output_dir)
output_root = output_dir if output_dir != DEFAULT_VISUALIZATION_ROOT else DEFAULT_VISUALIZATION_ROOT
if args.video_case_dir:
result = run_video_case_inference(
context=context,
video_case_dir=args.video_case_dir,
output_dir=output_root,
max_images=args.max_images,
video_stride=args.video_stride,
)
else:
result = run_case_inference(
context=context,
case_dir=args.case_dir,
images_dir=args.images_dir,
calib_file=args.calib_file,
output_dir=output_root,
glob_pattern=args.glob,
max_images=args.max_images,
)
print(f"Saved {result['num_frames']} visualizations to {result['output_dir']}")
print(f"Predictions JSON: {result['predictions_path']}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,692 @@
from __future__ import annotations
import argparse
import csv
import json
import math
import random
import sys
import time
from collections import Counter, defaultdict
from datetime import datetime
from pathlib import Path
from typing import Any, Optional
import cv2
import numpy as np
import torch
FILE = Path(__file__).resolve()
ROOT = FILE.parents[2]
if str(ROOT) not in sys.path:
sys.path.append(str(ROOT))
from tools.pdcl_inference.analyze_val_two_roi_badcases import (
MIN_CONFIDENCE_FOR_2D_THRESHOLD_SEARCH,
annotate_panel_title,
box_iou_matrix,
draw_box_with_label,
entry_to_image_file,
entry_to_label_file,
get_cls_name,
greedy_match_indices_any_class,
infer_class_name_map,
load_split_entries,
load_yaml,
make_reservoir_store,
names_to_dict,
prepare_gt_for_roi,
read_raw_calib_from_label,
reservoir_add,
reservoir_records,
resolve_data_path,
run_model_for_prepared_roi,
sanitize_name,
to_float,
)
from tools.pdcl_inference.two_roi_inference import (
_filter_prediction_rows,
_prepare_roi_image,
build_inference_context,
)
from tools.pdcl_inference.run_batch_two_roi_infer import (
add_two_roi_inference_args,
build_roi_specs_from_args,
)
DEFAULT_OUTPUT_ROOT = FILE.parent / "validation_analysis" / "background_miss_samples_{}".format(
datetime.now().strftime("%Y%m%d_%H%M%S")
)
MISS_REASON_ORDER = ("no_overlap", "localization", "threshold_limited", "assignment_conflict")
MISS_RECORD_FIELDS = [
"sample_index",
"roi",
"frame_name",
"image_path",
"label_path",
"cls_id",
"cls_name",
"gt_index",
"confidence_threshold",
"threshold_source",
"miss_reason",
"difficulty",
"gt_bbox_xyxy",
"gt_bbox_w_px",
"gt_bbox_h_px",
"gt_bbox_diag_px",
"max_iou_kept",
"best_kept_pred_index",
"best_kept_pred_cls_id",
"best_kept_pred_cls_name",
"best_kept_pred_conf",
"best_kept_pred_bbox_xyxy",
"max_iou_raw",
"best_raw_pred_index",
"best_raw_pred_cls_id",
"best_raw_pred_cls_name",
"best_raw_pred_conf",
"best_raw_pred_bbox_xyxy",
"visualization",
]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Sample GT objects that end up in the confusion-matrix background row (class-agnostic 2D misses)."
)
parser.add_argument(
"--data",
type=str,
default=str(ROOT / "ultralytics" / "cfg" / "datasets" / "mono3d_ground.yaml"),
help="Dataset YAML path used to resolve the analyzed split.",
)
parser.add_argument("--split", type=str, default="val", choices=("train", "val", "test"), help="Dataset split to analyze.")
parser.add_argument(
"--output-root",
type=str,
default=str(DEFAULT_OUTPUT_ROOT),
help="Directory where sampled miss visualizations and manifests will be written.",
)
parser.add_argument(
"--analyze-rois",
nargs="+",
default=["roi0", "roi1"],
choices=("roi0", "roi1"),
help="ROI models to analyze.",
)
parser.add_argument("--max-samples", type=int, default=0, help="Optional cap on analyzed samples. 0 means full split.")
parser.add_argument(
"--sample-selection",
type=str,
default="head",
choices=("head", "random"),
help="How to choose the first --max-samples entries when a cap is set.",
)
parser.add_argument("--sample-random-seed", type=int, default=20260408, help="Random seed used when --sample-selection=random.")
parser.add_argument("--per-class-samples", type=int, default=100, help="How many random missed GT samples to save per class.")
parser.add_argument("--badcase-random-seed", type=int, default=20260408, help="Random seed for per-class reservoir sampling.")
parser.add_argument("--log-every", type=int, default=100, help="Progress log interval in samples.")
parser.add_argument("--torch-threads", type=int, default=0, help="Optional torch CPU thread count override.")
parser.add_argument(
"--confidence-threshold",
type=float,
default=None,
help="Explicit confidence threshold for the confusion-matrix miss definition. If unset, try --report-root first, else use the model default.",
)
parser.add_argument(
"--report-root",
type=str,
default=None,
help="Optional existing report root whose <roi>/summary.json provides recommended_2d_confidence.",
)
parser.add_argument(
"--raw-conf-threshold",
type=float,
default=float(MIN_CONFIDENCE_FOR_2D_THRESHOLD_SEARCH),
help="Low confidence threshold used to keep raw candidate detections for debugging threshold-limited misses.",
)
parser.add_argument(
"--crop-scale",
type=float,
default=2.5,
help="Context scale around the GT box for the zoomed panel.",
)
add_two_roi_inference_args(parser, include_output_dir=False)
return parser.parse_args()
def load_json(path: Path) -> dict[str, Any]:
with path.open("r", encoding="utf-8") as file:
return json.load(file)
def write_json(path: Path, payload: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as file:
json.dump(payload, file, indent=2, ensure_ascii=False)
def write_csv(path: Path, rows: list[dict[str, Any]], fieldnames: list[str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8", newline="") as file:
writer = csv.DictWriter(file, fieldnames=fieldnames)
writer.writeheader()
for row in rows:
writer.writerow(row)
def maybe_list(value: Any) -> Optional[list[float]]:
if value is None:
return None
return [float(v) for v in np.asarray(value, dtype=np.float32).reshape(-1).tolist()]
def float_or_none(value: Any) -> Optional[float]:
resolved = to_float(value)
return None if resolved is None or not math.isfinite(float(resolved)) else float(resolved)
def resolve_confidence_threshold(
roi_name: str,
bundle,
explicit_threshold: Optional[float],
report_root: Optional[Path],
) -> tuple[float, str]:
if explicit_threshold is not None:
return float(explicit_threshold), "explicit_arg"
if report_root is not None:
summary_path = report_root / roi_name / "summary.json"
if summary_path.is_file():
payload = load_json(summary_path)
overall = payload.get("overall") or {}
threshold_advice = payload.get("threshold_advice_2d") or {}
recommended = float_or_none(overall.get("recommended_confidence_2d"))
if recommended is None:
recommended = float_or_none(threshold_advice.get("recommended_confidence"))
if recommended is not None:
return float(recommended), f"report:{summary_path}"
return float(bundle.spec.conf), "model_default"
def classify_miss_reason(max_iou_kept: Optional[float], max_iou_raw: Optional[float]) -> str:
kept = 0.0 if max_iou_kept is None else float(max_iou_kept)
raw = 0.0 if max_iou_raw is None else float(max_iou_raw)
if kept >= 0.5:
return "assignment_conflict"
if raw >= 0.5:
return "threshold_limited"
if raw >= 0.1:
return "localization"
return "no_overlap"
def crop_with_context(image: np.ndarray, gt_box: list[float], crop_scale: float) -> tuple[np.ndarray, int, int]:
x1, y1, x2, y2 = [float(v) for v in gt_box]
w = max(x2 - x1, 1.0)
h = max(y2 - y1, 1.0)
cx = 0.5 * (x1 + x2)
cy = 0.5 * (y1 + y2)
side = max(w, h) * max(float(crop_scale), 1.0)
side = max(side, 160.0)
crop_x1 = max(0, int(math.floor(cx - side * 0.5)))
crop_y1 = max(0, int(math.floor(cy - side * 0.5)))
crop_x2 = min(image.shape[1], int(math.ceil(cx + side * 0.5)))
crop_y2 = min(image.shape[0], int(math.ceil(cy + side * 0.5)))
if crop_x2 <= crop_x1:
crop_x2 = min(image.shape[1], crop_x1 + 1)
if crop_y2 <= crop_y1:
crop_y2 = min(image.shape[0], crop_y1 + 1)
return image[crop_y1:crop_y2, crop_x1:crop_x2].copy(), crop_x1, crop_y1
def translate_box(xyxy: Optional[list[float]], offset_x: int, offset_y: int) -> Optional[list[float]]:
if xyxy is None:
return None
x1, y1, x2, y2 = [float(v) for v in xyxy]
return [x1 - float(offset_x), y1 - float(offset_y), x2 - float(offset_x), y2 - float(offset_y)]
def draw_record_overlays(image: np.ndarray, record: dict[str, Any], title: str) -> np.ndarray:
panel = draw_box_with_label(image, record["gt_bbox_xyxy"], (0, 200, 0), f"GT {record['cls_name']}")
kept_box = record.get("best_kept_pred_bbox_xyxy")
if kept_box is not None:
kept_label = (
f"kept {record.get('best_kept_pred_cls_name', 'unknown')} "
f"{record.get('best_kept_pred_conf', 0.0):.2f} IoU={record.get('max_iou_kept', 0.0):.2f}"
)
panel = draw_box_with_label(panel, kept_box, (0, 0, 255), kept_label)
raw_box = record.get("best_raw_pred_bbox_xyxy")
raw_index = record.get("best_raw_pred_index")
kept_index = record.get("best_kept_pred_index")
if raw_box is not None and raw_index != kept_index:
raw_label = (
f"raw {record.get('best_raw_pred_cls_name', 'unknown')} "
f"{record.get('best_raw_pred_conf', 0.0):.2f} IoU={record.get('max_iou_raw', 0.0):.2f}"
)
panel = draw_box_with_label(panel, raw_box, (0, 165, 255), raw_label)
return annotate_panel_title(panel, title)
def make_text_panel(shape: tuple[int, int, int], title: str, record: dict[str, Any]) -> np.ndarray:
panel = np.full(shape, 28, dtype=np.uint8)
lines = [
title,
f"{record['cls_name']} miss_reason={record['miss_reason']}",
f"threshold={record['confidence_threshold']:.3f} source={record['threshold_source']}",
(
f"GT diag={record['gt_bbox_diag_px']:.1f}px "
f"w={record['gt_bbox_w_px']:.1f}px h={record['gt_bbox_h_px']:.1f}px"
),
f"difficulty={record.get('difficulty') if record.get('difficulty') is not None else 'n/a'}",
(
f"kept IoU={record.get('max_iou_kept', 0.0):.3f} "
f"cls={record.get('best_kept_pred_cls_name', 'n/a')} conf={record.get('best_kept_pred_conf', 'n/a')}"
),
(
f"raw IoU={record.get('max_iou_raw', 0.0):.3f} "
f"cls={record.get('best_raw_pred_cls_name', 'n/a')} conf={record.get('best_raw_pred_conf', 'n/a')}"
),
f"frame={record['frame_name']}",
f"gt_index={record['gt_index']} sample_index={record['sample_index']}",
]
y = 28
for line in lines:
cv2.putText(panel, str(line), (10, y), cv2.FONT_HERSHEY_SIMPLEX, 0.56, (220, 220, 220), 1, cv2.LINE_AA)
y += 34
return panel
def save_sample_visuals(
records: list[dict[str, Any]],
output_dir: Path,
bundle,
crop_scale: float,
) -> list[dict[str, Any]]:
output_dir.mkdir(parents=True, exist_ok=True)
manifest: list[dict[str, Any]] = []
for rank, record in enumerate(records, start=1):
image_path = Path(str(record["image_path"]))
label_path = Path(str(record["label_path"]))
image_bgr = cv2.imread(str(image_path), cv2.IMREAD_COLOR)
if image_bgr is None:
continue
raw_calib = read_raw_calib_from_label(image_path, label_path)
prepared = _prepare_roi_image(image_bgr, raw_calib, bundle.spec, bundle.imgsz)
roi_image = prepared.image.copy()
panel_size = (roi_image.shape[1], roi_image.shape[0])
panel_full = draw_record_overlays(roi_image, record, f"{bundle.spec.name} full miss")
crop_image, crop_x1, crop_y1 = crop_with_context(roi_image, record["gt_bbox_xyxy"], crop_scale)
crop_record = dict(record)
crop_record["gt_bbox_xyxy"] = translate_box(record["gt_bbox_xyxy"], crop_x1, crop_y1)
crop_record["best_kept_pred_bbox_xyxy"] = translate_box(record.get("best_kept_pred_bbox_xyxy"), crop_x1, crop_y1)
crop_record["best_raw_pred_bbox_xyxy"] = translate_box(record.get("best_raw_pred_bbox_xyxy"), crop_x1, crop_y1)
panel_crop = draw_record_overlays(crop_image, crop_record, f"{bundle.spec.name} crop")
panel_crop = cv2.resize(panel_crop, panel_size, interpolation=cv2.INTER_LINEAR)
panel_text = make_text_panel(roi_image.shape, f"{bundle.spec.name} miss #{rank}", record)
grid = np.concatenate([panel_full, panel_crop, panel_text], axis=1)
filename = (
f"{rank:03d}_{sanitize_name(Path(record['frame_name']).stem)}_{sanitize_name(record['cls_name'])}"
f"_reason_{sanitize_name(record['miss_reason'])}_g{record['gt_index']}.jpg"
)
image_out = output_dir / filename
cv2.imwrite(str(image_out), grid)
manifest.append({**record, "visualization": str(image_out)})
write_json(output_dir / "manifest.json", manifest)
return manifest
def record_to_csv_row(record: dict[str, Any]) -> dict[str, Any]:
row: dict[str, Any] = {}
for field in MISS_RECORD_FIELDS:
value = record.get(field)
if isinstance(value, (list, tuple, dict)):
row[field] = json.dumps(value, ensure_ascii=False)
else:
row[field] = value
return row
def sample_background_misses_for_roi(
bundle,
entries: list[tuple[str, str]],
image_root: Path,
class_map: dict[str, int],
difficulty_weights: list[float],
face_3d_classes: set[int],
complete_3d_classes: set[int],
classes: Optional[set[int]],
min_wh_px: float,
output_root: Path,
per_class_samples: int,
log_every: int,
badcase_random_seed: int,
confidence_threshold: float,
threshold_source: str,
raw_conf_threshold: float,
crop_scale: float,
) -> dict[str, Any]:
roi_name = bundle.spec.name.lower()
roi_output = output_root / roi_name
roi_output.mkdir(parents=True, exist_ok=True)
names_dict = names_to_dict(bundle.names)
class_gt_total: Counter[int] = Counter()
class_miss_total: Counter[int] = Counter()
class_reason_counts: defaultdict[int, Counter[str]] = defaultdict(Counter)
class_sample_stores: defaultdict[tuple[int, str], dict[str, Any]] = defaultdict(make_reservoir_store)
rng = random.Random(int(badcase_random_seed) + (0 if roi_name == "roi0" else 1000))
search_conf = min(float(raw_conf_threshold), float(bundle.spec.conf))
start_time = time.time()
for sample_index, entry in enumerate(entries):
image_path = entry_to_image_file(entry, image_root)
label_path = entry_to_label_file(entry)
image_bgr = cv2.imread(str(image_path), cv2.IMREAD_COLOR)
if image_bgr is None:
continue
ori_h, ori_w = image_bgr.shape[:2]
raw_calib = read_raw_calib_from_label(image_path, label_path)
prepared = _prepare_roi_image(image_bgr, raw_calib, bundle.spec, bundle.imgsz)
gt = prepare_gt_for_roi(
label_file=label_path,
ori_w=ori_w,
ori_h=ori_h,
prepared=prepared,
bundle=bundle,
class_map=class_map,
difficulty_weights=difficulty_weights,
face_3d_classes=face_3d_classes,
complete_3d_classes=complete_3d_classes,
include_classes=classes,
min_wh_px=min_wh_px,
)
raw_outputs = run_model_for_prepared_roi(bundle, prepared)
analysis_detections, _analysis_preds_3d, _analysis_preds_edge, _analysis_anchors, _analysis_strides = _filter_prediction_rows(
*raw_outputs,
conf_thres=float(search_conf),
max_det=bundle.spec.max_det,
classes=classes,
)
raw_pred_boxes = (
np.asarray(analysis_detections[:, :4], dtype=np.float32) if len(analysis_detections) else np.zeros((0, 4), dtype=np.float32)
)
raw_pred_cls = (
np.asarray(analysis_detections[:, 5], dtype=np.int32).reshape(-1) if len(analysis_detections) else np.zeros((0,), dtype=np.int32)
)
raw_pred_conf = (
np.asarray(analysis_detections[:, 4], dtype=np.float32).reshape(-1) if len(analysis_detections) else np.zeros((0,), dtype=np.float32)
)
keep = raw_pred_conf > float(confidence_threshold) if raw_pred_conf.size else np.zeros((0,), dtype=bool)
kept_pred_boxes = raw_pred_boxes[keep]
kept_pred_cls = raw_pred_cls[keep]
kept_pred_conf = raw_pred_conf[keep]
gt_boxes = np.asarray(gt["boxes_xyxy"], dtype=np.float32).reshape(-1, 4)
gt_cls = np.asarray(gt["classes"], dtype=np.int32).reshape(-1)
gt_difficulties = np.asarray(gt["lb_2d"].get("difficulties", []), dtype=np.float32).reshape(-1)
matches, iou_kept = greedy_match_indices_any_class(gt_boxes, kept_pred_boxes, iou_thr=0.5)
iou_raw = box_iou_matrix(gt_boxes, raw_pred_boxes)
matched_gt = {int(gt_index) for gt_index, _ in matches.tolist()}
for cls_id in gt_cls.tolist():
class_gt_total[int(cls_id)] += 1
for gt_index, cls_id in enumerate(gt_cls.tolist()):
if int(gt_index) in matched_gt:
continue
cls_id = int(cls_id)
cls_name = get_cls_name(names_dict, cls_id)
class_miss_total[cls_id] += 1
kept_row = iou_kept[gt_index] if iou_kept.shape[1] > 0 else np.zeros((0,), dtype=np.float32)
raw_row = iou_raw[gt_index] if iou_raw.shape[1] > 0 else np.zeros((0,), dtype=np.float32)
best_kept_pred_index = None if kept_row.size == 0 else int(np.argmax(kept_row))
best_raw_pred_index = None if raw_row.size == 0 else int(np.argmax(raw_row))
max_iou_kept = 0.0 if best_kept_pred_index is None else float(kept_row[best_kept_pred_index])
max_iou_raw = 0.0 if best_raw_pred_index is None else float(raw_row[best_raw_pred_index])
if best_kept_pred_index is not None and max_iou_kept <= 0.0:
best_kept_pred_index = None
max_iou_kept = 0.0
if best_raw_pred_index is not None and max_iou_raw <= 0.0:
best_raw_pred_index = None
max_iou_raw = 0.0
miss_reason = classify_miss_reason(max_iou_kept=max_iou_kept, max_iou_raw=max_iou_raw)
class_reason_counts[cls_id][miss_reason] += 1
gt_box = gt_boxes[gt_index]
gt_w = float(max(gt_box[2] - gt_box[0], 0.0))
gt_h = float(max(gt_box[3] - gt_box[1], 0.0))
record = {
"sample_index": int(sample_index),
"roi": roi_name,
"frame_name": image_path.name,
"image_path": str(image_path),
"label_path": str(label_path),
"cls_id": cls_id,
"cls_name": cls_name,
"gt_index": int(gt_index),
"confidence_threshold": float(confidence_threshold),
"threshold_source": str(threshold_source),
"miss_reason": miss_reason,
"difficulty": (
None if gt_index >= len(gt_difficulties) else int(round(float(gt_difficulties[gt_index])))
),
"gt_bbox_xyxy": maybe_list(gt_box),
"gt_bbox_w_px": gt_w,
"gt_bbox_h_px": gt_h,
"gt_bbox_diag_px": float(math.hypot(gt_w, gt_h)),
"max_iou_kept": float(max_iou_kept),
"best_kept_pred_index": None if best_kept_pred_index is None else int(best_kept_pred_index),
"best_kept_pred_cls_id": (
None if best_kept_pred_index is None else int(kept_pred_cls[best_kept_pred_index])
),
"best_kept_pred_cls_name": (
None if best_kept_pred_index is None else get_cls_name(names_dict, int(kept_pred_cls[best_kept_pred_index]))
),
"best_kept_pred_conf": (
None if best_kept_pred_index is None else float(kept_pred_conf[best_kept_pred_index])
),
"best_kept_pred_bbox_xyxy": (
None if best_kept_pred_index is None else maybe_list(kept_pred_boxes[best_kept_pred_index])
),
"max_iou_raw": float(max_iou_raw),
"best_raw_pred_index": None if best_raw_pred_index is None else int(best_raw_pred_index),
"best_raw_pred_cls_id": None if best_raw_pred_index is None else int(raw_pred_cls[best_raw_pred_index]),
"best_raw_pred_cls_name": (
None if best_raw_pred_index is None else get_cls_name(names_dict, int(raw_pred_cls[best_raw_pred_index]))
),
"best_raw_pred_conf": None if best_raw_pred_index is None else float(raw_pred_conf[best_raw_pred_index]),
"best_raw_pred_bbox_xyxy": None if best_raw_pred_index is None else maybe_list(raw_pred_boxes[best_raw_pred_index]),
}
reservoir_add(class_sample_stores[(cls_id, cls_name)], record, per_class_samples, rng)
if (sample_index + 1) % max(1, log_every) == 0 or (sample_index + 1) == len(entries):
elapsed = time.time() - start_time
per_sample = elapsed / max(sample_index + 1, 1)
remaining = len(entries) - (sample_index + 1)
eta = remaining * per_sample
print(
f"[{roi_name}] {sample_index + 1}/{len(entries)} "
f"elapsed={elapsed / 60:.1f}m eta={eta / 60:.1f}m background_misses={sum(class_miss_total.values())}"
)
class_summary_rows: list[dict[str, Any]] = []
sampled_rows: list[dict[str, Any]] = []
class_manifests: list[dict[str, Any]] = []
for (cls_id, cls_name), store in sorted(class_sample_stores.items(), key=lambda item: item[0][0]):
sampled_records = reservoir_records(store, rng)
class_dir = roi_output / "samples_by_class" / f"{int(cls_id):02d}_{sanitize_name(cls_name)}"
manifest = save_sample_visuals(sampled_records, class_dir, bundle=bundle, crop_scale=float(crop_scale))
class_manifests.append(
{
"cls_id": int(cls_id),
"cls_name": str(cls_name),
"count": len(manifest),
"manifest_path": str(class_dir / "manifest.json"),
}
)
sampled_rows.extend(record_to_csv_row(record) for record in manifest)
reason_counts = class_reason_counts.get(int(cls_id), Counter())
class_summary_rows.append(
{
"cls_id": int(cls_id),
"cls_name": str(cls_name),
"gt_total": int(class_gt_total.get(int(cls_id), 0)),
"background_miss_total": int(class_miss_total.get(int(cls_id), 0)),
"background_miss_rate": (
float(class_miss_total.get(int(cls_id), 0)) / float(class_gt_total.get(int(cls_id), 0))
if int(class_gt_total.get(int(cls_id), 0)) > 0
else None
),
"sampled_count": int(len(manifest)),
"confidence_threshold": float(confidence_threshold),
"threshold_source": str(threshold_source),
"no_overlap_count": int(reason_counts.get("no_overlap", 0)),
"localization_count": int(reason_counts.get("localization", 0)),
"threshold_limited_count": int(reason_counts.get("threshold_limited", 0)),
"assignment_conflict_count": int(reason_counts.get("assignment_conflict", 0)),
}
)
summary_fields = [
"cls_id",
"cls_name",
"gt_total",
"background_miss_total",
"background_miss_rate",
"sampled_count",
"confidence_threshold",
"threshold_source",
"no_overlap_count",
"localization_count",
"threshold_limited_count",
"assignment_conflict_count",
]
write_csv(roi_output / "class_background_miss_summary.csv", class_summary_rows, summary_fields)
write_csv(roi_output / "sampled_background_misses.csv", sampled_rows, MISS_RECORD_FIELDS)
payload = {
"roi": roi_name,
"model_path": bundle.spec.model_path,
"confidence_threshold": float(confidence_threshold),
"threshold_source": str(threshold_source),
"raw_conf_threshold": float(search_conf),
"per_class_samples": int(per_class_samples),
"total_gt": int(sum(class_gt_total.values())),
"total_background_misses": int(sum(class_miss_total.values())),
"class_summary_rows": class_summary_rows,
"class_manifests": class_manifests,
"summary_csv": str(roi_output / "class_background_miss_summary.csv"),
"sampled_csv": str(roi_output / "sampled_background_misses.csv"),
}
write_json(roi_output / "summary.json", payload)
return payload
def main() -> None:
args = parse_args()
if args.torch_threads and args.torch_threads > 0:
torch.set_num_threads(int(args.torch_threads))
torch.set_num_interop_threads(max(1, min(int(args.torch_threads), 4)))
data_yaml = Path(args.data).resolve()
data_cfg = load_yaml(data_yaml)
dataset_root = data_cfg.get("path")
split_path = resolve_data_path(data_yaml, dataset_root, data_cfg.get(args.split))
entries = load_split_entries(
split_path,
max_samples=args.max_samples,
sample_selection=str(args.sample_selection),
sample_random_seed=int(args.sample_random_seed),
)
image_root = resolve_data_path(data_yaml, None, dataset_root)
args.roi0_data = args.roi0_data or args.data
args.roi1_data = args.roi1_data or args.data
requested_rois = {roi.lower() for roi in args.analyze_rois}
roi_specs = [spec for spec in build_roi_specs_from_args(args) if spec.name.lower() in requested_rois]
context = build_inference_context(
roi_specs=roi_specs,
device=args.device,
half=args.half,
classes=args.classes,
edge_yaw_max_lateral_dist_m=float(args.edge_yaw_max_lateral_dist),
)
output_root = Path(args.output_root).resolve()
output_root.mkdir(parents=True, exist_ok=True)
report_root = None if args.report_root is None else Path(args.report_root).resolve()
class_map = {str(key): int(value) for key, value in (data_cfg.get("class_map") or {}).items()}
difficulty_weights = [float(v) for v in data_cfg.get("difficulty_weights", [1.0, 1.0, 1.0, 1.0])]
face_3d_classes = set(int(v) for v in data_cfg.get("face_3d_classes", []))
complete_3d_classes = set(int(v) for v in data_cfg.get("complete_3d_classes", []))
include_classes = None if args.classes is None else set(int(v) for v in args.classes)
min_wh_px = float(data_cfg.get("min_wh", 2.0))
class_names = infer_class_name_map(class_map, names_to_dict(context.roi_models[0].names) if context.roi_models else None)
start = time.time()
summary_by_roi: dict[str, dict[str, Any]] = {}
for bundle in context.roi_models:
roi_name = bundle.spec.name.lower()
confidence_threshold, threshold_source = resolve_confidence_threshold(
roi_name=roi_name,
bundle=bundle,
explicit_threshold=float_or_none(args.confidence_threshold),
report_root=report_root,
)
print(
f"\nSampling confusion-matrix background misses for {roi_name} on {len(entries)} {args.split} samples "
f"(conf>{confidence_threshold:.3f}, source={threshold_source})..."
)
summary_by_roi[roi_name] = sample_background_misses_for_roi(
bundle=bundle,
entries=entries,
image_root=image_root,
class_map=class_map,
difficulty_weights=difficulty_weights,
face_3d_classes=face_3d_classes,
complete_3d_classes=complete_3d_classes,
classes=include_classes,
min_wh_px=min_wh_px,
output_root=output_root,
per_class_samples=int(args.per_class_samples),
log_every=int(args.log_every),
badcase_random_seed=int(args.badcase_random_seed),
confidence_threshold=float(confidence_threshold),
threshold_source=threshold_source,
raw_conf_threshold=float(args.raw_conf_threshold),
crop_scale=float(args.crop_scale),
)
combined_summary = {
"data": str(data_yaml),
"split": str(args.split),
"split_path": str(split_path),
"image_root": str(image_root),
"num_entries": int(len(entries)),
"output_root": str(output_root),
"class_names": class_names,
"summary_by_roi": summary_by_roi,
"args": vars(args),
"elapsed_minutes": (time.time() - start) / 60.0,
}
write_json(output_root / "summary.json", combined_summary)
print("\nBackground-miss sampling complete.")
print(f"Output root: {output_root}")
for roi_name, payload in summary_by_roi.items():
print(
f"[{roi_name}] total_gt={payload['total_gt']} "
f"background_misses={payload['total_background_misses']} "
f"summary={payload['summary_csv']}"
)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,321 @@
"""
统计 aeb_clips*.json 中各场景的 rawid 数量和 clip 数量。
默认按每条 rawid 记录里的 "场景" 字段聚合,输出同名 *_stats.md。
示例:
python tools/pdcl_inference/stat_aeb_clips_by_scene.py \
--input tools/pdcl_inference/aeb_clips-20260322152509_to_20260430005758.json
python tools/pdcl_inference/stat_aeb_clips_by_scene.py \
--input tools/pdcl_inference/aeb_clips-20260322152509_to_20260430005758.json \
--json-output /tmp/aeb_scene_stats.json
"""
import argparse
import json
from collections import OrderedDict
from pathlib import Path
from typing import Any, Iterable
FILE = Path(__file__).resolve()
DEFAULT_INPUT = FILE.parent / "aeb_clips-20260322152509_to_20260430005758.json"
def _to_str(value: Any) -> str:
if value is None:
return ""
return str(value).strip()
def _default_output_path(input_path: Path) -> Path:
return input_path.with_name(f"{input_path.stem}_stats.md")
def normalize_scene_name(scene_name: str) -> str:
"""归一化历史表格中大小写不一致的场景名。"""
aliases = {
"CBLA-cn21": "CBLA-CN21",
"CBLA-cn24": "CBLA-CN24",
}
return aliases.get(scene_name, scene_name)
def load_scenario_records(input_path: Path) -> OrderedDict[str, list[dict[str, Any]]]:
with input_path.open("r", encoding="utf-8") as file:
payload = json.load(file)
if not isinstance(payload, dict):
raise ValueError(f"输入 JSON 顶层必须是 dict实际: {type(payload).__name__}")
scenarios = payload.get("scenarios", payload)
if not isinstance(scenarios, dict):
raise ValueError("输入 JSON 的 scenarios 字段必须是 dict")
records_by_key: OrderedDict[str, list[dict[str, Any]]] = OrderedDict()
for scenario_key, records in scenarios.items():
if not isinstance(records, list):
raise ValueError(
f"场景 {scenario_key} 对应的数据必须是 list实际: {type(records).__name__}"
)
records_by_key[str(scenario_key)] = records
return records_by_key
def iter_records(records_by_key: dict[str, list[dict[str, Any]]]) -> Iterable[tuple[str, dict[str, Any]]]:
for scenario_key, records in records_by_key.items():
for record in records:
if not isinstance(record, dict):
raise ValueError(
f"场景 {scenario_key} 中的记录必须是 dict实际: {type(record).__name__}"
)
yield scenario_key, record
def get_record_scene_name(scenario_key: str, record: dict[str, Any], normalize_scene: bool) -> str:
scene_name = _to_str(record.get("场景"))
if not scene_name:
scene_name = scenario_key.split("-", 1)[0] if scenario_key else "未知场景"
return normalize_scene_name(scene_name) if normalize_scene else scene_name
def get_record_rawid(record: dict[str, Any]) -> str:
return _to_str(record.get("rawid") or record.get("raw_id"))
def get_record_clips(record: dict[str, Any], raw_id: str) -> list[str]:
raw_clips = record.get("clips", [])
if raw_clips is None:
return []
if not isinstance(raw_clips, list):
raise ValueError(f"rawid={raw_id or 'UNKNOWN'} 的 clips 字段必须是 list")
return [clip for clip in (_to_str(item) for item in raw_clips) if clip]
def build_scene_stats(
records_by_key: dict[str, list[dict[str, Any]]],
normalize_scene: bool = True,
dedupe_rawids: bool = False,
dedupe_clips: bool = False,
) -> tuple[list[dict[str, Any]], dict[str, int]]:
grouped: OrderedDict[str, dict[str, Any]] = OrderedDict()
for scenario_key, record in iter_records(records_by_key):
raw_id = get_record_rawid(record)
if not raw_id:
continue
scene_name = get_record_scene_name(scenario_key, record, normalize_scene=normalize_scene)
clips = get_record_clips(record, raw_id)
item = grouped.setdefault(
scene_name,
{
"scene": scene_name,
"rawid_count": 0,
"clip_count": 0,
"_rawids": OrderedDict(),
"_clips": OrderedDict(),
},
)
if dedupe_rawids:
if raw_id not in item["_rawids"]:
item["_rawids"][raw_id] = None
item["rawid_count"] += 1
else:
item["_rawids"][raw_id] = None
item["rawid_count"] += 1
if dedupe_clips:
for clip in clips:
if clip not in item["_clips"]:
item["_clips"][clip] = None
item["clip_count"] += 1
else:
for clip in clips:
item["_clips"][clip] = None
item["clip_count"] += len(clips)
stats = [
{
"scene": item["scene"],
"rawid_count": item["rawid_count"],
"unique_rawid_count": len(item["_rawids"]),
"clip_count": item["clip_count"],
"unique_clip_count": len(item["_clips"]),
}
for item in grouped.values()
]
stats.sort(key=lambda item: item["scene"])
unique_rawids = {
raw_id
for item in grouped.values()
for raw_id in item["_rawids"].keys()
}
unique_clips = {
clip
for item in grouped.values()
for clip in item["_clips"].keys()
}
totals = {
"scene_count": len(stats),
"rawid_count": sum(item["rawid_count"] for item in stats),
"unique_rawid_count": len(unique_rawids),
"clip_count": sum(item["clip_count"] for item in stats),
"unique_clip_count": len(unique_clips),
}
return stats, totals
def render_markdown(
input_path: Path,
stats: list[dict[str, Any]],
totals: dict[str, int],
normalize_scene: bool,
dedupe_rawids: bool,
dedupe_clips: bool,
) -> str:
lines = [
"# AEB Clips 场景统计",
"",
f"源文件:`{input_path}`",
"",
"## 统计口径",
"",
"- 按每条记录中的 `场景` 字段聚合统计。",
]
if dedupe_rawids:
lines.append("- `rawid数量` 为当前场景下去重后的 rawid 数。")
else:
lines.append("- `rawid数量` 为 rawid 记录数。")
if dedupe_clips:
lines.append("- `clip数量` 为当前场景下去重后的 clip 数。")
else:
lines.append("- `clip数量` 为各 rawid 记录中 `clips` 列表长度之和。")
if normalize_scene:
lines.extend(
[
"- 场景名大小写归并:",
" - `CBLA-cn21` 归并到 `CBLA-CN21`",
" - `CBLA-cn24` 归并到 `CBLA-CN24`",
]
)
else:
lines.append("- 未做场景名归并。")
lines.extend(
[
"",
"## 汇总",
"",
"| 指标 | 数量 |",
"|---|---:|",
f"| 场景数 | {totals['scene_count']} |",
f"| rawid数量 | {totals['rawid_count']} |",
f"| clip数量 | {totals['clip_count']} |",
"",
"## 场景统计",
"",
"| 场景 | rawid数量 | clip数量 |",
"|---|---:|---:|",
]
)
for item in stats:
lines.append(f"| {item['scene']} | {item['rawid_count']} | {item['clip_count']} |")
lines.append(f"| **TOTAL** | **{totals['rawid_count']}** | **{totals['clip_count']}** |")
lines.append("")
return "\n".join(lines)
def save_json_stats(output_path: Path, stats: list[dict[str, Any]], totals: dict[str, int]) -> None:
output_path.parent.mkdir(parents=True, exist_ok=True)
with output_path.open("w", encoding="utf-8") as file:
json.dump({"summary": totals, "scenes": stats}, file, ensure_ascii=False, indent=2)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="统计 aeb_clips*.json 中各场景的 rawid 数量和 clip 数量。"
)
parser.add_argument(
"--input",
default=str(DEFAULT_INPUT),
help=f"输入 aeb_clips*.json默认: {DEFAULT_INPUT}",
)
parser.add_argument(
"--output",
default=None,
help="输出 Markdown 路径;未指定时输出到输入文件同目录的 *_stats.md",
)
parser.add_argument(
"--json-output",
default=None,
help="可选:同时输出机器可读 JSON 统计结果",
)
parser.add_argument(
"--no-normalize-scene",
action="store_true",
help="关闭历史场景名大小写归并",
)
parser.add_argument(
"--dedupe-rawids",
action="store_true",
help="按场景统计去重 rawid 数,而不是 rawid 记录数",
)
parser.add_argument(
"--dedupe-clips",
action="store_true",
help="按场景统计去重 clip 数,而不是 clips 列表长度之和",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
input_path = Path(args.input)
output_path = Path(args.output) if args.output else _default_output_path(input_path)
records_by_key = load_scenario_records(input_path)
stats, totals = build_scene_stats(
records_by_key,
normalize_scene=not args.no_normalize_scene,
dedupe_rawids=args.dedupe_rawids,
dedupe_clips=args.dedupe_clips,
)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(
render_markdown(
input_path=input_path,
stats=stats,
totals=totals,
normalize_scene=not args.no_normalize_scene,
dedupe_rawids=args.dedupe_rawids,
dedupe_clips=args.dedupe_clips,
),
encoding="utf-8",
)
if args.json_output:
save_json_stats(Path(args.json_output), stats, totals)
print(f"已保存 Markdown 统计: {output_path}")
if args.json_output:
print(f"已保存 JSON 统计: {args.json_output}")
print(
f"场景数={totals['scene_count']} rawid数量={totals['rawid_count']} "
f"clip数量={totals['clip_count']}"
)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,57 @@
import json
from pathlib import Path
from threading import Lock
from typing import Dict, Optional
class StatusStore:
"""Persistent status tracker for resumable batch inference."""
def __init__(self, status_file: Path):
self.status_file = status_file
self.lock = Lock()
self._status: Dict[str, Dict[str, str]] = {}
self.status_file.parent.mkdir(parents=True, exist_ok=True)
if self.status_file.exists():
try:
self._status = json.loads(self.status_file.read_text())
except Exception:
self._status = {}
def is_done(self, task_id: str) -> bool:
return self._status.get(task_id, {}).get("state") == "done"
def get(self, task_id: str) -> Optional[Dict[str, str]]:
return self._status.get(task_id)
def mark_running(self, task_id: str, detail: str) -> None:
with self.lock:
self._status[task_id] = {"state": "running", "detail": detail}
self._flush()
def mark_done(self, task_id: str, detail: str) -> None:
with self.lock:
self._status[task_id] = {"state": "done", "detail": detail}
self._flush()
def mark_failed(self, task_id: str, detail: str) -> None:
with self.lock:
self._status[task_id] = {"state": "failed", "detail": detail}
self._flush()
def summary(self) -> Dict[str, int]:
done = 0
running = 0
failed = 0
for item in self._status.values():
state = item.get("state")
if state == "done":
done += 1
elif state == "running":
running += 1
elif state == "failed":
failed += 1
return {"done": done, "running": running, "failed": failed, "total": len(self._status)}
def _flush(self) -> None:
self.status_file.write_text(json.dumps(self._status, indent=2, ensure_ascii=False))

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,316 @@
#!/usr/bin/env python3
import argparse
import json
import math
from pathlib import Path
from typing import Iterable, Tuple
import cv2
import numpy as np
def load_calib(calib_path: Path) -> dict:
with calib_path.open("r", encoding="utf-8") as file:
calib = json.load(file)
required = ["focal_u", "focal_v", "cu", "cv"]
missing = [key for key in required if key not in calib]
if missing:
raise KeyError(f"{calib_path} missing required keys: {missing}")
return calib
def infer_image_size(calib: dict) -> Tuple[int, int]:
width = calib.get("image_width", calib.get("img_width"))
height = calib.get("image_height", calib.get("img_height"))
if width is None or height is None:
raise ValueError("Calibration JSON is missing image_width/image_height.")
return int(width), int(height)
def compute_vanishing_point(calib: dict) -> Tuple[float, float]:
vx = calib["cu"] + calib["focal_u"] * math.tan(math.radians(calib.get("yaw", 0.0)))
vy = calib["cv"] - calib["focal_v"] * math.tan(math.radians(calib.get("pitch", 0.0)))
return vx, vy
def iter_images(images_dir: Path, glob_pattern: str) -> Iterable[Path]:
if not images_dir.is_dir():
raise FileNotFoundError(f"Images directory not found: {images_dir}")
for path in sorted(images_dir.glob(glob_pattern)):
if path.is_file():
yield path
def compute_simul_calib(
calib_params: dict,
ori_img_size: Tuple[int, int],
target_size: Tuple[int, int],
crop_center_x: float,
crop_center_y: float,
target_fx: float,
) -> dict:
fx_orig = calib_params["focal_u"]
fy_orig = calib_params["focal_v"]
cx_orig = calib_params["cu"]
cy_orig = calib_params["cv"]
distort_coeffs = calib_params.get("distort_coeffs", [])
ori_w, ori_h = ori_img_size
target_w, target_h = target_size
k_orig = np.array(
[[fx_orig, 0.0, cx_orig], [0.0, fy_orig, cy_orig], [0.0, 0.0, 1.0]],
dtype=np.float64,
)
d = np.array(distort_coeffs[:4], dtype=np.float64) if len(distort_coeffs) >= 4 else np.zeros(4, dtype=np.float64)
k_new = cv2.fisheye.estimateNewCameraMatrixForUndistortRectify(
k_orig,
d,
(ori_w, ori_h),
np.eye(3),
balance=0.0,
)
fx_undistorted = k_new[0, 0]
fy_undistorted = k_new[1, 1]
cx_undistorted = k_new[0, 2]
cy_undistorted = k_new[1, 2]
dist_point = np.array([[[crop_center_x, crop_center_y]]], dtype=np.float32)
undist_point = cv2.fisheye.undistortPoints(dist_point, k_orig, d, P=k_new)
center_x_undist = float(undist_point[0, 0, 0])
center_y_undist = float(undist_point[0, 0, 1])
max_w = min(center_x_undist * 2.0, (ori_w - center_x_undist) * 2.0)
max_h = min(center_y_undist * 2.0, (ori_h - center_y_undist) * 2.0)
gcd = math.gcd(target_w, target_h)
ratio_w = target_w // gcd
ratio_h = target_h // gcd
k_from_w = int(max_w / ratio_w)
k_from_h = int(max_h / ratio_h)
k_max = min(k_from_w, k_from_h)
if k_max <= 0:
raise ValueError(
f"Unable to find a valid crop for target size {target_size} around center "
f"({crop_center_x:.2f}, {crop_center_y:.2f})."
)
# Unlike the dataloader training path, this offline export tool should also
# work when the valid no-black-margin undistorted crop is smaller than the
# requested output size. In that case we keep the largest valid crop and
# upscale it back to the target size instead of failing.
k = k_max
crop_w = k * ratio_w
crop_h = k * ratio_h
crop_x1 = int(center_x_undist - crop_w / 2)
crop_y1 = int(center_y_undist - crop_h / 2)
crop_x2 = crop_x1 + crop_w
crop_y2 = crop_y1 + crop_h
scale_x = target_w / crop_w
scale_y = target_h / crop_h
if not np.isclose(scale_x, scale_y):
raise ValueError(f"Non-uniform resize is not supported: scale_x={scale_x}, scale_y={scale_y}")
scaled_fx = scale_x * fx_undistorted
scaled_fy = scale_y * fy_undistorted
scaled_cx = (cx_undistorted - crop_x1) * scale_x
scaled_cy = (cy_undistorted - crop_y1) * scale_y
return {
"fx": float(scaled_fx),
"fy": float(scaled_fy),
"cx": float(scaled_cx),
"cy": float(scaled_cy),
"distort_coeffs": [],
"depth_scale": float(scaled_fx / target_fx),
"crop_bounds": (int(crop_x1), int(crop_y1), int(crop_x2), int(crop_y2)),
"scale": (float(scale_x), float(scale_y)),
"K_undistorted": k_new,
"crop_center_undistorted": (center_x_undist, center_y_undist),
"fx_to_target_scale": float(target_fx / scaled_fx),
}
def apply_simul_transform(
img: np.ndarray,
simul_calib: dict,
calib_params: dict,
target_size: Tuple[int, int],
) -> np.ndarray:
fx = calib_params["focal_u"]
fy = calib_params["focal_v"]
cx = calib_params["cu"]
cy = calib_params["cv"]
distort_coeffs = calib_params.get("distort_coeffs", [])
k_orig = np.array(
[[fx, 0.0, cx], [0.0, fy, cy], [0.0, 0.0, 1.0]],
dtype=np.float64,
)
d = np.array(distort_coeffs[:4], dtype=np.float64) if len(distort_coeffs) >= 4 else np.zeros(4, dtype=np.float64)
k_new = simul_calib["K_undistorted"]
img_undistorted = cv2.fisheye.undistortImage(img, k_orig, d, Knew=k_new)
crop_x1, crop_y1, crop_x2, crop_y2 = simul_calib["crop_bounds"]
img_cropped = img_undistorted[crop_y1:crop_y2, crop_x1:crop_x2]
return cv2.resize(img_cropped, target_size, interpolation=cv2.INTER_LINEAR)
def build_output_calib(calib: dict, simul_calib: dict, target_size: Tuple[int, int]) -> dict:
target_w, target_h = target_size
output_calib = dict(calib)
output_calib["focal_u"] = simul_calib["fx"]
output_calib["focal_v"] = simul_calib["fy"]
output_calib["cu"] = simul_calib["cx"]
output_calib["cv"] = simul_calib["cy"]
output_calib["distort_coeffs"] = []
output_calib["image_width"] = int(target_w)
output_calib["image_height"] = int(target_h)
output_calib["img_width"] = int(target_w)
output_calib["img_height"] = int(target_h)
output_calib["simul_transform"] = {
"crop_bounds": list(simul_calib["crop_bounds"]),
"scale": list(simul_calib["scale"]),
"depth_scale": simul_calib["depth_scale"],
"fx_to_target_scale": simul_calib["fx_to_target_scale"],
}
return output_calib
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Undistort decoded PDCL clip images using the same virtual-calibration logic as dataloaders3d.py."
)
parser.add_argument("--case-dir", type=str, default="", help="Case directory containing images/ and calib/L2_calib/camera4.json")
parser.add_argument("--images-dir", type=str, default="", help="Input image directory. Used when --case-dir is not set.")
parser.add_argument("--calib-file", type=str, default="", help="Input camera4.json path. Used when --case-dir is not set.")
parser.add_argument("--output-dir", type=str, required=True, help="Output directory for undistorted data.")
parser.add_argument("--glob", type=str, default="*.png", help="Image glob pattern inside images-dir. Default: *.png")
parser.add_argument("--target-size", nargs=2, type=int, default=None, metavar=("W", "H"), help="Output image size. Default: use source calibration size.")
parser.add_argument("--crop-center-x", type=float, default=None, help="Crop center x in distorted image. Default: image center.")
parser.add_argument("--crop-center-y", type=float, default=None, help="Crop center y in distorted image. Default: vanishing point y.")
parser.add_argument("--target-fx", type=float, default=None, help="Reference target focal length. Default: use source focal_u.")
parser.add_argument("--overwrite", action="store_true", help="Overwrite existing output images.")
return parser.parse_args()
def resolve_inputs(args: argparse.Namespace) -> Tuple[Path, Path]:
if args.case_dir:
case_dir = Path(args.case_dir).resolve()
images_dir = case_dir / "images"
calib_path = case_dir / "calib" / "L2_calib" / "camera4.json"
return images_dir, calib_path
if not args.images_dir or not args.calib_file:
raise ValueError("Either --case-dir or both --images-dir and --calib-file are required.")
return Path(args.images_dir).resolve(), Path(args.calib_file).resolve()
def main() -> int:
args = parse_args()
images_dir, calib_path = resolve_inputs(args)
output_dir = Path(args.output_dir).resolve()
output_images_dir = output_dir / "images"
output_calib_dir = output_dir / "calib" / "L2_calib"
output_images_dir.mkdir(parents=True, exist_ok=True)
output_calib_dir.mkdir(parents=True, exist_ok=True)
calib = load_calib(calib_path)
ori_img_size = infer_image_size(calib)
target_size = tuple(args.target_size) if args.target_size else ori_img_size
vp_x, vp_y = compute_vanishing_point(calib)
crop_center_x = args.crop_center_x if args.crop_center_x is not None else ori_img_size[0] / 2.0
crop_center_y = args.crop_center_y if args.crop_center_y is not None else vp_y
target_fx = args.target_fx if args.target_fx is not None else calib["focal_u"]
simul_calib = compute_simul_calib(
calib_params=calib,
ori_img_size=ori_img_size,
target_size=target_size,
crop_center_x=crop_center_x,
crop_center_y=crop_center_y,
target_fx=target_fx,
)
output_calib = build_output_calib(calib, simul_calib, target_size)
image_paths = list(iter_images(images_dir, args.glob))
if not image_paths:
raise FileNotFoundError(f"No images matched {args.glob} in {images_dir}")
print(f"Input images : {images_dir}")
print(f"Input calib : {calib_path}")
print(f"Output dir : {output_dir}")
print(f"Image count : {len(image_paths)}")
print(f"Target size : {target_size[0]}x{target_size[1]}")
print(f"Crop center : ({crop_center_x:.2f}, {crop_center_y:.2f})")
print(f"Crop bounds : {simul_calib['crop_bounds']}")
print(f"Resize scale : ({simul_calib['scale'][0]:.6f}, {simul_calib['scale'][1]:.6f})")
print(f"Output fx/fy : ({simul_calib['fx']:.4f}, {simul_calib['fy']:.4f})")
print(f"Output cx/cy : ({simul_calib['cx']:.4f}, {simul_calib['cy']:.4f})")
ok_count = 0
for idx, image_path in enumerate(image_paths, start=1):
out_path = output_images_dir / image_path.name
if out_path.exists() and not args.overwrite:
print(f"[{idx}/{len(image_paths)}] SKIP exists: {out_path}")
continue
image = cv2.imread(str(image_path), cv2.IMREAD_COLOR)
if image is None:
print(f"[{idx}/{len(image_paths)}] SKIP unreadable: {image_path}")
continue
transformed = apply_simul_transform(
img=image,
simul_calib=simul_calib,
calib_params=calib,
target_size=target_size,
)
if not cv2.imwrite(str(out_path), transformed):
print(f"[{idx}/{len(image_paths)}] FAIL write: {out_path}")
continue
ok_count += 1
print(f"[{idx}/{len(image_paths)}] OK -> {out_path}")
output_calib_path = output_calib_dir / "camera4.json"
with output_calib_path.open("w", encoding="utf-8") as file:
json.dump(output_calib, file, indent=2, ensure_ascii=False)
manifest = {
"input_images": str(images_dir),
"input_calib": str(calib_path),
"output_images": str(output_images_dir),
"output_calib": str(output_calib_path),
"source_image_size": list(ori_img_size),
"target_size": list(target_size),
"source_vanishing_point": [vp_x, vp_y],
"crop_center": [crop_center_x, crop_center_y],
"crop_bounds": list(simul_calib["crop_bounds"]),
"scale": list(simul_calib["scale"]),
"output_intrinsics": {
"focal_u": simul_calib["fx"],
"focal_v": simul_calib["fy"],
"cu": simul_calib["cx"],
"cv": simul_calib["cy"],
},
"processed_images": ok_count,
"total_images": len(image_paths),
}
manifest_path = output_dir / "undistort_manifest.json"
with manifest_path.open("w", encoding="utf-8") as file:
json.dump(manifest, file, indent=2, ensure_ascii=False)
print(f"Completed: {ok_count}/{len(image_paths)} image(s) written.")
print(f"Calib : {output_calib_path}")
print(f"Manifest : {manifest_path}")
return 0 if ok_count > 0 else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,74 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
PYTHON_BIN="${PYTHON_BIN:-python}"
# Launcher for undistorting decoded PDCL clip data.
#
# Usage:
# bash tools/pdcl_inference/undistort_decoded_clip_data.sh
#
# Preferred env overrides:
# CASE_DIR, OUTPUT_DIR
#
# Optional env overrides:
# IMAGES_DIR, CALIB_FILE, GLOB, TARGET_SIZE_W, TARGET_SIZE_H,
# CROP_CENTER_X, CROP_CENTER_Y, TARGET_FX, EXTRA_ARGS
CASE_DIR="${CASE_DIR:-}"
# OUTPUT_DIR="${OUTPUT_DIR:-${CASE_DIR}_undist}"
IMAGES_DIR="${IMAGES_DIR:-/data1/dongying/Mono3d/G1M3/cases_coding/249/2025-09-09_11-49-49_voice_经过旁车是大车误制动/sigmastar.1/images_1616}"
CALIB_FILE="${CALIB_FILE:-/data1/dongying/Mono3d/G1M3/cases_coding/249/2025-09-09_11-49-49_voice_经过旁车是大车误制动/sigmastar.1/calibs/camera4.json}"
GLOB="${GLOB:-*.jpg}"
TARGET_SIZE_W="${TARGET_SIZE_W:-}"
TARGET_SIZE_H="${TARGET_SIZE_H:-}"
CROP_CENTER_X="${CROP_CENTER_X:-}"
CROP_CENTER_Y="${CROP_CENTER_Y:-}"
TARGET_FX="${TARGET_FX:-}"
EXTRA_ARGS="${EXTRA_ARGS:-}"
OUTPUT_DIR="${OUTPUT_DIR:-${IMAGES_DIR}_undist}"
CMD=(
"${PYTHON_BIN}" "${PROJECT_ROOT}/tools/pdcl_inference/undistort_decoded_clip_data.py"
--output-dir "${OUTPUT_DIR}"
--glob "${GLOB}"
--overwrite
)
if [[ -n "${CASE_DIR}" ]]; then
CMD+=(--case-dir "${CASE_DIR}")
else
if [[ -z "${IMAGES_DIR}" || -z "${CALIB_FILE}" ]]; then
echo "Error: set CASE_DIR, or set both IMAGES_DIR and CALIB_FILE."
exit 1
fi
CMD+=(--images-dir "${IMAGES_DIR}" --calib-file "${CALIB_FILE}")
fi
if [[ -n "${TARGET_SIZE_W}" && -n "${TARGET_SIZE_H}" ]]; then
CMD+=(--target-size "${TARGET_SIZE_W}" "${TARGET_SIZE_H}")
fi
if [[ -n "${CROP_CENTER_X}" ]]; then
CMD+=(--crop-center-x "${CROP_CENTER_X}")
fi
if [[ -n "${CROP_CENTER_Y}" ]]; then
CMD+=(--crop-center-y "${CROP_CENTER_Y}")
fi
if [[ -n "${TARGET_FX}" ]]; then
CMD+=(--target-fx "${TARGET_FX}")
fi
if [[ -n "${EXTRA_ARGS}" ]]; then
# shellcheck disable=SC2206
EXTRA_ARR=(${EXTRA_ARGS})
CMD+=("${EXTRA_ARR[@]}")
fi
"${CMD[@]}"