ui-test-vision-2b-alpha / code /make_synthetic.py
yuta24's picture
v0.1.0-alpha: PyTorch LoRA + heads, code, model card
e379257 verified
Raw History Blame Contribute Delete
12.5 kB
"""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()