Skip to main content

Contours & shape analysis

A contour is the outline of a connected region in a binary image — the boundary curve that traces around one solid blob of foreground. Once you have that outline as data you can ask the questions that matter for most vision work: how big is this shape, where is its centre, how many corners does it have, does it enclose a hole? findContours traces those outlines; scalacv hands them back as plain, immutable Scala data you can measure, filter, and draw. Every snippet here is compiled by mdoc against the real library, so it cannot drift out of date.

When you reach for contours

Contours are the workhorse of classical (non-neural) shape analysis: counting objects on a conveyor, measuring blobs under a microscope, finding the paper in a document scan, isolating a coloured region after colour masking, turning a segmentation mask back into per-object boxes. If you can get your subject to stand out as bright-on-dark (or dark-on-bright), contours turn that mask into countable, measurable objects.

You need a binary image first

findContours works on a single-channel 8-bit image (CV_8UC1, or CV_32SC1): non-zero pixels are foreground, zero is background. Anything else raises CvError.NativeCall. In practice you reach that shape one of two ways:

  • Threshold a greyscale image — everything brighter (or darker) than a cutoff becomes foreground.
  • Canny it — the edge map is already CV_8UC1, so you can trace the edges directly.

At the high level both are one call, and Image.contours runs findContours for you:

// Threshold to a mask, then trace the outlines.
Image.reading("coins.jpg")(_.gray.threshold(127).contours())

// Or trace Canny edges directly.
Image.reading("coins.jpg")(_.gray.canny(80, 160).contours())
Threshold vs. Canny

Threshold gives you the outline of a filled region — one contour per blob, ready to measure area and centroid. Canny gives you the outline of edges — you may get the outer boundary and an inner one for a thick stroke, because both sides of the stroke are edges. For counting and measuring solid objects, threshold first; for tracing thin structure, Canny. See image processing for both.

The examples below skip the file and draw their own binary images, so they run against real pixels with nothing to download. This helper paints two solid white shapes on black — a 100×50 rectangle and a circle:

def shapes(): Mat =
val m = Mat(120, 220, CvType.CV_8UC1, org.opencv.core.Scalar(0))
Imgproc.rectangle(m, org.opencv.core.Point(20, 20), org.opencv.core.Point(119, 69),
org.opencv.core.Scalar(255), -1) // thickness -1 = filled
Imgproc.circle(m, org.opencv.core.Point(170, 60), 30, org.opencv.core.Scalar(255), -1)
m

Finding contours

findContours is a mid-level extension on Mat. It returns a Seq[Contour] — one entry per outline, in OpenCV's order:

Managed.use(shapes())(_.findContours().size)
// res2: Int = 2

Two shapes, two contours. Note the Managed.use: the source Mat is a live native handle and must be released. The Seq[Contour] it returns is not — read on.

What comes back is safe to keep

Contour is ordinary immutable Scala data. findContours copies each outline's points across the native boundary and frees the native buffers before it returns, so the result stays valid after the source Mat is released — as here, where the Managed.use block has already freed the Mat by the time we measure:

val boxes: Seq[Rect] =
Managed.use(shapes())(_.findContours().map(_.boundingRect))
// boxes: Seq[Rect] = Vector(
// Rect(x = 140, y = 30, width = 61, height = 61),
// Rect(x = 20, y = 20, width = 100, height = 50)
// )
boxes
// res3: Seq[Rect] = Vector(
// Rect(x = 140, y = 30, width = 61, height = 61),
// Rect(x = 20, y = 20, width = 100, height = 50)
// )

This is deliberate, and it is the one place scalacv most visibly earns its keep. The raw OpenCV Java API hands contours back as a java.util.List[MatOfPoint] — a list of live native handles the caller is expected to free individually. Nothing in that signature says the list owns anything, and the handles survive every reasonable-looking use of the result, so leaking them is the single most reliable leak in the Java API. findContours releases every MatOfPoint (and the hierarchy Mat) in a finally block and gives you copied-out data instead. There are no handles in a Contour to forget. (The same design runs through the whole library — see Mat lifecycle for why.)

