Extracting lab tables from documents and scans
Lab results arrive as tables: a column of test names, a value column, units,
a reference range, and an abnormal flag (H/L/Crit). To use them downstream you
must recover that grid from a PDF, scan, or spreadsheet into clean rows — then
code each test to LOINC, normalize units with UCUM, and flag abnormals.
This skill is the intake step: it OCRs/parses the table on-device with
openmed.multimodal, de-identifies any embedded PHI, and emits structured rows.
It pairs before OpenMed's clinical helpers — the LOINC/UCUM coding and the
high/low/critical flag are downstream (see parsing-lab-values).
When to use
- You have a lab report as a scanned image / photo / PDF page and need the
panel as rows, not pixels.
- The source is a CSV/TSV export and you need columns classified (which is
the value, the unit, the range, the flag) and PHI columns redacted.
- You need machine-readable rows to feed LOINC mapping and a FHIR
Observation/DiagnosticReport.
What OpenMed gives you here
openmed.multimodal ships the intake primitives (no heavy deps at import; the
OCR backend loads lazily):
openmed.multimodal.ocr.ocr(image, engine=...) → an OcrResult whose .words
are OcrWord(text, bbox, confidence, page) and .text is the joined string.
OcrResult.to_document() bridges each word (with its pixel bbox) into an
ExtractedDocument so detected PHI can project back to the source location.
read_table(...) → a TableView (headers, rows, delimiter,
has_header, columns) for delimited text; classify_columns(...) labels
each column; redact_table(...) → a RedactedTable with a PHI-safe manifest.
Engines: Tesseract (pip install "openmed[multimodal]" + the system binary) or
PaddleOCR (pip install "openmed[ocr-paddle]"). ocr() auto-selects the first
installed backend.
Quick start
from openmed.multimodal.ocr import ocr
from openmed.multimodal import read_table, classify_columns, redact_table
# A) Scanned / image lab report -> words with pixel boxes.
result = ocr("cbc_report.png") # OcrResult
for w in result.words[:5]:
print(repr(w.text), w.bbox, round(w.confidence, 2), "p", w.page)
doc = result.to_document() # ExtractedDocument; bbox preserved
# B) Delimited lab export (CSV/TSV) -> classified, PHI-redacted rows.
csv_text = (
"PatientName,Test,Value,Unit,RefRange,Flag\n"
"Jane Roe,Hemoglobin,9.1,g/dL,12.0-15.5,L\n"
"Jane Roe,Glucose,148,mg/dL,70-99,H\n"
)
view = read_table(csv_text) # TableView
view = classify_columns(view) # tag PHI vs data columns
redacted = redact_table(view) # RedactedTable: PatientName redacted
for row in redacted.rows:
print(row) # name column masked; lab data intact
for col in redacted.manifest: # PHI-safe per-column audit manifest
print(col["column_name"], col["assigned_class"], col["action"])
For an OCR'd (image) table, you reconstruct the grid yourself from word
boxes (next section) — OCR yields positioned words, not a delimited table.
Workflow
- Detect the source type. CSV/TSV →
read_table. Image/scan → ocr().
PDF/DOCX are not directly parseable (they raise UnsupportedDocumentError);
render PDF pages to images first, or extract their text layer, then OCR.
- OCR with positions.
ocr() returns OcrWords carrying bbox and page.
Keep the boxes — they let you cluster words into rows/columns and project PHI
redaction back to pixels.
- Reconstruct the grid. Cluster words by their
bbox y into rows, by x
into columns. The header row names the columns; align body cells to those x
bands. Confidence (OcrWord.confidence) flags shaky cells for review.
- Identify the lab columns. Map headers to roles: test name, value,
unit, reference range, flag. For delimited input,
classify_columns
tags PHI columns (name/MRN/DOB) so redact_table masks them.
- De-identify embedded PHI. Patient name/MRN often sit in the table header or
a leading column. Redact those columns (
redact_table) and run free-text
cells through openmed.deidentify before the rows leave the device.
- Emit structured rows
{test, value, unit, ref_range, flag} per result and
hand off to LOINC/UCUM coding and parsing-lab-values.
Hand-off to / from OpenMed
- To
parsing-lab-values (openmed.clinical.parse_reference_range,
derive_abnormal_flag): pass the parsed value + ref_range (+ any explicit
lab flag) to get a structured low/normal/high/critical signal.
- To
mapping-loinc: code each test name to a LOINC code; normalize the unit
with UCUM. OpenMed emits the row; the terminology binding is out-of-process.
- To FHIR (
exporting-to-fhir): each row becomes an Observation
(code=LOINC, valueQuantity with UCUM unit, referenceRange,
interpretation) grouped under a DiagnosticReport.
- De-identify with
deidentifying-clinical-text (openmed.deidentify)
before export. OCR words carry pixel boxes so redaction maps back to the image.
- Everything here runs on-device; no scan or row leaves the process
un-de-identified.
Edge cases & gotchas
- PDF/DOCX raise
UnsupportedDocumentError. The multimodal dispatcher has no
PDF/DOCX handler — rasterize PDF pages to PNG (or pull the text layer) before
calling ocr(). Image formats (PNG/JPG/TIFF/…) and CSV/TSV are handled.
- OCR returns words, not a table. You must reconstruct rows/columns from
bbox geometry. Multi-line cells, wrapped test names, and merged header cells
break naive x/y bucketing — tune the clustering tolerance per template.
- Reference ranges are easy to mis-split. "12.0-15.5", "<5", "70 - 99", and
en/em dashes must survive OCR and tokenization as one cell. Don't let a space
or a misread dash fracture the range —
parse_reference_range downstream
expects it whole.
- Units belong to the value, not the range. Keep "9.1 g/dL" and the range
"12.0-15.5" in separate fields; the value's unit must match the range's unit or
the abnormal flag will be wrong (the flag helper is unit-agnostic).
- Low-confidence cells. Gate on
OcrWord.confidence; a 0.4-confidence value
in a lab table is a patient-safety risk — route it to human review, don't
silently accept it.
- PHI hides in tables. Patient name, MRN, DOB, and accession numbers commonly
occupy the header or first column. Classify and redact them; never log the raw
table.
- Engine availability.
ocr() raises a clear MissingDependencyError if no
backend is installed — install Tesseract or PaddleOCR per the extras.
Standards & references
1---2name: extracting-lab-tables3description: Detects and extracts tabular laboratory panels from PDFs, scans, and images into structured rows ready for OpenMed and FHIR. Use when the user has a CBC, CMP, lipid panel, or other lab report as a scanned image / PDF / spreadsheet and needs the test name, value, unit, reference range, and abnormal flag as clean rows. Trigger keywords: lab table extraction, lab panel, OCR labs, table detection, layout analysis, header detection, reference range column, abnormal flag column, LOINC, UCUM, CBC, CMP, structured labs. Pairs before OpenMed: OCR/parse the table on-device (openmed.multimodal.ocr.ocr, read_table), de-identify embedded PHI with openmed.deidentify, then hand structured rows to LOINC/UCUM mapping and openmed.clinical lab flagging. Image/CSV/TSV intake is supported; PDF/DOCX raise UnsupportedDocumentError — render those to images or text first.4license: Apache-2.05---67# Extracting lab tables from documents and scans89Lab results arrive as **tables**: a column of test names, a value column, units,10a reference range, and an abnormal flag (H/L/Crit). To use them downstream you11must recover that grid from a PDF, scan, or spreadsheet into clean rows — then12code each test to **LOINC**, normalize units with **UCUM**, and flag abnormals.1314This skill is the **intake** step: it OCRs/parses the table on-device with15`openmed.multimodal`, de-identifies any embedded PHI, and emits structured rows.16It pairs **before** OpenMed's clinical helpers — the LOINC/UCUM coding and the17high/low/critical flag are downstream (see `parsing-lab-values`).1819## When to use2021- You have a lab report as a **scanned image / photo / PDF page** and need the22 panel as rows, not pixels.23- The source is a **CSV/TSV** export and you need columns classified (which is24 the value, the unit, the range, the flag) and PHI columns redacted.25- You need machine-readable rows to feed LOINC mapping and a FHIR26 `Observation`/`DiagnosticReport`.2728## What OpenMed gives you here2930`openmed.multimodal` ships the intake primitives (no heavy deps at import; the31OCR backend loads lazily):3233- `openmed.multimodal.ocr.ocr(image, engine=...)` → an `OcrResult` whose `.words`34 are `OcrWord(text, bbox, confidence, page)` and `.text` is the joined string.35 `OcrResult.to_document()` bridges each word (with its pixel bbox) into an36 `ExtractedDocument` so detected PHI can project back to the source location.37- `read_table(...)` → a `TableView` (`headers`, `rows`, `delimiter`,38 `has_header`, `columns`) for delimited text; `classify_columns(...)` labels39 each column; `redact_table(...)` → a `RedactedTable` with a PHI-safe `manifest`.4041Engines: Tesseract (`pip install "openmed[multimodal]"` + the system binary) or42PaddleOCR (`pip install "openmed[ocr-paddle]"`). `ocr()` auto-selects the first43installed backend.4445## Quick start4647```python48from openmed.multimodal.ocr import ocr49from openmed.multimodal import read_table, classify_columns, redact_table5051# A) Scanned / image lab report -> words with pixel boxes.52result = ocr("cbc_report.png") # OcrResult53for w in result.words[:5]:54 print(repr(w.text), w.bbox, round(w.confidence, 2), "p", w.page)5556doc = result.to_document() # ExtractedDocument; bbox preserved5758# B) Delimited lab export (CSV/TSV) -> classified, PHI-redacted rows.59csv_text = (60 "PatientName,Test,Value,Unit,RefRange,Flag\n"61 "Jane Roe,Hemoglobin,9.1,g/dL,12.0-15.5,L\n"62 "Jane Roe,Glucose,148,mg/dL,70-99,H\n"63)64view = read_table(csv_text) # TableView65view = classify_columns(view) # tag PHI vs data columns66redacted = redact_table(view) # RedactedTable: PatientName redacted6768for row in redacted.rows:69 print(row) # name column masked; lab data intact70for col in redacted.manifest: # PHI-safe per-column audit manifest71 print(col["column_name"], col["assigned_class"], col["action"])72```7374For an OCR'd (image) table, you reconstruct the grid yourself from word75boxes (next section) — OCR yields positioned words, not a delimited table.7677## Workflow78791. **Detect the source type.** CSV/TSV → `read_table`. Image/scan → `ocr()`.80 PDF/DOCX are **not** directly parseable (they raise `UnsupportedDocumentError`);81 render PDF pages to images first, or extract their text layer, then OCR.822. **OCR with positions.** `ocr()` returns `OcrWord`s carrying `bbox` and `page`.83 Keep the boxes — they let you cluster words into rows/columns and project PHI84 redaction back to pixels.853. **Reconstruct the grid.** Cluster words by their `bbox` *y* into rows, by *x*86 into columns. The header row names the columns; align body cells to those x87 bands. Confidence (`OcrWord.confidence`) flags shaky cells for review.884. **Identify the lab columns.** Map headers to roles: *test name*, *value*,89 *unit*, *reference range*, *flag*. For delimited input, `classify_columns`90 tags PHI columns (name/MRN/DOB) so `redact_table` masks them.915. **De-identify embedded PHI.** Patient name/MRN often sit in the table header or92 a leading column. Redact those columns (`redact_table`) and run free-text93 cells through `openmed.deidentify` before the rows leave the device.946. **Emit structured rows** `{test, value, unit, ref_range, flag}` per result and95 hand off to LOINC/UCUM coding and `parsing-lab-values`.9697## Hand-off to / from OpenMed9899- **To** `parsing-lab-values` (`openmed.clinical.parse_reference_range`,100 `derive_abnormal_flag`): pass the parsed value + `ref_range` (+ any explicit101 lab flag) to get a structured low/normal/high/critical signal.102- **To** `mapping-loinc`: code each test name to a LOINC code; normalize the unit103 with UCUM. OpenMed emits the row; the terminology binding is out-of-process.104- **To** FHIR (`exporting-to-fhir`): each row becomes an `Observation`105 (`code`=LOINC, `valueQuantity` with UCUM `unit`, `referenceRange`,106 `interpretation`) grouped under a `DiagnosticReport`.107- **De-identify** with `deidentifying-clinical-text` (`openmed.deidentify`)108 before export. OCR words carry pixel boxes so redaction maps back to the image.109- Everything here runs **on-device**; no scan or row leaves the process110 un-de-identified.111112## Edge cases & gotchas113114- **PDF/DOCX raise `UnsupportedDocumentError`.** The multimodal dispatcher has no115 PDF/DOCX handler — rasterize PDF pages to PNG (or pull the text layer) before116 calling `ocr()`. Image formats (PNG/JPG/TIFF/…) and CSV/TSV are handled.117- **OCR returns words, not a table.** You must reconstruct rows/columns from118 `bbox` geometry. Multi-line cells, wrapped test names, and merged header cells119 break naive x/y bucketing — tune the clustering tolerance per template.120- **Reference ranges are easy to mis-split.** "12.0-15.5", "<5", "70 - 99", and121 en/em dashes must survive OCR and tokenization as one cell. Don't let a space122 or a misread dash fracture the range — `parse_reference_range` downstream123 expects it whole.124- **Units belong to the value, not the range.** Keep "9.1 g/dL" and the range125 "12.0-15.5" in separate fields; the value's unit must match the range's unit or126 the abnormal flag will be wrong (the flag helper is unit-agnostic).127- **Low-confidence cells.** Gate on `OcrWord.confidence`; a 0.4-confidence value128 in a lab table is a patient-safety risk — route it to human review, don't129 silently accept it.130- **PHI hides in tables.** Patient name, MRN, DOB, and accession numbers commonly131 occupy the header or first column. Classify and redact them; never log the raw132 table.133- **Engine availability.** `ocr()` raises a clear `MissingDependencyError` if no134 backend is installed — install Tesseract or PaddleOCR per the extras.135136## Standards & references137138- LOINC — universal lab observation codes: https://loinc.org/139- UCUM — Unified Code for Units of Measure: https://ucum.org/140- HL7 FHIR R4 Observation (`valueQuantity`, `referenceRange`, `interpretation`): https://hl7.org/fhir/R4/observation.html141- HL7 FHIR R4 DiagnosticReport (lab grouping): https://hl7.org/fhir/R4/diagnosticreport.html142- Tesseract OCR: https://github.com/tesseract-ocr/tesseract143- PaddleOCR: https://github.com/PaddlePaddle/PaddleOCR144- OpenMed source: `openmed/multimodal/ocr.py`, `openmed/multimodal/tabular_csv.py`.