A single-page, zero-dependency-server synthetic handwriting generator for the Georgian (Mkhedruli) script. Everything runs client-side in Canvas 2D, so it can be hosted on GitHub Pages as-is.
Live demo: enable GitHub Pages on this repo (Settings → Pages → deploy from main, root)
and open https://<user>.github.io/GeoScribe/.
Instead of rendering whole strings atomically, each character is rendered separately and composed with a chain of parametric distortions — a miniature SynthTIGER for Georgian:
- Per-glyph random affine — rotation, scale and shear jitter, plus a global slant.
- Kerning jitter with negative spacing — occasional overlaps produce fake ligatures, crucial for handwriting realism.
- Baseline drift — slow sine wave + mean-reverting random walk.
- Elastic distortion — a smooth random displacement field (Gaussian-filtered noise), applied via backward mapping with bilinear sampling.
- Stroke-width variation — morphological erode/dilate, globally and per-glyph.
- Ink & paper model — per-glyph darkness variation, edge bleed, ink hues, paper texture noise, optional ruled lines.
Every knob is a distribution parameter (elastic α/σ, baseline_amp, overlap_prob, …).
Randomness is fully seeded (mulberry32), so every sample is reproducible.
The Dataset .zip button generates N samples (sentences + random word lines, random seeds, current knob settings) and downloads an HF-imagefolder-style archive:
images/00000.webp …
metadata.jsonl # {"file_name": "images/00000.webp", "text", "split", "seed", "source", "writer", "font"}
params.json # generator + export configuration used
Export options (training-optimized): WebP/JPEG/PNG with quality control, downscale to final training height (48–64 px — full-res RGBA PNGs are ~5× wasted bytes when the loader resizes anyway), and grayscale. WebP q0.85 @ 64 px ≈ 10–25 KB/image, so 50k samples ≈ 0.7–1 GB instead of ~15 GB of PNGs.
Leakage-proof splits: every sample gets a split field computed as a hash of its
text — identical text always lands in the same split, so no sentence can appear in
both train and val. (Text-overlapping splits let a decoder memorize the corpus instead
of learning to read; with a small corpus this dominates the loss descent.)
corpus/words.txt and corpus/sentences.txt (one item per line) replace the embedded
mini-corpus at runtime — served both by GitHub Pages and any local static server, so
the corpus can grow to book scale without touching or bloating the HTML.
Corpus size should exceed dataset image count to prevent text memorization.
For datasets beyond the browser-zip range (100k+ samples), the headless driver reuses the exact same page:
python3 -m http.server # local dev: same app at localhost:8000
npm install puppeteer # one-time
node tools/generate.mjs --n 50000 --out dataset --set handprob=0.5
It writes images/ + metadata.jsonl straight to disk (no zip memory limit) via the
in-page window.GEOSCRIBE.renderSample hook — the same code path as the batch button.
No framework, no build step: the "local app" and the public site are the same file.
The drawing board captures each of the 33 Mkhedruli letters as pointer-event trajectories — real (x, y, t) online handwriting data, not pixels:
- strokes are arc-length resampled (44 pts) with real pen speed carried per point, normalized against the median velocity;
- draw each letter ≥2× and generation morphs between random pairs of your executions plus smooth correlated noise — a cheap measured motor-variability model;
- rendering thins strokes on fast segments (sigma-lognormal-flavored pen model),
with
pen width,speed thinning, andmotor jitterknobs; - hand glyphs flow through the same Stage A composition pipeline (baseline drift, overlaps, elastic field, ink model); letters without samples fall back to fonts;
- the writer profile persists in localStorage and can be exported/imported as a small JSON file — one file ≈ one writer.
- Word mode on the drawing board captures whole handwritten words (wider canvas, any word you type or a random corpus word). When generated text contains a drawn word, a morphed instance of the whole word is used — real co-articulation and spacing — with letter-composition fallback otherwise.
- Writer profiles: create/delete/merge named writers; any subset can be active for generation. Imports add writers (auto-renamed on collision), never overwrite — so community-contributed profile JSONs can be pooled into a writer population.
- Hand/font mixing:
hand probabilitysets the per-sample chance of a hand-written vs font-rendered sample. The source is decided per sample, not per glyph — a real scanned line is one writer with one pen; the mixture lives across the batch. labels.jsonlrecordssource(hand/font) andwriterper sample, enabling writer-disjoint splits downstream — required for a credible benchmark.
Adjacent hand-drawn letters within a word can connect: a Bernoulli connect-vs-lift
decision per letter pair — P(connect) = knob × exp(−1.3·|Δy| − 2·max(0, gap−0.7)),
penalizing vertical travel (Mkhedruli has 13 ascenders + 13 descenders) — then a
G1-continuous cubic Bézier bridge along the exit/entry tangents (handles = gap/3),
drawn thinner (fast transit stroke) before elastic/ink processing so joins age with
the rest of the line. The cursive joining knob sweeps formal print-hand (0) to
flowing diary-hand (1) — the two registers documented for Georgian handwriting.
- Per-letter mean-shape + covariance estimation once ≥5 samples exist.
- Stage C — small trajectory RNN/diffusion trained on parametric + real scanned samples.
19 verified Mkhedruli styles: 13 handwriting/calligraphic hands (DM writer-hand fonts, 3D Unicode, Calligraphy, MCh Vardi ×2) + 6 print styles (BPG Glaho, BPG Nino Elite Caps (Mtavruli), Gancxadebebi, Kolkheti, Noto Sans/Serif Georgian via Google Fonts).
Every local font in fonts/ was validated with fontTools (full U+10D0–U+10F0 cmap
coverage + non-empty glyph outlines) plus a visual render check. Three fonts are legacy
ASCII-mapped Georgian fonts; the generator transliterates each character to its Latin
keyboard slot before rendering (standard Georgian keyboard mapping). Rejected from the
source collection: 4 fonts containing only Latin glyphs, 1 with a scrambled mapping,
2 with nonstandard symbol cmaps (tofu).
Font modes (research-motivated — style diversity beats per-sample realism for downstream HTR pretraining, cf. "Quo Vadis HTG for HTR?", ICCV 2025):
- fixed font,
- random handwriting font per sample (default; the font id is recorded in
labels.jsonl), - random handwriting font per glyph (maximum diversity, unrealistic but useful).
fonts/ have not been verified — check before publishing rendered datasets or hosting
the fonts publicly.