☰
Pygame 2D变换编辑器2.0:3×3齐次矩阵与可逆仿射变换
2026/10/27 6:53:39 网站建设 项目流程

上一个版本里,我对着屏幕折腾了两个多小时,只为了让一个矩形绕着它自己的中心旋转。当时的做法是按住鼠标拖动,然后根据鼠标位置算一个角度,直接传给pygame.transform.rotozoom。转到四五十度的时候问题就来了:图像会明显跳一下,拖着拖着尺寸也变了,松手再拖一次,角度起点也变了。那天收工后我把它归到一个自己写给自己看的小工具目录里。直到前几天又开始补“图像形成”这条线——上一节把针孔模型和投影的前半段讲完了,我发现自己缺一个能直观验证 2D 仿射的工具,于是索性把这个拖了两个版本的编辑器重做了一遍,也就是现在这个 2D 变换编辑器 2.0。

这篇内容讲的是它怎么搭起来的:一个用 Pygame 做的 2D 变换编辑器,核心能力是把一张图当作平面上的点集,任意地旋转、缩放、平移,并且把这些操作统一收敛到一个 3×3 的齐次矩阵里。它解决的核心问题只有一个:让变换过程可见、可控、可逆,你能看到矩阵怎么变,也能把任意一组拖拽操作还原成矩阵。适合正在学计算机视觉里图像形成、仿射变换、重采样这几块内容的读者,也适合手上需要一个轻量级标注或预览工具的人,用 Python 加 Pygame 就够了,不需要另外引入任何视觉库。

1. 从 rotozoom 到矩阵:为什么必须换掉旧引擎

1.1 旧版本的能力上限卡在“绕中心”这一个姿势上

旧版的本质是把 Pygame 现成的rotozoom当黑盒用:旋转角度和缩放系数各传一个,剩下的交给它。这种写法很适合做演示,但一旦进入真实的编辑行为就会崩掉。原因很直接:rotozoom的参数空间只有两个自由度,而平面上的相似变换至少需要四个自由度,平移两个、旋转一个、缩放一个,如果再允许非等比缩放就是五个。参数空间不够,就意味着某些操作根本表达不出来,比如绕左上角旋转,或者一边旋转一边单独压扁 x 轴。

我试过一个折中方案:先rotozoom得到旋转后的图,再通过blit的目标坐标做平移。这个方案能跑,但每叠加一层操作就要多维护一个中间 Surface,而且角度、缩放、平移三份状态的顺序一错,结果就会错得莫名其妙。最要命的是,这三份状态一旦被反复更新,就无法回答“当前这张图和原图之间到底差了多少”这个问题,也就是不可逆。一个不可逆的编辑器,在验证仿射变换这种场景里价值几乎为零。

1.2 2.0 要同时满足的三个硬条件

重写之前我给自己定了三条线,后来发现这三条基本就是这个工具的全部骨架。第一条是任意轴心,轴心要能被鼠标拖,也要能一键切回图像中心。第二条是任意顺序,缩放和旋转的先后关系必须可切换,因为矩阵乘法不满足交换律,R·S和S·R在非等比缩放下会给出完全不同的结果,这个差异恰恰是初学者最容易搞混的地方,有工具能当场演示比看图强太多。第三条是可逆且可导出,屏幕上的每一个像素都能反查回原图坐标,导出时给你一个干净的 3×3 矩阵,可以被别的程序直接消费。

三条条件一摆出来,引擎的选择就没有悬念了:状态必须收敛到一个矩阵,所有拖拽行为都只是对这个矩阵的读写。rotozoom从这里开始彻底退场,只可能在“快速预览”里作为降级方案出现。

1.3 为什么用 Pygame 而不用视觉库自带的窗口

这个选择被问过好几次。Pygame 在这种活儿里的优势是事件循环和 Surface 完全透明。鼠标按下、移动、抬起、滚轮,全都是你可以直接拦下来处理的原始事件,中间没有隐藏层。我需要精确知道一次拖拽的起点在哪个像素,也需要在拖动过程中冻结渲染尺寸,这些在 Pygame 里都是一行代码的事。反过来说,图像重采样这种真正吃算力的部分,交给 NumPy 做向量化,Pygame 只负责把算好的数组贴到窗口上,两边职责分得很干净。

