ginka-generator/docs/wall-growth-generation-design.md

368 lines
18 KiB
Markdown
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.

# Stage1 墙壁生长算法生成设计文档
## 背景与问题诊断
### 当前 Stage1 的 MaskGIT 采样机制
Stage1 负责生成地图的墙壁/地面骨架floor/wall使用标准 MaskGIT 迭代解码器:输入全 MASK 地图,通过余弦退火调度逐步揭开置信度最高的 token共 18 步完成。每步流程如下:
```
全 MASK 地图 → 模型预测所有位置 → 选出 Top-K 高置信度位置 →
揭开这些位置 → 其余位置重新 MASK → 下一步
```
### 问题现象
当前 Stage1 生成的地图墙壁质量显著低于预期,具体表现为:
- 墙壁分布破碎出现孤立墙块block 不与外围墙壁连接)
- 房间边界不完整,未能形成闭合区域
- 走廊断断续续,不能形成连通通道
- 整体拓扑结构与训练集分布偏差大
### 根本原因MaskGIT 缺乏空间连续约束
MaskGIT 的迭代解掩码unmasking机制本质上是**逐 token 独立决策**:每一步根据余弦退火比例选出全局置信度最高的 N 个位置进行揭开,选取标准仅依赖于该位置的类别预测置信度,不包含任何空间结构约束。
在 Stage1 墙壁生成中,墙壁的核心特性是**连通性**
1. 所有墙壁必须与外围墙壁(地图边框)连通
2. 墙壁必须形成闭合或半闭合的房间边界
3. 墙壁的合理性完全由其邻域关系决定——一个孤立的墙块在游戏中毫无意义
MaskGIT 的全局置信度排序无视了这些空间邻接约束。一个远离外围墙壁的内部位置,即使模型以 99% 置信度预测为墙壁,也不应该被单独采出——因为游戏规则要求墙壁必须连通。当前机制却会欣然接受这个"高置信度的错误"。这本质上是全局独立采样与局部连续性要求之间的结构性矛盾。
类比:
| 机制 | 类比 | 问题 |
| ------------------ | ------------------------------------ | ---------------- |
| MaskGIT 全局 Top-K | 在所有空白处随机填色,先填最"确信"的 | 可能出现孤立色块 |
| 墙壁生长算法 | 从已有色块边缘逐步向外扩展 | 保证连通性 |
---
## 核心思路:生长式墙壁生成
### 基本思想
将 Stage1 从"全局置信度排序 + 逐步解掩码"改为**边界生长**Frontier Growth
1. 初始状态外围一圈固定为墙壁tile=1内部全部为 MASK
2. 每步:模型预测所有位置,但**只在已存在墙壁的邻接位置**接受墙壁预测
3. 新产生的墙壁成为下一轮的"种子",继续向外生长
4. 直到没有新的邻接候选位置或达到最大步数
```
Step 0 Step 1 Step 2 Step 3
####### ####### ####### #######
#.....# #.....# #...#.# #.###.#
#.....# #..#..# #.###.# #####.#
#.....# #.....# #.....# #.#.#.#
####### ####### ####### #######
# = 墙壁 . = MASK/空地
```
### 与原 MaskGIT 的对比
| 维度 | 原 MaskGIT全局 Top-K | 生长算法(邻接约束) |
| ---------- | ------------------------ | ------------------------------------------ |
| 选取机制 | 全局置信度排序取 Top-K | 只在邻接已存在墙壁的位置候选中按置信度选取 |
| 空间约束 | 无,独立决策 | 强制连通:新墙壁必须"挨着"已有墙壁 |
| 墙壁连通性 | 不保证,依赖模型隐式学习 | 结构性保证 |
| 生成过程 | 并行:一步可揭开任意位置 | 扩散式:从边界逐步向内推进 |
| 收敛步数 | 固定 18 步 | 动态,取决于地图墙壁密度 |
---
## 算法详细设计
### 数据结构
```
地图状态矩阵 state[13][13],每格取值:
0: FLOOR空地/地面)
1: WALL墙壁
6: MASK待生成
外墙固化state 中位于最外圈row=0, row=12, col=0, col=12的位置
在初始化时设为 tile=1WALL并在整个过程中保持不可修改
```
### 主算法
```python
def wall_growth_sample(
model, z, z_dist, struct, target_density,
max_steps: int = 24
) -> np.ndarray:
# 1. 初始化:外圈墙壁 + 内部 MASK
state = torch.full((1, MAP_SIZE), MASK_TOKEN, dtype=torch.long)
state[0, outer_wall_indices] = 1 # 固化外圈
for step in range(max_steps):
# 2. 模型前向:预测所有位置的 logits
remain = compute_remaining(state, target_density, stage=1)
logits = model(state, z, z_dist, struct, remain)
probs = F.softmax(logits, dim=-1) # [1, MAP_SIZE, NUM_CLASSES]
wall_probs = probs[0, :, 1] # 每个位置预测为墙壁的概率
# 3. 寻找生长前沿:哪些 MASK 位置与已有墙壁四连通相邻
mask_positions = (state[0] == MASK_TOKEN)
adjacent_to_wall = compute_adjacency_mask(state[0]) # [MAP_SIZE] bool
candidates = mask_positions & adjacent_to_wall
if candidates.sum() == 0:
break # 没有可生长的位置,终止
# 4. 在候选位置中选出置信度最高的墙壁预测
cand_conf = wall_probs[candidates]
num_to_reveal = compute_reveal_count(step, max_steps, candidates.sum())
_, top_indices = torch.topk(cand_conf, k=num_to_reveal)
cand_positions = candidates.nonzero(as_tuple=True)[0][top_indices]
state[0, cand_positions] = 1 # 设为墙壁
# 5. 剩余 MASK 位置 → 空地
state[0, state[0] == MASK_TOKEN] = 0
return state[0].cpu().numpy().reshape(MAP_H, MAP_W)
```
### 邻接计算
```python
def compute_adjacency_mask(flat_state: torch.Tensor) -> torch.Tensor:
# 返回 [MAP_SIZE] 布尔张量True 表示该位置与任意墙壁 4-邻接
state_2d = flat_state.reshape(MAP_H, MAP_W)
wall = (state_2d == 1)
# 上/下/左/右 各 shift 一位取 OR
adj = torch.zeros_like(wall, dtype=torch.bool)
adj[:, 1:] |= wall[:, :-1] # 上方有墙壁
adj[:, :-1] |= wall[:, 1:] # 下方有墙壁
adj[1:, :] |= wall[:-1, :] # 左方有墙壁
adj[:-1, :] |= wall[1:, :] # 右方有墙壁
return adj.flatten()
```
### 每步揭开数量调度
与标准 MaskGIT 的余弦退火调度不同,生长算法在前期应积极揭开(扩大生长前沿),后期逐渐放缓(精细收束):
```python
def compute_reveal_count(step: int, max_steps: int, num_candidates: int) -> int:
# 前期多揭(扩大前沿),后期少揭(精细控制)
t = step / max_steps
ratio = 1.0 - t ** 0.5 # 从 1.0 降至 0.0,前期保持高位
# 不能超过当前候选数量,至少揭 1 个(若有候选)
return max(1, min(num_candidates, math.ceil(num_candidates * ratio)))
```
备选:沿用在生长前沿上的余弦退火——效果需通过实验验证。
### 候选类型扩展FLOOR 的处理
上述算法仅在候选位置接受墙壁tile=1预测。另一种思路是同时接受 FLOORtile=0预测让"空地"也能与墙壁一同向外推进:
| 策略 | 描述 | 优缺点 |
| ------------------ | -------------------------------------------------- | ------------------------------------------ |
| 仅接受墙壁 | 候选位置只接受 tile=1 预测 | 简单,墙壁生长可控;但后期可能挤压空地空间 |
| 同时接受墙壁和空地 | 候选位置按模型预测的最高置信度类别决定是墙还是空地 | 更接近自然生长;但对模型预测质量要求更高 |
推荐先使用"仅接受墙壁"策略,原因是:空地本身没有连通性要求,一旦墙壁骨架确定,空地自然就是剩余位置。实验验证阶段可对比两种策略。
### 终止条件
生长在以下条件之一满足时终止:
1. **无候选位置**:所有 MASK 位置均不邻接现有墙壁——墙壁已完全生长,剩余位置就是空地
2. **达到最大步数**`max_steps` 上限防止极端情况无限循环参考值24 步,对应 13×13 地图)
---
## Stage1 训练方案:矩形掩码 + 连通损失
### 设计动机
标准 MaskGIT 训练使用 `std_mask()`50% 随机散布掩码 / 50% 随机分块掩码),在整图上生成破碎的 MASK 图案。这种训练方式与生长式推理**完全不匹配**
- **训练**:模型看到散落各处的孤立 MASK token学习从全局上下文独立预测每个位置
- **推理**MASK 区域是一个连续的边界生长前沿,模型需要在已有墙壁的基础上向外延伸
训练与推理的分布不一致("train-test mismatch")导致模型在推理时表现不佳:模型从未在训练中见过"全连通空白区域从边界开始生长"的场景。
**核心改造**:将 Stage1 训练改为矩形掩码rectangular masking且只在掩码区域内与已有墙壁连通的位置计算损失。这使训练任务与推理任务完全对齐——都是"从已有墙壁向外延伸填充一个连续区域"。
### 矩形掩码生成
直接使用 `train_seperated.py` 中已有的 `rect_mask` 函数(第 419 行),无需修改。调用方式:
```python
from ginka.train_seperated import rect_mask
mask = rect_mask(ratio=0.5) # 反复放置随机矩形直到覆盖约 50% 面积
```
现有实现通过反复叠加随机小矩形2×2 ~ 7×7来达到目标遮盖比例形状不规则但保证连续性。
### 连通损失计算
仅对掩码区域内**与外部墙壁 4-连通**的位置计算 CE Loss。原因生长算法推理时只有能从已有墙壁"触及"的位置才有机会被生长覆盖——模型不需要学习预测生长前沿触及不到的位置。
```
原图: MASK阴影区: 连通位置(★):
########### ########### ###########
#.........# #.........# #.........#
#.###.....# #.###.....# #.###.....#
#.#.#.....# → #▓▓▓▓▓▓▓..# → #.★▓▓▓▓..# ← MASK 内左下角为墙,
#.....###.# #▓▓▓▓▓▓▓##.# #▓▓▓▓▓▓★#.# 因与外部墙连通故参与 loss
####### ####### ####### MASK 内右上方为地板,不参与 loss
# = 墙 . = 地板 ▓ = MASK 区域 ★ = 参与 loss 的位置
```
具体算法:
```python
def compute_stage1_loss_mask(
rect_mask: np.ndarray, target1: np.ndarray, inp1: np.ndarray
) -> np.ndarray:
# rect_mask: [H, W] boolTrue=被矩形掩码覆盖
# target1: [H, W] intGT 墙壁骨架(仅含 0=floor, 1=wall
# inp1: [H, W] int输入地图MASK 区域为 MASK_ID其余保留原值
# 返回: [H, W] boolTrue=该位置需要计算 loss
# 策略:在 rect_mask 内,仅对从"外部墙壁"可 4-连通触及的 WALL 位置计算 loss
H, W = rect_mask.shape
MASK_ID = 7
loss_mask = np.zeros((H, W), dtype=bool)
# 1. 找到外部墙壁(不在 rect_mask 内且值为 WALL=1 的位置)
external_wall = (~rect_mask) & (inp1 == 1)
# 2. 从外部墙壁出发 BFS沿 target1==1 传播,找到所有连通墙壁
visited = np.zeros((H, W), dtype=bool)
# BFS/DFS 种子:外部墙壁位置
seeds = np.argwhere(external_wall)
for r, c in seeds:
if visited[r, c]:
continue
stack = [(r, c)]
visited[r, c] = True
while stack:
cr, cc = stack.pop()
for nr, nc in [(cr-1, cc), (cr+1, cc), (cr, cc-1), (cr, cc+1)]:
if 0 <= nr < H and 0 <= nc < W:
if not visited[nr, nc] and target1[nr, nc] == 1:
visited[nr, nc] = True
stack.append((nr, nc))
# 3. 连通位置 = rect_mask 内 & 被 BFS 访问到的位置(即与外部墙连通的墙壁)
loss_mask = rect_mask & visited
return loss_mask
```
关键逻辑:
1. **外部墙壁** = 不在掩码区域内且值为 WALL 的位置
2. **BFS 传播** = 从外部墙壁出发,沿 GT 中值为 WALL(1) 的格子 4-连通扩散,标记所有可达的墙壁位置
3. **Loss 掩码** = 掩码区域内、且被 BFS 标记为可达的位置
注意BFS 使用 GTtarget1而非输入inp1因为需要标注的是"GT 中与外部墙壁连通的墙壁分布",而非输入中已存在的墙壁。即使掩码区域内的某个墙壁位置在输入中被 MASK 覆盖(值为 MASK_ID只要它在 GT 中与外部墙壁连通,就应该参与 loss。
loss_mask 中:
- **True** = 掩码区域内、与外部墙壁连通的墙壁位置 → 模型应该预测 WALL
- **False** = 掩码区域内的其他位置(地板、或与外部墙壁不连通的孤立墙壁) → 不参与 loss
### 对比:新旧训练方案
| 维度 | 旧方案MaskGIT std_mask | 新方案(矩形掩码 + 连通损失) |
| ---- | ------------------------- | ---------------------------- |
| 掩码形状 | 随机散布 + 随机方块叠加 | 连续矩形块(复用 `rect_mask` |
| MASK 覆盖比例 | Beta(2,2),约 5%~95% | 可由 `ratio` 参数控制 |
| loss 计算范围 | 所有 MASK 位置 | 仅 MASK 区域中与外部墙壁连通的位置 |
| 训练-推理一致性 | 低:散点 MASK vs 连续生长 | 高:矩形空缺 vs 连续生长边界 |
| 模型学习目标 | 从破碎上下文预测单个位置 | 从已有墙壁向外延伸填补连续区域 |
### 生长式推断
生长算法仅影响**Stage1 的推理采样方式**。推理时:
1. 初始状态外围一圈固定为墙壁tile=1内部全部为 MASK
2. 每步:模型预测所有位置,但只在已存在墙壁的邻接位置接受墙壁预测
3. 新产生的墙壁成为下一轮的"种子",继续向外生长
4. 直到没有新的邻接候选位置或达到最大步数
训练时使用矩形掩码 + 连通损失,推理时使用生长算法——两者在概念上完全一致:都是"从已有墙壁向外延伸"。训练为推理做了准备的准备teacher forcing推理则自回归地展开autoregressive rollout
### 推理管线影响
```
当前推理管线:
Stage1 (MaskGIT Top-K) → floor/wall 骨架
Stage2 (MaskGIT) → +门/怪物/入口
Stage3 (MaskGIT) → +资源
修改后推理管线:
Stage1 (生长算法) → floor/wall 骨架 [改动点]
Stage2 (MaskGIT) → +门/怪物/入口 [不变]
Stage3 (MaskGIT) → +资源 [不变]
```
Stage2 和 Stage3 的训练与推理**保持不变**——它们继续使用标准 MaskGIT 方法。这是因为门/怪物/资源是稀疏离散元素不涉及全局连通性约束MaskGIT 的独立 token 预测对此类任务足够有效。
### 模型参数
生长算法**不改变模型结构**GinkaMaskGIT 的 Transformer 架构不变),仅修改:
- `dataset.py`Stage1 的 `apply_subset*` 方法,将 MaskGIT 掩码策略改为矩形掩码 + 连通损失掩码
- `train_seperated.py`:新增 `wall_growth_sample` 函数替代 `maskgit_sample` 的 Stage1 分支
- 训练 loss 计算中Stage1 的 CE Loss 需要乘以 `loss_mask` 进行筛选
模型检查点格式与 Stage2/3 训练流程不受影响。
---
## 潜在风险与应对
| 风险 | 描述 | 应对策略 |
| -------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| 墙壁密度过高 | 生长不退让,可能填满所有可达区域 | 增加 target_density 的约束权重;在后期步数中降低揭开速率 |
| 生长形状单一 | 从外圈均匀向内心扩散,产生"洋葱圈"形墙壁 | 引入随机扰动(每步以一定概率跳过某些候选位置);调整 target_density 分布 |
| 屋顶封闭 | 生长过早将空地包围封闭,导致内部无法再生长墙壁 | 在揭开判断中加入"是否会导致孤岛"的后处理检查 |
| 模型能力不匹配 | 生长算法依赖模型在邻接位置的预测质量,若模型本身不能捕捉邻接关系则效果有限 | 可结合已有的二维因式位置嵌入,确保模型具备足够的局部感知能力 |
| 步数不均匀 | 不同地图墙壁密度不同,生长步数的固定上限可能不够或过早收敛 | 将 max_steps 设为动态参数,或改为"无新墙即终止"的收敛策略 |
### 墙壁"洋葱圈"问题专项说明
若每步以固定速率在所有邻接生长前沿均匀揭开,且模型倾向于预测墙壁,则墙壁可能从外围逐层向内均匀扩散,形成洋葱圈状结构。应对策略:
1. **target_density 约束**:靶向墙壁密度作为硬约束,当已生成的墙壁数量接近目标密度时,大幅降低每步揭开速率
2. **非均匀揭开**:每步仅在候选位置中随机选取子集,而非全部可选中的最高置信度位置,增加生长路径的随机性
3. **后处理修剪**:生成完成后,对墙壁进行一次 BFS 修剪,移除不改变连通性的"冗余墙壁"分支
---
## 实施计划
### 数据集修改
- [ ] Stage1 训练中直接调用已有 `rect_mask` 替代 `std_mask`
- [ ] 新增 `compute_stage1_loss_mask` 函数BFS 计算连通损失掩码
- [ ]`__getitem__` 返回的 sample 中新增 `"loss_mask_stage1"` 字段
### 推理修改
- [ ]`train_seperated.py` 中实现 `wall_growth_sample` 函数
- [ ] 修改 `full_generate_specific_z` 中 Stage1 的调用,替换为生长算法
- [ ] 实现 `compute_adjacency_mask` 工具函数(四邻接检测)
- [ ] 实现生长步数调度 `compute_reveal_count`
### 训练脚本修改
- [ ] Stage1 的 CE Loss 改为 `(F.cross_entropy(..., reduction='none') * loss_mask_stage1.float()).sum() / loss_mask_stage1.sum()`
- [ ] Stage2/3 的训练与原有逻辑保持一致,不做修改
### 验证
- [ ] 单步测试:加载已有检查点,运行生长算法一次,输出墙壁骨架地图
- [ ] 添加可视化对比:同一组 z 下MaskGIT Top-K vs 生长算法的 Stage1 输出
- [ ] 对比指标:墙壁连通率、孤立墙块数、房间闭合度