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:
@@ -123,6 +123,30 @@ exe 的 PE 版本资源 → **用 Godot 直接加载包内 pck 跑 180 帧** →
|
|||||||
已对**已发布的 v0.2.2 资产**(重新下载)跑通,并用「篡改包内 contracts/VERSION」与
|
已对**已发布的 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)
|
### WebSocket 契约一致性验收(T-WIN-020)
|
||||||
|
|
||||||
`tests/test_pose_server.gd` 用的是**同引擎内的客户端**,而真实对端是
|
`tests/test_pose_server.gd` 用的是**同引擎内的客户端**,而真实对端是
|
||||||
|
|||||||
Executable
+152
@@ -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
@@ -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
|
||||||
@@ -180,6 +180,7 @@ echo "分发:上传 $NAME.zip 与 SHA256SUMS 两个资产;用户下载后"
|
|||||||
echo " sha256sum -c SHA256SUMS && unzip $NAME.zip 即可直接运行 OpenDance.exe。"
|
echo " sha256sum -c SHA256SUMS && unzip $NAME.zip 即可直接运行 OpenDance.exe。"
|
||||||
echo ""
|
echo ""
|
||||||
echo "下一步(文档 §7.5 / §7.6:发布前必须做的三条产物级验收):"
|
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-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-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 # 跨端(需已部署服务端)"
|
echo " API_BASE=http://127.0.0.1:3000 GODOT_BIN=... bash build/accept-integration.sh $ZIP # 跨端(需已部署服务端)"
|
||||||
|
|||||||
@@ -106,6 +106,10 @@ tasks:
|
|||||||
file: tasks/020-ws-contract-conformance.md
|
file: tasks/020-ws-contract-conformance.md
|
||||||
scope: WebSocket 契约一致性验收(Node 独立客户端 × 发布包内 PoseServer)+ 故意不合规的负例服务端
|
scope: WebSocket 契约一致性验收(Node 独立客户端 × 发布包内 PoseServer)+ 故意不合规的负例服务端
|
||||||
status: done
|
status: done
|
||||||
|
- id: T-WIN-021
|
||||||
|
file: tasks/021-frame-vector-conformance.md
|
||||||
|
scope: 关键点帧跨实现一致性(Web 参考实现 × 包内 GDScript 解码器,12 用例 / 396 点逐值比对)
|
||||||
|
status: done
|
||||||
|
|
||||||
deliverable:
|
deliverable:
|
||||||
artifact: opendance-windows-v0.2.3.zip
|
artifact: opendance-windows-v0.2.3.zip
|
||||||
|
|||||||
@@ -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 解码 → 驱动模型)仍需设备实测。
|
||||||
Reference in New Issue
Block a user