代价也明确:Pygame 不带插值采样,transform.scale默认走最近邻,smoothscale只能处理整体缩放,一旦你用的是自己组合出来的仿射矩阵,这些现成函数都用不上,必须自己写一个 warp 函数。这部分的坑后面单独开一节讲。

2. 用 3×3 齐次矩阵统一描述变换

2.1 多加的那一维用来把平移塞进乘法里

把平移写成矩阵乘法,是整件事最反直觉的一步。二维平面上一个点写成列向量(x, y)ᵀ,乘上 2×2 矩阵只能得到线性组合,永远变不出常数项,也就是做不了平移。解决办法是把点升到三维,写成(x, y, 1)ᵀ,矩阵扩成 3×3,第三列留出来专门装平移量。这样一来,旋转、缩放、平移三种操作就变成了同一类对象,可以互相相乘,也可以求逆。

我用numpy的 float64 二维数组来存这个矩阵,形状固定为(3, 3),最后一行恒为[0, 0, 1]。虽然多存了一行看起来浪费,但好处是求逆、组合、批量作用点都能直接用@算子写,代码可读性比手动拆成 2×3 好得多。真正做批量点变换的时候再取前两行,能省一半运算量。

import numpy as np def translate(tx, ty): return np.array([[1, 0, tx], [0, 1, ty], [0, 0, 1 ]], dtype=np.float64) def rotate(deg): r = np.deg2rad(deg) c, s = np.cos(r), np.sin(r) return np.array([[c, -s, 0], [s, c, 0], [0, 0, 1]], dtype=np.float64) def scale(sx, sy): return np.array([[sx, 0, 0], [0, sy, 0], [0, 0, 1]], dtype=np.float64)

这里有一个必须提前钉死的约定:旋转矩阵里的正角度,在数学坐标系下是逆时针,但屏幕坐标系 y 轴朝下,实际显示出来是顺时针。这个差异会在后面反复咬人,我在这里先记住,等讲到手柄时就明白它有多要命。

2.2 组合顺序不满足交换律,先缩放还是先旋转结果完全不同

构建模型矩阵时我用的是绕轴心的标准形式:把轴心平移到原点、做旋转和缩放、再平移回去。写成矩阵就是从右往左乘:

def model_matrix(pivot, deg, sx, sy, tx, ty, order="RS"): px, py = pivot T1 = translate(-px, -py) T2 = translate(px, py) R = rotate(deg) S = scale(sx, sy) core = (R @ S) if order == "RS" else (S @ R) return translate(tx, ty) @ T2 @ core @ T1

RS和SR的差别只在非等比缩放下才明显,但那个差别是灾难性的。举个例子:sx=2, sy=1,也就是只横向拉长。如果先缩放再旋转 45 度,图像会被拉成斜的平行四边形轮廓;如果先旋转再缩放,拉伸方向固定在世界坐标的 x 轴上,图像会沿着水平方向被扯长。两种结果都是合法的仿射变换,但视觉意图完全不同。编辑器里我把这个选项做成了一个开关,切换时直接重建模型矩阵,不做任何补间,因为补间反而会让人看不清差别。

注意:组合时永远把“想要最后发生的操作”放在最左边。因为作用在点上是M · p,右边的先作用。

2.3 渲染必须用逆矩阵,前向映射会撒出满屏麻点

这是整个项目里我第一次真正意识到“方向”重要的地方。前向映射的想法很朴素:遍历原图的每个像素,用模型矩阵把它搬到新位置,写进输出缓冲。跑起来你就看到问题了:sx=1.3的时候,输出图上全是黑色的麻点。原因是源像素是整数的,乘完矩阵变成小数,四舍五入之后有些输出位置被写了两次,有些一次都没写过,那些没人写的格子就留下了底色。

逆向映射把因果反过来:遍历输出图的每个像素,用逆矩阵把它变回源图坐标,再去源图上取一个颜色。因为输出空间是稠密的、一个不落,所以输出图完整;源坐标是小数也没关系,插值采样能处理。所有正经的 warp 实现都是这个思路,包括后来我对照验证用的那几个库。

