Files
opendance-windows/build/integration/probe-frame-vectors.gd
T
zijunchiang 1c5f28cd41 test(codec): 关键点帧跨实现一致性验收——包内 GDScript 解码器 × Web 参考实现(T-WIN-021)
契约 §2 的参考实现是 opendance-web 的 keypoint-codec.ts(其 decodeBinaryFrame
注释就是「Windows 端参考实现」)。Android 已用金标准向量与它对齐;而本端的解码器
此前**只跟自己比过**(自己编码、自己解码),两个方向共用同一份错误假设也能一直绿。

新增:
  * build/integration/frame-vectors.json —— 由参考实现生成并锁定的向量
    (12 用例:i16 最小值 -32768、i16 最大值、±1 全量程、置信度 0/128/255、
    flags 低 3 位组合、frame_id 0xFFFFFFFF 与 0x80000000、取整边界)。
  * build/integration/probe-frame-vectors.gd —— 用**包内** KeypointFrame 解码同一批字节,
    与参考解码结果**逐值精确比对**(坐标/置信度先收敛到 float32 再比精确相等,
    因为本端点存 PackedVector3Array、置信度存 PackedFloat32Array)。
  * build/accept-frame-vectors.sh —— 编排:用参考实现重新生成向量并与锁定文件比对
    (参考实现变了却不同步向量即失败)→ 解包 → 包内解码比对 → 报告核对。

实测(**已发布**的 v0.2.3 资产重新下载后跑):
  ✓ 锁定的向量与参考实现重新生成的结果逐字节一致
  ✓ 解码用例 12/12,逐点比对 396 个,解码差异 0 项
  ✓ 编码器差异 1 项,且全部属于「量化前恰好 .5」的取整边界
  关键点帧跨实现一致性验收全部通过

已测量并归类的一处差异(非缺陷):JS Math.round 与 Kotlin(生产发送方)Math.round
对 -2.5 取 -2(半数朝 +∞),本端 roundi 取 -3(半数远离零)。Windows 是接收方,
encode() 只在自测/协议回归里用,故对产品无影响;脚本硬性要求这类差异只能出现在
取整边界用例上,否则视为新问题。若将来本端要当发送方,把 roundi 换成 floor(x+0.5) 即可。

踩坑(两处「看着通过其实没测到」,都写进任务文档):
  1. 生成器第一版把「喂进编码器的输入」当成 points 写进向量 → 比对变成「比编码量化」,
     靠抽查数值才发现;现在向量同时含 inputPoints / f32InputPoints / f32Hex / points。
  2. 编码器比对一开始用 float64 输入编出的 hex 当基准(正数恰好 .5 那例整例 99 字节不同),
     实际只是输入从 float64 收敛到 float32 的量化差;改用同输入的 f32Hex 基准后,
     差异收敛到唯一一个真正的取整边界用例。教训:跨实现比对必须先统一输入。

package-release.sh 结尾把四条发布前验收一次列全;verify.sh 全绿(774 通过 / 0 失败)。
2026-09-17 05:52:11 +08:00

212 lines
8.4 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()` 对向量里的 `inputPoints` 重新编码,与参考字节比对。
## 本端**不是发送方**(encode 只用于自测),因此这一项差异记为「已记录差异」
## 而非失败;但差异必须**只出现在取整边界用例**上,否则说明别处也有问题。
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() 与参考字节的差异(仅记录,本端非发送方):")
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)