Code readingHow-to

Find contour and line intersection coordinates in Python with OpenCV

Solve f(q + t·v) = 0 segment by segment to get exact intersection coordinates where a line crosses an OpenCV contour, with a worked example and test code.

LumaSpec Vision10 min read Share on LinkedIn

On this page 10 sections
  1. 01Steps at a glance
  2. 02Get the contour as a coordinate array
  3. 03Write the line as p = q + t·v
  4. 04Solve f(q + t·v) = 0 one segment at a time
  5. 05Worked example: an arm contour crossed by the scan line y = 240
  6. 06Cross-check the count with pointPolygonTest
  7. 07No solution, one solution, many solutions
  8. 08Common mistakes
  9. 09Size the camera that feeds the solver
  10. 10Questions

A drawn overlay line that visibly crosses a contour is not a result: your measurement code needs the crossing as numbers. Set the line up as one point q and one direction v, solve the contour boundary condition f(q + t·v) = 0 segment by segment, and every intersection comes back as an exact float pixel coordinate — including the case that matters on real parts, where a limb-like silhouette crosses the line twice. Everything runs on the coordinate array cv2.findContours already gave you — computed on a frame from an area scan camera or exported from one of the smart cameras that inspect on the device. One short NumPy function does the whole job, with no per-pixel loop.

Steps at a glance

  1. Pull the contour points into an (N, 2) float array from the cv2.findContours result.
  2. Write the gauge line as a point q and a direction v, so any point on it is p = q + t·v.
  3. For each contour segment with start Pᵢ and direction d = Pᵢ₊₁ − Pᵢ, compute t and s from the cross-product formulas.
  4. Keep every segment where cross(v, d) ≠ 0 and 0 ≤ s < 1; the crossing point is q + t·v.
  5. Sort the hits along the line, check the count against what the part should give, and only then round for drawing.
  6. Cross-check the count with sampled pointPolygonTest sign changes, then scale pixels to millimetres with your calibration.

Get the contour as a coordinate array

In OpenCV 4, cv2.findContours returns two values: the list of contours and the hierarchy. Each contour is an array of shape (N, 1, 2), integer coordinates in tracing order around the boundary. Reshape to (N, 2) and cast to float64 once at the start; the solver then stays in floats end to end.

The order is what makes the geometry work: point i and point i + 1 form segment i, and the wrap from the last point back to the first closes the polygon. If the coordinates come from somewhere else — a chain your own code assembles, or points read back from a saved overlay — check they form an ordered boundary before solving; unordered points make "segment" meaningless. The reshape behind this is the same one used when contour coordinates become x and y arrays, and when the boundary itself is too rough to trust, fitting an ellipse to a noisy contour shows how to replace it with a clean analytic curve.

Write the line as p = q + t·v

A gauge line is fully defined by a point q on it and a direction v: every point on the line is p = q + t·v, with t running positive along v. For a horizontal scan line at height 240 px, take q = (0, 240) and v = (1, 0); for any drawn segment with endpoints a and b, take q = a and v = b − a.

p = q + t·v
f(q + t·v) = 0

The contour boundary is the implicit curve f(p) = 0. Substituting the line turns "where does it cross?" into one equation in t: f(q + t·v) = 0. A fitted straight line has one answer; a closed polygon boundary is piecewise linear, so the answers are the segments the line stabs — and a closed shape crossed clear through is stabbed an even number of times. If the line comes from a Hough fit, check the fit is worth solving first: Hough line detection for cracks: when to switch to contour stats shows when the line model stops matching the boundary.

Solve f(q + t·v) = 0 one segment at a time

Each segment is a straight edge, and two straight lines in a plane meet in a 2×2 linear system. Write segment i as Pᵢ + s·d with d = Pᵢ₊₁ − Pᵢ and 0 ≤ s < 1. Setting q + t·v = Pᵢ + s·d and taking two-dimensional cross products isolates t and s directly:

w = Pi - q, d = Pi+1 - Pi t = cross(w, d) / cross(v, d) s = cross(w, v) / cross(v, d) cross(a, b) = ax·by - ay·bx

Skip the segment when cross(v, d) = 0 — the gauge line and the edge are parallel. Accept the crossing when 0 ≤ s < 1; the strict upper bound makes a line passing exactly through a shared vertex count once, not twice. Every accepted segment yields the point q + t·v. All N segments solve in one vectorized pass:

import numpy as np

