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 失败)。
This commit is contained in:
2026-09-17 05:52:11 +08:00
parent cb1c293dcf
commit 1c5f28cd41
8 changed files with 7776 additions and 2 deletions
+24
View File
@@ -123,6 +123,30 @@ exe 的 PE 版本资源 → **用 Godot 直接加载包内 pck 跑 180 帧** →
已对**已发布的 v0.2.2 资产**(重新下载)跑通,并用「篡改包内 contracts/VERSION」与
「期望版本写错」两个负例反向验证过。
### 关键点帧跨实现一致性验收(T-WIN-021)
`web-server/src/preprocess/keypoint-codec.ts` 是契约 §2 的**参考实现**
(其 `decodeBinaryFrame` 注释就是「Windows 端参考实现(T-WIN-004)」)。
Android 已用「金标准字节向量」与它对齐;而**本端的解码器此前只跟自己比过**
(自己编码、自己解码)。这条边由下列脚本补上:
```bash
GODOT_BIN=/path/to/godot bash build/accept-frame-vectors.sh dist-release/opendance-windows-v0.2.3.zip
# 没有 web 仓 / 未装 node_modules 时:... --skip-regen
```
1. 用**参考实现**(`npm run vectors:frame`)重新生成向量,与仓库锁定的
`build/integration/frame-vectors.json` 比对 —— 参考实现变了却不同步向量,这里就会失败;
2. 用**包内 pck** 解码同一批字节,与参考解码结果**逐值精确比对**
(float32 表示精度内):实测 **12 用例 / 396 点 / 0 差异**;
3. 附带比对两边编码器:本端不是发送方(`encode()` 只用于自测),差异只归类记录,
但**必须只出现在「量化前恰好 .5」的用例上**。
> 已测量并归类的一处差异:JS `Math.round` 与 Kotlin `Math.round` 对 `-2.5` 取 `-2`
> (半数朝 +∞),而本端 `roundi` 取 `-3`(半数远离零)。生产发送方是 Android,
> 与参考实现一致;Windows 是接收方,`encode()` 不在生产链路上。
> 详见 [`tasks/021-frame-vector-conformance.md`](tasks/021-frame-vector-conformance.md)。
### WebSocket 契约一致性验收(T-WIN-020)
`tests/test_pose_server.gd` 用的是**同引擎内的客户端**,而真实对端是
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env bash
# ============================================================
# OpenDance Studio Windows 客户端 —— **关键点帧跨实现一致性验收**(T-WIN-021)
#
# opendance-web 的 TypeScript 参考实现(契约 §2)× 发布包里的 GDScript 解码器
#
# 为什么需要它:`web-server/src/preprocess/keypoint-codec.ts` 是契约 §2 的
# **参考实现**(其 `decodeBinaryFrame` 的注释就是「Windows 端参考实现(T-WIN-004)」)。
# Android 的 Kotlin 实现已用「金标准字节向量」与它对齐;而**本端的 GDScript 解码器
# 此前只跟自己比过**(自己编码、自己解码)。本脚本把这条缺失的边补上:
#
# 1. 用**参考实现**重新生成向量,与仓库里锁定的 `build/integration/frame-vectors.json`
# 比对 —— 参考实现一旦变化,这里必须失败(否则"锁定"就没有意义);
# 2. 用**发布包里的 pck** 解码同一批字节,与参考实现的解码结果**逐值精确比对**
# (float32 表示精度内);
# 3. 附带比对两边**编码器**的字节:本端不是发送方(`encode()` 只用于自测),
# 因此只归类记录 —— 但差异必须**只出现在量化前恰好落在 .5 的用例**上。
#
# 用法:
# GODOT_BIN=/path/to/godot bash build/accept-frame-vectors.sh dist-release/opendance-windows-v0.2.3.zip
# ... --skip-regen # 不重新生成向量(没有 web 仓 / 没装 node_modules 时)
#
# 环境变量:
# WEB_REPO opendance-web 仓库路径(默认 ../opendance-web)
# WORK_DIR 工作目录(默认 mktemp -d)
# ============================================================
set -uo pipefail
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$PROJECT_DIR"
TARGET="${1:-}"
SKIP_REGEN=""
for arg in "$@"; do
case "$arg" in
--skip-regen) SKIP_REGEN=1 ;;
"$TARGET") : ;;
*) echo "✗ 未知参数:$arg(只支持 --skip-regen)" >&2; exit 2 ;;
esac
done
WEB_REPO="${WEB_REPO:-$PROJECT_DIR/../opendance-web}"
WORK_DIR="${WORK_DIR:-$(mktemp -d)}"
mkdir -p "$WORK_DIR"
VECTORS="$PROJECT_DIR/build/integration/frame-vectors.json"
REPORT="$WORK_DIR/frame-vectors-report.json"
FAILED=0
log() { printf '\033[1;34m[accept-fv]\033[0m %s\n' "$*"; }
fail() { printf '\033[1;31m[accept-fv] ✗ %s\033[0m\n' "$*" >&2; FAILED=1; }
if [ -z "$TARGET" ] || [ ! -e "$TARGET" ]; then
echo "用法:GODOT_BIN=... bash build/accept-frame-vectors.sh <发布包 zip 或已解压目录> [--skip-regen]" >&2
exit 2
fi
[ -f "$VECTORS" ] || { fail "找不到锁定的向量文件:$VECTORS"; exit 1; }
# ---- 1. 用参考实现重新生成并比对(确保向量仍与参考实现一致)----
log "参考实现:$WEB_REPO/web-server/src/preprocess/keypoint-codec.ts"
if [ -n "$SKIP_REGEN" ]; then
log "跳过重新生成(--skip-regen)"
elif [ ! -f "$WEB_REPO/web-server/scripts/gen-frame-vectors.mts" ]; then
fail "找不到生成脚本($WEB_REPO/web-server/scripts/gen-frame-vectors.mts)—— 用 WEB_REPO 指定仓库,或 --skip-regen"
elif [ ! -d "$WEB_REPO/web-server/node_modules" ]; then
fail "web 仓没有 node_modules,无法运行参考实现(先在那边 npm install,或 --skip-regen)"
else
log "用参考实现重新生成向量并比对…"
REGEN="$WORK_DIR/frame-vectors.regen.json"
if ( cd "$WEB_REPO/web-server" && timeout 300 npx vite-node scripts/gen-frame-vectors.mts -- --out "$REGEN" >"$WORK_DIR/regen.log" 2>&1 ); then
if diff -q "$VECTORS" "$REGEN" >/dev/null; then
echo " ✓ 锁定的向量与参考实现重新生成的结果逐字节一致"
else
fail "锁定的向量已与参考实现不一致(参考实现变了却没同步向量)"
diff "$VECTORS" "$REGEN" | head -20 | sed 's/^/ /'
fi
else
fail "参考实现生成失败(见 $WORK_DIR/regen.log)"
tail -10 "$WORK_DIR/regen.log" | sed 's/^/ /'
fi
fi
# ---- 2. 取包内 pck ----
if [ -f "$TARGET" ]; then
log "解压发布包…"
if ! unzip -qq -o "$TARGET" -d "$WORK_DIR" 2>/dev/null; then
command -v 7z >/dev/null 2>&1 && 7z x -y -o"$WORK_DIR" "$TARGET" >/dev/null || { fail "解压失败"; exit 1; }
fi
PACKAGE_DIR="$(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d | head -1)"
else
PACKAGE_DIR="$TARGET"
fi
PCK="$PACKAGE_DIR/OpenDance.pck"
[ -f "$PCK" ] || { fail "找不到包内 pck:$PCK"; exit 1; }
log "包内 pck:$PCK"
GODOT_BIN="${GODOT_BIN:-}"
if [ -z "$GODOT_BIN" ]; then
for candidate in godot godot4 Godot; do
command -v "$candidate" >/dev/null 2>&1 && { GODOT_BIN="$candidate"; break; }
done
fi
[ -n "$GODOT_BIN" ] || { fail "未找到 Godot(用 GODOT_BIN=<路径> 指定,需 4.7.x)"; exit 1; }
# ---- 3. 用包内解码器逐值比对 ----
log "包内解码器 × 参考向量…"
PROBE_LOG="$WORK_DIR/probe.log"
rm -f "$REPORT"
if timeout 300 "$GODOT_BIN" --headless --main-pack "$PCK" \
--script "$PROJECT_DIR/build/integration/probe-frame-vectors.gd" \
-- --vectors "$VECTORS" --report "$REPORT" >"$PROBE_LOG" 2>&1; then
PROBE_OK=1
else
PROBE_OK=0
fi
grep -vE "^Godot Engine|^$" "$PROBE_LOG" | sed 's/^/ /'
[ "$PROBE_OK" = 1 ] || fail "解码比对未通过"
# ---- 4. 报告核对 ----
if [ ! -f "$REPORT" ]; then
fail "探针没有产出报告($REPORT)"
exit 1
fi
log "报告核对:"
node -e '
const fs = require("node:fs");
const r = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
let bad = 0;
const say = (ok, text) => { console.log(` ${ok ? "✓" : "✗"} ${text}`); if (!ok) bad = 1; };
say(r.cases > 0 && r.decodedCases === r.cases, `解码用例 ${r.decodedCases}/${r.cases}`);
say(r.pointsCompared === r.cases * 33, `逐点比对 ${r.pointsCompared} 个(期望 ${r.cases * 33})`);
say(Array.isArray(r.decodeFailures) && r.decodeFailures.length === 0,
`解码差异 ${r.decodeFailures?.length ?? "?"} 项(必须为 0)`);
if (r.decodeFailures?.length) for (const f of r.decodeFailures.slice(0, 5)) console.log(` · ${f.case}:${f.problem}`);
// 编码器差异:本端不是发送方(只用于自测),但差异必须只出现在「量化前恰好 .5」的用例上
const diffs = r.encodeDifferences ?? [];
const unexpected = diffs.filter((d) => !d.halfValueInput);
say(unexpected.length === 0,
`编码器差异 ${diffs.length} 项,全部属于「量化前恰好 .5」的取整边界:${diffs.map((d) => d.case + "(" + d.diffBytes + "B)").join("、") || "无"}`);
if (unexpected.length) for (const d of unexpected) console.log(` · 非预期差异:${d.case}(${d.diffBytes} 字节)`);
process.exit(bad);
' "$REPORT" || fail "报告核对未通过"
echo ""
if [ "$FAILED" -ne 0 ]; then
echo "============================================================"
echo " 关键点帧跨实现一致性验收失败(工作目录:$WORK_DIR)"
echo "============================================================"
exit 1
fi
echo "============================================================"
echo " 关键点帧跨实现一致性验收全部通过(工作目录:$WORK_DIR)"
echo "============================================================"
File diff suppressed because it is too large Load Diff
+211
View File
@@ -0,0 +1,211 @@
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)
@@ -0,0 +1 @@
uid://chtushi1p64ng
+3 -2
View File
@@ -180,6 +180,7 @@ echo "分发:上传 $NAME.zip 与 SHA256SUMS 两个资产;用户下载后"
echo " sha256sum -c SHA256SUMS && unzip $NAME.zip 即可直接运行 OpenDance.exe。"
echo ""
echo "下一步(文档 §7.5 / §7.6:发布前必须做的三条产物级验收):"
echo " GODOT_BIN=/path/to/godot bash build/accept-release.sh $ZIP # 产物自证"
echo " GODOT_BIN=/path/to/godot bash build/accept-ws.sh $ZIP # WebSocket 契约一致性"
echo " GODOT_BIN=/path/to/godot bash build/accept-release.sh $ZIP # 产物自证"
echo " GODOT_BIN=/path/to/godot bash build/accept-ws.sh $ZIP # WebSocket 契约一致性"
echo " GODOT_BIN=/path/to/godot bash build/accept-frame-vectors.sh $ZIP # 关键点帧跨实现一致性"
echo " API_BASE=http://127.0.0.1:3000 GODOT_BIN=... bash build/accept-integration.sh $ZIP # 跨端(需已部署服务端)"
+4
View File
@@ -106,6 +106,10 @@ tasks:
file: tasks/020-ws-contract-conformance.md
scope: WebSocket 契约一致性验收(Node 独立客户端 × 发布包内 PoseServer)+ 故意不合规的负例服务端
status: done
- id: T-WIN-021
file: tasks/021-frame-vector-conformance.md
scope: 关键点帧跨实现一致性(Web 参考实现 × 包内 GDScript 解码器,12 用例 / 396 点逐值比对)
status: done
deliverable:
artifact: opendance-windows-v0.2.3.zip
+86
View File
@@ -0,0 +1,86 @@
# T-WIN-021:关键点帧跨实现一致性(Web 参考实现 × 包内 GDScript 解码器)
- **状态**: done
- **依赖**: T-WIN-004(关键点帧解码)/ T-WEB-006(动作内部标准)
- **文档**: v1.6 §7.6.2
- **契约**: `websocket-keypoints 0.2.0` §2
## 为什么需要
`opendance-web/web-server/src/preprocess/keypoint-codec.ts` 是契约 §2 的**参考实现**
(它的 `encodeBinaryFrame` 注释就是「Android 端参考实现」,
`decodeBinaryFrame` 注释是「Windows 端参考实现(T-WIN-004)」):
* **Android ↔ Web** 已经用「金标准字节向量」对齐(`KeypointFrameTest.goldenVectorMatchesWebImplementation`);
* **Windows 的解码器此前只跟自己比过** —— 自己编码、自己解码,
两个方向用同一份错误假设也能一直绿。这条边是本轮补的。
## 实现
| 文件 | 角色 |
|--|--|
| `opendance-web/web-server/scripts/gen-frame-vectors.mts`(新增) | 用**参考实现**生成向量:12 个用例的字节(`hex`)+参考解码结果(`points`,float64)+ float32 输入基准(`f32InputPoints` / `f32Hex`) |
| `opendance-web/web-server/test/keypoint-frame-codec.spec.ts`(新增,7 项) | 把参考实现对其他两端的**承诺显式钉住**:i16 最小值边界、**取整规则**、置信度阈值 128、u32 frame_id、flags 掩码、非法帧拒绝、随机输入往返 |
| `opendance-windows/build/integration/frame-vectors.json`(锁定) | 由参考实现生成并入库的向量(比对基准) |
| `opendance-windows/build/integration/probe-frame-vectors.gd`(新增) | 用**包内** `KeypointFrame` 解码同一批字节并逐值比对,产出 JSON 报告 |
| `opendance-windows/build/accept-frame-vectors.sh`(新增) | 编排:**重新生成并比对向量** → 解包 → 包内解码比对 → 报告核对 |
### 比对口径(关键)
* `flags` / `frame_id`:**精确相等**;
* 坐标与置信度:本端点存进 `PackedVector3Array`、置信度存进 `PackedFloat32Array`,
即 **float32**;参考实现是 float64。因此先把期望值收敛到 float32
(GDScript 里借 `Vector3(...)` / `PackedFloat32Array([...])` 天然完成)再比**精确相等**
—— 比用容差更严格,并把差异限定在「表示精度」这一点上。
## 实测(已发布的 v0.2.3 资产重新下载后跑)
```
[accept-fv] 用参考实现重新生成向量并比对…
✓ 锁定的向量与参考实现重新生成的结果逐字节一致
[accept-fv] 包内解码器 × 参考向量…
[frame-vectors] 用例 12 个;解码用例 12/12,比对点 396 个
[frame-vectors] ✓ 解码结果与 Web 参考实现逐值一致(float32 精确相等)
✓ 解码差异 0 项(必须为 0)
✓ 逐点比对 396 个(期望 396)
✓ 编码器差异 1 项,全部属于「量化前恰好 .5」的取整边界
关键点帧跨实现一致性验收全部通过
```
覆盖的边界:`i16` 最小值 `-32768`(`-32768/32767`,必须是 `0x8000` 而不是被夹成 `-32767`)、
`i16` 最大值、`±1` 全量程、置信度 `0 / 128 / 255`、`flags` 低 3 位组合、
`frame_id` 的 `0xFFFFFFFF` 与 `0x80000000`、以及取整边界。
## 一处**已测量并归类**的差异(非缺陷)
本端 `KeypointFrame.encode()` 与参考编码器在**一个**用例上差 33 字节:
| | 取整方式 | `-X.5` 的行为 |
|--|--|--|
| JS 参考实现(`Math.round`) | 半数朝 **+∞** | `-2.5 → -2` |
| **Android(生产发送方,`Math.round`)** | 同上 | `-2.5 → -2` ✅ 与参考一致 |
| Windows `KeypointFrame.encode()`(`roundi`,半数远离零) | | `-2.5 → -3` ❌ 差 1 LSB |
**影响**:Windows 是**接收方**,生产链路里不调用 `encode()`(它只用于本仓自测与协议回归),
因此对产品无影响。`accept-frame-vectors.sh` 因此把编码差异记为「归类记录」,
但**硬性要求差异只能出现在「量化前恰好落在 .5」的用例上** —— 若哪天出现在别处,
就说明还有别的问题。若将来 Windows 需要成为发送方,把 `roundi` 换成
`floor(x + 0.5)` 即可与参考实现完全一致。
## 踩坑记录(两处"看起来通过、其实没测到")
1. **期望值写成了输入值**:第一版生成器把「喂进编码器的原始点」当成 `points` 写进向量,
于是比对变成了「比编码量化」而不是「比两个解码器是否一致」。
靠抽查数值(`-2.5/32767` 的原值出现在解码结果里)才发现。
→ 向量里现在同时有 `inputPoints`(原始)、`f32InputPoints` / `f32Hex`(同输入基准)
与 `points`(**参考解码结果**),并各自注明用途。
2. **拿不同输入的字节做比较**:编码器比对一开始用 `hex`(float64 输入编出来的)当基准,
于是「正数恰好 .5」整例 99 字节不同 —— 实际只是输入从 float64 收敛到 float32 的量化差,
与取整规则无关。改用 `f32Hex`(**同输入**基准)后,差异收敛到唯一一个真正的取整边界用例。
→ 教训:跨实现比对必须先统一**输入**,否则差异无法归因。
## 未覆盖
* 三端矩阵的另外两条边已有证据:Android ↔ Web(金标准向量,Android 仓 JVM 测试)、
Android → Windows(T-WIN-020 的实时链路验收,真实 WebSocket 发 239 字节帧);
* 真机端到端(相机 → MediaPipe → 编码 → Wi-Fi → Windows 解码 → 驱动模型)仍需设备实测。