Rework layout engine around exact-glyph collision, add tests and docs sync
Replace the old bbox/heuristic placement (scale search rounds, large-font capping, stratified sampling, fill-retry ladders) with an area-model font sizing pass feeding a C++ exact-glyph collision engine (centroid-biased spiral + random probing, HD clearance refinement, density/hole optimization). Simplify the frontend advanced-params panel and JobParams type to match the surviving config surface, add a layout-constraints test suite and a repeatable benchmark tool, and bring docs/*.md back in sync with current code (plus new TESTING.md and DEPLOYMENT.md). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
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.ewc import EfficientWordCloud # 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, largest_empty_square_size # noqa: E402
|
||||
from core.weights import merge_weight_maps # noqa: E402
|
||||
|
||||
|
||||
def chinese_names(count: int) -> list[str]:
|
||||
surnames = "赵钱孙李周吴郑王冯陈褚卫蒋沈韩杨朱秦尤许何吕施张孔曹严华金魏陶姜"
|
||||
given = "子涵宇轩梓萱浩然欣怡雨桐诗涵俊杰思远若曦嘉怡明哲一诺安然沐阳"
|
||||
return [
|
||||
surnames[index % len(surnames)]
|
||||
+ given[(index * 3) % len(given)]
|
||||
+ given[(index * 7 + 1) % len(given)]
|
||||
for index in range(count)
|
||||
]
|
||||
|
||||
|
||||
def circle_mask(size: int) -> np.ndarray:
|
||||
image = Image.new("L", (size, size), 255)
|
||||
ImageDraw.Draw(image).ellipse((20, 20, size - 21, size - 21), fill=0)
|
||||
return np.asarray(image)
|
||||
|
||||
|
||||
class LayoutConstraintTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.saved = {
|
||||
key: getattr(config, key)
|
||||
for key in (
|
||||
"SIZE_RATIO",
|
||||
"WORK_SCALE",
|
||||
"MIN_READABLE_HEIGHT_PX",
|
||||
"MIN_FONT_SIZE",
|
||||
"USER_MIN_FONT_SIZE",
|
||||
"USER_MAX_FONT_SIZE",
|
||||
"N_REPETITIONS",
|
||||
"LAYOUT_SEED",
|
||||
"SEED",
|
||||
"TARGET_FILL_RATIO",
|
||||
"WC_FONT_PATH",
|
||||
)
|
||||
}
|
||||
config.WC_FONT_PATH = str(config.PROJECT_DEFAULT_FONT)
|
||||
config.SIZE_RATIO = 1.0
|
||||
config.WORK_SCALE = 0.18
|
||||
config.MIN_READABLE_HEIGHT_PX = 22
|
||||
config.MIN_FONT_SIZE = 3
|
||||
config.USER_MIN_FONT_SIZE = None
|
||||
config.USER_MAX_FONT_SIZE = None
|
||||
config.N_REPETITIONS = 1
|
||||
config.LAYOUT_SEED = 20260718
|
||||
config.SEED = 20260718
|
||||
config.TARGET_FILL_RATIO = 0.42
|
||||
|
||||
def tearDown(self) -> None:
|
||||
for key, value in self.saved.items():
|
||||
setattr(config, key, value)
|
||||
|
||||
def generate(self, count: int, canvas: int = 1600):
|
||||
names = chinese_names(count)
|
||||
frequencies = [(name, 10.0) for name in names]
|
||||
weights = dict(frequencies)
|
||||
result = run_generation_pass(
|
||||
names,
|
||||
frequencies,
|
||||
weights,
|
||||
circle_mask(canvas),
|
||||
canvas,
|
||||
canvas,
|
||||
)
|
||||
return names, result
|
||||
|
||||
def test_size_ratio_one_keeps_every_equal_weight_size_identical(self) -> None:
|
||||
names, result = self.generate(40)
|
||||
layout = result["wc"].layout_
|
||||
self.assertEqual(len(layout), len(names))
|
||||
self.assertEqual(len({font_size for _, font_size, *_ in layout}), 1)
|
||||
|
||||
def test_explicit_equal_min_max_is_exact(self) -> None:
|
||||
config.USER_MIN_FONT_SIZE = 12
|
||||
config.USER_MAX_FONT_SIZE = 12
|
||||
names, result = self.generate(20)
|
||||
layout = result["wc"].layout_
|
||||
self.assertEqual(len(layout), len(names))
|
||||
self.assertEqual({font_size for _, font_size, *_ in layout}, {12})
|
||||
|
||||
def test_same_weight_groups_receive_the_same_size(self) -> None:
|
||||
config.SIZE_RATIO = 2.0
|
||||
names = chinese_names(24)
|
||||
weights = {
|
||||
name: (100.0 if index < 8 else 30.0 if index < 16 else 10.0)
|
||||
for index, name in enumerate(names)
|
||||
}
|
||||
frequencies = [(name, weights[name]) for name in names]
|
||||
result = run_generation_pass(
|
||||
names,
|
||||
frequencies,
|
||||
weights,
|
||||
circle_mask(1800),
|
||||
1800,
|
||||
1800,
|
||||
)
|
||||
sizes_by_weight: dict[float, set[int]] = {}
|
||||
for name, font_size, *_ in result["wc"].layout_:
|
||||
sizes_by_weight.setdefault(weights[name], set()).add(font_size)
|
||||
self.assertEqual(len(result["wc"].layout_), len(names))
|
||||
self.assertTrue(all(len(sizes) == 1 for sizes in sizes_by_weight.values()))
|
||||
self.assertGreater(
|
||||
max(sizes_by_weight[100.0]),
|
||||
max(sizes_by_weight[10.0]),
|
||||
)
|
||||
|
||||
def test_stroke_weight_is_applied_when_excel_weights_are_flat(self) -> None:
|
||||
merged = merge_weight_maps(
|
||||
["甲", "乙", "丙"],
|
||||
{"甲": 100.0, "乙": 200.0, "丙": 300.0},
|
||||
{"甲": 1.0, "乙": 1.0, "丙": 1.0},
|
||||
)
|
||||
self.assertLess(merged["甲"], merged["乙"])
|
||||
self.assertLess(merged["乙"], merged["丙"])
|
||||
|
||||
def test_largest_empty_square_ignores_space_outside_mask(self) -> None:
|
||||
mask = np.full((7, 7), 255, dtype=np.uint8)
|
||||
mask[1:6, 1:6] = 0
|
||||
occupancy = np.zeros((7, 7), dtype=np.uint8)
|
||||
occupancy[1:3, 1:6] = 1
|
||||
self.assertEqual(largest_empty_square_size(occupancy, mask), 3)
|
||||
|
||||
def test_conflicting_explicit_font_bounds_fail(self) -> None:
|
||||
config.USER_MIN_FONT_SIZE = 13
|
||||
config.USER_MAX_FONT_SIZE = 12
|
||||
with self.assertRaisesRegex(ValueError, "字号硬约束冲突"):
|
||||
self.generate(10)
|
||||
|
||||
def test_explicit_max_overrides_automatic_readability_floor(self) -> None:
|
||||
config.USER_MAX_FONT_SIZE = 2
|
||||
names, result = self.generate(12)
|
||||
layout = result["wc"].layout_
|
||||
self.assertEqual(len(layout), len(names))
|
||||
self.assertEqual({font_size for _, font_size, *_ in layout}, {2})
|
||||
|
||||
def test_base_class_never_uses_a_private_fallback_size(self) -> None:
|
||||
words = {name: 1.0 for name in chinese_names(12)}
|
||||
wc = EfficientWordCloud(
|
||||
width=150,
|
||||
height=150,
|
||||
font_path=config.WC_FONT_PATH,
|
||||
max_words=len(words),
|
||||
min_font_size=5,
|
||||
max_font_size=30,
|
||||
prefer_horizontal=1.0,
|
||||
random_state=7,
|
||||
margin=1,
|
||||
)
|
||||
wc.generate_from_frequencies(words)
|
||||
self.assertGreater(len(wc.layout_), 0)
|
||||
self.assertEqual({font_size for _, font_size, *_ in wc.layout_}, {30})
|
||||
|
||||
def test_rendered_ink_stays_inside_mask_and_does_not_overlap(self) -> None:
|
||||
_names, result = self.generate(50)
|
||||
layout = result["wc"].layout_
|
||||
mask = result["mask_small"]
|
||||
height, width = mask.shape
|
||||
coverage = np.zeros((height, width), dtype=np.uint16)
|
||||
for word, size, (y, x), orient, _color in layout:
|
||||
image = Image.new("L", (width, height), 0)
|
||||
draw = ImageDraw.Draw(image)
|
||||
font = get_cached_font(config.WC_FONT_PATH, size)
|
||||
if orient:
|
||||
font = ImageFont.TransposedFont(font, orientation=orient)
|
||||
draw.text((x, y), word, font=font, fill=255)
|
||||
coverage += (np.asarray(image) > 0).astype(np.uint16)
|
||||
|
||||
self.assertFalse(np.any((coverage > 0) & (mask != 0)))
|
||||
self.assertLessEqual(int(coverage.max()), 1)
|
||||
|
||||
def test_hd_rendered_ink_does_not_overlap_after_scaling(self) -> None:
|
||||
names, result = self.generate(50)
|
||||
self.assertEqual(len(result["wc"].layout_), len(names))
|
||||
hd_layout = result["hd_layout"]
|
||||
overlap_pixels = count_layout_overlap_pixels(
|
||||
hd_layout,
|
||||
(1600, 1600),
|
||||
config.WC_FONT_PATH,
|
||||
)
|
||||
self.assertEqual(result["hd_overlap_pixels"], 0)
|
||||
self.assertEqual(overlap_pixels, 0)
|
||||
self.assertEqual(result["collision_margin"], 0)
|
||||
self.assertEqual(result["hd_clearance"]["failed_word"], None)
|
||||
self.assertIn(result["hd_clearance"]["clearance_px"], (0, 1))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user