feat(wordcloud): 收口在途开发(布局/存储/前端)+ R4 WCD 生产任务(jobs wcd_file)与生产订单列表

This commit is contained in:
2026-08-13 14:22:48 +08:00
parent 1d17b5e20d
commit e518540235
32 changed files with 3525 additions and 592 deletions
+314 -85
View File
@@ -15,10 +15,13 @@ from .fonts import get_cached_font
from .layout import OptimizedEfficientWordCloud
from .mask import analyze_mask, apply_safe_padding, calculate_dynamic_dimensions, prepare_mask
from .render import (
compute_coverage_score,
compute_fill_ratio_fast,
count_layout_overlap_pixels,
largest_empty_square_size,
append_layout_with_hd_clearance,
refine_layout_with_hd_clearance,
render_layout_occupancy,
scale_layout_for_hd,
)
from .weights import (
@@ -127,7 +130,7 @@ def run_generation_pass(
max_font = min(max_font, hard_max_font)
return min_font, max(min_font, max_font)
def try_place(scale, layout_seed=base_layout_seed):
def try_place(scale, layout_seed=base_layout_seed, probe=False):
min_font, max_font = scaled_bounds(scale)
wc = OptimizedEfficientWordCloud(
width=w_small,
@@ -138,12 +141,23 @@ def run_generation_pass(
min_font_size=min_font,
max_font_size=max_font,
background_color=config.get_output_background(),
prefer_horizontal=0.82,
# Each word independently draws horizontal vs vertical, so the mix
# is scattered rather than banded. VERTICAL_RATIO is the chance of
# a vertical word; mixing orientations is one of the cheapest ways
# to break up an over-regular grid-like look.
prefer_horizontal=1.0 - float(config.VERTICAL_RATIO),
# HD clearance is applied after scaling. Keeping the coarse-grid
# margin at zero avoids turning 1 HD pixel into 5-6 output pixels.
margin=_collision_margin,
)
wc.layout_seed = layout_seed
# A probe only needs to answer "does every word fit at this scale?", so
# it stops at the first word that cannot be placed. The answer is exact
# -- a word is only reported unplaced once an exhaustive scan has ruled
# out every position -- and it avoids paying for a doomed batch's
# remaining failures, each of which is far more expensive than a
# successful placement.
wc.max_failures = 1 if probe else None
wc.generate_from_frequencies(frequencies_data)
return wc, len(wc.layout_), min_font, max_font
@@ -165,55 +179,99 @@ def run_generation_pass(
best_wc = None
best_count = 0
best_scale = 1.0
scale = 1.0
best_coverage = -1.0 # shape-aware coverage of the current best candidate
best_occ = None # occupancy raster of the current best candidate
tried_layouts = set()
failed_scales = []
for attempt in range(1, 4):
attempt = 0
def probe_scale(scale):
"""Lay out every word at `scale`; return (wc, placed_count, complete)."""
nonlocal attempt, best_wc, best_count, best_scale
nonlocal best_coverage, best_occ
attempt += 1
bounds = scaled_bounds(scale)
layout_key = (bounds, base_layout_seed)
if layout_key in tried_layouts:
break
tried_layouts.add(layout_key)
wc, placed_count, min_font, max_font = try_place(scale)
tried_layouts.add((bounds, base_layout_seed))
wc, placed_count, min_font, max_font = try_place(scale, probe=True)
complete = placed_count >= total_target
print(
f" 整批布局 #{attempt}: scale={scale:.3f}, "
f"字号=[{min_font}, {max_font}] -> {placed_count}/{total_target}"
f"字号=[{min_font}, {max_font}] -> "
f"{'完整' if complete else '不足'} ({placed_count}/{total_target})"
)
log.info(
" 整批布局 #%d scale=%.3f 字号=[%d,%d] -> %d/%d",
attempt,
scale,
min_font,
max_font,
placed_count,
total_target,
" 整批布局 #%d scale=%.3f 字号=[%d,%d] -> %d/%d complete=%s",
attempt, scale, min_font, max_font, placed_count, total_target, complete,
)
if placed_count > best_count:
best_wc = wc
best_count = placed_count
best_scale = scale
if placed_count >= total_target:
final_wc = wc
final_scale = scale
final_layout_seed = base_layout_seed
if complete:
# Among complete layouts prefer the one whose ink reaches furthest
# into the mask shape, not merely the largest font scale. A Fermat
# spiral packs words into a disc around the fillable centroid; with
# few words or large fonts that disc stays small and never reaches
# the mask's protrusions, so the cloud reads as a circle instead of
# the intended silhouette. Coverage rewards layouts that spread into
# those deep regions, letting a slightly smaller scale win when it
# trades font size for a recognisable outline. Scale is the tie-
# breaker so an equal-coverage denser picture is still preferred.
occ = render_layout_occupancy(wc.layout_, mask_small.shape, config.WC_FONT_PATH)
coverage = compute_coverage_score(occ, mask_small)
log.info(
" 整批布局 #%d coverage=%.4f (best=%.4f)",
attempt, coverage, best_coverage,
)
if coverage > best_coverage or (
coverage == best_coverage and scale > best_scale
):
best_wc, best_count, best_scale = wc, placed_count, scale
best_coverage, best_occ = coverage, occ
if not complete:
failed_scales.append(scale)
return wc, placed_count, complete
# Find the largest scale at which every word still fits. Bigger is strictly
# better here: the same names drawn larger leave less blank space. A probe
# answers feasibility exactly and stops at the first unplaceable word, so
# searching for the best scale costs little more than accepting the first
# one that happens to work.
lo = None # largest scale known to fit everything
hi = None # smallest scale known to be too big
scale = 1.0
for _ in range(2 if config.FAST_MODE else 4):
wc, placed_count, complete = probe_scale(scale)
if complete:
lo = scale
break
failed_scales.append(scale)
hi = scale
placed_ratio = placed_count / max(1, total_target)
# Required box area is roughly proportional to size². The extra
# safety margin absorbs fragmentation without wasting a binary search.
shrink = 0.62 if placed_ratio <= 0 else min(0.92, max(0.58, math.sqrt(placed_ratio) * 0.92))
# Area scales with size², so linear size scales with sqrt(ratio). The
# probe stops early, which understates how many words would have fit,
# so this deliberately undershoots and the bisection below climbs back.
shrink = 0.62 if placed_ratio <= 0 else min(0.92, max(0.55, math.sqrt(placed_ratio) * 0.92))
scale *= shrink
if final_wc is None:
# Close the gap between the largest failing scale and the smallest passing
# one. Each step recovers font size that the shrink above gave away.
if lo is not None and hi is not None:
for _ in range(1 if config.FAST_MODE else 3):
mid = (lo + hi) / 2.0
if hi - lo < 0.02 or scaled_bounds(mid) == scaled_bounds(lo):
break
_wc, _placed, complete = probe_scale(mid)
if complete:
lo = mid
else:
hi = mid
if best_wc is not None:
final_wc = best_wc
final_scale = best_scale
final_layout_seed = base_layout_seed
if final_wc is None:
return {
"wc": None,
"fill_ratio": 0.0,
"coverage": 0.0,
"occ_fast": None,
"w_small": w_small,
"h_small": h_small,
@@ -250,10 +308,16 @@ def run_generation_pass(
debug_dir.mkdir(parents=True, exist_ok=True)
Image.fromarray((occ_fast * 255).astype(np.uint8)).save(str(debug_dir / "occ_fast.png"))
# Probe larger whole-cloud layouts and keep the largest complete one. If
# an earlier batch was too large, search the discrete interval between the
# complete and failed scales instead of accepting an over-aggressive
# shrink. Every probe rebuilds the entire cloud with one shared scale.
# Probe whole-cloud layouts in BOTH font-size directions and keep the one
# whose ink reaches furthest into the mask shape. The original search only
# grew the font (chasing a higher pixel fill ratio), but a Fermat spiral
# packs words into a disc around the centroid: growing the font shrinks that
# disc, so an under-filled silhouette gets *more* circular, not less.
# Shrinking the font lets the spiral walk further out and reach the mask's
# protrusions, which raises shape coverage even when the raw fill ratio
# drops a little. Both directions are probed each round and the higher-
# coverage candidate wins; the loop stops when neither improves coverage.
# Every probe rebuilds the entire cloud with one shared scale.
if (
len(final_wc.layout_) >= total_target
and fill_ratio > 0
@@ -262,11 +326,22 @@ def run_generation_pass(
or has_character_sized_hole(final_wc, largest_empty_square)
)
):
current_coverage = compute_coverage_score(occ_fast, mask_small)
free_px = max(1, int(np.sum(mask_small == 0)))
upper_scale = min(
(failed for failed in failed_scales if failed > final_scale),
default=None,
)
for density_attempt in range(1, 5):
def _fill_and_coverage(wc):
occ = render_layout_occupancy(wc.layout_, mask_small.shape, config.WC_FONT_PATH)
new_fill = float(np.sum((mask_small == 0) & (occ == 1))) / free_px
cov = compute_coverage_score(occ, mask_small)
return new_fill, cov, occ
for density_attempt in range(1, 2 if config.FAST_MODE else 5):
# Grow direction (larger font): bisect toward a known-too-big scale,
# or nudge up by the fill-ratio deficit, exactly as before.
if upper_scale is not None:
grow_scale = (final_scale + upper_scale) / 2.0
elif equal_size_mode:
@@ -277,61 +352,79 @@ def run_generation_pass(
1.12,
math.sqrt(config.TARGET_FILL_RATIO / fill_ratio) * 0.98,
)
if desired_growth <= 1.005:
break
grow_scale = final_scale * desired_growth
grow_scale = final_scale * desired_growth if desired_growth > 1.005 else None
grow_bounds = scaled_bounds(grow_scale)
if grow_bounds == scaled_bounds(final_scale):
break
grow_min, grow_max = grow_bounds
layout_key = (grow_bounds, base_layout_seed)
attempted_layout = False
if layout_key not in tried_layouts:
tried_layouts.add(layout_key)
wc, placed_count, _, _ = try_place(grow_scale)
attempted_layout = True
print(
f" 密度优化 #{density_attempt}: scale={grow_scale:.3f}, "
f"字号=[{grow_min}, {grow_max}] -> {placed_count}/{total_target}"
)
else:
wc, placed_count = None, -1
# Shrink direction (smaller font): the inverse nudge. Letting the
# spiral walk further out costs font size but can reach protrusions
# the grow direction abandons. Cap the shrink so one round cannot
# collapse the font to the floor.
shrink_scale = None
if not equal_size_mode and fill_ratio > 0:
shrink_factor = 1.0 / max(1.02, min(1.20, math.sqrt(fill_ratio / max(0.05, config.TARGET_FILL_RATIO)) * 1.02))
cand = final_scale * shrink_factor
if scaled_bounds(cand) != scaled_bounds(final_scale):
shrink_scale = cand
elif equal_size_mode:
current_size, _ = scaled_bounds(final_scale)
if current_size > hard_min_font:
shrink_scale = (current_size - 1) / max(1, base_min_font)
selected_seed = base_layout_seed
if placed_count < total_target and base_layout_seed is not None:
# The reference library samples a fresh legal position order.
# One bounded whole-cloud re-layout recovers dense solutions
# without per-word shrinking or an unbounded random search.
candidate_seed = (int(base_layout_seed) * 3 + 3) % (2**31 - 1)
retry_key = (grow_bounds, candidate_seed)
if retry_key not in tried_layouts:
tried_layouts.add(retry_key)
retry_wc, retry_count, _, _ = try_place(grow_scale, candidate_seed)
attempted_layout = True
candidates = []
for direction, scale in (("grow", grow_scale), ("shrink", shrink_scale)):
if scale is None or scaled_bounds(scale) == scaled_bounds(final_scale):
continue
bounds = scaled_bounds(scale)
d_min, d_max = bounds
key = (bounds, base_layout_seed)
wc, placed_count, selected_seed = None, -1, base_layout_seed
if key not in tried_layouts:
tried_layouts.add(key)
wc, placed_count, _, _ = try_place(scale)
# Bounded seed retry to recover a complete layout, as before.
if placed_count < total_target and base_layout_seed is not None:
candidate_seed = (int(base_layout_seed) * 3 + 3) % (2**31 - 1)
retry_key = (bounds, candidate_seed)
if retry_key not in tried_layouts:
tried_layouts.add(retry_key)
retry_wc, retry_count, _, _ = try_place(scale, candidate_seed)
print(
f" 密度优化 #{density_attempt} {direction} 整批重排: "
f"seed={candidate_seed}, 字号=[{d_min}, {d_max}] -> "
f"{retry_count}/{total_target}"
)
if retry_count > placed_count:
wc, placed_count = retry_wc, retry_count
selected_seed = candidate_seed
if placed_count < total_target:
if direction == "grow":
upper_scale = scale
print(
f" 密度优化 #{density_attempt} 整批重排: "
f"seed={candidate_seed}, 字号=[{grow_min}, {grow_max}] -> "
f"{retry_count}/{total_target}"
f" 密度优化 #{density_attempt} {direction}: scale={scale:.3f}, "
f"字号=[{d_min}, {d_max}] -> {placed_count}/{total_target} (不完整)"
)
if retry_count > placed_count:
wc = retry_wc
placed_count = retry_count
selected_seed = candidate_seed
if not attempted_layout:
break
if placed_count < total_target:
upper_scale = grow_scale
continue
continue
new_fill, cov, occ = _fill_and_coverage(wc)
print(
f" 密度优化 #{density_attempt} {direction}: scale={scale:.3f}, "
f"字号=[{d_min}, {d_max}] -> {placed_count}/{total_target} "
f"fill={new_fill:.3f} coverage={cov:.4f}"
)
candidates.append((direction, scale, selected_seed, wc, placed_count, new_fill, cov, occ))
new_fill, new_occ = compute_fill_ratio_fast(wc.layout_, mask_small, config.WC_FONT_PATH)
if new_fill <= fill_ratio:
if not candidates:
break
# Pick the higher-coverage candidate; tie-break on fill ratio so a
# genuinely denser picture still wins when coverage is equal.
candidates.sort(key=lambda c: (c[6], c[5]))
direction, scale, selected_seed, wc, placed_count, new_fill, cov, occ = candidates[-1]
if cov <= current_coverage and new_fill <= fill_ratio:
break
final_wc = wc
final_scale = grow_scale
final_scale = scale
final_layout_seed = selected_seed
fill_ratio = new_fill
occ_fast = new_occ
occ_fast = occ
current_coverage = cov
largest_empty_square = largest_empty_square_size(occ_fast, mask_small)
complete_candidates.append(
(final_wc, final_scale, final_layout_seed, fill_ratio, occ_fast)
@@ -339,6 +432,7 @@ def run_generation_pass(
if (
fill_ratio >= config.TARGET_FILL_RATIO * 0.98
and not has_character_sized_hole(final_wc, largest_empty_square)
and current_coverage >= 0.98
):
break
@@ -350,6 +444,7 @@ def run_generation_pass(
and len(final_wc.layout_) >= total_target
and has_character_sized_hole(final_wc, largest_empty_square)
and base_layout_seed is not None
and not config.FAST_MODE
):
if total_target < 100:
hole_attempt_budget = 3
@@ -389,6 +484,77 @@ def run_generation_pass(
(final_wc, final_scale, final_layout_seed, fill_ratio, occ_fast)
)
# ── 工作网格增量填充:原名字号不变,用更小字号追加副本填轮廓 ──
# 已填区域标记为阻挡,新词只能进空白间隙。每一轮只生成一份名单,
# 并把新占用合并回工作网格;因此自动填充没有固定的重复数量,
# 只在轮廓仍未覆盖且还有合法位置时继续追加。
fill_work_layout = []
if (
config.AUTO_REPEAT_TO_FILL
and final_wc is not None
and len(final_wc.layout_) >= total_target
and occ_fast is not None
):
current_cov = compute_coverage_score(occ_fast, mask_small)
if current_cov < 0.95 and names:
# 原名字号范围
original_sizes = [size for _, size, *_ in final_wc.layout_]
orig_min_font = int(min(original_sizes))
orig_max_font = int(max(original_sizes))
span = orig_max_font - orig_min_font
# 追加用缩小字号:原最大字号的 50~60%
fill_min_font = max(config.MIN_FONT_FLOOR, int(round(orig_min_font * 0.50)))
fill_max_font = max(fill_min_font, int(round(orig_min_font + span * 0.60)))
fill_seed = (final_layout_seed ^ 0x9E3779B9) & 0x7FFFFFFF
if fill_seed == 0:
fill_seed = 1
# AUTO_REPEAT_MAX 只是防止异常掩膜导致无限循环,不是目标重复次数。
max_fill_rounds = max(1, int(config.AUTO_REPEAT_MAX))
for fill_round in range(max_fill_rounds):
if current_cov >= 0.95:
break
# 融合 mask:原阻挡 + 已填区域都标为 255
fill_mask = mask_small.copy()
fill_mask[(occ_fast == 1)] = 255
fill_wc = OptimizedEfficientWordCloud(
width=w_small, height=h_small,
mask=fill_mask,
font_path=config.WC_FONT_PATH,
# 一轮只追加一份名单;需要更多时由下一轮按需追加。
max_words=len(names),
min_font_size=fill_min_font,
max_font_size=fill_max_font,
background_color=config.get_output_background(),
prefer_horizontal=1.0 - float(config.VERTICAL_RATIO),
margin=_collision_margin,
)
fill_wc.layout_seed = (fill_seed + fill_round) & 0x7FFFFFFF or 1
fill_freq = {name: 0.3 for name in names}
fill_wc.generate_from_frequencies(fill_freq)
fill_placed = len(fill_wc.layout_)
print(
f"[增量填充#{fill_round + 1}] 工作网格追加放置 "
f"{fill_placed}/{len(names)} 词,字号=[{fill_min_font}, {fill_max_font}]"
)
log.info(
"[增量填充#%d] 工作网格追加放置 %d/%d 词, 字号=[%d,%d]",
fill_round + 1, fill_placed, len(names), fill_min_font, fill_max_font,
)
if fill_placed <= 0:
break
fill_work_layout.extend(fill_wc.layout_)
fill_occ = render_layout_occupancy(
fill_wc.layout_, mask_small.shape, config.WC_FONT_PATH
)
occ_fast = np.maximum(occ_fast, fill_occ)
current_cov = compute_coverage_score(occ_fast, mask_small)
print(f"[增量填充#{fill_round + 1}] 工作网格覆盖度={current_cov:.4f}")
hd_layout = None
hd_clearance = None
raw_hd_layout = []
@@ -473,14 +639,73 @@ def run_generation_pass(
config.WC_FONT_PATH,
)
# ── 增量填充(工作网格 → HD) ──
# 工作网格上的合法位置经过放大后可能因取整发生碰撞,因此填充词必须
# 与基础布局一起再次做高清精修。若整批填充无法通过,则二分保留最多
# 的追加词;绝不能让自动填充破坏原本已经成功的基础布局。
if (
fill_work_layout
and hd_layout is not None
):
old_count = len(hd_layout)
fill_hd = scale_layout_for_hd(fill_work_layout, config.WORK_SCALE)
base_hd_layout = list(hd_layout)
accepted_additions, clearance_stats = append_layout_with_hd_clearance(
base_hd_layout,
fill_hd,
mask_hd,
config.WC_FONT_PATH,
clearance=0,
allow_global_search=False,
)
accepted_count = len(accepted_additions)
accepted_clearance = 0
hd_layout = base_hd_layout + accepted_additions
hd_overlap_pixels = count_layout_overlap_pixels(
hd_layout, (real_hd_h, real_hd_w), config.WC_FONT_PATH
)
new_fill, new_occ = compute_fill_ratio_fast(
hd_layout, mask_hd, config.WC_FONT_PATH
)
new_cov = compute_coverage_score(new_occ, mask_hd) if new_occ is not None else 0.0
clearance_stats = dict(clearance_stats or {})
clearance_stats["clearance_px"] = accepted_clearance
hd_clearance = clearance_stats
print(
f"[增量填充] 高清验收: {old_count}+{accepted_count}="
f"{len(hd_layout)} 词, fill={new_fill:.3f}, "
f"coverage={new_cov:.4f}, overlap={hd_overlap_pixels}"
)
print(
f"[增量填充] 工作网格候选 {len(fill_work_layout)} 词,"
f"高清接受 {len(hd_layout) - old_count}"
)
log.info(
"[增量填充] HD 验收: base=%d candidate=%d accepted=%d fill=%.4f coverage=%.4f overlap=%d",
old_count, len(fill_hd), len(hd_layout) - old_count, new_fill, new_cov, hd_overlap_pixels,
)
fill_ratio = new_fill
occ_fast = new_occ
largest_empty_square = largest_empty_square_size(occ_fast, mask_hd)
print(
f"最终填充率: {fill_ratio:.3f} | 高清重叠像素: {hd_overlap_pixels} | "
f"精修位移: {hd_clearance['shifted_words']} 词, 最大 {hd_clearance['max_shift']}px | "
f"隔离带: {hd_clearance['clearance_px']}px"
)
# occ_fast 可能是工作网格或 HD 网格形状(增量填充后),按形状匹配计算覆盖度
if occ_fast is not None and occ_fast.shape == mask_small.shape:
coverage = compute_coverage_score(occ_fast, mask_small)
elif occ_fast is not None and occ_fast.shape == mask_hd.shape:
coverage = compute_coverage_score(occ_fast, mask_hd)
else:
coverage = 0.0
print(f"轮廓覆盖度: {coverage:.4f}")
return {
"wc": final_wc,
"fill_ratio": fill_ratio,
"coverage": coverage,
"occ_fast": occ_fast,
"w_small": w_small,
"h_small": h_small,
@@ -614,6 +839,9 @@ def main():
)
frequencies_data = name_weights_map if config.REMOVE_DUPLICATES else [(name, name_weights_map.get(name, 10)) for name in names]
canvas_retry_round = 0
generation_result = None
t_gen = time.time()
canvas_retry_round = 0
generation_result = None
t_gen = time.time()
@@ -649,8 +877,9 @@ def main():
canvas_retry_round,
)
sys.exit(1)
log.info(" 生成完成 placed=%d/%d fill=%.4f retry=%d",
placed, target, generation_result["fill_ratio"], canvas_retry_round)
log.info(" 生成完成 placed=%d/%d fill=%.4f coverage=%.4f retry=%d",
placed, target, generation_result["fill_ratio"],
generation_result.get("coverage", 0.0), canvas_retry_round)
break
canvas_retry_round += 1