Metadata-Version: 2.4
Name: chromapair
Version: 1.0.0
Summary: Learn and apply deterministic color transforms from aligned image pairs
Project-URL: Documentation, https://github.com/alixsep/chromapair#readme
Project-URL: Issues, https://github.com/alixsep/chromapair/issues
Project-URL: Source, https://github.com/alixsep/chromapair
Author-email: Alixsep <alixsep@outlook.com>
License-Expression: MIT
License-File: LICENSE
Keywords: cli,color-matching,color-transfer,image-processing
Classifier: Development Status :: 5 - Production/Stable
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Multimedia :: Graphics
Requires-Python: >=3.11
Requires-Dist: numpy>=2.0
Requires-Dist: pillow>=11.0
Requires-Dist: scikit-image>=0.25
Provides-Extra: dev
Requires-Dist: build>=1.2; extra == 'dev'
Requires-Dist: pytest>=8.3; extra == 'dev'
Requires-Dist: ruff>=0.11; extra == 'dev'
Description-Content-Type: text/markdown

# Chromapair

Chromapair learns a deterministic, reusable color transform from spatially
aligned areas in a source image and a reference image. It exports either three
256-entry RGB lookup tables or a regularized RGB polynomial, selected by the
lowest four-fold cross-validated CIEDE2000 error.

It is image- and layout-agnostic:

- dimensions come from the input images by default;
- a smaller source can be positioned inside a reference canvas;
- one area or several disjoint areas can be fitted together;
- averaging tile size is calculated automatically and edge tiles need not
  divide evenly;
- output is stable, validated JSON that can be applied from the CLI or Python.

## Install

Chromapair requires Python 3.11 or newer and is tested with Python 3.14.

```console
uv tool install git+https://github.com/alixsep/chromapair.git
chromapair --help
```

After a PyPI release, `uv tool install chromapair` works as well. For
development:

```console
git clone https://github.com/alixsep/chromapair.git
cd chromapair
uv sync --extra dev
uv run pytest
```

## Fit and apply

With no area, the full overlap between the source and reference is used:

```console
chromapair fit \
  --source source.png \
  --reference reference.png \
  --name warmer
```

The reference retains its natural dimensions and the source starts at `(0, 0)`.
Apply the generated preset to the original source or another image:

```console
chromapair apply \
  --preset output/presets/warmer.json \
  --input another-source.png \
  --output warmer-output.png
```

The output format is selected from its extension. RGB is transformed while
dimensions and transparency are preserved. Existing outputs are protected
unless `--force` is supplied.

## Fit multiple areas

Coordinates use exclusive right and bottom bounds. Repeat `--area` as needed:

```console
chromapair fit \
  --source texture.png \
  --reference proxy.png \
  --name violet \
  --canvas-size 813 1185 \
  --source-origin 28 28 \
  --area 35 204 67 844 \
  --area 71 1084 426 1109
```

`--canvas-size` resizes the reference with Lanczos resampling. The source is
never resized or modified; `--source-origin` only establishes aligned
coordinates. Every selected area must be covered by both images.

By default Chromapair aims for about 512 spatial samples:

```text
sample size = floor(sqrt(total selected pixels / target samples))
```

Each area is tiled independently. Partial tiles along the right and bottom
edges are averaged normally, so area dimensions do not need to share factors.
Use `--target-samples N` to adjust the automatic tradeoff, or
`--sample-size N` for an explicit, reproducible override.

Other useful options:

```console
chromapair fit --help
chromapair fit ... --no-previews
chromapair fit ... --preset-output path/to/preset.json
chromapair fit ... --metadata '{"target":"frame"}'
chromapair apply ... --force
```

## Batch configuration

Run all four checked-in examples with:

```console
chromapair batch --config examples/batch.json --no-previews
```

Paths are relative to the config. `areas` accepts either one rectangle:

```json
"areas": [35, 204, 67, 844]
```

or multiple rectangles:

```json
"areas": [
  [35, 204, 67, 844],
  [71, 1084, 426, 1109]
]
```

