Metadata-Version: 2.4
Name: focalpy
Version: 0.0.3
Summary: Image processing in C++ with a zero-copy NumPy interface
Keywords: image-processing,imaging,computer-vision,numpy,cpp
Author-Email: Edison Sun <edisonsun31@gmail.com>
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Science/Research
Classifier: Programming Language :: C++
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Multimedia :: Graphics
Classifier: Topic :: Scientific/Engineering :: Image Processing
Project-URL: Homepage, https://github.com/edisons3608/focal
Project-URL: Repository, https://github.com/edisons3608/focal
Project-URL: Issues, https://github.com/edisons3608/focal/issues
Requires-Python: >=3.12
Requires-Dist: numpy>=1.23
Provides-Extra: test
Requires-Dist: pytest; extra == "test"
Requires-Dist: scipy; extra == "test"
Requires-Dist: scikit-image; extra == "test"
Requires-Dist: pillow; extra == "test"
Description-Content-Type: text/markdown

# focal

Image processing in C++20 with a zero-copy NumPy interface.

```bash
pip install focalpy
```

```python
import focal

img = focal.imread("photo.jpg")            # linear float32, (h, w, c)
img = focal.exposure(img, 0.5)             # half a stop brighter
img = focal.resize(img, 1080, 1920)        # prefiltered, in linear light
img = focal.gaussian_blur(img, 2.0)
focal.imwrite("out.png", img)              # encodes back to sRGB
```

## Why

**Downscaling prefilters, so fine detail averages instead of aliasing.**
Shrinking by 4x means each output pixel must draw on about 4 input pixels. A
resize that skips this doesn't lose fine detail gracefully — it invents
patterns that were never in the image.

![aliasing comparison](https://raw.githubusercontent.com/edisons3608/focal/main/docs/images/resize_aliasing.png)

**Filtering happens in linear light.** Blurring and resizing mix pixels
together, and mixing light is physics — it has to happen on linear values, not
gamma-encoded ones. `imread` decodes sRGB on the way in and `imwrite` encodes
on the way out, so every op in between is correct by construction.

![linear light comparison](https://raw.githubusercontent.com/edisons3608/focal/main/docs/images/linear_light.png)

Averaging equal black and white gives half the light, which displays as *light*
grey (182/255). Doing it on encoded values gives 128/255 — visibly too dark,
and it compounds every time an image is resized.

**Nothing converts silently.** Array arguments are declared `.noconvert()`, so
a float64 or non-contiguous array raises `TypeError` rather than quietly
costing a full-size copy. Converting is the caller's decision, not a hidden one.

## What's here

| | |
|---|---|
| I/O | `imread`, `imwrite` — PNG, JPEG, BMP, TGA |
| Resize | `resize` — nearest, bilinear, Catmull-Rom, Mitchell, Lanczos3 |
| Filtering | `gaussian_blur`, `correlate1d`, `separable_filter` |
| Point ops | `gamma`, `exposure`, `brightness_contrast`, `invert`, `clip`, `blend`, `apply_lut` |
| Sampling | `sample` — nearest and bilinear at fractional coordinates |
| Color | `rgb_to_gray`, `gray_to_rgb`, `rgb_to_hsv`, `rgb_to_xyz`, `rgb_to_lab` and inverses |
| Transfer | `srgb_to_linear`, `linear_to_srgb` |

Box blur, Sobel, and unsharp mask fall out of the primitives above:

```python
box    = focal.separable_filter(img, k, k)                    # k = ones(n)/n
sobel  = focal.separable_filter(img, [-1,0,1], [1,2,1])
sharp  = focal.blend(img, focal.gaussian_blur(img, 2.0), -0.5)
```

Border modes (`clamp`, `reflect`, `reflect101`, `wrap`, `constant`) are shared
by every spatial op and match `scipy.ndimage`'s semantics.

Each color conversion states which light it expects, because the answer is
different per space. `rgb_to_gray`, `rgb_to_xyz` and `rgb_to_lab` take linear
light — luminance weights (0.2126, 0.7152, 0.0722) are weights on light, and
the XYZ matrix is defined on linear sRGB. `rgb_to_hsv` takes display-referred
values, because that is the convention the space was defined under. Nothing is
clamped: an XYZ or Lab color outside the sRGB gamut comes back with negative
channels rather than silently snapping into range.

## Design

**Everything is a view.** The core type is a non-owning
`{pointer, dtype, shape, strides}` struct, so a crop, a single channel, and a
NumPy array handed over from Python are all the same type, and none of them
copy. Strides are in bytes, matching NumPy exactly, so wrapping an incoming
array is copying six integers.

**Ops run on float32 linear.** dtype conversion happens at the edges of a
pipeline, never per pixel inside an op.

**Correctness is checked against independent oracles.** The test suite compares
against `scipy.ndimage` and `PIL` wherever one exists — border handling is
verified for every index from -2n to 3n across four modes and four image sizes,
and resize matches PIL's interior to 1e-7.

## Building from source

Needs a C++20 compiler and CMake:

```bash
git clone https://github.com/edisons3608/focal
cd focal
pip install -e . --no-build-isolation
pytest
```

OpenMP is used when available (`-DFOCAL_USE_OPENMP=OFF` to disable). On macOS
that means `brew install libomp`.

## Status

Early, and the API will change. Missing so far: geometric warps, histogram
operations, 16-bit and HDR file formats.

## License

MIT. Bundles [stb](https://github.com/nothings/stb) (public domain).
