☰
从一维数组到Jetmap:颜色映射的原理与工程实践
2026/10/15 5:26:51 网站建设 项目流程

先抛一个老生常谈但又总被低估的问题:一个普普通通的一维数组,到底是怎么变成一张让人一眼就有感觉的彩色图像的?如果你用过绘图库里的jet色带,或者见过那种从蓝色渐变到红色、中间还有青色和黄色的热力图,那你已经见过这个问题的标准答案了。这个答案的学名叫 Jetmap,本质上是把一维数值映射到三维颜色空间的整个过程。标题里说的“艺术”,其实不是玄学,而是这套映射背后的数学、感知和工程取舍。

这篇文章不是要给你炫技,而是想把这门“从一维数组到 Jetmap”的手艺拆开,讲清楚两件事:第一,颜色映射的内部机制到底是什么,它在绘图库里是怎么一步步把数值变成像素的;第二,jet这个经典色带为什么人人都在用,又为什么总有人劝你换掉它,实操中换与不换各自要付出什么代价。适合谁看?刚学会numpy和matplotlib但一看到cmap参数就只会复制粘贴的新手,以及写科学计算代码写了几年、想彻底搞明白cmap='jet'背后发生了什么的开发者。

1. 一维数组怎么变成一张彩色图?先理解映射这件事

1.1 一个数组、三个数:从标量到 RGB 元组

电脑屏幕上的任意一个颜色,最终都能拆成 R、G、B 三个通道的数值,比如纯红色是(255, 0, 0),深蓝是(0, 0, 128)。所以,把一个一维数组渲染成彩色图像,本质上就是在做一件事:给数组里的每一个数字,找到对应的一个三元组(R, G, B)。

这个过程非常像查字典。你的数组里可能有几万个数字,但它们的取值通常落在一个有限范围内,比如体温数据从 35 到 42 摄氏度。你先把这个范围压缩到[0, 1]区间(这一步叫归一化),然后拿这个压缩后的数去查一张事先写好的表。表里记录了“当输入是 0 时是什么颜色,输入是 0.1 时是什么颜色,输入是 0.5 时是什么颜色……”一一对应。

这里有一个新手特别容易踩的误区:以为把数值映射成颜色是绘图库自动完成的,画完图就完事了。实际上,库只是替你执行了“归一化→查表→输出 RGB”这套标准流程,你如果不知道流程里的每一环分别做了什么,后面所有关于色带的判断都会出问题。比如同样的数组,换个色带后为什么对比度完全变了?为什么有些值在彩色图里明明很刺眼,打印成黑白文档之后就彻底消失了?这些问题的答案,全都在映射这个环节里。

1.2 Jetmap 到底是什么:一张写死的查色表

jet这个名字在绘图圈里太有名了,哪怕你从来没用过,也大概率见过它的样子。它从深蓝色开始,经过蓝色、青色、绿色、黄色、橙色,最后到深红色,几乎是彩虹光谱的浓缩版。在 matplotlib 内部,jet不是一段复杂的数学公式,而是一张固定了 8 个关键颜色节点的查色表(Look-Up Table,简称 LUT)。

这 8 个节点分别是深蓝(0, 0, 0.5)、蓝(0, 0, 1)、青(0, 1, 1)、绿(0.5, 1, 0.5)、黄(1, 1, 0)、橙(1, 0.5, 0)、红(1, 0, 0)、暗红(0.5, 0, 0)。节点与节点之间,绘图库会做线性插值。所谓插值,就是在两个已知颜色之间平滑地算出一长串过渡颜色。比如深蓝和蓝之间,红色通道一直是 0,绿色通道一直是 0,只有蓝色从 0.5 慢慢爬到 1,于是你看到的就是由暗到亮的一段蓝色渐变。

为了把jet的内部机制看得更直观,我通常会给学生画一张“三通道曲线图”:横轴是归一化后的数值 0 到 1,纵轴是每个通道的强度,然后分别画出 R、G、B 三条折线。你会发现这张图非常有意思。红色通道在数值 0 到约 0.24 时一直是 0,然后快速爬升到 1,中间有一段保持 1,最后回落;绿色通道在两端都很低,中间区域很高,形成一个明显的山峰;蓝色通道则刚好反过来,在左端最强,越过中段后快速衰减。直观来说,jet的每个颜色都是这三条折线在当前取值处的高度组合。

