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.
- cm_pixel-0.1.0.dist-info/METADATA +99 -0
- cm_pixel-0.1.0.dist-info/RECORD +15 -0
- cm_pixel-0.1.0.dist-info/WHEEL +5 -0
- cm_pixel-0.1.0.dist-info/entry_points.txt +2 -0
- cm_pixel-0.1.0.dist-info/licenses/LICENSE +21 -0
- cm_pixel-0.1.0.dist-info/top_level.txt +1 -0
- cmpixel/__init__.py +11 -0
- cmpixel/animations.py +337 -0
- cmpixel/canvas.py +86 -0
- cmpixel/cli.py +106 -0
- cmpixel/device.py +129 -0
- cmpixel/font.py +132 -0
- cmpixel/layout.json +1 -0
- cmpixel/media.py +61 -0
- cmpixel/rubik.py +177 -0
|
@@ -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,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)
|