Files
agentmc/mcfold/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

213 lines
8.4 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.

"""M9 第一个用例:`capture_photo()` —— 粗搜机位 → 细出图。
## 这个用例验证什么
plan §5 的原始动机(用户场景):
> 「我和某个景物的合照」—— agent 先用**粗渲染**找好角度,
> 再用**细渲染**出真实照片。
它之所以排在最后,是因为它**同时验证两个子系统**:
- 粗渲染(M6)能不能给出**可用的机位候选**(不是拍脑袋的坐标)
- 细渲染(M8)能不能把**服务端真状态**渲成图
## 关键设计:机位从哪来
**不是**让 agent 拍脑袋报坐标,而是:
1. 先问 M6:目标可见吗?周围能站吗?地面在哪?
2. 由这些**观察**生成若干机位候选(正面/侧面/俯视/远景…)
3. 逐个(或挑最优的)用细渲染出图
4. 把「机位是怎么来的」一并返回 —— agent 能判断可不可信
## 为什么不追求「自动选最优机位」
构图好坏是**审美判断**,属决策。按 mcfold 纪律(工具不决策),
本模块给出**结构化的机位候选 + 每个机位的观察依据**,
让 agent 自己选(可以 `shot_index` 指定,或全都要)。
"""
from __future__ import annotations
import math
import os
from typing import Any
from . import rough
from .state import WorldState
def _facing_yaw(tx: float, tz: float, fx: float, fz: float) -> float:
"""从机位看向目标所需的 yaw(MC 约定:0=+Z)。"""
dx, dz = tx - fx, tz - fz
return (math.degrees(math.atan2(dz, dx)) - 90.0) % 360.0
def propose_shots(
world: WorldState,
target: tuple[float, float, float],
*,
radius: int = 3,
max_shots: int = 6,
) -> dict[str, Any]:
"""用**粗渲染的观察**生成机位候选(不渲染,只观察)。
每个候选都带**为什么选它**(依据),便于 agent 判断与追问。
"""
p = world.player
tx, ty, tz = target
ground = world.block_at(int(tx), int(ty) - 1, int(tz))["name"]
base_y = ty - 0.6 # 略微下蹲的机位高度,比站在格心自然
cands: list[dict[str, Any]] = []
def add(name: str, fx: float, fy: float, fz: float, pitch: float,
why: str, dist: float | None = None) -> None:
d = dist if dist is not None else math.dist((fx, fy, fz), (tx, ty, tz))
cands.append({
"index": len(cands),
"name": name,
"camera": [round(fx, 2), round(fy, 2), round(fz, 2),
round(_facing_yaw(tx, tz, fx, fz), 1), round(pitch, 1)],
"distance": round(d, 2),
"why": why,
})
def add_at(name: str, fx: float, fy: float, fz: float,
why: str, dist: float | None = None) -> None:
"""**由位置反算** yaw/pitch,保证真的看向目标。
★ 为什么需要它:俯视机位在目标**正上方 4 格**,此时
水平方向的 yaw 已经退化(dx/dz ≈ 0,方位无意义),
真正的方向是「几乎垂直向下」。
我先前写死 `pitch=-35` 配水平算出的 yaw ⇒ 两者**不自洽**
(实测 dot=0.82,相机没真正对着目标)。
⇒ 只要机位不在目标同一水平面,就必须由向量反算朝向。
"""
dx, dy, dz = tx - fx, ty - fy, tz - fz
horiz = math.hypot(dx, dz)
yaw = (math.degrees(math.atan2(dz, dx)) - 90.0) % 360.0
pitch = -math.degrees(math.atan2(dy, horiz)) if (horiz or dy) else 0.0
d = dist if dist is not None else math.sqrt(dx * dx + dy * dy + dz * dz)
cands.append({
"index": len(cands), "name": name,
"camera": [round(fx, 2), round(fy, 2), round(fz, 2),
round(yaw, 1), round(pitch, 1)],
"distance": round(d, 2), "why": why,
})
# ① 目标正前方一格(经典正面照)
dx, dz = tx - p.x, tz - p.z
n = math.hypot(dx, dz) or 1.0
ux, uz = dx / n, dz / n
fx, fz = tx - ux * 3.0, tz - uz * 3.0
fx, fz = int(round(fx)), int(round(fz))
if world.world.registry is not None and world.block_at(fx, int(ty) - 1, fz)["name"]:
add_at("正面", fx, base_y, fz,
f"站在目标正前方 3 格({fx},{int(ty)-1},{fz} 有 {ground} 支撑)")
else:
add_at("正面(悬空)", fx, base_y, fz, "目标正前方 3 格(脚下无支撑,可能悬空)")
# ② 侧面 45°
sx, sz = int(round(tx - uz * 3.0 + ux * 3.0)), int(round(tz + ux * 3.0 + uz * 3.0))
if world.block_at(sx, int(ty) - 1, sz)["name"]:
add_at("斜侧", sx, base_y, sz,
f"侧 45°、距离 3 格({sx},{int(ty)-1},{sz} 有支撑)")
else:
add_at("斜侧(悬空)", sx, base_y, sz, "侧 45°(脚下无支撑)")
# ③ 俯视(看全景)—— 用 add_at:机位在目标上方,水平朝向退化
add_at("俯视", tx, ty + 4.0, tz + 0.01,
"目标正上方 4 格俯视(朝向由位置反算,保证真的对着目标)")
# ④ 远景(若视线通)
far_x, far_z = int(round(tx - ux * 8.0)), int(round(tz - uz * 8.0))
vis = rough.can_see(world, (fx, base_y, fz))
add_at("远景", far_x, base_y, far_z,
f"8 格外远景(近处视线:{vis['reason']})")
# ⑤ 玩家当前机位(零成本:就在这儿)
add("当前位置", p.x, p.y + 1.62, p.z, p.pitch,
f"就用你现在的视角({p.yaw:.0f}°/{p.pitch:.0f}°)")
return {
"ok": True,
"target": list(target),
"shots": cands[:max_shots],
"count": min(len(cands), max_shots),
"target_ground": ground,
"source": "粗渲染观察(M6 raycast/can_see/block_at),未出图",
"note": "机位是从**观察**推出的候选,不是构图判断;"
"用 shot_index 选一个,或对每个都出图再挑",
}
def capture_photo(
world: WorldState,
target: tuple[float, float, float] | None = None,
*,
store: str,
shot_index: int | None = None,
out_dir: str | None = None,
name: str = "photo",
hide_gui: bool = True,
frames: int = 40,
render_distance: int = 6,
) -> dict[str, Any]:
"""【M9】拍照:粗搜机位 → 细出图。
## 参数
- `target` 目标坐标;省略则以**玩家朝向的前方**为目标
- `shot_index` 选第几个机位;省略则**每个机位都出一张**
- `store` 内容根(含 `.mcfold` 渲染产物)
## 返回
```python
{"ok": True, "shots": [ {...,"image": "...", "ok": True}, ... ],
"shot_options": [...], "target": [...]}
```
出图失败的机位**不丢弃**(带上 `ok:false, why`),
这样 agent 能知道「哪些角度失败、为什么」。
"""
from .fine import render_from_state # 延迟导入
p = world.player
if target is None:
# 用当前朝向的前方当目标(粗渲染的 raycast 就是这么算的)
v = rough._look_vector(p.yaw, p.pitch)
d = 6.0
target = (p.x + v[0] * d, p.y + 1.62 + v[1] * d, p.z + v[2] * d)
prop = propose_shots(world, target)
if not prop["shots"]:
return {"ok": False, "why": "没能提出任何机位候选",
"how_to": "确认状态层有区块(pump 几秒)", "target": list(target)}
out_dir = out_dir or os.path.join(
os.environ.get("MCFOLD_RUNTIME", "/tmp"), "photos")
os.makedirs(out_dir, exist_ok=True)
idxs = [shot_index] if shot_index is not None else \
[s["index"] for s in prop["shots"]]
results: list[dict[str, Any]] = []
for i in idxs:
if not (0 <= i < len(prop["shots"])):
return {"ok": False, "why": f"shot_index {i} 越界(共 {len(prop['shots'])} 个机位)",
"how_to": f"可用 index:0..{len(prop['shots'])-1}",
"shot_options": prop["shots"]}
s = prop["shots"][i]
out = os.path.join(out_dir, f"{name}_{i}.png")
r = render_from_state(
world, store=store, camera=tuple(s["camera"]), out=out,
hide_gui=hide_gui, frames=frames, render_distance=render_distance)
results.append({**s, **r})
ok_any = any(r.get("ok") for r in results)
return {
"ok": ok_any,
"target": list(target),
"shots": results,
"shot_options": prop["shots"],
"target_ground": prop["target_ground"],
"out_dir": out_dir,
"note": (prop["note"] if ok_any else
"所有机位都出图失败;见每个 shot 的 why"),
}