- 이미지를 백엔드(/account/ocr/receipt)로 업로드 → Vision 텍스트 → 금액·날짜·상호 파싱 - 업로드 전 이미지 축소(최대 1600px JPEG), Tesseract.js 의존성 제거 - 진행률 표시 제거(인식 중…), 정확도·속도 대폭 개선 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+19
-93
@@ -1,7 +1,7 @@
|
||||
// 영수증 OCR (온디바이스, Tesseract.js). 서버·API키 불필요.
|
||||
// 이미지 → 텍스트 인식 → 금액·날짜·상호 추출.
|
||||
// 속도 최적화: ① 워커 1회 생성 후 재사용(모델 재로드 방지) ② 인식 전 이미지 축소·흑백 전처리.
|
||||
import { createWorker } from 'tesseract.js'
|
||||
// 영수증 OCR 보조 유틸.
|
||||
// 실제 인식은 백엔드(Google Vision)에서 수행하고, 여기서는
|
||||
// ① 업로드 전 이미지 축소(imageToBlob) ② 인식된 텍스트에서 금액·날짜·상호 파싱(parseReceiptText)
|
||||
// 만 담당한다.
|
||||
|
||||
// 합계 금액을 가리키는 키워드 (우선순위 높은 순)
|
||||
const TOTAL_KEYWORDS = [
|
||||
@@ -12,27 +12,7 @@ const TOTAL_KEYWORDS = [
|
||||
// 합계로 오인하기 쉬운(제외할) 키워드
|
||||
const EXCLUDE_KEYWORDS = ['부가세', '면세', '과세', '봉사료', '거스름', '받은금액', '현금', '잔액', '포인트']
|
||||
|
||||
// ===== 워커 재사용 =====
|
||||
let workerPromise = null
|
||||
let onProgressCb = null
|
||||
function getWorker() {
|
||||
if (!workerPromise) {
|
||||
workerPromise = createWorker('kor+eng', 1, {
|
||||
logger: (m) => {
|
||||
if (m.status === 'recognizing text' && onProgressCb && typeof m.progress === 'number') {
|
||||
onProgressCb(Math.round(m.progress * 100))
|
||||
}
|
||||
},
|
||||
}).then(async (w) => {
|
||||
// 영수증은 단일 블록에 가까움 → 자동 레이아웃 분석 비용 절감
|
||||
await w.setParameters({ tessedit_pageseg_mode: '6' })
|
||||
return w
|
||||
})
|
||||
}
|
||||
return workerPromise
|
||||
}
|
||||
|
||||
// ===== 이미지 전처리: 축소 + 흑백 =====
|
||||
// ===== 업로드 전 이미지 축소 (JPEG Blob) =====
|
||||
function loadImage(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
@@ -41,7 +21,7 @@ function loadImage(src) {
|
||||
img.src = src
|
||||
})
|
||||
}
|
||||
async function preprocess(image, maxDim = 1600) {
|
||||
export async function imageToBlob(image, maxDim = 1600) {
|
||||
const url = typeof image === 'string' ? image : URL.createObjectURL(image)
|
||||
try {
|
||||
const img = await loadImage(url)
|
||||
@@ -51,52 +31,16 @@ async function preprocess(image, maxDim = 1600) {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = w
|
||||
canvas.height = h
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.drawImage(img, 0, 0, w, h)
|
||||
// 흑백 → Otsu 이진화 (영수증 글자/숫자 인식률 향상)
|
||||
const imgData = ctx.getImageData(0, 0, w, h)
|
||||
const d = imgData.data
|
||||
const hist = new Array(256).fill(0)
|
||||
const gray = new Uint8Array(d.length / 4)
|
||||
for (let i = 0, p = 0; i < d.length; i += 4, p++) {
|
||||
const g = (d[i] * 0.299 + d[i + 1] * 0.587 + d[i + 2] * 0.114) | 0
|
||||
gray[p] = g
|
||||
hist[g]++
|
||||
}
|
||||
const th = otsu(hist, gray.length)
|
||||
for (let i = 0, p = 0; i < d.length; i += 4, p++) {
|
||||
const v = gray[p] > th ? 255 : 0
|
||||
d[i] = d[i + 1] = d[i + 2] = v
|
||||
}
|
||||
ctx.putImageData(imgData, 0, 0)
|
||||
return canvas
|
||||
canvas.getContext('2d').drawImage(img, 0, 0, w, h)
|
||||
const blob = await new Promise((res) => canvas.toBlob(res, 'image/jpeg', 0.85))
|
||||
return blob || image
|
||||
} catch {
|
||||
return image // 전처리 실패 시 원본 그대로
|
||||
} finally {
|
||||
if (typeof image !== 'string') URL.revokeObjectURL(url)
|
||||
}
|
||||
}
|
||||
|
||||
// Otsu 임계값 계산
|
||||
function otsu(hist, total) {
|
||||
let sum = 0
|
||||
for (let i = 0; i < 256; i++) sum += i * hist[i]
|
||||
let sumB = 0, wB = 0, max = 0, threshold = 127
|
||||
for (let i = 0; i < 256; i++) {
|
||||
wB += hist[i]
|
||||
if (wB === 0) continue
|
||||
const wF = total - wB
|
||||
if (wF === 0) break
|
||||
sumB += i * hist[i]
|
||||
const mB = sumB / wB
|
||||
const mF = (sum - sumB) / wF
|
||||
const between = wB * wF * (mB - mF) * (mB - mF)
|
||||
if (between > max) {
|
||||
max = between
|
||||
threshold = i
|
||||
}
|
||||
}
|
||||
return threshold
|
||||
}
|
||||
|
||||
// 한 줄에서 금액 후보 추출 (전화·사업자·카드번호·시간 제외) → [{n, hasComma}]
|
||||
function moneyNumbers(line) {
|
||||
const res = []
|
||||
@@ -108,9 +52,8 @@ function moneyNumbers(line) {
|
||||
const end = start + raw.length
|
||||
const before = line[start - 1] || ''
|
||||
const after = line[end] || ''
|
||||
// 시간(12:30) / 전화·사업자·카드(하이픈 연결) 제외
|
||||
if (before === ':' || after === ':') continue
|
||||
if (before === '-' || after === '-') continue
|
||||
if (before === ':' || after === ':') continue // 시간
|
||||
if (before === '-' || after === '-') continue // 전화/사업자/카드
|
||||
const n = parseInt(raw.replace(/,/g, ''), 10)
|
||||
if (Number.isNaN(n)) continue
|
||||
res.push({ n, hasComma: raw.includes(',') })
|
||||
@@ -118,10 +61,8 @@ function moneyNumbers(line) {
|
||||
return res
|
||||
}
|
||||
|
||||
// 합계 금액 추정
|
||||
function extractAmount(lines) {
|
||||
// 1) 합계 키워드 줄(제외 키워드 줄은 건너뜀) + 바로 다음 줄까지 탐색.
|
||||
// 콤마 금액이 있으면 우선, 없으면 최댓값.
|
||||
// 1) 합계 키워드 줄(+다음 줄)에서 — 콤마 금액 우선, 없으면 최댓값
|
||||
for (const kw of TOTAL_KEYWORDS) {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (!lines[i].includes(kw)) continue
|
||||
@@ -138,17 +79,14 @@ function extractAmount(lines) {
|
||||
const all = lines.flatMap(moneyNumbers)
|
||||
const comma = all.filter((x) => x.hasComma).map((x) => x.n).filter((n) => n >= 100 && n < 100_000_000)
|
||||
if (comma.length) return Math.max(...comma)
|
||||
// 3) 콤마가 전혀 없으면(인식 누락) 1000~천만, 10원 단위인 가장 큰 수
|
||||
// 3) 콤마가 없으면 1000~천만, 10원 단위 최댓값
|
||||
const plain = all.map((x) => x.n).filter((n) => n >= 1000 && n <= 10_000_000 && n % 10 === 0)
|
||||
return plain.length ? Math.max(...plain) : null
|
||||
}
|
||||
|
||||
// 거래일 추정 (YYYY-MM-DD 반환)
|
||||
function extractDate(text) {
|
||||
// 4자리 연도: 2024-01-05 / 2024.01.05 / 2024년 1월 5일 / 2024/1/5
|
||||
let m = text.match(/(20\d{2})\s*[.\-/년]\s*(\d{1,2})\s*[.\-/월]\s*(\d{1,2})/)
|
||||
if (m) return fmt(m[1], m[2], m[3])
|
||||
// 2자리 연도: 24.01.05
|
||||
m = text.match(/(\d{2})\s*[.\-/]\s*(\d{1,2})\s*[.\-/]\s*(\d{1,2})/)
|
||||
if (m) return fmt('20' + m[1], m[2], m[3])
|
||||
return null
|
||||
@@ -160,7 +98,6 @@ function fmt(y, mo, d) {
|
||||
return `${y}-${mm}-${dd}`
|
||||
}
|
||||
|
||||
// 상호(가게 이름) 추정 — 상단의 한글 포함, 숫자/키워드 없는 첫 줄
|
||||
function extractStore(lines) {
|
||||
for (const line of lines.slice(0, 6)) {
|
||||
const t = line.trim()
|
||||
@@ -174,23 +111,12 @@ function extractStore(lines) {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 영수증 이미지에서 금액·날짜·상호 추출.
|
||||
* @param {File|Blob|string} image 파일 또는 dataURL
|
||||
* @param {(p:number)=>void} onProgress 0~100 진행률 콜백
|
||||
* @returns {Promise<{amount:number|null, date:string|null, store:string|null, text:string}>}
|
||||
*/
|
||||
export async function scanReceipt(image, onProgress) {
|
||||
onProgressCb = onProgress
|
||||
const [worker, canvas] = await Promise.all([getWorker(), preprocess(image)])
|
||||
const { data } = await worker.recognize(canvas)
|
||||
onProgressCb = null
|
||||
const text = data.text || ''
|
||||
const lines = text.split('\n').map((l) => l.trim()).filter(Boolean)
|
||||
/** Vision 이 인식한 전체 텍스트 → { amount, date, store } */
|
||||
export function parseReceiptText(text) {
|
||||
const lines = (text || '').split('\n').map((l) => l.trim()).filter(Boolean)
|
||||
return {
|
||||
amount: extractAmount(lines),
|
||||
date: extractDate(text),
|
||||
date: extractDate(text || ''),
|
||||
store: extractStore(lines),
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user