Jimmyzheng-10 commited on
Commit
84b257f
·
1 Parent(s): 39494ee

Harden layout generation and image mapping

Browse files
screencoder/block_parsor.py CHANGED
@@ -12,9 +12,22 @@ PROMPT_LIST = [
12
  ("navigation", "Please output the minimum bounding box of the navigation. Please output the bounding box in the format of <bbox>x1 y1 x2 y2</bbox>. Avoid the blank space in the navigation."),
13
  ("main content", "Please output the minimum bounding box of the main content. Please output the bounding box in the format of <bbox>x1 y1 x2 y2</bbox>. Avoid the blank space in the main content."),
14
  ]
15
- PROMPT_MERGE = "Return the bounding boxes of the sidebar, main content, header, and navigation in this webpage screenshot. Please only return the corresponding bounding boxes. Note: 1. The areas should not overlap; 2. All text information and other content should be framed inside; 3. Try to keep it compact without leaving a lot of blank space; 4. Output a label and the corresponding bounding box for each line."
 
 
 
 
 
 
 
 
 
 
 
 
16
  BBOX_TAG_START = "<bbox>"
17
  BBOX_TAG_END = "</bbox>"
 
18
 
19
  def get_args():
20
  parser = argparse.ArgumentParser(description="Parses bounding boxes from an image using a vision model.")
@@ -62,6 +75,51 @@ def parse_bboxes(bbox_input: str) -> dict[str, tuple[int, int, int, int]]:
62
  print("Final parsed bboxes:", bboxes)
63
  return bboxes
64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  def draw_bboxes(image_path: str, bboxes: dict[str, tuple[int, int, int, int]], output_path: str) -> str:
66
  """Draws normalized (0-1000) bboxes on an image for visualization."""
67
  image = cv2.imread(image_path)
@@ -229,7 +287,8 @@ def main():
229
 
230
  image_path = os.path.join(tmp_dir, f"{run_id}.png")
231
  # api_path = os.path.join(base_dir, "doubao_api.txt")
232
- json_output_path = os.path.join(tmp_dir, f"{run_id}_bboxes.json")
 
233
  annotated_image_output_path = os.path.join(tmp_dir, f"{run_id}_with_bboxes.png")
234
 
235
  # Debug: Print all paths and check if files exist
@@ -250,6 +309,7 @@ def main():
250
  print(f"Error: Input image not found at {image_path}")
251
  # Create empty json file so the pipeline doesn't break
252
  save_bboxes_to_json({}, json_output_path)
 
253
  exit(1)
254
 
255
  api_key = os.environ.get('API_key')
@@ -257,6 +317,7 @@ def main():
257
  print(f"Error: API key not found in environment variable 'API_key'")
258
  # Create empty json file so the pipeline doesn't break
259
  save_bboxes_to_json({}, json_output_path)
 
260
  exit(1)
261
 
262
  print(f"--- Starting BBox Parsing for run_id: {run_id} ---")
@@ -268,23 +329,32 @@ def main():
268
  print(f"Error: Failed to encode image {image_path}")
269
  # Create empty json file so the pipeline doesn't break
270
  save_bboxes_to_json({}, json_output_path)
 
271
  exit(1)
272
 
273
  bbox_content = client.ask(PROMPT_MERGE, base64_image)
 
 
 
 
274
  bboxes = parse_bboxes(bbox_content)
275
 
276
  if bboxes:
 
 
277
  print("\n--- Resolving containment issues ---")
278
  bboxes = resolve_containment(bboxes)
279
  print("--- Containment resolved ---")
280
 
281
  print(f"\n--- Detection Complete for run_id: {run_id} ---")
282
  save_bboxes_to_json(bboxes, json_output_path)
 
283
  draw_bboxes(image_path, bboxes, annotated_image_output_path)
284
  else:
285
  print(f"\nNo valid bounding box coordinates found for run_id: {run_id}")
286
  # Still create an empty json file so the pipeline doesn't break
287
  save_bboxes_to_json({}, json_output_path)
 
288
 
289
  if __name__ == "__main__":
290
- main()
 
12
  ("navigation", "Please output the minimum bounding box of the navigation. Please output the bounding box in the format of <bbox>x1 y1 x2 y2</bbox>. Avoid the blank space in the navigation."),
13
  ("main content", "Please output the minimum bounding box of the main content. Please output the bounding box in the format of <bbox>x1 y1 x2 y2</bbox>. Avoid the blank space in the main content."),
14
  ]
15
+ PROMPT_MERGE = """Partition the entire webpage screenshot into exactly four rectangular regions: sidebar, header, navigation, and main content.
16
+
17
+ Use a normalized 0-1000 coordinate system, where the top-left corner of the full screenshot is (0, 0) and the bottom-right corner is (1000, 1000).
18
+
19
+ Treat this as a gap-free page partition rather than four independent object detections:
20
+ 1. The four regions must not overlap.
21
+ 2. Their union must cover the entire visible webpage.
22
+ 3. Adjacent regions must share exact boundaries, with no uncovered gaps between them.
23
+ 4. Extend each region through internal whitespace until it reaches the neighboring region. Do not crop tightly around only the visible text, icons, or controls.
24
+ 5. Every visible text item, icon, image, control, and UI component must belong to one of the four regions.
25
+ 6. Return exactly four lines and no explanation. Use this exact format for every line:
26
+ label: <bbox>x1 y1 x2 y2</bbox>
27
+ """
28
  BBOX_TAG_START = "<bbox>"
