#!/usr/bin/env python3 """ Comprehensive Dataset Profiling & Statistical Analysis for YOLOv5-3D. Analyze training and evaluation datasets to produce a complete "data portrait" covering: 1. Basic dataset overview (image/label counts, label format distribution) 2. Per-class object statistics (counts, proportions, 2D/3D coverage) 3. 2D bounding box analysis (size, aspect ratio, position heatmaps) 4. 3D geometry analysis (depth, dimensions, rotation distributions) 5. Face visibility & cut-type analysis (for vehicles/tricycles) 6. Per-image density analysis (objects per image, class co-occurrence) 7. Vehicle / date distribution analysis 8. Data quality checks (invalid labels, NaN fields, outliers) 9. Train vs Eval distribution comparison Usage: python dataset_profiling.py [--data data/mono3d.yaml] [--split both|train|val] [--max-files 0] [--output-dir dataset_profiling_results] [--img-size 1920 960] [--analysis-mode original|roi] --max-files 0 means process all files (default). --analysis-mode 'original' uses full image coordinates (default); 'roi' computes per-camera ROI from calibration, filters out-of-ROI objects, clips boundary objects, and normalizes to ROI space (matching training pipeline). """ import argparse import json import math import os import sys from collections import Counter, defaultdict from multiprocessing import Pool, cpu_count from pathlib import Path import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import matplotlib.gridspec as gridspec import numpy as np import yaml # ─────────────────────── Constants ─────────────────────── CLASS_NAMES = { 0: "vehicle", 1: "pedestrian", 2: "bicycle", 3: "rider", 4: "roadblock", 5: "head", 6: "tsr", 7: "guideboard", 8: "plate", 9: "wheel", 10: "tl_border", 11: "tl_wick", 12: "tl_num", 13: "tricycle", } # Classes that use face-based 3D annotation (50-col raw format → 47-dim) FACE_BASED_CLASSES = {0, 13} # vehicle, tricycle # Classes that have complete 3D annotations (18-col → 47-dim) COMPLETE_3D_CLASSES = {1, 2, 3} # pedestrian, bicycle, rider # All classes with 3D info ALL_3D_CLASSES = FACE_BASED_CLASSES | COMPLETE_3D_CLASSES FACE_NAMES = ["front", "rear", "left", "right"] # Vehicle subtype thresholds: 大车 (large vehicle) vs 小车 (small vehicle) LARGE_VEHICLE_L_THRESH = 6.0 # length > 6m → large vehicle LARGE_VEHICLE_H_THRESH = 2.0 # height > 2m → large vehicle VEH_LARGE = 100 # virtual class ID for large vehicles VEH_SMALL = 101 # virtual class ID for small vehicles CLASS_NAMES[VEH_LARGE] = "vehicle_large" CLASS_NAMES[VEH_SMALL] = "vehicle_small" # Internal 47-dim index map IDX = { "cls": 0, "x": 1, "y": 2, "w": 3, "h": 4, "x3d": 5, "y3d": 6, "z3d": 7, "length": 8, "height": 9, "width": 10, "rot_y": 11, "xc": 12, "yc": 13, "alpha": 14, # Each face: [x3d, y3d, z3d, alpha, xc, yc, score, is_visible] "face_front": 15, # 15-22 "face_rear": 23, # 23-30 "face_left": 31, # 31-38 "face_right": 39, # 39-46 } # ─────────────── Label File Parsing ───────────────────── def parse_label_file(label_path: str) -> list: """Parse a single label file into list of 47-dim numpy arrays. Returns list of (label_47dim, raw_col_count) tuples. """ results = [] try: with open(label_path, "r") as f: content = f.read() if not content: return results for raw_line in content.split('\n'): parts = raw_line.split() if not parts: continue n = len(parts) temp = np.full(47, np.nan, dtype=np.float64) if n >= 50: # 50-column: vehicle/tricycle with face annotations # Convert all values at once (C-optimized map) all_v = list(map(float, parts)) temp[0:14] = all_v[0:14] temp[14] = all_v[16] # alpha (skip cols 14, 15, 17) temp[15:47] = all_v[18:50] results.append((temp, 50)) elif n >= 18: # 18-column: pedestrian/bicycle/rider all_v = list(map(float, parts[:17])) temp[0:14] = all_v[0:14] temp[14] = all_v[16] # alpha results.append((temp, 18)) elif n >= 6: # 6-column: 2D-only k = min(n, 6) temp[:k] = list(map(float, parts[:k])) results.append((temp, 6)) else: results.append((temp, n)) except FileNotFoundError: pass except Exception: pass return results def image_path_to_label_path(img_path: str) -> str: """Convert image path to corresponding label path.""" return img_path.replace("/images/", "/labels/").replace(".png", ".txt").replace(".jpg", ".txt") # ─────────── ROI Computation & Label Processing ──────── # Global cache: sequence_dir → (roi_x1, roi_y1, roi_x2, roi_y2) _roi_cache: dict = {} def read_calib_for_image(img_path: str) -> dict: """Read calibration parameters for an image. Derives calib path as: .replace('images','calib')/L2_calib/camera4.json Returns dict with keys: focal_u, focal_v, cu, cv, pitch, yaw, distort_coeffs. Returns None if calibration file not found. """ base_path = os.path.dirname(img_path) calib_path = base_path.replace('images', 'calib') + '/L2_calib/camera4.json' if not os.path.exists(calib_path): return None try: with open(calib_path, 'r') as f: return json.load(f) except Exception: return None def compute_roi_bounds(calib_params: dict, ori_img_size: tuple, roi_size: tuple) -> tuple: """Compute ROI bounds from calibration parameters and config. Uses the vanishing point formula: vanish_y = cv - focal_v * tan(pitch * pi / 180) Then crops a region of roi_size centered at (oriW//2, vanish_y), clamped to image. Args: calib_params: dict with 'focal_v', 'cv', 'pitch' ori_img_size: (oriW, oriH) original image dimensions roi_size: (roi_w, roi_h) target ROI dimensions from config Returns: (roi_x1, roi_y1, roi_x2, roi_y2) absolute pixel coordinates, or None on error. """ try: fy = calib_params['focal_v'] cy = calib_params['cv'] pitch = calib_params['pitch'] except (KeyError, TypeError): return None oriW, oriH = ori_img_size roi_w, roi_h = roi_size vanish_y = cy - fy * math.tan(pitch * math.pi / 180.0) crop_center_x = oriW // 2 crop_center_y = vanish_y roi_x1 = int(crop_center_x - roi_w / 2.0) roi_y1 = int(crop_center_y - roi_h / 2.0) roi_x2 = roi_x1 + roi_w roi_y2 = roi_y1 + roi_h # Clamp to image bounds if roi_y1 < 0: roi_y1 = 0 roi_y2 = roi_h if roi_y2 > oriH: roi_y2 = oriH roi_y1 = oriH - roi_h if roi_x1 < 0: roi_x1 = 0 roi_x2 = roi_w if roi_x2 > oriW: roi_x2 = oriW roi_x1 = oriW - roi_w return (roi_x1, roi_y1, roi_x2, roi_y2) def get_roi_for_image(img_path: str, ori_img_size: tuple, roi_size: tuple) -> tuple: """Get ROI bounds for an image, with per-sequence caching. All images in the same directory share the same calibration → same ROI. Returns (roi_x1, roi_y1, roi_x2, roi_y2) or None. """ seq_dir = os.path.dirname(img_path) if seq_dir in _roi_cache: return _roi_cache[seq_dir] calib = read_calib_for_image(img_path) if calib is None: _roi_cache[seq_dir] = None return None bounds = compute_roi_bounds(calib, ori_img_size, roi_size) _roi_cache[seq_dir] = bounds return bounds def filter_clip_labels_to_roi(labels_47: list, ori_img_size: tuple, roi_x1: int, roi_y1: int, roi_x2: int, roi_y2: int) -> list: """Filter and clip parsed labels to ROI region. Matches the logic from dataloaders3d.post_process_labels_to_roi(): - Convert normalized 2D bbox to absolute pixels - Shift to ROI-relative coordinates - Remove fully-outside objects - Clip boundary objects (and mark as cut-in/cut-out) - Re-normalize coordinates to ROI dimensions Args: labels_47: list of (47-dim np.array, raw_col_count) tuples ori_img_size: (oriW, oriH) roi_x1, roi_y1, roi_x2, roi_y2: ROI bounds in absolute pixels Returns: Filtered/clipped list of (47-dim np.array, raw_col_count) tuples """ if not labels_47: return labels_47 oriW, oriH = ori_img_size roi_w = roi_x2 - roi_x1 roi_h = roi_y2 - roi_y1 # Stack all labels into array for vectorized processing arr = np.array([l[0] for l in labels_47]) raw_cols = [l[1] for l in labels_47] # --- 2D bbox: convert xywhn → xyxy (absolute pixels) --- xn, yn, wn, hn = arr[:, 1], arr[:, 2], arr[:, 3], arr[:, 4] abs_x1 = oriW * (xn - wn / 2.0) abs_y1 = oriH * (yn - hn / 2.0) abs_x2 = oriW * (xn + wn / 2.0) abs_y2 = oriH * (yn + hn / 2.0) # --- Shift to ROI-relative coordinates --- new_x1 = abs_x1 - roi_x1 new_y1 = abs_y1 - roi_y1 new_x2 = abs_x2 - roi_x1 new_y2 = abs_y2 - roi_y1 # --- Determine inside / outside / partial --- fully_outside = ((new_x2 <= 0) | (new_x1 >= roi_w) | (new_y2 <= 0) | (new_y1 >= roi_h)) fully_inside = ((new_x1 >= 0) & (new_y1 >= 0) & (new_x2 <= roi_w) & (new_y2 <= roi_h)) partial = ~(fully_inside | fully_outside) # --- Handle cut-in/cut-out for partial objects (face-based classes) --- if np.any(partial): partial_indices = np.where(partial)[0] rot_y = arr[partial_indices, 11] # rot_y field is_cut_in = (rot_y >= -np.pi) & (rot_y <= 0) # Face attribute indices (excluding score column for each face) head_related_idx = np.array([15, 16, 17, 18, 19, 20, 22]) rear_related_idx = np.array([23, 24, 25, 26, 27, 28, 30]) left_related_idx = np.array([31, 32, 33, 34, 35, 36, 38]) right_related_idx = np.array([39, 40, 41, 42, 43, 44, 46]) # Cut-in: keep front face, invalidate others cut_in_idx = partial_indices[is_cut_in] if len(cut_in_idx) > 0: arr[np.ix_(cut_in_idx, rear_related_idx)] = -1 arr[np.ix_(cut_in_idx, left_related_idx)] = -1 arr[np.ix_(cut_in_idx, right_related_idx)] = -1 arr[cut_in_idx, 21] = 1 # front face score # Cut-out: keep rear face, invalidate others cut_out_idx = partial_indices[~is_cut_in] if len(cut_out_idx) > 0: arr[np.ix_(cut_out_idx, head_related_idx)] = -1 arr[np.ix_(cut_out_idx, left_related_idx)] = -1 arr[np.ix_(cut_out_idx, right_related_idx)] = -1 arr[cut_out_idx, 29] = 1 # rear face score # --- Clip coordinates to ROI bounds --- new_x1 = np.clip(new_x1, 0, roi_w - 1) new_y1 = np.clip(new_y1, 0, roi_h - 1) new_x2 = np.clip(new_x2, 0, roi_w - 1) new_y2 = np.clip(new_y2, 0, roi_h - 1) # --- Re-normalize 2D bbox to ROI dimensions --- arr[:, 1] = (new_x1 + new_x2) * 0.5 / roi_w arr[:, 2] = (new_y1 + new_y2) * 0.5 / roi_h arr[:, 3] = (new_x2 - new_x1) / roi_w arr[:, 4] = (new_y2 - new_y1) / roi_h # --- Re-normalize face center projections (xc, yc for each face) --- for xi, yi in [(12, 13), (19, 20), (27, 28), (35, 36), (43, 44)]: valid = ~np.isnan(arr[:, xi]) & (arr[:, xi] >= 0) if np.any(valid): arr[valid, xi] = (arr[valid, xi] * oriW - roi_x1) / roi_w arr[valid, yi] = (arr[valid, yi] * oriH - roi_y1) / roi_h # --- Remove fully-outside objects --- keep_mask = ~fully_outside results = [] for i in range(len(arr)): if keep_mask[i]: results.append((arr[i], raw_cols[i])) return results def resolve_image_paths(data_cfg: dict, split: str) -> list: """Resolve image paths from data config for a given split (train/val). Relative paths inside txt list files are resolved against the txt file's parent directory (not the YAML 'path' key), matching the convention used by the training dataloader. """ raw = data_cfg.get(split, []) base = data_cfg.get("path", "") if isinstance(raw, str): raw = [raw] all_paths = [] for entry in raw: entry = str(entry).strip() if not entry: continue p = Path(entry) if p.is_file(): # text file listing image paths — resolve relative to txt file's dir txt_parent = str(p.parent) with open(p, "r") as f: lines = [l.strip() for l in f if l.strip()] for line in lines: if line.startswith("./"): line = str(Path(txt_parent) / line[2:]) elif not line.startswith("/"): line = str(Path(txt_parent) / line) all_paths.append(line) elif p.is_dir(): for ext in ("*.png", "*.jpg", "*.jpeg"): all_paths.extend(str(x) for x in p.rglob(ext)) else: # might be a glob or template all_paths.append(entry) return all_paths def _looks_like_date_token(token: str) -> bool: """Return True when token resembles a YYYYMMDD date string.""" return isinstance(token, str) and len(token) == 8 and token.isdigit() def extract_vehicle_date_from_path(path: str) -> tuple: """Extract vehicle ID and date from an image/label path. Preferred rule: - Find the nearest 'images'/'labels' directory and search backwards for the closest YYYYMMDD token; its previous segment is treated as vehicle. Fallback rule: - For paths rooted at driving_png* directories, use the next two segments as /. Returns: (vehicle_id, date_str, vehicle_date_key) Missing values are returned as empty strings. """ norm_path = path.replace("\\", "/") parts = [p for p in norm_path.split("/") if p and p != "."] anchor_idx = None for i, seg in enumerate(parts): if seg in ("images", "labels"): anchor_idx = i if anchor_idx is not None: search_start = max(anchor_idx - 4, 0) for j in range(anchor_idx - 1, search_start - 1, -1): if _looks_like_date_token(parts[j]): vehicle_id = parts[j - 1] if j - 1 >= 0 else "" date_str = parts[j] vehicle_date = f"{vehicle_id}/{date_str}" if vehicle_id else date_str return vehicle_id, date_str, vehicle_date for i, seg in enumerate(parts): if seg.startswith("driving_png") and i + 2 < len(parts): vehicle_id = parts[i + 1] date_str = parts[i + 2] if _looks_like_date_token(parts[i + 2]) else "" vehicle_date = f"{vehicle_id}/{date_str}" if vehicle_id and date_str else "" return vehicle_id, date_str, vehicle_date return "", "", "" # ─────────────── Statistics Collectors ────────────────── def _default_zeros4(): """Picklable default factory: returns np.zeros(4).""" return np.zeros(4) def _default_4lists(): """Picklable default factory: returns 4 empty lists.""" return [[] for _ in range(4)] def _default_int_zeros4(): """Picklable default factory: returns int np.zeros(4).""" return np.zeros(4, dtype=np.int64) def encode_yaw_bins_numpy(rot_y: float) -> dict: """Encode one rot_y angle with the same 4-bin soft-label logic used in training.""" delta_0 = rot_y delta_1 = rot_y - np.pi / 2 delta_2 = rot_y + np.pi / 2 delta_3 = rot_y - np.pi if abs(rot_y - np.pi) < abs(rot_y + np.pi) else rot_y + np.pi angles = np.array([delta_0, delta_1, delta_2, delta_3], dtype=np.float64) ang_cls = (np.pi * 0.5 - np.abs(angles)) / (np.pi * 0.5) ang_cls = np.clip(ang_cls, 0.0, 1.0) hard_bin = int(np.argmax(ang_cls)) sorted_cls = np.sort(ang_cls)[::-1] top1 = float(sorted_cls[0]) top2 = float(sorted_cls[1]) if len(sorted_cls) > 1 else 0.0 return { 'angles': angles, 'ang_cls': ang_cls, 'hard_bin': hard_bin, 'hard_delta': float(angles[hard_bin]), 'positive_bin_count': int(np.sum(ang_cls > 0.0)), 'strong_bin_count': int(np.sum(ang_cls >= 0.5)), 'bin_margin': float(top1 - top2), } class DatasetProfiler: """Collect and compute comprehensive statistics for a dataset split.""" def __init__(self, split_name: str, img_w: int = 1920, img_h: int = 960, analysis_mode: str = "original", ori_img_size: tuple = (1920, 1080), roi_size: tuple = (1920, 960)): self.split_name = split_name self.img_w = img_w self.img_h = img_h self.analysis_mode = analysis_mode # "original" or "roi" self.ori_img_size = ori_img_size # original image size (W, H) self.roi_size = roi_size # target ROI size (W, H) # ROI statistics self.roi_calib_found = 0 self.roi_calib_missing = 0 self.roi_filtered_objects = 0 # objects removed (fully outside ROI) self.roi_clipped_objects = 0 # objects clipped at ROI boundary # ── Overview counters ── self.total_images = 0 self.total_labels_found = 0 self.total_labels_missing = 0 self.total_objects = 0 self.empty_label_count = 0 # label files with 0 objects # ── Per-format counters ── self.format_counts = Counter() # {50: N, 18: N, 6: N, ...} # ── Per-class counters ── self.class_counts = Counter() # ── Per-image object count ── self.objects_per_image = [] self.classes_per_image = [] # set sizes # ── Vehicle / date distribution ── self.images_per_vehicle = Counter() self.images_per_date = Counter() self.images_per_vehicle_date = Counter() self.objects_per_vehicle = Counter() self.objects_per_date = Counter() self.objects_per_vehicle_date = Counter() self.images_missing_vehicle_date = 0 # ── 2D bbox stats (all classes) ── self.bbox2d_w = defaultdict(list) # class -> list of widths (pixels) self.bbox2d_h = defaultdict(list) self.bbox2d_cx = defaultdict(list) # center x (pixels) self.bbox2d_cy = defaultdict(list) self.bbox2d_area = defaultdict(list) self.bbox2d_aspect = defaultdict(list) # w/h # ── 3D stats (classes with 3D info) ── self.depth = defaultdict(list) # z3d per class self.dim_l = defaultdict(list) # length self.dim_h = defaultdict(list) # height self.dim_w = defaultdict(list) # width self.rot_y = defaultdict(list) # yaw self.alpha = defaultdict(list) # observation angle self.x3d = defaultdict(list) self.y3d = defaultdict(list) # ── Yaw-bin profiling (same 4-bin encoding as training) ── self.yaw_bin_hard_counts = defaultdict(_default_int_zeros4) self.yaw_bin_soft_mass = defaultdict(_default_zeros4) self.yaw_bin_positive_count_hist = defaultdict(Counter) self.yaw_bin_strong_count_hist = defaultdict(Counter) self.yaw_bin_margin = defaultdict(list) self.yaw_bin_hard_delta = defaultdict(_default_4lists) # ── Face visibility (vehicles/tricycles only) ── self.face_visible_count = defaultdict(_default_zeros4) # class->[front,rear,left,right] self.face_total_count = defaultdict(int) self.face_scores = defaultdict(_default_4lists) # per-face scores # ── Cut-type analysis ── self.cut_type_counts = Counter() # {noncut, cut_in, cut_out} # ── Vehicle subtype counts (大车 vs 小车) ── self.vehicle_subtype_counts = Counter() # {"vehicle_large": N, "vehicle_small": N} self.vehicle_total_counts_per_vehicle = Counter() # vehicle_id -> all cls-0 objects self.vehicle_subtype_counts_per_vehicle = defaultdict(Counter) # vehicle_id -> subtype counts # ── Data quality ── self.nan_3d_objects = 0 # Objects with 3D class but NaN 3D fields self.invalid_depth_count = 0 # z3d <= 0 self.outlier_depth_count = 0 # z3d > 200m self.tiny_box_count = 0 # 2D box < min_box_size def process_image(self, img_path: str) -> None: """Process one image ↔ label pair.""" self.total_images += 1 vehicle_id, date_str, vehicle_date = extract_vehicle_date_from_path(img_path) if vehicle_id: self.images_per_vehicle[vehicle_id] += 1 if date_str: self.images_per_date[date_str] += 1 if vehicle_date: self.images_per_vehicle_date[vehicle_date] += 1 if not vehicle_date: self.images_missing_vehicle_date += 1 label_path = image_path_to_label_path(img_path) entries = parse_label_file(label_path) if not os.path.exists(label_path): self.total_labels_missing += 1 self.objects_per_image.append(0) self.classes_per_image.append(0) return self.total_labels_found += 1 if len(entries) == 0: self.empty_label_count += 1 self.objects_per_image.append(0) self.classes_per_image.append(0) return # ── ROI filtering/clipping (when analysis_mode == "roi") ── if self.analysis_mode == "roi": roi_bounds = get_roi_for_image(img_path, self.ori_img_size, self.roi_size) if roi_bounds is None: self.roi_calib_missing += 1 # Fall back to original image analysis for this image else: self.roi_calib_found += 1 roi_x1, roi_y1, roi_x2, roi_y2 = roi_bounds n_before = len(entries) entries = filter_clip_labels_to_roi( entries, self.ori_img_size, roi_x1, roi_y1, roi_x2, roi_y2) n_after = len(entries) self.roi_filtered_objects += (n_before - n_after) # Count partial/clipped objects: objects that had their bbox # coordinates changed (partial objects that survived filtering) # We track this approximately from the partial mask computed inside # filter_clip_labels_to_roi - for efficiency, count difference n_objs = len(entries) self.total_objects += n_objs self.objects_per_image.append(n_objs) if vehicle_id: self.objects_per_vehicle[vehicle_id] += n_objs if date_str: self.objects_per_date[date_str] += n_objs if vehicle_date: self.objects_per_vehicle_date[vehicle_date] += n_objs cls_set = set() for label, raw_cols in entries: self.format_counts[raw_cols] += 1 cls_id = int(label[IDX["cls"]]) if not np.isnan(label[IDX["cls"]]) else -1 self.class_counts[cls_id] += 1 cls_set.add(cls_id) if cls_id == 0 and vehicle_id: self.vehicle_total_counts_per_vehicle[vehicle_id] += 1 # ── 2D bbox (all objects) ── x_n, y_n, w_n, h_n = label[1], label[2], label[3], label[4] if not any(np.isnan(v) for v in [x_n, y_n, w_n, h_n]): w_px = w_n * self.img_w h_px = h_n * self.img_h cx_px = x_n * self.img_w cy_px = y_n * self.img_h area_px = w_px * h_px aspect = w_px / max(h_px, 1e-6) self.bbox2d_w[cls_id].append(w_px) self.bbox2d_h[cls_id].append(h_px) self.bbox2d_cx[cls_id].append(cx_px) self.bbox2d_cy[cls_id].append(cy_px) self.bbox2d_area[cls_id].append(area_px) self.bbox2d_aspect[cls_id].append(aspect) if w_px < 8 or h_px < 8: self.tiny_box_count += 1 # ── 3D stats ── if cls_id in ALL_3D_CLASSES: z3d = label[IDX["z3d"]] x3d = label[IDX["x3d"]] y3d = label[IDX["y3d"]] l = label[IDX["length"]] h = label[IDX["height"]] w = label[IDX["width"]] ry = label[IDX["rot_y"]] al = label[IDX["alpha"]] if np.isnan(z3d) or np.isnan(l): self.nan_3d_objects += 1 continue if z3d <= 0: self.invalid_depth_count += 1 elif z3d > 200: self.outlier_depth_count += 1 self.depth[cls_id].append(z3d) self.x3d[cls_id].append(x3d) self.y3d[cls_id].append(y3d) self.dim_l[cls_id].append(l) self.dim_h[cls_id].append(h) self.dim_w[cls_id].append(w) if not np.isnan(ry): self.rot_y[cls_id].append(ry) yaw_bin_info = encode_yaw_bins_numpy(float(ry)) self.yaw_bin_hard_counts[cls_id][yaw_bin_info['hard_bin']] += 1 self.yaw_bin_soft_mass[cls_id] += yaw_bin_info['ang_cls'] self.yaw_bin_positive_count_hist[cls_id][yaw_bin_info['positive_bin_count']] += 1 self.yaw_bin_strong_count_hist[cls_id][yaw_bin_info['strong_bin_count']] += 1 self.yaw_bin_margin[cls_id].append(yaw_bin_info['bin_margin']) self.yaw_bin_hard_delta[cls_id][yaw_bin_info['hard_bin']].append(yaw_bin_info['hard_delta']) if not np.isnan(al): self.alpha[cls_id].append(al) # ── Vehicle subtype: 大车 (large) vs 小车 (small) ── if cls_id == 0 and not np.isnan(h): veh_sub_id = (VEH_LARGE if (l > LARGE_VEHICLE_L_THRESH and h > LARGE_VEHICLE_H_THRESH) else VEH_SMALL) sub_name = CLASS_NAMES[veh_sub_id] self.vehicle_subtype_counts[sub_name] += 1 if vehicle_id: self.vehicle_subtype_counts_per_vehicle[vehicle_id][sub_name] += 1 self.depth[veh_sub_id].append(z3d) self.x3d[veh_sub_id].append(x3d) self.y3d[veh_sub_id].append(y3d) self.dim_l[veh_sub_id].append(l) self.dim_h[veh_sub_id].append(h) self.dim_w[veh_sub_id].append(w) if not np.isnan(ry): self.rot_y[veh_sub_id].append(ry) self.yaw_bin_hard_counts[veh_sub_id][yaw_bin_info['hard_bin']] += 1 self.yaw_bin_soft_mass[veh_sub_id] += yaw_bin_info['ang_cls'] self.yaw_bin_positive_count_hist[veh_sub_id][yaw_bin_info['positive_bin_count']] += 1 self.yaw_bin_strong_count_hist[veh_sub_id][yaw_bin_info['strong_bin_count']] += 1 self.yaw_bin_margin[veh_sub_id].append(yaw_bin_info['bin_margin']) self.yaw_bin_hard_delta[veh_sub_id][yaw_bin_info['hard_bin']].append(yaw_bin_info['hard_delta']) if not np.isnan(al): self.alpha[veh_sub_id].append(al) # 2D bbox (x_n, y_n, w_n, h_n are in scope from the 2D block above) if not any(np.isnan(v) for v in [x_n, y_n, w_n, h_n]): _w = w_n * self.img_w _h = h_n * self.img_h self.bbox2d_w[veh_sub_id].append(_w) self.bbox2d_h[veh_sub_id].append(_h) self.bbox2d_cx[veh_sub_id].append(x_n * self.img_w) self.bbox2d_cy[veh_sub_id].append(y_n * self.img_h) self.bbox2d_area[veh_sub_id].append(_w * _h) self.bbox2d_aspect[veh_sub_id].append(_w / max(_h, 1e-6)) # ── Face analysis (face-based classes only) ── if cls_id in FACE_BASED_CLASSES and raw_cols >= 50: self.face_total_count[cls_id] += 1 face_offsets = [IDX["face_front"], IDX["face_rear"], IDX["face_left"], IDX["face_right"]] visible_faces = [] for fi, offset in enumerate(face_offsets): score = label[offset + 6] # face score is_vis = label[offset + 7] # is_visible flag if not np.isnan(score): self.face_scores[cls_id][fi].append(score) if not np.isnan(is_vis) and is_vis >= 0: # is_visible: 1=visible, 0=not visible, -1=invalid if is_vis >= 0.5: self.face_visible_count[cls_id][fi] += 1 visible_faces.append(fi) # Determine cut type # Check if spatial values are -1 (cut indicator) def _is_face_cut(offset): """Face is cut if x3d,y3d,z3d,alpha,score are all -1 or score is 0.""" vals = label[offset:offset + 5] # x3d,y3d,z3d,alpha,xc s = label[offset + 6] if np.isnan(s): return True return (np.nansum(np.abs(vals + 1)) < 0.01) and s < 0.01 front_cut = _is_face_cut(IDX["face_front"]) rear_cut = _is_face_cut(IDX["face_rear"]) left_cut = _is_face_cut(IDX["face_left"]) right_cut = _is_face_cut(IDX["face_right"]) if rear_cut and left_cut and right_cut and not front_cut: self.cut_type_counts["cut_in"] += 1 elif front_cut and left_cut and right_cut and not rear_cut: self.cut_type_counts["cut_out"] += 1 elif not (front_cut or rear_cut or left_cut or right_cut): self.cut_type_counts["noncut"] += 1 else: self.cut_type_counts["partial_cut"] += 1 elif raw_cols == 6: # 2D-only object with 3D class pass self.classes_per_image.append(len(cls_set)) def merge(self, other: 'DatasetProfiler'): """Merge statistics from another DatasetProfiler into this one. Used to combine results from parallel workers. """ # ── Scalar counters ── self.total_images += other.total_images self.total_labels_found += other.total_labels_found self.total_labels_missing += other.total_labels_missing self.total_objects += other.total_objects self.empty_label_count += other.empty_label_count self.roi_calib_found += other.roi_calib_found self.roi_calib_missing += other.roi_calib_missing self.roi_filtered_objects += other.roi_filtered_objects self.roi_clipped_objects += other.roi_clipped_objects self.nan_3d_objects += other.nan_3d_objects self.invalid_depth_count += other.invalid_depth_count self.outlier_depth_count += other.outlier_depth_count self.tiny_box_count += other.tiny_box_count self.images_missing_vehicle_date += other.images_missing_vehicle_date # ── Counter objects ── self.format_counts += other.format_counts self.class_counts += other.class_counts self.cut_type_counts += other.cut_type_counts self.vehicle_subtype_counts += other.vehicle_subtype_counts self.vehicle_total_counts_per_vehicle += other.vehicle_total_counts_per_vehicle self.images_per_vehicle += other.images_per_vehicle self.images_per_date += other.images_per_date self.images_per_vehicle_date += other.images_per_vehicle_date self.objects_per_vehicle += other.objects_per_vehicle self.objects_per_date += other.objects_per_date self.objects_per_vehicle_date += other.objects_per_vehicle_date for vehicle_id, subtype_counter in other.vehicle_subtype_counts_per_vehicle.items(): self.vehicle_subtype_counts_per_vehicle[vehicle_id] += subtype_counter # ── Per-image lists ── self.objects_per_image.extend(other.objects_per_image) self.classes_per_image.extend(other.classes_per_image) # ── Per-class 2D bbox lists ── for cls_id in other.bbox2d_w: self.bbox2d_w[cls_id].extend(other.bbox2d_w[cls_id]) self.bbox2d_h[cls_id].extend(other.bbox2d_h[cls_id]) self.bbox2d_cx[cls_id].extend(other.bbox2d_cx[cls_id]) self.bbox2d_cy[cls_id].extend(other.bbox2d_cy[cls_id]) self.bbox2d_area[cls_id].extend(other.bbox2d_area[cls_id]) self.bbox2d_aspect[cls_id].extend(other.bbox2d_aspect[cls_id]) # ── Per-class 3D lists ── for cls_id in other.depth: self.depth[cls_id].extend(other.depth[cls_id]) for cls_id in other.x3d: self.x3d[cls_id].extend(other.x3d[cls_id]) for cls_id in other.y3d: self.y3d[cls_id].extend(other.y3d[cls_id]) for cls_id in other.dim_l: self.dim_l[cls_id].extend(other.dim_l[cls_id]) for cls_id in other.dim_h: self.dim_h[cls_id].extend(other.dim_h[cls_id]) for cls_id in other.dim_w: self.dim_w[cls_id].extend(other.dim_w[cls_id]) for cls_id in other.rot_y: self.rot_y[cls_id].extend(other.rot_y[cls_id]) for cls_id in other.alpha: self.alpha[cls_id].extend(other.alpha[cls_id]) # ── Yaw-bin profiling ── for cls_id in other.yaw_bin_hard_counts: self.yaw_bin_hard_counts[cls_id] += other.yaw_bin_hard_counts[cls_id] for cls_id in other.yaw_bin_soft_mass: self.yaw_bin_soft_mass[cls_id] += other.yaw_bin_soft_mass[cls_id] for cls_id in other.yaw_bin_positive_count_hist: self.yaw_bin_positive_count_hist[cls_id] += other.yaw_bin_positive_count_hist[cls_id] for cls_id in other.yaw_bin_strong_count_hist: self.yaw_bin_strong_count_hist[cls_id] += other.yaw_bin_strong_count_hist[cls_id] for cls_id in other.yaw_bin_margin: self.yaw_bin_margin[cls_id].extend(other.yaw_bin_margin[cls_id]) for cls_id in other.yaw_bin_hard_delta: for bin_idx in range(4): self.yaw_bin_hard_delta[cls_id][bin_idx].extend(other.yaw_bin_hard_delta[cls_id][bin_idx]) # ── Face visibility ── for cls_id in other.face_total_count: self.face_total_count[cls_id] += other.face_total_count[cls_id] self.face_visible_count[cls_id] += other.face_visible_count[cls_id] for fi in range(4): self.face_scores[cls_id][fi].extend(other.face_scores[cls_id][fi]) def summarize(self) -> dict: """Compute summary statistics and return as dict.""" summary = {} # ── 1. Overview ── summary["overview"] = { "split": self.split_name, "analysis_mode": self.analysis_mode, "total_images": self.total_images, "labels_found": self.total_labels_found, "labels_missing": self.total_labels_missing, "empty_labels": self.empty_label_count, "total_objects": self.total_objects, "format_distribution": dict(self.format_counts), } # ── ROI info (when in ROI mode) ── if self.analysis_mode == "roi": summary["overview"]["roi_info"] = { "ori_img_size": list(self.ori_img_size), "roi_size": list(self.roi_size), "img_size_used": [self.img_w, self.img_h], "calib_found": self.roi_calib_found, "calib_missing": self.roi_calib_missing, "objects_filtered_outside_roi": self.roi_filtered_objects, } # ── 2. Per-image density ── opi = np.array(self.objects_per_image) if self.objects_per_image else np.array([0]) cpi = np.array(self.classes_per_image) if self.classes_per_image else np.array([0]) summary["per_image_density"] = { "objects_per_image": _dist_stats(opi), "classes_per_image": _dist_stats(cpi), } # ── 2.5 Vehicle / date distribution ── dates_per_vehicle = {} for vehicle_id in sorted(self.images_per_vehicle.keys()): date_counter = Counter() prefix = f"{vehicle_id}/" for vehicle_date, image_count in self.images_per_vehicle_date.items(): if vehicle_date.startswith(prefix): date_str = vehicle_date[len(prefix):] if date_str: date_counter[date_str] = image_count dates_per_vehicle[vehicle_id] = { "unique_dates": len(date_counter), "images_per_date_stats": _counter_value_stats(date_counter), "image_counts": _counter_to_sorted_dict(date_counter, sort_mode="label_asc"), } vehicle_subtype_per_vehicle = {} for vehicle_id in sorted(set(self.images_per_vehicle.keys()) | set(self.vehicle_total_counts_per_vehicle.keys()) | set(self.vehicle_subtype_counts_per_vehicle.keys())): total_vehicle = self.vehicle_total_counts_per_vehicle.get(vehicle_id, 0) large_n = self.vehicle_subtype_counts_per_vehicle[vehicle_id].get("vehicle_large", 0) small_n = self.vehicle_subtype_counts_per_vehicle[vehicle_id].get("vehicle_small", 0) classifiable_n = large_n + small_n vehicle_subtype_per_vehicle[vehicle_id] = { "total_vehicle_objects": total_vehicle, "classifiable_vehicle_objects": classifiable_n, "unclassified_vehicle_objects": total_vehicle - classifiable_n, "large_vehicle": { "count": large_n, "proportion_of_classifiable": large_n / max(classifiable_n, 1), }, "small_vehicle": { "count": small_n, "proportion_of_classifiable": small_n / max(classifiable_n, 1), }, } summary["vehicle_date_distribution"] = { "missing_vehicle_date_images": self.images_missing_vehicle_date, "vehicle": { "unique_count": len(self.images_per_vehicle), "images_per_vehicle_stats": _counter_value_stats(self.images_per_vehicle), "objects_per_vehicle_stats": _counter_value_stats(self.objects_per_vehicle), "image_counts": _counter_to_sorted_dict(self.images_per_vehicle), "object_counts": _counter_to_sorted_dict(self.objects_per_vehicle), }, "date": { "unique_count": len(self.images_per_date), "images_per_date_stats": _counter_value_stats(self.images_per_date), "objects_per_date_stats": _counter_value_stats(self.objects_per_date), "image_counts": _counter_to_sorted_dict(self.images_per_date, sort_mode="label_asc"), "object_counts": _counter_to_sorted_dict(self.objects_per_date, sort_mode="label_asc"), }, "vehicle_date": { "unique_count": len(self.images_per_vehicle_date), "images_per_vehicle_date_stats": _counter_value_stats(self.images_per_vehicle_date), "objects_per_vehicle_date_stats": _counter_value_stats(self.objects_per_vehicle_date), "image_counts": _counter_to_sorted_dict(self.images_per_vehicle_date), "object_counts": _counter_to_sorted_dict(self.objects_per_vehicle_date), }, "dates_per_vehicle": dates_per_vehicle, "vehicle_subtype_per_vehicle": vehicle_subtype_per_vehicle, } # ── 3. Per-class breakdown ── class_info = {} for cls_id in sorted(self.class_counts.keys()): name = CLASS_NAMES.get(cls_id, f"class_{cls_id}") info = { "count": self.class_counts[cls_id], "proportion": self.class_counts[cls_id] / max(self.total_objects, 1), } # 2D bbox stats if self.bbox2d_w.get(cls_id): info["bbox2d"] = { "width_px": _dist_stats(np.array(self.bbox2d_w[cls_id])), "height_px": _dist_stats(np.array(self.bbox2d_h[cls_id])), "area_px": _dist_stats(np.array(self.bbox2d_area[cls_id])), "aspect_ratio": _dist_stats(np.array(self.bbox2d_aspect[cls_id])), } # 3D stats if self.depth.get(cls_id): d = np.array(self.depth[cls_id]) info["depth_m"] = _dist_stats(d) info["depth_ranges"] = { "0-10m": int(np.sum(d < 10)), "10-20m": int(np.sum((d >= 10) & (d < 20))), "20-30m": int(np.sum((d >= 20) & (d < 30))), "30-50m": int(np.sum((d >= 30) & (d < 50))), "50-80m": int(np.sum((d >= 50) & (d < 80))), "80-120m": int(np.sum((d >= 80) & (d < 120))), ">120m": int(np.sum(d >= 120)), } if self.dim_l.get(cls_id): info["dimensions_m"] = { "length": _dist_stats(np.array(self.dim_l[cls_id])), "height": _dist_stats(np.array(self.dim_h[cls_id])), "width": _dist_stats(np.array(self.dim_w[cls_id])), } if self.rot_y.get(cls_id): info["rot_y_rad"] = _dist_stats(np.array(self.rot_y[cls_id])) info["yaw_bin_profile"] = _summarize_yaw_bin_profile( self.yaw_bin_hard_counts[cls_id], self.yaw_bin_soft_mass[cls_id], self.yaw_bin_positive_count_hist[cls_id], self.yaw_bin_strong_count_hist[cls_id], self.yaw_bin_margin[cls_id], self.yaw_bin_hard_delta[cls_id], ) if self.alpha.get(cls_id): info["alpha_rad"] = _dist_stats(np.array(self.alpha[cls_id])) if self.x3d.get(cls_id): info["lateral_x3d_m"] = _dist_stats(np.array(self.x3d[cls_id])) class_info[name] = info # ── Vehicle subtype breakdown (大车 vs 小车) ── # Pre-compute classifiable count so it's available inside the loop. lv_n_pre = self.vehicle_subtype_counts.get("vehicle_large", 0) sv_n_pre = self.vehicle_subtype_counts.get("vehicle_small", 0) _classifiable = max(lv_n_pre + sv_n_pre, 1) for veh_sub_id in [VEH_LARGE, VEH_SMALL]: sub_name = CLASS_NAMES[veh_sub_id] count = self.vehicle_subtype_counts.get(sub_name, 0) if count == 0: continue info = { "count": count, "proportion": count / max(self.total_objects, 1), "proportion_of_vehicle": count / _classifiable, } if self.bbox2d_w.get(veh_sub_id): info["bbox2d"] = { "width_px": _dist_stats(np.array(self.bbox2d_w[veh_sub_id])), "height_px": _dist_stats(np.array(self.bbox2d_h[veh_sub_id])), "area_px": _dist_stats(np.array(self.bbox2d_area[veh_sub_id])), "aspect_ratio": _dist_stats(np.array(self.bbox2d_aspect[veh_sub_id])), } if self.depth.get(veh_sub_id): d = np.array(self.depth[veh_sub_id]) info["depth_m"] = _dist_stats(d) info["depth_ranges"] = { "0-10m": int(np.sum(d < 10)), "10-20m": int(np.sum((d >= 10) & (d < 20))), "20-30m": int(np.sum((d >= 20) & (d < 30))), "30-50m": int(np.sum((d >= 30) & (d < 50))), "50-80m": int(np.sum((d >= 50) & (d < 80))), "80-120m": int(np.sum((d >= 80) & (d < 120))), ">120m": int(np.sum(d >= 120)), } if self.dim_l.get(veh_sub_id): info["dimensions_m"] = { "length": _dist_stats(np.array(self.dim_l[veh_sub_id])), "height": _dist_stats(np.array(self.dim_h[veh_sub_id])), "width": _dist_stats(np.array(self.dim_w[veh_sub_id])), } if self.rot_y.get(veh_sub_id): info["rot_y_rad"] = _dist_stats(np.array(self.rot_y[veh_sub_id])) info["yaw_bin_profile"] = _summarize_yaw_bin_profile( self.yaw_bin_hard_counts[veh_sub_id], self.yaw_bin_soft_mass[veh_sub_id], self.yaw_bin_positive_count_hist[veh_sub_id], self.yaw_bin_strong_count_hist[veh_sub_id], self.yaw_bin_margin[veh_sub_id], self.yaw_bin_hard_delta[veh_sub_id], ) if self.x3d.get(veh_sub_id): info["lateral_x3d_m"] = _dist_stats(np.array(self.x3d[veh_sub_id])) class_info[sub_name] = info summary["per_class"] = class_info # ── 3.5 Yaw-bin overview (real 3D classes only, excluding virtual subtypes) ── overall_yaw_hard_counts = np.zeros(4, dtype=np.int64) overall_yaw_soft_mass = np.zeros(4, dtype=np.float64) overall_positive_hist = Counter() overall_strong_hist = Counter() overall_yaw_margin = [] overall_hard_delta = [[] for _ in range(4)] for cls_id in sorted(ALL_3D_CLASSES): if cls_id not in self.yaw_bin_hard_counts: continue overall_yaw_hard_counts += self.yaw_bin_hard_counts[cls_id] overall_yaw_soft_mass += self.yaw_bin_soft_mass[cls_id] overall_positive_hist += self.yaw_bin_positive_count_hist[cls_id] overall_strong_hist += self.yaw_bin_strong_count_hist[cls_id] overall_yaw_margin.extend(self.yaw_bin_margin[cls_id]) for bin_idx in range(4): overall_hard_delta[bin_idx].extend(self.yaw_bin_hard_delta[cls_id][bin_idx]) summary["yaw_bin_overview"] = _summarize_yaw_bin_profile( overall_yaw_hard_counts, overall_yaw_soft_mass, overall_positive_hist, overall_strong_hist, overall_yaw_margin, overall_hard_delta, ) # ── Vehicle subtype summary (dedicated entry) ── # Denominator = only vehicles with valid 3D annotations (classifiable ones). # Vehicles with 2D-only labels (6-col) have no length/height and cannot be # classified as large/small; they are reported separately as "unclassified". total_vehicles_all = self.class_counts.get(0, 0) lv_n = self.vehicle_subtype_counts.get("vehicle_large", 0) sv_n = self.vehicle_subtype_counts.get("vehicle_small", 0) classifiable = max(lv_n + sv_n, 1) unclassified_n = total_vehicles_all - lv_n - sv_n summary["vehicle_subtype"] = { "threshold": { "length_gt_m": LARGE_VEHICLE_L_THRESH, "height_gt_m": LARGE_VEHICLE_H_THRESH, }, "total_vehicles": total_vehicles_all, "classifiable_vehicles": lv_n + sv_n, "unclassified_vehicles": unclassified_n, # 2D-only labels, no 3D dims "large_vehicle": { "count": lv_n, "proportion_of_classifiable": lv_n / classifiable, }, "small_vehicle": { "count": sv_n, "proportion_of_classifiable": sv_n / classifiable, }, } # ── 4. Face visibility (vehicles/tricycles) ── face_info = {} for cls_id in sorted(self.face_total_count.keys()): name = CLASS_NAMES.get(cls_id, f"class_{cls_id}") total = self.face_total_count[cls_id] vis = self.face_visible_count[cls_id] face_info[name] = { "total_objects": total, "face_visibility_rate": { fn: float(vis[i] / max(total, 1)) for i, fn in enumerate(FACE_NAMES) }, "face_score_stats": { fn: _dist_stats(np.array(self.face_scores[cls_id][i])) if self.face_scores[cls_id][i] else None for i, fn in enumerate(FACE_NAMES) }, } summary["face_visibility"] = face_info # ── 5. Cut type distribution ── summary["cut_type"] = dict(self.cut_type_counts) # ── 6. Data quality ── summary["data_quality"] = { "nan_3d_objects": self.nan_3d_objects, "invalid_depth_le0": self.invalid_depth_count, "outlier_depth_gt200m": self.outlier_depth_count, "tiny_box_lt8px": self.tiny_box_count, } return summary def _dist_stats(arr: np.ndarray) -> dict: """Compute descriptive statistics for a numeric array.""" if len(arr) == 0: return {"count": 0} return { "count": int(len(arr)), "mean": float(np.nanmean(arr)), "std": float(np.nanstd(arr)), "min": float(np.nanmin(arr)), "p5": float(np.nanpercentile(arr, 5)), "p25": float(np.nanpercentile(arr, 25)), "median": float(np.nanmedian(arr)), "p75": float(np.nanpercentile(arr, 75)), "p95": float(np.nanpercentile(arr, 95)), "max": float(np.nanmax(arr)), } def _counter_value_stats(counter: Counter) -> dict: """Compute descriptive statistics for Counter values.""" if not counter: return {"count": 0} return _dist_stats(np.array(list(counter.values()), dtype=np.float64)) def _counter_items(counter: Counter, sort_mode: str = "count_desc", limit: int = None) -> list: """Return Counter items in a deterministic, presentation-friendly order.""" items = list(counter.items()) if sort_mode == "label_asc": items.sort(key=lambda kv: kv[0]) else: items.sort(key=lambda kv: (-kv[1], kv[0])) if limit is not None: items = items[:limit] return items def _counter_to_sorted_dict(counter: Counter, sort_mode: str = "count_desc", limit: int = None) -> dict: """Convert Counter to an ordered dict representation for JSON/report output.""" return {k: int(v) for k, v in _counter_items(counter, sort_mode=sort_mode, limit=limit)} def _summarize_yaw_bin_profile(hard_counts: np.ndarray, soft_mass: np.ndarray, positive_hist: Counter, strong_hist: Counter, margins: list, hard_delta_lists: list) -> dict: """Summarize yaw-bin distribution statistics for one class/group.""" hard_counts = np.asarray(hard_counts, dtype=np.int64) soft_mass = np.asarray(soft_mass, dtype=np.float64) hard_total = int(np.sum(hard_counts)) soft_total = float(np.sum(soft_mass)) def _ratio_dict(values, denom): denom = max(float(denom), 1e-12) return {f'bin_{i}': float(values[i] / denom) for i in range(4)} return { 'hard_bin_counts': {f'bin_{i}': int(hard_counts[i]) for i in range(4)}, 'hard_bin_ratio': _ratio_dict(hard_counts.astype(np.float64), max(hard_total, 1)), 'soft_bin_mass': {f'bin_{i}': float(soft_mass[i]) for i in range(4)}, 'soft_bin_mass_ratio': _ratio_dict(soft_mass, max(soft_total, 1e-12)), 'positive_bin_count_hist': {str(k): int(v) for k, v in sorted(positive_hist.items())}, 'strong_bin_count_hist': {str(k): int(v) for k, v in sorted(strong_hist.items())}, 'bin_margin': _dist_stats(np.array(margins, dtype=np.float64)) if margins else {'count': 0}, 'hard_delta_per_bin': { f'bin_{i}': _dist_stats(np.array(hard_delta_lists[i], dtype=np.float64)) if hard_delta_lists[i] else {'count': 0} for i in range(4) }, } def _process_chunk(args): """Worker function for multiprocessing: process a chunk of images. Args is a tuple of: (paths, split_name, img_w, img_h, analysis_mode, ori_img_size, roi_size) Returns a DatasetProfiler with statistics for this chunk. """ paths, split_name, img_w, img_h, analysis_mode, ori_img_size, roi_size = args profiler = DatasetProfiler(split_name, img_w, img_h, analysis_mode, ori_img_size, roi_size) for p in paths: profiler.process_image(p) return profiler # ─────────────────── Visualization ────────────────────── def plot_overview(summary: dict, output_dir: str): """Plot 1: Dataset overview bar charts.""" fig, axes = plt.subplots(1, 3, figsize=(18, 5)) fig.suptitle(f"Dataset Overview — {summary['overview']['split']}", fontsize=14, fontweight="bold") # A) Per-class object count ax = axes[0] cls_data = summary["per_class"] names = list(cls_data.keys()) counts = [cls_data[n]["count"] for n in names] if names: colors = plt.cm.tab20(np.linspace(0, 1, len(names))) bars = ax.barh(names, counts, color=colors) ax.set_xlabel("Object Count") ax.set_title("Per-Class Object Count") for bar, c in zip(bars, counts): ax.text(bar.get_width() + max(counts) * 0.01, bar.get_y() + bar.get_height() / 2, f"{c:,}", va="center", fontsize=8) else: ax.set_title("Per-Class Object Count (no data)") ax.axis("off") # B) Label format distribution ax = axes[1] fmt_data = summary["overview"]["format_distribution"] fmt_labels = [f"{k}-col" for k in sorted(fmt_data.keys())] fmt_vals = [fmt_data[k] for k in sorted(fmt_data.keys())] if fmt_vals: ax.pie(fmt_vals, labels=fmt_labels, autopct="%1.1f%%", startangle=90) ax.set_title("Label Format Distribution") else: ax.set_title("Label Format Distribution (no data)") ax.axis("off") # C) Objects per image histogram ax = axes[2] opi = summary["per_image_density"]["objects_per_image"] if opi.get("mean") is not None: ax.text(0.5, 0.95, f"mean={opi['mean']:.1f} median={opi['median']:.1f} " f"max={opi['max']:.0f} p95={opi['p95']:.0f}", transform=ax.transAxes, ha="center", va="top", fontsize=9, bbox=dict(boxstyle="round", facecolor="lightyellow")) ax.set_title("Objects per Image (stats)") ax.axis("off") plt.tight_layout() plt.savefig(os.path.join(output_dir, "01_overview.png"), dpi=150, bbox_inches="tight") plt.close() def plot_2d_bbox_analysis(profiler: DatasetProfiler, output_dir: str): """Plot 2: 2D bounding box distributions and position heatmaps.""" # Collect top classes by count top_classes = sorted(profiler.class_counts.keys(), key=lambda c: profiler.class_counts[c], reverse=True)[:6] if not top_classes: return fig = plt.figure(figsize=(20, 14)) gs = gridspec.GridSpec(3, len(top_classes), hspace=0.35, wspace=0.3) fig.suptitle(f"2D Bounding Box Analysis — {profiler.split_name}", fontsize=14, fontweight="bold") for ci, cls_id in enumerate(top_classes): name = CLASS_NAMES.get(cls_id, f"cls{cls_id}") # Row 0: Width-Height scatter ax = fig.add_subplot(gs[0, ci]) w_arr = np.array(profiler.bbox2d_w.get(cls_id, [])) h_arr = np.array(profiler.bbox2d_h.get(cls_id, [])) if len(w_arr) > 0: # Subsample for scatter idx = np.random.choice(len(w_arr), min(5000, len(w_arr)), replace=False) ax.scatter(w_arr[idx], h_arr[idx], s=1, alpha=0.3) ax.set_xlabel("Width (px)") ax.set_ylabel("Height (px)") ax.set_title(f"{name} (N={profiler.class_counts[cls_id]:,})", fontsize=10) # Row 1: Area histogram ax = fig.add_subplot(gs[1, ci]) area_arr = np.array(profiler.bbox2d_area.get(cls_id, [])) if len(area_arr) > 0: ax.hist(np.clip(area_arr, 0, np.percentile(area_arr, 99)), bins=50, color="steelblue", edgecolor="none") ax.set_xlabel("Area (px²)") ax.set_ylabel("Count") ax.set_title("Area distribution", fontsize=9) # Row 2: Position heatmap ax = fig.add_subplot(gs[2, ci]) cx_arr = np.array(profiler.bbox2d_cx.get(cls_id, [])) cy_arr = np.array(profiler.bbox2d_cy.get(cls_id, [])) if len(cx_arr) > 0: ax.hist2d(cx_arr, cy_arr, bins=50, cmap="YlOrRd", range=[[0, profiler.img_w], [0, profiler.img_h]]) ax.set_xlabel("X (px)") ax.set_ylabel("Y (px)") ax.set_title("Center heatmap", fontsize=9) ax.invert_yaxis() ax.set_aspect("equal") plt.savefig(os.path.join(output_dir, "02_bbox2d_analysis.png"), dpi=150, bbox_inches="tight") plt.close() def plot_3d_depth_analysis(profiler: DatasetProfiler, output_dir: str): """Plot 3: Depth distributions per class.""" cls_ids = sorted([c for c in profiler.depth.keys() if profiler.depth[c]]) if not cls_ids: return n = len(cls_ids) fig, axes = plt.subplots(2, n, figsize=(5 * n, 9)) if n == 1: axes = axes.reshape(2, 1) fig.suptitle(f"Depth (Z3D) Analysis — {profiler.split_name}", fontsize=14, fontweight="bold") for ci, cls_id in enumerate(cls_ids): name = CLASS_NAMES.get(cls_id, f"cls{cls_id}") d = np.array(profiler.depth[cls_id]) # Row 0: Histogram ax = axes[0, ci] ax.hist(np.clip(d, 0, 150), bins=80, color="teal", edgecolor="none", alpha=0.8) ax.axvline(np.median(d), color="red", linestyle="--", label=f"median={np.median(d):.1f}m") ax.axvline(np.mean(d), color="orange", linestyle="--", label=f"mean={np.mean(d):.1f}m") ax.set_xlabel("Depth (m)") ax.set_ylabel("Count") ax.set_title(f"{name} (N={len(d):,})") ax.legend(fontsize=8) # Row 1: Cumulative distribution (CDF) ax = axes[1, ci] sorted_d = np.sort(d) cdf = np.arange(1, len(sorted_d) + 1) / len(sorted_d) ax.plot(sorted_d, cdf, color="teal", linewidth=1.5) ax.set_xlabel("Depth (m)") ax.set_ylabel("CDF") ax.set_title("Cumulative distribution") ax.grid(True, alpha=0.3) # Mark key percentiles for p in [50, 90, 95]: val = np.percentile(d, p) ax.axhline(p / 100, color="gray", linestyle=":", alpha=0.4) ax.axvline(val, color="gray", linestyle=":", alpha=0.4) ax.text(val, p / 100 + 0.02, f"p{p}={val:.0f}m", fontsize=7) plt.tight_layout() plt.savefig(os.path.join(output_dir, "03_depth_analysis.png"), dpi=150, bbox_inches="tight") plt.close() def plot_3d_dimensions(profiler: DatasetProfiler, output_dir: str): """Plot 4: 3D dimension distributions (LxHxW) per class.""" cls_ids = sorted([c for c in profiler.dim_l.keys() if profiler.dim_l[c]]) if not cls_ids: return n = len(cls_ids) fig, axes = plt.subplots(3, n, figsize=(5 * n, 12)) if n == 1: axes = axes.reshape(3, 1) fig.suptitle(f"3D Dimensions (L×H×W) — {profiler.split_name}", fontsize=14, fontweight="bold") dim_names = ["Length", "Height", "Width"] dim_data = [profiler.dim_l, profiler.dim_h, profiler.dim_w] colors = ["#e74c3c", "#2ecc71", "#3498db"] for ci, cls_id in enumerate(cls_ids): name = CLASS_NAMES.get(cls_id, f"cls{cls_id}") for di, (dname, ddict, color) in enumerate(zip(dim_names, dim_data, colors)): ax = axes[di, ci] vals = np.array(ddict.get(cls_id, [])) if len(vals) > 0: ax.hist(vals, bins=60, color=color, edgecolor="none", alpha=0.8) ax.axvline(np.mean(vals), color="black", linestyle="--", label=f"μ={np.mean(vals):.2f} σ={np.std(vals):.2f}") ax.legend(fontsize=7) ax.set_xlabel(f"{dname} (m)") if di == 0: ax.set_title(name) plt.tight_layout() plt.savefig(os.path.join(output_dir, "04_dimensions_3d.png"), dpi=150, bbox_inches="tight") plt.close() def plot_rotation_analysis(profiler: DatasetProfiler, output_dir: str): """Plot 5: Rotation (rot_y) and alpha distributions.""" cls_ids = sorted([c for c in profiler.rot_y.keys() if profiler.rot_y[c]]) if not cls_ids: return n = len(cls_ids) fig, axes = plt.subplots(2, n, figsize=(5 * n, 9)) if n == 1: axes = axes.reshape(2, 1) fig.suptitle(f"Rotation Analysis — {profiler.split_name}", fontsize=14, fontweight="bold") for ci, cls_id in enumerate(cls_ids): name = CLASS_NAMES.get(cls_id, f"cls{cls_id}") # Row 0: rot_y polar histogram ax = axes[0, ci] ry = np.array(profiler.rot_y[cls_id]) ax.hist(ry, bins=72, range=(-np.pi, np.pi), color="purple", edgecolor="none", alpha=0.8) ax.set_xlabel("rot_y (rad)") ax.set_ylabel("Count") ax.set_title(f"{name} — rot_y") # Mark key orientations for val, lbl in [(-np.pi / 2, "forward"), (np.pi / 2, "backward"), (0, "right"), (np.pi, "left")]: ax.axvline(val, color="gray", linestyle=":", alpha=0.5) ax.text(val, ax.get_ylim()[1] * 0.95, lbl, fontsize=7, ha="center", va="top") # Row 1: alpha histogram ax = axes[1, ci] al = np.array(profiler.alpha.get(cls_id, [])) if len(al) > 0: ax.hist(al, bins=72, range=(-np.pi, np.pi), color="teal", edgecolor="none", alpha=0.8) ax.set_xlabel("alpha (rad)") ax.set_ylabel("Count") ax.set_title(f"{name} — alpha") plt.tight_layout() plt.savefig(os.path.join(output_dir, "05_rotation_analysis.png"), dpi=150, bbox_inches="tight") plt.close() def plot_yaw_bin_analysis(profiler: DatasetProfiler, output_dir: str): """Plot 5b: Yaw-bin hard/soft distributions and bin-overlap statistics.""" cls_ids = sorted([c for c in profiler.rot_y.keys() if profiler.rot_y[c]]) if not cls_ids: return n = len(cls_ids) fig, axes = plt.subplots(4, n, figsize=(5 * n, 16)) if n == 1: axes = axes.reshape(4, 1) fig.suptitle(f"Yaw-Bin Analysis — {profiler.split_name}", fontsize=14, fontweight="bold") bin_labels = ["bin0", "bin1", "bin2", "bin3"] count_labels = ["0", "1", "2", "3", "4"] count_x = np.arange(len(count_labels)) for ci, cls_id in enumerate(cls_ids): name = CLASS_NAMES.get(cls_id, f"cls{cls_id}") hard_counts = np.asarray(profiler.yaw_bin_hard_counts[cls_id], dtype=np.float64) hard_ratios = hard_counts / max(np.sum(hard_counts), 1.0) soft_mass = np.asarray(profiler.yaw_bin_soft_mass[cls_id], dtype=np.float64) soft_ratios = soft_mass / max(np.sum(soft_mass), 1e-12) ax = axes[0, ci] ax.bar(bin_labels, hard_ratios * 100.0, color=["#3498db", "#2ecc71", "#e67e22", "#9b59b6"]) ax.set_ylim(0, 100) ax.set_ylabel("Hard Ratio (%)") ax.set_title(f"{name} — hard bin") ax = axes[1, ci] ax.bar(bin_labels, soft_ratios * 100.0, color=["#5dade2", "#58d68d", "#f5b041", "#af7ac5"]) ax.set_ylim(0, 100) ax.set_ylabel("Soft Mass Ratio (%)") ax.set_title(f"{name} — soft bin mass") ax = axes[2, ci] positive_vals = [profiler.yaw_bin_positive_count_hist[cls_id].get(i, 0) for i in range(5)] strong_vals = [profiler.yaw_bin_strong_count_hist[cls_id].get(i, 0) for i in range(5)] width = 0.38 ax.bar(count_x - width / 2, positive_vals, width=width, label=">0", color="#3498db") ax.bar(count_x + width / 2, strong_vals, width=width, label=">=0.5", color="#e74c3c") ax.set_xticks(count_x) ax.set_xticklabels(count_labels) ax.set_xlabel("Active Bin Count") ax.set_ylabel("Samples") ax.set_title(f"{name} — bin overlap") ax.legend(fontsize=7) ax = axes[3, ci] delta_lists = profiler.yaw_bin_hard_delta[cls_id] has_delta = any(len(vals) > 0 for vals in delta_lists) if has_delta: ax.boxplot([vals if vals else [np.nan] for vals in delta_lists], labels=bin_labels, showfliers=False) ax.set_ylabel("Hard Delta (rad)") else: ax.text(0.5, 0.5, "No hard-delta data", ha="center", va="center", transform=ax.transAxes) ax.set_xticks([]) ax.set_title(f"{name} — hard delta") plt.tight_layout() plt.savefig(os.path.join(output_dir, "05b_yaw_bin_analysis.png"), dpi=150, bbox_inches="tight") plt.close() def plot_face_visibility(profiler: DatasetProfiler, summary: dict, output_dir: str): """Plot 6: Face visibility rates and cut-type distribution.""" face_data = summary.get("face_visibility", {}) cut_data = summary.get("cut_type", {}) if not face_data and not cut_data: return ncols = len(face_data) + (1 if cut_data else 0) if ncols == 0: return fig, axes = plt.subplots(1, ncols, figsize=(6 * ncols, 5)) if ncols == 1: axes = [axes] fig.suptitle(f"Face Visibility & Cut Analysis — {profiler.split_name}", fontsize=14, fontweight="bold") ci = 0 for name, finfo in face_data.items(): ax = axes[ci] rates = finfo["face_visibility_rate"] face_labels = list(rates.keys()) face_vals = [rates[k] * 100 for k in face_labels] colors = ["#e74c3c", "#2ecc71", "#3498db", "#f39c12"] bars = ax.bar(face_labels, face_vals, color=colors) for bar, v in zip(bars, face_vals): ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 1, f"{v:.1f}%", ha="center", fontsize=9) ax.set_ylabel("Visibility Rate (%)") ax.set_title(f"{name} — Face Visibility") ax.set_ylim(0, 110) ci += 1 if cut_data: ax = axes[ci] labels = list(cut_data.keys()) vals = [cut_data[k] for k in labels] ax.pie(vals, labels=labels, autopct="%1.1f%%", startangle=90, colors=["#2ecc71", "#e74c3c", "#f39c12", "#9b59b6"]) ax.set_title("Cut-Type Distribution") plt.tight_layout() plt.savefig(os.path.join(output_dir, "06_face_visibility_cut.png"), dpi=150, bbox_inches="tight") plt.close() def plot_lateral_depth_scatter(profiler: DatasetProfiler, output_dir: str): """Plot 7: Lateral (X3D) vs Depth (Z3D) bird's-eye view scatter.""" cls_ids = sorted([c for c in profiler.x3d.keys() if profiler.x3d[c]]) if not cls_ids: return fig, axes = plt.subplots(1, len(cls_ids), figsize=(6 * len(cls_ids), 6)) if len(cls_ids) == 1: axes = [axes] fig.suptitle(f"Bird's Eye View (X vs Z) — {profiler.split_name}", fontsize=14, fontweight="bold") for ci, cls_id in enumerate(cls_ids): ax = axes[ci] name = CLASS_NAMES.get(cls_id, f"cls{cls_id}") x = np.array(profiler.x3d[cls_id]) z = np.array(profiler.depth[cls_id]) # Subsample idx = np.random.choice(len(x), min(10000, len(x)), replace=False) ax.scatter(x[idx], z[idx], s=1, alpha=0.3, c="teal") ax.set_xlabel("Lateral X (m)") ax.set_ylabel("Depth Z (m)") ax.set_title(f"{name} (N={len(x):,})") ax.set_xlim(-60, 60) ax.set_ylim(0, 150) ax.grid(True, alpha=0.3) ax.set_aspect("equal") plt.tight_layout() plt.savefig(os.path.join(output_dir, "07_bev_scatter.png"), dpi=150, bbox_inches="tight") plt.close() def plot_depth_vs_box_size(profiler: DatasetProfiler, output_dir: str): """Plot 8: Depth vs 2D box size (proxy for size-distance relationship).""" cls_ids = sorted([c for c in ALL_3D_CLASSES if profiler.depth.get(c) and profiler.bbox2d_area.get(c)]) if not cls_ids: return fig, axes = plt.subplots(1, len(cls_ids), figsize=(6 * len(cls_ids), 5)) if len(cls_ids) == 1: axes = [axes] fig.suptitle(f"Depth vs 2D Box Area — {profiler.split_name}", fontsize=14, fontweight="bold") for ci, cls_id in enumerate(cls_ids): ax = axes[ci] name = CLASS_NAMES.get(cls_id, f"cls{cls_id}") # Align arrays (both from same objects in order) d = np.array(profiler.depth[cls_id]) a = np.array(profiler.bbox2d_area[cls_id]) n = min(len(d), len(a)) d, a = d[:n], a[:n] idx = np.random.choice(n, min(5000, n), replace=False) ax.scatter(d[idx], a[idx], s=1, alpha=0.3, c="coral") ax.set_xlabel("Depth (m)") ax.set_ylabel("2D Box Area (px²)") ax.set_title(name) ax.grid(True, alpha=0.3) plt.tight_layout() plt.savefig(os.path.join(output_dir, "08_depth_vs_area.png"), dpi=150, bbox_inches="tight") plt.close() def plot_train_vs_val_comparison(train_summary: dict, val_summary: dict, output_dir: str): """Plot 9: Side-by-side comparison of train vs val distributions.""" train_cls = train_summary.get("per_class", {}) val_cls = val_summary.get("per_class", {}) all_names = sorted(set(list(train_cls.keys()) + list(val_cls.keys()))) if not all_names: return fig, axes = plt.subplots(2, 3, figsize=(18, 10)) fig.suptitle("Train vs Val Distribution Comparison", fontsize=14, fontweight="bold") # A) Class proportion comparison ax = axes[0, 0] x = np.arange(len(all_names)) w = 0.35 train_props = [train_cls.get(n, {}).get("proportion", 0) * 100 for n in all_names] val_props = [val_cls.get(n, {}).get("proportion", 0) * 100 for n in all_names] ax.barh(x - w / 2, train_props, w, label="Train", color="steelblue") ax.barh(x + w / 2, val_props, w, label="Val", color="coral") ax.set_yticks(x) ax.set_yticklabels(all_names, fontsize=8) ax.set_xlabel("Proportion (%)") ax.set_title("Class Proportion") ax.legend() # B) Depth comparison (vehicle) ax = axes[0, 1] for split_name, s, color in [("Train", train_cls, "steelblue"), ("Val", val_cls, "coral")]: veh = s.get("vehicle", {}) dr = veh.get("depth_ranges", {}) if dr: labels = list(dr.keys()) vals = [dr[k] for k in labels] total = max(sum(vals), 1) props = [v / total * 100 for v in vals] ax.plot(labels, props, "o-", label=split_name, color=color) ax.set_xlabel("Depth Range") ax.set_ylabel("% of vehicles") ax.set_title("Vehicle Depth Distribution") ax.legend() ax.tick_params(axis="x", rotation=30) # C) Depth comparison (pedestrian) ax = axes[0, 2] for split_name, s, color in [("Train", train_cls, "steelblue"), ("Val", val_cls, "coral")]: ped = s.get("pedestrian", {}) dr = ped.get("depth_ranges", {}) if dr: labels = list(dr.keys()) vals = [dr[k] for k in labels] total = max(sum(vals), 1) props = [v / total * 100 for v in vals] ax.plot(labels, props, "o-", label=split_name, color=color) ax.set_xlabel("Depth Range") ax.set_ylabel("% of pedestrians") ax.set_title("Pedestrian Depth Distribution") ax.legend() ax.tick_params(axis="x", rotation=30) # D-F) Dimension comparison for key classes for idx, cls_name in enumerate(["vehicle", "pedestrian", "bicycle"]): ax = axes[1, idx] for split_name, s, color in [("Train", train_cls, "steelblue"), ("Val", val_cls, "coral")]: cls_info = s.get(cls_name, {}) dims = cls_info.get("dimensions_m", {}) if dims: dim_labels = ["Length", "Height", "Width"] means = [dims[d.lower()]["mean"] for d in dim_labels] stds = [dims[d.lower()]["std"] for d in dim_labels] x_pos = np.arange(len(dim_labels)) offset = -0.15 if split_name == "Train" else 0.15 ax.bar(x_pos + offset, means, 0.3, yerr=stds, label=split_name, color=color, alpha=0.8, capsize=3) ax.set_xticks(np.arange(3)) ax.set_xticklabels(["Length", "Height", "Width"]) ax.set_ylabel("Meters") ax.set_title(f"{cls_name} Dimensions") ax.legend(fontsize=8) plt.tight_layout() plt.savefig(os.path.join(output_dir, "09_train_vs_val.png"), dpi=150, bbox_inches="tight") plt.close() def plot_vehicle_subtype(profiler: 'DatasetProfiler', summary: dict, output_dir: str): """Plot 10: Large vehicle (大车) vs Small vehicle (小车) comparative analysis.""" vs = summary.get("vehicle_subtype", {}) lv_count = vs.get("large_vehicle", {}).get("count", 0) sv_count = vs.get("small_vehicle", {}).get("count", 0) if lv_count + sv_count == 0: return fig = plt.figure(figsize=(20, 14)) gs = gridspec.GridSpec(3, 4, hspace=0.4, wspace=0.35) fig.suptitle( f"Vehicle Subtype Analysis (Large vs Small) — {profiler.split_name}\n" f"Threshold: length > {LARGE_VEHICLE_L_THRESH:.0f}m AND height > {LARGE_VEHICLE_H_THRESH:.0f}m", fontsize=13, fontweight="bold") subtypes = [(VEH_LARGE, "vehicle_large", "Large vehicle", "#e74c3c"), (VEH_SMALL, "vehicle_small", "Small vehicle", "#3498db")] # ── Row 0, Col 0: Count comparison pie ── ax = fig.add_subplot(gs[0, 0]) pie_vals = [lv_count, sv_count] pie_labels = [f"Large\n{lv_count:,}", f"Small\n{sv_count:,}"] ax.pie(pie_vals, labels=pie_labels, autopct="%1.1f%%", colors=["#e74c3c", "#3498db"], startangle=90) ax.set_title("Count Distribution") # ── Row 0, Cols 1-3: Dimension histograms (L, H, W) ── dim_configs = [ ("dim_l", "Length (m)", 1), ("dim_h", "Height (m)", 2), ("dim_w", "Width (m)", 3), ] for attr, xlabel, col in dim_configs: ax = fig.add_subplot(gs[0, col]) for sub_id, sub_name, label, color in subtypes: vals = getattr(profiler, attr).get(sub_id, []) if vals: ax.hist(np.array(vals), bins=50, color=color, alpha=0.6, label=f"{label} (μ={np.mean(vals):.2f}m)", density=True) ax.set_xlabel(xlabel) ax.set_ylabel("Density") ax.set_title(f"{xlabel} Distribution") ax.legend(fontsize=7) # Draw threshold lines if attr == "dim_l": ax.axvline(LARGE_VEHICLE_L_THRESH, color="gray", linestyle="--", alpha=0.7, label=f"threshold ({LARGE_VEHICLE_L_THRESH}m)") elif attr == "dim_h": ax.axvline(LARGE_VEHICLE_H_THRESH, color="gray", linestyle="--", alpha=0.7, label=f"threshold ({LARGE_VEHICLE_H_THRESH}m)") # ── Row 1: Depth distributions ── for ci, (sub_id, sub_name, label, color) in enumerate(subtypes): ax = fig.add_subplot(gs[1, ci * 2: ci * 2 + 2]) d = profiler.depth.get(sub_id, []) if d: d = np.array(d) ax.hist(np.clip(d, 0, 150), bins=80, color=color, edgecolor="none", alpha=0.8) ax.axvline(np.median(d), color="black", linestyle="--", label=f"median={np.median(d):.1f}m") ax.axvline(np.mean(d), color="orange", linestyle="--", label=f"mean={np.mean(d):.1f}m") ax.legend(fontsize=8) ax.set_xlabel("Depth (m)") ax.set_ylabel("Count") ax.set_title(f"{label} — Depth (N={len(d):,})") # ── Row 2, Cols 0-1: Depth range bar comparison ── ax = fig.add_subplot(gs[2, 0:2]) depth_range_keys = ["0-10m", "10-20m", "20-30m", "30-50m", "50-80m", "80-120m", ">120m"] x_pos = np.arange(len(depth_range_keys)) bar_w = 0.35 for offset, (sub_id, sub_name, label, color) in enumerate(subtypes): info = summary["per_class"].get(sub_name, {}) dr = info.get("depth_ranges", {}) total = max(sum(dr.values()), 1) if dr else 1 vals = [dr.get(k, 0) / total * 100 for k in depth_range_keys] ax.bar(x_pos + offset * bar_w, vals, bar_w, label=label, color=color, alpha=0.8) ax.set_xticks(x_pos + bar_w / 2) ax.set_xticklabels(depth_range_keys, rotation=20, fontsize=8) ax.set_ylabel("% within subtype") ax.set_title("Depth Range Distribution") ax.legend(fontsize=8) # ── Row 2, Cols 2-3: Bird's eye view scatter ── ax = fig.add_subplot(gs[2, 2:4]) for sub_id, sub_name, label, color in subtypes: x = profiler.x3d.get(sub_id, []) z = profiler.depth.get(sub_id, []) if x and z: x, z = np.array(x), np.array(z) idx = np.random.choice(len(x), min(5000, len(x)), replace=False) ax.scatter(x[idx], z[idx], s=1, alpha=0.3, c=color, label=label) ax.set_xlabel("Lateral X (m)") ax.set_ylabel("Depth Z (m)") ax.set_title("Bird's Eye View (X vs Z)") ax.set_xlim(-60, 60) ax.set_ylim(0, 150) ax.grid(True, alpha=0.3) ax.legend(fontsize=8, markerscale=6) plt.savefig(os.path.join(output_dir, "10_vehicle_subtype.png"), dpi=150, bbox_inches="tight") plt.close() def plot_vehicle_date_distribution(profiler: 'DatasetProfiler', summary: dict, output_dir: str): """Plot 11: Vehicle frame counts + per-vehicle date frame counts.""" dist = summary.get("vehicle_date_distribution", {}) if not dist: return vehicle_counts = list(dist.get("vehicle", {}).get("image_counts", {}).items()) dates_per_vehicle = dist.get("dates_per_vehicle", {}) subtype_per_vehicle = dist.get("vehicle_subtype_per_vehicle", {}) if not vehicle_counts: return vehicle_names = [k for k, _ in vehicle_counts] n_vehicles = len(vehicle_names) ncols = 2 if n_vehicles > 1 else 1 n_vehicle_rows = max(1, math.ceil(n_vehicles / ncols)) n_pie_rows = max(1, math.ceil(n_vehicles / ncols)) fig_h = 6.0 + 3.8 * n_vehicle_rows + 3.6 * n_pie_rows fig = plt.figure(figsize=(8 * ncols, fig_h)) gs = gridspec.GridSpec(2 + n_pie_rows + n_vehicle_rows, ncols, hspace=0.60, wspace=0.30) missing_n = dist.get("missing_vehicle_date_images", 0) fig.suptitle( f"Vehicle / Date Distribution — {profiler.split_name}\n" f"missing vehicle/date paths: {missing_n:,}", fontsize=14, fontweight="bold", ) def _plot_bar(ax, items, title, ylabel, color, rotate=30): if not items: ax.set_title(f"{title} (no data)") ax.axis("off") return labels = [k for k, _ in items] values = [v for _, v in items] xpos = np.arange(len(items)) ax.bar(xpos, values, color=color, alpha=0.88) ax.set_xticks(xpos) ax.set_xticklabels(labels, rotation=rotate, ha="right", fontsize=8) ax.set_ylabel(ylabel) ax.set_title(title) ax.grid(axis="y", alpha=0.25) y_pad = max(values) * 0.01 if max(values) > 0 else 0.1 for x, val in enumerate(values): ax.text(x, val + y_pad, f"{val:,}", ha="center", va="bottom", fontsize=8) ax_frames = fig.add_subplot(gs[0, :]) _plot_bar( ax_frames, vehicle_counts, "Image Frames per Vehicle", "Image Frames", "#4c78a8", rotate=20 if len(vehicle_counts) <= 8 else 35, ) for idx, vehicle_id in enumerate(vehicle_names): row = 1 + idx // ncols col = idx % ncols ax_pie = fig.add_subplot(gs[row, col]) subtype_info = subtype_per_vehicle.get(vehicle_id, {}) lv = subtype_info.get("large_vehicle", {}).get("count", 0) sv = subtype_info.get("small_vehicle", {}).get("count", 0) uv = subtype_info.get("unclassified_vehicle_objects", 0) pie_vals = [v for v in [lv, sv, uv] if v > 0] pie_labels = [] pie_colors = [] if lv > 0: pie_labels.append(f"Large\n{lv:,}") pie_colors.append("#e74c3c") if sv > 0: pie_labels.append(f"Small\n{sv:,}") pie_colors.append("#3498db") if uv > 0: pie_labels.append(f"Unclassified\n{uv:,}") pie_colors.append("#b0b7c3") if pie_vals: ax_pie.pie( pie_vals, labels=pie_labels, autopct="%1.1f%%", startangle=90, colors=pie_colors, textprops={"fontsize": 8}, ) else: ax_pie.text(0.5, 0.5, "no subtype data", ha="center", va="center", fontsize=9) ax_pie.set_title(f"{vehicle_id} — Large/Small Vehicle Mix", fontsize=10) palette = plt.cm.tab20(np.linspace(0, 1, max(n_vehicles, 2))) for idx, vehicle_id in enumerate(vehicle_names): row = 1 + n_pie_rows + idx // ncols col = idx % ncols ax = fig.add_subplot(gs[row, col]) date_items = list(dates_per_vehicle.get(vehicle_id, {}).get("image_counts", {}).items()) _plot_bar( ax, date_items, f"{vehicle_id} — Image Frames by Date", "Image Frames", palette[idx % len(palette)], rotate=35 if len(date_items) <= 10 else 45, ) subtype_info = subtype_per_vehicle.get(vehicle_id, {}) lv = subtype_info.get("large_vehicle", {}).get("count", 0) sv = subtype_info.get("small_vehicle", {}).get("count", 0) uv = subtype_info.get("unclassified_vehicle_objects", 0) ax.text( 0.98, 0.96, f"large={lv:,} small={sv:,} unclassified={uv:,}", transform=ax.transAxes, ha="right", va="top", fontsize=8, bbox=dict(boxstyle="round", facecolor="white", alpha=0.85), ) plt.savefig(os.path.join(output_dir, "11_vehicle_date_distribution.png"), dpi=150, bbox_inches="tight") plt.close() # ────────────────── Report Generation ─────────────────── def generate_text_report(summary: dict, output_path: str): """Generate a human-readable text report.""" lines = [] lines.append("=" * 90) lines.append(f" DATASET PROFILING REPORT — {summary['overview']['split']}") lines.append("=" * 90) # 1. Overview ov = summary["overview"] lines.append(f"\n{'─' * 40} Overview {'─' * 40}") lines.append(f" Analysis mode: {ov.get('analysis_mode', 'original'):>10}") lines.append(f" Total images: {ov['total_images']:>10,}") lines.append(f" Labels found: {ov['labels_found']:>10,}") lines.append(f" Labels missing: {ov['labels_missing']:>10,}") lines.append(f" Empty labels: {ov['empty_labels']:>10,}") lines.append(f" Total objects: {ov['total_objects']:>10,}") lines.append(f" Format distribution: {ov['format_distribution']}") # ROI info (if present) roi_info = ov.get("roi_info") if roi_info: lines.append(f"\n{'─' * 40} ROI Info {'─' * 40}") lines.append(f" Original image size: {roi_info['ori_img_size'][0]}×{roi_info['ori_img_size'][1]}") lines.append(f" ROI size (config): {roi_info['roi_size'][0]}×{roi_info['roi_size'][1]}") lines.append(f" Pixel stats size: {roi_info['img_size_used'][0]}×{roi_info['img_size_used'][1]}") lines.append(f" Calib found: {roi_info['calib_found']:>10,}") lines.append(f" Calib missing: {roi_info['calib_missing']:>10,}") lines.append(f" Objects filtered (outside ROI): {roi_info['objects_filtered_outside_roi']:>8,}") # 2. Per-image density pid = summary["per_image_density"] lines.append(f"\n{'─' * 38} Per-Image Density {'─' * 33}") for k, v in pid.items(): lines.append(f" {k}: mean={v['mean']:.1f} median={v['median']:.1f} " f"p5={v['p5']:.0f} p95={v['p95']:.0f} max={v['max']:.0f}") # 2.5 Vehicle / date distribution vdd = summary.get("vehicle_date_distribution") if vdd: veh = vdd.get("vehicle", {}) dates_per_vehicle = vdd.get("dates_per_vehicle", {}) subtype_per_vehicle = vdd.get("vehicle_subtype_per_vehicle", {}) lines.append(f"\n{'─' * 34} Vehicle / Date Distribution {'─' * 25}") lines.append(f" Unique vehicles: {veh.get('unique_count', 0):>10,}") lines.append(f" Missing vehicle/date: {vdd.get('missing_vehicle_date_images', 0):>10,} images") lines.append(" Image frames per vehicle:") for vehicle_id, image_count in veh.get("image_counts", {}).items(): unique_dates = dates_per_vehicle.get(vehicle_id, {}).get("unique_dates", 0) subtype_info = subtype_per_vehicle.get(vehicle_id, {}) lv = subtype_info.get("large_vehicle", {}).get("count", 0) sv = subtype_info.get("small_vehicle", {}).get("count", 0) uv = subtype_info.get("unclassified_vehicle_objects", 0) lines.append( f" {vehicle_id}: images={image_count:,}, dates={unique_dates}, " f"large={lv:,}, small={sv:,}, unclassified={uv:,}" ) if dates_per_vehicle: lines.append(" Image frames per date within each vehicle:") for vehicle_id, info in dates_per_vehicle.items(): lines.append(f" {vehicle_id}:") image_counts = info.get("image_counts", {}) if not image_counts: lines.append(" (no valid date extracted)") continue for date_str, image_count in image_counts.items(): lines.append(f" {date_str}: {image_count:,}") # 3. Per-class lines.append(f"\n{'─' * 38} Per-Class Statistics {'─' * 31}") for name, info in summary["per_class"].items(): lines.append(f"\n ▶ {name} (N={info['count']:,}, proportion={info['proportion']:.2%})") if "bbox2d" in info: bb = info["bbox2d"] lines.append(f" 2D box: W={bb['width_px']['mean']:.1f}±{bb['width_px']['std']:.1f}px " f"H={bb['height_px']['mean']:.1f}±{bb['height_px']['std']:.1f}px " f"AR={bb['aspect_ratio']['mean']:.2f}±{bb['aspect_ratio']['std']:.2f}") if "depth_m" in info: dp = info["depth_m"] lines.append(f" Depth: mean={dp['mean']:.1f}m median={dp['median']:.1f}m " f"std={dp['std']:.1f}m range=[{dp['min']:.1f}, {dp['max']:.1f}]m") if "depth_ranges" in info: dr = info["depth_ranges"] lines.append(f" Depth ranges: {dr}") if "dimensions_m" in info: dm = info["dimensions_m"] lines.append(f" 3D dims: L={dm['length']['mean']:.2f}±{dm['length']['std']:.2f}m " f"H={dm['height']['mean']:.2f}±{dm['height']['std']:.2f}m " f"W={dm['width']['mean']:.2f}±{dm['width']['std']:.2f}m") if "rot_y_rad" in info: ry = info["rot_y_rad"] lines.append(f" Rot_y: mean={ry['mean']:.2f} std={ry['std']:.2f} " f"range=[{ry['min']:.2f}, {ry['max']:.2f}] rad") if "lateral_x3d_m" in info: x = info["lateral_x3d_m"] lines.append(f" Lateral: mean={x['mean']:.1f}m std={x['std']:.1f}m " f"range=[{x['min']:.1f}, {x['max']:.1f}]m") # 4. Vehicle subtype (大车 vs 小车) vs = summary.get("vehicle_subtype") if vs: thresh = vs["threshold"] lv = vs["large_vehicle"] sv = vs["small_vehicle"] total_veh = vs["total_vehicles"] classifiable = vs["classifiable_vehicles"] unclassified = vs["unclassified_vehicles"] lines.append(f"\n{'─' * 34} Vehicle Subtype (大车 vs 小车) {'─' * 23}") lines.append(f" Threshold: length > {thresh['length_gt_m']:.1f}m AND " f"height > {thresh['height_gt_m']:.1f}m → large vehicle (大车)") lines.append(f" Total vehicles: {total_veh:>10,}") lines.append(f" 3D-classifiable (50-col):{classifiable:>10,} ({classifiable/max(total_veh,1):.1%} of vehicles)") lines.append(f" 2D-only (unclassified): {unclassified:>10,} ({unclassified/max(total_veh,1):.1%} of vehicles)") lines.append(f" 大车 (large): {lv['count']:>10,} ({lv['proportion_of_classifiable']:.1%} of 3D-classifiable)") lines.append(f" 小车 (small): {sv['count']:>10,} ({sv['proportion_of_classifiable']:.1%} of 3D-classifiable)") # Detailed stats from per_class for sub_name in ["vehicle_large", "vehicle_small"]: info = summary["per_class"].get(sub_name) if info is None: continue label = "大车" if sub_name == "vehicle_large" else "小车" lines.append(f"\n ▶ {sub_name} [{label}] (N={info['count']:,}, " f"{info.get('proportion_of_vehicle', 0):.1%} of 3D-classifiable)") if "bbox2d" in info: bb = info["bbox2d"] lines.append(f" 2D box: W={bb['width_px']['mean']:.1f}±{bb['width_px']['std']:.1f}px " f"H={bb['height_px']['mean']:.1f}±{bb['height_px']['std']:.1f}px " f"AR={bb['aspect_ratio']['mean']:.2f}±{bb['aspect_ratio']['std']:.2f}") if "depth_m" in info: dp = info["depth_m"] lines.append(f" Depth: mean={dp['mean']:.1f}m median={dp['median']:.1f}m " f"std={dp['std']:.1f}m range=[{dp['min']:.1f}, {dp['max']:.1f}]m") if "depth_ranges" in info: lines.append(f" Depth ranges: {info['depth_ranges']}") if "dimensions_m" in info: dm = info["dimensions_m"] lines.append(f" 3D dims: L={dm['length']['mean']:.2f}±{dm['length']['std']:.2f}m " f"H={dm['height']['mean']:.2f}±{dm['height']['std']:.2f}m " f"W={dm['width']['mean']:.2f}±{dm['width']['std']:.2f}m") # 5. Face visibility if summary.get("face_visibility"): lines.append(f"\n{'─' * 37} Face Visibility {'─' * 36}") for name, finfo in summary["face_visibility"].items(): lines.append(f"\n ▶ {name} (N={finfo['total_objects']:,})") rates = finfo["face_visibility_rate"] lines.append(f" Visibility: front={rates['front']:.1%} rear={rates['rear']:.1%} " f"left={rates['left']:.1%} right={rates['right']:.1%}") # 5. Cut type if summary.get("cut_type"): lines.append(f"\n{'─' * 38} Cut Type {'─' * 41}") for k, v in summary["cut_type"].items(): lines.append(f" {k}: {v:,}") # 6. Data quality dq = summary["data_quality"] lines.append(f"\n{'─' * 38} Data Quality {'─' * 38}") lines.append(f" NaN 3D objects: {dq['nan_3d_objects']:>8,}") lines.append(f" Invalid depth (≤0): {dq['invalid_depth_le0']:>8,}") lines.append(f" Outlier depth (>200m): {dq['outlier_depth_gt200m']:>8,}") lines.append(f" Tiny box (<8px): {dq['tiny_box_lt8px']:>8,}") lines.append("") report_text = "\n".join(lines) with open(output_path, "w") as f: f.write(report_text) print(report_text) # ──────────────────── Main ────────────────────────────── def run_profiling(data_cfg: dict, split: str, max_files: int, img_size: tuple, output_dir: str, analysis_mode: str = "original", ori_img_size: tuple = (1920, 1080), roi_size: tuple = (1920, 960), num_workers: int = 1) -> dict: """Run profiling for a single split.""" img_w, img_h = img_size paths = resolve_image_paths(data_cfg, split) if max_files > 0: paths = paths[:max_files] mode_str = f"mode={analysis_mode}" if analysis_mode == "roi": mode_str += f" ori={ori_img_size[0]}×{ori_img_size[1]} roi={roi_size[0]}×{roi_size[1]}" print(f"\n{'=' * 70}") print(f" Profiling [{split}] — {len(paths):,} images, img_size={img_w}×{img_h}, {mode_str}") print(f"{'=' * 70}") # Clear global ROI cache for a fresh run global _roi_cache _roi_cache = {} profiler = DatasetProfiler(split, img_w, img_h, analysis_mode=analysis_mode, ori_img_size=ori_img_size, roi_size=roi_size) if num_workers > 1 and len(paths) > 100: # ── Parallel processing with multiprocessing ── chunk_size = min(max(len(paths) // (num_workers * 4), 500), 50000) chunks = [paths[i:i + chunk_size] for i in range(0, len(paths), chunk_size)] args_list = [ (chunk, split, img_w, img_h, analysis_mode, ori_img_size, roi_size) for chunk in chunks ] print(f" Using {num_workers} workers, {len(chunks)} chunks " f"(~{chunk_size:,} images/chunk)") with Pool(num_workers) as pool: for ci, partial_profiler in enumerate(pool.imap_unordered(_process_chunk, args_list)): profiler.merge(partial_profiler) done_approx = min((ci + 1) * chunk_size, len(paths)) pct = min(done_approx / len(paths) * 100, 100) print(f" [{split}] Progress: ~{done_approx:>8,}/{len(paths):,} ({pct:.0f}%)" f" objects so far: {profiler.total_objects:,}") else: # ── Sequential processing ── report_interval = max(len(paths) // 20, 1) for i, p in enumerate(paths): profiler.process_image(p) if (i + 1) % report_interval == 0: pct = (i + 1) / len(paths) * 100 print(f" [{split}] Progress: {i + 1:>8,}/{len(paths):,} ({pct:.0f}%)" f" objects so far: {profiler.total_objects:,}") summary = profiler.summarize() # Generate outputs split_dir = os.path.join(output_dir, split) os.makedirs(split_dir, exist_ok=True) # Text report generate_text_report(summary, os.path.join(split_dir, "report.txt")) # JSON summary with open(os.path.join(split_dir, "summary.json"), "w") as f: json.dump(summary, f, indent=2, default=str) # Plots plot_overview(summary, split_dir) plot_2d_bbox_analysis(profiler, split_dir) plot_3d_depth_analysis(profiler, split_dir) plot_3d_dimensions(profiler, split_dir) plot_rotation_analysis(profiler, split_dir) plot_yaw_bin_analysis(profiler, split_dir) plot_face_visibility(profiler, summary, split_dir) plot_lateral_depth_scatter(profiler, split_dir) plot_depth_vs_box_size(profiler, split_dir) plot_vehicle_subtype(profiler, summary, split_dir) plot_vehicle_date_distribution(profiler, summary, split_dir) return summary, profiler def main(): parser = argparse.ArgumentParser(description="Dataset Profiling for YOLOv5-3D") parser.add_argument("--data", type=str, default="data/mono3d.yaml", help="Path to data YAML config") parser.add_argument("--split", type=str, default="both", choices=["train", "val", "both"], help="Which split(s) to profile") parser.add_argument("--max-files", type=int, default=0, help="Max files per split (0=all)") parser.add_argument("--output-dir", type=str, default="dataset_profiling_results", help="Output directory for reports and plots") parser.add_argument("--img-size", type=int, nargs=2, default=None, help="Image size [width height] for pixel-unit conversion. " "In 'original' mode defaults to ori_img_size from YAML (1920 1080); " "in 'roi' mode defaults to roi size from YAML (1920 960).") parser.add_argument("--analysis-mode", type=str, default="original", choices=["original", "roi"], help="Analysis mode: 'original' analyzes labels against the full original " "image; 'roi' computes per-camera ROI from calibration, filters objects " "outside ROI, clips boundary objects, and re-normalizes to ROI space.") parser.add_argument("--workers", type=int, default=1, help="Number of parallel workers (default: 1 = sequential). " "Use --workers 8 or --workers 0 (auto-detect CPU count) " "for faster processing on large datasets.") args = parser.parse_args() # Load data config with open(args.data, "r") as f: data_cfg = yaml.safe_load(f) # Read ROI config from YAML (with sensible defaults) ori_img_size = tuple(data_cfg.get("ori_img_size", [1920, 1080])) roi_size = tuple(data_cfg.get("roi", [1920, 960])) # Determine img_size for pixel conversion if args.img_size is not None: img_size = tuple(args.img_size) elif args.analysis_mode == "roi": img_size = roi_size # In ROI mode, stats use ROI dimensions else: img_size = ori_img_size # In original mode, stats use original image dimensions # Append analysis mode to output dir to avoid overwriting output_dir = args.output_dir if not output_dir.endswith(f"_{args.analysis_mode}"): output_dir = f"{output_dir}_{args.analysis_mode}" os.makedirs(output_dir, exist_ok=True) print(f" Analysis mode: {args.analysis_mode}") print(f" Original img: {ori_img_size[0]}×{ori_img_size[1]}") if args.analysis_mode == "roi": print(f" ROI size (cfg): {roi_size[0]}×{roi_size[1]}") print(f" Pixel conversion: {img_size[0]}×{img_size[1]}") print(f" Output dir: {output_dir}") # Determine number of workers num_workers = args.workers if num_workers <= 0: num_workers = cpu_count() or 1 if num_workers > 1: print(f" Workers: {num_workers}") summaries = {} profilers = {} splits = ["train", "val"] if args.split == "both" else [args.split] for split in splits: if not data_cfg.get(split): print(f"Skipping [{split}] — not defined in {args.data}") continue s, p = run_profiling(data_cfg, split, args.max_files, img_size, output_dir, analysis_mode=args.analysis_mode, ori_img_size=ori_img_size, roi_size=roi_size, num_workers=num_workers) summaries[split] = s profilers[split] = p # Comparison plot if "train" in summaries and "val" in summaries: plot_train_vs_val_comparison(summaries["train"], summaries["val"], output_dir) print(f"\n ✓ Train vs Val comparison → {output_dir}/09_train_vs_val.png") print(f"\n{'=' * 70}") print(f" All outputs saved to: {output_dir}/") print(f"{'=' * 70}") if __name__ == "__main__": main()