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