# AI 꾸러미 — 숫자 손글씨 알아보기 — Handwritten digit recognition (template matching)
> 손으로 쓴 숫자를 28×28 칸으로 줄여 0 ~ 9 기준 모양 여럿과 거리 지도(챔퍼 거리)로 비교해, 가장 닮은 숫자와 몇 % 인지 맞힌다.  
> 견본: https://ai-techstudio.web.app/#t/i316

이 문서를 코드 도우미(Claude Code · Cursor · ChatGPT 등)에 그대로 주면 돼요. 「## 주문서」 가 할 일, 나머지는 참고 자료예요.

## 주문서

### 만들어 줘: 숫자 손글씨 알아보기 — Handwritten digit recognition (template matching)

#### 1. 목표
손으로 답 쓰기 계산 문제에 손글씨 숫자 알아보기를 넣어 줘 — 쓴 숫자를 28×28 로 줄여 0 ~ 9 기준 모양과 거리 비교, 맞힌 숫자와 % 막대. 분위기는 연필 공책.

#### 2. 핵심 기술 용어
- **Handwritten digit recognition (template matching)** — 손글씨 숫자 알아보기 — 기준 모양과 비교
- **Chamfer distance** — 두 그림의 선이 서로 얼마나 떨어져 있나
- **Distance transform (two-pass)** — 두 번 훑어 칸마다 가장 가까운 선까지 거리
- **Softmax** — 점수를 합이 1 인 확률로

#### 3. 환경
- 플랫폼: HTML Canvas 2D (TypeScript), requestAnimationFrame 루프, 라이브러리 없이
- 화면: 2D · 브라우저 — PC 와 폰(390px 폭) 모두, devicePixelRatio 맞춰 또렷하게, 60fps 목표

#### 4. 조건
- 먼저 상자 기준으로 크기 · 자리 맞추기 (긴 변 20칸, 가운데)
- 거리 지도는 두 번 훑기 (왼쪽 위 → 오른쪽 아래, 반대) — 픽셀마다 전부 찾기 금지
- 챔퍼는 양방향 (내 → 기준 + 기준 → 나)
- 기준 모양은 숫자마다 여러 쓰는 법 · 기울기로 미리 굽고 캐시
- 마지막 획 뒤 잠깐 기다렸다 판정 (여러 획 숫자)

#### 5. 완성 기준 (이게 보이면 성공)
- 숫자를 쓰고 손을 떼면 잠시 뒤 큰 숫자와 「7 — 92%」, 0 ~ 9 막대가 자란다
- 28×28 격자에 줄인 그림이 보인다
- 크게 · 작게 · 비스듬히 써도 대체로 맞힌다
- 결과가 나온 뒤 새로 쓰면 이전 글씨가 지워진다

#### 6. 진행 방식
- 핵심 코드 위주로, 설명은 짧게. 내 프로젝트에 끼워 넣기 쉬운 함수 · 클래스로 나눠 줘.
- 처음 화면에 바로 결과가 보이게, 그리고 켬/끔(또는 전/후) 비교를 할 수 있게 만들어 줘.
- 그림 · 소리 · 모델 파일이 필요하면 코드로 만든 임시 대체물로 먼저 돌아가게 하고, 진짜 파일로 바꿀 자리를 표시해 줘.
- 마지막에 「확인 방법」(무엇을 보면 성공인지)과 「조절할 값」 목록을 짧게 정리해 줘.
- 답변과 코드 주석은 한국어로 해 줘.

## 원리
- 쓴 획들의 상자를 구해 긴 변이 20칸이 되게 줄여 28×28 캔버스 가운데에 굵기 2.3 으로 다시 그린다 — 크기 · 자리 차이가 사라진다.
- 거리 지도: 선 칸 = 0, 나머지 = 99 로 두고 왼쪽 위 → 오른쪽 아래, 다시 거꾸로 두 번 훑으며 이웃 + 1(대각선 + 1.414)로 줄인다.
- 챔퍼 거리 = (내 선 칸에서 기준 거리 지도 값 평균) + (기준 선 칸에서 내 거리 지도 값 평균). 양쪽을 다 봐야 「덜 쓴 것」도 「더 쓴 것」도 벌점.
- 기준 모양은 숫자마다 몇 가지 쓰는 법 × 기울기 3가지(−0.12 · 0 · 0.12)를 미리 구워 둔다. 숫자마다 가장 작은 거리 → exp(−5 × 거리) 로 확률.
- 손 떼고 0.65초 기다렸다 맞힌다 — 여러 획 숫자(4 · 5)를 다 쓸 시간.

