MedCard / app.py
LLDDWW's picture
Use PaddleOCR predict API and normalize inputs
a97e706
import json
import re
from typing import List, Optional, Tuple, Union
import numpy as np
import os
import gradio as gr
import spaces
import torch
from PIL import Image
from transformers import AutoTokenizer, AutoModelForCausalLM
from huggingface_hub import login, snapshot_download
from paddleocr import PaddleOCR
# Hugging Face ํ† ํฐ์œผ๋กœ ๋กœ๊ทธ์ธ (Spaces Secret์—์„œ ๊ฐ€์ ธ์˜ด)
HF_TOKEN = os.getenv("HF_TOKEN")
if HF_TOKEN:
login(token=HF_TOKEN.strip())
# ์•ฝ ์ •๋ณด ๋ถ„์„ ๋ชจ๋ธ ID (๋น ๋ฅธ ์ถ”๋ก ์„ ์œ„ํ•ด ๊ฒฝ๋Ÿ‰ ๋ชจ๋ธ ์‚ฌ์šฉ)
MED_MODEL_ID = "google/gemma-2-2b-it"
# ์ „์—ญ ๋ชจ๋ธ ๋ณ€์ˆ˜ (ํ•œ ๋ฒˆ๋งŒ ๋กœ๋“œ)
OCR_READER = None
MED_MODEL = None
MED_TOKENIZER = None
OCR_MODEL_REPO_ID = "PaddlePaddle/korean_PP-OCRv5_mobile_rec"
def _collect_ocr_texts(ocr_payload) -> List[str]:
"""PaddleOCR ๊ฒฐ๊ณผ ๊ตฌ์กฐ์—์„œ ํ…์ŠคํŠธ๋งŒ ์ถ”์ถœ"""
texts: List[str] = []
seen = set()
def add_text(candidate: str):
if not isinstance(candidate, str):
return
normalized = candidate.strip()
if normalized and normalized not in seen:
seen.add(normalized)
texts.append(normalized)
def walk(node):
if isinstance(node, str):
add_text(node)
return
if isinstance(node, dict):
for key in ("text", "label", "transcription"):
add_text(node.get(key))
for key in ("texts", "labels"):
values = node.get(key)
if isinstance(values, (list, tuple)):
for value in values:
add_text(value)
for key in ("text_recognition", "rec_results", "data", "results"):
if key in node:
walk(node[key])
return
if isinstance(node, (list, tuple)):
if len(node) >= 2:
second = node[1]
if isinstance(second, str):
add_text(second)
elif isinstance(second, (list, tuple)) and second:
maybe_text = second[0]
add_text(maybe_text)
for item in node:
walk(item)
walk(ocr_payload)
return texts
def load_models():
"""๋ชจ๋ธ๋“ค์„ ํ•œ ๋ฒˆ๋งŒ ๋กœ๋“œ"""
global OCR_READER, MED_MODEL, MED_TOKENIZER
if OCR_READER is None:
print("๐Ÿ”„ Loading PaddleOCR (Korean PP-OCRv5 mobile recognition)...")
rec_model_dir = snapshot_download(
OCR_MODEL_REPO_ID,
allow_patterns=[
"*.pdmodel",
"*.pdiparams",
"*.pdparams",
"*.json",
"*.yml",
],
)
OCR_READER = PaddleOCR(
lang='korean',
use_textline_orientation=True,
text_recognition_model_dir=rec_model_dir,
text_recognition_model_name="korean_PP-OCRv5_mobile_rec",
)
print("โœ… PaddleOCR loaded!")
if MED_MODEL is None:
print("๐Ÿ”„ Loading Gemma-2-2B for medical analysis (8bit quantization)...")
MED_MODEL = AutoModelForCausalLM.from_pretrained(
MED_MODEL_ID,
torch_dtype=torch.bfloat16,
device_map="auto",
load_in_8bit=True
)
MED_TOKENIZER = AutoTokenizer.from_pretrained(MED_MODEL_ID)
print("โœ… Medical model loaded!")
# ์•ฑ ์‹œ์ž‘ ์‹œ ๋ชจ๋ธ ๋กœ๋“œ
load_models()
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)
@spaces.GPU(duration=120)
def analyze_medication_image(image: Image.Image) -> Tuple[str, str]:
"""์ด๋ฏธ์ง€์—์„œ OCR ์ถ”์ถœ ํ›„ ์•ฝ ์ •๋ณด ๋ถ„์„"""
import time
try:
# Step 1: OCR - PaddleOCR๋กœ ํ•œ๊ธ€ ํ…์ŠคํŠธ ์ถ”์ถœ
start_time = time.time()
img_array = np.array(image)
try:
ocr_results = OCR_READER.predict(img_array)
except (TypeError, AttributeError):
ocr_results = OCR_READER.ocr(img_array)
ocr_time = time.time() - start_time
print(f"โฑ๏ธ OCR took {ocr_time:.2f}s")
if not ocr_results:
return "ํ…์ŠคํŠธ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", ""
# ํ…์ŠคํŠธ ์ถ”์ถœ
texts = _collect_ocr_texts(ocr_results)
if not texts:
return "ํ…์ŠคํŠธ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", ""
ocr_text = "\n".join(texts)
# Step 2: ์•ฝ ์ •๋ณด ๋ถ„์„ - MedGemma๋กœ ์˜๋ฃŒ ์ •๋ณด ์ œ๊ณต
analysis_start = time.time()
analysis_prompt = f"""๋‹ค์Œ์€ ์•ฝ ๋ด‰ํˆฌ๋‚˜ ์ฒ˜๋ฐฉ์ „์—์„œ ์ถ”์ถœํ•œ ํ…์ŠคํŠธ์ž…๋‹ˆ๋‹ค:
{ocr_text}
์œ„ ํ…์ŠคํŠธ์—์„œ ์•ฝ ์ด๋ฆ„์„ ์ฐพ์•„์„œ, ๊ฐ ์•ฝ์— ๋Œ€ํ•ด **๋…ธ์ธ๊ณผ ์–ด๋ฆฐ์ด ๋ชจ๋‘ ์‰ฝ๊ฒŒ ์ดํ•ดํ•  ์ˆ˜ ์žˆ๋„๋ก** ์žฌ๋ฏธ์žˆ๊ณ  ์นœ๊ทผํ•˜๊ฒŒ ์„ค๋ช…ํ•ด์ฃผ์„ธ์š”:
๐Ÿ“‹ **๊ฐ ์•ฝ๋งˆ๋‹ค ๋‹ค์Œ ์ •๋ณด๋ฅผ ํฌํ•จํ•ด์ฃผ์„ธ์š”:**
1. ๐Ÿ’Š **์•ฝ ์ด๋ฆ„**: ์ •ํ™•ํ•œ ์•ฝ ์ด๋ฆ„
2. ๐ŸŽฏ **ํšจ๋Šฅ**: ์ด ์•ฝ์ด ๋ฌด์—‡์„ ์น˜๋ฃŒํ•˜๊ณ  ์–ด๋–ป๊ฒŒ ๋„์›€์ด ๋˜๋Š”์ง€
3. โš ๏ธ **๋ถ€์ž‘์šฉ**: ์ฃผ์˜ํ•ด์•ผ ํ•  ๋ถ€์ž‘์šฉ๋“ค
4. ๐Ÿ’ก **๋ณต์šฉ ๋ฐฉ๋ฒ•**: ์–ธ์ œ, ์–ด๋–ป๊ฒŒ ๋จน์–ด์•ผ ํ•˜๋Š”์ง€ (์‹์ „/์‹ํ›„, ํ•˜๋ฃจ ๋ช‡ ๋ฒˆ ๋“ฑ)
5. ๐Ÿšซ **์ฃผ์˜์‚ฌํ•ญ**: ์ด ์•ฝ๊ณผ ํ•จ๊ป˜ ๋จน์œผ๋ฉด ์•ˆ ๋˜๋Š” ๊ฒƒ๋“ค (์Œ์‹, ๋‹ค๋ฅธ ์•ฝ ๋“ฑ)
**์Šคํƒ€์ผ ๊ฐ€์ด๋“œ:**
- ์ด๋ชจ์ง€๋ฅผ ์ ๊ทน ํ™œ์šฉํ•˜์—ฌ ์žฌ๋ฏธ์žˆ๊ฒŒ ์ž‘์„ฑ
- ํ• ๋จธ๋‹ˆ ํ• ์•„๋ฒ„์ง€๋‚˜ ์ดˆ๋“ฑํ•™์ƒ๋„ ์ดํ•ดํ•  ์ˆ˜ ์žˆ๋Š” ์‰ฌ์šด ๋‹จ์–ด ์‚ฌ์šฉ
- ๊ฐ ์•ฝ๋งˆ๋‹ค ๊ตฌ๋ถ„์„ ์œผ๋กœ ๊ตฌ๋ถ„
- ์นœ๊ทผํ•˜๊ณ  ๋”ฐ๋œปํ•œ ๋งํˆฌ ์‚ฌ์šฉ
- ๋งˆํฌ๋‹ค์šด ํ˜•์‹์œผ๋กœ ์ž‘์„ฑ
์‹œ์ž‘ํ•ด์ฃผ์„ธ์š”!"""
messages = [
{"role": "user", "content": analysis_prompt}
]
input_text = MED_TOKENIZER.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
inputs = MED_TOKENIZER(input_text, return_tensors="pt").to(MED_MODEL.device)
with torch.no_grad():
outputs = MED_MODEL.generate(
**inputs,
max_new_tokens=768,
temperature=0.7,
top_p=0.9,
do_sample=True
)
analysis_text = MED_TOKENIZER.decode(outputs[0][inputs.input_ids.shape[1]:], skip_special_tokens=True)
analysis_time = time.time() - analysis_start
total_time = time.time() - start_time
print(f"โฑ๏ธ Medical analysis took {analysis_time:.2f}s")
print(f"โฑ๏ธ Total processing time: {total_time:.2f}s")
return ocr_text.strip(), analysis_text.strip()
except Exception as e:
raise Exception(f"๋ถ„์„ ์˜ค๋ฅ˜: {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 _ensure_pil(image_input: Optional[Union[Image.Image, np.ndarray, str]]) -> Optional[Image.Image]:
"""Gradio ์ž…๋ ฅ์„ PIL ์ด๋ฏธ์ง€๋กœ ๋ณ€ํ™˜"""
if image_input is None:
return None
if isinstance(image_input, Image.Image):
return image_input
if isinstance(image_input, np.ndarray):
if image_input.dtype != np.uint8:
image_input = np.clip(image_input, 0, 255).astype(np.uint8)
return Image.fromarray(image_input).convert("RGB")
if isinstance(image_input, str):
if not os.path.exists(image_input):
return None
with Image.open(image_input) as img:
return img.convert("RGB")
return None
def run_analysis(image: Optional[Union[Image.Image, np.ndarray, str]], progress=gr.Progress()):
"""๋ฉ”์ธ ๋ถ„์„ ํŒŒ์ดํ”„๋ผ์ธ: OCR + ์•ฝ ์ •๋ณด ๋ถ„์„"""
pil_image = _ensure_pil(image)
if pil_image is None:
return "๐Ÿ“ท ์•ฝ ๋ด‰ํˆฌ๋‚˜ ์ฒ˜๋ฐฉ์ „ ์‚ฌ์ง„์„ ์—…๋กœ๋“œํ•ด์ฃผ์„ธ์š”.", ""
progress(0.3, desc="๐Ÿ“ธ 1๋‹จ๊ณ„: OCR ํ…์ŠคํŠธ ์ถ”์ถœ ์ค‘...")
progress(0.6, desc="๐Ÿค– 2๋‹จ๊ณ„: ์•ฝ ์ •๋ณด ๋ถ„์„ ์ค‘...")
try:
ocr_text, analysis = analyze_medication_image(pil_image)
progress(1.0, desc="โœ… ์™„๋ฃŒ!")
ocr_output = f"### ๐Ÿ“„ ์ถ”์ถœ๋œ ํ…์ŠคํŠธ\n\n```\n{ocr_text}\n```"
analysis_output = f"### ๐Ÿ’Š ์•ฝ ์ •๋ณด ์„ค๋ช…\n\n{analysis}"
return ocr_output, analysis_output
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="numpy", image_mode="RGB", label="์•ฝ๋ด‰ํˆฌ ๋˜๋Š” ์ฒ˜๋ฐฉ์ „ ์‚ฌ์ง„", height=350)
analyze_button = gr.Button("๐Ÿ” ์•ฝ ์ •๋ณด ๋ถ„์„ํ•˜๊ธฐ", elem_classes=["analyze-btn"], size="lg")
with gr.Row():
with gr.Column(elem_classes=["result-section"]):
gr.Markdown("### ๐Ÿ“‹ 1๋‹จ๊ณ„: ์ถ”์ถœ๋œ ํ…์ŠคํŠธ")
ocr_output = gr.Markdown("OCR๋กœ ์ถ”์ถœ๋œ ํ…์ŠคํŠธ๊ฐ€ ์—ฌ๊ธฐ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค.")
with gr.Column(elem_classes=["result-section"]):
gr.Markdown("### ๐Ÿ“‹ 2๋‹จ๊ณ„: ์‰ฌ์šด ์•ฝ ์„ค๋ช…")
analysis_output = gr.Markdown("๋…ธ์ธ๊ณผ ์–ด๋ฆฐ์ด๋„ ์ดํ•ดํ•˜๊ธฐ ์‰ฌ์šด ์•ฝ ์ •๋ณด๊ฐ€ ์—ฌ๊ธฐ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค.")
analyze_button.click(
run_analysis,
inputs=image_input,
outputs=[ocr_output, analysis_output],
)
gr.Markdown("""
---
**โ„น๏ธ ์‚ฌ์šฉ ๋ฐฉ๋ฒ•**
1. ์•ฝ ๋ด‰ํˆฌ๋‚˜ ์ฒ˜๋ฐฉ์ „ ์‚ฌ์ง„์„ ์—…๋กœ๋“œํ•˜์„ธ์š”
2. '์•ฝ ์ •๋ณด ๋ถ„์„ํ•˜๊ธฐ' ๋ฒ„ํŠผ์„ ํด๋ฆญํ•˜์„ธ์š”
3. ์™ผ์ชฝ์—๋Š” ์ถ”์ถœ๋œ ํ…์ŠคํŠธ, ์˜ค๋ฅธ์ชฝ์—๋Š” ์‰ฌ์šด ์„ค๋ช…์ด ๋‚˜ํƒ€๋‚ฉ๋‹ˆ๋‹ค!
**โš ๏ธ ์ฃผ์˜์‚ฌํ•ญ**
- ์ด ์•ฑ์€ ์ฐธ๊ณ ์šฉ์ด๋ฉฐ, ์‹ค์ œ ๋ณต์•ฝ์€ ๋ฐ˜๋“œ์‹œ ์˜์‚ฌ๋‚˜ ์•ฝ์‚ฌ์˜ ์ง€์‹œ๋ฅผ ๋”ฐ๋ฅด์„ธ์š”
- AI๊ฐ€ ์ƒ์„ฑํ•œ ์ •๋ณด์ด๋ฏ€๋กœ ์ •ํ™•ํ•˜์ง€ ์•Š์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค
**๐Ÿค– ๊ธฐ์ˆ  ์Šคํƒ**
- PaddleOCR PP-OCRv5 (ํ•œ๊ตญ์–ด ์ตœ์ ํ™” OCR)
- Google Gemma-2-2B-IT (8bit ์–‘์žํ™”, ๋น ๋ฅธ ์˜๋ฃŒ ์ •๋ณด ๋ถ„์„)
**๐Ÿ”‘ ์„ค์ • ๋ฐฉ๋ฒ•**
- Hugging Face Spaces์˜ Settings โ†’ Repository secrets์—์„œ `HF_TOKEN` ์ถ”๊ฐ€ ํ•„์š”
""")
if __name__ == "__main__":
demo.queue().launch()