feat: 영수증 OCR을 Google Vision(백엔드) 방식으로 전환
CI / build (push) Failing after 14m1s

- 이미지를 백엔드(/account/ocr/receipt)로 업로드 → Vision 텍스트 → 금액·날짜·상호 파싱
- 업로드 전 이미지 축소(최대 1600px JPEG), Tesseract.js 의존성 제거
- 진행률 표시 제거(인식 중…), 정확도·속도 대폭 개선

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-06-03 17:37:35 +09:00
parent 715dae70ab
commit 68dd926cce
5 changed files with 36 additions and 216 deletions
+10
View File
@@ -152,4 +152,14 @@ export const accountApi = {
removeTag(id) {
return http.delete(`/account/tags/${id}`)
},
// 영수증 OCR (백엔드가 Google Vision 호출 → 전체 텍스트 반환)
ocrReceipt(blob) {
const fd = new FormData()
fd.append('image', blob, 'receipt.jpg')
return http.post('/account/ocr/receipt', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 30000,
})
},
}
+19 -93
View File
@@ -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,
}
}
+7 -7
View File
@@ -2,7 +2,7 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { accountApi } from '@/api/accountApi'
import IconBtn from '@/components/ui/IconBtn.vue'
import { scanReceipt } from '@/utils/receiptOcr'
import { imageToBlob, parseReceiptText } from '@/utils/receiptOcr'
import { Capacitor } from '@capacitor/core'
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'
@@ -59,7 +59,6 @@ const formError = ref(null)
// 영수증 OCR (온디바이스)
const receiptInput = ref(null)
const ocrRunning = ref(false)
const ocrProgress = ref(0)
const ocrResult = ref(null) // { amount, date, store }
async function pickReceipt() {
ocrResult.value = null
@@ -91,11 +90,12 @@ async function onReceiptFile(e) {
}
async function runOcr(image) {
ocrRunning.value = true
ocrProgress.value = 0
ocrResult.value = null
formError.value = null
try {
const r = await scanReceipt(image, (p) => (ocrProgress.value = p))
const blob = await imageToBlob(image)
const { text } = await accountApi.ocrReceipt(blob)
const r = parseReceiptText(text)
// 추출값이 있을 때만 폼에 채움 (메모는 비어있을 때만)
if (r.amount) form.amount = r.amount
if (r.date) form.entryDate = r.date
@@ -104,8 +104,8 @@ async function runOcr(image) {
if (!r.amount && !r.date) {
formError.value = '영수증에서 정보를 충분히 인식하지 못했습니다. 직접 입력하거나 더 선명한 사진으로 다시 시도하세요.'
}
} catch {
formError.value = '영수증 인식에 실패했습니다. 다시 시도해 주세요.'
} catch (e) {
formError.value = e.response?.data?.message || '영수증 인식에 실패했습니다. 다시 시도해 주세요.'
} finally {
ocrRunning.value = false
}
@@ -541,7 +541,7 @@ onMounted(async () => {
type="button" class="receipt-btn"
:disabled="submitting || ocrRunning" @click="pickReceipt"
>📷 영수증 스캔</button>
<span v-if="ocrRunning" class="receipt-status">인식 {{ ocrProgress }}%</span>
<span v-if="ocrRunning" class="receipt-status">인식 </span>
<span v-else-if="ocrResult" class="receipt-status ok">
<template v-if="ocrResult.amount">{{ won(ocrResult.amount) }}</template>
<template v-if="ocrResult.date"> · {{ ocrResult.date }}</template>