This commit is contained in:
ywb
2026-06-13 14:07:19 +08:00
parent 48121b2a05
commit cbc88def27
14 changed files with 626 additions and 80 deletions
+212 -34
View File
@@ -53,6 +53,7 @@ class GlobalState:
self.machines_config = [] # 机器配置(每台机器的正面/背面点位+姿态) self.machines_config = [] # 机器配置(每台机器的正面/背面点位+姿态)
self.qr_config = [] # 二维码配置(独立点位列表) self.qr_config = [] # 二维码配置(独立点位列表)
self.navigator = None # Nav2Navigator 实例 self.navigator = None # Nav2Navigator 实例
self.current_customs = None # 当前设定的报关单信息
self.error_msg = "" # 错误弹窗消息(waiting_error 状态时) self.error_msg = "" # 错误弹窗消息(waiting_error 状态时)
self.lock = threading.Lock() self.lock = threading.Lock()
@@ -188,9 +189,6 @@ def api_status():
arm_connected = ok arm_connected = ok
except: except:
arm_connected = False arm_connected = False
# 连接已断开,清理 socket
if gs.arm_client:
gs.arm_client._sock = None
# 实际验证 AGV 连接 # 实际验证 AGV 连接
agv_connected = False agv_connected = False
@@ -1088,33 +1086,25 @@ def api_camera_preview():
if not gs.qr_scanner or not gs.qr_scanner._cap: if not gs.qr_scanner or not gs.qr_scanner._cap:
return "camera not opened", 400 return "camera not opened", 400
import time as _time
def gen(): def gen():
_last_ok = _time.time()
while True: while True:
frame = gs.qr_scanner.read_frame() frame = gs.qr_scanner.read_frame()
if frame is None: if frame is None:
break if _time.time() - _last_ok > 5:
# 编码为 JPEG break
_time.sleep(0.05)
continue
import cv2 import cv2
ret, buf = cv2.imencode(".jpg", frame) ret, buf = cv2.imencode(".jpg", frame)
if ret: if ret:
_last_ok = _time.time()
yield (b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" + yield (b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" +
buf.tobytes() + b"\r\n") buf.tobytes() + b"\r\n")
return Response(gen(), mimetype="multipart/x-mixed-replace; boundary=frame") return Response(gen(), mimetype="multipart/x-mixed-replace; boundary=frame")
@app.route("/api/camera/refresh")
def api_camera_refresh():
"""AGV 摄像头单帧 JPEGpolling 模式)"""
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") @app.route("/api/camera/capture")
def api_camera_capture(): def api_camera_capture():
@@ -1132,28 +1122,101 @@ def api_camera_capture():
cv2.imwrite(photo_path, frame) cv2.imwrite(photo_path, frame)
return jsonify({"ok": True, "path": photo_path}) 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") @app.route("/api/camera/arm_refresh")
def api_arm_camera_refresh(): def api_arm_camera_refresh():
"""从机械臂拉一张 JPEG(请求 snapshot 端点,简单 HTTP GET""" """从机械臂拉一张 JPEG,翻转后返回(机械臂摄像头物理倒装)。"""
import requests import requests
try: import cv2
r = requests.get(ARM_CAMERA_CONFIG.get("snapshot_url", ARM_CAMERA_CONFIG["url"]), timeout=8) import numpy as np
if r.status_code == 200 and r.content: url = ARM_CAMERA_CONFIG.get("snapshot_url", ARM_CAMERA_CONFIG["url"])
resp = Response(r.content, mimetype="image/jpeg") max_retries = 3
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0" for attempt in range(1, max_retries + 1):
resp.headers["Pragma"] = "no-cache" try:
resp.headers["Expires"] = "0" r = requests.get(url, timeout=8)
return resp if r.status_code == 200 and r.content:
return "", 404 corruption = _is_corrupted_jpeg(r.content)
except Exception as ex: if corruption > 0.5:
logger.info(f"arm_refresh 不可用: {ex}") logger.warning(f"arm_refresh {attempt}次尝试检测到花屏 (置信度{corruption:.2f}),重试...")
return "", 404 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") @app.route("/api/camera/arm_preview")
def api_arm_camera_preview(): def api_arm_camera_preview():
"""代理机械臂 MJPEG 视频流,供页面连续预览""" """代理机械臂 MJPEG 视频流,逐帧上下翻转后返回"""
import requests import requests
import cv2
import numpy as np
try: try:
upstream = requests.get( upstream = requests.get(
ARM_CAMERA_CONFIG["url"], ARM_CAMERA_CONFIG["url"],
@@ -1165,10 +1228,31 @@ def api_arm_camera_preview():
return "", 404 return "", 404
def generate(): def generate():
buf = b""
try: try:
for chunk in upstream.iter_content(chunk_size=8192): for chunk in upstream.iter_content(chunk_size=8192):
if chunk: if not chunk:
yield 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: finally:
upstream.close() upstream.close()
@@ -1670,12 +1754,105 @@ def api_qr_config_scan(qr_id):
return jsonify({"ok": False, "error": f"扫描失败: {str(ex)}"}), 400 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/<name>") @app.route("/photos/<name>")
def photos(name): def photos(name):
return send_from_directory(os.path.join(DATA_DIR, "photos"), name) return send_from_directory(os.path.join(DATA_DIR, "photos"), name)
@app.route("/api/camera/refresh")
def api_camera_refresh():
"""AGV 摄像头单帧 JPEGpolling 模式)"""
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__": if __name__ == "__main__":
logger.info("=" * 50) logger.info("=" * 50)
@@ -1702,3 +1879,4 @@ if __name__ == "__main__":
debug=SERVER_CONFIG["debug"], debug=SERVER_CONFIG["debug"],
threaded=True threaded=True
) )
+3 -3
View File
@@ -7,8 +7,8 @@ import os
BASE_DIR = os.path.dirname(os.path.abspath(__file__)) BASE_DIR = os.path.dirname(os.path.abspath(__file__))
# ========== 网络配置(集中管理所有 IP 地址 — 修改此处即可全局生效)========== # ========== 网络配置(集中管理所有 IP 地址 — 修改此处即可全局生效)==========
AGV_HOST = "192.168.60.177" AGV_HOST = "192.168.60.80"
ARM_HOST = "192.168.60.88" ARM_HOST = "192.168.60.120"
# ========== AGV 参数 ========== # ========== AGV 参数 ==========
AGV_CONFIG = { AGV_CONFIG = {
@@ -35,7 +35,7 @@ MAP_CONFIG = {
# ========== 摄像头 ========== # ========== 摄像头 ==========
CAMERA_CONFIG = { CAMERA_CONFIG = {
"device_index": 4, # AGV 摄像头 video4(标准彩色摄像头V4L2后端) "device_index": 3, # AGV 摄像头 video3Orbbec Gemini 彩色流V4L2后端)
"backend": "v4l2", # 使用 V4L2 后端获取标准彩色格式(640x480) "backend": "v4l2", # 使用 V4L2 后端获取标准彩色格式(640x480)
"qr_detect_interval": 0.5, "qr_detect_interval": 0.5,
"capture_delay": 0.5, "capture_delay": 0.5,
+115
View File
@@ -465,6 +465,10 @@ a:hover { text-decoration: underline; }
aspect-ratio: 4/3; aspect-ratio: 4/3;
object-fit: cover; object-fit: cover;
} }
.camera-img.arm {
transform: rotate(180deg);
}
.camera-placeholder { .camera-placeholder {
width: 100%; width: 100%;
aspect-ratio: 4/3; 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-pending { background: #141e28; border-color: #2a3a4a; }
.machine-cell.mstatus-active { background: #1a2535; border-color: #4fc3f7; } .machine-cell.mstatus-active { background: #1a2535; border-color: #4fc3f7; }
.machine-cell.mstatus-completed { background: #152522; border-color: #2e7d32; } .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;
}
+17
View File
@@ -18,6 +18,7 @@ createApp({
agvCameraSrc: '/api/camera/refresh?t=' + Date.now(), agvCameraSrc: '/api/camera/refresh?t=' + Date.now(),
armCameraSrc: '/api/camera/arm_preview?t=' + Date.now(), armCameraSrc: '/api/camera/arm_preview?t=' + Date.now(),
agvCameraError: false, agvCameraError: false,
hasAgvCamera: false, // AGV 车体是否有可用相机
armCameraError: false, armCameraError: false,
reconnectingDevice: null reconnectingDevice: null
} }
@@ -36,6 +37,7 @@ createApp({
}, },
mounted() { mounted() {
this.refresh() this.refresh()
this.refreshCameraCapabilities()
setInterval(this.refreshStatus, 3000) setInterval(this.refreshStatus, 3000)
this.refreshCams() this.refreshCams()
setInterval(() => this.refreshCams(), 2000) setInterval(() => this.refreshCams(), 2000)
@@ -47,6 +49,17 @@ createApp({
this.armCameraSrc = '/api/camera/arm_preview?t=' + Date.now() 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() { async refresh() {
await this.refreshStatus() await this.refreshStatus()
await this.loadPoints() await this.loadPoints()
@@ -58,6 +71,10 @@ createApp({
this.agvConnected = data.agv_connected this.agvConnected = data.agv_connected
this.armConnected = data.arm_connected this.armConnected = data.arm_connected
this.cameraOpened = data.camera_opened 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.armCameraOpened = data.arm_camera_opened
this.mapLoaded = data.map_loaded this.mapLoaded = data.map_loaded
this.currentState = data.state || 'idle' this.currentState = data.state || 'idle'
+8
View File
@@ -9,6 +9,7 @@ createApp({
tasks: [], tasks: [],
report: null, report: null,
armCameraOpened: false, armCameraOpened: false,
hasAgvCamera: false,
agvPreviewUrl: API + '/api/camera/preview', agvPreviewUrl: API + '/api/camera/preview',
armPreviewUrl: '', armPreviewUrl: '',
polling: null, polling: null,
@@ -70,6 +71,13 @@ createApp({
} }
} catch (e) {} } 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() { poll() {
this.refresh() this.refresh()
this.pollLogs() this.pollLogs()
+76 -1
View File
@@ -66,6 +66,15 @@ createApp({
armSnapshotLoading: false, armSnapshotLoading: false,
newQrName: '', newQrName: '',
armInitialPose: [0, 0, 0, 0, 0, 0], armInitialPose: [0, 0, 0, 0, 0, 0],
// 报关单
customsList: [],
customsLoading: false,
customsPage: 1,
customsPageSize: 15,
customsTotal: 0,
selectedCustomsId: '',
selectedCustomsName: '',
customsMachines: [],
} }
}, },
mounted() { mounted() {
@@ -76,6 +85,13 @@ createApp({
this.armCameraUrl = API + '/api/camera/arm_preview?t=' + Date.now() this.armCameraUrl = API + '/api/camera/arm_preview?t=' + Date.now()
}, },
computed: { computed: {
customsTotalPages() {
return Math.max(1, Math.ceil(this.customsTotal / this.customsPageSize))
},
customsPageData() {
// 前端显示 pagination data — 但我们在 API 后端做分页,所以这里只是引用
return this.customsList
},
hasQr() { hasQr() {
return !!(this.selectedMachine && this.selectedMachine.qr) return !!(this.selectedMachine && this.selectedMachine.qr)
}, },
@@ -1187,5 +1203,64 @@ createApp({
alert('❌ 复位请求失败: ' + e.message) 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') }).mount('#app')
+4 -3
View File
@@ -93,9 +93,10 @@
<h2>📷 摄像头预览</h2> <h2>📷 摄像头预览</h2>
<div class="camera-row"> <div class="camera-row">
<div class="camera-box"> <div class="camera-box">
<div class="camera-label">AGV 摄像头 <button class="btn btn-small" @click="agvCameraSrc='/api/camera/refresh?t='+Date.now(); agvCameraError=false">刷新</button></div> <div class="camera-label">AGV 摄像头 <button class="btn btn-small" @click="refreshAgvCamera()">刷新</button></div>
<img v-if="cameraOpened && !agvCameraError" :src="agvCameraSrc" class="camera-img" @error="agvCameraError=true"> <img v-if="cameraOpened && hasAgvCamera && !agvCameraError" :src="agvCameraSrc" class="camera-img" @error="agvCameraError=true">
<div v-if="cameraOpened && agvCameraError" class="camera-placeholder">AGV 摄像头异常</div> <div v-if="cameraOpened && agvCameraError && hasAgvCamera" class="camera-placeholder">AGV 摄像头异常</div>
<div v-if="cameraOpened && !hasAgvCamera" class="camera-placeholder">AGV 无可用彩色摄像头</div>
<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">
+3 -2
View File
@@ -173,8 +173,9 @@
<h2>📷 摄像头预览</h2> <h2>📷 摄像头预览</h2>
<div class="camera-dual"> <div class="camera-dual">
<div class="camera-box"> <div class="camera-box">
<div class="camera-label">🎥 AGV 摄像头</div> <div class="camera-label">🎥 AGV 摄像头 <span v-if="!hasAgvCamera" style="font-size:0.8em;color:#999">(不可用)</span></div>
<img :src="agvPreviewUrl" @error="onAgvPreviewError" class="camera-img"> <img v-if="hasAgvCamera" :src="agvPreviewUrl" @error="onAgvPreviewError" class="camera-img">
<div v-else class="camera-placeholder">AGV 无可用彩色摄像头</div>
</div> </div>
<div class="camera-box" v-if="armCameraOpened"> <div class="camera-box" v-if="armCameraOpened">
<div class="camera-label">🦾 机械臂摄像头</div> <div class="camera-label">🦾 机械臂摄像头</div>
+96 -4
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>设置 - AGV 拍摄系统</title> <title>设置 - AGV 拍摄系统</title>
<link rel="stylesheet" href="/static/css/style.css?v=20260529b"> <link rel="stylesheet" href="/static/css/style.css?v=20260612b">
</head> </head>
<body> <body>
<div id="app"> <div id="app">
@@ -25,6 +25,7 @@
<button class="tab" :class="{active: tab === 'model'}" @click="tab = 'model'">📦 机型配置</button> <button class="tab" :class="{active: tab === 'model'}" @click="tab = 'model'">📦 机型配置</button>
<button class="tab" :class="{active: tab === 'arm'}" @click="tab = 'arm'">🤖 机械臂</button> <button class="tab" :class="{active: tab === 'arm'}" @click="tab = 'arm'">🤖 机械臂</button>
<button class="tab" :class="{active: tab === 'agv'}" @click="tab = 'agv'">🚗 AGV控制</button> <button class="tab" :class="{active: tab === 'agv'}" @click="tab = 'agv'">🚗 AGV控制</button>
<button class="tab" :class="{active: tab === 'customs'}" @click="tab = 'customs'">📋 报关单</button>
</div> </div>
<main class="container"> <main class="container">
@@ -435,7 +436,7 @@
<!-- 机械臂摄像头画面 --> <!-- 机械臂摄像头画面 -->
<div style="margin-bottom:8px"> <div style="margin-bottom:8px">
<div class="camera-preview" style="max-width:640px"> <div class="camera-preview" style="max-width:640px">
<img :src="armCameraUrl" @error="onArmPreviewError" style="width:100%;border-radius:8px"> <img :src="armCameraUrl" @error="onArmPreviewError" class="camera-img arm">
</div> </div>
</div> </div>
<div style="display:flex;justify-content:flex-end;margin-bottom:16px"> <div style="display:flex;justify-content:flex-end;margin-bottom:16px">
@@ -499,7 +500,7 @@
</div> </div>
<div v-else> <div v-else>
<div class="camera-preview"> <div class="camera-preview">
<img :src="armCameraUrl" @error="onArmPreviewError"> <img :src="armCameraUrl" @error="onArmPreviewError" class="camera-img arm">
</div> </div>
<div class="joints-panel"> <div class="joints-panel">
<h3>关节角度控制</h3> <h3>关节角度控制</h3>
@@ -587,6 +588,97 @@
</div> </div>
</section> </section>
</div> </div>
<!-- ========== 报关单 Tab ========== -->
<div v-if="tab === 'customs'">
<section class="card">
<div style="display:flex;justify-content:space-between;align-items:center">
<h2>📋 报关单列表</h2>
<button class="btn btn-secondary" @click="loadCustomsList" :disabled="customsLoading">
<span v-if="customsLoading"></span><span v-else>🔄</span> 刷新
</button>
</div>
<p class="hint" style="margin-bottom:12px">选择报关单查看其中的机器列表,点击报关单 ID 展开机器信息</p>
<!-- 分页表格 -->
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
<th style="width:60px">序号</th>
<th>报关单号</th>
<th>名称</th>
<th>状态</th>
<th>机器数</th>
<th style="width:80px">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, idx) in customsPageData" :key="item.id || idx"
class="clickable-row"
:class="{ 'row-selected': selectedCustomsId === (item.id || item.customsId || item.customs_id) }"
@click="selectCustomsRow(item, idx)">
<td>{% raw %}{{ (customsPage - 1) * customsPageSize + idx + 1 }}{% endraw %}</td>
<td><strong>{% raw %}{{ item.customsNo || item.customs_no || item.id || '-' }}{% endraw %}</strong></td>
<td>{% raw %}{{ item.name || item.customsName || item.customs_name || '-' }}{% endraw %}</td>
<td><span class="badge" :class="'badge-' + (item.status || 'unknown')">{% raw %}{{ item.status || '未知' }}{% endraw %}</span></td>
<td>{% raw %}{{ item.machineCount || item.machine_count || item.machineNum || item.machine_num || '?' }}{% endraw %}</td>
<td>
<button class="btn btn-small btn-primary" @click.stop="selectCustomsRow(item, idx)">
📦 查看机器
</button>
</td>
</tr>
<tr v-if="!customsPageData.length && !customsLoading">
<td colspan="6" style="text-align:center;color:#8899aa;padding:24px">暂无报关单数据</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页控件 -->
<div class="pagination" v-if="customsTotal > customsPageSize">
<button class="btn btn-small" :disabled="customsPage <= 1" @click="customsPage = customsPage - 1; loadCustomsList()"> 上一页</button>
<span style="margin:0 12px;color:#9aa0a6;font-size:13px">
第 {% raw %}{{ customsPage }}{% endraw %} / {% raw %}{{ customsTotalPages }}{% endraw %} 页(共 {% raw %}{{ customsTotal }}{% endraw %} 条)
</span>
<button class="btn btn-small" :disabled="customsPage >= customsTotalPages" @click="customsPage = customsPage + 1; loadCustomsList()">下一页 </button>
</div>
</section>
<!-- 机器列表(点击报关单行时展开) -->
<section class="card" v-if="customsMachines.length">
<div style="display:flex;justify-content:space-between;align-items:center">
<h2>📦 机器列表</h2>
<span style="color:#9aa0a6;font-size:13px">报关单: <strong style="color:#e0e0e0">{% raw %}{{ selectedCustomsName }}{% endraw %}</strong></span>
</div>
<p class="hint" style="margin-bottom:12px"><strong>{% raw %}{{ customsMachines.length }}{% endraw %}</strong> 台机器</p>
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
<th style="width:50px">序号</th>
<th>机器编号</th>
<th>机器名称</th>
<th>型号</th>
<th>二维码值</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr v-for="(m, mi) in customsMachines" :key="mi">
<td>{% raw %}{{ mi + 1 }}{% endraw %}</td>
<td><strong>{% raw %}{{ m.serialNumber || m.serial_number || m.serialNo || m.serial_no || m.machineNo || m.machine_no || m.id || '-' }}{% endraw %}</strong></td>
<td>{% raw %}{{ m.name || m.machineName || m.machine_name || m.model || '-' }}{% endraw %}</td>
<td>{% raw %}{{ m.modelName || m.model_name || m.model || '-' }}{% endraw %}</td>
<td style="font-family:monospace;color:#4fc3f7">{% raw %}{{ m.qrValue || m.qr_value || m.qr || m.qrCode || m.qr_code || m.serialNumber || m.serial_number || '-' }}{% endraw %}</td>
<td><span class="badge" :class="'badge-' + (m.status || 'normal')">{% raw %}{{ m.status || '正常' }}{% endraw %}</span></td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</main> </main>
<!-- 手动输入二维码弹窗 --> <!-- 手动输入二维码弹窗 -->
@@ -621,6 +713,6 @@
</div> </div>
<script src="/static/js/vue3.global.prod.js?v=20260526a"></script> <script src="/static/js/vue3.global.prod.js?v=20260526a"></script>
<script src="/static/js/setting.js?v=20260605a"></script> <script src="/static/js/setting.js?v=20260612a"></script>
</body> </body>
</html> </html>
+23 -14
View File
@@ -61,14 +61,18 @@ class ArmClient:
def get_angles(self) -> Tuple[bool, List[float]]: def get_angles(self) -> Tuple[bool, List[float]]:
"""获取所有关节角度""" """获取所有关节角度"""
ok, resp = self.send_command("get_angles()") ok, resp = self.send_command("get_angles()")
if ok and resp.startswith("get_angles:["): if ok:
try: try:
# get_angles:[0.174, 0.520, ...] → list # 兼容 "get_angles:[-260.2,...]" 和 "[-260.2,...]" 两种格式
nums = resp.split("[")[1].split("]")[0] text = resp.split(":", 1)[-1] if ":" in resp else resp
angles = [float(x) for x in nums.split(",")] text = text.strip()
return True, angles 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: except:
return False, [] pass
return False, [] return False, []
def set_angles(self, angles: List[float], speed: int = 500) -> bool: 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]]: def get_coords(self) -> Tuple[bool, List[float]]:
"""获取当前坐标和姿态 [x, y, z, rx, ry, rz]""" """获取当前坐标和姿态 [x, y, z, rx, ry, rz]"""
ok, resp = self.send_command("get_coords()") ok, resp = self.send_command("get_coords()")
if ok and "get_coords:" in resp: if ok:
try: try:
nums = resp.split("[")[1].split("]")[0] text = resp.split(":", 1)[-1] if ":" in resp else resp
coords = [float(x) for x in nums.split(",")] text = text.strip()
return True, coords 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: except:
return False, [] pass
return False, [] return False, []
def set_coords(self, coords: List[float], speed: int = 500) -> bool: def set_coords(self, coords: List[float], speed: int = 500) -> bool:
@@ -132,19 +140,20 @@ class ArmClient:
def state_check(self) -> bool: def state_check(self) -> bool:
"""检查机械臂状态是否正常""" """检查机械臂状态是否正常"""
ok, resp = self.send_command("state_check()") 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: def check_running(self) -> bool:
"""检查机械臂是否在运行""" """检查机械臂是否在运行"""
ok, resp = self.send_command("check_running()") 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: def wait_done(self, timeout: float = 30) -> bool:
"""等待上一条命令执行完成""" """等待上一条命令执行完成"""
start = time.time() start = time.time()
while time.time() - start < timeout: while time.time() - start < timeout:
ok, resp = self.send_command("check_running()") 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 return True
time.sleep(0.5) time.sleep(0.5)
return False return False
+2 -2
View File
@@ -7,8 +7,8 @@ import os
BASE_DIR = os.path.dirname(os.path.abspath(__file__)) BASE_DIR = os.path.dirname(os.path.abspath(__file__))
# ========== 网络配置(集中管理所有 IP 地址 — 修改此处即可全局生效)========== # ========== 网络配置(集中管理所有 IP 地址 — 修改此处即可全局生效)==========
AGV_HOST = "192.168.60.177" AGV_HOST = "192.168.60.80"
ARM_HOST = "192.168.60.88" ARM_HOST = "192.168.60.120"
# ========== AGV 参数 ========== # ========== AGV 参数 ==========
AGV_CONFIG = { AGV_CONFIG = {
+62 -12
View File
@@ -29,19 +29,22 @@ class QRScanner:
def open(self) -> bool: def open(self) -> bool:
"""打开摄像头""" """打开摄像头"""
try: try:
# 强制 V4L2 后端,获取标准彩色格式(与 test/server.py 一致) # 强制 V4L2 后端
self._cap = cv2.VideoCapture(self.device_index, cv2.CAP_V4L2) self._cap = cv2.VideoCapture(self.device_index, cv2.CAP_V4L2)
if self._cap.isOpened(): if not self._cap.isOpened():
logger.info(f"摄像头 {self.device_index} 已打开 (V4L2)")
return True
else:
# fallback: 不指定后端
self._cap = cv2.VideoCapture(self.device_index) self._cap = cv2.VideoCapture(self.device_index)
if self._cap.isOpened():
logger.info(f"摄像头 {self.device_index} 已打开 (默认后端)") if not self._cap.isOpened():
return True
logger.error(f"无法打开摄像头 {self.device_index}") logger.error(f"无法打开摄像头 {self.device_index}")
return False 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: except Exception as e:
logger.error(f"摄像头打开失败: {e}") logger.error(f"摄像头打开失败: {e}")
return False return False
@@ -51,14 +54,61 @@ class QRScanner:
self._cap.release() self._cap.release()
self._cap = None 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]: def read_frame(self) -> Optional[np.ndarray]:
"""读取一帧""" """读取一帧"""
if not self._cap or not self._cap.isOpened(): if not self._cap or not self._cap.isOpened():
return None return None
ret, frame = self._cap.read() ret, frame = self._cap.read()
if not ret: if not ret or frame is None:
return None return None
return frame return self._fix_frame(frame)
def detect_qr(self, frame: np.ndarray) -> Optional[str]: def detect_qr(self, frame: np.ndarray) -> Optional[str]:
"""从图像帧中检测二维码""" """从图像帧中检测二维码"""
@@ -96,4 +146,4 @@ class QRScanner:
return self return self
def __exit__(self, *args): def __exit__(self, *args):
self.close() self.close()
+3 -3
View File
@@ -105,11 +105,11 @@ def _ensure_ffmpeg():
"ffmpeg", "ffmpeg",
"-f", "v4l2", "-f", "v4l2",
"-input_format", "mjpeg", "-input_format", "mjpeg",
"-framerate", "15", "-framerate", "12",
"-video_size", "640x480", "-video_size", "1280x720",
"-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", "4",
"-f", "mjpeg", "-f", "mjpeg",
"-" "-"
], ],
+2 -2
View File
@@ -1,13 +1,13 @@
{ {
"agv": { "agv": {
"ip": "192.168.60.177", "ip": "192.168.60.80",
"ssh_user": "elephant", "ssh_user": "elephant",
"ssh_password": "Elephant", "ssh_password": "Elephant",
"map_file": "map.yaml", "map_file": "map.yaml",
"map_dir": "/home/elephant" "map_dir": "/home/elephant"
}, },
"arm": { "arm": {
"ip": "192.168.60.88", "ip": "192.168.60.120",
"ssh_user": "pi", "ssh_user": "pi",
"ssh_password": "elephant", "ssh_password": "elephant",
"socket_port": 5001, "socket_port": 5001,