Files
opendance-windows/build/integration/probe-frame-vectors.gd
T
zijunchiang 6ea2c93d66 fix(pack): 编码取整与参考实现对齐 + 产物不再夹带测试夹具(v0.2.4)
两个缺陷,都由上一轮的验收机制暴露:

1) 编码取整与契约参考实现不一致(T-WIN-022)
   KeypointFrame.encode() 用 GDScript 的 roundi(半数远离零),而契约参考实现
   (Web 的 keypoint-codec.ts)与**生产发送方 Android**(Kotlin Math.round)都是
   「半数朝 +∞」:-2.5 分别是 -3 与 -2。实测在 12 个跨实现向量里 1 例 33 字节不同。
   本端是接收方、encode() 只用于自测与协议回归,对产品无影响;但它让「跨实现一致性」
   只能停留在宽松口径("差异仅限 .5 取整边界"),而宽松口径会退化成没人看的已知差异。
   修复:新增 _round_like_js(v) = floor(v + 0.5),用于 4 处取整(坐标 ×3 + 置信度);
   夹紧顺序对任意实数与参考实现等价。
   收紧:accept-frame-vectors.sh 的编码口径改为「必须为 0 项差异」。
   新增单测(+11 断言):_round_like_js 的 5 个取值、端到端 -0.5/32767 → 0(roundi
   会给 -1,是有效判别用例),以及 test_reference_vectors_decode_and_encode_exactly
   —— 直接读锁定的参考向量逐值解码 + 用 f32InputPoints 重编码与 f32Hex 逐字节比对,
   让 verify.sh(只跑源码、无需 pck)也覆盖「Web ↔ Windows」这条边。

