""" Improved HaMeR pipeline with 3-pass architecture using YOLO hand detector: Pass 1: Extract all raw bboxes (YOLO → Hand bboxes directly) Pass 2: Clean bbox sequences globally (detect anomalies, smooth, interpolate) Pass 3: Run HaMeR on cleaned bboxes This uses YOLO hand detector (like WiLoR) instead of Detectron2 + ViTPose. """ from pathlib import Path import torch import argparse import os import sys import cv2 import numpy as np import pickle from typing import Dict, List, Tuple, Optional from tqdm import tqdm import json import time import imageio from hamer.configs import CACHE_DIR_HAMER from hamer.models import HAMER, download_models, load_hamer from hamer.utils import recursive_to from hamer.utils.geometry import aa_to_rotmat, perspective_projection from hamer.datasets.vitdet_dataset import ViTDetDataset, DEFAULT_MEAN, DEFAULT_STD from hamer.utils.renderer import Renderer, cam_crop_to_full from ultralytics import YOLO import hamer LIGHT_BLUE = (0.65098039, 0.74117647, 0.85882353) openpose_indices = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20] gt_indices = openpose_indices def compute_bbox_from_keypoints(keypoints, img_shape, scale_factor=1.0, max_size_ratio=0.4): """Compute tight bbox from keypoints. Scaling is applied later when creating HaMeR input patches.""" valid = keypoints[:, 2] > 0.5 if valid.sum() < 10: return None valid_kp = keypoints[valid, :2] x_min, y_min = valid_kp.min(axis=0) x_max, y_max = valid_kp.max(axis=0) width = x_max - x_min height = y_max - y_min cx = (x_min + x_max) / 2 cy = (y_min + y_max) / 2 width *= scale_factor height *= scale_factor img_h, img_w = img_shape[:2] max_width = img_w * max_size_ratio max_height = img_h * max_size_ratio width = min(width, max_width) height = min(height, max_height) size = max(width, height) x_min = cx - size / 2 y_min = cy - size / 2 x_max = cx + size / 2 y_max = cy + size / 2 return np.array([x_min, y_min, x_max, y_max]) def compute_iou(bbox1, bbox2): """Compute IoU between two bboxes.""" x1_min, y1_min, x1_max, y1_max = bbox1 x2_min, y2_min, x2_max, y2_max = bbox2 inter_x_min = max(x1_min, x2_min) inter_y_min = max(y1_min, y2_min) inter_x_max = min(x1_max, x2_max) inter_y_max = min(y1_max, y2_max) inter_area = max(0, inter_x_max - inter_x_min) * max(0, inter_y_max - inter_y_min) bbox1_area = (x1_max - x1_min) * (y1_max - y1_min) bbox2_area = (x2_max - x2_min) * (y2_max - y2_min) union_area = bbox1_area + bbox2_area - inter_area return inter_area / union_area if union_area > 0 else 0 def compute_containment_ratio(bbox1, bbox2): """ Compute how much bbox1 is contained within bbox2. Returns the ratio of bbox1's area that overlaps with bbox2. """ x1_min, y1_min, x1_max, y1_max = bbox1 x2_min, y2_min, x2_max, y2_max = bbox2 inter_x_min = max(x1_min, x2_min) inter_y_min = max(y1_min, y2_min) inter_x_max = min(x1_max, x2_max) inter_y_max = min(y1_max, y2_max) inter_area = max(0, inter_x_max - inter_x_min) * max(0, inter_y_max - inter_y_min) bbox1_area = (x1_max - x1_min) * (y1_max - y1_min) return inter_area / bbox1_area if bbox1_area > 0 else 0 def create_video_from_images(image_folder, output_video_path, fps=30): """Create MP4 video from images in a folder using imageio (same as src_cam video).""" image_folder = Path(image_folder) if not image_folder.exists(): print(f"Warning: {image_folder} does not exist, skipping video creation") return # Get all jpg images sorted by filename image_files = sorted(list(image_folder.glob('*.jpg'))) if len(image_files) == 0: print(f"Warning: No images found in {image_folder}, skipping video creation") return print(f"Creating video: {output_video_path}") # Load all images frames = [] for img_file in tqdm(image_files, desc=f" Loading frames"): img = imageio.imread(str(img_file)) if img is not None: frames.append(img) if len(frames) == 0: print(f"Warning: Could not load any images from {image_folder}") return # Write video using imageio (same method as src_cam video generation) imageio.mimwrite(str(output_video_path), frames, fps=fps) print(f" Video saved: {output_video_path} ({len(frames)} frames @ {fps} fps)") # ============================================================================ # PASS 1: Extract all raw bboxes using YOLO hand detector # ============================================================================ def extract_raw_bboxes(img_paths, detector, vis_dir=None): """ Pass 1: Extract raw hand bboxes using YOLO hand detector (like WiLoR). YOLO directly detects hand bboxes with left/right classification. No ViTPose needed - much simpler and more reliable! Args: img_paths: List of image paths detector: YOLO hand detector model vis_dir: Optional directory for visualizations Returns: raw_data: List of dicts for each frame with bbox data (no keypoints from YOLO) """ print("\n" + "="*80) print("PASS 1: Extracting raw hand bboxes (YOLO hand detector)") print("="*80) if vis_dir is not None: pass1_dir = os.path.join(vis_dir, 'pass1_raw_bboxes') os.makedirs(pass1_dir, exist_ok=True) raw_data = [] for frame_idx, img_path in enumerate(tqdm(sorted(img_paths), desc="Pass 1: Extracting hand bboxes")): img_path = str(img_path) img_cv2 = cv2.imread(img_path) frame_data = { 'frame_idx': frame_idx, 'img_path': img_path, 'left_bbox': None, 'right_bbox': None, 'left_keypoints': None, # Will be None for YOLO version 'right_keypoints': None, # Will be None for YOLO version 'left_conf': 0.0, 'right_conf': 0.0 } # YOLO direct hand detection (following WiLoR demo.py) # Class 0 = left hand, Class 1 = right hand detections = detector(img_cv2, conf=0.5, verbose=False)[0] # Track best detection for each hand (in case of duplicates) left_detections = [] right_detections = [] # Helper function to expand bbox by a given scale (e.g., 1.2x) and make it SQUARE def enlarge_bbox(bbox, scale=1.2, img_shape=None): x1, y1, x2, y2 = bbox w = x2 - x1 h = y2 - y1 cx = x1 + w / 2.0 cy = y1 + h / 2.0 # Make it square by taking the max dimension size = max(w, h) * scale new_x1 = cx - size / 2.0 new_y1 = cy - size / 2.0 new_x2 = cx + size / 2.0 new_y2 = cy + size / 2.0 # Optionally clip to image boundaries if img_shape is not None: H, W = img_shape[:2] new_x1 = max(0, min(W-1, new_x1)) new_y1 = max(0, min(H-1, new_y1)) new_x2 = max(0, min(W-1, new_x2)) new_y2 = max(0, min(H-1, new_y2)) return np.array([new_x1, new_y1, new_x2, new_y2], dtype=bbox.dtype) img_shape = img_cv2.shape for det in detections: bbox = det.boxes.xyxy[0].cpu().numpy() # x1, y1, x2, y2 conf = float(det.boxes.conf[0]) cls = int(det.boxes.cls[0]) # Enlarge bbox by 1.2x, clamp to image bbox = enlarge_bbox(bbox, scale=1.2, img_shape=img_shape) if cls == 0: # left hand left_detections.append({'bbox': bbox, 'conf': conf}) elif cls == 1: # right hand right_detections.append({'bbox': bbox, 'conf': conf}) # Keep highest confidence detection for each hand if len(left_detections) > 0: best_left = max(left_detections, key=lambda x: x['conf']) frame_data['left_bbox'] = best_left['bbox'] frame_data['left_conf'] = best_left['conf'] if len(right_detections) > 0: best_right = max(right_detections, key=lambda x: x['conf']) frame_data['right_bbox'] = best_right['bbox'] frame_data['right_conf'] = best_right['conf'] # Visualize raw bboxes if vis_dir is not None: img_vis = img_cv2.copy() if frame_data['left_bbox'] is not None: x1, y1, x2, y2 = frame_data['left_bbox'].astype(int) cv2.rectangle(img_vis, (x1, y1), (x2, y2), (255, 0, 0), 2) # Blue for left cv2.putText(img_vis, f"L (conf={frame_data['left_conf']:.2f})", (x1, y1-10), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 0, 0), 2) if frame_data['right_bbox'] is not None: x1, y1, x2, y2 = frame_data['right_bbox'].astype(int) cv2.rectangle(img_vis, (x1, y1), (x2, y2), (0, 255, 0), 2) # Green for right cv2.putText(img_vis, f"R (conf={frame_data['right_conf']:.2f})", (x1, y1-10), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) img_fn = os.path.splitext(os.path.basename(img_path))[0] cv2.imwrite(os.path.join(pass1_dir, f'{img_fn}_raw.jpg'), img_vis) raw_data.append(frame_data) # Print statistics with missing frame details left_count = sum(1 for f in raw_data if f['left_bbox'] is not None) right_count = sum(1 for f in raw_data if f['right_bbox'] is not None) missing_left = [i for i, f in enumerate(raw_data) if f['left_bbox'] is None] missing_right = [i for i, f in enumerate(raw_data) if f['right_bbox'] is None] print(f"\nExtracted bboxes for {len(raw_data)} frames:") print(f" Left hand detected in {left_count} frames ({100*left_count/len(raw_data):.1f}%)") print(f" Right hand detected in {right_count} frames ({100*right_count/len(raw_data):.1f}%)") if len(missing_left) > 0: print(f" Missing left hand in {len(missing_left)} frames") if len(missing_left) <= 20: print(f" Frame indices: {missing_left}") else: print(f" First 10: {missing_left[:10]}") print(f" Last 10: {missing_left[-10:]}") if len(missing_right) > 0: print(f" Missing right hand in {len(missing_right)} frames") if len(missing_right) <= 20: print(f" Frame indices: {missing_right}") else: print(f" First 10: {missing_right[:10]}") print(f" Last 10: {missing_right[-10:]}") # Create video from Pass 1 visualizations if vis_dir is not None: pass1_dir = os.path.join(vis_dir, 'pass1_raw_bboxes') video_path = os.path.join(vis_dir, 'pass1_raw_bboxes.mp4') create_video_from_images(pass1_dir, video_path, fps=30) return raw_data # ============================================================================ # PASS 2: Clean bbox sequences globally # ============================================================================ def detect_overlapping_bboxes(raw_data, iou_threshold=0.7, containment_threshold=0.7): """ Detect and remove overlapping bboxes (hallucinations) - YOLO version without keypoints. Uses simplified criteria: 1. High bbox IoU (> 0.7) - very overlapping bboxes 2. High containment (> 0.7) - one bbox is largely contained in another Since YOLO doesn't provide keypoints, we only use bbox-based overlap detection. This is actually more reliable since YOLO is specifically trained for hand detection. Also tracks which hand was removed due to overlap and which hand "caused" the removal, so we can potentially restore the removed hand if the winner is later invalidated. """ print("\n" + "-"*80) print("Step 1: Detecting overlapping bboxes (hallucinations)") print("-"*80) overlap_count = 0 for frame_data in raw_data: # Initialize removal tracking flags frame_data['left_removed_due_to_overlap_with'] = None frame_data['right_removed_due_to_overlap_with'] = None if frame_data['left_bbox'] is not None and frame_data['right_bbox'] is not None: # Criterion 1: Bbox IoU iou = compute_iou(frame_data['left_bbox'], frame_data['right_bbox']) # Criterion 2: Containment ratio (either bbox contains the other) left_in_right = compute_containment_ratio(frame_data['left_bbox'], frame_data['right_bbox']) right_in_left = compute_containment_ratio(frame_data['right_bbox'], frame_data['left_bbox']) max_containment = max(left_in_right, right_in_left) # Determine if this is an overlap/hallucination is_overlap = False reason = [] if iou > iou_threshold: is_overlap = True reason.append(f"IoU={iou:.3f}") if max_containment > containment_threshold: is_overlap = True which_contained = "L_in_R" if left_in_right > right_in_left else "R_in_L" reason.append(f"containment={max_containment:.2f} ({which_contained})") if is_overlap: overlap_count += 1 print(f" Frame {frame_data['frame_idx']:04d}: {', '.join(reason)} - OVERLAP DETECTED") # Keep hand with higher confidence, track which hand was removed and why if frame_data['left_conf'] > frame_data['right_conf']: print(f" → Keeping LEFT (conf={frame_data['left_conf']:.3f}), removing RIGHT") frame_data['right_bbox'] = None frame_data['right_keypoints'] = None frame_data['right_conf'] = 0.0 frame_data['right_removed_due_to_overlap_with'] = 'left' else: print(f" → Keeping RIGHT (conf={frame_data['right_conf']:.3f}), removing LEFT") frame_data['left_bbox'] = None frame_data['left_keypoints'] = None frame_data['left_conf'] = 0.0 frame_data['left_removed_due_to_overlap_with'] = 'right' print(f"Removed overlapping bboxes in {overlap_count} frames") return raw_data def fix_handedness_swaps_by_trajectory(raw_data, position_threshold=200): """ Detect handedness swaps by analyzing trajectory jumps. If one hand suddenly jumps to where the other hand was, it's likely mislabeled. Example: Green curve (right) drops to blue curve's position while blue disappears → The "right" detection is actually the left hand with wrong label """ print("\n" + "-"*80) print(f"Step 1.4a: Detecting handedness swaps via trajectory analysis") print("-"*80) swap_count = 0 n = len(raw_data) for i in range(1, n): left_bbox = raw_data[i]['left_bbox'] right_bbox = raw_data[i]['right_bbox'] prev_left = raw_data[i-1]['left_bbox'] prev_right = raw_data[i-1]['right_bbox'] # Case 1: Right hand jumps to where left hand was (left disappears) if right_bbox is not None and left_bbox is None and prev_left is not None and prev_right is not None: # Calculate positions prev_left_cx = (prev_left[0] + prev_left[2]) / 2 prev_right_cx = (prev_right[0] + prev_right[2]) / 2 curr_right_cx = (right_bbox[0] + right_bbox[2]) / 2 # Distance from current "right" to previous left/right dist_to_prev_left = abs(curr_right_cx - prev_left_cx) dist_to_prev_right = abs(curr_right_cx - prev_right_cx) # If current "right" is much closer to previous left than previous right, it's swapped if dist_to_prev_left < dist_to_prev_right and dist_to_prev_right > position_threshold: print(f" Frame {i:04d}: RIGHT jumped to LEFT position (dist to prev_left={dist_to_prev_left:.0f}, dist to prev_right={dist_to_prev_right:.0f}) - Swapping RIGHT → LEFT") raw_data[i]['left_bbox'] = right_bbox raw_data[i]['left_keypoints'] = raw_data[i]['right_keypoints'] raw_data[i]['left_conf'] = raw_data[i]['right_conf'] raw_data[i]['right_bbox'] = None raw_data[i]['right_keypoints'] = None raw_data[i]['right_conf'] = 0.0 swap_count += 1 # Case 2: Left hand jumps to where right hand was (right disappears) elif left_bbox is not None and right_bbox is None and prev_right is not None and prev_left is not None: # Calculate positions prev_left_cx = (prev_left[0] + prev_left[2]) / 2 prev_right_cx = (prev_right[0] + prev_right[2]) / 2 curr_left_cx = (left_bbox[0] + left_bbox[2]) / 2 # Distance from current "left" to previous left/right dist_to_prev_left = abs(curr_left_cx - prev_left_cx) dist_to_prev_right = abs(curr_left_cx - prev_right_cx) # If current "left" is much closer to previous right than previous left, it's swapped if dist_to_prev_right < dist_to_prev_left and dist_to_prev_left > position_threshold: print(f" Frame {i:04d}: LEFT jumped to RIGHT position (dist to prev_right={dist_to_prev_right:.0f}, dist to prev_left={dist_to_prev_left:.0f}) - Swapping LEFT → RIGHT") raw_data[i]['right_bbox'] = left_bbox raw_data[i]['right_keypoints'] = raw_data[i]['left_keypoints'] raw_data[i]['right_conf'] = raw_data[i]['left_conf'] raw_data[i]['left_bbox'] = None raw_data[i]['left_keypoints'] = None raw_data[i]['left_conf'] = 0.0 swap_count += 1 if swap_count > 0: print(f" Fixed {swap_count} trajectory-based handedness swaps") else: print(f" No trajectory-based swaps detected") return raw_data def fix_handedness_swaps(raw_data, context_window=10): """ Detect and fix handedness swaps where YOLO misclassifies hand labels. For example, detecting left hand as right hand for a few frames. Strategy: If a hand appears/disappears while the other hand simultaneously disappears/appears in a short sequence, it's likely a handedness swap. """ print("\n" + "-"*80) print(f"Step 1.4: Detecting handedness swaps (context window={context_window})") print("-"*80) swap_count = 0 n = len(raw_data) for i in range(n): left_bbox = raw_data[i]['left_bbox'] right_bbox = raw_data[i]['right_bbox'] # Case 1: Only right hand detected, but neighbors mostly have only left hand if right_bbox is not None and left_bbox is None: # Count neighbors with left/right left_count = 0 right_count = 0 for j in range(max(0, i - context_window), min(n, i + context_window + 1)): if j == i: continue if raw_data[j]['left_bbox'] is not None and raw_data[j]['right_bbox'] is None: left_count += 1 elif raw_data[j]['right_bbox'] is not None and raw_data[j]['left_bbox'] is None: right_count += 1 # If neighbors are mostly left-only, this right is probably mislabeled left if left_count > 0 and left_count > right_count * 2: print(f" Frame {i:04d}: Swapping RIGHT → LEFT (neighbors: {left_count} left-only, {right_count} right-only)") raw_data[i]['left_bbox'] = right_bbox raw_data[i]['left_keypoints'] = raw_data[i]['right_keypoints'] raw_data[i]['left_conf'] = raw_data[i]['right_conf'] raw_data[i]['right_bbox'] = None raw_data[i]['right_keypoints'] = None raw_data[i]['right_conf'] = 0.0 swap_count += 1 # Case 2: Only left hand detected, but neighbors mostly have only right hand elif left_bbox is not None and right_bbox is None: # Count neighbors with left/right left_count = 0 right_count = 0 for j in range(max(0, i - context_window), min(n, i + context_window + 1)): if j == i: continue if raw_data[j]['left_bbox'] is not None and raw_data[j]['right_bbox'] is None: left_count += 1 elif raw_data[j]['right_bbox'] is not None and raw_data[j]['left_bbox'] is None: right_count += 1 # If neighbors are mostly right-only, this left is probably mislabeled right if right_count > 0 and right_count > left_count * 2: print(f" Frame {i:04d}: Swapping LEFT → RIGHT (neighbors: {left_count} left-only, {right_count} right-only)") raw_data[i]['right_bbox'] = left_bbox raw_data[i]['right_keypoints'] = raw_data[i]['left_keypoints'] raw_data[i]['right_conf'] = raw_data[i]['left_conf'] raw_data[i]['left_bbox'] = None raw_data[i]['left_keypoints'] = None raw_data[i]['left_conf'] = 0.0 swap_count += 1 if swap_count > 0: print(f" Fixed {swap_count} handedness swaps") else: print(f" No handedness swaps detected") return raw_data def fix_handedness_swaps_frame_to_frame(raw_data, iou_threshold=0.7, max_gap=10): """ Detect frame-to-frame handedness swaps: same bbox position but different label. Example problem: - Frame N: Right hand at position X (green box) - Frame N+1: Left hand at position X (blue box) - SAME BBOX, WRONG LABEL! This catches YOLO randomly mislabeling the same physical hand. Strategy: If a hand suddenly appears while the other hand recently disappeared (within max_gap frames), AND the new hand's bbox has high IoU with the disappeared hand's LAST VALID bbox, → It's the same hand with wrong label! Swap it back! Args: max_gap: Maximum frames to look back for last valid other hand (default 10) """ print("\n" + "-"*80) print(f"Step 1.4: Detecting frame-to-frame handedness swaps (IoU threshold={iou_threshold}, max_gap={max_gap})") print("-"*80) swap_count = 0 for i in range(1, len(raw_data)): curr = raw_data[i] for hand_name in ['left', 'right']: bbox_key = f'{hand_name}_bbox' keyp_key = f'{hand_name}_keypoints' conf_key = f'{hand_name}_conf' other_hand = 'right' if hand_name == 'left' else 'left' other_bbox_key = f'{other_hand}_bbox' other_keyp_key = f'{other_hand}_keypoints' other_conf_key = f'{other_hand}_conf' curr_bbox = curr[bbox_key] curr_other_bbox = curr[other_bbox_key] # Case: Current hand exists and other hand is missing if curr_bbox is not None and curr_other_bbox is None: # Look back to find LAST VALID bbox of OTHER hand (within max_gap frames) last_valid_other_bbox = None last_valid_other_idx = None for j in range(i - 1, max(0, i - max_gap - 1), -1): if raw_data[j][other_bbox_key] is not None: last_valid_other_bbox = raw_data[j][other_bbox_key] last_valid_other_idx = j break # If found a recent OTHER hand detection, check IoU print(f" Frame {curr['frame_idx']:04d}: {last_valid_other_bbox is not None}") if last_valid_other_bbox is not None: gap = i - last_valid_other_idx iou = compute_iou(curr_bbox, last_valid_other_bbox) print(f" Frame {curr['frame_idx']:04d}: IoU = {iou:.3f}") if iou > iou_threshold: print(f" Frame {curr['frame_idx']:04d}: {hand_name} at same position as {other_hand} " f"(last seen at frame {raw_data[last_valid_other_idx]['frame_idx']}, gap={gap}, IoU={iou:.3f}) " f"- SWAPPING {hand_name} → {other_hand}") # Swap: this is actually the OTHER hand curr[other_bbox_key] = curr_bbox.copy() curr[other_keyp_key] = curr[keyp_key].copy() if curr[keyp_key] is not None else None curr[other_conf_key] = curr[conf_key] # Remove from current hand curr[bbox_key] = None curr[keyp_key] = None curr[conf_key] = 0.0 swap_count += 1 if swap_count > 0: print(f" Fixed {swap_count} frame-to-frame handedness swaps") else: print(f" No frame-to-frame swaps detected") return raw_data def fix_handedness_inconsistencies(raw_data, original_data, context_window=5): """ Detect handedness swaps using SPATIAL-TEMPORAL consistency. Example problem: [L, L, L, _, _, R, R, R, L, L] - The 3 Rs are spatially in the LEFT hand's trajectory but mislabeled as RIGHT Strategy: 1. For each detection, compute IoU with recent detections of SAME hand 2. Also compute IoU with recent detections of OTHER hand 3. If IoU with OTHER hand's trajectory is much higher → handedness is swapped! """ print("\n" + "-"*80) print(f"Step 1.5: Fixing handedness inconsistencies via spatial-temporal consistency (window={context_window})") print("-"*80) swap_count = 0 for i, frame_data in enumerate(raw_data): for hand_name in ['left', 'right']: bbox_key = f'{hand_name}_bbox' keyp_key = f'{hand_name}_keypoints' conf_key = f'{hand_name}_conf' other_hand = 'right' if hand_name == 'left' else 'left' other_bbox_key = f'{other_hand}_bbox' other_keyp_key = f'{other_hand}_keypoints' other_conf_key = f'{other_hand}_conf' current_bbox = frame_data[bbox_key] if current_bbox is None: continue # Collect recent bboxes for SAME hand and OTHER hand same_hand_bboxes = [] other_hand_bboxes = [] # Look at context window (both past and future) for j in range(max(0, i - context_window), min(len(raw_data), i + context_window + 1)): if j == i: continue # Skip current frame # Collect SAME hand bboxes if raw_data[j][bbox_key] is not None: same_hand_bboxes.append(raw_data[j][bbox_key]) # Collect OTHER hand bboxes if raw_data[j][other_bbox_key] is not None: other_hand_bboxes.append(raw_data[j][other_bbox_key]) # Need enough context to make a decision if len(same_hand_bboxes) < 2 and len(other_hand_bboxes) < 2: continue # Not enough spatial context # Compute average IoU with SAME hand trajectory same_hand_ious = [] for bbox in same_hand_bboxes: iou = compute_iou(current_bbox, bbox) same_hand_ious.append(iou) avg_iou_same = np.mean(same_hand_ious) if len(same_hand_ious) > 0 else 0.0 # Compute average IoU with OTHER hand trajectory other_hand_ious = [] for bbox in other_hand_bboxes: iou = compute_iou(current_bbox, bbox) other_hand_ious.append(iou) avg_iou_other = np.mean(other_hand_ious) if len(other_hand_ious) > 0 else 0.0 # DETECTION LOGIC: If current bbox fits OTHER hand's trajectory much better, it's swapped! # Criteria: # 1. OTHER hand trajectory has high overlap (>0.5) with current bbox # 2. OTHER hand trajectory overlap is significantly higher than SAME hand (1.5x) # 3. We have enough OTHER hand detections nearby (at least 3) if (len(other_hand_bboxes) >= 3 and avg_iou_other > 0.5 and avg_iou_other > avg_iou_same * 1.5): print(f" Frame {frame_data['frame_idx']:04d} ({hand_name}): " f"Bbox fits {other_hand} trajectory better! " f"(IoU with {hand_name}={avg_iou_same:.3f}, IoU with {other_hand}={avg_iou_other:.3f}, " f"neighbors: {len(same_hand_bboxes)} {hand_name}, {len(other_hand_bboxes)} {other_hand}) " f"- SWAPPING {hand_name} → {other_hand}") # Swap: move current bbox to OTHER hand frame_data[other_bbox_key] = current_bbox.copy() frame_data[other_keyp_key] = frame_data[keyp_key].copy() if frame_data[keyp_key] is not None else None frame_data[other_conf_key] = frame_data[conf_key] # Remove from current hand frame_data[bbox_key] = None frame_data[keyp_key] = None frame_data[conf_key] = 0.0 swap_count += 1 if swap_count > 0: print(f" Fixed {swap_count} handedness swaps via spatial-temporal consistency") else: print(f" No spatial-temporal inconsistencies found") return raw_data def detect_bbox_jumps(raw_data, base_iou_threshold=0.5): """ Detect and fix bbox jumps with ADAPTIVE threshold based on detection gaps. Adaptive threshold formula: adjusted_threshold = max(0.0, base_threshold - gap_frames * 0.15) - Gap 0 (consecutive): threshold = 0.5 (strict, catches hallucinations) - Gap 1: threshold = 0.35 (slightly lenient) - Gap 2: threshold = 0.2 (more lenient) - Gap 3+: threshold = 0.05/0.0 (very lenient, accepts genuine movement) This allows genuine hand movement after detection gaps while still protecting against spurious jumps in consecutive frames. """ print("\n" + "-"*80) print(f"Step 2: Detecting bbox jumps with adaptive threshold (base={base_iou_threshold})") print("-"*80) for hand_name in ['left', 'right']: bbox_key = f'{hand_name}_bbox' hand_id = 0 if hand_name == 'left' else 1 last_valid_bbox = None last_valid_idx = None frames_since_last_detection = 0 jump_count = 0 for i, frame_data in enumerate(raw_data): current_bbox = frame_data[bbox_key] if current_bbox is not None: if last_valid_bbox is not None: # Calculate IoU with last valid bbox iou = compute_iou(current_bbox, last_valid_bbox) # Adaptive threshold based on gap gap_frames = frames_since_last_detection adjusted_threshold = max(0.0, base_iou_threshold - (gap_frames * 0.01)) if iou < adjusted_threshold: # Jump detected! jump_count += 1 print(f" Frame {frame_data['frame_idx']:04d} ({hand_name}): " f"IoU={iou:.3f} < threshold={adjusted_threshold:.3f} (gap={gap_frames}) - JUMP DETECTED") print(f" → Current bbox: [{current_bbox[0]:.1f}, {current_bbox[1]:.1f}, {current_bbox[2]:.1f}, {current_bbox[3]:.1f}]") print(f" → Last valid bbox (frame {last_valid_idx}): [{last_valid_bbox[0]:.1f}, {last_valid_bbox[1]:.1f}, {last_valid_bbox[2]:.1f}, {last_valid_bbox[3]:.1f}]") print(f" → Replacing with last valid bbox from frame {last_valid_idx}") frame_data[bbox_key] = last_valid_bbox.copy() else: # Valid detection if gap_frames > 0: print(f" Frame {frame_data['frame_idx']:04d} ({hand_name}): " f"IoU={iou:.3f} > threshold={adjusted_threshold:.3f} (gap={gap_frames}) - ACCEPTING movement") last_valid_bbox = current_bbox.copy() last_valid_idx = i frames_since_last_detection = 0 else: # First valid bbox last_valid_bbox = current_bbox.copy() last_valid_idx = i frames_since_last_detection = 0 else: # Hand is missing frames_since_last_detection += 1 if jump_count > 0: print(f" {hand_name.capitalize()}: Fixed {jump_count} jumps") return raw_data def interpolate_missing_bboxes(raw_data, max_gap=5): """Interpolate missing bboxes for short gaps and create dummy keypoints.""" print("\n" + "-"*80) print(f"Step 3: Interpolating missing bboxes (max gap={max_gap} frames)") print("-"*80) for hand_name in ['left', 'right']: bbox_key = f'{hand_name}_bbox' keyp_key = f'{hand_name}_keypoints' conf_key = f'{hand_name}_conf' # Find valid bbox indices valid_indices = [i for i, f in enumerate(raw_data) if f[bbox_key] is not None] if len(valid_indices) < 2: continue interpolated_count = 0 for i in range(len(valid_indices) - 1): start_idx = valid_indices[i] end_idx = valid_indices[i + 1] gap = end_idx - start_idx - 1 if 0 < gap <= max_gap: start_bbox = raw_data[start_idx][bbox_key] end_bbox = raw_data[end_idx][bbox_key] for j in range(1, gap + 1): alpha = j / (gap + 1) interp_bbox = (1 - alpha) * start_bbox + alpha * end_bbox raw_data[start_idx + j][bbox_key] = interp_bbox # Create dummy keypoints for interpolated frames dummy_keypoints = np.zeros((21, 3)) dummy_keypoints[:, 2] = 0.5 raw_data[start_idx + j][keyp_key] = dummy_keypoints raw_data[start_idx + j][conf_key] = 0.5 interpolated_count += 1 print(f" {hand_name.capitalize()}: Interpolated frames {start_idx+1}-{end_idx-1} (gap={gap})") if interpolated_count > 0: print(f" {hand_name.capitalize()}: Interpolated {interpolated_count} frames total") return raw_data def smooth_bbox_sequence(raw_data, window_size=5): """Apply temporal smoothing using moving average.""" print("\n" + "-"*80) print(f"Step 4: Smoothing bbox sequences (window={window_size})") print("-"*80) for hand_name in ['left', 'right']: bbox_key = f'{hand_name}_bbox' bboxes = [f[bbox_key] for f in raw_data] smoothed_bboxes = [] for i in range(len(bboxes)): if bboxes[i] is None: smoothed_bboxes.append(None) continue window_start = max(0, i - window_size // 2) window_end = min(len(bboxes), i + window_size // 2 + 1) window_bboxes = [bboxes[j] for j in range(window_start, window_end) if bboxes[j] is not None] if len(window_bboxes) > 0: smoothed_bbox = np.mean(window_bboxes, axis=0) smoothed_bboxes.append(smoothed_bbox) else: smoothed_bboxes.append(bboxes[i]) for i, frame_data in enumerate(raw_data): if smoothed_bboxes[i] is not None: frame_data[bbox_key] = smoothed_bboxes[i] print(" Smoothing complete") return raw_data def check_if_bboxes_overlap(bbox1, bbox2, keypoints1=None, keypoints2=None, iou_threshold=0.7, containment_threshold=0.7, wrist_dist_ratio=0.15, keypoint_sim_ratio=0.10): """ Check if two bboxes overlap using the same comprehensive criteria as detect_overlapping_bboxes. Returns True if the bboxes are considered overlapping (hallucination). """ # Compute average bbox size for normalization size1 = max(bbox1[2] - bbox1[0], bbox1[3] - bbox1[1]) size2 = max(bbox2[2] - bbox2[0], bbox2[3] - bbox2[1]) avg_bbox_size = (size1 + size2) / 2 # Criterion 1: Bbox IoU iou = compute_iou(bbox1, bbox2) if iou > iou_threshold: return True # Criterion 2: Containment ratio containment_1_in_2 = compute_containment_ratio(bbox1, bbox2) containment_2_in_1 = compute_containment_ratio(bbox2, bbox1) max_containment = max(containment_1_in_2, containment_2_in_1) if max_containment > containment_threshold: return True # Criterion 3 & 4: Keypoint-based checks (if keypoints available) if keypoints1 is not None and keypoints2 is not None: # Wrist distance wrist1 = keypoints1[0, :2] wrist2 = keypoints2[0, :2] wrist_dist = np.linalg.norm(wrist1 - wrist2) wrist_dist_normalized = wrist_dist / avg_bbox_size if wrist_dist_normalized < wrist_dist_ratio: return True # Average keypoint distance keypoint_dists = [] for i in range(min(len(keypoints1), len(keypoints2))): kp1 = keypoints1[i, :2] kp2 = keypoints2[i, :2] dist = np.linalg.norm(kp1 - kp2) keypoint_dists.append(dist) avg_keypoint_dist = np.mean(keypoint_dists) avg_keypoint_dist_normalized = avg_keypoint_dist / avg_bbox_size if avg_keypoint_dist_normalized < keypoint_sim_ratio: return True return False def plot_bbox_trajectories(raw_data, vis_dir, filename='bbox_trajectories.png'): """ Plot left and right hand bbox center trajectories over time. This helps visualize handedness consistency and detect label swaps. """ import matplotlib matplotlib.use('Agg') # Use non-interactive backend import matplotlib.pyplot as plt frames = [] left_centers_x = [] left_centers_y = [] right_centers_x = [] right_centers_y = [] for i, frame_data in enumerate(raw_data): frames.append(i) # Left hand if frame_data['left_bbox'] is not None: left_cx = (frame_data['left_bbox'][0] + frame_data['left_bbox'][2]) / 2 left_cy = (frame_data['left_bbox'][1] + frame_data['left_bbox'][3]) / 2 left_centers_x.append(left_cx) left_centers_y.append(left_cy) else: left_centers_x.append(None) left_centers_y.append(None) # Right hand if frame_data['right_bbox'] is not None: right_cx = (frame_data['right_bbox'][0] + frame_data['right_bbox'][2]) / 2 right_cy = (frame_data['right_bbox'][1] + frame_data['right_bbox'][3]) / 2 right_centers_x.append(right_cx) right_centers_y.append(right_cy) else: right_centers_x.append(None) right_centers_y.append(None) # Create figure with 2 subplots fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(20, 10)) # Plot X coordinates (horizontal position) ax1.plot(frames, left_centers_x, 'b-', label='Left hand', linewidth=1.5, alpha=0.8) ax1.plot(frames, right_centers_x, 'g-', label='Right hand', linewidth=1.5, alpha=0.8) ax1.set_xlabel('Frame', fontsize=12) ax1.set_ylabel('X coordinate (pixels)', fontsize=12) ax1.set_title('Hand Bbox Center X-Position Over Time\n(Left hand should be consistently < Right hand)', fontsize=14, fontweight='bold') ax1.legend(fontsize=11) ax1.grid(True, alpha=0.3) # Highlight regions where left > right (potential swaps) swap_count = 0 for i in range(len(frames)): if left_centers_x[i] is not None and right_centers_x[i] is not None: if left_centers_x[i] > right_centers_x[i]: ax1.axvspan(i-0.5, i+0.5, alpha=0.3, color='red') swap_count += 1 # Plot Y coordinates (vertical position) ax2.plot(frames, left_centers_y, 'b-', label='Left hand', linewidth=1.5, alpha=0.8) ax2.plot(frames, right_centers_y, 'g-', label='Right hand', linewidth=1.5, alpha=0.8) ax2.set_xlabel('Frame', fontsize=12) ax2.set_ylabel('Y coordinate (pixels)', fontsize=12) ax2.set_title('Hand Bbox Center Y-Position Over Time', fontsize=14, fontweight='bold') ax2.legend(fontsize=11) ax2.grid(True, alpha=0.3) ax2.invert_yaxis() # Invert Y axis since image coordinates go top-to-bottom plt.tight_layout() save_path = os.path.join(vis_dir, filename) plt.savefig(save_path, dpi=150, bbox_inches='tight') plt.close() print(f"\n{'='*80}") print(f"Bbox trajectory plot saved to: {save_path}") print(f" Red regions indicate frames where left_x > right_x (potential label swaps)") print(f" Total frames with left_x > right_x: {swap_count}") print(f"{'='*80}\n") def clean_bbox_sequences(raw_data, vis_dir=None): """Pass 2: Clean bbox sequences using global temporal information.""" print("\n" + "="*80) print("PASS 2: Cleaning bbox sequences") print("="*80) if vis_dir is not None: pass2_dir = os.path.join(vis_dir, 'pass2_cleaned_bboxes') os.makedirs(pass2_dir, exist_ok=True) # Store original bboxes and keypoints for comparison and restoration original_data = [{ 'left_bbox': f['left_bbox'].copy() if f['left_bbox'] is not None else None, 'right_bbox': f['right_bbox'].copy() if f['right_bbox'] is not None else None, 'left_keypoints': f['left_keypoints'].copy() if f['left_keypoints'] is not None else None, 'right_keypoints': f['right_keypoints'].copy() if f['right_keypoints'] is not None else None, 'left_conf': f['left_conf'] if f['left_conf'] is not None else None, 'right_conf': f['right_conf'] if f['right_conf'] is not None else None, } for f in raw_data] # Plot trajectories BEFORE cleaning if vis_dir is not None: plot_bbox_trajectories(raw_data, vis_dir, filename='bbox_trajectories_before_cleaning.png') # Remove oversized bboxes (> 50% of image area) print("\n" + "-"*80) print("Step 1.5: Removing oversized bboxes (> 50% of image area)") print("-"*80) MAX_BBOX_AREA_RATIO = 0.5 removed_oversized = 0 # Load first image to get dimensions (all images should have same size) first_img = cv2.imread(raw_data[0]['img_path']) img_area = first_img.shape[0] * first_img.shape[1] for frame_data in raw_data: for hand_name in ['left', 'right']: bbox_key = f'{hand_name}_bbox' keyp_key = f'{hand_name}_keypoints' conf_key = f'{hand_name}_conf' bbox = frame_data[bbox_key] if bbox is not None: bbox_width = bbox[2] - bbox[0] bbox_height = bbox[3] - bbox[1] bbox_area = bbox_width * bbox_height area_ratio = bbox_area / img_area if area_ratio > MAX_BBOX_AREA_RATIO: print(f" Frame {frame_data['frame_idx']:04d} ({hand_name}): " f"Bbox too large ({area_ratio:.1%} of image) - REMOVING") frame_data[bbox_key] = None frame_data[keyp_key] = None frame_data[conf_key] = 0.0 removed_oversized += 1 if removed_oversized > 0: print(f" Removed {removed_oversized} oversized bboxes") else: print(f" No oversized bboxes found") raw_data = detect_overlapping_bboxes(raw_data, iou_threshold=0.7) raw_data = fix_handedness_swaps_by_trajectory(raw_data, position_threshold=200) raw_data = fix_handedness_inconsistencies(raw_data, original_data, context_window=5) # raw_data = detect_bbox_jumps(raw_data, base_iou_threshold=0.4) raw_data = fix_handedness_swaps_frame_to_frame(raw_data, iou_threshold=0.6) # Apply patience mechanism with interpolation when hand reappears print("\n" + "-"*80) print("Step 3: Applying patience mechanism with interpolation") print("-"*80) PATIENCE_FRAMES = 25 MAX_PATIENCE_WITHOUT_RETURN = 0 # No padding if hand never reappears patience_applied_count = 0 interpolated_count = 0 trimmed_count = 0 for hand_id in [0, 1]: hand_name = 'left' if hand_id == 0 else 'right' bbox_key = f'{hand_name}_bbox' keyp_key = f'{hand_name}_keypoints' conf_key = f'{hand_name}_conf' i = 0 while i < len(raw_data): # Find start of a gap (hand missing) if raw_data[i][bbox_key] is None: # Look back to find last valid bbox last_valid_idx = None for j in range(i - 1, -1, -1): if raw_data[j][bbox_key] is not None: last_valid_idx = j break if last_valid_idx is None: i += 1 continue # Find end of gap (hand reappears or sequence ends) gap_start = i gap_end = None for j in range(i, min(i + PATIENCE_FRAMES, len(raw_data))): if raw_data[j][bbox_key] is not None: gap_end = j break if gap_end is not None: # Hand reappeared within patience threshold - CHECK DISTANCE FIRST gap_length = gap_end - gap_start start_bbox = raw_data[last_valid_idx][bbox_key] end_bbox = raw_data[gap_end][bbox_key] # Compute center distance between start and end bbox start_cx = (start_bbox[0] + start_bbox[2]) / 2 start_cy = (start_bbox[1] + start_bbox[3]) / 2 end_cx = (end_bbox[0] + end_bbox[2]) / 2 end_cy = (end_bbox[1] + end_bbox[3]) / 2 center_distance = np.sqrt((end_cx - start_cx)**2 + (end_cy - start_cy)**2) # Compute bbox width (use average of start and end) start_width = start_bbox[2] - start_bbox[0] end_width = end_bbox[2] - end_bbox[0] avg_width = (start_width + end_width) / 2 # Only interpolate if bboxes are close enough (within 2× bbox width) max_distance = avg_width if center_distance <= max_distance: # Bboxes are close - INTERPOLATE print(f" {hand_name.capitalize()}: Interpolating frames {gap_start}-{gap_end-1} " f"(gap={gap_length}, from frame {last_valid_idx} to {gap_end}, " f"distance={center_distance:.1f}px < {max_distance:.1f}px)") # Total distance includes the step from last_valid to gap_start # and from gap_end-1 to gap_end total_steps = gap_end - last_valid_idx for j in range(gap_start, gap_end): # Linear interpolation: evenly distribute motion across all steps alpha = (j - last_valid_idx) / total_steps interp_bbox = (1 - alpha) * start_bbox + alpha * end_bbox raw_data[j][bbox_key] = interp_bbox # Create dummy keypoints dummy_keypoints = np.zeros((21, 3)) dummy_keypoints[:, 2] = 0.5 raw_data[j][keyp_key] = dummy_keypoints raw_data[j][conf_key] = 0.5 interpolated_count += 1 i = gap_end else: # Bboxes are too far apart - DON'T interpolate, treat as separate instances print(f" {hand_name.capitalize()}: Skipping interpolation for frames {gap_start}-{gap_end-1} " f"(distance={center_distance:.1f}px > {max_distance:.1f}px - likely different hand instances)") # Move to the next detection without filling the gap i = gap_end else: # Hand never reappeared within patience threshold # Apply limited patience (only MAX_PATIENCE_WITHOUT_RETURN frames), then skip the rest last_bbox = raw_data[last_valid_idx][bbox_key] frames_to_fill = min(MAX_PATIENCE_WITHOUT_RETURN, len(raw_data) - gap_start) if frames_to_fill > 0: print(f" {hand_name.capitalize()}: Applying static patience for frames {gap_start}-{gap_start + frames_to_fill - 1} " f"(hand never reappeared, using last bbox from frame {last_valid_idx})") for j in range(gap_start, gap_start + frames_to_fill): raw_data[j][bbox_key] = last_bbox.copy() # Create dummy keypoints dummy_keypoints = np.zeros((21, 3)) dummy_keypoints[:, 2] = 0.5 raw_data[j][keyp_key] = dummy_keypoints raw_data[j][conf_key] = 0.5 patience_applied_count += 1 # Skip ahead to find where hand reappears (or end of sequence) next_valid_idx = None for j in range(gap_start + frames_to_fill, len(raw_data)): if raw_data[j][bbox_key] is not None: next_valid_idx = j break # Jump to next valid detection or end of sequence i = next_valid_idx if next_valid_idx is not None else len(raw_data) else: i += 1 if interpolated_count > 0: print(f" Interpolated {interpolated_count} frames where hand reappeared within patience threshold") if patience_applied_count > 0: print(f" Applied static patience to {patience_applied_count} frames where hand never reappeared") if interpolated_count == 0 and patience_applied_count == 0: print(f" No patience needed") # Remove spurious short motions (brief appearances after long absence) # This runs AFTER patience to filter out hallucinations that patience might have extended print("\n" + "-"*80) print("Step 4: Removing spurious short motions") print("-"*80) MIN_MOTION_DURATION = 30 # Motion must last at least this many frames MIN_ABSENCE_BEFORE = 30 # Must be absent for this long before MIN_ABSENCE_AFTER = 30 # Must be absent for this long after for hand_name in ['left', 'right']: bbox_key = f'{hand_name}_bbox' keyp_key = f'{hand_name}_keypoints' conf_key = f'{hand_name}_conf' # Find all continuous segments where hand is present segments = [] start_idx = None for i, frame_data in enumerate(raw_data): if frame_data[bbox_key] is not None: if start_idx is None: start_idx = i # Start of a new segment else: if start_idx is not None: # End of a segment segments.append((start_idx, i - 1)) start_idx = None # Don't forget the last segment if it extends to the end if start_idx is not None: segments.append((start_idx, len(raw_data) - 1)) # Check each segment removed_segments = [] for seg_start, seg_end in segments: seg_duration = seg_end - seg_start + 1 # Check absence before absence_before = seg_start # frames before this segment # Check absence after absence_after = len(raw_data) - 1 - seg_end # frames after this segment # If this is a short motion after long absence and before long absence, remove it if (seg_duration < MIN_MOTION_DURATION and absence_before >= MIN_ABSENCE_BEFORE and absence_after >= MIN_ABSENCE_AFTER): print(f" {hand_name.capitalize()}: Removing spurious short motion at frames {seg_start}-{seg_end} " f"(duration={seg_duration}, absence_before={absence_before}, absence_after={absence_after})") # Remove this segment for i in range(seg_start, seg_end + 1): raw_data[i][bbox_key] = None raw_data[i][keyp_key] = None raw_data[i][conf_key] = 0.0 removed_segments.append((seg_start, seg_end)) if len(removed_segments) > 0: print(f" {hand_name.capitalize()}: Removed {len(removed_segments)} spurious short motion segments") else: print(f" {hand_name.capitalize()}: No spurious short motions found") # raw_data = interpolate_missing_bboxes(raw_data, max_gap=10) # # Bbox smoothing disabled - it was causing drift (e.g., frames 326->330) # raw_data = smooth_bbox_sequence(raw_data, window_size=5) # Re-check for overlaps after interpolation (interpolation can create new overlaps) print("\n" + "-"*80) print("Step 4: Re-checking overlaps after interpolation") print("-"*80) raw_data = detect_overlapping_bboxes(raw_data, iou_threshold=0.7) # Restore hands that were removed due to overlap, but ONLY if the "winner" hand was # subsequently invalidated by other checks (handedness consistency). # Logic: If LEFT was removed because RIGHT had higher confidence, but then RIGHT itself # was removed by handedness check, we should restore LEFT since it was the original detection. print("\n" + "-"*80) print("Step 5: Restoring hands removed by overlap if winner was invalidated") print("-"*80) restored_count = 0 for i, (frame_data, orig) in enumerate(zip(raw_data, original_data)): for hand_name in ['left', 'right']: bbox_key = f'{hand_name}_bbox' keyp_key = f'{hand_name}_keypoints' conf_key = f'{hand_name}_conf' removal_flag = f'{hand_name}_removed_due_to_overlap_with' # Check if this hand was removed due to overlap if frame_data.get(removal_flag) is not None: other_hand = frame_data[removal_flag] # The hand that "won" the overlap check other_bbox_key = f'{other_hand}_bbox' other_handedness_flag = f'{other_hand}_removed_by_handedness_check' # Check if the "winner" hand was subsequently removed by handedness check if frame_data.get(other_handedness_flag, False): print(f" Frame {i}: Restoring {hand_name} hand (winner '{other_hand}' was invalidated)") # Restore original keypoints and confidence if orig[keyp_key] is not None: frame_data[bbox_key] = orig[bbox_key].copy() frame_data[keyp_key] = orig[keyp_key].copy() frame_data[conf_key] = orig[conf_key] else: # Create dummy keypoints if original didn't have them dummy_keypoints = np.zeros((21, 3)) dummy_keypoints[:, 2] = 0.5 frame_data[keyp_key] = dummy_keypoints frame_data[conf_key] = 0.5 # Clear the removal flag since we've restored it frame_data[removal_flag] = None restored_count += 1 raw_data = fix_handedness_swaps_frame_to_frame(raw_data, iou_threshold=0.6) if restored_count > 0: print(f" Restored {restored_count} hand instances") else: print(f" No hands needed restoration") # Visualize before/after comparison dash_length = 10 if vis_dir is not None: for i, frame_data in enumerate(raw_data): img_path = frame_data['img_path'] img_cv2 = cv2.imread(img_path) img_vis = img_cv2.copy() # Draw original bboxes (dashed) orig = original_data[i] if orig['left_bbox'] is not None: x1, y1, x2, y2 = orig['left_bbox'].astype(int) # Draw dashed rectangle for j in range(x1, x2, dash_length * 2): cv2.line(img_vis, (j, y1), (min(j + dash_length, x2), y1), (173, 216, 230), 2) cv2.line(img_vis, (j, y2), (min(j + dash_length, x2), y2), (173, 216, 230), 2) for j in range(y1, y2, dash_length * 2): cv2.line(img_vis, (x1, j), (x1, min(j + dash_length, y2)), (173, 216, 230), 2) cv2.line(img_vis, (x2, j), (x2, min(j + dash_length, y2)), (173, 216, 230), 2) cv2.putText(img_vis, "L_before", (x1, y1-30), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (173, 216, 230), 2) if orig['right_bbox'] is not None: x1, y1, x2, y2 = orig['right_bbox'].astype(int) for j in range(x1, x2, dash_length * 2): cv2.line(img_vis, (j, y1), (min(j + dash_length, x2), y1), (144, 238, 144), 2) cv2.line(img_vis, (j, y2), (min(j + dash_length, x2), y2), (144, 238, 144), 2) for j in range(y1, y2, dash_length * 2): cv2.line(img_vis, (x1, j), (x1, min(j + dash_length, y2)), (144, 238, 144), 2) cv2.line(img_vis, (x2, j), (x2, min(j + dash_length, y2)), (144, 238, 144), 2) cv2.putText(img_vis, "R_before", (x1, y1-30), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (144, 238, 144), 2) # Draw cleaned bboxes (solid) and keypoints if frame_data['left_bbox'] is not None: x1, y1, x2, y2 = frame_data['left_bbox'].astype(int) cv2.rectangle(img_vis, (x1, y1), (x2, y2), (255, 0, 0), 3) # Blue for left cv2.putText(img_vis, "L_after", (x1, y1-10), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 0, 0), 2) # Draw left hand keypoints in blue if frame_data['left_keypoints'] is not None: for kp in frame_data['left_keypoints']: x, y, conf = kp if conf > 0.1: # Only draw confident keypoints cv2.circle(img_vis, (int(x), int(y)), 3, (255, 0, 0), -1) # Blue circles if frame_data['right_bbox'] is not None: x1, y1, x2, y2 = frame_data['right_bbox'].astype(int) cv2.rectangle(img_vis, (x1, y1), (x2, y2), (0, 255, 0), 3) # Green for right cv2.putText(img_vis, "R_after", (x1, y1-10), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) # Draw right hand keypoints in green if frame_data['right_keypoints'] is not None: for kp in frame_data['right_keypoints']: x, y, conf = kp if conf > 0.1: # Only draw confident keypoints cv2.circle(img_vis, (int(x), int(y)), 3, (0, 255, 0), -1) # Green circles img_fn = os.path.splitext(os.path.basename(img_path))[0] cv2.imwrite(os.path.join(pass2_dir, f'{img_fn}_cleaned.jpg'), img_vis) # Create video from Pass 2 visualizations if vis_dir is not None: pass2_dir = os.path.join(vis_dir, 'pass2_cleaned_bboxes') video_path = os.path.join(vis_dir, 'pass2_cleaned_bboxes.mp4') create_video_from_images(pass2_dir, video_path, fps=30) # Plot bbox trajectories to visualize handedness consistency plot_bbox_trajectories(raw_data, vis_dir, filename='bbox_trajectories_after_cleaning.png') print("\n" + "="*80) print("PASS 2 Complete: Bbox sequences cleaned") print("="*80) return raw_data # ============================================================================ # PASS 3: Run HaMeR on cleaned bboxes # ============================================================================ def render_openpose(img, hand_keypoints): """Render hand keypoints (simplified version).""" # Implementation from original code (lines 718-730) return img # Placeholder def convert_crop_coords_to_orig_img(bbox, keypoints, crop_size): """Convert cropped coordinates to original image coordinates.""" cx, cy, h = bbox[:, 0], bbox[:, 1], bbox[:, 2] keypoints *= h[..., None, None] / crop_size keypoints[:,:,0] = (cx - h/2)[..., None] + keypoints[:,:,0] keypoints[:,:,1] = (cy - h/2)[..., None] + keypoints[:,:,1] return keypoints def run_hamer_on_cleaned_bboxes(raw_data, model, model_cfg, renderer, args): """Pass 3: Run HaMeR on cleaned bboxes.""" print("\n" + "="*80) print("PASS 3: Running HaMeR on cleaned bboxes") print("="*80) device = torch.device('cuda') if torch.cuda.is_available() else torch.device('cpu') results_dict = {} for frame_data in tqdm(raw_data, desc="Pass 3: Running HaMeR"): img_path = frame_data['img_path'] frame_idx = frame_data['frame_idx'] img_cv2 = cv2.imread(img_path) img_fn = os.path.splitext(os.path.basename(img_path))[0] # Prepare bboxes bboxes = [] is_right = [] vit_keypoints_list = [] if frame_data['left_bbox'] is not None: bboxes.append(frame_data['left_bbox']) is_right.append(0) vit_keypoints_list.append(frame_data['left_keypoints']) if frame_data['right_bbox'] is not None: bboxes.append(frame_data['right_bbox']) is_right.append(1) vit_keypoints_list.append(frame_data['right_keypoints']) if len(bboxes) == 0: results_dict[img_path] = { 'mano': [], 'cam_trans': [], 'tracked_ids': [], 'tracked_time': [], 'extra_data': [], 'tid': np.array([]), 'shot': 0 } continue boxes = np.array(bboxes) right = np.array(is_right) # Validate and fix keypoints (create dummy keypoints if None) fixed_keypoints_list = [] for i, kp in enumerate(vit_keypoints_list): if kp is None: # Create dummy keypoints with 0.5 confidence dummy_keypoints = np.zeros((21, 3)) dummy_keypoints[:, 2] = 0.5 fixed_keypoints_list.append(dummy_keypoints) elif not isinstance(kp, np.ndarray): raise ValueError(f"Frame {frame_idx} ({img_fn}): keypoints are not numpy array for hand {i}, got {type(kp)}") elif kp.shape != (21, 3): raise ValueError(f"Frame {frame_idx} ({img_fn}): keypoints have wrong shape {kp.shape} for hand {i}, expected (21, 3)") else: fixed_keypoints_list.append(kp) vit_keypoints = np.stack(fixed_keypoints_list, axis=0) # Create dataset dataset = ViTDetDataset(model_cfg, img_cv2, boxes, right, vit_keypoints, rescale_factor=args.rescale_factor) dataloader = torch.utils.data.DataLoader(dataset, batch_size=8, shuffle=False, num_workers=0) # # Save HaMeR input patches for debugging # if args.render and args.res_folder is not None: # hamer_input_dir = os.path.join(os.path.dirname(args.res_folder), f'hamer_input_patches_{model_cfg.EXTRA.FOCAL_LENGTH}') # os.makedirs(hamer_input_dir, exist_ok=True) # for hand_idx, (bbox, is_right_val) in enumerate(zip(boxes, right)): # # Get the cropped patch from dataset # sample = dataset[hand_idx] # img_patch = sample['img_patch'].astype(np.uint8) # This is a tensor (C, H, W) normalized # # Denormalize and convert back to image # print('img_patch_bgr: ', img_patch.shape) # img_patch_bgr = cv2.cvtColor(img_patch, cv2.COLOR_RGB2BGR) # hand_name = 'right' if is_right_val == 1 else 'left' # patch_filename = f'{img_fn}_{hand_name}.jpg' # cv2.imwrite(os.path.join(hamer_input_dir, patch_filename), img_patch_bgr) # Run HaMeR all_verts = [] all_cam_t = [] all_right = [] all_mano_params = [] all_pred_2d = [] all_bboxes = [] for batch in dataloader: batch = recursive_to(batch, device) with torch.no_grad(): out = model(batch) multiplier = (2 * batch['right'] - 1) pred_cam = out['pred_cam'] pred_cam[:, 1] = multiplier * pred_cam[:, 1] box_center = batch["box_center"].float() box_size = batch["box_size"].float() img_size = batch["img_size"].float() scaled_focal_length = model_cfg.EXTRA.FOCAL_LENGTH / model_cfg.MODEL.IMAGE_SIZE * img_size.max() # Compute camera translation (SLAHMR-style) batch_size = batch['img'].shape[0] pred_cam_t_full = torch.zeros(batch_size, 3, device=pred_cam.device) for n in range(batch_size): cam = pred_cam[n] H, W = img_size[n, 1], img_size[n, 0] focal = scaled_focal_length[n] if scaled_focal_length.ndim > 0 else scaled_focal_length cx, cy = box_center[n] scale = box_size[n] tz = 2 * focal / (scale * cam[0] + 1e-6) tx = cam[1] + tz / focal * (cx - W / 2) ty = cam[2] + tz / focal * (cy - H / 2) pred_cam_t_full[n] = torch.tensor([tx, ty, tz], device=pred_cam.device) pred_cam_t_full = pred_cam_t_full.detach().cpu().numpy() for n in range(batch_size): verts = out['pred_vertices'][n].detach().cpu().numpy() pred_joints = out['pred_keypoints_2d'][n].detach().cpu().numpy() is_right_val = int(batch['right'][n].cpu().numpy()) verts[:, 0] = (2 * is_right_val - 1) * verts[:, 0] pred_joints[:, 0] = (2 * is_right_val - 1) * pred_joints[:, 0] cam_t = pred_cam_t_full[n] all_verts.append(verts) all_cam_t.append(cam_t) all_right.append(is_right_val) all_pred_2d.append(pred_joints) all_bboxes.append(batch['bbox'][n].detach().cpu().numpy()) mano_params = out['pred_mano_params'][n] mano_params['is_right'] = is_right_val all_mano_params.append(mano_params) # Convert 2D keypoints to original image coordinates if len(all_pred_2d) > 0: all_pred_2d_np = np.stack(all_pred_2d) all_bboxes_np = np.stack(all_bboxes) # Add confidence column v = np.ones((all_pred_2d_np.shape[0], all_pred_2d_np.shape[1], 1)) all_pred_2d_np = np.concatenate((all_pred_2d_np, v), axis=-1) # Convert from crop coords to original image coords all_pred_2d_np = model_cfg.MODEL.IMAGE_SIZE * (all_pred_2d_np + 0.5) all_pred_2d_np = convert_crop_coords_to_orig_img(bbox=all_bboxes_np, keypoints=all_pred_2d_np, crop_size=model_cfg.MODEL.IMAGE_SIZE) all_pred_2d_np[:, :, -1] = 1 # Set all confidences to 1 extra_data = [all_pred_2d_np[i].tolist() for i in range(len(all_pred_2d_np))] else: extra_data = [] # Store results results_dict[img_path] = { 'mano': all_mano_params, 'cam_trans': all_cam_t, 'tracked_ids': all_right, 'tracked_time': [0] * len(all_right), 'extra_data': extra_data, 'tid': np.array(all_right), 'shot': 0 } # Render hand meshes if requested if args.render and len(all_verts) > 0 and renderer is not None: LIGHT_BLUE = (0.65098039, 0.74117647, 0.85882353) misc_args = dict( mesh_base_color=LIGHT_BLUE, scene_bg_color=(1, 1, 1), focal_length=scaled_focal_length, ) cam_view, _ = renderer.render_rgba_multiple( all_verts, cam_t=all_cam_t, render_res=img_size[0], is_right=all_right, **misc_args ) # Overlay on original image input_img = img_cv2.astype(np.float32)[:, :, ::-1] / 255.0 input_img = np.concatenate([input_img, np.ones_like(input_img[:, :, :1])], axis=2) input_img_overlay = input_img[:, :, :3] * (1 - cam_view[:, :, 3:]) + cam_view[:, :, :3] * cam_view[:, :, 3:] # Save rendered result render_path = os.path.join(os.path.dirname(args.res_folder), f'render_all_{model_cfg.EXTRA.FOCAL_LENGTH}') os.makedirs(render_path, exist_ok=True) cv2.imwrite(os.path.join(render_path, f'{img_fn}.jpg'), 255 * input_img_overlay[:, :, ::-1]) # Create video from Pass 3 rendered results render_path = os.path.join(os.path.dirname(args.res_folder), f'render_all_{model_cfg.EXTRA.FOCAL_LENGTH}') if os.path.exists(render_path): video_path = os.path.join(os.path.dirname(args.res_folder), f'render_all_{model_cfg.EXTRA.FOCAL_LENGTH}.mp4') create_video_from_images(render_path, video_path, fps=30) print("\n" + "="*80) print("PASS 3 Complete: HaMeR reconstruction finished") print("="*80) return results_dict # ============================================================================ # Main # ============================================================================ def main(): parser = argparse.ArgumentParser(description='HaMeR with 3-pass architecture using YOLO hand detector') parser.add_argument('--checkpoint', type=str, default=None, help='Path to pretrained model checkpoint') parser.add_argument('--img_folder', type=str, default='images', help='Folder with input images') parser.add_argument('--out_folder', type=str, default='out_demo', help='Output folder to save rendered results') parser.add_argument('--res_folder', type=str, help='Output folder to save rendered results') parser.add_argument('--side_view', dest='side_view', action='store_true', default=False, help='If set, render side view also') parser.add_argument('--full_frame', dest='full_frame', action='store_true', default=True, help='If set, render all people together also') parser.add_argument('--save_mesh', dest='save_mesh', action='store_true', default=False, help='If set, save meshes to disk also') parser.add_argument('--batch_size', type=int, default=1, help='Batch size for inference/fitting') parser.add_argument('--rescale_factor', type=float, default=1.3, help='Factor for padding the bbox') parser.add_argument('--file_type', nargs='+', default=['*.jpg', '*.png'], help='List of file extensions to consider') parser.add_argument('--conf', type=float, default=2.0, help='Factor for padding the bbox') parser.add_argument('--type', type=str, default='EgoDexter', help='Path to pretrained model checkpoint') parser.add_argument('--render', dest='render', action='store_true', default=False, help='If set, render side view also') parser.add_argument('--yolo_model', type=str, default='./pretrained_models/detector.pt', help='Path to YOLO hand detector model (like WiLoR detector.pt)') args = parser.parse_args() # Setup device = torch.device('cuda') if torch.cuda.is_available() else torch.device('cpu') img_folder = Path(args.img_folder) # Get all images matching file_type patterns img_paths = sorted([img for end in args.file_type for img in img_folder.glob(end)]) print(f"Found {len(img_paths)} images in {img_folder}") # Load models print("\nLoading models...") CACHE_DIR_HAMER = args.checkpoint download_models(CACHE_DIR_HAMER) model, model_cfg = load_hamer(args.checkpoint) model = model.to(device) model.eval() # Load YOLO hand detector (like WiLoR) print(f"\nLoading YOLO hand detector: {args.yolo_model}") yolo_detector = YOLO(args.yolo_model) yolo_detector.to(device) # Load renderer renderer = Renderer(model_cfg, faces=model.mano.faces) # Create visualization directory vis_dir = None if args.render and args.res_folder is not None: vis_dir = os.path.join(os.path.dirname(args.res_folder), f'bbox_vis_{model_cfg.EXTRA.FOCAL_LENGTH}') os.makedirs(vis_dir, exist_ok=True) print(f"\nBbox visualizations will be saved to: {vis_dir}") # PASS 1: Extract raw bboxes using YOLO raw_data = extract_raw_bboxes(img_paths, yolo_detector, vis_dir=vis_dir) # PASS 2: Clean bbox sequences cleaned_data = clean_bbox_sequences(raw_data, vis_dir=vis_dir) # PASS 3: Run HaMeR results = run_hamer_on_cleaned_bboxes(cleaned_data, model, model_cfg, renderer, args) # Save results output_path = Path(args.res_folder) output_path.parent.mkdir(parents=True, exist_ok=True) with open(output_path, 'wb') as f: pickle.dump(results, f) print(f"\nResults saved to {output_path}") if __name__ == '__main__': main()