M = model_matrix(...) # 图像坐标 -> 屏幕坐标 M_inv = np.linalg.inv(M) # 屏幕坐标 -> 图像坐标

2.4 2×3 求逆的显式写法

np.linalg.inv完全够用,一次调用也就几微秒。不过因为最后一行的结构是固定的,仿射矩阵的逆有闭合式,写在热路径里可以省掉 LU 分解的开销。设a,b,c是第一行,d,e,f是第二行,行列式det = a*e - b*d:

def inv_affine(M): a, b, c = M[0] d, e, f = M[1] det = a * e - b * d if abs(det) < 1e-12: raise ValueError("矩阵不可逆,缩放可能被拖到 0 了") return np.array([[ e / det, -b / det, (b * f - c * e) / det], [-d / det, a / det, (c * d - a * f) / det], [ 0, 0, 1 ]])

那个det判零保护不是凑数的。编辑器里把某个方向缩放到 0 是完全可能的操作,一旦发生,屏幕上的图像会变成一条线,此时用np.linalg.inv会得到一堆inf,再拿去乘坐标就会让整个画面变成nan,Pygame 那边直接抛异常。显式写一遍顺便把这道防线加上了。

3. 两套坐标系之间如何来回转换

3.1 图像索引、图像坐标和屏幕坐标不是一回事

这个项目里同时存在三套说法,混起来就是无穷无尽的错位。第一套是NumPy 数组索引,arr[row, col],行先列后,行对应 y。第二套是图像坐标,原点在左上角,x 向右、y 向下,单位是像素,我用连续值表示,范围是[0, w] × [0, h]。第三套是屏幕坐标,原点在窗口左上角,同样是 x 右 y 下,但窗口里除了图像还有工具栏、状态栏这些外围元素。

之所以要区分第二和第三套,是因为图像的显示位置和缩放倍率是独立的。你可能把一张 800×600 的图放在 1200×900 的窗口里,还希望它初始时按比例缩到能完整显示,这时候图像到屏幕之间就多了一层视口变换。把这层单独抽出来,好处是调整窗口大小时只需要改视口矩阵,模型矩阵一个字都不用动。

3.2 视口矩阵负责把图像摆到窗口中间

视口矩阵我固定成“等比缩放 + 居中平移”的形式,写起来很短:

def viewport_matrix(img_w, img_h, win_w, win_h, margin=40): s = min((win_w - 2 * margin) / img_w, (win_h - 2 * margin) / img_h) ox = (win_w - img_w * s) / 2 oy = (win_h - img_h * s) / 2 return np.array([[s, 0, ox], [0, s, oy], [0, 0, 1 ]], dtype=np.float64), s

注意这里的s是只读的显示倍率,它不参与编辑,用户拖动缩放手柄改的是模型矩阵里的sx, sy,两者互不干扰。这一点在调试时救过我一次:一度我以为是缩放手柄坏了,其实是窗口 resize 之后视口倍率变了,看着像图像在动。

3.3 鼠标拾取必须在屏幕空间做,但结果要落到图像空间

手柄的命中判定我一律在屏幕空间做,因为人对“十来个像素”的距离感是屏幕像素级别的,跟图像本身的缩放无关。你不可能要求用户在一个已经被缩到 30% 的图上精确点击一个 4 图像像素宽的手柄。具体做法是把图像的四个角变换到屏幕坐标,然后算鼠标到每个角点的平方距离,取最小的那个,小于阈值就判定命中。

def to_screen(pts_img, V, M): # pts_img: (N, 2) -> 齐次 -> 应用 M 再应用 V P = np.hstack([pts_img, np.ones((len(pts_img), 1))]) out = (P @ M.T) @ V.T return out[:, :2] / out[:, 2:3]

最后那步除以第三维看起来多余,因为仿射变换的第三行永远是[0,0,1],结果恒为 1。我保留它是有意为之:下一节要接透视变换,那时候第三行不再是固定的,除法就开始干活了。现在写好,将来换矩阵不用改渲染和拾取的代码。

3.4 surfarray 出来的数组是 x 在前,转置这一步千万别省

