Metadata-Version: 2.4
Name: pixelhog
Version: 1.3.1
Classifier: Development Status :: 5 - Production/Stable
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Rust
Classifier: Operating System :: OS Independent
Classifier: License :: OSI Approved :: MIT License
Requires-Dist: pytest>=8.0 ; extra == 'test'
Requires-Dist: pillow>=10.0 ; extra == 'test'
Provides-Extra: test
License-File: LICENSE
Summary: Rust-accelerated pixelmatch and SSIM for PNG bytes
Author: PostHog
Requires-Python: >=3.12
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
Project-URL: Repository, https://github.com/PostHog/pixelhog

# pixelhog

Fast visual regression primitives for Python, implemented in Rust.

`pixelhog` compares screenshots in two complementary ways:
- `diff`: exact pixel-level differences (with anti-alias handling), optional diff image output
- `ssim`: perceptual similarity score in `[0.0, 1.0]`

It also provides spatial clustering (where on the page did things change), early-exit checks,
and WebP thumbnail generation — all accessible through a stateful `Comparison` object that
decodes images once and exposes methods on demand.

## Install (local dev)

```bash
uv venv .venv --python 3.12
source .venv/bin/activate
uv pip install -U pip maturin
maturin develop --release
```

## Quickstart

```python
from pixelhog import Comparison, thumbnail

cmp = Comparison(baseline_png, current_png)

count = cmp.diff_count()                     # pixel mismatch count
score = cmp.ssim()                           # perceptual similarity
png   = cmp.diff_image()                     # diff visualization (PNG bytes)
thumb = cmp.current_thumbnail(width=200)     # lossless WebP thumbnail

# Spatial clustering — where did things change?
result = cmp.clusters(dilation=8, merge_gap=60)
for cluster in result.clusters:
    print(cluster.bbox.x, cluster.bbox.y, cluster.bbox.width, cluster.bbox.height)

# Early exit — fail fast if too many diffs
capped = cmp.diff_count_capped(max_diffs=1000)

# Standalone thumbnail (lossless WebP, Lanczos3 downscale, top-crop)
thumb = thumbnail(current_png, width=200, height=150)
```

## API at a glance

### Comparison

| Method | Returns | Notes |
|---|---|---|
| `Comparison(baseline_png, current_png)` | `Comparison` | Decode pair once, call methods on demand |
| `Comparison.from_rgba(...)` | `Comparison` | Pre-decoded RGBA buffers |
| `Comparison.batch(pairs)` | `list[Comparison]` | Parallel decode |
| `.diff_count(threshold, include_aa)` | `int` | Pixel mismatch count |
| `.diff_count_capped(max_diffs, ...)` | `int` | Early-exit count |
| `.ssim()` | `float` | Structural similarity |
| `.clusters(dilation, merge_gap, ...)` | `ClustersResult` | Spatial regions of change |
| `.row_alignment(...)` | `RowAlignment` | Separate a vertical shift from real changes |
| `.aligned_clusters(alignment, ...)` | `ClustersResult` | Clusters of the changed content, shift left out |
| `.aligned_ssim(alignment)` | `float` | SSIM over the matched rows only |
| `.aligned_diff_image(alignment, ...)` | `bytes` (PNG) | Shift-aware diff visualization |
| `.diff_image(...)` | `bytes` (PNG) | Diff visualization |
| `.current_thumbnail(width, height, ...)` | `bytes` (WebP) | Thumbnail of current image |
| `.baseline_thumbnail(width, height, ...)` | `bytes` (WebP) | Thumbnail of baseline image |
| `.size_mismatch` | `bool` | Whether images had different dimensions |

### Utilities and batch

| Function | Input | Output | Use when |
|---|---|---|---|
| `thumbnail` | PNG bytes | `bytes` (WebP) | Single-image thumbnail (no pair needed) |
| `diff_batch` | `list[(baseline, current)]` | `list[DiffResult]` | Parallel diff across many pairs |
| `diff_count_batch` | `list[(baseline, current)]` | `list[DiffCountResult]` | Parallel count-only |
| `ssim_batch` | `list[(baseline, current)]` | `list[float]` | Parallel SSIM |
| `compare_batch` | `list[(baseline, current)]` | `list[CompareResult]` | Parallel combined metrics |

## Row alignment (vertical shifts)

A panel grows by a pixel, a banner is inserted, a list gains a row — everything below moves down.
A top-aligned pixel diff then flags most of the page and SSIM reports large dissimilarity, even
though nothing else changed. `row_alignment()` hashes each pixel row and runs a budgeted Myers
diff over the hashes.

