Code readingHow-to

Speed up batch OCR processing: crop, hoist setup, parallelize

Speed up batch OCR processing by cropping tight grayscale ROIs, hoisting setup out of the loop and running independent calls on 4-8 CPU workers.

LumaSpec Vision10 min read Share on LinkedIn

On this page 12 sections
  1. 01Where the minutes actually go
  2. 02Step 1: measure one call before changing anything
  3. 03Step 2: crop tight, one channel, one line
  4. 04Step 3: build the setup once, write the workbook once
  5. 05Step 4: tell the engine what it will see
  6. 06Step 5: run the independent calls in parallel
  7. 07The assembled script
  8. 08Worked example: tens of minutes to about half a minute
  9. 09Verify the speedup did not change a read
  10. 10Common mistakes that give the minutes back
  11. 11When the digits live on the line, not in a folder
  12. 12Questions

A few hundred images should not take an afternoon to read. If a script OCRs digits from every image into Excel and sits there for tens of minutes, the spreadsheet is almost never the problem. The cost is the OCR engine's per-call time, multiplied by every avoidable thing the loops do around it. This guide walks the three changes that matter: crop before recognition, build the setup once, and run the independent calls in parallel. The arithmetic along the way predicts your runtime before you press enter, and the pattern fits any Python batch that calls a Tesseract-style engine through a wrapper such as pytesseract or pyocr.

The five steps, in order:

  1. Measure one call before changing anything.
  2. Crop tight, one channel, one line.
  3. Build the setup once, write the workbook once.
  4. Tell the engine what it will see.
  5. Run the independent calls in parallel.

Where the minutes actually go

Every call to a Tesseract-style engine carries a fixed cost of roughly 0.1-1 s on a modern PC, even for a small crop, because layout analysis starts from scratch each time. Pixel count multiplies that cost, and per-loop setup churn and spreadsheet saves stack minutes on top. The whole batch is therefore predictable:

t_batch ≈ N × (t_call + t_prep) / W + t_write
  • N — crops to read. The example job below is 63 images × 3 fields × 3 sets = 567 calls.
  • t_call — engine seconds per crop; step 1 measures it.
  • t_prep — seconds to open, crop and convert each image, typically 0.01-0.05 s from disk.
  • W — worker processes, up to your physical core count.
  • t_write — seconds for the single spreadsheet write at the end.

Pixel counts set the scale of t_call. A crop box such as (266, 519, 462, 563) covers 196 × 44 px = 8,624 px. A 2448 × 2048 frame is 5,015,040 px, about 580× more, so a script that OCRs the full frame three times per image pays 580× the necessary pixels, three times over. Cropping before recognition is not a refinement; it is the difference between seconds and tens of minutes.

Step 1: measure one call before changing anything

  1. Pick one representative image from the batch.
  2. Run one crop through the engine with a stopwatch around the call:
import time

from PIL import Image
import pytesseract

img = Image.open('sample.png')                       # one representative image
crop = img.crop((266, 519, 462, 563)).convert('L')   # 196 x 44 px, single channel
t0 = time.perf_counter()
text = pytesseract.image_to_string(crop, config='--psm 7')
t_call_s = time.perf_counter() - t0                  # repeat over 20 crops and average

Three numbers now decide the plan: t_call_s, your physical core count, and N. A healthy small-crop call lands between 0.1 and 1 s. If a single call takes several seconds, the input is still too large or the page mode is fighting the image; fix that first, because no amount of parallelism repairs an oversized call. The serial floor is N × t_call: 567 × 1.0 s = 567 s, about 9.5 min of pure engine time, which is where the tens of minutes start.

Step 2: crop tight, one channel, one line

Keep about 10 px of margin around the digits so no stroke clips at the border; a clipped digit is a reject no engine can rescue. Convert each crop to single-channel grayscale with convert('L') so the engine reads one channel instead of three, and threshold it with point() when the background is clean and even.

Then declare what the crop contains. The page-segmentation mode tells the engine what shape of text to expect: mode 7 means a single text line, which is exactly what a fixed digit field is. Wrappers expose it as a config flag in pytesseract and as the builder's layout parameter elsewhere — the same setting the slow pattern pins at 6 for a uniform block, just with a tighter promise. Fewer possible layouts means less work per call.

