Files
yolov26_3d/tools/scripts_for_gt/profiling/dataset_profiling.py
2026-06-24 09:35:46 +08:00

2316 lines
99 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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: <image_dir>.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 <vehicle>/<date>.
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()