Skip to content

Repository files navigation

FerrumPixel

Rust-accelerated image preprocessing for Python.

Install · Quickstart · API reference · Semantics · Concurrency · Performance


FerrumPixel gives you the operations an ML pipeline actually needs — resize, crop, rotate, brightness, contrast, equalize_histogram, sharpen, normalize — behind a small chainable API that hands you a NumPy array at the end. The pixel work happens in Rust, with the GIL released for the whole of every call. It is built for the layer between your web framework and your model, where preprocessing runs on every request and the GIL decides how much of your machine you actually get to use.

import ferrumpixel as fp

arr = (fp.load("image.jpg")
       .resize(512, 512)
       .sharpen()
       .normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
       .numpy())        # (512, 512, 3) float32, RGB

Table of contents


What it replaces, and why

The pipeline FerrumPixel targets is the familiar PIL → OpenCV → NumPy chain: open with PIL, convert to an array, run a few OpenCV ops, normalize with NumPy. That works, but it carries three costs.

The GIL. Those libraries release the GIL unevenly, and a mixed chain reacquires it repeatedly, so preprocessing serialises against the rest of your process. FerrumPixel releases the GIL for the entire duration of every call, single-image and batch alike. In a threaded server that is the difference between preprocessing blocking your request handlers and running alongside them.

Copies. Each hand-off between PIL, OpenCV and NumPy is a conversion and usually a copy. FerrumPixel keeps one buffer in Rust for the whole chain and copies out exactly once, when you call .numpy().

Undefined semantics. In the usual chain, rotation direction, contrast pivot and rounding behaviour depend on which library you happened to reach for. FerrumPixel fixes them once and versions them — see Pixel semantics.

Batches are the case FerrumPixel is strongest at: they dispatch across Rayon's thread pool with the GIL released for the whole call, so throughput scales with cores rather than with the interpreter.

Install

pip install ferrumpixel

Wheels are abi3, so a single wheel per platform covers CPython 3.9 through 3.13. No Rust toolchain is needed to install.

Platform Architectures
Linux (manylinux 2.17+) x86_64, aarch64
macOS x86_64, arm64
Windows x64

Only dependency is numpy. On a platform without a prebuilt wheel, pip falls back to the sdist, which requires a Rust toolchain (1.70+).

CPython only. abi3 is a CPython ABI, so PyPy is not supported.

Quickstart

import ferrumpixel as fp

# Load from a path, or straight from bytes — an upload body, an S3 object, ...
image = fp.load("image.jpg")
image = fp.load(request_body_bytes)

# Chain operations; nothing is computed lazily, each call does its work now
arr = (fp.load("image.jpg")
       .resize(512, 512, fp.Interpolation.BILINEAR)
       .crop(0, 0, 480, 480)
       .rotate(90)
       .brightness(0.1)
       .contrast(1.2)
       .equalize_histogram()
       .sharpen(strength=0.5)
       .normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
       .numpy())

arr.shape, arr.dtype        # ((480, 480, 3), dtype('float32'))

To write an image back out, skip normalize — it converts to float32, and save needs the uint8 path:

fp.load("image.jpg").resize(512, 512).sharpen().save("optimized.jpg")

Batches

load_batch decodes in parallel, every operation runs in parallel, and .numpy() stacks the result into one tensor:

arr = (fp.load_batch(["a.jpg", "b.jpg", "c.jpg"])
       .resize(224, 224)
       .normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
       .numpy())

arr.shape                   # (3, 224, 224, 3)  ->  (N, H, W, C)

Stacking requires uniform dimensions, so resize before .numpy(). A ragged batch raises ValueError rather than padding or guessing.

Core concepts

The chain

Every operation returns something chainable, and every call executes immediately — there is no lazy graph and no .compute(). .numpy() and .save() are the terminal operations that get data back out of Rust.

Input is always converted to 8-bit RGB on load:

Input Result
RGB unchanged
Grayscale expanded to 3 identical channels
RGBA / palette with alpha alpha is dropped, not composited

If you need alpha-aware compositing, do it before handing the image to FerrumPixel.

The aliasing contract

This is the one part of the API that can surprise you, so it is worth reading once.

Operations fall into two groups:

  • Shape- or dtype-changingresize, crop, rotate, sharpen, normalize. These allocate and return a new Image. The receiver is untouched.
  • Neitherbrightness, contrast, equalize_histogram. These mutate the buffer in place and return the same object.
a = fp.load("x.jpg")

b = a.resize(256, 256)      # b is independent; a is unchanged
c = a.brightness(0.1)       # c IS a  ->  True; a was modified too

Chained left to right this never bites you, because you never hold an intermediate:

