Created
September 16, 2025 12:13
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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