Code readingHow-to

Batch OCR a folder of images in Python: loop the right filenames

Fix a Python batch loop that opens one hardcoded file instead of every image in the folder, filter by extension, and write one OCR text file per image.

LumaSpec Vision10 min read Share on LinkedIn

On this page 10 sections
  1. 01Why does the batch script die with FileNotFoundError on 'filename.jpg'?
  2. 02How do you loop over every image in the folder?
  3. 03How do you skip files that are not images?
  4. 04Do you need to threshold every pixel before OCR?
  5. 05How do you write one text file per image without collisions?
  6. 06How long will a folder of a few hundred images take?
  7. 07Why does the same script fail on a second PC?
  8. 08Can the camera read the text itself instead of the script?
  9. 09Script or on-device read: which route fits your line?
  10. 10Questions

You have a folder of saved frames — inspection shots, label photos, document scans — and you want one text file per image, produced in a single run without opening anything by hand. Then Python stops on the very first file with FileNotFoundError: [Errno 2] No such file or directory: 'filename.jpg'.

That error is not about Tesseract or your OCR settings; the loop is opening one hard-coded name instead of each file it listed. This guide fixes the loop with os.listdir and os.path.join, and filters the folder with os.path.splitext so the script never re-reads its own output.

It also replaces the per-pixel threshold pass that costs tens of seconds per frame, and works the timing arithmetic that tells you whether the batch finishes in a coffee break or runs past lunch. A working Python 3 install with Pillow and pytesseract is all you need to follow along.

Why does the batch script die with FileNotFoundError on 'filename.jpg'?

The loop never opens the files it listed: the name 'filename.jpg' is assigned to a variable once, before the loop starts, so every pass calls Image.open on that same fixed string and the very first pass fails with FileNotFoundError, [Errno 2] No such file or directory.

The broken pattern assigns the name above the loop and then ignores the loop variable:

jpgFile = 'filename.jpg'          # set once, above the loop
for filename in files:
    img = Image.open(jpgFile)     # same file on every pass -> FileNotFoundError

Two details hide the mistake. The two names differ by one character — jpgFile versus filename — so a skim reads them as the same thing. And the traceback points into Pillow's internals rather than at your loop, because the exception only surfaces when Pillow calls the operating system's open. The fix is one line: open the loop variable, joined to the folder, as the next section shows.

How do you loop over every image in the folder?

Call os.listdir(folder) once, then open os.path.join(folder, filename) for each name: os.listdir returns bare file names, and a bare name is looked up in the current working directory, so any folder other than the one Python was launched from fails.

os.listdir gives you frame_0001.jpg, not C:\Images\frame_0001.jpg. The join turns each bare name into a full path, which removes the working-directory trap on Windows and Linux alike — the same trap behind most Python image file path errors across operating systems. The batch then comes down to five steps:

  1. Install the wrapper and the engine separately: pip install pillow pytesseract covers the Python side, and the Tesseract program itself needs installing with the language packs you plan to use.
  2. Ask for the folder and strip the quotes a file explorer pastes around paths: folder = input('Folder of images: ').strip('" ').
  3. Create the output folder once, before the loop: os.makedirs(out_dir, exist_ok=True) — pass the boolean True, not a truthy string such as 'true'.
  4. Loop with for filename in os.listdir(folder):, build image_path = os.path.join(folder, filename), and apply the extension test from the next section before opening anything.
  5. Read with pytesseract.image_to_string(Image.open(image_path), lang='eng') and write the result into out_dir as root + '.txt' with encoding='utf-8'.

The complete script:

import os
from PIL import Image
import pytesseract

folder = input('Folder of images: ').strip('" ')
out_dir = os.path.join(folder, 'text')
os.makedirs(out_dir, exist_ok=True)

extensions = {'.jpg', '.jpeg', '.png', '.bmp', '.tif', '.tiff'}

n_done = 0                                    # int counter, starts at 0
for filename in os.listdir(folder):
    root, ext = os.path.splitext(filename)
    if ext.lower() not in extensions:
        continue
    image_path = os.path.join(folder, filename)
    text = pytesseract.image_to_string(Image.open(image_path), lang='eng')
    with open(os.path.join(out_dir, root + '.txt'), 'w', encoding='utf-8') as f:
        f.write(text)
    n_done += 1

