通过Canvas在网页中将后端发来的一帧帧图片渲染成“视频”的实现过程

1.背景

最近有这样的场景,网页端需要显示现场无人系统(机器人)的摄像头数据(图片)。值得注意的是,一个无人系统(机器人)它身上可能挂载若干个摄像头,这若干个摄像头都需要在前端的若干个小区域内显示;另外不同的用户访问前端网页,每个用户都访问他自己想关注的无人系统(机器人)摄像头数据。而前端直接和现场的无人系统对接是不合适的:因为对于同一个无人系统,可能不同的用户同一时间或相近时间都访问它,导致该无人系统要处理反馈多份资源请求,并且很容易导致超过机器人的处理负荷;另外对于前端来讲,他并不知知道应该和现场的哪一个无人系统进行对接(因为前端并没有现场的无人系统相关身份数据,无法做识别)。

为此,设计了如下方案,现场的无人系统统一和数据中转服务器对接,每个机器人都只给一份实时摄像头数据给数据中转服务器。数据中转服务器建立websocket服务端程序,并处理网页端的请求(请求获取特定机器人的所有摄像头信息),数据中转服务器根据网页端的请求,对请求信息进行解析,并创建特定的websocket服务实例。具体通信示意图如下:

通过Canvas在网页中将后端发来的一帧帧图片渲染成“视频”的实现过程

 这里所提到的前端网页,实际是业务中的可视化大屏,他对之前项目的已有功能有些注意点:

  • 总控大屏现有对接无人系统的视频使用的是后端发给前端的rtsp流地址,默认使用的是该方式。但后续无人系统(机器人)传输的数据也有可能是一帧帧二进制图片数据
  • 原有前端使用的组件适用接收rtsp流方式,不适用新的接收图片帧的方式,前端需要做两套模式区分(区别开发:一套<video>,一套<canvas>
  • 在无人系统(机器人)传输的数据是一帧帧二进制图片数据的情况下,有可能该无人系统有多个摄像头,它会传输多组独立的图片帧数据(前端最多支持4个摄像头数据)

2.约定接口

针对以上内容进行分析,并为了兼容已有实现的功能,约定如下大屏与数据中转器的接口方式:

网页端通过GET请求,调用数据中转服务器接口,请求接口地址为:

http://ip:port/api/usdisplay?usid=2 。其中请求参数usid代表前端给数据中转服务器(后端)传递的无人系统id.

数据中转服务器需要根据无人系统id,判断该无人系统摄像头数据传递是使用的哪种方式?并根据特定的方式返回前端结果,前端根据不同的模式,执行不同的渲染方式。

数据中转服务器(后端)返回前端的结果格式为:

 

  • rtsp模式,如果一个无人系统有3个摄像头举例

 

 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     <div class="chartarea">  2             <div class="charttitle"><span>态势总览</span></div>  3             <div class="chartdata" id="videoGrid">  4               <!-- 四个视频区域 -->  5               <div class="video-container" data-camera="1">  6                 <video class="video-stream" autoplay muted></video>  7                 <div class="camera-label"></div>  8               </div>  9               <div class="video-container" data-camera="2"> 10                 <video class="video-stream" autoplay muted></video> 11                 <div class="camera-label"></div> 12               </div> 13               <div class="video-container" data-camera="3"> 14                 <video class="video-stream" autoplay muted></video> 15                 <div class="camera-label"></div> 16               </div> 17               <div class="video-container" data-camera="4"> 18                 <video class="video-stream" autoplay muted></video> 19                 <div class="camera-label"></div> 20               </div> 21             </div> 22           </div>

主要是在一个区域内预先占用4个小区域,每个小区域用于显示同一个无人系统的一个摄像头信息,最多支持显示同一个无人系统的4个摄像头信息(实际显示其中的1-4个小区域是以实际同一个无人系统的摄像头个数而定的)。

以上的html结构最先是为了支持rtsp视频流而设计的,对于当前的图片帧显示使用的Canvas技术不适用,所以如果是在图片帧显示的模式下,后续需要通过js动态的修改html结果,切换为<canvas>相关标签结构。

以上现有的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           <div id="videoModal" class="modal"> 3             <div class="modal-content"> 4               <span class="close-btn">&times;</span> 5               <video id="modalVideo" autoplay controls></video> 6               <div class="modal-camera-label"></div> 7             </div> 8           </div>

 

 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     // <div class="video-container" data-camera="1">  29     //   <video class="video-stream" autoplay muted></video>  30     //   <div class="camera-label"></div>  31     // </div>  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     <div style="color:white;display:flex;justify-content:center;align-items:center;height:100%;position:absolute;top:0;left:0;right:0;bottom:0;">  97       视频流无法加载  98     </div>  99   `; 100     //showConnectionStatus('视频流连接失败', 'error'); 101  102   } 103  104   // 监听每个视频区域div的用户点击事件 105   //每个视频div结构如下: 106   // <div class="video-container" data-camera="1"> 107   //   <video class="video-stream" autoplay muted></video> 108   //   <div class="camera-label"></div> 109   // </div> 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   // <div class="chartdata" id="videoGrid">//下面包含4个视频区域   5     // <div class="video-container" data-camera="1">   6     //   <canvas class="videoCanvas"></canvas>   7     //   <div class="camera-label"></div>   8     // </div>   9     // <div class="video-container" data-camera="2">  10     //   <canvas class="videoCanvas"></canvas>  11     //   <div class="camera-label"></div>  12     // </div>  13     // <div class="video-container" data-camera="3">  14     //   <canvas class="videoCanvas"></canvas>  15     //   <div class="camera-label"></div>  16     // </div>  17     // <div class="video-container" data-camera="4">  18     //   <canvas class="videoCanvas"></canvas>  19     //   <div class="camera-label"></div>  20     // </div>  21   // </div>  22   23   //原有老结构  24   // <div class="chartdata" id="videoGrid">  25   //   <!-- 四个视频区域 -->  26   //   <div class="video-container" data-camera="1">  27   //     <video class="video-stream" autoplay muted></video>  28   //     <div class="camera-label"></div>  29   //   </div>  30   //   <div class="video-container" data-camera="2">  31   //     <video class="video-stream" autoplay muted></video>  32   //     <div class="camera-label"></div>  33   //   </div>  34   //   <div class="video-container" data-camera="3">  35   //     <video class="video-stream" autoplay muted></video>  36   //     <div class="camera-label"></div>  37   //   </div>  38   //   <div class="video-container" data-camera="4">  39   //     <video class="video-stream" autoplay muted></video>  40   //     <div class="camera-label"></div>  41   //   </div>  42   // </div>  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       // <div id="videoModal" class="modal"> 372       //   <div class="modal-content"> 373       //     <span class="close-btn">&times;</span> 374       //     <video id="modalVideo" autoplay controls></video> 375       //     <div class="modal-camera-label"></div> 376       //   </div> 377       // </div> 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的控制台应用程序代码如下:

通过Canvas在网页中将后端发来的一帧帧图片渲染成“视频”的实现过程

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图片帧demodash-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 Code

6 效果展示

 3个小区域的图片帧显示:

通过Canvas在网页中将后端发来的一帧帧图片渲染成“视频”的实现过程

点击任意一个小区域,弹出图片帧放大显示弹出框:

通过Canvas在网页中将后端发来的一帧帧图片渲染成“视频”的实现过程

 

发表评论

评论已关闭。

相关文章