Geometry Operations¶
polars-cv provides three expression namespaces for geometry: .contour for polygon operations, .point for point operations, and .bbox for bounding-box operations.
Expression parameters¶
Numeric parameters in these namespaces accept either a literal or a Polars expression, resolved per row at execution time — the same rule as the image operations:
# Normalize each contour against its own image's dimensions
df.with_columns(
norm=pl.col("contour").contour.normalize(pl.col("img_w"), pl.col("img_h"))
)
Eligibility is decided by effect, not type: a parameter may be per-row when
its value changes no output shape, rank or dtype. That admits the enums and
flags as well as the numbers — ensure_winding(direction=) and
scale(origin=) reorder or move a ring's vertices and leave
List(Struct(CONTOUR_SCHEMA)) exactly as it was, so both take an expression
too.
On .contour: normalize, to_absolute, translate, scale (sx, sy and
origin), simplify, ensure_winding(direction=), area(signed=),
largest(k=), close_along_border (every argument), to_coords(order=),
boundary_distances(sample_step=), label_reduce(reduction=, region_mode=),
correspond(threshold=) and correspond_by_coverage(tolerance=, threshold=,
sample_step=). On .point: normalize, to_absolute, translate, scale,
rotate(angle=), interpolate(t=) and to_coords(order=). On .bbox:
correspond(threshold=).
An aggregation broadcasts, matching Polars' own semantics — pl.col("w").max()
produces one value applied to every row.
A null parameter raises by default. on_null(...) on the accessor opts into a
null result for the affected rows instead, mirroring Pipeline.on_null_param
(these namespaces have no Pipeline object, so the policy chains ahead of the
call):
For a fallback value instead, fill the null in the expression:
pl.col("w").fill_null(1.0).
Invalid rows¶
A row whose data a function refuses — a line too far from the frame for
close_along_border, an open contour given to area, a zero normalize
size — fails the query by default, naming the row. on_error("null") nulls
just those rows, so they can be counted and inspected, mirroring
source(on_error="null"):
An error about the column rather than a row — a contour set where one
contour per row is expected, a dtype no reader understands — still raises
under "null": no row could succeed. on_error and on_null compose.
Schemas¶
Geometry data uses Polars Struct columns:
from polars_cv import CONTOUR_SCHEMA, POINT_SCHEMA, BBOX_SCHEMA
# POINT_SCHEMA: Struct({x: f64, y: f64})
# BBOX_SCHEMA: Struct({x: f64, y: f64, width: f64, height: f64})
# CONTOUR_SCHEMA: Struct({exterior: List(POINT), holes: List(List(POINT)), is_closed: bool})
Rings are implicitly closed — do not repeat the first point. A ring is a hole
because it sits in holes, not because of how it is wound: every operation is
winding-independent, so flip() and ensure_winding() change what
winding() reports without changing the region the contour describes.
Open polylines¶
is_closed=False makes a contour an open polyline — an annotated skin
line or muscle edge — whose edges join consecutive points only. It has no
region, so the functions split by what they read:
- Boundary functions measure it as a line:
perimeter(its arc length),hausdorff_distance,boundary_distances,bounding_box,simplify,smooth,densify,convex_hull, the point-wise transforms (translate,scale,flip,normalize,to_absolute), and.point.distance_to_contour/nearest_point_on_contour. - Region functions refuse it, naming the row:
area,centroid,largest,is_convex,winding,ensure_winding,contains_point,iou,dice,pairwise_iou,correspond,label_reduce,boundary,offset,union_all, the set operations (union,intersection,difference,symmetric_difference) and.point.signed_distance_to_contour.scale(origin="centroid")refuses one too. rasterizerefuses it too;stroke()it first (see Drawing lines).
Pipelines carry lines the same way: source("contour") reads open rows, and
each contour op behaves as its .contour accessor does (one definition serves
both), so a pipeline region op refuses a line with the same message.
boundary() turns a region's rings into lines (the exterior, then each hole,
each returning to its first point), so the result is a set; close() joins a
line's ends into the region it bounds, undoing boundary() for a region
without holes:
# A region's outline as lines, and their lengths
lines = Pipeline().source("contour").boundary()
lengths = Pipeline().source("contour").boundary().perimeter()
# An annotated polyline as the region it encloses
area = pl.col("polyline").contour.close().contour.area()
An open contour may not have holes. An unspecified is_closed (a null, or a
struct without the field) reads as closed.
A line whose ends lie on the image frame closes into the region it bounds along the frame, through the corners it passes:
# A pectoral-muscle edge from the top edge to the left edge -> its corner region
region = pl.col("edge").contour.close_along_border(pl.col("w"), pl.col("h"))
arc= picks the way round ("shortest", "clockwise",
"counterclockwise", as displayed); an end farther than max_snap (default
2 px) from the frame is refused rather than joined.
From coordinate lists, and back¶
from polars_cv.geometry import point_from_coords, contour_from_coords
df.with_columns(
pt=point_from_coords(pl.col("yx"), order="yx"), # [row, col] pairs
line=contour_from_coords(pl.col("pts"), closed=False), # an open polyline
)
df.select(pl.col("line").contour.to_coords(order="xy")) # List(Array(f64, 2))
Integers and Array(_, 2) pairs are accepted; a pair that is not two
non-null, finite numbers is an error naming its row. contour_set_from_coords
builds a set; .contour.to_coords() refuses a contour with holes.
Boxes come from four numbers per row, in a layout you name — the three are
indistinguishable from the data, so format= is required:
from polars_cv.geometry import bbox_from_coords
df.with_columns(
box=bbox_from_coords("xyxy_list", format="xyxy"), # one column of 4
coco=bbox_from_coords(["x", "y", "w", "h"], format="xywh"), # four columns
yolo=bbox_from_coords("cxcywh", format="cxcywh"),
)
The result is BBOX_SCHEMA (x, y, width, height); a null box gives null.
Contours¶
The .contour namespace operates on polygon columns.
One contour or a whole set¶
Every .contour accessor reads either arity: a CONTOUR_SCHEMA struct per
row, or the CONTOUR_SET_SCHEMA list of them that extract_contours()
produces. The result is wrapped to match the input, so the same call answers for
both:
one = pl.col("contour").contour.area() # Struct column -> Float64
many = pl.col("contours").contour.area() # List(Struct) -> List(Float64), one per contour
That is what lets the namespace read the column its own pipeline produced —
extract_contours() sinks a set, and every accessor takes one.
Two-operand accessors (iou, dice, hausdorff_distance) broadcast: a set
on one side and a single contour on the other gives one result per contour,
whichever side the set is on. A set on both sides raises rather than guessing,
because it could mean the N×M matrix (pairwise_iou) or an index-wise pairing
(.explode() one side), and those are different answers.
The set-level accessors (pairwise_iou, correspond,
correspond_by_coverage, label_reduce, largest, set_boundary_distances,
single) run the rule backwards: a lone contour is read as a set of one.
single(label=) takes each row's one contour from a set, for a column where
every row should hold exactly one (the outline of an instance mask, say). A set
of any other size fails the query, quoting that row's label rather than a
row number, which under the streaming engine is only a position in one batch:
outline = pl.col("contours").contour.single(label=pl.col("image_id"))
# ComputeError: the plugin failed with message:
# contour_single: 'img7' holds 2 contours, not one
Measurements¶
df.with_columns(
area=pl.col("contour").contour.area(),
perimeter=pl.col("contour").contour.perimeter(),
centroid=pl.col("contour").contour.centroid(),
bbox=pl.col("contour").contour.bounding_box(),
)
Boundary distances¶
hausdorff_distance is vertex-to-vertex, so two tracings of one outline at
different vertex densities come out apart. boundary_distances measures each
boundary's samples to the other's edges, both directions:
d = pl.col("pred").contour.boundary_distances(pl.col("gt"), sample_step=0.5)
# Struct{mean_a_to_b, mean_b_to_a, assd, hd, hd95}
assd is the average symmetric surface distance, hd the Hausdorff
distance and hd95 the larger directed 95th percentile (MONAI's
convention). For physical units, .contour.scale(..., origin="origin") both
sides by the pixel spacing first.
A region cut off by the image edge has a frame segment in its outline that no
annotation traces. Pass the image as frame= (a bbox per row) and boundary on
or outside it is not measured, in either direction. Every remaining sample is
still measured to the other side's whole outline, so an annotation drawn a
pixel inside the edge is a pixel away rather than measured across the region.
Inset the bbox to also skip boundary near the edge:
frame = pl.struct( # a BBOX_SCHEMA struct: Float64 fields
x=pl.lit(0.0), y=pl.lit(0.0), width=pl.col("w").cast(pl.Float64), height=pl.col("h").cast(pl.Float64)
)
d = pl.col("pred").contour.boundary_distances(pl.col("gt"), frame=frame)
For masks with several regions, .contour.set_boundary_distances(other) reads
each side's contour set as one boundary, the union of its outlines (the
surface distance MONAI computes on masks). Every sample is measured to the
nearest edge of any region on the other side. boundary_distances instead
gives one result per contour of a set. An empty set (an empty mask) has no
boundary and gives null. sample_step and frame work as above.
Keeping the largest¶
largest(k=1) keeps the k largest contours of a set by area, largest first
(equal areas in input order) — as .contour.largest(k) and as a pipeline op
after extract_contours(). The result is always a set. To drop specks
relative to each image's size rather than in pixels, filter at extraction with
extract_contours(min_area_fraction=...) (a fraction of height x width, in
(0, 1]; with min_area too, a contour must pass both):
pipe = (
Pipeline().source("image_bytes").grayscale().threshold(128)
.extract_contours(min_area_fraction=0.001)
.largest(k=3)
)
Transforms¶
df.with_columns(
moved=pl.col("contour").contour.translate(dx=10, dy=20),
scaled=pl.col("contour").contour.scale(sx=2.0, sy=2.0),
simplified=pl.col("contour").contour.simplify(tolerance=1.0),
hull=pl.col("contour").contour.convex_hull(),
)
scale takes origin= — "centroid" (the default), "bbox_center" or
"origin". .contour.scale and Pipeline.scale_contour are one operation
with one definition, so they always agree.
Shaping¶
Five more ops, each both a pipeline op and a .contour accessor with one
definition (stroke, the fifth, is under Drawing lines):
offset(distance, join="round")grows a set's regions bydistancepixels, or shrinks them for a negative one: every point withindistanceof their union (or inside it and at least-distancefrom its edge). The set is read as one union, so growing can merge regions and shrinking can split them or leave none — the result is always a set. Convex corners turn byjoin("round","miter"— cut off past 4 times the distance, SVG's default limit — or"bevel"); concave ones stay sharp. Round arcs are polygons within 1/64 pixel of the true arc up to a radius of about 32 pixels, and within aboutr / 2000beyond (0.12 pixel at 1000), the finest arc the polygon engine draws.union_all()merges a set's regions into disjoint ones (a hole another member covers is filled) — always a set.smooth(iterations=1)rounds corners by Chaikin's corner cutting: each pass replaces every edge by its points a quarter and three quarters along. A line keeps its ends (one returning to its first point is smoothed through it).densify(max_segment_length)splits every edge intoceil(length / max_segment_length)equal parts, keeping every point.
offset and union_all take regions; smooth and densify take lines too.
# A 3 px halo round each blob, as one mask
halo = (
Pipeline().source("image_bytes").grayscale().threshold(128)
.extract_contours().offset(3.0).rasterize(width=200, height=200)
)
# Soften a traced outline's pixel staircase
soft = pl.col("contours").contour.smooth(iterations=3)
Combining two sets¶
union, intersection, difference and symmetric_difference combine two
contour sets: each side is read as the union of its regions, and the result is
disjoint regions — always a set, since combining shapes can split or merge
them. As .contour accessors the other side is a contour column (a single
contour is a set of one; a null side nulls the row); in a pipeline it is
another contour-domain expression, and the result chains on:
# Prediction regions outside the ground truth, per row
false_positive = pl.col("pred").contour.difference(pl.col("truth"))
# The same in a pipeline, measured
pred = pl.col("pred").cv.pipe(Pipeline().source("contour"))
truth = pl.col("truth").cv.pipe(Pipeline().source("contour"))
missed_area = truth.difference(pred).area().sink("native")
Rasterizing a result paints exactly the pixelwise |, &, & ~ or ^ of
rasterizing the two sides.
Rasterization¶
Convert contours to binary masks:
pipe = Pipeline().source("contour").rasterize(width=200, height=200)
result = df.with_columns(
mask=pl.col("contour").cv.pipe(pipe).sink("numpy")
)
The column may hold one contour per row (CONTOUR_SCHEMA) or a whole set
(List(CONTOUR_SCHEMA)) — the source reads both, and a set paints the union of
its members: each member's exterior minus its own holes. One contour's hole never
erases another's fill, and the mask does not depend on the order of the set.
That is what closes the loop, because extract_contours() sinks a contour set:
contours = (
pl.col("image")
.cv.pipe(Pipeline().source("image_bytes").grayscale().threshold(128).extract_contours())
.sink("native") # List(CONTOUR_SCHEMA), one set per row
)
mask = pl.col("contours").cv.pipe(Pipeline().source("contour").rasterize(width=200, height=200))
The trip back is lossless: extract_contours() traces the edges of the
boundary pixels, so a region filling w x h pixels returns bounding w x h,
its area is its pixel count, and re-rasterizing gives back the same mask.
Staying inside one pipeline — extract_contours().rasterize(...) — produces the
same mask as sinking the set and reading it back through source("contour").
fill_value and background may be inverted (fill_value=0, background=255);
the same region is painted either way.
By default a pixel is filled when its centre lies inside, so edges are hard.
supersample=n anti-aliases them: each pixel samples its n x n sub-pixel
centres and is painted background + (fill_value - background) * k / n**2 for
the k that fall inside (rounded half away from zero), from 1 (the default) up
to 16.
# Smooth edges: 4 x 4 samples per pixel
mask = pl.col("contour").cv.pipe(
Pipeline().source("contour").rasterize(width=200, height=200, supersample=4)
)
Drawing lines¶
rasterize fills regions. A line has no region, so stroke(width=1.0,
cap="round", join="round") first turns paths into the regions they cover:
every point within width / 2 of a path, its ends shaped by cap
("round", the default; "square"; "butt", flat at the end point) and its
corners by join ("round", "miter", "bevel"). Every member of the set
is read as its outline: an open line is capped at its ends, while a region's
rings — and a line that returns exactly to its first point — are closed
paths with no ends. The result is a set of disjoint regions, a pipeline op and
a .contour accessor alike, and width, cap and join each accept an
expression. See Drawing with polars-cv for building pictures
from these.
A 1-pixel stroke on integer coordinates puts its edges exactly on pixel
centres, so which side of each edge a pixel lands on is decided by the
scanline's tie rule; use supersample (or a non-integer width) for an even
line.
# A region's outline, and its fill
outline = (
Pipeline().source("contour")
.stroke(width=2.5, join="miter")
.rasterize(width=200, height=200, supersample=4)
)
filled = Pipeline().source("contour").rasterize(width=200, height=200)
Infer dimensions from an existing image:
img = pl.col("image").cv.pipe(Pipeline().source("image_bytes").resize(height=200, width=200))
mask = pl.col("contour").cv.pipe(Pipeline().source("contour").rasterize(shape=img))
shape= takes a reference pipeline (a LazyPipelineExpr) instead of literal
dimensions: the referenced pipeline's output [H, W] is resolved and used to size
the raster canvas, so the mask matches the source image without hard-coding its
size. The same shape= reference is accepted by rasterize(...) directly when
you already have a contour-domain pipeline — which is what extract_contours()
leaves you with:
mask = (
pl.col("image")
.cv.pipe(Pipeline().source("image_bytes").grayscale().threshold(128).extract_contours())
.rasterize(shape=img)
)
Provide either explicit width/height or shape= — not both.
Points¶
The .point namespace operates on point columns: one point per row
(POINT_SCHEMA) or a point set (POINT_SET_SCHEMA, List(point)). Over a set
every method gives one value per point, in input order, as a List — so a set
of points is measured against the row's contour without an explode/group-by:
A contour or bbox operand broadcasts against the set; two point columns broadcast either way (a set against a single point), and a set on both sides is refused. A null point in a set gives a null in its place.
Transforms¶
df.with_columns(
normalized=pl.col("point").point.normalize(width=100, height=100),
absolute=pl.col("point").point.to_absolute(width=100, height=100),
moved=pl.col("point").point.translate(dx=10, dy=20),
scaled=pl.col("point").point.scale(sx=2.0, sy=2.0),
rotated=pl.col("point").point.rotate(math.pi / 2),
)
Distances¶
df.with_columns(
euclidean=pl.col("p1").point.distance(pl.col("p2")),
manhattan=pl.col("p1").point.manhattan_distance(pl.col("p2")),
to_boundary=pl.col("point").point.distance_to_contour(pl.col("contour")),
signed=pl.col("point").point.signed_distance_to_contour(pl.col("contour")),
)
Geometric Operations¶
df.with_columns(
angle=pl.col("p1").point.angle_to(pl.col("p2")),
mid=pl.col("p1").point.midpoint(pl.col("p2")),
interp=pl.col("p1").point.interpolate(pl.col("p2"), t=0.25),
nearest=pl.col("point").point.nearest_point_on_contour(pl.col("contour")),
inside=pl.col("point").point.within_bbox(pl.col("bbox")),
)
Bounding Boxes¶
The .bbox namespace operates on List[BBOX_SCHEMA] columns for detection tasks.
Pairwise IoU¶
Compute IoU between two sets of bounding boxes:
Correspondence¶
Greedy one-to-one pairing between two bbox sets, by overlap. Boxes are visited
in order and each takes the highest-IoU partner not already taken:
df.with_columns(
pairs=pl.col("pred_bboxes").bbox.correspond(
pl.col("gt_bboxes"),
threshold=0.5,
# Visit highest-confidence first. `correspond` only sees overlap, so
# what "confidence" means -- and that it should decide the order -- is
# yours to say. Omit `order` to visit in natural order.
order=pl.col("pred_scores").list.eval(
pl.element().rank(method="ordinal", descending=True).arg_sort()
),
),
)
The result is a struct matching CORRESPONDENCE_SCHEMA: right_idx (the
partner's index, null where unpaired), overlap (its IoU) and duplicate
(left unpaired although it cleared the threshold against a partner another
element had already claimed — a repeated hit, which LUNA16/CAMELYON-style
evaluation ignores), all positionally aligned with the left column. Counting
how many pairings your dataset contains is a question about your dataset, so
correspond does not answer it.
.contour.correspond(...) is the same rule over contour IoU, and
.contour.correspond_by_coverage(other, tolerance, ...) pairs by coverage —
the fraction of each target's boundary inside the candidate or within
tolerance of its edges — which scores line-shaped targets (polylines) that
IoU cannot.
Mask Metrics¶
Pixel-based metrics for binary masks: