System designTroubleshooting

Linear regression line following: threshold, theta wrap and PID gates

Fix a linear regression line following loop: threshold polarity, the theta wrap past 90 degrees, the magnitude gate and a safe no-line fallback.

LumaSpec Vision12 min read Share on LinkedIn

On this page 9 sections
  1. 01Why does the regression return no line on a white tape track?
  2. 02Which threshold values make the white line the only white pixels?
  3. 03How do rho and theta become one steering command?
  4. 04What magnitude gate keeps the wheels off scattered floor pixels?
  5. 05Why does the car spin on the spot when the line drops out?
  6. 06What frame rate, exposure and resolution hold the line at speed?
  7. 07Which camera runs this follower on a real AGV?
  8. 08How do you prove the fix on the bench before the wheels turn?
  9. 09Questions

A white-tape line follower that keeps losing its regression line and spins on the spot has a threshold problem, not a PID problem. The binary image maps the dark floor to white, so the line fitter consumes the floor instead of the 19 mm tape, finds nothing line-shaped, and the car.run(50, -50) fallback pivots the chassis in place. This guide fixes the fault end to end: threshold polarity, the rho-offset and theta-wrap error definitions, the magnitude fit gate, the no-line fallback, and the exposure arithmetic that keeps the fit alive at speed. You will finish with a bench test that proves each fix before the wheels turn, on an 80x60-pixel loop any embedded vision sensor runs.

Why does the regression return no line on a white tape track?

The threshold polarity is inverted: binary() maps every dark floor pixel to white and leaves the 19 mm tape black, so get_regression, which fits only the pure-white pixels, receives a broad floor blob instead of a thin line and returns no line object at all.

The mechanism runs two steps deep. The example threshold (0, 50, -24, -1, -18, 6) captures pixels whose L channel sits between 0 and 50 — the dark floor — and binary() paints exactly those pixels white. The tape, near L 90 under office light, falls outside the threshold and stays black. get_regression([(100,100,0,0,0,0)]) then fits only pure-white pixels, so the fitter is handed a floor-shaped continent instead of a thin stroke, the robust fit rejects it, and line comes back empty. With line empty, the if (line): test is false and the fallback spin runs on every frame.

The threshold is doing its own job correctly — it finds dark things. It is the wrong polarity for a fitter that consumes white pixels, which is why the symptom feels paradoxical: the dark threshold is exactly why the white line never appears. Rank the usual suspects for a dead fit in this order: threshold polarity (this case), margin too tight for the light, exposure drift moving the L values, orientation sign flips, and frame dropouts after startup. The line pattern skeletonization guide opens with the same polarity-first rule.

Which threshold values make the white line the only white pixels?

Point the bounds at the tape, not the floor: start from (60, 100, -20, 20, -20, 20) in L, A, B order, keep at least 10 L units of margin above the floor's brightest value, and confirm the result on a live binary preview before the car moves.

Measure before you guess. Freeze a frame and read values: under office light a dark floor sits near L 30 to 40 and white tape near 90, so a lower bound of 60 keeps roughly 20 units of margin on both sides. Keep the A and B bounds wide at ±20 because white is color-neutral; only the L range does the separating. If tape and floor sit within 10 to 15 L units of each other, change the light or the tape before touching code — a margin that thin flips with every lamp.

The regression's color list never changes. After binary(), the foreground is pure white, and (100,100,0,0,0,0) is that pure white written in L, A, B bounds; all the polarity work lives in THRESHOLD:

THRESHOLD = (60, 100, -20, 20, -20, 20)  # illustrative start; tune on your floor
MAG_GATE = 8                             # fit-score gate, 0-100 scale

img = sensor.snapshot().binary([THRESHOLD])
line = img.get_regression([(100,100,0,0,0,0)], robust = True)

Check the result on a live binary image preview: tape white, floor black, nothing else white. For a dark tape on a light floor, invert the roles instead — keep a dark threshold and negate the binary output, the same inversion that pulls a dark circle out for a sub-pixel centroid.

How do rho and theta become one steering command?

