File size: 16,128 Bytes
4764138
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
"""Verification step 1b (2026-10-13): presence scores of the base Qwen3.5-9B (no LoRA) from token logprobs, plus the
box temporal check of PLAN_DECOUPLED_20261013.md 2.4 point 3. Thinking off, temperature 0, top 20 logprobs.

1b, on step 1's 500 items (runs/diag_20261013/perception_direction/items.jsonl: 300 geometric positives, 100 behind,
100 wrong-name), two scores:
- s_yes: B's question, max_tokens 1; P(yes) / (P(yes) + P(no)), case and leading-space variants summed;
- s_box: step 1's grounding question with max_tokens 48 (the controller's call). Answers either open a code fence
  ("```", "json", "\\n", "[", "\\n", "\\t", '{"', ...) or refuse ("There are no objects belonging to ..."); after the fence
  the list token is "[" with P("[]") ~ 0 (stored per item as p_list_empty), so empty vs non-empty is decided at the
  first token: s_box = P(first token "```").
AUC: 300 positives vs 200 negatives, and step 1's hand-labelled contact-sheet positives (37 visible vs 11 not).
Temporal check on GRPO v2 rounds 1-5: disjoint windows of 5 consecutive frames turning >= 5 deg at each step.
real = a target in the geometric field of view (|h| <= 51.2, |v| <= 35) 3-16 blocks away in all 5 frames;
wrong = the same windows asked about a wrong name (step 1's rule); behind = a target 3-16 blocks away with |h| >= 120
in all 5 frames (spread as step 1, up to 100). Per step, the first box centre's angle change is compared with
-delta yaw, the change a fixed world point shows.
Writes items.jsonl, windows.jsonl, summary.md.
Usage: diag_perception_presence.py --url http://localhost:9041/v1 --model NAME [--out DIR]
"""
import argparse
import base64
from concurrent.futures import ThreadPoolExecutor
import json
import math
from pathlib import Path
import random
import statistics

from openai import OpenAI

from diag_perception import BENCH, HALF_VFOV, ROOT, episodes, spread, view
from diag_perception_direction import BOX, GROUND, SIDES, TAN_HALF_HFOV, side

STEP1 = ROOT / "runs/diag_20261013/perception_direction/items.jsonl"
HIDDEN = {11, 17, 19, 20, 27, 29, 33, 36, 43, 45, 47}  # contact-sheet positives judged not visible (step 1 summary.md)
HALF_HFOV = math.degrees(math.atan(TAN_HALF_HFOV))
YESNO = "Is there a {0} in this image? Answer yes or no."


def windows(rng):
    """(real, wrong, behind) windows: start frame, the 4 yaw changes, geometric (dist, h, v) per frame."""
    real, behind, names, placed = [], [], {}, {}
    for ep, scene, _, poses, targets in episodes():
        placed[scene] = " ".join(json.loads((BENCH / scene / "multi-agent/metadata.json").read_text())["commands"])
        turn = [(b["yaw"] - a["yaw"] + 180) % 360 - 180 for a, b in zip(poses, poses[1:])]
        for mid, task, target in targets:
            if not task.startswith("find "):
                continue
            name = task[5:].removeprefix("a ").replace("_", " ")
            names.setdefault(scene, set()).add(name)
            free = {"real": 0, "behind": 0}
            for s in range(len(poses) - 4):
                if not all(abs(d) >= 5 for d in turn[s:s + 4]):
                    continue
                geo = [[round(a, 1) for a in view(poses[s + j], target)] for j in range(5)]
                kind = ("real" if all(3 <= d <= 16 and abs(h) <= HALF_HFOV and abs(v) <= HALF_VFOV for d, h, v in geo)
                        else "behind" if all(3 <= d <= 16 and abs(h) >= 120 for d, h, v in geo) else None)
                if kind and s >= free[kind]:
                    free[kind] = s + 5
                    (real if kind == "real" else behind).append(
                        {"kind": kind, "ep": str(ep.relative_to(ROOT)), "scene": scene, "mid": mid, "frame": s,
                         "name": name, "asked": name, "turn": [round(d, 2) for d in turn[s:s + 4]], "geo": geo})
    every = sorted(set().union(*names.values()))
    wrong = [{**w, "kind": "wrong", "asked": rng.choice([x for x in every if x not in names[w["scene"]]
                                                         and x.replace(" ", "_") not in placed[w["scene"]]])}
             for w in real]
    return real, wrong, spread(behind, 100, rng)


