Commit 08689e77ae8d326093618b44a58f82e969ba2a82
1 parent
727ff70e
feat(lifelog/diarize): 凝聚式合并收尾,治声纹过度切分(同人被拆成多簇)
贪心单遍聚类会把同一人切成多簇:早期噪声段一旦另起簇,后续同人段按「最近质心」 各自归簇,再不会并回(实测主说话人被拆成 说话人11+12+29,质心却差 0.65+)。 - 新增 SpeakerClusterer.mergeOverSplit:重聚类后做 agglomerative 合并——反复把质心 余弦 ≥ mergeThreshold 的两簇并掉,质心用两簇全部成员段重算(比存的封顶滑动均值 更准,能暴露被掩盖的过切:实测 说话人11↔12 成员重算余弦 0.749)。 约束:不并两个人工命名簇;一人工一自动并入人工保留名字;都自动并入成员多的。 合并后刷新被并段的 speakerId 与相似度。 - reclusterAll / clearAllAndRecluster 收尾调用合并;mergeThreshold 默认 0.55,设置页可调 - 真机实测:32→29 簇,主说话人统一为单簇(580 段),残留仅 4 对 0.50–0.52 小簇待人工 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Showing
3 changed files
with
92 additions
and
4 deletions
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt
| @@ -864,6 +864,7 @@ private fun SpeakersTab( | @@ -864,6 +864,7 @@ private fun SpeakersTab( | ||
| 864 | repo, | 864 | repo, |
| 865 | matchThreshold = SpeakerClusterer.thresholdFromPrefs(ctx), | 865 | matchThreshold = SpeakerClusterer.thresholdFromPrefs(ctx), |
| 866 | minRecognizeMs = SpeakerClusterer.minRecognizeMsFromPrefs(ctx), | 866 | minRecognizeMs = SpeakerClusterer.minRecognizeMsFromPrefs(ctx), |
| 867 | + mergeThreshold = SpeakerClusterer.mergeThresholdFromPrefs(ctx), | ||
| 867 | ) | 868 | ) |
| 868 | } | 869 | } |
| 869 | reclustering = false | 870 | reclustering = false |
| @@ -975,6 +976,7 @@ private fun SpeakersTab( | @@ -975,6 +976,7 @@ private fun SpeakersTab( | ||
| 975 | repo, | 976 | repo, |
| 976 | matchThreshold = SpeakerClusterer.thresholdFromPrefs(ctx), | 977 | matchThreshold = SpeakerClusterer.thresholdFromPrefs(ctx), |
| 977 | minRecognizeMs = SpeakerClusterer.minRecognizeMsFromPrefs(ctx), | 978 | minRecognizeMs = SpeakerClusterer.minRecognizeMsFromPrefs(ctx), |
| 979 | + mergeThreshold = SpeakerClusterer.mergeThresholdFromPrefs(ctx), | ||
| 978 | ) | 980 | ) |
| 979 | } | 981 | } |
| 980 | reclustering = false | 982 | reclustering = false |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/SettingsScreen.kt
| @@ -51,6 +51,7 @@ fun SettingsScreen(activity: Context) { | @@ -51,6 +51,7 @@ fun SettingsScreen(activity: Context) { | ||
| 51 | var minSpeech by remember { mutableStateOf(prefs.getInt(SummaryWorker.KEY_MIN_SPEECH_SEC, SummaryWorker.DEFAULT_MIN_SPEECH_SEC)) } | 51 | var minSpeech by remember { mutableStateOf(prefs.getInt(SummaryWorker.KEY_MIN_SPEECH_SEC, SummaryWorker.DEFAULT_MIN_SPEECH_SEC)) } |
| 52 | var backendId by remember { mutableStateOf(prefs.getString(SummaryWorker.KEY_BACKEND, ChatConfig.ID_2B) ?: ChatConfig.ID_2B) } | 52 | var backendId by remember { mutableStateOf(prefs.getString(SummaryWorker.KEY_BACKEND, ChatConfig.ID_2B) ?: ChatConfig.ID_2B) } |
| 53 | var threshold by remember { mutableStateOf(SpeakerClusterer.thresholdFromPrefs(activity)) } | 53 | var threshold by remember { mutableStateOf(SpeakerClusterer.thresholdFromPrefs(activity)) } |
| 54 | + var mergeThreshold by remember { mutableStateOf(SpeakerClusterer.mergeThresholdFromPrefs(activity)) } | ||
| 54 | var minRecognizeSec by remember { | 55 | var minRecognizeSec by remember { |
| 55 | mutableStateOf(prefs.getInt(SpeakerClusterer.KEY_MIN_RECOGNIZE_SEC, (SpeakerClusterer.MIN_RECOGNIZE_MS / 1000).toInt())) | 56 | mutableStateOf(prefs.getInt(SpeakerClusterer.KEY_MIN_RECOGNIZE_SEC, (SpeakerClusterer.MIN_RECOGNIZE_MS / 1000).toInt())) |
| 56 | } | 57 | } |
| @@ -98,11 +99,15 @@ fun SettingsScreen(activity: Context) { | @@ -98,11 +99,15 @@ fun SettingsScreen(activity: Context) { | ||
| 98 | listOf(0.35f, 0.40f, 0.45f, 0.50f, 0.55f, 0.60f, 0.65f).map { "%.2f".format(it) to it }) { | 99 | listOf(0.35f, 0.40f, 0.45f, 0.50f, 0.55f, 0.60f, 0.65f).map { "%.2f".format(it) to it }) { |
| 99 | threshold = it; prefs.edit().putFloat(SpeakerClusterer.KEY_THRESHOLD, it).apply() | 100 | threshold = it; prefs.edit().putFloat(SpeakerClusterer.KEY_THRESHOLD, it).apply() |
| 100 | } | 101 | } |
| 102 | + DropdownSetting("合并阈值(越小越爱并同人)", "%.2f".format(mergeThreshold), | ||
| 103 | + listOf(0.45f, 0.50f, 0.55f, 0.60f, 0.65f).map { "%.2f".format(it) to it }) { | ||
| 104 | + mergeThreshold = it; prefs.edit().putFloat(SpeakerClusterer.KEY_MERGE_THRESHOLD, it).apply() | ||
| 105 | + } | ||
| 101 | DropdownSetting("最短识别时长(短于此不认声纹)", "$minRecognizeSec 秒", | 106 | DropdownSetting("最短识别时长(短于此不认声纹)", "$minRecognizeSec 秒", |
| 102 | listOf(1, 2, 3, 5, 8).map { "$it 秒" to it }) { | 107 | listOf(1, 2, 3, 5, 8).map { "$it 秒" to it }) { |
| 103 | minRecognizeSec = it; prefs.edit().putInt(SpeakerClusterer.KEY_MIN_RECOGNIZE_SEC, it).apply() | 108 | minRecognizeSec = it; prefs.edit().putInt(SpeakerClusterer.KEY_MIN_RECOGNIZE_SEC, it).apply() |
| 104 | } | 109 | } |
| 105 | - Text("改完到「声纹」tab 点「重新聚类」对历史段生效;新录音自动用新值。", | 110 | + Text("重聚类会先贪心分簇、再把质心很近的簇按「合并阈值」并回(治同人被拆成多个)。改完到「声纹」tab 点「重新聚类」生效。", |
| 106 | style = MaterialTheme.typography.labelSmall) | 111 | style = MaterialTheme.typography.labelSmall) |
| 107 | 112 | ||
| 108 | HorizontalDivider() | 113 | HorizontalDivider() |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/asr/SpeakerClusterer.kt
| @@ -114,11 +114,21 @@ class SpeakerClusterer( | @@ -114,11 +114,21 @@ class SpeakerClusterer( | ||
| 114 | const val PREFS = "lifelog_prefs" | 114 | const val PREFS = "lifelog_prefs" |
| 115 | const val KEY_THRESHOLD = "speaker_threshold" | 115 | const val KEY_THRESHOLD = "speaker_threshold" |
| 116 | const val KEY_MIN_RECOGNIZE_SEC = "speaker_min_recognize_sec" | 116 | const val KEY_MIN_RECOGNIZE_SEC = "speaker_min_recognize_sec" |
| 117 | + const val KEY_MERGE_THRESHOLD = "speaker_merge_threshold" | ||
| 117 | 118 | ||
| 118 | fun thresholdFromPrefs(ctx: android.content.Context): Float = | 119 | fun thresholdFromPrefs(ctx: android.content.Context): Float = |
| 119 | ctx.getSharedPreferences(PREFS, android.content.Context.MODE_PRIVATE) | 120 | ctx.getSharedPreferences(PREFS, android.content.Context.MODE_PRIVATE) |
| 120 | .getFloat(KEY_THRESHOLD, MATCH_THRESHOLD) | 121 | .getFloat(KEY_THRESHOLD, MATCH_THRESHOLD) |
| 121 | 122 | ||
| 123 | + /** 合并阈值(设置页可调):重聚类后,质心余弦 ≥ 此值的两簇判为同一人并合并。 */ | ||
| 124 | + fun mergeThresholdFromPrefs(ctx: android.content.Context): Float = | ||
| 125 | + ctx.getSharedPreferences(PREFS, android.content.Context.MODE_PRIVATE) | ||
| 126 | + .getFloat(KEY_MERGE_THRESHOLD, MERGE_THRESHOLD) | ||
| 127 | + | ||
| 128 | + /** 合并阈值默认值:贪心聚类易把同一人切成多簇(早期噪声段另起簇后互相吸段), | ||
| 129 | + * 收尾做凝聚式合并把质心很近的簇并回来。0.55 偏保守,只并高置信对;调低并得更狠。 */ | ||
| 130 | + const val MERGE_THRESHOLD = 0.55f | ||
| 131 | + | ||
| 122 | /** 最短识别时长(设置页可调,秒):短于此的段完全跳过声纹识别。 */ | 132 | /** 最短识别时长(设置页可调,秒):短于此的段完全跳过声纹识别。 */ |
| 123 | fun minRecognizeMsFromPrefs(ctx: android.content.Context): Long = | 133 | fun minRecognizeMsFromPrefs(ctx: android.content.Context): Long = |
| 124 | ctx.getSharedPreferences(PREFS, android.content.Context.MODE_PRIVATE) | 134 | ctx.getSharedPreferences(PREFS, android.content.Context.MODE_PRIVATE) |
| @@ -170,6 +180,7 @@ class SpeakerClusterer( | @@ -170,6 +180,7 @@ class SpeakerClusterer( | ||
| 170 | matchThreshold: Float = MATCH_THRESHOLD, | 180 | matchThreshold: Float = MATCH_THRESHOLD, |
| 171 | minNewClusterMs: Long = MIN_NEW_CLUSTER_MS, | 181 | minNewClusterMs: Long = MIN_NEW_CLUSTER_MS, |
| 172 | minRecognizeMs: Long = MIN_RECOGNIZE_MS, | 182 | minRecognizeMs: Long = MIN_RECOGNIZE_MS, |
| 183 | + mergeThreshold: Float = MERGE_THRESHOLD, | ||
| 173 | maxSamplesPerCentroid: Int = MAX_SAMPLES_PER_CENTROID, | 184 | maxSamplesPerCentroid: Int = MAX_SAMPLES_PER_CENTROID, |
| 174 | ): Int = withContext(Dispatchers.IO) { | 185 | ): Int = withContext(Dispatchers.IO) { |
| 175 | // 1. 删掉自动簇,保留人工命名簇作种子。 | 186 | // 1. 删掉自动簇,保留人工命名簇作种子。 |
| @@ -234,8 +245,77 @@ class SpeakerClusterer( | @@ -234,8 +245,77 @@ class SpeakerClusterer( | ||
| 234 | 245 | ||
| 235 | repo.segments.updateSpeaker(seg.id, assignment.first, assignment.second) | 246 | repo.segments.updateSpeaker(seg.id, assignment.first, assignment.second) |
| 236 | } | 247 | } |
| 237 | - Log.i(TAG, "reclusterAll: ${segs.size} segments → ${clusters.size} clusters") | ||
| 238 | - clusters.size | 248 | + Log.i(TAG, "reclusterAll greedy: ${segs.size} segments → ${clusters.size} clusters") |
| 249 | + // 3. 凝聚式合并:把过度切分的同人簇并回来。 | ||
| 250 | + val merges = mergeOverSplit(repo, mergeThreshold) | ||
| 251 | + val finalCount = repo.speakers.all().size | ||
| 252 | + Log.i(TAG, "reclusterAll: merged $merges pairs → $finalCount clusters") | ||
| 253 | + finalCount | ||
| 254 | + } | ||
| 255 | + | ||
| 256 | + /** | ||
| 257 | + * 凝聚式合并(agglomerative):解决贪心聚类的过度切分——同一人因早期噪声段另起簇后, | ||
| 258 | + * 后续段按「最近质心」各自归簇,再不会并回。这里反复找质心余弦最大的一对簇, | ||
| 259 | + * ≥ [mergeThreshold] 就合并(质心用两簇**全部成员段**重算),直到没有可并的对。 | ||
| 260 | + * | ||
| 261 | + * 约束:不合并两个**人工命名**簇(会毁掉用户标定);一人工一自动则并入人工(保留名字); | ||
| 262 | + * 都自动则并入成员多的那个。合并后刷新被并段的 speakerId 与相似度。 | ||
| 263 | + * @return 合并发生的次数 | ||
| 264 | + */ | ||
| 265 | + suspend fun mergeOverSplit( | ||
| 266 | + repo: Repository, | ||
| 267 | + mergeThreshold: Float = MERGE_THRESHOLD, | ||
| 268 | + ): Int = withContext(Dispatchers.IO) { | ||
| 269 | + class WC(val id: Long, val manual: Boolean, val members: MutableList<FloatArray>, var centroid: FloatArray) | ||
| 270 | + fun meanNorm(ms: List<FloatArray>): FloatArray { | ||
| 271 | + val dim = ms[0].size | ||
| 272 | + val m = FloatArray(dim) | ||
| 273 | + for (e in ms) for (i in 0 until dim) m[i] += e[i] | ||
| 274 | + for (i in 0 until dim) m[i] /= ms.size | ||
| 275 | + l2normalize(m); return m | ||
| 276 | + } | ||
| 277 | + val wcs = ArrayList<WC>() | ||
| 278 | + for (p in repo.speakers.all()) { | ||
| 279 | + val mem = repo.segments.embeddingsForSpeaker(p.id).map { it.toFloatArrayLe() } | ||
| 280 | + if (mem.isEmpty()) continue | ||
| 281 | + wcs += WC(p.id, autoIndexOf(p.name) == null, mem.toMutableList(), meanNorm(mem)) | ||
| 282 | + } | ||
| 283 | + var merges = 0 | ||
| 284 | + while (true) { | ||
| 285 | + var bi = -1; var bj = -1; var best = mergeThreshold | ||
| 286 | + for (i in wcs.indices) for (j in i + 1 until wcs.size) { | ||
| 287 | + if (wcs[i].manual && wcs[j].manual) continue // 两个人工名不并 | ||
| 288 | + val c = cosineSim(wcs[i].centroid, wcs[j].centroid) | ||
| 289 | + if (c >= best) { best = c; bi = i; bj = j } | ||
| 290 | + } | ||
| 291 | + if (bi < 0) break | ||
| 292 | + val a = wcs[bi]; val b = wcs[bj] | ||
| 293 | + val keep: WC; val drop: WC | ||
| 294 | + when { | ||
| 295 | + a.manual && !b.manual -> { keep = a; drop = b } | ||
| 296 | + b.manual && !a.manual -> { keep = b; drop = a } | ||
| 297 | + a.members.size >= b.members.size -> { keep = a; drop = b } | ||
| 298 | + else -> { keep = b; drop = a } | ||
| 299 | + } | ||
| 300 | + keep.members.addAll(drop.members) | ||
| 301 | + keep.centroid = meanNorm(keep.members) | ||
| 302 | + repo.segments.reassignSpeaker(drop.id, keep.id) | ||
| 303 | + repo.speakers.delete(drop.id) | ||
| 304 | + wcs.remove(drop) | ||
| 305 | + merges++ | ||
| 306 | + } | ||
| 307 | + // 持久化保留簇的质心 + 刷新各段相似度(合并后质心变了)。 | ||
| 308 | + for (w in wcs) { | ||
| 309 | + repo.speakers.updateEmbedding(w.id, w.centroid.toLeBytes(), minOf(w.members.size, MAX_SAMPLES_PER_CENTROID)) | ||
| 310 | + } | ||
| 311 | + val byId = wcs.associateBy { it.id } | ||
| 312 | + for (seg in repo.segments.allWithEmbedding()) { | ||
| 313 | + val w = byId[seg.speakerId] ?: continue | ||
| 314 | + val sim = cosineSim(seg.embedding!!.toFloatArrayLe(), w.centroid) | ||
| 315 | + repo.segments.updateSpeaker(seg.id, w.id, sim) | ||
| 316 | + } | ||
| 317 | + Log.i(TAG, "mergeOverSplit: $merges merges, ${wcs.size} clusters remain (threshold=$mergeThreshold)") | ||
| 318 | + merges | ||
| 239 | } | 319 | } |
| 240 | 320 | ||
| 241 | /** | 321 | /** |
| @@ -248,11 +328,12 @@ class SpeakerClusterer( | @@ -248,11 +328,12 @@ class SpeakerClusterer( | ||
| 248 | matchThreshold: Float = MATCH_THRESHOLD, | 328 | matchThreshold: Float = MATCH_THRESHOLD, |
| 249 | minNewClusterMs: Long = MIN_NEW_CLUSTER_MS, | 329 | minNewClusterMs: Long = MIN_NEW_CLUSTER_MS, |
| 250 | minRecognizeMs: Long = MIN_RECOGNIZE_MS, | 330 | minRecognizeMs: Long = MIN_RECOGNIZE_MS, |
| 331 | + mergeThreshold: Float = MERGE_THRESHOLD, | ||
| 251 | ): Int = withContext(Dispatchers.IO) { | 332 | ): Int = withContext(Dispatchers.IO) { |
| 252 | repo.segments.clearAllSpeakerAssignments() | 333 | repo.segments.clearAllSpeakerAssignments() |
| 253 | for (p in repo.speakers.all()) repo.speakers.delete(p.id) | 334 | for (p in repo.speakers.all()) repo.speakers.delete(p.id) |
| 254 | Log.i(TAG, "clearAllAndRecluster: wiped all profiles + assignments") | 335 | Log.i(TAG, "clearAllAndRecluster: wiped all profiles + assignments") |
| 255 | - reclusterAll(repo, matchThreshold, minNewClusterMs, minRecognizeMs) | 336 | + reclusterAll(repo, matchThreshold, minNewClusterMs, minRecognizeMs, mergeThreshold) |
| 256 | } | 337 | } |
| 257 | } | 338 | } |
| 258 | } | 339 | } |