Skip to content

Sources

Sources define how the input column should be interpreted before operations are applied.

The auto Source (default)

source() defaults to "auto", which picks the decode path from the input column's Polars dtype:

Column dtype Resolves to
String file_path
List list
Array array
Binary carrying the VIEW protocol magic blob
Binary otherwise image_bytes
from polars_cv import Pipeline

# Reads image bytes from a Binary column, or paths from a String column
pipe = Pipeline().source().resize(height=224, width=224)

The column dtype is constant across rows, so the resolution happens once per batch — the default path adds no per-row cost. Options that apply to the resolved format still apply to auto: cloud_options and the windowed remote fetch (over a String URL column), decode_max_size, and require_contiguous.

Pass an explicit format when the dtype cannot be routed — a plain numeric column raises and names the alternatives — or when you want to override the inference (for example source("raw", dtype="u8") over a Binary column that would otherwise be treated as encoded image bytes).

Image Sources: image_bytes and file_path

Both image_bytes and file_path decode images with automatic format and dtype detection:

  • PNG/JPEG usually decode to u8
  • 16-bit PNG decodes to u16
  • TIFF may decode to u8, u16, f32, or f64

Decoded images are treated as 3D buffers ([H, W, C]).

from polars_cv import Pipeline

bytes_pipe = Pipeline().source("image_bytes")
path_pipe = Pipeline().source("file_path")

Scaled Decoding: decode_max_size

Both image sources accept decode_max_size=<n>, asserting the pipeline needs at most n pixels on the decoded long side. JPEG decoding then uses IDCT scaling (1/8, 1/4, 1/2) to skip work — a large CPU and memory saving for curation passes. Non-JPEG formats ignore the assertion and decode at full size.

pipe = Pipeline().source("file_path", decode_max_size=256)

The chainable thumbnail(max_size) method is the explicit equivalent. Because a scaled decode + resize is not bit-identical to a full decode + the same resize, this is an opt-in.

Error Handling

source(..., on_error="null") turns a row that fails to decode into a null instead of failing the query. That, the pipeline-wide .on_error(...) and .on_null_param(...) for null parameter columns are covered in Parameters & Errors.

Auto DType Behavior

For image_bytes and file_path, dtype is runtime-dependent, so the pipeline starts with dtype auto. An auto source starts there too, since its decode path is not chosen until execution.

You can resolve dtype by:

  • providing dtype in source(...)
  • using an operation with deterministic output dtype (for example normalize, threshold, or cast)
# Assert and enforce dtype at source
pipe = Pipeline().source("image_bytes", dtype="f32").resize(height=224, width=224)

Planning-Time Requirement for list/array Sinks

sink("list") and sink("array") require known element dtype at planning time.

list/array sources — and an auto source over a List/Array column — resolve their leaf dtype and rank from the Polars column when the plan sees the input, so these sinks type-check without any extra help. An auto source over a Binary/String (image) column does not: its dtype is only known after decode.

For image sources, resolve dtype before these sinks:

# Option 1: source dtype
pipe = Pipeline().source("file_path", dtype="u8").resize(height=224, width=224)

# Option 2: cast in pipeline
pipe = Pipeline().source("image_bytes").resize(height=224, width=224).cast("f32")

file_path and Cloud Access

