""" 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 (0–9999), 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()