Two errors feed one steering sum: rho_err = abs(rho) − width/2 is the lateral offset in pixels, theta wraps past 90° by subtracting 180 to keep its sign, and the example gains 0.4 and 0.001 turn the two into one wheel differential.

Both errors are readings of one fitted line. rho is the line's perpendicular offset in the code's convention, so abs(rho) - img.width()/2 reduces it to a signed lateral error — ±40 pixels on the 80-pixel frame. theta returns unsigned over 0 to 180, so a tape leaning 10° left of vertical reads 170, not -10. The example sketch keeps two PID objects, rho_pid and theta_pid; with zero integral each is a plain proportional gain, and their sum is this arithmetic:

rho_err = abs(rho) - width / 2 # pixels; ±40 on an 80 px frame theta_err = theta - 180 when theta > 90 # else theta; degrees, ±90 output = 0.4 × rho_err + 0.001 × theta_err car.run(50 + output, 50 - output)

Without the wrap, 170° enters the PID as an enormous error and the car snaps toward the tape instead of easing; with it, 170° steers as a gentle -10°. The gain ratio matters as much as the wrap. At 0.4 and 0.001, a 10-pixel offset adds 4.0 to the output while a 10° heading error adds 0.01 — the offset term dominates by 400:1, which is why the example car recenters diligently and barely reacts to a bending tape. If heading response is missing, raise the theta gain by tens, not by thousandths.

Mounting flips change signs. The example runs with set_vflip(True) and set_hmirror(True), and abs() on rho hides a mirrored mount, but theta's sign — and therefore the steering direction — follows those flags. Rotate the camera 10° on the bench and watch theta_err move by about 10 in the direction the wheels must turn; if the correction fights the tape, swap the output sign or fix the flags. The 90-degree image rotation guide covers what orientation changes do to measurements.

What magnitude gate keeps the wheels off scattered floor pixels?

magnitude() scores how well the white pixels form a single straight line on a 0-100 scale, and the shipped gate of 8 is deliberately loose: treat it as a starting point and raise it in steps of 5 until 100 bare-floor frames all stay below the gate.

magnitude() answers a different question than line presence: not whether white pixels exist, but whether they agree on one straight line. A robust fit can still return an object over scattered floor speckle, so the if (line): test alone is not an existence test — the magnitude gate is. On the worked geometry below, a clean tape stroke is 13 pixels wide and covers roughly 800 of the frame's 4,800 pixels; that much aligned area scores high, and stray speckle does not.

Tune the gate with two logs. Slide the camera over clean track and print magnitude() for 100 frames; aim at bare floor and log 100 more; set the gate between the clusters. If the clusters overlap, fix the threshold or the lighting first — no gate rescues an ambiguous image. From the shipped gate of 8, step up in increments of 5 until the bare-floor log stays silent for all 100 frames.

A dashed tape or a reflective floor breaks one global fit into disagreeing segments; when that happens, per-region statistics take over from the single regression, the same fork worked through in the Hough line and contour statistics guide.

Why does the car spin on the spot when the line drops out?

The fallback car.run(50, -50) drives one wheel at +50 and the other at -50, which gives zero forward speed and a pure pivot, and because it fires on the first missed frame, a single dropout spins the car.

car.run(50, -50) commands equal and opposite wheel speeds: forward speed zero, pure pivot. It also fires on the first missed frame, so one specular glint or one flicker twitches the car into a spin. Debounce the loss, then choose stop or creep:

LOST_MAX = 5          # frames; about 100 ms at 50 fps
lost = 0              # frame counter, reset on every good fit

if line and line.magnitude() > MAG_GATE:
    lost = 0
    rho_err = abs(line.rho()) - img.width()/2
    theta_err = line.theta() - 180 if line.theta() > 90 else line.theta()
    output = 0.4 * rho_err + 0.001 * theta_err
    car.run(50 + output, 50 - output)
else:
    lost += 1
    if lost > LOST_MAX:
        car.run(0, 0)   # stop; or creep straight: car.run(30, 30)

