| """ |
| Evaluation utilities for VideoMME benchmark. |
| Contains functions for answer extraction and evaluation. |
| """ |
|
|
| import os |
| import requests |
| import time |
| import random |
| import string |
| import copy |
| import traceback |
| import pandas as pd |
| from PIL import Image |
| from typing import List, Dict, Tuple, Any |
|
|
| def encode_image_to_base64(image, target_size=None): |
| """Encode an image to base64 string.""" |
| import base64 |
| import io |
| |
| if target_size is not None: |
| width, height = image.size |
| if width > height: |
| new_width = target_size |
| new_height = int(height * target_size / width) |
| else: |
| new_height = target_size |
| new_width = int(width * target_size / height) |
| image = image.resize((new_width, new_height)) |
| |
| buffer = io.BytesIO() |
| image.save(buffer, format="JPEG") |
| return base64.b64encode(buffer.getvalue()).decode('utf-8') |
|
|
| class OpenAIWrapper: |
| """Wrapper for OpenAI API.""" |
| |
| def __init__(self, model, api_base, api_key, timeout=60, retry=5, wait=5): |
| self.model = model |
| self.api_base = api_base |
| self.api_key = api_key |
| self.timeout = timeout |
| self.retry = retry |
| self.wait = wait |
| self.fail_msg = 'Failed to obtain answer via API.' |
| |
| def generate(self, messages): |
| """Generate a response from the API.""" |
| headers = {'Content-Type': 'application/json', 'Authorization': f'Bearer {self.api_key}'} |
| |
| |
| formatted_messages = [] |
| for msg in messages: |
| if msg['type'] == 'text': |
| formatted_messages.append({"role": "user", "content": [{"type": "text", "text": msg['value']}]}) |
| elif msg['type'] == 'image': |
| |
| image = Image.open(msg['value']) |
| image_data = encode_image_to_base64(image) |
| formatted_messages.append({ |
| "role": "user", |
| "content": [ |
| {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image_data}"}} |
| ] |
| }) |
| |
| payload = { |
| "model": self.model, |
| "messages": formatted_messages, |
| "max_tokens": 4096, |
| "temperature": 0 |
| } |
| |
| for i in range(self.retry): |
| try: |
| response = requests.post( |
| self.api_base, |
| headers=headers, |
| json=payload, |
| timeout=self.timeout |
| ) |
| |
| if response.status_code == 200: |
| resp_json = response.json() |
| return resp_json['choices'][0]['message']['content'].strip() |
| |
| time.sleep(self.wait) |
| except Exception as e: |
| print(f"API error: {e}") |
| time.sleep(self.wait) |
| |
| return self.fail_msg |
|
|
| class DashScopeWrapper: |
| """Wrapper for DashScope API.""" |
| |
| def __init__(self, model, api_base, api_key, timeout=60, retry=5, wait=5): |
| self.model = model |
| self.api_base = api_base |
| self.api_key = api_key |
| self.timeout = timeout |
| self.retry = retry |
| self.wait = wait |
| self.fail_msg = 'Failed to obtain answer via API.' |
| |
| def generate(self, messages): |
| """Generate a response from the API.""" |
| headers = {'Content-Type': 'application/json', 'Authorization': f'Bearer {self.api_key}'} |
| |
| |
| formatted_messages = [] |
| for msg in messages: |
| if msg['type'] == 'text': |
| formatted_messages.append({"role": "user", "content": [{"type": "text", "text": msg['value']}]}) |
| elif msg['type'] == 'image': |
| |
| image = Image.open(msg['value']) |
| image_data = encode_image_to_base64(image) |
| formatted_messages.append({ |
| "role": "user", |
| "content": [ |
| {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image_data}"}} |
| ] |
| }) |
| |
| payload = { |
| "model": self.model, |
| "messages": formatted_messages, |
| "max_completion_tokens": 4096, |
| "n": 1, |
| "temperature": 0, |
| "stream": False |
| } |
|
|
| for i in range(self.retry): |
| try: |
| response = requests.post( |
| self.api_base, |
| headers=headers, |
| json=payload, |
| timeout=self.timeout |
| ) |
| |
| if response.status_code == 200: |
| resp_json = response.json() |
| |
| |
| for output in resp_json['choices']: |
| if output['finish_reason'] not in ['stop', 'function_call']: |
| print(f"DashScope finished with error: {resp_json}") |
| time.sleep(self.wait) |
| continue |
| |
| return resp_json['choices'][0]['message']['content'] |
| else: |
| print(f"DashScope API error: HTTP {response.status_code}") |
| try: |
| error_content = response.json() |
| print(f"Error details: {error_content}") |
| except: |
| print(f"Raw error content: {response.content.decode('utf-8', errors='replace')}") |
| |
| time.sleep(self.wait) |
| except requests.exceptions.ConnectionError as conn_err: |
| print(f"DashScope: Connection error occurred: {conn_err}") |
| time.sleep(self.wait) |
| except requests.exceptions.Timeout as timeout_err: |
| print(f"DashScope: Timeout error occurred: {timeout_err}") |
| time.sleep(self.wait) |
| except requests.exceptions.RequestException as req_err: |
| print(f"DashScope: Request exception occurred: {req_err}") |
| time.sleep(self.wait) |
| except Exception as e: |
| print(f"DashScope: An error occurred: {e}") |
| print(traceback.format_exc()) |
| time.sleep(self.wait) |
| |
| return self.fail_msg |
|
|
| def build_judge(model, api_type): |
| """Build a judge model for evaluation.""" |
| if api_type == 'mit': |
| api_key = os.environ.get('MIT_SPIDER_TOKEN', '') |
| api_base = os.environ.get('MIT_SPIDER_URL', '') |
| return OpenAIWrapper(model, api_base, api_key) |
| elif api_type == 'dash': |
| api_key = os.environ.get('CHATGPT_DASHSCOPE_API_KEY', '') |
| api_base = os.environ.get('DASHSCOPE_API_BASE', '') |
| return DashScopeWrapper(model, api_base, api_key) |
| else: |
| raise ValueError(f"Unsupported API type: {api_type}") |
|
|
| def can_infer_option(answer, choices): |
| """Rule-based extraction of answer option.""" |
| if 'Failed to obtain answer via API' in answer: |
| return False |
|
|
| reject_to_answer = [ |
| "Sorry, I can't help with images of people yet.", |
| "I can't process this file.", |
| "I'm sorry, but without the image provided", |
| 'Cannot determine the answer' |
| ] |
| for err in reject_to_answer: |
| if err in answer: |
| return 'Z' |
|
|
| def count_choice(splits, choices, prefix='', suffix=''): |
| cnt = 0 |
| for c in choices: |
| if prefix + c + suffix in splits: |
| cnt += 1 |
| return cnt |
|
|
| answer_mod = copy.copy(answer) |
| chars = '.()[],:;!*#{}' |
| for c in chars: |
| answer_mod = answer_mod.replace(c, ' ') |
|
|
| splits = [x.strip() for x in answer_mod.split()] |
| count = count_choice(splits, choices) |
|
|
| if count == 1: |
| for ch in choices: |
| if 'A' in splits and len(splits) > 3: |
| return False |
| if ch in splits: |
| return ch |
| elif count == 0 and count_choice(splits, {'Z', ''}) == 1: |
| return 'Z' |
| return False |
|
|
| def can_infer_text(answer, choices): |
| """Extract answer by matching text content.""" |
| answer = answer.lower() |
| assert isinstance(choices, dict) |
| for k in choices: |
| assert k in string.ascii_uppercase |
| choices[k] = str(choices[k]).lower() |
| cands = [] |
| for k in choices: |
| if choices[k] in answer: |
| cands.append(k) |
| if len(cands) == 1: |
| return cands[0] |
| return False |
|
|
| def can_infer(answer, choices): |
| """Combined approach to infer answer choice.""" |
| answer = str(answer) |
| copt = can_infer_option(answer, choices) |
| return copt if copt else can_infer_text(answer, choices) |
|
|
| def build_choices(item): |
| """Build choices dictionary from item.""" |
| ret = {} |
| for ch in string.ascii_uppercase: |
| if ch in item and (not pd.isna(item[ch])): |
| ret[ch] = item[ch] |
| return ret |
|
|
| def build_option_str(option_dict): |
| """Build option string from dictionary.""" |
| s = 'There are several options: \n' |
| for c, content in option_dict.items(): |
| if not pd.isna(content): |
| s += f'{c}. {content}\n' |
| return s |
|
|
| def build_prompt(question, options, prediction): |
| """Build prompt for judge model.""" |
| tmpl = ( |
| 'You are an AI assistant who will help me to match ' |
| 'an answer with several options of a single-choice question. ' |
| 'You are provided with a question, several options, and an answer, ' |
| 'and you need to find which option is most similar to the answer. ' |
| 'If the meaning of all options are significantly different from the answer, output Z. ' |
| 'Your should output a single uppercase character in A, B, C, D (if they are valid options), and Z. \n' |
| 'Example 1: \n' |
| 'Question: What is the main object in image?\nOptions: A. teddy bear B. rabbit C. cat D. dog\n' |
| 'Answer: a cute teddy bear\nYour output: A\n' |
| 'Example 2: \n' |
| 'Question: What is the main object in image?\nOptions: A. teddy bear B. rabbit C. cat D. dog\n' |
| 'Answer: Spider\nYour output: Z\n' |
| 'Example 3: \n' |
| 'Question: {}?\nOptions: {}\nAnswer: {}\nYour output: ' |
| ) |
| return tmpl.format(question, options, prediction) |
|
|
| def extract_answer_from_item(model, item, wait=5): |
| """Extract answer from model prediction using rule-based and model-based approaches.""" |
| |
| choices = build_choices(item) |
| option_str = build_option_str(choices) |
| prompt = build_prompt(item['question'], option_str, item['prediction']) |
| |
| |
| prediction = item['prediction'] |
| ret = can_infer(prediction, choices) |
| |
| if ret: |
| if ret == 'Z': |
| extract_flag = False |
| log = f"Rule extract failed with rule result: {ret} prediction: {prediction}" |
| else: |
| extract_flag = True |
| log = f"Rule extract success with rule result: {ret} prediction: {prediction}" |
| return dict(opt=ret, log=log, extract_model='rule', extract_flag=extract_flag) |
| |
| |
| print(f"Rule extract failed. Use model-based extraction.") |
| if model is None: |
| assert model is not None, 'Judge model is None for VideoMME !!!' |
| |
| |
| retry = 25 |
| while retry: |
| messages_for_judge = [{'type': 'text', 'value': prompt}] |
| ans = model.generate(messages_for_judge) |
| if 'Failed to obtain answer via API' in ans: |
| print('API failed to answer.') |
| else: |
| ret = can_infer(ans, choices) |
| if ret and ret != 'Z': |
| log = f'{model.model} extract Succeed. {model.model}:{ans}\n' |
| return dict(opt=ret, log=log, extract_model=model.model, extract_flag=True) |
| else: |
| print(f'Output includes 0 / > 1 letter among candidates {set(choices)} and Z: {ans}') |
| retry -= 1 |
| T = random.random() * wait * 2 |
| time.sleep(T) |
| |
| if retry == 0: |
| options = list(choices) + ['Z'] if 'Z' not in choices else list(choices) |
| log = f'{model.model} extract failed. randomly generate one. {model.model} response:{ans}\n' |
| return dict(opt=random.choice(options), log=log, extract_model=model.model, extract_flag=False) |
|
|
| def eval_single_sample(args): |
| """Evaluate a single sample.""" |
| model, item = args |
| |
| |
| result = extract_answer_from_item(model, item) |
| |
| |
| hit = 1 if result['opt'] == item['answer'] else 0 |
| |
| return { |
| "index": item['index'], |
| "question_id": item['question_id'], |
| "question": item['question'], |
| "domain": item['category'], |
| "sub_category": item['sub_category'], |
| "prediction": item['prediction'], |
| "extracted_answer": result['opt'], |
| "extraction_method": result['extract_model'], |
| "extraction_success": result['extract_flag'], |
| "extraction_log": result['log'], |
| "gt": item['answer'], |
| "hit": hit |
| } |
|
|
|
|