evalstone/evalscope/tests/benchmark/test_cc_ocr_v2.py
sora 13274243a0 Bump vendored EvalScope and add K3-ready DPV4 configs.
Keep K3 suite selection and report-schema scoring in bash, merge K3/vision dataset_args into dpv4 yamls, and pin EvalScope at 735d920ee911 with local patches.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 07:30:48 +00:00

203 lines
9.8 KiB
Python

"""Unit tests for the CC-OCR V2 scorers.
Every track uses a different metric and the answer conventions differ per scenario, so a
regression here silently shifts scores instead of raising. The expected values below were
cross-checked against the official ``src/evaluate_*.py`` scripts.
"""
import json
import os
import tempfile
import pytest
from evalscope.benchmarks.cc_ocr_v2.cc_ocr_v2_adapter import _index_images
from evalscope.benchmarks.cc_ocr_v2.utils import (
parse_object_list,
parse_point_box,
parsing_op,
score_kie,
score_object_grounding,
score_parsing,
score_recognition,
score_sample,
score_text_grounding,
score_vqa,
strip_code_fence,
)
def test_strip_code_fence_only_unwraps_bare_and_json_fences() -> None:
"""Regression: the official scorers accept only bare and ``json`` fences.
An ``html`` or ``latex`` fence is not recognised, so the tag itself leaks into the scored
text and costs the prediction points -- which is why the parsing track unwraps those
fences explicitly instead of relying on this helper.
"""
assert strip_code_fence('```json\n{"a": 1}\n```') == '{"a": 1}'
assert strip_code_fence('```\nplain\n```') == 'plain'
assert strip_code_fence('```html\n<p>x</p>\n```') == 'html\n<p>x</p>'
assert strip_code_fence('```latex\n\\alpha\n```') == 'latex\n\\alpha'
# A fence that is not the whole reply is left alone
assert strip_code_fence('text ```json\n1\n``` tail').startswith('text ```json')
def test_score_recognition_tokenizes_per_language() -> None:
# English scene text: word level, alphanumeric only for multi_scene scenarios
scenario = 'multi_scene_ocr_document_text_CORD_100'
assert score_recognition('Lemon Tea 25.000', 'Lemon Tea 25.000', scenario) == pytest.approx(1.0)
# Punctuation is dropped for multi_scene word-level data, so it cannot cost points
assert score_recognition('Lemon, Tea 25000', 'Lemon Tea 25.000', scenario) == pytest.approx(1.0)
# Chinese ground truth switches to character level
zh_scenario = 'multi_scene_ocr_document_text_zh_doc_100'
assert score_recognition('合计金额', '合计金额', zh_scenario) == pytest.approx(1.0)
assert 0.0 < score_recognition('合计', '合计金额', zh_scenario) < 1.0
# Non-multi_scene scenarios keep punctuation, so the token no longer matches
assert score_recognition('bonjour!', 'bonjour', 'multi_lan_ocr_French_French_20') == 0.0
def test_score_kie_matches_fields() -> None:
reference = json.dumps({'Name': '周志强', 'Total Amount': '1674.00'}, ensure_ascii=False)
assert score_kie(reference, reference) == pytest.approx(1.0, abs=1e-5)
assert score_kie(f'```json\n{reference}\n```', reference) == pytest.approx(1.0, abs=1e-5)
# One field right, one wrong: that is 1 true positive plus a false positive and a false
# negative, so F1 = 1 / (1 + 2/2) = 0.5
half = json.dumps({'Name': '周志强', 'Total Amount': '0.00'}, ensure_ascii=False)
assert score_kie(half, reference) == pytest.approx(0.5, abs=1e-5)
# Unparseable prediction earns nothing but must not raise
assert score_kie('I cannot read this image.', reference) == 0.0
def test_score_vqa_short_and_long_answers() -> None:
# Short English answer: substring match, no partial credit
assert score_vqa('The total is 1,234 dollars.', '1,234') == 1.0
assert score_vqa('The total is 9,999 dollars.', '1,234') == 0.0
assert score_vqa('total two thousand', 'total one thousand') == 0.0
# Short Chinese answers fall back to ANLS -- the one asymmetry in the official scorers
assert score_vqa('合计金额一千圆', '合计金额一千元') == pytest.approx(6 / 7)
# Long answers are scored by ANLS with a 0.5 floor
long_answer = 'the quick brown fox jumps over the lazy dog'
assert score_vqa(long_answer, long_answer) == 1.0
assert score_vqa('completely different words here entirely', long_answer) == 0.0
# A list of acceptable answers takes the best match
assert score_vqa('it is 42', json.dumps(['7', '42'])) == 1.0
assert score_vqa('', '42') == 0.0
def test_parse_point_box_reads_the_first_tuple() -> None:
assert parse_point_box('(845, 163, 1102, 307)') == [845.0, 163.0, 1102.0, 307.0]
assert parse_point_box('The box is [0.1, 0.2, 0.3, 0.4].') == [0.1, 0.2, 0.3, 0.4]
assert parse_point_box('I cannot locate that text.') is None
def test_score_text_grounding_without_image_compares_as_is() -> None:
reference = '[100.0, 100.0, 200.0, 200.0]'
assert score_text_grounding('(100, 100, 200, 200)', reference, '') == 1.0
assert score_text_grounding('no box here', reference, '') == 0.0
# A malformed reference cannot be scored
assert score_text_grounding('(100, 100, 200, 200)', 'not a box', '') == 0.0
def test_score_text_grounding_maps_normalized_predictions_to_pixels(tmp_path) -> None:
"""Regression: nothing else exercises the rescaling branch.
Every model observed so far answered in absolute pixels, so a run never reaches the
``scale=1000`` path -- a silent break here would zero out the score of every model that
actually obeys the prompt, while leaving the rest of the suite green.
"""
from PIL import Image
image_path = str(tmp_path / 'page.png')
Image.new('RGB', (1000, 500), 'white').save(image_path)
reference = '[400.0, 100.0, 600.0, 300.0]' # pixels
# The same box expressed on the 0-1000 grid the prompt asks for, and unit normalized
assert score_text_grounding('(400, 200, 600, 600)', reference, image_path) == pytest.approx(1.0)
assert score_text_grounding('(0.4, 0.2, 0.6, 0.6)', reference, image_path) == pytest.approx(1.0)
# Absolute pixels are read as 0-1000 values and therefore land elsewhere, matching the
# official scorer, which normalizes the same way.
assert score_text_grounding('(400, 100, 600, 300)', reference, image_path) < 0.5
def test_parse_object_list_accepts_common_bbox_notations() -> None:
payload = json.dumps([
{'bbox_2d': [1, 2, 3, 4], 'label': 'DATE'},
{'box_2d': {'xmin': 5, 'ymin': 6, 'xmax': 7, 'ymax': 8}, 'category_name': 'CITY'},
{'bbox': {'x': 10, 'y': 10, 'width': 5, 'height': 5}, 'label': 'ZIP'},
{'label': 'no box'},
])
assert parse_object_list(payload) == [[1, 2, 3, 4], [5, 6, 7, 8], [10, 10, 15, 15]]
assert parse_object_list('not json at all') is None
def test_score_object_grounding_averages_matched_iou() -> None:
reference = json.dumps([
{'bbox_2d': [0, 0, 10, 10], 'label': 'A'},
{'bbox_2d': [100, 100, 110, 110], 'label': 'B'},
])
assert score_object_grounding(reference, reference, '') == 1.0
# One of two boxes recovered -> mean IoU over ground-truth boxes is 0.5
partial = json.dumps([{'bbox_2d': [0, 0, 10, 10], 'label': 'A'}])
assert abs(score_object_grounding(partial, reference, '') - 0.5) < 1e-9
assert score_object_grounding('sorry, nothing found', reference, '') == 0.0
def test_parsing_op_detection_covers_every_scenario_family() -> None:
assert parsing_op('doc_parsing_formula_formula_handwriting_100') == 'formula'
assert parsing_op('doc_parsing_molecular_molecular_handwriting_100') == 'molecular'
assert parsing_op('doc_parsing_custom_info_board_8') == 'custom'
assert parsing_op('doc_parsing_table_table_photo_150') == 'table'
assert parsing_op('doc_parsing_doc_doc_scan_150') == 'doc'
def test_score_parsing_per_op() -> None:
formula = r'\frac{1}{2}'
assert score_parsing(f'```latex\n{formula}\n```', formula, 'doc_parsing_formula_formula_x_1') == 1.0
smiles = 'O=C(N)CC'
assert score_parsing(f'<smiles>{smiles}</smiles>', smiles, 'doc_parsing_molecular_molecular_x_1') == 1.0
table = '<table><tr><td>a</td><td>b</td></tr></table>'
assert score_parsing(f'```html\n{table}\n```', table, 'doc_parsing_table_table_photo_1') == 1.0
assert score_parsing('no table at all', table, 'doc_parsing_table_table_photo_1') == 0.0
doc = r'\section{Report} Total: 42'
assert score_parsing(doc, doc, 'doc_parsing_doc_doc_scan_1') == 1.0
assert score_parsing('', doc, 'doc_parsing_doc_doc_scan_1') == 0.0
def test_malformed_table_prediction_scores_zero_instead_of_raising() -> None:
"""A garbled table can leave a non-numeric ``colspan``; the official TEDS tree builder
raises ``ValueError`` on it, which would abort the whole run instead of scoring 0."""
reference = '<table><tr><td colspan="2">a</td></tr></table>'
prediction = '<table><tr><td colspan="a b">x</td></tr></table>'
assert score_parsing(prediction, reference, 'doc_parsing_table_table_photo_1') == 0.0
def test_score_sample_dispatches_on_metadata() -> None:
metadata = {
'task': 'recognition',
'sub_task': 'natural_scene_recognition',
'scenario': 'multi_scene_ocr_document_text_CORD_100',
'image_paths': [],
}
assert score_sample('TOTAL 30', 'TOTAL 30', metadata) == pytest.approx(1.0)
metadata = {'task': 'grounding', 'sub_task': 'text_grounding', 'scenario': 'x', 'image_paths': []}
assert score_sample('(1, 1, 2, 2)', '[1, 1, 2, 2]', metadata) == 1.0
def test_index_images_handles_single_files_and_page_directories() -> None:
with tempfile.TemporaryDirectory() as root:
open(os.path.join(root, 'aaa.jpg'), 'wb').close()
open(os.path.join(root, 'notes.txt'), 'wb').close()
pages = os.path.join(root, 'bbb')
os.makedirs(pages)
for name in ('page_10.jpg', 'page_2.jpg', 'page_1.jpg'):
open(os.path.join(pages, name), 'wb').close()
index = _index_images(root)
assert index['aaa'] == [os.path.join(root, 'aaa.jpg')]
# Multi-page documents keep natural page order, not lexicographic order
assert [os.path.basename(path) for path in index['bbb']] == ['page_1.jpg', 'page_2.jpg', 'page_10.jpg']
assert 'notes' not in index
assert _index_images(os.path.join(root, 'missing')) == {}