Skip to content

Commit eece75f

Browse files
committed
Adds webcam example
1 parent 89d4f2b commit eece75f

28 files changed

+1226
-1
lines changed

.github/workflows/esp-idf-build.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ jobs:
2525
- static_test_card
2626
- streaming_gif
2727
- pong
28+
- webcam
2829

2930
steps:
3031
- name: Checkout

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.DS_Store

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@ This repo contains small **ESP-IDF projects** that turn an **ESP32‑S3** into a
66

77
- **Upstream component**: [`usb_device_uvc` in esp-iot-solution](https://github.com/espressif/esp-iot-solution/tree/36d8130e8e880720108de2c31ce0779827b1bcd9/components/usb/usb_device_uvc)
88
- **Output format**: MJPEG (JPEG frames over UVC)
9-
- **Input source**: not a sensor — these demos generate frames from embedded assets or a software renderer
9+
- **Input source**: most demos generate frames from embedded assets or a software renderer; **`webcam/`** streams from a real camera sensor via `esp32-camera`
1010

1111
### Demos in this repo
1212

1313
- **`static_test_card/`**: stream a single embedded JPEG test card (simplest “hello UVC”).
1414
- **`streaming_gif/`**: decode an embedded animated GIF at boot, JPEG‑encode each frame, then stream the animation.
1515
- **`pong/`**: render a Pong game into an in‑RAM framebuffer, JPEG‑encode frames on demand, and stream to the host.
16+
- **`webcam/`**: capture JPEG frames from a camera sensor (`esp32-camera`) and stream them as a UVC webcam.
1617

1718
### Hardware
1819

gif_info/.python-version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.12.2

gif_info/README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# gif_info
2+
3+
Create a vertical PNG “contact sheet” from a GIF: each frame is stacked top-to-bottom, and the per-frame delay is drawn **on the frame** (top-right) in **white** with a dark outline (“burn”) so it stays readable.
4+
5+
## Requirements
6+
7+
- [`uv`](https://github.com/astral-sh/uv)
8+
- Python (any **3.9+** interpreter; the script uses PEP 723 inline dependencies so you don’t need a venv)
9+
10+
## Usage
11+
12+
From this folder:
13+
14+
```bash
15+
uv run gif_vertical_sheet.py path/to/input.gif
16+
```
17+
18+
Outputs: `path/to/input.frames.png`
19+
20+
## Common options
21+
22+
- **Output path**
23+
24+
```bash
25+
uv run gif_vertical_sheet.py input.gif -o out.png
26+
```
27+
28+
- **Make frames 1000px wide** (default)
29+
30+
```bash
31+
uv run gif_vertical_sheet.py input.gif --frame-width 1000
32+
```
33+
34+
- **Change label styling**
35+
36+
```bash
37+
uv run gif_vertical_sheet.py input.gif --text-size 48 --burn-px 5 --text-inset 16
38+
```
39+
40+
- **Use a specific font**
41+
42+
```bash
43+
uv run gif_vertical_sheet.py input.gif --font /System/Library/Fonts/Supplemental/Arial.ttf
44+
```
45+
46+
## Forcing a specific Python version
47+
48+
If `uv` is picking an older interpreter (e.g. via `PYENV_VERSION` / `.python-version`), you can force it:
49+
50+
```bash
51+
uv run -p 3.12 gif_vertical_sheet.py input.gif
52+
```
53+

gif_info/gif_vertical_sheet.py

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
#!/usr/bin/env python3
2+
# /// script
3+
# requires-python = ">=3.9"
4+
# dependencies = [
5+
# "pillow>=10.0.0",
6+
# ]
7+
# ///
8+
9+
from __future__ import annotations
10+
11+
import argparse
12+
from dataclasses import dataclass
13+
from pathlib import Path
14+
15+
from PIL import Image, ImageDraw, ImageFont, ImageSequence
16+
17+
18+
@dataclass(frozen=True)
19+
class FrameInfo:
20+
image: Image.Image # RGBA
21+
duration_ms: int
22+
23+
24+
def _load_font(size: int, font_path: str | None = None) -> ImageFont.ImageFont:
25+
if size <= 0:
26+
raise ValueError("--text-size must be > 0")
27+
if font_path:
28+
return ImageFont.truetype(font_path, size=size)
29+
# Try common fonts; fall back to Pillow's tiny default if unavailable.
30+
for name in ("DejaVuSans-Bold.ttf", "DejaVuSans.ttf", "Arial.ttf"):
31+
try:
32+
return ImageFont.truetype(name, size=size)
33+
except Exception:
34+
continue
35+
return ImageFont.load_default()
36+
37+
38+
def _extract_frames(gif_path: Path) -> list[FrameInfo]:
39+
with Image.open(gif_path) as im:
40+
frames: list[FrameInfo] = []
41+
for frame in ImageSequence.Iterator(im):
42+
# In most GIFs this produces a fully composited frame when converted.
43+
rgba = frame.copy().convert("RGBA")
44+
duration = int(frame.info.get("duration", im.info.get("duration", 0)) or 0)
45+
frames.append(FrameInfo(image=rgba, duration_ms=duration))
46+
47+
if not frames:
48+
raise ValueError(f"No frames found in GIF: {gif_path}")
49+
return frames
50+
51+
52+
def _measure_max_label_width(
53+
labels: list[str], font: ImageFont.ImageFont, padding: int = 0
54+
) -> int:
55+
if not labels:
56+
return 0
57+
tmp = Image.new("RGBA", (1, 1))
58+
draw = ImageDraw.Draw(tmp)
59+
max_w = 0
60+
for s in labels:
61+
bbox = draw.textbbox((0, 0), s, font=font)
62+
w = bbox[2] - bbox[0]
63+
max_w = max(max_w, w)
64+
return max_w + padding
65+
66+
67+
def _resize_if_needed(img: Image.Image, scale: float) -> Image.Image:
68+
if scale == 1.0:
69+
return img
70+
if scale <= 0:
71+
raise ValueError("--scale must be > 0")
72+
w, h = img.size
73+
nw = max(1, int(round(w * scale)))
74+
nh = max(1, int(round(h * scale)))
75+
return img.resize((nw, nh), resample=Image.Resampling.LANCZOS)
76+
77+
78+
def render_vertical_sheet(
79+
frames: list[FrameInfo],
80+
*,
81+
frame_width: int | None = None,
82+
scale: float = 1.0,
83+
margin: int = 16,
84+
gap: int = 10,
85+
bg: str = "#ffffff",
86+
text_color: str = "#ffffff",
87+
burn_color: str = "#000000",
88+
text_size: int = 32,
89+
text_inset: int = 10,
90+
burn_px: int = 3,
91+
font_path: str | None = None,
92+
) -> Image.Image:
93+
font = _load_font(text_size, font_path=font_path)
94+
95+
if frame_width is not None and frame_width <= 0:
96+
raise ValueError("--frame-width must be a positive integer")
97+
if text_inset < 0:
98+
raise ValueError("--text-inset must be >= 0")
99+
if burn_px < 0:
100+
raise ValueError("--burn-px must be >= 0")
101+
102+
# Compute a uniform scale so the widest frame becomes `frame_width` (if provided),
103+
# then apply the user-provided `scale` multiplier.
104+
base_w = max(f.image.size[0] for f in frames)
105+
width_scale = (frame_width / base_w) if frame_width else 1.0
106+
final_scale = width_scale * scale
107+
108+
scaled_frames: list[FrameInfo] = []
109+
for f in frames:
110+
scaled_frames.append(FrameInfo(_resize_if_needed(f.image, final_scale), f.duration_ms))
111+
112+
max_frame_w = max(f.image.size[0] for f in scaled_frames)
113+
total_h = sum(f.image.size[1] for f in scaled_frames) + gap * (len(scaled_frames) - 1)
114+
115+
canvas_w = margin + max_frame_w + margin
116+
canvas_h = margin + total_h + margin
117+
118+
out = Image.new("RGBA", (canvas_w, canvas_h), bg)
119+
draw = ImageDraw.Draw(out)
120+
121+
x_img = margin
122+
y = margin
123+
for f in scaled_frames:
124+
# Left align frames; keep their native width.
125+
out.paste(f.image, (x_img, y), f.image)
126+
127+
label = f"{f.duration_ms} ms"
128+
bbox = draw.textbbox((0, 0), label, font=font)
129+
text_w = bbox[2] - bbox[0]
130+
131+
x_text = x_img + f.image.size[0] - text_inset - text_w
132+
y_text = y + text_inset
133+
134+
# "Burn" outline for readability on any background.
135+
if burn_px > 0:
136+
for dy in range(-burn_px, burn_px + 1):
137+
for dx in range(-burn_px, burn_px + 1):
138+
if dx == 0 and dy == 0:
139+
continue
140+
draw.text((x_text + dx, y_text + dy), label, fill=burn_color, font=font)
141+
142+
draw.text((x_text, y_text), label, fill=text_color, font=font)
143+
144+
y += f.image.size[1] + gap
145+
146+
return out
147+
148+
149+
def main() -> int:
150+
p = argparse.ArgumentParser(
151+
description="Extract GIF frames and render a vertical sheet with per-frame delays."
152+
)
153+
p.add_argument("input_gif", type=Path, help="Path to input GIF")
154+
p.add_argument(
155+
"-o",
156+
"--output",
157+
type=Path,
158+
default=None,
159+
help="Output PNG path (default: <input>.frames.png)",
160+
)
161+
p.add_argument(
162+
"--frame-width",
163+
type=int,
164+
default=1000,
165+
help="Scale frames so the widest frame is this many pixels wide (default: 1000). "
166+
"Set to 0 to disable.",
167+
)
168+
p.add_argument("--scale", type=float, default=1.0, help="Scale frames (e.g. 0.5)")
169+
p.add_argument("--margin", type=int, default=16, help="Outer margin (px)")
170+
p.add_argument("--gap", type=int, default=10, help="Gap between frames (px)")
171+
p.add_argument("--bg", type=str, default="#ffffff", help="Background color")
172+
p.add_argument("--text-color", type=str, default="#ffffff", help="Label text color")
173+
p.add_argument("--burn-color", type=str, default="#000000", help="Burn/outline color")
174+
p.add_argument("--text-size", type=int, default=32, help="Label font size (px)")
175+
p.add_argument("--text-inset", type=int, default=10, help="Inset from top-right of frame (px)")
176+
p.add_argument("--burn-px", type=int, default=3, help="Burn/outline thickness (px)")
177+
p.add_argument(
178+
"--font",
179+
type=str,
180+
default=None,
181+
help="Optional path to a .ttf/.otf font file to use for labels",
182+
)
183+
args = p.parse_args()
184+
185+
in_path: Path = args.input_gif
186+
if not in_path.exists():
187+
raise SystemExit(f"Input GIF not found: {in_path}")
188+
189+
out_path: Path = args.output or in_path.with_suffix("").with_suffix(".frames.png")
190+
frames = _extract_frames(in_path)
191+
sheet = render_vertical_sheet(
192+
frames,
193+
frame_width=(None if args.frame_width == 0 else args.frame_width),
194+
scale=args.scale,
195+
margin=args.margin,
196+
gap=args.gap,
197+
bg=args.bg,
198+
text_color=args.text_color,
199+
burn_color=args.burn_color,
200+
text_size=args.text_size,
201+
text_inset=args.text_inset,
202+
burn_px=args.burn_px,
203+
font_path=args.font,
204+
)
205+
206+
out_path.parent.mkdir(parents=True, exist_ok=True)
207+
sheet.save(out_path, format="PNG")
208+
print(f"Wrote: {out_path}")
209+
return 0
210+
211+
212+
if __name__ == "__main__":
213+
raise SystemExit(main())
214+

webcam/.clangd

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
CompileFlags:
2+
Remove: [-f*, -m*]

webcam/.devcontainer/Dockerfile

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
ARG DOCKER_TAG=latest
2+
FROM espressif/idf:${DOCKER_TAG}
3+
4+
ENV LC_ALL=C.UTF-8
5+
ENV LANG=C.UTF-8
6+
7+
RUN apt-get update -y && apt-get install udev -y
8+
9+
RUN echo "source /opt/esp/idf/export.sh > /dev/null 2>&1" >> ~/.bashrc
10+
11+
ENTRYPOINT [ "/opt/esp/entrypoint.sh" ]
12+
13+
CMD ["/bin/bash", "-c"]
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"name": "ESP-IDF QEMU",
3+
"build": {
4+
"dockerfile": "Dockerfile"
5+
},
6+
"customizations": {
7+
"vscode": {
8+
"settings": {
9+
"terminal.integrated.defaultProfile.linux": "bash",
10+
"idf.espIdfPath": "/opt/esp/idf",
11+
"idf.toolsPath": "/opt/esp",
12+
"idf.gitPath": "/usr/bin/git"
13+
},
14+
"extensions": [
15+
"espressif.esp-idf-extension",
16+
"espressif.esp-idf-web"
17+
]
18+
}
19+
},
20+
"runArgs": ["--privileged"]
21+
}

0 commit comments

Comments
 (0)