diff --git a/agv_app/app.py b/agv_app/app.py index 58a8484..a663f21 100644 --- a/agv_app/app.py +++ b/agv_app/app.py @@ -53,6 +53,7 @@ class GlobalState: self.machines_config = [] # 机器配置(每台机器的正面/背面点位+姿态) self.qr_config = [] # 二维码配置(独立点位列表) self.navigator = None # Nav2Navigator 实例 + self.current_customs = None # 当前设定的报关单信息 self.error_msg = "" # 错误弹窗消息(waiting_error 状态时) self.lock = threading.Lock() @@ -188,9 +189,6 @@ def api_status(): arm_connected = ok except: arm_connected = False - # 连接已断开,清理 socket - if gs.arm_client: - gs.arm_client._sock = None # 实际验证 AGV 连接 agv_connected = False @@ -1088,33 +1086,25 @@ def api_camera_preview(): if not gs.qr_scanner or not gs.qr_scanner._cap: return "camera not opened", 400 + import time as _time def gen(): + _last_ok = _time.time() while True: frame = gs.qr_scanner.read_frame() if frame is None: - break - # 编码为 JPEG + if _time.time() - _last_ok > 5: + break + _time.sleep(0.05) + continue import cv2 ret, buf = cv2.imencode(".jpg", frame) if ret: + _last_ok = _time.time() yield (b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" + buf.tobytes() + b"\r\n") return Response(gen(), mimetype="multipart/x-mixed-replace; boundary=frame") -@app.route("/api/camera/refresh") -def api_camera_refresh(): - """AGV 摄像头单帧 JPEG(polling 模式)""" - if not gs.qr_scanner or not gs.qr_scanner._cap: - return "camera not opened", 400 - import cv2 - frame = gs.qr_scanner.read_frame() - if frame is None: - return "", 400 - ret, buf = cv2.imencode(".jpg", frame) - if ret: - return Response(buf.tobytes(), mimetype="image/jpeg") - return "encode failed", 500 @app.route("/api/camera/capture") def api_camera_capture(): @@ -1132,28 +1122,101 @@ def api_camera_capture(): cv2.imwrite(photo_path, frame) return jsonify({"ok": True, "path": photo_path}) + +def _is_corrupted_jpeg(jpeg_bytes: bytes) -> float: + """检测 JPEG 是否为花屏帧。返回 0~1 的置信度 (1=确定花屏)。""" + try: + import cv2 + import numpy as np + arr = np.frombuffer(jpeg_bytes, dtype=np.uint8) + img = cv2.imdecode(arr, cv2.IMREAD_COLOR) + if img is None: + return 1.0 + h, w = img.shape[:2] + if h < 10 or w < 10: + return 1.0 + + hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) + + # 绿色条纹检测:HSV 中绿色 H=40~80 + green_mask = cv2.inRange(hsv, (40, 30, 40), (80, 255, 255)) + green_ratio = cv2.countNonZero(green_mask) / (h * w) + + # 紫色/品红条纹 + purple_mask = cv2.inRange(hsv, (130, 30, 40), (170, 255, 255)) + purple_ratio = cv2.countNonZero(purple_mask) / (h * w) + + if green_ratio > 0.80 or purple_ratio > 0.80: + return 0.95 + + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + row_stds = np.std(gray, axis=1) + col_stds = np.std(gray, axis=0) + + row_std_of_stds = float(np.std(row_stds)) + col_std_of_stds = float(np.std(col_stds)) + + if row_std_of_stds > 70 and col_std_of_stds > 30: + return 0.85 + + unique_colors = len(np.unique(img.reshape(-1, 3), axis=0)) + if unique_colors < 200: + return 0.75 + + return 0.0 + except ImportError: + return 0.0 + except Exception: + return 0.0 + + @app.route("/api/camera/arm_refresh") def api_arm_camera_refresh(): - """从机械臂拉一张 JPEG(请求 snapshot 端点,简单 HTTP GET)""" + """从机械臂拉一张 JPEG,翻转后返回(机械臂摄像头物理倒装)。""" import requests - try: - r = requests.get(ARM_CAMERA_CONFIG.get("snapshot_url", ARM_CAMERA_CONFIG["url"]), timeout=8) - if r.status_code == 200 and r.content: - resp = Response(r.content, mimetype="image/jpeg") - resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0" - resp.headers["Pragma"] = "no-cache" - resp.headers["Expires"] = "0" - return resp - return "", 404 - except Exception as ex: - logger.info(f"arm_refresh 不可用: {ex}") - return "", 404 + import cv2 + import numpy as np + url = ARM_CAMERA_CONFIG.get("snapshot_url", ARM_CAMERA_CONFIG["url"]) + max_retries = 3 + for attempt in range(1, max_retries + 1): + try: + r = requests.get(url, timeout=8) + if r.status_code == 200 and r.content: + corruption = _is_corrupted_jpeg(r.content) + if corruption > 0.5: + logger.warning(f"arm_refresh 第{attempt}次尝试检测到花屏 (置信度{corruption:.2f}),重试...") + time.sleep(0.3) + continue + # 解码 → 上下翻转 → 编码 + img = cv2.imdecode(np.frombuffer(r.content, dtype=np.uint8), cv2.IMREAD_COLOR) + if img is not None: + img = cv2.flip(img, 0) # 0 = 上下翻转 + ret, jpg = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 85]) + if ret: + resp = Response(jpg.tobytes(), mimetype="image/jpeg") + resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0" + resp.headers["Pragma"] = "no-cache" + resp.headers["Expires"] = "0" + return resp + resp = Response(r.content, mimetype="image/jpeg") + resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0" + resp.headers["Pragma"] = "no-cache" + resp.headers["Expires"] = "0" + return resp + return "", 404 + except Exception as ex: + logger.info(f"arm_refresh 尝试{attempt}/{max_retries} 失败: {ex}") + time.sleep(0.5) + logger.warning(f"arm_refresh 在 {max_retries} 次尝试后仍失败") + return "", 404 @app.route("/api/camera/arm_preview") def api_arm_camera_preview(): - """代理机械臂 MJPEG 视频流,供页面连续预览。""" + """代理机械臂 MJPEG 视频流,逐帧上下翻转后返回。""" import requests + import cv2 + import numpy as np try: upstream = requests.get( ARM_CAMERA_CONFIG["url"], @@ -1165,10 +1228,31 @@ def api_arm_camera_preview(): return "", 404 def generate(): + buf = b"" try: for chunk in upstream.iter_content(chunk_size=8192): - if chunk: - yield chunk + if not chunk: + continue + buf += chunk + # 查找 MJPEG 帧边界 + while True: + start = buf.find(b"\xff\xd8") + end = buf.find(b"\xff\xd9") + if start != -1 and end != -1 and end > start: + jpg_data = buf[start:end+2] + buf = buf[end+2:] + # 解码 → 上下翻转 → 编码 + img = cv2.imdecode(np.frombuffer(jpg_data, dtype=np.uint8), cv2.IMREAD_COLOR) + if img is not None: + img = cv2.flip(img, 0) + ret, flipped_jpg = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 85]) + if ret: + yield b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" + flipped_jpg.tobytes() + b"\r\n" + else: + # 解码失败,直透原始帧(不应发生) + yield b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" + jpg_data + b"\r\n" + else: + break finally: upstream.close() @@ -1670,12 +1754,105 @@ def api_qr_config_scan(qr_id): return jsonify({"ok": False, "error": f"扫描失败: {str(ex)}"}), 400 + +# ========== 报关单接口(代理外部 API) ========== + +_ZHIJIAN_BASE = "https://ts.zhijian168.com/prod-api/zhijian/integration" +_ZHIJIAN_AUTH = "Bearer eyJhbGciOiJIUzUxMiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VyX2tleSI6ImZhNTNkZTZiLWE3NjYtNDZmNC05MDUyLTQ2MjUzZTAyNjdmNSIsInVzZXJuYW1lIjoiYWRtaW4ifQ.lC4vKThZo4aAOLsekm2kPgaEJRqRx-YDQWKfHFqxdPNESCKy57l3eIqaKTj2ZjAMaoYAwYlMrv5M1zAOJsO_PA" + + +@app.route("/api/customs/list") +def api_customs_list(): + """获取报关单列表(代理外部 API)""" + import requests + page = request.args.get("pageNum", 1) + size = request.args.get("pageSize", 50) + url = f"{_ZHIJIAN_BASE}/customsListPage?pageNum={page}&pageSize={size}" + try: + r = requests.get(url, headers={"Authorization": _ZHIJIAN_AUTH}, timeout=15) + if r.status_code != 200: + return jsonify({"ok": False, "error": f"报关单API返回 {r.status_code}"}), 502 + data = r.json() + return jsonify({"ok": True, "data": data}) + except Exception as e: + logger.error(f"获取报关单列表失败: {e}") + return jsonify({"ok": False, "error": str(e)}), 502 + + +@app.route("/api/customs/machines") +def api_customs_machines(): + """根据报关单 ID 获取机器列表(代理外部 API) + 查询参数: customsId=xxx + """ + import requests + customs_id = request.args.get("customsId", "") + if not customs_id: + return jsonify({"ok": False, "error": "缺少 customsId 参数"}), 400 + url = f"{_ZHIJIAN_BASE}/customsMachines?customsId={customs_id}" + try: + r = requests.get(url, headers={"Authorization": _ZHIJIAN_AUTH}, timeout=15) + if r.status_code != 200: + return jsonify({"ok": False, "error": f"机器列表API返回 {r.status_code}"}), 502 + data = r.json() + return jsonify({"ok": True, "data": data}) + except Exception as e: + logger.error(f"获取报关单机器列表失败: {e}") + return jsonify({"ok": False, "error": str(e)}), 502 + + +@app.route("/api/customs/selected", methods=["POST"]) +def api_customs_selected(): + """任务开始前设定当前报关单(存储当前任务对应的报关单信息)""" + data = request.json or {} + gs.current_customs = { + "id": data.get("id", ""), + "name": data.get("name", ""), + "machine_ids": data.get("machine_ids", []), + } + logger.info(f"设定报关单: {gs.current_customs['name']} ({len(gs.current_customs['machine_ids'])} 台机器)") + return jsonify({"ok": True, "customs": gs.current_customs}) + + +@app.route("/api/customs/selected", methods=["GET"]) +def api_customs_selected_get(): + """获取当前设定的报关单信息""" + c = gs.current_customs or {"id": "", "name": "未选择", "machine_ids": []} + return jsonify({"ok": True, "customs": c}) + + # ========== 静态资源 ========== @app.route("/photos/") def photos(name): return send_from_directory(os.path.join(DATA_DIR, "photos"), name) +@app.route("/api/camera/refresh") +def api_camera_refresh(): + """AGV 摄像头单帧 JPEG(polling 模式)""" + if not gs.qr_scanner: + return jsonify({"error": "scanner not initialized"}), 400 + if not gs.qr_scanner._cap or not gs.qr_scanner._cap.isOpened(): + return jsonify({"error": "camera not opened"}), 400 + import cv2 + frame = gs.qr_scanner.read_frame() + if frame is None: + return jsonify({"error": "read frame failed"}), 400 + # 检查是否为全黑/无内容的帧(Orbbec 深度/IR 帧可能无内容) + if frame.mean() < 5: + return jsonify({"error": "camera sensor not ready"}), 400 + ret, buf = cv2.imencode(".jpg", frame) + if ret: + return Response(buf.tobytes(), mimetype="image/jpeg") + return jsonify({"error": "encode failed"}), 500 + + +@app.route("/api/camera/capabilities") +def api_camera_capabilities(): + """返回摄像头能力信息,前端据此决定如何展示""" + return jsonify({ + "has_agv_camera": False, # Orbbec 深度相机不提供可用的彩色画面 + "has_arm_camera": True, + }) # ========== 启动 ========== if __name__ == "__main__": logger.info("=" * 50) @@ -1702,3 +1879,4 @@ if __name__ == "__main__": debug=SERVER_CONFIG["debug"], threaded=True ) + diff --git a/agv_app/config.py b/agv_app/config.py index 4125625..20d6479 100644 --- a/agv_app/config.py +++ b/agv_app/config.py @@ -7,8 +7,8 @@ import os BASE_DIR = os.path.dirname(os.path.abspath(__file__)) # ========== 网络配置(集中管理所有 IP 地址 — 修改此处即可全局生效)========== -AGV_HOST = "192.168.60.177" -ARM_HOST = "192.168.60.88" +AGV_HOST = "192.168.60.80" +ARM_HOST = "192.168.60.120" # ========== AGV 参数 ========== AGV_CONFIG = { @@ -35,7 +35,7 @@ MAP_CONFIG = { # ========== 摄像头 ========== CAMERA_CONFIG = { - "device_index": 4, # AGV 摄像头 video4(标准彩色摄像头,V4L2后端) + "device_index": 3, # AGV 摄像头 video3(Orbbec Gemini 彩色流,V4L2后端) "backend": "v4l2", # 使用 V4L2 后端获取标准彩色格式(640x480) "qr_detect_interval": 0.5, "capture_delay": 0.5, diff --git a/agv_app/static/css/style.css b/agv_app/static/css/style.css index c695410..fe2e0e0 100644 --- a/agv_app/static/css/style.css +++ b/agv_app/static/css/style.css @@ -465,6 +465,10 @@ a:hover { text-decoration: underline; } aspect-ratio: 4/3; object-fit: cover; } +.camera-img.arm { + transform: rotate(180deg); +} + .camera-placeholder { width: 100%; aspect-ratio: 4/3; @@ -1076,3 +1080,114 @@ a:hover { text-decoration: underline; } .machine-cell.mstatus-pending { background: #141e28; border-color: #2a3a4a; } .machine-cell.mstatus-active { background: #1a2535; border-color: #4fc3f7; } .machine-cell.mstatus-completed { background: #152522; border-color: #2e7d32; } + +/* ===== 报关单选择 ===== */ +.customs-panel { + display: flex; + flex-direction: column; + gap: 4px; +} +.customs-row { + display: flex; + align-items: center; + gap: 8px; +} +.customs-select { + flex: 1; + max-width: 400px; + padding: 10px 12px; + background: #1a2535; + border: 1px solid #2a3a4a; + border-radius: 8px; + color: #e0e0e0; + font-size: 14px; + outline: none; +} +.customs-select:focus { + border-color: #4fc3f7; +} +.customs-select:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.customs-select option { + background: #1a2535; + color: #e0e0e0; +} +.customs-select:disabled option { + opacity: 1; +} +.customs-info { + font-size: 13px; + color: #8899aa; +} +.customs-badge { + font-size: 13px; +} +.customs-actions { + display: flex; + align-items: center; + gap: 4px; +} + +/* ===== 数据表格 ===== */ +.table-wrapper { + overflow-x: auto; + margin-top: 8px; +} +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} +.data-table th { + text-align: left; + padding: 10px 12px; + background: #0f1923; + color: #8899aa; + font-weight: 500; + border-bottom: 1px solid #2a3a4a; + white-space: nowrap; +} +.data-table td { + padding: 10px 12px; + border-bottom: 1px solid #1a2a3a; + color: #ccc; +} +.data-table tbody tr:hover { + background: #1a2a3a; +} +.clickable-row { + cursor: pointer; + transition: background 0.15s; +} +.clickable-row:hover { + background: #1a2535 !important; +} +.row-selected { + background: #142a3a !important; + border-left: 3px solid #4fc3f7; +} + +/* ===== Badge 状态标签 ===== */ +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 500; +} +.badge-unknown { background: #2a3441; color: #8899aa; } +.badge-normal { background: #1a3a2a; color: #4caf50; } +.badge-active { background: #1a3050; color: #4fc3f7; } +.badge-finished { background: #1a3a2a; color: #4caf50; } +.badge-waiting { background: #3a3020; color: #ffc107; } +.badge-error { background: #3a1a1a; color: #f44336; } + +/* ===== 分页控件 ===== */ +.pagination { + display: flex; + align-items: center; + justify-content: center; + padding: 16px 0 8px; +} diff --git a/agv_app/static/js/app.js b/agv_app/static/js/app.js index 4ae96ba..f0c91a8 100644 --- a/agv_app/static/js/app.js +++ b/agv_app/static/js/app.js @@ -18,6 +18,7 @@ createApp({ agvCameraSrc: '/api/camera/refresh?t=' + Date.now(), armCameraSrc: '/api/camera/arm_preview?t=' + Date.now(), agvCameraError: false, + hasAgvCamera: false, // AGV 车体是否有可用相机 armCameraError: false, reconnectingDevice: null } @@ -36,6 +37,7 @@ createApp({ }, mounted() { this.refresh() + this.refreshCameraCapabilities() setInterval(this.refreshStatus, 3000) this.refreshCams() setInterval(() => this.refreshCams(), 2000) @@ -47,6 +49,17 @@ createApp({ this.armCameraSrc = '/api/camera/arm_preview?t=' + Date.now() } }, + async refreshCameraCapabilities() { + try { + const res = await fetch(API + '/api/camera/capabilities') + const data = await res.json() + this.hasAgvCamera = data.has_agv_camera + } catch (e) { this.hasAgvCamera = false } + }, + refreshAgvCamera() { + this.agvCameraSrc = '/api/camera/refresh?t=' + Date.now() + this.agvCameraError = false + }, async refresh() { await this.refreshStatus() await this.loadPoints() @@ -58,6 +71,10 @@ createApp({ this.agvConnected = data.agv_connected this.armConnected = data.arm_connected this.cameraOpened = data.camera_opened + // 尝试从后端获取摄像头能力,若无字段则保持默认 false + if (data.has_agv_camera !== undefined) { + this.hasAgvCamera = data.has_agv_camera + } this.armCameraOpened = data.arm_camera_opened this.mapLoaded = data.map_loaded this.currentState = data.state || 'idle' diff --git a/agv_app/static/js/running.js b/agv_app/static/js/running.js index b97d1c1..598c519 100644 --- a/agv_app/static/js/running.js +++ b/agv_app/static/js/running.js @@ -9,6 +9,7 @@ createApp({ tasks: [], report: null, armCameraOpened: false, + hasAgvCamera: false, agvPreviewUrl: API + '/api/camera/preview', armPreviewUrl: '', polling: null, @@ -70,6 +71,13 @@ createApp({ } } catch (e) {} }, + async checkAgvCameraCapabilities() { + try { + const res = await fetch(API + '/api/camera/capabilities') + const data = await res.json() + this.hasAgvCamera = data.has_agv_camera + } catch (e) { this.hasAgvCamera = false } + }, poll() { this.refresh() this.pollLogs() diff --git a/agv_app/static/js/setting.js b/agv_app/static/js/setting.js index 30ea170..625b394 100644 --- a/agv_app/static/js/setting.js +++ b/agv_app/static/js/setting.js @@ -66,6 +66,15 @@ createApp({ armSnapshotLoading: false, newQrName: '', armInitialPose: [0, 0, 0, 0, 0, 0], + // 报关单 + customsList: [], + customsLoading: false, + customsPage: 1, + customsPageSize: 15, + customsTotal: 0, + selectedCustomsId: '', + selectedCustomsName: '', + customsMachines: [], } }, mounted() { @@ -76,6 +85,13 @@ createApp({ this.armCameraUrl = API + '/api/camera/arm_preview?t=' + Date.now() }, computed: { + customsTotalPages() { + return Math.max(1, Math.ceil(this.customsTotal / this.customsPageSize)) + }, + customsPageData() { + // 前端显示 pagination data — 但我们在 API 后端做分页,所以这里只是引用 + return this.customsList + }, hasQr() { return !!(this.selectedMachine && this.selectedMachine.qr) }, @@ -1187,5 +1203,64 @@ createApp({ alert('❌ 复位请求失败: ' + e.message) } }, - } + }, + // ===== 报关单方法 ===== + async loadCustomsList() { + this.customsLoading = true + try { + const url = API + '/api/customs/list?pageNum=' + this.customsPage + '&pageSize=' + this.customsPageSize + const res = await fetch(url) + const d = await res.json() + if (d.ok && d.data) { + const raw = d.data + let list = [] + let total = 0 + if (raw.rows) { list = raw.rows; total = raw.total || list.length } + else if (raw.records) { list = raw.records; total = raw.total || list.length } + else if (Array.isArray(raw)) { list = raw; total = list.length } + else if (raw.data && raw.data.rows) { list = raw.data.rows; total = raw.data.total || list.length } + else if (raw.data && raw.data.records) { list = raw.data.records; total = raw.data.total || list.length } + else if (raw.data && Array.isArray(raw.data)) { list = raw.data; total = list.length } + this.customsList = list + this.customsTotal = total || list.length + } else { + this.customsList = [] + this.customsTotal = 0 + } + } catch (e) { + console.error('加载报关单列表失败', e) + this.customsList = [] + this.customsTotal = 0 + } finally { + this.customsLoading = false + } + }, + async selectCustomsRow(item) { + const id = item.id || item.customsId || item.customs_id || '' + if (!id) return + this.selectedCustomsId = id + this.selectedCustomsName = item.customsNo || item.customs_no || item.name || item.customsName || item.customs_name || id + this.customsMachines = [] + try { + const res = await fetch(API + '/api/customs/machines?customsId=' + encodeURIComponent(id)) + const d = await res.json() + if (d.ok && d.data) { + const raw = d.data + let machines = [] + if (raw.rows) { machines = raw.rows } + else if (raw.records) { machines = raw.records } + else if (raw.data && Array.isArray(raw.data)) { machines = raw.data } + else if (Array.isArray(raw)) { machines = raw } + else if (Array.isArray(raw.data)) { machines = raw.data } + this.customsMachines = machines + } else { + this.customsMachines = [] + } + } catch (e) { + console.error('加载机器列表失败', e) + this.customsMachines = [] + } + }, +} + }).mount('#app') diff --git a/agv_app/templates/index.html b/agv_app/templates/index.html index 99a2a88..b6e0045 100644 --- a/agv_app/templates/index.html +++ b/agv_app/templates/index.html @@ -93,9 +93,10 @@

