-
This commit is contained in:
+212
-34
@@ -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/<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 摄像头单帧 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
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user