roojask/Wep_pathology
0
1import os
2import re
3import uuid
4import datetime
5from pathlib import Path
6from flask import Flask, render_template, request, send_from_directory, redirect, url_for, flash
7import fitz # PyMuPDF
8import whisper
9
10# --- Config ---
11BASE_DIR = Path(__file__).parent
12UPLOAD_DIR = BASE_DIR / "uploads"
13OUTPUT_DIR = BASE_DIR / "outputs"
14ASSETS_DIR = BASE_DIR / "assets"
15TEMPLATE_DIR = BASE_DIR / "templates"
16
17PDF_TEMPLATE_PATH = ASSETS_DIR / "Breast_Gross_Template.pdf"
18
19for p in [UPLOAD_DIR, OUTPUT_DIR, ASSETS_DIR, TEMPLATE_DIR]:
20 p.mkdir(exist_ok=True)
21
22app = Flask(__name__)
23app.secret_key = "pathology-secret"
24
25print("⏳ Loading Whisper model...")
26model = whisper.load_model(os.environ.get("WHISPER_MODEL", "small"))
27print("✅ Whisper model loaded!")
28
29# --- 1. Helper Functions ---
30
31def normalize_text(text):
32 t = text.lower()
33 t = t.replace(" by ", " x ").replace(" times ", " x ")
34 t = t.replace("centimeters", "cm").replace("centimeter", "cm")
35 t = t.replace("millimeter", "mm").replace("millimeters", "mm")
36 t = t.replace("equal", "=").replace("equals", "=")
37
38 # FIX: แก้ ASR ฟัง "8" เป็น "x" ในบริบทระยะห่าง (เช่น x cm from)
39 # ใช้ Regex \b เพื่อให้แน่ใจว่าเป็นตัว x เดี่ยวๆ ไม่ใช่ส่วนหนึ่งของคำอื่น
40 t = re.sub(r"\bx\s+(?:cm|centimeters?)\s+from", "8 cm from", t)
41 # --- Specific Fixes (แก้คำผิด) ---
42 t = t.replace("mast", "mass")
43 t = t.replace("medium margin", "medial margin")
44 t = t.replace("massectomy", "mastectomy")
45 t = t.replace("slit-like", "slit like")
46 t = t.replace("the resected", "deep resected")
47
48 # FIX: แก้ ASR error "nipple is inverted" -> "nipple is everted"
49 # เพื่อให้ติ๊กช่อง "is everted" ตามที่ user ต้องการ
50 t = t.replace("nipple is inverted", "nipple is everted")
51
52 return t
53
54def format_section_code(code):
55 """Format codes: A21 -> A2-1, A2-1 to A4-1 -> A2-1-A4-1"""
56 code = re.sub(r"\b(to|and)\b", "-", code, flags=re.IGNORECASE)
57 code = code.upper().replace(" ", "")
58 parts = re.split(r"[-;,]", code)
59 formatted_parts = []
60 for p in parts:
61 if not p: continue
62 if re.match(r"^[A-Z]\d{2,}$", p):
63 p = f"{p[0]}{p[1]}-{p[2:]}"
64 formatted_parts.append(p)
65 return "-".join(formatted_parts)
66
67def extract_data_15_sections(text):
68 t = normalize_text(text)
69 data = {}
70
71 # 1. Side
72 if "right" in t: data["s1_side"] = "right"
73 elif "left" in t: data["s1_side"] = "left"
74
75 # 2. Procedure
76 if "modified radical" in t: data["s2_proc"] = "modified"
77 elif "simple mastectomy" in t: data["s2_proc"] = "simple"
78 else:
79 m = re.search(r"procedure is (.+)", t)
80 if m:
81 data["s2_proc"] = "other"
82 data["s2_other_text"] = m.group(1).strip()
83
84 # 3. Measuring
85 m = re.search(r"specimen measuring\s+([\d.]+)\s*x\s*([\d.]+)\s*x\s*([\d.]+)", t)
86 if m: data["s3_dims"] = [m.group(1), m.group(2), m.group(3)]
87
88 # 4. Axillary
89 if "axillary content" in t:
90 data["s4_check"] = True
91 m = re.search(r"axillary content.*?\s+([\d.]+)\s*x\s*([\d.]+)\s*x\s*([\d.]+)", t)
92 if m: data["s4_dims"] = [m.group(1), m.group(2), m.group(3)]
93
94 # 5. Skin Ellipse
95 m = re.search(r"skin ellipse.*?\s+([\d.]+)\s*x\s*([\d.]+)", t)
96 if m: data["s5_dims"] = [m.group(1), m.group(2)]
97
98 # 6. Appears Normal
99 if "appears normal" in t: data["s6_check"] = True
100
101 # 7. Scar
102 if "scar" in t:
103 data["s7_check"] = True
104 m = re.search(r"scar\s+([\d.]+)\s*cm", t)
105 if m: data["s7_len"] = m.group(1)
106
107 scar_idx = t.find("scar")
108 if scar_idx != -1:
109 context = t[scar_idx:scar_idx+100]
110 locs = []
111 for l in ["upper", "lower", "inner", "outer", "areola"]:
112 if l in context: locs.append(l)
113 if locs: data["s7_locs"] = locs
114
115 # 8. Ulceration
116 ulcer_match = re.search(r"ulceration", t)
117 if ulcer_match:
118 start_idx = max(0, ulcer_match.start() - 20)
119 preceding = t[start_idx:ulcer_match.start()]
120 if "nipple" not in preceding:
121 data["s8_check"] = True
122 m = re.search(r"ulceration\s+([\d.]+)\s*x\s*([\d.]+)", t)
123 if m: data["s8_dims"] = [m.group(1), m.group(2)]
124
125 context = t[ulcer_match.start():ulcer_match.end()+100]
126 locs = []
127 for l in ["upper", "lower", "inner", "outer", "areola"]:
128 if l in context: locs.append(l)
129 if locs: data["s8_locs"] = locs
130
131 # 9. Nipple (Logic updated via normalize_text)
132 if "nipple" in t:
133 if "is everted" in t or "nipple everted" in t: data["s9_val"] = "everted"
134 elif "shows inverted" in t or "nipple inverted" in t: data["s9_val"] = "inverted"
135 elif "shows ulceration" in t and "nipple" in t[t.find("shows ulceration")-20:t.find("shows ulceration")]:
136 data["s9_val"] = "ulceration"
137
138 # 10. Mass
139 mass_count = 0
140 mass_types = []
141
142 if "infiltrative" in t:
143 mass_count += 1
144 data["s10_infiltrative"] = True
145 mass_types.append("infiltrative")
146 m = re.search(r"infiltrative.*?\s+([\d.]+)\s*x\s*([\d.]+)\s*x\s*([\d.]+)", t)
147 if m: data["s10_inf_dims"] = [m.group(1), m.group(2), m.group(3)]
148
149 if "well" in t and "defined" in t:
150 mass_count += 1
151 data["s10_well"] = True
152 mass_types.append("well")
153 m = re.search(r"well.*?defined.*?\s+([\d.]+)\s*x\s*([\d.]+)\s*x\s*([\d.]+)", t)
154 if m: data["s10_well_dims"] = [m.group(1), m.group(2), m.group(3)]
155
156 if "previous surgical cavity" in t and "residual mass" not in t:
157 mass_count += 1
158 data["s10_prev1"] = True
159 mass_types.append("prev1")
160 m = re.search(r"previous surgical cavity.*?\s+([\d.]+)\s*x\s*([\d.]+)\s*x\s*([\d.]+)", t)
161 if m: data["s10_prev1_dims"] = [m.group(1), m.group(2), m.group(3)]
162
163 if "residual mass" in t:
164 mass_count += 1
165 data["s10_prev2"] = True
166 mass_types.append("prev2")
167 m1 = re.search(r"previous surgical cavity.*?\s+([\d.]+)\s*x\s*([\d.]+)\s*x\s*([\d.]+)", t)
168 if m1: data["s10_prev2_cavity_dims"] = [m1.group(1), m1.group(2), m1.group(3)]
169 m2 = re.search(r"residual mass.*?\s+([\d.]+)\s*x\s*([\d.]+)\s*x\s*([\d.]+)", t)
170 if m2: data["s10_prev2_mass_dims"] = [m2.group(1), m2.group(2), m2.group(3)]
171
172 if mass_count == 1:
173 data["s10_grammar"] = "is an" if mass_types[0] == "infiltrative" else "is a"
174 elif mass_count == 2:
175 data["s10_grammar"] = "are two"
176 elif mass_count > 2:
177 data["s10_grammar"] = "are multiple"
178
179 # 10.5 Location (FIX: Search 'located in ... quadrant' in full text)
180 if "beneath the nipple" in t: data["s10_5_nipple"] = True
181 if "beneath the scar" in t: data["s10_5_scar"] = True
182 if "central" in t and "portion" in t: data["s10_5_central"] = True
183
184 locs = []
185 tumor_loc_match = re.search(r"(?:tumor|mass|located).*?(\bin\s+(?:the\s+)?(?:upper|lower|inner|outer)[\w\s]*?quadrant)", t)
186
187 if tumor_loc_match:
188 loc_text = tumor_loc_match.group(1)
189 for q in ["upper", "lower", "inner", "outer"]:
190 if q in loc_text:
191 data["s10_5_quadrant_check"] = True
192 locs.append(q)
193
194 if locs: data["s10_5_quadrant_vals"] = locs
195
196 # 11. Margins
197 margins = ["deep", "superior", "inferior", "medial", "lateral", "skin"]
198 for m_name in margins:
199 regex = rf"([\d.]+)\s*cm\s*(?:from|at)?\s*{m_name}\s*margin"
200 m = re.search(regex, t)
201 if not m: regex = rf"{m_name}\s*margin\s*(?:is)?\s*([\d.]+)\s*cm"
202 m = re.search(regex, t)
203 if m: data[f"s11_{m_name}"] = m.group(1)
204 if m_name == "skin":
205 m_skin = re.search(r"([\d.]+)\s*cm\s*from\s*skin", t)
206 if m_skin: data["s11_skin"] = m_skin.group(1)
207
208 # 12. Ratio
209 m = re.search(r"ratio.*?\b(\d+)\s*(?::|to)\s*(\d+)", t)
210 if m:
211 data["s12_check"] = True
212 data["s12_val_left"] = m.group(1)
213 data["s12_val_right"] = m.group(2)
214
215 # 13. Remaining Tissue
216 if "unremarkable" in t:
217 data["s13_type"] = "unremarkable"
218 elif "remaining of breast tissue" in t:
219 m = re.search(r"remaining of breast tissue (?:is|shows) (.+)", t)
220 if m:
221 data["s13_type"] = "other"
222 data["s13_text"] = m.group(1).split('.')[0].split(',')[0]
223
224 # 14. Lymph Nodes
225 if "lymph node" in t:
226 data["s14_check"] = True
227 m = re.search(r"ranging from\s+([\d.]+).*?to\s+([\d.]+)", t)
228 if m:
229 data["s14_min"] = m.group(1)
230 data["s14_max"] = m.group(2)
231
232 # 15. Sections
233 section_map = {
234 "= nipple": ["nipple"],
235 "= mass": ["mass"],
236 "= old biopsy cavity with fibrosis": ["fibrosis", "biopsy cavity", "old biopsy"],
237 "= deep resected margin": ["deep resected", "deep margin", "the resected"],
238 "= nearest resected margin": ["nearest resected", "nearest margin", "inferior resected", "superior resected"],
239 "= sampling upper inner quadrant": ["upper inner", "superior inner", "superior medial"],
240 "= sampling upper outer quadrant": ["upper outer", "superior outer", "superior lateral"],
241 "= sampling lower inner quadrant": ["lower inner", "inferior inner", "inferior medial"],
242 "= sampling lower outer quadrant": ["lower outer", "inferior outer", "inferior lateral"],
243 "= sampling central region": ["central"],
244 "= axillary lymph nodes": ["axillary"]
245 }
246 data["sections"] = {}
247 for anchor, keywords in section_map.items():
248 for kw in keywords:
249 pattern = rf"((?:[a-zA-Z]\s?-?\s?\d+(?:[-\s]?\d+)*(?:\s*(?:to|and|-|,)\s*)*)+)\s*(?:=|equals?|is|-|old|sampling|submitted as|with)?\s*{kw}"
250 m = re.search(pattern, t)
251 if m:
252 raw_code = m.group(1)
253 clean_code = re.sub(r"\b(old|to|and|is|sampling|with)\b", "", raw_code, flags=re.IGNORECASE).strip()
254 formatted_code = format_section_code(clean_code)
255
256 extra_text = ""
257 if "nearest resected" in anchor or "deep resected" in anchor:
258 suffix_match = re.search(rf"{kw}\s+(with\s+[^,.]+)", t)
259 if suffix_match:
260 extra_text = suffix_match.group(1).strip()
261
262 data["sections"][anchor] = {
263 "code": formatted_code,
264 "extra": extra_text
265 }
266 break
267 return data
268
269# --- Drawing Functions ---
270
271# --- FIX: Define Colors ---
272RED = (1, 0, 0)
273BLUE = (0, 0, 1)
274
275def draw_tick(page, anchor_text, offset_x=-15, offset_y=5, search_instance=0):
276 hits = page.search_for(anchor_text)
277 if not hits:
278 hits = page.search_for(anchor_text.replace("(", "( "))
279 if not hits or len(hits) <= search_instance: return
280
281 rect = hits[search_instance]
282 start_pt = fitz.Point(rect.x0 + offset_x, rect.y1 - offset_y)
283
284 # --- FIX: Checkmark Shape & Color ---
285 # ใช้ 2 เส้นขีดให้เป็นตัว V (Tick) ชัดเจน ไม่ปิด path
286 shape = page.new_shape()
287
288 # จุดหักมุม (ก้นตัว V)
289 bottom_pt = fitz.Point(start_pt.x + 3, start_pt.y + 4)
290 # จุดปลาย (หางตัว V ชี้ขึ้น)
291 end_pt = fitz.Point(start_pt.x + 8, start_pt.y - 6)
292
293 shape.draw_line(start_pt, bottom_pt)
294 shape.draw_line(bottom_pt, end_pt)
295
296 # ใช้ finish() แบบ stroke เพื่อวาดเส้น ไม่เติมสี (ไม่เป็นสามเหลี่ยมทึบ)
297 shape.finish(color=RED, width=1.5)
298 shape.commit()
299
300def draw_circle(page, target_word, context_anchor=None):
301 search_rect = None
302 if context_anchor:
303 ctx_hits = page.search_for(context_anchor)
304 if not ctx_hits:
305 ctx_hits = page.search_for(context_anchor.replace("(", "( "))
306 if ctx_hits:
307 r = ctx_hits[0]
308 search_rect = fitz.Rect(0, r.y0 - 20, page.rect.width, r.y1 + 40)
309 hits = page.search_for(target_word, clip=search_rect)
310 if not hits: return
311 best_hit = hits[0]
312 if context_anchor and ctx_hits:
313 best_hit = min(hits, key=lambda r: abs(r.y0 - ctx_hits[0].y0))
314 rect = best_hit
315 shape = page.new_shape()
316 shape.draw_oval(fitz.Rect(rect.x0-3, rect.y0-2, rect.x1+3, rect.y1+3))
317
318 # --- Circle stays RED ---
319 shape.finish(color=RED, width=1.0)
320 shape.commit()
321
322def circle_multiline(page, loc_list, context_anchor):
323 for loc in loc_list:
324 draw_circle(page, loc, context_anchor=context_anchor)
325
326def write_text(page, anchor_text, text, offset_x=5, offset_y=-3, align_left=False):
327 hits = page.search_for(anchor_text)
328 if not hits: return
329 rect = hits[0]
330 x = rect.x1 + offset_x
331 if align_left:
332 width = len(str(text)) * 6
333 x = rect.x0 - width - offset_x
334 y = rect.y1 + offset_y
335 # --- FIX: Text Color to BLUE ---
336 page.insert_text(fitz.Point(x, y), str(text), fontsize=10, fontname="helv", color=BLUE)
337
338def write_spaced_dims(page, anchor_text, dims_list, start_offset=45, gap=40, instance=0):
339 if not dims_list: return
340 hits = page.search_for(anchor_text)
341 if not hits or len(hits) <= instance: return
342 rect = hits[instance]
343 current_x = rect.x1 + start_offset
344 y = rect.y1 - 3
345 for val in dims_list:
346 # --- FIX: Text Color to BLUE ---
347 page.insert_text(fitz.Point(current_x, y), str(val), fontsize=10, fontname="helv", color=BLUE)
348 current_x += gap
349
350def process_pdf_15_sections(template_path, output_path, data):
351 doc = fitz.open(template_path)
352 page = doc[0]
353
354 # Sections 1-9
355 if data.get("s1_side"): draw_circle(page, data["s1_side"], context_anchor="Received in formalin")
356 if data.get("s2_proc") == "modified": draw_tick(page, "modified radical mastectomy")
357 elif data.get("s2_proc") == "simple": draw_tick(page, "simple mastectomy")
358 elif data.get("s2_proc") == "other":
359 draw_tick(page, "simple mastectomy", offset_x=220)
360 if data.get("s2_other_text"): write_text(page, "simple mastectomy", data["s2_other_text"], offset_x=240)
361 if data.get("s3_dims"): write_spaced_dims(page, "Measuring", data["s3_dims"], start_offset=15, gap=40)
362 if data.get("s4_check"):
363 draw_tick(page, "with axillary content")
364 if data.get("s4_dims"): write_spaced_dims(page, "with axillary content", data["s4_dims"], start_offset=15, gap=40)
365 if data.get("s5_dims"): write_spaced_dims(page, "The skin ellipse", data["s5_dims"], start_offset=20, gap=40)
366 if data.get("s6_check"): draw_tick(page, "appears normal")
367 if data.get("s7_check"):
368 draw_tick(page, "shows an old surgical scar")
369 if data.get("s7_len"): write_text(page, "cm in length", data["s7_len"], align_left=True, offset_x=5)
370 if data.get("s7_locs"): circle_multiline(page, data["s7_locs"], context_anchor="shows an old surgical scar")
371 if data.get("s8_check"):
372 draw_tick(page, "shows an ulceration")
373 if data.get("s8_dims"): write_spaced_dims(page, "shows an ulceration", data["s8_dims"], start_offset=115, gap=45)
374 if data.get("s8_locs"): circle_multiline(page, data["s8_locs"], context_anchor="shows an ulceration")
375 if data.get("s9_val"):
376 if data["s9_val"] == "everted": draw_tick(page, "is everted", offset_x=-15)
377 elif data["s9_val"] == "inverted": draw_tick(page, "shows inverted", offset_x=-20)
378 elif data["s9_val"] == "ulceration": draw_tick(page, "shows ulceration", offset_x=-20)
379
380 # 10. Mass
381 if data.get("s10_grammar"): draw_circle(page, data["s10_grammar"], context_anchor="There (")
382
383 if data.get("s10_infiltrative"):
384 draw_tick(page, "infiltrative")
385 if data.get("s10_inf_dims"):
386 # FIX: Adjusted to 115 (Left Shift)
387 write_spaced_dims(page, "infiltrative", data["s10_inf_dims"], start_offset=115, gap=42)
388
389 if data.get("s10_well"):
390 draw_tick(page, "well")
391 if data.get("s10_well_dims"): write_spaced_dims(page, "slit like appearance", data["s10_well_dims"], start_offset=25, gap=42)
392
393 if data.get("s10_prev1"):
394 draw_tick(page, "previous surgical cavity", search_instance=0)
395 if data.get("s10_prev1_dims"): write_spaced_dims(page, "adjacent fibrous tissue", data["s10_prev1_dims"], start_offset=25, instance=0, gap=42)
396
397 if data.get("s10_prev2"):
398 draw_tick(page, "previous surgical cavity", search_instance=1)
399 if data.get("s10_prev2_cavity_dims"): write_spaced_dims(page, "adjacent fibrous tissue", data["s10_prev2_cavity_dims"], start_offset=25, instance=1, gap=42)
400 if data.get("s10_prev2_mass_dims"): write_spaced_dims(page, "white residual mass", data["s10_prev2_mass_dims"], start_offset=25, gap=42)
401
402 # 10.5 Location (Fixed Anchor)
403 if data.get("s10_5_nipple"): draw_tick(page, "beneath the nipple")
404 if data.get("s10_5_scar"): draw_tick(page, "beneath the scar")
405 if data.get("s10_5_central"): draw_tick(page, "in the central portion")
406
407 if data.get("s10_5_quadrant_check"):
408 draw_tick(page, "in (upper", offset_x=-25, offset_y=-1)
409 if data.get("s10_5_quadrant_vals"):
410 circle_multiline(page, data["s10_5_quadrant_vals"], context_anchor="in ( upper / lower")
411
412 if data.get("s10_5_other"):
413 draw_tick(page, "in (upper", offset_x=170)
414 write_text(page, "in (upper", data["s10_5_other"], offset_x=190)
415
416 margin_anchors = {
417 "s11_deep": "cm. from deep margin", "s11_superior": "cm. from superior margin",
418 "s11_inferior": "cm. from inferior margin", "s11_medial": "cm. from medial margin",
419 "s11_lateral": "cm. from lateral margin", "s11_skin": "cm. from skin"
420 }
421 for key, anchor in margin_anchors.items():
422 val = data.get(key)
423 if val: write_text(page, anchor, val, align_left=True, offset_x=10)
424
425 if data.get("s12_check"):
426 draw_tick(page, "The uninvolved breast")
427 hits = page.search_for("ratio of approximately")
428 if hits:
429 rect = hits[0]
430 colon_x = rect.x1 + 30
431 # --- FIX: Text color to BLUE ---
432 if data.get("s12_val_left"): page.insert_text(fitz.Point(colon_x - 15, rect.y1 - 3), str(data["s12_val_left"]), fontsize=10, fontname="helv", color=BLUE)
433 if data.get("s12_val_right"): page.insert_text(fitz.Point(colon_x + 10, rect.y1 - 3), str(data["s12_val_right"]), fontsize=10, fontname="helv", color=BLUE)
434
435 if data.get("s13_type") == "unremarkable": draw_tick(page, "is unremarkable")
436 elif data.get("s13_type") == "other":
437 draw_tick(page, "is unremarkable", offset_x=100)
438 if data.get("s13_text"): write_text(page, "is unremarkable", data["s13_text"], offset_x=120)
439
440 if data.get("s14_check"):
441 draw_tick(page, "There are multiple lymph nodes")
442 if data.get("s14_min"): write_text(page, "ranging from", data["s14_min"])
443 if data.get("s14_max"): write_text(page, "cm . to", data["s14_max"])
444
445 for anchor, item in data.get("sections", {}).items():
446 if isinstance(item, dict):
447 write_text(page, anchor, item["code"], align_left=True, offset_x=10)
448 if item["extra"]:
449 hits = page.search_for(anchor)
450 if hits:
451 rect = hits[0]
452 # --- FIX: Text color to BLUE ---
453 page.insert_text(fitz.Point(rect.x1 + 40, rect.y1 - 3), f", {item['extra']}", fontsize=10, fontname="helv", color=BLUE)
454 else:
455 write_text(page, anchor, item, align_left=True, offset_x=10)
456
457 current_time = datetime.datetime.now().strftime("%d/%m/%Y %H:%M")
458 write_text(page, "Date", current_time, offset_x=20)
459
460 doc.save(output_path)
461 doc.close()
462
463# --- Routes ---
464
465@app.route("/", methods=["GET", "POST"])
466def index():
467 transcription = None
468 pdf_filename = None
469
470 if request.method == "POST":
471 file = request.files.get("audio_file")
472 if not file or file.filename == "":
473 return redirect(url_for("index"))
474
475 uid = uuid.uuid4().hex
476 ext = Path(file.filename).suffix
477 audio_path = UPLOAD_DIR / f"{uid}{ext}"
478 file.save(audio_path)
479
480 result = model.transcribe(str(audio_path), language="en")
481 transcription = result["text"]
482
483 data = extract_data_15_sections(transcription)
484
485 pdf_filename = f"filled_{uid}.pdf"
486 process_pdf_15_sections(PDF_TEMPLATE_PATH, OUTPUT_DIR / pdf_filename, data)
487
488 return render_template("index.html", transcription=transcription, pdf_filename=pdf_filename)
489
490 return render_template("index.html")
491
492@app.route('/download/<filename>')
493def download_file(filename):
494 return send_from_directory(OUTPUT_DIR, filename)
495
496if __name__ == "__main__":
497 app.run(host="0.0.0.0", port=5000, debug=True)