Skip to main content

with_simple_color

Create RGB halftones using only a specified palette. Rather than minimizing the difference between individual output and input pixels, this project minimizes the difference between the Gaussian-blurred output and the input. It is intended for images viewed through a spatial averaging process.

The returned and saved result is always the unblurred palette image. Gaussian blur is used internally to evaluate the reconstruction error, not as an output effect. The CLI does not save or display a blurred reconstruction.

Installation

Python 3.10 or newer is required. From the project directory:

uv sync
uv run with-simple-color --help

Alternatively, install with python -m pip install . and use python -m with_simple_color. NumPy and SciPy provide numerical operations, Numba accelerates the sequential optimization and initialization, Pillow handles image files, and Matplotlib provides optional comparison plots. The first run includes Numba compilation overhead; compiled kernels are cached.

Quick start

Run the built-in 64-by-64 demo without opening a window:

uv run with-simple-color --sigma 2 --output halftone.png

Reproduce the original larger synthetic example with a fixed seed:

uv run with-simple-color --size 256 --sigma 10 --max-iter 15 --seed 42 --output halftone.png

With the default eight-color CLI palette, this example reduces total squared reconstruction error from about 62.3408 after initialization to 1.36979 after refinement, compared with 12555.8 for nearest-color quantization alone.

Process an image using the eight RGB cube corners:

uv run with-simple-color --input photo.png --palette cube --sigma 2 --max-iter 30 --output halftone.png

Available built-in CLI palettes are cube (the default, eight colors), rgbk (red, green, blue, black), cmyk (white, black, yellow, magenta, cyan at 0.75 intensity), and bw (black and white). A JSON file can specify an arbitrary nonempty list of normalized RGB triples, including fractional colors:

[
  [0.10, 0.15, 0.20],
  [0.85, 0.30, 0.20],
  [0.95, 0.90, 0.80]
]
uv run with-simple-color --input photo.png --palette palette.json --output halftone.npy

Use --show for an interactive comparison, or --comparison comparison.png to save a two-panel comparison of the original and unblurred palette image. Neither option displays a blurred image. --seed 0 is the CLI default. --boundary, --init, --switch-fraction, and --tolerance expose optimization settings; --quiet suppresses progress. --size changes the synthetic demo size. All output paths must be distinct and must not overwrite an input image or palette.

Example outputs

Unblurred palette halftones of the same image, rendered with the built-in palettes:

Original cube rgbk cmyk bw

Image and color conventions

  • The Python API and .npy inputs require finite arrays of shape (height, width, 3) with values in [0, 1]. Integer 0/1 arrays are valid; divide 8-bit image arrays by 255 before calling the API.
  • Ordinary image files are loaded as 8-bit RGB, with EXIF orientation applied. Grayscale is expanded to RGB; transparency is composited over white.
  • API results and .npy outputs are float64, preserving the supplied palette values after conversion to float64. Use .npy for exact fractional colors.
  • PNG output rounds to 8-bit RGB. Fractional palette entries not representable as multiples of 1/255 cannot be preserved exactly in PNG. Reported loss refers to the in-memory result, before PNG rounding.
  • The objective operates in the supplied RGB coordinates. Image loading does not perform ICC color management or linear-light conversion. For a physical light-averaging model, convert both the image and palette to linear-light RGB before using the API, and apply the appropriate display conversion afterward.

Python API

import numpy as np

from with_simple_color import (
    compute_loss,
    quantize,
    validate_B_general,
)

image = np.full((32, 32, 3), 0.4)
palette = np.array([[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]])
quantized, losses = quantize(
    image,
    sigma=2.0,
    palette=palette,
    max_iter=30,
    seed=42,
    verbose=False,
    return_history=True,
)
np.save("halftone.npy", quantized)
assert validate_B_general(quantized, palette)[0]
assert all(after <= before for before, after in zip(losses, losses[1:]))
print(compute_loss(quantized, image, sigma=2.0))

Without return_history=True, quantize returns only the unblurred image. The API's default palette remains red, green, blue, and black, unlike the CLI demo's eight-color palette. Existing helper names remain available from with_simple_color.main and the package root. Use image= and quantized= for named image arguments; the old positional argument order is unchanged.

Important options:

Argument Meaning
sigma Finite positive Gaussian standard deviation in pixels.
init="auto" Select the lower actual reconstruction loss of nearest-color and serpentine Floyd-Steinberg initializations.
init="nearest" Start with the closest palette color at each pixel.
init="error_diffusion" Use serpentine Floyd-Steinberg diffusion as a starting point, not as the optimizer.
init="probabilistic" Sample distance-weighted palette colors. This is only a heuristic, not an unbiased color mixture.
max_iter=30 Maximum sequential sweeps; zero returns the initialization unchanged.
switch_fraction=1.0 Cap accepted changes per sweep at ceil(switch_fraction * height * width), in (0, 1]. Smaller values may need more sweeps.
boundary_mode="reflect" reflect, mirror, nearest, wrap, or constant. Constant padding is zero; SciPy's grid aliases are also accepted by the API.
seed=None Seed for random coordinate order and probabilistic initialization; set an integer for repeatability.
tolerance=1e-12 Minimum absolute decrease in total squared error needed to accept one recoloring.
return_history=False Return (quantized, losses) when true. History starts with the initialization loss.