Pygame 的surfarray模块跟 NumPy 之间有个约定,坑过的人不少:pygame.surfarray.array3d(surf)返回的形状是(width, height, 3),也就是x 在前、y 在后,跟PIL、cv2那种(height, width, 3)刚好反过来。我第一次写 warp 的时候直接拿它当常规图像索引用,结果输出的图是沿着对角线转置的,视觉效果像是被翻转了,还以为是矩阵算错了。

所以进和出都要转一次:

import pygame from pygame import surfarray def surf_to_rgb(surf): arr = surfarray.array3d(surf) # (w, h, 3) return arr.transpose(1, 0, 2) # -> (h, w, 3) def rgb_to_surf(rgb): arr = rgb.transpose(1, 0, 2) # (h, w, 3) -> (w, h, 3) return surfarray.make_surface(arr)

make_surface还有个隐含要求:输入必须是连续的 uint8 数组。如果你在中间做过加减法,数组的 dtype 可能已经变成 float 或者 int64,make_surface不会帮你转换,直接报错或者给出花屏。我习惯在出口处强制np.ascontiguousarray(rgb, dtype=np.uint8),省得每次都要单独 debug。

4. 重采样:为什么自己写插值比想象中更重要

4.1 最近邻的锯齿来源与使用场合

最近邻采样的逻辑极其简单:把逆变换得到的浮点坐标四舍五入取整,直接取源图那个像素。数学上就是floor(x + 0.5)。它的优点是快,并且不产生新的颜色值,原图有多少种颜色,输出就有多少种。对于本身就是硬边、色块分明的图,比如二值掩膜、像素风素材、标注用的多边形掩码,最近邻是唯一正确的选择,插值反而会污染标签。

缺点同样明显:当缩放倍率接近 1 但又不是 1 的时候,会出现典型的“阶梯”和“抖动”,一条原本平滑的斜边会变成一堆长短不一的水平段。旋转 30 度的时候最明显,边缘的锯齿会随着鼠标拖动一步一跳地变化,看着很廉价。

4.2 双线性的四个邻点与加权公式

双线性插值解决的就是这个台阶问题。它的思路是把输出点的浮点坐标拆成整数部分和小数部分,取它周围最近的四个源像素,按距离加权。设小数部分是dx, dy,四个邻居是c00, c10, c01, c11,权重分别是:

邻点位置权重
c00(x0, y0)(1-dx)·(1-dy)
c10(x1, y0)dx·(1-dy)
c01(x0, y1)(1-dx)·dy
c11(x1, y1)dx·dy

先把上面两个水平邻居按dx混合,下面两个也混合,再把得到的两个中间值按dy混合,两次线性就得到了双线性。写成向量化代码只需要十几行,一次算完整张图:

def warp_affine(src, M_inv, out_w, out_h): h, w = src.shape[:2] xs, ys = np.meshgrid(np.arange(out_w, dtype=np.float64), np.arange(out_h, dtype=np.float64)) den = M_inv[2, 0] * xs + M_inv[2, 1] * ys + M_inv[2, 2] sx = (M_inv[0, 0] * xs + M_inv[0, 1] * ys + M_inv[0, 2]) / den sy = (M_inv[1, 0] * xs + M_inv[1, 1] * ys + M_inv[1, 2]) / den x0 = np.floor(sx).astype(np.int64) y0 = np.floor(sy).astype(np.int64) dx = (sx - x0)[..., None] dy = (sy - y0)[..., None] x1, y1 = x0 + 1, y0 + 1 inside = (sx >= 0) & (sx <= w - 1) & (sy >= 0) & (sy <= h - 1) x0c = np.clip(x0, 0, w - 1); x1c = np.clip(x1, 0, w - 1) y0c = np.clip(y0, 0, h - 1); y1c = np.clip(y1, 0, h - 1) f = src.astype(np.float32) c00 = f[y0c, x0c]; c10 = f[y0c, x1c] c01 = f[y1c, x0c]; c11 = f[y1c, x1c] top = c00 * (1 - dx) + c10 * dx bot = c01 * (1 - dx) + c11 * dx out = top * (1 - dy) + bot * dy out[~inside] = 0 return out.astype(np.uint8)

