"""Generate a tiny synthetic dataset of dialog-like iPhone screenshots (PIL) for smoke tests. !! The "teacher" distributions here are DUMMY (derived from the gold label + noise), NOT Clef outputs. !! Replace them later with real teacher labels (scripts/label_workers_ai.py or label_local_clef.py). --box adds Jev-style box questions (`primary_button_box` + presence `primary_button_box_visible`) whose gold is the exact drawn button rectangle (ground truth, not the teacher). Images are identical with and without --box.""" import argparse, json, random from pathlib import Path from PIL import Image, ImageDraw, ImageFont FONT_CANDIDATES = ["C:/Windows/Fonts/YuGothM.ttc", "C:/Windows/Fonts/meiryo.ttc", "C:/Windows/Fonts/msgothic.ttc", "/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc", "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"] def font(size): for p in FONT_CANDIDATES: if Path(p).exists(): try: return ImageFont.truetype(p, size) except OSError: pass return ImageFont.load_default(size=size) TXT = { "ja": {"err_title": ["エラー", "通信エラー", "保存に失敗しました"], "err_msg": ["ネットワークに接続できません。", "もう一度お試しください。", "サーバーが応答しません。"], "ok": "OK", "retry": "再試行", "cancel": "キャンセル", "delete": "削除", "confirm_title": ["削除しますか?", "変更を破棄しますか?"], "confirm_msg": ["この操作は取り消せません。"], "loading": ["読み込み中…", "お待ちください"], "login": "ログイン", "email": "メールアドレス", "password": "パスワード", "home": ["ホーム", "最近の項目"], "row": ["メモ", "写真", "設定", "お気に入り", "ダウンロード"]}, "en": {"err_title": ["Error", "Network Error", "Save Failed"], "err_msg": ["Unable to connect to the network.", "Please try again.", "The server is not responding."], "ok": "OK", "retry": "Retry", "cancel": "Cancel", "delete": "Delete", "confirm_title": ["Delete this item?", "Discard changes?"], "confirm_msg": ["This action cannot be undone."], "loading": ["Loading…", "Please wait"], "login": "Log In", "email": "Email", "password": "Password", "home": ["Home", "Recents"], "row": ["Notes", "Photos", "Settings", "Favorites", "Downloads"]}, } SCREENS = ["error_dialog", "confirm_dialog", "loading", "login", "home"] BUTTONS = ["ok", "retry", "delete", "login", "none"] def questions(lang): ja = lang == "ja" return { "screen": {"type": "choice", "instructions": "現在の画面状態はどれか?" if ja else "What is the current screen state?", "criteria": {"error_dialog": "An error alert/dialog is shown / エラーダイアログが表示されている", "confirm_dialog": "A confirmation dialog asks the user to confirm / 確認ダイアログ", "loading": "A loading spinner or progress indicator / 読み込み中", "login": "A login form with email and password / ログインフォーム", "home": "A normal home/list screen without dialogs / 通常のホーム・一覧画面"}}, "has_error": {"type": "noul", "instructions": "エラーダイアログが表示されているか?" if ja else "Is an error dialog visible?"}, "primary_button": {"type": "choice", "instructions": "最も目立つ操作ボタンはどれか?" if ja else "Which is the primary action button?", "criteria": {"ok": "OK button", "retry": "Retry / 再試行", "delete": "Delete / 削除", "login": "Log In / ログイン", "none": "No primary button"}}, } def box_questions(lang): ja = lang == "ja" return { "primary_button_box": {"type": "box", "instructions": "`screen` 内の最も目立つ操作ボタン" if ja else "the primary action button in `screen`"}, "primary_button_box_visible": {"type": "noul", "instructions": "`screen` に主要な操作ボタンが見えているか?" if ja else "Is a primary action button visible in `screen`?"}, } def box_gold(rect, W, H): """Pixel rect (x0, y0, x1, y1) -> Jev box gold (fractions of height/width). None -> not present.""" if rect is None: return {"present": False} x0, y0, x1, y1 = rect return {"top": round(y0 / H, 4), "bottom": round(y1 / H, 4), "left": round(x0 / W, 4), "right": round(x1 / W, 4), "present": True} def draw_status_bar(d, W, dark): fg = (255, 255, 255) if dark else (0, 0, 0) d.text((90, 50), f"{random.randint(9,23)}:{random.randint(0,59):02d}", fill=fg, font=font(44)) d.rectangle([W - 190, 60, W - 100, 95], outline=fg, width=4) def dialog(d, W, H, title, msg, buttons, dark, primary_color): bw, bh = int(W * 0.72), int(H * 0.2) x0, y0 = (W - bw) // 2, int(H * random.uniform(0.33, 0.45)) bg = (44, 44, 46) if dark else (242, 242, 247) fg = (255, 255, 255) if dark else (0, 0, 0) d.rounded_rectangle([x0, y0, x0 + bw, y0 + bh], radius=40, fill=bg) d.text((x0 + bw // 2, y0 + 80), title, fill=fg, font=font(52), anchor="mm") d.text((x0 + bw // 2, y0 + 170), msg, fill=fg, font=font(38), anchor="mm") d.line([x0, y0 + bh - 140, x0 + bw, y0 + bh - 140], fill=(120, 120, 128), width=2) n = len(buttons); primary_rect = None for i, (label, is_primary) in enumerate(buttons): if is_primary: # the tappable cell of the primary button primary_rect = (x0 + bw * i // n, y0 + bh - 140, x0 + bw * (i + 1) // n, y0 + bh) cx = x0 + bw * (2 * i + 1) // (2 * n) d.text((cx, y0 + bh - 70), label, fill=primary_color if is_primary else (10, 132, 255), font=font(46 if is_primary else 42), anchor="mm") if i: d.line([x0 + bw * i // n, y0 + bh - 140, x0 + bw * i // n, y0 + bh], fill=(120, 120, 128), width=2) return primary_rect def render(screen, lang, W, H): t = TXT[lang]; dark = random.random() < 0.3 img = Image.new("RGB", (W, H), (0, 0, 0) if dark else (255, 255, 255)); d = ImageDraw.Draw(img) fg = (255, 255, 255) if dark else (0, 0, 0) draw_status_bar(d, W, dark) d.text((80, 200), random.choice(t["home"]), fill=fg, font=font(96)) for i in range(random.randint(4, 9)): # background list y = 380 + i * 170 d.text((90, y), random.choice(t["row"]) + f" {random.randint(1, 99)}", fill=fg, font=font(50)) d.line([90, y + 110, W, y + 110], fill=(200, 200, 205) if not dark else (60, 60, 64), width=2) primary = "none"; rect = None if screen in ("error_dialog", "confirm_dialog", "loading"): ov = Image.new("RGBA", (W, H), (0, 0, 0, random.randint(80, 140))) img = Image.alpha_composite(img.convert("RGBA"), ov).convert("RGB"); d = ImageDraw.Draw(img) if screen == "error_dialog": if random.random() < 0.5: rect = dialog(d, W, H, random.choice(t["err_title"]), random.choice(t["err_msg"]), [(t["ok"], True)], dark, (10, 132, 255)); primary = "ok" else: rect = dialog(d, W, H, random.choice(t["err_title"]), random.choice(t["err_msg"]), [(t["cancel"], False), (t["retry"], True)], dark, (10, 132, 255)); primary = "retry" elif screen == "confirm_dialog": rect = dialog(d, W, H, random.choice(t["confirm_title"]), random.choice(t["confirm_msg"]), [(t["cancel"], False), (t["delete"], True)], dark, (255, 59, 48)); primary = "delete" elif screen == "loading": cx, cy, r = W // 2, H // 2, 90 d.rounded_rectangle([cx - 220, cy - 220, cx + 220, cy + 220], radius=40, fill=(60, 60, 64)) for k in range(12): d.arc([cx - r, cy - r - 40, cx + r, cy + r - 40], k * 30, k * 30 + 18, fill=(255, 255, 255), width=14) d.text((cx, cy + 150), random.choice(t["loading"]), fill=(255, 255, 255), font=font(44), anchor="mm") elif screen == "login": img = Image.new("RGB", (W, H), (0, 0, 0) if dark else (255, 255, 255)); d = ImageDraw.Draw(img); draw_status_bar(d, W, dark) d.text((W // 2, 500), t["login"], fill=fg, font=font(96), anchor="mm") for i, ph in enumerate([t["email"], t["password"]]): y = 800 + i * 220 d.rounded_rectangle([100, y, W - 100, y + 150], radius=24, outline=(160, 160, 166), width=4) d.text((140, y + 75), ph, fill=(140, 140, 146), font=font(48), anchor="lm") d.rounded_rectangle([100, 1350, W - 100, 1500], radius=30, fill=(10, 132, 255)) d.text((W // 2, 1425), t["login"], fill=(255, 255, 255), font=font(54), anchor="mm"); primary = "login" rect = (100, 1350, W - 100, 1500) return img, {"screen": screen, "has_error": "true" if screen == "error_dialog" else "false", "primary_button": primary}, rect def dummy_teacher(qs, gold, rng=random): """DUMMY: gold-centred noisy distribution. Not a real teacher.""" out = {} for qid, q in qs.items(): if q["type"] == "box": # box gold is ground truth; Clef has no box type continue opts = ["true", "false"] if q["type"] == "noul" else list(q["criteria"]) p_gold = rng.uniform(0.55, 0.95) if rng.random() < 0.08: # occasional "teacher mistake" to mimic label noise p_gold = rng.uniform(0.05, 0.3) rest = [rng.random() for _ in opts[1:]]; s = sum(rest) others = [o for o in opts if o != gold[qid]] dist = {gold[qid]: p_gold} for o, r in zip(others, rest): dist[o] = (1 - p_gold) * r / s out[qid] = {o: round(dist[o], 4) for o in opts} return out def main(): ap = argparse.ArgumentParser() ap.add_argument("--out", default="data/synthetic") ap.add_argument("--n-train", type=int, default=60) ap.add_argument("--n-eval", type=int, default=20) ap.add_argument("--width", type=int, default=1179) ap.add_argument("--height", type=int, default=2556) ap.add_argument("--seed", type=int, default=0) ap.add_argument("--box", action="store_true", help="also add Jev-style box questions (+ presence) with ground-truth gold") args = ap.parse_args(); random.seed(args.seed); box_rng = random.Random(args.seed + 1) out = Path(args.out); (out / "images").mkdir(parents=True, exist_ok=True) for split, n in (("train", args.n_train), ("eval", args.n_eval)): with open(out / f"{split}.jsonl", "w", encoding="utf-8") as f: for i in range(n): screen = SCREENS[i % len(SCREENS)]; lang = random.choice(["ja", "en"]) img, gold, rect = render(screen, lang, args.width, args.height) rid = f"syn-{split}-{i:04d}"; rel = f"images/{rid}.png"; img.save(out / rel) qs = questions(lang); teacher = dummy_teacher(qs, gold) if args.box: # separate RNG: images/labels of the base questions stay identical bq = box_questions(lang) gold["primary_button_box"] = box_gold(rect, args.width, args.height) gold["primary_button_box_visible"] = "true" if rect else "false" teacher.update(dummy_teacher(bq, gold, box_rng)); qs.update(bq) rec = {"id": rid, "images": [rel], "state": {"app": "SyntheticApp", "locale": "ja-JP" if lang == "ja" else "en-US", "device": "iPhone (synthetic)"}, "questions": qs, "teacher": teacher, "gold": gold, "meta": {"source": "synthetic_pil", "teacher_model": "DUMMY(gold+noise)", "teacher_is_dummy": True, "split_group": "SyntheticApp", "lang": lang}} f.write(json.dumps(rec, ensure_ascii=False) + "\n") print(f"wrote {args.n_train}+{args.n_eval} records to {out.resolve()}") if __name__ == "__main__": main()