☰
远程桌面工具开发画面还原与数据所有权:一次复制解决撕裂与错帧
2026/9/30 3:05:28 网站建设 项目流程

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,cw=self.canvas.shape[:2]forty,tx,ax,ayinpkt["tiles"]:sy,sx=ay*ts,ax*ts# atlas 内的像素起点dy,dx=ty*ts,tx*ts# 画布内的像素起点self.canvas[dy:dy+ts,dx:dx+ts]=atlas[sy:sy+ts,sx:sx+ts]

注意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 抛异常ifdy+ts>chordx+ts>cworsy+ts>ahorsx+ts>aw:continueself.canvas[dy:dy+ts,dx:dx+ts]=atlas[sy:sy+ts,sx:sx+ts]

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_keyframe=Truereturn

至于这个_need_keyframe怎么被消费,是利用 asyncio 的保活循环:_keepalive_loop每 0.5 秒小步进检查一次,发现需要关键帧就立刻发CTRL_KEYFRAME给对端,不用等到下一次 ping(5 秒一次)。这样新连上来的客户端能马上拿到首屏,而不是干等。

八、帧包处理失败也置需要关键帧(自愈)

还有一类自愈逻辑:如果某一帧包解析或贴图过程抛了异常(比如损坏的 JPEG、非法坐标),控制端不会让画面永远残缺,而是:

try:pkt=protocol.decode_frame_packet(payload)self._apply_packet(pkt)exceptExceptionase:self.log("[画面] 帧包处理失败:%s: %s"%(type(e).__name__,e))self._need_keyframe=True

把_need_keyframe置真,触发对端补发一个整屏关键帧。这等于「认错一次,回到基准重来」,代价是多传一张整屏图,但换来画面永远能恢复正确——这种「自愈」机制对长连接尤其重要,你不能指望网络一年到头零错误。

九、三档显示缩放模式,坐标归一化基于实际绘制矩形

界面把画布显示到窗口,有三种缩放模式(项目源码里叫scale_mode):

模式行为
fit等比缩放居中,黑边填充
actual原始像素,1:1,窗口多大显示多大
stretch拉伸填满整个窗口(可能变形)

这里有个极易踩的坑,尤其在fit模式下:鼠标点击的坐标换算,必须基于「实际绘制出来的矩形」,而不是「窗口矩形」。

为什么?因为fit模式会在画布四周留黑边,画布实际只占窗口中间一块。如果归一化时拿「窗口的宽高」去算,你就会发现点击点整体偏移——你点画面左上角,远端却收到偏中间的坐标。

项目源码的处理是:每次绘制时记下实际绘制矩形_drawn,鼠标事件发生时用这个矩形来归一化:

def_normalized(self,pos:QPoint):r=self._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×1080,tile_size = 64,那么画布被切成 30 行 × 15 列的小块。

  1. 后台线程收到一个普通帧,解出来atlas是 256×192(里面装了 4 个变化块,拼成 2 行 × 2 列),tiles列表是[(3,5,0,0), (3,6,0,1), (4,5,1,0), (4,6,1,1)]。
  2. 取第一个(ty=3, tx=5, ay=0, ax=0):源块在 atlas 的(0,0),即像素起点(sy=0, sx=0);目标在画布的(dy=3*64=192, dx=5*64=320)。检查越界:192+64=256 ≤ 1080、320+64=384 ≤ 1920、0+64=64 ≤ 192、0+64=64 ≤ 256,全过,粘贴。
  3. 剩下的三块照同样算法贴到(192,384)、(256,320)、(256,384)三个位置。
  4. 四块贴完,画布在对应位置更新,_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 得像素、越界就跳过、复制画布防撕裂、没基准先要关键帧、出错也要关键帧自愈、显示缩放时别拿窗口矩形当基准。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询