AKIS-4 commited on
Commit
636c50f
·
verified ·
1 Parent(s): 92d2a48

Upload 3 files

Browse files
Files changed (3) hide show
  1. README.md +74 -1
  2. app.py +543 -0
  3. requirements.txt +6 -0
README.md CHANGED
@@ -12,4 +12,77 @@ license: apache-2.0
12
  short_description: App that scans CT docs using TotalSegmentator model, flags o
13
  ---
14
 
15
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  short_description: App that scans CT docs using TotalSegmentator model, flags o
13
  ---
14
 
15
+
16
+ # 🩻 CT Report Generator
17
+ > **An automated 3D volumetric reporting pipeline for CT scans, powered by TotalSegmentator (3D U-Net) (⚡ ~30 Million Total Parameters) — deployed serverlessly on Modal.**
18
+
19
+ > 📺 **[Watch the full video demo and post on X (Twitter)!](https://x.com/AKIS23820044161/status/2066586748541657272)**
20
+
21
+ ---
22
+
23
+ ## 📖 Overview
24
+
25
+ CT report generator is a Gradio-based clinical dashboard that automates the extraction and quantification of anatomical structures from 3D CT scans. It processes raw `.nii` / `.nii.gz` volumetric data, calculates the exact volume of dozens of internal organs, and automatically flags any measurements that fall outside of expected healthy reference ranges (e.g., hepatomegaly, splenomegaly, or asymmetrical kidneys).
26
+
27
+ ---
28
+
29
+ ## 🚀 Features
30
+
31
+ | Feature | Description |
32
+ |---|---|
33
+ | 🧠 **Total Body Segmentation** | Automatically identifies and segments major solid organs, thoracic structures, and GI/GU tracts. |
34
+ | 📊 **Clinical Volume Alerts** | Cross-references organ volumes with normal adult reference ranges and flags anomalies (e.g. Enlarged liver, asymmetric lungs). |
35
+ | 🖼️ **Cross-Section Preview** | Generates an immediate mid-axial visual slice of the uploaded 3D volume. |
36
+ | 📑 **PDF Report Generation** | Automatically compiles the findings into a clean, professional, downloadable PDF clinical report using WeasyPrint. |
37
+
38
+ ---
39
+
40
+ ## 🤖 AI Models Used
41
+
42
+ ### 1. `TotalSegmentator` — 3D Anatomical Segmentation
43
+ - **Architecture:** 3D U-Net (nnU-Net framework)
44
+ - **Total Parameters:** ~30 Million (30M)
45
+ - **Task:** 3D medical image segmentation.
46
+ - **Used for:** Identifying and calculating the exact cubic centimeter (cm³) volume of 100+ anatomical structures from raw CT scans.
47
+ - **Inference:** Fast-mode enabled for rapid screening on Modal **A10G GPU**.
48
+
49
+ ---
50
+
51
+ ## 🏗️ Architecture
52
+
53
+ ```
54
+ GRADIO FRONTEND (app.py)
55
+ ├── 3D Visualization → nibabel + PIL (Mid-axial slice rendering)
56
+ ├── Validation → Checks for valid 3D shape and intensity spread
57
+ ├── PDF Generation → WeasyPrint HTML-to-PDF conversion
58
+ └── Remote RPC → Connects to Modal backend via `modal.Cls`
59
+
60
+ MODAL SERVERLESS BACKEND (backend.py)
61
+ └── Segmenter [A10G] → `TotalSegmentator` subprocess
62
+ → JSON parsing & Reference Range Logic
63
+ → Returns structured clinical findings
64
+ ```
65
+
66
+ ---
67
+
68
+ ## 🖥️ GPU Resources (Modal)
69
+
70
+ | Container | GPU | Model(s) | Purpose |
71
+ |---|---|---|---|
72
+ | `Segmenter` | A10G (24GB) | TotalSegmentator 3D U-Net | Heavy volumetric segmentation and pixel quantification |
73
+
74
+ ---
75
+
76
+ ## ⚙️ Setup & Deployment
77
+
78
+ ```bash
79
+ # 1. Install dependencies
80
+ python -m venv venv && source venv/bin/activate
81
+ pip install -r requirements.txt
82
+
83
+ # 2. Deploy Modal backend
84
+ modal deploy backend.py
85
+
86
+ # 3. Run the Gradio frontend
87
+ python app.py
88
+ ```
app.py ADDED
@@ -0,0 +1,543 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import tempfile
3
+ import time
4
+ import nibabel as nib
5
+ import numpy as np
6
+ from PIL import Image
7
+ import gradio as gr
8
+ import modal
9
+
10
+ try:
11
+ Segmenter = modal.Cls.from_name("ct-summary-backend", "Segmenter")
12
+ segmenter_instance = Segmenter()
13
+ except Exception as e:
14
+ print(f"[LOCAL] Failed to connect to Modal backend: {e}")
15
+ segmenter_instance = None
16
+
17
+ if segmenter_instance is not None:
18
+ try:
19
+ t0 = time.time()
20
+ health = segmenter_instance.ping.remote()
21
+ print(f"[LOCAL] Backend ping OK ({time.time() - t0:.2f}s)")
22
+ except Exception as e:
23
+ print(f"[LOCAL] Backend ping FAILED: {e}")
24
+
25
+
26
+ def slice_3d_volumetric_scan(nifti_path):
27
+ try:
28
+ img = nib.load(nifti_path)
29
+ data = img.get_fdata()
30
+ z_mid = data.shape[2] // 2
31
+ slice_data = data[:, :, z_mid]
32
+ slice_data = np.rot90(slice_data)
33
+ data_min, data_max = np.min(slice_data), np.max(slice_data)
34
+ if data_max - data_min > 0:
35
+ normalized = 255.0 * (slice_data - data_min) / (data_max - data_min)
36
+ else:
37
+ normalized = np.zeros_like(slice_data)
38
+ img_uint8 = normalized.astype(np.uint8)
39
+ tmp_img = tempfile.NamedTemporaryFile(delete=False, suffix=".png")
40
+ Image.fromarray(img_uint8).save(tmp_img.name)
41
+ return tmp_img.name
42
+ except Exception as e:
43
+ print(f"Visualization Error: {e}")
44
+ return None
45
+
46
+
47
+ def _validate_scan_local(nifti_path):
48
+ """Minimal validation: 3D only, not a mask. Let TotalSegmentator handle the rest."""
49
+ try:
50
+ img = nib.load(nifti_path)
51
+ data = img.get_fdata()
52
+ except Exception as e:
53
+ return False, f"Not supported file or wrong CT scan. Could not read volume: {e}"
54
+
55
+ if len(data.shape) != 3:
56
+ return False, f"Not supported file or wrong CT scan. Expected 3D volume, got {len(data.shape)}D shape {data.shape}."
57
+
58
+ unique_count = len(np.unique(data))
59
+ print(f"[LOCAL] Validation: shape={data.shape}, unique_values={unique_count}, min={np.min(data):.1f}, max={np.max(data):.1f}")
60
+
61
+ if unique_count < 50:
62
+ return False, "Not supported file or wrong CT scan. Uploaded file appears to be a segmentation mask (too few unique values)."
63
+
64
+ return True, None
65
+
66
+
67
+ SECTION_ORDER = [
68
+ "Solid Organs", "Gastrointestinal", "Thoracic", "Genitourinary", "Other Structures"
69
+ ]
70
+
71
+
72
+ def build_preview_html(findings: dict) -> str:
73
+ if findings.get("error"):
74
+ return (
75
+ '<div class="preview-alert preview-alert-error">'
76
+ f'<strong>Processing issue:</strong> {findings["error"]}'
77
+ '</div>'
78
+ )
79
+
80
+ alerts = findings.get("alerts", [])
81
+ sections = findings.get("sections", {})
82
+ total_structures = findings.get("total_structures", 0)
83
+
84
+ if alerts:
85
+ html = '<div class="preview-alert preview-alert-warning">'
86
+ html += f'<div class="preview-alert-title">⚠ {len(alerts)} finding(s) outside expected range</div>'
87
+ html += '<ul>'
88
+ for a in alerts:
89
+ vol_str = f" — {a['volume']:.1f} cm³" if a.get("volume") is not None else ""
90
+ html += f'<li><strong>{a["name"]}</strong>{vol_str}<br><span class="preview-note">{a["note"]}</span></li>'
91
+ html += '</ul></div>'
92
+ else:
93
+ html = (
94
+ '<div class="preview-alert preview-alert-ok">'
95
+ '✓ No findings outside expected range across measured structures.'
96
+ '</div>'
97
+ )
98
+
99
+ html += '<div class="preview-metrics">'
100
+ for section_name in SECTION_ORDER:
101
+ entries = sections.get(section_name)
102
+ if not entries:
103
+ continue
104
+ html += f'<div class="preview-section-title">{section_name}</div>'
105
+ for e in entries:
106
+ cls = "preview-metric-alert" if e["status"] == "alert" else "preview-metric"
107
+ html += f'<div class="{cls}"><span>{e["name"]}</span><span>{e["volume"]:.1f} cm³</span></div>'
108
+ html += '</div>'
109
+
110
+ html += (
111
+ f'<div class="preview-footnote">'
112
+ f'{total_structures} structures measured. '
113
+ f'Volumes are approximate (fast-mode segmentation) — screening only, not diagnostic.'
114
+ f'</div>'
115
+ )
116
+
117
+ return html
118
+
119
+
120
+ def build_report_html(findings: dict, scan_label: str, for_pdf: bool = False) -> str:
121
+ if findings.get("error"):
122
+ body = f'<div class="alert-banner alert-error"><strong>Processing issue:</strong> {findings["error"]}</div>'
123
+ return _wrap_html(body, scan_label, for_pdf)
124
+
125
+ alerts = findings.get("alerts", [])
126
+ sections = findings.get("sections", {})
127
+ total_structures = findings.get("total_structures", 0)
128
+
129
+ if alerts:
130
+ body = '<div class="alert-banner alert-warning">'
131
+ body += f'<div class="alert-title">⚠ {len(alerts)} finding(s) outside expected range</div>'
132
+ body += '<ul class="alert-list">'
133
+ for a in alerts:
134
+ vol_str = f" ({a['volume']:.1f} cm³)" if a.get("volume") is not None else ""
135
+ body += f'<li><span class="organ-name">{a["name"]}</span>{vol_str} — {a["note"]}</li>'
136
+ body += '</ul></div>'
137
+ else:
138
+ body = '<div class="alert-banner alert-ok">'
139
+ body += '<div class="alert-title">✓ No findings outside expected range</div>'
140
+ body += '<p>All measured structures fall within typical adult volume ranges for the available reference set.</p>'
141
+ body += '</div>'
142
+
143
+ for section_name in SECTION_ORDER:
144
+ entries = sections.get(section_name)
145
+ if not entries:
146
+ continue
147
+ body += f'<div class="section-title">{section_name}</div><ul>'
148
+ for e in entries:
149
+ status_class = "status-alert" if e["status"] == "alert" else "status-normal"
150
+ note_html = f'<div class="organ-note">{e["note"]}</div>' if e.get("note") else ""
151
+ body += (
152
+ f'<li class="{status_class}">'
153
+ f'<span class="organ-name">{e["name"]}</span>: {e["volume"]:.1f} cm³'
154
+ f'{note_html}</li>'
155
+ )
156
+ body += '</ul>'
157
+
158
+ body += (
159
+ f'<p class="meta-note">Total structures measured: {total_structures}. '
160
+ f'Volumes are approximate, derived from a fast-mode segmentation pass and intended '
161
+ f'for screening purposes only — not a substitute for radiologist review.</p>'
162
+ )
163
+
164
+ return _wrap_html(body, scan_label, for_pdf)
165
+
166
+
167
+ def _wrap_html(content_html: str, scan_label: str, for_pdf: bool) -> str:
168
+ page_rule = """
169
+ @page {
170
+ size: A4;
171
+ margin: 20mm 15mm 20mm 15mm;
172
+ @bottom-right {
173
+ content: "Page " counter(page) " of " counter(pages);
174
+ font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
175
+ font-size: 9pt;
176
+ color: #64748b;
177
+ }
178
+ }
179
+ """ if for_pdf else ""
180
+
181
+ return f"""<!DOCTYPE html>
182
+ <html>
183
+ <head>
184
+ <meta charset="utf-8">
185
+ <style>
186
+ {page_rule}
187
+ body {{
188
+ font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
189
+ color: #1e293b;
190
+ margin: 0;
191
+ padding: 0;
192
+ line-height: 1.6;
193
+ background-color: #ffffff;
194
+ }}
195
+ .header {{
196
+ border-bottom: 2px solid #0f172a;
197
+ padding-bottom: 12px;
198
+ margin-bottom: 25px;
199
+ }}
200
+ .header h1 {{
201
+ font-size: 22pt;
202
+ color: #0f172a;
203
+ margin: 0 0 6px 0;
204
+ text-transform: uppercase;
205
+ letter-spacing: 0.5px;
206
+ }}
207
+ .header .subtitle {{
208
+ font-size: 11pt;
209
+ color: #475569;
210
+ margin: 0;
211
+ font-weight: bold;
212
+ }}
213
+ .metadata-table {{
214
+ width: 100%;
215
+ margin-bottom: 25px;
216
+ border-collapse: collapse;
217
+ background-color: #f8fafc;
218
+ border: 1px solid #e2e8f0;
219
+ }}
220
+ .metadata-table td {{
221
+ padding: 10px 12px;
222
+ font-size: 10pt;
223
+ border: 1px solid #e2e8f0;
224
+ }}
225
+ .metadata-label {{
226
+ font-weight: bold;
227
+ color: #334155;
228
+ background-color: #f1f5f9;
229
+ width: 25%;
230
+ }}
231
+ .alert-banner {{
232
+ border-radius: 4px;
233
+ padding: 14px 16px;
234
+ margin-bottom: 22px;
235
+ border: 1px solid;
236
+ }}
237
+ .alert-warning {{
238
+ background-color: #fef2f2;
239
+ border-color: #fecaca;
240
+ color: #991b1b;
241
+ }}
242
+ .alert-ok {{
243
+ background-color: #f0fdf4;
244
+ border-color: #bbf7d0;
245
+ color: #166534;
246
+ }}
247
+ .alert-error {{
248
+ background-color: #fef2f2;
249
+ border-color: #fecaca;
250
+ color: #991b1b;
251
+ }}
252
+ .alert-title {{
253
+ font-size: 11.5pt;
254
+ font-weight: bold;
255
+ margin-bottom: 6px;
256
+ }}
257
+ .alert-list {{
258
+ margin: 6px 0 0 0;
259
+ padding-left: 20px;
260
+ }}
261
+ .alert-list li {{
262
+ font-size: 10.5pt;
263
+ margin-bottom: 4px;
264
+ }}
265
+ .section-title {{
266
+ font-size: 12pt;
267
+ color: #1e3a8a;
268
+ background-color: #eff6ff;
269
+ padding: 6px 10px;
270
+ margin-top: 22px;
271
+ margin-bottom: 12px;
272
+ font-weight: bold;
273
+ border-left: 4px solid #2563eb;
274
+ text-transform: uppercase;
275
+ letter-spacing: 0.5px;
276
+ page-break-after: avoid;
277
+ }}
278
+ ul {{
279
+ margin: 0 0 15px 0;
280
+ padding-left: 20px;
281
+ }}
282
+ li {{
283
+ font-size: 10.5pt;
284
+ margin-bottom: 6px;
285
+ page-break-inside: avoid;
286
+ }}
287
+ li.status-alert {{
288
+ color: #991b1b;
289
+ }}
290
+ .organ-name {{
291
+ font-weight: bold;
292
+ color: #0f172a;
293
+ }}
294
+ li.status-alert .organ-name {{
295
+ color: #991b1b;
296
+ }}
297
+ .organ-note {{
298
+ font-size: 9.5pt;
299
+ font-weight: normal;
300
+ color: #7f1d1d;
301
+ margin-top: 2px;
302
+ }}
303
+ .meta-note {{
304
+ font-size: 9pt;
305
+ color: #64748b;
306
+ margin-top: 20px;
307
+ font-style: italic;
308
+ }}
309
+ </style>
310
+ </head>
311
+ <body>
312
+ <div class="header">
313
+ <h1>Automated 3D Volumetric Report</h1>
314
+ <div class="subtitle">Full-Body Clinical Quantification Pipeline Output</div>
315
+ </div>
316
+
317
+ <table class="metadata-table">
318
+ <tr>
319
+ <td class="metadata-label">Protocol Type</td>
320
+ <td>{scan_label}</td>
321
+ <td class="metadata-label">Analysis Target</td>
322
+ <td>Full Volumetric Masking (Total Body)</td>
323
+ </tr>
324
+ <tr>
325
+ <td class="metadata-label">Pipeline Engine</td>
326
+ <td>TotalSegmentator 3D U-Net (fast mode)</td>
327
+ <td class="metadata-label">Reporting Method</td>
328
+ <td>Rule-Based Reference Range Analysis</td>
329
+ </tr>
330
+ </table>
331
+
332
+ <div class="content-body">
333
+ {content_html}
334
+ </div>
335
+ </body>
336
+ </html>
337
+ """
338
+
339
+
340
+ def run_pipeline(file_obj, progress=gr.Progress()):
341
+ t_start = time.time()
342
+
343
+ if file_obj is None:
344
+ return None, '<div class="preview-alert preview-alert-error">Upload a NIfTI (.nii or .nii.gz) volume to begin.</div>', None
345
+
346
+ scan_label = "Whole Body CT (Auto-Detected)"
347
+
348
+ # --- Local validation ---
349
+ progress(0.05, desc="Validating file...")
350
+ is_valid, err_msg = _validate_scan_local(file_obj.name)
351
+ if not is_valid:
352
+ return None, f'<div class="preview-alert preview-alert-error"><strong>{err_msg}</strong></div>', None
353
+
354
+ # --- Slice extraction ---
355
+ progress(0.15, desc="Extracting preview slice...")
356
+ slice_path = slice_3d_volumetric_scan(file_obj.name)
357
+ if slice_path is None:
358
+ return None, '<div class="preview-alert preview-alert-error">Failed to extract preview slice.</div>', None
359
+
360
+ if segmenter_instance is None:
361
+ err_html = (
362
+ '<div class="preview-alert preview-alert-error">'
363
+ "Could not connect to the Modal backend. Confirm the 'ct-summary-backend' app is deployed."
364
+ '</div>'
365
+ )
366
+ return slice_path, err_html, None
367
+
368
+ try:
369
+ # --- Read file ---
370
+ progress(0.25, desc="Reading file...")
371
+ with open(file_obj.name, "rb") as f:
372
+ file_bytes = f.read()
373
+
374
+ # --- Pre-flight ping ---
375
+ progress(0.30, desc="Connecting to backend...")
376
+ try:
377
+ segmenter_instance.ping.remote()
378
+ except Exception as e:
379
+ return slice_path, f'<div class="preview-alert preview-alert-error">Backend unreachable: {e}</div>', None
380
+
381
+ # --- Modal remote call ---
382
+ progress(0.35, desc="Uploading & running segmentation (~20-30s)...")
383
+ t0 = time.time()
384
+ findings = segmenter_instance.validate_and_report.remote(file_bytes)
385
+ t_remote = time.time() - t0
386
+ print(f"[frontend timing] Modal remote call: {t_remote:.1f}s")
387
+
388
+ # --- Preview HTML ---
389
+ progress(0.80, desc="Building report...")
390
+ report_preview = build_preview_html(findings)
391
+
392
+ # --- PDF generation ---
393
+ progress(0.90, desc="Generating PDF...")
394
+ from weasyprint import HTML
395
+ pdf_html = build_report_html(findings, scan_label, for_pdf=True)
396
+ pdf_dir = tempfile.mkdtemp()
397
+ pdf_path = os.path.join(pdf_dir, "ct_report.pdf")
398
+ HTML(string=pdf_html).write_pdf(pdf_path)
399
+
400
+ progress(1.0, desc="Done")
401
+ print(f"[frontend timing] TOTAL pipeline: {time.time() - t_start:.1f}s")
402
+
403
+ return slice_path, report_preview, pdf_path
404
+
405
+ except Exception as e:
406
+ err_html = f'<div class="preview-alert preview-alert-error"><strong>Pipeline execution failed:</strong> {e}</div>'
407
+ return slice_path, err_html, None
408
+
409
+
410
+ clinical_theme = gr.themes.Soft(
411
+ primary_hue="blue",
412
+ neutral_hue="slate",
413
+ ).set(
414
+ body_background_fill="#0f172a",
415
+ block_background_fill="#1e293b",
416
+ block_border_color="#334155",
417
+ button_primary_background_fill="#2563eb",
418
+ button_primary_text_color="#ffffff",
419
+ body_text_color="#f1f5f9"
420
+ )
421
+
422
+ custom_css = """
423
+ .gradio-container { font-family: 'Helvetica Neue', Arial, sans-serif; }
424
+ h1, h2, h3, h4, h5, h6 { color: #ffffff !important; }
425
+
426
+ #main-heading { text-align: center; }
427
+
428
+ .full-height-image { height: 790px !important; }
429
+ .full-height-image img { height: 100% !important; object-fit: contain; }
430
+
431
+ .report-frame {
432
+ background-color: #ffffff !important;
433
+ border-radius: 6px;
434
+ border: 1px solid #334155;
435
+ min-height: 300px;
436
+ max-height: 790px !important;
437
+ padding: 16px;
438
+ font-family: 'Helvetica Neue', Arial, sans-serif;
439
+ overflow-y: auto !important;
440
+ }
441
+ .report-frame, .report-frame * {
442
+ color: #1e293b !important;
443
+ }
444
+ .report-frame h1, .report-frame h2, .report-frame h3 { color: #0f172a !important; }
445
+
446
+ .preview-alert {
447
+ border-radius: 4px;
448
+ padding: 12px 14px;
449
+ margin-bottom: 16px;
450
+ border: 1px solid;
451
+ font-size: 10.5pt;
452
+ }
453
+ .preview-alert-warning, .preview-alert-warning * { background-color: #fef2f2; border-color: #fecaca; color: #991b1b !important; }
454
+ .preview-alert-ok, .preview-alert-ok * { background-color: #f0fdf4; border-color: #bbf7d0; color: #166534 !important; }
455
+ .preview-alert-error, .preview-alert-error * { background-color: #fef2f2; border-color: #fecaca; color: #991b1b !important; }
456
+ .preview-alert-title { font-weight: bold; margin-bottom: 6px; }
457
+ .preview-alert ul { margin: 6px 0 0 0; padding-left: 18px; }
458
+ .preview-alert li { margin-bottom: 8px; }
459
+ .preview-note, .preview-note * { font-size: 9pt; color: #7f1d1d !important; }
460
+
461
+ .preview-section-title, .preview-section-title * {
462
+ font-size: 10.5pt;
463
+ font-weight: bold;
464
+ color: #1e3a8a !important;
465
+ background-color: #eff6ff;
466
+ padding: 4px 8px;
467
+ margin-top: 14px;
468
+ margin-bottom: 6px;
469
+ border-left: 3px solid #2563eb;
470
+ text-transform: uppercase;
471
+ letter-spacing: 0.5px;
472
+ }
473
+ .preview-metric, .preview-metric * {
474
+ display: flex;
475
+ justify-content: space-between;
476
+ font-size: 10.5pt;
477
+ padding: 3px 6px;
478
+ border-bottom: 1px solid #f1f5f9;
479
+ color: #1e293b !important;
480
+ }
481
+ .preview-metric-alert, .preview-metric-alert * {
482
+ display: flex;
483
+ justify-content: space-between;
484
+ font-size: 10.5pt;
485
+ padding: 3px 6px;
486
+ border-bottom: 1px solid #f1f5f9;
487
+ color: #991b1b !important;
488
+ font-weight: bold;
489
+ background-color: #fef2f2;
490
+ }
491
+ .preview-footnote, .preview-footnote * {
492
+ font-size: 9pt;
493
+ color: #64748b !important;
494
+ font-style: italic;
495
+ margin-top: 14px;
496
+ }
497
+ """
498
+
499
+ PLACEHOLDER_HTML = """
500
+ <div style="padding: 40px 20px; text-align:center; color:#94a3b8; font-family: 'Helvetica Neue', Arial, sans-serif;">
501
+ Upload a CT volume (.nii / .nii.gz) and run the analysis to see the metrics here.
502
+ </div>
503
+ """
504
+
505
+ with gr.Blocks(theme=clinical_theme, css=custom_css, title="CT Report Generator") as demo:
506
+ gr.Markdown("# Automated 3D Imaging Extraction & Reporting Pipeline", elem_id="main-heading")
507
+ gr.Markdown(
508
+ "Upload a 3D CT volume to generate a structured report with volume-based alerts.",
509
+ elem_id="main-heading"
510
+ )
511
+
512
+ with gr.Row():
513
+ with gr.Column(scale=1):
514
+ gr.Markdown("### 1. Cross-Section Visualization")
515
+ image_output = gr.Image(
516
+ label="Middle Z-Axis Cross-Section",
517
+ type="filepath",
518
+ height=790,
519
+ elem_classes=["full-height-image"]
520
+ )
521
+
522
+ with gr.Column(scale=1):
523
+ gr.Markdown("### 2. Upload & Analyze")
524
+ file_input = gr.File(
525
+ label="Upload 3D Volumetric Scan (.nii.gz / .nii)",
526
+ file_types=[".gz", ".nii"]
527
+ )
528
+
529
+ submit_btn = gr.Button("Analyze Scan & Generate Report", variant="primary")
530
+
531
+ gr.Markdown("#### Metrics & Alerts")
532
+ report_output = gr.HTML(value=PLACEHOLDER_HTML, elem_classes=["report-frame"])
533
+
534
+ pdf_download = gr.DownloadButton("Download Official PDF Report", variant="secondary")
535
+
536
+ submit_btn.click(
537
+ fn=run_pipeline,
538
+ inputs=[file_input],
539
+ outputs=[image_output, report_output, pdf_download]
540
+ )
541
+
542
+ if __name__ == "__main__":
543
+ demo.launch(server_name="127.0.0.1", server_port=7860, show_error=True)
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ gradio>=4.0.0
2
+ modal>=0.60.0
3
+ nibabel
4
+ numpy
5
+ Pillow
6
+ weasyprint