Files
opendance-android/app/src/main/java/com/opendance/studio/model/KeypointFrame.kt
T

173 lines
6.6 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}