自適應閾值效果圖 demo
這幾天抽空看了下GpuImage的filter,移植了高斯模糊與自適應閾值的vulkan compute shader實現,一個是基本的圖像處理,一個是組合基礎圖像處理聚合,算是比較有代表性的二種.
高斯模糊實現與優化
大部分模糊效果主要是卷積核的實現,相應值根據公式得到.
int ksize = paramet.blurRadius * 2 + 1;
if (paramet.sigma <= 0) {
paramet.sigma = ((ksize - 1) * 0.5 - 1) * 0.3 + 0.8;
}
double scale = 1.0f / (paramet.sigma * paramet.sigma * 2.0);
double cons = scale / M_PI;
double sum = 0.0;
std::vector<float> karray(ksize * ksize);
for (int i = 0; i < ksize; i++) {
for (int j = 0; j < ksize; j++) {
int x = i - (ksize - 1) / 2;
int y = j - (ksize - 1) / 2;
karray[i * ksize + j] = cons * exp(-scale * (x * x + y * y));
sum += karray[i * ksize + j];
}
}
sum = 1.0 / sum;
for (int i = ksize * ksize - 1; i >= 0; i--) {
karray[i] *= sum;
}
其中對應compute shader代碼.
#version 450
layout (local_size_x = 16, local_size_y = 16) in;// gl_WorkGroupSize
layout (binding = 0, rgba8) uniform readonly image2D inTex;
layout (binding = 1, rgba8) uniform image2D outTex;
layout (binding = 2) uniform UBO
{
int xksize;
int yksize;
int xanchor;
int yanchor;
} ubo;
layout (binding = 3) buffer inBuffer{
float kernel[];
};
void main(){
ivec2 uv = ivec2(gl_GlobalInvocationID.xy);
ivec2 size = imageSize(outTex);
if(uv.x >= size.x || uv.y >= size.y){
return;
}
vec4 sum = vec4(0);
int kInd = 0;
for(int i = 0; i< ubo.yksize; ++i){
for(int j= 0; j< ubo.xksize; ++j){
int x = uv.x-ubo.xanchor+j;
int y = uv.y-ubo.yanchor+i;
// REPLICATE border
x = max(0,min(x,size.x-1));
y = max(0,min(y,size.y-1));
vec4 rgba = imageLoad(inTex,ivec2(x,y)) * kernel[kInd++];
sum = sum + rgba;
}
}
imageStore(outTex, uv, sum);
}
這樣一個簡單的高斯模糊就實現了,結果就是我在用Redmi 10X Pro在攝像頭1080P下使用21的核長是不到一楨的處理速度.
高斯模糊的優化都有現成的講解與實現,其一就是圖像處理中的卷積核分離,一個m行乘以n列的高斯卷積可以分解成一個1行乘以n列的行卷積,計算復雜度從原來的O(k^2)降為O(k),其二就是用shared局部顯存減少訪問紋理顯存的操作,注意這塊容量非常有限,如果不合理分配,能並行的組就少了.考慮到Android平台,使用packUnorm4x8/unpackUnorm4x8優化局部顯存占用.
其核分成一列與一行,具體相應實現請看VkSeparableLinearLayer類的實現,由二個compute shader組合執行.
int ksize = paramet.blurRadius * 2 + 1;
std::vector<float> karray(ksize);
double sum = 0.0;
double scale = 1.0f / (paramet.sigma * paramet.sigma * 2.0);
for (int i = 0; i < ksize; i++) {
int x = i - (ksize - 1) / 2;
karray[i] = exp(-scale * (x * x));
sum += karray[i];
}
sum = 1.0 / sum;
for (int i = 0; i < ksize; i++) {
karray[i] *= sum;
}
rowLayer->updateBuffer(karray);
updateBuffer(karray);
其glsl主要邏輯實現來自opencv里opencv_cudafilters模塊里cuda代碼改寫,在這只貼filterRow的實現,filterColumn的實現和filterRow類似,有興趣的朋友可以自己翻看.
#version 450
layout (local_size_x = 16, local_size_y = 16) in;// gl_WorkGroupSize
layout (binding = 0, rgba8) uniform readonly image2D inTex;
layout (binding = 1, rgba8) uniform image2D outTex;
layout (binding = 2) uniform UBO
{
int xksize;
int anchor;
} ubo;
layout (binding = 3) buffer inBuffer{
float kernel[];
};
const int PATCH_PER_BLOCK = 4;
const int HALO_SIZE = 1;
// 共享塊,擴充左邊右邊HALO_SIZE(分為左邊HALO_SIZE,中間自身*PATCH_PER_BLOCK,右邊HALO_SIZE)
shared uint row_shared[16][16*(PATCH_PER_BLOCK+HALO_SIZE*2)];//vec4[local_size_y][local_size_x]
// 假定1920*1080,gl_WorkGroupSize(16,16),gl_NumWorkGroups(120/4,68),每一個線程寬度要管理4個
// 核心的最大寬度由HALO_SIZE*gl_WorkGroupSize.x決定
void main(){
ivec2 size = imageSize(outTex);
uint y = gl_GlobalInvocationID.y;
if(y >= size.y){
return;
}
// 紋理正常范圍的全局起點
uint xStart = gl_WorkGroupID.x * (gl_WorkGroupSize.x*PATCH_PER_BLOCK) + gl_LocalInvocationID.x;
// 每個線程組填充HALO_SIZE*gl_WorkGroupSize個數據
// 填充每個左邊HALO_SIZE,需要注意每行左邊是沒有紋理數據的
if(gl_WorkGroupID.x > 0){//填充非最左邊塊的左邊
for(int j=0;j<HALO_SIZE;++j){
vec4 rgba = imageLoad(inTex,ivec2(xStart-(HALO_SIZE-j)*gl_WorkGroupSize.x,y));
row_shared[gl_LocalInvocationID.y][gl_LocalInvocationID.x + j*gl_WorkGroupSize.x] = packUnorm4x8(rgba);
}
}else{ // 每行最左邊
for(int j=0;j<HALO_SIZE;++j){
uint maxIdx = max(0,xStart-(HALO_SIZE-j)*gl_WorkGroupSize.x);
vec4 rgba = imageLoad(inTex,ivec2(maxIdx,y));
row_shared[gl_LocalInvocationID.y][gl_LocalInvocationID.x + j*gl_WorkGroupSize.x] = packUnorm4x8(rgba);
}
}
// 填充中間與右邊HALO_SIZE塊,注意每行右邊的HALO_SIZE塊是沒有紋理數據的
if(gl_WorkGroupID.x + 2 < gl_NumWorkGroups.x){
// 填充中間塊
for(int j=0;j<PATCH_PER_BLOCK;++j){
vec4 rgba = imageLoad(inTex,ivec2(xStart+j*gl_WorkGroupSize.x,y));
uint x = gl_LocalInvocationID.x + (HALO_SIZE+j)*gl_WorkGroupSize.x;
row_shared[gl_LocalInvocationID.y][x] = packUnorm4x8(rgba);
}
// 右邊的擴展中,還在紋理中
for(int j=0;j<HALO_SIZE;++j){
vec4 rgba = imageLoad(inTex,ivec2(xStart+(PATCH_PER_BLOCK+j)*gl_WorkGroupSize.x,y));
uint x = gl_LocalInvocationID.x + (PATCH_PER_BLOCK+HALO_SIZE+j)*gl_WorkGroupSize.x;
row_shared[gl_LocalInvocationID.y][x] = packUnorm4x8(rgba);
}
}else{// 每行右邊的一個塊
for (int j = 0; j < PATCH_PER_BLOCK; ++j){
uint minIdx = min(size.x-1,xStart+j*gl_WorkGroupSize.x);
uint x = gl_LocalInvocationID.x + (HALO_SIZE+j)*gl_WorkGroupSize.x;
row_shared[gl_LocalInvocationID.y][x] = packUnorm4x8(imageLoad(inTex,ivec2(minIdx,y)));
}
for(int j=0;j<HALO_SIZE;++j){
uint minIdx = min(size.x-1,xStart+(PATCH_PER_BLOCK+j)*gl_WorkGroupSize.x);
uint x = gl_LocalInvocationID.x + (PATCH_PER_BLOCK+HALO_SIZE+j)*gl_WorkGroupSize.x;
row_shared[gl_LocalInvocationID.y][x] = packUnorm4x8(imageLoad(inTex,ivec2(minIdx,y)));
}
}
// groupMemoryBarrier();
memoryBarrier();
for (int j = 0; j < PATCH_PER_BLOCK; ++j){
uint x = xStart + j*gl_WorkGroupSize.x;
if(x<size.x){
vec4 sum = vec4(0);
for(int k=0;k<ubo.xksize;++k){
uint xx = gl_LocalInvocationID.x + (HALO_SIZE+j)*gl_WorkGroupSize.x - ubo.anchor + k;
sum = sum+unpackUnorm4x8(row_shared[gl_LocalInvocationID.y][xx]) * kernel[k];
}
imageStore(outTex, ivec2(x,y),sum);
}
}
}
一般compute shader常用圖像處理操作來說,我們一個線程處理一個像素,在這里PATCH_PER_BLOCK=4,表示一個線程操作4個像素,所以線程組的分配也會改變,針對圖像塊就是WorkGroupSize*PATCH_PER_BLOCK這塊正常取對應數據,其中HALO_SIZE塊在row中是左右二邊,如果是最左邊和最右邊需要考慮取不到的情況,我采用的邏輯對應opencv的邊框填充REPLICATE模式,余下的塊的HALO_SIZE塊都不是對應當前線程組對應的圖像塊.column的上下塊同理,可以看到最大核的大小限定在HALO_SIZEx2+WorkGroupSize,如果真有超大核的要求,可以變大HALO_SIZE.
不過優化完后,我發現在PC平台應用會有噪點,特別是核小的時候.我分別針對filterRow/filterColumn做測試應用,發現只有filterColumn有問題,而代碼我反復檢測也沒發現那有邏輯錯誤,更新邏輯查看filterColumn各種測試中,我發現在groupMemoryBarrier后,隔gl_WorkGroupSize.y的數據能拿到,但是行+1拿的是有噪點的,斷定問題出在同步局部共享顯存上,前面核大不會出現這問題也應該是核大導致局部共享顯存變大導致並行線程組數少,groupMemoryBarrier改為memoryBarrier還是不行,后改為barrier可行,按邏輯上來說,應該是用groupMemoryBarrier就行,不知是不是和硬件有關,不為奇怪的是為啥filterRow的使用groupMemoryBarrier沒問題了,二者唯一區別一個是擴展寬度,一個擴展長度,有思路的朋友歡迎解答.
在1080P下取核長為21(半徑為10)的高斯模糊查看PC平台沒有優化及優化的效果.
其中沒優化的需要12.03ms,而優化后的是0.60+0.61=1.21ms,差不多10倍左右的差距,符合前面k/2的優化值,之所以快到理論值,應該要加上優化方向二使用局部共享顯存減少訪問紋理顯存這個.
把更新后的實現再次放入Redmi 10X Pro,同樣1080P下21核長下,可以看到不是放幻燈片了,差不多有10楨了吧,沒有專業工具測試,后續有時間完善測試比對.
AdaptiveThreshold 自適應閾值化
可以先看下GPUImage3里的實現.
public class AdaptiveThreshold: OperationGroup {
public var blurRadiusInPixels: Float { didSet { boxBlur.blurRadiusInPixels = blurRadiusInPixels } }
let luminance = Luminance()
let boxBlur = BoxBlur()
let adaptiveThreshold = BasicOperation(fragmentFunctionName:"adaptiveThresholdFragment", numberOfInputs:2)
public override init() {
blurRadiusInPixels = 4.0
super.init()
self.configureGroup{input, output in
input --> self.luminance --> self.boxBlur --> self.adaptiveThreshold --> output
self.luminance --> self.adaptiveThreshold
}
}
}
可以看到實現不復雜,根據輸入圖片得到亮度,然后boxBlur,然后把亮度圖與blur后的亮度圖交給adaptiveThreshold處理就完成了,原理很簡單,但是要求層可以內部加入別的處理層以及多輸入,當初設計時使用Graph計算圖時就考慮過多輸入多輸出的問題,這個是支持的,內部層加入別的處理層,這是圖層組合能力,這個我當初設計是給外部使用者用的,在這稍微改動一下,也是比較容易支持內部層類組合.
void VkAdaptiveThresholdLayer::onInitGraph() {
VkLayer::onInitGraph();
// 輸入輸出
inFormats[0].imageType = ImageType::r8;
inFormats[1].imageType = ImageType::r8;
outFormats[0].imageType = ImageType::r8;
// 這幾個節點添加在本節點之前
pipeGraph->addNode(luminance.get())->addNode(boxBlur->getLayer());
// 更新下默認UBO信息
memcpy(constBufCpu.data(), ¶met.offset, conBufSize);
}
void VkAdaptiveThresholdLayer::onInitNode() {
luminance->getNode()->addLine(getNode(), 0, 0);
boxBlur->getNode()->addLine(getNode(), 0, 1);
getNode()->setStartNode(luminance->getNode());
}
和別的處理層一樣,不同的是添加這個層時,根據onInitNode設定Graph如何自動連接前后層.
相應的luminance/adaptiveThreshold以及專門顯示只有一個通道層的圖像處理大家有興趣自己翻看,比較簡單就不貼了.
有興趣的可以在samples/vulkanextratest里,PC平台修改Win32.cpp,Android平台修改Android.cpp查看不同效果.后續有時間完善android下的UI使之查看不同層效果.