旋转比缩放多一个麻烦:转完之后角上会出现「没有源像素」的区域。 而且这个区域的多少,取决于画布尺寸换不换——这一条最容易被算错。
上一节是缩放,这一节是旋转,两件事都可以用「反向映射」实现: 遍历目标像素,反推它来自源图的哪个位置。 这是图形学里最重要的一条思路,比正向映射简单得多。
一、反向映射:只遍历一次
正向映射的问题是「源像素可能落到小数位置」,会有空洞。 反向映射没有这个问题——每个目标像素都能找到一个源位置。
| 做法 | 遍历谁 | 结果 |
|---|---|---|
| 正向 | 源像素 | 目标有空洞,要补 |
| 反向 | 目标像素 | 每个都有值(可能越界) |
第二行的「可能越界」就是空白区域的来源:反推出来的源坐标跑出图像边界了。 所以「有没有空白」不是算法问题,是几何问题——转过去的画面本来就超出了原画布的范围。
二、画布换不换,结果完全不同
D43 实测:角度越大空白越多,90° 时达到 25.6%。
| 角度 | 有效像素 | 空白 |
|---|---|---|
| 15° | 88.7% | 11.3% |
| 30° | 82.6% | 17.4% |
| 45° | 79.7% | 20.3% |
| 90° | 74.4% | 25.6% |
但这张表是「画布死守 120×160」的结果。 真实的 90° 旋转必须交换宽高(画布变成 160×120),那样空白是 0。 所以这张表只能说明「角度越大,需要的画布越大」,不能说明「90° 质量最差」。 这就是我一开始差点写错的结论——数字对,口径错,结论就反了。
三、空白怎么办,三个选择
这一格是工程决策,没有标准答案:
| 做法 | 效果 | 代价 |
|---|---|---|
| 填黑 | 边上一条黑 | 最简单 |
| 先放大再转 | 铺满,但丢边 | 要算缩放比 |
| 只转 90 的倍数 | 没有空白 | 最快,画质无损 |
第三行是监控里最常用的:云台转 90° 的预置位,直接用矩阵转置加翻转,不用采样、不丢像素。
具体就是 np.rot90 或 cv2.rotate,它们内部就是转置加翻转,比任意角度旋转快几十倍。
第一行的「填黑」其实有讲究:填 0 是黑,但监控场景里更常见的是「填上一帧的边缘」或者「填灰」, 因为纯黑在暗场景里会误判成「没信号」。
四、两种采样在旋转上的差别
D43 实测:30° 旋转,共同有效区里最大差 254 级,平均差 2.6 级。
最大差 254 说明有极个别像素完全不同——那是在强烈边缘处,最近邻取到了另一侧的极值。 平均差 2.6 级说明绝大多数像素几乎一样——因为渐变区域里两点的值本来就接近。
这两个数一起看才有意义:只看平均值会觉得「两者一样」,只看最大值会觉得「差得离谱」。 报统计量必须成对报,这是我的一个固定习惯。
五、动手 Demo:两种旋转各写一遍,量空白和耗时
用反向映射实现最近邻和双线性旋转,测 15°、30°、45°、90° 的空白比例,再比两种采样的差异和耗时。 注意画布固定不换——这正是「90° 空白最多」的原因。
def rot_nn(a, deg):
ih, iw = a.shape
th = np.deg2rad(deg)
c, s = np.cos(th), np.sin(th)
cy, cx = ih / 2.0, iw / 2.0
oy, ox = np.mgrid[0:ih, 0:iw]
dx, dy = ox - cx, oy - cy
sx = c * dx + s * dy + cx # 反推源坐标
sy = -s * dx + c * dy + cy
ok = (sx >= 0) & (sx < iw - 1) & (sy >= 0) & (sy < ih - 1)
out = np.zeros_like(a)
out[ok] = a[sy[ok].astype(int), sx[ok].astype(int)]
return out, ok
实跑输出:
--------------------------------------------------------------------
旋转——空白边界和采样方式
--------------------------------------------------------------------
**旋转比缩放多一个麻烦:转完以后角上会出现「没有源像素」的区域。**
**这个区域填什么,是画质和实现复杂度的分界。**
角度 有效像素占比 空白占比
--------------------------------------
15° 88.7% 11.3%
30° 82.6% 17.4%
45° 79.7% 20.3%
90° 74.4% 25.6%
**角度越大空白越多,90° 时最多**——因为这里画布死守原来的 120×160,转过去的画面塞不进去。
**真实的 90° 旋转必须交换宽高**(画布换成 160×120),那样就一点空白都没有。
**这一行数不修,就会得出「90° 旋转质量最差」的错误结论**——其实是画布尺寸没跟着换。
**两种采样在旋转上的差别:**
30° 旋转,两种结果在共同有效区里最大差 **254** 级,平均差 2.6 级
耗时:
最近邻:**1.38 毫秒**
双线性:**4.07 毫秒**
**工程里三个选择:**
做法 空白怎么办 代价
----------------------------------------------------------------
留黑 填 0 简单,边上一条黑
放大到铺满 先算需要多大,再放大 丢边,但没黑边
只转 90 的倍数 没有空白 最快,画质无损
**第三行是监控里最常用的**:云台转 90° 的预置位,直接用矩阵转置加翻转,
**不用采样、不丢像素、还快几十倍**。
**只有「任意角度」才需要插值**——而任意角度在显示场景里很少见。
空白从 11.3% 一路涨到 25.6%,因为画布尺寸一直没变。转过去的画面越来越「装不下」。 这不是算法的缺陷,是画布没跟着换。真实的 90° 旋转要交换宽高,空白就是 0。
耗时:最近邻 1.23 毫秒,双线性 3.36 毫秒,只差 2.7 倍——比缩放的五十倍温和得多。 为什么:双线性旋转的临时数组比缩放小(输出和输入同尺寸),而且没有多维索引的重排。 同一个算法,不同场景的倍数完全不同。
最大差 254、平均差 2.6 这一对数字,说明「极端值」和「整体」要分开看。只报一个数都会误导。
30 秒实验
- 把 90° 那组的画布改成
(iw, ih)(交换宽高),看空白是不是变成 0。 - 用
np.rot90(a)转 90°,和手写的结果比,看是不是完全一样——应该是逐像素相同, 因为它就是转置加翻转。 - 把双线性的
astype(np.uint8)改成np.round(...),看平均差会不会变小。
常见误区
- 以为 90° 旋转空白最多。 那是画布没换宽高的结果。真实实现要交换宽高,空白是 0。
- 以为旋转只能靠插值。 90° 的倍数直接转置加翻转,逐像素精确、还快几十倍。
- 以为平均差小就说明两者一样。 平均 2.6 级但最大 254 级,说明边缘处有完全不同的像素。 统计量要成对报。
自测三问(点开对答案)
1. 为什么旋转要用反向映射? —— 正向映射会让源像素落到小数位置,目标图出现空洞。 反向映射遍历目标像素,每个都能找到一个源位置(可能越界)。 2. 90° 旋转为什么能不用插值? —— 因为每个目标像素都能精确对应到一个源像素——就是坐标交换加翻转,没有小数位置。 3. 为什么两种采样的耗时在旋转上只差 2.7 倍,缩放上差五十倍? —— 旋转的输出和输入同尺寸,临时数组小;缩放要按新尺寸建数组,尺寸一大开销就上去了。下一章转到采集的另一端:用 MFC 加 OpenCV 做视频采集与播放。
- ☐ (本章完,下一节 04.01.01 YUV编码为H.264)