Files
hand-motion-pipeline/third_party/Dyn-HaMR/third-party/hamer/run.py
T

1681 lines
74 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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()