29
  BBOX_TAG_END = "</bbox>"
30
+ EXPECTED_REGION_NAMES = {"sidebar", "header", "navigation", "main content"}
31
 
32
  def get_args():
33
  parser = argparse.ArgumentParser(description="Parses bounding boxes from an image using a vision model.")
 
75
  print("Final parsed bboxes:", bboxes)
76
  return bboxes
77
 
78
+
79
+ def normalize_gap_free_partition(
80
+ bboxes: dict[str, tuple[int, int, int, int]], edge_tolerance: int = 1
81
+ ) -> dict[str, tuple[int, int, int, int]]:
82
+ """Snap tiny outer-edge drift, then require an exact four-box page partition."""
83
+ if set(bboxes) != EXPECTED_REGION_NAMES:
84
+ raise ValueError(
85
+ f"gap-free partition requires exactly {sorted(EXPECTED_REGION_NAMES)}"
86
+ )
87
+
88
+ normalized = {}
89
+ for name, (x1, y1, x2, y2) in bboxes.items():
90
+ if 0 <= x1 <= edge_tolerance:
91
+ x1 = 0
92
+ if 0 <= y1 <= edge_tolerance:
93
+ y1 = 0
94
+ if 1000 - edge_tolerance <= x2 <= 1000:
95
+ x2 = 1000
96
+ if 1000 - edge_tolerance <= y2 <= 1000:
97
+ y2 = 1000
98
+ if not (0 <= x1 < x2 <= 1000 and 0 <= y1 < y2 <= 1000):
99
+ raise ValueError(f"gap-free partition has invalid box for {name}")
100
+ normalized[name] = (x1, y1, x2, y2)
101
+
102
+ boxes = list(normalized.items())
103
+ for index, (name_a, box_a) in enumerate(boxes):
104
+ ax1, ay1, ax2, ay2 = box_a
105
+ for name_b, box_b in boxes[index + 1 :]:
106
+ bx1, by1, bx2, by2 = box_b
107
+ overlap_w = max(0, min(ax2, bx2) - max(ax1, bx1))
108
+ overlap_h = max(0, min(ay2, by2) - max(ay1, by1))
109
+ if overlap_w * overlap_h > 0:
110
+ raise ValueError(
111
+ f"gap-free partition overlaps: {name_a} and {name_b}"
112
+ )
113
+
114
+ total_area = sum(
115
+ (x2 - x1) * (y2 - y1) for x1, y1, x2, y2 in normalized.values()
116
+ )
117
+ if total_area != 1_000_000:
118
+ raise ValueError(
119
+ f"gap-free partition covers {total_area}/1000000 normalized cells"
120
+ )
121
+ return normalized
122
+
123
  def draw_bboxes(image_path: str, bboxes: dict[str, tuple[int, int, int, int]], output_path: str) -> str:
124
  """Draws normalized (0-1000) bboxes on an image for visualization."""
125
  image = cv2.imread(image_path)
 
287
 
288
  image_path = os.path.join(tmp_dir, f"{run_id}.png")
289
  # api_path = os.path.join(base_dir, "doubao_api.txt")
290
+ json_output_path = os.path.join(tmp_dir, f"{run_id}_region_bboxes.json")
291
+ legacy_json_output_path = os.path.join(tmp_dir, f"{run_id}_bboxes.json")
292
  annotated_image_output_path = os.path.join(tmp_dir, f"{run_id}_with_bboxes.png")
293
 
294
  # Debug: Print all paths and check if files exist
 
309
  print(f"Error: Input image not found at {image_path}")
310
  # Create empty json file so the pipeline doesn't break
311
  save_bboxes_to_json({}, json_output_path)
312
+ save_bboxes_to_json({}, legacy_json_output_path)
313
  exit(1)
314
 
315
  api_key = os.environ.get('API_key')
 
317
  print(f"Error: API key not found in environment variable 'API_key'")
318
  # Create empty json file so the pipeline doesn't break
319
  save_bboxes_to_json({}, json_output_path)
320
+ save_bboxes_to_json({}, legacy_json_output_path)
321
  exit(1)
322
 
323
  print(f"--- Starting BBox Parsing for run_id: {run_id} ---")
 
329
  print(f"Error: Failed to encode image {image_path}")
330
  # Create empty json file so the pipeline doesn't break
331
  save_bboxes_to_json({}, json_output_path)
332
+ save_bboxes_to_json({}, legacy_json_output_path)
333
  exit(1)
334
 
335
  bbox_content = client.ask(PROMPT_MERGE, base64_image)
336
+ raw_response_path = os.path.join(tmp_dir, f"{run_id}_bbox_raw.txt")
337
+ with open(raw_response_path, "w", encoding="utf-8") as f:
338
+ f.write(bbox_content or "")
339
+ print(f"Raw bbox response saved to: {raw_response_path}")
340
  bboxes = parse_bboxes(bbox_content)
341
 
342
  if bboxes:
