@wendellresearch-ai/benchwarmers 0.3.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -1
- package/USAGE.md +8 -1
- package/VERSION +1 -1
- package/cli.py +4 -1
- package/metrics/aliases.py +24 -3
- package/metrics/endpoints.py +36 -4
- package/metrics/model_roles.py +3 -1
- package/metrics/route.py +3 -2
- package/metrics/wrong.py +3 -1
- package/package.json +1 -1
- package/scripts/check.py +2 -0
- package/scripts/onboard.py +12 -1
- package/scripts/test_no_owui.py +5 -2
- package/scripts/test_serve_edit.py +4 -4
- package/scripts/test_server_json.py +22 -2
- package/serve.py +286 -29
- package/suite/README.md +33 -0
- package/suite/pace.py +8 -4
- package/suite/run.py +144 -16
- package/suite/tasks/vision-bars/fixtures/bars.png +0 -0
- package/suite/tasks/vision-bars/task.json +40 -0
- package/suite/tasks/vision-invoice/fixtures/invoice.png +0 -0
- package/suite/tasks/vision-invoice/task.json +40 -0
- package/suite/tasks/vision-label/fixtures/label.png +0 -0
- package/suite/tasks/vision-label/task.json +52 -0
- package/suite/tasks/vision-receipt/fixtures/receipt.png +0 -0
- package/suite/tasks/vision-receipt/task.json +44 -0
- package/suite/tasks/vision-screen/fixtures/screen.png +0 -0
- package/suite/tasks/vision-screen/task.json +48 -0
- package/suite/tasks/vision-shapes/fixtures/shapes.png +0 -0
- package/suite/tasks/vision-shapes/task.json +40 -0
- package/suite/tasks/vision-table/fixtures/table.png +0 -0
- package/suite/tasks/vision-table/task.json +40 -0
- package/suite/test_vision_tasks.py +201 -0
- package/suite/vision_keys.py +74 -0
- package/suite/vision_sample.py +232 -0
- package/suite/vision_tasks.py +338 -0
- package/ui/bench.js +87 -10
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""The vision tasks: each answer key takes a right answer whole and catches a
|
|
2
|
+
guess on its trap; a vision task goes only to a model that takes images, a
|
|
3
|
+
text task only to one that is not images only; the shipped tasks reach the
|
|
4
|
+
data folder without touching its saved answers.
|
|
5
|
+
|
|
6
|
+
python suite/test_vision_tasks.py
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import pathlib
|
|
13
|
+
import sys
|
|
14
|
+
import tempfile
|
|
15
|
+
|
|
16
|
+
HERE = pathlib.Path(__file__).resolve().parent
|
|
17
|
+
sys.path.insert(0, str(HERE))
|
|
18
|
+
import run # noqa: E402
|
|
19
|
+
|
|
20
|
+
RIGHT = {
|
|
21
|
+
"vision-receipt": "store: Harbor Lane Grocery\ndate: 2026-03-14\nitems: 4\npriciest: Cheddar 200g\n"
|
|
22
|
+
"total: $22.17\ntip: not shown",
|
|
23
|
+
"vision-label": "**grader:** Northline Grading\ngrade: MINT 9\nyear: 2019\nnumber: #148\nplayer: Maren Okafor\n"
|
|
24
|
+
"cert: 40731958\nsurface: 8.5\nautograph: no",
|
|
25
|
+
"vision-bars": "tallest: Billing\nshortest: Data\nweb: 35\nover_30: 2\ntarget: not shown",
|
|
26
|
+
"vision-table": "south_q3: 1,120\nwest_q1: 870\ncentral_q3: 1098\nq4_highest: East\nnortheast_q2: not shown",
|
|
27
|
+
"vision-screen": "toggles_on: Back up photos, Notify on conflicts\nconflict: Keep both\ndisabled: Sync now\n"
|
|
28
|
+
"error_code: E-213\naccount: not shown",
|
|
29
|
+
"vision-shapes": "- blue_circles: 6\n- red_squares: 3\n- green_triangles: 2\n- top_right: yellow star\n"
|
|
30
|
+
"- purple_shapes: 0",
|
|
31
|
+
"vision-invoice": "invoice: INV-2026-0417\ndue: 2026-05-02\namount_due: EUR 1,284.50\nbill_to: Kestrel Labs BV\n"
|
|
32
|
+
"po_number: not shown",
|
|
33
|
+
}
|
|
34
|
+
# Each one guesses the trap, and gets one field wrong besides.
|
|
35
|
+
GUESS = {
|
|
36
|
+
"vision-receipt": RIGHT["vision-receipt"].replace("tip: not shown", "tip: $3.00").replace("items: 4", "items: 5"),
|
|
37
|
+
"vision-label": RIGHT["vision-label"].replace("autograph: no", "autograph: yes").replace("40731958", "40781958"),
|
|
38
|
+
"vision-bars": RIGHT["vision-bars"].replace("target: not shown", "target: 40").replace("over_30: 2", "over_30: 3"),
|
|
39
|
+
"vision-table": RIGHT["vision-table"].replace("northeast_q2: not shown", "northeast_q2: 1,210")
|
|
40
|
+
.replace("central_q3: 1098", "central_q3: 1160"),
|
|
41
|
+
"vision-screen": RIGHT["vision-screen"].replace("account: not shown", "account: sam@example.com")
|
|
42
|
+
.replace("Notify on conflicts", "Notify on conflicts, Sync on mobile data"),
|
|
43
|
+
"vision-shapes": RIGHT["vision-shapes"].replace("purple_shapes: 0", "purple_shapes: 1")
|
|
44
|
+
.replace("blue_circles: 6", "blue_circles: 5"),
|
|
45
|
+
"vision-invoice": RIGHT["vision-invoice"].replace("po_number: not shown", "po_number: 7781")
|
|
46
|
+
.replace("due: 2026-05-02", "due: 2026-04-02"),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def main() -> None:
|
|
51
|
+
specs = {}
|
|
52
|
+
for path in sorted((HERE / "tasks").glob("vision-*/task.json")):
|
|
53
|
+
spec = json.loads(path.read_text(encoding="utf-8"))
|
|
54
|
+
spec["dir"] = path.parent
|
|
55
|
+
specs[spec["id"]] = spec
|
|
56
|
+
assert set(specs) == set(RIGHT), sorted(specs)
|
|
57
|
+
for tid, spec in specs.items():
|
|
58
|
+
assert spec["shape"] == "vision" and spec["images"], tid
|
|
59
|
+
assert all((spec["dir"] / "fixtures" / n).exists() for n in spec["images"]), tid
|
|
60
|
+
good = run.score(spec, RIGHT[tid])
|
|
61
|
+
assert good["kind"] == "answered" and good["found"] == good["of"] and not good["invented"], (tid, good)
|
|
62
|
+
bad = run.score(spec, GUESS[tid])
|
|
63
|
+
assert bad["found"] < bad["of"] and bad["invented"], (tid, bad)
|
|
64
|
+
assert run.image_urls(spec)[0].startswith("data:image/png;base64,"), tid
|
|
65
|
+
|
|
66
|
+
# Who each task is for: pictures to a model that takes them, text to one that is not images only.
|
|
67
|
+
text, pic = {"id": "t"}, {"id": "v", "images": ["x.png"]}
|
|
68
|
+
skip, seeing = {"eyes-only"}, {"eyes-only": True, "general-sees": True, "general-blind": False}
|
|
69
|
+
assert run.applies(text, "general-sees", skip, seeing) and run.applies(pic, "general-sees", skip, seeing)
|
|
70
|
+
assert run.applies(text, "general-blind", skip, seeing) and not run.applies(pic, "general-blind", skip, seeing)
|
|
71
|
+
assert not run.applies(text, "eyes-only", skip, seeing) and run.applies(pic, "eyes-only", skip, seeing)
|
|
72
|
+
|
|
73
|
+
# The shipped tasks reach the data folder; its saved answers stay.
|
|
74
|
+
kept = run.TASKS, run.SETTINGS
|
|
75
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
76
|
+
run.TASKS = pathlib.Path(tmp)
|
|
77
|
+
run.SETTINGS = pathlib.Path(tmp) / "suite.json"
|
|
78
|
+
try:
|
|
79
|
+
# A hold keeps them out of every run until its day; --task still runs one.
|
|
80
|
+
run.SETTINGS.write_text(json.dumps({"vision_from": "2999-01-01"}), encoding="utf-8")
|
|
81
|
+
assert run.vision_held() == "2999-01-01"
|
|
82
|
+
assert not [t for t in run.load_tasks(None) if t.get("images")]
|
|
83
|
+
assert run.load_tasks("vision-bars")[0]["images"]
|
|
84
|
+
run.SETTINGS.write_text(json.dumps({"vision_from": "2000-01-01"}), encoding="utf-8")
|
|
85
|
+
assert run.vision_held() == "" and len([t for t in run.load_tasks(None) if t.get("images")]) == len(RIGHT)
|
|
86
|
+
saved = run.TASKS / "vision-bars" / "output" / "m.md"
|
|
87
|
+
saved.parent.mkdir(parents=True)
|
|
88
|
+
saved.write_text("an answer", encoding="utf-8")
|
|
89
|
+
(run.TASKS / "vision-bars" / "task.json").write_text("{}", encoding="utf-8") # an old key
|
|
90
|
+
run.seed_shipped()
|
|
91
|
+
for tid in RIGHT:
|
|
92
|
+
assert (run.TASKS / tid / "task.json").read_bytes() == (HERE / "tasks" / tid / "task.json").read_bytes()
|
|
93
|
+
assert saved.read_text(encoding="utf-8") == "an answer"
|
|
94
|
+
finally:
|
|
95
|
+
run.TASKS, run.SETTINGS = kept
|
|
96
|
+
panel()
|
|
97
|
+
sample()
|
|
98
|
+
print("ok")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def sample() -> None:
|
|
102
|
+
"""vision_sample.py builds each source's key from rows shaped as datasets-server
|
|
103
|
+
returns them (no network: rows and pictures stand in)."""
|
|
104
|
+
import vision_sample as vs
|
|
105
|
+
kept = vs.rows, vs.image
|
|
106
|
+
receipt = {"menu": [{"nm": "EGG TART", "cnt": "1", "price": "13,000"}, {"nm": "TEA", "cnt": "2", "price": "8,000"}],
|
|
107
|
+
"total": {"total_price": "21.000", "cashprice": "50.000", "changeprice": "29.000"}}
|
|
108
|
+
data = {
|
|
109
|
+
"naver-clova-ix/cord-v2": [{"ground_truth": json.dumps({"gt_parse": receipt})},
|
|
110
|
+
{"ground_truth": json.dumps({"gt_parse": {**receipt, "total": {
|
|
111
|
+
**receipt["total"], "creditcardprice": "21.000"}}})}], # paid by card: skipped
|
|
112
|
+
"bevaya/RICO-ScreenQA": [
|
|
113
|
+
{"screen_id": "7", "question": "What is the version?",
|
|
114
|
+
"ground_truth": [{"full_answer": "It is 1.69", "ui_elements": [{"text": "1.69"}]}]},
|
|
115
|
+
{"screen_id": "7", "question": "Is GPS on?", "ground_truth": [{"full_answer": "on", "ui_elements": [{"text": "ON"}]}]},
|
|
116
|
+
{"screen_id": "7", "question": "What is the lawn type?", "ground_truth": [{"full_answer": "<no answer>", "ui_elements": []}]}],
|
|
117
|
+
"lmms-lab-encoder/POPE": [{"image_source": "COCO_val2014_000000000042", "question": f"Is there a {t} in the image?",
|
|
118
|
+
"answer": a} for t, a in (("dog", "yes"), ("cat", "no"), ("ball", "yes"), ("car", "no"))],
|
|
119
|
+
}
|
|
120
|
+
vs.rows = lambda dataset, config, split, offset, length: data.get(dataset, [])
|
|
121
|
+
vs.image = lambda row, field="image": (b"\xff\xd8", ".jpg")
|
|
122
|
+
try:
|
|
123
|
+
(c, _, _), = list(vs.cord(0, 10))
|
|
124
|
+
(s, _, _), = list(vs.screenqa(0, 10))
|
|
125
|
+
(p, _, _), = list(vs.pope(0, 10))
|
|
126
|
+
finally:
|
|
127
|
+
vs.rows, vs.image = kept
|
|
128
|
+
for spec, right, wrong in (
|
|
129
|
+
(c, "total: Rp 21.000\ncash: 50,000\nchange: 29000\nitems: 2\ncard: not shown", "card: 4417"),
|
|
130
|
+
(s, "\n".join(f"q{n}: {a}" for n, a in enumerate(
|
|
131
|
+
["not shown" if "lawn" in ln else "1.69" if "version" in ln else "on"
|
|
132
|
+
for ln in s["prompt"].splitlines() if ln.startswith("q")], 1)), None),
|
|
133
|
+
(p, "\n".join(f"q{n}: {'yes' if ('dog' in ln or 'ball' in ln) else 'no'}"
|
|
134
|
+
for n, ln in enumerate((ln for ln in p["prompt"].splitlines() if ln.startswith("q")), 1)), None)):
|
|
135
|
+
got = run.score(spec, right)
|
|
136
|
+
assert got["found"] == got["of"] and not got["invented"], (spec["id"], got, right)
|
|
137
|
+
assert run.score(c, "total: 21.000\ncard: 4417")["invented"], c
|
|
138
|
+
yes = "\n".join(f"q{n}: yes" for n in range(1, 5))
|
|
139
|
+
assert len(run.score(p, yes)["invented"]) == 2 # a yes to each absent thing
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def panel() -> None:
|
|
143
|
+
"""serve.vision_rows: the tasks field by field, and real work by how it was judged."""
|
|
144
|
+
sys.path.insert(0, str(HERE.parent))
|
|
145
|
+
import offload
|
|
146
|
+
import serve
|
|
147
|
+
kept = serve.DATA, serve.on_bench, offload.LOG, serve._vision_work
|
|
148
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
149
|
+
data = pathlib.Path(tmp)
|
|
150
|
+
for tid in ("vision-bars", "vision-shapes"):
|
|
151
|
+
out = data / "suite" / "tasks" / tid / "output"
|
|
152
|
+
out.mkdir(parents=True)
|
|
153
|
+
(out.parent / "task.json").write_bytes((HERE / "tasks" / tid / "task.json").read_bytes())
|
|
154
|
+
(out / "sharp.md").write_text(RIGHT[tid], encoding="utf-8")
|
|
155
|
+
(out / "guesser.md").write_text(GUESS[tid], encoding="utf-8")
|
|
156
|
+
(out / "gone.md").write_text(RIGHT[tid], encoding="utf-8") # not on the bench now
|
|
157
|
+
(data / "suite" / "tasks" / "vision-bars" / "output" / "down.md").write_text(
|
|
158
|
+
"Error: Error code: 503 - service unavailable", encoding="utf-8")
|
|
159
|
+
seats = [{"call": "p1.m1", "alias": "sharp", "status": "ok", "answer": "x", "seconds": 4},
|
|
160
|
+
{"call": "p1.m2", "alias": "guesser", "status": "ok", "answer": "x", "seconds": 9},
|
|
161
|
+
{"call": "p2.m1", "alias": "sharp", "status": "ok", "answer": "x", "seconds": 6},
|
|
162
|
+
{"call": "p2.m2", "alias": "guesser", "status": "error", "answer": "", "seconds": 1},
|
|
163
|
+
{"call": "p3.m1", "alias": "sharp", "status": "ok", "answer": "x", "seconds": 5}]
|
|
164
|
+
(data / "runs" / "gateway").mkdir(parents=True)
|
|
165
|
+
(data / "runs" / "gateway" / "calls.jsonl").write_text(
|
|
166
|
+
json.dumps({"shape": "vision", "seats": seats[:2]}) + "\n" + json.dumps({"shape": "vision", "seats": seats[2:4]})
|
|
167
|
+
+ "\n" + json.dumps({"shape": "general", "seats": [dict(seats[0], call="p9.m1")]})
|
|
168
|
+
+ "\n" + json.dumps({"shape": "vision", "seats": seats[4:]}) + "\n", encoding="utf-8")
|
|
169
|
+
(data / "metrics").mkdir()
|
|
170
|
+
log = data / "metrics" / "offloads.jsonl"
|
|
171
|
+
log.write_text("\n".join(json.dumps(e) for e in (
|
|
172
|
+
{"date": "2026-10-27", "model": "sharp", "task": "t", "call": "p1.m1", "useful": True,
|
|
173
|
+
"real": ["grade=9", "cert=1"], "false": []},
|
|
174
|
+
{"date": "2026-10-27", "model": "guesser", "task": "t", "call": "p1.m2", "useful": False,
|
|
175
|
+
"real": ["grade=9"], "false": ["cert=2"]},
|
|
176
|
+
{"date": "2026-10-27", "model": "sharp", "task": "t", "call": "p2.m1", "useful": True,
|
|
177
|
+
"note": "agreed with another seat"})) + "\n", encoding="utf-8")
|
|
178
|
+
serve.DATA, offload.LOG, serve._vision_work = data, log, (None, {})
|
|
179
|
+
serve.on_bench = lambda: {"sharp", "guesser", "down"}
|
|
180
|
+
try:
|
|
181
|
+
v = serve.vision_rows()
|
|
182
|
+
finally:
|
|
183
|
+
serve.DATA, serve.on_bench, offload.LOG, serve._vision_work = kept
|
|
184
|
+
s = v["summary"]
|
|
185
|
+
assert set(s) == {"sharp", "guesser", "down"}, s # "gone" is not on the bench
|
|
186
|
+
assert s["sharp"] == {"asked": 2, "answered": 2, "fields": 10, "right": 10, "traps": 2, "taken": 0}, s["sharp"]
|
|
187
|
+
assert s["guesser"]["right"] == 6 and s["guesser"]["taken"] == 2, s["guesser"]
|
|
188
|
+
assert s["down"] == {"asked": 1, "answered": 0, "fields": 0, "right": 0, "traps": 0, "taken": 0}, s["down"]
|
|
189
|
+
states = {(h["model"], h["task"], h["field"]): h["state"] for h in v["heat"]}
|
|
190
|
+
assert states[("guesser", "vision-bars", "over_30")] == "misread"
|
|
191
|
+
assert states[("guesser", "vision-bars", "target")] == "trap taken"
|
|
192
|
+
assert states[("down", "vision-bars", "web")] == "no answer"
|
|
193
|
+
w = v["work"]
|
|
194
|
+
assert w["sharp"]["checked, helped"] == 1 and w["sharp"]["useful, no claims"] == 1 and w["sharp"]["not judged"] == 1, w
|
|
195
|
+
assert (w["sharp"]["real"], w["sharp"]["false"], w["sharp"]["seconds"]) == (2, 0, 5.0), w["sharp"]
|
|
196
|
+
assert w["guesser"]["checked, did not help"] == 1 and w["guesser"]["no answer"] == 1, w["guesser"]
|
|
197
|
+
assert (w["guesser"]["real"], w["guesser"]["false"]) == (1, 1), w["guesser"]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
if __name__ == "__main__":
|
|
201
|
+
main()
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""How every vision task asks and is scored: answers as `key: value` lines,
|
|
2
|
+
a key per field, `not shown` for what the picture does not show.
|
|
3
|
+
|
|
4
|
+
Shared by suite/vision_tasks.py (the made pictures), suite/vision_sample.py
|
|
5
|
+
(items from public datasets) and any task written by hand, so one answer
|
|
6
|
+
style is scored one way. Standard library only.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
NOT_SHOWN = (r"(not shown|not visible|not present|not listed|not given|not stated|not on the|not in the"
|
|
14
|
+
r"|not provided|not included|none shown|n/?a\b|none\b|no such|does not show|doesn't show"
|
|
15
|
+
r"|cannot be (?:determined|answered|seen)|can't be (?:determined|answered|seen)|unanswerable)")
|
|
16
|
+
PROMPT = ("Read the attached image and answer in exactly these lines, one per line, nothing else:\n\n{lines}\n\n"
|
|
17
|
+
"If the image does not show something, write `not shown` for it. Do not guess.")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def key(name: str, value: str) -> str:
|
|
21
|
+
"""A regex for the line `name: value`, markdown around the key allowed."""
|
|
22
|
+
k = re.escape(name).replace("_", "[ _]")
|
|
23
|
+
return rf"(?m)^[^\w\n]*{k}[^\w\n]*:[ \t*_`\"']*{value}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def clause(name: str, value: str, label: str) -> dict:
|
|
27
|
+
return {"regex": key(name, value), "label": label}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def not_shown(name: str) -> tuple[dict, dict]:
|
|
31
|
+
"""(must_find, must_not_claim) for a field the picture does not answer:
|
|
32
|
+
`not shown` is right, any other value an invention."""
|
|
33
|
+
return (clause(name, NOT_SHOWN, f"{name}: not shown"),
|
|
34
|
+
clause(name, rf"(?!{NOT_SHOWN}|unknown|unclear)\w", f"{name}: a value (the picture does not answer it)"))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def number(text: str) -> str:
|
|
38
|
+
"""A regex for a printed amount however its thousands are separated:
|
|
39
|
+
"45,500" also matches 45.500, 45 500 and 45500; a currency word or sign
|
|
40
|
+
before it is allowed."""
|
|
41
|
+
digits = re.sub(r"[^\d.,]", "", text)
|
|
42
|
+
m = re.fullmatch(r"(.*?)[.,](\d{1,2})", digits) # one or two digits after the last mark: cents
|
|
43
|
+
whole, cents = (m.group(1), m.group(2)) if m else (digits, None)
|
|
44
|
+
groups = [g for g in re.split(r"[.,]", whole) if g]
|
|
45
|
+
body = r"[.,\s]?".join(groups)
|
|
46
|
+
if cents:
|
|
47
|
+
body += rf"[.,]{cents}"
|
|
48
|
+
return rf"(?:[a-z]{{1,3}}\.?\s*|[$€£¥]\s*)?{body}(?!\d)"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def phrase(text: str) -> str:
|
|
52
|
+
"""A regex for a short answer anywhere on its line, spacing and case loose."""
|
|
53
|
+
words = [re.escape(w) for w in text.lower().split()]
|
|
54
|
+
return r".*" + r"\s+".join(words)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _selftest() -> None:
|
|
58
|
+
n = number("45,500")
|
|
59
|
+
for ok in ("45,500", "45.500", "45 500", "45500", "rp 45.500", "Rp. 45,500"):
|
|
60
|
+
assert re.fullmatch(n, ok.lower()), (ok, n)
|
|
61
|
+
for bad in ("45,5000", "455,00"):
|
|
62
|
+
assert not re.fullmatch(n, bad), (bad, n)
|
|
63
|
+
assert re.fullmatch(number("3.14%").removesuffix("(?!\\d)"), "3.14"), number("3.14%")
|
|
64
|
+
assert re.fullmatch(number("$1,284.50"), "eur 1,284.50") and re.fullmatch(number("23.000"), "23000")
|
|
65
|
+
find, claim = not_shown("q3")
|
|
66
|
+
# Answers arrive lowercased (run.score).
|
|
67
|
+
assert re.search(find["regex"], "q3: not shown") and not re.search(claim["regex"], "q3: not shown")
|
|
68
|
+
assert re.search(claim["regex"], "q3: 12 questions") and re.search(find["regex"], "**q3**: cannot be determined")
|
|
69
|
+
assert re.search(key("q1", phrase("Unauthorized transactions")), "q1: from unauthorized transactions.")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if __name__ == "__main__":
|
|
73
|
+
_selftest()
|
|
74
|
+
print("ok")
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Vision tasks sampled from public datasets, each item's answer verified by people.
|
|
2
|
+
|
|
3
|
+
python suite/vision_sample.py --count 5 # five of each source
|
|
4
|
+
python suite/vision_sample.py --source cord --count 3 # one source
|
|
5
|
+
python suite/vision_sample.py --dry-run # what would be written
|
|
6
|
+
|
|
7
|
+
The made pictures (suite/vision_tasks.py) are clean renders: exact keys, never
|
|
8
|
+
seen in training, but no blur, glare or crumpled paper. These are the real
|
|
9
|
+
thing, read through Hugging Face's datasets-server like suite/rebench.py, no
|
|
10
|
+
`datasets` package:
|
|
11
|
+
|
|
12
|
+
cord naver-clova-ix/cord-v2 (CC BY 4.0): photographed receipts with
|
|
13
|
+
their totals, cash and change as annotated. Cash receipts only, so
|
|
14
|
+
a card number is a field the picture does not show.
|
|
15
|
+
screenqa bevaya/RICO-ScreenQA (CC BY 4.0): app screenshots, two questions
|
|
16
|
+
people answered from the screen and one they marked `<no answer>`.
|
|
17
|
+
tablevqa terryoo/TableVQA-Bench (CC BY 4.0): tables from financial reports
|
|
18
|
+
and Wikipedia as images, a short answer each.
|
|
19
|
+
pope lmms-lab-encoder/POPE (no licence on its card; COCO photos): "is
|
|
20
|
+
there a <thing> in the image?", two present and two absent, the
|
|
21
|
+
absent ones chosen to be likely (its adversarial split). A yes to
|
|
22
|
+
an absent thing is the invention.
|
|
23
|
+
|
|
24
|
+
Written to the data folder, never the repository: each install samples its
|
|
25
|
+
own, so nothing of theirs is redistributed. Written ready to run, not as
|
|
26
|
+
drafts: the answers are the datasets' own, checked by people. Look at a few
|
|
27
|
+
anyway (`--dry-run` prints each key); a source's quirks show in its first items.
|
|
28
|
+
|
|
29
|
+
POPE's photos are often in training sets (COCO is everywhere); the receipts,
|
|
30
|
+
screens and tables less so, and the made pictures and your own photos not
|
|
31
|
+
at all. The Vision panel shows each task, so a source that is too easy shows.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import argparse
|
|
37
|
+
import hashlib
|
|
38
|
+
import json
|
|
39
|
+
import pathlib
|
|
40
|
+
import re
|
|
41
|
+
import sys
|
|
42
|
+
import urllib.parse
|
|
43
|
+
import urllib.request
|
|
44
|
+
|
|
45
|
+
HERE = pathlib.Path(__file__).resolve().parent
|
|
46
|
+
sys.path.insert(0, str(HERE))
|
|
47
|
+
from rebench import api # noqa: E402 -- datasets-server JSON, retrying its busy 500s
|
|
48
|
+
from vision_keys import NOT_SHOWN, PROMPT, clause, key, not_shown, number, phrase # noqa: E402
|
|
49
|
+
|
|
50
|
+
sys.path.insert(0, str(HERE.parent / "metrics"))
|
|
51
|
+
from paths import DATA # noqa: E402
|
|
52
|
+
TASKS = DATA / "suite" / "tasks"
|
|
53
|
+
PAGE = 100 # datasets-server's largest page
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def rows(dataset: str, config: str, split: str, offset: int, length: int) -> list[dict]:
|
|
57
|
+
out = []
|
|
58
|
+
while len(out) < length:
|
|
59
|
+
page = api("rows", dataset=dataset, config=config, split=split, offset=offset + len(out),
|
|
60
|
+
length=min(PAGE, length - len(out)))
|
|
61
|
+
got = [r["row"] for r in page.get("rows", [])]
|
|
62
|
+
out += got
|
|
63
|
+
if len(got) < PAGE:
|
|
64
|
+
break
|
|
65
|
+
return out
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def image(row: dict, field: str = "image") -> tuple[bytes, str]:
|
|
69
|
+
"""The row's picture, fetched now: datasets-server's links expire."""
|
|
70
|
+
src = row[field]["src"]
|
|
71
|
+
req = urllib.request.Request(src, headers={"User-Agent": "benchwarmers"})
|
|
72
|
+
with urllib.request.urlopen(req, timeout=90) as r:
|
|
73
|
+
data = r.read()
|
|
74
|
+
ext = pathlib.PurePosixPath(urllib.parse.urlparse(src).path).suffix.lower() or ".jpg"
|
|
75
|
+
return data, ext
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def alternatives(name: str, answers: list[str], label: str) -> dict:
|
|
79
|
+
"""Any of several accepted answers, anywhere on the field's line."""
|
|
80
|
+
alts = "|".join(phrase(a)[2:] for a in answers)
|
|
81
|
+
return clause(name, rf".*(?:{alts})", label)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def spec(task_id: str, title: str, source: str, dataset: str, row_ref: str, licence: str,
|
|
85
|
+
lines: list[str], find: list[dict], not_claim: list[dict], why: str) -> dict:
|
|
86
|
+
return {"id": task_id, "title": title, "shape": "vision", "prompt": PROMPT.format(lines="\n".join(lines)),
|
|
87
|
+
"min_answer_chars": 8, "must_find": find, "must_not_claim": not_claim, "why": why,
|
|
88
|
+
"source": f"{dataset} {row_ref}", "source_url": f"https://huggingface.co/datasets/{dataset}",
|
|
89
|
+
"license": licence, "made_by": f"suite/vision_sample.py --source {source}"}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---- the sources: each yields (spec without images, picture bytes, extension) ----
|
|
93
|
+
|
|
94
|
+
def cord(offset: int, scan: int):
|
|
95
|
+
"""Cash receipts: total, cash given, change, how many menu lines."""
|
|
96
|
+
ds = "naver-clova-ix/cord-v2"
|
|
97
|
+
for i, r in enumerate(rows(ds, "default", "test", offset, scan)):
|
|
98
|
+
gt = json.loads(r["ground_truth"]).get("gt_parse", {})
|
|
99
|
+
menu, total = gt.get("menu"), gt.get("total") or {}
|
|
100
|
+
menu = [menu] if isinstance(menu, dict) else menu or []
|
|
101
|
+
paid = {k: total.get(k) for k in ("total_price", "cashprice", "changeprice")}
|
|
102
|
+
if (not all(isinstance(v, str) and re.search(r"\d", v) for v in paid.values())
|
|
103
|
+
or any(k in total for k in ("creditcardprice", "emoneyprice"))
|
|
104
|
+
or not 1 <= len(menu) <= 8 or any("sub" in m for m in menu)):
|
|
105
|
+
continue
|
|
106
|
+
find = [clause("total", number(paid["total_price"]), f"total: {paid['total_price']}"),
|
|
107
|
+
clause("cash", number(paid["cashprice"]), f"cash: {paid['cashprice']}"),
|
|
108
|
+
clause("change", number(paid["changeprice"]), f"change: {paid['changeprice']}"),
|
|
109
|
+
clause("items", rf"{len(menu)}\b", f"items: {len(menu)}")]
|
|
110
|
+
trap_find, trap_claim = not_shown("card")
|
|
111
|
+
yield (spec(f"vision-cord-{offset + i}", "Read a photographed receipt", "cord", ds, f"test row {offset + i}",
|
|
112
|
+
"CC BY 4.0",
|
|
113
|
+
["total: <the total to pay>", "cash: <cash given>", "change: <change given back>",
|
|
114
|
+
"items: <how many different items are listed>", "card: <the last four digits of the card used>"],
|
|
115
|
+
find + [trap_find], [trap_claim],
|
|
116
|
+
"A receipt as photographed, from CORD v2 (Park et al., 2019). Paid in cash, so no card is"
|
|
117
|
+
" shown. Items: " + "; ".join(f"{m.get('nm', '?')} x{m.get('cnt', '?')}" for m in menu)),
|
|
118
|
+
*image(r))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def screenqa(offset: int, scan: int):
|
|
122
|
+
"""App screens: two questions answered from the screen, one it cannot answer."""
|
|
123
|
+
ds = "bevaya/RICO-ScreenQA"
|
|
124
|
+
screens: dict[str, list[dict]] = {}
|
|
125
|
+
for r in rows(ds, "default", "test", offset, scan):
|
|
126
|
+
screens.setdefault(r["screen_id"], []).append(r)
|
|
127
|
+
for sid, qs in screens.items():
|
|
128
|
+
def short(q):
|
|
129
|
+
return sorted({g["ui_elements"][0]["text"].strip() for g in q["ground_truth"] if g["ui_elements"]})
|
|
130
|
+
answered = [q for q in qs if 1 <= len(short(q)) <= 2 and all(0 < len(a) <= 40 for a in short(q))]
|
|
131
|
+
blank = [q for q in qs if all(g["full_answer"] == "<no answer>" for g in q["ground_truth"])]
|
|
132
|
+
if len(answered) < 2 or not blank:
|
|
133
|
+
continue
|
|
134
|
+
picked = answered[:2]
|
|
135
|
+
at = int(hashlib.sha1(sid.encode()).hexdigest(), 16) % 3 # where the unanswerable one sits
|
|
136
|
+
picked.insert(at, blank[0])
|
|
137
|
+
lines, find, claim = [], [], []
|
|
138
|
+
for n, q in enumerate(picked, 1):
|
|
139
|
+
name = f"q{n}"
|
|
140
|
+
lines.append(f"{name}: <answer to: {q['question']}>")
|
|
141
|
+
if q is blank[0]:
|
|
142
|
+
f, c = not_shown(name)
|
|
143
|
+
find.append(f)
|
|
144
|
+
claim.append(c)
|
|
145
|
+
else:
|
|
146
|
+
find.append(alternatives(name, short(q), f"{name}: {' or '.join(short(q))}"))
|
|
147
|
+
yield (spec(f"vision-screenqa-{sid}", "Answer questions about an app screen", "screenqa", ds,
|
|
148
|
+
f"screen {sid}", "CC BY 4.0", lines, find, claim,
|
|
149
|
+
"A RICO app screenshot with questions from ScreenQA (Hsiao et al., 2022); people marked one"
|
|
150
|
+
" as having no answer on this screen."),
|
|
151
|
+
*image(picked[0]))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def tablevqa(offset: int, scan: int):
|
|
155
|
+
"""Tables as images: one question, one short answer."""
|
|
156
|
+
ds = "terryoo/TableVQA-Bench"
|
|
157
|
+
splits = ("fintabnetqa", "vwtq")
|
|
158
|
+
pages = [list(enumerate(rows(ds, "default", s, offset, scan // 2))) for s in splits]
|
|
159
|
+
# The two kinds in turn: a report's table, then a Wikipedia one.
|
|
160
|
+
for (i, r), split in ((item, s) for pair in zip(*pages) for item, s in zip(pair, splits)):
|
|
161
|
+
gt = (r.get("gt") or "").strip().rstrip(".")
|
|
162
|
+
if not gt or len(gt) > 24 or "," in gt and not re.fullmatch(r"[\d,.$%()-]+", gt):
|
|
163
|
+
continue
|
|
164
|
+
value = number(gt) if re.fullmatch(r"[\d,.$%()\s-]+", gt) and re.search(r"\d", gt) else phrase(gt)[2:]
|
|
165
|
+
yield (spec(f"vision-tablevqa-{split}-{offset + i}", "Read a table", "tablevqa", ds,
|
|
166
|
+
f"{split} row {offset + i}", "CC BY 4.0", [f"answer: <answer to: {r['question']}>"],
|
|
167
|
+
# Anywhere on the line, but whole: 3.14 is not the end of 13.14.
|
|
168
|
+
[clause("answer", rf".*(?<![\w.,]){value}", f"answer: {gt}")], [],
|
|
169
|
+
f"A table from {'a financial report (FinTabNet)' if split == 'fintabnetqa' else 'Wikipedia (WTQ)'},"
|
|
170
|
+
" via TableVQA-Bench (Kim et al., 2024)."),
|
|
171
|
+
*image(r))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def pope(offset: int, scan: int):
|
|
175
|
+
"""Photos: is there a <thing>? Two present, two absent but likely."""
|
|
176
|
+
ds = "lmms-lab-encoder/POPE"
|
|
177
|
+
photos: dict[str, list[dict]] = {}
|
|
178
|
+
for r in rows(ds, "Full", "adversarial", offset, scan):
|
|
179
|
+
photos.setdefault(r["image_source"], []).append(r)
|
|
180
|
+
for src, qs in photos.items():
|
|
181
|
+
yes = [q for q in qs if q["answer"].strip().lower() == "yes"][:2]
|
|
182
|
+
no = [q for q in qs if q["answer"].strip().lower() == "no"][:2]
|
|
183
|
+
if len(yes) < 2 or len(no) < 2:
|
|
184
|
+
continue
|
|
185
|
+
order = [yes[0], no[0], no[1], yes[1]] if int(hashlib.sha1(src.encode()).hexdigest(), 16) % 2 else \
|
|
186
|
+
[no[0], yes[0], yes[1], no[1]]
|
|
187
|
+
lines, find, claim = [], [], []
|
|
188
|
+
for n, q in enumerate(order, 1):
|
|
189
|
+
name, thing = f"q{n}", re.sub(r"^is there an? |\s*in the image\?$", "", q["question"].lower())
|
|
190
|
+
lines.append(f"{name}: <yes or no: {q['question']}>")
|
|
191
|
+
find.append(clause(name, rf"{q['answer'].lower()}\b", f"{name}: {q['answer'].lower()} ({thing})"))
|
|
192
|
+
if q["answer"].lower() == "no":
|
|
193
|
+
claim.append(clause(name, r"yes\b", f"{name}: yes, a {thing} (there is none)"))
|
|
194
|
+
yield (spec(f"vision-pope-{src.rsplit('_', 1)[-1].lstrip('0')}", "Say which things a photo shows", "pope", ds,
|
|
195
|
+
src, "no licence on the dataset card; COCO photos", lines, find, claim,
|
|
196
|
+
f"COCO photo {src}, questions from POPE's adversarial split (Li et al., 2023): the absent"
|
|
197
|
+
" things are ones that often appear with what is there."),
|
|
198
|
+
*image(qs[0]))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
SOURCES = {"cord": cord, "screenqa": screenqa, "tablevqa": tablevqa, "pope": pope}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def main() -> None:
|
|
205
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
206
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
207
|
+
ap.add_argument("--source", choices=[*SOURCES, "all"], default="all")
|
|
208
|
+
ap.add_argument("--count", type=int, default=5, help="tasks to write per source")
|
|
209
|
+
ap.add_argument("--offset", type=int, default=0, help="start this far into each source's rows")
|
|
210
|
+
ap.add_argument("--scan", type=int, default=300, help="rows to read per source, at most")
|
|
211
|
+
ap.add_argument("--dry-run", action="store_true", help="fetch and print each key, write nothing")
|
|
212
|
+
args = ap.parse_args()
|
|
213
|
+
for name in SOURCES if args.source == "all" else [args.source]:
|
|
214
|
+
made = 0
|
|
215
|
+
for s, picture, ext in SOURCES[name](args.offset, args.scan):
|
|
216
|
+
folder = TASKS / s["id"]
|
|
217
|
+
if folder.exists():
|
|
218
|
+
continue # sampled before: kept, answers and all
|
|
219
|
+
made += 1
|
|
220
|
+
print(f"{s['id']}: " + "; ".join(c["label"] for c in s["must_find"]))
|
|
221
|
+
if not args.dry_run:
|
|
222
|
+
(folder / "fixtures").mkdir(parents=True)
|
|
223
|
+
s["images"] = [f"{s['id'].removeprefix('vision-')}{ext}"]
|
|
224
|
+
(folder / "fixtures" / s["images"][0]).write_bytes(picture)
|
|
225
|
+
(folder / "task.json").write_text(json.dumps(s, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
226
|
+
if made >= args.count:
|
|
227
|
+
break
|
|
228
|
+
print(f"{name}: {made} {'would be ' if args.dry_run else ''}written")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
if __name__ == "__main__":
|
|
232
|
+
main()
|