- Replaced libswipetype with MornsGestureEngine — ported from FlorisBoard's StatisticalGlideTypingClassifier (Apache 2.0) - Uses combined shape + location Gaussian probability scoring (SHAPE_STD=22.08, LOCATION_STD=0.5109) with 200-point sampling - Gesture activates instantly on key change with velocity-based detection - Trail draws from initial touch through entire glide - Added Kotlin plugin to build (v2.1.20) - 2000 word dictionary from cracklib-small - Removed libswipetype dependency from glide flow (libswipetype still present as submodule but unused)
196 lines
7.5 KiB
Kotlin
196 lines
7.5 KiB
Kotlin
/*
|
|
* Copyright (C) 2025 The FlorisBoard Contributors
|
|
* Licensed under the Apache License, Version 2.0
|
|
* Adapted for Morn's Keyboard (GPL-3.0)
|
|
*
|
|
* Gesture recognition using statistical shape + location matching.
|
|
* Based on Étienne Desticourt's algorithm (AnySoftKeyboard PR #1870).
|
|
* https://github.com/AnySoftKeyboard/AnySoftKeyboard/pull/1870
|
|
*/
|
|
|
|
package juloo.keyboard2
|
|
|
|
import kotlin.math.*
|
|
|
|
/**
|
|
* Gesture recognition engine for swipe typing.
|
|
* Takes a sequence of touch points, generates ideal paths for dictionary words,
|
|
* and scores them using combined shape + location Gaussian probability.
|
|
*/
|
|
class MornsGestureEngine(
|
|
private val keysByCodePoint: Map<Int, KeyInfo>,
|
|
private val words: List<String>
|
|
) {
|
|
companion object {
|
|
const val SAMPLING_POINTS = 200
|
|
private const val SHAPE_STD = 22.08f
|
|
private const val LOCATION_STD = 0.5109f
|
|
private const val PRUNING_LENGTH_THRESHOLD = 8.42
|
|
private const val MAX_GESTURE_SIZE = 500
|
|
private const val MAX_CANDIDATES = 8
|
|
}
|
|
|
|
data class KeyInfo(
|
|
val codePoint: Int,
|
|
val centerX: Float,
|
|
val centerY: Float,
|
|
val width: Float,
|
|
val height: Float
|
|
)
|
|
|
|
/** Accumulated gesture touch points. */
|
|
class Gesture {
|
|
private val xs = FloatArray(MAX_GESTURE_SIZE)
|
|
private val ys = FloatArray(MAX_GESTURE_SIZE)
|
|
private var size = 0
|
|
val isEmpty: Boolean get() = size == 0
|
|
fun addPoint(x: Float, y: Float) {
|
|
if (size < MAX_GESTURE_SIZE) { xs[size] = x; ys[size] = y; size++ }
|
|
}
|
|
fun getX(i: Int): Float = if (i < size) xs[i] else 0f
|
|
fun getY(i: Int): Float = if (i < size) ys[i] else 0f
|
|
val firstX: Float get() = getX(0)
|
|
val firstY: Float get() = getY(0)
|
|
val lastX: Float get() = getX(size - 1)
|
|
val lastY: Float get() = getY(size - 1)
|
|
fun clear() { size = 0 }
|
|
|
|
private fun d(x1: Float, y1: Float, x2: Float, y2: Float): Float =
|
|
sqrt((x1 - x2).pow(2f) + (y1 - y2).pow(2f))
|
|
|
|
val length: Float get() {
|
|
var len = 0f
|
|
for (i in 1 until size) len += d(xs[i-1], ys[i-1], xs[i], ys[i])
|
|
return len
|
|
}
|
|
|
|
fun resample(numPoints: Int): Gesture {
|
|
val interval = length / numPoints
|
|
val out = Gesture(); out.addPoint(xs[0], ys[0])
|
|
var lastX = xs[0]; var lastY = ys[0]
|
|
var cumErr = 0f
|
|
if (size == 1) { repeat(numPoints) { out.addPoint(xs[0], ys[0]) }; return out }
|
|
for (i in 0 until size - 1) {
|
|
var dx = xs[i+1] - xs[i]; var dy = ys[i+1] - ys[i]
|
|
val norm = sqrt(dx.pow(2f) + dy.pow(2f))
|
|
if (norm < 0.001f) continue
|
|
dx /= norm; dy /= norm
|
|
var n = norm / interval
|
|
cumErr += n - n.toInt()
|
|
if (cumErr > 1) { n = n.toInt() + cumErr.toInt().toFloat(); cumErr %= 1f }
|
|
repeat(n.toInt()) {
|
|
lastX += dx * interval; lastY += dy * interval
|
|
out.addPoint(lastX, lastY)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
/** Normalize to centroid-centered bounding box, preserving aspect ratio. */
|
|
fun normalizeByBoxSide(): Gesture {
|
|
val out = Gesture()
|
|
var maxX = -1e10f; var maxY = -1e10f
|
|
var minX = 1e10f; var minY = 1e10f
|
|
for (i in 0 until size) { maxX = max(xs[i], maxX); maxY = max(ys[i], maxY); minX = min(xs[i], minX); minY = min(ys[i], minY) }
|
|
val w = maxX - minX; val h = maxY - minY
|
|
val side = max(max(w, h), 0.00001f)
|
|
val cx = (w / 2f + minX) / side
|
|
val cy = (h / 2f + minY) / side
|
|
for (i in 0 until size) { out.addPoint(xs[i] / side - cx, ys[i] / side - cy) }
|
|
return out
|
|
}
|
|
|
|
fun clone(): Gesture { val g = Gesture(); xs.copyInto(g.xs); ys.copyInto(g.ys); g.size = size; return g }
|
|
}
|
|
|
|
/** Generate ideal gesture path for a word by connecting key centers. */
|
|
private fun generateIdealGesture(word: String): Gesture {
|
|
val g = Gesture()
|
|
for (c in word) {
|
|
val key = keysByCodePoint[Character.toLowerCase(c).code] ?: continue
|
|
g.addPoint(key.centerX, key.centerY)
|
|
}
|
|
return g
|
|
}
|
|
|
|
/** Find the n closest keys to a point. Returns their code points. */
|
|
private fun findNClosestKeys(x: Float, y: Float, n: Int): List<Int> {
|
|
return keysByCodePoint.entries
|
|
.map { (cp, k) -> cp to sqrt((x - k.centerX).pow(2f) + (y - k.centerY).pow(2f)) }
|
|
.sortedBy { it.second }
|
|
.take(n)
|
|
.map { it.first }
|
|
}
|
|
|
|
/** Score a gesture against a word. Lower is better. */
|
|
private fun scoreGesture(userGesture: Gesture, wordGesture: Gesture): Float {
|
|
val resampledUser = userGesture.resample(SAMPLING_POINTS)
|
|
val resampledWord = wordGesture.resample(SAMPLING_POINTS)
|
|
// Shape distance (normalized)
|
|
val normUser = resampledUser.normalizeByBoxSide()
|
|
val normWord = resampledWord.normalizeByBoxSide()
|
|
var shapeDist = 0f
|
|
for (i in 0 until SAMPLING_POINTS) {
|
|
shapeDist += distance(normWord.getX(i), normWord.getY(i), normUser.getX(i), normUser.getY(i))
|
|
}
|
|
// Location distance (unnormalized)
|
|
var locDist = 0f
|
|
for (i in 0 until SAMPLING_POINTS) {
|
|
locDist += abs(resampledWord.getX(i) - resampledUser.getX(i)) + abs(resampledWord.getY(i) - resampledUser.getY(i))
|
|
}
|
|
locDist /= SAMPLING_POINTS * 2f
|
|
// Combined Gaussian probability
|
|
val shapeProb = gaussianProb(shapeDist, 0f, SHAPE_STD)
|
|
val locProb = gaussianProb(locDist, 0f, LOCATION_STD * 50f)
|
|
return 1f / (shapeProb * locProb + 1e-10f)
|
|
}
|
|
|
|
private fun distance(x1: Float, y1: Float, x2: Float, y2: Float): Float =
|
|
sqrt((x1 - x2).pow(2f) + (y1 - y2).pow(2f))
|
|
|
|
private fun gaussianProb(value: Float, mean: Float, std: Float): Float {
|
|
val factor = 1f / (std * sqrt(2f * PI.toFloat()))
|
|
val exponent = ((value - mean) / std).toDouble().pow(2.0)
|
|
return (factor * exp(-0.5 * exponent)).toFloat()
|
|
}
|
|
|
|
/**
|
|
* Recognize the gesture and return candidate words sorted by confidence.
|
|
*/
|
|
fun recognize(points: List<MornsGlideAdapter.Pair>): List<String> {
|
|
val gesture = Gesture()
|
|
points.forEach { p -> gesture.addPoint(p.first, p.second) }
|
|
if (gesture.isEmpty) return emptyList()
|
|
|
|
val startKeys = findNClosestKeys(gesture.firstX, gesture.firstY, 2)
|
|
val endKeys = findNClosestKeys(gesture.lastX, gesture.lastY, 2)
|
|
|
|
// Filter words by start/end letters
|
|
val candidateWords = mutableListOf<String>()
|
|
for (w in words) {
|
|
if (w.isEmpty()) continue
|
|
val first = Character.toLowerCase(w[0])
|
|
val last = Character.toLowerCase(w[w.length - 1])
|
|
if (first.code in startKeys && last.code in endKeys) {
|
|
candidateWords.add(w)
|
|
}
|
|
}
|
|
|
|
// Score candidates
|
|
val scored = mutableListOf<Pair<String, Float>>()
|
|
val userGesture = gesture.resample(SAMPLING_POINTS)
|
|
|
|
for (word in candidateWords) {
|
|
val ideal = generateIdealGesture(word)
|
|
if (ideal.isEmpty) continue
|
|
val score = scoreGesture(userGesture, ideal)
|
|
scored.add(word to score)
|
|
}
|
|
|
|
scored.sortBy { it.second }
|
|
return scored.take(MAX_CANDIDATES).map { it.first }
|
|
}
|
|
|
|
fun clear() {}
|
|
}
|