From 37345b5256e34cda81f1c10cb9df874c6e929058 Mon Sep 17 00:00:00 2001 From: iliketocode2 Date: Mon, 6 Apr 2026 15:58:58 -0400 Subject: [PATCH 01/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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