Commit 860e810053382435e79162618d4961b112bbce25

Authored by yibo
1 parent 6fa813b7

fix(lifelog/diarize): L1 reclusterAll 冻结人工命名 anchor 质心,止住雪球吞并

reclusterAll 原本对命中的簇一律滑动均值精修质心。问题:人工命名的 anchor
吸入相邻说话人的段后,质心会朝其漂移、再吸走更多 → 雪球式吞并(实测「秦子琛」
把「宜博」从 ~125 段啃到只剩 56)。

改为:命中人工命名 anchor 时只归属、**冻结质心不漂移**(autoIndexOf==null 判定);
自动簇仍滑动均值。anchor 始终保持用户标定方向,不被相邻说话人污染。

实测确认生效:冻结后「秦子琛」anchor 不再吞并,各簇内聚 0.76-0.82 干净分离。
注:此改动也暴露出历史漂移已把存量 anchor 的 embedding 平均坏了(连本该代表的
主说话人都匹配不上),需 L2 多 anchor 重注册给出干净 anchor。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/asr/SpeakerClusterer.kt
... ... @@ -159,13 +159,21 @@ class SpeakerClusterer(
159 159 }
160 160  
161 161 val assignment = if (best != null && bestSim >= matchThreshold) {
162   - val n = minOf(best.count, maxSamplesPerCentroid)
163   - val merged = FloatArray(emb.size) { i -> (best.centroid[i] * n + emb[i]) / (n + 1) }
164   - l2normalize(merged)
165   - best.centroid = merged
166   - best.count += 1
167   - repo.speakers.updateEmbedding(best.id, merged.toLeBytes(), best.count)
168   - best.id to bestSim
  162 + if (autoIndexOf(best.name) == null) {
  163 + // 人工命名 anchor:只归属、**冻结质心不漂移**。否则 anchor 吸入相邻
  164 + // 说话人的段后质心会朝其漂移、再吸走更多 → 雪球式吞并(实测秦子琛
  165 + // 把宜博从 ~125 段啃到只剩 56)。冻结后 anchor 始终是用户标定的方向。
  166 + best.id to bestSim
  167 + } else {
  168 + // 自动簇:滑动均值精修质心(自适应、不被早期样本钉死)。
  169 + val n = minOf(best.count, maxSamplesPerCentroid)
  170 + val merged = FloatArray(emb.size) { i -> (best.centroid[i] * n + emb[i]) / (n + 1) }
  171 + l2normalize(merged)
  172 + best.centroid = merged
  173 + best.count += 1
  174 + repo.speakers.updateEmbedding(best.id, merged.toLeBytes(), best.count)
  175 + best.id to bestSim
  176 + }
169 177 } else if (seg.durationMs >= minNewClusterMs) {
170 178 val norm = emb.copyOf().also { l2normalize(it) }
171 179 seq += 1
... ...