Measuring a contour

Each Contour carries its points: Seq[Point] and computes its metrics lazily, delegating to OpenCV so the numbers match what the rest of the ecosystem reports:

val rect: Contour =
Managed.use(shapes())(_.findContours().head) // the rectangle
// rect: Contour = Contour(
// ArraySeq(
// Point(x = 170.0, y = 30.0),
// Point(x = 169.0, y = 31.0),
// Point(x = 163.0, y = 31.0),
// Point(x = 162.0, y = 32.0),
// Point(x = 160.0, y = 32.0),
// Point(x = 159.0, y = 33.0),
// Point(x = 157.0, y = 33.0),
// Point(x = 155.0, y = 35.0),
// Point(x = 154.0, y = 35.0),
// Point(x = 153.0, y = 36.0),
// Point(x = 152.0, y = 36.0),
// Point(x = 146.0, y = 42.0),
// Point(x = 146.0, y = 43.0),
// Point(x = 145.0, y = 44.0),
// Point(x = 145.0, y = 45.0),
// Point(x = 143.0, y = 47.0),
// Point(x = 143.0, y = 49.0),
// Point(x = 142.0, y = 50.0),
// Point(x = 142.0, y = 52.0),
// Point(x = 141.0, y = 53.0),
// Point(x = 141.0, y = 59.0),
// Point(x = 140.0, y = 60.0),
// Point(x = 141.0, y = 61.0),
// Point(x = 141.0, y = 67.0),
// Point(x = 142.0, y = 68.0),
// Point(x = 142.0, y = 70.0),
// Point(x = 143.0, y = 71.0),
// Point(x = 143.0, y = 73.0),
// Point(x = 145.0, y = 75.0),
// Point(x = 145.0, y = 76.0),
// Point(x = 146.0, y = 77.0),
// Point(x = 146.0, y = 78.0),
// Point(x = 152.0, y = 84.0),
// Point(x = 153.0, y = 84.0),
// Point(x = 154.0, y = 85.0),
// Point(x = 155.0, y = 85.0),
// Point(x = 157.0, y = 87.0),
// Point(x = 159.0, y = 87.0),
// Point(x = 160.0, y = 88.0),
// Point(x = 162.0, y = 88.0),
// Point(x = 163.0, y = 89.0),
// Point(x = 169.0, y = 89.0),
// Point(x = 170.0, y = 90.0),
// Point(x = 171.0, y = 89.0),
// Point(x = 177.0, y = 89.0),
// Point(x = 178.0, y = 88.0),
// Point(x = 180.0, y = 88.0),
// ...
(rect.boundingRect, rect.area, rect.perimeter)
// res4: Tuple3[Rect, Double, Double] = (
// Rect(x = 140, y = 30, width = 61, height = 61),
// 2736.0,
// 197.82337379455566
// )

Here is the full surface of a Contour. Everything but points is lazy, so a contour you never measure never pays for the native call:

MemberTypeWhat it gives you
pointsSeq[Point]the outline vertices, sub-pixel Double (widened int32s from findContours)
isEmptyBooleantrue only for a hand-built empty contour — findContours never emits one
boundingRectRectthe upright bounding box (Imgproc.boundingRect)
areaDoubleenclosed area by the shoelace formula (Imgproc.contourArea), always ≥ 0
perimeterDoubleclosed arc length (Imgproc.arcLength, closed = true)
centroidOption[Point]centre of mass from image moments; None when it is undefined
convexHullContourthe tightest convex outline enclosing the points
approx(epsilon, closed)ContourRamer–Douglas–Peucker simplification
  • boundingRect: Rect — the upright bounding box. OpenCV's box is inclusive of the extreme pixels, so a shape spanning x = 20..119 reports width == 100, not 99. (Rect itself is covered in Geometry.)
  • area: Double — the enclosed area by the shoelace formula, not the filled pixel count. Area is measured between the centres of the boundary pixels, so our 100×50 filled rectangle reports 99 × 49 = 4851, and never boundingRect.area. It is always non-negative.
  • perimeter: Double — the closed arc length; contours from findContours are always closed curves.

