diff --git a/tests/fixtures/predefined_video_shortcuts_manifest.json b/tests/fixtures/predefined_video_shortcuts_manifest.json index 5a2dbe951..0e47a64bc 100644 --- a/tests/fixtures/predefined_video_shortcuts_manifest.json +++ b/tests/fixtures/predefined_video_shortcuts_manifest.json @@ -2606,5 +2606,67 @@ "dataset_alias_name": "VUE-TR_256frame", "dataset_class_name": "VUETR", "dataset_name": "VUE-TR" + }, + "ETBench-Subset_1fps": { + "build_config": { + "class": "ETBench", + "dataset": "ETBench-Subset", + "fps": 1.0 + }, + "dataset_alias_name": "ETBench-Subset_1fps", + "dataset_class_name": "ETBench", + "dataset_name": "ETBench-Subset" + }, + "ETBench-Subset_2fps": { + "build_config": { + "class": "ETBench", + "dataset": "ETBench-Subset", + "fps": 2.0 + }, + "dataset_alias_name": "ETBench-Subset_2fps", + "dataset_class_name": "ETBench", + "dataset_name": "ETBench-Subset" + }, + "ETBench_1fps": { + "build_config": { + "class": "ETBench", + "dataset": "ETBench", + "fps": 1.0 + }, + "dataset_alias_name": "ETBench_1fps", + "dataset_class_name": "ETBench", + "dataset_name": "ETBench" + }, + "ETBench_2fps": { + "build_config": { + "class": "ETBench", + "dataset": "ETBench", + "fps": 2.0 + }, + "dataset_alias_name": "ETBench_2fps", + "dataset_class_name": "ETBench", + "dataset_name": "ETBench" + }, + "ETBench-Subset_1fps_vid": { + "build_config": { + "class": "ETBench", + "dataset": "ETBench-Subset", + "fps": 1.0, + "prompt_style": "vid" + }, + "dataset_alias_name": "ETBench-Subset_1fps_vid", + "dataset_class_name": "ETBench", + "dataset_name": "ETBench-Subset" + }, + "ETBench_1fps_vid": { + "build_config": { + "class": "ETBench", + "dataset": "ETBench", + "fps": 1.0, + "prompt_style": "vid" + }, + "dataset_alias_name": "ETBench_1fps_vid", + "dataset_class_name": "ETBench", + "dataset_name": "ETBench" } } diff --git a/tests/test_etbench_metrics.py b/tests/test_etbench_metrics.py new file mode 100644 index 000000000..5d965f3fa --- /dev/null +++ b/tests/test_etbench_metrics.py @@ -0,0 +1,162 @@ +import json + +import pandas as pd +import pytest + +from vlmeval.dataset import etbench as etb +from vlmeval.smp import dump, load + + +@pytest.mark.parametrize('answer, expected', [ + ('The event happens in 10.2 - 12.8 seconds.', [(10.2, 12.8)]), + ('It happens from 5 to 9 seconds.', [(5.0, 9.0)]), + ('The event starts at 4 seconds.\nIt ends at 8 seconds.', [(4.0, 8.0)]), + ('The event starts at 4s.', None), # no word boundary in "4s", as in the official parser + ('12.8 - 10.2', [(10.2, 12.8)]), + ('No idea.', None), +]) +def test_parse_spans(answer, expected): + assert etb.parse_spans(answer) == expected + + +def test_parse_events(): + times, caps = etb.parse_events('0 - 5 seconds, add salt.\n5 - 10 seconds, stir the soup.') + assert times == [[0.0, 5.0], [5.0, 10.0]] and caps == ['add salt.', 'stir the soup.'] + # A single time t is read as [t - 20, t + 20]. + assert etb.parse_events('At 25 seconds, add salt.') == ([[5.0, 45.0]], ['add salt.']) + assert etb.parse_events('nothing here') == (None, None) + + +def test_score_grounding(): + samples = [dict(tgt=[0, 10], a='0 - 10 seconds'), dict(tgt=[0, 10], a='5 - 15 seconds')] + res = etb.score_grounding(samples) # IoUs 1 and 1/3 + assert res['F1'] == pytest.approx((1 + 1 + 0.5 + 0.5) / 4) + assert res['mIoU'] == pytest.approx(0.66667) + + +def test_score_highlight(): + samples = [dict(tgt=[[[2, 4]], [[10, 12]]], a='The highlight moment happens in 11s.'), + dict(tgt=[[2, 4]], a='none')] + assert etb.score_highlight(samples) == dict(Total=2, Failed=1, F1=0.5) + + +def test_score_action_localization_and_summarization(): + tal = etb.score_action_localization([dict(tgt=[[0, 10], [20, 30]], a='0 - 10, 50 - 60 seconds')]) + assert tal['F1'] == pytest.approx(0.5) # precision 1/2 and recall 1/2 at every threshold + evs = etb.score_summarization([dict(tgt=[[0, 10]], a='0 - 5 seconds')]) + assert evs['F1'] == pytest.approx(0.66667) # recall 1/2, precision 1 + + +class FirstOption: + def best_option(self, answer, options): + return 0 + + +def test_score_choice(): + o = ['a cat', 'a dog', 'a bird', 'a fish'] + samples = [dict(o=o, p=1, a=a) for a in ('Best Option: (B)', 'B. a dog', '', 'the second one')] + res = etb.score_choice(samples, FirstOption()) + assert res == dict(Total=4, Failed=2, Acc=0.5) + + +def test_evaluate(tmp_path): + rows = [ + dict(task='tvg', source='charades_sta', tgt=[0, 10], prediction='The event happens in 0 - 10 seconds.'), + dict(task='tvg', source='charades_sta', tgt=[0, 10], prediction='I do not know.'), + dict(task='tal', source='thumos14', tgt=[[0, 10]], prediction='The action happens in 0 - 10 seconds.'), + dict(task='rar', source='perception_test', p=1, o=['a cat', 'a dog'], prediction='Best Option: (B)'), + ] + data = pd.DataFrame([ + dict(index=i, idx=i, video=f'v{i}', question='q', duration=30.0, g='', src='', task=r['task'], + source=r['source'], prediction=r['prediction'], tgt=json.dumps(r['tgt']) if 'tgt' in r else '', + p=r.get('p'), o=json.dumps(r['o']) if 'o' in r else '') for i, r in enumerate(rows) + ]) + eval_file = tmp_path / 'model_ETBench_1fps.xlsx' + dump(data, str(eval_file)) + metrics = object.__new__(etb.ETBench).evaluate(str(eval_file)) + assert metrics['TVG_F1'] == 50.0 and metrics['TAL_F1'] == 100.0 + assert metrics['RAR_Acc'] == 100.0 # the answer index is read back from the file as a number + assert metrics['per_source']['tvg_charades_sta']['Failed'] == 1 + assert metrics['num_samples'] == 4 + + +def test_broken_videos_are_repaired_once(tmp_path, monkeypatch): + data_file = tmp_path / 'ETBench.tsv' + dump(pd.DataFrame(dict(index=[0], video=['summe/cooking'], question=['q'])), str(data_file)) + (tmp_path / 'summe').mkdir() + (tmp_path / 'summe' / 'cooking.mp4').write_bytes(b'video') + calls = [] + + def fake_reencode(path): + calls.append(path.name) + path.rename(path.with_suffix('.orig.mp4')) + path.write_bytes(b'fixed') + + monkeypatch.setattr(etb.ETBench, '_reencode', staticmethod(fake_reencode)) + dataset = object.__new__(etb.ETBench) + dataset._repair_videos(tmp_path, data_file) + dataset._repair_videos(tmp_path, data_file) # the kept original marks the video as repaired + assert calls == ['cooking.mp4'] + + +def test_build_tsv_keeps_both_prompt_styles(tmp_path): + txt = [dict(idx=0, task='eca', source='a', video='a/v0.mp4', duration=10, q='around 4.6 seconds', src=[4.574], + p=1, o=['1.0 - 2.0 seconds', '3.0 - 4.0 seconds']), + dict(idx=0, task='tvg', source='b', video='b/v1.mp4', duration=20, q='Answer in seconds.', tgt=[1, 2])] + vid = [dict(a, q=a['q'].replace('4.6 seconds', '')) for a in txt] + vid[0]['o'] = ['1.0 - 2.0', '3.0 - 4.0'] + vid[1]['q'] = 'Answer.' + for name, annos in (('txt.json', txt), ('vid.json', vid), ('subset.json', dict(tvg=dict(b=[0])))): + (tmp_path / name).write_text(json.dumps(annos)) + data_file = tmp_path / 'ETBench.tsv' + etb.ETBench._build_tsv(tmp_path / 'txt.json', tmp_path / 'vid.json', tmp_path / 'subset.json', data_file) + data = load(str(data_file)) + assert list(data['question']) == ['around 4.6 seconds', 'Answer in seconds.'] + assert list(data['question_vid']) == ['around ', 'Answer.'] + assert json.loads(data['src'][0]) == [4.574] + assert json.loads(data['o'][0]) == txt[0]['o'] and json.loads(data['o_vid'][0]) == vid[0]['o'] + annotations = (tmp_path / 'txt.json', tmp_path / 'vid.json') + assert etb.ETBench._tsv_up_to_date(data_file, annotations) + dump(data.drop(columns=['question_vid']), str(data_file)) # a file built before the column existed + assert not etb.ETBench._tsv_up_to_date(data_file, annotations) + + subset_file = tmp_path / 'ETBench-Subset.tsv' + etb.ETBench._build_tsv(tmp_path / 'txt.json', tmp_path / 'vid.json', tmp_path / 'subset.json', subset_file, + subset=True) + assert list(load(str(subset_file))['question_vid']) == ['Answer.'] + + +def test_vid_prompt_style(): + dataset = object.__new__(etb.ETBench) + dataset.data_root, dataset.fps, dataset.nframe = '/videos', 1.0, 0 + line = pd.Series(dict(video='a/v0', task='rar', question='around 4.6 seconds', question_vid='around ', + src='[4.574]')) + dataset.prompt_style = 'txt' + assert dataset.build_prompt(line, video_llm=True)[1] == dict(type='text', value='around 4.6 seconds') + dataset.prompt_style = 'vid' + video, text = dataset.build_prompt(line, video_llm=True) + assert video == dict(type='video', value='/videos/a/v0.mp4', fps=1.0) + assert text == dict(type='text', value='around ', task='rar', src=[4.574]) + no_src = dataset.build_prompt(pd.concat([line.drop('src'), pd.Series(dict(src=float('nan')))]), video_llm=True) + assert no_src[1]['src'] is None # an empty cell is read back as NaN + with pytest.raises(ValueError): + dataset.build_prompt(line, video_llm=False) + + +class SameWords: + def best_option(self, answer, options): # the option that shares the most words with the answer + return max(range(len(options)), key=lambda i: len(set(answer.split()) & set(options[i].split()))) + + +def test_vid_style_is_scored_with_its_own_options(tmp_path, monkeypatch): + monkeypatch.setattr(etb, 'SentenceSimilarity', SameWords) + rows = [dict(index=0, idx=0, video='v0', question='q', duration=30.0, task='eca', source='charades_sta', + prediction='Best Option: (A) red', p=1, o=json.dumps(['red', 'blue']), + o_vid=json.dumps(['green', 'red']), tgt='', g='', src='')] + eval_file = tmp_path / 'model_ETBench_1fps_vid.xlsx' + dump(pd.DataFrame(rows), str(eval_file)) + dataset = object.__new__(etb.ETBench) + dataset.prompt_style = 'txt' + assert dataset.evaluate(str(eval_file))['ECA_Acc'] == 0.0 + dataset.prompt_style = 'vid' # the wrong letter is matched to the most similar option text: "red" + assert dataset.evaluate(str(eval_file))['ECA_Acc'] == 100.0 diff --git a/vlmeval/dataset/__init__.py b/vlmeval/dataset/__init__.py index d3bf75968..20464660c 100644 --- a/vlmeval/dataset/__init__.py +++ b/vlmeval/dataset/__init__.py @@ -38,6 +38,7 @@ from .eriq import ERIQBench from .erqa import ERQADataset from .erqabench import ERQABench +from .etbench import ETBench from .favor_bench import FavorBench from .flames import FlamesDataset from .foxbench import FoxBench @@ -358,7 +359,7 @@ def evaluate(self, eval_file, **judge_kwargs): Video_Holmes, VCRBench, CGAVCounting, EgoExoBench_MCQ, DREAM, VideoTT, VideoMMMU, MVUEval, OMTGBench, V2PBench, AVSpeakerBench, VideoMMEv2, ReVSI, SISBench, VideoEvalPro_MCQ, VideoEvalPro_OpenEnded, VRBenchDataset, FavorBench, - TimeLensBench, VideoOdysseyV, VUETR + TimeLensBench, VideoOdysseyV, VUETR, ETBench ] # add by EASI team diff --git a/vlmeval/dataset/etbench.py b/vlmeval/dataset/etbench.py new file mode 100644 index 000000000..1ebd27511 --- /dev/null +++ b/vlmeval/dataset/etbench.py @@ -0,0 +1,684 @@ +import json +import os +import os.path as osp +import random +import re +import string +import tarfile +import warnings +from pathlib import Path + +import numpy as np +import pandas as pd + +from vlmeval.smp import dump, get_intermediate_file_path, load +from vlmeval.smp.file import LMUDataRoot +from .utils import temporal_grounding as tg +from .video_base import VideoBaseDataset + +IOU_THRESHOLDS = (0.1, 0.3, 0.5, 0.7) +# Headline metrics of E.T. Bench, in the order of the paper's main table: (task, metric). +HEADLINE = [('rar', 'Acc'), ('eca', 'Acc'), ('rvq', 'Acc'), ('tvg', 'F1'), ('epm', 'F1'), ('tal', 'F1'), ('evs', 'F1'), + ('vhd', 'F1'), ('dvc', 'F1'), ('dvc', 'Sim'), ('slc', 'F1'), ('slc', 'Sim'), ('tem', 'Rec'), ('gvq', 'Rec')] +FRAMES_PREFIX = ('You are given a video with multiple frames. ' + 'The numbers before each video frame indicate its sampling timestamp (in seconds). ') + +_RANGE = re.compile(r'(\d+\.*\d*)\s*-\s*(\d+\.*\d*)') +_RANGE_STR = re.compile(r'(\d+\.*\d*\s*-\s*\d+\.*\d*)') +_TO = re.compile(r'(\d+\.*\d*)\s* to \s*(\d+\.*\d*)') +_FIRST_NUMBER = re.compile(r'\b(\d+\.\d+\b|\b\d+)\b') +_KEYWORDS = ('starts', 'ends', 'happens in', 'start time', 'end time', 'start', 'end', 'happen') +_OPTION_LETTERS = 'ABCDE' + + +# ----------------------------------------------------------------- parsing +# The parsing rules follow the official E.T. Bench evaluation (evaluation/compute_metrics.py), so that the +# scores are comparable with published E.T. Bench results. +def _floats(pairs): + out = [] + for a, b in pairs: + try: + out.append((float(a), float(b))) + except ValueError: + continue + return out + + +def parse_spans(answer): + """Time spans [(start, end), ...] in an answer, or None. "a - b" ranges anywhere in the answer come + first, then "a to b"; otherwise the first number of each sentence that mentions a start, an end or a + "happen" is read, and these numbers are paired in order. Reversed spans are swapped.""" + text = str(answer).lower() + candidates = [s for s in re.split(r'[!?\n]', text) if any(k in s for k in _KEYWORDS)] + spans = _floats(_RANGE.findall(text)) or _floats(_TO.findall(text)) + if not spans: + times = [] + for sentence in candidates: + found = _FIRST_NUMBER.findall(sentence) + if found: + times.append(float(found[0])) + spans = [(times[i], times[i + 1]) for i in range(0, len(times) // 2 * 2, 2)] + return [(min(s, e), max(s, e)) for s, e in spans] or None + + +def _time_part_to_range(time_part, radius=20): + """Read the time of one "