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