Files
agentmc/tests/test_photo.py
JianFeeeee 4980d6e81f M8/M9:细渲染接服务端真状态 + capture_photo 拍照用例
新增 mcfold/fine.py:把 M3 状态层的区块灌进细渲染器
- chunks_to_sections:铺平顺序 = (y<<8)|(z<<4)|x(与 Java PalettedContainer 一致)
  ★ 错一位不报错、只是地形静默错位 ⇒ 判据用构造数据打这个点
- 缺失的 section **省略**而非填 0(0=空气,把「没数据」渲染成空洞)
- render_from_state:单次调用,失败全部返回 ok:false + why + how_to

新增 mcfold/photo.py:capture_photo
- propose_shots 用**粗渲染观察**生成机位候选(正面/斜侧/俯视/远景/当前位置),
  每个都带「为什么选它」的依据;候选阶段不出图(省时省钱)
- 构图好坏是审美判断=决策,工具不替 agent 选

★ 关键发现:桥接比预想的干净
原以为要把 state id 反算成 name[props],但 Java 侧 set_blocks 收的
**本来就是 state id**(走官方 LevelChunk API),而 state id 本身含属性
⇒ 无损、无需转换。

修的真 bug:
1. ★★ HUD 没隐藏:桥层 RenderBackend.set_view 早有 hide_gui
   (→ 官方 options.hideGui),但 WorldRenderer **没有这个入口**
   ⇒ AttributeError 被 except: pass 吞掉 ⇒ 出图带生命条/饥饿/物品栏,
   而返回值仍写 hide_gui: True(自相矛盾)
   教训:桥层有能力 ≠ 上层有入口;except: pass 让「以为设了」与
   「实际没设」无法区分。现在 hide_gui 设不上就显式失败。
2. 俯视机位朝向不自洽(水平 yaw 退化却配写死的 pitch)→ 3D dot=0.82。
   改为由位置向量反算 yaw/pitch;正面/斜侧/远景同样修正。
3. inject_world 的 ok 自相矛盾(无区块时 ok=True 却说「没有可渲染的区块」)
4. 缺失 section 被填 0 冒充空气

实测出图:854×480、1902 种颜色,地形/天空/月亮/草地纹理正确。

判据 17 项;2 项变异全被抓。
★ 「机位朝向」判据我第一版只比水平分量 ⇒ 俯视机位恒为 0 会判红,
  改为完整 3D 点积(dot > 0.99)。

200 项全通过。
2026-09-26 13:52:33 +08:00

459 lines
18 KiB
Python
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.