这段代码里坐标约定要说清楚:我采用的是“像素中心位于整数索引”的简化约定,也就是像素(i, j)的采样点就在(i, j)。这样floor直接给出左右邻居,不需要再加减 0.5。代价是它跟严格按像素中心定义的实现对不齐,整体会差半个像素。编辑器里看不出来,但如果你要拿它跟别的库做逐像素对比验证,就会看到均匀的半像素偏移,别急着怀疑矩阵。

4.3 边界处理:useful 的邻居不存在时取谁

x1有可能等于w,直接拿去索引数组会越界抛异常。所以取邻居之前必须clip到合法范围,这一步是防崩溃,不是防错误。真正决定视觉结果的是inside掩膜:只有逆映射后落在源图范围内的输出像素才写颜色,落在外面的一律填 0。这就是为什么旋转之后四个角出现的空白是黑色的,不是巧合,是我主动填的。

提示:做可视化对比的时候建议把填充色改成醒目的洋红或纯绿,比黑色更容易发现“图像被切掉了一块”这类问题。

4.4 超采样成本高,只在导出时打开

即使写了双线性,当缩放倍率小于 1 的时候,输出图仍会有轻微的走样,因为每个输出像素只采了四个邻居,而它对应的源区域可能覆盖十几个像素。专业做法是区域平均或者 mipmap,但那两样在交互式编辑器里太贵。我用的是最简单粗暴的办法:导出时按 2 倍分辨率渲染再降采样,交互时不开。

这个开关的成本很直观。以一张 800×600 的源图为例,输出尺寸 1000×1000,单次双线性 warp 在纯 NumPy 下大概是十几毫秒的量级;开 2 倍超采样就是 2000×2000 的输出网格,像素数翻四倍,耗时也大致翻四倍,交互时完全没法接受,导出时等个一两百毫秒无所谓。

5. 手柄交互:拖拽行为与鼠标的对应关系

5.1 命中判定:平方距离比较,别开根号

四个角手柄加一个旋转柄,一共五个热区。判定逻辑是遍历五个手柄的屏幕坐标,算鼠标到它们的平方距离,取最小值,再跟阈值平方比较:

HIT_R2 = 12 * 12 def pick_handle(mouse, handles): best, best_d2 = None, HIT_R2 for name, (hx, hy) in handles.items(): d2 = (mouse[0] - hx) ** 2 + (mouse[1] - hy) ** 2 if d2 < best_d2: best, best_d2 = name, d2 return best

不用sqrt只是因为没必要,比较大小和开根号是单调等价的。手柄半径固定在屏幕像素,不随图像缩放变大变小,前面说过理由了,这里只补一句:如果你把它做成随缩放变,用户把图缩到 20% 的时候手柄会小到点不中,把图放到 400% 的时候手柄又会大到盖住半张图。

5.2 拖拽角点时该在哪个空间里求解

这是我认为整个编辑器里最需要想清楚的一处。鼠标动一下,屏幕位移是固定的,但你到底想让图像跟着平移、还是改变缩放?答案取决于你按的是哪个手柄。按角点,意图是缩放,所以要先把鼠标位置反解到图像的局部空间,在局部空间里算缩放系数。

反解要跑三次变换:先用视口逆矩阵把屏幕坐标变到“模型输出的世界坐标”,再用旋转和缩放的逆把世界坐标变到“以轴心为原点的局部坐标”。为了省事,我直接把完整模型矩阵求逆,一步到位:

u = apply(M_inv, mouse_pos) # 屏幕 -> 图像坐标 dx_local = u[0] - center_img[0] dy_local = u[1] - center_img[1] sx_new = dx_local / (img_w / 2) sy_new = dy_local / (img_h / 2)

这里sx_new就是“拖到哪,边就贴到哪”的解。因为除以的是半宽,所以sx = 1时拖到右边界恰好意味着图像保持原尺寸。注意这一步默认了轴心在图像中心,如果把轴心拖到别处,center_img要相应换成轴心坐标,否则会出现“拖角点,图像往侧面滑”的怪现象。我第一版就漏了这个,轴心挪走之后整个交互的手感立刻变得不对劲。

5.3 旋转柄的角度跨越 180 度时会跳