📷 摄像头预览

-
AGV 摄像头
- -
AGV 摄像头异常
+
AGV 摄像头
+ +
AGV 摄像头异常
+
AGV 无可用彩色摄像头
未打开(先点击连接设备)
diff --git a/agv_app/templates/running.html b/agv_app/templates/running.html index d285356..28b9138 100644 --- a/agv_app/templates/running.html +++ b/agv_app/templates/running.html @@ -173,8 +173,9 @@

📷 摄像头预览

-
🎥 AGV 摄像头
- +
🎥 AGV 摄像头 (不可用)
+ +
AGV 无可用彩色摄像头
🦾 机械臂摄像头
diff --git a/agv_app/templates/setting.html b/agv_app/templates/setting.html index af079b2..ae95fdc 100644 --- a/agv_app/templates/setting.html +++ b/agv_app/templates/setting.html @@ -4,7 +4,7 @@ 设置 - AGV 拍摄系统 - +
@@ -25,6 +25,7 @@ +
@@ -435,7 +436,7 @@
- +
@@ -499,7 +500,7 @@
- +

关节角度控制

@@ -587,6 +588,97 @@
+ +
+
+
+

📋 报关单列表

+ +
+

选择报关单查看其中的机器列表,点击报关单 ID 展开机器信息

+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
序号报关单号名称状态机器数操作
{% raw %}{{ (customsPage - 1) * customsPageSize + idx + 1 }}{% endraw %}{% raw %}{{ item.customsNo || item.customs_no || item.id || '-' }}{% endraw %}{% raw %}{{ item.name || item.customsName || item.customs_name || '-' }}{% endraw %}{% raw %}{{ item.status || '未知' }}{% endraw %}{% raw %}{{ item.machineCount || item.machine_count || item.machineNum || item.machine_num || '?' }}{% endraw %} + +
暂无报关单数据
+
+ + + +
+ + +
+
+

