在你打开相机拍下一张照片的瞬间,手机里发生了什么?
那张照片在内存里,长什么样子?
这篇文章的目标只有一个:让你从"看图"的视角,切换到"处理内存"的视角。这是图像处理的第一步,也是最关键的认知跃迁。如果你今天只记住一句话,请记住它:
图像处理,本质上不是在“处理图片”,而是在“操作内存中的数据”。
一、图像的本质:一块矩形的数字阵列
打开一张 100×100 的图片,你看到的是一个彩色方块。但计算机看到的是:
一个二维数组,每个格子存着一个像素的颜色信息
更精确地说:
图像 = 二维像素矩阵
= width × height 个像素
= 每个像素由若干"通道"组成
以最常见的 RGBA 格式为例:
| 坐标 | R(红) | G(绿) | B(蓝) | A(透明度) | | :-- | :-- | :-- | :-- | :-- | | (0,0) | 255 | 0 | 0 | 255 | | (1,0) | 0 | 255 | 0 | 128 | | (0,1) | 0 | 0 | 255 | 255 |
每个通道的值都在 0~255 之间,因为一个字节(8 bit)能存储的范围恰好是 0~255。
二、像素(Pixel)是什么
Pixel 是 "Picture Element" 的缩写,图像元素。它是图像的最小单位,不可再分。
一个像素本身没有尺寸,是抽象的。当图像显示在屏幕上时,一个像素对应显示器上的一个发光点(或若干个——这涉及到 Retina 屏幕的概念,后面讲)。
RGB 颜色模型
人眼有三种感光细胞,分别对红(Red)、绿(Green)、蓝(Blue)敏感。计算机借鉴了这个原理,用三个数字来描述颜色:
红色 = (255, 0, 0)
绿色 = (0, 255, 0)
蓝色 = (0, 0, 255)
白色 = (255, 255, 255) // 三色全满
黑色 = (0, 0, 0) // 三色全无
黄色 = (255, 255, 0) // 红 + 绿
Alpha 通道(透明度)
Alpha 表示像素的不透明程度:
-
•
A = 255:完全不透明(你能看到这个像素的颜色) -
•
A = 0:完全透明(这个像素不可见,透过去看后面的内容) -
•
A = 128:半透明(叠加时与背景各占 50%)
PNG 格式支持 Alpha 通道,JPEG 不支持。 这是格式选择的核心依据之一。
三、内存布局:二维变一维
计算机的内存是一维的(连续的字节序列)。二维的像素矩阵必须被"展平"存储。
行优先存储(Row-major)
最自然的方式:从第 0 行开始,一行一行地存:
图像(4×3):
像素(0,0) 像素(1,0) 像素(2,0) 像素(3,0) ← 第 0 行
像素(0,1) 像素(1,1) 像素(2,1) 像素(3,1) ← 第 1 行
像素(0,2) 像素(1,2) 像素(2,2) 像素(3,2) ← 第 2 行
展平到内存:
[p(0,0), p(1,0), p(2,0), p(3,0), p(0,1), p(1,1), ...]
RGBA8888 格式
每个像素占 4 个字节(R/G/B/A 各一个字节),连续排列:
字节布局:
[R₀, G₀, B₀, A₀, R₁, G₁, B₁, A₁, R₂, G₂, B₂, A₂, ...]
↑ ↑ ↑
像素(0,0) 像素(1,0) 像素(2,0)
"8888" 表示每个通道 8 bit,共 32 bit(4 字节)。
关键公式
总字节数 = width × height × 每像素字节数
= width × height × 4 (RGBA8888)
像素 (x, y) 在数组中的起始字节索引:
index = (y × width + x) × 4
R 通道:pixels[index]
G 通道:pixels[index + 1]
B 通道:pixels[index + 2]
A 通道:pixels[index + 3]
举例: 一张 100×100 的图片:
-
• 像素总数 = 10,000
-
• 字节总数 = 40,000(约 40 KB)
-
• 这是原始未压缩的大小
-
• 保存为 PNG 后,全白图可压缩到约 4 KB(压缩率 10:1)
-
• 保存为 JPEG 后,更小
四、为什么是 y × width + x,而不是 x × height + y?
这是行优先(Row-major)存储的直接结果:
第 0 行占据字节 0 ~ width-1
第 1 行占据字节 width ~ 2×width-1
第 y 行占据字节 y×width ~ (y+1)×width-1
第 y 行,第 x 列的像素 = 第 y×width+x 个像素
像素索引 × 4 = 字节起始地址
这个公式是所有像素访问的核心,必须烂熟于心。
五、坐标系约定:原点在哪里?
这是图像处理最容易踩坑的地方之一。
本框架约定(也是 UIKit/SwiftUI 的约定):
-
• 原点
(0, 0)在图像的左上角 -
•
x向右增大 -
•
y向下增大
(0,0) ────────→ x
│
│ 图像内容
│
↓
y
注意:数学坐标系和 OpenGL 坐标系的原点在左下角(y 向上),与此不同。CGContext 在不同使用方式下也会有不同表现(下篇内容)。
六、MLBitmap:本框架的核心数据结构
理解了以上知识,再来看代码就非常清晰了:
public struct MLBitmap {
public let width: Int
public let height: Int
/// 像素原始数据,RGBA8888,长度 = width × height × 4
public var pixels: [UInt8]
public static let bytesPerPixel = 4
/// 像素 (x, y) 的字节起始偏移
@inline(__always)
func index(x: Int, y: Int) -> Int {
(y * width + x) * Self.bytesPerPixel
}
/// 通过二维坐标读写像素
subscript(x: Int, y: Int) -> Pixel {
get {
let i = index(x: x, y: y)
return Pixel(r: pixels[i], g: pixels[i+1], b: pixels[i+2], a: pixels[i+3])
}
set {
let i = index(x: x, y: y)
pixels[i] = newValue.r
pixels[i+1] = newValue.g
pixels[i+2] = newValue.b
pixels[i+3] = newValue.a
}
}
}
MLBitmap的本质:
对一块图像内存的结构化封装
几个设计决策的背后逻辑:
为什么用 struct 而非 class?
Swift 的结构体(struct)是值类型,赋值时会发生复制(Copy-on-Write)。对于图像处理来说:
-
• 每个 Filter 对图像做变换后,应该返回一个新图像,不修改原始数据
-
• 这符合函数式编程的"不可变性"原则
-
• Swift 的 CoW(写时复制)确保没有真正写入时不会实际复制内存
为什么用 [UInt8] 而非 Data?
[UInt8] 是 Swift 原生类型,访问更简洁,下标运算直接。Data 更适合 IO 操作(写文件、网络传输)。两者可以通过 Data(pixels) / [UInt8](data) 互转。
@inline(__always) 是什么?
index(x:y:) 是像素操作的热路径——每处理一个像素都要调用一次。对于 100×100 的图,就是 10,000 次调用。@inline(__always) 告诉编译器强制内联,消除函数调用开销。
一个关键坑:RGB vs RGBA
既然也有RGB,那为何不考虑这种情形呢?理论上可以,实际开发中基本不用。原因在于:
-
• 内存对齐(4字节更高效)
-
• GPU优化(SIMD)
-
• CoreGraphics默认 RGBA/BGRA
为什么必须用 sRGB?
结论:算法统一用sRGB。原因有三:
-
• 保证计算一致性
-
• 不受设备影响
-
• 方便测试
至于显示,系统自动处理(Display P3 等)。
pixels为何不设计为struct数组,而是UInt8数组
区别在于:
[Pixel]
-
• 好理解
-
• 不利于性能
-
• 不适合底层API
[UInt8]
-
• 连续内存
-
• 可直接给 GPU / CGContext
-
• 高性能
最佳实践:
-
• 底层:UInt8
-
• 上层:Pixel 抽象
七、内存大小的直觉感受
| 图像尺寸 | 像素数 | 原始内存(RGBA) | | :-- | :-- | :-- | | 100×100 | 10,000 | 40 KB | | 1920×1080(1080p) | 2,073,600 | ~8 MB | | 3840×2160(4K) | 8,294,400 | ~32 MB | | 12000×9000(中高端单反) | 108,000,000 | ~412 MB |
这就是为什么需要内存防御:一张稍大的 RAW 图片,仅原始像素数据就可以轻松超过 256 MB,导致 OOM(内存溢出)崩溃。
// ImageLoader 中的防御
let requiredBytes = width * height * MLBitmap.bytesPerPixel
guard requiredBytes <= maxMemoryBytes else {
throw LoadError.memoryTooLarge(bytes: requiredBytes)
}
八、小结
|
概念
|
核心内容
|
| :-- | :-- |
|
图像本质
|
二维像素矩阵,存储在一维连续内存中
|
|
像素
|
最小单元,RGBA 各 8 bit(0~255)
|
|
内存布局
|
行优先,RGBA8888,index = (y×w+x)×4
|
|
坐标系
|
原点左上角,x 向右,y 向下
|
|
内存大小
| width × height × 4
字节,大图注意 OOM
|
| MLBitmap |
struct(值类型),[UInt8] 存储
|
思考题
-
1. 一张 2000×1500 的 RGBA 图像,第 (100, 200) 个像素的蓝色通道在数组第几个字节?
-
2. 如果把行优先改成列优先(Column-major),
index(x:y:)公式会怎么变?遍历图像时性能会有什么影响? -
3. 为什么 JPEG 不支持透明通道,而 PNG 支持?(提示:从格式的设计目标思考)
如果这篇对你有一点启发:
点个赞,让更多人少踩一个坑
转发给那个正在纠结的人
也欢迎关注我——
我们一起,把认知变成长期复利。
往期推荐:
iPhone相册背后的图像处理知识(下)
iPhone相册背后的图像处理知识(中)
iPhone相册背后的图像处理知识(上)
一张图了解图像处理的本质
图像到底是什么
图像处理技术概要图
AI时代,软件工程师必备概念全景图