레슨 7 / 8·25분
실전: 손글씨 인식 앱
지금까지 배운 내용을 종합하여 브라우저에서 동작하는 손글씨 숫자 인식 앱을 만들어 봅니다. 캔버스에 숫자를 그리면 학습된 모델이 실시간으로 인식하는 완전한 웹 애플리케이션입니다.
HTML 캔버스 설정
javascript
// 캔버스 요소 설정
const canvas = document.getElementById('drawCanvas');
const ctx = canvas.getContext('2d');
canvas.width = 280;
canvas.height = 280;
// 배경을 검은색으로 설정 (MNIST 형식)
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 그리기 설정
ctx.strokeStyle = 'white';
ctx.lineWidth = 15;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
let isDrawing = false;
canvas.addEventListener('mousedown', (e) => {
isDrawing = true;
ctx.beginPath();
ctx.moveTo(e.offsetX, e.offsetY);
});
canvas.addEventListener('mousemove', (e) => {
if (!isDrawing) return;
ctx.lineTo(e.offsetX, e.offsetY);
ctx.stroke();
});
canvas.addEventListener('mouseup', () => {
isDrawing = false;
predictDigit();
});MNIST 모델 학습
javascript
import * as tf from '@tensorflow/tfjs';
async function createAndTrainModel() {
const model = tf.sequential();
model.add(tf.layers.conv2d({
inputShape: [28, 28, 1],
filters: 32,
kernelSize: 3,
activation: 'relu',
}));
model.add(tf.layers.maxPooling2d({ poolSize: 2 }));
model.add(tf.layers.conv2d({
filters: 64,
kernelSize: 3,
activation: 'relu',
}));
model.add(tf.layers.maxPooling2d({ poolSize: 2 }));
model.add(tf.layers.flatten());
model.add(tf.layers.dropout({ rate: 0.25 }));
model.add(tf.layers.dense({ units: 128, activation: 'relu' }));
model.add(tf.layers.dense({ units: 10, activation: 'softmax' }));
model.compile({
optimizer: 'adam',
loss: 'categoricalCrossentropy',
metrics: ['accuracy'],
});
// MNIST 데이터로 학습
// (실제로는 MNIST 데이터를 로드하는 유틸리티 사용)
await model.fit(trainImages, trainLabels, {
epochs: 10,
batchSize: 128,
validationSplit: 0.1,
callbacks: {
onEpochEnd: (epoch, logs) => {
const status = document.getElementById('status');
status.textContent =
"학습 중... 에포크 " + (epoch + 1) + "/10" +
" (정확도: " + (logs.val_acc * 100).toFixed(1) + "%)";
},
},
});
return model;
}실시간 예측 함수
javascript
let model;
async function init() {
const status = document.getElementById('status');
status.textContent = '모델 로딩 중...';
// 저장된 모델 불러오기 또는 새로 학습
try {
model = await tf.loadLayersModel('localstorage://mnist-model');
status.textContent = '모델 로드 완료!';
} catch (e) {
model = await createAndTrainModel();
await model.save('localstorage://mnist-model');
status.textContent = '모델 학습 및 저장 완료!';
}
}
async function predictDigit() {
if (!model) return;
const tensor = tf.tidy(() => {
// 캔버스를 28x28 텐서로 변환
const imageData = tf.browser.fromPixels(canvas, 1);
const resized = tf.image.resizeBilinear(imageData, [28, 28]);
const normalized = resized.div(255.0);
return normalized.expandDims(0);
});
const prediction = model.predict(tensor);
const probabilities = await prediction.data();
// 결과 표시
const resultDiv = document.getElementById('result');
const maxProb = Math.max(...probabilities);
const digit = probabilities.indexOf(maxProb);
resultDiv.innerHTML =
'<h2>예측: ' + digit + '</h2>' +
'<p>확률: ' + (maxProb * 100).toFixed(1) + '%</p>';
// 모든 숫자의 확률 바 차트 표시
const chartDiv = document.getElementById('chart');
chartDiv.innerHTML = '';
for (let i = 0; i < 10; i++) {
const pct = (probabilities[i] * 100).toFixed(1);
const bar = document.createElement('div');
bar.className = 'prob-bar';
bar.innerHTML =
'<span>' + i + '</span>' +
'<div class="bar" style="width:' + pct + '%"></div>' +
'<span>' + pct + '%</span>';
chartDiv.appendChild(bar);
}
tensor.dispose();
prediction.dispose();
}
function clearCanvas() {
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, canvas.width, canvas.height);
document.getElementById('result').innerHTML = '';
document.getElementById('chart').innerHTML = '';
}
// 초기화
init();💡
MNIST 모델은 28x28 크기의 흰 글씨/검은 배경 이미지를 기대합니다. 캔버스 크기를 280x280으로 설정한 후 28x28로 리사이즈하면 더 부드러운 입력이 됩니다. 모델을 localStorage에 저장하면 다음 방문 시 재학습 없이 바로 사용할 수 있습니다.