Spaces:
Running
Running
Commit ·
84b257f
1
Parent(s): 39494ee
Harden layout generation and image mapping
Browse files- screencoder/block_parsor.py +73 -3
- screencoder/html_generator.py +73 -9
- screencoder/image_box_detection.py +47 -20
- screencoder/image_replacer.py +8 -6
- screencoder/mapping.py +9 -1
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 = "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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}
|
|
|
|
| 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图标和原图一致。
|
| 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图标和原图一致。
|
| 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中的相对位置、文字排版、颜色需要在用户额外条件的基础上与原始截图基本
|
| 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代码以准确再现给定的容器。截图中
|
| 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 =
|
|
|
|
|
|
|
| 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 |
-
<
|
| 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 |
-
|
|
|
|
| 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 =
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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.
|
| 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 |
-
|
| 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 |
-
|
| 101 |
-
return [], [], 1280, 720
|
| 102 |
except Exception as install_error:
|
| 103 |
print(f"Failed to install browser dependencies: {install_error}")
|
| 104 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
|
| 126 |
for (const el of all_potential_placeholders) {
|
| 127 |
// Apply the same filters as before
|
| 128 |
-
if (el.tagName === 'SVG')
|
| 129 |
-
|
| 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 |
-
|
| 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}
|
| 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 |
-
|
| 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_='
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
|