def line_contour_intersections(contour, q, v):
    # contour: (N, 1, 2) int array as returned by cv2.findContours
    # q, v: tuples of floats; v must not be (0, 0)
    # returns a (K, 2) float64 array of (x, y) crossing points
    pts = contour.reshape(-1, 2).astype(np.float64)
    d = np.roll(pts, -1, axis=0) - pts      # segment directions, last -> first included
    w = pts - np.asarray(q, dtype=np.float64)
    cross_vd = v[0] * d[:, 1] - v[1] * d[:, 0]
    parallel = np.abs(cross_vd) < 1e-9
    denom = np.where(parallel, 1.0, cross_vd)
    t = (w[:, 0] * d[:, 1] - w[:, 1] * d[:, 0]) / denom
    s = (w[:, 0] * v[1] - w[:, 1] * v[0]) / denom
    hit = (~parallel) & (s >= 0.0) & (s < 1.0)
    return np.asarray(q) + t[hit, None] * np.asarray(v)

The whole contour costs one pass over N edges — no sampling, no per-pixel scanning, the same reason per-pixel Python loops wreck frame rate in the processing stage.

Worked example: an arm contour crossed by the scan line y = 240

Inputs. The contour is the ordered boundary of an arm-like silhouette, four vertices, integer pixels as findContours returns them: P0 = (100, 320), P1 = (220, 200), P2 = (340, 200), P3 = (460, 320). The gauge line runs horizontally at mid-height: q = (0, 240), v = (1, 0). A healthy part must give exactly two crossings, one per edge.

Rule. With w = Pᵢ − q and d = Pᵢ₊₁ − Pᵢ, accept the edge when cross(v, d) ≠ 0 and 0 ≤ s < 1, where s = cross(w, v)/cross(v, d); the crossing sits at t = cross(w, d)/cross(v, d), point q + t·v.

Arithmetic, left edge P0→P1: d = (120, −120), w = (100, 80). cross(v, d) = 1·(−120) − 0·120 = −120. t = (100·(−120) − 80·120)/(−120) = −21600/−120 = 180. s = (100·0 − 80·1)/(−120) = 0.667, accepted, point (0 + 180·1, 240) = (180.0, 240.0).

Arithmetic, right edge P2→P3: d = (120, 120), w = (340, −40). cross(v, d) = 120. t = (340·120 − (−40)·120)/120 = 45600/120 = 380. s = (340·0 − (−40)·1)/120 = 0.333, accepted, point (380.0, 240.0).

The two horizontal edges — at y = 200, and the closing edge at y = 320 — give cross(v, d) = 0 and drop out as parallel.

Test:

contour = np.array([[[100, 320]], [[220, 200]],
                    [[340, 200]], [[460, 320]]], dtype=np.int32)
pts = line_contour_intersections(contour, q=(0.0, 240.0), v=(1.0, 0.0))
assert np.allclose(pts, [[180.0, 240.0], [380.0, 240.0]])

Result and decision. Sorted along the line, the crossings are (180.0, 240.0) and (380.0, 240.0), so the chord is 380 − 180 = 200 px. The hard gate is the count: two hits means both edges were found, while one or three means a broken threshold or a grazing corner, and the frame goes to review. The soft gate is the width: 200 px against your tolerance band. At an illustrative calibration of 0.05 mm/px, the chord measures 200 × 0.05 = 10.0 mm at the calibrated plane.

Cross-check the count with pointPolygonTest

Before you trust the solver on live images, give it an independent referee. Sample the ray at 1 px steps and ask cv2.pointPolygonTest(contour, pt, True) for the signed distance at each sample: positive inside the contour, negative outside. Every sign change brackets one crossing, so the number of sign changes must equal the number of returned points.

# cv2 and numpy as np already imported
def crossing_count_check(contour, q, v, t_max, step=1.0):
    ts = np.arange(0.0, t_max, step)
    inside = np.array([
        cv2.pointPolygonTest(contour,
                             (float(q[0] + t * v[0]), float(q[1] + t * v[1])),
                             True) > 0.0
        for t in ts
    ])
    return int(np.count_nonzero(inside[:-1] != inside[1:]))

For the worked example this returns 2, matching the solver. On straight edges away from corners the distance changes linearly along the ray, so interpolating between the two samples around a sign change lands within a fraction of a pixel of the exact t — a good estimate, but the segment solve remains the exact source. Keep the check in tests and first-article runs, not in the hot loop: each pointPolygonTest call walks the whole boundary, so a few hundred samples cost far more than the single-pass solve.

