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.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 摄像头单帧 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")
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/<name>")
def 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__":
logger.info("=" * 50)
@@ -1702,3 +1879,4 @@ if __name__ == "__main__":
debug=SERVER_CONFIG["debug"],
threaded=True
)