单目3D初始代码
This commit is contained in:
1
tools/pdcl_inference/__init__.py
Executable file
1
tools/pdcl_inference/__init__.py
Executable file
@@ -0,0 +1 @@
|
||||
"""Batch PDCL inference package."""
|
||||
8009
tools/pdcl_inference/analyze_val_two_roi_badcases.py
Executable file
8009
tools/pdcl_inference/analyze_val_two_roi_badcases.py
Executable file
File diff suppressed because it is too large
Load Diff
5
tools/pdcl_inference/analyze_val_two_roi_badcases.sh
Executable file
5
tools/pdcl_inference/analyze_val_two_roi_badcases.sh
Executable 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
|
||||
6263
tools/pdcl_inference/analyze_val_two_roi_badcases_bak.py
Executable file
6263
tools/pdcl_inference/analyze_val_two_roi_badcases_bak.py
Executable file
File diff suppressed because it is too large
Load Diff
498
tools/pdcl_inference/export_mcap_frames_by_clip_id.py
Executable file
498
tools/pdcl_inference/export_mcap_frames_by_clip_id.py
Executable 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()
|
||||
29
tools/pdcl_inference/export_mcap_frames_by_clip_id.sh
Executable file
29
tools/pdcl_inference/export_mcap_frames_by_clip_id.sh
Executable 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}
|
||||
695
tools/pdcl_inference/extract_data_from_pdcl.py
Executable file
695
tools/pdcl_inference/extract_data_from_pdcl.py
Executable 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):
|
||||
"""
|
||||
将 YUVJ420P(3个平面)转换为 NV12(2个平面)格式。
|
||||
|
||||
参数:
|
||||
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
|
||||
|
||||
|
||||
658
tools/pdcl_inference/extract_gt_by_group_id.py
Executable file
658
tools/pdcl_inference/extract_gt_by_group_id.py
Executable 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()
|
||||
64
tools/pdcl_inference/extract_gt_by_group_id.sh
Executable file
64
tools/pdcl_inference/extract_gt_by_group_id.sh
Executable 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}"
|
||||
6
tools/pdcl_inference/extract_gt_by_group_id_case.sh
Executable file
6
tools/pdcl_inference/extract_gt_by_group_id_case.sh
Executable 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"
|
||||
957
tools/pdcl_inference/file_pred_bundle.py
Executable file
957
tools/pdcl_inference/file_pred_bundle.py
Executable 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 (0–1 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"
|
||||
22
tools/pdcl_inference/get_clip_by_eventid.py
Executable file
22
tools/pdcl_inference/get_clip_by_eventid.py
Executable 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}")
|
||||
486
tools/pdcl_inference/get_clips_of_aeb.py
Executable file
486
tools/pdcl_inference/get_clips_of_aeb.py
Executable 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()
|
||||
59
tools/pdcl_inference/pdcl_clip_service.py
Executable file
59
tools/pdcl_inference/pdcl_clip_service.py
Executable 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)
|
||||
56
tools/pdcl_inference/pipeline_types.py
Executable file
56
tools/pdcl_inference/pipeline_types.py
Executable 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
|
||||
265
tools/pdcl_inference/read_calib_from_clips.py
Executable file
265
tools/pdcl_inference/read_calib_from_clips.py
Executable 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()
|
||||
1087
tools/pdcl_inference/run_batch_two_roi_infer.py
Executable file
1087
tools/pdcl_inference/run_batch_two_roi_infer.py
Executable file
File diff suppressed because it is too large
Load Diff
75
tools/pdcl_inference/run_batch_two_roi_infer.sh
Executable file
75
tools/pdcl_inference/run_batch_two_roi_infer.sh
Executable 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
|
||||
181
tools/pdcl_inference/run_latest_two_aeb_rawids.py
Executable file
181
tools/pdcl_inference/run_latest_two_aeb_rawids.py
Executable 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()
|
||||
68
tools/pdcl_inference/run_two_roi_infer.py
Executable file
68
tools/pdcl_inference/run_two_roi_infer.py
Executable 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()
|
||||
692
tools/pdcl_inference/sample_confusion_background_misses.py
Executable file
692
tools/pdcl_inference/sample_confusion_background_misses.py
Executable 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()
|
||||
321
tools/pdcl_inference/stat_aeb_clips_by_scene.py
Executable file
321
tools/pdcl_inference/stat_aeb_clips_by_scene.py
Executable 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()
|
||||
57
tools/pdcl_inference/status_store.py
Executable file
57
tools/pdcl_inference/status_store.py
Executable 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))
|
||||
1573
tools/pdcl_inference/two_roi_inference.py
Executable file
1573
tools/pdcl_inference/two_roi_inference.py
Executable file
File diff suppressed because it is too large
Load Diff
316
tools/pdcl_inference/undistort_decoded_clip_data.py
Executable file
316
tools/pdcl_inference/undistort_decoded_clip_data.py
Executable 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())
|
||||
74
tools/pdcl_inference/undistort_decoded_clip_data.sh
Executable file
74
tools/pdcl_inference/undistort_decoded_clip_data.sh
Executable 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[@]}"
|
||||
Reference in New Issue
Block a user