Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- modules/infographics_generator/__init__.py +16 -0
- modules/infographics_generator/build_scene_tree_v3.py +0 -0
- modules/infographics_generator/chat_utils.py +54 -0
- modules/infographics_generator/color_utils.py +334 -0
- modules/infographics_generator/data_utils.py +115 -0
- modules/infographics_generator/image_utils.py +247 -0
- modules/infographics_generator/infographics_generator.py +0 -0
- modules/infographics_generator/layout_system/__init__.py +6 -0
- modules/infographics_generator/layout_system/constraints/__init__.py +20 -0
- modules/infographics_generator/layout_system/constraints/alignment.py +102 -0
- modules/infographics_generator/layout_system/constraints/base.py +46 -0
- modules/infographics_generator/layout_system/constraints/gap.py +74 -0
- modules/infographics_generator/layout_system/constraints/orientation.py +97 -0
- modules/infographics_generator/layout_system/constraints/overlap.py +36 -0
- modules/infographics_generator/layout_system/constraints/padding.py +69 -0
- modules/infographics_generator/layout_system/constraints/relative_size.py +68 -0
- modules/infographics_generator/layout_system/element_loader.py +196 -0
- modules/infographics_generator/layout_system/handlers/__init__.py +12 -0
- modules/infographics_generator/layout_system/handlers/base.py +55 -0
- modules/infographics_generator/layout_system/handlers/image_handler.py +99 -0
- modules/infographics_generator/layout_system/handlers/text_handler.py +77 -0
- modules/infographics_generator/layout_system/hierarchical_optimizer.py +640 -0
- modules/infographics_generator/layout_system/parameters.py +45 -0
- modules/infographics_generator/layout_system/sdf/__init__.py +10 -0
- modules/infographics_generator/layout_system/sdf/bbox.py +156 -0
- modules/infographics_generator/layout_system/sdf/core.py +193 -0
- modules/infographics_generator/layout_system/sdf/losses.py +558 -0
- modules/infographics_generator/layout_system/sdf/optimizer.py +921 -0
- modules/infographics_generator/layout_system/sdf/visualization.py +634 -0
- modules/infographics_generator/layout_system/strategies/__init__.py +12 -0
- modules/infographics_generator/layout_system/strategies/base.py +43 -0
- modules/infographics_generator/layout_system/strategies/rule_based_strategy.py +328 -0
- modules/infographics_generator/layout_system/strategies/sdf_strategy.py +314 -0
- modules/infographics_generator/layout_system/test_hierarchical.py +92 -0
- modules/infographics_generator/layout_system/test_sdf.py +99 -0
- modules/infographics_generator/layout_system/utils/__init__.py +13 -0
- modules/infographics_generator/layout_system/utils/composite.py +92 -0
- modules/infographics_generator/layout_system/utils/nodes.py +378 -0
- modules/infographics_generator/layout_system/utils/parser.py +62 -0
- modules/infographics_generator/layout_system/utils/placeholder.py +69 -0
- modules/infographics_generator/layout_system/utils/save_result.py +303 -0
- modules/infographics_generator/mask_utils.py +622 -0
- modules/infographics_generator/parse_utils.py +152 -0
- modules/infographics_generator/screenshot_utils.py +299 -0
- modules/infographics_generator/svg_utils.py +544 -0
- modules/infographics_generator/template_utils.py +585 -0
- 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('&', '&')
|
| 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('&', '&')
|
| 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('&', '&')
|
| 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
|