This commit is contained in:
ywb
2026-06-02 14:52:10 +08:00
parent 0b5862b271
commit 525025a5f7
6 changed files with 141 additions and 58 deletions
+32
View File
@@ -1132,6 +1132,38 @@ def api_arm_camera_refresh():
return "", 404 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"]) @app.route("/api/camera/qr_scan", methods=["GET"])
def api_qr_scan(): def api_qr_scan():
"""扫描一次二维码""" """扫描一次二维码"""
+4 -2
View File
@@ -16,7 +16,7 @@ createApp({
currentState: 'idle', currentState: 'idle',
// 摄像头轮询 // 摄像头轮询
agvCameraSrc: '/api/camera/refresh?t=' + Date.now(), 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, agvCameraError: false,
armCameraError: false, armCameraError: false,
reconnectingDevice: null reconnectingDevice: null
@@ -43,7 +43,9 @@ createApp({
methods: { methods: {
refreshCams() { refreshCams() {
this.agvCameraSrc = '/api/camera/refresh?t=' + Date.now() 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() { async refresh() {
await this.refreshStatus() await this.refreshStatus()
+1 -1
View File
@@ -9,7 +9,7 @@ createApp({
tasks: [], tasks: [],
report: null, report: null,
agvPreviewUrl: API + '/api/camera/preview', agvPreviewUrl: API + '/api/camera/preview',
armPreviewUrl: API + '/api/camera/arm_refresh', armPreviewUrl: API + '/api/camera/arm_preview',
polling: null, polling: null,
logs: [], logs: [],
showQrModal: false, showQrModal: false,
+2 -6
View File
@@ -57,7 +57,7 @@ createApp({
qrScanning: false, qrScanning: false,
qrConfigs: [], qrConfigs: [],
qrScanningId: null, qrScanningId: null,
armCameraUrl: API + '/api/camera/arm_refresh', armCameraUrl: API + '/api/camera/arm_preview',
newQrName: '', newQrName: '',
armInitialPose: [0, 0, 0, 0, 0, 0], armInitialPose: [0, 0, 0, 0, 0, 0],
} }
@@ -67,11 +67,7 @@ createApp({
this.refreshAngles() this.refreshAngles()
this.loadQrConfigs() this.loadQrConfigs()
this.nav2Timer = setInterval(this.refreshNavStatus, 3000) this.nav2Timer = setInterval(this.refreshNavStatus, 3000)
// 机械臂摄像头自动刷新(每2秒) this.armCameraUrl = API + '/api/camera/arm_preview?t=' + Date.now()
this.armCameraUrl = API + '/api/camera/arm_refresh?t=' + Date.now()
this.armCameraTimer = setInterval(() => {
this.armCameraUrl = API + '/api/camera/arm_refresh?t=' + Date.now()
}, 2000)
}, },
computed: { computed: {
hasQr() { hasQr() {
+1 -1
View File
@@ -99,7 +99,7 @@
<div v-else-if="!cameraOpened" class="camera-placeholder">未打开(先点击连接设备)</div> <div v-else-if="!cameraOpened" class="camera-placeholder">未打开(先点击连接设备)</div>
</div> </div>
<div class="camera-box"> <div class="camera-box">
<div class="camera-label">机械臂摄像头 <button class="btn btn-small" @click="armCameraSrc='/api/camera/arm_refresh?t='+Date.now(); armCameraError=false">刷新</button></div> <div class="camera-label">机械臂摄像头 <button class="btn btn-small" @click="armCameraSrc='/api/camera/arm_preview?t='+Date.now(); armCameraError=false">刷新</button></div>
<img v-if="armCameraOpened && !armCameraError" :src="armCameraSrc" class="camera-img" @error="armCameraError=true"> <img v-if="armCameraOpened && !armCameraError" :src="armCameraSrc" class="camera-img" @error="armCameraError=true">
<div v-if="armCameraOpened && armCameraError" class="camera-placeholder">机械臂摄像头异常</div> <div v-if="armCameraOpened && armCameraError" class="camera-placeholder">机械臂摄像头异常</div>
<div v-else-if="!armCameraOpened" class="camera-placeholder">未连接</div> <div v-else-if="!armCameraOpened" class="camera-placeholder">未连接</div>
+100 -47
View File
@@ -33,19 +33,80 @@ arm_video_app = Flask(__name__)
ARM_CAMERA_INDEX = 0 # 机械臂端摄像头设备号 ARM_CAMERA_INDEX = 0 # 机械臂端摄像头设备号
_ffmpeg_proc = None _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(): def _ensure_ffmpeg():
"""确保 ffmpeg 进程在运行,自动重启崩溃的进程""" """确保 ffmpeg 进程在运行,自动重启崩溃的进程"""
global _ffmpeg_proc global _ffmpeg_proc, _ffmpeg_thread
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None: 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})") logger.info(f"启动 ffmpeg 视频流 (Video{ARM_CAMERA_INDEX})")
_ffmpeg_proc = subprocess.Popen( _ffmpeg_proc = subprocess.Popen(
[ [
"ffmpeg", "ffmpeg",
"-f", "v4l2", "-f", "v4l2",
"-input_format", "mjpeg", "-input_format", "mjpeg",
"-re", "-framerate", "15",
"-video_size", "640x480",
"-i", f"/dev/video{ARM_CAMERA_INDEX}", "-i", f"/dev/video{ARM_CAMERA_INDEX}",
"-vf", "rotate=PI", "-vf", "rotate=PI",
"-q:v", "8", "-q:v", "8",
@@ -55,29 +116,41 @@ def _ensure_ffmpeg():
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, 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") @arm_video_app.route("/api/camera/preview")
def arm_camera_preview(): def arm_camera_preview():
"""机械臂摄像头 MJPEG 流 (ffmpeg)""" """机械臂摄像头 MJPEG 流,共用后台 ffmpeg 采集进程。"""
_ensure_ffmpeg() _ensure_ffmpeg()
def generate(): def generate():
global _ffmpeg_proc last_ts = 0.0
try: try:
while True: while True:
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None: frame = _get_latest_frame(timeout=3.0)
_ensure_ffmpeg() if frame is None:
jpeg = _ffmpeg_proc.stdout.read(65536) logger.warning("等待摄像头帧超时,重启 ffmpeg")
if not jpeg: _stop_ffmpeg()
# ffmpeg 无数据输出,重启它
logger.warning("ffmpeg stdout 空,重启")
_ffmpeg_proc.terminate()
_ffmpeg_proc = None
_ensure_ffmpeg()
continue 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: except Exception as e:
logger.error(f"视频流异常: {e}") logger.error(f"视频流异常: {e}")
finally: finally:
@@ -91,51 +164,31 @@ def arm_camera_status():
"""摄像头状态""" """摄像头状态"""
global _ffmpeg_proc global _ffmpeg_proc
running = _ffmpeg_proc is not None and _ffmpeg_proc.poll() is None 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"]) @arm_video_app.route("/api/camera/restart", methods=["POST"])
def arm_camera_restart(): def arm_camera_restart():
"""重启视频流""" """重启视频流"""
global _ffmpeg_proc global _latest_frame, _latest_frame_ts
if _ffmpeg_proc: _stop_ffmpeg()
_ffmpeg_proc.terminate() with _frame_cond:
_ffmpeg_proc = None _latest_frame = None
_latest_frame_ts = 0.0
_ensure_ffmpeg() _ensure_ffmpeg()
return jsonify({"ok": True}) return jsonify({"ok": True})
@arm_video_app.route("/api/camera/snapshot") @arm_video_app.route("/api/camera/snapshot")
def arm_camera_snapshot(): def arm_camera_snapshot():
"""机械臂摄像头单帧 JPEG — pkill -9 强杀旧 ffmpeg,再临时抓一帧""" """机械臂摄像头单帧 JPEG,从常驻视频流缓存读取最新帧。"""
import subprocess frame = _get_latest_frame(timeout=3.0)
global _ffmpeg_proc if frame:
# 用 pkill -9 强杀所有 ffmpeg 进程,释放 /dev/video0 r = Response(frame, mimetype="image/jpeg")
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")
r.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0" r.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0"
r.headers["Pragma"] = "no-cache" r.headers["Pragma"] = "no-cache"
r.headers["Expires"] = "0" r.headers["Expires"] = "0"
return r return r
logger.warning(f"ffmpeg snapshot failed: rc={proc.returncode}") logger.warning("snapshot failed: no cached frame")
return "", 500 return "", 500