单目3D初始代码

This commit is contained in:
zhao.zhu
2026-06-24 09:35:46 +08:00
commit 04a5895b6b
1153 changed files with 340700 additions and 0 deletions

301
eval_tools/core/eval.py Executable file
View File

@@ -0,0 +1,301 @@
#!/usr/bin/env python3
"""
YOLOv5-3D Model Evaluation Script
This script evaluates 2D and 3D detection performance of YOLOv5-3D model.
It computes metrics including Precision, Recall, AP, mAP for 2D detection,
and lateral/longitudinal errors and heading errors for 3D detection.
Usage:
# Basic usage with paths
python eval_tools/eval.py --det-path /path/to/detections --gt-path /path/to/labels --output-dir results
# With configuration file
python eval_tools/eval.py --config eval_tools/configs/eval_config.yaml
# Evaluate 2D only
python eval_tools/eval.py --det-path /path/to/detections --gt-path /path/to/labels --eval-2d-only
# Evaluate 3D only
python eval_tools/eval.py --det-path /path/to/detections --gt-path /path/to/labels --eval-3d-only
"""
import argparse
import sys
import yaml
from pathlib import Path
from datetime import datetime
# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from eval_tools.evaluator import Evaluator
def load_config(config_path):
"""Load configuration from YAML file."""
with open(config_path, 'r') as f:
config = yaml.safe_load(f)
# Replace timestamp placeholders in paths
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
if 'output' in config and 'save_path' in config['output']:
config['output']['save_path'] = config['output']['save_path'].replace('{timestamp}', timestamp)
if 'dataset' in config:
if 'det_path' in config['dataset']:
config['dataset']['det_path'] = config['dataset']['det_path'].replace('{timestamp}', timestamp)
if 'gt_path' in config['dataset']:
config['dataset']['gt_path'] = config['dataset']['gt_path'].replace('{timestamp}', timestamp)
return config
def parse_arguments():
"""Parse command line arguments."""
parser = argparse.ArgumentParser(
description='Evaluate YOLOv5-3D model detection results',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Basic evaluation
python eval_tools/eval.py --det-path /data/detections --gt-path /data/labels --output-dir results
# With custom image size
python eval_tools/eval.py --det-path /data/detections --gt-path /data/labels --img-width 3840 --img-height 2160
# Using config file
python eval_tools/eval.py --config eval_tools/configs/eval_config.yaml
# 2D evaluation only
python eval_tools/eval.py --det-path /data/detections --gt-path /data/labels --eval-2d-only
"""
)
# Path arguments
parser.add_argument('--det-path', type=str,
help='Path to detection results root directory (contains case folders)')
parser.add_argument('--gt-path', type=str,
help='Path to ground truth labels root directory (contains case folders)')
parser.add_argument('--path-depth', type=int, default=1, choices=[1, 2],
help='Directory depth: 1 = det_path/case/txt_results, 2 = det_path/level1/case/txt_results (default: 1)')
parser.add_argument('--det-format', type=str, default='auto', choices=['auto', 'json', 'txt'],
help='Detection file format: auto (default, probe json_results/ then txt_results/), json, or txt')
parser.add_argument('--gt-format', type=str, default='auto', choices=['auto', 'json', 'txt'],
help='Ground truth file format: auto (default, probe labels_json/ then labels/), json, or txt')
parser.add_argument('--output-dir', type=str, default='eval_results',
help='Output directory for evaluation results (default: eval_results)')
# Config file
parser.add_argument('--config', type=str,
help='Path to YAML configuration file')
# Image parameters
parser.add_argument('--img-width', type=int, default=1920,
help='Image width in pixels (default: 1920)')
parser.add_argument('--img-height', type=int, default=1080,
help='Image height in pixels (default: 1080)')
# Evaluation options
parser.add_argument('--eval-2d-only', action='store_true',
help='Evaluate 2D detection only')
parser.add_argument('--eval-3d-only', action='store_true',
help='Evaluate 3D detection only')
parser.add_argument('--iou-threshold', type=float, default=0.5,
help='IoU threshold for 2D matching (default: 0.5)')
parser.add_argument('--conf-threshold', type=float, default=0.5,
help='Confidence threshold for precision/recall (default: 0.5)')
parser.add_argument('--ap-method', type=str, default='voc2010',
choices=['voc2010', 'coco'],
help='AP calculation method (default: voc2010)')
parser.add_argument('--heading-tolerance', type=str, default='strict',
choices=['strict', 'relaxed', 'both'],
help='Heading error calculation mode: strict (default), relaxed (180° symmetry), or both')
parser.add_argument('--coord-system', type=str, default='camera',
choices=['camera', 'ego'],
help='3D evaluation coordinate system: camera (default) or ego')
parser.add_argument('--num-workers', type=int, default=None,
help='Number of parallel workers for evaluation (default: auto-detect)')
parser.add_argument('--roi-bottom-offset', type=int, default=None,
help='Pixels to trim from bottom edge of ROI (shifts y2 upward, default: from config or 0)')
parser.add_argument('--save-detailed-matches', action='store_true',
help='Save detailed 3D match information for common-match comparison')
return parser.parse_args()
def main():
"""Main evaluation function."""
args = parse_arguments()
# Load configuration
config = {}
if args.config:
print(f"Loading configuration from: {args.config}")
config = load_config(args.config)
# Override with command line arguments if provided
if args.det_path:
config.setdefault('dataset', {})['det_path'] = args.det_path
if args.gt_path:
config.setdefault('dataset', {})['gt_path'] = args.gt_path
if args.path_depth != 1:
config.setdefault('dataset', {})['path_depth'] = args.path_depth
if args.det_format != 'auto':
config.setdefault('dataset', {})['det_format'] = args.det_format
if args.gt_format != 'auto':
config.setdefault('dataset', {})['gt_format'] = args.gt_format
# Only override output_dir if explicitly provided (not default value)
if args.output_dir != 'eval_results':
config.setdefault('output', {})['save_path'] = args.output_dir
# Override image size if explicitly provided
if args.img_width != 1920:
config.setdefault('image', {})['width'] = args.img_width
if args.img_height != 1080:
config.setdefault('image', {})['height'] = args.img_height
# Override matching threshold if explicitly provided
if args.iou_threshold != 0.5:
config.setdefault('matching', {})['iou_threshold'] = args.iou_threshold
# Override 2D metrics parameters if explicitly provided
if args.conf_threshold != 0.5:
config.setdefault('metrics_2d', {})['conf_threshold'] = args.conf_threshold
if args.ap_method != 'voc2010':
config.setdefault('metrics_2d', {})['ap_method'] = args.ap_method
# Override 3D metrics parameters if explicitly provided
if args.heading_tolerance != 'strict':
config.setdefault('metrics_3d', {})['heading_tolerance'] = args.heading_tolerance
if args.coord_system != 'camera':
config.setdefault('metrics_3d', {})['coordinate_system'] = args.coord_system
# Override roi_bottom_offset if explicitly provided
if args.roi_bottom_offset is not None:
config.setdefault('roi_gt', {})['roi_bottom_offset'] = args.roi_bottom_offset
else:
# Build config from command line arguments
if not args.det_path or not args.gt_path:
print("Error: --det-path and --gt-path are required when not using --config")
sys.exit(1)
config = {
'dataset': {
'det_path': args.det_path,
'gt_path': args.gt_path,
'path_depth': args.path_depth,
'det_format': args.det_format,
'gt_format': args.gt_format,
},
'image': {
'width': args.img_width,
'height': args.img_height
},
'matching': {
'iou_threshold': args.iou_threshold
},
'metrics_2d': {
'enabled': not args.eval_3d_only,
'conf_threshold': args.conf_threshold,
'ap_method': args.ap_method
},
'metrics_3d': {
'enabled': not args.eval_2d_only,
'heading_tolerance': args.heading_tolerance,
'coordinate_system': args.coord_system,
},
'output': {
'save_path': args.output_dir
}
}
if args.roi_bottom_offset is not None:
config.setdefault('roi_gt', {})['roi_bottom_offset'] = args.roi_bottom_offset
# Set evaluation flags
config['eval_2d'] = config.get('metrics_2d', {}).get('enabled', True)
config['eval_3d'] = config.get('metrics_3d', {}).get('enabled', True)
if args.eval_2d_only:
config['eval_2d'] = True
config['eval_3d'] = False
elif args.eval_3d_only:
config['eval_2d'] = False
config['eval_3d'] = True
# Extract paths
det_path = config['dataset']['det_path']
gt_path = config['dataset']['gt_path']
path_depth = config['dataset'].get('path_depth', 1) # Default to 1-level structure
det_format = config['dataset'].get('det_format', 'auto')
gt_format = config['dataset'].get('gt_format', 'auto')
det_subdir = config['dataset'].get('det_subdir')
output_dir = config['output']['save_path']
img_width = config.get('image', {}).get('width', 1920)
img_height = config.get('image', {}).get('height', 1080)
iou_threshold = config.get('matching', {}).get('iou_threshold', 0.5)
# Get num_workers from config if not specified in command line
num_workers = args.num_workers
if num_workers is None and 'performance' in config:
num_workers = config['performance'].get('num_workers', None)
print("="*80)
print("YOLOv5-3D Model Evaluation")
print("="*80)
print(f"Detection path: {det_path}")
print(f"Ground truth path: {gt_path}")
print(f"Path depth: {path_depth}")
print(f"Detection format: {det_format}")
print(f"Ground truth format: {gt_format}")
if det_subdir:
print(f"Detection subdirectory: {det_subdir}")
print(f"Output directory: {output_dir}")
print(f"Image size: {img_width}x{img_height}")
print(f"IoU threshold: {iou_threshold}")
print(f"Confidence threshold: {config.get('metrics_2d', {}).get('conf_threshold', 0.5)}")
print(f"AP method: {config.get('metrics_2d', {}).get('ap_method', 'voc2010')}")
heading_tolerance = config.get('metrics_3d', {}).get('heading_tolerance', 'strict')
coord_system = config.get('metrics_3d', {}).get('coordinate_system', 'camera')
print(f"Heading tolerance: {heading_tolerance}")
print(f"3D coordinate system: {coord_system}")
roi_bottom_offset_val = config.get('roi_gt', {}).get('roi_bottom_offset', 0)
if config.get('roi_gt', {}).get('enabled', False):
print(f"ROI bottom offset: {roi_bottom_offset_val}")
if num_workers:
print(f"Number of workers: {num_workers}")
print(f"Evaluate 2D: {config['eval_2d']}")
print(f"Evaluate 3D: {config['eval_3d']}")
if args.save_detailed_matches:
print(f"Save detailed matches: Yes")
print("="*80)
# Initialize evaluator
evaluator = Evaluator(
config=config,
iou_threshold=iou_threshold,
num_workers=num_workers,
save_detailed_matches=args.save_detailed_matches
)
# Load data
print("\nLoading data...")
evaluator.load_data_from_paths(det_path, gt_path, img_width, img_height, path_depth,
det_format=det_format, gt_format=gt_format)
if len(evaluator.image_pairs) == 0:
print("Error: No image pairs found for evaluation!")
sys.exit(1)
# Run evaluation
results = evaluator.evaluate()
# Generate and save report
evaluator.generate_report(results, output_dir)
print("\n✓ Evaluation completed successfully!")
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,584 @@
"""Rename detection result files so frame stems match ground-truth stems.
This script is intended as a preprocessing step before running the evaluation
pipeline. The evaluator pairs detection and ground-truth files strictly by
their filename stem, so detections that omit a timestamp suffix will be skipped
unless they are renamed to the GT stem.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
from collections import defaultdict
from concurrent.futures import ProcessPoolExecutor
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
import yaml
DEFAULT_TIMESTAMP_PATTERNS = (
re.compile(r"(?:[_-]?ts)?\d{10,20}$"),
re.compile(r"[_-]?\d{8}[_-]\d{6}(?:\d+)?$"),
re.compile(r"[_-]?\d{8}T\d{6}(?:\d+)?$"),
)
DEFAULT_TIMESTAMP_TOKEN_PATTERN = re.compile(r"^(?:ts)?\d{8,20}$")
@dataclass(frozen=True)
class CaseInfo:
"""One evaluation case directory."""
det_case_dir: Path
case_name: str
level1_name: str | None = None
@dataclass(frozen=True)
class MatchPair:
"""Mapping from one detection file to one GT file."""
det_file: Path
gt_file: Path
strategy: str
@dataclass(frozen=True)
class CaseProcessResult:
"""Worker result for one case."""
summary: dict[str, object]
manifest_rows: list[dict[str, object]]
def parse_args() -> argparse.Namespace:
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(
description=(
"Rename or copy detection result files so their stems match GT stems "
"before running eval_tools/core/eval.py."
)
)
parser.add_argument("--config", type=Path, help="Evaluation config YAML used to infer det/gt paths.")
parser.add_argument("--det-root", type=Path, help="Detection root directory. Overrides config dataset.det_path.")
parser.add_argument("--gt-root", type=Path, help="Ground-truth root directory. Overrides config dataset.gt_path.")
parser.add_argument(
"--path-depth",
type=int,
choices=(1, 2),
help="Directory depth: 1 = root/case, 2 = root/level1/case. Overrides config dataset.path_depth.",
)
parser.add_argument(
"--det-format",
choices=("auto", "json", "txt"),
help="Detection format. Overrides config dataset.det_format.",
)
parser.add_argument(
"--gt-format",
choices=("auto", "json", "txt"),
help="GT format. Overrides config dataset.gt_format.",
)
parser.add_argument(
"--det-subdir",
help="Relative detection subdirectory inside each case, e.g. predictions/roi0.",
)
parser.add_argument(
"--output-root",
type=Path,
help=(
"Write renamed files to a mirrored directory tree instead of changing the source det root in place. "
"Recommended for the first run."
),
)
parser.add_argument(
"--in-place",
action="store_true",
help="Rename files directly inside the source detection directory.",
)
parser.add_argument(
"--enable-index-fallback",
action="store_true",
help=(
"If normalized-name matching still leaves unmatched files, allow one-to-one pairing by sorted index. "
"Use only when detection and GT frame order is guaranteed to be aligned."
),
)
parser.add_argument(
"--strip-pattern",
action="append",
default=[],
help=(
"Custom regex used to strip a suffix from stems before matching. Can be specified multiple times. "
"If omitted, built-in timestamp suffix patterns are used."
),
)
parser.add_argument(
"--case",
dest="cases",
action="append",
default=[],
help="Limit processing to specific case names. Can be specified multiple times.",
)
parser.add_argument(
"--manifest-path",
type=Path,
help="Where to save the rename manifest JSON. Defaults under output_root or det_root.",
)
parser.add_argument(
"--strict",
action="store_true",
help="Return a non-zero exit code if any case has unmatched or ambiguous files.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Preview the mapping without copying or renaming files.",
)
parser.add_argument(
"--num-workers",
type=int,
default=1,
help="Number of case-level worker processes. Use 0 to auto-select based on CPU count.",
)
return parser.parse_args()
def load_config(config_path: Path | None) -> dict:
"""Load evaluation YAML config if provided."""
if config_path is None:
return {}
with config_path.open("r", encoding="utf-8") as handle:
return yaml.safe_load(handle) or {}
def resolve_runtime_options(args: argparse.Namespace, config: dict) -> dict:
"""Resolve CLI options with config fallback."""
dataset_cfg = config.get("dataset", {}) if isinstance(config, dict) else {}
det_root = Path(args.det_root or dataset_cfg.get("det_path", "")).expanduser() if (args.det_root or dataset_cfg.get("det_path")) else None
gt_root = Path(args.gt_root or dataset_cfg.get("gt_path", "")).expanduser() if (args.gt_root or dataset_cfg.get("gt_path")) else None
path_depth = args.path_depth or dataset_cfg.get("path_depth", 1)
det_format = args.det_format or dataset_cfg.get("det_format", "auto")
gt_format = args.gt_format or dataset_cfg.get("gt_format", "auto")
det_subdir = args.det_subdir if args.det_subdir is not None else dataset_cfg.get("det_subdir")
if det_root is None or gt_root is None:
raise ValueError("Both detection root and GT root must be provided by --config or explicit arguments.")
if args.in_place and args.output_root:
raise ValueError("Use either --in-place or --output-root, not both.")
if not args.in_place and args.output_root is None and not args.dry_run:
raise ValueError("Choose --output-root for a copied result tree or --in-place for source renaming.")
return {
"det_root": det_root.resolve(),
"gt_root": gt_root.resolve(),
"path_depth": int(path_depth),
"det_format": det_format,
"gt_format": gt_format,
"det_subdir": det_subdir,
}
def iter_case_dirs(det_root: Path, path_depth: int, selected_cases: set[str]) -> list[CaseInfo]:
"""Enumerate detection case directories according to evaluator path depth."""
def is_case_dir(path: Path) -> bool:
return path.is_dir() and not path.name.startswith((".", "_"))
cases: list[CaseInfo] = []
if path_depth == 1:
for case_dir in sorted(p for p in det_root.iterdir() if is_case_dir(p)):
if selected_cases and case_dir.name not in selected_cases:
continue
cases.append(CaseInfo(det_case_dir=case_dir, case_name=case_dir.name))
return cases
for level1_dir in sorted(p for p in det_root.iterdir() if is_case_dir(p)):
for case_dir in sorted(p for p in level1_dir.iterdir() if is_case_dir(p)):
if selected_cases and case_dir.name not in selected_cases:
continue
cases.append(CaseInfo(det_case_dir=case_dir, case_name=case_dir.name, level1_name=level1_dir.name))
return cases
def resolve_gt_case_dir(gt_root: Path, case_info: CaseInfo, path_depth: int) -> Path:
"""Resolve ground-truth case directory from case info."""
if path_depth == 1:
return gt_root / case_info.case_name
return gt_root / str(case_info.level1_name) / case_info.case_name
def resolve_detection_dir(case_dir: Path, det_format: str, det_subdir: str | None) -> tuple[Path | None, str | None]:
"""Resolve the directory and suffix for detection result files."""
json_candidates: list[Path] = []
if det_subdir:
subdir_path = Path(det_subdir)
json_candidates.append(subdir_path if subdir_path.is_absolute() else case_dir / subdir_path)
json_candidates.extend([case_dir / "json_results", case_dir / "predictions"])
txt_candidate = case_dir / "txt_results"
def pick_json_dir() -> Path | None:
for candidate in json_candidates:
if candidate.exists() and any(candidate.glob("*.json")):
return candidate
return None
if det_format == "json":
result_dir = pick_json_dir()
return result_dir, ".json"
if det_format == "txt":
return (txt_candidate, ".txt") if txt_candidate.exists() else (None, None)
result_dir = pick_json_dir()
if result_dir is not None:
return result_dir, ".json"
if txt_candidate.exists() and any(txt_candidate.glob("*.txt")):
return txt_candidate, ".txt"
return None, None
def resolve_gt_dir(case_dir: Path, gt_format: str) -> tuple[Path | None, str | None]:
"""Resolve the directory and suffix for GT files."""
json_candidate = case_dir / "labels_json"
txt_candidate = case_dir / "labels"
if gt_format == "json":
return (json_candidate, ".json") if json_candidate.exists() else (None, None)
if gt_format == "txt":
return (txt_candidate, ".txt") if txt_candidate.exists() else (None, None)
if json_candidate.exists():
return json_candidate, ".json"
if txt_candidate.exists():
return txt_candidate, ".txt"
return None, None
def build_normalizer(custom_patterns: list[str]) -> Callable[[str], str]:
"""Build a stem normalizer that removes timestamp-like suffixes."""
patterns = [re.compile(pattern) for pattern in custom_patterns] if custom_patterns else list(DEFAULT_TIMESTAMP_PATTERNS)
def normalize(stem: str) -> str:
normalized = stem
for pattern in patterns:
normalized = pattern.sub("", normalized)
tokens = [token for token in normalized.rstrip("_-").split("_") if token]
filtered_tokens = [token for token in tokens if not DEFAULT_TIMESTAMP_TOKEN_PATTERN.fullmatch(token)]
return "_".join(filtered_tokens)
return normalize
def build_pairs(
det_files: list[Path],
gt_files: list[Path],
normalize_stem: Callable[[str], str],
enable_index_fallback: bool,
) -> tuple[list[MatchPair], list[str], list[dict[str, object]]]:
"""Match detection files to GT files using exact, normalized, then optional index fallback."""
gt_by_stem = {path.stem: path for path in gt_files}
used_gt_stems: set[str] = set()
pairs: list[MatchPair] = []
issues: list[str] = []
ambiguous_groups: list[dict[str, object]] = []
unmatched_det: list[Path] = []
for det_file in det_files:
gt_file = gt_by_stem.get(det_file.stem)
if gt_file is None:
unmatched_det.append(det_file)
continue
used_gt_stems.add(gt_file.stem)
pairs.append(MatchPair(det_file=det_file, gt_file=gt_file, strategy="exact"))
unmatched_gt = [gt_file for gt_file in gt_files if gt_file.stem not in used_gt_stems]
if not unmatched_det or not unmatched_gt:
return pairs, issues, ambiguous_groups
det_groups: dict[str, list[Path]] = defaultdict(list)
gt_groups: dict[str, list[Path]] = defaultdict(list)
for det_file in unmatched_det:
det_groups[normalize_stem(det_file.stem)].append(det_file)
for gt_file in unmatched_gt:
gt_groups[normalize_stem(gt_file.stem)].append(gt_file)
remaining_det: list[Path] = []
remaining_gt: list[Path] = []
group_keys = sorted(set(det_groups) | set(gt_groups))
for key in group_keys:
det_group = sorted(det_groups.get(key, []))
gt_group = sorted(gt_groups.get(key, []))
if not det_group:
remaining_gt.extend(gt_group)
continue
if not gt_group:
remaining_det.extend(det_group)
continue
if len(det_group) == 1 and len(gt_group) == 1:
pairs.append(MatchPair(det_file=det_group[0], gt_file=gt_group[0], strategy="normalized"))
continue
ambiguous_groups.append(
{
"normalized_key": key,
"det_files": [path.name for path in det_group],
"gt_files": [path.name for path in gt_group],
}
)
remaining_det.extend(det_group)
remaining_gt.extend(gt_group)
if remaining_det or remaining_gt:
if enable_index_fallback and len(remaining_det) == len(remaining_gt):
for det_file, gt_file in zip(sorted(remaining_det), sorted(remaining_gt)):
pairs.append(MatchPair(det_file=det_file, gt_file=gt_file, strategy="index"))
else:
if remaining_det:
issues.append(
"Unmatched detection files: " + ", ".join(path.name for path in sorted(remaining_det))
)
if remaining_gt:
issues.append(
"Unused GT files: " + ", ".join(path.name for path in sorted(remaining_gt))
)
return pairs, issues, ambiguous_groups
def ensure_parent(path: Path) -> None:
"""Create parent directory if missing."""
path.parent.mkdir(parents=True, exist_ok=True)
def apply_mapping(
pairs: list[MatchPair],
det_root: Path,
output_root: Path | None,
in_place: bool,
dry_run: bool,
) -> list[dict[str, object]]:
"""Apply renaming or copying for matched pairs."""
manifest_rows: list[dict[str, object]] = []
for pair in pairs:
relative_det_path = pair.det_file.relative_to(det_root)
target_name = f"{pair.gt_file.stem}{pair.det_file.suffix}"
if output_root is not None:
target_dir = output_root / relative_det_path.parent
target_path = target_dir / target_name
action = "copy"
else:
target_path = pair.det_file.with_name(target_name)
action = "rename"
row = {
"strategy": pair.strategy,
"action": action,
"source": str(pair.det_file),
"target": str(target_path),
"gt_file": str(pair.gt_file),
"changed": pair.det_file.name != target_path.name,
}
manifest_rows.append(row)
if dry_run:
continue
ensure_parent(target_path)
if output_root is not None:
if pair.det_file.resolve() == target_path.resolve():
continue
shutil.copy2(pair.det_file, target_path)
elif in_place and pair.det_file != target_path:
if target_path.exists():
raise FileExistsError(f"Refusing to overwrite existing file: {target_path}")
pair.det_file.rename(target_path)
return manifest_rows
def default_manifest_path(args: argparse.Namespace, runtime: dict) -> Path:
"""Choose a default manifest path."""
if args.manifest_path is not None:
return args.manifest_path
root = args.output_root.resolve() if args.output_root else runtime["det_root"]
return root / "rename_detection_frames_manifest.json"
def resolve_num_workers(requested_workers: int, case_count: int) -> int:
"""Resolve worker count from CLI input and case count."""
if case_count <= 1:
return 1
if requested_workers <= 0:
requested_workers = os.cpu_count() or 1
return max(1, min(requested_workers, case_count))
def process_case(
case_info: CaseInfo,
runtime: dict[str, object],
strip_patterns: list[str],
enable_index_fallback: bool,
output_root: str | None,
in_place: bool,
dry_run: bool,
) -> CaseProcessResult:
"""Process one case, including match generation and optional file operations."""
normalize_stem = build_normalizer(strip_patterns)
det_root = Path(str(runtime["det_root"]))
gt_root = Path(str(runtime["gt_root"]))
path_depth = int(runtime["path_depth"])
det_format = str(runtime["det_format"])
gt_format = str(runtime["gt_format"])
det_subdir = runtime.get("det_subdir")
det_subdir_str = None if det_subdir is None else str(det_subdir)
gt_case_dir = resolve_gt_case_dir(gt_root, case_info, path_depth)
det_dir, det_suffix = resolve_detection_dir(case_info.det_case_dir, det_format, det_subdir_str)
gt_dir, gt_suffix = resolve_gt_dir(gt_case_dir, gt_format)
summary = {
"case": case_info.case_name,
"level1_name": case_info.level1_name,
"det_case_dir": str(case_info.det_case_dir),
"gt_case_dir": str(gt_case_dir),
"status": "ok",
"issues": [],
"ambiguous_groups": [],
"matched": 0,
"changed": 0,
}
if det_dir is None or det_suffix is None:
summary["status"] = "missing_detection_dir"
summary["issues"].append("Detection results directory not found or empty.")
return CaseProcessResult(summary=summary, manifest_rows=[])
if gt_dir is None or gt_suffix is None:
summary["status"] = "missing_gt_dir"
summary["issues"].append("GT labels directory not found.")
return CaseProcessResult(summary=summary, manifest_rows=[])
det_files = sorted(path for path in det_dir.iterdir() if path.suffix == det_suffix)
gt_files = sorted(path for path in gt_dir.iterdir() if path.suffix == gt_suffix)
if not det_files:
summary["status"] = "empty_detection_dir"
summary["issues"].append("No detection files found in resolved directory.")
return CaseProcessResult(summary=summary, manifest_rows=[])
if not gt_files:
summary["status"] = "empty_gt_dir"
summary["issues"].append("No GT files found in resolved directory.")
return CaseProcessResult(summary=summary, manifest_rows=[])
pairs, issues, ambiguous_groups = build_pairs(
det_files=det_files,
gt_files=gt_files,
normalize_stem=normalize_stem,
enable_index_fallback=enable_index_fallback,
)
manifest_rows = apply_mapping(
pairs=pairs,
det_root=det_root,
output_root=Path(output_root) if output_root else None,
in_place=in_place,
dry_run=dry_run,
)
summary["issues"] = issues
summary["ambiguous_groups"] = ambiguous_groups
summary["matched"] = len(pairs)
summary["changed"] = sum(1 for row in manifest_rows if row["changed"])
if issues or ambiguous_groups:
summary["status"] = "partial"
return CaseProcessResult(summary=summary, manifest_rows=manifest_rows)
def main() -> int:
"""Run the rename workflow."""
args = parse_args()
config = load_config(args.config)
runtime = resolve_runtime_options(args, config)
selected_cases = set(args.cases)
case_infos = iter_case_dirs(runtime["det_root"], runtime["path_depth"], selected_cases)
if not case_infos:
raise FileNotFoundError(f"No detection cases found under {runtime['det_root']}")
num_workers = resolve_num_workers(args.num_workers, len(case_infos))
all_manifest_rows: list[dict[str, object]] = []
case_summaries: list[dict[str, object]] = []
failed_cases = 0
output_root = str(args.output_root.resolve()) if args.output_root else None
if num_workers == 1:
results = [
process_case(
case_info=case_info,
runtime=runtime,
strip_patterns=args.strip_pattern,
enable_index_fallback=args.enable_index_fallback,
output_root=output_root,
in_place=args.in_place,
dry_run=args.dry_run,
)
for case_info in case_infos
]
else:
with ProcessPoolExecutor(max_workers=num_workers) as executor:
results = list(
executor.map(
process_case,
case_infos,
[runtime] * len(case_infos),
[args.strip_pattern] * len(case_infos),
[args.enable_index_fallback] * len(case_infos),
[output_root] * len(case_infos),
[args.in_place] * len(case_infos),
[args.dry_run] * len(case_infos),
)
)
for result in results:
case_summaries.append(result.summary)
all_manifest_rows.extend(result.manifest_rows)
if result.summary["status"] != "ok":
failed_cases += 1
manifest = {
"dry_run": args.dry_run,
"in_place": args.in_place,
"output_root": str(args.output_root.resolve()) if args.output_root else None,
"det_root": str(runtime["det_root"]),
"gt_root": str(runtime["gt_root"]),
"path_depth": runtime["path_depth"],
"det_format": runtime["det_format"],
"gt_format": runtime["gt_format"],
"det_subdir": runtime["det_subdir"],
"cases": case_summaries,
"mappings": all_manifest_rows,
}
manifest_path = default_manifest_path(args, runtime)
ensure_parent(manifest_path)
with manifest_path.open("w", encoding="utf-8") as handle:
json.dump(manifest, handle, ensure_ascii=False, indent=2)
total_cases = len(case_summaries)
total_matched = sum(item["matched"] for item in case_summaries)
total_changed = sum(item["changed"] for item in case_summaries)
print(f"Processed cases: {total_cases}")
print(f"Workers used: {num_workers}")
print(f"Matched files: {total_matched}")
print(f"Renamed/copied files with changed stems: {total_changed}")
print(f"Manifest: {manifest_path}")
if args.strict and failed_cases:
print(f"Strict mode: {failed_cases} case(s) still have unresolved issues.")
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

79
eval_tools/core/run_eval.sh Executable file
View File

@@ -0,0 +1,79 @@
#!/bin/bash
# YOLOv5-3D 评测启动脚本
# 使用方法: sh eval_tools/run_eval.sh
# ==============================================
# 配置参数
# ==============================================
# 数据路径
# Set PYTHONPATH to project root for module imports
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
export PYTHONPATH="${PROJECT_ROOT}:${PYTHONPATH}"
DET_PATH="/data1/dongying/Mono3d/G1M3/300w/txt_results"
GT_PATH="/data1/dongying/Mono3d/G1M3/labels"
OUTPUT_DIR="eval_results/$(date +%Y%m%d_%H%M%S)"
# 图像参数
IMG_WIDTH=1920
IMG_HEIGHT=1080
# 评测参数
IOU_THRESHOLD=0.5
CONF_THRESHOLD=0.5
AP_METHOD="voc2010" # voc2010 或 coco
# 并行进程数 (默认自动检测)
NUM_WORKERS=8 # 设置为空使用自动检测: NUM_WORKERS=""
# ==============================================
# 构建命令
# ==============================================
# 构建命令
# ==============================================
CMD="python eval_tools/core/eval.py \
--det-path $DET_PATH \
--gt-path $GT_PATH \
--output-dir $OUTPUT_DIR \
--img-width $IMG_WIDTH \
--img-height $IMG_HEIGHT \
--iou-threshold $IOU_THRESHOLD \
--conf-threshold $CONF_THRESHOLD \
--ap-method $AP_METHOD"
# 添加并行进程数参数
if [ -n "$NUM_WORKERS" ]; then
CMD="$CMD --num-workers $NUM_WORKERS"
fi
# ==============================================
# 执行评测
# ==============================================
echo "========================================"
echo "YOLOv5-3D 评测"
echo "========================================"
echo "检测结果: $DET_PATH"
echo "真值标签: $GT_PATH"
echo "输出目录: $OUTPUT_DIR"
echo "图像尺寸: ${IMG_WIDTH}x${IMG_HEIGHT}"
echo "IoU阈值: $IOU_THRESHOLD"
echo "置信度阈值: $CONF_THRESHOLD"
if [ -n "$NUM_WORKERS" ]; then
echo "并行进程: $NUM_WORKERS"
else
echo "并行进程: 自动检测"
fi
echo "========================================"
echo ""
# 执行命令
eval $CMD
echo ""
echo "评测完成! 结果已保存到: $OUTPUT_DIR"

View File

@@ -0,0 +1,25 @@
#!/bin/bash
# YOLOv5-3D 快速评测脚本 - 仅评测2D
# 使用方法: sh eval_tools/run_eval_2d_only.sh
# Set PYTHONPATH to project root for module imports
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
export PYTHONPATH="${PROJECT_ROOT}:${PYTHONPATH}"
DET_PATH="/data1/dongying/Mono3d/G1M3/300w/txt_results"
GT_PATH="/data1/dongying/Mono3d/G1M3/labels"
OUTPUT_DIR="eval_results/2d_only_$(date +%Y%m%d_%H%M%S)"
python eval_tools/core/eval.py \
--det-path $DET_PATH \
--gt-path $GT_PATH \
--output-dir $OUTPUT_DIR \
--eval-2d-only \
--num-workers 8 \
--img-width 1920 \
--img-height 1080
echo "2D评测完成! 结果: $OUTPUT_DIR"

View File

@@ -0,0 +1,25 @@
#!/bin/bash
# YOLOv5-3D 快速评测脚本 - 仅评测3D
# 使用方法: sh eval_tools/run_eval_3d_only.sh
# Set PYTHONPATH to project root for module imports
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
export PYTHONPATH="${PROJECT_ROOT}:${PYTHONPATH}"
DET_PATH="/data1/dongying/Mono3d/G1M3/300w/txt_results"
GT_PATH="/data1/dongying/Mono3d/G1M3/labels"
OUTPUT_DIR="eval_results/3d_only_$(date +%Y%m%d_%H%M%S)"
python eval_tools/core/eval.py \
--det-path $DET_PATH \
--gt-path $GT_PATH \
--output-dir $OUTPUT_DIR \
--eval-3d-only \
--num-workers 8 \
--img-width 1920 \
--img-height 1080
echo "3D评测完成! 结果: $OUTPUT_DIR"

View File

@@ -0,0 +1,84 @@
#!/bin/bash
set -e # Exit on error
# Set PYTHONPATH to project root for module imports
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
export PYTHONPATH="${PROJECT_ROOT}:${PYTHONPATH}"
echo "================================================================================"
echo "Evaluation Workflow"
echo "================================================================================"
# Configuration
MODEL_CONFIG_ROI0="eval_tools/configs/eval_config_yolov26s-roi0.yaml"
MODEL_CONFIG_ROI1="eval_tools/configs/eval_config_yolov26s-roi1.yaml"
OUTPUT_BASE="evaluation_results/eval_results_yolo26s_768_20260407_DL_KPI_SCENE" # Base directory for all evaluation outputs; individual runs will be timestamped subdirectories
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
MODEL_NAME="yolo26s-20260407-conf0.27" # Used for output directory naming
# Heading tolerance mode: strict, relaxed, or both
HEADING_TOLERANCE="both"
# Step 1: Evaluate ROI0 detections
echo ""
echo "Step 1: Evaluating Model (${MODEL_NAME}) — ROI0 detections..."
echo " Heading tolerance: ${HEADING_TOLERANCE}"
echo "--------------------------------------------------------------------------------"
MODEL_OUTPUT_ROI0="${OUTPUT_BASE}/${MODEL_NAME}/${TIMESTAMP}_roi0"
python eval_tools/core/eval.py \
--config ${MODEL_CONFIG_ROI0} \
--output-dir ${MODEL_OUTPUT_ROI0} \
--heading-tolerance ${HEADING_TOLERANCE} \
--save-detailed-matches
REPORT_JSON_ROI0="${MODEL_OUTPUT_ROI0}/evaluation_report.json"
REPORT_MD_ROI0="${MODEL_OUTPUT_ROI0}/EVALUATION_REPORT.md"
REPORT_DATE=$(date +%F)
echo ""
echo "Step 2: Generating Markdown report for ROI0..."
echo "--------------------------------------------------------------------------------"
python eval_tools/model_comparison/generate_eval_report.py \
"${REPORT_JSON_ROI0}" \
--output "${REPORT_MD_ROI0}" \
--model "${MODEL_NAME}-roi0" \
--date "${REPORT_DATE}"
# Step 3: Evaluate ROI1 detections
echo ""
echo "Step 3: Evaluating Model (${MODEL_NAME}) — ROI1 detections..."
echo " Heading tolerance: ${HEADING_TOLERANCE}"
echo "--------------------------------------------------------------------------------"
MODEL_OUTPUT_ROI1="${OUTPUT_BASE}/${MODEL_NAME}/${TIMESTAMP}_roi1"
python eval_tools/core/eval.py \
--config ${MODEL_CONFIG_ROI1} \
--output-dir ${MODEL_OUTPUT_ROI1} \
--heading-tolerance ${HEADING_TOLERANCE} \
--save-detailed-matches
REPORT_JSON_ROI1="${MODEL_OUTPUT_ROI1}/evaluation_report.json"
REPORT_MD_ROI1="${MODEL_OUTPUT_ROI1}/EVALUATION_REPORT.md"
echo ""
echo "Step 4: Generating Markdown report for ROI1..."
echo "--------------------------------------------------------------------------------"
python eval_tools/model_comparison/generate_eval_report.py \
"${REPORT_JSON_ROI1}" \
--output "${REPORT_MD_ROI1}" \
--model "${MODEL_NAME}-roi1" \
--date "${REPORT_DATE}"
echo ""
echo "================================================================================"
echo "Evaluation completed"
echo "--------------------------------------------------------------------------------"
echo "ROI0 output: ${MODEL_OUTPUT_ROI0}"
echo "ROI0 JSON report: ${REPORT_JSON_ROI0}"
echo "ROI0 MD report: ${REPORT_MD_ROI0}"
echo "ROI1 output: ${MODEL_OUTPUT_ROI1}"
echo "ROI1 JSON report: ${REPORT_JSON_ROI1}"
echo "ROI1 MD report: ${REPORT_MD_ROI1}"
echo "================================================================================"

View File

@@ -0,0 +1,148 @@
#!/bin/bash
# Example script for running YOLOv5-3D evaluation
#
# Usage: ./eval_tools/run_evaluation_example.sh
# =============================================================================
# Configuration
# =============================================================================
# Paths (modify these according to your data location)
# Set PYTHONPATH to project root for module imports
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
export PYTHONPATH="${PROJECT_ROOT}:${PYTHONPATH}"
GT_PATH="/data1/xdzhu/Testdata_0129"
OUTPUT_DIR="eval_results/mono3d/$(date +%Y%m%d_%H%M%S)"
# Image properties
IMG_WIDTH=1920
IMG_HEIGHT=1080
# Evaluation parameters
IOU_THRESHOLD=0.5
CONF_THRESHOLD=0.5
AP_METHOD="voc2010" # or "coco"
DET_PATH="/data1/dongying/Mono3d/G1M3/inference_results/mono3d/evalset_roi0"
# Distance ranges for 3D evaluation (optional)
# Comment out to disable distance-based statistics
ENABLE_DISTANCE_RANGES=true
DISTANCE_RANGES="[[0, 30], [30, 60], [60, 100], [100, 999]]"
# =============================================================================
# Prepare Configuration
# =============================================================================
# Create temporary config file
CONFIG_FILE="/tmp/eval_config_$$.yaml"
if [ "$ENABLE_DISTANCE_RANGES" = true ]; then
cat > "$CONFIG_FILE" << EOF
dataset:
det_path: "$DET_PATH"
gt_path: "$GT_PATH"
image:
width: $IMG_WIDTH
height: $IMG_HEIGHT
matching:
iou_threshold: $IOU_THRESHOLD
metrics_2d:
enabled: true
conf_threshold: $CONF_THRESHOLD
ap_method: "$AP_METHOD"
metrics_3d:
enabled: true
distance_ranges:
- [0, 30]
- [30, 60]
- [60, 100]
- [100, 999]
output:
save_path: "$OUTPUT_DIR"
EOF
else
cat > "$CONFIG_FILE" << EOF
dataset:
det_path: "$DET_PATH"
gt_path: "$GT_PATH"
image:
width: $IMG_WIDTH
height: $IMG_HEIGHT
matching:
iou_threshold: $IOU_THRESHOLD
metrics_2d:
enabled: true
conf_threshold: $CONF_THRESHOLD
ap_method: "$AP_METHOD"
metrics_3d:
enabled: true
output:
save_path: "$OUTPUT_DIR"
EOF
fi
# =============================================================================
# Run Evaluation
# =============================================================================
echo "=========================================="
echo "YOLOv5-3D Model Evaluation"
echo "=========================================="
echo "Detection path: $DET_PATH"
echo "Ground truth path: $GT_PATH"
echo "Output directory: $OUTPUT_DIR"
if [ "$ENABLE_DISTANCE_RANGES" = true ]; then
echo "Distance ranges: ENABLED (0-30m, 30-60m, 60-100m, 100-999m)"
else
echo "Distance ranges: DISABLED"
fi
echo "=========================================="
# Check if paths exist
if [ ! -d "$DET_PATH" ]; then
echo "Error: Detection path does not exist: $DET_PATH"
exit 1
fi
if [ ! -d "$GT_PATH" ]; then
echo "Error: Ground truth path does not exist: $GT_PATH"
exit 1
fi
# Run evaluation
python eval_tools/core/eval.py \
--config "$CONFIG_FILE"
# Check if evaluation was successful
if [ $? -eq 0 ]; then
echo ""
echo "✓ Evaluation completed successfully!"
echo "Results saved to: $OUTPUT_DIR"
echo ""
echo "View results:"
echo " JSON: $OUTPUT_DIR/evaluation_report.json"
echo " Text: $OUTPUT_DIR/evaluation_report.txt"
# Cleanup
rm -f "$CONFIG_FILE"
else
echo ""
echo "✗ Evaluation failed!"
rm -f "$CONFIG_FILE"
exit 1
fi