Commit 9beb325a9c788a525b1aa44cc61eca26d06c858f

Authored by yibo
1 parent 31f530a2

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>
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt
... ... @@ -864,6 +864,7 @@ private fun SpeakersTab(
864 864 repo,
865 865 matchThreshold = SpeakerClusterer.thresholdFromPrefs(ctx),
866 866 minRecognizeMs = SpeakerClusterer.minRecognizeMsFromPrefs(ctx),
  867 + mergeThreshold = SpeakerClusterer.mergeThresholdFromPrefs(ctx),
867 868 )
868 869 }
869 870 reclustering = false
... ... @@ -975,6 +976,7 @@ private fun SpeakersTab(
975 976 repo,
976 977 matchThreshold = SpeakerClusterer.thresholdFromPrefs(ctx),
977 978 minRecognizeMs = SpeakerClusterer.minRecognizeMsFromPrefs(ctx),
  979 + mergeThreshold = SpeakerClusterer.mergeThresholdFromPrefs(ctx),
978 980 )
979 981 }
980 982 reclustering = false
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/SettingsScreen.kt
... ... @@ -51,6 +51,7 @@ fun SettingsScreen(activity: Context) {
51 51 var minSpeech by remember { mutableStateOf(prefs.getInt(SummaryWorker.KEY_MIN_SPEECH_SEC, SummaryWorker.DEFAULT_MIN_SPEECH_SEC)) }
52 52 var backendId by remember { mutableStateOf(prefs.getString(SummaryWorker.KEY_BACKEND, ChatConfig.ID_2B) ?: ChatConfig.ID_2B) }
53 53 var threshold by remember { mutableStateOf(SpeakerClusterer.thresholdFromPrefs(activity)) }
  54 + var mergeThreshold by remember { mutableStateOf(SpeakerClusterer.mergeThresholdFromPrefs(activity)) }
54 55 var minRecognizeSec by remember {
55 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 99 listOf(0.35f, 0.40f, 0.45f, 0.50f, 0.55f, 0.60f, 0.65f).map { "%.2f".format(it) to it }) {
99 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 106 DropdownSetting("最短识别时长(短于此不认声纹)", "$minRecognizeSec 秒",
102 107 listOf(1, 2, 3, 5, 8).map { "$it 秒" to it }) {
103 108 minRecognizeSec = it; prefs.edit().putInt(SpeakerClusterer.KEY_MIN_RECOGNIZE_SEC, it).apply()
104 109 }
105   - Text("改完到「声纹」tab 点「重新聚类」对历史段生效;新录音自动用新值。",
  110 + Text("重聚类会先贪心分簇、再把质心很近的簇按「合并阈值」并回(治同人被拆成多个)。改完到「声纹」tab 点「重新聚类」生效。",
106 111 style = MaterialTheme.typography.labelSmall)
107 112  
108 113 HorizontalDivider()
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/asr/SpeakerClusterer.kt
... ... @@ -114,11 +114,21 @@ class SpeakerClusterer(
114 114 const val PREFS = "lifelog_prefs"
115 115 const val KEY_THRESHOLD = "speaker_threshold"
116 116 const val KEY_MIN_RECOGNIZE_SEC = "speaker_min_recognize_sec"
  117 + const val KEY_MERGE_THRESHOLD = "speaker_merge_threshold"
117 118  
118 119 fun thresholdFromPrefs(ctx: android.content.Context): Float =
119 120 ctx.getSharedPreferences(PREFS, android.content.Context.MODE_PRIVATE)
120 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 133 fun minRecognizeMsFromPrefs(ctx: android.content.Context): Long =
124 134 ctx.getSharedPreferences(PREFS, android.content.Context.MODE_PRIVATE)
... ... @@ -170,6 +180,7 @@ class SpeakerClusterer(
170 180 matchThreshold: Float = MATCH_THRESHOLD,
171 181 minNewClusterMs: Long = MIN_NEW_CLUSTER_MS,
172 182 minRecognizeMs: Long = MIN_RECOGNIZE_MS,
  183 + mergeThreshold: Float = MERGE_THRESHOLD,
173 184 maxSamplesPerCentroid: Int = MAX_SAMPLES_PER_CENTROID,
174 185 ): Int = withContext(Dispatchers.IO) {
175 186 // 1. 删掉自动簇,保留人工命名簇作种子。
... ... @@ -234,8 +245,77 @@ class SpeakerClusterer(
234 245  
235 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 328 matchThreshold: Float = MATCH_THRESHOLD,
249 329 minNewClusterMs: Long = MIN_NEW_CLUSTER_MS,
250 330 minRecognizeMs: Long = MIN_RECOGNIZE_MS,
  331 + mergeThreshold: Float = MERGE_THRESHOLD,
251 332 ): Int = withContext(Dispatchers.IO) {
252 333 repo.segments.clearAllSpeakerAssignments()
253 334 for (p in repo.speakers.all()) repo.speakers.delete(p.id)
254 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 }
... ...