echo-memory / src /model_training /fov_retrieval.py
multimodalart's picture
multimodalart HF Staff
Upload folder using huggingface_hub
6e4b62e verified
Raw
History Blame Contribute Delete
64.8 kB
"""
FOV (Field of View) Overlap-based Memory Retrieval Module
Implements geometric retrieval based on camera pose overlap for Context-as-Memory
Aligned with the Context-as-Memory paper [2506.03141].
"""
import os
import json
import random
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
import numpy as np
from typing import List, Dict, Tuple, Optional
from pathlib import Path
import torch
from PIL import Image
def degrees_to_radians(degrees: float) -> float:
"""Convert degrees to radians."""
return degrees * np.pi / 180
def compute_rotation_list_z_only(x: float, y: float, z: float, yaw_degrees: float) -> List[float]:
"""
Paper-aligned: rotation only around Z-axis (yaw). 4 parameters.
R = R_z(yaw). No roll/pitch.
Returns:
[t_x, t_y, t_z, R_11, R_12, ..., R_33] (12 elements)
"""
yaw_rad = degrees_to_radians(yaw_degrees)
c, s = np.cos(yaw_rad), np.sin(yaw_rad)
R_z = np.array([
[c, -s, 0],
[s, c, 0],
[0, 0, 1],
], dtype=np.float64)
return [float(x), float(y), float(z)] + R_z.flatten().tolist()
def compute_rotation_list_yaw_pitch(
x: float, y: float, z: float, yaw_degrees: float, pitch_degrees: float
) -> List[float]:
"""
Five-parameter rotation: R = R_z(pitch) @ R_y(yaw). Same convention as external pipelines.
- R_y: rotation around Y (yaw in degrees).
- R_z: rotation around Z (pitch in degrees).
Returns [t_x, t_y, t_z, R_11, ..., R_33] (12 elements), encoder-compatible.
"""
yaw_rad = degrees_to_radians(yaw_degrees)
pitch_rad = degrees_to_radians(pitch_degrees)
cy, sy = np.cos(yaw_rad), np.sin(yaw_rad)
cp, sp = np.cos(pitch_rad), np.sin(pitch_rad)
R_y = np.array([
[cy, 0, sy],
[0, 1, 0],
[-sy, 0, cy],
], dtype=np.float64)
R_z = np.array([
[cp, -sp, 0],
[sp, cp, 0],
[0, 0, 1],
], dtype=np.float64)
R = R_z @ R_y
return [float(x), float(y), float(z)] + R.flatten().tolist()
def compute_rotation_list(params: List[float]) -> List[float]:
"""
Compute rotation matrix from camera parameters and flatten to list.
- 4 params [x, y, z, yaw]: Z-only, R = R_z(yaw) (paper default).
- 5 params [x, y, z, yaw, pitch]: R = R_z(pitch) @ R_y(yaw).
Returns [t_x, t_y, t_z, R_11, ..., R_33] (12 elements).
"""
if len(params) >= 5:
x, y, z = float(params[0]), float(params[1]), float(params[2])
yaw = float(params[3])
pitch = float(params[4])
return compute_rotation_list_yaw_pitch(x, y, z, yaw, pitch)
if len(params) >= 4:
x, y, z = float(params[0]), float(params[1]), float(params[2])
yaw = float(params[3])
return compute_rotation_list_z_only(x, y, z, yaw)
x = y = z = yaw = 0.0
return compute_rotation_list_z_only(x, y, z, yaw)
def yaw_deg_from_rt(rt: List[float]) -> float:
"""Extract yaw (degrees) from 12-dim RT. Z-only: yaw = atan2(R_21, R_11)."""
if rt is None or len(rt) < 12:
return 0.0
R = np.array(rt[3:12]).reshape(3, 3)
return float(np.degrees(np.arctan2(R[1, 0], R[0, 0])))
def flip_yaw_rt(rt: List[float]) -> List[float]:
"""Return RT with yaw negated (CW<->CCW). rt is 12 elements [t_x,t_y,t_z, R_11..R_33]."""
if rt is None or len(rt) < 12:
return list(rt) if rt else []
tx, ty, tz = rt[0], rt[1], rt[2]
yaw_deg = yaw_deg_from_rt(rt)
return compute_rotation_list_z_only(tx, ty, tz, -yaw_deg)
def flip_yaw_rt_list(rt_list: List[List[float]]) -> List[List[float]]:
"""Flip yaw for each RT in list (data aug for direction sensitivity)."""
return [flip_yaw_rt(rt) for rt in rt_list]
def convert_rt_to_relative(rt_list_all: List[List[float]], ref_rt: List[float]) -> List[List[float]]:
"""
Convert RT (rotation-translation) poses to relative coordinates.
This aligns with the Context-as-Memory paper's use of relative camera poses
for better geometric consistency in context frame retrieval.
Args:
rt_list_all: List of RT poses, each is [t_x, t_y, t_z, R_11, R_12, ..., R_33] (12 elements)
ref_rt: Reference RT pose [t_x, t_y, t_z, R_11, R_12, ..., R_33] (12 elements)
Returns:
new_rt_list: List of relative RT poses in the same format
"""
def parse_rt(rt: List[float]) -> tuple:
"""Parse RT list into rotation matrix R and translation vector t."""
t = np.array(rt[:3]).reshape((3, 1))
R = np.array(rt[3:]).reshape((3, 3))
return R, t
R_ref, T_ref = parse_rt(ref_rt)
R_ref_inv = R_ref.T
T_ref_inv = -R_ref_inv @ T_ref
new_rt_list = []
for rt in rt_list_all:
R_i, T_i = parse_rt(rt)
# Convert to relative coordinates
R_new = R_ref_inv @ R_i
T_new = R_ref_inv @ T_i + T_ref_inv
# Flatten back to list format: [t_x, t_y, t_z, R_11, R_12, ..., R_33]
rt_new = T_new.flatten().tolist() + R_new.flatten().tolist()
new_rt_list.append(rt_new)
return new_rt_list
def pose_to_rt(pose: Dict, constrain_to_xy: bool = True) -> Optional[List[float]]:
"""
Convert camera pose dict to RT format. Paper-aligned by default.
- 2D plane (constrain_to_xy=True): use position x, y only; z=0.
- Rotation: Z-axis only; use rotation[2] as yaw (degrees). Roll/pitch ignored.
Args:
pose: Dict with 'position' [x, y, z] and 'rotation' [roll, pitch, yaw] in degrees
constrain_to_xy: If True (default), set z=0 for strict XY-plane displacement (paper).
Returns:
RT list: [t_x, t_y, t_z, R_11, R_12, ..., R_33] (12 elements) or None if invalid
"""
if pose is None:
return None
pos = pose.get('position', [0, 0, 0])
rot = pose.get('rotation', [0, 0, 0])
if len(pos) < 2:
return None
x = float(pos[0])
y = float(pos[1])
z = 0.0 if constrain_to_xy else (float(pos[2]) if len(pos) >= 3 else 0.0)
# Paper: rotation only around Z-axis -> use yaw (index 2) only
yaw = float(rot[2]) if len(rot) > 2 else 0.0
return compute_rotation_list([x, y, z, yaw])
def rt_to_pose(rt: List[float]) -> Optional[Dict]:
"""
Convert RT format back to pose dict.
Args:
rt: RT list [t_x, t_y, t_z, R_11, R_12, ..., R_33] (12 elements)
Returns:
pose dict with 'position' and 'rotation' or None if invalid
"""
if rt is None or len(rt) < 12:
return None
t = np.array(rt[:3])
R = np.array(rt[3:]).reshape((3, 3))
# Extract Euler angles from rotation matrix
# Using ZYX convention (yaw-pitch-roll)
sy = np.sqrt(R[0, 0] * R[0, 0] + R[1, 0] * R[1, 0])
singular = sy < 1e-6
if not singular:
roll = np.arctan2(R[2, 1], R[2, 2])
pitch = np.arctan2(-R[2, 0], sy)
yaw = np.arctan2(R[1, 0], R[0, 0])
else:
roll = np.arctan2(-R[1, 2], R[1, 1])
pitch = np.arctan2(-R[2, 0], sy)
yaw = 0
return {
'position': t.tolist(),
'rotation': [np.degrees(roll), np.degrees(pitch), np.degrees(yaw)]
}
def _parse_poses_dict(data: dict) -> dict:
"""Extract poses dict from JSON data (CineCameraActor or flat dict)."""
if 'CineCameraActor' in data:
return data['CineCameraActor']
return data if isinstance(data, dict) else {}
def load_poses_dict(json_file: str) -> dict:
"""
Load full camera poses dict from JSON file (one read for all frames).
Args:
json_file: Path to camera pose JSON file
Returns:
Dict mapping frame_idx (str) -> pose dict, or {} if failed
"""
if not os.path.exists(json_file):
return {}
try:
with open(json_file, 'r') as f:
data = json.load(f)
return _parse_poses_dict(data)
except Exception as e:
print(f"Error loading poses from {json_file}: {e}")
return {}
def load_camera_pose(json_file: str, frame_idx: int) -> Optional[Dict]:
"""
Load camera pose for a specific frame from JSON file.
Args:
json_file: Path to camera pose JSON file
frame_idx: Frame index
Returns:
Dict with 'position' and 'rotation' keys, or None if not found
"""
poses = load_poses_dict(json_file)
frame_key = str(frame_idx)
return poses.get(frame_key)
def load_camera_poses_batch(json_file: str, frame_indices: List[int]) -> List[Optional[Dict]]:
"""
Load camera poses for multiple frames in one JSON read.
Args:
json_file: Path to camera pose JSON file
frame_indices: List of frame indices
Returns:
List of pose dicts (or None) in same order as frame_indices
"""
poses = load_poses_dict(json_file)
return [poses.get(str(fi)) for fi in frame_indices]
def load_overlap_frames(overlap_labels_dir: str, video_name: str, frame_idx: int) -> List[int]:
"""
Load overlapping frame indices for a given frame from overlap_labels.
Args:
overlap_labels_dir: Base directory for overlap labels
video_name: Name of the video
frame_idx: Current frame index
Returns:
List of overlapping frame indices
"""
overlap_file = os.path.join(overlap_labels_dir, video_name, f"{frame_idx}.json")
if not os.path.exists(overlap_file):
return []
try:
# Add distributed training safety - timeout protection
import signal
import torch.distributed as dist
def timeout_handler(signum, frame):
raise TimeoutError(f"Timeout loading overlap file: {overlap_file}")
# Set 10-second timeout for file operations in distributed training
if dist.is_available() and dist.is_initialized() and dist.get_world_size() > 1:
signal.signal(signal.SIGALRM, timeout_handler)
signal.alarm(10)
try:
with open(overlap_file, 'r') as f:
data = json.load(f)
overlapping_frames = data.get('overlapping_frames', [])
# Convert string indices to integers
result = [int(f) for f in overlapping_frames if f.isdigit() or isinstance(f, int)]
if dist.is_available() and dist.is_initialized() and dist.get_world_size() > 1:
signal.alarm(0) # Cancel alarm
return result
finally:
if dist.is_available() and dist.is_initialized() and dist.get_world_size() > 1:
signal.alarm(0) # Always cancel alarm
except TimeoutError as e:
print(f"Warning: Timeout loading overlap file {overlap_file} (distributed training): {e}")
return []
except Exception as e:
print(f"Error loading overlap labels from {overlap_file}: {e}")
return []
def compute_fov_overlap_3d(
pose1: Dict,
pose2: Dict,
fov_degrees: float = 52.67,
max_distance: float = 500.0 # Increased default from 50.0 to 500.0 for large scenes
) -> float:
"""
Compute FOV overlap score between two camera poses using 3D geometry.
This implementation uses full 6-DoF camera poses (position + rotation)
to compute more accurate FOV overlap, as described in Context-as-Memory.
Args:
pose1: First camera pose with 'position' [x, y, z] and 'rotation' [roll, pitch, yaw] in degrees
pose2: Second camera pose with 'position' [x, y, z] and 'rotation' [roll, pitch, yaw] in degrees
fov_degrees: Field of view in degrees (default: 52.67 from paper)
max_distance: Maximum distance to consider (meters)
Returns:
Overlap score between 0 and 1
"""
if pose1 is None or pose2 is None:
return 0.0
pos1 = np.array(pose1.get('position', [0, 0, 0]), dtype=np.float32)
pos2 = np.array(pose2.get('position', [0, 0, 0]), dtype=np.float32)
# Distance between cameras
distance = np.linalg.norm(pos2 - pos1)
# Instead of returning 0.0 for distances > max_distance, we use a soft threshold
# that still gives some score based on direction similarity even for far cameras
distance_exceeds_max = distance > max_distance
if distance < 1e-6:
# Same position - high overlap
return 1.0
# Extract rotation angles (assuming [roll, pitch, yaw] or [x, y, z] rotation in degrees)
rot1 = pose1.get('rotation', [0, 0, 0])
rot2 = pose2.get('rotation', [0, 0, 0])
# Convert to numpy array
rot1 = np.array(rot1, dtype=np.float32)
rot2 = np.array(rot2, dtype=np.float32)
# Compute rotation matrices from Euler angles
# Note: The rotation order may vary by dataset. Common conventions:
# - ZYX (yaw-pitch-roll): R = R_z(yaw) * R_y(pitch) * R_x(roll)
# - XYZ (roll-pitch-yaw): R = R_x(roll) * R_y(pitch) * R_z(yaw)
# Based on Context-as-Memory dataset, rotation[2] is yaw (rotation around Z-axis)
# We'll use ZYX convention: yaw (Z), pitch (Y), roll (X)
def euler_to_rotation_matrix(euler_angles):
"""Convert Euler angles [roll, pitch, yaw] in degrees to rotation matrix (ZYX order)"""
roll, pitch, yaw = np.radians(euler_angles)
# Rotation around X-axis (roll)
Rx = np.array([
[1, 0, 0],
[0, np.cos(roll), -np.sin(roll)],
[0, np.sin(roll), np.cos(roll)]
])
# Rotation around Y-axis (pitch)
Ry = np.array([
[np.cos(pitch), 0, np.sin(pitch)],
[0, 1, 0],
[-np.sin(pitch), 0, np.cos(pitch)]
])
# Rotation around Z-axis (yaw)
Rz = np.array([
[np.cos(yaw), -np.sin(yaw), 0],
[np.sin(yaw), np.cos(yaw), 0],
[0, 0, 1]
])
# ZYX order: R = Rz * Ry * Rx
R = Rz @ Ry @ Rx
return R
# Handle different rotation formats
if len(rot1) >= 3:
# Euler angles [roll, pitch, yaw] or [x, y, z]
R1 = euler_to_rotation_matrix([rot1[0], rot1[1], rot1[2]])
elif len(rot1) == 9:
# Rotation matrix flattened (3x3 = 9 elements)
R1 = rot1.reshape(3, 3)
else:
# Fallback: only yaw
R1 = euler_to_rotation_matrix([0, 0, rot1[2] if len(rot1) > 2 else 0])
if len(rot2) >= 3:
R2 = euler_to_rotation_matrix([rot2[0], rot2[1], rot2[2]])
elif len(rot2) == 9:
R2 = rot2.reshape(3, 3)
else:
R2 = euler_to_rotation_matrix([0, 0, rot2[2] if len(rot2) > 2 else 0])
# Camera forward vector (typically Z-axis in camera coordinate system)
# In OpenCV/OpenGL convention, forward is usually -Z or +Z
# Based on Context-as-Memory dataset, we assume forward is +Z (third column)
forward1 = R1[:, 2] # Third column of rotation matrix
forward2 = R2[:, 2]
# Vector from camera1 to camera2
vec_1_to_2 = pos2 - pos1
vec_1_to_2_norm = np.linalg.norm(vec_1_to_2)
vec_1_to_2_unit = vec_1_to_2 / (vec_1_to_2_norm + 1e-6)
# FOV half-angle threshold (cosine of half FOV)
fov_rad = np.radians(fov_degrees)
fov_half_cos = np.cos(fov_rad / 2)
# Check if camera1 can see camera2's position (within FOV)
# cos(angle) = dot(forward, vec_to_target)
# angle < fov/2 => cos(angle) > cos(fov/2)
dot1 = np.dot(forward1, vec_1_to_2_unit)
can_1_see_2 = dot1 > fov_half_cos
# Check if camera2 can see camera1's position (within FOV)
dot2 = np.dot(forward2, -vec_1_to_2_unit) # Negative because looking back
can_2_see_1 = dot2 > fov_half_cos
# Compute overlap score based on mutual visibility and distance
# Normalize distance for scoring (use max_distance as reference, but don't hard-cut)
normalized_distance = min(1.0, distance / max_distance) if max_distance > 0 else 1.0
if can_1_see_2 and can_2_see_1:
# Both cameras can see each other - high overlap
# Score decreases with distance, but never goes to 0
distance_factor = 1.0 - normalized_distance * 0.5
overlap = 0.8 + 0.2 * distance_factor
elif can_1_see_2 or can_2_see_1:
# One camera can see the other - medium overlap
distance_factor = 1.0 - normalized_distance * 0.6
overlap = 0.4 + 0.3 * distance_factor
else:
# Check if cameras are looking in similar directions (even if not directly at each other)
# This handles the case where both cameras see the same scene from different angles
forward_similarity = np.dot(forward1, forward2)
if forward_similarity > 0.7: # Cameras looking in similar directions
# Even for far cameras, if they're looking in similar directions, there's some overlap
distance_factor = 1.0 - normalized_distance * 0.7
overlap = 0.2 + 0.3 * distance_factor
elif forward_similarity > 0.0:
# Cameras looking in somewhat similar directions
distance_factor = 1.0 - normalized_distance * 0.8
overlap = 0.05 + 0.15 * distance_factor * forward_similarity
else:
# Cameras looking away from each other - very low overlap
# But still give some score based on distance (closer = slightly better)
overlap = max(0.0, 0.01 - normalized_distance * 0.01)
# Apply distance penalty for cameras exceeding max_distance (soft penalty)
if distance_exceeds_max:
# Reduce score by distance penalty, but don't make it zero
distance_penalty = min(0.5, (distance - max_distance) / max_distance * 0.3)
overlap = overlap * (1.0 - distance_penalty)
return np.clip(overlap, 0.0, 1.0)
# Keep the simple version as fallback
def compute_fov_overlap_simple(
pose1: Dict,
pose2: Dict,
fov_degrees: float = 52.67,
max_distance: float = 50.0
) -> float:
"""
Simplified FOV overlap computation (fallback).
Use compute_fov_overlap_3d for more accurate results.
"""
return compute_fov_overlap_3d(pose1, pose2, fov_degrees, max_distance)
class FOVMemoryRetriever:
"""
FOV-based Memory Retriever for Context-as-Memory.
Retrieves relevant historical frames based on FOV overlap with current frame.
"""
def __init__(
self,
dataset_base_path: str,
fov_degrees: float = 52.67,
max_distance: float = 50.0,
use_precomputed_overlaps: bool = True
):
"""
Initialize FOV Memory Retriever.
Args:
dataset_base_path: Base path to Context-as-Memory dataset
fov_degrees: Field of view in degrees (default from paper: 52.67)
max_distance: Maximum distance to consider for overlap (meters)
use_precomputed_overlaps: Whether to use precomputed overlap_labels if available
"""
self.dataset_base_path = dataset_base_path
self.fov_degrees = fov_degrees
self.max_distance = max_distance
self.use_precomputed_overlaps = use_precomputed_overlaps
self.jsons_dir = os.path.join(dataset_base_path, 'jsons')
self.overlap_labels_dir = os.path.join(dataset_base_path, 'overlap_labels')
# Cache for loaded poses
self._pose_cache: Dict[str, Dict] = {}
def retrieve_frames(
self,
video_name: str,
current_frame_idx: int,
candidate_frame_indices: List[int],
top_k: int = 5,
include_last_frame: bool = True,
use_relative_poses: bool = False # Experiment 1_4_2: use RT relative conversion
) -> List[int]:
"""
Retrieve top-k most relevant frames based on FOV overlap.
According to Context-as-Memory, for temporal coherence, we should:
1. Always include the last frame (current_frame_idx - 1) as short-term memory
2. Retrieve top-(k-1) frames from history as long-term memory
Args:
video_name: Name of the video
current_frame_idx: Index of current frame to generate
candidate_frame_indices: List of candidate frame indices to consider
top_k: Number of frames to retrieve
include_last_frame: Whether to force include the last frame (default: True, per Context-as-Memory)
use_relative_poses: Whether to use RT relative conversion (experiment 1_4_2, aligned with paper)
Returns:
List of top-k frame indices sorted by relevance (last frame first if included)
"""
if not candidate_frame_indices:
return []
retrieved_frames = []
# Step 1: Force include last frame for short-term memory (Context-as-Memory requirement)
if include_last_frame and current_frame_idx > 0:
last_frame_idx = current_frame_idx - 1
if last_frame_idx in candidate_frame_indices:
retrieved_frames.append(last_frame_idx)
# Remove from candidates to avoid duplication
candidate_frame_indices = [idx for idx in candidate_frame_indices if idx != last_frame_idx]
# Calculate how many more frames we need
remaining_k = top_k - len(retrieved_frames)
if remaining_k <= 0:
return retrieved_frames[:top_k]
# Step 2: Retrieve long-term memory frames using FOV overlap
# If precomputed overlaps are available, use them
if self.use_precomputed_overlaps:
overlap_frames = load_overlap_frames(
self.overlap_labels_dir,
video_name,
current_frame_idx
)
# Filter to only include candidate frames (and exclude already included last frame)
overlap_frames = [f for f in overlap_frames
if f in candidate_frame_indices and f not in retrieved_frames]
if overlap_frames:
# Take top remaining_k frames
retrieved_frames.extend(overlap_frames[:remaining_k])
return retrieved_frames[:top_k]
# Step 3: Compute FOV overlap using camera poses (if precomputed not available)
current_pose = self._load_pose(video_name, current_frame_idx)
if current_pose is None:
# Fallback: return first k candidates
retrieved_frames.extend(candidate_frame_indices[:remaining_k])
return retrieved_frames[:top_k]
# Experiment 1_4_2: Convert to relative poses if enabled (aligned with Context-as-Memory)
if use_relative_poses:
# Convert current pose to RT format
ref_rt = pose_to_rt(current_pose)
if ref_rt is None:
# Fallback to absolute poses if conversion fails
use_relative_poses = False
# Compute overlap scores for all candidates
overlap_scores = []
for candidate_idx in candidate_frame_indices:
if candidate_idx in retrieved_frames:
continue # Skip already included frames
candidate_pose = self._load_pose(video_name, candidate_idx)
if candidate_pose is None:
continue
# Experiment 1_4_2: Use relative poses for FOV overlap computation
if use_relative_poses and ref_rt is not None:
# Convert candidate pose to RT format
candidate_rt = pose_to_rt(candidate_pose)
if candidate_rt is not None:
# Convert to relative coordinates
relative_rt_list = convert_rt_to_relative([candidate_rt], ref_rt)
if relative_rt_list:
# Convert back to pose format for FOV overlap computation
relative_pose = rt_to_pose(relative_rt_list[0])
if relative_pose is not None:
# Use relative pose for overlap computation
# Reference pose in relative coordinates is identity (origin)
ref_relative_pose = {'position': [0, 0, 0], 'rotation': [0, 0, 0]}
score = compute_fov_overlap_3d(
ref_relative_pose,
relative_pose,
self.fov_degrees,
self.max_distance
)
overlap_scores.append((candidate_idx, score))
continue
# Fallback: Use absolute poses (original method)
score = compute_fov_overlap_3d(
current_pose,
candidate_pose,
self.fov_degrees,
self.max_distance
)
overlap_scores.append((candidate_idx, score))
# Sort by score (descending) and take top remaining_k
overlap_scores.sort(key=lambda x: x[1], reverse=True)
retrieved_frames.extend([idx for idx, _ in overlap_scores[:remaining_k]])
return retrieved_frames[:top_k]
def _load_pose(self, video_name: str, frame_idx: int) -> Optional[Dict]:
"""Load and cache camera pose."""
cache_key = f"{video_name}_{frame_idx}"
if cache_key in self._pose_cache:
return self._pose_cache[cache_key]
json_file = os.path.join(self.jsons_dir, f"{video_name}.json")
pose = load_camera_pose(json_file, frame_idx)
if pose is not None:
self._pose_cache[cache_key] = pose
return pose
def clear_cache(self):
"""Clear pose cache."""
self._pose_cache.clear()
def create_fov_retriever(dataset_base_path: str) -> Optional[FOVMemoryRetriever]:
"""
Create FOV retriever if dataset has camera pose information.
Args:
dataset_base_path: Base path to dataset
Returns:
FOVMemoryRetriever instance or None if dataset doesn't support it
"""
jsons_dir = os.path.join(dataset_base_path, 'jsons')
if not os.path.exists(jsons_dir):
return None
return FOVMemoryRetriever(dataset_base_path)
# Context/FOV retrieval integration helpers.
def _load_frame_png(frame_file: str) -> Optional[Image.Image]:
"""Load a single frame from PNG file."""
if os.path.exists(frame_file):
try:
return Image.open(frame_file).convert('RGB')
except Exception:
pass
return None
def retrieve_simple_context_frames(
data: Dict,
dataset_base_path: str,
top_k: int = 4, # Number of overlap frames to retrieve. First Frame will be added automatically.
drop_overlap_probability: float = 0.1, # 10% probability to drop overlap frames (paper strategy)
use_rt_relative: bool = False, # Experiment 1_4_2: use RT relative conversion (aligned with Context-as-Memory)
) -> Tuple[List[Image.Image], List, List[int], int, str, str]:
"""
Retrieve context frames according to the Context-as-Memory paper [2506.03141].
Data Structure (Precomputed Retrieval Results):
- Each JSON file: overlap_labels/{video_name}/{frame_index}.json
- JSON structure: {
"frame_index": "0", # First Frame (short-term memory)
"overlapping_frames": ["2796", "2797", ..., "3839", ...] # Long-term memory (sample 4)
}
- frame_index and the next 80 frames constitute GT (target frames to generate)
- Iterating through all JSON files = one epoch
Design principles:
1. First Frame (frame_index from JSON) as Immediate Condition:
- The frame_index in JSON is the First Frame (short-term memory)
- Provides immediate visual and temporal starting point (Image-to-Video mode)
- Always included as context
- GT: frame_index and the next 80 frames (81 frames total)
2. Overlap Frames (from overlapping_frames in JSON) as Long-term Memory:
- Retrieved from precomputed overlap_labels JSON files
- Precomputed lists may be very long (e.g., [2796, 3839, 4183, ..., 6339])
- Random uniform sampling: sample top_k (4) frames from overlapping_frames list
- Provides long-term consistency information
- Memory frames are unordered snapshots (no temporal sequence)
3. Context Composition:
- Order: [First Frame, Overlap Frame 1, Overlap Frame 2, Overlap Frame 3, Overlap Frame 4]
- Total: 1 First Frame + top_k Overlap Frames = top_k + 1 frames (e.g., 5 frames)
- Context frames are concatenated with target frames in temporal dimension
4. 10% Probability Drop Strategy:
- With 10% probability, drop all Overlap Frames, only use First Frame
- Simulates video generation starting stage (no historical memory)
- Forces model to generate reasonable videos without long-term memory assistance
5. Epoch Definition:
- One epoch = iterate through all JSON files in overlap_labels/{video_name}/
- Each JSON file = one training sample
6. Positional Encoding Note:
- Memory frames should NOT use original absolute time positions
- They should be treated as unordered image collection or use memory ID encoding only
- First Frame should use explicit "Ref Frame" encoding
Args:
data: Training data dict containing video frames and metadata
dataset_base_path: Base path to Context-as-Memory dataset
top_k: Number of overlap frames to retrieve (default: 4). First Frame will be added automatically.
drop_overlap_probability: Probability to drop overlap frames (default: 0.1 = 10%)
Returns:
Tuple of:
(context_frames, context_actions, context_indices, current_frame_idx, video_name, source)
"""
video_frames = data.get("video", [])
# Get segment boundaries
start_frame = data.get("start_frame", 0)
end_frame = data.get("end_frame", None)
# Use frame_idx if available, otherwise calculate from segment (middle of segment)
# This ensures we can find previous frames for context retrieval
if "frame_idx" in data:
current_frame_idx = data.get("frame_idx")
else:
# Calculate middle of segment as reference frame (same as training convention)
if end_frame is not None:
current_frame_idx = (start_frame + end_frame) // 2
else:
# Fallback: use middle of video_frames if available
if len(video_frames) > 0:
current_frame_idx = len(video_frames) // 2
else:
current_frame_idx = 0
# First Frame: current segment's first frame (start_frame) - ALWAYS included
first_frame_idx = start_frame
video_name = data.get("video_name", "")
context_frames: List[Image.Image] = []
context_actions: List = []
context_indices: List[int] = []
source = "none"
# Get video frames from data
if not isinstance(video_frames, list):
video_frames = []
if not video_name:
# Try to infer from data
if "video_path" in data:
video_name = os.path.basename(data["video_path"]).replace(".mp4", "").replace(".avi", "")
elif "file_path" in data:
video_name = os.path.basename(data["file_path"]).replace(".mp4", "").replace(".avi", "")
# Step 1: Load First Frame (current segment's first frame) - ALWAYS included
# According to JSON structure: frame_index in JSON is the First Frame (short-term memory)
# frame_index and the next 80 frames constitute GT (target frames to generate)
# First Frame provides immediate visual and temporal starting point (Image-to-Video mode)
frames_dir = os.path.join(dataset_base_path, 'frames', video_name)
first_frame_loaded = False
# Experiment 1_4_2: Load camera poses once for all context frames (one JSON read)
json_file = os.path.join(dataset_base_path, "jsons", f"{video_name}.json")
poses_dict = load_poses_dict(json_file)
first_frame_pose_rt = None
first_frame_pose = poses_dict.get(str(first_frame_idx))
if first_frame_pose is not None:
first_frame_pose_rt = pose_to_rt(first_frame_pose)
if first_frame_pose_rt is not None:
# Experiment 1_4_2: When use_rt_relative, first frame = reference frame = identity RT
# Target actions use ref=first_frame, so context first frame must also be identity
# to align with target's coordinate system (same frame = same RT representation)
if use_rt_relative:
# Identity RT: [t=0,0,0, R=eye(3)] = [0,0,0,1,0,0,0,1,0,0,0,1]
context_actions.append([0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0])
else:
context_actions.append(first_frame_pose_rt)
else:
context_actions.append([0.0] * 12)
else:
context_actions.append([0.0] * 12)
if os.path.exists(frames_dir):
first_frame_file = os.path.join(frames_dir, f"{first_frame_idx:04d}.png")
if os.path.exists(first_frame_file):
try:
first_frame = Image.open(first_frame_file).convert('RGB')
context_frames.append(first_frame)
context_indices.append(first_frame_idx)
first_frame_loaded = True
except Exception as e:
pass # Frame loading failed, skip
# Fallback: use first frame from video_frames if file not found
if not first_frame_loaded and len(video_frames) > 0:
if isinstance(video_frames[0], Image.Image):
context_frames.append(video_frames[0])
context_indices.append(start_frame)
first_frame_loaded = True
elif isinstance(video_frames[0], str):
try:
first_frame = Image.open(video_frames[0]).convert('RGB')
context_frames.append(first_frame)
context_indices.append(start_frame)
first_frame_loaded = True
except Exception as e:
pass # Fallback frame loading failed, skip
# Step 2: Retrieve Overlap Frames (long-term memory) - with 10% probability drop
# According to the data structure:
# - Each JSON file in overlap_labels/{video_name}/{frame_index}.json contains:
# - frame_index: First Frame (short-term memory) - this is the current segment's start
# - overlapping_frames: List of historical frames (long-term memory) - sample 4 from this list
# - frame_index and the next 80 frames constitute GT (target frames to generate)
# - Iterating through all JSON files = one epoch
drop_overlap = random.random() < drop_overlap_probability
if not drop_overlap:
# Use precomputed overlap_labels for FOV overlap-based selection
overlap_labels_dir = os.path.join(dataset_base_path, 'overlap_labels')
if os.path.exists(overlap_labels_dir):
# Load overlapping_frames from JSON file: overlap_labels/{video_name}/{current_frame_idx}.json
# The JSON structure: {"frame_index": "...", "overlapping_frames": ["2796", "2797", ...]}
overlapping_indices = load_overlap_frames(
overlap_labels_dir,
video_name,
current_frame_idx
)
# Filter: exclude first_frame_idx, but allow frames with FOV overlap (may include future frames)
# FOV overlap indicates visual similarity, which is valuable for context memory
# even if the overlapping frame is from the future
overlapping_indices = [idx for idx in overlapping_indices
if idx != first_frame_idx]
# If we have overlap frames, randomly sample top_k from them
# Note: overlap_labels are precomputed FOV overlap results, containing potentially
# very long lists of non-contiguous frame indices (e.g., [2796, 3839, 4183, ...]).
# We cannot use all frames due to memory constraints, so we use random uniform sampling.
# Sampling all JSON files once = one epoch
if overlapping_indices:
# Experiment 1_4_2: Use RT relative conversion for better geometric consistency
# Aligned with Context-as-Memory [2506.03141] - use relative camera poses for FOV overlap
if use_rt_relative:
ref_pose = poses_dict.get(str(current_frame_idx))
if ref_pose is not None:
# Convert reference pose to RT format
ref_rt = pose_to_rt(ref_pose)
if ref_rt is not None:
# Score candidates using relative poses
candidate_scores = []
for candidate_idx in overlapping_indices:
candidate_pose = poses_dict.get(str(candidate_idx))
if candidate_pose is None:
continue
# Convert to RT and compute relative pose
candidate_rt = pose_to_rt(candidate_pose)
if candidate_rt is not None:
relative_rt_list = convert_rt_to_relative([candidate_rt], ref_rt)
if relative_rt_list:
relative_pose = rt_to_pose(relative_rt_list[0])
if relative_pose is not None:
# Compute FOV overlap using relative poses
ref_relative_pose = {'position': [0, 0, 0], 'rotation': [0, 0, 0]}
score = compute_fov_overlap_3d(
ref_relative_pose,
relative_pose,
fov_degrees=52.67,
max_distance=500.0
)
candidate_scores.append((candidate_idx, score))
# Sort by score and select top_k
if candidate_scores:
candidate_scores.sort(key=lambda x: x[1], reverse=True)
sampled_overlap_indices = [idx for idx, _ in candidate_scores[:top_k]]
else:
# Fallback to random sampling if RT conversion fails
num_overlap_frames = max(1, top_k)
sampled_overlap_indices = random.sample(
overlapping_indices,
min(len(overlapping_indices), num_overlap_frames)
)
else:
# Fallback to random sampling if RT conversion fails
num_overlap_frames = max(1, top_k)
sampled_overlap_indices = random.sample(
overlapping_indices,
min(len(overlapping_indices), num_overlap_frames)
)
else:
# Fallback to random sampling if reference pose not found
num_overlap_frames = max(1, top_k)
sampled_overlap_indices = random.sample(
overlapping_indices,
min(len(overlapping_indices), num_overlap_frames)
)
else:
# Original strategy: Random Uniform Sampling (recommended for robustness)
# This allows the model to learn from diverse temporal spans of memory.
# The precomputed overlapping_frames list may contain hundreds or thousands of indices,
# but we only sample top_k (e.g., 4) frames due to memory constraints.
num_overlap_frames = max(1, top_k)
# Random uniform sampling from the precomputed overlapping_frames list
# This treats memory frames as an unordered image collection
# IMPORTANT: Do NOT sort sampled_indices - they are unordered snapshots, not a temporal sequence
# Positional encoding should NOT use original absolute time positions for memory frames
sampled_overlap_indices = random.sample(
overlapping_indices,
min(len(overlapping_indices), num_overlap_frames)
)
# Load overlap frames (memory frames) - long-term memory
# Experiment 1_4_2: Use first_frame (start_frame) as reference for ALL context RTs
# Target actions use ref=first_frame; context must use same ref for trajectory alignment
ref_pose_for_rt = first_frame_pose_rt if use_rt_relative else None
if os.path.exists(frames_dir):
to_load = [(idx, os.path.join(frames_dir, f"{idx:04d}.png")) for idx in sampled_overlap_indices[:top_k]]
frames_loaded = {}
with ThreadPoolExecutor(max_workers=max(1, min(5, len(to_load)))) as ex:
futures = {ex.submit(_load_frame_png, fp): idx for idx, fp in to_load}
for fut in as_completed(futures):
frame_idx = futures[fut]
frame = fut.result()
if frame is not None:
frames_loaded[frame_idx] = frame
for frame_idx in sampled_overlap_indices[:top_k]:
if frame_idx not in frames_loaded:
continue
frame = frames_loaded[frame_idx]
context_frames.append(frame)
context_indices.append(frame_idx)
pose = poses_dict.get(str(frame_idx))
if pose is not None:
rt_pose = pose_to_rt(pose)
if rt_pose is not None:
if use_rt_relative and ref_pose_for_rt is not None:
relative_rt_list = convert_rt_to_relative([rt_pose], ref_pose_for_rt)
context_actions.append(relative_rt_list[0] if relative_rt_list else rt_pose)
else:
context_actions.append(rt_pose)
else:
context_actions.append([0.0] * 12)
else:
context_actions.append([0.0] * 12)
source = "overlap_labels_random"
else:
# No overlap frames found, will use fallback below
source = "first_frame_only"
else:
# No overlap_labels directory, will use fallback below
source = "first_frame_only"
else:
# 10% probability: drop overlap frames, only use First Frame
# This simulates video generation starting stage (no historical memory)
source = "first_frame_only_dropped"
# Step 3: Fallback if we don't have enough overlap frames (and not dropped)
# Only fill if we haven't dropped overlap frames and need more frames
if source not in ["first_frame_only_dropped"] and len(context_frames) < top_k + 1:
# Try random fallback: use random previous frames before current_frame_idx
max_prev_frame = max(1, current_frame_idx - 1)
if max_prev_frame > 1:
# Exclude first_frame_idx from random sampling
candidate_indices = [idx for idx in range(max_prev_frame)
if idx != first_frame_idx and idx < current_frame_idx]
if candidate_indices:
num_needed = top_k + 1 - len(context_frames)
num_random_frames = min(len(candidate_indices), num_needed)
if num_random_frames > 0:
sampled_indices = random.sample(candidate_indices, num_random_frames)
# Load random frames
if os.path.exists(frames_dir):
for frame_idx in sampled_indices:
frame_file = os.path.join(frames_dir, f"{frame_idx:04d}.png")
if os.path.exists(frame_file):
try:
frame = Image.open(frame_file).convert('RGB')
context_frames.append(frame)
context_indices.append(frame_idx)
pose = poses_dict.get(str(frame_idx))
if pose is not None:
rt_pose = pose_to_rt(pose)
if rt_pose is not None:
# Convert to relative RT if enabled
if use_rt_relative and first_frame_pose_rt is not None:
relative_rt_list = convert_rt_to_relative([rt_pose], first_frame_pose_rt)
context_actions.append(relative_rt_list[0] if relative_rt_list else rt_pose)
else:
context_actions.append(rt_pose)
else:
context_actions.append([0.0] * 12)
else:
context_actions.append([0.0] * 12)
except Exception as e:
pass # Frame loading failed, skip
if source == "first_frame_only":
source = "random_fallback"
# Step 4: Final fallback - use additional frames from current segment if needed
# Target is top_k + 1 frames (1 First Frame + top_k Overlap/Random Frames)
target_total_frames = top_k + 1
if len(context_frames) < target_total_frames and len(video_frames) > 0:
num_needed = target_total_frames - len(context_frames)
# Use additional frames from current segment (after first frame)
segment_start_idx = 1 if len(context_frames) > 0 else 0
for i in range(segment_start_idx, min(segment_start_idx + num_needed, len(video_frames))):
frame_idx_seg = start_frame + i
if isinstance(video_frames[i], Image.Image):
context_frames.append(video_frames[i])
context_indices.append(frame_idx_seg)
elif isinstance(video_frames[i], str):
# If it's a path, load it
try:
frame = Image.open(video_frames[i]).convert('RGB')
context_frames.append(frame)
context_indices.append(frame_idx_seg)
except:
pass
pose = poses_dict.get(str(frame_idx_seg))
if pose is not None:
rt_pose = pose_to_rt(pose)
if rt_pose is not None:
# Convert to relative RT if enabled
if use_rt_relative and first_frame_pose_rt is not None:
relative_rt_list = convert_rt_to_relative([rt_pose], first_frame_pose_rt)
context_actions.append(relative_rt_list[0] if relative_rt_list else rt_pose)
else:
context_actions.append(rt_pose)
else:
context_actions.append([0.0] * 12)
else:
context_actions.append([0.0] * 12)
# Update source if we used segment frames
if len(context_frames) >= num_needed and source in ["first_frame_only", "none"]:
source = "segment_fallback"
# Step 5: Ensure we return exactly top_k + 1 frames
# If we have some frames but not enough, pad by repeating the last frame
if len(context_frames) < target_total_frames:
if context_frames:
last_frame = context_frames[-1]
last_idx = context_indices[-1] if context_indices else first_frame_idx
last_action = context_actions[-1] if context_actions else [0.0] * 12
while len(context_frames) < target_total_frames:
context_frames.append(last_frame)
context_indices.append(last_idx)
context_actions.append(last_action) # Always pad with pose data
# Limit to top_k + 1 (in case we have more)
context_frames = context_frames[:target_total_frames]
if context_indices:
context_indices = context_indices[:target_total_frames]
if context_actions:
context_actions = context_actions[:target_total_frames]
# Ensure context_actions length matches context_frames (pad with zeros if needed)
# Always ensure context_actions are provided (not just when use_rt_relative=True)
while len(context_actions) < len(context_frames):
context_actions.append([0.0] * 12)
return context_frames, context_actions, context_indices, current_frame_idx, video_name, source
def retrieve_fov_context_frames(
data: Dict,
dataset_base_path: str,
fov_retriever=None,
top_k: int = 4, # Number of overlap frames to retrieve. First Frame will be added automatically.
use_precomputed_overlaps: bool = True,
use_rt_relative: bool = False, # Experiment 1_4_2: Use RT relative conversion (aligned with Context-as-Memory)
strict_overlap_labels: bool = False,
allow_realtime_fallback: bool = True,
allow_segment_fallback: bool = True,
drop_overlap_probability: float = 0.1, # 10% probability to drop overlap frames (paper strategy)
):
"""
Backward-compatible wrapper.
We use FOV overlap scoring to select top-k overlap frames (as in Context-as-Memory).
According to Context-as-Memory [2506.03141]:
- First Frame (current segment's first frame) is always included as immediate condition
- Overlap Frames are retrieved as long-term memory
- With 10% probability, drop overlap frames to simulate starting stage
Experiment 1_4_2: Uses RT relative conversion for better geometric consistency.
"""
context_frames, context_actions, context_indices, cur_idx, video_name, source = retrieve_simple_context_frames(
data=data,
dataset_base_path=dataset_base_path,
top_k=top_k, # top_k is number of overlap frames (4), First Frame will be added automatically (total: 5)
use_rt_relative=use_rt_relative, # Experiment 1_4_2: RT relative conversion
drop_overlap_probability=drop_overlap_probability, # 10% probability to drop overlap frames
)
# Check if we have top_k + 1 frames (1 First Frame + top_k Overlap Frames)
target_total_frames = top_k + 1
if strict_overlap_labels and len(context_frames) < target_total_frames:
return [], [], [], cur_idx, video_name, "overlap_labels_insufficient"
return context_frames, context_actions, context_indices, cur_idx, video_name, source
def save_sampling_jsonl(
output_path: str,
video_name: str,
frame_index: int,
context_indices: List[int],
prompt: Optional[str] = None,
start_frame: Optional[int] = None,
end_frame: Optional[int] = None,
source: Optional[str] = None,
append: bool = True,
) -> None:
"""
Save context sampling result to JSONL file for eval consistency.
Format:
{
"video_name": "AncientTempleEnv_0",
"frame_index": 0, # First Frame (short-term memory)
"context_indices": [0, 2796, 3839, 4183, 6339], # First Frame + 4 Overlap Frames
"prompt": "...", # Optional
"start_frame": 0, # Optional: GT segment start
"end_frame": 80, # Optional: GT segment end
"source": "overlap_labels_random" # Optional: sampling source
}
Args:
output_path: Path to JSONL file
video_name: Video name
frame_index: First Frame index (from JSON file)
context_indices: List of context frame indices [first_frame, overlap1, overlap2, ...]
prompt: Optional prompt text
start_frame: Optional GT segment start frame
end_frame: Optional GT segment end frame
source: Optional sampling source
append: Whether to append to existing file (default: True)
"""
os.makedirs(os.path.dirname(output_path), exist_ok=True)
item = {
"video_name": video_name,
"frame_index": frame_index,
"context_indices": context_indices,
}
if prompt is not None:
item["prompt"] = prompt
if start_frame is not None:
item["start_frame"] = start_frame
if end_frame is not None:
item["end_frame"] = end_frame
if source is not None:
item["source"] = source
mode = "a" if append else "w"
with open(output_path, mode, encoding="utf-8") as f:
f.write(json.dumps(item, ensure_ascii=False) + "\n")
def load_sampling_jsonl(jsonl_path: str) -> List[Dict]:
"""
Load context sampling results from JSONL file.
Args:
jsonl_path: Path to JSONL file
Returns:
List of sampling items, each containing:
{
"video_name": str,
"frame_index": int,
"context_indices": List[int],
"prompt": Optional[str],
"start_frame": Optional[int],
"end_frame": Optional[int],
"source": Optional[str]
}
"""
if not os.path.exists(jsonl_path):
return []
items = []
with open(jsonl_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
item = json.loads(line)
items.append(item)
except json.JSONDecodeError as e:
print(f"Warning: Failed to parse JSONL line: {e}")
continue
return items
def setup_fov_retriever_for_training(
dataset_base_path: str,
enable_fov_retrieval: bool = True,
) -> Optional[object]:
"""
Setup FOV retriever for training (simplified version).
Context frames are selected by FOV overlap scoring from precomputed `overlap_labels`.
"""
return None
_WARN_ONCE_KEYS = set()
def _warn_once(key: str, msg: str) -> None:
if key in _WARN_ONCE_KEYS:
return
_WARN_ONCE_KEYS.add(key)
print(f"[context_retrieval] WARN: {msg}", file=sys.stderr, flush=True)
def _load_latent(latent_dir: str, video_name: str, frame_idx: int):
"""Load a single-frame latent. Expects latent_dir/video_name/{frame_idx:04d}.pt or .pt with key 'latent' or raw tensor."""
base = os.path.join(latent_dir, video_name)
for fmt in (f"{frame_idx:04d}.pt", f"{frame_idx}.pt"):
path = os.path.join(base, fmt)
if os.path.isfile(path):
try:
try:
x = torch.load(path, map_location="cpu", weights_only=True)
except TypeError:
x = torch.load(path, map_location="cpu")
if isinstance(x, dict) and "latent" in x:
z = x["latent"]
else:
z = x
if hasattr(z, "shape"):
# (C, 1, H, W) or (C, H, W) -> flatten for similarity
return z.flatten()
return None
except Exception:
pass
return None
def latent_sim_rank(
video_name: str,
first_frame_idx: int,
overlapping_indices: List[int],
dataset_base_path: str,
top_k: int,
latent_dir: Optional[str] = None,
use_cosine: bool = True,
) -> List[int]:
"""
Rank overlapping frame indices by latent similarity to the first (reference) frame.
If latent_dir is None or latents are missing, falls back to random sample.
Expects per-frame latents under latent_dir/video_name/{frame_idx:04d}.pt (or {frame_idx}.pt).
"""
if not overlapping_indices or top_k <= 0:
return []
if latent_dir is None or not os.path.isdir(latent_dir):
return random.sample(overlapping_indices, min(top_k, len(overlapping_indices)))
ref = _load_latent(latent_dir, video_name, first_frame_idx)
if ref is None:
return random.sample(overlapping_indices, min(top_k, len(overlapping_indices)))
ref = ref.float().unsqueeze(0)
scores = []
for idx in overlapping_indices:
cand = _load_latent(latent_dir, video_name, idx)
if cand is None:
continue
cand = cand.float().unsqueeze(0)
if use_cosine:
sim = torch.nn.functional.cosine_similarity(ref, cand, dim=1).item()
else:
sim = -((ref - cand) ** 2).sum().item()
scores.append((idx, sim))
if not scores:
return random.sample(overlapping_indices, min(top_k, len(overlapping_indices)))
scores.sort(key=lambda x: x[1], reverse=True)
return [idx for idx, _ in scores[:top_k]]
def retrieve_context_frames_advanced(
data: Dict,
dataset_base_path: str,
top_k: int = 4,
drop_overlap_probability: float = 0.1,
use_rt_relative: bool = False,
retrieval_method: str = "fov",
latent_retrieval_dir: Optional[str] = None,
strict_overlap_labels: bool = False,
) -> Tuple[List, List, List[int], int, str, str]:
"""
Retrieve context frames with pluggable retrieval method.
Interface matches retrieve_fov_context_frames return:
(context_frames, context_actions, context_indices, cur_idx, video_name, source).
retrieval_method:
- "fov": use existing FOV/overlap_labels logic (random or RT-scored from overlap).
- "latent_sim": rank overlap candidates by latent similarity to first frame (requires latent_retrieval_dir).
When latent_sim is used but latent_retrieval_dir is missing or latents absent, falls back to FOV behavior.
"""
if retrieval_method == "latent_sim" and not latent_retrieval_dir:
_warn_once(
"latent_sim_missing_dir",
"retrieval_method=latent_sim but latent_retrieval_dir is not set; fallback to FOV retrieval.",
)
if retrieval_method == "fov" or (retrieval_method == "latent_sim" and not latent_retrieval_dir):
return retrieve_simple_context_frames(
data=data,
dataset_base_path=dataset_base_path,
top_k=top_k,
drop_overlap_probability=drop_overlap_probability,
use_rt_relative=use_rt_relative,
)
if retrieval_method == "latent_sim" and latent_retrieval_dir and not os.path.isdir(latent_retrieval_dir):
_warn_once(
"latent_sim_bad_dir",
f"latent_retrieval_dir not found: {latent_retrieval_dir}; latent_sim will degrade to random-overlap selection.",
)
# latent_sim: we need to inject a custom ranking into the flow. We do a minimal duplicate of the
# overlap selection step then reuse the rest via a wrapper around retrieve_simple_context_frames
# by passing a custom rank function. Since retrieve_simple_context_frames doesn't support that yet,
# we implement a full path here that mirrors it but uses latent_sim_rank for overlap selection.
video_frames = data.get("video", [])
start_frame = data.get("start_frame", 0)
end_frame = data.get("end_frame", None)
if "frame_idx" in data:
current_frame_idx = data.get("frame_idx")
else:
current_frame_idx = (start_frame + end_frame) // 2 if end_frame is not None else (len(video_frames) // 2 if video_frames else 0)
first_frame_idx = start_frame
video_name = data.get("video_name", "")
if not video_name and "video_path" in data:
video_name = os.path.basename(data["video_path"]).replace(".mp4", "").replace(".avi", "")
elif not video_name and "file_path" in data:
video_name = os.path.basename(data["file_path"]).replace(".mp4", "").replace(".avi", "")
frames_dir = os.path.join(dataset_base_path, "frames", video_name)
json_file = os.path.join(dataset_base_path, "jsons", f"{video_name}.json")
poses_dict = load_poses_dict(json_file) if os.path.isfile(json_file) else {}
first_frame_pose = poses_dict.get(str(first_frame_idx))
first_frame_pose_rt = pose_to_rt(first_frame_pose) if first_frame_pose is not None and pose_to_rt else None
context_frames: List[Image.Image] = []
context_actions: List = []
context_indices: List[int] = []
source = "none"
def _append_pose(frame_idx: int):
pose = poses_dict.get(str(frame_idx))
if pose is not None and pose_to_rt and use_rt_relative and first_frame_pose_rt is not None:
rt = pose_to_rt(pose)
if rt is not None and convert_rt_to_relative:
rel = convert_rt_to_relative([rt], first_frame_pose_rt)
context_actions.append(rel[0] if rel else [0.0] * 12)
else:
context_actions.append([0.0] * 12)
elif pose is not None and pose_to_rt:
rt = pose_to_rt(pose)
context_actions.append(rt if rt is not None else [0.0] * 12)
else:
context_actions.append([0.0] * 12)
if first_frame_pose_rt is not None and use_rt_relative:
context_actions.append([0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0])
else:
context_actions.append(first_frame_pose_rt if first_frame_pose_rt is not None else [0.0] * 12)
if os.path.isdir(frames_dir):
first_path = os.path.join(frames_dir, f"{first_frame_idx:04d}.png")
if os.path.isfile(first_path):
try:
context_frames.append(Image.open(first_path).convert("RGB"))
context_indices.append(first_frame_idx)
except Exception:
pass
if not context_frames and video_frames:
if isinstance(video_frames[0], Image.Image):
context_frames.append(video_frames[0])
context_indices.append(start_frame)
elif isinstance(video_frames[0], str) and os.path.isfile(video_frames[0]):
try:
context_frames.append(Image.open(video_frames[0]).convert("RGB"))
context_indices.append(start_frame)
except Exception:
pass
drop_overlap = random.random() < drop_overlap_probability
if drop_overlap:
target_total = top_k + 1
if strict_overlap_labels and len(context_frames) < target_total:
return [], [], [], current_frame_idx, video_name, "overlap_labels_insufficient"
return context_frames, context_actions, context_indices, current_frame_idx, video_name, "first_frame_only_dropped"
overlap_labels_dir = os.path.join(dataset_base_path, "overlap_labels")
overlapping_indices = []
if os.path.isdir(overlap_labels_dir):
overlapping_indices = load_overlap_frames(overlap_labels_dir, video_name, current_frame_idx)
overlapping_indices = [i for i in overlapping_indices if i != first_frame_idx]
if not overlapping_indices:
source = "first_frame_only"
if strict_overlap_labels and len(context_frames) < top_k + 1:
return [], [], [], current_frame_idx, video_name, "overlap_labels_insufficient"
return context_frames, context_actions, context_indices, current_frame_idx, video_name, source
sampled_overlap_indices = latent_sim_rank(
video_name, first_frame_idx, overlapping_indices, dataset_base_path, top_k,
latent_dir=latent_retrieval_dir, use_cosine=True,
)
def _load_frame(path: str):
if os.path.isfile(path):
try:
return Image.open(path).convert("RGB")
except Exception:
pass
return None
to_load = [(idx, os.path.join(frames_dir, f"{idx:04d}.png")) for idx in sampled_overlap_indices[:top_k]]
with ThreadPoolExecutor(max_workers=max(1, min(5, len(to_load)))) as ex:
futures = {ex.submit(_load_frame, path): idx for idx, path in to_load}
for fut in futures:
idx = futures[fut]
frame = fut.result()
if frame is not None:
context_frames.append(frame)
context_indices.append(idx)
_append_pose(idx)
source = "latent_sim"
if strict_overlap_labels and len(context_frames) < top_k + 1:
return [], [], [], current_frame_idx, video_name, "overlap_labels_insufficient"
return context_frames, context_actions, context_indices, current_frame_idx, video_name, source