Ray1ee01 commited on
Commit
2cf467c
·
verified ·
1 Parent(s): 520da1b

Upload folder using huggingface_hub

Browse files
Files changed (47) hide show
  1. modules/infographics_generator/__init__.py +16 -0
  2. modules/infographics_generator/build_scene_tree_v3.py +0 -0
  3. modules/infographics_generator/chat_utils.py +54 -0
  4. modules/infographics_generator/color_utils.py +334 -0
  5. modules/infographics_generator/data_utils.py +115 -0
  6. modules/infographics_generator/image_utils.py +247 -0
  7. modules/infographics_generator/infographics_generator.py +0 -0
  8. modules/infographics_generator/layout_system/__init__.py +6 -0
  9. modules/infographics_generator/layout_system/constraints/__init__.py +20 -0
  10. modules/infographics_generator/layout_system/constraints/alignment.py +102 -0
  11. modules/infographics_generator/layout_system/constraints/base.py +46 -0
  12. modules/infographics_generator/layout_system/constraints/gap.py +74 -0
  13. modules/infographics_generator/layout_system/constraints/orientation.py +97 -0
  14. modules/infographics_generator/layout_system/constraints/overlap.py +36 -0
  15. modules/infographics_generator/layout_system/constraints/padding.py +69 -0
  16. modules/infographics_generator/layout_system/constraints/relative_size.py +68 -0
  17. modules/infographics_generator/layout_system/element_loader.py +196 -0
  18. modules/infographics_generator/layout_system/handlers/__init__.py +12 -0
  19. modules/infographics_generator/layout_system/handlers/base.py +55 -0
  20. modules/infographics_generator/layout_system/handlers/image_handler.py +99 -0
  21. modules/infographics_generator/layout_system/handlers/text_handler.py +77 -0
  22. modules/infographics_generator/layout_system/hierarchical_optimizer.py +640 -0
  23. modules/infographics_generator/layout_system/parameters.py +45 -0
  24. modules/infographics_generator/layout_system/sdf/__init__.py +10 -0
  25. modules/infographics_generator/layout_system/sdf/bbox.py +156 -0
  26. modules/infographics_generator/layout_system/sdf/core.py +193 -0
  27. modules/infographics_generator/layout_system/sdf/losses.py +558 -0
  28. modules/infographics_generator/layout_system/sdf/optimizer.py +921 -0
  29. modules/infographics_generator/layout_system/sdf/visualization.py +634 -0
  30. modules/infographics_generator/layout_system/strategies/__init__.py +12 -0
  31. modules/infographics_generator/layout_system/strategies/base.py +43 -0
  32. modules/infographics_generator/layout_system/strategies/rule_based_strategy.py +328 -0
  33. modules/infographics_generator/layout_system/strategies/sdf_strategy.py +314 -0
  34. modules/infographics_generator/layout_system/test_hierarchical.py +92 -0
  35. modules/infographics_generator/layout_system/test_sdf.py +99 -0
  36. modules/infographics_generator/layout_system/utils/__init__.py +13 -0
  37. modules/infographics_generator/layout_system/utils/composite.py +92 -0
  38. modules/infographics_generator/layout_system/utils/nodes.py +378 -0
  39. modules/infographics_generator/layout_system/utils/parser.py +62 -0
  40. modules/infographics_generator/layout_system/utils/placeholder.py +69 -0
  41. modules/infographics_generator/layout_system/utils/save_result.py +303 -0
  42. modules/infographics_generator/mask_utils.py +622 -0
  43. modules/infographics_generator/parse_utils.py +152 -0
  44. modules/infographics_generator/screenshot_utils.py +299 -0
  45. modules/infographics_generator/svg_utils.py +544 -0
  46. modules/infographics_generator/template_utils.py +585 -0
  47. modules/infographics_generator/utils/logger.py +29 -0
modules/infographics_generator/__init__.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Infographics Generator package
3
+ """
4
+
5
+ from modules.infographics_generator.infographics_generator import process
6
+ from modules.infographics_generator.color_utils import get_contrast_color
7
+ from .infographics_generator import process
8
+ from .color_utils import get_contrast_color
9
+ from .mask_utils import calculate_mask, calculate_content_height
10
+
11
+ __all__ = [
12
+ 'process',
13
+ 'get_contrast_color',
14
+ 'calculate_mask',
15
+ 'calculate_content_height',
16
+ ]
modules/infographics_generator/build_scene_tree_v3.py ADDED
The diff for this file is too large to render. See raw diff
 
modules/infographics_generator/chat_utils.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ from datetime import datetime
3
+ import json
4
+ import os
5
+ import time
6
+ import logging
7
+ from pathlib import Path
8
+ from PIL import Image
9
+
10
+
11
+ my_logger = None
12
+
13
+ def get_logger(name=None, log_path=None):
14
+ global my_logger
15
+ if my_logger is not None:
16
+ return my_logger
17
+ assert name is not None, 'name should not be None'
18
+ assert log_path is not None, 'log_path should not be None'
19
+ my_logger = logging.getLogger(name)
20
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s', \
21
+ handlers=[logging.FileHandler(filename=log_path, encoding='utf-8', mode='a+'), logging.StreamHandler()])
22
+ return my_logger
23
+
24
+ def load_json(save_path, output=False):
25
+ info_dict = {}
26
+ if os.path.exists(save_path):
27
+ with open(save_path, "r", encoding='utf-8') as f:
28
+ info_dict = json.load(f)
29
+ if output:
30
+ print('already have', len(info_dict))
31
+ return info_dict
32
+
33
+
34
+ def load_txt(save_path):
35
+ assert os.path.exists(save_path), f'{save_path} not exist'
36
+ info = ''
37
+ if os.path.exists(save_path):
38
+ with open(save_path, "r", encoding='utf-8') as f:
39
+ info = f.read()
40
+ return info
41
+
42
+
43
+ def safe_save_json(info_dict, save_path, output=False):
44
+ while True:
45
+ try:
46
+ with open(save_path, "w", encoding='utf-8') as f:
47
+ json.dump(info_dict, f, indent=2, ensure_ascii=False)
48
+ break
49
+ except Exception as e:
50
+ time.sleep(1)
51
+ print('----------save error:', str(e))
52
+ print('----------do not interrupt saving, retrying...')
53
+ if output:
54
+ print(f'--------------------save success,', len(info_dict), 'saved')
modules/infographics_generator/color_utils.py ADDED
@@ -0,0 +1,334 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Tuple
2
+ import colorsys
3
+ import random
4
+ import math
5
+
6
+ def parse_color(c: str) -> Tuple[int, int, int]:
7
+ """将颜色字符串解析为RGB元组"""
8
+ if c.startswith('#'):
9
+ c = c.lstrip('#')
10
+ if len(c) == 3:
11
+ c = ''.join(x + x for x in c)
12
+ return tuple(int(c[i:i+2], 16) for i in (0, 2, 4))
13
+ elif c.startswith('rgb'):
14
+ return tuple(map(int, c.strip('rgb()').split(',')))
15
+ raise ValueError(f"Unsupported color format: {c}")
16
+
17
+ def rgb_to_hsl(r: int, g: int, b: int) -> Tuple[float, float, float]:
18
+ """RGB颜色转换为HSL颜色空间"""
19
+ r, g, b = r/255.0, g/255.0, b/255.0
20
+ max_val = max(r, g, b)
21
+ min_val = min(r, g, b)
22
+ h, s, l = 0, 0, (max_val + min_val) / 2
23
+
24
+ if max_val != min_val:
25
+ d = max_val - min_val
26
+ s = d / (2 - max_val - min_val) if l > 0.5 else d / (max_val + min_val)
27
+ if max_val == r:
28
+ h = (g - b) / d + (6 if g < b else 0)
29
+ elif max_val == g:
30
+ h = (b - r) / d + 2
31
+ elif max_val == b:
32
+ h = (r - g) / d + 4
33
+ h /= 6
34
+
35
+ return h * 360, s * 100, l * 100
36
+
37
+ def hsl_to_rgb(h: float, s: float, l: float) -> Tuple[int, int, int]:
38
+ """HSL颜色转换为RGB颜色空间"""
39
+ h, s, l = h/360, s/100, l/100
40
+
41
+ def hue_to_rgb(p: float, q: float, t: float) -> float:
42
+ if t < 0:
43
+ t += 1
44
+ if t > 1:
45
+ t -= 1
46
+ if t < 1/6:
47
+ return p + (q - p) * 6 * t
48
+ if t < 1/2:
49
+ return q
50
+ if t < 2/3:
51
+ return p + (q - p) * (2/3 - t) * 6
52
+ return p
53
+
54
+ if s == 0:
55
+ r = g = b = l
56
+ else:
57
+ q = l * (1 + s) if l < 0.5 else l + s - l * s
58
+ p = 2 * l - q
59
+ r = hue_to_rgb(p, q, h + 1/3)
60
+ g = hue_to_rgb(p, q, h)
61
+ b = hue_to_rgb(p, q, h - 1/3)
62
+
63
+ return tuple(round(x * 255) for x in (r, g, b))
64
+
65
+ def get_contrast_color(hex_color: str) -> str:
66
+ """获取与给定颜色形成对比的颜色"""
67
+ # 移除#号并转换为RGB
68
+ hex_color = hex_color.lstrip('#')
69
+ r, g, b = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
70
+
71
+ # 计算亮度
72
+ luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
73
+
74
+ # 根据亮度返回黑色或白色
75
+ return '#000000' if luminance > 0.5 else '#ffffff'
76
+
77
+ def hex_to_rgb(hex_color: str) -> tuple:
78
+ """
79
+ Convert hex color to RGB tuple
80
+
81
+ Args:
82
+ hex_color: Hex color string (e.g. "#FFFFFF")
83
+
84
+ Returns:
85
+ tuple: (r, g, b) where each value is between 0 and 1
86
+ """
87
+ hex_color = hex_color.lstrip('#')
88
+ r = int(hex_color[0:2], 16) / 255.0
89
+ g = int(hex_color[2:4], 16) / 255.0
90
+ b = int(hex_color[4:6], 16) / 255.0
91
+ return (r, g, b)
92
+
93
+ def rgb_to_hex(rgb: tuple) -> str:
94
+ """
95
+ Convert RGB tuple to hex color
96
+
97
+ Args:
98
+ rgb: (r, g, b) tuple where each value is between 0 and 1
99
+
100
+ Returns:
101
+ str: Hex color string (e.g. "#FFFFFF")
102
+ """
103
+ r, g, b = [int(x * 255) for x in rgb]
104
+ return f"#{r:02x}{g:02x}{b:02x}"
105
+ def lighten_color(hex_color: str, amount: float = 0.2) -> str:
106
+ """
107
+ Lighten a color by converting to HSL, increasing lightness, and converting back
108
+
109
+ Args:
110
+ hex_color: Hex color string (e.g. "#FFFFFF")
111
+ amount: Amount to lighten (0-1)
112
+
113
+ Returns:
114
+ str: Lightened hex color
115
+ """
116
+ # Convert hex to RGB
117
+ r, g, b = hex_to_rgb(hex_color)
118
+
119
+ # Convert RGB to HSL
120
+ h, l, s = colorsys.rgb_to_hls(r, g, b)
121
+
122
+ # Increase lightness to ensure RGB values are at least 220/255
123
+ # Calculate the minimum lightness needed to get RGB values above 220
124
+ r_new, g_new, b_new = r, g, b
125
+ target_min = 220/255
126
+
127
+ # Gradually increase lightness until all RGB values are above target
128
+ while min(r_new, g_new, b_new) < target_min and l < 0.99:
129
+ l = min(0.99, l + 0.05)
130
+ r_new, g_new, b_new = colorsys.hls_to_rgb(h, l, s)
131
+
132
+ # Convert back to hex
133
+ return rgb_to_hex((r_new, g_new, b_new))
134
+
135
+ def is_dark_color(hex_color: str) -> bool:
136
+ """
137
+ Check if a color is dark by calculating its luminance
138
+
139
+ Args:
140
+ hex_color: Hex color string (e.g. "#FFFFFF")
141
+
142
+ Returns:
143
+ bool: True if color is dark, False otherwise
144
+ """
145
+ r, g, b = hex_to_rgb(hex_color)
146
+ luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b
147
+ return luminance < 0.8
148
+
149
+
150
+ def has_indistinguishable_colors(color_list, threshold=0.85):
151
+ """
152
+ 判断颜色列表中是否存在不可区分的颜色
153
+
154
+ 参数:
155
+ color_list: 十六进制颜色代码列表,如 ["#FF5733", "#33FF57"]
156
+ threshold: 相似度阈值,超过此值的两个颜色被视为不可区分
157
+
158
+ 返回:
159
+ 如果存在不可区分的颜色,返回True,否则返回False
160
+ """
161
+ import math
162
+
163
+ def color_similarity(color1, color2):
164
+ # 将十六进制转换为RGB
165
+ color1 = color1.lstrip('#')
166
+ r1, g1, b1 = tuple(int(color1[i:i+2], 16) for i in (0, 2, 4))
167
+
168
+ color2 = color2.lstrip('#')
169
+ r2, g2, b2 = tuple(int(color2[i:i+2], 16) for i in (0, 2, 4))
170
+
171
+ # 计算RGB空间中的欧氏距离
172
+ distance = math.sqrt((r1-r2)**2 + (g1-g2)**2 + (b1-b2)**2)
173
+
174
+ # 归一化相似度 (最大可能距离是sqrt(3*255^2))
175
+ similarity = 1 - (distance / math.sqrt(3 * 255**2))
176
+
177
+ return similarity
178
+
179
+ # 比较每一对颜色
180
+ for i in range(len(color_list)):
181
+ for j in range(i+1, len(color_list)):
182
+ if color_similarity(color_list[i], color_list[j]) > threshold:
183
+ return True
184
+
185
+ return False
186
+
187
+ def generate_distinct_palette(main_color, num_colors=5):
188
+ """
189
+ 根据主颜色生成一组美观且可区分的颜色调色板
190
+
191
+ 参数:
192
+ main_color: 主颜色,十六进制格式,如 "#FF5733"
193
+ num_colors: 需要生成的颜色数量,包括主颜色
194
+
195
+ 返回:
196
+ 一个包含十六进制颜色代码的列表
197
+ """
198
+ # 将十六进制颜色转换为RGB
199
+ main_color = main_color.lstrip('#')
200
+ r, g, b = tuple(int(main_color[i:i+2], 16) for i in (0, 2, 4))
201
+
202
+ # 转换RGB为HSL
203
+ h, s, l = colorsys.rgb_to_hls(r/255, g/255, b/255)
204
+ h = h * 360 # 转换到0-360度
205
+ s = s * 100 # 转换到0-100%
206
+ l = l * 100 # 转换到0-100%
207
+
208
+ palette = ["#" + main_color] # 添加主颜色到调色板
209
+
210
+ # 选择生成策略
211
+ strategy = random.choice(["complementary", "analogous", "triadic", "golden_ratio"])
212
+
213
+ # 根据不同策略生成颜色
214
+ if strategy == "complementary" and num_colors >= 2:
215
+ # 互补色方案,最容易区分
216
+ for i in range(1, num_colors):
217
+ # 互补色基础上增加变化
218
+ new_h = (h + 180 + (i-1) * 30) % 360
219
+
220
+ # 限制饱和度和亮度范围
221
+ new_s = max(30, min(90, s + random.uniform(-20, 20)))
222
+ new_l = max(35, min(75, l + random.uniform(-15, 15)))
223
+
224
+ # 转回RGB并添加到调色板
225
+ r, g, b = colorsys.hls_to_rgb(new_h/360, new_l/100, new_s/100)
226
+ hex_color = "#{:02x}{:02x}{:02x}".format(int(r*255), int(g*255), int(b*255))
227
+ palette.append(hex_color)
228
+
229
+ elif strategy == "analogous":
230
+ # 类似色方案但确保足够区分
231
+ for i in range(1, num_colors):
232
+ # 在主色左右30-60度范围内分布
233
+ new_h = (h + (i % 2 * 2 - 1) * random.uniform(30, 60)) % 360
234
+
235
+ # 限制饱和度和亮度范围
236
+ new_s = max(30, min(90, s + random.uniform(-15, 15)))
237
+ new_l = max(35, min(75, l + random.uniform(-10, 10)))
238
+
239
+ r, g, b = colorsys.hls_to_rgb(new_h/360, new_l/100, new_s/100)
240
+ hex_color = "#{:02x}{:02x}{:02x}".format(int(r*255), int(g*255), int(b*255))
241
+ palette.append(hex_color)
242
+
243
+ elif strategy == "triadic":
244
+ # 三等分色环方案
245
+ for i in range(1, num_colors):
246
+ # 在色环上120度间隔分布
247
+ new_h = (h + (i % 3) * 120) % 360
248
+
249
+ # 限制饱和度和亮度范围
250
+ new_s = max(30, min(90, s + random.uniform(-10, 10)))
251
+ new_l = max(35, min(75, l + random.uniform(-10, 10)))
252
+
253
+ r, g, b = colorsys.hls_to_rgb(new_h/360, new_l/100, new_s/100)
254
+ hex_color = "#{:02x}{:02x}{:02x}".format(int(r*255), int(g*255), int(b*255))
255
+ palette.append(hex_color)
256
+
257
+ else: # golden_ratio
258
+ # 黄金比例方法
259
+ golden_ratio = 0.618033988749895 * 360 # 转换到角度
260
+ for i in range(1, num_colors):
261
+ new_h = (h + golden_ratio * i) % 360
262
+
263
+ # 限制饱和度和亮度范围
264
+ new_s = max(30, min(90, 60 + random.uniform(-20, 20))) # 基准饱和度60%
265
+ new_l = max(35, min(75, 55 + random.uniform(-15, 15))) # 基准亮度55%
266
+
267
+ r, g, b = colorsys.hls_to_rgb(new_h/360, new_l/100, new_s/100)
268
+ hex_color = "#{:02x}{:02x}{:02x}".format(int(r*255), int(g*255), int(b*255))
269
+ palette.append(hex_color)
270
+
271
+ # 检查颜色区分度,如果颜色太相似,则调整
272
+ final_palette = [palette[0]] # 始终保留主颜色
273
+ for color in palette[1:]:
274
+ if len(final_palette) >= num_colors:
275
+ break
276
+
277
+ # 检查与已有颜色的区分度
278
+ is_distinct = True
279
+ for existing_color in final_palette:
280
+ if color_similarity(color, existing_color) > 0.85: # 相似度阈值
281
+ is_distinct = False
282
+ break
283
+
284
+ if is_distinct:
285
+ final_palette.append(color)
286
+ else:
287
+ # 生成一个替代颜色
288
+ new_h = random.uniform(0, 360)
289
+ new_s = random.uniform(30, 90) # 限��饱和度范围
290
+ new_l = random.uniform(35, 75) # 限制亮度范围
291
+
292
+ r, g, b = colorsys.hls_to_rgb(new_h/360, new_l/100, new_s/100)
293
+ hex_color = "#{:02x}{:02x}{:02x}".format(int(r*255), int(g*255), int(b*255))
294
+ final_palette.append(hex_color)
295
+
296
+ # 如果颜色不足,继续补充
297
+ while len(final_palette) < num_colors:
298
+ new_h = random.uniform(0, 360)
299
+ new_s = random.uniform(30, 90) # 限制饱和度范围
300
+ new_l = random.uniform(35, 75) # 限制亮度范围
301
+
302
+ r, g, b = colorsys.hls_to_rgb(new_h/360, new_l/100, new_s/100)
303
+ hex_color = "#{:02x}{:02x}{:02x}".format(int(r*255), int(g*255), int(b*255))
304
+
305
+ # 检查与已有颜色的区分度
306
+ is_distinct = True
307
+ for existing_color in final_palette:
308
+ if color_similarity(hex_color, existing_color) > 0.85:
309
+ is_distinct = False
310
+ break
311
+
312
+ if is_distinct:
313
+ final_palette.append(hex_color)
314
+
315
+ return final_palette
316
+
317
+ def color_similarity(color1, color2):
318
+ """
319
+ 计算两个颜色的相似度(0-1之间,1表示完全相同)
320
+ """
321
+ # 将十六进制转换为RGB
322
+ color1 = color1.lstrip('#')
323
+ r1, g1, b1 = tuple(int(color1[i:i+2], 16) for i in (0, 2, 4))
324
+
325
+ color2 = color2.lstrip('#')
326
+ r2, g2, b2 = tuple(int(color2[i:i+2], 16) for i in (0, 2, 4))
327
+
328
+ # 计算RGB空间中的欧氏距离
329
+ distance = math.sqrt((r1-r2)**2 + (g1-g2)**2 + (b1-b2)**2)
330
+
331
+ # 归一化相似度 (最大可能距离是sqrt(3*255^2))
332
+ similarity = 1 - (distance / math.sqrt(3 * 255**2))
333
+
334
+ return similarity
modules/infographics_generator/data_utils.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, List, Tuple
2
+ import re
3
+ from datetime import datetime
4
+ import logging
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ def process_temporal_data(data: Dict) -> None:
9
+ """处理时间类型的数据"""
10
+ for column in data["data"]["columns"]:
11
+ if column["data_type"] == "temporal":
12
+ for row in data["data"]["data"]:
13
+ value = str(row.get(column["name"], ""))
14
+
15
+ try:
16
+ # 处理简单年份格式 (如 "05" 表示 2005)
17
+ if value.isdigit():
18
+ if len(value) == 2:
19
+ row[column["name"]] = f"2000-{value}" # 使用年份-月份格式
20
+ else:
21
+ row[column["name"]] = value # 保持原样的年份
22
+ continue
23
+
24
+ # 处理带小数点的年份格式 (如 "2025.1" → "2025-01")
25
+ if "." in value:
26
+ year, month = value.split(".")
27
+ if year.isdigit() and month.isdigit():
28
+ # 确保月份是两位数
29
+ month = month.zfill(2)
30
+ row[column["name"]] = f"{year}-{month}"
31
+ continue
32
+
33
+ # 处理月份年份组合 (如 "Jul 2025")
34
+ if " " in value:
35
+ try:
36
+ # 尝试解析完整的月份名称
37
+ date_obj = datetime.strptime(value, "%B %Y")
38
+ except ValueError:
39
+ try:
40
+ # 尝试解析缩写的月份名称
41
+ date_obj = datetime.strptime(value, "%b %Y")
42
+ except ValueError:
43
+ continue
44
+
45
+ # 转换为 "YYYY-MM" 格式
46
+ row[column["name"]] = date_obj.strftime("%Y-%m")
47
+ continue
48
+
49
+ except Exception as e:
50
+ logger.warning(f"Failed to parse temporal value '{value}': {str(e)}")
51
+ continue
52
+
53
+ def process_numerical_data(data: Dict) -> None:
54
+ """处理数值类型的数据"""
55
+ for column in data["data"]["columns"]:
56
+ if column["data_type"] == "numerical":
57
+ for row in data["data"]["data"]:
58
+ value = row.get(column["name"])
59
+
60
+ # 处理 null 或 None
61
+ if value is None or value == "null" or value == "":
62
+ row[column["name"]] = 0
63
+ continue
64
+
65
+ # 转换为字符串以进行处理
66
+ value_str = str(value)
67
+
68
+ # 提取数字(包括负号和小数点)
69
+ numeric_chars = re.findall(r'-?\d*\.?\d+', value_str)
70
+ if numeric_chars:
71
+ # 使用第一个匹配的数字
72
+ try:
73
+ row[column["name"]] = float(numeric_chars[0])
74
+ except ValueError:
75
+ row[column["name"]] = 0
76
+ else:
77
+ row[column["name"]] = 0
78
+
79
+ def deduplicate_combinations(data: Dict) -> None:
80
+ """检查并去重temporal和categorical属性的组合
81
+
82
+ Args:
83
+ data: 包含数据的字典,格式为 {"data": {"columns": [...], "data": [...]}}
84
+ """
85
+ # 找出所有temporal和categorical列
86
+ temporal_categorical_cols = [
87
+ col["name"] for col in data["data"]["columns"]
88
+ if col["data_type"] in ["temporal", "categorical"]
89
+ ]
90
+
91
+ if not temporal_categorical_cols:
92
+ return
93
+
94
+ # 用于存储已见过的组合
95
+ seen_combinations = set()
96
+ # 用于存储要保留的行索引
97
+ rows_to_keep = []
98
+
99
+ # 检查每一行
100
+ for idx, row in enumerate(data["data"]["data"]):
101
+ # 获取当前行的temporal和categorical值组合
102
+ combination = tuple(str(row.get(col, "")) for col in temporal_categorical_cols)
103
+
104
+ # 如果这个组合还没见过,就保留这行
105
+ if combination not in seen_combinations:
106
+ seen_combinations.add(combination)
107
+ rows_to_keep.append(idx)
108
+
109
+ # 只保留不重复的行
110
+ data["data"]["data"] = [data["data"]["data"][i] for i in rows_to_keep]
111
+
112
+ # 记录去重信息
113
+ removed_count = len(data["data"]["data"]) - len(rows_to_keep)
114
+ #if removed_count > 0:
115
+ # logger.info(f"Removed {removed_count} duplicate combinations of temporal/categorical attributes")
modules/infographics_generator/image_utils.py ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ from typing import Tuple
3
+ from .mask_utils import calculate_mask, expand_mask
4
+ import os
5
+ from PIL import Image
6
+
7
+ def find_best_size_and_position(main_mask: np.ndarray, image_content: str, padding: int, mode: str = "side", chart_bbox: dict = None, avoid_mask: np.ndarray = None) -> Tuple[int, int, int]:
8
+ """
9
+ 通过降采样加速查找最佳图片尺寸和位置
10
+
11
+ Args:
12
+ main_mask: 主要内容的mask
13
+ image_content: base64图片内容
14
+ padding: 边界padding
15
+ mode: 放置模式,可选"side"、"background"或"overlay"
16
+ chart_bbox: 图表边界框,格式为{"x": x, "y": y, "width": width, "height": height}
17
+ avoid_mask: 需要避免重叠的区域mask
18
+
19
+ Returns:
20
+ Tuple[int, int, int]: (image_size, best_x, best_y)
21
+ """
22
+ # Save the main_mask to PNG for debugging
23
+ os.makedirs('tmp', exist_ok=True)
24
+ mask_image = Image.fromarray((main_mask * 255).astype(np.uint8))
25
+ mask_image.save('tmp/main_mask.png')
26
+
27
+ grid_size = 5
28
+
29
+ # 将main_mask降采样到1/grid_size大小
30
+ h, w = main_mask.shape
31
+ downsampled_h = h // grid_size
32
+ downsampled_w = w // grid_size
33
+ downsampled_main = np.zeros((downsampled_h, downsampled_w), dtype=np.uint8)
34
+
35
+ # 对每个grid进行降采样,只要原grid中有内容(1)就标记为1
36
+ for i in range(downsampled_h):
37
+ for j in range(downsampled_w):
38
+ y_start = max(0, (i - 1) * (grid_size))
39
+ x_start = max(0, (j - 1) * (grid_size))
40
+ y_end = min((i + 2) * (grid_size), h)
41
+ x_end = min((j + 2) * (grid_size), w)
42
+ grid = main_mask[y_start:y_end, x_start:x_end]
43
+ downsampled_main[i, j] = 1 if np.any(grid == 1) else 0
44
+
45
+ # 如果有avoid_mask,也进行降采样
46
+ downsampled_avoid = None
47
+ if avoid_mask is not None:
48
+ downsampled_avoid = np.zeros((downsampled_h, downsampled_w), dtype=np.uint8)
49
+ for i in range(downsampled_h):
50
+ for j in range(downsampled_w):
51
+ y_start = max(0, (i - 1) * (grid_size))
52
+ x_start = max(0, (j - 1) * (grid_size))
53
+ y_end = min((i + 2) * (grid_size), h)
54
+ x_end = min((j + 2) * (grid_size), w)
55
+ grid = avoid_mask[y_start:y_end, x_start:x_end]
56
+ downsampled_avoid[i, j] = 1 if np.any(grid == 1) else 0
57
+
58
+ # 调整padding到降采样尺度
59
+ downsampled_padding = max(1, padding // grid_size)
60
+
61
+ # 二分查找最佳尺寸
62
+ min_size = max(1, 64 // grid_size) # 最小尺寸也要降采样
63
+ max_size = int(min(downsampled_main.shape) * 1)
64
+ best_size = min_size
65
+ best_x = downsampled_padding
66
+ best_y = downsampled_padding
67
+
68
+ if mode == "side":
69
+ best_overlap_ratio = float('inf')
70
+ elif mode == "background":
71
+ best_overlap_ratio = float('inf')
72
+ else:
73
+ best_overlap_ratio = 0
74
+
75
+ overlap_threshold = 0.01
76
+ if mode == "side":
77
+ overlap_threshold = 0.01
78
+ elif mode == "background":
79
+ overlap_threshold = 0.05
80
+ elif mode == "overlay":
81
+ overlap_threshold = 0.97
82
+
83
+ while max_size - min_size >= 2: # 由于降采样,可以用更小的阈值
84
+ mid_size = (min_size + max_size) // 2
85
+
86
+ # 生成当前尺寸的图片mask并降采样
87
+ original_size = mid_size * grid_size
88
+ temp_svg = f"""<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="{original_size}" height="{original_size}">
89
+ <image width="{original_size}" height="{original_size}" href="{image_content}"/>
90
+ </svg>"""
91
+ image_mask = calculate_mask(temp_svg, original_size, original_size, 0, grid_size=grid_size, bg_threshold=240)
92
+ if mode == "background":
93
+ image_mask = expand_mask(image_mask, 10)
94
+ # Save the original image mask to PNG for debugging
95
+ os.makedirs('tmp', exist_ok=True)
96
+ mask_image = Image.fromarray((image_mask * 255).astype(np.uint8))
97
+ mask_image.save('tmp/image_mask.png')
98
+ # 将image_mask降采样
99
+ downsampled_image = np.zeros((mid_size, mid_size), dtype=np.uint8)
100
+ for i in range(mid_size):
101
+ for j in range(mid_size):
102
+ y_start = max(0, (i - 1) * (grid_size))
103
+ x_start = max(0, (j - 1) * (grid_size))
104
+ y_end = min((i + 2) * (grid_size), original_size)
105
+ x_end = min((j + 2) * (grid_size), original_size)
106
+ grid = image_mask[y_start:y_end, x_start:x_end]
107
+ downsampled_image[i, j] = 1 if np.any(grid == 1) else 0
108
+
109
+ # 计算有效的搜索范围
110
+ if mode == "background" and chart_bbox is not None:
111
+ # 将chart_bbox转换到降采样尺度
112
+ chart_x = max(0, chart_bbox["x"] // grid_size)
113
+ chart_y = max(0, chart_bbox["y"] // grid_size)
114
+ chart_width = min(chart_bbox["width"] // grid_size, downsampled_w - chart_x)
115
+ chart_height = min(chart_bbox["height"] // grid_size, downsampled_h - chart_y)
116
+
117
+ # 确保搜索范围在chart_bbox内
118
+ y_range = chart_height - mid_size - downsampled_padding * 2
119
+ x_range = chart_width - mid_size - downsampled_padding * 2
120
+
121
+ if y_range <= 0 or x_range <= 0:
122
+ max_size = mid_size - 1
123
+ continue
124
+ else:
125
+ y_range = downsampled_h - mid_size - downsampled_padding * 2
126
+ x_range = downsampled_w - mid_size - downsampled_padding * 2
127
+
128
+ if y_range <= 0 or x_range <= 0:
129
+ max_size = mid_size - 1
130
+ continue
131
+
132
+ # 在降采样空间中寻找最佳位置
133
+ min_overlap = float('inf')
134
+ if mode == "side" or mode == "background":
135
+ min_overlap = float('inf')
136
+ elif mode == "overlay":
137
+ min_overlap = 0
138
+ current_x = downsampled_padding
139
+ current_y = downsampled_padding
140
+ min_distance = float('inf')
141
+ mask_center_x = np.mean(np.where(downsampled_main == 1)[1]) if np.any(downsampled_main == 1) else downsampled_w // 2
142
+ mask_center_y = np.mean(np.where(downsampled_main == 1)[0]) if np.any(downsampled_main == 1) else downsampled_h // 2
143
+
144
+ if mode == "background" and chart_bbox is not None:
145
+ y_start = chart_y + downsampled_padding
146
+ y_end = chart_y + chart_height - mid_size - downsampled_padding + 1
147
+ x_start = chart_x + downsampled_padding
148
+ x_end = chart_x + chart_width - mid_size - downsampled_padding + 1
149
+ else:
150
+ y_start = downsampled_padding
151
+ y_end = downsampled_h - mid_size - downsampled_padding + 1
152
+ x_start = downsampled_padding
153
+ x_end = downsampled_w - mid_size - downsampled_padding + 1
154
+
155
+ for y in range(y_start, y_end):
156
+ for x in range(x_start, x_end):
157
+ region = downsampled_main[y:y + mid_size, x:x + mid_size]
158
+
159
+ overlap = np.sum((region == 1) & (downsampled_image == 1))
160
+ total = np.sum(downsampled_image == 1)
161
+ overlap_ratio = overlap / total if total > 0 else 1.0
162
+
163
+ # 检查与avoid_mask的重叠
164
+ avoid_overlap = 0
165
+ if downsampled_avoid is not None:
166
+ avoid_region = downsampled_avoid[y:y + mid_size, x:x + mid_size]
167
+ avoid_overlap = np.sum((avoid_region == 1) & (downsampled_image == 1))
168
+
169
+ if mode == "side" or mode == "background":
170
+ if mode == "background" and chart_bbox is not None:
171
+ distance_to_left = x - (chart_x + downsampled_padding)
172
+ distance_to_right = (chart_x + chart_width - mid_size - downsampled_padding) - x
173
+ distance_to_top = y - (chart_y + downsampled_padding)
174
+ distance_to_bottom = (chart_y + chart_height - mid_size - downsampled_padding) - y
175
+ else:
176
+ distance_to_left = x - downsampled_padding
177
+ distance_to_right = downsampled_w - mid_size - downsampled_padding - x
178
+ distance_to_top = y - downsampled_padding
179
+ distance_to_bottom = downsampled_h - mid_size - downsampled_padding - y
180
+
181
+ distance_to_border = min(distance_to_left, distance_to_right, distance_to_top, distance_to_bottom)
182
+ if overlap_ratio < min_overlap or (overlap_ratio < overlap_threshold and distance_to_border < min_distance):
183
+ min_overlap = overlap_ratio
184
+ current_x = x
185
+ current_y = y
186
+ min_distance = distance_to_border
187
+ elif mode == "overlay":
188
+ # 对于overlay模式,需要同时满足与main_mask的重叠足够大,且与avoid_mask没有重叠
189
+ if avoid_overlap > 0:
190
+ continue # 跳过与avoid_mask有重叠的位置
191
+ distance_to_center = np.sqrt(((x + mid_size / 2 - mask_center_x) ** 2 + (y + mid_size / 2 - mask_center_y) ** 2))
192
+ if overlap_ratio > min_overlap or (overlap_ratio > overlap_threshold and distance_to_center < min_distance):
193
+ min_overlap = overlap_ratio
194
+ current_x = x
195
+ current_y = y
196
+ min_distance = distance_to_center
197
+
198
+ # print(f"Trying size {mid_size * grid_size}x{mid_size * grid_size}, minimum overlap ratio: {min_overlap:.3f}")
199
+
200
+ if mode == "side" or mode == "background":
201
+ if min_overlap < overlap_threshold:
202
+ best_size = mid_size
203
+ best_overlap_ratio = min_overlap
204
+ best_x = current_x
205
+ best_y = current_y
206
+ min_size = mid_size + 1
207
+ else:
208
+ max_size = mid_size - 1
209
+ elif mode == "overlay":
210
+ if min_overlap > overlap_threshold:
211
+ best_size = mid_size
212
+ best_overlap_ratio = min_overlap
213
+ best_x = current_x
214
+ best_y = current_y
215
+ min_size = mid_size + 1
216
+ else:
217
+ max_size = mid_size - 1
218
+
219
+ if best_overlap_ratio > overlap_threshold and (mode == "side" or mode == "background"):
220
+ return 0, 0, 0
221
+ if best_overlap_ratio < overlap_threshold and mode == "overlay":
222
+ return 0, 0, 0
223
+
224
+ final_size = best_size * grid_size
225
+ final_x = best_x * grid_size
226
+ final_y = best_y * grid_size
227
+
228
+ '''
229
+ # 生成最终尺寸的图片mask
230
+ temp_svg = f"""<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="{final_size}" height="{final_size}">
231
+ <image width="{final_size}" height="{final_size}" href="{image_content}"/>
232
+ </svg>"""
233
+ final_image_mask = calculate_mask(temp_svg, final_size, final_size, 0)
234
+
235
+ # 创建合并的mask,将image_mask放在正确的位置
236
+ combined_mask = np.zeros_like(main_mask)
237
+ combined_mask[main_mask == 1] = 1
238
+ # 将image_mask放在正确的位置
239
+ combined_mask[final_y:final_y + final_size, final_x:final_x + final_size] = np.where(final_image_mask == 1, 2, combined_mask[final_y:final_y + final_size, final_x:final_x + final_size])
240
+
241
+ # 保存合并的mask
242
+ combined_image = Image.fromarray((combined_mask * 127).astype(np.uint8))
243
+ combined_image.save('tmp/all_mask.png')
244
+
245
+ print(f"Final result: size={final_size}x{final_size}, position=({final_x}, {final_y}), overlap ratio={best_overlap_ratio:.3f}")
246
+ '''
247
+ return final_size, final_x, final_y
modules/infographics_generator/infographics_generator.py ADDED
The diff for this file is too large to render. See raw diff
 
modules/infographics_generator/layout_system/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """元素加载模块"""
2
+
3
+ from .element_loader import load_element_from_image, ElementLoader
4
+
5
+ __all__ = ["load_element_from_image", "ElementLoader"]
6
+
modules/infographics_generator/layout_system/constraints/__init__.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Constraint processors for layout optimization."""
2
+
3
+ from .base import ConstraintProcessor
4
+ from .relative_size import RelativeSizeProcessor
5
+ from .padding import PaddingProcessor
6
+ from .orientation import OrientationProcessor
7
+ from .overlap import OverlapProcessor
8
+ from .alignment import AlignmentProcessor
9
+ from .gap import GapProcessor
10
+
11
+ __all__ = [
12
+ 'ConstraintProcessor',
13
+ 'RelativeSizeProcessor',
14
+ 'PaddingProcessor',
15
+ 'OrientationProcessor',
16
+ 'OverlapProcessor',
17
+ 'AlignmentProcessor',
18
+ 'GapProcessor',
19
+ ]
20
+
modules/infographics_generator/layout_system/constraints/alignment.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Alignment constraint processor."""
2
+
3
+ import torch
4
+ import torch.nn.functional as F
5
+ from typing import List, Tuple
6
+ from .base import ConstraintProcessor
7
+
8
+
9
+ class AlignmentProcessor(ConstraintProcessor):
10
+ """Processor for alignment constraints."""
11
+
12
+ def can_handle(self, constraint_type: str) -> bool:
13
+ return constraint_type == "alignment"
14
+
15
+ def process(self, constraint: dict, bboxes: List[Tuple[float, float, float, float]],
16
+ device: str = "cpu") -> torch.Tensor:
17
+ """Process alignment constraints.
18
+
19
+ Args:
20
+ constraint: Dictionary with "alignment" key containing alignment constraint
21
+ bboxes: List of (x, y, w, h) bounding boxes
22
+ device: Device for tensors
23
+
24
+ Returns:
25
+ Loss tensor
26
+ """
27
+ alignment_constraint = constraint.get("alignment", {})
28
+ if not alignment_constraint:
29
+ return torch.tensor(0.0, device=device)
30
+
31
+ L_alignment = torch.tensor(0.0, device=device)
32
+
33
+ direction = alignment_constraint.get("direction", "horizontal") # "horizontal" or "vertical"
34
+ value = alignment_constraint.get("value", "center") # "left", "center", "right", "top", "bottom"
35
+
36
+ if not bboxes:
37
+ return L_alignment
38
+
39
+ # Get container size (should be passed separately, but for now estimate from bboxes)
40
+ # Calculate bounding box of all elements
41
+ all_x = [x for x, _, _, _ in bboxes]
42
+ all_y = [y for _, y, _, _ in bboxes]
43
+ all_w = [w for _, _, w, _ in bboxes]
44
+ all_h = [h for _, _, _, h in bboxes]
45
+
46
+ container_w = max(x + w for x, w in zip(all_x, all_w)) if all_x else 1000.0
47
+ container_h = max(y + h for y, h in zip(all_y, all_h)) if all_y else 1000.0
48
+
49
+ if direction == "horizontal":
50
+ # Horizontal alignment: align elements along x-axis
51
+ if value == "left":
52
+ # All elements should align to left edge
53
+ for x, _, _, _ in bboxes:
54
+ x_t = torch.tensor(x, device=device)
55
+ L_alignment += x_t ** 2
56
+ elif value == "center":
57
+ # All elements should be centered horizontally
58
+ for x, _, w, _ in bboxes:
59
+ x_t = torch.tensor(x, device=device)
60
+ w_t = torch.tensor(w, device=device)
61
+ center_x = x_t + 0.5 * w_t
62
+ target_center = torch.tensor(container_w / 2.0, device=device)
63
+ L_alignment += (center_x - target_center) ** 2
64
+ elif value == "right":
65
+ # All elements should align to right edge
66
+ for x, _, w, _ in bboxes:
67
+ x_t = torch.tensor(x, device=device)
68
+ w_t = torch.tensor(w, device=device)
69
+ right_x = x_t + w_t
70
+ target_right = torch.tensor(container_w, device=device)
71
+ L_alignment += (right_x - target_right) ** 2
72
+
73
+ elif direction == "vertical":
74
+ # Vertical alignment: align elements along y-axis
75
+ if value == "top":
76
+ # All elements should align to top edge
77
+ for _, y, _, _ in bboxes:
78
+ y_t = torch.tensor(y, device=device)
79
+ L_alignment += y_t ** 2
80
+ elif value == "center":
81
+ # All elements should be centered vertically
82
+ for _, y, _, h in bboxes:
83
+ y_t = torch.tensor(y, device=device)
84
+ h_t = torch.tensor(h, device=device)
85
+ center_y = y_t + 0.5 * h_t
86
+ target_center = torch.tensor(container_h / 2.0, device=device)
87
+ L_alignment += (center_y - target_center) ** 2
88
+ elif value == "bottom":
89
+ # All elements should align to bottom edge
90
+ for _, y, _, h in bboxes:
91
+ y_t = torch.tensor(y, device=device)
92
+ h_t = torch.tensor(h, device=device)
93
+ bottom_y = y_t + h_t
94
+ target_bottom = torch.tensor(container_h, device=device)
95
+ L_alignment += (bottom_y - target_bottom) ** 2
96
+
97
+ return L_alignment
98
+
99
+ def get_weight_key(self) -> str:
100
+ return "w_alignment"
101
+
102
+
modules/infographics_generator/layout_system/constraints/base.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Base class for constraint processors."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import List, Tuple
5
+ import torch
6
+
7
+
8
+ class ConstraintProcessor(ABC):
9
+ """Base class for constraint processors."""
10
+
11
+ @abstractmethod
12
+ def can_handle(self, constraint_type: str) -> bool:
13
+ """Check if this processor can handle the given constraint type.
14
+
15
+ Args:
16
+ constraint_type: Type of constraint (e.g., "relative_size", "padding")
17
+
18
+ Returns:
19
+ True if this processor can handle the constraint type
20
+ """
21
+ pass
22
+
23
+ @abstractmethod
24
+ def process(self, constraint: dict, bboxes: List[Tuple[float, float, float, float]],
25
+ device: str = "cpu") -> torch.Tensor:
26
+ """Process constraint and return loss tensor.
27
+
28
+ Args:
29
+ constraint: Constraint dictionary from JSON
30
+ bboxes: List of (x, y, w, h) bounding boxes for nodes
31
+ device: Device to create tensors on
32
+
33
+ Returns:
34
+ Loss tensor (scalar)
35
+ """
36
+ pass
37
+
38
+ @abstractmethod
39
+ def get_weight_key(self) -> str:
40
+ """Get the weight parameter key for this constraint.
41
+
42
+ Returns:
43
+ Weight key name (e.g., "w_relative_size")
44
+ """
45
+ pass
46
+
modules/infographics_generator/layout_system/constraints/gap.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gap constraint processor."""
2
+
3
+ import torch
4
+ import torch.nn.functional as F
5
+ from typing import List, Tuple
6
+ from .base import ConstraintProcessor
7
+
8
+
9
+ class GapProcessor(ConstraintProcessor):
10
+ """Processor for gap constraints."""
11
+
12
+ def can_handle(self, constraint_type: str) -> bool:
13
+ return constraint_type == "gap"
14
+
15
+ def process(self, constraint: dict, bboxes: List[Tuple[float, float, float, float]],
16
+ device: str = "cpu") -> torch.Tensor:
17
+ """Process gap constraints.
18
+
19
+ Args:
20
+ constraint: Dictionary with "gap" key containing gap constraint
21
+ bboxes: List of (x, y, w, h) bounding boxes
22
+ device: Device for tensors
23
+
24
+ Returns:
25
+ Loss tensor
26
+ """
27
+ gap_constraint = constraint.get("gap", {})
28
+ if not gap_constraint:
29
+ return torch.tensor(0.0, device=device)
30
+
31
+ L_gap = torch.tensor(0.0, device=device)
32
+
33
+ direction = gap_constraint.get("direction", "vertical") # "horizontal" or "vertical"
34
+ target_gap = gap_constraint.get("value", 0.0) # Target gap value in pixels
35
+
36
+ if len(bboxes) < 2:
37
+ return L_gap
38
+
39
+ if direction == "vertical":
40
+ # Vertical gap: distance between bottom of first element and top of second element
41
+ # Sort by y coordinate
42
+ sorted_bboxes = sorted(bboxes, key=lambda b: b[1])
43
+ for i in range(len(sorted_bboxes) - 1):
44
+ _, y1, _, h1 = sorted_bboxes[i]
45
+ _, y2, _, _ = sorted_bboxes[i + 1]
46
+
47
+ bottom1 = y1 + h1
48
+ top2 = y2
49
+ actual_gap = top2 - bottom1
50
+
51
+ gap_diff = actual_gap - target_gap
52
+ L_gap += gap_diff ** 2
53
+
54
+ elif direction == "horizontal":
55
+ # Horizontal gap: distance between right edge of first element and left edge of second element
56
+ # Sort by x coordinate
57
+ sorted_bboxes = sorted(bboxes, key=lambda b: b[0])
58
+ for i in range(len(sorted_bboxes) - 1):
59
+ x1, _, w1, _ = sorted_bboxes[i]
60
+ x2, _, _, _ = sorted_bboxes[i + 1]
61
+
62
+ right1 = x1 + w1
63
+ left2 = x2
64
+ actual_gap = left2 - right1
65
+
66
+ gap_diff = actual_gap - target_gap
67
+ L_gap += gap_diff ** 2
68
+
69
+ return L_gap
70
+
71
+ def get_weight_key(self) -> str:
72
+ return "w_gap"
73
+
74
+
modules/infographics_generator/layout_system/constraints/orientation.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Orientation constraint processor."""
2
+
3
+ import torch
4
+ import torch.nn.functional as F
5
+ from typing import List, Tuple
6
+ from .base import ConstraintProcessor
7
+
8
+
9
+ class OrientationProcessor(ConstraintProcessor):
10
+ """Processor for orientation constraints."""
11
+
12
+ def can_handle(self, constraint_type: str) -> bool:
13
+ return constraint_type == "orientation"
14
+
15
+ def process(self, constraint: dict, bboxes: List[Tuple[float, float, float, float]],
16
+ device: str = "cpu") -> torch.Tensor:
17
+ """Process orientation constraints.
18
+
19
+ Args:
20
+ constraint: Dictionary with "orientation" key containing list of constraints
21
+ bboxes: List of (x, y, w, h) bounding boxes
22
+ device: Device for tensors
23
+
24
+ Returns:
25
+ Loss tensor
26
+ """
27
+ orientation_constraints = constraint.get("orientation", [])
28
+ if not orientation_constraints:
29
+ return torch.tensor(0.0, device=device)
30
+
31
+ L_orientation = torch.tensor(0.0, device=device)
32
+
33
+ for orient_constraint in orientation_constraints:
34
+ src_idx = orient_constraint["source_index"]
35
+ tgt_idx = orient_constraint["target_index"]
36
+ position = orient_constraint["position"]
37
+
38
+ if src_idx >= len(bboxes) or tgt_idx >= len(bboxes):
39
+ continue
40
+
41
+ src_x, src_y, src_w, src_h = bboxes[src_idx]
42
+ tgt_x, tgt_y, tgt_w, tgt_h = bboxes[tgt_idx]
43
+
44
+ # Compute centers
45
+ src_cx = src_x + 0.5 * src_w
46
+ src_cy = src_y + 0.5 * src_h
47
+ tgt_cx = tgt_x + 0.5 * tgt_w
48
+ tgt_cy = tgt_y + 0.5 * tgt_h
49
+
50
+ # Convert to tensors
51
+ src_cx_t = torch.tensor(src_cx, device=device)
52
+ src_cy_t = torch.tensor(src_cy, device=device)
53
+ tgt_cx_t = torch.tensor(tgt_cx, device=device)
54
+ tgt_cy_t = torch.tensor(tgt_cy, device=device)
55
+
56
+ # Divide target bbox into 3x3 grid
57
+ tgt_cell_w = tgt_w / 3.0
58
+ tgt_cell_h = tgt_h / 3.0
59
+
60
+ # Map position string to grid coordinates (col, row)
61
+ position_map = {
62
+ "Top-Left": (0, 0),
63
+ "Top": (1, 0),
64
+ "Top-Right": (2, 0),
65
+ "Left": (0, 1),
66
+ "Center": (1, 1),
67
+ "Right": (2, 1),
68
+ "Bottom-Left": (0, 2),
69
+ "Bottom": (1, 2),
70
+ "Bottom-Right": (2, 2),
71
+ "left": (0, 1),
72
+ "right": (2, 1),
73
+ "top": (1, 0),
74
+ "bottom": (1, 2),
75
+ }
76
+
77
+ if position not in position_map:
78
+ continue
79
+
80
+ col, row = position_map[position]
81
+
82
+ # Compute target region center
83
+ tgt_region_cx = tgt_x + (col + 0.5) * tgt_cell_w
84
+ tgt_region_cy = tgt_y + (row + 0.5) * tgt_cell_h
85
+
86
+ tgt_region_cx_t = torch.tensor(tgt_region_cx, device=device)
87
+ tgt_region_cy_t = torch.tensor(tgt_region_cy, device=device)
88
+
89
+ # Compute squared distance from source center to target region center
90
+ dist_sq = (src_cx_t - tgt_region_cx_t) ** 2 + (src_cy_t - tgt_region_cy_t) ** 2
91
+ L_orientation += dist_sq
92
+
93
+ return L_orientation
94
+
95
+ def get_weight_key(self) -> str:
96
+ return "w_orientation"
97
+
modules/infographics_generator/layout_system/constraints/overlap.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Overlap constraint processor."""
2
+
3
+ import torch
4
+ from typing import List, Tuple
5
+ from .base import ConstraintProcessor
6
+
7
+
8
+ class OverlapProcessor(ConstraintProcessor):
9
+ """Processor for overlap constraints.
10
+
11
+ Note: Overlap is typically handled as a hard constraint in the optimization,
12
+ but this processor can provide additional soft penalties if needed.
13
+ """
14
+
15
+ def can_handle(self, constraint_type: str) -> bool:
16
+ return constraint_type == "overlap"
17
+
18
+ def process(self, constraint: dict, bboxes: List[Tuple[float, float, float, float]],
19
+ device: str = "cpu") -> torch.Tensor:
20
+ """Process overlap constraints.
21
+
22
+ Args:
23
+ constraint: Dictionary with "overlap" key
24
+ bboxes: List of (x, y, w, h) bounding boxes
25
+ device: Device for tensors
26
+
27
+ Returns:
28
+ Loss tensor (typically 0, as overlap is handled as hard constraint)
29
+ """
30
+ # Overlap is handled as a hard constraint in the main optimization loop
31
+ # This processor can be extended to add soft penalties if needed
32
+ return torch.tensor(0.0, device=device)
33
+
34
+ def get_weight_key(self) -> str:
35
+ return "w_overlap"
36
+
modules/infographics_generator/layout_system/constraints/padding.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Padding constraint processor."""
2
+
3
+ import torch
4
+ import torch.nn.functional as F
5
+ from typing import List, Tuple
6
+ from .base import ConstraintProcessor
7
+
8
+
9
+ class PaddingProcessor(ConstraintProcessor):
10
+ """Processor for padding constraints."""
11
+
12
+ def can_handle(self, constraint_type: str) -> bool:
13
+ return constraint_type == "padding"
14
+
15
+ def process(self, constraint: dict, bboxes: List[Tuple[float, float, float, float]],
16
+ device: str = "cpu") -> torch.Tensor:
17
+ """Process padding constraints.
18
+
19
+ Args:
20
+ constraint: Dictionary with "padding" key
21
+ bboxes: List of (x, y, w, h) bounding boxes
22
+ device: Device for tensors
23
+
24
+ Returns:
25
+ Loss tensor
26
+ """
27
+ padding_constraint = constraint.get("padding", {})
28
+ if not padding_constraint:
29
+ return torch.tensor(0.0, device=device)
30
+
31
+ L_padding = torch.tensor(0.0, device=device)
32
+
33
+ horiz = padding_constraint.get("horizontal", {})
34
+ vert = padding_constraint.get("vertical", {})
35
+
36
+ pad_left = horiz.get("left", 0)
37
+ pad_right = horiz.get("right", 0)
38
+ pad_top = vert.get("top", 0)
39
+ pad_bottom = vert.get("bottom", 0)
40
+
41
+ # Get container size from first bbox (assuming all bboxes are within container)
42
+ if not bboxes:
43
+ return L_padding
44
+
45
+ # Estimate container size (this should be passed separately in real implementation)
46
+ # For now, assume container is large enough
47
+ container_w = 1000.0 # Default, should be passed as parameter
48
+ container_h = 1000.0 # Default, should be passed as parameter
49
+
50
+ for x, y, w, h in bboxes:
51
+ x_t = torch.tensor(x, device=device)
52
+ y_t = torch.tensor(y, device=device)
53
+ w_t = torch.tensor(w, device=device)
54
+ h_t = torch.tensor(h, device=device)
55
+
56
+ if pad_left > 0:
57
+ L_padding += F.relu(torch.tensor(pad_left, device=device) - x_t) ** 2
58
+ if pad_right > 0:
59
+ L_padding += F.relu((x_t + w_t) - (torch.tensor(container_w, device=device) - torch.tensor(pad_right, device=device))) ** 2
60
+ if pad_top > 0:
61
+ L_padding += F.relu(torch.tensor(pad_top, device=device) - y_t) ** 2
62
+ if pad_bottom > 0:
63
+ L_padding += F.relu((y_t + h_t) - (torch.tensor(container_h, device=device) - torch.tensor(pad_bottom, device=device))) ** 2
64
+
65
+ return L_padding
66
+
67
+ def get_weight_key(self) -> str:
68
+ return "w_padding"
69
+
modules/infographics_generator/layout_system/constraints/relative_size.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Relative size constraint processor."""
2
+
3
+ import torch
4
+ import torch.nn.functional as F
5
+ from typing import List, Tuple
6
+ from .base import ConstraintProcessor
7
+
8
+
9
+ class RelativeSizeProcessor(ConstraintProcessor):
10
+ """Processor for relative size constraints."""
11
+
12
+ def can_handle(self, constraint_type: str) -> bool:
13
+ return constraint_type == "relative_size"
14
+
15
+ def process(self, constraint: dict, bboxes: List[Tuple[float, float, float, float]],
16
+ device: str = "cpu") -> torch.Tensor:
17
+ """Process relative size constraints.
18
+
19
+ Args:
20
+ constraint: Dictionary with "relative_size" key containing list of constraints
21
+ bboxes: List of (x, y, w, h) bounding boxes
22
+ device: Device for tensors
23
+
24
+ Returns:
25
+ Loss tensor
26
+ """
27
+ relative_size_constraints = constraint.get("relative_size", [])
28
+ if not relative_size_constraints:
29
+ return torch.tensor(0.0, device=device)
30
+
31
+ L_relative_size = torch.tensor(0.0, device=device)
32
+
33
+ for rel_constraint in relative_size_constraints:
34
+ src_idx = rel_constraint["source_index"]
35
+ tgt_idx = rel_constraint["target_index"]
36
+ target_ratio = rel_constraint["ratio"]
37
+
38
+ if src_idx >= len(bboxes) or tgt_idx >= len(bboxes):
39
+ continue
40
+
41
+ _, _, w_src, h_src = bboxes[src_idx]
42
+ _, _, w_tgt, h_tgt = bboxes[tgt_idx]
43
+
44
+ w_src_t = torch.tensor(w_src, device=device)
45
+ h_src_t = torch.tensor(h_src, device=device)
46
+ w_tgt_t = torch.tensor(w_tgt, device=device)
47
+ h_tgt_t = torch.tensor(h_tgt, device=device)
48
+ target_ratio_t = torch.tensor(target_ratio, device=device)
49
+
50
+ if rel_constraint["type"] == "relative_height":
51
+ actual_ratio = h_src_t / (h_tgt_t + 1e-8)
52
+ elif rel_constraint["type"] == "relative_width":
53
+ actual_ratio = w_src_t / (w_tgt_t + 1e-8)
54
+ else:
55
+ continue
56
+
57
+ # Use relative error
58
+ if target_ratio > 1e-6:
59
+ relative_error = (actual_ratio - target_ratio_t) / target_ratio_t
60
+ L_relative_size += relative_error ** 2
61
+ else:
62
+ L_relative_size += (actual_ratio - target_ratio_t) ** 2
63
+
64
+ return L_relative_size
65
+
66
+ def get_weight_key(self) -> str:
67
+ return "w_relative_size"
68
+
modules/infographics_generator/layout_system/element_loader.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 元素加载器
3
+
4
+ 从RGBA格式的PNG图片加载元素,根据图片尺寸确定元素尺寸,
5
+ 并根据alpha通道生成mask
6
+ """
7
+
8
+ import os
9
+ import numpy as np
10
+ from PIL import Image
11
+ from typing import Optional, Tuple
12
+
13
+ from .utils.nodes import LeafNode, NodeType
14
+
15
+
16
+ def resize_image_with_aspect_ratio(img: Image.Image,
17
+ target_width: int,
18
+ target_height: int) -> Tuple[Image.Image, Tuple[int, int]]:
19
+ """
20
+ 保持横纵比resize图片,最短边对齐,不添加透明padding
21
+
22
+ 例如:原始1024x1024,目标500x1000,结果500x500(保持1:1比例,最短边对齐)
23
+
24
+ Args:
25
+ img: PIL图片对象(RGBA格式)
26
+ target_width: 目标宽度
27
+ target_height: 目标高度
28
+
29
+ Returns:
30
+ (resized_image, actual_size): 调整后的图片和实际尺寸
31
+ """
32
+ original_width, original_height = img.size
33
+ original_aspect = original_width / original_height
34
+ print("original_aspect: ", original_aspect)
35
+ print("target_width: ", target_width, "target_height: ", target_height)
36
+ # 找到目标尺寸的较短边,以较短边为基准
37
+ if target_width <= target_height:
38
+ # 目标宽度是较短边,以宽度为准
39
+ new_width = target_width
40
+ new_height = int(original_height * (target_width / original_width))
41
+ else:
42
+ # 目标高度是较短边,以高度为准
43
+ new_height = target_height
44
+ new_width = int(original_width * (target_height / original_height))
45
+
46
+ # 确保不超过目标尺寸(双重检查)
47
+ if new_width > target_width:
48
+ new_width = target_width
49
+ new_height = int(original_height * (target_width / original_width))
50
+ if new_height > target_height:
51
+ new_height = target_height
52
+ new_width = int(original_width * (target_height / original_height))
53
+
54
+ print("new_width: ", new_width, "new_height: ", new_height)
55
+ # Resize图片(保持横纵比,不添加透明padding)
56
+ resized_img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
57
+
58
+ return resized_img, (new_width, new_height)
59
+
60
+
61
+ class ElementLoader:
62
+ """元素加载器"""
63
+
64
+ def __init__(self, base_dir: Optional[str] = None):
65
+ """
66
+ 初始化元素加载器
67
+
68
+ Args:
69
+ base_dir: 图片文件的基础目录,如果提供,相对路径会基于此目录
70
+ """
71
+ self.base_dir = base_dir
72
+
73
+ def load_from_image(self, image_path: str, node_id: str,
74
+ node_type: NodeType,
75
+ metadata: Optional[dict] = None,
76
+ target_width: Optional[float] = None,
77
+ target_height: Optional[float] = None) -> LeafNode:
78
+ """
79
+ 从PNG图片加载元素
80
+
81
+ Args:
82
+ image_path: 图片文件路径(可以是绝对路径或相对于base_dir的路径)
83
+ node_id: 节点ID
84
+ node_type: 节点类型
85
+ metadata: 额外的元数据
86
+ target_width: 目标宽度(可选,如果提供则resize图片)
87
+ target_height: 目标高度(可选,如果提供则resize图片)
88
+
89
+ Returns:
90
+ LeafNode对象,包含图片尺寸和mask
91
+ """
92
+ # 解析路径
93
+ full_path = self._resolve_path(image_path)
94
+
95
+ # 加载图片
96
+ img = Image.open(full_path)
97
+
98
+ # 确保是RGBA格式
99
+ if img.mode != 'RGBA':
100
+ img = img.convert('RGBA')
101
+
102
+ # 获取原始尺寸
103
+ original_width, original_height = img.size
104
+
105
+ # 如果指定了目标尺寸,resize图片(保持横纵比,最短边对齐)
106
+ if target_width is not None and target_height is not None:
107
+ target_width = int(target_width)
108
+ target_height = int(target_height)
109
+ # 使用保持横纵比的resize函数(最短边对齐,不添加透明padding)
110
+ img, (width, height) = resize_image_with_aspect_ratio(
111
+ img, target_width, target_height
112
+ )
113
+ else:
114
+ width, height = original_width, original_height
115
+
116
+ # 从alpha通道生成mask
117
+ # mask是二值图像,alpha > 0 的像素为1,否则为0
118
+ alpha_channel = np.array(img.split()[3]) # 获取alpha通道
119
+ mask = (alpha_channel > 0).astype(np.uint8) * 255
120
+
121
+ # 创建元数据
122
+ node_metadata = metadata or {}
123
+ node_metadata['image_path'] = image_path
124
+ node_metadata['image_size'] = (width, height)
125
+ node_metadata['original_image_size'] = (original_width, original_height)
126
+ if target_width is not None and target_height is not None:
127
+ node_metadata['resized'] = True
128
+
129
+ # 创建叶子节点
130
+ node = LeafNode(
131
+ node_id=node_id,
132
+ node_type=node_type,
133
+ width=float(width),
134
+ height=float(height),
135
+ mask=mask,
136
+ metadata=node_metadata
137
+ )
138
+
139
+ return node
140
+
141
+ def _resolve_path(self, image_path: str) -> str:
142
+ """解析图片路径"""
143
+ if os.path.isabs(image_path):
144
+ return image_path
145
+
146
+ if self.base_dir:
147
+ return os.path.join(self.base_dir, image_path)
148
+
149
+ return image_path
150
+
151
+ def load_chart(self, image_path: str, node_id: str,
152
+ metadata: Optional[dict] = None) -> LeafNode:
153
+ """加载图表元素"""
154
+ return self.load_from_image(image_path, node_id, NodeType.CHART, metadata)
155
+
156
+ def load_image(self, image_path: str, node_id: str,
157
+ metadata: Optional[dict] = None) -> LeafNode:
158
+ """加载图像元素"""
159
+ return self.load_from_image(image_path, node_id, NodeType.IMAGE, metadata)
160
+
161
+ def load_text(self, image_path: str, node_id: str,
162
+ metadata: Optional[dict] = None) -> LeafNode:
163
+ """加载文本元素(文本渲染为图片)"""
164
+ return self.load_from_image(image_path, node_id, NodeType.TEXT, metadata)
165
+
166
+ def load_shape(self, image_path: str, node_id: str,
167
+ metadata: Optional[dict] = None) -> LeafNode:
168
+ """加载形状元素"""
169
+ return self.load_from_image(image_path, node_id, NodeType.SHAPE, metadata)
170
+
171
+
172
+ def load_element_from_image(image_path: str, node_id: str,
173
+ node_type: NodeType,
174
+ base_dir: Optional[str] = None,
175
+ metadata: Optional[dict] = None,
176
+ target_width: Optional[float] = None,
177
+ target_height: Optional[float] = None) -> LeafNode:
178
+ """
179
+ 便捷函数:从图片加载元素
180
+
181
+ Args:
182
+ image_path: 图片文件路径
183
+ node_id: 节点ID
184
+ node_type: 节点类型
185
+ base_dir: 基础目录(可选)
186
+ metadata: 额外的元数据(可选)
187
+ target_width: 目标宽度(可选,如果提供则resize图片)
188
+ target_height: 目标高度(可选,如果提供则resize图片)
189
+
190
+ Returns:
191
+ LeafNode对象
192
+ """
193
+ loader = ElementLoader(base_dir=base_dir)
194
+ return loader.load_from_image(image_path, node_id, node_type, metadata,
195
+ target_width, target_height)
196
+
modules/infographics_generator/layout_system/handlers/__init__.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Node handlers for loading and processing different node types."""
2
+
3
+ from .base import NodeHandler
4
+ from .image_handler import ImageNodeHandler
5
+ from .text_handler import TextNodeHandler
6
+
7
+ __all__ = [
8
+ 'NodeHandler',
9
+ 'ImageNodeHandler',
10
+ 'TextNodeHandler',
11
+ ]
12
+
modules/infographics_generator/layout_system/handlers/base.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Base class for node handlers."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Tuple, Optional
5
+ import numpy as np
6
+ from PIL import Image
7
+
8
+
9
+ class NodeHandler(ABC):
10
+ """Base class for node handlers."""
11
+
12
+ @abstractmethod
13
+ def can_handle(self, node_type: str) -> bool:
14
+ """Check if this handler can handle the given node type.
15
+
16
+ Args:
17
+ node_type: Type of node (e.g., "image", "text", "chart")
18
+
19
+ Returns:
20
+ True if this handler can handle the node type
21
+ """
22
+ pass
23
+
24
+ @abstractmethod
25
+ def load(self, node: dict, base_dir: Optional[str] = None) -> Tuple[np.ndarray, dict]:
26
+ """Load node data and return mask and metadata.
27
+
28
+ Args:
29
+ node: Node dictionary from JSON
30
+ base_dir: Base directory for resolving relative paths
31
+
32
+ Returns:
33
+ Tuple of (mask, metadata)
34
+ - mask: Binary mask array (H, W) with values 0 or 1
35
+ - metadata: Dictionary with additional node information
36
+ """
37
+ pass
38
+
39
+ @abstractmethod
40
+ def create_placeholder(self, width: float, height: float,
41
+ metadata: Optional[dict] = None) -> Tuple[Image.Image, np.ndarray]:
42
+ """Create placeholder image and mask for nodes without image_path.
43
+
44
+ Args:
45
+ width: Placeholder width
46
+ height: Placeholder height
47
+ metadata: Optional metadata dictionary
48
+
49
+ Returns:
50
+ Tuple of (image, mask)
51
+ - image: PIL Image (RGBA format)
52
+ - mask: Binary mask array (H, W)
53
+ """
54
+ pass
55
+
modules/infographics_generator/layout_system/handlers/image_handler.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Image node handler."""
2
+
3
+ import os
4
+ import numpy as np
5
+ from PIL import Image
6
+ from typing import Tuple, Optional
7
+ from .base import NodeHandler
8
+ from modules.infographics_generator.layout_system.utils.placeholder import create_placeholder_rectangle
9
+
10
+
11
+ def load_binary_mask_from_rgba(png_path: str, alpha_thresh: int = 1) -> np.ndarray:
12
+ """Load binary mask from RGBA PNG.
13
+
14
+ For chart images, will try to use the no_grid version if available.
15
+ """
16
+ # 如果是 chart 图片,尝试使用 no_grid 版本
17
+ actual_path = get_no_grid_version(png_path)
18
+
19
+ img = Image.open(actual_path).convert("RGBA")
20
+ a = np.array(img, dtype=np.uint8)[..., 3]
21
+ mask = (a > alpha_thresh).astype(np.float32)
22
+ return mask
23
+
24
+
25
+ def get_no_grid_version(image_path: str) -> str:
26
+ """
27
+ 获取图片的 no_grid 版本路径,如果不存在则返回原始路径。
28
+
29
+ 用于图表(chart)图片的 mask 计算时,优先使用去掉网格线的版本,
30
+ 这样可以更准确地计算图表的实际内容区域。
31
+
32
+ Args:
33
+ image_path: 原始图片路径 (如 variation_xxx.png)
34
+
35
+ Returns:
36
+ no_grid 版本路径(如果存在)或原始路径
37
+ """
38
+ if not image_path or not os.path.exists(image_path):
39
+ return image_path
40
+
41
+ # 构造 no_grid 版本的路径
42
+ base_name, ext = os.path.splitext(image_path)
43
+ no_grid_path = f"{base_name}_no_grid{ext}"
44
+
45
+ # 如果 no_grid 版本存在,返回它;否则返回原始路径
46
+ if os.path.exists(no_grid_path):
47
+ print(f"[ImageHandler] 使用 no_grid 版本进行 mask 计算: {os.path.basename(no_grid_path)}")
48
+ return no_grid_path
49
+ else:
50
+ return image_path
51
+
52
+
53
+ class ImageNodeHandler(NodeHandler):
54
+ """Handler for image and chart nodes."""
55
+
56
+ def can_handle(self, node_type: str) -> bool:
57
+ return node_type in ["image", "chart"]
58
+
59
+ def load(self, node: dict, base_dir: Optional[str] = None) -> Tuple[np.ndarray, dict]:
60
+ """Load image node data.
61
+
62
+ Args:
63
+ node: Node dictionary with "image_path" key
64
+ base_dir: Base directory for resolving relative paths
65
+
66
+ Returns:
67
+ Tuple of (mask, metadata)
68
+ """
69
+ image_path = node.get("image_path")
70
+ if not image_path:
71
+ # No image_path, create placeholder from bbox
72
+ bbox = node.get("bbox", {})
73
+ width = bbox.get("width", 100)
74
+ height = bbox.get("height", 100)
75
+ _, mask = create_placeholder_rectangle(width, height)
76
+ return mask, {"placeholder": True, "width": width, "height": height}
77
+
78
+ # Resolve path
79
+ if base_dir and not os.path.isabs(image_path):
80
+ full_path = os.path.join(base_dir, image_path)
81
+ else:
82
+ full_path = image_path
83
+
84
+ # Load mask
85
+ mask = load_binary_mask_from_rgba(full_path)
86
+
87
+ metadata = {
88
+ "image_path": image_path,
89
+ "full_path": full_path,
90
+ "shape": mask.shape,
91
+ }
92
+
93
+ return mask, metadata
94
+
95
+ def create_placeholder(self, width: float, height: float,
96
+ metadata: Optional[dict] = None) -> Tuple[Image.Image, np.ndarray]:
97
+ """Create placeholder for image node."""
98
+ return create_placeholder_rectangle(width, height)
99
+
modules/infographics_generator/layout_system/handlers/text_handler.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Text node handler."""
2
+
3
+ import os
4
+ import numpy as np
5
+ from PIL import Image
6
+ from typing import Tuple, Optional
7
+ from .base import NodeHandler
8
+ from modules.infographics_generator.layout_system.utils.placeholder import create_placeholder_rectangle
9
+
10
+
11
+ def load_binary_mask_from_rgba(png_path: str, alpha_thresh: int = 1) -> np.ndarray:
12
+ """Load binary mask from RGBA PNG."""
13
+ img = Image.open(png_path).convert("RGBA")
14
+ a = np.array(img, dtype=np.uint8)[..., 3]
15
+ mask = (a > alpha_thresh).astype(np.float32)
16
+ return mask
17
+
18
+
19
+ class TextNodeHandler(NodeHandler):
20
+ """Handler for text nodes."""
21
+
22
+ def can_handle(self, node_type: str) -> bool:
23
+ return node_type == "text"
24
+
25
+ def load(self, node: dict, base_dir: Optional[str] = None) -> Tuple[np.ndarray, dict]:
26
+ """Load text node data.
27
+
28
+ Args:
29
+ node: Node dictionary with bbox and optionally image_path
30
+ base_dir: Base directory for resolving image paths
31
+
32
+ Returns:
33
+ Tuple of (mask, metadata)
34
+ """
35
+ # Check if text node has image_path (e.g., rendered text image)
36
+ image_path = node.get("image_path")
37
+ if image_path:
38
+ # Resolve full path
39
+ if base_dir:
40
+ full_path = os.path.join(base_dir, image_path)
41
+ else:
42
+ full_path = image_path
43
+
44
+ # Load actual mask from image if file exists
45
+ if os.path.exists(full_path):
46
+ mask = load_binary_mask_from_rgba(full_path)
47
+ metadata = {
48
+ "image_path": image_path,
49
+ "placeholder": False,
50
+ "content": node.get("content", ""),
51
+ "type": "text",
52
+ }
53
+ return mask, metadata
54
+
55
+ # Fallback: use bbox to create placeholder
56
+ bbox = node.get("bbox", {})
57
+ width = bbox.get("width", 100)
58
+ height = bbox.get("height", 100)
59
+
60
+ # Create placeholder rectangle
61
+ _, mask = create_placeholder_rectangle(width, height)
62
+
63
+ metadata = {
64
+ "placeholder": True,
65
+ "width": width,
66
+ "height": height,
67
+ "content": node.get("content", ""),
68
+ "type": "text",
69
+ }
70
+
71
+ return mask, metadata
72
+
73
+ def create_placeholder(self, width: float, height: float,
74
+ metadata: Optional[dict] = None) -> Tuple[Image.Image, np.ndarray]:
75
+ """Create placeholder for text node."""
76
+ return create_placeholder_rectangle(width, height)
77
+
modules/infographics_generator/layout_system/hierarchical_optimizer.py ADDED
@@ -0,0 +1,640 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hierarchical layout optimizer with extensible architecture."""
2
+
3
+ from typing import Dict, List, Optional, Tuple, Any
4
+ import numpy as np
5
+ from dataclasses import dataclass, field
6
+ from . import parameters as params
7
+ from .constraints import (
8
+ ConstraintProcessor,
9
+ RelativeSizeProcessor,
10
+ PaddingProcessor,
11
+ OrientationProcessor,
12
+ OverlapProcessor,
13
+ AlignmentProcessor,
14
+ GapProcessor,
15
+ )
16
+ from .handlers import (
17
+ NodeHandler,
18
+ ImageNodeHandler,
19
+ TextNodeHandler,
20
+ )
21
+ from .strategies import OptimizationStrategy, SDFOptimizationStrategy, RuleBasedLayoutStrategy
22
+ from .utils.parser import parse_layout_tree, LayoutNode
23
+ from .utils.composite import composite_nodes
24
+ from .utils.placeholder import create_placeholder_rectangle, create_placeholder_rounded_rectangle
25
+
26
+
27
+ @dataclass
28
+ class OptimizationConfig:
29
+ """Optimization configuration."""
30
+ strategy: OptimizationStrategy = field(default_factory=SDFOptimizationStrategy)
31
+ constraint_processors: Dict[str, ConstraintProcessor] = field(default_factory=dict)
32
+ node_handlers: Dict[str, NodeHandler] = field(default_factory=dict)
33
+ weights: Dict[str, float] = field(default_factory=dict)
34
+ optimization_params: Dict[str, Any] = field(default_factory=dict)
35
+ placeholder_config: Dict[str, Any] = field(default_factory=dict)
36
+ base_dir: Optional[str] = None
37
+ device: Optional[str] = None
38
+ debug: bool = False # Enable debug mode: visualization and detailed logging
39
+ use_rule_based: bool = True # Enable rule-based layout for row/column nodes (faster)
40
+ rule_based_types: List[str] = field(default_factory=lambda: ['row', 'column']) # Types that use rule-based layout
41
+
42
+ def __post_init__(self):
43
+ """Initialize default values."""
44
+ if not self.constraint_processors:
45
+ self.constraint_processors = {
46
+ "relative_size": RelativeSizeProcessor(),
47
+ "padding": PaddingProcessor(),
48
+ "orientation": OrientationProcessor(),
49
+ "overlap": OverlapProcessor(),
50
+ "gap": GapProcessor(),
51
+ }
52
+
53
+ if not self.node_handlers:
54
+ self.node_handlers = {
55
+ "image": ImageNodeHandler(),
56
+ "chart": ImageNodeHandler(),
57
+ "text": TextNodeHandler(),
58
+ }
59
+
60
+ if not self.weights:
61
+ self.weights = {
62
+ "w_similarity": params.W_SIMILARITY,
63
+ "w_readability": params.W_READABILITY,
64
+ "w_alignment_consistency": params.W_ALIGNMENT_CONSISTENCY,
65
+ "w_alignment_similarity": params.W_ALIGNMENT_SIMILARITY,
66
+ "w_proximity": params.W_PROXIMITY,
67
+ }
68
+
69
+ if not self.optimization_params:
70
+ self.optimization_params = {
71
+ "opt_res_list": params.OPT_RES_LIST,
72
+ "outer_rounds": params.OUTER_ROUNDS,
73
+ "inner_steps": params.INNER_STEPS,
74
+ "lr": params.LEARNING_RATE,
75
+ }
76
+
77
+
78
+ class HierarchicalOptimizer:
79
+ """Hierarchical layout optimizer with extensible architecture."""
80
+
81
+ def __init__(self, config: Optional[OptimizationConfig] = None):
82
+ """Initialize optimizer.
83
+
84
+ Args:
85
+ config: Optimization configuration (uses default if None)
86
+ """
87
+ self.config = config or OptimizationConfig()
88
+ self._register_default_processors()
89
+ self._register_default_handlers()
90
+
91
+ def _register_default_processors(self):
92
+ """Register default constraint processors."""
93
+ if not self.config.constraint_processors:
94
+ self.config.constraint_processors = {
95
+ "relative_size": RelativeSizeProcessor(),
96
+ "padding": PaddingProcessor(),
97
+ "orientation": OrientationProcessor(),
98
+ "overlap": OverlapProcessor(),
99
+ }
100
+
101
+ def _register_default_handlers(self):
102
+ """Register default node handlers."""
103
+ if not self.config.node_handlers:
104
+ self.config.node_handlers = {
105
+ "image": ImageNodeHandler(),
106
+ "chart": ImageNodeHandler(),
107
+ "text": TextNodeHandler(),
108
+ }
109
+
110
+ def register_processor(self, processor: ConstraintProcessor):
111
+ """Register a custom constraint processor.
112
+
113
+ Args:
114
+ processor: Constraint processor instance
115
+ """
116
+ # Register for all constraint types it can handle
117
+ for constraint_type in ["relative_size", "padding", "orientation", "overlap", "gap", "alignment"]:
118
+ if processor.can_handle(constraint_type):
119
+ self.config.constraint_processors[constraint_type] = processor
120
+
121
+ def register_handler(self, handler: NodeHandler):
122
+ """Register a custom node handler.
123
+
124
+ Args:
125
+ handler: Node handler instance
126
+ """
127
+ # Register for all node types it can handle
128
+ for node_type in ["image", "chart", "text", "shape", "layer", "column", "row"]:
129
+ if handler.can_handle(node_type):
130
+ self.config.node_handlers[node_type] = handler
131
+
132
+ def set_strategy(self, strategy: OptimizationStrategy):
133
+ """Set optimization strategy.
134
+
135
+ Args:
136
+ strategy: Optimization strategy instance
137
+ """
138
+ self.config.strategy = strategy
139
+
140
+ def optimize_tree(self, tree_json: dict) -> Dict[str, Any]:
141
+ """Optimize entire tree structure.
142
+
143
+ Args:
144
+ tree_json: JSON dictionary with layout tree structure
145
+
146
+ Returns:
147
+ Dictionary with optimization results for each node
148
+ """
149
+ # Parse tree
150
+ root_node = parse_layout_tree(tree_json)
151
+ # print("Root node: ", root_node)
152
+
153
+ # Get container bbox from root
154
+ root_bbox = (
155
+ root_node.bbox.get("x", 0),
156
+ root_node.bbox.get("y", 0),
157
+ root_node.bbox.get("width", 1000),
158
+ root_node.bbox.get("height", 1000),
159
+ )
160
+
161
+ # Optimize recursively from bottom up
162
+ result = self._optimize_node(root_node, root_bbox, "root")
163
+
164
+ return result
165
+
166
+ def _optimize_node(self, node: LayoutNode, parent_bbox: Tuple[float, float, float, float],
167
+ node_path: str = "root") -> Dict[str, Any]:
168
+ """Optimize a single node and its children recursively.
169
+
170
+ Args:
171
+ node: Layout node to optimize
172
+ parent_bbox: Parent container bounding box (x, y, w, h)
173
+ node_path: Path string for this node (e.g., "root.child0.child1")
174
+
175
+ Returns:
176
+ Dictionary with optimization results
177
+ """
178
+ result = {
179
+ "type": node.type,
180
+ "bbox": node.bbox,
181
+ "final_bbox": None,
182
+ "image_path": getattr(node, "image_path", None), # Preserve image_path for saving
183
+ }
184
+
185
+ # Check if node is a container (has children)
186
+ if node.children:
187
+ # Check if we can use rule-based layout (much faster for row/column)
188
+ if self._can_use_rule_based_layout(node):
189
+ return self._rule_based_layout(node, parent_bbox, node_path)
190
+
191
+ # Container node: optimize children using SDF optimization
192
+ # print(f"[HierarchicalOptimizer] Processing container node: type={node.type}, num_children={len(node.children)}, path={node_path}")
193
+ child_results = []
194
+ child_nodes_data = []
195
+
196
+ # First, recursively optimize all children
197
+ for i, child in enumerate(node.children):
198
+ child_path = f"{node_path}.child{i}"
199
+ child_result = self._optimize_node(child, parent_bbox, child_path)
200
+ child_results.append(child_result)
201
+
202
+ # Load child node data (masks, etc.)
203
+ # Use final_bbox from child_result if available (for container nodes that have been optimized),
204
+ # otherwise use initial bbox
205
+ for i, child in enumerate(node.children):
206
+ child_result = child_results[i]
207
+ # Get bbox: use final_bbox from child_result if child is a container that has been optimized
208
+ if child_result.get("final_bbox") is not None:
209
+ # Convert final_bbox tuple to dict format for consistency
210
+ final_bbox = child_result["final_bbox"]
211
+ if isinstance(final_bbox, (tuple, list)) and len(final_bbox) >= 4:
212
+ child_bbox = {
213
+ "x": final_bbox[0],
214
+ "y": final_bbox[1],
215
+ "width": final_bbox[2],
216
+ "height": final_bbox[3]
217
+ }
218
+ else:
219
+ child_bbox = child.bbox
220
+ else:
221
+ child_bbox = child.bbox
222
+
223
+ # Check if child has composite_mask (from previous optimization)
224
+ # Use composite_mask if available, as it represents the actual shape
225
+ mask = None
226
+ metadata = {}
227
+ if child_result.get("composite_mask") is not None:
228
+ mask = child_result["composite_mask"]
229
+ # print(f"[HierarchicalOptimizer] Using composite_mask for child {i} (type={child.type})")
230
+ else:
231
+ handler = self._get_handler(child.type)
232
+ if handler:
233
+ mask, metadata = handler.load(child.__dict__, self.config.base_dir)
234
+ else:
235
+ # Fallback: use placeholder
236
+ bbox = child_bbox
237
+ width = bbox.get("width", 100) if isinstance(bbox, dict) else (bbox[2] if isinstance(bbox, (tuple, list)) else 100)
238
+ height = bbox.get("height", 100) if isinstance(bbox, dict) else (bbox[3] if isinstance(bbox, (tuple, list)) else 100)
239
+ # For container nodes (layer/column/row), create a rounded rectangle instead of solid rectangle
240
+ if child.type in ["layer", "column", "row"]:
241
+ try:
242
+ _, mask = create_placeholder_rounded_rectangle(width, height)
243
+ except Exception:
244
+ # Fallback to rectangle if rounded rectangle is unavailable
245
+ _, mask = create_placeholder_rectangle(width, height)
246
+ else:
247
+ _, mask = create_placeholder_rectangle(width, height)
248
+ metadata = {"placeholder": True}
249
+
250
+ child_nodes_data.append({
251
+ "mask": mask,
252
+ "bbox": child_bbox,
253
+ "metadata": metadata,
254
+ "type": child.type,
255
+ })
256
+
257
+ # Get container bbox (use node's bbox or parent's)
258
+ container_bbox = (
259
+ node.bbox.get("x", 0),
260
+ node.bbox.get("y", 0),
261
+ node.bbox.get("width", parent_bbox[2]),
262
+ node.bbox.get("height", parent_bbox[3]),
263
+ )
264
+
265
+ # Optimize children layout
266
+ constraints = node.constraints or {}
267
+
268
+ # Generate unique save prefix for this node using node path
269
+ # Replace dots and special characters to make valid filename
270
+ save_prefix = node_path.replace(".", "_").replace(" ", "_")
271
+
272
+ # Extract grandchildren info for proximity ratio calculation
273
+ # For each child, collect its children's bboxes (grandchildren of current container)
274
+ grandchildren_list = []
275
+ for child_result in child_results:
276
+ grandchildren = []
277
+ # Check if child_result has children (from recursive optimization)
278
+ child_children = child_result.get("children", [])
279
+ for grandchild_result in child_children:
280
+ grandchild_bbox = grandchild_result.get("final_bbox")
281
+ if grandchild_bbox:
282
+ if isinstance(grandchild_bbox, (tuple, list)) and len(grandchild_bbox) >= 4:
283
+ grandchildren.append(tuple(grandchild_bbox[:4]))
284
+ elif isinstance(grandchild_bbox, dict):
285
+ grandchildren.append((
286
+ grandchild_bbox.get("x", 0),
287
+ grandchild_bbox.get("y", 0),
288
+ grandchild_bbox.get("width", grandchild_bbox.get("w", 0)),
289
+ grandchild_bbox.get("height", grandchild_bbox.get("h", 0))
290
+ ))
291
+ grandchildren_list.append(grandchildren)
292
+
293
+ config = {
294
+ **self.config.optimization_params,
295
+ "device": self.config.device,
296
+ "debug": self.config.debug, # Pass debug flag
297
+ "w_similarity": self.config.weights.get("w_similarity", 1.0),
298
+ "w_readability": self.config.weights.get("w_readability", 1.0),
299
+ "w_alignment_consistency": self.config.weights.get("w_alignment_consistency", 1.0),
300
+ "w_alignment_similarity": self.config.weights.get("w_alignment_similarity", params.W_ALIGNMENT_SIMILARITY),
301
+ "w_proximity": self.config.weights.get("w_proximity", params.W_PROXIMITY),
302
+ "container_type": node.type, # Pass container type (column, row, or layer)
303
+ "grandchildren_list": grandchildren_list, # Pass grandchildren for proximity calculation
304
+ }
305
+
306
+ # print(f"[HierarchicalOptimizer] Calling strategy.optimize for container_type={node.type}, num_children={len(child_nodes_data)}")
307
+ # print("child_nodes_data:", child_nodes_data)
308
+ # print("config:", config)
309
+ optimized_bboxes = self.config.strategy.optimize(
310
+ child_nodes_data,
311
+ container_bbox,
312
+ constraints,
313
+ config,
314
+ save_prefix=save_prefix,
315
+ )
316
+
317
+ # print(f"[HierarchicalOptimizer] Strategy returned optimized_bboxes: {optimized_bboxes}")
318
+ # Calculate actual container bbox based on children's layout results
319
+ # Note: optimized_bboxes are relative to container origin (0,0)
320
+ if optimized_bboxes:
321
+ # Convert bbox to tuple format if needed (handle both dict and tuple formats)
322
+ def bbox_to_tuple(bbox):
323
+ if isinstance(bbox, dict):
324
+ return (
325
+ bbox.get("x", 0),
326
+ bbox.get("y", 0),
327
+ bbox.get("width", bbox.get("w", 0)),
328
+ bbox.get("height", bbox.get("h", 0))
329
+ )
330
+ elif isinstance(bbox, (tuple, list)) and len(bbox) >= 4:
331
+ return tuple(bbox[:4])
332
+ else:
333
+ return (0, 0, 0, 0)
334
+
335
+ bbox_tuples = [bbox_to_tuple(bbox) for bbox in optimized_bboxes]
336
+
337
+ # print("bbox_tuples:", bbox_tuples)
338
+ # Find the bounding box that contains all children (for position adjustment)
339
+ min_x = min(bbox[0] for bbox in bbox_tuples)
340
+ min_y = min(bbox[1] for bbox in bbox_tuples)
341
+
342
+ # Adjust children's bboxes: shift them so that min_x and min_y become 0
343
+ # This makes children relative to the new container origin (0,0)
344
+ # For container nodes, preserve their own calculated width/height
345
+ adjusted_bboxes_for_composite = []
346
+ for i, (child_result, opt_bbox) in enumerate(zip(child_results, optimized_bboxes)):
347
+ bbox_tuple = bbox_to_tuple(opt_bbox)
348
+ # Adjust coordinates: subtract min_x and min_y to start from (0,0)
349
+ adjusted_x = bbox_tuple[0] - min_x
350
+ adjusted_y = bbox_tuple[1] - min_y
351
+
352
+ # For container nodes, preserve the width/height calculated from their children
353
+ # Only update position (x, y), not size (w, h)
354
+ if child_result.get("children") and child_result.get("final_bbox"):
355
+ # Child is a container with its own final_bbox calculated from its children
356
+ child_final = child_result["final_bbox"]
357
+ if isinstance(child_final, (tuple, list)) and len(child_final) >= 4:
358
+ # Use the child's own calculated width and height
359
+ adjusted_bbox = (
360
+ adjusted_x,
361
+ adjusted_y,
362
+ child_final[2], # Keep child's calculated width
363
+ child_final[3] # Keep child's calculated height
364
+ )
365
+ else:
366
+ adjusted_bbox = (adjusted_x, adjusted_y, bbox_tuple[2], bbox_tuple[3])
367
+ else:
368
+ # Leaf node or no children: use optimized bbox size
369
+ adjusted_bbox = (adjusted_x, adjusted_y, bbox_tuple[2], bbox_tuple[3])
370
+
371
+ adjusted_bboxes_for_composite.append(adjusted_bbox)
372
+
373
+ # Store relative coordinates (relative to parent container) in final_bbox
374
+ # save_result.py will convert to absolute coordinates by adding parent offsets
375
+ child_result["final_bbox"] = adjusted_bbox
376
+ if i < len(node.children):
377
+ node.children[i].final_bbox = adjusted_bbox
378
+ # print(f"node.children[{i}].final_bbox:", node.children[i].final_bbox)
379
+
380
+ # Calculate actual container size based on adjusted bboxes
381
+ # This ensures the container size is based on children's preserved widths/heights
382
+ actual_max_x = max(bbox[0] + bbox[2] for bbox in adjusted_bboxes_for_composite)
383
+ actual_max_y = max(bbox[1] + bbox[3] for bbox in adjusted_bboxes_for_composite)
384
+ actual_width = actual_max_x
385
+ actual_height = actual_max_y
386
+ # print(f"actual_container_size: width={actual_width:.2f}, height={actual_height:.2f}")
387
+
388
+ # Container position: keep original container position
389
+ # Container size: actual size needed to contain all children
390
+ # Children are now adjusted to start from (0,0) relative to container
391
+ actual_container_bbox = (
392
+ container_bbox[0], # Keep original x position
393
+ container_bbox[1], # Keep original y position
394
+ actual_width, # Actual width needed
395
+ actual_height # Actual height needed
396
+ )
397
+ else:
398
+ # Fallback to original container bbox if no children
399
+ actual_container_bbox = container_bbox
400
+ adjusted_bboxes_for_composite = optimized_bboxes
401
+ # Update child results with optimized bboxes (no adjustment needed)
402
+ for i, (child_result, opt_bbox) in enumerate(zip(child_results, optimized_bboxes)):
403
+ child_result["final_bbox"] = opt_bbox
404
+ if i < len(node.children):
405
+ node.children[i].final_bbox = opt_bbox
406
+
407
+ # Composite children results (use actual container bbox and adjusted bboxes)
408
+ composite_mask, composite_sdf = self.config.strategy.composite(
409
+ child_nodes_data,
410
+ adjusted_bboxes_for_composite,
411
+ actual_container_bbox,
412
+ )
413
+
414
+ result["final_bbox"] = actual_container_bbox
415
+ result["composite_mask"] = composite_mask
416
+ result["composite_sdf"] = composite_sdf
417
+ result["children"] = child_results
418
+
419
+ else:
420
+ # Leaf node: just load data
421
+ handler = self._get_handler(node.type)
422
+ if handler:
423
+ mask, metadata = handler.load(node.__dict__, self.config.base_dir)
424
+ result["mask"] = mask
425
+ result["metadata"] = metadata
426
+ result["final_bbox"] = (
427
+ node.bbox.get("x", 0),
428
+ node.bbox.get("y", 0),
429
+ node.bbox.get("width", 100),
430
+ node.bbox.get("height", 100),
431
+ )
432
+
433
+ return result
434
+
435
+ def _get_handler(self, node_type: str) -> Optional[NodeHandler]:
436
+ """Get handler for node type.
437
+
438
+ Args:
439
+ node_type: Type of node
440
+
441
+ Returns:
442
+ Node handler or None
443
+ """
444
+ return self.config.node_handlers.get(node_type)
445
+
446
+ def _can_use_rule_based_layout(self, node: LayoutNode) -> bool:
447
+ """Check if node can use rule-based layout instead of SDF optimization.
448
+
449
+ Rule-based layout is much faster and more accurate for simple row/column layouts.
450
+
451
+ Args:
452
+ node: Layout node to check
453
+
454
+ Returns:
455
+ True if rule-based layout can be used
456
+ """
457
+ # Check if rule-based is enabled
458
+ if not self.config.use_rule_based:
459
+ return False
460
+
461
+ # Check if node type is in the allowed list
462
+ if node.type not in self.config.rule_based_types:
463
+ return False
464
+
465
+ # Need at least 2 children to benefit from rule-based layout
466
+ if not node.children or len(node.children) < 2:
467
+ return False
468
+
469
+ # Check for complex overlap constraints that require SDF
470
+ constraints = node.constraints or {}
471
+ if 'overlap' in constraints:
472
+ # Overlap constraints need precise collision detection - use SDF
473
+ return False
474
+
475
+ return True
476
+
477
+ def _rule_based_layout(self, node: LayoutNode, parent_bbox: Tuple[float, float, float, float],
478
+ node_path: str) -> Dict[str, Any]:
479
+ """Execute rule-based layout for simple row/column arrangements.
480
+
481
+ Args:
482
+ node: Layout node to optimize
483
+ parent_bbox: Parent container bounding box (x, y, w, h)
484
+ node_path: Path string for this node
485
+
486
+ Returns:
487
+ Dictionary with optimization results
488
+ """
489
+ # print(f"[HierarchicalOptimizer] Using rule-based layout for {node.type} node: {node_path}")
490
+
491
+ result = {
492
+ "type": node.type,
493
+ "bbox": node.bbox,
494
+ "final_bbox": None,
495
+ "image_path": getattr(node, "image_path", None),
496
+ }
497
+
498
+ # First, recursively optimize all children
499
+ child_results = []
500
+ for i, child in enumerate(node.children):
501
+ child_path = f"{node_path}.child{i}"
502
+ child_result = self._optimize_node(child, parent_bbox, child_path)
503
+ child_results.append(child_result)
504
+
505
+ # Load child node data
506
+ child_nodes_data = []
507
+ for i, child in enumerate(node.children):
508
+ child_result = child_results[i]
509
+
510
+ # Get bbox from child result
511
+ if child_result.get("final_bbox") is not None:
512
+ final_bbox = child_result["final_bbox"]
513
+ if isinstance(final_bbox, (tuple, list)) and len(final_bbox) >= 4:
514
+ child_bbox = {
515
+ "x": final_bbox[0],
516
+ "y": final_bbox[1],
517
+ "width": final_bbox[2],
518
+ "height": final_bbox[3]
519
+ }
520
+ else:
521
+ child_bbox = child.bbox
522
+ else:
523
+ child_bbox = child.bbox
524
+
525
+ # Get mask
526
+ mask = None
527
+ metadata = {}
528
+ if child_result.get("composite_mask") is not None:
529
+ mask = child_result["composite_mask"]
530
+ else:
531
+ handler = self._get_handler(child.type)
532
+ if handler:
533
+ mask, metadata = handler.load(child.__dict__, self.config.base_dir)
534
+ else:
535
+ # Fallback placeholder
536
+ bbox = child_bbox
537
+ width = bbox.get("width", 100) if isinstance(bbox, dict) else bbox[2]
538
+ height = bbox.get("height", 100) if isinstance(bbox, dict) else bbox[3]
539
+ if child.type in ["layer", "column", "row"]:
540
+ _, mask = create_placeholder_rounded_rectangle(width, height)
541
+ else:
542
+ _, mask = create_placeholder_rectangle(width, height)
543
+ metadata = {"placeholder": True}
544
+
545
+ child_nodes_data.append({
546
+ "mask": mask,
547
+ "bbox": child_bbox,
548
+ "metadata": metadata,
549
+ "type": child.type,
550
+ })
551
+
552
+ # Get container bbox
553
+ container_bbox = (
554
+ node.bbox.get("x", 0),
555
+ node.bbox.get("y", 0),
556
+ node.bbox.get("width", parent_bbox[2]),
557
+ node.bbox.get("height", parent_bbox[3]),
558
+ )
559
+
560
+ # Use rule-based strategy
561
+ from .strategies import RuleBasedLayoutStrategy
562
+ rule_strategy = RuleBasedLayoutStrategy()
563
+
564
+ constraints = node.constraints or {}
565
+ config = {
566
+ "container_type": node.type,
567
+ }
568
+
569
+ # Calculate optimized bboxes using rule-based layout
570
+ optimized_bboxes = rule_strategy.optimize(
571
+ child_nodes_data,
572
+ container_bbox,
573
+ constraints,
574
+ config,
575
+ )
576
+
577
+ # Adjust bboxes to start from (0, 0) relative to container
578
+ if optimized_bboxes:
579
+ min_x = min(bbox[0] for bbox in optimized_bboxes)
580
+ min_y = min(bbox[1] for bbox in optimized_bboxes)
581
+
582
+ adjusted_bboxes_for_composite = []
583
+ for i, (child_result, opt_bbox) in enumerate(zip(child_results, optimized_bboxes)):
584
+ adjusted_x = opt_bbox[0] - min_x
585
+ adjusted_y = opt_bbox[1] - min_y
586
+
587
+ # For container nodes, preserve their calculated size
588
+ if child_result.get("children") and child_result.get("final_bbox"):
589
+ child_final = child_result["final_bbox"]
590
+ if isinstance(child_final, (tuple, list)) and len(child_final) >= 4:
591
+ adjusted_bbox = (
592
+ adjusted_x,
593
+ adjusted_y,
594
+ child_final[2], # Keep child's calculated width
595
+ child_final[3] # Keep child's calculated height
596
+ )
597
+ else:
598
+ adjusted_bbox = (adjusted_x, adjusted_y, opt_bbox[2], opt_bbox[3])
599
+ else:
600
+ adjusted_bbox = (adjusted_x, adjusted_y, opt_bbox[2], opt_bbox[3])
601
+
602
+ adjusted_bboxes_for_composite.append(adjusted_bbox)
603
+ child_result["final_bbox"] = adjusted_bbox
604
+ if i < len(node.children):
605
+ node.children[i].final_bbox = adjusted_bbox
606
+
607
+ # Calculate actual container size
608
+ actual_max_x = max(bbox[0] + bbox[2] for bbox in adjusted_bboxes_for_composite)
609
+ actual_max_y = max(bbox[1] + bbox[3] for bbox in adjusted_bboxes_for_composite)
610
+ actual_width = actual_max_x
611
+ actual_height = actual_max_y
612
+
613
+ actual_container_bbox = (
614
+ container_bbox[0],
615
+ container_bbox[1],
616
+ actual_width,
617
+ actual_height
618
+ )
619
+ else:
620
+ actual_container_bbox = container_bbox
621
+ adjusted_bboxes_for_composite = optimized_bboxes
622
+ for i, (child_result, opt_bbox) in enumerate(zip(child_results, optimized_bboxes)):
623
+ child_result["final_bbox"] = opt_bbox
624
+ if i < len(node.children):
625
+ node.children[i].final_bbox = opt_bbox
626
+
627
+ # Composite children results
628
+ composite_mask, composite_sdf = rule_strategy.composite(
629
+ child_nodes_data,
630
+ adjusted_bboxes_for_composite,
631
+ actual_container_bbox,
632
+ )
633
+
634
+ result["final_bbox"] = actual_container_bbox
635
+ result["composite_mask"] = composite_mask
636
+ result["composite_sdf"] = composite_sdf
637
+ result["children"] = child_results
638
+
639
+ return result
640
+
modules/infographics_generator/layout_system/parameters.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Global parameters for layout optimization."""
2
+
3
+ # Optimization resolution stages
4
+ OPT_RES_LIST = (1000,) # Reduced from (256, 512, 1000) for faster optimization
5
+
6
+ # Augmented Lagrangian parameters
7
+ OUTER_ROUNDS = 8 # augmented-lagrangian outer updates per stage (reduced from 5 for faster convergence)
8
+ INNER_STEPS = 30 # gradient steps per outer round (reduced from 50 for faster convergence)
9
+ RHO_INIT = 1e-4 # initial penalty parameter for constraint enforcement
10
+ RHO_MULT = 5.0 # multiplier for penalty parameter
11
+
12
+ # Optimizer parameters
13
+ LEARNING_RATE = 0.02
14
+ SIZE_MIN = 20.0 # minimum element size in pixels
15
+
16
+ # Penalty weights
17
+ PEN_WEIGHT = 1000.0 # weight for penetration penalty
18
+ PEN_ETA_PX = 1.0 # eta parameter for penetration penalty
19
+
20
+ # Loss weights
21
+ W_SIMILARITY = 0 # weight for position/size similarity loss
22
+ W_READABILITY = 0 # weight for readability loss (size hierarchy)
23
+ W_ALIGNMENT_CONSISTENCY = 10000.0 # weight for alignment consistency loss (hierarchical alignment)
24
+ W_ALIGNMENT_SIMILARITY = 0 # weight for alignment similarity loss (based on JSON constraint)
25
+ W_DATA_INK = 10 # weight for data ink loss (maximize union area, minimize white space)
26
+ W_VISUAL_BALANCE = 0.0 # weight for visual balance loss (centroid to center distance)
27
+
28
+ # Tau schedule for soft mask (controls boundary sharpness)
29
+ TAU_SCHEDULE = (2.0, 1.5, 1.2, 1.0, 0.8, 0.6, 0.5, 0.4, 0.35, 0.3, 0.25, 0.2)
30
+
31
+ # Pictogram dilation radius
32
+ PICTOGRAM_DILATION_RADIUS = 5.0 # pixels
33
+
34
+ # Readability threshold
35
+ SIZE_RATIO_THRESHOLD = 1.5 # minimum ratio to consider size difference significant
36
+ # Rules: if ratio >= 1.5 or ratio <= 1/1.5 (0.67), the size difference is significant
37
+
38
+ # Min size constraints (default values)
39
+ MIN_WIDTH_DEFAULT = 30.0
40
+ MIN_HEIGHT_DEFAULT = 30.0
41
+
42
+ # Proximity ratio loss parameters
43
+ W_PROXIMITY = 0 # Weight for proximity ratio loss (default disabled, can be enabled when needed)
44
+ PROXIMITY_EPSILON = 1e-6 # Small value to avoid division by zero
45
+
modules/infographics_generator/layout_system/sdf/__init__.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ """SDF-based layout optimization module.
2
+
3
+ This module provides SDF (Signed Distance Field) based optimization
4
+ for layout arrangement.
5
+ """
6
+
7
+ from .optimizer import optimize
8
+
9
+ __all__ = ["optimize"]
10
+
modules/infographics_generator/layout_system/sdf/bbox.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """BBox parameterization functions for optimization."""
2
+
3
+ import numpy as np
4
+ import torch
5
+
6
+
7
+ def bbox_aspect_from_unconstrained(tx, ty, ts, W, H, r, min_width=None, min_height=None, size_min=None):
8
+ """Convert unconstrained parameters to bbox with fixed aspect ratio.
9
+
10
+ This function maps unconstrained optimization parameters (tx, ty, ts) to
11
+ a valid bounding box that:
12
+ - Maintains the aspect ratio r = w/h
13
+ - Stays within the container bounds (W, H)
14
+ - Respects minimum size constraints
15
+
16
+ Args:
17
+ tx, ty, ts: Unconstrained parameters (will be mapped via sigmoid)
18
+ W, H: Container width and height
19
+ r: Aspect ratio (w/h)
20
+ min_width: Minimum width constraint (if None, uses size_min or default)
21
+ min_height: Minimum height constraint (if None, uses size_min or default)
22
+ size_min: Legacy parameter for backward compatibility (maps to both min_width and min_height)
23
+
24
+ Returns:
25
+ (x, y, w, h) bounding box
26
+ """
27
+ # Backward compatibility: if size_min is provided, use it for both
28
+ if size_min is not None:
29
+ if min_width is None:
30
+ min_width = size_min
31
+ if min_height is None:
32
+ min_height = size_min
33
+
34
+ # Use defaults if not provided
35
+ if min_width is None:
36
+ min_width = 30.0 # Default from parameters
37
+ if min_height is None:
38
+ min_height = 30.0 # Default from parameters
39
+
40
+ # w must satisfy w<=W and h=w/r<=H => w <= rH
41
+ wmax = min(float(W), float(r) * float(H))
42
+
43
+ # Enforce constraints:
44
+ # w >= min_width
45
+ # h = w/r >= min_height => w >= r * min_height
46
+ # Combined: w >= max(min_width, r * min_height)
47
+ wmin = max(float(min_width), float(r) * float(min_height))
48
+ if wmin >= wmax:
49
+ # infeasible min size; fall back
50
+ wmin = max(1.0, 0.5 * wmax)
51
+
52
+ w = wmin + (wmax - wmin) * torch.sigmoid(ts)
53
+ h = w / r
54
+
55
+ x = (W - w) * torch.sigmoid(tx)
56
+ y = (H - h) * torch.sigmoid(ty)
57
+ return x, y, w, h
58
+
59
+
60
+ def unconstrained_from_bbox(x, y, w, h, W, H, r, min_width=None, min_height=None, size_min=None):
61
+ """Convert bbox to unconstrained parameters (inverse of bbox_aspect_from_unconstrained).
62
+
63
+ This function is used to initialize optimization parameters from a reference bbox.
64
+ It adjusts the bbox to match the material's aspect ratio while keeping the area
65
+ approximately constant.
66
+
67
+ Args:
68
+ x, y, w, h: Bounding box (x, y, w, h)
69
+ W, H: Container width and height
70
+ r: Aspect ratio (w/h) - material's aspect ratio (used to adjust w/h)
71
+ min_width: Minimum width constraint
72
+ min_height: Minimum height constraint
73
+ size_min: Legacy parameter for backward compatibility
74
+
75
+ Returns:
76
+ (tx, ty, ts) unconstrained parameters
77
+ """
78
+ # Backward compatibility: if size_min is provided, use it for both
79
+ if size_min is not None:
80
+ if min_width is None:
81
+ min_width = size_min
82
+ if min_height is None:
83
+ min_height = size_min
84
+
85
+ # Use defaults if not provided
86
+ if min_width is None:
87
+ min_width = 30.0
88
+ if min_height is None:
89
+ min_height = 30.0
90
+
91
+ # Adjust w and h to match material's aspect ratio r
92
+ # If JSON bbox has aspect ratio r_json = w/h, but material has r = w_material/h_material
93
+ # We need to adjust: use material's aspect ratio, keep area approximately constant
94
+ r_json = w / h if h > 0 else 1.0
95
+
96
+ if abs(r_json - r) > 1e-6:
97
+ # Aspect ratios don't match, adjust to material's aspect ratio
98
+ # Strategy: keep area approximately constant, adjust to match r
99
+ area = w * h
100
+ if r > 0:
101
+ # w_new * h_new = area, w_new / h_new = r => h_new^2 * r = area => h_new = sqrt(area/r)
102
+ h_adjusted = np.sqrt(area / r)
103
+ w_adjusted = h_adjusted * r
104
+ else:
105
+ h_adjusted = h
106
+ w_adjusted = w
107
+
108
+ # Ensure adjusted dimensions are within bounds
109
+ w_adjusted = max(min_width, min(w_adjusted, W))
110
+ h_adjusted = w_adjusted / r if r > 0 else h_adjusted
111
+ h_adjusted = max(min_height, min(h_adjusted, H))
112
+ if r > 0:
113
+ w_adjusted = h_adjusted * r
114
+
115
+ w = float(w_adjusted)
116
+ h = float(h_adjusted)
117
+ print(f" Adjusted bbox from ({w/h if h>0 else 0:.4f}) to aspect ratio {r:.4f}: w={w:.1f}, h={h:.1f}")
118
+
119
+ # Compute wmin and wmax (same as in bbox_aspect_from_unconstrained)
120
+ wmax = min(float(W), float(r) * float(H))
121
+ wmin = max(float(min_width), float(r) * float(min_height))
122
+ if wmin >= wmax:
123
+ wmin = max(1.0, 0.5 * wmax)
124
+
125
+ # Invert: w = wmin + (wmax - wmin) * sigmoid(ts)
126
+ # => sigmoid(ts) = (w - wmin) / (wmax - wmin)
127
+ # => ts = logit((w - wmin) / (wmax - wmin))
128
+ if wmax > wmin:
129
+ sigmoid_ts = (w - wmin) / (wmax - wmin)
130
+ sigmoid_ts = np.clip(sigmoid_ts, 1e-6, 1.0 - 1e-6) # Avoid log(0)
131
+ ts = np.log(sigmoid_ts / (1.0 - sigmoid_ts))
132
+ else:
133
+ ts = 0.0
134
+
135
+ # Invert: x = (W - w) * sigmoid(tx)
136
+ # => sigmoid(tx) = x / (W - w)
137
+ # => tx = logit(x / (W - w))
138
+ if W > w:
139
+ sigmoid_tx = x / (W - w)
140
+ sigmoid_tx = np.clip(sigmoid_tx, 1e-6, 1.0 - 1e-6)
141
+ tx = np.log(sigmoid_tx / (1.0 - sigmoid_tx))
142
+ else:
143
+ tx = 0.0
144
+
145
+ # Invert: y = (H - h) * sigmoid(ty)
146
+ # => sigmoid(ty) = y / (H - h)
147
+ # => ty = logit(y / (H - h))
148
+ if H > h:
149
+ sigmoid_ty = y / (H - h)
150
+ sigmoid_ty = np.clip(sigmoid_ty, 1e-6, 1.0 - 1e-6)
151
+ ty = np.log(sigmoid_ty / (1.0 - sigmoid_ty))
152
+ else:
153
+ ty = 0.0
154
+
155
+ return tx, ty, ts
156
+
modules/infographics_generator/layout_system/sdf/core.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SDF core functions: IO, mask processing, and SDF computation."""
2
+
3
+ import numpy as np
4
+ from PIL import Image
5
+ from scipy.ndimage import distance_transform_edt, binary_dilation
6
+
7
+ import torch
8
+ import torch.nn.functional as F
9
+
10
+
11
+ # -----------------------------
12
+ # IO: RGBA PNG -> binary mask from alpha
13
+ # -----------------------------
14
+ def load_binary_mask_from_rgba(png_path: str, alpha_thresh: int = 1) -> np.ndarray:
15
+ """Load a binary mask from an RGBA PNG file using the alpha channel.
16
+
17
+ Args:
18
+ png_path: Path to the PNG file
19
+ alpha_thresh: Threshold for alpha channel (pixels with alpha > thresh are 1)
20
+
21
+ Returns:
22
+ Binary mask as float32 array (0 or 1)
23
+ """
24
+ img = Image.open(png_path).convert("RGBA")
25
+ a = np.array(img, dtype=np.uint8)[..., 3]
26
+ mask = (a > alpha_thresh).astype(np.float32)
27
+ return mask
28
+
29
+
30
+ def tight_bbox_ratio(mask01: np.ndarray):
31
+ """Calculate the aspect ratio of the tight bounding box of a mask.
32
+
33
+ Args:
34
+ mask01: Binary mask (0 or 1)
35
+
36
+ Returns:
37
+ Tuple of (aspect_ratio, (x0, y0, w, h))
38
+ """
39
+ ys, xs = np.where(mask01 > 0.5)
40
+ if len(xs) == 0:
41
+ raise ValueError("Mask is empty.")
42
+ x0, x1 = xs.min(), xs.max() + 1
43
+ y0, y1 = ys.min(), ys.max() + 1
44
+ w0 = x1 - x0
45
+ h0 = y1 - y0
46
+ r = w0 / float(h0)
47
+ return r, (x0, y0, w0, h0)
48
+
49
+
50
+ # -----------------------------
51
+ # Binary mask -> SDF (inside negative, outside positive), normalized to local coord units
52
+ # local coords are [-1,1]x[-1,1]
53
+ # -----------------------------
54
+ def binary_to_sdf_norm(mask01: np.ndarray, pad: int = 16) -> np.ndarray:
55
+ """Convert a binary mask to a normalized signed distance field.
56
+
57
+ The SDF is normalized so that distances are in local coordinate units
58
+ where the mask spans [-1, 1] in both dimensions.
59
+
60
+ Args:
61
+ mask01: Binary mask (0 or 1)
62
+ pad: Padding to add around the mask
63
+
64
+ Returns:
65
+ Normalized SDF (positive outside, negative inside)
66
+ """
67
+ mask = (mask01 > 0.5)
68
+
69
+ # pad with outside False to ensure border is outside
70
+ mask_p = np.pad(mask, ((pad, pad), (pad, pad)), mode="constant", constant_values=False)
71
+
72
+ dist_out = distance_transform_edt(~mask_p) # outside: dist to nearest inside
73
+ dist_in = distance_transform_edt(mask_p) # inside: dist to nearest outside
74
+ sdf_px = dist_out - dist_in # outside +, inside -
75
+
76
+ Ht, Wt = sdf_px.shape
77
+ scale = (min(Ht, Wt) / 2.0) # px per local-unit (since [-1,1] spans ~min/2)
78
+ sdf_norm = (sdf_px / scale).astype(np.float32)
79
+ return sdf_norm
80
+
81
+
82
+ # -----------------------------
83
+ # Morphological dilation for mask expansion
84
+ # -----------------------------
85
+ def dilate_mask(mask01: np.ndarray, radius: float) -> np.ndarray:
86
+ """Dilate a binary mask using morphological dilation with a circular structuring element.
87
+
88
+ Args:
89
+ mask01: Binary mask (0 or 1)
90
+ radius: Dilation radius in pixels
91
+
92
+ Returns:
93
+ Dilated binary mask
94
+ """
95
+ if radius <= 0:
96
+ return mask01
97
+
98
+ # Create circular structuring element
99
+ # For radius r, we need a (2*r+1) x (2*r+1) square, then mask it to a circle
100
+ r_int = int(np.ceil(radius))
101
+ size = 2 * r_int + 1
102
+ y, x = np.ogrid[-r_int:r_int+1, -r_int:r_int+1]
103
+ mask_circle = (x*x + y*y <= radius*radius)
104
+
105
+ # Perform dilation
106
+ dilated = binary_dilation(mask01, structure=mask_circle.astype(np.uint8))
107
+
108
+ return dilated.astype(np.float32)
109
+
110
+
111
+ # -----------------------------
112
+ # Container sampling grid (pixel centers) for a chosen optimization resolution
113
+ # -----------------------------
114
+ def make_container_grid(H: int, W: int, device):
115
+ """Create a grid of pixel center coordinates for a container.
116
+
117
+ Args:
118
+ H: Container height
119
+ W: Container width
120
+ device: PyTorch device
121
+
122
+ Returns:
123
+ Tuple of (X, Y) coordinate grids, each of shape [H, W]
124
+ """
125
+ ys = torch.arange(H, device=device, dtype=torch.float32) + 0.5
126
+ xs = torch.arange(W, device=device, dtype=torch.float32) + 0.5
127
+ Y, X = torch.meshgrid(ys, xs, indexing="ij")
128
+ return X, Y # [H,W]
129
+
130
+
131
+ # -----------------------------
132
+ # Differentiable rasterization: sample SDF -> soft mask
133
+ # tau_px controls boundary softness in container pixel units
134
+ # -----------------------------
135
+ def sdf_to_softmask(
136
+ sdf_norm_t: torch.Tensor, # [1,1,Ht,Wt]
137
+ x, y, w, h, # bbox in container pixels
138
+ X, Y, # grid in container pixels [Hs,Ws]
139
+ tau_px: float = 1.5,
140
+ ):
141
+ """Convert a normalized SDF to a soft mask at given bbox location.
142
+
143
+ Args:
144
+ sdf_norm_t: Normalized SDF tensor [1, 1, Ht, Wt]
145
+ x, y, w, h: Bounding box in container pixels
146
+ X, Y: Grid coordinates in container pixels [Hs, Ws]
147
+ tau_px: Softness parameter in pixels
148
+
149
+ Returns:
150
+ Tuple of (soft_mask, distance_in_pixels)
151
+ """
152
+ cx = x + 0.5 * w
153
+ cy = y + 0.5 * h
154
+
155
+ # local coords (u,v) in [-1,1] within bbox
156
+ u = (X - cx) / (0.5 * w)
157
+ v = (Y - cy) / (0.5 * h)
158
+ grid = torch.stack([u, v], dim=-1).unsqueeze(0) # [1,Hs,Ws,2]
159
+
160
+ d_norm = F.grid_sample(
161
+ sdf_norm_t, grid,
162
+ mode="bilinear",
163
+ padding_mode="border",
164
+ align_corners=False
165
+ ) # [1,1,Hs,Ws]
166
+
167
+ # convert local distance -> approx container pixel distance
168
+ d_px = d_norm * (0.5 * torch.minimum(w, h))
169
+ m = torch.sigmoid(-d_px / tau_px)
170
+
171
+ # Window function to clip to bbox
172
+ eps = 0.02
173
+ win = torch.sigmoid((1.0 - u.abs()) / eps) * torch.sigmoid((1.0 - v.abs()) / eps)
174
+ m = m * win
175
+
176
+ return m, d_px
177
+
178
+
179
+ def area_sum(mask01: torch.Tensor, W_container: int, H_container: int):
180
+ """Calculate the area of a mask scaled to container coordinates.
181
+
182
+ Args:
183
+ mask01: Mask tensor [1, 1, Hs, Ws]
184
+ W_container: Container width
185
+ H_container: Container height
186
+
187
+ Returns:
188
+ Scaled area sum
189
+ """
190
+ _, _, Hs, Ws = mask01.shape
191
+ da = (W_container / float(Ws)) * (H_container / float(Hs))
192
+ return mask01.sum() * da
193
+
modules/infographics_generator/layout_system/sdf/losses.py ADDED
@@ -0,0 +1,558 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Loss functions for SDF-based layout optimization."""
2
+
3
+ from typing import List, Tuple, Optional
4
+ import torch
5
+
6
+
7
+ # -----------------------------
8
+ # Alignment Consistency Loss (Hierarchical Alignment)
9
+ # -----------------------------
10
+ def compute_alignment_consistency_loss(
11
+ reference_bboxes: List[Tuple[float, float, float, float]],
12
+ generated_bboxes: List[torch.Tensor],
13
+ reference_parent_bbox: Tuple[float, float, float, float],
14
+ generated_parent_bbox: Tuple[float, float, float, float],
15
+ device: str = "cpu"
16
+ ) -> torch.Tensor:
17
+ if len(generated_bboxes) < 2:
18
+ return torch.tensor(0.0, device=device)
19
+
20
+ bbox_tensor = torch.stack([bbox for bbox in generated_bboxes])
21
+
22
+ x = bbox_tensor[:, 0]
23
+ y = bbox_tensor[:, 1]
24
+ w = bbox_tensor[:, 2]
25
+ h = bbox_tensor[:, 3]
26
+
27
+ left_coords = x
28
+ right_coords = x + w
29
+ center_x_coords = x + w / 2.0
30
+ top_coords = y
31
+ bottom_coords = y + h
32
+ center_y_coords = y + h / 2.0
33
+
34
+ horizontal_coords = torch.stack([
35
+ left_coords,
36
+ right_coords,
37
+ center_x_coords
38
+ ])
39
+
40
+ vertical_coords = torch.stack([
41
+ top_coords,
42
+ bottom_coords,
43
+ center_y_coords
44
+ ])
45
+
46
+ sigma_horizontal = torch.std(horizontal_coords, dim=1)
47
+ sigma_vertical = torch.std(vertical_coords, dim=1)
48
+
49
+ penalty = torch.min(sigma_horizontal) + torch.min(sigma_vertical)
50
+
51
+ return penalty
52
+
53
+
54
+ # -----------------------------
55
+ # Alignment Similarity Loss (Based on JSON Constraint)
56
+ # -----------------------------
57
+ def compute_alignment_similarity_loss(
58
+ generated_bboxes: List[torch.Tensor],
59
+ container_bbox: Tuple[float, float, float, float],
60
+ alignment_constraint: dict,
61
+ device: str = "cpu"
62
+ ) -> torch.Tensor:
63
+ """Compute alignment similarity loss based on JSON alignment constraint.
64
+
65
+ For row/column layouts, JSON specifies which axis to align elements along,
66
+ rather than taking the minimum loss across all alignment possibilities.
67
+
68
+ Args:
69
+ generated_bboxes: List of generated element bboxes as torch tensors (x, y, w, h)
70
+ container_bbox: Container bbox (x, y, w, h)
71
+ alignment_constraint: Dictionary with "direction" and "value" keys
72
+ - direction: "horizontal" or "vertical"
73
+ - value: "left", "center", "right" (for horizontal) or "top", "center", "bottom" (for vertical)
74
+ device: Device for tensor operations
75
+
76
+ Returns:
77
+ Alignment loss tensor
78
+ """
79
+ if len(generated_bboxes) < 2:
80
+ return torch.tensor(0.0, device=device)
81
+
82
+ if not alignment_constraint:
83
+ return torch.tensor(0.0, device=device)
84
+
85
+ direction = alignment_constraint.get("direction", "horizontal")
86
+ value = alignment_constraint.get("value", "center")
87
+
88
+ _, _, container_w, container_h = container_bbox
89
+
90
+ L_alignment = torch.tensor(0.0, device=device)
91
+
92
+ if direction == "horizontal":
93
+ if value == "left":
94
+ left_coords = []
95
+ for bbox in generated_bboxes:
96
+ x = bbox[0]
97
+ left_coords.append(x)
98
+ left_tensor = torch.stack(left_coords)
99
+ L_alignment = torch.std(left_tensor) ** 2
100
+ elif value == "center":
101
+ center_x_coords = []
102
+ for bbox in generated_bboxes:
103
+ x, w = bbox[0], bbox[2]
104
+ center_x = x + w / 2.0
105
+ target_center = container_w / 2.0
106
+ center_x_coords.append(center_x - target_center)
107
+ center_x_tensor = torch.stack(center_x_coords)
108
+ L_alignment = torch.mean(center_x_tensor ** 2)
109
+ elif value == "right":
110
+ right_coords = []
111
+ for bbox in generated_bboxes:
112
+ x, w = bbox[0], bbox[2]
113
+ right_x = x + w
114
+ target_right = container_w
115
+ right_coords.append(right_x - target_right)
116
+ right_tensor = torch.stack(right_coords)
117
+ L_alignment = torch.mean(right_tensor ** 2)
118
+
119
+ elif direction == "vertical":
120
+ if value == "top":
121
+ top_coords = []
122
+ for bbox in generated_bboxes:
123
+ y = bbox[1]
124
+ top_coords.append(y)
125
+ top_tensor = torch.stack(top_coords)
126
+ L_alignment = torch.std(top_tensor) ** 2
127
+ elif value == "center":
128
+ center_y_coords = []
129
+ for bbox in generated_bboxes:
130
+ y, h = bbox[1], bbox[3]
131
+ center_y = y + h / 2.0
132
+ target_center = container_h / 2.0
133
+ center_y_coords.append(center_y - target_center)
134
+ center_y_tensor = torch.stack(center_y_coords)
135
+ L_alignment = torch.mean(center_y_tensor ** 2)
136
+ elif value == "bottom":
137
+ bottom_coords = []
138
+ for bbox in generated_bboxes:
139
+ y, h = bbox[1], bbox[3]
140
+ bottom_y = y + h
141
+ target_bottom = container_h
142
+ bottom_coords.append(bottom_y - target_bottom)
143
+ bottom_tensor = torch.stack(bottom_coords)
144
+ L_alignment = torch.mean(bottom_tensor ** 2)
145
+
146
+ return L_alignment
147
+
148
+
149
+ # -----------------------------
150
+ # Readability Loss (Global Size Consistency)
151
+ # -----------------------------
152
+ def compute_readability_loss(
153
+ size_rules: List[Tuple[int, int]],
154
+ generated_bboxes: List[torch.Tensor],
155
+ size_ratio_threshold: float = 1.5,
156
+ device: str = "cpu"
157
+ ) -> torch.Tensor:
158
+ """Compute readability loss based on element size hierarchy rules.
159
+
160
+ Args:
161
+ size_rules: List of tuples (source_idx, target_idx) indicating that
162
+ element at source_idx should be larger than element at target_idx
163
+ generated_bboxes: List of bbox tensors (x, y, w, h) for generated elements
164
+ size_ratio_threshold: Minimum ratio threshold (default from params.SIZE_RATIO_THRESHOLD)
165
+ device: Device for tensor operations
166
+
167
+ Returns:
168
+ Sum of size difference penalties for violated rules
169
+ """
170
+ if not size_rules or len(size_rules) == 0:
171
+ return torch.tensor(0.0, device=device)
172
+
173
+ # Calculate size (area) for each element: Size = width × height
174
+ sizes = []
175
+ for bbox in generated_bboxes:
176
+ w, h = bbox[2], bbox[3] # width and height
177
+ size = w * h
178
+ sizes.append(size)
179
+
180
+ # For each rule (source_idx, target_idx): source should be >= target * size_ratio_threshold
181
+ # Penalty = max(0, target * size_ratio_threshold - source)
182
+ total_loss = torch.tensor(0.0, device=device)
183
+ for source_idx, target_idx in size_rules:
184
+ if source_idx < len(sizes) and target_idx < len(sizes):
185
+ size_source = sizes[source_idx]
186
+ size_target = sizes[target_idx]
187
+ # Required minimum size for source: target * size_ratio_threshold
188
+ min_size_source = size_target * size_ratio_threshold
189
+ # Penalty if source is smaller than required
190
+ penalty = torch.clamp(min_size_source - size_source, min=0.0)
191
+ total_loss = total_loss + penalty
192
+
193
+ return total_loss
194
+
195
+
196
+ # -----------------------------
197
+ # Proximity Ratio Loss
198
+ # -----------------------------
199
+ def compute_proximity_ratio_loss(
200
+ container_bboxes: List[Tuple[float, float, float, float]],
201
+ child_bboxes_list: List[List], # Can be List[Tuple] or List[Tensor]
202
+ grandchild_bboxes_list: List[List[List[Tuple[float, float, float, float]]]],
203
+ container_types: List[str],
204
+ container_weights: Optional[List[float]] = None,
205
+ epsilon: float = 1e-6,
206
+ device: str = "cpu"
207
+ ) -> torch.Tensor:
208
+ """Compute proximity ratio loss for layout hierarchy clarity.
209
+
210
+ Simplified for bottom-up layout: only considers current layer's children spacing
211
+ and children's children spacing.
212
+
213
+ Proximity Ratio measures whether inner-group distance < outer-group distance.
214
+ Score_P = Gap_external / (Gap_internal + epsilon)
215
+ Higher score (>1.0, ideally >1.5) indicates clear grouping.
216
+
217
+ Args:
218
+ container_bboxes: List of container bboxes (x, y, w, h)
219
+ child_bboxes_list: For each container, list of child bboxes (x, y, w, h)
220
+ grandchild_bboxes_list: For each container, list of lists of grandchildren bboxes
221
+ (children of each child)
222
+ container_types: List of container types ("column", "row", or "layer")
223
+ container_weights: Optional weights for weighted average (default: equal weights)
224
+ epsilon: Small value to avoid division by zero
225
+ device: Device for tensor operations
226
+
227
+ Returns:
228
+ Proximity loss: negative of weighted average score (to minimize)
229
+ """
230
+ if len(container_bboxes) == 0:
231
+ return torch.tensor(0.0, device=device)
232
+
233
+ scores = []
234
+ weights = []
235
+
236
+ for i, container_bbox in enumerate(container_bboxes):
237
+ container_type = container_types[i] if i < len(container_types) else "layer"
238
+
239
+ # Skip layer type (free arrangement, no proximity constraint)
240
+ if container_type == "layer":
241
+ continue
242
+
243
+ child_bboxes = child_bboxes_list[i] if i < len(child_bboxes_list) else []
244
+ grandchild_bboxes_list_for_container = grandchild_bboxes_list[i] if i < len(grandchild_bboxes_list) else []
245
+
246
+ # Ensure grandchild_bboxes_list_for_container is a list of lists (one list per child)
247
+ if not isinstance(grandchild_bboxes_list_for_container, list):
248
+ grandchild_bboxes_list_for_container = []
249
+ elif len(grandchild_bboxes_list_for_container) > 0:
250
+ # Check if it's a flat list (first element is a bbox tuple) or nested list
251
+ first_elem = grandchild_bboxes_list_for_container[0]
252
+ if isinstance(first_elem, (tuple, list)) and len(first_elem) == 4 and isinstance(first_elem[0], (int, float)):
253
+ # Flat list: wrap it as a single child's grandchildren list
254
+ grandchild_bboxes_list_for_container = [grandchild_bboxes_list_for_container]
255
+
256
+ if len(child_bboxes) < 2:
257
+ # Need at least 2 children to calculate internal gap
258
+ continue
259
+
260
+ # Step A: Determine direction based on container type
261
+ # column: vertical arrangement, row: horizontal arrangement
262
+ is_horizontal = (container_type == "row")
263
+
264
+ # Step B: Calculate Gap_internal
265
+ # For each child, calculate gap between its children (grandchildren), then take the maximum
266
+ gap_internal_max = 0.0
267
+ if len(grandchild_bboxes_list_for_container) > 0:
268
+ for child_idx, grandchild_list in enumerate(grandchild_bboxes_list_for_container):
269
+ if grandchild_list is None or not isinstance(grandchild_list, list):
270
+ continue
271
+
272
+ # Collect valid grandchildren for this child
273
+ grandchildren = []
274
+ for gc in grandchild_list:
275
+ if gc is None:
276
+ continue
277
+ if isinstance(gc, (tuple, list)) and len(gc) >= 4:
278
+ grandchildren.append(tuple(gc[:4]))
279
+ elif isinstance(gc, dict):
280
+ grandchildren.append((
281
+ gc.get("x", 0), gc.get("y", 0),
282
+ gc.get("width", gc.get("w", 0)),
283
+ gc.get("height", gc.get("h", 0))
284
+ ))
285
+
286
+ if len(grandchildren) < 2:
287
+ continue
288
+
289
+ # Sort grandchildren by the same direction as container
290
+ if is_horizontal:
291
+ sorted_gc = sorted(grandchildren, key=lambda b: b[0])
292
+ else:
293
+ sorted_gc = sorted(grandchildren, key=lambda b: b[1])
294
+
295
+ # Calculate gaps between adjacent grandchildren within this child
296
+ for j in range(len(sorted_gc) - 1):
297
+ bbox1 = sorted_gc[j]
298
+ bbox2 = sorted_gc[j + 1]
299
+
300
+ if is_horizontal:
301
+ gap = bbox2[0] - (bbox1[0] + bbox1[2])
302
+ else:
303
+ gap = bbox2[1] - (bbox1[1] + bbox1[3])
304
+
305
+ gap_internal_max = max(gap_internal_max, max(0.0, gap))
306
+
307
+ if gap_internal_max == 0.0:
308
+ # No valid internal gaps found, skip this container
309
+ continue
310
+
311
+ gap_internal = gap_internal_max
312
+
313
+ # Step C: Calculate Gap_external (spacing between children)
314
+ # Convert child_bboxes to tensors if needed for gradient computation
315
+ child_bboxes_tensors = []
316
+ for bbox in child_bboxes:
317
+ if isinstance(bbox, torch.Tensor):
318
+ child_bboxes_tensors.append(bbox)
319
+ elif isinstance(bbox, (tuple, list)) and len(bbox) >= 4:
320
+ # If bbox is tuple/list, create tensor but note: this breaks gradient chain
321
+ # Ideally, bboxes should be passed as tensors from the caller
322
+ child_bboxes_tensors.append(torch.tensor([bbox[0], bbox[1], bbox[2], bbox[3]], device=device, dtype=torch.float32))
323
+ else:
324
+ continue
325
+
326
+ if len(child_bboxes_tensors) < 2:
327
+ # Not enough children: skip or use fallback
328
+ _, _, w_container, h_container = container_bbox
329
+ gap_external_tensor = torch.tensor(max(w_container, h_container) * 0.1, device=device, dtype=torch.float32)
330
+ else:
331
+ # Sort children by x (horizontal) or y (vertical)
332
+ if is_horizontal:
333
+ x_coords = torch.stack([bbox[0] for bbox in child_bboxes_tensors])
334
+ sorted_indices = torch.argsort(x_coords)
335
+ else:
336
+ y_coords = torch.stack([bbox[1] for bbox in child_bboxes_tensors])
337
+ sorted_indices = torch.argsort(y_coords)
338
+
339
+ sorted_children_tensors = [child_bboxes_tensors[i] for i in sorted_indices]
340
+
341
+ gap_external_list = []
342
+ for j in range(len(sorted_children_tensors) - 1):
343
+ bbox1 = sorted_children_tensors[j]
344
+ bbox2 = sorted_children_tensors[j + 1]
345
+
346
+ if is_horizontal:
347
+ # Horizontal: gap = x2 - (x1 + w1)
348
+ gap = bbox2[0] - (bbox1[0] + bbox1[2])
349
+ else:
350
+ # Vertical: gap = y2 - (y1 + h1)
351
+ gap = bbox2[1] - (bbox1[1] + bbox1[3])
352
+
353
+ gap_external_list.append(gap)
354
+
355
+ if len(gap_external_list) > 0:
356
+ gap_external_tensor = torch.stack(gap_external_list).mean()
357
+ else:
358
+ _, _, w_container, h_container = container_bbox
359
+ gap_external_tensor = torch.tensor(max(w_container, h_container) * 0.1, device=device, dtype=torch.float32)
360
+
361
+ gap_internal_tensor = torch.tensor(gap_internal, device=device, dtype=torch.float32)
362
+
363
+ # Step D: Calculate Score_P
364
+ # Use gap_external directly (can be negative), but scale it for score calculation
365
+ # When gap_external is negative, we still want gradient, so use a smooth function
366
+ # Score = gap_external / (internal_gap + epsilon)
367
+ # This allows negative scores, which will be penalized in the loss
368
+ score_p = gap_external_tensor / (gap_internal_tensor + epsilon)
369
+
370
+ scores.append(score_p)
371
+
372
+ # Weight by container area (larger containers have more influence)
373
+ _, _, w_container, h_container = container_bbox
374
+ weight = w_container * h_container
375
+ weights.append(weight)
376
+
377
+ if len(scores) == 0:
378
+ return torch.tensor(0.0, device=device)
379
+
380
+ # Stack scores to preserve gradients
381
+ scores_tensor = torch.stack(scores)
382
+
383
+ # Normalize weights
384
+ if container_weights is not None and len(container_weights) == len(weights):
385
+ weights = container_weights
386
+
387
+ weights_tensor = torch.tensor(weights, device=device)
388
+ if weights_tensor.sum() > 0:
389
+ weights_tensor = weights_tensor / weights_tensor.sum()
390
+ else:
391
+ weights_tensor = torch.ones_like(weights_tensor) / len(weights_tensor)
392
+
393
+ # Weighted average score
394
+ weighted_score = (scores_tensor * weights_tensor).sum()
395
+
396
+ # Loss: penalize deviation from target score of 1.5
397
+ # Ideal: gap_external / gap_internal should be exactly 1.5
398
+ target_score = 1.5
399
+
400
+ # Loss is the squared deviation from target (or absolute deviation)
401
+ # This ensures loss = 0 only when score = 1.5
402
+ loss = (weighted_score - target_score) ** 2
403
+
404
+ return loss
405
+
406
+
407
+ # -----------------------------
408
+ # Visual Balance Loss
409
+ # -----------------------------
410
+ def compute_visual_balance_loss(
411
+ bboxes: List[Tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]], # List of (x, y, w, h) tuples
412
+ W_container: int,
413
+ H_container: int,
414
+ device: str = "cpu"
415
+ ) -> torch.Tensor:
416
+ """Compute visual balance loss based on weighted centroid of elements.
417
+
418
+ Each element has:
419
+ - Centroid position: (x + w/2, y + h/2) - moves with tx, ty
420
+ - Mass: w * h (area) - scales with ts
421
+
422
+ Visual balance is measured by the distance between the weighted centroid of all elements
423
+ and the center of the container. Lower loss means better balance.
424
+
425
+ This implementation is differentiable with respect to tx, ty, ts:
426
+ - tx, ty changes: directly affect centroid position (x, y)
427
+ - ts changes: affects mass (w, h) and indirectly affects centroid position
428
+
429
+ Args:
430
+ bboxes: List of bbox tuples (x, y, w, h) where each is a torch.Tensor
431
+ W_container: Container width in pixels
432
+ H_container: Container height in pixels
433
+ device: Device for tensor operations
434
+
435
+ Returns:
436
+ Visual balance loss: squared distance from weighted centroid to container center
437
+ """
438
+ if len(bboxes) == 0:
439
+ return torch.tensor(0.0, device=device)
440
+
441
+ # Calculate centroid and mass for each element
442
+ total_mass = torch.tensor(0.0, device=device)
443
+ weighted_x = torch.tensor(0.0, device=device)
444
+ weighted_y = torch.tensor(0.0, device=device)
445
+
446
+ for bbox in bboxes:
447
+ x, y, w, h = bbox
448
+
449
+ # Centroid position: center of bbox
450
+ centroid_x = x + w / 2.0
451
+ centroid_y = y + h / 2.0
452
+
453
+ # Mass: area of bbox (scales with ts through w, h)
454
+ mass = w * h
455
+
456
+ # Accumulate weighted centroid
457
+ total_mass = total_mass + mass
458
+ weighted_x = weighted_x + mass * centroid_x
459
+ weighted_y = weighted_y + mass * centroid_y
460
+
461
+ # Handle edge case: if total mass is zero, return zero loss
462
+ if total_mass < 1e-8:
463
+ return torch.tensor(0.0, device=device)
464
+
465
+ # Calculate overall centroid
466
+ overall_centroid_x = weighted_x / total_mass
467
+ overall_centroid_y = weighted_y / total_mass
468
+
469
+ # Container center
470
+ center_x = torch.tensor(W_container / 2.0, device=device, dtype=torch.float32)
471
+ center_y = torch.tensor(H_container / 2.0, device=device, dtype=torch.float32)
472
+
473
+ # Calculate squared distance from centroid to center
474
+ loss = (overall_centroid_x - center_x) ** 2 + (overall_centroid_y - center_y) ** 2
475
+
476
+ return loss
477
+
478
+
479
+ # -----------------------------
480
+ # Position/Size Similarity Loss
481
+ # -----------------------------
482
+ def compute_position_size_similarity_loss(
483
+ reference_bboxes: List[Tuple[float, float, float, float]],
484
+ generated_bboxes: List[torch.Tensor], # List of (x, y, w, h) tensors
485
+ reference_parent_bbox: Tuple[float, float, float, float],
486
+ generated_parent_bbox: Tuple[float, float, float, float],
487
+ device: str = "cpu"
488
+ ) -> torch.Tensor:
489
+ """Compute position/size similarity loss between reference and generated layouts.
490
+
491
+ Args:
492
+ reference_bboxes: List of reference element bboxes (x, y, w, h) from Example layout
493
+ generated_bboxes: List of generated element bboxes as torch tensors (x, y, w, h)
494
+ reference_parent_bbox: Parent container bbox (x, y, w, h) for reference layout
495
+ generated_parent_bbox: Parent container bbox (x, y, w, h) for generated layout
496
+ device: Device for tensor operations
497
+
498
+ Returns:
499
+ Total similarity loss (L2 distance sum over all elements)
500
+ """
501
+ if len(reference_bboxes) != len(generated_bboxes):
502
+ raise ValueError(f"Mismatch in number of elements: {len(reference_bboxes)} vs {len(generated_bboxes)}")
503
+
504
+ # Extract parent container coordinates
505
+ x_PE, y_PE, w_PE, h_PE = reference_parent_bbox
506
+ x_PG, y_PG, w_PG, h_PG = generated_parent_bbox
507
+
508
+ # Convert reference parent to tensors
509
+ x_PE_t = torch.tensor(x_PE, device=device, dtype=torch.float32)
510
+ y_PE_t = torch.tensor(y_PE, device=device, dtype=torch.float32)
511
+ w_PE_t = torch.tensor(w_PE, device=device, dtype=torch.float32)
512
+ h_PE_t = torch.tensor(h_PE, device=device, dtype=torch.float32)
513
+
514
+ # Convert generated parent to tensors
515
+ x_PG_t = torch.tensor(x_PG, device=device, dtype=torch.float32)
516
+ y_PG_t = torch.tensor(y_PG, device=device, dtype=torch.float32)
517
+ w_PG_t = torch.tensor(w_PG, device=device, dtype=torch.float32)
518
+ h_PG_t = torch.tensor(h_PG, device=device, dtype=torch.float32)
519
+
520
+ total_loss = torch.tensor(0.0, device=device)
521
+
522
+ for ref_bbox, gen_bbox in zip(reference_bboxes, generated_bboxes):
523
+ # Extract element coordinates
524
+ x_E, y_E, w_E, h_E = ref_bbox
525
+
526
+ # Convert reference element to tensors
527
+ x_E_t = torch.tensor(x_E, device=device, dtype=torch.float32)
528
+ y_E_t = torch.tensor(y_E, device=device, dtype=torch.float32)
529
+ w_E_t = torch.tensor(w_E, device=device, dtype=torch.float32)
530
+ h_E_t = torch.tensor(h_E, device=device, dtype=torch.float32)
531
+
532
+ # Generated bbox is already a tensor (x, y, w, h)
533
+ x_G_t, y_G_t, w_G_t, h_G_t = gen_bbox
534
+
535
+ # Compute relative attribute vector V_E for reference element
536
+ V_E = torch.stack([
537
+ (x_E_t - x_PE_t) / w_PE_t, # relative x position
538
+ (y_E_t - y_PE_t) / h_PE_t, # relative y position
539
+ w_E_t / w_PE_t, # relative width
540
+ h_E_t / h_PE_t # relative height
541
+ ])
542
+
543
+ # Compute relative attribute vector V_G for generated element
544
+ V_G = torch.stack([
545
+ (x_G_t - x_PG_t) / w_PG_t, # relative x position
546
+ (y_G_t - y_PG_t) / h_PG_t, # relative y position
547
+ w_G_t / w_PG_t, # relative width
548
+ h_G_t / h_PG_t # relative height
549
+ ])
550
+
551
+ # Compute L2 distance: D = ||V_E - V_G||_2
552
+ diff = V_E - V_G
553
+ distance = torch.norm(diff, p=2)
554
+
555
+ total_loss = total_loss + distance
556
+
557
+ return total_loss
558
+
modules/infographics_generator/layout_system/sdf/optimizer.py ADDED
@@ -0,0 +1,921 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Main optimization function for SDF-based layout optimization."""
2
+
3
+ import os
4
+ import shutil
5
+ from typing import List, Tuple, Optional, Dict
6
+
7
+ import numpy as np
8
+ import torch
9
+ import torch.nn.functional as F
10
+
11
+ from modules.infographics_generator.layout_system import parameters as params
12
+
13
+ from .core import (
14
+ load_binary_mask_from_rgba,
15
+ tight_bbox_ratio,
16
+ binary_to_sdf_norm,
17
+ dilate_mask,
18
+ make_container_grid,
19
+ sdf_to_softmask,
20
+ area_sum,
21
+ )
22
+ from .bbox import (
23
+ bbox_aspect_from_unconstrained,
24
+ unconstrained_from_bbox,
25
+ )
26
+ from .losses import (
27
+ compute_alignment_consistency_loss,
28
+ compute_alignment_similarity_loss,
29
+ compute_readability_loss,
30
+ compute_proximity_ratio_loss,
31
+ compute_visual_balance_loss,
32
+ compute_position_size_similarity_loss,
33
+ )
34
+ from .visualization import (
35
+ visualize_optimization_progress,
36
+ visualize_final_result,
37
+ save_composite_image,
38
+ )
39
+
40
+
41
+ def optimize(
42
+ png_list, # List of image paths for N nodes
43
+ original_png_list=None, # List of original image paths for saving composite
44
+ Wc=1000, Hc=1000,
45
+ opt_res_list=params.OPT_RES_LIST, # optimize on these resolutions
46
+ outer_rounds=params.OUTER_ROUNDS, # augmented-lagrangian outer updates per stage
47
+ inner_steps=params.INNER_STEPS, # gradient steps per outer round
48
+ tau_schedule=params.TAU_SCHEDULE,
49
+ rho_init=params.RHO_INIT, # initial penalty parameter
50
+ rho_mult=params.RHO_MULT, # penalty multiplier
51
+ lr=params.LEARNING_RATE,
52
+ size_min=params.SIZE_MIN, # Legacy parameter for backward compatibility
53
+ min_sizes=None, # List of (min_width, min_height) tuples for each element
54
+ pen_weight=params.PEN_WEIGHT, # weight for penetration penalty
55
+ pen_eta_px=params.PEN_ETA_PX,
56
+ reference_bboxes=None, # List of reference element bboxes (x, y, w, h) from Example layout
57
+ reference_parent_bbox=None, # Reference parent container bbox (x, y, w, h)
58
+ w_similarity=params.W_SIMILARITY, # weight for position/size similarity loss
59
+ size_rules=None, # List of tuples (source_idx, target_idx) for size hierarchy rules
60
+ w_readability=params.W_READABILITY, # weight for readability loss
61
+ w_alignment_consistency=params.W_ALIGNMENT_CONSISTENCY, # weight for alignment consistency loss
62
+ alignment_constraint=None, # Dictionary with alignment constraint from JSON (direction, value)
63
+ w_alignment_similarity=params.W_ALIGNMENT_SIMILARITY, # weight for alignment similarity loss
64
+ proximity_info=None, # Dictionary with container hierarchy info for proximity ratio calculation
65
+ w_proximity=params.W_PROXIMITY, # weight for proximity ratio loss
66
+ w_data_ink=params.W_DATA_INK, # weight for data ink loss (maximize union area)
67
+ w_visual_balance=params.W_VISUAL_BALANCE, # weight for visual balance loss
68
+ min_gap_px=20.0, # minimum gap between elements in pixels
69
+ device=None,
70
+ save_prefix=None, # Prefix for saving result images (None = use default names)
71
+ debug=False, # Enable debug mode: visualization and detailed logging
72
+ ):
73
+ """Main SDF-based layout optimization function.
74
+
75
+ Args:
76
+ png_list: List of image paths for N nodes
77
+ original_png_list: List of original image paths for saving composite
78
+ Wc, Hc: Container width and height
79
+ opt_res_list: Optimize on these resolutions
80
+ outer_rounds: Augmented-lagrangian outer updates per stage
81
+ inner_steps: Gradient steps per outer round
82
+ tau_schedule: Schedule for tau parameter
83
+ rho_init: Initial penalty parameter
84
+ rho_mult: Penalty multiplier
85
+ lr: Learning rate
86
+ size_min: Legacy minimum size parameter
87
+ min_sizes: List of (min_width, min_height) tuples for each element
88
+ pen_weight: Weight for penetration penalty
89
+ pen_eta_px: Eta parameter for penetration penalty
90
+ reference_bboxes: List of reference element bboxes from Example layout
91
+ reference_parent_bbox: Reference parent container bbox
92
+ w_similarity: Weight for position/size similarity loss
93
+ size_rules: List of tuples (source_idx, target_idx) for size hierarchy rules
94
+ w_readability: Weight for readability loss
95
+ w_alignment_consistency: Weight for alignment consistency loss
96
+ alignment_constraint: Dictionary with alignment constraint from JSON
97
+ w_alignment_similarity: Weight for alignment similarity loss
98
+ proximity_info: Dictionary with container hierarchy info
99
+ w_proximity: Weight for proximity ratio loss
100
+ w_data_ink: Weight for data ink loss
101
+ w_visual_balance: Weight for visual balance loss
102
+ min_gap_px: Minimum gap between elements in pixels
103
+ device: PyTorch device
104
+ save_prefix: Prefix for saving result images
105
+ debug: Enable debug mode (visualization and detailed logging)
106
+
107
+ Returns:
108
+ List of final bounding boxes [(x, y, w, h), ...]
109
+ """
110
+ if device is None:
111
+ device = "cuda" if torch.cuda.is_available() else "cpu"
112
+ print("Device:", device)
113
+
114
+ # Validate png_list
115
+ if png_list is None or len(png_list) < 1:
116
+ raise ValueError("png_list must be provided with at least one image path")
117
+
118
+ num_nodes = len(png_list)
119
+
120
+ # Handle original_png_list
121
+ if original_png_list is None:
122
+ original_png_list = png_list
123
+
124
+ # Set dilation_radii to 100 for all elements
125
+ dilation_radii = [20.0] * num_nodes
126
+
127
+ # print(f"Number of nodes: {num_nodes}")
128
+ # print(f"Dilation radii: {dilation_radii}")
129
+
130
+ # Validate reference bboxes
131
+ if reference_bboxes is None:
132
+ reference_bboxes = []
133
+ if reference_parent_bbox is None:
134
+ reference_parent_bbox = (0.0, 0.0, float(Wc), float(Hc))
135
+
136
+ # Normalize reference bboxes: subtract x_min and y_min to make coordinates start from 0
137
+ if reference_bboxes and len(reference_bboxes) > 0:
138
+ # Find minimum x and y across all reference bboxes
139
+ x_min = min(bbox[0] for bbox in reference_bboxes)
140
+ y_min = min(bbox[1] for bbox in reference_bboxes)
141
+
142
+ # Subtract x_min and y_min from all bboxes
143
+ normalized_reference_bboxes = []
144
+ for bbox in reference_bboxes:
145
+ if isinstance(bbox, (tuple, list)) and len(bbox) >= 4:
146
+ normalized_bbox = (bbox[0] - x_min, bbox[1] - y_min, bbox[2], bbox[3])
147
+ elif isinstance(bbox, dict):
148
+ normalized_bbox = {
149
+ 'x': bbox.get('x', 0) - x_min,
150
+ 'y': bbox.get('y', 0) - y_min,
151
+ 'width': bbox.get('width', bbox.get('w', 100)),
152
+ 'height': bbox.get('height', bbox.get('h', 100))
153
+ }
154
+ else:
155
+ normalized_bbox = bbox
156
+ normalized_reference_bboxes.append(normalized_bbox)
157
+
158
+ reference_bboxes = normalized_reference_bboxes
159
+
160
+ # Also normalize reference_parent_bbox
161
+ if reference_parent_bbox:
162
+ x_p, y_p, w_p, h_p = reference_parent_bbox
163
+ reference_parent_bbox = (x_p - x_min, y_p - y_min, w_p, h_p)
164
+
165
+ # print(f"Normalized reference bboxes: subtracted x_min={x_min:.1f}, y_min={y_min:.1f}")
166
+
167
+ # Validate size rules
168
+ if size_rules is None:
169
+ size_rules = []
170
+
171
+ # Validate and set up min_sizes
172
+ if min_sizes is None:
173
+ # Use default values for all elements
174
+ min_sizes = [(params.MIN_WIDTH_DEFAULT, params.MIN_HEIGHT_DEFAULT)] * num_nodes
175
+ elif len(min_sizes) < num_nodes:
176
+ # Extend with default values
177
+ default_size = (params.MIN_WIDTH_DEFAULT, params.MIN_HEIGHT_DEFAULT)
178
+ min_sizes = min_sizes + [default_size] * (num_nodes - len(min_sizes))
179
+ elif len(min_sizes) > num_nodes:
180
+ # Truncate to num_nodes
181
+ min_sizes = min_sizes[:num_nodes]
182
+
183
+ # print(f"Reference bboxes: {len(reference_bboxes)} elements")
184
+ # print(f"Reference parent bbox: {reference_parent_bbox}")
185
+ # print(f"Size rules: {len(size_rules)} rules")
186
+ # print(f"Similarity weight: {w_similarity}, Readability weight: {w_readability}")
187
+ # print(f"Min sizes: {min_sizes}")
188
+ # print(f"Debug mode: {debug}")
189
+
190
+ # Create visualization folder for this optimization run (only in debug mode)
191
+ viz_folder = None
192
+ if debug:
193
+ if save_prefix:
194
+ viz_folder = f"{save_prefix}_visualization"
195
+ else:
196
+ viz_folder = "optimization_visualization"
197
+
198
+ # Clear existing folder contents if it exists
199
+ if os.path.exists(viz_folder):
200
+ shutil.rmtree(viz_folder)
201
+
202
+ os.makedirs(viz_folder, exist_ok=True)
203
+ # print(f"Visualization folder created: {viz_folder}")
204
+
205
+ # load masks for all nodes
206
+ masks = []
207
+ ratios = []
208
+ for i, png_path in enumerate(png_list):
209
+ if png_path is None:
210
+ raise ValueError(f"Image path at index {i} is None")
211
+ mask = load_binary_mask_from_rgba(png_path)
212
+
213
+ # Apply dilation with radius 100 to all elements
214
+ mask = dilate_mask(mask, dilation_radii[i])
215
+ # print(f"Applied dilation to node {i} mask with radius {dilation_radii[i]:.1f} pixels")
216
+
217
+ masks.append(mask)
218
+ r, _ = tight_bbox_ratio(mask)
219
+ ratios.append(r)
220
+ # print(f"Node {i} aspect ratio (tight alpha bbox): r={r:.4f}")
221
+
222
+ # precompute SDF templates for all nodes
223
+ sdf_norms = []
224
+ sdf_tensors = []
225
+ for i, mask in enumerate(masks):
226
+ sdf_norm = binary_to_sdf_norm(mask, pad=16)
227
+ sdf_norms.append(sdf_norm)
228
+ sdf_t = torch.from_numpy(sdf_norm)[None, None].to(device)
229
+ sdf_tensors.append(sdf_t)
230
+
231
+ # learnable parameters: (tx,ty,ts) for each object
232
+ # Initialize from reference_bboxes if available, otherwise use zeros
233
+ opt_params = []
234
+ for i in range(num_nodes):
235
+ if reference_bboxes and i < len(reference_bboxes):
236
+ # Initialize from reference bbox
237
+ ref_bbox = reference_bboxes[i]
238
+ if isinstance(ref_bbox, (tuple, list)) and len(ref_bbox) >= 4:
239
+ ref_x, ref_y, ref_w, ref_h = ref_bbox[0], ref_bbox[1], ref_bbox[2], ref_bbox[3]
240
+ elif isinstance(ref_bbox, dict):
241
+ ref_x = ref_bbox.get("x", 0)
242
+ ref_y = ref_bbox.get("y", 0)
243
+ ref_w = ref_bbox.get("width", ref_bbox.get("w", 100))
244
+ ref_h = ref_bbox.get("height", ref_bbox.get("h", 100))
245
+ else:
246
+ ref_x, ref_y, ref_w, ref_h = 0, 0, 100, 100
247
+
248
+ # Convert reference bbox to unconstrained parameters
249
+ # Use material's aspect ratio (ratios[i]) instead of JSON's w/h
250
+ min_width, min_height = min_sizes[i]
251
+ tx_init, ty_init, ts_init = unconstrained_from_bbox(
252
+ ref_x, ref_y, ref_w, ref_h,
253
+ Wc, Hc, ratios[i],
254
+ min_width=min_width, min_height=min_height,
255
+ size_min=size_min
256
+ )
257
+ # print(f"Node {i}: Initializing from reference bbox ({ref_x:.1f}, {ref_y:.1f}, {ref_w:.1f}, {ref_h:.1f}), "
258
+ # f"adjusted to aspect ratio {ratios[i]:.4f}")
259
+ else:
260
+ # Initialize with zeros (default)
261
+ tx_init, ty_init, ts_init = 0.0, 0.0, 0.0
262
+
263
+ tx = torch.nn.Parameter(torch.tensor(tx_init, device=device))
264
+ ty = torch.nn.Parameter(torch.tensor(ty_init, device=device))
265
+ ts = torch.nn.Parameter(torch.tensor(ts_init, device=device))
266
+ opt_params.extend([tx, ty, ts])
267
+
268
+ opt = torch.optim.Adam(opt_params, lr=lr)
269
+
270
+ # augmented lagrangian multipliers for constraint g = A_inter = 0
271
+ lam = torch.tensor(0.0, device=device)
272
+ rho = torch.tensor(rho_init, device=device)
273
+
274
+ tau_list = list(tau_schedule)
275
+ if len(tau_list) < outer_rounds:
276
+ tau_list += [tau_list[-1]] * (outer_rounds - len(tau_list))
277
+
278
+ # staged optimization over resolutions
279
+ for stage_idx, stage_res in enumerate(opt_res_list):
280
+ Hs = Ws = int(stage_res)
281
+ X, Y = make_container_grid(Hs, Ws, device=device)
282
+ # print(f"\n=== Stage optimize at {Ws}x{Hs} (container {Wc}x{Hc}) ===")
283
+
284
+ # Visualize initial state before optimization (only for first stage and only in debug mode)
285
+ if stage_idx == 0 and debug:
286
+ with torch.no_grad():
287
+ # Use full resolution for visualization
288
+ X_init, Y_init = make_container_grid(Hc, Wc, device=device)
289
+ initial_bboxes = []
290
+ initial_softmasks = []
291
+ for i in range(num_nodes):
292
+ tx_idx = i * 3
293
+ ty_idx = i * 3 + 1
294
+ ts_idx = i * 3 + 2
295
+ tx = opt_params[tx_idx]
296
+ ty = opt_params[ty_idx]
297
+ ts = opt_params[ts_idx]
298
+
299
+ min_width, min_height = min_sizes[i]
300
+ x, y, w, h = bbox_aspect_from_unconstrained(
301
+ tx, ty, ts, Wc, Hc, ratios[i],
302
+ min_width=min_width, min_height=min_height,
303
+ size_min=size_min
304
+ )
305
+ # print(f"Node {i} initial bbox: ({x.item():.1f}, {y.item():.1f}, {w.item():.1f}, {h.item():.1f})")
306
+ initial_bboxes.append((x.item(), y.item(), w.item(), h.item()))
307
+
308
+ # Use smaller tau for initial visualization to show actual mask shape
309
+ m, _ = sdf_to_softmask(sdf_tensors[i], x, y, w, h, X_init, Y_init, tau_px=0.5)
310
+ initial_softmasks.append(m)
311
+
312
+ # Compute initial loss values for display
313
+ initial_union = torch.ones_like(initial_softmasks[0])
314
+ for m in initial_softmasks:
315
+ initial_union = initial_union * (1.0 - m)
316
+ initial_union = 1.0 - initial_union
317
+ initial_A_union = initial_union.sum().item() # Full resolution, da=1
318
+
319
+ initial_inter = torch.zeros_like(initial_softmasks[0])
320
+ for i in range(num_nodes):
321
+ for j in range(i + 1, num_nodes):
322
+ initial_inter = initial_inter + initial_softmasks[i] * initial_softmasks[j]
323
+ initial_A_inter = initial_inter.sum().item() # Full resolution, da=1
324
+
325
+ # Compute visual balance loss for initial state (using bboxes)
326
+ initial_bboxes_tensors = []
327
+ for i in range(num_nodes):
328
+ tx_idx = i * 3
329
+ ty_idx = i * 3 + 1
330
+ ts_idx = i * 3 + 2
331
+ tx = opt_params[tx_idx]
332
+ ty = opt_params[ty_idx]
333
+ ts = opt_params[ts_idx]
334
+
335
+ min_width, min_height = min_sizes[i]
336
+ x, y, w, h = bbox_aspect_from_unconstrained(
337
+ tx, ty, ts, Wc, Hc, ratios[i],
338
+ min_width=min_width, min_height=min_height,
339
+ size_min=size_min
340
+ )
341
+ initial_bboxes_tensors.append((x, y, w, h))
342
+
343
+ L_visual_balance_init = compute_visual_balance_loss(
344
+ initial_bboxes_tensors, Wc, Hc, device=device
345
+ )
346
+
347
+ initial_loss_info = {
348
+ 'A_union': initial_A_union,
349
+ 'A_inter': initial_A_inter,
350
+ 'visual_balance': w_visual_balance * L_visual_balance_init.item(),
351
+ }
352
+
353
+ initial_save_path = os.path.join(viz_folder, f"initial_stage{stage_idx}.png")
354
+
355
+ visualize_optimization_progress(
356
+ initial_softmasks, initial_bboxes, Wc, Hc,
357
+ epoch=-1, loss_info=initial_loss_info,
358
+ save_path=initial_save_path
359
+ )
360
+
361
+ for k in range(outer_rounds):
362
+ tau_px = float(tau_list[min(k, len(tau_list)-1)])
363
+
364
+ for t in range(inner_steps):
365
+ opt.zero_grad(set_to_none=True)
366
+
367
+ # Compute bboxes for all nodes
368
+ bboxes = []
369
+ softmasks = []
370
+ distances = []
371
+ for i in range(num_nodes):
372
+ tx_idx = i * 3
373
+ ty_idx = i * 3 + 1
374
+ ts_idx = i * 3 + 2
375
+ tx = opt_params[tx_idx]
376
+ ty = opt_params[ty_idx]
377
+ ts = opt_params[ts_idx]
378
+
379
+ min_width, min_height = min_sizes[i]
380
+ x, y, w, h = bbox_aspect_from_unconstrained(
381
+ tx, ty, ts, Wc, Hc, ratios[i],
382
+ min_width=min_width, min_height=min_height,
383
+ size_min=size_min
384
+ )
385
+ bboxes.append((x, y, w, h))
386
+
387
+ m, d_px = sdf_to_softmask(sdf_tensors[i], x, y, w, h, X, Y, tau_px=tau_px)
388
+ softmasks.append(m)
389
+ distances.append(d_px)
390
+
391
+ # Compute union: 1 - product of (1 - mask_i)
392
+ union = torch.ones_like(softmasks[0])
393
+ for m in softmasks:
394
+ union = union * (1.0 - m)
395
+ union = 1.0 - union
396
+
397
+ # Compute intersection: sum of all pairwise intersections
398
+ inter = torch.zeros_like(softmasks[0])
399
+ for i in range(num_nodes):
400
+ for j in range(i + 1, num_nodes):
401
+ inter = inter + softmasks[i] * softmasks[j]
402
+
403
+ A_union = area_sum(union, Wc, Hc)
404
+ A_inter = area_sum(inter, Wc, Hc) # must go to 0
405
+
406
+ # Visual balance loss (using bboxes directly for differentiability)
407
+ L_visual_balance = compute_visual_balance_loss(
408
+ bboxes, Wc, Hc, device=device
409
+ )
410
+
411
+ # Penetration loss: based on actual bbox gap (not limited by SDF range)
412
+ # Penalize when gap < min_gap_px (ensures minimum gap between elements)
413
+ # Use ReLU for hard cutoff: no penalty when gap >= min_gap_px
414
+ # For "layer" container type, skip this loss (overlapping is allowed)
415
+ L_pen = torch.tensor(0.0, device=device)
416
+
417
+ # # Check if this is a layer container (overlapping allowed)
418
+ # is_layer = False
419
+ # if proximity_info:
420
+ # container_types = proximity_info.get("types", [])
421
+ # if len(container_types) > 0:
422
+ # is_layer = (container_types[0] == "layer")
423
+
424
+ # if not is_layer:
425
+ # # Only apply penetration penalty for row/column layouts
426
+ # for i in range(num_nodes):
427
+ # for j in range(i + 1, num_nodes):
428
+ # x_i, y_i, w_i, h_i = bboxes[i]
429
+ # x_j, y_j, w_j, h_j = bboxes[j]
430
+
431
+ # # Compute gap in each dimension (negative if overlapping)
432
+ # gap_x = torch.max(x_j - (x_i + w_i), x_i - (x_j + w_j))
433
+ # gap_y = torch.max(y_j - (y_i + h_i), y_i - (y_j + h_j))
434
+
435
+ # # Combined gap logic:
436
+ # # - If both separated (both positive): Euclidean distance
437
+ # # - If both overlapping (both negative): use MAX (smallest overlap, easiest to fix)
438
+ # # - If one separated, one overlapping: use the separated one (that's the actual gap)
439
+ # if gap_x >= 0 and gap_y >= 0:
440
+ # # Both separated: Euclidean distance
441
+ # gap = torch.sqrt(gap_x * gap_x + gap_y * gap_y)
442
+ # elif gap_x < 0 and gap_y < 0:
443
+ # # Both overlapping: use the smaller overlap (easier to separate)
444
+ # gap = torch.max(gap_x, gap_y)
445
+ # else:
446
+ # # One separated, one overlapping: they're aligned in overlapping dimension
447
+ # # Use the separated dimension's gap
448
+ # gap = torch.max(gap_x, gap_y)
449
+
450
+ # # Penalty if gap < min_gap_px
451
+ # L_pen = L_pen + F.relu(min_gap_px - gap)
452
+
453
+ # Position/Size similarity loss
454
+ L_similarity = torch.tensor(0.0, device=device)
455
+ if reference_bboxes and len(reference_bboxes) >= num_nodes:
456
+ # Current generated bboxes as tensors
457
+ generated_bboxes = [torch.stack([bbox[0], bbox[1], bbox[2], bbox[3]]) for bbox in bboxes]
458
+ # Generated parent container bbox (current container)
459
+ generated_parent_bbox = (0.0, 0.0, float(Wc), float(Hc))
460
+
461
+ L_similarity = compute_position_size_similarity_loss(
462
+ reference_bboxes[:num_nodes],
463
+ generated_bboxes,
464
+ reference_parent_bbox,
465
+ generated_parent_bbox,
466
+ device=device
467
+ )
468
+
469
+ # Readability loss (size hierarchy consistency)
470
+ L_readability = torch.tensor(0.0, device=device)
471
+ if size_rules and len(size_rules) > 0:
472
+ generated_bboxes_readability = [torch.stack([bbox[0], bbox[1], bbox[2], bbox[3]]) for bbox in bboxes]
473
+ L_readability = compute_readability_loss(
474
+ size_rules,
475
+ generated_bboxes_readability,
476
+ size_ratio_threshold=params.SIZE_RATIO_THRESHOLD,
477
+ device=device
478
+ )
479
+
480
+ # Alignment consistency loss (hierarchical alignment)
481
+ L_alignment_consistency = torch.tensor(0.0, device=device)
482
+ if reference_bboxes and len(reference_bboxes) >= num_nodes:
483
+ generated_bboxes_alignment = [torch.stack([bbox[0], bbox[1], bbox[2], bbox[3]]) for bbox in bboxes]
484
+ generated_parent_bbox_alignment = (0.0, 0.0, float(Wc), float(Hc))
485
+
486
+ L_alignment_consistency = compute_alignment_consistency_loss(
487
+ reference_bboxes[:num_nodes],
488
+ generated_bboxes_alignment,
489
+ reference_parent_bbox,
490
+ generated_parent_bbox_alignment,
491
+ device=device
492
+ )
493
+
494
+ # Alignment similarity loss (based on JSON constraint)
495
+ L_alignment_similarity = torch.tensor(0.0, device=device)
496
+ if alignment_constraint and w_alignment_similarity > 0:
497
+ generated_bboxes_alignment_sim = [torch.stack([bbox[0], bbox[1], bbox[2], bbox[3]]) for bbox in bboxes]
498
+ container_bbox_alignment = (0.0, 0.0, float(Wc), float(Hc))
499
+
500
+ L_alignment_similarity = compute_alignment_similarity_loss(
501
+ generated_bboxes_alignment_sim,
502
+ container_bbox_alignment,
503
+ alignment_constraint,
504
+ device=device
505
+ )
506
+
507
+ # Proximity ratio loss
508
+ L_proximity = torch.tensor(0.0, device=device)
509
+ if proximity_info and w_proximity > 0:
510
+ # Extract information from proximity_info
511
+ container_bboxes = proximity_info.get("containers", [])
512
+ child_bboxes_list = proximity_info.get("children", [])
513
+ grandchild_bboxes_list = proximity_info.get("grandchildren", [])
514
+ container_types = proximity_info.get("types", [])
515
+ container_weights = proximity_info.get("weights", None)
516
+
517
+ # Convert current generated bboxes to tuples for proximity calculation
518
+ # For N-element case: treat as single container with N children
519
+ if len(container_bboxes) == 0:
520
+ # Simplified N-element case: create a container with N children
521
+ # Note: container type should be provided in proximity_info
522
+ container_bbox = (0.0, 0.0, float(Wc), float(Hc))
523
+ # Keep bboxes as tensors for gradient computation
524
+ child_bboxes = [torch.stack([bbox[0], bbox[1], bbox[2], bbox[3]]) for bbox in bboxes]
525
+ # For N-element case, grandchildren would be empty (children are leaf nodes)
526
+ grandchild_bboxes = []
527
+ container_type = container_types[0] if container_types else "row" # Default to row
528
+
529
+ L_proximity = compute_proximity_ratio_loss(
530
+ [container_bbox],
531
+ [child_bboxes],
532
+ [grandchild_bboxes],
533
+ [container_type],
534
+ container_weights=[1.0] if container_weights is None else container_weights,
535
+ epsilon=params.PROXIMITY_EPSILON,
536
+ device=device
537
+ )
538
+ else:
539
+ # Use provided proximity_info
540
+ # Keep bboxes as tensors for gradient computation
541
+ generated_bboxes_proximity = [torch.stack([bbox[0], bbox[1], bbox[2], bbox[3]]) for bbox in bboxes]
542
+
543
+ # Update child_bboxes_list with current generated bboxes if needed
544
+ # This is a simplified approach - in full implementation, we'd need to map
545
+ # generated bboxes to the correct containers
546
+ if len(child_bboxes_list) > 0 and len(child_bboxes_list[0]) == num_nodes:
547
+ # Update first container's children with generated bboxes (as tensors)
548
+ updated_child_bboxes_list = [generated_bboxes_proximity] + child_bboxes_list[1:]
549
+ else:
550
+ updated_child_bboxes_list = child_bboxes_list
551
+
552
+ L_proximity = compute_proximity_ratio_loss(
553
+ container_bboxes,
554
+ updated_child_bboxes_list,
555
+ grandchild_bboxes_list,
556
+ container_types,
557
+ container_weights,
558
+ epsilon=params.PROXIMITY_EPSILON,
559
+ device=device
560
+ )
561
+
562
+ g = A_inter
563
+ # Data ink loss: maximize union area (minimize white space)
564
+ # Negative because we want to maximize A_union (minimize -A_union)
565
+ L_data_ink = -A_union
566
+
567
+ # Scale pen_weight by rho to prevent L_pen from being overwhelmed when rho is large
568
+ # When rho is large, AL constraint dominates, so we need to scale pen_weight accordingly
569
+ pen_weight_scaled = pen_weight * (1.0 + rho.item() / 1e4)
570
+
571
+ # AL constraint on overlap + penalty term + similarity loss + readability loss + alignment consistency loss + alignment similarity loss + proximity loss + data ink loss + visual balance loss
572
+ loss = (lam * g + 0.5 * rho * g * g + pen_weight_scaled * L_pen +
573
+ w_similarity * L_similarity + w_readability * L_readability +
574
+ w_alignment_consistency * L_alignment_consistency + w_alignment_similarity * L_alignment_similarity +
575
+ w_proximity * L_proximity + w_data_ink * L_data_ink + w_visual_balance * L_visual_balance)
576
+
577
+ loss.backward()
578
+ opt.step()
579
+
580
+ # outer AL update
581
+ with torch.no_grad():
582
+ # Compute bboxes for logging and AL update
583
+ bboxes_log = []
584
+ for i in range(num_nodes):
585
+ tx_idx = i * 3
586
+ ty_idx = i * 3 + 1
587
+ ts_idx = i * 3 + 2
588
+ tx = opt_params[tx_idx]
589
+ ty = opt_params[ty_idx]
590
+ ts = opt_params[ts_idx]
591
+
592
+ min_width, min_height = min_sizes[i]
593
+ x, y, w, h = bbox_aspect_from_unconstrained(
594
+ tx, ty, ts, Wc, Hc, ratios[i],
595
+ min_width=min_width, min_height=min_height,
596
+ size_min=size_min
597
+ )
598
+ bboxes_log.append((x, y, w, h))
599
+
600
+ # Compute A_inter for AL update (always needed)
601
+ softmasks_log = []
602
+ for i in range(num_nodes):
603
+ x, y, w, h = bboxes_log[i]
604
+ m, d_px = sdf_to_softmask(sdf_tensors[i], x, y, w, h, X, Y, tau_px=tau_px)
605
+ softmasks_log.append(m)
606
+
607
+ inter_log = torch.zeros_like(softmasks_log[0])
608
+ for i in range(num_nodes):
609
+ for j in range(i + 1, num_nodes):
610
+ inter_log = inter_log + softmasks_log[i] * softmasks_log[j]
611
+
612
+ A_inter = area_sum(inter_log, Wc, Hc)
613
+
614
+ # Update Lagrangian multiplier
615
+ lam = lam + rho * A_inter
616
+ rho = rho * rho_mult
617
+
618
+ # Detailed logging and visualization (only in debug mode)
619
+ if debug:
620
+ # Compute all loss components for detailed logging
621
+ distances_log = []
622
+ for i in range(num_nodes):
623
+ x, y, w, h = bboxes_log[i]
624
+ m, d_px = sdf_to_softmask(sdf_tensors[i], x, y, w, h, X, Y, tau_px=tau_px)
625
+ distances_log.append(d_px)
626
+
627
+ union_log = torch.ones_like(softmasks_log[0])
628
+ for m in softmasks_log:
629
+ union_log = union_log * (1.0 - m)
630
+ union_log = 1.0 - union_log
631
+ A_union = area_sum(union_log, Wc, Hc)
632
+
633
+ A_union = area_sum(union_log, Wc, Hc)
634
+
635
+ # Recompute penalty for logging (based on actual bbox gap)
636
+ # Skip for layer containers (overlapping allowed)
637
+ L_pen_val = torch.tensor(0.0, device=device)
638
+
639
+ # Check if this is a layer container
640
+ is_layer = False
641
+ if proximity_info:
642
+ container_types = proximity_info.get("types", [])
643
+ if len(container_types) > 0:
644
+ is_layer = (container_types[0] == "layer")
645
+
646
+ if not is_layer:
647
+ for i in range(num_nodes):
648
+ for j in range(i + 1, num_nodes):
649
+ x_i, y_i, w_i, h_i = bboxes_log[i]
650
+ x_j, y_j, w_j, h_j = bboxes_log[j]
651
+
652
+ # Compute gap in each dimension
653
+ gap_x = torch.max(x_j - (x_i + w_i), x_i - (x_j + w_j))
654
+ gap_y = torch.max(y_j - (y_i + h_i), y_i - (y_j + h_j))
655
+
656
+ # Combined gap logic
657
+ if gap_x >= 0 and gap_y >= 0:
658
+ # Both separated: Euclidean distance
659
+ gap = torch.sqrt(gap_x * gap_x + gap_y * gap_y)
660
+ elif gap_x < 0 and gap_y < 0:
661
+ # Both overlapping: use smaller overlap
662
+ gap = torch.max(gap_x, gap_y)
663
+ else:
664
+ # One separated, one overlapping
665
+ gap = torch.max(gap_x, gap_y)
666
+
667
+ # Penalty if gap < min_gap_px
668
+ L_pen_val = L_pen_val + F.relu(min_gap_px - gap)
669
+
670
+ # Recompute similarity loss for logging
671
+ L_similarity_val = torch.tensor(0.0, device=device)
672
+ if reference_bboxes and len(reference_bboxes) >= num_nodes:
673
+ generated_bboxes_log = [torch.stack([bbox[0], bbox[1], bbox[2], bbox[3]]) for bbox in bboxes_log]
674
+ generated_parent_bbox_log = (0.0, 0.0, float(Wc), float(Hc))
675
+
676
+ L_similarity_val = compute_position_size_similarity_loss(
677
+ reference_bboxes[:num_nodes],
678
+ generated_bboxes_log,
679
+ reference_parent_bbox,
680
+ generated_parent_bbox_log,
681
+ device=device
682
+ )
683
+
684
+ # Recompute readability loss for logging
685
+ L_readability_val = torch.tensor(0.0, device=device)
686
+ if size_rules and len(size_rules) > 0:
687
+ generated_bboxes_readability_log = [torch.stack([bbox[0], bbox[1], bbox[2], bbox[3]]) for bbox in bboxes_log]
688
+ L_readability_val = compute_readability_loss(
689
+ size_rules,
690
+ generated_bboxes_readability_log,
691
+ size_ratio_threshold=params.SIZE_RATIO_THRESHOLD,
692
+ device=device
693
+ )
694
+
695
+ # Recompute alignment consistency loss for logging
696
+ L_alignment_consistency_val = torch.tensor(0.0, device=device)
697
+ if reference_bboxes and len(reference_bboxes) >= num_nodes:
698
+ generated_bboxes_alignment_log = [torch.stack([bbox[0], bbox[1], bbox[2], bbox[3]]) for bbox in bboxes_log]
699
+ generated_parent_bbox_alignment_log = (0.0, 0.0, float(Wc), float(Hc))
700
+
701
+ L_alignment_consistency_val = compute_alignment_consistency_loss(
702
+ reference_bboxes[:num_nodes],
703
+ generated_bboxes_alignment_log,
704
+ reference_parent_bbox,
705
+ generated_parent_bbox_alignment_log,
706
+ device=device
707
+ )
708
+
709
+ # Recompute alignment similarity loss for logging
710
+ L_alignment_similarity_val = torch.tensor(0.0, device=device)
711
+ if alignment_constraint and w_alignment_similarity > 0:
712
+ generated_bboxes_alignment_sim_log = [torch.stack([bbox[0], bbox[1], bbox[2], bbox[3]]) for bbox in bboxes_log]
713
+ container_bbox_alignment_log = (0.0, 0.0, float(Wc), float(Hc))
714
+
715
+ L_alignment_similarity_val = compute_alignment_similarity_loss(
716
+ generated_bboxes_alignment_sim_log,
717
+ container_bbox_alignment_log,
718
+ alignment_constraint,
719
+ device=device
720
+ )
721
+
722
+ # Recompute proximity ratio loss for logging
723
+ L_proximity_val = torch.tensor(0.0, device=device)
724
+ if proximity_info and w_proximity > 0:
725
+ container_bboxes = proximity_info.get("containers", [])
726
+ child_bboxes_list = proximity_info.get("children", [])
727
+ grandchild_bboxes_list = proximity_info.get("grandchildren", [])
728
+ container_types = proximity_info.get("types", [])
729
+ container_weights = proximity_info.get("weights", None)
730
+
731
+ if len(container_bboxes) == 0:
732
+ container_bbox = (0.0, 0.0, float(Wc), float(Hc))
733
+ child_bboxes = [(bbox[0].item(), bbox[1].item(), bbox[2].item(), bbox[3].item()) for bbox in bboxes_log]
734
+ grandchild_bboxes = []
735
+ container_type = container_types[0] if container_types else "row" # Default to row
736
+
737
+ L_proximity_val = compute_proximity_ratio_loss(
738
+ [container_bbox],
739
+ [child_bboxes],
740
+ [grandchild_bboxes],
741
+ [container_type],
742
+ container_weights=[1.0] if container_weights is None else container_weights,
743
+ epsilon=params.PROXIMITY_EPSILON,
744
+ device=device
745
+ )
746
+ else:
747
+ generated_bboxes_proximity_log = [(bbox[0].item(), bbox[1].item(), bbox[2].item(), bbox[3].item()) for bbox in bboxes_log]
748
+ if len(child_bboxes_list) > 0 and len(child_bboxes_list[0]) == num_nodes:
749
+ updated_child_bboxes_list_log = [generated_bboxes_proximity_log] + child_bboxes_list[1:]
750
+ else:
751
+ updated_child_bboxes_list_log = child_bboxes_list
752
+
753
+ L_proximity_val = compute_proximity_ratio_loss(
754
+ container_bboxes,
755
+ updated_child_bboxes_list_log,
756
+ grandchild_bboxes_list,
757
+ container_types,
758
+ container_weights,
759
+ epsilon=params.PROXIMITY_EPSILON,
760
+ device=device
761
+ )
762
+
763
+ # Compute data ink loss for logging
764
+ L_data_ink_val = -A_union
765
+
766
+ # Compute visual balance loss for logging (using bboxes_log)
767
+ L_visual_balance_val = compute_visual_balance_loss(
768
+ bboxes_log, Wc, Hc, device=device
769
+ )
770
+
771
+ # Compute total loss for visualization
772
+ g_val = A_inter
773
+ total_loss_val = (lam.item() * g_val.item() + 0.5 * rho.item() * g_val.item() * g_val.item() +
774
+ pen_weight * L_pen_val.item() +
775
+ w_similarity * L_similarity_val.item() +
776
+ w_readability * L_readability_val.item() +
777
+ w_alignment_consistency * L_alignment_consistency_val.item() +
778
+ w_alignment_similarity * L_alignment_similarity_val.item() +
779
+ w_proximity * L_proximity_val.item() +
780
+ w_data_ink * L_data_ink_val.item() +
781
+ w_visual_balance * L_visual_balance_val.item())
782
+
783
+ print(f"[outer {k:02d}] tau={tau_px:.3f} A_union={A_union.item():.2f} A_inter={A_inter.item():.6f} "
784
+ f"L_pen={pen_weight*L_pen_val.item():.4f} L_sim={w_similarity*L_similarity_val.item():.4f} "
785
+ f"L_read={w_readability*L_readability_val.item():.4f} "
786
+ f"L_align_cons={w_alignment_consistency*L_alignment_consistency_val.item():.4f} "
787
+ f"L_align_sim={w_alignment_similarity*L_alignment_similarity_val.item():.4f} "
788
+ f"L_prox={w_proximity*L_proximity_val.item():.4f} "
789
+ f"L_data_ink={w_data_ink*L_data_ink_val.item():.4f} "
790
+ f"L_balance={w_visual_balance*L_visual_balance_val.item():.4f} "
791
+ f"lam={lam.item():.3e} rho={rho.item():.3e}")
792
+
793
+ # Visualize optimization progress at end of each outer epoch
794
+ # Use full resolution for visualization
795
+ X_viz, Y_viz = make_container_grid(Hc, Wc, device=device)
796
+ epoch_bboxes = []
797
+ epoch_softmasks = []
798
+ for i, bbox_log in enumerate(bboxes_log):
799
+ x, y, w, h = bbox_log
800
+ epoch_bboxes.append((x.item(), y.item(), w.item(), h.item()))
801
+ m_viz, _ = sdf_to_softmask(sdf_tensors[i], x, y, w, h, X_viz, Y_viz, tau_px=tau_px)
802
+ epoch_softmasks.append(m_viz)
803
+
804
+ epoch_loss_info = {
805
+ 'total': total_loss_val,
806
+ 'A_union': A_union.item(),
807
+ 'A_inter': A_inter.item(),
808
+ 'pen': L_pen_val.item(),
809
+ 'similarity': w_similarity * L_similarity_val.item(),
810
+ 'readability': w_readability * L_readability_val.item(),
811
+ 'alignment': w_alignment_consistency * L_alignment_consistency_val.item() + w_alignment_similarity * L_alignment_similarity_val.item(),
812
+ 'alignment_consistency': w_alignment_consistency * L_alignment_consistency_val.item(),
813
+ 'alignment_similarity': w_alignment_similarity * L_alignment_similarity_val.item(),
814
+ 'proximity': w_proximity * L_proximity_val.item(),
815
+ 'data_ink': w_data_ink * L_data_ink_val.item(),
816
+ }
817
+
818
+ epoch_save_path = os.path.join(viz_folder, f"stage{stage_idx}_epoch{k:02d}.png")
819
+
820
+ visualize_optimization_progress(
821
+ epoch_softmasks, epoch_bboxes, Wc, Hc,
822
+ epoch=k, loss_info=epoch_loss_info,
823
+ save_path=epoch_save_path
824
+ )
825
+ else:
826
+ # Non-debug mode: simple logging
827
+ print(f"[outer {k:02d}] tau={tau_px:.3f} A_inter={A_inter.item():.6f} lam={lam.item():.3e} rho={rho.item():.3e}")
828
+
829
+ # final bbox (continuous)
830
+ final_bboxes = []
831
+ with torch.no_grad():
832
+ for i in range(num_nodes):
833
+ tx_idx = i * 3
834
+ ty_idx = i * 3 + 1
835
+ ts_idx = i * 3 + 2
836
+ tx = opt_params[tx_idx]
837
+ ty = opt_params[ty_idx]
838
+ ts = opt_params[ts_idx]
839
+
840
+ min_width, min_height = min_sizes[i]
841
+ x, y, w, h = bbox_aspect_from_unconstrained(
842
+ tx, ty, ts, Wc, Hc, ratios[i],
843
+ min_width=min_width, min_height=min_height,
844
+ size_min=size_min
845
+ )
846
+ final_bboxes.append((x.item(), y.item(), w.item(), h.item()))
847
+
848
+ # hard evaluation at full 1000x1000: overlap using (SDF<0) AND
849
+ with torch.no_grad():
850
+ Xf, Yf = make_container_grid(Hc, Wc, device=device)
851
+ # use a small tau for union display (not needed for hard overlap)
852
+ softmasks_f = []
853
+ distances_f = []
854
+ for i in range(num_nodes):
855
+ x, y, w, h = final_bboxes[i]
856
+ m, d_px = sdf_to_softmask(sdf_tensors[i],
857
+ torch.tensor(x, device=device),
858
+ torch.tensor(y, device=device),
859
+ torch.tensor(w, device=device),
860
+ torch.tensor(h, device=device),
861
+ Xf, Yf, tau_px=0.2)
862
+ softmasks_f.append(m)
863
+ distances_f.append(d_px)
864
+
865
+ # Compute union
866
+ union_f = torch.ones_like(softmasks_f[0])
867
+ for m in softmasks_f:
868
+ union_f = union_f * (1.0 - m)
869
+ union_f = 1.0 - union_f
870
+ A_union_f = union_f.sum().item() # da=1 at full res
871
+
872
+ # hard inside test: d_px < 0 for all pairs
873
+ hard_overlap = torch.zeros_like(softmasks_f[0])
874
+ for i in range(num_nodes):
875
+ for j in range(i + 1, num_nodes):
876
+ overlap_ij = ((distances_f[i] < 0.0) & (distances_f[j] < 0.0)).float()
877
+ hard_overlap = hard_overlap + overlap_ij
878
+ A_overlap_hard = hard_overlap.sum().item()
879
+
880
+ # print("\n=== Final Results ===")
881
+ # for i, bbox in enumerate(final_bboxes):
882
+ # print(f"bbox{i+1} (x,y,w,h) = {bbox}")
883
+ # print(f"Union area (approx, {Wc}x{Hc}) = {A_union_f:.2f} -> ratio {A_union_f/(Wc*Hc):.4f}")
884
+ # print(f"Hard overlap area (SDF<0) = {A_overlap_hard:.0f} pixels")
885
+
886
+ # Save visualization and composite images (only in debug mode)
887
+ if debug:
888
+ # Determine save paths based on prefix (save to visualization folder)
889
+ if save_prefix:
890
+ final_result_path = os.path.join(viz_folder, f"{save_prefix}_final_result.png")
891
+ composite_result_path = os.path.join(viz_folder, f"{save_prefix}_composite_result.png")
892
+ else:
893
+ final_result_path = os.path.join(viz_folder, "final_result.png")
894
+ composite_result_path = os.path.join(viz_folder, "composite_result.png")
895
+
896
+ # Visualize final result (only for 2 nodes, skip for N nodes)
897
+ # TODO: Extend visualize_final_result to support N nodes
898
+ if num_nodes == 2:
899
+ mask1_orig = load_binary_mask_from_rgba(png_list[0])
900
+ mask2_orig = load_binary_mask_from_rgba(png_list[1])
901
+ if mask1_orig is not None and mask2_orig is not None:
902
+ visualize_final_result(softmasks_f[0], softmasks_f[1], distances_f[0], distances_f[1],
903
+ final_bboxes[0], final_bboxes[1],
904
+ mask1_orig, mask2_orig, Wc, Hc,
905
+ save_path=final_result_path)
906
+
907
+ # Save composite image with original images
908
+ # Use original paths if provided, otherwise use the paths passed to optimize
909
+ save_png_list = []
910
+ for i in range(num_nodes):
911
+ orig_png = original_png_list[i] if i < len(original_png_list) and original_png_list[i] is not None else png_list[i]
912
+ save_png_list.append(orig_png)
913
+
914
+ if all(png is not None for png in save_png_list):
915
+ save_composite_image(png_list=save_png_list, bbox_list=final_bboxes, Wc=Wc, Hc=Hc,
916
+ save_path=composite_result_path)
917
+ else:
918
+ print(f"Warning: Skipping composite image save - some image paths are None")
919
+
920
+ return final_bboxes
921
+
modules/infographics_generator/layout_system/sdf/visualization.py ADDED
@@ -0,0 +1,634 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Visualization functions for SDF-based layout optimization."""
2
+
3
+ import numpy as np
4
+ from PIL import Image
5
+ import matplotlib.pyplot as plt
6
+ from typing import List, Tuple, Dict
7
+ import torch
8
+
9
+ from .core import make_container_grid, sdf_to_softmask
10
+
11
+
12
+ # -----------------------------
13
+ # Visualize SDF normalization and softmask conversion
14
+ # -----------------------------
15
+ def visualize_sdf_norm_and_softmask(sdf_norm: np.ndarray, mask: np.ndarray,
16
+ bbox: tuple = None, container_size: tuple = None,
17
+ tau_values: list = [0.5, 1.0, 1.5, 2.0],
18
+ save_path: str = None):
19
+ """
20
+ Visualize SDF normalization and sdf_to_softmask conversion process.
21
+
22
+ Args:
23
+ sdf_norm: Normalized SDF array [H, W]
24
+ mask: Original binary mask [H, W]
25
+ bbox: Optional bounding box (x, y, w, h) for softmask visualization
26
+ container_size: Optional container size (W, H) for softmask visualization
27
+ tau_values: List of tau values to visualize for softmask conversion
28
+ save_path: Optional path to save the figure
29
+ """
30
+ fig = plt.figure(figsize=(20, 12))
31
+
32
+ # Row 1: SDF normalization visualization
33
+ # Original mask
34
+ ax1 = plt.subplot(3, 4, 1)
35
+ ax1.imshow(mask, cmap='gray', interpolation='bilinear')
36
+ ax1.set_title('Original Binary Mask', fontsize=11, fontweight='bold')
37
+ ax1.set_xlabel('Width')
38
+ ax1.set_ylabel('Height')
39
+
40
+ # SDF normalized - full range
41
+ ax2 = plt.subplot(3, 4, 2)
42
+ im2 = ax2.imshow(sdf_norm, cmap='RdYlBu', interpolation='bilinear')
43
+ ax2.contour(sdf_norm, levels=[0], colors='black', linewidths=2)
44
+ ax2.set_title(f'SDF Normalized (Full Range)\n[{sdf_norm.min():.3f}, {sdf_norm.max():.3f}]',
45
+ fontsize=11, fontweight='bold')
46
+ ax2.set_xlabel('Width')
47
+ ax2.set_ylabel('Height')
48
+ plt.colorbar(im2, ax=ax2, label='SDF Value')
49
+
50
+ # SDF normalized - zoomed range around zero
51
+ ax3 = plt.subplot(3, 4, 3)
52
+ sdf_range = 0.1
53
+ vmin, vmax = -sdf_range, sdf_range
54
+ im3 = ax3.imshow(sdf_norm, cmap='RdYlBu', interpolation='bilinear', vmin=vmin, vmax=vmax)
55
+ ax3.contour(sdf_norm, levels=[0], colors='black', linewidths=2)
56
+ ax3.contour(sdf_norm, levels=np.linspace(-sdf_range, sdf_range, 11),
57
+ colors='gray', linewidths=0.5, alpha=0.3)
58
+ ax3.set_title(f'SDF Normalized (Zoomed)\nRange: [-{sdf_range}, {sdf_range}]',
59
+ fontsize=11, fontweight='bold')
60
+ ax3.set_xlabel('Width')
61
+ ax3.set_ylabel('Height')
62
+ plt.colorbar(im3, ax=ax3, label='SDF Value')
63
+
64
+ # SDF histogram
65
+ ax4 = plt.subplot(3, 4, 4)
66
+ ax4.hist(sdf_norm.flatten(), bins=100, alpha=0.7, edgecolor='black')
67
+ ax4.axvline(x=0, color='red', linestyle='--', linewidth=2, label='Zero level')
68
+ ax4.set_title('SDF Value Distribution', fontsize=11, fontweight='bold')
69
+ ax4.set_xlabel('SDF Value')
70
+ ax4.set_ylabel('Frequency')
71
+ ax4.legend()
72
+ ax4.grid(True, alpha=0.3)
73
+
74
+ # Row 2-3: Softmask conversion with different tau values
75
+ if bbox is not None and container_size is not None:
76
+ x, y, w, h = bbox
77
+ Wc, Hc = container_size
78
+
79
+ # Convert to torch tensors
80
+ sdf_norm_t = torch.from_numpy(sdf_norm)[None, None].float()
81
+ device = sdf_norm_t.device
82
+
83
+ # Create container grid
84
+ X, Y = make_container_grid(Hc, Wc, device=device)
85
+
86
+ # Visualize softmask for different tau values
87
+ # Layout: Row 2-3, each row has 2 tau values, each tau has 2 subplots (mask + distance)
88
+ for idx, tau_px in enumerate(tau_values):
89
+ # Row: 1 (idx 0-1) or 2 (idx 2-3)
90
+ row = 1 + idx // 2
91
+ # Column: 1-2 (idx 0) or 3-4 (idx 1) for row 1, 1-2 (idx 2) or 3-4 (idx 3) for row 2
92
+ col_offset = (idx % 2) * 2 # 0 or 2
93
+
94
+ # Convert bbox values to torch tensors for sdf_to_softmask
95
+ x_t = torch.tensor(x, device=device, dtype=torch.float32)
96
+ y_t = torch.tensor(y, device=device, dtype=torch.float32)
97
+ w_t = torch.tensor(w, device=device, dtype=torch.float32)
98
+ h_t = torch.tensor(h, device=device, dtype=torch.float32)
99
+
100
+ # Compute softmask
101
+ m, d_px = sdf_to_softmask(sdf_norm_t, x_t, y_t, w_t, h_t, X, Y, tau_px=tau_px)
102
+ m_np = m.squeeze().cpu().numpy()
103
+ d_px_np = d_px.squeeze().cpu().numpy()
104
+
105
+ # Softmask visualization - position: row*4 + col_offset + 1
106
+ ax_mask = plt.subplot(3, 4, row * 4 + col_offset + 1)
107
+ im_mask = ax_mask.imshow(m_np, cmap='viridis', interpolation='bilinear', vmin=0, vmax=1)
108
+ rect = plt.Rectangle((x, y), w, h, linewidth=2, edgecolor='red', facecolor='none')
109
+ ax_mask.add_patch(rect)
110
+ ax_mask.set_title(f'Softmask (τ={tau_px:.1f}px)', fontsize=11, fontweight='bold')
111
+ ax_mask.set_xlabel('Width')
112
+ ax_mask.set_ylabel('Height')
113
+ ax_mask.set_xlim(0, Wc)
114
+ ax_mask.set_ylim(Hc, 0)
115
+ plt.colorbar(im_mask, ax=ax_mask, label='Mask Value')
116
+
117
+ # Distance visualization - position: row*4 + col_offset + 2
118
+ ax_dist = plt.subplot(3, 4, row * 4 + col_offset + 2)
119
+ d_range = 5.0 # Show distance range
120
+ im_dist = ax_dist.imshow(d_px_np, cmap='coolwarm', interpolation='bilinear',
121
+ vmin=-d_range, vmax=d_range)
122
+ ax_dist.contour(d_px_np, levels=[0], colors='black', linewidths=2)
123
+ rect_dist = plt.Rectangle((x, y), w, h, linewidth=2, edgecolor='red', facecolor='none')
124
+ ax_dist.add_patch(rect_dist)
125
+ ax_dist.set_title(f'Distance (τ={tau_px:.1f}px)\nRange: [-{d_range}, {d_range}]px',
126
+ fontsize=11, fontweight='bold')
127
+ ax_dist.set_xlabel('Width')
128
+ ax_dist.set_ylabel('Height')
129
+ ax_dist.set_xlim(0, Wc)
130
+ ax_dist.set_ylim(Hc, 0)
131
+ plt.colorbar(im_dist, ax=ax_dist, label='Distance (px)')
132
+
133
+ plt.tight_layout()
134
+
135
+ if save_path:
136
+ plt.savefig(save_path, dpi=150, bbox_inches='tight')
137
+ print(f"SDF norm and softmask visualization saved to: {save_path}")
138
+ else:
139
+ plt.show()
140
+
141
+ plt.close()
142
+
143
+
144
+ # -----------------------------
145
+ # Visualize SDF
146
+ # -----------------------------
147
+ def visualize_sdf(sdf1: np.ndarray, sdf2: np.ndarray,
148
+ mask1: np.ndarray = None, mask2: np.ndarray = None,
149
+ save_path: str = None, sdf_range: float = 0.1):
150
+ """
151
+ Visualize two SDFs side by side with heatmaps and zero contours.
152
+
153
+ Args:
154
+ sdf1: First SDF array [H, W]
155
+ sdf2: Second SDF array [H, W]
156
+ mask1: Optional original mask for first image
157
+ mask2: Optional original mask for second image
158
+ save_path: Optional path to save the figure
159
+ sdf_range: Range of SDF values to display around zero (default 0.1)
160
+ """
161
+ fig, axes = plt.subplots(2, 2, figsize=(14, 14))
162
+
163
+ # SDF 1 visualization - limit range for finer detail
164
+ ax1 = axes[0, 0]
165
+ vmin1, vmax1 = -sdf_range, sdf_range
166
+ im1 = ax1.imshow(sdf1, cmap='RdYlBu', interpolation='bilinear',
167
+ vmin=vmin1, vmax=vmax1)
168
+ # Add multiple contour lines for detail
169
+ ax1.contour(sdf1, levels=[0], colors='black', linewidths=2)
170
+ ax1.contour(sdf1, levels=np.linspace(-sdf_range, sdf_range, 11),
171
+ colors='gray', linewidths=0.5, alpha=0.3)
172
+ ax1.set_title('SDF 1 (chart.png)', fontsize=12, fontweight='bold')
173
+ ax1.set_xlabel('Width')
174
+ ax1.set_ylabel('Height')
175
+ plt.colorbar(im1, ax=ax1, label='SDF Value')
176
+
177
+ # SDF 2 visualization - limit range for finer detail
178
+ ax2 = axes[0, 1]
179
+ vmin2, vmax2 = -sdf_range, sdf_range
180
+ im2 = ax2.imshow(sdf2, cmap='RdYlBu', interpolation='bilinear',
181
+ vmin=vmin2, vmax=vmax2)
182
+ # Add multiple contour lines for detail
183
+ ax2.contour(sdf2, levels=[0], colors='black', linewidths=2)
184
+ ax2.contour(sdf2, levels=np.linspace(-sdf_range, sdf_range, 11),
185
+ colors='gray', linewidths=0.5, alpha=0.3)
186
+ ax2.set_title('SDF 2 (pictogram.png)', fontsize=12, fontweight='bold')
187
+ ax2.set_xlabel('Width')
188
+ ax2.set_ylabel('Height')
189
+ plt.colorbar(im2, ax=ax2, label='SDF Value')
190
+
191
+ # Original masks if provided
192
+ if mask1 is not None:
193
+ ax3 = axes[1, 0]
194
+ ax3.imshow(mask1, cmap='gray', interpolation='bilinear')
195
+ ax3.set_title('Original Mask 1', fontsize=12, fontweight='bold')
196
+ ax3.set_xlabel('Width')
197
+ ax3.set_ylabel('Height')
198
+
199
+ if mask2 is not None:
200
+ ax4 = axes[1, 1]
201
+ ax4.imshow(mask2, cmap='gray', interpolation='bilinear')
202
+ ax4.set_title('Original Mask 2', fontsize=12, fontweight='bold')
203
+ ax4.set_xlabel('Width')
204
+ ax4.set_ylabel('Height')
205
+
206
+ plt.tight_layout()
207
+
208
+ if save_path:
209
+ plt.savefig(save_path, dpi=150, bbox_inches='tight')
210
+ print(f"SDF visualization saved to: {save_path}")
211
+ else:
212
+ plt.show()
213
+
214
+ plt.close()
215
+
216
+
217
+ # -----------------------------
218
+ # Visualize optimization progress
219
+ # -----------------------------
220
+ def visualize_optimization_progress(
221
+ softmasks: List[torch.Tensor],
222
+ bboxes: List[Tuple[float, float, float, float]],
223
+ Wc: int, Hc: int,
224
+ epoch: int = -1, # -1 for initial, >=0 for epoch number
225
+ loss_info: Dict[str, float] = None,
226
+ save_path: str = None
227
+ ):
228
+ """
229
+ Visualize optimization progress showing current layout and loss information.
230
+
231
+ Args:
232
+ softmasks: List of soft masks for each node [1,1,H,W]
233
+ bboxes: List of bounding boxes (x, y, w, h) for each node
234
+ Wc: Container width
235
+ Hc: Container height
236
+ epoch: Epoch number (-1 for initial, >=0 for epoch number)
237
+ loss_info: Dictionary with loss values (keys: 'total', 'pen', 'similarity', 'readability',
238
+ 'alignment', 'proximity', 'data_ink', 'A_union', 'A_inter', etc.)
239
+ save_path: Path to save the figure
240
+ """
241
+ num_nodes = len(softmasks)
242
+
243
+ # Convert tensors to numpy
244
+ masks_np = []
245
+ for m in softmasks:
246
+ masks_np.append(m.squeeze().cpu().numpy())
247
+
248
+ # Create figure with subplots
249
+ fig = plt.figure(figsize=(16, 10))
250
+
251
+ # Main layout visualization (left side)
252
+ ax_main = plt.subplot(1, 2, 1)
253
+
254
+ # Create combined visualization
255
+ combined = np.zeros((Hc, Wc, 3))
256
+ colors = plt.cm.tab10(np.linspace(0, 1, num_nodes))
257
+
258
+ # Compute union mask for visual balance calculation
259
+ union_mask = np.ones((Hc, Wc))
260
+ for mask_np in masks_np:
261
+ union_mask = union_mask * (1.0 - mask_np)
262
+ union_mask = 1.0 - union_mask
263
+
264
+ for i, (mask_np, bbox) in enumerate(zip(masks_np, bboxes)):
265
+ x, y, w, h = bbox
266
+ # Use different colors for each node
267
+ combined[:, :, 0] += mask_np * colors[i][0] # Red channel
268
+ combined[:, :, 1] += mask_np * colors[i][1] # Green channel
269
+ combined[:, :, 2] += mask_np * colors[i][2] # Blue channel
270
+
271
+ # Draw bbox rectangle
272
+ rect = plt.Rectangle((x, y), w, h, linewidth=2, edgecolor=colors[i],
273
+ facecolor='none', linestyle='--')
274
+ ax_main.add_patch(rect)
275
+
276
+ # Normalize combined image
277
+ combined = np.clip(combined, 0, 1)
278
+ ax_main.imshow(combined, interpolation='bilinear')
279
+ ax_main.set_xlim(0, Wc)
280
+ ax_main.set_ylim(Hc, 0)
281
+ ax_main.set_xlabel('Width (px)', fontsize=12)
282
+ ax_main.set_ylabel('Height (px)', fontsize=12)
283
+
284
+ title = "Initial Layout" if epoch < 0 else f"Epoch {epoch}"
285
+ ax_main.set_title(title, fontsize=14, fontweight='bold')
286
+
287
+ # Add bbox labels
288
+ for i, bbox in enumerate(bboxes):
289
+ x, y, w, h = bbox
290
+ ax_main.text(x + w/2, y + h/2, f"Node {i+1}",
291
+ ha='center', va='center', fontsize=10, fontweight='bold',
292
+ color='white', bbox=dict(boxstyle='round', facecolor='black', alpha=0.5))
293
+
294
+ # Visual balance visualization: show centroid and container center
295
+ total_mass = union_mask.sum()
296
+ if total_mass > 1e-8:
297
+ # Calculate centroid
298
+ x_coords = np.arange(Wc)
299
+ y_coords = np.arange(Hc)
300
+ x_grid, y_grid = np.meshgrid(x_coords, y_coords)
301
+
302
+ centroid_x = (union_mask * x_grid).sum() / total_mass
303
+ centroid_y = (union_mask * y_grid).sum() / total_mass
304
+
305
+ # Container center
306
+ center_x = Wc / 2.0
307
+ center_y = Hc / 2.0
308
+
309
+ # Draw container center (green cross)
310
+ ax_main.plot(center_x, center_y, 'g+', markersize=15, markeredgewidth=3,
311
+ label='Container Center', zorder=10)
312
+
313
+ # Draw centroid (red circle)
314
+ ax_main.plot(centroid_x, centroid_y, 'ro', markersize=10, markeredgewidth=2,
315
+ label='Centroid', zorder=10)
316
+
317
+ # Draw line connecting centroid to center
318
+ ax_main.plot([centroid_x, center_x], [centroid_y, center_y],
319
+ 'r--', linewidth=2, alpha=0.7, label='Balance Distance', zorder=9)
320
+
321
+ # Add distance annotation
322
+ distance = np.sqrt((centroid_x - center_x)**2 + (centroid_y - center_y)**2)
323
+ mid_x = (centroid_x + center_x) / 2
324
+ mid_y = (centroid_y + center_y) / 2
325
+ ax_main.annotate(f'd={distance:.1f}px',
326
+ xy=(mid_x, mid_y), xytext=(5, 5), textcoords='offset points',
327
+ fontsize=9, color='red', fontweight='bold',
328
+ bbox=dict(boxstyle='round,pad=0.3', facecolor='yellow', alpha=0.7))
329
+
330
+ ax_main.legend(loc='upper right', fontsize=9)
331
+
332
+ # Loss information (right side)
333
+ ax_info = plt.subplot(1, 2, 2)
334
+ ax_info.axis('off')
335
+
336
+ # Build loss info text
337
+ info_lines = []
338
+ info_lines.append("Optimization Progress")
339
+ info_lines.append("=" * 30)
340
+ info_lines.append("")
341
+
342
+ if epoch >= 0:
343
+ info_lines.append(f"Epoch: {epoch}")
344
+ else:
345
+ info_lines.append("Stage: Initial")
346
+
347
+ info_lines.append("")
348
+ info_lines.append("Layout Information:")
349
+ info_lines.append(f" Container: {Wc} x {Hc} px")
350
+ info_lines.append(f" Number of nodes: {num_nodes}")
351
+ info_lines.append("")
352
+ info_lines.append("Node Bounding Boxes:")
353
+ info_lines.append("-" * 30)
354
+ for i, bbox in enumerate(bboxes):
355
+ x, y, w, h = bbox
356
+ info_lines.append(f" Node {i+1}:")
357
+ info_lines.append(f" x: {x:.2f} px")
358
+ info_lines.append(f" y: {y:.2f} px")
359
+ info_lines.append(f" w: {w:.2f} px")
360
+ info_lines.append(f" h: {h:.2f} px")
361
+ info_lines.append("")
362
+
363
+ if loss_info:
364
+ info_lines.append("Loss Values:")
365
+ info_lines.append("-" * 30)
366
+
367
+ if 'total' in loss_info:
368
+ info_lines.append(f" Total Loss: {loss_info['total']:.6f}")
369
+
370
+ if 'A_union' in loss_info:
371
+ info_lines.append(f" Union Area: {loss_info['A_union']:.2f} px²")
372
+
373
+ if 'A_inter' in loss_info:
374
+ info_lines.append(f" Intersection: {loss_info['A_inter']:.6f} px²")
375
+
376
+ info_lines.append("")
377
+ info_lines.append("Loss Components:")
378
+ info_lines.append("-" * 30)
379
+
380
+ if 'pen' in loss_info:
381
+ info_lines.append(f" Penetration: {loss_info['pen']:.6f}")
382
+
383
+ if 'similarity' in loss_info:
384
+ info_lines.append(f" Similarity: {loss_info['similarity']:.6f}")
385
+
386
+ if 'readability' in loss_info:
387
+ info_lines.append(f" Readability: {loss_info['readability']:.6f}")
388
+
389
+ if 'alignment' in loss_info:
390
+ info_lines.append(f" Alignment: {loss_info['alignment']:.6f}")
391
+
392
+ if 'proximity' in loss_info:
393
+ info_lines.append(f" Proximity: {loss_info['proximity']:.6f}")
394
+
395
+ if 'data_ink' in loss_info:
396
+ info_lines.append(f" Data Ink: {loss_info['data_ink']:.6f}")
397
+
398
+ if 'visual_balance' in loss_info:
399
+ info_lines.append(f" Visual Balance: {loss_info['visual_balance']:.6f}")
400
+
401
+ # Display text
402
+ info_text = "\n".join(info_lines)
403
+ ax_info.text(0.1, 0.95, info_text, transform=ax_info.transAxes,
404
+ fontsize=11, verticalalignment='top', fontfamily='monospace',
405
+ bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.8))
406
+
407
+ plt.tight_layout()
408
+
409
+ if save_path:
410
+ plt.savefig(save_path, dpi=150, bbox_inches='tight')
411
+ print(f"Optimization progress visualization saved to: {save_path}")
412
+ else:
413
+ plt.show()
414
+
415
+ plt.close()
416
+
417
+
418
+ # -----------------------------
419
+ # Visualize final layout result
420
+ # -----------------------------
421
+ def visualize_final_result(m1f: torch.Tensor, m2f: torch.Tensor,
422
+ d1f_px: torch.Tensor, d2f_px: torch.Tensor,
423
+ bbox1: tuple, bbox2: tuple,
424
+ mask1: np.ndarray, mask2: np.ndarray,
425
+ Wc: int, Hc: int,
426
+ save_path: str = None):
427
+ """
428
+ Visualize the final optimization result showing layout, masks, and overlap.
429
+
430
+ Args:
431
+ m1f: Final soft mask for object 1 [1,1,H,W]
432
+ m2f: Final soft mask for object 2 [1,1,H,W]
433
+ d1f_px: Final SDF distance for object 1 [1,1,H,W]
434
+ d2f_px: Final SDF distance for object 2 [1,1,H,W]
435
+ bbox1: Bounding box (x, y, w, h) for object 1
436
+ bbox2: Bounding box (x, y, w, h) for object 2
437
+ mask1: Original mask for object 1
438
+ mask2: Original mask for object 2
439
+ Wc: Container width
440
+ Hc: Container height
441
+ save_path: Optional path to save the figure
442
+ """
443
+ # Convert tensors to numpy
444
+ m1_np = m1f.squeeze().cpu().numpy()
445
+ m2_np = m2f.squeeze().cpu().numpy()
446
+ d1_np = d1f_px.squeeze().cpu().numpy()
447
+ d2_np = d2f_px.squeeze().cpu().numpy()
448
+
449
+ # Compute union and intersection
450
+ union = 1.0 - (1.0 - m1_np) * (1.0 - m2_np)
451
+ inter = m1_np * m2_np
452
+ hard_overlap = ((d1_np < 0.0) & (d2_np < 0.0)).astype(np.float32)
453
+
454
+ fig, axes = plt.subplots(2, 3, figsize=(18, 12))
455
+
456
+ # Row 1: Individual masks
457
+ ax1 = axes[0, 0]
458
+ ax1.imshow(m1_np, cmap='gray', interpolation='bilinear')
459
+ x1, y1, w1, h1 = bbox1
460
+ rect1 = plt.Rectangle((x1, y1), w1, h1, linewidth=2, edgecolor='red', facecolor='none')
461
+ ax1.add_patch(rect1)
462
+ ax1.set_title(f'Object 1 Mask\nbbox: ({x1:.1f}, {y1:.1f}, {w1:.1f}, {h1:.1f})',
463
+ fontsize=11, fontweight='bold')
464
+ ax1.set_xlabel('Width')
465
+ ax1.set_ylabel('Height')
466
+ ax1.set_xlim(0, Wc)
467
+ ax1.set_ylim(Hc, 0)
468
+
469
+ ax2 = axes[0, 1]
470
+ ax2.imshow(m2_np, cmap='gray', interpolation='bilinear')
471
+ x2, y2, w2, h2 = bbox2
472
+ rect2 = plt.Rectangle((x2, y2), w2, h2, linewidth=2, edgecolor='blue', facecolor='none')
473
+ ax2.add_patch(rect2)
474
+ ax2.set_title(f'Object 2 Mask\nbbox: ({x2:.1f}, {y2:.1f}, {w2:.1f}, {h2:.1f})',
475
+ fontsize=11, fontweight='bold')
476
+ ax2.set_xlabel('Width')
477
+ ax2.set_ylabel('Height')
478
+ ax2.set_xlim(0, Wc)
479
+ ax2.set_ylim(Hc, 0)
480
+
481
+ # Combined view
482
+ ax3 = axes[0, 2]
483
+ combined = np.zeros((Hc, Wc, 3))
484
+ combined[:, :, 0] = m1_np # Red channel for object 1
485
+ combined[:, :, 2] = m2_np # Blue channel for object 2
486
+ combined[:, :, 1] = inter # Green channel for intersection
487
+ ax3.imshow(combined, interpolation='bilinear')
488
+ rect1_comb = plt.Rectangle((x1, y1), w1, h1, linewidth=2, edgecolor='red',
489
+ facecolor='none', linestyle='--')
490
+ rect2_comb = plt.Rectangle((x2, y2), w2, h2, linewidth=2, edgecolor='blue',
491
+ facecolor='none', linestyle='--')
492
+ ax3.add_patch(rect1_comb)
493
+ ax3.add_patch(rect2_comb)
494
+ ax3.set_title('Combined Layout\n(Red: Obj1, Blue: Obj2, Green: Overlap)',
495
+ fontsize=11, fontweight='bold')
496
+ ax3.set_xlabel('Width')
497
+ ax3.set_ylabel('Height')
498
+ ax3.set_xlim(0, Wc)
499
+ ax3.set_ylim(Hc, 0)
500
+
501
+ # Row 2: Union, Intersection, and Hard Overlap
502
+ ax4 = axes[1, 0]
503
+ im4 = ax4.imshow(union, cmap='viridis', interpolation='bilinear')
504
+ ax4.set_title('Union Area', fontsize=11, fontweight='bold')
505
+ ax4.set_xlabel('Width')
506
+ ax4.set_ylabel('Height')
507
+ plt.colorbar(im4, ax=ax4, label='Union Value')
508
+
509
+ ax5 = axes[1, 1]
510
+ im5 = ax5.imshow(inter, cmap='hot', interpolation='bilinear')
511
+ ax5.set_title('Intersection Area (Soft)', fontsize=11, fontweight='bold')
512
+ ax5.set_xlabel('Width')
513
+ ax5.set_ylabel('Height')
514
+ plt.colorbar(im5, ax=ax5, label='Intersection Value')
515
+
516
+ ax6 = axes[1, 2]
517
+ im6 = ax6.imshow(hard_overlap, cmap='Reds', interpolation='bilinear')
518
+ ax6.set_title('Hard Overlap (SDF < 0)', fontsize=11, fontweight='bold')
519
+ ax6.set_xlabel('Width')
520
+ ax6.set_ylabel('Height')
521
+ plt.colorbar(im6, ax=ax6, label='Overlap')
522
+
523
+ plt.tight_layout()
524
+
525
+ if save_path:
526
+ plt.savefig(save_path, dpi=150, bbox_inches='tight')
527
+ print(f"Final result visualization saved to: {save_path}")
528
+ else:
529
+ plt.show()
530
+
531
+ plt.close()
532
+
533
+
534
+ # -----------------------------
535
+ # Save composite image with original images placed according to optimized layout
536
+ # -----------------------------
537
+ def save_composite_image(png1: str = None, png2: str = None, bbox1: tuple = None, bbox2: tuple = None,
538
+ png_list: List[str] = None, bbox_list: List[tuple] = None,
539
+ Wc: int = 1000, Hc: int = 1000, save_path: str = "composite_result.png"):
540
+ """
541
+ Load original PNG images and composite them according to optimized bbox positions.
542
+
543
+ Supports both legacy 2-node format and new N-node format.
544
+
545
+ Args:
546
+ png1: Legacy - Path to first image (for backward compatibility)
547
+ png2: Legacy - Path to second image (for backward compatibility)
548
+ bbox1: Legacy - Bounding box (x, y, w, h) for first image
549
+ bbox2: Legacy - Bounding box (x, y, w, h) for second image
550
+ png_list: List of image paths for N nodes
551
+ bbox_list: List of bounding boxes (x, y, w, h) for N nodes
552
+ Wc: Container width
553
+ Hc: Container height
554
+ save_path: Path to save the composite image
555
+ """
556
+ import os
557
+
558
+ # Handle backward compatibility: convert png1/png2 to png_list
559
+ if png_list is None:
560
+ if png1 is not None and png2 is not None:
561
+ png_list = [png1, png2]
562
+ bbox_list = [bbox1, bbox2]
563
+ else:
564
+ raise ValueError("Either (png_list, bbox_list) or (png1, png2, bbox1, bbox2) must be provided")
565
+
566
+ if bbox_list is None:
567
+ raise ValueError("bbox_list must be provided")
568
+
569
+ if len(png_list) != len(bbox_list):
570
+ raise ValueError(f"Mismatch: {len(png_list)} images but {len(bbox_list)} bboxes")
571
+
572
+ num_nodes = len(png_list)
573
+ if num_nodes == 0:
574
+ print("Warning: No images to composite")
575
+ return
576
+
577
+ # Check if paths are valid
578
+ for i, png_path in enumerate(png_list):
579
+ if png_path is None:
580
+ print(f"Warning: Image path at index {i} is None")
581
+ return
582
+ if not os.path.exists(png_path):
583
+ print(f"Warning: Image file not found: {png_path}")
584
+ return
585
+
586
+ print(f"Loading {num_nodes} images for composite")
587
+ print(f"Container size: {Wc}x{Hc}")
588
+
589
+ # Create output canvas
590
+ canvas = Image.new("RGBA", (Wc, Hc), (255, 255, 255, 255))
591
+
592
+ # Place all images
593
+ for i, (png_path, bbox) in enumerate(zip(png_list, bbox_list)):
594
+ x, y, w, h = bbox
595
+ x_int = int(x)
596
+ y_int = int(y)
597
+ w_int = max(1, int(w))
598
+ h_int = max(1, int(h))
599
+
600
+ if w_int > 0 and h_int > 0:
601
+ # Clip coordinates to canvas bounds
602
+ x_clip = max(0, min(x_int, Wc - 1))
603
+ y_clip = max(0, min(y_int, Hc - 1))
604
+
605
+ # Calculate how much of the image fits in the canvas
606
+ x_end = min(x_clip + w_int, Wc)
607
+ y_end = min(y_clip + h_int, Hc)
608
+ w_fit = x_end - x_clip
609
+ h_fit = y_end - y_clip
610
+
611
+ if w_fit > 0 and h_fit > 0:
612
+ # Load image
613
+ img = Image.open(png_path).convert("RGBA")
614
+ orig_w, orig_h = img.size
615
+
616
+ img_resized = img.resize((w_int, h_int), Image.Resampling.LANCZOS)
617
+ # Crop image if it extends beyond canvas
618
+ if w_fit < w_int or h_fit < h_int:
619
+ img_resized = img_resized.crop((0, 0, w_fit, h_fit))
620
+ canvas.paste(img_resized, (x_clip, y_clip), img_resized)
621
+ print(f"Placed image{i+1} ({png_path}) at ({x_clip}, {y_clip}) with size ({w_fit}, {h_fit})")
622
+ else:
623
+ print(f"Warning: Image{i+1} has no valid area to place: ({x_clip}, {y_clip}, {w_fit}, {h_fit})")
624
+ else:
625
+ print(f"Warning: Skipping image{i+1} - invalid size: ({w_int}, {h_int})")
626
+
627
+ # Convert to RGB for saving (remove alpha channel)
628
+ canvas_rgb = Image.new("RGB", canvas.size, (255, 255, 255))
629
+ canvas_rgb.paste(canvas, mask=canvas.split()[3]) # Use alpha channel as mask
630
+
631
+ # Save result
632
+ canvas_rgb.save(save_path, "PNG")
633
+ print(f"Composite image saved to: {save_path}")
634
+
modules/infographics_generator/layout_system/strategies/__init__.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Optimization strategies for layout optimization."""
2
+
3
+ from .base import OptimizationStrategy
4
+ from .sdf_strategy import SDFOptimizationStrategy
5
+ from .rule_based_strategy import RuleBasedLayoutStrategy
6
+
7
+ __all__ = [
8
+ 'OptimizationStrategy',
9
+ 'SDFOptimizationStrategy',
10
+ 'RuleBasedLayoutStrategy',
11
+ ]
12
+
modules/infographics_generator/layout_system/strategies/base.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Base class for optimization strategies."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import List, Tuple, Optional
5
+ import numpy as np
6
+
7
+
8
+ class OptimizationStrategy(ABC):
9
+ """Base class for optimization strategies."""
10
+
11
+ @abstractmethod
12
+ def optimize(self, nodes: List[dict], container_bbox: Tuple[float, float, float, float],
13
+ constraints: dict, config: dict) -> List[Tuple[float, float, float, float]]:
14
+ """Execute optimization and return bounding boxes for each node.
15
+
16
+ Args:
17
+ nodes: List of node dictionaries with masks and metadata
18
+ container_bbox: Container bounding box (x, y, w, h)
19
+ constraints: Constraints dictionary from JSON
20
+ config: Optimization configuration
21
+
22
+ Returns:
23
+ List of optimized bounding boxes (x, y, w, h) for each node
24
+ """
25
+ pass
26
+
27
+ @abstractmethod
28
+ def composite(self, nodes: List[dict], bboxes: List[Tuple[float, float, float, float]],
29
+ container_bbox: Tuple[float, float, float, float]) -> Tuple[np.ndarray, np.ndarray]:
30
+ """Composite optimized results and return mask and SDF.
31
+
32
+ Args:
33
+ nodes: List of node dictionaries with masks and metadata
34
+ bboxes: List of optimized bounding boxes (x, y, w, h)
35
+ container_bbox: Container bounding box (x, y, w, h)
36
+
37
+ Returns:
38
+ Tuple of (mask, sdf)
39
+ - mask: Composite mask array (H, W)
40
+ - sdf: Signed distance field array (H, W)
41
+ """
42
+ pass
43
+
modules/infographics_generator/layout_system/strategies/rule_based_strategy.py ADDED
@@ -0,0 +1,328 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rule-based layout strategy for simple row/column layouts.
2
+
3
+ This strategy provides deterministic, fast layout calculation for simple
4
+ row and column arrangements without requiring gradient-based optimization.
5
+ """
6
+
7
+ import numpy as np
8
+ from typing import List, Tuple, Dict, Any
9
+ from .base import OptimizationStrategy
10
+
11
+
12
+ class RuleBasedLayoutStrategy(OptimizationStrategy):
13
+ """Rule-based layout strategy for row/column layouts.
14
+
15
+ This strategy calculates positions deterministically based on:
16
+ - Container type (row/column)
17
+ - Alignment constraints
18
+ - Gap/spacing between elements
19
+
20
+ Much faster than SDF-based optimization for simple linear arrangements.
21
+ """
22
+
23
+ def optimize(self, nodes: List[dict], container_bbox: Tuple[float, float, float, float],
24
+ constraints: dict, config: dict, save_prefix: str = None) -> List[Tuple[float, float, float, float]]:
25
+ """Execute rule-based layout optimization.
26
+
27
+ Args:
28
+ nodes: List of node dictionaries with "mask", "bbox" keys
29
+ container_bbox: Container bounding box (x, y, w, h)
30
+ constraints: Constraints dictionary (gap, alignment, etc.)
31
+ config: Configuration dictionary with container_type
32
+ save_prefix: Prefix for saving (unused in rule-based)
33
+
34
+ Returns:
35
+ List of optimized bounding boxes (x, y, w, h) for each node
36
+ """
37
+ container_type = config.get("container_type", "row")
38
+
39
+ print(f"[RuleBasedLayoutStrategy] Optimizing {len(nodes)} nodes in {container_type} layout")
40
+
41
+ if len(nodes) == 0:
42
+ return []
43
+
44
+ if len(nodes) == 1:
45
+ # Single node: just return its bbox
46
+ bbox = nodes[0].get("bbox", (0, 0, 100, 100))
47
+ return [self._normalize_bbox(bbox)]
48
+
49
+ # Choose layout method based on container type
50
+ if container_type == "row":
51
+ return self._layout_row(nodes, container_bbox, constraints)
52
+ elif container_type == "column":
53
+ return self._layout_column(nodes, container_bbox, constraints)
54
+ else:
55
+ # Fallback to center alignment for unknown types
56
+ print(f"[RuleBasedLayoutStrategy] Warning: Unknown container type '{container_type}', using default layout")
57
+ return self._layout_default(nodes, container_bbox)
58
+
59
+ def _layout_row(self, nodes: List[dict], container_bbox: Tuple[float, float, float, float],
60
+ constraints: dict) -> List[Tuple[float, float, float, float]]:
61
+ """Layout nodes horizontally (left to right).
62
+
63
+ Args:
64
+ nodes: List of nodes to layout
65
+ container_bbox: Container bounding box (x, y, w, h)
66
+ constraints: Layout constraints (gap, alignment)
67
+
68
+ Returns:
69
+ List of positioned bounding boxes
70
+ """
71
+ x, y, container_w, container_h = container_bbox
72
+
73
+ # Extract constraints
74
+ gap = self._get_gap(constraints)
75
+ alignment = self._get_alignment(constraints, default='center')
76
+
77
+ # Calculate total content size and scale if needed
78
+ original_bboxes = []
79
+ total_width = 0.0
80
+ max_height = 0.0
81
+
82
+ for node in nodes:
83
+ child_bbox = self._normalize_bbox(node.get("bbox", (0, 0, 100, 100)))
84
+ original_bboxes.append(child_bbox)
85
+ child_w = child_bbox[2]
86
+ child_h = child_bbox[3]
87
+ total_width += child_w
88
+ max_height = max(max_height, child_h)
89
+
90
+ # Add gaps to total width
91
+ total_width += gap * (len(nodes) - 1)
92
+
93
+ # Calculate scale factor to fit container (if needed)
94
+ scale_w = 1.0
95
+ scale_h = 1.0
96
+
97
+ if total_width > container_w:
98
+ scale_w = container_w / total_width
99
+ if max_height > container_h:
100
+ scale_h = container_h / max_height
101
+
102
+ # Use the smaller scale to maintain aspect ratio
103
+ scale = min(scale_w, scale_h)
104
+
105
+ # Layout elements with scaling
106
+ current_x = x
107
+ optimized_bboxes = []
108
+
109
+ for child_bbox in original_bboxes:
110
+ child_w = child_bbox[2] * scale
111
+ child_h = child_bbox[3] * scale
112
+
113
+ # X position: sequential from left to right
114
+ child_x = current_x
115
+
116
+ # Y position: based on alignment
117
+ child_y = self._calculate_cross_axis_position(
118
+ y, container_h, child_h, alignment, is_vertical=True
119
+ )
120
+
121
+ optimized_bboxes.append((child_x, child_y, child_w, child_h))
122
+ current_x += child_w + gap * scale
123
+
124
+ print(f"[RuleBasedLayoutStrategy] Row layout: gap={gap}, alignment={alignment}, scale={scale:.3f}")
125
+ return optimized_bboxes
126
+
127
+ def _layout_column(self, nodes: List[dict], container_bbox: Tuple[float, float, float, float],
128
+ constraints: dict) -> List[Tuple[float, float, float, float]]:
129
+ """Layout nodes vertically (top to bottom).
130
+
131
+ Args:
132
+ nodes: List of nodes to layout
133
+ container_bbox: Container bounding box (x, y, w, h)
134
+ constraints: Layout constraints (gap, alignment)
135
+
136
+ Returns:
137
+ List of positioned bounding boxes
138
+ """
139
+ x, y, container_w, container_h = container_bbox
140
+
141
+ # Extract constraints
142
+ gap = self._get_gap(constraints)
143
+ alignment = self._get_alignment(constraints, default='center')
144
+
145
+ # Calculate total content size and scale if needed
146
+ original_bboxes = []
147
+ total_height = 0.0
148
+ max_width = 0.0
149
+
150
+ for node in nodes:
151
+ child_bbox = self._normalize_bbox(node.get("bbox", (0, 0, 100, 100)))
152
+ original_bboxes.append(child_bbox)
153
+ child_w = child_bbox[2]
154
+ child_h = child_bbox[3]
155
+ total_height += child_h
156
+ max_width = max(max_width, child_w)
157
+
158
+ # Add gaps to total height
159
+ total_height += gap * (len(nodes) - 1)
160
+
161
+ # Calculate scale factor to fit container (if needed)
162
+ scale_w = 1.0
163
+ scale_h = 1.0
164
+
165
+ if max_width > container_w:
166
+ scale_w = container_w / max_width
167
+ if total_height > container_h:
168
+ scale_h = container_h / total_height
169
+
170
+ # Use the smaller scale to maintain aspect ratio
171
+ scale = min(scale_w, scale_h)
172
+
173
+ # Layout elements with scaling
174
+ current_y = y
175
+ optimized_bboxes = []
176
+
177
+ for child_bbox in original_bboxes:
178
+ child_w = child_bbox[2] * scale
179
+ child_h = child_bbox[3] * scale
180
+
181
+ # Y position: sequential from top to bottom
182
+ child_y = current_y
183
+
184
+ # X position: based on alignment
185
+ child_x = self._calculate_cross_axis_position(
186
+ x, container_w, child_w, alignment, is_vertical=False
187
+ )
188
+
189
+ optimized_bboxes.append((child_x, child_y, child_w, child_h))
190
+ current_y += child_h + gap * scale
191
+
192
+ print(f"[RuleBasedLayoutStrategy] Column layout: gap={gap}, alignment={alignment}, scale={scale:.3f}")
193
+ return optimized_bboxes
194
+
195
+ def _layout_default(self, nodes: List[dict], container_bbox: Tuple[float, float, float, float]
196
+ ) -> List[Tuple[float, float, float, float]]:
197
+ """Default layout: center all nodes at their original positions.
198
+
199
+ Fallback for unknown container types.
200
+ """
201
+ optimized_bboxes = []
202
+ for node in nodes:
203
+ child_bbox = self._normalize_bbox(node.get("bbox", (0, 0, 100, 100)))
204
+ optimized_bboxes.append(child_bbox)
205
+ return optimized_bboxes
206
+
207
+ def _calculate_cross_axis_position(self, container_start: float, container_size: float,
208
+ child_size: float, alignment: str, is_vertical: bool) -> float:
209
+ """Calculate position on the cross axis based on alignment.
210
+
211
+ Args:
212
+ container_start: Starting position of container (x or y)
213
+ container_size: Size of container (width or height)
214
+ child_size: Size of child element (width or height)
215
+ alignment: Alignment string ('left'/'top', 'center', 'right'/'bottom')
216
+ is_vertical: True if calculating Y position, False for X position
217
+
218
+ Returns:
219
+ Position on cross axis
220
+ """
221
+ alignment_lower = alignment.lower()
222
+
223
+ # Map alignment strings
224
+ if is_vertical:
225
+ # Vertical alignment (Y axis)
226
+ if alignment_lower in ['top', 'start', 'flex-start']:
227
+ return container_start
228
+ elif alignment_lower in ['center', 'middle']:
229
+ return container_start + (container_size - child_size) / 2
230
+ elif alignment_lower in ['bottom', 'end', 'flex-end']:
231
+ return container_start + container_size - child_size
232
+ else:
233
+ # Default: center
234
+ return container_start + (container_size - child_size) / 2
235
+ else:
236
+ # Horizontal alignment (X axis)
237
+ if alignment_lower in ['left', 'start', 'flex-start']:
238
+ return container_start
239
+ elif alignment_lower in ['center', 'middle']:
240
+ return container_start + (container_size - child_size) / 2
241
+ elif alignment_lower in ['right', 'end', 'flex-end']:
242
+ return container_start + container_size - child_size
243
+ else:
244
+ # Default: center
245
+ return container_start + (container_size - child_size) / 2
246
+
247
+ def _get_gap(self, constraints: dict) -> float:
248
+ """Extract gap value from constraints.
249
+
250
+ Args:
251
+ constraints: Constraints dictionary
252
+
253
+ Returns:
254
+ Gap value in pixels (default: 20)
255
+ """
256
+ if not constraints:
257
+ return 30
258
+
259
+ # Try to get gap - it might be a dict or a direct value
260
+ gap = constraints.get('gap', constraints.get('spacing', 20.0))
261
+
262
+ # If gap is a dict (e.g., {'type': 'gap', 'value': 32.0}), extract the value
263
+ if isinstance(gap, dict):
264
+ return float(gap.get('value', 20.0))
265
+
266
+ return float(gap)
267
+
268
+ def _get_alignment(self, constraints: dict, default: str = 'center') -> str:
269
+ """Extract alignment from constraints.
270
+
271
+ Args:
272
+ constraints: Constraints dictionary
273
+ default: Default alignment if not specified
274
+
275
+ Returns:
276
+ Alignment string
277
+ """
278
+ if not constraints:
279
+ return default
280
+
281
+ # Try to get alignment - it might be a dict or a direct value
282
+ alignment = constraints.get('alignment', default)
283
+
284
+ # If alignment is a dict (e.g., {'type': 'alignment', 'value': 'center'}), extract the value
285
+ if isinstance(alignment, dict):
286
+ return str(alignment.get('value', alignment.get('direction', default)))
287
+
288
+ return str(alignment)
289
+
290
+ def _normalize_bbox(self, bbox) -> Tuple[float, float, float, float]:
291
+ """Normalize bbox to (x, y, w, h) tuple format.
292
+
293
+ Args:
294
+ bbox: Bbox in tuple or dict format
295
+
296
+ Returns:
297
+ Normalized bbox tuple (x, y, w, h)
298
+ """
299
+ if isinstance(bbox, dict):
300
+ return (
301
+ float(bbox.get('x', 0)),
302
+ float(bbox.get('y', 0)),
303
+ float(bbox.get('width', bbox.get('w', 100))),
304
+ float(bbox.get('height', bbox.get('h', 100)))
305
+ )
306
+ elif isinstance(bbox, (tuple, list)) and len(bbox) >= 4:
307
+ return (float(bbox[0]), float(bbox[1]), float(bbox[2]), float(bbox[3]))
308
+ else:
309
+ return (0.0, 0.0, 100.0, 100.0)
310
+
311
+ def composite(self, nodes: List[dict], bboxes: List[Tuple[float, float, float, float]],
312
+ container_bbox: Tuple[float, float, float, float]) -> Tuple[np.ndarray, np.ndarray]:
313
+ """Composite nodes using existing utility function.
314
+
315
+ This delegates to the common composite function since rule-based layout
316
+ doesn't need special compositing logic.
317
+
318
+ Args:
319
+ nodes: List of node dictionaries
320
+ bboxes: List of optimized bounding boxes
321
+ container_bbox: Container bounding box
322
+
323
+ Returns:
324
+ Tuple of (composite_mask, composite_sdf)
325
+ """
326
+ from ..utils.composite import composite_nodes
327
+ return composite_nodes(nodes, bboxes, container_bbox)
328
+
modules/infographics_generator/layout_system/strategies/sdf_strategy.py ADDED
@@ -0,0 +1,314 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SDF-based optimization strategy."""
2
+
3
+ import numpy as np
4
+ import tempfile
5
+ import os
6
+ from PIL import Image
7
+ from typing import List, Tuple, Dict, Any
8
+ from .base import OptimizationStrategy
9
+ from modules.infographics_generator.layout_system import parameters as params
10
+ from modules.infographics_generator.layout_system.sdf import optimize as sdf_optimize
11
+ from modules.infographics_generator.layout_system.utils.composite import composite_nodes
12
+
13
+
14
+ class SDFOptimizationStrategy(OptimizationStrategy):
15
+ """SDF-based optimization strategy using existing optimize function."""
16
+
17
+ def optimize(self, nodes: List[dict], container_bbox: Tuple[float, float, float, float],
18
+ constraints: dict, config: dict, save_prefix: str = None) -> List[Tuple[float, float, float, float]]:
19
+ """Execute SDF-based optimization.
20
+
21
+ Args:
22
+ nodes: List of node dictionaries with "mask", "image_path", and "bbox" keys
23
+ container_bbox: Container bounding box (x, y, w, h)
24
+ constraints: Constraints dictionary from JSON (deprecated, kept for compatibility)
25
+ config: Optimization configuration
26
+
27
+ Returns:
28
+ List of optimized bounding boxes (x, y, w, h) for each node
29
+ """
30
+ container_type = config.get("container_type", "unknown")
31
+ debug = config.get("debug", False) # Get debug mode from config
32
+ print(f"[SDFOptimizationStrategy] optimize called: container_type={container_type}, num_nodes={len(nodes)}, debug={debug}")
33
+
34
+ if len(nodes) < 2:
35
+ # For single node, return its initial bbox
36
+ if nodes:
37
+ bbox = nodes[0].get("bbox", (0, 0, 100, 100))
38
+ return [bbox]
39
+ return []
40
+
41
+ # Extract image paths and masks, create temp files if needed
42
+ image_paths = []
43
+ original_paths = [] # Store original paths for saving composite image
44
+ temp_files = [] # Track temp files for cleanup
45
+
46
+ for i, node in enumerate(nodes):
47
+ metadata = node.get("metadata", {})
48
+ image_path = metadata.get("image_path") or node.get("image_path")
49
+ mask = node.get("mask")
50
+
51
+ # Store original path
52
+ original_paths.append(image_path)
53
+
54
+ if image_path and os.path.exists(image_path):
55
+ image_paths.append(image_path)
56
+ elif mask is not None:
57
+ # Create temporary image from mask for optimization
58
+ # But we'll use original path for saving composite image
59
+ mask_uint8 = (mask * 255).astype(np.uint8)
60
+ mask_image = Image.fromarray(mask_uint8, mode='L')
61
+ # Convert to RGBA with white background
62
+ rgba_image = Image.new('RGBA', mask_image.size, (255, 255, 255, 255))
63
+ rgba_image.putalpha(mask_image)
64
+
65
+ # Save to temp file
66
+ temp_file = tempfile.NamedTemporaryFile(suffix='.png', delete=False)
67
+ rgba_image.save(temp_file.name)
68
+ temp_files.append(temp_file.name)
69
+ image_paths.append(temp_file.name)
70
+ else:
71
+ # Fallback: use placeholder
72
+ image_paths.append(None)
73
+
74
+ # Handle N-node case
75
+ print(f"[SDFOptimizationStrategy] Processing {len(nodes)}-node case for container_type={container_type}")
76
+
77
+ # Check if all image paths are valid
78
+ if not all(image_paths):
79
+ # Cleanup temp files
80
+ for tf in temp_files:
81
+ if os.path.exists(tf):
82
+ os.unlink(tf)
83
+ # Fallback: return initial bboxes if no image paths
84
+ return [node.get("bbox", (0, 0, 100, 100)) for node in nodes]
85
+
86
+ # Get container dimensions
87
+ _, _, Wc, Hc = container_bbox
88
+ Wc = int(Wc)
89
+ Hc = int(Hc)
90
+
91
+ # Extract optimization parameters from config
92
+ opt_res_list = config.get("opt_res_list", (256, 512, 1000))
93
+ outer_rounds = config.get("outer_rounds", 12)
94
+ inner_steps = config.get("inner_steps", 300)
95
+ lr = config.get("lr", 0.05)
96
+ w_similarity = config.get("w_similarity", 1.0)
97
+ w_readability = config.get("w_readability", 1.0)
98
+ w_alignment_consistency = config.get("w_alignment_consistency", 1.0)
99
+ w_alignment_similarity = config.get("w_alignment_similarity", params.W_ALIGNMENT_SIMILARITY)
100
+ w_data_ink = config.get("w_data_ink", params.W_DATA_INK)
101
+
102
+ # Extract reference bboxes from nodes (initial bboxes from Example layout)
103
+ reference_bboxes = []
104
+ for node in nodes:
105
+ bbox = node.get("bbox", (0, 0, 100, 100))
106
+ if isinstance(bbox, dict):
107
+ # Convert dict bbox to tuple
108
+ ref_bbox = (
109
+ bbox.get("x", 0),
110
+ bbox.get("y", 0),
111
+ bbox.get("width", bbox.get("w", 100)),
112
+ bbox.get("height", bbox.get("h", 100))
113
+ )
114
+ else:
115
+ ref_bbox = bbox
116
+ reference_bboxes.append(ref_bbox)
117
+
118
+ # Reference parent container bbox (from container_bbox parameter)
119
+ reference_parent_bbox = container_bbox
120
+
121
+ # Extract size rules from constraints (only significant size differences)
122
+ size_rules = []
123
+ if constraints:
124
+ print("constraints:", constraints)
125
+ print("relative_size:", constraints.get("relative_size"))
126
+ relative_size_constraints = constraints.get("relative_size", [])
127
+ for rel_constraint in relative_size_constraints:
128
+ ratio = rel_constraint.get("ratio", 1.0)
129
+ source_idx = rel_constraint.get("source_index")
130
+ target_idx = rel_constraint.get("target_index")
131
+
132
+ if source_idx is not None and target_idx is not None:
133
+ # Only keep rules with significant size differences
134
+ # ratio >= SIZE_RATIO_THRESHOLD: source should be larger than target
135
+ # ratio <= 1/SIZE_RATIO_THRESHOLD: target should be larger than source
136
+ threshold = params.SIZE_RATIO_THRESHOLD
137
+ if ratio >= threshold:
138
+ size_rules.append((source_idx, target_idx))
139
+ elif ratio <= 1.0 / threshold:
140
+ size_rules.append((target_idx, source_idx))
141
+ print("size rules:", size_rules)
142
+
143
+ # Extract min_width and min_height from each node
144
+ min_sizes = []
145
+ for node in nodes:
146
+ # Check multiple possible locations for min_width and min_height
147
+ min_width = node.get("min_width")
148
+ min_height = node.get("min_height")
149
+
150
+ # If not found at node level, check bbox dict
151
+ if min_width is None or min_height is None:
152
+ bbox = node.get("bbox", {})
153
+ if isinstance(bbox, dict):
154
+ min_width = min_width or bbox.get("min_width")
155
+ min_height = min_height or bbox.get("min_height")
156
+
157
+ # If still not found, check metadata
158
+ if min_width is None or min_height is None:
159
+ metadata = node.get("metadata", {})
160
+ min_width = min_width or metadata.get("min_width")
161
+ min_height = min_height or metadata.get("min_height")
162
+
163
+ # Use defaults if not found
164
+ if min_width is None:
165
+ min_width = params.MIN_WIDTH_DEFAULT
166
+ if min_height is None:
167
+ min_height = params.MIN_HEIGHT_DEFAULT
168
+
169
+ min_sizes.append((float(min_width), float(min_height)))
170
+
171
+ print(f"Min sizes extracted: {min_sizes}")
172
+
173
+ # Extract proximity info for proximity ratio loss
174
+ # Simplified for bottom-up layout: only considers current layer's children spacing
175
+ # and children's children spacing
176
+ proximity_info = None
177
+ w_proximity = config.get("w_proximity", params.W_PROXIMITY)
178
+
179
+ if w_proximity > 0:
180
+ # Get container type from config (passed from hierarchical_optimizer)
181
+ container_type = config.get("container_type", "row") # Default to row
182
+
183
+ # Get grandchildren list from config (passed from hierarchical_optimizer)
184
+ grandchildren_list_from_config = config.get("grandchildren_list", [])
185
+
186
+ # Build proximity_info for current container
187
+ # Container is the parent container
188
+ container_bbox = container_bbox
189
+
190
+ # Children are the N elements (will be updated during optimization)
191
+ # For now, use initial bboxes
192
+ child_bboxes = []
193
+ grandchild_bboxes_list = [] # List of lists: grandchildren for each child
194
+
195
+ # Ensure grandchildren_list_from_config is a list of lists
196
+ # If it's a flat list, we need to handle it differently
197
+ if len(grandchildren_list_from_config) > 0:
198
+ first_elem = grandchildren_list_from_config[0]
199
+ if isinstance(first_elem, (tuple, list)) and len(first_elem) == 4 and isinstance(first_elem[0], (int, float)):
200
+ # Flat list: all grandchildren in one list, need to distribute to children
201
+ # This shouldn't happen, but handle it gracefully
202
+ # print(f"Warning: grandchildren_list_from_config appears to be a flat list, expected list of lists")
203
+ # For now, assign all grandchildren to first child (not ideal, but better than crashing)
204
+ if len(nodes) > 0:
205
+ grandchild_bboxes_list = [grandchildren_list_from_config] + [[]] * (len(nodes) - 1)
206
+ else:
207
+ grandchild_bboxes_list = []
208
+ else:
209
+ # Proper nested structure: list of lists
210
+ grandchild_bboxes_list = grandchildren_list_from_config[:len(nodes)]
211
+ # Pad with empty lists if needed
212
+ while len(grandchild_bboxes_list) < len(nodes):
213
+ grandchild_bboxes_list.append([])
214
+
215
+ for i, node in enumerate(nodes):
216
+ bbox = node.get("bbox", (0, 0, 100, 100))
217
+ if isinstance(bbox, dict):
218
+ child_bboxes.append((
219
+ bbox.get("x", 0),
220
+ bbox.get("y", 0),
221
+ bbox.get("width", bbox.get("w", 100)),
222
+ bbox.get("height", bbox.get("h", 100))
223
+ ))
224
+ else:
225
+ child_bboxes.append(bbox)
226
+
227
+ # Use grandchildren from grandchild_bboxes_list (already processed above)
228
+ # If not available, try to extract from node metadata as fallback
229
+ if i >= len(grandchild_bboxes_list) or not grandchild_bboxes_list[i]:
230
+ grandchildren = []
231
+ node_children = node.get("children", [])
232
+ if isinstance(node_children, list):
233
+ for grandchild in node_children:
234
+ grandchild_bbox = grandchild.get("bbox") if isinstance(grandchild, dict) else None
235
+ if grandchild_bbox:
236
+ if isinstance(grandchild_bbox, dict):
237
+ grandchildren.append((
238
+ grandchild_bbox.get("x", 0),
239
+ grandchild_bbox.get("y", 0),
240
+ grandchild_bbox.get("width", grandchild_bbox.get("w", 0)),
241
+ grandchild_bbox.get("height", grandchild_bbox.get("h", 0))
242
+ ))
243
+ elif isinstance(grandchild_bbox, (tuple, list)) and len(grandchild_bbox) >= 4:
244
+ grandchildren.append(tuple(grandchild_bbox[:4]))
245
+ if i < len(grandchild_bboxes_list):
246
+ grandchild_bboxes_list[i] = grandchildren
247
+ else:
248
+ grandchild_bboxes_list.append(grandchildren)
249
+
250
+ proximity_info = {
251
+ "containers": [container_bbox],
252
+ "children": [child_bboxes],
253
+ "grandchildren": grandchild_bboxes_list,
254
+ "types": [container_type],
255
+ "weights": None # Will use area-based weighting
256
+ }
257
+ print(f"Proximity info prepared: container={container_bbox}, type={container_type}, "
258
+ f"children={len(child_bboxes)}, grandchildren={[len(gc) for gc in grandchild_bboxes_list]}")
259
+
260
+ # Store original image paths for saving composite image
261
+ original_png_list = original_paths
262
+
263
+ # Get save prefix from parameter or config
264
+ save_prefix = save_prefix or config.get("save_prefix")
265
+
266
+ # Call optimize function with N-node support
267
+ # Use temp files for optimization, but pass original paths for saving
268
+ print(f"[SDFOptimizationStrategy] Calling test_sdf.optimize for container_type={container_type}, num_nodes={len(nodes)}")
269
+ optimized_bboxes = sdf_optimize(
270
+ png_list=image_paths,
271
+ original_png_list=original_png_list,
272
+ Wc=Wc,
273
+ Hc=Hc,
274
+ opt_res_list=opt_res_list,
275
+ outer_rounds=outer_rounds,
276
+ inner_steps=inner_steps,
277
+ lr=lr,
278
+ min_sizes=min_sizes,
279
+ reference_bboxes=reference_bboxes,
280
+ reference_parent_bbox=reference_parent_bbox,
281
+ w_similarity=w_similarity,
282
+ size_rules=size_rules,
283
+ w_readability=w_readability,
284
+ w_alignment_consistency=w_alignment_consistency,
285
+ # alignment_constraint=alignment_constraint,
286
+ w_alignment_similarity=w_alignment_similarity,
287
+ proximity_info=proximity_info,
288
+ w_proximity=w_proximity,
289
+ w_data_ink=w_data_ink,
290
+ device=config.get("device"),
291
+ save_prefix=save_prefix,
292
+ debug=debug, # Pass debug flag
293
+ )
294
+
295
+ # Note: Don't cleanup temp files here because save_composite_image
296
+ # is called inside sdf_optimize and needs the files
297
+ # Temp files will be cleaned up by Python's garbage collector or manually later
298
+
299
+ return optimized_bboxes
300
+
301
+ def composite(self, nodes: List[dict], bboxes: List[Tuple[float, float, float, float]],
302
+ container_bbox: Tuple[float, float, float, float]) -> Tuple[np.ndarray, np.ndarray]:
303
+ """Composite optimized results.
304
+
305
+ Args:
306
+ nodes: List of node dictionaries
307
+ bboxes: List of optimized bounding boxes
308
+ container_bbox: Container bounding box
309
+
310
+ Returns:
311
+ Tuple of (mask, sdf)
312
+ """
313
+ return composite_nodes(nodes, bboxes, container_bbox)
314
+
modules/infographics_generator/layout_system/test_hierarchical.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Test script for hierarchical layout optimization."""
2
+
3
+ import json
4
+ import sys
5
+
6
+ from layout_system.hierarchical_optimizer import (
7
+ HierarchicalOptimizer,
8
+ OptimizationConfig,
9
+ )
10
+ from layout_system.utils.save_result import save_hierarchical_result
11
+
12
+
13
+ def test_simplified_json():
14
+ """Test with simplified.json."""
15
+ print("=" * 60)
16
+ print("Testing hierarchical optimization with simplified.json")
17
+ print("=" * 60)
18
+
19
+ # Load JSON
20
+ with open("simplified.json", "r") as f:
21
+ tree_json = json.load(f)
22
+
23
+ # Create optimizer
24
+ config = OptimizationConfig(
25
+ base_dir=".",
26
+ device="cuda" if __import__("torch").cuda.is_available() else "cpu",
27
+ )
28
+ optimizer = HierarchicalOptimizer(config)
29
+
30
+ # Optimize tree
31
+ result = optimizer.optimize_tree(tree_json)
32
+
33
+ # Print results
34
+ print("\n" + "=" * 60)
35
+ print("Optimization Results:")
36
+ print("=" * 60)
37
+ print(json.dumps(result, indent=2, default=str))
38
+
39
+ return result
40
+
41
+
42
+ def test_complex_json(json_path=None):
43
+ """Test with complex JSON file."""
44
+ print("\n" + "=" * 60)
45
+ print("Testing hierarchical optimization with complex JSON")
46
+ print("=" * 60)
47
+
48
+ if json_path is None:
49
+ json_path = "2ff42ec4163d2bd9552a934f46e5090b3f6e2827da236aef6f645152974af629.png_with_constraints.json"
50
+
51
+ with open(json_path, "r") as f:
52
+ json_data = json.load(f)
53
+
54
+ tree_json = json_data.get("scene_tree", json_data)
55
+
56
+ # Create optimizer
57
+ # base_dir = os.path.dirname(json_path)
58
+ base_dir = "."
59
+ config = OptimizationConfig(
60
+ base_dir=base_dir,
61
+ device="cuda" if __import__("torch").cuda.is_available() else "cpu",
62
+ )
63
+ print("Creating optimizer with config: ", config)
64
+ optimizer = HierarchicalOptimizer(config)
65
+
66
+ # Optimize tree
67
+ result = optimizer.optimize_tree(tree_json)
68
+
69
+ # Print summary
70
+ print("\n" + "=" * 60)
71
+ print("Optimization Results Summary:")
72
+ print("=" * 60)
73
+ print(f"Root node type: {result.get('type')}")
74
+ print(f"Root final bbox: {result.get('final_bbox')}")
75
+ if "children" in result:
76
+ print(f"Number of children: {len(result['children'])}")
77
+ for i, child in enumerate(result["children"]):
78
+ print(f" Child {i}: {child.get('type')}, bbox: {child.get('final_bbox')}")
79
+
80
+ # Save hierarchical layout result
81
+ print("\n" + "=" * 60)
82
+ print("Saving hierarchical layout result...")
83
+ print("=" * 60)
84
+ save_hierarchical_result(result, save_path="hierarchical_result.png", base_dir=base_dir)
85
+
86
+ return result
87
+
88
+
89
+ if __name__ == "__main__":
90
+ json_path = sys.argv[1] if len(sys.argv) > 1 else None
91
+ test_complex_json(json_path)
92
+
modules/infographics_generator/layout_system/test_sdf.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Backward compatibility wrapper for sdf module.
2
+
3
+ This module re-exports all functions from the sdf subpackage for backward compatibility.
4
+ New code should import directly from the sdf subpackage instead.
5
+ """
6
+
7
+ from layout_system.sdf import optimize
8
+ from layout_system.sdf.core import (
9
+ binary_to_sdf_norm,
10
+ load_binary_mask_from_rgba,
11
+ )
12
+ from layout_system.sdf.visualization import (
13
+ visualize_sdf,
14
+ visualize_sdf_norm_and_softmask,
15
+ )
16
+
17
+ if __name__ == "__main__":
18
+ import json
19
+
20
+ # Visualize SDFs
21
+ print("Loading images and computing SDFs...")
22
+ mask1 = load_binary_mask_from_rgba("chart.png")
23
+ mask2 = load_binary_mask_from_rgba("pictogram.png")
24
+
25
+ sdf1_norm = binary_to_sdf_norm(mask1, pad=16)
26
+ sdf2_norm = binary_to_sdf_norm(mask2, pad=16)
27
+
28
+ print(f"SDF 1 shape: {sdf1_norm.shape}, range: [{sdf1_norm.min():.3f}, {sdf1_norm.max():.3f}]")
29
+ print(f"SDF 2 shape: {sdf2_norm.shape}, range: [{sdf2_norm.min():.3f}, {sdf2_norm.max():.3f}]")
30
+
31
+ # Use a smaller range (0.05) for finer detail around the boundary
32
+ visualize_sdf(sdf1_norm, sdf2_norm, mask1, mask2,
33
+ save_path="sdf_visualization.png", sdf_range=0.05)
34
+
35
+ # Visualize SDF normalization and softmask conversion for both images
36
+ print("\nVisualizing SDF normalization and softmask conversion...")
37
+
38
+ # For mask1 (chart) - use a sample bbox covering most of the image
39
+ H1, W1 = mask1.shape
40
+ bbox1_sample = (W1 * 0.1, H1 * 0.1, W1 * 0.8, H1 * 0.8)
41
+ visualize_sdf_norm_and_softmask(sdf1_norm, mask1,
42
+ bbox=bbox1_sample,
43
+ container_size=(W1, H1),
44
+ save_path="sdf_norm_softmask_chart.png")
45
+
46
+ # For mask2 (pictogram) - use a sample bbox covering most of the image
47
+ H2, W2 = mask2.shape
48
+ bbox2_sample = (W2 * 0.1, H2 * 0.1, W2 * 0.8, H2 * 0.8)
49
+ visualize_sdf_norm_and_softmask(sdf2_norm, mask2,
50
+ bbox=bbox2_sample,
51
+ container_size=(W2, H2),
52
+ save_path="sdf_norm_softmask_pictogram.png")
53
+
54
+ # Load reference layout from JSON
55
+ print("\n" + "="*50)
56
+ print("Loading reference layout from simplified.json...")
57
+ print("="*50)
58
+
59
+ json_path = "simplified.json"
60
+
61
+ with open(json_path, 'r') as f:
62
+ json_data = json.load(f)
63
+
64
+ # Extract reference bboxes from children
65
+ reference_bboxes = []
66
+ reference_parent_bbox = None
67
+
68
+ if "children" in json_data and len(json_data["children"]) >= 2:
69
+ # Extract parent container bbox
70
+ parent_bbox = json_data.get("bbox", {})
71
+ reference_parent_bbox = (
72
+ parent_bbox.get("x", 0),
73
+ parent_bbox.get("y", 0),
74
+ parent_bbox.get("width", 1000),
75
+ parent_bbox.get("height", 1000)
76
+ )
77
+
78
+ # Extract children bboxes (reference layout)
79
+ for child in json_data["children"]:
80
+ child_bbox = child.get("bbox", {})
81
+ ref_bbox = (
82
+ child_bbox.get("x", 0),
83
+ child_bbox.get("y", 0),
84
+ child_bbox.get("width", 100),
85
+ child_bbox.get("height", 100)
86
+ )
87
+ reference_bboxes.append(ref_bbox)
88
+
89
+ print(f"Reference parent bbox: {reference_parent_bbox}")
90
+ print(f"Reference bboxes: {reference_bboxes}")
91
+
92
+ # Run optimization
93
+ print("\n" + "="*50)
94
+ print("Running optimization...")
95
+ print("="*50)
96
+ optimize(png_list=["chart.png", "pictogram.png"],
97
+ reference_bboxes=reference_bboxes,
98
+ reference_parent_bbox=reference_parent_bbox,
99
+ w_similarity=1000.0)
modules/infographics_generator/layout_system/utils/__init__.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Utility functions for hierarchical optimization."""
2
+
3
+ from .placeholder import create_placeholder_rectangle
4
+ from .parser import parse_layout_tree, LayoutNode
5
+ from .composite import composite_nodes
6
+
7
+ __all__ = [
8
+ 'create_placeholder_rectangle',
9
+ 'parse_layout_tree',
10
+ 'LayoutNode',
11
+ 'composite_nodes',
12
+ ]
13
+
modules/infographics_generator/layout_system/utils/composite.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Composite utilities for combining node results."""
2
+
3
+ import numpy as np
4
+ from typing import List, Tuple
5
+ from PIL import Image
6
+
7
+
8
+ def composite_nodes(nodes: List[dict], bboxes: List[Tuple[float, float, float, float]],
9
+ container_bbox: Tuple[float, float, float, float]) -> Tuple[np.ndarray, np.ndarray]:
10
+ """Composite multiple nodes into a single mask.
11
+
12
+ Args:
13
+ nodes: List of node dictionaries with "mask" key
14
+ bboxes: List of (x, y, w, h) bounding boxes for each node
15
+ container_bbox: Container bounding box (x, y, w, h)
16
+
17
+ Returns:
18
+ Tuple of (composite_mask, composite_sdf)
19
+ - composite_mask: Combined mask (H, W)
20
+ - composite_sdf: Combined SDF (H, W) - simplified version
21
+ """
22
+ # Handle container_bbox format
23
+ if isinstance(container_bbox, (tuple, list)) and len(container_bbox) == 4:
24
+ cx, cy, cw, ch = container_bbox
25
+ elif isinstance(container_bbox, dict):
26
+ cx = container_bbox.get("x", 0)
27
+ cy = container_bbox.get("y", 0)
28
+ cw = container_bbox.get("width", container_bbox.get("w", 0))
29
+ ch = container_bbox.get("height", container_bbox.get("h", 0))
30
+ else:
31
+ raise ValueError(f"Invalid container_bbox format: {container_bbox}")
32
+
33
+ cw_int = int(float(cw))
34
+ ch_int = int(float(ch))
35
+
36
+ # Initialize composite mask
37
+ composite_mask = np.zeros((ch_int, cw_int), dtype=np.float32)
38
+
39
+ # Place each node's mask at its bbox position
40
+ for i, (node, bbox) in enumerate(zip(nodes, bboxes)):
41
+ mask = node.get("mask")
42
+ if mask is None:
43
+ continue
44
+
45
+ # Handle different bbox formats
46
+ if isinstance(bbox, (tuple, list)) and len(bbox) == 4:
47
+ x, y, w, h = bbox
48
+ elif isinstance(bbox, dict):
49
+ x = bbox.get("x", 0)
50
+ y = bbox.get("y", 0)
51
+ w = bbox.get("width", bbox.get("w", 0))
52
+ h = bbox.get("height", bbox.get("h", 0))
53
+ else:
54
+ print(f"Warning: Invalid bbox format at index {i}: {bbox} (type: {type(bbox)})")
55
+ continue
56
+
57
+ x_int = int(float(x))
58
+ y_int = int(float(y))
59
+ w_int = int(float(w))
60
+ h_int = int(float(h))
61
+
62
+ # Resize mask to bbox size if needed
63
+ if mask.shape != (h_int, w_int):
64
+ from scipy.ndimage import zoom
65
+ zoom_y = h_int / mask.shape[0]
66
+ zoom_x = w_int / mask.shape[1]
67
+ mask_resized = zoom(mask, (zoom_y, zoom_x), order=1)
68
+ else:
69
+ mask_resized = mask
70
+
71
+ # Clip to valid range
72
+ mask_resized = np.clip(mask_resized, 0, 1)
73
+
74
+ # Place mask in composite
75
+ y_end = min(y_int + h_int, ch_int)
76
+ x_end = min(x_int + w_int, cw_int)
77
+ y_start = max(0, y_int)
78
+ x_start = max(0, x_int)
79
+
80
+ if y_end > y_start and x_end > x_start:
81
+ mask_crop = mask_resized[:y_end-y_start, :x_end-x_start]
82
+ composite_mask[y_start:y_end, x_start:x_end] = np.maximum(
83
+ composite_mask[y_start:y_end, x_start:x_end],
84
+ mask_crop
85
+ )
86
+
87
+ # Generate simplified SDF from composite mask
88
+ # This is a simplified version - full SDF generation would use distance_transform_edt
89
+ composite_sdf = composite_mask - 0.5 # Simple approximation
90
+
91
+ return composite_mask, composite_sdf
92
+
modules/infographics_generator/layout_system/utils/nodes.py ADDED
@@ -0,0 +1,378 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 布局系统核心节点定义
3
+
4
+ 基于 template.ebnf 实现的布局系统
5
+ 支持:
6
+ - Flow Layout (ROW/COLUMN)
7
+ - Non-Flow Layout (Z-layer)
8
+ """
9
+
10
+ from abc import ABC, abstractmethod
11
+ from typing import List, Optional, Tuple, Any
12
+ from dataclasses import dataclass, field
13
+ from enum import Enum
14
+ import numpy as np
15
+
16
+
17
+ # ============= 枚举定义 =============
18
+
19
+ class NodeType(Enum):
20
+ """节点类型"""
21
+ GROUP = "GROUP"
22
+ TEXT = "TEXT"
23
+ IMAGE = "IMAGE"
24
+ CHART = "CHART"
25
+ SHAPE = "SHAPE"
26
+
27
+
28
+ class LayoutType(Enum):
29
+ """布局类型"""
30
+ FLOW = "FLOW"
31
+ NON_FLOW = "NON_FLOW"
32
+
33
+
34
+ class FlowDirection(Enum):
35
+ """Flow布局方向"""
36
+ ROW = "ROW" # 水平排列
37
+ COLUMN = "COLUMN" # 垂直排列
38
+ # 未来扩展:
39
+ # GRID = "GRID"
40
+ # CIRCULAR = "CIRCULAR"
41
+ # IRREGULAR = "IRREGULAR"
42
+
43
+
44
+ class MainAlignment(Enum):
45
+ """主轴对齐方式"""
46
+ START = "START"
47
+ CENTER = "CENTER"
48
+ END = "END"
49
+
50
+
51
+ class CrossAlignment(Enum):
52
+ """交叉轴对齐方式"""
53
+ START = "START"
54
+ CENTER = "CENTER"
55
+ END = "END"
56
+ STRETCH = "STRETCH"
57
+
58
+
59
+ class PositionAlign(Enum):
60
+ """非流式布局位置对齐"""
61
+ TOP_LEFT = "top-left"
62
+ TOP_CENTER = "top-center"
63
+ TOP_RIGHT = "top-right"
64
+ CENTER_LEFT = "left"
65
+ CENTER = "center"
66
+ CENTER_RIGHT = "right"
67
+ BOTTOM_LEFT = "bottom-left"
68
+ BOTTOM_CENTER = "bottom-center"
69
+ BOTTOM_RIGHT = "bottom-right"
70
+
71
+
72
+ class Alignment(Enum):
73
+ """对齐方式(用于每个节点自己的对齐)"""
74
+ START = "START"
75
+ CENTER = "CENTER"
76
+ END = "END"
77
+ LEFT = "LEFT"
78
+ RIGHT = "RIGHT"
79
+ # 特殊值(用于 layer 子节点)
80
+ BACKGROUND = "BACKGROUND"
81
+ TOP_LEFT = "TOP_LEFT"
82
+ TOP_CENTER = "TOP_CENTER"
83
+ TOP_RIGHT = "TOP_RIGHT"
84
+ BOTTOM_LEFT = "BOTTOM_LEFT"
85
+ BOTTOM_CENTER = "BOTTOM_CENTER"
86
+ BOTTOM_RIGHT = "BOTTOM_RIGHT"
87
+
88
+
89
+ # ============= 数据类定义 =============
90
+
91
+ @dataclass
92
+ class BoundingBox:
93
+ """边界框 (x, y, width, height)"""
94
+ x: float
95
+ y: float
96
+ width: float
97
+ height: float
98
+
99
+ @property
100
+ def left(self) -> float:
101
+ return self.x
102
+
103
+ @property
104
+ def right(self) -> float:
105
+ return self.x + self.width
106
+
107
+ @property
108
+ def top(self) -> float:
109
+ return self.y
110
+
111
+ @property
112
+ def bottom(self) -> float:
113
+ return self.y + self.height
114
+
115
+ @property
116
+ def center_x(self) -> float:
117
+ return self.x + self.width / 2
118
+
119
+ @property
120
+ def center_y(self) -> float:
121
+ return self.y + self.height / 2
122
+
123
+
124
+ @dataclass
125
+ class Padding:
126
+ """内边距"""
127
+ top: float = 0
128
+ right: float = 0
129
+ bottom: float = 0
130
+ left: float = 0
131
+
132
+ @classmethod
133
+ def uniform(cls, value: float) -> 'Padding':
134
+ """创建统一的内边距"""
135
+ return cls(value, value, value, value)
136
+
137
+ @property
138
+ def horizontal(self) -> float:
139
+ """水平方向总内边距"""
140
+ return self.left + self.right
141
+
142
+ @property
143
+ def vertical(self) -> float:
144
+ """垂直方向总内边距"""
145
+ return self.top + self.bottom
146
+
147
+
148
+ @dataclass
149
+ class FlowAlignment:
150
+ """Flow布局对齐配置"""
151
+ main: MainAlignment = MainAlignment.START
152
+ cross: CrossAlignment = CrossAlignment.START
153
+
154
+
155
+ # ============= 抽象基类 =============
156
+
157
+ class Node(ABC):
158
+ """节点抽象基类"""
159
+
160
+ def __init__(self, node_id: str, node_type: NodeType, alignment: Optional[str] = None, parent: Optional['Node'] = None):
161
+ self.id = node_id
162
+ self.type = node_type
163
+ self.alignment = alignment # 每个节点自己的对齐方式
164
+ self.bbox: Optional[BoundingBox] = None # 布局计算后的边界框
165
+ self.parent: Optional['Node'] = parent # 父节点引用
166
+
167
+ @abstractmethod
168
+ def compute_intrinsic_size(self) -> Tuple[float, float]:
169
+ """
170
+ 计算节点的固有尺寸 (width, height)
171
+
172
+ 对于 Leaf Node: 返回内容的实际尺寸
173
+ 对于 Non-Leaf Node: 根据子节点和布局规则计算
174
+ """
175
+ pass
176
+
177
+ @abstractmethod
178
+ def layout(self, x: float, y: float, available_width: Optional[float] = None,
179
+ available_height: Optional[float] = None) -> BoundingBox:
180
+ """
181
+ 执行布局计算
182
+
183
+ Args:
184
+ x: 起始 x 坐标
185
+ y: 起始 y 坐标
186
+ available_width: 可用宽度(可选)
187
+ available_height: 可用高度(可选)
188
+
189
+ Returns:
190
+ 计算后的边界框
191
+ """
192
+ pass
193
+
194
+ @abstractmethod
195
+ def to_dict(self) -> dict:
196
+ """序列化为字典"""
197
+ pass
198
+
199
+
200
+ # ============= Leaf Node =============
201
+
202
+ class LeafNode(Node):
203
+ """叶子节点
204
+
205
+ 叶子节点表示具体的视觉元素,具有固定的尺寸和可选的遮罩
206
+ """
207
+
208
+ def __init__(self, node_id: str, node_type: NodeType,
209
+ width: float, height: float,
210
+ mask: Optional[np.ndarray] = None,
211
+ metadata: Optional[dict] = None,
212
+ alignment: Optional[str] = None,
213
+ parent: Optional[Node] = None):
214
+ super().__init__(node_id, node_type, alignment, parent)
215
+ self.width = width
216
+ self.height = height
217
+ self.mask = mask # 可选的二值遮罩,用于不规则形状
218
+ self.metadata = metadata or {} # 存储额外的元数据(如content, role, src等)
219
+
220
+ def compute_intrinsic_size(self) -> Tuple[float, float]:
221
+ """叶子节点的固有尺寸就是其宽高"""
222
+ return (self.width, self.height)
223
+
224
+ def layout(self, x: float, y: float, available_width: Optional[float] = None,
225
+ available_height: Optional[float] = None) -> BoundingBox:
226
+ """叶子节点的布局很简单,直接放置在指定位置"""
227
+ self.bbox = BoundingBox(x, y, self.width, self.height)
228
+ return self.bbox
229
+
230
+ def to_dict(self) -> dict:
231
+ result = {
232
+ "id": self.id,
233
+ "type": self.type.value,
234
+ "bbox": {
235
+ "x": self.bbox.x if self.bbox else 0,
236
+ "y": self.bbox.y if self.bbox else 0,
237
+ "width": self.width,
238
+ "height": self.height
239
+ }
240
+ }
241
+ # 添加元数据
242
+ if self.metadata:
243
+ result.update(self.metadata)
244
+ return result
245
+
246
+
247
+ # ============= Non-Leaf Node =============
248
+
249
+ class GroupNode(Node):
250
+ """组节点(非叶子节点)
251
+
252
+ 组节点包含多个子节点,并根据布局类型对子节点进行排列
253
+ """
254
+
255
+ def __init__(self, node_id: str, layout_type: LayoutType,
256
+ children: List[Node], padding: Optional[Padding] = None,
257
+ alignment: Optional[str] = None,
258
+ parent: Optional[Node] = None):
259
+ super().__init__(node_id, NodeType.GROUP, alignment, parent)
260
+ self.layout_type = layout_type
261
+ self.children = children
262
+ # 设置每个子节点的 parent
263
+ for child in self.children:
264
+ child.parent = self
265
+ self.padding = padding or Padding()
266
+ self.mask: Optional[np.ndarray] = None # 根据子节点mask合并得到
267
+
268
+ # 布局特定属性(子类设置)
269
+ self.layout_attrs = {}
270
+
271
+ def compute_intrinsic_size(self) -> Tuple[float, float]:
272
+ """
273
+ 根据子节点和布局规则计算固有尺寸
274
+ 这个方法会在具体的布局子类中实现
275
+ """
276
+ raise NotImplementedError("Subclass must implement compute_intrinsic_size")
277
+
278
+ def layout(self, x: float, y: float, available_width: Optional[float] = None,
279
+ available_height: Optional[float] = None) -> BoundingBox:
280
+ """
281
+ 执行组节点的布局
282
+ 具体的布局算法由子类实现
283
+ """
284
+ raise NotImplementedError("Subclass must implement layout")
285
+
286
+ def _compute_mask_from_children(self):
287
+ """
288
+ 根据所有子节点的mask计算父节点的mask
289
+
290
+ 将每个子节点的mask根据其bbox位置转换到父节点坐标系,
291
+ 然后合并所有mask(使用OR操作)
292
+ """
293
+ if not self.bbox or not self.children:
294
+ self.mask = None
295
+ return
296
+
297
+ # 收集所有有mask的子节点
298
+ children_with_mask = [
299
+ child for child in self.children
300
+ if child.mask is not None and child.bbox is not None
301
+ ]
302
+
303
+ if not children_with_mask:
304
+ self.mask = None
305
+ return
306
+
307
+ # 创建父节点的mask(全零)
308
+ parent_width = int(self.bbox.width)
309
+ parent_height = int(self.bbox.height)
310
+ parent_mask = np.zeros((parent_height, parent_width), dtype=np.uint8)
311
+
312
+ # 将每个子节点的mask转换到父节点坐标系并合并
313
+ for child in children_with_mask:
314
+ child_mask = child.mask
315
+ child_bbox = child.bbox
316
+
317
+ # 计算子节点在父节点坐标系中的位置(相对于父节点左上角)
318
+ child_x_in_parent = int(child_bbox.x - self.bbox.x)
319
+ child_y_in_parent = int(child_bbox.y - self.bbox.y)
320
+ child_width = int(child_bbox.width)
321
+ child_height = int(child_bbox.height)
322
+
323
+ # 确保子节点mask的尺寸与bbox一致
324
+ if child_mask.shape != (child_height, child_width):
325
+ # 如果尺寸不匹配,调整mask尺寸(使用最近邻插值)
326
+ mask_h, mask_w = child_mask.shape
327
+
328
+ # 计算源坐标
329
+ y_coords = np.clip(
330
+ (np.arange(child_height) * mask_h / child_height).astype(int),
331
+ 0, mask_h - 1
332
+ )
333
+ x_coords = np.clip(
334
+ (np.arange(child_width) * mask_w / child_width).astype(int),
335
+ 0, mask_w - 1
336
+ )
337
+
338
+ # 使用numpy的高级索引进行最近邻插值
339
+ y_indices, x_indices = np.meshgrid(y_coords, x_coords, indexing='ij')
340
+ child_mask = child_mask[y_indices, x_indices]
341
+
342
+ # 计算在父节点mask中的位置范围
343
+ y_start = max(0, child_y_in_parent)
344
+ y_end = min(parent_height, child_y_in_parent + child_height)
345
+ x_start = max(0, child_x_in_parent)
346
+ x_end = min(parent_width, child_x_in_parent + child_width)
347
+
348
+ # 计算在子节点mask中的对应范围
349
+ child_y_start = max(0, -child_y_in_parent)
350
+ child_y_end = child_y_start + (y_end - y_start)
351
+ child_x_start = max(0, -child_x_in_parent)
352
+ child_x_end = child_x_start + (x_end - x_start)
353
+
354
+ # 将子节点mask复制到父节点mask的对应位置(OR操作)
355
+ if (y_end > y_start and x_end > x_start and
356
+ child_y_end > child_y_start and child_x_end > child_x_start):
357
+ parent_mask[y_start:y_end, x_start:x_end] = np.maximum(
358
+ parent_mask[y_start:y_end, x_start:x_end],
359
+ child_mask[child_y_start:child_y_end, child_x_start:child_x_end]
360
+ )
361
+
362
+ self.mask = parent_mask
363
+
364
+ def to_dict(self) -> dict:
365
+ return {
366
+ "id": self.id,
367
+ "type": self.type.value,
368
+ "layout": self.layout_type.value,
369
+ "layoutAttrs": self.layout_attrs,
370
+ "children": [child.to_dict() for child in self.children],
371
+ "bbox": {
372
+ "x": self.bbox.x if self.bbox else 0,
373
+ "y": self.bbox.y if self.bbox else 0,
374
+ "width": self.bbox.width if self.bbox else 0,
375
+ "height": self.bbox.height if self.bbox else 0
376
+ }
377
+ }
378
+
modules/infographics_generator/layout_system/utils/parser.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """JSON parser for layout tree structure."""
2
+
3
+ from typing import List, Optional, Dict, Any
4
+ from dataclasses import dataclass, field
5
+
6
+
7
+ @dataclass
8
+ class LayoutNode:
9
+ """Layout node data structure."""
10
+ type: str
11
+ bbox: Dict[str, float]
12
+ constraints: Dict[str, Any] = field(default_factory=dict)
13
+ children: List['LayoutNode'] = field(default_factory=list)
14
+ image_path: Optional[str] = None
15
+ content: Optional[str] = None
16
+ alignment: Optional[str] = None
17
+ metadata: Dict[str, Any] = field(default_factory=dict)
18
+
19
+ # Optimization results (filled after optimization)
20
+ final_bbox: Optional[tuple] = None
21
+ composite_mask: Optional[Any] = None
22
+ composite_sdf: Optional[Any] = None
23
+
24
+
25
+ def parse_layout_tree(json_data: dict) -> LayoutNode:
26
+ """Parse JSON layout tree structure.
27
+
28
+ Args:
29
+ json_data: JSON dictionary with layout tree structure
30
+
31
+ Returns:
32
+ Root LayoutNode
33
+ """
34
+ # Handle different JSON formats
35
+ if "scene_tree" in json_data:
36
+ tree_data = json_data["scene_tree"]
37
+ else:
38
+ tree_data = json_data
39
+
40
+ return _parse_node(tree_data)
41
+
42
+
43
+ def _parse_node(node_data: dict) -> LayoutNode:
44
+ """Recursively parse a node from JSON data."""
45
+ node = LayoutNode(
46
+ type=node_data.get("type", "unknown"),
47
+ bbox=node_data.get("bbox", {}),
48
+ constraints=node_data.get("constraints", {}),
49
+ image_path=node_data.get("image_path"),
50
+ content=node_data.get("content"),
51
+ alignment=node_data.get("alignment"),
52
+ metadata={},
53
+ )
54
+
55
+ # Parse children recursively
56
+ children_data = node_data.get("children", [])
57
+ for child_data in children_data:
58
+ child_node = _parse_node(child_data)
59
+ node.children.append(child_node)
60
+
61
+ return node
62
+
modules/infographics_generator/layout_system/utils/placeholder.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Placeholder generation utilities."""
2
+
3
+ import numpy as np
4
+ from PIL import Image, ImageDraw
5
+ from typing import Tuple
6
+
7
+
8
+ def create_placeholder_rectangle(width: float, height: float,
9
+ color: Tuple[int, int, int, int] = (224, 224, 224, 255)) -> Tuple[Image.Image, np.ndarray]:
10
+ """
11
+ Create a solid color rectangle placeholder.
12
+
13
+ Args:
14
+ width: Rectangle width
15
+ height: Rectangle height
16
+ color: RGBA color tuple, default light gray
17
+
18
+ Returns:
19
+ Tuple of (image, mask)
20
+ - image: PIL Image in RGBA format
21
+ - mask: Binary mask array (H, W) with all ones (fully filled rectangle)
22
+ """
23
+ w_int = int(width)
24
+ h_int = int(height)
25
+
26
+ # Create RGBA image with solid color
27
+ image = Image.new("RGBA", (w_int, h_int), color)
28
+
29
+ # Create mask (all ones for a filled rectangle)
30
+ mask = np.ones((h_int, w_int), dtype=np.float32)
31
+
32
+ return image, mask
33
+
34
+
35
+ def create_placeholder_rounded_rectangle(width: float, height: float,
36
+ radius: float = None,
37
+ color: Tuple[int, int, int, int] = (224, 224, 224, 255)) -> Tuple[Image.Image, np.ndarray]:
38
+ """
39
+ Create a rounded rectangle placeholder with rounded corners.
40
+
41
+ Args:
42
+ width: Rectangle width
43
+ height: Rectangle height
44
+ radius: Corner radius (default: min(width, height) * 0.1)
45
+ color: RGBA color tuple, default light gray
46
+
47
+ Returns:
48
+ Tuple of (image, mask)
49
+ - image: PIL Image in RGBA format
50
+ - mask: Binary mask array (H, W) with rounded rectangle shape
51
+ """
52
+ w_int = int(width)
53
+ h_int = int(height)
54
+
55
+ if radius is None:
56
+ radius = min(w_int, h_int) * 0.1
57
+
58
+ # Create RGBA image
59
+ image = Image.new("RGBA", (w_int, h_int), (0, 0, 0, 0))
60
+ draw = ImageDraw.Draw(image)
61
+
62
+ # Draw rounded rectangle
63
+ draw.rounded_rectangle([(0, 0), (w_int-1, h_int-1)], radius=radius, fill=color)
64
+
65
+ # Create mask from alpha channel
66
+ mask = np.array(image.split()[3], dtype=np.float32) / 255.0
67
+
68
+ return image, mask
69
+
modules/infographics_generator/layout_system/utils/save_result.py ADDED
@@ -0,0 +1,303 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Save hierarchical layout optimization results."""
2
+
3
+ import numpy as np
4
+ from PIL import Image, ImageDraw
5
+ from typing import Dict, Any, Tuple, Optional
6
+ import os
7
+
8
+
9
+ def save_hierarchical_result(result: Dict[str, Any], save_path: str = "hierarchical_result.png",
10
+ base_dir: str = ".") -> None:
11
+ """Save hierarchical optimization result as an image.
12
+
13
+ Args:
14
+ result: Result dictionary from HierarchicalOptimizer.optimize_tree()
15
+ save_path: Path to save the final image
16
+ base_dir: Base directory for resolving image paths
17
+ """
18
+ # Get root bbox
19
+ root_bbox = result.get("final_bbox")
20
+ if root_bbox is None:
21
+ print("Warning: No final_bbox found in result")
22
+ return
23
+
24
+ # Handle different bbox formats
25
+ if isinstance(root_bbox, (tuple, list)) and len(root_bbox) == 4:
26
+ x, y, w, h = root_bbox
27
+ elif isinstance(root_bbox, dict):
28
+ x = root_bbox.get("x", 0)
29
+ y = root_bbox.get("y", 0)
30
+ w = root_bbox.get("width", root_bbox.get("w", 1000))
31
+ h = root_bbox.get("height", root_bbox.get("h", 1000))
32
+ else:
33
+ print(f"Warning: Invalid root_bbox format: {root_bbox}")
34
+ return
35
+
36
+ root_x = int(float(x))
37
+ root_y = int(float(y))
38
+ Wc = int(float(w))
39
+ Hc = int(float(h))
40
+
41
+ # First pass: calculate the actual bounding box needed for all nodes
42
+ # Root node's final_bbox is absolute, so pass 0,0 as offset (will use final_bbox directly)
43
+ min_x, min_y, max_x, max_y = _calculate_bounds(result, offset_x=0, offset_y=0, is_root=True)
44
+
45
+ # Add some padding to ensure nothing is cut off
46
+ padding = 10
47
+ canvas_width = max_x - min_x + padding * 2
48
+ canvas_height = max_y - min_y + padding * 2
49
+ canvas_offset_x = min_x - padding
50
+ canvas_offset_y = min_y - padding
51
+
52
+ # Create canvas
53
+ canvas = Image.new("RGBA", (canvas_width, canvas_height), (255, 255, 255, 255))
54
+
55
+ # Print root bbox info
56
+ print(f"\n[Saving hierarchical result]")
57
+ print(f" Root bbox: ({root_x}, {root_y}, {Wc}, {Hc})")
58
+ print(f" Content bounds: ({min_x}, {min_y}) to ({max_x}, {max_y})")
59
+ print(f" Canvas size: {canvas_width}x{canvas_height} (offset: {canvas_offset_x}, {canvas_offset_y})")
60
+
61
+ # Second pass: recursively composite all nodes
62
+ # Root node's final_bbox is absolute, so we need to adjust for canvas offset
63
+ # For root node, we pass a flag indicating it's the root
64
+ _composite_node_to_canvas(result, canvas, base_dir,
65
+ offset_x=root_x - canvas_offset_x,
66
+ offset_y=root_y - canvas_offset_y,
67
+ is_root=True)
68
+
69
+ # Convert to RGB and save
70
+ canvas_rgb = Image.new("RGB", canvas.size, (255, 255, 255))
71
+ canvas_rgb.paste(canvas, mask=canvas.split()[3])
72
+ canvas_rgb.save(save_path, "PNG")
73
+ print(f"Hierarchical layout result saved to: {save_path}\n")
74
+
75
+
76
+ def _draw_bbox(canvas: Image.Image, x: int, y: int, w: int, h: int, node_type: str) -> None:
77
+ """Draw bounding box on canvas.
78
+
79
+ Args:
80
+ canvas: PIL Image canvas to draw on
81
+ x: X coordinate (absolute)
82
+ y: Y coordinate (absolute)
83
+ w: Width
84
+ h: Height
85
+ node_type: Type of node (for color selection)
86
+ """
87
+ # Clip coordinates to canvas bounds
88
+ x_clip = max(0, min(x, canvas.width - 1))
89
+ y_clip = max(0, min(y, canvas.height - 1))
90
+ x_end = min(x + w, canvas.width)
91
+ y_end = min(y + h, canvas.height)
92
+
93
+ if x_end <= x_clip or y_end <= y_clip:
94
+ return
95
+
96
+ # Choose color based on node type
97
+ color_map = {
98
+ "column": (255, 0, 0, 255), # Red for column
99
+ "row": (0, 255, 0, 255), # Green for row
100
+ "layer": (0, 0, 255, 255), # Blue for layer
101
+ "chart": (255, 165, 0, 255), # Orange for chart
102
+ "image": (255, 0, 255, 255), # Magenta for image
103
+ "text": (0, 255, 255, 255), # Cyan for text
104
+ }
105
+ color = color_map.get(node_type, (128, 128, 128, 255)) # Gray for unknown types
106
+
107
+ # Draw rectangle
108
+ draw = ImageDraw.Draw(canvas)
109
+ draw.rectangle([x_clip, y_clip, x_end - 1, y_end - 1], outline=color, width=2)
110
+
111
+
112
+ def _calculate_bounds(node_result: Dict[str, Any], offset_x: int = 0, offset_y: int = 0,
113
+ is_root: bool = False) -> Tuple[int, int, int, int]:
114
+ """Calculate the bounding box of all nodes in the tree.
115
+
116
+ Args:
117
+ node_result: Node result dictionary
118
+ offset_x: X offset accumulated from parent containers (for relative coordinates)
119
+ offset_y: Y offset accumulated from parent containers (for relative coordinates)
120
+ is_root: Whether this is the root node (root's final_bbox is absolute, others are relative)
121
+
122
+ Returns:
123
+ Tuple of (min_x, min_y, max_x, max_y) in absolute coordinates
124
+ """
125
+ # Get node bbox
126
+ bbox = node_result.get("final_bbox")
127
+ if bbox is None:
128
+ return (0, 0, 0, 0)
129
+
130
+ # Handle different bbox formats
131
+ if isinstance(bbox, (tuple, list)) and len(bbox) == 4:
132
+ x, y, w, h = bbox
133
+ elif isinstance(bbox, dict):
134
+ x = bbox.get("x", 0)
135
+ y = bbox.get("y", 0)
136
+ w = bbox.get("width", bbox.get("w", 0))
137
+ h = bbox.get("height", bbox.get("h", 0))
138
+ else:
139
+ return (0, 0, 0, 0)
140
+
141
+ # Root node's bbox is absolute, other nodes' bboxes are relative to parent container
142
+ if is_root:
143
+ # Root bbox is already absolute, use it directly
144
+ x_abs = int(float(x))
145
+ y_abs = int(float(y))
146
+ else:
147
+ # Convert relative coordinates to absolute by adding parent offset
148
+ x_abs = offset_x + int(float(x))
149
+ y_abs = offset_y + int(float(y))
150
+
151
+ w_int = max(1, int(float(w)))
152
+ h_int = max(1, int(float(h)))
153
+
154
+ # Initialize bounds with this node's bounds
155
+ min_x = x_abs
156
+ min_y = y_abs
157
+ max_x = x_abs + w_int
158
+ max_y = y_abs + h_int
159
+
160
+ # Recursively process children
161
+ # Children are not root nodes, so pass is_root=False
162
+ children = node_result.get("children", [])
163
+ for child_result in children:
164
+ child_min_x, child_min_y, child_max_x, child_max_y = _calculate_bounds(
165
+ child_result, offset_x=x_abs, offset_y=y_abs, is_root=False
166
+ )
167
+ if child_min_x < min_x:
168
+ min_x = child_min_x
169
+ if child_min_y < min_y:
170
+ min_y = child_min_y
171
+ if child_max_x > max_x:
172
+ max_x = child_max_x
173
+ if child_max_y > max_y:
174
+ max_y = child_max_y
175
+
176
+ return (min_x, min_y, max_x, max_y)
177
+
178
+
179
+ def _composite_node_to_canvas(node_result: Dict[str, Any], canvas: Image.Image,
180
+ base_dir: str, offset_x: int = 0, offset_y: int = 0,
181
+ is_root: bool = False) -> None:
182
+ """Recursively composite node results onto canvas.
183
+
184
+ Args:
185
+ node_result: Node result dictionary
186
+ canvas: PIL Image canvas to composite onto
187
+ base_dir: Base directory for resolving image paths
188
+ offset_x: X offset accumulated from parent containers (for canvas offset adjustment)
189
+ offset_y: Y offset accumulated from parent containers (for canvas offset adjustment)
190
+ is_root: Whether this is the root node (root's final_bbox is absolute, others are relative)
191
+ """
192
+ # Get node bbox
193
+ bbox = node_result.get("final_bbox")
194
+ if bbox is None:
195
+ return
196
+
197
+ # Handle different bbox formats
198
+ if isinstance(bbox, (tuple, list)) and len(bbox) == 4:
199
+ x, y, w, h = bbox
200
+ elif isinstance(bbox, dict):
201
+ x = bbox.get("x", 0)
202
+ y = bbox.get("y", 0)
203
+ w = bbox.get("width", bbox.get("w", 0))
204
+ h = bbox.get("height", bbox.get("h", 0))
205
+ else:
206
+ return
207
+
208
+ # Root node's bbox is absolute, other nodes' bboxes are relative to parent container
209
+ if is_root:
210
+ # Root bbox is already absolute, offset_x/offset_y here are canvas offsets (root_x - canvas_offset_x)
211
+ # For root: final_bbox x is root_x (absolute), offset_x = root_x - canvas_offset_x
212
+ # We want canvas-relative: root_x - canvas_offset_x = offset_x
213
+ # So we use offset_x directly (it's already the canvas-relative position)
214
+ x_abs = offset_x
215
+ y_abs = offset_y
216
+ else:
217
+ # Child node bbox is relative to parent container
218
+ # Convert to absolute coordinates by adding parent offset
219
+ x_abs = offset_x + int(float(x))
220
+ y_abs = offset_y + int(float(y))
221
+ w_int = max(1, int(float(w)))
222
+ h_int = max(1, int(float(h)))
223
+
224
+ # Get node type for debugging
225
+ node_type = node_result.get("type", "unknown")
226
+
227
+ # Check if this is a leaf node with an image
228
+ metadata = node_result.get("metadata", {})
229
+ image_path = metadata.get("image_path") or metadata.get("full_path") or node_result.get("image_path")
230
+
231
+ # Try to resolve path
232
+ if image_path:
233
+ if os.path.isabs(image_path):
234
+ full_path = image_path
235
+ else:
236
+ full_path = os.path.join(base_dir, image_path)
237
+
238
+ if os.path.exists(full_path):
239
+ # Load and place image
240
+ img = Image.open(full_path).convert("RGBA")
241
+
242
+ # Resize image while preserving aspect ratio
243
+ # Calculate scale to fit within bbox
244
+ img_w, img_h = img.size
245
+ scale_w = w_int / img_w if img_w > 0 else 1.0
246
+ scale_h = h_int / img_h if img_h > 0 else 1.0
247
+ scale = min(scale_w, scale_h) # Use smaller scale to fit within bbox
248
+
249
+ # Calculate new size preserving aspect ratio
250
+ new_w = int(img_w * scale)
251
+ new_h = int(img_h * scale)
252
+
253
+ # Resize image
254
+ img_resized = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
255
+
256
+ # Center image within bbox
257
+ x_offset = (w_int - new_w) // 2
258
+ y_offset = (h_int - new_h) // 2
259
+
260
+ # Calculate final position on canvas
261
+ x_final = x_abs + x_offset
262
+ y_final = y_abs + y_offset
263
+
264
+ # Clip coordinates to canvas bounds
265
+ x_clip = max(0, min(x_final, canvas.width - 1))
266
+ y_clip = max(0, min(y_final, canvas.height - 1))
267
+
268
+ # Calculate how much of the image fits
269
+ x_end = min(x_clip + new_w, canvas.width)
270
+ y_end = min(y_clip + new_h, canvas.height)
271
+ w_fit = x_end - x_clip
272
+ h_fit = y_end - y_clip
273
+
274
+ if w_fit > 0 and h_fit > 0:
275
+ if w_fit < new_w or h_fit < new_h:
276
+ img_resized = img_resized.crop((0, 0, w_fit, h_fit))
277
+ canvas.paste(img_resized, (x_clip, y_clip), img_resized)
278
+ # Print bbox info for debugging
279
+ coord_type = "absolute" if is_root else "relative"
280
+ print(f" [Save] Node '{node_type}': image={os.path.basename(image_path)}, "
281
+ f"bbox=({int(float(x))}, {int(float(y))}, {w_int}, {h_int}) [{coord_type}], "
282
+ f"img_size=({img_w}x{img_h}→{new_w}x{new_h}), "
283
+ f"placed_at=({x_final}, {y_final}) [canvas-relative]")
284
+ else:
285
+ # Print bbox info even if image doesn't exist
286
+ coord_type = "absolute" if is_root else "relative"
287
+ print(f" [Save] Node '{node_type}': bbox=({int(float(x))}, {int(float(y))}, {w_int}, {h_int}) [{coord_type}], "
288
+ f"image_path={image_path} (not found)")
289
+ else:
290
+ # Print bbox info for nodes without image_path (container nodes, text nodes, etc.)
291
+ coord_type = "absolute" if is_root else "relative"
292
+ print(f" [Save] Node '{node_type}': bbox=({int(float(x))}, {int(float(y))}, {w_int}, {h_int}) [{coord_type}], no image")
293
+
294
+ # Draw bounding box
295
+ _draw_bbox(canvas, x_abs, y_abs, w_int, h_int, node_type)
296
+
297
+ # Recursively process children
298
+ # Children's coordinates are relative to this container, so pass this container's absolute position as offset
299
+ # Children are not root nodes
300
+ children = node_result.get("children", [])
301
+ for child_result in children:
302
+ _composite_node_to_canvas(child_result, canvas, base_dir, offset_x=x_abs, offset_y=y_abs, is_root=False)
303
+
modules/infographics_generator/mask_utils.py ADDED
@@ -0,0 +1,622 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import random
3
+ import subprocess
4
+ import re
5
+ from PIL import Image
6
+ import numpy as np
7
+ from typing import Tuple
8
+ import tempfile
9
+ from bs4 import BeautifulSoup
10
+ import scipy.ndimage as ndimage
11
+ import time
12
+ import logging
13
+ import base64
14
+
15
+
16
+
17
+ # 设置日志
18
+ logging.basicConfig(level=logging.ERROR, format='%(asctime)s - %(levelname)s - %(message)s')
19
+ logger = logging.getLogger(__name__)
20
+
21
+ def validate_svg_file(file_path):
22
+ """验证SVG文件是否存在且内容有效"""
23
+ if not os.path.exists(file_path):
24
+ logger.error(f"SVG文件不存在: {file_path}")
25
+ return False
26
+
27
+ file_size = os.path.getsize(file_path)
28
+ if file_size == 0:
29
+ logger.error(f"SVG文件为空: {file_path}")
30
+ return False
31
+
32
+ return True
33
+
34
+
35
+ def extract_mask_from_base64(base64_str: str, width: int, height: int, background_color: str = "#FFFFFF") -> np.ndarray:
36
+ """
37
+ 从base64编码的图像中提取mask
38
+
39
+ Args:
40
+ base64_str: base64编码的图像字符串(可以带data:image/...;base64,前缀)
41
+ width: 目标宽度
42
+ height: 目标高度
43
+ background_color: 背景色,用于判断哪些区域是有内容的
44
+
45
+ Returns:
46
+ np.ndarray: 二值mask,1表示有内容,0表示背景
47
+ """
48
+ from PIL import Image
49
+ import io
50
+
51
+ if "base64," in base64_str:
52
+ base64_str = base64_str.split("base64,")[1]
53
+
54
+ img_data = base64.b64decode(base64_str)
55
+ img = Image.open(io.BytesIO(img_data))
56
+
57
+ if img.mode != 'RGBA':
58
+ img = img.convert('RGBA')
59
+
60
+ img = img.resize((int(width), int(height)), Image.Resampling.LANCZOS)
61
+ img_array = np.array(img)
62
+
63
+ if img_array.shape[2] == 4:
64
+ mask = (img_array[:, :, 3] > 0).astype(np.uint8)
65
+ else:
66
+ bg_rgb = tuple(int(background_color.lstrip('#')[i:i+2], 16) for i in (0, 2, 4))
67
+ diff = np.abs(img_array[:, :, :3].astype(int) - np.array(bg_rgb))
68
+ mask = (np.sum(diff, axis=2) > 30).astype(np.uint8)
69
+
70
+ return mask
71
+
72
+
73
+ def calculate_mask_v3(svg_content: str, width: int, height: int, background_color: str, grid_size: int = 5, max_difference = 15) -> np.ndarray:
74
+ """将SVG转换为基于背景色的二值化mask数组"""
75
+ width = int(width)
76
+ height = int(height)
77
+
78
+ # 将背景色转换为RGB格式
79
+ original_background_color = background_color
80
+ background_color = tuple(int(background_color[i:i+2], 16) for i in (1, 3, 5))
81
+
82
+ # 预处理SVG内容,删除背景元素和细线条
83
+
84
+ # 解析SVG内容
85
+ soup = BeautifulSoup(svg_content, 'xml')
86
+ # 删除class="background"的所有元素
87
+ background_elements = soup.select('[class="background"]')
88
+ for element in background_elements:
89
+ element.decompose()
90
+
91
+ # 删除stroke-width<=1或没有stroke-width的所有line元素
92
+ thin_lines = soup.find_all('line')
93
+ for line in thin_lines:
94
+ stroke_width = line.get('stroke-width')
95
+ if not stroke_width or float(stroke_width) <= 1:
96
+ line.decompose()
97
+
98
+ # 删除opacity<=0.1的所有元素
99
+ all_elements = soup.find_all()
100
+ for element in all_elements:
101
+ opacity = element.get('opacity')
102
+ if opacity and float(opacity) <= 0.1:
103
+ element.decompose()
104
+ # 删除所有text元素
105
+ text_elements = soup.find_all('text')
106
+ for text in text_elements:
107
+ text.decompose()
108
+
109
+ # 重新获取处理后的SVG内容
110
+ svg_content_without_text = str(soup)
111
+
112
+ # 创建临时文件
113
+ with tempfile.NamedTemporaryFile(suffix='.svg', delete=False) as mask_svg_file_without_text, \
114
+ tempfile.NamedTemporaryFile(suffix='.png', delete=False) as temp_mask_png_file_without_text:
115
+ mask_svg_without_text = mask_svg_file_without_text.name
116
+ temp_mask_png_without_text = temp_mask_png_file_without_text.name
117
+
118
+ # 修改SVG内容,移除渐变
119
+ # 将渐变填充替换为可见的纯色填充,而不是none
120
+ mask_svg_content = svg_content_without_text
121
+ # mask_svg_content = re.sub(r'fill="url\(#[^"]*\)"', 'fill="#333333"', mask_svg_content)
122
+ # mask_svg_content = re.sub(r'stroke="url\(#[^"]*\)"', 'stroke="#333333"', mask_svg_content)
123
+ mask_svg_content = mask_svg_content.replace('&', '&amp;')
124
+
125
+ # 提取SVG内容并添加新的SVG标签
126
+ svg_content_match = re.search(r'<svg[^>]*>(.*?)</svg>', mask_svg_content, re.DOTALL)
127
+ if svg_content_match:
128
+ inner_content = svg_content_match.group(1)
129
+ # 创建新的SVG标签
130
+ mask_svg_content = f'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="{width}" height="{height}"> \
131
+ <rect width="{width}" height="{height}" fill="{original_background_color}" /> \
132
+ {inner_content} \
133
+ </svg>'
134
+
135
+ mask_svg_file_without_text.write(mask_svg_content.encode('utf-8'))
136
+ mask_svg_file_without_text.flush()
137
+
138
+ # 验证SVG文件
139
+ if not validate_svg_file(mask_svg_without_text):
140
+ logger.error(f"无效的SVG文件: {mask_svg_without_text}")
141
+
142
+ retry_count = 0
143
+ max_retries = 3
144
+ while retry_count < max_retries:
145
+ try:
146
+ subprocess.run([
147
+ 'rsvg-convert',
148
+ '-f', 'png',
149
+ '-o', temp_mask_png_without_text,
150
+ '--dpi-x', '300',
151
+ '--dpi-y', '300',
152
+ '--background-color', f"{original_background_color}",
153
+ mask_svg_without_text
154
+ ], check=True)
155
+ break
156
+ except Exception as e:
157
+ retry_count += 1
158
+ logger.error(f"rsvg-convert执行失败 (尝试 {retry_count}/{max_retries}): {str(e)}")
159
+ if retry_count >= max_retries:
160
+ raise e
161
+ time.sleep(1)
162
+
163
+ img_without_text = Image.open(temp_mask_png_without_text).convert('RGB')
164
+ img_array_without_text = np.array(img_without_text)
165
+
166
+ # 确保图像尺寸匹配预期尺寸
167
+ actual_height, actual_width = img_array_without_text.shape[:2]
168
+ if actual_width != width or actual_height != height:
169
+ img_without_text = img_without_text.resize((width, height), Image.LANCZOS)
170
+ img_array_without_text = np.array(img_without_text)
171
+
172
+
173
+ # 解析SVG内容
174
+ soup = BeautifulSoup(svg_content, 'xml')
175
+ # 仅保留text、group和image元素
176
+ for element in soup.find_all():
177
+ if element.name not in ['text', 'g', 'svg', 'image']:
178
+ element.decompose()
179
+
180
+ svg_content_only_text = str(soup)
181
+
182
+ # 创建临时文件
183
+ with tempfile.NamedTemporaryFile(suffix='.svg', delete=False) as mask_svg_file_only_text, \
184
+ tempfile.NamedTemporaryFile(suffix='.png', delete=False) as temp_mask_png_file_only_text:
185
+ mask_svg_only_text = mask_svg_file_only_text.name
186
+ temp_mask_png_only_text = temp_mask_png_file_only_text.name
187
+ mask_svg_file_only_text.write(svg_content_only_text.encode('utf-8'))
188
+ mask_svg_file_only_text.flush()
189
+
190
+ # 验证SVG文件
191
+ if not validate_svg_file(mask_svg_only_text):
192
+ logger.error(f"无效的SVG文件: {mask_svg_only_text}")
193
+
194
+ retry_count = 0
195
+ max_retries = 3
196
+ while retry_count < max_retries:
197
+ try:
198
+ subprocess.run([
199
+ 'rsvg-convert',
200
+ '-f', 'png',
201
+ '-o', temp_mask_png_only_text,
202
+ '--dpi-x', '300',
203
+ '--dpi-y', '300',
204
+ '--background-color', f"{original_background_color}",
205
+ mask_svg_only_text
206
+ ], check=True)
207
+ break
208
+ except Exception as e:
209
+ retry_count += 1
210
+ logger.error(f"rsvg-convert执行失败 (尝试 {retry_count}/{max_retries}): {str(e)}")
211
+ if retry_count >= max_retries:
212
+ raise e
213
+ time.sleep(1)
214
+
215
+
216
+ img_only_text = Image.open(temp_mask_png_only_text).convert('RGB')
217
+ img_array_only_text = np.array(img_only_text)
218
+
219
+ # 确保图像尺寸匹配预期尺寸
220
+ actual_height, actual_width = img_array_only_text.shape[:2]
221
+ if actual_width != width or actual_height != height:
222
+ img_only_text = img_only_text.resize((width, height), Image.LANCZOS)
223
+ img_array_only_text = np.array(img_only_text)
224
+
225
+ # 转换为二值mask
226
+ mask = np.ones((height, width), dtype=np.uint8)
227
+ # 随机采样300个点
228
+ total_pixels = height * width
229
+ sample_indices = np.random.choice(total_pixels, min(1000, total_pixels), replace=False)
230
+ sample_pixels = img_array_without_text.reshape(-1, 3)[sample_indices]
231
+
232
+ # 排除接近背景色的像素
233
+ non_bg_pixels = sample_pixels[~np.all(np.abs(sample_pixels - background_color) <= 40, axis=1)]
234
+
235
+ if len(non_bg_pixels) == 0:
236
+ mode_color = np.array([0, 0, 0]) # 如果没有非背景色像素,返回黑色
237
+ else:
238
+ # 将像素转换为元组以便计数
239
+ pixels_tuple = [tuple(p) for p in non_bg_pixels]
240
+ # 直接用Counter找出最常见的颜色
241
+ from collections import Counter
242
+ mode_color = np.array(Counter(pixels_tuple).most_common(1)[0][0])
243
+ # 使用mode_color作为众数颜色创建mask
244
+ mask = np.zeros((height, width), dtype=np.uint8)
245
+ mask_only_text = np.zeros((height, width), dtype=np.uint8)
246
+
247
+ color_diff = np.sqrt(np.sum((img_array_without_text - mode_color) ** 2, axis=2))
248
+ mask[color_diff <= 2] = 1
249
+
250
+ # 计算与背景色的差异,使用更严格的阈值
251
+ color_diff_only_text = np.sqrt(np.sum((img_array_only_text - background_color) ** 2, axis=2))
252
+ mask_only_text[color_diff_only_text >= 15] = 1 # 提高阈值从10到15,要求与背景色差异更��
253
+
254
+ # 初始化填充mask
255
+ fill_mask = np.zeros((height, width), dtype=np.uint8)
256
+ fill_mask_only_text = np.zeros((height, width), dtype=np.uint8)
257
+ mask_padding = 3
258
+ for i in range(height):
259
+ last_j = -mask_padding
260
+ for j in range(width):
261
+ if mask[i, j] == 1:
262
+ if j - last_j < mask_padding:
263
+ fill_mask[i, last_j:j+1] = 1
264
+ else:
265
+ fill_mask[i, j] = 1
266
+ last_j = j
267
+
268
+ for j in range(width):
269
+ last_i = -mask_padding
270
+ for i in range(height):
271
+ if mask[i, j] == 1:
272
+ if i - last_i < mask_padding:
273
+ fill_mask[last_i:i+1, j] = 1
274
+ else:
275
+ fill_mask[i, j] = 1
276
+ last_i = i
277
+
278
+ for j in range(width):
279
+ last_i = -mask_padding
280
+ for i in range(height):
281
+ if mask_only_text[i, j] == 1:
282
+ if i - last_i < mask_padding:
283
+ fill_mask_only_text[last_i:i+1, j] = 1
284
+ else:
285
+ fill_mask_only_text[i, j] = 1
286
+ last_i = i
287
+
288
+ for i in range(height):
289
+ last_j = -mask_padding
290
+ for j in range(width):
291
+ if mask_only_text[i, j] == 1:
292
+ if j - last_j < mask_padding:
293
+ fill_mask_only_text[i, last_j:j+1] = 1
294
+ else:
295
+ fill_mask_only_text[i, j] = 1
296
+ last_j = j
297
+
298
+ mask = fill_mask
299
+ mask_only_text = fill_mask_only_text
300
+ os.remove(mask_svg_without_text)
301
+ os.remove(temp_mask_png_without_text)
302
+ os.remove(mask_svg_only_text)
303
+ os.remove(temp_mask_png_only_text)
304
+
305
+ return mask, mask_only_text
306
+
307
+
308
+
309
+ def calculate_mask_v2(svg_content: str, width: int, height: int, background_color: str, grid_size: int = 5, max_difference = 15, avoid_chart = False) -> np.ndarray:
310
+ """将SVG转换为基于背景色的二值化mask数组"""
311
+ width = int(width)
312
+ height = int(height)
313
+
314
+ # 将背景色转换为RGB格式
315
+ original_background_color = background_color
316
+ background_color = tuple(int(background_color[i:i+2], 16) for i in (1, 3, 5))
317
+
318
+ # 预处理SVG内容,删除背景元素和细线条
319
+
320
+ # 解析SVG内容
321
+ soup = BeautifulSoup(svg_content, 'xml')
322
+
323
+ background_elements = soup.select('[class="background"]')
324
+ for element in background_elements:
325
+ element.decompose()
326
+
327
+ # 删除stroke-width<=1或没有stroke-width的所有line元素
328
+ thin_lines = soup.find_all('line')
329
+ for line in thin_lines:
330
+ stroke_width = line.get('stroke-width')
331
+ if not stroke_width or float(stroke_width) <= 1:
332
+ line.decompose()
333
+
334
+ # 删除opacity<=0.1的所有元素
335
+ all_elements = soup.find_all()
336
+ for element in all_elements:
337
+ opacity = element.get('opacity')
338
+ if opacity and float(opacity) <= 0.1:
339
+ element.decompose()
340
+
341
+ # 重新获取处理后的SVG内容
342
+ svg_content = str(soup)
343
+
344
+ # 创建临时文件
345
+ with tempfile.NamedTemporaryFile(suffix='.svg', delete=False) as mask_svg_file, \
346
+ tempfile.NamedTemporaryFile(suffix='.png', delete=False) as temp_mask_png_file:
347
+ mask_svg = mask_svg_file.name
348
+ temp_mask_png = temp_mask_png_file.name
349
+
350
+ # 修改SVG内容,移除渐变
351
+ # 将渐变填充替换为可见的纯色填充,而不是none
352
+ mask_svg_content = re.sub(r'fill="url\(#[^"]*\)"', 'fill="#333333"', svg_content)
353
+ mask_svg_content = re.sub(r'stroke="url\(#[^"]*\)"', 'stroke="#333333"', mask_svg_content)
354
+ mask_svg_content = mask_svg_content.replace('&', '&amp;')
355
+
356
+ # 提取SVG内容并添加新的SVG标签
357
+ svg_content_match = re.search(r'<svg[^>]*>(.*?)</svg>', mask_svg_content, re.DOTALL)
358
+ if svg_content_match:
359
+ inner_content = svg_content_match.group(1)
360
+ # 创建新的SVG标签
361
+ mask_svg_content = f'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="{width}" height="{height}"> \
362
+ <rect width="{width}" height="{height}" fill="{original_background_color}" /> \
363
+ {inner_content} \
364
+ </svg>'
365
+
366
+ mask_svg_file.write(mask_svg_content.encode('utf-8'))
367
+ mask_svg_file.flush()
368
+
369
+ # 验证SVG文件
370
+ if not validate_svg_file(mask_svg):
371
+ logger.error(f"无效的SVG文件: {mask_svg}")
372
+
373
+ max_retries = 3
374
+ retry_count = 0
375
+ while retry_count < max_retries:
376
+ try:
377
+ subprocess.run([
378
+ 'rsvg-convert',
379
+ '-f', 'png',
380
+ '-o', temp_mask_png,
381
+ '--dpi-x', '300',
382
+ '--dpi-y', '300',
383
+ '--background-color', f"{original_background_color}",
384
+ mask_svg
385
+ ], check=True)
386
+ break
387
+ except Exception as e:
388
+ retry_count += 1
389
+ logger.error(f"rsvg-convert执行失败 (尝试 {retry_count}/{max_retries}): {str(e)}")
390
+ if retry_count >= max_retries:
391
+ raise Exception(f"重试{max_retries}次后仍然失败: {str(e)}")
392
+ time.sleep(1) # 等待1秒后重试
393
+
394
+ # 读取为numpy数组并处理
395
+ img = Image.open(temp_mask_png).convert('RGB')
396
+ img_array = np.array(img)
397
+
398
+ # 确保图像尺寸匹配预期尺寸
399
+ actual_height, actual_width = img_array.shape[:2]
400
+ if actual_width != width or actual_height != height:
401
+ img = img.resize((width, height), Image.LANCZOS)
402
+ img_array = np.array(img)
403
+
404
+ # 转换为二值mask
405
+ mask = np.ones((height, width), dtype=np.uint8)
406
+
407
+ for y in range(0, height, grid_size):
408
+ for x in range(0, width, grid_size):
409
+ y_end = min(y + grid_size, height)
410
+ x_end = min(x + grid_size, width)
411
+
412
+ if y_end > y and x_end > x:
413
+ grid = img_array[y:y_end, x:x_end]
414
+ if grid.size > 0:
415
+ # 计算与背景色的差异
416
+ background_diff = np.sqrt(np.sum((grid - background_color) ** 2, axis=2))
417
+ white_ratio = np.mean(background_diff < max_difference)
418
+ mask[y:y_end, x:x_end] = 0 if white_ratio > 0.95 else 1
419
+ if avoid_chart:
420
+ # 如果avoid_chart为True,则找到mask中的1的x_min,x_max,y_min,y_max
421
+ y_min, x_min, y_max, x_max = calculate_bbox(mask)
422
+ print("x_min, x_max, y_min, y_max: ", x_min, x_max, y_min, y_max)
423
+ # 将mask中的x_min,x_max,y_min,y_max之间的区域填充为1
424
+ mask[y_min:y_max+1, x_min:x_max+1] = 1
425
+
426
+ # 删除临时文件
427
+ os.remove(mask_svg)
428
+ os.remove(temp_mask_png)
429
+
430
+ return mask
431
+
432
+
433
+ def calculate_mask(svg_content: str, width: int, height: int, padding: int, grid_size: int = 5, bg_threshold: float = 220) -> np.ndarray:
434
+ """将SVG转换为二值化的mask数组"""
435
+ width = int(width)
436
+ height = int(height)
437
+
438
+ # 创建临时文件
439
+ tmp_dir = "./tmp"
440
+ os.makedirs(tmp_dir, exist_ok=True)
441
+ mask_svg = os.path.join(tmp_dir, f"temp_mask_{random.randint(0, 999999)}.svg")
442
+ temp_mask_png = os.path.join(tmp_dir, f"temp_mask_{random.randint(0, 999999)}.png")
443
+
444
+ try:
445
+ # 修改SVG内容,移除渐变
446
+ # 将渐变填充替换为可见的纯色填充,而不是none
447
+ mask_svg_content = re.sub(r'fill="url\(#[^"]*\)"', 'fill="#333333"', svg_content)
448
+ mask_svg_content = re.sub(r'stroke="url\(#[^"]*\)"', 'stroke="#333333"', mask_svg_content)
449
+ mask_svg_content = mask_svg_content.replace('&', '&amp;')
450
+
451
+ # 提取SVG内容并添加新的SVG标签
452
+ svg_content_match = re.search(r'<svg[^>]*>(.*?)</svg>', mask_svg_content, re.DOTALL)
453
+ if svg_content_match:
454
+ inner_content = svg_content_match.group(1)
455
+ # 创建新的SVG标签
456
+ mask_svg_content = f'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="{width}" height="{height}">{inner_content}</svg>'
457
+
458
+ # 添加padding
459
+ if padding > 0:
460
+ svg_tag_match = re.search(r'<svg[^>]*>', mask_svg_content)
461
+ if svg_tag_match:
462
+ svg_tag = svg_tag_match.group(0)
463
+ svg_tag_end = svg_tag_match.end()
464
+ svg_content_part = mask_svg_content[svg_tag_end:]
465
+ svg_end_tag = '</svg>'
466
+ svg_content_without_end = svg_content_part.replace(svg_end_tag, '')
467
+
468
+ # 添加transform group
469
+ mask_svg_content = svg_tag + f'<g transform="translate({padding}, {padding})">' + svg_content_without_end + '</g>' + svg_end_tag
470
+
471
+ with open(mask_svg, "w", encoding="utf-8") as f:
472
+ f.write(mask_svg_content)
473
+
474
+ # 验证SVG文件
475
+ if not validate_svg_file(mask_svg):
476
+ logger.error(f"无效的SVG文件: {mask_svg}")
477
+
478
+ retry_count = 0
479
+ max_retries = 3
480
+ while retry_count < max_retries:
481
+ try:
482
+ subprocess.run([
483
+ 'rsvg-convert',
484
+ '-f', 'png',
485
+ '-o', temp_mask_png,
486
+ '--dpi-x', '300',
487
+ '--dpi-y', '300',
488
+ '--background-color', "#ffffff",
489
+ mask_svg
490
+ ], check=True)
491
+ break
492
+ except Exception as e:
493
+ retry_count += 1
494
+ logger.error(f"rsvg-convert执行失败 (尝试 {retry_count}/{max_retries}): {str(e)}")
495
+ if retry_count >= max_retries:
496
+ raise e
497
+ time.sleep(1)
498
+
499
+ # 读取为numpy数组并处理
500
+ img = Image.open(temp_mask_png).convert('RGB')
501
+ img_array = np.array(img)
502
+
503
+ # 确保图像尺寸匹配预期尺寸
504
+ actual_height, actual_width = img_array.shape[:2]
505
+ if actual_width != width or actual_height != height:
506
+ img = img.resize((width, height), Image.LANCZOS)
507
+ img_array = np.array(img)
508
+
509
+ # 转换为二值mask
510
+ mask = np.ones((height, width), dtype=np.uint8)
511
+
512
+ for y in range(0, height, grid_size):
513
+ for x in range(0, width, grid_size):
514
+ y_end = min(y + grid_size, height)
515
+ x_end = min(x + grid_size, width)
516
+
517
+ if y_end > y and x_end > x:
518
+ grid = img_array[y:y_end, x:x_end]
519
+ if grid.size > 0:
520
+ white_pixels = np.all(grid >= bg_threshold, axis=2)
521
+ white_ratio = np.mean(white_pixels)
522
+ mask[y:y_end, x:x_end] = 0 if white_ratio > 0.95 else 1
523
+
524
+ return mask
525
+
526
+ finally:
527
+ if os.path.exists(mask_svg):
528
+ os.remove(mask_svg)
529
+ if os.path.exists(temp_mask_png):
530
+ os.remove(temp_mask_png)
531
+
532
+ def calculate_bbox(mask: np.ndarray) -> Tuple[int, int, int, int]:
533
+ """计算mask的bbox"""
534
+ rows = np.sum(mask == 1, axis=1) > 0
535
+ cols = np.sum(mask == 1, axis=0) > 0
536
+ row_indices = np.where(rows)[0]
537
+ col_indices = np.where(cols)[0]
538
+ return row_indices[0], col_indices[0], row_indices[-1], col_indices[-1]
539
+
540
+ def calculate_content_width(mask: np.ndarray, padding: int = 0) -> Tuple[int, int, int]:
541
+ """计算mask中内容的实际宽度范围"""
542
+ content_columns = np.sum(mask == 1, axis=0) > 0 # 任何非零值表示该列有内容
543
+ content_indices = np.where(content_columns)[0]
544
+
545
+ return content_indices[0] - padding, content_indices[-1] - padding, content_indices[-1] - content_indices[0] + 1
546
+
547
+ def calculate_content_height(mask: np.ndarray, padding: int = 0) -> Tuple[int, int, int]:
548
+ """计算mask中内容的实际高度范围"""
549
+ # mask中1表示内容,0表示背景
550
+ content_rows = np.sum(mask == 1, axis=1) > 0 # 任何非零值表示该行有内容
551
+ content_indices = np.where(content_rows)[0]
552
+
553
+ if len(content_indices) == 0:
554
+ return 0, 0, 0
555
+
556
+ start_y = content_indices[0]
557
+ end_y = content_indices[-1]
558
+ height = end_y - start_y + 1
559
+
560
+ return start_y - padding, end_y - padding, height
561
+
562
+ def fill_columns_between_bounds(mask: np.ndarray, x_min: int, x_max: int, y_min: int, y_max: int) -> np.ndarray:
563
+ """
564
+ 扫描子矩形区域内每一列的第一个1和最后一个1,将两者之间的区域填充为1
565
+
566
+ Args:
567
+ mask: 输入的mask数组
568
+ x_min: 子矩形区域的最小x坐标
569
+ x_max: 子矩形区域的最大x坐标
570
+ y_min: 子矩形区域的最小y坐标
571
+ y_max: 子矩形区域的最大y坐标
572
+
573
+ Returns:
574
+ np.ndarray: 处理后的mask数组
575
+ """
576
+ # 确保坐标在有效范围内
577
+ height, width = mask.shape
578
+ x_min = max(0, min(x_min, width - 1))
579
+ x_max = max(0, min(x_max, width - 1))
580
+ y_min = max(0, min(y_min, height - 1))
581
+ y_max = max(0, min(y_max, height - 1))
582
+
583
+ # 创建新的mask副本
584
+ new_mask = mask.copy()
585
+
586
+ # 对每一列进行处理
587
+ for x in range(x_min, x_max + 1):
588
+ # 直接获取该列在指定范围内的切片
589
+ col_slice = new_mask[y_min:y_max+1, x]
590
+
591
+ if np.any(col_slice == 1):
592
+ # 找出该列中1的位置
593
+ content_indices = np.where(col_slice == 1)[0]
594
+
595
+ if len(content_indices) > 0:
596
+ # 填充该列从第一个1到最后一个1之间的所有位置
597
+ col_slice[content_indices[0]:content_indices[-1]+1] = 1
598
+ # 将修改后的切片放回原数组
599
+ new_mask[y_min:y_max+1, x] = col_slice
600
+
601
+ return new_mask
602
+
603
+ def expand_mask(mask: np.ndarray, dist: int) -> np.ndarray:
604
+ """
605
+ 扩展现有掩码,将任何与现有掩码距离小于dist的像素设为1。
606
+
607
+ Args:
608
+ mask: 输入的掩码数组,其中1表示内容,0表示背景
609
+ dist: 距离阈值(像素)
610
+
611
+ Returns:
612
+ np.ndarray: 扩展后的掩码数组
613
+ """
614
+ # 使用距离变换计算每个背景像素到最近的内容像素的距离
615
+ # 首先反转掩码,因为距离变换计算到0的距离
616
+ inv_mask = 1 - mask
617
+ # 计算距离图
618
+ dist_map = ndimage.distance_transform_edt(inv_mask)
619
+ # 创建新的掩码,将距离小于dist的像素设为1
620
+ expanded_mask = np.where(dist_map < dist, 1, mask)
621
+
622
+ return expanded_mask
modules/infographics_generator/parse_utils.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ from selenium import webdriver
3
+ from selenium.webdriver.common.by import By
4
+ from bs4 import BeautifulSoup, NavigableString
5
+ from modules.infographics_generator.chat_utils import safe_save_json, load_txt
6
+
7
+ def parse_element_with_style_and_bbox(driver, web_element):
8
+ computed_style = driver.execute_script("""
9
+ const elem = arguments[0];
10
+ const styles = window.getComputedStyle(elem);
11
+ const style_dict = {};
12
+ for (let i = 0; i < styles.length; i++) {
13
+ const prop = styles[i];
14
+ style_dict[prop] = styles.getPropertyValue(prop);
15
+ }
16
+ return style_dict;
17
+ """, web_element)
18
+
19
+ # only keep color-related attributes in style
20
+ color_attributes = ['fill', 'stroke', 'opacity', 'fill-opacity', 'stroke-opacity', 'stroke-width']
21
+ computed_style = {k: v for k, v in computed_style.items() if k in color_attributes}
22
+
23
+ bbox = driver.execute_script("""
24
+ const elem = arguments[0];
25
+ try {
26
+ const box = elem.getBoundingClientRect();
27
+ return {x: box.x, y: box.y, width: box.width, height: box.height};
28
+ } catch (e) {
29
+ return null;
30
+ }
31
+ """, web_element)
32
+
33
+ svg_bbox = driver.execute_script("""
34
+ const elem = arguments[0];
35
+ try {
36
+ const box = elem.getBBox();
37
+ return {x: box.x, y: box.y, width: box.width, height: box.height};
38
+ } catch (e) {
39
+ return null;
40
+ }
41
+ """, web_element)
42
+
43
+ return computed_style, bbox, svg_bbox
44
+
45
+ def parse_svg_tree(driver, bs_element: BeautifulSoup, selenium_element):
46
+ tag_name = bs_element.name
47
+ assert not isinstance(bs_element, NavigableString), "bs_element should not be NavigableString"
48
+
49
+ attributes = dict(bs_element.attrs)
50
+ computed_style, bbox, svg_bbox = None, None, None
51
+ if selenium_element:
52
+ computed_style, bbox, svg_bbox = parse_element_with_style_and_bbox(driver, selenium_element)
53
+
54
+ node_info = {
55
+ "tag": tag_name,
56
+ "attributes": attributes,
57
+ "computed_style": computed_style,
58
+ "bounding_box": bbox,
59
+ "svg_bounding_box": svg_bbox,
60
+ "html": str(bs_element),
61
+ "children": []
62
+ }
63
+
64
+ if len(bs_element.find_all(recursive=False)) > 5000:
65
+ print(f"--- {bs_element.name} has too many children: {len(bs_element.find_all(recursive=False))}")
66
+ return node_info
67
+
68
+ for child in bs_element.find_all(recursive=False):
69
+ siblings = child.find_previous_siblings(child.name)
70
+ index = len(siblings) + 1
71
+ child_sele = selenium_element.find_element(By.XPATH, f'./*[local-name()="{child.name}"][{index}]')
72
+ child_info = parse_svg_tree(driver, child, child_sele)
73
+ if child_info:
74
+ node_info["children"].append(child_info)
75
+ if not node_info["children"]:
76
+ # judge if has text
77
+ text_content = bs_element.text.strip()
78
+ if text_content:
79
+ node_info["text"] = text_content
80
+
81
+
82
+ return node_info
83
+
84
+ def parse_tree_from_html(driver: webdriver.Chrome, html_path: str, save_svg=True):
85
+ driver.get(f'file://{html_path}')
86
+
87
+ time.sleep(0.2)
88
+
89
+ svg_element = driver.find_element("css selector", "svg")
90
+ svg_content = svg_element.get_attribute('outerHTML')
91
+ if save_svg:
92
+ svg_file_path = html_path.replace('.html', '_extracted.svg')
93
+ with open(svg_file_path, 'w', encoding='utf-8') as f:
94
+ f.write(svg_content)
95
+
96
+ soup = BeautifulSoup(svg_content, "xml")
97
+ svg_root = soup.find('svg')
98
+
99
+ tree_data = parse_svg_tree(driver, svg_root, svg_element)
100
+ safe_save_json(tree_data, html_path.replace('.html', '.json'))
101
+ return tree_data
102
+
103
+ def convert_svg_to_html(svg_path: str, html_path: str):
104
+ svg_content = load_txt(svg_path)
105
+ html = f"""<!DOCTYPE html>
106
+ <html lang="en">
107
+ <head>
108
+ <meta charset="UTF-8" />
109
+ <title>Infographic Chart</title>
110
+ <style>
111
+ html, body {{
112
+ margin: 0;
113
+ padding: 0;
114
+ background: #ffffff;
115
+ }}
116
+ svg {{
117
+ display: block;
118
+ }}
119
+ </style>
120
+ </head>
121
+ <body>
122
+ <div id="chart-container">
123
+ {svg_content}
124
+ </div>
125
+ </body>
126
+ </html>
127
+ """
128
+ with open(html_path, 'w', encoding='utf-8') as f:
129
+ f.write(html)
130
+ return html
131
+
132
+ def convert_g_to_html(g_str, gw, gh, html_path: str):
133
+ html = f"""<!DOCTYPE html>
134
+ <html lang="en">
135
+ <head>
136
+ <meta charset="UTF-8" />
137
+ <title>Infographic Chart</title>
138
+ </head>
139
+ <body>
140
+ <div id="chart-container">
141
+ <svg width="{gw}" height="{gh}">
142
+ <g transform="translate({gw / 2}, {gh / 2})">
143
+ {g_str}
144
+ </g>
145
+ </svg>
146
+ </div>
147
+ </body>
148
+ </html>
149
+ """
150
+ with open(html_path, 'w', encoding='utf-8') as f:
151
+ f.write(html)
152
+ return html
modules/infographics_generator/screenshot_utils.py ADDED
@@ -0,0 +1,299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from selenium import webdriver
2
+ from selenium.webdriver.chrome.options import Options
3
+ from selenium.common.exceptions import WebDriverException, NoSuchElementException
4
+ from selenium.webdriver.chrome.service import Service as ChromeService
5
+ from selenium.webdriver.support.ui import WebDriverWait
6
+ from selenium.webdriver.support import expected_conditions as EC
7
+ from selenium.webdriver.common.by import By
8
+ import time
9
+ import tempfile
10
+ import random
11
+ import os
12
+ import sys
13
+ import warnings
14
+ warnings.filterwarnings('ignore')
15
+ _stderr = sys.stderr
16
+ sys.stderr = open(os.devnull, 'w')
17
+ from PIL import Image
18
+ sys.stderr = _stderr
19
+
20
+ try:
21
+ from config import RENDER_LONGEST_SIDE
22
+ except Exception:
23
+ RENDER_LONGEST_SIDE = 3860
24
+
25
+
26
+ def get_driver(max_retries=1, delay=0):
27
+ """启动稳定的 headless Chrome,支持 Linux headless 环境。"""
28
+ for attempt in range(1, max_retries + 1):
29
+ try:
30
+ options = webdriver.ChromeOptions()
31
+ options.add_argument("--headless=new")
32
+ options.add_argument("--no-sandbox")
33
+ options.add_argument("--disable-software-rasterizer")
34
+ options.add_argument("--disable-gpu")
35
+ options.add_argument("--disable-extensions")
36
+ options.add_argument("--disable-background-networking")
37
+ options.add_argument("--disable-default-apps")
38
+ options.add_argument("--disable-sync")
39
+ options.add_argument("--disable-translate")
40
+ options.add_argument("--metrics-recording-only")
41
+ options.add_argument("--mute-audio")
42
+ options.add_argument("--hide-scrollbars")
43
+
44
+ base_tmp = os.environ.get("CHROMIUM_TMP", "/dev/shm/chartpipeline_chromium" if os.path.isdir("/dev/shm") else os.path.join(tempfile.gettempdir(), "chartpipeline_chromium"))
45
+ cache_dir = os.path.join(base_tmp, "cache")
46
+ crash_dir = os.path.join(base_tmp, "crash")
47
+ media_cache_dir = os.path.join(base_tmp, "media_cache")
48
+ os.makedirs(cache_dir, exist_ok=True)
49
+ os.makedirs(crash_dir, exist_ok=True)
50
+ os.makedirs(media_cache_dir, exist_ok=True)
51
+
52
+ options.add_argument(f"--disk-cache-dir={cache_dir}")
53
+ options.add_argument(f"--media-cache-dir={media_cache_dir}")
54
+ options.add_argument(f"--crash-dumps-dir={crash_dir}")
55
+
56
+ service = ChromeService(
57
+ log_output=open(f"chromedriver_attempt{attempt}.log", "w")
58
+ )
59
+ options.add_argument("--enable-logging")
60
+ options.add_argument("--v=1")
61
+ options.add_argument("--log-file=chrome.log")
62
+
63
+ unique_tmpdir = tempfile.mkdtemp(prefix="chrome_")
64
+ options.add_argument(f"--user-data-dir={unique_tmpdir}")
65
+
66
+ driver = webdriver.Chrome(options=options, service=service)
67
+ print(f">>> ChromeDriver started successfully on attempt {attempt}")
68
+ return driver
69
+ except WebDriverException as e:
70
+ if attempt < max_retries:
71
+ print(f"[Retry {attempt}/{max_retries}] Chrome 启动失败: {e}, {delay}s 后重试...")
72
+ time.sleep(delay)
73
+ else:
74
+ raise
75
+
76
+
77
+ # JS run inside the headless Chrome to mitigate text-overlap artifacts before
78
+ # rasterizing. We never know in advance which chart template will produce
79
+ # overlapping labels (outer-ring labels on circular charts, dense axis ticks,
80
+ # absolute-positioned annotations, ...), so we work directly on the live DOM:
81
+ # * collect every visible non-empty <text>
82
+ # * find pairs whose client rects overlap by more than MIN_OVERLAP_FRAC
83
+ # * shrink the font-size of every text in a conflict by SHRINK each round
84
+ # Repeat until no overlaps remain, every conflicting text has hit MIN_FONT_PX,
85
+ # or we exhaust MAX_ITER. Pure font-size tweaks keep each <text> anchored at
86
+ # the same x/y so SVG layout stays valid; we then serialize the patched DOM
87
+ # back so the caller can persist the post-processed SVG alongside the PNG.
88
+ _RESOLVE_TEXT_OVERLAP_JS = r"""
89
+ return (function() {
90
+ const MIN_FONT_PX = 6.0;
91
+ const SHRINK = 0.9;
92
+ const MAX_ITER = 12;
93
+ const MAX_TEXTS = 600;
94
+ // Require this much breathing room (in CSS px) between any two text bboxes.
95
+ // We inflate each rect by MARGIN_PX/2 on every side before testing for
96
+ // overlap, so a "real" 0-px touch counts as a conflict and the algorithm
97
+ // keeps shrinking until at least MARGIN_PX of whitespace exists between
98
+ // neighbouring labels (or font hits MIN_FONT_PX).
99
+ const MARGIN_PX = 3.0;
100
+ // Even with margin enforced, ignore micro-overlaps below this many
101
+ // squared pixels so anti-alias seams or sub-pixel rounding don't trigger
102
+ // an infinite shrink loop.
103
+ const MIN_OVERLAP_AREA_PX2 = 1.0;
104
+
105
+ function inflate(r, m) {
106
+ return {
107
+ left: r.left - m,
108
+ right: r.right + m,
109
+ top: r.top - m,
110
+ bottom: r.bottom + m,
111
+ width: r.width + 2 * m,
112
+ height: r.height + 2 * m,
113
+ };
114
+ }
115
+
116
+ function rectsOverlap(a, b) {
117
+ const ai = inflate(a, MARGIN_PX / 2);
118
+ const bi = inflate(b, MARGIN_PX / 2);
119
+ const ix = Math.max(0, Math.min(ai.right, bi.right) - Math.max(ai.left, bi.left));
120
+ const iy = Math.max(0, Math.min(ai.bottom, bi.bottom) - Math.max(ai.top, bi.top));
121
+ if (ix <= 0 || iy <= 0) return 0;
122
+ const inter = ix * iy;
123
+ if (inter < MIN_OVERLAP_AREA_PX2) return 0;
124
+ return inter;
125
+ }
126
+
127
+ function visibleNonEmptyTexts() {
128
+ const all = document.querySelectorAll('text');
129
+ const out = [];
130
+ for (const t of all) {
131
+ const cs = window.getComputedStyle(t);
132
+ if (cs.display === 'none' || cs.visibility === 'hidden') continue;
133
+ if (!t.textContent || !t.textContent.trim()) continue;
134
+ const r = t.getBoundingClientRect();
135
+ if (r.width <= 0 || r.height <= 0) continue;
136
+ out.push(t);
137
+ if (out.length >= MAX_TEXTS) break;
138
+ }
139
+ return out;
140
+ }
141
+
142
+ function fs(el) {
143
+ const cs = window.getComputedStyle(el);
144
+ return parseFloat(cs.fontSize) || 12;
145
+ }
146
+
147
+ let conflictsAtStart = 0;
148
+ let totalShrunk = 0;
149
+ let iters = 0;
150
+ let conflictsAtEnd = 0;
151
+
152
+ for (let it = 0; it < MAX_ITER; it++) {
153
+ iters = it + 1;
154
+ const texts = visibleNonEmptyTexts();
155
+ if (texts.length === 0) break;
156
+
157
+ const rects = texts.map(t => t.getBoundingClientRect());
158
+ const conflict = new Set();
159
+ for (let i = 0; i < texts.length; i++) {
160
+ for (let j = i + 1; j < texts.length; j++) {
161
+ if (rectsOverlap(rects[i], rects[j]) > 0) {
162
+ conflict.add(i);
163
+ conflict.add(j);
164
+ }
165
+ }
166
+ }
167
+ if (it === 0) conflictsAtStart = conflict.size;
168
+ conflictsAtEnd = conflict.size;
169
+ if (conflict.size === 0) break;
170
+
171
+ let shrunkThisIter = 0;
172
+ for (const i of conflict) {
173
+ const cur = fs(texts[i]);
174
+ if (cur > MIN_FONT_PX + 0.01) {
175
+ const nv = Math.max(MIN_FONT_PX, cur * SHRINK);
176
+ texts[i].style.fontSize = nv.toFixed(2) + 'px';
177
+ shrunkThisIter++;
178
+ }
179
+ }
180
+ totalShrunk += shrunkThisIter;
181
+ if (shrunkThisIter === 0) break;
182
+ }
183
+
184
+ const svgEl = document.querySelector('svg');
185
+ const finalSvg = svgEl ? new XMLSerializer().serializeToString(svgEl) : '';
186
+ return {
187
+ conflictsAtStart: conflictsAtStart,
188
+ conflictsAtEnd: conflictsAtEnd,
189
+ totalShrunk: totalShrunk,
190
+ iters: iters,
191
+ svg: finalSvg,
192
+ };
193
+ })();
194
+ """
195
+
196
+
197
+ def take_screenshot(
198
+ driver: webdriver.Chrome,
199
+ html_path: str,
200
+ longest_side: int = None,
201
+ resolve_text_overlap: bool = False,
202
+ ):
203
+ """
204
+ Use headless Chrome to rasterize the chart at the requested longest side,
205
+ using CDP to set a high device pixel ratio so output matches browser rendering
206
+ (font weight, kerning, fallbacks).
207
+
208
+ When ``resolve_text_overlap`` is true, we run an in-browser pass that
209
+ iteratively shrinks the font-size of any visible <text> elements whose
210
+ bounding boxes overlap, then capture the screenshot of the patched DOM.
211
+ The post-processed SVG (with inline ``style="font-size:..."`` on each
212
+ shrunk text) is returned so the caller can persist it alongside the PNG.
213
+
214
+ Returns
215
+ -------
216
+ dict | None
217
+ ``{"conflictsAtStart", "conflictsAtEnd", "totalShrunk", "iters", "svg"}``
218
+ when text-overlap resolution ran (regardless of whether anything was
219
+ shrunk), or ``None`` if it was disabled.
220
+ """
221
+ if longest_side is None:
222
+ longest_side = int(os.environ.get("RENDER_LONGEST_SIDE", RENDER_LONGEST_SIDE))
223
+
224
+ abs_path = os.path.abspath(html_path)
225
+ out_png = abs_path.replace('.html', '.png')
226
+
227
+ driver.get(f'file://{abs_path}')
228
+ wait = WebDriverWait(driver, 10)
229
+ svg = wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, "svg")))
230
+
231
+ overlap_result = None
232
+ if resolve_text_overlap:
233
+ try:
234
+ overlap_result = driver.execute_script(_RESOLVE_TEXT_OVERLAP_JS)
235
+ except Exception as _e:
236
+ import traceback as _tb
237
+ print(f"[overlap-fix] JS failed: {_e}\n{_tb.format_exc()}", flush=True)
238
+ overlap_result = None
239
+
240
+ svg_width = float(driver.execute_script("return arguments[0].getBoundingClientRect().width;", svg))
241
+ svg_height = float(driver.execute_script("return arguments[0].getBoundingClientRect().height;", svg))
242
+ if svg_width <= 0 or svg_height <= 0:
243
+ raise RuntimeError("Failed to measure SVG size")
244
+
245
+ # Pick DPR so the long edge in physical pixels equals longest_side.
246
+ css_long = max(svg_width, svg_height)
247
+ dpr = max(1.0, longest_side / css_long)
248
+ # Cap DPR so chrome doesn't try to allocate huge buffers; we'll LANCZOS-upscale
249
+ # the rest if needed.
250
+ MAX_DPR = 4.0
251
+ dpr = min(dpr, MAX_DPR)
252
+
253
+ css_w = int(round(svg_width)) + 2
254
+ css_h = int(round(svg_height)) + 2
255
+
256
+ try:
257
+ driver.execute_cdp_cmd("Emulation.setDeviceMetricsOverride", {
258
+ "width": css_w,
259
+ "height": css_h,
260
+ "deviceScaleFactor": dpr,
261
+ "mobile": False,
262
+ })
263
+ except Exception:
264
+ pass
265
+
266
+ with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as temp_file:
267
+ temp_path = temp_file.name
268
+ try:
269
+ location = svg.location
270
+ size = svg.size
271
+ driver.save_screenshot(temp_path)
272
+
273
+ image = Image.open(temp_path)
274
+ # Coordinates are in CSS pixels; multiply by DPR to crop the actual image.
275
+ left = max(0, int(round(location['x'] * dpr)))
276
+ top = max(0, int(round(location['y'] * dpr)))
277
+ right = min(image.width, int(round((location['x'] + size['width']) * dpr)))
278
+ bottom = min(image.height, int(round((location['y'] + size['height']) * dpr)))
279
+ cropped = image.crop((left, top, right, bottom))
280
+
281
+ cw, ch = cropped.size
282
+ if cw > 0 and ch > 0 and max(cw, ch) != longest_side:
283
+ scale = longest_side / max(cw, ch)
284
+ cropped = cropped.resize(
285
+ (max(1, int(round(cw * scale))), max(1, int(round(ch * scale)))),
286
+ Image.LANCZOS,
287
+ )
288
+ cropped.save(out_png)
289
+ finally:
290
+ try:
291
+ os.unlink(temp_path)
292
+ except OSError:
293
+ pass
294
+ try:
295
+ driver.execute_cdp_cmd("Emulation.clearDeviceMetricsOverride", {})
296
+ except Exception:
297
+ pass
298
+
299
+ return overlap_result
modules/infographics_generator/svg_utils.py ADDED
@@ -0,0 +1,544 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional
2
+ from lxml import etree
3
+ import xml.etree.ElementTree as ET
4
+ from svgpathtools import parse_path
5
+ import re
6
+ import numpy as np
7
+ from PIL import Image
8
+ import subprocess
9
+ import tempfile
10
+ import os
11
+ import re
12
+ import colorsys
13
+
14
+ def add_gradient_to_rect(rect_svg):
15
+ """
16
+ 将普通填充的矩形SVG转换为带有渐变效果的矩形
17
+
18
+ 参数:
19
+ rect_svg (str): 普通填充的矩形SVG,例如 <rect x="0" y="0" width="781" height="707" fill="#E2F1F6" />
20
+
21
+ 返回:
22
+ str: 带有渐变效果的矩形SVG
23
+ """
24
+ # 提取矩形属性
25
+ x_match = re.search(r'x="([^"]*)"', rect_svg)
26
+ y_match = re.search(r'y="([^"]*)"', rect_svg)
27
+ width_match = re.search(r'width="([^"]*)"', rect_svg)
28
+ height_match = re.search(r'height="([^"]*)"', rect_svg)
29
+ fill_match = re.search(r'fill="([^"]*)"', rect_svg)
30
+
31
+ # 默认值
32
+ x = x_match.group(1) if x_match else "0"
33
+ y = y_match.group(1) if y_match else "0"
34
+ width = width_match.group(1) if width_match else "100"
35
+ height = height_match.group(1) if height_match else "100"
36
+ fill = fill_match.group(1) if fill_match else "#000000"
37
+
38
+ # 创建渐变ID
39
+ gradient_id = f"gradient_{hash(rect_svg) % 10000}"
40
+
41
+ # 计算渐变的第二个颜色(稍微暗一点或者亮一点)
42
+ # 移除#前缀并解析十六进制颜色
43
+ hex_color = fill.lstrip('#')
44
+ # 转换十六进制为RGB
45
+ r, g, b = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
46
+ # 转换RGB为HSL
47
+ h, l, s = colorsys.rgb_to_hls(r/255, g/255, b/255)
48
+
49
+ # 创建稍微暗一点的颜色
50
+ l_darker = max(0, l - 0.1) # 降低亮度,但不低于0
51
+ r_darker, g_darker, b_darker = colorsys.hls_to_rgb(h, l_darker, s)
52
+
53
+ # 转换回十六进制
54
+ end_color = "#{:02x}{:02x}{:02x}".format(
55
+ int(r_darker * 255),
56
+ int(g_darker * 255),
57
+ int(b_darker * 255)
58
+ )
59
+
60
+ # 创建带有渐变的SVG
61
+ gradient_svg = f"""<defs>
62
+ <!-- 背景渐变 -->
63
+ <linearGradient id="{gradient_id}" x1="0%" y1="0%" x2="0%" y2="100%">
64
+ <stop offset="0%" stop-color="{fill}" />
65
+ <stop offset="100%" stop-color="{end_color}" />
66
+ </linearGradient>
67
+ </defs>
68
+
69
+ <!-- 背景矩形 -->
70
+ <rect x="{x}" y="{y}" width="{width}" height="{height}" fill="url(#{gradient_id})" />"""
71
+
72
+ return gradient_svg
73
+
74
+ def extract_svg_content(svg_content: str) -> Optional[str]:
75
+ """从SVG内容中提取内部元素"""
76
+ # try:
77
+ svg_tree = etree.fromstring(svg_content.encode())
78
+ # 获取所有子元素
79
+ children = svg_tree.getchildren()
80
+ if not children:
81
+ return None
82
+
83
+ # 将子元素转换为字符串
84
+ content = ""
85
+ for child in children:
86
+ content += etree.tostring(child, encoding='unicode')
87
+ return content
88
+ # except Exception as e:
89
+ # return None
90
+
91
+ def remove_large_rects(svg_content: str) -> str:
92
+ """移除SVG中的大型矩形元素"""
93
+ try:
94
+ svg_tree = etree.fromstring(svg_content.encode())
95
+ for rect in svg_tree.xpath("//rect"):
96
+ width = float(rect.get("width", 0))
97
+ height = float(rect.get("height", 0))
98
+ if width * height > 500 * 500:
99
+ rect.getparent().remove(rect)
100
+ return etree.tostring(svg_tree, encoding='unicode')
101
+ except Exception as e:
102
+ return svg_content
103
+
104
+ def extract_large_rect(svg_content: str) -> tuple[str, str]:
105
+ """
106
+ 提取SVG中的大型背景矩形或图像元素,并从原SVG中删除它。
107
+
108
+ 条件:
109
+ 1. 元素是rect或image,且class="background"
110
+ 2. width*height > 500*500
111
+ 3. 没有设置opacity属性或opacity > 0.5
112
+
113
+ 参数:
114
+ svg_content (str): 原始SVG内容
115
+
116
+ 返回:
117
+ tuple[str, str]: (修改后的SVG内容, 提取的背景元素内容)
118
+ 如果没有找到符合条件的元素,则返回(原始SVG, '')
119
+ """
120
+ try:
121
+ # 确保SVG内容被正确的<svg>标签包围
122
+ if not svg_content.strip().startswith('<svg'):
123
+ svg_content = f'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">{svg_content}</svg>'
124
+
125
+ svg_tree = etree.fromstring(svg_content.encode())
126
+
127
+ # 寻找class为background的rect元素
128
+ background_elements = svg_tree.xpath("//*[local-name()='rect' and @class='background']|"
129
+ "//*[local-name()='image' and @class='background']")
130
+ # print("background_elements", background_elements)
131
+
132
+ for element in background_elements:
133
+ # 获取宽度和高度
134
+ width = float(element.get("width", 0))
135
+ height = float(element.get("height", 0))
136
+
137
+ # 检查大小条件
138
+ if width * height <= 500 * 500:
139
+ continue
140
+
141
+ # 检查opacity条件
142
+ opacity = element.get("opacity")
143
+ if opacity is not None and float(opacity) <= 0.5:
144
+ continue
145
+
146
+ # 提取背景元素
147
+ background_element = etree.tostring(element, encoding='unicode')
148
+
149
+ # 从原SVG中删除该元素
150
+ element.getparent().remove(element)
151
+
152
+ # 返回修改后的SVG内容和提取的背景元素内容
153
+ result_svg = etree.tostring(svg_tree, encoding='unicode')
154
+ # 如果原始内容不包含<svg>标签,则提取内部内容
155
+ if not svg_content.strip().startswith('<svg'):
156
+ result_svg = extract_svg_content(result_svg) or ''
157
+
158
+ return result_svg, background_element
159
+
160
+ # 如果没有找到符合条件的元素
161
+ if not svg_content.strip().startswith('<svg'):
162
+ return svg_content, ''
163
+ else:
164
+ content = extract_svg_content(svg_content) or ''
165
+ return content, ''
166
+
167
+ except Exception as e:
168
+ print(f"提取背景元素时发生错误: {e}")
169
+ return svg_content, ''
170
+
171
+ def extract_background_element(svg_content: str) -> str:
172
+ """
173
+ 提取SVG中属于class="chart"下面且class="background"的元素,并把他们移动到svg的顶层,放置在image元素的前面
174
+ 注意:需要保持位置不变,因此需要将transform属性累加
175
+ """
176
+ try:
177
+ # 确保SVG内容被正确的<svg>标签包围
178
+ if not svg_content.strip().startswith('<svg'):
179
+ svg_content = f'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">{svg_content}</svg>'
180
+
181
+ # 解析SVG内容
182
+ svg_tree = etree.fromstring(svg_content.encode())
183
+
184
+ # 查找所有class="chart"元素下的class="background"元素
185
+ namespaces = {'svg': 'http://www.w3.org/2000/svg'}
186
+ background_elements = svg_tree.xpath("//*[contains(@class, 'chart')]//*[contains(@class, 'background')]")
187
+
188
+ if not background_elements:
189
+ return svg_content
190
+
191
+ # 存储提取的背景元素
192
+ extracted_elements = []
193
+
194
+ for element in background_elements:
195
+ # 计算累积的transform
196
+ current = element
197
+ total_transform = ""
198
+ transforms = []
199
+
200
+ while current is not None and current != svg_tree:
201
+ transform = current.get("transform")
202
+ if transform:
203
+ transforms.insert(0, transform)
204
+ current = current.getparent()
205
+
206
+ if transforms:
207
+ total_transform = " ".join(transforms)
208
+
209
+ # 创建新元素,复制原始元素的所有属性
210
+ new_element = etree.Element(element.tag)
211
+ for key, value in element.attrib.items():
212
+ if key != "transform": # 不复制原始的transform
213
+ new_element.set(key, value)
214
+
215
+ # 设置累积后的transform
216
+ if total_transform:
217
+ new_element.set("transform", total_transform)
218
+
219
+ # 保存新元素的字符串表示
220
+ extracted_elements.append(etree.tostring(new_element, encoding='unicode'))
221
+
222
+ # 从原位置移除元素
223
+ element.getparent().remove(element)
224
+ # print("svg_tree", svg_tree)
225
+ # 找到第一个image元素 (tag是image且class是image)
226
+ # 使用xpath查找所有image元素,不管是否有class属性
227
+ first_image = svg_tree.xpath(".//image")
228
+ if first_image:
229
+ first_image = first_image[0]
230
+ else:
231
+ # 如果没有找到image元素,尝试使用命名空间查找
232
+ namespaces = {'svg': 'http://www.w3.org/2000/svg'}
233
+ first_image = svg_tree.xpath(".//svg:image", namespaces=namespaces)
234
+ if first_image:
235
+ first_image = first_image[0]
236
+ else:
237
+ first_image = None
238
+ # print("找到的image元素:", first_image)
239
+
240
+
241
+ # 将提取的元素插入到适当的位置
242
+ if first_image is not None:
243
+ # print("first_image", first_image)
244
+ # 如果存在image元素,将背景元素插入到其前面
245
+ parent = first_image.getparent()
246
+ for elem_str in extracted_elements:
247
+ new_elem = etree.fromstring(elem_str)
248
+ parent.insert(parent.index(first_image), new_elem)
249
+ else:
250
+ # print("no first_image")
251
+ # 如果不存在image元素,将背景元素添加到SVG的开始位置
252
+ for elem_str in reversed(extracted_elements):
253
+ new_elem = etree.fromstring(elem_str)
254
+ svg_tree.insert(0, new_elem)
255
+
256
+ # 转换回字符串
257
+ result = etree.tostring(svg_tree, encoding='unicode')
258
+
259
+ # 如果原始输入没有svg标签,则提取内部内容
260
+ if not svg_content.strip().startswith('<svg'):
261
+ result = extract_svg_content(result) or ''
262
+
263
+ return result
264
+
265
+ except Exception as e:
266
+ print(f"提取背景元素时发生错误: {e}")
267
+ return svg_content
268
+
269
+ def parse_translate(transform_str):
270
+ """Parse translate(x, y) from the transform attribute."""
271
+ match = re.search(r'translate\(\s*([-\d.]+)(?:[\s,]+([-\d.]+))?\s*\)', transform_str)
272
+ if match:
273
+ tx = float(match.group(1))
274
+ ty = float(match.group(2)) if match.group(2) else 0.0
275
+ return tx, ty
276
+ return 0.0, 0.0
277
+
278
+
279
+ def adjust_and_get_bbox(svg_content, background_color = "#FFFFFF"):
280
+ """Adjust SVG and get precise bounding box."""
281
+ # Create temporary files for SVG and PNG
282
+ with tempfile.NamedTemporaryFile(suffix='.svg', delete=False) as temp_svg, \
283
+ tempfile.NamedTemporaryFile(suffix='.png', delete=False) as temp_png:
284
+ temp_svg_path = temp_svg.name
285
+ temp_png_path = temp_png.name
286
+
287
+ svg_container = f"<svg \
288
+ width='1000' \
289
+ height='1000' \
290
+ xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'> \
291
+ {svg_content}</svg>"
292
+ with open(temp_svg_path, 'w', encoding='utf-8') as f:
293
+ f.write(svg_container)
294
+ f.flush()
295
+ bbox = get_svg_actual_bbox(temp_svg_path)
296
+ if bbox is None:
297
+ try:
298
+ os.unlink(temp_svg_path)
299
+ os.unlink(temp_png_path)
300
+ except Exception:
301
+ pass
302
+ raise ValueError("chart SVG has no recognizable geometry (empty bbox)")
303
+ padding = 150
304
+ new_width = bbox['width'] + padding * 2
305
+ new_height = bbox['height'] + padding * 2
306
+ svg_container = f"<svg \
307
+ width='{new_width}' \
308
+ height='{new_height}' \
309
+ xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'> \
310
+ <rect width='{new_width}' height='{new_height}' fill='{background_color}' /> \
311
+ <g transform='translate({padding - bbox['min_x']}, {padding - bbox['min_y']})'> \
312
+ {svg_content} \
313
+ </g> \
314
+ </svg>"
315
+
316
+ with open(temp_svg_path, 'w', encoding='utf-8') as f:
317
+ f.write(svg_container)
318
+ f.flush()
319
+ svg_to_png(temp_svg_path, temp_png_path, background_color)
320
+ x_min, y_min, x_max, y_max = get_precise_bbox(temp_png_path, background_color)
321
+ width = x_max - x_min + 1
322
+ height = y_max - y_min + 1
323
+ offset_x = padding - bbox['min_x'] - x_min
324
+ offset_y = padding - bbox['min_y'] - y_min
325
+ svg_container = f"<g transform='translate({offset_x}, {offset_y})'> \
326
+ {svg_content} \
327
+ </g>"
328
+
329
+ os.unlink(temp_svg_path)
330
+ os.unlink(temp_png_path)
331
+
332
+ return svg_container, width, height, offset_x, offset_y
333
+
334
+ def remove_image_element(svg_content: str) -> str:
335
+ """Remove image element from SVG content."""
336
+ try:
337
+ # 确保SVG内容以</svg>结尾
338
+ if not svg_content.strip().endswith('</svg>'):
339
+ svg_content = svg_content + '</svg>'
340
+
341
+ # 解析SVG内容
342
+ svg_tree = etree.fromstring(svg_content.encode())
343
+
344
+ # 移除所有image元素,包括不同命名空间下的image元素
345
+ namespaces = {'svg': 'http://www.w3.org/2000/svg',
346
+ 'xlink': 'http://www.w3.org/1999/xlink'}
347
+ for image in svg_tree.xpath("//image | //*[local-name()='image']", namespaces=namespaces):
348
+ # print("image", image)
349
+ image.getparent().remove(image)
350
+
351
+ # 转换回字符串
352
+ return etree.tostring(svg_tree, encoding='unicode', pretty_print=True)
353
+ except Exception as e:
354
+ print(f"Error removing image element: {str(e)}")
355
+ print(f"SVG content: {svg_content}")
356
+ return svg_content
357
+
358
+ def svg_to_png(svg_path, png_path, background_color = "#FFFFFF"):
359
+ """Convert SVG to PNG using rsvg-convert with a white background."""
360
+ # Add --background-color=#FFFFFF to set white background
361
+ cmd = ['rsvg-convert', '--background-color=' + background_color, svg_path, '-o', png_path]
362
+ subprocess.run(cmd, check=True)
363
+
364
+ def get_precise_bbox(png_path, background_color = "#FFFFFF"):
365
+ """Get precise bounding box by detecting the exact non-transparent pixels."""
366
+ img = Image.open(png_path).convert("RGBA")
367
+ width, height = img.size
368
+
369
+ # Convert image to numpy array for efficient processing
370
+ img_array = np.array(img)
371
+
372
+ # Get alpha channel and RGB values
373
+ alpha = img_array[:, :, 3]
374
+ rgb = img_array[:, :, :3]
375
+
376
+ # Consider pixels close to the background color as transparent too
377
+ bg_rgb = np.array([int(background_color[i:i+2], 16) for i in (1, 3, 5)]) # Convert hex to RGB
378
+ threshold = 15 # Define a threshold for color similarity
379
+ is_background = np.all(np.abs(rgb - bg_rgb) < threshold, axis=2)
380
+
381
+ # Find non-transparent and non-background pixels
382
+ non_transparent = (alpha > 0) & (~is_background)
383
+
384
+ # If there are no non-transparent pixels, return the full image dimensions
385
+ if not np.any(non_transparent):
386
+ return 0, 0, width, height
387
+
388
+ # Find the bounds of non-transparent pixels
389
+ rows = np.any(non_transparent, axis=1)
390
+ cols = np.any(non_transparent, axis=0)
391
+
392
+ # Get the boundaries
393
+ y_min, y_max = np.where(rows)[0][[0, -1]]
394
+ x_min, x_max = np.where(cols)[0][[0, -1]]
395
+ # Get full image dimensions
396
+ return x_min, y_min, x_max + 1, y_max + 1
397
+
398
+ def get_svg_actual_bbox(svg_path):
399
+ # print("svg_path: ", svg_path)
400
+ # 使用lxml而不是xml.etree.ElementTree
401
+ tree = etree.parse(svg_path)
402
+ root = tree.getroot()
403
+ # 获取SVG的原始宽度和高度
404
+ svg_width = float(root.get('width', '0').replace('px', ''))
405
+ svg_height = float(root.get('height', '0').replace('px', ''))
406
+
407
+ min_x, min_y = float('inf'), float('inf')
408
+ max_x, max_y = float('-inf'), float('-inf')
409
+
410
+ def update_bounds(x_vals, y_vals):
411
+ nonlocal min_x, min_y, max_x, max_y
412
+ min_x = min(min_x, min(x_vals))
413
+ max_x = max(max_x, max(x_vals))
414
+ min_y = min(min_y, min(y_vals))
415
+ max_y = max(max_y, max(y_vals))
416
+
417
+ def get_accumulated_transform(elem):
418
+ # 获取从根元素到当前元素的所有transform累加
419
+ total_dx, total_dy = 0.0, 0.0
420
+ current = elem
421
+ while current is not None:
422
+ transform = current.get("transform")
423
+ if transform:
424
+ dx, dy = parse_translate(transform)
425
+ total_dx += dx
426
+ total_dy += dy
427
+ parent = current.getparent()
428
+ if current == root:
429
+ break
430
+ current = parent
431
+ return total_dx, total_dy
432
+
433
+ def parse_percentage(value, base):
434
+ """解析百分比值,返回实际数值"""
435
+ if isinstance(value, str) and '%' in value:
436
+ percentage = float(value.replace('%', '')) / 100
437
+ return base * percentage
438
+ return float(value)
439
+
440
+ def parse_points(points_str):
441
+ """解析points属性中的点坐标"""
442
+ points = []
443
+ for point in points_str.strip().split():
444
+ x, y = point.split(',')
445
+ points.append((float(x), float(y)))
446
+ return points
447
+
448
+ for elem in root.iter():
449
+ tag = elem.tag.split('}')[-1]
450
+ dx, dy = get_accumulated_transform(elem)
451
+ # print("tag: ", tag)
452
+ if tag == 'rect':
453
+ # 检查是否应该忽略此矩形(fill="none"且没有stroke属性)
454
+ fill = elem.get('fill', '').lower()
455
+ has_stroke = elem.get('stroke') is not None
456
+ if fill == 'none' and not has_stroke:
457
+ continue # 忽略这个矩形
458
+
459
+ x = parse_percentage(elem.get('x', '0'), svg_width) + dx
460
+ y = parse_percentage(elem.get('y', '0'), svg_height) + dy
461
+ w = parse_percentage(elem.get('width', '0'), svg_width)
462
+ h = parse_percentage(elem.get('height', '0'), svg_height)
463
+ update_bounds([x, x + w], [y, y + h])
464
+ elif tag == 'circle':
465
+ cx = parse_percentage(elem.get('cx', '0'), svg_width) + dx
466
+ cy = parse_percentage(elem.get('cy', '0'), svg_height) + dy
467
+ r = parse_percentage(elem.get('r', '0'), svg_width)
468
+ update_bounds([cx - r, cx + r], [cy - r, cy + r])
469
+ elif tag == 'ellipse':
470
+ cx = parse_percentage(elem.get('cx', '0'), svg_width) + dx
471
+ cy = parse_percentage(elem.get('cy', '0'), svg_height) + dy
472
+ rx = parse_percentage(elem.get('rx', '0'), svg_width)
473
+ ry = parse_percentage(elem.get('ry', '0'), svg_height)
474
+ update_bounds([cx - rx, cx + rx], [cy - ry, cy + ry])
475
+ elif tag == 'line':
476
+ x1 = parse_percentage(elem.get('x1', '0'), svg_width) + dx
477
+ y1 = parse_percentage(elem.get('y1', '0'), svg_height) + dy
478
+ x2 = parse_percentage(elem.get('x2', '0'), svg_width) + dx
479
+ y2 = parse_percentage(elem.get('y2', '0'), svg_height) + dy
480
+ update_bounds([x1, x2], [y1, y2])
481
+ elif tag == 'polygon':
482
+ points_str = elem.get('points', '')
483
+ if points_str:
484
+ points = parse_points(points_str)
485
+ x_vals = [x + dx for x, _ in points]
486
+ y_vals = [y + dy for _, y in points]
487
+ update_bounds(x_vals, y_vals)
488
+ elif tag == 'path':
489
+ d = elem.get('d')
490
+ if d:
491
+ try:
492
+ path = parse_path(d)
493
+ for segment in path:
494
+ box = segment.bbox()
495
+ update_bounds(
496
+ [box[0] + dx, box[1] + dx],
497
+ [box[2] + dy, box[3] + dy]
498
+ )
499
+ except Exception as e:
500
+ # 处理无效路径
501
+ print(f"Error parsing path: {e}")
502
+ elif tag == 'text':
503
+ x = parse_percentage(elem.get('x', '0'), svg_width) + dx
504
+ y = parse_percentage(elem.get('y', '0'), svg_height) + dy
505
+ font_size_str = elem.get('font-size', '16')
506
+ font_size = float(font_size_str.replace('px', '')) if 'px' in font_size_str else float(font_size_str)
507
+ text_len = len(elem.text or "")
508
+ text_width = font_size * 0.6 * text_len
509
+ text_height = font_size
510
+
511
+ # 处理文本对齐方式
512
+ text_anchor = elem.get('text-anchor', 'start')
513
+ if text_anchor == 'start':
514
+ text_left = x
515
+ text_right = x + text_width
516
+ elif text_anchor == 'middle':
517
+ text_left = x - text_width / 2
518
+ text_right = x + text_width / 2
519
+ elif text_anchor == 'end':
520
+ text_left = x - text_width
521
+ text_right = x
522
+ else: # 默认为start
523
+ text_left = x
524
+ text_right = x + text_width
525
+
526
+ update_bounds(
527
+ [text_left, text_right],
528
+ [y - 0.8 * text_height, y + 0.2 * text_height]
529
+ )
530
+ # print("min_x: ", min_x)
531
+ # print("min_y: ", min_y)
532
+ # print("max_x: ", max_x)
533
+ # print("max_y: ", max_y)
534
+ if min_x == float('inf'):
535
+ return None # 没有有效图形
536
+
537
+ return {
538
+ 'min_x': min_x,
539
+ 'min_y': min_y,
540
+ 'max_x': max_x,
541
+ 'max_y': max_y,
542
+ 'width': max_x - min_x,
543
+ 'height': max_y - min_y
544
+ }
modules/infographics_generator/template_utils.py ADDED
@@ -0,0 +1,585 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, List, Tuple, Optional, Union
2
+ import random
3
+ import json
4
+ from modules.infographics_generator.color_utils import get_contrast_color, has_indistinguishable_colors, generate_distinct_palette
5
+ import os
6
+
7
+ # 添加全局字典来跟踪模板使用频率
8
+ template_usage_counter = {}
9
+ field_order = ['x', 'y', 'y2', 'y3', 'size', 'group', 'group2', 'group3']
10
+
11
+ # ============================================================
12
+ # chart_type 白名单
13
+ # ------------------------------------------------------------
14
+ # 通过外部 JSON 文件指定允许使用的 chart_type 子集(例如只关心
15
+ # "bump chart" / "bar chart")。analyze_templates 和
16
+ # check_template_compatibility 都会读这份白名单:
17
+ # * 文件不存在 / 为空 / 内容不是非空列表 -> 不过滤(保留原全量行为)
18
+ # * 文件存在且为非空列表 -> 只保留列表内的 chart_type
19
+ #
20
+ # 文件路径默认为工作目录下的 ``allowed_chart_types.json``,可通过
21
+ # 环境变量 ``ALLOWED_CHART_TYPES_FILE`` 覆盖。
22
+ #
23
+ # 支持的文件格式(任选其一):
24
+ # ["bump chart", "bar chart"]
25
+ # {"chart_types": ["bump chart", "bar chart"]}
26
+ # ============================================================
27
+ _ALLOWED_CHART_TYPES_DEFAULT_PATH = "allowed_chart_types.json"
28
+ _allowed_chart_types_cache: Optional[set] = None
29
+ _allowed_chart_types_cache_mtime: Optional[float] = None
30
+ _allowed_chart_types_cache_path: Optional[str] = None
31
+
32
+
33
+ def _get_allowed_chart_types_path() -> str:
34
+ return os.environ.get("ALLOWED_CHART_TYPES_FILE", _ALLOWED_CHART_TYPES_DEFAULT_PATH)
35
+
36
+
37
+ def _load_allowed_chart_types() -> Optional[set]:
38
+ """加载允许的 chart_type 白名单;返回 None 表示不过滤。
39
+
40
+ 带 mtime 缓存:文件被修改时自动重新读取,多线程/多进程环境下也安全
41
+ (ProcessPoolExecutor 的 worker 各自独立 import,缓存彼此隔离)。
42
+ """
43
+ global _allowed_chart_types_cache, _allowed_chart_types_cache_mtime, _allowed_chart_types_cache_path
44
+
45
+ path = _get_allowed_chart_types_path()
46
+ if not os.path.exists(path):
47
+ _allowed_chart_types_cache = None
48
+ _allowed_chart_types_cache_mtime = None
49
+ _allowed_chart_types_cache_path = path
50
+ return None
51
+
52
+ mtime = os.path.getmtime(path)
53
+ if (
54
+ _allowed_chart_types_cache_path == path
55
+ and _allowed_chart_types_cache_mtime == mtime
56
+ ):
57
+ return _allowed_chart_types_cache
58
+
59
+ with open(path, "r", encoding="utf-8") as f:
60
+ raw = json.load(f)
61
+ if isinstance(raw, dict):
62
+ raw = raw.get("chart_types", [])
63
+ if not isinstance(raw, list) or len(raw) == 0:
64
+ _allowed_chart_types_cache = None
65
+ else:
66
+ _allowed_chart_types_cache = set(raw)
67
+
68
+ _allowed_chart_types_cache_mtime = mtime
69
+ _allowed_chart_types_cache_path = path
70
+ return _allowed_chart_types_cache
71
+
72
+
73
+ def _is_chart_type_allowed(chart_type: str) -> bool:
74
+ allowed = _load_allowed_chart_types()
75
+ if allowed is None:
76
+ return True
77
+ return chart_type in allowed
78
+
79
+ def flatten(lst):
80
+ """Flattens a nested list into a single list."""
81
+ result = []
82
+ for item in lst:
83
+ if isinstance(item, list): # Check if the item is a list
84
+ result.extend(flatten(item)) # Recursively flatten the sublist
85
+ else:
86
+ result.append(item) # Add the non-list item to the result
87
+ return result
88
+
89
+ def get_flatten_fields(required_fields) -> List[str]:
90
+ """Flatten a nested list of fields into a single list"""
91
+ lst = flatten(required_fields)
92
+ lst = [field for field in field_order if field in lst]
93
+ return lst
94
+
95
+ def get_unique_fields_and_types(
96
+ required_fields: Union[List[str], List[List[str]]],
97
+ required_fields_type: Union[List[List[str]], List[List[List[str]]]],
98
+ required_fields_range: Optional[Union[List[List[int]], List[List[List[int]]]]] = None
99
+ ) -> Tuple[List[str], Dict[str, str], List[List[int]]]:
100
+ """Extract unique fields and their corresponding types from nested structure"""
101
+ field_types = {}
102
+ field_ranges = {}
103
+
104
+ # Check if required_fields is a list of lists
105
+ if required_fields and isinstance(required_fields[0], list):
106
+ # Handle list of lists case
107
+ for i, (fields_group, types_group) in enumerate(zip(required_fields, required_fields_type)):
108
+ range_group = required_fields_range[i] if required_fields_range != None else [[float('-inf'), float('inf')] for _ in fields_group]
109
+ for field, type_list, range_list in zip(fields_group, types_group, range_group):
110
+ if field not in field_types:
111
+ field_types[field] = type_list[0] # Use first type from the list
112
+ field_ranges[field] = range_list # Use first range from the list
113
+ else:
114
+ # Handle simple list case
115
+ range_list = required_fields_range if required_fields_range != None else [[float('-inf'), float('inf')] for _ in required_fields]
116
+ for field, type_list, range_val in zip(required_fields, required_fields_type, range_list):
117
+ if field not in field_types:
118
+ field_types[field] = type_list[0] # Use first type from the list
119
+ field_ranges[field] = range_val # Use first range from the list
120
+
121
+ # Order fields according to field_order, keeping only those that exist
122
+ ordered_fields = [field for field in field_order if field in field_types]
123
+ for field in field_ranges:
124
+ r = field_ranges[field]
125
+ try:
126
+ if r[0] == "-inf":
127
+ r[0] = float('-inf')
128
+ if r[1] == "inf":
129
+ r[1] = float('inf')
130
+ except:
131
+ pass
132
+ ordered_ranges = [field_ranges[field] for field in ordered_fields]
133
+
134
+ return ordered_fields, field_types, ordered_ranges
135
+
136
+ def analyze_templates(templates: Dict) -> Tuple[int, Dict[str, str], int]:
137
+ """Analyze templates and return count, data requirements and unique colors count"""
138
+ template_count = 0
139
+ template_requirements = {}
140
+ template_list = []
141
+ unique_colors = set()
142
+ requirement_dump = {}
143
+
144
+ for engine, templates_dict in templates.items():
145
+ for chart_type, chart_names_dict in templates_dict.items():
146
+ if not _is_chart_type_allowed(chart_type):
147
+ continue
148
+ for chart_name, template_info in chart_names_dict.items():
149
+ if 'base' in chart_name:
150
+ continue
151
+ if engine == 'vegalite_py':
152
+ continue
153
+ template_list.append(f"{chart_type} / {chart_name}")
154
+ template_count += 1
155
+ if 'requirements' in template_info:
156
+ req = template_info['requirements']
157
+
158
+ # Count unique required colors
159
+ if 'required_other_colors' in req:
160
+ for color in req['required_other_colors']:
161
+ unique_colors.add(color)
162
+
163
+ if 'required_fields_colors' in req:
164
+ for color in req['required_fields_colors']:
165
+ unique_colors.add(color)
166
+
167
+ if 'required_fields' in req and 'required_fields_type' in req:
168
+ template_requirements[f"{engine}/{chart_type}/{chart_name}"] = template_info['requirements']
169
+ requirement_dump[chart_name] = template_info['requirements']
170
+
171
+ # print("template_count", template_count)
172
+ if not os.path.exists("template_list.txt"):
173
+ f = open("template_list.txt", "w")
174
+ f.write("\n".join(template_list))
175
+ f.close()
176
+ if not os.path.exists("requirement_dump.json"):
177
+ f = open("requirement_dump.json", "w")
178
+ f.write(json.dumps(requirement_dump, indent=4))
179
+ f.close()
180
+
181
+ return template_count, template_requirements
182
+
183
+ # block_list = ["multiple_line_graph_06", "layered_area_chart_02", "multiple_area_chart_01", "stacked_area_chart_01", "stacked_area_chart_03"]
184
+ block_list = []
185
+
186
+ def check_field_color_compatibility(requirements: Dict, data: Dict) -> bool:
187
+ """Check if the field color is compatible with the template"""
188
+ if len(requirements.get('required_fields_colors', [])) > 0 and len(data.get("colors", {}).get("field", {}).keys()) == 0:
189
+ return False
190
+ data_fields = get_flatten_fields(requirements.get('required_fields',[]))
191
+ for color_field in requirements.get('required_fields_colors', []):
192
+ field_column = None
193
+ for i, field in enumerate(data_fields):
194
+ if field == color_field:
195
+ field_column = data.get("data", {}).get("columns", {})[i]
196
+ break
197
+ if field_column is None:
198
+ return False
199
+ field_name = field_column["name"]
200
+ for value in data.get("data", {}).get("data", []):
201
+ if value[field_name] not in data.get("colors", {}).get("field", {}).keys():
202
+ return False
203
+ return True
204
+
205
+ def check_field_icon_compatibility(requirements: Dict, data: Dict) -> bool:
206
+ """Check if the field icon is compatible with the template"""
207
+ if len(requirements.get('required_fields_icons', [])) > 0 and len(data.get("images", {}).get("field", {}).keys()) == 0:
208
+ return False
209
+ data_fields = get_flatten_fields(requirements.get('required_fields',[]))
210
+ for icon_field in requirements.get('required_fields_icons', []):
211
+ for i, field in enumerate(data_fields):
212
+ if field == icon_field:
213
+ field_column = data.get("data", {}).get("columns", {})[i]
214
+ break
215
+ if field_column is None:
216
+ return False
217
+ field_name = field_column["name"]
218
+ for value in data.get("data", {}).get("data", []):
219
+ if value[field_name] not in data.get("images", {}).get("field", {}).keys():
220
+ return False
221
+ return True
222
+
223
+ def check_template_compatibility(data: Dict, templates: Dict, specific_chart_name: str = None) -> List[str]:
224
+ """Check which templates are compatible with the given data"""
225
+ compatible_templates = []
226
+
227
+ def normalize_range_bound(value):
228
+ if isinstance(value, str):
229
+ normalized = value.strip().lower()
230
+ if normalized in {"inf", "+inf", "infinity", "+infinity"}:
231
+ return float("inf")
232
+ if normalized in {"-inf", "-infinity"}:
233
+ return float("-inf")
234
+ try:
235
+ return float(normalized)
236
+ except ValueError:
237
+ return value
238
+ return value
239
+
240
+ # Get the combination type from the data
241
+ combination_type = data.get("data", {}).get("type_combination", "")
242
+ combination_types = [col["data_type"] for col in data["data"]["columns"]]
243
+ if combination_type == "":
244
+ combination_type = " + ".join(combination_types)
245
+
246
+ if not combination_type:
247
+ return compatible_templates
248
+
249
+ for engine, templates_dict in templates.items():
250
+ for chart_type, chart_names_dict in templates_dict.items():
251
+ if not _is_chart_type_allowed(chart_type):
252
+ continue
253
+ for chart_name, template_info in chart_names_dict.items():
254
+ if 'base' in chart_name:
255
+ continue
256
+ if engine == 'vegalite_py':
257
+ continue
258
+
259
+ template_key = f"{engine}/{chart_type}/{chart_name}"
260
+
261
+ if specific_chart_name and specific_chart_name != chart_name:
262
+ continue
263
+
264
+ try:
265
+ if 'requirements' in template_info:
266
+ req = template_info['requirements']
267
+ hierarchy = req.get('hierarchy', [])
268
+ if 'required_fields' in req and 'required_fields_type' in req:
269
+ ordered_fields, field_types, ordered_ranges = get_unique_fields_and_types(
270
+ req['required_fields'],
271
+ req['required_fields_type'],
272
+ req.get('required_fields_range', None)
273
+ )
274
+ data_types = [field_types[field] for field in ordered_fields]
275
+ data_type_str = ' + '.join(data_types)
276
+ if len(req.get('required_fields_colors', [])) > 0 and len(data.get("colors", {}).get("field", [])) == 0:
277
+ # print(f"template {template_key} failed color compatibility check")
278
+ continue
279
+
280
+ # if len(req.get('required_fields_icons', [])) > 0 and len(data.get("images", {}).get("field", [])) == 0:
281
+ # print(f"template {template_key} failed icon compatibility check")
282
+ # continue
283
+
284
+ if not check_field_color_compatibility(req, data):
285
+ # print(f"template {template_key} failed color compatibility check")
286
+ continue
287
+
288
+ if not check_field_icon_compatibility(req, data):
289
+ # print(f"template {template_key} failed icon compatibility check")
290
+ continue
291
+ # print("data_types", data_types)
292
+ # print("combination_types", combination_types)
293
+ # 如果data_types和combination_types相同,或者data_types是combination_types的一个子序列
294
+ if len(data_types) == len(combination_types):# or all(data_type in combination_types for data_type in data_types):
295
+ check_flag = True
296
+ for data_type, combination_type in zip(data_types, combination_types[:len(data_types)]):
297
+ if data_type == "categorical" and (combination_type == "temporal" or combination_type == "categorical"):
298
+ pass
299
+ elif data_type == "numerical" and combination_type == "numerical":
300
+ pass
301
+ elif data_type == "temporal" and combination_type == "temporal":
302
+ pass
303
+ else:
304
+ check_flag = False
305
+ break
306
+ if not check_flag:
307
+ # print(f"template {template_key} failed data type compatibility check")
308
+ continue
309
+ else:
310
+ # print(f"template {template_key} failed data type compatibility check")
311
+ continue
312
+
313
+ disallow_temporal_fields = set(req.get('disallow_temporal_fields', []))
314
+ if disallow_temporal_fields:
315
+ rejected_for_temporal = False
316
+ for i, field in enumerate(ordered_fields):
317
+ if (
318
+ field in disallow_temporal_fields
319
+ and i < len(data["data"]["columns"])
320
+ and data["data"]["columns"][i].get("data_type") == "temporal"
321
+ ):
322
+ rejected_for_temporal = True
323
+ break
324
+ if rejected_for_temporal:
325
+ continue
326
+
327
+ flag = True
328
+ # print("check compatibility")
329
+ for i, range_bounds in enumerate(ordered_ranges):
330
+ if i >= len(data["data"]["columns"]):
331
+ flag = False
332
+ break
333
+ min_bound = normalize_range_bound(range_bounds[0])
334
+ max_bound = normalize_range_bound(range_bounds[1])
335
+
336
+ if data["data"]["columns"][i]["data_type"] in ["temporal", "categorical"]:
337
+ key = data["data"]["columns"][i]["name"]
338
+ unique_values = list(set(value[key] for value in data["data"]["data"]))
339
+ if len(unique_values) > max_bound or len(unique_values) < min_bound:
340
+ flag = False
341
+ break
342
+ else:
343
+ pass
344
+ #if specific_chart_name and specific_chart_name == chart_name:
345
+ # print(f"template {template_key} matched", data["name"], len(unique_values), range)
346
+ elif data["data"]["columns"][i]["data_type"] in ["numerical"]:
347
+ key = data["data"]["columns"][i]["name"]
348
+ min_value = min(value[key] for value in data["data"]["data"])
349
+ max_value = max(value[key] for value in data["data"]["data"])
350
+ if min_value < min_bound or max_value > max_bound:
351
+ flag = False
352
+ break
353
+ elif "diverging" in chart_name and min_value >= 0 and min_bound < 0:
354
+ flag = False
355
+ break
356
+ elif "scatterplot" in chart_name and min_value >= 0 and min_bound < 0:
357
+ flag = False
358
+ break
359
+ for i, field in enumerate(ordered_fields):
360
+ if field == "group":
361
+ x_col = [j for j, field2 in enumerate(ordered_fields) if field2 == "x"][0]
362
+ x_name = data["data"]["columns"][x_col]["name"]
363
+ field_name = data["data"]["columns"][i]["name"]
364
+ num_unique_x = len(list(set(value[x_name] for value in data["data"]["data"])))
365
+ num_unique_comb = len(list(set(str(value[x_name]) + ' ' + str(value[field_name]) for value in data["data"]["data"])))
366
+ if field in hierarchy:
367
+ if num_unique_comb > num_unique_x:
368
+ flag = False
369
+ break
370
+ else:
371
+ if num_unique_comb == num_unique_x:
372
+ flag = False
373
+ break
374
+ elif field == "group2":
375
+ x_col = [j for j, field2 in enumerate(ordered_fields) if field2 == "x"][0]
376
+ group_col = [j for j, field2 in enumerate(ordered_fields) if field2 == "group"][0]
377
+ x_name = data["data"]["columns"][x_col]["name"]
378
+ group_name = data["data"]["columns"][group_col]["name"]
379
+ field_name = data["data"]["columns"][i]["name"]
380
+ num_unique_x = len(list(set(str(value[x_name]) + ' ' + str(value[group_name]) for value in data["data"]["data"])))
381
+ num_unique_comb = len(list(set(str(value[x_name]) + ' ' + str(value[group_name]) + ' ' + str(value[field_name]) for value in data["data"]["data"])))
382
+ if field in hierarchy:
383
+ if num_unique_comb > num_unique_x:
384
+ flag = False
385
+ break
386
+ else:
387
+ if num_unique_comb == num_unique_x:
388
+ flag = False
389
+ break
390
+ if flag:
391
+ if specific_chart_name == None or specific_chart_name == chart_name:
392
+ compatible_templates.append((template_key, ordered_fields))
393
+ except:
394
+ pass
395
+ #print("compatible_templates", compatible_templates)
396
+ return compatible_templates
397
+
398
+
399
+ import fcntl # 用于文件锁
400
+ def select_template(compatible_templates: List[str]) -> Tuple[str, str, str]:
401
+ """
402
+ 根据variation.json中的使用统计选择模板
403
+ 按照使用频率分为4个level,优先选择使用较少的level
404
+ 同level内按照具体使用次数加权随机选择
405
+ 使用文件锁确保多线程安全
406
+ """
407
+ # 读取variation.json,使用文件锁
408
+ try:
409
+ with open('variation.json', 'r') as f:
410
+ # 获取文件锁
411
+ fcntl.flock(f, fcntl.LOCK_EX)
412
+ try:
413
+ variation_stats = json.load(f)
414
+ finally:
415
+ # 释放文件锁
416
+ fcntl.flock(f, fcntl.LOCK_UN)
417
+ except:
418
+ variation_stats = {}
419
+
420
+ # 获取所有模板的使用次数
421
+ template_counts = []
422
+ for template_info in compatible_templates:
423
+ template_key = template_info[0]
424
+ _, chart_type, chart_name = template_key.split('/')
425
+
426
+ # 如果variation_stats为空,所有模板使用次数都为0
427
+ if not variation_stats:
428
+ count = 0
429
+ else:
430
+ if chart_type not in variation_stats:
431
+ variation_stats[chart_type] = {"total_count": 0}
432
+
433
+ if chart_name not in variation_stats[chart_type]:
434
+ variation_stats[chart_type][chart_name] = 0
435
+
436
+ count = variation_stats[chart_type][chart_name]
437
+
438
+ template_counts.append((template_info, count))
439
+
440
+ # 按使用次数排序并分level
441
+ template_counts.sort(key=lambda x: x[1])
442
+ n = len(template_counts)
443
+
444
+ # 如果没有可用模板,返回 None
445
+ if n == 0:
446
+ return None, None, None, None
447
+
448
+ level_size = max(1, n // 4)
449
+ # 找出使用次数最少的模板
450
+ min_count = min(c for _, c in template_counts)
451
+ min_level_templates = [(t, c) for t, c in template_counts if c == min_count]
452
+
453
+ # 固定选择第一个最少使用的模板
454
+ selected_index = 0
455
+
456
+ selected_template, _ = min_level_templates[selected_index]
457
+ [template_key, ordered_fields] = selected_template
458
+ print("selected_template", selected_template)
459
+
460
+ # 更新variation.json,使用文件锁
461
+ engine, chart_type, chart_name = template_key.split('/')
462
+ if os.environ.get("CHARTPIPELINE_SKIP_VARIATION_STATS_UPDATE") == "1":
463
+ return engine, chart_type, chart_name, ordered_fields
464
+ try:
465
+ with open('variation.json', 'r+') as f:
466
+ # 获取文件锁
467
+ fcntl.flock(f, fcntl.LOCK_EX)
468
+ try:
469
+ # 重新读取以确保获取最新数据
470
+ variation_stats = json.load(f)
471
+
472
+ # 初始化如果不存在
473
+ if chart_type not in variation_stats:
474
+ variation_stats[chart_type] = {"total_count": 0}
475
+ if chart_name not in variation_stats[chart_type]:
476
+ variation_stats[chart_type][chart_name] = 0
477
+
478
+ # 更新计数
479
+ variation_stats[chart_type][chart_name] += 1
480
+ variation_stats[chart_type]["total_count"] += 1
481
+
482
+ # 写入更新后的数据
483
+ f.seek(0)
484
+ json.dump(variation_stats, f, indent=2)
485
+ f.truncate()
486
+ finally:
487
+ # 释放文件锁
488
+ fcntl.flock(f, fcntl.LOCK_UN)
489
+ except FileNotFoundError:
490
+ # 如果文件不存在,创建新的variation_stats
491
+ variation_stats = {
492
+ chart_type: {
493
+ "total_count": 1,
494
+ chart_name: 1
495
+ }
496
+ }
497
+ with open('variation.json', 'w') as f:
498
+ json.dump(variation_stats, f, indent=2)
499
+ return engine, chart_type, chart_name, ordered_fields
500
+
501
+
502
+ def process_template_requirements(requirements: Dict, data: Dict, engine: str, chart_name: str) -> None:
503
+ """处理模板的颜色要求"""
504
+ default_colors = {
505
+ "text_color": "#333333",
506
+ "background_color": "#ffffff",
507
+ "field": {},
508
+ "other": {"primary": "#4e79a7"},
509
+ "available_colors": ["#4e79a7", "#f28e2b", "#59a14f", "#e15759", "#76b7b2", "#edc948"]
510
+ }
511
+ default_dark_colors = {
512
+ "text_color": "#ffffff",
513
+ "background_color": "#1f2933",
514
+ "field": {},
515
+ "other": {"primary": "#76b7b2"},
516
+ "available_colors": ["#76b7b2", "#f28e2b", "#59a14f", "#e15759", "#9c755f", "#edc948"]
517
+ }
518
+
519
+ if not isinstance(data.get("colors"), dict):
520
+ data["colors"] = json.loads(json.dumps(default_colors))
521
+ else:
522
+ data["colors"].setdefault("text_color", default_colors["text_color"])
523
+ data["colors"].setdefault("background_color", default_colors["background_color"])
524
+ data["colors"].setdefault("field", {})
525
+ data["colors"].setdefault("other", {})
526
+ data["colors"]["other"].setdefault("primary", default_colors["other"]["primary"])
527
+ data["colors"].setdefault("available_colors", default_colors["available_colors"])
528
+
529
+ if not isinstance(data.get("colors_dark"), dict):
530
+ data["colors_dark"] = json.loads(json.dumps(default_dark_colors))
531
+ else:
532
+ data["colors_dark"].setdefault("text_color", default_dark_colors["text_color"])
533
+ data["colors_dark"].setdefault("background_color", default_dark_colors["background_color"])
534
+ data["colors_dark"].setdefault("field", {})
535
+ data["colors_dark"].setdefault("other", {})
536
+ data["colors_dark"]["other"].setdefault("primary", default_dark_colors["other"]["primary"])
537
+ data["colors_dark"].setdefault("available_colors", default_dark_colors["available_colors"])
538
+
539
+ colors = data["colors"]
540
+ colors_dark = data["colors_dark"]
541
+
542
+ if len(colors["field"]) > 1:
543
+ # 检查颜色是否可区分
544
+ field_colors = list(colors["field"].values())
545
+ if has_indistinguishable_colors(field_colors):
546
+ # 如果颜色不可区分,使用主色生成新的调色板
547
+ primary_color = colors["other"]["primary"]
548
+ new_colors = generate_distinct_palette(primary_color, len(field_colors))
549
+ # 更新颜色字典
550
+ for i, field in enumerate(colors["field"].keys()):
551
+ colors["field"][field] = new_colors[i]
552
+
553
+ if len(colors_dark["field"]) > 1:
554
+ # 检查颜色是否可区分
555
+ field_colors = list(colors_dark["field"].values())
556
+ if has_indistinguishable_colors(field_colors):
557
+ # 如果颜色不可区分,使用主色生成新的调色板
558
+ primary_color = colors_dark["other"]["primary"]
559
+ new_colors = generate_distinct_palette(primary_color, len(field_colors))
560
+ # 更新颜色字典
561
+ for i, field in enumerate(colors_dark["field"].keys()):
562
+ colors_dark["field"][field] = new_colors[i]
563
+
564
+
565
+ if len(requirements.get("required_other_colors", [])) > 0:
566
+ for key in requirements["required_other_colors"]:
567
+ if key == "positive" and "positive" not in colors["other"]:
568
+ colors["other"]["positive"] = colors["other"]["primary"]
569
+ elif key == "negative" and "negative" not in colors["other"]:
570
+ colors["other"]["negative"] = get_contrast_color(colors["other"]["primary"])
571
+
572
+ colors_dark["text_color"] = "#ffffff"
573
+ # if ('donut' in chart_name or 'pie' in chart_name) and engine == 'vegalite_py':
574
+ # data["variables"]["height"] = 500
575
+ # data["variables"]["width"] = 500
576
+ # else:
577
+ # if "min_height" in requirements:
578
+ # data["variables"]["height"] = max(600, requirements["min_height"])
579
+ # elif 'height' in requirements:
580
+ # data["variables"]["height"] = max(600, requirements["height"][0])
581
+
582
+ # if "min_width" in requirements:
583
+ # data["variables"]["width"] = max(800, requirements["min_width"])
584
+ # elif 'width' in requirements:
585
+ # data["variables"]["width"] = max(600, requirements["width"][0])
modules/infographics_generator/utils/logger.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import logging
3
+ from logging import getLogger
4
+
5
+ def setup_logger():
6
+ """Configure and return the logger for infographics generator"""
7
+ # 创建tmp目录(如果不存在)
8
+ os.makedirs("tmp", exist_ok=True)
9
+
10
+ # 配置日志
11
+ logger = getLogger(__name__)
12
+ logger.setLevel(logging.INFO)
13
+
14
+ # 移除所有现有的处理器
15
+ for handler in logger.handlers[:]:
16
+ logger.removeHandler(handler)
17
+
18
+ # 创建文件处理器
19
+ file_handler = logging.FileHandler('tmp/log.txt')
20
+ file_handler.setLevel(logging.INFO)
21
+
22
+ # 创建格式化器
23
+ formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
24
+ file_handler.setFormatter(formatter)
25
+
26
+ # 添加处理器到logger
27
+ logger.addHandler(file_handler)
28
+
29
+ return logger