velxio/test/test-esp32-cam/tests/webcam_helper.py

121 lines
4.4 KiB
Python
Raw Normal View History

feat: ESP32-CAM emulation with webcam frame bridge First open-source end-to-end emulation of the AI-Thinker ESP32-CAM in QEMU, paired with a browser webcam → firmware bridge so users can develop camera sketches without hardware. Status: esp_camera_init() returns ESP_OK; OV2640 chip-id verifies (PID/VER/MIDH/MIDL exactly match the datasheet); GPIO 25 VSYNC NEGEDGE interrupt enabled by the upstream driver. Final piece (cam_task accepting frames) is in progress — descriptor walker fix landed in this commit. Backend (Python/FastAPI): - simulation.py: camera_attach/frame/detach WS handlers - esp32_worker.py: ctypes binding to velxio_push_camera_frame + feature-detection fallback for older DLLs - esp32_lib_manager.py: forward camera commands to the worker stdin - esp-idf-template/main/CMakeLists.txt: esp32-camera headers added via add_prebuilt_library + REQUIRES driver (resolves i2c_master_* symbols). LED_BUILTIN=2 fallback for sketches that hardcode it. Frontend (React/TS): - EditorToolbar.tsx: ESP32-CAM (and the rest of the ESP32 family) added to isQemuBoard list — Run button now starts the QEMU bridge for these boards instead of falling through to the AVR path - useWebcamFrames.ts: getUserMedia → OffscreenCanvas → toBlob('image/jpeg') → base64 → WS at ~10 fps - CameraToggle.tsx: header button with status colors + frame counter - SimulatorCanvas.tsx: render CameraToggle for esp32-cam boards - Esp32Bridge.ts: sendCameraAttach/Frame/Detach + chunked btoa - useSimulatorStore.ts: diagnostic log on compileBoardProgram - components-metadata.json: regen including esp32-cam component Submodule pointer: - wokwi-libs/qemu-lcgamboa → ff8eee0 (camera devices commit on davidmonterocrespo24/qemu-lcgamboa branch picsimlab-esp32) Investigation + tests in test/test-esp32-cam/: - 13 autosearch markdown docs (overview, SOTA, OV2640 spec, DVP/I2S spec, build blueprint, blockers resolved, descriptor walker fix) - 5 sketches (camera_init, sccb_probe, dma_smoke, frame_roundtrip, webcam_demo) + 8 live + WS regression tests - README with the user-facing flow .gitignore: - libqemu-*.dll.{pre-camera,new,bak} (rollback points, regenerated) - wokwi-libs/esp32-camera/ (clone consumed by arduino-esp32 path, not part of this repo) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 05:28:55 +07:00
"""
Capture a single JPEG frame for the live camera tests.
Two modes:
- **Live webcam** (preferred when running interactively): uses OpenCV
to grab one frame from the default camera. Falls back to synthetic
when OpenCV isn't installed or no camera is attached. Triggered by
setting `VELXIO_USE_WEBCAM=1`.
- **Synthetic** (default keeps CI deterministic): a 4×4 RGB JPEG
encoded with PIL or a precomputed b64-encoded blob if PIL is
missing. ~600 bytes.
The synthetic blob has a recognisable byte pattern at offsets 2..7
(JFIF marker `J F I F \0 \1`) that the QEMU device tests look for to
prove the host bytes round-tripped into firmware memory.
"""
from __future__ import annotations
import base64
import os
# Pre-baked 4×4 red JPEG (PIL-generated, valid JFIF). Used when neither
# OpenCV nor PIL is available, OR when running in CI deterministic mode.
_SYNTHETIC_JPEG_B64 = (
"/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAoHBwgHBgoICAgLCgoLDhgQDg0NDh0VFhEYIx8lJCIfIiEm"
"KzcvJik0KSEiMEExNDk7Pj4+JS5ESUM8SDc9Pjv/2wBDAQoLCw4NDhwQEBw7KCIoOzs7Ozs7Ozs7Ozs7"
"Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozv/wAARCAAEAAQDASIAAhEBAxEB/8QA"
"HwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIh"
"MUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVW"
"V1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXG"
"x8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQF"
"BgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAV"
"YnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOE"
"hYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq"
"8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDlaKKK+dP2Y//Z"
)
def _try_opencv_webcam() -> bytes | None:
"""Capture one frame from the default webcam. Returns JPEG bytes or
None if OpenCV is unavailable or capture fails."""
try:
import cv2 # type: ignore
except ImportError:
return None
cap = cv2.VideoCapture(0)
try:
if not cap.isOpened():
return None
# Some webcams need a few grabs to warm up exposure; throw out
# the first 3 frames.
for _ in range(3):
cap.read()
ok, frame = cap.read()
if not ok or frame is None:
return None
# Down-scale to QVGA so the test doesn't ship a 2 MB JPEG.
h, w = frame.shape[:2]
target = (320, 240)
if (w, h) != target:
frame = cv2.resize(frame, target, interpolation=cv2.INTER_AREA)
ok, buf = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), 70])
if not ok:
return None
return bytes(buf)
finally:
cap.release()
def _try_pil_synthetic() -> bytes | None:
"""A larger, more realistic synthetic JPEG via PIL. Returns None if
PIL isn't installed."""
try:
from PIL import Image, ImageDraw # type: ignore
except ImportError:
return None
import io
img = Image.new("RGB", (320, 240), color=(40, 80, 160))
draw = ImageDraw.Draw(img)
# A few coloured shapes give us non-uniform bytes — easier to
# differentiate from the QEMU idle pattern (0xAA).
draw.rectangle((20, 30, 100, 120), fill=(220, 30, 30))
draw.ellipse((150, 60, 280, 200), fill=(30, 200, 30))
draw.text((10, 10), "VELXIO TEST", fill=(255, 255, 255))
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=70)
return buf.getvalue()
def get_test_jpeg(prefer_webcam: bool | None = None) -> tuple[bytes, str]:
"""Return (jpeg_bytes, source_label).
`source_label` is one of "webcam", "pil-synthetic", "embedded" so
test logs make it obvious where the bytes came from.
`prefer_webcam` defaults to the env var VELXIO_USE_WEBCAM=1.
"""
if prefer_webcam is None:
prefer_webcam = os.environ.get("VELXIO_USE_WEBCAM", "") == "1"
if prefer_webcam:
cam = _try_opencv_webcam()
if cam is not None:
return cam, "webcam"
pil = _try_pil_synthetic()
if pil is not None:
return pil, "pil-synthetic"
return base64.b64decode(_SYNTHETIC_JPEG_B64), "embedded"
if __name__ == "__main__":
data, src = get_test_jpeg()
print(f"source: {src}")
print(f"size: {len(data)} bytes")
print(f"head: {data[:16].hex()}")
print(f"is_jpeg: {data[:2] == b'\\xff\\xd8'}")