Contours, polygons and vector geometry
A model outputs a raster mask. A takeoff system, a CAD export or a quantity calculation needs vector geometry. The conversion between them is its own engineering problem with its own failure modes, and it is where a lot of “the model is fine but the numbers are wrong” originates.
Mask to contour
import cv2
contours, hierarchy = cv2.findContours(
mask.astype("uint8"),
cv2.RETR_CCOMP, # two levels: outer boundaries and holes
cv2.CHAIN_APPROX_SIMPLE, # collapse straight runs to endpoints
)Two arguments carry all the meaning:
- Retrieval mode.
RETR_EXTERNALgives outer boundaries only and silently discards holes — wrong for a room with a column in it, where the column is a hole that must be subtracted from the floor area.RETR_CCOMPgives outers plus holes;RETR_TREEgives the full nesting. - Approximation.
CHAIN_APPROX_NONEstores every boundary pixel;CHAIN_APPROX_SIMPLEcollapses straight runs to their endpoints. Use SIMPLE unless you need per-pixel boundary.
The hierarchy array is what tells you which contour is a hole in which
parent, and ignoring it is the standard way to double-count area.
Simplification, and the tolerance that matters
A raw contour follows every pixel step, so a straight wall becomes a staircase of hundreds of vertices. Douglas-Peucker simplification removes vertices that lie within a tolerance of the line between their neighbours.
eps = 0.002 * cv2.arcLength(cnt, True) # relative, not absolute
simple = cv2.approxPolyDP(cnt, eps, closed=True)Make the tolerance relative to the contour’s own size, not a fixed pixel
count. A fixed eps that cleans a large room’s outline will erase a small
cupboard entirely, and this is the single most common bug in this step.
The trade-off to state out loud: too small a tolerance leaves pixel noise in the geometry and produces CAD output nobody can edit; too large cuts corners off and changes measured areas. Validate by checking that simplified area stays within a tolerance of raster area.
Shapely for the operations that follow
OpenCV gives you contours. Shapely gives you predicates and set operations on them — the things a takeoff workflow actually needs.
from shapely.geometry import Polygon
from shapely.ops import unary_union
room = Polygon(shell=outer[:, 0, :], holes=[h[:, 0, :] for h in holes])
if not room.is_valid: # self-intersection, usually
room = room.buffer(0) # the standard repair
area_m2 = room.area * (m_per_px ** 2)
perimeter_m = room.length * m_per_px
merged = unary_union([room_a, room_b]) # dissolve a shared wall
overlap = room_a.intersection(room_b) # should be near-empty; if not, bugbuffer(0) as a validity repair is the idiom worth knowing. A polygon that
self-intersects — a bowtie, a boundary that touches itself — is invalid, and
area on an invalid polygon is undefined rather than merely wrong. buffer(0)
re-runs the geometry engine’s noding and usually returns a valid equivalent,
though it can split one polygon into a MultiPolygon, which downstream code must
expect.
Handle MultiPolygon everywhere. Any operation can return one:
intersection of two L-shapes, buffer(0) on a pinched polygon, difference
that splits a region in two. Code that assumes a single geometry crashes on
real data eventually.
Snapping and regularisation
Vectorised architecture should look like architecture: walls axis-aligned or at consistent angles, corners meeting exactly, no 0.3-degree skews.
import numpy as np
def snap_angles(pts, tol_deg=5.0):
"""Force near-axis-aligned segments to exactly axis-aligned."""
out = pts.copy().astype(float)
for i in range(len(out) - 1):
dx, dy = out[i + 1] - out[i]
ang = np.degrees(np.arctan2(dy, dx)) % 90
if ang < tol_deg or ang > 90 - tol_deg:
if abs(dx) > abs(dy):
out[i + 1][1] = out[i][1] # horizontal
else:
out[i + 1][0] = out[i][0] # vertical
return outSnap after rectification, never before — a photographed plan’s walls are not axis-aligned in image coordinates, and snapping there destroys the very skew the homography needs to measure. Order matters and interviewers ask about it.
Where it breaks in production
- Holes ignored.
RETR_EXTERNALplus a courtyard gives an area that includes the courtyard. Silent, and wrong by a lot. - Fixed simplification tolerance. Erases small features, keeps noise on large ones.
- Invalid polygons propagate. An invalid input to
uniongives an exception or a wrong result depending on version. Validate at the boundary. - Winding order. Some consumers require exteriors counter-clockwise and
holes clockwise.
shapely.geometry.polygon.orientnormalises it. - Coordinate frame confusion. Image y grows downward, most CAD y grows upward. Flip once, in one place, and write down where.
- Float accumulation. Summing thousands of small areas in float32 drifts. Use float64, and reconcile the total against the raster count as a check.
Related
Interview angle 4
- “How do you get square metres from a segmentation mask?” - find contours with hole retrieval, simplify with a tolerance relative to contour length, build a Shapely polygon with its holes, validate, then multiply by the squared scale factor from rectification. Say the hole part unprompted; it is the common error.
- “Your room areas are all slightly too large.” - holes are being dropped.
RETR_EXTERNALdiscards inner boundaries, so columns and courtyards are counted as floor. - “What does
buffer(0)do and why?” - repairs an invalid polygon, usually a self-intersection, by re-noding it. Necessary because area and set operations are undefined on invalid geometry. Follow-up: what can it return? A MultiPolygon - your code must handle that. - “When do you snap walls to axes?” - after rectification, never before. In the original photograph the walls genuinely are not axis-aligned, and snapping there destroys the perspective signal you need.