📦 机器列表

+ 报关单: {% raw %}{{ selectedCustomsName }}{% endraw %} +
+

{% raw %}{{ customsMachines.length }}{% endraw %} 台机器

+
+ + + + + + + + + + + + + + + + + + + + + +
序号机器编号机器名称型号二维码值状态
{% raw %}{{ mi + 1 }}{% endraw %}{% raw %}{{ m.serialNumber || m.serial_number || m.serialNo || m.serial_no || m.machineNo || m.machine_no || m.id || '-' }}{% endraw %}{% raw %}{{ m.name || m.machineName || m.machine_name || m.model || '-' }}{% endraw %}{% raw %}{{ m.modelName || m.model_name || m.model || '-' }}{% endraw %}{% raw %}{{ m.qrValue || m.qr_value || m.qr || m.qrCode || m.qr_code || m.serialNumber || m.serial_number || '-' }}{% endraw %}{% raw %}{{ m.status || '正常' }}{% endraw %}
+
+
+
+
@@ -621,6 +713,6 @@
- + diff --git a/agv_app/utils/arm_client.py b/agv_app/utils/arm_client.py index e58ed5f..9dd9d9a 100644 --- a/agv_app/utils/arm_client.py +++ b/agv_app/utils/arm_client.py @@ -61,14 +61,18 @@ class ArmClient: def get_angles(self) -> Tuple[bool, List[float]]: """获取所有关节角度""" ok, resp = self.send_command("get_angles()") - if ok and resp.startswith("get_angles:["): + if ok: try: - # get_angles:[0.174, 0.520, ...] → list - nums = resp.split("[")[1].split("]")[0] - angles = [float(x) for x in nums.split(",")] - return True, angles + # 兼容 "get_angles:[-260.2,...]" 和 "[-260.2,...]" 两种格式 + text = resp.split(":", 1)[-1] if ":" in resp else resp + text = text.strip() + if text.startswith("[") and text.endswith("]"): + nums = text[1:-1].split(",") + angles = [float(x) for x in nums] + if len(angles) == 6: + return True, angles except: - return False, [] + pass return False, [] def set_angles(self, angles: List[float], speed: int = 500) -> bool: @@ -94,13 +98,17 @@ class ArmClient: def get_coords(self) -> Tuple[bool, List[float]]: """获取当前坐标和姿态 [x, y, z, rx, ry, rz]""" ok, resp = self.send_command("get_coords()") - if ok and "get_coords:" in resp: + if ok: try: - nums = resp.split("[")[1].split("]")[0] - coords = [float(x) for x in nums.split(",")] - return True, coords + text = resp.split(":", 1)[-1] if ":" in resp else resp + text = text.strip() + if text.startswith("[") and text.endswith("]"): + nums = text[1:-1].split(",") + coords = [float(x) for x in nums] + if len(coords) == 6: + return True, coords except: - return False, [] + pass return False, [] def set_coords(self, coords: List[float], speed: int = 500) -> bool: @@ -132,19 +140,20 @@ class ArmClient: def state_check(self) -> bool: """检查机械臂状态是否正常""" ok, resp = self.send_command("state_check()") - return ok and resp == "state_check:1" + # 兼容 "state_check:1" 和 "1" 两种格式 + return ok and resp.strip().lstrip("state_check:") == "1" def check_running(self) -> bool: """检查机械臂是否在运行""" ok, resp = self.send_command("check_running()") - return ok and resp == "check_running:1" + return ok and resp.strip().lstrip("check_running:") == "1" def wait_done(self, timeout: float = 30) -> bool: """等待上一条命令执行完成""" start = time.time() while time.time() - start < timeout: ok, resp = self.send_command("check_running()") - if ok and resp == "check_running:0": + if ok and resp.strip().lstrip("check_running:") == "0": return True time.sleep(0.5) return False diff --git a/agv_app/utils/config.py b/agv_app/utils/config.py index 4125625..b8adbc8 100644 --- a/agv_app/utils/config.py +++ b/agv_app/utils/config.py @@ -7,8 +7,8 @@ import os BASE_DIR = os.path.dirname(os.path.abspath(__file__)) # ========== 网络配置(集中管理所有 IP 地址 — 修改此处即可全局生效)========== -AGV_HOST = "192.168.60.177" -ARM_HOST = "192.168.60.88" +AGV_HOST = "192.168.60.80" +ARM_HOST = "192.168.60.120" # ========== AGV 参数 ========== AGV_CONFIG = { diff --git a/agv_app/utils/qr_scanner.py b/agv_app/utils/qr_scanner.py index 402f8d5..d0c779c 100644 --- a/agv_app/utils/qr_scanner.py +++ b/agv_app/utils/qr_scanner.py @@ -29,19 +29,22 @@ class QRScanner: def open(self) -> bool: """打开摄像头""" try: - # 强制 V4L2 后端,获取标准彩色格式(与 test/server.py 一致) + # 强制 V4L2 后端 self._cap = cv2.VideoCapture(self.device_index, cv2.CAP_V4L2) - if self._cap.isOpened(): - logger.info(f"摄像头 {self.device_index} 已打开 (V4L2)") - return True - else: - # fallback: 不指定后端 + if not self._cap.isOpened(): self._cap = cv2.VideoCapture(self.device_index) - if self._cap.isOpened(): - logger.info(f"摄像头 {self.device_index} 已打开 (默认后端)") - return True + + if not self._cap.isOpened(): 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}") + return True except Exception as e: logger.error(f"摄像头打开失败: {e}") return False @@ -51,14 +54,61 @@ class QRScanner: self._cap.release() self._cap = None + def _fix_frame(self, frame: np.ndarray) -> Optional[np.ndarray]: + """修复绿屏/格式错误帧,返回修复后的 BGR 帧或 None""" + if frame is None: + return None + + h, w = frame.shape[:2] + if h < 10 or w < 10: + return None + + ndim = len(frame.shape) + + # 情况 1: 2 通道原始 YUYV → 手动转换 BGR + if ndim == 2: + frame = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_YUYV) + logger.debug("YUYV 2ch → BGR 转换") + return frame + + # 情况 2: 3 通道但实际帧数据显示为 YUYV(绿屏特征:G 通道全满,B/R 近空) + if ndim == 3: + g_mean = frame[:, :, 1].mean() + if g_mean > 220 and frame[:, :, 0].mean() < 30 and frame[:, :, 2].mean() < 30: + # 典型的"Lime"绿屏 — 当做 YUYV 原始数据解码 + logger.debug(f"检测到绿屏 (G={g_mean:.0f}, B={frame[:,:,0].mean():.0f}, R={frame[:,:,2].mean():.0f}),尝试修复") + try: + # 把内存当做 YUYV 数据重新解析 + raw_bytes = frame.tobytes() + # 3ch w*h 的数据量 = w*h*3 字节 + # YUYV 每像素 2 字节,所以一幅 YUYV 图像的总字节 = w*h*2 + # 我们只需要取前 w*h*2 字节作为 YUYV 数据 + yuyv_len = w * h * 2 + if len(raw_bytes) >= yuyv_len: + yuyv_img = np.frombuffer(raw_bytes[:yuyv_len], dtype=np.uint8).reshape(h, w * 2, 1) + frame = cv2.cvtColor(yuyv_img, cv2.COLOR_YUV2BGR_YUYV) + logger.debug("绿屏修复完成") + return frame + except Exception as e: + logger.warning(f"绿屏修复失败: {e}") + return None + + # 情况 3: 全黑帧 + if frame.mean() < 5: + logger.warning("全黑帧,丢弃") + return None + + # 正常 BGR 帧 + return frame + def read_frame(self) -> Optional[np.ndarray]: """读取一帧""" if not self._cap or not self._cap.isOpened(): return None ret, frame = self._cap.read() - if not ret: + if not ret or frame is None: return None - return frame + return self._fix_frame(frame) def detect_qr(self, frame: np.ndarray) -> Optional[str]: """从图像帧中检测二维码""" @@ -96,4 +146,4 @@ class QRScanner: return self def __exit__(self, *args): - self.close() \ No newline at end of file + self.close() diff --git a/arm_server/arm_server.py b/arm_server/arm_server.py index fd53767..255503e 100644 --- a/arm_server/arm_server.py +++ b/arm_server/arm_server.py @@ -105,11 +105,11 @@ def _ensure_ffmpeg(): "ffmpeg", "-f", "v4l2", "-input_format", "mjpeg", - "-framerate", "15", - "-video_size", "640x480", + "-framerate", "12", + "-video_size", "1280x720", "-i", f"/dev/video{ARM_CAMERA_INDEX}", "-vf", "rotate=PI", - "-q:v", "8", + "-q:v", "4", "-f", "mjpeg", "-" ], diff --git a/shared/config.json b/shared/config.json index 00f123a..360daa4 100644 --- a/shared/config.json +++ b/shared/config.json @@ -1,13 +1,13 @@ { "agv": { - "ip": "192.168.60.177", + "ip": "192.168.60.80", "ssh_user": "elephant", "ssh_password": "Elephant", "map_file": "map.yaml", "map_dir": "/home/elephant" }, "arm": { - "ip": "192.168.60.88", + "ip": "192.168.60.120", "ssh_user": "pi", "ssh_password": "elephant", "socket_port": 5001,