基于 BIOS SN 的局域网远程屏幕共享系统
在日常运维、远程协作和设备巡检场景中,快速查看远端电脑屏幕是极为常见的需求。 本系统基于 C/S 架构,以电脑主板 BIOS 序列号(SN)作为设备唯一标识, 实现了一套轻量级的局域网屏幕共享方案。服务端利用 mss 库高效截取屏幕画面, 经 OpenCV 编码为 JPEG 后通过 Flask 提供 MJPEG 视频流接口; 客户端基于 PyQt5 构建图形界面,运维人员只需输入目标设备 SN, 即可实时接收并显示远端电脑的完整桌面画面。整套系统无需安装第三方远程桌面软件, 部署简单、资源占用低、支持 10 台设备同时在线,非常适合中小规模局域网环境下的设备监控与协作。
架构
┌─────────────────────────────────────────────────────────────────────┐│ 局 域 网 ││ ││ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ ││ │ 被控电脑 A │ │ 被控电脑 B │ │ 被控电脑 C │ ││ │ SN: 5CD514.. │ │ SN: ABC123.. │ │ SN: XYZ987.. │ ││ │ IP: .1.100 │ │ IP: .1.101 │ │ IP: .1.102 │ ││ │ │ │ │ │ │ ││ │ screen_ │ │ screen_ │ │ screen_ │ ││ │ server.py │ │ server.py │ │ server.py │ ││ │ :5001 │ │ :5001 │ │ :5001 │ ││ │ │ │ │ │ │ ││ │ [mss截屏] │ │ [mss截屏] │ │ [mss截屏] │ ││ │ ↓ │ │ ↓ │ │ ↓ │ ││ │ [JPEG编码] │ │ [JPEG编码] │ │ [JPEG编码] │ ││ │ ↓ │ │ ↓ │ │ ↓ │ ││ │ [MJPEG流] │ │ [MJPEG流] │ │ [MJPEG流] │ ││ └───────┬───────┘ └───────┬───────┘ └───────┬───────┘ ││ │ │ │ ││ └──────────────────┼──────────────────┘ ││ │ HTTP GET /stream ││ ▼ ││ ┌─────────────────────┐ ││ │ 控制端电脑 │ ││ │ screen_client.py │ ││ │ 输入 SN → 看屏幕 │ ││ └─────────────────────┘ │└─────────────────────────────────────────────────────────────────────┘
✨ 功能特性
🛠️ 环境要求
服务端(每台被控电脑):
Python >= 3.8opencv-python >= 4.5Flask >= 2.0mss >= 6.0numpy >= 1.20Windows 操作系统
客户端(控制电脑):
Python >= 3.8opencv-python >= 4.5PyQt5 >= 5.15requests >= 2.25numpy >= 1.20
🚀 快速部署
1. 服务端部署(每台被控电脑执行)
pip install flask mss opencv-python numpypython screen_server.py
启动后输出:
[屏幕共享服务端] 本机 SN: 5CD5145Y25[屏幕共享服务端] 监听端口: 5001[屏幕共享服务端] 屏幕流地址: http://<本机IP>:5001/stream
2. 客户端启动(控制电脑执行)
pip install opencv-python PyQt5 requests numpypython screen_client.py
3. 使用流程
┌───────────────────────────────────────────────────────────────┐│ Step 1: 在每台被控电脑上启动 screen_server.py ││ ↓ ││ Step 2: 在控制电脑启动 screen_client.py ││ ↓ ││ Step 3: 添加设备 (填入对方 SN + IP + 端口) → 点击「添加/更新」 ││ ↓ ││ Step 4: 输入 SN → 点击「连接屏幕」→ 实时查看远程桌面 │└───────────────────────────────────────────────────────────────┘
🧩 核心代码解析
Part 1:服务端 — 高效屏幕截取引擎
本模块使用 mss 库截取屏幕画面,相比 Pillow 的 ImageGrab,mss 基于系统原生 API,速度快 3~5 倍。
import mssimport numpy as npimport cv2import timedefgenerate_frames():"""截取屏幕生成 MJPEG 视频流"""global streaming streaming = Truewith mss.mss() as sct: monitor = sct.monitors[1] # 主显示器(monitors[0] 是所有屏幕的合集)while streaming:# 截取屏幕 — 返回 BGRA 格式的原始像素数据 img = sct.grab(monitor)# mss 输出为 BGRA 4通道,去掉 Alpha 通道得到 BGR frame = np.array(img)[:, :, :3]# 缩放至 1280x720,使用 INTER_AREA 插值(缩小时效果最佳) frame = cv2.resize(frame, (1280, 720), interpolation=cv2.INTER_AREA)# JPEG 编码,质量 55 _, buffer = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, 55])# 以 MJPEG multipart 格式输出yield (b'--frame\r\n'b'Content-Type: image/jpeg\r\n\r\n' + buffer.tobytes() + b'\r\n')# 控制帧率 ~15fps time.sleep(0.066)
技术要点:
mss.mss() 上下文管理器:自动管理系统资源,截屏效率极高monitors[1]:主显示器;monitors[0] 代表所有显示器的虚拟合集- BGRA → BGR:mss 返回 4 通道数据,需丢弃 Alpha 通道供 OpenCV 处理
cv2.INTER_AREA:缩小图像时的最佳插值算法,避免锯齿time.sleep(0.066):约 15fps,平衡画面流畅度与 CPU/网络负载- JPEG 质量 55:单帧约 40~80KB,1280×720 分辨率下带宽需求约 5~10 Mbps
Part 2:服务端 — Flask HTTP 服务与 API 设计
服务端基于 Flask 暴露 RESTful 接口,支持 SN 查询、视频流获取、状态管理。
import subprocessfrom flask import Flask, Response, jsonifyapp = Flask(__name__)streaming = Falsedefget_bios_sn():"""获取本机 BIOS 序列号"""try: result = subprocess.run( ['wmic', 'bios', 'get', 'serialnumber'], capture_output=True, text=True, timeout=10 ) lines = [l.strip() for l in result.stdout.strip().split('\n') if l.strip()]if len(lines) >= 2:return lines[1]except Exception:passreturn"UNKNOWN"LOCAL_SN = get_bios_sn()@app.route('/sn')defget_sn():"""获取本机 SN"""return jsonify({"sn": LOCAL_SN})@app.route('/stream')defvideo_stream():"""屏幕共享视频流 — MJPEG 格式,浏览器可直接访问"""return Response(generate_frames(), mimetype='multipart/x-mixed-replace; boundary=frame')@app.route('/stop')defstop_stream():"""停止推流,释放资源"""global streaming streaming = Falsereturn jsonify({"status": "stopped"})@app.route('/status')defstatus():"""查询当前服务状态"""return jsonify({"sn": LOCAL_SN, "streaming": streaming})if __name__ == '__main__': app.run(host='0.0.0.0', port=5001, threaded=True)
技术要点:
host='0.0.0.0':监听所有网卡接口,局域网设备可通过 IP 访问threaded=True:多线程处理并发请求,支持同时被多个客户端连接- 端口
5001:与摄像头服务(5000)分离,可并行运行 multipart/x-mixed-replace:HTTP 流式响应标准,浏览器原生支持播放
Part 3:客户端 — 多线程流接收与帧解码
客户端使用 QThread 在后台接收网络数据,解析 JPEG 帧后通过 Qt 信号传递给主线程渲染。
import requestsimport cv2import numpy as npfrom PyQt5.QtCore import QThread, pyqtSignalclassStreamThread(QThread):"""后台线程接收 MJPEG 屏幕流""" frame_received = pyqtSignal(np.ndarray) error_occurred = pyqtSignal(str)def__init__(self, url): super().__init__() self.url = url self.running = Truedefrun(self):try: response = requests.get(self.url, stream=True, timeout=10) buf = b''for chunk in response.iter_content(chunk_size=8192):ifnot self.running:break buf += chunk# JPEG 帧边界检测 start = buf.find(b'\xff\xd8') # SOI 标记 end = buf.find(b'\xff\xd9') # EOI 标记if start != -1and end != -1and end > start: jpg = buf[start:end + 2] buf = buf[end + 2:] frame = cv2.imdecode( np.frombuffer(jpg, dtype=np.uint8), cv2.IMREAD_COLOR )if frame isnotNone: self.frame_received.emit(frame)except requests.exceptions.ConnectionError: self.error_occurred.emit("连接失败,请确认目标电脑服务端已启动")except requests.exceptions.Timeout: self.error_occurred.emit("连接超时")except Exception as e: self.error_occurred.emit(f"流接收错误: {e}")defstop(self): self.running = False
技术要点:
chunk_size=8192:屏幕画面帧较大,增大 chunk 提升接收效率- SOI/EOI 帧边界:JPEG 文件固定以
0xFFD8 开始、0xFFD9 结束 - 缓冲区累积:网络数据分片到达,累积直到检测到完整帧再解码
- 信号机制:
frame_received 和 error_occurred 两个信号覆盖正常和异常场景 self.running 标志:支持外部调用 stop() 优雅退出线程
Part 4:客户端 — GUI 界面与设备管理
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QTableWidgetfrom PyQt5.QtGui import QImage, QPixmapfrom PyQt5.QtCore import QtclassScreenClient(QWidget):defopen_screen(self): sn = self.open_sn_input.text().strip()if sn notin self.config: self.status_label.setText(f"未找到 SN: {sn} 的配置")return info = self.config[sn] ip = info["ip"] port = info.get("port", 5001) self.stop_stream() url = f"http://{ip}:{port}/stream" self.stream_thread = StreamThread(url) self.stream_thread.frame_received.connect(self.update_frame) self.stream_thread.error_occurred.connect(self.on_stream_error) self.stream_thread.start()defupdate_frame(self, frame): frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) h, w, ch = frame.shape img = QImage(frame.data, w, h, ch * w, QImage.Format_RGB888) scaled = QPixmap.fromImage(img).scaled( self.video_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.video_label.setPixmap(scaled)defstop_stream(self):if self.stream_thread: self.stream_thread.stop() self.stream_thread.wait(2000) self.stream_thread = None
界面结构:
QVBoxLayout (主布局)├── QLabel "设备配置"├── QHBoxLayout (SN + IP + 端口 + 添加/删除按钮)├── QTableWidget (设备列表表格 3列: SN/IP/端口)├── QHBoxLayout (SN输入 + 连接/断开按钮)├── QLabel (屏幕画面显示区 960×540)└── QLabel (状态栏)
📄 配置文件说明
客户端运行后生成 screen_devices.json:
{"5CD5145Y25": {"ip": "192.168.1.100","port": 5001 },"ABC1234567": {"ip": "192.168.1.101","port": 5001 },"XYZ9876543": {"ip": "192.168.1.102","port": 5001 }}
📡 API 接口文档
响应示例:
// GET /sn{"sn": "5CD5145Y25"}// GET /status{"sn": "5CD5145Y25", "streaming": true}// GET /stop{"status": "stopped"}
📚 知识点总结
1. 屏幕截取技术对比
mss 使用系统原生 API(Windows 上为 GDI),跳过了 Pillow 的图像对象封装开销, 直接返回原始像素缓冲区,适合高帧率场景。
2. MJPEG 流媒体传输原理
HTTP Response Header: Content-Type: multipart/x-mixed-replace; boundary=frameBody (循环输出): --frame\r\n Content-Type: image/jpeg\r\n \r\n [JPEG 二进制数据] \r\n --frame\r\n Content-Type: image/jpeg\r\n \r\n [下一帧 JPEG 数据] ...
- 客户端通过 SOI(
0xFFD8) 和 EOI(0xFFD9) 标记识别帧边界 - 缺点:带宽利用率低于 H.264/H.265 等视频编码
3. Python Generator 与流式响应
- Flask 的
Response 接受 Generator 对象作为参数,实现 HTTP 流式输出
4. PyQt5 多线程与信号槽
- QThread:继承后重写
run() 方法,调用 start() 启动 - pyqtSignal:类型安全的跨线程通信,参数类型在定义时指定
- 线程安全原则:永远不在子线程中直接操作 GUI 控件
- 退出机制:设置标志位 → 子线程检测后退出循环 → 主线程
wait() 等待
5. 图像格式转换链路
mss 截屏 (BGRA) → 去掉 Alpha (BGR) → cv2.resize (缩放) → cv2.imencode (JPEG 编码) → 网络传输 → cv2.imdecode (解码) → cv2.cvtColor (BGR→RGB) → QImage → QPixmap → QLabel 显示
🔮 拓展场景与测试步骤
拓展应用场景
进阶拓展方向
| |
|---|
| 新增 /input POST 接口 + pyautogui 执行操作 |
| sct.monitors[2] |
| |
| 服务端同时将帧写入 cv2.VideoWriter 保存为视频文件 |
| 替换 HTTP 长连接为 WebSocket,支持双向通信 |
| 使用 FFmpeg 替代 JPEG 实现帧间压缩,带宽降低 80% |
测试步骤
Step 1:环境准备
# 服务端电脑pip install flask mss opencv-python numpy# 客户端电脑pip install opencv-python PyQt5 requests numpy
Step 2:网络连通性验证
# 在客户端 ping 服务端ping 192.168.1.100# 确认端口可达Test-NetConnection -ComputerName 192.168.1.100 -Port 5001
Step 3:服务端验证
python screen_server.py
验证项:
- [ ] 浏览器访问
http://<IP>:5001/sn 返回 JSON - [ ] 浏览器访问
http://<IP>:5001/stream 能看到屏幕画面
Step 4:客户端连接测试
- [ ] 启动
python screen_client.py
Step 5:性能测试
- [ ] 观察服务端 CPU 占用(正常应在 10~25%)
- [ ] 观察网络带宽(1280×720@15fps 约 5~10 Mbps)
Step 6:多设备并发测试
Step 7:异常场景测试
Step 8:防火墙配置(如连接失败)
# 服务端电脑开放 5001 端口(管理员 PowerShell)New-NetFirewallRule -DisplayName "Screen Share Server" -Direction Inbound -Port 5001 -Protocol TCP -Action Allow
📝 本系统为纯查看方案(只看屏幕不操控)。如需加入远程鼠标键盘操控功能,可在服务端新增 /input 接口配合 pyautogui 实现。端口 5001 与摄像头服务 5000 互不冲突,可同时运行。
"""屏幕共享客户端 GUI - 在控制端运行输入 SN 即可查看对应电脑的屏幕画面依赖: pip install opencv-python PyQt5 requests numpy"""import sysimport osimport jsonimport requestsimport cv2import numpy as npfrom PyQt5.QtWidgets import (QApplication, QWidget, QVBoxLayout, QHBoxLayout,QLabel, QLineEdit, QPushButton, QTableWidget, QTableWidgetItem,QMessageBox, QHeaderView, QSpinBox)from PyQt5.QtGui import QImage, QPixmapfrom PyQt5.QtCore import Qt, QThread, pyqtSignalCONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(file)), "screen_devices.json")def load_config():if os.path.exists(CONFIG_FILE):with open(CONFIG_FILE, "r", encoding="utf-8") as f:return json.load(f)return {}def save_config(config):with open(CONFIG_FILE, "w", encoding="utf-8") as f:json.dump(config, f, indent=2, ensure_ascii=False)class StreamThread(QThread):"""后台线程接收 MJPEG 屏幕流"""frame_received = pyqtSignal(np.ndarray)error_occurred = pyqtSignal(str)def __init__(self, url): super().__init__() self.url = url self.running = Truedef run(self): try: response = requests.get(self.url, stream=True, timeout=10) buf = b'' for chunk in response.iter_content(chunk_size=8192): if not self.running: break buf += chunk # 查找 JPEG 帧边界 start = buf.find(b'\xff\xd8') end = buf.find(b'\xff\xd9') if start != -1 and end != -1 and end > start: jpg = buf[start:end + 2] buf = buf[end + 2:] frame = cv2.imdecode( np.frombuffer(jpg, dtype=np.uint8), cv2.IMREAD_COLOR ) if frame is not None: self.frame_received.emit(frame) except requests.exceptions.ConnectionError: self.error_occurred.emit("连接失败,请确认目标电脑服务端已启动") except requests.exceptions.Timeout: self.error_occurred.emit("连接超时") except Exception as e: self.error_occurred.emit(f"流接收错误: {e}")def stop(self): self.running = Falseclass ScreenClient(QWidget):def init(self):super().init()self.setWindowTitle("远程屏幕共享客户端")self.resize(1000, 700) self.config = load_config() self.stream_thread = None self.init_ui() self.refresh_table()def init_ui(self): layout = QVBoxLayout() # 设备配置区域 layout.addWidget(QLabel("设备配置 (SN → IP 地址):")) config_layout = QHBoxLayout() config_layout.addWidget(QLabel("SN:")) self.sn_input = QLineEdit() self.sn_input.setPlaceholderText("电脑 BIOS SN") config_layout.addWidget(self.sn_input) config_layout.addWidget(QLabel("IP:")) self.ip_input = QLineEdit() self.ip_input.setPlaceholderText("如 192.168.1.100") config_layout.addWidget(self.ip_input) config_layout.addWidget(QLabel("端口:")) self.port_spin = QSpinBox() self.port_spin.setRange(1, 65535) self.port_spin.setValue(5001) config_layout.addWidget(self.port_spin) self.btn_add = QPushButton("添加/更新") self.btn_add.clicked.connect(self.add_device) config_layout.addWidget(self.btn_add) self.btn_delete = QPushButton("删除选中") self.btn_delete.clicked.connect(self.delete_device) config_layout.addWidget(self.btn_delete) layout.addLayout(config_layout) # 设备列表表格 self.table = QTableWidget() self.table.setColumnCount(3) self.table.setHorizontalHeaderLabels(["SN", "IP", "端口"]) self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) self.table.setMaximumHeight(130) self.table.setSelectionBehavior(QTableWidget.SelectRows) layout.addWidget(self.table) # 连接操作区 open_layout = QHBoxLayout() open_layout.addWidget(QLabel("输入 SN 查看远程屏幕:")) self.open_sn_input = QLineEdit() self.open_sn_input.setPlaceholderText("输入已配置的电脑 SN") open_layout.addWidget(self.open_sn_input) self.btn_open = QPushButton("连接屏幕") self.btn_open.clicked.connect(self.open_screen) open_layout.addWidget(self.btn_open) self.btn_stop = QPushButton("断开") self.btn_stop.clicked.connect(self.stop_stream) open_layout.addWidget(self.btn_stop) layout.addLayout(open_layout) # 屏幕画面显示 self.video_label = QLabel("远程屏幕画面") self.video_label.setMinimumSize(960, 540) self.video_label.setStyleSheet("background-color: #1a1a2e; color: #aaa; font-size: 16px;") self.video_label.setAlignment(Qt.AlignCenter) layout.addWidget(self.video_label) # 状态栏 self.status_label = QLabel("就绪") layout.addWidget(self.status_label) self.setLayout(layout)def refresh_table(self): self.table.setRowCount(0) for sn, info in self.config.items(): row = self.table.rowCount() self.table.insertRow(row) self.table.setItem(row, 0, QTableWidgetItem(sn)) if isinstance(info, dict): self.table.setItem(row, 1, QTableWidgetItem(info.get("ip", ""))) self.table.setItem(row, 2, QTableWidgetItem(str(info.get("port", 5001)))) else: self.table.setItem(row, 1, QTableWidgetItem(str(info))) self.table.setItem(row, 2, QTableWidgetItem("5001"))def add_device(self): sn = self.sn_input.text().strip() ip = self.ip_input.text().strip() port = self.port_spin.value() if not sn or not ip: QMessageBox.warning(self, "提示", "请输入 SN 和 IP 地址") return self.config[sn] = {"ip": ip, "port": port} save_config(self.config) self.refresh_table() self.status_label.setText(f"已保存: {sn} → {ip}:{port}")def delete_device(self): row = self.table.currentRow() if row < 0: return sn = self.table.item(row, 0).text() del self.config[sn] save_config(self.config) self.refresh_table() self.status_label.setText(f"已删除: {sn}")def open_screen(self): sn = self.open_sn_input.text().strip() if not sn: self.status_label.setText("请输入 SN") return if sn not in self.config: self.status_label.setText(f"未找到 SN: {sn} 的配置,请先添加设备") return info = self.config[sn] if isinstance(info, dict): ip = info["ip"] port = info.get("port", 5001) else: ip = str(info) port = 5001 self.stop_stream() url = f"http://{ip}:{port}/stream" self.status_label.setText(f"正在连接 {ip}:{port} ...") self.stream_thread = StreamThread(url) self.stream_thread.frame_received.connect(self.update_frame) self.stream_thread.error_occurred.connect(self.on_stream_error) self.stream_thread.start()def update_frame(self, frame): self.status_label.setText("正在接收屏幕画面...") frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) h, w, ch = frame.shape img = QImage(frame.data, w, h, ch * w, QImage.Format_RGB888) scaled = QPixmap.fromImage(img).scaled( self.video_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.video_label.setPixmap(scaled)def on_stream_error(self, msg): self.status_label.setText(f"错误: {msg}")def stop_stream(self): if self.stream_thread: self.stream_thread.stop() self.stream_thread.wait(2000) self.stream_thread = None self.video_label.clear() self.video_label.setText("远程屏幕画面") self.status_label.setText("已断开")def closeEvent(self, event): self.stop_stream() event.accept()if name == 'main':app = QApplication(sys.argv)window = ScreenClient()window.show()sys.exit(app.exec_())