## 핵심 코드 — 두 번 훑는 거리 지도 · 양방향 챔퍼 거리
(발췌: demos/demosDrawTools.ts distField() · chamfer() · digitRecog() classify() 를 정리)
```ts
/** gray: 28×28 (0 ~ 1). 칸마다 가장 가까운 선 칸까지 거리 */
function distField(gray: Float32Array): Float32Array {
  const D = new Float32Array(784);
  for (let i = 0; i < 784; i++) D[i] = gray[i] > 0.3 ? 0 : 99;
  const at = (x: number, y: number) => (x < 0 || y < 0 || x > 27 || y > 27 ? 99 : D[y * 28 + x]);
  for (let y = 0; y < 28; y++) for (let x = 0; x < 28; x++) {          // 왼쪽 위 → 오른쪽 아래
    const i = y * 28 + x;
    D[i] = Math.min(D[i], at(x - 1, y) + 1, at(x, y - 1) + 1, at(x - 1, y - 1) + 1.414, at(x + 1, y - 1) + 1.414);
  }
  for (let y = 27; y >= 0; y--) for (let x = 27; x >= 0; x--) {        // 거꾸로
    const i = y * 28 + x;
    D[i] = Math.min(D[i], at(x + 1, y) + 1, at(x, y + 1) + 1, at(x + 1, y + 1) + 1.414, at(x - 1, y + 1) + 1.414);
  }
  return D;
}
/** 양방향 — 덜 쓴 것도 더 쓴 것도 벌점 */
function chamfer(gu: Float32Array, du: Float32Array, gt: Float32Array, dt: Float32Array) {
  let a = 0, na = 0, b = 0, nb = 0;
  for (let i = 0; i < 784; i++) {
    if (gu[i] > 0.3) { a += dt[i]; na++; }
    if (gt[i] > 0.3) { b += du[i]; nb++; }
  }
  return a / Math.max(1, na) + b / Math.max(1, nb);
}
function classify(gray: Float32Array, tpls: { digit: number; gray: Float32Array; df: Float32Array }[]) {
  const df = distField(gray);
  const best = new Array(10).fill(99) as number[];
  for (const t of tpls) best[t.digit] = Math.min(best[t.digit], chamfer(gray, df, t.gray, t.df));
  const ex = best.map((s) => Math.exp(-s * 5));
  const sum = ex.reduce((p, q) => p + q, 0) || 1;
  return ex.map((v) => v / sum);                                         // 0 ~ 9 확률
}
```

## 흔한 실수 · 확인 목록
- [ ] **크기 · 자리를 맞추지 않으면 작게 쓴 숫자를 못 알아본다** — 상자 긴 변을 20칸으로, 28칸 가운데에 다시 그린다 (MNIST 와 같은 준비).
- [ ] **한쪽 방향 거리만 보면 「1」이 모든 숫자와 가깝다** — 내 → 기준, 기준 → 나 양방향을 더한다.
- [ ] **여러 획 숫자를 첫 획 끝에서 판정해 버린다** — 마지막 획 뒤 0.65초 기다렸다 판정, 새 획을 시작하면 기다림을 취소.
- [ ] **getImageData 를 자주 부르면 느리다는 경고가 뜬다** — 28×28 캔버스를 getContext("2d", { willReadFrequently: true }) 로 만든다.

## 완성 기준 체크리스트
- [ ] 숫자를 쓰고 손을 떼면 잠시 뒤 큰 숫자와 「7 — 92%」, 0 ~ 9 막대가 자란다
- [ ] 28×28 격자에 줄인 그림이 보인다
- [ ] 크게 · 작게 · 비스듬히 써도 대체로 맞힌다
- [ ] 결과가 나온 뒤 새로 쓰면 이전 글씨가 지워진다