Watch digit height too. The engine's own guidance is that it works best at 300 dpi and above, so crops whose digits stand under roughly 20 px should be upscaled two to three times before recognition; the minimum font size for OCR guide gives the thresholds. This is the same ROI discipline that keeps inline reading fast: set the ROI deliberately, not by default.

Step 3: build the setup once, write the workbook once

A builder object holds configuration, not image state, so create it once before any loop and reuse it for all 567 calls; nothing per-image goes into it. Accumulate result rows in a list, build one DataFrame from the finished list, and call to_excel once at the end.

Spreadsheet saves are not free. Each save rewrites the workbook, and at around 1 s per save, saving once per image adds about 189 s to this batch — minutes bought for nothing. If you need one sheet per set, keep a single writer open and write each completed sheet once: three writes, not 189.

Build paths with pathlib instead of chained string concatenation. The path errors across operating systems article catalogs what hand-built strings break, and pathlib sorts the same files out with one glob.

Step 4: tell the engine what it will see

Two settings cost nothing and shave every call. Dark digits on a light background: set tessedit_do_invert to 0. By default the engine also analyses an inverted copy of the image, so a black-on-white digit pays for a white-on-black pass it does not need. Second, restrict the allowed characters to digits if your wrapper exposes a whitelist setting; fewer candidate characters means fewer wrong branches to score. Keep both in one config string built once at the top of the script, next to the page mode.

Step 5: run the independent calls in parallel

Every crop is independent of every other, so the batch maps cleanly onto worker processes. Use a process pool sized to your physical core count: four to eight workers on a typical development or line-side PC. Pass each worker a (path, region) pair and let it open, crop and read its own image; do not pre-open 189 images in the parent process and ship them to workers through pickle, which moves megabytes for no reason.

The assembled script

from concurrent.futures import ProcessPoolExecutor
from pathlib import Path

import pandas as pd
from PIL import Image
import pytesseract

CONFIG = '--psm 7 -c tessedit_do_invert=0'      # one line, dark digits on light ground
REGIONS = {                                     # name -> (left, top, right, bottom), px
    'field_1': (266, 519, 462, 563),
    'field_2': (286, 563, 462, 608),
    'field_3': (266, 608, 462, 652),
}

def read_one(job):
    # job is a (path, region name) tuple; each worker opens, crops and reads once
    image_path, region = job
    with Image.open(image_path) as img:
        crop = img.crop(REGIONS[region]).convert('L')
    text = pytesseract.image_to_string(crop, config=CONFIG)
    return {'file': Path(image_path).name, 'region': region, 'text': text.strip()}

if __name__ == '__main__':
    paths = sorted(Path('pic').rglob('*.png'))
    jobs = [(str(path), name) for path in paths for name in REGIONS]

    with ProcessPoolExecutor(max_workers=8) as pool:    # one worker per physical core
        rows = list(pool.map(read_one, jobs))           # 567 rows for the example batch

    df = pd.DataFrame(rows)                             # one DataFrame
    df.to_excel('results.xlsx', sheet_name='all_sets')  # one write

Against the slow pattern, the config string and region table exist once at module level, each worker opens and crops its own image, pool.map hands back all rows in input order, and the workbook is written exactly once. Swap in your own boxes and file layout; nothing else changes.

Worked example: tens of minutes to about half a minute

The job: 3 fields × 63 images × 3 sets = 567 calls. The timings are illustrative; step 1 produces your own.

  • Baseline. Measured t_call = 1.0 s per small crop. Serial engine time: 567 × 1.0 s = 567 s ≈ 9.5 min. Add a workbook save per set and any full-frame calls elsewhere in the script, and you are in the tens of minutes the slow run shows.
  • After steps 2-4. Tight grayscale crops, page mode 7, inversion off: measured t_call ≈ 0.3 s. 567 × 0.3 s = 170 s.
  • Eight workers. 170 s / 8 ≈ 21 s of engine time. Add about 5 s of process start-up (one model load per worker) and one 2 s workbook write: about 28 s end to end.

That is 567 s down to 28 s, a cut of about 20×, with identical reads. If your baseline is 0.5 s rather than 1.0 s, every term halves and the recipe stays the same.

Verify the speedup did not change a read

  1. Run the old script and the new one over the same 20 images.
  2. Compare output strings cell by cell; pool.map preserves input order, so row 1 in both files is the same crop. Require an exact match.
  3. Trace any difference to a crop box, the page mode or the whitelist — never to the worker count — and the runtime parameter checklist shows how to pin every engine setting down.
  4. Save rejected crops as annotated images for a human spot check, the way the annotated image loop does it.
  5. Run the full batch once, timed, and write the number down next to the baseline.

