Metadata-Version: 2.4
Name: ferrumpixel
Version: 1.0.0
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Science/Research
Classifier: License :: OSI Approved :: MIT License
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Operating System :: POSIX :: Linux
Classifier: Operating System :: MacOS :: MacOS X
Classifier: Operating System :: Microsoft :: Windows
Classifier: Programming Language :: Rust
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Topic :: Multimedia :: Graphics
Classifier: Topic :: Scientific/Engineering :: Image Processing
Classifier: Typing :: Typed
Requires-Dist: numpy
Requires-Dist: pytest ; extra == 'dev'
Requires-Dist: pytest-benchmark ; extra == 'dev'
Requires-Dist: ruff ; extra == 'dev'
Requires-Dist: mypy ; extra == 'dev'
Provides-Extra: dev
License-File: LICENSE-APACHE
License-File: LICENSE-MIT
Summary: A Rust-accelerated image preprocessing core exposed to Python
Keywords: image,preprocessing,resize,rust,pyo3,numpy,ml
Author: ShivamMalge
License: MIT OR Apache-2.0
Requires-Python: >=3.9
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
Project-URL: Changelog, https://github.com/ShivamMalge/FerrumPixel/blob/main/CHANGELOG.md
Project-URL: Homepage, https://github.com/ShivamMalge/FerrumPixel
Project-URL: Issues, https://github.com/ShivamMalge/FerrumPixel/issues
Project-URL: Repository, https://github.com/ShivamMalge/FerrumPixel

# FerrumPixel

**Rust-accelerated image preprocessing for Python.**

[Install](#install) · [Quickstart](#quickstart) · [API reference](#api-reference) · [Semantics](#pixel-semantics) · [Concurrency](#concurrency-and-threading) · [Performance](#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.

```python
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](#what-it-replaces-and-why)
- [Install](#install)
- [Quickstart](#quickstart)
- [Core concepts](#core-concepts)
  - [The chain](#the-chain)
  - [The aliasing contract](#the-aliasing-contract)
- [API reference](#api-reference)
  - [`fp.load`](#fploadpath_or_bytes)
  - [`fp.load_batch`](#fpload_batchpaths_or_bytes)
  - [`fp.Interpolation`](#fpinterpolation)
  - [`fp.Image`](#fpimage)
  - [`fp.BatchImage`](#fpbatchimage)
- [Pixel semantics](#pixel-semantics)
- [Concurrency and threading](#concurrency-and-threading)
- [Error handling](#error-handling)
- [Type checking](#type-checking)
- [Performance](#performance)
- [Known deviations and limitations](#known-deviations-and-limitations)
- [Versioning and stability](#versioning-and-stability)
- [Project documentation](#project-documentation)

---

## 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](#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

```bash
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

```python
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:

```python
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:

```python
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-changing** — `resize`, `crop`, `rotate`, `sharpen`, `normalize`. These allocate and return a **new** `Image`. The receiver is untouched.
- **Neither** — `brightness`, `contrast`, `equalize_histogram`. These mutate the buffer **in place** and return the *same* object.

```python
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:

```python
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:

```python
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](./design.md) 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](#the-aliasing-contract)).

| Method | Signature | New? |
|---|---|---|
| [`resize`](#resizew-h-interpolationbilinear) | `(w, h, interpolation=BILINEAR)` | new |
| [`crop`](#cropx-y-w-h) | `(x, y, w, h)` | new |
| [`rotate`](#rotatedegrees-expandfalse) | `(degrees, expand=False)` | new |
| [`sharpen`](#sharpenstrength10) | `(strength=1.0)` | new |
| [`normalize`](#normalizemean-std) | `(mean, std)` | new |
| [`brightness`](#brightnessdelta) | `(delta)` | in place |
| [`contrast`](#contrastfactor) | `(factor)` | in place |
| [`equalize_histogram`](#equalize_histogram) | `()` | in place |
| [`numpy`](#numpy) | `()` | — |
| [`save`](#savepath) | `(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.

```python
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](#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`.

```python
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](#known-deviations-and-limitations).

#### `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.

```python
# 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.

```python
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](./techstack.md).

| 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](./benchmark.md).

## 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.

```bash
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`:

```python
@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/](./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:

```python
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](./benchmark.md) and the [Phase 4 report](./benches/reports/phase4_report.md).

| 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](./design.md) 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](./techstack.md). Release history in [CHANGELOG.md](./CHANGELOG.md).

## Project documentation

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

```bash
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](./LICENSE-MIT) or [Apache-2.0](./LICENSE-APACHE), at your option.