## 이 기술 정보
- id: `i316` · 분류: 2D · 화면 › 그리기 도구 · 2D · 난이도 어려움 · 폰 부담 가벼움 (폰 OK) — 28×28 = 784칸 × 기준 모양 수십 개 비교. 손 뗄 때 한 번 — 몇 ms.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i316
- 쓰면 좋을 때: 계산 문제 답을 손으로 쓰게 할 때 (숫자 한 자리) / 「컴퓨터는 어떻게 글씨를 알아보나」 설명
- 쓰지 말 때: 여러 자리 · 글자 · 문장 — 한 칸 한 숫자만 된다. 대신 칸을 나눠 받거나 진짜 신경망(MNIST 학습 모델) / 틀리면 안 되는 시험 — 템플릿 비교는 90% 안팎. 고르기 단추를 함께 둔다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### digitRecog — `src/demos/demosDrawTools.ts:1846`
```ts
function digitRecog(): Tool {
  const c28 = document.createElement('canvas');
  c28.width = c28.height = 28;
  const g28 = c28.getContext('2d', { willReadFrequently: true })!;
  const tpls = getTemplates(g28);
  const BOX: Rect = { x: 36, y: 34, w: 150, h: 188 };
  let strokes: Strokes = [];
  let cur: P[] | null = null;
  let wait = -1;
  let result: { d: number; probs: number[]; t: number } | null = null;
  const gray = new Float32Array(784);
  let hasGray = false;
  let showGrid = true;
  function classify(): void {
    if (!strokes.length) return;
    rasterize(strokes, gray, g28);
    hasGray = true;
    const df = distField(gray);
    const best = new Array(10).fill(99) as number[];
    for (const t of tpls) best[t.digit] = Math.min(best[t.digit]!, chamfer(gray, df, t.gray, t.df));
    const ex = best.map((s) => Math.exp(-s * 5));
    const sum = ex.reduce((a, b) => a + b, 0) || 1;
    const probs = ex.map((v) => v / sum);
    let d = 0;
    probs.forEach((p, i) => {
      if (p > probs[d]!) d = i;
    });
    result = { d, probs, t: 0 };
  }
  return {
    cursor: () => 'pencil',
    color: () => '#1d2747',
    begin(p) {
      if (result) {
        strokes = [];
        result = null;
        hasGray = false;
      }
      cur = [p];
      strokes.push(cur);
      wait = -1;
    },
    drag(p) {
      if (cur && dist(cur[cur.length - 1]!, p) > 0.6) cur.push(p);
    },
    end() {
      cur = null;
      wait = 0.65;
    },
    step(dt) {
      if (wait > 0) {
        wait -= dt;
        if (wait <= 0) classify();
      }
      if (result) result.t += dt;
    },
    clear() {
      strokes = [];
      cur = null;
      result = null;
      hasGray = false;
      wait = -1;
    },
    draw(g) {
      clipPaper(g, PAPER);
      // 쓰는 칸
      g.setLineDash([4, 3]);
      g.strokeStyle = 'rgba(47,111,224,0.45)';
      g.lineWidth = 1;
      rrect(g, BOX.x, BOX.y, BOX.w, BOX.h, 8);
      g.stroke();
      g.setLineDash([]);
      g.fillStyle = 'rgba(47,111,224,0.55)';
      g.font = `600 8px ${F}`;
      g.textAlign = 'center';
      g.textBaseline = 'middle';
      g.fillText('숫자 하나를 쓰세요 (0 ~ 9)', BOX.x + BOX.w / 2, BOX.y + BOX.h + 7);
      g.strokeStyle = '#1d2747';
      g.lineWidth = 7;
      for (const s of strokes) {
        if (s.length === 1) {
          g.beginPath();
          g.arc(s[0]!.x, s[0]!.y, 3, 0, Math.PI * 2);
          g.fillStyle = '#1d2747';
          g.fill();
          continue;
        }
        smoothPath(g, s);
        g.stroke();
      }
      // 28×28 격자
      const gx = 206;
      const gy = 36;
      const cs = 3.1;
      g.fillStyle = '#fff';
      g.fillRect(gx - 2, gy - 2, cs * 28 + 4, cs * 28 + 4);
      if (hasGray) {
        for (let i = 0; i < 784; i++) {
          const v = gray[i]!;
          if (v < 0.04) continue;
          g.fillStyle = `rgba(29,39,71,${v})`;
          g.fillRect(gx + (i % 28) * cs, gy + Math.floor(i / 28) * cs, cs, cs);
        }
      }
      if (showGrid) {
        g.strokeStyle = 'rgba(29,39,71,0.12)';
        g.lineWidth = 0.3;
        g.beginPath();
        for (let k = 0; k <= 28; k++) {
          g.moveTo(gx + k * cs, gy);
          g.lineTo(gx + k * cs, gy + 28 * cs);
          g.moveTo(gx, gy + k * cs);
          g.lineTo(gx + 28 * cs, gy + k * cs);
        }
        g.stroke();
      }
      g.strokeStyle = 'rgba(29,39,71,0.5)';
      g.lineWidth = 0.8;
      g.strokeRect(gx, gy, cs * 28, cs * 28);
      g.fillStyle = '#7a6f62';
      g.font = `700 7.5px ${F}`;
      g.fillText('28 × 28 로 줄임', gx + cs * 14, gy + cs * 28 + 8);
      // 결과
      const rx = 342;
      if (result) {
        const k = easeBack(clamp(result.t / 0.35, 0, 1));
        g.save();
        g.translate(rx, 78);
        g.scale(k, k);
        g.fillStyle = '#2f6fe0';
        g.beginPath();
        g.arc(0, 0, 30, 0, Math.PI * 2);
        g.fill();
        g.fillStyle = '#fff';
        g.font = `800 40px ${F}`;
        g.fillText(String(result.d), 0, 2);
        g.restore();
        pill(g, rx, 122, `${result.d} — ${Math.round(Math.min(0.99, result.probs[result.d]!) * 100)}%`, 9, '#1d2747', '#fff');
      } else {
        g.strokeStyle = 'rgba(29,39,71,0.25)';
        g.setLineDash([3, 3]);
        g.lineWidth = 1.2;
        g.beginPath();
        g.arc(rx, 78, 30, 0, Math.PI * 2);
        g.stroke();
        g.setLineDash([]);
        g.fillStyle = 'rgba(29,39,71,0.35)';
        g.font = `800 28px ${F}`;
        g.fillText(strokes.length ? '…' : '?', rx, 80);
      }
      // 0 ~ 9 막대
      const bx = 206;
      const by = 206;
      for (let d = 0; d < 10; d++) {
        const p = result ? result.probs[d]! * clamp(result.t / 0.4, 0, 1) : 0;
        const x = bx + d * 17;
        g.fillStyle = 'rgba(29,39,71,0.08)';
        g.fillRect(x, by - 52, 12, 52);
        g.fillStyle = result && d === result.d ? '#2f6fe0' : '#9aa6c0';
        g.fillRect(x, by - 52 * p, 12, 52 * p);
        g.fillStyle = '#4a4a55';
        g.font = `700 7.5px ${F}`;
        g.fillText(String(d), x + 6, by + 7);
      }
    },
    script(loop) {
      const vs = digitVariants();
      const r = rng(loop * 7 + 1);
      const pick = [7, 3, 2, 8, 4, 0, 5, 9, 6, 1];
      const acts: Act[] = [];
      for (let k = 0; k < 3; k++) {
        const d = pick[(loop * 3 + k) % 10]!;
        const v = vs[d]!;
        const st = v[Math.floor(r() * v.length)]!;
        const slant = (r() - 0.5) * 0.25;
        const sx = 92 + (r() - 0.5) * 10;
        const sy = 38 + r() * 6;
        const sz = 150 + r() * 20;
        st.forEach((s) => {
          const pts = s.map((q) => pt(BOX.x + BOX.w / 2 + (q.x - 0.5) * sz * 0.62 + (0.5 - q.y) * slant * sz + (sx - 92), sy + q.y * sz * 0.98));
          acts.push(S(shaky(resampleStep(pts, 3), 1.6, loop * 11 + k), 260, 2.5), WAIT(0.08));
        });
        acts.push(WAIT(1.7));
      }
      return acts;
    },
    controls: [{ type: 'toggle', label: '28×28 격자선', value: true, on: (v) => (showGrid = v) }],
  };
}
```

### i316 견본 항목 — `src/demos/demosDrawTools.ts:3438`
```ts
  i316: dom('손으로 쓴 숫자를 28×28 로 줄여 0 ~ 9 기준 모양과 거리 비교 — 몇 % 로 맞힘', () => digitRecog())
```

## 관련 기술
- 먼저 알면 좋은 기술: [도형 알아보기 (손그림 → 원 · 네모 · 세모)](https://ai-techstudio.web.app/ai/t/i315.md) `i315`
- 다음에 해 볼 기술: [되돌리기 · 다시 하기 (명령 기록)](https://ai-techstudio.web.app/ai/t/i317.md) `i317`
- 참고 문서: [Wikipedia — Distance transform](https://en.wikipedia.org/wiki/Distance_transform)