print(n_done, 'images read into', out_dir)

Camera save folders add one wrinkle: capture software writes a fresh name per frame, usually a timestamp plus a frame counter, so nothing stays constant except the extension. Listing the folder is how any environment resolves those shifting names; a guide to resolving changing capture filenames covers that job in a different environment.

How do you skip files that are not images?

Filter each name through os.path.splitext before opening: keep only names whose extension — the second element of the returned pair — is in a set such as {'.jpg', '.jpeg', '.png', '.bmp', '.tif', '.tiff'}, and skip everything else.

The os.path module reference describes splitext as splitting the pathname into a pair (root, ext), with the extension either empty or starting with a period. Inside the loop the test is two lines:

root, ext = os.path.splitext(filename)
if ext.lower() not in extensions:
    continue

.lower() absorbs the .JPG that some cameras write. The filter earns its keep on a re-run: a folder that started as 312 captures and gained 25 .txt results beside them now holds 312 entries, and the test passes 312 − 25 = 287 images while skipping the rest. With outputs in their own subfolder, as below, the filter becomes the second line of defense.

Do you need to threshold every pixel before OCR?

Almost never: Tesseract thresholds the image internally, and a per-pixel loop costs 2560 × 2048 = 5,242,880 visits on a 5 MP frame — tens of seconds of pure Python per image against about 2 s for the OCR call itself.

The nested version reads and writes each of those pixels through two Python calls, getpixel and putpixel. When a scan genuinely needs a threshold, do the whole frame in one call instead:

gray = Image.open(image_path).convert('L')
bw = gray.point(lambda p: 255 if p > 110 else 0)
text = pytesseract.image_to_string(bw, lang='eng')

Image.point() runs the test per pixel inside Pillow's own code, with the same 110 cutoff the nested loop used. Keep the explicit threshold only if a side-by-side test on one saved frame shows it reading characters the original misses; otherwise delete the stage and let the engine binarize.

How do you write one text file per image without collisions?

Name each output from the root that os.path.splitext returns and write it into its own folder: out_path = os.path.join(out_dir, root + '.txt'), with out_dir created once by os.makedirs(out_dir, exist_ok=True) before the loop.

root is the input name minus its extension, so frame_0001.jpg produces frame_0001.txt. Because the results land in the text subfolder rather than beside the images, a re-run overwrites each file cleanly instead of mixing inputs and outputs. Write with encoding='utf-8': without it, Windows falls back to a locale codec and any character beyond basic ASCII can come out scrambled for the next reader.

How long will a folder of a few hundred images take?

Time one image, then multiply: in the worked example below one pass takes 2.0 s, so the 287 surviving images take 287 × 2.0 s = 574 s ≈ 9 min 34 s, while the same batch with a 40 s per-frame threshold loop runs 287 × 42 s = 12,054 s ≈ 3.3 h.

t_batch = n × t_one

t_batch and t_one are in seconds; n is the count that survives the filter. Measure t_one by wrapping the clock around the same call the loop makes:

import time

t0 = time.perf_counter()
text = pytesseract.image_to_string(Image.open(image_path), lang='eng')
t_one = time.perf_counter() - t0   # float, seconds for one image
print(round(t_one, 2), 's per image')

Worked example, with illustrative inputs: the filter leaves 287 of 312 entries (312 − 25 old results) and one pass on a desktop PC measures 2.0 s. The batch is 287 × 2.0 s = 574 s, and 574 s ÷ 60 s/min = 9.6 min, or 9 min 34 s. Re-insert the nested pixel loop at roughly 40 s per 2560 × 2048 frame and the same folder runs 287 × (40 + 2) s = 12,054 s, and 12,054 s ÷ 3,600 s/h ≈ 3.3 h. That gap is the whole case for Image.point(). When even ten minutes single-threaded is too slow, crop each image to its label region so the engine reads fewer pixels, and run several images in parallel — the batch OCR speed-up guide covers both moves and the worker setup that keeps results ordered.

Why does the same script fail on a second PC?

pytesseract is a wrapper, not the engine: the second PC needs the Tesseract program itself, the command path pointed at tesseract.exe, and the language file for lang='eng' present in tessdata, or every call returns an error instead of text.

