Skip to content

Instantly share code, notes, and snippets.

@cstenkamp
Created September 16, 2025 12:13
Show Gist options
  • Select an option

  • Save cstenkamp/dd5f321e16f1b7154811b97bdda2c311 to your computer and use it in GitHub Desktop.

Select an option

Save cstenkamp/dd5f321e16f1b7154811b97bdda2c311 to your computer and use it in GitHub Desktop.
This is some code to create a WLED LED-Map (the JSON that tells WLED the positions of the LEDs) with some features that I didn't find in any online editors: Dragging the mouse over the canvas fills all hovered cells, rescaling the map to a different size, inverting, and inserting LEDs in between existing ones.
import json
import sys
import math
import tkinter as tk
from tkinter import filedialog
from tkinter import font as tkfont
class MapperApp:
def __init__(self, width=30, height=20, cell=22, name="my_matrix"):
self.w, self.h, self.cell, self.name = width, height, max(1, cell), name
self.root = tk.Tk()
self.ctrl = tk.Frame(self.root)
self.ctrl.pack(fill="x")
tk.Label(self.ctrl, text="W").pack(side="left")
self.w_spin = tk.Spinbox(self.ctrl, from_=1, to=4096, width=4)
self.w_spin.pack(side="left")
tk.Label(self.ctrl, text="H").pack(side="left")
self.h_spin = tk.Spinbox(self.ctrl, from_=1, to=4096, width=4)
self.h_spin.pack(side="left")
tk.Label(self.ctrl, text="Cell px").pack(side="left", padx=(8,0))
self.c_spin = tk.Spinbox(self.ctrl, from_=1, to=128, width=4)
self.c_spin.pack(side="left")
tk.Button(self.ctrl, text="Apply size", command=self._apply_size).pack(side="left", padx=6)
tk.Button(self.ctrl, text="Invert X (X)", command=self._invert_x).pack(side="left", padx=(8,0))
tk.Button(self.ctrl, text="Invert Y (Y)", command=self._invert_y).pack(side="left")
tk.Button(self.ctrl, text="Undo (U)", command=self._undo).pack(side="left", padx=(8,0))
tk.Button(self.ctrl, text="Erase All (C)", command=self._clear).pack(side="left")
tk.Button(self.ctrl, text="Export (E)", command=self._export).pack(side="left", padx=(8,0))
tk.Button(self.ctrl, text="Import (I)", command=self._import).pack(side="left")
tk.Label(self.ctrl, text="Rescale to").pack(side="left", padx=(8,0))
self.rw_spin = tk.Spinbox(self.ctrl, from_=1, to=4096, width=4)
self.rh_spin = tk.Spinbox(self.ctrl, from_=1, to=4096, width=4)
self.rw_spin.pack(side="left"); self.rh_spin.pack(side="left")
tk.Button(self.ctrl, text="Rescale (R)", command=self._rescale).pack(side="left")
self.w_spin.delete(0,"end"); self.w_spin.insert(0, str(self.w))
self.h_spin.delete(0,"end"); self.h_spin.insert(0, str(self.h))
self.c_spin.delete(0,"end"); self.c_spin.insert(0, str(self.cell))
self.rw_spin.delete(0,"end"); self.rw_spin.insert(0, str(self.w))
self.rh_spin.delete(0,"end"); self.rh_spin.insert(0, str(self.h))
self.canvas = tk.Canvas(self.root, bg="white", highlightthickness=0)
self.canvas.pack()
self.order = []
self.pos2idx = {}
self.map = []
self.rect = []
self.font = None
self.pos2text = {}
self.anchor_idx = None
self.canvas.bind("<Button-1>", self._paint)
self.canvas.bind("<B1-Motion>", self._paint)
self.canvas.bind("<Button-3>", self._erase)
self.canvas.bind("<B3-Motion>", self._erase)
self.canvas.bind("<Button-2>", self._set_anchor)
for e, fn in [("e", self._export), ("u", self._undo), ("c", self._clear),
("x", self._invert_x), ("y", self._invert_y),
("i", self._import), ("r", self._rescale)]:
self.root.bind(f"<{e}>", fn); self.root.bind(f"<{e.upper()}>", fn)
self._build_grid()
self.run()
def _title(self):
self.root.title(f"LED mapper {self.w}x{self.h} - {self.name}")
def _evt_to_xy(self, event):
x, y = event.x // self.cell, event.y // self.cell
if 0 <= x < self.w and 0 <= y < self.h:
return int(x), int(y)
return None
def _idx(self, x, y):
return y * self.w + x
def _build_grid(self):
self._title()
self.canvas.config(width=self.w * self.cell, height=self.h * self.cell)
self.canvas.delete("all")
self.rect = []
self.pos2text.clear()
self.order, self.pos2idx = [], {}
self.map = [-1] * (self.w * self.h)
self.font = tkfont.Font(size=max(1, int(self.cell * 0.6)))
self.anchor_idx = None
for y in range(self.h):
for x in range(self.w):
x0, y0 = x * self.cell, y * self.cell
x1, y1 = x0 + self.cell, y0 + self.cell
r = self.canvas.create_rectangle(x0, y0, x1, y1, outline="#ddd", fill="white", width=1 if self.cell >= 3 else 0)
self.rect.append(r)
self.w_spin.delete(0,"end"); self.w_spin.insert(0, str(self.w))
self.h_spin.delete(0,"end"); self.h_spin.insert(0, str(self.h))
self.rw_spin.delete(0,"end"); self.rw_spin.insert(0, str(self.w))
self.rh_spin.delete(0,"end"); self.rh_spin.insert(0, str(self.h))
def _rebuild_indices(self):
self.pos2idx.clear()
self.map = [-1] * (self.w * self.h)
for i, (x, y) in enumerate(self.order):
self.pos2idx[(x, y)] = i
self.map[self._idx(x, y)] = i
self._recolor_all()
self._refresh_labels()
self._highlight_anchor()
def _recolor_all(self):
for r in self.rect:
self.canvas.itemconfig(r, fill="white")
for x, y in self.order:
self._color_cell(x, y, True)
def _refresh_labels(self):
for p, tid in list(self.pos2text.items()):
if p not in self.pos2idx:
self.canvas.delete(tid)
del self.pos2text[p]
for x, y in self.order:
i = self._idx(x, y)
self._ensure_label(x, y, str(self.map[i]))
def _ensure_label(self, x, y, text):
p = (x, y)
x0, y0 = x * self.cell, y * self.cell
cx, cy = x0 + self.cell // 2, y0 + self.cell // 2
if p in self.pos2text:
self.canvas.itemconfig(self.pos2text[p], text=text, font=self.font)
self.canvas.coords(self.pos2text[p], cx, cy)
else:
self.pos2text[p] = self.canvas.create_text(cx, cy, text=text, font=self.font)
def _color_cell(self, x, y, on):
i = self._idx(x, y)
self.canvas.itemconfig(self.rect[i], fill="#7fbfff" if on else "white")
def _highlight_anchor(self):
if self.anchor_idx is None or not self.order:
return
if 0 <= self.anchor_idx < len(self.order):
x, y = self.order[self.anchor_idx]
i = self._idx(x, y)
self.canvas.itemconfig(self.rect[i], fill="#ff6b6b")
def _paint(self, event):
p = self._evt_to_xy(event)
if not p:
return
if self.anchor_idx is not None:
if p in self.pos2idx:
return
ins = min(self.anchor_idx + 1, len(self.order))
self.order.insert(ins, p)
self.anchor_idx = None
self._rebuild_indices()
return
if p in self.pos2idx:
return
self.order.append(p)
self._rebuild_indices()
def _erase(self, event):
p = self._evt_to_xy(event)
if not p or p not in self.pos2idx:
return
i = self.pos2idx[p]
del self.order[i]
if self.anchor_idx is not None:
if i < self.anchor_idx:
self.anchor_idx -= 1
elif i == self.anchor_idx:
self.anchor_idx = None
if p in self.pos2text:
self.canvas.delete(self.pos2text[p])
del self.pos2text[p]
self._rebuild_indices()
def _set_anchor(self, event):
p = self._evt_to_xy(event)
if not p or p not in self.pos2idx:
return
self.anchor_idx = self.pos2idx[p]
self._rebuild_indices()
def _invert_x(self, event=None):
if not self.order:
return
self.order = [(self.w - 1 - x, y) for (x, y) in self.order]
self.anchor_idx = None
self._rebuild_indices()
def _invert_y(self, event=None):
if not self.order:
return
self.order = [(x, self.h - 1 - y) for (x, y) in self.order]
self.anchor_idx = None
self._rebuild_indices()
def _undo(self, event=None):
if not self.order:
return
p = self.order.pop()
if self.anchor_idx is not None and self.anchor_idx >= len(self.order):
self.anchor_idx = None
if p in self.pos2text:
self.canvas.delete(self.pos2text[p])
del self.pos2text[p]
self._rebuild_indices()
def _clear(self, event=None):
self.order.clear()
self.anchor_idx = None
for tid in self.pos2text.values():
self.canvas.delete(tid)
self.pos2text.clear()
self._rebuild_indices()
def _apply_size(self):
try:
self.w = max(1, int(self.w_spin.get()))
self.h = max(1, int(self.h_spin.get()))
self.cell = max(1, int(self.c_spin.get()))
except ValueError:
return
self.anchor_idx = None
self._build_grid()
def _export(self, event=None):
data = {"n": self.name, "width": self.w, "height": self.h, "map": self.map}
path = filedialog.asksaveasfilename(defaultextension=".json", initialfile=f"{self.name}.json", filetypes=[("JSON", "*.json")])
if not path:
return
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, separators=(",", ":"))
self._title()
def _import(self, event=None):
path = filedialog.askopenfilename(filetypes=[("JSON", "*.json"), ("All", "*.*")])
if not path:
return
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
except Exception:
return
w = data.get("width"); h = data.get("height"); m = data.get("map")
if not isinstance(w, int) or not isinstance(h, int) or not isinstance(m, list) or len(m) != w * h:
return
self.name = data.get("n", self.name)
self.w, self.h = w, h
self.anchor_idx = None
self._build_grid()
pairs = []
for y in range(h):
for x in range(w):
v = m[y * w + x]
if isinstance(v, int) and v >= 0:
pairs.append((v, (x, y)))
pairs.sort(key=lambda t: t[0])
self.order = [p for _, p in pairs]
self._rebuild_indices()
def _nearest_free(self, p, taken, w, h):
x, y = p
if 0 <= x < w and 0 <= y < h and (x, y) not in taken:
return (x, y)
for d in range(1, max(w, h) + 1):
for dx in range(-d, d + 1):
for dy in (-d, d):
xx, yy = x + dx, y + dy
if 0 <= xx < w and 0 <= yy < h and (xx, yy) not in taken:
return (xx, yy)
for dy in range(-d + 1, d):
for dx in (-d, d):
xx, yy = x + dx, y + dy
if 0 <= xx < w and 0 <= yy < h and (xx, yy) not in taken:
return (xx, yy)
return None
def _rescale(self, event=None):
try:
nw = max(1, int(self.rw_spin.get()))
nh = max(1, int(self.rh_spin.get()))
except ValueError:
return
if nw == self.w and nh == self.h:
return
if not self.order:
self.w, self.h = nw, nh
self.anchor_idx = None
self._build_grid()
return
ow, oh = self.w, self.h
sx = (nw - 1) / (ow - 1) if ow > 1 and nw > 1 else 1.0
sy = (nh - 1) / (oh - 1) if oh > 1 and nh > 1 else 1.0
s_len = math.sqrt(max(0.0, sx * sy))
N = len(self.order)
target = max(0, min(nw * nh, int(round(N * s_len))))
if target == 0:
self.w, self.h = nw, nh
self.anchor_idx = None
self._build_grid()
return
pts = []
for x, y in self.order:
fx = 0.0 if ow == 1 else x * (nw - 1) / (ow - 1)
fy = 0.0 if oh == 1 else y * (nh - 1) / (oh - 1)
pts.append((fx, fy))
seg = []
total = 0.0
for i in range(len(pts) - 1):
x0, y0 = pts[i]
x1, y1 = pts[i + 1]
d = math.hypot(x1 - x0, y1 - y0)
seg.append(d)
total += d
if total == 0.0:
idxs = [int(round(i * (N - 1) / max(1, target - 1))) for i in range(target)]
samples = [pts[k] for k in idxs]
else:
step = total / max(1, target - 1)
samples = []
acc = 0.0
i = 0
x0, y0 = pts[0]
for n in range(target):
t = n * step
while i < len(seg) and t > acc + seg[i]:
acc += seg[i]
i += 1
if i + 1 < len(pts):
x0, y0 = pts[i]
if i >= len(seg):
samples.append(pts[-1])
else:
ratio = 0.0 if seg[i] == 0 else (t - acc) / seg[i]
x1, y1 = pts[i + 1]
samples.append((x0 + (x1 - x0) * ratio, y0 + (y1 - y0) * ratio))
taken = set()
new_order = []
for fx, fy in samples:
px = int(round(fx))
py = int(round(fy))
q = self._nearest_free((px, py), taken, nw, nh)
if q is None:
continue
taken.add(q)
new_order.append(q)
self.w, self.h = nw, nh
self.anchor_idx = None
self._build_grid()
self.order = new_order
self._rebuild_indices()
def run(self):
self._title()
self.root.mainloop()
if __name__ == "__main__":
w, h, cell, name = 30, 20, 22, "my_matrix"
if len(sys.argv) >= 3:
w, h = int(sys.argv[1]), int(sys.argv[2])
if len(sys.argv) >= 4:
cell = int(sys.argv[3])
if len(sys.argv) >= 5:
name = sys.argv[4]
MapperApp(w, h, cell, name)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment