This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

fix: get metadata from json

+244 -13
+3 -3
README.md
··· 8 8 Import the Compose library 9 9 10 10 ```kotlin 11 - implementation("com.performancecoachlab.posedetection:posedetection-compose:4.6.0") 11 + implementation("com.performancecoachlab.posedetection:posedetection-compose:4.7.1") 12 12 ``` 13 13 14 14 Add camera use to your android manifest ··· 102 102 For android you need to add a .tflite model file to your assets folder, then set androidModelPath to the name of the model file, including the .tflite extension. 103 103 For iOS you need to add a .mlmodel model file to your Xcode project, then set iosModelPath to the name of the model file without the .mlmodel extension. 104 104 ```kotlin 105 - val generalModel = initialiseObjectModel( 105 + val generalModel = ObjectModelProvider.get( 106 106 ModelPath( 107 - "lite-model_efficientdet_lite2_detection_metadata_1.tflite", 107 + "yolov10n_float16.tflite", 108 108 "YOLOv3FP16" 109 109 ) 110 110 )
+1 -1
posedetection/build.gradle.kts
··· 6 6 7 7 mavenPublishing { 8 8 publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) 9 - coordinates("com.performancecoachlab.posedetection", "posedetection-compose", "4.7.0") 9 + coordinates("com.performancecoachlab.posedetection", "posedetection-compose", "4.7.1") 10 10 11 11 pom { 12 12 name.set("Pose Detection")
+240 -9
posedetection/src/androidMain/kotlin/com/performancecoachlab/posedetection/custom/CustomObjectModel.android.kt
··· 3 3 import androidx.compose.runtime.Composable 4 4 import androidx.compose.ui.platform.LocalContext 5 5 import co.touchlab.kermit.Logger 6 + import org.json.JSONObject 6 7 import org.tensorflow.lite.Interpreter 7 8 import org.tensorflow.lite.gpu.CompatibilityList 8 9 import org.tensorflow.lite.gpu.GpuDelegate 9 10 import org.tensorflow.lite.gpu.GpuDelegateFactory 10 11 import org.tensorflow.lite.support.common.FileUtil 11 12 import org.tensorflow.lite.support.metadata.MetadataExtractor 13 + import java.io.ByteArrayInputStream 12 14 import java.nio.MappedByteBuffer 15 + import java.nio.charset.StandardCharsets 16 + import java.util.zip.GZIPInputStream 17 + import java.util.zip.ZipInputStream 18 + import java.util.zip.Inflater 19 + import java.util.zip.InflaterInputStream 13 20 14 21 @Composable 15 22 actual fun initialiseObjectModel(modelPath: ModelPath): ObjectModel { ··· 64 71 fun labels(model: MappedByteBuffer): List<String> { 65 72 return runCatching { 66 73 val extractor = MetadataExtractor(model) 74 + val files = extractor.associatedFileNames.orEmpty() 75 + if (files.isEmpty()) return@runCatching emptyList() 67 76 68 - // Ultralytics exports often store this as a .txt "metadata" file containing Python-dict text. 69 - val metaFile = extractor.associatedFileNames.orEmpty() 70 - .firstOrNull { it.endsWith(".txt", ignoreCase = true) || it.endsWith(".json", ignoreCase = true) } 71 - ?: return@runCatching emptyList() 77 + // Try every associated file. Pick the first that decodes to JSON with a `names` object. 78 + for (name in files) { 79 + val rawBytes = runCatching { extractor.getAssociatedFile(name).readBytes() }.getOrNull() ?: continue 72 80 73 - val text = extractor.getAssociatedFile(metaFile) 74 - .bufferedReader() 75 - .use { it.readText() } 81 + val decoded = rawBytes.decodeUtf8PossiblyCompressed() 82 + val trimmed = decoded.trimStart() 83 + if (!trimmed.startsWith("{")) continue 76 84 77 - parseUltralyticsNames(text) 85 + val labels = parseUltralyticsNamesJson(decoded) 86 + if (labels.isNotEmpty()) { 87 + Logger.d { "Loaded ${labels.size} labels from associated file '$name'" } 88 + return@runCatching labels 89 + } 90 + } 91 + emptyList() 78 92 }.onFailure { t -> 79 93 Logger.w(t) { "Failed to load labels from TFLite metadata" } 80 94 }.getOrDefault(emptyList()) 81 95 } 82 96 97 + private fun ByteArray.decodeUtf8PossiblyCompressed(): String { 98 + if (isEmpty()) return "" 99 + 100 + fun firstBytesHex(n: Int = 8): String = 101 + take(minOf(size, n)).joinToString(" ") { b -> "%02x".format(b) } 102 + 103 + // 0) Scan for embedded magic headers / common compressed stream signatures. 104 + val zipOffset = indexOfSubsequence(byteArrayOf('P'.code.toByte(), 'K'.code.toByte(), 0x03, 0x04)) 105 + val gzipOffset = indexOfSubsequence(byteArrayOf(0x1F.toByte(), 0x8B.toByte())) 106 + 107 + // Common zlib headers (CMF/FLG). Most common are 0x78 0x9C (default), 0x78 0xDA (best), 0x78 0x01 (no compression). 108 + val zlibOffsets = listOf( 109 + indexOfSubsequence(byteArrayOf(0x78.toByte(), 0x9C.toByte())), 110 + indexOfSubsequence(byteArrayOf(0x78.toByte(), 0xDA.toByte())), 111 + indexOfSubsequence(byteArrayOf(0x78.toByte(), 0x01.toByte())), 112 + ).filter { it >= 0 }.distinct().sorted() 113 + 114 + if (zipOffset > 0) { 115 + runCatching { 116 + val sliced = copyOfRange(zipOffset, size) 117 + val text = sliced.decodeFromZipIfPossible(preferEntryName = "metadata.json") 118 + if (text.isNotBlank() && text.trimStart().startsWith("{")) return text 119 + } 120 + } 121 + 122 + if (gzipOffset > 0) { 123 + runCatching { 124 + val sliced = copyOfRange(gzipOffset, size) 125 + val text = sliced.decodeFromGzipOrTarGz() 126 + if (text.isNotBlank() && text.trimStart().startsWith("{")) return text 127 + } 128 + } 129 + 130 + for (off in zlibOffsets) { 131 + if (off <= 0) continue 132 + runCatching { 133 + val sliced = copyOfRange(off, size) 134 + val text = sliced.decodeFromZlib() 135 + if (text.isNotBlank() && text.trimStart().startsWith("{")) return text 136 + } 137 + } 138 + 139 + runCatching { 140 + val text = decodeFromGzipOrTarGz() 141 + if (text.isNotBlank() && text.trimStart().startsWith("{")) return text 142 + } 143 + 144 + runCatching { 145 + val text = decodeFromZipIfPossible(preferEntryName = "metadata.json") 146 + if (text.isNotBlank() && text.trimStart().startsWith("{")) return text 147 + } 148 + 149 + runCatching { 150 + val text = decodeFromZlib() 151 + if (text.isNotBlank() && text.trimStart().startsWith("{")) return text 152 + } 153 + 154 + runCatching { 155 + val text = decodeFromDeflateRaw() 156 + if (text.isNotBlank() && text.trimStart().startsWith("{")) return text 157 + } 158 + 159 + return toString(StandardCharsets.UTF_8) 160 + } 161 + 162 + private fun ByteArray.decodeFromDeflateRaw(): String { 163 + return runCatching { 164 + val inflater = Inflater(true) // nowrap=true => raw DEFLATE 165 + InflaterInputStream(ByteArrayInputStream(this), inflater) 166 + .bufferedReader(StandardCharsets.UTF_8) 167 + .use { it.readText() } 168 + }.getOrDefault("") 169 + } 170 + 171 + private fun ByteArray.decodeFromZlib(): String { 172 + return runCatching { 173 + InflaterInputStream(ByteArrayInputStream(this)) 174 + .bufferedReader(StandardCharsets.UTF_8) 175 + .use { it.readText() } 176 + }.getOrDefault("") 177 + } 178 + 179 + private fun ByteArray.decodeFromGzipOrTarGz(): String { 180 + // First try: plain gzipped UTF-8 directly 181 + runCatching { 182 + val text = GZIPInputStream(ByteArrayInputStream(this)) 183 + .bufferedReader(StandardCharsets.UTF_8) 184 + .use { it.readText() } 185 + if (text.isNotBlank()) return text 186 + } 187 + 188 + // Second try: gzipped TAR that contains metadata.json (or first file) 189 + return runCatching { 190 + GZIPInputStream(ByteArrayInputStream(this)).use { gz -> 191 + extractFirstTarFileUtf8(gz) 192 + } 193 + }.getOrElse { 194 + "" 195 + } 196 + } 197 + 198 + private fun extractFirstTarFileUtf8(input: java.io.InputStream): String { 199 + // Minimal TAR reader: TAR headers are 512 bytes. 200 + // We only need to extract the first regular file (or metadata.json if present) 201 + // and decode it as UTF-8. 202 + 203 + fun readExactly(buf: ByteArray): Boolean { 204 + var off = 0 205 + while (off < buf.size) { 206 + val r = input.read(buf, off, buf.size - off) 207 + if (r <= 0) return false 208 + off += r 209 + } 210 + return true 211 + } 212 + 213 + val header = ByteArray(512) 214 + var firstText: String? = null 215 + 216 + while (true) { 217 + if (!readExactly(header)) break 218 + // End of archive: two consecutive 512-byte blocks of zero 219 + if (header.all { it == 0.toByte() }) break 220 + 221 + val name = header.copyOfRange(0, 100).toString(StandardCharsets.US_ASCII).trimEnd { it == '\u0000' } 222 + val sizeOctal = header.copyOfRange(124, 136).toString(StandardCharsets.US_ASCII).trim().trimEnd { it == '\u0000' } 223 + val typeFlag = header[156] 224 + 225 + val fileSize = sizeOctal.toLongOrNull(8) ?: 0L 226 + val isRegularFile = typeFlag == 0.toByte() || typeFlag == '0'.code.toByte() 227 + 228 + val fileData = ByteArray(fileSize.toInt()) 229 + if (fileSize > 0 && !readExactly(fileData)) break 230 + 231 + // TAR pads file data to 512 byte blocks. 232 + val pad = ((512 - (fileSize % 512)) % 512).toInt() 233 + if (pad > 0) { 234 + val skip = ByteArray(pad) 235 + if (!readExactly(skip)) break 236 + } 237 + 238 + if (isRegularFile) { 239 + val text = fileData.toString(StandardCharsets.UTF_8) 240 + if (name.equals("metadata.json", ignoreCase = true)) return text 241 + if (firstText == null && text.isNotBlank()) firstText = text 242 + } 243 + } 244 + 245 + return firstText.orEmpty() 246 + } 247 + 248 + private fun ByteArray.indexOfSubsequence(needle: ByteArray): Int { 249 + if (needle.isEmpty() || size < needle.size) return -1 250 + outer@ for (i in 0..(size - needle.size)) { 251 + for (j in needle.indices) { 252 + if (this[i + j] != needle[j]) continue@outer 253 + } 254 + return i 255 + } 256 + return -1 257 + } 258 + 259 + private fun parseUltralyticsNamesJson(text: String): List<String> { 260 + return runCatching { 261 + val root = JSONObject(text) 262 + val namesObj = root.optJSONObject("names") ?: return@runCatching emptyList() 263 + 264 + val keys = namesObj.keys().asSequence() 265 + .mapNotNull { k -> k.toIntOrNull()?.let { idx -> idx to k } } 266 + .sortedBy { it.first } 267 + .toList() 268 + 269 + if (keys.isEmpty()) return@runCatching emptyList() 270 + 271 + val maxIdx = keys.maxOf { it.first } 272 + val out = MutableList(maxIdx + 1) { "" } 273 + 274 + for ((idx, key) in keys) { 275 + val label = namesObj.optString(key, "").trim() 276 + if (label.isNotBlank() && idx in out.indices) { 277 + out[idx] = label 278 + } 279 + } 280 + 281 + out.filter { it.isNotBlank() } 282 + }.getOrDefault(emptyList()) 283 + } 284 + 83 285 private fun parseUltralyticsNames(text: String): List<String> { 84 286 // Grab the `names: { ... }` section (non-greedy) to avoid matching other maps. 85 287 val namesBlock = Regex("""['"]names['"]\s*:\s*\{([\s\S]*?)\}""") ··· 167 369 fun getDetector(): AndroidDetector? { 168 370 return detector 169 371 } 170 - } 372 + } 373 + 374 + private fun ByteArray.decodeFromZipIfPossible(preferEntryName: String): String { 375 + return runCatching { 376 + ZipInputStream(ByteArrayInputStream(this)).use { zis -> 377 + var firstNonEmpty: String? = null 378 + val entries = mutableListOf<String>() 379 + 380 + while (true) { 381 + val entry = zis.nextEntry ?: break 382 + entries += entry.name 383 + if (entry.isDirectory) continue 384 + 385 + val entryBytes = zis.readBytes() 386 + val text = entryBytes.toString(StandardCharsets.UTF_8) 387 + 388 + if (entry.name.equals(preferEntryName, ignoreCase = true)) { 389 + Logger.d { "TFLite metadata zip entry=${entry.name} size=${entryBytes.size} entries=$entries" } 390 + return@use text 391 + } 392 + 393 + if (firstNonEmpty == null && text.isNotBlank()) { 394 + firstNonEmpty = text 395 + } 396 + } 397 + 398 + firstNonEmpty.orEmpty() 399 + } 400 + }.getOrDefault("") 401 + }