Five frames at 50 fps is 100 ms of riding through single-frame dropouts; the creep option at 30/30 walks the line back into the 118 mm field. On anything carrying stock, choose the stop — a pivoting search near a rack is a collision you wrote yourself.

One dropout source sits before the loop starts: some cameras shed their first frames after a recipe load or power-up. Gate the steering on a ready signal before trusting any lost counter — the first-images-after-load fix shows the pattern.

What frame rate, exposure and resolution hold the line at speed?

Cap exposure so blur stays under a third of the line width: at 200 mm/s a 10 ms exposure smears 2 mm, about 1.4 pixels on the 1.47 mm-per-pixel geometry, while 40 ms smears 5.4 pixels and frays the fit.

Three numbers tie the camera to the floor, and the worked case is the short-range geometry of the next section — a 3.4 mm lens on a 1/3.6-inch-class sensor about 4.0 mm wide, mounted 100 mm over the floor:

FOV = sensor width × WD / f = 4.0 mm × 100 mm / 3.4 mm ≈ 118 mm scale = 118 mm / 80 px ≈ 1.47 mm/px line = 19 mm tape / 1.47 mm/px ≈ 13 px wide blur = 200 mm/s × 10 ms = 2 mm ≈ 1.4 px

That is no accident: the AGV guide sensor in the next section prints this exact combination — a 3.4 mm lens, a 1/3.6" sensor and a 100 mm focus distance.

The blur rule: keep the smear under a third of the line width, about 4.3 pixels here. A 10 ms exposure at 200 mm/s costs 1.4 pixels; drift to 40 ms and the smear is 5.4 pixels, half the tape, right when the car moves fastest. Cap exposure manually or slow down. Frame rate then sets control sampling: at 50 fps the car advances 4 mm per frame, about 2.7 pixels, while at 10 fps the step is 14 pixels, a full tape width, and corrections arrive too late to be gentle. Hold 30 fps or better; the 80x60 QQQVGA frame, 4,800 pixels total, exists to buy exactly that.

Last, lock exposure and white balance. As auto exposure hunts, the tape's L value wanders and the tuned lower bound starts missing — the live exposure and white balance control guide shows the runtime calls.

Which camera runs this follower on a real AGV?

The MV-SC2005AC-03WBN ships as an AGV guide sensor: a 3.4 mm M12 lens focused at 100 mm over a 0.5 MP 1/3.6" color sensor on Fast Ethernet — the short-range, wide-view geometry this loop wants. The steering math does not change from demo car to AGV; mounting height and lens set the pixel scale.

Smart cameras & vision sensorsHikrobot MV-SC2005AC-03WBN SC2000A AGV Guide Sensor, 0.5 MP, Color, M12-3.4 mm focal length, focus 100mmMV-SC2005AC-03WBN
Interface
Fast Ethernet
Resolution
Up to 3 MP
Sensor format
1/3.6"
Shutter
Global
$709.00View part

For a taller mast, the MV-SCA002M-03S-WBN-SR pairs a 3 mm lens with a 240 mm focus distance on a 0.3 MP mono 1/6" sensor: a wider view from higher up, and mono drops the color work from the threshold. Both print global shutter, which keeps the fit stable when the chassis pitches and the floor image shears.

Smart cameras & vision sensorsHikrobot MV-SCA002M-03S-WBN-SR SCA vision sensor, 0.3 MP, Mono, 3 mm lens, Focus distance 240 mmMV-SCA002M-03S-WBN-SR
Interface
Fast Ethernet
Resolution
Up to 3 MP
Sensor format
1/6"
Shutter
Global
$1,989.00 · quotedView and quote

Choose by mount height first, interface second: work the field you need out of the FOV formula above, then let the smart cameras category and the camera finder narrow by mount, shutter and interface. Mono is the default for tape following; color earns its cost only when the track carries colored stop markers.

How do you prove the fix on the bench before the wheels turn?