refine_general(quantized, image, sigma, palette, ...) improves an existing palette image. Inputs are not mutated. plot_comparison(..., show=False) returns a two-panel Matplotlib figure containing only the original and unblurred palette image, suitable for saving without an interactive window. Plotting is imported lazily; the internal reconstruction is not clipped before computing the objective.

Objective and algorithm

Let image be the target and quantized[p] be one palette color at every pixel. Let H be the spatial Gaussian blur, applied independently to each RGB channel. The objective is:

minimize    L(quantized) = ||H(quantized) - image||_F^2
subject to  quantized[p] belongs to palette for every pixel p

The target is not blurred. H uses SciPy's discrete, normalized Gaussian with truncate=4.0 and radius int(4 * sigma + 0.5), including the selected boundary extension. The channel axis is never blurred.

For a recoloring at pixel p, define delta = new_color - old_color, the current residual residual = H(quantized) - image, and column_p = H(unit_impulse_at_p). Its exact change in loss is:

delta_loss = 2 * dot(delta, sum_over_pixels(column_p * residual))
             + dot(delta, delta) * sum_over_pixels(column_p ** 2)

The optimizer evaluates all palette colors for one pixel, accepts the best strictly improving choice, and immediately updates the residual before considering another pixel. This is discrete coordinate descent with exact single-pixel moves. It avoids treating overlapping pixel changes as independent.

Boundary-aware, sparse one-dimensional operator columns are constructed and combined separably. Their squared norms depend on pixel position near edges; they are not replaced by an infinite-domain kernel norm. This also avoids incorrectly assuming that every boundary extension makes H self-adjoint. No dense two-dimensional pixel-to-pixel blur matrix is constructed.

Each completed sweep recomputes the residual using SciPy. A sweep that fails to reduce the measured objective is rolled back to protect against numerical drift. Returned loss history therefore never increases. A full sweep with no accepted changes certifies only that no single-pixel move improves the objective by more than tolerance.

Limitations and cost

This is a discrete, nonconvex optimization problem. The implementation does not promise the global minimum. It uses recolorings, not multi-pixel swaps; escaping a one-pixel local optimum can require coordinated changes. Reaching max_iter does not certify convergence. Different seeds and initializations can yield different local solutions.

For normalized boundary modes, every reconstructed color is a convex combination of palette colors. A target outside that convex hull cannot be reproduced exactly. In particular, red/green/blue/black cannot represent white by spatial mixing. The eight RGB cube corners span the full unit RGB cube, but spatial coupling and discrete pixel counts still prevent arbitrary exact reconstruction. Zero padding in constant mode additionally mixes black into border pixels.

With pixel_count = height * width, color_count palette colors, and Gaussian radius radius, a sweep costs approximately O(pixel_count * ((2 * radius + 1)**2 + color_count)). Large blur radii and high-resolution images can be expensive; begin with a small image and a modest sweep limit. Sparse operator construction uses O((height + width) * (2 * radius + 1)) temporary storage, in addition to image buffers and the palette. There is no height * width * color_count cost volume.

Development checks

uv run python -m compileall -q with_simple_color
uv run python -m with_simple_color --size 16 --max-iter 3 --output smoke.npy --comparison smoke.png
uv build

The numerical implementation can be independently checked by constructing each Gaussian impulse with SciPy and comparing the predicted delta_loss with two full objective evaluations. For tiny images, enumerate every recoloring after convergence to verify one-pixel optimality. Check all five boundary modes, singleton image dimensions, kernels larger than the image, fractional palettes, and nonincreasing loss histories rather than judging only a displayed picture.

License

MIT. See LICENSE.

Metadata

Release files for with-simple-color 0.1.1

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

Source distribution (sdist)

Source distribution for with-simple-color 0.1.1
File Size Uploaded
with_simple_color-0.1.1.tar.gz 16.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for with-simple-color 0.1.1
File Interpreter ABI Platform
with_simple_color-0.1.1-py3-none-any.whl Python 3 none any Details

Total release size: 32.0 kB

Release files / with_simple_color-0.1.1.tar.gz

Download URL with_simple_color-0.1.1.tar.gz
Size 16.7 kB
Tags Source
SHA-256 checksum
How to use checksums
d0f552998af1b1a1c2ad6deb294d3ecf8784455ea01b91b1792863fface9d6e2
BLAKE2b-256 checksum
How to use checksums
49cd1194953ecf40ee0df3c064991d0106e4c0bd9430d6feb7b7bc855eb11b1a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via poetry/2.4.1 CPython/3.14.6 Windows/11

Release files / with_simple_color-0.1.1-py3-none-any.whl

Download URL with_simple_color-0.1.1-py3-none-any.whl
Size 15.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
38592933e51d43f15eb502d53071528739e25893dd4ae7c3e13c83d7f5321d27
BLAKE2b-256 checksum
How to use checksums
8881d0240deb01fa404c0a31c419a45557db1cd861cba92e8c16f5ffc0a195b9
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via poetry/2.4.1 CPython/3.14.6 Windows/11

Release history Release notifications | RSS feed

This release

0.1.1 This release

2 release files

0.1.0

2 release files

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