feat(renderer): correct dithering, background models, colour treatments, compare view and presets - #8
Closed
devin-ai-integration[bot] wants to merge 9 commits into
Closed
Conversation
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>
Author
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
Contributor
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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>
Owner
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.tscalledapplyDither(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 vsnonewas ~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:source * (1 - strength) + dithered * strength, reintroducing continuous tone and washing the pattern out.glyphForToneapplied its owntone^0.72warp after dithering, re-quantising off the dither lattice so adjacent dither levels merged onto one glyph.sin()white-noise hash.Dithering fixes
toneLevels: 0(Auto) picksglyphCount - 1fornoneandmin(glyphCount - 1, 8)when dithering; a Tone levels control exposes 2–16 (&levels=).strengthscales 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.renderer/tone.ts) and glyph selection is linear. Braille, binary, texture and edge-direction cutoffs were raw-tone constants, so they now map throughtoneCurveValue()to keep those glyph sets looking as they did.(rank + 0.5) / size² - 0.5threshold, one quantisation step wide, no attenuation or phase hash. Diffusion keeps serpentine scanning with mirrored taps and an unclamped error buffer.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:
Flat mid-tone patch, levels 2 — each mode's spatial signature, previously near-identical:
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,
0°left→right,90°top→bottom,45°↘,315°↗, emitted asangle + 90for 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_METADATAgives 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
0.6, which stays the default) with presets for current/square/tall cells.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