Skip to content

Why a CT pixel is not automatically HU

Tonight · ~25 min · read + a 5-line Python check · energy: low · setup: optional Python

You read one CT voxel and the integer in the file is 37. You reasonably assume the tissue is 37 HU. It is not — it is −987 HU. This lesson is about the most common silent error in quantitative CT pipelines, and the two tags that prevent it.

The anchor: 37 ≠ 37

Hounsfield units are a defined scale, but scanners do not store HU directly. Most CT scanners store unsigned 16-bit integers with an offset, so that the value 0 lands near −1000 HU and air stays representable without a sign bit. To recover the physical HU you must apply a linear transform fixed by exactly two DICOM tags:

HU = stored_pixel × RescaleSlope + RescaleIntercept

With the common default on many scanners — RescaleSlope = 1, RescaleIntercept = −1024:

stored pixel = 37
HU = 37 × 1 + (−1024) = −987

So the integer 37 is air-adjacent tissue, not soft tissue at +37. If you had thresholded “soft tissue” at > 0 using the raw stored value, you would have silently miscoded almost the entire lung.

The two tags, precisely

  • RescaleSlope (0028,1053) — usually 1, but not always (some enhanced objects and some vendors use other slopes).
  • RescaleIntercept (0028,1052) — the offset. Very commonly -1024 or -1023 on scanners that store unsigned pixels. Sometimes 0 when the pixels are already signed HU.

These two tags are the Modality LUT in DICOM terms: the linear mapping from stored value to the modality’s quantity (HU for CT). They are part of the contract the file carries alongside the pixel bytes.

Why this is the first thing to break

If you read PixelData and treat the integers as HU, every downstream step is silently wrong:

  • window/level thresholds are off by the intercept;
  • intensity discretisation for texture features bins the wrong grey levels;
  • HU clips (e.g. the common [-1000, +400] lung window before radiomics) clip at the wrong physical values;
  • any “mean tumour attenuation” you report is biased by ~1024.

The error is silent because the array looks fine — it is a valid image, just shifted. Nothing crashes. This is exactly the failure TRACE-CT’s ingestion pipeline applies first: its load_ct_volume_explicit() does volume[k] = pixel × slope + intercept while building the volume, so nothing downstream ever touches a stored pixel.

A worked example you can run

import pydicom, numpy as np
ds = pydicom.dcmread("one_slice.dcm", force=True)
arr = ds.pixel_array.astype(np.float64)
hu = arr * float(ds.RescaleSlope) + float(ds.RescaleIntercept)
print("stored min/max:", arr.min(), arr.max())
print("HU min/max:", hu.min(), hu.max())

Read the two sanity signals:

  • hu.min() should be ≈ −1000 (air, outside the body). If it is ≈ 0 or ≈ +1024, you used stored pixels as if they were HU.
  • A region you know is water/soft-tissue should sit near 0 (±a few tens).

Bridge back to the model

When that printout shows hu.min() ≈ −1000, you have just demonstrated the central distinction of this chapter: the array holds encoded intensity values, while the physical interpretation lives in metadata. The slope/intercept is the bridge between the two. Internalise this split — contract vs payload — and the rest of the chapter’s tags (and most of Chapter 2) are the same idea applied to geometry instead of intensity.

Signed vs unsigned: the other half of the trap

RescaleIntercept only does its job if you read the pixel dtype correctly. Two more tags decide that:

  • PixelRepresentation (0028,0103)0 = unsigned, 1 = signed.
  • BitsAllocated (0028,0100) — typically 16.

A file with unsigned uint16 pixels and RescaleIntercept = −1024 stores air as a small positive integer. The same anatomy on a scanner storing signed int16 might already be near −1000 with RescaleIntercept = 0. pydicom’s pixel_array respects these tags for you, but if you ever read raw PixelData bytes you must honour them yourself or you will reinterpret the bits and shift everything by ~32768.

Stop and think — then reveal

You apply pixel × slope + intercept to one slice and hu.min() comes out near −1024, not −1000, but air should be ≈ −1000. Is something wrong?

Not necessarily. Air is approximately −1000 by convention, and the intercept (−1024) plus a stored air value of 0 gives −1024 exactly; real air regions often read anywhere in the −1000 to −1024 band depending on the scanner and the reconstruction. What would be wrong is hu.min() near 0 or a large positive number — that means you skipped the slope/intercept (or misread the dtype). The check is “does the minimum land in the air band?”, not “is it exactly −1000?”.

What to retain

  1. The stored pixel is an encoded integer; HU = stored × RescaleSlope + RescaleIntercept. Never use stored pixels as HU.
  2. RescaleSlope/RescaleIntercept are the Modality LUT — the intensity part of the file’s contract. Default slope is often 1 and intercept often −1024, but read them, never assume.
  3. Pair them with PixelRepresentation and BitsAllocated so you interpret the bits correctly; pydicom does this for pixel_array, but not for raw PixelData bytes.
  4. The pattern “payload holds encoded values; metadata holds the decoding” generalises — the geometry tags of Chapter 2 are the spatial version of the same idea.

Next: even after a correct HU conversion, one acquisition can yield several different images — and that is a feature, not a bug, that you must learn to treat as a variable.