LLDDWW commited on
Commit
459e392
ยท
1 Parent(s): b387d82

refactor: switch to qwen vl multimodal analysis

Browse files
Files changed (2) hide show
  1. app.py +219 -350
  2. 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, Sequence
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 paddleocr import PaddleOCR
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
- ocr_reader = _load_ocr()
34
 
35
 
36
- def _load_llm():
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 = AutoModelForCausalLM.from_pretrained(
40
- LLM_MODEL_ID,
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
- tokenizer = AutoTokenizer.from_pretrained(LLM_MODEL_ID, trust_remote_code=True)
48
- return model, tokenizer
49
-
50
-
51
- LLM_MODEL, LLM_TOKENIZER = _load_llm()
52
-
53
- # Korean keywords describing time slots on prescription labels.
54
- TIME_KEYWORDS = [
55
- "์•„์นจ",
56
- "์ ์‹ฌ",
57
- "์ €๋…",
58
- "์ทจ์นจ",
59
- "์ž๊ธฐ",
60
- "์‹์ „",
61
- "์‹ํ›„",
62
- "์‹๊ฐ„",
63
- "๊ธฐ์ƒ",
64
- ]
65
-
66
- # Very small knowledge base for common Korean OTC medications.
67
- MED_KNOWLEDGE: Sequence[Dict[str, Any]] = [
68
- {
69
- "keywords": ["ํƒ€์ด๋ ˆ๋†€", "์•„์„ธํŠธ์•„๋ฏธ๋…ธํŽœ", "acetaminophen"],
70
- "category": "์ง„ํ†ตยทํ•ด์—ด์ œ",
71
- "what_it_does": "๋ชธ์‚ด์ด๋‚˜ ๊ฐ๊ธฐ๋กœ ์—ด์ด ๋‚˜๊ฑฐ๋‚˜ ๋จธ๋ฆฌ๊ฐ€ ์•„ํ”Œ ๋•Œ ํ†ต์ฆ๊ณผ ์—ด์„ ๋‚ฎ์ถฐ ์ค๋‹ˆ๋‹ค.",
72
- "example": "์˜ˆ: ์ˆ˜ํ•™์‹œํ—˜ ์ค€๋น„๋กœ ๊ธด์žฅํ–ˆ๋Š”๋ฐ ๋จธ๋ฆฌ๊ฐ€ ์ง€๋ˆ๊ฑฐ๋ฆด ๋•Œ, ํ•œ ์•Œ ๋ณต์šฉํ•˜๋ฉด ํ†ต์ฆ์ด ์ค„์–ด๋“ญ๋‹ˆ๋‹ค.",
73
- "tip": "์œ„์— ๋ถ€๋‹ด์„ ์ค„์ด๊ธฐ ์œ„ํ•ด ๊ฐ„๋‹จํ•œ ๊ฐ„์‹๊ณผ ํ•จ๊ป˜ ๋ฌผ๊ณผ ๋ณต์šฉํ•˜๊ณ , ํ•˜๋ฃจ ์ด ๋ณต์šฉ ํšŸ์ˆ˜(์ผ๋ฐ˜์ ์œผ๋กœ 4ํšŒ ์ดํ•˜)๋ฅผ ๋„˜๊ธฐ์ง€ ๋งˆ์„ธ์š”.",
74
- },
75
- {
76
- "keywords": ["์ด๋ถ€ํ”„๋กœํŽœ", "๋ถ€๋ฃจํŽœ", "ibuprofen"],
77
- "category": "์ง„ํ†ตยท์†Œ์—ผ์ œ",
78
- "what_it_does": "๋ชธ์† ์—ผ์ฆ์„ ๊ฐ€๋ผ์•‰ํžˆ๊ณ  ํ†ต์ฆ์„ ์™„ํ™”ํ•ด์„œ ๊ทผ์œกํ†ต์ด๋‚˜ ์น˜ํ†ต์— ์ž์ฃผ ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค.",
79
- "example": "์˜ˆ: ์ฒด์œก ์‹œ๊ฐ„์— ๋ฌด๋ฆŽ์„ ์‚ด์ง ์‚์—ˆ์„ ๋•Œ ๋ถ“๊ธฐ์™€ ์•„ํ””์„ ์ค„์—ฌ ์ค๋‹ˆ๋‹ค.",
80
- "tip": "์‹ํ›„์— ๋ณต์šฉํ•˜๋ฉด ์† ์“ฐ๋ฆผ์„ ์ค„์ผ ์ˆ˜ ์žˆ๊ณ , ๋‹ค๋ฅธ ์†Œ์—ผ์ง„ํ†ต์ œ์™€๋Š” ์‹œ๊ฐ„ ๊ฐ„๊ฒฉ์„ ๋‘์„ธ์š”.",
81
- },
82
- {
83
- "keywords": ["์‹œ์ž˜", "์„ธํ‹ฐ๋ฆฌ์ง„", "cetirizine", "์ง€๋ฅดํ…"],
84
- "category": "์•Œ๋ ˆ๋ฅด๊ธฐ ์™„ํ™”์ œ",
85
- "what_it_does": "์ฝ”๊ฐ€ ๊ฐ„์งˆ๊ฑฐ๋ฆฌ๊ฑฐ๋‚˜ ํ”ผ๋ถ€๊ฐ€ ๊ฐ€๋ ค์šธ ๋•Œ ์•Œ๋ ˆ๋ฅด๊ธฐ ๋ฐ˜์‘์„ ๊ฐ€๋ผ์•‰ํ˜€ ์ค๋‹ˆ๋‹ค.",
86
- "example": "์˜ˆ: ๋ด„์ฒ  ๊ฝƒ๊ฐ€๋ฃจ ๋•Œ๋ฌธ์— ๊ธฐ์นจ๊ณผ ์ฝง๋ฌผ์ด ๋‚˜์˜ฌ ๋•Œ ์ฆ์ƒ์„ ์ค„์—ฌ ์ค๋‹ˆ๋‹ค.",
87
- "tip": "์กธ๋ฆด ์ˆ˜ ์žˆ์œผ๋‹ˆ ์ฒซ ๋ณต์šฉ ํ›„์—๋Š” ์šด์ „์ด๋‚˜ ์ง‘์ค‘์ด ํ•„์š”ํ•œ ํ™œ๋™์€ ํ”ผํ•˜์„ธ์š”.",
88
- },
89
- {
90
- "keywords": ["ํ›ผ์Šคํƒˆ", "pancreatin", "์œ„์žฅ", "์†Œํ™”์ œ"],
91
- "category": "์†Œํ™”์ œ",
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
- "drug_name": drug_name,
165
- "dose_per_intake": dose_per_intake,
166
- "times_per_day": times_per_day,
167
- "time_slots": time_slots or None,
168
- "medications": medications,
 
 
 
 
169
  }
170
 
171
 
172
- def ocr_and_parse(image: Image.Image) -> Dict[str, Any]:
173
- np_img = np.array(image.convert("RGB"))
174
- ocr_results = ocr_reader.ocr(np_img)
175
-
176
- segments: List[Dict[str, Any]] = []
177
- lines: List[str] = []
178
- for result in ocr_results:
179
- if not result:
180
- continue
181
- for entry in result:
182
- if not entry:
183
- continue
184
- bbox = entry[0]
185
- text = ""
186
- confidence = 1.0
187
- if len(entry) == 2:
188
- text_info = entry[1]
189
- if isinstance(text_info, (list, tuple)) and text_info:
190
- text = text_info[0] or ""
191
- if len(text_info) > 1 and text_info[1] is not None:
192
- confidence = float(text_info[1])
193
- else:
194
- text = str(text_info)
195
- elif len(entry) >= 3:
196
- text = entry[1] or ""
197
- raw_conf = entry[2]
198
- try:
199
- if raw_conf is not None:
200
- confidence = float(raw_conf)
201
- except (TypeError, ValueError):
202
- confidence = 1.0
203
-
204
- cleaned = text.strip()
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
- "fields": fields,
231
  "warnings": warnings,
232
- "segments": segments,
233
  }
234
 
235
 
236
- def render_card(fields: Dict[str, Any]) -> Image.Image:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  width, height = 720, 400
238
- img = Image.new("RGB", (width, height), "white")
239
- draw = ImageDraw.Draw(img)
240
 
241
- header_text = "์˜ค๋Š˜ ๋ณต์šฉ ์ผ์ •"
242
  draw.rectangle((0, 0, width, 60), fill=(230, 240, 255))
243
- draw.text((24, 18), header_text, fill=(0, 0, 0))
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
- display = value if value else "-"
251
- draw.text((180, y), f": {display}", fill=(0, 0, 0))
252
  y += 34
253
 
254
- add_line("์•ฝ ์ด๋ฆ„", fields.get("drug_name"))
255
- add_line("1ํšŒ ์šฉ๋Ÿ‰", fields.get("dose_per_intake"))
256
- add_line("1์ผ ํšŸ์ˆ˜", str(fields.get("times_per_day") or ""))
257
 
258
- slots = fields.get("time_slots") or []
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 img
264
 
265
 
266
- def to_csv_row(output: Dict[str, Any]) -> str:
267
- fields = output["fields"]
 
 
268
  row = [
269
- fields.get("drug_name") or "",
270
- fields.get("dose_per_intake") or "",
271
- str(fields.get("times_per_day") or ""),
272
- ";".join(fields.get("time_slots") or []),
273
  ]
274
  return ",".join(row)
275
 
276
 
277
- def _match_knowledge(name: str) -> Optional[Dict[str, Any]]:
278
- lowered = name.lower()
279
- for info in MED_KNOWLEDGE:
280
- for kw in info["keywords"]:
281
- if kw.lower() in lowered or lowered in kw.lower():
282
- return info
283
- return None
284
-
285
-
286
- def build_kb_explanations(output: Dict[str, Any]) -> str:
287
- meds = output["fields"].get("medications") or []
288
- if not meds:
289
- return (
290
- "### ์•ฝ ์„ค๋ช…\n"
291
- "- ์•ฝ ์ด๋ฆ„์„ ์ •ํ™•ํžˆ ์ธ์‹ํ•˜์ง€ ๋ชปํ–ˆ์–ด์š”. ์‚ฌ์ง„์„ ๋‹ค์‹œ ์ฐ๊ฑฐ๋‚˜ ์•ฝ์‚ฌ์—๊ฒŒ ์ง์ ‘ ํ™•์ธํ•ด ์ฃผ์„ธ์š”.\n"
292
- "\n> โš ๏ธ ์˜๋ฃŒ์ง„ ์ฒ˜๋ฐฉ๊ณผ ๋ณต์•ฝ ์ง€์‹œ๊ฐ€ ๊ฐ€์žฅ ์šฐ์„ ์ž…๋‹ˆ๋‹ค."
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
- output = ocr_and_parse(image)
414
- card = render_card(output["fields"])
415
- csv_row = to_csv_row(output)
416
- json_text = json.dumps(output, ensure_ascii=False, indent=2)
417
- explanations = build_explanations(output)
418
- warnings_md = format_warnings(output.get("warnings", []))
419
- return json_text, card, csv_row, explanations, warnings_md, output.get("raw_text", "")
 
 
 
 
 
 
 
 
 
 
 
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>์‚ฌ์ง„ ์† ์•ฝ ์ด๋ฆ„์„ OCR๋กœ ์ฝ์–ด ๋“ค์ด๊ณ , Qwen LLM์ด ์ค‘ํ•™์ƒ๋„ ์ดํ•ดํ•  ์ˆ˜ ์žˆ๋Š” ๋งํˆฌ๋กœ ์•ฝ์„ ์„ค๋ช…ํ•ด ๋“œ๋ฆฝ๋‹ˆ๋‹ค.
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="OCR ์›๋ฌธ ํ…์ŠคํŠธ", lines=5, interactive=False)
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="์ธ์‹ ๊ฒฐ๊ณผ(JSON)")
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