System designHow-to

OpenCV contour coordinates to x and y arrays for centroid distance

Slice OpenCV contour coordinates into x and y arrays, compute the moments centroid, and get pixel distances to every boundary point with worked numbers.

LumaSpec Vision8 min read Share on LinkedIn

On this page 8 sections
  1. 01What cv2.findContours returns
  2. 02What you need
  3. 03Four steps from contour list to distance array
    1. Step 1: one contour, collapsed
    2. Step 2: the x and y arrays
    3. Step 3: the centroid from moments
    4. Step 4: all distances in one line
  4. 04Check the math on a 60 × 20 pixel rectangle
  5. 05CHAIN_APPROX_SIMPLE or CHAIN_APPROX_NONE
  6. 06Common mistakes
  7. 07Running the loop on the line
  8. 08Questions

You have a binary image, cv2.findContours has found the outline, and print(contours) shows a wall of nested brackets. The part you want sits three levels deep: each contour is one NumPy array of shape (N, 1, 2) and dtype int32, with x in the first column and y in the second. This guide gives you the slices that split the contour coordinates into x and y arrays, the two moments divisions that give the centroid, and one np.linalg.norm call that returns the distance from the centroid to every boundary point. The indexing is identical across OpenCV 4.x, 4.5.4 included, so the same lines run on a bench PC, on a controller, or on a smart camera that hosts Python itself. A 60 × 20 pixel rectangle closes the loop with arithmetic you can check by hand.

What cv2.findContours returns

contours, hierarchy = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)

contours is a Python list with one entry per outer contour. Each entry is an np.ndarray of shape (N, 1, 2) and dtype int32; a real contour prints as, for example, (136, 1, 2). The middle dimension is a constant 1 left over from the C++ point-vector layout. It carries no information, and contour[:, 0] removes it.

N moves with the approximation mode. CHAIN_APPROX_NONE keeps every boundary pixel, roughly 160 points on a filled 60 × 20 rectangle. CHAIN_APPROX_SIMPLE drops every point on a straight run and keeps the 4 corners. RETR_EXTERNAL returns outer contours only, which is what you want when each part contributes one distance profile. Once you hold the points, the square detection with contour geometry guide shows the shape checks they feed.

What you need

  • Python 3 with NumPy and OpenCV 4.x.
  • thresh: a uint8 binary image, 255 at the part and 0 at the background — the output of cv2.inRange or cv2.threshold.
  • Nothing else: the splits and the distance call are NumPy slicing.

If the contour flickers between frames, fix the mask first. Image subtraction false defects: reduce contour artifacts traces unstable contours back to the threshold step.

Four steps from contour list to distance array

  1. Index one contour and collapse its (N, 1, 2) shape to (N, 2).
  2. Slice the x and y arrays.
  3. Compute the centroid from cv2.moments.
  4. Take np.linalg.norm along axis 1.

Step 1: one contour, collapsed

import cv2
import numpy as np

# thresh: np.uint8 binary image, 255 = part, 0 = background
contours, hierarchy = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)

contour = contours[0]   # np.ndarray, shape (N, 1, 2), dtype int32
xy = contour[:, 0]      # (N, 2) int32, column 0 = x, column 1 = y

Step 2: the x and y arrays

x = contour[:, 0, 0].astype(np.float64)   # (N,) float64
y = contour[:, 0, 1].astype(np.float64)   # (N,) float64

OpenCV stores points as (x, y): first coordinate across the frame, second down it. If you arrive from row-and-column image indexing, the order above is already the OpenCV one, so do not swap the columns.

Step 3: the centroid from moments

M = cv2.moments(contour)     # dict of float64 moments
cx = M["m10"] / M["m00"]     # float, px
cy = M["m01"] / M["m00"]     # float, px
cx = m10 / m00 cy = m01 / m00 d_i = sqrt((x_i - cx)^2 + (y_i - cy)^2)

m00 is the polygon area in px², m10 and m01 are the first-order moments in px³, and the ratios give the centroid shown in the OpenCV contour features tutorial. For a clean silhouette the result matches the filled region. If the centroid feeds a position output, read half-pixel offset in blob centroids first: the pixel-origin convention shifts the value by half a pixel.

Step 4: all distances in one line

center = np.array([cx, cy])                                # (2,) float64
d = np.linalg.norm(xy.astype(np.float64) - center, axis=1) # (N,) float64, px
print(d.min(), d.mean(), d.max())

The subtraction promotes the arrays to float64 before the norm runs; the NumPy norm reference gives the axis behavior. For the single distance from the centroid to the boundary itself, cv2.pointPolygonTest(contour, (cx, cy), True) returns it directly, positive inside the part.

Check the math on a 60 × 20 pixel rectangle

Build the contour by hand and every intermediate value is checkable:

sq = np.array([[[0, 0]], [[60, 0]], [[60, 20]], [[0, 20]]], dtype=np.int32)
Msq = cv2.moments(sq)
print(Msq["m10"] / Msq["m00"], Msq["m01"] / Msq["m00"])   # 30.0 10.0
dsq = np.linalg.norm(sq[:, 0].astype(np.float64) - np.array([30.0, 10.0]), axis=1)
print(np.round(dsq, 2))                                    # [31.62 31.62 31.62 31.62]

m00 = 60 × 20 = 1200 px², m10 = 30 × 1200 = 36,000 px³, m01 = 10 × 1200 = 12,000 px³, so cx = 36,000 / 1200 = 30.0 px and cy = 12,000 / 1200 = 10.0 px. Corner (0, 0) sits dx = 30 px across and dy = 10 px down from the center, giving d = sqrt(30² + 10²) = sqrt(1000) = 31.62 px. All four corners land on the same value.

