feat(win): T-WIN-004 239 字节关键点帧编解码 + 无头测试框架

This commit is contained in:
2026-09-14 20:04:54 +08:00
parent 85a2568266
commit ad62929f59
4 changed files with 590 additions and 0 deletions
+210
View File
@@ -0,0 +1,210 @@
class_name KeypointFrame
extends RefCounted
## 239 字节关键点帧编解码 —— 契约 contracts/websocket-keypoints.md §2(冻结)
##
## 帧布局(小端序):
## 偏移 0 2B magic = 0x4F44 ('OD')
## 偏移 2 1B version = 0x01
## 偏移 3 1B flags bit0=镜像 bit1=含低置信度点 bit2=上半身模式
## 偏移 4 4B frame_id u32
## 偏移 8 231B keypoints[33] × 7B
## 每点:+0 s16 x +2 s16 y +4 s16 z +6 u8 conf
## 合计 239
##
## 坐标语义见 contracts/skeleton-topology.md §4:
## x,y ∈ [0,1] 归一化图像坐标(原点左上);z 与 x 同尺度,负值朝相机。
## 归一化系数统一为 32767。
const MAGIC := 0x4F44
const PROTOCOL_VERSION := 1
const BINARY_SIZE := 239
const KEYPOINT_COUNT := 33
const HEADER_SIZE := 8
const POINT_BYTES := 7
const SCALE := 32767.0
## flags 位定义
const FLAG_MIRRORED := 1 << 0
const FLAG_LOW_CONFIDENCE := 1 << 1
const FLAG_UPPER_BODY_ONLY := 1 << 2
## 低置信度阈值:conf < 0.5 的点坐标无意义,必须插值
const CONFIDENCE_THRESHOLD := 0.5
## MediaPipe 33 点索引 —— 契约 skeleton-topology.md §1(完整 33 项)
const NOSE := 0
const LEFT_EYE_INNER := 1
const LEFT_EYE := 2
const LEFT_EYE_OUTER := 3
const RIGHT_EYE_INNER := 4
const RIGHT_EYE := 5
const RIGHT_EYE_OUTER := 6
const LEFT_EAR := 7
const RIGHT_EAR := 8
const MOUTH_LEFT := 9
const MOUTH_RIGHT := 10
const LEFT_SHOULDER := 11
const RIGHT_SHOULDER := 12
const LEFT_ELBOW := 13
const RIGHT_ELBOW := 14
const LEFT_WRIST := 15
const RIGHT_WRIST := 16
const LEFT_PINKY := 17
const RIGHT_PINKY := 18
const LEFT_INDEX := 19
const RIGHT_INDEX := 20
const LEFT_THUMB := 21
const RIGHT_THUMB := 22
const LEFT_HIP := 23
const RIGHT_HIP := 24
const LEFT_KNEE := 25
const RIGHT_KNEE := 26
const LEFT_ANKLE := 27
const RIGHT_ANKLE := 28
const LEFT_HEEL := 29
const RIGHT_HEEL := 30
const LEFT_FOOT_INDEX := 31
const RIGHT_FOOT_INDEX := 32
## 一帧姿态数据
class PoseFrame extends RefCounted:
var frame_id: int = 0
var flags: int = 0
## 33 个归一化坐标;z 为深度
var points: PackedVector3Array = PackedVector3Array()
## 33 个置信度 [0,1]
var confidence: PackedFloat32Array = PackedFloat32Array()
## 接收时刻(毫秒,引擎时钟)
var received_at_ms: int = 0
func is_mirrored() -> bool:
return (flags & KeypointFrame.FLAG_MIRRORED) != 0
func is_upper_body_only() -> bool:
return (flags & KeypointFrame.FLAG_UPPER_BODY_ONLY) != 0
func has_low_confidence() -> bool:
return (flags & KeypointFrame.FLAG_LOW_CONFIDENCE) != 0
func confidence_at(index: int) -> float:
if index < 0 or index >= confidence.size():
return 0.0
return confidence[index]
func is_reliable(index: int) -> bool:
return confidence_at(index) >= KeypointFrame.CONFIDENCE_THRESHOLD
func point(index: int) -> Vector3:
if index < 0 or index >= points.size():
return Vector3.ZERO
return points[index]
## 已镜像的帧需要翻转 x 才能与本端坐标系一致(契约 skeleton-topology.md §4)
func unmirrored_points() -> PackedVector3Array:
if not is_mirrored():
return points
var out := PackedVector3Array()
out.resize(points.size())
for i in points.size():
var p := points[i]
out[i] = Vector3(1.0 - p.x, p.y, p.z)
return out
## 校验帧字节流;合法返回空字符串,否则返回可读原因
static func validate(buf: PackedByteArray) -> String:
if buf.size() != BINARY_SIZE:
return "帧长度必须为 %d 字节,实际 %d" % [BINARY_SIZE, buf.size()]
var magic := buf.decode_u16(0)
if magic != MAGIC:
return "magic 校验失败:期望 0x%04X,实际 0x%04X" % [MAGIC, magic]
var version := buf.decode_u8(2)
if version != PROTOCOL_VERSION:
return "不支持的协议版本 %d(本端支持 %d)" % [version, PROTOCOL_VERSION]
return ""
## 解码 239 字节帧;失败返回 null 并 push_error
static func decode(buf: PackedByteArray) -> PoseFrame:
var problem := validate(buf)
if problem != "":
push_error("[KeypointFrame] 解码失败:%s" % problem)
return null
var frame := PoseFrame.new()
frame.flags = buf.decode_u8(3)
frame.frame_id = buf.decode_u32(4)
frame.points.resize(KEYPOINT_COUNT)
frame.confidence.resize(KEYPOINT_COUNT)
for i in KEYPOINT_COUNT:
var offset := HEADER_SIZE + i * POINT_BYTES
frame.points[i] = Vector3(
buf.decode_s16(offset) / SCALE,
buf.decode_s16(offset + 2) / SCALE,
buf.decode_s16(offset + 4) / SCALE
)
frame.confidence[i] = buf.decode_u8(offset + 6) / 255.0
return frame
## 编码为 239 字节帧(本端为接收方,此函数用于自测与协议回归)
static func encode(frame_id: int, points: PackedVector3Array, confidence: PackedFloat32Array, flags: int = 0) -> PackedByteArray:
assert(points.size() == KEYPOINT_COUNT, "points 必须为 33 项")
assert(confidence.size() == KEYPOINT_COUNT, "confidence 必须为 33 项")
var buf := PackedByteArray()
buf.resize(BINARY_SIZE)
buf.encode_u16(0, MAGIC)
buf.encode_u8(2, PROTOCOL_VERSION)
buf.encode_u8(3, flags & 0xFF)
buf.encode_u32(4, frame_id)
for i in KEYPOINT_COUNT:
var offset := HEADER_SIZE + i * POINT_BYTES
var p := points[i]
buf.encode_s16(offset, clampi(roundi(p.x * SCALE), -32768, 32767))
buf.encode_s16(offset + 2, clampi(roundi(p.y * SCALE), -32768, 32767))
buf.encode_s16(offset + 4, clampi(roundi(p.z * SCALE), -32768, 32767))
buf.encode_u8(offset + 6, clampi(roundi(confidence[i] * 255.0), 0, 255))
return buf
## 由 JSON 调试帧构造(契约 §4,仅联调用)
static func from_json_dict(data: Dictionary) -> PoseFrame:
var raw_points: Array = data.get("points", [])
if raw_points.size() != KEYPOINT_COUNT:
push_error("[KeypointFrame] JSON 帧 points 必须为 33 项")
return null
var frame := PoseFrame.new()
frame.frame_id = int(data.get("frameId", 0))
var flags: Dictionary = data.get("flags", {})
if bool(flags.get("mirrored", false)):
frame.flags |= FLAG_MIRRORED
if bool(flags.get("upperBodyOnly", false)):
frame.flags |= FLAG_UPPER_BODY_ONLY
frame.points.resize(KEYPOINT_COUNT)
frame.confidence.resize(KEYPOINT_COUNT)
for i in KEYPOINT_COUNT:
var entry: Array = raw_points[i]
if entry.size() != 4:
push_error("[KeypointFrame] JSON 帧第 %d 个点必须为 [x, y, z, conf]" % i)
return null
frame.points[i] = Vector3(float(entry[0]), float(entry[1]), float(entry[2]))
frame.confidence[i] = float(entry[3])
return frame
## 该帧是否含低于阈值的点,用于设置 FLAG_LOW_CONFIDENCE
static func compute_low_confidence(points: PackedVector3Array, confidence: PackedFloat32Array) -> bool:
for i in confidence.size():
if confidence[i] < CONFIDENCE_THRESHOLD:
return true
return false
+164
View File
@@ -0,0 +1,164 @@
extends SceneTree
## 无头测试入口。
##
## 用法:
## godot --headless --path . --script res://tests/run_tests.gd
##
## 约定:tests/test_*.gd 继承 TestCase,其中 test_ 开头的方法会被执行。
## 不依赖 autoload —— 测试直接 preload 被测类,确保可在 --script 模式下运行。
const TEST_DIR := "res://tests"
const TEST_PREFIX := "test_"
const TEST_SUFFIX := ".gd"
## 断言基类所在文件,不作为测试用例收集
const BASE_CASE_FILE := "test_case.gd"
var _passed := 0
var _failed := 0
var _skipped := 0
var _assertions := 0
var _failures: Array[String] = []
var _current := ""
var _started := false
## 注意:不能在 _initialize() 里跑测试。
## 那时 root Window 尚未真正进入场景树,HTTPRequest.request() 会返回
## ERR_UNCONFIGURED(3),导致所有网络相关测试假失败。改在首帧 _process 中启动。
func _initialize() -> void:
pass
func _process(_delta: float) -> bool:
# 必须始终返回 false:只要测试里有 await(例如真实 HTTP 请求),
# 协程就会跨帧挂起;此时若返回 true,主循环会立刻退出,
# 测试被拦腰截断且悄无声息(同步测试因为不跨帧,一直掩盖了这个缺陷)。
# 结束由 _run_all() 内的 quit() 负责。
if not _started:
_started = true
_run_all()
return false
func _run_all() -> void:
var files := _discover_tests()
if files.is_empty():
print("未发现任何测试文件")
quit(1)
return
print("")
print("== OpenDance Studio Windows 客户端 —— 单元测试 ==")
print("")
for path in files:
await _run_file(path)
print("")
var skip_note := (",%d 跳过" % _skipped) if _skipped > 0 else ""
print("== 结果:%d 通过 / %d 失败%s ==" % [_passed, _failed, skip_note])
if _failed > 0:
print("")
print("失败明细:")
for line in _failures:
print(" ✗ %s" % line)
print("")
quit(1)
else:
print("全部通过")
print("")
quit(0)
func _discover_tests() -> Array[String]:
var out: Array[String] = []
var dir := DirAccess.open(TEST_DIR)
if dir == null:
push_error("无法打开 %s" % TEST_DIR)
return out
dir.list_dir_begin()
var name := dir.get_next()
while name != "":
if not dir.current_is_dir() and name.begins_with(TEST_PREFIX) and name.ends_with(TEST_SUFFIX) and name != BASE_CASE_FILE:
out.append("%s/%s" % [TEST_DIR, name])
name = dir.get_next()
dir.list_dir_end()
out.sort()
return out
func _run_file(path: String) -> void:
var case_name := path.get_file().get_basename()
_current = case_name
print("[%s]" % case_name)
var script: GDScript = load(path)
# 脚本编译失败时 load() 仍返回对象,但 can_instantiate() 为 false。
# 不做这层检查的话,整个文件会静默贡献 0 个测试而不被察觉。
if script == null or not script.can_instantiate():
_record(false, case_name, "测试脚本无法编译/实例化(查看导入日志中的 Parse Error)")
return
var instance: Object = script.new()
if instance == null:
_record(false, case_name, "测试脚本实例化返回 null")
return
# 注入断言上下文
instance.set("_runner", self)
var methods: Array[String] = []
for m in instance.get_method_list():
var mname: String = m["name"]
if mname.begins_with("test_") and not methods.has(mname):
methods.append(mname)
methods.sort()
for mname in methods:
var before_failed := _failed
var before_skipped := _skipped
var before_assertions := _assertions
# call() 对协程返回 GDScriptFunctionState,await 会等到其真正结束;
# 对普通方法返回值则立即返回。因此同步与异步测试共用一条路径。
await instance.call(mname)
if _failed != before_failed or _skipped != before_skipped:
continue
# 关键防线:一个测试方法若一条断言都没执行,说明它静默失败了
# (脚本编译失败、preload 失败、方法内报错提前返回等),一律计为失败,
# 绝不允许「什么都没做」被记成通过。
if _assertions == before_assertions:
_record(false, case_name, "%s 未执行任何断言(疑似脚本编译/加载失败)" % mname)
else:
passed("%s.%s" % [case_name, mname], true)
func report_assert(ok: bool, label: String, detail: String) -> void:
_record(ok, _current, label, detail)
## 跳过当前测试(例如依赖外部服务而该服务不可达)
func report_skip(label: String, reason: String) -> void:
_skipped += 1
print(" ⊘ %s — %s" % [label, reason])
func _record(ok: bool, group: String, label: String, detail: String = "") -> void:
_assertions += 1
if ok:
_passed += 1
print(" ✓ %s" % label)
else:
_failed += 1
var line := "%s > %s" % [group, label]
if detail != "":
line += " — %s" % detail
_failures.append(line)
print(" ✗ %s%s" % [label, (" — " + detail) if detail != "" else ""])
func passed(label: String, silent: bool = false) -> void:
if not silent:
_passed += 1
print(" ✓ %s" % label)
+55
View File
@@ -0,0 +1,55 @@
class_name TestCase
extends RefCounted
## 测试断言基类。由 tests/run_tests.gd 注入 _runner 后调用。
var _runner: Object = null
func ok(condition: bool, label: String, detail: String = "") -> void:
_runner.report_assert(condition, label, detail)
func is_true(condition: bool, label: String, detail: String = "") -> void:
ok(condition, label, detail if detail != "" else "期望 true,实际 false")
func is_false(condition: bool, label: String, detail: String = "") -> void:
ok(not condition, label, detail if detail != "" else "期望 false,实际 true")
func eq(actual: Variant, expected: Variant, label: String) -> void:
ok(actual == expected, label, "期望 %s,实际 %s" % [str(expected), str(actual)])
func ne(actual: Variant, unexpected: Variant, label: String) -> void:
ok(actual != unexpected, label, "不应等于 %s" % str(unexpected))
func almost(actual: float, expected: float, tolerance: float, label: String) -> void:
ok(
absf(actual - expected) <= tolerance,
label,
"期望 %.6f ± %.6f,实际 %.6f(差 %.6f)" % [expected, tolerance, actual, absf(actual - expected)]
)
func between(actual: float, low: float, high: float, label: String) -> void:
ok(actual >= low and actual <= high, label, "期望落在 [%.4f, %.4f],实际 %.4f" % [low, high, actual])
func is_null(value: Variant, label: String) -> void:
ok(value == null, label, "期望 null,实际 %s" % str(value))
func not_null(value: Variant, label: String) -> void:
ok(value != null, label, "期望非 null")
## 跳过当前测试(依赖外部服务不可达等)。不计入通过,也不计入失败。
func skip(label: String, reason: String) -> void:
_runner.report_skip(label, reason)
func contains(haystack: String, needle: String, label: String) -> void:
ok(haystack.contains(needle), label, "「%s」中未包含「%s」" % [haystack, needle])
+161
View File
@@ -0,0 +1,161 @@
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 帧被拒绝")