From 68dd926cce31421b5ccbafecd233c8af8101af73 Mon Sep 17 00:00:00 2001 From: ByungCheol Date: Wed, 3 Jun 2026 17:37:35 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=98=81=EC=88=98=EC=A6=9D=20OCR?= =?UTF-8?q?=EC=9D=84=20Google=20Vision(=EB=B0=B1=EC=97=94=EB=93=9C)=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이미지를 백엔드(/account/ocr/receipt)로 업로드 → Vision 텍스트 → 금액·날짜·상호 파싱 - 업로드 전 이미지 축소(최대 1600px JPEG), Tesseract.js 의존성 제거 - 진행률 표시 제거(인식 중…), 정확도·속도 대폭 개선 Co-Authored-By: Claude Opus 4.8 --- package-lock.json | 115 ------------------------------ package.json | 1 - src/api/accountApi.js | 10 +++ src/utils/receiptOcr.js | 112 +++++------------------------ src/views/account/AccountView.vue | 14 ++-- 5 files changed, 36 insertions(+), 216 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8bf7e5e..d8d42b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,6 @@ "pinia": "^3.0.4", "prismjs": "^1.30.0", "sortablejs": "^1.15.7", - "tesseract.js": "^7.0.0", "vue": "^3.5.32", "vue-router": "^5.0.4" }, @@ -2261,12 +2260,6 @@ "url": "https://github.com/sponsors/antfu" } }, - "node_modules/bmp-js": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz", - "integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==", - "license": "MIT" - }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -3291,12 +3284,6 @@ "node": ">= 6" } }, - "node_modules/idb-keyval": { - "version": "6.2.5", - "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.5.tgz", - "integrity": "sha512-eKQkTnS0relYsSOYomx8ozIbmdsQCKUdhyuIaQ2DZgKuaxtyQQMkyD/wlnQN32pO3yutN1b1L8uqwcDKaJd7/Q==", - "license": "Apache-2.0" - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3412,12 +3399,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-url": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", - "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", - "license": "MIT" - }, "node_modules/is-what": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", @@ -4072,26 +4053,6 @@ "dev": true, "license": "MIT" }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, "node_modules/node-releases": { "version": "2.0.46", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", @@ -4217,15 +4178,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/opencollective-postinstall": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", - "integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==", - "license": "MIT", - "bin": { - "opencollective-postinstall": "index.js" - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4727,12 +4679,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/regenerator-runtime": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "license": "MIT" - }, "node_modules/rfdc": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", @@ -5053,30 +4999,6 @@ "node": ">=18" } }, - "node_modules/tesseract.js": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz", - "integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "bmp-js": "^0.1.0", - "idb-keyval": "^6.2.0", - "is-url": "^1.2.4", - "node-fetch": "^2.6.9", - "opencollective-postinstall": "^2.0.3", - "regenerator-runtime": "^0.13.3", - "tesseract.js-core": "^7.0.0", - "wasm-feature-detect": "^1.8.0", - "zlibjs": "^0.3.1" - } - }, - "node_modules/tesseract.js-core": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz", - "integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==", - "license": "Apache-2.0" - }, "node_modules/through2": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", @@ -5112,12 +5034,6 @@ "node": ">=6" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -5676,34 +5592,12 @@ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, - "node_modules/wasm-feature-detect": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz", - "integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==", - "license": "Apache-2.0" - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, "node_modules/webpack-virtual-modules": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "license": "MIT" }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5863,15 +5757,6 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } - }, - "node_modules/zlibjs": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz", - "integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==", - "license": "MIT", - "engines": { - "node": "*" - } } } } diff --git a/package.json b/package.json index c0c6a6e..8faae98 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,6 @@ "pinia": "^3.0.4", "prismjs": "^1.30.0", "sortablejs": "^1.15.7", - "tesseract.js": "^7.0.0", "vue": "^3.5.32", "vue-router": "^5.0.4" }, diff --git a/src/api/accountApi.js b/src/api/accountApi.js index 35c872b..69da852 100644 --- a/src/api/accountApi.js +++ b/src/api/accountApi.js @@ -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, + }) + }, } diff --git a/src/utils/receiptOcr.js b/src/utils/receiptOcr.js index deb727e..7f920d1 100644 --- a/src/utils/receiptOcr.js +++ b/src/utils/receiptOcr.js @@ -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, } } diff --git a/src/views/account/AccountView.vue b/src/views/account/AccountView.vue index 5773663..34daf07 100644 --- a/src/views/account/AccountView.vue +++ b/src/views/account/AccountView.vue @@ -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" >📷 영수증 스캔 - 인식 중… {{ ocrProgress }}% + 인식 중…