cm-pixel 0.1.0__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.
@@ -0,0 +1,99 @@
1
+ Metadata-Version: 2.4
2
+ Name: cm-pixel
3
+ Version: 0.1.0
4
+ Summary: Open-source driver for the Cooler Master MasterLiquid Atmos II Pixel LED hexagonal screen
5
+ Author-email: Feiko Wielsma <feiko.w@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/FeikoWielsma/cm-pixel
8
+ Project-URL: Source, https://github.com/FeikoWielsma/cm-pixel
9
+ Project-URL: Issues, https://github.com/FeikoWielsma/cm-pixel/issues
10
+ Keywords: cooler-master,atmos,pixel,led,hid,aio
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: System :: Hardware
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: hidapi>=0.14
18
+ Requires-Dist: numpy>=1.21
19
+ Requires-Dist: Pillow>=9
20
+ Dynamic: license-file
21
+
22
+ # cm-pixel
23
+
24
+ Open-source driver + toolkit for the **Cooler Master MasterLiquid Atmos II Pixel LED**
25
+ hexagonal pixel screen — a clean, lightweight alternative to the official MasterCTRL app
26
+ for controlling the LEDs over USB. **Screen only** (pump/fans are PWM and untouched).
27
+
28
+ The USB protocol was reverse-engineered from scratch; see [`PROTOCOL.md`](PROTOCOL.md).
29
+
30
+ ## Hardware
31
+ - Cooler Master "Atmos V2 - Pixel", USB `2516:021C`, plain HID (no special driver).
32
+ - 32×32 addressing grid, **556 real LEDs** arranged in a hexagon, RGB888.
33
+
34
+ ## Install
35
+
36
+ This project uses [uv](https://docs.astral.sh/uv/). From a clone:
37
+ ```bash
38
+ uv sync # creates .venv and installs everything (incl. uv.lock)
39
+ uv run cm-pixel list # run the CLI
40
+ ```
41
+ Or add it to your own uv project:
42
+ ```bash
43
+ uv add cm-pixel
44
+ ```
45
+ (Plain pip also works: `pip install cm-pixel`.)
46
+
47
+ > Close the official MasterCTRL software first — only one program can drive the screen.
48
+ > On Linux you may need a udev rule for HID write access.
49
+
50
+ ## CLI
51
+ ```bash
52
+ cm-pixel color 255 0 0 # solid red
53
+ cm-pixel image photo.png # show an image (auto-fit to the hexagon)
54
+ cm-pixel gif spinner.gif # play a GIF
55
+ cm-pixel text "HI" # static centered text
56
+ cm-pixel scroll "HELLO WORLD" # scrolling marquee
57
+ cm-pixel anim plasma # procedural animation
58
+ cm-pixel anim fire --speed 1.5
59
+ cm-pixel list # list animations
60
+ ```
61
+ Common flags: `--fps`, `--duration`, `--mask` (blank pixels outside the hexagon), `--keep`,
62
+ `--brightness 0-100`, `--rotate 0|90|180|270`. Brightness and rotation are applied in software
63
+ (the device has no command for them); 180° is exact, 90/270 clip slightly at the hexagon vertices.
64
+
65
+ Animations: `plasma rainbow swirl ripple breathe sparkle fire starfield`.
66
+
67
+ ## Library
68
+ ```python
69
+ from cmpixel import PixelDisplay, Canvas
70
+ from cmpixel.font import draw_text
71
+
72
+ with PixelDisplay() as d:
73
+ c = Canvas().fill((0, 0, 20))
74
+ c.set_pixel(16, 16, (255, 255, 255))
75
+ draw_text(c, "GO", color=(0, 255, 0))
76
+ d.send_canvas(c)
77
+ ```
78
+
79
+ ```python
80
+ # play a built-in animation
81
+ from cmpixel import PixelDisplay
82
+ from cmpixel.animations import plasma
83
+ from cmpixel.media import play
84
+
85
+ with PixelDisplay() as d:
86
+ play(d, plasma(speed=1.0), fps=30, duration=10)
87
+ ```
88
+
89
+ ## How it works (short version)
90
+ A frame is 28 HID output reports of 64 bytes: `80 DD <pkt:u16> + 60 data`, concatenated to
91
+ 556×RGB in serpentine LED order. Streaming a frame displays it immediately — no handshake.
92
+ Full details and the LED layout map are in [`PROTOCOL.md`](PROTOCOL.md) and `cmpixel/layout.json`.
93
+
94
+ ## Status
95
+ v0.1 — drawing, text, GIF, procedural animations, software brightness & rotation.
96
+
97
+ ## Disclaimer
98
+ Unofficial, not affiliated with Cooler Master. Use at your own risk; touches only the
99
+ screen framebuffer (no firmware/DFU).
@@ -0,0 +1,15 @@
1
+ cm_pixel-0.1.0.dist-info/licenses/LICENSE,sha256=QWs4fDn9m68TwZkHsL1hhO4urfdzqfdVnJCZlvgeNb0,1062
2
+ cmpixel/__init__.py,sha256=z9ISz71omFdOr3Fdrc4WDGfKtX4vzVWYp87fmtpOFn8,376
3
+ cmpixel/animations.py,sha256=ma96nIqR-Mieb--tMAhaOdLJ9gFsbRFQZBLkOEWydZA,11019
4
+ cmpixel/canvas.py,sha256=maZCqOvYOvJ2Pht6G7CZ50xhc9EfTwzUKFX-a8Fjsiw,2701
5
+ cmpixel/cli.py,sha256=YxVo1HJ-U2v-uOk62bJYDjDSlnU477QNMIv7B3EUfXc,4036
6
+ cmpixel/device.py,sha256=hgm5bqeTQdiZOdX_jW_YA8l79O-NnosekpLUlX2q6zs,4338
7
+ cmpixel/font.py,sha256=IRAVTZR9lI0UbwmT_X-rkSL96jhuS2w-IeUet8TNh4Y,4799
8
+ cmpixel/layout.json,sha256=HeMw46UeLjlsWjFfAxAGorx7nv9P9IynuEvYD8jkHjQ,4076
9
+ cmpixel/media.py,sha256=o5wSsHVZoQNCzPyGl-WzOmUwQuRCjX0luTSX28-iEuw,1921
10
+ cmpixel/rubik.py,sha256=WgSfMgA83pnzEoHya4f1vkplmFsS-nYW4HEBadSrJMM,6312
11
+ cm_pixel-0.1.0.dist-info/METADATA,sha256=XJgFoPiC_k4KsM2_hjImlPGwLSdSkcAB0UZiJbM4b8E,3624
12
+ cm_pixel-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
13
+ cm_pixel-0.1.0.dist-info/entry_points.txt,sha256=yPP9C0lOZAY7FUmi17FG0rYYk8k9uGBl_r3-5WsHDys,46
14
+ cm_pixel-0.1.0.dist-info/top_level.txt,sha256=h3O7TuQBhaU7nx9Itox-NWXRBOQXTxKUyO9AXp1LqCU,8
15
+ cm_pixel-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cm-pixel = cmpixel.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Feiko
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ cmpixel
cmpixel/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """cmpixel — open-source driver for the Cooler Master Atmos V2 'Pixel' screen."""
2
+ from .device import PixelDisplay, VID, PID, GRID_W, GRID_H, NLED
3
+ from .canvas import Canvas, MASK
4
+ from . import animations, font, media
5
+
6
+ __version__ = "0.1.0"
7
+ __all__ = [
8
+ "PixelDisplay", "Canvas", "MASK",
9
+ "VID", "PID", "GRID_W", "GRID_H", "NLED",
10
+ "animations", "font", "media",
11
+ ]
cmpixel/animations.py ADDED
@@ -0,0 +1,337 @@
1
+ """Procedural animations. Each is a generator yielding (32,32,3) uint8 frames.
2
+
3
+ All take a `speed` multiplier; most accept extra knobs. They run forever.
4
+ Frames are full-grid; the hexagon mask is applied by the player/device anyway.
5
+ """
6
+ import numpy as np
7
+
8
+ from .canvas import GRID_W, GRID_H, MASK
9
+ from .rubik import rubik
10
+
11
+ _Y, _X = np.mgrid[0:GRID_H, 0:GRID_W].astype(np.float32)
12
+ _CX, _CY = (GRID_W - 1) / 2.0, (GRID_H - 1) / 2.0
13
+ _DX, _DY = _X - _CX, _Y - _CY
14
+ _R = np.sqrt(_DX**2 + _DY**2)
15
+ _ANG = np.arctan2(_DY, _DX)
16
+
17
+
18
+ def hsv_to_rgb(h, s, v):
19
+ """h,s,v in [0,1] arrays -> (H,W,3) uint8."""
20
+ h = (h % 1.0) * 6.0
21
+ i = np.floor(h).astype(int)
22
+ f = h - i
23
+ p = v * (1 - s)
24
+ q = v * (1 - f * s)
25
+ t = v * (1 - (1 - f) * s)
26
+ i = i % 6
27
+ r = np.choose(i, [v, q, p, p, t, v])
28
+ g = np.choose(i, [t, v, v, q, p, p])
29
+ b = np.choose(i, [p, p, t, v, v, q])
30
+ return (np.stack([r, g, b], axis=-1).clip(0, 1) * 255).astype(np.uint8)
31
+
32
+
33
+ def plasma(speed=1.0):
34
+ t = 0.0
35
+ while True:
36
+ v = (np.sin(_X / 4.0 + t)
37
+ + np.sin(_Y / 3.0 - t)
38
+ + np.sin((_X + _Y) / 4.0 + t)
39
+ + np.sin(_R / 3.0 - t))
40
+ h = (v + 4) / 8.0
41
+ yield hsv_to_rgb(h, np.ones_like(h), np.ones_like(h))
42
+ t += 0.15 * speed
43
+
44
+
45
+ def rainbow(speed=1.0, vertical=False):
46
+ t = 0.0
47
+ base = _Y if vertical else _X
48
+ while True:
49
+ h = base / GRID_W + t
50
+ yield hsv_to_rgb(h, np.ones_like(h), np.ones_like(h))
51
+ t += 0.02 * speed
52
+
53
+
54
+ def swirl(speed=1.0):
55
+ t = 0.0
56
+ while True:
57
+ h = (_ANG / (2 * np.pi)) + _R / 16.0 + t
58
+ yield hsv_to_rgb(h, np.ones_like(h), np.ones_like(h))
59
+ t += 0.05 * speed
60
+
61
+
62
+ def ripple(speed=1.0, color=None):
63
+ t = 0.0
64
+ while True:
65
+ wave = (np.sin(_R - t) + 1) / 2.0
66
+ if color is None:
67
+ out = hsv_to_rgb((_R / 16.0 - t / 6.0), np.ones_like(wave), wave)
68
+ else:
69
+ out = (np.array(color, np.float32) * wave[..., None]).astype(np.uint8)
70
+ yield out
71
+ t += 0.3 * speed
72
+
73
+
74
+ def breathe(color=(0, 120, 255), speed=1.0):
75
+ t = 0.0
76
+ col = np.array(color, np.float32)
77
+ while True:
78
+ v = (np.sin(t) + 1) / 2.0
79
+ frame = np.empty((GRID_H, GRID_W, 3), np.uint8)
80
+ frame[:] = (col * v).astype(np.uint8)
81
+ yield frame
82
+ t += 0.08 * speed
83
+
84
+
85
+ def sparkle(color=(255, 255, 255), speed=1.0, fade=0.85, rate=6):
86
+ buf = np.zeros((GRID_H, GRID_W, 3), np.float32)
87
+ leds = np.argwhere(MASK)
88
+ while True:
89
+ buf *= fade
90
+ for _ in range(int(rate * speed)):
91
+ y, x = leds[np.random.randint(len(leds))]
92
+ buf[y, x] = color
93
+ yield buf.clip(0, 255).astype(np.uint8)
94
+
95
+
96
+ def fire(speed=1.0):
97
+ heat = np.zeros((GRID_H + 2, GRID_W), np.float32)
98
+ # fire palette: black -> red -> orange -> yellow -> white
99
+ stops = np.array([[0, 0, 0], [80, 0, 0], [220, 50, 0],
100
+ [255, 160, 0], [255, 255, 120]], np.float32)
101
+ xp = np.linspace(0, 1, len(stops))
102
+ grad = np.stack([np.interp(np.linspace(0, 1, 256), xp, stops[:, c]) for c in range(3)], axis=-1)
103
+ while True:
104
+ heat[-1] = np.random.rand(GRID_W) * 255 # spark row at bottom
105
+ # cool + rise
106
+ nh = (heat[1:] * 0.5 + heat[:-1] * 0.0)
107
+ rolled = (np.roll(heat, -1, axis=0) * 0.42 + heat * 0.30
108
+ + np.roll(heat, (-1, 1), (0, 1)) * 0.14
109
+ + np.roll(heat, (-1, -1), (0, 1)) * 0.14)
110
+ heat[:] = rolled.clip(0, 255)
111
+ idx = heat[:GRID_H].astype(int).clip(0, 255)
112
+ yield grad[idx].astype(np.uint8)
113
+ if speed != 1.0:
114
+ pass
115
+
116
+
117
+ def starfield(speed=1.0, density=0.04):
118
+ stars = [] # (x, y, z)
119
+ while True:
120
+ if np.random.rand() < density * 8:
121
+ stars.append([np.random.uniform(-_CX, _CX), np.random.uniform(-_CY, _CY), 16.0])
122
+ frame = np.zeros((GRID_H, GRID_W, 3), np.uint8)
123
+ alive = []
124
+ for s in stars:
125
+ s[2] -= 0.4 * speed
126
+ if s[2] <= 1:
127
+ continue
128
+ px = int(_CX + s[0] * 8 / s[2])
129
+ py = int(_CY + s[1] * 8 / s[2])
130
+ if 0 <= px < GRID_W and 0 <= py < GRID_H:
131
+ b = int(255 * (1 - s[2] / 16))
132
+ frame[py, px] = (b, b, b)
133
+ alive.append(s)
134
+ stars[:] = alive[-200:]
135
+ yield frame
136
+
137
+
138
+ def _solid(hue, sat, val):
139
+ """hsv_to_rgb for scalar h,s,v -> (3,) float array."""
140
+ return hsv_to_rgb(np.array([[hue]]), np.array([[sat]]), np.array([[val]]))[0, 0].astype(float)
141
+
142
+
143
+ def tunnel(speed=1.0):
144
+ """Hypnotic radial tunnel rushing inward."""
145
+ t = 0.0
146
+ v = 1.0 / (_R + 0.6)
147
+ u = _ANG / (2 * np.pi)
148
+ vig = 1 - np.clip(_R / (GRID_W * 0.6), 0, 1)
149
+ while True:
150
+ stripes = 0.5 + 0.5 * np.sin(v * 18 - t * 4)
151
+ hue = (v * 1.5 + u + t * 0.05) % 1.0
152
+ val = stripes * (0.3 + 0.7 * vig)
153
+ yield hsv_to_rgb(hue, np.ones_like(hue), val)
154
+ t += 0.05 * speed
155
+
156
+
157
+ def metaballs(speed=1.0, n=4):
158
+ """Smooth gooey blobs drifting and merging."""
159
+ t = 0.0
160
+ ph = np.random.rand(n, 2) * 2 * np.pi
161
+ while True:
162
+ field = np.zeros((GRID_H, GRID_W), np.float32)
163
+ for i in range(n):
164
+ cx = GRID_W / 2 + GRID_W * 0.34 * np.sin(t * 0.6 + ph[i, 0])
165
+ cy = GRID_H / 2 + GRID_H * 0.34 * np.cos(t * 0.5 + ph[i, 1])
166
+ field += 30.0 / ((_X - cx) ** 2 + (_Y - cy) ** 2 + 8)
167
+ hue = (field * 0.3 + t * 0.05) % 1.0
168
+ val = np.clip(field * 0.9, 0, 1)
169
+ yield hsv_to_rgb(hue, np.ones_like(hue), val)
170
+ t += 0.05 * speed
171
+
172
+
173
+ def matrix(speed=1.0, fade=0.78):
174
+ """Falling green 'digital rain' columns."""
175
+ buf = np.zeros((GRID_H, GRID_W, 3), np.float32)
176
+ heads = np.zeros(GRID_W)
177
+ spd = 0.3 + np.random.rand(GRID_W) * 0.7
178
+ active = np.random.rand(GRID_W) < 0.4
179
+ while True:
180
+ buf *= fade
181
+ for x in range(GRID_W):
182
+ if not active[x]:
183
+ if np.random.rand() < 0.03:
184
+ active[x] = True
185
+ heads[x] = 0
186
+ continue
187
+ y = int(heads[x])
188
+ if 0 <= y < GRID_H:
189
+ buf[y, x] = (170, 255, 170)
190
+ heads[x] += spd[x] * speed
191
+ if heads[x] > GRID_H + 4:
192
+ active[x] = False
193
+ yield buf.clip(0, 255).astype(np.uint8)
194
+
195
+
196
+ def fireworks(speed=1.0, gravity=0.05):
197
+ """Launch bursts that explode and fall."""
198
+ parts = []
199
+ buf = np.zeros((GRID_H, GRID_W, 3), np.float32)
200
+ while True:
201
+ buf *= 0.82
202
+ if np.random.rand() < 0.06 * speed or not parts:
203
+ cx, cy = np.random.uniform(8, 24), np.random.uniform(6, 18)
204
+ col = _solid(np.random.rand(), 1.0, 1.0)
205
+ n = np.random.randint(18, 30)
206
+ for k in range(n):
207
+ a = 2 * np.pi * k / n
208
+ sp = np.random.uniform(0.4, 1.2)
209
+ parts.append([cx, cy, np.cos(a) * sp, np.sin(a) * sp, 1.0, col])
210
+ alive = []
211
+ for p in parts:
212
+ p[0] += p[2] * speed
213
+ p[1] += p[3] * speed
214
+ p[3] += gravity * speed
215
+ p[4] -= 0.02 * speed
216
+ if p[4] <= 0:
217
+ continue
218
+ xi, yi = int(round(p[0])), int(round(p[1]))
219
+ if 0 <= xi < GRID_W and 0 <= yi < GRID_H:
220
+ buf[yi, xi] = np.array(p[5]) * p[4]
221
+ alive.append(p)
222
+ parts[:] = alive
223
+ yield buf.clip(0, 255).astype(np.uint8)
224
+
225
+
226
+ def aurora(speed=1.0):
227
+ """Northern-lights curtains, green through purple."""
228
+ t = 0.0
229
+ while True:
230
+ center = GRID_H * 0.5 + 6 * np.sin(_X * 0.3 + t) + 3 * np.sin(_X * 0.7 - t * 1.3)
231
+ val = np.clip(np.exp(-((_Y - center) ** 2) / 18.0), 0, 1)
232
+ hue = (0.33 + 0.18 * np.sin(_X * 0.2 + t * 0.5) + _Y * 0.012) % 1.0
233
+ yield hsv_to_rgb(hue, np.full_like(hue, 0.85), val)
234
+ t += 0.06 * speed
235
+
236
+
237
+ def comet(speed=1.0):
238
+ """A color-cycling comet orbiting with a fading tail."""
239
+ buf = np.zeros((GRID_H, GRID_W, 3), np.float32)
240
+ t = 0.0
241
+ while True:
242
+ buf *= 0.80
243
+ r = GRID_W * 0.32
244
+ x = GRID_W / 2 + r * np.cos(t)
245
+ y = GRID_H / 2 + r * np.sin(t)
246
+ xi, yi = int(round(x)), int(round(y))
247
+ if 0 <= xi < GRID_W and 0 <= yi < GRID_H:
248
+ buf[yi, xi] = _solid((t * 0.1) % 1, 1.0, 1.0)
249
+ yield buf.clip(0, 255).astype(np.uint8)
250
+ t += 0.15 * speed
251
+
252
+
253
+ def pinwheel(speed=1.0, arms=5):
254
+ """Rotating rainbow pinwheel."""
255
+ t = 0.0
256
+ while True:
257
+ val = 0.5 + 0.5 * np.sin(_ANG * arms + _R * 0.5 - t * 3)
258
+ hue = (_ANG / (2 * np.pi) + t * 0.1) % 1.0
259
+ yield hsv_to_rgb(hue, np.ones_like(hue), val ** 1.5)
260
+ t += 0.05 * speed
261
+
262
+
263
+ def bounce(speed=1.0):
264
+ """A bouncing ball with a motion trail."""
265
+ buf = np.zeros((GRID_H, GRID_W, 3), np.float32)
266
+ pos = np.array([16.0, 16.0])
267
+ vel = np.array([0.7, 0.5])
268
+ lo, hi = 4, 27
269
+ t = 0.0
270
+ while True:
271
+ buf *= 0.75
272
+ pos += vel * speed
273
+ for k in range(2):
274
+ if pos[k] < lo:
275
+ pos[k], vel[k] = lo, -vel[k]
276
+ if pos[k] > hi:
277
+ pos[k], vel[k] = hi, -vel[k]
278
+ buf[int(round(pos[1])), int(round(pos[0]))] = _solid((t * 0.02) % 1, 1.0, 1.0)
279
+ yield buf.clip(0, 255).astype(np.uint8)
280
+ t += speed
281
+
282
+
283
+ def interference(speed=1.0):
284
+ """Two moving wave sources rippling and interfering."""
285
+ t = 0.0
286
+ while True:
287
+ c1x, c1y = GRID_W / 2 + 10 * np.sin(t * 0.7), GRID_H / 2 + 10 * np.cos(t * 0.5)
288
+ c2x, c2y = GRID_W / 2 + 10 * np.sin(t * 0.4 + 2), GRID_H / 2 + 10 * np.cos(t * 0.6 + 1)
289
+ d1 = np.sqrt((_X - c1x) ** 2 + (_Y - c1y) ** 2)
290
+ d2 = np.sqrt((_X - c2x) ** 2 + (_Y - c2y) ** 2)
291
+ val = (np.sin(d1 - t * 3) + np.sin(d2 - t * 3)) / 2
292
+ hue = (val * 0.5 + 0.5 + t * 0.03) % 1.0
293
+ yield hsv_to_rgb(hue, np.ones_like(hue), val * 0.5 + 0.5)
294
+ t += 0.05 * speed
295
+
296
+
297
+ def twinkle(speed=1.0, n=45):
298
+ """Colorful stars fading in and out."""
299
+ leds = np.argwhere(MASK)
300
+ stars = []
301
+ while True:
302
+ if len(stars) < n and np.random.rand() < 0.6:
303
+ y, x = leds[np.random.randint(len(leds))]
304
+ stars.append([int(x), int(y), np.random.rand(), 0.0, 0.03 + np.random.rand() * 0.06])
305
+ frame = np.zeros((GRID_H, GRID_W, 3), np.uint8)
306
+ alive = []
307
+ for s in stars:
308
+ s[3] += s[4] * speed
309
+ if s[3] >= np.pi:
310
+ continue
311
+ frame[s[1], s[0]] = _solid(s[2], 0.6, np.sin(s[3]))
312
+ alive.append(s)
313
+ stars[:] = alive
314
+ yield frame
315
+
316
+
317
+ ANIMATIONS = {
318
+ "plasma": plasma,
319
+ "rainbow": rainbow,
320
+ "swirl": swirl,
321
+ "ripple": ripple,
322
+ "breathe": breathe,
323
+ "sparkle": sparkle,
324
+ "fire": fire,
325
+ "starfield": starfield,
326
+ "tunnel": tunnel,
327
+ "metaballs": metaballs,
328
+ "matrix": matrix,
329
+ "fireworks": fireworks,
330
+ "aurora": aurora,
331
+ "comet": comet,
332
+ "pinwheel": pinwheel,
333
+ "bounce": bounce,
334
+ "interference": interference,
335
+ "twinkle": twinkle,
336
+ "rubik": rubik,
337
+ }
cmpixel/canvas.py ADDED
@@ -0,0 +1,86 @@
1
+ """32x32 RGB canvas with the hexagon LED mask baked in."""
2
+ import json
3
+ import os
4
+
5
+ import numpy as np
6
+
7
+ GRID_W = GRID_H = 32
8
+
9
+ _LAYOUT = json.load(open(os.path.join(os.path.dirname(__file__), "layout.json")))
10
+ # boolean mask: True where a real LED exists
11
+ MASK = np.array(_LAYOUT, dtype=bool).reshape(GRID_H, GRID_W)
12
+ # (y, x) coords of every lit LED, handy for procedural effects
13
+ LED_YX = np.argwhere(MASK)
14
+
15
+
16
+ class Canvas:
17
+ """A (32, 32, 3) uint8 framebuffer. Pixels outside the hexagon are ignored
18
+ by the device, but `apply_mask()` can zero them for clean previews."""
19
+
20
+ def __init__(self, fill=(0, 0, 0)):
21
+ self.buf = np.zeros((GRID_H, GRID_W, 3), dtype=np.uint8)
22
+ if any(fill):
23
+ self.buf[:] = fill
24
+
25
+ # --- basics ---
26
+ def clear(self):
27
+ self.buf[:] = 0
28
+ return self
29
+
30
+ def fill(self, rgb):
31
+ self.buf[:] = rgb
32
+ return self
33
+
34
+ def set_pixel(self, x, y, rgb):
35
+ if 0 <= x < GRID_W and 0 <= y < GRID_H:
36
+ self.buf[y, x] = rgb
37
+ return self
38
+
39
+ def get_pixel(self, x, y):
40
+ return tuple(int(c) for c in self.buf[y, x])
41
+
42
+ # --- bulk ---
43
+ @classmethod
44
+ def from_array(cls, arr):
45
+ c = cls()
46
+ a = np.asarray(arr, dtype=np.uint8)
47
+ c.buf[:] = a[:GRID_H, :GRID_W, :3]
48
+ return c
49
+
50
+ @classmethod
51
+ def from_image(cls, img, fit="cover"):
52
+ """img: PIL.Image -> 32x32 Canvas. fit='cover' crops, 'stretch' distorts."""
53
+ from PIL import Image
54
+ img = img.convert("RGB")
55
+ if fit == "cover":
56
+ w, h = img.size
57
+ s = max(GRID_W / w, GRID_H / h)
58
+ img = img.resize((max(1, round(w * s)), max(1, round(h * s))), Image.LANCZOS)
59
+ w, h = img.size
60
+ left, top = (w - GRID_W) // 2, (h - GRID_H) // 2
61
+ img = img.crop((left, top, left + GRID_W, top + GRID_H))
62
+ else:
63
+ img = img.resize((GRID_W, GRID_H), Image.LANCZOS)
64
+ return cls.from_array(np.asarray(img))
65
+
66
+ def paste(self, arr, x=0, y=0):
67
+ """Blit a (h, w, 3) array at (x, y), clipped to the grid."""
68
+ a = np.asarray(arr, dtype=np.uint8)
69
+ h, w = a.shape[:2]
70
+ x0, y0 = max(0, x), max(0, y)
71
+ x1, y1 = min(GRID_W, x + w), min(GRID_H, y + h)
72
+ if x1 <= x0 or y1 <= y0:
73
+ return self
74
+ self.buf[y0:y1, x0:x1] = a[y0 - y:y1 - y, x0 - x:x1 - x, :3]
75
+ return self
76
+
77
+ def apply_mask(self):
78
+ self.buf[~MASK] = 0
79
+ return self
80
+
81
+ def to_image(self, scale=12):
82
+ from PIL import Image
83
+ im = Image.fromarray(self.buf, "RGB")
84
+ if scale != 1:
85
+ im = im.resize((GRID_W * scale, GRID_H * scale), Image.NEAREST)
86
+ return im
cmpixel/cli.py ADDED
@@ -0,0 +1,106 @@
1
+ """Command-line interface: cm-pixel <command> ...
2
+
3
+ Examples:
4
+ cm-pixel color 255 0 0
5
+ cm-pixel image photo.png
6
+ cm-pixel gif spinner.gif
7
+ cm-pixel text "HI" --color 0 255 0
8
+ cm-pixel scroll "HELLO WORLD"
9
+ cm-pixel anim plasma
10
+ cm-pixel list
11
+ """
12
+ import argparse
13
+ import itertools
14
+ import sys
15
+
16
+ from . import PixelDisplay
17
+ from .canvas import Canvas
18
+ from .animations import ANIMATIONS
19
+ from .font import draw_text, scroll_text
20
+ from . import media
21
+
22
+
23
+ def _common(p):
24
+ p.add_argument("--fps", type=float, default=None, help="frames per second")
25
+ p.add_argument("--duration", type=float, default=None, help="seconds to run, then stop")
26
+ p.add_argument("--mask", action="store_true", help="zero pixels outside the hexagon")
27
+ p.add_argument("--keep", action="store_true", help="don't clear the screen on exit")
28
+ p.add_argument("--brightness", type=int, default=100, help="0-100 (host-side scale)")
29
+ p.add_argument("--rotate", type=int, choices=[0, 90, 180, 270], default=0, help="rotate degrees")
30
+
31
+
32
+ def build_parser():
33
+ ap = argparse.ArgumentParser(prog="cm-pixel", description="Drive the Atmos V2 Pixel screen.")
34
+ sub = ap.add_subparsers(dest="cmd", required=True)
35
+
36
+ sp = sub.add_parser("list", help="list available animations")
37
+
38
+ sp = sub.add_parser("color", help="fill a solid color")
39
+ sp.add_argument("r", type=int); sp.add_argument("g", type=int); sp.add_argument("b", type=int)
40
+ _common(sp)
41
+
42
+ sp = sub.add_parser("image", help="show an image file")
43
+ sp.add_argument("path"); sp.add_argument("--fit", choices=["cover", "stretch"], default="cover")
44
+ _common(sp)
45
+
46
+ sp = sub.add_parser("gif", help="play a GIF")
47
+ sp.add_argument("path"); sp.add_argument("--fit", choices=["cover", "stretch"], default="cover")
48
+ _common(sp)
49
+
50
+ sp = sub.add_parser("text", help="show static text (centered)")
51
+ sp.add_argument("message"); sp.add_argument("--color", nargs=3, type=int, default=[255, 255, 255])
52
+ _common(sp)
53
+
54
+ sp = sub.add_parser("scroll", help="scroll text right-to-left")
55
+ sp.add_argument("message"); sp.add_argument("--color", nargs=3, type=int, default=[255, 255, 255])
56
+ _common(sp)
57
+
58
+ sp = sub.add_parser("anim", help="run a procedural animation")
59
+ sp.add_argument("name", choices=list(ANIMATIONS))
60
+ sp.add_argument("--speed", type=float, default=1.0)
61
+ _common(sp)
62
+
63
+ return ap
64
+
65
+
66
+ def main(argv=None):
67
+ args = build_parser().parse_args(argv)
68
+
69
+ if args.cmd == "list":
70
+ print("animations:", ", ".join(ANIMATIONS))
71
+ return 0
72
+
73
+ with PixelDisplay(brightness=getattr(args, "brightness", 100),
74
+ rotation=getattr(args, "rotate", 0)) as d:
75
+ clear = not getattr(args, "keep", False)
76
+ mask = getattr(args, "mask", False)
77
+ dur = getattr(args, "duration", None)
78
+
79
+ if args.cmd == "color":
80
+ c = Canvas().fill((args.r, args.g, args.b))
81
+ media.play(d, itertools.repeat(c), fps=args.fps or 5, duration=dur, mask=mask, clear_on_exit=clear)
82
+
83
+ elif args.cmd == "image":
84
+ c = media.load_image(args.path, fit=args.fit)
85
+ media.play(d, itertools.repeat(c), fps=args.fps or 5, duration=dur, mask=mask, clear_on_exit=clear)
86
+
87
+ elif args.cmd == "gif":
88
+ media.play(d, media.iter_gif(args.path, fit=args.fit), fps=args.fps, duration=dur, mask=mask, clear_on_exit=clear)
89
+
90
+ elif args.cmd == "text":
91
+ c = draw_text(Canvas(), args.message, color=tuple(args.color))
92
+ media.play(d, itertools.repeat(c), fps=args.fps or 5, duration=dur, mask=mask, clear_on_exit=clear)
93
+
94
+ elif args.cmd == "scroll":
95
+ gen = scroll_text(args.message, color=tuple(args.color))
96
+ media.play(d, gen, fps=args.fps or 15, duration=dur, mask=mask, clear_on_exit=clear)
97
+
98
+ elif args.cmd == "anim":
99
+ gen = ANIMATIONS[args.name](speed=args.speed)
100
+ media.play(d, gen, fps=args.fps or 30, duration=dur, mask=mask, clear_on_exit=clear)
101
+
102
+ return 0
103
+
104
+
105
+ if __name__ == "__main__":
106
+ sys.exit(main())
cmpixel/device.py ADDED
@@ -0,0 +1,129 @@
1
+ """Driver for the Cooler Master Atmos V2 'Pixel' hexagonal LED screen.
2
+
3
+ Protocol fully documented in PROTOCOL.md. Screen is a 32x32 grid with 556 real
4
+ LEDs in a hexagon; frames are streamed over HID interface 1 as 28 x 64-byte reports.
5
+ """
6
+ import json
7
+ import os
8
+ import time
9
+
10
+ import hid
11
+
12
+ VID = 0x2516
13
+ PID = 0x021C
14
+ FRAME_USAGE_PAGE = 0xFF01 # interface 1 = frame channel
15
+ GRID_W = GRID_H = 32
16
+ NLED = 556
17
+ PACKETS = 28
18
+ PAYLOAD = 60 # data bytes per report
19
+ MAGIC = (0x80, 0xDD)
20
+
21
+ _LAYOUT = json.load(open(os.path.join(os.path.dirname(__file__), "layout.json")))
22
+
23
+
24
+ class PixelDisplay:
25
+ """Connect to the screen and push 32x32 RGB frames."""
26
+
27
+ def __init__(self, layout=_LAYOUT, brightness=100, rotation=0):
28
+ self.layout = layout
29
+ self.dev = None
30
+ self.brightness = brightness # 0..100, host-side scale (device has no brightness cmd)
31
+ self.rotation = rotation # 0/90/180/270, host-side rotate
32
+ # precompute: led_index(0-based) -> grid cell (for fast image->stream)
33
+ self.cell_of_led = [0] * NLED
34
+ for cell, lamp in enumerate(layout):
35
+ if lamp:
36
+ self.cell_of_led[lamp - 1] = cell
37
+
38
+ # ---- connection ----
39
+ def open(self):
40
+ path = None
41
+ for d in hid.enumerate(VID, PID):
42
+ if d["usage_page"] == FRAME_USAGE_PAGE:
43
+ path = d["path"]
44
+ break
45
+ if path is None:
46
+ raise RuntimeError(
47
+ "Frame interface (usage_page 0xff01) not found. "
48
+ "Is the cooler connected and the CM software closed?"
49
+ )
50
+ self.dev = hid.device()
51
+ self.dev.open_path(path)
52
+ return self
53
+
54
+ def close(self):
55
+ if self.dev:
56
+ self.dev.close()
57
+ self.dev = None
58
+
59
+ def __enter__(self):
60
+ return self.open()
61
+
62
+ def __exit__(self, *exc):
63
+ self.close()
64
+
65
+ # ---- frame building ----
66
+ def _stream_from_grid(self, pixels):
67
+ """pixels: list of 1024 (r,g,b). Returns 1668-byte LED stream in index order."""
68
+ buf = bytearray(NLED * 3)
69
+ for led, cell in enumerate(self.cell_of_led):
70
+ r, g, b = pixels[cell]
71
+ o = led * 3
72
+ buf[o], buf[o + 1], buf[o + 2] = r & 0xFF, g & 0xFF, b & 0xFF
73
+ return bytes(buf)
74
+
75
+ def send_stream(self, led_stream):
76
+ """led_stream: 1668 bytes (556*RGB) in LED index order -> write 28 reports."""
77
+ if self.dev is None:
78
+ raise RuntimeError("not open")
79
+ data = bytes(led_stream).ljust(PACKETS * PAYLOAD, b"\x00") # pad to 1680
80
+ for i in range(PACKETS):
81
+ chunk = data[i * PAYLOAD:(i + 1) * PAYLOAD]
82
+ report = bytes([0x00, MAGIC[0], MAGIC[1], (i >> 8) & 0xFF, i & 0xFF]) + chunk
83
+ self.dev.write(report)
84
+
85
+ def send_grid(self, pixels):
86
+ """pixels: list of 1024 (r,g,b) tuples (row-major, 32 wide)."""
87
+ self.send_stream(self._stream_from_grid(pixels))
88
+
89
+ def send_array(self, arr):
90
+ """arr: numpy (32, 32, 3) uint8 (row-major). Applies rotation+brightness."""
91
+ import numpy as np
92
+ a = np.asarray(arr, dtype=np.uint8)
93
+ if self.rotation % 360:
94
+ a = np.rot90(a, k=(self.rotation // 90) % 4)
95
+ if self.brightness != 100:
96
+ a = (a.astype(np.uint16) * max(0, min(100, self.brightness)) // 100).astype(np.uint8)
97
+ stream = a.reshape(-1, 3)[self.cell_of_led] # (556, 3) in LED index order
98
+ self.send_stream(stream.tobytes())
99
+
100
+ def send_canvas(self, canvas):
101
+ """canvas: cmpixel.canvas.Canvas"""
102
+ self.send_array(canvas.buf)
103
+
104
+ def send_image(self, img):
105
+ """img: PIL Image; resized to 32x32 RGB and pushed."""
106
+ from PIL import Image
107
+ img = img.convert("RGB").resize((GRID_W, GRID_H), Image.NEAREST)
108
+ self.send_grid(list(img.getdata()))
109
+
110
+ def fill(self, rgb):
111
+ self.send_grid([tuple(rgb)] * (GRID_W * GRID_H))
112
+
113
+
114
+ def main():
115
+ import sys
116
+ color = (0, 80, 0)
117
+ with PixelDisplay() as d:
118
+ print("connected. filling green; Ctrl+C to stop.")
119
+ try:
120
+ while True:
121
+ d.fill(color)
122
+ time.sleep(0.1) # keep streaming ~10fps
123
+ except KeyboardInterrupt:
124
+ d.fill((0, 0, 0))
125
+ print("\ncleared.")
126
+
127
+
128
+ if __name__ == "__main__":
129
+ main()
cmpixel/font.py ADDED
@@ -0,0 +1,132 @@
1
+ """Tiny 5-pixel-tall variable-width bitmap font + text rendering helpers.
2
+
3
+ Glyphs are written as ASCII art ('#' = on) so they're trivial to read/extend.
4
+ Lowercase falls back to uppercase; unknown chars render as a blank box.
5
+ """
6
+ import numpy as np
7
+
8
+ from .canvas import Canvas, GRID_W, GRID_H
9
+
10
+ FONT_H = 5
11
+
12
+ _GLYPHS = {
13
+ " ": ["..", "..", "..", "..", ".."],
14
+ "0": ["###", "#.#", "#.#", "#.#", "###"],
15
+ "1": [".#.", "##.", ".#.", ".#.", "###"],
16
+ "2": ["###", "..#", "###", "#..", "###"],
17
+ "3": ["###", "..#", ".##", "..#", "###"],
18
+ "4": ["#.#", "#.#", "###", "..#", "..#"],
19
+ "5": ["###", "#..", "###", "..#", "###"],
20
+ "6": ["###", "#..", "###", "#.#", "###"],
21
+ "7": ["###", "..#", "..#", ".#.", ".#."],
22
+ "8": ["###", "#.#", "###", "#.#", "###"],
23
+ "9": ["###", "#.#", "###", "..#", "###"],
24
+ "A": [".#.", "#.#", "###", "#.#", "#.#"],
25
+ "B": ["##.", "#.#", "##.", "#.#", "##."],
26
+ "C": [".##", "#..", "#..", "#..", ".##"],
27
+ "D": ["##.", "#.#", "#.#", "#.#", "##."],
28
+ "E": ["###", "#..", "##.", "#..", "###"],
29
+ "F": ["###", "#..", "##.", "#..", "#.."],
30
+ "G": [".##", "#..", "#.#", "#.#", ".##"],
31
+ "H": ["#.#", "#.#", "###", "#.#", "#.#"],
32
+ "I": ["###", ".#.", ".#.", ".#.", "###"],
33
+ "J": ["..#", "..#", "..#", "#.#", "##."],
34
+ "K": ["#..#", "#.#.", "##..", "#.#.", "#..#"],
35
+ "L": ["#..", "#..", "#..", "#..", "###"],
36
+ "M": ["#...#", "##.##", "#.#.#", "#...#", "#...#"],
37
+ "N": ["#..#", "##.#", "#.##", "#..#", "#..#"],
38
+ "O": [".#.", "#.#", "#.#", "#.#", ".#."],
39
+ "P": ["##.", "#.#", "##.", "#..", "#.."],
40
+ "Q": [".#.", "#.#", "#.#", "#.#", ".##"],
41
+ "R": ["##.", "#.#", "##.", "#.#", "#.#"],
42
+ "S": [".##", "#..", ".#.", "..#", "##."],
43
+ "T": ["###", ".#.", ".#.", ".#.", ".#."],
44
+ "U": ["#.#", "#.#", "#.#", "#.#", "###"],
45
+ "V": ["#.#", "#.#", "#.#", "#.#", ".#."],
46
+ "W": ["#...#", "#...#", "#.#.#", "##.##", "#...#"],
47
+ "X": ["#.#", "#.#", ".#.", "#.#", "#.#"],
48
+ "Y": ["#.#", "#.#", ".#.", ".#.", ".#."],
49
+ "Z": ["###", "..#", ".#.", "#..", "###"],
50
+ ".": [".", ".", ".", ".", "#"],
51
+ ",": [".", ".", ".", "#", "#"],
52
+ ":": [".", "#", ".", "#", "."],
53
+ ";": [".", "#", ".", "#", "#"],
54
+ "-": ["...", "...", "###", "...", "..."],
55
+ "_": ["...", "...", "...", "...", "###"],
56
+ "+": ["...", ".#.", "###", ".#.", "..."],
57
+ "=": ["...", "###", "...", "###", "..."],
58
+ "/": ["..#", "..#", ".#.", "#..", "#.."],
59
+ "\\": ["#..", "#..", ".#.", "..#", "..#"],
60
+ "!": ["#", "#", "#", ".", "#"],
61
+ "?": ["###", "..#", ".##", "...", ".#."],
62
+ "'": ["#", "#", ".", ".", "."],
63
+ "\"": ["#.#", "#.#", "...", "...", "..."],
64
+ "(": [".#", "#.", "#.", "#.", ".#"],
65
+ ")": ["#.", ".#", ".#", ".#", "#."],
66
+ "*": ["#.#", ".#.", "###", ".#.", "#.#"],
67
+ "%": ["#.#", "..#", ".#.", "#..", "#.#"],
68
+ "#": [".#.#.", "#####", ".#.#.", "#####", ".#.#."],
69
+ "<": ["..#", ".#.", "#..", ".#.", "..#"],
70
+ ">": ["#..", ".#.", "..#", ".#.", "#.."],
71
+ "°": ["###", "#.#", "###", "...", "..."],
72
+ "@": ["###", "#.#", "#.#", "#..", "###"],
73
+ }
74
+ _UNKNOWN = ["###", "#.#", "#.#", "#.#", "###"]
75
+
76
+
77
+ def _glyph(ch):
78
+ return _GLYPHS.get(ch) or _GLYPHS.get(ch.upper()) or _UNKNOWN
79
+
80
+
81
+ def text_width(s, spacing=1):
82
+ if not s:
83
+ return 0
84
+ return sum(len(_glyph(c)[0]) for c in s) + spacing * (len(s) - 1)
85
+
86
+
87
+ def render_text(s, color=(255, 255, 255), bg=(0, 0, 0), spacing=1):
88
+ """Return an (5, width, 3) uint8 array of rendered text."""
89
+ w = max(1, text_width(s, spacing))
90
+ out = np.zeros((FONT_H, w, 3), dtype=np.uint8)
91
+ out[:] = bg
92
+ x = 0
93
+ for c in s:
94
+ g = _glyph(c)
95
+ gw = len(g[0])
96
+ for row in range(FONT_H):
97
+ for col in range(gw):
98
+ if g[row][col] == "#":
99
+ out[row, x + col] = color
100
+ x += gw + spacing
101
+ return out
102
+
103
+
104
+ def draw_text(canvas, s, x=0, y=None, color=(255, 255, 255), spacing=1):
105
+ """Draw text onto a Canvas. y defaults to vertically centered."""
106
+ img = render_text(s, color=color, spacing=spacing)
107
+ if y is None:
108
+ y = (GRID_H - FONT_H) // 2
109
+ # paste only the 'on' pixels (transparent bg)
110
+ h, w = img.shape[:2]
111
+ for ry in range(h):
112
+ for rx in range(w):
113
+ if img[ry, rx].any():
114
+ canvas.set_pixel(x + rx, y + ry, tuple(int(v) for v in img[ry, rx]))
115
+ return canvas
116
+
117
+
118
+ def scroll_text(s, color=(255, 255, 255), y=None, gap=GRID_W, spacing=1):
119
+ """Yield Canvas frames scrolling `s` right-to-left. Loops forever."""
120
+ img = render_text(s, color=color, spacing=spacing)
121
+ h, w = img.shape[:2]
122
+ if y is None:
123
+ y = (GRID_H - FONT_H) // 2
124
+ total = w + gap
125
+ pos = GRID_W
126
+ while True:
127
+ c = Canvas()
128
+ c.paste(img, x=pos, y=y)
129
+ yield c
130
+ pos -= 1
131
+ if pos < -w:
132
+ pos = gap
cmpixel/layout.json ADDED
@@ -0,0 +1 @@
1
+ [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10, 9, 8, 7, 6, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 34, 33, 32, 31, 30, 29, 28, 27, 26, 25, 24, 23, 22, 21, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 72, 71, 70, 69, 68, 67, 66, 65, 64, 63, 62, 61, 60, 59, 58, 57, 56, 55, 54, 53, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 0, 0, 0, 0, 0, 0, 0, 122, 121, 120, 119, 118, 117, 116, 115, 114, 113, 112, 111, 110, 109, 108, 107, 106, 105, 104, 103, 102, 101, 100, 99, 98, 97, 0, 0, 0, 0, 0, 0, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 0, 0, 0, 0, 0, 0, 174, 173, 172, 171, 170, 169, 168, 167, 166, 165, 164, 163, 162, 161, 160, 159, 158, 157, 156, 155, 154, 153, 152, 151, 150, 149, 0, 0, 0, 0, 0, 0, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 0, 0, 0, 0, 0, 0, 226, 225, 224, 223, 222, 221, 220, 219, 218, 217, 216, 215, 214, 213, 212, 211, 210, 209, 208, 207, 206, 205, 204, 203, 202, 201, 0, 0, 0, 0, 0, 0, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 0, 0, 0, 0, 0, 0, 278, 277, 276, 275, 274, 273, 272, 271, 270, 269, 268, 267, 266, 265, 264, 263, 262, 261, 260, 259, 258, 257, 256, 255, 254, 253, 0, 0, 0, 0, 0, 0, 279, 280, 281, 282, 283, 284, 285, 286, 287, 288, 289, 290, 291, 292, 293, 294, 295, 296, 297, 298, 299, 300, 301, 302, 303, 304, 0, 0, 0, 0, 0, 0, 330, 329, 328, 327, 326, 325, 324, 323, 322, 321, 320, 319, 318, 317, 316, 315, 314, 313, 312, 311, 310, 309, 308, 307, 306, 305, 0, 0, 0, 0, 0, 0, 331, 332, 333, 334, 335, 336, 337, 338, 339, 340, 341, 342, 343, 344, 345, 346, 347, 348, 349, 350, 351, 352, 353, 354, 355, 356, 0, 0, 0, 0, 0, 0, 382, 381, 380, 379, 378, 377, 376, 375, 374, 373, 372, 371, 370, 369, 368, 367, 366, 365, 364, 363, 362, 361, 360, 359, 358, 357, 0, 0, 0, 0, 0, 0, 383, 384, 385, 386, 387, 388, 389, 390, 391, 392, 393, 394, 395, 396, 397, 398, 399, 400, 401, 402, 403, 404, 405, 406, 407, 408, 0, 0, 0, 0, 0, 0, 434, 433, 432, 431, 430, 429, 428, 427, 426, 425, 424, 423, 422, 421, 420, 419, 418, 417, 416, 415, 414, 413, 412, 411, 410, 409, 0, 0, 0, 0, 0, 0, 435, 436, 437, 438, 439, 440, 441, 442, 443, 444, 445, 446, 447, 448, 449, 450, 451, 452, 453, 454, 455, 456, 457, 458, 459, 460, 0, 0, 0, 0, 0, 0, 0, 484, 483, 482, 481, 480, 479, 478, 477, 476, 475, 474, 473, 472, 471, 470, 469, 468, 467, 466, 465, 464, 463, 462, 461, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 485, 486, 487, 488, 489, 490, 491, 492, 493, 494, 495, 496, 497, 498, 499, 500, 501, 502, 503, 504, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 522, 521, 520, 519, 518, 517, 516, 515, 514, 513, 512, 511, 510, 509, 508, 507, 506, 505, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 523, 524, 525, 526, 527, 528, 529, 530, 531, 532, 533, 534, 535, 536, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 546, 545, 544, 543, 542, 541, 540, 539, 538, 537, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 547, 548, 549, 550, 551, 552, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 556, 555, 554, 553, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
cmpixel/media.py ADDED
@@ -0,0 +1,61 @@
1
+ """Image / GIF loading and the frame playback loop."""
2
+ import time
3
+
4
+ import numpy as np
5
+
6
+ from .canvas import Canvas, MASK
7
+
8
+
9
+ def _to_array(frame):
10
+ """Accept a Canvas or an ndarray, return (32,32,3) uint8."""
11
+ if isinstance(frame, Canvas):
12
+ return frame.buf
13
+ return np.asarray(frame, dtype=np.uint8)
14
+
15
+
16
+ def load_image(path, fit="cover"):
17
+ from PIL import Image
18
+ return Canvas.from_image(Image.open(path), fit=fit)
19
+
20
+
21
+ def iter_gif(path, fit="cover"):
22
+ """Yield (32,32,3) frames of a GIF forever, honoring per-frame timing.
23
+ Yields (array, duration_seconds)."""
24
+ from PIL import Image, ImageSequence
25
+ im = Image.open(path)
26
+ frames = []
27
+ for fr in ImageSequence.Iterator(im):
28
+ dur = fr.info.get("duration", 100) / 1000.0
29
+ frames.append((Canvas.from_image(fr.convert("RGB"), fit=fit).buf.copy(), max(0.02, dur)))
30
+ while True:
31
+ for f in frames:
32
+ yield f
33
+
34
+
35
+ def play(device, frames, fps=None, duration=None, mask=False, clear_on_exit=True):
36
+ """Drive the device from an iterable of frames (Canvas or ndarray).
37
+
38
+ frames may yield either `arr` or `(arr, frame_duration)` tuples (GIF).
39
+ fps overrides per-frame timing. duration = seconds to run (None = forever).
40
+ """
41
+ period = (1.0 / fps) if fps else None
42
+ t_end = (time.monotonic() + duration) if duration else None
43
+ try:
44
+ for fr in frames:
45
+ if isinstance(fr, tuple):
46
+ arr, dur = fr
47
+ else:
48
+ arr, dur = fr, period or 0.066
49
+ arr = _to_array(arr)
50
+ if mask:
51
+ arr = arr.copy()
52
+ arr[~MASK] = 0
53
+ device.send_array(arr)
54
+ time.sleep(period if period is not None else dur)
55
+ if t_end and time.monotonic() >= t_end:
56
+ break
57
+ except KeyboardInterrupt:
58
+ pass
59
+ finally:
60
+ if clear_on_exit:
61
+ device.fill((0, 0, 0))
cmpixel/rubik.py ADDED
@@ -0,0 +1,177 @@
1
+ """Self-solving isometric Rubik's cube — its hexagonal silhouette fits the panel.
2
+
3
+ A minimal 3x3 cube model with real layer turns, rendered with an isometric
4
+ projection + painter's algorithm. It scrambles, then solves itself, forever.
5
+ """
6
+ import numpy as np
7
+
8
+ from .canvas import GRID_W, GRID_H
9
+
10
+ # sticker color by the cubie face's outward normal (x, y, z)
11
+ _COLORS = {
12
+ (1, 0, 0): (0, 210, 40), # +x green
13
+ (-1, 0, 0): (0, 90, 255), # -x blue
14
+ (0, 1, 0): (245, 245, 245), # +y white (top)
15
+ (0, -1, 0): (255, 210, 0), # -y yellow
16
+ (0, 0, 1): (235, 0, 0), # +z red
17
+ (0, 0, -1): (255, 95, 0), # -z orange
18
+ }
19
+ _LIGHT = np.array([0.35, 1.0, 0.55])
20
+ _LIGHT /= np.linalg.norm(_LIGHT)
21
+
22
+
23
+ def _rot(axis, deg):
24
+ r = np.radians(deg)
25
+ c, s = np.cos(r), np.sin(r)
26
+ if axis == 0:
27
+ return np.array([[1, 0, 0], [0, c, -s], [0, s, c]])
28
+ if axis == 1:
29
+ return np.array([[c, 0, s], [0, 1, 0], [-s, 0, c]])
30
+ return np.array([[c, -s, 0], [s, c, 0], [0, 0, 1]])
31
+
32
+
33
+ # isometric view: corner-on so we see three faces (silhouette = hexagon)
34
+ _VIEW = _rot(0, 35.264) @ _rot(1, 45)
35
+ _SCALE = 5.6
36
+ _CX, _CY = (GRID_W - 1) / 2.0, (GRID_H - 1) / 2.0
37
+
38
+
39
+ class _Cube:
40
+ def __init__(self):
41
+ self.cubies = [] # each: [pos(int3), [[normal(int3), color], ...]]
42
+ for x in (-1, 0, 1):
43
+ for y in (-1, 0, 1):
44
+ for z in (-1, 0, 1):
45
+ if x == y == z == 0:
46
+ continue
47
+ pos = np.array([x, y, z])
48
+ st = []
49
+ for ax, n in ((0, x), (1, y), (2, z)):
50
+ if n:
51
+ nrm = np.zeros(3, int)
52
+ nrm[ax] = n
53
+ st.append([nrm, _COLORS[tuple(nrm)]])
54
+ self.cubies.append([pos, st])
55
+
56
+ def turn(self, axis, layer, direction):
57
+ R = np.rint(_rot(axis, 90 * direction)).astype(int)
58
+ for cub in self.cubies:
59
+ if cub[0][axis] == layer:
60
+ cub[0] = R @ cub[0]
61
+ for s in cub[1]:
62
+ s[0] = R @ s[0]
63
+
64
+
65
+ def _basis(normal):
66
+ """two in-plane unit axes for a face normal."""
67
+ ax = int(np.argmax(np.abs(normal)))
68
+ u = np.zeros(3); u[(ax + 1) % 3] = 1
69
+ v = np.zeros(3); v[(ax + 2) % 3] = 1
70
+ return u, v
71
+
72
+
73
+ def _inside(poly, px, py):
74
+ sign = 0
75
+ n = len(poly)
76
+ for i in range(n):
77
+ ax, ay = poly[i]
78
+ bx, by = poly[(i + 1) % n]
79
+ cross = (bx - ax) * (py - ay) - (by - ay) * (px - ax)
80
+ if cross != 0:
81
+ s = 1 if cross > 0 else -1
82
+ if sign == 0:
83
+ sign = s
84
+ elif s != sign:
85
+ return False
86
+ return True
87
+
88
+
89
+ def _fill(buf, pts, color):
90
+ xs = [p[0] for p in pts]; ys = [p[1] for p in pts]
91
+ x0 = max(0, int(np.floor(min(xs)))); x1 = min(GRID_W - 1, int(np.ceil(max(xs))))
92
+ y0 = max(0, int(np.floor(min(ys)))); y1 = min(GRID_H - 1, int(np.ceil(max(ys))))
93
+ for yy in range(y0, y1 + 1):
94
+ for xx in range(x0, x1 + 1):
95
+ if _inside(pts, xx + 0.5, yy + 0.5):
96
+ buf[yy, xx] = color
97
+
98
+
99
+ def _render(cube, moving_axis=None, moving_layer=None, anim_R=None):
100
+ buf = np.zeros((GRID_H, GRID_W, 3), np.uint8)
101
+ quads = []
102
+ for pos, stickers in cube.cubies:
103
+ in_layer = moving_axis is not None and pos[moving_axis] == moving_layer
104
+ for normal, color in stickers:
105
+ n = normal.astype(float)
106
+ center = pos.astype(float) + 0.5 * n
107
+ u, v = _basis(normal)
108
+ corners = [center + u * 0.5 + v * 0.5, center + u * 0.5 - v * 0.5,
109
+ center - u * 0.5 - v * 0.5, center - u * 0.5 + v * 0.5]
110
+ face_corners = [center + u * 0.42 + v * 0.42, center + u * 0.42 - v * 0.42,
111
+ center - u * 0.42 - v * 0.42, center - u * 0.42 + v * 0.42]
112
+ if in_layer and anim_R is not None:
113
+ n = anim_R @ n
114
+ corners = [anim_R @ c for c in corners]
115
+ face_corners = [anim_R @ c for c in face_corners]
116
+ nv = _VIEW @ n
117
+ if nv[2] <= 0.02: # back-face cull
118
+ continue
119
+ shade = 0.45 + 0.55 * max(0.0, float(np.dot(n / np.linalg.norm(n), _LIGHT)))
120
+ depth = 0.0
121
+ black_pts, col_pts = [], []
122
+ for c in corners:
123
+ cv = _VIEW @ c
124
+ depth += cv[2]
125
+ black_pts.append((_CX + cv[0] * _SCALE, _CY - cv[1] * _SCALE))
126
+ for c in face_corners:
127
+ cv = _VIEW @ c
128
+ col_pts.append((_CX + cv[0] * _SCALE, _CY - cv[1] * _SCALE))
129
+ col = tuple(int(c * shade) for c in color)
130
+ quads.append((depth, black_pts, col_pts, col))
131
+ quads.sort(key=lambda q: q[0]) # far first
132
+ for _, black_pts, col_pts, col in quads:
133
+ _fill(buf, black_pts, (8, 8, 8)) # black cube frame
134
+ _fill(buf, col_pts, col) # colored sticker
135
+ return buf
136
+
137
+
138
+ _AXES = (0, 1, 2)
139
+
140
+
141
+ def _scramble(n):
142
+ moves, last = [], None
143
+ for _ in range(n):
144
+ while True:
145
+ ax = _AXES[np.random.randint(3)]
146
+ layer = (-1, 1)[np.random.randint(2)]
147
+ if (ax, layer) != last:
148
+ break
149
+ moves.append((ax, layer, (-1, 1)[np.random.randint(2)]))
150
+ last = (ax, layer)
151
+ return moves
152
+
153
+
154
+ def rubik(speed=1.0, scramble_len=16):
155
+ """Self-solving isometric Rubik's cube."""
156
+ cube = _Cube()
157
+ queue = [] # list of (move, frames_per_move)
158
+ pause = 0
159
+ while True:
160
+ if pause > 0:
161
+ pause -= 1
162
+ yield _render(cube)
163
+ continue
164
+ if not queue:
165
+ scr = _scramble(scramble_len)
166
+ sol = [(a, l, -d) for (a, l, d) in reversed(scr)]
167
+ queue = [(m, 3) for m in scr] + [(m, 9) for m in sol]
168
+ move, fpm = queue.pop(0)
169
+ ax, layer, direction = move
170
+ steps = max(1, int(round(fpm / max(0.25, speed))))
171
+ for f in range(1, steps + 1):
172
+ theta = 90 * direction * f / steps
173
+ yield _render(cube, ax, layer, _rot(ax, theta))
174
+ cube.turn(ax, layer, direction)
175
+ if not queue: # just finished solving
176
+ pause = int(18 / max(0.25, speed))
177
+ yield _render(cube)