Three logs settle it before the car moves: 100 frames of rho_err, theta_err and magnitude() on the track, a 10° camera rotation that must move theta_err by about 10, and a 15 mm tape slide that must move rho_err by about 10 pixels.

  1. Fit, every frame. Wheels off, tape laid straight ahead. Run the loop and confirm line exists and magnitude() beats the gate on at least 95 of 100 frames. Fewer means a marginal threshold — fix it now, not on the moving car.
  2. Rotation check. Rotate the camera 10° about its optical axis. theta_err must move by about 10, with the sign that steers back toward the tape. A fight here means flipped orientation flags or an inverted output.
  3. Offset check. Slide the tape 15 mm sideways. rho_err must move by about 10 pixels (15 mm / 1.47 mm per pixel ≈ 10 px). Half that change means your real scale is half the assumption — remeasure the mount height.
  4. Gain check. Wheels-down at 0.2 m/s, corrections should stay small: a 10-pixel error adds 4 to one wheel and removes 4 from the other, so 50 ± 4. Outputs near full differential mean a sign or scale fault, not an aggressive tune.
  5. Loss check. Cut the tape mid-run. The car must stop, not spin, after the 5-frame debounce — about 100 ms.

Keep prevention mechanical: locked exposure and white balance, polarity re-checked after any lighting change, orientation flags set in exactly one place, tape wiped where forklifts cross it, and a ready gate before the loop trusts its first frames.

When the bench logs are clean, size the hardware chain in one pass — the configurator steps through sensor, trigger and mount:

Build the whole station

The configurator matches a camera, lens, light, light controller and cables to each other, step by step, and lists only parts that fit what you already picked.

Start with the camera

For the faults that follow this one — skeleton edges, contour splits, centroid drift on the same binary images — the vision systems topic page is where those guides live.

Questions

Can the regression fit a grayscale image without the binary pass?

Yes. Feed the grayscale image to get_regression and set its color list to the tape's gray band, keeping the A and B bounds at 0-0 because a grayscale image only carries L. The binary pass buys a cleaner, faster foreground, which matters at 50 fps; fitting the grayscale directly costs more per frame and pulls the tape-versus-floor contrast problem into one tuple.

Why no integral or derivative terms in the PID?

The example runs proportional-only because the signal is short and quantized: rho_err moves in whole pixels over roughly a ±40 range, and derivative action on that stair-step amplifies jitter at 50 fps. Add an integral only if the car holds a steady offset down long straights, starting near a tenth of the proportional gain, and watch for windup during the lost-line stop, because a wound-up term snaps the wheels when the line reappears.

What if the track is dark tape on a light floor?

Invert the roles and keep every downstream rule: threshold so the dark tape maps to white, or negate the binary output, and the rho offset, theta wrap, magnitude gate and debounce all stay as written. The regression only ever fits white pixels and never cares which object they came from. Dark tape on a bright floor often has a narrower L margin, so re-run the two-log magnitude test before trusting the gate.

Where do tape junctions and crossings send the regression?

A robust fit through a T-junction tilts toward the branch, because the regression sees one cloud of white pixels and returns its least-squares compromise. Split the frame: steer on the near half, where the tape is single, and treat the far half as a lookahead marker only. If the route must be chosen at the crossing, fit each branch separately and pick by angle and continuity.

How fast can the car go before the camera becomes the limit?

Tie speed to blur and per-frame motion, not to the sensor's maximum frame rate. On the 1.47 mm-per-pixel geometry, keep blur under about 4 pixels (a third of the 13-pixel tape width) and motion under a few pixels per frame: 200 mm/s at 50 fps costs 2.7 pixels per frame and is comfortable, while 500 mm/s at the same frame rate costs 6.8 and needs a shorter exposure, a wider tape or a taller mount.

Does a higher-resolution camera improve the follower?

Not the way people expect: resolution adds detail inside the field, not field width, and scale stays set by lens and mount height. A 5 MP sensor windowed to an 80x60 strip around the tape, the set_windowing pattern, keeps the same pixels-per-millimeter scale, trims the fit back to 4,800 pixels and preserves frame rate. Take higher resolution as margin for narrow tape or longer lookahead, not as speed.