Text on a production line rarely sits still or alone. A serial number lands somewhere new on every casting, a shipping label carries five fields, and plates arrive at an angle. Full-frame OCR struggles in those scenes: it merges neighboring strings, invents characters, or returns nothing. The pattern that holds up is two-stage — a detector you train finds each text-bearing region, a padded crop of that region goes to whatever OCR engine you choose, and the detection index ties every string back to its place on the part. This guide gives the steps, a backend-agnostic code pattern, and the camera math that makes the crops worth reading.
What each stage owns
The detector answers where; the recognizer answers what. Keep that boundary hard. Train the detector to return one box per readable region and name the classes by role — plate, shipping_label, serial_number, meter_display — never by content. A detector asked to read characters starves the recognizer of context and fails at both jobs.
Two-stage detect-then-recognize is also how the big OCR engines are built internally: a text detector finds each textual region, and the recognizer transcribes one crop at a time. Putting your own detector in the first seat moves that stage under your control — exactly what plates and meters need. The license plate OCR pipeline walkthrough and the digital meter capture guide both stop at this same seam.
Put the engine behind a callable you own: a function that takes a crop and returns text plus a score. Engines get replaced, licenses change, and one plant wants offline decoding while another already runs a service. The orchestration — crop, pad, clamp, join — is the part worth writing once.
The pipeline step by step
- Capture training frames at the station. Same lens, same lighting, same working distance as production. Detection accuracy collapses when training frames are sharper or better lit than the real thing.
- Annotate one box per readable region. Draw just outside the text, not around the whole part; the padding rule below adds the margin. One class per region type.
- Add background frames with no text at all. They teach the detector what an empty frame looks like; why negative images need no labels covers the mechanics.
- Train, then set the confidence floor. Start at 0.30, raise it until the first real region drops out, step back one notch and freeze. A missed region is an unread part; a false region costs one wasted call you reject on score.
-
Fix the two interfaces.
detect(frame)returns a list of[x1, y1, x2, y2, class_id, score]with float coordinates;recognize(crop)returns(text, ocr_score). Everything else is plumbing you write once. - Crop with padding and clamp to the frame. If your detector letterboxes frames to a square input, map boxes back to full-frame pixels first — input dimensions and padding shows that arithmetic.
- Read one crop per accepted detection, in order. The detection index is the join key.
- Log every failed read with its crop. Misses are your next training batch. A pipeline that cannot show its failures cannot be tuned.
Crop with the right padding
Pad every box by the larger of 8 px and 4% of its longer side. A 40 × 20 px box gets 8 px; a 546 × 328 px box gets 22 px. Padding keeps ascenders, descenders and tilted strokes inside the crop. Recognizers resample every crop to a fixed input height, so a stroke cut at the edge stays cut — no later stage can repair it.
Clamp the padded box to the frame on all four sides, round to integers, and slice rows before columns: frame[y1:y2, x1:x2]. Slicing with float coordinates or swapped axes is the most common bug in these pipelines. Flag any detection whose box already touches a frame edge before padding: detectors return out-of-frame coordinates when a region straddles the border, the crop comes out partial, and the OCR output looks confident while being wrong.
A reader you can point at any engine
# Every name below belongs to this guide. Plug your detector into `detect`
# and your OCR engine into `recognize`; neither is a third-party API name.
# frame: 2-D uint8 grayscale array. box: [x1, y1, x2, y2, class_id, score].
def crop_with_padding(frame, box, pad_px=8):
# Crop box from frame, padded and clamped to the frame edges.
height, width = frame.shape[0], frame.shape[1]
x1, y1, x2, y2 = (int(round(v)) for v in box[:4])
pad = max(pad_px, int(0.04 * max(x2 - x1, y2 - y1)))
x1, x2 = max(0, x1 - pad), min(width, x2 + pad)
y1, y2 = max(0, y1 - pad), min(height, y2 + pad)
return frame[y1:y2, x1:x2].copy # rows first, then columns
def read_detections(frame, detect, recognize, min_score=0.30):
# One record per accepted detection, joined on the detection index.
records = []
for index, box in enumerate(detect(frame)):
if box[5] < min_score:
continue
crop = crop_with_padding(frame, box)
text, ocr_score = recognize(crop)
records.append({'index': index, 'class_id': box[4], 'box': box[:4],
'detector_score': round(box[5], 3),
'text': text, 'ocr_score': ocr_score})
return records
The test that proves the padding and the join:
import numpy as np
def test_padding_and_association:
frame = np.zeros((100, 100), dtype=np.uint8)
frame[10:30, 10:50] = 200 # the label patch, 40 x 20 px
seen = []
def detect(f):
return [[10.0, 10.0, 50.0, 30.0, 1, 0.92], # keep
[70.0, 70.0, 90.0, 90.0, 2, 0.05]] # below min_score
def recognize(crop):
seen.append(crop.shape)
return 'SN-2041', 0.97
records = read_detections(frame, detect, recognize)
assert seen == [(36, 56)] # 20 + 2 x 8 rows, 40 + 2 x 8 columns
assert len(records) == 1
assert records[0]['text'] == 'SN-2041' and records[0]['index'] == 0
The two callables are the whole contract. Point recognize at a local engine, a decoding service, or OCR options already inside an HMI — the orchestration above does not change, and that is the argument for a callable over one baked-in library.
The pixel budget that picks the camera
Design so the smallest character in the crop stands at least 16 px tall, and treat 10 px as the floor. Below that line the loss is unrecoverable: a 16 px character upscales 2–3× to the recognizer's typical 32–48 px input and strokes survive, while a 10 px character stretches 3–5× and stroke edges go soft. Why small text fails covers the failure modes below that line.
Work the budget from the part, not the sensor: measure the smallest print with calipers, choose the field of view the station needs, and let the two lines above return the frame width in pixels. Any camera in the area scan cameras category that clears that count can carry both stages — the detector asks for nothing special.
When the field of view cannot shrink, frame pixels are the only lever left. The MV-CS200-10UM is a 20 MP USB3.0 camera with a 1" sensor. Count pixels across the field of view rather than megapixels alone: a larger sensor behind the same lens widens the field and hands the extra pixels straight back.
- Sensor format
- 1"
- Lens mount
- C-mount
- Interface
- USB3
- Resolution
- 16-25 MP
Worked example: 3 mm print at 600 mm on a belt
Inputs, each with its source:
- Smallest print to read: 3 mm character height, from the label drawing, confirmed with calipers on the lightest print run.
- Pixel budget: 16 px per character height, the design value above.
- Working distance: 600 mm, fixed by the machine guard over the belt.
- Belt speed: 0.5 m/s, from the line spec.
- Sensor width: about 7.4 mm, the nominal active width of a 1/1.7" sensor — the one estimate in this example.
- Camera: the LS-CMR-0166, a 12 MP USB3 area scan camera with a 4000 × 3000 resolution.
- Sensor format
- 1/1.7"
- Interface
- USB3
- Resolution
- 12 MP
- Shutter
- Rolling
The arithmetic:
- Sampling needed: 16 px ÷ 3 mm = 5.33 px/mm.
- Field of view: 4000 px ÷ 5.33 px/mm = 750 mm wide; 3000 px ÷ 5.33 px/mm = 563 mm tall.
- Magnification: m = sensor width ÷ FOV = 7.4 mm ÷ 750 mm = 0.0099.
- Focal length: f = WD × m ÷ (1 + m) = 600 mm × 0.0099 ÷ 1.0099 = 5.9 mm, so a 6 mm lens is the one to order.
- Check the real lens: m = 6 ÷ (600 − 6) = 0.0101; FOV = 7.4 ÷ 0.0101 = 733 mm; sampling = 4000 ÷ 733 = 5.46 px/mm; smallest character = 3 × 5.46 = 16.4 px. That passes with about 2% margin — keep the label zone near frame center, where the lens is sharpest.
- Exposure: one pixel spans 1 ÷ 5.46 = 0.183 mm, so half a pixel of blur at 0.5 m/s takes 0.0915 ÷ 500 = 183 µs. Fix exposure at 150 µs and light the belt hard enough to hold it.
- Crop size: a 100 × 60 mm label images at 546 × 328 px, padding is max(8, 4% × 546) = 22 px, and the recognizer receives 590 × 372 px.
The decision: a 12 MP camera, a 6 mm lens at 600 mm, 150 µs exposure, 22 px padding — every check passes for a stop-and-read station, the normal case for label inspection. One hardware split: this sensor has a rolling shutter, so a belt that never stops takes a global shutter model from the camera finder instead.
When the detector in front is the wrong answer
Dense, fixed-layout pages — forms, invoices — already carry a layout model inside full-frame OCR engines, and a detector in front of them mostly adds missed regions. Run one pass over the page and use its own segmentation.
Barcodes and 2D codes never need this pipeline: dedicated decoders find and read many symbols per frame by themselves. The LS-CRD-0028 is a 2560 × 2048 fixed code reader rated at 60 codes/s with an 8 mm lens. Even mixed labels stay a decoder problem — reading multiple barcodes in one image is iterative decoding, not detection. Browse the fixed code readers category first.
- Interface
- Fast Ethernet
- Resolution
- 5 MP
- Sensor format
- 1/1.7"
- Shutter
- Global
One text field at a fixed position needs a fixed ROI, not a detector — until the fixture drifts, and then it needs one badly. Where no PC is allowed at the station, smart cameras run detection and OCR on the device; AI versus rule-based inspection helps you pick. Short strings can even stay on a code reader entirely — OCR of several text strings on one reader image shows that route.
Checks before you ship it
- Zero missed regions over a replayed production day. A false region is one rejected call; a miss is an unread part.
- Every crop at 16 px or more over its smallest character, measured on real crops, not the drawing.
- Edge boxes padded, clamped and flagged when the box starts at the frame boundary.
- One join key: the detection index travels inside the record, and nothing sorts crops between slicing and reading.
- Exposure inside the blur budget at top belt speed — 150 µs in the example, recomputed for your line.
- Failure logging on: every empty or rejected read saved as a crop, ready to become the next training batch.
From here, size the whole station in the configurator, or bring the label, the print spec and the belt speed to our applications desk and we will run this arithmetic against your parts.
Questions
How much padding does an OCR crop need around the detection box?
On small boxes, 5–10 px; on larger boxes, about 4% of the longer side — take whichever is larger. Padding keeps ascenders, descenders and tilted strokes inside the crop, because recognizers resample whatever you hand them and a cut stroke stays cut. Always clamp the padded box to the frame, and flag detections that already touch the edge: their text may continue outside the image, and the read will look valid while being wrong.
How many pixels per character does the recognizer need?
Design for at least 16 pixels over the height of the smallest character, and treat roughly 10 pixels as the hard floor. Below that the upscale to the model's fixed input height — typically 32–48 px — becomes too aggressive: from 16 px it is a 2–3× stretch and strokes survive; from 10 px it is 3–5× and stroke edges go soft. If the budget fails, shrink the field of view or add frame pixels before changing engines.
Should the detector or the OCR engine find the text?
Use your own detector when text location varies part to part, when several fields share one frame, or when different fields need different preprocessing — plates, labels, serial numbers. Use a full-frame engine alone on dense fixed-layout pages like forms, where its layout model already segments the lines; a detector in front mostly adds missed regions. The test: if one rectangle always contains the text, you can skip the detector until fixtures drift.
How do I keep each text string attached to the right detection?
Make the detection index the only join key. Loop over the detections in order, crop, call the recognizer, and append one record carrying index, class, pixel coordinates, detector score, text and OCR score together. Never sort, filter or resize crops between slicing and reading — any reordering silently mismatches text and location. If you batch crops for speed, carry the index inside the batch record and reject the frame if any index comes back twice.
Can the same camera run detection and OCR?
Yes, whenever one field of view satisfies both budgets: wide enough that the detector sees the region wherever it appears, and dense enough to put 16 px on the smallest character. The worked example in this guide passes both at 750 mm wide on a 12 MP frame. Split into two cameras when the scene is wide but the print is tiny — a wide overview for detection and a close-up station for reading.
Do barcoded parts need this two-stage pipeline?
No. Barcodes and 2D codes have dedicated decoders that find and read many symbols per frame without a detector in front; a fixed reader rated at 60 codes/s covers a whole carton face in one exposure. Build the detect-crop-read chain for human-readable text, or when one part carries both codes and printed text and you want a single station and one set of records.


