Doctor handwriting is a meme for a reason.
After analyzing 1,100+ handwritten prescriptions, I can confirm: doctor handwriting is legitimately the hardest OCR challenge in healthcare.
Harder than radiology reports (typed 90% of the time).
Harder than pathology labels (mostly printed).
Harder than patient forms (block letters encouraged).
Doctor prescriptions? Cursive. Abbreviated. Context-dependent. Written in 10 seconds while thinking about the next patient.
This is how we built an OCR pipeline that achieves 70-85% accuracy on this chaos.
Before we dive into solutions, let's understand why traditional OCR fails spectacularly on medical prescriptions.
Doctors don't write "Paracetamol 500mg". They write:
"PCM 500 BD x 3/7"
"Paracetamol 500mg, twice daily, for 3 days"
- "PCM" could be "PCM", "PGM", "POH", "ROM" (similar shapes)
- "BD" could be "BD", "BID", "RD", "PD"
- "3/7" could be "3/7", "3/1", "5/7", "3/2"
In dosage context: "1/" (1 tablet)
In frequency context: "TID" (3 times daily)
In duration context: "5/" (5 days)
| Prescription Type |
Baseline OCR Accuracy |
Main Challenge |
| Printed (rare) |
98-99% |
Easy - standard fonts |
| Typed (uncommon) |
95-98% |
Medium - occasional formatting issues |
| Clear handwriting |
80-85% |
Medium - readable but variable |
| Average doctor handwriting |
60-75% |
Hard - cursive + abbreviations |
| Rushed handwriting |
40-60% |
Very hard - illegible to humans too |
| Worst-case |
20-40% |
Nearly impossible - requires pharmacist decoding |
VaidyaAI targets the "Average doctor handwriting" category: 70-85% accuracy on real-world prescriptions.
The OCR Pipeline: 6-Stage Processing
Here's the complete pipeline that takes a prescription image and extracts structured data:
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β STAGE 1: IMAGE UPLOAD & VALIDATION β
β β’ Accept image (JPEG, PNG, PDF) β
β β’ Max size: 5MB β
β β’ Validate format & dimensions β
β β’ Store to /uploads directory β
β Time: ~100ms β
ββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β STAGE 2: IMAGE PREPROCESSING β
β β’ Resize to max 2000px (preserve aspect ratio) β
β β’ Contrast enhancement (adaptive histogram equalization) β
β β’ Noise reduction (Gaussian blur) β
β β’ Binarization (Otsu's method) β
β β’ Deskewing (rotate if tilted) β
β Time: ~500ms β
ββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β STAGE 3: BASE64 ENCODING β
β β’ Convert preprocessed image to base64 β
β β’ Prepare for Claude API payload β
β Time: ~50ms β
ββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β STAGE 4: CLAUDE API VISION CALL β
β β’ Model: claude-3-haiku-20240307 (fast & cheap) β
β β’ Input: Base64 image + structured prompt β
β β’ Output: JSON with extracted text β
β Time: 2-5 seconds β
ββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β STAGE 5: POST-PROCESSING & VALIDATION β
β β’ Parse JSON response β
β β’ Validate medicine names against formulary β
β β’ Expand abbreviations (PCM β Paracetamol) β
β β’ Standardize dosages (1tab β 1 tablet) β
β β’ Flag ambiguous entries for review β
β Time: ~200ms β
ββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β STAGE 6: STRUCTURED OUTPUT β
β β’ Return prescription as JSON β
β β’ Include confidence scores β
β β’ Highlight uncertain fields β
β β’ Store for audit trail β
β Time: ~50ms β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Stage 1: Image Upload & Validation
First, we need to accept and validate the prescription image:
function handlePrescriptionUpload() {
if (!isset($_FILES['prescription_image'])) {
return ['error' => 'No file uploaded'];
}
$file = $_FILES['prescription_image'];
if ($file['size'] > 5 * 1024 * 1024) {
return ['error' => 'File too large (max 5MB)'];
}
$allowedTypes = ['image/jpeg', 'image/png', 'image/jpg', 'application/pdf'];
if (!in_array($file['type'], $allowedTypes)) {
return ['error' => 'Invalid file type'];
}
$filename = uniqid() . '_' . time() . '.' . pathinfo($file['name'], PATHINFO_EXTENSION);
$uploadPath = '/uploads/prescriptions/' . $filename;
if (!move_uploaded_file($file['tmp_name'], $uploadPath)) {
return ['error' => 'Upload failed'];
}
return [
'success' => true,
'filepath' => $uploadPath,
'filename' => $filename
];
}
Stage 2: Image Preprocessing (The Secret Sauce)
Raw prescription images are terrible for OCR. We need to clean them up:
function preprocessPrescriptionImage($filepath) {
$image = imagecreatefromjpeg($filepath);
if (!$image) $image = imagecreatefrompng($filepath);
$width = imagesx($image);
$height = imagesy($image);
$maxDim = 2000;
if ($width > $maxDim || $height > $maxDim) {
$scale = $maxDim / max($width, $height);
$newWidth = round($width * $scale);
$newHeight = round($height * $scale);
$resized = imagecreatetruecolor($newWidth, $newHeight);
imagecopyresampled($resized, $image, 0, 0, 0, 0,
$newWidth, $newHeight, $width, $height);
imagedestroy($image);
$image = $resized;
}
imagefilter($image, IMG_FILTER_CONTRAST, -30);
imagefilter($image, IMG_FILTER_GAUSSIAN_BLUR);
imagefilter($image, IMG_FILTER_MEAN_REMOVAL);
imagefilter($image, IMG_FILTER_GRAYSCALE);
$preprocessedPath = str_replace('.jpg', '_processed.jpg', $filepath);
imagejpeg($image, $preprocessedPath, 95);
imagedestroy($image);
return $preprocessedPath;
}
Why These Preprocessing Steps?
- Resize: Large images slow down Claude API. 2000px is sweet spot for quality vs speed.
- Contrast enhancement: Makes faint text darker, easier to read.
- Gaussian blur: Removes camera noise, scanner artifacts.
- Mean removal: Sharpens edges, makes text crisper.
- Grayscale: Reduces data size, focuses on text structure.
Result: 15-20% accuracy improvement from preprocessing alone.
Stage 4: Claude API Vision Call (The Core)
This is where the magic happens. Claude's vision model reads the prescription:
function extractPrescriptionWithClaude($imagePath) {
$imageData = file_get_contents($imagePath);
$base64Image = base64_encode($imageData);
$imageType = 'image/jpeg';
if (strpos($imagePath, '.png') !== false) $imageType = 'image/png';
$apiUrl = 'https://api.anthropic.com/v1/messages';
$apiKey = getenv('ANTHROPIC_API_KEY');
$prompt = "You are a medical prescription OCR system. Extract ALL information from this handwritten prescription.
Return a JSON object with:
{
\"medicines\": [
{
\"name\": \"full medicine name\",
\"dosage\": \"dosage amount\",
\"frequency\": \"how often (e.g., twice daily)\",
\"timing\": \"when to take (e.g., after meals)\",
\"duration\": \"how many days\"
}
],
\"doctor_name\": \"prescribing doctor\",
\"patient_name\": \"patient name\",
\"date\": \"prescription date\",
\"diagnosis\": \"diagnosis if mentioned\",
\"instructions\": \"any special instructions\",
\"confidence\": \"high/medium/low\"
}
Important:
- Expand abbreviations (PCM β Paracetamol, BD β twice daily)
- If unsure, mark confidence as 'low'
- Extract ALL visible medicines
- Include dosage timing (before/after meals)
- Return ONLY valid JSON, no other text";
$ch = curl_init($apiUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'x-api-key: ' . $apiKey,
'anthropic-version: 2023-06-01'
],
CURLOPT_POSTFIELDS => json_encode([
'model' => 'claude-3-haiku-20240307',
'max_tokens' => 2000,
'messages' => [
[
'role' => 'user',
'content' => [
[
'type' => 'image',
'source' => [
'type' => 'base64',
'media_type' => $imageType,
'data' => $base64Image
]
],
[
'type' => 'text',
'text' => $prompt
]
]
]
]
]),
CURLOPT_TIMEOUT => 30
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
error_log("Claude API error: HTTP $httpCode");
return ['error' => 'OCR failed'];
}
$result = json_decode($response, true);
$extractedText = $result['content'][0]['text'];
if (preg_match('/\{.*\}/s', $extractedText, $matches)) {
$extractedText = $matches[0];
}
$prescriptionData = json_decode($extractedText, true);
return $prescriptionData;
}
Why This Prompt Works
- Explicit JSON structure: Claude knows exactly what format we need
- Abbreviation expansion: "Expand abbreviations" instruction crucial
- Confidence scoring: Lets us filter low-confidence results
- "Return ONLY valid JSON": Prevents Claude from adding explanations
Stage 5: Post-Processing & Validation
Claude's output needs validation against our medicine database:
function validatePrescription($prescriptionData, $pdo) {
$validated = $prescriptionData;
foreach ($validated['medicines'] as $index => $medicine) {
$stmt = $pdo->prepare("
SELECT name, generic_name
FROM medicines
WHERE name LIKE ? OR generic_name LIKE ?
LIMIT 1
");
$searchTerm = '%' . $medicine['name'] . '%';
$stmt->execute([$searchTerm, $searchTerm]);
$match = $stmt->fetch();
if ($match) {
$validated['medicines'][$index]['name'] = $match['name'];
$validated['medicines'][$index]['validated'] = true;
} else {
$validated['medicines'][$index]['validated'] = false;
$validated['medicines'][$index]['warning'] = 'Medicine not in formulary';
}
$frequencyMap = [
'BD' => 'Twice daily',
'TID' => 'Three times daily',
'QID' => 'Four times daily',
'OD' => 'Once daily',
'HS' => 'At bedtime',
'PRN' => 'As needed'
];
$frequency = strtoupper($medicine['frequency']);
if (isset($frequencyMap[$frequency])) {
$validated['medicines'][$index]['frequency'] = $frequencyMap[$frequency];
}
}
return $validated;
}
Real-World Accuracy Breakdown
After processing 1,100+ prescriptions, here's what we learned:
| Prescription Element |
Accuracy |
Notes |
| Medicine names |
85-90% |
Best performance - distinct words |
| Dosages |
80-85% |
Numbers easier than words |
| Frequency |
70-75% |
Abbreviations cause confusion |
| Duration |
65-70% |
"3/7" vs "3 days" ambiguity |
| Doctor name |
90-95% |
Usually printed or stamped |
| Special instructions |
50-60% |
Free text, high variability |
Common Failure Patterns
Top 5 OCR Failures
- Severe cursive: When letters connect, Claude struggles to segment
- Overwriting: Corrections on top of original text confuse the model
- Faded ink: Light pencil or old prescriptions have low contrast
- Background patterns: Prescription pads with logos/watermarks
- Non-standard abbreviations: Doctor-specific shorthand not in training data
Error Handling & Fallback Strategy
When OCR fails, we need graceful degradation:
function processPrescriptionOCR($imagePath, $pdo) {
try {
$processedPath = preprocessPrescriptionImage($imagePath);
$extracted = extractPrescriptionWithClaude($processedPath);
if (isset($extracted['error'])) {
throw new Exception($extracted['error']);
}
$validated = validatePrescription($extracted, $pdo);
$validatedCount = count(array_filter(
$validated['medicines'],
fn($m) => $m['validated'] ?? false
));
$totalCount = count($validated['medicines']);
$confidence = $totalCount > 0 ? ($validatedCount / $totalCount) * 100 : 0;
$needsReview = $confidence < 70 ||
$validated['confidence'] === 'low';
return [
'success' => true,
'data' => $validated,
'confidence' => round($confidence),
'needs_review' => $needsReview,
'review_reason' => $needsReview ?
'Low confidence extraction' : null
];
} catch (Exception $e) {
error_log("OCR failed: " . $e->getMessage());
return [
'success' => false,
'error' => $e->getMessage(),
'fallback_mode' => 'manual_entry',
'image_path' => $imagePath
];
}
}
Performance Optimization
Cost Analysis
π° OCR Cost Breakdown
- Claude Haiku API: ~βΉ0.15 per prescription image
- Image preprocessing: Negligible (server CPU)
- Database validation: Negligible (cached)
- Total cost per OCR: ~βΉ0.15
- Monthly cost (1,000 OCR): βΉ150
Compare to: Manual data entry @ βΉ5 per prescription = βΉ5,000/month
Savings: 97% cost reduction
Speed Optimization
Original pipeline: 8-12 seconds.
Optimized pipeline: 3-8 seconds.
Optimizations made:
- Async processing: Preprocessing happens while user sees loading screen
- Image size limit: 2000px max (vs 4000px originally) = 2x faster
- Haiku model: 3x faster than Sonnet with minimal accuracy loss
- Cached validation: Medicine name lookups use indexed queries
The Human-in-the-Loop Strategy
70-85% accuracy means 15-30% needs review. We handle this intelligently:
β
Review UI Design
- Side-by-side view: Original image + extracted text
- Highlighted uncertainties: Low-confidence fields marked in yellow
- Quick corrections: Click field to edit inline
- Autocomplete: Medicine names auto-suggest from formulary
- Approval workflow: One-click approve after review
Result: Manual correction takes 30-60 seconds vs 3-5 minutes for full retyping
Future Improvements: The Roadmap
Q1 2026: Fine-tuned Model
- Collect 10,000+ reviewed prescriptions
- Fine-tune Claude on our specific doctor handwriting patterns
- Target: 85-90% accuracy
Q2 2026: Multi-language Support
- Add Hindi, Telugu, Tamil script recognition
- Regional language prescription support
- Target: 70%+ accuracy on regional languages
Q3 2026: Real-time OCR
- Mobile app with live camera OCR
- Instant feedback as doctor writes
- Prevent illegible prescriptions proactively
Key Takeaways: Prescription OCR at Scale
- 70-85% accuracy achievable: On real doctor handwriting using Claude Haiku
- Preprocessing is critical: 15-20% accuracy gain from image enhancement
- Structured prompts work: Explicit JSON schema gets consistent output
- Validation catches errors: Cross-reference with formulary prevents mistakes
- Human-in-the-loop essential: Review UI for 15-30% edge cases
- Cost-effective: βΉ0.15 per OCR vs βΉ5 for manual entry (97% savings)
- 3-8 second processing: Fast enough for clinical workflows
- Graceful degradation: Fallback to manual entry when OCR fails
Perfect OCR is impossible. 70-85% accuracy with intelligent review UI is better than 0% accuracy from manual retyping. Ship the 80% solution, iterate from there.
The Bottom Line
Doctor handwriting will always be challenging. But 70-85% OCR accuracy means:
- 7-8 out of 10 medicines extracted correctly
- 30-60 seconds review time vs 3-5 minutes full retyping
- 80-90% time savings overall
- 97% cost reduction vs manual data entry
That's good enough to transform workflows. Perfect is the enemy of shipped.
See OCR in Action
Upload a handwritten prescription and watch VaidyaAI extract the data. Free trial, no credit card required.
About Dr. Daya Shankar
Dean of School of Sciences, Woxsen University | Founder, VaidyaAI
PhD in Nuclear Thermal Hydraulics from IIT Guwahati. I built VaidyaAI's OCR pipeline processing 1,100+ prescriptions with 70-85% accuracy using Claude API. I share technical implementations, performance optimizations, and real-world learnings.
Specialization: Computer vision, OCR systems, healthcare AI, production ML deployment.