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.
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:
- Parse — splits any free-text report into labeled sections, extracts measurements, links findings to anatomy
- Detect — flags critical/urgent findings with negation awareness (no false alerts for "no pneumothorax")
- De-identify — redacts PHI (dates, MRNs, names, contact info…) with a full audit trail, so reports can leave a controlled environment for research
- 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:DiagnosticReportstatus:finalcode: LOINC code matched to modality (CT, MRI, US, etc.)conclusion: impression textpresentedForm: full report text as base64 attachmentcontained: FHIR Observations for each active (non-negated) critical findingextension: structured sections for downstream parsingsubject: patient reference (whenpatient_idprovided)
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 (
radreportcommand) -
parse_batch()API for processing lists of reports -
to_json()convenience method onParsedReport - 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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f737352415c873f38c2eb95007950924dd3653475de10141a06cf2b6fc2e48f1
|
|
| MD5 |
d059f62374810aed77e26e0dfe3c417d
|
|
| BLAKE2b-256 |
fcd6c00df7cfb6f9c0facaf6e07943c79f2149945b8bf1ad7feabb02827e370d
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a9c484118637eb5a121f406872e8ee504b96beb0b1e05e35e33458293246dc71
|
|
| MD5 |
831810321fa13fc10daa420bfeb0de08
|
|
| BLAKE2b-256 |
c6accdf17b1b65dbcd42d15d96a55fefef292c540b30d3a246be2bc2bdfeaa3d
|