refactor: switch to qwen vl multimodal analysis
Browse files- app.py +219 -350
- requirements.txt +2 -4
app.py
CHANGED
|
@@ -1,394 +1,256 @@
|
|
| 1 |
import json
|
| 2 |
import re
|
| 3 |
-
from typing import Any, Dict, List, Optional
|
| 4 |
|
| 5 |
import gradio as gr
|
| 6 |
-
import numpy as np
|
| 7 |
-
import paddle
|
| 8 |
import torch
|
| 9 |
from PIL import Image, ImageDraw
|
| 10 |
-
from
|
| 11 |
-
from transformers import AutoModelForCausalLM, AutoTokenizer, pipeline
|
| 12 |
-
|
| 13 |
-
# --- OCR pipeline ---------------------------------------------------------
|
| 14 |
-
# Use a high-capacity OCR model for better accuracy on prescription labels.
|
| 15 |
-
OCR_LANGS = ["korean", "en"]
|
| 16 |
-
LLM_MODEL_ID = "Qwen/Qwen2.5-1.5B-Instruct"
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
def _load_ocr():
|
| 20 |
-
use_gpu = torch.cuda.is_available()
|
| 21 |
-
device = "gpu" if use_gpu else "cpu"
|
| 22 |
-
paddle.device.set_device(device)
|
| 23 |
-
return PaddleOCR(
|
| 24 |
-
lang=OCR_LANGS[0],
|
| 25 |
-
use_textline_orientation=True,
|
| 26 |
-
text_det_limit_side_len=2048,
|
| 27 |
-
text_det_box_thresh=0.5,
|
| 28 |
-
det_model_dir=None,
|
| 29 |
-
rec_model_dir=None,
|
| 30 |
-
)
|
| 31 |
-
|
| 32 |
|
| 33 |
-
|
| 34 |
|
| 35 |
|
| 36 |
-
def
|
| 37 |
device_map = "auto" if torch.cuda.is_available() else None
|
| 38 |
dtype = torch.float16 if torch.cuda.is_available() else torch.float32
|
| 39 |
-
model =
|
| 40 |
-
|
| 41 |
device_map=device_map,
|
| 42 |
torch_dtype=dtype,
|
| 43 |
trust_remote_code=True,
|
| 44 |
)
|
| 45 |
if device_map is None:
|
| 46 |
model = model.to(torch.device("cpu"))
|
| 47 |
-
|
| 48 |
-
return model,
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
"
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
"
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
"what_it_does": "๊ธฐ๋ฆ์ง ์์์ ๋จน๊ณ ๋ฐฐ๊ฐ ๋๋ถ๋ฃฉํ ๋ ์ํ๋ฅผ ๋์ ์์ ํธํ๊ฒ ํด ์ค๋๋ค.",
|
| 93 |
-
"example": "์: ์นํจ์ ๋ง์ด ๋จน์ด ์์ด ๋๋ถ๋ฃฉํ ๋ ์์ ๊ฐ๋ณ๊ฒ ํด ์ค๋๋ค.",
|
| 94 |
-
"tip": "์ํ์ ๋ณต์ฉํ๋ฉด ํจ๊ณผ๊ฐ ์ข์ผ๋ฉฐ, ๋ณตํต์ด ๊ณ์๋๋ฉด ๋ณ์์ ๋ฐฉ๋ฌธํ์ธ์.",
|
| 95 |
-
},
|
| 96 |
-
{
|
| 97 |
-
"keywords": ["๋นํ๋ฏผ", "multivitamin", "vitamin"],
|
| 98 |
-
"category": "์์์ ",
|
| 99 |
-
"what_it_does": "๋ชธ์ ํ์ํ ๋นํ๋ฏผ์ ์ฑ์ ํผ๊ณคํจ์ ์ค์ด๊ณ ๋ฉด์ญ๋ ฅ์ ๋์ต๋๋ค.",
|
| 100 |
-
"example": "์: ์ํ ์ค๋น๋ก ์ ์ ์ค์์ ๋ ๋ชธ์ด ์ง์น์ง ์๋๋ก ๋์์ค๋๋ค.",
|
| 101 |
-
"tip": "ํ๋ฃจ ๊ถ์ฅ๋์ ์ง์ผ ๊พธ์คํ ๋ณต์ฉํ๋ฉด ๋ ํจ๊ณผ์ ์ด๋ฉฐ, ๋ฌผ๊ณผ ํจ๊ป ์ผํค์ธ์.",
|
| 102 |
-
},
|
| 103 |
-
]
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
def _extract_time_slots(text: str) -> List[str]:
|
| 107 |
-
slots = []
|
| 108 |
-
for kw in TIME_KEYWORDS:
|
| 109 |
-
if kw in text:
|
| 110 |
-
slots.append(kw)
|
| 111 |
-
# Also capture explicit times like 08:00 ํน์ 8์
|
| 112 |
-
for match in re.findall(r"(\d{1,2}[:์]\d{0,2})", text):
|
| 113 |
-
norm = match.replace("์", ":")
|
| 114 |
-
if norm.endswith(":"):
|
| 115 |
-
norm += "00"
|
| 116 |
-
if norm not in slots:
|
| 117 |
-
slots.append(norm)
|
| 118 |
-
return slots
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
STOPWORDS = {"์ฉ๋ฒ", "์ฉ๋", "๋ณต์ฉ", "๏ฟฝ๏ฟฝ๏ฟฝ๋ฒ", "์ฝ", "์ "}
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
def _extract_medications(text: str) -> List[Dict[str, Optional[str]]]:
|
| 125 |
-
meds: List[Dict[str, Optional[str]]] = []
|
| 126 |
-
pattern = re.compile(
|
| 127 |
-
r"([๊ฐ-ํฃA-Za-z]{2,})[\sยท]*(\d+[\./]?\d*\s*(?:mg|mL|ML|ml|์ |์บก์))?"
|
| 128 |
-
)
|
| 129 |
-
seen: set[str] = set()
|
| 130 |
-
for match in pattern.finditer(text):
|
| 131 |
-
name = match.group(1)
|
| 132 |
-
if name in STOPWORDS or len(name) <= 1:
|
| 133 |
-
continue
|
| 134 |
-
if any(sw in name for sw in STOPWORDS):
|
| 135 |
-
continue
|
| 136 |
-
name_norm = name.strip()
|
| 137 |
-
if name_norm in seen:
|
| 138 |
-
continue
|
| 139 |
-
seen.add(name_norm)
|
| 140 |
-
dose = match.group(2).strip() if match.group(2) else None
|
| 141 |
-
meds.append({"name": name_norm, "dose": dose})
|
| 142 |
-
return meds
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
def parse_fields(raw: str) -> Dict[str, Any]:
|
| 146 |
-
"""Extract drug name and dosage information from OCR text."""
|
| 147 |
-
collapsed = raw.replace("\n", " ")
|
| 148 |
-
collapsed = re.sub(r"\s+", " ", collapsed)
|
| 149 |
-
|
| 150 |
-
medications = _extract_medications(collapsed)
|
| 151 |
-
|
| 152 |
-
first = medications[0] if medications else {"name": None, "dose": None}
|
| 153 |
-
drug_name = first.get("name")
|
| 154 |
-
dose_per_intake = first.get("dose")
|
| 155 |
-
|
| 156 |
-
times_per_day: Optional[int] = None
|
| 157 |
-
times_match = re.search(r"(?:1์ผ|ํ๋ฃจ)\s*(\d+)\s*ํ", collapsed)
|
| 158 |
-
if times_match:
|
| 159 |
-
times_per_day = int(times_match.group(1))
|
| 160 |
-
|
| 161 |
-
time_slots = _extract_time_slots(collapsed)
|
| 162 |
|
| 163 |
return {
|
| 164 |
-
"
|
| 165 |
-
"dose_per_intake":
|
| 166 |
-
"times_per_day":
|
| 167 |
-
"time_slots": time_slots
|
| 168 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
}
|
| 170 |
|
| 171 |
|
| 172 |
-
def
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
if not cleaned:
|
| 206 |
-
continue
|
| 207 |
-
lines.append(cleaned)
|
| 208 |
-
try:
|
| 209 |
-
box_arr = np.asarray(bbox, dtype=float)
|
| 210 |
-
box_serializable = box_arr.tolist()
|
| 211 |
-
except (TypeError, ValueError):
|
| 212 |
-
box_serializable = None
|
| 213 |
-
segments.append({
|
| 214 |
-
"text": cleaned,
|
| 215 |
-
"confidence": float(confidence),
|
| 216 |
-
"bbox": box_serializable,
|
| 217 |
-
})
|
| 218 |
-
|
| 219 |
-
raw_text = "\n".join(lines)
|
| 220 |
-
fields = parse_fields(raw_text)
|
| 221 |
-
|
| 222 |
-
warnings: List[str] = []
|
| 223 |
-
if not fields["drug_name"]:
|
| 224 |
-
warnings.append("์ฝ ์ด๋ฆ ์ธ์์ด ๋ถํ์คํฉ๋๋ค.")
|
| 225 |
-
if not fields["times_per_day"]:
|
| 226 |
-
warnings.append("1์ผ ํ์๋ฅผ ์ฐพ์ง ๋ชปํ์ต๋๋ค (์: 1์ผ 3ํ).")
|
| 227 |
|
| 228 |
return {
|
| 229 |
"raw_text": raw_text,
|
| 230 |
-
"
|
| 231 |
"warnings": warnings,
|
| 232 |
-
"segments": segments,
|
| 233 |
}
|
| 234 |
|
| 235 |
|
| 236 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
width, height = 720, 400
|
| 238 |
-
|
| 239 |
-
draw = ImageDraw.Draw(
|
| 240 |
|
| 241 |
-
|
| 242 |
draw.rectangle((0, 0, width, 60), fill=(230, 240, 255))
|
| 243 |
-
draw.text((24, 18),
|
| 244 |
|
| 245 |
y = 90
|
| 246 |
|
| 247 |
def add_line(label: str, value: Optional[str]):
|
| 248 |
nonlocal y
|
|
|
|
| 249 |
draw.text((24, y), label, fill=(60, 60, 60))
|
| 250 |
-
|
| 251 |
-
draw.text((180, y), f": {display}", fill=(0, 0, 0))
|
| 252 |
y += 34
|
| 253 |
|
| 254 |
-
add_line("์ฝ ์ด๋ฆ",
|
| 255 |
-
add_line("1ํ ์ฉ๋",
|
| 256 |
-
add_line("1์ผ ํ์",
|
| 257 |
|
| 258 |
-
slots =
|
| 259 |
add_line("์๊ฐ๋", ", ".join(slots) if slots else None)
|
| 260 |
|
| 261 |
-
footer = "โป ์๋ฃ์ง ์ฒ๋ฐฉ์ด ์ฐ์ ์ด๋ฉฐ, ๋ณธ ์ฑ์
|
| 262 |
draw.text((24, height - 60), footer, fill=(120, 120, 120))
|
| 263 |
-
return
|
| 264 |
|
| 265 |
|
| 266 |
-
def
|
| 267 |
-
|
|
|
|
|
|
|
| 268 |
row = [
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
";".join(
|
| 273 |
]
|
| 274 |
return ",".join(row)
|
| 275 |
|
| 276 |
|
| 277 |
-
def
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
"-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
lines = ["### ์ฝ๊ฒ ์์๋ณด๋ ์ฝ ์ค๋ช
"]
|
| 296 |
-
for med in meds:
|
| 297 |
-
name = med.get("name") or "์ด๋ฆ ๋ฏธํ์ธ"
|
| 298 |
-
info = _match_knowledge(name) if name else None
|
| 299 |
-
dose = med.get("dose")
|
| 300 |
-
if info:
|
| 301 |
-
lines.append(
|
| 302 |
-
f"- **{name}** ({info['category']})"
|
| 303 |
-
)
|
| 304 |
-
if dose:
|
| 305 |
-
lines.append(f" - ์ฝ ๋ดํฌ์ ์ ํ ์ฉ๋: `{dose}`")
|
| 306 |
-
lines.append(f" - ํ๋ ์ผ: {info['what_it_does']}")
|
| 307 |
-
lines.append(f" - ์คํ์ ์์: {info['example']}")
|
| 308 |
-
lines.append(f" - ๋ณต์ฉ ํ: {info['tip']}")
|
| 309 |
-
else:
|
| 310 |
-
lines.append(f"- **{name}**")
|
| 311 |
-
if dose:
|
| 312 |
-
lines.append(f" - ์ฝ ๋ดํฌ ์ฉ๋: `{dose}`")
|
| 313 |
-
lines.append(
|
| 314 |
-
" - ์์ง ๋ฐ์ดํฐ๊ฐ ์์ด์. ์ฝ ์ด๋ฆ์ ๋ค์ ํ์ธํ๊ฑฐ๋ ์ฝ์ฌ์๊ฒ ๋ฌผ์ด๋ณด์ธ์."
|
| 315 |
-
)
|
| 316 |
|
| 317 |
lines.append("\n> โ ๏ธ ์ค์ ๋ณต์ฝ์ ์์ฌยท์ฝ์ฌ์ ์ง์์ ๋ฐ๋์ ๋ฐ๋ฅด์ธ์.")
|
| 318 |
return "\n".join(lines)
|
| 319 |
|
| 320 |
|
| 321 |
-
def generate_llm_explanations(output: Dict[str, Any]) -> str:
|
| 322 |
-
meds = output["fields"].get("medications") or []
|
| 323 |
-
if not meds:
|
| 324 |
-
return (
|
| 325 |
-
"์ฝ ์ด๋ฆ์ ์ ๋๋ก ์ธ์ํ์ง ๋ชปํ์ด์. ์ฌ์ง์ ๋ค์ ์ฐ๊ฑฐ๋ ์ฝ์ฌ์๊ฒ ์ง์ ํ์ธํด ์ฃผ์ธ์."
|
| 326 |
-
)
|
| 327 |
-
|
| 328 |
-
med_lines = []
|
| 329 |
-
for idx, med in enumerate(meds, 1):
|
| 330 |
-
name = med.get("name") or "์ด๋ฆ ๋ฏธํ์ธ"
|
| 331 |
-
dose = med.get("dose") or "์ฉ๋ ์ ๋ณด ์์"
|
| 332 |
-
med_lines.append(f"{idx}. {name} โ {dose}")
|
| 333 |
-
|
| 334 |
-
context = "\n".join(med_lines)
|
| 335 |
-
raw_text = output.get("raw_text", "")
|
| 336 |
-
|
| 337 |
-
system_prompt = (
|
| 338 |
-
"๋น์ ์ ์ฝ์ฌ ์ ์๋์
๋๋ค. ์ด๋ ค์ด ์ํ ์ฉ์ด๋ฅผ ์ฐ์ง ๋ง๊ณ , ์คํ์๋ ์ดํดํ ์ ์๋ ๋งํฌ๋ก ์น์ ํ๊ฒ ์ค๋ช
ํ์ธ์."
|
| 339 |
-
)
|
| 340 |
-
user_prompt = (
|
| 341 |
-
"๋ค์์ ์ฝ๋ดํฌ์์ OCR๋ก ์ถ์ถํ ์ ์ฒด ํ
์คํธ์
๋๋ค. ์ฝ ์ด๋ฆ๊ณผ ๋ณต์ฉ ์ง์๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ๊ฐ ์ฝ์ ์ ๋ณด๋ฅผ ์์ฃผ ์ฝ๊ฒ ์ ๋ฆฌํด ์ฃผ์ธ์.\n"
|
| 342 |
-
"์๊ตฌ ์ฌํญ:\n"
|
| 343 |
-
"1. ๊ฐ ์ฝ๋ง๋ค ์๋ ํญ๋ชฉ์ bullet ํ์์ผ๋ก ์์ฑํฉ๋๋ค.\n"
|
| 344 |
-
" - ์ฝ ์ด๋ฆ: (๊ฐ๋ฅํ๋ฉด ํ๊ธ/์๋ฌธ ๋ณ๊ธฐ)\n"
|
| 345 |
-
" - ์ด๋ค ์ฝ์ธ์ง ํ ์ค ์ค๋ช
\n"
|
| 346 |
-
" - ๋ณต์ฉ ์์: ์ธ์ , ์ด๋ค ์ํฉ์์ ๋ณต์ฉํ๋ฉด ์ข์์ง ์์\n"
|
| 347 |
-
" - ๋ณต์ฉ ๋ฐฉ๋ฒ ์์: 1ํ ์ฉ๋/ํ๋ฃจ ํ์๊ฐ ์๋ค๋ฉด ์ธ๊ธ\n"
|
| 348 |
-
" - ๋ถ์์ฉ ๋๋ ์ฃผ์์ฌํญ: ํํ ๋ถ์์ฉ, ํผํด์ผ ํ ํ๋\n"
|
| 349 |
-
"2. ์ด๋ ค์ด ์ํ ์ฉ์ด๋ ํผํ๊ณ , ์คํ์๋ ์ดํดํ ์ ์๋ ๋งํฌ๋ก ์์ฑํฉ๋๋ค.\n"
|
| 350 |
-
"3. ์ฝ ์ด๋ฆ์ ํ์คํ ๋ชจ๋ฅด๋ฉด โ์ด๋ฆ ๋ฏธํ์ธโ์ด๋ผ๊ณ ์ฐ๊ณ , ์ฝ์ฌ์๊ฒ ํ์ธํ๋ผ๊ณ ์๋ดํฉ๋๋ค.\n"
|
| 351 |
-
"4. ๋ง์ง๋ง ๋ฌธ๋จ์ ๋ฐ๋์ โ์ค์ ๋ณต์ฝ์ ์์ฌยท์ฝ์ฌ์ ์ง์๋ฅผ ๋ฐ๋ฅด์ธ์โ ๋ฌธ์ฅ์ ํฌํจํ์ธ์.\n"
|
| 352 |
-
f"\n์ฝ ๋ชฉ๋ก(์ถ์ถ ์์ฝ):\n{context}\n\nOCR ์๋ฌธ ์ ์ฒด:\n{raw_text}\n"
|
| 353 |
-
)
|
| 354 |
-
|
| 355 |
-
messages = [
|
| 356 |
-
{"role": "system", "content": system_prompt},
|
| 357 |
-
{"role": "user", "content": user_prompt},
|
| 358 |
-
]
|
| 359 |
-
|
| 360 |
-
input_ids = LLM_TOKENIZER.apply_chat_template(
|
| 361 |
-
messages,
|
| 362 |
-
add_generation_prompt=True,
|
| 363 |
-
return_tensors="pt",
|
| 364 |
-
)
|
| 365 |
-
input_ids = input_ids.to(LLM_MODEL.device)
|
| 366 |
-
|
| 367 |
-
with torch.no_grad():
|
| 368 |
-
output_ids = LLM_MODEL.generate(
|
| 369 |
-
input_ids,
|
| 370 |
-
max_new_tokens=480,
|
| 371 |
-
temperature=0.7,
|
| 372 |
-
top_p=0.9,
|
| 373 |
-
do_sample=True,
|
| 374 |
-
eos_token_id=LLM_TOKENIZER.eos_token_id,
|
| 375 |
-
)
|
| 376 |
-
|
| 377 |
-
generated_ids = output_ids[0][input_ids.shape[1]:]
|
| 378 |
-
text = LLM_TOKENIZER.decode(generated_ids, skip_special_tokens=True).strip()
|
| 379 |
-
return text
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
def build_explanations(output: Dict[str, Any]) -> str:
|
| 383 |
-
try:
|
| 384 |
-
llm_text = generate_llm_explanations(output)
|
| 385 |
-
if llm_text:
|
| 386 |
-
return llm_text
|
| 387 |
-
except Exception as err: # pragma: no cover - safe fallback
|
| 388 |
-
print(f"[WARN] LLM generation failed: {err}", flush=True)
|
| 389 |
-
return build_kb_explanations(output)
|
| 390 |
-
|
| 391 |
-
|
| 392 |
def format_warnings(warnings: List[str]) -> str:
|
| 393 |
if not warnings:
|
| 394 |
return "โ
์ธ์๋ ์ ๋ณด๊ฐ ์ถฉ๋ถํด์. ๋ณต์ฝ ์๊ฐ๋ง ์ ์ง์ผ ์ฃผ์ธ์."
|
|
@@ -410,13 +272,24 @@ def run_pipeline(image: Optional[Image.Image]):
|
|
| 410 |
"",
|
| 411 |
)
|
| 412 |
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 420 |
|
| 421 |
|
| 422 |
CUSTOM_CSS = """
|
|
@@ -432,7 +305,6 @@ body {background: radial-gradient(circle at top left, #f5f0ff 0%, #fff7ec 60%, #
|
|
| 432 |
.hero h1 {font-size: 2.4rem; font-weight: 700; color: #1f1c3b; margin-bottom: 12px;}
|
| 433 |
.hero p {color: #514c7b; font-size: 1.05rem; line-height: 1.6; max-width: 640px;}
|
| 434 |
.glass-panel {background: rgba(255, 255, 255, 0.72); backdrop-filter: blur(18px); border-radius: 26px; padding: 28px; box-shadow: 0 12px 32px rgba(80, 60, 160, 0.12);}
|
| 435 |
-
.panel-title {font-weight: 700; font-size: 1.2rem; margin-bottom: 18px; color: #2f2355;}
|
| 436 |
.primary-btn button {background: linear-gradient(120deg, #7c62ff, #ffa74d); border: none; color: white; font-weight: 600; border-radius: 999px; padding: 12px 22px; box-shadow: 0 12px 24px rgba(124, 98, 255, 0.25);}
|
| 437 |
.primary-btn button:hover {opacity: 0.95; transform: translateY(-1px);}
|
| 438 |
.output-card {background: rgba(255, 255, 255, 0.88); border-radius: 22px; padding: 24px; box-shadow: inset 0 0 0 1px rgba(124, 98, 255, 0.08), 0 14px 30px rgba(49, 32, 114, 0.12);}
|
|
@@ -445,8 +317,7 @@ body {background: radial-gradient(circle at top left, #f5f0ff 0%, #fff7ec 60%, #
|
|
| 445 |
HERO_HTML = """
|
| 446 |
<div class="hero">
|
| 447 |
<h1>MedCard-KR ยท ์ฝ๋ดํฌ ํ ์ปท์ผ๋ก ์ดํดํ๋ ๋ณต์ฉ ์๋ด</h1>
|
| 448 |
-
<p
|
| 449 |
-
๋ณต์ฉ ์ผ์ ์นด๋์ CSV๊น์ง ํ ๋ฒ์ ๋ฐ์ ๋ณด์ธ์.</p>
|
| 450 |
</div>
|
| 451 |
"""
|
| 452 |
|
|
@@ -462,11 +333,11 @@ with gr.Blocks(theme=gr.themes.Soft(), css=CUSTOM_CSS) as demo:
|
|
| 462 |
with gr.Column(scale=6, elem_classes=["glass-panel"]):
|
| 463 |
gr.Markdown("### 2. ๊ฒฐ๊ณผ๋ฅผ ํ์ธํ์ธ์")
|
| 464 |
explain_md = gr.Markdown("์ฌ๊ธฐ์ ์ฝ ์ค๋ช
์ด ํ์๋ฉ๋๋ค.", elem_classes=["output-card"])
|
| 465 |
-
raw_box = gr.Textbox(label="
|
| 466 |
card_out = gr.Image(type="pil", label="์ผ์ ์นด๋(๋ฏธ๋ฆฌ๋ณด๊ธฐ)")
|
| 467 |
csv_box = gr.Textbox(label="CSV(์ฝ๋ช
,1ํ์ฉ๋,1์ผํ์,์๊ฐ๋)", lines=2, elem_classes=["csv-box"])
|
| 468 |
with gr.Accordion("์ธ๋ถ JSON ๊ฒฐ๊ณผ", open=False, elem_classes=["accordion"]):
|
| 469 |
-
json_out = gr.Code(label="
|
| 470 |
|
| 471 |
btn.click(
|
| 472 |
run_pipeline,
|
|
@@ -475,9 +346,7 @@ with gr.Blocks(theme=gr.themes.Soft(), css=CUSTOM_CSS) as demo:
|
|
| 475 |
)
|
| 476 |
|
| 477 |
gr.Markdown(
|
| 478 |
-
""
|
| 479 |
-
> โน๏ธ **์ฃผ์**: ์ด ์๋น์ค๋ ์ฐธ๊ณ ์ฉ ๋๊ตฌ์ด๋ฉฐ, ์ค์ ๋ณต์ฝ์ ๋ฐ๋์ ์์ฌยท์ฝ์ฌ์ ์ง์์ ๋ฐ๋ผ ์ฃผ์ธ์.
|
| 480 |
-
"""
|
| 481 |
)
|
| 482 |
|
| 483 |
|
|
|
|
| 1 |
import json
|
| 2 |
import re
|
| 3 |
+
from typing import Any, Dict, List, Optional
|
| 4 |
|
| 5 |
import gradio as gr
|
|
|
|
|
|
|
| 6 |
import torch
|
| 7 |
from PIL import Image, ImageDraw
|
| 8 |
+
from transformers import AutoModelForVision2Seq, AutoProcessor
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
+
VL_MODEL_ID = "Qwen/Qwen2.5-VL-3B-Instruct"
|
| 11 |
|
| 12 |
|
| 13 |
+
def _load_vl_model():
|
| 14 |
device_map = "auto" if torch.cuda.is_available() else None
|
| 15 |
dtype = torch.float16 if torch.cuda.is_available() else torch.float32
|
| 16 |
+
model = AutoModelForVision2Seq.from_pretrained(
|
| 17 |
+
VL_MODEL_ID,
|
| 18 |
device_map=device_map,
|
| 19 |
torch_dtype=dtype,
|
| 20 |
trust_remote_code=True,
|
| 21 |
)
|
| 22 |
if device_map is None:
|
| 23 |
model = model.to(torch.device("cpu"))
|
| 24 |
+
processor = AutoProcessor.from_pretrained(VL_MODEL_ID, trust_remote_code=True)
|
| 25 |
+
return model, processor
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
VL_MODEL, VL_PROCESSOR = _load_vl_model()
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _extract_assistant_content(decoded: str) -> str:
|
| 32 |
+
if "<|im_start|>assistant" in decoded:
|
| 33 |
+
content = decoded.split("<|im_start|>assistant")[-1]
|
| 34 |
+
content = content.replace("<|im_end|>", "").strip()
|
| 35 |
+
return content
|
| 36 |
+
return decoded.strip()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _extract_json_block(text: str) -> Optional[str]:
|
| 40 |
+
match = re.search(r"\{.*\}", text, re.DOTALL)
|
| 41 |
+
if not match:
|
| 42 |
+
return None
|
| 43 |
+
return match.group(0)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _sanitize_medication(item: Dict[str, Any]) -> Dict[str, Any]:
|
| 47 |
+
def _as_str(value: Any) -> str:
|
| 48 |
+
if value is None:
|
| 49 |
+
return ""
|
| 50 |
+
return str(value).strip()
|
| 51 |
+
|
| 52 |
+
name = _as_str(item.get("name"))
|
| 53 |
+
dose = _as_str(item.get("dose_per_intake"))
|
| 54 |
+
|
| 55 |
+
times = item.get("times_per_day")
|
| 56 |
+
if isinstance(times, (int, float)):
|
| 57 |
+
times_str = str(int(times)) if float(times).is_integer() else str(times)
|
| 58 |
+
else:
|
| 59 |
+
times_str = _as_str(times)
|
| 60 |
+
|
| 61 |
+
time_slots_raw = item.get("time_slots")
|
| 62 |
+
if isinstance(time_slots_raw, (list, tuple)):
|
| 63 |
+
time_slots = [str(t).strip() for t in time_slots_raw if str(t).strip()]
|
| 64 |
+
elif isinstance(time_slots_raw, str):
|
| 65 |
+
slots = [s.strip() for s in re.split(r"[,;]\s*", time_slots_raw) if s.strip()]
|
| 66 |
+
time_slots = slots
|
| 67 |
+
else:
|
| 68 |
+
time_slots = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
return {
|
| 71 |
+
"name": name,
|
| 72 |
+
"dose_per_intake": dose,
|
| 73 |
+
"times_per_day": times_str,
|
| 74 |
+
"time_slots": time_slots,
|
| 75 |
+
"description": _as_str(item.get("description")),
|
| 76 |
+
"usage_example": _as_str(item.get("usage_example")),
|
| 77 |
+
"dosage_example": _as_str(item.get("dosage_example")),
|
| 78 |
+
"side_effects": _as_str(item.get("side_effects")),
|
| 79 |
+
"warnings": _as_str(item.get("warnings")),
|
| 80 |
}
|
| 81 |
|
| 82 |
|
| 83 |
+
def _parse_vl_response(text: str) -> Dict[str, Any]:
|
| 84 |
+
json_block = _extract_json_block(text)
|
| 85 |
+
if not json_block:
|
| 86 |
+
return {
|
| 87 |
+
"raw_text": "",
|
| 88 |
+
"medications": [],
|
| 89 |
+
"warnings": ["LLM ์๋ต์์ JSON์ ์ฐพ์ง ๋ชปํ์ต๋๋ค.", text.strip()],
|
| 90 |
+
}
|
| 91 |
+
try:
|
| 92 |
+
data = json.loads(json_block)
|
| 93 |
+
except json.JSONDecodeError:
|
| 94 |
+
return {
|
| 95 |
+
"raw_text": "",
|
| 96 |
+
"medications": [],
|
| 97 |
+
"warnings": ["LLM JSON ํ์ฑ ์คํจ", text.strip()],
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
raw_text = str(data.get("raw_text", "")).strip()
|
| 101 |
+
|
| 102 |
+
meds_raw = data.get("medications") or []
|
| 103 |
+
medications: List[Dict[str, Any]] = []
|
| 104 |
+
if isinstance(meds_raw, list):
|
| 105 |
+
for item in meds_raw:
|
| 106 |
+
if isinstance(item, dict):
|
| 107 |
+
medications.append(_sanitize_medication(item))
|
| 108 |
+
|
| 109 |
+
warnings_raw = data.get("warnings")
|
| 110 |
+
if isinstance(warnings_raw, list):
|
| 111 |
+
warnings = [str(w).strip() for w in warnings_raw if str(w).strip()]
|
| 112 |
+
elif warnings_raw:
|
| 113 |
+
warnings = [str(warnings_raw).strip()]
|
| 114 |
+
else:
|
| 115 |
+
warnings = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
|
| 117 |
return {
|
| 118 |
"raw_text": raw_text,
|
| 119 |
+
"medications": medications,
|
| 120 |
"warnings": warnings,
|
|
|
|
| 121 |
}
|
| 122 |
|
| 123 |
|
| 124 |
+
def analyze_image_with_qwen(image: Image.Image) -> Dict[str, Any]:
|
| 125 |
+
instructions = (
|
| 126 |
+
"์ฌ์ง ์ ์ฝ๋ดํฌ/์ฒ๋ฐฉ์ ์ ์ฝ๊ณ ์๋ JSON ํ์์ผ๋ก๋ง ๋ต๋ณํ์ธ์. "
|
| 127 |
+
"ํ
์คํธ ์ธ์ ์ค๋ช
์ด๋ ์ถ๊ฐ ๋ฌธ์ฅ์ ์ ๋ ๋ฃ์ง ๋ง์ธ์."
|
| 128 |
+
)
|
| 129 |
+
schema = (
|
| 130 |
+
"{\n"
|
| 131 |
+
" \"raw_text\": \"OCR๋ก ์ฝ์ ์ ์ฒด ๋ฌธ์ฅ\",\n"
|
| 132 |
+
" \"medications\": [\n"
|
| 133 |
+
" {\n"
|
| 134 |
+
" \"name\": \"์ฝ ์ด๋ฆ\",\n"
|
| 135 |
+
" \"dose_per_intake\": \"1ํ ์ฉ๋ (์: 1์ , 5mL)\",\n"
|
| 136 |
+
" \"times_per_day\": \"ํ๋ฃจ ๋ณต์ฉ ํ์ (๋ชจ๋ฅด๋ฉด ๋น ๋ฌธ์์ด)\",\n"
|
| 137 |
+
" \"time_slots\": [\"๋ณต์ฉ ์๊ฐ๋\"],\n"
|
| 138 |
+
" \"description\": \"์ด๋ค ์ฝ์ธ์ง ํ ์ค ์ค๋ช
\",\n"
|
| 139 |
+
" \"usage_example\": \"์ธ์ ๋ณต์ฉํ๋ฉด ์ข์์ง ์์\",\n"
|
| 140 |
+
" \"dosage_example\": \"๋ณต์ฉ ๋ฐฉ๋ฒ ์์(์: ์ํ 30๋ถ, 1ํ 1์ )\",\n"
|
| 141 |
+
" \"side_effects\": \"์ฃผ์ ๋ถ์์ฉ ๋๋ ์ฃผ์์ฌํญ\",\n"
|
| 142 |
+
" \"warnings\": \"์ถ๊ฐ ์ฃผ์ ๋ฌธ๊ตฌ\"\n"
|
| 143 |
+
" }\n"
|
| 144 |
+
" ],\n"
|
| 145 |
+
" \"warnings\": [\"์ ์ฒด์ ์ธ ๊ฒฝ๊ณ ๋ฌธ๊ตฌ\"]\n"
|
| 146 |
+
"}"
|
| 147 |
+
)
|
| 148 |
+
user_prompt = (
|
| 149 |
+
"์ JSON ์คํค๋ง๋ฅผ ๊ทธ๋๋ก ๋ฐ๋ฅด์ธ์. ๋น ๊ฐ์ ๋น ๋ฌธ์์ด๋ก ๋ก๋๋ค. "
|
| 150 |
+
"๋ชจ๋ ๊ฐ์ ํ๊ตญ์ด๋ก ์์ฑํ๊ณ , ์คํ์๋ ์ดํดํ ์ ์๋ ๋งํฌ๋ก ์ค๋ช
ํ์ธ์."
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
messages = [
|
| 154 |
+
{
|
| 155 |
+
"role": "system",
|
| 156 |
+
"content": "๋น์ ์ ์ฝ์ฌ ์ ์๋์ผ๋ก์ ์ฝ๋ดํฌ ์ด๋ฏธ์ง๋ฅผ ํด์ํ๊ณ ์น์ ํ๊ฒ ์ค๋ช
ํฉ๋๋ค.",
|
| 157 |
+
},
|
| 158 |
+
{
|
| 159 |
+
"role": "user",
|
| 160 |
+
"content": [
|
| 161 |
+
{"type": "text", "text": instructions},
|
| 162 |
+
{"type": "text", "text": schema},
|
| 163 |
+
{"type": "text", "text": user_prompt},
|
| 164 |
+
{"type": "image"},
|
| 165 |
+
],
|
| 166 |
+
},
|
| 167 |
+
]
|
| 168 |
+
|
| 169 |
+
chat_text = VL_PROCESSOR.apply_chat_template(messages, add_generation_prompt=True)
|
| 170 |
+
inputs = VL_PROCESSOR(
|
| 171 |
+
text=[chat_text],
|
| 172 |
+
images=[image],
|
| 173 |
+
return_tensors="pt",
|
| 174 |
+
).to(VL_MODEL.device)
|
| 175 |
+
|
| 176 |
+
output_ids = VL_MODEL.generate(
|
| 177 |
+
**inputs,
|
| 178 |
+
max_new_tokens=1024,
|
| 179 |
+
temperature=0.1,
|
| 180 |
+
top_p=0.9,
|
| 181 |
+
do_sample=False,
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
decoded = VL_PROCESSOR.batch_decode(output_ids, skip_special_tokens=False)[0]
|
| 185 |
+
assistant_text = _extract_assistant_content(decoded)
|
| 186 |
+
return _parse_vl_response(assistant_text)
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def render_card(primary: Dict[str, Any]) -> Image.Image:
|
| 190 |
width, height = 720, 400
|
| 191 |
+
canvas = Image.new("RGB", (width, height), "white")
|
| 192 |
+
draw = ImageDraw.Draw(canvas)
|
| 193 |
|
| 194 |
+
header = "์ค๋ ๋ณต์ฉ ์ผ์ "
|
| 195 |
draw.rectangle((0, 0, width, 60), fill=(230, 240, 255))
|
| 196 |
+
draw.text((24, 18), header, fill=(0, 0, 0))
|
| 197 |
|
| 198 |
y = 90
|
| 199 |
|
| 200 |
def add_line(label: str, value: Optional[str]):
|
| 201 |
nonlocal y
|
| 202 |
+
text_value = value if value else "-"
|
| 203 |
draw.text((24, y), label, fill=(60, 60, 60))
|
| 204 |
+
draw.text((200, y), f": {text_value}", fill=(0, 0, 0))
|
|
|
|
| 205 |
y += 34
|
| 206 |
|
| 207 |
+
add_line("์ฝ ์ด๋ฆ", primary.get("name"))
|
| 208 |
+
add_line("1ํ ์ฉ๋", primary.get("dose_per_intake"))
|
| 209 |
+
add_line("1์ผ ํ์", primary.get("times_per_day"))
|
| 210 |
|
| 211 |
+
slots = primary.get("time_slots") or []
|
| 212 |
add_line("์๊ฐ๋", ", ".join(slots) if slots else None)
|
| 213 |
|
| 214 |
+
footer = "โป ์๋ฃ์ง ์ฒ๋ฐฉ์ด ์ฐ์ ์ด๋ฉฐ, ๋ณธ ์ฑ์ ์๋ด์ฉ์
๋๋ค."
|
| 215 |
draw.text((24, height - 60), footer, fill=(120, 120, 120))
|
| 216 |
+
return canvas
|
| 217 |
|
| 218 |
|
| 219 |
+
def medications_to_csv(medications: List[Dict[str, Any]]) -> str:
|
| 220 |
+
if not medications:
|
| 221 |
+
return ""
|
| 222 |
+
first = medications[0]
|
| 223 |
row = [
|
| 224 |
+
first.get("name", ""),
|
| 225 |
+
first.get("dose_per_intake", ""),
|
| 226 |
+
first.get("times_per_day", ""),
|
| 227 |
+
";".join(first.get("time_slots") or []),
|
| 228 |
]
|
| 229 |
return ",".join(row)
|
| 230 |
|
| 231 |
|
| 232 |
+
def build_markdown(medications: List[Dict[str, Any]]) -> str:
|
| 233 |
+
if not medications:
|
| 234 |
+
return "### ์ฝ ์ค๋ช
\n- ์ฝ ์ ๋ณด๋ฅผ ์ธ์ํ์ง ๋ชปํ์ต๋๋ค. ์ฝ์ฌ์๊ฒ ์ง์ ํ์ธํด ์ฃผ์ธ์."
|
| 235 |
+
|
| 236 |
+
lines: List[str] = ["### ์ฝ๊ฒ ์์๋ณด๋ ์ฝ ์ค๋ช
"]
|
| 237 |
+
for med in medications:
|
| 238 |
+
lines.append(f"- **{med.get('name') or '์ด๋ฆ ๋ฏธํ์ธ'}**")
|
| 239 |
+
if med.get("description"):
|
| 240 |
+
lines.append(f" - ํ๋ ์ผ: {med['description']}")
|
| 241 |
+
if med.get("usage_example"):
|
| 242 |
+
lines.append(f" - ๋ณต์ฉ ์์: {med['usage_example']}")
|
| 243 |
+
if med.get("dosage_example"):
|
| 244 |
+
lines.append(f" - ๋ณต์ฉ ๋ฐฉ๋ฒ ์์: {med['dosage_example']}")
|
| 245 |
+
if med.get("side_effects"):
|
| 246 |
+
lines.append(f" - ๋ถ์์ฉ/์ฃผ์: {med['side_effects']}")
|
| 247 |
+
if med.get("warnings"):
|
| 248 |
+
lines.append(f" - ์ถ๊ฐ ์ฃผ์: {med['warnings']}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
|
| 250 |
lines.append("\n> โ ๏ธ ์ค์ ๋ณต์ฝ์ ์์ฌยท์ฝ์ฌ์ ์ง์์ ๋ฐ๋์ ๋ฐ๋ฅด์ธ์.")
|
| 251 |
return "\n".join(lines)
|
| 252 |
|
| 253 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
def format_warnings(warnings: List[str]) -> str:
|
| 255 |
if not warnings:
|
| 256 |
return "โ
์ธ์๋ ์ ๋ณด๊ฐ ์ถฉ๋ถํด์. ๋ณต์ฝ ์๊ฐ๋ง ์ ์ง์ผ ์ฃผ์ธ์."
|
|
|
|
| 272 |
"",
|
| 273 |
)
|
| 274 |
|
| 275 |
+
result = analyze_image_with_qwen(image)
|
| 276 |
+
|
| 277 |
+
medications = result.get("medications") or []
|
| 278 |
+
primary = medications[0] if medications else {
|
| 279 |
+
"name": "",
|
| 280 |
+
"dose_per_intake": "",
|
| 281 |
+
"times_per_day": "",
|
| 282 |
+
"time_slots": [],
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
card_img = render_card(primary)
|
| 286 |
+
csv_row = medications_to_csv(medications)
|
| 287 |
+
markdown = build_markdown(medications)
|
| 288 |
+
warnings_md = format_warnings(result.get("warnings", []))
|
| 289 |
+
raw_text = result.get("raw_text", "")
|
| 290 |
+
json_text = json.dumps(result, ensure_ascii=False, indent=2)
|
| 291 |
+
|
| 292 |
+
return json_text, card_img, csv_row, markdown, warnings_md, raw_text
|
| 293 |
|
| 294 |
|
| 295 |
CUSTOM_CSS = """
|
|
|
|
| 305 |
.hero h1 {font-size: 2.4rem; font-weight: 700; color: #1f1c3b; margin-bottom: 12px;}
|
| 306 |
.hero p {color: #514c7b; font-size: 1.05rem; line-height: 1.6; max-width: 640px;}
|
| 307 |
.glass-panel {background: rgba(255, 255, 255, 0.72); backdrop-filter: blur(18px); border-radius: 26px; padding: 28px; box-shadow: 0 12px 32px rgba(80, 60, 160, 0.12);}
|
|
|
|
| 308 |
.primary-btn button {background: linear-gradient(120deg, #7c62ff, #ffa74d); border: none; color: white; font-weight: 600; border-radius: 999px; padding: 12px 22px; box-shadow: 0 12px 24px rgba(124, 98, 255, 0.25);}
|
| 309 |
.primary-btn button:hover {opacity: 0.95; transform: translateY(-1px);}
|
| 310 |
.output-card {background: rgba(255, 255, 255, 0.88); border-radius: 22px; padding: 24px; box-shadow: inset 0 0 0 1px rgba(124, 98, 255, 0.08), 0 14px 30px rgba(49, 32, 114, 0.12);}
|
|
|
|
| 317 |
HERO_HTML = """
|
| 318 |
<div class="hero">
|
| 319 |
<h1>MedCard-KR ยท ์ฝ๋ดํฌ ํ ์ปท์ผ๋ก ์ดํดํ๋ ๋ณต์ฉ ์๋ด</h1>
|
| 320 |
+
<p>Qwen2.5-VL์ด ์ฌ์ง ์ ๊ธ์๋ฅผ ์ง์ ์ฝ๊ณ , ์ฝ ์ค๋ช
ยท๋ณต์ฉ ์์ยท๋ถ์์ฉ๊น์ง ํ ๋ฒ์ ์ ๋ฆฌํด ๋๋ฆฝ๋๋ค.</p>
|
|
|
|
| 321 |
</div>
|
| 322 |
"""
|
| 323 |
|
|
|
|
| 333 |
with gr.Column(scale=6, elem_classes=["glass-panel"]):
|
| 334 |
gr.Markdown("### 2. ๊ฒฐ๊ณผ๋ฅผ ํ์ธํ์ธ์")
|
| 335 |
explain_md = gr.Markdown("์ฌ๊ธฐ์ ์ฝ ์ค๋ช
์ด ํ์๋ฉ๋๋ค.", elem_classes=["output-card"])
|
| 336 |
+
raw_box = gr.Textbox(label="๋ชจ๋ธ์ด ์ฝ์ ์๋ฌธ ํ
์คํธ", lines=5, interactive=False)
|
| 337 |
card_out = gr.Image(type="pil", label="์ผ์ ์นด๋(๋ฏธ๋ฆฌ๋ณด๊ธฐ)")
|
| 338 |
csv_box = gr.Textbox(label="CSV(์ฝ๋ช
,1ํ์ฉ๋,1์ผํ์,์๊ฐ๋)", lines=2, elem_classes=["csv-box"])
|
| 339 |
with gr.Accordion("์ธ๋ถ JSON ๊ฒฐ๊ณผ", open=False, elem_classes=["accordion"]):
|
| 340 |
+
json_out = gr.Code(label="๋ชจ๋ธ ๋ถ์(JSON)")
|
| 341 |
|
| 342 |
btn.click(
|
| 343 |
run_pipeline,
|
|
|
|
| 346 |
)
|
| 347 |
|
| 348 |
gr.Markdown(
|
| 349 |
+
"> โน๏ธ **์ฃผ์**: ์ด ์๋น์ค๋ ์ฐธ๊ณ ์ฉ ๋๊ตฌ์ด๋ฉฐ, ์ค์ ๋ณต์ฝ์ ๋ฐ๋์ ์์ฌยท์ฝ์ฌ์ ์ง์์ ๋ฐ๋ผ ์ฃผ์ธ์."
|
|
|
|
|
|
|
| 350 |
)
|
| 351 |
|
| 352 |
|
requirements.txt
CHANGED
|
@@ -1,9 +1,7 @@
|
|
| 1 |
transformers
|
| 2 |
torch
|
|
|
|
|
|
|
| 3 |
gradio
|
| 4 |
Pillow
|
| 5 |
sentencepiece
|
| 6 |
-
paddleocr
|
| 7 |
-
paddlepaddle
|
| 8 |
-
opencv-python-headless
|
| 9 |
-
numpy
|
|
|
|
| 1 |
transformers
|
| 2 |
torch
|
| 3 |
+
accelerate
|
| 4 |
+
einops
|
| 5 |
gradio
|
| 6 |
Pillow
|
| 7 |
sentencepiece
|
|
|
|
|
|
|
|
|
|
|
|