""" Evaluate temporal grounding on TimeLens-Bench with HuggingFace transformers. Self-contained: no dependency on nncore or the timelens package. Supports Qwen3-VL / Qwen3.5-VL model families. Usage (single GPU): python eval/eval_timelens_hf.py \ --model_path /path/to/model \ --bench_dir /path/to/TimeLens-Bench \ --dataset charades-timelens \ --output_dir outputs/eval_run Multi-GPU (launched by run_eval.sh): CUDA_VISIBLE_DEVICES=0 python eval/eval_timelens_hf.py \ --model_path /path/to/model ... --chunk 8 --index 0 & ... """ import argparse import copy import json import logging import os import random import re import time import warnings from pathlib import Path os.environ.setdefault("FORCE_QWENVL_VIDEO_READER", "decord") import numpy as np import torch from qwen_vl_utils import process_vision_info from torch.utils.data import DataLoader, Dataset from tqdm import tqdm from transformers import AutoModelForImageTextToText, AutoProcessor, StoppingCriteria, StoppingCriteriaList warnings.filterwarnings("ignore", message=".*pad_token_id.*") warnings.filterwarnings( "ignore", message="The video decoding and encoding capabilities of torchvision are deprecated.*", ) logging.getLogger("transformers").setLevel(logging.ERROR) class StopOnDecodedText(StoppingCriteria): def __init__(self, tokenizer, start_len: int, stop_text: str): super().__init__() self.tokenizer = tokenizer self.start_len = start_len self.stop_text = stop_text def __call__(self, input_ids, scores, **kwargs): generated = input_ids[:, self.start_len:] if generated.numel() == 0: return False stopped = [] for seq in generated: text = self.tokenizer.decode(seq, skip_special_tokens=True, clean_up_tokenization_spaces=False) stopped.append(self.stop_text in text) return all(stopped) def truncate_after_first_answer(text: str) -> str: end = text.find("") if end < 0: return text return text[: end + len("")] # --------------------------------------------------------------------------- # Prompts (aligned with TimeLens evaluation) # --------------------------------------------------------------------------- # Temporal grounding prompts — single source of truth in # eval/task/eval_prompt.py (PROMPT_WO_THINK matches sft_joint_all.jsonl). import sys as _sys _EVAL_TASK_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) if _EVAL_TASK_DIR not in _sys.path: _sys.path.insert(0, _EVAL_TASK_DIR) from canonical_data import canonical_dataset_record, load_json_records # noqa: E402 from eval_prompt import PROMPT_WO_THINK, TIMELENS_OFFICIAL_PROMPT # noqa: E402 GROUNDER_PROMPT = TIMELENS_OFFICIAL_PROMPT # --------------------------------------------------------------------------- # Timestamp extraction (from TimeLens) # --------------------------------------------------------------------------- def extract_time(paragraph): paragraph = paragraph.lower() # Isolate the final answer. Prefer the LAST ... block; if the # closing tag is missing (generation truncated, or stripped by a stop string), # fall back to everything after the last opening . This prevents # parsing timestamps out of the CoT reasoning that precedes the answer. answer_blocks = re.findall(r"\s*(.*?)\s*", paragraph) if answer_blocks: paragraph = answer_blocks[-1] else: open_idx = paragraph.rfind("") if open_idx >= 0: paragraph = paragraph[open_idx + len(""):] timestamps = [] time_regex = re.compile( r"\b(\d{1,2}:\d{2}:\d{2}(?:\.\d+)?|\d{1,2}:\d{2}(?:\.\d+)?)\b" ) time_matches = re.findall(time_regex, paragraph) time_matches = time_matches[: len(time_matches) // 2 * 2] if time_matches: time_matches_converted = [] for t in time_matches: parts = t.split(":") if len(parts) == 3: h, m = map(int, parts[:2]) s = float(parts[2]) time_in_sec = h * 3600 + m * 60 + s elif len(parts) == 2: m = int(parts[0]) s = float(parts[1]) time_in_sec = m * 60 + s time_matches_converted.append(float(time_in_sec)) timestamps = [ (time_matches_converted[i], time_matches_converted[i + 1]) for i in range(0, len(time_matches_converted), 2) ] if len(timestamps) == 0: patterns = [ r"(\d+\.?\d*)\s*-\s*(\d+\.?\d*)", r"(\d+\.?\d*)\s+to\s+(\d+\.?\d*)", ] for time_pattern in patterns: time_matches = re.findall(time_pattern, paragraph) if time_matches: timestamps = [(float(s), float(e)) for s, e in time_matches] break if len(timestamps) == 0: time_regex = re.compile(r"\b(\d+\.\d+|\d+)\b") time_matches = re.findall(time_regex, paragraph) time_matches = time_matches[: len(time_matches) // 2 * 2] timestamps = [ (float(time_matches[i]), float(time_matches[i + 1])) for i in range(0, len(time_matches), 2) ] return timestamps def compute_iou(a, b): max0 = max(a[0], b[0]) min0 = min(a[0], b[0]) max1 = max(a[1], b[1]) min1 = min(a[1], b[1]) return max(min1 - max0, 0) / (max1 - min0) if (max1 - min0) > 0 else 0.0 # --------------------------------------------------------------------------- # Data loading (self-contained, no timelens package needed) # --------------------------------------------------------------------------- def parse_query(query): return re.sub(r"\s+", " ", query).strip().strip(".").strip() DATASET_CONFIGS = { "charades-timelens": { "anno": "charades-timelens.json", "video_subdir": "video_shards/charades", }, "activitynet-timelens": { "anno": "activitynet-timelens.json", "video_subdir": "video_shards/activitynet", }, "qvhighlights-timelens": { "anno": "qvhighlights-timelens.json", "video_subdir": "video_shards/qvhighlights", }, } def load_annotations(bench_dir, dataset_name): canonical_path = Path(bench_dir) / f"{dataset_name}.jsonl" if canonical_path.is_file(): annos = [] for row in load_json_records(canonical_path): span = row.get("span", row.get("answer")) if ( isinstance(span, (list, tuple)) and len(span) == 2 and not isinstance(span[0], (list, tuple)) ): span = [list(span)] annos.append( { "video_path": row.get("video_path") or row.get("path"), "duration": row.get("duration", 0), "query": parse_query(row.get("problem", "")), "span": span, } ) return annos cfg = DATASET_CONFIGS[dataset_name] anno_path = os.path.join(bench_dir, cfg["anno"]) video_root = os.path.join(bench_dir, cfg["video_subdir"]) with open(anno_path, "r") as f: raw = json.load(f) annos = [] for vid, info in raw.items(): video_path = os.path.join(video_root, vid + ".mp4") duration = info.get("duration", 0) queries = info.get("queries", info.get("sentences", [])) spans = info.get("spans", info.get("timestamps", [])) for span, query in zip(spans, queries): annos.append(dict( video_path=video_path, duration=duration, query=parse_query(query), span=[span] if not isinstance(span[0], (list, tuple)) else span, )) return annos # --------------------------------------------------------------------------- # Dataset (aligned with TimeLens evaluation/utils.py) # --------------------------------------------------------------------------- class GroundingDataset(Dataset): def __init__(self, annos, processor, args): super().__init__() self.annos = annos self.processor = processor self.args = args model_lower = args.model_path.lower() if getattr(args, "prompt_mode", "same") == "timelens_official": self.prompt = TIMELENS_OFFICIAL_PROMPT else: # Default remains aligned with data/joint/sft_joint_all.jsonl. self.prompt = PROMPT_WO_THINK if "qwen3" in model_lower or "timelens-8b" in model_lower: self.downsample_rate = 32 self.model_family = "qwen3" elif "qwen2" in model_lower or "timelens-7b" in model_lower: self.downsample_rate = 28 self.model_family = "qwen2" else: self.downsample_rate = 32 self.model_family = "qwen3" def __len__(self): return len(self.annos) def __getitem__(self, index): anno = copy.deepcopy(self.annos[index]) video_path = anno["video_path"] query = anno["query"] dr = self.downsample_rate messages = [{ "role": "user", "content": [ { "type": "video", "video": video_path, "min_pixels": self.args.min_tokens * dr * dr, "max_pixels": self.args.max_pixels, "total_pixels": self.args.total_tokens * dr * dr, "max_frames": self.args.max_frames, "fps": self.args.fps, }, {"type": "text", "text": self.prompt.format(query)}, ], }] chat_kwargs = dict(tokenize=False, add_generation_prompt=True, enable_thinking=self.args.enable_thinking) text = self.processor.apply_chat_template(messages, **chat_kwargs) if self.model_family == "qwen3": images, videos, video_kwargs = process_vision_info( messages, image_patch_size=16, return_video_kwargs=True, return_video_metadata=True, ) videos, video_metadatas = zip(*videos) videos, video_metadatas = list(videos), list(video_metadatas) inputs = self.processor( text=[text], images=images, videos=videos, video_metadata=video_metadatas, padding=True, return_tensors="pt", **video_kwargs, ) else: images, videos, video_kwargs = process_vision_info( messages, return_video_kwargs=True ) inputs = self.processor( text=[text], images=images, videos=videos, padding=True, return_tensors="pt", **video_kwargs, ) return { "inputs": inputs, "anno": anno, "prompt": text, "prompt_template": self.prompt, } # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- def set_seed(seed): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed) def parse_args(): p = argparse.ArgumentParser(description="TimeLens-Bench eval with HuggingFace") p.add_argument("--model_path", required=True) p.add_argument("--bench_dir", required=True) p.add_argument( "--dataset", required=True, help="Legacy TimeLens name or a canonical snake_case split.", ) p.add_argument("--output_dir", required=True) p.add_argument("--enable_thinking", default="false", choices=["true", "false"]) p.add_argument( "--prompt_mode", default="same", choices=["same", "timelens_official"], help="same: training-aligned prompt; " "timelens_official: exact official TimeLens prompt", ) p.add_argument("--min_tokens", type=int, default=1) p.add_argument("--total_tokens", type=int, default=128000) p.add_argument("--max_pixels", type=int, default=409600) p.add_argument("--max_frames", type=int, default=2048) p.add_argument("--fps", type=int, default=4) p.add_argument("--max_new_tokens", type=int, default=128) p.add_argument("--repetition_penalty", type=float, default=1.0) p.add_argument("--temperature", type=float, default=0.0) p.add_argument("--top_p", type=float, default=1.0) p.add_argument("--num_return_sequences", type=int, default=1) p.add_argument( "--max_samples", type=int, default=0, help="Maximum prompts in this shard; 0 evaluates the full shard.", ) p.add_argument( "--stop_after_answer", default="true", choices=["true", "false"], help="Stop decoding immediately after the first generated .", ) p.add_argument("--chunk", type=int, default=1) p.add_argument("--index", type=int, default=0) p.add_argument("--seed", type=int, default=42) p.add_argument("--num_workers", type=int, default=2) p.add_argument("--compile", action="store_true", help="Use torch.compile for faster inference") p.add_argument("--processor_path", default=None, help="Path to load processor from (defaults to model_path)") args = p.parse_args() args.enable_thinking = args.enable_thinking == "true" args.stop_after_answer = args.stop_after_answer == "true" if args.num_return_sequences < 1: p.error("--num_return_sequences must be >= 1") if args.num_return_sequences > 1 and args.temperature <= 0: p.error("--num_return_sequences > 1 requires --temperature > 0") if args.processor_path is None: args.processor_path = args.model_path return args def main(): args = parse_args() canonical_profile = canonical_dataset_record("temporal_grounding", args.dataset) if canonical_profile is not None: preprocessing = canonical_profile.get("preprocessing", {}) if isinstance(preprocessing, dict): for name in ( "fps", "min_tokens", "max_frames", "max_pixels", "total_tokens", ): if name in preprocessing: setattr(args, name, preprocessing[name]) set_seed(args.seed) os.makedirs(args.output_dir, exist_ok=True) result_path = os.path.join( args.output_dir, f"results_{args.dataset}_shard{args.index}.json", ) print(f"Model: {args.model_path}") print(f"Dataset: {args.dataset} | Chunk {args.index}/{args.chunk}") print( f"Thinking: {args.enable_thinking} | Prompt mode: {args.prompt_mode} " f"| FPS: {args.fps}" ) print(f"Tokens: min={args.min_tokens}, total={args.total_tokens}") print(f"Video: max_pixels={args.max_pixels}, max_frames={args.max_frames}") print(f"Output: {result_path}") # Load model print("\nLoading model ...") if not torch.cuda.is_available(): raise RuntimeError( "CUDA is required for TimeLens HF evaluation because inputs are moved " "to CUDA and FlashAttention2 is enabled." ) model = AutoModelForImageTextToText.from_pretrained( args.model_path, dtype=torch.bfloat16, attn_implementation="flash_attention_2", device_map={"": "cuda:0"}, ).eval() if args.compile: print("Compiling model with torch.compile ...") model = torch.compile(model, mode="reduce-overhead") processor = AutoProcessor.from_pretrained( args.processor_path, padding_side="left", do_resize=False, trust_remote_code=True, ) # Load data annos = load_annotations(args.bench_dir, args.dataset) annos.sort(key=lambda x: x["duration"], reverse=True) annos = annos[args.index :: args.chunk] if args.max_samples > 0: annos = annos[: args.max_samples] print(f"Loaded {len(annos)} samples (shard {args.index}/{args.chunk})") dataset = GroundingDataset(annos, processor, args) loader = DataLoader( dataset, batch_size=1, shuffle=False, num_workers=args.num_workers, prefetch_factor=2 if args.num_workers > 0 else None, pin_memory=True, collate_fn=lambda x: x[0], ) # Inference results = [] ious = [] recall = {0.3: 0, 0.5: 0, 0.7: 0} t0 = time.time() data_wait = 0.0 gen_total = 0.0 sample_idx = 0 iter_start = time.time() for data in tqdm(loader, desc=f"shard-{args.index}"): t_data = time.time() data_elapsed = t_data - iter_start data_wait += data_elapsed inputs = data["inputs"].to("cuda", non_blocking=True) anno = data["anno"] prompt = data["prompt"] prompt_template = data["prompt_template"] duration = anno["duration"] span = anno["span"] if isinstance(span[0], (list, tuple)): span = span[0] input_len = inputs.input_ids.shape[-1] stopping_criteria = None if args.stop_after_answer: stopping_criteria = StoppingCriteriaList([ StopOnDecodedText(processor.tokenizer, input_len, "") ]) t_gen = time.time() do_sample = args.temperature > 0 output_ids = model.generate( **inputs, do_sample=do_sample, temperature=args.temperature if do_sample else None, top_p=args.top_p if do_sample else None, top_k=None, num_return_sequences=args.num_return_sequences, max_new_tokens=args.max_new_tokens, repetition_penalty=args.repetition_penalty, stopping_criteria=stopping_criteria, ) gen_elapsed = time.time() - t_gen gen_total += gen_elapsed trimmed = [out[input_len:] for out in output_ids] answers = processor.batch_decode( trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False, ) if args.stop_after_answer: answers = [truncate_after_first_answer(answer) for answer in answers] sample_idx += 1 if sample_idx <= 30 or sample_idx % 50 == 0: print(f" [{sample_idx}] data={data_elapsed:.1f}s gen={gen_elapsed:.1f}s " f"in={input_len} n={len(answers)} out={trimmed[0].shape[0]} | {answers[0][:80]}", flush=True) for rollout_index, answer in enumerate(answers): timestamps = extract_time(answer) parsed = bool(timestamps) if not timestamps: timestamps = [(duration + 10, duration + 20)] timestamps = [(round(s), round(e)) for s, e in timestamps] pred = timestamps[0] iou_val = compute_iou(span, pred) ious.append(iou_val) for t in recall: if iou_val >= t: recall[t] += 1 results.append({ "video": os.path.basename(anno["video_path"]), "query": anno["query"], "gt_span": span, "pred_span": list(pred), "iou": round(iou_val, 4), "answer": answer, "parsed": parsed, "rollout_index": rollout_index, "prompt": prompt, "prompt_template": prompt_template, "duration": duration, }) if sample_idx % 50 == 0 or sample_idx == len(dataset): n_cur = len(ious) cur_metrics = { "num_samples": n_cur, "mIoU": round(sum(ious) / n_cur * 100, 2), } for t in [0.3, 0.5, 0.7]: cur_metrics[f"R@{t}"] = round(recall[t] / n_cur * 100, 2) print(f" [checkpoint {sample_idx}/{len(dataset)}] " f"mIoU={cur_metrics['mIoU']:.2f}% R@0.3={cur_metrics['R@0.3']:.2f}% " f"R@0.5={cur_metrics['R@0.5']:.2f}% R@0.7={cur_metrics['R@0.7']:.2f}%", flush=True) with open(result_path, "w") as f: json.dump(results, f, ensure_ascii=False, indent=2) iter_start = time.time() elapsed = time.time() - t0 n = len(ious) print(f"\nInference done: {n} samples in {elapsed:.1f}s ({n / elapsed:.1f} samples/s)") print(f" data_wait={data_wait:.1f}s gen={gen_total:.1f}s " f"avg_data={data_wait/max(1, sample_idx):.2f}s " f"avg_gen={gen_total/max(1, sample_idx):.2f}s") # Metrics metrics = { "num_samples": n, "mIoU": round(sum(ious) / n * 100, 2) if n else 0, } for t in [0.3, 0.5, 0.7]: metrics[f"R@{t}"] = round(recall[t] / n * 100, 2) if n else 0 n_parsed = sum(1 for r in results if r.get("parsed", False)) metrics["parse_rate"] = round(n_parsed / n * 100, 2) if n else 0 print(f" mIoU={metrics['mIoU']:.2f}% R@0.3={metrics['R@0.3']:.2f}% " f"R@0.5={metrics['R@0.5']:.2f}% R@0.7={metrics['R@0.7']:.2f}% " f"Parse={metrics['parse_rate']:.2f}%") # Save with open(result_path, "w") as f: json.dump(results, f, ensure_ascii=False, indent=2) summary_path = os.path.join( args.output_dir, f"summary_shard{args.index}.json", ) with open(summary_path, "w") as f: json.dump({args.dataset: metrics}, f, ensure_ascii=False, indent=2) print(f"Results -> {result_path}") print(f"Summary -> {summary_path}") if __name__ == "__main__": main()