From 6eb6cec170ea49825008a6cc779462481373375b Mon Sep 17 00:00:00 2001 From: T Date: Wed, 23 Jul 2025 09:36:20 +0200 Subject: [PATCH] Live Painting Mode --- config.py | 2 + html/draw.html | 465 +++++++++++++++++++++++++++++++++++++++++++++++ main.py | 123 ++++++++++++- requirements.txt | 3 +- 4 files changed, 584 insertions(+), 9 deletions(-) create mode 100644 html/draw.html diff --git a/config.py b/config.py index 5f03917..5f1e686 100644 --- a/config.py +++ b/config.py @@ -97,4 +97,6 @@ Apps = [ # App(guiname="Snake", name="snake", cmd="./snake.py"), # App(name="gif", cmd="./gif.sh"), # App(name="colormap", cmd="./colormap.py"), + + AppConfig(guiname="Pixel Canvas", name="pixelcanvas", cmd=""), ] diff --git a/html/draw.html b/html/draw.html new file mode 100644 index 0000000..e56568e --- /dev/null +++ b/html/draw.html @@ -0,0 +1,465 @@ + + + + + + Pixel Art Canvas + + + + +
+
+

Pixel Art Canvas

+

Click and drag to paint. Your changes are live.

+
+ +
+
+ +
+ +
+
+ + +
+
+ +
+
+ +
+
+ + + + diff --git a/main.py b/main.py index c36f390..6a90cb6 100755 --- a/main.py +++ b/main.py @@ -12,6 +12,11 @@ from collections import OrderedDict import bottle import numpy as np import serial +# noinspection PyUnresolvedReferences +import bottle.ext.websocket as bottle_ws +# noinspection PyUnresolvedReferences +from bottle.ext.websocket import GeventWebSocketServer +import geventwebsocket.websocket import config import filters @@ -27,7 +32,7 @@ class DataSource: self.data = initial self.listeners = [] - def getData(self): + def getData(self) -> "Frame": return self.data def addListener(self, listener): @@ -89,7 +94,7 @@ class LogReader(threading.Thread): class Frame: def __init__(self, buffer, channels=3): - self.buffer = buffer + self.buffer: np.ndarray = buffer self.created = time.time() self.channels = channels @@ -223,13 +228,14 @@ class SerialWriter(threading.Thread): # App # ######################################################################## class App(threading.Thread): - def __init__(self, cmd, param, listener, is_persistent, is_white=False, path="."): + def __init__(self, name, cmd, param, listener, is_persistent, is_white=False, path="."): super().__init__(daemon=True) # start app + self.name = name args = cmd + [str(config.ScreenX), str(config.ScreenY), param] self.app = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, close_fds=True, cwd=path) self.last_update = time.time() - self.cv = threading.Condition() + # self.cv = threading.Condition() self.watchdog = WatchDog(lambda: self.isAppTimedOut(), lambda: self.terminateApp()) self.watchdog.start() self.logreader = LogReader(self) @@ -253,8 +259,8 @@ class App(threading.Thread): frame = Frame(buffer, channels=channels) self.last_update = time.time() self.datasource.pushData(frame) - except: - logging.debug("Exception in App.run") + except Exception as ex: + logging.debug(f"Exception in App.run: {ex}") with self.listener: self.listener.notify_all() self.watchdog.stop() @@ -288,6 +294,35 @@ class App(threading.Thread): return time.time() - self.last_update > config.NoDataTimeout +class PixelCanvas(App): + # noinspection PyMissingConstructor + def __init__(self): + threading.Thread.__init__(self, daemon=True) + self.name = "pixelcanvas" + self.running = True + self.is_persistent = True + self.datasource = DataSource(Frame(np.zeros((config.ScreenY, config.ScreenX, 3)))) + + def run(self): + while running and self.running: + time.sleep(1) + + def alive(self): + return True + + def stop(self): + pass + + def getLog(self): + return "" + + def terminateApp(self): + pass + + def isAppTimedOut(self): + return False + + ######################################################################## # Main # ######################################################################## @@ -322,7 +357,10 @@ class AppRunner(threading.Thread): def startApp(self, i, param=""): app = config.Apps[i] - newapp = App(app.cmd, param, self.cv, is_persistent=app.persistent, is_white=app.white, path=app.path) + if app.name == "pixelcanvas": + newapp = PixelCanvas() + else: + newapp = App(app.name, app.cmd, param, self.cv, is_persistent=app.persistent, is_white=app.white, path=app.path) newapp.datasource.addListener(self.cv) newapp.start() if app.persistent: @@ -451,6 +489,75 @@ def apps_running(): return config.Apps[runner.currentApp].name +@bottle.route("/frame") +def frame(): + data = runner.datasource.getData() + return {"data": data.buffer.flatten().tolist(), "channels": data.channels} + + +@bottle.route("/pixel//////") +def pixel(x, y, r, g, b, w): + if runner.app.name != "pixelcanvas": + startApp("pixelcanvas") + + data = runner.app.datasource.getData().clone() + data.created = time.time() + data.buffer[y][x][0] = r + data.buffer[y][x][1] = g + data.buffer[y][x][2] = b + if data.channels == 4: + data.buffer[y][x][3] = w + runner.datasource.pushData(data) + return "ok" + + +@bottle.get("/frame_ws", apply=[bottle_ws.websocket]) +def frame_ws(ws: geventwebsocket.websocket.WebSocket): + while not ws.closed: + msg = json.loads(ws.receive()) + + if msg["ty"] == "frame": + data = runner.datasource.getData() + if msg.get("time", None) == str(data.created): + ws.send(json.dumps({ + "type": "frame_unchanged", + }, separators=(',', ':'))) + else: + ws.send(json.dumps({ + "ty": "frame", + "data": data.buffer.flatten().tolist(), + "channels": data.channels, + "time": str(data.created), + }, separators=(',', ':'))) + elif msg["ty"] == "pixel": + if runner.app.name != "pixelcanvas": + startApp("pixelcanvas") + + x = msg["x"] + y = msg["y"] + + data = runner.app.datasource.getData().clone() + data.created = time.time() + data.buffer[y, x, 0] = msg["r"] + data.buffer[y, x, 1] = msg["g"] + data.buffer[y, x, 2] = msg["b"] + if data.channels == 4 and "w" in msg: + data.buffer[y, x, 3] = msg["w"] + runner.app.datasource.pushData(data) + elif msg["ty"] == "fill": + if runner.app.name != "pixelcanvas": + startApp("pixelcanvas") + + data = runner.app.datasource.getData().clone() + data.created = time.time() + data.buffer[..., 0] = msg["r"] + data.buffer[..., 1] = msg["g"] + data.buffer[..., 2] = msg["b"] + if data.channels == 4 and "w" in msg: + data.buffer[..., 3] = msg["w"] + runner.app.datasource.pushData(data) + + @bottle.route("/") def index(): return bottle.static_file("index.html", root='html') @@ -523,7 +630,7 @@ def main(): runner.start() # runner.setFilter("5_crazy", MakeBrightnessExprFilter("0.5+0.25*sin(x/3)/x")) - bottle.run(host=config.WebHost, port=config.WebPort) + bottle.run(host=config.WebHost, port=config.WebPort, server=GeventWebSocketServer) ######################################################################## # Shutdown # diff --git a/requirements.txt b/requirements.txt index d1fb964..606acd1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,8 @@ bottle +bottle-websocket numpy scipy pygame pyserial paho-mqtt -pillow \ No newline at end of file +pillow