CoolFace
Apppublic

roojask/Wep_pathology

sourceHugging Faceupdated 8mo agoView on Hugging Face
0likes
app.py497 linesDownload Raw Back to root
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)