見本の文字の上に指やマウスでハングルを書くと、指を離すたびに、その 1 画が合っていたかをすぐ教えてくれる機能を作りました。ハングルの書き取り練習アプリ「推し韓マル」と、Web の「iffthen hangul」の書き順判定です。
間違えたときは、理由を 4 つに分けて見せます。
- 順番が違う(別の画を先に書いた)
- 位置が違う
- 形が違う(まっすぐな画を折って書いた など)
- 向きが違う(逆向きに引いた)
同じ画を 3 回間違えると、その画の書き順の動画をもう一度見せます。対象は、よく使うハングル 2,351 字すべてです。Web ではすぐに試せます。
最初の方式:全部書いてから、まとめて判定した(失敗)
最初は、文字を書き終えてから一度に見る方式でした。
- 文字認識(ML Kit)で、何の文字を書いたかを確かめる
- 画数を比べる
- 「書いた画 i ↔ 正解の画 j」の距離から最適な組み合わせを求め(ハンガリアン法)、順番を判定する
- 位置 → 5. 形 → 6. 向き
各段階は、合格・不合格・判定不能の 3 つのどれかを返します。段階を分けたおかげで「どこで間違えたか」は言えるようになりましたが、実機では問題が出続けました。
- ㅎ の点画:正解データは見本フォントの形に合わせて、点画がすべて横向きでした。実際の手書きでは、縦・斜め・点でも書きます。その結果、向きの段階で判定不能になりました
- 字母の比率:ㄷ の下の脚の長さや、ㄷ と ㅏ の大きさの比のような「人によって違う比率」を、ずれとして数えていました
- 短い画:ㅏ の短い横画が少しずれただけで、向きの比較に位置のずれが混ざり、判定不能になりました
基準を 3 回直し、合成した手書きでの合格率を 93.6 % から 99.9 % まで上げました。それでも、よく使う「하」「다」「말하다」で判定不能が残りました。
根本の原因はひとつでした。「どの書いた画が、どの正解の画なのか」をまず合わせなければならない構造そのものが、手書きの揺れに弱かったのです。
切り替え:指を離すたびに「今書くべき 1 画」とだけ比べる
Hanzi Writer や Skritter のような書き順の仕組みが使っている方式に切り替えました。見本の上に書いている間、指を離すたびに、今書くべき正解の画ひとつとだけ比べます。組み合わせを求める必要がなくなり、間違えたその場ですぐに知らせることができます。
座標はマスの比率(0〜1)にそろえました。基準の長さ G は、正解の文字全体を囲む枠の長い辺です。すべての許容値を G に対する比率にしておくと、画面の大きさや機種に関係なく同じ結果になります。
export type StepPoint = { readonly x: number; readonly y: number };
export type StepVerdict =
| { ok: true }
| { ok: false; kind: 'order' | 'position' | 'shape' | 'direction' };
/** 位置の許容(文字の大きさに対して)・正解の画の長さに比例した追加の許容。 */
export const POS = 0.15;
export const POS_LEN = 0.1;
/** 書いた画の長さ ÷ 正解の画の長さの許容範囲(形)。 */
export const LEN_MIN = 0.4;
export const LEN_MAX = 2.2;
/** 向き:逆向きに合わせた距離が、順向きに合わせた距離のこの倍数未満なら逆向きに書いた。 */
export const DIR_RATIO = 0.85;
/** 形:始まりと終わりの向き・折れ具合の差の上限(ラジアン・約 54°)。 */
export const SHAPE_PASS = 0.95;
判定の順番:順番 → 位置 → 形 → 向き
判定する関数の骨組みです(実際のコードから抜き出しています)。
export function judgeStep(done, written, expected, syllable): StepVerdict {
const k = done.length; // 今書くべき正解の画の番号
if (k >= expected.length || written.length === 0) return { ok: false, kind: 'order' };
const g = glyphSize(expected);
const exp = follow(done, expected); // 書いた人のずれの分だけ正解を動かす(後で説明)
const w = written.length > 1 ? written : [written[0], { x: written[0].x + 1e-4, y: written[0].y }];
const e = exp[k];
const wl = lengthOf(w);
const el = lengthOf(e);
const dk = sym(w, e);
// 1 順番:残りの別の画にずっと近く重なり、長さと形もその画と合う = その画を先に書いた
for (let j = k + 1; j < exp.length; j += 1) {
const ej = exp[j];
const lj = lengthOf(ej);
// 計算の軽い条件から(長さ → 重なりの距離 → 形)
if (wl < lj * ORDER_LEN_MIN || wl > lj * ORDER_LEN_MAX) continue;
const dj = sym(w, ej);
if (dj >= POS * g + POS_LEN * lj || dj >= dk * ORDER_MARGIN) continue;
if (!closed(ej) && lj >= SHORT * g && shapeDiffEither(w, ej, g) > SHAPE_PASS) continue;
return { ok: false, kind: 'order' };
}
// 2 位置
const tol = POS * g + POS_LEN * el;
if (near(w, e) >= tol) return { ok: false, kind: 'position' };
// 3 形・4 向き(閉じた画 ㅇ = 丸さ・回る向き)
if (closed(e)) {
if (Math.abs(area(w)) / Math.max(wl * wl, 1e-9) < LOOP_MIN) return { ok: false, kind: 'shape' };
return Math.sign(area(w)) !== Math.sign(area(e)) ? { ok: false, kind: 'direction' } : { ok: true };
}
if (el >= SHORT * g && (wl < el * LEN_MIN || wl > el * LEN_MAX)) return { ok: false, kind: 'shape' };
if (shapeDiffEither(w, e, g) > SHAPE_PASS) return { ok: false, kind: 'shape' };
return pairDist(w, e, true) < pairDist(w, e, false) * DIR_RATIO
? { ok: false, kind: 'direction' }
: { ok: true };
}
(実際のコードには、「点のように短く書いた画」と「点画」の分岐が、位置の判定の前にもうひとつあります。下の「つまずいたこと」で触れます。)
距離の関数を、用途ごとに 3 つに分ける
ひとつの「画どうしの距離」ですべてを判定すると、判定どうしが干渉します。用途ごとに別の距離を使います。
/** 片方の画の点が、もう片方の線からどれだけ離れているかの平均。 */
function oneWay(a, b) {
const ra = resample(a);
return ra.reduce((s, q) => s + toPoly(q, b), 0) / ra.length;
}
/** 位置:両方向のうち小さいほう(長さの差・端のはみ出しは数えない・長さは形で見る)。 */
function near(a, b) { return Math.min(oneWay(a, b), oneWay(b, a)); }
/** 順番:互いの線までの平均(書き始めの位置に関係しない)。 */
function sym(a, b) { return (oneWay(a, b) + oneWay(b, a)) / 2; }
/** 向き:同じ数に打ち直した点どうしの平均距離(rev = 片方を逆向きに)。 */
function pairDist(a, b, rev) {
const ra = resample(a), rb = resample(b);
let s = 0;
for (let i = 0; i < ra.length; i += 1) {
const q = rb[rev ? rb.length - 1 - i : i];
s += Math.hypot(ra[i].x - q.x, ra[i].y - q.y);
}
return s / ra.length;
}
- 位置(
near)は、短いほうを基準にします。少し短く・長く書いたことを、位置の問題として扱わないためです。長さは形の段階が別に見ます - 向き(
pairDist)は、同じ間隔で 32 点ずつ打ち直したうえで、順向きに合わせた距離と逆向きに合わせた距離を比べます。位置のずれが混ざりません
形は「始まりの向き・終わりの向き・折れ具合」だけを見る
点をひとつずつ比べると、脚の長さの比率のような個人差に引っかかります。そこで、特徴を 3 つだけ取り出します。
/** 始まりと終わりの向き(trim = 端の小さな跳ねを除く)。 */
function feature(s, g, trim) {
const v = trim ? trimHooks(s, g) : simplify(s, SIMPLIFY_EPS * g); // RDP で単純化
const l = lengthOf(v);
const a = pointAt(v, l * END_SPAN); // 前から 20 % の地点
const b = pointAt(v, l * (1 - END_SPAN)); // 後ろから 20 % の地点
return {
start: Math.atan2(a.y - v[0].y, a.x - v[0].x),
end: Math.atan2(v[v.length - 1].y - b.y, v[v.length - 1].x - b.x),
};
}
/** 形の差 = 始まりの向きの差・終わりの向きの差・折れ具合の差のうち最大。 */
function shapeDiff(w, e, g) {
let best = Infinity;
for (const te of [true, false]) {
const r = feature(e, g, te);
for (const tw of [true, false]) {
const f = feature(w, g, tw);
best = Math.min(best, Math.max(
ang(f.start, r.start), ang(f.end, r.end),
Math.abs(ang(f.start, f.end) - ang(r.start, r.end)),
));
}
}
return best;
}
こうすると、「ㅏ を ㄴ + ㅣ のように折って書いた」「折れた画をまっすぐ書いた」は捕まえ、脚の長さや大きさの違いは通します。
trimHooks は、指を置くときや離すときにできる短い跳ねを切り落とします。跳ねを切ったもの・切らないものの 4 通りの組み合わせのうち、いちばん近い組を使うのがポイントです。跳ねを正解の一部と取り違えることがなくなりました。
スポンサーリンク
スポンサーリンク
ずらして書いても後の画が崩れないように:follow()
見本より少し右に寄せて書き進めると、後の画ほど見本との距離が積み重なり、「位置が違う」になってしまいます。そこで、正しく書けた画から、書いた人のずれ(移動・大きさ)を推定し、残りの正解の画をその分だけ動かしてから比べます。
export function follow(done, expected) {
if (done.length === 0) return expected.map((s) => s.slice());
const a = [], b = []; // a = 正解の点・b = 書いた点(同じ数に打ち直して組にする)
done.forEach((w, i) => {
const rw = resample(w), re = resample(expected[i]);
const rev = pairDist(w, expected[i], true) < pairDist(w, expected[i], false);
rw.forEach((p, k) => { a.push(re[rev ? re.length - 1 - k : k]); b.push(p); });
});
const ca = centroid(a), cb = centroid(b);
let num = 0, den = 0;
a.forEach((p, i) => {
num += (p.x - ca.x) * (b[i].x - cb.x) + (p.y - ca.y) * (b[i].y - cb.y);
den += (p.x - ca.x) ** 2 + (p.y - ca.y) ** 2;
});
// 大きさは正しく書けた画が 2 つ以上のときだけ・0.8〜1.25 に制限
const scale = den > 1e-9 && done.length >= 2 ? Math.min(1.25, Math.max(0.8, num / den)) : 1;
return expected.map((s) => s.map((p) => ({ x: cb.x + (p.x - ca.x) * scale, y: cb.y + (p.y - ca.y) * scale })));
}
回転は推定しません。回転まで許すと、「傾けて書いた画」が向きと形の判定をすり抜けてしまいます。判定が甘くなる方向に自由度を増やさないのが原則です。
品質はテストで守る
人の手書きを何千個も集めることはできません。そこで、同じ条件なら毎回同じ手書きを作る生成器を作り、Jest のテストに組み込みました。
/** 決定的な乱数(mulberry32)・同じ seed = 同じ手書き = 同じ結果。 */
function rng(seed) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
生成器が正解の画に混ぜる揺れは、次のとおりです。
- 文字の位置 ±3 %・大きさ ±6 %・傾き
- 字母ごとの大きさ・位置・回転、画ごとの長さ・回転・位置
- 折れた画の脚の長さ、点画(ㅎ・ㅊ の最初の画)を横・縦・斜め・点で
- ㅇ の書き始めの位置・つぶれ具合・閉じきらない形、端の払い、手の震え、指のタッチの間隔
間違った手書きも 9 種類作ります(順番の入れ替え・逆向き・まっすぐな画を折って書く・折れた画をまっすぐ書く・ㅇ を逆回り・点画を上向きに など)。どれもその画で不合格になって、はじめてテストが通ります。
確定データ 2,351 字すべてで測った結果です。
| 場合 | 期待 | 結果 |
|---|---|---|
| 正しい手書き | 合格 | 99.86 % |
| 順番の入れ替え | その画で不合格 | 98.8 % |
| 長い画を逆向き | 〃 | 99.8 % |
| まっすぐな画を折って書く | 〃 | 99.7 % |
| 折れた画をまっすぐ書く | 〃 | 94.4 %(残りも順番違いで不合格) |
| ㅇ を逆回り・点画を上向き | 〃 | 100 % |
テストの時間の都合で、生成器のテストは 3 文字に 1 文字だけ回し、「正解そのまま = 合格」のテストは全文字を回しています。
つまずいたこと
- まねた手書きで測った 100 % を信じない。初期の生成器は正解データをほぼそのまま写していたので、「合格 100 %」が出ました。実機ではすぐに崩れました。生成器には、実際の手書きで見た揺れ(点画を縦に書くなど)を必ず入れます。実機で判定不能が出たら、その形を生成器に足し、テストで直します
- データの「正解の形」が、手書きの正解と違うことがある。ㅎ・ㅊ の点画は、フォントでは横向きでも、正しい手書きでは横・縦・斜め・点のどれも使われます。こうした画は形を見ず、向きだけを見ます(右下方向に引いたか・−40°〜150° を許容・← ↑ ↖ ↗ は不合格)
- 点のように短く書いた画は、形も向きも見られません。正解が短い画か、点画の位置なら合格にします
- 文字認識は、1 位の候補が完全に一致したときだけ正解にする。「上位 3 つに入れば正解」にしたところ、学習者の間違いはたいてい似た文字(가 ↔ 거)なので、間違えて書いても 3 位以内に入り、正解扱いになっていました
- 計算の軽い条件から。順番の判定は残りの画をすべてなめるので、長さの比較 → 重なりの距離 → 形の順でふるい落とします。結果は同じで、速くなるだけです
- 判定のコードは純粋関数にする。画面やネットワークから切り離しておいたところ、同じ TypeScript のファイルを、アプリと Web の判定サーバー(Cloudflare Workers)がそのまま共有できました。判定サーバーの作り方は、後の回で書きます
おわりに
- 「全部書いてから組み合わせを合わせる」より、「指を離すたびに 1 画」のほうが、手書きの揺れにずっと強い
- 距離は、用途ごとに(位置・順番・向き)別に定義する
- 形は、点の比較ではなく、始まりと終わりの向きと折れ具合で見る
- 品質は、決定的な生成器とテストで、数字で守る
スポンサーリンク
スポンサーリンク
この記事について
自社のハングル書き取り練習アプリ「推し韓マル」と、Web「iffthen hangul」の書き順判定を作ったときの記録です。コードは実際のソースから抜き出し、コメントを読みやすく縮めています。動作を変える修正はしていません。合格率は、ソースのテストで測った値です。
- 実際に書いてみる(無料・インストール不要):iffthen hangul
- 同じ判定で毎日の復習までできるアプリ:推し韓マル(App Store・Google Play)


コメント