xwk123 commited on
Commit
4e0fe9d
·
verified ·
1 Parent(s): cb06db2

Add history-w3 HTML world model render

Browse files
inference/render_cmgui_html_outputs.py ADDED
@@ -0,0 +1,478 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Render CMGUIBENCH HTML predictions to PNG screenshots with Playwright.
4
+
5
+ Default input:
6
+ html_outputs/pair_0001.html
7
+
8
+ Default output:
9
+ outputs/pair_0001.png
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import asyncio
16
+ import json
17
+ import os
18
+ import re
19
+ import uuid
20
+ from pathlib import Path
21
+ from typing import Any, Dict, List, Optional, Tuple
22
+
23
+ from PIL import Image
24
+
25
+ DATA_ROOT = Path(__file__).resolve().parents[1]
26
+ DEFAULT_META = DATA_ROOT / "cmgui_test_500_screenshot_pairs.json"
27
+ DEFAULT_IMAGES = DATA_ROOT / "images"
28
+ DEFAULT_HTML_DIR = DATA_ROOT / "html_outputs"
29
+ DEFAULT_OUT_DIR = DATA_ROOT / "outputs"
30
+ DEFAULT_FONTS_ROOT = Path(
31
+ "/data/shared/public/weikai.xu/AAAI2026-AppDeltaWorld/training/training_data/annotation-main/assets/fonts"
32
+ )
33
+ _EMBEDDED_FONT_CSS: Optional[str] = None
34
+
35
+
36
+ def read_pairs(path: Path) -> List[Dict[str, Any]]:
37
+ obj = json.loads(path.read_text(encoding="utf-8"))
38
+ if isinstance(obj, dict) and isinstance(obj.get("pairs"), list):
39
+ return obj["pairs"]
40
+ if isinstance(obj, list):
41
+ return obj
42
+ raise ValueError(f"Unsupported metadata format: {path}")
43
+
44
+
45
+ def image_size(pair: Dict[str, Any], images_root: Path) -> Tuple[int, int]:
46
+ before = images_root / str(pair["image_before"])
47
+ with Image.open(before) as im:
48
+ return im.size
49
+
50
+
51
+ def wrap_html_if_fragment(html: str) -> str:
52
+ if re.search(r"<html[\s>]", html, re.IGNORECASE):
53
+ return html
54
+ return "<!DOCTYPE html><html><head><meta charset='utf-8'></head><body>" + html + "</body></html>"
55
+
56
+
57
+ def viewport_css(width: int, height: int) -> str:
58
+ return (
59
+ f"html, body {{ margin: 0; padding: 0; width: {width}px; min-height: {height}px; "
60
+ f"overflow: hidden; background: white; }}\n"
61
+ "* { box-sizing: border-box; }"
62
+ )
63
+
64
+
65
+ def inject_viewport_css(html: str, width: int, height: int) -> str:
66
+ # Strip any existing rogue CSS that forces incorrect sizing
67
+ html = re.sub(r"<style>html,body\{width:\d+px!important;height:\d+px!important;max-width:\d+px!important;max-height:\d+px!important;margin:0;padding:0;overflow:hidden;\}#render-target\{width:\d+px!important;height:\d+px!important;\}</style>", "", html)
68
+
69
+ css = f"\n<style id=\"cmgui-render-css\">\n{viewport_css(width, height)}\n</style>\n"
70
+ if re.search(r"</head>", html, re.IGNORECASE):
71
+ return re.sub(r"</head>", css + "</head>", html, count=1, flags=re.IGNORECASE)
72
+ return css + html
73
+
74
+
75
+ CHROMIUM_ARGS = [
76
+ "--allow-file-access-from-files",
77
+ "--disable-web-security",
78
+ "--no-sandbox",
79
+ "--disable-dev-shm-usage",
80
+ ]
81
+
82
+
83
+ def strip_file_font_faces(html: str) -> str:
84
+ """Remove injected @font-face rules without deleting layout <style> blocks."""
85
+ html = re.sub(r"@font-face\s*\{[^}]*\}", "", html, flags=re.IGNORECASE)
86
+ # Drop style blocks that only contain @font-face / font-family overrides.
87
+ html = re.sub(
88
+ r"<style[^>]*>\s*(?:@font-face\s*\{[^}]*\}\s*)+"
89
+ r"(?:html,body[^{]*\{[^}]*\}\s*)*</style>",
90
+ "",
91
+ html,
92
+ flags=re.IGNORECASE,
93
+ )
94
+ return html
95
+
96
+
97
+ def inject_fallback_font_stack(html: str) -> str:
98
+ if "cmgui-fallback-fonts" in html:
99
+ return html
100
+ stack = "'Noto Sans SC','WenQuanYi Micro Hei','DejaVu Sans',sans-serif"
101
+ css = f"<style id=\"cmgui-fallback-fonts\">html,body,*{{font-family:{stack}!important}}</style>\n"
102
+ if re.search(r"</head>", html, re.IGNORECASE):
103
+ return re.sub(r"</head>", css + "</head>", html, count=1, flags=re.IGNORECASE)
104
+ return css + html
105
+
106
+
107
+ def should_use_embedded_css() -> bool:
108
+ mode = os.environ.get("CMGUI_RENDER_FONT_MODE", "file").strip().lower()
109
+ return mode in {"base64", "embedded", "data"}
110
+
111
+
112
+ def embedded_font_css_path() -> Optional[Path]:
113
+ for cand in [
114
+ os.environ.get("CMGUI_FONTS_EMBEDDED_CSS", "").strip(),
115
+ str(Path(os.environ.get("CMGUI_FONTS_ROOT", "")) / "embedded.css"),
116
+ str(DATA_ROOT / "third_party" / "cmgui-render-fonts" / "embedded.css"),
117
+ ]:
118
+ if cand and Path(cand).is_file():
119
+ return Path(cand)
120
+ return None
121
+
122
+
123
+ def resolve_fonts_root(preferred: Path) -> Path:
124
+ env_root = os.environ.get("CMGUI_FONTS_ROOT", "").strip()
125
+ if env_root:
126
+ p = Path(env_root)
127
+ if (p / "NotoSansSC-Regular.otf").is_file():
128
+ return p.resolve()
129
+ if (preferred / "NotoSansSC-Regular.otf").is_file():
130
+ return preferred.resolve()
131
+ return preferred.resolve()
132
+
133
+
134
+ def cjk_font_css(fonts_root: Path) -> str:
135
+ """Embed Noto/Inter from local /tmp fonts for Linux headless Chromium."""
136
+ fonts = {
137
+ "Noto Sans SC": [
138
+ ("400", "normal", fonts_root / "NotoSansSC-Regular.otf"),
139
+ ("500", "normal", fonts_root / "NotoSansSC-Medium.otf"),
140
+ ("700", "normal", fonts_root / "NotoSansSC-Bold.otf"),
141
+ ],
142
+ "Inter": [
143
+ ("400", "normal", fonts_root / "Inter-Regular.otf"),
144
+ ("500", "normal", fonts_root / "Inter-Medium.otf"),
145
+ ("600", "normal", fonts_root / "Inter-SemiBold.otf"),
146
+ ("700", "normal", fonts_root / "Inter-Bold.otf"),
147
+ ],
148
+ "AnnotationCJK": [
149
+ ("400", "normal", fonts_root / "NotoSansSC-Regular.otf"),
150
+ ("500", "normal", fonts_root / "NotoSansSC-Medium.otf"),
151
+ ("700", "normal", fonts_root / "NotoSansSC-Bold.otf"),
152
+ ],
153
+ "AnnotationLatin": [
154
+ ("400", "normal", fonts_root / "Inter-Regular.otf"),
155
+ ("500", "normal", fonts_root / "Inter-Medium.otf"),
156
+ ("600", "normal", fonts_root / "Inter-SemiBold.otf"),
157
+ ("700", "normal", fonts_root / "Inter-Bold.otf"),
158
+ ],
159
+ }
160
+ lines = []
161
+ seen_uri: set[str] = set()
162
+ for family, entries in fonts.items():
163
+ for weight, style, path in entries:
164
+ if not path.is_file():
165
+ continue
166
+ uri = path.resolve().as_uri()
167
+ key = f"{family}|{weight}|{style}|{uri}"
168
+ if key in seen_uri:
169
+ continue
170
+ seen_uri.add(key)
171
+ lines.append(
172
+ f"@font-face{{font-family:'{family}';font-weight:{weight};font-style:{style};"
173
+ f"src:url('{uri}') format('opentype');font-display:swap}}"
174
+ )
175
+ if not lines:
176
+ return ""
177
+ stack = "'Noto Sans SC','Inter','AnnotationCJK','AnnotationLatin','PingFang SC',sans-serif"
178
+ lines.append(f"html,body,.screen,.text-node,.search-input-text,.suggestion-item{{font-family:{stack}!important}}")
179
+ return "\n".join(lines)
180
+
181
+
182
+ def get_embedded_font_css() -> Optional[str]:
183
+ global _EMBEDDED_FONT_CSS
184
+ if _EMBEDDED_FONT_CSS is None:
185
+ css_path = embedded_font_css_path()
186
+ if css_path is not None:
187
+ _EMBEDDED_FONT_CSS = css_path.read_text(encoding="utf-8")
188
+ return _EMBEDDED_FONT_CSS
189
+
190
+
191
+ def inject_cjk_fonts_if_needed(html: str, fonts_root: Path) -> str:
192
+ html = strip_file_font_faces(html)
193
+ if should_use_embedded_css():
194
+ css = get_embedded_font_css()
195
+ if css:
196
+ tag = f"<style id=\"cmgui-cjk-fonts\">\n{css}\n</style>\n"
197
+ if re.search(r"</head>", html, re.IGNORECASE):
198
+ return re.sub(r"</head>", tag + "</head>", html, count=1, flags=re.IGNORECASE)
199
+ return tag + html
200
+ local_root = resolve_fonts_root(fonts_root)
201
+ css = cjk_font_css(local_root)
202
+ if css:
203
+ tag = f"<style id=\"cmgui-cjk-fonts\">\n{css}\n</style>\n"
204
+ if re.search(r"</head>", html, re.IGNORECASE):
205
+ return re.sub(r"</head>", tag + "</head>", html, count=1, flags=re.IGNORECASE)
206
+ return tag + html
207
+ css = get_embedded_font_css()
208
+ if css:
209
+ tag = f"<style id=\"cmgui-cjk-fonts\">\n{css}\n</style>\n"
210
+ if re.search(r"</head>", html, re.IGNORECASE):
211
+ return re.sub(r"</head>", tag + "</head>", html, count=1, flags=re.IGNORECASE)
212
+ return tag + html
213
+ return inject_fallback_font_stack(html)
214
+
215
+
216
+ async def wait_for_render_ready(page: Any) -> None:
217
+ try:
218
+ await asyncio.wait_for(page.evaluate("() => document.fonts.ready"), timeout=5.0)
219
+ except Exception:
220
+ pass
221
+ await page.evaluate(
222
+ """async () => {
223
+ const timeout = (ms) => new Promise((res) => setTimeout(res, ms));
224
+ const waits = Array.from(document.images).map((img) =>
225
+ img.complete ? Promise.resolve() : Promise.race([
226
+ new Promise((res) => {
227
+ img.addEventListener('load', res, { once: true });
228
+ img.addEventListener('error', res, { once: true });
229
+ }),
230
+ timeout(60000),
231
+ ])
232
+ );
233
+ await Promise.all(waits);
234
+ await new Promise((r) => setTimeout(r, 300));
235
+ }"""
236
+ )
237
+
238
+
239
+ def inject_base_href(html: str, base_dir: Path) -> str:
240
+ base_tag = f'<base href="{base_dir.resolve().as_uri()}/">\n'
241
+ if re.search(r"<head[\s>]", html, re.IGNORECASE):
242
+ return re.sub(r"(<head[^>]*>)", r"\1\n" + base_tag, html, count=1, flags=re.IGNORECASE)
243
+ return base_tag + html
244
+
245
+
246
+ async def render_one(
247
+ page: Any,
248
+ html_path: Path,
249
+ out_png: Path,
250
+ width: int,
251
+ height: int,
252
+ *,
253
+ force: bool,
254
+ fonts_root: Path,
255
+ ) -> str:
256
+ if out_png.is_file() and out_png.stat().st_size > 0 and not force:
257
+ return "skipped"
258
+ await page.set_viewport_size({"width": width, "height": height})
259
+ html_text = html_path.read_text(encoding="utf-8", errors="replace")
260
+ html_text = wrap_html_if_fragment(html_text)
261
+ html_text = inject_cjk_fonts_if_needed(html_text, fonts_root)
262
+ html_text = inject_viewport_css(html_text, width, height)
263
+ html_dir = html_path.resolve().parent
264
+ html_text = inject_base_href(html_text, html_dir)
265
+ tmp_root = Path(os.environ.get("TMPDIR", "/tmp")) / "cmgui_render_html"
266
+ tmp_root.mkdir(parents=True, exist_ok=True)
267
+ tmp_html = tmp_root / f"{html_path.stem}.{uuid.uuid4().hex[:8]}.render.html"
268
+ try:
269
+ tmp_html.write_text(html_text, encoding="utf-8")
270
+ await page.goto(tmp_html.resolve().as_uri(), wait_until="load", timeout=120_000)
271
+ await wait_for_render_ready(page)
272
+ await page.screenshot(path=str(out_png), full_page=False)
273
+ finally:
274
+ tmp_html.unlink(missing_ok=True)
275
+ return "rendered"
276
+
277
+
278
+ async def process_pair(
279
+ browser_pool: "BrowserPool",
280
+ semaphore: asyncio.Semaphore,
281
+ print_lock: asyncio.Lock,
282
+ pair: Dict[str, Any],
283
+ idx: int,
284
+ end: int,
285
+ args: argparse.Namespace,
286
+ ) -> str:
287
+ pair_id = str(pair["pair_id"])
288
+ html_path = args.html_dir / f"{pair_id}.html"
289
+ out_png = args.output_dir / f"{pair_id}.png"
290
+ if not html_path.is_file():
291
+ async with print_lock:
292
+ print(f"[{idx}/{end}] missing html {pair_id}", flush=True)
293
+ return "missing"
294
+ if out_png.is_file() and out_png.stat().st_size > 0 and not args.force:
295
+ async with print_lock:
296
+ print(f"[{idx}/{end}] skipped {pair_id}", flush=True)
297
+ return "skipped"
298
+ async with semaphore:
299
+ browser = None
300
+ for attempt in range(2):
301
+ page = None
302
+ try:
303
+ browser = await browser_pool.acquire()
304
+ page = await browser.new_page(device_scale_factor=args.device_scale_factor)
305
+ if args.width and args.height:
306
+ width, height = int(args.width), int(args.height)
307
+ else:
308
+ width, height = image_size(pair, args.images_root)
309
+ status = await render_one(
310
+ page, html_path, out_png, width, height,
311
+ force=args.force, fonts_root=args.fonts_root,
312
+ )
313
+ async with print_lock:
314
+ print(f"[{idx}/{end}] {status} {pair_id} -> {out_png.name} ({width}x{height})", flush=True)
315
+ return status
316
+ except Exception as exc:
317
+ exc_name = type(exc).__name__
318
+ if browser is not None:
319
+ await browser_pool.discard(browser)
320
+ browser = None
321
+ if attempt == 0 and exc_name in {"TargetClosedError", "Error"}:
322
+ continue
323
+ (args.output_dir / f"{pair_id}.render_error.txt").write_text(repr(exc), encoding="utf-8")
324
+ async with print_lock:
325
+ print(f"[{idx}/{end}] FAIL {pair_id}: {exc!r}", flush=True)
326
+ return "failed"
327
+ finally:
328
+ if page is not None:
329
+ try:
330
+ await page.close()
331
+ except Exception:
332
+ pass
333
+ if browser is not None:
334
+ await browser_pool.release(browser)
335
+ browser = None
336
+ return "failed"
337
+
338
+
339
+ class BrowserPool:
340
+ def __init__(self, playwright: Any, launch_kwargs: Dict[str, Any], size: int) -> None:
341
+ self._playwright = playwright
342
+ self._launch_kwargs = launch_kwargs
343
+ self._size = max(1, size)
344
+ self._browsers: List[Any] = []
345
+ self._available: asyncio.Queue[Any] = asyncio.Queue()
346
+ self._lock = asyncio.Lock()
347
+
348
+ async def _launch(self) -> Any:
349
+ browser = await self._playwright.chromium.launch(**self._launch_kwargs)
350
+ self._browsers.append(browser)
351
+ return browser
352
+
353
+ async def start(self) -> None:
354
+ for _ in range(self._size):
355
+ await self._available.put(await self._launch())
356
+
357
+ async def acquire(self) -> Any:
358
+ return await self._available.get()
359
+
360
+ async def release(self, browser: Any) -> None:
361
+ try:
362
+ _ = browser.contexts
363
+ await self._available.put(browser)
364
+ except Exception:
365
+ await self.discard(browser)
366
+
367
+ async def discard(self, browser: Any) -> None:
368
+ try:
369
+ await browser.close()
370
+ except Exception:
371
+ pass
372
+ if browser in self._browsers:
373
+ self._browsers.remove(browser)
374
+ async with self._lock:
375
+ await self._available.put(await self._launch())
376
+
377
+ async def close(self) -> None:
378
+ for browser in list(self._browsers):
379
+ try:
380
+ await browser.close()
381
+ except Exception:
382
+ pass
383
+ self._browsers.clear()
384
+
385
+
386
+ async def async_main(args: argparse.Namespace) -> int:
387
+ deps_lib = Path.home() / "lib" / "playwright-deps" / "usr" / "lib" / "x86_64-linux-gnu"
388
+ if deps_lib.is_dir():
389
+ prev = os.environ.get("LD_LIBRARY_PATH", "")
390
+ os.environ["LD_LIBRARY_PATH"] = str(deps_lib) + (os.pathsep + prev if prev else "")
391
+
392
+ try:
393
+ from playwright.async_api import async_playwright
394
+ except ImportError as e:
395
+ raise ImportError("Please install playwright and browsers: pip install playwright && playwright install chromium") from e
396
+
397
+ pairs = read_pairs(args.metadata_json)
398
+ args.html_dir = args.html_dir.resolve()
399
+ args.output_dir = args.output_dir.resolve()
400
+ args.images_root = args.images_root.resolve()
401
+ end = len(pairs) if args.max_samples is None else min(len(pairs), args.start + args.max_samples)
402
+ subset = pairs[args.start:end]
403
+ args.output_dir.mkdir(parents=True, exist_ok=True)
404
+ if should_use_embedded_css():
405
+ get_embedded_font_css()
406
+
407
+ rendered = skipped = missing = failed = 0
408
+ workers = max(1, int(args.workers))
409
+ async with async_playwright() as p:
410
+ launch_kwargs = {"headless": True, "args": CHROMIUM_ARGS}
411
+ if args.browser_channel:
412
+ launch_kwargs["channel"] = args.browser_channel
413
+ pool = BrowserPool(p, launch_kwargs, workers)
414
+ await pool.start()
415
+ semaphore = asyncio.Semaphore(workers)
416
+ print_lock = asyncio.Lock()
417
+ print(
418
+ f"[render] workers={workers} browsers={workers} "
419
+ f"fonts_root={resolve_fonts_root(args.fonts_root)} "
420
+ f"samples={len(subset)}",
421
+ flush=True,
422
+ )
423
+ try:
424
+ results = await asyncio.gather(
425
+ *[
426
+ process_pair(pool, semaphore, print_lock, pair, i, end, args)
427
+ for i, pair in enumerate(subset, start=args.start + 1)
428
+ ]
429
+ )
430
+ finally:
431
+ await pool.close()
432
+
433
+ for status in results:
434
+ if status == "rendered":
435
+ rendered += 1
436
+ elif status == "skipped":
437
+ skipped += 1
438
+ elif status == "missing":
439
+ missing += 1
440
+ elif status == "failed":
441
+ failed += 1
442
+
443
+ summary = {
444
+ "total": len(subset),
445
+ "rendered": rendered,
446
+ "skipped_existing": skipped,
447
+ "missing_html": missing,
448
+ "failed": failed,
449
+ "workers": workers,
450
+ "html_dir": str(args.html_dir),
451
+ "output_dir": str(args.output_dir),
452
+ }
453
+ (args.output_dir / "render_summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
454
+ print(json.dumps(summary, ensure_ascii=False, indent=2))
455
+ return 0 if rendered or skipped else 1
456
+
457
+
458
+ def main() -> int:
459
+ ap = argparse.ArgumentParser()
460
+ ap.add_argument("--metadata-json", type=Path, default=DEFAULT_META)
461
+ ap.add_argument("--images-root", type=Path, default=DEFAULT_IMAGES)
462
+ ap.add_argument("--html-dir", type=Path, default=DEFAULT_HTML_DIR)
463
+ ap.add_argument("--output-dir", type=Path, default=DEFAULT_OUT_DIR)
464
+ ap.add_argument("--start", type=int, default=0)
465
+ ap.add_argument("--max-samples", type=int, default=None)
466
+ ap.add_argument("--width", type=int, default=None)
467
+ ap.add_argument("--height", type=int, default=None)
468
+ ap.add_argument("--device-scale-factor", type=float, default=1.0)
469
+ ap.add_argument("--browser-channel", type=str, default=None, help="Optional Playwright browser channel, e.g. chrome or msedge")
470
+ ap.add_argument("--fonts-root", type=Path, default=DEFAULT_FONTS_ROOT)
471
+ ap.add_argument("--workers", type=int, default=1, help="Parallel Playwright pages (default 1)")
472
+ ap.add_argument("--force", action="store_true")
473
+ args = ap.parse_args()
474
+ return asyncio.run(async_main(args))
475
+
476
+
477
+ if __name__ == "__main__":
478
+ raise SystemExit(main())