isEmpty is true only for a Contour built by hand — findContours never emits one — and the metrics short-circuit to zero rather than reaching native code in that case.

More shape metrics: centroid, hull, simplification

Three further members answer the questions shape work most often asks after size.

Centroid — where the shape is

centroid is the centre of mass, computed from image moments. It is an Option because it is undefined for an empty contour or a zero-area one (a single point or a collinear run), where the division m10/m00 would be by zero:

Managed.use(shapes())(_.findContours().head.centroid).map(p => (p.x.round, p.y.round))
// res5: Option[Tuple2[Long, Long]] = Some((170L, 60L))

The rectangle spans x = 20..119 and y = 20..69, so its centre lands near (70, 44). Centroids are how you track an object frame to frame, sort blobs left-to-right, or place a label in the middle of a shape.

Convex hull — the shape with its dents filled

convexHull returns a new Contour: the tightest convex outline enclosing the points. The vertices it returns are exactly points of the original contour (OpenCV computes indices, which scalacv maps back), not re-derived coordinates. A filled rectangle read with None approximation is a long chain of boundary pixels; its hull is just the four corners:

Managed.use(shapes())(_.findContours(approximation = ContourApproximation.None).head.convexHull.points.size)
// res6: Int = 28

The hull is the standard way to measure convexity — compare contour.area to contour.convexHull.area and a low ratio tells you the shape has deep concavities (fingers of a hand, teeth of a gear).

approx — fewer vertices, same shape

approx(epsilon) runs Ramer–Douglas–Peucker simplification: it drops vertices, keeping none more than epsilon pixels off the original outline. epsilon is usually a small fraction of the perimeter. This is the classic "how many sides does this polygon have?" tool — a noisy quadrilateral collapses back to four corners:

val quad = Managed.use(shapes())(_.findContours(approximation = ContourApproximation.None).head)
quad.approx(0.02 * quad.perimeter).points.size
// res7: Int = 8
Counting sides

approx(0.02 * perimeter).points.size is the idiom behind shape classifiers: 3 → triangle, 4 → quad, and a count that stays high as you shrink epsilon → a circle. Tune the 0.02 up to tolerate more noise, down to keep more detail.

Retrieval: which contours you get

The first knob is retrieval, a ContourRetrieval. The default, External, returns only the outermost outline of each region — what callers who ignore nesting almost always mean. The others also return the boundaries of holes. Consider a square with a square hole punched out of it:

def ring(): Mat =
val m = Mat(120, 120, CvType.CV_8UC1, org.opencv.core.Scalar(0))
Imgproc.rectangle(m, org.opencv.core.Point(20, 20), org.opencv.core.Point(99, 99),
org.opencv.core.Scalar(255), -1)
Imgproc.rectangle(m, org.opencv.core.Point(45, 45), org.opencv.core.Point(74, 74),
org.opencv.core.Scalar(0), -1) // punch a hole
m
Managed.use(ring())(_.findContours(retrieval = ContourRetrieval.External).size) // outer only
// res8: Int = 1
Managed.use(ring())(_.findContours(retrieval = ContourRetrieval.List).size) // outer + hole
// res9: Int = 2
ModeReturnsNesting
Externalthe outermost outline of each region only (default)ignored
Listevery contour, including holes, as a flat listflattened
CCompevery contour, organised two-level (outer boundaries + their holes)two-level
Treeevery contour, in a full parent/child nesting treefull tree
Hierarchy is not exposed (yet)

CComp and Tree compute a parent/child hierarchy, but scalacv does not currently surface it — handing back OpenCV's raw Nx1 CV_32SC4 index Mat would be exactly the untyped, unmanaged shape this library exists to remove. A typed nesting API can be added later. Until then, choose between External and List unless you only need the count.

