Skip to content

feat(renderer): correct dithering, background models, colour treatments, compare view and presets - #8

Closed
devin-ai-integration[bot] wants to merge 9 commits into
mainfrom
devin/1786150822-dither-correctness
Closed

feat(renderer): correct dithering, background models, colour treatments, compare view and presets#8
devin-ai-integration[bot] wants to merge 9 commits into
mainfrom
devin/1786150822-dither-correctness

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Aug 8, 2026

Copy link
Copy Markdown

Summary

Halftone's eight dithering modes were nearly indistinguishable in output. The cause was not the diffusion weights — it was that the dither's amplitude was tied to the glyph ramp length, and the dithered result was then blended back toward the undithered tone. This PR fixes that, then builds the rest of the requested renderer work on top: background models with export parity, colour treatments, a grouped dither picker with a Dither Compare view, aspect/fit/grain controls, and reworked presets.

Root cause

art.ts called applyDither(field, algorithm, strength, glyphs.length - 1) — 9 levels for ASCII, 67 for Unicode Fine. The ordered perturbation is ±0.5/levels, so at 67 levels almost no cell could cross a quantisation boundary. Measured on a 96×48 linear gradient at identical settings: glyph difference vs none was ~3% at 67 levels and ~27% at 9; Bayer 4×4 vs 8×8 differed in 1.3% of glyphs. Four further bugs compounded it:

  • Every mode ended with source * (1 - strength) + dithered * strength, reintroducing continuous tone and washing the pattern out.
  • glyphForTone applied its own tone^0.72 warp after dithering, re-quantising off the dither lattice so adjacent dither levels merged onto one glyph.
  • Ordered dithering was attenuated up to 38% by a resolution fudge and phase-shifted by a per-size hash, destroying the stable grid signature.
  • Diffusion clamped its own accumulated error buffer, so error never reached neighbours, and scaled propagated error non-conservatively.
  • "Blue noise" was a sin() white-noise hash.

Dithering fixes

  • Levels decoupled from the glyph ramp. toneLevels: 0 (Auto) picks glyphCount - 1 for none and min(glyphCount - 1, 8) when dithering; a Tone levels control exposes 2–16 (&levels=).
  • Strength no longer blends. All modes emit a strictly quantised value; strength scales the threshold offset (ordered/noise) and the distributed error (diffusion). 0% is exactly plain quantisation, 100% is the full algorithm, and intermediate values ramp monotonically: bayer4 vs none = 0% / 5.5% / 14.1% / 18.4% / 25.0% at 0, 0.25, 0.5, 0.75, 1.
  • Single quantisation. The perceptual curve moved ahead of quantisation (renderer/tone.ts) and glyph selection is linear. Braille, binary, texture and edge-direction cutoffs were raw-tone constants, so they now map through toneCurveValue() to keep those glyph sets looking as they did.
  • Ordered uses the textbook (rank + 0.5) / size² - 0.5 threshold, one quantisation step wide, no attenuation or phase hash. Diffusion keeps serpentine scanning with mirrored taps and an unclamped error buffer.
  • Blue noise is a real deterministic Ulichney void-and-cluster rank tile (renderer/blueNoise.ts). Worth noting: the first replacement (progressive farthest-point ranks) was worse — on a flat mid-tone patch it produced a perfect checkerboard, structurally identical to Bayer 2×2. The shipped version uses toroidal Gaussian energy with incremental add/subtract updates on toggle instead of refiltering the tile each iteration, which took first-use generation from 229 ms to ~15 ms for the cached 32×32 tile.

Differing glyph cells on a 64×32 gradient, strength 1, levels 4:

pair before after
bayer4 vs none ~3% (67-level ramp) 25.0%
bayer2 vs bayer4 ~1% 6.3%
floyd-steinberg vs none 24.3%

Flat mid-tone patch, levels 2 — each mode's spatial signature, previously near-identical:

bayer2      .#.#.#.#.#.#.#.#  |  #.#.#.#.#.#.#.#.
bayer4      .#.#.#.#.#.#.#.#  |  #.###.###.###.##
bayer8      .#.#.#.#.#.#.#.#  |  #.#.#.###.#.#.##
atkinson    ##.###.###.###.#  |  ..##..##..##..##
floyd       #.##.#.##.#.##.#  |  .##.#.##.#.##.#.
sierra-lite #.##.#.##.#.##.#  |  #.#.#.##.#.##.#.

Backgrounds (src/lib/background.ts)

