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