"""M8/M9 判据:fine.py(细渲染接真状态)+ photo.py(拍照用例)。
## 判据分两层
**可离线判的**(不需要 JVM,默认套件里跑):
- `chunks_to_sections` 的索引顺序(错一位地形就乱)
- 机位候选的**依据**是否成立(不能凭空给坐标)
- 失败必须可读(不抛异常)
- 空世界/未加载必须明确失败,不返回空图
**需要渲染环境的**(`--with-render`):
- 真出图并检查 distinctColors 等
## 为什么索引顺序要单独判
Java 侧按 `(y<<8)|(z<<4)|x` 遍历 4096 格。
铺平顺序错一位,**不报错**,只是地形整体错位(画出来是乱的)。
这类"静默错位"必须用构造数据判:给某坐标一个独特值,看它落在哪个下标。
"""
import sys, os, math
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from mcfold.chunk import Chunk, Section, BitArray
from mcfold.fine import chunks_to_sections, inject_world, render_from_state
from mcfold.photo import propose_shots, _facing_yaw
from mcfold.state import WorldState, load_vanilla_registry
DATA_ROOT = ('/home/newqqagent/plugin_data/mc/runtime/node_modules/'
'minecraft-data/minecraft-data/data')
_REG = load_vanilla_registry('1.21.1', DATA_ROOT)
GRASS = _REG.by_name["grass_block"]["minStateId"]
STONE = _REG.by_name["stone"]["minStateId"]
AIR = _REG.by_name["air"]["minStateId"]
class _ChunkLike:
def __init__(self, fn):
self.fn = fn
def state_id_at(self, x, y, z):
return self.fn(x, y, z)
class _FakeBridge:
"""记录 set_blocks 调用的假桥(不启 JVM)。"""
def __init__(self, fail_on=None):
self.calls: list[tuple[int, int, dict]] = []
self.fail_on = fail_on or set()
def set_blocks(self, cx, cz, sections):
if (cx, cz) in self.fail_on:
raise RuntimeError("模拟写入失败")
self.calls.append((cx, cz, sections))
return {"ok": True}
class _FakeRenderer:
def __init__(self, fail_on=None):
self.bridge = _FakeBridge(fail_on)
self.chunks: dict = {}
self._loaded: set = set()
def _real_chunk(fn, y_index=0):
"""造一个**真 Chunk**(inject_world 需要 .sections/state_id_at)。
★ 用假区块(只有 state_id_at)测不了 inject_world ——
它按 section 遍历,假对象没有 .sections ⇒ 会被记进 skipped,
于是测的是「假对象不支持」而不是「注入逻辑对错」。
这是判据 fixture 的坑:fixture 形状必须匹配被测代码的真实输入。
"""
return Chunk(x=0, z=0, sections=[Section(y_index=y_index, single_value=fn(0, -1, 0))])
def _scene(fn, chunks=((0, 0),)):
ws = WorldState(registry=_REG, num_sections=24)
for cx, cz in chunks:
ch = _real_chunk(fn)
ch.x, ch.z = cx, cz
ws.world.chunks[(cx, cz)] = ch
ws.player.x, ws.player.y, ws.player.z = 0.5, 0.0, 0.5
return ws
# ---------- ① 索引顺序(静默错位是最危险的) ----------
def test_section_linear_index_matches_java_layout():
"""★★ 铺平顺序必须是 `idx = (y<<8)|(z<<4)|x`(与 Java 的 PalettedContainer 一致)。
错一位**不报错**,只是地形整体错位(画出来是乱的)——
属于"静默错位",必须用构造数据判。
"""
# 单值 section:全 4096 格都是 42
s = Section(y_index=0, single_value=42)
secs = chunks_to_sections(Chunk(x=0, z=0, sections=[s]))
assert list(secs.keys()) == [0], secs.keys()
assert len(secs[0]) == 4096, len(secs[0])
assert all(v == 42 for v in secs[0]), "单值 section 应全为该值"
# 位数组 section:只把 (x=1,y=0,z=0) 设为 1 ⇒ 它必须落在下标 1
bits = BitArray([0b10], capacity=4096, bits_per_value=1)
s2 = Section(y_index=1, bits_per_block=1, palette=[7, 9], bits=bits)
secs2 = chunks_to_sections(Chunk(x=0, z=0, sections=[s2]))
c = secs2[1]
assert c[1] == 9, f"(x=1,y=0,z=0) 应落在下标 1,实得 {c[1]}"
assert c[0] == 7, f"(x=0,y=0,z=0) 应是 7,实得 {c[0]}"
# 多点验证:(x=2,y=0,z=1) ⇒ idx = (0<<8)|(1<<4)|2 = 18
# BitArray 没有 set(),直接构造 data:1 bit/value ⇒ 每 long 64 格,
# 偏移 = (index % 64) * 1
bits2 = BitArray([0] * 8, capacity=4096, bits_per_value=1)
bits2.data[18 // 64] |= 1 << ((18 % 64) * 1)
assert bits2.get(18) == 1, "先确认构造正确"
s3 = Section(y_index=2, bits_per_block=1, palette=[7, 9], bits=bits2)
c3 = chunks_to_sections(Chunk(x=0, z=0, sections=[s3]))[2]
assert c3[18] == 9, f"(x=2,z=1) 应落在下标 18,实得 {c3[18]}"
assert sum(1 for v in c3 if v == 9) == 1, "只应有一个格子被置位"
print(" ★★ 铺平索引 = (y<<8)|(z<<4)|x(与 Java 一致)")
def test_chunks_to_sections_uses_section_index():
"""★ section 的 y_index 决定它落在哪个下标(不能顺序错位)。"""
s_a = Section(y_index=0, single_value=GRASS)
s_b = Section(y_index=5, single_value=STONE)
secs = chunks_to_sections(Chunk(x=0, z=0, sections=[s_a, s_b]))
assert sorted(secs.keys()) == [0, 5], secs.keys()
assert secs[0][0] == GRASS and secs[5][0] == STONE
print(" ★ section 按下标归位(0 与 5)")
def test_missing_sections_are_omitted_not_zeroed():
"""★★ 缺失的 section **不产出**,不能填 0。
0 是空气 ⇒ 把「没数据」渲染成「空气」是撒谎
(本该是实心的地方变成空洞,看起来像被挖空了)。
"""
s = Section(y_index=3, single_value=STONE)
secs = chunks_to_sections(Chunk(x=0, z=0, sections=[s]))
assert list(secs.keys()) == [3], "只该有 y_index=3 这一个 section"
assert 0 not in secs and 1 not in secs, "缺失的 section 不该被填 0"
print(" ★★ 缺失 section 被省略(不填 0 冒充空气)")
# ---------- ② inject_world:不抛异常、如实报告 ----------
def test_inject_world_reports_counts():
ws = _scene(lambda x, y, z: GRASS if y < 0 else AIR)
r = _FakeRenderer()
out = inject_world(r, ws)
assert out["ok"] is True
assert out["injected"] == 1, out
assert out["skipped"] == []
assert r.bridge.calls and r.bridge.calls[0][0] == 0
print(f" ✓ inject_world 报告注入数({out['injected']})")
def test_inject_world_survives_write_failure():
"""★ 写入失败要记进 skipped 并继续(不是整批崩掉)。"""
ws = _scene(lambda x, y, z: GRASS if y < 0 else AIR, chunks=((0, 0), (1, 0)))
r = _FakeRenderer(fail_on={(1, 0)})
out = inject_world(r, ws)
assert out["injected"] == 1, out
assert len(out["skipped"]) == 1, out
assert out["skipped"][0]["column"] == [1, 0]
assert "模拟写入失败" in out["skipped"][0]["why"]
print(" ★ 单列写入失败:记入 skipped 且其余继续")
def test_inject_world_empty_is_honest():
"""★ 没有区块时 **ok=False 且说明原因**,不假装成功。"""
ws = _scene(lambda x, y, z: GRASS if y < 0 else AIR, chunks=())
r = _FakeRenderer()
out = inject_world(r, ws)
assert out["injected"] == 0
assert out["ok"] is False, out
assert "走动" in out["note"] or "加载" in out["note"], out["note"]
print(" ✓ 无区块:ok=False + 可执行建议")
# ---------- ③ render_from_state:失败必须可读(不抛异常) ----------
def test_render_from_state_empty_world_is_readable_failure():
"""★ 空世界出图 ⇒ 结构化失败 + how_to(不是 traceback)。"""
ws = _scene(lambda x, y, z: AIR, chunks=())
r = render_from_state(ws, store="/nonexistent-store")
assert r["ok"] is False, r
assert r.get("why"), r
assert r.get("how_to"), r
assert "空图" in r["why"] or "区块" in r["why"], r["why"]
print(f" ✓ 空世界:可读失败({r['why'][:40]}…)")
def test_render_from_state_bad_store_is_readable_failure():
"""★ store 缺产物 ⇒ 结构化失败(不抛异常)。"""
ws = _scene(lambda x, y, z: GRASS if y < 0 else AIR)
r = render_from_state(ws, store="/tmp/no-such-store-xyz")
assert r["ok"] is False, r
assert r.get("why") and r.get("how_to"), r
print(f" ✓ 坏 store:可读失败({r['why'][:44]}…)")
# ---------- ④ 机位候选:依据必须成立 ----------
def test_propose_shots_gives_reasons():
"""★ 每个机位都要说明**为什么选它**(不能凭空给坐标)。"""
ws = _scene(lambda x, y, z: GRASS if y < 0 else AIR)
r = propose_shots(ws, (5.0, 0.0, 0.0))
assert r["ok"] is True
assert r["count"] >= 3, r["count"]
for s in r["shots"]:
assert s["why"], f"机位 {s['name']} 没给依据"
assert len(s["camera"]) == 5, s # x,y,z,yaw,pitch
assert s["distance"] >= 0
assert "未出图" in r["source"] or "粗渲染" in r["source"]
print(f" ★ {r['count']} 个机位都带依据")
def test_propose_shots_yaw_points_at_target():
"""★★ 机位朝向必须**真的看向目标**(算错的症状是拍到别处)。
★ 判据要用**完整 3D 方向**,不能只看水平分量:
俯视机位在目标正上方 ⇒ 水平方向退化(dx,dz≈0),
只比水平的话 dot 恒为 0/无意义 ⇒ 会把正确的实现判红
(我第一版就犯了这个错)。
正确做法:把 yaw/pitch 还原成 3D 单位向量,与「机位→目标」向量比点积。
"""
ws = _scene(lambda x, y, z: GRASS if y < 0 else AIR)
target = (5.0, 0.0, 0.0)
r = propose_shots(ws, target)
for s in r["shots"]:
# ★ 「当前位置」是**故意**用玩家当前朝向的(它代表「就用你现在的视角」),
# 不保证对准给定目标 ⇒ 排除它,其余必须严格对准。
if s["name"] == "当前位置":
continue
fx, fy, fz, yaw, pitch = s["camera"]
# yaw/pitch → 3D 单位向量(MC 约定:yaw 0=+Z,pitch 负=向下)
y = math.radians(yaw)
p = math.radians(pitch)
vx = -math.sin(y) * math.cos(p)
vy = -math.sin(p)
vz = math.cos(y) * math.cos(p)
# 机位 → 目标
tx, ty, tz = target[0] - fx, target[1] - fy, target[2] - fz
n = math.sqrt(tx * tx + ty * ty + tz * tz)
if n < 1e-6:
continue # 与目标重合,无需朝向
dot = (vx * tx + vy * ty + vz * tz) / n
assert dot > 0.99, \
f"机位 {s['name']} 朝向没对准目标(3D dot={dot:.3f},camera={s['camera']})"
print(" ★★ 所有机位的 3D 朝向都指向目标(dot > 0.99)")
def test_facing_yaw_convention():
"""★ yaw 约定:0=+Z(南) 90=-X(西) 180=-Z(北) 270=+X(东)。"""
# 站在原点看向 +Z ⇒ yaw 应为 0
assert abs(_facing_yaw(0.0, 1.0, 0.0, 0.0) - 0.0) < 1e-6
# 看向 +X ⇒ 270
assert abs(_facing_yaw(1.0, 0.0, 0.0, 0.0) - 270.0) < 1e-6
# 看向 -Z ⇒ 180
assert abs(_facing_yaw(0.0, -1.0, 0.0, 0.0) - 180.0) < 1e-6
print(" ✓ 朝向计算符合 MC 约定")
def test_propose_shots_empty_world_still_returns_options():
"""★ 没有区块时也要给候选(但要如实说明脚下没支撑)。"""
ws = _scene(lambda x, y, z: AIR, chunks=())
r = propose_shots(ws, (5.0, 0.0, 0.0))
assert r["ok"] is True
# 至少要给「当前位置」这个零成本机位
assert any(s["name"] == "当前位置" for s in r["shots"]), r["shots"]
print(" ✓ 空世界仍给候选(含当前机位)")
# ---------- ⑤ 两个子系统的分工(不互相预测) ----------
def test_photo_uses_rough_for_candidates_not_rendering():
"""★ 机位候选来自**粗渲染观察**(M6),不是细渲染出图 —— 省时省钱。
plan §4.5:两者不做像素级互相预测。
"""
ws = _scene(lambda x, y, z: GRASS if y < 0 else AIR)
r = propose_shots(ws, (5.0, 0.0, 0.0))
# 候选阶段没有 image 字段(说明没出图)
assert all("image" not in s for s in r["shots"]), "候选阶段不该出图"
assert "未出图" in r["source"], r["source"]
print(" ★ 机位候选阶段不出图(纯粗渲染观察)")
def test_capture_photo_bad_shot_index_is_readable():
"""★ shot_index 越界 ⇒ 可读失败 + 列出可用范围。"""
from mcfold.photo import capture_photo
ws = _scene(lambda x, y, z: GRASS if y < 0 else AIR)
r = capture_photo(ws, (5.0, 0.0, 0.0), store="/nonexistent", shot_index=99)
assert r["ok"] is False, r
assert "越界" in r["why"], r["why"]
assert r.get("how_to"), r
assert r.get("shot_options"), "应列出可用机位"
print(f" ✓ 机位越界:可读失败({r['why']})")
# ---------- ⑥ HUD 隐藏是硬要求(plan: 截图必须隐藏 HUD) ----------
def test_world_renderer_exposes_set_view():
"""★★ 桥层有能力 ≠ 上层有入口。
回归:桥层 `RenderBackend.set_view` 早就实现了完整 `view` 命令
(含 hide_gui → 官方 options.hideGui),但 `WorldRenderer` **没有**
这个入口。我在 fine.py 里调 `renderer.set_view(hide_gui=True)`,
撞上 AttributeError,又被 `except: pass` 吞掉
⇒ **出图带着 HUD**(血量/热键栏/教学提示),
而返回值里还写着 `hide_gui: True` —— 自相矛盾且无人发现。
判据:WorldRenderer 必须暴露 set_view,且能透传到桥层。
"""
from mcfold.render import WorldRenderer
assert hasattr(WorldRenderer, "set_view"), \
"WorldRenderer 缺 set_view(桥层有但上层没入口)"
# 透传验证:用假桥看请求内容
calls = []
class _B:
def set_view(self, **kw):
calls.append(kw)
return {"ok": True}
r = WorldRenderer.__new__(WorldRenderer)
r.bridge = _B()
out = r.set_view(hide_gui=True)
assert out == {"ok": True}
assert calls == [{"hide_gui": True}], calls
print(" ★★ WorldRenderer.set_view 存在且透传 hide_gui")
def test_hide_gui_failure_is_not_swallowed():
"""★★ hide_gui 设不上必须**显式失败**,不能被 except: pass 吞掉。
静默吞掉的后果:出图带 HUD,而调用方以为已隐藏 ——
「以为设了」与「实际没设」无法区分。
"""
import mcfold.render as render_mod
import mcfold.fine as fine
class _R:
def __init__(self):
self.bridge = _FakeBridge()
self.chunks = {} # inject_world 会写
self._loaded = set() # inject_world 会写
def set_blocks(self, *a, **k):
return self.bridge.set_blocks(*a, **k)
def set_render_distance(self, *a):
return {}
def set_view(self, **kw):
raise AttributeError("set_view") # 模拟「后端不支持」
def set_viewport(self, *a, **k):
return {}
def camera(self, *a):
return None
def screenshot(self, **k):
raise AssertionError("不该走到截图:hide_gui 已失败")
def close(self):
return None
# ★ 要 patch **render 模块**里的类(fine 是函数内延迟导入的)
orig_open = render_mod.WorldRenderer.open
render_mod.WorldRenderer.open = classmethod(lambda cls: _R())
try:
ws = _scene(lambda x, y, z: GRASS if y < 0 else AIR)
r = fine.render_from_state(ws, store="/tmp/x", hide_gui=True)
assert r["ok"] is False, f"hide_gui 失败应报错,实际 {r}"
assert "HUD" in r["why"], r
assert r.get("how_to"), r
finally:
render_mod.WorldRenderer.open = orig_open
print(" ★★ hide_gui 设不上:显式失败(不静默带 HUD 出图)")
def test_hide_gui_false_is_allowed():
"""★ 明确要求带 HUD 时(hide_gui=False)不该报错。"""
import mcfold.render as render_mod
import mcfold.fine as fine
shots = []
class _R:
def __init__(self):
self.bridge = _FakeBridge()
self.chunks = {}
self._loaded = set()
def set_blocks(self, *a, **k):
return self.bridge.set_blocks(*a, **k)
def set_render_distance(self, *a):
return {}
def set_view(self, **kw):
raise AssertionError("hide_gui=False 时不该调 set_view")
def set_viewport(self, *a, **k):
return {}
def camera(self, *a):
return None
def screenshot(self, **k):
shots.append(k)
return type("R", (), {"path": "/tmp/ok.png"})()
def close(self):
return None
orig = render_mod.WorldRenderer.open
render_mod.WorldRenderer.open = classmethod(lambda cls: _R())
try:
ws = _scene(lambda x, y, z: GRASS if y < 0 else AIR)
r = fine.render_from_state(ws, store="/tmp/x", hide_gui=False)
assert r["ok"] is True, r
assert shots, "应走到截图"
finally:
render_mod.WorldRenderer.open = orig
print(" ✓ hide_gui=False:允许带 HUD 出图")
if __name__ == "__main__":
for fn in [test_section_linear_index_matches_java_layout,
test_chunks_to_sections_uses_section_index,
test_missing_sections_are_omitted_not_zeroed,
test_inject_world_reports_counts,
test_inject_world_survives_write_failure,
test_inject_world_empty_is_honest,
test_render_from_state_empty_world_is_readable_failure,
test_render_from_state_bad_store_is_readable_failure,
test_propose_shots_gives_reasons,
test_propose_shots_yaw_points_at_target,
test_facing_yaw_convention,
test_propose_shots_empty_world_still_returns_options,
test_photo_uses_rough_for_candidates_not_rendering,
test_capture_photo_bad_shot_index_is_readable,
test_world_renderer_exposes_set_view,
test_hide_gui_failure_is_not_swallowed,
test_hide_gui_false_is_allowed]:
fn()
print("\n细渲染+拍照:全部通过")