Common mistakes that give the minutes back

  • Crop boxes with zero margin: digits clip at the edge and reads fail. Keep about 10 px.
  • RGB crops into the engine: three channels for no gain. convert('L') first.
  • A new builder or engine handle per call: the fixed cost returns. Build once, reuse everywhere.
  • More workers than physical cores: the extras queue, and memory pressure slows the ones that run.
  • A to_excel call per image or per crop: one write at the end.
  • Pre-opening every image in the parent and pickling them to workers: pass paths, crop in the worker.
  • Threads for an engine that runs inside the Python process: the interpreter's global lock serialises them. Use processes.

When the digits live on the line, not in a folder

A Python batch is the right tool for offline folders: logged images, archived trials, inspection reports. When the same digits arrive on parts moving down a line, the job belongs on a fixed code reader or a smart camera. The crop, the engine and the output string live on the device, the PLC receives the result over Ethernet, and there is no Python loop to tune. The article OCR on a fixed code reader: multiple text strings in one image covers the same multi-field read on-device, and the camera finder filters both groups by resolution, interface and shutter.

If glare from shiny or curved labels forces polarization, the matching lens covers and light boards sit in code reader accessories.

The three readers below cover 1.3-6 MP with 4.9 mm and 8 mm focal lengths and Fast Ethernet or GigE interfaces.

Code readers & vision sensorsFixed code readers

1D/2D codes on the line

658 models · from $399.00
Fixed code readers2560 x 2048 fixed code reader, 30 fps, 60 codes/s, 8 mm lens (LS-CRD-0028)LS-CRD-0028
Interface
Fast Ethernet
Resolution
5 MP
$920.00View part
Fixed code readersHikrobot MV-IDS006M-12C-C 6 MP logistics code readerMV-IDS006M-12C-C
Interface
GigE
Resolution
6-10 MP
$719.00View part
Fixed code readersHikrobot MV-ID813M-05S-WBN-SR 1.3 MP resolution, 4.9 mm focal length, White light, SR, GigE, Static cableMV-ID813M-05S-WBN-SR
Interface
GigE
Resolution
Up to 3 MP
$719.00View part

The decision: measure t_call on 20 crops, then commit the three changes — tight single-channel crops with a fixed page mode, setup and workbook handled once, one worker per physical core. A batch of a few hundred fields should finish in tens of seconds on an ordinary PC, and the formula above names the term to blame when it does not. For digits arriving at cycle rate on a live line, skip the script and spec the reader station in the configurator, or contact the applications team with your field sizes and cycle time.

Questions

Should worker count follow logical or physical cores?

Follow physical cores. Hyperthreading exposes two logical threads per core, but the OCR engine wants real execution units, so eight workers on a four-core hyperthreaded machine gain little over four. The quick test: time 50 crops at 4 workers, then at the logical core count, and keep whichever finished first — on most office PCs that is the physical count.

Why is the first OCR call in each worker much slower?

The engine loads its language model from disk on the first call of every process. One cold call per worker runs a few hundred milliseconds to a second or two, and with eight workers that is eight cold calls in the budget — the start-up cost the worked example books at about 5 s. It is not a leak; exclude the first call from your per-call timing and move on.

Can I use threads instead of processes to parallelize OCR?

Threads work only when the library runs the engine as a separate executable: the Python side merely waits, so a thread pool overlaps calls without pickling images. When the engine is bound inside the Python process, the GIL serializes it and you need processes. Processes also parallelize your own crop and threshold code, so they are the safer default.

Will parallel workers return my results out of order?

Not with pool.map: Executor.map returns results in the order of the input list, whatever order the workers finish in. Your spreadsheet rows therefore line up with your sorted file list, as the assembled script shows. If you later submit futures individually, pair each future with its job when you collect it, or attach the file name inside the returned row as the script does.

Is the same approach workable for several thousand images?

Yes, and the arithmetic is linear: 5,000 crops at 0.3 s per call on eight workers is 5,000 × 0.3 / 8, about 190 s. Save results incrementally, one file per chunk, so an interrupted run keeps its finished work, and skip crops whose result row already exists when you resume.