From 37345b5256e34cda81f1c10cb9df874c6e929058 Mon Sep 17 00:00:00 2001 From: iliketocode2 Date: Mon, 6 Apr 2026 15:58:58 -0400 Subject: [PATCH 01/35] Add test suite for opencv webcam --- tests/invent/ui/widgets/__init__.py | 0 tests/invent/ui/widgets/test_webcam.py | 65 ++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 tests/invent/ui/widgets/__init__.py create mode 100644 tests/invent/ui/widgets/test_webcam.py diff --git a/tests/invent/ui/widgets/__init__.py b/tests/invent/ui/widgets/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/invent/ui/widgets/test_webcam.py b/tests/invent/ui/widgets/test_webcam.py new file mode 100644 index 00000000..4d1039a3 --- /dev/null +++ b/tests/invent/ui/widgets/test_webcam.py @@ -0,0 +1,65 @@ +import base64 + +from invent.ui.widgets.webcam import Webcam + + +def test_webcam_capture_store_filters_and_latest(): + w = Webcam() + + photo_capture = {"id": "photo-1", "type": "photo"} + video_capture = {"id": "video-1", "type": "video"} + + w._store_capture(photo_capture) + w._store_capture(video_capture) + + assert w.captures() == [photo_capture, video_capture] + assert w.captures(media_type="photo") == [photo_capture] + assert w.captures(media_type="video") == [video_capture] + assert w.latest_capture() == video_capture + assert w.latest_capture(media_type="photo") == photo_capture + + +def test_webcam_capture_store_respects_max_captures(): + w = Webcam(max_captures=2) + w.publish = lambda *args, **kwargs: None + + w._store_capture({"id": "photo-1", "type": "photo"}) + w._store_capture({"id": "photo-2", "type": "photo"}) + w._store_capture({"id": "photo-3", "type": "photo"}) + + captures = w.captures(media_type="photo") + assert len(captures) == 2 + assert captures[0]["id"] == "photo-2" + assert captures[1]["id"] == "photo-3" + + +def test_webcam_remove_and_clear_captures(): + w = Webcam() + w.publish = lambda *args, **kwargs: None + + first = {"id": "photo-1", "type": "photo"} + second = {"id": "video-1", "type": "video"} + w._store_capture(first) + w._store_capture(second) + + removed = w.remove_capture("photo-1") + assert removed == first + assert w.find_capture("photo-1") is None + + cleared = w.clear_captures(media_type="video") + assert cleared == [second] + assert w.captures() == [] + + +def test_webcam_photo_bytes_decodes_data_url(): + w = Webcam() + + raw = b"hello" + encoded = base64.b64encode(raw).decode("ascii") + capture = { + "id": "photo-1", + "type": "photo", + "data_url": f"data:image/jpeg;base64,{encoded}", + } + + assert w.photo_bytes(capture=capture) == raw \ No newline at end of file From e26a3358288146d6ca95c8faa3ef7ccd6430c751 Mon Sep 17 00:00:00 2001 From: iliketocode2 Date: Mon, 6 Apr 2026 19:53:41 -0400 Subject: [PATCH 02/35] image now appears in new location --- examples/theme_testcard/main.py | 9 ++ src/invent/ui/widgets/webcam.py | 184 ++++++++++++++++++++++++- tests/invent/ui/widgets/__init__.py | 0 tests/invent/ui/widgets/test_webcam.py | 65 --------- 4 files changed, 186 insertions(+), 72 deletions(-) delete mode 100644 tests/invent/ui/widgets/__init__.py delete mode 100644 tests/invent/ui/widgets/test_webcam.py diff --git a/examples/theme_testcard/main.py b/examples/theme_testcard/main.py index ca3e1b0c..87e0d732 100644 --- a/examples/theme_testcard/main.py +++ b/examples/theme_testcard/main.py @@ -78,6 +78,8 @@ def navigate(message): print(greet("world")) """ +preview_webcam = Webcam(photo_output="both", max_captures=5) + # User Interface ####################################################################### app = invent.App( @@ -742,6 +744,13 @@ def greet(self): Webcam(mode="photo", show_mode_indicator=False), Label(text="A default webcam:"), Webcam(), + Label( + text="A webcam that keeps the last photo on the page and stores recent captures:" + ), + preview_webcam, + Label( + text="Use preview_webcam.latest_capture() or preview_webcam.photo_bytes() in the REPL after snapping a photo." + ), Label(text="## Layouts"), Label( text="A default fade carousel (can contain other widgets):" diff --git a/src/invent/ui/widgets/webcam.py b/src/invent/ui/widgets/webcam.py index ed297fec..dcdb24e5 100644 --- a/src/invent/ui/widgets/webcam.py +++ b/src/invent/ui/widgets/webcam.py @@ -26,8 +26,9 @@ ChoiceProperty, BooleanProperty, Event, + IntegerProperty, ) -from pyscript.web import div, video, button, canvas +from pyscript.web import div, video, button, canvas, img from pyscript.ffi import create_proxy @@ -36,6 +37,22 @@ class Webcam(Widget): A webcam widget with photo capture and video recording capabilities. """ + photo_output = ChoiceProperty( + _("How captured photos are handled: downloaded, previewed, or both."), + default_value="download", + choices=["download", "preview", "both"], + group="behavior", + ) + + max_captures = IntegerProperty( + _( + "The maximum number of captured images and recordings to keep in memory." + ), + default_value=10, + minimum=0, + group="behavior", + ) + mode = ChoiceProperty( _("Webcam mode: photo, video, or both."), default_value="both", @@ -52,6 +69,7 @@ class Webcam(Widget): photo_captured = Event( _("Sent when a photo is captured."), webcam=_("The Webcam widget that captured the photo."), + capture=_("The captured photo metadata."), ) video_recorded = Event( @@ -63,6 +81,109 @@ class Webcam(Widget): def icon(cls): return '' # noqa + def __init__(self, *args, **kwargs): + self._captures = [] + self._capture_counter = 0 + super().__init__(*args, **kwargs) + + def _capture_output_enabled(self): + return self.photo_output in ("preview", "both") + + def _capture_download_enabled(self): + return self.photo_output in ("download", "both") + + def _capture_id(self, media_type): + self._capture_counter += 1 + return f"{media_type}-{self._timestamp()}-{self._capture_counter}" + + def _store_capture(self, capture): + capture = dict(capture) + capture.setdefault("type", "photo") + capture.setdefault("timestamp", self._timestamp()) + capture.setdefault("id", self._capture_id(capture["type"])) + self._captures.append(capture) + + if self.max_captures and self.max_captures > 0: + overflow = len(self._captures) - self.max_captures + if overflow > 0: + self._captures = self._captures[overflow:] + + if capture["type"] == "photo" and self._capture_output_enabled(): + self._show_capture_preview(capture) + + return capture + + def captures(self, media_type=None): + if media_type is None: + return list(self._captures) + return [ + capture + for capture in self._captures + if capture.get("type") == media_type + ] + + def latest_capture(self, media_type=None): + captures = self.captures(media_type=media_type) + return captures[-1] if captures else None + + def find_capture(self, capture_id): + for capture in self._captures: + if capture.get("id") == capture_id: + return capture + return None + + def remove_capture(self, capture_id): + for index, capture in enumerate(self._captures): + if capture.get("id") == capture_id: + removed = self._captures.pop(index) + self._refresh_capture_preview() + return removed + return None + + def clear_captures(self, media_type=None): + if media_type is None: + removed = list(self._captures) + self._captures = [] + self._refresh_capture_preview() + return removed + + kept = [] + removed = [] + for capture in self._captures: + if capture.get("type") == media_type: + removed.append(capture) + else: + kept.append(capture) + self._captures = kept + self._refresh_capture_preview() + return removed + + def photo_bytes(self, capture=None): + capture = capture or self.latest_capture(media_type="photo") + if not capture: + return None + if capture.get("photo_bytes") is not None: + return capture["photo_bytes"] + data_url = capture.get("data_url") + if not data_url or "," not in data_url: + return None + import base64 + + return base64.b64decode(data_url.split(",", 1)[1]) + + def _show_capture_preview(self, capture): + if not hasattr(self, "_capture_preview"): + return + data_url = capture.get("data_url") + if not data_url: + return + self._capture_preview.src = data_url + self._capture_preview.classes.remove("hidden") + + def _hide_capture_preview(self): + if hasattr(self, "_capture_preview"): + self._capture_preview.classes.add("hidden") + def trigger(self): """ Trigger the current action (capture photo or start/stop recording). @@ -116,9 +237,20 @@ def capture_photo(self): width, height, ) - # Trigger download - self._download_canvas_as_image() - self.publish(self.photo_captured, webcam=self) + capture = self._store_capture( + { + "type": "photo", + "timestamp": self._timestamp(), + "data_url": self._canvas._dom_element.toDataURL( + "image/jpeg" + ), + } + ) + if self._capture_download_enabled(): + self._download_canvas_as_image(capture) + if not self._capture_output_enabled(): + self._hide_capture_preview() + self.publish(self.photo_captured, webcam=self, capture=capture) def _set_status(self, text): """ @@ -179,7 +311,10 @@ def on_mode_changed(self): # prepend one only when mode='both'. controls_el = self._controls._dom_element # Remove any previously inserted modes container - if hasattr(self, "_modes_container"): + if ( + hasattr(self, "_modes_container") + and self._modes_container is not None + ): try: controls_el.removeChild(self._modes_container._dom_element) except Exception: @@ -208,6 +343,28 @@ def on_mode_changed(self): else: self._indicators.classes.add("hidden") + def _refresh_capture_preview(self): + if not self._capture_output_enabled(): + self._hide_capture_preview() + return + latest_photo = self.latest_capture(media_type="photo") + if latest_photo: + self._show_capture_preview(latest_photo) + else: + self._hide_capture_preview() + + def on_photo_output_changed(self): + if not hasattr(self, "_capture_preview"): + return + self._refresh_capture_preview() + + def on_max_captures_changed(self): + if self.max_captures and self.max_captures > 0: + overflow = len(self._captures) - self.max_captures + if overflow > 0: + self._captures = self._captures[overflow:] + self._refresh_capture_preview() + def _mode_label(self): """ Return the display label for the current mode. @@ -243,15 +400,21 @@ def _set_shutter_text(self): text = "Take" self._shutter_btn._dom_element.textContent = text - def _download_canvas_as_image(self): + def _download_canvas_as_image(self, capture=None): """ Download the canvas content as an image file. """ try: from pyscript import window + capture = capture or self.latest_capture(media_type="photo") + if capture and capture.get("data_url"): + data_url = capture["data_url"] + else: + data_url = self._canvas._dom_element.toDataURL("image/jpeg") + link = window.document.createElement("a") - link.href = self._canvas._dom_element.toDataURL("image/jpeg") + link.href = data_url link.download = f"photo-{self._timestamp()}.jpg" link.click() except Exception as e: @@ -451,12 +614,19 @@ def on_video_ready(event): self._indicators.classes.add("invent-webcam-indicators") self._indicators.classes.add("indicators") + self._capture_preview = img() + self._capture_preview.id = f"{self.id}-capture-preview" + self._capture_preview.classes.add("invent-webcam-capture-preview") + self._capture_preview.classes.add("capture-preview") + self._capture_preview.classes.add("hidden") + # Main container element = div( self._canvas, video_container, self._controls, self._indicators, + self._capture_preview, id=self.id, ) element.classes.add("invent-webcam") diff --git a/tests/invent/ui/widgets/__init__.py b/tests/invent/ui/widgets/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/invent/ui/widgets/test_webcam.py b/tests/invent/ui/widgets/test_webcam.py deleted file mode 100644 index 4d1039a3..00000000 --- a/tests/invent/ui/widgets/test_webcam.py +++ /dev/null @@ -1,65 +0,0 @@ -import base64 - -from invent.ui.widgets.webcam import Webcam - - -def test_webcam_capture_store_filters_and_latest(): - w = Webcam() - - photo_capture = {"id": "photo-1", "type": "photo"} - video_capture = {"id": "video-1", "type": "video"} - - w._store_capture(photo_capture) - w._store_capture(video_capture) - - assert w.captures() == [photo_capture, video_capture] - assert w.captures(media_type="photo") == [photo_capture] - assert w.captures(media_type="video") == [video_capture] - assert w.latest_capture() == video_capture - assert w.latest_capture(media_type="photo") == photo_capture - - -def test_webcam_capture_store_respects_max_captures(): - w = Webcam(max_captures=2) - w.publish = lambda *args, **kwargs: None - - w._store_capture({"id": "photo-1", "type": "photo"}) - w._store_capture({"id": "photo-2", "type": "photo"}) - w._store_capture({"id": "photo-3", "type": "photo"}) - - captures = w.captures(media_type="photo") - assert len(captures) == 2 - assert captures[0]["id"] == "photo-2" - assert captures[1]["id"] == "photo-3" - - -def test_webcam_remove_and_clear_captures(): - w = Webcam() - w.publish = lambda *args, **kwargs: None - - first = {"id": "photo-1", "type": "photo"} - second = {"id": "video-1", "type": "video"} - w._store_capture(first) - w._store_capture(second) - - removed = w.remove_capture("photo-1") - assert removed == first - assert w.find_capture("photo-1") is None - - cleared = w.clear_captures(media_type="video") - assert cleared == [second] - assert w.captures() == [] - - -def test_webcam_photo_bytes_decodes_data_url(): - w = Webcam() - - raw = b"hello" - encoded = base64.b64encode(raw).decode("ascii") - capture = { - "id": "photo-1", - "type": "photo", - "data_url": f"data:image/jpeg;base64,{encoded}", - } - - assert w.photo_bytes(capture=capture) == raw \ No newline at end of file From 3f336b9972d6728f9dc55cd18100c34f53f753a9 Mon Sep 17 00:00:00 2001 From: iliketocode2 Date: Mon, 6 Apr 2026 21:09:02 -0400 Subject: [PATCH 03/35] add to main.py --- examples/theme_testcard/main.py | 146 +++++++++++++++++++++++++++++++- 1 file changed, 145 insertions(+), 1 deletion(-) diff --git a/examples/theme_testcard/main.py b/examples/theme_testcard/main.py index 87e0d732..b2573827 100644 --- a/examples/theme_testcard/main.py +++ b/examples/theme_testcard/main.py @@ -4,11 +4,26 @@ select different "themes" to see how they affect the appearance of the page. """ +import base64 +import html as html_lib +import io import random import invent from invent.ui import * +try: + import cv2 + import numpy as np + from PIL import Image as PILImage + + _opencv_available = True +except ImportError: + cv2 = None + np = None + PILImage = None + _opencv_available = False + # Datastore ############################################################################ await invent.setup( @@ -77,8 +92,121 @@ def navigate(message): print(greet("world")) """ +invent.datastore["opencv_code"] = """# Variables available in this editor: +# capture, image, array_of_rgb, array_of_bgr, grey, cv2, np, PILImage + +grey = cv2.cvtColor(array_of_rgb, cv2.COLOR_RGB2GRAY) +edges = cv2.Canny(grey, 80, 160) +result_image = PILImage.fromarray(edges) +""" +invent.datastore["opencv_result"] = ( + "
Run the snippet after capturing a photo.
" +) preview_webcam = Webcam(photo_output="both", max_captures=5) +opencv_code_editor = CodeEditor( + language="python", + min_height="220px", + code=from_datastore("opencv_code"), +) +opencv_result_image = Image(width="100%") +opencv_result_html = Html(html=from_datastore("opencv_result")) + + +def _pil_image_to_data_url(pil_image): + """ + Convert a PIL image into a data URL for the image widget. + """ + buffer = io.BytesIO() + pil_image.save(buffer, format="PNG") + encoded = base64.b64encode(buffer.getvalue()).decode("ascii") + return f"data:image/png;base64,{encoded}" + + +def _render_opencv_result(text): + """ + Render status or traceback text as safe HTML. + """ + invent.datastore["opencv_result"] = ( + "
" + html_lib.escape(text) + "
" + ) + + +def run_opencv_on_latest_capture(message=None): + """ + Execute the current OpenCV snippet against the most recent webcam capture. + """ + if not _opencv_available: + opencv_result_image.image = invent.media.images.invent_logo.png + _render_opencv_result( + "OpenCV support is not available in this runtime. " + "Install cv2, numpy, and Pillow in a local Python environment " + "or use a browser-compatible image-processing library." + ) + return + + capture = preview_webcam.latest_capture(media_type="photo") + if not capture: + opencv_result_image.image = invent.media.images.invent_logo.png + _render_opencv_result("No photo has been captured yet.") + return + + raw_bytes = preview_webcam.photo_bytes(capture=capture) + if raw_bytes is None: + opencv_result_image.image = invent.media.images.invent_logo.png + _render_opencv_result("The latest capture could not be decoded.") + return + + try: + source_image = PILImage.open(io.BytesIO(raw_bytes)).convert("RGB") + array_of_rgb = np.array(source_image) + array_of_bgr = cv2.cvtColor(array_of_rgb, cv2.COLOR_RGB2BGR) + grey = cv2.cvtColor(array_of_rgb, cv2.COLOR_RGB2GRAY) + + namespace = { + "capture": capture, + "image": source_image, + "array_of_rgb": array_of_rgb, + "array_of_bgr": array_of_bgr, + "grey": grey, + "cv2": cv2, + "np": np, + "PILImage": PILImage, + } + exec(opencv_code_editor.code, namespace, namespace) + + result_image = ( + namespace.get("result_image") + or namespace.get("processed_image") + or namespace.get("output_image") + or namespace.get("result") + ) + if isinstance(result_image, np.ndarray): + result_image = PILImage.fromarray(result_image) + elif result_image is None: + result_image = source_image + + if isinstance(result_image, PILImage.Image): + opencv_result_image.image = _pil_image_to_data_url(result_image) + else: + opencv_result_image.image = _pil_image_to_data_url(source_image) + + summary = [ + f"Capture id: {capture.get('id', 'unknown')}", + f"Capture size: {array_of_rgb.shape[1]} x {array_of_rgb.shape[0]}", + "Snippet executed successfully.", + ] + _render_opencv_result("\n".join(summary)) + except Exception as exc: + opencv_result_image.image = invent.media.images.invent_logo.png + _render_opencv_result(f"OpenCV snippet failed:\n{exc}") + + +invent.subscribe( + run_opencv_on_latest_capture, + to_channel="opencv-run", + when_subject=["press"], +) # User Interface ####################################################################### @@ -749,7 +877,23 @@ def greet(self): ), preview_webcam, Label( - text="Use preview_webcam.latest_capture() or preview_webcam.photo_bytes() in the REPL after snapping a photo." + text="Use the editor below to run OpenCV against the latest captured photo." + ), + Label( + text="### OpenCV capture processor\n\nThe editor starts with a snippet that converts the latest photo to edges. The runner provides `capture`, `image`, `array_of_rgb`, `array_of_bgr`, `grey`, `cv2`, `np`, and `PILImage`." + ), + opencv_code_editor, + Button( + text="Run OpenCV on latest capture", + purpose="PRIMARY", + channel="opencv-run", + ), + Label(text="Processed image preview:"), + opencv_result_image, + Label(text="Run log:"), + opencv_result_html, + Label( + text="Use preview_webcam.latest_capture() or preview_webcam.photo_bytes() from the REPL if you want to inspect the current capture directly." ), Label(text="## Layouts"), Label( From 63466659cc92c392280bcdb5b577898b19dcc83e Mon Sep 17 00:00:00 2001 From: iliketocode2 Date: Wed, 8 Apr 2026 14:52:30 -0400 Subject: [PATCH 04/35] attempt at opencv integration again --- examples/theme_testcard/config.json | 4 + examples/theme_testcard/index.html | 2 +- examples/theme_testcard/main.py | 172 ++----- src/invent/themes/default.css | 145 +++++- src/invent/ui/widgets/webcam.py | 672 ++++++++++++++++++++----- tests/invent/ui/widgets/test_webcam.py | 91 ++++ 6 files changed, 817 insertions(+), 269 deletions(-) create mode 100644 tests/invent/ui/widgets/test_webcam.py diff --git a/examples/theme_testcard/config.json b/examples/theme_testcard/config.json index 22f69ba9..22c472c8 100644 --- a/examples/theme_testcard/config.json +++ b/examples/theme_testcard/config.json @@ -1,4 +1,8 @@ { + "packages": [ + "numpy", + "pillow" + ], "files": { "/static/invent.min.tar.gz": "./*" } diff --git a/examples/theme_testcard/index.html b/examples/theme_testcard/index.html index 120e1b57..b5a391d6 100644 --- a/examples/theme_testcard/index.html +++ b/examples/theme_testcard/index.html @@ -45,6 +45,6 @@
- + diff --git a/examples/theme_testcard/main.py b/examples/theme_testcard/main.py index b2573827..f2675e74 100644 --- a/examples/theme_testcard/main.py +++ b/examples/theme_testcard/main.py @@ -19,16 +19,13 @@ _opencv_available = True except ImportError: - cv2 = None - np = None - PILImage = None _opencv_available = False # Datastore ############################################################################ await invent.setup( richtext="This is a **rich text editor**. It supports _formatting_, [links](https://inventframework.org/), and more!" -) # Load default values for the datastore. +) # Code ################################################################################# @@ -92,119 +89,42 @@ def navigate(message): print(greet("world")) """ -invent.datastore["opencv_code"] = """# Variables available in this editor: -# capture, image, array_of_rgb, array_of_bgr, grey, cv2, np, PILImage -grey = cv2.cvtColor(array_of_rgb, cv2.COLOR_RGB2GRAY) -edges = cv2.Canny(grey, 80, 160) -result_image = PILImage.fromarray(edges) -""" -invent.datastore["opencv_result"] = ( - "
Run the snippet after capturing a photo.
" -) +# --------------------------------------------------------------------------- +# Webcam widgets +# --------------------------------------------------------------------------- -preview_webcam = Webcam(photo_output="both", max_captures=5) -opencv_code_editor = CodeEditor( - language="python", - min_height="220px", - code=from_datastore("opencv_code"), +# Standard photo-only webcam (download on capture) +preview_webcam = Webcam( + photo_output="download", + max_captures=5, ) -opencv_result_image = Image(width="100%") -opencv_result_html = Html(html=from_datastore("opencv_result")) - - -def _pil_image_to_data_url(pil_image): - """ - Convert a PIL image into a data URL for the image widget. - """ - buffer = io.BytesIO() - pil_image.save(buffer, format="PNG") - encoded = base64.b64encode(buffer.getvalue()).decode("ascii") - return f"data:image/png;base64,{encoded}" +# OpenCV playground webcam. +# opencv_mode=True means: +# • No auto-download on snap +# • Capture appears side-by-side with the live feed +# • Built-in CodeEditor + "Run OpenCV" button + result image +# The default snippet (edges via Canny) is baked into the widget, but you +# can override it before the widget renders: +# +# opencv_webcam._opencv_code = "result_image = PILImage.fromarray(grey)" +# +opencv_webcam = Webcam( + opencv_mode=True, + photo_output="preview", + max_captures=5, +) -def _render_opencv_result(text): - """ - Render status or traceback text as safe HTML. - """ - invent.datastore["opencv_result"] = ( - "
" + html_lib.escape(text) + "
" - ) - - -def run_opencv_on_latest_capture(message=None): - """ - Execute the current OpenCV snippet against the most recent webcam capture. - """ - if not _opencv_available: - opencv_result_image.image = invent.media.images.invent_logo.png - _render_opencv_result( - "OpenCV support is not available in this runtime. " - "Install cv2, numpy, and Pillow in a local Python environment " - "or use a browser-compatible image-processing library." - ) - return - - capture = preview_webcam.latest_capture(media_type="photo") - if not capture: - opencv_result_image.image = invent.media.images.invent_logo.png - _render_opencv_result("No photo has been captured yet.") - return - - raw_bytes = preview_webcam.photo_bytes(capture=capture) - if raw_bytes is None: - opencv_result_image.image = invent.media.images.invent_logo.png - _render_opencv_result("The latest capture could not be decoded.") - return - - try: - source_image = PILImage.open(io.BytesIO(raw_bytes)).convert("RGB") - array_of_rgb = np.array(source_image) - array_of_bgr = cv2.cvtColor(array_of_rgb, cv2.COLOR_RGB2BGR) - grey = cv2.cvtColor(array_of_rgb, cv2.COLOR_RGB2GRAY) - - namespace = { - "capture": capture, - "image": source_image, - "array_of_rgb": array_of_rgb, - "array_of_bgr": array_of_bgr, - "grey": grey, - "cv2": cv2, - "np": np, - "PILImage": PILImage, - } - exec(opencv_code_editor.code, namespace, namespace) - - result_image = ( - namespace.get("result_image") - or namespace.get("processed_image") - or namespace.get("output_image") - or namespace.get("result") - ) - if isinstance(result_image, np.ndarray): - result_image = PILImage.fromarray(result_image) - elif result_image is None: - result_image = source_image - - if isinstance(result_image, PILImage.Image): - opencv_result_image.image = _pil_image_to_data_url(result_image) - else: - opencv_result_image.image = _pil_image_to_data_url(source_image) - summary = [ - f"Capture id: {capture.get('id', 'unknown')}", - f"Capture size: {array_of_rgb.shape[1]} x {array_of_rgb.shape[0]}", - "Snippet executed successfully.", - ] - _render_opencv_result("\n".join(summary)) - except Exception as exc: - opencv_result_image.image = invent.media.images.invent_logo.png - _render_opencv_result(f"OpenCV snippet failed:\n{exc}") +def run_opencv_from_button(message): + """Run OpenCV processing on the latest captured webcam photo.""" + opencv_webcam.run_opencv() invent.subscribe( - run_opencv_on_latest_capture, - to_channel="opencv-run", + run_opencv_from_button, + to_channel="opencv-controls", when_subject=["press"], ) @@ -868,33 +788,27 @@ def greet(self): ), Label(text="A test video player (Vimeo):"), Video(source="https://vimeo.com/347119375"), - Label(text="A webcam with just camera:"), - Webcam(mode="photo", show_mode_indicator=False), - Label(text="A default webcam:"), - Webcam(), - Label( - text="A webcam that keeps the last photo on the page and stores recent captures:" - ), + # ---- Webcam: download-only (no OpenCV) ---- + Label(text="## Standard webcam (download on capture)"), preview_webcam, + # ---- Webcam: OpenCV playground ---- + Label(text="## OpenCV webcam playground"), Label( - text="Use the editor below to run OpenCV against the latest captured photo." - ), - Label( - text="### OpenCV capture processor\n\nThe editor starts with a snippet that converts the latest photo to edges. The runner provides `capture`, `image`, `array_of_rgb`, `array_of_bgr`, `grey`, `cv2`, `np`, and `PILImage`." + text=( + "Snap a photo above, edit the snippet, then press " + "**Run OpenCV (channel button)**. Available names: `capture`, `image`, " + "`array_of_rgb`, `array_of_bgr`, `grey`, `cv2`, `np`, " + "`PILImage`. Assign any of `result_image`, " + "`processed_image`, `output_image`, or `result` to " + "display the output." + ) ), - opencv_code_editor, Button( - text="Run OpenCV on latest capture", + text="Run OpenCV", purpose="PRIMARY", - channel="opencv-run", - ), - Label(text="Processed image preview:"), - opencv_result_image, - Label(text="Run log:"), - opencv_result_html, - Label( - text="Use preview_webcam.latest_capture() or preview_webcam.photo_bytes() from the REPL if you want to inspect the current capture directly." + channel="opencv-controls", ), + opencv_webcam, Label(text="## Layouts"), Label( text="A default fade carousel (can contain other widgets):" diff --git a/src/invent/themes/default.css b/src/invent/themes/default.css index bb2c7093..8e099465 100644 --- a/src/invent/themes/default.css +++ b/src/invent/themes/default.css @@ -2635,15 +2635,15 @@ figure.invent-avatar:focus-visible { /* Webcam Widget Styles */ .invent-webcam, .webcam-container { - --webcam-bg: var(--muted-light); - --webcam-btn-bg: var(--primary-light); - --webcam-btn-hover: var(--primary-active); - --webcam-accent: var(--secondary); - --webcam-text-primary: var(--color-body); + --webcam-bg: var(--muted-light); + --webcam-btn-bg: var(--primary-light); + --webcam-btn-hover: var(--primary-active); + --webcam-accent: var(--secondary); + --webcam-text-primary: var(--color-body); --webcam-text-secondary: var(--muted-text); - --webcam-shutter-bg: var(--danger); - --webcam-shutter-hover: var(--danger); - --webcam-indicator-bg: var(--primary-light); + --webcam-shutter-bg: var(--danger); + --webcam-shutter-hover: var(--danger); + --webcam-indicator-bg: var(--primary-light); width: 100%; max-width: 800px; @@ -2699,7 +2699,7 @@ figure.invent-avatar:focus-visible { .invent-webcam-shutter-container, .shutter-container { justify-self: center; - text-align: center; + text-align: center; } .invent-webcam-mode-btn, @@ -2730,24 +2730,24 @@ figure.invent-avatar:focus-visible { .invent-webcam-shutter, .shutter { - height: 60px; - width: 60px; - display: flex; - align-items: center; + height: 60px; + width: 60px; + display: flex; + align-items: center; justify-content: center; - text-align: center; - padding: 0; - line-height: 1; - background: var(--webcam-shutter-bg); - border: 0; - border-radius: 50%; - cursor: pointer; - font-size: var(--font-size-sm); - font-family: var(--font); - font-weight: 500; - color: var(--white); - transition: transform var(--transition-speed) ease; - position: relative; + text-align: center; + padding: 0; + line-height: 1; + background: var(--webcam-shutter-bg); + border: 0; + border-radius: 50%; + cursor: pointer; + font-size: var(--font-size-sm); + font-family: var(--font); + font-weight: 500; + color: var(--white); + transition: transform var(--transition-speed) ease; + position: relative; } .invent-webcam-shutter:hover, @@ -2773,6 +2773,97 @@ figure.invent-avatar:focus-visible { z-index: 1; } +.invent-webcam-opencv-video-row { + align-items: stretch; + display: grid; + gap: var(--spacing-lg); + grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr); +} + +.invent-webcam-opencv-col .invent-webcam-box { + min-height: 220px; +} + +.invent-webcam-opencv-col { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + min-width: 0; +} + +.invent-webcam-opencv-col video, +.invent-webcam-opencv-col img { + display: block; + height: auto; + width: 100%; +} + +.invent-webcam-opencv-preview-box { + position: relative; +} + +.invent-webcam-opencv-preview-box .invent-webcam-capture-preview { + height: 100%; + object-fit: cover; + width: 100%; +} + +.invent-webcam-opencv-label { + color: var(--webcam-text-secondary); + font-family: var(--font-mono); + font-size: var(--font-size-xs); + margin: 0; + text-transform: uppercase; +} + +.invent-webcam-opencv-panel { + background: var(--main-background); + border: var(--border-width) solid var(--primary-light); + border-radius: var(--border-radius-lg); + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + padding: var(--spacing-md); +} + +.invent-webcam-opencv-run-btn { + align-self: flex-start; + background: var(--primary-light); + border: var(--border-width) solid var(--primary); + border-radius: var(--border-radius-lg); + color: var(--primary-text); + cursor: pointer; + font-family: var(--font); + font-size: var(--font-size-sm); + font-weight: 600; + padding: var(--spacing-sm) var(--spacing-lg); +} + +.invent-webcam-opencv-run-btn:hover { + background: var(--primary-active); +} + +.invent-webcam-opencv-status { + color: var(--webcam-text-secondary); + font-family: var(--font-mono); + font-size: var(--font-size-xs); + margin: 0; +} + +.invent-webcam-opencv-result-panel { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.invent-webcam-opencv-result-img { + aspect-ratio: 4 / 3; + background: var(--webcam-bg); + border: var(--border-width) solid var(--primary-light); + border-radius: var(--border-radius); + object-fit: contain; +} + @keyframes invent-webcam-recording-ring { 0% { border: var(--border-width) solid var(--webcam-text-primary); @@ -2841,4 +2932,4 @@ figure.invent-avatar:focus-visible { .shutter-container { justify-self: center; } -} \ No newline at end of file +} diff --git a/src/invent/ui/widgets/webcam.py b/src/invent/ui/widgets/webcam.py index dcdb24e5..b2232dc4 100644 --- a/src/invent/ui/widgets/webcam.py +++ b/src/invent/ui/widgets/webcam.py @@ -3,6 +3,8 @@ Enables photo capture and video recording with automatic downloads. Supports live video preview from the user's webcam. +Supports an opencv_mode for running OpenCV processing on captured images, +with side-by-side layout and no automatic downloads. Copyright (c) 2019-present Invent contributors. @@ -28,15 +30,123 @@ Event, IntegerProperty, ) -from pyscript.web import div, video, button, canvas, img +from pyscript.web import div, video, button, canvas, img, p from pyscript.ffi import create_proxy +# --------------------------------------------------------------------------- +# Optional OpenCV / numpy / PIL imports – webcam works fine without them. +# In opencv_mode, cv2 is preferred but we can run a compatibility path with +# numpy + Pillow when cv2 is unavailable in-browser. +# --------------------------------------------------------------------------- +try: + import cv2 as _cv2 +except ImportError: + _cv2 = None + +try: + import numpy as _np +except ImportError: + _np = None + +try: + from PIL import Image as _PILImage + from PIL import ImageFilter as _PILImageFilter +except ImportError: + _PILImage = None + _PILImageFilter = None + +_OPENCV_AVAILABLE = _cv2 is not None +_NUMPY_AVAILABLE = _np is not None +_PIL_AVAILABLE = _PILImage is not None + + +class _Cv2Compat: + """Minimal cv2-compatible surface for browser runtimes without cv2.""" + + COLOR_RGB2BGR = 1 + COLOR_RGB2GRAY = 2 + + @staticmethod + def cvtColor(array, code): + if _np is None: + raise RuntimeError("numpy is required for cv2 compatibility mode") + + if code == _Cv2Compat.COLOR_RGB2BGR: + return array[..., ::-1].copy() + + if code == _Cv2Compat.COLOR_RGB2GRAY: + if array.ndim != 3 or array.shape[2] < 3: + raise ValueError("Expected RGB image with shape (H, W, 3)") + grey = _np.dot(array[..., :3], _np.array([0.299, 0.587, 0.114])) + return grey.astype(_np.uint8) + + raise ValueError(f"Unsupported color conversion code: {code}") + + @staticmethod + def Canny(grey, threshold1, threshold2): + del threshold1, threshold2 + if _PILImage is None or _PILImageFilter is None or _np is None: + raise RuntimeError( + "Pillow and numpy are required for edge detection" + ) + pil = _PILImage.fromarray(grey.astype(_np.uint8), mode="L") + edges = pil.filter(_PILImageFilter.FIND_EDGES) + return _np.array(edges, dtype=_np.uint8) + + +try: + # CodeEditor is a peer Invent widget – import lazily so webcam.py can be + # imported even in environments where the editor widget isn't present. + from invent.ui import CodeEditor as _CodeEditor + + _CODE_EDITOR_AVAILABLE = True +except ImportError: + _CodeEditor = None + _CODE_EDITOR_AVAILABLE = False + +import base64 +import io + class Webcam(Widget): """ - A webcam widget with photo capture and video recording capabilities. + A webcam widget with photo capture, video recording, and optional + OpenCV processing capabilities. + + opencv_mode + ----------- + When True the widget renders a self-contained OpenCV playground: + • Captured image appears **side-by-side** with the live feed. + • Automatic file downloads are suppressed regardless of photo_output. + • A code editor pre-filled with a starter snippet and a "Run OpenCV" + button are rendered below the video row. + • The processed result is shown next to the raw capture. + + The code snippet executed by "Run OpenCV" has the following names bound + in its namespace: + + capture – the raw capture dict stored by the widget + image – PIL Image (RGB) of the captured photo + array_of_rgb – numpy uint8 array, shape (H, W, 3), RGB order + array_of_bgr – numpy uint8 array, shape (H, W, 3), BGR order + grey – numpy uint8 array, shape (H, W), greyscale + cv2 – the cv2 module + np – the numpy module + PILImage – PIL.Image module + + The snippet should assign one of the following names to be shown as the + result image: + + result_image | processed_image | output_image | result + + Any of those may be a numpy ndarray or a PIL Image; both are handled. + If none is assigned the original captured image is shown unchanged. """ + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + photo_output = ChoiceProperty( _("How captured photos are handled: downloaded, previewed, or both."), default_value="download", @@ -66,6 +176,25 @@ class Webcam(Widget): group="style", ) + opencv_mode = BooleanProperty( + _( + "When True, enables the built-in OpenCV processing playground. " + "The capture preview is shown side-by-side with the live feed, " + "downloads are suppressed, and a code editor + run button are " + "rendered inside the widget." + ), + default_value=False, + group="behavior", + ) + + opencv_default_code = _( + "The default OpenCV snippet shown in the code editor." + ) + + # ------------------------------------------------------------------ + # Events + # ------------------------------------------------------------------ + photo_captured = Event( _("Sent when a photo is captured."), webcam=_("The Webcam widget that captured the photo."), @@ -77,21 +206,85 @@ class Webcam(Widget): webcam=_("The Webcam widget that recorded the video."), ) + # ------------------------------------------------------------------ + # Default OpenCV starter snippet + # ------------------------------------------------------------------ + + _DEFAULT_OPENCV_CODE = ( + "# Names available: capture, image, array_of_rgb, array_of_bgr,\n" + "# grey, cv2, np, PILImage\n" + "#\n" + "# Assign result_image (PIL Image or numpy array) to show output.\n" + "\n" + "grey = cv2.cvtColor(array_of_rgb, cv2.COLOR_RGB2GRAY)\n" + "edges = cv2.Canny(grey, 80, 160)\n" + "result_image = PILImage.fromarray(edges)\n" + ) + + # ------------------------------------------------------------------ + @classmethod def icon(cls): - return '' # noqa + return ( + '' + ) + + @staticmethod + def _coerce_bool(value): + """Best-effort bool coercion for initial constructor kwargs.""" + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in { + "1", + "true", + "yes", + "on", + } + return bool(value) def __init__(self, *args, **kwargs): + # Invent may render before all properties are fully applied. Capture + # the requested mode from kwargs so initial layout is correct. + self._initial_opencv_mode = self._coerce_bool( + kwargs.get("opencv_mode", False) + ) self._captures = [] self._capture_counter = 0 + # opencv_mode internal state + self._opencv_code = self._DEFAULT_OPENCV_CODE + self._opencv_result_img_elem = None # the DOM wrapper for result + self._opencv_status_elem = None #

for status text super().__init__(*args, **kwargs) + # ------------------------------------------------------------------ + # Download / preview helpers + # ------------------------------------------------------------------ + def _capture_output_enabled(self): + """True when the preview img should be shown after capture.""" return self.photo_output in ("preview", "both") def _capture_download_enabled(self): + """ + True when the file should be auto-downloaded after capture. + Downloads are always suppressed in opencv_mode. + """ + use_opencv_layout = self.opencv_mode or self._initial_opencv_mode + if use_opencv_layout: + return False return self.photo_output in ("download", "both") + # ------------------------------------------------------------------ + # Capture management + # ------------------------------------------------------------------ + def _capture_id(self, media_type): self._capture_counter += 1 return f"{media_type}-{self._timestamp()}-{self._capture_counter}" @@ -111,16 +304,16 @@ def _store_capture(self, capture): if capture["type"] == "photo" and self._capture_output_enabled(): self._show_capture_preview(capture) + # In opencv_mode, always show the raw capture in the side panel. + if self.opencv_mode and capture["type"] == "photo": + self._show_capture_preview(capture) + return capture def captures(self, media_type=None): if media_type is None: return list(self._captures) - return [ - capture - for capture in self._captures - if capture.get("type") == media_type - ] + return [c for c in self._captures if c.get("type") == media_type] def latest_capture(self, media_type=None): captures = self.captures(media_type=media_type) @@ -147,8 +340,7 @@ def clear_captures(self, media_type=None): self._refresh_capture_preview() return removed - kept = [] - removed = [] + kept, removed = [], [] for capture in self._captures: if capture.get("type") == media_type: removed.append(capture) @@ -159,6 +351,7 @@ def clear_captures(self, media_type=None): return removed def photo_bytes(self, capture=None): + """Return raw JPEG bytes for *capture* (defaults to latest photo).""" capture = capture or self.latest_capture(media_type="photo") if not capture: return None @@ -167,10 +360,12 @@ def photo_bytes(self, capture=None): data_url = capture.get("data_url") if not data_url or "," not in data_url: return None - import base64 - return base64.b64decode(data_url.split(",", 1)[1]) + # ------------------------------------------------------------------ + # Preview helpers + # ------------------------------------------------------------------ + def _show_capture_preview(self, capture): if not hasattr(self, "_capture_preview"): return @@ -184,17 +379,31 @@ def _hide_capture_preview(self): if hasattr(self, "_capture_preview"): self._capture_preview.classes.add("hidden") + def _refresh_capture_preview(self): + if not self._capture_output_enabled() and not self.opencv_mode: + self._hide_capture_preview() + return + latest_photo = self.latest_capture(media_type="photo") + if latest_photo: + self._show_capture_preview(latest_photo) + else: + self._hide_capture_preview() + + # ------------------------------------------------------------------ + # Programmatic trigger + # ------------------------------------------------------------------ + def trigger(self): - """ - Trigger the current action (capture photo or start/stop recording). - """ + """Trigger the current action (capture photo or start/stop recording).""" if hasattr(self, "_shutter_btn"): self._shutter_btn.click() + # ------------------------------------------------------------------ + # Mode switching + # ------------------------------------------------------------------ + def set_mode(self, mode): - """ - Set the active webcam mode when switching is enabled. - """ + """Set the active webcam mode when switching is enabled.""" if self.mode != "both": return if mode in ["photo", "video"]: @@ -209,17 +418,16 @@ def set_mode(self, mode): self._set_shutter_text() def _current_mode(self): - """ - Return the active mode used for behavior and UI labels. - """ if self.mode == "both": return getattr(self, "_active_mode", "photo") return self.mode + # ------------------------------------------------------------------ + # Photo capture + # ------------------------------------------------------------------ + def capture_photo(self): - """ - Capture a photo from the current video stream. - """ + """Capture a photo from the current video stream.""" if hasattr(self, "_canvas") and hasattr(self, "_video_elem"): video_el = self._video_elem._dom_element canvas_el = self._canvas._dom_element @@ -230,13 +438,8 @@ def capture_photo(self): canvas_el.height = height ctx = canvas_el.getContext("2d") - ctx.drawImage( - video_el, - 0, - 0, - width, - height, - ) + ctx.drawImage(video_el, 0, 0, width, height) + capture = self._store_capture( { "type": "photo", @@ -246,30 +449,32 @@ def capture_photo(self): ), } ) + if self._capture_download_enabled(): self._download_canvas_as_image(capture) - if not self._capture_output_enabled(): + + if not self._capture_output_enabled() and not self.opencv_mode: self._hide_capture_preview() + self.publish(self.photo_captured, webcam=self, capture=capture) + # ------------------------------------------------------------------ + # Status helpers + # ------------------------------------------------------------------ + def _set_status(self, text): - """ - Update the status indicator text. - """ if not hasattr(self, "_status_elem"): return self._status_elem._dom_element.textContent = text def _timestamp(self): - """ - Return a millisecond timestamp for generated filenames. - """ return int(time.time() * 1000) + # ------------------------------------------------------------------ + # Video recording + # ------------------------------------------------------------------ + def start_recording(self): - """ - Start recording video from the webcam. - """ if hasattr(self, "_recorder"): if not self._recording and self._recorder.state == "inactive": self._recorded_chunks = [] @@ -280,9 +485,6 @@ def start_recording(self): self._set_status("Recording...") def stop_recording(self): - """ - Stop recording and trigger download of the video file. - """ if ( hasattr(self, "_recorder") and self._recording @@ -294,23 +496,17 @@ def stop_recording(self): self._set_shutter_text() self._set_status("Saving video...") + # ------------------------------------------------------------------ + # Property-change callbacks + # ------------------------------------------------------------------ + def on_mode_changed(self): - """ - Apply all mode-dependent configuration to the already-rendered DOM. - The framework calls this after the widget's properties are set, - so self.mode is reliable here (unlike during render()). - """ if not hasattr(self, "_controls"): - # render() hasn't run yet; nothing to configure return - # Set active mode self._active_mode = "photo" if self.mode == "both" else self.mode - # Rebuild controls: remove any existing modes container, then - # prepend one only when mode='both'. controls_el = self._controls._dom_element - # Remove any previously inserted modes container if ( hasattr(self, "_modes_container") and self._modes_container is not None @@ -331,28 +527,16 @@ def on_mode_changed(self): else: self._mode_buttons = [] - # Update shutter label and mode indicator text self._set_shutter_text() if hasattr(self, "_mode_indicator"): self._mode_indicator._dom_element.textContent = self._mode_label() - # Apply show_mode_indicator if hasattr(self, "_indicators"): if self.show_mode_indicator: self._indicators.classes.remove("hidden") else: self._indicators.classes.add("hidden") - def _refresh_capture_preview(self): - if not self._capture_output_enabled(): - self._hide_capture_preview() - return - latest_photo = self.latest_capture(media_type="photo") - if latest_photo: - self._show_capture_preview(latest_photo) - else: - self._hide_capture_preview() - def on_photo_output_changed(self): if not hasattr(self, "_capture_preview"): return @@ -365,18 +549,16 @@ def on_max_captures_changed(self): self._captures = self._captures[overflow:] self._refresh_capture_preview() + # ------------------------------------------------------------------ + # Mode-button UI helpers + # ------------------------------------------------------------------ + def _mode_label(self): - """ - Return the display label for the current mode. - """ if self._current_mode() == "video": return "Video Mode" return "Photo Mode" def _update_mode_buttons(self): - """ - Update the visual state of mode buttons based on current mode. - """ for btn_info in self._mode_buttons: btn = btn_info["element"] btn_mode = btn_info["mode"] @@ -388,9 +570,6 @@ def _update_mode_buttons(self): btn.classes.remove("active") def _set_shutter_text(self): - """ - Keep shutter text aligned with current mode and recording state. - """ if not hasattr(self, "_shutter_btn"): return is_recording = getattr(self, "_recording", False) @@ -400,10 +579,11 @@ def _set_shutter_text(self): text = "Take" self._shutter_btn._dom_element.textContent = text + # ------------------------------------------------------------------ + # Download helper + # ------------------------------------------------------------------ + def _download_canvas_as_image(self, capture=None): - """ - Download the canvas content as an image file. - """ try: from pyscript import window @@ -420,10 +600,11 @@ def _download_canvas_as_image(self, capture=None): except Exception as e: print(f"Error downloading photo: {e}") + # ------------------------------------------------------------------ + # Shutter click + # ------------------------------------------------------------------ + def _on_shutter_click(self, event): - """ - Handle shutter button clicks. - """ if self._current_mode() == "photo": self.capture_photo() else: @@ -432,6 +613,10 @@ def _on_shutter_click(self, event): else: self.start_recording() + # ------------------------------------------------------------------ + # Webcam stream setup + # ------------------------------------------------------------------ + def _setup_webcam_stream(self): try: from pyscript import window @@ -468,7 +653,6 @@ async def get_stream(): print(f"Camera access denied or error: {e}") self._set_status("Camera access denied") - # Handle promise import asyncio asyncio.create_task(get_stream()) @@ -477,9 +661,6 @@ async def get_stream(): print(f"Error setting up webcam: {e}") def _setup_recorder(self, stream): - """ - Set up the MediaRecorder for video recording. - """ try: from pyscript import window @@ -516,11 +697,11 @@ def on_stop(event): except Exception as e: print(f"Error setting up recorder: {e}") + # ------------------------------------------------------------------ + # Mode-button builder + # ------------------------------------------------------------------ + def _build_mode_buttons(self): - """ - Build and return the mode toggle container for mode='both'. - Populates self._mode_buttons as a side effect. - """ self._mode_buttons = [] def build_mode_button(mode_name): @@ -547,17 +728,238 @@ def build_mode_button(mode_name): self._update_mode_buttons() return modes_container + # ------------------------------------------------------------------ + # OpenCV helpers + # ------------------------------------------------------------------ + + @staticmethod + def _pil_to_data_url(pil_image): + """Convert a PIL Image to a PNG data URL string.""" + buf = io.BytesIO() + pil_image.save(buf, format="PNG") + encoded = base64.b64encode(buf.getvalue()).decode("ascii") + return f"data:image/png;base64,{encoded}" + + def _set_opencv_status(self, text): + """Update the status

element inside the OpenCV panel.""" + if self._opencv_status_elem is not None: + self._opencv_status_elem._dom_element.textContent = text + + def _set_opencv_result_image(self, data_url): + """Set the src of the OpenCV result element.""" + if self._opencv_result_img_elem is not None: + self._opencv_result_img_elem.src = data_url + self._opencv_result_img_elem.classes.remove("hidden") + + def run_opencv(self, event=None): + """ + Execute the OpenCV snippet from the embedded code editor against + the most recent captured photo. + + This method is also callable from outside the widget, e.g. from + main.py, if you keep a reference to the Webcam instance: + + my_webcam.run_opencv() + """ + self._set_opencv_status("Running OpenCV...") + self._set_status("Running OpenCV...") + + cv2_module = _cv2 + compatibility_mode = False + if cv2_module is None: + if not (_NUMPY_AVAILABLE and _PIL_AVAILABLE): + self._set_opencv_status( + "OpenCV unavailable. Install numpy + Pillow to run compatibility mode." + ) + self._set_status("OpenCV unavailable") + return + cv2_module = _Cv2Compat + compatibility_mode = True + + if not (_NUMPY_AVAILABLE and _PIL_AVAILABLE): + self._set_opencv_status( + "Image processing requires numpy and Pillow in this runtime." + ) + self._set_status("Image processing unavailable") + return + + capture = self.latest_capture(media_type="photo") + if not capture: + self._set_opencv_status( + "No photo captured yet. Press 'Take' first." + ) + self._set_status("No photo to process") + return + + raw_bytes = self.photo_bytes(capture=capture) + if raw_bytes is None: + self._set_opencv_status("Could not decode the latest capture.") + self._set_status("Capture decode failed") + return + + # Retrieve the current snippet from the embedded CodeEditor, falling + # back to the stored string if the editor widget isn't available. + code_to_run = self._opencv_code + if hasattr(self, "_opencv_code_editor"): + try: + code_to_run = self._opencv_code_editor.code + except Exception: + pass + + try: + source_image = _PILImage.open(io.BytesIO(raw_bytes)).convert("RGB") + array_of_rgb = _np.array(source_image) + array_of_bgr = cv2_module.cvtColor( + array_of_rgb, cv2_module.COLOR_RGB2BGR + ) + grey = cv2_module.cvtColor(array_of_rgb, cv2_module.COLOR_RGB2GRAY) + + namespace = { + "capture": capture, + "image": source_image, + "array_of_rgb": array_of_rgb, + "array_of_bgr": array_of_bgr, + "grey": grey, + "cv2": cv2_module, + "np": _np, + "PILImage": _PILImage, + } + + exec(code_to_run, namespace, namespace) # noqa: S102 + + result = ( + namespace.get("result_image") + or namespace.get("processed_image") + or namespace.get("output_image") + or namespace.get("result") + ) + + if isinstance(result, _np.ndarray): + # Greyscale (2-D) → needs conversion for PIL + if result.ndim == 2: + result = _PILImage.fromarray(result) + else: + result = _PILImage.fromarray(result) + + if result is None: + result = source_image # show original if snippet set nothing + + if isinstance(result, _PILImage.Image): + data_url = self._pil_to_data_url(result) + self._set_opencv_result_image(data_url) + self._set_opencv_status( + f"OK — {array_of_rgb.shape[1]}×{array_of_rgb.shape[0]} px " + f"| capture {capture.get('id', '?')}" + ) + if compatibility_mode: + self._set_opencv_status( + "OK (compat mode: numpy + Pillow) — " + f"{array_of_rgb.shape[1]}×{array_of_rgb.shape[0]} px " + f"| capture {capture.get('id', '?')}" + ) + self._set_status("OpenCV processing complete") + else: + self._set_opencv_status( + "Snippet did not produce a displayable image." + ) + self._set_status("OpenCV produced no displayable image") + + except Exception as exc: + self._set_opencv_status(f"Error: {exc}") + self._set_status("OpenCV error") + + # ------------------------------------------------------------------ + # OpenCV panel builder + # ------------------------------------------------------------------ + + def _build_opencv_panel(self): + """ + Build and return the OpenCV editor + result panel that sits below + the video row when opencv_mode=True. + + Also populates: + self._opencv_code_editor – CodeEditor widget (if available) + self._opencv_result_img_elem – img element for the result + self._opencv_status_elem – p element for status text + """ + # ---- result image (shown in the side panel, updated after run) ---- + # (The raw capture preview is shown in _capture_preview which lives + # inside the video row. The *processed* result goes here.) + result_img = img() + result_img.id = f"{self.id}-opencv-result" + result_img.classes.add("invent-webcam-opencv-result-img") + result_img.classes.add("hidden") + self._opencv_result_img_elem = result_img + + result_label_el = p("Processed result:") + result_label_el.classes.add("invent-webcam-opencv-label") + + result_container = div(result_label_el, result_img) + result_container.classes.add("invent-webcam-opencv-result-panel") + + # ---- status line ---- + status_p = p("Run OpenCV to see results.") + status_p.classes.add("invent-webcam-opencv-status") + self._opencv_status_elem = status_p + + # ---- code editor ---- + if _CODE_EDITOR_AVAILABLE: + try: + editor = _CodeEditor( + language="python", + min_height="180px", + code=self._opencv_code, + ) + self._opencv_code_editor = editor + editor_element = editor + except Exception as e: + print(f"Could not create CodeEditor: {e}") + editor_element = p( + "CodeEditor unavailable – edit self._opencv_code directly." + ) + self._opencv_code_editor = None + else: + editor_element = p( + "CodeEditor widget not available – edit self._opencv_code directly." + ) + self._opencv_code_editor = None + + # ---- run button ---- + run_btn = button("Run OpenCV") + run_btn.id = f"{self.id}-opencv-run-btn" + run_btn.classes.add("invent-webcam-opencv-run-btn") + run_btn._dom_element.addEventListener( + "click", create_proxy(self.run_opencv) + ) + + # ---- assemble panel ---- + opencv_panel = div( + editor_element, + run_btn, + status_p, + result_container, + ) + opencv_panel.classes.add("invent-webcam-opencv-panel") + + return opencv_panel + + # ------------------------------------------------------------------ + # render() + # ------------------------------------------------------------------ + def render(self): """ - Render the webcam widget with controls. - Mode-dependent configuration is applied in on_mode_changed, - which the framework calls after properties are set. + Render the webcam widget. + + Normal mode → identical layout to the original widget. + opencv_mode → video + raw-capture side-by-side in a flex row, + followed by a code-editor + result panel below. """ - # Hidden canvas for photo capture + # ---- hidden canvas for photo capture ---- self._canvas = canvas() self._canvas.classes.add("invent-webcam-canvas-hidden") - # Video element for preview + # ---- live video element ---- self._video_elem = video() self._video_elem.id = f"{self.id}-video" self._video_elem.autoplay = True @@ -567,10 +969,8 @@ def render(self): def on_video_ready(event): video_el = self._video_elem._dom_element canvas_el = self._canvas._dom_element - width = video_el.videoWidth or 1280 - height = video_el.videoHeight or 720 - canvas_el.width = width - canvas_el.height = height + canvas_el.width = video_el.videoWidth or 1280 + canvas_el.height = video_el.videoHeight or 720 self._video_elem._dom_element.addEventListener( "loadedmetadata", create_proxy(on_video_ready) @@ -580,7 +980,7 @@ def on_video_ready(event): video_container.classes.add("invent-webcam-box") video_container.classes.add("webcam-box") - # Shutter button + # ---- shutter button ---- self._shutter_btn = button("Take") self._shutter_btn.id = f"{self.id}-shutter" self._shutter_btn.classes.add("invent-webcam-shutter") @@ -593,14 +993,12 @@ def on_video_ready(event): shutter_container.classes.add("invent-webcam-shutter-container") shutter_container.classes.add("shutter-container") - # Controls container: starts with just the shutter; - # on_mode_changed inserts mode toggle buttons when mode='both'. self._controls = div(shutter_container) self._controls.classes.add("invent-webcam-actions") self._controls.classes.add("actions") self._shutter_container = shutter_container - # Status indicators + # ---- status indicators ---- self._status_elem = div("Initializing camera...") self._status_elem.id = f"{self.id}-status" self._status_elem.classes.add("invent-webcam-status") @@ -614,25 +1012,75 @@ def on_video_ready(event): self._indicators.classes.add("invent-webcam-indicators") self._indicators.classes.add("indicators") + # ---- capture preview image ---- self._capture_preview = img() self._capture_preview.id = f"{self.id}-capture-preview" self._capture_preview.classes.add("invent-webcam-capture-preview") self._capture_preview.classes.add("capture-preview") self._capture_preview.classes.add("hidden") - # Main container - element = div( - self._canvas, - video_container, - self._controls, - self._indicators, - self._capture_preview, - id=self.id, - ) + # ------------------------------------------------------------------ + # Layout differs between normal and opencv_mode + # ------------------------------------------------------------------ + + if self.opencv_mode: + # ---------------------------------------------------------- + # opencv_mode layout + # ---------------------------------------------------------- + # Row 1: [live feed] [raw capture preview] ← flex row + # Row 2: [opencv panel: editor + run btn + result] + + # Label the two panels + live_label = p("Live feed") + live_label.classes.add("invent-webcam-opencv-label") + + capture_label = p("Captured image") + capture_label.classes.add("invent-webcam-opencv-label") + + live_col = div(live_label, video_container, self._controls) + live_col.classes.add("invent-webcam-opencv-col") + + capture_preview_box = div(self._capture_preview) + capture_preview_box.classes.add("invent-webcam-box") + capture_preview_box.classes.add("webcam-box") + capture_preview_box.classes.add("invent-webcam-opencv-preview-box") + + capture_col = div( + capture_label, + capture_preview_box, + self._indicators, + ) + capture_col.classes.add("invent-webcam-opencv-col") + + video_row = div(live_col, capture_col) + video_row.classes.add("invent-webcam-opencv-video-row") + + opencv_panel = self._build_opencv_panel() + + element = div( + self._canvas, + video_row, + opencv_panel, + id=self.id, + ) + + else: + # ---------------------------------------------------------- + # Normal (original) layout + # ---------------------------------------------------------- + element = div( + self._canvas, + video_container, + self._controls, + self._indicators, + self._capture_preview, + id=self.id, + ) + element.classes.add("invent-webcam") element.classes.add("webcam-container") - # Initialize the webcam stream + # Kick off the camera stream self._setup_webcam_stream() return element diff --git a/tests/invent/ui/widgets/test_webcam.py b/tests/invent/ui/widgets/test_webcam.py new file mode 100644 index 00000000..39142536 --- /dev/null +++ b/tests/invent/ui/widgets/test_webcam.py @@ -0,0 +1,91 @@ +import base64 + +import umock + +from invent.ui.widgets.webcam import Webcam + + +def test_webcam_capture_store_filters_and_latest(): + w = Webcam() + + photo_capture = {"id": "photo-1", "type": "photo"} + video_capture = {"id": "video-1", "type": "video"} + + w._store_capture(photo_capture) + w._store_capture(video_capture) + + assert w.captures() == [photo_capture, video_capture] + assert w.captures(media_type="photo") == [photo_capture] + assert w.captures(media_type="video") == [video_capture] + assert w.latest_capture() == video_capture + assert w.latest_capture(media_type="photo") == photo_capture + + +def test_webcam_capture_store_respects_max_captures(): + w = Webcam(max_captures=2) + w.publish = lambda *args, **kwargs: None + + w._store_capture({"id": "photo-1", "type": "photo"}) + w._store_capture({"id": "photo-2", "type": "photo"}) + w._store_capture({"id": "photo-3", "type": "photo"}) + + captures = w.captures(media_type="photo") + assert len(captures) == 2 + assert captures[0]["id"] == "photo-2" + assert captures[1]["id"] == "photo-3" + + +def test_webcam_remove_and_clear_captures(): + w = Webcam() + w.publish = lambda *args, **kwargs: None + + first = {"id": "photo-1", "type": "photo"} + second = {"id": "video-1", "type": "video"} + w._store_capture(first) + w._store_capture(second) + + removed = w.remove_capture("photo-1") + assert removed == first + assert w.find_capture("photo-1") is None + + cleared = w.clear_captures(media_type="video") + assert cleared == [second] + assert w.captures() == [] + + +def test_webcam_photo_bytes_decodes_data_url(): + w = Webcam() + + raw = b"hello" + encoded = base64.b64encode(raw).decode("ascii") + capture = { + "id": "photo-1", + "type": "photo", + "data_url": f"data:image/jpeg;base64,{encoded}", + } + + assert w.photo_bytes(capture=capture) == raw + + +def test_webcam_photo_output_preview_updates_inline_image(): + w = Webcam() + w.photo_output = "preview" + + preview_classes = umock.Mock() + preview_classes.remove = umock.Mock() + preview = umock.Mock() + preview.classes = preview_classes + w._capture_preview = preview + + raw = b"hello" + encoded = base64.b64encode(raw).decode("ascii") + capture = w._store_capture( + { + "id": "photo-1", + "type": "photo", + "data_url": f"data:image/jpeg;base64,{encoded}", + } + ) + + assert preview.src == capture["data_url"] + preview.classes.remove.assert_called_once_with("hidden") From 4a25d1675765fb4b79a1e8ea9b06996c7bfa486d Mon Sep 17 00:00:00 2001 From: iliketocode2 Date: Wed, 8 Apr 2026 14:58:29 -0400 Subject: [PATCH 05/35] remove test --- examples/theme_testcard/index.html | 2 +- tests/invent/ui/widgets/test_webcam.py | 91 -------------------------- 2 files changed, 1 insertion(+), 92 deletions(-) delete mode 100644 tests/invent/ui/widgets/test_webcam.py diff --git a/examples/theme_testcard/index.html b/examples/theme_testcard/index.html index b5a391d6..120e1b57 100644 --- a/examples/theme_testcard/index.html +++ b/examples/theme_testcard/index.html @@ -45,6 +45,6 @@

- + diff --git a/tests/invent/ui/widgets/test_webcam.py b/tests/invent/ui/widgets/test_webcam.py deleted file mode 100644 index 39142536..00000000 --- a/tests/invent/ui/widgets/test_webcam.py +++ /dev/null @@ -1,91 +0,0 @@ -import base64 - -import umock - -from invent.ui.widgets.webcam import Webcam - - -def test_webcam_capture_store_filters_and_latest(): - w = Webcam() - - photo_capture = {"id": "photo-1", "type": "photo"} - video_capture = {"id": "video-1", "type": "video"} - - w._store_capture(photo_capture) - w._store_capture(video_capture) - - assert w.captures() == [photo_capture, video_capture] - assert w.captures(media_type="photo") == [photo_capture] - assert w.captures(media_type="video") == [video_capture] - assert w.latest_capture() == video_capture - assert w.latest_capture(media_type="photo") == photo_capture - - -def test_webcam_capture_store_respects_max_captures(): - w = Webcam(max_captures=2) - w.publish = lambda *args, **kwargs: None - - w._store_capture({"id": "photo-1", "type": "photo"}) - w._store_capture({"id": "photo-2", "type": "photo"}) - w._store_capture({"id": "photo-3", "type": "photo"}) - - captures = w.captures(media_type="photo") - assert len(captures) == 2 - assert captures[0]["id"] == "photo-2" - assert captures[1]["id"] == "photo-3" - - -def test_webcam_remove_and_clear_captures(): - w = Webcam() - w.publish = lambda *args, **kwargs: None - - first = {"id": "photo-1", "type": "photo"} - second = {"id": "video-1", "type": "video"} - w._store_capture(first) - w._store_capture(second) - - removed = w.remove_capture("photo-1") - assert removed == first - assert w.find_capture("photo-1") is None - - cleared = w.clear_captures(media_type="video") - assert cleared == [second] - assert w.captures() == [] - - -def test_webcam_photo_bytes_decodes_data_url(): - w = Webcam() - - raw = b"hello" - encoded = base64.b64encode(raw).decode("ascii") - capture = { - "id": "photo-1", - "type": "photo", - "data_url": f"data:image/jpeg;base64,{encoded}", - } - - assert w.photo_bytes(capture=capture) == raw - - -def test_webcam_photo_output_preview_updates_inline_image(): - w = Webcam() - w.photo_output = "preview" - - preview_classes = umock.Mock() - preview_classes.remove = umock.Mock() - preview = umock.Mock() - preview.classes = preview_classes - w._capture_preview = preview - - raw = b"hello" - encoded = base64.b64encode(raw).decode("ascii") - capture = w._store_capture( - { - "id": "photo-1", - "type": "photo", - "data_url": f"data:image/jpeg;base64,{encoded}", - } - ) - - assert preview.src == capture["data_url"] - preview.classes.remove.assert_called_once_with("hidden") From 5a736d2adce94bfae1f3f9359677cd59b2d2f50c Mon Sep 17 00:00:00 2001 From: iliketocode2 Date: Wed, 8 Apr 2026 15:10:42 -0400 Subject: [PATCH 06/35] main.py cleanup --- examples/theme_testcard/config.json | 4 ---- examples/theme_testcard/main.py | 22 ++-------------------- 2 files changed, 2 insertions(+), 24 deletions(-) diff --git a/examples/theme_testcard/config.json b/examples/theme_testcard/config.json index 22c472c8..22f69ba9 100644 --- a/examples/theme_testcard/config.json +++ b/examples/theme_testcard/config.json @@ -1,8 +1,4 @@ { - "packages": [ - "numpy", - "pillow" - ], "files": { "/static/invent.min.tar.gz": "./*" } diff --git a/examples/theme_testcard/main.py b/examples/theme_testcard/main.py index f2675e74..0f43f455 100644 --- a/examples/theme_testcard/main.py +++ b/examples/theme_testcard/main.py @@ -89,34 +89,18 @@ def navigate(message): print(greet("world")) """ - -# --------------------------------------------------------------------------- -# Webcam widgets -# --------------------------------------------------------------------------- - -# Standard photo-only webcam (download on capture) +# Pre-define some webcam variations preview_webcam = Webcam( photo_output="download", max_captures=5, ) -# OpenCV playground webcam. -# opencv_mode=True means: -# • No auto-download on snap -# • Capture appears side-by-side with the live feed -# • Built-in CodeEditor + "Run OpenCV" button + result image -# The default snippet (edges via Canny) is baked into the widget, but you -# can override it before the widget renders: -# -# opencv_webcam._opencv_code = "result_image = PILImage.fromarray(grey)" -# opencv_webcam = Webcam( opencv_mode=True, photo_output="preview", max_captures=5, ) - def run_opencv_from_button(message): """Run OpenCV processing on the latest captured webcam photo.""" opencv_webcam.run_opencv() @@ -788,10 +772,8 @@ def greet(self): ), Label(text="A test video player (Vimeo):"), Video(source="https://vimeo.com/347119375"), - # ---- Webcam: download-only (no OpenCV) ---- - Label(text="## Standard webcam (download on capture)"), + Label(text="## Standard webcam"), preview_webcam, - # ---- Webcam: OpenCV playground ---- Label(text="## OpenCV webcam playground"), Label( text=( From 5b72646d907ee430788bbceef2eb9efcdb19ec3c Mon Sep 17 00:00:00 2001 From: iliketocode2 Date: Thu, 9 Apr 2026 09:43:28 -0400 Subject: [PATCH 07/35] isolate openCV python changes --- examples/open_cv_playground/config.json | 6 ++ examples/open_cv_playground/index.html | 50 ++++++++++ examples/open_cv_playground/main.py | 126 ++++++++++++++++++++++++ examples/theme_testcard/main.py | 59 +---------- src/invent/ui/widgets/webcam.py | 5 - 5 files changed, 187 insertions(+), 59 deletions(-) create mode 100644 examples/open_cv_playground/config.json create mode 100644 examples/open_cv_playground/index.html create mode 100644 examples/open_cv_playground/main.py diff --git a/examples/open_cv_playground/config.json b/examples/open_cv_playground/config.json new file mode 100644 index 00000000..6d03bf57 --- /dev/null +++ b/examples/open_cv_playground/config.json @@ -0,0 +1,6 @@ +{ + "packages": ["opencv-python", "numpy", "Pillow"], + "files": { + "/static/invent.min.tar.gz": "./*" + } +} \ No newline at end of file diff --git a/examples/open_cv_playground/index.html b/examples/open_cv_playground/index.html new file mode 100644 index 00000000..b5a391d6 --- /dev/null +++ b/examples/open_cv_playground/index.html @@ -0,0 +1,50 @@ + + + + + + Test Card + + + + + + + + + +
+ + + diff --git a/examples/open_cv_playground/main.py b/examples/open_cv_playground/main.py new file mode 100644 index 00000000..28dbfb56 --- /dev/null +++ b/examples/open_cv_playground/main.py @@ -0,0 +1,126 @@ +""" +This app is a simple test card for the theme system. We ensure all the various +UI aspects of the Invent framework are shown in a page, and allow the user to +select different "themes" to see how they affect the appearance of the page. +""" + +import base64 +import html as html_lib +import io +import random + +import cv2 +import numpy as np +from PIL import Image as PILImage + +import invent +from invent.ui import * + +# Datastore ############################################################################ + +await invent.setup( + richtext="This is a **rich text editor**. It supports _formatting_, [links](https://inventframework.org/), and more!" +) + +# Code ################################################################################# + +# Create some sample appointments for the calendar widget based upon today's month and +# year, so that the calendar will show some appointments when it is rendered. Needs to +# include both just plain dates, and datetimes with times, to show how both are rendered. +from datetime import date, datetime + + +def navigate(message): + """ + Handle navigation between pages based on button clicks / names. + """ + # Extract the page name from the button name. The button names are in the format + # "pagename_button", so we split on "_button" and take the first part to get the page + # name. + page_name = message.source.name.split("_button")[0] + invent.show_page(page_name) + + +invent.subscribe(navigate, to_channel="navigate", when_subject=["press"]) + + +# Some random funky backgrounds for page 4. It's just boring CSS. +backgrounds = [ + "linear-gradient(to bottom, #ff7e5f, #feb47b)", # Linear gradient. + "#3498db", # A solid single colour. + f"linear-gradient(var(--bg-image-overlay), var(--bg-image-overlay)), url('{invent.media.images.repeat_image.png}') repeat", # A repeated image. + f"linear-gradient(var(--bg-image-overlay), var(--bg-image-overlay)), url('{invent.media.images.random.png}') center / cover no-repeat", # A centered, cover image. +] + + +# Pre-define some webcam variations +preview_webcam = Webcam( + photo_output="download", + max_captures=5, +) + +opencv_webcam = Webcam( + opencv_mode=True, + photo_output="preview", + max_captures=5, +) + + +def run_opencv_from_button(message): + """Run OpenCV processing on the latest captured webcam photo.""" + opencv_webcam.run_opencv() + + +invent.subscribe( + run_opencv_from_button, + to_channel="opencv-controls", + when_subject=["press"], +) + +# User Interface ####################################################################### + +app = invent.App( + name="Theme Testcard", + pages=[ + Page( + id="testcard", + children=[ + Column( + children=[ + Row( + children=[ + Label(text="# Invent Test Card"), + ] + ), + Label( + text="This is a test card for the Invent framework. It includes all the different widgets and components in the framework, so that we can see how they look with different themes applied." + ), + Label(text="## Standard webcam"), + preview_webcam, + Label(text="## OpenCV webcam playground"), + Label( + text=( + "Snap a photo above, edit the snippet, then press " + "**Run OpenCV (channel button)**. Available names: `capture`, `image`, " + "`array_of_rgb`, `array_of_bgr`, `grey`, `cv2`, `np`, " + "`PILImage`. Assign any of `result_image`, " + "`processed_image`, `output_image`, or `result` to " + "display the output." + ) + ), + Button( + text="Run OpenCV", + purpose="PRIMARY", + channel="opencv-controls", + ), + opencv_webcam, + ], + ), + ], + ), + ], +) + +# GO! ################################################################################## + +invent.go() diff --git a/examples/theme_testcard/main.py b/examples/theme_testcard/main.py index 0f43f455..ca3e1b0c 100644 --- a/examples/theme_testcard/main.py +++ b/examples/theme_testcard/main.py @@ -4,28 +4,16 @@ select different "themes" to see how they affect the appearance of the page. """ -import base64 -import html as html_lib -import io import random import invent from invent.ui import * -try: - import cv2 - import numpy as np - from PIL import Image as PILImage - - _opencv_available = True -except ImportError: - _opencv_available = False - # Datastore ############################################################################ await invent.setup( richtext="This is a **rich text editor**. It supports _formatting_, [links](https://inventframework.org/), and more!" -) +) # Load default values for the datastore. # Code ################################################################################# @@ -89,28 +77,6 @@ def navigate(message): print(greet("world")) """ -# Pre-define some webcam variations -preview_webcam = Webcam( - photo_output="download", - max_captures=5, -) - -opencv_webcam = Webcam( - opencv_mode=True, - photo_output="preview", - max_captures=5, -) - -def run_opencv_from_button(message): - """Run OpenCV processing on the latest captured webcam photo.""" - opencv_webcam.run_opencv() - - -invent.subscribe( - run_opencv_from_button, - to_channel="opencv-controls", - when_subject=["press"], -) # User Interface ####################################################################### @@ -772,25 +738,10 @@ def greet(self): ), Label(text="A test video player (Vimeo):"), Video(source="https://vimeo.com/347119375"), - Label(text="## Standard webcam"), - preview_webcam, - Label(text="## OpenCV webcam playground"), - Label( - text=( - "Snap a photo above, edit the snippet, then press " - "**Run OpenCV (channel button)**. Available names: `capture`, `image`, " - "`array_of_rgb`, `array_of_bgr`, `grey`, `cv2`, `np`, " - "`PILImage`. Assign any of `result_image`, " - "`processed_image`, `output_image`, or `result` to " - "display the output." - ) - ), - Button( - text="Run OpenCV", - purpose="PRIMARY", - channel="opencv-controls", - ), - opencv_webcam, + Label(text="A webcam with just camera:"), + Webcam(mode="photo", show_mode_indicator=False), + Label(text="A default webcam:"), + Webcam(), Label(text="## Layouts"), Label( text="A default fade carousel (can contain other widgets):" diff --git a/src/invent/ui/widgets/webcam.py b/src/invent/ui/widgets/webcam.py index b2232dc4..6ba5baa9 100644 --- a/src/invent/ui/widgets/webcam.py +++ b/src/invent/ui/widgets/webcam.py @@ -33,11 +33,6 @@ from pyscript.web import div, video, button, canvas, img, p from pyscript.ffi import create_proxy -# --------------------------------------------------------------------------- -# Optional OpenCV / numpy / PIL imports – webcam works fine without them. -# In opencv_mode, cv2 is preferred but we can run a compatibility path with -# numpy + Pillow when cv2 is unavailable in-browser. -# --------------------------------------------------------------------------- try: import cv2 as _cv2 except ImportError: From 8d506cd592d1a525d04958846642a997d842c16d Mon Sep 17 00:00:00 2001 From: iliketocode2 Date: Thu, 9 Apr 2026 11:00:49 -0400 Subject: [PATCH 08/35] It finally works! OpenCV loading properly --- examples/open_cv_playground/main.py | 62 ++++++++++++++--------------- src/invent/ui/widgets/webcam.py | 23 ++++------- 2 files changed, 37 insertions(+), 48 deletions(-) diff --git a/examples/open_cv_playground/main.py b/examples/open_cv_playground/main.py index 28dbfb56..312d2050 100644 --- a/examples/open_cv_playground/main.py +++ b/examples/open_cv_playground/main.py @@ -65,9 +65,19 @@ def navigate(message): max_captures=5, ) +opencv_code_editor = CodeEditor( + theme="light", + code=opencv_webcam._DEFAULT_OPENCV_CODE, +) + def run_opencv_from_button(message): - """Run OpenCV processing on the latest captured webcam photo.""" + """Run OpenCV processing on the latest captured webcam photo. + + Syncs the external CodeEditor's current code into the webcam widget + before executing, so edits made in the editor are always used. + """ + opencv_webcam._opencv_code = opencv_code_editor.code opencv_webcam.run_opencv() @@ -85,37 +95,25 @@ def run_opencv_from_button(message): Page( id="testcard", children=[ - Column( - children=[ - Row( - children=[ - Label(text="# Invent Test Card"), - ] - ), - Label( - text="This is a test card for the Invent framework. It includes all the different widgets and components in the framework, so that we can see how they look with different themes applied." - ), - Label(text="## Standard webcam"), - preview_webcam, - Label(text="## OpenCV webcam playground"), - Label( - text=( - "Snap a photo above, edit the snippet, then press " - "**Run OpenCV (channel button)**. Available names: `capture`, `image`, " - "`array_of_rgb`, `array_of_bgr`, `grey`, `cv2`, `np`, " - "`PILImage`. Assign any of `result_image`, " - "`processed_image`, `output_image`, or `result` to " - "display the output." - ) - ), - Button( - text="Run OpenCV", - purpose="PRIMARY", - channel="opencv-controls", - ), - opencv_webcam, - ], + Label(text="# Invent Test Card"), + Label( + text="This is a test card for the Invent framework. It includes all the different widgets and components in the framework, so that we can see how they look with different themes applied." + ), + Label(text="## Standard webcam"), + preview_webcam, + Label(text="## OpenCV webcam playground"), + Label( + text=( + "Taka a photo, edit the snippet, then press Run OpenCV." + ) + ), + opencv_webcam, + Button( + text="Run OpenCV", + purpose="PRIMARY", + channel="opencv-controls", ), + opencv_code_editor, ], ), ], @@ -123,4 +121,4 @@ def run_opencv_from_button(message): # GO! ################################################################################## -invent.go() +invent.go() \ No newline at end of file diff --git a/src/invent/ui/widgets/webcam.py b/src/invent/ui/widgets/webcam.py index 6ba5baa9..0d617b9a 100644 --- a/src/invent/ui/widgets/webcam.py +++ b/src/invent/ui/widgets/webcam.py @@ -113,11 +113,11 @@ class Webcam(Widget): When True the widget renders a self-contained OpenCV playground: • Captured image appears **side-by-side** with the live feed. • Automatic file downloads are suppressed regardless of photo_output. - • A code editor pre-filled with a starter snippet and a "Run OpenCV" - button are rendered below the video row. + • A code editor pre-filled with a starter snippet is rendered below + the video row. • The processed result is shown next to the raw capture. - The code snippet executed by "Run OpenCV" has the following names bound + The code snippet executed by run_opencv() has the following names bound in its namespace: capture – the raw capture dict stored by the widget @@ -175,7 +175,7 @@ class Webcam(Widget): _( "When True, enables the built-in OpenCV processing playground. " "The capture preview is shown side-by-side with the live feed, " - "downloads are suppressed, and a code editor + run button are " + "downloads are suppressed, and a code editor is " "rendered inside the widget." ), default_value=False, @@ -919,18 +919,9 @@ def _build_opencv_panel(self): ) self._opencv_code_editor = None - # ---- run button ---- - run_btn = button("Run OpenCV") - run_btn.id = f"{self.id}-opencv-run-btn" - run_btn.classes.add("invent-webcam-opencv-run-btn") - run_btn._dom_element.addEventListener( - "click", create_proxy(self.run_opencv) - ) - # ---- assemble panel ---- opencv_panel = div( editor_element, - run_btn, status_p, result_container, ) @@ -1018,12 +1009,12 @@ def on_video_ready(event): # Layout differs between normal and opencv_mode # ------------------------------------------------------------------ - if self.opencv_mode: + if self.opencv_mode or self._initial_opencv_mode: # ---------------------------------------------------------- # opencv_mode layout # ---------------------------------------------------------- # Row 1: [live feed] [raw capture preview] ← flex row - # Row 2: [opencv panel: editor + run btn + result] + # Row 2: [opencv panel: editor + status + result] # Label the two panels live_label = p("Live feed") @@ -1078,4 +1069,4 @@ def on_video_ready(event): # Kick off the camera stream self._setup_webcam_stream() - return element + return element \ No newline at end of file From 86c895aab39649e419669e6234bcec3d9c7adeaf Mon Sep 17 00:00:00 2001 From: iliketocode2 Date: Thu, 9 Apr 2026 11:36:43 -0400 Subject: [PATCH 09/35] Properly listening to codeeditor event to change opencv result --- examples/open_cv_playground/main.py | 25 +++++++--- src/invent/ui/widgets/webcam.py | 72 +++++++---------------------- 2 files changed, 34 insertions(+), 63 deletions(-) diff --git a/examples/open_cv_playground/main.py b/examples/open_cv_playground/main.py index 312d2050..37d91eb3 100644 --- a/examples/open_cv_playground/main.py +++ b/examples/open_cv_playground/main.py @@ -65,19 +65,25 @@ def navigate(message): max_captures=5, ) + +# Keep the webcam's code in sync whenever the editor changes. +def _on_code_changed(message): + opencv_webcam._opencv_code = message.code + opencv_code_editor = CodeEditor( theme="light", code=opencv_webcam._DEFAULT_OPENCV_CODE, ) +invent.subscribe( + _on_code_changed, + to_channel=opencv_code_editor.channel, + when_subject="changed", +) -def run_opencv_from_button(message): - """Run OpenCV processing on the latest captured webcam photo. - Syncs the external CodeEditor's current code into the webcam widget - before executing, so edits made in the editor are always used. - """ - opencv_webcam._opencv_code = opencv_code_editor.code +def run_opencv_from_button(message): + """Run OpenCV using the current code in the editor.""" opencv_webcam.run_opencv() @@ -104,7 +110,12 @@ def run_opencv_from_button(message): Label(text="## OpenCV webcam playground"), Label( text=( - "Taka a photo, edit the snippet, then press Run OpenCV." + "Snap a photo above, edit the snippet, then press " + "**Run OpenCV (channel button)**. Available names: `capture`, `image`, " + "`array_of_rgb`, `array_of_bgr`, `grey`, `cv2`, `np`, " + "`PILImage`. Assign any of `result_image`, " + "`processed_image`, `output_image`, or `result` to " + "display the output." ) ), opencv_webcam, diff --git a/src/invent/ui/widgets/webcam.py b/src/invent/ui/widgets/webcam.py index 0d617b9a..beeae671 100644 --- a/src/invent/ui/widgets/webcam.py +++ b/src/invent/ui/widgets/webcam.py @@ -89,15 +89,6 @@ def Canny(grey, threshold1, threshold2): return _np.array(edges, dtype=_np.uint8) -try: - # CodeEditor is a peer Invent widget – import lazily so webcam.py can be - # imported even in environments where the editor widget isn't present. - from invent.ui import CodeEditor as _CodeEditor - - _CODE_EDITOR_AVAILABLE = True -except ImportError: - _CodeEditor = None - _CODE_EDITOR_AVAILABLE = False import base64 import io @@ -113,11 +104,11 @@ class Webcam(Widget): When True the widget renders a self-contained OpenCV playground: • Captured image appears **side-by-side** with the live feed. • Automatic file downloads are suppressed regardless of photo_output. - • A code editor pre-filled with a starter snippet is rendered below - the video row. + • A code editor pre-filled with a starter snippet and a "Run OpenCV" + button are rendered below the video row. • The processed result is shown next to the raw capture. - The code snippet executed by run_opencv() has the following names bound + The code snippet executed by "Run OpenCV" has the following names bound in its namespace: capture – the raw capture dict stored by the widget @@ -175,7 +166,7 @@ class Webcam(Widget): _( "When True, enables the built-in OpenCV processing playground. " "The capture preview is shown side-by-side with the live feed, " - "downloads are suppressed, and a code editor is " + "downloads are suppressed, and a code editor + run button are " "rendered inside the widget." ), default_value=False, @@ -792,14 +783,8 @@ def run_opencv(self, event=None): self._set_status("Capture decode failed") return - # Retrieve the current snippet from the embedded CodeEditor, falling - # back to the stored string if the editor widget isn't available. + # Run the current snippet (set externally via self._opencv_code). code_to_run = self._opencv_code - if hasattr(self, "_opencv_code_editor"): - try: - code_to_run = self._opencv_code_editor.code - except Exception: - pass try: source_image = _PILImage.open(io.BytesIO(raw_bytes)).convert("RGB") @@ -869,17 +854,18 @@ def run_opencv(self, event=None): def _build_opencv_panel(self): """ - Build and return the OpenCV editor + result panel that sits below - the video row when opencv_mode=True. + Build and return the OpenCV result panel that sits below the video + row when opencv_mode=True. + + The code editor and run button live outside the widget (in the app) + and communicate via self._opencv_code / run_opencv(). This panel + only handles displaying the processed result and status. - Also populates: - self._opencv_code_editor – CodeEditor widget (if available) + Populates: self._opencv_result_img_elem – img element for the result - self._opencv_status_elem – p element for status text + self._opencv_status_elem – p element for status text """ - # ---- result image (shown in the side panel, updated after run) ---- - # (The raw capture preview is shown in _capture_preview which lives - # inside the video row. The *processed* result goes here.) + # ---- result image ---- result_img = img() result_img.id = f"{self.id}-opencv-result" result_img.classes.add("invent-webcam-opencv-result-img") @@ -897,34 +883,8 @@ def _build_opencv_panel(self): status_p.classes.add("invent-webcam-opencv-status") self._opencv_status_elem = status_p - # ---- code editor ---- - if _CODE_EDITOR_AVAILABLE: - try: - editor = _CodeEditor( - language="python", - min_height="180px", - code=self._opencv_code, - ) - self._opencv_code_editor = editor - editor_element = editor - except Exception as e: - print(f"Could not create CodeEditor: {e}") - editor_element = p( - "CodeEditor unavailable – edit self._opencv_code directly." - ) - self._opencv_code_editor = None - else: - editor_element = p( - "CodeEditor widget not available – edit self._opencv_code directly." - ) - self._opencv_code_editor = None - # ---- assemble panel ---- - opencv_panel = div( - editor_element, - status_p, - result_container, - ) + opencv_panel = div(status_p, result_container) opencv_panel.classes.add("invent-webcam-opencv-panel") return opencv_panel @@ -1014,7 +974,7 @@ def on_video_ready(event): # opencv_mode layout # ---------------------------------------------------------- # Row 1: [live feed] [raw capture preview] ← flex row - # Row 2: [opencv panel: editor + status + result] + # Row 2: [opencv panel: editor + run btn + result] # Label the two panels live_label = p("Live feed") From 9a251a88229c964531c3e31d7677c2f75cc45333 Mon Sep 17 00:00:00 2001 From: iliketocode2 Date: Thu, 9 Apr 2026 16:25:06 -0400 Subject: [PATCH 10/35] simplify v1 --- examples/open_cv_playground/main.py | 3 +- src/invent/ui/widgets/webcam.py | 64 ++++++++++------------------- 2 files changed, 24 insertions(+), 43 deletions(-) diff --git a/examples/open_cv_playground/main.py b/examples/open_cv_playground/main.py index 37d91eb3..e26843bb 100644 --- a/examples/open_cv_playground/main.py +++ b/examples/open_cv_playground/main.py @@ -70,6 +70,7 @@ def navigate(message): def _on_code_changed(message): opencv_webcam._opencv_code = message.code + opencv_code_editor = CodeEditor( theme="light", code=opencv_webcam._DEFAULT_OPENCV_CODE, @@ -132,4 +133,4 @@ def run_opencv_from_button(message): # GO! ################################################################################## -invent.go() \ No newline at end of file +invent.go() diff --git a/src/invent/ui/widgets/webcam.py b/src/invent/ui/widgets/webcam.py index beeae671..af8e618b 100644 --- a/src/invent/ui/widgets/webcam.py +++ b/src/invent/ui/widgets/webcam.py @@ -89,7 +89,6 @@ def Canny(grey, threshold1, threshold2): return _np.array(edges, dtype=_np.uint8) - import base64 import io @@ -129,10 +128,6 @@ class Webcam(Widget): If none is assigned the original captured image is shown unchanged. """ - # ------------------------------------------------------------------ - # Properties - # ------------------------------------------------------------------ - photo_output = ChoiceProperty( _("How captured photos are handled: downloaded, previewed, or both."), default_value="download", @@ -177,10 +172,6 @@ class Webcam(Widget): "The default OpenCV snippet shown in the code editor." ) - # ------------------------------------------------------------------ - # Events - # ------------------------------------------------------------------ - photo_captured = Event( _("Sent when a photo is captured."), webcam=_("The Webcam widget that captured the photo."), @@ -192,9 +183,7 @@ class Webcam(Widget): webcam=_("The Webcam widget that recorded the video."), ) - # ------------------------------------------------------------------ - # Default OpenCV starter snippet - # ------------------------------------------------------------------ + # Starting Code: _DEFAULT_OPENCV_CODE = ( "# Names available: capture, image, array_of_rgb, array_of_bgr,\n" @@ -207,8 +196,6 @@ class Webcam(Widget): "result_image = PILImage.fromarray(edges)\n" ) - # ------------------------------------------------------------------ - @classmethod def icon(cls): return ( @@ -249,24 +236,6 @@ def __init__(self, *args, **kwargs): self._opencv_status_elem = None #

for status text super().__init__(*args, **kwargs) - # ------------------------------------------------------------------ - # Download / preview helpers - # ------------------------------------------------------------------ - - def _capture_output_enabled(self): - """True when the preview img should be shown after capture.""" - return self.photo_output in ("preview", "both") - - def _capture_download_enabled(self): - """ - True when the file should be auto-downloaded after capture. - Downloads are always suppressed in opencv_mode. - """ - use_opencv_layout = self.opencv_mode or self._initial_opencv_mode - if use_opencv_layout: - return False - return self.photo_output in ("download", "both") - # ------------------------------------------------------------------ # Capture management # ------------------------------------------------------------------ @@ -287,11 +256,11 @@ def _store_capture(self, capture): if overflow > 0: self._captures = self._captures[overflow:] - if capture["type"] == "photo" and self._capture_output_enabled(): - self._show_capture_preview(capture) - - # In opencv_mode, always show the raw capture in the side panel. - if self.opencv_mode and capture["type"] == "photo": + if capture["type"] == "photo" and ( + self.photo_output in ("preview", "both") + or self.opencv_mode + or self._initial_opencv_mode + ): self._show_capture_preview(capture) return capture @@ -366,7 +335,11 @@ def _hide_capture_preview(self): self._capture_preview.classes.add("hidden") def _refresh_capture_preview(self): - if not self._capture_output_enabled() and not self.opencv_mode: + if not ( + self.photo_output in ("preview", "both") + or self.opencv_mode + or self._initial_opencv_mode + ): self._hide_capture_preview() return latest_photo = self.latest_capture(media_type="photo") @@ -436,10 +409,16 @@ def capture_photo(self): } ) - if self._capture_download_enabled(): + if self.photo_output in ("download", "both") and not ( + self.opencv_mode or self._initial_opencv_mode + ): self._download_canvas_as_image(capture) - if not self._capture_output_enabled() and not self.opencv_mode: + if not ( + self.photo_output in ("preview", "both") + or self.opencv_mode + or self._initial_opencv_mode + ): self._hide_capture_preview() self.publish(self.photo_captured, webcam=self, capture=capture) @@ -969,7 +948,8 @@ def on_video_ready(event): # Layout differs between normal and opencv_mode # ------------------------------------------------------------------ - if self.opencv_mode or self._initial_opencv_mode: + use_opencv_layout = self.opencv_mode or self._initial_opencv_mode + if use_opencv_layout: # ---------------------------------------------------------- # opencv_mode layout # ---------------------------------------------------------- @@ -1029,4 +1009,4 @@ def on_video_ready(event): # Kick off the camera stream self._setup_webcam_stream() - return element \ No newline at end of file + return element From 5df6b6d1a6202e225f00a7e53b1adeed27d94128 Mon Sep 17 00:00:00 2001 From: iliketocode2 Date: Thu, 9 Apr 2026 16:48:05 -0400 Subject: [PATCH 11/35] more simplifications --- src/invent/ui/widgets/webcam.py | 130 ++++++++++++-------------------- 1 file changed, 50 insertions(+), 80 deletions(-) diff --git a/src/invent/ui/widgets/webcam.py b/src/invent/ui/widgets/webcam.py index af8e618b..7efd7cd7 100644 --- a/src/invent/ui/widgets/webcam.py +++ b/src/invent/ui/widgets/webcam.py @@ -50,10 +50,6 @@ _PILImage = None _PILImageFilter = None -_OPENCV_AVAILABLE = _cv2 is not None -_NUMPY_AVAILABLE = _np is not None -_PIL_AVAILABLE = _PILImage is not None - class _Cv2Compat: """Minimal cv2-compatible surface for browser runtimes without cv2.""" @@ -208,26 +204,10 @@ def icon(cls): ' 16-16a16 16 0 0 1-16 16"/>' ) - @staticmethod - def _coerce_bool(value): - """Best-effort bool coercion for initial constructor kwargs.""" - if isinstance(value, bool): - return value - if isinstance(value, str): - return value.strip().lower() in { - "1", - "true", - "yes", - "on", - } - return bool(value) - def __init__(self, *args, **kwargs): # Invent may render before all properties are fully applied. Capture # the requested mode from kwargs so initial layout is correct. - self._initial_opencv_mode = self._coerce_bool( - kwargs.get("opencv_mode", False) - ) + self._initial_opencv_mode = bool(kwargs.get("opencv_mode", False)) self._captures = [] self._capture_counter = 0 # opencv_mode internal state @@ -240,27 +220,29 @@ def __init__(self, *args, **kwargs): # Capture management # ------------------------------------------------------------------ - def _capture_id(self, media_type): - self._capture_counter += 1 - return f"{media_type}-{self._timestamp()}-{self._capture_counter}" - def _store_capture(self, capture): capture = dict(capture) capture.setdefault("type", "photo") capture.setdefault("timestamp", self._timestamp()) - capture.setdefault("id", self._capture_id(capture["type"])) + self._capture_counter += 1 + capture.setdefault( + "id", + f"{capture['type']}-{self._timestamp()}-{self._capture_counter}", + ) self._captures.append(capture) + preview_enabled = ( + self.photo_output in ("preview", "both") + or self.opencv_mode + or self._initial_opencv_mode + ) + if self.max_captures and self.max_captures > 0: overflow = len(self._captures) - self.max_captures if overflow > 0: self._captures = self._captures[overflow:] - if capture["type"] == "photo" and ( - self.photo_output in ("preview", "both") - or self.opencv_mode - or self._initial_opencv_mode - ): + if capture["type"] == "photo" and preview_enabled: self._show_capture_preview(capture) return capture @@ -335,11 +317,12 @@ def _hide_capture_preview(self): self._capture_preview.classes.add("hidden") def _refresh_capture_preview(self): - if not ( + preview_enabled = ( self.photo_output in ("preview", "both") or self.opencv_mode or self._initial_opencv_mode - ): + ) + if not preview_enabled: self._hide_capture_preview() return latest_photo = self.latest_capture(media_type="photo") @@ -367,12 +350,15 @@ def set_mode(self, mode): return if mode in ["photo", "video"]: self._active_mode = mode + mode_label = ( + "Video Mode" + if self._current_mode() == "video" + else "Photo Mode" + ) if hasattr(self, "_mode_buttons"): self._update_mode_buttons() if hasattr(self, "_mode_indicator"): - self._mode_indicator._dom_element.textContent = ( - self._mode_label() - ) + self._mode_indicator._dom_element.textContent = mode_label if hasattr(self, "_shutter_btn"): self._set_shutter_text() @@ -409,16 +395,20 @@ def capture_photo(self): } ) - if self.photo_output in ("download", "both") and not ( - self.opencv_mode or self._initial_opencv_mode - ): - self._download_canvas_as_image(capture) - - if not ( + download_enabled = self.photo_output in ( + "download", + "both", + ) and not (self.opencv_mode or self._initial_opencv_mode) + preview_enabled = ( self.photo_output in ("preview", "both") or self.opencv_mode or self._initial_opencv_mode - ): + ) + + if download_enabled: + self._download_canvas_as_image(capture) + + if not preview_enabled: self._hide_capture_preview() self.publish(self.photo_captured, webcam=self, capture=capture) @@ -470,6 +460,9 @@ def on_mode_changed(self): return self._active_mode = "photo" if self.mode == "both" else self.mode + mode_label = ( + "Video Mode" if self._current_mode() == "video" else "Photo Mode" + ) controls_el = self._controls._dom_element if ( @@ -494,7 +487,7 @@ def on_mode_changed(self): self._set_shutter_text() if hasattr(self, "_mode_indicator"): - self._mode_indicator._dom_element.textContent = self._mode_label() + self._mode_indicator._dom_element.textContent = mode_label if hasattr(self, "_indicators"): if self.show_mode_indicator: @@ -518,11 +511,6 @@ def on_max_captures_changed(self): # Mode-button UI helpers # ------------------------------------------------------------------ - def _mode_label(self): - if self._current_mode() == "video": - return "Video Mode" - return "Photo Mode" - def _update_mode_buttons(self): for btn_info in self._mode_buttons: btn = btn_info["element"] @@ -697,25 +685,11 @@ def build_mode_button(mode_name): # OpenCV helpers # ------------------------------------------------------------------ - @staticmethod - def _pil_to_data_url(pil_image): - """Convert a PIL Image to a PNG data URL string.""" - buf = io.BytesIO() - pil_image.save(buf, format="PNG") - encoded = base64.b64encode(buf.getvalue()).decode("ascii") - return f"data:image/png;base64,{encoded}" - def _set_opencv_status(self, text): """Update the status

element inside the OpenCV panel.""" if self._opencv_status_elem is not None: self._opencv_status_elem._dom_element.textContent = text - def _set_opencv_result_image(self, data_url): - """Set the src of the OpenCV result element.""" - if self._opencv_result_img_elem is not None: - self._opencv_result_img_elem.src = data_url - self._opencv_result_img_elem.classes.remove("hidden") - def run_opencv(self, event=None): """ Execute the OpenCV snippet from the embedded code editor against @@ -729,25 +703,23 @@ def run_opencv(self, event=None): self._set_opencv_status("Running OpenCV...") self._set_status("Running OpenCV...") - cv2_module = _cv2 - compatibility_mode = False - if cv2_module is None: - if not (_NUMPY_AVAILABLE and _PIL_AVAILABLE): + if _cv2 is None: + if _np is None or _PILImage is None or _PILImageFilter is None: self._set_opencv_status( "OpenCV unavailable. Install numpy + Pillow to run compatibility mode." ) self._set_status("OpenCV unavailable") return - cv2_module = _Cv2Compat - compatibility_mode = True - if not (_NUMPY_AVAILABLE and _PIL_AVAILABLE): + if _np is None or _PILImage is None: self._set_opencv_status( "Image processing requires numpy and Pillow in this runtime." ) self._set_status("Image processing unavailable") return + cv2_module = _cv2 or _Cv2Compat + capture = self.latest_capture(media_type="photo") if not capture: self._set_opencv_status( @@ -804,18 +776,17 @@ def run_opencv(self, event=None): result = source_image # show original if snippet set nothing if isinstance(result, _PILImage.Image): - data_url = self._pil_to_data_url(result) - self._set_opencv_result_image(data_url) + buf = io.BytesIO() + result.save(buf, format="PNG") + encoded = base64.b64encode(buf.getvalue()).decode("ascii") + self._opencv_result_img_elem.src = ( + f"data:image/png;base64,{encoded}" + ) + self._opencv_result_img_elem.classes.remove("hidden") self._set_opencv_status( f"OK — {array_of_rgb.shape[1]}×{array_of_rgb.shape[0]} px " f"| capture {capture.get('id', '?')}" ) - if compatibility_mode: - self._set_opencv_status( - "OK (compat mode: numpy + Pillow) — " - f"{array_of_rgb.shape[1]}×{array_of_rgb.shape[0]} px " - f"| capture {capture.get('id', '?')}" - ) self._set_status("OpenCV processing complete") else: self._set_opencv_status( @@ -948,8 +919,7 @@ def on_video_ready(event): # Layout differs between normal and opencv_mode # ------------------------------------------------------------------ - use_opencv_layout = self.opencv_mode or self._initial_opencv_mode - if use_opencv_layout: + if self.opencv_mode or self._initial_opencv_mode: # ---------------------------------------------------------- # opencv_mode layout # ---------------------------------------------------------- From e2ea61b30beddec550e34de75555070b4f823d45 Mon Sep 17 00:00:00 2001 From: iliketocode2 Date: Thu, 9 Apr 2026 17:21:17 -0400 Subject: [PATCH 12/35] Clean up comments --- src/invent/ui/widgets/webcam.py | 108 ++++++++++---------------------- 1 file changed, 34 insertions(+), 74 deletions(-) diff --git a/src/invent/ui/widgets/webcam.py b/src/invent/ui/widgets/webcam.py index 7efd7cd7..10fb09e1 100644 --- a/src/invent/ui/widgets/webcam.py +++ b/src/invent/ui/widgets/webcam.py @@ -52,8 +52,7 @@ class _Cv2Compat: - """Minimal cv2-compatible surface for browser runtimes without cv2.""" - + """Provides a small subset of OpenCV functionality using Pillow and numpy when cv2 is not available.""" COLOR_RGB2BGR = 1 COLOR_RGB2GRAY = 2 @@ -106,14 +105,14 @@ class Webcam(Widget): The code snippet executed by "Run OpenCV" has the following names bound in its namespace: - capture – the raw capture dict stored by the widget - image – PIL Image (RGB) of the captured photo - array_of_rgb – numpy uint8 array, shape (H, W, 3), RGB order - array_of_bgr – numpy uint8 array, shape (H, W, 3), BGR order - grey – numpy uint8 array, shape (H, W), greyscale - cv2 – the cv2 module - np – the numpy module - PILImage – PIL.Image module + capture : the raw capture dict stored by the widget + image : PIL Image (RGB) of the captured photo + array_of_rgb : numpy uint8 array, shape (H, W, 3), RGB order + array_of_bgr : numpy uint8 array, shape (H, W, 3), BGR order + grey : numpy uint8 array, shape (H, W), greyscale + cv2 : the cv2 module + np : the numpy module + PILImage : PIL.Image module The snippet should assign one of the following names to be shown as the result image: @@ -156,9 +155,6 @@ class Webcam(Widget): opencv_mode = BooleanProperty( _( "When True, enables the built-in OpenCV processing playground. " - "The capture preview is shown side-by-side with the live feed, " - "downloads are suppressed, and a code editor + run button are " - "rendered inside the widget." ), default_value=False, group="behavior", @@ -216,10 +212,8 @@ def __init__(self, *args, **kwargs): self._opencv_status_elem = None #

for status text super().__init__(*args, **kwargs) - # ------------------------------------------------------------------ - # Capture management - # ------------------------------------------------------------------ + # Capture management def _store_capture(self, capture): capture = dict(capture) capture.setdefault("type", "photo") @@ -299,10 +293,8 @@ def photo_bytes(self, capture=None): return None return base64.b64decode(data_url.split(",", 1)[1]) - # ------------------------------------------------------------------ - # Preview helpers - # ------------------------------------------------------------------ + # Preview helpers def _show_capture_preview(self, capture): if not hasattr(self, "_capture_preview"): return @@ -331,19 +323,14 @@ def _refresh_capture_preview(self): else: self._hide_capture_preview() - # ------------------------------------------------------------------ - # Programmatic trigger - # ------------------------------------------------------------------ def trigger(self): """Trigger the current action (capture photo or start/stop recording).""" if hasattr(self, "_shutter_btn"): self._shutter_btn.click() - # ------------------------------------------------------------------ - # Mode switching - # ------------------------------------------------------------------ + # Mode switching def set_mode(self, mode): """Set the active webcam mode when switching is enabled.""" if self.mode != "both": @@ -367,10 +354,8 @@ def _current_mode(self): return getattr(self, "_active_mode", "photo") return self.mode - # ------------------------------------------------------------------ - # Photo capture - # ------------------------------------------------------------------ + # Photo capture def capture_photo(self): """Capture a photo from the current video stream.""" if hasattr(self, "_canvas") and hasattr(self, "_video_elem"): @@ -413,10 +398,8 @@ def capture_photo(self): self.publish(self.photo_captured, webcam=self, capture=capture) - # ------------------------------------------------------------------ - # Status helpers - # ------------------------------------------------------------------ + # Status helpers def _set_status(self, text): if not hasattr(self, "_status_elem"): return @@ -425,10 +408,8 @@ def _set_status(self, text): def _timestamp(self): return int(time.time() * 1000) - # ------------------------------------------------------------------ - # Video recording - # ------------------------------------------------------------------ + # Video recording def start_recording(self): if hasattr(self, "_recorder"): if not self._recording and self._recorder.state == "inactive": @@ -451,10 +432,8 @@ def stop_recording(self): self._set_shutter_text() self._set_status("Saving video...") - # ------------------------------------------------------------------ - # Property-change callbacks - # ------------------------------------------------------------------ + # Callbacks def on_mode_changed(self): if not hasattr(self, "_controls"): return @@ -507,10 +486,8 @@ def on_max_captures_changed(self): self._captures = self._captures[overflow:] self._refresh_capture_preview() - # ------------------------------------------------------------------ - # Mode-button UI helpers - # ------------------------------------------------------------------ + # UI helpers def _update_mode_buttons(self): for btn_info in self._mode_buttons: btn = btn_info["element"] @@ -532,10 +509,8 @@ def _set_shutter_text(self): text = "Take" self._shutter_btn._dom_element.textContent = text - # ------------------------------------------------------------------ - # Download helper - # ------------------------------------------------------------------ + # Download helper def _download_canvas_as_image(self, capture=None): try: from pyscript import window @@ -553,10 +528,6 @@ def _download_canvas_as_image(self, capture=None): except Exception as e: print(f"Error downloading photo: {e}") - # ------------------------------------------------------------------ - # Shutter click - # ------------------------------------------------------------------ - def _on_shutter_click(self, event): if self._current_mode() == "photo": self.capture_photo() @@ -566,10 +537,8 @@ def _on_shutter_click(self, event): else: self.start_recording() - # ------------------------------------------------------------------ - # Webcam stream setup - # ------------------------------------------------------------------ + # Webcam stream def _setup_webcam_stream(self): try: from pyscript import window @@ -650,10 +619,8 @@ def on_stop(event): except Exception as e: print(f"Error setting up recorder: {e}") - # ------------------------------------------------------------------ - # Mode-button builder - # ------------------------------------------------------------------ + # Determine Camera Mode (video or photo) def _build_mode_buttons(self): self._mode_buttons = [] @@ -681,10 +648,8 @@ def build_mode_button(mode_name): self._update_mode_buttons() return modes_container - # ------------------------------------------------------------------ - # OpenCV helpers - # ------------------------------------------------------------------ + # OpenCV helpers def _set_opencv_status(self, text): """Update the status

element inside the OpenCV panel.""" if self._opencv_status_elem is not None: @@ -766,7 +731,7 @@ def run_opencv(self, event=None): ) if isinstance(result, _np.ndarray): - # Greyscale (2-D) → needs conversion for PIL + # Greyscale which needs PIL conversion if result.ndim == 2: result = _PILImage.fromarray(result) else: @@ -798,10 +763,8 @@ def run_opencv(self, event=None): self._set_opencv_status(f"Error: {exc}") self._set_status("OpenCV error") - # ------------------------------------------------------------------ - # OpenCV panel builder - # ------------------------------------------------------------------ + # OpenCV UI construction def _build_opencv_panel(self): """ Build and return the OpenCV result panel that sits below the video @@ -812,8 +775,8 @@ def _build_opencv_panel(self): only handles displaying the processed result and status. Populates: - self._opencv_result_img_elem – img element for the result - self._opencv_status_elem – p element for status text + self._opencv_result_img_elem : img element for the result + self._opencv_status_elem : p element for status text """ # ---- result image ---- result_img = img() @@ -839,9 +802,12 @@ def _build_opencv_panel(self): return opencv_panel - # ------------------------------------------------------------------ + # render() - # ------------------------------------------------------------------ + # + # This function constructs the entire DOM structure of the widget. + # It is called once when the widget is first rendered, and should + # return the root element. def render(self): """ @@ -915,15 +881,11 @@ def on_video_ready(event): self._capture_preview.classes.add("capture-preview") self._capture_preview.classes.add("hidden") - # ------------------------------------------------------------------ - # Layout differs between normal and opencv_mode - # ------------------------------------------------------------------ - if self.opencv_mode or self._initial_opencv_mode: # ---------------------------------------------------------- # opencv_mode layout # ---------------------------------------------------------- - # Row 1: [live feed] [raw capture preview] ← flex row + # Row 1: [live feed] [raw capture preview] <-- flex row # Row 2: [opencv panel: editor + run btn + result] # Label the two panels @@ -961,9 +923,7 @@ def on_video_ready(event): ) else: - # ---------------------------------------------------------- - # Normal (original) layout - # ---------------------------------------------------------- + # Normal (vertical) layout element = div( self._canvas, video_container, @@ -976,7 +936,7 @@ def on_video_ready(event): element.classes.add("invent-webcam") element.classes.add("webcam-container") - # Kick off the camera stream + # Start the camera stream! self._setup_webcam_stream() - return element + return element \ No newline at end of file From 9d3a32abf7416c2e0c9ce668d559727f9435eb50 Mon Sep 17 00:00:00 2001 From: iliketocode2 Date: Fri, 10 Apr 2026 11:03:25 -0400 Subject: [PATCH 13/35] last changes before implementing donkey --- src/invent/ui/widgets/webcam.py | 28 ++++++---------------------- 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/src/invent/ui/widgets/webcam.py b/src/invent/ui/widgets/webcam.py index 10fb09e1..a23d35ab 100644 --- a/src/invent/ui/widgets/webcam.py +++ b/src/invent/ui/widgets/webcam.py @@ -53,6 +53,7 @@ class _Cv2Compat: """Provides a small subset of OpenCV functionality using Pillow and numpy when cv2 is not available.""" + COLOR_RGB2BGR = 1 COLOR_RGB2GRAY = 2 @@ -153,9 +154,7 @@ class Webcam(Widget): ) opencv_mode = BooleanProperty( - _( - "When True, enables the built-in OpenCV processing playground. " - ), + _("When True, enables the built-in OpenCV processing playground. "), default_value=False, group="behavior", ) @@ -212,7 +211,6 @@ def __init__(self, *args, **kwargs): self._opencv_status_elem = None #

for status text super().__init__(*args, **kwargs) - # Capture management def _store_capture(self, capture): capture = dict(capture) @@ -293,7 +291,6 @@ def photo_bytes(self, capture=None): return None return base64.b64decode(data_url.split(",", 1)[1]) - # Preview helpers def _show_capture_preview(self, capture): if not hasattr(self, "_capture_preview"): @@ -323,13 +320,11 @@ def _refresh_capture_preview(self): else: self._hide_capture_preview() - def trigger(self): """Trigger the current action (capture photo or start/stop recording).""" if hasattr(self, "_shutter_btn"): self._shutter_btn.click() - # Mode switching def set_mode(self, mode): """Set the active webcam mode when switching is enabled.""" @@ -354,7 +349,6 @@ def _current_mode(self): return getattr(self, "_active_mode", "photo") return self.mode - # Photo capture def capture_photo(self): """Capture a photo from the current video stream.""" @@ -398,7 +392,6 @@ def capture_photo(self): self.publish(self.photo_captured, webcam=self, capture=capture) - # Status helpers def _set_status(self, text): if not hasattr(self, "_status_elem"): @@ -408,7 +401,6 @@ def _set_status(self, text): def _timestamp(self): return int(time.time() * 1000) - # Video recording def start_recording(self): if hasattr(self, "_recorder"): @@ -432,7 +424,6 @@ def stop_recording(self): self._set_shutter_text() self._set_status("Saving video...") - # Callbacks def on_mode_changed(self): if not hasattr(self, "_controls"): @@ -486,7 +477,6 @@ def on_max_captures_changed(self): self._captures = self._captures[overflow:] self._refresh_capture_preview() - # UI helpers def _update_mode_buttons(self): for btn_info in self._mode_buttons: @@ -509,7 +499,6 @@ def _set_shutter_text(self): text = "Take" self._shutter_btn._dom_element.textContent = text - # Download helper def _download_canvas_as_image(self, capture=None): try: @@ -537,7 +526,6 @@ def _on_shutter_click(self, event): else: self.start_recording() - # Webcam stream def _setup_webcam_stream(self): try: @@ -619,7 +607,6 @@ def on_stop(event): except Exception as e: print(f"Error setting up recorder: {e}") - # Determine Camera Mode (video or photo) def _build_mode_buttons(self): self._mode_buttons = [] @@ -648,7 +635,6 @@ def build_mode_button(mode_name): self._update_mode_buttons() return modes_container - # OpenCV helpers def _set_opencv_status(self, text): """Update the status

element inside the OpenCV panel.""" @@ -763,7 +749,6 @@ def run_opencv(self, event=None): self._set_opencv_status(f"Error: {exc}") self._set_status("OpenCV error") - # OpenCV UI construction def _build_opencv_panel(self): """ @@ -802,11 +787,10 @@ def _build_opencv_panel(self): return opencv_panel - # render() - # - # This function constructs the entire DOM structure of the widget. - # It is called once when the widget is first rendered, and should + # + # This function constructs the entire DOM structure of the widget. + # It is called once when the widget is first rendered, and should # return the root element. def render(self): @@ -939,4 +923,4 @@ def on_video_ready(event): # Start the camera stream! self._setup_webcam_stream() - return element \ No newline at end of file + return element From f7872820fddd7c5d6d61d2f76a2ba25b30f38a74 Mon Sep 17 00:00:00 2001 From: iliketocode2 Date: Mon, 6 Apr 2026 16:06:22 -0400 Subject: [PATCH 14/35] update gitignore to not commit temp files --- .gitignore | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index aaef1261..8d26df39 100644 --- a/.gitignore +++ b/.gitignore @@ -120,4 +120,8 @@ test_suite.tar.gz invent.min.tar.gz static/default.css static/default.min.css -static/*.zip \ No newline at end of file +static/*.zip + +# All temp files +/temp +temp/ \ No newline at end of file From b8f684b1c1c703e251ba3e42092e66cac4d6633e Mon Sep 17 00:00:00 2001 From: iliketocode2 Date: Fri, 10 Apr 2026 11:21:09 -0400 Subject: [PATCH 15/35] planning --- src/invent/ui/widgets/webcam.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/invent/ui/widgets/webcam.py b/src/invent/ui/widgets/webcam.py index a23d35ab..5903f334 100644 --- a/src/invent/ui/widgets/webcam.py +++ b/src/invent/ui/widgets/webcam.py @@ -1,3 +1,28 @@ +""" +New Implementation and planning: + +- I should be able to channel to a donkey +- the worker does the heavy lifting with the OpenCV, the donkey is in between + and there is a lightweight micropython section which is the existing webcam + in invent +- take out everything from the widget except whats needed for4 the camera +- OpenCV related code runs on the donkey and channel logic runs as a function in main.py + (a function of the invent app_ +- the donkey --> I want a worker and I want you to run this python code on the worker + the worker exposes OpenCV functions like find a face or run an outline + the donly then goes and there is a flag that you can check to see it ready + the user has to tell the donkey to go (I think) and tell the donkey to find a face + and then the donkey comes back with + +cpython thigns are in the worker via the donkey +app logic is in a function within main.py or simply within main.py +webcam logic is in webcam + +""" + + + + """ A webcam widget for the Invent framework. From 5362003ad618c81a6fc5bb0bfd76540658de778f Mon Sep 17 00:00:00 2001 From: iliketocode2 Date: Mon, 13 Apr 2026 10:24:29 -0400 Subject: [PATCH 16/35] Huge reworking and pyscript wrapping to get donkey working --- examples/open_cv_playground/config.json | 1 - examples/open_cv_playground/index.html | 63 ++-- examples/open_cv_playground/main.py | 170 ++++++----- src/invent/tools/__init__.py | 19 ++ src/invent/tools/device.py | 201 +++++++++++- src/invent/ui/core/property.py | 8 +- src/invent/ui/widgets/webcam.py | 386 +----------------------- 7 files changed, 380 insertions(+), 468 deletions(-) diff --git a/examples/open_cv_playground/config.json b/examples/open_cv_playground/config.json index 6d03bf57..22f69ba9 100644 --- a/examples/open_cv_playground/config.json +++ b/examples/open_cv_playground/config.json @@ -1,5 +1,4 @@ { - "packages": ["opencv-python", "numpy", "Pillow"], "files": { "/static/invent.min.tar.gz": "./*" } diff --git a/examples/open_cv_playground/index.html b/examples/open_cv_playground/index.html index b5a391d6..e3540131 100644 --- a/examples/open_cv_playground/index.html +++ b/examples/open_cv_playground/index.html @@ -5,23 +5,31 @@ Test Card + + + - - + + - - - -

- - - + + +
+ + +
+ + + + + + \ No newline at end of file diff --git a/examples/open_cv_playground/main.py b/examples/open_cv_playground/main.py index e26843bb..6c74420f 100644 --- a/examples/open_cv_playground/main.py +++ b/examples/open_cv_playground/main.py @@ -1,58 +1,19 @@ """ -This app is a simple test card for the theme system. We ensure all the various -UI aspects of the Invent framework are shown in a page, and allow the user to -select different "themes" to see how they affect the appearance of the page. +OpenCV playground with a camera-only webcam widget and Donkey worker pipeline. """ -import base64 -import html as html_lib -import io -import random - -import cv2 -import numpy as np -from PIL import Image as PILImage +import asyncio import invent +from invent.tools import create_opencv_donkey from invent.ui import * # Datastore ############################################################################ -await invent.setup( - richtext="This is a **rich text editor**. It supports _formatting_, [links](https://inventframework.org/), and more!" -) +await invent.setup() # Code ################################################################################# -# Create some sample appointments for the calendar widget based upon today's month and -# year, so that the calendar will show some appointments when it is rendered. Needs to -# include both just plain dates, and datetimes with times, to show how both are rendered. -from datetime import date, datetime - - -def navigate(message): - """ - Handle navigation between pages based on button clicks / names. - """ - # Extract the page name from the button name. The button names are in the format - # "pagename_button", so we split on "_button" and take the first part to get the page - # name. - page_name = message.source.name.split("_button")[0] - invent.show_page(page_name) - - -invent.subscribe(navigate, to_channel="navigate", when_subject=["press"]) - - -# Some random funky backgrounds for page 4. It's just boring CSS. -backgrounds = [ - "linear-gradient(to bottom, #ff7e5f, #feb47b)", # Linear gradient. - "#3498db", # A solid single colour. - f"linear-gradient(var(--bg-image-overlay), var(--bg-image-overlay)), url('{invent.media.images.repeat_image.png}') repeat", # A repeated image. - f"linear-gradient(var(--bg-image-overlay), var(--bg-image-overlay)), url('{invent.media.images.random.png}') center / cover no-repeat", # A centered, cover image. -] - - # Pre-define some webcam variations preview_webcam = Webcam( photo_output="download", @@ -60,40 +21,102 @@ def navigate(message): ) opencv_webcam = Webcam( - opencv_mode=True, photo_output="preview", max_captures=5, ) +opencv_output = Image( + width="100%", +) -# Keep the webcam's code in sync whenever the editor changes. -def _on_code_changed(message): - opencv_webcam._opencv_code = message.code +opencv_status = Label( + text="Donkey idle. Press 'Start Donkey' to initialize the worker.", +) +opencv_worker = None -opencv_code_editor = CodeEditor( - theme="light", - code=opencv_webcam._DEFAULT_OPENCV_CODE, -) -invent.subscribe( - _on_code_changed, - to_channel=opencv_code_editor.channel, - when_subject="changed", -) +async def ensure_worker(): + """Start the Donkey worker and bootstrap OpenCV when needed.""" + global opencv_worker + if opencv_worker is not None and opencv_worker.ready: + opencv_status.text = "Donkey ready." + return + opencv_status.text = "Starting Donkey worker..." + try: + opencv_worker = await create_opencv_donkey( + result_key="opencv.worker.status" + ) + opencv_status.text = ( + "Donkey ready. Capture a photo and choose an action." + ) + except Exception as exc: + opencv_status.text = f"Failed to start donkey worker: {exc}" + + +def _latest_capture_data_url(): + capture = opencv_webcam.latest_capture(media_type="photo") + if capture is None: + return None + return capture.get("data_url") -def run_opencv_from_button(message): - """Run OpenCV using the current code in the editor.""" - opencv_webcam.run_opencv() +async def run_worker_action(action): + if opencv_worker is None or not opencv_worker.ready: + opencv_status.text = "Donkey is not ready. Press 'Start Donkey' first." + return + + data_url = _latest_capture_data_url() + if not data_url: + opencv_status.text = "Capture a photo first, then run an action." + return + + opencv_status.text = f"Running {action}..." + try: + result = await opencv_worker.run(action, data_url) + except Exception as exc: + opencv_status.text = f"Worker error: {exc}" + return + + if isinstance(result, dict) and result.get("ok"): + processed_data_url = result.get("data_url") + if processed_data_url: + opencv_output.image = processed_data_url + if action == "find_face": + face_count = result.get("count", 0) + opencv_status.text = f"Done. Faces found: {face_count}." + else: + opencv_status.text = "Done. Outline generated." + return + + opencv_status.text = "Worker returned no displayable result." + + +async def handle_opencv_controls(message): + button_name = getattr(message.source, "name", "") + + if button_name == "start_donkey_button": + await ensure_worker() + return + + if button_name == "find_face_button": + await run_worker_action("find_face") + return + + if button_name == "outline_button": + await run_worker_action("outline") invent.subscribe( - run_opencv_from_button, + handle_opencv_controls, to_channel="opencv-controls", when_subject=["press"], ) + +# Lazy boot so the first interaction is still explicit via Start Donkey. +asyncio.create_task(ensure_worker()) + # User Interface ####################################################################### app = invent.App( @@ -111,21 +134,32 @@ def run_opencv_from_button(message): Label(text="## OpenCV webcam playground"), Label( text=( - "Snap a photo above, edit the snippet, then press " - "**Run OpenCV (channel button)**. Available names: `capture`, `image`, " - "`array_of_rgb`, `array_of_bgr`, `grey`, `cv2`, `np`, " - "`PILImage`. Assign any of `result_image`, " - "`processed_image`, `output_image`, or `result` to " - "display the output." + "The webcam remains lightweight and only captures images. " + "OpenCV runs in a Donkey worker. " + "Press **Start Donkey**, capture a photo, then run **Find Face** " + "or **Outline**." ) ), opencv_webcam, Button( - text="Run OpenCV", + text="Start Donkey", + name="start_donkey_button", purpose="PRIMARY", channel="opencv-controls", ), - opencv_code_editor, + Button( + text="Find Face", + name="find_face_button", + channel="opencv-controls", + ), + Button( + text="Outline", + name="outline_button", + channel="opencv-controls", + ), + opencv_status, + Label(text="Processed output"), + opencv_output, ], ), ], diff --git a/src/invent/tools/__init__.py b/src/invent/tools/__init__.py index e69de29b..35cf01df 100644 --- a/src/invent/tools/__init__.py +++ b/src/invent/tools/__init__.py @@ -0,0 +1,19 @@ +from .device import ( + DONKEY_BUSY, + DONKEY_CREATING, + DONKEY_ERROR, + DONKEY_KILLED, + DONKEY_READY, + OpenCVDonkey, + create_opencv_donkey, +) + +__all__ = [ + "DONKEY_BUSY", + "DONKEY_CREATING", + "DONKEY_ERROR", + "DONKEY_KILLED", + "DONKEY_READY", + "OpenCVDonkey", + "create_opencv_donkey", +] diff --git a/src/invent/tools/device.py b/src/invent/tools/device.py index 937caad3..387362ee 100644 --- a/src/invent/tools/device.py +++ b/src/invent/tools/device.py @@ -1,6 +1,199 @@ """ -Vibrate -Microphone -Flashlight -Camera +Device related helpers. + +This module contains helpers for running heavyweight device processing in a +PyScript Donkey worker whilst keeping UI widgets lightweight. """ + +import invent +import asyncio +from pyscript import window +from pyscript.ffi import to_js + +# Datastore flags for Donkey worker status. +DONKEY_CREATING = "_DEVICE_DONKEY_CREATING" +DONKEY_READY = "_DEVICE_DONKEY_READY" +DONKEY_BUSY = "_DEVICE_DONKEY_BUSY" +DONKEY_ERROR = "_DEVICE_DONKEY_ERROR" +DONKEY_KILLED = "_DEVICE_DONKEY_KILLED" + + +# The OpenCV worker code written to the worker's virtual filesystem as a +# proper Python module. Using execute("from _opencv_worker import *") then +# pulls everything into the worker's global scope, which is the only +# reliable way to define persistent callables in a donkey worker — +# process() chokes on large multiline strings via xterm-readline, and +# execute() alone scopes defs locally and discards them. +_OPENCV_WORKER_MODULE = r""" +import base64 +import cv2 +import numpy as np + + +def _decode_data_url(data_url): + if not data_url or "," not in data_url: + raise ValueError("Expected an image data URL") + payload = data_url.split(",", 1)[1] + binary = base64.b64decode(payload) + buf = np.frombuffer(binary, dtype=np.uint8) + image = cv2.imdecode(buf, cv2.IMREAD_COLOR) + if image is None: + raise ValueError("Could not decode input image") + return image + + +def _encode_png_data_url(image): + ok, encoded = cv2.imencode(".png", image) + if not ok: + raise ValueError("Could not encode processed image") + payload = base64.b64encode(encoded.tobytes()).decode("ascii") + return "data:image/png;base64," + payload + + +def worker_find_face(data_url): + image = _decode_data_url(data_url) + grey = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + detector = cv2.CascadeClassifier( + cv2.data.haarcascades + "haarcascade_frontalface_default.xml" + ) + faces = detector.detectMultiScale( + grey, + scaleFactor=1.2, + minNeighbors=5, + minSize=(40, 40), + ) + for x, y, w, h in faces: + cv2.rectangle(image, (x, y), (x + w, y + h), (0, 255, 0), 2) + return { + "ok": True, + "kind": "find_face", + "count": int(len(faces)), + "data_url": _encode_png_data_url(image), + } + + +def worker_outline(data_url): + image = _decode_data_url(data_url) + grey = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + edges = cv2.Canny(grey, 80, 160) + outlined = cv2.cvtColor(edges, cv2.COLOR_GRAY2BGR) + return { + "ok": True, + "kind": "outline", + "count": 0, + "data_url": _encode_png_data_url(outlined), + } + + +def worker_run(action, data_url): + if action == "find_face": + return worker_find_face(data_url) + if action == "outline": + return worker_outline(data_url) + raise ValueError("Unsupported worker action: " + str(action)) +""" + + +async def _await_donkey_bridge(timeout_seconds=3.0): + """Wait for window.__invent_make_donkey to become available.""" + interval = 0.05 + checks = max(1, int(timeout_seconds / interval)) + for _ in range(checks): + fn = getattr(window, "__invent_make_donkey", None) + if callable(fn): + return + await asyncio.sleep(interval) + raise RuntimeError( + f"Donkey bridge not available after {timeout_seconds}s. " + "Ensure index.html imports core.js and defines window.__invent_make_donkey." + ) + + +class OpenCVDonkey: + """Thin wrapper around a PyScript donkey used for OpenCV processing.""" + + def __init__(self, donkey, result_key=None): + self._donkey = donkey + self._result_key = result_key + self._ready = False + + @property + def ready(self): + return self._ready + + def _set_status(self, status): + if self._result_key: + invent.datastore[self._result_key] = status + + async def initialize(self): + self._set_status(DONKEY_BUSY) + # Write the module to the worker's virtual filesystem as a proper + # .py file, then import it into global scope with execute(). + # This sidesteps two known donkey limitations: + # - process() feeds code through xterm-readline line-by-line, + # which breaks on large multiline strings. + # - execute() uses exec() which scopes `def` statements locally + # and discards them after the call returns. + # Writing a file and importing it is the correct pattern. + await self._donkey.execute( + f"open('_opencv_worker.py', 'w').write({_OPENCV_WORKER_MODULE!r})" + ) + await self._donkey.execute("from _opencv_worker import *") + self._ready = True + self._set_status(DONKEY_READY) + + async def run(self, action, data_url): + if not self._ready: + raise RuntimeError("Donkey is not ready yet") + self._set_status(DONKEY_BUSY) + try: + result = await self._donkey.evaluate( + f"worker_run({action!r}, {data_url!r})" + ) + self._set_status(DONKEY_READY) + return result + except Exception as exc: + self._set_status(f"{DONKEY_ERROR}: {exc}") + raise + + async def kill(self): + await self._donkey.kill() + self._ready = False + self._set_status(DONKEY_KILLED) + + +async def create_opencv_donkey(result_key=None, *, packages=None): + """ + Create and initialise an OpenCV donkey worker. + + Parameters + ---------- + result_key : str | None + Datastore key for status updates, e.g. "opencv.worker.status". + packages : list[str] | None + Extra packages to install in the worker (opencv-python and numpy + are always included). + """ + if packages is None: + packages = [] + + base_packages = ["opencv-python", "numpy"] + all_packages = base_packages + [p for p in packages if p not in base_packages] + + if result_key: + invent.datastore[result_key] = DONKEY_CREATING + + await _await_donkey_bridge() + + options = to_js({ + "type": "py", + "persistent": True, + "terminal": "#donkey-terminal", + "config": {"packages": all_packages}, + }) + + donkey = await window.__invent_make_donkey(options) + + worker = OpenCVDonkey(donkey, result_key=result_key) + await worker.initialize() + return worker \ No newline at end of file diff --git a/src/invent/ui/core/property.py b/src/invent/ui/core/property.py index c60989fd..b17d4473 100644 --- a/src/invent/ui/core/property.py +++ b/src/invent/ui/core/property.py @@ -406,11 +406,15 @@ def validate(self, value): length = len(value) if self.min_length and length < self.min_length: raise ValidationError( - _("The length of the value is less than min_value allowed.") + _( + "The length of the value is less than min_value allowed." + ) ) if self.max_length and length > self.max_length: raise ValidationError( - _("The length of the value is more than max_value allowed.") + _( + "The length of the value is more than max_value allowed." + ) ) return value diff --git a/src/invent/ui/widgets/webcam.py b/src/invent/ui/widgets/webcam.py index 5903f334..c976ba4f 100644 --- a/src/invent/ui/widgets/webcam.py +++ b/src/invent/ui/widgets/webcam.py @@ -1,35 +1,8 @@ -""" -New Implementation and planning: - -- I should be able to channel to a donkey -- the worker does the heavy lifting with the OpenCV, the donkey is in between - and there is a lightweight micropython section which is the existing webcam - in invent -- take out everything from the widget except whats needed for4 the camera -- OpenCV related code runs on the donkey and channel logic runs as a function in main.py - (a function of the invent app_ -- the donkey --> I want a worker and I want you to run this python code on the worker - the worker exposes OpenCV functions like find a face or run an outline - the donly then goes and there is a flag that you can check to see it ready - the user has to tell the donkey to go (I think) and tell the donkey to find a face - and then the donkey comes back with - -cpython thigns are in the worker via the donkey -app logic is in a function within main.py or simply within main.py -webcam logic is in webcam - -""" - - - - """ A webcam widget for the Invent framework. Enables photo capture and video recording with automatic downloads. Supports live video preview from the user's webcam. -Supports an opencv_mode for running OpenCV processing on captured images, -with side-by-side layout and no automatic downloads. Copyright (c) 2019-present Invent contributors. @@ -55,98 +28,14 @@ Event, IntegerProperty, ) -from pyscript.web import div, video, button, canvas, img, p +from pyscript.web import div, video, button, canvas, img from pyscript.ffi import create_proxy - -try: - import cv2 as _cv2 -except ImportError: - _cv2 = None - -try: - import numpy as _np -except ImportError: - _np = None - -try: - from PIL import Image as _PILImage - from PIL import ImageFilter as _PILImageFilter -except ImportError: - _PILImage = None - _PILImageFilter = None - - -class _Cv2Compat: - """Provides a small subset of OpenCV functionality using Pillow and numpy when cv2 is not available.""" - - COLOR_RGB2BGR = 1 - COLOR_RGB2GRAY = 2 - - @staticmethod - def cvtColor(array, code): - if _np is None: - raise RuntimeError("numpy is required for cv2 compatibility mode") - - if code == _Cv2Compat.COLOR_RGB2BGR: - return array[..., ::-1].copy() - - if code == _Cv2Compat.COLOR_RGB2GRAY: - if array.ndim != 3 or array.shape[2] < 3: - raise ValueError("Expected RGB image with shape (H, W, 3)") - grey = _np.dot(array[..., :3], _np.array([0.299, 0.587, 0.114])) - return grey.astype(_np.uint8) - - raise ValueError(f"Unsupported color conversion code: {code}") - - @staticmethod - def Canny(grey, threshold1, threshold2): - del threshold1, threshold2 - if _PILImage is None or _PILImageFilter is None or _np is None: - raise RuntimeError( - "Pillow and numpy are required for edge detection" - ) - pil = _PILImage.fromarray(grey.astype(_np.uint8), mode="L") - edges = pil.filter(_PILImageFilter.FIND_EDGES) - return _np.array(edges, dtype=_np.uint8) - - import base64 -import io class Webcam(Widget): """ - A webcam widget with photo capture, video recording, and optional - OpenCV processing capabilities. - - opencv_mode - ----------- - When True the widget renders a self-contained OpenCV playground: - • Captured image appears **side-by-side** with the live feed. - • Automatic file downloads are suppressed regardless of photo_output. - • A code editor pre-filled with a starter snippet and a "Run OpenCV" - button are rendered below the video row. - • The processed result is shown next to the raw capture. - - The code snippet executed by "Run OpenCV" has the following names bound - in its namespace: - - capture : the raw capture dict stored by the widget - image : PIL Image (RGB) of the captured photo - array_of_rgb : numpy uint8 array, shape (H, W, 3), RGB order - array_of_bgr : numpy uint8 array, shape (H, W, 3), BGR order - grey : numpy uint8 array, shape (H, W), greyscale - cv2 : the cv2 module - np : the numpy module - PILImage : PIL.Image module - - The snippet should assign one of the following names to be shown as the - result image: - - result_image | processed_image | output_image | result - - Any of those may be a numpy ndarray or a PIL Image; both are handled. - If none is assigned the original captured image is shown unchanged. + A webcam widget with photo capture and video recording. """ photo_output = ChoiceProperty( @@ -161,7 +50,7 @@ class Webcam(Widget): "The maximum number of captured images and recordings to keep in memory." ), default_value=10, - minimum=0, + min_value=0, group="behavior", ) @@ -178,16 +67,6 @@ class Webcam(Widget): group="style", ) - opencv_mode = BooleanProperty( - _("When True, enables the built-in OpenCV processing playground. "), - default_value=False, - group="behavior", - ) - - opencv_default_code = _( - "The default OpenCV snippet shown in the code editor." - ) - photo_captured = Event( _("Sent when a photo is captured."), webcam=_("The Webcam widget that captured the photo."), @@ -199,19 +78,6 @@ class Webcam(Widget): webcam=_("The Webcam widget that recorded the video."), ) - # Starting Code: - - _DEFAULT_OPENCV_CODE = ( - "# Names available: capture, image, array_of_rgb, array_of_bgr,\n" - "# grey, cv2, np, PILImage\n" - "#\n" - "# Assign result_image (PIL Image or numpy array) to show output.\n" - "\n" - "grey = cv2.cvtColor(array_of_rgb, cv2.COLOR_RGB2GRAY)\n" - "edges = cv2.Canny(grey, 80, 160)\n" - "result_image = PILImage.fromarray(edges)\n" - ) - @classmethod def icon(cls): return ( @@ -225,15 +91,8 @@ def icon(cls): ) def __init__(self, *args, **kwargs): - # Invent may render before all properties are fully applied. Capture - # the requested mode from kwargs so initial layout is correct. - self._initial_opencv_mode = bool(kwargs.get("opencv_mode", False)) self._captures = [] self._capture_counter = 0 - # opencv_mode internal state - self._opencv_code = self._DEFAULT_OPENCV_CODE - self._opencv_result_img_elem = None # the DOM wrapper for result - self._opencv_status_elem = None #

for status text super().__init__(*args, **kwargs) # Capture management @@ -248,11 +107,7 @@ def _store_capture(self, capture): ) self._captures.append(capture) - preview_enabled = ( - self.photo_output in ("preview", "both") - or self.opencv_mode - or self._initial_opencv_mode - ) + preview_enabled = self.photo_output in ("preview", "both") if self.max_captures and self.max_captures > 0: overflow = len(self._captures) - self.max_captures @@ -331,11 +186,7 @@ def _hide_capture_preview(self): self._capture_preview.classes.add("hidden") def _refresh_capture_preview(self): - preview_enabled = ( - self.photo_output in ("preview", "both") - or self.opencv_mode - or self._initial_opencv_mode - ) + preview_enabled = self.photo_output in ("preview", "both") if not preview_enabled: self._hide_capture_preview() return @@ -402,12 +253,8 @@ def capture_photo(self): download_enabled = self.photo_output in ( "download", "both", - ) and not (self.opencv_mode or self._initial_opencv_mode) - preview_enabled = ( - self.photo_output in ("preview", "both") - or self.opencv_mode - or self._initial_opencv_mode ) + preview_enabled = self.photo_output in ("preview", "both") if download_enabled: self._download_canvas_as_image(capture) @@ -660,171 +507,9 @@ def build_mode_button(mode_name): self._update_mode_buttons() return modes_container - # OpenCV helpers - def _set_opencv_status(self, text): - """Update the status

element inside the OpenCV panel.""" - if self._opencv_status_elem is not None: - self._opencv_status_elem._dom_element.textContent = text - - def run_opencv(self, event=None): - """ - Execute the OpenCV snippet from the embedded code editor against - the most recent captured photo. - - This method is also callable from outside the widget, e.g. from - main.py, if you keep a reference to the Webcam instance: - - my_webcam.run_opencv() - """ - self._set_opencv_status("Running OpenCV...") - self._set_status("Running OpenCV...") - - if _cv2 is None: - if _np is None or _PILImage is None or _PILImageFilter is None: - self._set_opencv_status( - "OpenCV unavailable. Install numpy + Pillow to run compatibility mode." - ) - self._set_status("OpenCV unavailable") - return - - if _np is None or _PILImage is None: - self._set_opencv_status( - "Image processing requires numpy and Pillow in this runtime." - ) - self._set_status("Image processing unavailable") - return - - cv2_module = _cv2 or _Cv2Compat - - capture = self.latest_capture(media_type="photo") - if not capture: - self._set_opencv_status( - "No photo captured yet. Press 'Take' first." - ) - self._set_status("No photo to process") - return - - raw_bytes = self.photo_bytes(capture=capture) - if raw_bytes is None: - self._set_opencv_status("Could not decode the latest capture.") - self._set_status("Capture decode failed") - return - - # Run the current snippet (set externally via self._opencv_code). - code_to_run = self._opencv_code - - try: - source_image = _PILImage.open(io.BytesIO(raw_bytes)).convert("RGB") - array_of_rgb = _np.array(source_image) - array_of_bgr = cv2_module.cvtColor( - array_of_rgb, cv2_module.COLOR_RGB2BGR - ) - grey = cv2_module.cvtColor(array_of_rgb, cv2_module.COLOR_RGB2GRAY) - - namespace = { - "capture": capture, - "image": source_image, - "array_of_rgb": array_of_rgb, - "array_of_bgr": array_of_bgr, - "grey": grey, - "cv2": cv2_module, - "np": _np, - "PILImage": _PILImage, - } - - exec(code_to_run, namespace, namespace) # noqa: S102 - - result = ( - namespace.get("result_image") - or namespace.get("processed_image") - or namespace.get("output_image") - or namespace.get("result") - ) - - if isinstance(result, _np.ndarray): - # Greyscale which needs PIL conversion - if result.ndim == 2: - result = _PILImage.fromarray(result) - else: - result = _PILImage.fromarray(result) - - if result is None: - result = source_image # show original if snippet set nothing - - if isinstance(result, _PILImage.Image): - buf = io.BytesIO() - result.save(buf, format="PNG") - encoded = base64.b64encode(buf.getvalue()).decode("ascii") - self._opencv_result_img_elem.src = ( - f"data:image/png;base64,{encoded}" - ) - self._opencv_result_img_elem.classes.remove("hidden") - self._set_opencv_status( - f"OK — {array_of_rgb.shape[1]}×{array_of_rgb.shape[0]} px " - f"| capture {capture.get('id', '?')}" - ) - self._set_status("OpenCV processing complete") - else: - self._set_opencv_status( - "Snippet did not produce a displayable image." - ) - self._set_status("OpenCV produced no displayable image") - - except Exception as exc: - self._set_opencv_status(f"Error: {exc}") - self._set_status("OpenCV error") - - # OpenCV UI construction - def _build_opencv_panel(self): - """ - Build and return the OpenCV result panel that sits below the video - row when opencv_mode=True. - - The code editor and run button live outside the widget (in the app) - and communicate via self._opencv_code / run_opencv(). This panel - only handles displaying the processed result and status. - - Populates: - self._opencv_result_img_elem : img element for the result - self._opencv_status_elem : p element for status text - """ - # ---- result image ---- - result_img = img() - result_img.id = f"{self.id}-opencv-result" - result_img.classes.add("invent-webcam-opencv-result-img") - result_img.classes.add("hidden") - self._opencv_result_img_elem = result_img - - result_label_el = p("Processed result:") - result_label_el.classes.add("invent-webcam-opencv-label") - - result_container = div(result_label_el, result_img) - result_container.classes.add("invent-webcam-opencv-result-panel") - - # ---- status line ---- - status_p = p("Run OpenCV to see results.") - status_p.classes.add("invent-webcam-opencv-status") - self._opencv_status_elem = status_p - - # ---- assemble panel ---- - opencv_panel = div(status_p, result_container) - opencv_panel.classes.add("invent-webcam-opencv-panel") - - return opencv_panel - - # render() - # - # This function constructs the entire DOM structure of the widget. - # It is called once when the widget is first rendered, and should - # return the root element. - def render(self): """ Render the webcam widget. - - Normal mode → identical layout to the original widget. - opencv_mode → video + raw-capture side-by-side in a flex row, - followed by a code-editor + result panel below. """ # ---- hidden canvas for photo capture ---- self._canvas = canvas() @@ -890,57 +575,14 @@ def on_video_ready(event): self._capture_preview.classes.add("capture-preview") self._capture_preview.classes.add("hidden") - if self.opencv_mode or self._initial_opencv_mode: - # ---------------------------------------------------------- - # opencv_mode layout - # ---------------------------------------------------------- - # Row 1: [live feed] [raw capture preview] <-- flex row - # Row 2: [opencv panel: editor + run btn + result] - - # Label the two panels - live_label = p("Live feed") - live_label.classes.add("invent-webcam-opencv-label") - - capture_label = p("Captured image") - capture_label.classes.add("invent-webcam-opencv-label") - - live_col = div(live_label, video_container, self._controls) - live_col.classes.add("invent-webcam-opencv-col") - - capture_preview_box = div(self._capture_preview) - capture_preview_box.classes.add("invent-webcam-box") - capture_preview_box.classes.add("webcam-box") - capture_preview_box.classes.add("invent-webcam-opencv-preview-box") - - capture_col = div( - capture_label, - capture_preview_box, - self._indicators, - ) - capture_col.classes.add("invent-webcam-opencv-col") - - video_row = div(live_col, capture_col) - video_row.classes.add("invent-webcam-opencv-video-row") - - opencv_panel = self._build_opencv_panel() - - element = div( - self._canvas, - video_row, - opencv_panel, - id=self.id, - ) - - else: - # Normal (vertical) layout - element = div( - self._canvas, - video_container, - self._controls, - self._indicators, - self._capture_preview, - id=self.id, - ) + element = div( + self._canvas, + video_container, + self._controls, + self._indicators, + self._capture_preview, + id=self.id, + ) element.classes.add("invent-webcam") element.classes.add("webcam-container") From e3a2b57b9458c5f99da804da7841b2c7c18e375b Mon Sep 17 00:00:00 2001 From: iliketocode2 Date: Mon, 13 Apr 2026 10:28:19 -0400 Subject: [PATCH 17/35] remove max-captures feature from webcam --- src/invent/ui/widgets/webcam.py | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/src/invent/ui/widgets/webcam.py b/src/invent/ui/widgets/webcam.py index c976ba4f..aa12c2dc 100644 --- a/src/invent/ui/widgets/webcam.py +++ b/src/invent/ui/widgets/webcam.py @@ -45,15 +45,6 @@ class Webcam(Widget): group="behavior", ) - max_captures = IntegerProperty( - _( - "The maximum number of captured images and recordings to keep in memory." - ), - default_value=10, - min_value=0, - group="behavior", - ) - mode = ChoiceProperty( _("Webcam mode: photo, video, or both."), default_value="both", @@ -109,11 +100,6 @@ def _store_capture(self, capture): preview_enabled = self.photo_output in ("preview", "both") - if self.max_captures and self.max_captures > 0: - overflow = len(self._captures) - self.max_captures - if overflow > 0: - self._captures = self._captures[overflow:] - if capture["type"] == "photo" and preview_enabled: self._show_capture_preview(capture) @@ -342,13 +328,6 @@ def on_photo_output_changed(self): return self._refresh_capture_preview() - def on_max_captures_changed(self): - if self.max_captures and self.max_captures > 0: - overflow = len(self._captures) - self.max_captures - if overflow > 0: - self._captures = self._captures[overflow:] - self._refresh_capture_preview() - # UI helpers def _update_mode_buttons(self): for btn_info in self._mode_buttons: From 2a2ec88e24d21f70e4e45031b96e93b4db0f19c9 Mon Sep 17 00:00:00 2001 From: iliketocode2 Date: Mon, 13 Apr 2026 10:39:13 -0400 Subject: [PATCH 18/35] It finally works! --- examples/open_cv_playground/main.py | 22 ++++++++++++++----- src/invent/tools/device.py | 34 +++++++++++++++++------------ 2 files changed, 36 insertions(+), 20 deletions(-) diff --git a/examples/open_cv_playground/main.py b/examples/open_cv_playground/main.py index 6c74420f..2727c597 100644 --- a/examples/open_cv_playground/main.py +++ b/examples/open_cv_playground/main.py @@ -17,12 +17,10 @@ # Pre-define some webcam variations preview_webcam = Webcam( photo_output="download", - max_captures=5, ) opencv_webcam = Webcam( photo_output="preview", - max_captures=5, ) opencv_output = Image( @@ -78,18 +76,30 @@ async def run_worker_action(action): opencv_status.text = f"Worker error: {exc}" return - if isinstance(result, dict) and result.get("ok"): - processed_data_url = result.get("data_url") + if result is None: + opencv_status.text = "Worker returned no result." + return + + getter = getattr(result, "get", None) + if callable(getter): + ok = getter("ok") + processed_data_url = getter("data_url") + face_count = getter("count", 0) + else: + ok = False + processed_data_url = None + face_count = 0 + + if ok: if processed_data_url: opencv_output.image = processed_data_url if action == "find_face": - face_count = result.get("count", 0) opencv_status.text = f"Done. Faces found: {face_count}." else: opencv_status.text = "Done. Outline generated." return - opencv_status.text = "Worker returned no displayable result." + opencv_status.text = f"Worker returned no displayable result ({type(result).__name__})." async def handle_opencv_controls(message): diff --git a/src/invent/tools/device.py b/src/invent/tools/device.py index 387362ee..80dcb9fc 100644 --- a/src/invent/tools/device.py +++ b/src/invent/tools/device.py @@ -7,15 +7,16 @@ import invent import asyncio +import json from pyscript import window from pyscript.ffi import to_js # Datastore flags for Donkey worker status. DONKEY_CREATING = "_DEVICE_DONKEY_CREATING" -DONKEY_READY = "_DEVICE_DONKEY_READY" -DONKEY_BUSY = "_DEVICE_DONKEY_BUSY" -DONKEY_ERROR = "_DEVICE_DONKEY_ERROR" -DONKEY_KILLED = "_DEVICE_DONKEY_KILLED" +DONKEY_READY = "_DEVICE_DONKEY_READY" +DONKEY_BUSY = "_DEVICE_DONKEY_BUSY" +DONKEY_ERROR = "_DEVICE_DONKEY_ERROR" +DONKEY_KILLED = "_DEVICE_DONKEY_KILLED" # The OpenCV worker code written to the worker's virtual filesystem as a @@ -147,9 +148,10 @@ async def run(self, action, data_url): raise RuntimeError("Donkey is not ready yet") self._set_status(DONKEY_BUSY) try: - result = await self._donkey.evaluate( - f"worker_run({action!r}, {data_url!r})" + payload = await self._donkey.evaluate( + f"__import__('json').dumps(worker_run({action!r}, {data_url!r}))" ) + result = json.loads(payload) self._set_status(DONKEY_READY) return result except Exception as exc: @@ -178,22 +180,26 @@ async def create_opencv_donkey(result_key=None, *, packages=None): packages = [] base_packages = ["opencv-python", "numpy"] - all_packages = base_packages + [p for p in packages if p not in base_packages] + all_packages = base_packages + [ + p for p in packages if p not in base_packages + ] if result_key: invent.datastore[result_key] = DONKEY_CREATING await _await_donkey_bridge() - options = to_js({ - "type": "py", - "persistent": True, - "terminal": "#donkey-terminal", - "config": {"packages": all_packages}, - }) + options = to_js( + { + "type": "py", + "persistent": True, + "terminal": "#donkey-terminal", + "config": {"packages": all_packages}, + } + ) donkey = await window.__invent_make_donkey(options) worker = OpenCVDonkey(donkey, result_key=result_key) await worker.initialize() - return worker \ No newline at end of file + return worker From dfcd1139676f02062f62c9b28d196babe10afb91 Mon Sep 17 00:00:00 2001 From: iliketocode2 Date: Mon, 13 Apr 2026 11:08:37 -0400 Subject: [PATCH 19/35] Lots of simplifications, add code editor --- examples/open_cv_playground/index.html | 23 ++++---- examples/open_cv_playground/main.py | 74 ++++++++++++------------- src/invent/tools/device.py | 75 +++++++++++++------------- 3 files changed, 87 insertions(+), 85 deletions(-) diff --git a/examples/open_cv_playground/index.html b/examples/open_cv_playground/index.html index e3540131..0432e1d0 100644 --- a/examples/open_cv_playground/index.html +++ b/examples/open_cv_playground/index.html @@ -6,6 +6,7 @@ Test Card -

+ - \ No newline at end of file diff --git a/examples/open_cv_playground/main.py b/examples/open_cv_playground/main.py index b3e1091a..a8fbb173 100644 --- a/examples/open_cv_playground/main.py +++ b/examples/open_cv_playground/main.py @@ -21,11 +21,10 @@ opencv_webcam = Webcam( photo_output="preview", + preview_layout="side-by-side", + mode="photo", ) -opencv_output = Image( - width="100%", -) opencv_status = Label( text="Donkey starting...", @@ -108,7 +107,7 @@ async def run_worker_code(): if ok: if processed_data_url: - opencv_output.image = processed_data_url + opencv_webcam.show_image(processed_data_url) opencv_status.text = "Done. Custom OpenCV code executed." return @@ -164,8 +163,6 @@ async def handle_opencv_controls(message): ), opencv_code_editor, opencv_status, - Label(text="Processed output"), - opencv_output, ], ), ], diff --git a/src/invent/themes/default.css b/src/invent/themes/default.css index 8e099465..b73ddade 100644 --- a/src/invent/themes/default.css +++ b/src/invent/themes/default.css @@ -2773,95 +2773,71 @@ figure.invent-avatar:focus-visible { z-index: 1; } -.invent-webcam-opencv-video-row { - align-items: stretch; - display: grid; - gap: var(--spacing-lg); - grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr); -} - -.invent-webcam-opencv-col .invent-webcam-box { - min-height: 220px; +/* Media row: transparent by default so video box stacks normally in the + * flex-column webcam container. Activated side-by-side with modifier class. */ +.invent-webcam-media-row { + display: contents; } -.invent-webcam-opencv-col { - display: flex; - flex-direction: column; - gap: var(--spacing-sm); - min-width: 0; -} - -.invent-webcam-opencv-col video, -.invent-webcam-opencv-col img { - display: block; - height: auto; - width: 100%; -} - -.invent-webcam-opencv-preview-box { - position: relative; +/* Capture preview in stacked mode: full-width img below the video. */ +.invent-webcam-preview-col { + width: 100%; } -.invent-webcam-opencv-preview-box .invent-webcam-capture-preview { - height: 100%; - object-fit: cover; - width: 100%; +.invent-webcam-preview-col .invent-webcam-capture-preview { + border-radius: 12px; + display: block; + height: auto; + width: 100%; } -.invent-webcam-opencv-label { - color: var(--webcam-text-secondary); - font-family: var(--font-mono); - font-size: var(--font-size-xs); - margin: 0; - text-transform: uppercase; +.invent-webcam-preview-col .invent-webcam-capture-preview.hidden { + display: none; } -.invent-webcam-opencv-panel { - background: var(--main-background); - border: var(--border-width) solid var(--primary-light); - border-radius: var(--border-radius-lg); - display: flex; - flex-direction: column; - gap: var(--spacing-sm); - padding: var(--spacing-md); +/* Side-by-side mode: media row becomes a flex row, both columns equal size. */ +.invent-webcam--side-by-side { + min-height: 0; } -.invent-webcam-opencv-run-btn { - align-self: flex-start; - background: var(--primary-light); - border: var(--border-width) solid var(--primary); - border-radius: var(--border-radius-lg); - color: var(--primary-text); - cursor: pointer; - font-family: var(--font); - font-size: var(--font-size-sm); - font-weight: 600; - padding: var(--spacing-sm) var(--spacing-lg); +.invent-webcam--side-by-side .invent-webcam-media-row { + display: flex; + gap: var(--spacing-lg); + align-items: flex-start; } -.invent-webcam-opencv-run-btn:hover { - background: var(--primary-active); +.invent-webcam--side-by-side .invent-webcam-media-row .invent-webcam-box { + flex: 1; + min-width: 0; + min-height: 0; + width: auto; } -.invent-webcam-opencv-status { - color: var(--webcam-text-secondary); - font-family: var(--font-mono); - font-size: var(--font-size-xs); - margin: 0; +.invent-webcam--side-by-side .invent-webcam-preview-col { + aspect-ratio: 4 / 3; + background: var(--webcam-bg); + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + flex: 1; + min-width: 0; + overflow: hidden; + width: auto; } -.invent-webcam-opencv-result-panel { - display: flex; - flex-direction: column; - gap: var(--spacing-sm); +.invent-webcam--side-by-side .invent-webcam-preview-col .invent-webcam-capture-preview { + border-radius: 0; + display: block; + height: 100%; + object-fit: cover; + width: 100%; } -.invent-webcam-opencv-result-img { - aspect-ratio: 4 / 3; - background: var(--webcam-bg); - border: var(--border-width) solid var(--primary-light); - border-radius: var(--border-radius); - object-fit: contain; +@media (max-width: 600px) { + .invent-webcam--side-by-side .invent-webcam-media-row { + flex-direction: column; + } } @keyframes invent-webcam-recording-ring { diff --git a/src/invent/tools/device.py b/src/invent/tools/device.py index 891306c8..f443119b 100644 --- a/src/invent/tools/device.py +++ b/src/invent/tools/device.py @@ -8,7 +8,7 @@ import invent import asyncio import json -from pyscript import window +from pyscript import document, js_import, window from pyscript.ffi import to_js # Datastore flags for Donkey worker status. @@ -94,19 +94,17 @@ def worker_run_user_code(user_code, data_url): """ -async def _await_donkey_bridge(timeout_seconds=3.0): - """Wait for window.__invent_make_donkey to become available.""" - interval = 0.05 - checks = max(1, int(timeout_seconds / interval)) - for _ in range(checks): - fn = getattr(window, "__invent_make_donkey", None) - if callable(fn): - return - await asyncio.sleep(interval) - raise RuntimeError( - f"Donkey bridge not available after {timeout_seconds}s. " - "Ensure index.html imports core.js and defines window.__invent_make_donkey." - ) +_PYSCRIPT_CORE = "https://pyscript.net/releases/2026.3.1/core.js" + + +def _ensure_terminal_div(terminal_id="donkey-terminal"): + """Create a hidden terminal container in the DOM if it doesn't exist yet.""" + if document.getElementById(terminal_id) is None: + div = document.createElement("div") + div.id = terminal_id + div.style.display = "none" + document.body.appendChild(div) + return f"#{terminal_id}" class OpenCVDonkey: @@ -148,8 +146,8 @@ async def run_code(self, code, data_url): self._set_status(DONKEY_BUSY) try: payload = await self._donkey.evaluate( - "__import__('json').dumps(worker_run_user_code(" - f"{code!r}, {data_url!r}" + "__import__('json').dumps(worker_run_user_code(" + f"{code!r}, {data_url!r}" "))" ) result = json.loads(payload) @@ -188,18 +186,21 @@ async def create_opencv_donkey(result_key=None, *, packages=None): if result_key: invent.datastore[result_key] = DONKEY_CREATING - await _await_donkey_bridge() + # Import donkey directly from the PyScript ES module — no HTML bridge needed. + (core,) = await js_import(_PYSCRIPT_CORE) + # Create the hidden terminal div programmatically so index.html stays clean. + terminal_selector = _ensure_terminal_div() options = to_js( { "type": "py", "persistent": True, - "terminal": "#donkey-terminal", + "terminal": terminal_selector, "config": {"packages": all_packages}, } ) - donkey = await window.__invent_make_donkey(options) + donkey = await core.donkey(options) worker = OpenCVDonkey(donkey, result_key=result_key) await worker.initialize() diff --git a/src/invent/ui/widgets/webcam.py b/src/invent/ui/widgets/webcam.py index aa12c2dc..b55c780c 100644 --- a/src/invent/ui/widgets/webcam.py +++ b/src/invent/ui/widgets/webcam.py @@ -58,6 +58,13 @@ class Webcam(Widget): group="style", ) + preview_layout = ChoiceProperty( + _("Layout of the capture preview relative to the live video feed."), + default_value="stacked", + choices=["stacked", "side-by-side"], + group="style", + ) + photo_captured = Event( _("Sent when a photo is captured."), webcam=_("The Webcam widget that captured the photo."), @@ -182,6 +189,13 @@ def _refresh_capture_preview(self): else: self._hide_capture_preview() + def show_image(self, data_url): + """Display any image in the preview panel, replacing the current content.""" + if not hasattr(self, "_capture_preview"): + return + self._capture_preview.src = data_url + self._capture_preview.classes.remove("hidden") + def trigger(self): """Trigger the current action (capture photo or start/stop recording).""" if hasattr(self, "_shutter_btn"): @@ -328,6 +342,14 @@ def on_photo_output_changed(self): return self._refresh_capture_preview() + def on_preview_layout_changed(self): + if not hasattr(self, "element"): + return + if self.preview_layout == "side-by-side": + self.element.classes.add("invent-webcam--side-by-side") + else: + self.element.classes.remove("invent-webcam--side-by-side") + # UI helpers def _update_mode_buttons(self): for btn_info in self._mode_buttons: @@ -554,12 +576,20 @@ def on_video_ready(event): self._capture_preview.classes.add("capture-preview") self._capture_preview.classes.add("hidden") + # Always wrap video + preview in a media row. The layout is toggled + # reactively via on_preview_layout_changed() which adds/removes the + # invent-webcam--side-by-side class on the outer element. + self._preview_col = div(self._capture_preview) + self._preview_col.classes.add("invent-webcam-preview-col") + + self._media_row = div(video_container, self._preview_col) + self._media_row.classes.add("invent-webcam-media-row") + element = div( self._canvas, - video_container, + self._media_row, self._controls, self._indicators, - self._capture_preview, id=self.id, ) From 7540d5a8e77580b6ac23aba9e71322f337b282c7 Mon Sep 17 00:00:00 2001 From: iliketocode2 Date: Wed, 15 Apr 2026 12:31:56 -0400 Subject: [PATCH 21/35] Code cleanup --- src/invent/tools/device.py | 32 +++++++------- src/invent/ui/widgets/webcam.py | 77 +++------------------------------ 2 files changed, 23 insertions(+), 86 deletions(-) diff --git a/src/invent/tools/device.py b/src/invent/tools/device.py index f443119b..6d220df4 100644 --- a/src/invent/tools/device.py +++ b/src/invent/tools/device.py @@ -19,12 +19,19 @@ DONKEY_KILLED = "_DEVICE_DONKEY_KILLED" +""" +NOTE: I am using execute("from _opencv_worker import *") which +pulls everything into the worker's global scope, which is the only +reliable way to define persistent callables in a donkey worker that I +could find. Both + - process() feeds large multiline strings line-by-line which + prevents this sort of data from using it, and + - execute() alone seems to scope defs locally and discard them + after the call returns. +""" + # The OpenCV worker code written to the worker's virtual filesystem as a -# proper Python module. Using execute("from _opencv_worker import *") then -# pulls everything into the worker's global scope, which is the only -# reliable way to define persistent callables in a donkey worker — -# process() chokes on large multiline strings via xterm-readline, and -# execute() alone scopes defs locally and discards them. +# proper Python module. _OPENCV_WORKER_MODULE = r""" import base64 import cv2 @@ -125,14 +132,8 @@ def _set_status(self, status): async def initialize(self): self._set_status(DONKEY_BUSY) - # Write the module to the worker's virtual filesystem as a proper + # Write the module to the worker's virtual filesystem as a # .py file, then import it into global scope with execute(). - # This sidesteps two known donkey limitations: - # - process() feeds code through xterm-readline line-by-line, - # which breaks on large multiline strings. - # - execute() uses exec() which scopes `def` statements locally - # and discards them after the call returns. - # Writing a file and importing it is the correct pattern. await self._donkey.execute( f"open('_opencv_worker.py', 'w').write({_OPENCV_WORKER_MODULE!r})" ) @@ -172,8 +173,7 @@ async def create_opencv_donkey(result_key=None, *, packages=None): result_key : str | None Datastore key for status updates, e.g. "opencv.worker.status". packages : list[str] | None - Extra packages to install in the worker (opencv-python and numpy - are always included). + Extra packages to install in the worker """ if packages is None: packages = [] @@ -186,9 +186,9 @@ async def create_opencv_donkey(result_key=None, *, packages=None): if result_key: invent.datastore[result_key] = DONKEY_CREATING - # Import donkey directly from the PyScript ES module — no HTML bridge needed. + # Import donkey directly from PyScript (core,) = await js_import(_PYSCRIPT_CORE) - # Create the hidden terminal div programmatically so index.html stays clean. + # Create the hidden terminal div terminal_selector = _ensure_terminal_div() options = to_js( diff --git a/src/invent/ui/widgets/webcam.py b/src/invent/ui/widgets/webcam.py index b55c780c..d81dad6e 100644 --- a/src/invent/ui/widgets/webcam.py +++ b/src/invent/ui/widgets/webcam.py @@ -26,11 +26,9 @@ ChoiceProperty, BooleanProperty, Event, - IntegerProperty, ) from pyscript.web import div, video, button, canvas, img from pyscript.ffi import create_proxy -import base64 class Webcam(Widget): @@ -76,18 +74,6 @@ class Webcam(Widget): webcam=_("The Webcam widget that recorded the video."), ) - @classmethod - def icon(cls): - return ( - '' - ) - def __init__(self, *args, **kwargs): self._captures = [] self._capture_counter = 0 @@ -121,49 +107,6 @@ def latest_capture(self, media_type=None): captures = self.captures(media_type=media_type) return captures[-1] if captures else None - def find_capture(self, capture_id): - for capture in self._captures: - if capture.get("id") == capture_id: - return capture - return None - - def remove_capture(self, capture_id): - for index, capture in enumerate(self._captures): - if capture.get("id") == capture_id: - removed = self._captures.pop(index) - self._refresh_capture_preview() - return removed - return None - - def clear_captures(self, media_type=None): - if media_type is None: - removed = list(self._captures) - self._captures = [] - self._refresh_capture_preview() - return removed - - kept, removed = [], [] - for capture in self._captures: - if capture.get("type") == media_type: - removed.append(capture) - else: - kept.append(capture) - self._captures = kept - self._refresh_capture_preview() - return removed - - def photo_bytes(self, capture=None): - """Return raw JPEG bytes for *capture* (defaults to latest photo).""" - capture = capture or self.latest_capture(media_type="photo") - if not capture: - return None - if capture.get("photo_bytes") is not None: - return capture["photo_bytes"] - data_url = capture.get("data_url") - if not data_url or "," not in data_url: - return None - return base64.b64decode(data_url.split(",", 1)[1]) - # Preview helpers def _show_capture_preview(self, capture): if not hasattr(self, "_capture_preview"): @@ -196,11 +139,6 @@ def show_image(self, data_url): self._capture_preview.src = data_url self._capture_preview.classes.remove("hidden") - def trigger(self): - """Trigger the current action (capture photo or start/stop recording).""" - if hasattr(self, "_shutter_btn"): - self._shutter_btn.click() - # Mode switching def set_mode(self, mode): """Set the active webcam mode when switching is enabled.""" @@ -512,11 +450,11 @@ def render(self): """ Render the webcam widget. """ - # ---- hidden canvas for photo capture ---- + # hidden canvas for photo capture self._canvas = canvas() self._canvas.classes.add("invent-webcam-canvas-hidden") - # ---- live video element ---- + # live video element self._video_elem = video() self._video_elem.id = f"{self.id}-video" self._video_elem.autoplay = True @@ -537,7 +475,7 @@ def on_video_ready(event): video_container.classes.add("invent-webcam-box") video_container.classes.add("webcam-box") - # ---- shutter button ---- + # shutter button self._shutter_btn = button("Take") self._shutter_btn.id = f"{self.id}-shutter" self._shutter_btn.classes.add("invent-webcam-shutter") @@ -555,7 +493,7 @@ def on_video_ready(event): self._controls.classes.add("actions") self._shutter_container = shutter_container - # ---- status indicators ---- + # status indicators self._status_elem = div("Initializing camera...") self._status_elem.id = f"{self.id}-status" self._status_elem.classes.add("invent-webcam-status") @@ -569,16 +507,14 @@ def on_video_ready(event): self._indicators.classes.add("invent-webcam-indicators") self._indicators.classes.add("indicators") - # ---- capture preview image ---- + # capture preview image self._capture_preview = img() self._capture_preview.id = f"{self.id}-capture-preview" self._capture_preview.classes.add("invent-webcam-capture-preview") self._capture_preview.classes.add("capture-preview") self._capture_preview.classes.add("hidden") - # Always wrap video + preview in a media row. The layout is toggled - # reactively via on_preview_layout_changed() which adds/removes the - # invent-webcam--side-by-side class on the outer element. + # Wrap video and preview in a media row self._preview_col = div(self._capture_preview) self._preview_col.classes.add("invent-webcam-preview-col") @@ -600,3 +536,4 @@ def on_video_ready(event): self._setup_webcam_stream() return element + \ No newline at end of file From c5524d3c650cafb1cebef5b4ac284903d3475275 Mon Sep 17 00:00:00 2001 From: iliketocode2 Date: Wed, 15 Apr 2026 12:35:33 -0400 Subject: [PATCH 22/35] Remove extra spacing additions --- src/invent/themes/default.css | 50 +++++++++++++++++------------------ 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/src/invent/themes/default.css b/src/invent/themes/default.css index b73ddade..e325defc 100644 --- a/src/invent/themes/default.css +++ b/src/invent/themes/default.css @@ -2635,15 +2635,15 @@ figure.invent-avatar:focus-visible { /* Webcam Widget Styles */ .invent-webcam, .webcam-container { - --webcam-bg: var(--muted-light); - --webcam-btn-bg: var(--primary-light); - --webcam-btn-hover: var(--primary-active); - --webcam-accent: var(--secondary); - --webcam-text-primary: var(--color-body); + --webcam-bg: var(--muted-light); + --webcam-btn-bg: var(--primary-light); + --webcam-btn-hover: var(--primary-active); + --webcam-accent: var(--secondary); + --webcam-text-primary: var(--color-body); --webcam-text-secondary: var(--muted-text); - --webcam-shutter-bg: var(--danger); - --webcam-shutter-hover: var(--danger); - --webcam-indicator-bg: var(--primary-light); + --webcam-shutter-bg: var(--danger); + --webcam-shutter-hover: var(--danger); + --webcam-indicator-bg: var(--primary-light); width: 100%; max-width: 800px; @@ -2730,24 +2730,24 @@ figure.invent-avatar:focus-visible { .invent-webcam-shutter, .shutter { - height: 60px; - width: 60px; - display: flex; - align-items: center; + height: 60px; + width: 60px; + display: flex; + align-items: center; justify-content: center; - text-align: center; - padding: 0; - line-height: 1; - background: var(--webcam-shutter-bg); - border: 0; - border-radius: 50%; - cursor: pointer; - font-size: var(--font-size-sm); - font-family: var(--font); - font-weight: 500; - color: var(--white); - transition: transform var(--transition-speed) ease; - position: relative; + text-align: center; + padding: 0; + line-height: 1; + background: var(--webcam-shutter-bg); + border: 0; + border-radius: 50%; + cursor: pointer; + font-size: var(--font-size-sm); + font-family: var(--font); + font-weight: 500; + color: var(--white); + transition: transform var(--transition-speed) ease; + position: relative; } .invent-webcam-shutter:hover, From 0b4fe2e6813b4b583ce6b3bb09216ef4b57ec72f Mon Sep 17 00:00:00 2001 From: iliketocode2 Date: Wed, 15 Apr 2026 12:40:44 -0400 Subject: [PATCH 23/35] More comment cleanup --- examples/open_cv_playground/main.py | 8 +------- src/invent/tools/device.py | 13 +++---------- 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/examples/open_cv_playground/main.py b/examples/open_cv_playground/main.py index a8fbb173..b677d297 100644 --- a/examples/open_cv_playground/main.py +++ b/examples/open_cv_playground/main.py @@ -1,7 +1,3 @@ -""" -OpenCV playground with a camera-only webcam widget and Donkey worker pipeline. -""" - import asyncio import invent @@ -150,9 +146,7 @@ async def handle_opencv_controls(message): Label(text="## OpenCV webcam playground"), Label( text=( - "The webcam remains lightweight and only captures images. " - "OpenCV runs in a Donkey worker and starts automatically. " - "Capture a photo, write code, then press **Run Code**." + "Take a photo, write your OpenCV code, and then press **Run Code**." ) ), opencv_webcam, diff --git a/src/invent/tools/device.py b/src/invent/tools/device.py index 6d220df4..910cc951 100644 --- a/src/invent/tools/device.py +++ b/src/invent/tools/device.py @@ -1,10 +1,3 @@ -""" -Device related helpers. - -This module contains helpers for running heavyweight device processing in a -PyScript Donkey worker whilst keeping UI widgets lightweight. -""" - import invent import asyncio import json @@ -73,7 +66,7 @@ def worker_run_user_code(user_code, data_url): "result": None, } - exec(user_code, namespace, namespace) # noqa: S102 + exec(user_code, namespace, namespace) result = namespace.get("result_image") if result is None: @@ -168,8 +161,8 @@ async def create_opencv_donkey(result_key=None, *, packages=None): """ Create and initialise an OpenCV donkey worker. - Parameters - ---------- + Parameters: + result_key : str | None Datastore key for status updates, e.g. "opencv.worker.status". packages : list[str] | None From 1853529a1df16bf6375317e8c6ddeab817eb44ca Mon Sep 17 00:00:00 2001 From: iliketocode2 Date: Wed, 22 Apr 2026 00:08:16 -0400 Subject: [PATCH 24/35] fix import location in webcam.py --- src/invent/ui/widgets/webcam.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/invent/ui/widgets/webcam.py b/src/invent/ui/widgets/webcam.py index d81dad6e..9e691af9 100644 --- a/src/invent/ui/widgets/webcam.py +++ b/src/invent/ui/widgets/webcam.py @@ -19,14 +19,17 @@ limitations under the License. """ -from invent.i18n import _ +import asyncio import time + +from invent.i18n import _ from invent.ui.core import ( Widget, ChoiceProperty, BooleanProperty, Event, ) +from pyscript import window from pyscript.web import div, video, button, canvas, img from pyscript.ffi import create_proxy @@ -313,8 +316,6 @@ def _set_shutter_text(self): # Download helper def _download_canvas_as_image(self, capture=None): try: - from pyscript import window - capture = capture or self.latest_capture(media_type="photo") if capture and capture.get("data_url"): data_url = capture["data_url"] @@ -340,8 +341,6 @@ def _on_shutter_click(self, event): # Webcam stream def _setup_webcam_stream(self): try: - from pyscript import window - navigator = window.navigator if not navigator.mediaDevices: print("Camera not supported in this browser") @@ -374,8 +373,6 @@ async def get_stream(): print(f"Camera access denied or error: {e}") self._set_status("Camera access denied") - import asyncio - asyncio.create_task(get_stream()) except Exception as e: @@ -383,7 +380,6 @@ async def get_stream(): def _setup_recorder(self, stream): try: - from pyscript import window def on_dataavailable(event): if event.data.size > 0: @@ -536,4 +532,3 @@ def on_video_ready(event): self._setup_webcam_stream() return element - \ No newline at end of file From 44891cd4f36f39ef9923afc5c6806f40c041933e Mon Sep 17 00:00:00 2001 From: iliketocode2 Date: Wed, 22 Apr 2026 11:13:55 -0400 Subject: [PATCH 25/35] Diversifying the donkey plugin --- src/invent/tools/__init__.py | 4 + src/invent/tools/device.py | 147 ++++++++++++++++++++++++++++------- 2 files changed, 124 insertions(+), 27 deletions(-) diff --git a/src/invent/tools/__init__.py b/src/invent/tools/__init__.py index 35cf01df..122256c4 100644 --- a/src/invent/tools/__init__.py +++ b/src/invent/tools/__init__.py @@ -1,19 +1,23 @@ from .device import ( + DonkeyConnection, DONKEY_BUSY, DONKEY_CREATING, DONKEY_ERROR, DONKEY_KILLED, DONKEY_READY, OpenCVDonkey, + create_donkey_connection, create_opencv_donkey, ) __all__ = [ + "DonkeyConnection", "DONKEY_BUSY", "DONKEY_CREATING", "DONKEY_ERROR", "DONKEY_KILLED", "DONKEY_READY", "OpenCVDonkey", + "create_donkey_connection", "create_opencv_donkey", ] diff --git a/src/invent/tools/device.py b/src/invent/tools/device.py index 910cc951..275b9efb 100644 --- a/src/invent/tools/device.py +++ b/src/invent/tools/device.py @@ -96,9 +96,28 @@ def worker_run_user_code(user_code, data_url): _PYSCRIPT_CORE = "https://pyscript.net/releases/2026.3.1/core.js" +_DONKEY_RUNTIME_MODULE = r""" +import json + + +def invent_run_code(code, context_json): + namespace = {} + if context_json: + context = json.loads(context_json) + if not isinstance(context, dict): + raise ValueError("context must decode to a dict") + namespace.update(context) + exec(code, namespace, namespace) + if "result" not in namespace: + raise ValueError("Your code must assign a value to `result`") + return namespace["result"] +""" + def _ensure_terminal_div(terminal_id="donkey-terminal"): - """Create a hidden terminal container in the DOM if it doesn't exist yet.""" + """ + Create a hidden terminal container in the DOM if needed. + """ if document.getElementById(terminal_id) is None: div = document.createElement("div") div.id = terminal_id @@ -110,6 +129,38 @@ def _ensure_terminal_div(terminal_id="donkey-terminal"): class OpenCVDonkey: """Thin wrapper around a PyScript donkey used for OpenCV processing.""" + def __init__(self, connection): + self._connection = connection + + @property + def ready(self): + return self._connection.ready + + async def initialize(self): + # Write the module to the worker's virtual filesystem as a + # .py file, then import it into global scope with execute(). + await self._connection.execute( + f"open('_opencv_worker.py', 'w').write({_OPENCV_WORKER_MODULE!r})" + ) + await self._connection.execute("from _opencv_worker import *") + + async def run_code(self, code, data_url): + if not self._connection.ready: + raise RuntimeError("Donkey is not ready yet") + payload = await self._connection.evaluate( + "__import__('json').dumps(worker_run_user_code(" + f"{code!r}, {data_url!r}" + "))" + ) + return json.loads(payload) + + async def kill(self): + await self._connection.kill() + + +class DonkeyConnection: + """General donkey wrapper with datastore-oriented execution.""" + def __init__(self, donkey, result_key=None): self._donkey = donkey self._result_key = result_key @@ -125,38 +176,86 @@ def _set_status(self, status): async def initialize(self): self._set_status(DONKEY_BUSY) - # Write the module to the worker's virtual filesystem as a - # .py file, then import it into global scope with execute(). - await self._donkey.execute( - f"open('_opencv_worker.py', 'w').write({_OPENCV_WORKER_MODULE!r})" + await self.execute( + "open('_invent_runtime.py', 'w').write(" + f"{_DONKEY_RUNTIME_MODULE!r})" ) - await self._donkey.execute("from _opencv_worker import *") + await self.execute("from _invent_runtime import *") self._ready = True self._set_status(DONKEY_READY) - async def run_code(self, code, data_url): + async def execute(self, code): + if not self._ready and "from _invent_runtime import *" not in code: + self._set_status(DONKEY_BUSY) + try: + result = await self._donkey.execute(code) + if self._ready: + self._set_status(DONKEY_READY) + return result + except Exception as exc: + self._set_status(f"{DONKEY_ERROR}: {exc}") + raise + + async def evaluate(self, expression): if not self._ready: raise RuntimeError("Donkey is not ready yet") self._set_status(DONKEY_BUSY) try: - payload = await self._donkey.evaluate( - "__import__('json').dumps(worker_run_user_code(" - f"{code!r}, {data_url!r}" - "))" - ) - result = json.loads(payload) + result = await self._donkey.evaluate(expression) self._set_status(DONKEY_READY) return result except Exception as exc: self._set_status(f"{DONKEY_ERROR}: {exc}") raise + async def run_code(self, code, result_key, context=None): + """Execute code and store structured result in datastore.""" + context_json = json.dumps(context or {}) + expression = ( + "__import__('json').dumps({" + "'ok': True, " + "'result': invent_run_code(" + f"{code!r}, {context_json!r}" + ")})" + ) + try: + payload = await self.evaluate(expression) + invent.datastore[result_key] = json.loads(payload) + except Exception as exc: + invent.datastore[result_key] = f"{DONKEY_ERROR}: {exc}" + async def kill(self): await self._donkey.kill() self._ready = False self._set_status(DONKEY_KILLED) +async def create_donkey_connection(result_key=None): + """ + Create a donkey connection for Python code execution. + + Each call creates a new worker instance. The framework manages + worker options internally. + """ + if result_key: + invent.datastore[result_key] = DONKEY_CREATING + + (core,) = await js_import(_PYSCRIPT_CORE) + terminal_selector = _ensure_terminal_div() + options = to_js( + { + "type": "py", + "persistent": True, + "terminal": terminal_selector, + "config": {"packages": []}, + } + ) + donkey = await core.donkey(options) + connection = DonkeyConnection(donkey, result_key=result_key) + await connection.initialize() + return connection + + async def create_opencv_donkey(result_key=None, *, packages=None): """ Create and initialise an OpenCV donkey worker. @@ -168,33 +267,27 @@ async def create_opencv_donkey(result_key=None, *, packages=None): packages : list[str] | None Extra packages to install in the worker """ - if packages is None: - packages = [] - - base_packages = ["opencv-python", "numpy"] - all_packages = base_packages + [ - p for p in packages if p not in base_packages - ] + if packages: + raise ValueError( + "Package overrides are not supported for OpenCV donkey." + ) if result_key: invent.datastore[result_key] = DONKEY_CREATING - # Import donkey directly from PyScript (core,) = await js_import(_PYSCRIPT_CORE) - # Create the hidden terminal div terminal_selector = _ensure_terminal_div() - options = to_js( { "type": "py", "persistent": True, "terminal": terminal_selector, - "config": {"packages": all_packages}, + "config": {"packages": ["opencv-python", "numpy"]}, } ) - donkey = await core.donkey(options) - - worker = OpenCVDonkey(donkey, result_key=result_key) + connection = DonkeyConnection(donkey, result_key=result_key) + await connection.initialize() + worker = OpenCVDonkey(connection) await worker.initialize() return worker From b7a9e2aad5a0a1489eaa4b4b4eb6f2ab0aee3abb Mon Sep 17 00:00:00 2001 From: iliketocode2 Date: Wed, 22 Apr 2026 12:32:58 -0400 Subject: [PATCH 26/35] Approaching a plugin donkey, chart test --- examples/chart_donkey/config.json | 5 + examples/chart_donkey/index.html | 23 ++++ examples/chart_donkey/main.py | 163 ++++++++++++++++++++++++++++ examples/open_cv_playground/main.py | 4 +- src/invent/tools/__init__.py | 2 + src/invent/tools/device.py | 73 ++++++++++++- src/invent/ui/widgets/chart.py | 55 ++++++++-- 7 files changed, 314 insertions(+), 11 deletions(-) create mode 100644 examples/chart_donkey/config.json create mode 100644 examples/chart_donkey/index.html create mode 100644 examples/chart_donkey/main.py diff --git a/examples/chart_donkey/config.json b/examples/chart_donkey/config.json new file mode 100644 index 00000000..c6df52d2 --- /dev/null +++ b/examples/chart_donkey/config.json @@ -0,0 +1,5 @@ +{ + "files": { + "/static/invent.min.tar.gz": "./*" + } +} diff --git a/examples/chart_donkey/index.html b/examples/chart_donkey/index.html new file mode 100644 index 00000000..cbb8f02f --- /dev/null +++ b/examples/chart_donkey/index.html @@ -0,0 +1,23 @@ + + + + + + Chart Donkey Interactive Test + + + + + + + + + + + diff --git a/examples/chart_donkey/main.py b/examples/chart_donkey/main.py new file mode 100644 index 00000000..3c1a5345 --- /dev/null +++ b/examples/chart_donkey/main.py @@ -0,0 +1,163 @@ +import asyncio + +import invent +from invent.tools import ChartDonkeyAdapter +from invent.ui import * + +await invent.setup() + +_PASS = "color:green;font-family:monospace;margin:4px 0" +_FAIL = "color:red;font-family:monospace;margin:4px 0" +_WAIT = "color:#555;font-family:monospace;margin:4px 0" + + +def _pass_html(text): + return f'

[PASS] {text}

' + + +def _fail_html(text): + return f'

[FAIL] {text}

' + + +def _wait_html(text): + return f'

[ ] {text}

' + + +chart = Chart( + chart_type="bar", + data={ + "labels": ["A", "B", "C"], + "datasets": [ + { + "label": "Values", + "data": [3, 5, 2], + "backgroundColor": "rgba(54, 162, 235, 0.5)", + } + ], + }, + options={"plugins": {"legend": {"display": True}}}, +) + +status = Label(text="Donkey starting...") + +default_code = ( + "# Inputs: chart_type, data, options\n" + "# Assign a dict to result with optional data/options keys.\n" + "new_data = dict(data)\n" + "datasets = [dict(ds) for ds in data.get('datasets', [])]\n" + "if datasets:\n" + " first = dict(datasets[0])\n" + " values = list(first.get('data', []))\n" + " first['data'] = [value * 2 for value in values]\n" + " first['label'] = 'Values x2'\n" + " datasets[0] = first\n" + "new_data['datasets'] = datasets\n" + "result = {'data': new_data}\n" +) + +code_editor = CodeEditor( + code=default_code, + language="python", + min_height="260px", +) + +assert_worker = Html(html=_wait_html("Worker not started.")) +assert_run = Html(html=_wait_html("Code not run.")) + +adapter = ChartDonkeyAdapter( + chart_widget=chart, + status_key="chart.worker.status", + result_key="chart.worker.result", +) + + +async def ensure_worker(): + if adapter.ready: + status.text = "Donkey ready." + assert_worker.html = _pass_html("Donkey worker started.") + return + status.text = "Starting donkey worker..." + try: + await adapter.initialize() + status.text = "Donkey ready. Press Run Code." + assert_worker.html = _pass_html("Donkey worker started.") + except Exception as exc: + status.text = f"Failed to start donkey: {exc}" + assert_worker.html = _fail_html( + f"Donkey worker failed to start: {exc}" + ) + + +async def run_chart_code(): + if not adapter.ready: + status.text = "Donkey not ready." + return + code = code_editor.code or "" + if not code.strip(): + status.text = "Write plugin code first." + return + status.text = "Running code..." + result = await adapter.run(code) + if isinstance(result, dict) and result.get("ok"): + status.text = "Done. Chart updated from donkey result." + assert_run.html = _pass_html("Code run succeeded.") + return + error = None + if isinstance(result, dict): + error = result.get("error") + if not error: + error = "Unknown error." + status.text = f"Worker error: {error}" + assert_run.html = _fail_html(f"Code run failed: {error}") + + +async def handle_controls(message): + if getattr(message.source, "name", "") == "run_chart_code": + await run_chart_code() + + +invent.subscribe( + handle_controls, + to_channel="chart-controls", + when_subject=["press"], +) + +asyncio.create_task(ensure_worker()) + +app = invent.App( + name="Chart Donkey Interactive Test", + pages=[ + Page( + id="chart-donkey-test", + children=[ + Html( + html=( + '

' + "Back to interactive tests

" + ) + ), + Label(text="# Chart Donkey Interactive Test"), + Label( + text=( + "Run Python code in a donkey worker to transform " + "chart data and apply the result back to the " + "widget." + ) + ), + chart, + Button( + text="Run Code", + name="run_chart_code", + channel="chart-controls", + ), + code_editor, + status, + Label(text="## Assertions"), + assert_worker, + assert_run, + ], + ), + ], +) + +invent.go() diff --git a/examples/open_cv_playground/main.py b/examples/open_cv_playground/main.py index b677d297..0930c31e 100644 --- a/examples/open_cv_playground/main.py +++ b/examples/open_cv_playground/main.py @@ -53,9 +53,7 @@ async def ensure_worker(): opencv_worker = await create_opencv_donkey( result_key="opencv.worker.status" ) - opencv_status.text = ( - "Donkey ready. Capture a photo and run your code." - ) + opencv_status.text = "Donkey ready. Capture a photo and run your code." except Exception as exc: opencv_status.text = f"Failed to start donkey worker: {exc}" diff --git a/src/invent/tools/__init__.py b/src/invent/tools/__init__.py index 122256c4..f5595893 100644 --- a/src/invent/tools/__init__.py +++ b/src/invent/tools/__init__.py @@ -1,4 +1,5 @@ from .device import ( + ChartDonkeyAdapter, DonkeyConnection, DONKEY_BUSY, DONKEY_CREATING, @@ -11,6 +12,7 @@ ) __all__ = [ + "ChartDonkeyAdapter", "DonkeyConnection", "DONKEY_BUSY", "DONKEY_CREATING", diff --git a/src/invent/tools/device.py b/src/invent/tools/device.py index 275b9efb..91e05695 100644 --- a/src/invent/tools/device.py +++ b/src/invent/tools/device.py @@ -222,7 +222,10 @@ async def run_code(self, code, result_key, context=None): payload = await self.evaluate(expression) invent.datastore[result_key] = json.loads(payload) except Exception as exc: - invent.datastore[result_key] = f"{DONKEY_ERROR}: {exc}" + invent.datastore[result_key] = { + "ok": False, + "error": f"{DONKEY_ERROR}: {exc}", + } async def kill(self): await self._donkey.kill() @@ -230,6 +233,74 @@ async def kill(self): self._set_status(DONKEY_KILLED) +class ChartDonkeyAdapter: + """ + Attach donkey-driven Python logic to a Chart widget. + + The adapter expects plugin code to assign `result` as a dictionary + containing optional `data` and `options` keys for chart updates. + """ + + def __init__(self, chart_widget, status_key, result_key): + self._chart = chart_widget + self._status_key = status_key + self._result_key = result_key + self._connection = None + + @property + def ready(self): + return self._connection is not None and self._connection.ready + + async def initialize(self): + self._connection = await create_donkey_connection( + result_key=self._status_key + ) + + def _context(self): + return { + "chart_type": self._chart.chart_type, + "data": self._chart.data, + "options": self._chart.options, + } + + def _apply_result(self, payload): + if not isinstance(payload, dict): + raise ValueError("Result payload must be a dict.") + if not payload.get("ok"): + return payload + result = payload.get("result") + if not isinstance(result, dict): + raise ValueError("Result value must be a dict.") + if "data" in result: + self._chart.data = result["data"] + if "options" in result: + self._chart.options = result["options"] + return payload + + async def run(self, code): + if not self.ready: + raise RuntimeError("Donkey is not ready yet") + await self._connection.run_code( + code=code, + result_key=self._result_key, + context=self._context(), + ) + payload = invent.datastore.get(self._result_key) + try: + return self._apply_result(payload) + except Exception as exc: + result = { + "ok": False, + "error": f"{DONKEY_ERROR}: {exc}", + } + invent.datastore[self._result_key] = result + return result + + async def kill(self): + if self._connection is not None: + await self._connection.kill() + + async def create_donkey_connection(result_key=None): """ Create a donkey connection for Python code execution. diff --git a/src/invent/ui/widgets/chart.py b/src/invent/ui/widgets/chart.py index f39f7009..bbb0e7c1 100644 --- a/src/invent/ui/widgets/chart.py +++ b/src/invent/ui/widgets/chart.py @@ -40,15 +40,36 @@ # Module-level Chart.js reference, loaded once on first use. _chart_js = None +_chart_js_error = None + +# Candidate ESM sources for Chart.js. We try these in order. +_CHART_JS_SOURCES = [ + "https://esm.sh/chart.js@4.5.1/auto?bundle-deps", + "https://cdn.jsdelivr.net/npm/chart.js@4.5.1/auto/+esm", + "https://esm.run/chart.js/auto", +] async def _ensure_chart_js(): """ Load Chart.js the first time a Chart widget is rendered. """ - global _chart_js - if _chart_js is None: - (_chart_js,) = await js_import("https://esm.run/chart.js/auto") + global _chart_js, _chart_js_error + if _chart_js is not None: + return + if _chart_js_error is not None: + raise RuntimeError(_chart_js_error) + for source in _CHART_JS_SOURCES: + try: + (_chart_js,) = await js_import(source) + _chart_js_error = None + return + except Exception as ex: + _chart_js_error = f"{type(ex).__name__}: {ex}" + raise RuntimeError( + "Could not load Chart.js from any configured source. " + f"Last error: {_chart_js_error}" + ) class Chart(Widget): @@ -135,6 +156,11 @@ def _update_chart(self): during render(). """ if self.parent: + if _chart_js is None: + raise RuntimeError( + "Chart.js is not loaded. " + "Cannot render chart without JS runtime." + ) chart_args = { "data": self.data, "responsive": True, @@ -149,6 +175,12 @@ def _update_chart(self): self.chart_instance.options = to_js(self.options) self.chart_instance.update() else: + existing = _chart_js.Chart.getChart( + self.chart_canvas._dom_element + ) + destroy = getattr(existing, "destroy", None) + if callable(destroy): + destroy() self.chart_instance = _chart_js.Chart.new( self.chart_canvas._dom_element, to_js(chart_args) ) @@ -165,10 +197,19 @@ async def _init_chart(self): Load Chart.js then trigger the initial render. Runs as a background task started by render() so that render() itself stays synchronous. """ - await _ensure_chart_js() - window.requestAnimationFrame( - create_proxy(lambda x: self._update_chart()) - ) + try: + await _ensure_chart_js() + window.requestAnimationFrame( + create_proxy(lambda x: self._update_chart()) + ) + except Exception as ex: + if self.chart_instance: + try: + self.chart_instance.destroy() + except Exception: + pass + self.chart_instance = None + print(f"Chart initialisation failed: {ex}") def render(self): """ From 2b1fc240228060cd572e9a09ced8ddeb06893b80 Mon Sep 17 00:00:00 2001 From: iliketocode2 Date: Wed, 22 Apr 2026 12:45:23 -0400 Subject: [PATCH 27/35] More modular donkey plugin, codeeditor test page --- .../{chart_donkey => tests/chart}/config.json | 0 .../{chart_donkey => tests/chart}/index.html | 0 .../{chart_donkey => tests/chart}/main.py | 0 examples/tests/codeeditor/config.json | 5 + examples/tests/codeeditor/index.html | 23 +++ examples/tests/codeeditor/main.py | 156 ++++++++++++++++++ .../webcam}/config.json | 0 .../webcam}/index.html | 0 .../webcam}/main.py | 0 src/invent/tools/__init__.py | 4 + src/invent/tools/device.py | 105 +++++++++--- 11 files changed, 269 insertions(+), 24 deletions(-) rename examples/{chart_donkey => tests/chart}/config.json (100%) rename examples/{chart_donkey => tests/chart}/index.html (100%) rename examples/{chart_donkey => tests/chart}/main.py (100%) create mode 100644 examples/tests/codeeditor/config.json create mode 100644 examples/tests/codeeditor/index.html create mode 100644 examples/tests/codeeditor/main.py rename examples/{open_cv_playground => tests/webcam}/config.json (100%) rename examples/{open_cv_playground => tests/webcam}/index.html (100%) rename examples/{open_cv_playground => tests/webcam}/main.py (100%) diff --git a/examples/chart_donkey/config.json b/examples/tests/chart/config.json similarity index 100% rename from examples/chart_donkey/config.json rename to examples/tests/chart/config.json diff --git a/examples/chart_donkey/index.html b/examples/tests/chart/index.html similarity index 100% rename from examples/chart_donkey/index.html rename to examples/tests/chart/index.html diff --git a/examples/chart_donkey/main.py b/examples/tests/chart/main.py similarity index 100% rename from examples/chart_donkey/main.py rename to examples/tests/chart/main.py diff --git a/examples/tests/codeeditor/config.json b/examples/tests/codeeditor/config.json new file mode 100644 index 00000000..c6df52d2 --- /dev/null +++ b/examples/tests/codeeditor/config.json @@ -0,0 +1,5 @@ +{ + "files": { + "/static/invent.min.tar.gz": "./*" + } +} diff --git a/examples/tests/codeeditor/index.html b/examples/tests/codeeditor/index.html new file mode 100644 index 00000000..61cffdfb --- /dev/null +++ b/examples/tests/codeeditor/index.html @@ -0,0 +1,23 @@ + + + + + + CodeEditor Donkey Interactive Test + + + + + + + + + + + diff --git a/examples/tests/codeeditor/main.py b/examples/tests/codeeditor/main.py new file mode 100644 index 00000000..2616fa26 --- /dev/null +++ b/examples/tests/codeeditor/main.py @@ -0,0 +1,156 @@ +import asyncio + +import invent +from invent.tools import CodeEditorDonkeyAdapter +from invent.ui import * + +await invent.setup() + +_PASS = "color:green;font-family:monospace;margin:4px 0" +_FAIL = "color:red;font-family:monospace;margin:4px 0" +_WAIT = "color:#555;font-family:monospace;margin:4px 0" + + +def _pass_html(text): + return f'

[PASS] {text}

' + + +def _fail_html(text): + return f'

[FAIL] {text}

' + + +def _wait_html(text): + return f'

[ ] {text}

' + + +source_editor = CodeEditor( + code=( + "Invent donkey plugins are composable.\n" + "This text comes from the source editor." + ), + language="python", + min_height="120px", +) + +plugin_editor = CodeEditor( + code=( + "# Available variable: editor_code\n" + "lines = editor_code.splitlines()\n" + "summary = {\n" + " 'line_count': len(lines),\n" + " 'char_count': len(editor_code),\n" + " 'preview': lines[0] if lines else '',\n" + "}\n" + "result = {'output': str(summary)}\n" + ), + language="python", + min_height="260px", +) + +output_label = Label(text="Output appears here after run.") +status_label = Label(text="Donkey starting...") +assert_worker = Html(html=_wait_html("Worker not started.")) +assert_run = Html(html=_wait_html("Code not run.")) + +adapter = CodeEditorDonkeyAdapter( + code_editor_widget=source_editor, + output_widget=output_label, + status_key="codeeditor.worker.status", + result_key="codeeditor.worker.result", +) + + +async def ensure_worker(): + if adapter.ready: + status_label.text = "Donkey ready." + assert_worker.html = _pass_html("Donkey worker started.") + return + status_label.text = "Starting donkey worker..." + try: + await adapter.initialize() + status_label.text = "Donkey ready. Press Run Code." + assert_worker.html = _pass_html("Donkey worker started.") + except Exception as exc: + status_label.text = f"Failed to start donkey: {exc}" + assert_worker.html = _fail_html( + f"Donkey worker failed to start: {exc}" + ) + + +async def run_plugin_code(): + if not adapter.ready: + status_label.text = "Donkey not ready." + return + code = plugin_editor.code or "" + if not code.strip(): + status_label.text = "Write plugin code first." + return + status_label.text = "Running code..." + result = await adapter.run(code) + if isinstance(result, dict) and result.get("ok"): + status_label.text = "Done. Plugin updated output label." + assert_run.html = _pass_html("Code run succeeded.") + return + error = None + if isinstance(result, dict): + error = result.get("error") + if not error: + error = "Unknown error." + status_label.text = f"Worker error: {error}" + assert_run.html = _fail_html(f"Code run failed: {error}") + + +async def handle_controls(message): + if getattr(message.source, "name", "") == "run_codeeditor_plugin": + await run_plugin_code() + + +invent.subscribe( + handle_controls, + to_channel="codeeditor-controls", + when_subject=["press"], +) + +asyncio.create_task(ensure_worker()) + +app = invent.App( + name="CodeEditor Donkey Interactive Test", + pages=[ + Page( + id="codeeditor-donkey-test", + children=[ + Html( + html=( + '

' + "Back to interactive tests

" + ) + ), + Label(text="# CodeEditor Donkey Interactive Test"), + Label( + text=( + "Run plugin code in a donkey worker. The plugin " + "reads source editor text via context and writes " + "an output message." + ) + ), + Label(text="## Source Editor (context input)"), + source_editor, + Label(text="## Plugin Code"), + plugin_editor, + Button( + text="Run Code", + name="run_codeeditor_plugin", + channel="codeeditor-controls", + ), + Label(text="## Output"), + output_label, + status_label, + Label(text="## Assertions"), + assert_worker, + assert_run, + ], + ), + ], +) + +invent.go() diff --git a/examples/open_cv_playground/config.json b/examples/tests/webcam/config.json similarity index 100% rename from examples/open_cv_playground/config.json rename to examples/tests/webcam/config.json diff --git a/examples/open_cv_playground/index.html b/examples/tests/webcam/index.html similarity index 100% rename from examples/open_cv_playground/index.html rename to examples/tests/webcam/index.html diff --git a/examples/open_cv_playground/main.py b/examples/tests/webcam/main.py similarity index 100% rename from examples/open_cv_playground/main.py rename to examples/tests/webcam/main.py diff --git a/src/invent/tools/__init__.py b/src/invent/tools/__init__.py index f5595893..4d486a40 100644 --- a/src/invent/tools/__init__.py +++ b/src/invent/tools/__init__.py @@ -1,5 +1,6 @@ from .device import ( ChartDonkeyAdapter, + CodeEditorDonkeyAdapter, DonkeyConnection, DONKEY_BUSY, DONKEY_CREATING, @@ -7,12 +8,14 @@ DONKEY_KILLED, DONKEY_READY, OpenCVDonkey, + WidgetDonkeyAdapter, create_donkey_connection, create_opencv_donkey, ) __all__ = [ "ChartDonkeyAdapter", + "CodeEditorDonkeyAdapter", "DonkeyConnection", "DONKEY_BUSY", "DONKEY_CREATING", @@ -20,6 +23,7 @@ "DONKEY_KILLED", "DONKEY_READY", "OpenCVDonkey", + "WidgetDonkeyAdapter", "create_donkey_connection", "create_opencv_donkey", ] diff --git a/src/invent/tools/device.py b/src/invent/tools/device.py index 91e05695..48c89146 100644 --- a/src/invent/tools/device.py +++ b/src/invent/tools/device.py @@ -24,7 +24,7 @@ """ # The OpenCV worker code written to the worker's virtual filesystem as a -# proper Python module. +# proper Python module. _OPENCV_WORKER_MODULE = r""" import base64 import cv2 @@ -233,16 +233,16 @@ async def kill(self): self._set_status(DONKEY_KILLED) -class ChartDonkeyAdapter: +class WidgetDonkeyAdapter: """ - Attach donkey-driven Python logic to a Chart widget. + Base adapter for attaching donkey logic to a widget-like target. - The adapter expects plugin code to assign `result` as a dictionary - containing optional `data` and `options` keys for chart updates. + Subclasses must implement: + - _context() + - _apply_result(payload) """ - def __init__(self, chart_widget, status_key, result_key): - self._chart = chart_widget + def __init__(self, status_key, result_key): self._status_key = status_key self._result_key = result_key self._connection = None @@ -257,25 +257,10 @@ async def initialize(self): ) def _context(self): - return { - "chart_type": self._chart.chart_type, - "data": self._chart.data, - "options": self._chart.options, - } + raise NotImplementedError() def _apply_result(self, payload): - if not isinstance(payload, dict): - raise ValueError("Result payload must be a dict.") - if not payload.get("ok"): - return payload - result = payload.get("result") - if not isinstance(result, dict): - raise ValueError("Result value must be a dict.") - if "data" in result: - self._chart.data = result["data"] - if "options" in result: - self._chart.options = result["options"] - return payload + raise NotImplementedError() async def run(self, code): if not self.ready: @@ -301,6 +286,78 @@ async def kill(self): await self._connection.kill() +class ChartDonkeyAdapter(WidgetDonkeyAdapter): + """ + Attach donkey-driven Python logic to a Chart widget. + + The adapter expects plugin code to assign `result` as a dictionary + containing optional `data` and `options` keys for chart updates. + """ + + def __init__(self, chart_widget, status_key, result_key): + super().__init__(status_key=status_key, result_key=result_key) + self._chart = chart_widget + + def _context(self): + return { + "chart_type": self._chart.chart_type, + "data": self._chart.data, + "options": self._chart.options, + } + + def _apply_result(self, payload): + if not isinstance(payload, dict): + raise ValueError("Result payload must be a dict.") + if not payload.get("ok"): + return payload + result = payload.get("result") + if not isinstance(result, dict): + raise ValueError("Result value must be a dict.") + if "data" in result: + self._chart.data = result["data"] + if "options" in result: + self._chart.options = result["options"] + return payload + + +class CodeEditorDonkeyAdapter(WidgetDonkeyAdapter): + """ + Run donkey logic from a CodeEditor and update an output target. + + The output target must provide a writable `text` attribute. + """ + + def __init__( + self, + code_editor_widget, + output_widget, + status_key, + result_key, + ): + super().__init__(status_key=status_key, result_key=result_key) + self._code_editor = code_editor_widget + self._output = output_widget + + def _context(self): + return { + "editor_code": self._code_editor.code or "", + } + + def _apply_result(self, payload): + if not isinstance(payload, dict): + raise ValueError("Result payload must be a dict.") + if not payload.get("ok"): + return payload + result = payload.get("result") + if not isinstance(result, dict): + raise ValueError("Result value must be a dict.") + message = result.get("output") + if message is None: + raise ValueError("Result must include an `output` value.") + self._output.text = str(message) + return payload + + async def create_donkey_connection(result_key=None): """ Create a donkey connection for Python code execution. From 9f4198197d641698570670f5f9c8aab7807080cf Mon Sep 17 00:00:00 2001 From: iliketocode2 Date: Wed, 22 Apr 2026 12:53:59 -0400 Subject: [PATCH 28/35] donkey plugin helper --- examples/tests/chart/main.py | 42 +++++++------------- examples/tests/codeeditor/main.py | 42 +++++++------------- src/invent/tools/__init__.py | 2 + src/invent/tools/donkey_plugin.py | 65 +++++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 58 deletions(-) create mode 100644 src/invent/tools/donkey_plugin.py diff --git a/examples/tests/chart/main.py b/examples/tests/chart/main.py index 3c1a5345..b952033e 100644 --- a/examples/tests/chart/main.py +++ b/examples/tests/chart/main.py @@ -1,7 +1,7 @@ import asyncio import invent -from invent.tools import ChartDonkeyAdapter +from invent.tools import ChartDonkeyAdapter, DonkeyPluginFlow from invent.ui import * await invent.setup() @@ -69,45 +69,29 @@ def _wait_html(text): status_key="chart.worker.status", result_key="chart.worker.result", ) +flow = DonkeyPluginFlow(adapter=adapter, status_widget=status) async def ensure_worker(): - if adapter.ready: - status.text = "Donkey ready." + result = await flow.ensure_worker( + ready_text="Donkey ready. Press Run Code." + ) + if result.get("ok"): assert_worker.html = _pass_html("Donkey worker started.") return - status.text = "Starting donkey worker..." - try: - await adapter.initialize() - status.text = "Donkey ready. Press Run Code." - assert_worker.html = _pass_html("Donkey worker started.") - except Exception as exc: - status.text = f"Failed to start donkey: {exc}" - assert_worker.html = _fail_html( - f"Donkey worker failed to start: {exc}" - ) + error = result.get("error", "Unknown error.") + assert_worker.html = _fail_html(f"Donkey worker failed to start: {error}") async def run_chart_code(): - if not adapter.ready: - status.text = "Donkey not ready." - return - code = code_editor.code or "" - if not code.strip(): - status.text = "Write plugin code first." - return - status.text = "Running code..." - result = await adapter.run(code) + result = await flow.run_code( + code_editor.code or "", + success_text="Done. Chart updated from donkey result.", + ) if isinstance(result, dict) and result.get("ok"): - status.text = "Done. Chart updated from donkey result." assert_run.html = _pass_html("Code run succeeded.") return - error = None - if isinstance(result, dict): - error = result.get("error") - if not error: - error = "Unknown error." - status.text = f"Worker error: {error}" + error = result.get("error", "Unknown error.") assert_run.html = _fail_html(f"Code run failed: {error}") diff --git a/examples/tests/codeeditor/main.py b/examples/tests/codeeditor/main.py index 2616fa26..e87c4c19 100644 --- a/examples/tests/codeeditor/main.py +++ b/examples/tests/codeeditor/main.py @@ -1,7 +1,7 @@ import asyncio import invent -from invent.tools import CodeEditorDonkeyAdapter +from invent.tools import CodeEditorDonkeyAdapter, DonkeyPluginFlow from invent.ui import * await invent.setup() @@ -58,45 +58,29 @@ def _wait_html(text): status_key="codeeditor.worker.status", result_key="codeeditor.worker.result", ) +flow = DonkeyPluginFlow(adapter=adapter, status_widget=status_label) async def ensure_worker(): - if adapter.ready: - status_label.text = "Donkey ready." + result = await flow.ensure_worker( + ready_text="Donkey ready. Press Run Code." + ) + if result.get("ok"): assert_worker.html = _pass_html("Donkey worker started.") return - status_label.text = "Starting donkey worker..." - try: - await adapter.initialize() - status_label.text = "Donkey ready. Press Run Code." - assert_worker.html = _pass_html("Donkey worker started.") - except Exception as exc: - status_label.text = f"Failed to start donkey: {exc}" - assert_worker.html = _fail_html( - f"Donkey worker failed to start: {exc}" - ) + error = result.get("error", "Unknown error.") + assert_worker.html = _fail_html(f"Donkey worker failed to start: {error}") async def run_plugin_code(): - if not adapter.ready: - status_label.text = "Donkey not ready." - return - code = plugin_editor.code or "" - if not code.strip(): - status_label.text = "Write plugin code first." - return - status_label.text = "Running code..." - result = await adapter.run(code) + result = await flow.run_code( + plugin_editor.code or "", + success_text="Done. Plugin updated output label.", + ) if isinstance(result, dict) and result.get("ok"): - status_label.text = "Done. Plugin updated output label." assert_run.html = _pass_html("Code run succeeded.") return - error = None - if isinstance(result, dict): - error = result.get("error") - if not error: - error = "Unknown error." - status_label.text = f"Worker error: {error}" + error = result.get("error", "Unknown error.") assert_run.html = _fail_html(f"Code run failed: {error}") diff --git a/src/invent/tools/__init__.py b/src/invent/tools/__init__.py index 4d486a40..9ecacb67 100644 --- a/src/invent/tools/__init__.py +++ b/src/invent/tools/__init__.py @@ -12,11 +12,13 @@ create_donkey_connection, create_opencv_donkey, ) +from .donkey_plugin import DonkeyPluginFlow __all__ = [ "ChartDonkeyAdapter", "CodeEditorDonkeyAdapter", "DonkeyConnection", + "DonkeyPluginFlow", "DONKEY_BUSY", "DONKEY_CREATING", "DONKEY_ERROR", diff --git a/src/invent/tools/donkey_plugin.py b/src/invent/tools/donkey_plugin.py new file mode 100644 index 00000000..5d5e14f3 --- /dev/null +++ b/src/invent/tools/donkey_plugin.py @@ -0,0 +1,65 @@ +"""Helpers for building user-facing donkey plugin pages.""" + + +class DonkeyPluginFlow: + """ + Handle common donkey plugin page workflow. + + This keeps page-level `main.py` files small by centralising startup, + run-state text, and standard error handling. + """ + + def __init__(self, adapter, status_widget): + self._adapter = adapter + self._status = status_widget + + @staticmethod + def _error_from_result(result): + if isinstance(result, dict): + error = result.get("error") + if error: + return str(error) + return "Unknown error." + + async def ensure_worker( + self, + *, + ready_text="Donkey ready.", + starting_text="Starting donkey worker...", + ): + if self._adapter.ready: + self._status.text = ready_text + return {"ok": True} + self._status.text = starting_text + try: + await self._adapter.initialize() + self._status.text = ready_text + return {"ok": True} + except Exception as exc: + error = str(exc) + self._status.text = f"Failed to start donkey: {error}" + return {"ok": False, "error": error} + + async def run_code( + self, + code, + *, + running_text="Running code...", + success_text="Done.", + not_ready_text="Donkey not ready.", + empty_code_text="Write plugin code first.", + ): + if not self._adapter.ready: + self._status.text = not_ready_text + return {"ok": False, "error": not_ready_text} + if not (code or "").strip(): + self._status.text = empty_code_text + return {"ok": False, "error": empty_code_text} + self._status.text = running_text + result = await self._adapter.run(code) + if isinstance(result, dict) and result.get("ok"): + self._status.text = success_text + return result + error = self._error_from_result(result) + self._status.text = f"Worker error: {error}" + return {"ok": False, "error": error} From 10cc2a4f890b7ee3ef149781a474706a4cdedf41 Mon Sep 17 00:00:00 2001 From: iliketocode2 Date: Wed, 22 Apr 2026 13:35:04 -0400 Subject: [PATCH 29/35] make_plugin_runner simplification --- examples/tests/chart/main.py | 43 +++++++---------- examples/tests/codeeditor/main.py | 43 +++++++---------- src/invent/tools/__init__.py | 8 +++- src/invent/tools/donkey_plugin.py | 78 +++++++++++++++++++++++++++++++ 4 files changed, 121 insertions(+), 51 deletions(-) diff --git a/examples/tests/chart/main.py b/examples/tests/chart/main.py index b952033e..03e34e44 100644 --- a/examples/tests/chart/main.py +++ b/examples/tests/chart/main.py @@ -1,7 +1,11 @@ import asyncio import invent -from invent.tools import ChartDonkeyAdapter, DonkeyPluginFlow +from invent.tools import ( + ChartDonkeyAdapter, + make_assertion_callbacks, + make_plugin_runner, +) from invent.ui import * await invent.setup() @@ -63,36 +67,25 @@ def _wait_html(text): assert_worker = Html(html=_wait_html("Worker not started.")) assert_run = Html(html=_wait_html("Code not run.")) +callbacks = make_assertion_callbacks( + worker_assert_widget=assert_worker, + run_assert_widget=assert_run, + pass_html=_pass_html, + fail_html=_fail_html, +) adapter = ChartDonkeyAdapter( chart_widget=chart, status_key="chart.worker.status", result_key="chart.worker.result", ) -flow = DonkeyPluginFlow(adapter=adapter, status_widget=status) - - -async def ensure_worker(): - result = await flow.ensure_worker( - ready_text="Donkey ready. Press Run Code." - ) - if result.get("ok"): - assert_worker.html = _pass_html("Donkey worker started.") - return - error = result.get("error", "Unknown error.") - assert_worker.html = _fail_html(f"Donkey worker failed to start: {error}") - - -async def run_chart_code(): - result = await flow.run_code( - code_editor.code or "", - success_text="Done. Chart updated from donkey result.", - ) - if isinstance(result, dict) and result.get("ok"): - assert_run.html = _pass_html("Code run succeeded.") - return - error = result.get("error", "Unknown error.") - assert_run.html = _fail_html(f"Code run failed: {error}") +_, ensure_worker, run_chart_code = make_plugin_runner( + adapter=adapter, + status_widget=status, + code_getter=lambda: code_editor.code or "", + success_text="Done. Chart updated from donkey result.", + **callbacks, +) async def handle_controls(message): diff --git a/examples/tests/codeeditor/main.py b/examples/tests/codeeditor/main.py index e87c4c19..9451fc2b 100644 --- a/examples/tests/codeeditor/main.py +++ b/examples/tests/codeeditor/main.py @@ -1,7 +1,11 @@ import asyncio import invent -from invent.tools import CodeEditorDonkeyAdapter, DonkeyPluginFlow +from invent.tools import ( + CodeEditorDonkeyAdapter, + make_assertion_callbacks, + make_plugin_runner, +) from invent.ui import * await invent.setup() @@ -51,6 +55,12 @@ def _wait_html(text): status_label = Label(text="Donkey starting...") assert_worker = Html(html=_wait_html("Worker not started.")) assert_run = Html(html=_wait_html("Code not run.")) +callbacks = make_assertion_callbacks( + worker_assert_widget=assert_worker, + run_assert_widget=assert_run, + pass_html=_pass_html, + fail_html=_fail_html, +) adapter = CodeEditorDonkeyAdapter( code_editor_widget=source_editor, @@ -58,30 +68,13 @@ def _wait_html(text): status_key="codeeditor.worker.status", result_key="codeeditor.worker.result", ) -flow = DonkeyPluginFlow(adapter=adapter, status_widget=status_label) - - -async def ensure_worker(): - result = await flow.ensure_worker( - ready_text="Donkey ready. Press Run Code." - ) - if result.get("ok"): - assert_worker.html = _pass_html("Donkey worker started.") - return - error = result.get("error", "Unknown error.") - assert_worker.html = _fail_html(f"Donkey worker failed to start: {error}") - - -async def run_plugin_code(): - result = await flow.run_code( - plugin_editor.code or "", - success_text="Done. Plugin updated output label.", - ) - if isinstance(result, dict) and result.get("ok"): - assert_run.html = _pass_html("Code run succeeded.") - return - error = result.get("error", "Unknown error.") - assert_run.html = _fail_html(f"Code run failed: {error}") +_, ensure_worker, run_plugin_code = make_plugin_runner( + adapter=adapter, + status_widget=status_label, + code_getter=lambda: plugin_editor.code or "", + success_text="Done. Plugin updated output label.", + **callbacks, +) async def handle_controls(message): diff --git a/src/invent/tools/__init__.py b/src/invent/tools/__init__.py index 9ecacb67..1fc5b5e0 100644 --- a/src/invent/tools/__init__.py +++ b/src/invent/tools/__init__.py @@ -12,13 +12,19 @@ create_donkey_connection, create_opencv_donkey, ) -from .donkey_plugin import DonkeyPluginFlow +from .donkey_plugin import ( + DonkeyPluginFlow, + make_assertion_callbacks, + make_plugin_runner, +) __all__ = [ "ChartDonkeyAdapter", "CodeEditorDonkeyAdapter", "DonkeyConnection", "DonkeyPluginFlow", + "make_assertion_callbacks", + "make_plugin_runner", "DONKEY_BUSY", "DONKEY_CREATING", "DONKEY_ERROR", diff --git a/src/invent/tools/donkey_plugin.py b/src/invent/tools/donkey_plugin.py index 5d5e14f3..081102cc 100644 --- a/src/invent/tools/donkey_plugin.py +++ b/src/invent/tools/donkey_plugin.py @@ -63,3 +63,81 @@ async def run_code( error = self._error_from_result(result) self._status.text = f"Worker error: {error}" return {"ok": False, "error": error} + + +def make_plugin_runner( + *, + adapter, + status_widget, + code_getter, + ready_text="Donkey ready. Press Run Code.", + success_text="Done.", + on_worker_ready=None, + on_worker_error=None, + on_run_success=None, + on_run_error=None, +): + """ + Return standard `ensure_worker` and `run_code` callables. + + This helper reduces repetitive workflow wiring in user-facing pages. + """ + flow = DonkeyPluginFlow(adapter=adapter, status_widget=status_widget) + + async def ensure_worker(): + result = await flow.ensure_worker(ready_text=ready_text) + if result.get("ok"): + if callable(on_worker_ready): + on_worker_ready(result) + return result + if callable(on_worker_error): + on_worker_error(result) + return result + + async def run_code(): + result = await flow.run_code( + code_getter(), + success_text=success_text, + ) + if result.get("ok"): + if callable(on_run_success): + on_run_success(result) + return result + if callable(on_run_error): + on_run_error(result) + return result + + return flow, ensure_worker, run_code + + +def make_assertion_callbacks( + *, + worker_assert_widget, + run_assert_widget, + pass_html, + fail_html, +): + """Return standard assertion callbacks for plugin runner hooks.""" + + def on_worker_ready(_result): + worker_assert_widget.html = pass_html("Donkey worker started.") + + def on_worker_error(result): + error = result.get("error", "Unknown error.") + worker_assert_widget.html = fail_html( + f"Donkey worker failed to start: {error}" + ) + + def on_run_success(_result): + run_assert_widget.html = pass_html("Code run succeeded.") + + def on_run_error(result): + error = result.get("error", "Unknown error.") + run_assert_widget.html = fail_html(f"Code run failed: {error}") + + return { + "on_worker_ready": on_worker_ready, + "on_worker_error": on_worker_error, + "on_run_success": on_run_success, + "on_run_error": on_run_error, + } From 600167639619ac5e491543ae752098d1cccaafb0 Mon Sep 17 00:00:00 2001 From: iliketocode2 Date: Mon, 27 Apr 2026 14:59:07 -0400 Subject: [PATCH 30/35] updated examples to post status to channels --- examples/tests/chart/main.py | 26 +++++++++++++++-- examples/tests/codeeditor/main.py | 24 +++++++++++++++- examples/tests/webcam/main.py | 46 ++++++++++++++++++++----------- 3 files changed, 77 insertions(+), 19 deletions(-) diff --git a/examples/tests/chart/main.py b/examples/tests/chart/main.py index 03e34e44..9e5d4c47 100644 --- a/examples/tests/chart/main.py +++ b/examples/tests/chart/main.py @@ -42,7 +42,29 @@ def _wait_html(text): options={"plugins": {"legend": {"display": True}}}, ) -status = Label(text="Donkey starting...") +status_label = Label(text="Donkey starting...") + + +def _set_chart_status(text): + # Set the visible label and publish a status message. + status_label.text = text + invent.publish(invent.Message("status", status=text), + to_channel="chart") + + +class _StatusProxy: + # Proxy exposing a `text` property for runners. + + @property + def text(self): + return status_label.text + + @text.setter + def text(self, value): + _set_chart_status(value) + + +status = _StatusProxy() default_code = ( "# Inputs: chart_type, data, options\n" @@ -128,7 +150,7 @@ async def handle_controls(message): channel="chart-controls", ), code_editor, - status, + status_label, Label(text="## Assertions"), assert_worker, assert_run, diff --git a/examples/tests/codeeditor/main.py b/examples/tests/codeeditor/main.py index 9451fc2b..5ab3d619 100644 --- a/examples/tests/codeeditor/main.py +++ b/examples/tests/codeeditor/main.py @@ -53,6 +53,28 @@ def _wait_html(text): output_label = Label(text="Output appears here after run.") status_label = Label(text="Donkey starting...") + + +def _set_codeeditor_status(text): + # Set visible label and publish a status message. + status_label.text = text + invent.publish( + invent.Message("status", status=text), to_channel="codeeditor" + ) + + +class _StatusProxy: + # Minimal proxy exposing `text` for plugin runners. + @property + def text(self): + return status_label.text + + @text.setter + def text(self, value): + _set_codeeditor_status(value) + + +status = _StatusProxy() assert_worker = Html(html=_wait_html("Worker not started.")) assert_run = Html(html=_wait_html("Code not run.")) callbacks = make_assertion_callbacks( @@ -70,7 +92,7 @@ def _wait_html(text): ) _, ensure_worker, run_plugin_code = make_plugin_runner( adapter=adapter, - status_widget=status_label, + status_widget=status, code_getter=lambda: plugin_editor.code or "", success_text="Done. Plugin updated output label.", **callbacks, diff --git a/examples/tests/webcam/main.py b/examples/tests/webcam/main.py index 0930c31e..8ee91f6c 100644 --- a/examples/tests/webcam/main.py +++ b/examples/tests/webcam/main.py @@ -22,9 +22,19 @@ ) -opencv_status = Label( - text="Donkey starting...", -) +opencv_status = Label(text="Donkey starting...") + + +def _set_opencv_status(text): + """Set the on-page label and publish a status message. + + Publishing to the `opencv` channel with subject `status` lets test + harnesses subscribe for assertions. + """ + opencv_status.text = text + invent.publish( + invent.Message("status", status=text), to_channel="opencv" + ) default_code = ( "# Available variables: image_bgr, image_rgb, grey, cv2, np\n" @@ -46,16 +56,18 @@ async def ensure_worker(): """Start the Donkey worker and bootstrap OpenCV when needed.""" global opencv_worker if opencv_worker is not None and opencv_worker.ready: - opencv_status.text = "Donkey ready." + _set_opencv_status("Donkey ready.") return - opencv_status.text = "Starting Donkey worker..." + _set_opencv_status("Starting Donkey worker...") try: opencv_worker = await create_opencv_donkey( result_key="opencv.worker.status" ) - opencv_status.text = "Donkey ready. Capture a photo and run your code." + _set_opencv_status( + "Donkey ready. Capture a photo and run your code." + ) except Exception as exc: - opencv_status.text = f"Failed to start donkey worker: {exc}" + _set_opencv_status(f"Failed to start donkey worker: {exc}") def _latest_capture_data_url(): @@ -67,24 +79,27 @@ def _latest_capture_data_url(): async def run_worker_code(): if opencv_worker is None or not opencv_worker.ready: - opencv_status.text = "Donkey is not ready. Press 'Start Donkey' first." + _set_opencv_status( + "Donkey is not ready. Press 'Start Donkey' first." + ) return data_url = _latest_capture_data_url() if not data_url: - opencv_status.text = "Capture a photo first, then run an action." + _set_opencv_status( + "Capture a photo first, then run an action." + ) return code = opencv_code_editor.code or "" if not code.strip(): - opencv_status.text = "Write some OpenCV code first." + _set_opencv_status("Write some OpenCV code first.") return - - opencv_status.text = "Running code..." + _set_opencv_status("Running code...") try: result = await opencv_worker.run_code(code, data_url) except Exception as exc: - opencv_status.text = f"Worker error: {exc}" + _set_opencv_status(f"Worker error: {exc}") return if result is None: @@ -102,10 +117,9 @@ async def run_worker_code(): if ok: if processed_data_url: opencv_webcam.show_image(processed_data_url) - opencv_status.text = "Done. Custom OpenCV code executed." + _set_opencv_status("Done. Custom OpenCV code executed.") return - - opencv_status.text = ( + _set_opencv_status( f"Worker returned no displayable result ({type(result).__name__})." ) From 0e2f5cddc9af518ced253bc10e79ce34516a766a Mon Sep 17 00:00:00 2001 From: iliketocode2 Date: Wed, 29 Apr 2026 23:49:22 -0400 Subject: [PATCH 31/35] Integrate webcam donkey class --- examples/tests/chart/main.py | 3 +- examples/tests/webcam/main.py | 72 +++++---------- src/invent/tools/__init__.py | 6 +- src/invent/tools/device.py | 159 +++++++++++++++++++--------------- 4 files changed, 111 insertions(+), 129 deletions(-) diff --git a/examples/tests/chart/main.py b/examples/tests/chart/main.py index 9e5d4c47..73251059 100644 --- a/examples/tests/chart/main.py +++ b/examples/tests/chart/main.py @@ -48,8 +48,7 @@ def _wait_html(text): def _set_chart_status(text): # Set the visible label and publish a status message. status_label.text = text - invent.publish(invent.Message("status", status=text), - to_channel="chart") + invent.publish(invent.Message("status", status=text), to_channel="chart") class _StatusProxy: diff --git a/examples/tests/webcam/main.py b/examples/tests/webcam/main.py index 8ee91f6c..87c357f4 100644 --- a/examples/tests/webcam/main.py +++ b/examples/tests/webcam/main.py @@ -1,7 +1,7 @@ import asyncio import invent -from invent.tools import create_opencv_donkey +from invent.tools import WebcamDonkeyAdapter from invent.ui import * # Datastore ############################################################################ @@ -32,9 +32,8 @@ def _set_opencv_status(text): harnesses subscribe for assertions. """ opencv_status.text = text - invent.publish( - invent.Message("status", status=text), to_channel="opencv" - ) + invent.publish(invent.Message("status", status=text), to_channel="opencv") + default_code = ( "# Available variables: image_bgr, image_rgb, grey, cv2, np\n" @@ -49,46 +48,28 @@ def _set_opencv_status(text): min_height="280px", ) -opencv_worker = None +opencv_adapter = WebcamDonkeyAdapter( + webcam_widget=opencv_webcam, + status_key="opencv.worker.status", + result_key="opencv.worker.result", +) async def ensure_worker(): - """Start the Donkey worker and bootstrap OpenCV when needed.""" - global opencv_worker - if opencv_worker is not None and opencv_worker.ready: + if opencv_adapter.ready: _set_opencv_status("Donkey ready.") return _set_opencv_status("Starting Donkey worker...") try: - opencv_worker = await create_opencv_donkey( - result_key="opencv.worker.status" - ) - _set_opencv_status( - "Donkey ready. Capture a photo and run your code." - ) + await opencv_adapter.initialize() + _set_opencv_status("Donkey ready. Capture a photo and run your code.") except Exception as exc: _set_opencv_status(f"Failed to start donkey worker: {exc}") -def _latest_capture_data_url(): - capture = opencv_webcam.latest_capture(media_type="photo") - if capture is None: - return None - return capture.get("data_url") - - async def run_worker_code(): - if opencv_worker is None or not opencv_worker.ready: - _set_opencv_status( - "Donkey is not ready. Press 'Start Donkey' first." - ) - return - - data_url = _latest_capture_data_url() - if not data_url: - _set_opencv_status( - "Capture a photo first, then run an action." - ) + if not opencv_adapter.ready: + _set_opencv_status("Donkey is not ready. Press 'Start Donkey' first.") return code = opencv_code_editor.code or "" @@ -97,31 +78,20 @@ async def run_worker_code(): return _set_opencv_status("Running code...") try: - result = await opencv_worker.run_code(code, data_url) + result = await opencv_adapter.run(code) + except ValueError as exc: + _set_opencv_status(str(exc)) + return except Exception as exc: _set_opencv_status(f"Worker error: {exc}") return - if result is None: - opencv_status.text = "Worker returned no result." + if result is None or not result.get("ok"): + error = (result or {}).get("error", "Unknown error.") + _set_opencv_status(f"Worker error: {error}") return - getter = getattr(result, "get", None) - if callable(getter): - ok = getter("ok") - processed_data_url = getter("data_url") - else: - ok = False - processed_data_url = None - - if ok: - if processed_data_url: - opencv_webcam.show_image(processed_data_url) - _set_opencv_status("Done. Custom OpenCV code executed.") - return - _set_opencv_status( - f"Worker returned no displayable result ({type(result).__name__})." - ) + _set_opencv_status("Done. Custom OpenCV code executed.") async def handle_opencv_controls(message): diff --git a/src/invent/tools/__init__.py b/src/invent/tools/__init__.py index 1fc5b5e0..98e5f10c 100644 --- a/src/invent/tools/__init__.py +++ b/src/invent/tools/__init__.py @@ -7,10 +7,9 @@ DONKEY_ERROR, DONKEY_KILLED, DONKEY_READY, - OpenCVDonkey, + WebcamDonkeyAdapter, WidgetDonkeyAdapter, create_donkey_connection, - create_opencv_donkey, ) from .donkey_plugin import ( DonkeyPluginFlow, @@ -30,8 +29,7 @@ "DONKEY_ERROR", "DONKEY_KILLED", "DONKEY_READY", - "OpenCVDonkey", + "WebcamDonkeyAdapter", "WidgetDonkeyAdapter", "create_donkey_connection", - "create_opencv_donkey", ] diff --git a/src/invent/tools/device.py b/src/invent/tools/device.py index 48c89146..7297ba01 100644 --- a/src/invent/tools/device.py +++ b/src/invent/tools/device.py @@ -93,9 +93,6 @@ def worker_run_user_code(user_code, data_url): } """ - -_PYSCRIPT_CORE = "https://pyscript.net/releases/2026.3.1/core.js" - _DONKEY_RUNTIME_MODULE = r""" import json @@ -113,6 +110,8 @@ def invent_run_code(code, context_json): return namespace["result"] """ +_PYSCRIPT_CORE = "https://pyscript.net/releases/2026.3.1/core.js" + def _ensure_terminal_div(terminal_id="donkey-terminal"): """ @@ -126,38 +125,6 @@ def _ensure_terminal_div(terminal_id="donkey-terminal"): return f"#{terminal_id}" -class OpenCVDonkey: - """Thin wrapper around a PyScript donkey used for OpenCV processing.""" - - def __init__(self, connection): - self._connection = connection - - @property - def ready(self): - return self._connection.ready - - async def initialize(self): - # Write the module to the worker's virtual filesystem as a - # .py file, then import it into global scope with execute(). - await self._connection.execute( - f"open('_opencv_worker.py', 'w').write({_OPENCV_WORKER_MODULE!r})" - ) - await self._connection.execute("from _opencv_worker import *") - - async def run_code(self, code, data_url): - if not self._connection.ready: - raise RuntimeError("Donkey is not ready yet") - payload = await self._connection.evaluate( - "__import__('json').dumps(worker_run_user_code(" - f"{code!r}, {data_url!r}" - "))" - ) - return json.loads(payload) - - async def kill(self): - await self._connection.kill() - - class DonkeyConnection: """General donkey wrapper with datastore-oriented execution.""" @@ -252,9 +219,15 @@ def ready(self): return self._connection is not None and self._connection.ready async def initialize(self): + # Allow subclasses to request extra packages by defining + # `self._packages` prior to initialize() being called. + packages = getattr(self, "_packages", None) self._connection = await create_donkey_connection( - result_key=self._status_key + result_key=self._status_key, packages=packages ) + # Give subclasses a chance to prepare the worker (write + # modules, import helpers, etc.). Default is no-op. + await self._prepare_worker(self._connection) def _context(self): raise NotImplementedError() @@ -281,6 +254,15 @@ async def run(self, code): invent.datastore[self._result_key] = result return result + async def _prepare_worker(self, connection): + """Optional hook called after the connection is created. + + Subclasses may override to write files into the worker and + import helper modules. The default implementation does + nothing. + """ + return None + async def kill(self): if self._connection is not None: await self._connection.kill() @@ -358,48 +340,83 @@ def _apply_result(self, payload): return payload -async def create_donkey_connection(result_key=None): +class WebcamDonkeyAdapter(WidgetDonkeyAdapter): """ - Create a donkey connection for Python code execution. + Attach OpenCV donkey logic to a Webcam widget. - Each call creates a new worker instance. The framework manages - worker options internally. + The worker executes user code with a pre-built image namespace + (image_bgr, image_rgb, grey, cv2, np). User code must assign a + numpy ndarray to `result_image` or `result`. The processed frame + is displayed via the webcam widget's `show_image()` method. """ - if result_key: - invent.datastore[result_key] = DONKEY_CREATING - (core,) = await js_import(_PYSCRIPT_CORE) - terminal_selector = _ensure_terminal_div() - options = to_js( - { - "type": "py", - "persistent": True, - "terminal": terminal_selector, - "config": {"packages": []}, - } - ) - donkey = await core.donkey(options) - connection = DonkeyConnection(donkey, result_key=result_key) - await connection.initialize() - return connection + def __init__(self, webcam_widget, status_key, result_key): + super().__init__(status_key=status_key, result_key=result_key) + self._webcam = webcam_widget + # Request OpenCV and numpy packages when creating the worker. + self._packages = ["opencv-python", "numpy"] + + async def _prepare_worker(self, connection): + # Write the OpenCV helper module into the worker and import it. + await connection.execute( + f"open('_opencv_worker.py', 'w').write({_OPENCV_WORKER_MODULE!r})" + ) + await connection.execute("from _opencv_worker import *") + def _context(self): + # Not used; OpenCV worker uses its own entrypoint. + return {} -async def create_opencv_donkey(result_key=None, *, packages=None): - """ - Create and initialise an OpenCV donkey worker. + def _apply_result(self, payload): + if not isinstance(payload, dict): + raise ValueError("Result payload must be a dict.") + if not payload.get("ok"): + return payload + data_url = payload.get("data_url") + if not data_url: + raise ValueError("Result must include a data_url value.") + self._webcam.show_image(data_url) + return payload + + async def run(self, code): + if not self.ready: + raise RuntimeError("Donkey is not ready yet") - Parameters: + capture = self._webcam.latest_capture(media_type="photo") + if capture is None: + raise ValueError("No photo captured yet. Take a photo first.") + data_url = capture.get("data_url") + if not data_url: + raise ValueError("Latest capture has no data_url.") - result_key : str | None - Datastore key for status updates, e.g. "opencv.worker.status". - packages : list[str] | None - Extra packages to install in the worker - """ - if packages: - raise ValueError( - "Package overrides are not supported for OpenCV donkey." + expression = ( + "__import__('json').dumps(worker_run_user_code(" + f"{code!r}, {data_url!r}" + "))" ) + try: + payload_str = await self._connection.evaluate(expression) + payload = json.loads(payload_str) + invent.datastore[self._result_key] = payload + except Exception as exc: + payload = {"ok": False, "error": f"{DONKEY_ERROR}: {exc}"} + invent.datastore[self._result_key] = payload + + try: + return self._apply_result(payload) + except Exception as exc: + result = {"ok": False, "error": f"{DONKEY_ERROR}: {exc}"} + invent.datastore[self._result_key] = result + return result + + +async def create_donkey_connection(result_key=None, packages=None): + """ + Create a donkey connection for Python code execution. + Each call creates a new worker instance. The framework manages + worker options internally. + """ if result_key: invent.datastore[result_key] = DONKEY_CREATING @@ -410,12 +427,10 @@ async def create_opencv_donkey(result_key=None, *, packages=None): "type": "py", "persistent": True, "terminal": terminal_selector, - "config": {"packages": ["opencv-python", "numpy"]}, + "config": {"packages": packages or []}, } ) donkey = await core.donkey(options) connection = DonkeyConnection(donkey, result_key=result_key) await connection.initialize() - worker = OpenCVDonkey(connection) - await worker.initialize() - return worker + return connection From b4424b9526aac656ba82dddca3165caddf3ec082 Mon Sep 17 00:00:00 2001 From: iliketocode2 Date: Thu, 30 Apr 2026 00:18:50 -0400 Subject: [PATCH 32/35] Modularize the testing assertions/helpers --- examples/tests/chart/main.py | 51 ++++--------------- examples/tests/codeeditor/main.py | 52 ++++--------------- examples/tests/webcam/main.py | 83 +++++++++++-------------------- src/invent/tools/__init__.py | 5 ++ src/invent/tools/test_helpers.py | 39 +++++++++++++++ 5 files changed, 94 insertions(+), 136 deletions(-) create mode 100644 src/invent/tools/test_helpers.py diff --git a/examples/tests/chart/main.py b/examples/tests/chart/main.py index 73251059..dd0a5b50 100644 --- a/examples/tests/chart/main.py +++ b/examples/tests/chart/main.py @@ -3,30 +3,17 @@ import invent from invent.tools import ( ChartDonkeyAdapter, + StatusProxy, + fail_html, make_assertion_callbacks, make_plugin_runner, + pass_html, + wait_html, ) from invent.ui import * await invent.setup() -_PASS = "color:green;font-family:monospace;margin:4px 0" -_FAIL = "color:red;font-family:monospace;margin:4px 0" -_WAIT = "color:#555;font-family:monospace;margin:4px 0" - - -def _pass_html(text): - return f'

[PASS] {text}

' - - -def _fail_html(text): - return f'

[FAIL] {text}

' - - -def _wait_html(text): - return f'

[ ] {text}

' - - chart = Chart( chart_type="bar", data={ @@ -43,27 +30,7 @@ def _wait_html(text): ) status_label = Label(text="Donkey starting...") - - -def _set_chart_status(text): - # Set the visible label and publish a status message. - status_label.text = text - invent.publish(invent.Message("status", status=text), to_channel="chart") - - -class _StatusProxy: - # Proxy exposing a `text` property for runners. - - @property - def text(self): - return status_label.text - - @text.setter - def text(self, value): - _set_chart_status(value) - - -status = _StatusProxy() +status = StatusProxy(status_label, "chart") default_code = ( "# Inputs: chart_type, data, options\n" @@ -86,13 +53,13 @@ def text(self, value): min_height="260px", ) -assert_worker = Html(html=_wait_html("Worker not started.")) -assert_run = Html(html=_wait_html("Code not run.")) +assert_worker = Html(html=wait_html("Worker not started.")) +assert_run = Html(html=wait_html("Code not run.")) callbacks = make_assertion_callbacks( worker_assert_widget=assert_worker, run_assert_widget=assert_run, - pass_html=_pass_html, - fail_html=_fail_html, + pass_html=pass_html, + fail_html=fail_html, ) adapter = ChartDonkeyAdapter( diff --git a/examples/tests/codeeditor/main.py b/examples/tests/codeeditor/main.py index 5ab3d619..d7ae7028 100644 --- a/examples/tests/codeeditor/main.py +++ b/examples/tests/codeeditor/main.py @@ -3,29 +3,17 @@ import invent from invent.tools import ( CodeEditorDonkeyAdapter, + StatusProxy, + fail_html, make_assertion_callbacks, make_plugin_runner, + pass_html, + wait_html, ) from invent.ui import * await invent.setup() -_PASS = "color:green;font-family:monospace;margin:4px 0" -_FAIL = "color:red;font-family:monospace;margin:4px 0" -_WAIT = "color:#555;font-family:monospace;margin:4px 0" - - -def _pass_html(text): - return f'

[PASS] {text}

' - - -def _fail_html(text): - return f'

[FAIL] {text}

' - - -def _wait_html(text): - return f'

[ ] {text}

' - source_editor = CodeEditor( code=( @@ -54,34 +42,16 @@ def _wait_html(text): output_label = Label(text="Output appears here after run.") status_label = Label(text="Donkey starting...") - -def _set_codeeditor_status(text): - # Set visible label and publish a status message. - status_label.text = text - invent.publish( - invent.Message("status", status=text), to_channel="codeeditor" - ) - - -class _StatusProxy: - # Minimal proxy exposing `text` for plugin runners. - @property - def text(self): - return status_label.text - - @text.setter - def text(self, value): - _set_codeeditor_status(value) - - -status = _StatusProxy() -assert_worker = Html(html=_wait_html("Worker not started.")) -assert_run = Html(html=_wait_html("Code not run.")) +# Proxy that syncs visible label and publishes to the +# `codeeditor` channel. +status = StatusProxy(status_label, "codeeditor") +assert_worker = Html(html=wait_html("Worker not started.")) +assert_run = Html(html=wait_html("Code not run.")) callbacks = make_assertion_callbacks( worker_assert_widget=assert_worker, run_assert_widget=assert_run, - pass_html=_pass_html, - fail_html=_fail_html, + pass_html=pass_html, + fail_html=fail_html, ) adapter = CodeEditorDonkeyAdapter( diff --git a/examples/tests/webcam/main.py b/examples/tests/webcam/main.py index 87c357f4..c14576f6 100644 --- a/examples/tests/webcam/main.py +++ b/examples/tests/webcam/main.py @@ -1,7 +1,15 @@ import asyncio import invent -from invent.tools import WebcamDonkeyAdapter +from invent.tools import ( + StatusProxy, + WebcamDonkeyAdapter, + fail_html, + make_assertion_callbacks, + make_plugin_runner, + pass_html, + wait_html, +) from invent.ui import * # Datastore ############################################################################ @@ -23,16 +31,7 @@ opencv_status = Label(text="Donkey starting...") - - -def _set_opencv_status(text): - """Set the on-page label and publish a status message. - - Publishing to the `opencv` channel with subject `status` lets test - harnesses subscribe for assertions. - """ - opencv_status.text = text - invent.publish(invent.Message("status", status=text), to_channel="opencv") +status = StatusProxy(opencv_status, "opencv") default_code = ( @@ -48,57 +47,35 @@ def _set_opencv_status(text): min_height="280px", ) -opencv_adapter = WebcamDonkeyAdapter( + +adapter = WebcamDonkeyAdapter( webcam_widget=opencv_webcam, status_key="opencv.worker.status", result_key="opencv.worker.result", ) +# Assertions and plugin runner wiring +assert_worker = Html(html=wait_html("Worker not started.")) +assert_run = Html(html=wait_html("Code not run.")) +callbacks = make_assertion_callbacks( + worker_assert_widget=assert_worker, + run_assert_widget=assert_run, + pass_html=pass_html, + fail_html=fail_html, +) -async def ensure_worker(): - if opencv_adapter.ready: - _set_opencv_status("Donkey ready.") - return - _set_opencv_status("Starting Donkey worker...") - try: - await opencv_adapter.initialize() - _set_opencv_status("Donkey ready. Capture a photo and run your code.") - except Exception as exc: - _set_opencv_status(f"Failed to start donkey worker: {exc}") - - -async def run_worker_code(): - if not opencv_adapter.ready: - _set_opencv_status("Donkey is not ready. Press 'Start Donkey' first.") - return - - code = opencv_code_editor.code or "" - if not code.strip(): - _set_opencv_status("Write some OpenCV code first.") - return - _set_opencv_status("Running code...") - try: - result = await opencv_adapter.run(code) - except ValueError as exc: - _set_opencv_status(str(exc)) - return - except Exception as exc: - _set_opencv_status(f"Worker error: {exc}") - return - - if result is None or not result.get("ok"): - error = (result or {}).get("error", "Unknown error.") - _set_opencv_status(f"Worker error: {error}") - return - - _set_opencv_status("Done. Custom OpenCV code executed.") +flow, ensure_worker, run_plugin_code = make_plugin_runner( + adapter=adapter, + status_widget=status, + code_getter=lambda: opencv_code_editor.code or "", + success_text="Done. Custom OpenCV code executed.", + **callbacks, +) async def handle_opencv_controls(message): - button_name = getattr(message.source, "name", "") - - if button_name == "run_code_button": - await run_worker_code() + if getattr(message.source, "name", "") == "run_code_button": + await run_plugin_code() invent.subscribe( diff --git a/src/invent/tools/__init__.py b/src/invent/tools/__init__.py index 98e5f10c..135444b2 100644 --- a/src/invent/tools/__init__.py +++ b/src/invent/tools/__init__.py @@ -16,6 +16,7 @@ make_assertion_callbacks, make_plugin_runner, ) +from .test_helpers import StatusProxy, fail_html, pass_html, wait_html __all__ = [ "ChartDonkeyAdapter", @@ -32,4 +33,8 @@ "WebcamDonkeyAdapter", "WidgetDonkeyAdapter", "create_donkey_connection", + "StatusProxy", + "fail_html", + "pass_html", + "wait_html", ] diff --git a/src/invent/tools/test_helpers.py b/src/invent/tools/test_helpers.py new file mode 100644 index 00000000..c5491ba0 --- /dev/null +++ b/src/invent/tools/test_helpers.py @@ -0,0 +1,39 @@ +"""Small helpers shared by interactive donkey test pages.""" + +import invent + +_PASS = "color:green;font-family:monospace;margin:4px 0" +_FAIL = "color:red;font-family:monospace;margin:4px 0" +_WAIT = "color:#555;font-family:monospace;margin:4px 0" + + +def pass_html(text): + return f'

[PASS] {text}

' + + +def fail_html(text): + return f'

[FAIL] {text}

' + + +def wait_html(text): + return f'

[ ] {text}

' + + +class StatusProxy: + """Proxy a label while publishing status updates.""" + + def __init__(self, status_label, channel): + self._label = status_label + self._channel = channel + + @property + def text(self): + return self._label.text + + @text.setter + def text(self, value): + self._label.text = value + invent.publish( + invent.Message("status", status=value), + to_channel=self._channel, + ) From d4252753c689ea02b13f8a42dd792f1a817e96d9 Mon Sep 17 00:00:00 2001 From: iliketocode2 Date: Thu, 30 Apr 2026 00:21:55 -0400 Subject: [PATCH 33/35] cleanup comments --- examples/tests/codeeditor/main.py | 1 - examples/tests/webcam/main.py | 4 ---- 2 files changed, 5 deletions(-) diff --git a/examples/tests/codeeditor/main.py b/examples/tests/codeeditor/main.py index d7ae7028..4c1652ef 100644 --- a/examples/tests/codeeditor/main.py +++ b/examples/tests/codeeditor/main.py @@ -14,7 +14,6 @@ await invent.setup() - source_editor = CodeEditor( code=( "Invent donkey plugins are composable.\n" diff --git a/examples/tests/webcam/main.py b/examples/tests/webcam/main.py index c14576f6..0f3e8bcf 100644 --- a/examples/tests/webcam/main.py +++ b/examples/tests/webcam/main.py @@ -12,12 +12,8 @@ ) from invent.ui import * -# Datastore ############################################################################ - await invent.setup() -# Code ################################################################################# - # Pre-define some webcam variations preview_webcam = Webcam( photo_output="download", From 6078778e87ee3c581fb1a0f2b65fd338e1d8d806 Mon Sep 17 00:00:00 2001 From: iliketocode2 Date: Thu, 30 Apr 2026 11:10:27 -0400 Subject: [PATCH 34/35] Add assertion text to the webcam test page --- examples/tests/webcam/main.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/examples/tests/webcam/main.py b/examples/tests/webcam/main.py index 0f3e8bcf..8ca6eb92 100644 --- a/examples/tests/webcam/main.py +++ b/examples/tests/webcam/main.py @@ -25,7 +25,6 @@ mode="photo", ) - opencv_status = Label(text="Donkey starting...") status = StatusProxy(opencv_status, "opencv") @@ -80,8 +79,6 @@ async def handle_opencv_controls(message): when_subject=["press"], ) - -# Lazy boot so the worker starts automatically when the page loads. asyncio.create_task(ensure_worker()) # User Interface ####################################################################### @@ -112,11 +109,12 @@ async def handle_opencv_controls(message): ), opencv_code_editor, opencv_status, + Label(text="## Assertions"), + assert_worker, + assert_run, ], ), ], ) -# GO! ################################################################################## - invent.go() From a6bd4ab4e4690d4a3d3b564d132ef3e1c0e4863b Mon Sep 17 00:00:00 2001 From: iliketocode2 Date: Mon, 11 May 2026 11:00:00 -0400 Subject: [PATCH 35/35] Refactor as per the discussed architecture --- examples/tests/chart/chart_helper.py | 10 ++ examples/tests/chart/config.json | 9 +- examples/tests/chart/main.py | 105 +++++++++------ .../tests/codeeditor/codeeditor_helper.py | 6 + examples/tests/codeeditor/config.json | 3 +- examples/tests/codeeditor/main.py | 108 +++++++++------ examples/tests/webcam/config.json | 3 +- examples/tests/webcam/main.py | 123 +++++++++++++----- examples/tests/webcam/opencv_helper.py | 65 +++++++++ 9 files changed, 313 insertions(+), 119 deletions(-) create mode 100644 examples/tests/chart/chart_helper.py create mode 100644 examples/tests/codeeditor/codeeditor_helper.py create mode 100644 examples/tests/webcam/opencv_helper.py diff --git a/examples/tests/chart/chart_helper.py b/examples/tests/chart/chart_helper.py new file mode 100644 index 00000000..a0a8dc71 --- /dev/null +++ b/examples/tests/chart/chart_helper.py @@ -0,0 +1,10 @@ +def run(chart_type, data, options, plugin_code): + namespace = { + "chart_type": chart_type, + "data": data, + "options": options, + } + exec(plugin_code, namespace, namespace) + if "result" not in namespace: + raise ValueError("Plugin code must assign to `result`.") + return namespace["result"] diff --git a/examples/tests/chart/config.json b/examples/tests/chart/config.json index c6df52d2..0870c7b1 100644 --- a/examples/tests/chart/config.json +++ b/examples/tests/chart/config.json @@ -1,5 +1,6 @@ { - "files": { - "/static/invent.min.tar.gz": "./*" - } -} + "files": { + "/static/invent.min.tar.gz": "./*", + "./chart_helper.py": "" + } +} \ No newline at end of file diff --git a/examples/tests/chart/main.py b/examples/tests/chart/main.py index dd0a5b50..93e6a6dd 100644 --- a/examples/tests/chart/main.py +++ b/examples/tests/chart/main.py @@ -1,19 +1,22 @@ -import asyncio - import invent -from invent.tools import ( - ChartDonkeyAdapter, - StatusProxy, +import sys +import os +from pathlib import Path +from invent.tools import make_helper +from invent.ui import * + +from invent.tools.common_ui import ( + back_link_widget, fail_html, - make_assertion_callbacks, - make_plugin_runner, + make_status_setter, pass_html, + publish_run, wait_html, ) -from invent.ui import * await invent.setup() + chart = Chart( chart_type="bar", data={ @@ -30,7 +33,7 @@ ) status_label = Label(text="Donkey starting...") -status = StatusProxy(status_label, "chart") +_set_chart_status = make_status_setter(invent, status_label, channel="chart") default_code = ( "# Inputs: chart_type, data, options\n" @@ -55,30 +58,65 @@ assert_worker = Html(html=wait_html("Worker not started.")) assert_run = Html(html=wait_html("Code not run.")) -callbacks = make_assertion_callbacks( - worker_assert_widget=assert_worker, - run_assert_widget=assert_run, - pass_html=pass_html, - fail_html=fail_html, -) -adapter = ChartDonkeyAdapter( - chart_widget=chart, - status_key="chart.worker.status", - result_key="chart.worker.result", -) -_, ensure_worker, run_chart_code = make_plugin_runner( - adapter=adapter, - status_widget=status, - code_getter=lambda: code_editor.code or "", - success_text="Done. Chart updated from donkey result.", - **callbacks, -) +HELPER_CHANNEL = "chart-helper" + +make_helper(src="chart_helper.py", channel=HELPER_CHANNEL) + + +def handle_helper_status(msg): + state = getattr(msg, "state", None) + detail = getattr(msg, "detail", None) + if state == "starting": + _set_chart_status("Starting donkey worker...") + elif state == "ready": + _set_chart_status("Donkey ready. Press Run Code.") + assert_worker.html = pass_html("Donkey worker ready.") + elif state == "busy": + _set_chart_status("Running code...") + elif state == "error": + _set_chart_status(f"Failed to start donkey: {detail}") + assert_worker.html = fail_html(f"Donkey worker failed: {detail}") + + +def handle_helper_result(msg): + if msg.function != "run": + return + if msg.error: + _set_chart_status(f"Worker error: {msg.error}") + assert_run.html = fail_html(f"Code run failed: {msg.error}") + return + payload = msg.result + if not isinstance(payload, dict): + assert_run.html = fail_html("Result must be a dict.") + _set_chart_status("Invalid chart result.") + return + if "data" in payload: + chart.data = payload["data"] + if "options" in payload: + chart.options = payload["options"] + assert_run.html = pass_html("Code run succeeded.") + _set_chart_status("Done. Chart updated from donkey result.") + + +invent.subscribe(handle_helper_status, HELPER_CHANNEL, "status") +invent.subscribe(handle_helper_result, HELPER_CHANNEL, "result") async def handle_controls(message): - if getattr(message.source, "name", "") == "run_chart_code": - await run_chart_code() + if getattr(message.source, "name", "") != "run_chart_code": + return + publish_run( + invent, + channel=HELPER_CHANNEL, + function="run", + args=[ + chart.chart_type, + chart.data, + chart.options, + code_editor.code or "", + ], + ) invent.subscribe( @@ -87,20 +125,13 @@ async def handle_controls(message): when_subject=["press"], ) -asyncio.create_task(ensure_worker()) - app = invent.App( name="Chart Donkey Interactive Test", pages=[ Page( id="chart-donkey-test", children=[ - Html( - html=( - '

' - "Back to interactive tests

" - ) - ), + back_link_widget(), Label(text="# Chart Donkey Interactive Test"), Label( text=( diff --git a/examples/tests/codeeditor/codeeditor_helper.py b/examples/tests/codeeditor/codeeditor_helper.py new file mode 100644 index 00000000..d0adf31b --- /dev/null +++ b/examples/tests/codeeditor/codeeditor_helper.py @@ -0,0 +1,6 @@ +def run(editor_code, plugin_code): + namespace = {"editor_code": editor_code} + exec(plugin_code, namespace, namespace) + if "result" not in namespace: + raise ValueError("Plugin code must assign to `result`.") + return namespace["result"] diff --git a/examples/tests/codeeditor/config.json b/examples/tests/codeeditor/config.json index c6df52d2..a6bf99c4 100644 --- a/examples/tests/codeeditor/config.json +++ b/examples/tests/codeeditor/config.json @@ -1,5 +1,6 @@ { "files": { - "/static/invent.min.tar.gz": "./*" + "/static/invent.min.tar.gz": "./*", + "./codeeditor_helper.py": "" } } diff --git a/examples/tests/codeeditor/main.py b/examples/tests/codeeditor/main.py index 4c1652ef..7c300e56 100644 --- a/examples/tests/codeeditor/main.py +++ b/examples/tests/codeeditor/main.py @@ -1,19 +1,22 @@ -import asyncio - import invent -from invent.tools import ( - CodeEditorDonkeyAdapter, - StatusProxy, +import sys +import os +from pathlib import Path +from invent.tools import make_helper +from invent.ui import * + +from invent.tools.common_ui import ( + back_link_widget, fail_html, - make_assertion_callbacks, - make_plugin_runner, + make_status_setter, pass_html, + publish_run, wait_html, ) -from invent.ui import * await invent.setup() + source_editor = CodeEditor( code=( "Invent donkey plugins are composable.\n" @@ -40,37 +43,69 @@ output_label = Label(text="Output appears here after run.") status_label = Label(text="Donkey starting...") +_set_codeeditor_status = make_status_setter( + invent, status_label, channel="codeeditor" +) -# Proxy that syncs visible label and publishes to the -# `codeeditor` channel. -status = StatusProxy(status_label, "codeeditor") assert_worker = Html(html=wait_html("Worker not started.")) assert_run = Html(html=wait_html("Code not run.")) -callbacks = make_assertion_callbacks( - worker_assert_widget=assert_worker, - run_assert_widget=assert_run, - pass_html=pass_html, - fail_html=fail_html, -) -adapter = CodeEditorDonkeyAdapter( - code_editor_widget=source_editor, - output_widget=output_label, - status_key="codeeditor.worker.status", - result_key="codeeditor.worker.result", -) -_, ensure_worker, run_plugin_code = make_plugin_runner( - adapter=adapter, - status_widget=status, - code_getter=lambda: plugin_editor.code or "", - success_text="Done. Plugin updated output label.", - **callbacks, -) +HELPER_CHANNEL = "codeeditor-helper" + +make_helper(src="codeeditor_helper.py", channel=HELPER_CHANNEL) + + +def handle_helper_status(msg): + state = getattr(msg, "state", None) + detail = getattr(msg, "detail", None) + if state == "starting": + _set_codeeditor_status("Starting donkey worker...") + elif state == "ready": + _set_codeeditor_status("Donkey ready. Press Run Code.") + assert_worker.html = pass_html("Donkey worker ready.") + elif state == "busy": + _set_codeeditor_status("Running plugin...") + elif state == "error": + _set_codeeditor_status(f"Failed to start donkey: {detail}") + assert_worker.html = fail_html(f"Donkey worker failed: {detail}") + + +def handle_helper_result(msg): + if msg.function != "run": + return + if msg.error: + _set_codeeditor_status(f"Worker error: {msg.error}") + assert_run.html = fail_html(f"Code run failed: {msg.error}") + return + payload = msg.result + if not isinstance(payload, dict): + assert_run.html = fail_html("Result must be a dict.") + return + out = payload.get("output") + if out is None: + assert_run.html = fail_html("Result must include `output`.") + return + output_label.text = str(out) + assert_run.html = pass_html("Code run succeeded.") + _set_codeeditor_status("Done. Plugin updated output label.") + + +invent.subscribe(handle_helper_status, HELPER_CHANNEL, "status") +invent.subscribe(handle_helper_result, HELPER_CHANNEL, "result") async def handle_controls(message): - if getattr(message.source, "name", "") == "run_codeeditor_plugin": - await run_plugin_code() + if getattr(message.source, "name", "") != "run_codeeditor_plugin": + return + publish_run( + invent, + channel=HELPER_CHANNEL, + function="run", + args=[ + source_editor.code or "", + plugin_editor.code or "", + ], + ) invent.subscribe( @@ -79,20 +114,13 @@ async def handle_controls(message): when_subject=["press"], ) -asyncio.create_task(ensure_worker()) - app = invent.App( name="CodeEditor Donkey Interactive Test", pages=[ Page( id="codeeditor-donkey-test", children=[ - Html( - html=( - '

' - "Back to interactive tests

" - ) - ), + back_link_widget(), Label(text="# CodeEditor Donkey Interactive Test"), Label( text=( diff --git a/examples/tests/webcam/config.json b/examples/tests/webcam/config.json index 22f69ba9..7068849f 100644 --- a/examples/tests/webcam/config.json +++ b/examples/tests/webcam/config.json @@ -1,5 +1,6 @@ { "files": { - "/static/invent.min.tar.gz": "./*" + "/static/invent.min.tar.gz": "./*", + "./opencv_helper.py": "" } } \ No newline at end of file diff --git a/examples/tests/webcam/main.py b/examples/tests/webcam/main.py index 8ca6eb92..2050693b 100644 --- a/examples/tests/webcam/main.py +++ b/examples/tests/webcam/main.py @@ -1,20 +1,20 @@ -import asyncio - import invent -from invent.tools import ( - StatusProxy, - WebcamDonkeyAdapter, +import sys +import os +from pathlib import Path +from invent.tools import make_helper +from invent.ui import * + +from invent.tools.common_ui import ( fail_html, - make_assertion_callbacks, - make_plugin_runner, + make_status_setter, pass_html, + publish_run, wait_html, ) -from invent.ui import * await invent.setup() -# Pre-define some webcam variations preview_webcam = Webcam( photo_output="download", ) @@ -26,7 +26,9 @@ ) opencv_status = Label(text="Donkey starting...") -status = StatusProxy(opencv_status, "opencv") +_set_opencv_status = make_status_setter( + invent, opencv_status, channel="opencv" +) default_code = ( @@ -42,35 +44,88 @@ min_height="280px", ) - -adapter = WebcamDonkeyAdapter( - webcam_widget=opencv_webcam, - status_key="opencv.worker.status", - result_key="opencv.worker.result", -) - -# Assertions and plugin runner wiring assert_worker = Html(html=wait_html("Worker not started.")) assert_run = Html(html=wait_html("Code not run.")) -callbacks = make_assertion_callbacks( - worker_assert_widget=assert_worker, - run_assert_widget=assert_run, - pass_html=pass_html, - fail_html=fail_html, -) -flow, ensure_worker, run_plugin_code = make_plugin_runner( - adapter=adapter, - status_widget=status, - code_getter=lambda: opencv_code_editor.code or "", - success_text="Done. Custom OpenCV code executed.", - **callbacks, +HELPER_CHANNEL = "opencv-helper" + +make_helper( + src="opencv_helper.py", + channel=HELPER_CHANNEL, + config={"packages": ["opencv-python", "numpy"]}, ) +def handle_helper_status(msg): + state = getattr(msg, "state", None) + detail = getattr(msg, "detail", None) + if state == "starting": + _set_opencv_status("Starting Donkey worker...") + elif state == "ready": + _set_opencv_status("Donkey ready. Capture a photo and run your code.") + assert_worker.html = pass_html("Donkey worker ready.") + elif state == "busy": + _set_opencv_status("Running code...") + elif state == "error": + _set_opencv_status(f"Failed to start donkey worker: {detail}") + assert_worker.html = fail_html(f"Donkey worker failed: {detail}") + + +def handle_helper_result(msg): + if msg.function != "process": + return + if msg.error: + _set_opencv_status(f"Worker error: {msg.error}") + assert_run.html = fail_html(f"Code run failed: {msg.error}") + return + result = msg.result + getter = getattr(result, "get", None) + if callable(getter) and getter("ok"): + processed = getter("data_url") + if processed: + opencv_webcam.show_image(processed) + assert_run.html = pass_html("Code run succeeded.") + _set_opencv_status("Done. Custom OpenCV code executed.") + return + assert_run.html = fail_html("Worker result did not include ok/data_url.") + _set_opencv_status( + f"Worker returned no displayable result ({type(result).__name__})." + ) + + +invent.subscribe(handle_helper_status, HELPER_CHANNEL, "status") +invent.subscribe(handle_helper_result, HELPER_CHANNEL, "result") + + +def _latest_capture_data_url(): + capture = opencv_webcam.latest_capture(media_type="photo") + if capture is None: + return None + return capture.get("data_url") + + async def handle_opencv_controls(message): - if getattr(message.source, "name", "") == "run_code_button": - await run_plugin_code() + button_name = getattr(message.source, "name", "") + + if button_name != "run_code_button": + return + + data_url = _latest_capture_data_url() + if not data_url: + _set_opencv_status("Capture a photo first, then run an action.") + return + + code = opencv_code_editor.code or "" + if not code.strip(): + _set_opencv_status("Write some OpenCV code first.") + return + + publish_run( + invent, + channel=HELPER_CHANNEL, + function="process", + args=[code, data_url], + ) invent.subscribe( @@ -79,10 +134,6 @@ async def handle_opencv_controls(message): when_subject=["press"], ) -asyncio.create_task(ensure_worker()) - -# User Interface ####################################################################### - app = invent.App( name="Theme Testcard", pages=[ diff --git a/examples/tests/webcam/opencv_helper.py b/examples/tests/webcam/opencv_helper.py new file mode 100644 index 00000000..06fc94d0 --- /dev/null +++ b/examples/tests/webcam/opencv_helper.py @@ -0,0 +1,65 @@ +import base64 +import cv2 +import numpy as np + + +def _decode_data_url(data_url): + if not data_url or "," not in data_url: + raise ValueError("Expected an image data URL") + payload = data_url.split(",", 1)[1] + binary = base64.b64decode(payload) + buf = np.frombuffer(binary, dtype=np.uint8) + image = cv2.imdecode(buf, cv2.IMREAD_COLOR) + if image is None: + raise ValueError("Could not decode input image") + return image + + +def _encode_png_data_url(image): + ok, encoded = cv2.imencode(".png", image) + if not ok: + raise ValueError("Could not encode processed image") + payload = base64.b64encode(encoded.tobytes()).decode("ascii") + return "data:image/png;base64," + payload + + +def process(user_code, data_url): + image_bgr = _decode_data_url(data_url) + image_rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB) + grey = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY) + + namespace = { + "cv2": cv2, + "np": np, + "image_bgr": image_bgr, + "image_rgb": image_rgb, + "grey": grey, + "result_image": None, + "result": None, + } + + exec(user_code, namespace, namespace) + + result = namespace.get("result_image") + if result is None: + result = namespace.get("result") + if result is None: + result = image_bgr + + if not isinstance(result, np.ndarray): + raise ValueError( + "Your code must assign a numpy ndarray to result_image or result" + ) + + if result.ndim == 2: + result = cv2.cvtColor(result, cv2.COLOR_GRAY2BGR) + elif result.ndim == 3 and result.shape[2] == 3: + pass + else: + raise ValueError("Unsupported result_image shape") + + return { + "ok": True, + "kind": "user_code", + "data_url": _encode_png_data_url(result), + }