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:
2026-07-26 18:32:25 +08:00
co-authored by Claude Sonnet 5
parent bf2b138007
commit 1d17b5e20d
24 changed files with 2600 additions and 1283 deletions
@@ -101,8 +101,6 @@ class EfficientWordCloud:
Probability a word is placed horizontally (01).
mode : str
PIL image mode ('RGB', 'RGBA', …).
use_spiral_search : bool
Use center-out sorted search (True) or reservoir sampling (False).
scale : float
Scaling factor between layout computation and final rendering.
``scale=2`` means the output image is 2× the canvas size in each
@@ -142,8 +140,6 @@ class EfficientWordCloud:
How much word frequency (vs rank) influences font size.
0 = rank only, 1 = fully frequency-driven.
When *repeat* is True, defaults to 0.
font_step : int
Step size when reducing font size to find a fit.
"""
def __init__(self,
@@ -156,16 +152,14 @@ class EfficientWordCloud:
background_color="black",
prefer_horizontal=0.9,
mode="RGB",
use_spiral_search=True,
scale=1,
scale=1,
contour_width=0,
contour_color="black",
margin=2,
color_func=None,
colormap=None,
random_state=None,
relative_scaling="auto",
font_step=1,
relative_scaling="auto",
repeat=False,
stopwords=None,
regexp=None,
@@ -185,7 +179,6 @@ class EfficientWordCloud:
self.background_color = background_color
self.prefer_horizontal = prefer_horizontal
self.mode = mode
self.use_spiral_search = use_spiral_search
self.scale = scale
self.contour_width = contour_width
self.contour_color = contour_color
@@ -197,7 +190,6 @@ class EfficientWordCloud:
else:
self.relative_scaling = relative_scaling
self.margin = margin
self.font_step = font_step
self.stopwords = stopwords if stopwords is not None else STOPWORDS
self.regexp = regexp
self.collocations = collocations
@@ -319,10 +311,9 @@ class EfficientWordCloud:
def _query(qh, qw):
return self.grid.query_direct(qh, qw, rs.randint(0, 2**31))
# v4: Ref-like linear step-down placement with bitmap occupancy
# After each word placement, stamp glyph bitmap into C++ canvas
# and rebuild integral for pixel-accurate collision detection.
# No PIL image drawn during placement — to_image() renders later.
# Each word is tried at exactly its weight-derived target size. A
# failed word may change orientation, but never receives a private
# fallback size. Whole-cloud scaling belongs to the caller.
# Dummy draw for textbbox measurement
_measure_img = Image.new("L", (1, 1))
@@ -344,36 +335,26 @@ class EfficientWordCloud:
else:
orientation = Image.ROTATE_90
tried_other_orientation = False
while True:
if font_size < self.min_font_size:
break
font = _get_font(font_size)
transposed = ImageFont.TransposedFont(font, orientation=orientation)
bbox = _measure_draw.textbbox((0, 0), word, font=transposed)
tw = bbox[2] - bbox[0]
th = bbox[3] - bbox[1]
pos = None
orientations = [orientation]
if self.prefer_horizontal < 1:
orientations.append(Image.ROTATE_90 if orientation is None else None)
for candidate_orientation in orientations:
font = _get_font(font_size)
transposed = ImageFont.TransposedFont(font, orientation=candidate_orientation)
bbox = _measure_draw.textbbox((0, 0), word, font=transposed)
tw = bbox[2] - bbox[0]
th = bbox[3] - bbox[1]
qh = th + self.margin
qw = tw + self.margin
pos = _query(qh, qw)
if pos is not None:
break
# No position found — try alternate orientation, then reduce size
if not tried_other_orientation and self.prefer_horizontal < 1:
orientation = Image.ROTATE_90 if orientation is None else None
tried_other_orientation = True
else:
font_size -= self.font_step
orientation = None
tried_other_orientation = False
if font_size < self.min_font_size:
# Canvas full — no more words can fit
break
pos = _query(qh, qw)
if pos is not None:
orientation = candidate_orientation
break
if pos is None:
continue
y, x = pos
# Adjust position for margin (like ref: x,y += margin // 2)