2316 lines
99 KiB
Python
Executable File
2316 lines
99 KiB
Python
Executable File
#!/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()
|