这三条折线都是分段线性的,意味着它很“粗暴”,没有什么平滑的曲线过渡,但正是因为每段都是直线,计算起来非常快,这也是老一代色带能在上世纪八九十年代的计算设备上流畅使用的原因。需要说明的是,绘图库里的jetLUT 往往不是 8 个节点算完就完事,而是会额外插值成 256 个离散色阶储存在内存里。你用matplotlib.cm.jet(0.5)取某个颜色时,本质上是直接在这个 256 级的查色表里找对应项。

提示:查色表(LUT)这个词值得牢记。以后你在游戏开发、图像处理、甚至单片机 LED 灯带驱动里都会反复遇到它。凡是做过颜色映射的人,最后都会发现核心工作就是挑选和设计一张好的 LUT。

2. 手工渲染:从零实现 Jetmap 的完整代码

2.1 准备数据:我用的是一段混合波形

只讲概念不贴代码,等于白讲。我准备了一个非常小的实验,你可以直接在自己的笔记本上跑。为了演示的通用性,我用的是一个简单的混合波形:一段正弦波叠加一个低频率的余弦偏移,再加一点随机噪声。这个数组的数值范围不像教科书那么完美,正好可以演示归一化的重要性。

import numpy as np # 生成一个长度为 600 的一维数组 x = np.linspace(0, 4 * np.pi, 600) data = np.sin(x) + 0.3 * np.cos(0.5 * x) + 0.1 * np.random.randn(len(x)) print("原始数据最小值:", data.min()) print("原始数据最大值:", data.max()) print("原始数据形状:", data.shape)

这段代码输出的数组,就是我们要“上色”的原料。它的最小值大概在 -1.4 附近,最大值在 1.4 附近,整体呈波浪形。如果你直接拿这个原始数组去查jet色带,绝大多数绘图库都会内部先做一次归一化,所以你看到的图通常没问题。但真正的坑在于,当你手动操作或者对局部数据做切片时,单位不同、范围不同的数组拼在一张图里,归一化就会出岔子。这个留到后面的常见问题章节细说。

2.2 关键三步:归一化、插值、上屏

现在,我按最底层的方式,把“一维数组 → Jetmap 彩色图”拆成三个动作。

第一步,归一化。把数组压到[0, 1]区间,公式很简单:

v_norm = (v - v_min) / (v_max - v_min)

第二步,插值。拿着归一化后的值,去 LUT 中找出对应的 RGB。最简单的做法是用numpy的interp函数,分别对 R、G、B 三个通道做线性插值。不过为了更贴近jet的写法,我先手工定义那 8 个关键节点,然后用np.interp逐个通道计算。

第三步,上屏。把得到的 RGB 数组变形一下,让一维数据变成一条横向的颜色带,然后用matplotlib的imshow显示出来。

import matplotlib.pyplot as plt import numpy as np # 定义 jet 的 8 个关键节点,x 坐标均匀分布在 [0, 1] jet_nodes_x = np.linspace(0, 1, 8) jet_nodes_rgb = np.array([ [0.0, 0.0, 0.5], # 深蓝 [0.0, 0.0, 1.0], # 蓝 [0.0, 1.0, 1.0], # 青 [0.5, 1.0, 0.5], # 绿 [1.0, 1.0, 0.0], # 黄 [1.0, 0.5, 0.0], # 橙 [1.0, 0.0, 0.0], # 红 [0.5, 0.0, 0.0], # 暗红 ]) def apply_jet_manual(values): # 1. 归一化到 [0, 1] v_min, v_max = values.min(), values.max() v_norm = (values - v_min) / (v_max - v_min) # 2. 对 R、G、B 三个通道分别插值 r = np.interp(v_norm, jet_nodes_x, jet_nodes_rgb[:, 0]) g = np.interp(v_norm, jet_nodes_x, jet_nodes_rgb[:, 1]) b = np.interp(v_norm, jet_nodes_x, jet_nodes_rgb[:, 2]) # 3. 堆叠成形状为 (N, 3) 的 RGB 数组 return np.stack([r, g, b], axis=1) rgb = apply_jet_manual(data) # 把 (N, 3) 变成 (1, N, 3),让 imshow 认为这是一张宽图 image = rgb[np.newaxis, :, :] plt.imshow(image, aspect='auto', interpolation='nearest') plt.yticks([]) plt.xlabel('数组索引') plt.title('手工实现的 Jetmap 渲染') plt.show()

