Nearly every failed computer vision pilot we are asked to rescue failed before a line of code was written. The lens was chosen because it was in the drawer, the camera because procurement had a vendor account, and the first anyone measured the actual resolving power of the system was when a 0.3 mm scratch refused to show up in the images.
Optics is the one part of a vision pipeline you cannot fix in software. You can retrain a detector in an afternoon; you cannot add detail that the lens never passed to the sensor. This tutorial covers the arithmetic you should do before buying hardware, and then the OpenCV 5 code to verify, on the bench, that the system you bought actually delivers what the spreadsheet promised — using a slanted-edge SFR/MTF measurement rather than a visual opinion about a resolution chart.
1. The four numbers that decide the optics
Before anything else, write down four numbers for the inspection task:
- Field of view (FoV) — the widest extent of the scene that must be in one frame, in millimetres, including part jitter and fixturing slop. A 180 mm part on a belt with ±10 mm lateral wander needs 200 mm, not 180 mm.
- Working distance (WD) — the distance from the front of the lens to the object, fixed by the mechanics of the cell.
- Smallest feature — the smallest defect or mark that must be detected, in millimetres.
- Depth of field needed — how much the object height varies within a single capture.
Everything else follows from those.
Pixels per millimetre, and the sampling rule
sensor_px_h = 4096 # horizontal pixels
fov_mm = 200.0 # required field of view, mm
smallest_mm = 0.30 # smallest feature to detect, mm
px_per_mm = sensor_px_h / fov_mm
px_on_feature = px_per_mm * smallest_mm
print(f"{px_per_mm:.2f} px/mm, feature spans {px_on_feature:.1f} px")
# 20.48 px/mm, feature spans 6.1 px
How many pixels does a feature need? Rules of thumb that hold up in practice:
| Task | Pixels across the smallest feature |
|---|---|
| Presence / absence of a large part | 3–5 px |
| Blob or edge measurement with subpixel fitting | 10–20 px across the dimension |
| Surface defect detection (scratch, pit, contamination) | 4–8 px across the defect's narrow axis |
| OCR / DataMatrix reading | 8–10 px per character stroke; at least 5 px per 2D code module |
| CNN-based defect classification | 20–30 px across the defect so it survives downscaling to the model input |
That last row is the one people forget. If your detector resizes to 640x640 and your native capture is 4096 px wide, every feature shrinks by 6.4x. A defect that spans 6 px natively spans one pixel at the model input and is gone. Either tile the image or state honestly that you need a bigger sensor. We covered the tiling and ingest side in multi-object tracking and counting; the fix here is upstream of the model.
Focal length
For a standard (non-telecentric) lens, with sensor width w_mm and magnification m = w_mm / fov_mm:
sensor_w_mm = 14.1 # sensor width (pixels * pixel pitch)
fov_mm = 200.0
wd_mm = 450.0 # working distance
m = sensor_w_mm / fov_mm # magnification, ~0.0705
f_mm = wd_mm * m / (1 + m) # thin-lens approximation
print(f"m={m:.4f}, f~{f_mm:.1f} mm") # m=0.0705, f~29.6 mm
Round to the nearest available lens (25 mm or 35 mm here) and re-solve for the working distance you actually get. The thin-lens form ignores the principal-plane offset, so treat it as a shortlist tool and confirm against the vendor's FoV calculator before you order.
Two sanity checks that catch most mistakes:
- Image circle must cover the sensor. A lens rated for 1/1.8" on an APS-C sensor vignettes the corners into uselessness. Compare image-circle diameter against the sensor diagonal.
- Mount and resolution class. A C-mount lens sold as "5 MP" on a 20 MP sensor throws away most of your pixel budget — which the MTF measurement in section 3 will show you brutally.
Depth of field and the diffraction wall
import math
def dof_mm(f_mm, N, m, coc_mm):
# Approximate total depth of field at magnification m
return 2 * N * coc_mm * (1 + m) / (m * m)
pixel_um = 3.45
coc = 2 * pixel_um / 1000.0 # circle of confusion ~ 2 px
for N in (2.8, 5.6, 8, 11, 16):
diff_spot_um = 2.44 * 0.55 * N * (1 + m) # Airy diameter, um, at 550 nm
print(f"f/{N}: DoF ~ {dof_mm(29.6, N, m, coc):6.1f} mm, "
f"Airy ~ {diff_spot_um:4.1f} um ({diff_spot_um/pixel_um:.1f} px)")
Stopping down buys depth of field and costs sharpness: once the Airy disc exceeds roughly two pixels you are diffraction-limited and extra f-number only darkens the image. On a 3.45 um pixel that happens around f/8. If the depth of field you need at f/8 is not enough, the answer is not f/16 — it is a shorter focal length, a smaller sensor, better part fixturing, or a telecentric lens.
When to pay for telecentric
Use a telecentric lens when the measurement must be insensitive to object height: parts of varying thickness, bores viewed down their axis, anything where perspective error would eat the tolerance. A standard lens magnifies nearer objects more, so a 2 mm height variation at 450 mm working distance is roughly a 0.44% dimensional error — about 0.9 mm on a 200 mm part. If your tolerance is ±0.2 mm, no calibration will save you; see camera calibration in OpenCV 5 for how to quantify what is left once the optics are right.
2. Light is part of the optics decision
Exposure time, aperture and illumination are one budget, not three. At belt speed v mm/s, motion blur is v * t_exp millimetres; keep it under half a pixel:
v_mm_s = 250.0
px_per_mm = 20.48
t_max_s = 0.5 / (px_per_mm * v_mm_s)
print(f"max exposure {t_max_s*1e6:.0f} us") # max exposure 98 us
98 us at f/8 is a strobe, not a ring light — and it means lens choice, aperture, illumination power and the sensor's quantum efficiency are a single coupled decision. Global shutter is effectively mandatory for moving parts; a rolling shutter skews a part moving at 250 mm/s into a parallelogram, and no amount of post-processing un-skews it reliably. Geometry and exposure control interact, which is why we treat input quality as a gated pipeline stage in camera control, HDR and low-light denoising.
3. Measuring what you actually bought: slanted-edge SFR in OpenCV 5
Vendor MTF curves are measured on-axis, wide open, at the vendor's favourite wavelength. What matters is the MTF of your camera, with your lens, at your aperture and working distance, in the corner of the frame. Measure it with the slanted-edge method (the ISO 12233 e-SFR approach): image a sharp, high-contrast straight edge tilted about 5 degrees from vertical, then recover the edge spread function at subpixel resolution.
Print or machine a knife-edge target, mount it flat at the working distance, fill a region of interest with the edge, and capture raw, linear, unprocessed frames — in-camera sharpening or gamma invalidates the measurement.
import cv2
import numpy as np
def sfr_from_roi(roi_u8, pixel_pitch_mm):
# ISO-12233-style slanted-edge SFR. ROI: vertical-ish edge, light on one side.
roi = roi_u8.astype(np.float64) / 255.0
roi = np.power(roi, 2.2) # linearise if frames are sRGB-encoded
h, w = roi.shape
# 1. Sub-pixel edge location per row, via centroid of the derivative
deriv = cv2.filter2D(roi, cv2.CV_64F, np.array([[-0.5, 0.0, 0.5]]))
xs = np.arange(w)
centroids = np.empty(h)
for r in range(h):
d = np.abs(deriv[r])
centroids[r] = (d * xs).sum() / max(d.sum(), 1e-12)
# 2. Fit the edge line; its slope gives the phase shift per row
rows = np.arange(h)
slope, intercept = np.polyfit(rows, centroids, 1)
angle_deg = np.degrees(np.arctan(slope))
if not (2.0 < abs(angle_deg) < 15.0):
raise ValueError(f"edge angle {angle_deg:.2f} deg outside 2-15, re-mount the target")
# 3. Project all rows onto a 4x super-sampled axis -> oversampled ESF
BIN = 4
shifted = xs[None, :] - (slope * rows + intercept)[:, None]
idx = np.round(shifted * BIN).astype(int) + (w * BIN) // 2
acc = np.zeros(w * BIN); cnt = np.zeros(w * BIN)
np.add.at(acc, idx.ravel(), roi.ravel())
np.add.at(cnt, idx.ravel(), 1.0)
keep = cnt > 0
esf = acc[keep] / cnt[keep]
# 4. LSF = d(ESF), windowed; MTF = |FFT(LSF)|
lsf = np.gradient(esf)
lsf *= np.hamming(lsf.size)
mtf = np.abs(np.fft.rfft(lsf))
mtf /= mtf[0]
freq_cyc_px = np.fft.rfftfreq(lsf.size, d=1.0 / BIN) # cycles per native pixel
freq_cyc_mm = freq_cyc_px / pixel_pitch_mm # cycles/mm at the sensor
return freq_cyc_mm, mtf, angle_deg
def mtf_at(freq, mtf, target=0.5):
# Frequency where MTF drops to `target` (MTF50 by default)
below = np.argmax(mtf < target)
if below == 0:
return float(freq[-1])
f0, f1 = freq[below - 1], freq[below]
m0, m1 = mtf[below - 1], mtf[below]
return float(f0 + (m0 - target) * (f1 - f0) / (m0 - m1))
Run it on five regions — centre plus the four corners — at each candidate aperture:
frame = cv2.imread("edge_f8.png", cv2.IMREAD_GRAYSCALE)
PITCH_MM = 0.00345
ROIS = {"centre": (1900, 1000, 256, 256), "top-left": (120, 120, 256, 256),
"top-right": (3720, 120, 256, 256), "bottom-left": (120, 1880, 256, 256),
"bottom-right": (3720, 1880, 256, 256)}
for name, (x, y, w, h) in ROIS.items():
f, m_curve, ang = sfr_from_roi(frame[y:y+h, x:x+w], PITCH_MM)
nyquist_half = 1.0 / (4 * PITCH_MM)
print(f"{name:13s} edge {ang:5.2f} deg MTF50 = {mtf_at(f, m_curve):6.1f} cyc/mm "
f"MTF@Nyq/2 = {np.interp(nyquist_half, f, m_curve):.2f}")
Reading the output:
- MTF50 in cycles/mm at the sensor, divided by magnification, gives cycles/mm at the object. A 0.30 mm scratch is roughly a 1.67 cyc/mm object feature — you want system MTF comfortably above ~0.2 there, not scraping it.
- Corner-to-centre ratio. Below about 0.6 the lens is under-specified for the sensor, or the image circle is too small. This single number kills more "5 MP lens on a 20 MP camera" purchases than any datasheet claim.
- Aperture sweep. MTF50 should rise from wide open, peak around f/4 to f/5.6, then fall as diffraction takes over. If it peaks at f/11, the lens is poor wide open and your light budget just got worse.
- Stability. Re-measure after the cell is bolted down and after a thermal soak. A 20% MTF50 drop across a shift usually means focus drift from thermal expansion, and in production it will look exactly like "the model got worse".
Keep the measured curves as commissioning artefacts. When defect escape rates rise six months later, re-running this on the same target answers "is it the optics or the model?" in ten minutes — the same instinct behind the golden-frame gates in testing OpenCV 5 pipelines like software.
4. Catching focus drift in production
The same physics gives you a cheap online focus monitor. Pick a static, textured region that is always in frame — a fixture edge, a printed fiducial — and track its high-frequency energy per frame:
def focus_score(gray_roi):
lap = cv2.Laplacian(gray_roi, cv2.CV_64F, ksize=3)
return float(lap.var())
BASELINE = 182.4 # captured at commissioning: same ROI, same exposure, same gain
def focus_ok(gray_roi, tol=0.75):
return focus_score(gray_roi) >= BASELINE * tol
Variance-of-Laplacian is scene- and exposure-dependent, so it is only meaningful on a fixed ROI with locked exposure and gain — which is exactly the situation in a controlled inspection cell. Log the score next to every inference result. A slow decline over weeks is a loosening lock-ring; a step change is somebody bumping the camera. Both are far cheaper to catch here than in a customer complaint.
5. An optics checklist for the specification phase
Before any hardware is ordered, the specification should state:
- Field of view, working distance and smallest feature, in millimetres, with tolerances
- Resulting px/mm and pixels-across-feature, with the rule of thumb used
- Sensor format, pixel pitch, shutter type, and sensor diagonal versus lens image circle
- Focal length, aperture, and whether telecentricity is required, with the perspective-error number that justifies it
- Depth of field at the chosen aperture versus the part-height variation
- Maximum exposure from the motion-blur limit, and the illumination power needed to reach it
- An acceptance test: measured MTF50 at centre and corners, with the minimum values the vendor must meet
That last line matters most. Making MTF an acceptance criterion rather than an opinion changes the conversation with integrators, and lets you reject hardware before it is embedded in a cell.
Where this fits
Model architecture is the cheapest part of a vision project to change and the part everyone argues about. Optics is the most expensive to change and the part most often decided by whatever was in the drawer. Doing the arithmetic in section 1 takes an hour; discovering at integration that the system physically cannot resolve the defect costs a quarter.
SentientSight's OpenCV consultants run this stage with clients routinely — optical specification, vendor acceptance testing, MTF benchmarking and the commissioning artefacts that keep a deployed system honest — before building the pipeline that sits on top. If you are specifying a new inspection cell, or you have one that cannot see the defect it was bought to see, get in touch.