feat(android): T-AND-004 239 字节帧编解码 + 跨端金标准向量测试

This commit is contained in:
2026-09-14 20:40:16 +08:00
parent 2f6212d5bf
commit 94ec0a68d9
3 changed files with 596 additions and 0 deletions
@@ -0,0 +1,172 @@
package com.opendance.studio.model
import java.nio.ByteBuffer
import java.nio.ByteOrder
/**
* 239 字节关键点帧编解码 —— 契约 contracts/websocket-keypoints.md §2(冻结)
*
* 帧布局(**小端序**):
* ```
* 偏移 长度 字段
* 0 2 magic u16 = 0x4F44 ('OD')
* 2 1 version u8 = 1
* 3 1 flags u8 bit0 镜像 | bit1 含低置信度点 | bit2 上半身模式
* 4 4 frame_id u32 LE,从 0 起单调递增
* 8 231 keypoints 33 × 7B:s16 x, s16 y, s16 z, u8 conf
* 合计 239
* ```
*
* 坐标语义(contracts/skeleton-topology.md §4):
* x,y ∈ [0,1] 归一化图像坐标(原点左上,y 向下);z 与 x 同尺度,
* 负值朝向相机。归一化系数统一 **32767**。
*
* 本实现与 opendance-web 的 TypeScript 参考实现、opendance-windows 的 GDScript
* 实现逐位对齐,并由单元测试用同一份金标准字节向量交叉校验。
*/
object KeypointFrame {
const val MAGIC = 0x4F44
const val PROTOCOL_VERSION = 1
const val BINARY_SIZE = 239
const val KEYPOINT_COUNT = 33
const val HEADER_SIZE = 8
const val POINT_BYTES = 7
const val SCALE = 32767.0
const val FLAG_MIRRORED = 1 shl 0
const val FLAG_LOW_CONFIDENCE = 1 shl 1
const val FLAG_UPPER_BODY_ONLY = 1 shl 2
/** 低于该置信度的点坐标无意义,接收端必须插值 */
const val CONFIDENCE_THRESHOLD = 0.5f
/** 一帧姿态数据(并行数组,避免 30 FPS 循环里的对象分配) */
class PoseFrame(
val frameId: Int,
val flags: Int,
val x: FloatArray,
val y: FloatArray,
val z: FloatArray,
val confidence: FloatArray,
) {
init {
require(x.size == KEYPOINT_COUNT) { "x 必须为 $KEYPOINT_COUNT 项" }
require(y.size == KEYPOINT_COUNT) { "y 必须为 $KEYPOINT_COUNT 项" }
require(z.size == KEYPOINT_COUNT) { "z 必须为 $KEYPOINT_COUNT 项" }
require(confidence.size == KEYPOINT_COUNT) { "confidence 必须为 $KEYPOINT_COUNT 项" }
}
val isMirrored: Boolean get() = (flags and FLAG_MIRRORED) != 0
val isUpperBodyOnly: Boolean get() = (flags and FLAG_UPPER_BODY_ONLY) != 0
val hasLowConfidence: Boolean get() = (flags and FLAG_LOW_CONFIDENCE) != 0
/** 该点是否可用于评分 */
fun isReliable(index: Int): Boolean = confidence[index] >= CONFIDENCE_THRESHOLD
/** 平均置信度,用于运行页质量指示 */
fun averageConfidence(): Float {
var total = 0f
for (c in confidence) total += c
return total / confidence.size
}
/** 已镜像的帧需翻转 x 才能与对端坐标系一致 */
fun xUnmirrored(index: Int): Float = if (isMirrored) 1f - x[index] else x[index]
}
/**
* 校验帧字节流;合法返回 null,否则返回可读原因。
*/
fun validate(bytes: ByteArray): String? {
if (bytes.size != BINARY_SIZE) {
return "帧长度必须为 $BINARY_SIZE 字节,实际 ${bytes.size}"
}
val buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN)
val magic = buffer.getShort(0).toInt() and 0xFFFF
if (magic != MAGIC) {
return "magic 校验失败:期望 0x%04X,实际 0x%04X".format(MAGIC, magic)
}
val version = buffer.get(2).toInt() and 0xFF
if (version != PROTOCOL_VERSION) {
return "不支持的协议版本 $version(本端支持 $PROTOCOL_VERSION)"
}
return null
}
/**
* 编码为 239 字节帧 —— 本端(Android)的主要职责。
*
* @param xs 归一化 x,[0,1]
* @param ys 归一化 y,[0,1]
* @param zs 深度,约 [-1,1],负值朝相机
* @param confidence [0,1]
* @param flags 见 FLAG_* 常量
*/
fun encode(
frameId: Int,
xs: FloatArray,
ys: FloatArray,
zs: FloatArray,
confidence: FloatArray,
flags: Int = 0,
): ByteArray {
require(xs.size == KEYPOINT_COUNT) { "xs 必须为 $KEYPOINT_COUNT 项" }
require(ys.size == KEYPOINT_COUNT) { "ys 必须为 $KEYPOINT_COUNT 项" }
require(zs.size == KEYPOINT_COUNT) { "zs 必须为 $KEYPOINT_COUNT 项" }
require(confidence.size == KEYPOINT_COUNT) { "confidence 必须为 $KEYPOINT_COUNT 项" }
val buffer = ByteBuffer.allocate(BINARY_SIZE).order(ByteOrder.LITTLE_ENDIAN)
buffer.putShort(0, MAGIC.toShort())
buffer.put(2, PROTOCOL_VERSION.toByte())
buffer.put(3, (flags and 0xFF).toByte())
buffer.putInt(4, frameId)
for (i in 0 until KEYPOINT_COUNT) {
val offset = HEADER_SIZE + i * POINT_BYTES
buffer.putShort(offset, toFixed16(xs[i]))
buffer.putShort(offset + 2, toFixed16(ys[i]))
buffer.putShort(offset + 4, toFixed16(zs[i]))
buffer.put(offset + 6, toConfidenceByte(confidence[i]))
}
return buffer.array()
}
/** 解码 239 字节帧;非法输入抛 IllegalArgumentException */
fun decode(bytes: ByteArray): PoseFrame {
validate(bytes)?.let { throw IllegalArgumentException(it) }
val buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN)
val frameId = buffer.getInt(4)
val flags = buffer.get(3).toInt() and 0xFF
val xs = FloatArray(KEYPOINT_COUNT)
val ys = FloatArray(KEYPOINT_COUNT)
val zs = FloatArray(KEYPOINT_COUNT)
val confidence = FloatArray(KEYPOINT_COUNT)
for (i in 0 until KEYPOINT_COUNT) {
val offset = HEADER_SIZE + i * POINT_BYTES
xs[i] = buffer.getShort(offset) / SCALE.toFloat()
ys[i] = buffer.getShort(offset + 2) / SCALE.toFloat()
zs[i] = buffer.getShort(offset + 4) / SCALE.toFloat()
confidence[i] = (buffer.get(offset + 6).toInt() and 0xFF) / 255f
}
return PoseFrame(frameId, flags, xs, ys, zs, confidence)
}
/** 依据置信度数组计算是否应置 FLAG_LOW_CONFIDENCE */
fun computeLowConfidence(confidence: FloatArray): Boolean =
confidence.any { it < CONFIDENCE_THRESHOLD }
fun withLowConfidenceFlag(flags: Int, confidence: FloatArray): Int =
if (computeLowConfidence(confidence)) flags or FLAG_LOW_CONFIDENCE else flags and FLAG_LOW_CONFIDENCE.inv()
private fun toFixed16(value: Float): Short {
val scaled = Math.round(value * SCALE)
return scaled.coerceIn(-32768L, 32767L).toShort()
}
private fun toConfidenceByte(value: Float): Byte =
Math.round(value.coerceIn(0f, 1f) * 255f).coerceIn(0, 255).toByte()
}
@@ -0,0 +1,115 @@
package com.opendance.studio.model
/**
* MediaPipe 33 点骨骼拓扑 —— 契约 contracts/skeleton-topology.md §1(冻结)
*
* 顺序一旦冻结不得插入或重排;新增点只能追加新协议版本。
* 本文件同时提供绘制骨架叠加层所需的连线表。
*/
object SkeletonTopology {
const val KEYPOINT_COUNT = 33
/** 33 点名称,索引即 MediaPipe 索引 */
val NAMES: List<String> = listOf(
"nose", // 0
"left_eye_inner", // 1
"left_eye", // 2
"left_eye_outer", // 3
"right_eye_inner", // 4
"right_eye", // 5
"right_eye_outer", // 6
"left_ear", // 7
"right_ear", // 8
"mouth_left", // 9
"mouth_right", // 10
"left_shoulder", // 11
"right_shoulder", // 12
"left_elbow", // 13
"right_elbow", // 14
"left_wrist", // 15
"right_wrist", // 16
"left_pinky", // 17
"right_pinky", // 18
"left_index", // 19
"right_index", // 20
"left_thumb", // 21
"right_thumb", // 22
"left_hip", // 23
"right_hip", // 24
"left_knee", // 25
"right_knee", // 26
"left_ankle", // 27
"right_ankle", // 28
"left_heel", // 29
"right_heel", // 30
"left_foot_index", // 31
"right_foot_index", // 32
)
// 常用索引常量
const val NOSE = 0
const val LEFT_SHOULDER = 11
const val RIGHT_SHOULDER = 12
const val LEFT_ELBOW = 13
const val RIGHT_ELBOW = 14
const val LEFT_WRIST = 15
const val RIGHT_WRIST = 16
const val LEFT_HIP = 23
const val RIGHT_HIP = 24
const val LEFT_KNEE = 25
const val RIGHT_KNEE = 26
const val LEFT_ANKLE = 27
const val RIGHT_ANKLE = 28
/**
* 父节点表(索引 0..34)—— 契约 §3。
* 33 = neck(双肩中点),34 = pelvis(双髋中点),-1 表示世界根。
* 仅用于骨骼角特征与调试,与坐标传输无关。
*/
val PARENT_TABLE: IntArray = intArrayOf(
33, // 0 nose
0, 0, 0, // 1-3 左眼内/眼/眼外
0, 0, 0, // 4-6 右眼内/眼/眼外
3, // 7 left_ear
6, // 8 right_ear
0, 0, // 9-10 嘴角
33, 33, // 11-12 双肩
11, 12, // 13-14 双肘
13, 14, // 15-16 双腕
15, 16, // 17-18 小指
15, 16, // 19-20 食指
15, 16, // 21-22 拇指
34, 34, // 23-24 双髋
23, 24, // 25-26 双膝
25, 26, // 27-28 双踝
27, 28, // 29-30 双足跟
29, 30, // 31-32 双足尖
34, // 33 neck
-1, // 34 pelvis
)
/** 运行页骨架叠加层连线(起点, 终点) */
val CONNECTIONS: List<Pair<Int, Int>> = listOf(
// 躯干
LEFT_SHOULDER to RIGHT_SHOULDER,
LEFT_SHOULDER to LEFT_HIP,
RIGHT_SHOULDER to RIGHT_HIP,
LEFT_HIP to RIGHT_HIP,
// 头
LEFT_SHOULDER to NOSE,
RIGHT_SHOULDER to NOSE,
// 左臂
LEFT_SHOULDER to LEFT_ELBOW,
LEFT_ELBOW to LEFT_WRIST,
// 右臂
RIGHT_SHOULDER to RIGHT_ELBOW,
RIGHT_ELBOW to RIGHT_WRIST,
// 左腿
LEFT_HIP to LEFT_KNEE,
LEFT_KNEE to LEFT_ANKLE,
// 右腿
RIGHT_HIP to RIGHT_KNEE,
RIGHT_KNEE to RIGHT_ANKLE,
)
}