# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license """Ground 3D detection data utilities. Functions for on-the-fly label parsing, calibration reading, and virtual camera augmentation for joint 2D+3D ground detection training. """ import json import os from functools import lru_cache from pathlib import Path import cv2 import numpy as np def parse_ground_3d_label_file(lb_file, class_map, difficulty_weights, face_3d_classes, complete_3d_classes, min_wh=2.0): """Parse a ground 3D label file on-the-fly. Returns 2D labels dict + 3D array. Reads label files with variable column counts: - 6-col: [class_name, x, y, w, h, difficulty] — 2D only - 19-col: [class_name, x, y, w, h, ...3D(13cols)..., difficulty] — complete 3D (no face) - 51-col: [class_name, x, y, w, h, ...3D+faces(45cols)..., difficulty] — face 3D The 48-dim internal format per object: [0]: class_id, [1-4]: 2D bbox (xywh norm) [5-7]: 3D center (x3d, y3d, z3d), [8-10]: dims (l, h, w) [11]: rot_y, [12-13]: 3D box center projection (xc, yc), [14]: alpha [15-22]: front face, [23-30]: rear face, [31-38]: left face, [39-46]: right face [47]: difficulty level Args: lb_file (str): Path to label file. class_map (dict): Mapping from class names to class IDs. difficulty_weights (list): Loss weights for difficulty levels [easy, normal, medium, hard]. face_3d_classes (set): Class IDs with 4-face annotations (51-col). complete_3d_classes (set): Class IDs with whole-box 3D only (19-col). min_wh (float): Minimum box width/height in normalized coords for filtering. Returns: lb_2d (dict): Dict with cls (n,1), bboxes (n,4), difficulties (n,1), segments, keypoints, normalized, bbox_format. lb_3d (np.ndarray): 3D portion shape (n, 42). Objects without 3D GT keep NaN values; images without labels return an empty `(0, 42)` array. """ empty_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), "difficulty_levels": np.zeros((0, 1), dtype=np.int64), "segments": [], "keypoints": None, "normalized": True, "bbox_format": "xywh", } empty_3d = np.full((0, 42), np.nan, dtype=np.float32) if not os.path.isfile(lb_file): return empty_2d, empty_3d # Read file once with open(lb_file, encoding="utf-8") as f: lines = f.read().strip().splitlines() if not lines: return empty_2d, empty_3d labels = [] # Cache method lookups to avoid repeated attribute access class_map_get = class_map.get face_3d_classes_contains = face_3d_classes.__contains__ complete_3d_classes_contains = complete_3d_classes.__contains__ for line in lines: parts = line.split() # split() without strip() is faster if not parts: continue cls_name = parts[0] cls_id = class_map_get(cls_name) if cls_id is None: continue # skip unknown classes ncols = len(parts) if ncols == 6: # 2D only: [class, x, y, w, h, difficulty] temp = np.full(48, np.nan, dtype=np.float32) temp[0] = cls_id temp[1:5] = [float(parts[i]) for i in range(1, 5)] temp[47] = float(parts[5]) labels.append(temp) elif ncols == 19 and complete_3d_classes_contains(cls_id): # Complete 3D without face: 19 cols, difficulty at index 18 # Use indices 1-13 + 16 (skip 14,15 which are unused), map to temp[1:15] temp = np.full(48, np.nan, dtype=np.float32) temp[0] = cls_id useful_indices = list(range(1, 14)) + [16] temp[1:15] = [float(parts[i]) for i in useful_indices] temp[47] = float(parts[18]) labels.append(temp) elif ncols == 51 and face_3d_classes_contains(cls_id): # Face 3D: 51 cols, difficulty at index 50 # [cls_id] + indices 1-13 + 16 → temp[0:15], then indices 18-49 → temp[15:47], difficulty → temp[47] temp_list = [cls_id] + [float(parts[i]) for i in (list(range(1, 14)) + [16])] temp_list.extend(float(parts[i]) for i in range(18, 50)) temp_list.append(float(parts[50])) # difficulty labels.append(np.array(temp_list, dtype=np.float32)) elif ncols == 7: # 2D with two difficulty columns: [class, x, y, w, h, diff1, diff2] # In joint 2D&3D training, diff2 is truncation, so use only diff1 for difficulty supervision. temp = np.full(48, np.nan, dtype=np.float32) temp[0] = cls_id temp[1:5] = [float(parts[i]) for i in range(1, 5)] temp[47] = float(parts[5]) labels.append(temp) else: raise ValueError(f"Unexpected number of columns ({ncols}) in label file {lb_file}: '{line}'") if not labels: return empty_2d, empty_3d lb = np.stack(labels, axis=0) # (n, 48) nl = len(lb) # Validate if nl > 0: assert lb.shape[1] == 48, f"labels require 48 columns, got {lb.shape[1]}" # Remove duplicates _, idx = np.unique(lb, axis=0, return_index=True) if len(idx) < nl: lb = lb[idx] if len(lb) == 0: return empty_2d, empty_3d # Split into 2D and 3D portions cls = lb[:, 0:1] # (n, 1) bboxes = lb[:, 1:5] # (n, 4) xywh normalized # Difficulty → loss weights dw = difficulty_weights raw_diff = lb[:, 47].astype(int).clip(0, len(dw) - 1) difficulties = np.array([dw[d] for d in raw_diff], dtype=np.float32).reshape(-1, 1) difficulty_levels = raw_diff.astype(np.int64).reshape(-1, 1) # 3D portion: columns 5-46 (42 dims) # [x3d, y3d, z3d, l, h, w, rot_y, xc, yc, alpha, front(8), rear(8), left(8), right(8)] labels_3d = lb[:, 5:47] # (n, 42) lb_2d = { "cls": cls, "bboxes": bboxes, "difficulties": difficulties, "difficulty_levels": difficulty_levels, "segments": [], "keypoints": None, "normalized": True, "bbox_format": "xywh", } return lb_2d, labels_3d @lru_cache(maxsize=256) def _read_label_root_camera4_cached(calib_path_str): """Cached helper to read a label-root clip-level camera4.json file.""" import math with open(calib_path_str, encoding="utf-8") as f: payload = json.load(f) required = ("focal_u", "focal_v", "cu", "cv") if any(key not in payload for key in required): return None calib = dict(payload) calib["focal_u"] = float(payload["focal_u"]) calib["focal_v"] = float(payload["focal_v"]) calib["cu"] = float(payload["cu"]) calib["cv"] = float(payload["cv"]) calib["distort_coeffs"] = list(payload.get("distort_coeffs", [])) if "pitch" in payload: calib["pitch"] = math.radians(float(payload["pitch"])) for angle_key in ("roll", "yaw"): if angle_key in payload: calib[angle_key] = math.radians(float(payload[angle_key])) return calib def read_calib_from_path(img_path, image_root=None, extra_calib_candidates=None): """Read clip-level camera4.json from the label-root calibration folder. Args: img_path (str): Path to the image file. image_root (str | Path | None): Unused compatibility arg kept for existing call sites. extra_calib_candidates (Iterable[str | Path] | None): Label-root per-frame calibration candidates. The loader resolves each candidate's sibling `L2_calib/camera4.json` and ignores all other layouts. Returns: dict | None: Calibration dict with keys: focal_u, focal_v, cu, cv, pitch (radians), distort_coeffs. Returns None if calibration file not found. """ _ = img_path, image_root for candidate in extra_calib_candidates or (): candidate = Path(candidate).resolve() camera4_path = candidate if candidate.name == "camera4.json" else candidate.parent / "L2_calib" / "camera4.json" if camera4_path.exists(): return _read_label_root_camera4_cached(str(camera4_path)) return None def compute_vanishing_point_x(raw_calib, ori_w): """Compute vanishing point X from calibration.""" if raw_calib is None: return ori_w / 2 return raw_calib.get("cu", ori_w / 2) def compute_vanishing_point_y(raw_calib, ori_h): """Compute vanishing point Y from calibration.""" if raw_calib is None: return ori_h / 2 cv_orig = raw_calib.get("cv", ori_h / 2) pitch = raw_calib.get("pitch", 0.0) focal_v = raw_calib.get("focal_v", ori_h) return cv_orig - focal_v * np.tan(pitch) if pitch != 0 else cv_orig def compute_centered_roi_bounds(ori_w, ori_h, roi_w, roi_h, center_x, center_y): """Compute ROI bounds centered on the requested crop center.""" crop_x1 = int(max(0, min(center_x - roi_w / 2, ori_w - roi_w))) crop_y1 = int(max(0, min(center_y - roi_h / 2, ori_h - roi_h))) return crop_x1, crop_y1, crop_x1 + roi_w, crop_y1 + roi_h def adjust_calib_for_roi_crop(raw_calib, ori_w, ori_h, crop_bounds=None): """Shift intrinsics into ROI crop coordinates before resize.""" crop_x1, crop_y1, crop_x2, crop_y2 = crop_bounds or (0, 0, ori_w, ori_h) cu = raw_calib.get("cu", ori_w / 2) if raw_calib else ori_w / 2 cv = raw_calib.get("cv", ori_h / 2) if raw_calib else ori_h / 2 focal_u = raw_calib.get("focal_u", ori_w) if raw_calib else ori_w focal_v = raw_calib.get("focal_v", ori_h) if raw_calib else ori_h distort_coeffs = raw_calib.get("distort_coeffs", []) if raw_calib else [] return { "focal_u": focal_u, "focal_v": focal_v, "cu": cu - crop_x1, "cv": cv - crop_y1, "src_w": crop_x2 - crop_x1, "src_h": crop_y2 - crop_y1, "distort_coeffs": distort_coeffs, } def build_final_resized_calib(focal_u, focal_v, cu, cv, src_w, src_h, target_w, target_h, virtual_fx, distort_coeffs=None): """Build final calibration after ROI crop and direct resize.""" scale_x = target_w / src_w scale_y = target_h / src_h fx_final = focal_u * scale_x return { "fx": fx_final, "fy": focal_v * scale_y, "cx": cu * scale_x, "cy": cv * scale_y, "distort_coeffs": distort_coeffs if distort_coeffs is not None else [], "depth_scale": fx_final / virtual_fx, } def pack_labels_to_48(lb_2d, lb_3d): """Pack 2D and 3D labels into the internal augmentation representation.""" bboxes = lb_2d["bboxes"] n = len(bboxes) if n == 0: return np.zeros((0, 49), dtype=np.float32) labels_48 = np.full((n, 49), np.nan, dtype=np.float32) labels_48[:, 0] = lb_2d["cls"].reshape(-1) labels_48[:, 1:5] = bboxes labels_48[:, 47] = lb_2d["difficulties"].reshape(-1) labels_48[:, 48] = lb_2d.get("difficulty_levels", np.zeros((n, 1), dtype=np.int64)).reshape(-1) if lb_3d is not None and len(lb_3d): labels_48[:, 5:47] = lb_3d return labels_48 def unpack_labels_from_48(labels_48): """Unpack the internal 48-dim representation into 2D and 3D labels.""" 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), "difficulty_levels": np.zeros((0, 1), dtype=np.int64), "segments": [], "keypoints": None, "normalized": True, "bbox_format": "xywh", } if len(labels_48) == 0: return lb_2d, None lb_2d["cls"] = labels_48[:, 0:1] lb_2d["bboxes"] = labels_48[:, 1:5] lb_2d["difficulties"] = labels_48[:, 47:48] lb_2d["difficulty_levels"] = labels_48[:, 48:49].astype(np.int64) if labels_48.shape[1] > 48 else np.zeros((len(labels_48), 1), dtype=np.int64) return lb_2d, labels_48[:, 5:47] def _handle_cut_labels_42(labels, outside_mask, still_inside_mask): """Handle cut-in/cut-out updates for ROI-remapped 42-dim labels.""" if len(labels) == 0: return partial_mask = ~(still_inside_mask | outside_mask) if not np.any(partial_mask): return rot_y = labels[partial_mask, 6] is_cut_in = (rot_y >= -np.pi) & (rot_y <= 0) partial_indices = np.where(partial_mask)[0] def _invalidate_face(face_indices, face_offset): labels[np.ix_(face_indices, np.arange(face_offset, face_offset + 6))] = -1 labels[face_indices, face_offset + 6] = 0 labels[face_indices, face_offset + 7] = 0 cut_in_idx = partial_indices[is_cut_in] if len(cut_in_idx): for face_offset in (18, 26, 34): _invalidate_face(cut_in_idx, face_offset) labels[cut_in_idx, 16] = 1 labels[cut_in_idx, 17] = 1 cut_out_idx = partial_indices[~is_cut_in] if len(cut_out_idx): for face_offset in (10, 26, 34): _invalidate_face(cut_out_idx, face_offset) labels[cut_out_idx, 24] = 1 labels[cut_out_idx, 25] = 1 def remap_labels_to_roi(lb_2d, lb_3d, ori_w, ori_h, crop_bounds): """Shift boxes and UV coordinates from original image space into ROI-normalized space.""" bboxes = lb_2d["bboxes"] if len(bboxes) == 0: return lb_2d, lb_3d crop_x1, crop_y1, crop_x2, crop_y2 = crop_bounds roi_width = crop_x2 - crop_x1 roi_height = crop_y2 - crop_y1 bboxes = bboxes.copy() x1 = (bboxes[:, 0] - bboxes[:, 2] / 2) * ori_w y1 = (bboxes[:, 1] - bboxes[:, 3] / 2) * ori_h x2 = (bboxes[:, 0] + bboxes[:, 2] / 2) * ori_w y2 = (bboxes[:, 1] + bboxes[:, 3] / 2) * ori_h x1_roi = x1 - crop_x1 y1_roi = y1 - crop_y1 x2_roi = x2 - crop_x1 y2_roi = y2 - crop_y1 still_inside = (x1_roi >= 0) & (y1_roi >= 0) & (x2_roi < roi_width) & (y2_roi < roi_height) outside = ( ((x1_roi < 0) & (x2_roi < 0)) | ((x1_roi >= roi_width) & (x2_roi >= roi_width)) | ((y1_roi < 0) & (y2_roi < 0)) | ((y1_roi >= roi_height) & (y2_roi >= roi_height)) ) if lb_3d is not None and len(lb_3d) > 0: lb_3d = lb_3d.copy() _handle_cut_labels_42(lb_3d, outside, still_inside) x1_roi = np.clip(x1_roi, 0, roi_width - 1) y1_roi = np.clip(y1_roi, 0, roi_height - 1) x2_roi = np.clip(x2_roi, 0, roi_width - 1) y2_roi = np.clip(y2_roi, 0, roi_height - 1) bboxes[:, 0] = (x1_roi + x2_roi) * 0.5 / roi_width bboxes[:, 1] = (y1_roi + y2_roi) * 0.5 / roi_height bboxes[:, 2] = (x2_roi - x1_roi) / roi_width bboxes[:, 3] = (y2_roi - y1_roi) / roi_height keep = ~outside lb_2d = { **lb_2d, "bboxes": bboxes[keep], "cls": lb_2d["cls"][keep], "difficulties": lb_2d["difficulties"][keep], "difficulty_levels": lb_2d["difficulty_levels"][keep], } if lb_3d is not None and len(lb_3d) > 0: lb_3d = lb_3d[keep] for xi, yi in [(7, 8), (14, 15), (22, 23), (30, 31), (38, 39)]: valid = ~np.isnan(lb_3d[:, xi]) & (lb_3d[:, xi] != -1) if np.any(valid): lb_3d[valid, xi] = (lb_3d[valid, xi] * ori_w - crop_x1) / roi_width lb_3d[valid, yi] = (lb_3d[valid, yi] * ori_h - crop_y1) / roi_height return lb_2d, lb_3d def normalize_roi_depth(lb_3d, fx_final, virtual_fx): """Normalize ROI z3d targets to the canonical virtual focal length.""" if lb_3d is None or len(lb_3d) == 0: return lb_3d lb_3d = lb_3d.copy() z3d_scale = virtual_fx / fx_final mask = ~np.isnan(lb_3d[:, 2]) & (lb_3d[:, 2] > 0) lb_3d[mask, 2] *= z3d_scale for col in [12, 20, 28, 36]: mask = ~np.isnan(lb_3d[:, col]) & (lb_3d[:, col] != -1.0) & (lb_3d[:, col] > 0) lb_3d[mask, col] *= z3d_scale return lb_3d def compute_simul_calib(calib_params, ori_img_size, target_size, crop_center_x, crop_center_y, target_fx, augment=False): """Compute virtual camera calibration parameters from original fisheye calibration. Uses OpenCV to compute optimal new camera matrix after undistortion (no black margins), then crops a region while maintaining target aspect ratio. Port from yolov5-3d/utils/dataloaders3d_ground.py:698-824. Args: calib_params (dict): Original calibration with focal_u, focal_v, cu, cv, distort_coeffs. ori_img_size (tuple): Original image size (width, height). target_size (tuple): Target size (width, height) e.g., (960, 480). crop_center_x (float): Crop center X in distorted image (typically image center). crop_center_y (float): Crop center Y in distorted image (typically vanishing point Y). target_fx (float): Target focal length x for virtual camera. augment (bool): If True, randomly select crop size between min and max. Returns: dict: Virtual camera calibration with fx, fy, cx, cy, crop_bounds, scale, K_undistorted, fx_to_target_scale. """ import math import random 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, cx_orig], [0, fy_orig, cy_orig], [0, 0, 1]], dtype=np.float64) D = np.array(distort_coeffs[:4], dtype=np.float64) if len(distort_coeffs) >= 4 else np.zeros(4, dtype=np.float64) # Optimal new camera matrix (no black margins) K_new = cv2.fisheye.estimateNewCameraMatrixForUndistortRectify(K_orig, D, (ori_w, ori_h), np.eye(3), balance=0.0) fx_undist = K_new[0, 0] fy_undist = K_new[1, 1] cx_undist = K_new[0, 2] cy_undist = K_new[1, 2] # Undistort crop center point 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) cx_undist_crop = undist_point[0, 0, 0] cy_undist_crop = undist_point[0, 0, 1] # Max crop dimensions centered on undistorted crop center max_w = min(cx_undist_crop * 2, (ori_w - cx_undist_crop) * 2) max_h = min(cy_undist_crop * 2, (ori_h - cy_undist_crop) * 2) # GCD approach for exact aspect ratio with integer coordinates 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) k_min = max(target_w // ratio_w, target_h // ratio_h) if augment and k_max > k_min: k = random.randint(k_min, k_max) else: k = k_max crop_w = k * ratio_w crop_h = k * ratio_h crop_x1 = int(cx_undist_crop - crop_w / 2) crop_y1 = int(cy_undist_crop - 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 scaled_fx = scale_x * fx_undist fx_to_target_scale = target_fx / scaled_fx return { "fx": scaled_fx, "fy": scale_y * fy_undist, "cx": (cx_undist - crop_x1) * scale_x, "cy": (cy_undist - crop_y1) * scale_y, "distort_coeffs": [], "depth_scale": scaled_fx / target_fx, "crop_bounds": (crop_x1, crop_y1, crop_x2, crop_y2), "scale": (scale_x, scale_y), "K_undistorted": K_new, "K_orig": K_orig, "D": D, "fx_to_target_scale": fx_to_target_scale, } def apply_simul_transform(img, labels_48, simul_calib, calib_params, target_size, augment=False): """Apply fisheye undistortion + crop + resize to image and 48-dim labels. Port from yolov5-3d/utils/dataloaders3d_ground.py:826-1039. Args: img (np.ndarray): Input image (H, W, 3) BGR — distorted fisheye image. labels_48 (np.ndarray): Label array (N, 48) in 48-dim format. simul_calib (dict): Pre-computed virtual camera calibration from compute_simul_calib(). calib_params (dict): Original calibration dict. target_size (tuple): Target size (width, height). augment (bool): If True, use random interpolation for resize. Returns: img_transformed (np.ndarray): Transformed image. labels_transformed (np.ndarray): Transformed labels (M, 48), M <= N. """ import random h_orig, w_orig = img.shape[:2] target_w, target_h = target_size K_orig = simul_calib["K_orig"] D = simul_calib["D"] K_new = simul_calib["K_undistorted"] # Step 1: Undistort full image img_undistorted = cv2.fisheye.undistortImage(img, K_orig, D, Knew=K_new) # Step 2: Crop crop_x1, crop_y1, crop_x2, crop_y2 = simul_calib["crop_bounds"] img_cropped = img_undistorted[crop_y1:crop_y2, crop_x1:crop_x2] # Step 3: Resize if augment: interp = random.choice([cv2.INTER_NEAREST, cv2.INTER_LINEAR, cv2.INTER_AREA, cv2.INTER_CUBIC, cv2.INTER_LANCZOS4]) else: interp = cv2.INTER_LINEAR img_transformed = cv2.resize(img_cropped, target_size, interpolation=interp) # Step 4: Transform labels labels_transformed = labels_48.copy() if len(labels_transformed) == 0: return img_transformed, labels_transformed scale_x, scale_y = simul_calib["scale"] fx_to_target_scale = simul_calib["fx_to_target_scale"] # Collect all 2D points to undistort in batch all_points = [] point_map = [] # (label_idx, field_type, col_indices) for i in range(len(labels_transformed)): # Bbox corners from xywhn xc = labels_transformed[i, 1] * w_orig yc = labels_transformed[i, 2] * h_orig bw = labels_transformed[i, 3] * w_orig bh = labels_transformed[i, 4] * h_orig x1, y1 = xc - bw / 2, yc - bh / 2 x2, y2 = xc + bw / 2, yc + bh / 2 all_points.extend([[x1, y1], [x2, y2]]) point_map.extend([(i, "bbox_tl"), (i, "bbox_br")]) # Whole box UV (dims 12-13 in 48-dim = xc, yc normalized) if not np.isnan(labels_transformed[i, 12]): ux = labels_transformed[i, 12] * w_orig uy = labels_transformed[i, 13] * h_orig all_points.append([ux, uy]) point_map.append((i, "whole_uv")) # Face UVs: front(19,20), rear(27,28), left(35,36), right(43,44) in 48-dim for face_name, uv_cols in [("front", (19, 20)), ("rear", (27, 28)), ("left", (35, 36)), ("right", (43, 44))]: if not np.isnan(labels_transformed[i, uv_cols[0]]) and labels_transformed[i, uv_cols[0]] != -1: fu = labels_transformed[i, uv_cols[0]] * w_orig fv = labels_transformed[i, uv_cols[1]] * h_orig all_points.append([fu, fv]) point_map.append((i, f"{face_name}_uv")) if not all_points: return img_transformed, labels_transformed # Batch undistort all points pts_dist = np.array(all_points, dtype=np.float32).reshape(-1, 1, 2) pts_undist = cv2.fisheye.undistortPoints(pts_dist, K_orig, D, P=K_new).reshape(-1, 2) # Apply crop + resize to undistorted points pts_transformed = np.zeros_like(pts_undist) pts_transformed[:, 0] = (pts_undist[:, 0] - crop_x1) * scale_x pts_transformed[:, 1] = (pts_undist[:, 1] - crop_y1) * scale_y # Write back transformed coordinates outside = np.zeros(len(labels_transformed), dtype=bool) still_inside = np.ones(len(labels_transformed), dtype=bool) for idx, (label_i, field) in enumerate(point_map): px, py = pts_transformed[idx] if field == "bbox_tl": labels_transformed[label_i, 1] = px # temp store x1 elif field == "bbox_br": x1_t = labels_transformed[label_i, 1] # Get the tl point from previous entry tl_idx = idx - 1 y1_t = pts_transformed[tl_idx, 1] # Check if fully inside before clipping if x1_t < 0 or y1_t < 0 or px > target_w or py > target_h: still_inside[label_i] = False # Clip to image bounds x1_c = np.clip(x1_t, 0, target_w) x2_c = np.clip(px, 0, target_w) y1_c = np.clip(y1_t, 0, target_h) y2_c = np.clip(py, 0, target_h) bw_new = x2_c - x1_c bh_new = y2_c - y1_c if bw_new <= 0 or bh_new <= 0: outside[label_i] = True continue # Convert back to xywhn labels_transformed[label_i, 1] = (x1_c + x2_c) / 2 / target_w labels_transformed[label_i, 2] = (y1_c + y2_c) / 2 / target_h labels_transformed[label_i, 3] = bw_new / target_w labels_transformed[label_i, 4] = bh_new / target_h elif field == "whole_uv": labels_transformed[label_i, 12] = px / target_w labels_transformed[label_i, 13] = py / target_h elif field.endswith("_uv"): face = field.split("_")[0] uv_map = {"front": (19, 20), "rear": (27, 28), "left": (35, 36), "right": (43, 44)} cols = uv_map[face] labels_transformed[label_i, cols[0]] = px / target_w labels_transformed[label_i, cols[1]] = py / target_h # Scale z3d by fx_to_target_scale labels_transformed = _scale_z3d(labels_transformed, fx_to_target_scale) # Handle partial visibility (cut-in/cut-out) _handle_cut_labels(labels_transformed, outside, still_inside) # Remove outside boxes labels_transformed = labels_transformed[~outside] return img_transformed, labels_transformed def _scale_z3d(labels, scale): """Scale z3d coordinates for depth normalization. Port from yolov5-3d/utils/dataloaders3d_ground.py:1041-1080. """ if len(labels) == 0 or scale == 1.0: return labels labels_scaled = labels.copy() # Whole z3d (dim 7 in 48-dim) labels_scaled[:, 7] *= scale # Face z3d: front(17), rear(25), left(33), right(41) # Note: only scale if not NaN and not -1 (invalid), to preserve missing/invalid indicators for col in [17, 25, 33, 41]: mask = ~np.isnan(labels_scaled[:, col]) & (labels_scaled[:, col] != -1.0) labels_scaled[mask, col] *= scale return labels_scaled def _handle_cut_labels(labels, outside_mask, still_inside_mask): """Handle partial visibility for objects partially outside the image. For objects with bbox partially outside, mark as cut-in or cut-out based on rotation angle. Cut-in (approaching, rot_y in [-pi, 0]): keep front face only. Cut-out (leaving, rot_y > 0): keep rear face only. Port from yolov5-3d/utils/dataloaders3d_ground.py:1000-1037. Args: labels (np.ndarray): Label array (N, 48) in 48-dim format. outside_mask (np.ndarray): Boolean mask (N,) — True for fully outside boxes. still_inside_mask (np.ndarray): Boolean mask (N,) — True for fully inside boxes. """ if len(labels) == 0: return partial_mask = ~(still_inside_mask | outside_mask) if not np.any(partial_mask): return rot_y = labels[partial_mask, 11] # dim11: rot_y in 48-dim is_cut_in = (rot_y >= -np.pi) & (rot_y <= 0) partial_indices = np.where(partial_mask)[0] def _invalidate_face(face_indices, face_offset): labels[np.ix_(face_indices, np.arange(face_offset, face_offset + 6))] = -1 labels[face_indices, face_offset + 6] = 0 labels[face_indices, face_offset + 7] = 0 # Cut-in: keep front face, invalidate others cut_in_idx = partial_indices[is_cut_in] if len(cut_in_idx): for face_offset in (23, 31, 39): _invalidate_face(cut_in_idx, face_offset) labels[cut_in_idx, 21] = 1 # front face score = 1 labels[cut_in_idx, 22] = 1 # Cut-out: keep rear face, invalidate others cut_out_idx = partial_indices[~is_cut_in] if len(cut_out_idx): for face_offset in (15, 31, 39): _invalidate_face(cut_out_idx, face_offset) labels[cut_out_idx, 29] = 1 # rear face score = 1 labels[cut_out_idx, 30] = 1