ocr
This commit is contained in:
@@ -208,10 +208,58 @@
|
||||
placeholder="여기에 생각하신 답안을 자유롭게 적어주세요. 핵심 용어나 개념이 포함되면 더 정확한 채점이 가능합니다."
|
||||
class="textarea textarea-ghost focus:bg-base-200/30 w-full min-h-[160px] text-sm sm:text-base leading-relaxed resize-none p-2 mt-2 border-0 focus:border-0 focus:outline-none focus:ring-0"
|
||||
></textarea>
|
||||
<div class="flex justify-end mt-2 border-t border-base-200 pt-2">
|
||||
<div class="flex justify-between items-center mt-2 border-t border-base-200 pt-2">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="triggerOcrUpload"
|
||||
:disabled="showFeedback || ocrLoading"
|
||||
class="btn btn-xs btn-outline btn-secondary gap-1"
|
||||
title="종이에 적은 답안 사진을 찍거나 올려서 텍스트로 인식시킵니다"
|
||||
>
|
||||
<CameraIcon :size="12" />
|
||||
종이 답안 인식 (OCR)
|
||||
</button>
|
||||
<input
|
||||
type="file"
|
||||
ref="ocrFileInput"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
@change="onOcrFileChange"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-xs opacity-40">{{ userDescriptiveAnswer.length }}자 입력됨</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OCR 진행 상태 표시 -->
|
||||
<div v-if="ocrLoading" class="card bg-base-300 border border-secondary/20 p-4 rounded-xl space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs font-bold text-secondary flex items-center gap-1.5">
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{{ ocrStatusText }}
|
||||
</span>
|
||||
<span class="text-xs font-mono font-bold">{{ ocrProgress }}%</span>
|
||||
</div>
|
||||
<progress class="progress progress-secondary w-full" :value="ocrProgress" max="100"></progress>
|
||||
</div>
|
||||
|
||||
<!-- OCR 인식 결과 확인 및 편집 영역 -->
|
||||
<div v-if="ocrResultText !== null" class="card bg-base-300 border border-base-300 p-4 rounded-xl space-y-3">
|
||||
<div class="flex justify-between items-center border-b border-base-100 pb-2">
|
||||
<span class="text-xs font-bold text-base-content opacity-80">인식된 글자 결과 (확인 및 수정 가능)</span>
|
||||
<button @click="discardOcr" class="btn btn-xs btn-circle btn-ghost" title="닫기">✕</button>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="ocrResultText"
|
||||
class="textarea textarea-bordered w-full min-h-[100px] text-xs sm:text-sm leading-relaxed"
|
||||
placeholder="인식된 결과가 비어있습니다. 직접 수정해 주세요."
|
||||
></textarea>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button @click="appendOcrResult" class="btn btn-xs btn-neutral">기존 답안 뒤에 추가</button>
|
||||
<button @click="applyOcrResult" class="btn btn-xs btn-primary">이 내용으로 답안 덮어쓰기</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Banner for Grading -->
|
||||
<div v-if="gradingError" class="alert alert-error shadow-sm text-xs sm:text-sm">
|
||||
@@ -315,6 +363,13 @@
|
||||
</div>
|
||||
<div class="text-xs sm:text-sm md:text-base leading-relaxed text-base-content markdown-content prose prose-sm prose-invert max-w-none" v-html="formatEvaluation(descriptiveGradingResult.evaluation)">
|
||||
</div>
|
||||
<!-- 제출한 종이 답안 사진 보기 -->
|
||||
<div v-if="descriptiveGradingResult.ocrImageUrl" class="mt-4 pt-3 border-t border-secondary/20 flex flex-col gap-1.5">
|
||||
<span class="text-[10px] sm:text-xs font-bold text-slate-400">제출한 종이 답안:</span>
|
||||
<a :href="descriptiveGradingResult.ocrImageUrl" target="_blank" class="inline-block hover:opacity-90 max-w-max">
|
||||
<img :src="descriptiveGradingResult.ocrImageUrl" class="max-w-[240px] max-h-[180px] rounded-lg border border-secondary/30 object-contain bg-base-300" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -330,6 +385,12 @@
|
||||
<span class="badge badge-sm" :class="h.score >= 60 ? 'badge-success' : 'badge-warning'">{{ h.score }}점</span>
|
||||
</div>
|
||||
<div class="collapse-content text-xs space-y-2.5">
|
||||
<div v-if="h.ocrImageUrl" class="bg-base-100 p-2.5 rounded-lg border border-base-300">
|
||||
<p class="font-bold opacity-60 mb-1 text-[10px] uppercase">제출한 종이 답안</p>
|
||||
<a :href="h.ocrImageUrl" target="_blank" class="inline-block mt-1 hover:opacity-90 max-w-max">
|
||||
<img :src="h.ocrImageUrl" class="max-w-[180px] max-h-[120px] rounded-lg border border-base-200 object-contain bg-base-300" />
|
||||
</a>
|
||||
</div>
|
||||
<div class="bg-base-100 p-2.5 rounded-lg border border-base-300">
|
||||
<p class="font-bold opacity-60 mb-1 text-[10px] uppercase">내 제출 답안</p>
|
||||
<p class="whitespace-pre-wrap leading-relaxed opacity-90">{{ h.userAnswer }}</p>
|
||||
@@ -446,8 +507,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed, watch, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ArrowLeftIcon, ArrowRightIcon, CheckCircleIcon, XCircleIcon, SparklesIcon, BotIcon, TrophyIcon, FileTextIcon, ImageIcon, ExternalLinkIcon, BookmarkIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-vue-next'
|
||||
import { ArrowLeftIcon, ArrowRightIcon, CheckCircleIcon, XCircleIcon, SparklesIcon, BotIcon, TrophyIcon, FileTextIcon, ImageIcon, ExternalLinkIcon, BookmarkIcon, ChevronLeftIcon, ChevronRightIcon, CameraIcon } from 'lucide-vue-next'
|
||||
import VuePdfEmbed from 'vue-pdf-embed'
|
||||
import Tesseract from 'tesseract.js'
|
||||
import { marked } from 'marked'
|
||||
import katex from 'katex'
|
||||
import 'katex/dist/katex.min.css'
|
||||
@@ -504,6 +566,106 @@ const descriptiveGradingResult = ref<DescriptiveGradingRecord | null>(null)
|
||||
const descriptiveHistory = ref<DescriptiveGradingRecord[]>([])
|
||||
const gradingError = ref('')
|
||||
|
||||
// OCR state
|
||||
const ocrFileInput = ref<HTMLInputElement | null>(null)
|
||||
const ocrLoading = ref(false)
|
||||
const ocrProgress = ref(0)
|
||||
const ocrStatusText = ref('')
|
||||
const ocrResultText = ref<string | null>(null)
|
||||
const uploadedOcrImageBase64 = ref('')
|
||||
|
||||
function triggerOcrUpload() {
|
||||
if (ocrFileInput.value) {
|
||||
ocrFileInput.value.click()
|
||||
}
|
||||
}
|
||||
|
||||
async function onOcrFileChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
ocrLoading.value = true
|
||||
ocrProgress.value = 0
|
||||
ocrStatusText.value = '인식 엔진 준비 중...'
|
||||
ocrResultText.value = null
|
||||
uploadedOcrImageBase64.value = ''
|
||||
|
||||
// Convert file to base64 synchronously
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
uploadedOcrImageBase64.value = e.target?.result as string || ''
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
|
||||
try {
|
||||
const result = await Tesseract.recognize(
|
||||
file,
|
||||
'kor+eng',
|
||||
{
|
||||
logger: (m) => {
|
||||
if (m && m.status) {
|
||||
switch (m.status) {
|
||||
case 'loading tesseract core':
|
||||
ocrStatusText.value = '인식 엔진 불러오는 중...'
|
||||
break
|
||||
case 'initializing api':
|
||||
ocrStatusText.value = '언어 데이터(한/영) 초기화 중...'
|
||||
break
|
||||
case 'recognizing text':
|
||||
ocrStatusText.value = '글자 판독 및 텍스트 변환 중...'
|
||||
break
|
||||
default:
|
||||
ocrStatusText.value = m.status
|
||||
}
|
||||
if (typeof m.progress === 'number') {
|
||||
ocrProgress.value = Math.round(m.progress * 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ocrResultText.value = result.data.text || ''
|
||||
} catch (err: any) {
|
||||
console.error('OCR 실패:', err)
|
||||
uploadedOcrImageBase64.value = ''
|
||||
alert(`글자 인식에 실패했습니다: ${err.message || err}`)
|
||||
} finally {
|
||||
ocrLoading.value = false
|
||||
if (ocrFileInput.value) {
|
||||
ocrFileInput.value.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearOcrResult() {
|
||||
ocrResultText.value = null
|
||||
}
|
||||
|
||||
function discardOcr() {
|
||||
ocrResultText.value = null
|
||||
uploadedOcrImageBase64.value = ''
|
||||
}
|
||||
|
||||
function appendOcrResult() {
|
||||
if (ocrResultText.value) {
|
||||
if (userDescriptiveAnswer.value.trim()) {
|
||||
userDescriptiveAnswer.value += ' ' + ocrResultText.value.trim()
|
||||
} else {
|
||||
userDescriptiveAnswer.value = ocrResultText.value.trim()
|
||||
}
|
||||
}
|
||||
clearOcrResult()
|
||||
}
|
||||
|
||||
function applyOcrResult() {
|
||||
if (ocrResultText.value) {
|
||||
userDescriptiveAnswer.value = ocrResultText.value.trim()
|
||||
}
|
||||
clearOcrResult()
|
||||
}
|
||||
|
||||
// Track background task states per-question using source_id keys
|
||||
const askingGeminiMap = ref<Record<string, boolean>>({})
|
||||
const gradingDescriptiveMap = ref<Record<string, boolean>>({})
|
||||
@@ -559,7 +721,13 @@ async function submitDescriptiveGrading() {
|
||||
gradingError.value = ''
|
||||
|
||||
try {
|
||||
const record = await QuizService.GradeDescriptiveAnswer(authStore.username, q.source, q.id, userDescriptiveAnswer.value)
|
||||
const record = await QuizService.GradeDescriptiveAnswer(
|
||||
authStore.username,
|
||||
q.source,
|
||||
q.id,
|
||||
userDescriptiveAnswer.value,
|
||||
uploadedOcrImageBase64.value
|
||||
)
|
||||
if (record) {
|
||||
descriptiveGradingResult.value = record
|
||||
|
||||
@@ -567,6 +735,7 @@ async function submitDescriptiveGrading() {
|
||||
descriptiveHistory.value.unshift(record)
|
||||
|
||||
isCorrect.value = false
|
||||
uploadedOcrImageBase64.value = ''
|
||||
} else {
|
||||
isCorrect.value = true
|
||||
gradingDescriptive.value = false
|
||||
@@ -1094,6 +1263,8 @@ function loadState(index: number) {
|
||||
isCorrect.value = false
|
||||
userDescriptiveAnswer.value = ''
|
||||
descriptiveGradingResult.value = null
|
||||
uploadedOcrImageBase64.value = ''
|
||||
ocrResultText.value = null
|
||||
}
|
||||
loadGradingHistory()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user