Files
opendance-windows/tests/test_keypoint_frame.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

256 lines
10 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 TestCase
## T-WIN-004:239 字节帧反序列化 —— 契约 contracts/websocket-keypoints.md §2
##
## 测试向量与 opendance-web 的 TypeScript 参考实现
## (web-server/src/preprocess/keypoint-codec.ts)保持一致,
## 保证两端对同一字节流的解释完全相同。
const KeypointFrame = preload("res://src/net/keypoint_frame.gd")
func _sample_points() -> PackedVector3Array:
var points := PackedVector3Array()
points.resize(33)
for i in 33:
points[i] = Vector3(
float(i % 33) / 32.0,
1.0 - float(i % 33) / 32.0,
float((i % 7) - 3) / 10.0
)
return points
func _sample_confidence() -> PackedFloat32Array:
var conf := PackedFloat32Array()
conf.resize(33)
for i in 33:
conf[i] = 1.0 if i % 2 == 0 else 0.5
return conf
func test_constants_match_contract() -> void:
eq(KeypointFrame.BINARY_SIZE, 239, "帧长度常量为 239")
eq(KeypointFrame.KEYPOINT_COUNT, 33, "关键点数量为 33")
eq(KeypointFrame.HEADER_SIZE + KeypointFrame.KEYPOINT_COUNT * KeypointFrame.POINT_BYTES, 239, "8 + 33×7 = 239")
eq(KeypointFrame.MAGIC, 0x4F44, "magic 为 'OD'(0x4F44)")
eq(KeypointFrame.PROTOCOL_VERSION, 1, "协议版本为 1")
func test_encode_produces_239_bytes_with_correct_header() -> void:
var buf := KeypointFrame.encode(42, _sample_points(), _sample_confidence(), 1)
eq(buf.size(), 239, "编码结果恰好 239 字节")
eq(buf.decode_u16(0), 0x4F44, "magic 正确")
eq(buf.decode_u8(2), 1, "version 正确")
eq(buf.decode_u8(3), 1, "flags 正确")
eq(buf.decode_u32(4), 42, "frame_id 正确")
func test_roundtrip_within_fixed_point_precision() -> void:
var points := _sample_points()
var conf := _sample_confidence()
var decoded := KeypointFrame.decode(KeypointFrame.encode(7, points, conf, 0))
not_null(decoded, "解码成功")
eq(decoded.frame_id, 7, "frame_id 往返正确")
eq(decoded.points.size(), 33, "解码出 33 个点")
var max_delta := 0.0
for i in 33:
max_delta = maxf(max_delta, absf(decoded.points[i].x - points[i].x))
max_delta = maxf(max_delta, absf(decoded.points[i].y - points[i].y))
max_delta = maxf(max_delta, absf(decoded.points[i].z - points[i].z))
# 定点精度:1/32767 ≈ 3.05e-5,留一倍余量
ok(max_delta < 1e-4, "坐标往返误差小于定点精度", "最大误差 %.8f" % max_delta)
var max_conf_delta := 0.0
for i in 33:
max_conf_delta = maxf(max_conf_delta, absf(decoded.confidence[i] - conf[i]))
ok(max_conf_delta < 1.0 / 255.0 + 1e-6, "置信度往返误差在一个量化步长内", "最大误差 %.8f" % max_conf_delta)
func test_rejects_wrong_length() -> void:
var short := PackedByteArray()
short.resize(238)
ne(KeypointFrame.validate(short), "", "238 字节被拒绝")
contains(KeypointFrame.validate(short), "239", "错误信息说明期望长度")
var long := PackedByteArray()
long.resize(240)
ne(KeypointFrame.validate(long), "", "240 字节被拒绝")
is_null(KeypointFrame.decode(short), "解码失败返回 null")
func test_rejects_bad_magic() -> void:
var buf := PackedByteArray()
buf.resize(239)
ne(KeypointFrame.validate(buf), "", "全零帧因 magic 错误被拒绝")
contains(KeypointFrame.validate(buf), "magic", "错误信息指出 magic")
func test_rejects_unsupported_version() -> void:
var buf := KeypointFrame.encode(0, _sample_points(), _sample_confidence())
buf.encode_u8(2, 2)
contains(KeypointFrame.validate(buf), "版本", "版本不符被拒绝")
func test_extreme_values_do_not_overflow() -> void:
var points := _sample_points()
points[0] = Vector3(1.0, 1.0, -1.0)
points[1] = Vector3(0.0, 0.0, 1.0)
var conf := _sample_confidence()
conf[0] = 1.0
conf[1] = 0.0
var decoded := KeypointFrame.decode(KeypointFrame.encode(0, points, conf))
almost(decoded.points[0].x, 1.0, 1e-4, "x = 1.0 不溢出 int16")
almost(decoded.points[0].z, -1.0, 1e-4, "z = -1.0 不溢出 int16")
almost(decoded.points[1].z, 1.0, 1e-4, "z = 1.0 不溢出 int16")
almost(decoded.confidence[1], 0.0, 1e-6, "置信度 0 往返正确")
func test_flags_helpers() -> void:
var frame := KeypointFrame.decode(
KeypointFrame.encode(0, _sample_points(), _sample_confidence(), KeypointFrame.FLAG_MIRRORED | KeypointFrame.FLAG_UPPER_BODY_ONLY)
)
is_true(frame.is_mirrored(), "镜像标志解析正确")
is_true(frame.is_upper_body_only(), "上半身模式标志解析正确")
is_false(frame.has_low_confidence(), "未设置低置信度标志")
func test_mirrored_frame_x_is_flipped() -> void:
var points := _sample_points()
var frame := KeypointFrame.decode(
KeypointFrame.encode(0, points, _sample_confidence(), KeypointFrame.FLAG_MIRRORED)
)
var flipped := frame.unmirrored_points()
almost(flipped[5].x, 1.0 - points[5].x, 1e-4, "镜像帧翻转 x")
# y 未经翻转,但仍经过 int16 定点往返,容差取定点精度
almost(flipped[5].y, points[5].y, 1e-4, "翻转不影响 y")
var normal := KeypointFrame.decode(KeypointFrame.encode(0, points, _sample_confidence(), 0))
almost(normal.unmirrored_points()[5].x, points[5].x, 1e-4, "非镜像帧不翻转 x")
func test_confidence_threshold_marks_unreliable() -> void:
var conf := _sample_confidence()
conf[11] = 0.49
conf[12] = 0.5
var frame := KeypointFrame.decode(KeypointFrame.encode(0, _sample_points(), conf))
is_false(frame.is_reliable(11), "0.49 判为不可信")
is_true(frame.is_reliable(12), "0.5 判为可信(阈值含端点)")
is_true(KeypointFrame.compute_low_confidence(_sample_points(), conf), "存在低置信度点时置位")
func test_json_debug_frame() -> void:
var raw_points: Array = []
for i in 33:
raw_points.append([0.25, 0.75, -0.1, 0.9])
var frame := KeypointFrame.from_json_dict({
"frameId": 9,
"flags": {"mirrored": true, "upperBodyOnly": false},
"points": raw_points,
})
not_null(frame, "JSON 调试帧解析成功")
eq(frame.frame_id, 9, "frameId 解析正确")
is_true(frame.is_mirrored(), "mirrored 标志解析正确")
almost(frame.points[0].x, 0.25, 1e-6, "坐标解析正确")
var bad := KeypointFrame.from_json_dict({"frameId": 1, "points": []})
is_null(bad, "点数不足的 JSON 帧被拒绝")
## T-WIN-021:编码/解码必须与契约参考实现(JS)**逐位一致**
##
## JS 的 `Math.round` 对「恰好 .5」朝 +∞ 取整(`Math.round(-2.5) === -2`),
## 而 GDScript 的 `roundi` 是半数远离零(`roundi(-2.5) == -3`)——
## 两者在 `-X.5` 上差 1 个 LSB。Android 的 Kotlin 实现用的正是 `Math.round`,
## 所以本端必须显式实现同一规则(`_round_like_js` = `floor(x + 0.5)`)。
func test_rounding_rule_matches_js_math_round() -> void:
eq(KeypointFrame._round_like_js(-2.5), -2, "取整 -2.5 → -2(半数朝 +∞,与 JS 一致)")
eq(KeypointFrame._round_like_js(2.5), 3, "取整 2.5 → 3")
eq(KeypointFrame._round_like_js(-0.5), 0, "取整 -0.5 → 0(roundi 会给 -1)")
eq(KeypointFrame._round_like_js(0.5), 1, "取整 0.5 → 1")
eq(KeypointFrame._round_like_js(-1.5), -1, "取整 -1.5 → -1")
# 端到端:-0.5/32767 经 float32 后在量化前恰好落在 -.5 上
var points := PackedVector3Array()
var conf := PackedFloat32Array()
points.resize(33)
conf.resize(33)
for i in 33:
points[i] = Vector3(-0.5 / 32767.0, 0.0, 0.0)
conf[i] = 128.0 / 255.0
var buf := KeypointFrame.encode(0, points, conf, 0)
eq(buf.decode_s16(8), 0, "坐标 -0.5/32767 编码为 0(roundi 会给 -1)")
eq(buf.decode_u8(14), 128, "置信度 128/255 编码为 128")
## 用 Web 参考实现(opendance-web 的 keypoint-codec.ts)生成的**锁定向量**做逐值比对。
## 这一步让 `verify.sh`(只跑源码,不需要 pck)也能覆盖「Web ↔ Windows」这条边;
## 发布包层面的同一比对由 build/accept-frame-vectors.sh 负责。
func test_reference_vectors_decode_and_encode_exactly() -> void:
var path := "res://build/integration/frame-vectors.json"
if not FileAccess.file_exists(path):
skip("参考向量", "找不到 %s" % path)
return
var file := FileAccess.open(path, FileAccess.READ)
var parsed: Variant = JSON.parse_string(file.get_as_text())
file.close()
not_null(parsed, "参考向量可解析")
var cases: Array = parsed.get("cases", [])
eq(cases.size(), 12, "参考向量用例数")
var mismatches: Array = []
var points_compared := 0
for c in cases:
var name := str(c.get("name", "?"))
var bytes := PackedByteArray()
var hex := str(c.get("hex", ""))
bytes.resize(hex.length() / 2)
for i in bytes.size():
bytes[i] = hex.substr(i * 2, 2).hex_to_int()
if KeypointFrame.validate(bytes) != "":
mismatches.append("%s: validate 失败" % name)
continue
var frame = KeypointFrame.decode(bytes)
if frame == null:
mismatches.append("%s: decode 为 null" % name)
continue
if int(frame.frame_id) != int(c.get("frameId", 0)):
mismatches.append("%s: frame_id" % name)
if int(frame.flags) != int(c.get("flags", 0)):
mismatches.append("%s: flags" % name)
var expect_points: Array = c.get("points", [])
for i in expect_points.size():
points_compared += 1
var e: Array = expect_points[i]
# 本端点存 PackedVector3Array(float32),参考实现是 float64:
# 先把期望值收敛到 float32(Vector3 的分量天然是 float32)再比精确相等
var want := Vector3(float(e[0]), float(e[1]), float(e[2]))
var want_conf := PackedFloat32Array([float(e[3])])[0]
if frame.points[i] != want:
mismatches.append("%s: 点 %d 坐标 %s != %s" % [name, i, str(frame.points[i]), str(want)])
elif frame.confidence[i] != want_conf:
mismatches.append("%s: 点 %d 置信度" % [name, i])
# 编码:用同输入基准 f32InputPoints,与 f32Hex 逐字节比对
var f32_pts: Array = c.get("f32InputPoints", [])
var f32_hex := str(c.get("f32Hex", ""))
if f32_pts.size() == 33 and f32_hex != "":
var pts := PackedVector3Array()
var cfs := PackedFloat32Array()
pts.resize(33)
cfs.resize(33)
for i in 33:
var p: Array = f32_pts[i]
pts[i] = Vector3(float(p[0]), float(p[1]), float(p[2]))
cfs[i] = float(p[3])
var encoded := KeypointFrame.encode(int(c.get("frameId", 0)), pts, cfs, int(c.get("flags", 0)))
if encoded.hex_encode() != f32_hex:
mismatches.append("%s: 编码字节与参考不一致" % name)
eq(points_compared, cases.size() * 33, "逐点比对数量")
eq(mismatches.slice(0, 5), [], "参考向量逐值/逐字节一致(差异 %d 项)" % mismatches.size())