Image mode gains Solid / Linear gradient / Radial gradient / Transparent as part of the artwork model, not CSS behind the preview. One module owns the geometry and exposes it in the three forms consumers need — canvas paint, SVG defs + rect, CSS background value — so preview, PNG, SVG and HTML cannot drift. The artwork keeps a resolved representative colour so ANSI/plain text is byte-identical to before for every background type. Transparent genuinely preserves alpha (verified in-browser: canvas pixel [0,0,0,0]), omits the SVG rect and leaves HTML background unset.

Angle convention, documented in the module and consistent across all three backends: clockwise in canvas coordinates, left→right, 90° top→bottom, 45° ↘, 315° ↗, emitted as angle + 90 for CSS.

Colour treatments (src/lib/colourTreatment.ts)

Optional glyph colour treatment: source (default, unchanged), monochrome, palette, duotone (shadow/highlight by luminance), gradient map (2–4 luminance stops). Palette reuses the existing image-palette quantiser rather than a parallel one. Colours are resolved once per render into pre-parsed RGB — no hex parsing per cell.

Dither picker + Compare view

DITHER_METADATA gives every algorithm a group (Ordered / Error diffusion / Noise) and a one-line description, typed so adding an algorithm without both is a compile error. The picker uses <optgroup>s and shows the selected mode's description beneath it, without growing the panel. Dither Compare is a compact overlay with four selectable slots (including None) rendering the current image and settings through the same artwork path with only the algorithm varied — nothing computes while it's closed.

Renderer options

  • Character aspect factor made explicit in the row derivation (the historical effective ratio was 0.6, which stays the default) with presets for current/square/tall cells.
  • Fit mode Contain / Cover / Stretch with nine crop positions for Cover; Stretch preserves today's framing.
  • Grain: deterministic pre-dither noise with amount and integer seed, reusing the blue-noise PRNG. Amount 0 is a bit-identical no-op.
  • Sharpening: inspected and left alone — the existing unsharp result is already clamped and already runs in tone space before quantisation, so neither the halo nor the dither-fighting failure mode had code evidence behind it. Reported rather than restructured.

Presets

Seven presets, each setting every relevant option explicitly (typed so a preset cannot omit one) so applying one lands in a known state: Clean ASCII, Classic Mac (Atkinson, mono, 4 levels), Newspaper (Bayer 4×4, warm paper, 4 levels), Terminal (Floyd–Steinberg, braille, green), Cyberpunk (blue noise, gradient map, diagonal gradient background, grain), Game Boy (Bayer 2×2, blocks, 4-tone palette), Blueprint (Sierra Lite, edge mode, blue duotone, radial background).

Tests

12 files, 55 tests. Dithering: determinism, dimensions, 1×1 edges, range, strength 0 ≡ plain quantisation, lattice membership, pairwise distinctness across the three Bayer sizes and three diffusion kernels, and blue-noise properties (full rank permutation, distinct from all three Bayer modes on a flat patch, lower Gaussian clumping energy than deterministic white noise). Plus tone-curve, background geometry/export-omission, treatment mapping, grain no-op/determinism, fit-mode region mapping, preset validity, and a regression pinning the default colour path so the colour-count control can't silently become a no-op again. Property assertions, not array snapshots.

Performance

Benchmark: 240 columns, Unicode Fine, blue noise, hybrid mode, 16-colour palette, gradient map, separation, grain, sharpening. Warm median 218 ms → 196 ms. The treatment resolver over 24k calls: gradient map 49.7 → 19.1 ms, duotone 16.6 → 7.7 ms, palette 27.2 → 22.0 ms, from hoisting all colour parsing out of the per-cell path. Blue noise generates once and is cached off the render path.

Follow-ups (separate work)

Gradient midpoint/balance stop, per-stage render timings and React Profiler instrumentation, and persisting the uploaded image so a shareable URL round-trips without re-uploading.

Link to Devin session: https://app.devin.ai/sessions/df89828bda4c40ed80f15430cb2b1f10
Requested by: @rowkav09

Decouple quantisation levels from the glyph ramp, stop blending the
undithered tone back into the result, move the perceptual tone curve
ahead of quantisation, and replace the sin()-hash blue noise with a
deterministic void-and-cluster rank tile. Expose tone levels as a
renderer control.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@rowkav09 rowkav09 self-assigned this Aug 8, 2026
@devin-ai-integration

Copy link
Copy Markdown
Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@vercel

vercel Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
halftone Ready Ready Preview Aug 8, 2026 1:40am

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@devin-ai-integration devin-ai-integration Bot changed the title fix(renderer): make dithering algorithms technically correct and visibly distinct feat(renderer): correct dithering, background models, colour treatments, compare view and presets Aug 8, 2026
@rowkav09

Copy link
Copy Markdown
Owner

Superseded by main via PR #9/#10 — see docs/branch-audit.md. Conflicting with main, not safe to merge as-is.

@rowkav09 rowkav09 closed this Aug 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant