COVID lung infection on CT

Segmentation and quantification demo. Scroll through the full 255-slice scan, run the pipeline stages, and stress-test the thresholds.

This page runs a simple, interpretable pipeline on a chest CT series from a COVID-positive patient: threshold the Hounsfield units to find the lungs, threshold again to find infected-looking tissue (ground glass in the -700 to -200 HU band), and report what fraction of the lung is affected. No neural network, no black box. Every image and number below is precomputed from the real 255-slice scan; the method and its limits are described further down.

Demo only, not a medical device. The scans come from the public MIDRC-RICORD-1A research dataset.

Slice viewer: the full series

All 255 axial slices of the RICORD series, segmented with the pipeline below. Drag the slider or use the arrow keys to move through the scan; toggle the infection overlay.

CT slice, lung window Infection overlay

Slice 128 of 255

-% of lung infected
95% CI -
Slice index-
Lung voxels-
Infected voxels-
Slice HU min / mean / max-

Precomputed with the segmentation pipeline described below; slice masks use the same fixed metric (infected voxels inside the lung mask). The interval is a Wilson score interval for the infected-voxel fraction, computed in your browser from the slice counts.

Pipeline run, step by step

The production pipeline in the repo (pipeline/) walks a DICOM tree, converts each series to NIfTI, and writes a versioned Parquet manifest that every downstream step reads. This is the actual run behind this page, stage by stage, with the real values from its artifacts. Click a stage to inspect it.

Ingest: DICOM → NIfTI → manifest

pipeline/ingest.py (pydicom → nibabel) converted the series and wrote manifest.parquet under contract manifest/1.0:

FieldValue
Series in manifest1
Slices255
HU range over the volume[-3024, 1857]
Series UID1.2.826.0.1.3680043.10.474.419639…
PatientMIDRC-RICORD-1A-419639-000082

Validate: schema, identity, eligibility gates

pipeline/validate.py (pandera + the eligibility rule) checked the manifest against its contract and wrote validation_report.json:

1series checked
1eligible
0quarantined
0validation failures

Rejected series are quarantined with a machine-readable reason instead of being silently dropped.

Eligibility: is this series analyzable?

A series enters the analysis table only if all four of these hold:

  1. Modality is CT.
  2. The series description does not name a localizer, scout, or topogram survey.
  3. ImageType is ORIGINAL: no derived or secondary reformats, MIPs, or minIPs.
  4. The acquisition geometry is axial (ImageOrientationPatient near the canonical [1,0,0,0,1,0]).

Verdict on this series: eligible axial diagnostic CT.

The rule is deliberately conservative: a series that cannot be proven axial and diagnostic is rejected. A rejected series can be whitelisted explicitly with a written reason, never by weakening the defaults.

Quantify: the fixed metric

pipeline/quantify.py segmented lungs (HU [-950,-300]) and infection (HU [-700,-200]); the numerator counts infected voxels inside the lung mask, so the percentage can never exceed 100:

682,603infected voxels
7,043,692lung voxels
9.69%infected, 95% CI [9.67, 9.71]
FieldValue
Lung band[-950, -300] HU
Infection band[-700, -200] HU
Code hashbfc77cf3e707
Git SHAd28bbc0
Run20261005T221101Z

Refresh: versioned results

pipeline/refresh.py stamped the results as quantification_v20261005T221101Z: every refresh carries its timestamp, code hash, and git SHA, so any number on this page can be traced back to the exact code that produced it. The repo carries 30 tests, DVC stages, a Dockerfile, and CI for the pipeline.

How the pipeline works

The segmentation runs in three stages, all with fixed, interpretable parameters:

  1. Lung mask. Keep voxels between -950 and -300 HU, fill holes, compute a distance map, run a watershed split, keep the largest region, and smooth with a median filter of radius 5. This is the standard cheap lung segmentation; it can leak into bowel gas or miss collapsed lung.
  2. COVID lesion mask. Inside the lung mask, keep voxels between -700 and -200 HU, then median-filter with radius 2 to drop specks. This band covers ground-glass opacity, the hallmark COVID pattern on CT, but it also catches vessels, partial-volume edges, and motion blur. A threshold cannot tell those apart.
  3. Quantification. Infection percentage = infected voxels ÷ lung voxels × 100, per slice and for the whole scan. The original version of this project divided by the wrong denominator and reported values over 100%; that bug is fixed here and documented in the repo.
