diff options
Diffstat (limited to 'app/api/ocr')
| -rw-r--r-- | app/api/ocr/enhanced/route.ts | 1112 | ||||
| -rw-r--r-- | app/api/ocr/utils/imageRotation.ts | 583 | ||||
| -rw-r--r-- | app/api/ocr/utils/tableExtraction.ts | 611 |
3 files changed, 1301 insertions, 1005 deletions
diff --git a/app/api/ocr/enhanced/route.ts b/app/api/ocr/enhanced/route.ts index 14a81399..06cea358 100644 --- a/app/api/ocr/enhanced/route.ts +++ b/app/api/ocr/enhanced/route.ts @@ -1,13 +1,15 @@ +// ============================================================================ // app/api/ocr/rotation-enhanced/route.ts -// DB 저장 기능이 추가된 회전 감지 및 테이블 추출 API +// 최적화된 OCR API - 통합 처리로 API 호출 최소화 +// ============================================================================ import { NextRequest, NextResponse } from 'next/server'; import { v4 as uuidv4 } from 'uuid'; -import db from '@/db/db'; // DB 인스턴스 import -import { - ocrSessions, - ocrTables, - ocrRows, +import db from '@/db/db'; +import { + ocrSessions, + ocrTables, + ocrRows, ocrRotationAttempts, type NewOcrSession, type NewOcrTable, @@ -17,20 +19,36 @@ import { type ExtractedRow } from '@/db/schema/ocr'; import { extractTablesFromOCR, analyzeOCRQuality } from '../utils/tableExtraction'; -import { - rotateImageBase64, - enhanceImageQuality, - convertPDFToImage, - needsRotation +import { + rotateImageBase64, + enhanceImageQuality, + convertPDFToImage, + needsRotation, + normalizeImageFormat, + validateBase64Image, + getPDFPageCount, + detectTextOrientation } from '../utils/imageRotation'; - -interface RotationTestResult { - rotation: number; +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { sql } from 'drizzle-orm'; +import sharp from 'sharp'; + +interface PageRotationInfo { + pageIndex: number; + optimalRotation: number; confidence: number; - tablesFound: number; - textQuality: number; - keywordCount: number; - extractedTables?: ExtractedRow[][]; + needsRotationCheck: boolean; +} + +interface OptimizedProcessingResult { + success: boolean; + sessionId: string; + extractedTables: ExtractedRow[][]; + totalApiCalls: number; + processingTime: number; + pageRotations: PageRotationInfo[]; + warnings: string[]; } interface ProcessingResult { @@ -45,6 +63,16 @@ interface ProcessingResult { bestRotation: number; imageEnhanced: boolean; pdfConverted: boolean; + pagesProcessed?: number; + totalApiCalls: number; + pageDetails?: Array<{ + pageIndex: number; + rotation: number; + tablesFound: number; + rowsFound: number; + confidence: number; + processingTime: number; + }>; }; error?: string; warnings?: string[]; @@ -54,17 +82,29 @@ export async function POST(request: NextRequest) { const startTime = Date.now(); const warnings: string[] = []; let sessionId: string | null = null; + let file: File | null = null; try { - console.log('🔄 Starting rotation-enhanced OCR processing...'); - + console.log('🚀 === STARTING OPTIMIZED OCR PROCESSING ==='); + + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json( + { success: false, message: "인증이 필요합니다" }, + { status: 401 } + ) + } + + const userId = parseInt(session.user.id) + + // formData 한 번만 읽기 const formData = await request.formData(); - const file = formData.get('file') as File; - + file = formData.get('file') as File; + if (!file) { - return NextResponse.json({ - success: false, - error: 'No file provided' + return NextResponse.json({ + success: false, + error: 'No file provided' }, { status: 400 }); } @@ -72,16 +112,16 @@ export async function POST(request: NextRequest) { // 파일 검증 if (!file.type.includes('pdf') && !file.type.includes('image')) { - return NextResponse.json({ - success: false, - error: 'Invalid file type. Only PDF and image files are supported.' + return NextResponse.json({ + success: false, + error: 'Invalid file type. Only PDF and image files are supported.' }, { status: 400 }); } if (file.size > 10 * 1024 * 1024) { // 10MB 제한 - return NextResponse.json({ - success: false, - error: 'File size too large. Maximum size is 10MB.' + return NextResponse.json({ + success: false, + error: 'File size too large. Maximum size is 10MB.' }, { status: 400 }); } @@ -90,54 +130,35 @@ export async function POST(request: NextRequest) { const fileBuffer = Buffer.from(buffer); const fileformat = file.type.includes('pdf') ? 'pdf' : 'image'; - let base64: string; - let imageEnhanced = false; - let pdfConverted = false; + let processingResult: OptimizedProcessingResult; - // PDF vs 이미지 처리 if (fileformat === 'pdf') { - console.log('📄 Converting PDF to high-quality image...'); - base64 = await convertPDFToImage(fileBuffer, 0); - pdfConverted = true; + console.log('📄 Processing PDF with optimized approach...'); + processingResult = await processPDFOptimized(fileBuffer, file.name); } else { - base64 = fileBuffer.toString('base64'); - - // 이미지 품질 개선 - console.log('🎨 Enhancing image quality...'); - base64 = await enhanceImageQuality(base64); - imageEnhanced = true; - } - - // 회전 테스트 수행 - console.log('🔍 Testing multiple rotations...'); - const rotationResults = await tryMultipleRotations(base64, file.name); - - // 최적 회전 각도 선택 - const bestResult = findBestRotation(rotationResults); - console.log(`✅ Best rotation found: ${bestResult.rotation}° (score: ${bestResult.score?.toFixed(2)})`); - - if (bestResult.rotation !== 0) { - warnings.push(`Document was auto-rotated ${bestResult.rotation}° for optimal OCR results`); + console.log('🖼️ Processing single image with optimized approach...'); + processingResult = await processImageOptimized(fileBuffer, file.name); } - // 최적 결과 사용 - const finalTables = bestResult.extractedTables || []; - const totalRows = finalTables.reduce((sum, table) => sum + table.length, 0); const processingTime = Date.now() - startTime; - // 🗃️ DB에 저장 + console.log(`\n📊 === PROCESSING SUMMARY ===`); + console.log(`Total API calls: ${processingResult.totalApiCalls} (optimized!)`); + console.log(`Total tables found: ${processingResult.extractedTables.length}`); + console.log(`Total rows extracted: ${processingResult.extractedTables.reduce((sum, table) => sum + table.length, 0)}`); + console.log(`Total processing time: ${(processingTime / 1000).toFixed(1)}s`); + + // DB에 저장 console.log('💾 Saving results to database...'); sessionId = await saveToDatabase({ + userId, file, fileformat, processingTime, - bestRotation: bestResult.rotation, - finalTables, - totalRows, - rotationResults, - imageEnhanced, - pdfConverted, - warnings + extractedTables: processingResult.extractedTables, + pageRotations: processingResult.pageRotations, + totalApiCalls: processingResult.totalApiCalls, + warnings: [...warnings, ...processingResult.warnings] }); console.log(`⏱️ Processing completed in ${(processingTime / 1000).toFixed(1)}s, saved as session ${sessionId}`); @@ -146,41 +167,48 @@ export async function POST(request: NextRequest) { success: true, sessionId, metadata: { - totalTables: finalTables.length, - totalRows, + totalTables: processingResult.extractedTables.length, + totalRows: processingResult.extractedTables.reduce((sum, table) => sum + table.length, 0), processingTime, fileName: file.name, fileSize: file.size, - bestRotation: bestResult.rotation, - imageEnhanced, - pdfConverted + bestRotation: processingResult.pageRotations[0]?.optimalRotation || 0, + imageEnhanced: fileformat === 'image', + pdfConverted: fileformat === 'pdf', + pagesProcessed: processingResult.pageRotations.length, + totalApiCalls: processingResult.totalApiCalls, + pageDetails: processingResult.pageRotations.map(p => ({ + pageIndex: p.pageIndex, + rotation: p.optimalRotation, + tablesFound: 0, // 통합 처리로 인해 페이지별 세부사항은 후처리에서 계산 + rowsFound: 0, + confidence: p.confidence, + processingTime: 0 + })) }, - warnings: warnings.length > 0 ? warnings : undefined + warnings: [...warnings, ...processingResult.warnings].length > 0 ? [...warnings, ...processingResult.warnings] : undefined }; - + return NextResponse.json(result); } catch (error) { console.error('❌ OCR processing error:', error); - + const processingTime = Date.now() - startTime; - - // 에러 발생시에도 세션 저장 (실패 기록) - if (!sessionId) { + + // 에러 발생시에도 세션 저장 + if (!sessionId && file) { try { - const errorFile = await request.formData().then(fd => fd.get('file') as File); - if (errorFile) { - sessionId = await saveErrorToDatabase({ - file: errorFile, - processingTime, - error: error instanceof Error ? error.message : 'Unknown error occurred' - }); - } + sessionId = await saveErrorToDatabase({ + file, + processingTime, + error: error instanceof Error ? error.message : 'Unknown error occurred' + }); } catch (dbError) { console.error('Failed to save error to database:', dbError); } } - + return NextResponse.json({ success: false, sessionId: sessionId || null, @@ -189,117 +217,582 @@ export async function POST(request: NextRequest) { processingTime, totalTables: 0, totalRows: 0, - fileName: '', - fileSize: 0, + fileName: file?.name || '', + fileSize: file?.size || 0, bestRotation: 0, imageEnhanced: false, - pdfConverted: false + pdfConverted: false, + totalApiCalls: 0 } }, { status: 500 }); } } -// DB 저장 함수 +// PDF 최적화 처리 - 통합 방식 +async function processPDFOptimized( + fileBuffer: Buffer, + fileName: string +): Promise<OptimizedProcessingResult> { + console.log('🚀 === OPTIMIZED PDF PROCESSING ==='); + + const warnings: string[] = []; + + try { + // 1단계: 페이지 수 확인 + const pageCount = await getPDFPageCount(fileBuffer); + console.log(`📊 PDF contains ${pageCount} pages`); + + // 2단계: 각 페이지의 최적 회전각 결정 (OCR API 호출 없이) + const pageRotations = await determineOptimalRotations(fileBuffer, pageCount); + console.log('🧭 Optimal rotations determined:', pageRotations.map(p => `Page ${p.pageIndex + 1}: ${p.optimalRotation}°`)); + + // 3단계: 처리 방식 결정 + if (shouldProcessAsWhole(pageCount, fileBuffer.length)) { + console.log('📋 Processing as single combined document...'); + const result = await processAsWholePDF(fileBuffer, pageRotations, fileName); + return { + success: true, + sessionId: '', + extractedTables: result.extractedTables, + totalApiCalls: 1, // 🎉 단 1번의 API 호출! + processingTime: Date.now(), + pageRotations, + warnings: [...warnings, ...result.warnings] + }; + } else { + console.log('📄 Processing pages individually with optimal rotations...'); + const result = await processPageByPageOptimized(fileBuffer, pageRotations, fileName); + return { + success: true, + sessionId: '', + extractedTables: result.extractedTables, + totalApiCalls: pageCount, // 페이지 수만큼만 호출 + processingTime: Date.now(), + pageRotations, + warnings: [...warnings, ...result.warnings] + }; + } + + } catch (error) { + console.error('❌ PDF processing failed:', error); + throw error; + } +} + +// 이미지 최적화 처리 +async function processImageOptimized( + fileBuffer: Buffer, + fileName: string +): Promise<OptimizedProcessingResult> { + console.log('🖼️ === OPTIMIZED IMAGE PROCESSING ==='); + + const warnings: string[] = []; + + try { + let base64 = fileBuffer.toString('base64'); + + // 원본 이미지 검증 + const originalValidation = validateBase64Image(base64); + if (!originalValidation.isValid) { + throw new Error(`Invalid image file: ${originalValidation.error}`); + } + + console.log(`✅ Original image valid: ${originalValidation.size} bytes`); + + // 이미지 품질 개선 + console.log('🎨 Enhancing image quality...'); + base64 = await enhanceImageQuality(base64); + + // 최적 회전각 결정 (OCR API 호출 없이) + const optimalRotation = await detectTextOrientation(base64); + console.log(`🧭 Optimal rotation detected: ${optimalRotation}°`); + + // 최적 회전 적용 + if (optimalRotation !== 0) { + console.log(`🔄 Applying optimal rotation: ${optimalRotation}°...`); + base64 = await rotateImageBase64(base64, optimalRotation); + warnings.push(`Image was auto-rotated ${optimalRotation}° for optimal OCR results`); + } + + // 단 1번의 OCR API 호출 + console.log('🌐 Single OCR API call for optimized image...'); + const ocrResult = await callOCRAPI(base64, 'jpg', fileName); + + // 결과 처리 + const reportNo = extractReportNo(ocrResult); + const analysis = analyzeOCRQuality(ocrResult); + const rawTables = await extractTablesFromOCR(ocrResult) as BaseExtractedRow[][]; + + const extractedTables: ExtractedRow[][] = rawTables.map(table => + table.map(row => ({ + ...row, + reportNo + })) + ); + + const pageRotations: PageRotationInfo[] = [{ + pageIndex: 0, + optimalRotation, + confidence: analysis.confidence, + needsRotationCheck: optimalRotation !== 0 + }]; + + return { + success: true, + sessionId: '', + extractedTables, + totalApiCalls: 1, // 단 1번의 API 호출! + processingTime: Date.now(), + pageRotations, + warnings + }; + + } catch (error) { + console.error('❌ Image processing failed:', error); + throw error; + } +} + +// 페이지별 최적 회전각 결정 (OCR API 없이) +async function determineOptimalRotations( + fileBuffer: Buffer, + pageCount: number +): Promise<PageRotationInfo[]> { + console.log('🔍 Determining optimal rotations without OCR calls...'); + + const rotations: PageRotationInfo[] = []; + + for (let pageIndex = 0; pageIndex < pageCount; pageIndex++) { + try { + console.log(` 📄 Analyzing page ${pageIndex + 1}...`); + + // 저해상도로 빠르게 변환 (150 DPI - 품질과 속도의 균형) + const lowResBase64 = await convertPDFToImage(fileBuffer, pageIndex, 150); + + // 회전 필요성 확인 + const needsRotationCheck = await needsRotation(lowResBase64); + + let optimalRotation = 0; + let confidence = 1.0; + + if (needsRotationCheck) { + console.log(` 🔄 Page ${pageIndex + 1} needs rotation analysis...`); + // 이미지 분석 기반 회전 감지 + optimalRotation = await detectTextOrientation(lowResBase64); + confidence = await calculateRotationConfidence(lowResBase64, optimalRotation); + } else { + console.log(` ✅ Page ${pageIndex + 1} appears correctly oriented`); + } + + rotations.push({ + pageIndex, + optimalRotation, + confidence, + needsRotationCheck + }); + + console.log(` 📊 Page ${pageIndex + 1}: ${optimalRotation}° rotation (confidence: ${(confidence * 100).toFixed(1)}%)`); + + } catch (error) { + console.warn(` ⚠️ Page ${pageIndex + 1}: rotation detection failed, using 0°`); + rotations.push({ + pageIndex, + optimalRotation: 0, + confidence: 0.5, + needsRotationCheck: false + }); + } + } + + return rotations; +} + +// 회전 신뢰도 계산 +async function calculateRotationConfidence( + base64: string, + rotation: number +): Promise<number> { + try { + const rotatedBase64 = await rotateImageBase64(base64, rotation); + + // 이미지 품질 지표들을 분석하여 신뢰도 계산 + const cleanBase64 = rotatedBase64.replace(/^data:image\/[a-z]+;base64,/, ''); + const buffer = Buffer.from(cleanBase64, 'base64'); + + const stats = await sharp(buffer) + .greyscale() + .stats(); + + const contrast = stats.channels[0].max - stats.channels[0].min; + const sharpness = stats.channels[0].stdev; + + // 대비와 선명도를 기반으로 신뢰도 계산 + const confidence = Math.min((contrast + sharpness) / 400, 1.0); + + return confidence; + + } catch (error) { + console.warn('Failed to calculate rotation confidence:', error); + return 0.5; + } +} + +// 처리 방식 결정 +function shouldProcessAsWhole(pageCount: number, fileSize: number): boolean { + // 조건: + // 1. 파일 크기가 8MB 이하 + // 2. 페이지 수가 5개 이하 + // 3. 모든 페이지가 동일한 회전각을 갖는 경우 우선 고려 + + const maxSizeForWhole = 8 * 1024 * 1024; // 8MB + const maxPagesForWhole = 5; + + const sizeOk = fileSize <= maxSizeForWhole; + const pagesOk = pageCount <= maxPagesForWhole; + + console.log(`📋 Processing decision: size(${sizeOk}) + pages(${pagesOk}) = ${sizeOk && pagesOk ? 'WHOLE' : 'INDIVIDUAL'}`); + + return sizeOk && pagesOk; +} + +// 전체 PDF를 하나로 처리 +async function processAsWholePDF( + fileBuffer: Buffer, + pageRotations: PageRotationInfo[], + fileName: string +): Promise<{ + extractedTables: ExtractedRow[][]; + warnings: string[]; +}> { + console.log('🔄 Creating single combined image with corrected rotations...'); + + const warnings: string[] = []; + + try { + // 각 페이지를 올바른 회전으로 변환하여 이미지 배열 생성 + const pageImages: string[] = []; + + for (const pageInfo of pageRotations) { + console.log(` 📄 Processing page ${pageInfo.pageIndex + 1} with ${pageInfo.optimalRotation}° rotation...`); + + // 고해상도로 변환 + let pageBase64 = await convertPDFToImage(fileBuffer, pageInfo.pageIndex, 300); + + // 필요시 회전 적용 + if (pageInfo.optimalRotation !== 0) { + pageBase64 = await rotateImageBase64(pageBase64, pageInfo.optimalRotation); + warnings.push(`Page ${pageInfo.pageIndex + 1} was rotated ${pageInfo.optimalRotation}° for optimal results`); + } + + pageImages.push(pageBase64); + } + + // 모든 페이지를 하나의 긴 이미지로 합치기 + console.log('🖼️ Combining all pages into single image...'); + const combinedBase64 = await combineImagesVertically(pageImages); + + // 한 번의 OCR API 호출 + console.log('🌐 Single OCR API call for entire combined document...'); + const ocrResult = await callOCRAPI(combinedBase64, 'jpg', `combined_${fileName}`); + + // 결과 처리 + const reportNo = extractReportNo(ocrResult); + const rawTables = await extractTablesFromOCR(ocrResult) as BaseExtractedRow[][]; + + const extractedTables: ExtractedRow[][] = rawTables.map(table => + table.map(row => ({ + ...row, + reportNo + })) + ); + + console.log(`✅ Combined processing complete: ${extractedTables.length} tables found`); + + return { + extractedTables, + warnings + }; + + } catch (error) { + console.error('❌ Whole PDF processing failed:', error); + throw error; + } +} + +// 페이지별 최적화 처리 +async function processPageByPageOptimized( + fileBuffer: Buffer, + pageRotations: PageRotationInfo[], + fileName: string +): Promise<{ + extractedTables: ExtractedRow[][]; + warnings: string[]; +}> { + console.log('📄 Processing pages individually with predetermined rotations...'); + + const allTables: ExtractedRow[][] = []; + const warnings: string[] = []; + + for (const pageInfo of pageRotations) { + try { + console.log(` 📄 Processing page ${pageInfo.pageIndex + 1} (${pageInfo.optimalRotation}° rotation)...`); + + // 고해상도로 변환 + let pageBase64 = await convertPDFToImage(fileBuffer, pageInfo.pageIndex, 300); + + // 미리 결정된 회전각 적용 + if (pageInfo.optimalRotation !== 0) { + pageBase64 = await rotateImageBase64(pageBase64, pageInfo.optimalRotation); + warnings.push(`Page ${pageInfo.pageIndex + 1} was rotated ${pageInfo.optimalRotation}° for optimal results`); + } + + // 단 1번의 OCR API 호출 (회전 테스트 없이) + console.log(` 🌐 OCR API call for page ${pageInfo.pageIndex + 1}...`); + const ocrResult = await callOCRAPI(pageBase64, 'jpg', `${fileName}_page_${pageInfo.pageIndex + 1}`); + + // 결과 처리 + const reportNo = extractReportNo(ocrResult); + const rawTables = await extractTablesFromOCR(ocrResult) as BaseExtractedRow[][]; + + const pageTables: ExtractedRow[][] = rawTables.map(table => + table.map(row => ({ + ...row, + reportNo + })) + ); + + allTables.push(...pageTables); + + console.log(` ✅ Page ${pageInfo.pageIndex + 1}: ${pageTables.length} tables found`); + + } catch (error) { + console.error(` ❌ Error processing page ${pageInfo.pageIndex + 1}:`, error); + warnings.push(`Page ${pageInfo.pageIndex + 1} processing failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + return { + extractedTables: allTables, + warnings + }; +} + +// 이미지들을 세로로 합치기 +async function combineImagesVertically(base64Images: string[]): Promise<string> { + console.log(`🖼️ Combining ${base64Images.length} images vertically...`); + + try { + if (base64Images.length === 0) { + throw new Error('No images to combine'); + } + + if (base64Images.length === 1) { + return base64Images[0]; + } + + // 각 이미지를 Sharp 객체로 변환 + const imageBuffers = base64Images.map(base64 => { + const cleanBase64 = base64.replace(/^data:image\/[a-z]+;base64,/, ''); + return Buffer.from(cleanBase64, 'base64'); + }); + + // 첫 번째 이미지의 메타데이터 확인 + const firstImage = sharp(imageBuffers[0]); + const firstMetadata = await firstImage.metadata(); + + if (!firstMetadata.width || !firstMetadata.height) { + throw new Error('Invalid first image metadata'); + } + + const targetWidth = firstMetadata.width; + let totalHeight = firstMetadata.height; + + // 나머지 이미지들의 높이 계산 (같은 너비로 리사이즈) + const resizedBuffers = [imageBuffers[0]]; + + for (let i = 1; i < imageBuffers.length; i++) { + const img = sharp(imageBuffers[i]); + const metadata = await img.metadata(); + + if (metadata.width && metadata.height) { + const aspectRatio = metadata.height / metadata.width; + const newHeight = Math.round(targetWidth * aspectRatio); + + const resizedBuffer = await img + .resize(targetWidth, newHeight) + .jpeg({ quality: 90 }) + .toBuffer(); + + resizedBuffers.push(resizedBuffer); + totalHeight += newHeight; + + console.log(` 📏 Image ${i + 1}: ${metadata.width}x${metadata.height} → ${targetWidth}x${newHeight}`); + } + } + + console.log(` 📊 Combined size will be: ${targetWidth}x${totalHeight}`); + + // 빈 캔버스 생성 + const combinedImage = sharp({ + create: { + width: targetWidth, + height: totalHeight, + channels: 3, + background: { r: 255, g: 255, b: 255 } + } + }); + + // 이미지들을 세로로 배치 + const composite = []; + let currentTop = 0; + + for (const buffer of resizedBuffers) { + composite.push({ + input: buffer, + top: currentTop, + left: 0 + }); + + const metadata = await sharp(buffer).metadata(); + currentTop += metadata.height || 0; + } + + const combinedBuffer = await combinedImage + .composite(composite) + .jpeg({ quality: 90 }) + .toBuffer(); + + const combinedBase64 = combinedBuffer.toString('base64'); + + console.log(`✅ Images combined successfully: ${combinedBuffer.length} bytes`); + + return combinedBase64; + + } catch (error) { + console.error('❌ Error combining images:', error); + throw new Error(`Failed to combine images: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +// DB 저장 함수 (업데이트됨) async function saveToDatabase({ + userId, file, fileformat, processingTime, - bestRotation, - finalTables, - totalRows, - rotationResults, - imageEnhanced, - pdfConverted, + extractedTables, + pageRotations, + totalApiCalls, warnings }: { + userId: number | string, file: File; fileformat: string; processingTime: number; - bestRotation: number; - finalTables: ExtractedRow[][]; - totalRows: number; - rotationResults: (RotationTestResult & { score?: number })[]; - imageEnhanced: boolean; - pdfConverted: boolean; + extractedTables: ExtractedRow[][]; + pageRotations: PageRotationInfo[]; + totalApiCalls: number; warnings: string[]; }): Promise<string> { - + return await db.transaction(async (tx) => { - // 1. 세션 저장 + + // 1단계: 필터링된 행 수를 미리 계산 + const filteredTables = extractedTables.map(table => + table.filter(row => (!row.no || row.no.length < 10) && row.no !== "No." && row.weldingDate.length < 15 && row.jointType.length < 5) + ); + const totalTablesFiltered = filteredTables.length; + const totalRowsFiltered = filteredTables.reduce((sum, t) => sum + t.length, 0); + + // 2단계: 세션 저장 const sessionData: NewOcrSession = { fileName: file.name, fileSize: file.size, fileType: fileformat, processingTime, - bestRotation, - totalTables: finalTables.length, - totalRows, - imageEnhanced, - pdfConverted, + bestRotation: pageRotations[0]?.optimalRotation || 0, + totalTables: totalTablesFiltered, + totalRows: totalRowsFiltered, + imageEnhanced: fileformat === 'image', + pdfConverted: fileformat === 'pdf', success: true, - warnings: warnings.length > 0 ? warnings : null, + warnings: warnings.length ? warnings : null, }; - const [session] = await tx.insert(ocrSessions).values(sessionData).returning({ id: ocrSessions.id }); - const sessionId = session.id; + const [sessionTable] = await tx.insert(ocrSessions) + .values(sessionData) + .returning({ id: ocrSessions.id }); - // 2. 테이블들 저장 - const tableIds: string[] = []; - for (let tableIndex = 0; tableIndex < finalTables.length; tableIndex++) { - const table = finalTables[tableIndex]; - - const tableData: NewOcrTable = { + const sessionId = sessionTable.id; + + // 3단계: 테이블 & 행 저장 + for (let tableIndex = 0; tableIndex < filteredTables.length; tableIndex++) { + const table = filteredTables[tableIndex]; + + const [savedTable] = await tx.insert(ocrTables).values({ sessionId, tableIndex, rowCount: table.length, - }; - - const [savedTable] = await tx.insert(ocrTables).values(tableData).returning({ id: ocrTables.id }); - tableIds.push(savedTable.id); + }).returning({ id: ocrTables.id }); - // 3. 각 테이블의 행들 저장 if (table.length > 0) { const rowsData: NewOcrRow[] = table.map((row, rowIndex) => ({ tableId: savedTable.id, sessionId, rowIndex, - reportNo: row.reportNo || null, - no: row.no || null, - identificationNo: row.identificationNo || null, - tagNo: row.tagNo || null, - jointNo: row.jointNo || null, - jointType: row.jointType || null, - weldingDate: row.weldingDate || null, + reportNo: row.reportNo ?? null, + no: row.no ?? null, + identificationNo: row.identificationNo ?? null, + tagNo: row.tagNo ?? null, + jointNo: row.jointNo ?? null, + jointType: row.jointType ?? null, + weldingDate: row.weldingDate ?? null, confidence: row.confidence.toString(), sourceTable: row.sourceTable, sourceRow: row.sourceRow, + userId: userId.toString() })); - await tx.insert(ocrRows).values(rowsData); + console.log(`💾 Inserting ${rowsData.length} rows for table ${tableIndex}`); + + await tx.insert(ocrRows) + .values(rowsData) + .onConflictDoUpdate({ + target: [ocrRows.reportNo, ocrRows.no,ocrRows.no,ocrRows.tagNo,ocrRows.jointNo,ocrRows.jointType], // 🚀 변경: 두 컬럼만! + set: { + identificationNo: sql`EXCLUDED.identification_no`, + tagNo: sql`EXCLUDED.tag_no`, + jointNo: sql`EXCLUDED.joint_no`, + jointType: sql`EXCLUDED.joint_type`, + weldingDate: sql`EXCLUDED.welding_date`, + confidence: sql`EXCLUDED.confidence`, + sourceTable: sql`EXCLUDED.source_table`, + sourceRow: sql`EXCLUDED.source_row`, + userId: sql`EXCLUDED.user_id`, + }, + }); + + console.log(`✅ Successfully saved/updated ${rowsData.length} rows for table ${tableIndex}`); } } - // 4. 회전 시도 결과들 저장 - const rotationAttemptsData: NewOcrRotationAttempt[] = rotationResults.map((result) => { - const extractedRowsCount = result.extractedTables?.reduce((sum, table) => sum + table.length, 0) || 0; - - return { - sessionId, - rotation: result.rotation, - confidence: result.confidence.toString(), - tablesFound: result.tablesFound, - textQuality: result.textQuality.toString(), - keywordCount: result.keywordCount, - score: result.score?.toString() || '0', - extractedRowsCount, - }; - }); + // 4단계: 최적화된 회전 시도 결과 저장 + const rotationAttemptsData: NewOcrRotationAttempt[] = pageRotations.map((pageInfo) => ({ + sessionId, + rotation: pageInfo.optimalRotation, + confidence: pageInfo.confidence.toString(), + tablesFound: 0, // 통합 처리로 인해 개별 페이지별 테이블 수는 미상 + textQuality: pageInfo.confidence.toString(), + keywordCount: 0, + score: pageInfo.confidence.toString(), + extractedRowsCount: 0, + })); if (rotationAttemptsData.length > 0) { await tx.insert(ocrRotationAttempts).values(rotationAttemptsData); } - console.log(`✅ Successfully saved session ${sessionId} with ${finalTables.length} tables and ${totalRows} rows`); + console.log(`✅ Successfully saved optimized session ${sessionId} with ${totalTablesFiltered} tables and ${totalRowsFiltered} rows (${totalApiCalls} API calls)`); return sessionId; }); } @@ -314,7 +807,7 @@ async function saveErrorToDatabase({ processingTime: number; error: string; }): Promise<string> { - + const sessionData: NewOcrSession = { fileName: file.name, fileSize: file.size, @@ -337,241 +830,150 @@ async function saveErrorToDatabase({ // OCR 결과에서 Report No 추출 function extractReportNo(ocrResult: any): string { try { - console.log('📊 Extracting Report No from OCR result...'); - - // OCR 결과에서 tables와 fields 모두 확인 - const allTexts: string[] = []; - - // tables에서 텍스트 추출 - if (ocrResult.images?.[0]?.tables) { - for (const table of ocrResult.images[0].tables) { - if (table.cells) { - for (const cell of table.cells) { - if (cell.cellTextLines) { - for (const textLine of cell.cellTextLines) { - if (textLine.cellWords) { - for (const word of textLine.cellWords) { - if (word.inferText) { - allTexts.push(word.inferText.trim()); - } - } - } - } - } - } - } - } - } - - // fields에서 텍스트 추출 - if (ocrResult.images?.[0]?.fields) { - for (const field of ocrResult.images[0].fields) { - if (field.inferText) { - allTexts.push(field.inferText.trim()); - } - } - } - - // Report No. 패턴을 찾기 - let reportNo = ''; - let foundReportNoLabel = false; - - for (let i = 0; i < allTexts.length; i++) { - const text = allTexts[i]; - - // "Report", "No.", "Report No." 등의 패턴 찾기 - if (text.toLowerCase().includes('report') && - (text.toLowerCase().includes('no') || - (i + 1 < allTexts.length && allTexts[i + 1].toLowerCase().includes('no')))) { - foundReportNoLabel = true; - console.log(`📋 Found Report No label at index ${i}: "${text}"`); - continue; - } - - // Report No 라벨 다음에 오는 값이나 특정 패턴 (예: SN2661FT20250526) 찾기 - if (foundReportNoLabel || /^[A-Z]{2}\d{4}[A-Z]{2}\d{8}$/.test(text)) { - // Report No 형식 패턴 체크 (예: SN2661FT20250526) - if (/^[A-Z]{2}\d{4}[A-Z]{2}\d{8}$/.test(text) || - /^[A-Z0-9]{10,20}$/.test(text)) { - reportNo = text; - console.log(`✅ Found Report No: "${reportNo}"`); - break; - } - } - } - - // 패턴이 없으면 더 관대한 검색 - if (!reportNo) { - // Report나 No 근처의 영숫자 조합 찾기 - for (let i = 0; i < allTexts.length; i++) { - const text = allTexts[i]; - if ((text.toLowerCase().includes('report') || text.toLowerCase().includes('no')) && - i + 1 < allTexts.length) { - const nextText = allTexts[i + 1]; - if (/^[A-Z0-9]{6,}$/.test(nextText)) { - reportNo = nextText; - console.log(`✅ Found Report No (fallback): "${reportNo}"`); - break; - } - } - } - } - - // 기본값 설정 - if (!reportNo) { - console.warn('⚠️ Could not extract Report No, using default'); - reportNo = 'UNKNOWN'; - } - - return reportNo; - - } catch (error) { - console.error('❌ Error extracting Report No:', error); - return 'UNKNOWN'; - } -} + const table = ocrResult.images?.[0]?.tables?.[0]; + if (!table?.cells?.length) return 'UNKNOWN'; -// 여러 회전 각도 테스트 -async function tryMultipleRotations(base64: string, filename: string): Promise<(RotationTestResult & { score?: number })[]> { - const rotations = [0, 90, 180, 270]; - const results: RotationTestResult[] = []; + const target = table.cells.find( + (c: any) => c.rowIndex === 0 && c.columnIndex === 3 + ); + if (!target) return 'UNKNOWN'; - console.log('🔍 Testing rotations with server-side image processing...'); + const reportNo = cellText(target).replace(/\s+/g, ''); + return reportNo || 'UNKNOWN'; - for (const rotation of rotations) { - try { - console.log(` Testing ${rotation}° rotation...`); - - // 🔄 서버 사이드에서 실제 이미지 회전 - const rotatedBase64 = await rotateImageBase64(base64, rotation); + } catch (e) { + console.error('extractReportNo 오류:', e); + return 'UNKNOWN'; + } - // OCR API 호출 - const ocrResult = await callOCRAPI(rotatedBase64, 'jpg', `${rotation}_${filename}`); + function cellText(cell: any): string { + return (cell.cellTextLines ?? []) + .flatMap((l: any) => + (l.cellWords ?? []).map((w: any) => (w.inferText ?? '').trim()) + ) + .join(' '); + } +} - console.log(ocrResult) - - // Report No 추출 - const reportNo = extractReportNo(ocrResult); - console.log(` ${rotation}°: Report No = "${reportNo}"`); - - const analysis = analyzeOCRQuality(ocrResult); - const rawTables = await extractTablesFromOCR(ocrResult) as BaseExtractedRow[][]; // 기존 함수 호출 - - // reportNo를 각 행에 추가 - const extractedTables: ExtractedRow[][] = rawTables.map(table => - table.map(row => ({ - ...row, - reportNo // reportNo 필드 추가 - })) - ); - - const result: RotationTestResult = { - rotation, - confidence: analysis.confidence, - tablesFound: analysis.tablesFound, - textQuality: analysis.textQuality, - keywordCount: analysis.keywordCount, - extractedTables - }; +// OCR API 호출 (기존과 동일) +async function callOCRAPI(base64: string, format: string, filename: string, rotation?: number): Promise<any> { + console.log('🌐 === OCR API CALL DEBUG ==='); + console.log(`📁 Filename: ${filename}`); + console.log(`🔄 Rotation: ${rotation || 0}°`); + console.log(`📊 Format: ${format}`); + + const validation = validateBase64Image(base64); + if (!validation.isValid) { + throw new Error(`Invalid base64 data: ${validation.error}`); + } - results.push(result); - - const extractedRows = extractedTables.reduce((sum, table) => sum + table.length, 0); - console.log(` ${rotation}°: confidence=${(analysis.confidence * 100).toFixed(1)}%, tables=${analysis.tablesFound}, quality=${(analysis.textQuality * 100).toFixed(1)}%, keywords=${analysis.keywordCount}, rows=${extractedRows}`); - - } catch (error) { - console.warn(` ${rotation}°: Failed -`, error instanceof Error ? error.message : 'Unknown error'); - results.push({ - rotation, - confidence: 0, - tablesFound: 0, - textQuality: 0, - keywordCount: 0, - extractedTables: [] - }); - } + console.log(`✅ Base64 validation passed: ${validation.size} bytes`); + + if (validation.size! > 52428800) { + throw new Error(`Image too large: ${validation.size} bytes (max: 50MB)`); } - return results.map(result => ({ ...result, score: 0 })); // score는 findBestRotation에서 계산 -} + if (validation.size! < 1000) { + throw new Error(`Image too small: ${validation.size} bytes (min: 1KB)`); + } -// 최적 회전 각도 찾기 -function findBestRotation(results: (RotationTestResult & { score?: number })[]): RotationTestResult & { score?: number } { - console.log('🎯 Analyzing rotation results...'); - - const scoredResults = results.map(result => { - const extractedRows = result.extractedTables?.reduce((sum, table) => sum + table.length, 0) || 0; - - // 개선된 점수 계산 알고리즘 - let score = 0; - - // 1. OCR 신뢰도 (25%) - score += result.confidence * 0.25; - - // 2. 발견된 테이블 수 (25%) - score += Math.min(result.tablesFound / 3, 1) * 0.25; // 최대 3개 테이블까지 점수 - - // 3. 텍스트 품질 (20%) - score += result.textQuality * 0.20; - - // 4. 키워드 개수 (15%) - score += Math.min(result.keywordCount / 10, 1) * 0.15; // 최대 10개 키워드까지 점수 - - // 5. 추출된 행 수 (15%) - score += Math.min(extractedRows / 20, 1) * 0.15; // 최대 20행까지 점수 - - // 보너스: 실제 데이터가 추출된 경우 - if (extractedRows > 0) { - score += 0.1; // 10% 보너스 - } - - console.log(` ${result.rotation}°: score=${score.toFixed(3)} (conf=${(result.confidence * 100).toFixed(1)}%, tables=${result.tablesFound}, quality=${(result.textQuality * 100).toFixed(1)}%, keywords=${result.keywordCount}, rows=${extractedRows})`); - - return { ...result, score }; - }); + const ocrSecretKey = process.env.OCR_SECRET_KEY; + if (!ocrSecretKey) { + throw new Error('OCR_SECRET_KEY environment variable is not set'); + } - const bestResult = scoredResults.reduce((best, current) => - current.score > best.score ? current : best - ); + let processedBase64: string; + try { + console.log('🔧 Normalizing image format...'); + processedBase64 = await normalizeImageFormat(base64); + console.log('✅ Image format normalized to JPEG'); + } catch (error) { + console.warn('⚠️ Format normalization failed, using original:', error); + processedBase64 = base64.replace(/^data:image\/[a-z]+;base64,/, ''); + } - console.log(`🏆 Winner: ${bestResult.rotation}° with score ${bestResult.score?.toFixed(3)}`); - - return bestResult; -} + const requestId = uuidv4(); + const timestamp = Math.floor(Date.now() / 1000); -// OCR API 호출 -async function callOCRAPI(base64: string, format: string, filename: string, rotation?: number): Promise<any> { const ocrBody = { version: "V2", - requestId: uuidv4(), - timestamp: Math.floor(Date.now() / 1000), + requestId, + timestamp, lang: "ko", images: [{ - format, + format: "jpg", url: null, - data: base64, + data: processedBase64, name: filename, ...(rotation !== undefined && rotation !== 0 && { rotation }) }], enableTableDetection: true }; - const response = await fetch( - "https://n4a9z5rb5r.apigw.ntruss.com/custom/v1/35937/41dcc5202686a37ee5f4fae85bd07f599e3627e9c00d33bf6e469f409fe0ca62/general", - { + console.log('📤 Request details:', { + requestId, + timestamp, + dataSize: processedBase64.length, + enableTableDetection: ocrBody.enableTableDetection, + hasRotation: rotation !== undefined && rotation !== 0 + }); + + const apiUrl = "https://n4a9z5rb5r.apigw.ntruss.com/custom/v1/35937/41dcc5202686a37ee5f4fae85bd07f599e3627e9c00d33bf6e469f409fe0ca62/general"; + + try { + console.log('🚀 Sending OCR request...'); + + const response = await fetch(apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'X-OCR-SECRET': process.env.OCR_SECRET_KEY || '', + 'X-OCR-SECRET': ocrSecretKey, }, body: JSON.stringify(ocrBody), - signal: AbortSignal.timeout(60000) // 60초 타임아웃 + signal: AbortSignal.timeout(60000) + }); + + console.log(`📨 Response: ${response.status} ${response.statusText}`); + + if (!response.ok) { + const errorText = await response.text(); + console.error(`❌ OCR API Error: ${errorText}`); + + let parsedError; + try { + parsedError = JSON.parse(errorText); + } catch { + parsedError = { message: errorText }; + } + + if (parsedError.code === "0011") { + throw new Error(`Image data size error: ${parsedError.message} (actual size: ${validation.size} bytes)`); + } else if (response.status === 401) { + throw new Error('Authentication failed - check OCR_SECRET_KEY'); + } else if (response.status === 403) { + throw new Error('Access forbidden - check API permissions'); + } else if (response.status === 429) { + throw new Error('Rate limit exceeded - too many requests'); + } + + throw new Error(`OCR API error: ${response.status} ${response.statusText} - ${errorText}`); } - ); - if (!response.ok) { - throw new Error(`OCR API error: ${response.status} ${response.statusText}`); - } + const result = await response.json(); + console.log('✅ OCR API Success!'); + console.log('📊 Response structure:', { + hasImages: !!result.images, + imageCount: result.images?.length || 0, + firstImageHasTables: !!result.images?.[0]?.tables, + firstImageTableCount: result.images?.[0]?.tables?.length || 0, + firstImageHasFields: !!result.images?.[0]?.fields, + firstImageFieldCount: result.images?.[0]?.fields?.length || 0 + }); + + return result; - return response.json(); + } catch (error) { + console.error('💥 OCR API Call Failed:', error); + throw error; + } }
\ No newline at end of file diff --git a/app/api/ocr/utils/imageRotation.ts b/app/api/ocr/utils/imageRotation.ts index fe9cf840..6d59dace 100644 --- a/app/api/ocr/utils/imageRotation.ts +++ b/app/api/ocr/utils/imageRotation.ts @@ -1,244 +1,427 @@ +// ============================================================================ // app/api/ocr/utils/imageRotation.ts -// Sharp을 사용한 서버 사이드 이미지 회전 +// PDF 페이지 처리 기능이 추가된 이미지 회전 유틸리티 +// ============================================================================ import sharp from 'sharp'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { randomUUID } from 'crypto'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import { tmpdir } from 'os'; + +const exec = promisify(execFile); /** - * 서버 사이드에서 이미지를 회전시킵니다 - * @param base64 - base64 인코딩된 이미지 데이터 - * @param degrees - 회전 각도 (0, 90, 180, 270) - * @returns Promise<string> - 회전된 이미지의 base64 데이터 + * PDF 페이지 수를 확인하는 함수 */ -export async function rotateImageBase64(base64: string, degrees: number): Promise<string> { +export async function getPDFPageCount(pdfBuffer: Buffer): Promise<number> { + const tmp = tmpdir(); + const id = randomUUID(); + const pdfPath = path.join(tmp, `${id}.pdf`); + try { - console.log(`🔄 Rotating image by ${degrees} degrees...`); + await fs.writeFile(pdfPath, pdfBuffer); - // base64를 Buffer로 변환 - const inputBuffer = Buffer.from(base64, 'base64'); + // pdfinfo 명령어로 페이지 수 확인 + const { stdout } = await exec('pdfinfo', [pdfPath]); + const pageMatch = stdout.match(/Pages:\s+(\d+)/); + const pageCount = pageMatch ? parseInt(pageMatch[1]) : 1; - // 회전 각도에 따른 처리 - let rotatedBuffer: Buffer; - - switch (degrees) { - case 0: - // 회전 없음 - rotatedBuffer = inputBuffer; - break; - - case 90: - rotatedBuffer = await sharp(inputBuffer) - .rotate(90) - .jpeg({ - quality: 90, - progressive: true - }) - .toBuffer(); - break; - - case 180: - rotatedBuffer = await sharp(inputBuffer) - .rotate(180) - .jpeg({ - quality: 90, - progressive: true - }) - .toBuffer(); - break; - - case 270: - rotatedBuffer = await sharp(inputBuffer) - .rotate(270) - .jpeg({ - quality: 90, - progressive: true - }) - .toBuffer(); - break; - - default: - console.warn(`⚠️ Unsupported rotation angle: ${degrees}°. Using original image.`); - rotatedBuffer = inputBuffer; - } - - // Buffer를 다시 base64로 변환 - const rotatedBase64 = rotatedBuffer.toString('base64'); - - console.log(`✅ Image rotated successfully (${degrees}°)`); - return rotatedBase64; + console.log(`📄 PDF has ${pageCount} pages`); + return pageCount; } catch (error) { - console.error(`❌ Error rotating image by ${degrees}°:`, error); - console.warn('Using original image due to rotation error'); - return base64; // 실패시 원본 반환 + console.warn('❌ Could not get PDF page count, trying alternative method:', error); + + // pdfinfo가 실패하면 pdftoppm으로 테스트 + try { + await exec('pdftoppm', ['-l', '1', '-null', pdfPath]); + return 1; // 최소 1페이지는 있음 + } catch { + console.warn('⚠️ Could not determine page count, assuming 1 page'); + return 1; + } + } finally { + await fs.rm(pdfPath, { force: true }).catch(() => {}); } } /** - * 이미지 품질을 개선합니다 - * @param base64 - base64 인코딩된 이미지 데이터 - * @returns Promise<string> - 개선된 이미지의 base64 데이터 + * base64 데이터 유효성 검증 */ -export async function enhanceImageQuality(base64: string): Promise<string> { - try { - console.log('🎨 Enhancing image quality...'); - - const inputBuffer = Buffer.from(base64, 'base64'); - - const enhancedBuffer = await sharp(inputBuffer) - .resize(2000, 2000, { - fit: 'inside', - withoutEnlargement: true - }) - // 개별 매개변수 방식으로 수정 - .sharpen(1, 1, 2) // sigma, m1(flat), m2(jagged) - .normalize() // 히스토그램 정규화 - .gamma(1.1) // 약간의 감마 보정 - .jpeg({ - quality: 95, - progressive: true, - mozjpeg: true - }) - .toBuffer(); - - const enhancedBase64 = enhancedBuffer.toString('base64'); - - console.log('✅ Image quality enhanced'); - return enhancedBase64; - - } catch (error) { - console.error('❌ Error enhancing image:', error); - return base64; - } +export function validateBase64Image(base64: string): { isValid: boolean; error?: string; size?: number } { + try { + if (!base64 || typeof base64 !== 'string') { + return { isValid: false, error: 'Base64 data is empty or invalid type' }; + } + + const cleanBase64 = base64.replace(/^data:image\/[a-z]+;base64,/, ''); + + if (!/^[A-Za-z0-9+/]*={0,2}$/.test(cleanBase64)) { + return { isValid: false, error: 'Invalid base64 format' }; + } + + if (cleanBase64.length < 100) { + return { isValid: false, error: `Base64 too short: ${cleanBase64.length} characters` }; + } + + const buffer = Buffer.from(cleanBase64, 'base64'); + const bufferSize = buffer.length; + + if (bufferSize < 1) { + return { isValid: false, error: `Buffer too small: ${bufferSize} bytes` }; + } + + if (bufferSize > 52428800) { // 50MB + return { isValid: false, error: `Buffer too large: ${bufferSize} bytes (max: 50MB)` }; + } + + return { isValid: true, size: bufferSize }; + + } catch (error) { + return { + isValid: false, + error: `Base64 validation failed: ${error instanceof Error ? error.message : 'Unknown error'}` + }; + } } /** - * PDF를 고품질 이미지로 변환합니다 - * @param pdfBuffer - PDF Buffer 데이터 - * @param pageIndex - 변환할 페이지 인덱스 (0부터 시작) - * @returns Promise<string> - 변환된 이미지의 base64 데이터 + * 서버 사이드에서 이미지를 회전시킵니다 */ -export async function convertPDFToImage(pdfBuffer: Buffer, pageIndex: number = 0): Promise<string> { - try { - console.log(`📄 Converting PDF page ${pageIndex + 1} to image...`); - - // pdf2pic 라이브러리 사용 - const pdf2pic = require('pdf2pic'); - - const convert = pdf2pic.fromBuffer(pdfBuffer, { - density: 300, // 300 DPI for high quality - saveFilename: "page", - savePath: "/tmp", // 임시 경로 - format: "jpeg", - width: 2480, // A4 크기 @ 300 DPI - height: 3508, - quality: 100 - }); - - const result = await convert(pageIndex + 1, { responseType: "buffer" }); - const base64 = result.buffer.toString('base64'); - - console.log('✅ PDF converted to image successfully'); - return base64; - - } catch (error) { - console.error('❌ Error converting PDF to image:', error); - throw new Error('Failed to convert PDF to image'); - } +export async function rotateImageBase64(base64: string, degrees: number): Promise<string> { + try { + console.log(`🔄 === ROTATING IMAGE BY ${degrees}° ===`); + + const validation = validateBase64Image(base64); + if (!validation.isValid) { + throw new Error(`Invalid input base64: ${validation.error}`); + } + + console.log(`✅ Input validation passed - size: ${validation.size} bytes`); + + const cleanBase64 = base64.replace(/^data:image\/[a-z]+;base64,/, ''); + const inputBuffer = Buffer.from(cleanBase64, 'base64'); + + console.log(`📊 Input buffer created: ${inputBuffer.length} bytes`); + + const normalizedDegrees = ((degrees % 360) + 360) % 360; + console.log(`📐 Normalized rotation: ${normalizedDegrees}°`); + + let rotatedBuffer: Buffer; + + if (normalizedDegrees === 0) { + console.log(' ↻ No rotation needed, applying quality enhancement...'); + rotatedBuffer = await sharp(inputBuffer) + .jpeg({ + quality: 90, + progressive: true, + mozjpeg: true + }) + .toBuffer(); + } else { + console.log(` 🔄 Applying ${normalizedDegrees}° rotation...`); + + const sharpInstance = sharp(inputBuffer); + const metadata = await sharpInstance.metadata(); + console.log(` 📏 Original image: ${metadata.width}x${metadata.height}, format: ${metadata.format}`); + + rotatedBuffer = await sharpInstance + .rotate(normalizedDegrees) + .jpeg({ + quality: 90, + progressive: true, + mozjpeg: true + }) + .toBuffer(); + } + + console.log(`📊 Output buffer created: ${rotatedBuffer.length} bytes`); + + if (rotatedBuffer.length === 0) { + throw new Error('Rotation resulted in empty buffer'); + } + + if (rotatedBuffer.length > 52428800) { + throw new Error(`Rotated image too large: ${rotatedBuffer.length} bytes`); + } + + const rotatedBase64 = rotatedBuffer.toString('base64'); + + const outputValidation = validateBase64Image(rotatedBase64); + if (!outputValidation.isValid) { + throw new Error(`Invalid output base64: ${outputValidation.error}`); + } + + console.log(`✅ Image rotated successfully: ${outputValidation.size} bytes`); + console.log(`📈 Size change: ${inputBuffer.length} → ${outputValidation.size} bytes`); + + return rotatedBase64; + + } catch (error) { + console.error(`❌ Error rotating image by ${degrees}°:`, error); + + if (error instanceof Error) { + if (error.message.includes('Input buffer contains unsupported image format')) { + console.error(' 🖼️ Unsupported image format - try converting to JPEG first'); + } else if (error.message.includes('Input image exceeds pixel limit')) { + console.error(' 📏 Image too large for processing'); + } else if (error.message.includes('premature close')) { + console.error(' 🔧 Corrupted image data'); + } + } + + const originalValidation = validateBase64Image(base64); + if (originalValidation.isValid) { + console.warn(' ↩️ Using original image due to rotation error'); + return base64; + } else { + throw new Error(`Rotation failed and original image is invalid: ${originalValidation.error}`); + } + } } /** - * 이미지에서 텍스트 방향을 감지합니다 - * @param base64 - base64 인코딩된 이미지 데이터 - * @returns Promise<number> - 감지된 올바른 회전 각도 + * 이미지 품질을 개선합니다 */ -export async function detectTextOrientation(base64: string): Promise<number> { - // 이 함수는 간단한 방향 감지를 시뮬레이션합니다 - // 실제로는 더 정교한 알고리즘이 필요할 수 있습니다 - - console.log('🧭 Detecting text orientation...'); - - const rotations = [0, 90, 180, 270]; - const scores: { rotation: number; score: number }[] = []; - - for (const rotation of rotations) { +export async function enhanceImageQuality(base64: string): Promise<string> { try { - const rotatedBase64 = await rotateImageBase64(base64, rotation); - - // 간단한 품질 측정 (실제로는 OCR API 호출이나 다른 방법 사용) - const score = await estimateTextQuality(rotatedBase64); - scores.push({ rotation, score }); - - console.log(` ${rotation}°: quality score = ${score.toFixed(3)}`); - + console.log('🎨 === ENHANCING IMAGE QUALITY ==='); + + const validation = validateBase64Image(base64); + if (!validation.isValid) { + console.warn(`⚠️ Invalid input for enhancement: ${validation.error}`); + return base64; + } + + console.log(`✅ Enhancement input valid: ${validation.size} bytes`); + + const cleanBase64 = base64.replace(/^data:image\/[a-z]+;base64,/, ''); + const inputBuffer = Buffer.from(cleanBase64, 'base64'); + + const sharpInstance = sharp(inputBuffer); + const metadata = await sharpInstance.metadata(); + + console.log(`📏 Original: ${metadata.width}x${metadata.height}, ${metadata.format}`); + + const maxDimension = 2000; + let needsResize = false; + + if (metadata.width && metadata.height) { + needsResize = metadata.width > maxDimension || metadata.height > maxDimension; + } + + let enhancedBuffer: Buffer; + + if (needsResize) { + console.log(`📐 Resizing to fit ${maxDimension}px...`); + enhancedBuffer = await sharpInstance + .resize(maxDimension, maxDimension, { + fit: 'inside', + withoutEnlargement: true + }) + .sharpen(0.5, 1, 2) + .normalize() + .gamma(1.1) + .jpeg({ + quality: 95, + progressive: true, + mozjpeg: true + }) + .toBuffer(); + } else { + console.log('📐 No resize needed, applying enhancement only...'); + enhancedBuffer = await sharpInstance + .sharpen(0.5, 1, 2) + .normalize() + .gamma(1.1) + .jpeg({ + quality: 95, + progressive: true, + mozjpeg: true + }) + .toBuffer(); + } + + const enhancedBase64 = enhancedBuffer.toString('base64'); + + const outputValidation = validateBase64Image(enhancedBase64); + if (!outputValidation.isValid) { + console.warn(`⚠️ Enhancement resulted in invalid image: ${outputValidation.error}`); + return base64; + } + + console.log(`✅ Image enhanced: ${validation.size} → ${outputValidation.size} bytes`); + return enhancedBase64; + } catch (error) { - console.warn(` ${rotation}°: Failed to test orientation`); - scores.push({ rotation, score: 0 }); + console.error('❌ Error enhancing image:', error); + return base64; } - } - - const bestOrientation = scores.reduce((best, current) => - current.score > best.score ? current : best - ); - - console.log(`🎯 Best orientation detected: ${bestOrientation.rotation}°`); - return bestOrientation.rotation; } /** - * 이미지의 텍스트 품질을 추정합니다 (간단한 버전) + * PDF를 이미지로 변환합니다 (개선된 버전) */ -async function estimateTextQuality(base64: string): Promise<number> { +export async function convertPDFToImage( + pdfBuffer: Buffer, + pageIndex = 0, + dpi = 300, +): Promise<string> { + const tmp = tmpdir(); + const id = randomUUID(); + const pdfPath = path.join(tmp, `${id}.pdf`); + const outPrefix = path.join(tmp, id); + const jpgPath = `${outPrefix}.jpg`; + try { - const buffer = Buffer.from(base64, 'base64'); + console.log(`📄 Converting PDF page ${pageIndex + 1} to image (${dpi} DPI)...`); - // Sharp을 사용해 이미지 통계 분석 - const stats = await sharp(buffer) - .greyscale() - .stats(); - - // 간단한 품질 지표 계산 - // 실제로는 더 복잡한 알고리즘이 필요합니다 - const contrast = stats.channels[0].max - stats.channels[0].min; - const sharpness = stats.channels[0].stdev; + // 1) PDF 임시 저장 + await fs.writeFile(pdfPath, pdfBuffer); + + // 2) pdftoppm 실행 + const page = pageIndex + 1; // pdftoppm은 1-based + await exec('pdftoppm', [ + '-jpeg', + '-singlefile', + '-r', dpi.toString(), + '-f', page.toString(), + '-l', page.toString(), + pdfPath, + outPrefix, + ], { maxBuffer: 1024 * 1024 * 50 }); // 50MB 버퍼 + + // 3) 결과 읽어 base64 변환 + const img = await fs.readFile(jpgPath); + const base64 = img.toString('base64'); - return (contrast + sharpness) / 510; // 0-1 범위로 정규화 + console.log(`✅ PDF page ${pageIndex + 1} converted successfully: ${img.length} bytes`); + return base64; + } catch (error) { - return 0; + console.error(`❌ Error converting PDF page ${pageIndex + 1}:`, error); + throw new Error(`Failed to convert PDF page ${pageIndex + 1}: ${error instanceof Error ? error.message : 'Unknown error'}`); + } finally { + // 4) 임시 파일 정리 + await fs.rm(pdfPath, { force: true }).catch(() => {}); + await fs.rm(jpgPath, { force: true }).catch(() => {}); } } /** - * 이미지가 회전이 필요한지 빠르게 체크합니다 - * @param base64 - base64 인코딩된 이미지 데이터 - * @returns Promise<boolean> - 회전이 필요하면 true + * 파일 형식을 JPEG로 정규화 */ -export async function needsRotation(base64: string): Promise<boolean> { - try { - const buffer = Buffer.from(base64, 'base64'); - - // 이미지 메타데이터 확인 - const metadata = await sharp(buffer).metadata(); - - // EXIF 방향 정보가 있으면 회전 필요 - if (metadata.orientation && metadata.orientation > 1) { - console.log(`📐 EXIF orientation detected: ${metadata.orientation}`); - return true; +export async function normalizeImageFormat(base64: string): Promise<string> { + try { + console.log('🔄 Normalizing image format to JPEG...'); + + const validation = validateBase64Image(base64); + if (!validation.isValid) { + throw new Error(`Cannot normalize invalid image: ${validation.error}`); + } + + const cleanBase64 = base64.replace(/^data:image\/[a-z]+;base64,/, ''); + const inputBuffer = Buffer.from(cleanBase64, 'base64'); + + const normalizedBuffer = await sharp(inputBuffer) + .jpeg({ + quality: 90, + progressive: true + }) + .toBuffer(); + + const normalizedBase64 = normalizedBuffer.toString('base64'); + + const outputValidation = validateBase64Image(normalizedBase64); + if (!outputValidation.isValid) { + throw new Error(`Normalization failed: ${outputValidation.error}`); + } + + console.log(`✅ Format normalized: ${validation.size} → ${outputValidation.size} bytes`); + return normalizedBase64; + + } catch (error) { + console.error('❌ Error normalizing image format:', error); + throw error; } - - // 가로/세로 비율 체크 (일반적으로 문서는 세로가 더 긺) - if (metadata.width && metadata.height) { - const aspectRatio = metadata.width / metadata.height; - if (aspectRatio > 1.5) { - console.log(`📐 Wide aspect ratio detected: ${aspectRatio.toFixed(2)}`); - return true; // 너무 가로로 긴 이미지는 회전 필요할 가능성 - } +} + +// 기존 함수들 +export async function detectTextOrientation(base64: string): Promise<number> { + console.log('🧭 Detecting text orientation...'); + + const rotations = [0, 90, 180, 270]; + const scores: { rotation: number; score: number }[] = []; + + for (const rotation of rotations) { + try { + const rotatedBase64 = await rotateImageBase64(base64, rotation); + const score = await estimateTextQuality(rotatedBase64); + scores.push({ rotation, score }); + + console.log(` ${rotation}°: quality score = ${score.toFixed(3)}`); + + } catch (error) { + console.warn(` ${rotation}°: Failed to test orientation`); + scores.push({ rotation, score: 0 }); + } + } + + const bestOrientation = scores.reduce((best, current) => + current.score > best.score ? current : best + ); + + console.log(`🎯 Best orientation detected: ${bestOrientation.rotation}°`); + return bestOrientation.rotation; +} + +async function estimateTextQuality(base64: string): Promise<number> { + try { + const cleanBase64 = base64.replace(/^data:image\/[a-z]+;base64,/, ''); + const buffer = Buffer.from(cleanBase64, 'base64'); + + const stats = await sharp(buffer) + .greyscale() + .stats(); + + const contrast = stats.channels[0].max - stats.channels[0].min; + const sharpness = stats.channels[0].stdev; + + return (contrast + sharpness) / 510; + + } catch (error) { + return 0; + } +} + +export async function needsRotation(base64: string): Promise<boolean> { + try { + const cleanBase64 = base64.replace(/^data:image\/[a-z]+;base64,/, ''); + const buffer = Buffer.from(cleanBase64, 'base64'); + + const metadata = await sharp(buffer).metadata(); + + if (metadata.orientation && metadata.orientation > 1) { + console.log(`📐 EXIF orientation detected: ${metadata.orientation}`); + return true; + } + + if (metadata.width && metadata.height) { + const aspectRatio = metadata.width / metadata.height; + if (aspectRatio > 1.5) { + console.log(`📐 Wide aspect ratio detected: ${aspectRatio.toFixed(2)}`); + return true; + } + } + + return false; + + } catch (error) { + console.warn('Error checking if rotation needed:', error); + return false; } - - return false; - - } catch (error) { - console.warn('Error checking if rotation needed:', error); - return false; - } }
\ No newline at end of file diff --git a/app/api/ocr/utils/tableExtraction.ts b/app/api/ocr/utils/tableExtraction.ts index ea543f8e..720e5a5f 100644 --- a/app/api/ocr/utils/tableExtraction.ts +++ b/app/api/ocr/utils/tableExtraction.ts @@ -1,7 +1,11 @@ // app/api/ocr/utils/tableExtraction.ts -// 완전한 테이블 추출 로직 구현 +// 개선된 완전 테이블 추출 로직 – Format‑1 식별번호 파싱 보강 & 중복 행 제거 -interface ExtractedRow { +/* -------------------------------------------------------------------------- */ +/* 타입 */ +/* -------------------------------------------------------------------------- */ + +export interface ExtractedRow { no: string; identificationNo: string; tagNo: string; @@ -41,516 +45,223 @@ interface ColumnMapping { weldingDate: number; } -// 메인 테이블 추출 함수 -export async function extractTablesFromOCR(ocrResult: any): Promise<ExtractedRow[][]> { - const extractedTables: ExtractedRow[][] = []; - const warnings: string[] = []; +/* -------------------------------------------------------------------------- */ +/* 메인 */ +/* -------------------------------------------------------------------------- */ - if (!ocrResult || !ocrResult.images) { - console.warn('No OCR images found in result'); - return []; - } +export async function extractTablesFromOCR (ocrResult: any): Promise<ExtractedRow[][]> { + const tables: ExtractedRow[][] = []; + if (!ocrResult?.images) return tables; - for (let imageIndex = 0; imageIndex < ocrResult.images.length; imageIndex++) { - const image = ocrResult.images[imageIndex]; - - if (!image.tables || image.tables.length === 0) { - console.warn(`No tables found in image ${imageIndex}`); - continue; - } + ocrResult.images.forEach((image: any, imgIdx: number) => { + image.tables?.forEach((table: OCRTable, tblIdx: number) => { + if (!isRelevantTable(table)) return; + const rows = extractTableData(table, imgIdx, tblIdx); + if (rows.length) tables.push(rows); + }); + }); + return tables; +} - for (let tableIndex = 0; tableIndex < image.tables.length; tableIndex++) { - const table = image.tables[tableIndex]; - - try { - if (isRelevantTable(table)) { - const extractedRows = extractTableData(table, imageIndex, tableIndex); - - if (extractedRows.length > 0) { - extractedTables.push(extractedRows); - console.log(`Successfully extracted ${extractedRows.length} rows from table ${tableIndex + 1} in image ${imageIndex + 1}`); - } else { - console.warn(`Table ${tableIndex + 1} in image ${imageIndex + 1} was identified as relevant but no data could be extracted`); - } - } else { - console.log(`Table ${tableIndex + 1} in image ${imageIndex + 1} is not relevant (no required headers found)`); - } - } catch (error) { - console.error(`Error processing table ${tableIndex + 1} in image ${imageIndex + 1}:`, error); - } - } - } +/* -------------------------------------------------------------------------- */ +/* 관련 테이블 판별 */ +/* -------------------------------------------------------------------------- */ - console.log(`Total extracted tables: ${extractedTables.length}`); - return extractedTables; +function isRelevantTable (table: OCRTable): boolean { + const headers = table.cells.filter(c => c.rowIndex < 3).map(getCellText).join(' ').toLowerCase(); + return /\bno\b|번호/.test(headers) && /identification|식별|ident|id/.test(headers); } -// 관련 테이블인지 확인 -function isRelevantTable(table: OCRTable): boolean { - if (!table.cells || table.cells.length === 0) { - return false; - } +/* -------------------------------------------------------------------------- */ +/* 표 해석 */ +/* -------------------------------------------------------------------------- */ - // 첫 3행에서 헤더 찾기 - const headerCells = table.cells.filter(cell => cell.rowIndex <= 2); - const headerTexts = headerCells - .map(cell => getCellText(cell).toLowerCase()) - .filter(text => text.length > 0); - - console.log('Header texts found:', headerTexts); - - // 필수 키워드 확인 - const hasNo = headerTexts.some(text => - text.includes('no.') || - text === 'no' || - text.includes('번호') || - text.match(/^no\.?$/i) - ); - - const hasIdentification = headerTexts.some(text => - text.includes('identification') || - text.includes('식별') || - text.includes('ident') || - text.includes('id') - ); - - // 테이블 품질 확인 - const hasMinimumCells = table.cells.length >= 6; // 최소 헤더 + 데이터 - const hasReasonableConfidence = table.inferConfidence >= 0.5; // 신뢰도 기준 완화 - - const isRelevant = hasNo && hasIdentification && hasMinimumCells && hasReasonableConfidence; - - console.log(`Table relevance check: hasNo=${hasNo}, hasIdentification=${hasIdentification}, minCells=${hasMinimumCells}, confidence=${hasReasonableConfidence} => ${isRelevant}`); - - return isRelevant; -} +function extractTableData (table: OCRTable, imgIdx: number, tblIdx: number): ExtractedRow[] { + const grid = buildGrid(table); + const headerRowIdx = findHeaderRow(grid); + if (headerRowIdx === -1) return []; -// 테이블 데이터 추출 -function extractTableData(table: OCRTable, imageIndex: number, tableIndex: number): ExtractedRow[] { - console.log(`Processing table ${tableIndex + 1} in image ${imageIndex + 1}`); - - // 테이블 그리드 구축 - const tableGrid = buildTableGrid(table); - - if (tableGrid.length < 2) { - console.warn('Table has less than 2 rows (need header + data)'); - return []; - } + const format = detectFormat(grid[headerRowIdx]); + const mapping = mapColumns(grid[headerRowIdx]); - console.log(`Table grid built: ${tableGrid.length} rows, ${tableGrid[0]?.length || 0} columns`); + const seen = new Set<string>(); + const data: ExtractedRow[] = []; - // 헤더 행 찾기 - const headerRowIndex = findHeaderRow(tableGrid); - if (headerRowIndex === -1) { - console.warn('No header row found'); - return []; - } + for (let r = headerRowIdx + 1; r < grid.length; r++) { + const row = grid[r]; + if (isBlankRow(row)) continue; - console.log(`Header row found at index: ${headerRowIndex}`); - - // 테이블 형식 결정 - const headerRow = tableGrid[headerRowIndex]; - const tableFormat = determineTableFormat(headerRow); - console.log(`Table format detected: ${tableFormat}`); - - // 컬럼 매핑 찾기 - const columnMapping = findColumnMapping(headerRow, tableFormat); - console.log('Column mapping:', columnMapping); - - // 데이터 행 추출 - const dataRows: ExtractedRow[] = []; - - for (let i = headerRowIndex + 1; i < tableGrid.length; i++) { - const row = tableGrid[i]; - - if (row && row.length > 0 && !isEmptyRow(row)) { - try { - const extractedRow = extractRowData(row, tableFormat, columnMapping, imageIndex, tableIndex, i); - if (extractedRow && isValidRow(extractedRow)) { - dataRows.push(extractedRow); - } - } catch (error) { - console.warn(`Error processing row ${i}:`, error); - } - } + const parsed = buildRow(row, format, mapping, tblIdx, r); + if (!parsed || !isValidRow(parsed)) continue; + + const key = `${parsed.no}-${parsed.identificationNo}`; + if (seen.has(key)) continue; + seen.add(key); + + data.push(parsed); } - - console.log(`Extracted ${dataRows.length} valid rows from table`); - return dataRows; + return data; } -// 테이블 그리드 구축 -function buildTableGrid(table: OCRTable): string[][] { - if (!table.cells || table.cells.length === 0) { - return []; - } +/* -------------------------------------------------------------------------- */ +/* Grid & Header */ +/* -------------------------------------------------------------------------- */ + +function buildGrid (table: OCRTable): string[][] { + const maxR = Math.max(...table.cells.map(c => c.rowIndex + c.rowSpan - 1)); + const maxC = Math.max(...table.cells.map(c => c.columnIndex + c.columnSpan - 1)); + const grid = Array.from({ length: maxR + 1 }, () => Array(maxC + 1).fill('')); - const maxRow = Math.max(...table.cells.map(cell => cell.rowIndex + cell.rowSpan - 1)) + 1; - const maxCol = Math.max(...table.cells.map(cell => cell.columnIndex + cell.columnSpan - 1)) + 1; - - const grid: string[][] = Array(maxRow).fill(null).map(() => Array(maxCol).fill('')); - - // 셀 내용으로 그리드 채우기 table.cells.forEach(cell => { - const text = getCellText(cell); - + const txt = getCellText(cell); for (let r = cell.rowIndex; r < cell.rowIndex + cell.rowSpan; r++) { for (let c = cell.columnIndex; c < cell.columnIndex + cell.columnSpan; c++) { - if (grid[r] && grid[r][c] !== undefined) { - // 기존 텍스트가 있으면 결합 - grid[r][c] = grid[r][c] ? `${grid[r][c]} ${text}`.trim() : text; - } + grid[r][c] = grid[r][c] ? `${grid[r][c]} ${txt}` : txt; } } }); - return grid; } -// 셀 텍스트 추출 -function getCellText(cell: TableCell): string { - if (!cell.cellTextLines || cell.cellTextLines.length === 0) { - return ''; - } - - return cell.cellTextLines - .map(line => - line.cellWords - .map(word => word.inferText || '') - .join(' ') - ) - .join('\n') - .trim(); +function getCellText (cell: TableCell): string { + return cell.cellTextLines?.flatMap(l => l.cellWords.map(w => w.inferText.trim())).filter(Boolean).join(' ') ?? ''; } -// 헤더 행 찾기 -function findHeaderRow(tableGrid: string[][]): number { - for (let i = 0; i < Math.min(3, tableGrid.length); i++) { - const row = tableGrid[i]; - const rowText = row.join(' ').toLowerCase(); - - console.log(`Checking row ${i}: "${rowText}"`); - - const hasNo = rowText.includes('no.') || rowText.includes('번호') || /\bno\b/.test(rowText); - const hasIdent = rowText.includes('identification') || rowText.includes('식별') || rowText.includes('ident'); - - if (hasNo && hasIdent) { - console.log(`Header row found at ${i}`); - return i; - } +function findHeaderRow (grid: string[][]): number { + for (let i = 0; i < Math.min(3, grid.length); i++) { + const t = grid[i].join(' ').toLowerCase(); + if (/\bno\b|번호/.test(t) && /identification|식별|ident/.test(t)) return i; } return -1; } -// 테이블 형식 결정 -function determineTableFormat(headerRow: string[]): 'format1' | 'format2' { - const headerText = headerRow.join(' ').toLowerCase(); - - // Format 2: Tag No와 Joint No가 분리된 컬럼 - const hasTagNoColumn = headerText.includes('tag') && headerText.includes('no'); - const hasJointNoColumn = headerText.includes('joint') && headerText.includes('no'); - - if (hasTagNoColumn && hasJointNoColumn) { - return 'format2'; - } - - // Format 1: Identification No에 통합 - return 'format1'; -} +/* -------------------------------------------------------------------------- */ +/* Column Mapping */ +/* -------------------------------------------------------------------------- */ -// 컬럼 매핑 찾기 -function findColumnMapping(headerRow: string[], format: 'format1' | 'format2'): ColumnMapping { - const mapping: ColumnMapping = { - no: -1, - identification: -1, - tagNo: -1, - jointNo: -1, - jointType: -1, - weldingDate: -1 - }; +function detectFormat (header: string[]): 'format1' | 'format2' { + const h = header.join(' ').toLowerCase(); + return h.includes('tag') && h.includes('joint') ? 'format2' : 'format1'; +} - headerRow.forEach((header, index) => { - const lowerHeader = header.toLowerCase().trim(); - - console.log(`Column ${index}: "${header}" -> "${lowerHeader}"`); - - if ((lowerHeader.includes('no.') || lowerHeader === 'no') && - !lowerHeader.includes('identification') && - !lowerHeader.includes('tag') && - !lowerHeader.includes('joint')) { - mapping.no = index; - console.log(` -> Mapped to 'no'`); - } else if (lowerHeader.includes('identification') || lowerHeader.includes('ident')) { - mapping.identification = index; - console.log(` -> Mapped to 'identification'`); - } else if (lowerHeader.includes('tag') && lowerHeader.includes('no')) { - mapping.tagNo = index; - console.log(` -> Mapped to 'tagNo'`); - } else if (lowerHeader.includes('joint') && lowerHeader.includes('no')) { - mapping.jointNo = index; - console.log(` -> Mapped to 'jointNo'`); - } else if (lowerHeader.includes('joint') && lowerHeader.includes('type')) { - mapping.jointType = index; - console.log(` -> Mapped to 'jointType'`); - } else if (lowerHeader.includes('type') && !lowerHeader.includes('joint')) { - mapping.jointType = index; - console.log(` -> Mapped to 'jointType'`); - } else if (lowerHeader.includes('welding') || lowerHeader.includes('date')) { - mapping.weldingDate = index; - console.log(` -> Mapped to 'weldingDate'`); - } +function mapColumns (header: string[]): ColumnMapping { + const mp: ColumnMapping = { no: -1, identification: -1, tagNo: -1, jointNo: -1, jointType: -1, weldingDate: -1 }; + + header.forEach((h, i) => { + const t = h.toLowerCase(); + if (/^no\.?$/.test(t) && !/ident|tag|joint/.test(t)) mp.no = i; + else if (/identification|ident/.test(t)) mp.identification = i; + else if (/tag.*no/.test(t)) mp.tagNo = i; + else if (/joint.*no/.test(t)) mp.jointNo = i; + else if (/joint.*type/.test(t) || (/^type$/.test(t) && mp.jointType === -1)) mp.jointType = i; + else if (/welding|date/.test(t)) mp.weldingDate = i; }); - - console.log('Final column mapping:', mapping); - return mapping; + return mp; } -// 행 데이터 추출 -function extractRowData( - row: string[], - format: 'format1' | 'format2', - columnMapping: ColumnMapping, - imageIndex: number, - tableIndex: number, - rowIndex: number +/* -------------------------------------------------------------------------- */ +/* Row Extraction */ +/* -------------------------------------------------------------------------- */ + +function buildRow ( + row: string[], + format: 'format1' | 'format2', + mp: ColumnMapping, + tblIdx: number, + rowIdx: number ): ExtractedRow | null { - - const extractedRow: ExtractedRow = { - no: '', + const out: ExtractedRow = { + no: mp.no >= 0 ? clean(row[mp.no]) : '', identificationNo: '', tagNo: '', jointNo: '', - jointType: '', + jointType: mp.jointType >= 0 ? clean(row[mp.jointType]) : '', weldingDate: '', confidence: 0, - sourceTable: tableIndex, - sourceRow: rowIndex + sourceTable: tblIdx, + sourceRow: rowIdx, }; - console.log(`Processing row ${rowIndex}: [${row.map(cell => `"${cell}"`).join(', ')}]`); - - // No. 추출 - if (columnMapping.no >= 0 && columnMapping.no < row.length) { - extractedRow.no = cleanText(row[columnMapping.no]); + if (mp.weldingDate >= 0) out.weldingDate = clean(row[mp.weldingDate]); + else { + const idx = row.findIndex(col => /\d{4}[.\-/]\d{1,2}[.\-/]\d{1,2}/.test(col)); + if (idx >= 0) out.weldingDate = clean(row[idx]); } - if (format === 'format1') { - // Format 1: 통합된 identification 데이터 - if (columnMapping.identification >= 0 && columnMapping.identification < row.length) { - const combinedText = row[columnMapping.identification]; - const parsedData = parseIdentificationData(combinedText); - extractedRow.identificationNo = parsedData.identificationNo; - extractedRow.tagNo = parsedData.tagNo; - extractedRow.jointNo = parsedData.jointNo; - - console.log(` Parsed identification: "${combinedText}" -> `, parsedData); - } + if (format === 'format2') { + if (mp.identification >= 0) out.identificationNo = clean(row[mp.identification]); + if (mp.jointNo >= 0) out.jointNo = clean(row[mp.jointNo]); + if (mp.tagNo >= 0) out.tagNo = clean(row[mp.tagNo]); } else { - // Format 2: 분리된 컬럼들 - if (columnMapping.identification >= 0 && columnMapping.identification < row.length) { - extractedRow.identificationNo = cleanText(row[columnMapping.identification]); - } - if (columnMapping.tagNo >= 0 && columnMapping.tagNo < row.length) { - extractedRow.tagNo = cleanText(row[columnMapping.tagNo]); - } - if (columnMapping.jointNo >= 0 && columnMapping.jointNo < row.length) { - extractedRow.jointNo = cleanText(row[columnMapping.jointNo]); - } + const combined = mp.identification >= 0 ? row[mp.identification] : ''; + const parsed = parseIdentificationData(combined); + out.identificationNo = parsed.identificationNo; + out.jointNo = parsed.jointNo; + out.tagNo = parsed.tagNo; } - // Joint Type 추출 - if (columnMapping.jointType >= 0 && columnMapping.jointType < row.length) { - extractedRow.jointType = cleanText(row[columnMapping.jointType]); - } - - // Welding Date 추출 (컬럼 매핑이 있으면 사용, 없으면 날짜 패턴으로 찾기) - if (columnMapping.weldingDate >= 0 && columnMapping.weldingDate < row.length) { - extractedRow.weldingDate = cleanText(row[columnMapping.weldingDate]); - } else { - const dateIndex = findDateColumn(row); - if (dateIndex >= 0) { - extractedRow.weldingDate = cleanText(row[dateIndex]); - } - } - - // 신뢰도 계산 - extractedRow.confidence = calculateRowConfidence(extractedRow); - - console.log(` Extracted row:`, extractedRow); - - return extractedRow; + out.confidence = scoreRow(out); + return out; } -// Identification 데이터 파싱 (Format 1용) -function parseIdentificationData(combinedText: string): { - identificationNo: string; - tagNo: string; - jointNo: string; -} { - const cleanedText = cleanText(combinedText); - - console.log(`Parsing identification data: "${cleanedText}"`); - - // 줄바꿈으로 먼저 분리 - const lines = cleanedText.split(/[\r\n]+/).map(line => line.trim()).filter(line => line.length > 0); - - const allParts: string[] = []; - lines.forEach(line => { - // 공백과 특수문자로 분리 - const parts = line.split(/[\s\-_]+/).filter(part => part.length > 0); - allParts.push(...parts); - }); +/* -------------------------------------------------------------------------- */ +/* Format‑1 셀 파싱 */ +/* -------------------------------------------------------------------------- */ - console.log(` Split into parts:`, allParts); +function parseIdentificationData (txt: string): { identificationNo: string; jointNo: string; tagNo: string } { + const cleaned = clean(txt); + if (!cleaned) return { identificationNo: '', jointNo: '', tagNo: '' }; - if (allParts.length === 0) { - return { identificationNo: cleanedText, tagNo: '', jointNo: '' }; - } + const tokens = cleaned.split(/\s+/).map(clean).filter(Boolean); - if (allParts.length === 1) { - return { identificationNo: allParts[0], tagNo: '', jointNo: '' }; - } + // Identification 후보: 하이픈이 2개 이상 포함된 토큰 가운데 가장 긴 것 + const idCand = tokens.filter(t => t.split('-').length >= 3).sort((a, b) => b.length - a.length); + const identificationNo = idCand[0] || ''; - // 길이별로 정렬하여 식별 - const sortedParts = [...allParts].sort((a, b) => b.length - a.length); - - const identificationNo = sortedParts[0]; // 가장 긴 것 - const jointNo = allParts.find(part => part.length <= 3 && /^[A-Z0-9]+$/i.test(part)) || - sortedParts[sortedParts.length - 1]; // 3글자 이하 영숫자 또는 가장 짧은 것 - const tagNo = allParts.find(part => part !== identificationNo && part !== jointNo) || ''; - - const result = { identificationNo, tagNo, jointNo }; - console.log(` Parsed result:`, result); - - return result; -} + const residual = tokens.filter(t => t !== identificationNo); + if (!residual.length) return { identificationNo, jointNo: '', tagNo: '' }; -// 날짜 컬럼 찾기 -function findDateColumn(row: string[]): number { - const datePattern = /\d{4}[.\-\/]\d{1,2}[.\-\/]\d{1,2}/; - - for (let i = 0; i < row.length; i++) { - if (datePattern.test(row[i])) { - console.log(` Found date in column ${i}: "${row[i]}"`); - return i; - } - } - - return -1; -} + residual.sort((a, b) => a.length - b.length); + const jointNo = residual[0] || ''; + const tagNo = residual[residual.length - 1] || ''; -// 텍스트 정리 -function cleanText(text: string): string { - return text - .replace(/[\r\n\t]+/g, ' ') - .replace(/\s+/g, ' ') - .trim(); + return { identificationNo, jointNo, tagNo }; } -// 빈 행 확인 -function isEmptyRow(row: string[]): boolean { - return row.every(cell => !cell || cell.trim().length === 0); +/* -------------------------------------------------------------------------- */ +/* Helpers */ +/* -------------------------------------------------------------------------- */ + +const clean = (s: string = '') => s.replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim(); +const isBlankRow = (row: string[]) => row.every(c => !clean(c)); +const isValidRow = (r: ExtractedRow) => !!(r.no || r.identificationNo); + +function scoreRow (r: ExtractedRow): number { + const w: Record<keyof ExtractedRow, number> = { + no: 1, identificationNo: 3, tagNo: 2, jointNo: 2, jointType: 1, weldingDate: 1, + confidence: 0, sourceTable: 0, sourceRow: 0, + } as any; + let s = 0, t = 0; + (Object.keys(w) as (keyof ExtractedRow)[]).forEach(k => { t += w[k]; if ((r[k] as string)?.length) s += w[k]; }); + return t ? s / t : 0; } -// 유효한 행 확인 -function isValidRow(row: ExtractedRow): boolean { - // 번호나 식별번호 중 하나라도 있으면 유효 - const hasBasicData = !!(row.no || row.identificationNo); - - // 너무 짧은 데이터는 제외 (오인식 방지) - const hasReasonableLength = (row.identificationNo?.length || 0) >= 3 || - (row.no?.length || 0) >= 1; - - return hasBasicData && hasReasonableLength; -} +/* -------------------------------------------------------------------------- */ +/* OCR 품질 분석 (기존 로직 유지) */ +/* -------------------------------------------------------------------------- */ -// 행 신뢰도 계산 -function calculateRowConfidence(row: ExtractedRow): number { - let score = 0; - let maxScore = 0; - - // 각 필드별 가중치 - const weights = { - no: 1, - identificationNo: 3, // 가장 중요 - tagNo: 2, - jointNo: 2, - jointType: 1, - weldingDate: 1 - }; +export function analyzeOCRQuality (ocrResult: any) { + let conf = 0, cnt = 0, tbl = 0, kw = 0; + const keys = ['no.', 'identification', 'joint', 'tag', 'type', 'weld', 'date']; - Object.entries(weights).forEach(([field, weight]) => { - maxScore += weight; - const value = row[field as keyof ExtractedRow] as string; - - if (value && value.length > 0) { - // 기본 점수 - score += weight * 0.5; - - // 길이 보너스 - if (field === 'identificationNo' && value.length > 10) { - score += weight * 0.3; - } else if (field === 'no' && /^\d+$/.test(value)) { - score += weight * 0.3; - } else if (field === 'weldingDate' && /\d{4}[.\-\/]\d{1,2}[.\-\/]\d{1,2}/.test(value)) { - score += weight * 0.3; - } else if (value.length > 2) { - score += weight * 0.2; - } - } + ocrResult.images?.forEach((img: any) => { + tbl += img.tables?.length || 0; + img.fields?.forEach((f: any) => { + conf += f.inferConfidence || 0; cnt++; + const t = (f.inferText || '').toLowerCase(); + keys.forEach(k => { if (t.includes(k)) kw++; }); + }); }); - return maxScore > 0 ? Math.min(score / maxScore, 1) : 0; + return { confidence: cnt ? conf / cnt : 0, tablesFound: tbl, textQuality: cnt ? kw / cnt : 0, keywordCount: kw }; } - -// 유틸리티: OCR 결과 품질 분석 -export function analyzeOCRQuality(ocrResult: any): { - confidence: number; - tablesFound: number; - textQuality: number; - keywordCount: number; -} { - let totalConfidence = 0; - let totalFields = 0; - let tablesFound = 0; - let relevantKeywords = 0; - - const keywords = ['no.', 'identification', 'joint', 'tag', 'type', 'weld', 'date']; - - if (ocrResult.images) { - ocrResult.images.forEach((image: any) => { - // 테이블 분석 - if (image.tables) { - tablesFound += image.tables.length; - } - - // 필드 신뢰도 분석 - if (image.fields) { - image.fields.forEach((field: any) => { - const confidence = field.inferConfidence || 0; - const text = (field.inferText || '').toLowerCase(); - - totalConfidence += confidence; - totalFields++; - - // 관련 키워드 확인 - keywords.forEach(keyword => { - if (text.includes(keyword)) { - relevantKeywords++; - } - }); - }); - } - }); - } - - const avgConfidence = totalFields > 0 ? totalConfidence / totalFields : 0; - const textQuality = totalFields > 0 ? relevantKeywords / totalFields : 0; - - return { - confidence: avgConfidence, - tablesFound, - textQuality, - keywordCount: relevantKeywords - }; -}
\ No newline at end of file |
