CarlBeek/dither

★ 0Forks 0JavaScriptGitHub ↗Compare

README

Dithered PNG

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 dev

Open 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.

Repository layout

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.

Controls

  • 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 #161513 and 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 #RRGGBB or #RRGGBBAA; for example, #16151380 is approximately 50% opaque and #f3f1eb00 is 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.

Rendering

  1. 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).
  2. Fit the full image or use the selected rectangular crop. Resize grayscale, using area averaging for reduction and bilinear interpolation for enlargement.
  3. Apply smooth, monotone shadow and highlight adjustments that preserve black and white, followed by midtone gamma, brightness, and contrast.
  4. 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.
  5. 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.
  6. Encode PNG bit depth 1, color type 3, with packed binary indices and your two-entry RGB palette. An optional tRNS chunk 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.

Pattern generation

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.

Verification

npm ci
npm run setup
npm test

Tests 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.

Cloudflare hosting

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 preview

After signing in to your Cloudflare account, publish with:

npx wrangler login
npm test
npm run deploy

Wrangler 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.

Automatic deployment from GitHub

The workflow runs tests on pull requests and pushes to main. To enable Cloudflare deployment after those tests pass:

  1. Add repository secrets CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN. Use a Cloudflare API token scoped to your account with Edit Cloudflare Workers permissions; keep its value in GitHub's secret storage.
  2. Set the repository variable CLOUDFLARE_DEPLOY to true.
  3. Push to main or 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.

Other static hosts and subdirectories

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.

License and origin

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.

Contributors

CarlBeek

Issues