Files
broccoliandClaude Sonnet 5 1d17b5e20d 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>
2026-07-26 18:32:25 +08:00

612 lines
24 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import numpy as np
import random as _random
from random import Random
import colorsys
from PIL import Image, ImageDraw, ImageFont, ImageFilter
import re
import os
import sys
from .ewc_core import IntegralGrid
from .tokenization import process_tokens, unigrams_and_bigrams
_FILE = os.path.dirname(__file__)
_STOPWORDS_PATH = os.path.join(_FILE, "stopwords")
def _load_stopwords():
if os.path.exists(_STOPWORDS_PATH):
with open(_STOPWORDS_PATH, encoding="utf-8") as f:
return set(map(str.strip, f))
# Fallback: minimal English stopwords so the module still works without the file
return {"the", "a", "an", "and", "or", "but", "in", "on", "at", "to",
"for", "of", "with", "is", "are", "was", "were", "be", "been",
"it", "its", "this", "that", "i", "you", "he", "she", "we", "they"}
STOPWORDS = _load_stopwords()
# ---------------------------------------------------------------------------
# Color functions (mirrors ref/word_cloud/wordcloud/wordcloud.py)
# ---------------------------------------------------------------------------
def random_color_func(word=None, font_size=None, position=None,
orientation=None, font_path=None, random_state=None):
"""Random hue color generation (HSL, saturation=80%, lightness=50%)."""
if random_state is None:
random_state = Random()
return "hsl(%d, 80%%, 50%%)" % random_state.randint(0, 255)
class colormap_color_func:
"""Color function backed by a matplotlib colormap."""
def __init__(self, colormap):
import matplotlib.pyplot as plt
self.colormap = plt.get_cmap(colormap)
def __call__(self, word, font_size, position, orientation,
random_state=None, **kwargs):
if random_state is None:
random_state = Random()
r, g, b, _ = np.maximum(0, 255 * np.array(
self.colormap(random_state.uniform(0, 1))))
return "rgb({:.0f}, {:.0f}, {:.0f})".format(r, g, b)
def get_single_color_func(color):
"""Return a color func that varies only the HSV value for a given color.
Accepted values are PIL/Pillow color strings, e.g. 'deepskyblue', '#00b4d2'.
"""
from PIL import ImageColor
old_r, old_g, old_b = ImageColor.getrgb(color)
h, s, v = colorsys.rgb_to_hsv(old_r / 255., old_g / 255., old_b / 255.)
def single_color_func(word=None, font_size=None, position=None,
orientation=None, font_path=None, random_state=None):
if random_state is None:
random_state = Random()
r, g, b = colorsys.hsv_to_rgb(h, s, random_state.uniform(0.2, 1))
return "rgb({:.0f}, {:.0f}, {:.0f})".format(r * 255, g * 255, b * 255)
return single_color_func
import logging
class EfficientWordCloud:
"""
EfficientWordCloud Generation Class.
Uses C++ backend (ewc_core) for high-performance collision detection.
Optimized for high-resolution generation (4k/8k+).
Parameters
----------
width, height : int
Canvas size (ignored when mask is provided).
mask : ndarray or None
Shape mask. White pixels (255) are treated as blocked, others as free.
font_path : str or None
Path to a TrueType font file.
max_words : int
Maximum number of words to place.
min_font_size : int
Smallest font size to use.
max_font_size : int or None
Largest font size. Derived automatically when None.
background_color : color
PIL-compatible background color.
prefer_horizontal : float
Probability a word is placed horizontally (01).
mode : str
PIL image mode ('RGB', 'RGBA', …).
scale : float
Scaling factor between layout computation and final rendering.
``scale=2`` means the output image is 2× the canvas size in each
dimension while layout is still computed at base resolution.
Equivalent to ref's ``scale`` parameter.
contour_width : float
If > 0 and mask is set, draw the mask contour on the output image.
contour_color : color
PIL-compatible color for the mask contour (default 'black').
margin : int
Pixel gap between words.
stopwords : set of str or None
Words to exclude when processing text. Defaults to built-in STOPWORDS.
regexp : str or None
Override the regex used to tokenize text (default ``r"\\w[\\w']+"``).
collocations : bool
Whether to detect bigrams (default True).
collocation_threshold : int
Dunning score threshold for bigrams (default 30).
normalize_plurals : bool
Strip trailing 's' to merge plurals (default True).
include_numbers : bool
Keep numeric tokens when processing text (default False).
min_word_length : int
Minimum character length for a token to be kept (default 0).
color_func : callable or None
``color_func(word, font_size, position, orientation, font_path,
random_state) -> color``. Overrides *colormap*.
colormap : str or matplotlib colormap or None
Matplotlib colormap used when *color_func* is None.
random_state : int, Random, or None
Seed for reproducibility.
repeat : bool
If True, repeat words (with decreasing weight) until *max_words* or
*min_font_size* is reached (default False).
relative_scaling : float (01)
How much word frequency (vs rank) influences font size.
0 = rank only, 1 = fully frequency-driven.
When *repeat* is True, defaults to 0.
"""
def __init__(self,
width=400, height=200,
mask=None,
font_path=None,
max_words=200,
min_font_size=4,
max_font_size=None,
background_color="black",
prefer_horizontal=0.9,
mode="RGB",
scale=1,
contour_width=0,
contour_color="black",
margin=2,
color_func=None,
colormap=None,
random_state=None,
relative_scaling="auto",
repeat=False,
stopwords=None,
regexp=None,
collocations=True,
collocation_threshold=30,
normalize_plurals=True,
include_numbers=False,
min_word_length=0):
self.width = width
self.height = height
self.mask = mask
self.font_path = font_path
self.max_words = max_words
self.min_font_size = min_font_size
self.max_font_size = max_font_size
self.background_color = background_color
self.prefer_horizontal = prefer_horizontal
self.mode = mode
self.scale = scale
self.contour_width = contour_width
self.contour_color = contour_color
self.repeat = repeat
# relative_scaling default mirrors ref: 0 when repeat, else 0.5
if relative_scaling == "auto":
self.relative_scaling = 0 if repeat else 0.5
else:
self.relative_scaling = relative_scaling
self.margin = margin
self.stopwords = stopwords if stopwords is not None else STOPWORDS
self.regexp = regexp
self.collocations = collocations
self.collocation_threshold = collocation_threshold
self.normalize_plurals = normalize_plurals
self.include_numbers = include_numbers
self.min_word_length = min_word_length
# Random state
if isinstance(random_state, int):
self.random_state = Random(random_state)
elif random_state is None:
self.random_state = Random()
else:
self.random_state = random_state
# Color function
if color_func is not None:
self.color_func = color_func
elif colormap is not None:
self.color_func = colormap_color_func(colormap)
else:
self.color_func = random_color_func
self.layout_ = []
# Handle mask
if self.mask is not None:
self.width = self.mask.shape[1]
self.height = self.mask.shape[0]
if self.mask.dtype == bool:
self.boolean_mask = self.mask.astype(np.uint8) * 255
elif self.mask.ndim == 3:
# White pixels (all channels == 255) are blocked
self.boolean_mask = np.where(
np.all(self.mask[:, :, :3] == 255, axis=-1), 255, 0
).astype(np.uint8)
else:
self.boolean_mask = self.mask.astype(np.uint8)
else:
self.boolean_mask = np.zeros((self.height, self.width), dtype=np.uint8)
# Initialize C++ grid (>0 = occupied)
self.grid = IntegralGrid(self.boolean_mask, self.height, self.width)
def generate_from_frequencies(self, frequencies, max_font_size=None):
"""Generate word cloud from a dict of {word: frequency}.
Parameters
----------
frequencies : dict
max_font_size : int or None
Override self.max_font_size for this call (used internally for
the automatic font-size estimation).
"""
sorted_freq = sorted(frequencies.items(), key=lambda x: x[1], reverse=True)
if not sorted_freq:
raise ValueError("Need at least 1 word to generate a word cloud.")
sorted_freq = sorted_freq[:self.max_words]
# Normalize so the top word = 1.0
max_freq = float(sorted_freq[0][1])
sorted_freq = [(w, f / max_freq) for w, f in sorted_freq]
# --- repeat: pad list up to max_words with down-weighted copies ---
if self.repeat and len(sorted_freq) < self.max_words:
import math
times_extend = math.ceil(self.max_words / len(sorted_freq)) - 1
base = list(sorted_freq)
downweight = base[-1][1]
for i in range(times_extend):
factor = downweight ** (i + 1)
sorted_freq.extend([(w, f * factor) for w, f in base])
sorted_freq = sorted_freq[:self.max_words]
self.words_ = dict(sorted_freq)
# --- auto max_font_size estimation (mirrors ref) ---
effective_max = max_font_size if max_font_size is not None else self.max_font_size
if effective_max is None:
if len(sorted_freq) == 1:
effective_max = self.height
else:
# Trial run with just the first 2 words to estimate a good max size.
# We must reinitialize the grid after so it is clean for the real run.
_repeat_bak = self.repeat
self.repeat = False
self.generate_from_frequencies(dict(sorted_freq[:2]),
max_font_size=self.height)
self.repeat = _repeat_bak
sizes = [s for _, s, *_ in self.layout_]
try:
effective_max = int(2 * sizes[0] * sizes[1] / (sizes[0] + sizes[1]))
except (IndexError, ZeroDivisionError):
effective_max = sizes[0] if sizes else self.height
# Reinitialize the C++ grid so the trial run does not consume space
self.grid = IntegralGrid(self.boolean_mask, self.height, self.width)
rs = self.random_state
# No PIL image needed during placement — C++ canvas handles collision.
# The PIL image is constructed lazily in to_image().
self.layout_ = []
font_size = int(effective_max)
last_freq = 1.0
# E1: font object cache {size -> ImageFont}
font_cache: dict = {}
def _get_font(size):
if size not in font_cache:
try:
font_cache[size] = ImageFont.truetype(self.font_path, size)
except IOError:
font_cache[size] = ImageFont.load_default()
return font_cache[size]
def _query(qh, qw):
return self.grid.query_direct(qh, qw, rs.randint(0, 2**31))
# 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))
_measure_draw = ImageDraw.Draw(_measure_img)
for idx, (word, freq) in enumerate(sorted_freq):
if freq == 0:
continue
# Relative-scaling font size adjustment (mirrors ref logic)
rs_val = self.relative_scaling
if rs_val != 0:
font_size = int(round(
(rs_val * (freq / float(last_freq)) + (1 - rs_val)) * font_size
))
if rs.random() < self.prefer_horizontal:
orientation = None
else:
orientation = Image.ROTATE_90
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:
orientation = candidate_orientation
break
if pos is None:
continue
y, x = pos
# Adjust position for margin (like ref: x,y += margin // 2)
draw_x = x + self.margin // 2
draw_y = y + self.margin // 2
# Get glyph bitmap and stamp into C++ canvas
font = _get_font(font_size)
transposed = ImageFont.TransposedFont(font, orientation=orientation)
glyph_mask = transposed.getmask(word, mode="L")
gw, gh = glyph_mask.size
glyph_arr = np.frombuffer(bytes(glyph_mask), dtype=np.uint8).reshape(gh, gw)
# Stamp glyph into C++ canvas + rebuild integral
self.grid.stamp_and_rebuild(glyph_arr, gh, gw, draw_y, draw_x)
color = self.color_func(
word=word,
font_size=font_size,
position=(y, x),
orientation=orientation,
font_path=self.font_path,
random_state=rs,
)
self.layout_.append((word, font_size, (y, x), orientation, color))
last_freq = freq
return self
def generate(self, text):
"""Generate word cloud from raw text (calls process_text + generate_from_frequencies)."""
return self.generate_from_text(text)
def generate_from_text(self, text):
"""Process *text* into word frequencies, then generate the word cloud."""
words = self.process_text(text)
self.generate_from_frequencies(words)
return self
def process_text(self, text):
"""Tokenize *text* and return ``{word: count}`` after filtering.
Applies regexp splitting, stopword removal, number/length filters,
plural normalization, and optional bigram collocation detection.
"""
min_len = self.min_word_length
pattern = r"\w[\w']+" if min_len <= 1 else r"\w[\w']+"
regexp = self.regexp if self.regexp is not None else pattern
words = re.findall(regexp, text)
# Strip possessive 's
words = [w[:-2] if w.lower().endswith("'s") else w for w in words]
if not self.include_numbers:
words = [w for w in words if not w.isdigit()]
if self.min_word_length:
words = [w for w in words if len(w) >= self.min_word_length]
stopwords_lower = {s.lower() for s in self.stopwords}
if self.collocations:
word_counts = unigrams_and_bigrams(
words, stopwords_lower,
normalize_plurals=self.normalize_plurals,
collocation_threshold=self.collocation_threshold,
)
else:
words = [w for w in words if w.lower() not in stopwords_lower]
word_counts, _ = process_tokens(words, self.normalize_plurals)
self.words_ = word_counts
return word_counts
def to_image(self):
"""Render the layout to a PIL Image, respecting *scale* and *contour*."""
s = self.scale
out_w = int(self.width * s)
out_h = int(self.height * s)
img = Image.new(self.mode, (out_w, out_h), self.background_color)
draw = ImageDraw.Draw(img)
for word, size, (y, x), orient, color in self.layout_:
try:
font = ImageFont.truetype(self.font_path, int(size * s))
except Exception:
font = ImageFont.load_default()
transposed_font = ImageFont.TransposedFont(font, orientation=orient)
draw.text((int(x * s), int(y * s)), word, font=transposed_font, fill=color)
return self._draw_contour(img)
def _draw_contour(self, img):
"""Draw mask contour on *img* if contour_width > 0."""
if self.mask is None or self.contour_width == 0:
return img
# Build boolean mask: True where drawing area (not blocked)
if self.mask.ndim == 3:
blocked = np.all(self.mask[:, :, :3] == 255, axis=-1)
else:
blocked = self.mask == 255
mask_uint8 = (~blocked).astype(np.uint8) * 255
contour = Image.fromarray(mask_uint8)
contour = contour.resize(img.size)
contour = contour.filter(ImageFilter.FIND_EDGES)
contour_arr = np.array(contour)
# Zero out border pixels so edges aren't drawn at image boundary
contour_arr[[0, -1], :] = 0
contour_arr[:, [0, -1]] = 0
# Gaussian blur controls perceived width (divide by 10 for sub-pixel)
radius = self.contour_width / 10
contour = Image.fromarray(contour_arr)
contour = contour.filter(ImageFilter.GaussianBlur(radius=radius))
contour_arr = np.array(contour) > 0
contour_3d = np.dstack([contour_arr] * 3)
result = np.array(img.convert("RGB")) * ~contour_3d
if self.contour_color != "black":
color_img = Image.new("RGB", img.size, self.contour_color)
result = result + np.array(color_img) * contour_3d
out = Image.fromarray(result.astype(np.uint8))
if self.mode == "RGBA":
out = out.convert("RGBA")
return out
def to_array(self, copy=None):
"""Return the word cloud as a numpy ndarray (H x W x channels)."""
image = self.to_image()
if copy is None:
return np.asarray(image)
try:
return np.asarray(image, copy=copy)
except TypeError:
return np.asarray(image)
def __array__(self, copy=None):
return self.to_array(copy=copy)
def to_file(self, filename):
"""Save to *filename* and return self (for chaining)."""
img = self.to_image()
img.save(filename, optimize=True)
return self
def recolor(self, random_state=None, color_func=None, colormap=None):
"""Re-apply colors to the current layout without regenerating it.
Parameters
----------
random_state : int, Random, or None
color_func : callable or None
colormap : str or matplotlib colormap or None
"""
if isinstance(random_state, int):
random_state = Random(random_state)
elif random_state is None:
random_state = Random()
if color_func is None:
if colormap is not None:
color_func = colormap_color_func(colormap)
else:
color_func = self.color_func
self.layout_ = [
(word, font_size, position, orientation,
color_func(word=word, font_size=font_size, position=position,
orientation=orientation, font_path=self.font_path,
random_state=random_state))
for word, font_size, position, orientation, _ in self.layout_
]
return self
def to_svg(self, filename=None):
"""Export as SVG with scale, correct rotation transforms and XML escaping.
Parameters
----------
filename : str or None
If given, write to this file. Otherwise return the SVG string.
"""
from xml.sax import saxutils
s = self.scale
out_w = int(self.width * s)
out_h = int(self.height * s)
# Derive font metadata from the actual font file
try:
_font_probe = ImageFont.truetype(self.font_path, 12)
raw_family, raw_style = _font_probe.getname()
except Exception:
raw_family, raw_style = "sans-serif", "Regular"
raw_style_lower = raw_style.lower()
font_weight = "bold" if "bold" in raw_style_lower else "normal"
if "italic" in raw_style_lower:
font_style = "italic"
elif "oblique" in raw_style_lower:
font_style = "oblique"
else:
font_style = "normal"
font_family = repr(raw_family)
lines = [
f'<svg width="{out_w}" height="{out_h}" xmlns="http://www.w3.org/2000/svg">',
f'<style>text{{font-family:{font_family};font-weight:{font_weight};'
f'font-style:{font_style};}}</style>',
]
if self.background_color is not None:
lines.append(
f'<rect width="100%" height="100%" style="fill:{self.background_color}"/>'
)
for word, size, (y, x), orient, color in self.layout_:
scaled_size = int(size * s)
try:
font = ImageFont.truetype(self.font_path, scaled_size)
except Exception:
font = ImageFont.load_default()
(size_x, size_y), (offset_x, offset_y) = font.font.getsize(word)
ascent, _ = font.getmetrics()
min_x = -offset_x
max_x = size_x - offset_x
max_y = ascent - offset_y
sx = int(x * s)
sy = int(y * s)
if orient == Image.ROTATE_90:
tx = sx + max_y
ty = sy + max_x - min_x
transform = f"translate({tx},{ty}) rotate(-90)"
else:
tx = sx + min_x
ty = sy + max_y
transform = f"translate({tx},{ty})"
lines.append(
f'<text transform="{transform}" font-size="{scaled_size}" '
f'style="fill:{color}">{saxutils.escape(word)}</text>'
)
lines.append("</svg>")
svg_str = "\n".join(lines)
if filename is not None:
with open(filename, "w", encoding="utf-8") as f:
f.write(svg_str)
return svg_str