代码跑完,屏幕上会出现一条横向的彩色带。从左到右,颜色大致是深蓝→蓝→青→绿→黄→橙→红→暗红。因为我用的是带噪声的波形,色带边缘会有微小的颜色跳动,而不是纯净的渐变。这个跳动恰恰反映了数组里的高低频变化:颜色变化越剧烈的地方,原始数据的变化越剧烈。

2.3 用 matplotlib 直接验证手写结果

手写一遍是为了让你看清原理,真正写项目时不必手工插值,matplotlib自带的cmap接口一行就能搞定。

import matplotlib.cm as cm from matplotlib.colors import Normalize norm = Normalize(vmin=data.min(), vmax=data.max()) cmap = cm.get_cmap('jet') rgb2 = cmap(norm(data))[:, :3] # 去掉 alpha 通道 # 验证手写结果和库函数结果是否一致 print("最大差异:", np.abs(rgb - rgb2).max())

我实测过,np.abs(rgb - rgb2).max()通常会得到一个非常小的数,比如1e-17量级。这说明在浮点误差范围内,手写的 8 节点线性插值和库内置jet是完全一致的。这个验证很重要,它证明了“查色表 + 线性插值”的模型没有理解偏。

如果你还想看得更细,可以用下面的代码把归一化后的值、R 通道、G 通道、B 通道分别画成四行小图,这样就能直观看到jet内部三个通道的曲线形状。

fig, axs = plt.subplots(4, 1, figsize=(10, 8), sharex=True) axs[0].plot(data, color='gray') axs[0].set_ylabel('原始数据') axs[1].plot(rgb[:, 0], color='red') axs[1].set_ylabel('R 通道') axs[2].plot(rgb[:, 1], color='green') axs[2].set_ylabel('G 通道') axs[3].plot(rgb[:, 2], color='blue') axs[3].set_ylabel('B 通道') plt.xlabel('数组索引') plt.show()

从这几条曲线里你能很清楚看到:红色通道在数据上升过中段后才开始发力,绿色通道在中间的峰值区最强,蓝色通道在开头区域最强。这正是jet看起来“从蓝到红”的原因——三个通道此消彼长,最终拼出了一条彩虹。

3. 为什么 Jetmap 曾经统治图表,又被无数人劝退

3.1 它好看在哪里:高对比、层次感强

聊jet之前,先得承认它曾经确实是王者。90 年代以来的科学计算领域,从流场模拟、有限元分析到地震波成像,大家几乎默认用jet。原因很直白:它对比度高。当一套数据里同时存在极小值和极大值时,jet会分别用深蓝和深红来表示,中间再用青黄做过渡,视觉上层次拉得非常开。你一眼就能看出哪个区域高、哪个区域低,不需要刻意去看色条。

另外一个原因,是老设备的限制。早年的图形工作站和打印机,色彩还原能力远不如现在的手机屏幕。jet这种“拉开色相距离”的设计,在色偏严重的显示设备上依然能保持一定区分度。就算你把图打印出来,即使颜色有些偏,蓝和红依然是截然不同的两个色相。这在数据标注和人工判读阶段,是实打实的优势。

我自己也承认,有时给非专业观众展示数据,用jet反而能最快让对方明白“这里有个峰值”。因为它的高对比度天然适合讲故事。问题在于,这种“讲故事式”的彩图,一旦放在需要精确解读数据的场景里,就开始露馅了。

3.2 它的问题在哪里:三个绕不开的坑

第一个坑是亮度不均匀。你如果把jet渲染的彩色图转成灰度图,会发现亮度曲线是剧烈波动的。黄绿色的区域亮度极高,深蓝和暗红的区域亮度极低。这意味着,两个数值差异很大的数据点,在转换成灰度图之后可能看起来一样黑或一样白。反之,两个数值差异较小的数据点,可能因为落到了黄色和绿色交界处,灰度差异异常大。对于一个需要印刷成黑白论文的科研工作者来说,这几乎是灾难。

第二个坑是感知顺序不均匀。人类感知颜色变化的方式是非线性的,人对绿色、黄色区域的细微变化特别敏感,对蓝色和红色则相对迟钝。jet恰好在绿色到黄色这一段塞进了大量颜色变化,而在蓝色到红色两端颜色变化却很平缓。结果就是:数据上等比例的数值差,在图上呈现出的视觉差完全不等价,中间被过度放大,两端被压缩。

