Skip to main content

PixelCache

One hashable wrapper for images stored as PIL, NumPy, or PyTorch — convert between them on demand, hash them safely as cache keys, and stop transferring pixel data through disk.

PyPI Downloads Python Tests Publish License: MIT uv Ruff mypy jaxtyping Pydantic v2 Beartype Socket PRs Welcome

                  ┌──────────────┐
   path / URL ──▶ │              │ ──▶ .pil()           PIL.Image
   bytes      ──▶ │ HashableImage│ ──▶ .numpy()         np.ndarray  (h w 3 | h w)
   ndarray    ──▶ │              │ ──▶ .tensor()        torch.Tensor (1 c h w)
   tensor     ──▶ │  (immutable) │ ──▶ .raw()           native type
   PIL.Image  ──▶ │              │ ──▶ hash(img)        xxhash content fingerprint
                  └──────────────┘

Every accessor returns an independent copy by default. Need the zero-copy view? Use the explicit .numpy_view(), .tensor_view(), .pil_view(), or .raw_view() — and never mutate them.

What can you build with it?

PixelCache is the glue layer between PIL / NumPy / PyTorch image code that doesn't want to know which one it's holding. Some workflows it makes much simpler:

  • ML inference pipelines — Hugging Face / diffusion / SAM-style models want torch tensors; preprocessing libs want NumPy; visualization wants PIL. Wrap once at the input boundary; convert at the call site without copying through Image.fromarray / torch.from_numpy glue.
  • Content-addressed caching — @functools.lru_cache and @cachetools.cached need hashable keys. hash(HashableImage(arr)) is a stable xxhash fingerprint of the pixel content, not id(), so the same image hits the cache regardless of which path / array / tensor it came in as.
  • Mask-driven crop / paste workflows — mask.mask2bbox() → image.crop_from_bbox(boxes) → process → cropped.uncrop_from_bbox(base, boxes) lets you run heavy models on the relevant region only, then composite back.
  • Reproducible test fixtures — feed the same image into HashableImage from any source (path, bytes, ndarray, tensor) and you'll get the same content hash. Useful for snapshot tests and regression suites.
  • Annotated debug grids — HashableImage.make_image_grid({"label": [img]}) stitches a labeled comparison sheet from a dict of HashableLists. One line. Saves to PNG/JPG.
  • EXIF-correct phone-photo loading — HashableImage("IMG_1234.HEIC") decodes via pillow-heif, applies EXIF orientation, and gives you an RGB tensor or array. No more sideways portraits.
  • Bounding box arithmetic — BoundingBox(xmin, ymin, xmax, ymax, image_size=...) carries normalized vs. pixel coords explicitly with .xyxy / .xywh / .xyxyn / .xywhn; arithmetic and equality compare by field, not just hash.
  • Hashable params for ML configs — HashableDict({"image": img, "prompt": "...", "seed": 42}) makes whole inference configurations content-hashable so you can cache by what the call looks like, not by argument identity.

Visual examples

The same HashableImage instance can drive a whole pipeline — color, threshold, palette, geometric ops — without ever touching disk:

Transformations grid

from pixelcache import HashableImage, ImageSize

img = HashableImage("photo.jpg").resize(ImageSize(height=256, width=256))

HashableImage.make_image_grid(
    {
        "original":      [img],
        "to_gray()":     [img.to_gray().to_rgb()],
        "to_binary(.5)": [img.to_gray().to_binary(0.5).to_rgb()],
        "apply_palette": [img.to_gray().apply_palette("viridis")],
        "equalize_hist": [img.equalize_hist().to_rgb()],
        "rotate(45)":    [img.rotate(45.0).resize(ImageSize(height=256, width=256))],
    },
    orientation="vertical",
    with_text=True,
).save("transformations.png")

Mask-driven workflows are one chain — derive a mask, blend it for debugging, crop the region of interest:

Mask workflow

import numpy as np
from pixelcache import HashableImage