343
+ bboxes = normalize_gap_free_partition(bboxes)
344
+ print("Validated exact gap-free 0-1000 partition.")
345
  print("\n--- Resolving containment issues ---")
346
  bboxes = resolve_containment(bboxes)
347
  print("--- Containment resolved ---")
348
 
349
  print(f"\n--- Detection Complete for run_id: {run_id} ---")
350
  save_bboxes_to_json(bboxes, json_output_path)
351
+ save_bboxes_to_json(bboxes, legacy_json_output_path)
352
  draw_bboxes(image_path, bboxes, annotated_image_output_path)
353
  else:
354
  print(f"\nNo valid bounding box coordinates found for run_id: {run_id}")
355
  # Still create an empty json file so the pipeline doesn't break
356
  save_bboxes_to_json({}, json_output_path)
357
+ save_bboxes_to_json({}, legacy_json_output_path)
358
 
359
  if __name__ == "__main__":
360
+ main()
screencoder/html_generator.py CHANGED
@@ -9,6 +9,8 @@ import os
9
 
10
  # This dictionary holds the user's instructions for the current run.
11
  user_instruction = {"sidebar": "", "header": "", "navigation": "", "main content": ""}
 
 
12
 
13
  def get_args():
14
  parser = argparse.ArgumentParser(description="Generates an HTML layout from bounding box data.")
@@ -18,6 +20,13 @@ def get_args():
18
 
19
  def get_prompt_dict(instructions):
20
  """Dynamically creates the prompt dictionary with the user's instructions."""
 
 
 
 
 
 
 
21
  # return {
22
  # "sidebar": f"""This is a screenshot of a container. Please fill in a complete HTML and tail-wind CSS code to accurately reproduce the given container. Please note that the layout, icon style, size, and text information of all blocks need to be basically consistent with the original screenshot based on the user's additional conditions. User instruction: {instructions["sidebar"]}. The following is the code for filling in:
23
  # <div>
@@ -41,7 +50,7 @@ def get_prompt_dict(instructions):
41
  # only return the code within the <div> and </div> tags""",
42
  # }
43
  return {
44
- "sidebar": f"""这是一个container的截图。这是用户给的额外要求:{instructions["sidebar"]}请填写一段完整的HTML和tail-wind CSS代码以准确再现给定的容器。请注意所有组块的排版、图标样式、大小、文字信息需要在用户额外条件的基础上与原始截图基本保持一致。对于小图标,请保持生成的svg图标和原图一致。请使用相同大小、位置的纯灰色图像块并标记为"bg-gray-400"代替大的图片,注意代码中的大小和位置信息。以下是供填写的代码:
45
 
46
  <div>
47
  your code here
@@ -49,7 +58,7 @@ def get_prompt_dict(instructions):
49
 
50
  只需返回<div>和</div>标签内的代码""",
51
 
52
- "header": f"""这是一个container的截图。这是用户给的额外要求:{instructions["header"]}请填写一段完整的HTML和tail-wind CSS代码以准确再现给定的容器。请注意所有组块在boundary box中的相对位置、排版、文字信息、颜色需要在用户额外条件的基础上与原始截图基本保持一致。请保持生成的svg图标和原图一致。请使用相同大小、位置的纯灰色图像块并标记为"bg-gray-400"代替图片,注意代码中的大小和位置信息。以下是供填写的代码:
53
 
54
  <div>
55
  your code here
@@ -57,7 +66,7 @@ def get_prompt_dict(instructions):
57
 
58
  只需返回<div>和</div>标签内的代码""",
59
 
60
- "navigation": f"""这是一个container的截图。这是用户给的额外要求:{instructions["navigation"]}请填写一段完整的HTML和tail-wind CSS代码以准确再现给定的容器。请注意所有组块的在boundary box中的相对位置、文字排版、颜色需要在用户额外条件的基础上与原始截图基本保持一致。图像请你直接使用原始截图中一致的图标。请使用相同大小、位置的纯灰色图像块并标记为"bg-gray-400"代替图片,注意代码中的大小和位置信息。以下是供填写的代码:
61
 
62
  <div>
63
  your code here
@@ -65,7 +74,7 @@ def get_prompt_dict(instructions):
65
 
66
  只需返回<div>和</div>标签内的代码""",
67
 
68
- "main content": f"""这是一个container的截图。这是用户给的额外要求:{instructions["main content"]}请填写一段完整的HTML和tail-wind CSS代码以准确再现给定的容器。截图中显示的图像请使用相同大小、位置的纯灰色图像块并标记为"bg-gray-400"代替图片,注意代码中其大小和位置信息,不需要识别其中的文字信息。请注意所有组块在boundary box中的相对位置、排版、文字信息、颜色需要在用户额外条件的基础上与原始截图基本保持一致。以下是供填写的代码:
69
 
70
  <div>
71
  your code here
@@ -74,6 +83,31 @@ def get_prompt_dict(instructions):
74
  只需返回<div>和</div>标签内的代码"""
75
  }
76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  def generate_code(bbox_tree, img_path, bot, instructions):
78
  """Generates code for each leaf node in the bounding box tree."""
79
  img = Image.open(img_path)
@@ -87,7 +121,9 @@ def generate_code(bbox_tree, img_path, bot, instructions):
87
 
88
  node_type = node.get("type")
