Modifying output to use H.264.

This commit is contained in:
2026-03-17 16:26:31 -07:00
parent 9a34f29259
commit 82274bdc4c
5 changed files with 246 additions and 95 deletions

View File

@@ -7,7 +7,8 @@ sudo apt-get install -y \
libcap-dev \ libcap-dev \
python3-prctl \ python3-prctl \
python3-picamera2 \ python3-picamera2 \
python3-pil python3-pil \
ffmpeg
echo "Configuring Poetry to use system site-packages..." echo "Configuring Poetry to use system site-packages..."
poetry config virtualenvs.options.system-site-packages true poetry config virtualenvs.options.system-site-packages true

View File

@@ -1,16 +1,14 @@
import logging import logging
from collections.abc import Generator
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any
from flask import Flask, Response, jsonify, render_template from flask import Flask, Response, abort, jsonify, render_template, send_from_directory
from src.camera import camera from src.camera import camera
app = Flask(__name__) app = Flask(__name__)
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.WARNING)
@app.get("/heartbeat") @app.get("/heartbeat")
@@ -43,17 +41,35 @@ def camera_stop() -> tuple[Response, int]:
return jsonify({"status": "stopped"}), 200 return jsonify({"status": "stopped"}), 200
@app.get("/camera/stream") @app.get("/camera/status")
def camera_stream() -> Response: def camera_status() -> tuple[Response, int]:
def generate() -> Generator[bytes, Any, Any]: return (
for frame in camera.frames(): jsonify(
yield (b"--frame\r\n" b"Content-Type: image/jpeg\r\n\r\n" + frame + b"\r\n") {
"running": camera.running,
return Response( "ready": camera.wait_until_ready(timeout=0),
generate(), }
mimetype="multipart/x-mixed-replace; boundary=frame", ),
200,
) )
@app.get("/camera/hls/<path:filename>")
def hls_segment(filename: str) -> Response:
hls_dir = camera.hls_dir
if not hls_dir.exists():
abort(404)
# set correct MIME types for HLS files
if filename.endswith(".m3u8"):
mimetype = "application/vnd.apple.mpegurl"
elif filename.endswith(".ts"):
mimetype = "video/mp2t"
else:
abort(404)
return send_from_directory(str(hls_dir), filename, mimetype=mimetype)
if __name__ == "__main__": if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=True, threaded=True) app.run(host="0.0.0.0", port=5000, debug=True, threaded=True)

View File

