diff --git a/app/src/main/java/com/opendance/studio/model/KeypointFrame.kt b/app/src/main/java/com/opendance/studio/model/KeypointFrame.kt new file mode 100644 index 0000000..537e8bf --- /dev/null +++ b/app/src/main/java/com/opendance/studio/model/KeypointFrame.kt @@ -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() +} diff --git a/app/src/main/java/com/opendance/studio/model/SkeletonTopology.kt b/app/src/main/java/com/opendance/studio/model/SkeletonTopology.kt new file mode 100644 index 0000000..5a38446 --- /dev/null +++ b/app/src/main/java/com/opendance/studio/model/SkeletonTopology.kt @@ -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 = 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> = 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, + ) +} diff --git a/app/src/test/java/com/opendance/studio/KeypointFrameTest.kt b/app/src/test/java/com/opendance/studio/KeypointFrameTest.kt new file mode 100644 index 0000000..227b063 --- /dev/null +++ b/app/src/test/java/com/opendance/studio/KeypointFrameTest.kt @@ -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]) + } +}