From 1d4de3f2a0144dec12f6e3465ceb8a56ffd1e0eb Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Mon, 22 Aug 2022 10:17:35 +0700 Subject: [PATCH] chore(web): adjustments per PR review --- .../src/cumulativePathStats.ts | 112 +++++++++++------- .../gesture-recognizer/src/pathSegmenter.ts | 16 ++- 2 files changed, 76 insertions(+), 52 deletions(-) diff --git a/common/web/gesture-recognizer/src/cumulativePathStats.ts b/common/web/gesture-recognizer/src/cumulativePathStats.ts index ebd4072458..088a371870 100644 --- a/common/web/gesture-recognizer/src/cumulativePathStats.ts +++ b/common/web/gesture-recognizer/src/cumulativePathStats.ts @@ -1,4 +1,24 @@ namespace com.keyman.osk { + /** + * Denotes one dimension utilized by touchpath input coordinates - 'x' and y' for space, + * 't' for time. + */ + export type PathCoordAxis = 'x' | 'y' | 't'; + + /** + * Denotes a pair of dimensions utilized by touchpath input coordinates. The two axes + * (see `PathCoordAxis`) must be specified in alphabetical order. + */ + export type PathCoordAxisPair = 'tx' | 'ty' | 'xy'; + + /** + * Denotes one dimension or feature (velocity) that this class tracks statistics for. + * + * Sine and Cosine stats are currently excluded due to their necessary lack of statistical + * independence. + */ + type StatAxis = PathCoordAxis | 'v'; + /** * As the name suggests, this class facilitates tracking of cumulative mathematical values, etc * necessary to perform the statistical operations necessary for path segmentation. @@ -6,15 +26,14 @@ namespace com.keyman.osk { * Instances of this class are immutable. */ export class CumulativePathStats { - // So... class-level "inner classes" are possible in TS... if defined via assignment to a field. /** * Provides linear-regression statistics & fitting values based on the underlying `CumulativePathStats` * object used to generate it. All operations are O(1). */ static readonly regression = class RegressionFromSums { - readonly independent: 'x' | 'y' | 't'; - readonly dependent: 'x' | 'y' | 't'; - readonly paired: 'tx' | 'ty' | 'xy'; + readonly independent: PathCoordAxis; + readonly dependent: PathCoordAxis; + readonly paired: PathCoordAxisPair; readonly accumulator: CumulativePathStats; @@ -25,7 +44,7 @@ namespace com.keyman.osk { * existing data of its relationship with the independent axis. * @param independentAxis The 'input' axis/dimension. */ - constructor(mainStats: CumulativePathStats, dependentAxis: 'x' | 'y' | 't', independentAxis: 'x' | 'y' | 't') { + constructor(mainStats: CumulativePathStats, dependentAxis: PathCoordAxis, independentAxis: PathCoordAxis) { if(dependentAxis == independentAxis) { throw "Two different axes must be specified for the regression object."; } @@ -36,9 +55,9 @@ namespace com.keyman.osk { this.independent = independentAxis; if(dependentAxis < independentAxis) { - this.paired = dependentAxis.concat(independentAxis) as 'tx' | 'ty' | 'xy'; + this.paired = dependentAxis.concat(independentAxis) as PathCoordAxisPair; } else { - this.paired = independentAxis.concat(dependentAxis) as 'tx' | 'ty' | 'xy'; + this.paired = independentAxis.concat(dependentAxis) as PathCoordAxisPair; } } @@ -124,6 +143,12 @@ namespace com.keyman.osk { } } + /** + * Floating-point errors may result from cross-sum calculations, and they may be slightly larger than + * Number.EPSILON as the sums grow. (Taking the difference of cross-sums) + */ + private static readonly CANCELLATION_EPSILON = Math.sqrt(Number.EPSILON); + private rawLinearSums: {'x': number, 'y': number, 't': number, 'v': number} = {'x': 0, 'y': 0, 't': 0, 'v': 0}; private rawSquaredSums: {'x': number, 'y': number, 't': number, 'v': number} = {'x': 0, 'y': 0, 't': 0, 'v': 0}; // Would 'tv' (time vs velocity) be worth it to track? And possibly even do a regression for? @@ -179,6 +204,8 @@ namespace com.keyman.osk { this.rawSquaredSums = {...obj.rawSquaredSums}; } else if(isAnInputSample(obj)) { Object.assign(this, this.extend(obj)); + } else { + throw "A constructor for this input pattern has not yet been implemented"; } } @@ -305,12 +332,13 @@ namespace com.keyman.osk { const tDelta = subsetStats.followingSample.t - subsetStats.lastSample.t; const tDeltaInSec = tDelta / 1000; - const coordArcSq = xDelta * xDelta + yDelta * yDelta; + const coordArcDeltaSq = xDelta * xDelta + yDelta * yDelta; + const coordArcDelta = Math.sqrt(coordArcDeltaSq); // Due to how arc length stuff gets segmented. // There's the arc length within the prefix subset (operand 2 below) AND the part connecting it to the // 'remaining' subset (operand 1 below) before the portion wholly within what remains (the result) - result.coordArcSum -= Math.sqrt(coordArcSq); + result.coordArcSum -= coordArcDelta; result.coordArcSum -= subsetStats.coordArcSum; result.cosLinearSum -= subsetStats.cosLinearSum; @@ -318,8 +346,8 @@ namespace com.keyman.osk { result.arcSampleCount -= subsetStats.arcSampleCount; if(tDeltaInSec) { - result.rawLinearSums['v'] -= Math.sqrt(coordArcSq) / tDeltaInSec; - result.rawSquaredSums['v'] -= coordArcSq / (tDeltaInSec * tDeltaInSec); + result.rawLinearSums['v'] -= coordArcDelta / tDeltaInSec; + result.rawSquaredSums['v'] -= coordArcDeltaSq / (tDeltaInSec * tDeltaInSec); } } @@ -360,7 +388,7 @@ namespace com.keyman.osk { * @param dim * @returns */ - private mappingConstant(dim: 'x' | 'y' | 't' | 'v') { + private mappingConstant(dim: StatAxis) { if(!this.baseSample) { return undefined; } @@ -382,7 +410,7 @@ namespace com.keyman.osk { * @param dim * @returns */ - private mappedMean(dim: 'x' | 'y' | 't' |'v') { + private mappedMean(dim: StatAxis) { return this.rawLinearSums[dim] / this.sampleCount; } @@ -392,7 +420,7 @@ namespace com.keyman.osk { * @param dim * @returns */ - public mean(dim: 'x' | 'y' | 't' | 'v') { + public mean(dim: StatAxis) { // This external-facing version needs to provide values in 'external'-friendly // coordinate space. return this.mappedMean(dim) + this.mappingConstant(dim); @@ -404,7 +432,7 @@ namespace com.keyman.osk { * @param dim * @returns */ - public squaredSum(dim: 'x' | 'y' | 't' | 'v') { + public squaredSum(dim: StatAxis) { const x2 = this.rawSquaredSums[dim]; const x1 = this.rawLinearSums[dim]; @@ -419,14 +447,11 @@ namespace com.keyman.osk { * @param dimPair * @returns */ - public crossSum(dimPair: 'tx' | 'ty' | 'xy') { + public crossSum(dimPair: PathCoordAxisPair) { const dim1 = dimPair.charAt(0); const dim2 = dimPair.charAt(1); let orderedDims: string = dimPair; - if(dim2 < dim1) { - orderedDims = dim2.concat(dim1); - } const ab = this.rawCrossSums[orderedDims]; const a = this.rawLinearSums[dim1]; @@ -444,7 +469,7 @@ namespace com.keyman.osk { * @param dimPair * @returns */ - public covariance(dimPair: 'tx' | 'ty' | 'xy') { + public covariance(dimPair: PathCoordAxisPair) { return this.crossSum(dimPair) / (this.sampleCount - 1); } @@ -452,7 +477,7 @@ namespace com.keyman.osk { * Gets the unbiased variance on the specified axis for samples observed * during the represented interval. */ - public variance(dim: 'x' | 'y' | 't' | 'v') { + public variance(dim: StatAxis) { return this.squaredSum(dim) / (this.sampleCount - 1); } @@ -480,11 +505,11 @@ namespace com.keyman.osk { result.baseSample = newBase; for(const dimPair in result.rawCrossSums) { - result.rawCrossSums[dimPair] = this.crossSum(dimPair as 'tx' | 'ty' | 'xy'); + result.rawCrossSums[dimPair] = this.crossSum(dimPair as PathCoordAxisPair); } for(const dim in result.rawSquaredSums) { - result.rawSquaredSums[dim] = this.squaredSum(dim as 'x' | 'y' | 't'); + result.rawSquaredSums[dim] = this.squaredSum(dim as PathCoordAxis); } return result; @@ -497,7 +522,7 @@ namespace com.keyman.osk { * @param independent * @returns */ - public fitRegression(dependent: 'x' | 'y' | 't', independent: 'x' | 'y' | 't') { + public fitRegression(dependent: PathCoordAxis, independent: PathCoordAxis) { return new CumulativePathStats.regression(this, dependent, independent); } @@ -510,7 +535,7 @@ namespace com.keyman.osk { public get netDistance() { // No issue with a net distance of 0 due to a single point. if(!this.lastSample || !this.initialSample) { - return Number.NaN; + return 0; } const xDelta = this.lastSample.targetX - this.initialSample.targetX; @@ -527,7 +552,7 @@ namespace com.keyman.osk { public get duration() { // no issue with a duration of zero from just one sample. if(!this.lastSample || !this.initialSample) { - return Number.NaN; + return 0; } return (this.lastSample.t - this.initialSample.t) * 0.001; } @@ -572,16 +597,12 @@ namespace com.keyman.osk { return undefined; } - const angle = this.angleInDegrees; - const buckets = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw']; + const buckets = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw', 'n']; - for(let threshold = 22.5, bucketIndex = 0; threshold < 360; threshold += 45, bucketIndex += 1) { - if(angle < threshold) { - return buckets[bucketIndex]; - } - } - - return 'n'; + // We could be 'more efficient' and use radians here instead, but this + // version helps a bit more with easy maintainability. + const bucketIndex = Math.ceil((this.angleInDegrees - 22.5)/45); + return buckets[bucketIndex]; } /** @@ -627,9 +648,14 @@ namespace com.keyman.osk { * Range: floating-point values on the interval [0, 1]. */ private get angleRSquared() { - // https://www.ebi.ac.uk/thornton-srv/software/PROCHECK/nmr_manual/man_cv.html may be a useful - // reference for this tidbit. The Wikipedia article's more dense... not that this link isn't - // a bit dense itself. + // Refer to https://en.wikipedia.org/wiki/Directional_statistics#Distribution_of_the_mean. + // We're computing the squared value of that page's R-bar stat. + // + // Now, why it's called that? ... good question. My best guess is that it's meant to + // correspond to linear regression's 'r' stat, which when squared serves as the + // coefficient of determination for the regression. Intuitively, that does seem to + // match what this represents - though for normal regressions, the c.o.d isn't normally + // used to compute deviation or variance! const rSquaredBase = this.cosLinearSum * this.cosLinearSum + this.sinLinearSum * this.sinLinearSum; return rSquaredBase / (this.arcSampleCount * this.arcSampleCount); } @@ -667,22 +693,22 @@ namespace com.keyman.osk { // This `likelyState` value is extremely prototyped & just here for reviewer/tester convenience. // It'll need to be developed a bit more fully, but follows my intuitions from development & // testing. - let likelyState = 'unknown'; + let likelyType = 'unknown'; if(this.mean('v') < 80 && this.rawDistance < 12 && this.duration > 0.1) { - likelyState = 'hold'; + likelyType = 'hold'; } else if(this.mean('v') < 80 && this.rawDistance < 6) { - likelyState = 'hold'; + likelyType = 'hold'; } if(this.mean('v') > 400 || (this.mean('v') > 200 && this.duration > 0.1) || this.netDistance > 20) { - likelyState = 'move'; + likelyType = 'move'; } return { angle: this.angle, cardinal: this.cardinalDirection, - likelyType: likelyState, + likelyType: likelyType, speedMean: this.mean('v'), rawDistance: this.rawDistance, duration: this.duration, diff --git a/common/web/gesture-recognizer/src/pathSegmenter.ts b/common/web/gesture-recognizer/src/pathSegmenter.ts index 9afee040dd..9a9357a24e 100644 --- a/common/web/gesture-recognizer/src/pathSegmenter.ts +++ b/common/web/gesture-recognizer/src/pathSegmenter.ts @@ -133,10 +133,6 @@ namespace com.keyman.osk { return 1; } - if(numDoF > 3) { - numDoF = 3; - } - const numIndex = (numDoF > 3 ? 3 : numDoF) - 2; const denomIndex = (denomDoF > 20 ? 20 : denomDoF) - 1; @@ -191,8 +187,8 @@ namespace com.keyman.osk { */ static readonly segmentationComparison = class SegmentedRegression { host: Segmentation; - readonly independent: 'x' | 'y' | 't'; - readonly dependent: 'x' | 'y' | 't'; + readonly independent: PathCoordAxis; + readonly dependent: PathCoordAxis; readonly paired: 'tx' | 'ty' | 'xy'; /** @@ -210,7 +206,7 @@ namespace com.keyman.osk { */ union: typeof CumulativePathStats.regression.prototype; - constructor(host: Segmentation, dependentAxis: 'x' | 'y' | 't', independentAxis: 'x' | 'y' | 't') { + constructor(host: Segmentation, dependentAxis: PathCoordAxis, independentAxis: PathCoordAxis) { if(dependentAxis == independentAxis) { throw "Two different axes must be specified for the regression object."; } @@ -377,7 +373,7 @@ namespace com.keyman.osk { * @param independent * @returns */ - public segReg(dependentAxis: 'x' | 'y' | 't', independentAxis: 'x' | 'y' | 't') { + public segReg(dependentAxis: PathCoordAxis, independentAxis: PathCoordAxis) { return new Segmentation.segmentationComparison(this, dependentAxis, independentAxis); } @@ -392,6 +388,8 @@ namespace com.keyman.osk { const xTest = new Segmentation.segmentationComparison(this, 'x', 't'); const yTest = new Segmentation.segmentationComparison(this, 'y', 't'); + // Our testing thresholds are for p=0.05 and p=0.10, which correspond to certainties of 95% and + // 90% that our segmentation did not arrive from random chance based on the axis being tested. totalThreshold += xTest.certaintyThreshold >= 0.95 ? 2 : (xTest.certaintyThreshold >= 0.90 ? 1 : 0) ; totalThreshold += yTest.certaintyThreshold >= 0.95 ? 2 : (yTest.certaintyThreshold >= 0.90 ? 1 : 0) ; @@ -404,7 +402,7 @@ namespace com.keyman.osk { * maintain the same direction but differ only in observed speed. */ get mergeMerited(): boolean { - // Because of caret-like motions (as in, in the '^' shape), we need to test for + // Because of caret-like motions (as in, in the '^' shape), we need to text for // regression on both axes. One may have notably higher variance than the other. // // These tests ignore time, and therefore speed. Only the raw geometry of the motion