A dedicated browser tool for converting JPG/PNG images into two-color, blue-noise dithered PNGs. Processing happens entirely on your device.
npm ci
npm run devOpen the printed URL. The server binds to its Tailscale IPv4 address when
available, otherwise 127.0.0.1. HOST and PORT override those defaults.
Node 22+ is required for development and tests. The deployed site only needs
a browser. npm start and npm run dither are aliases for the local server.
public/ deployable HTML, CSS, browser modules, and HTTP headers
scripts/serve.mjs local static server, with optional Tailscale binding
test/ image-processing and browser tests
wrangler.jsonc Cloudflare static hosting configuration
.github/workflows/ tests and optional deployment after successful tests
There is no build step or runtime dependency on another repository. The UI uses a system monospace font; commercial fonts and personal images are not bundled.
- Choose image or drag in a JPG/PNG. Images are processed in your browser.
- Width / Height set exact output dimensions, defaulting to 400 × 400. Each dimension can be 1–4096 pixels. Use source size copies the source dimensions, proportionally reduced if needed to stay within that limit.
- Crop to fill is off by default. The complete source fits within the output, preserving its proportions; any empty edges use the paper color. Enable cropping to fill the output. The crop follows the output aspect ratio. Drag the frame to move it, or its bottom-right handle to resize it. Zoom and position sliders provide keyboard and touch control.
- Color profile defaults to carlbeek.com, with bundled ink
#161513and paper#f3f1eb. Choose Black & white or set any two hex colors with the text fields and color pickers. Swap colors exchanges ink and paper, including their opacity. Changing the output palette leaves the controls readable. - Ink opacity / Paper opacity set transparency independently, from 0%
(transparent) to 100% (opaque). Hex fields accept
#RRGGBBor#RRGGBBAA; for example,#16151380is approximately 50% opaque and#f3f1eb00is fully transparent. A checkerboard shows transparent areas in the preview and is not included in the download. Selecting a preset resets both colors to opaque. - Shadows, midtones, and highlights are centered sliders from −100 to +100. Positive values brighten; negative values darken. Shadows affects the lower half of the tones and highlights the upper half; midtones shifts gamma across the image. Raise shadows to reveal detail in dark hair or other dark areas.
- Brightness, contrast, grain, and inversion adjust the image as a whole. Grain is measured in output pixels. The grayscale preview shows the adjusted image before dithering. Result at actual pixels avoids preview scaling. Reset all tone adjustments returns every slider to its neutral setting.
- Download PNG downloads the current result as
<source>-dither-<width>x<height>.png. It is a true 1-bit indexed PNG with exactly two palette entries, preserving the selected colors and alpha values.
Images, dither patterns, and downloads stay in browser memory. There are no image uploads, saved configurations, accounts, or automatic file storage.
- Decode the source as sRGB RGBA, honoring EXIF orientation. Composite alpha
against white and quantize to 8-bit grayscale with
round(0.2126 R + 0.7152 G + 0.0722 B). - Fit the full image or use the selected rectangular crop. Resize grayscale, using area averaging for reduction and bilinear interpolation for enlargement.
- Apply smooth, monotone shadow and highlight adjustments that preserve black and white, followed by midtone gamma, brightness, and contrast.
- Generate a blue-noise pattern with exactly the final output width and height. A 400 × 400 output gets 160,000 ranks; a 1200 × 800 output gets 960,000. The full output canvas determines the pattern dimensions, including when the image is fitted with padding. Patterns are never tiled or resized.
- Average tones within each grain block, quantize to a byte, and compare with
(rank + 0.5) / (output width × output height). Each block samples the pattern at its actual output coordinate. Pixels outside the fitted image stay paper colored, including when the image is inverted. - Encode PNG bit depth 1, color type 3, with packed binary indices and your
two-entry RGB palette. An optional
tRNSchunk stores each palette color's 8-bit alpha, so transparency does not increase the index bit depth. Preview and download use the same binary pixels and palette alpha.
Output opacity belongs to the two palette colors. Input-image alpha is still composited against white before conversion to grayscale; it is not exported as a separate per-pixel alpha channel. Set paper opacity to 0% for a transparent paper background, including any fit padding.
public/blue-noise.mjs runs Ulichney's void-and-cluster method in a browser worker:
seeded 10% initial coverage, periodic Gaussian filtering with sigma 1.5,
relaxation, and all three ranking phases. It works directly on the full output
rectangle. An extrema tree over small search blocks accelerates selection.
During ranking, energies only move in one direction, so the tree keeps bounds
and refreshes a block only when it could contain the next selected pixel.
Cached runner-up bounds avoid unnecessary rescans, and updates stop at unchanged
tree branches. These optimizations preserve the original ranks and tie order.
The search blocks are never used as texture tiles.
Odd dimensions, narrow images, and single pixels are supported.
The worker keeps only the current pattern in memory. Tone, color, crop position, grain, and source-image changes reuse it while output dimensions stay the same. Changing width or height generates a new pattern. Generation yields periodically, shows progress, and cancels obsolete sizes when a newer one is requested. Larger images take longer to generate. A deterministic seed derived from the dimensions keeps results consistent when revisiting a size.
No mask is fetched, downloaded, or written to disk, localStorage, or IndexedDB. Closing/reloading the page discards the in-memory pattern.
npm ci
npm run setup
npm testTests cover grayscale, tonal ranges and monotonicity, rectangular crops, aspect-preserving
fit, solid padding, pattern dimensions, unique ranks, blue-noise spectrum,
exact agreement with the original generator's ranks,
tone coverage, cancellation, PNG structure and palette transparency,
and browser interactions under a URL subdirectory over the advertised local
or Tailscale address. They compare
pixels from downloaded PNGs with the preview and verify that the browser sends
no uploads or mask-file requests, reuses patterns for adjustments, and cancels
outdated sizes. Tests verify that the website checkout, commercial fonts, and
old persistence endpoints are not needed. Browser screenshots
are written to a temporary directory printed by the test. Set CLEAN_QA_IMAGES=1
to remove the test directory afterward.
The site is configured for Cloudflare Workers Static Assets, serving only
public/. There is no server-side Worker script or database. Cloudflare lists
static asset requests as free and unlimited, with no additional storage charge:
pricing.
Domain registration, if you buy a new domain, is separate from hosting.
Preview the Cloudflare runtime locally:
npm run previewAfter signing in to your Cloudflare account, publish with:
npx wrangler login
npm test
npm run deployWrangler prints the resulting workers.dev URL. To use dither.carlbeek.com,
add that hostname under the deployed Worker's Settings → Domains & Routes →
Add → Custom Domain once the domain is managed in your Cloudflare account.
No custom domain is configured in this repo yet.
The workflow runs tests on pull requests and pushes to main. To enable
Cloudflare deployment after those tests pass:
- Add repository secrets
CLOUDFLARE_ACCOUNT_IDandCLOUDFLARE_API_TOKEN. Use a Cloudflare API token scoped to your account with Edit Cloudflare Workers permissions; keep its value in GitHub's secret storage. - Set the repository variable
CLOUDFLARE_DEPLOYtotrue. - Push to
mainor run Test and deploy to Cloudflare manually.
The deployment job uses the production environment and only publishes the
same commit that passed the tests. Without the deployment variable, the workflow
runs tests only. The repo contains no account credentials.
Any static host can serve public/ directly, including Cloudflare Pages with
public as its output directory and no build command. Asset and browser-worker
URLs are relative, so the app also works under a subdirectory. Test that locally
with BASE_PATH=/dither/ npm run dev.
MIT, copyright Carl Beekhuizen. The tool was extracted from
personal-brand at commit
990a34be41d8a9a55cab976cd461cb684fb32065. Historical source photos, exported
branding images, saved recipes, and the personal website remain in that repo.