def auc(pos, neg):
    return sum((a > b) + 0.5 * (a == b) for a in pos for b in neg) / (len(pos) * len(neg))


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--url", required=True)
    ap.add_argument("--model", required=True)
    ap.add_argument("--out", type=Path, default=ROOT / "runs/diag_20261013/perception_presence")
    args = ap.parse_args()
    args.out.mkdir(parents=True, exist_ok=True)
    step1 = [json.loads(line) for line in STEP1.read_text().splitlines()]
    real, wrong, behind = windows(random.Random(0))
    print(f"windows: real {len(real)} ({len({w['scene'] for w in real})} scenes, {len({w['ep'] for w in real})} "
          f"episodes), wrong {len(wrong)}, behind {len(behind)} ({len({w['scene'] for w in behind})} scenes)", flush=True)

    client = OpenAI(api_key="EMPTY", base_url=args.url)

    def chat(key, prompt, max_tokens):
        ep, frame, asked = key
        png = (ROOT / ep / "frames" / f"{frame:04d}.png").read_bytes()
        r = client.chat.completions.create(
            model=args.model, temperature=0, max_tokens=max_tokens, logprobs=True, top_logprobs=20,
            messages=[{"role": "user", "content": [
                {"type": "image_url", "image_url": {"url": "data:image/png;base64," + base64.b64encode(png).decode()}},
                {"type": "text", "text": prompt.format(asked)}]}],
            extra_body={"chat_template_kwargs": {"enable_thinking": False}})
        return r.choices[0].message.content, r.choices[0].logprobs.content

    def ground(key):
        text, lp = chat(key, GROUND, 48)
        boxes = [[float(c) for c in b] for b in BOX.findall(text)]
        out = {"ground": text, "boxes": boxes,
               "s_box": sum(math.exp(t.logprob) for t in lp[0].top_logprobs if t.token.strip() == "```"),
               "p_list_empty": sum(math.exp(t.logprob) for t in lp[3].top_logprobs if t.token == "[]")
               if text.startswith("```json\n[") else None}
        if boxes:
            x1, _, x2, _ = boxes[0]
            out["h_box"] = round(math.degrees(math.atan(((x1 + x2) / 1000 - 1) * TAN_HALF_HFOV)), 1)
        return out

    def yes(key):
        text, lp = chat(key, YESNO, 1)
        p = {w: sum(math.exp(t.logprob) for t in lp[0].top_logprobs if t.token.strip().lower() == w)
             for w in ("yes", "no")}
        return {"answer": text, "s_yes": p["yes"] / (p["yes"] + p["no"])}

    keys1 = [(x["ep"], x["frame"], x["asked"]) for x in step1]
    keys = list(dict.fromkeys(keys1 + [(w["ep"], w["frame"] + j, w["asked"]) for w in real + wrong + behind
                                       for j in range(5)]))
    with ThreadPoolExecutor(32) as pool:
        g = dict(zip(keys, pool.map(ground, keys)))
        y = dict(zip(keys1, pool.map(yes, keys1)))
    items = [{**{k: x[k] for k in ("kind", "ep", "scene", "mid", "frame", "name", "asked", "dist", "h", "v", "pitch")},
              **y[key], **g[key]} for x, key in zip(step1, keys1)]
    wins = [{**w, "ground": [g[(w["ep"], w["frame"] + j, w["asked"])] for j in range(5)]}
            for w in real + wrong + behind]
    real, wrong, behind = ([w for w in wins if w["kind"] == k] for k in ("real", "wrong", "behind"))
    with (args.out / "items.jsonl").open("w") as f:
        f.writelines(json.dumps(x, ensure_ascii=False) + "\n" for x in items)
    with (args.out / "windows.jsonl").open("w") as f:
        f.writelines(json.dumps(w, ensure_ascii=False) + "\n" for w in wins)

    by = {k: [x for x in items if x["kind"] == k] for k in ("pos", "behind", "wrong")}
    shown = [x for s in SIDES for x in [x for x in by["pos"] if side(x["h"]) == s][:16]]
    vis = [x for k, x in enumerate(shown) if k not in HIDDEN]
    hid = [x for k, x in enumerate(shown) if k in HIDDEN]
    neg = by["behind"] + by["wrong"]
    aucs = ["| 比较 | n | s_yes AUC | s_box AUC |", "|---|---|---:|---:|"]
    aucs += [f"| {label} | {len(a)} 对 {len(b)} | {auc([x['s_yes'] for x in a], [x['s_yes'] for x in b]):.3f} | "
             f"{auc([x['s_box'] for x in a], [x['s_box'] for x in b]):.3f} |"
             for label, a, b in (("正例对负例(背后 + 错名)", by["pos"], neg), ("正例对背后", by["pos"], by["behind"]),
                                 ("正例对错名", by["pos"], by["wrong"]), ("人工:可见对看不见", vis, hid),
                                 ("人工可见对负例", vis, neg))]
    med = lambda xs: f"{statistics.median(xs):.3f}"
    scores = ["| 组 | n | s_yes 中位数 | 答 yes | s_box 中位数 | 给框(48 token) |", "|---|---:|---:|---:|---:|---:|"]
    scores += [f"| {label} | {len(xs)} | {med([x['s_yes'] for x in xs])} | "
               f"{sum(x['answer'].strip().lower().startswith('yes') for x in xs)} | {med([x['s_box'] for x in xs])} | "
               f"{sum(bool(x['boxes']) for x in xs)} |"
               for label, xs in (("正例", by["pos"]), ("背后", by["behind"]), ("错名", by["wrong"]),
                                 ("人工可见", vis), ("人工看不见", hid))]
    fence = [x["p_list_empty"] for x in items if x["p_list_empty"] is not None]
    starts = sorted({x["ground"][:12] for x in items if x["p_list_empty"] is None})
    scores += ["", f"代码块开头的回答 {len(fence)} 条,列表 token 上 P(\"[]\") 最大 {max(fence):.1e};"
                   f"其余 {len(items) - len(fence)} 条开头为 {starts}。"]

    def row(label, seqs):
        """seqs: (4 yaw changes, 5 box centre angles, None = no box)."""
        had = [(-d, a, b) for turns, hs in seqs for d, a, b in zip(turns, hs, hs[1:]) if a is not None]
        both = [(x, b - a) for x, a, b in had if b is not None]
        slope, icpt = statistics.linear_regression(*zip(*both))
        rms = math.sqrt(statistics.fmean((y - slope * x - icpt) ** 2 for x, y in both))
        frames = [h for _, hs in seqs for h in hs]
        big = [y for x, y in both if abs(x) > 10]
        pct = lambda k, n: f"{100 * k / n:.0f}%"
        return (f"| {label} | {len(seqs)} | {pct(sum(h is not None for h in frames), len(frames))} | "
                f"{pct(sum(b is None for _, _, b in had), len(had))} | {len(both)} | "
                f"{statistics.median(abs(x) for x, _ in both):.1f} | {slope:.2f} | {icpt:+.1f} | {rms:.1f} | "
                f"{pct(sum(abs(y - x) <= 5 for x, y in both), len(both))} | {pct(sum(abs(y) <= 5 for y in big), len(big))} | "
                f"{pct(sum(min(abs(y - x), abs(y)) > 10 for x, y in both), len(both))} | "
                f"{statistics.median(abs(y) for _, y in both):.1f} |")

    boxes = lambda ws: [(w["turn"], [f.get("h_box") for f in w["ground"]]) for w in ws]
    rng = random.Random(0)
    pool = [f["h_box"] for w in wrong + behind for f in w["ground"] if "h_box" in f]
    errs = [x["h_box"] - x["h"] for x in by["pos"] if "h_box" in x]

    def fake_sim(redraw, world):
        """Fake box angles over the fake windows' turns (20 repeats): first from the observed fake boxes; each step
        redrawn with probability redraw, else kept (world = 1: shifted by -delta yaw like a fixed world point)."""
        seqs = []
        for w in (wrong + behind) * 20:
            hs = [rng.choice(pool)]
            for d in w["turn"]:
                hs.append(rng.choice(pool) if rng.random() < redraw else hs[-1] - world * d)
            seqs.append((w["turn"], hs))
        return seqs

    def real_sim(redraw):
        """True target angle + an error from the 300 positives (plan 2.4 point 1), redrawn with probability redraw."""
        seqs = []
        for w in real * 20:
            e, hs = rng.choice(errs), []
            for _, h, _ in w["geo"]:
                e = rng.choice(errs) if hs and rng.random() < redraw else e
                hs.append(h + e)
            seqs.append((w["turn"], hs))
        return seqs

    temporal = ["| 窗口 | 窗口数 | 帧给框 | 消失(上一帧有框,这一帧没有) | 相邻两帧都有框的步 | \\|Δyaw\\| 中位数 | 斜率 | 截距 | "
                "残差 RMS | 随世界点(\\|Δh_box + Δyaw\\| ≤ 5°) | 画面不动(\\|Δh_box\\| ≤ 5°,\\|Δyaw\\| > 10° 的步) | "
                "跳(两者都偏 >10°) | \\|Δh_box\\| 中位数 |",
                "|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|",
                row("真目标:几何真值 h(参照)", [(w["turn"], [g[1] for g in w["geo"]]) for w in real]),
                row("真目标:框", boxes(real)), row("假框:错名(同一批窗口)", boxes(wrong)),
                row("假框:背后", boxes(behind)), row("假框合计", boxes(wrong + behind)),
                row("模拟假框 (1) 每步独立", fake_sim(1, 0)), row("模拟假框 (2) 固定世界点", fake_sim(0, 1)),
                row("模拟假框 (3) 画面里不动", fake_sim(0, 0)), row("模拟假框 (3),每步以 0.5 概率重抽", fake_sim(0.5, 0)),
                row("模拟真框:每步独立误差(2.4 第 1 点原样)", real_sim(1)), row("模拟真框:误差保持,每步以 0.5 概率重抽", real_sim(0.5))]
    (args.out / "summary.md").write_text(
        "基座 Qwen3.5-9B(无 LoRA),思考关闭,温度 0,取前 20 个 token 的 logprob。\n\n"
        "## 1b 存在性分数\n\n"
        "同第 1 步的 500 条(runs/diag_20261013/perception_direction/items.jsonl:几何正例 300,背后负例 100,错名负例 100)。"
        f"s_yes:B 的问题 \"{YESNO.format('<name>')}\",max_tokens 1,P(yes)/(P(yes)+P(no)),大小写和前导空格变体合计。"
        "s_box:第 1 步的定位问题,max_tokens 48(2.2 里控制器的调用)。回答只有两种开头:代码块(```、json、换行、[、换行、制表符、{\"…)"
        "或拒答(There are no objects belonging to …);进了代码块后列表 token 是 [,P(\"[]\") 约为 0,"
        "所以空和非空在第一个 token 上决定:s_box = P(第一个 token 是 ```)。人工标注取第 1 步 summary.md 接触表核对:"
        "48 个正例里序号 11、17、19、20、27、29、33、36、43、45、47 看不见,其余 37 个可见。\n\n"
        + "\n".join(aucs) + "\n\n" + "\n".join(scores) + "\n\n"
        "## 框的时间行为\n\n"
        "GRPO v2 第 1 到 5 轮 rollout,5 帧连续窗口,4 步每步 |Δyaw| ≥ 5°,同一回合同一目标的窗口不重叠。"
        f"真目标:目标在全部 5 帧都在几何视场内(|h| ≤ {HALF_HFOV:.1f}°,|v| ≤ 35°)、距离 3 到 16 格,全部取用 {len(real)} 个"
        f"({len({w['scene'] for w in real})} 个场景、{len({w['ep'] for w in real})} 个回合,这批 rollout 里满足条件的只有这么多);"
        "错名:同一批窗口问别的场景的目标名(第 1 步的规则);背后:目标在全部 5 帧都在 3 到 16 格、|h| ≥ 120°,按第 1 步的 spread 取"
        f" {len(behind)} 个({len({w['scene'] for w in behind})} 个场景)。定位问题同 1b(max_tokens 48),取第一个框中心换算水平角 h_box。"
        "每步把 Δh_box 对 −Δyaw(固定世界点在画面里的水平角变化)做最小二乘:固定世界点斜率 1,画面里不动斜率 0。"
        "参照行用目标点的几何 h 代替框,差别来自走路的视差。跳:框中心变化离固定世界点的预测和画面不动都超过 10°。"
        "模拟行在观测窗口的转头序列上各重复 20 次(seed 0):假框的位置从观测到的假框 h_box 里抽,真框 = 目标几何 h + 从 1b 的 300 个正例里抽的"
        "框误差;“重抽”指这一步换一个新抽样,否则沿用上一步(假框 (2) 再按 −Δyaw 平移)。\n\n"
        + "\n".join(temporal) + "\n\n"
        "逐条结果:items.jsonl(1b 的 500 条),windows.jsonl(每个窗口 5 帧的回答、框、h_box、s_box)。\n")
    print((args.out / "summary.md").read_text(), flush=True)


if __name__ == "__main__":
    main()