通過Canvas在網頁中將后端發來的一幀幀圖片渲染成“視頻”的實現過程
1.背景
最近有這樣的場景,網頁端需要顯示現場無人系統(機器人)的攝像頭數據(圖片)。值得注意的是,一個無人系統(機器人)它身上可能掛載若干個攝像頭,這若干個攝像頭都需要在前端的若干個小區域內顯示;另外不同的用戶訪問前端網頁,每個用戶都訪問他自己想關注的無人系統(機器人)攝像頭數據。而前端直接和現場的無人系統對接是不合適的:因為對于同一個無人系統,可能不同的用戶同一時間或相近時間都訪問它,導致該無人系統要處理反饋多份資源請求,并且很容易導致超過機器人的處理負荷;另外對于前端來講,他并不知知道應該和現場的哪一個無人系統進行對接(因為前端并沒有現場的無人系統相關身份數據,無法做識別)。
為此,設計了如下方案,現場的無人系統統一和數據中轉服務器對接,每個機器人都只給一份實時攝像頭數據給數據中轉服務器。數據中轉服務器建立websocket服務端程序,并處理網頁端的請求(請求獲取特定機器人的所有攝像頭信息),數據中轉服務器根據網頁端的請求,對請求信息進行解析,并創建特定的websocket服務實例。具體通信示意圖如下:

這里所提到的前端網頁,實際是業務中的可視化大屏,他對之前項目的已有功能有些注意點:
- 總控大屏現有對接無人系統的視頻使用的是后端發給前端的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">×</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">×</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命令,將一個視頻按照指定的幀率切割成一張張幀圖片,以作為本地模擬服務端程序的模擬圖片幀數據源。具體操作步驟命令可參考之前博文:http://www.rzrgm.cn/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 = "未找到指定的機器人配置" }; } } } }
6 效果展示
3個小區域的圖片幀顯示:

點擊任意一個小區域,彈出圖片幀放大顯示彈出框:


浙公網安備 33010602011771號