"""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"