ApplicationsHow-to

Separating touching objects with watershed: distance map markers

Separating touching objects with watershed: seed the distance map, split at the contacts, and measure each stone's area in pixels.

LumaSpec Vision11 min read Share on LinkedIn

On this page 9 sections
  1. 01Why touching stones arrive as one blob
  2. 02The number that fixes it: half the smallest radius
  3. 03Step by step: seed, split, count
  4. 04Worked example: a 300 mm tray of stones
  5. 05Light the contact line, not just the stones
  6. 06Measure each stone's area in pixels
  7. 07When watershed is the wrong tool
  8. 08Checks before you trust the counts
  9. 09Questions

Touching stones, pills or coins arrive in the binary mask as one blob, so every area measured from that mask is wrong by a whole stone or more. The fix is marker-based watershed: turn the silhouette into a distance map, keep one seed region inside each object, and let the flood split the contacts. The one setting that decides success is the seed threshold on the distance map, and it must come from the smallest object's radius in pixels — never from the image maximum. This guide walks the pipeline end to end, gives the threshold rule with its arithmetic, sizes the camera and lens that put enough pixels on each stone, and closes with the per-stone area measurement.

Why touching stones arrive as one blob

A binary threshold followed by connected components labels any two stones that share a neck as one object. Connectivity has no concept of "almost separate" — the same property that decides between connectivity 4 or 8 in blob analysis. Watershed repairs this only when every stone owns exactly one marker before the flood starts, and two failures mimic "watershed does not work":

  • Seeds merged. The threshold sat so low that the seed regions of two touching stones connect, so cv2.connectedComponents hands the pair a single label and there is nothing left to split.
  • Seed missing. The threshold sat so high that a small stone keeps no pixel above it, so its area silently joins a neighbour or the background.

Recipes that threshold the distance map at a fixed fraction of the image maximum (0.1 to 0.7 in most copied demos) fail on mixed sizes because that maximum is the largest object's radius, not a property of the contact you need to cut.

The number that fixes it: half the smallest radius

The distance map stores, for every foreground pixel, the distance to the nearest background pixel, so its peak under a stone sits close to that stone's inradius. Between two touching stones the map shows two peaks with a saddle across the neck, and the seed threshold t has to land between the saddle and the smaller peak:

neck_max = sqrt(r^2 - (c/2)^2) two equal disks of radius r, centres c apart t must satisfy: neck_max < t < r_min start at t = 0.5 * r_min splits contacts up to 0.27 * r deep deep contacts: t = 0.7 * r_min splits contacts up to 0.57 * r deep

The fractions fall out of the same formula: t = 0.5 r separates two equal disks whose centres are more than 1.73 r apart, and t = 0.7 r pushes that to 1.43 r. For the 300 mm tray worked out below, r_min = 91 px and the image maximum is about 242 px:

Seed threshold What happens
24 px, about 0.1 × the image maximum seeds grow across the necks; touching pairs share one label
45 px, 0.5 × r_min splits contacts up to 25 px deep — the first choice
64 px, 0.7 × r_min splits contacts up to 52 px deep, seeds still wide
86 px, 0.95 × r_min seeds shrink to points; the object count turns unstable

When the neck is deeper than that window allows, no threshold splits the pair honestly — read that as a lighting or a 3D problem, not a tuning one.

Step by step: seed, split, count

You need a Python environment with OpenCV and NumPy, a sample image whose stones you have counted by hand, and one measurement: the smallest stone's diameter in pixels.

  1. Build the silhouette. Otsu on the grey image is the default start; when stone and tray greys overlap, automatic threshold segmentation in Lab colour with Otsu's method separates them, and when illumination drifts across the tray use local-mean adaptive thresholding instead of one global cut.
import cv2
import numpy as np

