PR
個人開発 JestTypeScriptハングル個人開発手書き判定推し韓マル開発ノート書き順

手書きの書き順を「1 画ずつ」自動で判定するエンジンを作った 全部書いてから照合する方式を捨てた理由

タブレットにペンで文字を書いている手元 個人開発

見本の文字の上に指やマウスでハングルを書くと、指を離すたびに、その 1 画が合っていたかをすぐ教えてくれる機能を作りました。ハングルの書き取り練習アプリ「推し韓マル」と、Web の「iffthen hangul」の書き順判定です。

間違えたときは、理由を 4 つに分けて見せます。

  • 順番が違う(別の画を先に書いた)
  • 位置が違う
  • 形が違う(まっすぐな画を折って書いた など)
  • 向きが違う(逆向きに引いた)

同じ画を 3 回間違えると、その画の書き順の動画をもう一度見せます。対象は、よく使うハングル 2,351 字すべてです。Web ではすぐに試せます。

スポンサーリンク

最初の方式:全部書いてから、まとめて判定した(失敗)

最初は、文字を書き終えてから一度に見る方式でした。

  1. 文字認識(ML Kit)で、何の文字を書いたかを確かめる
  2. 画数を比べる
  3. 「書いた画 i ↔ 正解の画 j」の距離から最適な組み合わせを求め(ハンガリアン法)、順番を判定する
  4. 位置 → 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 通りの組み合わせのうち、いちばん近い組を使うのがポイントです。跳ねを正解の一部と取り違えることがなくなりました。

スポンサーリンク

モバイルアプリ開発の知識地図:一冊でiOS/Android/Flutterの開発技術が学べる本

モバイルアプリ開発の知識地図:一冊でiOS/Android/Flutterの開発技術が学べる本

楽天で見る

スポンサーリンク

モバイルアプリ開発の知識地図 / 國師誠也

モバイルアプリ開発の知識地図 / 國師誠也

Yahoo!ショッピングで見る

ずらして書いても後の画が崩れないように: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 文字だけ回し、「正解そのまま = 合格」のテストは全文字を回しています。

つまずいたこと

  1. まねた手書きで測った 100 % を信じない。初期の生成器は正解データをほぼそのまま写していたので、「合格 100 %」が出ました。実機ではすぐに崩れました。生成器には、実際の手書きで見た揺れ(点画を縦に書くなど)を必ず入れます。実機で判定不能が出たら、その形を生成器に足し、テストで直します
  2. データの「正解の形」が、手書きの正解と違うことがある。ㅎ・ㅊ の点画は、フォントでは横向きでも、正しい手書きでは横・縦・斜め・点のどれも使われます。こうした画は形を見ず、向きだけを見ます(右下方向に引いたか・−40°〜150° を許容・← ↑ ↖ ↗ は不合格)
  3. 点のように短く書いた画は、形も向きも見られません。正解が短い画か、点画の位置なら合格にします
  4. 文字認識は、1 位の候補が完全に一致したときだけ正解にする。「上位 3 つに入れば正解」にしたところ、学習者の間違いはたいてい似た文字(가 ↔ 거)なので、間違えて書いても 3 位以内に入り、正解扱いになっていました
  5. 計算の軽い条件から。順番の判定は残りの画をすべてなめるので、長さの比較 → 重なりの距離 → 形の順でふるい落とします。結果は同じで、速くなるだけです
  6. 判定のコードは純粋関数にする。画面やネットワークから切り離しておいたところ、同じ TypeScript のファイルを、アプリと Web の判定サーバー(Cloudflare Workers)がそのまま共有できました。判定サーバーの作り方は、後の回で書きます

おわりに

  • 「全部書いてから組み合わせを合わせる」より、「指を離すたびに 1 画」のほうが、手書きの揺れにずっと強い
  • 距離は、用途ごとに(位置・順番・向き)別に定義する
  • 形は、点の比較ではなく、始まりと終わりの向きと折れ具合で見る
  • 品質は、決定的な生成器とテストで、数字で守る

スポンサーリンク

Coding Assistant対応 iPhoneアプリ開発集中講座 Xcode 26/iOS 26

Coding Assistant対応 iPhoneアプリ開発集中講座 Xcode 26/iOS 26

楽天で見る

スポンサーリンク

iPhoneアプリ開発集中講座 iOS 26 Xcode 26 AIアシスタント(Coding Assistant)も解説

iPhoneアプリ開発集中講座 iOS 26 Xcode 26 AIアシスタント(Coding Assistant)も解説

Yahoo!ショッピングで見る

この記事について

自社のハングル書き取り練習アプリ「推し韓マル」と、Web「iffthen hangul」の書き順判定を作ったときの記録です。コードは実際のソースから抜き出し、コメントを読みやすく縮めています。動作を変える修正はしていません。合格率は、ソースのテストで測った値です。

コメント

タイトルとURLをコピーしました