89
  if node_type and node_type in prompt_dict:
90
- prompt = prompt_dict[node_type]
 
 
91
  try:
92
  code = bot.ask(prompt, encode_image(cropped_img))
93
  code_dict[node["id"]] = code
@@ -133,8 +169,16 @@ def generate_html(bbox_tree, output_file):
133
  width: 100%;
134
  height: 100%;
135
  }
 
 
 
 
 
 
 
 
136
  </style>
137
- <link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
138
  </head>
139
  <body>
140
  <div class="container">
@@ -156,7 +200,8 @@ def generate_html(bbox_tree, output_file):
156
  width = (bbox[2] - bbox[0]) / parent_width * 100
157
  height = (bbox[3] - bbox[1]) / parent_height * 100
158
 
159
- html = f'<div id="{node_id}" class="box" style="left: {left}%; top: {top}%; width: {width}%; height: {height}%;">'
 
160
  if children:
161
  html += '<div class="container">'
162
  current_width = bbox[2] - bbox[0]
@@ -195,7 +240,9 @@ def generate_code_parallel(bbox_tree, img_path, bot, instructions):
195
  # Select prompt based on node type
196
  if "type" in node:
197
  if node["type"] in prompt_dict:
198
- prompt = prompt_dict[node["type"]]
 
 
199
  else:
200
  print(f"Unknown component type: {node['type']}")
201
  code_dict[node["id"]] = f"<!-- Unknown component type: {node['type']} -->"
@@ -265,7 +312,9 @@ def main():
265
  output_dir = os.path.join(base_dir, 'data', 'output', args.run_id)
266
  os.makedirs(output_dir, exist_ok=True)
267
 
268
- input_json_path = os.path.join(tmp_dir, f"{args.run_id}_bboxes.json")
 
 
269
  img_path = os.path.join(tmp_dir, f"{args.run_id}.png")
270
  output_html_path = os.path.join(output_dir, f"{args.run_id}_layout.html")
271
 
@@ -309,6 +358,21 @@ def main():
309
  # Model id is resolved by Doubao from ARK_MODEL_ID, with a safe default fallback.
310
  bot = Doubao(api_key)
311
  code_dict = generate_code_parallel(root, img_path, bot, user_instruction)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
  code_substitution(output_html_path, code_dict)
313
 
314
  print(f"HTML layout with generated content saved to {os.path.basename(output_html_path)}")
 
9
 
10
  # This dictionary holds the user's instructions for the current run.
11
  user_instruction = {"sidebar": "", "header": "", "navigation": "", "main content": ""}
12
+ TARGET_VIEWPORT_WIDTH = 1280
13
+ TARGET_VIEWPORT_HEIGHT = 720
14
 
15
  def get_args():
16
  parser = argparse.ArgumentParser(description="Generates an HTML layout from bounding box data.")
 
20
 
21
  def get_prompt_dict(instructions):
22
  """Dynamically creates the prompt dictionary with the user's instructions."""
23
+ image_contract = """
24
+ 对于截图中需要从原图裁剪并回填的真实位图(例如视频缩略图、照片、头像),请使用同时带有
25
+ \"screencoder-image-placeholder bg-gray-400\" 两个 class 的纯灰色占位元素,并为它设置明确、非零的宽度和高度。
26
+ 不要将纯装饰色块、背景、分割线、按钮或 SVG 图标标记为 screencoder-image-placeholder。
27
+ 生成内容会被放入当前区域的边界框中:请优先使用 w-full、h-full、flex、grid 和相对尺寸,避免根据原始截图像素写入夸张的固定宽高或字号。
28
+ 可以使用 Tailwind CSS 3 的 utility class。所有元素必须留在当前容器内,不得溢出。
29
+ """
30
  # return {
31
  # "sidebar": f"""This is a screenshot of a container. Please fill in a complete HTML and tail-wind CSS code to accurately reproduce the given container. Please note that the layout, icon style, size, and text information of all blocks need to be basically consistent with the original screenshot based on the user's additional conditions. User instruction: {instructions["sidebar"]}. The following is the code for filling in:
32
  # <div>
 
50
  # only return the code within the <div> and </div> tags""",
51
  # }
