Skip to main content

Parse, de-identify, structure, and export radiology free-text reports to FHIR

Project description

radreport

Parse radiology free-text reports into structured data. No ML. No GPU. No dependencies.

PyPI version Python 3.9+ License: MIT

Radiology reports come out as free-text PDFs. Downstream systems — EMRs, telehealth portals, billing platforms, research pipelines — need structured data. This library bridges that gap.

Four things it does well:

  1. Parse — splits any free-text report into labeled sections, extracts measurements, links findings to anatomy
  2. Detect — flags critical/urgent findings with negation awareness (no false alerts for "no pneumothorax")
  3. De-identify — redacts PHI (dates, MRNs, names, contact info…) with a full audit trail, so reports can leave a controlled environment for research
  4. Export — outputs FHIR R4 DiagnosticReport resources ready for any EMR

Install

pip install radreport

Zero required dependencies. Works on Python 3.9+.


Quick Start

from radreport import ReportParser, CriticalFindingsDetector, FHIRExporter
import json

report_text = """
INDICATION: Chest pain, rule out PE.

FINDINGS:
Lungs: Filling defect in the right main pulmonary artery consistent with
pulmonary embolism. No pneumothorax.

IMPRESSION:
Pulmonary embolism, right main pulmonary artery. Urgent correlation recommended.
"""

# 1. Parse
parser = ReportParser()
report = parser.parse(report_text, modality="CT")

print(report.impression)
# → "Pulmonary embolism, right main pulmonary artery. Urgent correlation recommended."

# 2. Detect critical findings
detector = CriticalFindingsDetector()
report = detector.detect(report)

for cf in report.critical_findings:
    if not cf.negated:
        print(f"[{cf.severity.upper()}] {cf.term} ({cf.category})")
        print(f"  Context: {cf.context}")
# → [CRITICAL] pulmonary embolism (pulmonary)
#     Context: Filling defect in the right main pulmonary artery consistent with pulmonary embolism.

# 3. Export to FHIR
exporter = FHIRExporter()
fhir = exporter.export(report, patient_id="pt-001")
print(json.dumps(fhir, indent=2))

CLI

After installation, the radreport command is available for single-file and batch processing:

# Parse a single report to JSON
radreport report.txt

# Parse with critical findings detection
radreport report.txt --critical

# Export as FHIR DiagnosticReport
radreport report.txt --fhir --patient-id pt-001 --modality CT

# Extract follow-up recommendations too
radreport report.txt --critical --recommend

# Redact PHI before parsing (safe to store/share the output)
radreport report.txt --deidentify --critical

