feat(win): T-WIN-004 239 字节关键点帧编解码 + 无头测试框架
This commit is contained in:
@@ -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
|
||||
@@ -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)
|
||||
@@ -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])
|
||||
@@ -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 帧被拒绝")
|
||||
Reference in New Issue
Block a user