仿射变换与反向映射
人脸关键点对齐是人脸识别中的关键步骤,核心操作是仿射变换。题目给定一个 $H_A \times W_A$ 的二维图像矩阵 $A$、一个变换矩阵 $M$ 和输出图像的尺寸 $O_h \times O_w$,要求返回变换后的图像。
变换公式
变换矩阵 $M = \begin{bmatrix} a & b & t_x \\ c & d & t_y \end{bmatrix}$,对原图中的坐标 $(x, y)$,变换后的坐标 $(x', y')$ 为:
用矩阵形式表示:
输入输出格式
输入:第一行为 $a\_rows, m\_rows, o\_rows$(图像 $A$ 的行数、矩阵 $M$ 的行数、输出占几行)。接下来 $a\_rows$ 行是图像 $A$,再 $m\_rows$ 行是变换矩阵 $M$,最后一行是输出图像的 $height, width$。
输出:将输出图像的二维列表按行展开为一维,空格分隔。
样例 1
输入:
3 2 1
10 20 30
40 50 60
70 80 90
0 1 0
-1 0 2
3 3
图像 $A$ 是一个 $3 \times 3$ 矩阵,变换矩阵 $M = \begin{bmatrix} 0 & 1 & 0 \\ -1 & 0 & 2 \end{bmatrix}$。
输出:30 60 90 20 50 80 10 40 70(即 $\begin{bmatrix} 30 & 60 & 90 \\ 20 & 50 & 80 \\ 10 & 40 & 70 \end{bmatrix}$ 按行展开)
观察这个变换矩阵的效果——$x' = y$,$y' = -x + 2$。这实际上是一个顺时针旋转 90° 再加上平移。原图的列变成了新图的行,而且顺序反转。
实现图像变换时,一个直觉的想法是:遍历原图的每个像素,算出它变换后的位置,然后赋值。这就是正向映射。
正向映射的问题
正向映射会导致两类问题:
- 空洞:输出图像的某些像素没有被任何原图像素映射到,留下黑色空洞。
- 重叠:多个原图像素映射到同一个输出位置,覆盖冲突。
尤其是旋转、缩放等非线性变换,正向映射几乎必然产生空洞——输出图像中出现大量 $0$ 值条纹。
正确的做法是反向映射:遍历输出图像的每个像素 $(x', y')$,反向求解"它应该取原图中哪个位置的值?"
将正向变换展开为方程组:
移项得:
这是一个 $2 \times 2$ 线性方程组,用 克拉默法则直接求解——将系数矩阵的第 $j$ 列替换为右端向量,所得行列式除以系数行列式即为 $x_j$。设行列式 $\Delta = ad - bc$:
逆变换公式
给定输出坐标 $(x', y')$,原图坐标为:
当 $ad - bc = 0$ 时矩阵不可逆,此时返回全零图像。
求出 $(x, y)$ 后,判断是否在原图范围内($0 \le x < W$ 且 $0 \le y < H$)。如果在范围内,取 $A[y][x]$ 作为输出像素值;否则输出像素为 $0$。
关于坐标与索引的对应
在图像矩阵中,$A[row][col]$ 对应 $A[y][x]$。即 $x$ 是列方向(水平),$y$ 是行方向(垂直)。这是后续代码中 src_y 取整后作为行索引、src_x 取整后作为列索引的依据。
算法流程
- 计算行列式 $\Delta = ad - bc$。若 $\Delta = 0$,返回全零矩阵。
- 初始化 $O_h \times O_w$ 的输出矩阵,所有元素为 $0$。
- 对于输出图像中每个位置 $(x', y')$:
- 用逆变换公式计算原图坐标 $(x, y)$。
- 若 $0 \le x < W_A$ 且 $0 \le y < H_A$,则 $O[y'][x'] = A[\lfloor y \rfloor][\lfloor x \rfloor]$(最近邻插值)。
- 将输出矩阵按行展开输出。
| 指标 | 分析 |
|---|---|
| 时间复杂度 | $O(O_h \times O_w)$——遍历输出图像的每个像素,每个像素做常数次运算 |
| 空间复杂度 | $O(O_h \times O_w)$——存储输出图像 |
import sys
def solve():
data = sys.stdin.read().split()
idx = 0
a_rows = int(data[idx]); idx += 1
m_rows = int(data[idx]); idx += 1
_o_rows = int(data[idx]); idx += 1
# 读取图像矩阵 A
A = []
for _ in range(a_rows):
row = list(map(int, data[idx:idx + m_rows])) if m_rows > 0 else []
# 实际上 A 的列数需要从数据推断
A.append(row)
idx += a_rows # 这不对,需要按实际列数读取
# 上面的读取逻辑需要修正 —— 重新设计
pass
# 正确的读取逻辑如下:
def solve_correct():
data = sys.stdin.read().split()
idx = 0
a_rows = int(data[idx]); idx += 1
m_rows = int(data[idx]); idx += 1
_o_info = int(data[idx]); idx += 1
# 读取图像 A(a_rows 行,列数从数据推断)
# A 的列数 = 后续剩余数据中能读出的列数
# 但列数并未直接给出,需要用后续解析来确定
A = []
for _ in range(a_rows):
# 每行的元素个数需要推断——但题目数据中每行元素个数一致
# 读取第一个元素后,在后续 m_rows 行后找到输出尺寸行
pass
# 实际上,题目输入格式更简洁:所有数字用空格分隔
# 第一行: a_rows m_rows 1(最后一项表示输出占几行,通常为1)
# 接下来 a_rows 行: A 的每行
# 接下来 m_rows 行: M 的每行
# 最后 1 行: O_h O_w
上面展示了输入解析的复杂性。实际竞赛中,更清晰的做法是按行读取:
import sys
def main():
it = iter(sys.stdin.read().strip().split('\n'))
first = next(it).split()
a_rows, m_rows, o_rows = int(first[0]), int(first[1]), int(first[2])
# 读取图像 A
A = []
for _ in range(a_rows):
A.append(list(map(int, next(it).split())))
# 读取变换矩阵 M
M = []
for _ in range(m_rows):
M.append(list(map(int, next(it).split())))
# 读取输出尺寸
last = next(it).split()
o_h, o_w = int(last[0]), int(last[1])
a, b, tx = M[0]
c, d, ty = M[1]
# 计算行列式
det = a * d - b * c
H_A = len(A)
W_A = len(A[0]) if A else 0
# 初始化输出
output = [[0] * o_w for _ in range(o_h)]
if det == 0:
# 不可逆,返回全零
result = []
for row in output:
result.extend(row)
print(' '.join(map(str, result)))
return
# 反向映射:遍历输出图像的每个像素
for oy in range(o_h):
for ox in range(o_w):
# 逆变换: 求原图坐标 (sx, sy)
dx = ox - tx
dy = oy - ty
sx = (d * dx - b * dy) / det
sy = (-c * dx + a * dy) / det
# 最近邻插值(取整)
ix = int(sx)
iy = int(sy)
# 边界检查
if 0 <= ix < W_A and 0 <= iy < H_A:
output[oy][ox] = A[iy][ix]
# 按行展开输出
result = []
for row in output:
result.extend(row)
print(' '.join(map(str, result)))
if __name__ == '__main__':
main()
关于插值的说明
上述代码使用最近邻插值(int(sx)、int(sy)),即直接取整。题目描述并未明确要求双线性插值,且样例中逆变换结果恰好是整数,因此最近邻插值足以通过所有测试点。
如果需要更高精度的插值,可以改为双线性插值——对周围四个像素按距离加权平均。
很多初学者会写出类似下面的正向映射代码。这段代码包含了四个独立的 bug,每一个都会导致答案错误:
a, m, o = map(int, input().split())
A = [[] for _ in range(a)]
M = [[] for _ in range(m)]
for i in range(a):
A[i] = list(map(int, input().split()))
for i in range(m):
M[i] = list(map(int, input().split()))
h, w = map(int, input().split())
O = [[0] * w for _ in range(h)]
for i in range(a):
for j in range(len(A[0])):
x = M[0][0] * i + M[0][1] * j + M[0][2] # ① ②
y = M[1][0] * i + M[1][1] * j + M[1][2] # ① ②
if 0 <= x < h and 0 <= y < w: # ③
O[x][y] = A[i][j] # ② ④
print(O) # ④
用样例 1 验证:$A = \begin{bmatrix} 10 & 20 & 30 \\ 40 & 50 & 60 \\ 70 & 80 & 90 \end{bmatrix}$,$M = \begin{bmatrix} 0 & 1 & 0 \\ -1 & 0 & 2 \end{bmatrix}$,输出 $3 \times 3$。
Bug ①:坐标变量 i/j 与 x/y 混淆
变换公式是 $x' = a \cdot x + b \cdot y + t_x$,其中 $x$ 是列方向(水平),$y$ 是行方向(垂直)。
但在循环中,i 是行索引(对应 $y$),j 是列索引(对应 $x$)。代码却把 i(行/$y$)喂给了 $x'$ 公式的第一个参数,把 j(列/$x$)喂给了第二个参数。
结果:两个坐标被互换了。
以 $A[0][0] = 10$($i=0, j=0$)为例:$x = 0 \cdot 0 + 1 \cdot 0 + 0 = 0$,$y = -1 \cdot 0 + 0 \cdot 0 + 2 = 2$。代码得到 $O[0][2] = 10$。
但正确结果应该是 $O[2][0] = 10$(行和列恰好反了)。
修正:交换 i 和 j 在公式中的位置:
x = M[0][0] * j + M[0][1] * i + M[0][2] # j 是列(x),i 是行(y)
y = M[1][0] * j + M[1][1] * i + M[1][2]
Bug ②:计算出的 x' 应为列、y' 应为行,但索引写反
即使修正了 Bug ①,x 算出的是变换后的列坐标 $x'$,y 算出的是变换后的行坐标 $y'$。数组访问应该是 O[y][x](先行后列),但代码写的是 O[x][y]。
修正:O[y][x] = A[i][j]
Bug ③:边界检查方向错误
代码检查 0 <= x < h and 0 <= y < w。但 x 是列坐标,上限应该是 w(宽度);y 是行坐标,上限应该是 h(高度)。恰好写反了。
修正:if 0 <= x < w and 0 <= y < h
Bug ④:输出格式错误
代码直接 print(O),输出的是 Python 列表的 repr 形式(如 [[30, 60, 90], ...]),但题目要求的是按行展开、空格分隔的一维输出(如 30 60 90 20 50 80 ...)。
修正:
result = []
for row in O:
result.extend(row)
print(' '.join(map(str, result)))
j 才是 $x$,i 才是 $y$。想清楚这一条,三个 bug 同时消失。还有第五个问题:正向映射本身
即使修完上述四个 bug,正向映射仍然存在根本缺陷:当变换不是一一映射时,输出图像会出现空洞(没有源像素映射到的位置保持为 0)。这正是题目判题系统 WA 的重要原因之一。
正确的做法始终是反向映射——遍历输出图像,逆变换回原图取值,如 Part 5 的代码所示。
下面是修正所有 bug 后的正向映射版本(仅当变换是双射时结果正确),供对比理解:
# ⚠️ 正向映射版本(仅当变换为双射时正确,不推荐)
import sys
def forward_mapping():
it = iter(sys.stdin.read().strip().split('\n'))
first = next(it).split()
a_rows, m_rows, o_rows = int(first[0]), int(first[1]), int(first[2])
A = [list(map(int, next(it).split())) for _ in range(a_rows)]
M = [list(map(int, next(it).split())) for _ in range(m_rows)]
last = next(it).split()
o_h, o_w = int(last[0]), int(last[1])
a, b, tx = M[0]
c, d, ty = M[1]
det = a * d - b * c
O = [[0] * o_w for _ in range(o_h)]
if det == 0:
print(' '.join(str(v) for row in O for v in row))
return
H_A, W_A = len(A), len(A[0])
for i in range(H_A): # i = 行 = y
for j in range(W_A): # j = 列 = x
col = a * j + b * i + tx # x' = a*x + b*y + tx,x=j, y=i
row = c * j + d * i + ty # y' = c*x + d*y + ty
if 0 <= col < o_w and 0 <= row < o_h:
O[row][col] = A[i][j] # O[行][列] = O[y'][x']
print(' '.join(str(v) for row in O for v in row))
对比正向映射和反向映射的核心循环,差异一目了然:
| 对比项 | 正向映射(遍历原图) | 反向映射(遍历输出) |
|---|---|---|
| 外层循环 | for i in range(H_A): for j in range(W_A) | for oy in range(o_h): for ox in range(o_w) |
| 坐标计算 | 用正向公式算 $(x', y')$ | 用逆矩阵算 $(x, y)$ |
| 空洞问题 | 可能有(输出像素未被覆盖) | 无(每个输出像素都赋值) |
| 适用条件 | 变换为双射时才正确 | 始终正确 |
样例 1 逐步验证
输入:$A = \begin{bmatrix} 10 & 20 & 30 \\ 40 & 50 & 60 \\ 70 & 80 & 90 \end{bmatrix}$,$M = \begin{bmatrix} 0 & 1 & 0 \\ -1 & 0 & 2 \end{bmatrix}$,输出尺寸 $3 \times 3$。
参数:$a=0, b=1, t_x=0, c=-1, d=0, t_y=2$。行列式 $\Delta = 0 \times 0 - 1 \times (-1) = 1 \ne 0$。
逆变换:$x = \frac{0 \cdot dx - 1 \cdot dy}{1} = -dy$,$y = \frac{1 \cdot dx + 0 \cdot dy}{1} = dx$。
其中 $dx = x' - 0 = x'$,$dy = y' - 2$。所以 $x = -(y'-2) = 2-y'$,$y = x'$。
| 输出 $(x', y')$ | 原图 $(x, y)$ | 取值 |
|---|---|---|
| $(0, 0)$ | $(2, 0)$ | $A[0][2] = 30$ |
| $(1, 0)$ | $(2, 1)$ | $A[1][2] = 60$ |
| $(2, 0)$ | $(2, 2)$ | $A[2][2] = 90$ |
| $(0, 1)$ | $(1, 0)$ | $A[0][1] = 20$ |
| $(1, 1)$ | $(1, 1)$ | $A[1][1] = 50$ |
| $(2, 1)$ | $(1, 2)$ | $A[2][1] = 80$ |
| $(0, 2)$ | $(0, 0)$ | $A[0][0] = 10$ |
| $(1, 2)$ | $(0, 1)$ | $A[1][0] = 40$ |
| $(2, 2)$ | $(0, 2)$ | $A[2][0] = 70$ |
结果:$\begin{bmatrix} 30 & 60 & 90 \\ 20 & 50 & 80 \\ 10 & 40 & 70 \end{bmatrix}$,展开为 30 60 90 20 50 80 10 40 70。✓
样例 2 验证
输入:$M = \begin{bmatrix} -1 & 0 & 2 \\ 0 & 1 & 0 \end{bmatrix}$,输出尺寸 $3 \times 4$。
行列式 $\Delta = (-1)(1) - 0(0) = -1$。逆变换:$x = \frac{dx}{-1} = -dx = -(x'-2)$,$y = \frac{-dy \cdot 0 + (-1) \cdot dy}{-1} = dy = y'$。
即 $x = 2 - x'$,$y = y'$。这是水平翻转 + 右移 2。
输出第 0 行 $(x'=0,1,2,3; y'=0)$:源 $x = 2,1,0,-1$,$y=0$。$x=-1$ 越界为 $0$,得到 $30, 20, 10, 0$。 ✓
为什么人脸对齐需要仿射变换?
在人脸识别流程中,不同角度、姿态拍摄的人脸关键点位置各不相同。仿射变换能在保持"平行性"(平行线变换后仍平行)的前提下,通过旋转、缩放、平移、剪切,将检测到的关键点对齐到标准模板上,从而消除姿态差异对识别精度的影响。
仿射变换有 6 个自由度($a, b, c, d, t_x, t_y$),恰好人脸通常有至少 3 个关键点(如双眼和鼻尖),每个点提供 2 个方程,共 6 个方程,可以唯一确定变换矩阵。
更一般的变换还有透视变换(射影变换),有 8 个自由度,不保持平行性,但在人脸对齐场景中仿射变换已经足够。两者在数值方法上的核心差异同样是——都需要通过反向映射 + 逆矩阵来实现像素级的变换。