Files
wordcloud/backend/core/layout.py
T
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

682 lines
29 KiB
Python

import math
import random
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw, ImageFilter, ImageFont
from matplotlib.path import Path as MplPath
from matplotlib.transforms import Affine2D
from . import config
from .ewc import EfficientWordCloud
from .fonts import get_cached_font, get_font_properties
# ── Fast SVG path cache (fontTools outlines, unscaled per-char) ──────────────
# font_path -> (glyph_set, cmap, units_per_em)
_FT_FONT_CACHE = {}
# (font_path, char) -> (svg_path_d_in_font_units, advance_width)
_FT_CHAR_PATH_CACHE = {}
# (font_path, word, size, orient) -> (path_d_scaled, tx0, ty0)
_SVG_SHAPE_CACHE = {}
def _load_ft_font(font_path):
cached = _FT_FONT_CACHE.get(font_path)
if cached is not None:
return cached
from fontTools.ttLib import TTFont
# .ttc collections: try face 0 first
try:
tt = TTFont(font_path, fontNumber=0)
except TypeError:
tt = TTFont(font_path)
glyph_set = tt.getGlyphSet()
cmap = tt.getBestCmap() or {}
units = tt["head"].unitsPerEm
cached = (tt, glyph_set, cmap, units)
_FT_FONT_CACHE[font_path] = cached
return cached
def build_svg_text_path_cached(word, size, x, y, font_path, orient):
"""Return (path_d, tx, ty, None). Geometry is cached for identical glyphs.
Path is in y-up font space (same as matplotlib TextPath). Caller applies
translate(tx, ty) scale(1, -1) to place it on the canvas.
"""
key = (font_path, word, int(size), bool(orient))
cached = _SVG_SHAPE_CACHE.get(key)
if cached is None:
try:
cached = _build_shape_fonttools(word, size, font_path, orient)
except Exception:
cached = _build_shape_matplotlib(word, size, font_path, orient)
_SVG_SHAPE_CACHE[key] = cached
if len(_SVG_SHAPE_CACHE) > 20000:
for i, k in enumerate(list(_SVG_SHAPE_CACHE.keys())):
if i % 2 == 0:
_SVG_SHAPE_CACHE.pop(k, None)
path_d, tx0, ty0 = cached
return path_d, tx0 + x, ty0 + y, None
def _build_shape_fonttools(word, size, font_path, orient):
from fontTools.pens.svgPathPen import SVGPathPen
from fontTools.pens.transformPen import TransformPen
from fontTools.misc.transform import Transform
_tt, glyph_set, cmap, units = _load_ft_font(font_path)
scale = float(size) / float(units)
pen = SVGPathPen(glyph_set)
cursor = 0.0
for ch in word:
gname = cmap.get(ord(ch))
if not gname or gname not in glyph_set:
continue
glyph = glyph_set[gname]
if orient:
# Horizontal layout then rotate -90° around origin:
# point (px, py) in string space -> after scale: (s*px, s*py)
# rotate -90: (s*py, -s*px). Compose with glyph origin at cursor:
# glyph local (gx,gy) -> (scale*gx + cursor, scale*gy)
# -> rotate -90: (scale*gy, -(scale*gx + cursor)) = (scale*gy, -scale*gx - cursor)
# matrix: x' = 0*gx + scale*gy + 0; y' = -scale*gx + 0*gy - cursor
# Transform(xx, xy, yx, yy, dx, dy): x' = xx*x + xy*y + dx; y' = yx*x + yy*y + dy
# xx=0, xy=scale, yx=-scale, yy=0, dx=0, dy=-cursor
tp = TransformPen(pen, Transform(0, -scale, scale, 0, 0, -cursor))
else:
tp = TransformPen(pen, Transform(scale, 0, 0, scale, cursor, 0))
glyph.draw(tp)
cursor += float(glyph.width) * scale
path_d = pen.getCommands()
if not path_d:
return "", 0.0, 0.0
xmin, ymin, xmax, ymax = _path_bbox(path_d)
# Offsets placing glyph top-left at (0,0) under translate(tx,ty) scale(1,-1)
tx0 = -xmin
ty0 = ymax
return path_d, tx0, ty0
def _path_bbox(path_d):
import re
nums = [float(n) for n in re.findall(r"[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?", path_d)]
# This is approximate (includes arc radii etc.) but good enough for placement offsets
# Better: parse properly. For font outlines, commands are mostly M/L/Q/C/Z with coords.
xs, ys = [], []
tokens = re.findall(r"[A-Za-z]|[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?", path_d)
i = 0
while i < len(tokens):
t = tokens[i]
if t.isalpha():
cmd = t
i += 1
if cmd in "Zz":
continue
if cmd in "Hh":
while i < len(tokens) and not tokens[i].isalpha():
xs.append(float(tokens[i])); i += 1
elif cmd in "Vv":
while i < len(tokens) and not tokens[i].isalpha():
ys.append(float(tokens[i])); i += 1
elif cmd in "Aa":
while i + 6 < len(tokens) and not tokens[i].isalpha():
xs.append(float(tokens[i + 5])); ys.append(float(tokens[i + 6])); i += 7
else:
while i + 1 < len(tokens) and not tokens[i].isalpha():
xs.append(float(tokens[i])); ys.append(float(tokens[i + 1])); i += 2
else:
i += 1
if not xs or not ys:
return 0.0, 0.0, 0.0, 0.0
return min(xs), min(ys), max(xs), max(ys)
def _build_shape_matplotlib(word, size, font_path, orient):
from matplotlib.textpath import TextPath
path = TextPath((0, 0), word, prop=get_font_properties(font_path, size), size=size)
if orient:
path = path.transformed(Affine2D().rotate_deg(-90))
bbox = path.get_extents()
tx0 = -bbox.xmin
ty0 = bbox.ymax
path_d = mpl_path_to_svg_d(path)
return path_d, tx0, ty0
def build_svg_text_path(word, size, x, y, font_path, orient):
path_d, tx, ty, _ = build_svg_text_path_cached(word, size, x, y, font_path, orient)
return path_d, tx, ty, None
def normalize_relative_scores(values):
if not values:
return []
v_min = min(values)
v_max = max(values)
if math.isclose(v_min, v_max):
# Equal weights should produce a neutral, equal hierarchy. Returning
# 1.0 made every word request the maximum size and later words were
# arbitrarily shrunk by placement order.
return [0.5 for _ in values]
scale = v_max - v_min
return [(value - v_min) / scale for value in values]
def build_log_rank_scores(freq_list, *, per_word=False):
if not freq_list:
return []
if per_word:
word_weights = {}
for word, freq in freq_list:
f = max(float(freq), 1e-6)
if word not in word_weights or f > word_weights[word]:
word_weights[word] = f
unique_weights = sorted(set(word_weights.values()), reverse=True)
if len(unique_weights) <= 1:
word_scores = {w: 0.5 for w in word_weights}
else:
log_vals = [math.log1p(w) for w in unique_weights]
normed = normalize_relative_scores(log_vals)
weight_to_score = dict(zip(unique_weights, normed))
word_scores = {w: weight_to_score[weight] for w, weight in word_weights.items()}
return [word_scores.get(w, 0.5) for w, _ in freq_list]
safe_freqs = [max(float(freq), 1e-6) for _word, freq in freq_list]
log_scores = normalize_relative_scores([math.log1p(freq) for freq in safe_freqs])
return log_scores
def pick_palette_color(relative_score):
if config.FONT_COLOR:
return config.FONT_COLOR
palette = config.LIGHT_COLOR_PALETTE if config.FILL_ON == "WHITE" else config.DARK_COLOR_PALETTE
if not palette:
return "#111111"
idx = min(len(palette) - 1, max(0, int(round((1.0 - relative_score) * (len(palette) - 1)))))
return palette[idx]
def _build_layout_sequence(sorted_freq, max_words, layout_seed):
if max_words <= 0 or not sorted_freq:
return []
rng = random.Random(layout_seed)
base_words = list(sorted_freq)
sequence = []
while len(sequence) < max_words:
round_items = []
start = 0
while start < len(base_words):
end = start + 1
weight = float(base_words[start][1])
while end < len(base_words) and math.isclose(float(base_words[end][1]), weight):
end += 1
# Start every equal-weight group from a canonical order before
# shuffling. A fixed seed must therefore give the same layout
# regardless of the row order in the uploaded workbook.
group = sorted(base_words[start:end], key=lambda item: str(item[0]))
rng.shuffle(group)
round_items.extend(group)
start = end
remaining = max_words - len(sequence)
sequence.extend(round_items[:remaining])
return sequence
class OptimizedEfficientWordCloud(EfficientWordCloud):
def generate_from_frequencies(self, frequencies):
if isinstance(frequencies, dict):
freq_list = list(frequencies.items())
elif isinstance(frequencies, list):
freq_list = frequencies
else:
raise ValueError("frequencies 必须是字典或 (word, freq) 列表")
sorted_freq = sorted(freq_list, key=lambda item: (-float(item[1]), str(item[0])))
layout_seed = getattr(self, "layout_seed", config.LAYOUT_SEED)
layout_sequence = _build_layout_sequence(
sorted_freq,
self.max_words,
layout_seed,
)
if not layout_sequence:
return self
self.layout_ = []
per_word_scores = build_log_rank_scores(freq_list, per_word=True)
word_to_score = {}
for (w, _f), s in zip(freq_list, per_word_scores):
if w not in word_to_score or s > word_to_score[w]:
word_to_score[w] = s
score_by_index = [word_to_score.get(w, 0.5) for w, _ in layout_sequence]
seed = layout_seed if layout_seed is not None else config.SEED
rng = np.random.default_rng(seed)
rotation_flags = [bool(rng.random() > self.prefer_horizontal) for _ in layout_sequence]
# Dummy draw for textbbox measurement (no actual PIL image needed during placement)
_measure_img = Image.new("L", (1, 1))
_measure_draw = ImageDraw.Draw(_measure_img)
# (word, size, rotate) -> exact collision and drawing geometry.
# The C++ canvas stores the same tight glyph bitmap that PIL renders;
# bbox bearings are carried separately so HD output cannot drift away
# from the collision map.
glyph_cache = {}
def measure_and_mask(word, size, rotate):
key = (word, size, rotate)
cached = glyph_cache.get(key)
if cached is not None:
return cached
font = get_cached_font(self.font_path, size)
orientation = Image.ROTATE_90 if rotate else None
transposed = ImageFont.TransposedFont(font, orientation=orientation) if orientation else font
bbox = _measure_draw.textbbox((0, 0), word, font=transposed)
glyph_mask = transposed.getmask(word, mode="L")
gw, gh = glyph_mask.size
if gw <= 0 or gh <= 0:
return None
# np.array avoids the intermediate bytes() copy that
# frombuffer(bytes(...)) would incur.
glyph_arr = np.array(glyph_mask, dtype=np.uint8).reshape(gh, gw)
pad = max(0, int(self.margin))
if pad:
padded = np.zeros((gh + 2 * pad, gw + 2 * pad), dtype=np.uint8)
padded[pad:pad + gh, pad:pad + gw] = glyph_arr
# Reserve a true inter-glyph margin while still allowing
# transparent corners and stroke gaps to interlock.
collision_arr = np.asarray(
Image.fromarray(padded).filter(ImageFilter.MaxFilter(2 * pad + 1)),
dtype=np.uint8,
)
stamp_arr = padded
else:
collision_arr = glyph_arr
stamp_arr = glyph_arr
result = (
collision_arr.shape[0],
collision_arr.shape[1],
collision_arr,
stamp_arr,
orientation,
int(bbox[0]),
int(bbox[1]),
pad,
)
glyph_cache[key] = result
return result
min_font = max(config.MIN_FONT_FLOOR, int(self.min_font_size))
max_font = max(min_font, int(self.max_font_size))
base_span = max_font - min_font
target_font_sizes = []
for score in score_by_index:
raw_size = min_font + base_span * score
f_size = min(max_font, max(min_font, int(round(raw_size))))
target_font_sizes.append(f_size)
random_large_prefix = max(1, int(math.ceil(len(layout_sequence) * 0.08)))
for idx, (word, _freq) in enumerate(layout_sequence):
font_size = target_font_sizes[idx]
placed = False
rotate = rotation_flags[idx]
for try_rotate in (rotate, not rotate):
measured = measure_and_mask(word, font_size, try_rotate)
if measured is None:
continue
(
query_h,
query_w,
collision_arr,
stamp_arr,
orientation,
bbox_left,
bbox_top,
pad,
) = measured
query_seed = int(rng.integers(0, 2**31))
large_word = idx < random_large_prefix or score_by_index[idx] >= 0.80
placement_mode = 2 if large_word else 1
pos = self.grid.place_glyph_exact(
collision_arr,
stamp_arr,
query_h,
query_w,
query_seed,
256,
placement_mode,
)
if pos is None:
continue
y, x = pos
ink_y = y + pad
ink_x = x + pad
draw_y = ink_y - bbox_top
draw_x = ink_x - bbox_left
color = pick_palette_color(score_by_index[idx])
self.layout_.append((word, font_size, (draw_y, draw_x), orientation, color))
placed = True
break
# Deliberately do not shrink an individual word. The pipeline
# treats a short layout as a failed batch and retries every word
# at one uniformly scaled size range.
return self
def to_image(self):
img = Image.new(self.mode, (self.width, self.height), self.background_color)
draw = ImageDraw.Draw(img)
for word, size, (y, x), orient, color in self.layout_:
font = get_cached_font(self.font_path, size)
if orient:
font = ImageFont.TransposedFont(font, orientation=orient)
draw.text((x, y), word, font=font, fill=color)
return img
def _iter_svg_paths(self):
"""Build SVG path data once per layout entry, with glyph-shape cache."""
# Cache by (word, size, orient): path geometry is identical; only translate differs.
shape_cache = {}
for word, size, (y, x), orient, color in self.layout_:
key = (word, int(size), bool(orient))
cached = shape_cache.get(key)
if cached is None:
try:
path_d, origin_tx, origin_ty, bbox = build_svg_text_path_cached(
word, size, 0, 0, self.font_path, orient
)
except Exception as exc:
config._warn(f"SVG path 导出失败,跳过词条: {word}, error={exc}")
continue
# origin_tx/ty place the glyph so its top-left is at (0,0)
shape_cache[key] = (path_d, origin_tx, origin_ty)
cached = shape_cache[key]
path_d, origin_tx, origin_ty = cached
# Shift from (0,0) origin to actual layout position
tx = origin_tx + x
ty = origin_ty + y
yield path_d, tx, ty, color
def export_svgs(self, fill_filename, stroke_color="#000000", stroke_width=1.0):
"""Write fill + stroke SVG in one pass (path geometry built once)."""
stroke_filename = str(
Path(fill_filename).with_name(Path(fill_filename).stem + "_stroke" + Path(fill_filename).suffix)
)
background = self.background_color
header = (
f'<svg width="{self.width}" height="{self.height}" viewBox="0 0 {self.width} {self.height}" '
f'xmlns="http://www.w3.org/2000/svg">\n'
)
with open(fill_filename, "w", encoding="utf-8") as ff, open(stroke_filename, "w", encoding="utf-8") as sf:
ff.write(header)
sf.write(header)
ff.write(f'<rect width="100%" height="100%" fill="{background}"/>\n')
sf.write('<rect width="100%" height="100%" fill="none"/>\n')
for path_d, tx, ty, color in self._iter_svg_paths():
transform = f'translate({tx:.3f} {ty:.3f}) scale(1 -1)'
ff.write(f'<path d="{path_d}" transform="{transform}" fill="{color}"/>\n')
sf.write(
f'<path d="{path_d}" transform="{transform}" '
f'fill="none" stroke="{stroke_color}" stroke-width="{stroke_width}" '
f'stroke-linejoin="round" stroke-linecap="round"/>\n'
)
ff.write("</svg>\n")
sf.write("</svg>\n")
return stroke_filename
def to_svg(self, filename):
background = self.background_color
with open(filename, "w", encoding="utf-8") as f:
f.write(
f'<svg width="{self.width}" height="{self.height}" viewBox="0 0 {self.width} {self.height}" '
f'xmlns="http://www.w3.org/2000/svg">\n'
)
f.write(f'<rect width="100%" height="100%" fill="{background}"/>\n')
for path_d, tx, ty, color in self._iter_svg_paths():
f.write(f'<path d="{path_d}" transform="translate({tx:.3f} {ty:.3f}) scale(1 -1)" fill="{color}"/>\n')
f.write("</svg>\n")
def to_svg_stroke(self, filename, stroke_color="#000000", stroke_width=1.0):
"""生成描边版 SVG,适合激光雕刻机使用(描边路径,无填充)。"""
with open(filename, "w", encoding="utf-8") as f:
f.write(
f'<svg width="{self.width}" height="{self.height}" viewBox="0 0 {self.width} {self.height}" '
f'xmlns="http://www.w3.org/2000/svg">\n'
)
f.write('<rect width="100%" height="100%" fill="none"/>\n')
for path_d, tx, ty, _color in self._iter_svg_paths():
f.write(
f'<path d="{path_d}" transform="translate({tx:.3f} {ty:.3f}) scale(1 -1)" '
f'fill="none" stroke="{stroke_color}" stroke-width="{stroke_width}" '
f'stroke-linejoin="round" stroke-linecap="round"/>\n'
)
f.write("</svg>\n")
def to_svg_dotfill(self, filename, dot_spacing=10, dot_radius=2, dot_color="#000000"):
"""生成点阵填充 SVG:文字区域用密排小圆点填充,适合激光雕刻逐点打标。"""
from .render import render_layout_occupancy
occ = render_layout_occupancy(self.layout_, (self.height, self.width), self.font_path)
occ_arr = np.array(occ)
with open(filename, "w", encoding="utf-8") as f:
f.write(
f'<svg width="{self.width}" height="{self.height}" viewBox="0 0 {self.width} {self.height}" '
f'xmlns="http://www.w3.org/2000/svg">\n'
)
f.write(f'<rect width="100%" height="100%" fill="none"/>\n')
half = dot_spacing / 2
dot_count = 0
h, w = occ_arr.shape
for gy in range(0, h, dot_spacing):
for gx in range(0, w, dot_spacing):
cy = min(gy + int(half), h - 1)
cx = min(gx + int(half), w - 1)
if occ_arr[cy, cx]:
f.write(
f'<circle cx="{cx}" cy="{cy}" r="{dot_radius}" '
f'fill="{dot_color}" stroke="none"/>\n'
)
dot_count += 1
f.write("</svg>\n")
return dot_count
def to_svg_custom(self, filename, fill_mode="fill", do_stroke=False,
dot_spacing=10, dot_radius=2, color="#000000",
line_spacing=6, line_width=1, line_angle=0,
ring_radius=3, ring_width=1, ring_spacing=8):
"""统一 SVG 导出:fill_mode=fill|dot|line|ring,可叠加描边。"""
# 预先构建所有文字路径(fill / dot 模式共用)
text_paths = []
for word, size, (y, x), orient, _color in self.layout_:
try:
path, tx, ty, _ = build_svg_text_path(word, size, x, y, self.font_path, orient)
text_paths.append((path, tx, ty))
except Exception as exc:
config._warn(f"SVG path 导出失败,跳过: {word}, error={exc}")
with open(filename, "w", encoding="utf-8") as f:
f.write(
f'<svg width="{self.width}" height="{self.height}" viewBox="0 0 {self.width} {self.height}" '
f'xmlns="http://www.w3.org/2000/svg">\n'
)
f.write(f'<rect width="100%" height="100%" fill="none"/>\n')
if fill_mode == "dot":
# 点阵模式:用 SVG pattern 平铺圆点 + clipPath 裁剪到文字形状
f.write('<defs>\n')
f.write(f' <pattern id="dot-pat" x="0" y="0" width="{dot_spacing}" height="{dot_spacing}" patternUnits="userSpaceOnUse">\n')
half = dot_spacing / 2
f.write(f' <circle cx="{half}" cy="{half}" r="{dot_radius}" fill="{color}"/>\n')
f.write(' </pattern>\n')
self._write_text_clip(f, text_paths)
f.write('</defs>\n')
f.write(f'<rect width="{self.width}" height="{self.height}" fill="url(#dot-pat)" clip-path="url(#text-clip)"/>\n')
elif fill_mode == "line":
# 线条填充:用 matplotlib Path 渲染占用蒙版(与 SVG 完全对齐)
import math as _m
occ = render_path_occupancy(self.layout_, (self.height, self.width), self.font_path)
angle = line_angle % 360
rad = _m.radians(angle)
cos_a, sin_a = _m.cos(rad), _m.sin(rad)
h, w = occ.shape
step = 1 # 逐像素采样,保证线段连续
# 垂直方向的总范围(确保覆盖整个画布)
perp_max = abs(h * cos_a) + abs(w * sin_a)
n_lines = max(1, int(perp_max / line_spacing) + 1)
sw = f'{line_width:g}'
path_parts = []
for i in range(n_lines):
d0 = (i - n_lines // 2) * line_spacing
sx = -d0 * sin_a
sy = d0 * cos_a
n_steps = int(perp_max) + 1
run_start = None
for s in range(n_steps + 1):
px = sx + s * step * cos_a
py = sy + s * step * sin_a
ix, iy = int(round(px)), int(round(py))
inside = (0 <= iy < h and 0 <= ix < w and occ[iy, ix])
if inside:
if run_start is None:
run_start = (px, py)
else:
if run_start is not None:
ex = px - step * cos_a
ey = py - step * sin_a
path_parts.append(f'M{run_start[0]:.1f} {run_start[1]:.1f}L{ex:.1f} {ey:.1f}')
run_start = None
if run_start is not None:
ex = sx + n_steps * step * cos_a
ey = sy + n_steps * step * sin_a
path_parts.append(f'M{run_start[0]:.1f} {run_start[1]:.1f}L{ex:.1f} {ey:.1f}')
if path_parts:
f.write(f'<path d="{" ".join(path_parts)}" fill="none" stroke="{color}" stroke-width="{sw}" stroke-linecap="round"/>\n')
elif fill_mode == "ring":
# 空心圆点填充:闭合路径,激光机可描一圈
occ = render_path_occupancy(self.layout_, (self.height, self.width), self.font_path)
h, w = occ.shape
r = ring_radius
sw = f'{ring_width:g}'
circle_parts = []
for gy in range(r, h - r, ring_spacing):
for gx in range(r, w - r, ring_spacing):
if not occ[gy, gx]:
continue
lx = gx - r
rx = gx + r
circle_parts.append(
f'M{lx} {gy}A{r} {r} 0 1 0 {rx} {gy}A{r} {r} 0 1 0 {lx} {gy}Z'
)
if circle_parts:
f.write(f'<path d="{" ".join(circle_parts)}" fill="none" stroke="{color}" stroke-width="{sw}"/>\n')
# 只有 fill 模式和显式描边时才输出 matplotlib 文字路径
# ring/line 模式用 PIL occupancy mask 生成填充,不需要文字轮廓
if fill_mode == "fill" or do_stroke:
for path, tx, ty in text_paths:
fill_attr = color if fill_mode == "fill" else "none"
stroke_attr = f'stroke="{color}" stroke-width="1" stroke-linejoin="round" stroke-linecap="round"' if do_stroke else ""
f.write(f'<path d="{path}" transform="translate({tx:.3f} {ty:.3f}) scale(1 -1)" fill="{fill_attr}" {stroke_attr}/>\n')
f.write("</svg>\n")
@staticmethod
def _write_text_clip(f, text_paths):
"""将文字路径写入 <clipPath id="text-clip">(调用方负责 <defs> 开闭)。"""
f.write(' <clipPath id="text-clip">\n')
for path, tx, ty in text_paths:
f.write(f' <path d="{path}" transform="translate({tx:.3f} {ty:.3f}) scale(1 -1)"/>\n')
f.write(' </clipPath>\n')
def mpl_path_to_svg_d(path):
parts = []
for vertices, code in path.iter_segments():
if code == MplPath.MOVETO:
x, y = vertices
parts.append(f"M{x:.3f} {y:.3f}")
elif code == MplPath.LINETO:
x, y = vertices
parts.append(f"L{x:.3f} {y:.3f}")
elif code == MplPath.CURVE3:
x1, y1, x2, y2 = vertices
parts.append(f"Q{x1:.3f} {y1:.3f} {x2:.3f} {y2:.3f}")
elif code == MplPath.CURVE4:
x1, y1, x2, y2, x3, y3 = vertices
parts.append(
f"C{x1:.3f} {y1:.3f} {x2:.3f} {y2:.3f} {x3:.3f} {y3:.3f}"
)
elif code == MplPath.CLOSEPOLY:
parts.append("Z")
return " ".join(parts)
def render_path_occupancy(layout_data, canvas_shape, font_path):
"""渲染文字占用蒙版:字形笔画=1,字内空洞(如口)=0,外部=0。
使用 PIL 渲染文字蒙版(与画布坐标完全对齐)+ 边界泛洪填充来区分外部区域与字内空洞。
layout_data: [(word, size, (y, x), orient, color), ...] 同 self.layout_
"""
from collections import deque
h, w = canvas_shape
if not layout_data:
return np.zeros((h, w), dtype=np.uint8)
# 用 PIL 渲染文字蒙版(坐标系与 to_image() 完全一致)
mask = Image.new("L", (w, h), 0)
draw = ImageDraw.Draw(mask)
for word, size, (y, x), orient, _color in layout_data:
font = get_cached_font(font_path, size)
if orient:
font = ImageFont.TransposedFont(font, orientation=orient)
draw.text((x, y), word, font=font, fill=255)
occ_raw = (np.array(mask) > 127).astype(np.uint8)
# 泛洪填充:从边框出发标记所有与外部连通的白色区域
# 口 等闭合字符的内部空洞不会与边框连通,因此正确保留为空
outside = np.zeros_like(occ_raw, dtype=np.uint8)
q = deque()
for x in range(w):
if occ_raw[0, x]:
q.append((0, x))
outside[0, x] = 1
if occ_raw[h - 1, x]:
q.append((h - 1, x))
outside[h - 1, x] = 1
for y in range(1, h - 1):
if occ_raw[y, 0]:
q.append((y, 0))
outside[y, 0] = 1
if occ_raw[y, w - 1]:
q.append((y, w - 1))
outside[y, w - 1] = 1
while q:
cy, cx = q.popleft()
for dy, dx in ((-1, 0), (1, 0), (0, -1), (0, 1)):
ny, nx = cy + dy, cx + dx
if 0 <= ny < h and 0 <= nx < w and occ_raw[ny, nx] and not outside[ny, nx]:
outside[ny, nx] = 1
q.append((ny, nx))
# 最终蒙版:文字笔画=1,外部和字内空洞=0
return (occ_raw & (~outside).astype(np.uint8)).astype(np.uint8)