Rename customs-tablet-frontend to public-frontend and add new features
- Rename customs-tablet-frontend/ to public-frontend/ for broader scope - Add new pages: customs, inspection with camera integration - Add new services: apiClient.ts, backendApi.ts, normalizers.ts - Add CameraFrame component for real-time video streaming - Add scan_fixer module with clock_publisher and timestamp fix utilities - Update startup scripts to support new frontend structure - Update arm_server configuration and service files Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+143
-16
@@ -1,15 +1,20 @@
|
||||
"""
|
||||
二维码识别模块 - 使用 OpenCV 识别二维码获取 serialNumber
|
||||
二维码识别模块 - 使用 AGV 摄像头识别二维码获取 serialNumber
|
||||
|
||||
优先通过 v4l2-ctl 读取 MJPG 帧,避开部分 Jetson/arm64 环境中 OpenCV
|
||||
直接读取 MJPG 花屏或解码失败的问题;v4l2-ctl 不可用时回退到 OpenCV。
|
||||
"""
|
||||
import cv2
|
||||
import time
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import numpy as np
|
||||
from typing import Optional, Tuple
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 尝试导入二维码识别库
|
||||
try:
|
||||
from pyzbar.pyzbar import decode as qr_decode
|
||||
PYZBAR_AVAILABLE = True
|
||||
@@ -19,17 +24,76 @@ except ImportError:
|
||||
|
||||
|
||||
class QRScanner:
|
||||
"""二维码扫描器"""
|
||||
"""二维码扫描器。"""
|
||||
|
||||
def __init__(self, device_index: int = 0):
|
||||
V4L2_CTL = "/usr/bin/v4l2-ctl"
|
||||
DEFAULT_WIDTH = 640
|
||||
DEFAULT_HEIGHT = 400
|
||||
V4L2_STREAM_COUNT = 3
|
||||
V4L2_READ_TIMEOUT = 5.0
|
||||
OPENCV_READ_TIMEOUT = 2.0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device_index: int = 0,
|
||||
width: int = DEFAULT_WIDTH,
|
||||
height: int = DEFAULT_HEIGHT,
|
||||
prefer_v4l2_ctl: bool = True,
|
||||
):
|
||||
self.device_index = device_index
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.prefer_v4l2_ctl = prefer_v4l2_ctl
|
||||
self._cap: Optional[cv2.VideoCapture] = None
|
||||
self._qr_detector = cv2.QRCodeDetector() # OpenCV 内置二维码检测器
|
||||
self._v4l2_ctl_ready = False
|
||||
self._qr_detector = cv2.QRCodeDetector()
|
||||
|
||||
def _device_path(self) -> str:
|
||||
return f"/dev/video{self.device_index}"
|
||||
|
||||
def _build_v4l2_cmd(self, stream_count: int = V4L2_STREAM_COUNT) -> list:
|
||||
return [
|
||||
self.V4L2_CTL,
|
||||
"-d", self._device_path(),
|
||||
"--set-fmt-video",
|
||||
f"width={self.width},height={self.height},pixelformat=MJPG",
|
||||
"--stream-mmap",
|
||||
"--stream-to=-",
|
||||
f"--stream-count={stream_count}",
|
||||
]
|
||||
|
||||
def _check_v4l2_ctl(self) -> bool:
|
||||
if not self.prefer_v4l2_ctl:
|
||||
return False
|
||||
if not os.path.exists(self._device_path()):
|
||||
logger.warning(f"摄像头设备 {self._device_path()} 不存在,回退到 OpenCV")
|
||||
return False
|
||||
try:
|
||||
subprocess.run(
|
||||
[self.V4L2_CTL, "--version"],
|
||||
capture_output=True,
|
||||
timeout=2,
|
||||
check=False,
|
||||
)
|
||||
return True
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
logger.warning(f"v4l2-ctl 不可用: {self.V4L2_CTL},回退到 OpenCV")
|
||||
return False
|
||||
|
||||
def open(self) -> bool:
|
||||
"""打开摄像头"""
|
||||
self.close()
|
||||
self._v4l2_ctl_ready = self._check_v4l2_ctl()
|
||||
if self._v4l2_ctl_ready:
|
||||
logger.info(
|
||||
f"摄像头 {self._device_path()} 使用 v4l2-ctl MJPG 读取,"
|
||||
f"分辨率 {self.width}x{self.height}"
|
||||
)
|
||||
return True
|
||||
return self._open_opencv_capture()
|
||||
|
||||
def _open_opencv_capture(self) -> bool:
|
||||
try:
|
||||
# 强制 V4L2 后端
|
||||
self._cap = cv2.VideoCapture(self.device_index, cv2.CAP_V4L2)
|
||||
if not self._cap.isOpened():
|
||||
self._cap = cv2.VideoCapture(self.device_index)
|
||||
@@ -38,12 +102,10 @@ class QRScanner:
|
||||
logger.error(f"无法打开摄像头 {self.device_index}")
|
||||
return False
|
||||
|
||||
# 确保 OpenCV 做 BGR 转换(部分 V4L2 后端默认不做 YUYV→BGR 转换)
|
||||
self._cap.set(cv2.CAP_PROP_CONVERT_RGB, 1)
|
||||
# 设置分辨率(使用默认分辨率,不强制 MJPG)
|
||||
w = int(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
h = int(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
logger.info(f"摄像头 {self.device_index} 已打开,分辨率 {w}x{h}")
|
||||
logger.info(f"摄像头 {self.device_index} 使用 OpenCV 读取,分辨率 {w}x{h}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"摄像头打开失败: {e}")
|
||||
@@ -53,6 +115,47 @@ class QRScanner:
|
||||
if self._cap:
|
||||
self._cap.release()
|
||||
self._cap = None
|
||||
self._v4l2_ctl_ready = False
|
||||
|
||||
@staticmethod
|
||||
def _extract_first_jpeg(data: bytes) -> Optional[bytes]:
|
||||
"""从 v4l2-ctl 输出流中提取第一帧完整 JPEG。"""
|
||||
soi = data.find(b"\xff\xd8")
|
||||
if soi == -1:
|
||||
return None
|
||||
eoi = data.find(b"\xff\xd9", soi + 2)
|
||||
if eoi == -1 or eoi <= soi:
|
||||
return None
|
||||
return data[soi:eoi + 2]
|
||||
|
||||
def _read_frame_with_v4l2_ctl(self) -> Optional[np.ndarray]:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
self._build_v4l2_cmd(),
|
||||
capture_output=True,
|
||||
timeout=self.V4L2_READ_TIMEOUT,
|
||||
check=False,
|
||||
)
|
||||
jpeg_data = self._extract_first_jpeg(proc.stdout)
|
||||
if jpeg_data is None or len(jpeg_data) < 100:
|
||||
return None
|
||||
|
||||
frame = cv2.imdecode(
|
||||
np.frombuffer(jpeg_data, dtype=np.uint8),
|
||||
cv2.IMREAD_COLOR,
|
||||
)
|
||||
if frame is None:
|
||||
return None
|
||||
|
||||
if frame.mean() < 3 or frame.mean() > 250:
|
||||
return None
|
||||
return frame
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("v4l2-ctl 读取超时")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"v4l2-ctl 读取异常: {e}")
|
||||
return None
|
||||
|
||||
def _fix_frame(self, frame: np.ndarray) -> Optional[np.ndarray]:
|
||||
"""修复绿屏/格式错误帧,返回修复后的 BGR 帧或 None"""
|
||||
@@ -101,13 +204,10 @@ class QRScanner:
|
||||
# 正常 BGR 帧
|
||||
return frame
|
||||
|
||||
def read_frame(self, timeout: float = 2.0) -> Optional[np.ndarray]:
|
||||
"""读取一帧(带超时保护,避免 V4L2 select() 永久阻塞)"""
|
||||
def _read_frame_with_opencv(self, timeout: float) -> Optional[np.ndarray]:
|
||||
if not self._cap or not self._cap.isOpened():
|
||||
return None
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
|
||||
|
||||
pool = ThreadPoolExecutor(max_workers=1)
|
||||
try:
|
||||
fut = pool.submit(self._cap.read)
|
||||
@@ -131,17 +231,43 @@ class QRScanner:
|
||||
finally:
|
||||
pool.shutdown(wait=False)
|
||||
|
||||
def read_frame(self, timeout: float = OPENCV_READ_TIMEOUT) -> Optional[np.ndarray]:
|
||||
"""读取一帧。"""
|
||||
if self._v4l2_ctl_ready:
|
||||
frame = self._read_frame_with_v4l2_ctl()
|
||||
if frame is not None:
|
||||
return frame
|
||||
logger.debug("v4l2-ctl 未读到有效帧,尝试 OpenCV 兜底")
|
||||
if not self._cap or not self._cap.isOpened():
|
||||
self._open_opencv_capture()
|
||||
|
||||
return self._read_frame_with_opencv(timeout)
|
||||
|
||||
def detect_qr(self, frame: np.ndarray) -> Optional[str]:
|
||||
"""从图像帧中检测二维码"""
|
||||
if frame is None:
|
||||
return None
|
||||
|
||||
if len(frame.shape) == 2:
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR)
|
||||
|
||||
try:
|
||||
# OpenCV 内置二维码检测
|
||||
data, vertices, _ = self._qr_detector.detectAndDecode(frame)
|
||||
if data and len(data) > 0:
|
||||
return data.strip()
|
||||
except Exception as e:
|
||||
logger.debug(f"二维码检测失败: {e}")
|
||||
logger.debug(f"OpenCV QR 检测失败: {e}")
|
||||
|
||||
if PYZBAR_AVAILABLE:
|
||||
try:
|
||||
results = qr_decode(frame)
|
||||
for res in results:
|
||||
data = res.data.decode("utf-8").strip()
|
||||
if data:
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.debug(f"pyzbar 检测失败: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def scan_once(self) -> Optional[str]:
|
||||
@@ -152,6 +278,7 @@ class QRScanner:
|
||||
def scan_with_retry(self, max_attempts: int = 5, interval: float = 0.5) -> Optional[str]:
|
||||
"""多次扫描直到成功或达到最大次数"""
|
||||
for i in range(max_attempts):
|
||||
logger.debug(f"QR 扫描尝试 {i + 1}/{max_attempts}")
|
||||
result = self.scan_once()
|
||||
if result:
|
||||
return result
|
||||
|
||||
Reference in New Issue
Block a user