Code readingHow-to

Deskew curved cap text into one line for OCR

Deskew curved cap text for OCR: fit an ellipse, rotate, polar-unwrap into a straight strip. Camera math, pixel targets, and checks for a read you can trust.

LumaSpec Vision10 min read Share on LinkedIn

On this page 10 sections
  1. 01Why curved text defeats a straight-line engine
  2. 02Set the pixels before you write code
  3. 03Step 1 — find the cap and fit an ellipse
  4. 04Step 2 — rotate the crop so the seam lands on blank lacquer
  5. 05Step 3 — unwrap the ring into a straight strip
  6. 06Step 4 — read the strip and reject what you cannot trust
  7. 07Reader, smart camera or PC
  8. 08Checks before you trust the strip
  9. 09When the usual answer is wrong
  10. 10Questions

A promo or date code printed around the top of a bottle cap gives a standard OCR engine two problems at once: the text line bends around a circle, and every character sits at a different angle. A recognizer built for straight baselines returns fragments or nothing. The fix is geometric. Locate the cap, rotate the crop level, then re-sample the annular text band column by column into a straight strip sized for the engine. This guide gives you the camera resolution the job needs, the four steps with the numbers that drive each one, and the checks that catch a bad strip before your read rate pays for it.

Why curved text defeats a straight-line engine

OCR engines find text lines by their baseline, and a ring has none. On a 28 mm cap with the text ring centered at a 10 mm radius, a 2 mm character subtends 2/10 rad ≈ 11.5° of arc; fourteen such characters span 28 mm of the 62.8 mm circumference, about 160°. The first and last characters differ by roughly 160° in orientation, so no single rotation levels the line — rotating helps one character and ruins the rest. The mapping that recovers the line is polar: angle becomes the x axis, radius becomes the y axis, and the ring unrolls into a rectangle.

That same unrolled strip is the right input when the station grades the print as well as reading it. The date code print quality inspection workflow starts from exactly this kind of level, single-line image.

Set the pixels before you write code

Two numbers govern the camera. Characters need at least 30 pixels of height at the sensor, and engines read most reliably between 30 and 50; below about 20 pixels, error rates climb steeply. That is the mechanism behind why small text fails OCR. Size the optics from the cap, not from a resolution headline:

FOV = cap diameter / 0.8 # cap fills 80% of the frame px/mm = sensor pixels across / FOV m = sensor width / FOV # sensor width in mm f = WD × m / (1 + m) # WD = working distance

Worked example: a 28 mm cap, 2.5 mm characters, a 1/1.7″ sensor (about 7.4 mm wide, typical for the format) with 2560 pixels across, 120 mm stand-off. FOV = 28/0.8 = 35 mm, so px/mm = 2560/35 = 73 and the characters land at 2.5 × 73 = 183 pixels. The focal length for a 35 mm field at 120 mm works out to f = 120 × 0.211 / 1.211 ≈ 21 mm, which is not a standard C-mount size. The nearest standard lens that still holds the whole cap is 16 mm: m = 16/(120 − 16) = 0.154, FOV = 7.4/0.154 = 48 mm, px/mm = 2560/48 = 53, characters 2.5 × 53 = 133 pixels — clear of the 30-pixel floor with margin to spare. Depth of field at f/8 is about 12 mm (DOF ≈ 2·N·c·(1+m)/m², circle of confusion 0.015 mm), enough for cap-height variation. Rerun your own lens in the depth of field calculator.

How sensor width, working distance and field of view set the focal length Rays from the edges of the sensor pass through the lens and spread to the edges of the field of view. The ratio of sensor width to field of view is the magnification m.CameraField of view (FOV)Working distance (WD)≈ fsensor width slensm = s / FOVf = WD × m / (1 + m)
How sensor width, working distance and field of view set the focal length. Rays from the edges of the sensor pass through the lens and spread to the edges of the field of view. The ratio of sensor width to field of view is the magnification m.
Lens focal length, field of view and working distance
Units
Solve for
Focal length
– mm
Field of view
– mm
Working distance
– mm
Magnification
– x
Angle of view
– °
Pixel on the part
– mm

Thin-lens model: m = sensor width / field of view, f = WD × m / (1 + m). Accurate to a few percent above about 150 mm; check the lens's minimum object distance.

Find lenses with this focal length Open the full calculator

The LS-CRD-0028 matches the sensor assumed in the example: 2560 x 2048, 5 MP, 1/1.7″, global shutter, an 8 mm lens and Fast Ethernet.

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

For finer print or a tighter field, the MV-ID5120RM-08M-RBN lifts the frame to 12 MP over GigE.

