-
This commit is contained in:
@@ -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():
|
||||||
"""扫描一次二维码"""
|
"""扫描一次二维码"""
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user