Extracting Measurements From a Radiology Report
10 min read · updated August 11, 2026
“A 2.1 x 1.8 cm hypodense lesion, previously 1.4 x 1.2 cm” contains four numbers, two measurements, one unit that governs all of them and one value that belongs to a study performed months ago. Every one of those relationships is implicit, and a regular expression that finds numbers followed by units gets all four wrong.
The forms a measurement takes
The grammar is small, which is what makes a parser feasible. The variants you actually meet:
9 mm one dimension 2.1 x 1.8 cm two dimensions, one plane 4.2 x 3.1 x 2.8 cm three dimensions, a volume-ish description 1.4 by 1.1 cm "by" spelled out 2.1 cm x 1.8 cm unit repeated on each dimension 15 x 12 mm integers, no decimal 0.9 cm (previously 1.4 cm) a current and a prior value 4-5 mm a range, not two dimensions approximately 3 cm hedged 2.1 x 1.8 cm (series 4, image 32) with an image reference sub-centimeter no number at all
Three of those deserve separate handling in the schema rather than in the regex. A range (4-5 mm) is one dimension with uncertainty and must not be read as two dimensions — the separator is a hyphen rather than an x, and that is the only difference. A hedged measurement carries an approximation marker that changes what the number licenses. And sub-centimeter is a real, common measurement statement with no numeral, which a numeric parser drops silently, so the count of lesions you extract is lower than the count the report describes.
The image reference in parentheses is worth capturing rather than discarding. A series and image number is the coordinate that lets somebody verify the measurement against the study, and it is the radiology equivalent of a span offset — the thing that makes an extracted value checkable rather than merely present.
The unit applies backwards
In 2.1 x 1.8 cm the unit appears once, at the end, and governs both numbers. This is the single most common way a measurement parser produces a wrong answer: it attaches cm to 1.8 and leaves 2.1 unitless, and then either drops the first dimension or defaults it to millimetres.
The rule is that a trailing unit scopes leftward across the whole dimension group. And it can be overridden mid-group: 2.1 cm x 18 mm is legal and means what it says, so the parser must let an explicit unit on a dimension win over the group unit rather than assuming consistency.
Normalise everything to millimetres on the way in, and keep the printed unit and printed value beside it. Millimetres because radiological measurements are conventionally reported to a millimetre or a tenth of a centimetre, so millimetres are integral or nearly so and the normalisation does not introduce floating-point noise into a field that will later be compared for equality. Keeping the printed form because a value of 21 mm that was written as 2.1 cm should still be recognisable as what the report said.
One arithmetic check falls out for free: if a report gives both a dimension pair and a stated volume, or gives the same lesion twice in different sections, the numbers must agree once normalised. A disagreement of a factor of ten is a unit error and a factor of 2.54 is a different kind of error entirely.
Which axis was measured
A number without an axis is not a measurement of a lesion; it is a number that was near a lesion. Three conventions are worth knowing because they are not interchangeable.
For most lesions, the reported dimension is the longest diameter in the plane of measurement. For lymph nodes, the convention is the opposite: the short-axis diameter is the one reported and the one that carries meaning, because node enlargement is assessed on short axis. This is codified in the RECIST criteria used in oncology trials, which measure target lesions on the longest diameter but nodal lesions on the short axis, with different thresholds for what counts as measurable at all.
The extraction consequence is direct. A schema with a single longest_diameter_mm field, populated for every measurement in the report, silently stores the wrong axis for every node. Store dimensions as an ordered list with an explicit axis type where the report states one, plus the lesion type, and let a nodal measurement be typed as short-axis rather than coerced.
The third convention is anatomical plane. Where a report gives three dimensions it usually names or implies the planes — transverse, anteroposterior, craniocaudal — and the order is conventional rather than guaranteed. If the planes are named, extract them. If they are not, store the dimensions in printed order and do not label them, since a guessed plane label is a fabricated field.
The prior value in the same sentence
This is the trap that produces the most confidently wrong data. A radiologist reporting a change writes both numbers in one sentence:
"The right lower lobe nodule measures 9 mm, previously 6 mm." "Lesion now 2.1 x 1.8 cm (was 1.4 x 1.2 cm on 14 March)." "Decreased from 3.2 cm to 2.4 cm."
A parser that extracts every measurement in the sentence returns two measurements for one lesion, with nothing distinguishing them. Take the first and you are right twice and wrong once — the third example leads with the prior value. Take the largest and you systematically record growth as the current state on every shrinking lesion.
The fix is a comparison-cue vocabulary applied before the numbers are assigned: previously, prior, was, compared to, from ... to ..., on [date]. A measurement following one of those cues is a prior value and belongs in a prior_measurement field with the comparison date attached, not in the current one. The from X to Y construction inverts the order and needs its own rule.
Where the cue is present but ambiguous, emit both values with an explicit unresolved flag rather than choosing. A lesion measurement that a human resolves in two seconds is a far better outcome than a silently inverted trend across a corpus, and the reason is the same one the findings and impressions page makes about change statements: the relation between two studies is a field, not an adjective.
A parser, and what it refuses
A workable shape, deliberately conservative — it recognises the grammar and declines everything else rather than guessing:
import re
NUM = r"\d+(?:\.\d+)?"
UNIT = r"(?:mm|cm)"
DIM = rf"{NUM}(?:\s*{UNIT})?"
MEASUREMENT = re.compile(
rf"(?P<dims>{DIM}(?:\s*(?:x|by)\s*{DIM}){{0,2}})"
rf"\s*(?P<unit>{UNIT})?",
re.IGNORECASE,
)
PRIOR_CUE = re.compile(
r"\b(previously|prior|was|compared\s+to|from)\b", re.IGNORECASE
)
TO_MM = {"mm": 1.0, "cm": 10.0}
def parse(text):
out = []
for m in MEASUREMENT.finditer(text):
group_unit = (m.group("unit") or "").lower()
dims = []
for part in re.split(r"\s*(?:x|by)\s*", m.group("dims"), flags=re.I):
n = re.match(rf"({NUM})\s*({UNIT})?", part.strip(), re.I)
if not n:
continue
own = (n.group(2) or group_unit).lower()
if own not in TO_MM:
dims.append({"printed": part.strip(), "mm": None,
"status": "no_unit"})
continue
dims.append({"printed": part.strip(),
"mm": float(n.group(1)) * TO_MM[own],
"unit_source": "own" if n.group(2) else "group"})
prefix = text[max(0, m.start() - 40):m.start()]
out.append({
"dimensions": dims,
"is_prior": bool(PRIOR_CUE.search(prefix)),
"printed": m.group(0).strip(),
})
return outWhat it refuses matters as much as what it matches. A dimension with no unit anywhere in scope gets status: no_unit and a null millimetre value rather than a defaulted one — a status per dimension rather than per measurement, which is per-field confidence scoring applied to a parser instead of a model, and the null itself is missing required field handling. The prior-value detection is a heuristic over a forty-character prefix and is deliberately reported as a field rather than used to drop a measurement, so a wrong heuristic loses nothing. And ranges, hedges and sub-centimeter simply do not match, which means they show up as reports where the extractor found fewer measurements than the text contains — a measurable gap rather than a silent one.
That last property is the one to design for. A parser that always returns something gives you no signal about its own coverage. A parser that returns nothing on the constructions it does not understand gives you a list, and the list is your work queue.