Sample 1 with infection overlay
Sample 1, infection overlay
Sample 2 with infection overlay
Sample 2, infection overlay
Sample 3 with infection overlay
Sample 3, infection overlay
Sample 4 with infection overlay
Sample 4, infection overlay

On the full scan (255 slices, one patient) the fixed method finds 9.69% of lung volume affected. Full analysis, figures, and code: github.com/nepalanurag/Biomedical-Imaging-Analysis.

Data: MIDRC-RICORD-1A (RSNA International COVID-19 Open Radiology Database), via The Cancer Imaging Archive, CC BY-NC 4.0. One COVID-positive chest CT series used here.

Try your own image

Upload a lung-window CT slice (grayscale PNG or JPEG). The page shows it and gives a rough on-screen estimate by inverting the standard lung window back to approximate Hounsfield units. This is approximate: there is no lung mask in the browser, so treat it as a sketch, not a measurement. The viewer above carries the real segmented results.

Validated against radiologist annotations

The MIDRC-RICORD-1A collection ships radiologist-drawn annotations of infectious opacities (MD.ai format, annotator Team 1). 142 of the 255 slices in this series carry 346 annotation polygons. Scoring the pipeline's prediction against them:

0.71Dice score
0.55IoU
142annotated slices

Dice measures overlap between the method's mask and the radiologist's drawings: 1.0 is perfect agreement, 0 is none. 0.71 is a solid result for plain HU thresholding with no learned model. The annotations also drove a real fix: the lung mask was losing infection near the pleura, and repairing it with morphological closing lifted Dice from 0.67 to 0.71.

Radiologist annotation vs method prediction, slice 141
Slice 141. Green: radiologist annotation. Red: predicted, not annotated. Blue: annotated, missed.
Radiologist annotation vs method prediction, slice 163
Slice 163. Green: radiologist annotation. Red: predicted, not annotated. Blue: annotated, missed.
Radiologist annotation vs method prediction, slice 165
Slice 165. Green: radiologist annotation. Red: predicted, not annotated. Blue: annotated, missed.

One series, one annotator team: these numbers describe this scan, not COVID CT in general. Reproduce them with python evaluate_annotations.py in the repo (needs the annotations JSON from the TCIA MIDRC-RICORD-1A collection page; see the main README).

Across patients

Infection percentages were computed for every scan series in the study set. Series reporting above 100% are an artifact of the old denominator bug and are dropped, matching the original analysis. That leaves 206 series from 109 patients.

A note on the numbers: the chart below shows the legacy study table, where series above 100% were dropped to match the original analysis. The repo now also carries a curated version of that table (infection_quantification_by_subject_curated.csv): the new series-eligibility rule keeps 154 axial diagnostic series and excludes 75 scout, localizer, and reformat series. Four kept rows still carry values above 100% from the pre-consolidation numerator; they are kept as-is and flagged for recomputation on the next refresh, not silently dropped. Details in the curation panel under Production pipeline.

-series kept
-patients
-mean infection
-median infection

Distribution of infection percentage across the 206 kept series. Data: infection_quantification_by_subject.csv in the repo.

Production pipeline

The method above now runs inside a production ingestion pipeline in the repo (pipeline/). It walks a DICOM tree, converts each series to NIfTI, and writes a versioned Parquet manifest that every downstream step reads. Validation gates check the schema contract, the de-identification pattern, and the series-eligibility rule before anything is quantified. Rejected series are quarantined with a machine-readable reason instead of being silently dropped, and every refresh is stamped with a timestamp, code hash, and git SHA. The repo carries 30 tests, DVC stages, a Dockerfile, and CI for the pipeline.

DICOM tree
→
ingest.pypydicom → nibabel
→
manifest.parquetcontract manifest/1.0
→
validate.pypandera + eligibility
→
quantify.pycontract results/1.0
→
refresh.pyversioned results

Validation on the real series

End-to-end run on the 255-slice RICORD series used in this demo:

1series checked
1eligible
0quarantined
0validation failures

Whole-scan result: 9.69% of lung volume infected, 95% CI [9.67, 9.71], from 682,603 infected voxels out of 7,043,692 lung voxels.

