Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
37345b5
Add test suite for opencv webcam
iliketocode2 Apr 6, 2026
e26a335
image now appears in new location
iliketocode2 Apr 6, 2026
3f336b9
add to main.py
iliketocode2 Apr 7, 2026
6346665
attempt at opencv integration again
iliketocode2 Apr 8, 2026
4a25d16
remove test
iliketocode2 Apr 8, 2026
5a736d2
main.py cleanup
iliketocode2 Apr 8, 2026
5b72646
isolate openCV python changes
iliketocode2 Apr 9, 2026
8d506cd
It finally works! OpenCV loading properly
iliketocode2 Apr 9, 2026
86c895a
Properly listening to codeeditor event to change opencv result
iliketocode2 Apr 9, 2026
9a251a8
simplify v1
iliketocode2 Apr 9, 2026
5df6b6d
more simplifications
iliketocode2 Apr 9, 2026
e2ea61b
Clean up comments
iliketocode2 Apr 9, 2026
9d3a32a
last changes before implementing donkey
iliketocode2 Apr 10, 2026
97b5094
Merge branch 'invent-framework:main' into webcam-opencv
iliketocode2 Apr 10, 2026
f787282
update gitignore to not commit temp files
iliketocode2 Apr 6, 2026
b8f684b
planning
iliketocode2 Apr 10, 2026
5362003
Huge reworking and pyscript wrapping to get donkey working
iliketocode2 Apr 13, 2026
e3a2b57
remove max-captures feature from webcam
iliketocode2 Apr 13, 2026
2a2ec88
It finally works!
iliketocode2 Apr 13, 2026
dfcd113
Lots of simplifications, add code editor
iliketocode2 Apr 13, 2026
0aa5442
fixed styling
iliketocode2 Apr 15, 2026
7540d5a
Code cleanup
iliketocode2 Apr 15, 2026
c5524d3
Remove extra spacing additions
iliketocode2 Apr 15, 2026
0b4fe2e
More comment cleanup
iliketocode2 Apr 15, 2026
1853529
fix import location in webcam.py
iliketocode2 Apr 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -120,4 +120,8 @@ test_suite.tar.gz
invent.min.tar.gz
static/default.css
static/default.min.css
static/*.zip
static/*.zip

# All temp files
/temp
temp/
5 changes: 5 additions & 0 deletions examples/open_cv_playground/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"files": {
"/static/invent.min.tar.gz": "./*"
}
}
59 changes: 59 additions & 0 deletions examples/open_cv_playground/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Test Card</title>

<!--
https://docs.pyscript.net/2026.3.1/faq/#sharedarraybuffer
mini-coi sets the Cross-Origin isolation headers via a service worker,
enabling SharedArrayBuffer and Atomics. This is required for PyScript
donkey workers to access window/document on the main thread (e.g. to
create the terminal element). Must be the FIRST script in <head> and
mini-coi.js must be served from the root of your site.
-->
<script src="/mini-coi.js"></script>

<!-- PyScript -->
<link rel="stylesheet" href="https://pyscript.net/releases/2026.3.1/core.css">
<script type="module" src="https://pyscript.net/releases/2026.3.1/core.js"></script>

<style id="invent-theme">
/* Honour the OS light/dark preference via browser defaults. */
:root { color-scheme: light dark; }

html { height: 100%; }

body {
display: grid;
place-items: center;
min-height: 100vh;
margin: 0;
}

#loader {
width: 6rem;
height: 6rem;
border: 8px solid color-mix(in srgb, currentColor 15%, transparent);
border-top-color: currentColor;
border-radius: 50%;
animation: spin .8s linear infinite,
fade 1s ease forwards;
}

@keyframes spin {
to { transform: rotate(360deg); }
}

@keyframes fade {
from { opacity: 0; }
to { opacity: 1; }
}
</style>
</head>
<body>
<div id="loader" role="status" aria-label="Loading, please wait"></div>
<script type="mpy" src="./main.py" config="./config.json"></script>
</body>
</html>
167 changes: 167 additions & 0 deletions examples/open_cv_playground/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import asyncio

import invent
from invent.tools import create_opencv_donkey
from invent.ui import *

