Files
Unexpected-Keyboard/srcs/gestures/MornsGestureEngine.kt
T
harley 0c3dd42354
Check generated files / Layout list (push) Has been cancelled
Check generated files / Layout warnings (push) Has been cancelled
Check generated files / Compose key data (push) Has been cancelled
Make Apk CI / Build-Apk (push) Has been cancelled
Integrate FlorisBoard-derived gesture engine (MornsGestureEngine)
- 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)
2026-07-07 03:59:47 -04:00

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() {}
}