# Batch process multiple files → JSON array
radreport reports/*.txt --critical -o batch.json

# Specify modality for all files
radreport *.txt --modality MRI --fhir -o fhir_batch.json

# Flat CSV for research/analytics (one row per report)
radreport reports/*.txt --critical --recommend --format csv -o cohort.csv

Flags:

Flag Short Description
--modality MOD -m CT, MRI, XR, US, NM, PET …
--critical -c Run critical findings detection
--recommend -r Extract follow-up imaging recommendations
--deidentify -d Redact PHI (dates, MRN, names, phone…) before parsing
--fhir -f Export as FHIR R4 DiagnosticReport (implies --critical)
--patient-id ID FHIR Patient resource ID (used with --fhir)
--format FMT --fmt Output format: json (default) or csv (not compatible with --fhir)
--output FILE -o Write output to file instead of stdout

Parsing

Sections

The parser recognizes standard radiology report sections regardless of formatting style:

Section key Matched headers
indication Indication, Clinical Indication, History, Reason for Exam
technique Technique, Procedure, Protocol
comparison Comparison, Prior Study, Previous
findings Findings, Observations
impression Impression, Conclusion, Assessment, Diagnosis
recommendation Recommendation, Follow-up, Advised
report = parser.parse(text, modality="MRI")

findings = report.get_section("findings")
print(findings.raw_text)

impression = report.get_section("impression")
print(impression.raw_text)

Measurements

All measurements are extracted and normalized to millimeters:

for m in report.all_measurements:
    print(f"  Raw: {m.raw}")
    print(f"  Normalized (mm): {m.dimensions_mm}")
    print(f"  Largest dimension: {m.largest_dimension_mm} mm")

# Raw: 2.3 x 1.8 cm
# Normalized (mm): [23.0, 18.0]
# Largest dimension: 23.0 mm

Handles: 1.2 x 0.8 cm, 12mm, 1.2cm, 12 x 8 x 5 mm, 1.2 x 0.8 x 0.5 cm

Findings by anatomy

findings_section = report.get_section("findings")
for finding in findings_section.findings:
    print(f"Anatomy: {finding.anatomy or 'unspecified'}")
    print(f"Text: {finding.text}")

Batch processing

reports = parser.parse_batch(list_of_texts, modality="CT")
# Returns list[ParsedReport | None] — None for empty/unparseable inputs
active = [r for r in reports if r is not None]

JSON serialization

report = parser.parse(text, modality="CT")

# As dict
d = report.to_dict()

# As JSON string (shorthand)
json_str = report.to_json()
json_str = report.to_json(indent=4)

Critical Findings Detection

Rule-based. Fully auditable. No black boxes.

Covers 45+ terms across 7 categories:

Category Examples
vascular aortic dissection, DVT, aortic aneurysm
pulmonary pulmonary embolism, PE, pneumothorax, hemothorax
neuro subdural hematoma, midline shift, intracranial hemorrhage
abdominal free air, bowel perforation, appendicitis
cardiac cardiac tamponade, pericardial effusion
spinal cord compression, cervical fracture
oncologic malignancy, metastasis, carcinoma

Negation awareness

# "No pneumothorax identified" → negated=True, won't trigger alert
# "Pneumothorax present" → negated=False, triggers alert

active = [cf for cf in report.critical_findings if not cf.negated]

Negation is scoped to the sentence and fails safe:

  • A negation in one sentence never carries into the next — "No acute hemorrhage. Large subdural hematoma is present." flags the hematoma as active.
  • When a term appears more than once, an active (non-negated) mention always wins over a negated one — "No pneumothorax at the apex. Large pneumothorax at the base." flags pneumothorax as active. A term is reported as negated only when every mention is negated. This prevents a real critical finding from being silently suppressed by an earlier "no ..." phrase.

Severity levels

  • critical — requires immediate action (PE, subdural hematoma, pneumothorax)
  • urgent — requires same-day follow-up (DVT, bowel obstruction, appendicitis)
  • significant — requires follow-up (malignancy, metastasis)

Extending the term list

from radreport.critical_findings import CRITICAL_TERMS

CRITICAL_TERMS["tension pneumothorax"] = ("pulmonary", "critical")
CRITICAL_TERMS["septic emboli"] = ("vascular", "urgent")

Follow-up Recommendations

Extract structured follow-up imaging recommendations from the recommendation and impression sections — interval, modality, and urgency.

from radreport import ReportParser, RecommendationExtractor

report = ReportParser().parse(text, modality="CT")
report = RecommendationExtractor().extract(report)

for rec in report.recommendations:
    print(rec.interval, rec.modality, rec.urgency)

# "Recommend follow-up CT in 6 months."
# → interval="6 months", modality="CT", urgency="routine"

Negation-aware: "No follow-up imaging indicated" yields no recommendation. Identical recommendations are deduplicated.


De-identification

Strip Protected Health Information (PHI) from a report before it leaves a controlled environment — for research collaboration, analytics warehouses, or off-site processing. Like everything else in this library, it is rule-based and fully auditable: every removal is a traceable regular-expression match, recorded with its original offset. No ML, no cloud NER service — the kind of thing hospital IT will actually approve.

from radreport import Deidentifier

deid = Deidentifier()
result = deid.deidentify(raw_report_text)

print(result.text)              # scrubbed report, safe to share
print(result.redaction_count)   # e.g. 11
print(result.category_counts()) # {"date": 3, "mrn": 1, "name": 2, ...}

# Audit trail — every removed span, keyed to the original text
for r in result.redactions:
    print(r.category, r.original, "→", r.replacement, f"@{r.start}:{r.end}")

What it detects

Categories map to the HIPAA Safe Harbor identifiers that are reliably matchable from text alone:

Category Examples
date 03/10/2024, 2024-03-10, March 5, 2024
age ages 90+ (94-year-old) — HIPAA requires aggregating these
ssn 123-45-6789
mrn MRN: 12345678, Medical Record Number 12345678
accession Accession: A98765432
phone (555) 123-4567, 555-123-4567
email jdoe@example.com
url http://pacs.hospital.org/...
ipv4 10.0.0.1
zip DC 20500 (ZIP after a state code)
name titled names (Dr. Jane Smith) and header fields (Patient Name: …)

Clinical content is preserved: a 6 mm nodule or a 90 mm mass is never mistaken for PHI, because age matching requires an explicit age cue.

Configuration

# Redact only dates and names, and use a custom placeholder for names
deid = Deidentifier(
    categories=["date", "name"],
    placeholders={"name": "XXXX"},
)

Scope and limitations. Rule-based de-identification is a strong first pass, not a compliance guarantee. Names that appear in free narrative without a title or header label are not caught. Always review the output before PHI leaves a controlled environment. This tool does not certify HIPAA Safe Harbor compliance.


FHIR Export

Outputs a valid FHIR R4 DiagnosticReport resource.

from datetime import datetime

fhir = exporter.export(
    report,
    patient_id="pt-001",       # Optional: links to FHIR Patient resource
    report_id="rpt-20240315",   # Optional: custom resource ID
    issued_dt=datetime.now(),   # Optional: defaults to UTC now
)

What's included

  • resourceType: DiagnosticReport
  • status: final
  • code: LOINC code matched to modality (CT, MRI, US, etc.)
  • conclusion: impression text
  • presentedForm: full report text as base64 attachment
  • contained: FHIR Observations for each active (non-negated) critical finding
  • extension: structured sections for downstream parsing
  • subject: patient reference (when patient_id provided)

Full Pipeline Example

import json
from radreport import ReportParser, CriticalFindingsDetector, FHIRExporter

parser   = ReportParser()
detector = CriticalFindingsDetector()
exporter = FHIRExporter()

def process_report(text: str, modality: str, patient_id: str) -> dict:
    report = parser.parse(text, modality=modality)
    report = detector.detect(report)

    active_criticals = [cf for cf in report.critical_findings if not cf.negated]
    if active_criticals:
        print(f"WARNING: {len(active_criticals)} critical finding(s) detected")

    return exporter.export(report, patient_id=patient_id)

fhir_json = process_report(report_text, modality="CT", patient_id="pt-001")
print(json.dumps(fhir_json, indent=2))

See examples/full_pipeline.py for a runnable end-to-end example.


Design Principles

No dependencies. The library installs with no third-party packages. This matters in hospital environments where every dependency goes through security review.

Rule-based, not ML-based. Every decision the library makes is traceable to a specific rule. No model weights, no GPU, no probabilistic outputs. Clinical teams can audit exactly why a finding was flagged.

Negation-aware. A library that can't distinguish "no pneumothorax" from "pneumothorax" is dangerous in clinical contexts. Negation detection is built into the core.

Auditable de-identification. PHI redaction runs locally with no ML and no external calls, and every removed span is logged with its original offset — so a privacy officer can review exactly what left the building and why.

FHIR-first output. Every modern EMR speaks FHIR. The export format is designed to drop into existing integrations without transformation.


Running Tests

pip install radreport[dev]
pytest tests/ -v

Roadmap

  • CLI tool for single-file and batch processing (radreport command)
  • parse_batch() API for processing lists of reports
  • to_json() convenience method on ParsedReport
  • Structured output for follow-up recommendations (RecommendationExtractor)
  • CSV export mode for research/analytics workflows (--format csv)
  • Rule-based PHI de-identification with audit trail (Deidentifier)
  • Template matching for common report types (Chest XR, CT Abdomen, MRI Brain)
  • Structured comparison / prior-study extraction (new / increased / stable / resolved)
  • Additional FHIR resource types (ImagingStudy, Condition)

Disclaimer

This library is a developer tool for structuring report text. It is not a medical device and is not intended for direct clinical decision-making. Critical findings detection is designed to assist human review workflows, not replace radiologist judgment.


License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

radreport-0.4.0.tar.gz (35.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

radreport-0.4.0-py3-none-any.whl (27.1 kB view details)

Uploaded Python 3

File details

Details for the file radreport-0.4.0.tar.gz.

File metadata

  • Download URL: radreport-0.4.0.tar.gz
  • Upload date:
  • Size: 35.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.9.6

File hashes

Hashes for radreport-0.4.0.tar.gz
Algorithm Hash digest
SHA256 f737352415c873f38c2eb95007950924dd3653475de10141a06cf2b6fc2e48f1
MD5 d059f62374810aed77e26e0dfe3c417d
BLAKE2b-256 fcd6c00df7cfb6f9c0facaf6e07943c79f2149945b8bf1ad7feabb02827e370d

See more details on using hashes here.

File details

Details for the file radreport-0.4.0-py3-none-any.whl.

File metadata

  • Download URL: radreport-0.4.0-py3-none-any.whl
  • Upload date:
  • Size: 27.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.9.6

File hashes

Hashes for radreport-0.4.0-py3-none-any.whl
Algorithm Hash digest
SHA256 a9c484118637eb5a121f406872e8ee504b96beb0b1e05e35e33458293246dc71
MD5 831810321fa13fc10daa420bfeb0de08
BLAKE2b-256 c6accdf17b1b65dbcd42d15d96a55fefef292c540b30d3a246be2bc2bdfeaa3d

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page