Defaults are merged into every job. See
[`examples/batch.json`](https://github.com/alixsep/chromapair/blob/main/examples/batch.json)
for the complete version-1 format.

The combined bundle produced by a batch can be applied directly:

```console
chromapair apply \
  --preset examples/presets.json \
  --preset-name example-1 \
  --input examples/input-1.webp \
  --output result.png
```

## Examples

These examples deliberately start with altered inputs and learn the inverse
transform from each aligned reference. Every output below was produced by
Chromapair using
[`examples/batch.json`](https://github.com/alixsep/chromapair/blob/main/examples/batch.json)
and the checked-in presets.

| Example | Altered input | Reference | Chromapair output |
|:---|:---:|:---:|:---:|
| **1. Hue shift**<br>Hue shifted by `-150`. | ![Blue hue-shifted hoodie input](https://raw.githubusercontent.com/alixsep/chromapair/main/examples/input-1.webp) | ![Pink hoodie reference](https://raw.githubusercontent.com/alixsep/chromapair/main/examples/reference-1.webp) | ![Recovered pink hoodie output](https://raw.githubusercontent.com/alixsep/chromapair/main/examples/outputs/output-1.png) |
| **2. Contrast, brightness, and size**<br>Reduced to 80% contrast and darkened by 30%. The 64×64 reference is resized to the input's 512×512 canvas for fitting; the output remains 512×512. | ![Dark low-contrast glass input](https://raw.githubusercontent.com/alixsep/chromapair/main/examples/input-2.webp) | ![Glass sculpture reference](https://raw.githubusercontent.com/alixsep/chromapair/main/examples/reference-2.webp) | ![Restored glass output](https://raw.githubusercontent.com/alixsep/chromapair/main/examples/outputs/output-2.png) |
| **3. Saturation and inversion**<br>Saturated by 50% and inverted. | ![Saturated inverted penguin input](https://raw.githubusercontent.com/alixsep/chromapair/main/examples/input-3.webp) | ![Penguin reference](https://raw.githubusercontent.com/alixsep/chromapair/main/examples/reference-3.webp) | ![Recovered penguin output](https://raw.githubusercontent.com/alixsep/chromapair/main/examples/outputs/output-3.png) |
| **4. Selective color shift**<br>Pink foliage shifted toward purple. | ![Purple-shifted landscape input](https://raw.githubusercontent.com/alixsep/chromapair/main/examples/input-4.webp) | ![Pink landscape reference](https://raw.githubusercontent.com/alixsep/chromapair/main/examples/reference-4.webp) | ![Recovered pink landscape output](https://raw.githubusercontent.com/alixsep/chromapair/main/examples/outputs/output-4.png) |

## Output

A single fit writes:

```text
output/
├── presets/<name>.json
├── reports/<name>.json
└── <name>/
    ├── reference-areas.png
    └── selected-source.png
```

The preset contains the transform and reproducibility metadata. The report
contains geometry, resolved sampling, all candidate errors, selection, and a
SHA-256 of the canonical pretty-printed preset.

Chromapair compares an RGB histogram LUT with regularized degree 1, 2, and 3
RGB polynomials. Model selection uses averaged, spatially corresponding
samples. Final histogram fitting and quality metrics use every selected visible
pixel. Fully transparent source pixels are ignored. Source alpha is preserved
in preview and applied output.

Presets are validated before they are written or applied. Invalid format
versions, methods, LUT lengths, polynomial terms, coefficient shapes, and
non-finite numeric values are rejected with a clear error.

## Python API

```python
import json
from pathlib import Path

from PIL import Image

from chromapair import apply_preset, validate_preset

preset = json.loads(Path("preset.json").read_text())
validate_preset(preset)

with Image.open("input.png") as image:
    output = apply_preset(image, preset)

output.save("output.png")
```

`apply_preset` returns a new image and does not modify its input.

## Determinism

Chromapair has no random fitting step. Tiles, fold assignments, candidate
ordering, coefficient rounding, JSON key ordering, and output hashing are
deterministic. Lock dependencies with the checked-in `uv.lock` when exact
cross-machine reproduction matters.

## License

MIT
