Spaces:
Sleeping
Sleeping
| from typing import Dict, List, Tuple, Optional, Union | |
| import random | |
| import json | |
| from modules.infographics_generator.color_utils import get_contrast_color, has_indistinguishable_colors, generate_distinct_palette | |
| import os | |
| # 添加全局字典来跟踪模板使用频率 | |
| template_usage_counter = {} | |
| field_order = ['x', 'y', 'y2', 'y3', 'size', 'group', 'group2', 'group3'] | |
| # ============================================================ | |
| # chart_type 白名单 | |
| # ------------------------------------------------------------ | |
| # 通过外部 JSON 文件指定允许使用的 chart_type 子集(例如只关心 | |
| # "bump chart" / "bar chart")。analyze_templates 和 | |
| # check_template_compatibility 都会读这份白名单: | |
| # * 文件不存在 / 为空 / 内容不是非空列表 -> 不过滤(保留原全量行为) | |
| # * 文件存在且为非空列表 -> 只保留列表内的 chart_type | |
| # | |
| # 文件路径默认为工作目录下的 ``allowed_chart_types.json``,可通过 | |
| # 环境变量 ``ALLOWED_CHART_TYPES_FILE`` 覆盖。 | |
| # | |
| # 支持的文件格式(任选其一): | |
| # ["bump chart", "bar chart"] | |
| # {"chart_types": ["bump chart", "bar chart"]} | |
| # ============================================================ | |
| _ALLOWED_CHART_TYPES_DEFAULT_PATH = "allowed_chart_types.json" | |
| _allowed_chart_types_cache: Optional[set] = None | |
| _allowed_chart_types_cache_mtime: Optional[float] = None | |
| _allowed_chart_types_cache_path: Optional[str] = None | |
| def _get_allowed_chart_types_path() -> str: | |
| return os.environ.get("ALLOWED_CHART_TYPES_FILE", _ALLOWED_CHART_TYPES_DEFAULT_PATH) | |
| def _load_allowed_chart_types() -> Optional[set]: | |
| """加载允许的 chart_type 白名单;返回 None 表示不过滤。 | |
| 带 mtime 缓存:文件被修改时自动重新读取,多线程/多进程环境下也安全 | |
| (ProcessPoolExecutor 的 worker 各自独立 import,缓存彼此隔离)。 | |
| """ | |
| global _allowed_chart_types_cache, _allowed_chart_types_cache_mtime, _allowed_chart_types_cache_path | |
| path = _get_allowed_chart_types_path() | |
| if not os.path.exists(path): | |
| _allowed_chart_types_cache = None | |
| _allowed_chart_types_cache_mtime = None | |
| _allowed_chart_types_cache_path = path | |
| return None | |
| mtime = os.path.getmtime(path) | |
| if ( | |
| _allowed_chart_types_cache_path == path | |
| and _allowed_chart_types_cache_mtime == mtime | |
| ): | |
| return _allowed_chart_types_cache | |
| with open(path, "r", encoding="utf-8") as f: | |
| raw = json.load(f) | |
| if isinstance(raw, dict): | |
| raw = raw.get("chart_types", []) | |
| if not isinstance(raw, list) or len(raw) == 0: | |
| _allowed_chart_types_cache = None | |
| else: | |
| _allowed_chart_types_cache = set(raw) | |
| _allowed_chart_types_cache_mtime = mtime | |
| _allowed_chart_types_cache_path = path | |
| return _allowed_chart_types_cache | |
| def _is_chart_type_allowed(chart_type: str) -> bool: | |
| allowed = _load_allowed_chart_types() | |
| if allowed is None: | |
| return True | |
| return chart_type in allowed | |
| def flatten(lst): | |
| """Flattens a nested list into a single list.""" | |
| result = [] | |
| for item in lst: | |
| if isinstance(item, list): # Check if the item is a list | |
| result.extend(flatten(item)) # Recursively flatten the sublist | |
| else: | |
| result.append(item) # Add the non-list item to the result | |
| return result | |
| def get_flatten_fields(required_fields) -> List[str]: | |
| """Flatten a nested list of fields into a single list""" | |
| lst = flatten(required_fields) | |
| lst = [field for field in field_order if field in lst] | |
| return lst | |
| def get_unique_fields_and_types( | |
| required_fields: Union[List[str], List[List[str]]], | |
| required_fields_type: Union[List[List[str]], List[List[List[str]]]], | |
| required_fields_range: Optional[Union[List[List[int]], List[List[List[int]]]]] = None | |
| ) -> Tuple[List[str], Dict[str, str], List[List[int]]]: | |
| """Extract unique fields and their corresponding types from nested structure""" | |
| field_types = {} | |
| field_ranges = {} | |
| # Check if required_fields is a list of lists | |
| if required_fields and isinstance(required_fields[0], list): | |
| # Handle list of lists case | |
| for i, (fields_group, types_group) in enumerate(zip(required_fields, required_fields_type)): | |
| range_group = required_fields_range[i] if required_fields_range != None else [[float('-inf'), float('inf')] for _ in fields_group] | |
| for field, type_list, range_list in zip(fields_group, types_group, range_group): | |
| if field not in field_types: | |
| field_types[field] = type_list[0] # Use first type from the list | |
| field_ranges[field] = range_list # Use first range from the list | |
| else: | |
| # Handle simple list case | |
| range_list = required_fields_range if required_fields_range != None else [[float('-inf'), float('inf')] for _ in required_fields] | |
| for field, type_list, range_val in zip(required_fields, required_fields_type, range_list): | |
| if field not in field_types: | |
| field_types[field] = type_list[0] # Use first type from the list | |
| field_ranges[field] = range_val # Use first range from the list | |
| # Order fields according to field_order, keeping only those that exist | |
| ordered_fields = [field for field in field_order if field in field_types] | |
| for field in field_ranges: | |
| r = field_ranges[field] | |
| try: | |
| if r[0] == "-inf": | |
| r[0] = float('-inf') | |
| if r[1] == "inf": | |
| r[1] = float('inf') | |
| except: | |
| pass | |
| ordered_ranges = [field_ranges[field] for field in ordered_fields] | |
| return ordered_fields, field_types, ordered_ranges | |
| def analyze_templates(templates: Dict) -> Tuple[int, Dict[str, str], int]: | |
| """Analyze templates and return count, data requirements and unique colors count""" | |
| template_count = 0 | |
| template_requirements = {} | |
| template_list = [] | |
| unique_colors = set() | |
| requirement_dump = {} | |
| for engine, templates_dict in templates.items(): | |
| for chart_type, chart_names_dict in templates_dict.items(): | |
| if not _is_chart_type_allowed(chart_type): | |
| continue | |
| for chart_name, template_info in chart_names_dict.items(): | |
| if 'base' in chart_name: | |
| continue | |
| if engine == 'vegalite_py': | |
| continue | |
| template_list.append(f"{chart_type} / {chart_name}") | |
| template_count += 1 | |
| if 'requirements' in template_info: | |
| req = template_info['requirements'] | |
| # Count unique required colors | |
| if 'required_other_colors' in req: | |
| for color in req['required_other_colors']: | |
| unique_colors.add(color) | |
| if 'required_fields_colors' in req: | |
| for color in req['required_fields_colors']: | |
| unique_colors.add(color) | |
| if 'required_fields' in req and 'required_fields_type' in req: | |
| template_requirements[f"{engine}/{chart_type}/{chart_name}"] = template_info['requirements'] | |
| requirement_dump[chart_name] = template_info['requirements'] | |
| # print("template_count", template_count) | |
| if not os.path.exists("template_list.txt"): | |
| f = open("template_list.txt", "w") | |
| f.write("\n".join(template_list)) | |
| f.close() | |
| if not os.path.exists("requirement_dump.json"): | |
| f = open("requirement_dump.json", "w") | |
| f.write(json.dumps(requirement_dump, indent=4)) | |
| f.close() | |
| return template_count, template_requirements | |
| # block_list = ["multiple_line_graph_06", "layered_area_chart_02", "multiple_area_chart_01", "stacked_area_chart_01", "stacked_area_chart_03"] | |
| block_list = [] | |
| def check_field_color_compatibility(requirements: Dict, data: Dict) -> bool: | |
| """Check if the field color is compatible with the template""" | |
| if len(requirements.get('required_fields_colors', [])) > 0 and len(data.get("colors", {}).get("field", {}).keys()) == 0: | |
| return False | |
| data_fields = get_flatten_fields(requirements.get('required_fields',[])) | |
| for color_field in requirements.get('required_fields_colors', []): | |
| field_column = None | |
| for i, field in enumerate(data_fields): | |
| if field == color_field: | |
| field_column = data.get("data", {}).get("columns", {})[i] | |
| break | |
| if field_column is None: | |
| return False | |
| field_name = field_column["name"] | |
| for value in data.get("data", {}).get("data", []): | |
| if value[field_name] not in data.get("colors", {}).get("field", {}).keys(): | |
| return False | |
| return True | |
| def check_field_icon_compatibility(requirements: Dict, data: Dict) -> bool: | |
| """Check if the field icon is compatible with the template""" | |
| if len(requirements.get('required_fields_icons', [])) > 0 and len(data.get("images", {}).get("field", {}).keys()) == 0: | |
| return False | |
| data_fields = get_flatten_fields(requirements.get('required_fields',[])) | |
| for icon_field in requirements.get('required_fields_icons', []): | |
| for i, field in enumerate(data_fields): | |
| if field == icon_field: | |
| field_column = data.get("data", {}).get("columns", {})[i] | |
| break | |
| if field_column is None: | |
| return False | |
| field_name = field_column["name"] | |
| for value in data.get("data", {}).get("data", []): | |
| if value[field_name] not in data.get("images", {}).get("field", {}).keys(): | |
| return False | |
| return True | |
| def check_template_compatibility(data: Dict, templates: Dict, specific_chart_name: str = None) -> List[str]: | |
| """Check which templates are compatible with the given data""" | |
| compatible_templates = [] | |
| def normalize_range_bound(value): | |
| if isinstance(value, str): | |
| normalized = value.strip().lower() | |
| if normalized in {"inf", "+inf", "infinity", "+infinity"}: | |
| return float("inf") | |
| if normalized in {"-inf", "-infinity"}: | |
| return float("-inf") | |
| try: | |
| return float(normalized) | |
| except ValueError: | |
| return value | |
| return value | |
| # Get the combination type from the data | |
| combination_type = data.get("data", {}).get("type_combination", "") | |
| combination_types = [col["data_type"] for col in data["data"]["columns"]] | |
| if combination_type == "": | |
| combination_type = " + ".join(combination_types) | |
| if not combination_type: | |
| return compatible_templates | |
| for engine, templates_dict in templates.items(): | |
| for chart_type, chart_names_dict in templates_dict.items(): | |
| if not _is_chart_type_allowed(chart_type): | |
| continue | |
| for chart_name, template_info in chart_names_dict.items(): | |
| if 'base' in chart_name: | |
| continue | |
| if engine == 'vegalite_py': | |
| continue | |
| template_key = f"{engine}/{chart_type}/{chart_name}" | |
| if specific_chart_name and specific_chart_name != chart_name: | |
| continue | |
| try: | |
| if 'requirements' in template_info: | |
| req = template_info['requirements'] | |
| hierarchy = req.get('hierarchy', []) | |
| if 'required_fields' in req and 'required_fields_type' in req: | |
| ordered_fields, field_types, ordered_ranges = get_unique_fields_and_types( | |
| req['required_fields'], | |
| req['required_fields_type'], | |
| req.get('required_fields_range', None) | |
| ) | |
| data_types = [field_types[field] for field in ordered_fields] | |
| data_type_str = ' + '.join(data_types) | |
| if len(req.get('required_fields_colors', [])) > 0 and len(data.get("colors", {}).get("field", [])) == 0: | |
| # print(f"template {template_key} failed color compatibility check") | |
| continue | |
| # if len(req.get('required_fields_icons', [])) > 0 and len(data.get("images", {}).get("field", [])) == 0: | |
| # print(f"template {template_key} failed icon compatibility check") | |
| # continue | |
| if not check_field_color_compatibility(req, data): | |
| # print(f"template {template_key} failed color compatibility check") | |
| continue | |
| if not check_field_icon_compatibility(req, data): | |
| # print(f"template {template_key} failed icon compatibility check") | |
| continue | |
| # print("data_types", data_types) | |
| # print("combination_types", combination_types) | |
| # 如果data_types和combination_types相同,或者data_types是combination_types的一个子序列 | |
| if len(data_types) == len(combination_types):# or all(data_type in combination_types for data_type in data_types): | |
| check_flag = True | |
| for data_type, combination_type in zip(data_types, combination_types[:len(data_types)]): | |
| if data_type == "categorical" and (combination_type == "temporal" or combination_type == "categorical"): | |
| pass | |
| elif data_type == "numerical" and combination_type == "numerical": | |
| pass | |
| elif data_type == "temporal" and combination_type == "temporal": | |
| pass | |
| else: | |
| check_flag = False | |
| break | |
| if not check_flag: | |
| # print(f"template {template_key} failed data type compatibility check") | |
| continue | |
| else: | |
| # print(f"template {template_key} failed data type compatibility check") | |
| continue | |
| disallow_temporal_fields = set(req.get('disallow_temporal_fields', [])) | |
| if disallow_temporal_fields: | |
| rejected_for_temporal = False | |
| for i, field in enumerate(ordered_fields): | |
| if ( | |
| field in disallow_temporal_fields | |
| and i < len(data["data"]["columns"]) | |
| and data["data"]["columns"][i].get("data_type") == "temporal" | |
| ): | |
| rejected_for_temporal = True | |
| break | |
| if rejected_for_temporal: | |
| continue | |
| flag = True | |
| # print("check compatibility") | |
| for i, range_bounds in enumerate(ordered_ranges): | |
| if i >= len(data["data"]["columns"]): | |
| flag = False | |
| break | |
| min_bound = normalize_range_bound(range_bounds[0]) | |
| max_bound = normalize_range_bound(range_bounds[1]) | |
| if data["data"]["columns"][i]["data_type"] in ["temporal", "categorical"]: | |
| key = data["data"]["columns"][i]["name"] | |
| unique_values = list(set(value[key] for value in data["data"]["data"])) | |
| if len(unique_values) > max_bound or len(unique_values) < min_bound: | |
| flag = False | |
| break | |
| else: | |
| pass | |
| #if specific_chart_name and specific_chart_name == chart_name: | |
| # print(f"template {template_key} matched", data["name"], len(unique_values), range) | |
| elif data["data"]["columns"][i]["data_type"] in ["numerical"]: | |
| key = data["data"]["columns"][i]["name"] | |
| min_value = min(value[key] for value in data["data"]["data"]) | |
| max_value = max(value[key] for value in data["data"]["data"]) | |
| if min_value < min_bound or max_value > max_bound: | |
| flag = False | |
| break | |
| elif "diverging" in chart_name and min_value >= 0 and min_bound < 0: | |
| flag = False | |
| break | |
| elif "scatterplot" in chart_name and min_value >= 0 and min_bound < 0: | |
| flag = False | |
| break | |
| for i, field in enumerate(ordered_fields): | |
| if field == "group": | |
| x_col = [j for j, field2 in enumerate(ordered_fields) if field2 == "x"][0] | |
| x_name = data["data"]["columns"][x_col]["name"] | |
| field_name = data["data"]["columns"][i]["name"] | |
| num_unique_x = len(list(set(value[x_name] for value in data["data"]["data"]))) | |
| num_unique_comb = len(list(set(str(value[x_name]) + ' ' + str(value[field_name]) for value in data["data"]["data"]))) | |
| if field in hierarchy: | |
| if num_unique_comb > num_unique_x: | |
| flag = False | |
| break | |
| else: | |
| if num_unique_comb == num_unique_x: | |
| flag = False | |
| break | |
| elif field == "group2": | |
| x_col = [j for j, field2 in enumerate(ordered_fields) if field2 == "x"][0] | |
| group_col = [j for j, field2 in enumerate(ordered_fields) if field2 == "group"][0] | |
| x_name = data["data"]["columns"][x_col]["name"] | |
| group_name = data["data"]["columns"][group_col]["name"] | |
| field_name = data["data"]["columns"][i]["name"] | |
| num_unique_x = len(list(set(str(value[x_name]) + ' ' + str(value[group_name]) for value in data["data"]["data"]))) | |
| num_unique_comb = len(list(set(str(value[x_name]) + ' ' + str(value[group_name]) + ' ' + str(value[field_name]) for value in data["data"]["data"]))) | |
| if field in hierarchy: | |
| if num_unique_comb > num_unique_x: | |
| flag = False | |
| break | |
| else: | |
| if num_unique_comb == num_unique_x: | |
| flag = False | |
| break | |
| if flag: | |
| if specific_chart_name == None or specific_chart_name == chart_name: | |
| compatible_templates.append((template_key, ordered_fields)) | |
| except: | |
| pass | |
| #print("compatible_templates", compatible_templates) | |
| return compatible_templates | |
| import fcntl # 用于文件锁 | |
| def select_template(compatible_templates: List[str]) -> Tuple[str, str, str]: | |
| """ | |
| 根据variation.json中的使用统计选择模板 | |
| 按照使用频率分为4个level,优先选择使用较少的level | |
| 同level内按照具体使用次数加权随机选择 | |
| 使用文件锁确保多线程安全 | |
| """ | |
| # 读取variation.json,使用文件锁 | |
| try: | |
| with open('variation.json', 'r') as f: | |
| # 获取文件锁 | |
| fcntl.flock(f, fcntl.LOCK_EX) | |
| try: | |
| variation_stats = json.load(f) | |
| finally: | |
| # 释放文件锁 | |
| fcntl.flock(f, fcntl.LOCK_UN) | |
| except: | |
| variation_stats = {} | |
| # 获取所有模板的使用次数 | |
| template_counts = [] | |
| for template_info in compatible_templates: | |
| template_key = template_info[0] | |
| _, chart_type, chart_name = template_key.split('/') | |
| # 如果variation_stats为空,所有模板使用次数都为0 | |
| if not variation_stats: | |
| count = 0 | |
| else: | |
| if chart_type not in variation_stats: | |
| variation_stats[chart_type] = {"total_count": 0} | |
| if chart_name not in variation_stats[chart_type]: | |
| variation_stats[chart_type][chart_name] = 0 | |
| count = variation_stats[chart_type][chart_name] | |
| template_counts.append((template_info, count)) | |
| # 按使用次数排序并分level | |
| template_counts.sort(key=lambda x: x[1]) | |
| n = len(template_counts) | |
| # 如果没有可用模板,返回 None | |
| if n == 0: | |
| return None, None, None, None | |
| level_size = max(1, n // 4) | |
| # 找出使用次数最少的模板 | |
| min_count = min(c for _, c in template_counts) | |
| min_level_templates = [(t, c) for t, c in template_counts if c == min_count] | |
| # 固定选择第一个最少使用的模板 | |
| selected_index = 0 | |
| selected_template, _ = min_level_templates[selected_index] | |
| [template_key, ordered_fields] = selected_template | |
| print("selected_template", selected_template) | |
| # 更新variation.json,使用文件锁 | |
| engine, chart_type, chart_name = template_key.split('/') | |
| if os.environ.get("CHARTPIPELINE_SKIP_VARIATION_STATS_UPDATE") == "1": | |
| return engine, chart_type, chart_name, ordered_fields | |
| try: | |
| with open('variation.json', 'r+') as f: | |
| # 获取文件锁 | |
| fcntl.flock(f, fcntl.LOCK_EX) | |
| try: | |
| # 重新读取以确保获取最新数据 | |
| variation_stats = json.load(f) | |
| # 初始化如果不存在 | |
| if chart_type not in variation_stats: | |
| variation_stats[chart_type] = {"total_count": 0} | |
| if chart_name not in variation_stats[chart_type]: | |
| variation_stats[chart_type][chart_name] = 0 | |
| # 更新计数 | |
| variation_stats[chart_type][chart_name] += 1 | |
| variation_stats[chart_type]["total_count"] += 1 | |
| # 写入更新后的数据 | |
| f.seek(0) | |
| json.dump(variation_stats, f, indent=2) | |
| f.truncate() | |
| finally: | |
| # 释放文件锁 | |
| fcntl.flock(f, fcntl.LOCK_UN) | |
| except FileNotFoundError: | |
| # 如果文件不存在,创建新的variation_stats | |
| variation_stats = { | |
| chart_type: { | |
| "total_count": 1, | |
| chart_name: 1 | |
| } | |
| } | |
| with open('variation.json', 'w') as f: | |
| json.dump(variation_stats, f, indent=2) | |
| return engine, chart_type, chart_name, ordered_fields | |
| def process_template_requirements(requirements: Dict, data: Dict, engine: str, chart_name: str) -> None: | |
| """处理模板的颜色要求""" | |
| default_colors = { | |
| "text_color": "#333333", | |
| "background_color": "#ffffff", | |
| "field": {}, | |
| "other": {"primary": "#4e79a7"}, | |
| "available_colors": ["#4e79a7", "#f28e2b", "#59a14f", "#e15759", "#76b7b2", "#edc948"] | |
| } | |
| default_dark_colors = { | |
| "text_color": "#ffffff", | |
| "background_color": "#1f2933", | |
| "field": {}, | |
| "other": {"primary": "#76b7b2"}, | |
| "available_colors": ["#76b7b2", "#f28e2b", "#59a14f", "#e15759", "#9c755f", "#edc948"] | |
| } | |
| if not isinstance(data.get("colors"), dict): | |
| data["colors"] = json.loads(json.dumps(default_colors)) | |
| else: | |
| data["colors"].setdefault("text_color", default_colors["text_color"]) | |
| data["colors"].setdefault("background_color", default_colors["background_color"]) | |
| data["colors"].setdefault("field", {}) | |
| data["colors"].setdefault("other", {}) | |
| data["colors"]["other"].setdefault("primary", default_colors["other"]["primary"]) | |
| data["colors"].setdefault("available_colors", default_colors["available_colors"]) | |
| if not isinstance(data.get("colors_dark"), dict): | |
| data["colors_dark"] = json.loads(json.dumps(default_dark_colors)) | |
| else: | |
| data["colors_dark"].setdefault("text_color", default_dark_colors["text_color"]) | |
| data["colors_dark"].setdefault("background_color", default_dark_colors["background_color"]) | |
| data["colors_dark"].setdefault("field", {}) | |
| data["colors_dark"].setdefault("other", {}) | |
| data["colors_dark"]["other"].setdefault("primary", default_dark_colors["other"]["primary"]) | |
| data["colors_dark"].setdefault("available_colors", default_dark_colors["available_colors"]) | |
| colors = data["colors"] | |
| colors_dark = data["colors_dark"] | |
| if len(colors["field"]) > 1: | |
| # 检查颜色是否可区分 | |
| field_colors = list(colors["field"].values()) | |
| if has_indistinguishable_colors(field_colors): | |
| # 如果颜色不可区分,使用主色生成新的调色板 | |
| primary_color = colors["other"]["primary"] | |
| new_colors = generate_distinct_palette(primary_color, len(field_colors)) | |
| # 更新颜色字典 | |
| for i, field in enumerate(colors["field"].keys()): | |
| colors["field"][field] = new_colors[i] | |
| if len(colors_dark["field"]) > 1: | |
| # 检查颜色是否可区分 | |
| field_colors = list(colors_dark["field"].values()) | |
| if has_indistinguishable_colors(field_colors): | |
| # 如果颜色不可区分,使用主色生成新的调色板 | |
| primary_color = colors_dark["other"]["primary"] | |
| new_colors = generate_distinct_palette(primary_color, len(field_colors)) | |
| # 更新颜色字典 | |
| for i, field in enumerate(colors_dark["field"].keys()): | |
| colors_dark["field"][field] = new_colors[i] | |
| if len(requirements.get("required_other_colors", [])) > 0: | |
| for key in requirements["required_other_colors"]: | |
| if key == "positive" and "positive" not in colors["other"]: | |
| colors["other"]["positive"] = colors["other"]["primary"] | |
| elif key == "negative" and "negative" not in colors["other"]: | |
| colors["other"]["negative"] = get_contrast_color(colors["other"]["primary"]) | |
| colors_dark["text_color"] = "#ffffff" | |
| # if ('donut' in chart_name or 'pie' in chart_name) and engine == 'vegalite_py': | |
| # data["variables"]["height"] = 500 | |
| # data["variables"]["width"] = 500 | |
| # else: | |
| # if "min_height" in requirements: | |
| # data["variables"]["height"] = max(600, requirements["min_height"]) | |
| # elif 'height' in requirements: | |
| # data["variables"]["height"] = max(600, requirements["height"][0]) | |
| # if "min_width" in requirements: | |
| # data["variables"]["width"] = max(800, requirements["min_width"]) | |
| # elif 'width' in requirements: | |
| # data["variables"]["width"] = max(600, requirements["width"][0]) | |