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

18 KiB
Raw Blame History

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并在整个过程中保持不可修改

主算法

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)

邻接计算

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 的余弦退火调度不同,生长算法在前期应积极揭开(扩大生长前沿),后期逐渐放缓(精细收束):

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 行),无需修改。调用方式:

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 的位置

具体算法:

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.pyStage1 的 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 输出
  • 对比指标:墙壁连通率、孤立墙块数、房间闭合度