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, orf64
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.
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
dtypeinsource(...) - using an operation with deterministic output dtype (for example
normalize,threshold, orcast)
# 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.
Bytes come back verbatim, so an encoded file survives the round trip unchanged:
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 explicitdtypeblob: self-describing binary VIEW protocollist/array: infer from Polars column type (or override withdtype)contour: decodes contour structs to the contour domain; add.rasterize(...)for a mask buffer (see above)