vlm.md
← All Recipes · Document Understanding · Intermediate

Parse Medical Lab Report Images

Extract test item names, values, units, reference ranges, and abnormal flags from blood work and biochemistry report photos — structured output for health management agents.

5/13/2026 · vlm.md · Recommended models: GPT-4oClaude 3.5 Sonnet

Scenario

Your health management agent processes lab report photos uploaded by users and extracts:

  • Each test item’s name, measured value, and unit
  • Reference range (normal interval)
  • Abnormal flag (high ↑ / low ↓ / critical)
  • Report date and ordering department

The structured output feeds into trend analysis, anomaly alerts, or summary generation for doctor consultations.

ModelWhen to use
GPT-4oStrongest table recognition; highest accuracy on CBC and metabolic panel formats
Claude 3.5 SonnetBetter semantic handling of abnormal markers; more reliable on unusual symbols like ”↑↑” or “HH”

Gemini is not recommended here — it underperforms on medical abbreviations (ALT, AST, eGFR, etc.) compared to the other two.

Prompt Template

You are a medical lab report parsing expert. Extract test results from the image and return JSON in this exact format:

{
  "report_date": "YYYY-MM-DD, or null if not found",
  "department": "Ordering department name, or null",
  "items": [
    {
      "name": "Test item name exactly as printed",
      "value": "Measured value (string)",
      "unit": "Unit string",
      "reference_range": "Reference range as printed, e.g. 3.5-5.5",
      "flag": "abnormal_high | abnormal_low | critical | normal"
    }
  ]
}

Rules:
- Preserve original item names — do not translate or abbreviate
- Keep value as a string to preserve original precision
- Set flag ONLY based on markers explicitly shown in the image (H/L, ↑↓, *, bold, red text, etc.)
- Do NOT calculate flags by comparing value to reference range yourself

Code

import anthropic
import base64
import json
import re
from pathlib import Path

client = anthropic.Anthropic()

PROMPT = """You are a medical lab report parsing expert. Extract test results from the image and return JSON in this exact format:

{
  "report_date": "YYYY-MM-DD, or null",
  "department": "Ordering department, or null",
  "items": [
    {
      "name": "Test item name exactly as printed",
      "value": "Measured value (string)",
      "unit": "Unit",
      "reference_range": "Reference range as printed",
      "flag": "abnormal_high | abnormal_low | critical | normal"
    }
  ]
}

Preserve original item names. Set flag ONLY based on markers explicitly shown in the image — do not calculate from values."""


def parse_report(image_path: str) -> dict:
    data = base64.standard_b64encode(Path(image_path).read_bytes()).decode()
    suffix = Path(image_path).suffix.lower().lstrip(".")
    media_type = {"jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png"}.get(
        suffix, "image/jpeg"
    )

    message = client.messages.create(
        model="claude-3-5-sonnet-20241022",
        max_tokens=2048,
        messages=[
            {
                "role": "user",
                "content": [
                    {
                        "type": "image",
                        "source": {"type": "base64", "media_type": media_type, "data": data},
                    },
                    {"type": "text", "text": PROMPT},
                ],
            }
        ],
    )

    raw = message.content[0].text.strip()
    raw = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw, flags=re.MULTILINE).strip()
    return json.loads(raw)


def get_abnormal_items(report: dict) -> list[dict]:
    return [
        item for item in report.get("items", [])
        if item.get("flag") in ("abnormal_high", "abnormal_low", "critical")
    ]


if __name__ == "__main__":
    report = parse_report("blood_test.jpg")
    print(json.dumps(report, indent=2))

    abnormal = get_abnormal_items(report)
    if abnormal:
        flag_labels = {
            "abnormal_high": "HIGH ↑",
            "abnormal_low": "LOW ↓",
            "critical": "CRITICAL ‼️",
        }
        print(f"\n⚠️  {len(abnormal)} abnormal result(s):")
        for item in abnormal:
            label = flag_labels[item["flag"]]
            print(f"  {item['name']}: {item['value']} {item['unit']} [{label}]")

Expected output:

{
  "report_date": "2024-03-15",
  "department": "Internal Medicine",
  "items": [
    {
      "name": "WBC",
      "value": "10.8",
      "unit": "10^9/L",
      "reference_range": "3.5-9.5",
      "flag": "abnormal_high"
    },
    {
      "name": "HGB",
      "value": "135",
      "unit": "g/L",
      "reference_range": "130-175",
      "flag": "normal"
    },
    {
      "name": "PLT",
      "value": "98",
      "unit": "10^9/L",
      "reference_range": "125-350",
      "flag": "abnormal_low"
    }
  ]
}

Gotchas

Gotcha 1: Never let the model calculate flags

An early prompt asked the model to “determine if the value is abnormal based on the reference range.” It would occasionally flag boundary values (exactly at the upper limit) as abnormal. The correct approach: only extract flags that are explicitly marked in the image (H/L, ↑↓, asterisk, bold, red text). Let the image’s own markers be the ground truth.

Gotcha 2: Phone photos have perspective distortion

Lab reports are printed on portrait A4. A phone photo taken at an angle introduces trapezoidal distortion that misaligns table columns — the model associates values with the wrong units or reference ranges. Prompt users to “hold the phone directly above the report, parallel to the page,” or apply perspective correction client-side before uploading.

Gotcha 3: Flag notation varies by hospital

One hospital uses H/L, another uses ↑↓, another uses asterisks (*), and some use bold or red text (visible as color variation in the photo). Add this line to your prompt:

"Abnormal markers in the image may appear as H/L, ↑↓, *, bold text, or color differences. Map all of them to the appropriate flag value."

Gotcha 4: max_tokens too low truncates results

A CBC has 20+ items; a comprehensive metabolic panel can have 40+. With max_tokens=512 the model truncates mid-JSON, causing a parse error. Set at least 2048. For full panels, use 4096.