result = fp.load("x.jpg").resize(256, 256).brightness(0.1).numpy()   # fine

It only matters if you bind an intermediate and expect it to stay put:

base = fp.load("x.jpg")
thumb = base.resize(64, 64)      # safe: new object
bright = base.brightness(0.2)    # base is now brighter too
dark = base.brightness(-0.2)     # applied on top of the previous change

The reason for the split is allocation: forcing brightness to allocate a full copy would measurably hurt long chains for no benefit in the common case. .clone() is specified in design.md §1.3 as the escape hatch but is not yet implemented — until it lands, call fp.load(...) twice if you need two independent handles.

API reference

fp.load(path_or_bytes)

Decode a single image.

Parameter Type Description
path_or_bytes str | bytes A filesystem path, or the encoded bytes of an image

Returns Image, always 8-bit RGB.

Formats: PNG, JPEG, BMP, WebP, TIFF and GIF all decode. PNG and JPEG are the formats under test in CI.

Raises OSError if the path is missing or unreadable, ValueError if the data is not a decodable image, TypeError if the argument is neither str nor bytes.

The GIL is released for the decode.

fp.load_batch(paths_or_bytes)

Decode many images in parallel.

Parameter Type Description
paths_or_bytes Sequence[str | bytes] Paths, encoded bytes, or a mix

Returns BatchImage, in the order given.

Raises the same exceptions as load. A single bad entry fails the whole call rather than yielding a partial batch, so the returned batch always has exactly one image per input.

fp.Interpolation

Resampling filter for resize. Members are upper-case per PEP 8.

Member Notes
NEAREST Fastest, blocky. Use for masks and label maps, where interpolating between class indices would invent values that mean nothing.
BILINEAR Default. Good quality for preprocessing, cheap.
BICUBIC Catmull-Rom. Sharper than bilinear on upscale.
LANCZOS3 Highest quality, most expensive. Best for large downscales.

fp.Image

A single decoded image. Construct with load, never directly.

Columns: New? indicates whether the method returns a new object (per the aliasing contract).

Method Signature New?
resize (w, h, interpolation=BILINEAR) new
crop (x, y, w, h) new
rotate (degrees, expand=False) new
sharpen (strength=1.0) new
normalize (mean, std) new
brightness (delta) in place
contrast (factor) in place
equalize_histogram () in place
numpy ()
save (path)

resize(w, h, interpolation=BILINEAR)

Resize to exactly w × h pixels. Aspect ratio is not preserved — the target size is used as given. Compute the aspect-correct size yourself if you need it.

Raises ValueError if either dimension is 0.

img.resize(224, 224)
img.resize(1024, 768, fp.Interpolation.LANCZOS3)

crop(x, y, w, h)

Crop a w × h region whose top-left corner is at (x, y), measured from the top-left of the image.

Raises ValueError if the region extends past the bounds. It is never silently clamped — a crop that does not fit is a bug in the caller, not something to paper over.

rotate(degrees, expand=False)

Rotate counter-clockwise for positive angles, matching PIL. (OpenCV's rotate constants turn the other way — see Pixel semantics.)

expand Behaviour
False (default) Keep the original dimensions; corners rotate out of frame and are lost
True Grow the canvas so the whole rotated image fits

Newly exposed area is filled with black. Rotation always resamples into a fresh buffer, so it returns a new object regardless of expand.

img.rotate(90)                  # 640x480 -> 640x480, corners clipped
img.rotate(30, expand=True)     # 640x480 -> 795x736

sharpen(strength=1.0)

Sharpen with a 3×3 convolution, blending between the identity kernel and PIL's ImageFilter.SHARPEN.

strength Effect
0.0 No-op
1.0 Matches PIL's SHARPEN (default)
> 1.0 Over-sharpens

Known deviation: the outermost 1-pixel ring is zero-filled (black), because out-of-bounds taps are treated as zero rather than replicated. Crop 1 pixel afterwards if that border matters. See Known deviations.

normalize(mean, std)

Convert to float32 as (pixel / 255 - mean) / std, applied per channel.

Parameter Type Description
mean Sequence[float] Exactly 3 per-channel means, RGB order
std Sequence[float] Exactly 3 per-channel standard deviations

This changes dtype from uint8 to float32, so it is normally the last step before .numpy(). Afterwards save and all the uint8-only operations are unavailable. Raises ValueError unless both sequences have exactly 3 elements.

# ImageNet statistics
img.normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])

brightness(delta)

Add delta to every channel, where delta is a fraction of full scale — 0.1 shifts by 25.5 of 255 levels. Negative darkens. Results are clamped to the valid range, not wrapped.

Mutates in place and returns self.

contrast(factor)

