@wendellresearch-ai/benchwarmers 0.3.2 → 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 +19 -4
- 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,338 @@
|
|
|
1
|
+
"""Make the vision tasks: pictures drawn here, each with its answer key.
|
|
2
|
+
|
|
3
|
+
python suite/vision_tasks.py # (re)writes suite/tasks/vision-*/
|
|
4
|
+
|
|
5
|
+
Needs Pillow, to draw (`pip install pillow`); nothing else does. The suite
|
|
6
|
+
reads the PNGs and task.json files this writes, which are committed, and
|
|
7
|
+
suite/run.py copies them into the data folder (run.seed_shipped).
|
|
8
|
+
|
|
9
|
+
THE KEY EXISTS BY CONSTRUCTION, as with suite/seed.py: every number and name
|
|
10
|
+
in a picture comes from the same data its task's answer key is built from, so
|
|
11
|
+
the two cannot disagree. The pictures are made, not photographed: nobody's
|
|
12
|
+
receipt, card or screen, so they ship with the public repository. What they
|
|
13
|
+
test is reading -- printed text, digits that must be exact, a chart against
|
|
14
|
+
its gridlines, a table cell by row and column, a screen's state, counting,
|
|
15
|
+
a page turned on its side -- the jobs vision seats are given.
|
|
16
|
+
|
|
17
|
+
ONE FIELD IN EACH ASKS FOR SOMETHING THE PICTURE DOES NOT SHOW. The prompt
|
|
18
|
+
says to write `not shown` then; a value is an invention, scored like a trap
|
|
19
|
+
in the text suite. Reading an image confidently wrong is the failure that
|
|
20
|
+
costs, the same as asserting a bug that is not there.
|
|
21
|
+
|
|
22
|
+
Answers are lines, `key: value`. Scoring is the suite's own (run.score):
|
|
23
|
+
regexes over the lowered answer, tolerant of markdown around the key.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import math
|
|
30
|
+
import pathlib
|
|
31
|
+
|
|
32
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
33
|
+
|
|
34
|
+
from vision_keys import NOT_SHOWN, PROMPT, clause
|
|
35
|
+
|
|
36
|
+
HERE = pathlib.Path(__file__).resolve().parent
|
|
37
|
+
OUT = HERE / "tasks"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def font(size: int, kind: str = "sans"):
|
|
41
|
+
names = {"sans": ("arial.ttf", "DejaVuSans.ttf", "LiberationSans-Regular.ttf"),
|
|
42
|
+
"bold": ("arialbd.ttf", "DejaVuSans-Bold.ttf", "LiberationSans-Bold.ttf"),
|
|
43
|
+
"mono": ("consola.ttf", "DejaVuSansMono.ttf", "LiberationMono-Regular.ttf")}[kind]
|
|
44
|
+
for name in names:
|
|
45
|
+
try:
|
|
46
|
+
return ImageFont.truetype(name, size)
|
|
47
|
+
except OSError:
|
|
48
|
+
continue
|
|
49
|
+
return ImageFont.load_default(size)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def write(task_id: str, title: str, image: Image.Image, lines: list[str], find: list[dict],
|
|
53
|
+
not_claim: list[dict], why: str) -> None:
|
|
54
|
+
folder = OUT / task_id
|
|
55
|
+
(folder / "fixtures").mkdir(parents=True, exist_ok=True)
|
|
56
|
+
name = task_id.removeprefix("vision-") + ".png"
|
|
57
|
+
image.save(folder / "fixtures" / name, optimize=True)
|
|
58
|
+
spec = {"id": task_id, "title": title, "shape": "vision", "images": [name],
|
|
59
|
+
"prompt": PROMPT.format(lines="\n".join(lines)), "min_answer_chars": 20,
|
|
60
|
+
"must_find": find, "must_not_claim": not_claim, "why": why,
|
|
61
|
+
"made_by": "suite/vision_tasks.py"}
|
|
62
|
+
(folder / "task.json").write_text(json.dumps(spec, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
63
|
+
print(f"wrote {folder.relative_to(HERE.parent)} ({len(find)} fields)")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---- a receipt: printed text, a date to rewrite, a sum to find ----------
|
|
67
|
+
|
|
68
|
+
def receipt() -> None:
|
|
69
|
+
items = [("Oat milk 1L", 3.49), ("Sourdough loaf", 5.25), ("Blueberries 250g", 4.99), ("Cheddar 200g", 6.80)]
|
|
70
|
+
subtotal = round(sum(p for _, p in items), 2)
|
|
71
|
+
tax = round(subtotal * 0.08, 2)
|
|
72
|
+
total = round(subtotal + tax, 2)
|
|
73
|
+
img = Image.new("RGB", (520, 760), "#f4f1ea")
|
|
74
|
+
d = ImageDraw.Draw(img)
|
|
75
|
+
mono, big = font(22, "mono"), font(30, "bold")
|
|
76
|
+
d.text((260, 50), "HARBOR LANE GROCERY", font=big, fill="#222", anchor="mm")
|
|
77
|
+
d.text((260, 92), "212 Quay Street", font=mono, fill="#333", anchor="mm")
|
|
78
|
+
d.text((260, 122), "Mar 14, 2026 17:42", font=mono, fill="#333", anchor="mm")
|
|
79
|
+
d.line((40, 160, 480, 160), fill="#888", width=2)
|
|
80
|
+
y = 190
|
|
81
|
+
for name, price in items:
|
|
82
|
+
d.text((50, y), name, font=mono, fill="#222")
|
|
83
|
+
d.text((470, y), f"{price:.2f}", font=mono, fill="#222", anchor="ra")
|
|
84
|
+
y += 44
|
|
85
|
+
d.line((40, y + 6, 480, y + 6), fill="#888", width=2)
|
|
86
|
+
y += 30
|
|
87
|
+
for label, value in (("SUBTOTAL", subtotal), ("TAX 8%", tax)):
|
|
88
|
+
d.text((50, y), label, font=mono, fill="#222")
|
|
89
|
+
d.text((470, y), f"{value:.2f}", font=mono, fill="#222", anchor="ra")
|
|
90
|
+
y += 40
|
|
91
|
+
d.text((50, y + 10), "TOTAL", font=big, fill="#111")
|
|
92
|
+
d.text((470, y + 10), f"{total:.2f}", font=big, fill="#111", anchor="ra")
|
|
93
|
+
d.text((260, y + 110), "PAID BY CARD", font=mono, fill="#333", anchor="mm")
|
|
94
|
+
d.text((260, y + 150), "THANK YOU", font=mono, fill="#333", anchor="mm")
|
|
95
|
+
priciest = max(items, key=lambda i: i[1])[0].split()[0].lower()
|
|
96
|
+
write("vision-receipt", "Read a receipt", img,
|
|
97
|
+
["store: <name>", "date: <YYYY-MM-DD>", "items: <how many line items were bought>",
|
|
98
|
+
"priciest: <the most expensive item>", "total: <amount paid>", "tip: <tip amount>"],
|
|
99
|
+
[clause("store", r"harbor lane", "store: Harbor Lane Grocery"),
|
|
100
|
+
clause("date", r"2026-03-14\b", "date: 2026-03-14"),
|
|
101
|
+
clause("items", r"4\b", f"items: {len(items)}"),
|
|
102
|
+
clause("priciest", rf"{priciest}", "priciest: Cheddar 200g"),
|
|
103
|
+
clause("total", rf"\$?\s*{total:.2f}\b".replace(".", r"\."), f"total: {total:.2f}"),
|
|
104
|
+
clause("tip", NOT_SHOWN, "tip: not shown")],
|
|
105
|
+
[clause("tip", r"\$?\s*\d", "tip: a value (the receipt shows none)")],
|
|
106
|
+
"Printed text, a date written differently from how it is asked for, a count and a maximum.")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---- a grading label: digits that must be exact -------------------------
|
|
110
|
+
|
|
111
|
+
def label() -> None:
|
|
112
|
+
img = Image.new("RGB", (900, 300), "#dfe3e8")
|
|
113
|
+
d = ImageDraw.Draw(img)
|
|
114
|
+
d.rounded_rectangle((20, 20, 880, 280), radius=14, fill="#ffffff", outline="#1d3557", width=4)
|
|
115
|
+
d.rectangle((20, 20, 880, 70), fill="#1d3557")
|
|
116
|
+
d.text((450, 45), "NORTHLINE GRADING", font=font(28, "bold"), fill="#ffffff", anchor="mm")
|
|
117
|
+
sans, small = font(26, "bold"), font(20)
|
|
118
|
+
d.text((50, 95), "2019 HARBOR SERIES", font=sans, fill="#111")
|
|
119
|
+
d.text((50, 135), "#148 MAREN OKAFOR", font=sans, fill="#111")
|
|
120
|
+
d.text((50, 175), "GOLD REFRACTOR", font=sans, fill="#111")
|
|
121
|
+
d.text((50, 225), "CEN 9.5 COR 9 EDG 9.5 SUR 8.5", font=small, fill="#333")
|
|
122
|
+
d.text((760, 110), "MINT", font=font(30, "bold"), fill="#111", anchor="mm")
|
|
123
|
+
d.text((760, 175), "9", font=font(80, "bold"), fill="#b22222", anchor="mm")
|
|
124
|
+
d.text((620, 240), "CERT 40731958", font=font(22, "mono"), fill="#111", anchor="mm")
|
|
125
|
+
x = 520 # a barcode's look; it encodes nothing
|
|
126
|
+
for i, w in enumerate((3, 1, 2, 1, 1, 3, 2, 1, 3, 1, 2, 2, 1, 3, 1, 1, 2, 3, 1, 2)):
|
|
127
|
+
if i % 2 == 0:
|
|
128
|
+
d.rectangle((x, 90, x + w * 2, 150), fill="#111")
|
|
129
|
+
x += w * 2 + 2
|
|
130
|
+
write("vision-label", "Read a graded card's label", img,
|
|
131
|
+
["grader: <company>", "grade: <the number>", "year: <year>", "number: <card number>",
|
|
132
|
+
"player: <name>", "cert: <certification number>", "surface: <surface subgrade>",
|
|
133
|
+
"autograph: <is the card signed: yes or no>"],
|
|
134
|
+
[clause("grader", r"northline", "grader: Northline"),
|
|
135
|
+
clause("grade", r"(mint\s*)?9\b(?!\.)", "grade: 9"),
|
|
136
|
+
clause("year", r"2019\b", "year: 2019"),
|
|
137
|
+
clause("number", r"#?\s*148\b", "number: 148"),
|
|
138
|
+
clause("player", r"maren okafor", "player: Maren Okafor"),
|
|
139
|
+
clause("cert", r"#?\s*40731958\b", "cert: 40731958"),
|
|
140
|
+
clause("surface", r"8\.5\b", "surface: 8.5"),
|
|
141
|
+
# A signed card's label says AUTO; this one does not, so "no" reads it right.
|
|
142
|
+
clause("autograph", r"(no\b|" + NOT_SHOWN[1:], "autograph: no, or not shown")],
|
|
143
|
+
[clause("autograph", r"(yes|signed|auto)", "autograph: yes (the label says nothing of one)")],
|
|
144
|
+
"A label read field by field; the cert number must be exact to the digit.")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ---- a bar chart: values against gridlines ------------------------------
|
|
148
|
+
|
|
149
|
+
def bars() -> None:
|
|
150
|
+
teams = [("Billing", 45), ("Mobile", 25), ("Web", 35), ("Data", 15), ("Infra", 30)]
|
|
151
|
+
img = Image.new("RGB", (820, 560), "#ffffff")
|
|
152
|
+
d = ImageDraw.Draw(img)
|
|
153
|
+
d.text((410, 35), "Support tickets by team, Q2", font=font(26, "bold"), fill="#111", anchor="mm")
|
|
154
|
+
left, right, top, bottom, most = 90, 780, 80, 470, 50
|
|
155
|
+
for v in range(0, most + 1, 5):
|
|
156
|
+
y = bottom - (bottom - top) * v / most
|
|
157
|
+
d.line((left, y, right, y), fill="#d0d0d0" if v % 10 else "#a8a8a8", width=1)
|
|
158
|
+
if v % 10 == 0:
|
|
159
|
+
d.text((left - 12, y), str(v), font=font(18), fill="#333", anchor="rm")
|
|
160
|
+
d.line((left, top, left, bottom), fill="#333", width=2)
|
|
161
|
+
d.line((left, bottom, right, bottom), fill="#333", width=2)
|
|
162
|
+
slot = (right - left) / len(teams)
|
|
163
|
+
for i, (name, v) in enumerate(teams):
|
|
164
|
+
x0 = left + slot * i + slot * 0.2
|
|
165
|
+
x1 = left + slot * (i + 1) - slot * 0.2
|
|
166
|
+
d.rectangle((x0, bottom - (bottom - top) * v / most, x1, bottom - 1), fill="#3a6ea5")
|
|
167
|
+
d.text(((x0 + x1) / 2, bottom + 22), name, font=font(20), fill="#111", anchor="mm")
|
|
168
|
+
d.text((30, (top + bottom) / 2), "tickets", font=font(18), fill="#333", anchor="mm")
|
|
169
|
+
tall, short = max(teams, key=lambda t: t[1])[0], min(teams, key=lambda t: t[1])[0]
|
|
170
|
+
over = sum(v > 30 for _, v in teams)
|
|
171
|
+
write("vision-bars", "Read a bar chart", img,
|
|
172
|
+
["tallest: <team>", "shortest: <team>", "web: <Web's value>",
|
|
173
|
+
"over_30: <how many teams are above 30, not counting 30 itself>", "target: <the target line's value>"],
|
|
174
|
+
[clause("tallest", tall.lower(), f"tallest: {tall}"),
|
|
175
|
+
clause("shortest", short.lower(), f"shortest: {short}"),
|
|
176
|
+
clause("web", r"35\b", "web: 35"),
|
|
177
|
+
clause("over_30", rf"{over}\b", f"over_30: {over}"),
|
|
178
|
+
clause("target", NOT_SHOWN, "target: not shown")],
|
|
179
|
+
[clause("target", r"\d", "target: a value (the chart has no target line)")],
|
|
180
|
+
"Values read against gridlines every 5, labelled every 10; one count asks for care at the boundary.")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ---- a table: a cell by row and column ----------------------------------
|
|
184
|
+
|
|
185
|
+
def table() -> None:
|
|
186
|
+
cols = ["Q1", "Q2", "Q3", "Q4"]
|
|
187
|
+
rows = [("North", [1240, 1315, 1402, 1388]), ("South", [980, 1045, 1120, 1206]),
|
|
188
|
+
("East", [1510, 1470, 1535, 1602]), ("West", [870, 905, 948, 1011]),
|
|
189
|
+
("Central", [1105, 1160, 1098, 1175])]
|
|
190
|
+
x0, y0, cw, rh, first = 40, 70, 150, 54, 180
|
|
191
|
+
img = Image.new("RGB", (x0 * 2 + first + cw * len(cols), 420), "#ffffff")
|
|
192
|
+
d = ImageDraw.Draw(img)
|
|
193
|
+
d.text((img.width / 2, 32), "Units shipped by region", font=font(26, "bold"), fill="#111", anchor="mm")
|
|
194
|
+
sans, bold = font(22), font(22, "bold")
|
|
195
|
+
d.rectangle((x0, y0, x0 + first + cw * len(cols), y0 + rh), fill="#e8eef5")
|
|
196
|
+
d.text((x0 + 14, y0 + rh / 2), "Region", font=bold, fill="#111", anchor="lm")
|
|
197
|
+
for j, c in enumerate(cols):
|
|
198
|
+
d.text((x0 + first + cw * (j + 1) - 16, y0 + rh / 2), c, font=bold, fill="#111", anchor="rm")
|
|
199
|
+
for i, (name, vals) in enumerate(rows):
|
|
200
|
+
y = y0 + rh * (i + 1)
|
|
201
|
+
if i % 2:
|
|
202
|
+
d.rectangle((x0, y, x0 + first + cw * len(cols), y + rh), fill="#f6f6f6")
|
|
203
|
+
d.text((x0 + 14, y + rh / 2), name, font=sans, fill="#111", anchor="lm")
|
|
204
|
+
for j, v in enumerate(vals):
|
|
205
|
+
d.text((x0 + first + cw * (j + 1) - 16, y + rh / 2), f"{v:,}", font=sans, fill="#111", anchor="rm")
|
|
206
|
+
d.rectangle((x0, y0, x0 + first + cw * len(cols), y0 + rh * (len(rows) + 1)), outline="#999", width=2)
|
|
207
|
+
val = {name: dict(zip(cols, vals)) for name, vals in rows}
|
|
208
|
+
q4 = max(rows, key=lambda r: r[1][3])[0]
|
|
209
|
+
|
|
210
|
+
def num(n: int) -> str:
|
|
211
|
+
s = f"{n:,}"
|
|
212
|
+
return s.replace(",", ",?") + r"\b" if "," in s else s + r"\b"
|
|
213
|
+
write("vision-table", "Read a table", img,
|
|
214
|
+
["south_q3: <number>", "west_q1: <number>", "central_q3: <number>",
|
|
215
|
+
"q4_highest: <the region with the most units in Q4>", "northeast_q2: <number>"],
|
|
216
|
+
[clause("south_q3", num(val["South"]["Q3"]), "south_q3: 1,120"),
|
|
217
|
+
clause("west_q1", num(val["West"]["Q1"]), "west_q1: 870"),
|
|
218
|
+
clause("central_q3", num(val["Central"]["Q3"]), "central_q3: 1,098"),
|
|
219
|
+
clause("q4_highest", q4.lower(), f"q4_highest: {q4}"),
|
|
220
|
+
clause("northeast_q2", NOT_SHOWN, "northeast_q2: not shown")],
|
|
221
|
+
[clause("northeast_q2", r"\d", "northeast_q2: a value (there is no Northeast row)")],
|
|
222
|
+
"Cells found by row and column; Central's Q3 dips, so a value from the wrong column shows.")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# ---- a settings screen: state, not just text -----------------------------
|
|
226
|
+
|
|
227
|
+
def screen() -> None:
|
|
228
|
+
img = Image.new("RGB", (760, 640), "#e9ecef")
|
|
229
|
+
d = ImageDraw.Draw(img)
|
|
230
|
+
d.rounded_rectangle((30, 30, 730, 610), radius=12, fill="#ffffff", outline="#c8ccd0", width=2)
|
|
231
|
+
d.text((60, 62), "Sync settings", font=font(28, "bold"), fill="#111")
|
|
232
|
+
d.rounded_rectangle((60, 110, 700, 160), radius=8, fill="#fdecea", outline="#e0a39d")
|
|
233
|
+
d.text((80, 135), "Last sync failed: storage quota exceeded (E-213)", font=font(20), fill="#8a1c12", anchor="lm")
|
|
234
|
+
toggles = [("Sync on mobile data", False), ("Back up photos", True),
|
|
235
|
+
("Notify on conflicts", True), ("Pause when battery is low", False)]
|
|
236
|
+
y = 195
|
|
237
|
+
for name, on in toggles:
|
|
238
|
+
d.text((60, y + 15), name, font=font(22), fill="#111", anchor="lm")
|
|
239
|
+
d.rounded_rectangle((620, y, 690, y + 30), radius=15, fill="#2b7a3d" if on else "#c4c4c4")
|
|
240
|
+
cx = 675 if on else 635
|
|
241
|
+
d.ellipse((cx - 12, y + 3, cx + 12, y + 27), fill="#ffffff")
|
|
242
|
+
y += 52
|
|
243
|
+
d.text((60, y + 10), "When files conflict", font=font(22, "bold"), fill="#111")
|
|
244
|
+
options, chosen = ["Prefer the newer file", "Keep both", "Prefer this device"], "Keep both"
|
|
245
|
+
y += 50
|
|
246
|
+
for o in options:
|
|
247
|
+
d.ellipse((62, y, 84, y + 22), outline="#555", width=2)
|
|
248
|
+
if o == chosen:
|
|
249
|
+
d.ellipse((68, y + 6, 78, y + 16), fill="#1f5fbf")
|
|
250
|
+
d.text((96, y + 11), o, font=font(21), fill="#111", anchor="lm")
|
|
251
|
+
y += 36
|
|
252
|
+
d.rounded_rectangle((420, 548, 550, 590), radius=8, fill="#eeeeee", outline="#d5d5d5")
|
|
253
|
+
d.text((485, 569), "Sync now", font=font(20), fill="#a8a8a8", anchor="mm")
|
|
254
|
+
d.rounded_rectangle((570, 548, 700, 590), radius=8, fill="#1f5fbf")
|
|
255
|
+
d.text((635, 569), "Save", font=font(20, "bold"), fill="#ffffff", anchor="mm")
|
|
256
|
+
write("vision-screen", "Read a settings screen", img,
|
|
257
|
+
["toggles_on: <every switch that is on>", "conflict: <the option chosen>",
|
|
258
|
+
"disabled: <the button that cannot be pressed>", "error_code: <code>", "account: <the signed-in email>"],
|
|
259
|
+
[clause("toggles_on", r".*back up photos", "toggles_on: Back up photos"),
|
|
260
|
+
clause("toggles_on", r".*notify on conflicts", "toggles_on: Notify on conflicts"),
|
|
261
|
+
clause("conflict", r"keep both", "conflict: Keep both"),
|
|
262
|
+
clause("disabled", r"sync now", "disabled: Sync now"),
|
|
263
|
+
clause("error_code", r"e-?213\b", "error_code: E-213"),
|
|
264
|
+
clause("account", NOT_SHOWN, "account: not shown")],
|
|
265
|
+
[clause("toggles_on", r".*(mobile data|battery)", "toggles_on: a switch that is off"),
|
|
266
|
+
clause("account", r"\S+@\S+", "account: an email (the screen shows none)")],
|
|
267
|
+
"A screen's state: which switches are on, which option is picked, which button is greyed out.")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# ---- shapes: counting and where things are -------------------------------
|
|
271
|
+
|
|
272
|
+
def shapes() -> None:
|
|
273
|
+
img = Image.new("RGB", (800, 600), "#ffffff")
|
|
274
|
+
d = ImageDraw.Draw(img)
|
|
275
|
+
blue = [(110, 120), (250, 90), (180, 300), (390, 210), (120, 480), (330, 420)]
|
|
276
|
+
red = [(520, 330), (640, 470), (260, 540)]
|
|
277
|
+
green = [(470, 120), (560, 520)]
|
|
278
|
+
for x, y in blue:
|
|
279
|
+
d.ellipse((x - 34, y - 34, x + 34, y + 34), fill="#2f6fd0")
|
|
280
|
+
for x, y in red:
|
|
281
|
+
d.rectangle((x - 32, y - 32, x + 32, y + 32), fill="#d33a2f")
|
|
282
|
+
for x, y in green:
|
|
283
|
+
d.polygon([(x, y - 38), (x - 38, y + 30), (x + 38, y + 30)], fill="#2e9e4f")
|
|
284
|
+
cx, cy, r = 715, 85, 50 # the star, in the top-right corner
|
|
285
|
+
d.polygon([(cx + (r if i % 2 == 0 else r * 0.45) * math.cos(math.radians(-90 + 36 * i)),
|
|
286
|
+
cy + (r if i % 2 == 0 else r * 0.45) * math.sin(math.radians(-90 + 36 * i))) for i in range(10)],
|
|
287
|
+
fill="#f2c200", outline="#b38f00")
|
|
288
|
+
write("vision-shapes", "Count shapes and say where one is", img,
|
|
289
|
+
["blue_circles: <how many>", "red_squares: <how many>", "green_triangles: <how many>",
|
|
290
|
+
"top_right: <the shape in the top-right corner, with its colour>", "purple_shapes: <how many>"],
|
|
291
|
+
[clause("blue_circles", rf"{len(blue)}\b", f"blue_circles: {len(blue)}"),
|
|
292
|
+
clause("red_squares", rf"{len(red)}\b", f"red_squares: {len(red)}"),
|
|
293
|
+
clause("green_triangles", rf"{len(green)}\b", f"green_triangles: {len(green)}"),
|
|
294
|
+
clause("top_right", r".*star", "top_right: a yellow star"),
|
|
295
|
+
clause("purple_shapes", r"(0\b|none\b|zero\b|no\b|" + NOT_SHOWN[1:], "purple_shapes: 0")],
|
|
296
|
+
[clause("purple_shapes", r"[1-9]", "purple_shapes: a count above 0 (there are none)")],
|
|
297
|
+
"Counting to six without losing one, and a corner told apart from its neighbours.")
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# ---- an invoice turned on its side ---------------------------------------
|
|
301
|
+
|
|
302
|
+
def invoice() -> None:
|
|
303
|
+
page = Image.new("RGB", (640, 820), "#ffffff")
|
|
304
|
+
d = ImageDraw.Draw(page)
|
|
305
|
+
d.text((40, 40), "INVOICE", font=font(40, "bold"), fill="#111")
|
|
306
|
+
d.text((40, 100), "INV-2026-0417", font=font(26, "mono"), fill="#111")
|
|
307
|
+
d.text((40, 160), "Issued: 2026-04-02", font=font(22), fill="#333")
|
|
308
|
+
d.text((40, 195), "Due: 2026-05-02", font=font(22), fill="#333")
|
|
309
|
+
d.text((40, 260), "Bill to", font=font(20, "bold"), fill="#555")
|
|
310
|
+
d.text((40, 290), "Kestrel Labs BV", font=font(24), fill="#111")
|
|
311
|
+
d.text((40, 322), "Havenstraat 18, Rotterdam", font=font(20), fill="#333")
|
|
312
|
+
lines = [("Model evaluation, March", 960.00), ("Hosting, March", 324.50)]
|
|
313
|
+
y = 400
|
|
314
|
+
d.line((40, y - 10, 600, y - 10), fill="#bbb", width=2)
|
|
315
|
+
for name, amount in lines:
|
|
316
|
+
d.text((40, y), name, font=font(22), fill="#111")
|
|
317
|
+
d.text((600, y), f"EUR {amount:,.2f}", font=font(22), fill="#111", anchor="ra")
|
|
318
|
+
y += 44
|
|
319
|
+
d.line((40, y + 4, 600, y + 4), fill="#bbb", width=2)
|
|
320
|
+
total = sum(a for _, a in lines)
|
|
321
|
+
d.text((40, y + 30), "Amount due", font=font(26, "bold"), fill="#111")
|
|
322
|
+
d.text((600, y + 30), f"EUR {total:,.2f}", font=font(26, "bold"), fill="#111", anchor="ra")
|
|
323
|
+
img = page.rotate(90, expand=True, fillcolor="#ffffff") # turned anticlockwise: reads bottom to top
|
|
324
|
+
write("vision-invoice", "Read an invoice turned on its side", img,
|
|
325
|
+
["invoice: <invoice number>", "due: <YYYY-MM-DD>", "amount_due: <amount>", "bill_to: <company>",
|
|
326
|
+
"po_number: <purchase order number>"],
|
|
327
|
+
[clause("invoice", r"inv-2026-0417\b", "invoice: INV-2026-0417"),
|
|
328
|
+
clause("due", r"2026-05-02\b", "due: 2026-05-02"),
|
|
329
|
+
clause("amount_due", r"(eur|€)?\s*1,?284\.50\b", "amount_due: EUR 1,284.50"),
|
|
330
|
+
clause("bill_to", r"kestrel labs", "bill_to: Kestrel Labs BV"),
|
|
331
|
+
clause("po_number", NOT_SHOWN, "po_number: not shown")],
|
|
332
|
+
[clause("po_number", r"\d", "po_number: a value (the invoice has none)")],
|
|
333
|
+
"A page a scanner or a phone delivered sideways; two dates close together, only one asked for.")
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
if __name__ == "__main__":
|
|
337
|
+
for make in (receipt, label, bars, table, screen, shapes, invoice):
|
|
338
|
+
make()
|
package/ui/bench.js
CHANGED
|
@@ -509,30 +509,42 @@
|
|
|
509
509
|
o.picker.appendChild(d);
|
|
510
510
|
}
|
|
511
511
|
}
|
|
512
|
-
|
|
513
|
-
|
|
512
|
+
// The server keeps each endpoint's list a few minutes (serve.LISTED_FOR);
|
|
513
|
+
// Refresh asks them all again.
|
|
514
|
+
function showAge(s) {
|
|
515
|
+
var age = $('o-age');
|
|
516
|
+
age.hidden = s == null;
|
|
517
|
+
if (s == null) return;
|
|
518
|
+
age.firstChild.textContent = s < 60 ? 'Listed just now.' : 'Listed ' + Math.round(s / 60) + ' min ago.';
|
|
519
|
+
}
|
|
520
|
+
function loadCandidates(fresh) {
|
|
521
|
+
showAge(null);
|
|
522
|
+
o.picker.textContent = ''; o.picker.appendChild(el('p', {class: 'empty'}, 'Please wait. Gathering models from your endpoints…'));
|
|
514
523
|
o.start.disabled = o.dismiss.disabled = true;
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
524
|
+
// Typing before the list arrives is lost, or lands on a row; the mode waits with it.
|
|
525
|
+
o.find.readOnly = true; $('o-mode').disabled = $('o-vision').disabled = true;
|
|
526
|
+
function ready() { o.find.readOnly = false; $('o-mode').disabled = $('o-vision').disabled = false; }
|
|
527
|
+
fetch('/api/candidates' + (fresh ? '?fresh=1' : '')).then(function (r) { return r.json(); }).then(function (res) {
|
|
528
|
+
ready(); o.find.focus();
|
|
518
529
|
if (!res.ok) { o.picker.textContent = ''; listErrors(o.errors, res.errors); return; }
|
|
519
530
|
if (res.errors.length) listErrors(o.errors, res.errors); // one endpoint down; the rest listed
|
|
520
|
-
data = res;
|
|
531
|
+
data = res; showAge(res.age);
|
|
521
532
|
// Only what is still offered stays ticked.
|
|
522
533
|
var offered = {};
|
|
523
534
|
res.candidates.forEach(function (c) { offered[c.key] = 1; });
|
|
524
535
|
Object.keys(chosen).forEach(function (k) { if (!offered[k]) delete chosen[k]; });
|
|
525
536
|
renderPicker(); armStart();
|
|
526
|
-
}).catch(function (err) {
|
|
537
|
+
}).catch(function (err) { ready(); o.errors.textContent = String(err); });
|
|
527
538
|
}
|
|
528
539
|
$('onboard-open').addEventListener('click', function () {
|
|
529
540
|
preStep = null;
|
|
530
541
|
o.errors.textContent = ''; o.find.value = ''; o.choose.hidden = false; o.run.hidden = true;
|
|
531
|
-
$('o-queue').hidden = true; chosen = {}; waitToken++;
|
|
542
|
+
$('o-queue').hidden = true; chosen = {}; waitToken++; $('o-vision').checked = false;
|
|
532
543
|
o.start.hidden = o.dismiss.hidden = false; armStart(); o.title.textContent = 'Onboard a model';
|
|
533
544
|
od.showModal(); o.find.focus(); loadCandidates();
|
|
534
545
|
});
|
|
535
546
|
o.find.addEventListener('input', renderPicker);
|
|
547
|
+
$('o-refresh').addEventListener('click', function () { o.errors.textContent = ''; loadCandidates(true); });
|
|
536
548
|
o.close.addEventListener('click', function () { od.close(); });
|
|
537
549
|
od.addEventListener('close', function () { if (timer) { clearInterval(timer); timer = null; } });
|
|
538
550
|
|
|
@@ -573,8 +585,8 @@
|
|
|
573
585
|
// model at a time. The popup closes: the header's chip shows the batch, and
|
|
574
586
|
// its Details the queue. Refused, it stays open with the reasons.
|
|
575
587
|
o.start.addEventListener('click', function () {
|
|
576
|
-
var mode = $('o-mode').value;
|
|
577
|
-
var items = Object.keys(chosen).map(function (k) { return Object.assign({mode: mode}, chosen[k]); });
|
|
588
|
+
var mode = $('o-mode').value, vision = $('o-vision').checked;
|
|
589
|
+
var items = Object.keys(chosen).map(function (k) { return Object.assign({mode: mode, vision: vision}, chosen[k]); });
|
|
578
590
|
if (!items.length) return;
|
|
579
591
|
o.errors.textContent = ''; o.start.disabled = o.dismiss.disabled = true; o.start.textContent = 'Starting…';
|
|
580
592
|
post('/api/onboard-queue', {items: items}).then(function (res) {
|
|
@@ -1737,6 +1749,71 @@
|
|
|
1737
1749
|
});
|
|
1738
1750
|
});
|
|
1739
1751
|
|
|
1752
|
+
// Vision tasks: one square per field and model, the task's name over its first field.
|
|
1753
|
+
put('chart-vision-fields', function (w) {
|
|
1754
|
+
var V = C.vision;
|
|
1755
|
+
if (!V.heat.length) return el('p', {class: 'hint'}, 'No vision task answers yet: the suite runs them for models'
|
|
1756
|
+
+ ' that take images, at onboarding (Full) or through the scheduled passes.');
|
|
1757
|
+
// Grouped by where the pictures come from, the group's name over its first field.
|
|
1758
|
+
var order = ['made', 'own', 'receipts', 'screens', 'tables', 'objects'], keys = [], head = {};
|
|
1759
|
+
Object.keys(V.tasks).sort(function (a, b) {
|
|
1760
|
+
return order.indexOf(V.tasks[a].family) - order.indexOf(V.tasks[b].family) || (a < b ? -1 : 1);
|
|
1761
|
+
}).forEach(function (t) {
|
|
1762
|
+
var fam = V.tasks[t].family;
|
|
1763
|
+
V.tasks[t].fields.forEach(function (f) {
|
|
1764
|
+
if (!keys.some(function (k) { return head[k] === fam; })) head[t + '·' + f] = fam;
|
|
1765
|
+
keys.push(t + '·' + f);
|
|
1766
|
+
});
|
|
1767
|
+
});
|
|
1768
|
+
var S = V.summary;
|
|
1769
|
+
function share(m) { return S[m].fields ? S[m].right / S[m].fields : 0; }
|
|
1770
|
+
var models = Object.keys(S).sort(function (a, b) { return share(b) - share(a) || S[a].taken - S[b].taken; });
|
|
1771
|
+
var colour = {right: good, misread: signal, 'left out': line, 'trap taken': err, 'no answer': muted};
|
|
1772
|
+
function tip(d) { return d.model + '\n' + d.task.replace(/^vision-/, '') + ': ' + d.field + (d.trap ? ' (a trap)' : '') + '\n' + d.state; }
|
|
1773
|
+
return Plot.plot({
|
|
1774
|
+
// The group names tilted: a narrow group (a few tables) would run into its neighbour's.
|
|
1775
|
+
width: w, height: 22 * models.length + 140, style: style, marginLeft: 300, marginTop: 80, padding: 0.08,
|
|
1776
|
+
x: {domain: keys, axis: 'top', label: null, tickSize: 0, tickRotate: -60, textAnchor: 'start',
|
|
1777
|
+
tickFormat: function (k) { return head[k] || ''; }},
|
|
1778
|
+
y: {domain: models, label: null, tickSize: 0,
|
|
1779
|
+
tickFormat: function (m) { return m + ' (' + S[m].right + '/' + S[m].fields + ')'; }},
|
|
1780
|
+
color: {legend: true, domain: V.states, range: V.states.map(function (s) { return colour[s]; })},
|
|
1781
|
+
marks: [
|
|
1782
|
+
Plot.cell(V.heat, {x: 'key', y: 'model', fill: 'state', title: tip}),
|
|
1783
|
+
Plot.dot(V.heat.filter(function (d) { return d.trap; }), {x: 'key', y: 'model', r: 1.8, fill: ink, fillOpacity: 0.55}),
|
|
1784
|
+
Plot.ruleX(Object.keys(head), {x: function (k) { return k; }, stroke: ink, strokeOpacity: 0.25, dx: -2})
|
|
1785
|
+
]
|
|
1786
|
+
});
|
|
1787
|
+
});
|
|
1788
|
+
|
|
1789
|
+
// Vision, real work: each model's seats on image panels by how they were judged.
|
|
1790
|
+
put('chart-vision-work', function (w) {
|
|
1791
|
+
var W = C.vision.work, kinds = C.vision.seatKinds, data = [], total = {};
|
|
1792
|
+
Object.keys(W).forEach(function (m) {
|
|
1793
|
+
kinds.forEach(function (k) { if (W[m][k]) data.push({model: m, kind: k, count: W[m][k]}); total[m] = (total[m] || 0) + W[m][k]; });
|
|
1794
|
+
});
|
|
1795
|
+
if (!data.length) return el('p', {class: 'hint'}, 'No vision panels through the gateway yet.');
|
|
1796
|
+
// The models doing the most image work first: their bars are the evidence.
|
|
1797
|
+
var models = Object.keys(total).sort(function (a, b) { return total[b] - total[a] || (a < b ? -1 : 1); });
|
|
1798
|
+
var colour = {'checked, helped': good, 'checked, did not help': err, 'useful, no claims': sky,
|
|
1799
|
+
'no use, no claims': warn, 'not judged': line, 'no answer': muted};
|
|
1800
|
+
// The claims checked by hand, beside the bar: the only right-or-wrong count real work has.
|
|
1801
|
+
var claims = models.filter(function (m) { return W[m].real + W[m].false; }).map(function (m) {
|
|
1802
|
+
return {model: m, text: W[m].real + ' of ' + (W[m].real + W[m].false) + ' claims right'};
|
|
1803
|
+
});
|
|
1804
|
+
return Plot.plot({
|
|
1805
|
+
width: w, height: rows(models.length), style: style, marginLeft: 300, marginRight: 150,
|
|
1806
|
+
x: {label: 'Share of its seats on image panels →', tickFormat: '.0%', grid: true, domain: [0, 1]},
|
|
1807
|
+
y: {domain: models, label: null, tickFormat: function (m) { return m + ' (' + total[m] + ')'; }},
|
|
1808
|
+
color: {legend: true, domain: kinds, range: kinds.map(function (k) { return colour[k]; })},
|
|
1809
|
+
marks: [
|
|
1810
|
+
Plot.barX(data, Plot.stackX({x: 'count', y: 'model', fill: 'kind', offset: 'normalize', order: kinds,
|
|
1811
|
+
title: function (d) { return d.model + ': ' + d.kind + ', ' + d.count + ' of ' + total[d.model] + ' seats'; }})),
|
|
1812
|
+
Plot.text(claims, {x: 1, y: 'model', text: 'text', dx: 8, textAnchor: 'start', fill: ink})
|
|
1813
|
+
]
|
|
1814
|
+
});
|
|
1815
|
+
});
|
|
1816
|
+
|
|
1740
1817
|
// Confidently wrong: stacked by kind, worst at the top.
|
|
1741
1818
|
put('chart-wrong', function (w) {
|
|
1742
1819
|
var T = C.wrongTotals;
|