A photographed line drawing is the classic starter problem: dark strokes on bright paper, one threshold, then an erode-dilate-subtract loop that should collapse each stroke to its centerline. Instead, the output traces the sheet. The loop is not broken — the threshold polarity is, because polarity decides whether the strokes or the paper become the white pixels the loop thins. This guide gives you the polarity fix, a pass-count formula that caps the loop from stroke width, a worked example you can check by hand, and a synthetic test that proves the pipeline before real parts. The same checks hold whether the loop ends up on a smart camera or on a vision controller.
Why polarity decides what gets thinned
The loop erodes whatever is white — that one fact decides everything downstream. cv::THRESH_BINARY copies every pixel brighter than the threshold to white. On dark ink over bright paper, the paper becomes the foreground and the strokes become holes in it, so erosion peels the sheet inward from its edges and the subtract collects the peel. The 'skeleton' is the paper's shape, which is exactly what the classic failing run shows after 100 iterations.
Switch the flag to cv::THRESH_BINARY_INV and the assignment flips: pixels darker than the threshold become white, the strokes become the foreground, and the identical loop now peels each stroke toward its centerline. Nothing else in the code changes.
| Threshold flag | Pixels that end up white | What the loop thins |
|---|---|---|
| cv::THRESH_BINARY | brighter than the threshold — the paper | the background |
| cv::THRESH_BINARY_INV | darker than the threshold — the strokes | the lines |
The pass counter gives a second signal. A stroke a few pixels wide empties in as many passes as half its width with a 3x3 cross; a sheet-sized foreground on the same frame runs for tens of passes before cv::countNonZero reaches zero. A large count is a polarity symptom before it is anything else. The same wrong-foreground trap sits behind OCR thresholding with a dark background, and the fix is identical: decide what must be white before you threshold.
Set the polarity and threshold
- Convert to grayscale when the source has color:
cv::cvtColor(image, gray, cv::COLOR_BGR2GRAY). The threshold works on single-channel CV_8UC1 data. - Threshold with inverted polarity:
cv::threshold(gray, bin, thresh, 255, cv::THRESH_BINARY_INV), thresh between 0 and 255. When no fixed value survives across sheets, add cv::THRESH_OTSU and pass 0 — Otsu's method returns a single threshold that separates the pixels into two classes (Otsu's method), and automatic threshold segmentation in Lab color with Otsu's method works the same idea through in color space. - Save and look before any thinning: write the binary image with cv::imwrite and open it. The strokes must be white on black. The live binary image preview for defect inspection is built around exactly this checkpoint.
Run the open-subtract thinning loop
- Build the element:
cv::getStructuringElement(cv::MORPH_CROSS, cv::Size(3,3))— a 3x3 cross, radius 1, the shape the pass-count math assumes. - Allocate the output with
cv::Mat::zeros(bin.size(), CV_8UC1)and three scratch matrices of the same size and type. - Loop: erode the binary, dilate the eroded copy, subtract the opened result from the current binary, OR the residue into the skeleton, then continue from the eroded image. Break when cv::countNonZero returns 0 or the pass cap trips.
- Log the passes per image and alarm at the cap — the count is the polarity signal from the first section.
// Written against the OpenCV C++ interface: cv:: names are documented API;
// image, passes, maxIter and residue are illustrative variables declared here.
cv::Mat gray;
if (image.channels() != 1) // image: CV_8UC1 or CV_8UC3 input
cv::cvtColor(image, gray, cv::COLOR_BGR2GRAY);
else
gray = image;
cv::Mat bin; // strokes white (255) on black
cv::threshold(gray, bin, 0, 255, cv::THRESH_BINARY_INV | cv::THRESH_OTSU);
cv::Mat skel = cv::Mat::zeros(bin.size(), CV_8UC1);
cv::Mat eroded(bin.size(), CV_8UC1), opened(bin.size(), CV_8UC1), residue(bin.size(), CV_8UC1);
cv::Mat element = cv::getStructuringElement(cv::MORPH_CROSS, cv::Size(3, 3));
int passes = 0;
const int maxIter = 6; // 2 x ceil(strokeWidth / 2), see worked example
while (passes < maxIter) {
cv::erode(bin, eroded, element);
cv::dilate(eroded, opened, element);
cv::subtract(bin, opened, residue); // pixels peeled off this pass
cv::bitwise_or(skel, residue, skel);
eroded.copyTo(bin);
++passes;
if (cv::countNonZero(bin) == 0)
break;
}
The pass count
Each pass of a radius-r element removes r pixels from each side of an axis-parallel stroke, so a stroke of width w empties after about w/(2r) passes:
Strokes at 45 degrees thin slightly faster under a cross, so read the formula as the upper bound; the zero-count break handles the rest.
Worked example: a 6 px stroke thinned with a 3x3 cross
- Image: dark ink on a bright photographed sheet, thresholded with cv::THRESH_BINARY_INV so the strokes are white.
- Stroke width: 6 px across at the thinnest point, counted by hand on the saved binary image — the example's illustrative input.
- Element: 3x3 cross, radius 1.
The rule in words: each pass erodes one pixel from each side of the stroke, the dilation re-inflates what remains, and the subtract keeps whatever the opening could not restore — that residue is the skeleton.
passes ≈ ceil(6 / (2 x 1)) = 3
- Pass 1: the stroke erodes from 6 px to 4; the dilation restores 4 back to 6, residue empty.
- Pass 2: 4 px erodes to 2 px; restored 2 back to 4 px, residue still empty.
- Pass 3: 2 px erodes to 0 px; nothing to restore, and the subtract returns the 2 px center strip.
cv::countNonZero now returns 0 and the loop stops after 3 passes. The skeleton is a 2 px strip down the stroke's middle: an even width has no single center pixel. A 7 px stroke under the same element takes ceil(7 / 2) = 4 passes and leaves a true 1 px line.
The decision: set the cap at 2 x 3 = 6 passes for this pattern. Above 6 passes, polarity or threshold is wrong — stop the loop and inspect the saved binary image. That is the entire difference between 3 passes and the classic 100-iteration run: the cap turns a silent polarity bug into a logged alarm.
Verify before you trust the skeleton
Prove the pipeline on a synthetic image before real parts. Paint a 100 x 100 white image with a black bar of the worked example's width, 40 px long, then run the loop twice:
- cv::THRESH_BINARY: the sheet becomes the foreground, the counter hits the 6-pass cap, and the residue traces the paper — the bug, reproduced on demand.
- cv::THRESH_BINARY_INV: the loop stops at 3 passes and the skeleton is the 2 px strip.
Repeat with a 7 px bar: 4 passes and a 1 px line. A device that reproduces both results has the polarity, element and cap right, so any later failure on real sheets lives in threshold or lighting, not in the thinning logic. On real sheets, read the saved binary for strokes that break where illumination falls off — that failure sits upstream of polarity.
Common mistakes that break the loop
- cv::equalizeHist before the threshold: on a synthetic drawing it changes nothing useful, and on photographed sheets it can shift a working threshold until thin strokes break. Judge it by the saved binary image.
- Element bigger than the strokes: a 5x5 cross empties a 3 px stroke in one pass, the opening cannot restore it, and the subtract returns the entire stroke — you get the drawing back, not a skeleton. Keep r below half the width you want to thin.
- One fixed threshold across changing exposure: too high breaks strokes into dots, too low welds them to noise. Add cv::THRESH_OTSU or lock the exposure so the histogram keeps two clean peaks.
- No cap and no log: with reversed polarity the loop runs until the largest region empties — dozens of erode-dilate passes over every pixel on a full frame.
Where to run the loop
The cost is pixels times passes: every pass runs an erode-dilate pair over every pixel in the image, so crop to the pattern's ROI first — a quarter-size ROI cuts the work by about 4x. For the wider split of stages between device and PC, on-camera image processing: what can you offload maps what fits on the camera.
For one inspection point above a static sheet, a mono smart camera runs threshold, thinning and measurement inside the acquisition cycle, and mono keeps the grayscale histogram clean for the threshold step. The sheet is static during capture, so the rolling shutter costs nothing here. Compare models in the smart cameras category or filter by resolution and shutter in the camera finder.
- Interface
- GigE
- Resolution
- 6-10 MP
- Sensor format
- 1/2.4"
- Shutter
- Rolling
When the inspection needs your own build of this pipeline on the device, the X86-platform smart camera below takes a C-mount lens and runs standard image-processing libraries directly.
- Interface
- USB 2.0
- Resolution
- 6-10 MP
- Sensor format
- 1"
- Shutter
- Global
Multi-camera stations move the loop to a vision controller: more camera ports, built-in light channels that drive the illumination without a separate box, and CPU headroom for heavier math. The two below bracket the range — a compact Core i3 model without PoE, and a Core i5 model with PoE camera ports and twice the light channels. If your cameras draw power over the network cable, Does my camera support PoE: check the port before you buy walks the port check.
| Specification | MV-VC3301H-128G66 | MV-VC5525-256G70-NN |
|---|---|---|
| CPU | Intel® Core i3-8100 | Intel® Core i5-12400 |
| Memory | 8 GB | 32 GB |
| Storage | 128G SSD | 256 GB SSD |
| GPU | Intel® HD Graphics 630 | Intel® UHD Graphics 730 |
| Operating system | Windows 10 64-bit, Not activated | Unactivated Windows 10 |
| Video output | HDMI interface × 1, VGA interface × 1; Supports independent display output at the same time; Max. resolution 1920 × 1080 @ 60 Hz | HDMI interface × 1, VGA interface × 1; Supports independent display output at the same time; Max. resolution 1920 × 1080 @ 60 Hz |
| Digital I/O | 8 digital isolated inputs supported; 16 opto-isolated outputs supported (NPN/PNP switchable) | Opto-isolated input × 8; Opto-isolated output × 12 ( supports NPN/PNP switching), digital-isolated low-side driver output × 8; Non-isolated bi-directional differential I/O × 4 |
| Network interface | 6× Gigabit Ethernet ports | GigE interface × 7, including interface for PoE × 4 |
| USB Interface | USB 2.0 × 4; USB 3.0 × 4; Built-in USB 2.0 × 1 | USB 2.0 × 4, USB 3.0 × 4, built-in USB 2.0 × 1 |
| Serial Port | 2× full-function RS-232, RS-485/RS-422 switchable | RS-232 × 2 by default, supports switching to RS-485 and RS-422 |
| Light interface | 4-channel 24 V constant-voltage light drive, Max. power per channel: 72 W, Max. power (4 channels): 150 W | 24 VDC constant voltage × 8; (Max. power consumption of single channel is 72 W, total max. power consumption 190 W) |
| Extended interface | 1× PCIe 3.0 x16 slot | SATA 3.0 interface × 2, M.2 NVME interface × 1; PCIe × 16 extended interface × 1, PCIe × 8 extended interface × 1, PCIe × 4 extended interface × 2; (When the PCIe × 8 extended interface is used, the PCIe × 16 will only provide half of the bandwidth.) |
| Price | $2,419.00 | $4,479.00 · quoted |
All 2 share: Humidity 20% RH to 95% RH (no condensation); Certification CE, KC. Values as printed in each product's data sheet; open a part for its drawing and full specifications.
Checks before you order
- Measure the thinnest stroke in pixels on the saved binary image and set the cap at twice ceil(w/2): w = 6 in the worked example, cap 6.
- Run the synthetic bar test on the target device, with the build that will ship — 3 passes for the example bar, 4 for the 7 px bar.
- Size the sensor for the stroke, not the sheet: the thinnest stroke must keep enough pixels across it to survive the threshold, and the camera resolution calculator turns field of view and smallest feature into a pixel count.
- Static sheet: either shutter works. Sheet moving under the camera: global shutter. Filter for shutter before you order.
- Confirm one threshold separates ink from paper across the whole sheet; where illumination varies, add cv::THRESH_OTSU or fix the light first — the lighting guides cover evenness from the hardware side.
When the usual answer is wrong
- Mixed widths: budget passes by the widest stroke, because the loop ends when the last feature empties. A pattern of thin lines with a 40 px border needs about 20 passes, not 3.
- Strict 1 px centerlines: even-width strokes leave a 2 px core. When a gauge reads centerline position to the pixel, run a dedicated thinning algorithm after the open-subtract loop.
- Wrong tool: skeletonize for centerlines, junctions and stroke counts. Measure the binary stroke directly for width; use an edge detector for boundaries. Pick by what the gauge reads.
If the skeleton is one stage in a bigger station, scoping a machine vision system before you call an integrator lists the decisions to settle first, and the systems guides carry the rest of the processing chain. Settle the stroke width and the cap first: they decide the sensor, and the sensor decides the rest.
Questions
Why does my skeleton trace the paper edge instead of the drawn strokes?
Threshold polarity. With cv::THRESH_BINARY on dark ink over bright paper, every pixel brighter than the threshold turns white, so the loop thins the sheet and its residue is the paper's shape. Switch to cv::THRESH_BINARY_INV so only the strokes are white. The counter confirms it: strokes of a few pixels empty in about 3 passes, while a sheet-sized foreground runs for dozens before it empties.
How many passes does a pattern with mixed stroke widths need?
Budget by the widest stroke, because the loop ends when the last feature empties: passes ≈ half the widest width with a 3x3 cross. Lines of 6 px beside a 10 px stroke need ceil(10 / 2) = 5 passes. A 5x5 cross peels 2 px per side per pass and roughly halves the count, but strokes of 4 px or fewer vanish in its first pass.
Can the loop thin a stroke narrower than the structuring element?
It hands the stroke back whole. A 2 px stroke under a 3x3 cross erodes to nothing in one pass, the opening cannot restore any of it, and the subtract returns the entire stroke as that pass's residue. The skeleton is then the original 2 px line. Keep the element radius clearly below half the stroke width when you actually want thinning.
Does the open-subtract method always give a one-pixel-wide skeleton?
No. Odd widths give a 1 px line; even widths have no single center pixel, so the residue settles on a 2 px strip, as the worked example shows. When a gauge needs a strict 1 px centerline, run a dedicated thinning algorithm after this loop, or size the optics so the strokes sample to odd widths.
Should the skeleton loop run on the camera or on a vision controller?
Cost scales with pixels times passes, so crop to the ROI first: a quarter-size ROI cuts the work by about 4x. One small ROI fits the on-device processing of a smart camera; several cameras, powered ports and built-in light channels point to a vision controller with PoE camera ports. Decide after the synthetic test tells you the true pass count per image.