Scale contrast about mid-grey: each channel becomes (v - 128) * factor + 128, clamped. 1.0 is a no-op, above 1 increases contrast, below 1 flattens toward grey.

Mutates in place and returns self.

The pivot is 128, not 0. OpenCV's convertScaleAbs scales about 0, so the same factor gives different results in the two libraries.

equalize_histogram()

Equalize the histogram of each channel independently.

Mutates in place and returns self.

Operating per-channel on RGB rather than on a luminance channel can shift colour balance on strongly tinted images. That is intentional and fixed as of 1.0.0.

numpy()

Copy the buffer out as a NumPy array of shape (H, W, C) in RGB order. dtype is uint8, or float32 once normalize has been applied.

This copies rather than sharing memory, so the array stays valid and independent no matter what happens to the Image afterwards.

save(path)

Encode and write to path. Format is chosen from the extension; PNG, JPEG, BMP, WebP, TIFF and GIF all work.

Only valid on the uint8 path. Calling it after normalize raises ValueError, because a normalized float buffer has no meaningful encoding as an 8-bit image. Raises OSError if the path is unwritable.

fp.BatchImage

Images transformed together in parallel. Construct with load_batch, never directly.

Every method mirrors Image with identical parameters and the same aliasing behaviour, applied across the whole batch. The only differences:

  • No save. Write images individually if you need files on disk.
  • numpy() returns (N, H, W, C) and requires uniform shape and dtype across the batch.
batch = fp.load_batch(paths)
arr = (batch
       .resize(224, 224)          # new batch
       .brightness(0.1)           # in place, returns the same batch
       .normalize(mean=..., std=...)
       .numpy())                  # (N, 224, 224, 3) float32

numpy() raises ValueError if the batch is empty, or if its images disagree on shape or dtype. Ragged batches are out of scope; call resize first to make them uniform.

Errors short-circuit: if crop falls outside any one image, the whole call fails rather than returning partial results.

Pixel semantics

These are fixed as of 1.0.0. Changing any of them is a major version bump, however small the diff — see techstack.md §6.

Behaviour FerrumPixel Note
Rotation direction Counter-clockwise for positive angles PIL convention. OpenCV's ROTATE_90_CLOCKWISE turns the other way
Rotation centre Pixel-grid centre, ((w-1)/2, (h-1)/2) Not w/2, which is off by half a pixel
Contrast pivot Mid-grey: (v - 128) * factor + 128 OpenCV's convertScaleAbs scales about 0
Brightness v + delta * 255, clamped delta is a 0–1 fraction, not a raw level
Normalize (pixel / 255 - mean) / std, float32 Per channel, RGB order
Rounding f32 → u8 rounds Does not truncate
Channel order RGB throughout Not BGR
Histogram equalization Per channel on RGB Not on a luminance channel

Every operation is parity-tested against a PIL → OpenCV → NumPy reference within documented per-op tolerances, and the benchmark suite refuses to report timings if that gate fails. Methodology in benchmark.md §7.

Concurrency and threading

GIL release

Every operation wraps its compute in py.allow_threads, so the GIL is free for the whole call. Other Python threads make progress while FerrumPixel works. This does not parallelise a single image internally — it prevents one slow transform from stalling the interpreter.

Batch parallelism

BatchImage dispatches across Rayon's global thread pool. The pool defaults to one worker per logical CPU.

export RAYON_NUM_THREADS=4

Containers: set this explicitly. Rayon sizes its pool from the logical CPU count of the host and does not read cgroup CPU limits. In a container with a 2-CPU quota on a 64-core host, it will spawn 64 workers and thrash. Pin RAYON_NUM_THREADS to your quota.

Thread count affects scheduling only, never output — this is covered by a test.

Web frameworks

In FastAPI or any ASGI framework, declare preprocessing handlers as plain def, not async def:

@app.post("/preprocess")
def preprocess(file: UploadFile = File(...)):     # def, not async def
    arr = fp.load(file.file.read()).resize(512, 512).numpy()
    return {"shape": arr.shape}

An async def handler runs directly on the event loop, so synchronous CPU-bound work inside it blocks every other in-flight request — the service then handles exactly one request at a time no matter how many clients connect. A plain def handler is dispatched to the threadpool, which is what lets the GIL release turn into actual parallelism. In our own load test this was the difference between throughput flat at ~34 req/s and throughput scaling from 23 to 140 req/s across a 1→100 concurrency ramp.

A complete worked example is in examples/fastapi_service/.

Error handling

Everything maps onto Python builtins — there is no custom exception hierarchy to learn or catch.

Condition Raises
Missing or unreadable path OSError
Unwritable destination in save OSError
Undecodable image data ValueError
Crop outside image bounds ValueError
mean/std not exactly 3 elements ValueError
save after normalize ValueError
Empty or ragged batch in numpy() ValueError
Argument neither str nor bytes TypeError

