单目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

View File

@@ -0,0 +1,12 @@
tools/temporal_analysis/track_objects.py
上述脚本对检测结果进行跟踪处理,现在期望对每个检测结果补充两个字段。
示例结果路径:/data1/dongying/Mono3d/G1M3/cases_regular/model_20260317_with_cls/019b2a13-9af3-7e97-a109-9fb7ba7a8f22
其中merge_json中新增了"attribute"字段,其中,"attr_cls"数值表示具体类别。同时路径中有frame_timestamps_interp.csv文件记录了时间戳信息。
第一个字段是细分类别sub_cls具体说明如下
1. 只针对车辆和行人两个大类目标定义细分类别
2. 对于车辆类别,当"attr_cls"数值<=11时自动填入; 当"attr_cls"数值>11,且不等于23时细分类别数值=int("attr_cls")+3; 当int("attr_cls")=23时细分类别数值=12当"is_fake"为1时细分类别数值=26
3. 对于行人类别,细分类别数值=int("attr_cls")
第二个字段是时间戳信息需根据merge_json/roi0/roi1中文件名最后的frameid信息查找frame_timestamps_interp.csv文件中"camera4_frame_id"列对应数值的索引,然后获取"camera4"列中对应索引的时间戳信息。

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,437 @@
#!/usr/bin/env python3
"""Analyze whether detections are lost between raw per-frame JSONs and tracking output.
Primary use case:
compare {case_dir}/roi0/*.json against {case_dir}/roi0.json
The script reports:
- frame-level coverage differences
- total / per-class detection count deltas
- per-frame missing/new detection counts
- detailed missing/new samples for debugging
Usage:
python tools/temporal_analysis/analyze_tracking_loss.py \
--case-dir /path/to/case --source roi0
python tools/temporal_analysis/analyze_tracking_loss.py \
--raw-dir /path/to/roi0 \
--tracking /path/to/roi0.json \
--output /path/to/roi0_loss_report.json
"""
import argparse
import json
from collections import Counter, defaultdict
from pathlib import Path
from merge_tracking_results import normalize_image_name
def round_float(value, digits=6):
"""Round float-like values to stabilize comparison keys."""
return round(float(value), digits)
def normalize_bbox(bbox):
"""Convert bbox to a rounded tuple."""
return tuple(round_float(v) for v in bbox[:4])
def normalize_vector(values, limit=None):
"""Convert an optional numeric vector to a rounded tuple."""
if values is None:
return None
seq = values if limit is None else values[:limit]
return tuple(round_float(v) for v in seq)
def detection_signature(det):
"""Build a stable comparison signature for one detection."""
return (
int(det.get("class_id", -1)),
normalize_bbox(det.get("bbox", [0, 0, 0, 0])),
round_float(det.get("confidence", 0.0)),
str(det.get("type_name", "")),
str(det.get("face_cls", "")),
int(det.get("cut_cls", -1)),
str(det.get("cut_cls_name", "")),
str(det.get("anchor", "")),
normalize_vector(det.get("object_3d"), limit=7),
normalize_vector(det.get("object_3d_ego"), limit=7),
)
def serialize_detection(det):
"""Return a compact JSON-friendly detection summary."""
data = {
"class_id": det.get("class_id"),
"bbox": det.get("bbox"),
"confidence": det.get("confidence"),
}
optional_keys = [
"track_id",
"type_name",
"face_cls",
"cut_cls",
"cut_cls_name",
"anchor",
"frameId",
]
for key in optional_keys:
if key in det:
data[key] = det.get(key)
return data
def parse_det_format(det_dict, image_name=None):
"""Parse raw single-frame detection JSON into the tracking input schema."""
if "detections" in det_dict and isinstance(det_dict["detections"], dict):
raw_detections = det_dict["detections"]
else:
raw_detections = det_dict
face_map = {
"front": "kMonocular3DFront",
"tail": "kMonocular3DRear",
"back": "kMonocular3DRear",
"left": "kMonocular3DLeft",
"right": "kMonocular3DRight",
"center": "kMonocular3DCenter",
"none": "kMonocular3DCenter",
}
detections = []
for det in raw_detections.values():
class_id = int(det["type"])
bbox = [float(v) for v in det["box2d"]]
score = float(det["score"])
xyzlhwyaw_raw = det.get("xyzlhwyaw", [])
object_3d = None
if xyzlhwyaw_raw and float(xyzlhwyaw_raw[0]) != -1:
object_3d = [float(v) for v in xyzlhwyaw_raw]
xyzlhwyaw_ego_raw = det.get("xyzlhwyaw_ego", [])
object_3d_ego = None
if xyzlhwyaw_ego_raw and float(xyzlhwyaw_ego_raw[0]) != -1:
object_3d_ego = [float(v) for v in xyzlhwyaw_ego_raw]
detection = {
"bbox": bbox,
"confidence": score,
"class_id": class_id,
"type_name": det.get("type_name", ""),
"face_cls": det.get("face_cls", "none"),
"cut_cls": int(det.get("cut_cls", -1)),
"cut_cls_name": det.get("cut_cls_name", "none"),
"frameId": normalize_image_name(image_name).split("_")[-1] if image_name else None,
"version": "20260228",
"timestamp": 0,
}
detection["anchor"] = face_map.get(detection["face_cls"], "kMonocular3DCenter")
if object_3d is not None:
detection["object_3d"] = object_3d
if object_3d_ego is not None:
detection["object_3d_ego"] = object_3d_ego
detections.append(detection)
return {
"image_name": image_name,
"detections": detections,
}
def load_predictions_from_dir(input_dir, pattern="*.json"):
"""Load raw per-frame JSON files using the same schema as tracking input."""
input_dir = Path(input_dir)
json_files = sorted(input_dir.glob(pattern))
predictions_data = []
for json_file in json_files:
with open(json_file, "r", encoding="utf-8") as f:
det_dict = json.load(f)
predictions_data.append(parse_det_format(det_dict, image_name=json_file.stem))
return predictions_data
def load_tracking_frames(tracking_path):
"""Load tracking frames from track_objects.py output."""
with open(tracking_path, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, list):
raise ValueError(f"Expected a list of frames in {tracking_path}, got {type(data).__name__}")
return data
def index_frames(frames):
"""Index frames by normalized image name."""
indexed = {}
ordered_keys = []
for frame in frames:
raw_name = frame.get("image_name") or ""
key = normalize_image_name(Path(raw_name).stem if raw_name else "")
indexed[key] = frame
ordered_keys.append(key)
return indexed, ordered_keys
def counter_to_detections(detections):
"""Build a multiset of detection signatures."""
counter = Counter()
det_by_sig = defaultdict(list)
for det in detections:
sig = detection_signature(det)
counter[sig] += 1
det_by_sig[sig].append(det)
return counter, det_by_sig
def expand_counter_delta(delta_counter, det_by_sig):
"""Materialize counter deltas back into example detections."""
items = []
for sig, count in delta_counter.items():
for idx in range(count):
items.append(serialize_detection(det_by_sig[sig][idx]))
return items
def compute_class_counts(detections):
"""Count detections by class_id."""
counts = Counter()
for det in detections:
counts[int(det.get("class_id", -1))] += 1
return counts
def analyze_pair(raw_frames, tracking_frames, top_k_frames=20, top_k_samples=200):
"""Compare raw parsed frames with tracking output frames."""
raw_index, raw_order = index_frames(raw_frames)
tracking_index, tracking_order = index_frames(tracking_frames)
all_keys = []
seen = set()
for key in raw_order + tracking_order:
if key not in seen:
all_keys.append(key)
seen.add(key)
frame_reports = []
totals = {
"raw_frames": len(raw_frames),
"tracking_frames": len(tracking_frames),
"shared_frames": 0,
"raw_only_frames": 0,
"tracking_only_frames": 0,
"raw_detections": 0,
"tracking_detections": 0,
"matched_detections": 0,
"missing_detections": 0,
"new_detections": 0,
}
per_class = defaultdict(lambda: {"raw": 0, "tracking": 0, "missing": 0, "new": 0})
missing_samples = []
new_samples = []
for key in all_keys:
raw_frame = raw_index.get(key)
tracking_frame = tracking_index.get(key)
raw_dets = raw_frame.get("detections", []) if raw_frame else []
tracking_dets = tracking_frame.get("detections", []) if tracking_frame else []
if raw_frame and tracking_frame:
totals["shared_frames"] += 1
elif raw_frame:
totals["raw_only_frames"] += 1
else:
totals["tracking_only_frames"] += 1
raw_count = len(raw_dets)
tracking_count = len(tracking_dets)
totals["raw_detections"] += raw_count
totals["tracking_detections"] += tracking_count
raw_by_class = compute_class_counts(raw_dets)
tracking_by_class = compute_class_counts(tracking_dets)
for cls_id, count in raw_by_class.items():
per_class[cls_id]["raw"] += count
for cls_id, count in tracking_by_class.items():
per_class[cls_id]["tracking"] += count
raw_counter, raw_det_by_sig = counter_to_detections(raw_dets)
tracking_counter, tracking_det_by_sig = counter_to_detections(tracking_dets)
matched_counter = raw_counter & tracking_counter
missing_counter = raw_counter - tracking_counter
new_counter = tracking_counter - raw_counter
matched_count = sum(matched_counter.values())
missing_count = sum(missing_counter.values())
new_count = sum(new_counter.values())
totals["matched_detections"] += matched_count
totals["missing_detections"] += missing_count
totals["new_detections"] += new_count
missing_examples = expand_counter_delta(missing_counter, raw_det_by_sig)
new_examples = expand_counter_delta(new_counter, tracking_det_by_sig)
for det in missing_examples:
cls_id = int(det.get("class_id", -1))
per_class[cls_id]["missing"] += 1
for det in new_examples:
cls_id = int(det.get("class_id", -1))
per_class[cls_id]["new"] += 1
frame_report = {
"image_name": key,
"raw_present": raw_frame is not None,
"tracking_present": tracking_frame is not None,
"raw_count": raw_count,
"tracking_count": tracking_count,
"matched_count": matched_count,
"missing_count": missing_count,
"new_count": new_count,
}
if missing_examples:
for det in missing_examples[: max(0, top_k_samples - len(missing_samples))]:
missing_samples.append({"image_name": key, "detection": det})
if new_examples:
for det in new_examples[: max(0, top_k_samples - len(new_samples))]:
new_samples.append({"image_name": key, "detection": det})
frame_reports.append(frame_report)
frame_reports_sorted = sorted(
frame_reports,
key=lambda item: (item["missing_count"], item["new_count"], abs(item["raw_count"] - item["tracking_count"])),
reverse=True,
)
per_class_report = {
str(cls_id): values
for cls_id, values in sorted(per_class.items(), key=lambda item: item[0])
}
loss_rate = (
totals["missing_detections"] / totals["raw_detections"]
if totals["raw_detections"] > 0 else 0.0
)
new_rate = (
totals["new_detections"] / totals["tracking_detections"]
if totals["tracking_detections"] > 0 else 0.0
)
return {
"summary": {
**totals,
"loss_rate_vs_raw": loss_rate,
"new_rate_vs_tracking": new_rate,
},
"per_class": per_class_report,
"top_frames_by_diff": frame_reports_sorted[:top_k_frames],
"missing_samples": missing_samples,
"new_samples": new_samples,
"all_frame_reports": frame_reports,
}
def resolve_inputs(case_dir, source, raw_dir, tracking_path):
"""Resolve raw/tracking inputs from CLI arguments."""
if case_dir is not None:
case_dir = Path(case_dir)
resolved_raw = case_dir / source
resolved_tracking = case_dir / f"{source}.json"
else:
resolved_raw = Path(raw_dir) if raw_dir is not None else None
resolved_tracking = Path(tracking_path) if tracking_path is not None else None
if resolved_raw is None or resolved_tracking is None:
raise ValueError("Provide either --case-dir or both --raw-dir and --tracking.")
if not resolved_raw.is_dir():
raise FileNotFoundError(f"Raw input directory not found: {resolved_raw}")
if not resolved_tracking.is_file():
raise FileNotFoundError(f"Tracking JSON not found: {resolved_tracking}")
return resolved_raw, resolved_tracking
def print_report(report, raw_dir, tracking_path, source):
"""Pretty-print the key report numbers."""
summary = report["summary"]
print("")
print("======================================================================")
print(f"Tracking loss analysis for source: {source}")
print("======================================================================")
print(f"Raw directory : {raw_dir}")
print(f"Tracking JSON : {tracking_path}")
print(f"Raw frames : {summary['raw_frames']}")
print(f"Tracking frames : {summary['tracking_frames']}")
print(f"Shared frames : {summary['shared_frames']}")
print(f"Raw-only frames : {summary['raw_only_frames']}")
print(f"Track-only frames: {summary['tracking_only_frames']}")
print(f"Raw detections : {summary['raw_detections']}")
print(f"Track detections: {summary['tracking_detections']}")
print(f"Matched : {summary['matched_detections']}")
print(f"Missing : {summary['missing_detections']} ({summary['loss_rate_vs_raw']:.2%} of raw)")
print(f"New : {summary['new_detections']} ({summary['new_rate_vs_tracking']:.2%} of tracking)")
print("\nPer-class summary:")
for cls_id, stats in report["per_class"].items():
print(
f" class {cls_id}: raw={stats['raw']}, tracking={stats['tracking']}, "
f"missing={stats['missing']}, new={stats['new']}"
)
print("\nTop frames by difference:")
if not report["top_frames_by_diff"]:
print(" (no frames)")
for item in report["top_frames_by_diff"]:
print(
f" {item['image_name']}: raw={item['raw_count']}, tracking={item['tracking_count']}, "
f"missing={item['missing_count']}, new={item['new_count']}"
)
def main():
parser = argparse.ArgumentParser(
description="Analyze whether detections are lost from raw per-frame JSONs to tracking JSON."
)
parser.add_argument("--case-dir", type=str, default=None,
help="Case directory containing <source>/ and <source>.json")
parser.add_argument("--source", type=str, default="roi0",
help="Source name under case-dir, e.g. roi0 / roi1 / merge")
parser.add_argument("--raw-dir", type=str, default=None,
help="Raw per-frame JSON directory, used when --case-dir is omitted")
parser.add_argument("--tracking", type=str, default=None,
help="Tracking JSON path, used when --case-dir is omitted")
parser.add_argument("--file-pattern", type=str, default="*.json",
help="Glob pattern for raw frame JSON files (default: %(default)s)")
parser.add_argument("--output", type=str, default=None,
help="Optional JSON report output path")
parser.add_argument("--top-k-frames", type=int, default=20,
help="Number of most-different frames to include in the summary (default: %(default)s)")
parser.add_argument("--top-k-samples", type=int, default=200,
help="Maximum missing/new samples to store in the JSON report (default: %(default)s)")
args = parser.parse_args()
raw_dir, tracking_path = resolve_inputs(args.case_dir, args.source, args.raw_dir, args.tracking)
raw_frames = load_predictions_from_dir(raw_dir, pattern=args.file_pattern)
tracking_frames = load_tracking_frames(tracking_path)
report = analyze_pair(
raw_frames=raw_frames,
tracking_frames=tracking_frames,
top_k_frames=args.top_k_frames,
top_k_samples=args.top_k_samples,
)
print_report(report, raw_dir, tracking_path, args.source)
if args.output:
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
json.dump(report, f, indent=2, ensure_ascii=False)
print(f"\nReport written to: {output_path}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,118 @@
#!/bin/bash
# 用法说明:
# 分析原始逐帧检测结果与跟踪结果之间是否存在检测框丢失。
#
# 默认对每个 case 的三路输入分别分析:
# ROI0: {case_dir}/roi0/ ↔ {case_dir}/roi0.json
# ROI1: {case_dir}/roi1/ ↔ {case_dir}/roi1.json
# Merge: {case_dir}/merge_json/ ↔ {case_dir}/merge.json
#
# 输出报告:
# {case_dir}/roi0_loss_report.json
# {case_dir}/roi1_loss_report.json
# {case_dir}/merge_loss_report.json
#
# 支持两种模式:
# 1) 批量模式(默认):处理 RESULTS_ROOT/MODEL_NAME 下所有 case
# bash analyze_tracking_loss.sh
# 2) 单 case 模式:指定 case 目录路径作为第一个参数
# bash analyze_tracking_loss.sh /path/to/case
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
export PYTHONPATH="${PROJECT_ROOT}:${PYTHONPATH:-}"
# -----------------------------------------------------------------------
# 配置区:与 track_objects.sh 保持一致
# -----------------------------------------------------------------------
RESULTS_ROOT="/data1/dongying/Mono3d/G1M3/cases_regular"
MODEL_NAME="model_20260228"
TOP_K_FRAMES=20
TOP_K_SAMPLES=200
RAW_FILE_PATTERN="*.json"
analyze_one_source() {
local case_dir="$1"
local raw_subdir="$2"
local tracking_name="$3"
local report_name="$4"
local source_label="$5"
local raw_dir="${case_dir}/${raw_subdir}"
local tracking_json="${case_dir}/${tracking_name}"
local report_json="${case_dir}/${report_name}"
if [[ ! -d "${raw_dir}" ]]; then
echo "Warning: raw directory not found, skipping ${source_label}: ${raw_dir}"
return 0
fi
if [[ ! -f "${tracking_json}" ]]; then
echo "Warning: tracking json not found, skipping ${source_label}: ${tracking_json}"
return 0
fi
echo ""
echo "--- Analyze ${source_label} ---"
python3 "${PROJECT_ROOT}/tools/temporal_analysis/analyze_tracking_loss.py" \
--raw-dir "${raw_dir}" \
--tracking "${tracking_json}" \
--output "${report_json}" \
--file-pattern "${RAW_FILE_PATTERN}" \
--top-k-frames "${TOP_K_FRAMES}" \
--top-k-samples "${TOP_K_SAMPLES}"
}
analyze_one_case() {
local case_dir="$1"
if [[ ! -d "${case_dir}" ]]; then
echo "Error: case directory does not exist: ${case_dir}"
return 1
fi
echo ""
echo "======================================================================"
echo "Analyze tracking loss: ${case_dir}"
echo "======================================================================"
analyze_one_source "${case_dir}" "roi0" "roi0.json" "roi0_loss_report.json" "roi0"
analyze_one_source "${case_dir}" "roi1" "roi1.json" "roi1_loss_report.json" "roi1"
analyze_one_source "${case_dir}" "merge_json" "merge.json" "merge_loss_report.json" "merge"
}
echo ""
echo "######################################################################"
echo "# Analyze detection loss between raw per-frame JSON and tracking JSON"
echo "######################################################################"
if [[ -n "${1:-}" ]]; then
CASE_DIR="$1"
echo "Single-case mode: ${CASE_DIR}"
analyze_one_case "${CASE_DIR}"
exit $?
fi
MODEL_DIR="${RESULTS_ROOT}/${MODEL_NAME}"
if [[ ! -d "${MODEL_DIR}" ]]; then
echo "Error: model directory not found: ${MODEL_DIR}"
exit 1
fi
mapfile -t CASE_DIRS < <(find "${MODEL_DIR}" -mindepth 1 -maxdepth 1 -type d | sort)
if [[ ${#CASE_DIRS[@]} -eq 0 ]]; then
echo "Warning: no case directories found under ${MODEL_DIR}"
exit 0
fi
echo "Batch mode: found ${#CASE_DIRS[@]} case(s) under ${MODEL_DIR}"
for case_dir in "${CASE_DIRS[@]}"; do
analyze_one_case "${case_dir}" || exit 1
done
echo ""
echo "Tracking loss analysis complete."

View File

@@ -0,0 +1,44 @@
#!/bin/bash
# Example usage of compare_temporal_stability.py
# Compare temporal stability between two models
# 019b18ef-ca2b-7e97-8ca5-d4b1d7eefdbb
# 4:9 343:399
# 179:200 343:434
# python eval_tools/temporal_analysis/compare_temporal_stability.py \
# --input1 /deeplearning_team/ydong/dongying/projects/yolov5-3d/runs/val_viz_300w/2026-01-07_14-21-06_roi0/tracking.json \
# --input2 /deeplearning_team/ydong/dongying/projects/monocular_3d_object_detection/yolov5_mono2d3d_augment_roi_for4face_double_roi_json_rickshaw_g1m3/output/2026-01-07_14-21-06_roi0/tracking.json \
# --track-mapping 20:9 488:399 \
# --class-id 3 \
# --model1-name "yolov5s-30w" \
# --model2-name "mono3d" \
# --output ./comparison_plots_300w/019b18ef-ca2b-7e97-8ca5-d4b1d7eefdbb_roi0/comparison_report.json \
# --output-dir ./comparison_plots_300w/019b18ef-ca2b-7e97-8ca5-d4b1d7eefdbb_roi0
# 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}"
python eval_tools/temporal_analysis/compare_temporal_stability.py \
--input1 /deeplearning_team/ydong/dongying/projects/yolov5-3d/runs/val_viz/2026-01-07_14-21-06_roi0/tracking.json \
--input2 /deeplearning_team/ydong/dongying/projects/monocular_3d_object_detection/yolov5_mono2d3d_augment_roi_for4face_double_roi_json_rickshaw_g1m3/output/2026-01-07_14-21-06_roi0/tracking.json \
--track-mapping 1:2 8:9 \
--class-id 0 \
--model1-name "yolov5s-30w" \
--model2-name "mono3d" \
--output ./comparison_plots_30w/2026-01-07_14-21-06_roi0/comparison_report.json \
--output-dir ./comparison_plots_30w/2026-01-07_14-21-06_roi0
# python eval_tools/temporal_analysis/compare_temporal_stability.py \
# --input1 /deeplearning_team/ydong/dongying/projects/yolov5-3d/runs/val_viz/20251210222153_roi0/tracking.json \
# --input2 /deeplearning_team/ydong/dongying/projects/monocular_3d_object_detection/yolov5_mono2d3d_augment_roi_for4face_double_roi_json_rickshaw_g1m3/output/20251210222153_roi0/tracking.json \
# --track-mapping 1:12 44:53 104:150 \
# --class-id 0 \
# --model1-name "yolov5s-30w" \
# --model2-name "mono3d" \
# --output ./comparison_plots_30w/20251210222153_roi0/comparison_report.json \
# --output-dir ./comparison_plots_30w/20251210222153_roi0

View File

@@ -0,0 +1,664 @@
"""
Compare temporal stability of 3D predictions between two models.
Usage:
python compare_temporal_stability.py \
--input1 runs/model1/tracking.json \
--input2 runs/model2/tracking.json \
--track-mapping 1:5 2:10 3:15 \
--class-id 0 \
--output comparison_report.json
"""
import argparse
import json
from pathlib import Path
import numpy as np
from collections import defaultdict
import matplotlib.pyplot as plt
import matplotlib
matplotlib.use('Agg')
def compute_angle_diff(angle1, angle2):
"""Compute the smallest difference between two angles in radians.
Args:
angle1, angle2: Angles in radians
Returns:
Angle difference in range [-pi, pi]
"""
diff = angle1 - angle2
while diff > np.pi:
diff -= 2 * np.pi
while diff < -np.pi:
diff += 2 * np.pi
return diff
def extract_trajectories(tracking_data):
"""Extract trajectories for each tracked object, organized by class.
Args:
tracking_data: List of frame data from tracking.json
Returns:
Dictionary mapping class_id to dict of {track_id: list of (frame_idx, detection) tuples}
"""
trajectories_by_class = defaultdict(lambda: defaultdict(list))
for frame_idx, frame_data in enumerate(tracking_data):
for det in frame_data.get('detections', []):
track_id = det.get('track_id')
class_id = det.get('class_id')
if track_id is not None and class_id is not None and 'object_3d' in det:
trajectories_by_class[class_id][track_id].append((frame_idx, det))
return trajectories_by_class
def extract_3d_data(trajectory):
"""Extract 3D data from trajectory.
Args:
trajectory: List of (frame_idx, detection) tuples
Returns:
Dictionary with extracted data arrays
"""
# Use dictionary to handle potential duplicate frames (take last occurrence)
frame_data = {}
for frame_idx, det in trajectory:
object_3d = det['object_3d']
# Support both list and dict formats
if isinstance(object_3d, dict):
x, y, z = object_3d['location']
l, h, w = object_3d['dimensions']
rot_y = object_3d['rotation_y']
else:
x, y, z = object_3d[0], object_3d[1], object_3d[2]
l, h, w = object_3d[3], object_3d[4], object_3d[5]
rot_y = object_3d[6]
frame_data[frame_idx] = {
'position': [x, y, z],
'dimension': [l, h, w],
'rotation': rot_y
}
# Sort by frame index and extract arrays
sorted_frames = sorted(frame_data.keys())
frames = []
positions = []
dimensions = []
rotations = []
for frame_idx in sorted_frames:
data = frame_data[frame_idx]
frames.append(frame_idx)
positions.append(data['position'])
dimensions.append(data['dimension'])
rotations.append(data['rotation'])
return {
'frames': np.array(frames),
'positions': np.array(positions),
'dimensions': np.array(dimensions),
'rotations': np.array(rotations),
}
def compute_trajectory_metrics(data1, data2):
"""Compute comparison metrics between two trajectories.
Args:
data1, data2: Extracted 3D data dictionaries
Returns:
Dictionary with comparison metrics
"""
# Ensure arrays have the same length
if len(data1['positions']) != len(data2['positions']):
raise ValueError(f"Data arrays have different lengths: {len(data1['positions'])} vs {len(data2['positions'])}")
# Position differences
pos_diff_mean = np.mean(np.linalg.norm(data1['positions'] - data2['positions'], axis=1))
pos_diff_std = np.std(np.linalg.norm(data1['positions'] - data2['positions'], axis=1))
pos_diff_max = np.max(np.linalg.norm(data1['positions'] - data2['positions'], axis=1))
# Dimension differences
dim_diff = np.abs(data1['dimensions'] - data2['dimensions'])
dim_diff_mean = np.mean(dim_diff, axis=0)
dim_diff_std = np.std(dim_diff, axis=0)
dim_diff_max = np.max(dim_diff, axis=0)
# Rotation differences
rot_diffs = []
for r1, r2 in zip(data1['rotations'], data2['rotations']):
diff = abs(compute_angle_diff(r1, r2))
rot_diffs.append(diff)
rot_diffs = np.array(rot_diffs)
rot_diff_mean = np.mean(rot_diffs)
rot_diff_std = np.std(rot_diffs)
rot_diff_max = np.max(rot_diffs)
# Position jitter (frame-to-frame variation)
pos_jitter1 = np.linalg.norm(np.diff(data1['positions'], axis=0), axis=1)
pos_jitter2 = np.linalg.norm(np.diff(data2['positions'], axis=0), axis=1)
# Dimension jitter
dim_jitter1 = np.abs(np.diff(data1['dimensions'], axis=0))
dim_jitter2 = np.abs(np.diff(data2['dimensions'], axis=0))
# Rotation jitter
rot_jitter1 = np.abs(np.diff(data1['rotations']))
rot_jitter2 = np.abs(np.diff(data2['rotations']))
metrics = {
'position_difference': {
'mean': float(pos_diff_mean),
'std': float(pos_diff_std),
'max': float(pos_diff_max),
},
'dimension_difference': {
'length_mean': float(dim_diff_mean[0]),
'height_mean': float(dim_diff_mean[1]),
'width_mean': float(dim_diff_mean[2]),
'length_std': float(dim_diff_std[0]),
'height_std': float(dim_diff_std[1]),
'width_std': float(dim_diff_std[2]),
'length_max': float(dim_diff_max[0]),
'height_max': float(dim_diff_max[1]),
'width_max': float(dim_diff_max[2]),
},
'rotation_difference': {
'mean': float(rot_diff_mean),
'std': float(rot_diff_std),
'max': float(rot_diff_max),
},
'position_jitter': {
'model1_mean': float(np.mean(pos_jitter1)),
'model2_mean': float(np.mean(pos_jitter2)),
'model1_std': float(np.std(pos_jitter1)),
'model2_std': float(np.std(pos_jitter2)),
},
'dimension_jitter': {
'model1_length_mean': float(np.mean(dim_jitter1[:, 0])),
'model2_length_mean': float(np.mean(dim_jitter2[:, 0])),
'model1_height_mean': float(np.mean(dim_jitter1[:, 1])),
'model2_height_mean': float(np.mean(dim_jitter2[:, 1])),
'model1_width_mean': float(np.mean(dim_jitter1[:, 2])),
'model2_width_mean': float(np.mean(dim_jitter2[:, 2])),
},
'rotation_jitter': {
'model1_mean': float(np.mean(rot_jitter1)),
'model2_mean': float(np.mean(rot_jitter2)),
'model1_std': float(np.std(rot_jitter1)),
'model2_std': float(np.std(rot_jitter2)),
},
}
return metrics
def plot_comparison(data1, data2, track_id1, track_id2, model1_name, model2_name, output_dir, class_id):
"""Generate comparison plots for two trajectories.
Args:
data1, data2: Extracted 3D data dictionaries
track_id1, track_id2: Track IDs from each model
model1_name, model2_name: Model names for labels
output_dir: Output directory for plots
class_id: Object class ID
"""
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
# Plot 1: Position (x, y, z) comparison
fig, axes = plt.subplots(3, 1, figsize=(14, 12))
axes[0].plot(data1['frames'], data1['positions'][:, 0], marker='o', markersize=4,
label=f'{model1_name} (Track {track_id1})', alpha=0.7, linewidth=2, color='blue')
axes[0].plot(data2['frames'], data2['positions'][:, 0], marker='s', markersize=4,
label=f'{model2_name} (Track {track_id2})', alpha=0.7, linewidth=2, color='red')
axes[0].set_ylabel('X Position (m)', fontsize=11)
axes[0].set_title(f'Class {class_id}: Position Comparison - {model1_name} vs {model2_name}', fontsize=13, fontweight='bold')
axes[0].legend(fontsize=10)
axes[0].grid(True, alpha=0.3)
axes[1].plot(data1['frames'], data1['positions'][:, 1], marker='o', markersize=4,
label=f'{model1_name} (Track {track_id1})', alpha=0.7, linewidth=2, color='blue')
axes[1].plot(data2['frames'], data2['positions'][:, 1], marker='s', markersize=4,
label=f'{model2_name} (Track {track_id2})', alpha=0.7, linewidth=2, color='red')
axes[1].set_ylabel('Y Position (m)', fontsize=11)
axes[1].legend(fontsize=10)
axes[1].grid(True, alpha=0.3)
axes[2].plot(data1['frames'], data1['positions'][:, 2], marker='o', markersize=4,
label=f'{model1_name} (Track {track_id1})', alpha=0.7, linewidth=2, color='blue')
axes[2].plot(data2['frames'], data2['positions'][:, 2], marker='s', markersize=4,
label=f'{model2_name} (Track {track_id2})', alpha=0.7, linewidth=2, color='red')
axes[2].set_ylabel('Z Position (m)', fontsize=11)
axes[2].set_xlabel('Frame Index', fontsize=11)
axes[2].legend(fontsize=10)
axes[2].grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig(output_dir / f'class_{class_id}_track_{track_id1}_{track_id2}_position_comparison.png', dpi=150, bbox_inches='tight')
plt.close()
# Plot 2: Position difference over time
fig, ax = plt.subplots(figsize=(14, 5))
pos_diff = np.linalg.norm(data1['positions'] - data2['positions'], axis=1)
ax.plot(data1['frames'], pos_diff, marker='o', markersize=4, linewidth=2, color='purple', alpha=0.7)
ax.fill_between(data1['frames'], 0, pos_diff, alpha=0.3, color='purple')
ax.set_xlabel('Frame Index', fontsize=11)
ax.set_ylabel('Position Difference (m)', fontsize=11)
ax.set_title(f'Class {class_id}: Position Difference - {model1_name} vs {model2_name}', fontsize=13, fontweight='bold')
ax.grid(True, alpha=0.3)
ax.axhline(y=np.mean(pos_diff), color='red', linestyle='--', label=f'Mean: {np.mean(pos_diff):.3f}m')
ax.legend(fontsize=10)
plt.tight_layout()
plt.savefig(output_dir / f'class_{class_id}_track_{track_id1}_{track_id2}_position_difference.png', dpi=150, bbox_inches='tight')
plt.close()
# Plot 3: Dimensions (l, h, w) comparison
fig, axes = plt.subplots(3, 1, figsize=(14, 12))
dim_labels = ['Length', 'Height', 'Width']
for i, label in enumerate(dim_labels):
axes[i].plot(data1['frames'], data1['dimensions'][:, i], marker='o', markersize=4,
label=f'{model1_name} (Track {track_id1})', alpha=0.7, linewidth=2, color='blue')
axes[i].plot(data2['frames'], data2['dimensions'][:, i], marker='s', markersize=4,
label=f'{model2_name} (Track {track_id2})', alpha=0.7, linewidth=2, color='red')
axes[i].set_ylabel(f'{label} (m)', fontsize=11)
if i == 0:
axes[i].set_title(f'Class {class_id}: Dimension Comparison - {model1_name} vs {model2_name}', fontsize=13, fontweight='bold')
axes[i].legend(fontsize=10)
axes[i].grid(True, alpha=0.3)
axes[2].set_xlabel('Frame Index', fontsize=11)
plt.tight_layout()
plt.savefig(output_dir / f'class_{class_id}_track_{track_id1}_{track_id2}_dimension_comparison.png', dpi=150, bbox_inches='tight')
plt.close()
# Plot 4: Dimension difference over time
fig, axes = plt.subplots(3, 1, figsize=(14, 10))
dim_diff = np.abs(data1['dimensions'] - data2['dimensions'])
for i, label in enumerate(dim_labels):
axes[i].plot(data1['frames'], dim_diff[:, i], marker='o', markersize=4, linewidth=2, color='purple', alpha=0.7)
axes[i].fill_between(data1['frames'], 0, dim_diff[:, i], alpha=0.3, color='purple')
axes[i].set_ylabel(f'{label} Diff (m)', fontsize=11)
if i == 0:
axes[i].set_title(f'Class {class_id}: Dimension Difference - {model1_name} vs {model2_name}', fontsize=13, fontweight='bold')
axes[i].grid(True, alpha=0.3)
axes[i].axhline(y=np.mean(dim_diff[:, i]), color='red', linestyle='--',
label=f'Mean: {np.mean(dim_diff[:, i]):.4f}m')
axes[i].legend(fontsize=10)
axes[2].set_xlabel('Frame Index', fontsize=11)
plt.tight_layout()
plt.savefig(output_dir / f'class_{class_id}_track_{track_id1}_{track_id2}_dimension_difference.png', dpi=150, bbox_inches='tight')
plt.close()
# Plot 5: Rotation comparison
fig, ax = plt.subplots(figsize=(14, 5))
ax.plot(data1['frames'], data1['rotations'], marker='o', markersize=4,
label=f'{model1_name} (Track {track_id1})', alpha=0.7, linewidth=2, color='blue')
ax.plot(data2['frames'], data2['rotations'], marker='s', markersize=4,
label=f'{model2_name} (Track {track_id2})', alpha=0.7, linewidth=2, color='red')
ax.set_ylabel('Rotation Y (rad)', fontsize=11)
ax.set_xlabel('Frame Index', fontsize=11)
ax.set_title(f'Class {class_id}: Rotation Comparison - {model1_name} vs {model2_name}', fontsize=13, fontweight='bold')
ax.legend(fontsize=10)
ax.grid(True, alpha=0.3)
ax.axhline(y=0, color='k', linestyle='--', alpha=0.3)
ax.axhline(y=np.pi, color='k', linestyle='--', alpha=0.3)
ax.axhline(y=-np.pi, color='k', linestyle='--', alpha=0.3)
plt.tight_layout()
plt.savefig(output_dir / f'class_{class_id}_track_{track_id1}_{track_id2}_rotation_comparison.png', dpi=150, bbox_inches='tight')
plt.close()
# Plot 6: Rotation difference over time
fig, ax = plt.subplots(figsize=(14, 5))
rot_diffs = [abs(compute_angle_diff(r1, r2)) for r1, r2 in zip(data1['rotations'], data2['rotations'])]
ax.plot(data1['frames'], rot_diffs, marker='o', markersize=4, linewidth=2, color='purple', alpha=0.7)
ax.fill_between(data1['frames'], 0, rot_diffs, alpha=0.3, color='purple')
ax.set_xlabel('Frame Index', fontsize=11)
ax.set_ylabel('Rotation Difference (rad)', fontsize=11)
ax.set_title(f'Class {class_id}: Rotation Difference - {model1_name} vs {model2_name}', fontsize=13, fontweight='bold')
ax.grid(True, alpha=0.3)
ax.axhline(y=np.mean(rot_diffs), color='red', linestyle='--', label=f'Mean: {np.mean(rot_diffs):.4f} rad')
ax.legend(fontsize=10)
plt.tight_layout()
plt.savefig(output_dir / f'class_{class_id}_track_{track_id1}_{track_id2}_rotation_difference.png', dpi=150, bbox_inches='tight')
plt.close()
# Plot 7: 3D trajectory comparison (bird's eye view)
fig, ax = plt.subplots(figsize=(12, 12))
# Plot trajectories
ax.plot(data1['positions'][:, 0], data1['positions'][:, 2], marker='o', markersize=5,
label=f'{model1_name} (Track {track_id1})', alpha=0.7, linewidth=2.5, color='blue')
ax.plot(data2['positions'][:, 0], data2['positions'][:, 2], marker='s', markersize=5,
label=f'{model2_name} (Track {track_id2})', alpha=0.7, linewidth=2.5, color='red')
# Mark start points
ax.scatter(data1['positions'][0, 0], data1['positions'][0, 2], s=150, marker='o',
edgecolors='darkblue', facecolors='blue', linewidths=3, zorder=5, label=f'{model1_name} Start')
ax.scatter(data2['positions'][0, 0], data2['positions'][0, 2], s=150, marker='s',
edgecolors='darkred', facecolors='red', linewidths=3, zorder=5, label=f'{model2_name} Start')
# Mark end points
ax.scatter(data1['positions'][-1, 0], data1['positions'][-1, 2], s=150, marker='^',
edgecolors='darkblue', facecolors='cyan', linewidths=3, zorder=5, label=f'{model1_name} End')
ax.scatter(data2['positions'][-1, 0], data2['positions'][-1, 2], s=150, marker='^',
edgecolors='darkred', facecolors='orange', linewidths=3, zorder=5, label=f'{model2_name} End')
# Draw lines connecting corresponding points
for i in range(len(data1['frames'])):
ax.plot([data1['positions'][i, 0], data2['positions'][i, 0]],
[data1['positions'][i, 2], data2['positions'][i, 2]],
color='gray', alpha=0.3, linewidth=1, linestyle='--', zorder=1)
ax.set_xlabel('X Position (m)', fontsize=11)
ax.set_ylabel('Z Position (m)', fontsize=11)
ax.set_title(f'Class {class_id}: 3D Trajectory Comparison (Bird\'s Eye View)\n{model1_name} vs {model2_name}',
fontsize=13, fontweight='bold')
ax.legend(fontsize=9, loc='best')
ax.grid(True, alpha=0.3)
ax.axis('equal')
plt.tight_layout()
plt.savefig(output_dir / f'class_{class_id}_track_{track_id1}_{track_id2}_trajectory_birds_eye.png', dpi=150, bbox_inches='tight')
plt.close()
# Plot 8: Jitter comparison (position stability)
fig, ax = plt.subplots(figsize=(14, 5))
pos_jitter1 = np.linalg.norm(np.diff(data1['positions'], axis=0), axis=1)
pos_jitter2 = np.linalg.norm(np.diff(data2['positions'], axis=0), axis=1)
frames_jitter = data1['frames'][1:] # Skip first frame since we're using diff
ax.plot(frames_jitter, pos_jitter1, marker='o', markersize=4,
label=f'{model1_name} Position Jitter', alpha=0.7, linewidth=2, color='blue')
ax.plot(frames_jitter, pos_jitter2, marker='s', markersize=4,
label=f'{model2_name} Position Jitter', alpha=0.7, linewidth=2, color='red')
ax.set_xlabel('Frame Index', fontsize=11)
ax.set_ylabel('Position Jitter (m/frame)', fontsize=11)
ax.set_title(f'Class {class_id}: Position Stability Comparison - {model1_name} vs {model2_name}', fontsize=13, fontweight='bold')
ax.legend(fontsize=10)
ax.grid(True, alpha=0.3)
ax.axhline(y=np.mean(pos_jitter1), color='blue', linestyle='--', alpha=0.5, label=f'{model1_name} Mean: {np.mean(pos_jitter1):.4f}')
ax.axhline(y=np.mean(pos_jitter2), color='red', linestyle='--', alpha=0.5, label=f'{model2_name} Mean: {np.mean(pos_jitter2):.4f}')
plt.tight_layout()
plt.savefig(output_dir / f'class_{class_id}_track_{track_id1}_{track_id2}_jitter_comparison.png', dpi=150, bbox_inches='tight')
plt.close()
print(f"Comparison plots saved to {output_dir}")
def parse_track_mapping(mapping_strings):
"""Parse track mapping from command line arguments.
Args:
mapping_strings: List of strings in format "id1:id2"
Returns:
List of tuples (id1, id2)
"""
mappings = []
for s in mapping_strings:
try:
id1, id2 = s.split(':')
mappings.append((int(id1), int(id2)))
except ValueError:
print(f"Warning: Invalid mapping format '{s}', expected 'id1:id2'")
continue
return mappings
def main():
parser = argparse.ArgumentParser(description='Compare temporal stability between two models')
parser.add_argument('--input1', type=str, required=True, help='First tracking.json file path (model 1)')
parser.add_argument('--input2', type=str, required=True, help='Second tracking.json file path (model 2)')
parser.add_argument('--track-mapping', type=str, nargs='+', required=True,
help='Track ID mappings in format "id1:id2" (e.g., --track-mapping 1:5 2:10)')
parser.add_argument('--class-id', type=int, default=None,
help='Filter by class ID (optional)')
parser.add_argument('--model1-name', type=str, default='Model 1',
help='Name for first model (for plot labels)')
parser.add_argument('--model2-name', type=str, default='Model 2',
help='Name for second model (for plot labels)')
parser.add_argument('--output', type=str, default='comparison_report.json',
help='Output comparison report JSON file path')
parser.add_argument('--output-dir', type=str, default=None,
help='Output directory for plots (default: same as output JSON)')
args = parser.parse_args()
# Read input tracking data
input1_path = Path(args.input1)
input2_path = Path(args.input2)
if not input1_path.exists():
print(f"Error: Input file 1 not found: {input1_path}")
return
if not input2_path.exists():
print(f"Error: Input file 2 not found: {input2_path}")
return
print(f"Loading tracking data from:")
print(f" Model 1: {input1_path}")
print(f" Model 2: {input2_path}")
with open(input1_path, 'r', encoding='utf-8') as f:
tracking_data1 = json.load(f)
with open(input2_path, 'r', encoding='utf-8') as f:
tracking_data2 = json.load(f)
print(f"Loaded {len(tracking_data1)} frames from model 1")
print(f"Loaded {len(tracking_data2)} frames from model 2")
print("Sorting frames by image name...")
tracking_data1.sort(key=lambda x: x.get('image_name', ''))
print("Sorting frames by image name...")
tracking_data2.sort(key=lambda x: x.get('image_name', ''))
# Extract trajectories
print("\nExtracting trajectories...")
trajectories_by_class1 = extract_trajectories(tracking_data1)
trajectories_by_class2 = extract_trajectories(tracking_data2)
total_tracks1 = sum(len(tracks) for tracks in trajectories_by_class1.values())
total_tracks2 = sum(len(tracks) for tracks in trajectories_by_class2.values())
print(f"Model 1: {total_tracks1} unique tracks across {len(trajectories_by_class1)} classes")
for class_id, tracks in sorted(trajectories_by_class1.items()):
print(f" Class {class_id}: {len(tracks)} tracks")
print(f"Model 2: {total_tracks2} unique tracks across {len(trajectories_by_class2)} classes")
for class_id, tracks in sorted(trajectories_by_class2.items()):
print(f" Class {class_id}: {len(tracks)} tracks")
# Parse track mappings
track_mappings = parse_track_mapping(args.track_mapping)
print(f"\nTrack mappings: {track_mappings}")
# Filter by class ID if specified and flatten the trajectories dict
if args.class_id is not None:
trajectories1 = trajectories_by_class1.get(args.class_id, {})
trajectories2 = trajectories_by_class2.get(args.class_id, {})
print(f"\nFiltered to class_id={args.class_id}:")
print(f" Model 1: {len(trajectories1)} tracks")
print(f" Model 2: {len(trajectories2)} tracks")
else:
# Flatten all classes into single dict if no class filter specified
trajectories1 = {}
for class_tracks in trajectories_by_class1.values():
trajectories1.update(class_tracks)
trajectories2 = {}
for class_tracks in trajectories_by_class2.values():
trajectories2.update(class_tracks)
print(f"\nUsing all classes:")
print(f" Model 1: {len(trajectories1)} total tracks")
print(f" Model 2: {len(trajectories2)} total tracks")
# Determine output directory
output_path = Path(args.output)
if args.output_dir:
output_dir = Path(args.output_dir)
else:
output_dir = output_path.parent / 'comparison_plots'
# Process each track mapping
comparison_results = []
for track_id1, track_id2 in track_mappings:
print(f"\n{'='*80}")
print(f"Processing track mapping: Model 1 Track {track_id1} <-> Model 2 Track {track_id2}")
print('='*80)
# Check if tracks exist
if track_id1 not in trajectories1:
print(f"Warning: Track {track_id1} not found in model 1, skipping")
continue
if track_id2 not in trajectories2:
print(f"Warning: Track {track_id2} not found in model 2, skipping")
continue
traj1 = trajectories1[track_id1]
traj2 = trajectories2[track_id2]
# Get class IDs
class_id1 = traj1[0][1].get('class_id')
class_id2 = traj2[0][1].get('class_id')
# Filter by class if specified
if args.class_id is not None:
if class_id1 != args.class_id:
print(f"Warning: Track {track_id1} has class_id={class_id1}, expected {args.class_id}, skipping")
continue
if class_id2 != args.class_id:
print(f"Warning: Track {track_id2} has class_id={class_id2}, expected {args.class_id}, skipping")
continue
# Check if classes match
if class_id1 != class_id2:
print(f"Warning: Class mismatch - Track {track_id1} (class {class_id1}) vs Track {track_id2} (class {class_id2})")
print("Continuing with comparison, but results may not be meaningful")
print(f"Model 1 Track {track_id1}: {len(traj1)} frames, class {class_id1}")
print(f"Model 2 Track {track_id2}: {len(traj2)} frames, class {class_id2}")
# Find common frame range
frames1 = set([f for f, _ in traj1])
frames2 = set([f for f, _ in traj2])
common_frames = sorted(frames1.intersection(frames2))
if len(common_frames) < 2:
print(f"Warning: Only {len(common_frames)} common frames, need at least 2 for comparison, skipping")
continue
print(f"Common frames: {len(common_frames)} ({min(common_frames)} to {max(common_frames)})")
# Filter trajectories to common frames and remove duplicates
# Use dict to keep only one detection per frame (last occurrence)
traj1_dict = {f: d for f, d in traj1 if f in common_frames}
traj2_dict = {f: d for f, d in traj2 if f in common_frames}
# Ensure both have exactly the same frames
final_common_frames = sorted(set(traj1_dict.keys()).intersection(set(traj2_dict.keys())))
if len(final_common_frames) < 2:
print(f"Warning: After deduplication, only {len(final_common_frames)} common frames, skipping")
continue
# Convert back to sorted list of tuples
traj1_filtered = [(f, traj1_dict[f]) for f in final_common_frames]
traj2_filtered = [(f, traj2_dict[f]) for f in final_common_frames]
print(f"After alignment: {len(traj1_filtered)} frames for comparison")
# Extract 3D data
data1 = extract_3d_data(traj1_filtered)
data2 = extract_3d_data(traj2_filtered)
# Compute metrics
metrics = compute_trajectory_metrics(data1, data2)
# Generate plots
plot_comparison(data1, data2, track_id1, track_id2,
args.model1_name, args.model2_name, output_dir, class_id1)
# Store results
comparison_results.append({
'track_id_model1': track_id1,
'track_id_model2': track_id2,
'class_id_model1': class_id1,
'class_id_model2': class_id2,
'num_common_frames': len(common_frames),
'frame_range': [min(common_frames), max(common_frames)],
'metrics': metrics,
})
# Print summary for this pair
print(f"\nComparison Metrics:")
print(f" Position Difference: {metrics['position_difference']['mean']:.4f} ± {metrics['position_difference']['std']:.4f} m (max: {metrics['position_difference']['max']:.4f})")
print(f" Dimension Difference (L/H/W): {metrics['dimension_difference']['length_mean']:.4f} / {metrics['dimension_difference']['height_mean']:.4f} / {metrics['dimension_difference']['width_mean']:.4f} m")
print(f" Rotation Difference: {metrics['rotation_difference']['mean']:.4f} ± {metrics['rotation_difference']['std']:.4f} rad (max: {metrics['rotation_difference']['max']:.4f})")
print(f" Position Jitter (Model1/Model2): {metrics['position_jitter']['model1_mean']:.4f} / {metrics['position_jitter']['model2_mean']:.4f} m/frame")
print(f" Rotation Jitter (Model1/Model2): {metrics['rotation_jitter']['model1_mean']:.4f} / {metrics['rotation_jitter']['model2_mean']:.4f} rad/frame")
# Save comparison report
report = {
'model1': {
'input_file': str(input1_path),
'name': args.model1_name,
'total_frames': len(tracking_data1),
'total_tracks': len(trajectories1),
},
'model2': {
'input_file': str(input2_path),
'name': args.model2_name,
'total_frames': len(tracking_data2),
'total_tracks': len(trajectories2),
},
'comparison_results': comparison_results,
'summary': {
'num_comparisons': len(comparison_results),
'filter_class_id': args.class_id,
}
}
print(f"\n{'='*80}")
print("Saving comparison report...")
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(report, f, indent=2, ensure_ascii=False)
print(f"Report saved to: {output_path}")
print(f"Plots saved to: {output_dir}")
# Print overall summary
print(f"\n{'='*80}")
print("COMPARISON SUMMARY")
print('='*80)
print(f"Model 1: {args.model1_name}")
print(f"Model 2: {args.model2_name}")
print(f"Successful comparisons: {len(comparison_results)}")
if comparison_results:
# Compute aggregate statistics
avg_pos_diff = np.mean([r['metrics']['position_difference']['mean'] for r in comparison_results])
avg_rot_diff = np.mean([r['metrics']['rotation_difference']['mean'] for r in comparison_results])
print(f"\nAggregate Statistics:")
print(f" Average position difference: {avg_pos_diff:.4f} m")
print(f" Average rotation difference: {avg_rot_diff:.4f} rad ({np.rad2deg(avg_rot_diff):.2f}°)")
print("\nComparison completed!")
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,19 @@
#!/bin/bash
# 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}"
INPUT=/data1/dongying/Mono3d/G1M3/cases_feishu_results/torchscript_python_300w_newdata-cncap/6676060198_20260109113210_2026-01-09_11-33-53_voice-顿挫/merge_tracking.json
INPUT_DIR=$(dirname "$INPUT")
python tools/temporal_analysis/evaluate_temporal_stability.py \
--input "$INPUT" \
--class-id 0 \
--track-ids 3 \
--output "$INPUT_DIR/stability_car_123.json" \
--plots
# id: 2, 41, 102
# id: 2, 33, 131

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,498 @@
# Heading Debug 时序分析方案
## 1. 背景与目标
当前 `merge_json``roi0``roi1` 结果文件中已经保存了 `heading_debug` 字段,包含:
- `yaw_bin`
- `yaw_delta`
- `rot_y_decoded`
- `yaw_probs`
这些字段可以帮助区分 heading 不稳定到底来自:
1. `yaw_bin` 分类抖动
2. `yaw_delta` 回归不稳
3. `+pi / -pi` 附近的角度回绕假跳变
4. ROI0 / ROI1 分支选择不一致
5. 遮挡、截断、远距离带来的朝向歧义
本文档给出一套基于 `heading_debug` 的时序分析方案,目标是回答以下问题:
- heading 不稳定主要发生在哪些类别、距离段、ROI 分支中
- 不稳定的主因是 bin 选错,还是 delta 抖动
- merged 结果的不稳定是来自模型本身,还是来自 ROI merge / NMS 选择
- 是否存在 `+pi / -pi` 表达带来的假性跳变
## 2. 推荐输入数据
建议优先使用以下结果作为分析输入:
- `merge_json/*.json`
- `roi0/*.json`
- `roi1/*.json`
- `track_objects.py` 输出的 tracking 结果,如果已有 `track_id`
- `frame_timestamps_interp.csv`,如果需要按真实时间间隔分析
如果已经走过 `tools/temporal_analysis/track_objects.py`,则推荐直接在 tracking 结果基础上分析,因为其中通常已经补充了:
- `track_id`
- `frameId`
- `timestamp`
- `sub_cls`
如果暂时没有 `track_id`,也可以先做 frame-level 分析,或者用相邻帧 IoU / 3D center 最近邻做临时关联。
## 3. `heading_debug` 字段解释
### 3.1 原始字段
每条 detection 中新增:
```json
"heading_debug": {
"yaw_bin": 2,
"yaw_delta": 0.137,
"rot_y_decoded": -1.434,
"yaw_probs": [0.01, 0.08, 0.91, 0.03]
}
```
含义如下:
- `yaw_bin`: 当前选中的 orientation bin取值为 `0/1/2/3`
- `yaw_delta`: 当前选中 bin 内的 residual
- `rot_y_decoded`: 按 `yaw_bin + yaw_delta` 解码后的相机系朝向角
- `yaw_probs`: 4 个 bin 的 sigmoid 分数
### 3.2 注意事项
- `rot_y_decoded``xyzlhwyaw[6]` 都是相机系 yaw单位是弧度可以直接比较。
- `xyzlhwyaw_ego[6]` 在当前脚本中是角度制 `deg`,不要和前两者直接混用。
- `yaw_probs` 是对 4 个 bin 分别做 sigmoid 得到的分数,不是 softmax 概率。
- 不应使用“4 个值求和为 1”的理解。
- 更应关注最大分数、次大分数以及它们的差值。
## 4. 数据整理建议
建议先把 JSON 扁平化成一张分析表,每行对应一条 detection。至少保留以下字段
- `case_name`
- `image_name`
- `frameId`
- `timestamp`
- `track_id`,如果有
- `type` / `class_id` / `sub_cls`
- `score`
- `roi_id`
- `box2d`
- `xyzlhwyaw`
- `face_cls`
- `cut_cls`
- `heading_debug.yaw_bin`
- `heading_debug.yaw_delta`
- `heading_debug.rot_y_decoded`
- `heading_debug.yaw_probs`
建议继续派生以下分析字段:
- `x1, y1, x2, y2`
- `box_w = x2 - x1`
- `box_h = y2 - y1`
- `box_area = box_w * box_h`
- `depth_z = xyzlhwyaw[2]`
- `yaw_final = xyzlhwyaw[6]`
- `p1 = max(yaw_probs)`
- `p2 = second_max(yaw_probs)`
- `margin = p1 - p2`
- `num_pos_bins = count(yaw_probs > 0.5)`
### 4.1 推荐扁平表输出
建议至少落两份中间结果:
- `heading_debug_flat.csv`
- `heading_debug_track_summary.csv`
其中:
- `heading_debug_flat.csv` 用于逐 detection 排查
- `heading_debug_track_summary.csv` 用于 track 级汇总和排序
## 5. 第一轮:做全局统计总览
在看单条 case 前,先做整体统计,回答“不稳定集中出现在哪里”。
建议统计以下分布:
- 各类别的 `yaw_bin` 分布
- 各类别的 `margin` 分布
- 各类别在不同 `depth_z` 范围下的 `margin` 分布
- 各类别在不同 `box_area` 范围下的稳定性指标
- `roi_id = 0/1` 的稳定性差异
- `face_cls` / `cut_cls` 不同取值下的 heading 波动大小
重点观察:
- 不稳定是否集中在远距离、小框、低分样本
- 不稳定是否明显偏向某个 ROI 分支
- 不稳定是否集中在某个类别或某种可见面
## 6. 第二轮:做时序稳定性分析
如果具备 `track_id`,建议以 track 为基本单位分析。
### 6.1 必须做角度 wrap
直接做 `yaw_t - yaw_t-1` 会把 `+179° -> -179°` 误判成大跳变,因此角度差必须按环形处理:
```python
import numpy as np
def wrapped_angle_diff(a, b):
return np.arctan2(np.sin(a - b), np.cos(a - b))
```
其中:
- `a``b` 为弧度制 yaw
- `wrapped_angle_diff(a, b)` 输出范围约为 `[-pi, pi]`
### 6.2 推荐 track 级指标
对每条 track 统计:
- `track_len`
- `median(|Δyaw|)`
- `p95(|Δyaw|)`
- `max(|Δyaw|)`
- `yaw_bin_switch_count`
- `yaw_bin_switch_rate`
- `median(|Δyaw_delta|)`
- `max(|Δyaw_delta|)`
- `low_margin_ratio`,例如 `margin < 0.1` 的占比
- `roi_switch_count`
- `face_cls_switch_count`
- `cut_cls_switch_count`
其中:
- `Δyaw` 使用 wrap 后角度差
- `Δyaw_delta` 只建议在相邻帧 `yaw_bin` 相同的条件下统计
### 6.3 推荐时序样本筛选方式
可以先筛出最不稳定的 track
- `max(|Δyaw|)` 最大的 Top-K
- `p95(|Δyaw|)` 最大的 Top-K
- `yaw_bin_switch_rate` 最高的 Top-K
优先人工查看这些 track对定位问题最有效。
## 7. 根因归类规则
可以根据 `heading_debug` 的表现把不稳定样本归成以下几类。
### 7.1 `yaw_bin` 抖动型
特征:
- 相邻帧 `yaw_bin` 频繁切换
- 同时 `margin` 较小,例如 `< 0.1`
-`num_pos_bins >= 2`
说明:
- 问题更偏向 bin 分类头不稳定
- 常见于朝向模糊、遮挡、远距离、小目标
### 7.2 `yaw_delta` 抖动型
特征:
- 相邻帧 `yaw_bin` 基本不变
-`|Δyaw_delta|` 很大
- `margin` 还比较稳定
说明:
- bin 选对了,但 bin 内 residual 回归不稳定
- 更像回归头噪声,而不是分类歧义
### 7.3 `+pi / -pi` 回绕假跳变型
特征:
- 样本集中在 `yaw_bin = 3` 或接近 `±pi`
- 原始 yaw 数值正负跳变明显
- 但 wrap 后 `|Δyaw|` 实际并不大
说明:
- 这类更多是角度表示问题,不一定是真正的 heading 不稳
### 7.4 ROI 敏感型
特征:
- ROI0 和 ROI1 分支对同一目标给出的 `heading_debug` 差异较大
- merged 结果的不稳定伴随 `roi_id` 切换
说明:
- 问题可能来自 crop 视角差异、ROI 边界、分支选择或 cross-ROI merge
### 7.5 可见性 / 朝向歧义型
特征:
- heading 跳变时伴随 `score` 低、框小、距离远
- 同时 `face_cls``cut_cls` 也在变化
说明:
- 更像输入信息不足导致的朝向歧义,而非单纯模型数值抖动
### 7.6 180 度翻转型
特征:
- 相邻帧 `|Δyaw|` 接近 `pi`
- 同时 `face_cls` 可能发生 `front <-> tail` 切换
说明:
- 需要重点怀疑前后向语义混淆
- 通常不只是小噪声,而是 orientation 语义层面的错误
## 8. ROI0 / ROI1 / merged 的分层分析建议
强烈建议不要只看 `merge_json`,而是同时对比:
- `roi0/*.json`
- `roi1/*.json`
- `merge_json/*.json`
可以按以下逻辑判断问题来源:
### 情况 1ROI0 和 ROI1 都稳定,但 merged 不稳定
说明:
- 更可能是 cross-ROI NMS / merge / 分支选择问题
### 情况 2ROI0 和 ROI1 本身差异很大
说明:
- 更可能是模型对不同 crop 视角敏感
- 需要结合 ROI 边界、目标位置、可见面一起看
### 情况 3ROI0、ROI1、merged 都不稳定
说明:
- 更可能是模型本身 heading 头不稳
- 或目标本身确实难判定
## 9. 如果有 GT建议把误差拆成“bin 错”还是“delta 错”
如果测试数据带朝向标注,可以把 GT yaw 也编码成 `gt_bin + gt_delta`,编码规则与后处理保持一致:
- `[-pi/4, pi/4)` -> `bin 0`, `delta = yaw`
- `[pi/4, 3pi/4)` -> `bin 1`, `delta = yaw - pi/2`
- `[-3pi/4, -pi/4)` -> `bin 2`, `delta = yaw + pi/2`
- 其他区域 -> `bin 3`
- `yaw > 0``delta = yaw - pi`
- `yaw <= 0``delta = yaw + pi`
然后分别统计:
- `pred_bin == gt_bin` 的比例
-`pred_bin == gt_bin` 条件下,`|pred_delta - gt_delta|`
- 最终 `|wrapped(pred_yaw - gt_yaw)|`
这样可以直接判断:
- 如果主要问题是 `pred_bin != gt_bin`,则偏向分类头问题
- 如果 `pred_bin` 大多正确,但 `delta` 偏差大,则偏向回归头问题
## 10. 推荐优先产出的图表
建议优先做以下 4 类图:
### 10.1 Track yaw 时序图
横轴:
- `frameId``timestamp`
纵轴:
- `yaw_final``rot_y_decoded`
同时:
- 用颜色标出 `yaw_bin`
- 标记 `roi_id` 切换点
- 标记 `margin` 低于阈值的时刻
用途:
- 最直观地看 heading 跳变和 bin 切换的关系
### 10.2 `|Δyaw|` vs `margin` 散点图
横轴:
- `margin`
纵轴:
- `|Δyaw|`
用途:
- 判断大跳变是否集中在低置信 bin 竞争区域
### 10.3 `yaw_bin(t-1) -> yaw_bin(t)` 转移矩阵
用途:
- 看哪些 bin 最容易互相跳转
- 尤其关注 `0 <-> 3``1 <-> 2``front / tail` 相关模式
### 10.4 分桶稳定性统计图
按以下维度分桶:
- `class`
- `roi_id`
- `depth_z`
- `box_area`
- `face_cls`
统计每个桶里的:
- `median(|Δyaw|)`
- `p95(|Δyaw|)`
- `yaw_bin_switch_rate`
- `low_margin_ratio`
用途:
- 快速定位最差场景
## 11. 推荐执行顺序
建议按以下顺序推进:
### 第一步:做全量统计
先找出不稳定最严重的:
- 类别
- ROI
- 距离段
- 框尺度区间
### 第二步:抽 Top-K 最差 track
建议优先抽:
- `max(|Δyaw|)` 最大的 Top-K
- `yaw_bin_switch_rate` 最高的 Top-K
- `low_margin_ratio` 最高的 Top-K
### 第三步:逐条排查根因
每条 track 同时看:
- `yaw_bin`
- `yaw_delta`
- `yaw_probs`
- `margin`
- `roi_id`
- `face_cls`
- `cut_cls`
- `score`
- `depth_z`
### 第四步:归因总结
把问题归类到以下一种或几种:
- `yaw_bin` 抖动型
- `yaw_delta` 抖动型
- 角度回绕假跳变型
- ROI 敏感型
- 可见性歧义型
- 180 度翻转型
## 12. 最终建议输出物
建议最终沉淀以下分析产物:
- `heading_debug_flat.csv`
- `heading_debug_track_summary.csv`
- `heading_debug_bad_tracks.csv`
- `fig_track_yaw_series/` 目录
- `fig_margin_vs_dyaw.png`
- `fig_bin_transition_matrix.png`
- `fig_stability_by_depth.png`
- `fig_stability_by_roi.png`
- `heading_debug_analysis_report.md`
其中 `heading_debug_analysis_report.md` 建议包含:
- 哪些类别最不稳定
- 哪些场景最不稳定
- 主要根因归类占比
- 是否更偏向 bin 问题还是 delta 问题
- 是否存在明显 ROI merge 问题
## 13. 实施时的关键注意点
### 13.1 单位统一
- `rot_y_decoded``xyzlhwyaw[6]` 为弧度
- `xyzlhwyaw_ego[6]` 为角度
做差前必须统一单位。
### 13.2 不要直接看未 wrap 的 yaw 差值
所有时序稳定性指标都建议基于 wrap 后角度差计算。
### 13.3 `yaw_probs` 不要当 softmax 使用
它们是 4 个独立 sigmoid 分数,建议用:
- 最大值 `p1`
- 次大值 `p2`
- 差值 `margin`
- 是否多个分数同时偏高
而不是看其和是否为 1。
### 13.4 merged 和 ROI 分支要分开分析
如果只看最终 merged 结果,容易把问题都归到模型本身。实际上很多问题可能来自:
- ROI 视角差异
- ROI 边界裁切
- cross-ROI NMS / merge 策略
### 13.5 先做统计,再做人工看图
不要一开始就逐条翻 case。先做分布和排序效率更高。
## 14. 一句话结论
基于 `heading_debug` 的核心分析思路是:
- 先把 detection 扁平化并补齐 track / timestamp
- 再用 wrap 后角度差做时序稳定性统计
- 再结合 `yaw_bin``yaw_delta``margin``roi_id``face_cls` 做根因归类
- 最后区分问题到底来自 bin 分类、delta 回归、角度表达、ROI 分支还是目标本身歧义

View File

@@ -0,0 +1,366 @@
"""
Merge multiple per-source tracking JSON files into a single combined JSON.
Each source (roi0, roi1, merge) is the output of track_objects.py. The script
aligns frames by image_name, adds a "source" field to every detection, and
writes a unified JSON where each frame contains detections from all sources.
Output format (list of frame dicts):
[
{
"image_name": "1741824631_0",
"detections": [
{ "lane_assignment": 0, "track_id": 1, "class_id": 0, "bbox": [...], ... },
{ "lane_assignment": 1, "track_id": 1, ... },
{ "lane_assignment": 2, "track_id": 2, ... },
...
],
"tracking_stats": {
"roi0": { "total_tracks": 3, "active_tracks_by_class": {...} },
"roi1": { ... },
"merge": { ... }
}
},
...
]
Notes:
- track_id values are offset per source so they are globally unique:
roi0 keeps original IDs (09999), roi1 adds 10000, merge adds 20000.
- lane_assignment values: 0=roi0, 1=roi1, 2=merge (positional index in source_map).
- Missing sources are silently skipped (partial merge is valid).
- Frames present in some sources but not others are still included, with
detections only from the sources that have that frame.
Usage:
python merge_tracking_results.py \
--roi0 {case_dir}/roi0.json \
--roi1 {case_dir}/roi1.json \
--merge {case_dir}/merge.json \
--output {case_dir}/combined_tracking.json
# Batch mode: scan all cases under results_root/model_name
python merge_tracking_results.py \
--results-root /data1/dongying/Mono3d/G1M3/cases_regular \
--model-name model_20260228
# Custom source names (optional):
python merge_tracking_results.py \
--roi0 roi0.json --roi1 roi1.json --merge merge.json \
--source-names roi0_track roi1_track merge_track \
--output combined_tracking.json
"""
import argparse
import json
import re
from pathlib import Path
def normalize_image_name(name):
"""Normalize image_name to a canonical form for cross-source frame alignment.
merge_json filenames are saved as ``{saved_count:06d}_{img_name}_merged.json``
while roi0/roi1 use plain ``{img_name}.json``. This function strips both
the leading 6-digit counter prefix and the trailing ``_merged`` suffix so
all three sources resolve to the same key.
Examples::
'000001_1741824631_0_merged' -> '1741824631_0'
'1741824631_0' -> '1741824631_0' (unchanged)
"""
name = re.sub(r'^\d{6}_', '', name) # strip leading counter, e.g. '000001_'
name = re.sub(r'_merged$', '', name) # strip trailing '_merged'
return name
def load_tracking_json(path, verbose=True):
"""Load a tracking JSON file produced by track_objects.py.
Args:
path: Path to the JSON file (list of frame dicts).
Returns:
List of frame dicts, or empty list if file does not exist / is invalid.
"""
p = Path(path)
if not p.exists():
if verbose:
print(f"Warning: tracking file not found, skipping: {p}")
return []
with open(p, 'r', encoding='utf-8') as f:
data = json.load(f)
if not isinstance(data, list):
if verbose:
print(f"Warning: expected a list in {p}, got {type(data).__name__}. Skipping.")
return []
if verbose:
print(f"Loaded {len(data)} frame(s) from {p}")
return data
def build_source_map(roi0_path, roi1_path, merge_path, source_names, extra_sources=None, verbose=True):
"""Load all available tracking inputs for a single case."""
roi0_name, roi1_name, merge_name = source_names
source_map = []
if roi0_path is not None:
frames = load_tracking_json(roi0_path, verbose=verbose)
if frames:
source_map.append((roi0_name, frames))
if roi1_path is not None:
frames = load_tracking_json(roi1_path, verbose=verbose)
if frames:
source_map.append((roi1_name, frames))
if merge_path is not None:
frames = load_tracking_json(merge_path, verbose=verbose)
if frames:
source_map.append((merge_name, frames))
for source_name, source_path in extra_sources or []:
frames = load_tracking_json(source_path, verbose=verbose)
if frames:
source_map.append((source_name, frames))
return source_map
# Offset added to track_id per source so IDs are globally unique:
# source_idx 0 (roi0) -> no offset
# source_idx 1 (roi1) -> +10000
# source_idx 2 (merge) -> +20000
# source_idx 3+ (extra src) -> keep incrementing by +10000
TRACK_ID_OFFSET_PER_SOURCE = 10000
def merge_tracking_results(source_map):
"""Merge tracking results from multiple sources into a single per-frame list.
Args:
source_map: Ordered dict / list of (source_name, frames_list) pairs.
Each frames_list is the output of track_objects.py.
Returns:
List of merged frame dicts, one per unique image_name found across all
sources. Frames are ordered by the first source that contains them;
remaining sources are appended in order.
"""
# Build per-source index: image_name -> frame_dict
source_indices = {}
all_image_names_ordered = []
seen_names = set()
for source_name, frames in source_map:
idx = {}
for frame in frames:
name = normalize_image_name(frame.get('image_name') or '')
idx[name] = frame
if name not in seen_names:
all_image_names_ordered.append(name)
seen_names.add(name)
source_indices[source_name] = idx
merged_frames = []
for image_name in all_image_names_ordered:
merged_detections = []
merged_stats = {}
for source_idx, (source_name, _) in enumerate(source_map):
frame = source_indices[source_name].get(image_name)
if frame is None:
continue
# Tag every detection with its lane_assignment (0=roi0, 1=roi1, 2=merge)
# and offset track_id so it is globally unique across sources.
id_offset = source_idx * TRACK_ID_OFFSET_PER_SOURCE
for det in frame.get('detections', []):
tagged = dict(det)
tagged['lane_assignment'] = source_idx
if 'track_id' in tagged and tagged['track_id'] is not None:
tagged['track_id'] = tagged['track_id'] + id_offset
merged_detections.append(tagged)
# Collect per-source tracking_stats
if 'tracking_stats' in frame:
merged_stats[source_name] = frame['tracking_stats']
merged_frame = {
'image_name': image_name,
'detections': merged_detections,
}
if merged_stats:
merged_frame['tracking_stats'] = merged_stats
merged_frames.append(merged_frame)
return merged_frames
def write_merged_output(merged, output_path):
"""Persist merged frames to disk."""
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(merged, f, indent=2, ensure_ascii=False)
def print_merge_summary(merged, source_map, output_path):
"""Print aggregate detection counts per source for one merged output."""
idx_to_name = {i: s for i, (s, _) in enumerate(source_map)}
total_by_source = {s: 0 for s, _ in source_map}
for frame in merged:
for det in frame.get('detections', []):
lane = det.get('lane_assignment')
src = idx_to_name.get(lane)
if src is not None:
total_by_source[src] += 1
print(f"\nMerge complete: {len(merged)} frame(s) written to {output_path}")
for i, (src, _) in enumerate(source_map):
print(f" lane_assignment={i} ({src}): {total_by_source[src]} detection(s) total")
def merge_case(roi0_path, roi1_path, merge_path, output_path, source_names, extra_sources=None, verbose=True):
"""Merge one case from explicit input paths."""
source_map = build_source_map(
roi0_path,
roi1_path,
merge_path,
source_names,
extra_sources=extra_sources,
verbose=verbose,
)
if not source_map:
if verbose:
print("Error: No valid tracking files were loaded. Nothing to merge.")
return False
if verbose:
print(f"Merging {len(source_map)} source(s): {[s for s, _ in source_map]}")
merged = merge_tracking_results(source_map)
write_merged_output(merged, output_path)
if verbose:
print_merge_summary(merged, source_map, output_path)
return True
def find_case_dirs(results_root, model_name):
"""Return all case directories that contain roi1.json under results_root/model_name."""
search_root = Path(results_root) / model_name
if not search_root.exists():
print(f"Error: model directory does not exist: {search_root}")
return []
return sorted(path.parent for path in search_root.rglob('roi1.json'))
def merge_cases_in_batch(results_root, model_name, source_names, output_name):
"""Scan case directories in Python and merge each one sequentially."""
case_dirs = find_case_dirs(results_root, model_name)
if not case_dirs:
print("Error: No roi1.json files were found. Nothing to merge.")
return False
print(f"Found {len(case_dirs)} case(s) under {Path(results_root) / model_name}")
merged_cases = 0
failed_cases = 0
for case_dir in case_dirs:
print("")
print("==========================================")
print(f"Case : {case_dir}")
print(f"Output : {case_dir / output_name}")
print("==========================================")
ok = merge_case(
roi0_path=case_dir / 'roi0.json',
roi1_path=case_dir / 'roi1.json',
merge_path=case_dir / 'merge.json',
output_path=case_dir / output_name,
source_names=source_names,
)
if ok:
merged_cases += 1
else:
failed_cases += 1
print("")
print("Batch merge complete")
print(f" merged_cases: {merged_cases}")
print(f" failed_cases: {failed_cases}")
return failed_cases == 0
def parse_extra_source_specs(specs):
"""Parse CLI specs of the form NAME=PATH for additional tracking sources."""
parsed = []
for spec in specs or []:
if '=' not in spec:
raise ValueError(f"Invalid --extra-source {spec!r}; expected NAME=PATH")
source_name, source_path = spec.split('=', 1)
source_name = source_name.strip()
source_path = source_path.strip()
if not source_name or not source_path:
raise ValueError(f"Invalid --extra-source {spec!r}; expected NAME=PATH")
parsed.append((source_name, source_path))
return parsed
def main():
parser = argparse.ArgumentParser(
description='Merge per-source tracking JSONs into a single combined JSON.'
)
parser.add_argument('--roi0', type=str, default=None,
help='Path to ROI0 tracking JSON (output of track_objects.py)')
parser.add_argument('--roi1', type=str, default=None,
help='Path to ROI1 tracking JSON (output of track_objects.py)')
parser.add_argument('--merge', type=str, default=None,
help='Path to merged-ROI tracking JSON (output of track_objects.py)')
parser.add_argument('--results-root', type=str, default=None,
help='Batch mode root directory that contains per-model case results')
parser.add_argument('--model-name', type=str, default=None,
help='Batch mode model directory name under results-root')
parser.add_argument('--output-name', type=str, default='combined_tracking.json',
help='Batch mode output filename written inside each case directory')
parser.add_argument('--source-names', type=str, nargs=3,
default=['roi0', 'roi1', 'merge'],
metavar=('ROI0_NAME', 'ROI1_NAME', 'MERGE_NAME'),
help='Custom labels for the three sources (default: roi0 roi1 merge)')
parser.add_argument('--extra-source', type=str, action='append', default=[],
metavar='NAME=PATH',
help='Additional tracking source to append in single-case mode. Can be passed multiple times.')
parser.add_argument('--output', type=str, default=None,
help='Output path for the combined tracking JSON in single-case mode')
args = parser.parse_args()
if args.results_root or args.model_name:
if not args.results_root or not args.model_name:
parser.error('--results-root and --model-name must be provided together in batch mode')
merge_cases_in_batch(
results_root=args.results_root,
model_name=args.model_name,
source_names=args.source_names,
output_name=args.output_name,
)
return
if args.output is None:
parser.error('--output is required in single-case mode')
try:
extra_sources = parse_extra_source_specs(args.extra_source)
except ValueError as exc:
parser.error(str(exc))
merge_case(
roi0_path=args.roi0,
roi1_path=args.roi1,
merge_path=args.merge,
output_path=args.output,
source_names=args.source_names,
extra_sources=extra_sources,
)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,21 @@
#!/bin/bash
# 将三路跟踪结果roi0.json / roi1.json / merge.json合并为 combined_tracking.json
# 前提:已运行 track_objects.sh 生成上述三个 JSON 文件
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
export PYTHONPATH="${PROJECT_ROOT}:${PYTHONPATH}"
RESULTS_ROOT="/data1/dongying/Mono3d/G1M3/cases_regular"
MODEL_NAME="model_20260228"
# python "${PROJECT_ROOT}/tools/temporal_analysis/merge_tracking_results.py" \
# --results-root "${RESULTS_ROOT}" \
# --model-name "${MODEL_NAME}"
python "${PROJECT_ROOT}/tools/temporal_analysis/merge_tracking_results.py" \
--roi0 /data1/dongying/Mono3d/G1M3/cases_regular/model_20260228_test/roi0.json \
--roi1 /data1/dongying/Mono3d/G1M3/cases_regular/model_20260228_test/roi1.json \
--merge /data1/dongying/Mono3d/G1M3/cases_regular/model_20260228_test/merge.json \
--output-name combined_tracking.json \
--output /data1/dongying/Mono3d/G1M3/cases_regular/model_20260228_test/combined_tracking.json

View File

@@ -0,0 +1,340 @@
#!/usr/bin/env python3
"""Plot range-vs-2D-feature correlation figures for one tracked object."""
from __future__ import annotations
import argparse
import csv
import json
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
DEFAULT_FEATURES = ("h", "area", "y2")
FEATURE_LABELS = {
"x_ego": "X Ego (m)",
"w": "BBox Width (px)",
"h": "BBox Height (px)",
"area": "BBox Area (px^2)",
"x1": "BBox x1 (px)",
"x2": "BBox x2 (px)",
"y1": "BBox y1 (px)",
"y2": "BBox y2 (px)",
"cx": "BBox cx (px)",
"cy": "BBox cy (px)",
"aspect": "BBox Aspect",
}
FEATURE_COLORS = {
"x_ego": "#1d4ed8",
"h": "#059669",
"area": "#b45309",
"y2": "#b91c1c",
"y1": "#7c3aed",
"cy": "#db2777",
"w": "#0f766e",
"x1": "#475569",
"x2": "#9333ea",
"cx": "#ea580c",
"aspect": "#0891b2",
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--tracking", required=True, type=Path, help="Tracking JSON, e.g. merge.json")
parser.add_argument("--series-csv", required=True, type=Path, help="Per-track temporal observation CSV")
parser.add_argument("--track-id", required=True, type=int)
parser.add_argument("--output-dir", required=True, type=Path)
parser.add_argument(
"--feature",
action="append",
dest="features",
help="2D feature to include. Can repeat. Defaults to h, area, y2.",
)
parser.add_argument("--smooth-window", type=int, default=21)
parser.add_argument("--max-lag", type=int, default=20)
return parser.parse_args()
def load_series_csv(path: Path) -> list[dict]:
rows = list(csv.DictReader(path.open()))
for row in rows:
row["frame_id"] = int(row["frame_id"])
row["frame_idx"] = int(row["frame_idx"])
row["x_ego"] = float(row["x_ego"])
return rows
def load_track_feature_series(tracking_path: Path, frame_ids: set[int], track_id: int) -> list[dict]:
frames = json.loads(tracking_path.read_text())
out = []
for frame in frames:
det = next((item for item in frame.get("detections", []) if int(item.get("track_id", -1)) == track_id), None)
if det is None:
continue
frame_id = int(det.get("frameId", det.get("frame_id")))
if frame_id not in frame_ids:
continue
x1, y1, x2, y2 = map(float, det["bbox"])
w = x2 - x1
h = y2 - y1
out.append(
{
"frame_id": frame_id,
"x1": x1,
"y1": y1,
"x2": x2,
"y2": y2,
"cx": (x1 + x2) * 0.5,
"cy": (y1 + y2) * 0.5,
"w": w,
"h": h,
"area": w * h,
"aspect": w / h if h else 0.0,
}
)
out.sort(key=lambda item: item["frame_id"])
return out
def moving_average(values: np.ndarray, window: int) -> np.ndarray:
if window <= 1:
return values.copy()
pad = window // 2
padded = np.pad(values, (pad, pad), mode="edge")
kernel = np.ones(window, dtype=np.float64) / window
return np.convolve(padded, kernel, mode="valid")
def detrend(values: np.ndarray) -> np.ndarray:
x = np.arange(len(values), dtype=np.float64)
coef = np.polyfit(x, values, 1)
return values - np.polyval(coef, x)
def zscore(values: np.ndarray) -> np.ndarray:
std = float(np.std(values))
if std == 0.0:
return np.zeros_like(values, dtype=np.float64)
return (values - float(np.mean(values))) / std
def corr(a: np.ndarray, b: np.ndarray) -> float:
a = a.astype(np.float64)
b = b.astype(np.float64)
a = a - a.mean()
b = b - b.mean()
denom = float(np.sqrt(np.dot(a, a) * np.dot(b, b)))
if denom == 0.0:
return 0.0
return float(np.dot(a, b) / denom)
def build_arrays(series_rows: list[dict], feature_rows: list[dict], features: list[str]) -> dict[str, np.ndarray]:
feature_map = {row["frame_id"]: row for row in feature_rows}
frame_ids = [row["frame_id"] for row in series_rows if row["frame_id"] in feature_map]
result = {
"frame_id": np.asarray(frame_ids, dtype=np.int64),
"x_ego": np.asarray([row["x_ego"] for row in series_rows if row["frame_id"] in feature_map], dtype=np.float64),
}
for feat in features:
result[feat] = np.asarray([feature_map[fid][feat] for fid in frame_ids], dtype=np.float64)
return result
def save_summary(path: Path, payload: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def plot_raw_curves(arrays: dict[str, np.ndarray], features: list[str], output_path: Path) -> None:
frame_id = arrays["frame_id"]
fig, axes = plt.subplots(len(features) + 1, 1, figsize=(14, 3.2 * (len(features) + 1)), sharex=True)
if len(features) == 0:
axes = [axes]
axes[0].plot(frame_id, arrays["x_ego"], color=FEATURE_COLORS["x_ego"], linewidth=1.4)
axes[0].set_ylabel(FEATURE_LABELS["x_ego"])
axes[0].set_title("Raw Curves")
axes[0].grid(True, alpha=0.3)
for idx, feat in enumerate(features, start=1):
axes[idx].plot(frame_id, arrays[feat], color=FEATURE_COLORS.get(feat, "#374151"), linewidth=1.2)
axes[idx].set_ylabel(FEATURE_LABELS.get(feat, feat))
axes[idx].grid(True, alpha=0.3)
axes[-1].set_xlabel("Frame ID")
fig.tight_layout()
fig.savefig(output_path, dpi=150)
plt.close(fig)
def plot_smoothed_detrended_overlay(
arrays: dict[str, np.ndarray],
features: list[str],
smooth_window: int,
output_path: Path,
) -> dict[str, np.ndarray]:
frame_id = arrays["frame_id"]
processed = {}
fig, ax = plt.subplots(figsize=(14, 6))
x_vals = zscore(detrend(moving_average(arrays["x_ego"], smooth_window)))
processed["x_ego"] = x_vals
ax.plot(frame_id, x_vals, color=FEATURE_COLORS["x_ego"], linewidth=2.0, label="x_ego")
for feat in features:
vals = zscore(detrend(moving_average(arrays[feat], smooth_window)))
processed[feat] = vals
ax.plot(frame_id, vals, linewidth=1.6, label=feat, color=FEATURE_COLORS.get(feat, "#374151"))
ax.set_title(f"Smoothed + Detrended + Z-scored Curves (window={smooth_window})")
ax.set_xlabel("Frame ID")
ax.set_ylabel("Z-score")
ax.grid(True, alpha=0.3)
ax.legend()
fig.tight_layout()
fig.savefig(output_path, dpi=150)
plt.close(fig)
return processed
def plot_delta_overlay(arrays: dict[str, np.ndarray], features: list[str], output_path: Path) -> dict[str, np.ndarray]:
frame_id = arrays["frame_id"][1:]
processed = {}
fig, ax = plt.subplots(figsize=(14, 6))
dx = np.diff(arrays["x_ego"])
processed["x_ego"] = dx
ax.plot(frame_id, zscore(dx), color=FEATURE_COLORS["x_ego"], linewidth=2.0, label="Δx_ego")
for feat in features:
vals = np.diff(arrays[feat])
processed[feat] = vals
ax.plot(frame_id, zscore(vals), linewidth=1.4, label=f"Δ{feat}", color=FEATURE_COLORS.get(feat, "#374151"))
ax.set_title("Frame-to-Frame Delta Curves (Z-scored)")
ax.set_xlabel("Frame ID")
ax.set_ylabel("Z-score")
ax.grid(True, alpha=0.3)
ax.legend()
fig.tight_layout()
fig.savefig(output_path, dpi=150)
plt.close(fig)
return processed
def plot_delta_scatter(delta_arrays: dict[str, np.ndarray], features: list[str], output_path: Path) -> dict[str, dict[str, float]]:
dx = delta_arrays["x_ego"]
fig, axes = plt.subplots(1, len(features), figsize=(5.3 * len(features), 4.6))
if len(features) == 1:
axes = [axes]
summary = {}
for ax, feat in zip(axes, features):
vals = delta_arrays[feat]
signed = corr(dx, vals)
magnitude = corr(np.abs(dx), np.abs(vals))
ax.scatter(vals, dx, s=12, alpha=0.55, color=FEATURE_COLORS.get(feat, "#374151"))
ax.set_xlabel(f"Δ{feat}")
ax.set_ylabel("Δx_ego")
ax.set_title(f"{feat}\nr={signed:.3f}, |r|={magnitude:.3f}")
ax.grid(True, alpha=0.25)
summary[feat] = {
"corr_signed": signed,
"corr_magnitude": magnitude,
}
fig.tight_layout()
fig.savefig(output_path, dpi=150)
plt.close(fig)
return summary
def plot_cross_correlation(
processed: dict[str, np.ndarray],
features: list[str],
max_lag: int,
output_path: Path,
) -> dict[str, list[dict[str, float]]]:
ref = processed["x_ego"]
fig, ax = plt.subplots(figsize=(14, 6))
summary = {}
for feat in features:
vals = processed[feat]
points = []
for lag in range(-max_lag, max_lag + 1):
if lag < 0:
corr_value = corr(ref[-lag:], vals[:lag])
elif lag > 0:
corr_value = corr(ref[:-lag], vals[lag:])
else:
corr_value = corr(ref, vals)
points.append({"lag": lag, "corr": corr_value})
summary[feat] = points
ax.plot([item["lag"] for item in points], [item["corr"] for item in points], label=feat, linewidth=1.8)
ax.axhline(0.0, color="#6b7280", linestyle="--", linewidth=1)
ax.set_title("Cross-correlation of Low-frequency Components")
ax.set_xlabel("Lag (frames)")
ax.set_ylabel("Correlation")
ax.grid(True, alpha=0.3)
ax.legend()
fig.tight_layout()
fig.savefig(output_path, dpi=150)
plt.close(fig)
return summary
def main() -> int:
args = parse_args()
features = args.features or list(DEFAULT_FEATURES)
tracking_path = args.tracking.resolve()
series_csv = args.series_csv.resolve()
output_dir = args.output_dir.resolve()
output_dir.mkdir(parents=True, exist_ok=True)
series_rows = load_series_csv(series_csv)
frame_ids = {row["frame_id"] for row in series_rows}
feature_rows = load_track_feature_series(tracking_path, frame_ids, args.track_id)
arrays = build_arrays(series_rows, feature_rows, features)
plot_raw_curves(arrays, features, output_dir / "raw_curves.png")
smooth_processed = plot_smoothed_detrended_overlay(
arrays,
features,
smooth_window=args.smooth_window,
output_path=output_dir / "smoothed_detrended_zscore_overlay.png",
)
delta_arrays = plot_delta_overlay(arrays, features, output_dir / "delta_zscore_overlay.png")
scatter_summary = plot_delta_scatter(delta_arrays, features, output_dir / "delta_scatter.png")
cross_corr_summary = plot_cross_correlation(
smooth_processed,
features,
max_lag=args.max_lag,
output_path=output_dir / "cross_correlation.png",
)
summary = {
"track_id": args.track_id,
"features": features,
"tracking_json": str(tracking_path),
"series_csv": str(series_csv),
"smooth_window": args.smooth_window,
"max_lag": args.max_lag,
"scatter_summary": scatter_summary,
"cross_correlation": cross_corr_summary,
}
save_summary(output_dir / "summary.json", summary)
print(output_dir)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,25 @@
#!/bin/bash
# 简洁启动 analyze_heading_debug.py 的脚本
# 用法示例:
# bash run_analyze_heading_debug.sh /path/to/tracking.json
# bash run_analyze_heading_debug.sh /path/to/case_dir combined_tracking.json
# /data1/dongying/Mono3d/G1M3/pdcl_batch_infer/cases_aeb_v2/CPFA/25_6.5_20/019d2d81-68fc-7b69-45fe-930341ffd56e/merge.json
# /data1/dongying/Mono3d/G1M3/pdcl_batch_infer/cases_aeb_v2/CBNA/50_15_50/019d2d86-4a7b-72da-6e09-1e7ab3494d0b/merge.json
INPUT_PATH="$1"
JSON_NAME="$2"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PYTHON_SCRIPT="$SCRIPT_DIR/analyze_heading_debug.py"
if [ -z "$INPUT_PATH" ]; then
echo "用法: bash run_analyze_heading_debug.sh <input_path> [json_name]"
exit 1
fi
if [ -z "$JSON_NAME" ]; then
python "$PYTHON_SCRIPT" --input "$INPUT_PATH" --class-ids 1 3 --large-dyaw 0.78 --large-delta 0.78
else
python "$PYTHON_SCRIPT" --input "$INPUT_PATH" --json-name "$JSON_NAME" --class-ids 1 3 --large-dyaw 0.78 --large-delta 0.78
fi

View File

@@ -0,0 +1,107 @@
#!/usr/bin/env bash
set -euo pipefail
shopt -s nullglob
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
PYTHON_BIN="${PYTHON_BIN:-/deeplearning_team/ydong/dongying/miniconda/envs/yolov5/bin/python}"
CASE_ROOT="${CASE_ROOT:-/data1/dongying/Mono3d/G1M3/pdcl_batch_infer/cases_aeb_v2}"
SCENE_FILTER="${SCENE_FILTER:-}"
TEST_FILTER="${TEST_FILTER:-}"
CASE_FILTER="${CASE_FILTER:-}"
DRY_RUN="${DRY_RUN:-0}"
STOP_ON_ERROR="${STOP_ON_ERROR:-0}"
FORWARD_ARGS=("$@")
run_one_case() {
local scene_name="$1"
local test_name="$2"
local case_dir="$3"
local case_name
case_name="$(basename "$case_dir")"
local cmd=(
"$PYTHON_BIN"
"tools/temporal_analysis/analyze_heading_debug.py"
--input "$case_dir"
"${FORWARD_ARGS[@]}"
)
printf '[RUN] %s / %s / %s\n' "$scene_name" "$test_name" "$case_name"
if [[ "$DRY_RUN" == "1" ]]; then
printf ' '
printf '%q ' "${cmd[@]}"
printf '\n'
return 0
fi
(
cd "$REPO_ROOT"
"${cmd[@]}"
)
}
if [[ ! -d "$CASE_ROOT" ]]; then
echo "[ERROR] CASE_ROOT not found: $CASE_ROOT" >&2
exit 1
fi
total=0
success=0
failed=0
for scene_dir in "$CASE_ROOT"/*; do
[[ -d "$scene_dir" ]] || continue
scene_name="$(basename "$scene_dir")"
if [[ -n "$SCENE_FILTER" ]] && [[ ! "$scene_name" =~ $SCENE_FILTER ]]; then
continue
fi
for test_dir in "$scene_dir"/*; do
[[ -d "$test_dir" ]] || continue
test_name="$(basename "$test_dir")"
if [[ -n "$TEST_FILTER" ]] && [[ ! "$test_name" =~ $TEST_FILTER ]]; then
continue
fi
found_case=0
for case_dir in "$test_dir"/*; do
[[ -d "$case_dir" ]] || continue
case_name="$(basename "$case_dir")"
if [[ -n "$CASE_FILTER" ]] && [[ ! "$case_name" =~ $CASE_FILTER ]]; then
continue
fi
found_case=1
((total+=1))
if run_one_case "$scene_name" "$test_name" "$case_dir"; then
((success+=1))
else
((failed+=1))
printf '[FAIL] %s / %s / %s\n' \
"$scene_name" "$test_name" "$case_name" >&2
if [[ "$STOP_ON_ERROR" == "1" ]]; then
exit 1
fi
fi
done
if [[ "$found_case" == "0" ]]; then
printf '[SKIP] empty test dir: %s / %s\n' "$scene_name" "$test_name"
fi
done
done
printf '\n[DONE] total=%d success=%d failed=%d\n' "$total" "$success" "$failed"
if [[ "$failed" -gt 0 ]]; then
exit 1
fi

View File

@@ -0,0 +1,239 @@
#!/usr/bin/env python3
"""Split tracking JSON into one JSON file per frame while preserving track IDs.
Input format:
[
{
"image_name": "1741824631_0",
"detections": [
{
"class_id": 0,
"confidence": 0.91,
"bbox": [x1, y1, x2, y2],
"track_id": 7,
...
}
]
},
...
]
Output format (one file per frame, evaluator-friendly):
{
"0": {
"type": "0",
"type_name": "vehicle",
"score": "0.91",
"box2d": [x1, y1, x2, y2],
"xyzlhwyaw": [...],
"face_cls": "front",
"cut_cls": "0",
"cut_cls_name": "nocut",
"track_id": 7
},
"1": { ... }
}
Usage:
python tools/temporal_analysis/split_tracking_json.py \
--input /path/to/roi0.json \
--output-dir /path/to/json_results
"""
import argparse
import json
import os
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor
from merge_tracking_results import normalize_image_name
CLASS_ID_TO_NAME = {
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",
}
def format_scalar(value):
"""Keep ints as ints, convert everything else to plain JSON numbers/strings."""
if value is None:
return value
if isinstance(value, bool):
return value
if isinstance(value, int):
return value
if isinstance(value, float):
return float(value)
return value
def normalize_detection(det, keep_all_fields=False):
"""Convert one tracking detection to evaluator-friendly single-frame JSON schema."""
class_id = int(det.get("class_id", -1))
bbox = det.get("bbox", [])
confidence = det.get("confidence", 0.0)
item = {
"type": str(class_id),
"type_name": det.get("type_name") or CLASS_ID_TO_NAME.get(class_id, ""),
"score": confidence,
"box2d": [format_scalar(v) for v in bbox[:4]],
"face_cls": det.get("face_cls", "none"),
"cut_cls": str(det.get("cut_cls", -1)),
"cut_cls_name": det.get("cut_cls_name", "none"),
}
if "track_id" in det and det.get("track_id") is not None:
item["track_id"] = int(det["track_id"])
if "lane_assignment" in det and det.get("lane_assignment") is not None:
item["lane_assignment"] = int(det["lane_assignment"])
if "frameId" in det and det.get("frameId") is not None:
item["frameId"] = det["frameId"]
if "timestamp" in det and det.get("timestamp") is not None:
item["timestamp"] = format_scalar(det["timestamp"])
if "version" in det and det.get("version") is not None:
item["version"] = det["version"]
if "anchor" in det and det.get("anchor") is not None:
item["anchor"] = det["anchor"]
if "roi_id" in det and det.get("roi_id") is not None:
item["roi_id"] = int(det["roi_id"])
object_3d = det.get("object_3d")
if object_3d is not None:
item["xyzlhwyaw"] = [format_scalar(v) for v in object_3d[:7]]
object_3d_ego = det.get("object_3d_ego")
if object_3d_ego is not None:
item["xyzlhwyaw_ego"] = [format_scalar(v) for v in object_3d_ego[:7]]
if keep_all_fields:
for key, value in det.items():
if key in item or key in {"class_id", "confidence", "bbox", "object_3d", "object_3d_ego"}:
continue
item[key] = value
return item
def frame_output_name(frame):
"""Choose output filename stem for one frame."""
image_name = frame.get("image_name") or ""
stem = Path(image_name).stem if image_name else ""
return normalize_image_name(stem) if stem else "unknown"
def build_output_data(frame, keep_all_fields=False):
"""Convert one tracking frame to per-frame output payload."""
output_data = {}
for idx, det in enumerate(frame.get("detections", [])):
output_data[str(idx)] = normalize_detection(det, keep_all_fields=keep_all_fields)
return output_data
def write_one_frame(frame, output_dir, keep_all_fields=False, pretty=False):
"""Write one output JSON file and return its path."""
frame_name = frame_output_name(frame)
output_data = build_output_data(frame, keep_all_fields=keep_all_fields)
output_path = output_dir / f"{frame_name}.json"
dump_kwargs = {
"ensure_ascii": False,
}
if pretty:
dump_kwargs["indent"] = 2
else:
dump_kwargs["separators"] = (",", ":")
with open(output_path, "w", encoding="utf-8") as f:
json.dump(output_data, f, **dump_kwargs)
return output_path
def split_tracking_json(input_path, output_dir, keep_all_fields=False, num_workers=1, pretty=False):
"""Split a tracking JSON list into one frame file per item."""
input_path = Path(input_path)
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
with open(input_path, "r", encoding="utf-8") as f:
frames = json.load(f)
if not isinstance(frames, list):
raise ValueError(f"Expected list of frames in {input_path}, got {type(frames).__name__}")
max_workers = max(1, int(num_workers))
if max_workers == 1 or len(frames) <= 1:
for frame in frames:
write_one_frame(frame, output_dir, keep_all_fields=keep_all_fields, pretty=pretty)
return len(frames)
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = [
executor.submit(
write_one_frame,
frame,
output_dir,
keep_all_fields,
pretty,
)
for frame in frames
]
for future in futures:
future.result()
return len(frames)
def default_output_dir(input_path):
"""Return a default output directory next to the input JSON."""
input_path = Path(input_path)
return input_path.parent / f"{input_path.stem}_split"
def main():
parser = argparse.ArgumentParser(
description="Split tracking JSON into evaluator-friendly per-frame JSON files."
)
parser.add_argument("--input", required=True, help="Path to roi0.json / tracking.json / merge.json")
parser.add_argument("--output-dir", default=None,
help="Directory to write per-frame JSON files (default: <input_stem>_split)")
parser.add_argument("--keep-all-fields", action="store_true",
help="Keep extra tracking fields beyond the evaluator-friendly schema")
parser.add_argument("--num-workers", type=int, default=max(1, min(16, os.cpu_count() or 1)),
help="Number of parallel workers for per-frame JSON writing (default: %(default)s)")
parser.add_argument("--pretty", action="store_true",
help="Write indented JSON for readability; default uses compact JSON for speed")
args = parser.parse_args()
output_dir = Path(args.output_dir) if args.output_dir else default_output_dir(args.input)
written = split_tracking_json(
input_path=args.input,
output_dir=output_dir,
keep_all_fields=args.keep_all_fields,
num_workers=args.num_workers,
pretty=args.pretty,
)
print(f"Input tracking JSON : {args.input}")
print(f"Output directory : {output_dir}")
print(f"Frames written : {written}")
print(f"Worker count : {args.num_workers}")
print(f"Pretty JSON : {args.pretty}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,132 @@
#!/bin/bash
# 用法说明:
# 将跟踪后的聚合 JSON如 roi0.json拆分为逐帧 JSON 文件,便于接入评测工具。
#
# 默认对 RESULTS_ROOT/MODEL_NAME 下所有 case 批量处理 roi0.json
# {case_dir}/roi0.json -> {case_dir}/json_results/*.json
#
# 支持两种模式:
# 1) 批量模式(默认)
# bash tools/temporal_analysis/split_tracking_json.sh
# 2) 单 case 模式
# bash tools/temporal_analysis/split_tracking_json.sh /path/to/case
#
# 可选第二个参数指定输入 JSON 文件名:
# bash tools/temporal_analysis/split_tracking_json.sh /path/to/case roi1.json
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
export PYTHONPATH="${PROJECT_ROOT}:${PYTHONPATH:-}"
# -----------------------------------------------------------------------
# 配置区
# -----------------------------------------------------------------------
RESULTS_ROOT="/data1/dongying/Mono3d/G1M3/cases_regular"
MODEL_NAME="model_20260317"
DEFAULT_INPUT_JSON="merge.json"
DEFAULT_OUTPUT_DIR="json_results_merge"
KEEP_ALL_FIELDS=0
NUM_WORKERS_PER_CASE=8
NUM_CASE_WORKERS=8
split_one_case() {
local case_dir="$1"
local input_json_name="$2"
local output_dir_name="$3"
if [[ ! -d "${case_dir}" ]]; then
echo "Error: case directory does not exist: ${case_dir}"
return 1
fi
local input_json="${case_dir}/${input_json_name}"
local output_dir="${case_dir}/${output_dir_name}"
if [[ ! -f "${input_json}" ]]; then
echo "Warning: input tracking JSON not found, skipping: ${input_json}"
return 0
fi
echo ""
echo "--- Split tracking JSON ---"
echo "Case : ${case_dir}"
echo "Input JSON : ${input_json}"
echo "Output dir : ${output_dir}"
local cmd=(
python3 "${PROJECT_ROOT}/tools/temporal_analysis/split_tracking_json.py"
--input "${input_json}"
--output-dir "${output_dir}"
--num-workers "${NUM_WORKERS_PER_CASE}"
)
if [[ "${KEEP_ALL_FIELDS}" == "1" ]]; then
cmd+=(--keep-all-fields)
fi
"${cmd[@]}"
}
run_with_limited_jobs() {
local max_jobs="$1"
while true; do
local current_jobs
current_jobs=$(jobs -pr | wc -l)
if [[ "${current_jobs}" -lt "${max_jobs}" ]]; then
break
fi
sleep 0.2
done
}
echo ""
echo "######################################################################"
echo "# Split tracking JSON into per-frame evaluator-friendly JSON files"
echo "######################################################################"
if [[ -n "${1:-}" ]]; then
CASE_DIR="$1"
INPUT_JSON_NAME="${2:-${DEFAULT_INPUT_JSON}}"
echo "Single-case mode: ${CASE_DIR}"
split_one_case "${CASE_DIR}" "${INPUT_JSON_NAME}" "${DEFAULT_OUTPUT_DIR}"
exit $?
fi
MODEL_DIR="${RESULTS_ROOT}/${MODEL_NAME}"
if [[ ! -d "${MODEL_DIR}" ]]; then
echo "Error: model directory not found: ${MODEL_DIR}"
exit 1
fi
mapfile -t CASE_DIRS < <(find "${MODEL_DIR}" -mindepth 1 -maxdepth 1 -type d | sort)
if [[ ${#CASE_DIRS[@]} -eq 0 ]]; then
echo "Warning: no case directories found under ${MODEL_DIR}"
exit 0
fi
echo "Batch mode: found ${#CASE_DIRS[@]} case(s) under ${MODEL_DIR}"
echo "Input JSON name : ${DEFAULT_INPUT_JSON}"
echo "Output dir name : ${DEFAULT_OUTPUT_DIR}"
echo "Case workers : ${NUM_CASE_WORKERS}"
echo "Frame workers : ${NUM_WORKERS_PER_CASE}"
declare -a PIDS=()
for case_dir in "${CASE_DIRS[@]}"; do
run_with_limited_jobs "${NUM_CASE_WORKERS}"
split_one_case "${case_dir}" "${DEFAULT_INPUT_JSON}" "${DEFAULT_OUTPUT_DIR}" &
PIDS+=("$!")
done
for pid in "${PIDS[@]}"; do
if ! wait "${pid}"; then
echo "Error: one or more split jobs failed."
exit 1
fi
done
echo ""
echo "Split complete."

View File

@@ -0,0 +1,562 @@
#!/usr/bin/env python3
"""Track exported inference outputs at event scope across multiple clips.
This tool aggregates all clip-level per-frame JSON predictions under one
event directory, orders frames globally by timestamp parsed from filenames,
and then reuses the existing tracking logic from track_objects.py to produce
one tracking result set per event.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional
FILE = Path(__file__).resolve()
THIS_DIR = FILE.parent
if str(THIS_DIR) not in sys.path:
sys.path.insert(0, str(THIS_DIR))
from merge_tracking_results import TRACK_ID_OFFSET_PER_SOURCE # noqa: E402
from track_objects import ( # noqa: E402
TRACKED_CLASS_IDS,
count_unique_tracks,
parse_det_format,
save_tracking_results,
track_objects,
)
SOURCE_SPECS = (
("roi0", "roi0.json"),
("roi1", "roi1.json"),
("merge", "merge.json"),
)
DEFAULT_EVENT_OUTPUT_DIRNAME = "event_tracking"
@dataclass
class EventFrameRecord:
key: str
clip_case_name: str
clip_token: str
original_image_name: str
original_frame_id: Optional[int]
timestamp: Optional[int]
source_files: dict[str, Path] = field(default_factory=dict)
event_frame_index: int = -1
event_frame_id: int = -1
image_name: str = ""
def _safe_int(value: Any) -> Optional[int]:
if value is None:
return None
try:
return int(str(value).strip())
except (TypeError, ValueError):
return None
def _normalize_output_dir_token(value: Any) -> str:
token = re.sub(r'[\\/:*?"<>|\s]+', "_", str(value or "").strip())
return token.strip("._")
def _extract_date_name_from_records(records: Any) -> Optional[str]:
if not isinstance(records, list):
return None
for record in records:
if not isinstance(record, dict):
continue
source_record = record.get("source_record")
if not isinstance(source_record, dict):
continue
for key in ("date_name", "datename", "datetime", "date"):
value = source_record.get(key)
normalized = _normalize_output_dir_token(value)
if normalized:
return normalized
return None
def parse_frame_name_metadata(image_name: str) -> tuple[str, Optional[int], Optional[int]]:
"""Parse clip token, frame_id, and timestamp from an exported frame stem."""
stem = str(image_name or "").strip()
if not stem:
return "", None, None
parts = stem.split("_")
numeric_tail = []
while parts and parts[-1].isdigit() and len(numeric_tail) < 2:
numeric_tail.append(parts.pop())
numeric_tail.reverse()
clip_token = "_".join(parts).strip("_")
if len(numeric_tail) >= 2:
return clip_token, _safe_int(numeric_tail[0]), _safe_int(numeric_tail[1])
if len(numeric_tail) == 1:
return clip_token, _safe_int(numeric_tail[0]), None
return stem, None, None
def build_event_sort_key(record: EventFrameRecord) -> tuple[Any, ...]:
timestamp_missing = record.timestamp is None
timestamp_value = record.timestamp if record.timestamp is not None else float("inf")
frame_id_missing = record.original_frame_id is None
frame_id_value = record.original_frame_id if record.original_frame_id is not None else float("inf")
return (
timestamp_missing,
timestamp_value,
record.clip_case_name,
frame_id_missing,
frame_id_value,
record.original_image_name,
)
def find_event_case_dirs(event_dir: Path) -> list[Path]:
"""Return all clip-case directories directly under one event directory."""
case_dirs = []
for predictions_dir in sorted(event_dir.glob("*/predictions")):
if not predictions_dir.is_dir():
continue
case_dirs.append(predictions_dir.parent)
return case_dirs
def collect_event_frames(
event_dir: Path,
pattern: str,
*,
verbose: bool = True,
) -> tuple[list[EventFrameRecord], list[Path]]:
"""Collect and globally order all per-frame JSON files under one event."""
case_dirs = find_event_case_dirs(event_dir)
if not case_dirs:
raise FileNotFoundError(f"No clip case predictions found under event directory: {event_dir}")
frame_map: dict[str, EventFrameRecord] = {}
source_file_count = 0
for case_dir in case_dirs:
predictions_dir = case_dir / "predictions"
for source_name, _ in SOURCE_SPECS:
source_dir = predictions_dir / source_name
if not source_dir.is_dir():
continue
for json_file in sorted(source_dir.glob(pattern)):
source_file_count += 1
original_image_name = json_file.stem
clip_token, frame_id, timestamp = parse_frame_name_metadata(original_image_name)
key = f"{case_dir.name}:{original_image_name}"
record = frame_map.get(key)
if record is None:
record = EventFrameRecord(
key=key,
clip_case_name=case_dir.name,
clip_token=clip_token,
original_image_name=original_image_name,
original_frame_id=frame_id,
timestamp=timestamp,
)
frame_map[key] = record
elif record.timestamp is None and timestamp is not None:
record.timestamp = timestamp
elif record.original_frame_id is None and frame_id is not None:
record.original_frame_id = frame_id
record.source_files[source_name] = json_file
ordered_frames = sorted(frame_map.values(), key=build_event_sort_key)
if not ordered_frames:
raise FileNotFoundError(
f"No source frame JSON files matching {pattern!r} were found under event directory: {event_dir}"
)
for frame_index, record in enumerate(ordered_frames):
event_frame_id = frame_index + 1
timestamp_token = record.timestamp if record.timestamp is not None else event_frame_id
record.event_frame_index = frame_index
record.event_frame_id = event_frame_id
record.image_name = f"camera4_{event_frame_id:06d}_{int(timestamp_token)}"
if verbose:
print(f"Discovered {len(case_dirs)} clip case(s) under {event_dir}")
print(f"Collected {source_file_count} source frame JSON file(s)")
print(f"Built {len(ordered_frames)} event frame(s) after cross-clip ordering")
return ordered_frames, case_dirs
def load_event_metadata(event_dir: Path) -> dict[str, Any]:
"""Load optional event manifest metadata for reporting only."""
manifest_path = event_dir / "_status" / "event_manifest.json"
payload: dict[str, Any] = {}
if manifest_path.is_file():
with manifest_path.open("r", encoding="utf-8") as file:
payload = json.load(file)
event_id = payload.get("event_id", event_dir.name)
scene = payload.get("scene", event_dir.parent.name)
date_name = _extract_date_name_from_records(payload.get("records"))
if not date_name:
scene_manifest_path = event_dir.parent / "_status" / "scene_event_manifest.json"
if scene_manifest_path.is_file():
with scene_manifest_path.open("r", encoding="utf-8") as file:
scene_payload = json.load(file)
scene_records = scene_payload.get("records", [])
matched_records = [
record for record in scene_records
if isinstance(record, dict) and str(record.get("event_id", "")).strip() == str(event_id)
]
date_name = _extract_date_name_from_records(matched_records)
if not date_name:
date_name = DEFAULT_EVENT_OUTPUT_DIRNAME
return {
"event_id": event_id,
"scene": scene,
"manifest_path": str(manifest_path) if manifest_path.is_file() else "",
"clip_ids": payload.get("clip_ids", []),
"clip_count": int(payload.get("clip_count", 0) or 0),
"date_name": date_name,
}
def build_frame_info(record: EventFrameRecord, source_name: str) -> dict[str, Any]:
"""Build frame metadata that will be copied through track_objects.py."""
return {
"event_frame_index": record.event_frame_index,
"event_frame_id": record.event_frame_id,
"source": source_name,
"clip_case_name": record.clip_case_name,
"clip_token": record.clip_token,
"original_image_name": record.original_image_name,
"original_frame_id": record.original_frame_id,
"timestamp": record.timestamp,
"source_json_path": str(record.source_files[source_name]),
}
def load_source_predictions(
ordered_frames: list[EventFrameRecord],
source_name: str,
*,
model_version: Optional[str] = None,
) -> list[dict[str, Any]]:
"""Load all available frames for one source in event-global temporal order."""
predictions_data: list[dict[str, Any]] = []
for record in ordered_frames:
source_file = record.source_files.get(source_name)
if source_file is None:
continue
with source_file.open("r", encoding="utf-8") as file:
det_dict = json.load(file)
frame_info = build_frame_info(record, source_name)
frame_data = parse_det_format(
det_dict,
image_name=record.image_name,
timestamp_lookup=None,
model_version=model_version,
frame_info=frame_info,
)
frame_data["frame_info"] = frame_info
predictions_data.append(frame_data)
return predictions_data
def merge_event_tracking_results(
*,
ordered_frames: list[EventFrameRecord],
tracking_results_by_source: dict[str, list[dict[str, Any]]],
) -> list[dict[str, Any]]:
"""Merge per-source event tracking results while preserving event order."""
frame_maps = {
source_name: {
frame.get("image_name"): frame
for frame in tracking_results
}
for source_name, tracking_results in tracking_results_by_source.items()
}
merged_frames: list[dict[str, Any]] = []
for ordered_frame in ordered_frames:
image_name = ordered_frame.image_name
merged_detections = []
merged_stats = {}
frame_info = None
for source_idx, (source_name, _) in enumerate(SOURCE_SPECS):
frame = frame_maps.get(source_name, {}).get(image_name)
if frame is None:
continue
frame_info = frame_info or frame.get("frame_info")
for det in frame.get("detections", []):
tagged = dict(det)
tagged["lane_assignment"] = source_idx
if "track_id" in tagged and tagged["track_id"] is not None:
tagged["track_id"] = tagged["track_id"] + source_idx * TRACK_ID_OFFSET_PER_SOURCE
merged_detections.append(tagged)
if "tracking_stats" in frame:
merged_stats[source_name] = frame["tracking_stats"]
if not merged_detections and not merged_stats:
continue
merged_frame = {
"image_name": image_name,
"detections": merged_detections,
}
if frame_info is not None:
merged_frame["frame_info"] = frame_info
if merged_stats:
merged_frame["tracking_stats"] = merged_stats
merged_frames.append(merged_frame)
return merged_frames
def build_frame_manifest_payload(
*,
event_dir: Path,
output_dir: Path,
event_metadata: dict[str, Any],
case_dirs: list[Path],
ordered_frames: list[EventFrameRecord],
source_summaries: dict[str, dict[str, Any]],
merge_output_path: Path,
) -> dict[str, Any]:
return {
"event_dir": str(event_dir),
"output_dir": str(output_dir),
"event_id": event_metadata.get("event_id", event_dir.name),
"scene": event_metadata.get("scene", event_dir.parent.name),
"date_name": event_metadata.get("date_name", DEFAULT_EVENT_OUTPUT_DIRNAME),
"event_manifest_path": event_metadata.get("manifest_path", ""),
"clip_ids": event_metadata.get("clip_ids", []),
"clip_count": event_metadata.get("clip_count", len(case_dirs)),
"clip_case_dirs": [str(case_dir) for case_dir in case_dirs],
"source_summaries": source_summaries,
"merge_output_path": str(merge_output_path),
"event_frame_count": len(ordered_frames),
"frames": [
{
"event_frame_index": record.event_frame_index,
"event_frame_id": record.event_frame_id,
"image_name": record.image_name,
"timestamp": record.timestamp,
"clip_case_name": record.clip_case_name,
"clip_token": record.clip_token,
"original_image_name": record.original_image_name,
"original_frame_id": record.original_frame_id,
"source_files": {
source_name: str(path)
for source_name, path in sorted(record.source_files.items())
},
}
for record in ordered_frames
],
}
def run_event_tracking(
*,
event_dir: Path,
output_dir: Path,
file_pattern: str,
classes: list[int],
iou_threshold: float,
max_age: int,
min_hits: int,
distance_threshold: float,
use_3d: bool,
max_3d_distance: float,
model_version: Optional[str],
merge_output_name: str,
manifest_name: str,
verbose: bool = True,
) -> dict[str, Any]:
event_metadata = load_event_metadata(event_dir)
ordered_frames, case_dirs = collect_event_frames(event_dir, file_pattern, verbose=verbose)
output_dir.mkdir(parents=True, exist_ok=True)
tracking_results_by_source: dict[str, list[dict[str, Any]]] = {}
source_summaries: dict[str, dict[str, Any]] = {}
for source_name, output_name in SOURCE_SPECS:
predictions_data = load_source_predictions(
ordered_frames,
source_name,
model_version=model_version,
)
output_path = output_dir / output_name
if not predictions_data:
source_summaries[source_name] = {
"ok": False,
"reason": "no_frames",
"frames": 0,
"unique_tracks": 0,
"output_path": str(output_path),
}
if verbose:
print(f"Warning: no frames found for source {source_name} under {event_dir}")
continue
if verbose:
print("")
print(f"--- Tracking {source_name} at event scope ---")
print(f"Frames: {len(predictions_data)}")
print(f"Output: {output_path}")
tracking_results = track_objects(
predictions_data,
target_classes=classes,
iou_threshold=iou_threshold,
max_age=max_age,
min_hits=min_hits,
distance_threshold=distance_threshold,
use_3d=use_3d,
max_3d_distance=max_3d_distance,
verbose=verbose,
)
save_tracking_results(tracking_results, output_path)
tracking_results_by_source[source_name] = tracking_results
source_summaries[source_name] = {
"ok": True,
"frames": len(predictions_data),
"unique_tracks": count_unique_tracks(tracking_results),
"output_path": str(output_path),
}
combined_output_path = output_dir / merge_output_name
if not tracking_results_by_source:
raise RuntimeError(f"No valid source predictions were loaded for event: {event_dir}")
combined_tracking = merge_event_tracking_results(
ordered_frames=ordered_frames,
tracking_results_by_source=tracking_results_by_source,
)
save_tracking_results(combined_tracking, combined_output_path)
manifest_path = output_dir / manifest_name
manifest_payload = build_frame_manifest_payload(
event_dir=event_dir,
output_dir=output_dir,
event_metadata=event_metadata,
case_dirs=case_dirs,
ordered_frames=ordered_frames,
source_summaries=source_summaries,
merge_output_path=combined_output_path,
)
with manifest_path.open("w", encoding="utf-8") as file:
json.dump(manifest_payload, file, indent=2, ensure_ascii=False)
if verbose:
print("")
print("==========================================")
print(f"Event : {event_metadata.get('event_id', event_dir.name)}")
print(f"Scene : {event_metadata.get('scene', event_dir.parent.name)}")
print(f"Date : {event_metadata.get('date_name', DEFAULT_EVENT_OUTPUT_DIRNAME)}")
print(f"Clips : {len(case_dirs)}")
print(f"Frames : {len(ordered_frames)}")
print(f"Merge : {combined_output_path}")
print(f"Manifest: {manifest_path}")
for source_name, _ in SOURCE_SPECS:
summary = source_summaries.get(source_name, {})
status = "ok" if summary.get("ok") else summary.get("reason", "skipped")
print(
f" - {source_name}: {status}, frames={summary.get('frames', 0)}, "
f"tracks={summary.get('unique_tracks', 0)}"
)
print("==========================================")
return {
"event_dir": str(event_dir),
"output_dir": str(output_dir),
"manifest_path": str(manifest_path),
"merge_output_path": str(combined_output_path),
"event_frame_count": len(ordered_frames),
"clip_case_count": len(case_dirs),
"source_summaries": source_summaries,
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Track all clip-level exported inference results under one event directory."
)
parser.add_argument("--event-dir", required=True, help="Event directory containing multiple clip-case outputs")
parser.add_argument(
"--output-dir",
default=None,
help="Output directory for event-level tracking results (default: <event-dir>/event_tracking)",
)
parser.add_argument("--file-pattern", default="*.json", help="Glob pattern for per-frame JSONs in each source dir")
parser.add_argument(
"--classes",
type=int,
nargs="+",
default=None,
help="Class IDs to track (default: track_objects.py defaults)",
)
parser.add_argument("--iou-threshold", type=float, default=0.3)
parser.add_argument("--max-age", type=int, default=5)
parser.add_argument("--min-hits", type=int, default=1)
parser.add_argument("--distance-threshold", type=float, default=100.0)
parser.add_argument("--model-version", type=str, default=None)
parser.add_argument("--use-3d", action="store_true")
parser.add_argument("--max-3d-distance", type=float, default=10.0)
parser.add_argument("--merge-output-name", type=str, default="combined_tracking.json")
parser.add_argument("--manifest-name", type=str, default="frame_order_manifest.json")
parser.add_argument("--quiet", action="store_true", help="Reduce progress logging")
return parser.parse_args()
def main() -> None:
args = parse_args()
event_dir = Path(args.event_dir).resolve()
if not event_dir.is_dir():
raise FileNotFoundError(f"Event directory does not exist: {event_dir}")
output_dir = (
Path(args.output_dir).resolve()
if args.output_dir is not None
else event_dir / load_event_metadata(event_dir).get("date_name", DEFAULT_EVENT_OUTPUT_DIRNAME)
)
classes = list(TRACKED_CLASS_IDS) if args.classes is None else [int(cls_id) for cls_id in args.classes]
run_event_tracking(
event_dir=event_dir,
output_dir=output_dir,
file_pattern=args.file_pattern,
classes=classes,
iou_threshold=args.iou_threshold,
max_age=args.max_age,
min_hits=args.min_hits,
distance_threshold=args.distance_threshold,
use_3d=args.use_3d,
max_3d_distance=args.max_3d_distance,
model_version=args.model_version,
merge_output_name=args.merge_output_name,
manifest_name=args.manifest_name,
verbose=not args.quiet,
)
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,134 @@
#!/bin/bash
# 用法说明:
# 批量对 test_merged_model.sh 推理结果进行跟踪后处理,覆盖三路输入:
# ROI0: {case_dir}/roi0/ → {case_dir}/roi0.json
# ROI1: {case_dir}/roi1/ → {case_dir}/roi1.json
# Merge: {case_dir}/merge_json/ → {case_dir}/merge.json
# 并将三路跟踪结果合并为:
# {case_dir}/combined_tracking.json
#
# 支持两种模式:
# 1) 批量模式(默认):处理 RESULTS_ROOT/MODEL_NAME 下所有 case
# bash track_objects.sh
# 2) 单 case 模式:指定 case 目录路径作为第一个参数
# bash track_objects.sh /data1/dongying/Mono3d/G1M3/cases_regular/model_20260228/some_case
# 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}"
# -----------------------------------------------------------------------
# 配置区:与 test_merged_model.sh 保持一致
# -----------------------------------------------------------------------
# RESULTS_ROOT="/data1/dongying/Mono3d/G1M3/cases_feishu_results"
# MODEL_NAME="torchscript_python_300w_newdata-cncap-v6-ego"
# /data1/dongying/Mono3d/G1Q3/tmp/20251018215336_results/predictions
RESULTS_ROOT="/data1/dongying/Mono3d/G1M3/cases_regular"
RESULTS_ROOT="/data1/dongying/Mono3d/G1M3/pdcl_batch_infer/cases_aeb_v2/CBNA/"
MODEL_NAME="50_15_60"
NUM_WORKERS=16
# 公共跟踪参数
TRACK_CLASSES="${TRACK_CLASSES:-0 1 2 3 4 5 6 7 8 9 10 11 12}"
IOU_THRESH=0.3
MAX_AGE=5
DIST_THRESH=100
MAX_FRAMES="${MAX_FRAMES:-200}"
MODEL_VERSION="${MODEL_VERSION:-model_20260403}"
echo ""
echo "######################################################################"
echo "# Batch tracking and merge: single scan + case-level parallel execution"
echo "######################################################################"
FRAME_LIMIT_ARGS=()
if [[ -n "${MAX_FRAMES}" ]]; then
FRAME_LIMIT_ARGS+=(--max-frames "${MAX_FRAMES}")
fi
MODEL_VERSION_ARGS=()
if [[ -n "${MODEL_VERSION}" ]]; then
MODEL_VERSION_ARGS+=(--model-version "${MODEL_VERSION}")
fi
if [[ -n "$1" ]]; then
# ===================================================================
# 单 case 模式:对指定 case 目录进行跟踪 + 合并
# ===================================================================
CASE_DIR="$1"
if [[ ! -d "${CASE_DIR}" ]]; then
echo "Error: case directory does not exist: ${CASE_DIR}"
exit 1
fi
echo "Single-case mode: ${CASE_DIR}"
# 逐源跟踪
for SOURCE in roi0 roi1 merge; do
INPUT_DIR="${CASE_DIR}/${SOURCE}"
if [[ "${SOURCE}" == "merge" ]]; then
OUTPUT_FILE="${CASE_DIR}/merge.json"
else
OUTPUT_FILE="${CASE_DIR}/${SOURCE}.json"
fi
if [[ -d "${INPUT_DIR}" ]]; then
echo ""
echo "--- Tracking ${SOURCE} ---"
python "${PROJECT_ROOT}/tools/temporal_analysis/track_objects.py" \
--input-dir "${INPUT_DIR}" \
--output "${OUTPUT_FILE}" \
--file-pattern "*.json" \
--classes ${TRACK_CLASSES} \
--iou-threshold ${IOU_THRESH} \
--max-age ${MAX_AGE} \
--distance-threshold ${DIST_THRESH} \
"${FRAME_LIMIT_ARGS[@]}" \
"${MODEL_VERSION_ARGS[@]}"
else
echo "Warning: ${INPUT_DIR} not found, skipping ${SOURCE}"
fi
done
# 合并三路跟踪结果
echo ""
echo "--- Merging tracking results ---"
python "${PROJECT_ROOT}/tools/temporal_analysis/merge_tracking_results.py" \
--roi0 "${CASE_DIR}/roi0.json" \
--roi1 "${CASE_DIR}/roi1.json" \
--merge "${CASE_DIR}/merge.json" \
--output "${CASE_DIR}/combined_tracking.json"
else
# ===================================================================
# 批量模式:扫描 RESULTS_ROOT/MODEL_NAME 下所有 case
# ===================================================================
python "${PROJECT_ROOT}/tools/temporal_analysis/track_objects.py" \
--results-root "${RESULTS_ROOT}" \
--model-name "${MODEL_NAME}" \
--num-workers "${NUM_WORKERS}" \
--file-pattern "*.json" \
--classes ${TRACK_CLASSES} \
--iou-threshold ${IOU_THRESH} \
--max-age ${MAX_AGE} \
--distance-threshold ${DIST_THRESH} \
"${FRAME_LIMIT_ARGS[@]}" \
"${MODEL_VERSION_ARGS[@]}"
fi
# -----------------------------------------------------------------------
# 附加3D距离增强匹配注释状态按需启用
# -----------------------------------------------------------------------
# while IFS= read -r -d '' merge_json_dir; do
# case_dir=$(dirname "$merge_json_dir")
# output_file="${case_dir}/merge_tracking_3d.json"
# python "${PROJECT_ROOT}/tools/temporal_analysis/track_objects.py" \
# --input-dir "$merge_json_dir" \
# --output "$output_file" \
# --classes 0 1 2 3 4 5 6 7 8 9 10 11 12 \
# --iou-threshold 0.3 \
# --max-age 5 \
# --distance-threshold 100 \
# --use-3d \
# --max-3d-distance 2.0
# done < <(find "${RESULTS_ROOT}/${MODEL_NAME}" -type d -name "merge_json" -print0 | sort -z)

View File

@@ -0,0 +1,215 @@
#!/usr/bin/env bash
set -euo pipefail
shopt -s nullglob
# 用法说明:
# 批量对 test_merged_model.sh 推理结果进行跟踪后处理,覆盖三路输入:
# ROI0: {case_dir}/roi0/ → {case_dir}/roi0.json
# ROI1: {case_dir}/roi1/ → {case_dir}/roi1.json
# Merge: {case_dir}/merge_json/ → {case_dir}/merge.json
# 并将三路跟踪结果合并为:
# {case_dir}/combined_tracking.json
#
# 支持两种模式:
# 1) 批量模式(默认):遍历 BATCH_ROOT 下所有 场景/模型 目录
# bash track_objects_cncap.sh
# 2) 批量模式(指定根目录):第一个参数传场景根目录
# bash track_objects_cncap.sh /data1/dongying/Mono3d/G1M3/pdcl_batch_infer/cases_aeb_v2
# 3) 单 case 模式:第一个参数传具体 case 目录路径
# bash track_objects_cncap.sh /path/to/one_case
# 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:-}"
PYTHON_BIN="${PYTHON_BIN:-/deeplearning_team/ydong/dongying/miniconda/envs/yolov5/bin/python}"
# -----------------------------------------------------------------------
# 配置区
# -----------------------------------------------------------------------
# 批量根目录,目录结构约定为:
# BATCH_ROOT/<scene_name>/<model_name>/<case_name>/{roi0,roi1,merge_json}
BATCH_ROOT="${BATCH_ROOT:-/data1/dongying/Mono3d/G1M3/pdcl_batch_infer/cases_aeb_v2}"
# 可选过滤,留空表示不过滤:
# SCENE_FILTER="CBNA"
# MODEL_FILTER="50_15_60"
SCENE_FILTER="${SCENE_FILTER:-}"
MODEL_FILTER="${MODEL_FILTER:-}"
NUM_WORKERS="${NUM_WORKERS:-16}"
# 公共跟踪参数
TRACK_CLASSES="${TRACK_CLASSES:-0 1 2 3 4 5 6 7 8 9 10 11 12}"
IOU_THRESH=0.3
MAX_AGE=5
DIST_THRESH=100
MODEL_VERSION="${MODEL_VERSION:-}"
MERGE_OUTPUT_NAME="${MERGE_OUTPUT_NAME:-combined_tracking.json}"
echo ""
echo "######################################################################"
echo "# Batch tracking and merge: auto scene/model traversal"
echo "######################################################################"
MODEL_VERSION_ARGS=()
if [[ -n "${MODEL_VERSION}" ]]; then
MODEL_VERSION_ARGS+=(--model-version "${MODEL_VERSION}")
fi
is_case_dir() {
local dir_path="$1"
[[ -d "${dir_path}/roi0" || -d "${dir_path}/roi1" || -d "${dir_path}/merge_json" ]]
}
run_single_case() {
local case_dir="$1"
echo "Single-case mode: ${case_dir}"
for source in roi0 roi1 merge; do
local input_dir="${case_dir}/${source}"
local output_file
if [[ "${source}" == "merge" ]]; then
output_file="${case_dir}/merge.json"
else
output_file="${case_dir}/${source}.json"
fi
if [[ -d "${input_dir}" ]]; then
echo ""
echo "--- Tracking ${source} ---"
"${PYTHON_BIN}" "${PROJECT_ROOT}/tools/temporal_analysis/track_objects.py" \
--input-dir "${input_dir}" \
--output "${output_file}" \
--file-pattern "*.json" \
--classes ${TRACK_CLASSES} \
--iou-threshold ${IOU_THRESH} \
--max-age ${MAX_AGE} \
--distance-threshold ${DIST_THRESH} \
"${MODEL_VERSION_ARGS[@]}"
else
echo "Warning: ${input_dir} not found, skipping ${source}"
fi
done
echo ""
echo "--- Merging tracking results ---"
"${PYTHON_BIN}" "${PROJECT_ROOT}/tools/temporal_analysis/merge_tracking_results.py" \
--roi0 "${case_dir}/roi0.json" \
--roi1 "${case_dir}/roi1.json" \
--merge "${case_dir}/merge.json" \
--output "${case_dir}/${MERGE_OUTPUT_NAME}"
}
run_one_model() {
local results_root="$1"
local model_name="$2"
echo ""
printf '[RUN] scene=%s model=%s\n' "$(basename "${results_root}")" "${model_name}"
"${PYTHON_BIN}" "${PROJECT_ROOT}/tools/temporal_analysis/track_objects.py" \
--results-root "${results_root}" \
--model-name "${model_name}" \
--num-workers "${NUM_WORKERS}" \
--merge-output-name "${MERGE_OUTPUT_NAME}" \
--file-pattern "*.json" \
--classes ${TRACK_CLASSES} \
--iou-threshold ${IOU_THRESH} \
--max-age ${MAX_AGE} \
--distance-threshold ${DIST_THRESH} \
"${MODEL_VERSION_ARGS[@]}"
}
run_batch_root() {
local batch_root="$1"
local total_models=0
local success_models=0
local failed_models=0
local matched_scenes=0
echo "Batch-root mode: ${batch_root}"
[[ -n "${SCENE_FILTER}" ]] && echo "Scene filter: ${SCENE_FILTER}"
[[ -n "${MODEL_FILTER}" ]] && echo "Model filter: ${MODEL_FILTER}"
for scene_dir in "${batch_root}"/*; do
[[ -d "${scene_dir}" ]] || continue
local scene_name
scene_name="$(basename "${scene_dir}")"
if [[ -n "${SCENE_FILTER}" ]] && [[ ! "${scene_name}" =~ ${SCENE_FILTER} ]]; then
continue
fi
((matched_scenes+=1))
local found_model=0
for model_dir in "${scene_dir}"/*; do
[[ -d "${model_dir}" ]] || continue
local model_name
model_name="$(basename "${model_dir}")"
if [[ -n "${MODEL_FILTER}" ]] && [[ ! "${model_name}" =~ ${MODEL_FILTER} ]]; then
continue
fi
found_model=1
((total_models+=1))
if run_one_model "${scene_dir}" "${model_name}"; then
((success_models+=1))
else
((failed_models+=1))
printf '[FAIL] scene=%s model=%s\n' "${scene_name}" "${model_name}" >&2
fi
done
if [[ "${found_model}" == "0" ]]; then
printf '[SKIP] empty scene dir or no matched model: %s\n' "${scene_name}"
fi
done
if [[ "${matched_scenes}" -eq 0 ]]; then
echo "[ERROR] No matched scene directories found under: ${batch_root}" >&2
return 1
fi
if [[ "${total_models}" -eq 0 ]]; then
echo "[ERROR] No matched model directories found under: ${batch_root}" >&2
return 1
fi
echo ""
printf '[DONE] models=%d success=%d failed=%d\n' \
"${total_models}" "${success_models}" "${failed_models}"
[[ "${failed_models}" -eq 0 ]]
}
TARGET_PATH="${1:-${BATCH_ROOT}}"
if [[ ! -d "${TARGET_PATH}" ]]; then
echo "Error: target directory does not exist: ${TARGET_PATH}" >&2
exit 1
fi
if is_case_dir "${TARGET_PATH}"; then
run_single_case "${TARGET_PATH}"
else
run_batch_root "${TARGET_PATH}"
fi
# -----------------------------------------------------------------------
# 附加3D距离增强匹配注释状态按需启用
# -----------------------------------------------------------------------
# while IFS= read -r -d '' merge_json_dir; do
# case_dir=$(dirname "$merge_json_dir")
# output_file="${case_dir}/merge_tracking_3d.json"
# "${PYTHON_BIN}" "${PROJECT_ROOT}/tools/temporal_analysis/track_objects.py" \
# --input-dir "$merge_json_dir" \
# --output "$output_file" \
# --classes 0 1 2 3 4 5 6 7 8 9 10 11 12 \
# --iou-threshold 0.3 \
# --max-age 5 \
# --distance-threshold 100 \
# --use-3d \
# --max-3d-distance 2.0
# done < <(find "${BATCH_ROOT}" -mindepth 3 -maxdepth 3 -type d -name "merge_json" -print0 | sort -z)

View File

@@ -0,0 +1,333 @@
#!/usr/bin/env bash
set -euo pipefail
shopt -s nullglob
# 用法说明:
# 适配 run_two_roi_exported_onnx_infer_case.sh 的输出目录,对每个 case 执行:
# {case_dir}/predictions/roi0/ -> {case_dir}/roi0.json
# {case_dir}/predictions/roi1/ -> {case_dir}/roi1.json
# {case_dir}/predictions/merge/ -> {case_dir}/merge.json
# 并继续合并为:
# {case_dir}/combined_tracking.json
#
# 支持三种模式:
# 1) 批量模式(默认):遍历 RESULTS_ROOT 下所有包含 predictions/{roi0,roi1,merge} 的 case
# bash track_objects_exported_onnx_infer_case.sh
# 2) 指定根目录模式:第一个参数传推理输出根目录
# bash track_objects_exported_onnx_infer_case.sh /path/to/inference_output_root
# 3) 单 case 模式:第一个参数传 case 目录,或直接传 case_dir/predictions
# bash track_objects_exported_onnx_infer_case.sh /path/to/one_case
# bash track_objects_exported_onnx_infer_case.sh /path/to/one_case/predictions
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
export PYTHONPATH="${PROJECT_ROOT}:${PYTHONPATH:-}"
PYTHON_BIN="${PYTHON_BIN:-/deeplearning_team/ydong/dongying/miniconda/envs/dev/bin/python}"
# -----------------------------------------------------------------------
# 配置区:默认与 run_two_roi_exported_onnx_infer_case.sh 保持一致
# -----------------------------------------------------------------------
# /data1/dongying/Mono3d/G1Q3/model_inference/KPI/OP_KPI_SCENE/model_20260317_with_cls
RESULTS_ROOT="${RESULTS_ROOT:-/data1/dongying/Mono3d/G1Q3/model_inference/KPI/OP_KPI_SCENE/model_20260423-raw_no_edge}"
TRACK_CLASSES="${TRACK_CLASSES:-0 1 2 3 4 5 6 7 8 9 10 11 12}"
# TRACK_CLASSES="${TRACK_CLASSES:-0 1 2 3}"
IOU_THRESH="${IOU_THRESH:-0.3}"
MAX_AGE="${MAX_AGE:-5}"
MIN_HITS="${MIN_HITS:-1}"
DIST_THRESH="${DIST_THRESH:-100}"
MAX_3D_DISTANCE="${MAX_3D_DISTANCE:-10.0}"
MAX_FRAMES="${MAX_FRAMES:-}"
MODEL_VERSION="${MODEL_VERSION:-20260423}"
MERGE_OUTPUT_NAME="${MERGE_OUTPUT_NAME:-combined_tracking.json}"
FILE_PATTERN="${FILE_PATTERN:-*.json}"
ENABLE_USE_3D="${ENABLE_USE_3D:-0}"
ENABLE_VRU_TRACKING="${ENABLE_VRU_TRACKING:-1}"
VRU_TRACKING_OUTPUT_NAME="${VRU_TRACKING_OUTPUT_NAME:-merge_vru.json}"
VRU_COMBINED_OUTPUT_NAME="${VRU_COMBINED_OUTPUT_NAME:-combined_tracking_with_vru.json}"
TRACK_PARALLEL_JOBS="${TRACK_PARALLEL_JOBS:-1}"
echo ""
echo "######################################################################"
echo "# Tracking launcher for exported case/eval-root inference outputs"
echo "######################################################################"
MAX_FRAMES_ARGS=()
if [[ -n "${MAX_FRAMES}" ]]; then
MAX_FRAMES_ARGS+=(--max-frames "${MAX_FRAMES}")
fi
MODEL_VERSION_ARGS=()
if [[ -n "${MODEL_VERSION}" ]]; then
MODEL_VERSION_ARGS+=(--model-version "${MODEL_VERSION}")
fi
USE_3D_ARGS=()
if [[ "${ENABLE_USE_3D}" == "1" ]]; then
USE_3D_ARGS+=(--use-3d --max-3d-distance "${MAX_3D_DISTANCE}")
fi
is_predictions_dir() {
local dir_path="$1"
[[ -d "${dir_path}" ]] && [[ -d "${dir_path}/roi0" || -d "${dir_path}/roi1" || -d "${dir_path}/merge" ]]
}
is_case_dir() {
local dir_path="$1"
is_predictions_dir "${dir_path}/predictions"
}
has_json_files() {
local dir_path="$1"
[[ -d "${dir_path}" ]] && compgen -G "${dir_path}/${FILE_PATTERN}" > /dev/null
}
resolve_case_dir() {
local target_path="$1"
if is_case_dir "${target_path}"; then
printf '%s\n' "${target_path}"
return 0
fi
if is_predictions_dir "${target_path}"; then
dirname "${target_path}"
return 0
fi
return 1
}
emit_batch_case_dirs() {
local batch_root="$1"
local -a case_dirs=()
for first_level_dir in "${batch_root}"/*; do
[[ -d "${first_level_dir}" ]] || continue
if is_case_dir "${first_level_dir}"; then
case_dirs+=("${first_level_dir}")
continue
fi
for second_level_dir in "${first_level_dir}"/*; do
[[ -d "${second_level_dir}" ]] || continue
if is_case_dir "${second_level_dir}"; then
case_dirs+=("${second_level_dir}")
fi
done
done
if [[ "${#case_dirs[@]}" -gt 0 ]]; then
printf '%s\0' "${case_dirs[@]}" | sort -z
fi
}
prune_active_pids() {
local -n pids_ref=$1
local -a remaining=()
local pid
for pid in "${pids_ref[@]}"; do
if kill -0 "${pid}" 2>/dev/null; then
remaining+=("${pid}")
fi
done
pids_ref=("${remaining[@]}")
}
run_tracking_for_source() {
local input_dir="$1"
local output_file="$2"
local source_name="$3"
shift 3
local extra_tracking_args=("$@")
if [[ ! -d "${input_dir}" ]]; then
echo "Warning: ${input_dir} not found, skipping ${source_name}"
return 0
fi
echo ""
echo "--- Tracking ${source_name} ---"
"${PYTHON_BIN}" "${PROJECT_ROOT}/tools/temporal_analysis/track_objects.py" \
--input-dir "${input_dir}" \
--output "${output_file}" \
--file-pattern "${FILE_PATTERN}" \
--classes ${TRACK_CLASSES} \
--iou-threshold "${IOU_THRESH}" \
--max-age "${MAX_AGE}" \
--min-hits "${MIN_HITS}" \
--distance-threshold "${DIST_THRESH}" \
"${MAX_FRAMES_ARGS[@]}" \
"${MODEL_VERSION_ARGS[@]}" \
"${USE_3D_ARGS[@]}" \
"${extra_tracking_args[@]}"
}
run_single_case() {
local case_dir="$1"
local predictions_dir="${case_dir}/predictions"
local has_input_source=0
local merge_args=()
echo ""
echo "Case: ${case_dir}"
if [[ -d "${predictions_dir}/roi0" ]]; then
run_tracking_for_source "${predictions_dir}/roi0" "${case_dir}/roi0.json" "roi0"
merge_args+=(--roi0 "${case_dir}/roi0.json")
has_input_source=1
else
echo "Warning: ${predictions_dir}/roi0 not found, roi0 will not participate in merge"
fi
if [[ -d "${predictions_dir}/roi1" ]]; then
run_tracking_for_source "${predictions_dir}/roi1" "${case_dir}/roi1.json" "roi1"
merge_args+=(--roi1 "${case_dir}/roi1.json")
has_input_source=1
else
echo "Warning: ${predictions_dir}/roi1 not found, roi1 will not participate in merge"
fi
if [[ -d "${predictions_dir}/merge" ]]; then
run_tracking_for_source "${predictions_dir}/merge" "${case_dir}/merge.json" "merge"
merge_args+=(--merge "${case_dir}/merge.json")
has_input_source=1
else
echo "Warning: ${predictions_dir}/merge not found, merge will not participate in merge"
fi
if [[ "${has_input_source}" -eq 0 ]]; then
echo "Warning: no tracking input directories were found for ${case_dir}, skipping merge"
return 0
fi
echo ""
echo "--- Merging tracking results ---"
"${PYTHON_BIN}" "${PROJECT_ROOT}/tools/temporal_analysis/merge_tracking_results.py" \
"${merge_args[@]}" \
--output "${case_dir}/${MERGE_OUTPUT_NAME}"
if [[ "${ENABLE_VRU_TRACKING}" == "1" ]] && has_json_files "${predictions_dir}/merge_vru"; then
run_tracking_for_source \
"${predictions_dir}/merge_vru" \
"${case_dir}/${VRU_TRACKING_OUTPUT_NAME}" \
"merge_vru" \
--association-mode vru
if [[ -f "${case_dir}/${VRU_TRACKING_OUTPUT_NAME}" ]]; then
echo ""
echo "--- Merging tracking results with VRU source ---"
"${PYTHON_BIN}" "${PROJECT_ROOT}/tools/temporal_analysis/merge_tracking_results.py" \
"${merge_args[@]}" \
--extra-source "merge_vru=${case_dir}/${VRU_TRACKING_OUTPUT_NAME}" \
--output "${case_dir}/${VRU_COMBINED_OUTPUT_NAME}"
else
echo "Warning: VRU tracking output was not created, skipping ${VRU_COMBINED_OUTPUT_NAME}"
fi
elif [[ "${ENABLE_VRU_TRACKING}" == "1" ]]; then
echo "Info: ${predictions_dir}/merge_vru has no ${FILE_PATTERN} files, skipping VRU tracking"
fi
}
run_batch_root() {
local batch_root="$1"
local parallel_jobs="${TRACK_PARALLEL_JOBS}"
local total_cases=0
local success_cases=0
local failed_cases=0
local status_dir
local -a active_pids=()
echo "Batch-root mode: ${batch_root}"
if ! [[ "${parallel_jobs}" =~ ^[0-9]+$ ]] || [[ "${parallel_jobs}" -lt 1 ]]; then
echo "[ERROR] TRACK_PARALLEL_JOBS must be an integer >= 1, got: ${parallel_jobs}" >&2
return 1
fi
echo "Parallel jobs : ${parallel_jobs}"
status_dir="$(mktemp -d)"
trap 'rm -rf "${status_dir}"' RETURN
while IFS= read -r -d '' case_dir; do
if ! is_case_dir "${case_dir}"; then
continue
fi
((total_cases+=1))
if [[ "${parallel_jobs}" == "1" ]]; then
if run_single_case "${case_dir}"; then
((success_cases+=1))
else
((failed_cases+=1))
printf '[FAIL] case=%s\n' "${case_dir}" >&2
fi
else
(
if run_single_case "${case_dir}"; then
printf 'success\t%s\n' "${case_dir}" > "${status_dir}/$(printf '%05d' "${total_cases}").status"
else
printf 'failed\t%s\n' "${case_dir}" > "${status_dir}/$(printf '%05d' "${total_cases}").status"
exit 1
fi
) &
active_pids+=("$!")
while [[ "${#active_pids[@]}" -ge "${parallel_jobs}" ]]; do
wait -n || true
prune_active_pids active_pids
done
fi
done < <(emit_batch_case_dirs "${batch_root}")
if [[ "${parallel_jobs}" != "1" ]]; then
local pid
local status_file
local status
local case_dir
for pid in "${active_pids[@]}"; do
wait "${pid}" || true
done
for status_file in "${status_dir}"/*.status; do
[[ -f "${status_file}" ]] || continue
IFS=$'\t' read -r status case_dir < "${status_file}"
if [[ "${status}" == "success" ]]; then
((success_cases+=1))
else
((failed_cases+=1))
printf '[FAIL] case=%s\n' "${case_dir}" >&2
fi
done
if [[ $((success_cases + failed_cases)) -ne "${total_cases}" ]]; then
failed_cases=$((total_cases - success_cases))
fi
fi
if [[ "${total_cases}" -eq 0 ]]; then
echo "[ERROR] No case directories containing predictions/{roi0,roi1,merge} were found under: ${batch_root}" >&2
return 1
fi
echo ""
printf '[DONE] cases=%d success=%d failed=%d\n' \
"${total_cases}" "${success_cases}" "${failed_cases}"
[[ "${failed_cases}" -eq 0 ]]
}
TARGET_PATH="${1:-${RESULTS_ROOT}}"
if [[ ! -d "${TARGET_PATH}" ]]; then
echo "Error: target directory does not exist: ${TARGET_PATH}" >&2
exit 1
fi
if RESOLVED_CASE_DIR="$(resolve_case_dir "${TARGET_PATH}")"; then
run_single_case "${RESOLVED_CASE_DIR}"
else
run_batch_root "${TARGET_PATH}"
fi

View File

@@ -0,0 +1,239 @@
#!/usr/bin/env bash
set -euo pipefail
shopt -s nullglob
# 用法说明:
# 适配 run_two_roi_exported_onnx_infer_cncap_json.sh 的输出目录,对每个 case 执行:
# {case_dir}/predictions/roi0/ -> {case_dir}/roi0.json
# {case_dir}/predictions/roi1/ -> {case_dir}/roi1.json
# {case_dir}/predictions/merge/ -> {case_dir}/merge.json
# 并继续合并为:
# {case_dir}/combined_tracking.json
#
# 支持三种模式:
# 1) 批量模式(默认):遍历 RESULTS_ROOT 下所有包含 predictions/{roi0,roi1,merge} 的 case
# bash track_objects_exported_onnx_infer_cncap_json.sh
# 2) 指定根目录模式:第一个参数传推理输出根目录
# bash track_objects_exported_onnx_infer_cncap_json.sh /path/to/inference_output_root
# 3) 单 case 模式:第一个参数传 case 目录,或直接传 case_dir/predictions
# bash track_objects_exported_onnx_infer_cncap_json.sh /path/to/one_case
# bash track_objects_exported_onnx_infer_cncap_json.sh /path/to/one_case/predictions
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
export PYTHONPATH="${PROJECT_ROOT}:${PYTHONPATH:-}"
PYTHON_BIN="${PYTHON_BIN:-/deeplearning_team/ydong/dongying/miniconda/envs/dev/bin/python}"
# -----------------------------------------------------------------------
# 配置区:默认与 run_two_roi_exported_onnx_infer_cncap_json.sh 保持一致
# -----------------------------------------------------------------------
RESULTS_ROOT="${RESULTS_ROOT:-/data1/dongying/Mono3d/G1Q3/model_inference/cncap/cncap_2024_with_cls_ego/model_20260407}"
TRACK_CLASSES="${TRACK_CLASSES:-0 1 2 3 4 5 6 7 8 9 10 11 12}"
IOU_THRESH="${IOU_THRESH:-0.3}"
MAX_AGE="${MAX_AGE:-5}"
MIN_HITS="${MIN_HITS:-1}"
DIST_THRESH="${DIST_THRESH:-100}"
MAX_3D_DISTANCE="${MAX_3D_DISTANCE:-10.0}"
MAX_FRAMES="${MAX_FRAMES:-}"
MODEL_VERSION="${MODEL_VERSION:-}"
MERGE_OUTPUT_NAME="${MERGE_OUTPUT_NAME:-combined_tracking.json}"
FILE_PATTERN="${FILE_PATTERN:-*.json}"
ENABLE_USE_3D="${ENABLE_USE_3D:-0}"
ENABLE_VRU_TRACKING="${ENABLE_VRU_TRACKING:-1}"
VRU_TRACKING_OUTPUT_NAME="${VRU_TRACKING_OUTPUT_NAME:-merge_vru.json}"
VRU_COMBINED_OUTPUT_NAME="${VRU_COMBINED_OUTPUT_NAME:-combined_tracking_with_vru.json}"
echo ""
echo "######################################################################"
echo "# Tracking launcher for exported CNCAP inference outputs"
echo "######################################################################"
MAX_FRAMES_ARGS=()
if [[ -n "${MAX_FRAMES}" ]]; then
MAX_FRAMES_ARGS+=(--max-frames "${MAX_FRAMES}")
fi
MODEL_VERSION_ARGS=()
if [[ -n "${MODEL_VERSION}" ]]; then
MODEL_VERSION_ARGS+=(--model-version "${MODEL_VERSION}")
fi
USE_3D_ARGS=()
if [[ "${ENABLE_USE_3D}" == "1" ]]; then
USE_3D_ARGS+=(--use-3d --max-3d-distance "${MAX_3D_DISTANCE}")
fi
is_predictions_dir() {
local dir_path="$1"
[[ -d "${dir_path}" ]] && [[ -d "${dir_path}/roi0" || -d "${dir_path}/roi1" || -d "${dir_path}/merge" ]]
}
is_case_dir() {
local dir_path="$1"
is_predictions_dir "${dir_path}/predictions"
}
has_json_files() {
local dir_path="$1"
[[ -d "${dir_path}" ]] && compgen -G "${dir_path}/${FILE_PATTERN}" > /dev/null
}
resolve_case_dir() {
local target_path="$1"
if is_case_dir "${target_path}"; then
printf '%s\n' "${target_path}"
return 0
fi
if is_predictions_dir "${target_path}"; then
dirname "${target_path}"
return 0
fi
return 1
}
run_tracking_for_source() {
local input_dir="$1"
local output_file="$2"
local source_name="$3"
shift 3
local extra_tracking_args=("$@")
if [[ ! -d "${input_dir}" ]]; then
echo "Warning: ${input_dir} not found, skipping ${source_name}"
return 0
fi
echo ""
echo "--- Tracking ${source_name} ---"
"${PYTHON_BIN}" "${PROJECT_ROOT}/tools/temporal_analysis/track_objects.py" \
--input-dir "${input_dir}" \
--output "${output_file}" \
--file-pattern "${FILE_PATTERN}" \
--classes ${TRACK_CLASSES} \
--iou-threshold "${IOU_THRESH}" \
--max-age "${MAX_AGE}" \
--min-hits "${MIN_HITS}" \
--distance-threshold "${DIST_THRESH}" \
"${MAX_FRAMES_ARGS[@]}" \
"${MODEL_VERSION_ARGS[@]}" \
"${USE_3D_ARGS[@]}" \
"${extra_tracking_args[@]}"
}
run_single_case() {
local case_dir="$1"
local predictions_dir="${case_dir}/predictions"
local has_input_source=0
local merge_args=()
echo ""
echo "Case: ${case_dir}"
if [[ -d "${predictions_dir}/roi0" ]]; then
run_tracking_for_source "${predictions_dir}/roi0" "${case_dir}/roi0.json" "roi0"
merge_args+=(--roi0 "${case_dir}/roi0.json")
has_input_source=1
else
echo "Warning: ${predictions_dir}/roi0 not found, roi0 will not participate in merge"
fi
if [[ -d "${predictions_dir}/roi1" ]]; then
run_tracking_for_source "${predictions_dir}/roi1" "${case_dir}/roi1.json" "roi1"
merge_args+=(--roi1 "${case_dir}/roi1.json")
has_input_source=1
else
echo "Warning: ${predictions_dir}/roi1 not found, roi1 will not participate in merge"
fi
if [[ -d "${predictions_dir}/merge" ]]; then
run_tracking_for_source "${predictions_dir}/merge" "${case_dir}/merge.json" "merge"
merge_args+=(--merge "${case_dir}/merge.json")
has_input_source=1
else
echo "Warning: ${predictions_dir}/merge not found, merge will not participate in merge"
fi
if [[ "${has_input_source}" -eq 0 ]]; then
echo "Warning: no tracking input directories were found for ${case_dir}, skipping merge"
return 0
fi
echo ""
echo "--- Merging tracking results ---"
"${PYTHON_BIN}" "${PROJECT_ROOT}/tools/temporal_analysis/merge_tracking_results.py" \
"${merge_args[@]}" \
--output "${case_dir}/${MERGE_OUTPUT_NAME}"
if [[ "${ENABLE_VRU_TRACKING}" == "1" ]] && has_json_files "${predictions_dir}/merge_vru"; then
run_tracking_for_source \
"${predictions_dir}/merge_vru" \
"${case_dir}/${VRU_TRACKING_OUTPUT_NAME}" \
"merge_vru" \
--association-mode vru
if [[ -f "${case_dir}/${VRU_TRACKING_OUTPUT_NAME}" ]]; then
echo ""
echo "--- Merging tracking results with VRU source ---"
"${PYTHON_BIN}" "${PROJECT_ROOT}/tools/temporal_analysis/merge_tracking_results.py" \
"${merge_args[@]}" \
--extra-source "merge_vru=${case_dir}/${VRU_TRACKING_OUTPUT_NAME}" \
--output "${case_dir}/${VRU_COMBINED_OUTPUT_NAME}"
else
echo "Warning: VRU tracking output was not created, skipping ${VRU_COMBINED_OUTPUT_NAME}"
fi
elif [[ "${ENABLE_VRU_TRACKING}" == "1" ]]; then
echo "Info: ${predictions_dir}/merge_vru has no ${FILE_PATTERN} files, skipping VRU tracking"
fi
}
run_batch_root() {
local batch_root="$1"
local total_cases=0
local success_cases=0
local failed_cases=0
echo "Batch-root mode: ${batch_root}"
while IFS= read -r -d '' predictions_dir; do
local case_dir
if ! is_predictions_dir "${predictions_dir}"; then
continue
fi
case_dir="$(dirname "${predictions_dir}")"
((total_cases+=1))
if run_single_case "${case_dir}"; then
((success_cases+=1))
else
((failed_cases+=1))
printf '[FAIL] case=%s\n' "${case_dir}" >&2
fi
done < <(
find "${batch_root}" -type d -name predictions -print0 | sort -z
)
if [[ "${total_cases}" -eq 0 ]]; then
echo "[ERROR] No case directories containing predictions/{roi0,roi1,merge} were found under: ${batch_root}" >&2
return 1
fi
echo ""
printf '[DONE] cases=%d success=%d failed=%d\n' \
"${total_cases}" "${success_cases}" "${failed_cases}"
[[ "${failed_cases}" -eq 0 ]]
}
TARGET_PATH="${1:-${RESULTS_ROOT}}"
if [[ ! -d "${TARGET_PATH}" ]]; then
echo "Error: target directory does not exist: ${TARGET_PATH}" >&2
exit 1
fi
if RESOLVED_CASE_DIR="$(resolve_case_dir "${TARGET_PATH}")"; then
run_single_case "${RESOLVED_CASE_DIR}"
else
run_batch_root "${TARGET_PATH}"
fi

View File

@@ -0,0 +1,187 @@
#!/usr/bin/env bash
set -euo pipefail
shopt -s nullglob
# 用法说明:
# 适配 run_two_roi_exported_onnx_infer_event_json.sh 的输出目录。
# 与按 clip 跟踪不同,这里会对同一个 event_id 目录下所有 clip 的逐帧 JSON
# 先按文件名中的时间戳进行全局排序,再执行一次 event 级跟踪。
#
# 输出目录约定:
# 默认自动解析每个 event 对应的 datename优先取 _status/event_manifest.json
# 再回退 scene 级 _status/scene_event_manifest.json输出为
# {event_dir}/{datename}/roi0.json
# {event_dir}/{datename}/roi1.json
# {event_dir}/{datename}/merge.json
# {event_dir}/{datename}/combined_tracking.json
# {event_dir}/{datename}/frame_order_manifest.json
#
# 如需强制使用固定目录名,可设置:
# EVENT_TRACKING_DIRNAME=event_tracking
#
# 支持三种模式:
# 1) 批量模式(默认):遍历 RESULTS_ROOT 下所有 event 目录
# bash track_objects_exported_onnx_infer_event_json.sh
# 2) 指定根目录或 scene 目录:
# bash track_objects_exported_onnx_infer_event_json.sh /path/to/event_results_root
# bash track_objects_exported_onnx_infer_event_json.sh /path/to/event_results_root/scene_xxx
# 3) 单 event 模式:
# bash track_objects_exported_onnx_infer_event_json.sh /path/to/event_results_root/scene_xxx/event_id_xxx
# bash track_objects_exported_onnx_infer_event_json.sh /path/to/event_results_root/scene_xxx/rawid_xxx
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
export PYTHONPATH="${PROJECT_ROOT}:${PYTHONPATH:-}"
PYTHON_BIN="${PYTHON_BIN:-/deeplearning_team/ydong/dongying/miniconda/envs/dev/bin/python}"
# -----------------------------------------------------------------------
# 配置区:默认与 run_two_roi_exported_onnx_infer_event_json.sh 保持一致
# -----------------------------------------------------------------------
RESULTS_ROOT="${RESULTS_ROOT:-/data1/dongying/Mono3d/G1Q3/model_inference/cncap/cncap_2021_with_cls_ego/model_20260416}"
TRACK_CLASSES="${TRACK_CLASSES:-0 1 2 3 4 5 6 7 8 9 10 11 12}"
IOU_THRESH="${IOU_THRESH:-0.3}"
MAX_AGE="${MAX_AGE:-5}"
MIN_HITS="${MIN_HITS:-1}"
DIST_THRESH="${DIST_THRESH:-100}"
MAX_3D_DISTANCE="${MAX_3D_DISTANCE:-10.0}"
MODEL_VERSION="${MODEL_VERSION:-20260416}"
FILE_PATTERN="${FILE_PATTERN:-*.json}"
ENABLE_USE_3D="${ENABLE_USE_3D:-0}"
MERGE_OUTPUT_NAME="${MERGE_OUTPUT_NAME:-combined_tracking.json}"
MANIFEST_NAME="${MANIFEST_NAME:-frame_order_manifest.json}"
EVENT_TRACKING_DIRNAME="${EVENT_TRACKING_DIRNAME:-}"
SCENE_FILTER="${SCENE_FILTER:-}"
EVENT_FILTER="${EVENT_FILTER:-}"
echo ""
echo "######################################################################"
echo "# Event-level tracking launcher for exported event-json inference"
echo "######################################################################"
MODEL_VERSION_ARGS=()
if [[ -n "${MODEL_VERSION}" ]]; then
MODEL_VERSION_ARGS+=(--model-version "${MODEL_VERSION}")
fi
USE_3D_ARGS=()
if [[ "${ENABLE_USE_3D}" == "1" ]]; then
USE_3D_ARGS+=(--use-3d --max-3d-distance "${MAX_3D_DISTANCE}")
fi
is_event_dir() {
local dir_path="$1"
[[ -d "${dir_path}" ]] || return 1
if [[ -f "${dir_path}/_status/event_manifest.json" ]]; then
return 0
fi
local predictions_dir
for predictions_dir in "${dir_path}"/*/predictions; do
[[ -d "${predictions_dir}" ]] || continue
return 0
done
return 1
}
emit_batch_event_dirs() {
local batch_root="$1"
local manifest_path
{
while IFS= read -r -d '' manifest_path; do
printf '%s\0' "$(dirname "$(dirname "${manifest_path}")")"
done < <(find "${batch_root}" -type f -path "*/_status/event_manifest.json" -print0)
# Backward-compatible fallback for older event-json outputs that may
# not have event manifests yet but still use the historical prefix.
find "${batch_root}" -type d -name "event_id_*" -print0
} | sort -zu
}
run_single_event() {
local event_dir="$1"
local output_dir_args=()
echo ""
echo "Event: ${event_dir}"
if [[ -n "${EVENT_TRACKING_DIRNAME}" ]]; then
output_dir_args=(--output-dir "${event_dir}/${EVENT_TRACKING_DIRNAME}")
fi
"${PYTHON_BIN}" "${PROJECT_ROOT}/tools/temporal_analysis/track_event_objects.py" \
--event-dir "${event_dir}" \
"${output_dir_args[@]}" \
--file-pattern "${FILE_PATTERN}" \
--classes ${TRACK_CLASSES} \
--iou-threshold "${IOU_THRESH}" \
--max-age "${MAX_AGE}" \
--min-hits "${MIN_HITS}" \
--distance-threshold "${DIST_THRESH}" \
--merge-output-name "${MERGE_OUTPUT_NAME}" \
--manifest-name "${MANIFEST_NAME}" \
"${MODEL_VERSION_ARGS[@]}" \
"${USE_3D_ARGS[@]}"
}
run_batch_root() {
local batch_root="$1"
local total_events=0
local success_events=0
local failed_events=0
echo "Batch-root mode: ${batch_root}"
[[ -n "${SCENE_FILTER}" ]] && echo "Scene filter: ${SCENE_FILTER}"
[[ -n "${EVENT_FILTER}" ]] && echo "Event filter: ${EVENT_FILTER}"
while IFS= read -r -d '' event_dir; do
local scene_name
local event_name
scene_name="$(basename "$(dirname "${event_dir}")")"
event_name="$(basename "${event_dir}")"
if [[ -n "${SCENE_FILTER}" ]] && [[ ! "${scene_name}" =~ ${SCENE_FILTER} ]]; then
continue
fi
if [[ -n "${EVENT_FILTER}" ]] && [[ ! "${event_name}" =~ ${EVENT_FILTER} ]]; then
continue
fi
if ! is_event_dir "${event_dir}"; then
continue
fi
((total_events+=1))
if run_single_event "${event_dir}"; then
((success_events+=1))
else
((failed_events+=1))
printf '[FAIL] scene=%s event=%s\n' "${scene_name}" "${event_name}" >&2
fi
done < <(emit_batch_event_dirs "${batch_root}")
if [[ "${total_events}" -eq 0 ]]; then
echo "[ERROR] No event directories were found under: ${batch_root}" >&2
return 1
fi
echo ""
printf '[DONE] events=%d success=%d failed=%d\n' \
"${total_events}" "${success_events}" "${failed_events}"
[[ "${failed_events}" -eq 0 ]]
}
TARGET_PATH="${1:-${RESULTS_ROOT}}"
if [[ ! -d "${TARGET_PATH}" ]]; then
echo "Error: target directory does not exist: ${TARGET_PATH}" >&2
exit 1
fi
if is_event_dir "${TARGET_PATH}"; then
run_single_event "${TARGET_PATH}"
else
run_batch_root "${TARGET_PATH}"
fi

View File

@@ -0,0 +1,88 @@
#!/bin/bash
# 批量将跟踪结果可视化到视频帧上。
#
# 流程(每个 case
# 直接读取 camera4.bin将 merge_tracking.json 叠加到每帧,保存可视化图片目录或 mp4。
# 无需保存中间帧,节省磁盘空间。
#
# 目录约定(与 test_merged_model.sh 保持一致):
# CASES_ROOT/.../{case_id}/{sub}/sigmastar.1/camera4.bin ← 原始视频
# RESULTS_ROOT/MODEL_NAME/{run_name}/merge_tracking.json ← 跟踪结果
# RESULTS_ROOT/MODEL_NAME/{run_name}/tracking_vis.mp4 ← 可视化输出SAVE_VIDEO=true
# RESULTS_ROOT/MODEL_NAME/{run_name}/tracking_vis/ ← 可视化图片SAVE_VIDEO=false
# 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}"
# -----------------------------------------------------------------------
# 配置区:与 test_merged_model.sh / track_objects.sh 保持一致
# -----------------------------------------------------------------------
CASES_ROOT="/data1/dongying/Mono3d/G1M3/cases_coding"
RESULTS_ROOT="/data1/dongying/Mono3d/G1M3/cases_coding_results"
MODEL_NAME="torchscript_python_300w_newdata-cncap-v2"
# 可视化选项
SAVE_VIDEO=false # true=保存 mp4false=保存图片目录
VIS_FPS=25 # 输出视频帧率
VIS_CLASS_ID="" # 过滤类别,例如 "--class-id 0";留空则显示全部类别
# -----------------------------------------------------------------------
# 批量循环:与 test_merged_model.sh 相同的路径推导逻辑
# -----------------------------------------------------------------------
while IFS= read -r -d '' camera4_bin; do
sigmastar_dir=$(dirname "$camera4_bin")
rel_path="${sigmastar_dir#"$CASES_ROOT"/}"
rel_path="${rel_path%/sigmastar.1}"
run_name=$(echo "$rel_path" | tr '/ ' '__')
case_dir="${RESULTS_ROOT}/${MODEL_NAME}/${run_name}"
tracking_file="${case_dir}/merge_tracking.json"
# 跟踪结果不存在则跳过(该 case 尚未跑 track_objects.sh
if [[ ! -f "$tracking_file" ]]; then
echo "[SKIP] No merge_tracking.json: $case_dir"
continue
fi
echo ""
echo "=========================================="
echo "Case : $run_name"
echo "Video : $camera4_bin"
echo "Track : $tracking_file"
echo "=========================================="
# ------------------------------------------------------------------
# 直接读取视频并可视化(不保存中间帧)
# ------------------------------------------------------------------
if [[ "$SAVE_VIDEO" == true ]]; then
output_arg="${case_dir}/tracking_vis.mp4"
save_video_flag="--save-video"
echo "[INFO] Visualizing → $output_arg"
else
output_arg="${case_dir}/tracking_vis"
save_video_flag=""
echo "[INFO] Visualizing → $output_arg/"
fi
python "${PROJECT_ROOT}/tools/temporal_analysis/visualize_tracking_boxes.py" \
--tracking "$tracking_file" \
--images "$camera4_bin" \
--output "$output_arg" \
--fps "$VIS_FPS" \
$save_video_flag \
$VIS_CLASS_ID
if [[ $? -ne 0 ]]; then
echo "[ERROR] Visualization failed for case: $run_name"
continue
fi
echo "[DONE] $run_name"
done < <(find "$CASES_ROOT" -name "camera4.bin" -print0 | sort -z)
echo ""
echo "All cases processed."

View File

@@ -0,0 +1,685 @@
"""
Visualize tracking results by drawing 2D boxes on images.
Usage:
# 从图像文件夹可视化
python visualize_tracking_boxes.py \
--tracking runs/val_viz/exp/tracking.json \
--images path/to/images \
--output runs/val_viz/exp/tracked_images
# 生成视频
python visualize_tracking_boxes.py \
--tracking runs/val_viz/exp/tracking.json \
--images path/to/images \
--output runs/val_viz/exp/tracked.mp4 \
--save-video
# 只可视化前100帧
python visualize_tracking_boxes.py \
--tracking tracking.json \
--images images/ \
--output output/ \
--max-frames 100
# 按 frameId 对齐原始 camera4.bin并导出单帧图片
python visualize_tracking_boxes.py \
--tracking merge.json \
--images /path/to/case/sigmastar.1/camera4.bin \
--output tracking_vis_raw \
--align-by-frame-id
"""
import argparse
import json
from pathlib import Path
import re
import sys
import cv2
import numpy as np
from tqdm import tqdm
import colorsys
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from tools.model_inference.adapters.video_dir_inference_utils import (
iter_video_case_frames,
read_video_frame_index,
)
def generate_colors(n):
"""生成n个区分度高的颜色"""
colors = []
for i in range(n):
hue = i / n
saturation = 0.9
value = 0.9
rgb = colorsys.hsv_to_rgb(hue, saturation, value)
colors.append(tuple(int(x * 255) for x in rgb))
return colors
def get_color_for_track(track_id, color_map):
"""为track_id分配一个持久的颜色"""
if track_id not in color_map:
# 使用track_id作为种子生成颜色
np.random.seed(track_id)
color = (
np.random.randint(50, 255),
np.random.randint(50, 255),
np.random.randint(50, 255)
)
color_map[track_id] = color
return color_map[track_id]
def should_visualize_detection(det, class_id_filter=None, track_ids_filter=None):
"""判断检测是否应该被可视化
Args:
det: 检测结果字典
class_id_filter: 指定的类别IDNone表示所有类别
track_ids_filter: 指定的track ID列表None表示所有ID
Returns:
bool: 是否应该可视化该检测
"""
# 检查class_id过滤
if class_id_filter is not None and det.get('class_id') != class_id_filter:
return False
# 检查track_id过滤
if track_ids_filter is not None:
track_id = det.get('track_id')
if track_id not in track_ids_filter:
return False
return True
def safe_int(value):
"""Best-effort integer conversion."""
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def extract_frame_id_from_image_name(image_name):
"""Best-effort frame-id extraction from tracking image_name."""
stem = Path(str(image_name or "")).stem
match = re.search(r"_(\d+)_(\d+)(?:_merged)?$", stem)
if match is not None:
return int(match.group(1))
match = re.search(r"_(\d+)(?:_merged)?$", stem)
if match is not None:
return int(match.group(1))
match = re.search(r"(\d+)", stem)
if match is not None:
return int(match.group(1))
return None
def extract_tracking_frame_id(frame_data, fallback_idx=None):
"""Resolve one frame's frameId from detections, frame_info, or image_name."""
for det in frame_data.get("detections", []):
for key in ("frameId", "frame_id"):
frame_id = safe_int(det.get(key))
if frame_id is not None:
return frame_id
frame_info = frame_data.get("frame_info")
if isinstance(frame_info, dict):
for key in ("original_frame_id", "frame_id", "frameId"):
frame_id = safe_int(frame_info.get(key))
if frame_id is not None:
return frame_id
frame_id = extract_frame_id_from_image_name(frame_data.get("image_name"))
if frame_id is not None:
return frame_id
return fallback_idx
def resolve_detection_class_name(det, class_id, class_names):
"""Prefer detection-provided type_name before falling back to static names."""
type_name = str(det.get("type_name") or "").strip()
if type_name:
return type_name
return class_names.get(class_id, f"Class{class_id}")
def build_output_frame_name(frame_data, fallback_idx):
"""Build a stable output filename for one visualized frame."""
frame_name = frame_data.get("image_name", f"frame_{fallback_idx:06d}")
stem = Path(str(frame_name)).stem
return f"{stem}.jpg"
def draw_box_with_label(img, bbox, track_id, class_id, confidence, color, class_names, class_name_override=None):
"""在图像上绘制边界框和标签"""
x1, y1, x2, y2 = map(int, bbox)
# 绘制边界框
thickness = 2
cv2.rectangle(img, (x1, y1), (x2, y2), color, thickness)
# 准备标签文本
class_name = class_name_override or class_names.get(class_id, f'Class{class_id}')
label = f'ID:{track_id} {class_name} {confidence:.2f}'
# 计算标签背景大小
font = cv2.FONT_HERSHEY_SIMPLEX
font_scale = 0.6
font_thickness = 2
(text_width, text_height), baseline = cv2.getTextSize(label, font, font_scale, font_thickness)
# 绘制标签背景
label_y1 = max(y1 - text_height - 10, 0)
label_y2 = y1
cv2.rectangle(img, (x1, label_y1), (x1 + text_width + 10, label_y2), color, -1)
# 绘制标签文本
text_x = x1 + 5
text_y = label_y1 + text_height + 5
cv2.putText(img, label, (text_x, text_y), font, font_scale, (255, 255, 255), font_thickness)
return img
def render_tracking_frame(
img,
frame_data,
frame_idx,
total_frames,
color_map,
class_names,
trajectory_history=None,
class_id_filter=None,
track_ids_filter=None,
):
"""Render one tracking frame on top of an image."""
detections = frame_data.get('detections', [])
visualized_count = 0
for det in detections:
track_id = det.get('track_id')
if track_id is None:
continue
if not should_visualize_detection(det, class_id_filter, track_ids_filter):
continue
visualized_count += 1
bbox = det['bbox']
class_id = det['class_id']
confidence = det['confidence']
color = get_color_for_track(track_id, color_map)
class_name = resolve_detection_class_name(det, class_id, class_names)
img = draw_box_with_label(
img,
bbox,
track_id,
class_id,
confidence,
color,
class_names,
class_name_override=class_name,
)
if trajectory_history is not None:
center = ((bbox[0] + bbox[2]) / 2, (bbox[1] + bbox[3]) / 2)
if track_id not in trajectory_history:
trajectory_history[track_id] = []
trajectory_history[track_id].append(center)
if len(trajectory_history[track_id]) > 1:
points = np.array(trajectory_history[track_id], dtype=np.int32)
for i in range(len(points) - 1):
cv2.line(img, tuple(points[i]), tuple(points[i + 1]), color, 2)
if class_id_filter is not None or track_ids_filter is not None:
info_text = f"Frame: {frame_idx + 1}/{total_frames} | Showing: {visualized_count}/{len(detections)}"
else:
info_text = f"Frame: {frame_idx + 1}/{total_frames} | Tracks: {len(detections)}"
cv2.putText(img, info_text, (10, 30), cv2.FONT_HERSHEY_SIMPLEX,
0.8, (0, 255, 0), 2)
return img, visualized_count
def visualize_tracking_on_raw_video_by_frame_id(
tracking_data,
video_path,
output_path,
max_frames=None,
show_trajectory=False,
class_id_filter=None,
track_ids_filter=None,
):
"""Visualize tracking results on raw camera4.bin frames using frameId alignment."""
video_path = Path(video_path)
output_path = Path(output_path)
output_path.mkdir(parents=True, exist_ok=True)
class_names = {
0: 'Car',
1: 'Pedestrian',
2: 'Tricycle',
3: 'Cyclist'
}
color_map = {}
trajectory_history = {} if show_trajectory else None
selected_tracking_data = tracking_data[:max_frames] if max_frames else tracking_data
frame_id_to_tracking = {}
ordered_frame_ids = []
skipped_duplicates = []
unresolved_frames = []
for idx, frame_data in enumerate(selected_tracking_data):
frame_id = extract_tracking_frame_id(frame_data, fallback_idx=idx)
if frame_id is None:
unresolved_frames.append(frame_data.get("image_name", f"frame_{idx:06d}"))
continue
if frame_id in frame_id_to_tracking:
skipped_duplicates.append(frame_id)
continue
frame_id_to_tracking[frame_id] = (idx, frame_data)
ordered_frame_ids.append(frame_id)
if not frame_id_to_tracking:
print("Error: no frameIds could be resolved from tracking data")
return
frame_index_payload = read_video_frame_index(video_path)
total_targets = len(ordered_frame_ids)
matched_frame_ids = set()
print(f"Reading raw video with frameId alignment: {video_path}")
print(f"Saving aligned images to: {output_path}")
if unresolved_frames:
print(f"Warning: skipped {len(unresolved_frames)} frame(s) without resolvable frameId")
if skipped_duplicates:
print(f"Warning: skipped duplicate frameId(s): {sorted(set(skipped_duplicates))[:10]}")
progress = tqdm(total=total_targets, desc="Visualizing")
for read_frame_index, frame_bgr, _, frame_info in iter_video_case_frames(
video_path,
frame_index_payload=frame_index_payload,
frame_stride=1,
max_frames=0,
):
aligned_frame_id = safe_int(None if frame_info is None else frame_info.get("frame_id"))
if aligned_frame_id is None:
aligned_frame_id = read_frame_index
if aligned_frame_id not in frame_id_to_tracking:
continue
tracking_idx, frame_data = frame_id_to_tracking[aligned_frame_id]
rendered, _ = render_tracking_frame(
frame_bgr.copy(),
frame_data,
tracking_idx,
total_targets,
color_map,
class_names,
trajectory_history=trajectory_history,
class_id_filter=class_id_filter,
track_ids_filter=track_ids_filter,
)
output_file = output_path / build_output_frame_name(frame_data, tracking_idx)
cv2.imwrite(str(output_file), rendered)
matched_frame_ids.add(aligned_frame_id)
progress.update(1)
if len(matched_frame_ids) >= total_targets:
break
progress.close()
missing_frame_ids = [frame_id for frame_id in ordered_frame_ids if frame_id not in matched_frame_ids]
print(f"\n✓ Visualization complete!")
print(f" Images saved to: {output_path}")
print(f" Matched frames: {len(matched_frame_ids)}/{total_targets}")
print(f" Total tracks visualized: {len(color_map)}")
if missing_frame_ids:
preview = ", ".join(str(frame_id) for frame_id in missing_frame_ids[:10])
suffix = "..." if len(missing_frame_ids) > 10 else ""
print(f" Missing frameIds: {preview}{suffix}")
def visualize_tracking_on_images(tracking_data, image_source, output_path,
save_video=False, fps=25, max_frames=None,
show_trajectory=False, class_id_filter=None,
track_ids_filter=None, align_by_frame_id=False):
"""将跟踪结果可视化到图像上
Args:
tracking_data: 跟踪结果数据从tracking.json加载
image_source: 图像来源(文件夹路径或视频文件路径)
output_path: 输出路径(文件夹或视频文件)
save_video: 是否保存为视频
fps: 视频帧率
max_frames: 最大处理帧数
show_trajectory: 是否显示轨迹线
class_id_filter: 指定类别IDNone表示所有类别
track_ids_filter: 指定track ID集合None表示所有ID
align_by_frame_id: 是否按 frameId 对齐原始视频帧
"""
image_source = Path(image_source)
output_path = Path(output_path)
if align_by_frame_id:
if save_video:
print("Error: --align-by-frame-id only supports image-directory output")
return
if not image_source.is_file():
print("Error: --align-by-frame-id requires a video file such as camera4.bin")
return
visualize_tracking_on_raw_video_by_frame_id(
tracking_data,
image_source,
output_path,
max_frames=max_frames,
show_trajectory=show_trajectory,
class_id_filter=class_id_filter,
track_ids_filter=track_ids_filter,
)
return
# 类别名称
class_names = {
0: 'Car',
1: 'Pedestrian',
2: 'Tricycle',
3: 'Cyclist'
}
# 颜色映射
color_map = {}
# 轨迹历史(用于绘制轨迹线)
trajectory_history = {} if show_trajectory else None
# 检查图像源类型
VIDEO_EXTENSIONS = {'.mp4', '.avi', '.mov', '.mkv', '.bin'}
if image_source.is_file() and image_source.suffix.lower() in VIDEO_EXTENSIONS:
# 从视频读取
cap = cv2.VideoCapture(str(image_source))
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
frame_width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
frame_height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
print(f"Reading from video: {image_source}")
print(f"Video info: {frame_width}x{frame_height}, {total_frames} frames")
elif image_source.is_dir():
# 从图像文件夹读取
cap = None
image_files = sorted(list(image_source.glob('*.jpg')) +
list(image_source.glob('*.png')) +
list(image_source.glob('*.jpeg')))
if not image_files:
print(f"Error: No images found in {image_source}")
return
# 读取第一张图像获取尺寸
first_img = cv2.imread(str(image_files[0]))
if first_img is None:
print(f"Error: Cannot read image {image_files[0]}")
return
frame_height, frame_width = first_img.shape[:2]
total_frames = len(image_files)
print(f"Reading from image folder: {image_source}")
print(f"Found {total_frames} images, size: {frame_width}x{frame_height}")
else:
print(f"Error: Invalid image source: {image_source}")
return
# 限制处理帧数
if max_frames:
total_frames = min(total_frames, max_frames)
tracking_data = tracking_data[:total_frames]
# 准备输出
if save_video:
if not str(output_path).endswith(('.mp4', '.avi')):
output_path = output_path.with_suffix('.mp4')
output_path.parent.mkdir(parents=True, exist_ok=True)
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
video_writer = cv2.VideoWriter(str(output_path), fourcc, fps, (frame_width, frame_height))
print(f"Saving video to: {output_path}")
else:
output_path.mkdir(parents=True, exist_ok=True)
video_writer = None
print(f"Saving images to: {output_path}")
# 处理每一帧
print(f"\nProcessing {len(tracking_data)} frames...")
for frame_idx, frame_data in enumerate(tqdm(tracking_data, desc="Visualizing")):
# 读取图像
if cap is not None:
ret, img = cap.read()
if not ret:
print(f"Warning: Cannot read frame {frame_idx}")
break
else:
if frame_idx >= len(image_files):
break
img = cv2.imread(str(image_files[frame_idx]))
if img is None:
print(f"Warning: Cannot read image {image_files[frame_idx]}")
continue
img, _ = render_tracking_frame(
img,
frame_data,
frame_idx,
len(tracking_data),
color_map,
class_names,
trajectory_history=trajectory_history,
class_id_filter=class_id_filter,
track_ids_filter=track_ids_filter,
)
# 保存或写入视频
if video_writer:
video_writer.write(img)
else:
frame_name = frame_data.get('image_name', f'frame_{frame_idx:06d}.jpg')
if not frame_name.endswith(('.jpg', '.png', '.jpeg')):
frame_name = f'{Path(frame_name).stem}.jpg'
output_file = output_path / frame_name.replace('.png', '.jpg')
cv2.imwrite(str(output_file), img)
# 清理
if cap is not None:
cap.release()
if video_writer is not None:
video_writer.release()
print(f"\n✓ Visualization complete!")
if save_video:
print(f" Video saved to: {output_path}")
else:
print(f" Images saved to: {output_path}")
print(f" Total tracks visualized: {len(color_map)}")
def create_side_by_side_comparison(tracking_file1, tracking_file2, image_source,
output_path, labels=None, max_frames=100):
"""创建并排对比可视化
Args:
tracking_file1, tracking_file2: 两个跟踪结果文件
image_source: 图像源
output_path: 输出视频路径
labels: 两个方法的标签
max_frames: 最大处理帧数
"""
if labels is None:
labels = ['Method 1', 'Method 2']
# 加载跟踪数据
with open(tracking_file1, 'r') as f:
data1 = json.load(f)
with open(tracking_file2, 'r') as f:
data2 = json.load(f)
image_source = Path(image_source)
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
class_names = {0: 'Car', 1: 'Pedestrian', 2: 'Tricycle', 3: 'Cyclist'}
color_map1, color_map2 = {}, {}
# 读取图像
if image_source.is_dir():
image_files = sorted(list(image_source.glob('*.jpg')) +
list(image_source.glob('*.png')))
first_img = cv2.imread(str(image_files[0]))
h, w = first_img.shape[:2]
else:
cap = cv2.VideoCapture(str(image_source))
w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
cap.release()
# 创建视频写入器(并排宽度加倍)
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
video_writer = cv2.VideoWriter(str(output_path), fourcc, 25, (w * 2, h))
print(f"Creating side-by-side comparison video...")
frames_to_process = min(len(data1), len(data2), max_frames)
for frame_idx in tqdm(range(frames_to_process), desc="Processing"):
# 读取原始图像
if image_source.is_dir():
img = cv2.imread(str(image_files[frame_idx]))
else:
cap = cv2.VideoCapture(str(image_source))
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx)
ret, img = cap.read()
cap.release()
if not ret:
break
img1 = img.copy()
img2 = img.copy()
# 绘制第一个跟踪结果
for det in data1[frame_idx].get('detections', []):
if 'track_id' in det:
color = get_color_for_track(det['track_id'], color_map1)
img1 = draw_box_with_label(img1, det['bbox'], det['track_id'],
det['class_id'], det['confidence'], color, class_names)
# 绘制第二个跟踪结果
for det in data2[frame_idx].get('detections', []):
if 'track_id' in det:
color = get_color_for_track(det['track_id'], color_map2)
img2 = draw_box_with_label(img2, det['bbox'], det['track_id'],
det['class_id'], det['confidence'], color, class_names)
# 添加标签
cv2.putText(img1, labels[0], (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2)
cv2.putText(img2, labels[1], (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2)
# 并排拼接
combined = np.hstack([img1, img2])
video_writer.write(combined)
video_writer.release()
print(f"✓ Comparison video saved to: {output_path}")
def main():
parser = argparse.ArgumentParser(description='Visualize tracking results on images')
parser.add_argument('--tracking', type=str, required=True, help='Path to tracking.json file')
parser.add_argument('--images', type=str, required=True, help='Path to images folder or video file')
parser.add_argument('--output', type=str, required=True, help='Output path (folder or video file)')
parser.add_argument('--save-video', action='store_true', help='Save as video instead of images')
parser.add_argument('--fps', type=int, default=25, help='Video FPS (default: 25)')
parser.add_argument('--max-frames', type=int, help='Maximum number of frames to process')
parser.add_argument('--show-trajectory', action='store_true', help='Show trajectory lines')
parser.add_argument('--compare', type=str, help='Second tracking.json for side-by-side comparison')
parser.add_argument('--labels', type=str, nargs=2, default=['Method 1', 'Method 2'],
help='Labels for comparison mode')
parser.add_argument('--class-id', type=int, default=None,
help='Filter by class ID (0=Car, 1=Pedestrian, 2=Cyclist, etc.). If not specified, all classes are shown.')
parser.add_argument('--track-ids', type=int, nargs='+', default=None,
help='Specific track IDs to visualize (e.g., --track-ids 1 2 3). If not specified, all tracks are shown.')
parser.add_argument('--align-by-frame-id', action='store_true',
help='Align tracking frames to the raw input video by frameId and export images only.')
args = parser.parse_args()
# 检查输入文件
tracking_path = Path(args.tracking)
if not tracking_path.exists():
print(f"Error: Tracking file not found: {tracking_path}")
return
image_source = Path(args.images)
if not image_source.exists():
print(f"Error: Image source not found: {image_source}")
return
# 加载跟踪数据
print(f"Loading tracking data from {tracking_path}")
with open(tracking_path, 'r') as f:
tracking_data = json.load(f)
print(f"Loaded {len(tracking_data)} frames")
# 对比模式
if args.compare:
compare_path = Path(args.compare)
if not compare_path.exists():
print(f"Error: Comparison file not found: {compare_path}")
return
output_path = Path(args.output)
if not str(output_path).endswith('.mp4'):
output_path = output_path.with_suffix('.mp4')
create_side_by_side_comparison(
tracking_path, compare_path, image_source,
output_path, args.labels, args.max_frames or 100
)
else:
# 单文件可视化
# 准备过滤参数
track_ids_set = set(args.track_ids) if args.track_ids else None
# 打印过滤信息
if args.class_id is not None or args.track_ids:
print(f"\nFiltering settings:")
if args.class_id is not None:
print(f" Class ID: {args.class_id}")
if args.track_ids:
print(f" Track IDs: {args.track_ids}")
visualize_tracking_on_images(
tracking_data,
image_source,
args.output,
save_video=args.save_video,
fps=args.fps,
max_frames=args.max_frames,
show_trajectory=args.show_trajectory,
class_id_filter=args.class_id,
track_ids_filter=track_ids_set,
align_by_frame_id=args.align_by_frame_id,
)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,35 @@
#!/bin/bash
# /data1/xdzhu/Testdata_0129/019b18ef-ca2b-7e97-8ca5-d4b1d7eefdbb/images/
# /data1/xdzhu/Mono_G1M3/test_1616_video/异型车/2026-01-07_14-21-06_voice-左侧异型车/sigmastar.1/frames_camera4/
# /data1/xdzhu/Mono_G1M3/test_1616_video/异型车/2026-01-07_13-27-03_voice-异型车/sigmastar.1/frames_camera4/
# /data1/dongying/Mono3d/G1M3/eval_dataset/20251210222153/sigmastar.1/frames_camera4
# python ./eval_tools/temporal_analysis/visualize_tracking_boxes.py \
# --tracking runs/val_viz_300w/20251210222153_roi0/tracking.json \
# --images /data1/dongying/Mono3d/G1M3/eval_dataset/20251210222153/sigmastar.1/frames_camera4 \
# --output runs/val_viz_300w/20251210222153_roi0/tracking_vis \
# --class-id 0
# --track-ids 2 41 102
# # --save-video
# 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}"
# python ./eval_tools/temporal_analysis/visualize_tracking_boxes.py \
# --tracking runs/val_viz/2026-01-07_14-21-06_roi1/tracking.json \
# --images /data1/xdzhu/Mono_G1M3/test_1616_video/异型车/2026-01-07_14-21-06_voice-左侧异型车/sigmastar.1/frames_camera4/ \
# --output runs/val_viz/2026-01-07_14-21-06_roi1/tracking_vis \
# --class-id 0
# # --track-ids 2 41 102
# # --save-video
python ./tools/temporal_analysis/visualize_tracking_boxes.py \
--tracking /data1/dongying/Mono3d/G1M3/test_outputs/video_case_model_20260416/20260416103945/merge.json \
--images /data1/dongying/Mono3d/G1M3/test_data/cncap_20260414/20260416103945/sigmastar.1/camera4.bin \
--output /data1/dongying/Mono3d/G1M3/test_outputs/video_case_model_20260416/20260416103945/tracking_vis \
--class-id 9
# --save-video

View File

@@ -0,0 +1,337 @@
#!/usr/bin/env bash
set -euo pipefail
# Visualize tracking JSON produced by exported video-root inference outputs.
#
# For each case, the script reads:
# {case_dir}/merge.json
# and renders tracking boxes onto:
# {VIDEO_ROOT_DIR}/{relative_case_dir}/sigmastar.1/camera4.bin
# then writes:
# {case_dir}/tracking_vis/
#
# Supported modes:
# 1) Batch mode (default): walk RESULTS_ROOT and process all case dirs
# containing TRACKING_JSON_NAME.
# 2) Root mode: pass an exported-infer results root as the first argument.
# 3) Single-case mode: pass a case dir or a direct tracking JSON path.
#
# Notes:
# - The script uses --align-by-frame-id, so output is an image directory.
# - TRACKING_JSON_NAME defaults to merge.json because combined_tracking.json
# offsets track IDs by source and is less convenient for per-target review.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
export PYTHONPATH="${PROJECT_ROOT}:${PYTHONPATH:-}"
RESULTS_ROOT="${RESULTS_ROOT:-/data1/dongying/Mono3d/G1Q3/dataset_for_evaluation/CNCAP/CSTA_LN_outputs/model_20260416}"
VIDEO_ROOT_DIR="${VIDEO_ROOT_DIR:-/data1/dongying/Mono3d/G1Q3/dataset_for_evaluation/CNCAP/CSTA_LN}"
OUTPUT_ROOT="${OUTPUT_ROOT:-}"
OUTPUT_DIR_NAME="${OUTPUT_DIR_NAME:-tracking_vis}"
PYTHON_BIN="${PYTHON_BIN:-/deeplearning_team/ydong/dongying/miniconda/envs/dev/bin/python}"
VISUALIZE_SCRIPT="${VISUALIZE_SCRIPT:-${PROJECT_ROOT}/tools/temporal_analysis/visualize_tracking_boxes.py}"
TRACKING_JSON_NAME="${TRACKING_JSON_NAME:-merge.json}"
VIS_MAX_FRAMES="${VIS_MAX_FRAMES:-}"
VIS_CLASS_ID="${VIS_CLASS_ID:-}"
VIS_TRACK_IDS="${VIS_TRACK_IDS:-}"
VIS_SHOW_TRAJECTORY="${VIS_SHOW_TRAJECTORY:-0}"
TARGET_PATH=""
CLI_CLASS_ID=""
CLI_MAX_FRAMES=""
CLI_SHOW_TRAJECTORY=0
CLI_TRACK_IDS=()
while (($# > 0)); do
case "$1" in
--track-id)
if (($# < 2)); then
echo "Error: --track-id requires a value" >&2
exit 1
fi
CLI_TRACK_IDS+=("$2")
shift 2
;;
--track-id=*)
CLI_TRACK_IDS+=("${1#*=}")
shift
;;
--class-id)
if (($# < 2)); then
echo "Error: --class-id requires a value" >&2
exit 1
fi
CLI_CLASS_ID="$2"
shift 2
;;
--class-id=*)
CLI_CLASS_ID="${1#*=}"
shift
;;
--max-frames)
if (($# < 2)); then
echo "Error: --max-frames requires a value" >&2
exit 1
fi
CLI_MAX_FRAMES="$2"
shift 2
;;
--max-frames=*)
CLI_MAX_FRAMES="${1#*=}"
shift
;;
--show-trajectory)
CLI_SHOW_TRAJECTORY=1
shift
;;
-*)
echo "Error: unsupported option: $1" >&2
exit 1
;;
*)
if [[ -n "${TARGET_PATH}" ]]; then
echo "Error: multiple target paths provided: ${TARGET_PATH} and $1" >&2
exit 1
fi
TARGET_PATH="$1"
shift
;;
esac
done
if [[ -z "${TARGET_PATH}" ]]; then
TARGET_PATH="${RESULTS_ROOT}"
fi
if [[ ! -e "${TARGET_PATH}" ]]; then
echo "Error: target path does not exist: ${TARGET_PATH}" >&2
exit 1
fi
if [[ ! -d "${VIDEO_ROOT_DIR}" ]]; then
echo "Error: VIDEO_ROOT_DIR does not exist: ${VIDEO_ROOT_DIR}" >&2
exit 1
fi
resolve_abs_path() {
local target_path="$1"
if [[ -d "${target_path}" ]]; then
(
cd "${target_path}"
pwd -P
)
return 0
fi
local parent_dir
parent_dir=$(
cd "$(dirname "${target_path}")"
pwd -P
)
printf '%s/%s\n' "${parent_dir}" "$(basename "${target_path}")"
}
RESULTS_ROOT_ABS="$(resolve_abs_path "${RESULTS_ROOT}")"
VIDEO_ROOT_DIR_ABS="$(resolve_abs_path "${VIDEO_ROOT_DIR}")"
is_case_dir() {
local dir_path="$1"
[[ -d "${dir_path}" ]] && [[ -f "${dir_path}/${TRACKING_JSON_NAME}" ]]
}
resolve_case_dir() {
local target_path="$1"
if is_case_dir "${target_path}"; then
printf '%s\n' "${target_path}"
return 0
fi
if [[ -f "${target_path}" ]] && [[ "$(basename "${target_path}")" == "${TRACKING_JSON_NAME}" ]]; then
dirname "${target_path}"
return 0
fi
return 1
}
derive_output_dir() {
local case_dir="$1"
local case_abs
local rel_case_dir
case_abs="$(resolve_abs_path "${case_dir}")"
if [[ -n "${OUTPUT_ROOT}" ]]; then
if [[ "${case_abs}" == "${RESULTS_ROOT_ABS}" ]]; then
printf '%s\n' "${OUTPUT_ROOT}"
return 0
fi
if [[ "${case_abs}" == "${RESULTS_ROOT_ABS}"/* ]]; then
rel_case_dir="${case_abs#"${RESULTS_ROOT_ABS}/"}"
printf '%s/%s\n' "${OUTPUT_ROOT%/}" "${rel_case_dir}"
return 0
fi
printf '%s/%s\n' "${OUTPUT_ROOT%/}" "$(basename "${case_abs}")"
return 0
fi
printf '%s/%s\n' "${case_abs}" "${OUTPUT_DIR_NAME}"
}
derive_raw_video_path() {
local case_dir="$1"
local case_abs
local rel_case_dir
case_abs="$(resolve_abs_path "${case_dir}")"
if [[ "${case_abs}" != "${RESULTS_ROOT_ABS}" && "${case_abs}" != "${RESULTS_ROOT_ABS}"/* ]]; then
echo "Error: case directory is not under RESULTS_ROOT: ${case_abs}" >&2
return 1
fi
if [[ "${case_abs}" == "${RESULTS_ROOT_ABS}" ]]; then
echo "Error: cannot derive a raw video path from the results root itself" >&2
return 1
fi
rel_case_dir="${case_abs#"${RESULTS_ROOT_ABS}/"}"
printf '%s/%s/sigmastar.1/camera4.bin\n' "${VIDEO_ROOT_DIR_ABS}" "${rel_case_dir}"
}
build_track_id_args() {
local -n out_ref=$1
out_ref=()
if [[ "${#CLI_TRACK_IDS[@]}" -gt 0 ]]; then
for track_id in "${CLI_TRACK_IDS[@]}"; do
out_ref+=(--track-ids "${track_id}")
done
return 0
fi
if [[ -n "${VIS_TRACK_IDS}" ]]; then
# shellcheck disable=SC2206
local track_id_arr=(${VIS_TRACK_IDS})
for track_id in "${track_id_arr[@]}"; do
out_ref+=(--track-ids "${track_id}")
done
fi
}
run_single_case() {
local case_dir="$1"
local tracking_json="${case_dir}/${TRACKING_JSON_NAME}"
local raw_video_path
local output_dir
local cmd
local track_id_args=()
local class_id_value=""
local max_frames_value=""
local show_trajectory_value=0
if [[ ! -f "${tracking_json}" ]]; then
echo "[ERROR] ${TRACKING_JSON_NAME} not found in case directory: ${case_dir}" >&2
return 1
fi
raw_video_path="$(derive_raw_video_path "${case_dir}")"
if [[ ! -f "${raw_video_path}" ]]; then
echo "[ERROR] camera4.bin not found for case: ${case_dir}" >&2
echo " expected: ${raw_video_path}" >&2
return 1
fi
output_dir="$(derive_output_dir "${case_dir}")"
build_track_id_args track_id_args
if [[ -n "${CLI_CLASS_ID}" ]]; then
class_id_value="${CLI_CLASS_ID}"
elif [[ -n "${VIS_CLASS_ID}" ]]; then
class_id_value="${VIS_CLASS_ID}"
fi
if [[ -n "${CLI_MAX_FRAMES}" ]]; then
max_frames_value="${CLI_MAX_FRAMES}"
elif [[ -n "${VIS_MAX_FRAMES}" ]]; then
max_frames_value="${VIS_MAX_FRAMES}"
fi
if [[ "${CLI_SHOW_TRAJECTORY}" == "1" || "${VIS_SHOW_TRAJECTORY}" == "1" ]]; then
show_trajectory_value=1
fi
cmd=(
"${PYTHON_BIN}" "${VISUALIZE_SCRIPT}"
--tracking "${tracking_json}"
--images "${raw_video_path}"
--output "${output_dir}"
--align-by-frame-id
)
if [[ -n "${max_frames_value}" ]]; then
cmd+=(--max-frames "${max_frames_value}")
fi
if [[ -n "${class_id_value}" ]]; then
cmd+=(--class-id "${class_id_value}")
fi
if [[ "${#track_id_args[@]}" -gt 0 ]]; then
cmd+=("${track_id_args[@]}")
fi
if [[ "${show_trajectory_value}" == "1" ]]; then
cmd+=(--show-trajectory)
fi
echo ""
echo "######################################################################"
echo "# Tracking visualization for exported video-root outputs"
echo "######################################################################"
echo "Case : ${case_dir}"
echo "Track : ${tracking_json}"
echo "Video : ${raw_video_path}"
echo "Output : ${output_dir}"
"${cmd[@]}"
}
run_batch_root() {
local batch_root="$1"
local total_cases=0
local success_cases=0
local failed_cases=0
while IFS= read -r -d '' tracking_json; do
local case_dir
case_dir="$(dirname "${tracking_json}")"
((total_cases += 1))
if run_single_case "${case_dir}"; then
((success_cases += 1))
else
((failed_cases += 1))
printf '[FAIL] case=%s\n' "${case_dir}" >&2
fi
done < <(
find "${batch_root}" -type f -name "${TRACKING_JSON_NAME}" -print0 | sort -z
)
if [[ "${total_cases}" -eq 0 ]]; then
echo "[ERROR] No ${TRACKING_JSON_NAME} files were found under: ${batch_root}" >&2
return 1
fi
echo ""
printf '[DONE] cases=%d success=%d failed=%d\n' \
"${total_cases}" "${success_cases}" "${failed_cases}"
[[ "${failed_cases}" -eq 0 ]]
}
if RESOLVED_CASE_DIR="$(resolve_case_dir "${TARGET_PATH}")"; then
run_single_case "${RESOLVED_CASE_DIR}"
elif [[ -d "${TARGET_PATH}" ]]; then
run_batch_root "${TARGET_PATH}"
else
echo "Error: unsupported target path: ${TARGET_PATH}" >&2
exit 1
fi