@@ -1,114 +1,166 @@
import io
import logging import logging
import shutil
import subprocess
import threading import threading
from collections.abc import Iterator import time
from pathlib import Path
try:
from PIL import Image
PIL_AVAILABLE = True
except ImportError:
PIL_AVAILABLE = False
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
try: try:
from picamera2 import Picamera2 from picamera2 import Picamera2
from picamera2.encoders import H264Encoder
from picamera2.outputs import FileOutput
PICAMERA_AVAILABLE = True PICAMERA_AVAILABLE = True
except ImportError: except ImportError:
PICAMERA_AVAILABLE = False PICAMERA_AVAILABLE = False
logger.warning("picamera2 not available — running in mock mode")
HLS_DIR = Path("/tmp/hls")
SEGMENT_DURATION = 2 # seconds per segment
SEGMENT_COUNT = 5 # segments to keep in playlist
BITRATE = 2_000_000 # 2 Mbps — adjust for bandwidth needs
class PipeOutput:
"""Accepts H.264 bytes from picamera2 and writes to a subprocess stdin pipe."""
def __init__(self, proc: subprocess.Popen[bytes]) -> None:
self._proc = proc
def write(self, data: bytes) -> None:
if self._proc.stdin:
try:
self._proc.stdin.write(data)
except BrokenPipeError:
pass
def close(self) -> None:
if self._proc.stdin:
self._proc.stdin.close()
class Camera: class Camera:
def __init__(self) -> None: def __init__(self) -> None:
self._picam: Picamera2 | None = None self._picam: Picamera2 | None = None
self._thread: threading.Thread | None = None self._encoder: H264Encoder | None = None
self._frame: bytes = b"" self._ffmpeg: subprocess.Popen[bytes] | None = None
self._lock = threading.Lock() self._output: PipeOutput | None = None
self._ready_event = threading.Event()
self._watch_thread: threading.Thread | None = None
self._stop_event = threading.Event() self._stop_event = threading.Event()
self._frame_event = threading.Event()
self.running = False self.running = False
def start(self) -> None: def start(self) -> None:
if self.running: if self.running:
return return
# prepare HLS output directory
if HLS_DIR.exists():
shutil.rmtree(HLS_DIR)
HLS_DIR.mkdir(parents=True)
if not PICAMERA_AVAILABLE: if not PICAMERA_AVAILABLE:
logger.info("Mock camera started") logger.info("Mock camera started")
self.running = True self.running = True
return return
# start ffmpeg: reads raw H.264 from stdin, writes HLS segments
ffmpeg_cmd = [
"ffmpeg",
"-loglevel",
"warning",
"-f",
"h264", # input is raw H.264
"-i",
"pipe:0", # read from stdin
"-c:v",
"copy", # no re-encoding — pass through directly
"-hls_time",
str(SEGMENT_DURATION),
"-hls_list_size",
str(SEGMENT_COUNT),
"-hls_flags",
"delete_segments+append_list",
"-hls_segment_filename",
str(HLS_DIR / "seg%03d.ts"),
str(HLS_DIR / "stream.m3u8"),
]
self._ffmpeg = subprocess.Popen(
ffmpeg_cmd,
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
)
self._output = PipeOutput(self._ffmpeg)
# configure picamera2 with H.264 encoder
self._picam = Picamera2() self._picam = Picamera2()
config = self._picam.create_still_configuration( config = self._picam.create_video_configuration(
main={"size": (1280, 720), "format": "RGB888"}, main={"size": (1280, 720)},
) )
self._picam.configure(config) self._picam.configure(config)
self._picam.start() self._encoder = H264Encoder(bitrate=BITRATE)
self._picam.start_recording(self._encoder, FileOutput(self._output))
# watch for the playlist to appear — signals first segment is ready
self._stop_event.clear() self._stop_event.clear()
self._frame_event.clear() self._ready_event.clear()
self._thread = threading.Thread(target=self._capture_loop, daemon=True) self._watch_thread = threading.Thread(target=self._watch_playlist, daemon=True)
self._thread.start() self._watch_thread.start()
self.running = True self.running = True
logger.info("Camera started") logger.info("Camera started — waiting for first HLS segment")
def _capture_loop(self) -> None:
assert self._picam is not None
self._stop_event.wait(0.5)
logger.info("Capture loop: starting")
def _watch_playlist(self) -> None:
"""Signal ready once the m3u8 playlist exists and has at least one segment."""
playlist = HLS_DIR / "stream.m3u8"
while not self._stop_event.is_set(): while not self._stop_event.is_set():
try: if playlist.exists():
logger.info("Capture loop: attempting capture_array") content = playlist.read_text()
array = self._picam.capture_array("main") if ".ts" in content:
logger.info(f"Capture loop: got array {array.shape}") logger.info("HLS playlist ready")
buffer = io.BytesIO() self._ready_event.set()
img = Image.fromarray(array) return
img.save(buffer, format="JPEG", quality=85) time.sleep(0.25)
frame = buffer.getvalue()
logger.info(f"Capture loop: encoded JPEG {len(frame)} bytes") def wait_until_ready(self, timeout: float = 15.0) -> bool:
with self._lock: if not PICAMERA_AVAILABLE:
self._frame = frame return True
self._frame_event.set() return self._ready_event.wait(timeout)
logger.info("Capture loop: frame event set")
except Exception as e:
logger.error(f"Capture loop ERROR: {e}", exc_info=True)
self._stop_event.wait(0.1)
def stop(self) -> None: def stop(self) -> None:
if not self.running: if not self.running:
return return
self._stop_event.set() self._stop_event.set()
self._frame_event.set() # unblock any waiting frames() calls self._ready_event.set() # unblock any waiters
if self._thread:
self._thread.join(timeout=3)
if self._picam: if self._picam:
self._picam.stop() self._picam.stop_recording()
self._picam.close() self._picam.close()
self._picam = None self._picam = None
self._frame = b""
if self._output:
self._output.close()
if self._ffmpeg:
try:
self._ffmpeg.wait(timeout=5)
except subprocess.TimeoutExpired:
self._ffmpeg.kill()
self._ffmpeg = None
if HLS_DIR.exists():
shutil.rmtree(HLS_DIR)
self.running = False self.running = False
logger.info("Camera stopped") logger.info("Camera stopped")
def get_frame(self) -> bytes: @property
with self._lock: def hls_dir(self) -> Path:
return self._frame return HLS_DIR
def wait_for_first_frame(self, timeout: float = 5.0) -> bool:
"""Block until the first frame is captured, or timeout."""
return self._frame_event.wait(timeout)
def frames(self) -> Iterator[bytes]:
"""Yield JPEG frames as they are captured."""
# wait for first real frame before yielding anything
if not self.wait_for_first_frame():
logger.error("Timed out waiting for first frame")
return
while self.running:
frame = self.get_frame()
if frame:
yield frame
self._stop_event.wait(0.033) # ~30fps cap
camera = Camera() camera = Camera()

View File

