feat(android): T-AND-004 239 字节帧编解码 + 跨端金标准向量测试
This commit is contained in:
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
package com.opendance.studio
|
||||||
|
|
||||||
|
import com.opendance.studio.model.KeypointFrame
|
||||||
|
import com.opendance.studio.model.SkeletonTopology
|
||||||
|
import org.junit.Assert.assertArrayEquals
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Assert.fail
|
||||||
|
import org.junit.Test
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.max
|
||||||
|
|
||||||
|
/**
|
||||||
|
* T-AND-004:239 字节帧序列化 —— 契约 contracts/websocket-keypoints.md §2
|
||||||
|
*
|
||||||
|
* 关键用例是 [goldenVectorMatchesWebImplementation]:
|
||||||
|
* 金标准字节向量由 **opendance-web 已通过验收的 TypeScript 实现**
|
||||||
|
* (web-server/dist/preprocess/keypoint-codec.js 的 encodeBinaryFrame)生成,
|
||||||
|
* 本端必须产出逐位相同的字节。这是三端契约一致性的硬证据。
|
||||||
|
*/
|
||||||
|
class KeypointFrameTest {
|
||||||
|
|
||||||
|
/** 与金标准向量生成脚本使用完全相同的输入 */
|
||||||
|
private fun sampleXs() = FloatArray(33) { i -> (i % 33) / 32f }
|
||||||
|
private fun sampleYs() = FloatArray(33) { i -> 1f - (i % 33) / 32f }
|
||||||
|
private fun sampleZs() = FloatArray(33) { i -> ((i % 7) - 3) / 10f }
|
||||||
|
private fun sampleConfidence() = FloatArray(33) { i -> if (i % 2 == 0) 1.0f else 0.5f }
|
||||||
|
|
||||||
|
private val goldenHex =
|
||||||
|
"444f01052a0000000000ff7f9ad9ff0004ff7b67e6800008ff7733f3ff000cff730000800010ff" +
|
||||||
|
"6fcd0cff0014ff6b9919800018ff676626ff001cff639ad9800020ff5f67e6ff0024ff5b33f380" +
|
||||||
|
"0028ff570000ff002cff53cd0c800030ff4f9919ff0034ff4b6626800038ff479ad9ff003cff43" +
|
||||||
|
"67e6800040004033f3ffff43003c000080ff470038cd0cffff4b0034991980ff4f00306626ffff" +
|
||||||
|
"53002c9ad980ff57002867e6ffff5b002433f380ff5f00200000ffff63001ccd0c80ff67001899" +
|
||||||
|
"19ffff6b0014662680ff6f00109ad9ffff73000c67e680ff77000833f3ffff7b0004000080ff7f" +
|
||||||
|
"0000cd0cff"
|
||||||
|
|
||||||
|
private fun hexToBytes(hex: String): ByteArray =
|
||||||
|
ByteArray(hex.length / 2) { i -> hex.substring(i * 2, i * 2 + 2).toInt(16).toByte() }
|
||||||
|
|
||||||
|
private fun bytesToHex(bytes: ByteArray): String =
|
||||||
|
bytes.joinToString("") { "%02x".format(it) }
|
||||||
|
|
||||||
|
// ---------------- 契约常量 ----------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun constantsMatchContract() {
|
||||||
|
assertEquals("帧长度", 239, KeypointFrame.BINARY_SIZE)
|
||||||
|
assertEquals("关键点数量", 33, KeypointFrame.KEYPOINT_COUNT)
|
||||||
|
assertEquals("帧头 8 + 33×7 = 239", 239, KeypointFrame.HEADER_SIZE + KeypointFrame.KEYPOINT_COUNT * KeypointFrame.POINT_BYTES)
|
||||||
|
assertEquals("magic 'OD'", 0x4F44, KeypointFrame.MAGIC)
|
||||||
|
assertEquals("协议版本", 1, KeypointFrame.PROTOCOL_VERSION)
|
||||||
|
assertEquals("归一化系数", 32767.0, KeypointFrame.SCALE, 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 跨实现金标准 ----------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun goldenVectorMatchesWebImplementation() {
|
||||||
|
val produced = KeypointFrame.encode(
|
||||||
|
frameId = 42,
|
||||||
|
xs = sampleXs(),
|
||||||
|
ys = sampleYs(),
|
||||||
|
zs = sampleZs(),
|
||||||
|
confidence = sampleConfidence(),
|
||||||
|
flags = 0b101,
|
||||||
|
)
|
||||||
|
val expected = hexToBytes(goldenHex)
|
||||||
|
|
||||||
|
assertEquals("长度必须为 239", 239, produced.size)
|
||||||
|
assertEquals("金标准向量长度", 239, expected.size)
|
||||||
|
if (!produced.contentEquals(expected)) {
|
||||||
|
// 逐字节定位首个差异,便于排查
|
||||||
|
var firstDiff = -1
|
||||||
|
for (i in expected.indices) {
|
||||||
|
if (produced[i] != expected[i]) { firstDiff = i; break }
|
||||||
|
}
|
||||||
|
fail(
|
||||||
|
"与 opendance-web TypeScript 实现产出不一致:首个差异字节 offset=$firstDiff " +
|
||||||
|
"本端=0x%02x 期望=0x%02x".format(
|
||||||
|
produced[firstDiff].toInt() and 0xFF,
|
||||||
|
expected[firstDiff].toInt() and 0xFF,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertArrayEquals(expected, produced)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun goldenVectorHeaderFieldsDecodeCorrectly() {
|
||||||
|
val frame = KeypointFrame.decode(hexToBytes(goldenHex))
|
||||||
|
assertEquals("frame_id", 42, frame.frameId)
|
||||||
|
assertEquals("flags", 0b101, frame.flags)
|
||||||
|
assertTrue("镜像标志(bit0)", frame.isMirrored)
|
||||||
|
assertTrue("上半身模式(bit2)", frame.isUpperBodyOnly)
|
||||||
|
assertFalse("低置信度标志(bit1)未置位", frame.hasLowConfidence)
|
||||||
|
assertEquals(33, frame.x.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 编解码往返 ----------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun encodeProducesCorrectHeader() {
|
||||||
|
val bytes = KeypointFrame.encode(7, sampleXs(), sampleYs(), sampleZs(), sampleConfidence(), 0)
|
||||||
|
assertEquals(239, bytes.size)
|
||||||
|
// 小端序:0x4F44 的低字节 0x44 在前
|
||||||
|
assertEquals(0x44, bytes[0].toInt() and 0xFF)
|
||||||
|
assertEquals(0x4F, bytes[1].toInt() and 0xFF)
|
||||||
|
assertEquals(1, bytes[2].toInt() and 0xFF)
|
||||||
|
assertEquals(0, bytes[3].toInt() and 0xFF)
|
||||||
|
// frame_id 小端序
|
||||||
|
assertEquals(7, bytes[4].toInt() and 0xFF)
|
||||||
|
assertEquals(0, bytes[5].toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun roundTripWithinFixedPointPrecision() {
|
||||||
|
val xs = sampleXs(); val ys = sampleYs(); val zs = sampleZs(); val conf = sampleConfidence()
|
||||||
|
val decoded = KeypointFrame.decode(KeypointFrame.encode(99, xs, ys, zs, conf, 0))
|
||||||
|
|
||||||
|
assertEquals(99, decoded.frameId)
|
||||||
|
|
||||||
|
var maxDelta = 0f
|
||||||
|
for (i in 0 until 33) {
|
||||||
|
maxDelta = max(maxDelta, abs(decoded.x[i] - xs[i]))
|
||||||
|
maxDelta = max(maxDelta, abs(decoded.y[i] - ys[i]))
|
||||||
|
maxDelta = max(maxDelta, abs(decoded.z[i] - zs[i]))
|
||||||
|
}
|
||||||
|
// 定点精度 1/32767 ≈ 3.05e-5,留一倍余量
|
||||||
|
assertTrue("坐标往返误差 $maxDelta 应小于 1e-4", maxDelta < 1e-4f)
|
||||||
|
|
||||||
|
var maxConfDelta = 0f
|
||||||
|
for (i in 0 until 33) {
|
||||||
|
maxConfDelta = max(maxConfDelta, abs(decoded.confidence[i] - conf[i]))
|
||||||
|
}
|
||||||
|
assertTrue("置信度误差应在一个量化步长内", maxConfDelta < 1f / 255f + 1e-6f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 负向用例 ----------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectsWrongLength() {
|
||||||
|
val short = ByteArray(238)
|
||||||
|
val reason = KeypointFrame.validate(short)
|
||||||
|
assertNotNull(reason)
|
||||||
|
assertTrue("错误信息应说明期望长度", reason!!.contains("239"))
|
||||||
|
|
||||||
|
assertNotNull(KeypointFrame.validate(ByteArray(240)))
|
||||||
|
try {
|
||||||
|
KeypointFrame.decode(short)
|
||||||
|
fail("非法长度应抛异常")
|
||||||
|
} catch (expected: IllegalArgumentException) {
|
||||||
|
assertTrue(expected.message!!.contains("239"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectsBadMagic() {
|
||||||
|
val bytes = ByteArray(239)
|
||||||
|
val reason = KeypointFrame.validate(bytes)
|
||||||
|
assertNotNull(reason)
|
||||||
|
assertTrue("错误信息应指出 magic", reason!!.contains("magic"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectsUnsupportedVersion() {
|
||||||
|
val bytes = KeypointFrame.encode(0, sampleXs(), sampleYs(), sampleZs(), sampleConfidence())
|
||||||
|
bytes[2] = 2
|
||||||
|
val reason = KeypointFrame.validate(bytes)
|
||||||
|
assertNotNull(reason)
|
||||||
|
assertTrue("错误信息应指出协议版本", reason!!.contains("版本"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun acceptsValidFrame() {
|
||||||
|
val bytes = KeypointFrame.encode(0, sampleXs(), sampleYs(), sampleZs(), sampleConfidence())
|
||||||
|
assertNull(KeypointFrame.validate(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 边界与辅助 ----------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun extremeValuesDoNotOverflowInt16() {
|
||||||
|
val xs = sampleXs(); val ys = sampleYs(); val zs = sampleZs(); val conf = sampleConfidence()
|
||||||
|
xs[0] = 1f; ys[0] = 1f; zs[0] = -1f; conf[0] = 1f
|
||||||
|
xs[1] = 0f; ys[1] = 0f; zs[1] = 1f; conf[1] = 0f
|
||||||
|
|
||||||
|
val decoded = KeypointFrame.decode(KeypointFrame.encode(0, xs, ys, zs, conf))
|
||||||
|
assertEquals(1f, decoded.x[0], 1e-4f)
|
||||||
|
assertEquals(-1f, decoded.z[0], 1e-4f)
|
||||||
|
assertEquals(1f, decoded.z[1], 1e-4f)
|
||||||
|
assertEquals(0f, decoded.confidence[1], 1e-6f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun outOfRangeInputIsClamped() {
|
||||||
|
val xs = sampleXs(); val ys = sampleYs(); val zs = sampleZs(); val conf = sampleConfidence()
|
||||||
|
xs[0] = 5f // 超范围
|
||||||
|
zs[0] = -5f
|
||||||
|
conf[0] = 2f
|
||||||
|
val decoded = KeypointFrame.decode(KeypointFrame.encode(0, xs, ys, zs, conf))
|
||||||
|
assertEquals("x 应被裁剪到 1.0", 1f, decoded.x[0], 1e-4f)
|
||||||
|
assertEquals("z 应被裁剪到 -1.0", -1f, decoded.z[0], 1e-4f)
|
||||||
|
assertEquals("置信度应被裁剪到 1.0", 1f, decoded.confidence[0], 1e-6f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun lowConfidenceFlagIsComputed() {
|
||||||
|
val high = sampleConfidence().copyOf().also { it.fill(1f) }
|
||||||
|
assertFalse(KeypointFrame.computeLowConfidence(high))
|
||||||
|
|
||||||
|
val low = high.copyOf().also { it[11] = 0.49f }
|
||||||
|
assertTrue(KeypointFrame.computeLowConfidence(low))
|
||||||
|
|
||||||
|
val edge = high.copyOf().also { it[11] = 0.5f }
|
||||||
|
assertFalse("阈值含端点,0.5 视为可信", KeypointFrame.computeLowConfidence(edge))
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
KeypointFrame.FLAG_LOW_CONFIDENCE,
|
||||||
|
KeypointFrame.withLowConfidenceFlag(0, low),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
0,
|
||||||
|
KeypointFrame.withLowConfidenceFlag(KeypointFrame.FLAG_LOW_CONFIDENCE, high),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reliabilityAndAverageConfidence() {
|
||||||
|
val decoded = KeypointFrame.decode(
|
||||||
|
KeypointFrame.encode(0, sampleXs(), sampleYs(), sampleZs(), sampleConfidence()),
|
||||||
|
)
|
||||||
|
assertTrue("conf=1.0 可信", decoded.isReliable(0))
|
||||||
|
assertTrue("conf=0.5 可信(阈值含端点)", decoded.isReliable(1))
|
||||||
|
// 平均置信度 = (17*1.0 + 16*0.5)/33 ≈ 0.7576。
|
||||||
|
// 注意 0.5 经 u8 量化后为 128/255 = 0.50196,容差需覆盖该量化误差。
|
||||||
|
assertEquals(25f / 33f, decoded.averageConfidence(), 1e-3f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mirroredFrameXCanBeUnmirrored() {
|
||||||
|
val xs = sampleXs()
|
||||||
|
val bytes = KeypointFrame.encode(0, xs, sampleYs(), sampleZs(), sampleConfidence(), KeypointFrame.FLAG_MIRRORED)
|
||||||
|
val frame = KeypointFrame.decode(bytes)
|
||||||
|
assertTrue(frame.isMirrored)
|
||||||
|
assertEquals(1f - xs[5], frame.xUnmirrored(5), 1e-4f)
|
||||||
|
|
||||||
|
val plain = KeypointFrame.decode(
|
||||||
|
KeypointFrame.encode(0, xs, sampleYs(), sampleZs(), sampleConfidence(), 0),
|
||||||
|
)
|
||||||
|
assertEquals(xs[5], plain.xUnmirrored(5), 1e-4f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectsWrongArraySize() {
|
||||||
|
try {
|
||||||
|
KeypointFrame.encode(0, FloatArray(32), sampleYs(), sampleZs(), sampleConfidence())
|
||||||
|
fail("点数不足应抛异常")
|
||||||
|
} catch (expected: IllegalArgumentException) {
|
||||||
|
assertTrue(expected.message!!.contains("33"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 拓扑一致性 ----------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun topologyMatchesContract() {
|
||||||
|
assertEquals("33 点名称", 33, SkeletonTopology.NAMES.size)
|
||||||
|
assertEquals("父节点表含 33 点 + 2 个虚拟关节", 35, SkeletonTopology.PARENT_TABLE.size)
|
||||||
|
assertEquals("neck 的父节点为 pelvis", 34, SkeletonTopology.PARENT_TABLE[33])
|
||||||
|
assertEquals("pelvis 为世界根", -1, SkeletonTopology.PARENT_TABLE[34])
|
||||||
|
assertEquals("nose 的父节点为 neck", 33, SkeletonTopology.PARENT_TABLE[0])
|
||||||
|
assertEquals("left_shoulder 的父节点为 neck", 33, SkeletonTopology.PARENT_TABLE[11])
|
||||||
|
assertEquals("left_hip 的父节点为 pelvis", 34, SkeletonTopology.PARENT_TABLE[23])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun topologyParentChainTerminates() {
|
||||||
|
for (start in 0..34) {
|
||||||
|
var cursor = start
|
||||||
|
var hops = 0
|
||||||
|
while (cursor != -1) {
|
||||||
|
cursor = SkeletonTopology.PARENT_TABLE[cursor]
|
||||||
|
hops++
|
||||||
|
assertTrue("父链在第 $start 点上成环", hops < 64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun connectionsReferenceValidIndices() {
|
||||||
|
assertTrue("至少 10 条骨架连线", SkeletonTopology.CONNECTIONS.size >= 10)
|
||||||
|
for ((a, b) in SkeletonTopology.CONNECTIONS) {
|
||||||
|
assertTrue("连线起点索引合法:$a", a in 0 until 33)
|
||||||
|
assertTrue("连线终点索引合法:$b", b in 0 until 33)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun pointNamesAreUniqueAndNonEmpty() {
|
||||||
|
assertEquals("名称唯一", 33, SkeletonTopology.NAMES.toSet().size)
|
||||||
|
assertTrue("名称非空", SkeletonTopology.NAMES.all { it.isNotBlank() })
|
||||||
|
assertEquals("left_shoulder 索引", "left_shoulder", SkeletonTopology.NAMES[SkeletonTopology.LEFT_SHOULDER])
|
||||||
|
assertEquals("right_ankle 索引", "right_ankle", SkeletonTopology.NAMES[SkeletonTopology.RIGHT_ANKLE])
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user