File size: 12,499 Bytes
e379257
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
"""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()