52
  return {
53
+ "sidebar": f"""这是一个container的截图。这是用户给的额外要求:{instructions["sidebar"]}请填写一段完整的HTML和tail-wind CSS代码以准确再现给定的容器。请注意所有组块的排版、图标样式、大小、文字信息需要在用户额外条件的基础上与原始截图基本保持一致。对于小图标,请保持生成的svg图标和原图一致。{image_contract}以下是供填写的代码:
54
 
55
  <div>
56
  your code here
 
58
 
59
  只需返回<div>和</div>标签内的代码""",
60
 
61
+ "header": f"""这是一个container的截图。这是用户给的额外要求:{instructions["header"]}请填写一段完整的HTML和tail-wind CSS代码以准确再现给定的容器。请注意所有组块在boundary box中的相对位置、排版、文字信息、颜色需要在用户额外条件的基础上与原始截图基本保持一致。请保持生成的svg图标和原图一致。{image_contract}以下是供填写的代码:
62
 
63
  <div>
64
  your code here
 
66
 
67
  只需返回<div>和</div>标签内的代码""",
68
 
69
+ "navigation": f"""这是一个container的截图。这是用户给的额外要求:{instructions["navigation"]}请填写一段完整的HTML和tail-wind CSS代码以准确再现给定的容器。请注意所有组块的在boundary box中的相对位置、文字排版、颜色需要在用户额外条件的基础上与原始截图基本���持一致。图像请你直接使用原始截图中一致的图标。{image_contract}以下是供填写的代码:
70
 
71
  <div>
72
  your code here
 
74
 
75
  只需返回<div>和</div>标签内的代码""",
76
 
77
+ "main content": f"""这是一个container的截图。这是用户给的额外要求:{instructions["main content"]}请填写一段完整的HTML和tail-wind CSS代码以准确再现给定的容器。{image_contract}截图中的位图内容不需要识别其中的文字信息。请注意所有组块在boundary box中的相对位置、排版、文字信息、颜色需要在用户额外条件的基础上与原始截图基本保持一致。以下是供填写的代码:
78
 
79
  <div>
80
  your code here
 
83
  只需返回<div>和</div>标签内的代码"""
84
  }
85
 