Series curation

The eligibility rule was applied to the legacy 229-row study table. 75 series were excluded as non-diagnostic, leaving 154 axial diagnostic series:

DecisionSeriesReason
Excluded27coronal reformats
Excluded26sagittal reformats
Excluded15scout / localizer surveys
Excluded5bolus-tracking (smart-prep) series
Excluded2maximum-intensity projections
Kept, flagged4values above 100% from the pre-consolidation numerator; kept as-is, flagged for recomputation on the next refresh
Kept, flagged96uninformative series description; kept, flagged for review
Kept54pass all checks clean

Curated table and curation log: infection_quantification_by_subject_curated.csv and analysis/curation_log.csv in the repo.

HU-band sensitivity sweep

The pipeline re-ran quantification over 13 threshold configurations (18.6 minutes wall time): the project-standard bands plus one-at-a-time variations of each lung and infection band edge. The headline 9.69% sits inside a sweep range of 0.24% to 14.47%. Click a bar to inspect that configuration.

13configurations
9.69%baseline
0.24–14.47%sweep range

Select a configuration

Click any bar to see its threshold bands, mean infection percentage, and runtime.

The infection band's upper edge is the dominant lever: raising it pulls dense consolidation into the mask, lowering it restricts the mask to ground glass. The lung band edges move the denominator and the mask together, so their effect is smaller, except at the extreme. Setting the lung lower bound to −1000 collapses the metric to 0.24%, because background padding floods the lung mask. That configuration is a documented failure mode, not a plausible alternative.

One-at-a-time sweep results
One-at-a-time variations around the standard bands.
Infection-band grid heatmap
3×3 grid over the infection band.
ConfigurationMean infection %Seconds
baseline (standard bands)9.6978.9
lung_lo = −10000.2490.0
lung_lo = −90010.3399.5
lung_hi = −3509.4194.2
lung_hi = −2509.8993.1
infection band [−750, −250]12.6485.6
infection band [−750, −200]13.4782.1
infection band [−750, −150]14.4789.3
infection band [−700, −250]8.9276.4
infection band [−700, −150]10.6166.3
infection band [−650, −250]6.4483.0
infection band [−650, −200]7.14129.8
infection band [−650, −150]7.9748.0

Every configuration is a full pipeline run with its code hash stamped; cost scales with configurations × series × segmentation time. Sweep code: pipeline/ai_sweep.py in the repo.

AI second reader

A vision model (Gemini 2.5 Flash) was shown the same four infection-overlay slices as the earlier dry-run study and asked to judge four known failure modes of threshold segmentation. Its reads are uncalibrated hypotheses, not ground truth, and they were checked against the analyst's own written takeaways:

Infection overlay, slice 83
Slice 83
Infection overlay, slice 137
Slice 137
Infection overlay, slice 205
Slice 205
Infection overlay, slice 225
Slice 225
Failure modeVerdictSeverityWhat the model saw
Vessel misclassification present moderate Small red voxels tracing pulmonary vessels in all four images, most prominent in slices 83 and 205.
Missed dense consolidation above the HU band present high On slice 225 the mask misses the denser central part of the large right-lung consolidation, stopping at the band edge.
Partial-volume edges absent low No clear partial-volume artifacts hugging the lung boundaries; peripheral red areas look like true pathology. This diverges from the heuristic proxy, which had flagged them.
Pleural-edge noise absent low No infection-labeled voxels touching the image border in any of the four images.

Agreement with the analyst's takeaways: the method-level points (T1–T5, T7) cannot be judged from overlays either way; T6 (documented disagreement modes versus radiologist polygons) was partially reproduced: the model flagged the missed consolidation and vessel misclassification but did not reproduce the partial-volume edge finding. Full report: pipeline/data/disagreement_report_vlm.md in the repo.

Analyze your own scan

Upload a zip of DICOM files from a chest CT series and the real pipeline (ingest, validation, eligibility, quantification) runs on it in the cloud. A full 255-slice series takes a few minutes; you get the infection percentage with its confidence interval, the validation outcome, and infection overlay images back.

Limits: zip up to 200 MB, at most 1,000 files, 10 analyses per visitor per day. The zip must contain DICOM files (checked by header). Your upload is stored in the project's cloud bucket and processed there. Demo only, not a medical device.