# Datastore ############################################################################

await invent.setup()

# Code #################################################################################

# Pre-define some webcam variations
preview_webcam = Webcam(
photo_output="download",
)

opencv_webcam = Webcam(
photo_output="preview",
preview_layout="side-by-side",
mode="photo",
)


opencv_status = Label(
text="Donkey starting...",
)

default_code = (
"# Available variables: image_bgr, image_rgb, grey, cv2, np\n"
"# Set result_image (or result) to a numpy ndarray.\n"
"# Example: start with the current frame and modify it however you like.\n\n"
"result_image = image_bgr.copy()\n"
)

opencv_code_editor = CodeEditor(
code=default_code,
language="python",
min_height="280px",
)

opencv_worker = None


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 run your code."
)
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")


async def run_worker_code():
if opencv_worker is None or not opencv_worker.ready:
opencv_status.text = "Donkey is not ready. Press 'Start Donkey' first."
return

data_url = _latest_capture_data_url()
if not data_url:
opencv_status.text = "Capture a photo first, then run an action."
return

code = opencv_code_editor.code or ""
if not code.strip():
opencv_status.text = "Write some OpenCV code first."
return

opencv_status.text = "Running code..."
try:
result = await opencv_worker.run_code(code, data_url)
except Exception as exc:
opencv_status.text = f"Worker error: {exc}"
return

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")
else:
ok = False
processed_data_url = None

if ok:
if processed_data_url:
opencv_webcam.show_image(processed_data_url)
opencv_status.text = "Done. Custom OpenCV code executed."
return

opencv_status.text = (
f"Worker returned no displayable result ({type(result).__name__})."
)


async def handle_opencv_controls(message):
button_name = getattr(message.source, "name", "")

if button_name == "run_code_button":
await run_worker_code()


invent.subscribe(
handle_opencv_controls,
to_channel="opencv-controls",
when_subject=["press"],
)


# Lazy boot so the worker starts automatically when the page loads.
asyncio.create_task(ensure_worker())

# User Interface #######################################################################

app = invent.App(
name="Theme Testcard",
pages=[
Page(
id="testcard",
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=(
"Take a photo, write your OpenCV code, and then press **Run Code**."
)
),
opencv_webcam,
Button(
text="Run Code",
name="run_code_button",
channel="opencv-controls",
),
opencv_code_editor,
opencv_status,
],
),
],
)

# GO! ##################################################################################

invent.go()
71 changes: 69 additions & 2 deletions src/invent/themes/default.css
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -2773,6 +2773,73 @@ figure.invent-avatar:focus-visible {
z-index: 1;
}

/* 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;
}

/* Capture preview in stacked mode: full-width img below the video. */
.invent-webcam-preview-col {
width: 100%;
}

.invent-webcam-preview-col .invent-webcam-capture-preview {
border-radius: 12px;
display: block;
height: auto;
width: 100%;
}

.invent-webcam-preview-col .invent-webcam-capture-preview.hidden {
display: none;
}

/* Side-by-side mode: media row becomes a flex row, both columns equal size. */
.invent-webcam--side-by-side {
min-height: 0;
}

.invent-webcam--side-by-side .invent-webcam-media-row {
display: flex;
gap: var(--spacing-lg);
align-items: flex-start;
}

.invent-webcam--side-by-side .invent-webcam-media-row .invent-webcam-box {
flex: 1;
min-width: 0;
min-height: 0;
width: auto;
}

.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--side-by-side .invent-webcam-preview-col .invent-webcam-capture-preview {
border-radius: 0;
display: block;
height: 100%;
object-fit: cover;
width: 100%;
}

@media (max-width: 600px) {
.invent-webcam--side-by-side .invent-webcam-media-row {
flex-direction: column;
}
}

@keyframes invent-webcam-recording-ring {
0% {
border: var(--border-width) solid var(--webcam-text-primary);
Expand Down Expand Up @@ -2841,4 +2908,4 @@ figure.invent-avatar:focus-visible {
.shutter-container {
justify-self: center;
}
}
}
19 changes: 19 additions & 0 deletions src/invent/tools/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
Loading