add ffmpeg backend for linux compatibility

This commit is contained in:
TPP-G6
2026-03-01 12:37:59 +08:00
parent aee2e86cda
commit c22c92cae3
3 changed files with 264 additions and 16 deletions
+133 -6
View File
@@ -7,9 +7,11 @@ and Helmholtz-Hodge decomposition.
""" """
import os import os
import platform
import numpy as np import numpy as np
import cv2 import cv2
import time import time
import threading
from enum import Enum, auto from enum import Enum, auto
from typing import Tuple from typing import Tuple
# from threading import Thread, Event # from threading import Thread, Event
@@ -21,6 +23,10 @@ from .HHD import nHHD
from . import configs from . import configs
from .get_depth import Contact3D from .get_depth import Contact3D
from .backend_availability import (check_cupy_available, check_opencl_available) from .backend_availability import (check_cupy_available, check_opencl_available)
try:
import av # Optional: used for Linux camera capture path.
except ImportError:
av = None
class Sensor: class Sensor:
""" """
@@ -128,6 +134,18 @@ class Sensor:
self.__contact3d = Contact3D(config_contact=self.__config_contact, verbose=self.verbose) self.__contact3d = Contact3D(config_contact=self.__config_contact, verbose=self.verbose)
self.__hhd = None self.__hhd = None
self.poisson_backend = poisson_backend self.poisson_backend = poisson_backend
self._use_pyav = False
self._av_container = None
self._av_stream = None
self._av_decoder = None
self.cap = None
self._is_live_camera = isinstance(self.vid_src, int)
self._capture_thread = None
self._capture_running = False
self._latest_frame = None
self._latest_frame_seq = 0
self._last_consumed_seq = -1
self._latest_frame_lock = threading.Lock()
# Setup video capture # Setup video capture
self._open_video_source(override, box) self._open_video_source(override, box)
@@ -157,7 +175,7 @@ class Sensor:
# Simple direct read - buffer size of 1 should minimize frame accumulation # Simple direct read - buffer size of 1 should minimize frame accumulation
# Timing measurement helps identify if capture or processing is the bottleneck # Timing measurement helps identify if capture or processing is the bottleneck
t_capture_start = time.perf_counter() t_capture_start = time.perf_counter()
ret, frame = self.cap.read() ret, frame = self._read_frame()
t_capture_end = time.perf_counter() t_capture_end = time.perf_counter()
self._last_capture_time = t_capture_end - t_capture_start self._last_capture_time = t_capture_end - t_capture_start
@@ -350,7 +368,20 @@ class Sensor:
def disconnect(self): def disconnect(self):
"""Release resources and stop async threads.""" """Release resources and stop async threads."""
if self.cap.isOpened(): self._capture_running = False
if self._capture_thread is not None:
self._capture_thread.join(timeout=1.0)
self._capture_thread = None
if self._use_pyav:
if self._av_container is not None:
self._av_container.close()
self._av_container = None
self._av_stream = None
self._av_decoder = None
if self.verbose:
print("Sensor disconnected.")
elif self.cap is not None and self.cap.isOpened():
self.cap.release() self.cap.release()
if self.verbose: if self.verbose:
print("Sensor disconnected.") print("Sensor disconnected.")
@@ -420,13 +451,41 @@ class Sensor:
def _open_video_source(self, override, box): def _open_video_source(self, override, box):
"""Initialize video capture and calibration.""" """Initialize video capture and calibration."""
use_linux_camera_pyav = (
platform.system() == "Linux"
and isinstance(self.vid_src, int)
and av is not None
)
if use_linux_camera_pyav:
device = f"/dev/video{self.vid_src}"
try:
self._av_container = av.open(
device,
format="v4l2",
options={
"input_format": "mjpeg",
"video_size": f"{self._config_resolution_raw[0]}x{self._config_resolution_raw[1]}",
"framerate": str(self._config_fps),
},
)
self._av_stream = self._av_container.streams.video[0]
self._av_decoder = self._av_container.decode(self._av_stream)
self._use_pyav = True
if self.verbose:
print(f"Using PyAV Linux camera backend: {device}")
except Exception as exc:
if self.verbose:
print(f"PyAV camera open failed ({exc}), fallback to OpenCV backend.")
self._use_pyav = False
if not self._use_pyav:
self.cap = cv2.VideoCapture(self.vid_src) self.cap = cv2.VideoCapture(self.vid_src)
# Set buffer size to 1 to reduce latency and avoid frame accumulation # Set buffer size to 1 to reduce latency and avoid frame accumulation
# Note: Not all backends support this, but it helps when available # Note: Not all backends support this, but it helps when available
try: try:
self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
except: except Exception:
pass # Some backends don't support this property pass # Some backends don't support this property
self.cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter.fourcc('M', 'J', 'P', 'G')) self.cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter.fourcc('M', 'J', 'P', 'G'))
self.cap.set(cv2.CAP_PROP_FPS, self._config_fps) self.cap.set(cv2.CAP_PROP_FPS, self._config_fps)
@@ -436,13 +495,13 @@ class Sensor:
if self.verbose: if self.verbose:
print("Connecting to video source...") print("Connecting to video source...")
if not self.cap.isOpened(): if not self._is_source_open():
raise Exception("Failed to open video source.") raise Exception("Failed to open video source.")
# Wait for first valid frame # Wait for first valid frame
frame = None frame = None
while frame is None: while frame is None:
ret, frame = self.cap.read() ret, frame = self._read_frame_raw()
if not ret: if not ret:
time.sleep(0.1) time.sleep(0.1)
@@ -463,6 +522,10 @@ class Sensor:
self.initial_img = cv2.flip(self.initial_img, 1) self.initial_img = cv2.flip(self.initial_img, 1)
self.initial_img = self.initial_img.astype(np.uint8) self.initial_img = self.initial_img.astype(np.uint8)
# For live cameras, start a latest-frame capture thread to avoid latency buildup.
if self._is_live_camera:
self._start_capture_thread()
def _find_stitch_boxes(self, img_src, show=False): def _find_stitch_boxes(self, img_src, show=False):
"""Detect vision tile boxes with camera stabilization.""" """Detect vision tile boxes with camera stabilization."""
@@ -470,7 +533,9 @@ class Sensor:
start_time = cv2.getTickCount() start_time = cv2.getTickCount()
while n_boxes < self._config_units: while n_boxes < self._config_units:
ret, frame = self.cap.read() ret, frame = self._read_frame_raw()
if not ret:
continue
v_mean = np.sum(cv2.mean(frame)) v_mean = np.sum(cv2.mean(frame))
if abs(v_mean - v_mean0) < 5: if abs(v_mean - v_mean0) < 5:
@@ -496,6 +561,68 @@ class Sensor:
self.stitcher.extract_tiles() self.stitcher.extract_tiles()
return True return True
def _is_source_open(self):
if self._use_pyav:
return self._av_container is not None
return self.cap is not None and self.cap.isOpened()
def _read_frame(self):
if self._capture_running:
# Startup race guard: wait briefly until capture thread publishes first frame.
if self._latest_frame is None:
deadline = time.perf_counter() + 1.0
while time.perf_counter() < deadline:
with self._latest_frame_lock:
if self._latest_frame is not None:
break
time.sleep(0.001)
# Return latest frame immediately (do not wait for next camera tick).
# This keeps end-to-end latency minimal; caller may receive repeated
# frames if processing runs faster than camera FPS.
with self._latest_frame_lock:
frame = self._latest_frame
seq = self._latest_frame_seq
if frame is not None:
self._last_consumed_seq = seq
return True, frame.copy()
return False, None
return self._read_frame_raw()
def _read_frame_raw(self):
if self._use_pyav:
if self._av_decoder is None:
return False, None
try:
frame = next(self._av_decoder)
except StopIteration:
return False, None
except Exception:
return False, None
return True, frame.to_ndarray(format="bgr24")
if self.cap is None:
return False, None
return self.cap.read()
def _start_capture_thread(self):
if self._capture_thread is not None:
return
self._capture_running = True
self._capture_thread = threading.Thread(target=self._capture_loop, daemon=True)
self._capture_thread.start()
if self.verbose:
print("Latest-frame capture thread started.")
def _capture_loop(self):
while self._capture_running:
ret, frame = self._read_frame_raw()
if not ret:
time.sleep(0.001)
continue
with self._latest_frame_lock:
self._latest_frame = frame
self._latest_frame_seq += 1
# ========================================================================= # =========================================================================
# Private - Utilities # Private - Utilities
+75
View File
@@ -0,0 +1,75 @@
import cv2
import time
# Toggle these to test whether exposure is limiting delivered FPS.
USE_MANUAL_EXPOSURE = False
MANUAL_EXPOSURE = -6 # Typical V4L2/OpenCV manual range is negative values.
cap = cv2.VideoCapture(4, cv2.CAP_V4L2)
cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter.fourcc('M', 'J', 'P', 'G'))
cap.set(cv2.CAP_PROP_FPS, 30)
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1920)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 1080)
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
# For V4L2 in OpenCV: auto-exposure is usually 3, manual is usually 1.
if USE_MANUAL_EXPOSURE:
ok_auto = cap.set(cv2.CAP_PROP_AUTO_EXPOSURE, 1)
ok_exp = cap.set(cv2.CAP_PROP_EXPOSURE, MANUAL_EXPOSURE)
else:
ok_auto = cap.set(cv2.CAP_PROP_AUTO_EXPOSURE, 3)
ok_exp = True
actual_fourcc = int(cap.get(cv2.CAP_PROP_FOURCC))
print(f"Backend: {cap.getBackendName()}")
print(
f"FOURCC: {chr(actual_fourcc & 0xFF)}"
f"{chr((actual_fourcc >> 8) & 0xFF)}"
f"{chr((actual_fourcc >> 16) & 0xFF)}"
f"{chr((actual_fourcc >> 24) & 0xFF)}"
)
print(f"Resolution: {int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))}x{int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))}")
print(f"FPS: {cap.get(cv2.CAP_PROP_FPS)}")
print(
f"Exposure mode: {'manual' if USE_MANUAL_EXPOSURE else 'auto'} "
f"(set_auto_ok={ok_auto}, set_exp_ok={ok_exp})"
)
print(f"AutoExposure(readback): {cap.get(cv2.CAP_PROP_AUTO_EXPOSURE)}")
print(f"Exposure(readback): {cap.get(cv2.CAP_PROP_EXPOSURE)}")
frame_count = 0
start = time.perf_counter()
while cap.isOpened():
t0 = time.perf_counter()
ok = cap.grab()
t1 = time.perf_counter()
if not ok:
break
ret, frame = cap.retrieve()
t2 = time.perf_counter()
if not ret:
continue
cv2.imshow("webcam", frame)
t3 = time.perf_counter()
key = cv2.waitKey(1) & 0xFF
t4 = time.perf_counter()
frame_count += 1
if frame_count % 30 == 0:
elapsed = t4 - start
print(
f"Frame {frame_count}: "
f"grab={1000*(t1-t0):.1f}ms, "
f"retrieve={1000*(t2-t1):.1f}ms, "
f"imshow={1000*(t3-t2):.1f}ms, "
f"waitKey={1000*(t4-t3):.1f}ms, "
f"avg_fps={frame_count/elapsed:.1f}"
)
if key == ord('q'):
break
cap.release()
cv2.destroyAllWindows()
+46
View File
@@ -0,0 +1,46 @@
import av
import cv2
import time
DEVICE = "/dev/video4"
TARGET_FPS = 30
WIDTH = 1280
HEIGHT = 720
# Open v4l2 device with same style options as ffmpeg CLI
container = av.open(
DEVICE,
format="v4l2",
options={
"input_format": "mjpeg",
"video_size": f"{WIDTH}x{HEIGHT}",
"framerate": str(TARGET_FPS),
},
)
stream = container.streams.video[0]
frame_count = 0
t_start = time.perf_counter()
for frame in container.decode(stream):
t0 = time.perf_counter()
img = frame.to_ndarray(format="bgr24") # OpenCV-compatible
t1 = time.perf_counter()
cv2.imshow("pyav_cam", img)
key = cv2.waitKey(1) & 0xFF
t2 = time.perf_counter()
frame_count += 1
if frame_count % 30 == 0:
elapsed = t2 - t_start
print(
f"Frame {frame_count}: convert={1000*(t1-t0):.1f}ms, "
f"imshow+waitKey={1000*(t2-t1):.1f}ms, avg_fps={frame_count/elapsed:.1f}"
)
if key == ord("q"):
break
container.close()
cv2.destroyAllWindows()