Skip to main content

Hough transforms

The Hough line transforms answer a deceptively simple question: given a scatter of edge pixels, which straight lines run through them? They are the classical way to find the edges of a table, the bars of a barcode, lane markings on a road, or the sides of a scanned document — anything where the structure you care about is made of straight runs. Each edge pixel "votes" for every line that could pass through it; the lines that collect the most votes win.

OpenCV returns the winners as an anonymous multi-channel Mat whose element type differs per transform and whose channels have no names — a shape you have to know three separate facts to read correctly, where getting any of them wrong either aborts from JNI or, worse, silently reinterprets the bits. scalacv decodes each into a small, immutable Scala case class so you never touch that Mat or guess at its layout.

Every snippet below runs against a synthetic edge image, so no fixture file is needed. All three transforms require a non-empty 8-bit single-channel image (CV_8UC1); handing them anything else fails with a named precondition rather than a JNI abort.

Two flavours

MethodReturnsA line is…Use when
houghLinesSeq[PolarLine]infinite, in polar form (rho, theta)you want the line's orientation/position, and endpoints are irrelevant (lane direction, dominant angle)
houghLinesPSeq[Segment]finite, with pixel endpoints (x1, y1, x2, y2)you want to draw or measure the actual runs of edge (table edges, bar-code bars, wireframe sides)

The probabilistic variant (houghLinesP) is the one most code wants: it reports where each line actually starts and stops, is cheaper, and its output renders directly with drawSegments.

tip
Start with houghLinesP

Unless you specifically need an infinite line's angle (a vanishing-point or dominant-orientation problem), reach for houghLinesP. Real endpoints are almost always what you want, and Segment gives you length, start, and end for free.

Building an edge image to work with

val edges = Mat(200, 200, CvType.CV_8UC1, org.opencv.core.Scalar(0))
// One horizontal and one vertical bright line — this stands in for a real edge map.
Imgproc.line(edges, org.opencv.core.Point(20, 30), org.opencv.core.Point(180, 30), org.opencv.core.Scalar(255), 1)
Imgproc.line(edges, org.opencv.core.Point(100, 10), org.opencv.core.Point(100, 190), org.opencv.core.Scalar(255), 1)

Standard: infinite lines

houghLines reports each line in Hesse normal form. rho is the signed distance in pixels from the image origin to the line; theta is the angle of the normal to the line, in radians — 0 is a vertical line, Pi/2 a horizontal one. threshold is the minimum number of accumulator votes (roughly, collinear edge pixels) a line needs to be reported, and is the only argument without a sensible default.

edges.houghLines(threshold = 120)
// res3: Seq[PolarLine] = Vector(
// PolarLine(rho = 100.0F, theta = 0.0F),
// PolarLine(rho = 30.0F, theta = 1.5707964F)
// )

PolarLine(rho, theta) carries Float fields because the underlying Mat for this transform is CV_32FC2 — a two-channel float. Reading it any other way would misinterpret the bits.

Because theta describes the normal, it is the usual source of confusion. Converting to degrees makes the orientations obvious — our horizontal line's normal is vertical (90°) and vice-versa:

edges.houghLines(threshold = 120).map(l => math.round(math.toDegrees(l.theta.toDouble)))
// res4: Seq[Long] = Vector(0L, 90L)

houghLines takes several more knobs, all with sensible defaults:

ParamDefaultMeaning
threshold(required)minimum accumulator votes for a line to be reported
rho1.0accumulator distance resolution, in pixels
thetaPi/180accumulator angle resolution, in radians (1°)
srn0.0divisor for a coarse-to-fine rho; 0 (with stn) selects the classic transform
stn0.0divisor for a coarse-to-fine theta
minTheta0.0lower bound on the reported angle, radians
maxThetaPiupper bound on the reported angle, radians
Constrain the angle to speed things up

If you only want near-horizontal lines, set minTheta/maxTheta around Pi/2. The accumulator ignores every other orientation, which is both faster and far less noisy than filtering the results afterwards.

Probabilistic: finite segments

houghLinesP gives real endpoints. minLineLength drops short segments and maxLineGap is the largest break, in pixels, that will still be bridged into one segment.

edges.houghLinesP(threshold = 50, minLineLength = 50, maxLineGap = 5)
// res5: Seq[Segment] = Vector(
// Segment(x1 = 100, y1 = 190, x2 = 100, y2 = 10),
// Segment(x1 = 20, y1 = 30, x2 = 180, y2 = 30)
// )
ParamDefaultMeaning
threshold(required)minimum accumulator votes for a line to be reported
rho1.0accumulator distance resolution, in pixels
thetaPi/180accumulator angle resolution, in radians (1°)
minLineLength0.0segments shorter than this are discarded; 0 keeps everything
maxLineGap0.0largest gap, in pixels, bridged into one segment

