Skip to content

Fix/remove grayscale parameter - #78

Merged
rbouteiller merged 19 commits into
usetrmnl:mainfrom
rbouteiller:fix/remove-grayscale-parameter
Jun 30, 2026
Merged

Fix/remove grayscale parameter#78
rbouteiller merged 19 commits into
usetrmnl:mainfrom
rbouteiller:fix/remove-grayscale-parameter

Conversation

@rbouteiller

Copy link
Copy Markdown
Collaborator

No description provided.

Use the TRMNL palette as the single source of truth for display rendering so bitmap gray levels are derived instead of stored or passed independently.
Normalize device log payloads before rendering so current firmware messages and legacy log_message entries display correctly.
Prevent oversized grayscale PNGs by encoding palette-limited output, and preserve structured device log messages instead of coercing them to object strings.
The TRMNL X (model v2) is an X-class board whose firmware allocates a 750KB receive buffer, but the model registry ships a generic ~92KB limit that falsely flags valid gray-16 screens as oversized. Override the size limit for X-class models to match firmware MAX_IMAGE_SIZE.
Collapse the weekly calendar to a 3-day view on narrow screens with an optional daysToShow override (0 = auto, 1-7 = explicit). Draw a current-time line in today's column, gray out the meeting in progress, and shade events per source calendar (grayscale so it survives e-ink quantization).
Replace generated TRMNL schema artifacts with local runtime validation so registry data stays checked without carrying stale generated files.
Share bitmap URL, image, dimension, and playlist ordering helpers so dashboard, device, recipe, playlist, and mixup previews render through the same path.
Move device callback reads and writes behind scoped helpers and shared mappers so setup, display, log, and device API routes have clearer ownership boundaries.
Share pagination and device-log entry rendering so system and device log screens keep less duplicated table behavior.
Drop unused UI, proxy, logger, recipe, and TRMNL helper code so the remaining modules expose only paths that are still used.
@ghcpuman902

Copy link
Copy Markdown
Collaborator

Coordination note: planning to land this first, then rebase #80 (bitmap fonts + dithering) on top. See #80 for context and the rendering alignment proposal.

@ghcpuman902

Copy link
Copy Markdown
Collaborator

Palette / quantization — current model + way forward

Cross-ref: scope split and #80 follow-up in #80.

Problem 1 — "how many gray levels" has three independent definitions

A device's gray-level count (BW=2, gray-4=4, gray-16=16) is the same fact whether we're encoding a BMP, building an error image, or quantizing a device PNG. Today it's defined in three places that can drift independently:

  • the BMP / error-image path reads a hardcoded id → levels map (getBmpGrayLevelsForPalette(palette.id) in palette-gray-levels.ts);
  • the device-PNG path reads palette.grays inline (device-image.ts), but only on the branch where resolvePaletteColors returns null;
  • palettes.json already carries the authoritative grays per palette — the same numbers the map restates.

Adding a new grayscale palette today requires updating the map, the inline branch, and JSON independently. There is no shared getPaletteGrayLevels(palette), so palettes.json is not the single source of truth in practice.

Fix: one resolver — getPaletteGrayLevels(palette) in palette-colors.ts, reading palettes.json.grays. BMP path calls it (falling back to 2 for color palettes); device path calls it. The map and the inline read both disappear.

Problem 2 — the grayscale posterize path never runs

For bw / gray-4 / gray-16, resolvePaletteColors returns a discrete gray RGB list, so paletteGrays is always null and every grayscale device falls through to quantizeToPalette — never quantizeToGrayLevels. The posterize branch described in the comment block is unreachable for standard grayscale palettes.

The consequence is Problem 3.

Problem 3 — Floyd–Steinberg runs on the entire frame, not just images

quantizeToPalette unconditionally applies Floyd–Steinberg (FS) error diffusion: each pixel is snapped to the nearest palette entry and its quantization error is pushed into the not-yet-processed neighbors. Two properties matter here:

  • It's non-local. The value written for a pixel depends on every pixel before it in scan order, so the whole frame is one serial dependency chain — the most CPU-heavy option, paid on every device render at request time.
  • It's applied to everything. Text, SVG-style icons, flat color fills, and Tailwind dither-* blocks all go through it, not just <img> content.

For UI, that's actively wrong, not just slow. Concrete cases on current recipes:

  • responsive-example — solid blue/red/green/purple panels become speckled gray instead of four uniform tones. The output looks broken, not dithered.
  • Any text-forward recipe (simple-text, weather, bitcoin-price) — glyph and icon edges pick up error-diffusion noise from neighboring pixels, so type that is crisp in the Takumi PNG arrives soft on device.

Already correct in #78

Dropping the independent Device.grayscale knob, TRMNL X PNG palette encoding for the size budget, and the device-preview / log / registry refactor are all good foundation and unaffected by the above.

Way forward (for #78)

  1. One resolvergetPaletteGrayLevels(palette) in palette-colors.ts; remove the hardcoded map and the inline read (Problem 1).
  2. Route grayscale to a level-snap pathbw/gray-4/gray-16 hard-quantize to their palette level count; reserve quantizeToPalette for discrete color palettes (Problem 2).
  3. Default = hard quantize, no FS — the Takumi PNG stays full-color and browser-faithful; the device step nearest-snaps each pixel to the palette independently. Levels still follow palette bit depth, the same contract as Framework image-dither, but without diffusing error across the frame (Problem 3).
  4. Scope Fix/remove grayscale parameter #78 to foundation — palette model, routing fix, hard-quantize default, device profiles, preview/log. No global FS.

Why hard quantize is the right default now: BYOS targets many models, palettes, and bit depths (1-bit BW through 16-level gray and color). The shared payload across all of them is text, layout, and icons — not photos. Those must stay sharp and read identically in intent on every screen. A per-pixel nearest-level snap is local, content-independent in cost, and preserves crisp type and uniform fills. If a richer dither is ever wanted for UI, Bayer (fixed threshold-matrix lookup) and threshold quantize (per-pixel: luminance ≥ 50% → high level, < 50% → low level) are both local and cheap — same cost regardless of frame content, predictable across devices — and are the right tools, not FS.

Images are the author's call, not the renderer's. Photographic / continuous-tone content is the only place dither helps, and the author knows their content. So for images specifically, give them two routes (both in #80): render the image themselves and pre-quantize per device (conditional classes / per-device assets), or opt into a server helper (prepareForDevice, Bayer / white-noise / threshold) that dithers on their behalf. Either way it's scoped to the image, not a blanket FS pass on every recipe.

@rbouteiller

Copy link
Copy Markdown
Collaborator Author

Working on it right now, good point on the FS applied everywhere

@rbouteiller

Copy link
Copy Markdown
Collaborator Author

responsive-example — solid blue/red/green/purple panels become speckled gray instead of four uniform tones. The output looks broken, not dithered.

This screen doesn't work anymore in 1bit no dithering

@ghcpuman902

ghcpuman902 commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

responsive-example — solid blue/red/green/purple panels become speckled gray instead of four uniform tones. The output looks broken, not dithered.

This screen doesn't work anymore in 1bit no dithering

Working as intended, I think.

On 1-bit, semantic bg-* fills were never going to stay hue-distinct without simulated tone, quantization / snap-only collapses them to perceptual luminance. For the layout recipe that's expected; the fix is on the recipe side (add dither-* or screen-conditional classes), not a global dither pass.

Two things worth mentioning:

  • Gray reduction should use Lab L*, not RGB channel average. We already match color palettes in Lab but reduce gray on raw channels. Blue-on-yellow reads fine in color yet can land on the same gray step — L* is what tells us whether text and background actually separate.
  • Low-bit authoring rule: text and background on opposite sides of the threshold (light-on-dark, with a white border halo if the block is busy). Contrast is the recipe's responsibility on 1-bit, and the bitmap preview should make a failing pair obvious before deploy.

Authors keep full control either way: dither-* for simulated gray on 1-bit, or solid color that degrades to its L* on grayscale screens. Soft-enforce via docs + preview, not by re-adding frame-wide FS.

Use one pixelated preview image component across dashboards, playlists, mixups, and recipe previews so loading states and bitmap rendering stay consistent.
Detect already-complete images and prioritize above-the-fold recipe previews so cached or eager bitmaps do not stay hidden behind placeholders.
Handle Date values returned by the database mapper so device status and timestamps survive pg driver type changes.
Let the app refresh visible server data on an interval and adopt fresh device props without overwriting in-progress edits.
Use the generated friendly ID in default device names so mock and provisioned devices are easier to recognize.
Move recent and full system log lists onto a shared responsive renderer, improve mobile readability, and label the header icon controls.
@rbouteiller
rbouteiller merged commit bdbe4a3 into usetrmnl:main Jun 30, 2026
3 checks passed
rbouteiller added a commit that referenced this pull request Jun 30, 2026
…r [skip release]

Fix/remove grayscale parameter
ghcpuman902 added a commit that referenced this pull request Jul 1, 2026
Rebase onto main (#78 palette SSOT, snap quantizer) and merge the
overlapping image paths into one surface:

- prepareDeviceImage / prepareDeviceImageFromContext (default Bayer)
- Author-selectable methods: bayer, white-noise, floyd-steinberg, etc.
- Color e-ink: bayerRgbToPalette in palette-reduction
- Gray paths use Lab L* luminance (lib/render/luminance.ts)
- RecipeDeviceContext carries levels + colorPalette via buildRecipeDeviceContext
- DeviceImage component; album/wikipedia use unified prep
- responsive-example: dither-* on 1-bit, solid colors on color screens
- Remove frame-wide dither flag; keep 1× rasterize (no supersample)
- Document e-ink palette mapping and update image dither docs

Co-authored-by: Cursor <cursoragent@cursor.com>
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.

2 participants