旋转柄的位置在图像上边中点外侧固定距离处,跟着模型矩阵一起转。按下时记录下当前角度angle_at_press和当时的鼠标极角,拖动时实时算新的极角,两者相减就得到增量。问题出在atan2的值域是(-180, 180],当你从 179 度拖到 -179 度的时候,直接相减会得到 -358 度,图像瞬间反向转大半圈。

解决办法是记上一次的极角,每次只取两帧之间的最短角差:

def shortest_delta(a, b): d = (b - a + 180.0) % 360.0 - 180.0 return d

然后把这个增量累加到累计角度上,而不是每次都从绝对角度重新算。这样无论怎么转、正转反转,角度都是连续的,也不会因为浮点误差慢慢漂。

5.4 按住 Shift 锁比例,锚点选择和表达式写法

锁比例的语义是“保持按下 Shift 那一刻的宽高比”。实现上我记下当时的ratio = sx / sy,之后每次更新时,取两个方向里变化幅度更大的那个作为主导,另一个用比值推出来。这样拖的时候手感是“跟手的那一边说了算”,不会出现两边互相拉扯的抖动。

if shift_held and ratio_0 > 0: if abs(sx_new) >= abs(sy_new) * ratio_0: sy_new = sx_new / ratio_0 else: sx_new = sy_new * ratio_0

ratio_0 > 0这个判断必不可少,因为用户完全可能先把 sy 拖到 0(前面也提过要防这个),此时比值是inf,再拿来做乘法就废了整个矩阵。

6. 帧率掉到 20 之后做的性能解剖

6.1 先量化再改,把一帧拆成三段计时

最初版本在 1200×900 的窗口下拖动时肉眼可见地卡,但我不知道卡在哪。于是我在主循环里插了三组计时器,分别量“事件处理”“warp 计算”“贴图与绘制手柄”的耗时,每 60 帧打印一次平均。结果很明确:warp 占了 90% 以上,事件处理和绘制几乎可以忽略。这个结论直接改变了优化方向,如果我一上来就去优化手柄绘制,那纯属白忙。

顺带说一个反直觉的结论:把逐像素的 Python 循环改成 NumPy 向量化,在这个尺寸下是几毫秒和几百毫秒的差别,量级能差两个数量级。所以这类计算的第一原则永远是不要写 Python 层的 for,一次都不行。

6.2 脏标记:矩阵没变就别重算

拖动的时候矩阵每帧都在变,必须重算,这个省不了。但编辑器大部分时间是静止的:鼠标没按、窗口没缩放、参数没改。这时候每帧重新 warp 一遍完全是浪费。我加了一个dirty标志,只有矩阵、轴心、显示倍率、源图这四样里任意一个变了才置位,渲染函数一进来先看标志:

if self.dirty: self.warped_surf = self.rebuild() self.dirty = False screen.blit(self.warped_surf, self.warped_rect)

这一条改完,静止状态的 CPU 占用从一直在跑降到了基本不动。而且它顺带解决了一个隐患:反复重算会让浮点误差不断积累,尤其在导出前后对比时容易出现“图上多了一道细边”的怪现象。

6.3 降采样预览:拖动时看个大概,松手后再精修

即便有向量化,输出尺寸大的时候拖动依然不跟手。我的做法是给拖动过程单独走一条低精度路径:把源图预先降采样到一半或四分之一,用同一套矩阵去 warp 这个缩略图,再把结果放大贴到窗口上。因为视口本身有倍率,用户看到的显示尺寸是一样的,只是细节暂时模糊。

松手的那一刻(MOUSEBUTTONUP)立刻用全分辨率重算一次。这个“预览用低精度、定格用高精度”的模式在做交互工具时几乎是标配,用起来心理负担为零,因为用户松手时注意力自然会把那一下模糊当成“正在精修”。

场景源图分辨率输出网格大致耗时量级
拖动预览缩放 1/2600×6002~4 ms
静止渲染原始1000×100010~15 ms
导出原始2000×200040~70 ms

表里的数字是我在自己机器上测的量级,具体到不同硬件肯定有出入,但相对关系是可复现的。

7. 踩坑实录:排查坐标与浮点问题

7.1 图像上下颠倒:旋转方向跟预期相反

