Part VI · Case studies · Lesson 18

Case study: lungs and vessels in CT scans

Medical images are not photos. Read CT slices in Hounsfield units, choose display windows, segment the lungs with thresholds, connected components and contours, segment the vessels inside them, and screen 21 patients.

Based on: INSA TIM lab 8

Run this lessonpython 18_ct_lungs.pypython 18_ct_lungs.py --save out
Download 18_ct_lungs.py

Not a medical tool

This is an image-processing exercise, not a diagnostic method. The "vessel to lung ratio" below is a toy criterion chosen for the lab.

Computed tomography in two paragraphs#

A CT scanner sends X-rays through the body from many angles and reconstructs, slice by slice, how much each small volume absorbs them. The result is not brightness in arbitrary units but a physical quantity on an absolute scale, the Hounsfield unit (HU): air is −1000 HU, water 0 HU, soft tissue about +20 to +80, bone +400 to +1000 and more. Lungs, full of air, sit around −900 to −500 HU; blood vessels inside them around +40.

Scans are stored in medical formats (DICOM, NIfTI) that also carry the physical size of a pixel, so areas can be measured in mm². The nibabel library reads NIfTI:

def load_slice(path):
    scan = nib.load(str(path))
    hu = np.flip(scan.get_fdata(), axis=1).T          # Hounsfield units, radiological orientation
    pix_mm = scan.header["pixdim"][1:3]               # pixel size (mm) along the two image axes
    return hu, pix_mm


hu, pix_mm = load_slice(U.path("ct/slice017.nii.gz"))
print(hu.shape, hu.dtype, f"HU range [{hu.min():.0f}, {hu.max():.0f}]", "pixel (mm):", pix_mm)
(512, 512) float64 HU range [-1024, 1554] pixel (mm): [0.76171875 0.76171875]

The array is float64, from −1024 to 1554: it cannot be shown "as is" on a 0–255 screen.

Windowing#

Radiologists display a range of HU chosen for the tissue they study: a window of width \(W\) centred on a level \(L\), mapped linearly to 0–255 and clipped outside. It is the dynamic expansion of lesson 04, in physical units:

\[\text{display} = 255 \cdot \frac{\operatorname{clip}(HU,\, L - W/2,\, L + W/2) - (L - W/2)}{W}\]
Region Level Width
Lungs −600 1500
Soft tissue 50 350
Brain 40 80
Bone 400 1800
def window(hu, level, width):
    """Map [level - width/2, level + width/2] HU to [0, 255]; clip everything else."""
    lo, hi = level - width / 2, level + width / 2
    return np.round(255 * (np.clip(hu, lo, hi) - lo) / (hi - lo)).astype(np.uint8)


lung_view = window(hu, level=-600, width=1500)
soft_tissue = window(hu, level=50, width=350)
bone = window(hu, level=400, width=1800)
The same slice with four windows. The full range wastes the screen's 256 levels; the lung window shows the airways and vessels, the soft-tissue window the heart and muscles.
The same slice with four windows. The full range wastes the screen's 256 levels; the lung window shows the airways and vessels, the soft-tissue window the heart and muscles.

Segmenting the lungs#

The plan, one idea per line:

  1. Air is everything below about −320 HU — but that includes the air around the patient.
  2. The outside air touches the image border; the lungs don't. Label the connected components (lesson 09) and drop those that touch the border, and the tiny ones (bowel gas, noise).
  3. The vessels inside the lungs are not air, so they are holes in the mask. Find the outer contour of each lung and fill it.
