惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Microsoft Security Blog
Microsoft Security Blog
WordPress大学
WordPress大学
Stack Overflow Blog
Stack Overflow Blog
D
DataBreaches.Net
罗磊的独立博客
博客园 - 司徒正美
Last Week in AI
Last Week in AI
The Cloudflare Blog
大猫的无限游戏
大猫的无限游戏
Microsoft Azure Blog
Microsoft Azure Blog
B
Blog RSS Feed
The GitHub Blog
The GitHub Blog
宝玉的分享
宝玉的分享
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
小众软件
小众软件
Jina AI
Jina AI
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
Hugging Face - Blog
Hugging Face - Blog
B
Blog
博客园 - 【当耐特】
V
V2EX
Apple Machine Learning Research
Apple Machine Learning Research
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
酷 壳 – CoolShell
酷 壳 – CoolShell

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
From Pixels to Prescriptions: Engineering OCR Pipelines f...
Kotha Deepak · 2026-04-28 · via DEV Community

Team Members

@k_sidharthareddy_15 | @k-deepak-544 | @nupur_madhrey_07 | @avika_kashyap | @dheerajkumar08 | @chanda_rajkumar


Introduction

So here's the thing — when We started working on MediSimplify, a project that takes medical reports and converts them into patient-friendly language, We thought the hard part would be the NLP simplification. Turns out, just getting the text out of the document was already a mini-nightmare.

Medical reports come as everything: clean PDFs, scanned images, ancient faxed documents that someone scanned and emailed. OCR tools are finicky. Tesseract might not be installed on the deployment machine. A "PDF" might be a text-selectable document or a rasterized scan — and you can't tell which until you open it. We needed something that handled all of this gracefully, without crashing or silently returning garbage.

This post walks through how we built ocr.py — the dedicated OCR service layer inside MediSimplify — and the specific decisions that made it actually reliable in a messy real-world setting.


The Problem

Medical documents are inconsistent by nature. They arrive in formats that no single extraction strategy can handle cleanly.

Images (JPG/PNG) always need OCR. PDFs might have selectable text embedded, or they might be 300 DPI scans of printed pages — you don't know until you try. And raw OCR output is noisy: double spaces, broken newlines, garbled characters everywhere. That noise degrades everything downstream, especially the simplification model.

On top of that, Tesseract isn't guaranteed to be installed wherever the backend runs. If you just call pytesseract.image_to_string() directly, any user who hasn't configured Tesseract will see a cryptic Python exception — not useful at all.

Always try the cheap path first. Embedded PDF text extraction is instant and perfect quality. OCR is slow and error-prone. Only call Tesseract when you have to — and when you do, render pages at proper DPI so the results are actually good.


Our Solution

Rather than scattering OCR logic across the codebase, we built a single ocr.py service that all file upload endpoints call through. It has one job: accept raw bytes, return clean text. Here's what it does:

  • Automatically resolves Tesseract's path from config or system PATH
  • Raises a clear, user-readable error when OCR is unavailable
  • Tries embedded PDF text first (fast path via PyMuPDF)
  • Falls back to Tesseract OCR only for scanned pages
  • Normalises whitespace so downstream NLP gets clean input


Tech Stack

  • Frontend: Next.js (App Router) + TypeScript + Tailwind CSS
  • Backend: FastAPI + PyMongo
  • Database: MongoDB
  • OCR: pytesseract + PyMuPDF
  • AI Simplification: flan-t5-small transformer with medical-term fallback
  • Auth: JWT (login/signup/logout)

Key Features

1. Resolve Tesseract's path from config or system PATH

Tesseract can be installed anywhere — a system binary, a virtualenv, a custom path set by ops. Rather than hardcoding where to look, the service checks your config first, then falls back to a system path search.

configured = (settings.tesseract_cmd or "").strip()

if configured:
    if Path(configured).exists():
        return configured
    resolved = shutil.which(configured)
    if resolved:
        return resolved

return shutil.which("tesseract")

Enter fullscreen mode Exit fullscreen mode

This means the same code works on a developer's Mac, a Docker container, and a cloud VM without any changes.


2. Fail fast when OCR isn't available

If Tesseract couldn't be found, we don't want a cryptic FileNotFoundError buried inside pytesseract. We raise a custom, user-readable exception immediately — before any processing starts.

def _require_tesseract() -> None:
    if not TESSERACT_CMD:
        raise OCRUnavailableError(
            "OCR engine is not available. Install Tesseract "
            "or upload a text-based PDF."
        )

Enter fullscreen mode Exit fullscreen mode

OCRUnavailableError is caught by FastAPI and returned as a clean 422 or 503 response with a message the user can actually act on. No stack traces leaking to the frontend.


3. Image OCR path — clean and direct

For plain image uploads (JPG, PNG, TIFF), the path is simple: convert to RGB, run Tesseract, normalise whitespace.

def _extract_text_from_image(file_bytes: bytes) -> str:
    _require_tesseract()
    image = Image.open(io.BytesIO(file_bytes)).convert("RGB")
    text = pytesseract.image_to_string(image)
    return " ".join(text.split())

Enter fullscreen mode Exit fullscreen mode

Converting to RGB first avoids issues with RGBA PNGs — the alpha channel confuses some Tesseract versions. The " ".join(text.split()) at the end collapses all whitespace variants into single spaces.


4. PDF dual-path strategy — embedded text wins