```python
cmp = Comparison(baseline_png, current_png)
alignment = cmp.row_alignment()

if alignment.aligned:
    print(alignment.inserted_rows, alignment.deleted_rows, alignment.residual_count)
    for band in alignment.bands:
        print(band.kind, band.y, band.rows)      # "inserted" / "deleted", current-image rows

    clusters = cmp.aligned_clusters(alignment)   # clusters of the changed content
    score = cmp.aligned_ssim(alignment)          # SSIM over the matched rows only
    png = cmp.aligned_diff_image(alignment)      # diff image in current-image coordinates
```

| Field | Meaning |
|---|---|
| `aligned` | False when the pair could not be aligned: too different, or a width change (alignment is vertical only). Every other field is then zero or empty, and every `aligned_*` method raises. |
| `inserted_rows` / `deleted_rows` | Rows the current image gained or lost — the shift itself. |
| `changed_rows` | Rows present in both images whose content differs. |
| `residual_count` | Differing pixels inside those changed rows. This is the number to threshold on: it excludes the shift. |
| `bands` | Where the content below moved, in current-image coordinates, as the diff image draws it. A deleted band is the seam row the removed rows left behind. Band rows can differ from the counts when a region was replaced. |

The cluster mask holds the changed content only. Shift bands are the other half of the answer, so read
`alignment.bands` to decide whether to absorb a shift or flag it.

A region replaced by content of a different height, such as a tall chart swapped for a short one, keeps blank rows that both versions share.
The Myers diff matches those blank rows inside the region, so the fields above report the change as separate inserted and deleted rows.
`aligned_diff_image()`, `aligned_clusters()` and `bands` show such a region as one changed region instead, followed by the rows one side has over the other.
The counts and `residual_count` do not change, so thresholds that read them behave as before. An alignment belongs to the pair
it was computed from; passing it to another `Comparison` raises. Tune the bail-out with
`max_edit_ratio` (default 0.25) and `max_edit_rows` (default 2048).

## Behavior

- `Comparison` decodes PNG bytes once at construction; methods compute on demand.
- `Comparison.from_rgba(...)` accepts pre-decoded RGBA buffers (zero-copy).
- Smaller images are padded to the larger dimensions with transparent pixels.
- SSIM uses 11×11 uniform windows with reflect padding; falls back to global for tiny images.
- Clustering uses morphological dilation + two-pass CCL with optional aligned-bbox merge.
- Row alignment hashes rows and runs a budgeted Myers diff over the hashes. It is vertical only:
  a width change makes a pair unalignable.

## Correctness and tests

The test suite is designed to validate both algorithm fidelity and practical product behavior.

- Rust unit/integration tests cover:
  - identical/completely different/partial-diff images
  - threshold behavior
  - different-size padding behavior
  - SSIM behavior (identical, slight change, large change, small-image fallback)
- Canonical pixelmatch fixture tests use the official Mapbox test set:
  - 8 fixture pairs with exact expected mismatch counts
  - expected diff image comparison against golden outputs
  - decoded RGBA byte equality checks to ensure pixel-perfect output matching
- Python integration tests cover:
  - high-level API contracts and error behavior
  - tall-page and subtle-change scenarios
  - cross-validation against a pure-Python reference implementation
    - pixel diff counts must match exactly
    - SSIM must stay within tolerance

Run the full correctness suite:

```bash
# Rust core only
cargo test -p pixelhog

# Full suite including Python integration tests
cargo test
uv run --python 3.12 --with maturin --with pytest --with pillow bash -lc \
  "maturin develop --release && pytest -q"
```

## Benchmarks

The repo includes both Criterion benches and pipeline breakdown tools.

- `cargo bench` runs Criterion API benchmarks (PNG-bytes entry points).
- Breakdown binaries in `examples/` measure where time goes:
  - `breakdown.rs`: decode vs core diff vs encode vs API call
  - `ssim_breakdown.rs`: decode/pad vs core SSIM vs API call
  - `combined_estimate.rs`: separate calls vs combined single-decode flow

Run:

```bash
cargo bench -p pixelhog
cargo run -p pixelhog --release --example breakdown
cargo run -p pixelhog --release --example ssim_breakdown
cargo run -p pixelhog --release --example combined_estimate
```

For screenshot-style workloads, the practical guidance is:
- `diff_count` is cheaper than `diff` when you do not need an artifact.
- `compare(..., return_diff=False)` avoids duplicate decode work when you need both diff-count and SSIM.

## Development

```bash
# Rust tests (includes canonical Mapbox fixture tests)
cargo test

# Python extension + tests
uv run --python 3.12 --with maturin --with pytest --with pillow bash -lc \
  "maturin develop --release && pytest -q"

# Lint/format/type-check
uv run --python 3.12 --with ruff ruff format --check .
uv run --python 3.12 --with ruff ruff check .
uv run --python 3.12 --with ty --with pytest --with pillow ty check . --python .venv
```

## License

This repository is MIT licensed. See [LICENSE](LICENSE).

Algorithm attribution for pixelmatch is documented in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).