Type checking

The package ships inline type stubs and a PEP 561 py.typed marker, so mypy and pyright pick up the API with no extra stub package:

img: fp.Image = fp.load("a.jpg")
arr = img.resize(512, 512).numpy()

fp.load(123)                     # error: no overload matches argument type "int"
img.resize("512", 512)           # error: incompatible type "str"; expected "int"

Performance

Measured on an 8-core Zen 3 machine. Full methodology and numbers in benchmark.md and the Phase 4 report.

Workload vs. PIL/OpenCV/NumPy baseline
Batch full chain, N=128 1.88x faster
Batch full chain, N=8 1.42x faster
resize alone 1.90x faster
Peak RSS during batch 0.88x (uses less memory)
Single-image full chain 0.54x — slower

Two honest observations.

Batch is where FerrumPixel wins, and the advantage grows with batch size — 1.42x at N=8 to 1.88x at N=128 — because Rayon's dispatch overhead amortises over more work while the baseline's per-image throughput stays flat.

Single-image full chains are currently slower than OpenCV. The cause is sharpen: it runs a scalar 3×3 convolution against OpenCV's hand-tuned SIMD filter2D, and at ~51 ms on a 1024² image it dominates the chain. If your workload is single-image and sharpen-heavy, OpenCV is still faster today. Vectorising it is the top item on the post-1.0 list. Chains without sharpen fare considerably better.

Under concurrent load the picture is different again: FerrumPixel holds a lower p99 than the baseline at high concurrency (1200 ms vs 1900 ms at 100 concurrent clients) because the GIL release keeps the tail from blowing out, even though median throughput still favours the baseline.

Known deviations and limitations

These are documented behaviour as of 1.0.0, not pending fixes. Correcting the first two would change output pixels and therefore requires a major version bump.

  • sharpen leaves a 1-pixel zero-filled border. OpenCV reflects and PIL replicates at the edge; FerrumPixel treats out-of-bounds taps as zero. Interior pixels match OpenCV to within 1 level.
  • equalize_histogram normalises its CDF by total, where OpenCV uses total - cdf_min. Outputs differ by up to 10 levels on the reference image. This is an accepted algorithmic divergence, not a bug.
  • .clone() is not implemented. It is specified in design.md §1.3 as the escape hatch from in-place aliasing. Additive, so it can land in a 1.x release.
  • Ragged batches are unsupported. BatchImage.numpy() raises rather than padding or returning a ragged structure.
  • Alpha is dropped on load, not composited.
  • No GPU support, and no zero-copy NumPy interop — .numpy() always copies.
  • PyPy is unsupported, since abi3 is a CPython ABI.

Versioning and stability

Semantic versioning, with one project-specific rule: any change to pixel semantics is a major bump, regardless of how small the code change is. Interpolation defaults, rounding, channel order, rotation direction and contrast pivot are all covered.

  • Major — pixel semantics change, or an API removal
  • Minor — new operations, new optional keyword arguments
  • Patch — bug fixes that do not change documented behaviour

Full policy in techstack.md §6. Release history in CHANGELOG.md.

Project documentation

Doc Purpose
design.md API design — signatures, aliasing semantics, pixel-semantics contract
benchmark.md Benchmark methodology, target numbers, correctness gate
architecture.md Component boundaries, data flow, concurrency model
prd.md Goals, non-goals, user stories, success metrics
techstack.md Every dependency, why it was chosen, versioning policy
libraryScope.md In-scope/out-of-scope boundaries per dependency, rejected alternatives
phases.md Implementation roadmap and per-phase exit criteria
agents.md Conventions and guardrails for AI coding agents working in this repo
CHANGELOG.md Version history

Contributors: start with prd.md → architecture.md → design.md → techstack.md. AI agents: read agents.md first, always.

Building from source

git clone https://github.com/ShivamMalge/FerrumPixel.git
cd FerrumPixel
python -m venv .venv && source .venv/bin/activate
pip install maturin pytest numpy pillow opencv-python-headless pytest-benchmark
maturin develop --release -m crates/ferrumpixel-py/Cargo.toml

pytest tests/python -v                              # test suite
pytest benches/test_bench.py --benchmark-disable    # correctness gate
cargo test --workspace                              # Rust tests
cargo clippy --workspace -- -D warnings

The correctness gate must pass before any benchmark will report a number — a fast wrong answer is not a result.

License

Dual licensed under MIT or Apache-2.0, at your option.

About

Rust-accelerated image preprocessing for Python ML pipelines — replace PIL/OpenCV/NumPy chains with a single GIL-releasing core

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages