End-to-end guide for the gesture-recognition model: how data is collected, how it’s turned into training inputs, how the models are trained and compared, and how the winning model ships to the browser app.
underline, box, curly, delete, squarebracket, wavybracket, circlebracket, none.app-v2/src/modifiers/predict.ts).trainer/ — 6 Python files, a Dockerfile, an Akash SDL.Each sample is one stroke = an ordered list of (x, y, p) points (screen
pixels; p = pressure, optional) plus a class label.
One self-describing record per line, points as compact [x, y, p] arrays. One
file per contributor so you can split without leakage:
data/raw_jsonl/<contributor>.jsonl
{"label":"box","stroke":[[135.0,112.3,0.7],[134.1,114.7,0.7]],"contributor":"Sang","pointer":"pen"}
Required: label, stroke (≥2 points). Optional and recommended for the future:
pointer (pen|touch|mouse), canvas ([w,h]), ts (ms). The trainer ignores
unknown fields, so adding metadata never breaks anything.
{x, y, p}.none should be diverse
(letters, dots, scribbles, brackets that aren’t gestures) so the model learns
to stay quiet.The legacy format was data/raw/<contributor>/<label>.json →
{"modifiers":[{"type","stroke":[{x,y,p}]}]}. Convert losslessly (≈5× smaller):
python convert_data.py --src ../data/raw --out ../data/raw_jsonl
The loader reads both formats, so you can mix old and new.
Three representations are derived from each stroke; the renderer and features are
1:1 ports of the app’s raster.ts / features.ts, so training inputs equal
inference inputs exactly.
| Representation | Shape | Used by |
|---|---|---|
| Image — 96×96 raster (white bg, black stroke, lw 3, non-uniform fill) | (96,96,3) in [0,1] |
image, hybrid |
| Geometric — 12 hand-crafted features (closure, compactness, aspect, …) | (12,) |
geometric, hybrid, tcn_hybrid |
Sequence — arc-length-resampled points [x, y, dx, dy] |
(64,4) |
tcn, tcn_hybrid |
Augmentation (train time): each raw stroke is jittered ~5× — small rotation (±8°), per-axis scale (0.85–1.2×), and ~1px point noise — applied to the raw points, so image, features, and sequence all stay consistent.
Trained by one harness so they’re directly comparable (trainer/models.py):
| Model | Input | Role | Idea |
|---|---|---|---|
geometric |
features | baseline | MLP on 12 hand-crafted features |
image |
image | baseline | MobileNetV3-Small CNN on the raster |
hybrid |
image + features | app model | CNN ⊕ features — what the browser deploys |
tcn |
sequence | candidate | dilated temporal ConvNet on the raw stroke |
tcn_hybrid |
sequence + features | candidate | TCN ⊕ features (usually strongest) |
The two baselines isolate “what does each modality alone buy you?”; the
candidates are compared against the deployed hybrid to justify an upgrade.
cd trainer
pip install -r requirements.txt # Python 3.10–3.12; TF 2.19 + tfjs 4.22
# one model
python train.py --model tcn_hybrid --augment 5
# the app model, and export it to TF.js
python train.py --model hybrid --finetune
# full sweep → out/comparison.md
python train.py --model all --augment 5 --finetune
Key flags: --epochs 40 --batch-size 64 --augment 5 --val-split 0.15,
--finetune (unfreeze the CNN for a 2nd low-LR pass, image/hybrid only),
--mixed-precision (float16 on GPU), --quantize uint16 (tfjs export).
Outputs (out/): <model>.keras each model, comparison.md/.json
(sweep), tfjs/ + labels.json (the app model). GPU is used automatically when
present.
--model all writes out/comparison.md — a table of val accuracy, macro-F1,
params, size, and per-sample CPU latency per model, ranked. Use it to argue:
comparison.json has the same data for plots.
The hybrid exports to the exact TF.js contract the app already loads —
inputs img_input (96,96,3) + feature_input (12), output output_0 (8-way
softmax), graph-model:
# write the tfjs model AND copy it into the app (explicit — never automatic)
python train.py --model hybrid --finetune --app-tfjs-dir ../app-v2/public/tfjs
The
tcn/tcn_hybridmodels use a different input (the sequence), so deploying one of those would also needpredict.tsto feed the resampled sequence. The image/feature contract is unchanged forhybrid.
Reload the app — tf.loadGraphModel('/tfjs/model.json') picks up the new model.
Training runs through AkashTrainer (no separate Akash config in this repo).
Paste this repo’s URL; AkashTrainer builds the repo-root Dockerfile and runs
the sweep. trainer/train.py calls akash_train.publish_results(), so each run
publishes to the sweep leaderboard.
In AkashTrainer’s /sweep wizard:
python3 trainer/train.py --no-export--model ∈ geometric, image, hybrid, tcn, tcn_hybrid
(optionally add --epochs, --augment, --finetune)Each lease trains one model and pushes its metrics + best .keras to a
trained-output/… branch. The v2 dataset is baked into the image — no upload.
Local GPU equivalent (same root image):
docker build -t onepen-trainer .
docker run --gpus all -v "$PWD/out:/output" \
-e TRAIN_CMD="python3 trainer/train.py --model all --no-export --data-dir data/raw_jsonl --out-dir /output" \
onepen-trainer
The browser TF.js model is exported locally (the CUDA image skips it via
--no-export); see §6.