两个缺陷,都由上一轮的验收机制暴露:
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 输入的行为其实完全一致。断言必须基于实际存储精度,或改用有效判别用例。
256 lines
10 KiB
GDScript
256 lines
10 KiB
GDScript
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())
|