scrollkit 0.8.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scrollkit/__init__.py +16 -0
- scrollkit/app/__init__.py +6 -0
- scrollkit/app/base.py +918 -0
- scrollkit/app/memory.py +70 -0
- scrollkit/config/__init__.py +1 -0
- scrollkit/config/settings_manager.py +215 -0
- scrollkit/config/transition_names.py +31 -0
- scrollkit/dev/__init__.py +41 -0
- scrollkit/dev/capabilities.py +374 -0
- scrollkit/dev/harness.py +383 -0
- scrollkit/dev/metrics.py +91 -0
- scrollkit/dev/performance.py +174 -0
- scrollkit/dev/validation.py +245 -0
- scrollkit/display/__init__.py +14 -0
- scrollkit/display/_graphics.py +299 -0
- scrollkit/display/_recording.py +165 -0
- scrollkit/display/_sim_backend.py +99 -0
- scrollkit/display/bitmap_text.py +408 -0
- scrollkit/display/boards.py +186 -0
- scrollkit/display/colors.py +176 -0
- scrollkit/display/content.py +604 -0
- scrollkit/display/gradient_text.py +167 -0
- scrollkit/display/interface.py +126 -0
- scrollkit/display/simulator.py +80 -0
- scrollkit/display/text_fill.py +59 -0
- scrollkit/display/text_pixels.py +250 -0
- scrollkit/display/unified.py +595 -0
- scrollkit/effects/__init__.py +28 -0
- scrollkit/effects/drip_splash.py +253 -0
- scrollkit/effects/easing.py +131 -0
- scrollkit/effects/overlay.py +92 -0
- scrollkit/effects/particles.py +355 -0
- scrollkit/effects/reveal_splash.py +132 -0
- scrollkit/effects/scrolling.py +363 -0
- scrollkit/effects/swarm_reveal.py +512 -0
- scrollkit/effects/text_render.py +25 -0
- scrollkit/effects/transitions.py +873 -0
- scrollkit/exceptions.py +55 -0
- scrollkit/network/__init__.py +1 -0
- scrollkit/network/http_client.py +505 -0
- scrollkit/network/mdns.py +44 -0
- scrollkit/network/wifi_manager.py +382 -0
- scrollkit/ota/__init__.py +13 -0
- scrollkit/ota/client.py +528 -0
- scrollkit/ota/display_progress.py +125 -0
- scrollkit/ota/manifest.py +206 -0
- scrollkit/ota/publish.py +379 -0
- scrollkit/simulator/ATTRIBUTION.md +20 -0
- scrollkit/simulator/CIRCUITPYTHON_COMPATIBILITY.md +364 -0
- scrollkit/simulator/LICENSE +176 -0
- scrollkit/simulator/README.md +87 -0
- scrollkit/simulator/__init__.py +11 -0
- scrollkit/simulator/adafruit_bitmap_font/__init__.py +5 -0
- scrollkit/simulator/adafruit_bitmap_font/bitmap_font.py +273 -0
- scrollkit/simulator/adafruit_bitmap_font/glyph_cache.py +70 -0
- scrollkit/simulator/adafruit_display_text/__init__.py +5 -0
- scrollkit/simulator/adafruit_display_text/label.py +336 -0
- scrollkit/simulator/bitmaptools.py +50 -0
- scrollkit/simulator/core/__init__.py +8 -0
- scrollkit/simulator/core/color_utils.py +62 -0
- scrollkit/simulator/core/device_benchmarks.json +254 -0
- scrollkit/simulator/core/feasibility.py +160 -0
- scrollkit/simulator/core/hardware_profile.py +191 -0
- scrollkit/simulator/core/led_matrix.py +307 -0
- scrollkit/simulator/core/matrixportal_s3_baseline.json +12 -0
- scrollkit/simulator/core/performance_manager.py +253 -0
- scrollkit/simulator/core/pixel_buffer.py +188 -0
- scrollkit/simulator/devices/__init__.py +7 -0
- scrollkit/simulator/devices/base_device.py +79 -0
- scrollkit/simulator/devices/matrixportal_s3.py +87 -0
- scrollkit/simulator/displayio/__init__.py +12 -0
- scrollkit/simulator/displayio/bitmap.py +158 -0
- scrollkit/simulator/displayio/display.py +195 -0
- scrollkit/simulator/displayio/fourwire.py +54 -0
- scrollkit/simulator/displayio/group.py +125 -0
- scrollkit/simulator/displayio/ondiskbitmap.py +109 -0
- scrollkit/simulator/displayio/palette.py +115 -0
- scrollkit/simulator/displayio/tilegrid.py +155 -0
- scrollkit/simulator/fonts/3x5.bdf +2474 -0
- scrollkit/simulator/fonts/Arial_16.bdf +7366 -0
- scrollkit/simulator/fonts/Arial_16.bdf.license +3 -0
- scrollkit/simulator/fonts/Arial_Bold_12.bdf +6131 -0
- scrollkit/simulator/fonts/Arial_Bold_12.bdf.license +2 -0
- scrollkit/simulator/fonts/Arial_Bold_18.bdf +32653 -0
- scrollkit/simulator/fonts/Arial_Bold_18.bdf.license +2 -0
- scrollkit/simulator/fonts/Junction_regular_24.bdf +8676 -0
- scrollkit/simulator/fonts/Junction_regular_24.bdf.license +3 -0
- scrollkit/simulator/fonts/LeagueSpartan-Bold-16.bdf +12458 -0
- scrollkit/simulator/fonts/LeagueSpartan-Bold-16.bdf.license +1921 -0
- scrollkit/simulator/fonts/LeagueSpartan_Bold_16.bdf +12458 -0
- scrollkit/simulator/fonts/LeagueSpartan_Bold_16.bdf.license +4 -0
- scrollkit/simulator/fonts/LibreBodoniv2002-Bold-27.bdf +16818 -0
- scrollkit/simulator/fonts/LibreBodoniv2002-Bold-27.bdf.license +1921 -0
- scrollkit/simulator/fonts/tom-thumb.bdf +2353 -0
- scrollkit/simulator/fonts/viii-bold.bdf +2673 -0
- scrollkit/simulator/fonts/viii.bdf +2659 -0
- scrollkit/simulator/terminalio/__init__.py +20 -0
- scrollkit/utils/__init__.py +1 -0
- scrollkit/utils/color_utils.py +54 -0
- scrollkit/utils/diagnostics.py +227 -0
- scrollkit/utils/error_handler.py +347 -0
- scrollkit/utils/system_utils.py +245 -0
- scrollkit/utils/url_utils.py +46 -0
- scrollkit/web/__init__.py +6 -0
- scrollkit/web/settings_server.py +328 -0
- scrollkit/web/wifi_setup.py +331 -0
- scrollkit-0.8.3.dist-info/METADATA +248 -0
- scrollkit-0.8.3.dist-info/RECORD +111 -0
- scrollkit-0.8.3.dist-info/WHEEL +5 -0
- scrollkit-0.8.3.dist-info/licenses/LICENSE +31 -0
- scrollkit-0.8.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# Copyright (c) 2024-2026 Michael Winslow Czeiszperger
|
|
2
|
+
"""Hardware timing/memory profile for modeling real-device performance.
|
|
3
|
+
|
|
4
|
+
Single source of truth for the (DESKTOP-ONLY) hardware-realism simulation: how
|
|
5
|
+
slow the real CircuitPython device is and how little RAM it has.
|
|
6
|
+
|
|
7
|
+
IMPORTANT — the hard-coded numbers in the ``*_estimate()`` builders below are
|
|
8
|
+
ROUGH ENGINEERING ESTIMATES, used only as a fallback. When a board's calibrated
|
|
9
|
+
baseline JSON is present, ``profile_for()`` loads it in preference (via
|
|
10
|
+
``HardwareProfile.from_measurements()``) and flips confidence to
|
|
11
|
+
``CALIBRATED_FROM_DEVICE``. The shipped ``matrixportal_s3_baseline.json`` WAS
|
|
12
|
+
measured on a real MatrixPortal S3, so the default S3 profile is calibrated;
|
|
13
|
+
recapture with ``test/claude/calibrate_device.py``. Reports built from an
|
|
14
|
+
uncalibrated profile say so and round to one significant figure to avoid false
|
|
15
|
+
precision. Trust the RELATIVE breakdown (which category dominates) more than the
|
|
16
|
+
absolute FPS.
|
|
17
|
+
|
|
18
|
+
This module is part of the simulator package and is never imported on hardware.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
CONFIDENCE_ESTIMATE = "ROUGH_ESTIMATE_UNCALIBRATED"
|
|
27
|
+
CONFIDENCE_CALIBRATED = "CALIBRATED_FROM_DEVICE"
|
|
28
|
+
|
|
29
|
+
# Calibrated baselines (captured on a real board by test/claude/calibrate_device.py)
|
|
30
|
+
# ship next to this module, one JSON per board. When a board's file is present it's
|
|
31
|
+
# used in preference to that board's estimate. The S3 file keeps its historical name
|
|
32
|
+
# rather than following the canonical-id convention, so it's mapped explicitly.
|
|
33
|
+
# Override the location for the default board with SCROLLKIT_HW_BASELINE.
|
|
34
|
+
_DEFAULT_BOARD_ID = "adafruit_matrixportal_s3"
|
|
35
|
+
_BASELINE_FILENAMES = {
|
|
36
|
+
"adafruit_matrixportal_s3": "matrixportal_s3_baseline.json",
|
|
37
|
+
"pimoroni_interstate75_w": "pimoroni_interstate75_w_baseline.json",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class HardwareProfile:
|
|
43
|
+
"""A device's modeled timing and memory characteristics."""
|
|
44
|
+
|
|
45
|
+
name: str
|
|
46
|
+
# --- Memory (bytes) ---
|
|
47
|
+
usable_ram_bytes: int # RAM free to the app after CP + displayio overhead
|
|
48
|
+
base_app_ram_bytes: int # modeled floor a running app costs
|
|
49
|
+
bytes_per_label_px: float # RAM per Label bitmap pixel
|
|
50
|
+
# --- Timing (microseconds) ---
|
|
51
|
+
pixel_write_us: float # per matrix.set_pixel() during a group render
|
|
52
|
+
full_refresh_us: float # per display.refresh() (group render + panel update)
|
|
53
|
+
bitmap_rebuild_us_per_px: float # per pixel of a rebuilt text bitmap (the CP killer)
|
|
54
|
+
gc_pause_us: float # modeled garbage-collection stall
|
|
55
|
+
gc_every_n_frames: int # how often a GC pause is modeled
|
|
56
|
+
# --- C bulk-op costs (fill_region / blit via bitmaptools) ---
|
|
57
|
+
# Defaulted so existing callers/baselines are unaffected. Seeded from
|
|
58
|
+
# device_benchmarks.json: fill_region ~147 us / 512 px (~0.287 us/px),
|
|
59
|
+
# blit ~160 us / 256 px (~0.623 us/px). bulk_base_us is a conservative fixed
|
|
60
|
+
# per-call C-dispatch overhead (over-counts slightly so the strict gate fails
|
|
61
|
+
# safe). TODO: capture these at 2-3 sizes to fit base+slope rather than estimate.
|
|
62
|
+
bulk_base_us: float = 12.0
|
|
63
|
+
fill_region_us_per_px: float = 0.287
|
|
64
|
+
blit_us_per_px: float = 0.623
|
|
65
|
+
# --- Honesty ---
|
|
66
|
+
confidence: str = CONFIDENCE_ESTIMATE
|
|
67
|
+
source: str = "engineering estimate"
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def is_calibrated(self):
|
|
71
|
+
return self.confidence == CONFIDENCE_CALIBRATED
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def from_measurements(cls, path, base=None):
|
|
75
|
+
"""Build a CALIBRATED profile from a JSON file of measured values.
|
|
76
|
+
|
|
77
|
+
The JSON may contain any subset of this dataclass's numeric fields; any
|
|
78
|
+
omitted fall back to ``base`` (that board's estimate, or the MatrixPortal
|
|
79
|
+
S3 estimate by default). Use after capturing real frame times / RAM on a
|
|
80
|
+
device.
|
|
81
|
+
"""
|
|
82
|
+
if base is None:
|
|
83
|
+
base = matrixportal_s3_estimate()
|
|
84
|
+
fields = {name: getattr(base, name) for name in base.__dataclass_fields__}
|
|
85
|
+
with open(path) as f:
|
|
86
|
+
data = json.load(f)
|
|
87
|
+
for key, value in data.items():
|
|
88
|
+
if key in fields and not key.startswith("_") and value is not None:
|
|
89
|
+
fields[key] = value
|
|
90
|
+
fields["confidence"] = CONFIDENCE_CALIBRATED
|
|
91
|
+
if not data.get("source"): # keep a descriptive source if the file gave one
|
|
92
|
+
fields["source"] = str(path)
|
|
93
|
+
return cls(**fields)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def matrixportal_s3_estimate():
|
|
97
|
+
"""Rough ESTIMATE for the Adafruit MatrixPortal S3 (ESP32-S3) + 64x32 panel.
|
|
98
|
+
|
|
99
|
+
Premise (documented assumption): CircuitPython on the S3 is roughly two orders
|
|
100
|
+
of magnitude slower than desktop pygame. Simple scrolling text runs ~15-25
|
|
101
|
+
FPS; busy multi-element frames drop to single digits. RAM free to an app is on
|
|
102
|
+
the order of ~180 KB after CircuitPython, displayio, and the Adafruit
|
|
103
|
+
libraries. Every value below is an estimate, flagged UNCALIBRATED.
|
|
104
|
+
"""
|
|
105
|
+
return HardwareProfile(
|
|
106
|
+
name="Adafruit MatrixPortal S3 (64x32) [ESTIMATE]",
|
|
107
|
+
# Memory: ~180 KB free; an app floors around ~40 KB; a 2-color Label
|
|
108
|
+
# bitmap costs ~0.5 byte/px (packed bits + object overhead).
|
|
109
|
+
usable_ram_bytes=180_000,
|
|
110
|
+
base_app_ram_bytes=40_000,
|
|
111
|
+
bytes_per_label_px=0.5,
|
|
112
|
+
# Timing: interpreted per-pixel writes are slow; the dominant cost for
|
|
113
|
+
# scrolling text is rebuilding the glyph bitmap every frame (per-pixel
|
|
114
|
+
# Python loop), which is why bitmap_rebuild_us_per_px is large.
|
|
115
|
+
pixel_write_us=5.0,
|
|
116
|
+
full_refresh_us=5_000.0,
|
|
117
|
+
bitmap_rebuild_us_per_px=75.0,
|
|
118
|
+
gc_pause_us=15_000.0,
|
|
119
|
+
gc_every_n_frames=30,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def interstate75w_estimate():
|
|
124
|
+
"""Rough ESTIMATE for the Pimoroni Interstate 75 W (RP2350) + 64x32 panel.
|
|
125
|
+
|
|
126
|
+
UNCALIBRATED — no timing has been captured on a real Interstate 75 W yet.
|
|
127
|
+
Premise (documented assumption): the RP2350 (dual Cortex-M33 @ ~150 MHz)
|
|
128
|
+
drives HUB75 via PIO/DMA, so refresh is efficient, but it has only ~520 KB
|
|
129
|
+
on-chip SRAM and — unlike the S3's 2 MB PSRAM — no large PSRAM by default,
|
|
130
|
+
so RAM free to an app is far tighter (the RAM feasibility gate matters more
|
|
131
|
+
here). Interpreted-Python cost is in the same order as the S3. Replace every
|
|
132
|
+
value below by running test/claude/calibrate_device.py --board
|
|
133
|
+
pimoroni_interstate75_w once the board is in hand.
|
|
134
|
+
"""
|
|
135
|
+
return HardwareProfile(
|
|
136
|
+
name="Pimoroni Interstate 75 W (64x32) [ESTIMATE]",
|
|
137
|
+
# Memory: ~520 KB SRAM, no PSRAM; after CircuitPython + displayio + the
|
|
138
|
+
# rgbmatrix framebuffer, an app has on the order of ~200 KB.
|
|
139
|
+
usable_ram_bytes=200_000,
|
|
140
|
+
base_app_ram_bytes=40_000,
|
|
141
|
+
bytes_per_label_px=0.5,
|
|
142
|
+
# Timing: PIO/DMA refresh estimated similar to the S3; interpreted
|
|
143
|
+
# per-pixel/glyph work slightly higher at the lower clock.
|
|
144
|
+
pixel_write_us=5.0,
|
|
145
|
+
full_refresh_us=5_000.0,
|
|
146
|
+
bitmap_rebuild_us_per_px=80.0,
|
|
147
|
+
gc_pause_us=15_000.0,
|
|
148
|
+
gc_every_n_frames=30,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _estimate_for(board_id):
|
|
153
|
+
"""That board's labeled ROUGH ESTIMATE profile (default: S3)."""
|
|
154
|
+
if board_id == "pimoroni_interstate75_w":
|
|
155
|
+
return interstate75w_estimate()
|
|
156
|
+
return matrixportal_s3_estimate()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def baseline_path(board_id=_DEFAULT_BOARD_ID):
|
|
160
|
+
"""Path to a board's shipped device baseline JSON (may not exist).
|
|
161
|
+
|
|
162
|
+
``SCROLLKIT_HW_BASELINE`` overrides the path for the default board.
|
|
163
|
+
"""
|
|
164
|
+
if board_id == _DEFAULT_BOARD_ID:
|
|
165
|
+
env = os.environ.get("SCROLLKIT_HW_BASELINE")
|
|
166
|
+
if env:
|
|
167
|
+
return env
|
|
168
|
+
filename = _BASELINE_FILENAMES.get(board_id, "%s_baseline.json" % board_id)
|
|
169
|
+
return os.path.join(os.path.dirname(__file__), filename)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def profile_for(board_id=_DEFAULT_BOARD_ID):
|
|
173
|
+
"""The performance profile for ``board_id``.
|
|
174
|
+
|
|
175
|
+
Returns a CALIBRATED profile built from that board's shipped baseline when one
|
|
176
|
+
is present, and falls back to the labeled ROUGH ESTIMATE otherwise. This is
|
|
177
|
+
what the simulator and capability catalog use.
|
|
178
|
+
"""
|
|
179
|
+
estimate = _estimate_for(board_id)
|
|
180
|
+
path = baseline_path(board_id)
|
|
181
|
+
try:
|
|
182
|
+
if path and os.path.exists(path):
|
|
183
|
+
return HardwareProfile.from_measurements(path, base=estimate)
|
|
184
|
+
except Exception:
|
|
185
|
+
pass
|
|
186
|
+
return estimate
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def matrixportal_s3_profile():
|
|
190
|
+
"""The default (MatrixPortal S3) profile. Back-compat alias for ``profile_for``."""
|
|
191
|
+
return profile_for(_DEFAULT_BOARD_ID)
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
# Copyright (c) 2024-2026 Michael Winslow Czeiszperger
|
|
2
|
+
"""Core LED matrix simulation with pixel-level control."""
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from .pixel_buffer import PixelBuffer
|
|
6
|
+
from .color_utils import apply_brightness
|
|
7
|
+
|
|
8
|
+
# pygame is imported lazily (inside the methods that build the desktop "LED
|
|
9
|
+
# circle" window surface) so the pixel-buffer render path stays pure-Python +
|
|
10
|
+
# numpy and importable in headless environments without pygame (e.g. Pyodide in
|
|
11
|
+
# the browser, or CI). See the ``headless`` flag below.
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LEDMatrix:
|
|
15
|
+
"""Core LED matrix simulation with pixel-level control.
|
|
16
|
+
|
|
17
|
+
This class simulates the physical appearance of an LED matrix display,
|
|
18
|
+
including realistic LED rendering with configurable pitch and appearance.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, width, height, pitch=3.0, led_size=None, performance_manager=None,
|
|
22
|
+
headless=False, bit_depth=4):
|
|
23
|
+
"""Initialize LED matrix.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
width: Matrix width in pixels
|
|
27
|
+
height: Matrix height in pixels
|
|
28
|
+
pitch: LED pitch in mm (2.5, 3, 4, 5, or 6)
|
|
29
|
+
led_size: Optional LED size override in pixels
|
|
30
|
+
performance_manager: Optional performance simulation manager
|
|
31
|
+
"""
|
|
32
|
+
self.width = width
|
|
33
|
+
self.height = height
|
|
34
|
+
self.pitch = pitch
|
|
35
|
+
|
|
36
|
+
# Calculate LED size and spacing based on pitch
|
|
37
|
+
# For a given pitch, LED size is typically 80% of pitch
|
|
38
|
+
# Scale factor adjusted to match physical 192mm x 96mm display (725x362 pixels at 96 DPI)
|
|
39
|
+
# 192mm / 64 pixels = 3mm pitch, display should be ~725 pixels wide
|
|
40
|
+
if led_size is None:
|
|
41
|
+
self.led_size = int(pitch * 0.8 * 3.75) # 9 pixel LED size
|
|
42
|
+
else:
|
|
43
|
+
self.led_size = led_size
|
|
44
|
+
self.spacing = int(pitch * 0.2 * 3.75) # 2 pixel spacing
|
|
45
|
+
|
|
46
|
+
self.pixel_buffer = PixelBuffer(width, height)
|
|
47
|
+
self.brightness = 1.0
|
|
48
|
+
self.performance_manager = performance_manager
|
|
49
|
+
# Headless: populate the pixel buffer but skip the pygame window surface
|
|
50
|
+
# entirely (no pygame import). Per-instance, or process-wide via
|
|
51
|
+
# SCROLLKIT_HEADLESS=1 (used by the browser/Pyodide and CI render paths).
|
|
52
|
+
self.headless = headless or os.environ.get("SCROLLKIT_HEADLESS") == "1"
|
|
53
|
+
|
|
54
|
+
# Calculate surface size based on LED arrangement
|
|
55
|
+
self.surface_width = width * (self.led_size + self.spacing) - self.spacing
|
|
56
|
+
self.surface_height = height * (self.led_size + self.spacing) - self.spacing
|
|
57
|
+
|
|
58
|
+
# Create pygame surface for rendering
|
|
59
|
+
self.surface = None
|
|
60
|
+
self._background_color = (8, 8, 11) # near-black, like a powered-but-unlit HUB75 panel
|
|
61
|
+
|
|
62
|
+
# Honest colour: quantise to the panel's per-channel bit depth (16 levels
|
|
63
|
+
# at bit_depth=4) so the simulator shows what the hardware actually shows,
|
|
64
|
+
# not full 24-bit colour the panel can't display. Set ``bit_depth`` to 6
|
|
65
|
+
# (and the caches self-refresh) for the smooth-gradient finale.
|
|
66
|
+
self.bit_depth = bit_depth
|
|
67
|
+
self.posterize = True
|
|
68
|
+
|
|
69
|
+
# Round-LED + soft-glow appearance geometry, derived from the LED size so
|
|
70
|
+
# it tracks the configured pitch (matches the rendered hero stills).
|
|
71
|
+
self._dot_radius = max(2, int(round(self.led_size * 0.46)))
|
|
72
|
+
self._core_radius = max(1, int(round(self.led_size * 0.22)))
|
|
73
|
+
self._off_radius = max(1, int(round(self.led_size * 0.30)))
|
|
74
|
+
self._off_color = (22, 22, 26) # faint unlit LED, so the grid reads
|
|
75
|
+
self._glow_reach = max(self.led_size, # how far the additive halo bleeds
|
|
76
|
+
int(round((self.led_size + self.spacing) * 0.95)))
|
|
77
|
+
self._glow_cache = {} # posterised color -> additive glow halo sprite
|
|
78
|
+
self._dot_cache = {} # posterised color -> crisp LED dot sprite
|
|
79
|
+
self._base_surface = None # near-black bg + unlit-LED grid (rebuilt only if cleared)
|
|
80
|
+
|
|
81
|
+
def initialize_surface(self):
|
|
82
|
+
"""Initialize the pygame surface for rendering (no-op when headless)."""
|
|
83
|
+
if self.headless:
|
|
84
|
+
return
|
|
85
|
+
import pygame
|
|
86
|
+
if not pygame.get_init():
|
|
87
|
+
pygame.init()
|
|
88
|
+
|
|
89
|
+
self.surface = pygame.Surface((self.surface_width, self.surface_height))
|
|
90
|
+
self.surface.fill(self._background_color)
|
|
91
|
+
|
|
92
|
+
def set_pixel(self, x, y, color):
|
|
93
|
+
"""Set a single pixel color.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
x: X coordinate (0 to width-1)
|
|
97
|
+
y: Y coordinate (0 to height-1)
|
|
98
|
+
color: Color as (r, g, b) tuple or RGB value
|
|
99
|
+
"""
|
|
100
|
+
if self.performance_manager and self.performance_manager.enabled:
|
|
101
|
+
# Simulate pixel write delay
|
|
102
|
+
self.performance_manager.simulate_instruction_delay(3)
|
|
103
|
+
|
|
104
|
+
self.pixel_buffer.set_pixel(x, y, color)
|
|
105
|
+
|
|
106
|
+
def get_pixel(self, x, y):
|
|
107
|
+
"""Get a single pixel color.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
x: X coordinate (0 to width-1)
|
|
111
|
+
y: Y coordinate (0 to height-1)
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Color as (r, g, b) tuple
|
|
115
|
+
"""
|
|
116
|
+
return self.pixel_buffer.get_pixel(x, y)
|
|
117
|
+
|
|
118
|
+
def fill(self, color):
|
|
119
|
+
"""Fill all pixels with a single color.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
color: Color as (r, g, b) tuple or RGB value
|
|
123
|
+
"""
|
|
124
|
+
self.pixel_buffer.fill(color)
|
|
125
|
+
|
|
126
|
+
def clear(self):
|
|
127
|
+
"""Clear all pixels to black."""
|
|
128
|
+
self.pixel_buffer.clear()
|
|
129
|
+
|
|
130
|
+
def set_brightness(self, brightness):
|
|
131
|
+
"""Set display brightness.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
brightness: Float from 0.0 to 1.0
|
|
135
|
+
"""
|
|
136
|
+
self.brightness = max(0.0, min(1.0, brightness))
|
|
137
|
+
|
|
138
|
+
def render(self):
|
|
139
|
+
"""Draw the (already-populated) pixel buffer to the pygame window surface.
|
|
140
|
+
|
|
141
|
+
The buffer is filled by set_pixel()/fill() before this runs. When
|
|
142
|
+
headless the perf model still runs (it models device timing, not pixels)
|
|
143
|
+
but the pygame surface step is skipped, so no pygame is needed.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
if self.performance_manager and self.performance_manager.enabled:
|
|
147
|
+
# Simulate display refresh delay
|
|
148
|
+
self.performance_manager.simulate_io_operation("display_refresh")
|
|
149
|
+
# Simulate potential GC pause during rendering
|
|
150
|
+
self.performance_manager.simulate_gc_pause()
|
|
151
|
+
|
|
152
|
+
if self.headless:
|
|
153
|
+
# Buffer already populated by set_pixel(); skip the pygame surface.
|
|
154
|
+
self.pixel_buffer.clear_dirty()
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
if self.surface is None:
|
|
158
|
+
self.initialize_surface()
|
|
159
|
+
|
|
160
|
+
# Only update dirty regions if tracking is enabled
|
|
161
|
+
if self.pixel_buffer.is_dirty():
|
|
162
|
+
dirty_region = self.pixel_buffer.get_dirty_region()
|
|
163
|
+
|
|
164
|
+
if dirty_region is None:
|
|
165
|
+
# Full update
|
|
166
|
+
self._render_full()
|
|
167
|
+
else:
|
|
168
|
+
# Partial update (future optimization)
|
|
169
|
+
self._render_full() # For now, always do full update
|
|
170
|
+
|
|
171
|
+
self.pixel_buffer.clear_dirty()
|
|
172
|
+
|
|
173
|
+
def _posterize_color(self, color):
|
|
174
|
+
"""Quantise an (r, g, b) to the panel's per-channel bit depth.
|
|
175
|
+
|
|
176
|
+
At ``bit_depth=4`` each channel snaps to 16 levels (multiples of 17) — the
|
|
177
|
+
honest hardware colour. Returns plain Python ints (no numpy scalars).
|
|
178
|
+
"""
|
|
179
|
+
if not self.posterize:
|
|
180
|
+
return (int(color[0]), int(color[1]), int(color[2]))
|
|
181
|
+
levels = (1 << self.bit_depth) - 1
|
|
182
|
+
if levels <= 0:
|
|
183
|
+
return (int(color[0]), int(color[1]), int(color[2]))
|
|
184
|
+
return tuple(int(round(int(c) / 255.0 * levels)) * 255 // levels
|
|
185
|
+
for c in color)
|
|
186
|
+
|
|
187
|
+
# All round LEDs are drawn supersampled then smooth-scaled down: a plain small
|
|
188
|
+
# pygame circle rasterises to a chunky diamond, so we draw big and shrink for
|
|
189
|
+
# clean anti-aliased dots (matches the rendered hero stills).
|
|
190
|
+
@staticmethod
|
|
191
|
+
def _aa_scale(surface, size):
|
|
192
|
+
import pygame
|
|
193
|
+
return pygame.transform.smoothscale(surface, size)
|
|
194
|
+
|
|
195
|
+
def _build_base_surface(self):
|
|
196
|
+
"""Build (and cache) the static panel: near-black ground + the unlit-LED grid."""
|
|
197
|
+
import pygame
|
|
198
|
+
ss = 3
|
|
199
|
+
big = pygame.Surface((self.surface_width * ss, self.surface_height * ss))
|
|
200
|
+
big.fill(self._background_color)
|
|
201
|
+
step = (self.led_size + self.spacing) * ss
|
|
202
|
+
half = (self.led_size // 2) * ss
|
|
203
|
+
rad = max(1, self._off_radius * ss)
|
|
204
|
+
for gy in range(self.height):
|
|
205
|
+
cy = gy * step + half
|
|
206
|
+
for gx in range(self.width):
|
|
207
|
+
cx = gx * step + half
|
|
208
|
+
pygame.draw.circle(big, self._off_color, (cx, cy), rad)
|
|
209
|
+
return self._aa_scale(big, (self.surface_width, self.surface_height))
|
|
210
|
+
|
|
211
|
+
def _glow_sprite(self, color):
|
|
212
|
+
"""A radial additive halo for an 'on' LED of ``color`` (cached per colour).
|
|
213
|
+
|
|
214
|
+
Drawn on black so an additive blit contributes only the glow; overlapping
|
|
215
|
+
halos sum, giving the soft bleed of a real panel.
|
|
216
|
+
"""
|
|
217
|
+
import pygame
|
|
218
|
+
sprite = self._glow_cache.get(color)
|
|
219
|
+
if sprite is not None:
|
|
220
|
+
return sprite
|
|
221
|
+
ss = 2
|
|
222
|
+
reach = self._glow_reach
|
|
223
|
+
big = pygame.Surface((2 * reach * ss, 2 * reach * ss)) # RGB; additive ignores black
|
|
224
|
+
big.fill((0, 0, 0))
|
|
225
|
+
steps = 6
|
|
226
|
+
for k in range(steps):
|
|
227
|
+
t = k / (steps - 1) # 0 outer .. 1 inner
|
|
228
|
+
rad = int(round((reach - t * (reach - self._dot_radius)) * ss))
|
|
229
|
+
bright = 0.10 + 0.42 * t # dim at the edge, bright near the core
|
|
230
|
+
col = tuple(min(255, int(ch * bright)) for ch in color)
|
|
231
|
+
pygame.draw.circle(big, col, (reach * ss, reach * ss), max(1, rad))
|
|
232
|
+
sprite = self._aa_scale(big, (2 * reach, 2 * reach))
|
|
233
|
+
if len(self._glow_cache) < 256:
|
|
234
|
+
self._glow_cache[color] = sprite
|
|
235
|
+
return sprite
|
|
236
|
+
|
|
237
|
+
def _dot_sprite(self, color):
|
|
238
|
+
"""The crisp LED dot + bright core for an 'on' LED (cached per colour)."""
|
|
239
|
+
import pygame
|
|
240
|
+
sprite = self._dot_cache.get(color)
|
|
241
|
+
if sprite is not None:
|
|
242
|
+
return sprite
|
|
243
|
+
ss = 4
|
|
244
|
+
size = self.led_size
|
|
245
|
+
big = pygame.Surface((size * ss, size * ss), pygame.SRCALPHA)
|
|
246
|
+
center = (size // 2) * ss
|
|
247
|
+
pygame.draw.circle(big, color, (center, center), self._dot_radius * ss)
|
|
248
|
+
core = tuple(min(255, int(ch * 1.18) + 40) for ch in color)
|
|
249
|
+
pygame.draw.circle(big, core, (center, center), max(1, self._core_radius * ss))
|
|
250
|
+
sprite = self._aa_scale(big, (size, size))
|
|
251
|
+
if len(self._dot_cache) < 256:
|
|
252
|
+
self._dot_cache[color] = sprite
|
|
253
|
+
return sprite
|
|
254
|
+
|
|
255
|
+
def _render_full(self):
|
|
256
|
+
"""Render the panel: near-black ground + unlit grid, then additive glow + dots.
|
|
257
|
+
|
|
258
|
+
Two passes so every halo blooms *under* every crisp dot (and overlapping
|
|
259
|
+
halos add), matching the rendered hero stills.
|
|
260
|
+
"""
|
|
261
|
+
import pygame
|
|
262
|
+
if self._base_surface is None:
|
|
263
|
+
self._base_surface = self._build_base_surface()
|
|
264
|
+
self.surface.blit(self._base_surface, (0, 0))
|
|
265
|
+
|
|
266
|
+
pixels = self.pixel_buffer.get_buffer()
|
|
267
|
+
step = self.led_size + self.spacing
|
|
268
|
+
half = self.led_size // 2
|
|
269
|
+
|
|
270
|
+
lit = []
|
|
271
|
+
for y in range(self.height):
|
|
272
|
+
for x in range(self.width):
|
|
273
|
+
color = tuple(int(c) for c in pixels[y, x])
|
|
274
|
+
if self.brightness < 1.0:
|
|
275
|
+
color = apply_brightness(color, self.brightness)
|
|
276
|
+
color = self._posterize_color(color)
|
|
277
|
+
if color[0] + color[1] + color[2] <= 24:
|
|
278
|
+
continue # 'off' — the unlit grid in the base shows through
|
|
279
|
+
lit.append((x * step, y * step, color))
|
|
280
|
+
|
|
281
|
+
reach = self._glow_reach
|
|
282
|
+
for led_x, led_y, color in lit:
|
|
283
|
+
self.surface.blit(self._glow_sprite(color),
|
|
284
|
+
(led_x + half - reach, led_y + half - reach),
|
|
285
|
+
special_flags=pygame.BLEND_RGB_ADD)
|
|
286
|
+
for led_x, led_y, color in lit:
|
|
287
|
+
self.surface.blit(self._dot_sprite(color), (led_x, led_y))
|
|
288
|
+
|
|
289
|
+
def get_surface(self):
|
|
290
|
+
"""Get the pygame surface for this matrix.
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
Pygame surface containing rendered LEDs
|
|
294
|
+
"""
|
|
295
|
+
if self.surface is None:
|
|
296
|
+
self.initialize_surface()
|
|
297
|
+
return self.surface
|
|
298
|
+
|
|
299
|
+
def save_screenshot(self, filename):
|
|
300
|
+
"""Save a screenshot of the current matrix state.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
filename: Path to save the screenshot
|
|
304
|
+
"""
|
|
305
|
+
if self.surface:
|
|
306
|
+
import pygame
|
|
307
|
+
pygame.image.save(self.surface, filename)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_mem_after_display": 2073536,
|
|
3
|
+
"_mem_boot": 2074512,
|
|
4
|
+
"base_app_ram_bytes": 976,
|
|
5
|
+
"bitmap_rebuild_us_per_px": 16.53,
|
|
6
|
+
"bytes_per_label_px": 0.1875,
|
|
7
|
+
"full_refresh_us": 4488.12,
|
|
8
|
+
"gc_pause_us": 383.0,
|
|
9
|
+
"name": "Adafruit MatrixPortal S3 (64x32)",
|
|
10
|
+
"source": "measured on adafruit_matrixportal_s3, CircuitPython 9.1.0",
|
|
11
|
+
"usable_ram_bytes": 2073536
|
|
12
|
+
}
|