maliang / code /gc25d /tests /test_prep.py
teawhite's picture
Add files using upload-large-folder tool
50762c1 verified
Raw History Blame Contribute Delete
14.3 kB
"""prep_v10 的快速单测(CPU):块 / prompt 对齐、扩展有效性、split 规则、原子写 / resume / merge。
PYTHONPATH=code/gc25d:code/arope .venv/bin/python -m pytest -q code/gc25d/tests/test_prep.py
最后一个用例在真实数据上跑一个 clip 的 worker(--no_vae 路径,≈10 s),数据不在就跳过。
"""
from __future__ import annotations
import json
import os
import numpy as np
import pytest
import torch
import geometry as GEO
import prep_v10 as P
import text as TX
N_FRAMES, N_BLOCKS = 4800, 1200
def synth_blocks(n=N_BLOCKS) -> np.ndarray:
"""block b 的 [w,a,s,d,attack] = b 的低 5 位:每块各不相同(模 32),对齐错一位就能看出来。"""
b = np.arange(n)
return np.stack([(b >> k) & 1 for k in range(5)], 1).astype(np.uint8)
# ---------------------------------------------------------------- 块 / prompt 对齐
@pytest.mark.parametrize("s", [0, 96, 1536, 4608])
def test_block_prompt_alignment(s):
AB = synth_blocks()
b20 = P.clip_blocks(AB, s)
assert (b20 == AB[s // 4: s // 4 + 20]).all()
cb = TX.cell_blocks(b20)
assert cb.shape == (21, 5) and (cb[0] == cb[1]).all()
for j in range(1, 21): # cell j ← block s//4 + j − 1(帧 4j−3 … 4j 由该块的按键产生)
assert (cb[j] == AB[s // 4 + j - 1]).all()
pr = TX.cell_prompts(b20)
assert pr == [TX.block_to_prompt(*r.tolist()) for r in cb]
eb = P.ext_blocks(AB, s) # 扩展 cell 21..27 ← block s//4+20 … s//4+26
assert eb.shape == (7, 5)
for k, j in enumerate(range(21, 28)):
assert (eb[k] == AB[s // 4 + j - 1]).all()
# cell 21..27 覆盖帧 81..108(= cond_ext 的 28 帧)
assert (4 * 21 - 3, 4 * 27) == (P.F_CLIP, P.F_SPAN - 1) and P.F_EXT == 4 * P.N_EXT_CELLS
def test_ext_blocks_past_run_end():
AB = synth_blocks()
with pytest.raises(AssertionError):
P.ext_blocks(AB, 4704) # 最后一个 clip:块 1196..1202 越界
# ---------------------------------------------------------------- 扩展有效性
def _ep_cam():
ep = np.zeros(N_FRAMES, np.int32)
cam = np.ones(N_FRAMES, np.int32)
return ep, cam
def _valid(ep, cam, s, n_blocks=N_BLOCKS):
return P.ext_valid(ep[s:s + P.F_SPAN], cam[s:s + P.F_SPAN], s, n_blocks)
def test_ext_valid_constant():
ep, cam = _ep_cam()
assert _valid(ep, cam, 0) and _valid(ep, cam, 96 * 48) # 4608 + 108 = 4716 < 4800
assert not _valid(ep, cam, 96 * 49) # 4704:帧 4812 越界(窗口不满 109)
@pytest.mark.parametrize("off,ok", [(81, False), (100, False), (108, False), (109, True), (1, False)])
def test_ext_valid_episode_change(off, ok):
s = 960
ep, cam = _ep_cam()
ep[s + off:] = 1
assert _valid(ep, cam, s) == ok
@pytest.mark.parametrize("off,ok", [(81, False), (95, False), (108, False), (109, True)])
def test_ext_valid_camera_change(off, ok):
s = 960
ep, cam = _ep_cam()
cam[s + off:] = 2
assert _valid(ep, cam, s) == ok
def test_ext_valid_blocks_and_short_window():
ep, cam = _ep_cam()
s = 960
assert _valid(ep, cam, s, n_blocks=s // 4 + 27)
assert not _valid(ep, cam, s, n_blocks=s // 4 + 26) # 块 s//4+26 不存在
assert not P.ext_valid(ep[s:s + 108], cam[s:s + 108], s, N_BLOCKS)
# ---------------------------------------------------------------- split / drop
def test_split_rule():
val = ["R10_s1512", "R10_s3512", "R10_s5512", "R10_s7511", "R10_s9511",
"P9_golden_s2909", "P9_autumn_s4908", "P9C_golden_s6946", "P9C_default_s8946", "P9C_snow_s10945"]
assert set(val) == P.VAL_RUNS
assert all(P.split_of(r) == "val" for r in val)
for r in ("R10_s1501", "R10_s2512", "R10_s10511", "P9C_autumn_s1941", "P9_default_s1909", "P9C_snow_s10944"):
assert P.split_of(r) == "train"
assert all(r in P.SPLIT_RULE for r in val)
def _fake_src(tmp_path, runs):
"""假 src:<subset>/<scene>/<run>/index/{clips_81.json, episodes.npy}。"""
src = tmp_path / "src"
for sub, scene, run in runs:
d = src / sub / scene / run / "index"
d.mkdir(parents=True)
clips = [{"start": 96 * k, "end": 96 * k + 81, "episode": 0, "camera_variant": 1, "look": 0, "look_name": "x",
"centered": True, "max_frame_offset_cm": 0.0, "events": [{}] * (k % 2)} for k in range(50)]
(d / "clips_81.json").write_text(json.dumps(clips))
np.save(d / "episodes.npy", np.zeros(N_FRAMES, np.int32))
return str(src)
def test_plan_split_drop(tmp_path):
src = _fake_src(tmp_path, [("v9_stylized", "02_B", "P9_golden_s2909"), ("v10_real", "01_A", "R10_s1501"),
("v10_real", "01_A", "R10_s1512")])
runs = P.list_runs(src)
assert [r[2] for r in runs] == ["R10_s1501", "R10_s1512", "P9_golden_s2909"] # v10_real 在前
assert [r[2] for r in P.list_runs(src, ["P9_golden_s2909"])] == ["P9_golden_s2909"]
with pytest.raises(SystemExit):
P.list_runs(src, ["nope"])
buried = {P.clip_id_of("01_A", "R10_s1501", 192)}
tasks = P.plan_tasks(runs, None, buried)
assert len(tasks) == 150 and len({t["clip_id"] for t in tasks}) == 150
by = {t["clip_id"]: t for t in tasks}
t = by["01_A__R10_s1501__000192"]
assert t["drop"] and t["drop_reason"] == P.DROP_REASON and t["split"] == "train" and t["n_events"] == 0
assert by["01_A__R10_s1512__000096"]["split"] == "val" and not by["01_A__R10_s1512__000096"]["drop"]
assert by["01_A__R10_s1512__000096"]["n_events"] == 1
assert {t["split"] for t in tasks if t["run"] == "P9_golden_s2909"} == {"val"}
sub = P.plan_tasks(runs[:1], 4)
assert [t["start"] for t in sub] == [0, 96 * 16, 96 * 33, 96 * 49] # 均匀取,含首尾
# ---------------------------------------------------------------- 原子写 / resume / merge
def test_atomic_savez_and_torch_save(tmp_path):
p = str(tmp_path / "a.npz")
P.atomic_savez(p, ids=np.arange(10, dtype=np.uint8), s=np.array(["x", "yy"]))
with np.load(p, allow_pickle=False) as z:
assert (z["ids"] == np.arange(10)).all() and z["s"].tolist() == ["x", "yy"]
q = str(tmp_path / "b.pt")
P.atomic_torch_save(q, {"latent": torch.zeros(2, dtype=torch.bfloat16), "prompts": ["a"]})
assert torch.load(q, weights_only=True)["prompts"] == ["a"]
assert sorted(os.listdir(tmp_path)) == ["a.npz", "b.pt"] # 没有残留 tmp
def _row(cid, run="R10_s1501", scene="01_A", start=0, has_ext=True, subset="v10_real"):
return {"clip_id": cid, "subset": subset, "scene": scene, "run": run, "start": start, "split": P.split_of(run),
"drop": False, "has_ext": has_ext, "res": GEO.RES_TAG}
def test_resume_and_merge(tmp_path):
out = str(tmp_path / "out")
for d in ("index", "latents", "cond", "cond_ext", "meta"):
os.makedirs(os.path.join(out, d))
a, b, c = (P.clip_id_of("01_A", "R10_s1501", s) for s in (0, 96, 192))
tasks = [{"clip_id": x} for x in (a, b, c)]
pa, pb = P.clip_paths(out, a), P.clip_paths(out, b)
for k in ("latent", "cond", "cond_ext", "meta"):
open(pa[k], "wb").close()
for k in ("cond", "meta"):
open(pb[k], "wb").close()
open(pb["latent"] + ".tmp123", "wb").close() # 被打断的 latent 写入
with open(os.path.join(out, "index", "rows_rank0.jsonl"), "w") as fh:
fh.write(json.dumps(_row(a, start=0)) + "\n" + json.dumps(_row(b, start=96, has_ext=False)) + "\n")
fh.write('{"clip_id": "trunc') # 被杀时的半行
assert P.outputs_exist(pa, True, True) and not P.outputs_exist(pb, False, True) and P.outputs_exist(pb, False, False)
assert P.done_clips(out, tasks, vae=True) == {a}
assert P.done_clips(out, tasks, vae=False) == {a, b}
# has_ext 的 clip 缺 cond_ext ⇒ 没完成
os.remove(pa["cond_ext"])
assert P.done_clips(out, tasks, vae=True) == set()
# merge:两个 rank 文件、重复行去重、按 (subset, scene, run, start) 排序
with open(os.path.join(out, "index", "rows_rank1.jsonl"), "w") as fh:
fh.write(json.dumps(_row(c, start=192)) + "\n" + json.dumps(_row(a, start=0)) + "\n")
fh.write(json.dumps(_row("02_B__P9_golden_s2909__000000", run="P9_golden_s2909", scene="02_B",
subset="v9_stylized")) + "\n")
with pytest.raises(SystemExit):
P.merge(out, expect=[a, b, c, "missing"])
rows = P.merge(out, expect=[a, b, c])
assert [r["clip_id"] for r in rows] == [a, b, c, "02_B__P9_golden_s2909__000000"]
lines = open(os.path.join(out, "index", "clips.jsonl")).read().splitlines()
assert len(lines) == 4
runs = json.load(open(os.path.join(out, "index", "runs.json")))
assert runs["R10_s1501"] == "train" and runs["P9_golden_s2909"] == "val" and "_rule" in runs
def test_open_rows_seals_torn_line(tmp_path):
"""上次被杀留下半行 ⇒ resume 追加的第一行不能粘在半行后面(否则 merge 报缺行)。"""
out = str(tmp_path / "out")
os.makedirs(os.path.join(out, "index"))
a, x = (P.clip_id_of("01_A", "R10_s1501", s) for s in (0, 96))
p = os.path.join(out, "index", "rows_rank0.jsonl")
with open(p, "w") as fh:
fh.write(json.dumps(_row(a)) + "\n" + json.dumps(_row(x, start=96))[:40])
for _ in range(2): # 第二次打开:文件已以 '\n' 结尾,不再补空行
with P.open_rows(p) as fh:
fh.write(json.dumps(_row(x, start=96)) + "\n")
assert set(P.read_rows(out)) == {a, x}
assert open(p).read().count("\n") == 4 # A、半行封口、X、X
assert [r["clip_id"] for r in P.merge(out, expect=[a, x])] == [a, x]
q = os.path.join(out, "index", "rows_rank1.jsonl") # 新文件 / 空文件:不补
with P.open_rows(q) as fh:
pass
assert open(q).read() == ""
def test_check_raw_ids():
raw = np.zeros((704, 1280), np.uint8)
raw[:, :7] = np.arange(7)
raw[0, 100], raw[0, 101] = 10, 11
P.check_raw_ids(raw, "ok")
for v in (7, 12, 16, 21, 255): # 12..21 是 label_parts 的输出值,源里不该有
bad = raw.copy()
bad[5, 5] = v
with pytest.raises(AssertionError, match=str(v)):
P.check_raw_ids(bad, "bad")
@pytest.mark.filterwarnings("ignore:This process .* is multi-threaded")
def test_die_with_parent():
"""子进程设 PDEATHSIG 后,父进程直接 _exit(模拟主进程被 SIGKILL)⇒ 子进程也退出,不变成孤儿。"""
import signal
import time
r, w = os.pipe()
mid = os.fork()
if mid == 0: # 中间进程(= 主进程)
os.close(r)
gc = os.fork()
if gc == 0: # 孙进程(= rank)
signal.signal(signal.SIGTERM, signal.SIG_DFL)
P.die_with_parent(os.getppid())
os.write(w, b"ready")
time.sleep(30)
os._exit(0)
os.write(w, f"{gc:010d}".encode())
time.sleep(0.5) # 等孙进程设好 prctl
os._exit(0)
os.close(w)
gpid = int(os.read(r, 10).decode())
os.waitpid(mid, 0)
def alive(pid):
try:
with open(f"/proc/{pid}/stat") as fh:
return fh.read().split(")")[-1].split()[0] != "Z"
except FileNotFoundError:
return False
t = time.time()
while alive(gpid) and time.time() - t < 5:
time.sleep(0.05)
os.close(r)
leaked = alive(gpid)
if leaked:
os.kill(gpid, signal.SIGKILL)
assert not leaked, "父进程退出后子进程还活着(PDEATHSIG 没生效)"
def test_ground_z():
caps = np.full((2, 2, 12, 7), np.nan, np.float32)
caps[0, 0, 0] = [0, 0, 50, 0, 0, 100, 10] # a.z 50, b.z 100, r 10 ⇒ 40
caps[0, 0, 7] = [0, 0, 30, 0, 0, 20, 5] # min(30,20) − 5 = 15
caps[0, 1, 0] = [0, 0, -500, 0, 0, -500, 1] # boss 不算
gz = P.ground_z(caps)
assert gz.dtype == np.float32 and gz[0] == 15 and np.isnan(gz[1])
def test_vae_input_lut_matches_cpu_path():
u8 = np.arange(256, dtype=np.uint8).reshape(1, 1, 256, 1).repeat(3, -1)
ref = torch.from_numpy(u8).permute(3, 0, 1, 2).float().div_(127.5).sub_(1.0) # infer.encode_video 的 CPU 写法
got = P._U8_TO_PM1[torch.from_numpy(u8).long()].permute(3, 0, 1, 2)
assert torch.equal(ref, got) and ref.stride() == got.stride()
# ---------------------------------------------------------------- 真实数据一个 clip(无 VAE)
RUN_DIR = os.path.join(P.SRC_DEFAULT, "v10_real", "01_Sunlit_Mediterranean_Courtyard", "R10_s1501")
@pytest.mark.skipif(not os.path.isdir(RUN_DIR), reason="v10 数据不在")
def test_real_clip_no_vae(tmp_path):
import model as GM
runs = [r for r in P.list_runs(P.SRC_DEFAULT, ["R10_s1501"])]
t = [x for x in P.plan_tasks(runs) if x["start"] == 96][0]
out = str(tmp_path)
for d in ("cond", "cond_ext", "meta"):
os.makedirs(os.path.join(out, d))
r = P.prep_clip(t, out, vae=False)
assert not r["skip"] and r["rgb"] is None and r["row"]["has_ext"]
with np.load(P.clip_paths(out, t["clip_id"])["cond"]) as z:
ids = z["ids"]
with np.load(P.clip_paths(out, t["clip_id"])["cond_ext"]) as z:
assert z["ids"].shape == (28, GEO.DST_H, GEO.DST_W) and z["blocks"].shape == (7, 5) and len(z["prompts"]) == 7
with np.load(P.clip_paths(out, t["clip_id"])["meta"]) as z:
assert z["capsules"].shape == (109, 2, 12, 7) and z["world_to_clip"].shape == (109, 16)
assert (z["player_px"][:81] == (GM.collapse_parts(ids) == 10).sum((1, 2))).all()
assert ids.shape == (81, GEO.DST_H, GEO.DST_W) and set(np.unique(ids).tolist()) <= GM.valid_raw_ids("parts") - {255}
assert r["row"]["res"] == GEO.RES_TAG
assert r["pt"]["prompts"] == TX.cell_prompts(r["pt"]["actions_block"][1:])
# 第二次:输出都在 ⇒ 只补行,不解码
r2 = P.prep_clip(t, out, vae=False)
assert r2["skip"] and r2["row"] == r["row"]