build-small-hackathon/ct-app
2
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")