1.背景
最近有这样的场景,网页端需要显示现场无人系统(机器人)的摄像头数据(图片)。值得注意的是,一个无人系统(机器人)它身上可能挂载若干个摄像头,这若干个摄像头都需要在前端的若干个小区域内显示;另外不同的用户访问前端网页,每个用户都访问他自己想关注的无人系统(机器人)摄像头数据。而前端直接和现场的无人系统对接是不合适的:因为对于同一个无人系统,可能不同的用户同一时间或相近时间都访问它,导致该无人系统要处理反馈多份资源请求,并且很容易导致超过机器人的处理负荷;另外对于前端来讲,他并不知知道应该和现场的哪一个无人系统进行对接(因为前端并没有现场的无人系统相关身份数据,无法做识别)。
为此,设计了如下方案,现场的无人系统统一和数据中转服务器对接,每个机器人都只给一份实时摄像头数据给数据中转服务器。数据中转服务器建立websocket服务端程序,并处理网页端的请求(请求获取特定机器人的所有摄像头信息),数据中转服务器根据网页端的请求,对请求信息进行解析,并创建特定的websocket服务实例。具体通信示意图如下:
这里所提到的前端网页,实际是业务中的可视化大屏,他对之前项目的已有功能有些注意点:
- 总控大屏现有对接无人系统的视频使用的是后端发给前端的rtsp流地址,默认使用的是该方式。但后续无人系统(机器人)传输的数据也有可能是一帧帧二进制图片数据
- 原有前端使用的组件适用接收rtsp流方式,不适用新的接收图片帧的方式,前端需要做两套模式区分(区别开发:一套,一套)
- 在无人系统(机器人)传输的数据是一帧帧二进制图片数据的情况下,有可能该无人系统有多个摄像头,它会传输多组独立的图片帧数据(前端最多支持4个摄像头数据)
2.约定接口
针对以上内容进行分析,并为了兼容已有实现的功能,约定如下大屏与数据中转器的接口方式:
网页端通过GET请求,调用数据中转服务器接口,请求接口地址为:
http://ip:port/api/usdisplay?usid=2 。其中请求参数usid代表前端给数据中转服务器(后端)传递的无人系统id.
数据中转服务器需要根据无人系统id,判断该无人系统摄像头数据传递是使用的哪种方式?并根据特定的方式返回前端结果,前端根据不同的模式,执行不同的渲染方式。
数据中转服务器(后端)返回前端的结果格式为:
- 1 {
- 2 "code": 200,
- 3 "success": true,
- 4 "data": {
- 5 "mode": "rtspurl",
- 6 "url": [
- 7 "rtsp: //127.0.0.1:8081",
- 8 "rtsp: //127.0.0.1:8082",
- 9 "rtsp: //127.0.0.1:8083"
- 10 ]
- 11 }
- 12 }
复制代码
- 以websocket模式,如果一个无人系统有3个摄像头举例
- {
- "code": 200,
- "success": true,
- "data": {
- "mode": "websocketurl",
- "url": [
- "ws://127.0.0.1:8080/api/websocket?usid=2&cam=0",
- "ws://127.0.0.1:8080/api/websocket?usid=2&cam=1",
- "ws://127.0.0.1:8080/api/websocket?usid=2&cam=2",
- ]
- }
- }
复制代码 3.前端开发过程
3.1 div结构设计
- 1
- 2 <span>态势总览</span>
- 3
- 4
- 5
- 6 <video class="video-stream" autoplay muted></video>
- 7
- 8
- 9
- 10 <video class="video-stream" autoplay muted></video>
- 11
- 12
- 13
- 14 <video class="video-stream" autoplay muted></video>
- 15
- 16
- 17
- 18 <video class="video-stream" autoplay muted></video>
- 19
- 20
- 21
- 22
复制代码 主要是在一个区域内预先占用4个小区域,每个小区域用于显示同一个无人系统的一个摄像头信息,最多支持显示同一个无人系统的4个摄像头信息(实际显示其中的1-4个小区域是以实际同一个无人系统的摄像头个数而定的)。
以上的html结构最先是为了支持rtsp视频流而设计的,对于当前的图片帧显示使用的Canvas技术不适用,所以如果是在图片帧显示的模式下,后续需要通过js动态的修改html结果,切换为相关标签结构。
以上现有的html结构对应的CSS样式如下:- 1 .chartarea {
- 2 width: 95%;
- 3 height: 31%;
- 4 margin-top: 3.5%;
- 5 }
- 6 .innerright .chartarea {
- 7 margin-left: 3%;
- 8 margin-right: 2%;
- 9 }
- 10 .charttitle {
- 11 width: 100%;
- 12 height: 15%;
- 13 background-image: url("/img/visualImages/20_chart_title.png");
- 14 background-size: 100% 100%;
- 15 }
- 16 .charttitle>span {
- 17 height: 100%;
- 18 margin-left: 5%;
- 19 display: flex;
- 20 align-items: center;
- 21 font-size: 0.8vw;
- 22 color: #fff;
- 23 font-weight: 700;
- 24 }
- 25 .chartdata {
- 26 width: 100%;
- 27 height: 85%;
- 28 /* background-image: url("/img/visualImages/21_chart_background.png");
- 29 background-size: cover;
- 30 background-repeat: no-repeat;
- 31 background-position:top left; */
- 32
- 33 /* 当背景图片无法完整铺满整个div,但自己又想即时图片变形不合比例拉伸,也要铺满,这是种好方式! */
- 34 /* 这种方法会将背景图片拉伸以完全覆盖div的宽度和高度,可能会导致图片变形,特别是如果图片的原始宽高比与div的宽高比不匹配时。 */
- 35 background-image: url("/img/visualImages/21_chart_background.png");
- 36 background-size: 100% 100%;
- 37 }
- 38 #videoGrid {
- 39 flex: 1;
- 40 display: grid;
- 41 grid-template-columns: 0.48fr 0.48fr;
- 42 grid-template-rows: 0.49fr 0.49fr;
- 43 /* gap: 5px; */
- 44 gap: 2%;
- 45 padding: 1.5%;
- 46 }
- 47
- 48 .video-container {
- 49 position: relative;
- 50 background-color: #000;
- 51 border-radius: 4px;
- 52 overflow: hidden;
- 53 }
- 54 .video-stream
- 55 {
- 56 width: 100%;
- 57 height: 100%;
- 58 object-fit: cover;
- 59 }
- 60
- 61 .camera-label {
- 62 position: absolute;
- 63 bottom: 5px;
- 64 left: 5px;
- 65 color: white;
- 66 background-color: rgba(0, 0, 0, 0.5);
- 67 padding: 2px 5px;
- 68 border-radius: 3px;
- 69 font-size: 12px;
- 70 }
复制代码 在上面的4个小视频区域,当用户点击其中任意一个有视频的小区域时,会弹出一个视频放大显示的弹出框,其对应的html结构和css如下:- 1
- 2
- 3
- 4 <span class="close-btn">×</span>
- 5 <video id="modalVideo" autoplay controls></video>
- 6
- 7
- 8
复制代码 - 1 /* 弹窗样式 */
- 2 .modal {
- 3 display: none;
- 4 position: fixed;
- 5 z-index: 1100;
- 6 left: 0;
- 7 top: 0;
- 8 width: 100%;
- 9 height: 100%;
- 10 background-color: rgba(0, 0, 0, 0.8);
- 11 justify-content: center;
- 12 align-items: center;
- 13 }
- 14 .modal-content {
- 15 position: relative;
- 16 width: 70vw;
- 17 height: 75vh;
- 18 background-color: #000;
- 19 border-radius: 5px;
- 20 overflow: hidden;
- 21 }
- 22
- 23 .close-btn {
- 24 position: absolute;
- 25 top: 10px;
- 26 right: 15px;
- 27 color: white;
- 28 font-size: 28px;
- 29 font-weight: bold;
- 30 cursor: pointer;
- 31 z-index: 1001;
- 32 }
- 33 .close-btn:hover {
- 34 color: #ccc;
- 35 }
- 36 .close-btn {
- 37 font-size: 24px;
- 38 font-weight: bold;
- 39 color: #999;
- 40 cursor: pointer;
- 41 }
- 42 #modalVideo{
- 43 width: 100%;
- 44 height: 100%;
- 45 object-fit: contain;
- 46 }
- 47 .modal-camera-label {
- 48 position: absolute;
- 49 bottom: 10px;
- 50 left: 10px;
- 51 color: white;
- 52 background-color: rgba(0, 0, 0, 0.5);
- 53 padding: 5px 10px;
- 54 border-radius: 3px;
- 55 font-size: 14px;
- 56 }
复制代码 3.2 js函数设计
3.2.1 设计统一的入口函数
设计统一的入口函数USDisplay(),当用户访问特定的tab页时触发该函数。USDisplay()通过Get请求、以无人系统id作为请求参数,访问数据中转服务器程序,数据中转服务器程序根据请求的无人系统id,分析判断该无人系统视频传输的模式,并执行模式信息反馈。
代码设计如下:- 1 export function USDisplay() {
- 2 //1 根据无人系统id,发送请求后端,并解析后端返回的是哪种模式
- 3 //Get请求
- 4 var result = null;
- 5 $.ajax({
- 6 type: 'GET',
- 7 //url: ipport + '/api/usdisplay', //!!!!后续由后端确定ip
- 8 url: 'http://127.0.0.1:8080' + '/api/usdisplay', //20250815临时测试用
- 9 data: {
- 10 //usid: clickedUnmanedDVId //无人系统id
- 11 usid: 3 //无人系统id //20250815临时测试用
- 12 },
- 13 dataType: 'json', // 期望的后端返回数据格式
- 14 async: false,
- 15 success: function (res) {
- 16 result = res;
- 17 console.log('成功拿到数据了----',result);
- 18 },
- 19 error: function (xhr, status, error) {
- 20 console.log("error result",result);
- 21 console.error('USDisplay API请求失败:', status, error);
- 22
- 23 showConnectionStatus('API连接失败', 'error');
- 24 }
- 25 });
- 26
- 27 var urlarray = [];//用于存储rtspurl/websocketurl地址数组
- 28 //解析模式
- 29 if (result.code === 200 && result.success === true && !!result.data && isNotEmptyObject(result.data)) {
- 30 //模式一:直接rtsp流(也有弊端,前端直连机器人视频,如果网页访问的用户过多,会导致机器人负荷过大,后期也需要数据中台中转)
- 31 if (result.data.mode === "rtspurl") {
- 32 urlarray = result.data.url;
- 33 if (urlarray.length >= 1) {
- 34 //-1----清理之前的连接资源
- 35 cleanupPreviousConnections();
- 36 //-2----rtsp构建显示逻辑
- 37 usrtspmode(urlarray);
- 38 }
- 39 }
- 40 //模式二:数据中台作为websocket服务端,网页端作为websocket客户端
- 41 else if (result.data.mode === "websocketurl") {
- 42 urlarray = result.data.url;
- 43 if (urlarray.length >= 1) {
- 44 //-1----清理之前的连接资源
- 45 cleanupPreviousConnections();
- 46 //-2----websocket构建显示逻辑
- 47 uswebsocketmode(urlarray);
- 48 }
- 49 }
- 50 //说明后端没有返回任何模式,不做任何处理
- 51 else{
- 52 console.warn('机器人rtsp/图片帧:后端未返回有效的显示模式');
- 53 showConnectionStatus('未知显示模式', 'warning');
- 54 }
- 55 }else{
- 56 console.error('USDisplay API返回数据无效,result:',result);
- 57 showConnectionStatus('数据获取失败', 'error');
- 58 }
- 59 }
复制代码 3.2.2 模式一:rtspurl模式的处理
- 1 function usrtspmode(url) {
- 2 // 获取元素
- 3 const videoContainers = document.querySelectorAll('.video-container');//4个视频div容器(各自平等独立)
- 4 const modal = document.getElementById('videoModal');//视频弹出框
- 5 const modalVideo = document.getElementById('modalVideo');//弹出框显示视频区域
- 6 const closeBtn = document.querySelector('.close-btn');//弹出框关闭按钮区域
- 7 const modalCameraLabel = document.querySelector('.modal-camera-label');//弹出框底部显示视频名称标识
- 8
- 9 var cameraConfigs = [];//重新构建rtsp地址,友好前端显示
- 10 url.forEach((item, index) => {
- 11 cameraConfigs.push(
- 12 {
- 13 id: index,
- 14 name: "camera" + (index + 1),
- 15 rtsp: item
- 16 }
- 17 );
- 18 });
- 19
- 20 // 用于存储webrtc实例 (几个视频就需要几个实例)
- 21 const webrtcInstances = [];
- 22
- 23 // 初始化视频流函数--核心方法
- 24 function setupVideoStreams() {
- 25
- 26 //遍历4个视频div元素操作
- 27 //每个视频div结构如下:
- 28 //
- 29 // <video autoplay muted></video>
- 30 //
- 31 //
- 32
- 33 videoContainers.forEach((container, index) => {
- 34 const videoElement = container.querySelector('.video-stream');//小区域视频本身
- 35 const cameraLabel = container.querySelector('.camera-label');//小区域视频标识
- 36
- 37 // (1)摄像头名称显示(从配置读取)
- 38 if (cameraLabel && cameraConfigs[index]) {
- 39 cameraLabel.textContent = cameraConfigs[index].name;//根据后台的摄像头名称(位置标识)进行标识显示
- 40 }
- 41
- 42 // (2)初始化webrtc-streamer
- 43 if (videoElement && cameraConfigs[index]) {
- 44 //----2.1 实例化WebRtcStreamer ---固定写法
- 45 const webrtc = new WebRtcStreamer(videoElement, WEBRTC_SERVER);
- 46
- 47 //----2.2 执行webrtc实例连接rtsp流(地址) ---固定写法
- 48 //webrtc.connect(cameraConfigs[index].rtsp);//优化
- 49 //webrtc.connect(cameraConfigs[index].rtsp,null,"rtptransport=tcp&timeout=60&width=320&height=240",null);
- 50 webrtc.connect(cameraConfigs[index].rtsp, null, "rtptransport=tcp&timeout=60", null);
- 51
- 52 //----2.3 存储实例以便管理
- 53 // webrtcInstances.push({
- 54 // id: cameraConfigs[index].id,
- 55 // instance: webrtc,
- 56 // element: videoElement
- 57 // });
- 58
- 59 //存储到全局数组用于资源管理
- 60 globalWebrtcInstances.push({
- 61 id: cameraConfigs[index].id,
- 62 instance: webrtc,
- 63 element: videoElement
- 64 });
- 65
- 66 // 错误处理
- 67 videoElement.onerror = function () {
- 68 handleStreamError(container);
- 69 };
- 70
- 71 //补充:连接成功反馈
- 72 videoElement.onloadstart = function(){
- 73 console.log(`<video>视频方式${cameraConfigs[index].name}连接成功`);
- 74 showConnectionStatus(`<video>视频方式${cameraConfigs[index].name}连接成功`, 'success');
- 75 };
- 76 }
- 77 });
- 78 }
- 79
- 80 // 处理流错误
- 81 function handleStreamError(container) {
- 82 const videoElement = container.querySelector('.video-stream');
- 83 const label = container.querySelector('.camera-label');
- 84
- 85 if (videoElement) {
- 86 videoElement.style.display = 'none';
- 87 }
- 88
- 89 if (label) {
- 90 label.style.color = '#ff4d4f';
- 91 label.textContent = label.textContent + ' (离线)';
- 92 }
- 93
- 94 container.style.backgroundColor = '#333';
- 95 container.innerHTML += `
- 96
- 97 视频流无法加载
- 98
- 99 `;
- 100 //showConnectionStatus('视频流连接失败', 'error');
- 101
- 102 }
- 103
- 104 // 监听每个视频区域div的用户点击事件
- 105 //每个视频div结构如下:
- 106 //
- 107 // <video autoplay muted></video>
- 108 //
- 109 //
- 110 videoContainers.forEach(container => {
- 111 container.addEventListener('click', function () {
- 112 const videoElement = this.querySelector('.video-stream');
- 113 const cameraId = this.getAttribute('data-camera');
- 114 //从配置变量中获取到对应视频的完整配置信息
- 115 const cameraConfig = cameraConfigs.find(c => c.id === Number(cameraId));
- 116
- 117 if (videoElement && videoElement.srcObject && cameraConfig) {
- 118 modalVideo.srcObject = videoElement.srcObject;
- 119 modalCameraLabel.textContent = cameraConfig.name;
- 120 modal.style.display = 'flex';
- 121
- 122 modalVideo.play().catch(e => console.error('弹窗视频播放失败:', e));
- 123 }
- 124 });
- 125 });
- 126
- 127
- 128 // 关闭弹窗
- 129 if (closeBtn) {
- 130 closeBtn.addEventListener('click', function () {
- 131 modal.style.display = 'none';
- 132 modalVideo.pause();
- 133 modalVideo.srcObject = null;
- 134 });
- 135 }
- 136
- 137
- 138 // 通过webrtc-streamer工具显示视频
- 139 setupVideoStreams();
- 140
- 141 // 页面卸载时清理资源----通过页面事件监听
- 142 window.addEventListener('beforeunload', function () {
- 143 // webrtcInstances.forEach(instance => {
- 144 // instance.instance.disconnect();//实例断开连接
- 145 // });
- 146 //------修订完善
- 147 globalWebrtcInstances.forEach(instance => {
- 148 if (instance && instance.instance) {
- 149 instance.instance.disconnect();
- 150 }
- 151 });
- 152
- 153 });
- 154
- 155
- 156
- 157 }
复制代码
3.2.3 模式二:websocketurl模式的处理
- 1 //websocket模式显示逻辑
- 2 function uswebsocketmode(url){
- 3 //websocket canvas div 待切换新结构梳理
- 4 // //下面包含4个视频区域
- 5 //
- 6 // <canvas ></canvas>
- 7 //
- 8 //
- 9 //
- 10 // <canvas ></canvas>
- 11 //
- 12 //
- 13 //
- 14 // <canvas ></canvas>
- 15 //
- 16 //
- 17 //
- 18 // <canvas ></canvas>
- 19 //
- 20 //
- 21 //
- 22
- 23 //原有老结构
- 24 //
- 25 //
- 26 //
- 27 // <video autoplay muted></video>
- 28 //
- 29 //
- 30 //
- 31 // <video autoplay muted></video>
- 32 //
- 33 //
- 34 //
- 35 // <video autoplay muted></video>
- 36 //
- 37 //
- 38 //
- 39 // <video autoplay muted></video>
- 40 //
- 41 //
- 42 //
- 43
- 44
- 45 const modal = document.getElementById('videoModal');//视频弹出框 //--------公共操作变量
- 46 const modalCameraLabel = document.querySelector('.modal-camera-label');//弹出框底部显示视频名称标识
- 47 var modalcanvas = null;
- 48 var modalctx = null;
- 49 //var cameraId = null;
- 50 var currentModalCameraId = null; // 当前弹出框显示的摄像头ID
- 51
- 52
- 53 const videoContainers = document.querySelectorAll(".video-container");//获取4个.video-container视频区域元素
- 54 //1- 先清掉原有默认页面的div结构内的元素,构建新的canvas元素
- 55 //依次进行替换
- 56 videoContainers.forEach(
- 57 container => {
- 58 //查找原有的<video>元素
- 59 const videoElement = container.querySelector(".video-stream");
- 60 if (videoElement) {
- 61
- 62 //创建<canvas>元素
- 63 const canvas = document.createElement("canvas");
- 64 canvas.className = 'videoCanvas';
- 65 // canvas.width = 320; //设置默认尺寸,即图片的分辨率、画布分辨率(和容器大小没有关系,最终都会在指定容器100%显示)
- 66 // canvas.height = 240;
- 67 //以上配置不能自动充满div区域
- 68
- 69
- 70 // // 根据容器大小动态设置,但保持最小分辨率
- 71 // const containerRect = container.getBoundingClientRect();
- 72 // canvas.width = Math.max(containerRect.width || 320, 160);
- 73 // canvas.height = Math.max(containerRect.height || 240, 120);
- 74
- 75 // 自适应容器尺寸:填满容器
- 76
- 77 const rect = container.getBoundingClientRect();
- 78 canvas.width = Math.max(1, Math.floor(rect.width));
- 79 canvas.height = Math.max(1, Math.floor(rect.height));
- 80
- 81
- 82 //用<canvas>元素替换<video>元素 --- 通过获取<video>元素的父节点,来将<video>替换为<canvas>
- 83 videoElement.parentNode.replaceChild(canvas, videoElement);
- 84 }
- 85 }
- 86 );
- 87
- 88 //2- 初始化canvas基础信息
- 89 var canvasElementArr = [];
- 90 var ctx = [];
- 91 var canvasElements = document.querySelectorAll(".videoCanvas");//获取到所有<canvas> //注意元素是4个,但是后台返回的不一定是4个
- 92 canvasElements.forEach((canvas, index) => {
- 93 //注意元素是4个,但是后台返回的不一定是4个。只需要根据后端返回的图片流地址个数,按需及可 (后台若超过4个,则只操作前4个)
- 94 if ( index < url.length) {
- 95 canvasElementArr[index] = canvas;
- 96
- 97 ctx[index] = canvas.getContext('2d');
- 98 //绘制初始状态 ---似乎没什么用
- 99 ctx[index].fillStyle = '#333';
- 100 ctx[index].fillRect(0, 0, canvas.width, canvas.height);
- 101 ctx[index].fillStyle = 'white';
- 102 ctx[index].font = '24px Arial'
- 103 ctx[index].textAlign = 'center';
- 104 ctx[index].fillText('正在连接...', canvas.width / 2, canvas.height / 2);
- 105
- 106 console.log("ctx["+index+"]",ctx[index]);
- 107 }
- 108 })
- 109
- 110 //3- 构建帧展示逻辑 ---- 若干个区域同时接收图片帧,要考虑异步和实时性
- 111 function displayFrame(blob,ctx,canvas){
- 112
- 113 //追加:--检查参数有效性
- 114 if (!blob || !ctx || !canvas) {
- 115 console.warn('displayFrame: 无效参数');
- 116 return;
- 117 }
- 118
- 119 const img = new Image();
- 120
- 121 //追加:--设置超时机制,防止图片加载卡死
- 122 const loadTimeout = setTimeout(() => {
- 123 console.warn('图片加载超时');
- 124 if (img.src) {
- 125 URL.revokeObjectURL(img.src);
- 126 }
- 127 img.onload = null;
- 128 img.onerror = null;
- 129 }, 2000); // 2秒超时
- 130
- 131 // 将超时定时器添加到全局管理数组
- 132 globalTimeouts.push(loadTimeout);
- 133
- 134
- 135 // img.onload = function(){//回调函数
- 136 // //1.先清除画布信息
- 137 // ctx.clearRect(0,0,canvas.width,canvas.height);
- 138
- 139 // //2.计算缩放比
- 140 // const scale = Math.min(canvas.width/img.width,canvas.height/img.height);
- 141 // const x = (canvas.width - img.width * scale)/2;
- 142 // const y = (canvas.height - img.height * scale)/2;
- 143
- 144 // //3.绘制图片在画布
- 145 // ctx.drawImage(img,x,y,img.width*scale,img.height*scale);
- 146
- 147 // //4.将图像引用取消
- 148 // URL.revokeObjectURL(img.src);
- 149 // };
- 150
- 151 // //补充图片的加载失败异常事件逻辑
- 152 // img.onerror = function () {
- 153 // console.error('图片帧函数----图片加载失败');
- 154 // ctx.fillStyle = '#ff4d4f';
- 155 // ctx.fillRect(0, 0, canvas.width, canvas.height);
- 156 // ctx.fillStyle = 'white';
- 157 // ctx.font = '14px Arial';
- 158 // ctx.textAlign = 'center';
- 159 // ctx.fillText('图片加载失败', canvas.width / 2, canvas.height / 2);
- 160 // };
- 161
- 162 //修复:内存管理
- 163 //--------------重新定义onload事件和onerror事件
- 164 const onLoadHandler = function(){
- 165
- 166 //追加: --0. 清理超时定时器
- 167 clearTimeout(loadTimeout);
- 168 try {
- 169 //1.先清除画布信息
- 170 ctx.clearRect(0, 0, canvas.width, canvas.height);
- 171
- 172 //2.计算缩放比
- 173 const scale = Math.min(canvas.width / img.width, canvas.height / img.height);
- 174 const x = (canvas.width - img.width * scale) / 2;
- 175 const y = (canvas.height - img.height * scale) / 2;
- 176
- 177 //3.绘制图片在画布
- 178 ctx.drawImage(img, x, y, img.width * scale, img.height * scale);
- 179
- 180 } catch (error) {
- 181 console.error('绘制图片时出错:', error);
- 182 } finally {
- 183 //4.清理资源
- 184 URL.revokeObjectURL(img.src);
- 185 img.onload = null;
- 186 img.onerror = null;
- 187 img.src = ''; // 清空src引用
- 188 }
- 189
- 190 };
- 191
- 192 const onErrorHandler = function(){
- 193
- 194 // 清理超时定时器
- 195 clearTimeout(loadTimeout);
- 196
- 197 console.error('图片帧函数----图片加载失败');
- 198
- 199 try {
- 200 ctx.fillStyle = '#ff4d4f';
- 201 ctx.fillRect(0, 0, canvas.width, canvas.height);
- 202 ctx.fillStyle = 'white';
- 203 ctx.font = '14px Arial';
- 204 ctx.textAlign = 'center';
- 205 ctx.fillText('图片加载失败', canvas.width / 2, canvas.height / 2);
- 206 } catch (error) {
- 207 console.error('绘制错误状态时出错:', error);
- 208 } finally {
- 209 //清理资源
- 210 URL.revokeObjectURL(img.src);
- 211 img.onload = null;
- 212 img.onerror = null;
- 213 img.src = ''; // 清空src引用
- 214 }
- 215
- 216 };
- 217
- 218 img.onload = onLoadHandler;//配合内部的资源管理
- 219 img.onerror = onErrorHandler;//配合内部的资源管理
- 220 img.src = URL.createObjectURL(blob);
- 221
- 222 }
- 223
- 224
- 225 //4- 构建网页客户端连接WebSocket服务端
- 226 //注意会有多个websocket(每个独立的socket连接一个摄像头数据(一个机器人有1-多个摄像头))
- 227
- 228 var ws=[];//用于存储websocket连接实例(网页客户端连接服务端)
- 229 function connectWebSocket(){
- 230 // 实例化websocket,并配置特有的官方监听事件
- 231 url.forEach((urlitem,index)=>{
- 232
- 233 // //0 检查是否已连接
- 234 // if(ws[index] && ws[index].readyState === WebSocket.OPEN){
- 235 // console.log(`WebSocket[${index}]已经连接,跳过重复连接`);
- 236 // return;
- 237 // }
- 238
- 239 //0 严格检查并清理已存在的连接
- 240 if (ws[index]) {
- 241 if (ws[index].readyState === WebSocket.OPEN || ws[index].readyState === WebSocket.CONNECTING) {
- 242 console.log(`WebSocket[${index}]已经连接或正在连接,跳过重复连接`);
- 243 return;
- 244 } else {
- 245 // 清理无效连接
- 246 try {
- 247 ws[index].close();
- 248 ws[index] = null;
- 249 } catch (e) {
- 250 console.log(`清理无效连接时出错: ${e.message}`);
- 251 }
- 252 }
- 253 }
- 254
- 255 try {
- 256 // 1 实例化
- 257 ws[index] = new WebSocket(urlitem);
- 258 globalWebSocketInstances[index] = ws[index];
- 259
- 260 // 2 配置监听事件
- 261 //-------- 2.1 onopen事件
- 262 ws[index].onopen = function () {
- 263 console.log("ws[" + index + "]:" + urlitem + "连接已建立,开始监听服务端WebSocket数据");
- 264 //showConnectionStatus(`摄像头${index + 1}连接成功`, 'success');//后面换vue框架自带的信息提醒框!
- 265 reconnectAttempts[index] = 0; //重置重连计数
- 266 }
- 267
- 268 //-------- 2.2 onmessage事件---核心事件
- 269 ws[index].onmessage = function (event) {
- 270 if (event.data instanceof Blob) {
- 271 //displayFrame(event.data,ctx[index]);//调用帧显示函数----[将帧显示在对应的canvas区域] function displayFrame(blob,ctx) canvasElementArr
- 272 displayFrame(event.data, ctx[index], canvasElementArr[index]);//始终小窗口需要渲染
- 273
- 274 // 如果当前索引与弹出框显示的摄像头索引匹配,且弹出框正在显示,则同时渲染弹出框
- 275 if (currentModalCameraId && (index === currentModalCameraId - 1) && modal && modal.style.display === 'flex' && modalctx && modalcanvas) {
- 276 displayFrame(event.data, modalctx, modalcanvas);
- 277 }
- 278 }
- 279 }
- 280
- 281 //-------- 2.3 onclose事件
- 282 ws[index].onclose = function (event) {
- 283 console.log("ws[" + index + "]:" + urlitem + "连接已关闭", event.code, event.reason);
- 284
- 285 //------------补充:自动重连逻辑
- 286 if (!reconnectAttempts[index]) reconnectAttempts[index] = 0;
- 287
- 288 if (reconnectAttempts[index] < MAX_RECONNECT_ATTEMPTS) {
- 289 reconnectAttempts[index]++;
- 290 showConnectionStatus(`摄像头${index + 1}重连中(${reconnectAttempts[index]}/${MAX_RECONNECT_ATTEMPTS})`, 'warning');////后续调用vue自身方法
- 291
- 292 //补充
- 293 // 清理该连接的旧定时器
- 294 if (reconnectTimeouts[index]) {
- 295 clearTimeout(reconnectTimeouts[index]);
- 296 }
- 297
- 298 const timeoutid = setTimeout(() => {
- 299 console.log(`尝试重连ws[${index}], 第${reconnectAttempts[index]}次`);
- 300
- 301 //补充
- 302 // 清理连接状态
- 303 if (ws[index]) {
- 304 try {
- 305 ws[index].close();
- 306 } catch (e) { }
- 307 ws[index] = null;
- 308 }
- 309 connectSingleWebSocket(urlitem, index);
- 310 //补充
- 311 reconnectTimeouts[index] = null;
- 312 }, RECONNECT_DELAY);
- 313
- 314 //追加内存管理
- 315 globalTimeouts.push(timeoutid);
- 316 reconnectTimeouts[index] = timeoutid;
- 317
- 318 } else {
- 319 showConnectionStatus(`摄像头${index + 1}连接失败`, 'error');////后续调用vue自身方法
- 320 //显示连接失败状态
- 321 if (ctx[index] && canvasElementArr[index]) {
- 322 ctx[index].fillStyle = '#ff4d4f';
- 323 ctx[index].fillRect(0, 0, canvasElementArr[index].width, canvasElementArr[index].height);
- 324 ctx[index].fillStyle = 'white';
- 325 ctx[index].font = '14px Arial';
- 326 ctx[index].textAlign = 'center';
- 327 ctx[index].fillText('ws[index].onclose事件连接失败', canvasElementArr[index].width / 2, canvasElementArr[index].height / 2);
- 328 }
- 329 }
- 330 };//onclose事件
- 331
- 332 //-------- 2.4 onerror事件
- 333 ws[index].onerror = function (error) {
- 334 console.log("ws[" + index + "]:" + urlitem + "连接出现错误:" + error);
- 335 showConnectionStatus(`摄像头${index + 1}连接错误`, 'error');//后续调用vue自身方法
- 336 };//onerror事件
- 337 } catch (error) {
- 338 console.error(`创建WebSocket[${index}]失败:`, error);
- 339 showConnectionStatus(`摄像头${index + 1}创建失败`, 'error');
- 340 }
- 341 });
- 342 }
- 343
- 344 //5- 构建网页客户端断开连接WebSocket服务端
- 345 function disconnectWebSocket(){
- 346 if(ws){
- 347 ws.forEach((wsitem,index)=>{
- 348 if(wsitem){
- 349 wsitem.close();
- 350 ws[index] = null;//恢复初始状态
- 351 }
- 352 });
- 353 ws = [];//恢复暂存数组初始状态
- 354 }
- 355 }
- 356
- 357 //6- 执行连接函数调用 (最多内部连4个websocket)
- 358 connectWebSocket();
- 359
- 360 //7- 执行调用关闭
- 361 // 页面卸载时清理资源----通过页面事件监听
- 362 window.addEventListener('beforeunload', function () {
- 363 disconnectWebSocket();
- 364 });
- 365
- 366 //8- 视频区域div点击事件 弹出弹出框放大视频显示 ---- 弹出框<video>也需要替换为<canvas>!
- 367 videoContainers.forEach(container => {
- 368 container.addEventListener('click',function(){
- 369 //1 先把原有弹出框<video>修改为<canvas>
- 370 //原有结构参考
- 371 //
- 372 //
- 373 // <span >×</span>
- 374 // <video id="modalVideo" autoplay controls></video>
- 375 //
- 376 //
- 377 //
- 378
- 379 // ---1.1 先查找到要被替换元素本身
- 380 const videoelement = document.querySelector('#modalVideo');
- 381 if(videoelement){
- 382 // ---1.2 再创建一个新的替换元素
- 383 const popcanvas = document.createElement("canvas");
- 384 // ---1.3 新元素沿用原来的id--换个新的吧
- 385 //popcanvas.id = 'modalVideo';
- 386 popcanvas.id = 'modalCanvas';
- 387
- 388 // //补充:设置canvas内图片的分辨率
- 389 // popcanvas.width = 800;
- 390 // popcanvas.height = 600;
- 391 //以上匹配会导致画布不能充满div区域;
- 392
- 393 // 让弹出框canvas自适应弹窗区域
- 394 const modalContent = modal.querySelector('.modal-content') || modal;
- 395 const mrect = modalContent.getBoundingClientRect();
- 396 popcanvas.width = Math.max(1, Math.floor(mrect.width));
- 397 popcanvas.height = Math.max(1, Math.floor(mrect.height));
- 398 // ---1.4 通过被替换元素的直接父元素,将被替换元素替换为新元素
- 399 videoelement.parentNode.replaceChild(popcanvas,videoelement);
- 400
- 401 }
- 402
- 403 // //2 给弹出框内的新元素<canvas>设置基础配置:canvas、ctx
- 404 // var modalcanvas = document.getElementById('modalCanvas');
- 405 // var modalctx = modalcanvas.getContext('2d');
- 406 // modalctx.fillRect(0,0,modalcanvas.width,modalcanvas.height);
- 407 // modalctx.fillStyle = 'red';
- 408 // modalctx.font = '24px Arial';
- 409 // modalctx.textAlign = 'center';
- 410 // modalctx.fillText('等待连接...',modalcanvas.width/2,modalcanvas.height/2);
- 411 //--------------------------------------------------
- 412 //注意:--------以上这些代码可能后续调试需要放在下方if内部代码:modal.style.display = 'flex'; //视频弹出框整体div显示下方。因为没显示前操作canvas的width和height可能不起作用
- 413
- 414 //3 构建视频配置信息对象
- 415 const canvasElement = this.querySelector('.videoCanvas'); //被点击的canvas元素
- 416 //cameraId = this.getAttribute('data-camera');//获取被点击的视频div区域编号(注意,从1开始)
- 417 const clickedCameraId = this.getAttribute('data-camera');//获取被点击的视频div区域编号(注意,从1开始)
- 418 currentModalCameraId = clickedCameraId; // 更新当前弹出框显示的摄像头ID
- 419
- 420 // const modal = document.getElementById('videoModal');//视频弹出框
- 421 // const modalCameraLabel = document.querySelector('.modal-camera-label');//弹出框底部显示视频名称标识
- 422
- 423 //根据点击的下标,获取对应的已有的ws实例,执行图像渲染
- 424 //if (canvasElement && cameraId && ws[cameraId - 1] != null) {
- 425 if (canvasElement && clickedCameraId && ws[clickedCameraId - 1] != null) {
- 426
- 427 //modalCameraLabel.textContent = 'camera'+ cameraId; //显示视频编号名称
- 428 modalCameraLabel.textContent = 'camera'+ clickedCameraId; //显示视频编号名称
- 429 modal.style.display = 'flex'; //视频弹出框整体div显示
- 430
- 431 //上方外层移到此处
- 432 //给弹出框内的新元素<canvas>设置基础配置:canvas、ctx
- 433 modalcanvas = document.getElementById('modalCanvas');
- 434 modalctx = modalcanvas.getContext('2d');
- 435
- 436 //补充:画布自适应显示 监听窗口尺寸变化,保持弹窗canvas自适应
- 437 const resizeModalCanvas = () => {
- 438 const modalContent = modal.querySelector('.modal-content') || modal;
- 439 const mrect = modalContent.getBoundingClientRect();
- 440 const w = Math.max(1, Math.floor(mrect.width));
- 441 const h = Math.max(1, Math.floor(mrect.height));
- 442 if (modalcanvas.width !== w || modalcanvas.height !== h) {
- 443 modalcanvas.width = w;
- 444 modalcanvas.height = h;
- 445 }
- 446 };
- 447
- 448 //window.addEventListener('resize', resizeModalCanvas);
- 449 // 移除之前的resize监听器,避免重复添加
- 450 if (resizeHandler) {
- 451 window.removeEventListener('resize', resizeHandler);
- 452 }
- 453 resizeHandler = resizeModalCanvas;
- 454 window.addEventListener('resize', resizeHandler);
- 455 globalEventListeners.push({element: window, event: 'resize', handler: resizeHandler});
- 456
- 457 resizeModalCanvas();
- 458 modalctx.fillStyle = '#333';
- 459 modalctx.fillRect(0, 0, modalcanvas.width, modalcanvas.height);
- 460 modalctx.fillStyle = 'white';
- 461 modalctx.font = '20px Arial';
- 462 modalctx.textAlign = 'center';
- 463 modalctx.fillText('等待图像...', modalcanvas.width / 2, modalcanvas.height / 2);
- 464
- 465 //canvas 图片帧显示
- 466 //ws[cameraId - 1].onmessage = function (event) {
- 467 //修改为同时渲染小窗口和弹出框
- 468 ws[clickedCameraId - 1].onmessage = function (event) {
- 469 if (event.data instanceof Blob) {
- 470 //displayFrame(event.data, modalctx, modalcanvas);//帧显示
- 471 // 始终渲染小窗口
- 472 displayFrame(event.data, ctx[clickedCameraId - 1], canvasElementArr[clickedCameraId - 1]);
- 473 // 如果弹出框显示且是当前摄像头,也渲染弹出框
- 474 if (modal.style.display === 'flex' && modalctx && modalcanvas && currentModalCameraId == clickedCameraId) {
- 475 displayFrame(event.data, modalctx, modalcanvas);
- 476 }
- 477 }
- 478 };
- 479
- 480 //console.log("ws["+(cameraId-1)+"]"+"弹出框放大显示已执行!");
- 481 console.log("ws["+(clickedCameraId-1)+"]"+"弹出框放大显示已执行!");
- 482 }
- 483 });
- 484 }
- 485 );
- 486
- 487 //9- 弹出框关闭按钮监听事件
- 488 const closeBtn = document.querySelector('.close-btn');//弹出框关闭按钮区域
- 489 if (closeBtn) {
- 490 // closeBtn.addEventListener('click', function () {
- 491 // modal.style.display = 'none';
- 492 // //-------- 9.1 恢复对应视频区域小窗口的图片帧显示
- 493 // if (cameraId != null && ws[cameraId-1]) {
- 494 // ws[cameraId - 1].onmessage = function (event) {//重新覆盖onmessage事件,在小窗口上渲染图片帧
- 495 // if (event.data instanceof Blob) {
- 496 // displayFrame(event.data, ctx[cameraId - 1], canvasElementArr[cameraId - 1]);
- 497 // }
- 498 // };
- 499 // }
- 500 //优化以上内容
- 501 // 移除之前的click事件监听器,避免重复添加
- 502 const existingListeners = globalEventListeners.filter(item =>
- 503 item.element === closeBtn && item.event === 'click'
- 504 );
- 505 existingListeners.forEach(item => {
- 506 item.element.removeEventListener(item.event, item.handler);
- 507 });
- 508
- 509 // 定义新的事件处理函数
- 510 const closeBtnHandler = function () {
- 511 modal.style.display = 'none';
- 512 // //-------- 9.1 恢复对应视频区域小窗口的图片帧显示
- 513 // if (cameraId != null && ws[cameraId - 1]) {
- 514 // ws[cameraId - 1].onmessage = function (event) {//重新覆盖onmessage事件,在小窗口上渲染图片帧
- 515 // if (event.data instanceof Blob) {
- 516 // displayFrame(event.data, ctx[cameraId - 1], canvasElementArr[cameraId - 1]);
- 517 // }
- 518 // };
- 519 // }
- 520 //以上内容不需要特殊恢复了,因为迭代代码后,再弹出弹出框的时候,也是一直保证小窗口也在显示的
- 521
- 522 //-------- 9.2 清除弹出框canvas的图片帧显示
- 523 if (modalctx != null && modalcanvas != null) {
- 524 modalctx.clearRect(0, 0, modalcanvas.width, modalcanvas.height);
- 525 }
- 526
- 527 // 重置弹出框相关变量
- 528 modalcanvas = null;
- 529 modalctx = null;
- 530 currentModalCameraId = null; // 清除当前弹出框摄像头ID
- 531
- 532 //});
- 533
- 534 // 移除resize事件监听器
- 535 if (resizeHandler) {
- 536 window.removeEventListener('resize', resizeHandler);
- 537 // 从全局列表中移除
- 538 const index = globalEventListeners.findIndex(item =>
- 539 item.element === window && item.event === 'resize' && item.handler === resizeHandler
- 540 );
- 541 if (index !== -1) {
- 542 globalEventListeners.splice(index, 1);
- 543 }
- 544 resizeHandler = null;
- 545 }
- 546 };
- 547
- 548 // 添加新的事件监听器并记录
- 549 closeBtn.addEventListener('click', closeBtnHandler);
- 550 globalEventListeners.push({ element: closeBtn, event: 'click', handler: closeBtnHandler });
- 551 }
- 552
- 553 //}
- 554
- 555 //追加: 10- 内存优化管理
- 556 // ------------10.1 页面可见性变化时的资源管理
- 557 document.addEventListener('visibilitychange', function () {
- 558 if (document.hidden) {
- 559 // 页面切换到后台时,清理资源但不断开连接
- 560 console.log('页面切换到后台,清理部分资源');
- 561
- 562 // 清理定时器
- 563 globalTimeouts.forEach(timeoutId => {
- 564 clearTimeout(timeoutId);
- 565 });
- 566 globalTimeouts = [];
- 567
- 568 // 清理状态提示元素
- 569 const statusElement = document.getElementById('connection-status');
- 570 if (statusElement) {
- 571 statusElement.remove();
- 572 }
- 573 } else {
- 574 // 页面重新可见时
- 575 console.log('页面重新可见');
- 576 }
- 577 });
- 578
- 579 // ------------10.2 页面失去焦点时的额外清理
- 580 window.addEventListener('blur', function () {
- 581 // 清理可能残留的定时器
- 582 globalTimeouts.forEach(timeoutId => {
- 583 clearTimeout(timeoutId);
- 584 });
- 585 globalTimeouts = [];
- 586 });
- 587
- 588 }
复制代码 3.2.4 其他辅助变量及函数- 1 //图片帧兼容方案
- 2 //全局变量用于资源管理
- 3 let globalWebrtcInstances = [];
- 4 let globalWebSocketInstances = [];
- 5 let reconnectAttempts = {};
- 6 const MAX_RECONNECT_ATTEMPTS = 3;
- 7 const RECONNECT_DELAY = 2000;
- 8
- 9 //新增修复:修复图片帧方式显示浏览器内存持续增长问题 -----全局定时器和事件监听器管理
- 10 var globalTimeouts = [];
- 11 var globalEventListeners = [];
- 12 var resizeHandler = null;
- 13 var reconnectTimeouts = []; // 管理重连定时器
- 14
- 15 //清理之前的连接资源
- 16 function cleanupPreviousConnections() {
- 17 //清理WebRTC连接
- 18 globalWebrtcInstances.forEach(instance => {
- 19 if (instance && instance.instance) {
- 20 instance.instance.disconnect();
- 21 }
- 22 });
- 23 globalWebrtcInstances = [];
- 24
- 25 //清理WebSocket连接
- 26 globalWebSocketInstances.forEach((ws, index) => {
- 27 if (ws && ws.readyState === WebSocket.OPEN) {
- 28 ws.close();
- 29 }
- 30 });
- 31 globalWebSocketInstances = [];
- 32
- 33 //-------------------------------------------追加补充部分---开始------------------------------------------------
- 34 //清理定时器
- 35 globalTimeouts.forEach(timeoutId => {
- 36 clearTimeout(timeoutId);
- 37 });
- 38 globalTimeouts = [];
- 39
- 40 //清理事件监听器
- 41 globalEventListeners.forEach(({ element, event, handler }) => {
- 42 element.removeEventListener(event, handler);
- 43 });
- 44 globalEventListeners = [];
- 45
- 46 //清理resize监听器
- 47 if (resizeHandler) {
- 48 window.removeEventListener('resize', resizeHandler);
- 49 resizeHandler = null;
- 50 }
- 51
- 52 //清理状态提示元素
- 53 const statusElement = document.getElementById('connection-status');
- 54 if (statusElement) {
- 55 statusElement.remove();
- 56 }
- 57 //-------------------------------------------追加补充部分---结束------------------------------------------------
- 58
- 59 //重置重连计数
- 60 reconnectAttempts = {};
- 61
- 62 //console.log('已清理所有之前的连接资源');
- 63 console.log('已清理所有之前的连接资源、定时器和事件监听器');
- 64 }
- 65
- 66 //单个WebSocket重连函数
- 67 function connectSingleWebSocket(urlitem, index) {
- 68 try {
- 69 ws[index] = new WebSocket(urlitem);
- 70 globalWebSocketInstances[index] = ws[index];
- 71
- 72 //重新绑定事件(复用上面的逻辑)
- 73 ws[index].onopen = function () {
- 74 console.log(`ws[${index}]:${urlitem} 重连成功`);
- 75 //showConnectionStatus(`摄像头${index + 1}重连成功`, 'success');
- 76 reconnectAttempts[index] = 0;
- 77 };
- 78
- 79 ws[index].onmessage = function (event) {
- 80 if (event.data instanceof Blob) {
- 81 // 始终渲染小窗口
- 82 displayFrame(event.data, ctx[index], canvasElementArr[index]);
- 83
- 84 // 如果当前索引与弹出框显示的摄像头索引匹配,且弹出框正在显示,则同时渲染弹出框
- 85 if (currentModalCameraId && (index === currentModalCameraId - 1) && modal && modal.style.display === 'flex' && modalctx && modalcanvas) {
- 86 displayFrame(event.data, modalctx, modalcanvas);
- 87 }
- 88
- 89 }
- 90 };
- 91
- 92 //重连的onclose和onerror事件处理与初始连接相同
- 93 ws[index].onclose = function (event) {
- 94 console.log(`ws[${index}] 重连后又关闭了`);
- 95 if (reconnectAttempts[index] < MAX_RECONNECT_ATTEMPTS) {
- 96 reconnectAttempts[index]++;
- 97 setTimeout(() => connectSingleWebSocket(urlitem, index), RECONNECT_DELAY);
- 98 }
- 99 };
- 100
- 101 ws[index].onerror = function (error) {
- 102 console.error(`ws[${index}] 重连错误:`, error);
- 103 };
- 104
- 105 } catch (error) {
- 106 console.error(`重连WebSocket[${index}]失败:`, error);
- 107 }
- 108 }
复制代码
4 模拟数据集构建(视频切割成图片帧 25fps)
此环节是通过ffmpeg命令,将一个视频按照指定的帧率切割成一张张帧图片,以作为本地模拟服务端程序的模拟图片帧数据源。具体操作步骤命令可参考之前博文:https://www.cnblogs.com/Jesuslovesme/p/18818356
5 模拟websocket服务端程序编写
这个可根据个人擅长的开发语言编写,因为我主要是为了验证前端显示方案是否可以落地,所以后端程序只要能按一定频率取本地的帧图片并实时通过websocket发送给前端显示即可。我通过ai生成了一个验证测试的C#后端程序。
基于.NET 6.0的控制台应用程序代码如下:
- using Fleck;
- using System.Text.Json;
- using System.Collections.Concurrent;
- using System.Net;
- using System.Net.Http;
- using System.Text;
- namespace WebSocketServerApp
- {
- public class Program
- {
- private static readonly ConcurrentDictionary<string, List<IWebSocketConnection>> _connections = new();
- private static readonly ConcurrentDictionary<string, CancellationTokenSource> _cancellationTokens = new();
- private static string _imagePath = @"D:\XX中心\总控系统项目\测试demo\图片帧demo\dash-video-to-img";//图片帧文件夹存放位置
-
- public static async Task Main(string[] args)
- {
- // 启动HTTP服务器------路线1
- var httpTask = StartHttpServer();//用于处理前端的模式请求确认
-
- // 启动WebSocket服务器 ----路线2
- var wsTask = StartWebSocketServer(); //对接网页websocket,传输图片帧
-
- Console.WriteLine("=== WebSocket服务器启动完成 ===");
- Console.WriteLine("HTTP API服务: http://localhost:8080");
- Console.WriteLine("WebSocket服务: ws://localhost:8081");
- Console.WriteLine("");
- Console.WriteLine("测试URL:");
- Console.WriteLine("- RTSP模式: http://localhost:8080/api/usdisplay?usid=2");
- Console.WriteLine("- WebSocket模式: http://localhost:8080/api/usdisplay?usid=3");
- Console.WriteLine("- WebSocket连接: ws://localhost:8081/api/websocket?usid=3&cam=0");
- Console.WriteLine("");
- Console.WriteLine("按 Ctrl+C 停止服务器");
-
- // 等待两个服务器
- await Task.WhenAll(httpTask, wsTask);
- }
- //异步函数 启动HTTP服务器
- private static async Task StartHttpServer()
- {
- //1.
- var listener = new HttpListener();
- // 绑定到localhost与127.0.0.1,避免因Host不匹配导致返回系统400且无CORS头
- //2.
- listener.Prefixes.Add("http://localhost:8080/");
- listener.Prefixes.Add("http://127.0.0.1:8080/");
- // 如需对外访问,可尝试开启以下通配符(需要管理员权限并配置urlacl)
- // listener.Prefixes.Add("http://+:8080/");
- //3.
- listener.Start();
-
- Console.WriteLine("HTTP服务器已启动: http://localhost:8080 与 http://127.0.0.1:8080");
-
- //4.持续监控
- while (true)
- {
- try
- {
- //5.获取访问请求上下文
- var context = await listener.GetContextAsync(); //等待一个即将到来的请求操作
- _ = Task.Run(() => HandleHttpRequest(context));//开启一个线程,处理http请求
- }
- catch (Exception ex)
- {
- Console.WriteLine($"HTTP服务器错误: {ex.Message}");
- }
- }
- }
-
- //处理http请求
- private static async Task HandleHttpRequest(HttpListenerContext context)
- {
- try
- {
- var request = context.Request;//请求上下文的客户端request
- var response = context.Response;//请求上下文的服务端response
- // 设置反馈的CORS头
- response.Headers.Add("Access-Control-Allow-Origin", "*");
- response.Headers.Add("Access-Control-Allow-Methods", "GET, POST, OPTIONS, PUT, DELETE");
- response.Headers.Add("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With, Accept, Origin");
- response.Headers.Add("Access-Control-Allow-Credentials", "true");
- response.Headers.Add("Access-Control-Max-Age", "86400");
-
- if (request.HttpMethod == "OPTIONS")
- {
- response.StatusCode = 200;
- response.Close();
- return;
- }
- //如果请求url不为空,且绝对地址为"/api/usdisplay"
- if (request.Url?.AbsolutePath == "/api/usdisplay")
- {
- var usid = request.QueryString["usid"];//获取到查询参数usid的值
- if (string.IsNullOrEmpty(usid))
- {
- response.StatusCode = 400;
- var errorBytes = Encoding.UTF8.GetBytes("Missing usid parameter");
- await response.OutputStream.WriteAsync(errorBytes, 0, errorBytes.Length);
- }
- else
- {
- //构建模式反馈json (两种模式,反馈的json模板不一样)
- var configResponse = GetDisplayConfig(usid);
- var jsonResponse = JsonSerializer.Serialize(configResponse);
- var responseBytes = Encoding.UTF8.GetBytes(jsonResponse);
-
- response.ContentType = "application/json";//设置返回数据类型
- response.StatusCode = 200;//设置返回状态码
- await response.OutputStream.WriteAsync(responseBytes, 0, responseBytes.Length);
- }
- }
- else
- {
- response.StatusCode = 404;
- var notFoundBytes = Encoding.UTF8.GetBytes("Not Found");
- await response.OutputStream.WriteAsync(notFoundBytes, 0, notFoundBytes.Length);
- }
-
- response.Close();
- }
- catch (Exception ex)
- {
- Console.WriteLine($"处理HTTP请求错误: {ex.Message}");
- }
- }
-
- private static async Task StartWebSocketServer()
- {
- //创建websocket服务端
- var server = new Fleck.WebSocketServer("ws://0.0.0.0:8081");
-
- //服务端socket执行事件监听
- server.Start(socket =>
- {
- //网页端触发socket请求后
- socket.OnOpen = () =>
- {
- var query = ParseQuery(socket.ConnectionInfo.Path);//获取前端连接服务端的websocket地址(网页端websocket请求连接地址)
- var usid = query.GetValueOrDefault("usid", "");//获取websocket请求连接地址的usid参数值
- var cam = query.GetValueOrDefault("cam", "");//获取websocket请求连接地址的cam参数值
- var connectionKey = $"{usid}-{cam}";//自定义变量,存储连接信息{usid}-{cam}
- Console.WriteLine($"WebSocket连接建立: usid={usid}, cam={cam}, IP={socket.ConnectionInfo.ClientIpAddress}");
- //socket.ConnectionInfo.ClientIpAddress 请求连接的客户端ip
- // 添加连接到管理字典
- _connections.AddOrUpdate(connectionKey,
- new List<IWebSocketConnection> { socket },
- (key, list) => { list.Add(socket); return list; });
-
- // 开始发送图片帧
- StartSendingFrames(socket, usid, cam, connectionKey);
- };
-
- socket.OnClose = () =>
- {
- var query = ParseQuery(socket.ConnectionInfo.Path);
- var usid = query.GetValueOrDefault("usid", "");
- var cam = query.GetValueOrDefault("cam", "");
- var connectionKey = $"{usid}-{cam}";
-
- Console.WriteLine($"WebSocket连接关闭: usid={usid}, cam={cam}");
-
- try
- {
- // 从管理字典中移除连接
- if (_connections.TryGetValue(connectionKey, out var connections))
- {
- connections.Remove(socket);
- if (connections.Count == 0)
- {
- _connections.TryRemove(connectionKey, out _);
-
- // 停止发送任务并释放资源
- if (_cancellationTokens.TryRemove(connectionKey, out var cts))
- {
- cts.Cancel();
- cts.Dispose();
- Console.WriteLine($"已清理连接资源: {connectionKey}");
- }
- }
- }
-
- // 强制垃圾回收释放内存
- GC.Collect();
- GC.WaitForPendingFinalizers();
- }
- catch (Exception ex)
- {
- Console.WriteLine($"连接关闭时清理资源出错: {ex.Message}");
- }
- };
-
- socket.OnError = exception =>
- {
- Console.WriteLine($"WebSocket错误: {exception.Message}");
- };
- });
-
- // 保持服务器运行
- await Task.Delay(Timeout.Infinite);
- //await Task.Delay(Timeout.Infinite); 的意思是在一个异步方法里“无限等待”,也就是说这个任务永远不会完成(除非有外部中断或取消)。
- //这通常用于让一个后台任务保持运行状态、占位、或者在某些调试场景下阻止应用退出。
- }
- private static Dictionary<string, string> ParseQuery(string path)
- {
- var result = new Dictionary<string, string>();
-
- if (string.IsNullOrEmpty(path) || !path.Contains('?'))
- return result;
-
- var queryString = path.Split('?')[1];
- var pairs = queryString.Split('&');
-
- foreach (var pair in pairs)
- {
- var keyValue = pair.Split('=');
- if (keyValue.Length == 2)
- {
- result[keyValue[0]] = Uri.UnescapeDataString(keyValue[1]);
- }
- }
-
- return result;
- }
-
- private static void StartSendingFrames(IWebSocketConnection socket, string usid, string cam, string connectionKey)
- {
- // 检查是否已有任务在运行,如果有则先取消
- if (_cancellationTokens.TryGetValue(connectionKey, out var existingCts))
- {
- try
- {
- existingCts.Cancel();
- existingCts.Dispose();
- Console.WriteLine($"取消已存在的发送任务: usid={usid}, cam={cam}");
- }
- catch (Exception ex)
- {
- Console.WriteLine($"取消已存在任务时出错: {ex.Message}");
- }
- }
-
- var cts = new CancellationTokenSource();
- _cancellationTokens[connectionKey] = cts;
-
- // 使用ConfigureAwait(false)避免上下文切换开销
- Task.Run(async () =>
- {
- try
- {
- if (!Directory.Exists(_imagePath))
- {
- Console.WriteLine($"图片目录不存在: {_imagePath}");
- socket.Close();
- return;
- }
-
- // 优化:只获取文件路径,不读入内存
- var imageFiles = Directory.GetFiles(_imagePath, "*.jpg")
- .Concat(Directory.GetFiles(_imagePath, "*.jpeg"))
- .Concat(Directory.GetFiles(_imagePath, "*.png"))
- .OrderBy(f => f)
- .ToArray();
-
- if (imageFiles.Length == 0)
- {
- Console.WriteLine($"图片目录中没有找到图片文件: {_imagePath}");
- socket.Close();
- return;
- }
-
- Console.WriteLine($"摄像头{cam}开始发送图片帧,共{imageFiles.Length}个文件");
-
- var frameIndex = 0;
-
- while (!cts.Token.IsCancellationRequested && socket.IsAvailable)
- {
- var currentImageFile = imageFiles[frameIndex % imageFiles.Length];
-
- try
- {
- // 内存优化:使用using确保资源及时释放
- using (var fileStream = new FileStream(currentImageFile, FileMode.Open, FileAccess.Read))
- {
- var imageBytes = new byte[fileStream.Length];
- await fileStream.ReadAsync(imageBytes, 0, imageBytes.Length, cts.Token);
-
- // 立即发送后释放引用
- socket.Send(imageBytes);
- imageBytes = null; // 显式释放引用
- }
-
- Console.WriteLine($"发送图片帧: usid={usid}, cam={cam}, frame={frameIndex}, file={Path.GetFileName(currentImageFile)}");
-
- frameIndex++;
-
- // 降低帧率减少内存压力:改为10fps,即每100ms发送一帧
- await Task.Delay(40, cts.Token);
-
- // 每100帧强制垃圾回收一次
- if (frameIndex % 100 == 0)
- {
- GC.Collect();
- GC.WaitForPendingFinalizers();
- Console.WriteLine($"执行垃圾回收: frame={frameIndex}");
- }
- }
- catch (OperationCanceledException)
- {
- break;
- }
- catch (Exception ex)
- {
- Console.WriteLine($"发送图片帧时出错: {ex.Message}");
- break;
- }
- }
- }
- catch (Exception ex)
- {
- Console.WriteLine($"图片发送任务异常: {ex.Message}");
- }
- finally
- {
- Console.WriteLine($"停止发送图片帧: usid={usid}, cam={cam}");
- }
- }, cts.Token);
- }
-
- //获取模式配置的反馈json
- private static object GetDisplayConfig(string usid)
- {
- //模拟数据,模拟两个无人系统,每个1个模式
- switch (usid)
- {
- case "2":
- // RTSP模式
- return new
- {
- code = 200,
- success = true,
- data = new
- {
- mode = "rtspurl",
- url = new string[]
- {
- "rtsp://127.0.0.1:8081",
- "rtsp://127.0.0.1:8082",
- "rtsp://127.0.0.1:8083"
- }
- }
- };
-
- case "3":
- // WebSocket模式
- return new
- {
- code = 200,
- success = true,
- data = new
- {
- mode = "websocketurl",
- url = new string[]
- {
- "ws://127.0.0.1:8081/api/websocket?usid=3&cam=0",
- "ws://127.0.0.1:8081/api/websocket?usid=3&cam=1",
- "ws://127.0.0.1:8081/api/websocket?usid=3&cam=2"
- }
- }
- };
-
- default:
- return new
- {
- code = 404,
- success = false,
- message = "未找到指定的机器人配置"
- };
- }
- }
- }
- }
复制代码 View Code6 效果展示
3个小区域的图片帧显示:
点击任意一个小区域,弹出图片帧放大显示弹出框:
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |