ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

RGB归一化全解析:从数值稳定到高效工程实现

RGB归一化全解析:从数值稳定到高效工程实现 做图像处理和视觉算法的几乎没有人能绕开RGB归一化这件事。我第一次认真琢磨它不是因为读论文受启发而是因为训练时loss从第一个epoch开始就一路NaN排查到凌晨才发现输入张量还停在0到255的整数区间几层卷积一叠加激活值直接爆掉。从那以后RGB归一化在我这里就不是顺手加一行的小操作而是整条数据管线里需要单独设计、单独压测的环节。简单说它做的事就是把图像三通道的原始像素值通过线性或近似线性的变换映射到一个数值范围可控、统计分布更友好的区间。它解决的是数值稳定、梯度尺度一致、激活函数工作点这几个具体问题直接决定了模型能不能训得动、收敛得快不快。这篇文章适合三类人看正在搭图像训练管线、被dtype和通道顺序折磨过的算法工程师做工业视觉、需要把相机原始帧喂给检测模型的工程人员以及在嵌入式或边缘设备上跑图像处理、必须在毫秒级完成整帧预处理的开发者。1. 从一个uint8像素说起RGB归一化的本质与边界原始图像在内存里就是一个三维数组形状通常是高×宽×3每个元素是0到255之间的无符号8位整数。归一化就是对这个数组做一次逐元素的数值变换输出一个新的数组或者张量。听起来平平无奇但它的影响面覆盖了从数据读取、预处理、送到网络、再到后处理和可视化的全链路。很多人对它的理解停留在除以255就完事了结果在通道顺序、值域范围、dtype回绕这几个地方反复翻车。所以先把它的本质和边界讲清楚比直接写代码更有价值。1.1 0~255这个区间其实是个历史包袱为什么原始像素是0到255因为绝大多数图像格式用一个字节存一个通道2的8次方等于256取值自然就是0到255。这个设计对存储和显示都很友好一个像素RGB三通道一共3字节内存对齐方便显示设备的转换电路也直接吃这个范围。但对数值计算来说它有两个天生的毛病。第一个是量纲问题。255这个数值在网络里不算小当你的输入不是单张图而是批量经过累加和点积之后数值很容易冲破float16甚至float32的舒适区小数位被逐步吃掉。第二个是分布问题。自然图像的像素值很少均匀铺满整个0到255大量像素挤在中间段直方图是个偏峰的形状。如果直接把这些整数丢进网络第一层权重的更新方向会被少数高亮像素带偏训练过程表现为前期震荡、后期难以收敛。打个生活化的比方假设你要统计一批人的身高和体重身高用厘米、体重用公斤数值都在几十到一百多之间放在一起算距离没问题。但如果身高用毫米、体重用吨一个是一千七、一个是零点零七你把它们放在同一个坐标系里算距离身高这一个维度就完全主导了结果体重信息基本被淹没。归一化干的事就是把这些量纲拉回同一个台面上让每个通道都有说话的机会。1.2 归一化实际影响的三件事很多人知道要归一化但说不清不归一化到底会怎样。我把它拆成三件可以观测、可以量化的事。数值稳定性。以float32为例它的有效尾数是23位大约能精确表示7位十进制有效数字。0到255的整数在这个精度下毫无压力但一旦进入卷积的乘加链条中间结果动辄上千上万累加几百次之后靠后的那些小数位就被舍入误差吃掉了。把输入压到0到1中间结果的量级也跟着下降同样的位宽下能保住更多有效位这是最直接的好处。梯度尺度。反向传播时梯度的大小和输入的尺度直接相关。输入是0到255时第一层权重的梯度会比输入是0到1时大两三个数量级。这意味着你要么把学习率调到极小、收敛慢得让人绝望要么忍受权重更新的剧烈震荡、loss曲线像心电图。做了归一化之后学习率就能待在1e-3到1e-4这个常规区间调参的搜索空间立刻小了一圈。激活函数的工作点。以Sigmoid为例输入绝对值超过4到5之后导数就接近0梯度基本消失。输入没归一化时加权和很容易落进饱和区网络学不动。归一化之后大部分激活值落在导数较大的区间收敛会明显顺畅。Tanh的情况类似只不过它是零中心的饱和区间稍微宽一点。注意这三条没有一条是不可违背的数学定律都是工程上的经验权衡。如果你的网络第一层就是BatchNorm而且输入量级稳定不归一化未必会崩但只要你需要在不同数据源、不同相机、不同批次之间切换归一化就是最省事的一致性保障。它的价值有一半在于统一而不只是缩小。1.3 别把硬件接口转换和归一化混为一谈搜索这个标题时我看到不少人把三路RGB接口转LVDS和RGB归一化放在一起问这是个典型的概念错位值得单独澄清一下因为搞混了会浪费大量排查时间。所谓的三路RGB接口说的是并行RGB信号的传输方式R、G、B三组数据线加上行场同步信号和像素时钟常见的RGB888就是24根数据线并行传输。而LVDS是一种低压差分信号传输方式把并行数据打包成串行的差分对来传特点是抗干扰强、走线少、能拉长传输距离。这两者之间是硬件电气层和协议层的转换涉及的是位宽、时钟频率、时序参数、走线匹配和归一化没有任何关系。归一化是纯数值层面的操作发生在数据已经变成内存里的一个数组之后。它的输入是numpy数组或者张量输出还是数组或张量中间不经过任何物理接口也不改变任何一根线的电平。两者唯一的交集在于如果你从采集芯片或处理器的RGB接口拿到的是8位并行数据数据落进内存之后你才需要考虑要不要把它归一化。搞清楚这条边界能省掉很多无效的排查——归一化出问题永远去查数值、类型、通道顺序不要去怀疑数据通路。2. 四种主流归一化方案以及各自的适用边界知道要归一化之后下一个问题是选哪一种。市面上的做法大体能归成四类它们不是互相替代的关系而是对应不同的数据假设和下游需求。选错了不会报错但会以精度莫名低几个点的形式慢慢折磨你。2.1 简单缩放直接除以255最朴素的做法。公式是out in / 255.0输入uint8的0到255输出float32的0到1。优点是它不依赖任何统计量计算简单结果完全可逆跨数据集、跨项目都一致。缺点是它只统一了量纲没有改变分布的形状。如果你的数据集整体偏暗或者偏亮除以255之后这种偏移依然存在只是被等比缩小了。适用场景快速原型验证、输入分布比较均匀的数据集、你打算在网络第一层用BatchNorm兜底、或者你根本不确定数据分布是什么样。我在做小规模实验时基本都用这个因为省事也没有需要预先计算和保存的均值方差文件复现阻力最小。2.2 对称缩放映射到负一到正一公式是out in / 127.5 - 1.0。它把0到255映射到-1到1。这个方案在生成模型里特别常见因为很多生成器的输出层用Tanh值域正好是-1到1输入端保持一致就不用额外做转换前后对称调试起来心里有底。另外以0为中心的输入对某些优化器更友好梯度方向不会因为输入全为正而产生系统性的偏置收敛路径更直。代价是它把绝对黑0映射成-1如果你下游任务对纯黑这个状态有特殊含义比如掩码的背景、透明区域需要额外注意边界处理。还有就是反归一化的公式更容易写错因为多了一个偏移项。2.3 减均值除标准差ImageNet那一套这是深度学习里最广为人知的方案。ImageNet的统计量大约是均值[0.485, 0.456, 0.406]标准差[0.229, 0.224, 0.225]注意顺序是RGB而且是0到1区间下的值别拿255区间的数去套。完整流程分两步先除以255缩到0到1再逐通道减去均值、除以标准差。工程上可以把两步合并成一次线性变换直接对uint8数据做减少遍历次数import numpy as np # 合并后的等效系数注意顺序是RGB mean np.array([0.485, 0.456, 0.406], dtypenp.float32) std np.array([0.229, 0.224, 0.225], dtypenp.float32) scale 1.0 / (255.0 * std) # 每个通道一个乘法系数 shift -mean / std # 每个通道一个加法偏移 def normalize_imagenet(img_uint8): # img_uint8: H x W x 3, uint8 x img_uint8.astype(np.float32) return x * scale shift这样写比先除255、再减、再除少了两趟遍历也少了一次中间数组分配。至于为什么要用ImageNet的统计量原因很实际绝大多数预训练骨干都是在ImageNet上训出来的第一层卷积的权重已经适配了那个输入分布。你用别的统计量相当于给一个已经调好焦距的镜头前面又加了一片颜色滤镜精度通常会掉一两个点而且是那种找不出原因的掉。提示如果你是从头训练自己的数据集统计量其实更合适。算一遍训练集的逐通道均值和标准差脚本也就二十行效果往往比硬套ImageNet还好一些尤其是在数据领域差异大的时候。2.4 逐通道自适应让数据自己说话当你的数据领域离自然图像很远时比如医学影像、红外图、工业X光片、显微镜图像ImageNet的统计量完全不适用这时候的做法是在训练集上自己统计。逐通道均值就是所有像素值的算术平均逐通道标准差是偏差平方的均值再开方。用numpy做的时候要注意内存几万张图不要一次性载入用增量式的累加和累加平方和import numpy as np n 0 s np.zeros(3, dtypenp.float64) s2 np.zeros(3, dtypenp.float64) for img in dataset: # img: H x W x 3, uint8 x img.reshape(-1, 3).astype(np.float64) / 255.0 n x.shape[0] s x.sum(axis0) s2 (x ** 2).sum(axis0) mean s / n std np.sqrt(s2 / n - mean ** 2) print(mean:, mean, std:, std)这里用float64累加是刻意的。float32累加上亿个数会产生明显的累积误差尤其在方差这种大数相减的公式里两个相近的大数一减有效位损失得非常快。统计阶段多花点时间是值得的因为这个值会被用在整个训练周期里错了就会一直错。四种方案的对比方案公式输出范围依赖统计量典型场景简单缩放x/2550到1否快速原型、教学演示对称缩放x/127.5-1-1到1否生成模型、Tanh输出层ImageNet标准化(x/255-μ)/σ约-2.5到2.5是固定值迁移学习、预训练骨干自适应标准化(x/255-μ_own)/σ_own约-3到3是自算垂直领域、专用数据选型的判断顺序我一般是先确定是不是用预训练权重是就用ImageNet那套不是就看看数据领域自然图像用简单缩放加BatchNorm垂直领域老老实实算自己的统计量。这个顺序能覆盖八成情况。3. 高效实现才是真功夫从for循环到查表前面讲的都是怎么选下面讲怎么快。归一化的单次计算量不大但它在一帧的处理链路里是必经环节频率高所以效率的边际收益非常大。3.1 先看代价一次实测对比理论说再多不如跑个数。我用一张1920×1080的彩色图做基准三种写法分别跑100次取平均写法单次耗时相对速度双层for循环逐像素约1.8秒1倍NumPy向量化约4.2毫秒约430倍查找表加向量化约2.1毫秒约860倍差距不是一点点。Python的循环开销在百万级像素面前完全扛不住一次属性查找、一次类型装箱就够向量化操作处理几百个元素了。所以第一条原则是任何逐像素的Python循环都必须消灭。看到for i in range(h): for j in range(w):这样的结构出现在预处理代码里基本可以直接判定为性能事故现场。但也要注意向量化不是终点。除以255这件事本质上只有256种可能的输入完全可以预先把结果算好存进一张表运行时只做一次索引。这就是LUT查找表思路也是很多图像处理库内部的真实做法。3.2 NumPy向量化的几种写法与差异同样是向量化写法不同效率也差一截import numpy as np img np.random.randint(0, 256, (1080, 1920, 3), dtypenp.uint8) # 写法A最直白会产生uint8到float32的临时拷贝 out_a img.astype(np.float32) / 255.0 # 写法B用乘法替代除法 out_b img.astype(np.float32) * (1.0 / 255.0) # 写法C原地运算复用同一块内存 out_c img.astype(np.float32) out_c * (1.0 / 255.0)性能上B和C略优于A原因有两个除法指令的延迟比乘法高好几倍而1.0/255.0是编译期就能折叠掉的常量把它提到外面运行时只剩一次乘法C的原地版本还省掉了一次同样大小的临时数组分配。别小看这一次分配1920×1080×3的float32数组是24MB每秒处理100帧就是每秒2.4GB的分配和释放内存分配器再高效也会成为瓶颈之一。注意C这种原地写法会改变输入数组的内容。如果这块内存后面还要用比如另一路分支要做色彩空间转换或者保存原图千万不要原地改。我见过因为共用缓冲区导致的诡异问题现象是同一张图两次处理结果不一样排查了半天才发现是上游某处偷偷用了*。还有一个细节是astype(np.float32)和astype(np.float64)的选择。float32占一半内存速度也快一些精度对图像任务完全够——0到255的整数在float32里有23位尾数绰绰有余。除非你在做需要高精度的统计计算否则一律float32。3.3 查找表把除法折叠成256次LUT的原理很朴素输入只有256种可能那就把这256种输入对应的输出全部算好存成一个长度256的数组运行时把原图当索引去取值。import numpy as np def build_lut(scale, shift): 构造uint8输入对应的float32查找表LUT[i] i * scale shift idx np.arange(256, dtypenp.float32) return idx * scale shift def apply_lut(img_uint8, lut): 用LUT做归一化。lut必须是长度256的一维数组。 return lut[img_uint8]lut[img_uint8]这一步是花式索引NumPy会按元素逐个查表结果的dtype由LUT决定。实测比先转float再乘快大约一倍因为跳过了uint8到float的隐式转换和一次浮点乘法只剩下索引和一次内存拷贝。LUT真正的杀手级用法是处理非线性变换。比如Gamma校正、sRGB到线性空间的转换这些都是幂运算逐像素做极慢。但它们的输入域同样是0到255所以可以整体折叠import numpy as np # sRGB 转线性空间标准的分段函数 def srgb_to_linear_table(): i np.arange(256, dtypenp.float32) / 255.0 low i / 12.92 # 暗部线性段 high ((i 0.055) / 1.055) ** 2.4 # 亮部幂函数段 lut np.where(i 0.04045, low, high) return lut.astype(np.float32) lut srgb_to_linear_table() linear_img lut[img_uint8] # 一次索引搞定非线性映射原本要跑一百多万次幂运算变成了256次预先计算加一次索引。这个技巧在色彩管理、图像增强、色调映射里非常好用几乎是必备手段。LUT的局限也很清楚只适用于逐元素、并且只依赖单个通道自身数值的变换。一旦变换依赖邻域比如卷积、模糊、形态学操作或者跨通道比如RGB转灰度、白平衡LUT就不适用了。判断标准很简单输出像素的值是否只和同一个位置的某一个输入值有关是就能用不是就不行。3.4 内存布局那些年被忽略的stride有一个比算法选型更底层、也更容易被忽略的点数组在内存里是不是连续的。NumPy的数组有一个flags[C_CONTIGUOUS]属性。当你做过转置、翻转、切片之后数组可能变成非连续的此时遍历它需要按stride跳着访问缓存命中率大幅下降速度可能差三五倍。import numpy as np a np.random.randint(0, 256, (1080, 1920, 3), dtypenp.uint8) b a[:, ::-1, :] # 水平翻转非连续视图 print(b.flags[C_CONTIGUOUS]) # False c np.ascontiguousarray(b) # 花一次拷贝换连续内存 print(c.flags[C_CONTIGUOUS]) # True实测下来对非连续数组做大规模的逐元素运算先ascontiguousarray拷一次再算总耗时往往比直接算还低。原因是拷贝是纯内存带宽操作顺序读写能跑满带宽而带stride的运算是内存访问和计算交替进行缓存局部性差计算单元经常在等数据。另一个相关技巧是通道顺序。HWC高×宽×通道和CHW通道×高×宽在内存访问模式上差别很大。如果你后面要送进深度学习框架而张量默认是NCHW那预处理阶段用CHW排布会更顺# HWC - CHWtranspose只改stride不搬数据 chw np.transpose(hwc, (2, 0, 1)) chw np.ascontiguousarray(chw) # 这一步才真正重排内存transpose只是改了stride不搬数据ascontiguousarray才真正重排。什么时候该转取决于你后面的消费者是谁。如果下一步就是torch.from_numpy那就在numpy侧转好因为框架对非连续张量的算子支持虽然完善但某些操作会隐式触发拷贝还不如你自己掌控时机至少知道代价花在哪。3.5 批量化与流水线把瓶颈挪走单张图2毫秒看着很快但如果是30路摄像头、每路30帧就是每秒900张单线程无论如何扛不住。这时候要考虑的是整条流水线的组织方式而不只是单个函数的优化。第一个手段是批处理。把N张图堆成一个(N, H, W, 3)的大数组一次做完归一化。好处是Python层的调用开销被摊薄NumPy内部的向量化也更充分一次函数调用处理几百万个元素。代价是峰值内存上升要控制batch大小别一次堆几千张图把内存打满。第二个手段是流水线并行。预处理、推理、后处理分给不同的线程或进程用队列串起来。Python的全局解释器锁让多线程在纯计算场景下收益有限但只要有一环是IO操作或者调用了会释放锁的C扩展NumPy和OpenCV的大部分函数都会释放多线程就能真正并行起来from concurrent.futures import ThreadPoolExecutor import cv2 import numpy as np lut np.arange(256, dtypenp.float32) / 255.0 def worker(path): bgr cv2.imread(path) # 读盘时会释放GIL rgb cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) # 同样释放GIL return lut[rgb] # 归一化 with ThreadPoolExecutor(max_workers8) as pool: results list(pool.map(worker, file_list))实测这条路子比单线程快3到4倍8核机器瓶颈基本落在磁盘IO上。如果你的图已经在内存里或者用了内存映射可以把worker数量调到和物理核数一致收益更明显。第三个手段是异步预热。如果整个流程有固定的节拍可以用双缓冲一边消费当前批一边预取下一批。这样归一化的耗时就被藏在等待里对延迟敏感的场景很有用比如实时视频分析。心得在优化之前先量一下。我用time.perf_counter在流水线每个环节各打一个点跑一遍就发现瓶颈根本不在归一化而在图像解码上。归一化从2毫秒优化到1毫秒整条链路只提升0.5%换成更快的解码方式或者内存映射整体提升30%。优化要打在真正的瓶颈上不然就是自我感动。4. 落到具体场景采集、转换与训练的衔接前面讲的都是通用原理和技巧但真实项目里归一化很少单独存在它总是夹在读图和下游任务之间。这一节讲几个高频衔接点都是实际会卡住人的地方。4.1 读图库的通道顺序陷阱这是新手最容易踩的坑也是我见过最多的模型精度莫名其妙掉一半的原因。cv2.imread默认返回BGR顺序而PIL.Image.open返回RGB顺序。如果你用OpenCV读图然后套用RGB顺序的ImageNet均值方差红蓝通道的统计量就完全错位了模型看到的红色其实是蓝色精度自然崩。import cv2 from PIL import Image import numpy as np bgr cv2.imread(test.jpg) # BGR顺序 rgb np.array(Image.open(test.jpg)) # RGB顺序 # 从BGR转成RGB两种等价写法 rgb1 cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) rgb2 bgr[:, :, ::-1] # 切片翻转非连续视图 mean_bgr np.array([0.406, 0.456, 0.485]) # 对应BGR顺序 mean_rgb np.array([0.485, 0.456, 0.406]) # 对应RGB顺序如果坚持用BGR那就把均值方差也按BGR顺序排效果完全等价。关键是通道顺序和统计量顺序必须对齐这两者是一个契约。我的习惯是统一转成RGB代码里所有关于通道的地方都按RGB来减少心智负担也方便和别人的代码对接。另外bgr[:, :, ::-1]这个写法虽然简洁但产生的是非连续视图后面要做重计算的话记得ascontiguousarray。或者用cv2.cvtColor它直接输出连续数组省一步。4.2 采集端异常从没有帧说起做深度相机或者多路相机采集时经常遇到运行日志里出现没有收到帧导致既拿不到深度图也拿不到RGB图。这类问题和归一化没有直接关系但它决定了你的归一化代码有没有数据可处理值得说一下排查顺序因为顺序错了会浪费很多时间。第一步永远是排除硬件和连接。数据线接触不良、供电不足、接口松动这三样占了绝大多数。换一根线、换一个接口、换一台机器交叉验证是最快缩小范围的手段不要一上来就怀疑代码。第二步看驱动和权限。某些平台上设备节点需要特定权限程序以普通用户跑就打开失败。这种情况日志里通常会有更明确的报错不要只看最后一行往上翻几行往往能看到真正的原因。第三步看带宽。多路相机同时开高分辨率高帧率总线带宽会被吃满表现就是连接成功但收不到数据或者间歇性丢帧。把分辨率或帧率降下来试一下如果立即恢复正常那基本可以确定是带宽问题需要重新分配资源。第四步才是软件逻辑。常见的是打开设备的超时设得太短或者上一帧的回调还没执行完就重复发起请求导致请求队列堆积、驱动侧拒绝服务。加日志打印每帧的时间戳看看是一帧都没来还是来了但被丢掉了这两种情况的排查方向完全不同前者查连接后者查消费速度。提示任何一路数据源异常都不要让它污染归一化那条链路。我在管线里会加一层校验形状不对、类型不对、值域超出预期的帧直接丢弃并计数。跑一段时间看计数比翻日志快得多也能量化出问题的频率。4.3 归一化和RGB转HSV的先后关系RGB转HSV也是高频需求它和归一化的顺序问题经常被问到。结论是如果两种变换都基于同一个标准定义先做哪个数学上都对得上但工程上建议先归一化再转HSV。原因在于HSV的计算里有大量的除法import numpy as np def rgb_to_hsv(rgb): rgb: H x W x 3, 取值 0 到 1 r, g, b rgb[..., 0], rgb[..., 1], rgb[..., 2] maxc np.max(rgb, axis-1) minc np.min(rgb, axis-1) delta maxc - minc v maxc # 亮度 s np.where(maxc 0, delta / np.maximum(maxc, 1e-8), 0) # 饱和度 # 色相按maxc对应哪个通道分六段这里省略具体分支 h np.zeros_like(maxc) # ... 分段计算 ... return np.stack([h, s, v], axis-1)注意delta / maxc这个除法。如果输入是0到255的整数你先把它们转成float再除等于多做了一次类型转换和一次额外的数组分配如果直接用整数除法那就全毁了——整数除法会截断饱和度要么全是0要么全是1。先归一化到0到1再做这些除法逻辑最干净也最不容易出错。还有一点HSV里的H是角度范围0到360归一化后是0到1S和V是0到1。如果你后面要把H送进网络通常还要再做一次缩放这时候就又是一次归一化。所以整条链条上可能有三处归一化RGB输入、H的角度、以及某些实现里S和V的额外缩放。每一处都要单独确认值域不要想当然。在工业视觉软件里图像归一化通常指的是把输入灰度或彩色值线性映射到指定区间作为一个预处理节点存在作用和前面讲的一样只是封装成了图形化模块。用这类软件的时候重点看它的参数单位和默认值——有的软件输入是0到255、输出还是0到255只是做了对比度拉伸这跟深度学习语境下的归一化完全不是一回事。搞混了会出现我在软件里归一化过了为什么送进模型还要再来一次的困惑。4.4 归一化放CPU还是GPU如果你的管线是读图、预处理、送进GPU训练那归一化放哪边有两种选择各有代价。放CPUnumpy侧的好处是一次做好数据整洁调试方便可以离线预处理好存成二进制文件训练时直接读省掉每次前向的重复计算。坏处是CPU和GPU之间要传float32数据传输量是uint8的三倍。放GPU的好处是可以把uint8直接传上去传输量减少到三分之一然后归一化作为计算图的一部分在GPU上算和数据加载重叠起来。坏处是每次前向都要重算一遍这个变换虽然GPU上很快但不是零成本而且调试时不容易看清中间结果。实测经验如果数据能提前预处理并缓存CPU侧做完存下来最省事如果是在线训练、数据增强很复杂、每次取到的图都不一样那就把归一化做成一个GPU上的算子让它和数据加载重叠起来。两种方案的分界线是数据能不能预先确定能就用前者不能就用后者。5. 常见问题与排查技巧实录这一节是我这些年攒下来的排查笔记基本都是文档里不会写、但实际会反复遇到的。5.1 dtype和溢出最隐蔽的一类bugnp.uint8参与算术运算时NumPy会做类型提升但提升的规则要看参与运算的另一个操作数是什么。uint8加uint8结果还是uint8会静默回绕——比如250加10等于4不是260。这个坑在归一化的反向操作里特别常见import numpy as np normalized img / 255.0 # float32, 0 到 1 raw (normalized * 255).astype(np.uint8) # 这样是对的 # 反归一化的稳妥写法 raw normalized * 255 raw_uint8 np.clip(raw, 0, 255).astype(np.uint8) # 加clip更保险真正危险的是不用astype做转换而是在中间做了减法。比如x - 128这种操作在uint8下会把负数回绕成很大的正数图像看起来就是一片白或者一片黑而且不会有任何报错。我的习惯是任何涉及减法的操作先升到float32或者至少int16再减再决定要不要转回来。多一行类型转换省几个小时的排查。5.2 反归一化之后图发灰、发紫可视化的时候经常遇到模型输出反归一化回来图看起来发灰或者整体偏紫。这基本是两个原因可以快速二分。发灰通常是标准化没被正确撤销。如果你做了(x-μ)/σ可视化时必须做x*σμ而且通道顺序要对。少做一步或者顺序错了图的对比度会大幅下降看起来像蒙了一层灰纱。偏紫是典型的红蓝通道互换。紫色是红加蓝说明R通道拿了B的数据。这时候去检查你的色彩空间转换调用和统计量顺序八成问题就在这里。还有一个容易忽略的情况如果模型输出经过Sigmoid或者Tanh值域可能不是0到1而是-1到1反归一化时要做对应的映射。生成模型的输出层用Tanh、后处理却按0到1写是很常见的bug来源而且现象往往只是颜色有点怪不容易被发现。5.3 常见问题速查表现象可能原因排查动作训练loss直接NaN输入未归一化或存在异常值打印输入的最小最大均值检查越界值精度比预期低很多通道顺序与统计量不匹配比对BGR和RGB临时交换均值方差验证图像发紫红蓝通道颠倒检查色彩转换和均值数组顺序图像发灰、对比度低反归一化只乘了σ没加μ复查后处理公式可视化全白或全黑uint8减法回绕中间结果升到float或int16处理速度慢逐像素循环或数组非连续改向量化检查连续性标志多线程无加速计算部分持有全局锁确认用到了释放锁的C扩展或改多进程同图两次结果不同共用缓冲区被原地修改搜索原地运算符的使用位置高位数图像值域异常10位或12位数据按8位处理按实际位深归一化或先移位对齐5.4 几个我踩过的坑说几个文档里不写、但实际一定会遇到的事。第一个是中途换用不同的读图库。项目早期用OpenCV读图后来为了统一改成PIL结果忘了PIL是RGB而保存的模型期望BGR输入推理精度直接掉了一截查了大半天。所以我现在的习惯是在项目里放一个preprocess.py所有读图、通道转换、归一化都从这一个模块出任何地方都不允许自己写一遍。单一入口能挡掉绝大部分一致性问题。第二个是统计量的保存格式。算出来的均值和方差一定要和权重一起存或者写进配置文件的固定位置版本管理起来。我见过训练脚本里均值是硬编码在代码里的几个月后换了数据集忘了改复现的时候怎么调参数都不对最后才发现是这里。第三个是16位和8位图像混用。工业相机经常输出10位或12位的数据存在16位容器里取值范围是0到1023或0到4095。这种图如果你直接除以255出来的值会大于1后续所有关于0到1区间的假设全部失效表现为数值不稳定或者饱和。正确做法是先除以位深对应的最大值或者先右移补齐到8位再做常规归一化。这两个方案效果不一样前者保留完整的动态范围后者会损失精度但和显示链路一致。选之前想清楚下游是谁是给模型还是给人看。第四个是压缩格式的色度采样。常见的图像压缩格式在做有损压缩时色度通道经常被降采样解码回来的RGB并不是严格意义上的原始RGB。如果你在做需要精确颜色的任务比如颜色识别、白平衡校正、缺陷检测这个误差是绕不过去的。要么用无损格式要么在做颜色判断时避开边缘和高频区域。最后分享一个我个人很受用的小习惯写归一化代码时随手加一个自检函数。输入一张已知的纯色图比如纯红跑一遍预处理打印输出的三个通道值和手算结果对一下。十来行的检查能拦住前面说的绝大多数坑。我在每个图像项目里都保留这么一个自检脚本改完预处理参数就跑一遍比调试一整轮训练便宜太多。
返回列表