Merge pull request #9067 from keymanapp/chore/web/disconnect-subsegmentation

chore(web): disconnects the gesture-oriented touchpath-subsegmentation engine 🐵
This commit is contained in:
Joshua Horton 2023-06-30 11:57:50 +07:00 committed by GitHub
commit 08f761c90f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 63 additions and 195 deletions

View file

@ -0,0 +1,14 @@
## About this subfolder
This folder contains code for an advanced touchpath sub-segmentation engine that would facilitate more complex gestures,
such as multi-segment flicks and/or swipe-style input. The decision was made to "ice" it for now in favor of preventing
further delays for the release of the feature.
To revisit a state when the system was fully connected, the following two commits may provide a useful reference:
- https://github.com/keymanapp/keyman/tree/0c7ac4ff3caf612674602fefb7ff71350b13964a
- The last feature-gestures commit with full subsegmentation integration
- https://github.com/keymanapp/keyman/tree/5c48cc5b9b127ab42a8055b7d960dbc4d39e4df7
- See #7440 - its description details the basic process for creation of a asynchronous FSM for recognizing & constructing gestures based on subsegments produced by the subsegmentation engine.
- The permalinked commit itself holds the code that said process would connect with.
- This was never fully committed to feature-gestures; #7440 was directly based upon the "last feature-gestures
commit..." mentioned above.

View file