2) 产物里夹带开发/验收工具(T-WIN-023)
   export_filter="all_resources" 且 exclude_filter 没有 build/*,于是每次新增
   构建/验收脚本都被编译进 pck —— 包括**故意不合规**的坏服务端夹具 probe-ws-bad.gd
   与 frame-vectors.json。实测 OpenDance.pck 315,464 B →(排除后)145,400 B,
   即 54% 的 pck 是测试夹具。已发布的 v0.2.3 同样带着这些残留。
   修复:exclude_filter 增加 build/*;
   新增产物级防线 build/integration/list-pck.gd(遍历包内条目、标出
   res://build/ tests/ _scratch/ dist-release/ 与 *.md 残留,有残留即退出码 1),
   并接进 accept-release.sh(打印条目总数并断言「无开发残留」)——否则下次加个新
   工具目录又会重演。顺带确认 pck 没有把上一次导出的 pck 自嵌套进去。

实测(v0.2.4 发布包,三条产物级验收全绿):
  * accept-release.sh:pck 58 项无开发残留 / PE 版本资源 0.2.4 / pck 启动 180 帧无错 /
    运行时探针(包内版本号 0.2.4、AboutScreen 显示值、15 项关键资源、契约表比对)
  * accept-ws.sh:客户端 30 通过 / 0 失败 + 服务端侧 16 项事实
  * accept-frame-vectors.sh:解码 12/12、396 点 0 差异,
    **编码器字节与参考实现完全一致(0 项差异)**
  * verify.sh:785 通过 / 0 失败(原 774)

踩坑:给 -0.5/32767 这类值写断言时,第一版按"理想有理数"期望(2.5/32767 → 3)而
失败 —— PackedVector3Array 存的是 float32,乘回 32767 后略小于半整数,与 JS 对同一
float32 输入的行为其实完全一致。断言必须基于实际存储精度,或改用有效判别用例。
2026-09-17 05:59:54 +08:00

214 lines
8.5 KiB
GDScript
Raw 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.
extends SceneTree
## 关键点帧跨实现一致性探针(T-WIN-021)
##
## 用**发布包里的** `KeypointFrame` 解码由 opendance-web 参考实现(TypeScript)
## 生成的 239 字节向量,逐值比对。用法(由 build/accept-frame-vectors.sh 调用):
##
## godot --headless --main-pack <包内 OpenDance.pck> \
## --script /绝对路径/build/integration/probe-frame-vectors.gd \
## -- --vectors /绝对路径/frame-vectors.json --report /tmp/frame-vectors-report.json
##
## 为什么需要:`web-server/src/preprocess/keypoint-codec.ts` 是契约 §2 的
## **参考实现**(其 `decodeBinaryFrame` 的注释就是「Windows 端参考实现(T-WIN-004)」),
## Android 的 Kotlin 实现已用「金标准字节向量」与它对齐;而**本端的 GDScript 解码器
## 此前从未与它逐值比对过** —— 只有本端自己的单测(自己编码、自己解码)。
##
## 比对口径(重要):
## * `flags` / `frame_id`:**精确相等**;
## * 坐标与置信度:本端把点存进 `PackedVector3Array`、置信度存进 `PackedFloat32Array`,
## 即 **float32**;参考实现是 float64。因此先把期望值收敛到 float32
## (GDScript 里借 `Vector3(...)` / `PackedFloat32Array([...])` 天然完成)
## 再比**精确相等** —— 这比用容差更严格,且把差异限定在「表示精度」上。
## * 另附:用本端 `encode()` 对向量里的 `f32InputPoints`(float32 同输入基准)
## 重新编码,与 `f32Hex` 逐字节比对。v0.2.4 起本端取整已改为 `floor(x + 0.5)`
## (与 JS `Math.round` 的「半数朝 +∞」一致),因此**差异必须为 0**;
## 收敛前的宽松口径(只允许出现在 .5 取整边界)已废弃 ——
## 规则性偏差留在日志里会变成"永远没人看的已知差异"。
const KeypointFrame = preload("res://src/net/keypoint_frame.gd")
var _vectors_path := ""
var _report_path := ""
var _done := false
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
var i := 0
while i < args.size():
match args[i]:
"--vectors":
_vectors_path = args[i + 1]; i += 2
"--report":
_report_path = args[i + 1]; i += 2
_:
i += 1
_run()
func _process(_delta: float) -> bool:
return _done
func _hex_to_bytes(hex: String) -> PackedByteArray:
var out := PackedByteArray()
out.resize(hex.length() / 2)
for i in out.size():
out[i] = hex.substr(i * 2, 2).hex_to_int()
return out
## 把 float64 收敛成 float32:GDScript 的 Vector3 分量 / PackedFloat32Array 都是 float32
static func _to_f32(value: float) -> float:
return Vector3(value, 0.0, 0.0).x
func _run() -> void:
if _vectors_path == "" or not FileAccess.file_exists(_vectors_path):
print("[frame-vectors] ✗ 找不到向量文件:%s" % _vectors_path)
quit(2)
return
var file := FileAccess.open(_vectors_path, FileAccess.READ)
var parsed: Variant = JSON.parse_string(file.get_as_text())
file.close()
if typeof(parsed) != TYPE_DICTIONARY:
print("[frame-vectors] ✗ 向量文件不是 JSON 对象")
quit(2)
return
var cases: Array = parsed.get("cases", [])
print("[frame-vectors] 参考实现:%s" % parsed.get("source", "(未标注)"))
print("[frame-vectors] 用例 %d 个" % cases.size())
var decoded_cases := 0
var decode_failures: Array = []
var encode_differences: Array = []
var total_points := 0
for c in cases:
var name := str(c.get("name", "(未命名)"))
var bytes := _hex_to_bytes(str(c.get("hex", "")))
var expect_frame_id := int(c.get("frameId", 0))
var expect_flags := int(c.get("flags", 0))
var expect_points: Array = c.get("points", [])
# ---- 校验 + 解码 ----
var problem := KeypointFrame.validate(bytes)
if problem != "":
decode_failures.append({"case": name, "problem": "validate: " + problem})
continue
var frame = KeypointFrame.decode(bytes)
if frame == null:
decode_failures.append({"case": name, "problem": "decode 返回 null"})
continue
decoded_cases += 1
if int(frame.frame_id) != expect_frame_id:
decode_failures.append({
"case": name,
"problem": "frame_id 期望 %d,实际 %d" % [expect_frame_id, frame.frame_id],
})
if int(frame.flags) != expect_flags:
decode_failures.append({
"case": name,
"problem": "flags 期望 %d,实际 %d" % [expect_flags, frame.flags],
})
# ---- 逐点比对(float32 精确相等)----
for i in expect_points.size():
total_points += 1
var e: Array = expect_points[i]
var want := Vector3(_to_f32(float(e[0])), _to_f32(float(e[1])), _to_f32(float(e[2])))
var want_conf := PackedFloat32Array([float(e[3])])[0]
var got: Vector3 = frame.points[i]
var got_conf: float = frame.confidence[i]
if got != want:
decode_failures.append({
"case": name,
"problem": "点 %d 坐标不符:期望 %s,实际 %s" % [i, str(want), str(got)],
})
elif got_conf != want_conf:
decode_failures.append({
"case": name,
"problem": "点 %d 置信度不符:期望 %s,实际 %s" % [i, str(want_conf), str(got_conf)],
})
# ---- 附带:本端 encode() 与参考字节的差异(本端不是发送方,记录不判死)----
# 必须用 **f32InputPoints**(已收敛到 float32)来重编码:本端点/置信度就是 float32,
# 若拿 float64 的原始输入比,差异会同时来自「输入精度」与「取整规则」,无法归因。
var input_points: Array = c.get("f32InputPoints", c.get("inputPoints", []))
# 与**同输入**基准比:`f32Hex` 是用 f32InputPoints 编出来的字节。
# 拿 `hex`(float64 输入)比会把「输入精度差异」也算进来(第一版就是这么误判的:
# 「正数恰好 .5」整例 99 字节不同,其实只是输入从 float64 收敛到 float32 后的量化差)。
var baseline_hex := str(c.get("f32Hex", c.get("hex", "")))
var baseline := _hex_to_bytes(baseline_hex)
if input_points.size() == 33:
var pts := PackedVector3Array()
var conf := PackedFloat32Array()
pts.resize(33)
conf.resize(33)
for i in 33:
var p: Array = input_points[i]
pts[i] = Vector3(float(p[0]), float(p[1]), float(p[2]))
conf[i] = float(p[3])
var re_encoded := KeypointFrame.encode(expect_frame_id, pts, conf, expect_flags)
var diff_offsets: Array = []
for i in re_encoded.size():
if re_encoded[i] != baseline[i]:
diff_offsets.append(i)
if diff_offsets.size() > 0:
# 归类:本端 `roundi` 对「.5」的进位方向与 JS `Math.round` 不同
# (-2.5:GDScript → -3,JS → -2)。**同输入**下,这类差异只可能
# 出现在量化前恰好落在 X.5 的用例上 —— 这里直接**算**出来,
# 而不是靠用例名猜(第一版按名字猜,漏判了「置信度 128/255」,
# 因为那一例的 y 也取了 -0.5)。
var has_half := false
for p in input_points:
for value in [p[0], p[1], p[2]]:
var scaled: float = float(value) * 32767.0
var a := absf(scaled)
if absf(a - floorf(a) - 0.5) < 1e-9:
has_half = true
var c_scaled: float = float(p[3]) * 255.0
var ac := absf(c_scaled)
if absf(ac - floorf(ac) - 0.5) < 1e-9:
has_half = true
encode_differences.append({
"case": name,
"diffBytes": diff_offsets.size(),
"halfValueInput": has_half,
})
var report := {
"source": parsed.get("source", ""),
"cases": cases.size(),
"decodedCases": decoded_cases,
"pointsCompared": total_points,
"decodeFailures": decode_failures,
"encodeDifferences": encode_differences,
}
if _report_path != "":
var out := FileAccess.open(_report_path, FileAccess.WRITE)
if out != null:
out.store_string(JSON.stringify(report, " "))
out.close()
print("[frame-vectors] 解码用例 %d/%d,比对点 %d 个" % [decoded_cases, cases.size(), total_points])
if decode_failures.is_empty():
print("[frame-vectors] ✓ 解码结果与 Web 参考实现逐值一致(float32 精确相等)")
else:
print("[frame-vectors] ✗ 解码差异 %d 项:" % decode_failures.size())
for f in decode_failures.slice(0, 8):
print(" · %s:%s" % [f["case"], f["problem"]])
if encode_differences.is_empty():
print("[frame-vectors] ✓ 本端 encode() 与参考字节完全一致")
else:
print("[frame-vectors] ✗ 本端 encode() 与参考字节存在差异(取整规则应已对齐,必须为 0):")
for d in encode_differences:
print(" · %s:%d 字节不同(量化前存在 .5 值=%s)" % [
d["case"], d["diffBytes"], str(d.get("halfValueInput", false))])
_done = true
quit(1 if decode_failures.size() > 0 else 0)