CoolFace
Apppublic

build-small-hackathon/ct-app

sourceHugging Faceapache-2.0updated 3mo agoView on Hugging Face
2likes
app.py537 linesDownload Raw Back to root
1import os2import tempfile3import time4import nibabel as nib5import numpy as np6from PIL import Image7import gradio as gr8import modal9 10try:11    Segmenter = modal.Cls.from_name("ct-summary-backend", "Segmenter")12    segmenter_instance = Segmenter()13except Exception as e:14    print(f"[LOCAL] Failed to connect to Modal backend: {e}")15    segmenter_instance = None16 17# Removed global ping that blocked HF Space startup18 19 20def slice_3d_volumetric_scan(nifti_path):21    try:22        img = nib.load(nifti_path)23        data = img.get_fdata()24        z_mid = data.shape[2] // 225        slice_data = data[:, :, z_mid]26        slice_data = np.rot90(slice_data)27        data_min, data_max = np.min(slice_data), np.max(slice_data)28        if data_max - data_min > 0:29            normalized = 255.0 * (slice_data - data_min) / (data_max - data_min)30        else:31            normalized = np.zeros_like(slice_data)32        img_uint8 = normalized.astype(np.uint8)33        tmp_img = tempfile.NamedTemporaryFile(delete=False, suffix=".png")34        Image.fromarray(img_uint8).save(tmp_img.name)35        return tmp_img.name36    except Exception as e:37        print(f"Visualization Error: {e}")38        return None39 40 41def _validate_scan_local(nifti_path):42    """Minimal validation: 3D only, not a mask. Let TotalSegmentator handle the rest."""43    try:44        img = nib.load(nifti_path)45        data = img.get_fdata()46    except Exception as e:47        return False, f"Not supported file or wrong CT scan. Could not read volume: {e}"48 49    if len(data.shape) != 3:50        return False, f"Not supported file or wrong CT scan. Expected 3D volume, got {len(data.shape)}D shape {data.shape}."51 52    unique_count = len(np.unique(data))53    print(f"[LOCAL] Validation: shape={data.shape}, unique_values={unique_count}, min={np.min(data):.1f}, max={np.max(data):.1f}")54 55    if unique_count < 50:56        return False, "Not supported file or wrong CT scan. Uploaded file appears to be a segmentation mask (too few unique values)."57 58    return True, None59 60 61SECTION_ORDER = [62    "Solid Organs", "Gastrointestinal", "Thoracic", "Genitourinary", "Other Structures"63]64 65 66def build_preview_html(findings: dict) -> str:67    if findings.get("error"):68        return (69            '<div class="preview-alert preview-alert-error">'70            f'<strong>Processing issue:</strong> {findings["error"]}'71            '</div>'72        )73 74    alerts = findings.get("alerts", [])75    sections = findings.get("sections", {})76    total_structures = findings.get("total_structures", 0)77 78    if alerts:79        html = '<div class="preview-alert preview-alert-warning">'80        html += f'<div class="preview-alert-title">⚠ {len(alerts)} finding(s) outside expected range</div>'81        html += '<ul>'82        for a in alerts:83            vol_str = f" — {a['volume']:.1f} cm³" if a.get("volume") is not None else ""84            html += f'<li><strong>{a["name"]}</strong>{vol_str}<br><span class="preview-note">{a["note"]}</span></li>'85        html += '</ul></div>'86    else:87        html = (88            '<div class="preview-alert preview-alert-ok">'89            '✓ No findings outside expected range across measured structures.'90            '</div>'91        )92 93    html += '<div class="preview-metrics">'94    for section_name in SECTION_ORDER:95        entries = sections.get(section_name)96        if not entries:97            continue98        html += f'<div class="preview-section-title">{section_name}</div>'99        for e in entries:100            cls = "preview-metric-alert" if e["status"] == "alert" else "preview-metric"101            html += f'<div class="{cls}"><span>{e["name"]}</span><span>{e["volume"]:.1f} cm³</span></div>'102        html += '</div>'103 104    html += (105        f'<div class="preview-footnote">'106        f'{total_structures} structures measured. '107        f'Volumes are approximate (fast-mode segmentation) — screening only, not diagnostic.'108        f'</div>'109    )110 111    return html112 113 114def build_report_html(findings: dict, scan_label: str, for_pdf: bool = False) -> str:115    if findings.get("error"):116        body = f'<div class="alert-banner alert-error"><strong>Processing issue:</strong> {findings["error"]}</div>'117        return _wrap_html(body, scan_label, for_pdf)118 119    alerts = findings.get("alerts", [])120    sections = findings.get("sections", {})121    total_structures = findings.get("total_structures", 0)122 123    if alerts:124        body = '<div class="alert-banner alert-warning">'125        body += f'<div class="alert-title">⚠ {len(alerts)} finding(s) outside expected range</div>'126        body += '<ul class="alert-list">'127        for a in alerts:128            vol_str = f" ({a['volume']:.1f} cm³)" if a.get("volume") is not None else ""129            body += f'<li><span class="organ-name">{a["name"]}</span>{vol_str} — {a["note"]}</li>'130        body += '</ul></div>'131    else:132        body = '<div class="alert-banner alert-ok">'133        body += '<div class="alert-title">✓ No findings outside expected range</div>'134        body += '<p>All measured structures fall within typical adult volume ranges for the available reference set.</p>'135        body += '</div>'136 137    for section_name in SECTION_ORDER:138        entries = sections.get(section_name)139        if not entries:140            continue141        body += f'<div class="section-title">{section_name}</div><ul>'142        for e in entries:143            status_class = "status-alert" if e["status"] == "alert" else "status-normal"144            note_html = f'<div class="organ-note">{e["note"]}</div>' if e.get("note") else ""145            body += (146                f'<li class="{status_class}">'147                f'<span class="organ-name">{e["name"]}</span>: {e["volume"]:.1f} cm³'148                f'{note_html}</li>'149            )150        body += '</ul>'151 152    body += (153        f'<p class="meta-note">Total structures measured: {total_structures}. '154        f'Volumes are approximate, derived from a fast-mode segmentation pass and intended '155        f'for screening purposes only — not a substitute for radiologist review.</p>'156    )157 158    return _wrap_html(body, scan_label, for_pdf)159 160 161def _wrap_html(content_html: str, scan_label: str, for_pdf: bool) -> str:162    page_rule = """163        @page {164            size: A4;165            margin: 20mm 15mm 20mm 15mm;166            @bottom-right {167                content: "Page " counter(page) " of " counter(pages);168                font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;169                font-size: 9pt;170                color: #64748b;171            }172        }173    """ if for_pdf else ""174 175    return f"""<!DOCTYPE html>176<html>177<head>178    <meta charset="utf-8">179    <style>180        {page_rule}181        body {{182            font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;183            color: #1e293b;184            margin: 0;185            padding: 0;186            line-height: 1.6;187            background-color: #ffffff;188        }}189        .header {{190            border-bottom: 2px solid #0f172a;191            padding-bottom: 12px;192            margin-bottom: 25px;193        }}194        .header h1 {{195            font-size: 22pt;196            color: #0f172a;197            margin: 0 0 6px 0;198            text-transform: uppercase;199            letter-spacing: 0.5px;200        }}201        .header .subtitle {{202            font-size: 11pt;203            color: #475569;204            margin: 0;205            font-weight: bold;206        }}207        .metadata-table {{208            width: 100%;209            margin-bottom: 25px;210            border-collapse: collapse;211            background-color: #f8fafc;212            border: 1px solid #e2e8f0;213        }}214        .metadata-table td {{215            padding: 10px 12px;216            font-size: 10pt;217            border: 1px solid #e2e8f0;218        }}219        .metadata-label {{220            font-weight: bold;221            color: #334155;222            background-color: #f1f5f9;223            width: 25%;224        }}225        .alert-banner {{226            border-radius: 4px;227            padding: 14px 16px;228            margin-bottom: 22px;229            border: 1px solid;230        }}231        .alert-warning {{232            background-color: #fef2f2;233            border-color: #fecaca;234            color: #991b1b;235        }}236        .alert-ok {{237            background-color: #f0fdf4;238            border-color: #bbf7d0;239            color: #166534;240        }}241        .alert-error {{242            background-color: #fef2f2;243            border-color: #fecaca;244            color: #991b1b;245        }}246        .alert-title {{247            font-size: 11.5pt;248            font-weight: bold;249            margin-bottom: 6px;250        }}251        .alert-list {{252            margin: 6px 0 0 0;253            padding-left: 20px;254        }}255        .alert-list li {{256            font-size: 10.5pt;257            margin-bottom: 4px;258        }}259        .section-title {{260            font-size: 12pt;261            color: #1e3a8a;262            background-color: #eff6ff;263            padding: 6px 10px;264            margin-top: 22px;265            margin-bottom: 12px;266            font-weight: bold;267            border-left: 4px solid #2563eb;268            text-transform: uppercase;269            letter-spacing: 0.5px;270            page-break-after: avoid;271        }}272        ul {{273            margin: 0 0 15px 0;274            padding-left: 20px;275        }}276        li {{277            font-size: 10.5pt;278            margin-bottom: 6px;279            page-break-inside: avoid;280        }}281        li.status-alert {{282            color: #991b1b;283        }}284        .organ-name {{285            font-weight: bold;286            color: #0f172a;287        }}288        li.status-alert .organ-name {{289            color: #991b1b;290        }}291        .organ-note {{292            font-size: 9.5pt;293            font-weight: normal;294            color: #7f1d1d;295            margin-top: 2px;296        }}297        .meta-note {{298            font-size: 9pt;299            color: #64748b;300            margin-top: 20px;301            font-style: italic;302        }}303    </style>304</head>305<body>306    <div class="header">307        <h1>Automated 3D Volumetric Report</h1>308        <div class="subtitle">Full-Body Clinical Quantification Pipeline Output</div>309    </div>310 311    <table class="metadata-table">312        <tr>313            <td class="metadata-label">Protocol Type</td>314            <td>{scan_label}</td>315            <td class="metadata-label">Analysis Target</td>316            <td>Full Volumetric Masking (Total Body)</td>317        </tr>318        <tr>319            <td class="metadata-label">Pipeline Engine</td>320            <td>TotalSegmentator 3D U-Net (fast mode)</td>321            <td class="metadata-label">Reporting Method</td>322            <td>Rule-Based Reference Range Analysis</td>323        </tr>324    </table>325 326    <div class="content-body">327        {content_html}328    </div>329</body>330</html>331"""332 333 334def run_pipeline(file_obj, progress=gr.Progress()):335    t_start = time.time()336 337    if file_obj is None:338        return None, '<div class="preview-alert preview-alert-error">Upload a NIfTI (.nii or .nii.gz) volume to begin.</div>', None339 340    scan_label = "Whole Body CT (Auto-Detected)"341 342    # --- Local validation ---343    progress(0.05, desc="Validating file...")344    is_valid, err_msg = _validate_scan_local(file_obj.name)345    if not is_valid:346        return None, f'<div class="preview-alert preview-alert-error"><strong>{err_msg}</strong></div>', None347 348    # --- Slice extraction ---349    progress(0.15, desc="Extracting preview slice...")350    slice_path = slice_3d_volumetric_scan(file_obj.name)351    if slice_path is None:352        return None, '<div class="preview-alert preview-alert-error">Failed to extract preview slice.</div>', None353 354    if segmenter_instance is None:355        err_html = (356            '<div class="preview-alert preview-alert-error">'357            "Could not connect to the Modal backend. Confirm the 'ct-summary-backend' app is deployed."358            '</div>'359        )360        return slice_path, err_html, None361 362    try:363        # --- Read file ---364        progress(0.25, desc="Reading file...")365        with open(file_obj.name, "rb") as f:366            file_bytes = f.read()367 368        # --- Pre-flight ping ---369        progress(0.30, desc="Connecting to backend...")370        try:371            segmenter_instance.ping.remote()372        except Exception as e:373            return slice_path, f'<div class="preview-alert preview-alert-error">Backend unreachable: {e}</div>', None374 375        # --- Modal remote call ---376        progress(0.35, desc="Uploading & running segmentation (~20-30s)...")377        t0 = time.time()378        findings = segmenter_instance.validate_and_report.remote(file_bytes)379        t_remote = time.time() - t0380        print(f"[frontend timing] Modal remote call: {t_remote:.1f}s")381 382        # --- Preview HTML ---383        progress(0.80, desc="Building report...")384        report_preview = build_preview_html(findings)385 386        # --- PDF generation ---387        progress(0.90, desc="Generating PDF...")388        from weasyprint import HTML389        pdf_html = build_report_html(findings, scan_label, for_pdf=True)390        pdf_dir = tempfile.mkdtemp()391        pdf_path = os.path.join(pdf_dir, "ct_report.pdf")392        HTML(string=pdf_html).write_pdf(pdf_path)393 394        progress(1.0, desc="Done")395        print(f"[frontend timing] TOTAL pipeline: {time.time() - t_start:.1f}s")396 397        return slice_path, report_preview, pdf_path398 399    except Exception as e:400        err_html = f'<div class="preview-alert preview-alert-error"><strong>Pipeline execution failed:</strong> {e}</div>'401        return slice_path, err_html, None402 403 404clinical_theme = gr.themes.Soft(405    primary_hue="blue",406    neutral_hue="slate",407).set(408    body_background_fill="#0f172a",409    block_background_fill="#1e293b",410    block_border_color="#334155",411    button_primary_background_fill="#2563eb",412    button_primary_text_color="#ffffff",413    body_text_color="#f1f5f9"414)415 416custom_css = """417.gradio-container { font-family: 'Helvetica Neue', Arial, sans-serif; }418h1, h2, h3, h4, h5, h6 { color: #ffffff !important; }419 420#main-heading { text-align: center; }421 422.full-height-image { height: 790px !important; }423.full-height-image img { height: 100% !important; object-fit: contain; }424 425.report-frame {426    background-color: #ffffff !important;427    border-radius: 6px;428    border: 1px solid #334155;429    min-height: 300px;430    max-height: 790px !important;431    padding: 16px;432    font-family: 'Helvetica Neue', Arial, sans-serif;433    overflow-y: auto !important;434}435.report-frame, .report-frame * {436    color: #1e293b !important;437}438.report-frame h1, .report-frame h2, .report-frame h3 { color: #0f172a !important; }439 440.preview-alert {441    border-radius: 4px;442    padding: 12px 14px;443    margin-bottom: 16px;444    border: 1px solid;445    font-size: 10.5pt;446}447.preview-alert-warning, .preview-alert-warning * { background-color: #fef2f2; border-color: #fecaca; color: #991b1b !important; }448.preview-alert-ok, .preview-alert-ok * { background-color: #f0fdf4; border-color: #bbf7d0; color: #166534 !important; }449.preview-alert-error, .preview-alert-error * { background-color: #fef2f2; border-color: #fecaca; color: #991b1b !important; }450.preview-alert-title { font-weight: bold; margin-bottom: 6px; }451.preview-alert ul { margin: 6px 0 0 0; padding-left: 18px; }452.preview-alert li { margin-bottom: 8px; }453.preview-note, .preview-note * { font-size: 9pt; color: #7f1d1d !important; }454 455.preview-section-title, .preview-section-title * {456    font-size: 10.5pt;457    font-weight: bold;458    color: #1e3a8a !important;459    background-color: #eff6ff;460    padding: 4px 8px;461    margin-top: 14px;462    margin-bottom: 6px;463    border-left: 3px solid #2563eb;464    text-transform: uppercase;465    letter-spacing: 0.5px;466}467.preview-metric, .preview-metric * {468    display: flex;469    justify-content: space-between;470    font-size: 10.5pt;471    padding: 3px 6px;472    border-bottom: 1px solid #f1f5f9;473    color: #1e293b !important;474}475.preview-metric-alert, .preview-metric-alert * {476    display: flex;477    justify-content: space-between;478    font-size: 10.5pt;479    padding: 3px 6px;480    border-bottom: 1px solid #f1f5f9;481    color: #991b1b !important;482    font-weight: bold;483    background-color: #fef2f2;484}485.preview-footnote, .preview-footnote * {486    font-size: 9pt;487    color: #64748b !important;488    font-style: italic;489    margin-top: 14px;490}491"""492 493PLACEHOLDER_HTML = """494<div style="padding: 40px 20px; text-align:center; color:#94a3b8; font-family: 'Helvetica Neue', Arial, sans-serif;">495    Upload a CT volume (.nii / .nii.gz) and run the analysis to see the metrics here.496</div>497"""498 499with gr.Blocks(theme=clinical_theme, css=custom_css, title="CT Report Generator") as demo:500    gr.Markdown("# Automated 3D Imaging Extraction & Reporting Pipeline", elem_id="main-heading")501    gr.Markdown(502        "Upload a 3D CT volume to generate a structured report with volume-based alerts.",503        elem_id="main-heading"504    )505 506    with gr.Row():507        with gr.Column(scale=1):508            gr.Markdown("### 1. Cross-Section Visualization")509            image_output = gr.Image(510                label="Middle Z-Axis Cross-Section",511                type="filepath",512                height=790,513                elem_classes=["full-height-image"]514            )515 516        with gr.Column(scale=1):517            gr.Markdown("### 2. Upload & Analyze")518            file_input = gr.File(519                label="Upload 3D Volumetric Scan (.nii.gz / .nii)",520                file_types=[".gz", ".nii"]521            )522 523            submit_btn = gr.Button("Analyze Scan & Generate Report", variant="primary")524 525            gr.Markdown("#### Metrics & Alerts")526            report_output = gr.HTML(value=PLACEHOLDER_HTML, elem_classes=["report-frame"])527 528            pdf_download = gr.DownloadButton("Download Official PDF Report", variant="secondary")529 530    submit_btn.click(531        fn=run_pipeline,532        inputs=[file_input],533        outputs=[image_output, report_output, pdf_download]534    )535 536if __name__ == "__main__":537    demo.launch(server_name="0.0.0.0")