Merge branch 'dev'
Deploy / deploy (push) Failing after 11m12s
CI / build (push) Failing after 12m27s

This commit is contained in:
ByungCheol
2026-06-03 17:02:37 +09:00
+125 -24
View File
@@ -1,6 +1,7 @@
// 영수증 OCR (온디바이스, Tesseract.js). 서버·API키 불필요.
// 이미지 → 텍스트 인식 → 금액·날짜·상호 추출.
import Tesseract from 'tesseract.js'
// 속도 최적화: ① 워커 1회 생성 후 재사용(모델 재로드 방지) ② 인식 전 이미지 축소·흑백 전처리.
import { createWorker } from 'tesseract.js'
// 합계 금액을 가리키는 키워드 (우선순위 높은 순)
const TOTAL_KEYWORDS = [
@@ -11,32 +12,135 @@ const TOTAL_KEYWORDS = [
// 합계로 오인하기 쉬운(제외할) 키워드
const EXCLUDE_KEYWORDS = ['부가세', '면세', '과세', '봉사료', '거스름', '받은금액', '현금', '잔액', '포인트']
// 문자열에서 숫자(천단위 콤마 포함)들을 추출 → 정수 배열
function numbersIn(line) {
const out = []
const re = /\d{1,3}(?:,\d{3})+|\d{3,}/g
// ===== 워커 재사용 =====
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
}
// ===== 이미지 전처리: 축소 + 흑백 =====
function loadImage(src) {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => resolve(img)
img.onerror = reject
img.src = src
})
}
async function preprocess(image, maxDim = 1600) {
const url = typeof image === 'string' ? image : URL.createObjectURL(image)
try {
const img = await loadImage(url)
const scale = Math.min(1, maxDim / Math.max(img.width, img.height))
const w = Math.max(1, Math.round(img.width * scale))
const h = Math.max(1, Math.round(img.height * scale))
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
} 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 = []
const re = /\d{1,3}(?:,\d{3})+|\d+/g
let m
while ((m = re.exec(line)) !== null) {
const n = parseInt(m[0].replace(/,/g, ''), 10)
if (!Number.isNaN(n)) out.push(n)
const raw = m[0]
const start = m.index
const end = start + raw.length
const before = line[start - 1] || ''
const after = line[end] || ''
// 시간(12:30) / 전화·사업자·카드(하이픈 연결) 제외
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(',') })
}
return out
return res
}
// 합계 금액 추정
function extractAmount(lines) {
// 1) 합계 키워드가 있는 줄에서 금액 찾기 (제외 키워드 줄은 건너뜀)
// 1) 합계 키워드 줄(제외 키워드 줄은 건너뜀) + 바로 다음 줄까지 탐색.
// 콤마 금액이 있으면 우선, 없으면 최댓값.
for (const kw of TOTAL_KEYWORDS) {
for (const line of lines) {
if (!line.includes(kw)) continue
if (EXCLUDE_KEYWORDS.some((ex) => line.includes(ex))) continue
const nums = numbersIn(line)
if (nums.length) return Math.max(...nums)
for (let i = 0; i < lines.length; i++) {
if (!lines[i].includes(kw)) continue
if (EXCLUDE_KEYWORDS.some((ex) => lines[i].includes(ex))) continue
for (let j = i; j <= Math.min(i + 1, lines.length - 1); j++) {
const nums = moneyNumbers(lines[j])
if (!nums.length) continue
const comma = nums.filter((x) => x.hasComma).map((x) => x.n)
return comma.length ? Math.max(...comma) : Math.max(...nums.map((x) => x.n))
}
}
// 2) 폴백: 전체에서 가장 큰 금액(100원 이상)
const all = lines.flatMap(numbersIn).filter((n) => n >= 100 && n < 100_000_000)
return all.length ? Math.max(...all) : null
}
// 2) 폴백: 콤마 금액 우선
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원 단위인 가장 큰 수
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 반환)
@@ -77,13 +181,10 @@ function extractStore(lines) {
* @returns {Promise<{amount:number|null, date:string|null, store:string|null, text:string}>}
*/
export async function scanReceipt(image, onProgress) {
const { data } = await Tesseract.recognize(image, 'kor+eng', {
logger: (msg) => {
if (msg.status === 'recognizing text' && typeof msg.progress === 'number') {
onProgress?.(Math.round(msg.progress * 100))
}
},
})
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)
return {