Approximation: how the outline is compressed

The second knob is approximation, a ContourApproximation. The default, Simple, collapses straight runs to their endpoints, so an axis-aligned rectangle comes back as its 4 corners. None keeps every pixel on the boundary:

Managed.use(shapes())(_.findContours(approximation = ContourApproximation.Simple).head.points.size)
// res10: Int = 84
Managed.use(shapes())(_.findContours(approximation = ContourApproximation.None).head.points.size)
// res11: Int = 168
ModeEffectReach for it when
Simplecollapses straight runs to their endpoints (default)corner-counting, polygon work, drawing
Nonekeeps every pixel on the boundaryyou need the full pixel chain (arc-length precision, curvature)
Tc89L1Teh–Chin chain approximation (L1)you want a smoother poly than Simple gives
Tc89KcosTeh–Chin chain approximation (k-cosine)as above, different corner metric

Simple is what you want for corner-counting and polygon work; None when you need the full pixel chain.

Filtering: keeping the shapes you want

Because the result is plain Seq[Contour], the whole Scala collections API is available. The near-universal first move is to throw away noise by area — tiny contours are almost always specks:

Managed.use(shapes())(_.findContours().map(_.area.round).sorted)
// res12: Seq[Long] = Vector(2736L, 4851L)
Managed.use(shapes())(_.findContours().count(_.area > 1000))
// res13: Int = 2

From there it composes like any other data: filter by area or perimeter, sortBy(_.boundingRect.x) to order left-to-right, maxBy(_.area) to grab the largest object, map(_.centroid) to collect centres. None of it touches native memory.

Drawing contours back

drawContours is the renderer for what findContours returns — see Drawing for the full set of draw ops. Passed a stroke it outlines each contour; passed Thickness.Filled it fills them, which is the usual way to rebuild a mask from a set of contours. Here we trace the shapes, then paint the outermost ones solid into a fresh canvas and count the foreground pixels:

val filledPixels: Int =
val cs = Managed.use(shapes())(_.findContours())
Managed.use(Mat(120, 220, CvType.CV_8UC1, org.opencv.core.Scalar(0))) { mask =>
mask.drawContours(cs, Scalar.White, Thickness.Filled)
org.opencv.core.Core.countNonZero(mask)
}
// filledPixels: Int = 7821

drawContours mutates the receiver and returns Unit; the mid-level version above takes a raw Mat. On an Image, the same call is a transform that consumes and returns the image (Thickness.Default for outlines, Thickness.Filled for a mask).

The high-level view

Everything above is the mid-level Mat API. On an Image, contours is a query — it borrows the image and returns plain data, leaving it alive for the next step (see The Image API):

val im = Image.wrap(Managed(shapes()))
im.contours(retrieval = ContourRetrieval.External).map(_.boundingRect)
// res14: Seq[Rect] = Vector(
// Rect(x = 140, y = 30, width = 61, height = 61),
// Rect(x = 20, y = 20, width = 100, height = 50)
// )

Because a query leaves the image alive, close it yourself when the surrounding work is done (or wrap the whole thing in Image.reading, which closes for you). The full read → threshold → trace → annotate chain reads as one line:

Image.reading("parts.png") { img =>
val cs = img.gray.threshold(127).contours()
cs.filter(_.area > 500).map(_.boundingRect) // keep the big ones
}
warning
contours borrows, transforms consume

img.contours(...) is a query: it leaves img alive, so you can keep chaining or must close() it yourself. img.gray, img.threshold(...) and every draw* on an Image are transforms: they consume the receiver and hand back a new image. Reusing a consumed Image throws — take a .copy first if you need to branch. This distinction is the whole of the Image API.

Next

  • The Image API — queries, transforms, and lifetimes
  • Image processing — threshold, Canny, and the mid-level ops that produce a binary image
  • DrawingdrawContours, Thickness, and the rest of the annotation surface
  • GeometryRect, Point, and the value types the metrics return