Fixed code readersHikrobot MV-ID5120RM-08M-RBN Industrial Code ReaderMV-ID5120RM-08M-RBN
Interface
GigE
Resolution
12 MP
Shutter
Rolling
Lens mount
M12
$1,969.00View part

Step 1 — find the cap and fit an ellipse

Light comes before geometry: a dome light or a crossed polarizer kills the specular glare that hides ink on lacquered and metal caps. Then segment, fit, and convert:

  1. Threshold the frame. Otsu on grayscale works when the cap contrasts with its background; on colored caps, convert to HSV and threshold the cap's hue band. The mask locates the cap only.
  2. Find contours and discard those with four points or fewer — fitEllipse needs at least five points to run.
  3. Fit the ellipse to the largest surviving contour. The call returns a center, two axes and an angle in degrees.
  4. Check the axes: a top-down shot puts them within about 10% of each other. A larger gap means the camera is tilted, the ring is a true ellipse, and a polar unwrap will stretch it unevenly.
  5. Convert the scale: a 14 mm cap radius measuring 742 pixels gives 53 px/mm, matching the optics example above.
  6. Crop a square around the center with room for the outer radius plus margin.

Step 2 — rotate the crop so the seam lands on blank lacquer

The unwrap cuts the ring at its 0° direction and lays it out left to right, so anything crossing that angle splits between the two ends of the strip. Rotate the crop about the fitted center by the negative of the ellipse angle — the rotate-about-a-point affine warp is two lines in any vision library. Aim the cut: rotate until the seam falls on blank lacquer, or on the first character if the fixture controls print position. When caps arrive at random rotation, leave the seam anywhere and let the double read in Step 4 cover it.

Step 3 — unwrap the ring into a straight strip

The polar warp maps angle to x and radius to y; set it from two numbers. The output scale is s_out = target character pixels / character height = 40 / 2.5 = 16 px/mm. The strip width must sample the full circle at that scale: W = 2π × r_mid × s_out = 2π × 10 × 16 ≈ 1005, rounded to 1006 columns. The height covers the outer radius: H = 14 × 16 = 224 rows, with maxRadius passed in source pixels, 14 mm × 53 px/mm = 742. The text band at 8-12 mm now sits at rows 128 to 192; crop those 64 rows and the characters stand 40 pixels tall, inside the engine's comfort zone.

Two details decide whether this works. Keep W below the source-detail limit — 2π × 10 × 53 ≈ 3330 columns here — or the strip invents detail. And the tangential scale grows with radius: characters at the 8 mm inner edge render at 0.8× the mid-band width, the outer edge at 1.2×. Most engines read through about ±20% width variation; wider bands need a radial rescale or a split into two strips.

# img: uint8 BGR frame, 2560 x 2048, cap filling about 58% of the frame width
grey = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)          # uint8, same size as img
_, mask = cv2.threshold(grey, 0, 255,
                        cv2.THRESH_BINARY + cv2.THRESH_OTSU)   # uint8, 0 or 255
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL,
                               cv2.CHAIN_APPROX_SIMPLE)
ring = max((c for c in contours if len(c) > 4),       # fitEllipse needs >= 5 points
           key=cv2.contourArea)
center, axes, angle = cv2.fitEllipse(ring)            # center: (float x, float y) in pixels
rot = rotate_about_point(img, center, -angle)         # helper: any affine rotation about center
polar = cv2.warpPolar(rot, (1006, 224), center, 742, flags)  # flags: linear interpolation + linear polar
strip = polar[128:192]                                # uint8, 64 x 1006, the r = 8-12 mm band
text = pytesseract.image_to_string(strip, config=single_line)  # single-line segmentation mode

The test that proves it: on a frame carrying a fourteen-character string, strip.shape reports (64, 1006), ink spans about 40 rows around the strip's middle, and the recognizer returns the printed string — while the same call on the untouched crop returns fragments. Roll polar by 503 columns, read again, and the two reads together cover every start angle.

Step 4 — read the strip and reject what you cannot trust

OCR the untouched grayscale strip, never the segmentation mask.

Set the recognizer to its single-line mode, narrow the alphabet to characters the print can contain, and check the result against the known format, such as a fixed prefix or length. Before blaming the recognizer, measure character height in the strip: under 30 pixels is an optics problem, and upscaling adds no information. A typeface that still fails on a clean strip needs a trained font — the same job as the curved inner-cap OCR font training walkthrough. With random cap rotation, read the strip, read it again rolled by 503 columns, and accept only a string that matches the expected pattern; everything else goes to reject.

Reader, smart camera or PC

Where the pipeline runs is a three-way decision.

