| from __future__ import annotations |
|
|
| import json |
| import traceback |
| from pathlib import Path |
| from typing import Any |
|
|
| import gradio as gr |
| from dotenv import load_dotenv |
| from PIL import Image |
|
|
| load_dotenv(Path(__file__).resolve().parent / ".env") |
|
|
| |
| |
| |
| _store: dict[str, Any] = {} |
|
|
| |
| |
| |
| |
| |
| |
| print("[app] Initializing AI modelsβ¦") |
| _startup_status: str |
| try: |
| from startup import initialize_all_models as _init_models |
| _startup_status = _init_models(_store) |
| print("[app] Model initialization complete.") |
| except Exception as _startup_err: |
| _startup_status = ( |
| f"<span style='color:#C62828;font-weight:600'>" |
| f"β Startup error: {_startup_err}</span>" |
| ) |
| print(f"[app] Startup error: {_startup_err}") |
|
|
| |
| |
| |
| from src.config.endpoints import ( |
| DEFAULT_MAX_FINDINGS, |
| MEDGEMMA_MODEL_ID, |
| MEDSAM_CHECKPOINT, |
| MEDSAM_DEVICE, |
| ) |
| from src.visualization import draw_segmentation_overlay, draw_view_localizations |
|
|
| |
| |
| |
|
|
| _STATUS_ICON: dict[str, str] = { |
| "localized": "β
", |
| "abstained": "β¬", |
| "rejected_by_quality_gate": "β οΈ", |
| "parser_error": "β", |
| } |
|
|
| _CERTAINTY_COLOR: dict[str, str] = { |
| "positive": "π΄", |
| "probable": "π ", |
| "questionable": "π‘", |
| } |
|
|
|
|
| def _findings_to_markdown(detection: Any) -> str: |
| if not detection.findings: |
| return "_No positive radiographic findings detected._" |
|
|
| lines: list[str] = [] |
| for i, f in enumerate(detection.findings, 1): |
| certainty_icon = _CERTAINTY_COLOR.get(f.certainty, "βͺ") |
| lines.append(f"### [{i}] {f.finding} {certainty_icon}") |
| lines.append(f"- **Location:** {f.anatomical_location}") |
| lines.append(f"- **Certainty:** {f.certainty}") |
| lines.append("") |
| lines.append("| View | Status | BBox |") |
| lines.append("|------|--------|------|") |
| for v in f.localizations: |
| icon = _STATUS_ICON.get(v.status, "β") |
| if v.boxes: |
| bbox_str = " Β· ".join(str(b.box_2d) for b in v.boxes) |
| elif v.candidate_boxes: |
| bbox_str = f"~~{v.candidate_boxes[0].box_2d}~~ (rejected)" |
| else: |
| bbox_str = "β" |
| lines.append(f"| View {v.image_index} | {icon} `{v.status}` | {bbox_str} |") |
| lines.append("") |
|
|
| return "\n".join(lines) |
|
|
|
|
| def _structured_report_to_md(r: Any) -> str: |
| """Render a StructuredReport as doctor-friendly markdown.""" |
| parts: list[str] = [] |
|
|
| if r.study_type: |
| parts.append(f"## {r.study_type}\n") |
|
|
| if r.summary: |
| parts.append(f"**Summary**\n\n{r.summary}\n") |
|
|
| if r.main_findings: |
| items = "\n".join(f"- {f}" for f in r.main_findings) |
| parts.append(f"**Main Findings**\n\n{items}\n") |
|
|
| if r.detail_findings: |
| items = "\n".join(f"- {f}" for f in r.detail_findings) |
| parts.append(f"**Detailed Findings**\n\n{items}\n") |
|
|
| if r.impression: |
| parts.append(f"**Impression**\n\n{r.impression}\n") |
|
|
| if r.recommendations: |
| parts.append(f"**Recommendations**\n\n{r.recommendations}\n") |
|
|
| ai = r.additional_informations or "" |
| if ai.strip().lower() not in ("none", "n/a", "-", ""): |
| parts.append(f"**Additional Information**\n\n{ai}\n") |
|
|
| return "\n---\n\n".join(parts) if parts else "_No report generated._" |
|
|
|
|
| |
| |
| |
|
|
|
|
| def run_analysis( |
| img1: Image.Image | None, |
| img2: Image.Image | None, |
| medsam_ckpt: str, |
| medsam_device: str, |
| progress: gr.Progress = gr.Progress(track_tqdm=True), |
| ) -> tuple[list, str, str, list, str]: |
| """Return (annotated_gallery, findings_md, findings_json, seg_gallery, report_text).""" |
|
|
| if "medgemma" not in _store: |
| msg = "β οΈ AI system not initialized. Click **Initialize AI System** first." |
| return [], msg, "{}", [], "" |
|
|
| images = [img for img in [img1, img2] if img is not None] |
| if not images: |
| return [], "β οΈ Please upload at least one chest X-ray image.", "{}", [], "" |
|
|
| try: |
| from src.clients.medgemma_client import MedGemmaDetector, MedGemmaReporter |
| from src.pipeline import CXRPipeline |
|
|
| medgemma = _store["medgemma"] |
|
|
| |
| segmenter = _store.get("medsam") |
| if segmenter is None and "medsam" not in _store: |
| |
| try: |
| progress(0.05, desc="Loading segmentation modelβ¦") |
| from src.clients.medsam_client import MedSAMClient |
| ckpt = _store.get("medsam_ckpt_path") or medsam_ckpt |
| _store["medsam"] = MedSAMClient(checkpoint_path=ckpt, device=medsam_device) |
| segmenter = _store["medsam"] |
| except Exception: |
| segmenter = None |
|
|
| detector = MedGemmaDetector(client=medgemma, max_findings=DEFAULT_MAX_FINDINGS) |
| reporter = MedGemmaReporter(client=medgemma) |
| pipeline = CXRPipeline(detector=detector, segmenter=segmenter, reporter=reporter) |
|
|
| input_images = [f"view_{i}.png" for i in range(len(images))] |
|
|
| |
| progress(0.1, desc="Stage 1 β Finding discoveryβ¦") |
| detection, processed_images = detector.detect(images, input_images) |
|
|
| |
| progress(0.4, desc="Stage 1 β Rendering annotationsβ¦") |
| annotated: list[Image.Image] = [ |
| draw_view_localizations(img, i, detection.findings) |
| for i, img in enumerate(processed_images) |
| ] |
|
|
| |
| findings_md = _findings_to_markdown(detection) |
| findings_json = json.dumps(detection.model_dump(), indent=2) |
|
|
| |
| masks: list = [] |
| seg_images: list[Image.Image] = [] |
| if segmenter is not None: |
| progress(0.55, desc="Stage 2 β Segmentationβ¦") |
| masks = pipeline._run_segmentation(detection, list(processed_images)) |
| if masks: |
| progress(0.75, desc="Stage 2 β Rendering segmentation masksβ¦") |
| seg_images = draw_segmentation_overlay(list(processed_images), masks) |
| if not seg_images: |
| seg_images = list(annotated) |
|
|
| |
| report_md = "" |
| report_text = "" |
| structured_report = None |
| if reporter is not None: |
| from src.schemas.report import ReportRequest |
| progress(0.85, desc="Stage 3 β Generating radiology reportβ¦") |
| report_request = ReportRequest( |
| case_id=None, |
| input_images=input_images, |
| images=list(processed_images), |
| findings=detection.findings, |
| masks=masks, |
| ) |
| report_result = reporter.generate_report(report_request) |
| if report_result: |
| if report_result.status == "success": |
| report_text = report_result.report_text |
| structured_report = report_result.structured |
| report_md = _structured_report_to_md(structured_report) if structured_report else report_text |
| else: |
| report_md = f"β οΈ Report generation failed:\n\n{report_result.error}" |
|
|
| |
| _store["last_originals"] = list(processed_images) |
| _store["last_annotated"] = annotated |
| _store["last_seg_images"] = seg_images if masks else None |
| _store["last_detection"] = detection |
| _store["last_masks"] = masks or None |
| _store["last_report"] = report_text or None |
| _store["last_report_structured"] = structured_report |
|
|
| progress(1.0, desc="Done.") |
| return annotated, findings_md, findings_json, seg_images, report_md |
|
|
| except Exception as exc: |
| err = f"β Pipeline error:\n{exc}\n\n{traceback.format_exc()}" |
| return [], err, "{}", [], "" |
|
|
|
|
| def export_pdf(): |
| """Generate a PDF and return a gr.update that sets value + visibility together.""" |
| if "last_detection" not in _store: |
| gr.Warning("Please run an analysis before exporting.") |
| return gr.update(value=None, visible=False) |
|
|
| try: |
| from src.export import export_to_tempfile |
| path = export_to_tempfile( |
| original_images=_store["last_originals"], |
| annotated_images=_store["last_annotated"], |
| seg_images=_store.get("last_seg_images"), |
| detection=_store["last_detection"], |
| masks=_store.get("last_masks"), |
| report_text=_store.get("last_report"), |
| structured_report=_store.get("last_report_structured"), |
| ) |
| return gr.update(value=path, visible=True) |
| except Exception as exc: |
| gr.Warning(f"PDF export failed: {exc}") |
| return gr.update(value=None, visible=False) |
|
|
|
|
| |
| |
| |
|
|
| _KALBE_DARK = "#1A5C38" |
| _KALBE_MID = "#2E7D32" |
| _KALBE_LIME = "#6DB33F" |
|
|
| _CSS = """ |
| .report-box { font-family: 'Georgia', serif; line-height: 1.75; padding: 4px 8px; } |
| .report-box h2 { color: #1A5C38; margin-bottom: 4px; } |
| .report-box strong { color: #2E7D32; } |
| .report-box hr { border-color: #C8E6C9; margin: 12px 0; } |
| .init-status { padding: 4px 0 2px; } |
| footer { display: none !important; } |
| |
| /* ββ Kalbe header banner ββ */ |
| .kalbe-header { |
| background: linear-gradient(135deg, #1A5C38 0%, #2E7D32 65%, #558B2F 100%); |
| border-radius: 12px; |
| padding: 22px 28px 18px; |
| margin-bottom: 4px; |
| } |
| .kalbe-header h1 { |
| color: #ffffff !important; |
| font-size: 1.75rem !important; |
| font-weight: 700 !important; |
| margin: 0 0 2px !important; |
| letter-spacing: 0.01em; |
| } |
| .kalbe-header p { |
| color: #A5D6A7 !important; |
| font-size: 1rem !important; |
| font-weight: 500 !important; |
| margin: 0 0 10px !important; |
| } |
| .kalbe-header .pipeline-pills { |
| display: flex; |
| gap: 8px; |
| flex-wrap: wrap; |
| margin-top: 8px; |
| } |
| .kalbe-header .pill { |
| background: rgba(255,255,255,0.15); |
| color: #E8F5E9 !important; |
| border-radius: 20px; |
| padding: 3px 12px; |
| font-size: 0.8rem; |
| font-weight: 500; |
| border: 1px solid rgba(255,255,255,0.25); |
| } |
| |
| /* ββ PDF download widget ββ */ |
| .pdf-download { |
| border: 1.5px dashed #2E7D32 !important; |
| border-radius: 8px !important; |
| background: #F1F8F2 !important; |
| margin-top: 4px; |
| } |
| """ |
|
|
| with gr.Blocks( |
| title="Thorax Report Generation β Kalbe Digital Lab", |
| theme=gr.themes.Soft( |
| primary_hue=gr.themes.colors.green, |
| secondary_hue=gr.themes.colors.emerald, |
| neutral_hue=gr.themes.colors.gray, |
| ), |
| css=_CSS, |
| ) as demo: |
|
|
| |
| gr.HTML(""" |
| <div class="kalbe-header"> |
| <h1>Thorax Report Generation</h1> |
| <p>Kalbe Digital Lab</p> |
| <div class="pipeline-pills"> |
| <span class="pill">Detection & Localization</span> |
| <span class="pill">Segmentation</span> |
| <span class="pill">Radiology Report</span> |
| </div> |
| </div> |
| """) |
|
|
| |
| with gr.Row(equal_height=False): |
|
|
| |
| with gr.Column(scale=1, min_width=300): |
|
|
| gr.Markdown("### Chest X-Ray Images") |
| img1 = gr.Image( |
| label="PA View (required)", |
| type="pil", |
| height=220, |
| ) |
| img2 = gr.Image( |
| label="Lateral View (optional)", |
| type="pil", |
| height=220, |
| ) |
|
|
| gr.Markdown("---") |
|
|
| with gr.Accordion("Advanced settings", open=False): |
| model_id_box = gr.Textbox( |
| value=MEDGEMMA_MODEL_ID, |
| label="Detection model ID", |
| ) |
| medsam_ckpt_box = gr.Textbox( |
| value=MEDSAM_CHECKPOINT, |
| label="Segmentation checkpoint path", |
| ) |
| medsam_device_box = gr.Textbox( |
| value=MEDSAM_DEVICE, |
| label="Segmentation device", |
| ) |
|
|
| gr.Markdown("---") |
| run_btn = gr.Button("Analyze X-Ray", variant="primary", size="lg") |
| export_btn = gr.Button("Download Report (PDF)", variant="secondary", size="sm") |
| pdf_output = gr.File( |
| label="PDF Report", |
| visible=False, |
| elem_classes=["pdf-download"], |
| ) |
|
|
| |
| with gr.Column(scale=2): |
| with gr.Tabs(): |
|
|
| with gr.Tab("Annotated"): |
| annotated_gallery = gr.Gallery( |
| label="Detected findings with bounding boxes", |
| columns=2, |
| height=500, |
| object_fit="contain", |
| show_label=True, |
| ) |
|
|
| with gr.Tab("Findings"): |
| findings_md = gr.Markdown( |
| value="_Run the pipeline to see findings._" |
| ) |
| with gr.Accordion("Raw JSON", open=False): |
| findings_json = gr.Code( |
| language="json", |
| label="DetectionResult", |
| lines=20, |
| ) |
|
|
| with gr.Tab("Segmentation"): |
| seg_gallery = gr.Gallery( |
| label="Polygon segmentation overlay", |
| columns=2, |
| height=500, |
| object_fit="contain", |
| show_label=True, |
| ) |
| gr.Markdown( |
| "_Segmentation masks appear here when Stage 2 is enabled and " |
| "at least one finding is localized._" |
| ) |
|
|
| with gr.Tab("Report"): |
| report_box = gr.Markdown( |
| value="_Run the analysis to generate a structured radiology report._", |
| elem_classes=["report-box"], |
| ) |
|
|
| |
| run_btn.click( |
| fn=run_analysis, |
| inputs=[ |
| img1, |
| img2, |
| medsam_ckpt_box, |
| medsam_device_box, |
| ], |
| outputs=[ |
| annotated_gallery, |
| findings_md, |
| findings_json, |
| seg_gallery, |
| report_box, |
| ], |
| ) |
|
|
| export_btn.click( |
| fn=export_pdf, |
| outputs=[pdf_output], |
| ) |
|
|
|
|
| |
| |
| |
|
|
| if __name__ == "__main__": |
| demo.launch( |
| server_name="0.0.0.0", |
| server_port=7860, |
| share=False, |
| ) |
|
|