@@ -38,23 +38,30 @@
border: 1px solid #333; border: 1px solid #333;
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #555;
font-size: 0.9rem;
} }
#preview img { #stream-video {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
display: none; display: none;
} }
#placeholder {
color: #555;
font-size: 0.9rem;
}
.controls { .controls {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
align-items: center;
flex-wrap: wrap;
justify-content: center;
} }
button { button {
@@ -94,7 +101,7 @@
<div id="preview"> <div id="preview">
<span id="placeholder">Stream not started</span> <span id="placeholder">Stream not started</span>
<img id="stream-img" alt="Camera stream" /> <video id="stream-video" autoplay muted playsinline></video>
</div> </div>
<div class="controls"> <div class="controls">
@@ -104,39 +111,96 @@
<p id="status"></p> <p id="status"></p>
<!-- hls.js from CDN -->
<script src="https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js"></script>
<script> <script>
const btnStart = document.getElementById("btn-start"); const btnStart = document.getElementById("btn-start");
const btnStop = document.getElementById("btn-stop"); const btnStop = document.getElementById("btn-stop");
const streamImg = document.getElementById("stream-img"); const video = document.getElementById("stream-video");
const placeholder = document.getElementById("placeholder"); const placeholder = document.getElementById("placeholder");
const status = document.getElementById("status"); const status = document.getElementById("status");
let hls = null;
async function postAction(url) { async function postAction(url) {
const res = await fetch(url, { method: "POST" }); const res = await fetch(url, { method: "POST" });
return res.json(); return res.json();
} }
async function waitForReady(maxAttempts = 40) {
for (let i = 0; i < maxAttempts; i++) {
const res = await fetch("/camera/status");
const data = await res.json();
if (data.ready) return true;
await new Promise(r => setTimeout(r, 500));
}
return false;
}
function startHls() {
const src = "/camera/hls/stream.m3u8";
if (Hls.isSupported()) {
hls = new Hls({
lowLatencyMode: false,
backBufferLength: 10,
});
hls.loadSource(src);
hls.attachMedia(video);
hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) {
console.error("HLS fatal error:", data);
status.textContent = "Stream error — try restarting";
}
});
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
// Safari native HLS support
video.src = src;
} else {
status.textContent = "HLS not supported in this browser";
return;
}
video.style.display = "block";
placeholder.style.display = "none";
video.play().catch(e => console.warn("Autoplay blocked:", e));
}
function stopHls() {
if (hls) {
hls.destroy();
hls = null;
}
video.pause();
video.src = "";
video.style.display = "none";
placeholder.style.display = "block";
}
btnStart.addEventListener("click", async () => { btnStart.addEventListener("click", async () => {
btnStart.disabled = true; btnStart.disabled = true;
status.textContent = "Starting…"; status.textContent = "Starting camera…";
await postAction("/camera/start"); await postAction("/camera/start");
// small delay to let camera initialise status.textContent = "Waiting for first segment…";
await new Promise(r => setTimeout(r, 500)); const ready = await waitForReady();
streamImg.src = "/camera/stream"; if (!ready) {
streamImg.style.display = "block"; status.textContent = "Camera timed out — check Pi logs";
placeholder.style.display = "none"; btnStart.disabled = false;
return;
}
startHls();
btnStop.disabled = false; btnStop.disabled = false;
status.textContent = "Streaming"; status.textContent = "Streaming (H.264 / HLS)";
}); });
btnStop.addEventListener("click", async () => { btnStop.addEventListener("click", async () => {
btnStop.disabled = true; btnStop.disabled = true;
status.textContent = "Stopping…"; status.textContent = "Stopping…";
streamImg.style.display = "none"; stopHls();
streamImg.src = "";
placeholder.style.display = "block";
await postAction("/camera/stop"); await postAction("/camera/stop");
btnStart.disabled = false; btnStart.disabled = false;
status.textContent = "Stream stopped"; status.textContent = "Stream stopped";

View File

@@ -14,6 +14,11 @@ def client() -> Generator[FlaskClient, Any, Any]:
yield client yield client
def test_heartbeat_status_code(client: FlaskClient) -> None:
response = client.get("/heartbeat")
assert response.status_code == 200
def test_camera_start(client: FlaskClient) -> None: def test_camera_start(client: FlaskClient) -> None:
response = client.post("/camera/start") response = client.post("/camera/start")
assert response.status_code == 200 assert response.status_code == 200
@@ -27,6 +32,19 @@ def test_camera_stop(client: FlaskClient) -> None:
assert response.get_json()["status"] == "stopped" assert response.get_json()["status"] == "stopped"
def test_camera_status(client: FlaskClient) -> None:
response = client.get("/camera/status")
assert response.status_code == 200
data = response.get_json()
assert "running" in data
assert "ready" in data
def test_hls_404_when_not_running(client: FlaskClient) -> None:
response = client.get("/camera/hls/stream.m3u8")
assert response.status_code == 404
def test_index_page(client: FlaskClient) -> None: def test_index_page(client: FlaskClient) -> None:
response = client.get("/") response = client.get("/")
assert response.status_code == 200 assert response.status_code == 200