Skip to main content

polars-cv

Image processing as Polars expressions: decode, transform and analyse images in a DataFrame column, in Rust, on every core, without a Python loop.

PyPI Downloads CI Docs License Python

Top row: one graph that decodes the image once and produces five outputs (source, grayscale, blur, canny edges, histogram equalisation). Bottom row: one pipeline whose rotation angle and blur sigma come from per-row columns.

Every panel is computed by polars-cv from a CC0 photo (scripts/make_readme_figure.py). equalize_histogram() is a contrast stretch: it remaps intensities so they spread evenly over 0–255.

Install

pip install polars-cv      # or: uv add polars-cv

Wheels for Linux (x86_64, aarch64) and macOS (Apple silicon), CPython 3.11+, Polars 2.0+. Other platforms build from source with Rust 1.96+.

Quickstart

A Pipeline describes the work, .cv.pipe() applies it to a column, and .sink() picks the output format.

from pathlib import Path

import polars as pl
from polars_cv import Pipeline

df = pl.DataFrame({"path": sorted(str(p) for p in Path("photos").glob("*.jpg"))})

pipe = Pipeline().source("file_path").resize(height=224, width=224).grayscale()
out = df.with_columns(gray=pl.col("path").cv.pipe(pipe).sink("numpy"))

Sources include image_bytes, local paths and s3:///gs:///az:///http(s):// URLs. Sinks include numpy, array, list, png and jpeg. See Sources and Pipelines.

One decode, several outputs, per-row parameters

A folder of photos to centre-cropped 224×224 training tensors plus JPEG thumbnails. Each file is decoded once and feeds both outputs, and the crop box is computed per row from each image's header.

from pathlib import Path

import polars as pl
from polars_cv import Pipeline

df = pl.DataFrame({"path": sorted(str(p) for p in Path("photos").glob("*.jpg"))})
df = df.with_columns(pl.col("path").cv.image_info().struct.unnest())  # header only

side = pl.min_horizontal("width", "height")  # a per-row value
img = pl.col("path").cv.pipe(Pipeline().source("file_path", dtype="u8"))
x = img.pipe(
    Pipeline()
    .crop(top=(pl.col("height") - side) // 2, left=(pl.col("width") - side) // 2,
          height=side, width=side)
    .resize(height=224, width=224, filter="bilinear")
).alias("x")
thumb = img.pipe(Pipeline().resize_max(128)).alias("thumb")

out = df.with_columns(
    o=x.merge_pipe(thumb).sink({
        "x": ("array", {"shape": [224, 224, 3]}),
        "thumb": ("jpeg", {"quality": 85}),
    })
).unnest("o")
batch = out["x"].to_numpy()  # (n_images, 224, 224, 3) uint8

Any parameter that does not change the output's rank, dtype or domain accepts a Polars expression. See Multi-output pipelines.

Branch, rejoin, optimise: cutting out the foreground

Pipelines compose into a graph. This one cuts a cup and saucer out of a photo with a transparent background: two cues find the china (bright pixels, and the saturated red that the brown table lacks), their union is traced to an outline, and the outline is rasterized back into an alpha channel for the photo.

A photo of an espresso cup on a saucer on a wooden table, and the same photo with the table made transparent.

from pathlib import Path

import polars as pl
from polars_cv import Pipeline

base = Pipeline().source("file_path").resize_max(512)
img = pl.col("path").cv

# Two cues, each written as if it stood alone. The threshold is a column.
bright = img.pipe(base.grayscale().threshold(190))
saturated = img.pipe(
    base.to_hsv().channel_select(1).threshold(pl.col("min_saturation"))
)

# Rejoin: either cue, cleaned up and traced; keep the largest outline's hull.
outline = bright.maximum(saturated).pipe(
    Pipeline().morphology_open(ksize=5).extract_contours().largest().convex_hull()
)

# The outline as an anti-aliased alpha channel joined to the photo's colours.
photo = img.pipe(base)
alpha = outline.rasterize(shape=photo, supersample=4)
cutout = photo.channel_merge(alpha).alias("cutout")

