Skip to content

Commit a79556e

Browse files
authored
Merge pull request #762 from kingzcheung/main
feat(handwriting): 实现手写叠写功能和版本更新
2 parents 49a9482 + 54c01f5 commit a79556e

10 files changed

Lines changed: 1014 additions & 63 deletions

File tree

app/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ android {
4343
applicationId = "com.kingzcheung.xime"
4444
minSdk = 28
4545
targetSdk = 35
46-
versionCode = 20260829
47-
versionName = "2.8.0"
46+
versionCode = 20260830
47+
versionName = "2.8.0-beta1"
4848

4949

5050
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package com.kingzcheung.xime.handwriting
2+
3+
import com.kingzcheung.xime.handwriting.OverlappedHandwritingRecognizer.Segment
4+
5+
/** 停顿分割阈值(自适应基准):笔画越多越接近写完,定型越快;起笔阶段保守防误分割。 */
6+
const val HW_SPLIT_PAUSE_BASE_MS = 700L
7+
8+
/** 每多一笔,停顿阈值递减量(ms)。 */
9+
const val HW_SPLIT_PAUSE_STEP_MS = 50L
10+
11+
/** 停顿分割阈值下限(ms)。 */
12+
const val HW_SPLIT_PAUSE_MIN_MS = 500L
13+
14+
/** 识别窗口笔画上限:超过后固化最早段滑窗(正常分割靠停顿,此为不停顿兜底)。 */
15+
const val HW_RECOGNIZE_WINDOW_LIMIT = 25
16+
17+
/**
18+
* 淡出的停顿门槛(ms):段边界笔间间隔达到此值才视为"换字",
19+
* 前面段笔画才变淡——连笔中途的切分抖动(如"在"=[一][丿][丨])不触发淡出,
20+
* 避免单字没写完部首就消失。
21+
*/
22+
const val HW_FADE_GAP_MS = 400L
23+
24+
/**
25+
* 手写叠写共享常量与纯函数(主手写键盘与手写查词键盘共用)。
26+
*/
27+
object HandwritingStrokeFx {
28+
29+
/**
30+
* 停顿分割阈值(自适应):笔画越多字越接近写完,定型越快;
31+
* 起笔阶段保守(1~2 笔时停顿多半是构思,防慢写者单字中途被误分割)。
32+
*
33+
* @param windowStrokes 当前识别窗口(未固化)笔画数。
34+
*/
35+
fun splitPauseMs(windowStrokes: Int): Long =
36+
(HW_SPLIT_PAUSE_BASE_MS - (windowStrokes - 1) * HW_SPLIT_PAUSE_STEP_MS)
37+
.coerceAtLeast(HW_SPLIT_PAUSE_MIN_MS)
38+
39+
/** 窗口的笔间时间间隔(gaps[j] = 第 j 笔起笔与上一笔收笔的间隔,gaps[0]=0)。 */
40+
fun windowGaps(window: List<List<StrokePoint>>): List<Long> =
41+
window.mapIndexed { idx, stroke ->
42+
if (idx == 0) 0L else stroke.first().timeMs - window[idx - 1].last().timeMs
43+
}
44+
45+
/**
46+
* 计算"已完成字"的笔画数(淡出范围):从最后一段往前找第一个
47+
* 换字停顿边界(间隔 ≥ [HW_FADE_GAP_MS]),该边界之前的段全部视为已完成。
48+
* 找不到(全程连笔,单字书写中途)返回 0——不淡出。
49+
*/
50+
fun settledStrokesBeforeCurrent(
51+
segments: List<Segment>,
52+
gaps: List<Long>,
53+
): Int {
54+
for (k in segments.size - 1 downTo 1) {
55+
val start = segments[k].startStroke
56+
if (start < gaps.size && gaps[start] >= HW_FADE_GAP_MS) {
57+
return start
58+
}
59+
}
60+
return 0
61+
}
62+
}
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
package com.kingzcheung.xime.handwriting
2+
3+
/**
4+
* 叠写(连写)识别引擎:在单字识别模型之上实现多字连笔切分。
5+
*
6+
* 原理:过分割 + 动态规划组合打分。把笔画序列按所有可能的切分点切段,
7+
* 每段送单字模型识别,DP 求全局最优切分。段间比较用"段均分 + 切分惩罚",
8+
* 解决部件成字问题(如"张" = 弓 + 长,两者都是独立汉字):
9+
* 未写完时即使被切成 [弓][卜],写完后合并段"张"的分数更高,均分比较
10+
* 会切回单段,上屏内容随之替换纠正。
11+
*
12+
* 缓存约定:笔画序列只允许尾部追加(前缀不变),段推理结果跨笔画复用;
13+
* 笔画缓冲被裁剪/清空时必须调用 [reset] 使缓存失效。
14+
*
15+
* 推理成本:每追加一笔,仅新增 ≤ [maxStrokesPerSegment] 次单字 ONNX 推理
16+
* (其余段全部命中缓存),后台线程调用即可。
17+
*/
18+
class OverlappedHandwritingRecognizer(
19+
/** 活动笔画最多同时叠写的字数,超过后由调用方固化最早的字。 */
20+
private val maxSegments: Int = DEFAULT_MAX_SEGMENTS,
21+
/** 单个字允许的最大笔画数(超出视为不可识别为单字)。 */
22+
private val maxStrokesPerSegment: Int = DEFAULT_MAX_STROKES_PER_SEGMENT,
23+
) {
24+
/**
25+
* 单个切分段(一个字)。
26+
*
27+
* @param startStroke 段起始笔画索引(0-based,相对本次 recognize 输入)。
28+
* @param strokeCount 段内笔画数。
29+
* @param candidates 候选字列表(降序,非空)。
30+
*/
31+
data class Segment(
32+
val startStroke: Int,
33+
val strokeCount: Int,
34+
val candidates: List<HandwritingCandidate>,
35+
)
36+
37+
/**
38+
* 识别结果。
39+
*
40+
* @param segments 最优切分(时间序):最后一段为"当前正在写的字",
41+
* 前面的段已可固化上屏。段候选非空。
42+
* @param totalScore 最优切分的识别分总和。
43+
* @param avgScore 段均分(切分比较依据)。
44+
*/
45+
data class Result(
46+
val segments: List<Segment>,
47+
val totalScore: Float,
48+
val avgScore: Float,
49+
)
50+
51+
private val segCache = HashMap<Long, List<HandwritingCandidate>>()
52+
53+
/**
54+
* 对笔画序列做叠写识别。
55+
*
56+
* @param strokes 笔画序列(时间序,前缀不可变;超长时自动裁剪并清缓存)。
57+
* @param gaps 笔间时间间隔(毫秒):gaps[j] = 第 j 笔起笔与第 j-1 笔收笔的
58+
* 间隔(gaps[0] 恒为 0)。分割信号:停顿处倾向切分、连笔处压制切分。
59+
* 与 strokes 长度一致或为空(视为无间隔信息)。
60+
* @param predictFn 单字识别函数,生产环境用 [HandwritingEngine.predict];
61+
* 测试注入假模型。
62+
*/
63+
fun recognize(
64+
strokes: List<List<Pair<Float, Float>>>,
65+
gaps: List<Long> = emptyList(),
66+
predictFn: (List<List<Pair<Float, Float>>>, Int) -> List<HandwritingCandidate> =
67+
{ s, k -> HandwritingEngine.predict(s, k) }
68+
): Result {
69+
val bounded = if (strokes.size > maxSegments * maxStrokesPerSegment) {
70+
reset()
71+
strokes.takeLast(maxSegments * maxStrokesPerSegment)
72+
} else {
73+
strokes
74+
}
75+
val n = bounded.size
76+
if (n == 0) return Result(emptyList(), 0f, 0f)
77+
78+
val neg = Float.NEGATIVE_INFINITY
79+
// best[k][i]:前 i 笔切成 k 段的最大识别分总和(含时间间隔偏置)
80+
val best = Array(maxSegments + 1) { FloatArray(n + 1) { neg } }
81+
val back = Array(maxSegments + 1) { IntArray(n + 1) { -1 } }
82+
best[0][0] = 0f
83+
84+
for (k in 1..maxSegments) {
85+
for (i in 1..n) {
86+
val minJ = (i - maxStrokesPerSegment).coerceAtLeast(0)
87+
for (j in minJ until i) {
88+
val prev = best[k - 1][j]
89+
if (prev == neg) continue
90+
val candidates = segmentCandidates(j, i, bounded, predictFn)
91+
if (candidates.isEmpty()) continue
92+
// 时间偏置:j>0 时新段起笔与上一段收笔的间隔影响切分倾向
93+
val gapBias = if (j > 0 && j < gaps.size) gapBias(gaps[j]) else 0f
94+
val score = prev + candidates[0].score + gapBias
95+
if (best[k][i] == neg || score > best[k][i]) {
96+
best[k][i] = score
97+
back[k][i] = j
98+
}
99+
}
100+
}
101+
}
102+
103+
// 段间比较:段均分 - 每多切一段的惩罚。均分避免"两段低分字之和
104+
// 虚高压过单段高分字";惩罚避免"多一刀白赚分数"。
105+
var bestK = 0
106+
var bestNorm = neg
107+
for (k in 1..maxSegments) {
108+
val total = best[k][n]
109+
if (total == neg) continue
110+
val norm = total / k - SEGMENT_PENALTY * (k - 1)
111+
if (norm > bestNorm) {
112+
bestNorm = norm
113+
bestK = k
114+
}
115+
}
116+
if (bestK == 0) return Result(emptyList(), 0f, 0f)
117+
118+
// "一个字"先验:DP 选出多段时,必须存在"显著换字停顿"才信任切分。
119+
// 显著性用相对阈值判定——边界间隔显著高于窗口内笔间间隔中位数
120+
// (慢写者字内间隔本身高,绝对阈值会把匀速书写的复杂字拆开;
121+
// 快写者字间间隔虽小但相对突出,仍能正确检出)。
122+
// 无显著停顿时按单字处理(合并段识别),输入框始终只显示一个字;
123+
// 连笔写多字词时合并段分数必然极低(跨字笔迹不成字),仍尊重 DP 切分。
124+
if (bestK >= 2 && !hasPauseBoundary(gaps)) {
125+
val merged = segmentCandidates(0, n, bounded, predictFn)
126+
val mergedScore = merged.firstOrNull()?.score ?: 0f
127+
if (mergedScore >= MERGED_CHAR_MIN_SCORE) {
128+
return Result(listOf(Segment(0, n, merged)), mergedScore, mergedScore)
129+
}
130+
}
131+
132+
val segments = mutableListOf<Segment>()
133+
var i = n
134+
for (k in bestK downTo 1) {
135+
val j = back[k][i]
136+
segments.add(0, Segment(j, i - j, segmentCandidates(j, i, bounded, predictFn)))
137+
i = j
138+
}
139+
val total = best[bestK][n]
140+
return Result(segments, total, total / bestK)
141+
}
142+
143+
/** 笔画缓冲被裁剪或清空时调用,使段缓存失效。 */
144+
fun reset() {
145+
segCache.clear()
146+
}
147+
148+
/**
149+
* 窗口滑窗:头部裁剪 k 笔后调用。段缓存 key 平移((j,i)→(j-k,i-k)),
150+
* 被裁笔画之外的段全部复用,无需重新推理。
151+
*/
152+
fun onStrokesTrimmed(k: Int) {
153+
if (k <= 0) return
154+
val shifted = HashMap<Long, List<HandwritingCandidate>>()
155+
for ((key, candidates) in segCache) {
156+
val j = (key / 1000L).toInt()
157+
val i = (key % 1000L).toInt()
158+
if (i > k) shifted[(j - k).toLong() * 1000L + (i - k)] = candidates
159+
}
160+
segCache.clear()
161+
segCache.putAll(shifted)
162+
}
163+
164+
private fun segmentCandidates(
165+
j: Int,
166+
i: Int,
167+
strokes: List<List<Pair<Float, Float>>>,
168+
predictFn: (List<List<Pair<Float, Float>>>, Int) -> List<HandwritingCandidate>
169+
): List<HandwritingCandidate> {
170+
val key = j.toLong() * 1000L + i
171+
segCache[key]?.let { return it }
172+
val segment = strokes.subList(j, i).map { stroke ->
173+
stroke.map { Pair(it.first, it.second) }
174+
}
175+
val candidates = predictFn(segment, SEGMENT_TOP_K).take(SEGMENT_TOP_K)
176+
segCache[key] = candidates
177+
return candidates
178+
}
179+
180+
/**
181+
* 切分点时间偏置:笔间停顿是分字的最强信号——
182+
* 明显停顿(≥500ms)倾向在此切分(加分);
183+
* 连笔(≤150ms)压制切分(减分),避免单字中途被拆出部件字。
184+
*/
185+
private fun gapBias(gapMs: Long): Float = when {
186+
gapMs >= GAP_SPLIT_MS -> GAP_SPLIT_BONUS
187+
gapMs <= GAP_JOIN_MS -> GAP_JOIN_MALUS
188+
else -> 0f
189+
}
190+
191+
/**
192+
* 显著换字停顿检测(相对阈值):
193+
* - 窗口内笔间间隔样本少(<3 个)时用绝对阈值 [GAP_SPLIT_MS](无中位数参照);
194+
* - 否则边界间隔 ≥ max(中位数×2, [GAP_PAUSE_MIN_MS]) 才算换字。
195+
* 匀速慢写的复杂字(字内间隔均匀偏高)无显著边界 → 判定单字;
196+
* 快写多字词(字间间隔相对突出)→ 正确检出边界。
197+
*/
198+
private fun hasPauseBoundary(gaps: List<Long>): Boolean {
199+
val inner = gaps.drop(1)
200+
if (inner.isEmpty()) return false
201+
val threshold = if (inner.size < 3) {
202+
GAP_SPLIT_MS
203+
} else {
204+
val median = inner.sorted()[inner.size / 2]
205+
maxOf(median * 2, GAP_PAUSE_MIN_MS)
206+
}
207+
return inner.any { it >= threshold }
208+
}
209+
210+
companion object {
211+
/**
212+
* 活动笔画最多同时叠写的字数。
213+
* 5 段上限保证单字书写中途的临时多段切分(如"在"写到 3 笔时的
214+
* [一][丿][丨])不会被误当作多字而触发固化——写完后合并段分数
215+
* 更高会自动切回单段替换。
216+
*/
217+
const val DEFAULT_MAX_SEGMENTS = 5
218+
const val DEFAULT_MAX_STROKES_PER_SEGMENT = 20
219+
private const val SEGMENT_PENALTY = 0.02f
220+
private const val SEGMENT_TOP_K = 8
221+
222+
/** 笔间停顿达到此值视为"换字"切分点(加分)。 */
223+
const val GAP_SPLIT_MS = 500L
224+
/** 笔间间隔小于此值视为连笔(压制切分)。 */
225+
const val GAP_JOIN_MS = 150L
226+
private const val GAP_SPLIT_BONUS = 0.15f
227+
private const val GAP_JOIN_MALUS = -0.08f
228+
229+
/**
230+
* 合并段(全窗口单字识别)分数达到此值视为"笔迹整体像一个字":
231+
* 无显著停顿时强制按单字处理(上屏 1 个字),防止单字中途被拆成多字上屏。
232+
*/
233+
private const val MERGED_CHAR_MIN_SCORE = 0.35f
234+
235+
/** 相对停顿检测的绝对下限(ms):中位数极低(极快连写)时防止阈值过低。 */
236+
private const val GAP_PAUSE_MIN_MS = 250L
237+
}
238+
}

app/src/main/java/com/kingzcheung/xime/service/ImeKeyboardCallbacks.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,17 @@ internal fun rememberImeKeyboardCallbacks(
105105
onClipboardPullRemote = { service.clipboardSyncBridge?.pullOnce() },
106106
onCommitText = { text -> service.textCommit.commitClipboardText(text) },
107107
onDeleteText = { count -> service.textCommit.deleteClipboardChars(count) },
108+
onHandwritingAutoCommit = { newTail, expectedTail ->
109+
if (expectedTail.isEmpty()) {
110+
service.commitTextSilently(newTail)
111+
true
112+
} else {
113+
// 光标前文本与手写尾部不对应(用户移动过光标/退格过)时不上屏,
114+
// 调用方重置手写尾部状态后以追加模式重建,避免误删/重复上屏
115+
service.replaceBeforeCursor(expectedTail, newTail)
116+
}
117+
},
118+
onHandwritingFinalize = { service.finalizeHandwritingPrediction() },
108119
onQuickSend = {},
109120
onKeyboardResize = {
110121
val config = service.resources.configuration

0 commit comments

Comments
 (0)