| """Render eval threads as iMessage-style screenshots for the vision A/B arm. |
| |
| Each record in an eval jsonl becomes training/data/screenshots/<stem>/<id>.png. |
| Chat-style threads ("Name: text" lines with a repeated sender or "Me") get |
| gray/blue bubbles; everything else (flyers, forwarded notices, appointment |
| cards) renders as a single notice card, line breaks preserved. The PNGs ship |
| to Modal with the repo (training/modal_eval.py --vision) and are fed to the |
| model INSTEAD of the thread text by `VISION=1 training/eval.py`. |
| |
| python training/render_screenshots.py # both eval files |
| python training/render_screenshots.py training/data/eval.jsonl |
| """ |
| from __future__ import annotations |
|
|
| import json |
| import re |
| import sys |
| from pathlib import Path |
|
|
| from PIL import Image, ImageDraw, ImageFont |
|
|
| ROOT = Path(__file__).resolve().parent.parent |
| DEFAULT_FILES = [ROOT / "training" / "data" / "eval.jsonl", |
| ROOT / "training" / "data" / "eval_unstructured.jsonl"] |
| OUT_ROOT = ROOT / "training" / "data" / "screenshots" |
|
|
| W = 390 |
| PAD = 12 |
| BUBBLE_PAD = 10 |
| MAX_BUBBLE = 270 |
| GRAY, BLUE, INK, WHITE = (229, 229, 234), (10, 132, 255), (20, 20, 22), (255, 255, 255) |
| SENDER_RE = re.compile(r"^([A-Za-z][\w .'’()-]{0,40}?):\s+(.*)$") |
|
|
| _FONT_CANDIDATES = ["segoeui.ttf", "C:/Windows/Fonts/segoeui.ttf", |
| "DejaVuSans.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", |
| "Arial.ttf"] |
|
|
|
|
| def _font(size: int) -> ImageFont.FreeTypeFont: |
| for name in _FONT_CANDIDATES: |
| try: |
| return ImageFont.truetype(name, size) |
| except OSError: |
| continue |
| return ImageFont.load_default() |
|
|
|
|
| BODY, LABEL = _font(15), _font(11) |
|
|
|
|
| def _wrap(text: str, font: ImageFont.FreeTypeFont, width: int) -> list[str]: |
| out = [] |
| for raw in text.split("\n"): |
| line = "" |
| for word in raw.split(" "): |
| cand = f"{line} {word}".strip() |
| if font.getlength(cand) <= width or not line: |
| line = cand |
| else: |
| out.append(line) |
| line = word |
| out.append(line) |
| return out |
|
|
|
|
| def _messages(thread: str) -> list[tuple[str | None, str]]: |
| """[(sender_or_None, text)] — None sender means a notice card.""" |
| lines = thread.splitlines() |
| parsed = [SENDER_RE.match(ln) for ln in lines] |
| senders = [m.group(1) for m in parsed if m] |
| is_chat = "Me" in senders or any(senders.count(s) > 1 for s in set(senders)) |
| if not is_chat: |
| return [(None, thread)] |
| msgs: list[tuple[str | None, str]] = [] |
| for ln, m in zip(lines, parsed): |
| if m: |
| msgs.append((m.group(1), m.group(2))) |
| elif msgs and ln.strip(): |
| msgs[-1] = (msgs[-1][0], msgs[-1][1] + "\n" + ln.strip()) |
| return msgs |
|
|
|
|
| def render(thread: str, out_path: Path) -> None: |
| line_h = BODY.getbbox("Ag")[3] + 4 |
| label_h = LABEL.getbbox("Ag")[3] + 2 |
| msgs = [(s, _wrap(t, BODY, MAX_BUBBLE - 2 * BUBBLE_PAD)) for s, t in _messages(thread)] |
|
|
| height = PAD |
| for sender, wrapped in msgs: |
| if sender not in (None, "Me"): |
| height += label_h |
| height += len(wrapped) * line_h + 2 * BUBBLE_PAD + 8 |
| height += PAD |
|
|
| img = Image.new("RGB", (W, max(height, 80)), WHITE) |
| d = ImageDraw.Draw(img) |
| y = PAD |
| for sender, wrapped in msgs: |
| bw = min(MAX_BUBBLE, max((BODY.getlength(l) for l in wrapped), default=0) + 2 * BUBBLE_PAD) |
| bh = len(wrapped) * line_h + 2 * BUBBLE_PAD |
| mine = sender == "Me" |
| x = W - PAD - bw if mine else PAD |
| if sender not in (None, "Me"): |
| d.text((x + 4, y), sender, font=LABEL, fill=(120, 120, 128)) |
| y += label_h |
| d.rounded_rectangle([x, y, x + bw, y + bh], radius=14, |
| fill=BLUE if mine else GRAY) |
| ty = y + BUBBLE_PAD |
| for line in wrapped: |
| d.text((x + BUBBLE_PAD, ty), line, font=BODY, fill=WHITE if mine else INK) |
| ty += line_h |
| y += bh + 8 |
| img.save(out_path) |
|
|
|
|
| def main() -> None: |
| files = [Path(a) for a in sys.argv[1:]] or DEFAULT_FILES |
| for f in files: |
| out_dir = OUT_ROOT / f.stem |
| out_dir.mkdir(parents=True, exist_ok=True) |
| n = 0 |
| for line in f.read_text(encoding="utf-8").splitlines(): |
| if not line.strip(): |
| continue |
| rec = json.loads(line) |
| render(rec["thread"], out_dir / f"{rec['id']}.png") |
| n += 1 |
| print(f"{f.name}: rendered {n} screenshot(s) -> {out_dir}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|