This is the most important design decision in the whole service. PDFs are a spectrum, not a type. When a PDF has selectable text baked in, PyMuPDF can extract it in milliseconds with perfect fidelity. Only when that fails do we fall back to the expensive render-and-OCR route.

for page in doc:
    page_text = page.get_text("text")
    if page_text and page_text.strip():
        embedded_text_pages.append(
            " ".join(page_text.split())
        )

if embedded_text_pages:
    return "\n".join(embedded_text_pages)

Enter fullscreen mode Exit fullscreen mode

PyMuPDF's get_text("text") returns an empty string for scanned pages, so we check for actual content before appending. If even one page has embedded text, we return early. If all pages return empty — that's a scanned document, and we move to the OCR fallback.


5. OCR fallback for scanned PDFs — render at proper DPI

For scanned PDFs, we render each page to a pixmap and run Tesseract on it. The DPI setting matters more than most people realise.

for page in doc:
    pix = page.get_pixmap(dpi=220)
    image = Image.open(
        io.BytesIO(pix.tobytes("png"))
    ).convert("RGB")
    page_text = pytesseract.image_to_string(image)
    pages.append(page_text)

Enter fullscreen mode Exit fullscreen mode

Why 220 DPI? Below 150, Tesseract struggles with small medical font sizes. Above 300, you're burning memory for minimal accuracy gain on typical scan quality. 220 is a practical sweet spot for medical documents. Each page is rendered independently so we never hold the full document in memory at once.


Overall Workflow

Step 1 — Secure upload: The user uploads a file (image or PDF) through the web dashboard. It lands at the FastAPI /reports/upload endpoint, authenticated via JWT.

Step 2 — OCR service called: The raw bytes are handed to ocr.py. The service detects the file type and chooses a path.

Step 3 — Fast path (text PDF): PyMuPDF checks each page for embedded text. If found, it's extracted and normalised immediately — no OCR needed.

Step 4 — Fallback path (scanned PDF or image): Tesseract availability is verified first. If missing, an OCRUnavailableError is raised with a clear message. If present, pages are rendered at 220 DPI and OCR'd one by one.

Step 5 — Simplification: The clean extracted text is passed to the flan-t5-small pipeline, which generates a patient-friendly explanation and highlights important medical terms.

Step 6 — Result stored: The simplified result and key terms are saved to MongoDB under the user's account. The user sees it on their dashboard.


Challenges We Faced

Challenge 1: OCR reliability depends on system setup

The problem: Tesseract behaves differently across OS, installation method, and locale settings. On macOS via Homebrew it just works. In a Debian Docker image you need specific language packs. On some cloud VMs it's not installed at all. We wasted hours debugging before realising the issue was never our code.

The fix: The _require_tesseract() gate runs before any OCR call. If Tesseract isn't there, the user gets a clean error message telling them exactly what to do — not a Python traceback. We also added Tesseract installation as a required step in our README and Dockerfile.


Challenge 2: Raw OCR output broke the simplification model

The problem: Early versions piped raw Tesseract output directly into the language model. The model kept getting confused by double newlines, hyphenated line-breaks from PDF column layouts, and random whitespace characters. Accuracy on medical term identification dropped noticeably.

The fix: Instead of cleaning up in the simplification layer, we normalise immediately in the OCR service. Every text string goes through " ".join(text.split()) before being returned. Simple, but it eliminated the most common noise patterns and gave the NLP model clean, consistent input.


Challenge 3: Some PDFs have both embedded text AND scanned pages

The problem: Hospital discharge summaries sometimes have a typed cover page and scanned test result attachments in the same PDF. Our initial strategy of "embedded text OR OCR" missed the scanned pages entirely.

The fix: We shifted from document-level to page-level decisions. Each page is checked for embedded text independently. Pages with content use fast extraction; pages without content fall through to OCR. The final result merges all pages in order — and handles hybrid documents cleanly.


What We Learned

1. OCR reliability is a system problem, not a code problem. You can write perfect Python and still get garbage output if Tesseract isn't configured right. Explicit dependency checks are essential — don't trust that the environment is set up correctly.

2. Prioritise embedded text over OCR, always. Mixed-document pipelines should attempt native extraction first. OCR is the last resort, not the default. This alone cut our average extraction time by 70% for the most common document type.

3. Normalise early, normalise once. Whitespace normalisation in the extraction layer is far better than doing it later. By the time text reaches the NLP model, it should already be clean. Downstream components shouldn't have to defend against upstream noise.

4. Error messages are UX. OCRUnavailableError with a sentence explaining what to do is infinitely more useful than a FileNotFoundError: tesseract with a stack trace. The extra 10 minutes to write a good exception class saves everyone hours of confusion later.

5. DPI isn't a detail — it's a quality lever. Rendering scanned PDFs at 72 DPI (screen resolution) gives terrible OCR results on small fonts. 220 DPI was the sweet spot between quality and memory usage for medical documents specifically. Always benchmark for your document type.


Conclusion

MediSimplify’s OCR layer is surprisingly small—around 80 lines of Python—but it handles a lot of real-world complexity. Decisions like dynamically resolving the Tesseract path, failing fast with clear errors, checking for embedded text before running OCR, and cleaning up output early all came from practical issues during development.

If you're working with user-uploaded documents, it’s worth treating OCR as a core part of your system, with proper error handling and dependency management—it makes debugging much easier later. The next step is adding preprocessing, like fixing skewed pages and reducing noise, which should noticeably improve results, especially for older, low-quality medical scans.


Try It Yourself

GitHub: https://github.com/K-Sidhartha-Reddy/MediSimplify

Demo: