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:
2026-06-22 10:18:20 +08:00
parent 083d12016a
commit 1429442dbd
49 changed files with 2758 additions and 2141 deletions
+143 -16
View File
@@ -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