PC beside the line. The development home and the fallback for mixed cap types. It passes the string to the PLC as in the smart camera OCR string output over TCP guide; if the string belongs at the SCADA layer instead, Ignition Perspective OCR reads image text from an HMI.

Smart camera on the device. One cap type in high volume: the unwrap and read run on the camera and the reject fires in-cycle. The MV-SC5060XM-08M-WBN carries 6 MP mono frames over GigE with an 8 mm focal-length M12 lens — enough pixels for the math above. The smart cameras category lists the rest of the on-device range.

Smart cameras & vision sensorsHikrobot MV-SC5060XM-08M-WBN SC5000X series Smart Camera, 6 MP, Mono, 8 mm focal length, M12-mountMV-SC5060XM-08M-WBN
Interface
GigE
Resolution
6-10 MP
Sensor format
1/2.4"
Shutter
Rolling
$1,629.00View part

Fixed code reader, when it is a code. A barcode or DataMatrix on the cap needs no OCR: fixed code readers decode it natively, the OCR on a fixed code reader guide shows text and codes read from one image, and graded code quality follows the barcode reader verification workflow.

Checks before you trust the strip

Run these on the first article and after any change:

  • Fitted axes agree within 10%; if not, fix the camera tilt before anything else.
  • Scale from the ellipse — 742 px / 14 mm = 53 px/mm — matches the lens design within a few percent.
  • Band rows 128-192 hold all the ink with at least 4 rows of padding above and below.
  • A string printed across the 0° seam reads after the 503-column roll.
  • A deliberately rotated cap returns the same string, character for character.
  • The strip is 1006 × 224 = 225,344 pixels, about 4% of a 5 MP frame (225,344 / 5,242,880), so recognition stays light; when a cycle needs margin, tighten the band rows before shrinking characters.

When the usual answer is wrong

  • Domed caps. The unwrap assumes the print lies on a plane through the fitted center; a domed top tilts characters at the inner radius away from the lens. Stop down for depth — the same formula gives about 16 mm at f/11 and 23 mm at f/16 — then confirm the read, because diffraction softens small pixels past f/11.
  • Side-wall print. Text on a cylindrical wall is a different geometry: square the camera to the wall and the lines are straight, so the crop alone is enough and a top-face unwrap only distorts it.
  • Codes hiding as text. A DataMatrix on the cap decodes faster and verifies better than OCR; treat it as a code from the start.
  • Broken print. If ink is interrupted at 53 px/mm, no unwrap recovers it; fix the print, the light or the pixels first.

Size the station from the cap, not the catalog. Cap diameter, character height and stand-off give you the field, the focal length and the pixel budget in four lines of arithmetic — the numbers above are the template. Filter readers and cameras to those values in the camera finder, assemble the chain in the configurator, browse the rest of the text-reading guides on the code-reading topic page, or contact us with the cap diameter and character height and we will check the math against a specific model.

Questions

How do I handle caps whose print starts at a random angle each cycle?

Rotate by the fitted ellipse angle, unwrap, then read twice: once on the strip and once on the strip rolled by half its width, 503 columns in the example. Accept the read that matches the expected pattern, such as a fixed prefix or length, and reject the rest. The second pass costs one more recognition on a 64 x 1006 strip and removes the last position dependence from the line.

Why is the text narrower at the inner edge of the unwrapped strip?

A polar unwrap gives every column the same angular step, so the arc one column covers grows with radius. On the 8-12 mm band centered at 10 mm, characters render at 0.8 times the mid-band width at the inner edge and 1.2 times at the outer edge. Most engines read through that spread; wider bands should be rescaled radially or split into two strips.

Can I upscale the strip when the characters come out too small?

Interpolation adds no detail; it only smooths edges the engine then has to re-guess. When the strip carries fewer than 30 pixels of character height, fix the optics: shorten the working distance, narrow the field of view, or move to a higher-resolution sensor. Keep resizing for final normalization, when the capture already holds the detail and you are bringing character height to around 40 pixels.

Does this work for text printed on the side wall of a bottle?

Not with a top-face polar unwrap. Side-wall text only curves when the wall is viewed from above or below; square the camera to the wall and the lines are straight, so the crop alone is enough. On small diameters the wall still bends the line near the frame edges — read there with a longer stand-off, or accept the residual curve, which many engines still read.

Which light works best on shiny metal caps?

Specular glare hides ink before geometry matters. A dome light surrounds the cap with diffuse light from every direction, so lacquer reads even and ink reads dark. Where a dome will not fit, a polarizer on the lens crossed with a polarized ring light does the same job. If ink pixels vanish at random angles in the strip, fix the light before touching the unwrap parameters.