86
+
87
+ def add_render_geometry_contract(prompt, bbox, root_bbox):
88
+ """Tell the model the CSS-pixel box its crop will occupy in the final page."""
89
+ root_width = max(1, root_bbox[2] - root_bbox[0])
90
+ root_height = max(1, root_bbox[3] - root_bbox[1])
91
+ target_width = max(
92
+ 1, round((bbox[2] - bbox[0]) / root_width * TARGET_VIEWPORT_WIDTH)
93
+ )
94
+ target_height = max(
95
+ 1, round((bbox[3] - bbox[1]) / root_height * TARGET_VIEWPORT_HEIGHT)
96
+ )
97
+ max_icon = max(12, min(24, target_width // 2, int(target_height * 0.67)))
98
+ max_font = max(10, min(16, target_width // 4, target_height // 3))
99
+ max_padding = max(1, min(8, target_width // 10, target_height // 6))
100
+
101
+ return f"""{prompt}
102
+
103
+ 【最终渲染几何约束】
104
+ 当前区域在最终 1280×720 viewport 中只有约 {target_width}×{target_height} CSS px。
105
+ 必须按照这个最终尺寸设计,不要按输入裁剪图的原始像素尺寸设计。
106
+ 最外层必须使用 w-full h-full overflow-hidden box-border;所有后代元素都必须留在区域边界内。
107
+ 最大图标边长 {max_icon}px,最大字号 {max_font}px,单侧 padding 不超过 {max_padding}px;必要时使用 Tailwind 3 arbitrary values(如 w-[20px]、text-[11px])。
108
+ 如果空间不足,优先省略次要文字或减少间距,禁止通过溢出、超大固定尺寸或超出边界来容纳内容。
109
+ """
110
+
111
  def generate_code(bbox_tree, img_path, bot, instructions):
112
  """Generates code for each leaf node in the bounding box tree."""
113
  img = Image.open(img_path)
 
121
 
122
  node_type = node.get("type")
123
  if node_type and node_type in prompt_dict:
124
+ prompt = add_render_geometry_contract(
125
+ prompt_dict[node_type], bbox, bbox_tree["bbox"]
126
+ )
127
  try:
128
  code = bot.ask(prompt, encode_image(cropped_img))
129
  code_dict[node["id"]] = code
 
169
  width: 100%;
170
  height: 100%;
171
  }
172
+ .box[data-region-type="header"],
173
+ .box[data-region-type="navigation"] {
174
+ line-height: 1 !important;
175
+ }
176
+ .box[data-region-type="header"] *,
177
+ .box[data-region-type="navigation"] * {
178
+ line-height: 1 !important;
179
+ }
180
  </style>
181
+ <script src="https://cdn.tailwindcss.com"></script>
182
  </head>
183
  <body>
184
  <div class="container">
 
200
  width = (bbox[2] - bbox[0]) / parent_width * 100
201
  height = (bbox[3] - bbox[1]) / parent_height * 100
202
 
203
+ region_type = node.get("type", "container")
204
+ html = f'<div id="{node_id}" data-region-type="{region_type}" class="box" style="left: {left}%; top: {top}%; width: {width}%; height: {height}%;">'
205
  if children:
206
  html += '<div class="container">'
207
  current_width = bbox[2] - bbox[0]
 
240
  # Select prompt based on node type
241
  if "type" in node:
242
  if node["type"] in prompt_dict:
243
+ prompt = add_render_geometry_contract(
244
+ prompt_dict[node["type"]], bbox, bbox_tree["bbox"]
245
+ )
246
  else:
247
  print(f"Unknown component type: {node['type']}")
248
  code_dict[node["id"]] = f"<!-- Unknown component type: {node['type']} -->"
 
312
  output_dir = os.path.join(base_dir, 'data', 'output', args.run_id)
313
  os.makedirs(output_dir, exist_ok=True)
314
 
315
+ region_json_path = os.path.join(tmp_dir, f"{args.run_id}_region_bboxes.json")
316
+ legacy_json_path = os.path.join(tmp_dir, f"{args.run_id}_bboxes.json")
317
+ input_json_path = region_json_path if os.path.exists(region_json_path) else legacy_json_path
318
  img_path = os.path.join(tmp_dir, f"{args.run_id}.png")
319
  output_html_path = os.path.join(output_dir, f"{args.run_id}_layout.html")
320
 
 
358
  # Model id is resolved by Doubao from ARK_MODEL_ID, with a safe default fallback.
359
  bot = Doubao(api_key)
360
  code_dict = generate_code_parallel(root, img_path, bot, user_instruction)
361
+ raw_generation_path = os.path.join(
362
+ output_dir, f"{args.run_id}_generation_raw.json"
363
+ )
364
+ with open(raw_generation_path, "w", encoding="utf-8") as f:
365
+ json.dump(
366
+ {
367
+ "model": bot.model,
368
+ "instructions": user_instruction,
369
+ "responses": code_dict,
370
+ },
371
+ f,
372
+ indent=2,
373
+ ensure_ascii=False,
374
+ )
375
+ print(f"Raw region generations saved to: {raw_generation_path}")
376
  code_substitution(output_html_path, code_dict)
377
 
378
  print(f"HTML layout with generated content saved to {os.path.basename(output_html_path)}")
screencoder/image_box_detection.py CHANGED
@@ -59,7 +59,7 @@ def extract_bboxes_from_html_fallback(html_path: Path):
59
 
60
  # Extract placeholder bboxes
61
  placeholder_bboxes = []
62
- placeholder_images = soup.find_all(class_='bg-gray-400')
63
  for i, img in enumerate(placeholder_images):
64
  # For fallback, we'll use a simple approach
65
  # In a real scenario, you'd need to parse the actual layout
@@ -75,8 +75,7 @@ def extract_bboxes_from_html_fallback(html_path: Path):
75
  return region_bboxes, placeholder_bboxes, 1280, 720
76
 
77
  except Exception as e:
78
- print(f"Error in fallback HTML parsing: {e}")
79
- return [], [], 1280, 720
80
 
81
  # ---------- Main logic ----------
82
  async def extract_bboxes_from_html(html_path: Path):
@@ -97,19 +96,17 @@ async def extract_bboxes_from_html(html_path: Path):
97
  browser = await p.chromium.launch(headless=True)
98
  else:
99
  print(f"Failed to install browser dependencies: {result.stderr}")
100
- # Return empty results to continue the pipeline
101
- return [], [], 1280, 720
102
  except Exception as install_error:
103
  print(f"Failed to install browser dependencies: {install_error}")
104
- # Return empty results to continue the pipeline
105
- return [], [], 1280, 720
106
 
107
  try:
108
  ctx = await browser.new_context(
109
  viewport={"width": 1280, "height": 720},
110
  )
111
  page = await ctx.new_page()
112
- await page.goto(html_path.resolve().as_uri())
113
 
114
  metrics = await page.evaluate("""
115
  () => {
@@ -121,14 +118,26 @@ async def extract_bboxes_from_html(html_path: Path):
121
 
122
  const placeholder_bboxes = [];
123
  let ph_id_counter = 0;
124
- const all_potential_placeholders = document.querySelectorAll('.bg-gray-400');
 
 
 
 
 
 
 
125
 
126
  for (const el of all_potential_placeholders) {
127
  // Apply the same filters as before
128
- if (el.tagName === 'SVG') continue;
129
- if (el.innerText && el.innerText.trim() !== '') continue;
130
-
 
131
  const el_rect = el.getBoundingClientRect();
 
 
 
 
132
  const el_center = { x: el_rect.left + el_rect.width / 2, y: el_rect.top + el_rect.height / 2 };
133
 
134
  // Find which region this placeholder is inside
@@ -151,6 +160,8 @@ async def extract_bboxes_from_html(html_path: Path):
151
  h: el_rect.height,
152
  region_id: containing_region_id
153
  });
 
 
154
  }
155
  }
156
 
@@ -159,17 +170,36 @@ async def extract_bboxes_from_html(html_path: Path):
159
  region_bboxes,
160
  placeholder_bboxes,
161
  layout_width: layout_rect.width,
162
- layout_height: layout_rect.height
 
 
 
 
163
  };
164
  }
165
  """)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  await browser.close()
167
  return metrics['region_bboxes'], metrics['placeholder_bboxes'], metrics['layout_width'], metrics['layout_height']
168
  except Exception as e:
169
  print(f"Error during browser operation: {e}")
170
  await browser.close()
171
- # Return empty results to continue the pipeline
172
- return [], [], 1280, 720
173
 
174
 
175
  def draw_bboxes_on_image(img, region_bboxes, placeholder_bboxes):
@@ -225,7 +255,7 @@ def main():
225
 
226
  html_path = output_dir / f"{run_id}_layout.html"
227
  screenshot_path = tmp_dir / f"{run_id}.png"
228
- output_json_path = tmp_dir / f"{run_id}_bboxes.json"
229
  debug_image_path = tmp_dir / f"debug_gray_bboxes_{run_id}.png"
230
 
231
  if not html_path.exists():
@@ -251,10 +281,7 @@ def main():
251
  )
252
  print("Successfully extracted bboxes using Playwright")
253
  except Exception as e:
254
- print(f"Playwright failed: {e}")
255
- print("Falling back to HTML parsing method...")
256
- region_bboxes, placeholder_bboxes, layout_width, layout_height = extract_bboxes_from_html_fallback(html_path)
257
- print("Successfully extracted bboxes using fallback method")
258
 
259
  if not placeholder_bboxes:
260
  # This is not necessarily an error; some UIs might not have placeholders.
 
59
 
60
  # Extract placeholder bboxes
61
  placeholder_bboxes = []
62
+ placeholder_images = soup.select('.screencoder-image-placeholder')
63
  for i, img in enumerate(placeholder_images):
64
  # For fallback, we'll use a simple approach
65
  # In a real scenario, you'd need to parse the actual layout
 
75
  return region_bboxes, placeholder_bboxes, 1280, 720
76
 
77
  except Exception as e:
78
+ raise RuntimeError(f"Fallback HTML parsing failed: {e}") from e
 
79
 
80
  # ---------- Main logic ----------
81
  async def extract_bboxes_from_html(html_path: Path):
 
96
  browser = await p.chromium.launch(headless=True)
97
  else:
98
  print(f"Failed to install browser dependencies: {result.stderr}")
99
+ raise RuntimeError(f"Playwright browser install failed: {result.stderr}")
 
100
  except Exception as install_error:
101
  print(f"Failed to install browser dependencies: {install_error}")
102
+ raise RuntimeError(f"Playwright browser setup failed: {install_error}") from install_error
 
103
 
104
  try:
105
  ctx = await browser.new_context(
106
  viewport={"width": 1280, "height": 720},
107
  )
108
  page = await ctx.new_page()
109
+ await page.goto(html_path.resolve().as_uri(), wait_until="networkidle")
110
 
111
  metrics = await page.evaluate("""
112
  () => {
 
118
 
119
  const placeholder_bboxes = [];
120
  let ph_id_counter = 0;
121
+ const dedicated = document.querySelectorAll('.screencoder-image-placeholder');
122
+ const all_potential_placeholders = dedicated.length
123
+ ? dedicated
124
+ : document.querySelectorAll('.bg-gray-400');
125
+ const candidate_placeholder_count = all_potential_placeholders.length;
126
+ let invalid_placeholder_count = 0;
127
+ let skipped_svg_count = 0;
128
+ let outside_region_count = 0;
129
 
130
  for (const el of all_potential_placeholders) {
131
  // Apply the same filters as before
132
+ if (el.tagName === 'SVG') {
133
+ skipped_svg_count += 1;
134
+ continue;
135
+ }
136
  const el_rect = el.getBoundingClientRect();
137
+ if (el_rect.width <= 0 || el_rect.height <= 0) {
138
+ invalid_placeholder_count += 1;
139
+ continue;
140
+ }
141
  const el_center = { x: el_rect.left + el_rect.width / 2, y: el_rect.top + el_rect.height / 2 };
142
 
143
  // Find which region this placeholder is inside
 
160
  h: el_rect.height,
161
  region_id: containing_region_id
162
  });
163
+ } else {
164
+ outside_region_count += 1;
165
  }
166
  }
167
 
 
170
  region_bboxes,
171
  placeholder_bboxes,
172
  layout_width: layout_rect.width,
173
+ layout_height: layout_rect.height,
174
+ candidate_placeholder_count,
175
+ invalid_placeholder_count,
176
+ skipped_svg_count,
177
+ outside_region_count
178
  };
179
  }
180
  """)
181
+ if metrics['invalid_placeholder_count'] > 0:
182
+ raise RuntimeError(
183
+ f"{metrics['invalid_placeholder_count']} placeholder(s) have zero rendered size"
184
+ )
185
+ if (
186
+ metrics['candidate_placeholder_count'] != len(metrics['placeholder_bboxes'])
187
+ or metrics['skipped_svg_count'] > 0
188
+ or metrics['outside_region_count'] > 0
189
+ ):
190
+ raise RuntimeError(
191
+ "Placeholder contract violation: "
192
+ f"candidates={metrics['candidate_placeholder_count']}, "
193
+ f"detected={len(metrics['placeholder_bboxes'])}, "
194
+ f"svg={metrics['skipped_svg_count']}, "
195
+ f"outside_region={metrics['outside_region_count']}"
196
+ )
197
  await browser.close()
198
  return metrics['region_bboxes'], metrics['placeholder_bboxes'], metrics['layout_width'], metrics['layout_height']
199
  except Exception as e:
200
  print(f"Error during browser operation: {e}")
201
  await browser.close()
202
+ raise RuntimeError(f"Playwright rendering failed: {e}") from e
 
203
 
204
 
205
  def draw_bboxes_on_image(img, region_bboxes, placeholder_bboxes):
 
255
 
256
  html_path = output_dir / f"{run_id}_layout.html"
257
  screenshot_path = tmp_dir / f"{run_id}.png"
258
+ output_json_path = tmp_dir / f"{run_id}_placeholder_bboxes.json"
259
  debug_image_path = tmp_dir / f"debug_gray_bboxes_{run_id}.png"
260
 
261
  if not html_path.exists():
 
281
  )
282
  print("Successfully extracted bboxes using Playwright")
283
  except Exception as e:
284
+ sys.exit(f"Error: Playwright bbox extraction failed: {e}")
 
 
 
285
 
286
  if not placeholder_bboxes:
287
  # This is not necessarily an error; some UIs might not have placeholders.
screencoder/image_replacer.py CHANGED
@@ -99,7 +99,10 @@ def main():
99
  soup = BeautifulSoup(html_content, 'html.parser')
100
 
101
  # 1. Find all placeholder elements by their class, in document order.
102
- placeholder_elements = soup.find_all(class_='bg-gray-400')
 
 
 
103
 
104
  # 2. Get the placeholder IDs from the mapping file in the correct, sorted order.
105
  def natural_sort_key(s):
@@ -115,16 +118,15 @@ def main():
115
 
116
  # 3. Check for count mismatches
117
  if len(placeholder_elements) != len(ordered_placeholder_ids):
118
- print(f"Warning: Mismatch in counts! Found {len(placeholder_elements)} placeholder images in HTML, but {len(ordered_placeholder_ids)} mappings.")
 
 
 
119
  else:
120
  print(f"Found {len(placeholder_elements)} placeholder images to replace.")
121
 
122
  # 4. Iterate through both lists, create a proper <img> tag, and replace the placeholder.
123
  for i, ph_element in enumerate(placeholder_elements):
124
- if i >= len(ordered_placeholder_ids):
125
- print(f"Warning: More placeholder images in HTML than mappings. Stopping at image {i+1}.")
126
- break
127
-
128
  ph_id = ordered_placeholder_ids[i]
129
  # Fix: Use the correct relative path from HTML file to image directory
130
  relative_img_path = f"{crop_dir.name}/{ph_id}.png"
 
99
  soup = BeautifulSoup(html_content, 'html.parser')
100
 
101
  # 1. Find all placeholder elements by their class, in document order.
102
+ placeholder_elements = soup.find_all(class_='screencoder-image-placeholder')
103
+ if not placeholder_elements:
104
+ # Compatibility for artifacts generated before the dedicated marker existed.
105
+ placeholder_elements = soup.find_all(class_='bg-gray-400')
106
 
107
  # 2. Get the placeholder IDs from the mapping file in the correct, sorted order.
108
  def natural_sort_key(s):
 
118
 
119
  # 3. Check for count mismatches
120
  if len(placeholder_elements) != len(ordered_placeholder_ids):
121
+ sys.exit(
122
+ "Error: Placeholder/mapping count mismatch: "
123
+ f"html={len(placeholder_elements)}, mappings={len(ordered_placeholder_ids)}"
124
+ )
125
  else:
126
  print(f"Found {len(placeholder_elements)} placeholder images to replace.")
127
 
128
  # 4. Iterate through both lists, create a proper <img> tag, and replace the placeholder.
129
  for i, ph_element in enumerate(placeholder_elements):
 
 
 
 
130
  ph_id = ordered_placeholder_ids[i]
131
  # Fix: Use the correct relative path from HTML file to image directory
132
  relative_img_path = f"{crop_dir.name}/{ph_id}.png"
screencoder/mapping.py CHANGED
@@ -235,7 +235,9 @@ def main():
235
  base_dir = Path(__file__).parent.resolve()
236
  tmp_dir = base_dir / 'data' / 'tmp' / run_id
237
 
238
- gray_json_path = tmp_dir / f"{run_id}_bboxes.json"
 
 
239
  uied_json_path = tmp_dir / "ip" / f"{run_id}.json"
240
  mapping_output_path = tmp_dir / f"mapping_full_{run_id}.json"
241
  debug_src_path = tmp_dir / f"{run_id}.png"
@@ -314,6 +316,12 @@ def main():
314
  # 6. Report and save results
315
  print(f"Successfully created {total_mappings_count} one-to-one mappings out of {total_placeholders_count} placeholders.")
316
 
 
 
 
 
 
 
317
  mapping_output_path.write_text(json.dumps(final_results, indent=2, ensure_ascii=False))
318
  print(f"Mapping data written to {mapping_output_path}")
319
 
 
235
  base_dir = Path(__file__).parent.resolve()
236
  tmp_dir = base_dir / 'data' / 'tmp' / run_id
237
 
238
+ placeholder_json_path = tmp_dir / f"{run_id}_placeholder_bboxes.json"
239
+ legacy_json_path = tmp_dir / f"{run_id}_bboxes.json"
240
+ gray_json_path = placeholder_json_path if placeholder_json_path.exists() else legacy_json_path
241
  uied_json_path = tmp_dir / "ip" / f"{run_id}.json"
242
  mapping_output_path = tmp_dir / f"mapping_full_{run_id}.json"
243
  debug_src_path = tmp_dir / f"{run_id}.png"
 
316
  # 6. Report and save results
317
  print(f"Successfully created {total_mappings_count} one-to-one mappings out of {total_placeholders_count} placeholders.")
318
 
319
+ if total_mappings_count != total_placeholders_count:
320
+ raise RuntimeError(
321
+ "Incomplete placeholder mapping: "
322
+ f"mapped={total_mappings_count}, placeholders={total_placeholders_count}"
323
+ )
324
+
325
  mapping_output_path.write_text(json.dumps(final_results, indent=2, ensure_ascii=False))
326
  print(f"Mapping data written to {mapping_output_path}")
327