@ -1,7 +1,7 @@
import { SegmentClassifier } from "../segmentClassifier.js";
import { CumulativePathStats } from "../cumulativePathStats.js";
import { SegmentationSplit, Subsegmentation } from "./pathSegmenter.js";
import { SegmentImplementation } from "../segment.js";
import { SegmentImplementation } from "./segment.js";
import { SubsegmentCompatibilityAnalyzer } from "./subsegmentCompatibilityAnalyzer.js";
/**

View file

@ -1,7 +1,7 @@
import { ConstructingSegment } from "./constructingSegment.js";
import { CumulativePathStats, PathCoordAxis, sigMinus } from "../cumulativePathStats.js";
import { InputSample } from "../inputSample.js";
import { Segment, SegmentImplementation } from "../segment.js";
import { Segment, SegmentImplementation } from "./segment.js";
import { SegmentClass, SegmentClassifier, SegmentClassifierConfig } from "../segmentClassifier.js";
// Mostly used here to compare the sum-squared error components of segmented regressions

View file

@ -1,6 +1,6 @@
import { CumulativePathStats } from "./cumulativePathStats.js";
import { InputSample } from "./inputSample.js";
import { type SegmentClass } from "./segmentClassifier.js";
import { CumulativePathStats } from "../cumulativePathStats.js";
import { InputSample } from "../inputSample.js";
import { type SegmentClass } from "../segmentClassifier.js";
export interface JSONSegment {
type: SegmentClass,

View file

@ -1,7 +1,6 @@
import EventEmitter from "eventemitter3";
import { InputSample } from "./inputSample.js";
import { PathSegmenter } from "./subsegmentation/pathSegmenter.js";
import { Segment } from "./segment.js";
import { CumulativePathStats } from "./cumulativePathStats.js";
/**
* Documents the expected typing of serialized versions of the `TrackedPoint` class.
@ -9,14 +8,12 @@ import { Segment } from "./segment.js";
export type JSONTrackedPath = {
coords: InputSample[]; // ensures type match with public class property.
wasCancelled?: boolean;
segments: Segment[];
}
interface EventMap {
'step': (sample: InputSample) => void,
'complete': () => void,
'invalidated': () => void
'segmentation': (segment: Segment) => void
}
/**
@ -44,25 +41,14 @@ interface EventMap {
* - Will precede resolution Promise fulfillment on the `Segment` provided by
* the most recently-preceding 'segmentation' event.
* - And possibly recognition Promise fulfillment.
*
* `'segmentation'`: a new segmentation boundary has been identified for the
* ongoing touchpath.
* - Provides one parameter - a new `Segment` instance representing the
* still-in-construction part of the touchpath until this event is
* raised again. (That would indicate a new segmentation boundary
* marking the end of the first event's returned `Segment`.)
*/
export class TrackedPath extends EventEmitter<EventMap> {
private samples: InputSample[] = [];
private _segments: Segment[] = [];
private readonly segmenter: PathSegmenter;
private _isComplete: boolean = false;
private wasCancelled?: boolean;
// private _segments: Segment[];
// public get segments(): readonly Segment[] { return this._segments; }
private stats: CumulativePathStats;
/**
* Initializes an empty path intended for tracking a newly-activated touchpoint.
@ -84,17 +70,7 @@ export class TrackedPath extends EventEmitter<EventMap> {
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.
// - DO use 'via closure.' That allows us to have the segment passing done via
// `private` method.
const segmentStartClosure = (segment: Segment) => {
this._segments.push(segment);
this.emit('segmentation', segment);
}
this.segmenter = new PathSegmenter(PathSegmenter.DEFAULT_CONFIG, segmentStartClosure);
this.stats = new CumulativePathStats();
}
/**
@ -117,9 +93,8 @@ export class TrackedPath extends EventEmitter<EventMap> {
// The tracked path should emit InputSample events before Segment events and
// resolution of Segment Promises.
this.samples.push(sample);
this.stats = this.stats.extend(sample);
this.emit('step', sample);
this.segmenter.add(sample);
}
/**
@ -138,8 +113,6 @@ export class TrackedPath extends EventEmitter<EventMap> {
this.emit('invalidated');
}
this.segmenter.close();
// If not cancelling, signal completion after finishing segments.
if(!cancel) {
this.emit('complete');
@ -156,38 +129,6 @@ export class TrackedPath extends EventEmitter<EventMap> {
return this.samples;
}
/**
* Returns the segmented form of the touchpath over its lifetime thus far. It is
* possible for certain parts of the path to go unrepresented if they are detected as
* insignificant.
*
* Also of note: for the common case, the final coordinate of one Segment will usually
* be the initial point of the following Segment. Usually, but not always. This is
* the only overlap that may occur.
*
* Note: segment events and updates will always occur in the following order:
*
* 1. A segment's role is 'recognized' - its classification becomes known.
* 2. The segment is 'resolved' - the segment is marked as completed.
* 3. The next segment is added to this field, with on('segmentation') is raised
* for it.
*
* Note that it is possible for a segment to be 'recognized' and even 'resolved'
* before its event is raised (thus, before it is added here) under some scenarios.
* In particular, 'start' and 'end'-type segments are always 'recognized' and
* 'resolved'.
*
* While the touchpath is active, there is a very high chance that the final
* segment listed will not be resolved. There will also be a distinct chance
* that it is not yet recognized.
*
* The first Segment should always be a 'start', while the final Segment - once
* _all_ input for the ongoing touchpath is complete - will be an 'end'.
*/
public get segments(): readonly Segment[] {
return this._segments;
}
/**
* Creates a serialization-friendly version of this instance for use by
* `JSON.stringify`.
@ -201,7 +142,6 @@ export class TrackedPath extends EventEmitter<EventMap> {
targetY: obj.targetY,
t: obj.t
}))),
segments: [].concat(this.segments),
wasCancelled: this.wasCancelled
}

View file

@ -10,7 +10,7 @@ export { MouseEventEngine } from "./mouseEventEngine.js";
export { PathSegmenter, Subsegmentation } from "./headless/subsegmentation/pathSegmenter.js";
export { PaddedZoneSource } from './configuration/paddedZoneSource.js';
export { RecognitionZoneSource } from './configuration/recognitionZoneSource.js';
export { Segment } from "./headless/segment.js";
export { Segment } from "./headless/subsegmentation/segment.js";
export { SegmentClassifier } from "./headless/segmentClassifier.js";
export { TouchEventEngine } from "./touchEventEngine.js";
export { ViewportZoneSource } from './configuration/viewportZoneSource.js';

View file

@ -19,6 +19,25 @@ const SEGMENT_TEST_JSON_FOLDER = path.resolve(`${scriptFolder}/../../resources/j
import { assertSegmentSimilarity } from '../../resources/assertSegmentSimilarity.js';
/**
* Since we're disconnecting subsegmentation stuff for the first gestures release, our
* previously-recorded subsegmentations aren't retrievable in the same spot as before.
*
* This manually retrieves them from the originally-recorded version in order to preserve
* the unit tests. Makes a few assumptions, but they're valid for the original recordings.
* @param {*} jsonObj
*/
function retrieveSubsegmentations(jsonObj) {
return jsonObj.inputs[0].touchpoints[0].path.segments;
}
// ---------------------------------------
// NOTE: this suite of tests is for a disconnected subsystem - subsegmentation - designed
// to handle more complicated gestures than supported in our initial release. We had to
// triage it to prevent further delays.
//
// The tests themselves should still be functional.
// ---------------------------------------
describe("Segmentation - from recorded sequences", function() {
beforeEach(function() {
this.fakeClock = sinon.useFakeTimers();
@ -85,7 +104,7 @@ describe("Segmentation - from recorded sequences", function() {
await recognitionTestCapturer.recognizedPromise;
// Any post-sequence tests to run.
const originalSegments = testObj.originalSegments;
const originalSegments = retrieveSubsegmentations(jsonObj);
const originalSegmentTypeSequence = originalSegments.map((segment) => segment.type);
const reproedSegments = spy.getCalls().map((call) => call.args[0]);
@ -151,7 +170,7 @@ describe("Segmentation - from recorded sequences", function() {
await recognitionTestCapturer.recognizedPromise;
// Any post-sequence tests to run.
const originalSegments = testObj.originalSegments;
const originalSegments = retrieveSubsegmentations(jsonObj);
const originalSegmentTypeSequence = originalSegments.map((segment) => segment.type);
const reproedSegments = spy.getCalls().map((call) => call.args[0]);
@ -187,7 +206,7 @@ describe("Segmentation - from recorded sequences", function() {
await testObj.compositePromise;
// Any post-sequence tests to run.
const originalSegments = testObj.originalSegments;
const originalSegments = retrieveSubsegmentations(jsonObj);
const originalSegmentTypeSequence = originalSegments.map((segment) => segment.type);
const reproedSegments = spy.getCalls().map((call) => call.args[0]);
@ -227,7 +246,7 @@ describe("Segmentation - from recorded sequences", function() {
await testObj.compositePromise;
// Any post-sequence tests to run.
const originalSegments = testObj.originalSegments;
const originalSegments = retrieveSubsegmentations(jsonObj);
const reproedSegments = spy.getCalls().map((call) => call.args[0]);
// Because of the sweeping arc motion, we won't assume a perfect match to the segmentation here.
@ -281,7 +300,7 @@ describe("Segmentation - from recorded sequences", function() {
const nulls = reproedSegments.filter((segment) => segment.type === null);
assert.isEmpty(nulls);
assert.isEmpty(holds.filter((hold) => hold.duration > 200)); // all motions were very quick.
assert.isEmpty(holds.filter((hold) => hold.duration > 300)); // all motions were very quick.
// True (intended) motions were 'e' -> 's' -> 'w' -> 'n', but the motions weren't that precise
// due to prioritizing speed.

View file

@ -1,20 +1,10 @@
import { assert } from 'chai'
import sinon from 'sinon';
import fs from 'fs';
import path from 'path';
import url from 'url';
import { TrackedPath } from '@keymanapp/gesture-recognizer';
import { HeadlessRecordingSimulator, timedPromise } from '../../../../build/tools/obj/index.js';
import { timedPromise } from '../../../../build/tools/obj/index.js';
// Ensures that the resources are resolved relative to this script, not to the cwd when the test
// runner was launched.
const scriptFolder = path.dirname(url.fileURLToPath(import.meta.url));
const SEGMENT_TEST_JSON_FOLDER = path.resolve(`${scriptFolder}/../../resources/json/segmentation`);
import { assertSegmentSimilarity } from '../../resources/assertSegmentSimilarity.js'
;
// End of "for the integrated style..."
describe("TrackedPath", function() {
@ -22,49 +12,6 @@ describe("TrackedPath", function() {
// let testJSONtext = fs.readFileSync('src/test/resources/json/canaryRecording.json');
describe("Single-sample 'sequence'", function() {
// A near-duplicate of the "Segmentation" -> "Single-sample 'sequence'" test.
// Acts as a mild 'integration' test of the segmentation engine + the public-facing
// TrackedPath events it advertises.
it("'segmentation' - expected segment types", function() {
let spy = sinon.fake();
const touchpath = new TrackedPath();
touchpath.on('segmentation', spy);
const sample = {
targetX: 1,
targetY: 1,
t: 100
};
// Expected sequence:
// 1: on `segmenter.add(sample)`:
// a. 'start' segment
// b. unrecognized (null-type) segment.
// 2: on `segmenter.close()`:
// b. 'end' segment
touchpath.extend(sample);
try {
assert.isTrue(spy.calledTwice, "'segmentation' event was not raised exactly twice before the .close().");
} finally {
touchpath.terminate(false); // false = "not cancelled"
}
assert.isTrue(spy.calledThrice, "'segmentation' event was not raised exactly once after the .close().");
for(let i=0; i < 3; i++) {
assert.equal(spy.args[i].length, 1, "'segmentation' event was raised with an unexpected number of arguments");
}
assert.equal(spy.firstCall.args[0].type, 'start', "First event should provide a 'start'-type segment.");
assert.equal(spy.secondCall.args[0].type, undefined, "Second event's segment should not be classified.");
assert.equal(spy.thirdCall.args[0].type, 'end', "Third event should provide an 'end'-type segment.");
assert.deepEqual(touchpath.coords, [sample]);
assert.deepEqual(touchpath.segments, spy.getCalls().map((call) => call.args[0]),
"The touchpath's segment array does not match the `Segment`s from raised events.");
});
it("'step', 'complete' events", function() {
const spyEventStep = sinon.fake();
const spyEventComplete = sinon.fake();
@ -134,13 +81,11 @@ describe("TrackedPath", function() {
const spyEventStep = sinon.fake();
const spyEventComplete = sinon.fake();
const spyEventInvalidated = sinon.fake();
const spyEventSegmentation = sinon.fake();
const touchpath = new TrackedPath();
touchpath.on('step', spyEventStep);
touchpath.on('complete', spyEventComplete);
touchpath.on('invalidated', spyEventInvalidated);
touchpath.on('segmentation', spyEventSegmentation);
const sample = {
targetX: 1,
@ -151,28 +96,18 @@ describe("TrackedPath", function() {
touchpath.extend(sample);
touchpath.terminate(false); // false = "not cancelled"
assert(spyEventStep.firstCall.calledBefore(spyEventSegmentation.firstCall),
"'step' should be raised before 'segmentation'");
assert(spyEventSegmentation.thirdCall.calledBefore(spyEventComplete.firstCall),
"all 'segmentation' events should be raised before 'complete'");
const spy = spyEventSegmentation;
assert.deepEqual(touchpath.coords, [sample]);
spy.call
assert.deepEqual(touchpath.segments, spy.getCalls().map((call) => call.args[0]));
});
it("event ordering - 'invalidated'", function() {
const spyEventStep = sinon.fake();
const spyEventComplete = sinon.fake();
const spyEventInvalidated = sinon.fake();
const spyEventSegmentation = sinon.fake();
const touchpath = new TrackedPath();
touchpath.on('step', spyEventStep);
touchpath.on('complete', spyEventComplete);
touchpath.on('invalidated', spyEventInvalidated);
touchpath.on('segmentation', spyEventSegmentation);
const sample = {
targetX: 1,
@ -183,17 +118,7 @@ describe("TrackedPath", function() {
touchpath.extend(sample);
touchpath.terminate(true); // false = "not cancelled"
assert(spyEventStep.firstCall.calledBefore(spyEventSegmentation.firstCall),
"'step' should be raised before 'segmentation'");
assert(spyEventSegmentation.secondCall.calledBefore(spyEventInvalidated.firstCall),
"first 'segmentation' event ('start' segment) not raised before cancellation");
assert(spyEventSegmentation.thirdCall.calledAfter(spyEventInvalidated.firstCall),
"Cancellation event not raised before cancelled segment completion");
// Even though the touchpath was 'cancelled', we should still see the segments that finished processing.
const spy = spyEventSegmentation;
assert.deepEqual(touchpath.coords, [sample]);
assert.deepEqual(touchpath.segments, spy.getCalls().map((call) => call.args[0]));
});
});
@ -211,13 +136,11 @@ describe("TrackedPath", function() {
const spyEventStep = sinon.fake();
const spyEventComplete = sinon.fake();
const spyEventInvalidated = sinon.fake();
const spyEventSegmentation = sinon.fake();
const touchpath = new TrackedPath();
touchpath.on('step', spyEventStep);
touchpath.on('complete', spyEventComplete);
touchpath.on('invalidated', spyEventInvalidated);
touchpath.on('segmentation', spyEventSegmentation);
const startSample = {
targetX: 1,
@ -247,8 +170,6 @@ describe("TrackedPath", function() {
}).then(() => {
// The main test assertions.
assert.deepEqual(touchpath.coords, [startSample, endSample]);
assert.deepEqual(touchpath.segments, spyEventSegmentation.getCalls().map((call) => call.args[0]));
assert.deepEqual(spyEventSegmentation.getCalls().map((call) => call.args[0].type), ['start', 'hold', 'end']);
});
const finalPromise = segmentationEndPromise.catch((reason) => {
@ -278,43 +199,5 @@ describe("TrackedPath", function() {
// suddenly restored during investigation can cause some very confusing behavior.
this.fakeClock.restore();
})
// A near-duplication of the recordedSegments.js version, but integrated with TrackedPath.
it("flick_ne_se.json", async function() {
let testJSONtext = fs.readFileSync(`${SEGMENT_TEST_JSON_FOLDER}/flick_ne_se.json`);
let jsonObj = JSON.parse(testJSONtext);
// Prepare some of the basic setup.
let spy = sinon.fake();
const trackedPath = new TrackedPath();
trackedPath.on('segmentation', spy);
//(PathSegmenter.DEFAULT_CONFIG, spy);
const configObj = {
replaySample: (sample) => trackedPath.extend(sample),
endSequence: () => trackedPath.terminate(false)
}
const testObj = HeadlessRecordingSimulator.prepareTest(jsonObj, configObj);
await this.fakeClock.runAllAsync();
await testObj.compositePromise;
// Any post-sequence tests to run.
const originalSegments = testObj.originalSegments;
const originalSegmentTypeSequence = originalSegments.map((segment) => segment.type);
const reproedSegments = spy.getCalls().map((call) => call.args[0]);
const reproedSegmentTypeSequence = reproedSegments.map((segment) => segment.type);
assert.sameOrderedMembers(reproedSegmentTypeSequence, originalSegmentTypeSequence);
assertSegmentSimilarity(reproedSegments[1], originalSegments[1], 'hold'); // ~820ms
assertSegmentSimilarity(reproedSegments[2], originalSegments[2], 'move'); // 'ne'
assertSegmentSimilarity(reproedSegments[3], originalSegments[3], 'hold'); // ~580ms
assertSegmentSimilarity(reproedSegments[4], originalSegments[4], 'move'); // 'se'
assertSegmentSimilarity(reproedSegments[5], originalSegments[5], 'hold'); // ~300ms
});
});
});

View file

@ -21,6 +21,7 @@ export interface RecordingTestConfig {
endSequence: () => void;
}
// Note: this class is currently only used by subsegmentation unit tests.
export class HeadlessRecordingSimulator {
// Designed to test against PathSegmenter and TrackedPath - just implement the config interface appropriately!
static prepareTest(recordingObj: RecordedCoordSequenceSet, config: RecordingTestConfig): ProcessedSequenceTest {
@ -30,7 +31,9 @@ export class HeadlessRecordingSimulator {
const sourceTrackedPath = recordingObj.inputs[0].touchpoints[0].path;
testObj.originalSamples = sourceTrackedPath.coords;
testObj.originalSegments = sourceTrackedPath.segments;
const sampleCount = testObj.originalSamples.length;
// still exists on original recorded sequences that exist before we removed segmentation.
testObj.originalSegments = sourceTrackedPath['segments'];
const lastSegment = testObj.originalSegments[testObj.originalSegments.length-1];
// Build promises designed to reproduce the events at the correct times.
@ -40,9 +43,18 @@ export class HeadlessRecordingSimulator {
}, sample.t - testObj.originalSamples[0].t);
});
// Originally we did not record the release-timing of the touch, instead using the timing of
// the last sample for the last subsegmentation's last sample. We've disabled that in order
// to get out a release in a more timely manner, but we'll still use it first if it's available.
//
// If not... we use a more current style. TODO: resolve 'release timing' aspect of input
// sequence recording + playback.
const endTime = lastSegment ? lastSegment.lastCoord.t : sourceTrackedPath.coords[sampleCount-1].t;
testObj.endPromise = timedPromise(() => {
config.endSequence();
}, lastSegment.lastCoord.t - testObj.originalSamples[0].t);
}, endTime - testObj.originalSamples[0].t);
// Wrap it all together with a nice little bow.
testObj.compositePromise = Promise.all([testObj.endPromise].concat(testObj.samplePromises)).catch((reason) => {