From ad62929f59d86a4cb6a263027e5d9bc3d0bedd6b Mon Sep 17 00:00:00 2001 From: zijunchiang Date: Mon, 14 Sep 2026 20:04:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(win):=20T-WIN-004=20239=20=E5=AD=97?= =?UTF-8?q?=E8=8A=82=E5=85=B3=E9=94=AE=E7=82=B9=E5=B8=A7=E7=BC=96=E8=A7=A3?= =?UTF-8?q?=E7=A0=81=20+=20=E6=97=A0=E5=A4=B4=E6=B5=8B=E8=AF=95=E6=A1=86?= =?UTF-8?q?=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/net/keypoint_frame.gd | 210 +++++++++++++++++++++++++++++++++++ tests/run_tests.gd | 164 +++++++++++++++++++++++++++ tests/test_case.gd | 55 +++++++++ tests/test_keypoint_frame.gd | 161 +++++++++++++++++++++++++++ 4 files changed, 590 insertions(+) create mode 100644 src/net/keypoint_frame.gd create mode 100644 tests/run_tests.gd create mode 100644 tests/test_case.gd create mode 100644 tests/test_keypoint_frame.gd diff --git a/src/net/keypoint_frame.gd b/src/net/keypoint_frame.gd new file mode 100644 index 0000000..cdd2959 --- /dev/null +++ b/src/net/keypoint_frame.gd @@ -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 diff --git a/tests/run_tests.gd b/tests/run_tests.gd new file mode 100644 index 0000000..5a832bb --- /dev/null +++ b/tests/run_tests.gd @@ -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) diff --git a/tests/test_case.gd b/tests/test_case.gd new file mode 100644 index 0000000..556d827 --- /dev/null +++ b/tests/test_case.gd @@ -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]) diff --git a/tests/test_keypoint_frame.gd b/tests/test_keypoint_frame.gd new file mode 100644 index 0000000..47e4873 --- /dev/null +++ b/tests/test_keypoint_frame.gd @@ -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 帧被拒绝")