img     = HashableImage("photo.jpg")
h, w    = img.size().height, img.size().width
mask_np = np.zeros((h, w), dtype=np.uint8)
mask_np[h // 4 : 3 * h // 4, w // 4 : 3 * w // 4] = 255    # or any source: torch / PIL / detection model
mask    = HashableImage(mask_np).to_binary(0.5)
debug   = img.blend(mask.to_rgb(), alpha=0.45, with_bbox=False)
region  = img.crop_from_mask(mask)                          # send `region` to your model

The full reproducible generator for both PNGs lives in pixelcache/examples/visuals.py.

Installation

# From PyPI
uv add pixelcache
# or
pip install pixelcache

# From source
uv add git+https://github.com/affromero/pixelcache.git

Requires Python ≥ 3.10. Runtime deps: numpy, torch, torchvision, pillow, opencv-python, pydantic, jaxtyping, beartype, xxhash, matplotlib, klogr, einops, pillow-heif, rich, tyro.

Basic Usage

HashableImage accepts any of these input types:

Input Notes
str / pathlib.Path Local file or HTTP/HTTPS URL — decoded once via torchvision (fast path for JPEG/PNG) or PIL (everything else, plus HEIC)
bytes Decoded eagerly, doesn't retain the buffer
PIL.Image.Image RGBA / P / CMYK / YCbCr / LAB inputs normalize to RGB
UInt8[np.ndarray, "h w 3"] / UInt8[np.ndarray, "h w"] / Bool[np.ndarray, "h w"] uint8 / bool numpy arrays
Float[torch.Tensor, "1 c h w"] / Bool[torch.Tensor, "1 1 h w"] torch tensors in [0, 1]
import torch
from pixelcache import HashableImage

image = HashableImage(torch.rand(1, 3, 256, 256))
image_pil    = image.pil()              # PIL.Image — safe to mutate
image_np     = image.numpy()            # np.ndarray — safe to mutate
image_t      = image.tensor()           # torch.Tensor — safe to mutate
image_bool   = image.to_binary(0.5)     # → HashableImage in "1" mode

# Zero-copy escape hatches (read-only):
image_np_ro  = image.numpy_view()       # writeable=False
image_t_ro   = image.tensor_view()      # do not mutate
image_pil_ro = image.pil_view()         # do not mutate

# Cache key usage — hash is stable within a process:
cache: dict[HashableImage, str] = {}
cache[image] = "first-seen"

Common transformations

image.resize(ImageSize(height=128, width=128))   # bilinear by default
image.downsample(factor=2)                       # halves both dims
image.rotate(45.0)
image.to_gray()                                  # → "L" mode
image.to_rgb()                                   # → "RGB" mode
image.to_binary(threshold=0.5)                   # → "1" mode
image.equalize_hist()
image.center_pad(ImageSize(height=512, width=512), fill=255)
image.crop_from_bbox(bboxes)
image.save("/path/to/out.png")

Usage Example 1 — Blending

Blending two images and saving an annotated comparison grid:

from pathlib import Path

from klogr import get_logger
from pixelcache import HashableDict, HashableImage, HashableList

logger = get_logger()

image0 = "https://images.pexels.com/photos/28811907/pexels-photo-28811907/free-photo-of-majestic-elk-standing-in-forest-clearing.jpeg"
image1 = Path("pixelcache") / "assets" / "pixel_cache.png"

images_hash = [HashableImage(image) for image in [image0, image1]]
for image in images_hash:
    logger.info(f"Image: {image} - Hash: {hash(image)}")
logger.info(f"Hash for list of images: {hash(HashableList(images_hash))}")

image_size = images_hash[1].size()
resized_images = [image.resize(image_size) for image in images_hash]

blended = resized_images[0].blend(resized_images[1], alpha=0.5, with_bbox=False)
blended_bin = resized_images[0].blend(
    resized_images[1].to_binary(0.5).invert_binary(),
    alpha=0.2,
    with_bbox=True,
)

output_debug = HashableDict({
    "image base":              HashableList([resized_images[0]]),
    "image reference":         HashableList([resized_images[1]]),
    "blended_image":           HashableList([blended]),
    "blended_image_binarized": HashableList([blended_bin]),
})
output = image1.parent / f"{image1.stem}_demo_blend.jpg"
HashableImage.make_image_grid(
    output_debug, orientation="horizontal", with_text=True,
).save(output)
logger.success(f"Output saved to: {output}")

Blending demo output

Usage Example 2 — Mask → BBox → Crop / Uncrop

Pull a binary mask off a reference image, crop the reference by the mask, then place the crop back onto a base image using mask2bbox. Any chain that produces a HashableImage in binary "1" mode works as the mask input.

from pathlib import Path

from klogr import get_logger
from pixelcache import HashableDict, HashableImage, HashableList, ImageSize

logger = get_logger()

image0 = "https://images.pexels.com/photos/18624700/pexels-photo-18624700/free-photo-of-a-vintage-typewriter.jpeg"
image1 = Path("pixelcache") / "assets" / "pixel_cache.png"

images_hash = [HashableImage(image) for image in [image0, image1]]
image_size = images_hash[1].size()
resized_images = [image.resize(image_size) for image in images_hash]

increased_size_pad = ImageSize(
    width=image_size.width + 1000,
    height=image_size.height + 1000,
)
mask = (
    images_hash[1]
    .center_pad(increased_size_pad, fill=255)
    .resize(image_size)
    .to_gray()
    .to_binary(0.3)
)

cropped = resized_images[1].crop_from_mask(mask)
uncropped = cropped.uncrop_from_bbox(
    base=resized_images[0],
    bboxes=mask.mask2bbox(margin=0.0),
    resize=True,
)

output_debug = HashableDict({
    "image base":      HashableList([resized_images[0]]),
    "image reference": HashableList([resized_images[1]]),
    "cropped_image":   HashableList([cropped.resize(image_size)]),
    "uncropped_image": HashableList([uncropped]),
})
output = image1.parent / f"{image1.stem}_demo_cropUncrop.jpg"
HashableImage.make_image_grid(
    output_debug, orientation="horizontal", with_text=True,
).save(output)
logger.success(f"Output saved to: {output}")

Crop / uncrop demo output

Both examples are runnable as-is from pixelcache/examples/.

Project conventions

This project follows CLAUDE.md: jaxtyping shape/dtype annotations on public surfaces, klogr.path instead of raw pathlib, no Any/# noqa/# type: ignore in new code, files under 1000 lines, immutable value types where possible.

Contributing

Contributions are welcome. Open an issue or submit a pull request — pre-commit run --all-files and pytest tests/ must both pass.

License

MIT — see LICENSE.md.

You might also like

If you found PixelCache useful, check out some other projects from the same author:

DiffLogTest Snapshot testing through print output comparison Python · pytest
fairtrail Track flight prices over time. Self-hosted. Bring your own LLM. Next.js · Prisma
gitpane Multi-repo git workspace dashboard for the terminal Rust · TUI
kin3o AI-powered Lottie animation generator CLI TypeScript · LLM
pricetoken Real-time LLM pricing API · REST + npm + historical data TypeScript
yazi-ssh.yazi Browse remote filesystems in yazi over SSH Lua · yazi plugin
ply2lcc Convert Gaussian Splats in PLY → XGRIDS LCC format Python · 3D
riasec-co Bayesian vocational orientation engine for Colombia npm · PyPI · CRAN

What's new in 0.1.0

This is a near-complete rewrite of pixelcache. See CHANGELOG.md for the full breakdown.

Hot-path speedups

Operation 0.0.x 0.1.0
HashableImage(numpy_array) ctor ~10 ms (always wrote a temp PNG to disk) <1 ms (lazy — only writes on get_filename())
read_image(path) Double-decode (EXIF check + actual decode) Single-decode (one PIL open or torchvision fast-path)
hash(img) (cached) ~17 μs (re-materialized full pixel bytes every call) ~0.4 μs (xxhash content fingerprint cached on the instance)
img.numpy() Returned the internal buffer (footgun) Returns an independent copy — numpy_view() for explicit zero-copy
HashableDict / HashableList Mutable, with stale-hash hazards Immutable; deep-copy mutable leaves; read-side leaf protection

Plus 12+ correctness bugs fixed (ImageCrop dims, Points axes, PIL-mode handling, mask2bbox, bgr2rgb torch path, get_filename fidelity, …) across 8 rounds of adversarial review.

Metadata

Release files for pixelcache 0.1.4

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for pixelcache 0.1.4
File Size Uploaded Provenance
pixelcache-0.1.4.tar.gz 6.2 MB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for pixelcache 0.1.4
File Interpreter ABI Platform
pixelcache-0.1.4-py3-none-any.whl Python 3 none any Details

Total release size: 12.3 MB

Release files / pixelcache-0.1.4.tar.gz

Download URL pixelcache-0.1.4.tar.gz
Size 6.2 MB
Tags Source
SHA-256 checksum
How to use checksums
b9ecdd34f7f5bd9bab99c1ea76dc090c7983a6e58d9d4c8aed298df8b2d8c279
BLAKE2b-256 checksum
How to use checksums
f669369b0fb84543dd20d7495ce170e1d123f85bc2be64c00543fda887dc4f7a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.14.6

Release files / pixelcache-0.1.4-py3-none-any.whl

Download URL pixelcache-0.1.4-py3-none-any.whl
Size 6.2 MB
Tags Python 3
SHA-256 checksum
How to use checksums
2cec99eb975df020987cddcdce87f8378660caac9d257782fd55377a76671ffe
BLAKE2b-256 checksum
How to use checksums
cd0a394c869dd1f22224e286a6c2b627edb0b252102a360a2c98d9ff18912645
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.14.6

Security for pixelcache 0.1.4

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page