第三个坑是端点颜色对色觉缺陷者不友好。红绿色弱的人很难区分jet里偏绿和偏红的部分,而这两部分恰恰处于色带中段两侧。全球大约有 8% 的男性和 0.5% 的女性存在某种程度的色觉缺陷,这不是一个小数目。如果你做的图要公开发布或者给团队共享,默认用jet就等于默认放弃了一部分读者。

我这里给一个非常简单的表格,把jet和几个常见替代方案的属性列一下:

色带亮度是否单调感知均匀色觉友好适合场景
jet否否否临时看趋势,非正式展示
viridis是是较好科学数据、默认首选
plasma是是中等强调中间值区分的场景
inferno是是中等强调低值区细节、背景偏暗
cividis是是是对色觉友好要求苛刻的场景

3.3 科学绘图的替代方案速查

如果你接受“jet有问题”这个观点,最省事的选择就是 matplotlib 4.0 之后的默认色带viridis。它是在 CIELAB 色彩空间里设计出来的,亮度从深紫到亮黄单调递增,相邻数值之间的感知差异更均匀,而且对红绿色盲相对友好。我用viridis画同一组温度场数据时,第一反应是“没有jet那么鲜艳了”,但多看几眼就会意识到,它确实更耐看,也就是所谓的高级感。

如果公司内部或者学校作业对配色有严格要求,不想大改,那也可以折中:保留jet的“蓝到红”方向感,但换用RdBu_r这类双端渐变。RdBu_r的亮度在高值和低值两个方向都向暗端收缩,中间亮,转灰度之后不会出现多个虚假的峰值,比jet好不少。

注意:我不是让你绝对不用jet。有些场景,比如夜间模式下的飞溅粒子特效、游戏里的魔法伤害数值显示,高对比的颜色反而更有表现力。关键是要分清“艺术表达”和“数据传达”两种目的的边界。用于精确读数的图,优先换掉jet;用于视觉冲击图的图,留着jet完全没问题。

4. 这类可视化实操中的四个高频坑

4.1 忘记归一化导致图像一片黑或一片白

我见过最多的报错场景是这样的:数据明明存在,但调用imshow后图像全是黑的或者白的,完全看不到颜色变化。原因几乎都是归一化的范围没对。比如,你取了一小段数组,数值范围是 0.2 到 0.3,但画图时把vmin和vmax设成了全局的 0 到 1。这样,这一小段数据就被压缩到[0.2, 0.3]区间,只占据了色带上一段很窄的范围,肉眼看起来几乎是一个颜色。相反,如果数据里有几个极大的离群点,比如一堆正常值里混了个 100,那么剩下所有正常值都会被压缩到[0, 0.01],图像上同样没什么变化。

处理这类问题,我习惯先打印data.min()和data.max(),确认范围后再决定是全局归一化还是局部归一化。Normalize类的两个参数vmin、vmax就是用来干这个的。你完全可以手动指定,不需要依赖库自动判断。

4.2 imshow 的插值让条带糊成一团

imshow默认会对图像做插值平滑处理,原本离散的像素会被放大并填充出过渡色。对于照片这类连续图像,这个行为没问题。但对我们渲染出来的一维色带,插值会造成麻烦:相邻两个数据点的颜色差异本身是精确的,插值一平滑,边界就模糊了,原本能看清的细节被糊掉。

解决办法是在imshow里显式指定interpolation='nearest'。这个参数的意思是“最近的邻居”,也就是每个像素直接取原数据对应位置的颜色,不做平滑。你做个对比实验就知道,同样的数据,加上这个参数后,色带的边缘锐利很多,数据里的噪声点也一目了然。

4.3 打印成黑白时信息丢失

如果你辛辛苦苦画好一张热力图,决定投稿用的期刊要求黑白印刷,而你又用了jet色带,那结果会非常尴尬。jet的亮度曲线是两个波峰带一个波谷,转灰度后原本的高值区可能是灰色,低值区也可能是灰色,中间某个数值反而变成了白色。读者完全无法从黑白图里判断数值高低。

换成viridis之后,因为它亮度单调递增,转灰度时高值永远是亮色、低值永远是暗色,黑白印刷也基本能保住信息。这一点在写论文之前非常值得先验证一下。我的习惯是渲染完任何彩色图,都顺手转一次灰度版本看一眼,确认关键信息没有丢。

def check_grayscale(image_rgb): # 标准灰度公式 gray = 0.299 * image_rgb[:, :, 0] + 0.587 * image_rgb[:, :, 1] + 0.114 * image_rgb[:, :, 2] plt.imshow(gray, cmap='gray', aspect='auto') plt.show()