第一个坑出现在刚接上手柄的时候。我的旋转矩阵是按数学惯例写的,[[cos, -sin], [sin, cos]],正角度逆时针。但在屏幕上拖旋转柄,顺时针拖,图像却逆时针转。排查过程其实很短,我在纸上画了一下:屏幕坐标 y 向下,所以一个点从(1, 0)转到(0, 1)在屏幕上是往下走,视觉上就是顺时针。数学矩阵没错,是我的直觉错了。

处理方式有两种,我选了第二种。第一种是在旋转矩阵里给 sin 项整体取反,这会让所有公式都变成不方便迁移的形式;第二种是保持矩阵数学正确,只在 UI 层把角度显示取负。这样导出的矩阵放到别的管线里仍然符合标准惯例,不会在跟别的库联调时出现莫名其妙的符号差异。

7.2 反复旋转后形状漂移

有一阵子我让用户每拖动一次就“烘焙”一次,也就是把当前矩阵分解成角度、缩放、平移三个标量存起来,下次从这些标量重建矩阵。转了两分钟之后,图像明显变形了。原因不难想:分解再重建是有损的,sin和cos的乘积再用atan2反解回来,每一步都会掉几个比特的精度,反复叠加就漂了。

改正很简单:矩阵是唯一真相,UI 上显示的角度缩放都只是只读投影。手柄拖拽直接对矩阵做左乘或右乘,永远不做分解重建。只有在导出面板上才做一次性的分解用于展示,那一次误差完全无所谓。

7.3 带透明通道的图整片发光

源图如果带 alpha,用array3d取出来就只剩 RGB 三个通道,alpha 被直接丢掉。我一开始没管,结果 PNG 里的透明区域变成了纯黑或者纯白,旋转后边缘一圈发光的黑边。修复路径是用array_alpha单独取一份,或者在创建 Surface 时用SRCALPHA加上pixels3d的 4 通道版本。

这一步还有个副产品:alpha 也应该参与插值,用同样的双线性权重算一遍,否则半透明边缘在旋转之后会出现硬边。把 RGB 和 A 拼成(h, w, 4)一起采样,代码不用改任何结构,只是通道数从 3 变成 4。

7.4 手柄追不上鼠标

这个现象最难受:拖动的时候手柄总是慢半拍,松手之后图像会往前“蹿”一下。查了很久才发现是双缓冲和事件顺序的问题。我在MOUSEMOTION事件里更新矩阵,但在渲染函数里用的是上一帧缓存下来的鼠标位置来画手柄,两者差一帧,视觉上就表现为手柄滞后。

修掉的要点是把“当前鼠标位置”和“当前矩阵”在同一次事件处理里一起更新,渲染函数只读,不做任何状态写入。顺带我把pygame.event.get()的批量消费和clock.tick(60)的顺序也调整成了先处理事件再计算再渲染,帧率不均匀导致的抖动也跟着消失了。

8. 还值得加的几个能力

写完之后我又花了一个晚上做了些补充,因为这东西的边际成本很低,但复用价值很高。第一个是矩阵导出为文本,一行三列的浮点数,直接粘到别的脚本里就能用,配合前面那个inv_affine可以立刻做正逆验证。第二个是移植性检查,导出前会算一次矩阵的行列式,绝对值小于阈值就给出警告,提醒用户当前变换接近退化,拿去训练或者做标注会出问题。

第三个是我个人最常用的:基准网格叠加。在图像上叠一层等距网格,网格跟着模型矩阵一起变换,这样旋转和缩放的效果一眼可见,比盯着照片猜角度靠谱得多。实现上就是把网格的横纵线用同一套to_screen变一遍,画成细线,开销可以忽略。

最后一个想法是接上透视变换。前面的warp_affine里我特意保留了第三行的除法和den的计算,就是为了让同一套渲染、拾取、命中判定的代码能直接吃 3×3 的一般形式。到那一步,四个角手柄就从“拖拽缩放”变成“拖拽定义任意四边形”,源码改动量大概只有手柄那一层,其余全部复用。这也是我坚持把变换收敛成矩阵、而不是散装几个参数的原因:多加一个自由度的代价,会从“重写一遍”变成“改几行”。

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

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

立即咨询