【Metal引擎研发笔录】完整版CSDN专栏:https://blog.csdn.net/cordova/category_9467887.html
Demo: https://github.com/jiangxh1992/XHMetalImageProcessing
Xcode新建一个Game工程,选择Metal作为引擎技术框架,会得到一个简单的绘制Cude的示例基础工程。这里对基础工程进行改装实现游戏RT后处理和图像处理的流程。
Demo中第一次提交版本实现了简单的后处理流程,先将图形绘制到我们的自定义RT上,然后对RT进行后处理,最后将RT画到一个和屏幕一样的Quad上并提交给view的 backbuffer绘制到屏幕上;
第二次提交对工程进行了简化,只用来做纯图像的处理,直接使用kenel函数对加载进来的MTLTexture进行图像处理,然后将结果画到Quad上提交给backbuffer显示到屏幕上。
Demo中定义了两个MTLTexture,sourceTexture用来加载要处理的图像,destTexture用来保存对sourceTexture图像处理后的结果,并作为RT渲染到屏幕上。
我们使用MTKTextureLoader加载本地图片到sourceTexture,用于后面的处理,图像选用了经典的lena人像:
MTKTextureLoader* textureLoader = [[MTKTextureLoader alloc] initWithDevice:_device];
NSDictionary *textureLoaderOptions =
@{
MTKTextureLoaderOptionTextureUsage : @(MTLTextureUsageShaderRead),
MTKTextureLoaderOptionTextureStorageMode : @(MTLStorageModePrivate)
};
sourceTexture = [textureLoader newTextureWithName:@"lena"
scaleFactor:1.0
bundle:nil
options:textureLoaderOptions
error:&error];
绘制阶段设置一个tile shader,设置每个tile上多线程数目的规格,setTileTexture传入sourceTexture和destTexture,在kernel函数中对图像进行处理:
// 图像处理
[myRenderEncoder pushDebugGroup:@"ImageProcess"];
[myRenderEncoder setRenderPipelineState:_postprocessPipeline];
[myRenderEncoder setTileTexture:sourceTexture atIndex:0];
[myRenderEncoder setTileTexture:destTexture atIndex:1];
[myRenderEncoder dispatchThreadsPerTile:MTLSizeMake(32, 32, 1)];
[myRenderEncoder popDebugGroup];
kernel void postProcessing(texture2d source[[texture(0)]],
texture2d dest[[texture(1)]],
uint2 gid [[thread_position_in_grid]]
)
{
float4 source_color = source.read(gid);
float4 result_color = source_color; // 直接拷贝图像,不做处理
dest.write(result_color, gid);
}
图像采样(sampling): 现实图像信息是连续的,图像上的点是无穷多的,为了能在屏幕上展示图像需要在屏幕空间对图像进行采样,将连续信息转为损失的离散信息,将连续图像维度降维到屏幕分辨率维度。每个采样点对应屏幕空间一个坐标。采样是在坐标上离散信号数据。
图像量化(quantization): 图像上每个点的颜色值在颜色空间上也是连续的,为了能用有限的空间表示每个点的颜色信息,需要对图像颜色进行量化。例如:用8bit表示颜色灰度,则有2^8=256阶灰度值来量化颜色。量化是在幅度上离散信号数据。
空间分辨率: 指的是屏幕分辨率M*N,图像采样的结果,屏幕像素宽度x屏幕像素高度。
灰度分辨率: 图像量化后的灰度数量,8bit的图像灰度分辨率L等于256。一张k bit的图像:L = 2 ^ k。存储一张图像需要的空间为: (M * N * k)bit。
灰度值r(色阶): 灰度分辨率L下颜色的灰度值为r,r = (0 ~ L - 1)。
直方图(histogram): 直方图是图像中不同灰度值对应的像素个数的统计。
nk指的是屏幕图像上灰度为rk的像素个数。
单位化的直方图表示为:
单位直方图的值p表示的是灰度rk在图像中出现的概率,且所有灰度概率和为1。
累加直方图(Cumulative Histogram): 累加直方图表示的是灰度r0~rk对应像素个数的累加之和。
灰度变换是图像增强的常见基本手段。主要是直接处理单个像素的灰度值,在函数曲线上对灰度值进行映射变换。常见的灰度变换有:线性变换、Log变换和幂律变换等。
这里灰度变换可以忽略彩色信息,代码中简化只处理r通道作为灰度图像:
float4 source_color = source.read(gid);
ushort grayLevel = (ushort)(source_color.x * 255);
float r = grayLevel / 255.0; // gray level
float4 result_color = float4(r,r,r,0);
原始灰度图像:
灰度取反主要是让亮处的细节对比凸显出来,在医学图像诊断中常用到。取反公式很简单:
Metal Computer Shader中处理如下:
float4 source_color = source.read(gid);
ushort grayLevel = (ushort)(source_color.x * 255);
float r = (255 - grayLevel) / 255.0; // image negative
float4 result_color = float4(r,r,r,0);
处理结果:
***注:***这里在对图像进行灰度处理测试时,将工程中的RGB图像简化为R通道的灰度图像,不关心彩色值。同时每个通道值为8bit,将颜色值量化在0~255范围内。
int grayLevel = (int)(source_color.x * 256);
float r = grayLevel / 256.0; // gray level
float4 result_color = float4(r,r,r,0);
图像的Log变换作用是扩展暗处的颜色范围以及压缩高亮高曝的颜色在有限的颜色范围内,对于我们图像有限的量化颜色范围(0~255),可以将更大范围的像素颜色数据动态压缩变换进来。图像的Log变换与HDR(Tone-Mapping)目的一致。
图像的Log变换是对灰度r求Log,并有一个可调常数参数c:
示例代码:
int grayLevel = (int)(source_color.x * 256);
float r = 10 * (log(grayLevel + 1.0) / 255.0); // log transform
float4 result_color = float4(r,r,r,0);
Log变换后的图像可以看出过暗和过曝的部分会被调和:
幂律变换指的是对灰度进行指数变换,指数值为正数,指数为1时退化为线性变换。幂律变换比Log变换更加灵活,主要用来调控图像的 对比度。
幂律变换公式中要将灰度范围变回0~1范围,示例代码:
int grayLevel = (int)(source_color.x * 256);
float r = 5 * (pow(grayLevel / 255.0, 5.0)); // power-law transformation
float4 result_color = float4(r,r,r,0);
这里指数为5.0,是将图像的对比度大大增强了,亮暗对比更加明显:
Bit Plane Slice是一幅图像的各bit位的贡献分片,通过分解每个分片可以分析每个bit对图像的贡献。对于图像量化在0~255之间的8bit图像,则可分解成8张子图像。
设置一个颜色值mask求&即可得到对应的bit分片图像:
float4 source_color = source.read(gid);
ushort grayLevel = (ushort)(source_color.x * 255);
/* bit plane slicing */
int n = 7;
int mask = 1 << n;
float r = (grayLevel & mask) / 255.0;
float4 result_color = float4(r,r,r,0);
分解得到的结果如下:
基本原理介绍:https://blog.csdn.net/schwein_van/article/details/84336633
Swift Demo: https://github.com/FlexMonkey/MPS_Equalisation
抛开数学原理,简单说直方图均衡化的过程是通过一个映射公式,将源图像每个像素对应的灰度rk,映射到计算得到的sk,即将灰度为rk的像素值改为sk,实现近似的直方图平滑,使颜色分布更加均匀,一定程度上增强图像的对比度。映射公式如下:
将得到的sk四舍五入的值替换掉源图像对应的灰度rk即得到了源图像的直方图均衡化后的增强图像。
MPSImageHistogram
MPSImageHistogramEqualization
Metal Performance Shader框架中有图像处理的模块,其中专门提供了优化后的直方图均衡化的工具,可以快速调用,代码示例:
// 计算直方图数据
MPSImageHistogramInfo info;
info.histogramForAlpha = true;
info.numberOfHistogramEntries = 256;
info.minPixelValue = simd_make_float4(0, 0, 0, 0);
info.maxPixelValue = simd_make_float4(1, 1, 1, 1);
MPSImageHistogram *histogram = [[MPSImageHistogram alloc] initWithDevice:_device histogramInfo:&info];
size_t length = [histogram histogramSizeForSourceFormat:sourceTexture.pixelFormat];
id histogramBuffer = [_device newBufferWithLength:length options:MTLResourceStorageModePrivate];
[histogram encodeToCommandBuffer:commandBuffer sourceTexture:sourceTexture histogram:histogramBuffer histogramOffset:0];
// 定义直方图均衡化对象
MPSImageHistogramEqualization *equalization = [[MPSImageHistogramEqualization alloc] initWithDevice:_device histogramInfo:&info];
// 根据直方图计算累加直方图数据
[equalization encodeTransformToCommandBuffer:commandBuffer sourceTexture:sourceTexture histogram:histogramBuffer histogramOffset:0];
// 最后进行均衡化处理
[equalization encodeToCommandBuffer:commandBuffer sourceImage:sourceTexture destinationImage:destTexture];
// ...
[commandBuffer commit];
其中MPSImageHistogramInfo是一个配置histogram信息的结构体,作为参数来初始化MPSImageHistogram和MPSImageHistogramEqualization对象。Histogram对象根据图像的格式通道个数确定histogram buffer的大小,申请一个MTLBuffer来缓存Histogram的结果,保证buffer容量足够。histogram提交给commandbuffer后之后在GPU上计算直方图数据并保存到histogramInfoBuffer中,供后续使用。
equalization对象的encodeTransformToCommandBuffer函数将histogramBuffer提交给GPU计算累加直方图等其他间接数据,最后equalization提交给commandbuffer进行最终的直方图均衡化图像处理,处理结果渲到destTexture上。
【注:官方encodeTransformToCommandBuffer这个计算累加直方图的接口目前有问题,报buffer index错误,可能是API版本更新导致,之后官方应该会修复】
自行实现直方图均衡化主要也分三个步骤:
直方图数据的统计可以在CPU上进行,也可以在GPU上计算。
在CPU上计算直方图数据是直接操作统计图像数据,比较简单直接,计算结果保存在buffer上最后传给GPU即可。
示例代码如下:
// 直方图统计
HistogramColor histogramData[256];
for (int i = 0; i < 256; ++i) {
histogramData[i].hr = 0;
histogramData[i].hg = 0;
histogramData[i].hb = 0;
}
UIImage *image = [UIImage imageNamed:imageName];
Byte *colors = (Byte *)[image CGImage];
for (int i = 0; i< sourceTexture.width * sourceTexture.height; ++i) {
histogramData[colors[i * 4 + 0]].hr++;
histogramData[colors[i * 4 + 1]].hg++;
histogramData[colors[i * 4 + 2]].hb++;
}
直方图数据保存在我们的结构体数组中,然后根据直方图数据的数组再加算数累加直方图数据,并将累加直方图数据拷贝到一张buffer上作为参数之后传给我们的computer shader:
// 累加直方图
HistogramColor accHistogramData[256];
accHistogramData[0].hr = histogramData[0].hr;
accHistogramData[0].hg = histogramData[0].hg;
accHistogramData[0].hb = histogramData[0].hb;
for (int i = 1; i < 256; ++i) {
accHistogramData[i].hr = accHistogramData[i-1].hr + histogramData[i].hr;
accHistogramData[i].hg = accHistogramData[i-1].hg + histogramData[i].hg;
accHistogramData[i].hb = accHistogramData[i-1].hb + histogramData[i].hb;
}
_accHistogramBuffer = [_device newBufferWithBytes:accHistogramData length:sizeof(accHistogramData) options:MTLResourceStorageModeShared];
另外也可以在GPU上使用kernel函数来快速并行计算我们要的直方图数据,但是要注意的是多线程的处理,要保证数据的原子性,使用原子操作避免数据读写出错,例如使用atomic_store_explicit
来重置atomic数据,使用atomic_fetch_add_explicit
增加计数等。
计算处理后得到累加直方图数据即可对图像进行直方图均衡化后处理,应用直方图均衡化公式计算sk映射数据,最后完成看均衡化操作:
kernel void postProcessing(texture2d source[[texture(0)]],
texture2d dest[[texture(1)]],
device HistogramColor *accHistogram [[buffer(0)]],
uint2 gid [[thread_position_in_grid]]
)
{
if(gid.x >= source.get_width() || gid.y >= source.get_height()) return;
float4 source_color = source.read(gid);
ushort grayLevel = (ushort)(source_color.x * 255);
/* 直方图均衡化 */
int M = source.get_width();
int N = source.get_height();
// 均衡化直方图sk
HistogramColor sk[256];
float size = M * N;
for(int i = 1;i<256;++i){
sk[i].hr = round(255.0 * (accHistogram[i].hr / size));
sk[i].hg = round(255.0 * (accHistogram[i].hg / size));
sk[i].hb = round(255.0 * (accHistogram[i].hb / size));
}
// 均衡化直方图sk替换原图像灰度值
float r = sk[grayLevel].hr / 255.0;
float g = sk[grayLevel].hg / 255.0;
float b = sk[grayLevel].hb / 255.0;
float4 result_color = float4(r,g,b,0);
dest.write(result_color, gid);
}
这里主要介绍了Metal中如何构建图像处理和后处理的Xcode工程,进行图形图像的处理。另外简单总结了图像增强的几个基础操作,包括灰度变换,直方图均衡化等,总结了不同图像处理操作的效果和意义,也介绍了在Metal中实现这些操作的思路和代码写法。之后会在此基础上总结其他的一些空域上的图像处理操作,不同的图像处理卷积算子,不同的图像滤波算法等,以及他们在Metal中的实现方法。