Files
yolov26_3d/eval_tools/heading_analysis/analyze_heading_errors.py
2026-06-24 09:35:46 +08:00

424 lines
18 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Heading Error Analysis Tool
This tool performs comprehensive analysis of heading errors between two models,
focusing on understanding why one model has larger heading errors.
Usage:
python eval_tools/analyze_heading_errors.py \\
--common-matches eval_results/common_matches.json \\
--model1-matches eval_results/model1/detailed_3d_matches.json \\
--model2-matches eval_results/model2/detailed_3d_matches.json \\
--model1-name "mono3d" \\
--model2-name "yolov5s-300w" \\
--output-dir heading_analysis_results
"""
import argparse
import json
import numpy as np
import matplotlib.pyplot as plt
from pathlib import Path
from collections import defaultdict
import pandas as pd
from scipy import stats
class HeadingErrorAnalyzer:
"""Analyze heading errors between two models."""
def __init__(self, common_matches_data, model1_matches, model2_matches,
model1_name="Model1", model2_name="Model2"):
"""
Initialize analyzer.
Args:
common_matches_data: dict, common matches data
model1_matches: dict, model1 detailed matches
model2_matches: dict, model2 detailed matches
model1_name: str, model1 name
model2_name: str, model2 name
"""
self.common_matches_data = common_matches_data
self.model1_matches = model1_matches
self.model2_matches = model2_matches
self.model1_name = model1_name
self.model2_name = model2_name
# Extract heading errors for common matches
self.data = self._extract_heading_data()
def _extract_heading_data(self):
"""Extract heading error data for all common matches."""
data = {
'model1': defaultdict(list),
'model2': defaultdict(list),
'common': defaultdict(list)
}
common_matches = self.common_matches_data['common_matches']
for case_name, frames in common_matches.items():
for frame_name, classes in frames.items():
for class_name, match_list in classes.items():
for match_info in match_list:
# Get match indices
m1_idx = match_info['model1_idx']
m2_idx = match_info['model2_idx']
# Get match data
m1_match = self.model1_matches[case_name][frame_name][class_name][m1_idx]
m2_match = self.model2_matches[case_name][frame_name][class_name][m2_idx]
# Extract information
item = {
'case': case_name,
'frame': frame_name,
'class': class_name,
'gt_rotation': m1_match.get('gt_rotation', 0),
'model1_rotation': m1_match.get('det_rotation', 0),
'model2_rotation': m2_match.get('det_rotation', 0),
'model1_error': m1_match['errors']['heading'],
'model2_error': m2_match['errors']['heading'],
'lateral_dist': m1_match['distance']['lateral'],
'longitudinal_dist': m1_match['distance']['longitudinal'],
'iou': m1_match['iou'],
'confidence': m1_match['confidence']
}
data['model1'][class_name].append(m1_match['errors']['heading'])
data['model2'][class_name].append(m2_match['errors']['heading'])
data['common'][class_name].append(item)
return data
def generate_distribution_analysis(self, output_dir):
"""Generate distribution analysis and plots."""
print("\n" + "="*80)
print("Heading Error Distribution Analysis")
print("="*80)
output_dir = Path(output_dir) / 'distribution'
output_dir.mkdir(parents=True, exist_ok=True)
results = {}
for class_name in self.data['model1'].keys():
m1_errors = np.array(self.data['model1'][class_name])
m2_errors = np.array(self.data['model2'][class_name])
# Calculate statistics
stats_data = {
'class': class_name,
'count': len(m1_errors),
'model1': {
'mean': float(np.mean(m1_errors)),
'median': float(np.median(m1_errors)),
'std': float(np.std(m1_errors)),
'p50': float(np.percentile(m1_errors, 50)),
'p75': float(np.percentile(m1_errors, 75)),
'p90': float(np.percentile(m1_errors, 90)),
'p95': float(np.percentile(m1_errors, 95)),
'p99': float(np.percentile(m1_errors, 99))
},
'model2': {
'mean': float(np.mean(m2_errors)),
'median': float(np.median(m2_errors)),
'std': float(np.std(m2_errors)),
'p50': float(np.percentile(m2_errors, 50)),
'p75': float(np.percentile(m2_errors, 75)),
'p90': float(np.percentile(m2_errors, 90)),
'p95': float(np.percentile(m2_errors, 95)),
'p99': float(np.percentile(m2_errors, 99))
}
}
results[class_name] = stats_data
# Print results
print(f"\n{class_name.upper()} (n={stats_data['count']:,}):")
print(f" {self.model1_name}:")
print(f" Mean: {stats_data['model1']['mean']:.4f} rad")
print(f" Median: {stats_data['model1']['median']:.4f} rad")
print(f" P90: {stats_data['model1']['p90']:.4f} rad")
print(f" P95: {stats_data['model1']['p95']:.4f} rad")
print(f" {self.model2_name}:")
print(f" Mean: {stats_data['model2']['mean']:.4f} rad")
print(f" Median: {stats_data['model2']['median']:.4f} rad")
print(f" P90: {stats_data['model2']['p90']:.4f} rad")
print(f" P95: {stats_data['model2']['p95']:.4f} rad")
print(f" Change:")
print(f" Mean: +{(stats_data['model2']['mean'] - stats_data['model1']['mean']):.4f} rad "
f"({((stats_data['model2']['mean'] / stats_data['model1']['mean'] - 1) * 100):.1f}%)")
# Create plots
fig, axes = plt.subplots(2, 2, figsize=(15, 12))
fig.suptitle(f'Heading Error Distribution - {class_name}', fontsize=16)
# Histogram
axes[0, 0].hist(m1_errors, bins=50, alpha=0.5, label=self.model1_name, density=True)
axes[0, 0].hist(m2_errors, bins=50, alpha=0.5, label=self.model2_name, density=True)
axes[0, 0].set_xlabel('Heading Error (rad)')
axes[0, 0].set_ylabel('Density')
axes[0, 0].set_title('Histogram')
axes[0, 0].legend()
axes[0, 0].grid(True, alpha=0.3)
# CDF
m1_sorted = np.sort(m1_errors)
m2_sorted = np.sort(m2_errors)
axes[0, 1].plot(m1_sorted, np.arange(len(m1_sorted)) / len(m1_sorted), label=self.model1_name)
axes[0, 1].plot(m2_sorted, np.arange(len(m2_sorted)) / len(m2_sorted), label=self.model2_name)
axes[0, 1].set_xlabel('Heading Error (rad)')
axes[0, 1].set_ylabel('Cumulative Probability')
axes[0, 1].set_title('Cumulative Distribution Function')
axes[0, 1].legend()
axes[0, 1].grid(True, alpha=0.3)
# Box plot
axes[1, 0].boxplot([m1_errors, m2_errors], labels=[self.model1_name, self.model2_name])
axes[1, 0].set_ylabel('Heading Error (rad)')
axes[1, 0].set_title('Box Plot')
axes[1, 0].grid(True, alpha=0.3)
# Q-Q plot
stats.probplot(m2_errors - m1_errors, dist="norm", plot=axes[1, 1])
axes[1, 1].set_title('Q-Q Plot (Error Difference)')
axes[1, 1].grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig(output_dir / f'{class_name}_distribution.png', dpi=150)
plt.close()
# Save results
with open(output_dir / 'statistics.json', 'w') as f:
json.dump(results, f, indent=2)
print(f"\n✓ Distribution analysis saved to: {output_dir}")
return results
def generate_distance_analysis(self, output_dir):
"""Analyze heading errors by distance ranges."""
print("\n" + "="*80)
print("Heading Error by Distance Analysis")
print("="*80)
output_dir = Path(output_dir) / 'distance'
output_dir.mkdir(parents=True, exist_ok=True)
# Define distance ranges
long_ranges = [(0, 20), (20, 40), (40, 60), (60, 80), (80, 100)]
lat_ranges = [(-30, -10), (-10, 0), (0, 10), (10, 30)]
for class_name, items in self.data['common'].items():
# Analyze by longitudinal distance
long_stats = {}
for range_start, range_end in long_ranges:
range_key = f"{range_start}-{range_end}m"
m1_errors = []
m2_errors = []
for item in items:
dist = item['longitudinal_dist']
if range_start <= dist < range_end:
m1_errors.append(item['model1_error'])
m2_errors.append(item['model2_error'])
if len(m1_errors) > 0:
long_stats[range_key] = {
'count': len(m1_errors),
'model1_mean': float(np.mean(m1_errors)),
'model2_mean': float(np.mean(m2_errors)),
'diff': float(np.mean(m2_errors) - np.mean(m1_errors))
}
# Plot longitudinal distance analysis
if long_stats:
fig, ax = plt.subplots(figsize=(12, 6))
ranges = list(long_stats.keys())
m1_means = [long_stats[r]['model1_mean'] for r in ranges]
m2_means = [long_stats[r]['model2_mean'] for r in ranges]
x = np.arange(len(ranges))
width = 0.35
ax.bar(x - width/2, m1_means, width, label=self.model1_name)
ax.bar(x + width/2, m2_means, width, label=self.model2_name)
ax.set_xlabel('Longitudinal Distance Range')
ax.set_ylabel('Mean Heading Error (rad)')
ax.set_title(f'Heading Error by Longitudinal Distance - {class_name}')
ax.set_xticks(x)
ax.set_xticklabels(ranges)
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig(output_dir / f'{class_name}_longitudinal.png', dpi=150)
plt.close()
print(f"\n{class_name.upper()} - Longitudinal Distance:")
for range_key, data in long_stats.items():
print(f" {range_key}: {self.model1_name}={data['model1_mean']:.4f}, "
f"{self.model2_name}={data['model2_mean']:.4f}, "
f"diff={data['diff']:+.4f} (n={data['count']})")
print(f"\n✓ Distance analysis saved to: {output_dir}")
def identify_bad_cases(self, output_dir, threshold=1.0):
"""Identify cases with large heading errors."""
print("\n" + "="*80)
print(f"Identifying Bad Cases (threshold > {threshold} rad)")
print("="*80)
output_dir = Path(output_dir) / 'bad_cases'
output_dir.mkdir(parents=True, exist_ok=True)
bad_cases = []
for class_name, items in self.data['common'].items():
for item in items:
# Check if either model has large error
if item['model2_error'] > threshold:
bad_cases.append({
'case': item['case'],
'frame': item['frame'],
'class': class_name,
'gt_rotation': item['gt_rotation'],
'model1_rotation': item['model1_rotation'],
'model2_rotation': item['model2_rotation'],
'model1_error': item['model1_error'],
'model2_error': item['model2_error'],
'error_increase': item['model2_error'] - item['model1_error'],
'longitudinal_dist': item['longitudinal_dist'],
'lateral_dist': item['lateral_dist'],
'iou': item['iou'],
'confidence': item['confidence']
})
# Sort by model2 error
bad_cases.sort(key=lambda x: x['model2_error'], reverse=True)
# Save to CSV
if bad_cases:
df = pd.DataFrame(bad_cases)
csv_path = output_dir / 'bad_cases.csv'
df.to_csv(csv_path, index=False)
print(f"\nFound {len(bad_cases)} bad cases:")
print(f" Saved to: {csv_path}")
print(f"\nTop 10 worst cases:")
print(df.head(10).to_string(index=False))
else:
print(f"\nNo bad cases found with threshold > {threshold} rad")
print(f"\n✓ Bad cases analysis saved to: {output_dir}")
return bad_cases
def generate_summary_report(self, output_dir):
"""Generate summary text report."""
report_path = output_dir / 'heading_analysis_summary.txt'
with open(report_path, 'w') as f:
f.write("="*80 + "\n")
f.write("HEADING ERROR ANALYSIS SUMMARY\n")
f.write("="*80 + "\n\n")
f.write(f"Model 1: {self.model1_name}\n")
f.write(f"Model 2: {self.model2_name}\n\n")
f.write("Overall Statistics by Class:\n")
f.write("-"*80 + "\n\n")
for class_name in self.data['model1'].keys():
m1_errors = np.array(self.data['model1'][class_name])
m2_errors = np.array(self.data['model2'][class_name])
f.write(f"{class_name.upper()} (n={len(m1_errors):,}):\n")
f.write(f" {self.model1_name}:\n")
f.write(f" Mean: {np.mean(m1_errors):.4f} rad ({np.degrees(np.mean(m1_errors)):.2f}°)\n")
f.write(f" Median: {np.median(m1_errors):.4f} rad ({np.degrees(np.median(m1_errors)):.2f}°)\n")
f.write(f" Std: {np.std(m1_errors):.4f} rad\n")
f.write(f" {self.model2_name}:\n")
f.write(f" Mean: {np.mean(m2_errors):.4f} rad ({np.degrees(np.mean(m2_errors)):.2f}°)\n")
f.write(f" Median: {np.median(m2_errors):.4f} rad ({np.degrees(np.median(m2_errors)):.2f}°)\n")
f.write(f" Std: {np.std(m2_errors):.4f} rad\n")
diff_mean = np.mean(m2_errors) - np.mean(m1_errors)
pct_change = (np.mean(m2_errors) / np.mean(m1_errors) - 1) * 100
f.write(f" Change:\n")
f.write(f" Mean: +{diff_mean:.4f} rad ({pct_change:+.2f}%)\n\n")
print(f"\n✓ Summary report saved to: {report_path}")
def main():
"""Main function."""
parser = argparse.ArgumentParser(description='Analyze heading errors between two models')
parser.add_argument('--common-matches', type=str, required=True,
help='Path to common_matches.json')
parser.add_argument('--model1-matches', type=str, required=True,
help='Path to model1 detailed_3d_matches.json')
parser.add_argument('--model2-matches', type=str, required=True,
help='Path to model2 detailed_3d_matches.json')
parser.add_argument('--model1-name', type=str, default='Model1',
help='Name of model 1')
parser.add_argument('--model2-name', type=str, default='Model2',
help='Name of model 2')
parser.add_argument('--output-dir', type=str, default='heading_analysis',
help='Output directory for analysis results')
parser.add_argument('--bad-case-threshold', type=float, default=1.0,
help='Threshold for identifying bad cases (radians)')
args = parser.parse_args()
# Load data
print("Loading data...")
with open(args.common_matches, 'r') as f:
common_matches_data = json.load(f)
with open(args.model1_matches, 'r') as f:
model1_matches = json.load(f)
with open(args.model2_matches, 'r') as f:
model2_matches = json.load(f)
# Create analyzer
analyzer = HeadingErrorAnalyzer(
common_matches_data,
model1_matches,
model2_matches,
model1_name=args.model1_name,
model2_name=args.model2_name
)
# Create output directory
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
# Run analyses
print("\n" + "="*80)
print("HEADING ERROR ANALYSIS")
print("="*80)
analyzer.generate_distribution_analysis(output_dir)
analyzer.generate_distance_analysis(output_dir)
analyzer.identify_bad_cases(output_dir, threshold=args.bad_case_threshold)
analyzer.generate_summary_report(output_dir)
print("\n" + "="*80)
print("ANALYSIS COMPLETE!")
print("="*80)
print(f"\nResults saved to: {output_dir}/")
print("\nGenerated files:")
print(" - heading_analysis_summary.txt: Text summary report")
print(" - distribution/: Error distribution analysis and plots")
print(" - distance/: Distance-based analysis and plots")
print(" - bad_cases/bad_cases.csv: List of cases with large errors")
print("")
if __name__ == '__main__':
main()