A real rectangle processed with CHAIN_APPROX_NONE also contains edge midpoints, and those are where the profile earns its keep:

Point (x, y) dx (px) dy (px) d = sqrt(dx² + dy²) (px)
(0, 0), (60, 0), (60, 20), (0, 20) 30 10 31.62
(30, 0), (30, 20) 0 10 10.00
(0, 10), (60, 10) 30 0 30.00

Over those eight points the mean is (4 × 31.62 + 2 × 10.00 + 2 × 30.00) / 8 = 206.49 / 8 = 25.81 px, and the spread is 31.62 / 10.00 = 3.16. A circle scores 1.00 because every distance equals its radius, so max(d) / mean(d) is a calibration-free elongation check that costs you no extra hardware on any boundary.

CHAIN_APPROX_SIMPLE or CHAIN_APPROX_NONE

The mode decides which points exist, so it decides what d can see.

  • CHAIN_APPROX_NONE keeps every boundary pixel — about 160 points on the test rectangle, as counted above. Use it whenever d is the output: radius profiles, burr and nick checks, elongation ratios.
  • CHAIN_APPROX_SIMPLE keeps the endpoints of straight runs, 4 corners on the same rectangle. Collinear points contribute nothing to polygon moments, so cx and cy are unchanged for straight-edged parts, with less data to move.

Decision: position and centroid outputs run on SIMPLE; anything that reads the d array runs on NONE.

Common mistakes

  1. Unpacking three return values. OpenCV 3.x returned the image first; OpenCV 4.x returns contours and hierarchy. The two-value unpack above is correct for every 4.x build, 4.5.4 included.
  2. Swapping x and y. The columns are (x, y), not row and column. If a position output lands mirrored, print(xy[:3]) and watch which column changes as you slide the part across the frame.
  3. Reading len(contours) as a point count. It is the number of contours; one contour's point count is contour.shape[0].
  4. Indexing contours[0] on an empty frame. If thresholding finds no part, the list is empty and the index raises IndexError. Gate the output on len(contours) > 0.
  5. A per-point Python loop. Two thousand loop iterations cost milliseconds per part; the vectorized norm costs microseconds. The fix pattern in slow Python OpenCV code: fix per-pixel loops that kill FPS applies — one call over the whole array.

Running the loop on the line

The script runs wherever Python and OpenCV do, so the decision is where the loop lives. For one check at one station, a smart camera that hosts the interpreter on the device keeps detection, coordinate output and the pass/fail call in the camera body.

Smart cameras & vision sensorsSC / SC smart camera (X86 platform), 8.8 MP, USB3, Color/Mono, global shutter (LS-SMC-0017)LS-SMC-0017
Interface
USB 2.0
Resolution
6-10 MP
Sensor format
1"
Shutter
Global
$1,359.00View part

The LS-SMC-0017 is an x86 smart camera with an 8.8 MP global-shutter sensor and a C-mount lens mount, so the script and the lens share the same housing.

When several cameras feed one decision, move the same code onto a vision controller and let it carry the camera links and light timing.

Vision controllersHikrobot MV-VC2140-128G40-1T VC2100 Vision Controller, N97, 8GB+128G SSD+ 1THDDMV-VC2140-128G40-1T
LAN ports
3-4 LAN ports
USB 3 ports
2 USB 3 ports
Light outputs
4 built-in channels
Memory
8 GB
$1,679.00View partBuild a system with it

The MV-VC2140-128G40-1T pairs an Intel N97 with 8 GB of memory and four built-in light channels.

If the x and y arrays feed a robot cell, publish the centroid and the point list over Modbus TCP rather than files or prints; there is a walkthrough for sending OpenCV x and y coordinates to a robot over Modbus TCP. To size the station around this check — camera, lens, light, controller — start at the build-a-system configurator, or send us the layout through the contact page.

Questions

Why does each contour array have shape (N, 1, 2) and not (N, 2)?

The middle dimension is a constant 1 inherited from the C++ storage layout, where each contour row wraps a single point; OpenCV keeps it so the Python arrays map onto that structure. Slice contour[:, 0] to collapse it to (N, 2). The point count is contour.shape[0], each value is an int32 pixel index, and the column order is x then y.

How many contour points can this distance math handle?

Far more than a camera hands you. A part filling a 4096 × 3000 frame has a boundary of about 2 × (4096 + 3000) = 14,192 points, and CHAIN_APPROX_NONE on a real part gives far fewer. As float64 the x and y arrays take about 0.1 MB each, and np.linalg.norm over 14,000 points runs in the sub-millisecond range, so the frame rate is the limit, not the array math.

How do I convert contour distances from pixels to millimeters?

Multiply by the calibrated scale, field of view width divided by pixel count. At 60 mm across 2448 pixels, one pixel is 60 / 2448 = 0.0245 mm, so the 31.62 px corner distance of the worked rectangle is 31.62 × 0.0245 = 0.77 mm. Measure the scale with a calibration target after the lens is mounted and focused; nominal lens numbers move once focus shifts.

Should I run cv2.moments on the contour or on the binary image?

For this job, on the contour. cv2.moments(contour) treats the points as a closed polygon and computes its area moments, which is exactly the region a clean silhouette describes. cv2.moments(thresh) on the binary image sums every white pixel instead, so stray specks or a second part in frame shift m10 and m01. The contour path also reuses the array you already need for the distances.

Why does my measured radius read a little small against a gauge block?

Contour points are integer pixel indices, so the polygon passes through pixel centers while the true edge usually falls between two of them; each side can read up to half a pixel short. At 0.0245 mm/px that is 0.012 mm per side. Threshold a gauge standard exactly like production parts, measure the offset once, and fold it into the specification rather than chasing it frame by frame.