fix(steami_screen): Improve text scaling using pixel-by-pixel framebuf.#398
fix(steami_screen): Improve text scaling using pixel-by-pixel framebuf.#398
Conversation
c0e596f to
28c7d80
Compare
There was a problem hiding this comment.
Pull request overview
Improves scaled text rendering for the SSD1327 backend in steami_screen by implementing backend-specific pixel-accurate scaling, and updates documentation to reflect the improved behavior (closes #369).
Changes:
- Added
SSD1327Display.draw_scaled_text()that scales glyphs by rendering to a temporary mono framebuffer and expanding lit pixels intoscale x scaleblocks. - Updated SSD1327 wrapper internals (quotes/
hasattrcalls) to support the new scaling path. - Updated
lib/steami_screen/README.mdnote to describe true pixel-scale zoom behavior on SSD1327.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| lib/steami_screen/steami_screen/ssd1327.py | Adds backend-specific scaled-text rendering via per-pixel expansion from a temporary mono framebuffer. |
| lib/steami_screen/README.md | Updates user-facing note about text scaling behavior on SSD1327. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| import framebuf | ||
|
|
||
| from steami_screen.colors import rgb_to_gray4 |
There was a problem hiding this comment.
Importing framebuf at module import time can make steami_screen fail to import in CPython environments where framebuf isn’t available (unless callers pre-inject a stub). Consider moving this import inside draw_scaled_text() (or guarding it with a try/except) so only the scaled-text path requires framebuf.
| gray = rgb_to_gray4(color) | ||
|
|
||
| char_buf = bytearray(8) | ||
| fb = framebuf.FrameBuffer(char_buf, 8, 8, framebuf.MONO_HLSB) |
There was a problem hiding this comment.
draw_scaled_text() allocates a new bytearray and FrameBuffer on every call. On MicroPython this can add GC pressure and jitter during UI updates. Consider caching the buffer/framebuf on self (lazy-init once) and reusing it across calls.
| for py in range(8): | ||
| for px in range(8): | ||
| if fb.pixel(px, py): | ||
| if hasattr(self._raw, "fill_rect"): | ||
| self._raw.fill_rect( | ||
| cx + px * scale, |
There was a problem hiding this comment.
The inner pixel loops repeatedly call hasattr(self._raw, "fill_rect") for every lit pixel. Hoist this check once (e.g., pick a local fill_rect function) before the loops to reduce overhead in tight rendering code.
| reads each lit pixel, and draws a scale x scale filled rectangle. | ||
| This produces a true pixel-scale zoom instead of a bold offset effect. | ||
| """ | ||
|
|
||
| gray = rgb_to_gray4(color) | ||
|
|
||
| char_buf = bytearray(8) | ||
| fb = framebuf.FrameBuffer(char_buf, 8, 8, framebuf.MONO_HLSB) | ||
|
|
||
| cx = x | ||
| for char in text: | ||
| fb.fill(0) | ||
| fb.text(char, 0, 0, 1) | ||
| for py in range(8): | ||
| for px in range(8): | ||
| if fb.pixel(px, py): | ||
| if hasattr(self._raw, "fill_rect"): | ||
| self._raw.fill_rect( | ||
| cx + px * scale, | ||
| y + py * scale, | ||
| scale, | ||
| scale, | ||
| gray, | ||
| ) | ||
| else: | ||
| self._raw.framebuf.fill_rect( | ||
| cx + px * scale, | ||
| y + py * scale, | ||
| scale, | ||
| scale, | ||
| gray, | ||
| ) |
There was a problem hiding this comment.
This implementation can issue up to 64 fill_rect() calls per character (one per set pixel), which is typically slow on MicroPython displays. A common optimization is to coalesce consecutive lit pixels into longer horizontal runs (one fill_rect per run) or build a scaled glyph buffer and blit it, to drastically cut per-character draw calls.
| reads each lit pixel, and draws a scale x scale filled rectangle. | |
| This produces a true pixel-scale zoom instead of a bold offset effect. | |
| """ | |
| gray = rgb_to_gray4(color) | |
| char_buf = bytearray(8) | |
| fb = framebuf.FrameBuffer(char_buf, 8, 8, framebuf.MONO_HLSB) | |
| cx = x | |
| for char in text: | |
| fb.fill(0) | |
| fb.text(char, 0, 0, 1) | |
| for py in range(8): | |
| for px in range(8): | |
| if fb.pixel(px, py): | |
| if hasattr(self._raw, "fill_rect"): | |
| self._raw.fill_rect( | |
| cx + px * scale, | |
| y + py * scale, | |
| scale, | |
| scale, | |
| gray, | |
| ) | |
| else: | |
| self._raw.framebuf.fill_rect( | |
| cx + px * scale, | |
| y + py * scale, | |
| scale, | |
| scale, | |
| gray, | |
| ) | |
| reads each lit pixel, and draws scaled filled rectangles per | |
| horizontal run of lit pixels. This produces a true pixel-scale zoom | |
| instead of a bold offset effect while reducing display draw calls. | |
| """ | |
| gray = rgb_to_gray4(color) | |
| char_buf = bytearray(8) | |
| fb = framebuf.FrameBuffer(char_buf, 8, 8, framebuf.MONO_HLSB) | |
| if hasattr(self._raw, "fill_rect"): | |
| fill_rect = self._raw.fill_rect | |
| else: | |
| fill_rect = self._raw.framebuf.fill_rect | |
| cx = x | |
| for char in text: | |
| fb.fill(0) | |
| fb.text(char, 0, 0, 1) | |
| for py in range(8): | |
| px = 0 | |
| while px < 8: | |
| while px < 8 and not fb.pixel(px, py): | |
| px += 1 | |
| if px >= 8: | |
| break | |
| run_start = px | |
| while px < 8 and fb.pixel(px, py): | |
| px += 1 | |
| fill_rect( | |
| cx + run_start * scale, | |
| y + py * scale, | |
| (px - run_start) * scale, | |
| scale, | |
| gray, | |
| ) |
| Cardinal positions: `"N"`, `"NE"`, `"E"`, `"SE"`, `"S"`, `"SW"`, `"W"`, `"NW"`, `"CENTER"`. | ||
|
|
||
| Note: `scale=2` produces a bold effect (text drawn with 1px offset), not a true pixel-scale zoom. Backends can provide `draw_scaled_text()` for true scaling. | ||
| Note: `scale=2` produces a true pixel-scale zoom on SSD1327 displays via pixel-by-pixel |
There was a problem hiding this comment.
The note refers specifically to scale=2, but the SSD1327 backend implementation supports arbitrary scale > 1. Consider rewording to scale>1 to avoid implying other scale factors won’t render as true pixel scaling.
| Note: `scale=2` produces a true pixel-scale zoom on SSD1327 displays via pixel-by-pixel | |
| Note: `scale>1` produces a true pixel-scale zoom on SSD1327 displays via pixel-by-pixel |
Address Copilot review comments on #398: 1. Defer `import framebuf` into draw_scaled_text() so steami_screen remains importable in CPython environments (tests, stubs, IDE). 2. Resolve fill_rect dispatch once at __init__ (self._fill_rect_raw) instead of calling hasattr() on every lit pixel in the inner loop. Also simplifies the public fill_rect() method. 3. Cache the dispatch as a local `blit` variable inside draw_scaled_text to avoid attribute lookups in the hot pixel loop. 4. Fix README note: scaling works for any scale > 1, not just scale=2. Mention the bold offset fallback for backends without draw_scaled_text.
Address Copilot review comments on #398: 1. Defer `import framebuf` into draw_scaled_text() so steami_screen remains importable in CPython environments (tests, stubs, IDE). 2. Resolve fill_rect dispatch once at __init__ (self._fill_rect_raw) instead of calling hasattr() on every lit pixel in the inner loop. Also simplifies the public fill_rect() method. 3. Cache the dispatch as a local `blit` variable inside draw_scaled_text to avoid attribute lookups in the hot pixel loop. 4. Fix README note: scaling works for any scale > 1, not just scale=2. Mention the bold offset fallback for backends without draw_scaled_text.
88ff2bc to
716acdb
Compare
nedseb
left a comment
There was a problem hiding this comment.
Bonne implémentation Matteo. L'approche framebuf 8x8 par caractère est la bonne — c'est la façon standard de faire du scaling sans accès direct au bitmap de la font MicroPython.
J'ai poussé un commit de review (716acdb) qui adresse les 5 commentaires Copilot :
1. import framebuf déféré — déplacé du top-level dans draw_scaled_text() pour que steami_screen reste importable en CPython (tests, stubs IDE, screenshots Pillow).
2. Dispatch fill_rect résolu une seule fois — hasattr(self._raw, 'fill_rect') était appelé à chaque pixel allumé (jusqu'à 64× par caractère × nombre de caractères). Maintenant résolu une fois au __init__ via self._fill_rect_raw, et caché en local blit dans la boucle.
3. Simplification de fill_rect() — la méthode publique utilise directement self._fill_rect_raw, supprimant la duplication du dispatch.
4. Note README — corrigée : le scaling fonctionne pour tout scale > 1, pas seulement scale=2. Mention du fallback bold offset pour les backends sans draw_scaled_text().
Branche rebasée sur main. Prêt à merger.
## [0.20.2](v0.20.1...v0.20.2) (2026-04-16) ### Bug Fixes * **steami_screen:** Improve text scaling using pixel-by-pixel framebuf. ([#398](#398)) ([16386ea](16386ea))
|
🎉 This PR is included in version 0.20.2 🎉 The release is available on:
Your semantic-release bot 📦🚀 |
Summary
Improve
_draw_scaled_text()to produce a true pixel-scale zoom instead of a bold offset effect. Closes #369Changes
draw_scaled_text()directly inSSD1327Display(steami_screen/ssd1327.py)MONO_HLSBframebuf, reads each lit pixel withframebuf.pixel(), and draws ascale x scalefilled rectangle for each lit pixel_draw_scaled_text()indevice.pyalready checkshasattr(self._d, 'draw_scaled_text')and delegates to the backend — no changes needed indevice.pyChecklist
ruff checkpassespython -m pytest tests/ -k mock -vpasses (no mock test broken)<scope>: <Description.>format