MedCard / app.py
LLDDWW's picture
feat: switch to PaddleOCR for better Korean text recognition
f16cb1a
raw
history blame
8.32 kB
import json
import re
from typing import List, Optional, Tuple
import numpy as np
import gradio as gr
import spaces
from PIL import Image
from paddleocr import PaddleOCR
# PaddleOCR ์ดˆ๊ธฐํ™” (ํ•œ๊ตญ์–ด)
print("๐Ÿ”„ Loading PaddleOCR (Korean)...")
OCR_MODEL = PaddleOCR(use_angle_cls=True, lang='korean', use_gpu=True)
print("โœ… PaddleOCR loaded!")
def _extract_assistant_content(decoded: str) -> str:
"""์–ด์‹œ์Šคํ„ดํŠธ ์‘๋‹ต ์ถ”์ถœ"""
if "<|im_start|>assistant" in decoded:
content = decoded.split("<|im_start|>assistant")[-1]
content = content.replace("<|im_end|>", "").strip()
return content
return decoded.strip()
def _extract_json_block(text: str) -> Optional[str]:
"""JSON ๋ธ”๋ก ์ถ”์ถœ"""
match = re.search(r"\{.*\}", text, re.DOTALL)
if not match:
return None
return match.group(0)
def extract_text_from_image(image: Image.Image) -> str:
"""PaddleOCR๋กœ ์ด๋ฏธ์ง€์—์„œ ํ…์ŠคํŠธ ์ถ”์ถœ"""
try:
# PIL Image๋ฅผ numpy array๋กœ ๋ณ€ํ™˜
img_array = np.array(image)
# PaddleOCR ์‹คํ–‰
result = OCR_MODEL.ocr(img_array, cls=True)
# ๊ฒฐ๊ณผ์—์„œ ํ…์ŠคํŠธ๋งŒ ์ถ”์ถœ
if result and result[0]:
texts = [line[1][0] for line in result[0]]
extracted_text = "\n".join(texts)
return extracted_text.strip()
else:
return "ํ…์ŠคํŠธ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."
except Exception as e:
raise Exception(f"OCR ์˜ค๋ฅ˜: {str(e)}")
def extract_medications_from_text(text: str) -> List[str]:
"""Stage 2: Qwen2.5๋กœ ํ…์ŠคํŠธ์—์„œ ์•ฝ ์ด๋ฆ„๋งŒ ์ถ”์ถœ"""
try:
messages = [
{
"role": "system",
"content": "You are a medical text analyzer. Extract only medication names from the given text and return them as a JSON array. Return ONLY valid JSON format."
},
{
"role": "user",
"content": f"Extract all medication names from this text:\n\n{text}\n\nReturn format: {{\"medications\": [\"name1\", \"name2\"]}}"
}
]
prompt = LLM_TOKENIZER.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True
)
inputs = LLM_TOKENIZER(prompt, return_tensors="pt").to(LLM_MODEL.device)
with torch.no_grad():
outputs = LLM_MODEL.generate(
**inputs,
max_new_tokens=512,
temperature=0.3,
top_p=0.9,
do_sample=True,
pad_token_id=LLM_TOKENIZER.eos_token_id,
)
response = LLM_TOKENIZER.decode(outputs[0], skip_special_tokens=True)
# Extract assistant response (Qwen format)
if "<|im_start|>assistant" in response:
response = response.split("<|im_start|>assistant")[-1]
response = response.replace("<|im_end|>", "").strip()
# Parse JSON
json_match = re.search(r'\{.*?\}', response, re.DOTALL)
if json_match:
data = json.loads(json_match.group(0))
medications = data.get("medications", [])
if isinstance(medications, list) and medications:
return [str(m).strip() for m in medications if str(m).strip()]
return ["์•ฝ ์ด๋ฆ„์„ ์ฐพ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค."]
except Exception as e:
raise Exception(f"LLM ๋ถ„์„ ์˜ค๋ฅ˜: {str(e)}")
@spaces.GPU(duration=120)
def extract_medication_names(image: Image.Image) -> Tuple[str, List[str]]:
"""2๋‹จ๊ณ„ ํŒŒ์ดํ”„๋ผ์ธ: OCR โ†’ LLM ๋ถ„์„"""
try:
# Stage 1: OCR๋กœ ํ…์ŠคํŠธ ์ถ”์ถœ
extracted_text = extract_text_from_image(image)
if not extracted_text:
return "", ["ํ…์ŠคํŠธ๋ฅผ ์ถ”์ถœํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค."]
# Stage 2: LLM์œผ๋กœ ์•ฝ ์ด๋ฆ„ ์ถ”์ถœ
medications = extract_medications_from_text(extracted_text)
return extracted_text, medications
except Exception as e:
return "", [f"์˜ค๋ฅ˜ ๋ฐœ์ƒ: {str(e)}"]
def format_results(extracted_text: str, medications: List[str]) -> Tuple[str, str]:
"""๊ฒฐ๊ณผ๋ฅผ ํฌ๋งทํŒ…"""
# ์ถ”์ถœ๋œ ์ „์ฒด ํ…์ŠคํŠธ
text_output = f"### ๐Ÿ“„ ์ถ”์ถœ๋œ ํ…์ŠคํŠธ\n\n```\n{extracted_text}\n```"
# ์•ฝ ์ด๋ฆ„ ๋ฆฌ์ŠคํŠธ
if not medications or medications[0].startswith("์˜ค๋ฅ˜") or medications[0].startswith("์•ฝ ์ด๋ฆ„์„ ์ฐพ์ง€") or medications[0].startswith("ํ…์ŠคํŠธ๋ฅผ"):
med_output = f"### โš ๏ธ {medications[0] if medications else '์•ฝ ์ด๋ฆ„์„ ์ฐพ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.'}"
else:
med_output = f"### ๐Ÿ’Š ๊ฒ€์ถœ๋œ ์•ฝ๋ฌผ ({len(medications)}๊ฐœ)\n\n"
for idx, med_name in enumerate(medications, 1):
med_output += f"{idx}. **{med_name}**\n"
return text_output, med_output
def run_analysis(image: Optional[Image.Image], progress=gr.Progress()):
"""๋ฉ”์ธ ๋ถ„์„ ํŒŒ์ดํ”„๋ผ์ธ: OCR๋งŒ ์‹คํ–‰"""
if image is None:
return "๐Ÿ“ท ์•ฝ ๋ด‰ํˆฌ๋‚˜ ์ฒ˜๋ฐฉ์ „ ์‚ฌ์ง„์„ ์—…๋กœ๋“œํ•ด์ฃผ์„ธ์š”."
progress(0.5, desc="๐Ÿ“ธ OCR ํ…์ŠคํŠธ ์ถ”์ถœ ์ค‘...")
try:
extracted_text = extract_text_from_image(image)
progress(1.0, desc="โœ… ์™„๋ฃŒ!")
return f"### ๐Ÿ“„ OCR ์ถ”์ถœ ๊ฒฐ๊ณผ\n\n```\n{extracted_text}\n```"
except Exception as e:
return f"### โš ๏ธ ์˜ค๋ฅ˜ ๋ฐœ์ƒ\n\n{str(e)}"
# ์‹ฌํ”Œํ•œ CSS
CUSTOM_CSS = """
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
:root {
--primary: #6366f1;
--secondary: #8b5cf6;
}
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
}
.gradio-container {
max-width: 900px !important;
margin: auto;
background: rgba(255, 255, 255, 0.98);
border-radius: 24px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.3);
padding: 40px;
}
.hero {
text-align: center;
padding: 30px 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 20px;
color: white;
margin-bottom: 30px;
}
.hero h1 {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 10px;
}
.hero p {
font-size: 1.1rem;
opacity: 0.95;
}
.upload-section {
background: white;
border-radius: 16px;
padding: 30px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.07);
margin-bottom: 20px;
}
.result-section {
background: white;
border-radius: 16px;
padding: 30px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.07);
min-height: 200px;
}
.analyze-btn button {
background: linear-gradient(135deg, var(--primary), var(--secondary)) !important;
color: white !important;
font-weight: 600 !important;
font-size: 1.1rem !important;
padding: 18px 40px !important;
border-radius: 12px !important;
border: none !important;
box-shadow: 0 10px 20px -5px rgba(99, 102, 241, 0.5) !important;
transition: all 0.3s ease !important;
}
.analyze-btn button:hover {
transform: translateY(-2px) !important;
box-shadow: 0 15px 30px -5px rgba(99, 102, 241, 0.6) !important;
}
.gr-image {
border-radius: 12px !important;
}
"""
HERO_HTML = """
<div class="hero">
<h1>๐Ÿ’Š ์•ฝ ์ด๋ฆ„ ์ถ”์ถœ๊ธฐ</h1>
<p>์•ฝ๋ด‰ํˆฌ/์ฒ˜๋ฐฉ์ „ ์‚ฌ์ง„์—์„œ ์•ฝ ์ด๋ฆ„์„ ์ž๋™์œผ๋กœ ์ถ”์ถœํ•ฉ๋‹ˆ๋‹ค</p>
</div>
"""
# Gradio ์ธํ„ฐํŽ˜์ด์Šค
with gr.Blocks(theme=gr.themes.Soft(), css=CUSTOM_CSS) as demo:
gr.HTML(HERO_HTML)
with gr.Column(elem_classes=["upload-section"]):
gr.Markdown("### ๐Ÿ“ธ ์‚ฌ์ง„ ์—…๋กœ๋“œ")
image_input = gr.Image(type="pil", label="์•ฝ๋ด‰ํˆฌ ๋˜๋Š” ์ฒ˜๋ฐฉ์ „ ์‚ฌ์ง„", height=350)
analyze_button = gr.Button("๐Ÿ” OCR ํ…์ŠคํŠธ ์ถ”์ถœ", elem_classes=["analyze-btn"], size="lg")
with gr.Column(elem_classes=["result-section"]):
gr.Markdown("### ๐Ÿ“‹ OCR ์ถ”์ถœ ๊ฒฐ๊ณผ")
text_output = gr.Markdown("OCR๋กœ ์ถ”์ถœ๋œ ์ „์ฒด ํ…์ŠคํŠธ๊ฐ€ ์—ฌ๊ธฐ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค.")
analyze_button.click(
run_analysis,
inputs=image_input,
outputs=text_output,
)
gr.Markdown("""
---
**โ„น๏ธ OCR ๋ชจ๋ธ**
- PaddleOCR (Korean) - ํ•œ๊ตญ์–ด ํ…์ŠคํŠธ ์ธ์‹์— ์ตœ์ ํ™”๋œ OCR ์—”์ง„
""")
if __name__ == "__main__":
demo.queue().launch()