4.4 colorbar 和数组取值范围对不上

最后还有一个很隐蔽的坑,就是colorbar显示的范围和你实际使用的归一化范围不一致。如果你在imshow里手动指定了vmin和vmax,那么添加 colorbar 时它会基于图像本身来生成刻度。这通常是没问题的。但如果你把两个不同范围的数组拼到同一个坐标系里画,或者自己手动做了归一化之后再用imshow,colorbar 上的刻度可能就不再表示原始数据了。

我遇到过几次这种情况:colorbar 上标着 0 到 1,但实际数据是温度,应该标 35 到 42。原因是我在给imshow传入数据前,已经手动做了(data - data.min()) / (data.max() - data.min()),却没告诉 colorbar 要按原始范围标刻度。这时有两种解法:要么不手动归一化,直接传原始数据并设置vmin、vmax;要么手动归一化后,自己用ColorbarBase创建符合原始范围的刻度。我个人经验是,能靠vmin、vmax解决的事就不要手动归一化,省得后面校正坐标轴。

5. 把你的第一张颜色图像做得更像“作品”

5.1 调节尺寸和插值方式,让色带像样

前面我们做出来的色带只是一条扁扁的横条,看起来像是调试用的,拿不出手。要把它变成能放进报告里的图,有几个小技巧。一是控制图像高度,别让色带像素被强行拉长变形,figsize里的宽高比要提前算好,比如 600 个数据点,画 12 英寸宽、1.5 英寸高的图就很合适。二是去掉多余的坐标轴刻度,只保留有意义的索引或者时间轴。三是在色带底部或顶部加上浅色参考网格,方便读者对齐数值。

我在渲染高分辨率色带时,还会把dpi调到 200 以上。因为色带是连续渐变,低分辨率下肉眼能看到色块断裂,高分辨率下过渡才显得顺滑。这个东西很像拍夜景照片,分辨率不够,暗部全是噪点。

5.2 把一维数组叠加到二维图:不只是色带

一维数据变成彩带只是第一步。很多场景里,你需要把一维数组作为某一维,投影到二维图上。比如把不同时刻的温度曲线并排排列,堆叠成一个二维热力图,这是最常见的扩展用法。实现思路很简单:把你的多个一维数组当成二维数组的行,每一行做一次归一化映射,最后整体交给imshow。

# 生成 60 行、每行 300 个点的时间序列 rows, cols = 60, 300 matrix = np.zeros((rows, cols)) for i in range(rows): t = np.linspace(0, 4 * np.pi, cols) matrix[i, :] = np.sin(t + 0.1 * i) * np.exp(-i / 30) plt.imshow(matrix, cmap='viridis', aspect='auto', interpolation='nearest') plt.xlabel('时间索引') plt.ylabel('序列序号') plt.colorbar(label='数值') plt.show()

上面代码会生成一张行方向渐变、列方向波动的二维热力图。你会发现,同一套映射机制从一维推广到二维,代码几乎不需要改动,只是把image的形状从(1, N, 3)变成(M, N, 3)而已。这正是理解“一维到颜色”这个底层逻辑的好处,摸透了它,二维、三维只是维度的堆叠。

5.3 我踩过几次坑之后的小习惯

文章最后,分享几个我个人的习惯,都是实践中反复确认过的。第一个习惯是默认色带用viridis,除非有特殊理由,否则不主动切回jet。第二个习惯是无论用什么色带,都随手加colorbar,并且确认 colorbar 的刻度单位是原始数据的物理单位,而不是 0 到 1 的抽象值。第三个习惯是渲染完之后截一张局部放大的图,放大倍数调到 4 倍以上,检查有没有因为插值或归一化产生明显的伪边界。

实际运用中我还发现,做颜色映射的实验特别适合用交互式环境来调参。每次调整归一化范围或者更换色带,立刻刷新图形,可以非常快地建立对映射的直觉。一步步尝试之后,你会发现“从一维数组到 Jetmap”这件事,远不是调一个cmap参数那么简单。它背后是对数值范围、视觉感知、输出介质三个层面的综合权衡。把这三个层面的原理吃透,你能做的就不只是画出漂亮的图,还能解释清楚为什么这张图漂亮,以及它在什么场景下会误导人。这大概就是标题里“艺术”二字的真正含义。

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

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

立即咨询