Segment(x1, y1, x2, y2) fields are Int, not Float, and this is not a rounding choice: the raw Mat here is CV_32SC4, genuine int32 pixel coordinates. A float-typed read of that Mat throws, so scalacv decodes it as integers. Segment also gives you start/end Points and a length, which makes the near-universal "keep only the long ones" filter a one-liner:

edges.houghLinesP(threshold = 50).filter(_.length > 100).map(_.length)
// res6: Seq[Double] = Vector(180.0)
note
maxLineGap fights fragmentation

A real Canny edge is rarely one unbroken run — noise and anti-aliasing chop it into pieces. Bump maxLineGap (say to 10–20) to stitch a dashed-looking edge back into one segment; leave it small when you genuinely want to detect the gaps (dashed lane lines, perforations).

Keeping the vote counts

The plain transform returns lines already sorted by strength but discards the magnitudes. houghLinesWithAccumulator keeps them, which is the only way to rank or threshold results yourself:

edges.houghLinesWithAccumulator(threshold = 120).take(3)
// res7: Seq[PolarLineWithVotes] = Vector(
// PolarLineWithVotes(rho = 100.0F, theta = 0.0F, votes = 181),
// PolarLineWithVotes(rho = 30.0F, theta = 1.5707964F, votes = 161)
// )

The votes are the accumulator scores — the number of edge pixels that voted for each line — so a bare list of them tells you at a glance how much stronger the top line is than the runners-up:

edges.houghLinesWithAccumulator(threshold = 120).map(_.votes)
// res8: Seq[Int] = Vector(181, 161)

PolarLineWithVotes(rho, theta, votes) comes from a third Mat shape again — CV_32FC3, where the third channel is the vote count. Call .line on one to drop the votes and get a plain PolarLine.

The pipeline: edges first

Hough needs edges, not a raw image — feed it a photo and it detects nothing. The normal first step is canny, whose output is always CV_8UC1 and so drops straight into any of the three transforms. Here a filled square stands in for a scene; Canny turns it into its four-sided outline, which houghLinesP recovers as segments:

val shape = Mat(200, 200, CvType.CV_8UC1, org.opencv.core.Scalar(0))
Imgproc.rectangle(shape, org.opencv.core.Point(50, 50), org.opencv.core.Point(150, 150), org.opencv.core.Scalar(255), -1)
shape.canny(50, 150).use { outline =>
outline.houghLinesP(threshold = 40, minLineLength = 40, maxLineGap = 10).size
}
// res10: Int = 4

canny returns a Managed[Mat]; its use borrows the edge image for the transform and releases it afterwards, so the intermediate never leaks. On an Image, the same pipeline chains through .mat — a borrow, so the image stays alive to close:

Image.reading("floor.jpg") { img =>
val e = img.gray.canny(80, 160) // Image, CV_8UC1
val lines = e.mat.houghLinesP(threshold = 60, minLineLength = 50, maxLineGap = 10)
e.close()
lines
}
Wrong type in, exception out

All three transforms assert CV_8UC1. Passing a colour image (or an empty one) fails a precondition before reaching native code, so you get a message naming the offending type instead of a JNI abort:

Managed.use(Mat(60, 60, CvType.CV_8UC3, org.opencv.core.Scalar(0)))(_.houghLines(threshold = 10))
// java.lang.IllegalArgumentException: requirement failed: houghLines needs a non-empty 8-bit single-channel image (typically the output of Canny), but got 60x60 of type CV_8UC3
// at scala.Predef$.require(Predef.scala:337)
// at scalacv.Hough$.requireEdgeImage(Hough.scala:176)
// at scalacv.Hough$package$.houghLines(Hough.scala:93)
// at repl.MdocSession$MdocApp.$init$$$anonfun$6$$anonfun$2(hough.md:114)
// at scalacv.Managed.use(Managed.scala:103)
// at scalacv.Managed$.use(Managed.scala:127)
// at repl.MdocSession$MdocApp.$init$$$anonfun$6(hough.md:114)

Rendering the segments

houghLinesP results are invisible until you draw them. drawSegments is their renderer — it strokes each Segment into a Mat you own (it mutates that Mat, like every draw* op):

val segments = edges.houghLinesP(threshold = 50, minLineLength = 50, maxLineGap = 5)

val canvas = Mat(200, 200, CvType.CV_8UC3, org.opencv.core.Scalar(0))
canvas.drawSegments(segments, Scalar.Red, Thickness.Stroke(2))
Images.encode(canvas, ".png").map(_.length)
// res13: Either[CvError, Int] = Right(1072)

Tuning cheat-sheet

When a Hough call reports too much or too little, these are the knobs, in the order worth trying:

SymptomTry
No lines at alllower threshold; confirm the input is a real edge map (run canny first)
Too many near-duplicate linesraise threshold; coarsen theta (e.g. 2 * Pi/180)
One line split into fragmentsraise maxLineGap (houghLinesP)
Short noise segments surviveraise minLineLength (houghLinesP)
Wrong orientations detectednarrow minTheta/maxTheta (houghLines)

Next