file_path supports local paths (bare, or file:///absolute/path URIs, percent-decoded; a file:// URI naming a host is refused) and remote URIs (for example s3://, gs://, az://, http://). Use CloudOptions when credentials or provider settings are needed:

from polars_cv import CloudOptions, Pipeline

options = CloudOptions(
    aws_region="us-east-1",
    aws_access_key_id="...",
    aws_secret_access_key="...",
)

pipe = Pipeline().source("file_path", cloud_options=options)

cloud_options also applies to the default auto source, since it can resolve to file_path from a String column. It is ignored (with a warning) for sources that never read remotely.

Remote requests are signed by default. To read from a public bucket without credentials, opt into anonymous access explicitly with CloudOptions(anonymous=True) (honored for S3, GCS, and Azure).

Stores are built by polars, and shared

s3://, gs:// and az:// reads go through polars' own object-store layer, so connections, credentials and retries behave exactly as they do for pl.scan_parquet("s3://…"). Stores are cached per bucket and credential for the life of the process, which is what lets a connection outlive the morsel that opened it; supplying a new credential for the same bucket builds a new store rather than reusing the old one.

Two consequences worth knowing. Ambient AWS configuration is honoured: ~/.aws/config and ~/.aws/credentials are consulted for a region and keys when the options do not supply them, so a machine with an unrelated AWS profile may sign a request that used to go unsigned — pass anonymous=True for a public bucket. And when no region is set, one HEAD per bucket is issued to discover it (cached thereafter).

Passing arbitrary backend options

CloudOptions exposes named fields for the common credentials, but any option the underlying object_store backend understands can be supplied through storage_options, keyed by that backend's native config names. Keys in storage_options win over the named fields on collision.

CloudOptions(storage_options={"aws_endpoint": "https://minio.local:9000"})
CloudOptions(storage_options={"google_application_credentials": "/path/adc.json"})

GCS authentication matrix

How Option
Service-account JSON file gcs_service_account_key="/path/sa.json" (or storage_options={"google_service_account": ...})
Inline service-account JSON storage_options={"google_service_account_key": inline_json}
Application Default Credentials file storage_options={"google_application_credentials": "/path/adc.json"}
Federated / workload identity automatic via gcloud (see below)
Token from a custom command token_command="my-broker get-token"
Pre-obtained OAuth access token gcs_bearer_token=token
Public bucket anonymous=True

Federated ADC (type external_account or external_account_authorized_user, e.g. an OIDC identity exchanged through a workload/workforce identity pool) cannot be parsed by object_store. polars-cv handles it by delegating to gcloud: when it detects a federated ADC — from GOOGLE_APPLICATION_CREDENTIALS, an explicit google_application_credentials option, or the well-known gcloud path — it runs gcloud auth application-default print-access-token and uses the resulting token, caching it until just before it expires. This needs the gcloud CLI on PATH; set POLARS_CV_DISABLE_GCS_FEDERATION=1 to disable it. To source the token another way, set token_command to any shell command that prints an access token (it takes precedence over the automatic gcloud delegation), or pass gcs_bearer_token to supply one yourself.

token_command is provider-agnostic across the OAuth-bearer backends: the same option works for Azure Blob Storage (e.g. az account get-access-token --resource https://storage.azure.com/ --query accessToken -o tsv). It does not apply to S3, which authenticates with SigV4 rather than a bearer token — supplying it, or gcs_bearer_token, with an s3:// source raises; use the aws_* fields or storage_options for AWS credentials instead.

Reading Bytes Without Decoding

The file_path source is two stages: fetch the bytes a path names, then decode them as an image. .cv.read_bytes() is the first stage on its own — same fetch mechanism, same credentials, same concurrency, no decode.

import polars as pl

df.with_columns(raw=pl.col("path").cv.read_bytes())  # -> Binary

Bytes come back verbatim, so an encoded file survives the round trip unchanged:

assert df.with_columns(raw=pl.col("path").cv.read_bytes())["raw"][0] == Path(p).read_bytes()

That matters because decoding is one-way. Re-encoding a decoded JPEG never reproduces the original file, and no image sink carries EXIF or ICC metadata — so even a PNG loses information through a decode/encode round trip. read_bytes is the only lossless path.

It also gives the header-only metadata methods a way to reach remote files, since they take binary columns:

df = df.with_columns(raw=pl.col("path").cv.read_bytes(cloud_options=options))
df = df.filter(pl.col("raw").cv.width() > 512)                  # header-only, no decode
df = df.with_columns(thumb=pl.col("raw").cv.pipe(pipe).sink("png"))
df.select("path", "raw")                                        # originals, untouched

The fetch happens once here and both the predicate and the pipeline read the same column — where two separate expressions over a path column would each fetch independently.

Argument Meaning
cloud_options Credentials/settings for remote reads. Same CloudOptions (or dict) the file_path source takes.
on_error "raise" (default) fails the query on the first unreadable path; "null" yields null for that row only.

Memory and streaming

Fetching is per plugin call — one morsel under the streaming engine, which for a Parquet scan is a whole row group. Within a call, remote paths are fetched a window ahead of the rows being read (exactly as the file_path source does): each row starts fetching its own path and those of the next rows, up to the concurrency budget below, then decodes or copies its bytes while the window downloads. So network time overlaps decoding, and a call holds about a window's worth of encoded files per row thread, not the whole call's. Each distinct path is still fetched once per call, and its bytes are freed once the last row naming it has read them. Local files are read per row.

How many are in flight is not per call: every remote request takes one permit from one budget shared by all of polars-cv's reads, so the total is bounded however many morsels are running. Set it with POLARS_CONCURRENCY_BUDGET (default: the larger of the thread count and 10). polars-cv is a plugin with its own copy of Polars' I/O layer, so this budget is separate from the one Polars' own scans use, though both read the same variable: a query that scans remote Parquet while polars-cv fetches remote images can have up to twice the budget in flight. The fetches run on Polars' async runtime (the plugin's copy), sized by POLARS_ASYNC_THREAD_COUNT.

Under the streaming engine (what a lazy .collect() uses by default since Polars 2.0) a bytes column is therefore morsel-bounded: if you filter on it and drop it, only a morsel's worth is resident at a time, and projection pushdown skips it entirely when nothing downstream uses it. It only becomes corpus-resident if you select it in the final projection — which is the point when you want the originals.

Under the in-memory engine there is no such bound — an eager DataFrame.with_columns, or collect(engine="in-memory"): it materializes the whole column, so read_bytes over a million-path frame holds every file at once. There is also no per-file size cap — whatever the path names is read in full. Go through a LazyFrame (or slice the frame) when the corpus does not fit in memory.

Paths are not sandboxed

read_bytes reads whatever the column names, local or remote, with no allowlisting — the same caveat that applies to the file_path source, but over any file rather than only decodable images. Two edges are sharper here than for the source: any local file is returned verbatim rather than having to survive an image decode, and an http:// path is fetched as-is, which reaches link-local addresses such as cloud instance-metadata endpoints. Use it with trusted path columns only.

Contour Source and Shape Inference

The contour source decodes a column of contour structs (one per row, or a set) to the contour domain, where the geometry operations apply. rasterize() turns it into a binary mask buffer, with dimensions given explicitly or inferred from another pipeline expression.

from polars_cv import Pipeline

# Explicit dimensions
mask_pipe = Pipeline().source("contour").rasterize(width=200, height=200)

# Infer dimensions from an image pipeline
img = pl.col("image").cv.pipe(Pipeline().source("image_bytes").resize(height=200, width=200))
mask_pipe = Pipeline().source("contour").rasterize(shape=img)

With shape=, the contour mask dimensions automatically match the referenced pipeline's output size. The referenced expression becomes an upstream dependency of the mask, so it executes first whether or not the graph consumes it anywhere else — referencing it only as shape= is fine. If its row is null (null bytes, on_error="null", or a null parameter under on_null_param("null")), the mask for that row is null too.

Other Sources

  • auto (default): infer the decode path from the column dtype (see above)
  • raw: raw bytes, requires explicit dtype
  • blob: self-describing binary VIEW protocol
  • list/array: infer from Polars column type (or override with dtype)
  • contour: decodes contour structs to the contour domain; add .rasterize(...) for a mask buffer (see above)