img = cv2.imread("stones.png")                                # uint8, BGR
img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)              # uint8
_, img_bin = cv2.threshold(img_gray, 0, 255,
                           cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
opening = cv2.morphologyEx(img_bin, cv2.MORPH_OPEN, kernel, iterations=2)
  1. Measure the smallest stone once. If it spans 150 px, r_min = 75 px. Every threshold below scales from this one number, so measure it on the smallest stone you must still count.

  2. Threshold the distance map into seeds.

img_dist = cv2.distanceTransform(opening, cv2.DIST_L2, 5)     # float32, pixels
r_min = 75                                                    # int, px, from step 2
ratio = 0.5                                                   # float; 0.7 for deep contacts
_, img_sure_fg = cv2.threshold(img_dist, ratio * r_min, 255, cv2.THRESH_BINARY)
img_sure_fg = img_sure_fg.astype(np.uint8)
  1. Fence off the band you are unsure about, label the seeds, and flood:
img_sure_bg = cv2.dilate(opening, kernel, iterations=3)
img_unknown = cv2.subtract(img_sure_bg, img_sure_fg)
ret, markers = cv2.connectedComponents(img_sure_fg)
markers += 1
markers[img_unknown == 255] = 0
markers = cv2.watershed(img, markers)          # contact pixels return as -1
  1. Prove the threshold on a synthetic pair before trusting it on stones. Two filled white disks of radius 90 px with centres 140 px apart overlap by 40 px, and the neck between them peaks at sqrt(90^2 - 70^2) = 56.6 px:
# two_disks is an illustrative helper that returns a uint8 image holding
# two filled disks; use whichever drawing code you already have
test = two_disks(size=(400, 400), r=90, centres=140)
dist_t = cv2.distanceTransform(test, cv2.DIST_L2, 5)
_, seeds_t = cv2.threshold(dist_t, 63, 255, cv2.THRESH_BINARY)   # 0.7 * 90
n_t, _ = cv2.connectedComponents(seeds_t.astype(np.uint8))
print(n_t - 1)     # 2 at t = 63 px; 1 at t = 45 px (0.5 * 90)

That printed pair is the whole tuning problem: 45 px leaves one seed at this contact depth, 63 px cuts it. Keep the test in the repository so every threshold change is measured, not judged by eye.

  1. Go per-component when sizes are mixed. With 15 mm and 40 mm stones in one frame, 0.6 of each component's own depth splits every pair at its own scale, capped at 0.7 × r_min so a small stone beside a large one never loses its seed:
n, fg = cv2.connectedComponents(opening)
seeds = (img_dist * 0).astype(np.uint8)                       # all-zero uint8 image
for i in range(1, n):
    comp = np.where(fg == i, 255, 0).astype(np.uint8)
    d_in = cv2.distanceTransform(comp, cv2.DIST_L2, 5)
    t_i = min(0.6 * d_in.max(), 0.7 * r_min)
    _, seed_i = cv2.threshold(d_in, t_i, 255, cv2.THRESH_BINARY)
    seeds[seed_i == 255] = 255

Then rerun step 4 with img_sure_fg replaced by seeds.

  1. Choose what the flood walks on. cv2.watershed follows the image you hand it. The colour image is right when the contact shows as a grey edge; when stones are textured and the edge carries no grey step, flood the distance map instead, stacked into three channels, and the basins break exactly at the neck.

Worked example: a 300 mm tray of stones

Task: stones 15–40 mm across, scattered on a 300 × 225 mm tray that stops under the camera. Shoot with margin: a 330 mm field of view over 4000 sensor pixels puts one pixel at 330 / 4000 = 0.0825 mm, so stones span 182 to 485 px and r_min = 91 px. The first seed threshold is 0.5 × 91 = 45 px, and deep contacts take 0.7 × 91 = 64 px. The same pixel-to-millimetre mapping is what object-space pixel size from magnification works through for any set-up; starting from the object instead, the calculator below turns "150 px on the smallest stone" into a pixel count for any field of view.

Camera resolution for the smallest feature
Units
Pixels needed
–
Sensor size
– MP
Smallest sensor that fits
–
Pixel on the part
– mm
Pixels across the feature, that sensor
–

Pixels needed = field / feature × pixels per feature on each axis (field incl. margin). The lens must resolve the pixels too: Nyquist = 1000 / (2 × pixel µm) lp/mm.

Find cameras with enough pixels Open the full calculator

A 1/1.7" sensor is about 7.4 mm wide — typical for the format — so the magnification is m = 7.4 / 330 = 0.0224. A 16 mm focal length then works at WD = f (1 + m) / m = 16 × 1.0224 / 0.0224 = 730 mm:

m = sensor width / field of view WD = f * (1 + m) / m pixel size = field of view / sensor pixels
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.

Depth of field: the pixel pitch is 7.4 mm / 4000 = 1.85 µm, a tolerance of two pitches gives c = 3.7 µm, and at f/8 the depth of field is 2 × 8 × 0.0037 × 1.022 / 0.0224² ≈ 120 mm — ±60 mm around a focus set mid-pile covers a 100 mm pile. Exposure on a stopped tray is easy: 10 ms at f/8 with gain left low. On a belt running 200 mm/s the 0.5 px blur budget is 0.5 × 0.0825 = 0.041 mm, so exposure must stay under 0.041 / 200 = 0.2 ms — strobe the light for about 100 µs (0.02 mm, 0.24 px of blur) and use a global-shutter camera.

The LS-CMR-0166 is a 12 MP USB3 camera: IMX226 sensor, 1/1.7" format, rolling shutter, 30 fps, 4000 × 3000 resolution. Rolling shutter is safe only on a stopped tray; for the moving belt, swap to a global-shutter model such as the MV-CH120-11UC, a 12 MP USB3 camera with an IMX304 sensor.

Area scan cameras12 MP USB3 area scan camera, IMX226, 1/1.7" rolling shutter, 30 fps, 4000 × 3000 resolution (LS-CMR-0166)LS-CMR-0166
Sensor format
1/1.7"
Interface
USB3
Resolution
12 MP
Shutter
Rolling
$560.00View partBuild a system with it

The LS-FFL-0567 is a 16 mm C-mount lens rated for 20 MP, aperture F2.8-22, distortion under 0.3%, covering a 1.1" sensor — comfortably more image circle than a 1/1.7" sensor needs.

Machine vision lenses16 mm, 1.1", 20 MP, C-mount machine vision lens, F2.8-22, <0.3% distortion (LS-FFL-0567)LS-FFL-0567
Type
Machine vision fixed
Sensor format
1.1"
Mount
C-mount
Resolution
16-25 MP
$679.00View partBuild a system with it

To compare other options, filter area scan cameras in the camera finder, or machine vision lenses in the lens finder.

Light the contact line, not just the stones

The flood can only cut where the image carries signal. A ring light at 0° skims across the tray and throws a shadow into every neck, giving the watershed a dark seam exactly where you want the split; a 30° diffused ring fills the rounded tops of the stones instead and reads them as bright objects on a dark tray. When geometry allows, backlight through a glass tray beats both: the silhouette is a hard edge and the contact is the only place two stones merge. Plan the choice the way the machine vision lens and lighting selection test plan lays out, and browse ring lights or the light finder for the geometry your mounting space allows. Two options that suit this tray: the LS-RNG-0426, a white 120 mm diffused ring at 0°, 24 V, and the LS-RNG-0143, a white 100 mm diffused ring at 30°, also 24 V — drive either from a vision light controller when you strobe.

Ring lightsRing light, 120 mm dia., 0°, White (LS-RNG-0426)LS-RNG-0426
Size
100-200 mm
Type
Diffused
$369.00View part
Ring lights100 mm dia. ring light with diffuser, 30° (LS-RNG-0143)LS-RNG-0143
Size
100-200 mm
Type
Diffused
$169.00View part

Measure each stone's area in pixels

One line returns every stone's pixel count:

stone_ids, areas_px = np.unique(markers[markers > 1], return_counts=True)

Boundary pixels carry label -1 and are excluded, which costs each stone its perimeter: the loss is 2 / r of its area, about 2% at r = 91 px. Hand the ring back before reporting:

kernel3 = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
resolved = markers.copy()
for lab in np.unique(markers[markers > 1]):
    touch = cv2.dilate(np.where(resolved == lab, 255, 0).astype(np.uint8), kernel3)
    resolved[np.where((markers == -1) & (touch == 255))] = lab
# a contested boundary pixel joins the highest label; harmless on a 1-px ring

To report square millimetres, multiply by the squared pixel size: 0.0825² = 0.0068 mm² per pixel, so a 15 mm stone at about π × 91² = 26,000 px comes to 26,000 × 0.0068 = 177 mm², matching π × 7.5² = 177 mm² — the arithmetic closes. Measure object size from a camera image walks the same conversion when the camera cannot sit square-on.

When watershed is the wrong tool

Stop tuning and change the method when a stone is mostly buried in its neighbour: the seed survives, but the split follows the map's saddle rather than the true edge, and areas drift with it. A fully hidden stone owns no pixels at all, and no 2D algorithm will find it. The same limit applies to stones with no silhouette — clear glass on a light tray, or two greys too close to threshold. There, promptable instance-segmentation models buy you labels at the cost of seconds per image on CPU, and a genuinely piled scene belongs to a 3D camera: choosing between 2D and 3D for conveyor pick and place sets out that decision.

Checks before you trust the counts

  • Seed count equals your hand count on five sample images, within one. If it does not, the threshold or the lighting is wrong before any watershed call.
  • No label spans two stones: draw the label borders and inspect the contacts, not the whole frame.
  • Stone areas sum to the foreground pixel count within 2% after the boundary ring is resolved.
  • A ±10% change in the threshold leaves the object count unchanged; if the count moves, the neck signal is too weak — fix the light first.
  • The smallest stone you accept still holds a seed at the shipping threshold; check it on that part, not on an average one.

Ready to size the hardware for your own tray? Walk the system configurator from camera to light, or send one sample image through the contact page and we will size it with you.

Questions

Why does thresholding the distance map at a fraction of its maximum fail?

Because img_dist.max() is the radius of the largest object in the frame, not a property of the contact you are cutting. At 0.1 × maximum the seed regions grow into the neck and two stones share one marker; at 0.5 × maximum any stone smaller than the biggest one can lose its marker and its pixels go to a neighbour. Anchor the threshold to the smallest expected radius — 0.5 × r_min to start — or to each component's own depth, and it stops depending on which stones happen to be in frame.

How many pixels across does an object need before watershed can split it?

Treat 100 px of diameter as the floor and 150 px as the comfortable working size. The split needs a seed band some tens of pixels wide inside each object and a contact neck several pixels wide to cut through; below about 100 px both shrink to a couple of pixels, and the window between the neck height and r_min closes to near nothing, so the object count flips on a 5% threshold change. Fit the field of view so the smallest object spans at least 150 px.

How do I get each stone's area after the watershed?

np.unique(markers[markers > 1], return_counts=True) returns one pixel count per stone in a single line. The watershed boundary carries label -1 and is excluded, which costs each stone about 2/r of its area — roughly 2% at a 91 px radius — so hand the boundary ring back to a touching label first. For mm², multiply by the squared pixel size: at 0.0825 mm/px each pixel is 0.0068 mm², so 26,000 px is about 177 mm².

Should the watershed run on the colour image or the distance map?

Run it on the colour image when the contact shows as a real grey edge — the flood then follows the visible boundary and the split lands on it. Run it on the distance map, stacked into three channels, when the objects are textured or the contact has no grey step: the map carries no texture, so the basins break exactly at the neck. When both outputs wobble at the same contact, the lighting is short of signal, not the algorithm.

When should I stop tuning watershed and use instance segmentation instead?

Stop when a stone sits mostly buried in its neighbour: a seed survives, but the cut follows the distance map's saddle instead of the true edge, and areas drift with it. Also stop when there is no silhouette to threshold — clear glass, or stone and tray greys too close — and when stones pile on top of each other. Promptable instance-segmentation models label those frames at the cost of seconds per image on CPU, and a true pile belongs to a 3D camera.