No solution, one solution, many solutions

  • Zero hits: the line misses the part, or an input is broken. Confirm the contour is the boundary you meant (the retrieval mode decides whether inner holes appear at all; requesting only outer contours is the RETR_EXTERNAL mode), that q lies on the drawn line, and that v points along it.
  • One hit on a closed silhouette: geometrically suspicious, because a line entering and leaving a closed shape crosses an even number of times. One hit usually means the line grazes a vertex exactly, runs along an edge that was skipped as parallel, or the chain contains a duplicate vertex. Log the frame and inspect it rather than shipping the lone point.
  • Many hits: normal for elongated or concave parts. Sort the points along the line — for a horizontal scan line, by x — then pair first with second, third with fourth, and so on; each pair brackets one pass through the part. A convex part that returns more hits than expected is telling you the threshold is noisy; fix the binary image before blaming the solver.

If the quantity you actually need is the single farthest boundary point — a tip or a leading edge — that is an extreme-point query, not a crossing; finding a droplet tip across changing shapes walks that case.

Common mistakes

  • Reading the crossing off the overlay. A 3 px marker or stroke puts the crossing anywhere within a pixel or two of truth; the solver puts it on the edge. Draw for humans, solve for numbers — and if you annotate stored images, know what burning a drawn ROI into the pixels does before measuring from them. Stroke width for a machine-vision overlay is a drawing decision, never a measurement one.
  • Rounding too early. Keep float64 from solve to mm conversion; cast to int only at draw time.
  • Losing the closing edge. A manual loop over range(len(pts) - 1) drops the last→first segment and quietly deletes the exit crossing; the np.roll in the solver handles the wrap.
  • Treating the sampled sign change as the answer. It brackets the crossing; the segment solve nails it.
  • Ignoring the plane. Pixels become millimetres only on the plane you calibrated. If the part can tilt, map the points through a plane transform first — mapping pixel coordinates through a homography shows it.

Size the camera that feeds the solver

The solver adds no error; the polygon does. findContours tracks a thresholded boundary in whole pixels, so expect roughly half a pixel of quantization between the polygon and the true grey edge. Size the view so the chord you measure spans at least 100 px: at 100 px across, half a pixel is 0.5 % of the reading; at 200 px across, 0.25 %. The camera resolution calculator turns your part size and that px-across requirement into a sensor pixel count, the camera finder filters by it, and the camera step of the configurator pairs the sensor with a mount and lens. If you would rather send the part drawing and the tolerance band, contact us and we will size the set-up with you.

Questions

How do I turn a Hough line in rho-theta form into q and v?

cv2.HoughLines returns each line as rho and theta with x·cos(theta) + y·sin(theta) = rho. Take the closest point to the origin as q = (rho·cos(theta), rho·sin(theta)) and the direction v = (-sin(theta), cos(theta)). Feed those into the same segment solver; the intersections come out identical to within floating-point rounding.

What scale converts a 200 px chord into millimetres?

Use your calibrated pixel size, not a sensor guess. If your field of view is 40 mm across 2000 recorded pixels, one pixel is 40/2000 = 0.02 mm, so the 200 px chord is 200 × 0.02 = 4.0 mm at the calibrated plane. For an angled part, map the points through a homography first, because the scale then changes across the view.

Can cv2.intersectConvexConvex find these crossing points instead?

It solves a different problem: cv2.intersectConvexConvex returns the polygon that two convex contours share, which suits area overlap, not point coordinates. Feeding it a one-pixel-thick line polygon invites numerical noise at the sliver corners. For crossing points as coordinates, the per-segment solve is simpler, exact for the polygon and returns as many points as the geometry hides.

Does the solver work on an open contour instead of a closed one?

Yes. np.roll pairs each point with the next and wraps the last back to the first, which closes the polygon. For an open chain, pair neighbours without the wrap and keep the same 0 ≤ s < 1 test. Count the expected crossings first: an open boundary crossed once may be legitimate, while a closed silhouette should give an even number.

How accurate is the intersection point?

Repeatability and accuracy differ: on a stable image the solve returns the same floats every run, but accuracy against the true edge is bounded by the integer polygon, about half a pixel of quantization. Improve accuracy at the source: better contrast, more pixels across the feature, or grey-level sub-pixel edge refinement; the line intersection math itself adds nothing.