Modifying output to use H.264.
This commit is contained in:
184
src/camera.py
184
src/camera.py
@@ -1,114 +1,166 @@
|
||||
import io
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
from collections.abc import Iterator
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
PIL_AVAILABLE = True
|
||||
except ImportError:
|
||||
PIL_AVAILABLE = False
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
try:
|
||||
from picamera2 import Picamera2
|
||||
from picamera2.encoders import H264Encoder
|
||||
from picamera2.outputs import FileOutput
|
||||
|
||||
PICAMERA_AVAILABLE = True
|
||||
except ImportError:
|
||||
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:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._picam: Picamera2 | None = None
|
||||
self._thread: threading.Thread | None = None
|
||||
self._frame: bytes = b""
|
||||
self._lock = threading.Lock()
|
||||
self._encoder: H264Encoder | None = None
|
||||
self._ffmpeg: subprocess.Popen[bytes] | None = None
|
||||
self._output: PipeOutput | None = None
|
||||
self._ready_event = threading.Event()
|
||||
self._watch_thread: threading.Thread | None = None
|
||||
self._stop_event = threading.Event()
|
||||
self._frame_event = threading.Event()
|
||||
self.running = False
|
||||
|
||||
def start(self) -> None:
|
||||
if self.running:
|
||||
return
|
||||
|
||||
# prepare HLS output directory
|
||||
if HLS_DIR.exists():
|
||||
shutil.rmtree(HLS_DIR)
|
||||
HLS_DIR.mkdir(parents=True)
|
||||
|
||||
if not PICAMERA_AVAILABLE:
|
||||
logger.info("Mock camera started")
|
||||
self.running = True
|
||||
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()
|
||||
config = self._picam.create_still_configuration(
|
||||
main={"size": (1280, 720), "format": "RGB888"},
|
||||
config = self._picam.create_video_configuration(
|
||||
main={"size": (1280, 720)},
|
||||
)
|
||||
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._frame_event.clear()
|
||||
self._thread = threading.Thread(target=self._capture_loop, daemon=True)
|
||||
self._thread.start()
|
||||
self._ready_event.clear()
|
||||
self._watch_thread = threading.Thread(target=self._watch_playlist, daemon=True)
|
||||
self._watch_thread.start()
|
||||
self.running = True
|
||||
logger.info("Camera started")
|
||||
|
||||
def _capture_loop(self) -> None:
|
||||
assert self._picam is not None
|
||||
self._stop_event.wait(0.5)
|
||||
logger.info("Capture loop: starting")
|
||||
logger.info("Camera started — waiting for first HLS segment")
|
||||
|
||||
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():
|
||||
try:
|
||||
logger.info("Capture loop: attempting capture_array")
|
||||
array = self._picam.capture_array("main")
|
||||
logger.info(f"Capture loop: got array {array.shape}")
|
||||
buffer = io.BytesIO()
|
||||
img = Image.fromarray(array)
|
||||
img.save(buffer, format="JPEG", quality=85)
|
||||
frame = buffer.getvalue()
|
||||
logger.info(f"Capture loop: encoded JPEG {len(frame)} bytes")
|
||||
with self._lock:
|
||||
self._frame = frame
|
||||
self._frame_event.set()
|
||||
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)
|
||||
if playlist.exists():
|
||||
content = playlist.read_text()
|
||||
if ".ts" in content:
|
||||
logger.info("HLS playlist ready")
|
||||
self._ready_event.set()
|
||||
return
|
||||
time.sleep(0.25)
|
||||
|
||||
def wait_until_ready(self, timeout: float = 15.0) -> bool:
|
||||
if not PICAMERA_AVAILABLE:
|
||||
return True
|
||||
return self._ready_event.wait(timeout)
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self.running:
|
||||
return
|
||||
self._stop_event.set()
|
||||
self._frame_event.set() # unblock any waiting frames() calls
|
||||
if self._thread:
|
||||
self._thread.join(timeout=3)
|
||||
self._ready_event.set() # unblock any waiters
|
||||
|
||||
if self._picam:
|
||||
self._picam.stop()
|
||||
self._picam.stop_recording()
|
||||
self._picam.close()
|
||||
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
|
||||
logger.info("Camera stopped")
|
||||
|
||||
def get_frame(self) -> bytes:
|
||||
with self._lock:
|
||||
return self._frame
|
||||
|
||||
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
|
||||
@property
|
||||
def hls_dir(self) -> Path:
|
||||
return HLS_DIR
|
||||
|
||||
|
||||
camera = Camera()
|
||||
|
||||
Reference in New Issue
Block a user