from collections import defaultdict import json import math from pathlib import Path import re import unicodedata PERCEPTION = {'existence', 'count', 'position', 'color', 'posters', 'celebrity', 'scene', 'landmark', 'artwork', 'OCR'} ANSWER_EXTRACTION_VERSION = 'visual-final-answer-v6' REFERENCE_CORRECTIONS = json.loads((Path(__file__).resolve().parents[1] / 'configs/visual_reference_corrections.json').read_text()) ANSWER_MARKER = re.compile('(?im)(?:\\b(?:the\\s+)?(?:(?:final|correct|best)\\s+)?(?:answer|option|choice|code)\\s*(?:(?:is|would be|should be)\\s*[:=]?|[:=])\\s*|(?:最终答案|正确答案|正确的?选项|答案|故选|选择)\\s*(?:是|为)?\\s*[::]?\\s*)([^\\n]+)') CHOICE_COMMITMENT = re.compile('(?im)(?:\\b((?-i:[A-D]))\\s+is\\s+(?:the\\s+)?(?:correct|best|right|intended)\\s+(?:answer|option|choice)|\\b(?:option|choice)\\s+((?-i:[A-D]))\\s+is\\s+(?:the\\s+)?(?:correct|best|right|intended)\\b|\\bI\\s+(?:will\\s+)?(?:choose|select|pick)\\s+((?-i:[A-D]))\\b|\\b(?:relationship|caption|statement|appeal)\\b[^.\\n]*?\\s+(?:is|would be)\\s+((?-i:[A-D]))\\b|\\b(?:letter|option)\\s+(?:representing\\b[^.\\n]*?|corresponding\\b[^.\\n]*?)\\s+is\\s+((?-i:[A-D]))\\b)') def clean_answer(text): value = unicodedata.normalize('NFKC', text).strip() value = value.replace('**', '').replace('`', '').replace('\\%', '%') value = re.sub('', '', value, flags=re.I) value = re.sub('\\\\[\\[\\]()]', '', value) for match in reversed(list(re.finditer('\\\\(?:boxed|fbox|text|textbf|textrm|mathrm|mathbf|operatorname)\\s*\\{', value))): depth = 1 for end in range(match.end(), len(value)): depth += (value[end] == '{') - (value[end] == '}') if depth == 0: value = value[:match.start()] + value[match.end():end] + value[end + 1:] break return value.strip().strip('$*').strip() def choice_value(value, choices, explicit_option=False): value = clean_answer(value).strip(' \t\r\n.*"\'::。!!') if re.fullmatch('[\\[(]\\s*[A-Za-z]\\s*[\\])]', value): value = value[1:-1].strip() labeled = re.fullmatch('([A-Da-d])[.)、::]\\s*(.+)', value) if labeled: content_keys = [key for key, answer in choices.items() if labeled[2].strip(' .*"\'。').casefold() == clean_answer(answer).strip(' .*"\'。').casefold()] if content_keys and labeled[1].upper() not in content_keys: return None if explicit_option: value = re.sub('^(?:option|choice|letter|选项)\\s*', '', value, flags=re.I) match = re.match('([A-Za-z])(?=$|[\\s.,:;()。::])', value) if match: if re.match('\\s*(?:\\b(?:or|and)\\b|[&/]|或|和|、|[,,]\\s*[A-D](?![A-Za-z]))', value[match.end():], re.I): return None if match[1].upper() in choices: return match[1].upper() if value in choices: return value matching = [key for key, answer in choices.items() if value.casefold() == clean_answer(answer).strip(' .*"\'。').casefold()] if matching: return matching[0] if len(value) == 1 and value.upper() in choices: return value.upper() match = labeled if match and match[1].upper() in choices: option = clean_answer(choices[match[1].upper()]).strip(' .*"\'。') content = match[2].strip(' .*"\'。') if content == match[1].upper() or content.casefold() == option.casefold(): return match[1].upper() if not re.search('\\b[A-D][.)、::]\\s|\\b(?:or|and)\\s+[A-D]\\b', content): return match[1].upper() return None def answer_markers(text): matches = [] for match in ANSWER_MARKER.finditer(text): prefix = re.split('[.!?。\\n]', text[:match.start()])[-1] if re.search('\\b(?:if|whether|possible|assuming)\\b|如果|是否|假如', prefix, re.I): continue if re.search('[??]', re.split('[.。\\n]', match[1])[0]): continue matches.append(match) return matches def choice_answer(text, choices): value = clean_answer(text) if not value: return None blocks = re.split('\\n\\s*\\n', value) tail = blocks[-1].strip() if re.fullmatch('[A-Za-z](?:\\s*[,,/&、]\\s*[A-Za-z])+[.。]?', tail): return None structured = re.findall('["\\\']answer["\\\']\\s*:\\s*["\\\']([^"\\\']+)["\\\']', tail, re.I) if structured: candidates = [choice_value(item, choices, explicit_option=True) for item in structured] return candidates[0] if len(set(candidates)) == 1 else None direct = choice_value(value, choices) if direct is not None: return direct tail_marked = answer_markers(tail) if tail_marked: candidates = [choice_value(match[1], choices, explicit_option=True) for match in tail_marked] if all((candidate is not None for candidate in candidates)): return candidates[0] if len(set(candidates)) == 1 else None if len(candidates) > 1: return None tail_lines = tail.splitlines() preceding = [choice_value(line, choices) for line in tail_lines[:-1]] if '\n' not in tail or not any((item is not None for item in preceding)): terminal_text = tail.splitlines()[-1] committed_tail = re.fullmatch("I(?:'ll| will)?\\s+(?:answer|choose|select|pick|go with)\\s+(?:option\\s+)?([A-Za-z])[.!。]?", terminal_text, re.I) if committed_tail: return choice_value(committed_tail[1], choices, explicit_option=True) named = re.fullmatch('(?:option|choice|answer|选项|答案)[_\\s::]*([A-Za-z])[.。]?', terminal_text, re.I) if named: return choice_value(named[1], choices, explicit_option=True) explicit = False marked = answer_markers(value) if marked and len(terminal_text) == 1 and terminal_text.islower(): explicit = bool(re.match(re.escape(terminal_text) + '(?=$|[\\s.。])', marked[-1][1], re.I)) terminal = choice_value(terminal_text, choices, explicit_option=explicit) if terminal is not None or re.fullmatch('[A-Za-z][.!。]?', terminal_text): return terminal depicted = re.fullmatch('The (?:diagram|image|picture) (?:depicts|shows|represents) ([A-D])[.。]?', terminal_text, re.I) if depicted: return depicted[1].upper() if depicted[1].upper() in choices else None marked = answer_markers(value) candidates = [choice_value(match[1], choices, explicit_option=True) for match in marked] candidates = [candidate for candidate in candidates if candidate is not None] if candidates: return candidates[0] if len(set(candidates)) == 1 else None committed = [] for match in CHOICE_COMMITMENT.finditer(value): if re.search('\\b(?:if|whether|possible|assuming)\\b|如果|是否', re.split('[.!?。\\n]', value[:match.start()])[-1], re.I): continue if re.match('\\s*(?:or|and|[,/&]|或|和)\\s*[A-D]\\b', value[match.end():], re.I): continue letter = next((group for group in match.groups() if group)) diagram = match.lastindex == 5 and match[0].lower().startswith('letter') committed.append(choice_value(letter.lower() if diagram else letter, choices, explicit_option=not diagram)) if committed: return committed[0] if len(set(committed)) == 1 else None if len(value.splitlines()) > 1: listed = [choice_value(line, choices) for line in value.splitlines()] listed = [candidate for candidate in listed if candidate is not None] if listed and len(set(listed)) == 1: return listed[0] return None def short_answer(text): value = clean_answer(text) if not value: return '' tail = re.split('\\n\\s*\\n', value)[-1].strip() marked = answer_markers(tail) return (marked[-1][1] if marked else tail).strip().strip('"\'').strip() def yes_no_answer(text): text = clean_answer(text) equivalents = {'true': 'yes', 'false': 'no', '是': 'yes', '否': 'no', '不是': 'no'} lines = [line.strip() for line in text.splitlines() if line.strip()] conclusion = re.split('\\b(?:therefore|thus|hence|in conclusion)\\s*,?\\s*', text, flags=re.I)[-1] for value in [short_answer(text), conclusion, *lines[-1:], *lines[:1]]: value = re.sub('^(?:(?:therefore|thus|so|in conclusion|hence)[,:]?\\s*)+', '', value, flags=re.I) if value.lower().strip('.。!!') in equivalents: return equivalents[value.lower().strip('.。!!')] match = re.match('^(yes|no|y|n)\\b', value, re.I) if match: if re.match('\\s*(?:or|and|/|或|和)\\s*(?:yes|no)\\b', value[match.end():], re.I): return None return 'yes' if match[1].lower() in ('yes', 'y') else 'no' return None CHART_NUMBER = '[+-]?(?:\\d{1,3}(?:[, ]\\d{3})+(?:\\.\\d+)?|\\d+(?:\\.\\d+)?|\\.\\d+)(?:[eE][+-]?\\d+)?' CHART_UNIT = '(?:(?:thousand|million|billion|trillion|Canadian|Australian|U\\.?S\\.?|US|USD|EUR|GBP|RMB|dollars?|euros?|pounds?|yuan|people|persons?|households?|tons?|tonnes?|kg|kilograms?|km|kWh|miles?|hours?|years?|units?|per|capita|k|t)\\b\\.?\\s*)*' def chart_scalar(value): value = clean_answer(value).rstrip('.。').replace('−', '-') value = re.sub('^(?:approximately|approx\\.?|about|around|roughly)\\s+', '', value, flags=re.I) value = re.sub('\\\\frac\\s*\\{([^{}]+)\\}\\s*\\{([^{}]+)\\}', '\\1/\\2', value) ratio = re.fullmatch('(' + CHART_NUMBER + ')\\s*[:/]\\s*(' + CHART_NUMBER + ')', value) if ratio: denominator = float(ratio[2].replace(',', '').replace(' ', '')) return str(float(ratio[1].replace(',', '').replace(' ', '')) / denominator) if denominator else None match = re.fullmatch('(?:[$€£¥]\\s*)?(' + CHART_NUMBER + ')\\s*(%?)\\s*' + CHART_UNIT, value, re.I) if match: return match[1].replace(',', '').replace(' ', '') + match[2] return None def chart_answer(text, question=''): value = short_answer(text).strip().rstrip('.。') value = re.sub('^(?:the|a|an)\\s+', '', value, flags=re.I) direct = chart_scalar(value) if direct is not None: return direct if not re.search('\\d', value): return value emphasis = re.findall('\\*\\*([^*\\n]+)\\*\\*', text) if emphasis: emphasized = chart_scalar(emphasis[-1]) if emphasized is not None: return emphasized conclusions = re.split('\\b(?:Therefore|Thus|Hence|In conclusion)[,:]?\\s*', value, flags=re.I) conclusion = conclusions[-1].strip() count = re.match('^(' + CHART_NUMBER + ')\\s+(?:countries|colors|bars|categories|people|items|years)\\b', conclusion, re.I) if count and (len(conclusions) > 1 or re.search('\\bhow many\\b', question, re.I)): return chart_scalar(count[1]) committed = re.findall('\\b(?:is|was|were|equals|gives|at)\\s+(?:(?:approximately|about|around)\\s+)?(' + CHART_NUMBER + '\\s*%?\\s*' + CHART_UNIT + ')(?=[.。,;]|$)', conclusion, re.I) if committed: scalar = chart_scalar(committed[-1].strip()) if scalar is not None: return scalar return value def chart_text(value): value = re.sub('\\bgrey\\b', 'gray', clean_answer(value).casefold()).strip(' []"\'.。') return tuple(sorted((item.strip(' \t\n"\'') for item in re.split('\\s+and\\s+|\\s*[,;]\\s*', value)))) def normalized_ocr(value, formula=False): value = clean_answer(value) if formula: value = re.sub('\\\\(?:quad|qquad|enspace|left|right)\\b|\\\\[,;! ]', '', value) value = re.sub('\\\\triangle\\b', '\\\\Delta', value) value = re.sub('\\\\perp\\b', '\\\\bot', value) value = re.sub('\\{\\s*([A-Za-z0-9])\\s*\\}', '\\1', value) return re.sub('\\s+', '', value) value = re.sub('(?<=\\.)\\s+(?=\\w)', '', value.casefold()) return re.sub('\\s+', ' ', value).strip() def score_prediction(row, final_answer): text = final_answer.strip() source = row['source'] prediction = text extracted = bool(text) correction = REFERENCE_CORRECTIONS['by_id'].get(row.get('id'), {}) family = f"{source}:{row.get('circular_id')}" correction = {**REFERENCE_CORRECTIONS['by_family'].get(family, {}), **correction} reference = correction.get('answer', row['answer']) details = {'answer_extraction_version': ANSWER_EXTRACTION_VERSION} if correction: details['reference_correction'] = correction['reason'] if correction.get('exclude'): details['excluded_from_metric'] = correction['reason'] if row['choices']: prediction = choice_answer(text, row['choices']) accepted = correction.get('choice_texts', [row['choices'][reference]]) accepted = [*accepted, *(row['choices'][reference].replace(old, new) for old, new in correction.get('choice_alias_replacements', []))] score = prediction is not None and any((clean_answer(row['choices'][prediction]) == clean_answer(answer) for answer in accepted)) extracted = prediction is not None elif source == 'ocrbench': def normalized(value): if row['dataset'] == 'HME100k': return value.strip().replace('\n', ' ').replace(' ', '') value = ''.join((chr(ord(c) - 65248) if 65281 <= ord(c) <= 65374 else ' ' if c == '\u3000' else c for c in value)) return value.lower().strip().replace('\n', ' ') details['literal_ocr_score'] = float(any((normalized(answer) in normalized(text) for answer in row['answer']))) if text else 0.0 formula = row['dataset'] == 'HME100k' if formula: expressions = re.findall('\\$\\$(.*?)\\$\\$|\\\\\\[(.*?)\\\\\\]|(?