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,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])
}
}