Coverage for orchestrant/pipeline/capture/gstreamer.py: 39%
244 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-18 00:48 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-18 00:48 +0000
1"""GStreamer subprocess capture backend."""
3from __future__ import annotations
5import os
6import platform
7import shlex
8import shutil
9import subprocess # nosec B404
10import threading
11import time
12from contextlib import suppress
13from pathlib import Path
14from queue import Empty, Full, Queue
15from typing import TYPE_CHECKING
17import numpy as np
18from loguru import logger
20from orchestrant.pipeline.constants import (
21 GST_DEFAULT_TIMEOUT_SECONDS,
22 GST_FALLBACK_HEIGHT,
23 GST_FALLBACK_WIDTH,
24 GST_FRAME_QUEUE_TIMEOUT,
25 GST_PROCESS_STARTUP_DELAY,
26 GST_PROCESS_WAIT_TIMEOUT,
27 WINDOWS_GSTREAMER_PATHS,
28)
31if TYPE_CHECKING:
32 from orchestrant.pipeline.types import CameraConfig
35def find_gstreamer_launch() -> tuple[str | None, str]:
36 """Find the gst-launch-1.0 executable."""
37 exe_name = (
38 "gst-launch-1.0.exe" if platform.system() == "Windows" else "gst-launch-1.0"
39 )
41 resolved = shutil.which("gst-launch-1.0")
42 if resolved:
43 return resolved, "Found in PATH"
45 if platform.system() == "Windows":
46 config_paths = _get_config_gstreamer_paths()
47 search_paths = config_paths or WINDOWS_GSTREAMER_PATHS
48 for base_path in search_paths:
49 exe_path = Path(base_path) / "bin" / exe_name
50 if exe_path.exists():
51 return str(exe_path), f"Found at {exe_path}"
53 return None, "gst-launch-1.0 not found"
56def _get_config_gstreamer_paths() -> list[str] | None:
57 """Get GStreamer paths from environment configuration."""
58 env_path = os.environ.get("KATAGLYPHIS_GSTREAMER_PATHS", "")
59 if not env_path:
60 return None
61 return [p.strip() for p in env_path.split(os.pathsep) if p.strip()]
64def get_gstreamer_env() -> dict[str, str]:
65 """Get the allowlisted environment variables GStreamer needs."""
66 env = {
67 "PATH": os.environ.get("PATH", ""),
68 "HOME": os.environ.get("HOME", ""),
69 "USER": os.environ.get("USER", ""),
70 "TMPDIR": os.environ.get("TMPDIR", ""),
71 "TEMP": os.environ.get("TEMP", ""),
72 }
73 # A package's launcher points GStreamer at its own plugins; GST_DEBUG and the like are the user's.
74 env.update({k: v for k, v in os.environ.items() if k.startswith("GST_")})
76 if platform.system() != "Windows":
77 return env
79 search_paths = _get_config_gstreamer_paths() or [
80 p for p in WINDOWS_GSTREAMER_PATHS if Path(p).exists()
81 ]
83 for gst_root in search_paths:
84 root_path = Path(gst_root)
85 if not root_path.exists():
86 continue
87 bin_path = root_path / "bin"
88 lib_path = root_path / "lib"
89 plugin_path = lib_path / "gstreamer-1.0"
91 current_path = env.get("PATH", "")
92 if str(bin_path) not in current_path:
93 env["PATH"] = str(bin_path) + os.pathsep + current_path
95 env["GST_PLUGIN_PATH"] = str(plugin_path)
96 env["GST_PLUGIN_SYSTEM_PATH"] = str(plugin_path)
98 logger.debug("GStreamer environment configured from: {}", root_path)
99 break
101 return env
104class GStreamerSubprocessCapture:
105 """GStreamer video capture using subprocess and raw frames on stdout."""
107 def __init__(self, config: CameraConfig) -> None:
108 """Initialize the capture backend."""
109 self.config = config
110 self.process: subprocess.Popen[bytes] | None = None
111 self.frame_queue: Queue[np.ndarray] = Queue(maxsize=2)
112 self.running = False
113 self.reader_thread: threading.Thread | None = None
114 self._frame_buffer: np.ndarray | None = None
116 self.actual_width = config.width
117 self.actual_height = config.height
118 self.actual_fps = float(config.fps)
119 self.pipeline_string = ""
120 self.frame_size = 0
122 self.gst_launch_path, self.gst_status = find_gstreamer_launch()
123 self.gst_env = get_gstreamer_env()
125 def _allocate_frame_buffer(self) -> None:
126 """Pre-allocate reusable frame buffer for zero-copy reading."""
127 frame_size = self.actual_width * self.actual_height * 3
128 self._frame_buffer = np.empty(frame_size, dtype=np.uint8)
130 def _build_pipeline_string(
131 self,
132 source: str,
133 width: int,
134 height: int,
135 fps: int,
136 pipeline_type: str = "strict",
137 *,
138 with_device: bool = True,
139 include_size: bool = True,
140 include_fps: bool = True,
141 include_format: bool = True,
142 ) -> str:
143 """Build a GStreamer pipeline string for the requested settings."""
144 sink = "fdsink fd=1 sync=false"
145 device = f" device-index={self.config.device_index}" if with_device else ""
146 source_prefix = f"{source}{device}"
148 caps_parts = []
149 if include_size:
150 caps_parts.append(f"width={width}")
151 caps_parts.append(f"height={height}")
152 if include_fps:
153 caps_parts.append(f"framerate={fps}/1")
154 if include_format:
155 caps_parts.append("format=BGR")
157 caps = f"video/x-raw,{','.join(caps_parts)}" if caps_parts else "video/x-raw"
159 if pipeline_type == "strict":
160 return f"{source_prefix} ! {caps} ! videoconvert ! {caps} ! {sink}"
161 if pipeline_type == "flexible":
162 return (
163 f"{source_prefix} ! "
164 f"videoconvert ! videoscale ! "
165 f"{caps} ! "
166 f"videorate ! {caps} ! "
167 f"videoconvert ! {caps} ! {sink}"
168 )
169 return f"{source_prefix} ! videoconvert ! videoscale ! {caps} ! {sink}"
171 def _log_short_read(self, bytes_read: int, frame_size: int) -> bool:
172 """Log an under-sized read; True means the stream ended (0 bytes)."""
173 if bytes_read == 0:
174 logger.warning("GStreamer process ended (no data)")
175 return True
176 logger.warning("Incomplete frame: {}/{}", bytes_read, frame_size)
177 return False
179 def _read_one_frame(self, frame_size: int) -> tuple[np.ndarray | None, bool]:
180 """Read one raw BGR frame; return (frame or None on a short read, stream_ended)."""
181 # A pipeline that died after the caller's check ends the stream, like a closed pipe.
182 process = self.process
183 if process is None or process.stdout is None:
184 logger.warning("GStreamer process is gone before read")
185 return None, True
186 stdout = process.stdout
188 # getattr, not hasattr: hasattr gives the type checker nothing to narrow.
189 readinto = getattr(stdout, "readinto", None)
190 # One read: open() allocates the buffer while this thread already runs.
191 buffer = self._frame_buffer
192 if (
193 buffer is not None
194 and buffer.size == frame_size
195 and readinto is not None
196 and callable(readinto)
197 ):
198 bytes_read = readinto(buffer)
199 if bytes_read != frame_size:
200 return None, self._log_short_read(bytes_read, frame_size)
201 # A copy: the queued frame must not be the buffer the next readinto overwrites.
202 frame = buffer.reshape((self.actual_height, self.actual_width, 3)).copy()
203 else:
204 raw_data = stdout.read(frame_size)
205 if len(raw_data) != frame_size:
206 return None, self._log_short_read(len(raw_data), frame_size)
207 frame = np.frombuffer(raw_data, dtype=np.uint8).reshape(
208 (self.actual_height, self.actual_width, 3)
209 )
210 return frame, False
212 def _offer_frame(self, frame: np.ndarray) -> None:
213 """Queue a frame, dropping the oldest while full; never blocks, never raises Full."""
214 while True:
215 try:
216 self.frame_queue.put_nowait(frame)
217 except Full:
218 with suppress(Empty):
219 self.frame_queue.get_nowait()
220 else:
221 return
223 def _frame_reader(self) -> None:
224 frame_size = self.actual_width * self.actual_height * 3
226 while self.running:
227 # One read of self.process: release() on another thread sets it to None.
228 process = self.process
229 if process is None or process.poll() is not None:
230 break
231 try:
232 if process.stdout is None:
233 break
235 frame, stream_ended = self._read_one_frame(frame_size)
236 if frame is None:
237 if stream_ended:
238 break
239 continue
241 self._offer_frame(np.ascontiguousarray(frame))
243 except (OSError, ValueError) as exc:
244 if self.running:
245 logger.error("Frame reader error: {}", exc)
246 break
248 self.running = False
250 def _start_pipeline(self, pipeline_str: str) -> bool:
251 if self.gst_launch_path is None:
252 return False
254 cmd = [self.gst_launch_path, "-q", *shlex.split(pipeline_str)]
255 try:
256 # nosec before noqa: ruff reads codes to end-of-line, bandit stops at "#".
257 self.process = subprocess.Popen( # nosec B603 # noqa: S603
258 cmd,
259 stdout=subprocess.PIPE,
260 stderr=subprocess.PIPE,
261 bufsize=0,
262 env=self.gst_env,
263 )
264 except OSError as exc:
265 logger.warning("Failed to start gst-launch: {}", exc)
266 self.process = None
267 return False
269 time.sleep(GST_PROCESS_STARTUP_DELAY)
270 if self.process.poll() is not None:
271 stderr_text = ""
272 if self.process.stderr is not None:
273 stderr_text = self.process.stderr.read().decode(
274 "utf-8", errors="ignore"
275 )
276 logger.warning("Pipeline failed: {}", stderr_text[:300])
277 self.release()
278 return False
280 self.running = True
281 self.reader_thread = threading.Thread(target=self._frame_reader, daemon=True)
282 self.reader_thread.start()
284 try:
285 frame = self.frame_queue.get(timeout=GST_FRAME_QUEUE_TIMEOUT)
286 except Empty:
287 logger.warning("No frames from pipeline")
288 self.release()
289 return False
291 if frame.shape[0] != self.actual_height or frame.shape[1] != self.actual_width:
292 logger.info("Adjusting dimensions: {}", frame.shape[:2])
293 self.actual_height, self.actual_width = frame.shape[:2]
294 self.frame_size = self.actual_width * self.actual_height * 3
296 self._offer_frame(frame)
297 self._allocate_frame_buffer()
298 return True
300 def _try_pipelines(
301 self,
302 sources: list[tuple[str, bool]],
303 specs: list[tuple[str, bool, bool, bool]],
304 width: int,
305 height: int,
306 fps: int,
307 deadline: float,
308 ) -> bool:
309 """Try multiple pipeline configurations until one succeeds or deadline passes."""
310 for source, with_device in sources:
311 for pipeline_type, include_size, include_fps, include_format in specs:
312 if time.monotonic() > deadline:
313 return False
315 pipeline_str = self._build_pipeline_string(
316 source,
317 width,
318 height,
319 fps,
320 pipeline_type,
321 with_device=with_device,
322 include_size=include_size,
323 include_fps=include_fps,
324 include_format=include_format,
325 )
326 logger.debug("Pipeline: {}", pipeline_str)
328 if self._start_pipeline(pipeline_str):
329 self.pipeline_string = pipeline_str
330 return True
332 logger.warning("Pipeline failed: {} ({})", pipeline_type, source)
333 return False
335 def open(self, timeout: float = GST_DEFAULT_TIMEOUT_SECONDS) -> bool:
336 """Start the GStreamer subprocess, trying pipelines for up to ``timeout`` seconds."""
337 if self.gst_launch_path is None:
338 logger.error("GStreamer not available: {}", self.gst_status)
339 return False
341 logger.info("GStreamer: {}", self.gst_status)
342 logger.debug("Executable: {}", self.gst_launch_path)
344 self.frame_size = self.actual_width * self.actual_height * 3
345 deadline = time.monotonic() + timeout
347 sources: list[tuple[str, bool]] = [
348 ("mfvideosrc", True),
349 ("ksvideosrc", True),
350 ("dshowvideosrc", False),
351 ("autovideosrc", False),
352 ]
353 pipeline_specs: list[tuple[str, bool, bool, bool]] = [
354 ("strict", True, True, True),
355 ("strict", True, False, True),
356 ("strict", False, False, True),
357 ("strict", False, False, False),
358 ("flexible", True, True, True),
359 ("flexible", True, False, True),
360 ("flexible", False, False, True),
361 ("auto", True, True, True),
362 ("auto", False, False, True),
363 ("auto", False, False, False),
364 ]
366 if self._try_pipelines(
367 sources,
368 pipeline_specs,
369 self.actual_width,
370 self.actual_height,
371 int(self.actual_fps),
372 deadline,
373 ):
374 logger.success(
375 "GStreamer started: {}x{}", self.actual_width, self.actual_height
376 )
377 return True
379 if time.monotonic() > deadline:
380 logger.warning("GStreamer pipeline search timed out after {}s", timeout)
381 return False
383 fallback_width = GST_FALLBACK_WIDTH
384 fallback_height = GST_FALLBACK_HEIGHT
385 fallback_fps = int(self.actual_fps) if self.actual_fps > 0 else 30
386 logger.info("Trying fallback pipeline ({}x{})", fallback_width, fallback_height)
388 self.actual_width = fallback_width
389 self.actual_height = fallback_height
390 self.frame_size = self.actual_width * self.actual_height * 3
392 if self._try_pipelines(
393 sources,
394 pipeline_specs,
395 fallback_width,
396 fallback_height,
397 fallback_fps,
398 deadline,
399 ):
400 logger.success(
401 "GStreamer started (fallback): {}x{}",
402 self.actual_width,
403 self.actual_height,
404 )
405 return True
407 logger.error("All GStreamer pipelines failed!")
408 return False
410 def read(self) -> tuple[bool, np.ndarray | None]:
411 """Read a frame from the capture queue."""
412 if not self.running:
413 return False, None
415 try:
416 frame = self.frame_queue.get(timeout=1.0)
417 except Empty:
418 return False, None
419 else:
420 return True, frame
422 def release(self) -> None:
423 """Stop capture and release subprocess resources."""
424 self.running = False
425 if self.process is not None:
426 try:
427 self.process.terminate()
428 self.process.wait(timeout=GST_PROCESS_WAIT_TIMEOUT)
429 except subprocess.TimeoutExpired:
430 self.process.kill()
431 except Exception as exc:
432 logger.debug("Failed to terminate GStreamer process: {}", exc)
433 self.process = None
435 if self.reader_thread and self.reader_thread.is_alive():
436 self.reader_thread.join(timeout=1.0)
438 while not self.frame_queue.empty():
439 with suppress(Empty):
440 self.frame_queue.get_nowait()
442 def is_opened(self) -> bool:
443 """Return True if the subprocess is running."""
444 return self.running and self.process is not None and self.process.poll() is None
446 def get_info(self) -> dict:
447 """Return backend metadata for diagnostics."""
448 return {
449 "backend": "GStreamer (subprocess)",
450 "pipeline": self.pipeline_string,
451 "width": self.actual_width,
452 "height": self.actual_height,
453 "fps": self.actual_fps,
454 }