1. 从一个"看起来简单"的需求说起:把图像的每个像素变成三维空间里的一个点
很多人第一次看到"用散点图画彩色图像色彩分布"这个需求时,第一反应是:不就是把像素点画出来吗?plt.scatter一行代码的事。但真正动手之后才会发现,这里面藏着一堆需要想清楚的问题——一张 1920×1080 的图片有 200 多万个像素,直接丢给 scatter 会卡到怀疑人生;RGB 三个通道怎么映射到 x、y、z 三个坐标轴;点的颜色又该怎么设置才能和它的坐标对应上;画出来的图为什么是一团糊在一起的球,而不是想象中漂亮的色彩分布。
这篇笔记就是围绕这些问题展开的。核心目标很明确:读取一张彩色图像,把每个像素的 R、G、B 三个值当作三维坐标 (x, y, z),在三维空间中画出散点图,并且让每个点的颜色等于它自身的 RGB 值所对应的颜色。最终得到的图,本质上是一张"色彩分布图"——你能直观看到一张图片里所有颜色在 RGB 立方体中的分布情况。
这个技巧在图像处理、色彩分析、数据可视化领域都用得上。比如做图像风格分析时,可以对比不同图片的色彩分布差异;做图像压缩或调色时,可以观察颜色是否集中在某个区域;做机器学习数据集分析时,可以快速判断一批图片的主色调。适合已经会一点 matplotlib 基础、想进一步掌握三维可视化和颜色映射的读者。哪怕你之前没画过 3D 图,跟着走一遍也能跑通。
需要提前说明的是,本文所有代码基于matplotlib和numpy这两个库,不涉及任何深度学习框架。环境准备部分我会给出具体的版本建议,因为不同版本在 3D 散点图的性能表现上差异挺明显的。
2. 环境准备与图像读取:为什么我坚持用 numpy 而不是 PIL 直接转
2.1 库的选择与版本建议
先把依赖装好。核心就三个:
pip install matplotlib numpy pillow版本上,我实测下来比较稳的组合是matplotlib>=3.5、numpy>=1.21、pillow>=9.0。为什么强调版本?因为 matplotlib 在 3.5 之后对 3D 散点图的渲染后端做了优化,早期版本画几万个点就会明显卡顿,而新版本在同样数据量下流畅度提升明显。另外 numpy 1.21 之后对数组切片和广播的处理更高效,后面做像素采样时会用到。
如果你用的是 Anaconda,这些库基本都自带,但要注意 conda 默认装的 matplotlib 版本可能偏旧,建议手动升级一下:
conda update matplotlib numpy pillow2.2 读取图像并转成 numpy 数组
读取图像有两种常见方式,一种是用 PIL(Pillow),一种是用matplotlib.image。我个人的习惯是用 PIL 读取,然后手动转成 numpy 数组,原因是这样对图像模式的控制更清晰。
import numpy as np from PIL import Image import matplotlib.pyplot as plt from mpl_toolkits.mplot3d import Axes3D # 读取图像 img = Image.open('test.jpg') # 统一转成 RGB 模式,避免灰度图或 RGBA 图导致通道数不一致 img = img.convert('RGB') # 转成 numpy 数组,形状为 (height, width, 3) img_array = np.array(img) print(img_array.shape) # 例如 (1080, 1920, 3) print(img_array.dtype) # uint8这里有个容易被忽略的细节:一定要显式调用convert('RGB')。如果原图是灰度图,np.array出来是二维的(height, width),没有通道维度;如果是 PNG 带透明通道,出来是(height, width, 4)。这两种情况直接拿去当三维坐标用都会报错或者得到错误结果。统一转成 RGB 之后,形状永远是(height, width, 3),后面处理起来就省心了。
2.3 把像素拉平成坐标点
现在img_array是一个三维数组,我们需要把它变成一堆(R, G, B)的点。做法是把前两个维度合并:
# 拉平成 (height*width, 3) pixels = img_array.reshape(-1, 3) print(pixels.shape) # 例如 (2073600, 3)reshape(-1, 3)的意思是"保持最后一维是 3,前面的维度自动计算"。这样pixels的每一行就是一个像素的 RGB 值,正好对应三维空间里的一个点坐标。
但问题来了:一张 1080p 的图有 200 多万行,直接画 200 万个点,matplotlib 基本会卡死。所以下一步必须做采样。这也是很多人第一次尝试时踩的坑——代码写对了,但跑不出来,以为是代码错了,其实是数据量太大。
3. 采样策略:为什么不能直接画全部像素,以及怎么采样才不失真
3.1 数据量到底有多大
先算一笔账。假设图片是 1920×1080,总像素数是 2,073,600。matplotlib 的 3D scatter 每个点都要单独计算投影、渲染,200 万个点的渲染时间可能是几分钟甚至直接内存溢出。我实测过,在普通笔记本上,超过 10 万个点就会明显卡顿,超过 50 万个点基本就动不了了。
所以采样是必须的。但采样不是随便抽几个点就行,采样的方式直接影响最终色彩分布图的"代表性"。
3.2 三种采样方式的对比
| 采样方式 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 随机采样 | 随机抽取 N 个像素 | 实现简单,分布均匀 | 可能漏掉小面积但重要的颜色 |
| 等间隔采样 | 每隔 k 个像素取一个 | 保留空间分布规律 | 对周期性纹理可能产生偏差 |
| 分层采样 | 按颜色区域分层抽取 | 代表性最好 | 实现复杂,需要先聚类 |
对于色彩分布图这个场景,我推荐随机采样,因为我们的目标是看颜色的整体分布,而不是保留空间位置信息。随机采样能最大程度保证颜色分布的统计代表性。
# 设定采样数量 n_samples = 20000 # 随机选择索引 indices = np.random.choice(pixels.shape[0], size=n_samples, replace=False) # 取出采样点 sampled_pixels = pixels[indices] print(sampled_pixels.shape) # (20000, 3)replace=False表示不重复采样,避免同一个像素被抽中多次。20000 这个数量是我反复测试后的经验值:既能清晰看出分布形态,又不会卡顿。如果你的机器性能好,可以加到 50000;如果图片本身颜色比较单一,10000 也够用。
3.3 采样数量的取舍逻辑
这里补充一下为什么选 20000 而不是更多或更少。散点图的视觉密度有个临界点:当点数超过大约 30000 时,点与点之间开始大量重叠,视觉上已经看不出更多信息了,反而增加渲染负担。而当点数少于 5000 时,分布形态会显得稀疏,某些颜色区域可能出现"空洞",误导判断。所以 10000 到 30000 是比较合理的区间,20000 是中间值,比较稳妥。
另外,如果你的图片本身分辨率不高(比如 500×500),总像素才 25 万,那采样 20000 已经占了 8%,代表性足够了。如果图片特别大,采样比例会更低,但因为是随机采样,统计上依然有效。
4. 三维散点图的核心:坐标映射与颜色映射怎么同时搞定
4.1 坐标映射:R、G、B 分别对应 x、y、z
这一步其实很直接。sampled_pixels的每一行是[R, G, B],我们把它拆成三个数组:
r = sampled_pixels[:, 0] g = sampled_pixels[:, 1] b = sampled_pixels[:, 2]然后r作为 x 坐标,g作为 y 坐标,b作为 z 坐标。这样每个像素就落在 RGB 立方体里的一个位置。RGB 立方体的范围是 0 到 255(因为图像是 uint8),所以三个轴的范围都是 0 到 255。
这里有个细节:RGB 值的范围是 0-255,但 matplotlib 设置颜色时通常需要 0-1 的浮点数。这个差异后面设置点颜色时会用到,先记着。
4.2 颜色映射:让每个点的颜色等于它自己的 RGB 值
这是整个需求里最关键、也最容易出错的一步。我们希望每个点的颜色就是它自身的 RGB 值。比如一个点是(255, 0, 0),那它应该是纯红色;一个点是(128, 128, 128),那它应该是灰色。
matplotlib 的scatter函数有一个c参数用来设置颜色。当c传入一个形状为(N, 3)或(N, 4)的数组时,它会为每个点单独设置颜色。但这里有个坑:c数组的值必须在 0-1 之间,如果是 0-255 的整数,需要先归一化。
# 归一化到 0-1 colors = sampled_pixels / 255.0 print(colors.shape) # (20000, 3) print(colors.min(), colors.max()) # 0.0 1.0归一化之后,colors的每一行就是一个合法的 RGB 颜色值,可以直接传给c参数。
4.3 完整绘图代码
把前面的步骤串起来:
fig = plt.figure(figsize=(10, 8)) ax = fig.add_subplot(111, projection='3d') ax.scatter(r, g, b, c=colors, s=1, alpha=0.5, marker='.') ax.set_xlabel('R') ax.set_ylabel('G') ax.set_zlabel('B') ax.set_xlim(0, 255) ax.set_ylim(0, 255) ax.set_zlim(0, 255) plt.show()几个参数解释一下:
s=1:点的大小。因为点很多,点太大会糊成一片,1 是比较合适的值。alpha=0.5:透明度。半透明能让重叠的点显示出密度差异,颜色深的地方说明点密集。marker='.':用点标记而不是圆圈,渲染更快,视觉上也更干净。
跑出来之后,你会看到一个立方体形状的点云,颜色从黑到白、从红到青都有分布。这就是这张图片的"色彩分布图"。
5. 实测中遇到的几个坑:从"能跑"到"跑得好"之间差了什么
5.1 坑一:点颜色和坐标对不上
我第一次画的时候,发现点的颜色和它的坐标位置不匹配——明明坐标是(255, 0, 0)的点,颜色却是蓝色。排查了半天,发现是c参数传错了:我传的是sampled_pixels(0-255 的整数),而不是归一化后的colors。matplotlib 对超出 0-1 范围的值会做截断或取模处理,导致颜色完全错乱。
注意:
c参数接收的颜色数组,值必须在 0-1 之间。如果传 0-255 的整数,不会报错,但颜色会错得离谱。
5.2 坑二:3D 图的坐标轴比例失真
matplotlib 的 3D 图默认会根据数据范围自动调整坐标轴比例,但有时候三个轴的比例不一致,导致 RGB 立方体看起来被压扁或拉长。解决办法是手动设置set_box_aspect:
ax.set_box_aspect([1, 1, 1])这样三个轴的长度比例就是 1:1:1,立方体看起来是正方的。这个细节在对比不同图片的色彩分布时特别重要,因为比例失真会影响你对颜色分布的判断。
5.3 坑三:渲染顺序导致颜色显示异常
3D 散点图有个固有的问题:matplotlib 的 3D 渲染是基于画家算法的,远处的点先画,近处的点后画。但如果点的透明度设置不当,会出现"近处的点被远处的点遮挡"的错觉。我的经验是alpha不要低于 0.3,也不要高于 0.7。太低会看不清,太高会糊成一片。0.5 是个比较平衡的值。
5.4 坑四:保存图片时分辨率不够
plt.show()看到的图是屏幕分辨率,如果直接截图保存,放大后会模糊。正确的做法是用savefig指定 dpi:
plt.savefig('color_distribution.png', dpi=300, bbox_inches='tight')dpi=300是印刷级分辨率,bbox_inches='tight'能去掉多余的白边。这样保存出来的图,放大看每个点都清晰。
6. 进阶玩法:让色彩分布图真正"有用"起来
6.1 对比多张图片的色彩分布
单张图的色彩分布图看着挺酷,但实际分析价值有限。真正有用的是对比。比如你想比较两张图片的色调差异,可以把它们画在同一个坐标系里,用不同的透明度区分:
fig = plt.figure(figsize=(12, 10)) ax = fig.add_subplot(111, projection='3d') # 图片 A ax.scatter(r_a, g_a, b_a, c=colors_a, s=1, alpha=0.3, marker='.') # 图片 B ax.scatter(r_b, g_b, b_b, c=colors_b, s=1, alpha=0.3, marker='.') ax.set_xlabel('R') ax.set_ylabel('G') ax.set_zlabel('B') ax.set_box_aspect([1, 1, 1]) plt.show()这样能直观看到两张图的颜色分布是否有重叠、各自集中在哪个区域。比如一张偏暖色调的图,点会集中在 R 值高的区域;一张偏冷色调的图,点会集中在 B 值高的区域。
6.2 用颜色直方图辅助分析
散点图看分布形态,直方图看具体数值。两者结合更全面。可以单独画 R、G、B 三个通道的直方图:
fig, axes = plt.subplots(1, 3, figsize=(15, 4)) for i, (channel, name) in enumerate(zip([r, g, b], ['R', 'G', 'B'])): axes[i].hist(channel, bins=50, color=name.lower(), alpha=0.7) axes[i].set_title(f'{name} Channel') axes[i].set_xlabel('Value') axes[i].set_ylabel('Count') plt.tight_layout() plt.show()直方图能告诉你每个通道的分布是否均匀、是否有明显的峰值。比如一张曝光过度的图,R、G、B 的直方图都会偏向 255 一侧;一张欠曝的图则偏向 0 一侧。
6.3 性能优化:大数据量下的替代方案
如果你确实需要画全部像素(比如做精确的色彩统计),scatter 扛不住的话,可以考虑用hexbin或者降维到 2D 投影。但这就偏离了"三维散点图"的主题了。我的建议是:分析用采样,展示用采样,只有做精确统计时才用全量数据,而且全量数据应该用 numpy 做统计而不是画图。
比如要统计颜色分布,完全可以用 numpy 的histogramdd做三维直方图,比画散点图快得多,而且结果更精确。散点图的价值在于"直观展示",不在于"精确计算"。想清楚这一点,很多性能问题就迎刃而解了。
7. 几个容易被问到的细节问题
7.1 为什么点看起来是"一团"而不是"一个立方体"
因为大多数自然图像的颜色并不是均匀分布在 RGB 立方体里的,而是集中在某些区域。比如一张风景照,颜色可能集中在绿色和蓝色区域;一张人像照,颜色可能集中在肤色对应的区域。所以点云看起来是一团而不是均匀填充的立方体,这是正常的,反而说明你的图是对的。
7.2 灰度图的点会落在哪里
如果图片是灰度的,那么每个像素的 R=G=B,点会落在 RGB 立方体的对角线上(从原点到白色顶点的那条线)。这是一个很好的验证方法:拿一张灰度图跑一下,如果点都落在对角线上,说明坐标映射是对的。
7.3 能不能用 HSV 代替 RGB
可以,但需要先做颜色空间转换。RGB 到 HSV 的转换可以用matplotlib.colors.rgb_to_hsv。不过 HSV 是圆柱坐标系,直接当三维坐标画出来会有点奇怪(H 是角度,S 和 V 是半径和高度)。如果要做,建议先把 HSV 转成笛卡尔坐标再画。这个属于进阶内容,本文不展开。
7.4 图片太大内存不够怎么办
两个思路:一是先缩小图片再读取,用 PIL 的resize方法;二是读取后立即采样,不要保留全量数组。我一般用第一种:
img = Image.open('test.jpg') img = img.convert('RGB') img = img.resize((800, 600)) # 缩小到 800x600 img_array = np.array(img)缩小到 800×600 之后,总像素才 48 万,采样 2 万完全够用,而且读取速度也快很多。
8. 我在实际使用中总结的几条经验
第一,采样数量不是越多越好。我一开始觉得点越多越精确,结果画了 10 万个点,图卡不说,视觉上反而因为重叠太严重看不出分布差异。后来固定在 2 万左右,效果最好。
第二,透明度是 3D 散点图的灵魂。同样的数据,alpha=1.0和alpha=0.5看起来完全是两张图。前者像一堵墙,后者才能看出密度层次。建议从 0.5 开始调,根据实际效果微调。
第三,保存图片一定要指定 dpi。这个坑我踩过好几次,屏幕上看着挺清楚,保存出来一放大就糊了。dpi=300是底线,如果要做海报或者印刷,建议dpi=600。
第四,颜色归一化别忘了。c参数接收 0-1 的浮点数,这是 matplotlib 的约定。虽然传 0-255 的整数不会报错,但颜色会完全错乱,而且这种错误很隐蔽,因为图还是能画出来,只是颜色不对。
第五,3D 图的交互体验比静态图好得多。如果你在 Jupyter Notebook 里跑,建议用%matplotlib notebook或者%matplotlib widget,这样可以用鼠标旋转、缩放,从不同角度观察色彩分布。静态图只能看一个角度,很多信息看不到。
最后分享一个小技巧:如果你想快速判断一张图的"主色调",不用画散点图,直接对采样后的像素做 KMeans 聚类,取聚类中心就是主色调。散点图的价值在于展示"分布",聚类在于提取"代表色",两者用途不同,配合使用效果更好。