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:
+113
-70
@@ -4,132 +4,175 @@
|
||||
|
||||
## 总流程
|
||||
|
||||
1. 读取 Excel 名单:`pipeline.main()`
|
||||
2. 计算自动画幅:`mask.calculate_dynamic_dimensions()`
|
||||
3. 生成并归一化掩膜:`mask.prepare_mask()`
|
||||
4. 计算权重:`weights.extract_weights_from_df()` 和 `weights.get_stroke_complexity_batch()`
|
||||
5. 估算字号范围:`weights.calculate_font_by_area_model()`
|
||||
6. 小画布布局:`layout.OptimizedEfficientWordCloud.generate_from_frequencies()`
|
||||
7. C++ 找可放位置:`IntegralGrid.query_direct()`
|
||||
8. C++ 写入字形占用:`IntegralGrid.stamp_and_rebuild()`
|
||||
9. 计算填充率并必要时重试放大
|
||||
10. 将小画布 layout 放大到高清画布并输出 PNG/SVG/DB/metrics
|
||||
```
|
||||
1. 读取 Excel 名单 → pipeline.main()
|
||||
2. 智能画幅计算 → mask.calculate_dynamic_dimensions()
|
||||
3. 生成并归一化掩膜 → mask.prepare_mask()
|
||||
4. 计算权重 → weights.extract_weights_from_df() + weights.get_stroke_complexity_batch()
|
||||
5. 估算字号范围 → weights.calculate_font_by_area_model()
|
||||
6. 小画布布局 → layout.OptimizedEfficientWordCloud.generate_from_frequencies()
|
||||
7. C++ 按真实字形找位置并原子写入 → IntegralGrid.place_glyph_exact()
|
||||
8. 整批未完整放入 → 统一缩放字号或扩大画布后重排
|
||||
9. 密度优化 → 探测更大字号并保留完整率不下降的候选
|
||||
10. 高清精修 → render.refine_layout_with_hd_clearance() 加入隔离带局部微调
|
||||
11. 输出 PNG / SVG / DB / metrics
|
||||
```
|
||||
|
||||
## 名单和重复填充
|
||||
## 名单与重复填充
|
||||
|
||||
`N_REPETITIONS` 决定目标词数:
|
||||
|
||||
```text
|
||||
```
|
||||
total_target = len(names) * N_REPETITIONS
|
||||
```
|
||||
|
||||
布局序列由 `_build_layout_sequence()` 生成。当前行为是按原名单循环追加:
|
||||
布局序列由 `_build_layout_sequence()` 生成。行为:**按原名单循环追加**,不是把同一个名字所有副本先放完。
|
||||
|
||||
```text
|
||||
```
|
||||
[A, B, C], N_REPETITIONS=4
|
||||
=> [A, B, C, A, B, C, A, B, C, A, B, C]
|
||||
→ [A, B, C, A, B, C, A, B, C, A, B, C]
|
||||
```
|
||||
|
||||
所以当前队列顺序是“先填一轮名单,再填下一轮”,不是先放完同一个名字所有副本。
|
||||
|
||||
需要注意:队列顺序公平不等于最终字号完全一致。放置阶段如果某个词以目标字号找不到位置,会单独降字号继续尝试。因此后几轮词语通常比前几轮小。
|
||||
同名副本在同一批布局中使用相同目标字号。放置失败不会触发逐词缩字号。
|
||||
|
||||
## 权重逻辑
|
||||
|
||||
### Excel 权重
|
||||
|
||||
`WEIGHT_COL_NAME` 优先于 `WEIGHT_COL_INDEX`。有效权重必须是可转数字且大于 0。
|
||||
|
||||
`REMOVE_DUPLICATES = True` 时,同名权重取最大值。`REMOVE_DUPLICATES = False` 时,仍会按名字聚合权重映射,所以同名不同权重不会保留为不同权重实例。
|
||||
- `WEIGHT_COL_NAME` 优先于 `WEIGHT_COL_INDEX`
|
||||
- 有效权重必须是可转数字且大于 `0`
|
||||
- `REMOVE_DUPLICATES=True` 时,同名权重取最大值
|
||||
- `REMOVE_DUPLICATES=False` 时,仍按名字聚合成权重映射
|
||||
|
||||
### 笔画权重
|
||||
|
||||
`ENABLE_STROKE_WEIGHTS = True` 时,系统渲染每个字符到 64x64 灰度图,用像素占用量估算复杂度。一个名字的笔画权重取其中最复杂字符的值。
|
||||
|
||||
`ENABLE_STROKE_WEIGHTS = False` 时跳过笔画权重。若没有 Excel 权重,所有名字权重默认为 `10`。
|
||||
- `ENABLE_STROKE_WEIGHTS=True` 时,系统渲染每个字符到 `64×64` 灰度图,用像素占用量估算复杂度
|
||||
- 一个名字的笔画权重取其中最复杂字符的值
|
||||
- 若同时存在 Excel 权重:Excel 值作为基础权重,笔画复杂度除以全体中位数后作为乘数
|
||||
- 这样手动权重比例仍保留,且 Excel 权重全为 `1` 时笔画开关也不会失效
|
||||
- `ENABLE_STROKE_WEIGHTS=False` 且无 Excel 权重时,所有名字权重默认为 `10`
|
||||
|
||||
## 字号范围估算
|
||||
|
||||
`calculate_font_by_area_model()` 使用可填充面积、目标填充率、packing efficiency、重复次数和名字长度估算 `min_font` / `max_font`。
|
||||
`calculate_font_by_area_model()` 使用以下输入估算 `min_font` / `max_font`:
|
||||
|
||||
- 可填充面积(掩膜中 `0` 的像素数)
|
||||
- 目标填充率 `TARGET_FILL_RATIO`
|
||||
- 打包效率 `PACKING_EFFICIENCY`
|
||||
- 重复次数 `N_REPETITIONS`
|
||||
- 名字长度、各名字权重
|
||||
|
||||
公式思想:
|
||||
|
||||
- 可填区域越大,字号越大
|
||||
- 名字越多、重复次数越高,字号越小
|
||||
- 字符越多,总占用质量越高,字号越小
|
||||
- 字符越多,总字符质量越高,字号越小
|
||||
- 权重越高,在 `log1p(weight)` 归一化后获得更高面积质量
|
||||
|
||||
最终 `max_font` 基于 `min_font * SIZE_RATIO` 计算。
|
||||
最终 `max_font = min_font × SIZE_RATIO`。
|
||||
|
||||
## 字号打分
|
||||
|
||||
当前 `build_log_rank_scores(..., per_word=True)` 会按姓名权重计算固定分数,然后映射到展开后的重复序列。
|
||||
`build_log_rank_scores(..., per_word=True)` 按姓名权重计算固定分数,映射到展开后的重复序列。结果是:
|
||||
|
||||
这意味着:
|
||||
|
||||
- 同名副本的目标分数相同
|
||||
- 同名副本的初始目标字号相同
|
||||
- 同名副本目标分数相同
|
||||
- 同名副本初始目标字号相同
|
||||
- 权重相同时,所有姓名初始目标字号相同
|
||||
|
||||
但最终放置字号仍可能变小,因为放置失败时会逐词降字号。
|
||||
`SIZE_RATIO=1` 时,`min_font == max_font`,所有同权重姓名在重试前后都保持完全相同字号。
|
||||
|
||||
## 等字号模式
|
||||
|
||||
`SIZE_RATIO=1` 时,面积估算阶段直接令 `min_font = max_font`。所有字号重试也只改变单一字号值。
|
||||
|
||||
等字号批次的特殊优化:
|
||||
|
||||
1. 视作整批同字号布局,使用整批字号打分(`per_word=False`)
|
||||
2. 密度优化阶段在完整候选中比较字符级最大空洞(`largest_empty_square_size`),并尝试不同布局种子降低空洞
|
||||
3. 高清精修阶段允许优先级回溯(把失败词提前到队列最前)
|
||||
|
||||
## 放置策略
|
||||
|
||||
每个词的放置流程:
|
||||
|
||||
1. 根据目标分数得到目标字号
|
||||
2. 随机决定横排或竖排
|
||||
3. 用 PIL 测量文字包围盒
|
||||
4. 调用 C++ `query_direct(query_h, query_w, seed)` 找位置
|
||||
5. 如果找不到,字号减 2 后重试,最低到目标字号的 40% 或 `min_font_size`
|
||||
6. 放置成功后,取真实字形 bitmap 并调用 `stamp_and_rebuild()`
|
||||
7. 主循环放不下的词进入 gap filling,用更小字号再尝试一次
|
||||
1. 根据目标分数得到目标字号(线性插值于 `min_font` 到 `max_font`)
|
||||
2. 随机决定横排或竖排(`prefer_horizontal` 控制概率)
|
||||
3. 用 PIL 渲染真实字形 bitmap,施加单侧安全边距(margin)生成碰撞 mask
|
||||
4. 调用 C++ `place_glyph_exact()` 搜索合法位置并原子写入
|
||||
5. 当前方向找不到时,只尝试同字号的另一方向
|
||||
6. 任意姓名失败即视为本次整批布局不完整
|
||||
7. **不逐词缩字号**。Python 统一缩放整批字号范围后重新布局
|
||||
8. 触及字号下限仍失败时,按 `CANVAS_RETRY_GROWTH` 扩大画布并整批重排
|
||||
9. 将完整布局映射到高清字号和坐标
|
||||
10. 高清画布进行逐词隔离带精修,局部无合法位置时扩大画布并整批重排
|
||||
|
||||
## C++ 积分图搜索
|
||||
## C++ 真实字形搜索
|
||||
|
||||
C++ `IntegralGrid` 维护两个核心结构:
|
||||
`IntegralGrid` 维护两个核心结构:
|
||||
|
||||
- `canvas`:真实占用像素,`1` 表示已占用或掩膜阻挡
|
||||
- `data`:`canvas` 的积分图,用于 O(1) 判断矩形区域是否为空
|
||||
- `canvas`:真实占用像素(`1` 表示已占用或掩膜阻挡)
|
||||
- `data`:兼容旧矩形查询的积分图;当前主路径不依赖逐词重建
|
||||
|
||||
`query_direct()` 的行为:
|
||||
`place_glyph_exact()` 行为:
|
||||
|
||||
1. 如果画布完全空,随机返回一个位置
|
||||
2. 先随机探测最多 16 个位置
|
||||
3. 如果未命中,扫描所有可能位置
|
||||
4. 对每个候选位置用积分图判断包围盒是否为空
|
||||
5. 从所有可放位置中随机选一个
|
||||
1. 等字号批次前 `70%` 姓名优先选择靠近可填区域质心的合法位置,避免少量姓名接受第一个随机空位而形成大块空洞
|
||||
2. 多字号批次只对前 `25%` 以及高权重(`score ≥ 0.80`)姓名启用中心偏好
|
||||
3. 少于 `100` 人的等字号批次中心候选数提高到 `768`;`100–300` 人提高到 `512`;其余保持 `256`
|
||||
4. 随机探测失败后,从种子决定的偏移开始完整扫描
|
||||
5. 对每个候选位置逐像素比较碰撞 mask 与 C++ `canvas`
|
||||
6. 命中后只写入真实字形,占位查询和写入在同一次 C++ 调用中完成
|
||||
|
||||
`stamp_and_rebuild()` 的行为:
|
||||
真实字形搜索允许透明角落和笔画间空隙安全交错,比外接矩形碰撞更密。安全边距只参与候选检查,不会被双侧累计放大。
|
||||
|
||||
1. 把真实字形像素写入 C++ `canvas`
|
||||
2. 从字形左上角开始局部重建积分图
|
||||
## 高清精修(隔离带)
|
||||
|
||||
当前碰撞检测是“矩形找位置 + 字形像素落图”。找位置阶段要求文字包围盒矩形完全空;实际占用阶段只写入字形像素。
|
||||
正式流水线不在低清工作网格增加边距,因为 `1` 个工作像素会被放大成约 `5–6` 个高清像素并显著损失容量。
|
||||
|
||||
隔离带优先作用在最终高清画布,宽度为 `1px`,不是用户参数。精修流程:
|
||||
|
||||
1. 小画布完整布局放大到高清
|
||||
2. 逐词在高清画布上验证,加入 `1px` 隔离带
|
||||
3. 碰撞时只允许最大 `max_shift=24px` 的局部微位移
|
||||
4. 精修失败时,最多进行 `1` 次确定性整批优先级回溯(把失败词移到队列最前)
|
||||
5. 若轮廓过窄无法容纳额外隔离带,降为精确零间隙碰撞(`clearance=0`)
|
||||
6. 全部候选无解时,由外层扩大画布后整批重新布局,不进行远距离单词搬移
|
||||
|
||||
密度搜索会保留各档完整整批候选。最高密度候选若无法在有限位置修正范围内通过高清隔离验收,则改用上一档完整整批候选;不会对碰撞姓名单独缩字号,也不会接受带重叠的高密度结果。
|
||||
|
||||
## 填充率重试
|
||||
|
||||
一次布局完成后,`compute_fill_ratio_fast()` 重新渲染 layout 并计算填充率。
|
||||
|
||||
如果填充率低于 `MIN_ACCEPT_FILL_RATIO`,管线会尝试二分放大 `size_scale`,并可通过 `FILL_RETRY_RELAX_LARGE_CAP` 放宽大字号限制。
|
||||
- 面积模型首次完整放入但明显低于 `TARGET_FILL_RATIO` 时,只允许一次整批等比例增字号尝试。新布局必须仍然完整且真实填充率更高才会采用
|
||||
- 整批未完整放入时,流水线不会输出半成品:先整批等比例调整字号;触及最小字号仍失败时按 `CANVAS_RETRY_GROWTH` 扩大画布并重新生成
|
||||
|
||||
如果仍无法达到目标,会保留填充率最好的 layout。
|
||||
## 字号硬约束
|
||||
|
||||
## 已知算法限制
|
||||
- 不存在逐词缩字号、gap filling 或大字号自动压缩路径
|
||||
- `USER_MIN_FONT_SIZE` 和 `USER_MAX_FONT_SIZE` 是不可越过的边界;冲突时直接报错
|
||||
- `SIZE_RATIO=1` 在面积估算、整批重试和高清放大阶段始终保持单一字号
|
||||
- 完整名单不可关闭;放不下时只能整批重排、整批等比例调整或扩大画布
|
||||
|
||||
- 当前没有真正的“整轮统一降字号”机制。重复填充虽然按轮展开,但每个词可以独立降字号。
|
||||
- `LIMIT_LARGE_FONTS` 是全局计数,不区分姓名和轮次。
|
||||
- `LAYOUT_ORDER_MODE_INTERLEAVED_RANDOM` 只改变展开序列的顺序,不改变 C++ 的空间采样策略。
|
||||
- `ENABLE_STRATIFIED_SAMPLING` 调用的 `reorder_stratified()` 对 `query_direct()` 主路径无效。
|
||||
- C++ `batch_query()` 会用矩形 `update_rect_add()` 更新,不走真实字形 `stamp_and_rebuild()`;当前 Python 主路径没有使用它。
|
||||
## 空洞优化(等字号模式)
|
||||
|
||||
## 后续公平重复填充建议
|
||||
等字号模式下,字符级最大空洞(`largest_empty_square_size`)用于判断是否存在"可放字符级空洞":
|
||||
|
||||
如果目标是“每一轮名单整体公平变小”,建议新增独立模式,而不是继续微调当前逐词降字号:
|
||||
- 当前 `largest_empty_square ≥ ceil(font_size * 1.25)` 时视为存在字符级空洞
|
||||
- 流水线会在同一字号档位尝试最多 `3` 种不同布局种子(按人数递减预算)
|
||||
- 目标是最小化最大空洞尺寸,不改变任何字号
|
||||
|
||||
- `REPEAT_FILL_MODE = "ROUND_ROBIN_FAIR"`
|
||||
- 以轮为单位生成任务
|
||||
- 同一轮使用统一字号或统一权重映射
|
||||
- 某一轮放不下时,整轮降低字号重试
|
||||
- 失败词统一进入下一档补位队列
|
||||
- 大字号限制按姓名或轮次计数
|
||||
## 自动画幅
|
||||
|
||||
`calculate_dynamic_dimensions()` 先对 `BASE_HD_WIDTH × BASE_HD_HEIGHT` 做一次 probe 掩膜,计算可填比例 `free_ratio`,再用以下公式扩展:
|
||||
|
||||
```
|
||||
area_per_word = (MIN_READABLE_HEIGHT_PX²) × avg_len × 1.05
|
||||
required_area = num_words × area_per_word × N_REPETITIONS / TARGET_FILL_RATIO
|
||||
required_area /= free_ratio
|
||||
scale_factor = √(required_area / current_area)
|
||||
new_w = clamp(base_w × scale_factor, max_edge=6000)
|
||||
```
|
||||
|
||||
- 扩展后宽高向上取整到最近的 `100`
|
||||
- 最大边长限制为 `6000px`,避免 SVG/PNG 过度膨胀
|
||||
- 掩膜只生成一次,扩展后复用
|
||||
|
||||
Reference in New Issue
Block a user