expr = cutout.merge_pipe(outline.area().alias("area"))
paths = sorted(str(p) for p in Path("photos").glob("*.jpg"))
out = (
    pl.LazyFrame({"path": paths, "min_saturation": 210})
    .with_columns(o=expr.sink({"cutout": "png", "area": "native"}))
    .unnest("o")
    .collect()
)

expr.sink(..., return_expr=False).show_graph() draws the graph that runs (it needs polars-cv[viz] and Graphviz's dot):

The optimised graph: one source node reads and resizes the image; one branch converts it to HSV and thresholds the saturation; a second node thresholds the brightness, takes the maximum with the saturation branch, cleans it up and traces the outline's hull; the hull feeds an area node and a rasterize node, whose alpha the photo's channels are merged with into the cutout.

Written out, this is eight nodes with three decodes; it runs as six with one. The optimiser computes each distinct value once: the three pipelines built from base share one read and resize (the SOURCE node), and each linear run of steps that nothing else reads becomes a single node, so the brightness threshold runs straight through maximum to the hull in one. Pass opt_flags=False to sink to see the graph as written; the outputs are byte for byte the same. The whole graph is one Polars expression: it runs in the lazy and streaming engines, per row, with min_saturation read from each row's column, and returns the PNG and the outline's area together.

Why polars-cv

  • Images are a column. Paths, bytes, labels, metadata and results sit in one DataFrame, so filtering, joining and group_by on image properties is plain Polars, not a second data structure kept in sync with a list of arrays.
  • Polars runs it. Pipelines are expressions: they run lazily, in the streaming engine for larger-than-memory data, with each call's rows spread over every core by Rust code. There is no Python per image.
  • Parameters can be expressions. Crop boxes from annotations, sizes from metadata, thresholds from a lookup, evaluated per row.

Benchmarks

5,000 COCO val2017 images on 4 cores, against PIL, OpenCV (process pool), torchvision (DataLoader), Daft and Hugging Face datasets. Each is written the way its users would write it and checked against PIL's output before timing.

Images per second on four workloads at 4 threads: polars-cv leads ML preprocessing and per-row crop/resize, OpenCV leads thumbnails, and the two are close on perceptual hashing.

Workload polars-cv (streaming) Fastest alternative polars-cv speed-up Peak memory, polars-cv vs that alternative
Decode → resize 224² → gray → NumPy 1,025 img/s torchvision 701 1.5× 849 MiB vs 1,094
Per-row crop box + per-row size 1,132 img/s OpenCV 751 1.5× 686 MiB vs 2,238
Size + pHash → duplicate groups 1,048 img/s OpenCV 1,180 0.89× 143 MiB vs 120
Thumbnail → JPEG 728 img/s OpenCV 939 0.78× 250 MiB vs 194

polars-cv trails OpenCV on thumbnails because its JPEG encoder is slower than libjpeg-turbo. Methodology, every table and the command to reproduce them are in benchmarks/.

What's included

Area Examples
Resize & geometry resize, resize_max, crop, pad, letterbox, rotate, flip, warp_affine
Colour & intensity grayscale, to_hsv, to_lab, adjust_contrast, adjust_gamma, equalize_histogram
Filters & morphology blur, sharpen, sobel, canny, convolve2d, erode/dilate (square or disk), distance_transform
Arithmetic normalize, cast, clamp, element-wise math, and add/blend/apply_mask between pipelines
Analysis histogram, perceptual_hash, reduce_mean/max/percentile/…, statistics()
Contours, points, boxes extract_contours, area, iou, hausdorff_distance, pairwise_iou via .contour/.point/.bbox
Drawing rasterize (anti-aliased), stroke, offset, smooth, union/intersection/difference, coordinate_grid, apply_lut palettes
Header metadata .cv.image_info(), .cv.width(), .cv.height() without decoding
Detection metrics COCO-style AP/mAP, PR curves, FROC/LROC, bootstrap intervals in polars_cv.metrics

The full list is in the operations guide and the API reference; the drawing guide builds the logo above from these ops.

Larger-than-memory data

Eager calls already use every core. For data that does not fit in memory, use a LazyFrame: .collect() streams by default, and .collect_batches() hands you one batch at a time.

from pathlib import Path

import polars as pl
from polars_cv import Pipeline

pipe = Pipeline().source("file_path", dtype="u8").resize(height=64, width=64)
lf = pl.LazyFrame({"path": sorted(str(p) for p in Path("photos").glob("*.jpg"))})
for batch in (
    lf.select(x=pl.col("path").cv.pipe(pipe).sink("array", shape=[64, 64, 3]))
    .collect_batches(chunk_size=2)
):
    x = batch["x"].to_numpy()  # (rows, 64, 64, 3) uint8

Thread counts, morsel sizing and memory budgets are covered in Streaming & Scaling.

Project status

ℹ️ Note: This is a largely AI-developed project and still in its early stages. Use at your own discretion.

polars-cv is 0.x: a minor release may break the API. Breaking changes are listed in the changelog, each with a migration guide. Pin a minor version (polars-cv~=0.38.0) if you depend on it. Outputs are checked against NumPy, SciPy, OpenCV and imagehash in CI.

Contributing

Issues and pull requests are welcome. CONTRIBUTING.md covers the build, and scripts/verify.sh runs every check CI runs.

Citation

If you use polars-cv in research, please cite the version you used:

@software{dar_polars_cv,
  author = {Dar, Hesham},
  title  = {polars-cv: Image processing as Polars expressions},
  url    = {https://github.com/heshamdar/polars-cv},
  year   = {2026}
}

Licensed under either of MIT or Apache-2.0, at your option.

Metadata

Release files for polars-cv 0.38.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Built distributions (wheels)

Table of built distributions (wheels) for polars-cv 0.38.0
File Interpreter ABI Platform
polars_cv-0.38.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl CPython 3.11 abi3 Linux glibc 2.17+ x86-64 Details
polars_cv-0.38.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl CPython 3.11 abi3 Linux glibc 2.17+ ARM64 Details
polars_cv-0.38.0-cp311-abi3-macosx_11_0_arm64.whl CPython 3.11 abi3 macOS 11.0+ ARM64 Details

Total release size: 45.9 MB

Release files / polars_cv-0.38.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl

Download URL polars_cv-0.38.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Size 18.5 MB
Tags CPython 3.11 Linux glibc 2.17+ x86-64 abi3
SHA-256 checksum
How to use checksums
a1ad940ee241f50382a234c48e1412e23d8355e5f3c7a7f7d764b38d210a1875
BLAKE2b-256 checksum
How to use checksums
3ce80589f811319426e710ebc182adf15f95043f015311d6e8b1758545cd43e7
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 18, 2026.

Transparency log

Release files / polars_cv-0.38.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl

Download URL polars_cv-0.38.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Size 13.4 MB
Tags CPython 3.11 Linux glibc 2.17+ ARM64 abi3
SHA-256 checksum
How to use checksums
2cacc9ddf8fbbec505bfd41779eae3c376e002c048b77225f3a8ef28506a9b93
BLAKE2b-256 checksum
How to use checksums
41abc9718dc9c7309c0416d17a799fa59cf829855a3ac3583f5a2400660369dd
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 18, 2026.

Transparency log

Release files / polars_cv-0.38.0-cp311-abi3-macosx_11_0_arm64.whl

Download URL polars_cv-0.38.0-cp311-abi3-macosx_11_0_arm64.whl
Size 13.9 MB
Tags CPython 3.11 abi3 macOS 11.0+ ARM64
SHA-256 checksum
How to use checksums
96dc5efe29897b82de450b5a173843c8bcff66047e12747107640f432a2f2f12
BLAKE2b-256 checksum
How to use checksums
d91aa430030222b500fa739aa47c31a0411379b06d04f70601ef6d74abd9ba39
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 18, 2026.

Transparency log
Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page