恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
远程桌面工具开发画面还原与数据所有权:一次复制解决撕裂与错帧
首页
资讯中心
/
远程桌面工具开发画面还原与数据所有权:一次复制解决撕裂与错帧
远程桌面工具开发画面还原与数据所有权:一次复制解决撕裂与错帧
发布时间:2026/9/30 3:05:32
02-画面还原与数据所有权一次复制解决撕裂与错帧上一篇我们讲了控制端怎么把界面线程和网络线程分开。这篇钻进画面还原的核心网络那头传来的不是「整张图」而是一堆「变化的小块」控制端要把它们一点点贴回一张大画布上。这里面藏着几个容易翻车的地方——坐标单位、越界保护、还有最关键的「数据所有权」。一、画面还原模型一块 (H, W, 3) 的 numpy 画布控制端在内存里维护一张巨大的画布形状是(H, W, 3)——高、宽、三个颜色通道RGB。它就是你在窗口里看到的远程桌面「当前应该长什么样」。远端不停发来两种帧关键帧keyframe整屏图像。控制端收到后直接用这张图整屏替换画布。相当于「重置基准」。普通帧只含「相对上一帧变化了哪些块」每个块带一个坐标告诉控制端「把这块新图贴到画布的哪个位置」。所以还原过程就是反复执行关键帧覆盖全图普通帧局部打补丁。只要补丁不丢、坐标不错画布始终和远端一致。二、关键帧整屏替换普通帧按 (ty,tx,ax,ay) 贴回普通帧的精髓在那组坐标(ty, tx, ax, ay)。我们在上一篇提过画面的差分是按「块tile」做的比如 64×64 一块。一个变化块的信息就是四个数字段含义ty目标要贴回画布的「第几行块」tx目标要贴回画布的「第几列块」ay源在 atlas拼接图里的「第几行块」ax源在 atlas 里的「第几列块」理解起来很直观远端把这一帧所有变化的块拼成一张 atlas 小图然后告诉控制端「atlas 的第 (ax, ay) 块请贴到画布的第 (tx, ty) 块位置」。项目源码里的贴图循环是这样的ch,cwself.canvas.shape[:2]forty,tx,ax,ayinpkt[tiles]:sy,sxay*ts,ax*ts# atlas 内的像素起点dy,dxty*ts,tx*ts# 画布内的像素起点self.canvas[dy:dyts,dx:dxts]atlas[sy:syts,sx:sxts]注意atlas是远端拼好的整张图canvas是本地画布两边用切片做「块到块」的拷贝。一次循环就把所有变化块补齐。三、坐标以「块」为单位乘 tile_size 得像素这里有个新手容易迷糊的点协议里传的坐标一律是「块」编号不是像素。要得到真实像素位置必须乘tile_size也就是每个块的边长比如 64。上面那段代码里ay * ts、ty * ts就是在做这个换算。为什么不直接传像素因为块编号更小、更紧凑而且「以块为单位」天然和差分算法对齐——差分本来就是按块找变化的传块号最自然省掉了「像素除以块大小」这步换算的歧义。所以记住一条铁律看到 (ty,tx,ax,ay) 别当像素用先乘 tile_size。四、输入事件用归一化坐标所以 Agent 端降采样不影响操作顺着上一节多提一句控制端发出的鼠标坐标用的是归一化 0~1的值比如屏幕正中是 (0.5, 0.5)而不是具体像素。这个设计的妙处在于远端Agent 那台电脑采的图无论是不是降过采样、无论 DPI 怎么缩放它收到 (0.5, 0.5) 都能在自己真实的屏幕分辨率上还原成正确的绝对像素。换句话说控制端这边降不降采样、缩放多少完全不影响操作的正确性——因为坐标已经和具体分辨率脱钩了。这也是为什么项目源码敢于把「降采样只走整数倍」当成硬约束而不怕把点击点算歪。五、越界保护包损坏或尺寸变化时不能让 numpy 抛异常网络传输不是绝对可靠的加密帧偶尔会损坏、或远端分辨率在你连着的时候变了。如果这时候还按上面的切片去贴下标可能超出画布或 atlas 的范围numpy 一越界就抛 IndexError整个接收循环都可能被带崩。所以项目源码加了越界保护超界就直接continue跳过这一块绝不让异常冒出去# 越界保护包损坏或尺寸变化时不能让 numpy 抛异常ifdytschordxtscworsytsahorsxtsaw:continueself.canvas[dy:dyts,dx:dxts]atlas[sy:syts,sx:sxts]ch/cw是画布高宽ah/aw是 atlas 高宽。四个条件任一不满足就跳过。这一道if看起来不起眼实则是「长时间常驻不能崩」的底线——你不可能因为对端某帧传错了一点就整个客户端挂掉。六、重点必须复制——否则撕裂与错帧这是整篇最关键的一点。前面贴图代码里self.canvas是「原地修改」的每来一帧控制端就直接往这块内存上写新块。而画面真正显示出来是另一回事界面线程拿到画布、构造QImage、调用update()去绘制。这个绘制动作和收帧动作不在同一时刻——收帧在后台 asyncio 线程绘制在主线程两者异步。于是危险来了假设后台线程刚把画布前一半贴完新块还没贴完后一半主线程的绘制事件正好触发它读到的就是一张「前半新、后半旧」的画布——画面撕裂。更糟的是如果下一帧已经把画布整块覆盖了而主线程迟迟没绘制上一帧你看到的可能是错乱的帧序列。解决方式只有一句话把画布复制一份再交给界面层。项目源码里是这样做的def_emit_frame(self)-None:ifself.on_frameandself.canvasisnotNone:# 必须复制画布在下一帧会被原地修改而回调界面层# 通常异步消费这份数据。不复制就会出现撕裂/错帧。self.on_frame(self.canvas.copy(),{tile_size:self.tile_size})self.canvas.copy()新建一块独立内存把当前完整的、一致的画面冻结下来再传给on_frame。界面层拿到的就是这一刻的「快照」之后后台线程怎么改画布都不影响它。代价只是每帧一次内存拷贝——对 2560×1408 的画布而言是几十微秒级相比换来「绝不撕裂」的保证这笔买卖太划算。顺带呼应上一篇这就是为什么主线程能「零拷贝」直接QImage(arr.data, ...)——因为arr已经是后台复制出来的独立内存了主线程不需要再拷第二次。复制发生在后台一次渲染零拷贝零次。七、还没收到关键帧时先请求一个普通帧的「贴图」是相对关键帧的增量。如果画布还是空的、从未收到过关键帧那贴普通帧就没有基准——你贴上去也是贴在一张全黑/全空的画布上毫无意义。所以项目源码里有个判断如果self.canvas is None还没基准就不贴普通帧而是置_need_keyframe True先去请求一个关键帧ifself.canvasisNone:# 还没收到关键帧先请求一个否则贴图没有基准self._need_keyframeTruereturn至于这个_need_keyframe怎么被消费是利用 asyncio 的保活循环_keepalive_loop每 0.5 秒小步进检查一次发现需要关键帧就立刻发CTRL_KEYFRAME给对端不用等到下一次 ping5 秒一次。这样新连上来的客户端能马上拿到首屏而不是干等。八、帧包处理失败也置需要关键帧自愈还有一类自愈逻辑如果某一帧包解析或贴图过程抛了异常比如损坏的 JPEG、非法坐标控制端不会让画面永远残缺而是try:pktprotocol.decode_frame_packet(payload)self._apply_packet(pkt)exceptExceptionase:self.log([画面] 帧包处理失败%s: %s%(type(e).__name__,e))self._need_keyframeTrue把_need_keyframe置真触发对端补发一个整屏关键帧。这等于「认错一次回到基准重来」代价是多传一张整屏图但换来画面永远能恢复正确——这种「自愈」机制对长连接尤其重要你不能指望网络一年到头零错误。九、三档显示缩放模式坐标归一化基于实际绘制矩形界面把画布显示到窗口有三种缩放模式项目源码里叫scale_mode模式行为fit等比缩放居中黑边填充actual原始像素1:1窗口多大显示多大stretch拉伸填满整个窗口可能变形这里有个极易踩的坑尤其在fit模式下鼠标点击的坐标换算必须基于「实际绘制出来的矩形」而不是「窗口矩形」。为什么因为fit模式会在画布四周留黑边画布实际只占窗口中间一块。如果归一化时拿「窗口的宽高」去算你就会发现点击点整体偏移——你点画面左上角远端却收到偏中间的坐标。项目源码的处理是每次绘制时记下实际绘制矩形_drawn鼠标事件发生时用这个矩形来归一化def_normalized(self,pos:QPoint):rself._drawnifr.width()0orr.height()0:return0.0,0.0# 归一化 0~1基于实际绘制矩形而非窗口矩形x(pos.x()-r.x())/float(r.width())y(pos.y()-r.y())/float(r.height())returnmin(1.0,max(0.0,x)),min(1.0,max(0.0,y))_drawn在paintEvent里被赋值为self._target_rect()的结果也就是fit/stretch/actual算出来的真实绘制区。把「显示坐标 → 归一化坐标」这一步锚定在真实绘制区上无论你怎么缩放、怎么留黑边点哪都准。十、一个具体例子一帧普通帧是怎么落地的光讲规则有点抽象我们走一遍真实流程。假设画布是 1920×1080tile_size 64那么画布被切成 30 行 × 15 列的小块。后台线程收到一个普通帧解出来atlas是 256×192里面装了 4 个变化块拼成 2 行 × 2 列tiles列表是[(3,5,0,0), (3,6,0,1), (4,5,1,0), (4,6,1,1)]。取第一个(ty3, tx5, ay0, ax0)源块在 atlas 的(0,0)即像素起点(sy0, sx0)目标在画布的(dy3*64192, dx5*64320)。检查越界19264256 ≤ 1080、32064384 ≤ 1920、06464 ≤ 192、06464 ≤ 256全过粘贴。剩下的三块照同样算法贴到(192,384)、(256,320)、(256,384)三个位置。四块贴完画布在对应位置更新_emit_frame复制一份交给界面层。你看所谓「远程桌面画面」本质上就是远端把「哪几块变了」告诉本地本地在自己的画布上把那几块刷掉。绝大多数办公时间里变化的块占比不到 1%所以绝大多数帧只是轻飘飘地贴几个小块带宽和 CPU 都极低。十一、画布的内存布局为什么是 (H, W, 3) 而不是别的形式最后补一句底层的画布用(H, W, 3)的连续 numpy 数组是因为 Qt 的QImage按「行优先、每行 RGB 交错」排布内存正好和 numpy 默认的 C 连续(高, 宽, 通道)一一对应。构造QImage时传的步长3*w每个像素 3 字节、每行 w 个像素就是从这里来的。也正是因为这个内存布局一致零拷贝才成立——Qt 读 numpy 的内存不需要任何重排直接当图像用。到这里画面从「一堆变化块」变回「一张完整图」的全流程就清楚了块坐标乘 tile_size 得像素、越界就跳过、复制画布防撕裂、没基准先要关键帧、出错也要关键帧自愈、显示缩放时别拿窗口矩形当基准。