Files
wordcloud/backend/tools/benchmark_layout.py

221 lines
9.9 KiB
Python

#!/usr/bin/env python3
"""可重复的中文词云性能与视觉质量基准。"""
from __future__ import annotations
import argparse
import json
import math
import sys
import time
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw, ImageFont
BACKEND_DIR = Path(__file__).resolve().parents[1]
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
from core import config # noqa: E402
from core.fonts import get_cached_font # noqa: E402
from core.pipeline import run_generation_pass # noqa: E402
from core.render import count_layout_overlap_pixels, scale_layout_for_hd # noqa: E402
SURNAMES = "赵钱孙李周吴郑王冯陈褚卫蒋沈韩杨朱秦尤许何吕施张孔曹严华金魏陶姜戚谢邹喻柏水窦章云苏潘葛奚范彭郎鲁韦昌马苗凤花方俞任袁柳唐罗薛雷贺倪汤滕殷罗毕郝邬安常乐于时傅皮卞齐康伍余元卜顾孟平黄和穆萧尹姚邵湛汪祁毛禹狄米贝明臧计伏成戴谈宋茅庞熊纪舒屈项祝董梁杜阮蓝闵席季麻强贾路娄危江童颜郭梅盛林刁钟徐邱骆高夏蔡田樊胡凌霍虞万支柯昝管卢莫经房裘缪干解应宗宣丁贲邓郁单杭洪包诸左石崔吉龚程嵇邢裴陆荣翁荀羊甄魏家封芮羿储靳汲邴糜松井段富巫乌焦巴弓牧隗山谷车侯宓蓬全郗班仰秋仲伊宫宁仇栾暴甘钭厉戎祖武符刘景詹束龙叶幸司韶郜黎蓟薄印宿白怀蒲台从鄂索咸籍赖卓蔺屠蒙池乔阴胥能苍双闻莘党翟谭贡劳逄姬申扶堵冉宰郦雍却璩桑桂濮牛寿通边扈燕冀郏浦尚农温别庄晏柴瞿阎充慕连茹习宦艾鱼容向古易慎戈廖庾终暨居衡步都耿满弘匡国文寇广禄阙东欧殳沃利蔚越夔隆师巩厍聂晁勾敖融冷訾辛阚那简饶空曾毋沙乜养鞠须丰巢关蒯相查后荆红游竺权逯盖益桓公"
GIVEN = "子涵宇轩梓萱浩然欣怡雨桐诗涵俊杰思远若曦嘉怡明哲一诺安然沐阳可馨奕辰语嫣皓轩晨曦梦瑶佳宁天佑书瑶瑞泽景行星辰清越知夏亦航舒雅嘉禾锦程乐言思齐云舟清欢予安望舒嘉树怀瑾景明青禾昭阳念初令仪时安向晚星野云舒允和知许南乔初晴清晏如松修远云起长风映雪听澜"
def make_names(count: int) -> list[str]:
names = []
for index in range(count):
surname = SURNAMES[index % len(SURNAMES)]
a = GIVEN[(index * 7) % len(GIVEN)]
b = GIVEN[(index * 17 + index // len(GIVEN)) % len(GIVEN)]
names.append(surname + a + b)
return names
def make_round_mask(size: int) -> np.ndarray:
image = Image.new("L", (size, size), 255)
draw = ImageDraw.Draw(image)
inset = max(8, size // 50)
draw.ellipse((inset, inset, size - inset - 1, size - inset - 1), fill=0)
return np.array(image)
def render_hd(layout, size: int, work_scale: float, output: Path) -> np.ndarray:
image = Image.new("RGB", (size, size), "white")
draw = ImageDraw.Draw(image)
hd_layout = scale_layout_for_hd(layout, work_scale)
for word, font_size, (y, x), orient, color in hd_layout:
font = get_cached_font(config.WC_FONT_PATH, font_size)
if orient:
font = ImageFont.TransposedFont(font, orientation=orient)
draw.text((x, y), word, font=font, fill=color)
image.save(output, compress_level=1)
return np.any(np.asarray(image) < 245, axis=2)
def visual_metrics(ink: np.ndarray, mask: np.ndarray) -> dict[str, float]:
free = mask == 0
true_ink = ink & free
free_area = int(free.sum())
density = float(true_ink.sum() / free_area) if free_area else 0.0
rows, cols = np.where(true_ink)
free_rows, free_cols = np.where(free)
bbox_coverage = 0.0
if rows.size and free_rows.size:
ink_h = int(rows.max() - rows.min() + 1)
ink_w = int(cols.max() - cols.min() + 1)
free_h = int(free_rows.max() - free_rows.min() + 1)
free_w = int(free_cols.max() - free_cols.min() + 1)
bbox_coverage = (ink_h * ink_w) / max(1, free_h * free_w)
# 轮廓覆盖:掩膜内 8×8 有效区域中,被真实墨迹触达的区域比例。
grid_hit = 0
grid_free = 0
height, width = free.shape
for gy in range(8):
y0, y1 = gy * height // 8, (gy + 1) * height // 8
for gx in range(8):
x0, x1 = gx * width // 8, (gx + 1) * width // 8
cell_free = free[y0:y1, x0:x1]
if not cell_free.any():
continue
grid_free += 1
if true_ink[y0:y1, x0:x1].any():
grid_hit += 1
return {
"hd_true_density": density,
"ink_bbox_coverage": float(bbox_coverage),
"contour_grid_coverage": float(grid_hit / grid_free) if grid_free else 0.0,
}
def run_case(count: int, canvas: int, output_dir: Path, max_growth_rounds: int) -> dict:
names = make_names(count)
weights = {name: 10.0 for name in names}
frequencies = [(name, 10.0) for name in names]
started = time.perf_counter()
current_canvas = canvas
growth_rounds = 0
while True:
mask = make_round_mask(current_canvas)
result = run_generation_pass(
names,
frequencies,
weights,
mask,
current_canvas,
current_canvas,
)
wc = result["wc"]
placed = len(wc.layout_) if wc is not None else 0
collision_ok = int(result.get("hd_overlap_pixels", -1)) == 0
if (placed >= count and collision_ok) or growth_rounds >= max_growth_rounds:
break
growth_rounds += 1
current_canvas = int(math.ceil(current_canvas * config.CANVAS_RETRY_GROWTH))
layout_seconds = time.perf_counter() - started
layout = wc.layout_ if wc is not None else []
png_path = output_dir / f"chinese_{count}.png"
render_started = time.perf_counter()
ink = render_hd(layout, current_canvas, config.WORK_SCALE, png_path)
render_seconds = time.perf_counter() - render_started
font_sizes = [int(item[1]) for item in layout]
hd_layout = result["hd_layout"]
metrics = {
"count": count,
"canvas": current_canvas,
"canvas_growth_rounds": growth_rounds,
"placed": len(layout),
"completeness": len(layout) / count if count else 1.0,
"layout_seconds": layout_seconds,
"render_seconds": render_seconds,
"total_seconds": layout_seconds + render_seconds,
"work_fill_ratio": float(result["fill_ratio"]),
"font_size_min": min(font_sizes) if font_sizes else 0,
"font_size_max": max(font_sizes) if font_sizes else 0,
"equal_weight_font_consistent": len(set(font_sizes)) <= 1,
"collision_margin": int(result["collision_margin"]),
"hd_clearance_shifted_words": int(result["hd_clearance"]["shifted_words"]),
"hd_clearance_max_shift": int(result["hd_clearance"]["max_shift"]),
"hd_clearance_px": int(result["hd_clearance"]["clearance_px"]),
"hd_clearance_priority_restarts": int(result["hd_clearance"]["priority_restarts"]),
"hd_overlap_pixels": count_layout_overlap_pixels(
hd_layout,
(current_canvas, current_canvas),
config.WC_FONT_PATH,
),
"largest_empty_square_work_px": int(result["largest_empty_square_work_px"]),
"largest_empty_square_font_ratio": result["largest_empty_square_font_ratio"],
"png": str(png_path),
}
metrics.update(visual_metrics(ink, mask))
return metrics
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--counts", nargs="+", type=int, default=[80, 800])
parser.add_argument("--canvas", type=int, default=2000)
parser.add_argument("--max-growth-rounds", type=int, default=1)
parser.add_argument("--assert-targets", action="store_true")
parser.add_argument("--output-dir", type=Path, default=BACKEND_DIR / "benchmark_outputs")
args = parser.parse_args()
args.output_dir.mkdir(parents=True, exist_ok=True)
config.WC_FONT_PATH = str(config.PROJECT_DEFAULT_FONT)
config.SIZE_RATIO = 1.0
config.N_REPETITIONS = 1
config.WORK_SCALE = 0.18
config.MIN_READABLE_HEIGHT_PX = 22
config.MIN_FONT_SIZE = max(1, int(config.MIN_READABLE_HEIGHT_PX * config.WORK_SCALE))
config.USER_MIN_FONT_SIZE = None
config.USER_MAX_FONT_SIZE = None
config.LAYOUT_SEED = 20260718
config.SEED = 20260718
config.TARGET_FILL_RATIO = 0.45
config.FONT_COLOR = "#102A43"
config.AUTO_REPEAT_TO_FILL = False
results = [
run_case(count, args.canvas, args.output_dir, args.max_growth_rounds)
for count in args.counts
]
if args.assert_targets:
failures = []
for item in results:
if item["completeness"] != 1.0:
failures.append(f'{item["count"]}: completeness={item["completeness"]:.4f}')
if not item["equal_weight_font_consistent"]:
failures.append(f'{item["count"]}: equal-weight font sizes differ')
if item["hd_overlap_pixels"] != 0:
failures.append(f'{item["count"]}: HD overlap pixels={item["hd_overlap_pixels"]}')
if item["contour_grid_coverage"] < 0.80:
failures.append(f'{item["count"]}: contour coverage below 0.80')
if item["hd_true_density"] < 0.10:
failures.append(f'{item["count"]}: HD true density below 0.10')
if item["count"] < 100 and item["total_seconds"] >= 1.0:
failures.append(f'{item["count"]}: total time >= 1.0s')
if item["count"] < 1000 and item["total_seconds"] >= 5.0:
failures.append(f'{item["count"]}: total time >= 5.0s')
if failures:
raise SystemExit("基准门禁失败: " + "; ".join(failures))
report = args.output_dir / "benchmark.json"
report.write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(results, ensure_ascii=False, indent=2))
print(f"报告: {report}")
if __name__ == "__main__":
main()