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:
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.
- 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)
-
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.
-
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)
- 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
- 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.
- 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.
- 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.
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:
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.
- Sensor format
- 1/1.7"
- Interface
- USB3
- Resolution
- 12 MP
- Shutter
- Rolling
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.
- Type
- Machine vision fixed
- Sensor format
- 1.1"
- Mount
- C-mount
- Resolution
- 16-25 MP
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.
- Size
- 100-200 mm
- Type
- Diffused
- Size
- 100-200 mm
- Type
- Diffused
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.



