diff --git a/agv_app/app.py b/agv_app/app.py index 3fb06e5..91ff396 100644 --- a/agv_app/app.py +++ b/agv_app/app.py @@ -1132,6 +1132,38 @@ def api_arm_camera_refresh(): return "", 404 +@app.route("/api/camera/arm_preview") +def api_arm_camera_preview(): + """代理机械臂 MJPEG 视频流,供页面连续预览。""" + import requests + try: + upstream = requests.get( + ARM_CAMERA_CONFIG["url"], + stream=True, + timeout=(3, 10), + ) + if upstream.status_code != 200: + upstream.close() + return "", 404 + + def generate(): + try: + for chunk in upstream.iter_content(chunk_size=8192): + if chunk: + yield chunk + finally: + upstream.close() + + return Response( + generate(), + mimetype="multipart/x-mixed-replace; boundary=frame", + headers={"Cache-Control": "no-cache, no-store, must-revalidate, max-age=0"}, + ) + except Exception as ex: + logger.error(f"arm_preview 失败: {ex}") + return "", 404 + + @app.route("/api/camera/qr_scan", methods=["GET"]) def api_qr_scan(): """扫描一次二维码""" diff --git a/agv_app/static/js/app.js b/agv_app/static/js/app.js index 13fc01c..4ae96ba 100644 --- a/agv_app/static/js/app.js +++ b/agv_app/static/js/app.js @@ -16,7 +16,7 @@ createApp({ currentState: 'idle', // 摄像头轮询 agvCameraSrc: '/api/camera/refresh?t=' + Date.now(), - armCameraSrc: '/api/camera/arm_refresh?t=' + Date.now(), + armCameraSrc: '/api/camera/arm_preview?t=' + Date.now(), agvCameraError: false, armCameraError: false, reconnectingDevice: null @@ -43,7 +43,9 @@ createApp({ methods: { refreshCams() { this.agvCameraSrc = '/api/camera/refresh?t=' + Date.now() - this.armCameraSrc = '/api/camera/arm_refresh?t=' + Date.now() + if (!this.armCameraSrc.startsWith('/api/camera/arm_preview')) { + this.armCameraSrc = '/api/camera/arm_preview?t=' + Date.now() + } }, async refresh() { await this.refreshStatus() diff --git a/agv_app/static/js/running.js b/agv_app/static/js/running.js index 9ebe055..a9251db 100644 --- a/agv_app/static/js/running.js +++ b/agv_app/static/js/running.js @@ -9,7 +9,7 @@ createApp({ tasks: [], report: null, agvPreviewUrl: API + '/api/camera/preview', - armPreviewUrl: API + '/api/camera/arm_refresh', + armPreviewUrl: API + '/api/camera/arm_preview', polling: null, logs: [], showQrModal: false, @@ -265,4 +265,4 @@ createApp({ return ms[field] || '' } } -}).mount('#app') \ No newline at end of file +}).mount('#app') diff --git a/agv_app/static/js/setting.js b/agv_app/static/js/setting.js index 03535f9..d8e43e5 100644 --- a/agv_app/static/js/setting.js +++ b/agv_app/static/js/setting.js @@ -57,7 +57,7 @@ createApp({ qrScanning: false, qrConfigs: [], qrScanningId: null, - armCameraUrl: API + '/api/camera/arm_refresh', + armCameraUrl: API + '/api/camera/arm_preview', newQrName: '', armInitialPose: [0, 0, 0, 0, 0, 0], } @@ -67,11 +67,7 @@ createApp({ this.refreshAngles() this.loadQrConfigs() this.nav2Timer = setInterval(this.refreshNavStatus, 3000) - // 机械臂摄像头自动刷新(每2秒) - this.armCameraUrl = API + '/api/camera/arm_refresh?t=' + Date.now() - this.armCameraTimer = setInterval(() => { - this.armCameraUrl = API + '/api/camera/arm_refresh?t=' + Date.now() - }, 2000) + this.armCameraUrl = API + '/api/camera/arm_preview?t=' + Date.now() }, computed: { hasQr() { diff --git a/agv_app/templates/index.html b/agv_app/templates/index.html index 46c2b1a..99a2a88 100644 --- a/agv_app/templates/index.html +++ b/agv_app/templates/index.html @@ -99,7 +99,7 @@
未打开(先点击连接设备)
-
机械臂摄像头
+
机械臂摄像头
机械臂摄像头异常
未连接
diff --git a/arm_server/arm_server.py b/arm_server/arm_server.py index b389e08..f74214c 100644 --- a/arm_server/arm_server.py +++ b/arm_server/arm_server.py @@ -33,19 +33,80 @@ arm_video_app = Flask(__name__) ARM_CAMERA_INDEX = 0 # 机械臂端摄像头设备号 _ffmpeg_proc = None +_ffmpeg_thread = None +_ffmpeg_lock = threading.Lock() +_frame_cond = threading.Condition() +_latest_frame = None +_latest_frame_ts = 0.0 +_stop_ffmpeg_reader = threading.Event() + + +def _stop_ffmpeg(): + """停止 ffmpeg 采集进程和读帧线程。""" + global _ffmpeg_proc + _stop_ffmpeg_reader.set() + if _ffmpeg_proc and _ffmpeg_proc.poll() is None: + _ffmpeg_proc.terminate() + try: + _ffmpeg_proc.wait(timeout=2) + except subprocess.TimeoutExpired: + _ffmpeg_proc.kill() + _ffmpeg_proc = None + + +def _frame_reader(): + """从 ffmpeg 的连续 MJPEG 输出中解析 JPEG 帧,并缓存最新一帧。""" + global _ffmpeg_proc, _latest_frame, _latest_frame_ts + buf = b"" + while not _stop_ffmpeg_reader.is_set(): + proc = _ffmpeg_proc + if proc is None or proc.poll() is not None or proc.stdout is None: + time.sleep(0.1) + continue + chunk = proc.stdout.read(8192) + if not chunk: + if proc.poll() is not None: + break + time.sleep(0.02) + continue + buf += chunk + while True: + start = buf.find(b"\xff\xd8") + end = buf.find(b"\xff\xd9", start + 2) if start >= 0 else -1 + if start < 0: + buf = buf[-2:] + break + if end < 0: + buf = buf[start:] + break + frame = buf[start:end + 2] + buf = buf[end + 2:] + with _frame_cond: + _latest_frame = frame + _latest_frame_ts = time.time() + _frame_cond.notify_all() def _ensure_ffmpeg(): """确保 ffmpeg 进程在运行,自动重启崩溃的进程""" - global _ffmpeg_proc - if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None: + global _ffmpeg_proc, _ffmpeg_thread + with _ffmpeg_lock: + if _ffmpeg_proc is not None and _ffmpeg_proc.poll() is None: + return + + _stop_ffmpeg_reader.set() + if _ffmpeg_proc and _ffmpeg_proc.poll() is None: + _ffmpeg_proc.terminate() + _stop_ffmpeg_reader.clear() + logger.info(f"启动 ffmpeg 视频流 (Video{ARM_CAMERA_INDEX})") _ffmpeg_proc = subprocess.Popen( [ "ffmpeg", "-f", "v4l2", "-input_format", "mjpeg", - "-re", + "-framerate", "15", + "-video_size", "640x480", "-i", f"/dev/video{ARM_CAMERA_INDEX}", "-vf", "rotate=PI", "-q:v", "8", @@ -55,29 +116,41 @@ def _ensure_ffmpeg(): stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, ) - time.sleep(2.0) # 等待 ffmpeg 初始化完成(设备冷启动可能需要更久) + _ffmpeg_thread = threading.Thread(target=_frame_reader, daemon=True) + _ffmpeg_thread.start() + + +def _get_latest_frame(timeout: float = 3.0): + """返回缓存的最新 JPEG 帧;必要时等待首帧。""" + _ensure_ffmpeg() + deadline = time.time() + timeout + with _frame_cond: + while _latest_frame is None and time.time() < deadline: + _frame_cond.wait(timeout=0.2) + return _latest_frame @arm_video_app.route("/api/camera/preview") def arm_camera_preview(): - """机械臂摄像头 MJPEG 流 (ffmpeg)""" + """机械臂摄像头 MJPEG 流,共用后台 ffmpeg 采集进程。""" _ensure_ffmpeg() def generate(): - global _ffmpeg_proc + last_ts = 0.0 try: while True: - if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None: - _ensure_ffmpeg() - jpeg = _ffmpeg_proc.stdout.read(65536) - if not jpeg: - # ffmpeg 无数据输出,重启它 - logger.warning("ffmpeg stdout 空,重启") - _ffmpeg_proc.terminate() - _ffmpeg_proc = None - _ensure_ffmpeg() + frame = _get_latest_frame(timeout=3.0) + if frame is None: + logger.warning("等待摄像头帧超时,重启 ffmpeg") + _stop_ffmpeg() continue - yield (b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" + jpeg + b"\r\n") + with _frame_cond: + if _latest_frame_ts <= last_ts: + _frame_cond.wait(timeout=1.0) + frame = _latest_frame + last_ts = _latest_frame_ts + if frame: + yield (b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" + frame + b"\r\n") except Exception as e: logger.error(f"视频流异常: {e}") finally: @@ -91,51 +164,31 @@ def arm_camera_status(): """摄像头状态""" global _ffmpeg_proc running = _ffmpeg_proc is not None and _ffmpeg_proc.poll() is None - return jsonify({"opened": running}) + return jsonify({"opened": running, "frame_age": time.time() - _latest_frame_ts if _latest_frame_ts else None}) @arm_video_app.route("/api/camera/restart", methods=["POST"]) def arm_camera_restart(): """重启视频流""" - global _ffmpeg_proc - if _ffmpeg_proc: - _ffmpeg_proc.terminate() - _ffmpeg_proc = None + global _latest_frame, _latest_frame_ts + _stop_ffmpeg() + with _frame_cond: + _latest_frame = None + _latest_frame_ts = 0.0 _ensure_ffmpeg() return jsonify({"ok": True}) @arm_video_app.route("/api/camera/snapshot") def arm_camera_snapshot(): - """机械臂摄像头单帧 JPEG — pkill -9 强杀旧 ffmpeg,再临时抓一帧""" - import subprocess - global _ffmpeg_proc - # 用 pkill -9 强杀所有 ffmpeg 进程,释放 /dev/video0 - subprocess.run(["pkill", "-9", "-f", "ffmpeg"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=3) - time.sleep(0.3) - _ffmpeg_proc = None - proc = subprocess.run( - [ - "ffmpeg", - "-f", "v4l2", - "-input_format", "mjpeg", - "-i", f"/dev/video{ARM_CAMERA_INDEX}", - "-vf", "rotate=PI", - "-vframes", "1", - "-q:v", "8", - "-f", "mjpeg", - "pipe:1" - ], - stdout=subprocess.PIPE, - timeout=5, - stderr=subprocess.DEVNULL - ) - if proc.returncode == 0 and proc.stdout: - r = Response(proc.stdout, mimetype="image/jpeg") + """机械臂摄像头单帧 JPEG,从常驻视频流缓存读取最新帧。""" + frame = _get_latest_frame(timeout=3.0) + if frame: + r = Response(frame, mimetype="image/jpeg") r.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0" r.headers["Pragma"] = "no-cache" r.headers["Expires"] = "0" return r - logger.warning(f"ffmpeg snapshot failed: rc={proc.returncode}") + logger.warning("snapshot failed: no cached frame") return "", 500