def segment_lungs(hu, air_hu=-320, min_area_px=2000):
    air = (hu < air_hu).astype(np.uint8)                  # air and lungs are both below ~-320 HU
    # the air *around* the patient touches the image border: remove it
    n, labels, stats, _ = cv2.connectedComponentsWithStats(air, connectivity=4)
    border = set(np.unique(np.r_[labels[0], labels[-1], labels[:, 0], labels[:, -1]]))
    keep = [i for i in range(1, n) if i not in border and stats[i, cv2.CC_STAT_AREA] >= min_area_px]
    candidates = np.isin(labels, keep).astype(np.uint8) * 255
    # vessels are *not* air, so they are holes in the candidates: fill each outer contour
    contours, _ = cv2.findContours(candidates, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
    mask = np.zeros_like(candidates)
    cv2.drawContours(mask, contours, -1, 255, thickness=cv2.FILLED)
    return mask, contours

My lab version selected contours by length and then took "the 2nd to 4th largest" (to skip the body outline). It worked on most slices but depended on the order of the contours. Removing everything that touches the border expresses the intent directly and works on all 21 slices — including those where the scanner padded the image with −8192 HU outside the field of view.

Segmenting the vessels#

Inside the lungs, a vessel is a pixel above −500 HU. But the lung wall is also above −500 HU, and the filled mask includes it. Eroding the mask a few pixels first keeps only the inside:

def segment_vessels(hu, lung_mask, vessel_hu=-500, erode_iter=3):
    # erode the lung mask so the dense lung wall is not counted as a vessel
    inner = cv2.erode(lung_mask, np.ones((3, 3), np.uint8), iterations=erode_iter)
    return ((hu > vessel_hu) & (inner > 0)).astype(np.uint8) * 255


def areas_mm2(mask, pix_mm):
    return np.count_nonzero(mask) * pix_mm[0] * pix_mm[1]
Air below −320 HU; the lungs (no border contact, large, filled); dense pixels inside them — the wall is included; with the eroded mask, only vessels remain; final overlay.
Air below −320 HU; the lungs (no border contact, large, filled); dense pixels inside them — the wall is included; with the eroded mask, only vessels remain; final overlay.
lungs 153.2 cm², vessels 6.1 cm², ratio 0.040

Screening all the patients#

results = []
for path in sorted(glob.glob(str(U.path("ct/*.nii.gz")))):
    hu, pix_mm = load_slice(path)
    lungs, _ = segment_lungs(hu)
    vessels = segment_vessels(hu, lungs)
    ratio = areas_mm2(vessels, pix_mm) / areas_mm2(lungs, pix_mm)
    results.append((Path(path).name.split(".")[0], ratio, hu, lungs, vessels))

for name, ratio, *_ in results:
    flag = "  <- suspicious (>= 5%)" if ratio >= 0.05 else ""
    print(f"{name}: vessel/lung = {100 * ratio:4.1f}%{flag}")

Sort your file lists

os.listdir and glob return files in an arbitrary order. In my lab report I numbered patients by their position in the list and then reloaded slice{i+1} — so the flagged "patients" were not the ones whose ratios I had computed. sorted(glob.glob(...)) and keeping the name with the result avoid that.

Vessel-to-lung area ratio per slice. Four slices reach the (arbitrary) 5% threshold.
Vessel-to-lung area ratio per slice. Four slices reach the (arbitrary) 5% threshold.
Eight of the 21 segmentations. Slices 010, 012, 020 and 021 contain dense regions inside the lungs, which is what pushes their ratio up.
Eight of the 21 segmentations. Slices 010, 012, 020 and 021 contain dense regions inside the lungs, which is what pushes their ratio up.

Limitations. The trachea and main bronchi are air too, and in a few slices they are counted in the lung area. A threshold of −500 HU also catches any dense tissue (scars, consolidations), not only vessels. Real pipelines work in 3-D (the airway is one connected tube across slices) and increasingly with neural networks — but they still start with Hounsfield windows and connected components.

Exercises#

  1. Remove the trachea: it is the round air component closest to the image centre in the upper slices. Add a circularity test, \(4\pi A / P^2 > 0.7\), with cv2.contourArea and cv2.arcLength.
  2. Save the lung and vessel masks of every slice as PNG files next to the windowed image, for a radiologist to review.
  3. Separate the left and right lung (the two largest components) and report the ratio for each one.