mirror of
https://github.com/keymanapp/keyman.git
synced 2026-09-16 04:37:41 +00:00
Merge pull request #7058 from keymanapp/feat/web/touchpath-segmentation
feat(web): gesture touchpath segmentation core 🐵
This commit is contained in:
commit
c2e846524b
6 changed files with 1729 additions and 2 deletions
746
common/web/gesture-recognizer/src/cumulativePathStats.ts
Normal file
746
common/web/gesture-recognizer/src/cumulativePathStats.ts
Normal file
|
|
@ -0,0 +1,746 @@
|
|||
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';
|
||||
|
||||
/**
|
||||
* Acts as a subtraction operation with a built-in, adaptive "significance" check.
|
||||
* If the result * 2^30 (~ * 10^9) is still smaller in magnitude than an operand, we
|
||||
* assume it to be a floating-point error that should have been 0 and act accordingly,
|
||||
* returning 0.
|
||||
*
|
||||
* For reference, (32-bit) floats have 23 bits of significand precision, while (64-bit)
|
||||
* doubles have 52. Therefore, we'll still be more precise than baseline floats.
|
||||
*/
|
||||
function sigMinus(operand1: number, operand2: number) {
|
||||
const diff = operand1 - operand2;
|
||||
|
||||
const logDiff = Math.log2(Math.abs(operand1)) - Math.log2(Math.abs(operand2));
|
||||
// If an operand is 2^30 (or ~10^9) larger than the result of the difference, it's
|
||||
// nigh-certainly a floating-point error at play.
|
||||
return logDiff < 30 ? diff : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* As the name suggests, this class facilitates tracking of cumulative mathematical values, etc
|
||||
* necessary to perform the statistical operations necessary for path segmentation.
|
||||
*
|
||||
* Instances of this class are immutable.
|
||||
*/
|
||||
export class CumulativePathStats {
|
||||
/**
|
||||
* 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: PathCoordAxis;
|
||||
readonly dependent: PathCoordAxis;
|
||||
readonly paired: PathCoordAxisPair;
|
||||
|
||||
readonly accumulator: CumulativePathStats;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param mainStats The `CumulativePathStats` instance to base all regression data on.
|
||||
* @param dependentAxis The 'output' axis / dimension; the axis whose behavior should be predicted based on
|
||||
* existing data of its relationship with the independent axis.
|
||||
* @param independentAxis The 'input' axis/dimension.
|
||||
*/
|
||||
constructor(mainStats: CumulativePathStats, dependentAxis: PathCoordAxis, independentAxis: PathCoordAxis) {
|
||||
if(dependentAxis == independentAxis) {
|
||||
throw "Two different axes must be specified for the regression object.";
|
||||
}
|
||||
|
||||
this.accumulator = mainStats;
|
||||
|
||||
this.dependent = dependentAxis;
|
||||
this.independent = independentAxis;
|
||||
|
||||
if(dependentAxis < independentAxis) {
|
||||
this.paired = dependentAxis.concat(independentAxis) as PathCoordAxisPair;
|
||||
} else {
|
||||
this.paired = independentAxis.concat(dependentAxis) as PathCoordAxisPair;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The 'slope' of the 'slope-intercept' form of the line that best fits the relationship between
|
||||
* this regression's selected axes.
|
||||
*/
|
||||
get slope(): number {
|
||||
// The technical definition is the commented-out line, but the denominator component of both
|
||||
// cancels out - it's 'more efficient' to use the following line as a result.
|
||||
|
||||
// this.accumulator.covariance(this.paired) / this.accumulator.variance(this.independent);
|
||||
const val = this.accumulator.crossSum(this.paired) / this.accumulator.squaredSum(this.independent);
|
||||
return val;
|
||||
}
|
||||
|
||||
/**
|
||||
* The 'intercept' of the 'slope-intercept' form of the line that best fits the relationship between
|
||||
* this regression's selected axes.
|
||||
*/
|
||||
get intercept(): number {
|
||||
// Performing a regression based on these pre-summed values means that our obtained intercept is in
|
||||
// the mapped coordinate system.
|
||||
const mappedIntercept = this.accumulator.mappedMean(this.dependent) - this.slope * this.accumulator.mappedMean(this.independent);
|
||||
|
||||
const val = mappedIntercept + this.accumulator.mappingConstant(this.dependent) -
|
||||
this.slope * this.accumulator.mappingConstant(this.independent);
|
||||
|
||||
return val;
|
||||
}
|
||||
|
||||
/**
|
||||
* The total summed squared-distances of the best fitting line from actually-observed values;
|
||||
* in other words, the "sum of the squared errors".
|
||||
*
|
||||
* Statistically, this is the portion of the dependent variable's variance (un-normalized)
|
||||
* that is unexplained by this regression.
|
||||
*/
|
||||
get sumOfSquaredError(): number {
|
||||
return this.accumulator.squaredSum(this.dependent) - this.sumOfSquaredModeled;
|
||||
}
|
||||
|
||||
/**
|
||||
* The portion of the dependent variable's variance that is successfully explained by this regression.
|
||||
*/
|
||||
get sumOfSquaredModeled(): number {
|
||||
// If we have a perfectly straight vertical line, from the perspective of our independent axis,
|
||||
// we get infinite slope. That's... not great for the math.
|
||||
//
|
||||
// Fortunately, it ALSO means that we can perfectly model the segment.
|
||||
if(this.accumulator.squaredSum(this.independent) == 0) {
|
||||
return this.accumulator.squaredSum(this.dependent);
|
||||
} else {
|
||||
return this.slope * this.accumulator.crossSum(this.paired);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A statistical term that signals how successful the regression is. Always has values on
|
||||
* the interval [0, 1], with 1 being a perfect fit.
|
||||
*/
|
||||
get coefficientOfDetermination(): number {
|
||||
if(this.accumulator.squaredSum(this.dependent) == 0 || this.accumulator.squaredSum(this.independent) == 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const acc = this.accumulator;
|
||||
const num = acc.crossSum(this.paired) * acc.crossSum(this.paired);
|
||||
const denom = acc.squaredSum(this.dependent) * acc.squaredSum(this.independent);
|
||||
|
||||
return num / denom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the value of the dependent axis that lies on the regression's fitted line
|
||||
* for a specified independent axis value.
|
||||
*
|
||||
* @param value The input value to use for the independent axis's variable.
|
||||
* @returns The predicted dependent axis value.
|
||||
*/
|
||||
predictFromValue(value: number) {
|
||||
return this.slope * value + this.intercept;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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': 0, 'y': 0, 't': 0, 'v': 0};
|
||||
private rawSquaredSums = {'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?
|
||||
// If so, maybe throw that in.
|
||||
private rawCrossSums = {'tx': 0, 'ty': 0, 'xy': 0};
|
||||
|
||||
private coordArcSum: number = 0;
|
||||
private arcSampleCount: number = 0;
|
||||
|
||||
// These two are kept separate because of their extreme interconnectedness - after all,
|
||||
// they actually represent a SINGLE (polar) axis - the angle.
|
||||
//
|
||||
// Sadly, there's no straightforward, well-founded way to use these to give a proper
|
||||
// statistical sense of 'fit' or 'regression' here, _especially_ in regard to segmentation.
|
||||
// Proper angle-[other] cross-sums are pretty much impossible, at least as efficiently
|
||||
// as the others are handled [O(1)].
|
||||
private cosLinearSum: number = 0;
|
||||
private sinLinearSum: number = 0;
|
||||
|
||||
/**
|
||||
* The base sample used to transpose all other received samples. Use of this helps
|
||||
* avoid potential "catastrophic cancellation" effects that can occur when diffing
|
||||
* two numbers far from the sample-space's mathematical origin.
|
||||
*
|
||||
* Refer to https://en.wikipedia.org/wiki/Catastrophic_cancellation.
|
||||
*/
|
||||
private baseSample?: InputSample;
|
||||
|
||||
/**
|
||||
* The initial sample included by this instance's computed stats. Needed for
|
||||
* the 'directness' properties.
|
||||
*/
|
||||
private initialSample?: InputSample;
|
||||
|
||||
private _lastSample?: InputSample;
|
||||
private followingSample?: InputSample;
|
||||
private _sampleCount = 0;
|
||||
|
||||
constructor();
|
||||
constructor(sample: InputSample);
|
||||
constructor(instance: CumulativePathStats);
|
||||
constructor(obj?: InputSample | CumulativePathStats) {
|
||||
if(!obj) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Will worry about JSON form later.
|
||||
if(obj instanceof CumulativePathStats) {
|
||||
Object.assign(this, obj);
|
||||
|
||||
this.rawLinearSums = {...obj.rawLinearSums};
|
||||
this.rawCrossSums = {...obj.rawCrossSums};
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Statistically "observes" a new sample point on the touchpath, accumulating values
|
||||
* useful for provision of relevant statistical properties.
|
||||
* @param sample A newly-sampled point on the touchpath.
|
||||
* @returns A new, separate instance for the cumulative properties up to the
|
||||
* newly-sampled point.
|
||||
*/
|
||||
public extend(sample: InputSample): CumulativePathStats {
|
||||
if(!this.initialSample) {
|
||||
this.initialSample = sample;
|
||||
this.baseSample = sample;
|
||||
}
|
||||
const result = new CumulativePathStats(this);
|
||||
|
||||
// Set _after_ deep-copying this for the result.
|
||||
this.followingSample = sample;
|
||||
|
||||
// Helps prevent "catastrophic cancellation" issues from floating-point computation
|
||||
// for these statistical properties and properties based upon them.
|
||||
const x = sample.targetX - this.baseSample.targetX;
|
||||
const y = sample.targetY - this.baseSample.targetY;
|
||||
const t = sample.t - this.baseSample.t;
|
||||
|
||||
result.rawLinearSums.x += x;
|
||||
result.rawLinearSums.y += y;
|
||||
result.rawLinearSums.t += t;
|
||||
|
||||
result.rawCrossSums.tx += t * x;
|
||||
result.rawCrossSums.ty += t * y;
|
||||
result.rawCrossSums.xy += x * y;
|
||||
|
||||
result.rawSquaredSums.x += x * x;
|
||||
result.rawSquaredSums.y += y * y;
|
||||
result.rawSquaredSums.t += t * t;
|
||||
|
||||
if(this.lastSample) {
|
||||
// arc length stuff!
|
||||
const xDelta = sample.targetX - this.lastSample.targetX;
|
||||
const yDelta = sample.targetY - this.lastSample.targetY;
|
||||
const tDelta = sample.t - this.lastSample.t;
|
||||
|
||||
const coordArcDeltaSq = xDelta * xDelta + yDelta * yDelta;
|
||||
const coordArcDelta = Math.sqrt(coordArcDeltaSq);
|
||||
|
||||
result.coordArcSum += coordArcDelta;
|
||||
|
||||
if(xDelta || yDelta) {
|
||||
// We wish to measure angle clockwise from <0, -1> in the DOM. So, cos values should
|
||||
// align with that axis, while sin values should align with the positive x-axis.
|
||||
//
|
||||
// This provides a mathematical 'transformation' to the axes used by `atan2` in the
|
||||
// `angleMean` property.
|
||||
result.cosLinearSum += -yDelta / coordArcDelta;
|
||||
result.sinLinearSum += xDelta / coordArcDelta;
|
||||
result.arcSampleCount += 1;
|
||||
}
|
||||
|
||||
if(tDelta) {
|
||||
result.rawLinearSums.v += coordArcDelta / tDelta;
|
||||
result.rawSquaredSums.v += coordArcDeltaSq / (tDelta * tDelta);
|
||||
}
|
||||
}
|
||||
|
||||
result._lastSample = sample;
|
||||
result.sampleCount = this.sampleCount + 1;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* "De-accumulates" currently-accumulated values corresponding to the specified
|
||||
* subset, which should represent an earlier, previously-observed part of the path.
|
||||
* @param subsetStats The accumulated stats for the part of the path being removed
|
||||
* from this instance's current accumulation.
|
||||
* @returns
|
||||
*/
|
||||
public deaccumulate(subsetStats?: CumulativePathStats): CumulativePathStats {
|
||||
// Possible addition: use `this.buildRenormalized` on the returned version
|
||||
// if catastrophic cancellation effects (random, small floating point errors)
|
||||
// are not sufficiently mitigated & handled by the measures currently in place.
|
||||
//
|
||||
// Even then, we'd need to apply such generated objects carefully - we can't
|
||||
// re-merge the accumulated values or remap them to their old coordinate system
|
||||
// afterward.`buildRenormalize`'s remapping maneuver is a one-way stats-abuse trick.
|
||||
//
|
||||
// Hint: we'd need to pay attention to the "lingering segments" aspects in which
|
||||
// detected sub-segments might be "re-merged".
|
||||
// - Whenever they're merged & cleared, we should be clear to recentralize
|
||||
// the cumulative stats that follow. If any are still active, we can't
|
||||
// recentralize.
|
||||
|
||||
const result = new CumulativePathStats(this);
|
||||
|
||||
// We actually WILL accept a `null` argument; makes some of the segmentation
|
||||
// logic simpler.
|
||||
if(!subsetStats) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if(!subsetStats.followingSample || !subsetStats.lastSample) {
|
||||
throw 'Invalid argument: stats missing necessary tracking variable.';
|
||||
}
|
||||
|
||||
for(let dim in result.rawLinearSums) {
|
||||
result.rawLinearSums[dim] -= subsetStats.rawLinearSums[dim];
|
||||
}
|
||||
|
||||
for(let dimPair in result.rawCrossSums) {
|
||||
result.rawCrossSums[dimPair] -= subsetStats.rawCrossSums[dimPair];
|
||||
}
|
||||
|
||||
for(let dim in result.rawSquaredSums) {
|
||||
result.rawSquaredSums[dim] -= subsetStats.rawSquaredSums[dim];
|
||||
}
|
||||
|
||||
// arc length stuff!
|
||||
if(subsetStats.followingSample && subsetStats.lastSample) {
|
||||
const xDelta = subsetStats.followingSample.targetX - subsetStats.lastSample.targetX;
|
||||
const yDelta = subsetStats.followingSample.targetY - subsetStats.lastSample.targetY;
|
||||
const tDelta = subsetStats.followingSample.t - subsetStats.lastSample.t;
|
||||
|
||||
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 -= coordArcDelta;
|
||||
result.coordArcSum -= subsetStats.coordArcSum;
|
||||
|
||||
result.cosLinearSum -= subsetStats.cosLinearSum;
|
||||
result.sinLinearSum -= subsetStats.sinLinearSum;
|
||||
result.arcSampleCount -= subsetStats.arcSampleCount;
|
||||
|
||||
if(tDelta) {
|
||||
result.rawLinearSums.v -= coordArcDelta / tDelta;
|
||||
result.rawSquaredSums.v -= coordArcDeltaSq / (tDelta * tDelta);
|
||||
}
|
||||
}
|
||||
|
||||
result.sampleCount -= subsetStats.sampleCount;
|
||||
|
||||
// NOTE: baseSample MUST REMAIN THE SAME. All math is based on the corresponding diff.
|
||||
// Though... very long touchpoint interactions could start being affected by that "catastrophic
|
||||
// cancellation" effect without further adjustment. (If it matters, we'll get to that later.)
|
||||
// But _probably_ not; we don't go far beyond a couple of orders of magnitude from the origin in
|
||||
// ANY case except the timestamp (.t) - and even then, not far from the baseSample's timestamp value.
|
||||
|
||||
// initialSample, though, we need to update b/c of the 'directness' properties.
|
||||
result.initialSample = subsetStats.followingSample;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public get lastSample() {
|
||||
return this._lastSample;
|
||||
}
|
||||
|
||||
public get lastTimestamp(): number {
|
||||
return this.lastSample?.t;
|
||||
}
|
||||
|
||||
public get sampleCount() {
|
||||
return this._sampleCount;
|
||||
}
|
||||
|
||||
private set sampleCount(value: number) {
|
||||
this._sampleCount = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* In order to mitigate the accumulation of small floating-point errors during the
|
||||
* various accumulations performed by this class, the domain of incoming values
|
||||
* is remapped near to the origin via axis-specific mapping constants.
|
||||
* @param dim
|
||||
* @returns
|
||||
*/
|
||||
private mappingConstant(dim: StatAxis) {
|
||||
if(!this.baseSample) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if(dim == 't') {
|
||||
return this.baseSample.t;
|
||||
} else if(dim == 'x') {
|
||||
return this.baseSample.targetX;
|
||||
} else if(dim == 'y') {
|
||||
return this.baseSample.targetY;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the statistical mean, utilizing the internal 'mapped' coordinate space.
|
||||
* This is the version compatible with cross-sums and squared-sums.
|
||||
* @param dim
|
||||
* @returns
|
||||
*/
|
||||
private mappedMean(dim: StatAxis) {
|
||||
return this.rawLinearSums[dim] / this.sampleCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the statistical mean value of the samples observed during the represented
|
||||
* interval on the specified axis.
|
||||
* @param dim
|
||||
* @returns
|
||||
*/
|
||||
public mean(dim: StatAxis) {
|
||||
// This external-facing version needs to provide values in 'external'-friendly
|
||||
// coordinate space.
|
||||
return this.mappedMean(dim) + this.mappingConstant(dim);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the sum of the squared distance from the mean seen in samples observed
|
||||
* during the represented interval on the specified axis.
|
||||
* @param dim
|
||||
* @returns
|
||||
*/
|
||||
public squaredSum(dim: StatAxis) {
|
||||
const x2 = this.rawSquaredSums[dim];
|
||||
const x1 = this.rawLinearSums[dim];
|
||||
|
||||
return sigMinus(x2, x1 * x1 / this.sampleCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the sum of the statistical 'cross' term "distance" away from the mean
|
||||
* observed during the represented interval on the specified axis.
|
||||
* @param dimPair
|
||||
* @returns
|
||||
*/
|
||||
public crossSum(dimPair: PathCoordAxisPair) {
|
||||
const dim1 = dimPair.charAt(0);
|
||||
const dim2 = dimPair.charAt(1);
|
||||
|
||||
let orderedDims: string = dimPair;
|
||||
|
||||
const ab = this.rawCrossSums[orderedDims];
|
||||
const a = this.rawLinearSums[dim1];
|
||||
const b = this.rawLinearSums[dim2];
|
||||
|
||||
return sigMinus(ab, a * b / this.sampleCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the unbiased covariance between the specified pair of axes for samples
|
||||
* observed during the represented interval.
|
||||
* @param dimPair
|
||||
* @returns
|
||||
*/
|
||||
public covariance(dimPair: PathCoordAxisPair) {
|
||||
return this.crossSum(dimPair) / (this.sampleCount - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the unbiased variance on the specified axis for samples observed
|
||||
* during the represented interval.
|
||||
*/
|
||||
public variance(dim: StatAxis) {
|
||||
return this.squaredSum(dim) / (this.sampleCount - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utilizing (and possibly abusing) statistical identities, this function produces
|
||||
* an equivalent, but-recentered copy of this instance's statistical accumulations
|
||||
* that will be less prone to catastrophic cancellation.
|
||||
*
|
||||
* In non-stats speak, the new instance will suffer smaller floating-point
|
||||
* errors than the old instance whenever they do occur.
|
||||
* @returns
|
||||
*/
|
||||
public buildRenormalized(): CumulativePathStats {
|
||||
// Other (internal) notes: the internal mapping of the new instance will not
|
||||
// match that of the old instance. This should not affect the practical
|
||||
// results of any mapping to and from the external coordinate space, however.
|
||||
let result = new CumulativePathStats(this);
|
||||
|
||||
let newBase: InputSample = {
|
||||
targetX: this.mappedMean('x') + this.baseSample.targetX,
|
||||
targetY: this.mappedMean('y') + this.baseSample.targetY,
|
||||
t: this.mappedMean('t') + this.baseSample.t
|
||||
};
|
||||
|
||||
result.baseSample = newBase;
|
||||
|
||||
for(const dimPair in result.rawCrossSums) {
|
||||
result.rawCrossSums[dimPair] = this.crossSum(dimPair as PathCoordAxisPair);
|
||||
}
|
||||
|
||||
for(const dim in result.rawSquaredSums) {
|
||||
// 'v' does not need renormalization.
|
||||
if(dim == 'v') {
|
||||
break;
|
||||
}
|
||||
|
||||
// The identity we're using to renormalize rawCrossSums and rawSquaredSums
|
||||
// automatically guarantees a mean of 0 after the renormalization.
|
||||
result.rawLinearSums[dim] = 0;
|
||||
result.rawSquaredSums[dim] = this.squaredSum(dim as PathCoordAxis);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a linear-regression perspective on two specified axes over the represented
|
||||
* interval.
|
||||
* @param dependent
|
||||
* @param independent
|
||||
* @returns
|
||||
*/
|
||||
public fitRegression(dependent: PathCoordAxis, independent: PathCoordAxis) {
|
||||
return new CumulativePathStats.regression(this, dependent, independent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the direct Euclidean distance between the start and end points of the segment
|
||||
* (or curve) of the interval represented by this instance.
|
||||
*
|
||||
* This will likely not match the actual pixel distance traveled.
|
||||
*/
|
||||
public get netDistance() {
|
||||
// No issue with a net distance of 0 due to a single point.
|
||||
if(!this.lastSample || !this.initialSample) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const xDelta = this.lastSample.targetX - this.initialSample.targetX;
|
||||
const yDelta = this.lastSample.targetY - this.initialSample.targetY;
|
||||
|
||||
return Math.sqrt(xDelta * xDelta + yDelta * yDelta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the duration of the represented interval in milliseconds.
|
||||
*/
|
||||
public get duration() {
|
||||
// no issue with a duration of zero from just one sample.
|
||||
if(!this.lastSample || !this.initialSample) {
|
||||
return 0;
|
||||
}
|
||||
return (this.lastSample.t - this.initialSample.t);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the angle (in radians) traveled by the corresponding segment clockwise
|
||||
* from the unit vector <0, -1> in the DOM (the unit "upward" direction).
|
||||
*/
|
||||
public get angle() {
|
||||
if(this.sampleCount == 1 || !this.lastSample || !this.initialSample) {
|
||||
return Number.NaN;
|
||||
} else if(this.netDistance < 1) {
|
||||
// < 1 px, thus sub-pixel, means we have nothing relevant enough to base an angle on.
|
||||
return Number.NaN;
|
||||
}
|
||||
|
||||
const xDelta = this.lastSample.targetX - this.initialSample.targetX;
|
||||
const yDelta = this.lastSample.targetY - this.initialSample.targetY;
|
||||
const yAngleDiff = Math.acos(-yDelta / this.netDistance);
|
||||
|
||||
return xDelta < 0 ? (2 * Math.PI - yAngleDiff) : yAngleDiff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the angle (in degrees) traveled by the corresponding segment clockwise
|
||||
* from the unit vector <0, -1> in the DOM (the unit "upward" direction).
|
||||
*/
|
||||
public get angleInDegrees() {
|
||||
return this.angle * 180 / Math.PI;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cardinal or intercardinal direction on the screen that most
|
||||
* closely matches the direction of movement represented by the represented
|
||||
* segment.
|
||||
*
|
||||
* @return A string one or two letters in length (e.g: 'n', 'sw'), or
|
||||
`undefined` if not enough data to determine a direction.
|
||||
*/
|
||||
public get cardinalDirection() {
|
||||
if(this.sampleCount == 1 || !this.lastSample || !this.initialSample) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const buckets = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw', '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];
|
||||
}
|
||||
|
||||
/**
|
||||
* Measured in pixels per second.
|
||||
* @return a speed in pixels per millisecond, or `Number.NaN` if no data
|
||||
*/
|
||||
public get speed() {
|
||||
return this.duration ? this.netDistance / this.duration : Number.NaN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the represented interval's 'mean angle' clockwise from the DOM's
|
||||
* <0, -1> (the unit vector toward the top of the screen) in radians.
|
||||
*
|
||||
* Based upon the 'circular mean'. Refer to https://en.wikipedia.org/wiki/Circular_mean.
|
||||
*
|
||||
* Note that very slow-moving segments may be heavily affected by pixel aliasing
|
||||
* effects; mouse and touch events usually do not provide sub-pixel resolution.
|
||||
*/
|
||||
public get angleMean() {
|
||||
if(this.arcSampleCount == 0) {
|
||||
return Number.NaN;
|
||||
}
|
||||
|
||||
// Neato reference: https://rosettacode.org/wiki/Averages/Mean_angle
|
||||
// But we don't actually need to divide by sample count; `atan2` handles that!
|
||||
const sinMean = this.sinLinearSum;
|
||||
const cosMean = this.cosLinearSum;
|
||||
|
||||
let angle = Math.atan2(sinMean, cosMean); // result: on the interval (-pi, pi]
|
||||
// Convert to [0, 2*pi).
|
||||
if(angle < 0) {
|
||||
angle = angle + 2 * Math.PI;
|
||||
}
|
||||
|
||||
return angle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the rSquared value needed internally for circular-statistic properties.
|
||||
*
|
||||
* Range: floating-point values on the interval [0, 1].
|
||||
*/
|
||||
private get angleRSquared() {
|
||||
// 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* The **circular standard deviation** of the represented interval's angle observations.
|
||||
*
|
||||
* Refer to https://en.wikipedia.org/wiki/Directional_statistics#Standard_deviation.
|
||||
*
|
||||
* Note that very slow-moving segments may be heavily affected by pixel aliasing
|
||||
* effects; mouse and touch events usually do not provide sub-pixel resolution.
|
||||
* This can result in very high deviation values.
|
||||
*
|
||||
* In less-technical terms - the "stair-stepping" effect seen on high zoom levels means we don't
|
||||
* get perfectly straight lines, and that can cause this value to be unexpectedly high.
|
||||
*/
|
||||
public get angleDeviation() {
|
||||
if(this.arcSampleCount == 0) {
|
||||
return Number.NaN;
|
||||
}
|
||||
|
||||
return Math.sqrt(-Math.log(this.angleRSquared));
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the actual, pixel-based distance actually traveled by the represented segment.
|
||||
* May not be an integer (because diagonals are a thing).
|
||||
*/
|
||||
public get rawDistance() {
|
||||
return this.coordArcSum;
|
||||
}
|
||||
|
||||
// Convert to a `toJSON` method for use during investigative debugging.
|
||||
private toDebuggingJSON() {
|
||||
// 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 likelyType = 'unknown';
|
||||
|
||||
if(this.mean('v') < 0.08 && this.rawDistance < 12 && this.duration > 100) {
|
||||
likelyType = 'hold';
|
||||
} else if(this.mean('v') < 0.08 && this.rawDistance < 6) {
|
||||
likelyType = 'hold';
|
||||
}
|
||||
|
||||
if(this.mean('v') > 0.4 || (this.mean('v') > 0.2 && this.duration > 80) || this.netDistance > 20) {
|
||||
likelyType = 'move';
|
||||
}
|
||||
|
||||
return {
|
||||
angle: this.angle,
|
||||
cardinal: this.cardinalDirection,
|
||||
likelyType: likelyType,
|
||||
speedMean: this.mean('v'),
|
||||
rawDistance: this.rawDistance,
|
||||
duration: this.duration,
|
||||
sampleCount: this.sampleCount,
|
||||
angleMeanDegrees: this.angleMean * 180 / Math.PI,
|
||||
angleDeviation: this.angleDeviation,
|
||||
speedVariance: this.variance('v')
|
||||
}
|
||||
}
|
||||
|
||||
public toJSON() {
|
||||
// We're not actually saving the JSON out to anything yet or loading/parsing it
|
||||
// for any use beyond direct human interpretation, so... it's "okay" to
|
||||
// leave like this for now.
|
||||
return this.toDebuggingJSON();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -36,4 +36,8 @@ namespace com.keyman.osk {
|
|||
}
|
||||
|
||||
export type InputSampleSequence = InputSample[];
|
||||
|
||||
export function isAnInputSample(obj: any): obj is InputSample {
|
||||
return 'targetX' in obj && 'targetY' in obj && 't' in obj;
|
||||
}
|
||||
}
|
||||
955
common/web/gesture-recognizer/src/pathSegmenter.ts
Normal file
955
common/web/gesture-recognizer/src/pathSegmenter.ts
Normal file
|
|
@ -0,0 +1,955 @@
|
|||
/// <reference path="cumulativePathStats.ts" />
|
||||
|
||||
/* FIXME: Too much `console`.
|
||||
*
|
||||
* There's an AWFUL LOT of `console.log`-ing in this file at present.
|
||||
* It was definitely useful for prototyping & development of its (and
|
||||
* of `cumulativePathStats`'s) code, but it's GOTTA be cleaned up
|
||||
* in the next PR... if not sooner.
|
||||
*/
|
||||
|
||||
namespace com.keyman.osk {
|
||||
// Note: the only `export`-ed class from this file is `PathSegmenter`, hence the helper
|
||||
// classes being placed within the same file. Not to say we _couldn't_ put them elsewhere.
|
||||
|
||||
// ------------
|
||||
|
||||
// Mostly used here to compare the sum-squared error components of segmented regressions
|
||||
// to the sum-squared "modeled" components of their overall variance. Those are the two
|
||||
// "independent random variables" we're examining. Variances (which are sums of squared
|
||||
// values themselves) tend to be chi-squared distributed, fulfilling the conditions.
|
||||
// That's stats-speak for "what this module does with it has a solid theoretical basis."
|
||||
//
|
||||
// We do NOT want to be computing this thing at run-time. (Just take one look at the
|
||||
// equations found in that Wikipedia article!) Fortunately, it's very common in stats
|
||||
// circles to just... use a lookup table for finding p-values for things following
|
||||
// the f-distribution.
|
||||
//
|
||||
// For future non-stats-inclined maintainers:
|
||||
// One could say that the p-value = the chance that what we have just "randomly" happened
|
||||
// to occur without our expectations actually being met. It's a super-common
|
||||
// stats term; they'd then say that if the p-value is sufficiently low, then we\
|
||||
// "reject the null hypothesis" - the theory that it actually DID happen randomly - in
|
||||
// favor of the high likelihood that we really are onto something.
|
||||
|
||||
/**
|
||||
* Acts as a lookup table for null-hypothesis tests utilizing the F-distribution from the
|
||||
* domain of statistics. Refer to https://en.wikipedia.org/wiki/F-distribution for details
|
||||
* on its properties.
|
||||
*
|
||||
* This is only a partial implementation; only a couple of numerator degrees-of-freedom are
|
||||
* worth our consideration, and there's only marginal gain to be found supporting more
|
||||
* denominator degrees-of-freedom than are already supported here. Keeping it small also
|
||||
* helps with maintenance and readability.
|
||||
*/
|
||||
class FDistribution {
|
||||
|
||||
/**
|
||||
* Indexing: [threshold-index][num-2][denom-1].
|
||||
*
|
||||
* If numerator has only 1 degree of freedom, that means we don't really have
|
||||
* segments so much as different averages between the two segments - there's
|
||||
* no slope on either side. In other words, "just a bump". No segmentation
|
||||
* there.
|
||||
*
|
||||
* 2 degrees of freedom: no movement on the axis on only one side.
|
||||
*
|
||||
* threshold-index:
|
||||
* - p-value of .100: 0
|
||||
* - p-value of .050: 1
|
||||
*
|
||||
* The f-statistic value must match or exceed its corresponding entry in
|
||||
* the table below, based on the detected DoF (degrees of freedom) for the
|
||||
* 'numerator' and 'denominator' components. Exceeding the threshold specified
|
||||
* for the p = .100 section indicates less than a 10% probability of the statistic
|
||||
* being tested having arisen by purely random chance. Failure to exceed even
|
||||
* that implies an unacceptably high chance of being "convenient but meaningless" -
|
||||
* the "null hypothesis", in stats speak.
|
||||
*/
|
||||
private static readonly table = [
|
||||
// p = .100
|
||||
[
|
||||
// numerator: 2
|
||||
[
|
||||
// denom: 1-10
|
||||
49.50, 9.00, 5.46, 4.32, 3.78, 3.46, 3.26, 3.11, 3.01, 2.92,
|
||||
// denom: 11-20
|
||||
2.86, 2.81, 2.76, 2.73, 2.70, 2.67, 2.64, 2.62, 2.61, 2.59
|
||||
// we COULD continue with threshold-dropping after that,
|
||||
// but I doubt we'll need it in practicality.
|
||||
// At 30: 2.49
|
||||
// limit -> infinity: 2.30
|
||||
],
|
||||
// numerator: 3
|
||||
[
|
||||
// denom: 1-10
|
||||
53.59, 9.16, 5.39, 4.19, 3.62, 3.29, 3.07, 2.92, 2.81, 2.73,
|
||||
// denom: 11-20
|
||||
2.66, 2.61, 2.56, 2.52, 2.49, 2.46, 2.44, 2.42, 2.40, 2.38
|
||||
// Likewise, from above.
|
||||
// At 30: 2.28
|
||||
// limit -> infinity: 2.08
|
||||
]
|
||||
],
|
||||
// p = .050
|
||||
[
|
||||
// numerator: 2
|
||||
[
|
||||
// denom: 1-10
|
||||
199.5, 19.0, 9.55, 6.94, 5.79, 5.14, 4.74, 4.46, 4.26, 4.10,
|
||||
// denom: 10-20
|
||||
3.98, 3.89, 3.81, 3.74, 3.68, 3.63, 3.59, 3.55, 3.52, 3.49
|
||||
// At 30: 3.32
|
||||
// limit -> infinity: 3.00
|
||||
],
|
||||
// numerator: 3
|
||||
[
|
||||
// denom: 1-10
|
||||
215.7,19.16, 9.28, 6.59, 5.41, 4.76, 4.35, 4.07, 3.86, 3.71,
|
||||
// denom: 10-20
|
||||
3.59, 3.49, 3.41, 3.34, 3.29, 3.24, 3.20, 3.16, 3.13, 3.10
|
||||
// At 30: 2.92
|
||||
// limit -> infinity: 2.60
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
/**
|
||||
* Determines a threshold tier for segmentation based on the segmented
|
||||
* regression f-statistic.
|
||||
* @param statistic
|
||||
* @param numDoF
|
||||
* @param denomDoF
|
||||
*
|
||||
* Returns 0.050 if the statistic indicates a p-value of 0.050 or lower.
|
||||
* Failing that, returns 0.100 if a p-value of 0.100 or lower is indicated.
|
||||
* Otherwise, returns 1.0 - we have no basis to "reject the null hypothesis".
|
||||
*/
|
||||
static thresholdTier(statistic: number, numDoF: number, denomDoF: number) {
|
||||
// Former case: we'd never segment anyway
|
||||
// Latter case: it's currently wrong to statistically test.
|
||||
// The F-distribution is not defined for this case.
|
||||
if(numDoF < 2 || denomDoF < 1) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const numIndex = (numDoF > 3 ? 3 : numDoF) - 2;
|
||||
const denomIndex = (denomDoF > 20 ? 20 : denomDoF) - 1;
|
||||
|
||||
const tier2Threshold = FDistribution.table[1][numIndex][denomIndex];
|
||||
if(statistic >= tier2Threshold) {
|
||||
return 0.05;
|
||||
}
|
||||
|
||||
const tier1Threshold = FDistribution.table[0][numIndex][denomIndex];
|
||||
// If arising purely randomly isn't at least less than 10% likely,
|
||||
// we'll categorically say it happened randomly with full certainty (1).
|
||||
// Obviously not actually true, but it works for our thresholding.
|
||||
return statistic >= tier1Threshold ? 0.10 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the result of a segmentation of the search path and the related
|
||||
* statistical accumulations needed to properly test the segmentation's
|
||||
* validity.
|
||||
*/
|
||||
class Segmentation {
|
||||
public static readonly SPLIT_CRITERION_THRESHOLD = 1.5;
|
||||
|
||||
/**
|
||||
* The properties of the "left-hand" / earlier half of the time interval
|
||||
* being segmented.
|
||||
*/
|
||||
readonly pre: CumulativePathStats;
|
||||
|
||||
/**
|
||||
* The properties of the "right-hand" / later half of the time interval
|
||||
* being segmented.
|
||||
*/
|
||||
readonly post: CumulativePathStats;
|
||||
|
||||
/**
|
||||
* The properties of the full interval being segmented, before the split.
|
||||
*/
|
||||
readonly union: CumulativePathStats;
|
||||
|
||||
/**
|
||||
* The full running accumulation for the ongoing touch path. May include
|
||||
* accumulations from before the interval under examination.
|
||||
*/
|
||||
readonly endpoint: CumulativePathStats;
|
||||
|
||||
/**
|
||||
* Provides segmented-regression statistics & fitting values on the two specified axes /
|
||||
* dimensions based on the subsegments and their corresponding linear-regression
|
||||
* statistics. All operations are O(1).
|
||||
*/
|
||||
static readonly segmentationComparison = class SegmentedRegression {
|
||||
host: Segmentation;
|
||||
readonly independent: PathCoordAxis;
|
||||
readonly dependent: PathCoordAxis;
|
||||
readonly paired: 'tx' | 'ty' | 'xy';
|
||||
|
||||
/**
|
||||
* The linear regression for the underlying `pre` (earlier) subsegment.
|
||||
*/
|
||||
pre: typeof CumulativePathStats.regression.prototype;
|
||||
|
||||
/**
|
||||
* The linear regression for the underlying `post` (later) subsegment.
|
||||
*/
|
||||
post: typeof CumulativePathStats.regression.prototype;
|
||||
|
||||
/**
|
||||
* The linear, unsegmented regression for the underlying `union` (combined) subsegment.
|
||||
*/
|
||||
union: typeof CumulativePathStats.regression.prototype;
|
||||
|
||||
constructor(host: Segmentation, dependentAxis: PathCoordAxis, independentAxis: PathCoordAxis) {
|
||||
if(dependentAxis == independentAxis) {
|
||||
throw "Two different axes must be specified for the regression object.";
|
||||
}
|
||||
|
||||
this.host = host;
|
||||
|
||||
this.dependent = dependentAxis;
|
||||
this.independent = independentAxis;
|
||||
|
||||
if(dependentAxis < independentAxis) {
|
||||
this.paired = dependentAxis.concat(independentAxis) as 'tx' | 'ty' | 'xy';
|
||||
} else {
|
||||
this.paired = independentAxis.concat(dependentAxis) as 'tx' | 'ty' | 'xy';
|
||||
}
|
||||
|
||||
this.pre = this.host.pre .fitRegression(dependentAxis, independentAxis);
|
||||
this.post = this.host.post .fitRegression(dependentAxis, independentAxis);
|
||||
this.union = this.host.union.fitRegression(dependentAxis, independentAxis);
|
||||
}
|
||||
|
||||
/**
|
||||
* The total summed squared-distances of the best fitting line from actually-observed values
|
||||
* for their corresponding subsegment; in other words, the "sum of the squared errors" when
|
||||
* utilizing segmented regression.
|
||||
*
|
||||
* Statistically, this is the portion of the dependent variable's variance (un-normalized)
|
||||
* that is unexplained even after dividing the interval into separate subsegments.
|
||||
*/
|
||||
get remainingSumSquaredError(): number {
|
||||
return this.pre.sumOfSquaredError + this.post.sumOfSquaredError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the coordinate that belongs to BOTH subsegments, as it ends one and begins
|
||||
* the other.
|
||||
*/
|
||||
private get splitPoint() {
|
||||
let splitPoint = this.host.pre.lastSample;
|
||||
|
||||
let indep = splitPoint.t;
|
||||
if(this.independent == 'x') {
|
||||
indep = splitPoint.targetX;
|
||||
} else if(this.independent == 'y') {
|
||||
indep = splitPoint.targetY;
|
||||
}
|
||||
|
||||
let dep = splitPoint.t;
|
||||
if(this.dependent == 'x') {
|
||||
dep = splitPoint.targetX;
|
||||
} else if(this.dependent == 'y') {
|
||||
dep = splitPoint.targetY;
|
||||
}
|
||||
|
||||
return {
|
||||
dep: dep,
|
||||
indep: indep
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The total summed squared-distances of the best fitting line from actually-observed values
|
||||
* when **not** utilizing segmented regression. Double-counts the subsegment's common point, as
|
||||
* it is counted within both subsegments' sum-of-squared error term.
|
||||
*/
|
||||
get unsegmentedSumSquaredError(): number {
|
||||
// What was our remaining error for the unsegmented regression?
|
||||
let baseSSE = this.union.sumOfSquaredError;
|
||||
|
||||
// We double-count the split point when segmenting, so we should add an
|
||||
// extra copy of its residual.
|
||||
const splitPoint = this.splitPoint;
|
||||
const splitPointError = this.splitPoint.dep - this.union.predictFromValue(splitPoint.indep);
|
||||
|
||||
// This is the total sum-squared error to be handled by segmenting.
|
||||
return baseSSE + splitPointError * splitPointError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the amount of (unnormalized) variance not explained by a standard regression on
|
||||
* the full interval but succesfully explained by splitting the interval in twain via
|
||||
* segmented regression.
|
||||
*/
|
||||
get sumSquaredGainFromSegmentation(): number {
|
||||
return this.unsegmentedSumSquaredError - this.remainingSumSquaredError;
|
||||
}
|
||||
|
||||
/**
|
||||
* A statistical term that signals how successful the segmented regression is. Always
|
||||
* has values on the interval [0, 1], with 1 being a perfect fit.
|
||||
*/
|
||||
get coefficientOfDetermination(): number {
|
||||
if(this.host.union.squaredSum(this.dependent) == 0 || this.host.union.squaredSum(this.independent) == 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 1 - this.remainingSumSquaredError / this.host.union.squaredSum(this.dependent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the raw statistic value needed to test for validity of segmentation based on the axes
|
||||
* under examination. The higher the proportion of newly-explained variance to still-unexplained
|
||||
* variance is, the more confident we can _formally_ be in our segmentation.
|
||||
*/
|
||||
get fStat(): number {
|
||||
const val = this.sumSquaredGainFromSegmentation / this.remainingSumSquaredError;
|
||||
|
||||
// We're fine with Infinity. Just... not so much NaN.
|
||||
return isNaN(val) ? 0 : val;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the degrees-of-freedom to be used for the numerator DoF parameter of
|
||||
* the F-distribution needed for validity testing.
|
||||
*/
|
||||
get fDoF1(): number {
|
||||
let numDoF = 3;
|
||||
// Cases where there clearly may as well be 'no slope' at all.
|
||||
// This saves us a degree of freedom, which is VERY useful for segmenting
|
||||
// the boundary between a 'hold' and a 'move'.
|
||||
if(this.host.pre.squaredSum(this.dependent) < 1e-8) {
|
||||
numDoF--;
|
||||
}
|
||||
if(this.host.post.squaredSum(this.dependent) < 1e-8) {
|
||||
numDoF--;
|
||||
}
|
||||
|
||||
return numDoF;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the degrees-of-freedom to be used for the denominator DoF parameter of
|
||||
* the F-distribution needed for validity testing.
|
||||
*/
|
||||
get fDoF2(): number {
|
||||
return this.host.union.sampleCount - 2 - this.fDoF1;
|
||||
}
|
||||
|
||||
/**
|
||||
* States a statistically-founded level of confidence we may place in upholding
|
||||
* the represented segmentation, as based on the regressions and axes under examination.
|
||||
*
|
||||
* If we cannot hold any confidence whatsoever (due to being unable to "reject the
|
||||
* null hypothesis" from this specific regression), will return 0.
|
||||
*/
|
||||
get certaintyThreshold() {
|
||||
return 1 - FDistribution.thresholdTier(this.fStat, this.fDoF1, this.fDoF2);
|
||||
}
|
||||
}
|
||||
|
||||
constructor(pre: CumulativePathStats,
|
||||
post: CumulativePathStats,
|
||||
union: CumulativePathStats,
|
||||
cumulativeEndpoint: CumulativePathStats) {
|
||||
this.pre = pre;
|
||||
this.post = post;
|
||||
this.union = union;
|
||||
this.endpoint = cumulativeEndpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a segmented-regression perspective for the represented interval and proposed
|
||||
* split point based upon the two specified axes.
|
||||
* @param dependent
|
||||
* @param independent
|
||||
* @returns
|
||||
*/
|
||||
public segReg(dependentAxis: PathCoordAxis, independentAxis: PathCoordAxis) {
|
||||
return new Segmentation.segmentationComparison(this, dependentAxis, independentAxis);
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines our test for upholding a minimum of sub-segmentation based upon changes in the
|
||||
* interval's coordinates over time. This may occur even with no change in spacial direction
|
||||
* if there is significant enough change in _speed_, as that "curves the line" when one axis
|
||||
* is time.
|
||||
*/
|
||||
get segmentationMerited(): boolean {
|
||||
let totalThreshold = 0;
|
||||
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.
|
||||
const p05 = 0.95; // 1 - 0.05
|
||||
const p10 = 0.9; // 1 - 0.10
|
||||
|
||||
totalThreshold += xTest.certaintyThreshold >= p05 ? 2 : (xTest.certaintyThreshold >= p10 ? 1 : 0) ;
|
||||
totalThreshold += yTest.certaintyThreshold >= p05 ? 2 : (yTest.certaintyThreshold >= p10 ? 1 : 0) ;
|
||||
|
||||
return totalThreshold >= 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines our test for considering two (or more) sub-segments as part of the same overall
|
||||
* segment. This seeks to 'link' geometrically-related subsegments - especially those that
|
||||
* 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 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
|
||||
// matters for this test.
|
||||
const xTest = new Segmentation.segmentationComparison(this, 'x', 'y');
|
||||
const yTest = new Segmentation.segmentationComparison(this, 'y', 'x');
|
||||
|
||||
// If we don't get a p-value less than .100, then as far as x & y are concerned - and thus the user
|
||||
// is concerned - it's the same segment. Speed may be different, but not the direction.
|
||||
if(xTest.certaintyThreshold > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return yTest.certaintyThreshold == 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A specialized form of the `Segmentation` class that assists in its
|
||||
* construction from the interval directly under examination by the
|
||||
* segmentation algorithm.
|
||||
*/
|
||||
class PotentialSegmentation extends Segmentation {
|
||||
/**
|
||||
* The full running accumulation for the ongoing touch path until the point
|
||||
* before `post` / the later subsegment. May include
|
||||
* accumulations from before the interval under examination.
|
||||
*/
|
||||
readonly chopPoint: CumulativePathStats;
|
||||
|
||||
/**
|
||||
* The full running accumulation for the ongoing touch path until the point
|
||||
* at the end of `pre` / the earlier subsegment. May include
|
||||
* accumulations from before the interval under examination, and will
|
||||
* include accumulations from the first point represented by `post`.
|
||||
*/
|
||||
readonly endOfPre: CumulativePathStats;
|
||||
|
||||
/**
|
||||
* The full running accumulation for the ongoing touch path until the point
|
||||
* before `pre` / the earlier subsegment. Will only include
|
||||
* accumulations from before the interval under examination.
|
||||
*/
|
||||
readonly baseChop: CumulativePathStats;
|
||||
|
||||
constructor(steppedStats: CumulativePathStats[],
|
||||
choppedStats: CumulativePathStats,
|
||||
splitIndex: number) {
|
||||
const pre = steppedStats[splitIndex].deaccumulate(choppedStats);
|
||||
|
||||
// Keep stats value components based on the final point of the 'pre' segment.
|
||||
const finalStats = steppedStats[steppedStats.length-1];
|
||||
const post = finalStats.deaccumulate(steppedStats[splitIndex-1]);
|
||||
const union = finalStats.deaccumulate(choppedStats);
|
||||
|
||||
super(pre, post, union, finalStats);
|
||||
this.baseChop = choppedStats;
|
||||
this.chopPoint = steppedStats[splitIndex-1];
|
||||
this.endOfPre = steppedStats[splitIndex];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The core logic, algorithm, and manager for touchpath segmentation.
|
||||
*/
|
||||
export class PathSegmenter {
|
||||
/**
|
||||
* The minimum amount of time (in ms) to wait between sample repetitions
|
||||
* once inputs stop arriving, so long as the path is still active.
|
||||
*/
|
||||
private readonly REPEAT_INTERVAL = 33;
|
||||
|
||||
/**
|
||||
* The time-interval length (in ms) at the end of the path to consider
|
||||
* when determining whether or not to trigger path segmentation.
|
||||
*/
|
||||
private readonly SLIDING_WINDOW_INTERVAL = 50;
|
||||
|
||||
/**
|
||||
* Tracks the mathematical values used to provide path segment stats
|
||||
* at each point on the path. Individual steps may be removed (and
|
||||
* batched into `choppedStats`) once their respective points on the
|
||||
* path have been fully processed.
|
||||
*/
|
||||
private steppedCumulativeStats: CumulativePathStats[];
|
||||
|
||||
/**
|
||||
* Tracks all subsegments awaiting completion of their overall segment.
|
||||
*/
|
||||
private lingeringSubsegmentations: PotentialSegmentation[];
|
||||
|
||||
/**
|
||||
* TODO: These directly represent path segments produced by the prototype
|
||||
* algorithm. Just... the stats analysis of the path segment, without
|
||||
* obvious / public members to relevant coordinates.
|
||||
*
|
||||
* In follow-up work, this should be used to build & to update
|
||||
* cleaner, public-facing segment objects for the touch path.
|
||||
*/
|
||||
private _protoSegments: CumulativePathStats[] = [];
|
||||
|
||||
/**
|
||||
* TODO: These directly represent the subsegments comprising their
|
||||
* corresponding path segments (in `_protoSegments`) produced by the
|
||||
* prototype algorithm. Just... the stats analysis of each subsegment.
|
||||
*
|
||||
* In follow-up work, these should be used internally to build & update
|
||||
* the internals of TBD cleaner, public-facing segment objects for the
|
||||
* touch path.
|
||||
*/
|
||||
private _protoSegmentSets: CumulativePathStats[][] = [];
|
||||
|
||||
/**
|
||||
* Used to 'repeat' the most-recently observed incoming sample if no
|
||||
* other replaces it before it triggers.
|
||||
*
|
||||
* A repeating sample indicates lack of motion, which is valuable
|
||||
* information for stats-based segmentation.
|
||||
*/
|
||||
private repeatTimer: number | NodeJS.Timeout;
|
||||
|
||||
/**
|
||||
* The timestamp of observation of the most recently observed sample's
|
||||
* most recent repetition - even if it's only the first evaluation.
|
||||
*/
|
||||
private repeatTimestamp: number;
|
||||
|
||||
/**
|
||||
* Represents the cumulative statistics of all points on the path
|
||||
* that lie on already-fully-segmented parts of it.
|
||||
*/
|
||||
private choppedStats: CumulativePathStats = null;
|
||||
|
||||
constructor() {
|
||||
this.steppedCumulativeStats = [];
|
||||
this.lingeringSubsegmentations = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a new coordinate to the touch path and also kick-starts a timer for replicating it
|
||||
* should neither further inputs be received nor termination of the touchpoint be indicated.
|
||||
* @param sample
|
||||
*/
|
||||
public add(sample: InputSample) {
|
||||
// Set up the input-repeater (in case we don't get further feedback but remain active)
|
||||
const repeater = (timeDelta: number) => {
|
||||
this.observe(sample, timeDelta);
|
||||
}
|
||||
|
||||
// If we previously set up an input-repeater, cancel it. We've got a more up-to-date
|
||||
// coordinate on the touchpath now.
|
||||
if(this.repeatTimer) {
|
||||
// @ts-ignore
|
||||
clearInterval(this.repeatTimer);
|
||||
this.repeatTimer = null;
|
||||
}
|
||||
|
||||
this.repeatTimer = setInterval(() => {
|
||||
const timeDelta = Date.now() - this.repeatTimestamp;
|
||||
repeater(timeDelta);
|
||||
}, this.REPEAT_INTERVAL);
|
||||
this.repeatTimestamp = Date.now();
|
||||
repeater(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to finalize all segmentation for the touchpath whenever termination of the corresponding
|
||||
* touchpoint has been terminated (mouse-up, touch-end).
|
||||
*/
|
||||
public close() {
|
||||
// The Node clearTimeout & DOM clearTimeout appear to TS as overloads of each other,
|
||||
// and their type definitions will conflict. A simple @ts-ignore will bypass this issue.
|
||||
// @ts-ignore
|
||||
clearInterval(this.repeatTimer); // Cancels the input-repeater.
|
||||
this.repeatTimer = null;
|
||||
|
||||
// The way things are structured, finalization.pre = final segment. It's a bit hacky, but
|
||||
// it _is_ happy, intentional 'fallout' from the implementation's design. `post` is effectively
|
||||
// a single-point 'subsegment' and will go effectively unutilized by the phase 2 subsegment linker.
|
||||
let finalization = new PotentialSegmentation(this.steppedCumulativeStats, this.choppedStats, this.steppedCumulativeStats.length-1);
|
||||
|
||||
console.log("! Finalization !");
|
||||
this.processSubsegmentation(finalization, true);
|
||||
|
||||
// TODO: these are temporary statements to facilitate exploration, experimentation, & debugging.
|
||||
// We should be providing output to the touchpath object (`.path.segments`).
|
||||
// But... that'll be left for a follow-up PR.
|
||||
console.log(this._protoSegments);
|
||||
console.log(this._protoSegmentSets);
|
||||
console.log(this._protoSegments.map((val) => (val.toJSON())));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a statistic 'observation' of the state of the touchpath.
|
||||
*
|
||||
* This is either to be called directly upon reception of a new input-coordinate or
|
||||
* upon replication of a prior input should the touchpath still be active without any
|
||||
* indication of motion.
|
||||
* @param sample
|
||||
* @param timeDelta
|
||||
*/
|
||||
private observe(sample: InputSample, timeDelta: number) {
|
||||
let cumulativeStats: CumulativePathStats;
|
||||
if(this.steppedCumulativeStats.length) {
|
||||
cumulativeStats = this.steppedCumulativeStats[this.steppedCumulativeStats.length-1];
|
||||
} else {
|
||||
cumulativeStats = new CumulativePathStats();
|
||||
}
|
||||
|
||||
sample = {... sample};
|
||||
sample.t += timeDelta;
|
||||
const extendedStats = cumulativeStats.extend(sample);
|
||||
this.steppedCumulativeStats.push(extendedStats);
|
||||
|
||||
this.performSubsegmentation();
|
||||
}
|
||||
|
||||
private _debugLogSegmentationReport(candidateSplit: PotentialSegmentation) {
|
||||
console.log("------------------------------------------------------------------");
|
||||
|
||||
console.log("Combined: ");
|
||||
console.log(candidateSplit.union.toJSON());
|
||||
console.log("Pre: ")
|
||||
console.log(candidateSplit.pre.toJSON());
|
||||
console.log("Post: ");
|
||||
console.log(candidateSplit.post.toJSON());
|
||||
|
||||
console.log();
|
||||
|
||||
const xF = candidateSplit.segReg('x', 't');
|
||||
const yF = candidateSplit.segReg('y', 't');
|
||||
console.log(`x F-test: F_(${xF.fDoF1}, ${xF.fDoF2}) = ${xF.fStat} @ ${xF.certaintyThreshold}`);
|
||||
console.log(`y F-test: F_(${yF.fDoF1}, ${yF.fDoF2}) = ${yF.fStat} @ ${yF.certaintyThreshold}`);
|
||||
|
||||
console.log();
|
||||
|
||||
console.log("Split object: ");
|
||||
console.log(candidateSplit);
|
||||
|
||||
console.log("------------------------------------------------------------------");
|
||||
// END: DO NOT RELEASE.
|
||||
}
|
||||
|
||||
// The "reported via event" aspect mentioned below is necessary because this may be
|
||||
// called via setTimeout callback on a held touchpoint. We need that `setTimeout`
|
||||
// (which replicates the most recently-seen input coordinate) in order to have good
|
||||
// sample-data for detecting the boundary between motion and lack thereof during
|
||||
// segmentation.
|
||||
/**
|
||||
* This is the "main method" for touchpath segmentation. Should a new segment result
|
||||
* from its analysis, it will be reported via event.
|
||||
* @returns
|
||||
*/
|
||||
private performSubsegmentation() {
|
||||
const cumulativeStats = this.steppedCumulativeStats[this.steppedCumulativeStats.length - 1];
|
||||
const unsegmentedDuration = cumulativeStats.lastTimestamp - this.steppedCumulativeStats[0].lastTimestamp;
|
||||
|
||||
// STEP 1: Determine the range of the sliding time window for the most recent samples.
|
||||
|
||||
if(unsegmentedDuration < this.SLIDING_WINDOW_INTERVAL * 2) {
|
||||
console.log("Interval too short for segmentation.");
|
||||
return;
|
||||
}
|
||||
|
||||
let splitPoint = 0;
|
||||
// Do not consider the just-added `extendedStats` entry when building the sliding
|
||||
// time window.
|
||||
for(let i = this.steppedCumulativeStats.length-2; i >=0; i--) {
|
||||
if(this.steppedCumulativeStats[i].lastTimestamp < cumulativeStats.lastTimestamp - this.SLIDING_WINDOW_INTERVAL) {
|
||||
splitPoint = i+1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// We split the cumulative stats on a specific point, which then resides on the edge
|
||||
// of both of the resulting intervals. This completes "step 1".
|
||||
let candidateSplit = new PotentialSegmentation(this.steppedCumulativeStats, this.choppedStats, splitPoint);
|
||||
|
||||
// STEP 2: given the proposed time window, do we have a basis for segmentation? And is there a better
|
||||
// candidate split point nearby?
|
||||
|
||||
// NOTE: During initial development, I actually had a hard rule about not even searching if segmentation
|
||||
// failed on the initially-constructed sliding window. Some analysis may be wise here, as it'd be nice
|
||||
// to optimize away the 'need to search' if we can find hard & fast rules about when we'll never need
|
||||
// to go looking. But... being too aggressive can cause nasty problems.
|
||||
|
||||
if(!candidateSplit.segmentationMerited) {
|
||||
// // Debug logging statements:
|
||||
const xTest = candidateSplit.segReg('x', 't');
|
||||
const yTest = candidateSplit.segReg('y', 't');
|
||||
console.log(`x F-test: F_(${xTest.fDoF1}, ${xTest.fDoF2}) = ${xTest.fStat} @ ${xTest.certaintyThreshold}`);
|
||||
console.log(`y F-test: F_(${yTest.fDoF1}, ${yTest.fDoF2}) = ${yTest.fStat} @ ${yTest.certaintyThreshold}`);
|
||||
|
||||
console.log("candidate split: " );
|
||||
console.log(candidateSplit);
|
||||
}
|
||||
|
||||
// We either have the conditions to trigger segmentation or just became long enough to consider it.
|
||||
// If we're only just long enough to consider it, there may be a better segmentation point to start with.
|
||||
// Now... is there a better segmentation point?
|
||||
class SplitSearchState {
|
||||
readonly xTest: typeof Segmentation.segmentationComparison.prototype;
|
||||
readonly yTest: typeof Segmentation.segmentationComparison.prototype;
|
||||
readonly candidate: PotentialSegmentation;
|
||||
|
||||
constructor(candidate?: PotentialSegmentation) {
|
||||
this.candidate = candidate;
|
||||
|
||||
if(candidate == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.xTest = candidate.segReg('x', 't');
|
||||
this.yTest = candidate.segReg('y', 't');
|
||||
}
|
||||
|
||||
// The value for an 'objective function' to optimize in our search for a better candidate.
|
||||
// The higher this is, the more we like its potential as the segmentation point.
|
||||
get segRating() {
|
||||
if(this.candidate) {
|
||||
return Math.max(this.xTest.coefficientOfDetermination, this.yTest.coefficientOfDetermination);
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// But, if it turns out this potential point wouldn't actually result in segmentation, well...
|
||||
// "abandon ship".
|
||||
get segmentationMerited() {
|
||||
return this.candidate?.segmentationMerited ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
// Start: split-point search
|
||||
|
||||
/* We'll use our initial sliding window as a starting point. It's close to the active location
|
||||
* of the touch, so we're unlikely to miss a proper segmentation point if it lies close.
|
||||
* We'll find the local maximum (best split point for the local part of the path)
|
||||
* via gradient descent.
|
||||
*
|
||||
* Note: binary search is a 'bad idea', as the touchpath is likely not following a
|
||||
* monotonous path, mathematically speaking. (It'd be possible to overshoot a "peak" or
|
||||
* "valley" quite easily.)
|
||||
*/
|
||||
let currentSplit = new SplitSearchState(candidateSplit);
|
||||
|
||||
let leftSplit = new SplitSearchState(new PotentialSegmentation(this.steppedCumulativeStats, this.choppedStats, splitPoint-1));
|
||||
let rightCandidate: PotentialSegmentation = null;
|
||||
if(splitPoint+1 < this.steppedCumulativeStats.length) {
|
||||
rightCandidate = new PotentialSegmentation(this.steppedCumulativeStats, this.choppedStats, splitPoint+1);
|
||||
}
|
||||
let rightSplit = new SplitSearchState(rightCandidate);
|
||||
|
||||
// Step 2a: detect which direction gives the best improvement in segmentation potential.
|
||||
const criteria = [leftSplit.segRating, currentSplit.segRating, rightSplit.segRating];
|
||||
let sortedCriteria = [...criteria].sort();
|
||||
|
||||
const delta = criteria.indexOf(sortedCriteria[2])-1; // -1 if 'left' is best, 1 if 'right' is best.
|
||||
|
||||
// Step 2b: we've found the direction: go searching!
|
||||
if(delta != 0) {
|
||||
// We can get better segmentation by shifting. Proceed in the optimal direction.
|
||||
do {
|
||||
const nextSplitIndex = splitPoint + delta;
|
||||
if(nextSplitIndex >= this.steppedCumulativeStats.length || nextSplitIndex < 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
let nextCandidate = new PotentialSegmentation(this.steppedCumulativeStats, this.choppedStats, splitPoint + delta);
|
||||
let nextSplit = new SplitSearchState(nextCandidate);
|
||||
|
||||
// Prevent overly-short intervals / over-segmentation.
|
||||
if(nextCandidate.pre.duration * 1000 < this.SLIDING_WINDOW_INTERVAL / 2) {
|
||||
break;
|
||||
} else if(nextCandidate.post.duration * 1000 < this.SLIDING_WINDOW_INTERVAL / 2) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Not an improvement? Guess we found the best spot.
|
||||
if(nextSplit.segRating <= currentSplit.segRating) {
|
||||
break;
|
||||
} else if(!nextSplit.segmentationMerited && currentSplit.segmentationMerited) {
|
||||
// Note: this can happen if segmentation is triggered due to divergence on both axes
|
||||
// if one of them drops a threshold tier and the other fails to improve sufficiently.
|
||||
console.warn("aborting split-point relocation due to no longer segmenting");
|
||||
break;
|
||||
} else {
|
||||
splitPoint += delta;
|
||||
currentSplit = nextSplit;
|
||||
candidateSplit = nextCandidate;
|
||||
}
|
||||
// If we found a new best segmentation point, we then ask if we can get even better by shifting further.
|
||||
} while(true);
|
||||
}
|
||||
// Step 2 complete.
|
||||
|
||||
// Step 3: evaluate our candidate split-point - do we sub-segment? If so, do so.
|
||||
console.log("best split: ");
|
||||
console.log(candidateSplit);
|
||||
|
||||
if(!candidateSplit.segmentationMerited) {
|
||||
return;
|
||||
}
|
||||
|
||||
// First phase of segmentation: complete!
|
||||
|
||||
// FIXME: DO NOT RELEASE.
|
||||
// This is exploratory / diagnostic code assisting development of the path segmentation
|
||||
// algorithm.
|
||||
this._debugLogSegmentationReport(candidateSplit);
|
||||
// END: DO NOT RELEASE.
|
||||
|
||||
this.steppedCumulativeStats = this.steppedCumulativeStats.slice(splitPoint);
|
||||
this.choppedStats = candidateSplit.chopPoint;
|
||||
|
||||
// Step 4: process the implications.
|
||||
|
||||
// There are some cases where we want to prevent segmentation from fully happening.
|
||||
// The next method will both handle that and the signal of any completed segments that
|
||||
// may result.
|
||||
this.processSubsegmentation(candidateSplit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the results of the subsegmentation process, this method analyzes the results
|
||||
* in order to produce and update the detected segments.
|
||||
*
|
||||
* TODO: Should produce actual `Segment` objects and trigger their emission
|
||||
* them via EventEmitter mechanics.
|
||||
* @param subsegmentation
|
||||
* @param finalize Set to `true` for a terminating touchpath, indicating that
|
||||
* no further subsegmentation will occur for this touchpath.
|
||||
*/
|
||||
private processSubsegmentation(subsegmentation: PotentialSegmentation, finalize?: boolean) {
|
||||
finalize = !!finalize;
|
||||
|
||||
// This function makes a LOT of assumptions particular to this method and class.
|
||||
// It is not safe to make this class-level, and it's especially unsafe to make it `public`.
|
||||
const mergeSubsegmentationAccumulations = function(array: PotentialSegmentation[]) {
|
||||
if(array.length == 1) {
|
||||
return array[0].pre; // It's pre-calculated, so just use it.
|
||||
} else {
|
||||
const finalSubsegmentation = array[array.length-1];
|
||||
return finalSubsegmentation.endOfPre.deaccumulate(array[0].baseChop);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: did we previously note that any previous subsegments are likely to be 'linked' to
|
||||
// the new, incoming left-hand subsegment? If so, validate that expectation & act accordingly.
|
||||
|
||||
let predecessor = subsegmentation.pre;
|
||||
let firstChopPoint = subsegmentation.baseChop;
|
||||
if(this.lingeringSubsegmentations.length) {
|
||||
// First: check if the newly-finished subsegment should be merged with the lingering ones.
|
||||
const mergedPrecursors = mergeSubsegmentationAccumulations(this.lingeringSubsegmentations);
|
||||
|
||||
const tailUnion = mergeSubsegmentationAccumulations([...this.lingeringSubsegmentations, subsegmentation]);
|
||||
console.log("Verifying linkage to pending merges: ");
|
||||
console.log(this.lingeringSubsegmentations);
|
||||
console.log("verification check:");
|
||||
console.log(mergedPrecursors);
|
||||
const precursorMergeSegmentation = new Segmentation(mergedPrecursors, subsegmentation.pre, tailUnion, subsegmentation.endOfPre);
|
||||
// Sometimes the start of a harsh turn seems like it's part of the same thing for a moment, but as it continues,
|
||||
// becomes something VERY different. Validate that we should still merge the left-hand with its predecessors.
|
||||
if(!PathSegmenter.shouldLinkSubsegments(precursorMergeSegmentation)) {
|
||||
// Emit as separate subsegment.
|
||||
const finishedSegment = mergeSubsegmentationAccumulations(this.lingeringSubsegmentations);
|
||||
this._protoSegments.push(finishedSegment);
|
||||
this._protoSegmentSets.push(this.lingeringSubsegmentations.map((val) => val.pre));
|
||||
this.lingeringSubsegmentations = [];
|
||||
|
||||
console.log("Proto-segments length: " + this._protoSegments.length);
|
||||
|
||||
console.log("Did not merge a lingering subsegment with the incoming one!");
|
||||
} else {
|
||||
predecessor = tailUnion;
|
||||
firstChopPoint = this.lingeringSubsegmentations[0].baseChop;
|
||||
console.log("Will merge in old subsegments!");
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: okay, predecessor handling complete. Now, do we think the newly-starting right-hand
|
||||
// subsegment is likely to be 'linked' to the newly-completed left-hand subsegment?
|
||||
|
||||
console.log("Double-checking right-side split subsegment for xy/yx correlation with prior segment candidate(s)");
|
||||
const fullUnion = subsegmentation.endpoint.deaccumulate(firstChopPoint);
|
||||
const fullMergeSegmentation = new Segmentation(predecessor, subsegmentation.post, fullUnion, subsegmentation.endpoint);
|
||||
console.log("segmentation-prevention check:")
|
||||
console.log(fullMergeSegmentation);
|
||||
// if(!force && PathSegmenter.shouldMergeSubsegments(subsegmentation.pre, subsegmentation.post, subsegmentation.union)) {
|
||||
if(!finalize && PathSegmenter.shouldLinkSubsegments(fullMergeSegmentation)) {
|
||||
this.lingeringSubsegmentations.push(subsegmentation);
|
||||
} else {
|
||||
// Merge all as a completed segment!
|
||||
this._protoSegments.push(predecessor);
|
||||
this._protoSegmentSets.push([...this.lingeringSubsegmentations.map((val) => val.pre), subsegmentation.pre]);
|
||||
this.lingeringSubsegmentations = [];
|
||||
|
||||
console.log("Proto-segments length: " + this._protoSegments.length);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Used by the 'filter' step (phase 2 of segmentation) to determine whether or not
|
||||
* two subsegments should be 'linked' as members of the same segment. (That is, if
|
||||
* a user would likely consider the two subsegments as belonging to the "same arc
|
||||
* of motion".)
|
||||
* @param segmentation
|
||||
* @returns
|
||||
*/
|
||||
private static shouldLinkSubsegments(segmentation: Segmentation): boolean {
|
||||
// // Known case #1:
|
||||
// // Near-identical direction, but heavy speed difference.
|
||||
// // Speed's still high enough to not be a 'wait'.
|
||||
// // We may want to 'note' the higher speed; that may be relevant for flick detection.
|
||||
// // Known case #2:
|
||||
// // Low-speed pivot; angle change caught on very low velocity for both subsegments.
|
||||
// // ... or should this be merged? Multiple mini-segments from 'wiggling' could just go ignored instead...
|
||||
|
||||
// This is kinda plain and arbitrary, but it seems to work "well enough" for now,
|
||||
// at least at this stage of development. (Most testing was done with Chrome emulation of
|
||||
// an iPhone SE.)
|
||||
|
||||
// .mean('v') < 0.08: the mean speed (taken timestamp to timestamp) does not exceed 0.08px/millisec.
|
||||
if(segmentation.pre.mean('v') < 0.08 && segmentation.post.mean('v') > 0.08) {
|
||||
console.log("desegmentation exception");
|
||||
return;
|
||||
}
|
||||
if(segmentation.pre.mean('v') > 0.08 && segmentation.post.mean('v') < 0.08) {
|
||||
console.log("desegmentation exception");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Desegmentation under consideration: ");
|
||||
console.log(segmentation);
|
||||
|
||||
const xF = segmentation.segReg('x', 'y');
|
||||
const yF = segmentation.segReg('y', 'x');
|
||||
console.log(`merger F-test (xy): F_(${xF.fDoF1}, ${xF.fDoF2}) = ${xF.fStat} @ ${xF.certaintyThreshold}`);
|
||||
console.log(`merger F-test (yx): F_(${yF.fDoF1}, ${yF.fDoF2}) = ${yF.fStat} @ ${yF.certaintyThreshold}`);
|
||||
console.log(`will remerge: ${segmentation.mergeMerited}`);
|
||||
|
||||
return segmentation.mergeMerited;
|
||||
}
|
||||
}
|
||||
}
|
||||
5
common/web/gesture-recognizer/src/segment.ts
Normal file
5
common/web/gesture-recognizer/src/segment.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
namespace com.keyman.osk {
|
||||
export class Segment {
|
||||
// TODO: stuff.
|
||||
}
|
||||
}
|
||||
|
|
@ -48,6 +48,7 @@ loadPromise.then((recognizer) => {
|
|||
});
|
||||
|
||||
sequence.on('end', function() {
|
||||
//let stats = sequence.touchpoints[0].path.segments[0].stats;
|
||||
logElement.value = recorder.recordingsToJSON();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ namespace com.keyman.osk {
|
|||
export type JSONTrackedPath = {
|
||||
coords: InputSample[]; // ensures type match with public class property.
|
||||
wasCancelled?: boolean;
|
||||
// segments: Segment[];
|
||||
//segments: Segment[];
|
||||
}
|
||||
|
||||
interface EventMap {
|
||||
|
|
@ -36,6 +36,10 @@ namespace com.keyman.osk {
|
|||
*/
|
||||
export class TrackedPath extends EventEmitter<EventMap> {
|
||||
private samples: InputSample[] = [];
|
||||
private _segments: Segment[] = [];
|
||||
|
||||
private readonly segmenter: PathSegmenter;
|
||||
|
||||
private _isComplete: boolean = false;
|
||||
private wasCancelled?: boolean;
|
||||
|
||||
|
|
@ -56,10 +60,16 @@ namespace com.keyman.osk {
|
|||
|
||||
if(jsonObj) {
|
||||
this.samples = [...jsonObj.coords.map((obj) => ({...obj} as InputSample))];
|
||||
// If we're reconstructing this from a JSON.parse, it's a previously-recorded, completed path.
|
||||
// If we're reconstructing this from a JSON.parse, it's a previously-recorded,
|
||||
// completed path.
|
||||
this._isComplete = true;
|
||||
this.wasCancelled = jsonObj.wasCancelled;
|
||||
}
|
||||
|
||||
// Keep this as the _final_ statement in the constructor. `PathSegmenter` will
|
||||
// need a reference to this instance, even if only via closure.
|
||||
// (Most likely; not yet done.) Kinda awkward, but it's useful for compartmentalization.
|
||||
this.segmenter = new PathSegmenter();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -80,6 +90,7 @@ namespace com.keyman.osk {
|
|||
}
|
||||
|
||||
this.samples.push(sample);
|
||||
this.segmenter.add(sample);
|
||||
this.emit('step', sample);
|
||||
}
|
||||
|
||||
|
|
@ -93,6 +104,7 @@ namespace com.keyman.osk {
|
|||
}
|
||||
this.wasCancelled = cancel;
|
||||
this._isComplete = true;
|
||||
this.segmenter.close();
|
||||
|
||||
if(cancel) {
|
||||
this.emit('invalidated');
|
||||
|
|
@ -110,6 +122,10 @@ namespace com.keyman.osk {
|
|||
return this.samples;
|
||||
}
|
||||
|
||||
public get segments(): readonly Segment[] {
|
||||
return this._segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a serialization-friendly version of this instance for use by
|
||||
* `JSON.stringify`.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue