Skip to content

feat: image compression tooling - #4

Open
humanapp wants to merge 2 commits into
mainfrom
humanapp/img-tools
Open

humanapp wants to merge 2 commits into
mainfrom
humanapp/img-tools

Conversation

@humanapp

@humanapp humanapp commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

Adds img-tools, a build-time tool that compresses an app's bmp image literals into one packed buffer with a keyed lookup, and the device runtime that decodes them (img.ts).

Separately, this PR includes a change that lets the localization tooling build each language with its own font. This is a change I meant to include in a separate PR, but I forgot I'd already committed it. It is benign; a no-op for MicroCode.

This PR introduces //% packable, a new comment attribute for bmp image literals. It is not a pxt attribute: pxt parses it like any attribute it doesn't recognize and ignores it. It is read by ui-core's new img-gen build tool, which compresses every marked image into one packed buffer that the app looks up by key at runtime.

//% packable
//% whenUsed
export const thermometer = bmp`...`
  • //% packable makes the image eligible for packing. The image is identified by its const name.
  • //% packable="name" sets that name explicitly, so a library can rename the const without breaking the apps that reference it.
  • It is meant to be paired with pxt's own //% whenUsed. That is what lets the compiler drop the original literal once it is packed; without it, the generator refuses to pack the image, since the pixels would ship twice.
  • It marks eligibility, not a guarantee. The generator still leaves an image out if code references the const directly, and says why.

On MicroCode's 115 images, pixel data goes from 18,328 B to 9,922 B, and each image's const initializer overhead disappears with it.

Using it

The app maps its lookup keys to images in img.keys.json, runs img-gen to generate img.g.ts, and retrieves images by key:

const img = _img.get(16)          // numeric key, e.g. a tile id
const del = _img.get("delete")    // string key

Setup, configuration and every diagnostic are documented in img-tools/README.md.

How it works

  • Record format. A record is an F4 image header followed by a payload. The header's padding field, which pxt writes as 0 and never reads, carries the method: 0x0000 is uncompressed, 0xC0nn is compressed method nn. Method 1, nibble RLE, is the only one defined. Because uncompressed is 0x0000, any ordinary bmp literal is already a valid record.
  • Pack. Records are laid end to end in one hex buffer with a u16 offset table. Identical images share a record.
  • Runtime. bitmaps.ofCompressed(buf, offset) decodes one record, returning null if it can't read it. ui.ImagePack decodes on first access and caches for the lifetime of the program, with no eviction, falling back to ui.MISSING for an unknown key or unreadable record.
  • Trade-off. Packing trades flash for heap: an unpacked literal is drawn from flash with no heap cost, while a packed image costs its decoded size once drawn. Each run reports the worst case ("RAM if all decoded").

Generator behavior

  • Discovers //% packable images across the app and its pxt dependencies, excluding testFiles. A dependency's image is packed only if the app's key table names it. If no key table exists, one is scaffolded containing all packed image keys. A note to review it manually is written to the console output.
  • Leaves an image out, with a diagnostic, when packing would duplicate it instead of moving it: missing //% whenUsed, or the const is still referenced directly. The reference scan is namespace-aware and ignores comments and string data.
  • A key table can qualify a name ("ui.thermometer") when two packages declare the same one; a bare name resolves to the app's own image.
  • Every record is round-tripped through a reference decoder, both on its own and at its final offset in the pack, and a mismatch fails the run.
  • Compares the ui-core version resolved by npm (the encoder) with the one resolved by pxt (the decoder) and fails if they differ.
  • Writes a starter img.keys.json if none exists, listing each packable app image under its own name, and asks for a review.
  • img-gen --check scans the built output for packed images that also ship as separate buffers. It reports, and never fails.

ui-core's own icons

23 of the 24 icons in icons.ts are now //% packable, so apps can absorb them into their own pack. This has no effect on apps that don't run the generator. MISSING is deliberately left unmarked, since it is the pack's fallback.

Other changes

  • package.json: adds the ./img export, the img-gen bin and a test:img script. package-lock.json picks up the package's scoped name and bins.

Testing

  • npm run test:img: 76 node:test tests with no dependencies, covering the codec, scanner, key table, packer, emitter, dependency discovery, pin check, generator, bin and post-build check. One test checks that the records embedded in test.ts are still what the encoder produces, so the device tests can't drift from the tooling.
  • Device tests runImgTest and runImagePackTest pass on a micro:bit V2. They cover both methods, a record at a nonzero offset, rejection of unknown methods, cache identity, shared records and fallbacks.
  • Validated against MicroCode: its migrated pack is byte-identical to the prototype's apart from the method marker, every icon renders correctly on hardware, and with every packed image decoded at boot the heap still had 13,380 B free.

Release

The standard dependency chain bump is required:

  • ui-controls and microcode-v2 PR merges are blocked on ui-core tagged release to v0.0.9.
  • microcode-v2's PR merge is blocked on ui-controls tagged release to v0.0.10.

@humanapp humanapp changed the title feat: image compression tooling, plus per-language localization fonts feat: image compression tooling Sep 18, 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