Two settings do the moving. Point the wrapper at the engine — on Windows, the folder the standard installer creates:

pytesseract.pytesseract.tesseract_cmd = r'C:\Program Files\Tesseract-OCR\tesseract.exe'

Then match the language: lang='eng' needs eng.traineddata in the tessdata folder, lang='jpn' needs the Japanese pack, and so on for each code you pass. Missing-language failures look nothing like a missing file, so the Tesseract language data path guide is the fastest way through them. A script shipped as a packaged executable needs the same two paths set inside the bundle it carries.

Can the camera read the text itself instead of the script?

For fresh production images, yes: a code-reader device reads the text where it is captured and publishes the string over Ethernet, so the folder, the loop and the batch timing disappear for everything shot after the change.

For the back catalog already on disk, the script stays the right tool. For new production, a fixed code reader reads codes and printed strings on the device and hands the result to the PLC — reading multiple text strings on a fixed code reader shows that setup. A smart camera applies the same principle beyond codes: capture and decision in one housing, one result on the wire. Free-form text — a serial plate in an unusual font, for instance — often still suits the Tesseract engine best, and the common pattern keeps Python for offline batches while the line software calls the same engine, as in license-plate recognition working with Python OCR.

Two catalog readers cover the common label and code cases:

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
Sensor format
1/1.7"
Shutter
Global
$920.00View part
Fixed code readersHikrobot FX-5526M-16W 6 MP smart code readerFX-5526M-16W
Interface
GigE
Resolution
6-10 MP
Sensor format
1/1.8"
Shutter
Rolling
$1,759.00View part

The LS-CRD-0028 reads up to 60 codes/s from 2560 × 2048 frames through its 8 mm lens. The FX-5526M-16W is a 6 MP smart code reader on GigE with an M12 mount.

Script or on-device read: which route fits your line?

If the folder is a back catalog, run the script as written; if the text arrives fresh every shift, move the read onto the device and keep Python for offline jobs; and if the camera itself is still undecided, pick it from read distance and code type first.

Before any batch you care about: sort the names if order matters, time one image, leave the extension filter on, and open one .txt against its frame before trusting the rest. If the camera behind the folder is still to be chosen, the camera finder filters by resolution, interface and mount, build a system walks the full chain, and the code-reading topic page gathers the guides around this one, from triggering to PLC framing. A read that resists these steps is worth one sample image at the contact page.

Questions

Does os.listdir return the files in alphabetical order?

No — os.listdir returns names in arbitrary order, and that order can differ between operating systems and even between runs. When sequence matters, sort explicitly: sorted(os.listdir(folder)) walks A to Z, and sorted(os.listdir(folder), key=os.path.getmtime) walks oldest capture first. Sorting a few thousand names costs milliseconds, so it is free insurance for ordered output files.

How do you OCR the same label region on every image?

Crop to the label before reading, so the engine sees only the region that matters: Image.open(path).crop((300, 200, 900, 420)) hands over a 600 × 220 pixel window, with the four integers read as left, upper, right, lower in pixels. Measure the box once on a saved frame, keep it fixed for the whole batch, and both accuracy and per-image time improve.

Can you get each word's position and confidence, not just the text?

Yes — swap image_to_string for pytesseract.image_to_data, which returns one row per word with left, top, width, height, conf and text columns, enough to draw boxes on a saved frame or reject reads below a confidence you choose. Keep image_to_string for plain text: the data variant is slower and its output needs a line of parsing before use.

What does the psm setting change in the OCR call?

It selects the page segmentation mode Tesseract applies before reading. psm 3, the default, suits a full page of mixed layout; psm 6 assumes one uniform block and is the better match for a cropped label; psm 7 treats the whole image as a single line. In pytesseract, pass it as config='--psm 6' in the image_to_string call.

Why do empty strings come from images that clearly contain text?

Usually the image, not the loop. Text with an x-height below roughly 20 pixels, heavy JPEG compression, or inverted polarity — light text on a dark background reads worse than the dark-on-light the engine expects — all produce empty results with no exception raised. Debug one image on its own: print the returned string, check the character height in pixels, and only then look at the batch code.