在上一篇文章里,我们讲解了为滤镜添加术语资源,从而使我们的滤镜可以被PS的scripting system感知和描述,这样即友好支持了PS的“动作”面板。在这一篇文章中,我们将对此前的DEMO进行进一步的细化,例如在参数对话框上增加实时预览的小缩略图等。对话框的引入主要是给用户一个机会和接口,设置或调节滤镜使用的图像处理算法。通常作为UI的友好性,在对话框上应该提供预览图,这样可以直观的把参数对结果产生的影响反馈给用户,指导他们调整参数。而不是要用户必须反复执行滤镜命令才能看到效果然后去调节参数。
此前我觉得“添加缩略图”这样的功能应该不是很困难,但当我尝试这样去做,我很快发现它的难度远远超过了以往我写的文章中的讲解。因为当我们尝试使用PS提供的回调函数去显示缩略图时,我们必须对PS提供的接口细节完全清楚,包括影响缩放的参数设置,数据分布,扫描行等细节。不能够有一分一毫的差错,否则我们就可能看到不正常的显示,甚至会不小心的使内存越界。
在引入缩略图之前,我先对滤镜算法做一些有趣的改进,进行了一点增强,使它更加实用化。
(1)引入“像素随机抖动”参数和算法。
此前我们设置像素时,输入和输出的位置本来是完全一致的,即 Dest(i, j) = f (Src(i, j))。
现在我们考虑对上式进行一点改动,把源像素进行随机抖动,即Dest(i, j) = f (Src(i+dx, j+dy))。
我们设置抖动距离为distance(像素)参数,这样我们取源像素时,在以当前像素为中心向外围扩展distance的正方形内随机选取某个点作为源像素。从而使他们在结果图中具有一种“溶解”或“腐蚀”效果。如下图所示:
因此我们在滤镜参数中增加了distance参数,表示上图中的随机抖动距离。这样当我们设置位于 (i,j)位置的像素时,我们取的源像素坐标是:
x = i + rand()%(2*distance+1) - distance;
y = j + rand()%(2*distance+1) - distance;
在实际处理时,我们还需要考虑上述结果x,y可能会超出有效数据边界,因此需要把x,y限定在 filterRect 内部。
而由于我们采用的是贴片(Tile)处理方法,因此我们的调整需要一点技巧性。我们将改动我们向PS请求的inRect,即每次贴片时,outRect依然保持和此前一致,而把 inRect 尝试向四周扩张distance像素距离,这样可以保证我们每次贴片时都能拿到有效数据(当贴片位于filterRect内部时),除非贴片位于filterRect的边缘。
必须注意的是,由于 inRect 比 outRect 要“大一圈”,所以这时候两个Rect的像素已经不是大小一致完全重叠关系了,而是有一定偏移的!在代码中我们必须考虑两个矩形之间的偏移关系。这里可以参考我的源代码,就不详细讲解该处理方法了。
(2)为对话框增加“缩略图”(Proxy)。
我们增加了一个参数,然后在对话框左侧空出一个较小区域用于显示缩略图,为了方便,我在缩略图位置放置了一个隐藏的STATIC 控件(Proxy Banner),它的主要用处是使我可以在运行时获取到 “缩略图”的边界(客户区坐标)。修改后的对话框如下图所示:
(2.1) displayPixles 回调函数 和 PSPixelMap 结构;
在显示缩略图时,我们使用的是 gFilterRecord 中的 displayPixels 回调函数,这个函数的原型如下(函数指针的typedef):
typedef MACPASCAL OSErr (*DisplayPixelsProc) (
const PSPixelMap *source,
const VRect *srcRect,
int32 dstRow,
int32 dstCol,
void *platformContext);
第一个参数是一个 PSPixelMap 结构体的指针用以描述一块像素数据区,它相当于BitBlt中的源图,srcRect参数描述的是源图矩形;
dstRow 和 dstCol 描述的是目标区域的“目标行”,“目标列”坐标,请注意其逻辑意义与我们通常使用的参数的区别。这里dstRow相当于 destY, dstCol就相当于 destX 参数,即(dstCol, dstRow) 是在目标区域中的起始坐标,这一点需要注意。
最后一个参数 platformContext 在 windows系统中也就是 HDC 。
其中第一个参数我们还需要再做简单介绍,即 PSPixelMap 的定义和 PS 对该数据区的分布要求。PSPixelMap的定义如下:
{
int32 version;
VRect bounds;
int32 imageMode;
int32 rowBytes;
int32 colBytes;
int32 planeBytes;
void *baseAddr;
//---------------------------------------------------------------------------
// Fields new in version 1:
//---------------------------------------------------------------------------
PSPixelMask *mat;
PSPixelMask *masks;
// Use to set the phase of the checkerboard:
int32 maskPhaseRow;
int32 maskPhaseCol;
//---------------------------------------------------------------------------
// Fields new in version 2:
//---------------------------------------------------------------------------
PSPixelOverlay *pixelOverlays;
unsigned32 colorManagementOptions;
} PSPixelMap;
◆ version: 结构体版本。
对于PS CS版本来说,它要求我们把它设置为1。PS的未来版本可能会扩展它并提升此版本号。
◆ bounds:像素数据所占据的矩形。
◆ imageMode:数据区的图像模式。
它支持以下模式: grayscale, RGB, CMYK, Lab。
◆ rowBytes: 相邻行之间的字节数距离。
相当于扫描行宽度(重要),以字节为单位,必须设置正确。
◆ colBytes: 相邻列像素数据的字节数距离。
由于数据是“集中分布”的,所以这个属性的值主要取决于像素的色深度。对于每个通道每个像素使用一个字节的普通24位深度的图像来说,这个距离为1byte。
◆ planeBytes: 相邻通道的字节数距离。
由于数据是“集中分布”的,所以这个属性通常是 rowBytes * 图像高度。
◆ baseAddr:数据区起始地址。
我曾在此前的文章中讲过,PS提供给我们的 inData 和 outData 是通道交叉分布的(interleave)。而这里PSPixelMap中的数据的分布要求则不同,它要求数据是通道集中分布的。
例如对于RGB图像来说, 当我们请求所有通道时,inData/outData中的数据分布是:
R | G | B | R | G | B | R | G | B | ..... (interleave)
而对于 PSPixelMap 中的数据来说,则要求按如下分布:
R | R | R | .... G | G | G | ... B | B | B .... (集中分布)
也就是说对inData 和 outData, 所有通道数据交叉分布,同一个通道(plane)的数据在数据区中是跳跃式存在的。
而对于 PSPixelMap 来说,同一个通道(plane)的数据是集中在一起的,先列出所有第一个通道数据,再列出所有第二个通道数据,等等。
同时我们进行像素定位和预测缓冲区大小时,和普通的Bitmap像素定位一样,必须使用滤镜参数中的 inRowBytes 属性(相当于扫描行宽度)进行在“行”间定位,而不能假设或自行计算“行宽度”。
【注意】我们必须清楚数据分布的细节,这样才能正确定位到指定位置的像素。
(2.2)控制缩放:FilterRecord中的 inputRate 和 inputPadding 属性;
由于PS处理的图像大小是多种多样的,因此我们显示缩略图时必然面对的一个问题是缩放问题。大多数情况下,由于缩略图只是定性展示,对数据精确性要求可以有所降低,并且考虑到性能因素,因此一般缩略图的尺寸可以设置的较小,当原图(filterRect)比缩略图(bannerRect)大时,我们希望图像缩小显示在缩略图上。而当原图比缩略图小时,我们就采用实际原图大小(即缩放因子=1)即可。因此现在我们需要了解如何把原图缩小到缩略图大小。在GDI中我们知道我们可以使用 StretchBlt 函数来完成缩放的。而在这里我们获取源图数据是从PS传递给我们的inData得到的,当我们希望得到缩小的原图时,我们即通过设置 FilterRecord 参数中的 inputRate (采样率)属性来完成缩小。
(2.2.1)Fixed inputRate;
==================================================
【关于 Fixed 类型】
Fixed在PS中被定义为 long 类型:
typedef long Fixed;
但是Fixed在PS中的实际意义是一个(定点)小数。所谓Fixed是相对 float(浮点小数)来说的。在float中小数点的位置是不固定的,因此称为浮动点。而 Fixed 则把一个小数拆解为整数部分和小数部分,分别存储到一个高16位和低16位。即其含义是 "16.16"。
例如假设有一个小数是3.00f;则相应的Fixed数是 0x00030000;
从浮点数转换成 Fixed 类型的方法是:
double _factor;
Fixed _fixed = ( Fixed ) ( _factor * 0x10000 ) ;
从 Fixed 类型转换成浮点类型的方法是 ( 备注:1 / 0x10000 = 0.0000152587890625 ) :
Fixed _fixed;
double _factor = _fixed * 0.0000152587890625 ;
==================================================
【补充】
根据我对 Photoshop CS 的观察,当设置 inputRate 时,PS 会把这个值进行了取整处理。也就是说,如果 inputRate < 1.0, 则 inputRate = 1.0; 相当于 Photoshop 对插件设置的 inputRate 做了如下处理:
int nInputRate = (pFilterRecord->inputRate + 0x8000) >> 16; if(nInputRate < 1) nInputRate = 1;
--hoodlum1980, on 2017-6-22。
==================================================
inputRate表示采样率,在逻辑意义上是一个小数,我们通过设置它的值来实现得到的inData是对原图的缩放结果。在默认情况下,PS设置的inputRate就是1,也就没有任何缩放。我们在获取缩略图数据时,需要计算缩放因子(factor),然后把inputRate设置为factor(注意数据类型的转换方法)。
这样设置后,我们得到的 inData 的实际图像坐标将是 inRect * inputRate,即
(inRect.left * inputRate, inRect.top* inputRate,
inRect.right * inputRate, inRect.bottom * inputRate)
例如,当 inputRate 为 2.0 时,则每两个像素采样一个点,则 inRect, inData 的关系如下图所示:
在上面这幅图中,展示了在缩放时应该如何设置inRect,请注意为了获取我们需要的矩形,我们需要把我们希望的inRect(粉红色矩形区域)的坐标除以 inputRate(图中假设inputRate = 2),才是需要提交给PS的 inRect(上图中的蓝色矩形)。然后我们使用advanceProc回调函数,即可得到 inData 为上图中右侧的图像数据,可见它的尺寸在两个方向上都缩小了一半。
【注意1】在处理完毕缩略图并关闭对话框时,必须把inputRate恢复为 0x00010000 (1.0)。否则它将会继续影响后续的实际处理中的 inData!使处理结果产生意料外的结果。
【注意2】滤镜参数必须考虑图像缩放所带来的影响。和缩放有关的参数也要相应的映射到缩略图尺寸上(例如本例中的随机抖动距离要同比例缩小)。和缩放无关的参数(例如本例中的不透明度百分比,填充色)可不考虑缩放影响。
(2.2.2)int16 inputPadding;
当PS提供 inData 时,它可以被补齐。可以指定补齐的像素的值(0~255),也可以设置为以下选项(它们被定义为负数,以和用户设置像素值区别):
plugInWantsEdgeReplication: 复制边缘像素。
plugInDoesNotWantPadding:随机值(不确定的值)。
plugInWantsErrorOnBoundsException:(默认值)请求边界外数据时标记一个错误。
当请求区域超出边界,PS将使用上述选项设置 inData 的数据。
(2.2.3)显示缩略图。
为了显示缩略图,我们需要请求PS为我们分配缓冲区。我们首先需要预测我们的缓冲区的大小,并在Prepare调用时通知 PS 我们的需求。
考虑到当用户在对话框上进行参数调整时,我们应该实时的更新缩略图显示,以反馈当前参数效果。所以我们需要两份缩略图数据,一份是缩略图的原始数据,它作为算法的输入,在创建对话框时获取到源图数据,然后在整个对话框生命期间保持数据不会改变。另一份是我们用于处理 WM_PAINT 消息时使用的绘制数据,即可以实时改变的缩略图实际显示数据。
因此我们评估缩略图的尺寸,然后使用以下估计值:
bufferSize = 缩略图最大宽度 * 缩略图最大高度 * 通道数 * 2;
在 Prepare 调用期间,我们把这个值(bufferSize)设置到 FilterRecord 的 bufferSpace 和 maxSpace 属性中,这表示我们(PlugIn)和PS(Host)进行内存需求“协商”,使 PS 了解到我们预期的内存开销,然后尝试准备足够内存以供我们后续的申请。
真正显示对话框是在 start 调用中,我们在对话框的初始化消息时准备请PS为我们申请缓冲区。基本方式如下:
BufferProcs *bufferProcs = gFilterRecord->bufferProcs;
//请PS为我们申请内存
bufferProcs->allocateProc(bufferSize, &m_ProxyData.bufferId0);
//请PS为我们锁定内存(禁止内存整理)
//[ 1 ]函数返回被锁定的内存起始地址。
//[ 2 ]第二个参数是BOOL moveHigth,对windows平台将被忽略。
m_ProxyData.data0 = bufferProcs->lockProc(m_ProxyData.bufferId0, TRUE);
//=============================
// 这里是处理和更新缓冲区的期间
//=============================
//使用结束后,释放和解锁缓冲区。
//解锁
gFilterRecord->bufferProcs->unlockProc(m_ProxyData.bufferId0);
//释放内存
gFilterRecord->bufferProcs->freeProc(m_ProxyData.bufferId0);
我们使用 lockProc 锁定缓冲区这块内存,主要是防止操作系统在我们处理数据期间进行内存整理,从而破坏缓冲区资源。
【注意】这里加锁和解锁使用的是“引用计数”机制,即解锁次数 必须 匹配加锁次数才能使缓冲区真正得到解锁。
为了显示缩略图,并能够实时反馈用户的调节,我们准备了下面的四个函数(其中CreateProxyBuffer 和 UpdateProxy 难度最大):
● CreateProxyBuffer
计算缩略图实际大小和缩放因子,委托PS为我们申请缓冲区,同时也初始化了原始数据(即把inData拷贝到PsPixelMap中),在处理 WM_INITDIALOG 时调用。
● UpdateProxy
当用户在对话框上修改了某个参数时(WM_COMMAND)被调用,用于更新缩略图显示数据,并刷新缩略图显示。会引起对 PaintProxy 函数的间接调用。
● PaintProxy
绘制缩略图,通过 displayPixels 回调函数完成,在处理 WM_PAINT 消息时调用。
● DeleteProxyBuffer
释放我们申请的缓冲区,在对话框退出前(WM_DESTROY)调用。
现在总结一下上面四个函数的调用时机,使我们对这四个函数的分工具有一个明确的认识,如下表:
窗口消息 |
事件 |
被调用的函数 |
说明 |
WM_INITDIALOG |
创建对话框 |
CreateProxyBuffer |
申请缩略图缓冲区并初始化 |
WM_COMMAND |
修改参数值 |
UpdateProxy |
更新缩略图,将间接调用PainProxy |
WM_PAINT |
窗口绘制 |
PaintProxy |
绘制缩略图 |
WM_DESTROY |
退出对话框 |
DeleteProxyBuffer |
释放缩略图缓冲区 |
【注意】把 inData 拷贝到 PSPixelMap, 是一个难度很大,并且特别需要注意的地方。两块数据的通道数据的分布不同,因此像素定位方式也完全不同。并且涉及到缓冲区大小的计算和申请。 复制缓冲区时是使用指针进行访问的,而这非常容易因为引发错误(将导致PS进程崩溃)。
在CreateProxyBuffer中,我们的主要任务是分配缓冲区,然后把源图数据(inData)相应的拷贝到我们的缓冲区(绘制时设置给PSPixelMap结构)。由于这是一个有难度的地方,因此我特别把这个函数代码放在此处展示,代码如下:
在上面的函数(CreateProxyBuffer)中,我们首先按照下面的方法计算出缩略图的缩放因子:
factor = ceiling (max(原图宽度 / 缩略图宽度, 原图高度 / 缩略图高度));
然后我们计算了缩略图的起始点坐标(m_ProxyData.left, m_ProxyData.top)和采用上述缩放因子后的缩略图实际尺寸(m_ProxyData.width, m_ProxyData.height)。请注意,我们把 factor 向上取整(ceiling),这会使缩略图的实际尺寸是小于等于其 BANNER 尺寸的。通过设置左上角坐标,我们使缩略图的位置在 BANNER 矩形中居中。
然后我们委托 PS 为我们分配两块同样大小的缓冲区 data0 和 data1(一个原图数据拷贝,一个是用于即时显示)并锁定它们。我们使用了PS提供的 advanceState 回调去请求原图数据,我在此前的文章中已经介绍过这个最重要的回调函数之一,它的作用是请求 Photoshop 立即更新滤镜参数(FilterRecord)结构中的相关数据,包括inData,outData等等。请注意在上面的代码中,我们是逐个通道进行复制的,即我们每次请求PS为我们发送一个通道的数据,然后我们把这批数据一次性的完全拷贝到缓冲区(使用memcpy),这样就完成了通道数据的“集中分布”。其中每个通道字节数(planeBytes)计算方法如下:
每个通道字节数(planeBytes) = 单一通道的扫描行宽度(inRowBytes) * 缩略图的图像高度(inRect高度);
我们把缩略图数据的信息并保存在m_ProxyData参数中。在 PaintProxy 中,我们只需要把这些信息再设置并提交给 displayProxy 回调函数即可。显示缩略图(PaintProxy,UpdateProxy)的主要逻辑和代码原理,限于篇幅这里不详细讲述,可参考附件中的源代码。最后我们可以看下滤镜的对话框运行效果如下:
当在上面的滤镜对话框中使用鼠标拖动或者键盘改变文本框数值时,左侧缩略图将会实时更新以反应当前的参数效果。在参数设置对话框中,我模拟了一个Photoshop中常见的UI特性,当你把鼠标悬停在数值文本框的左侧标签上时,光标变为一个拖动箭头的形状,这时按下鼠标,左右拖动,可以看到相应文本框的数据发生变化(这和操作滑杆控件非常类似)。在上面这个对话框中,你能够看到我如何模拟了PS的这种UI效果(在Photoshop看似朴素的外表下,隐藏着非常多让人惊叹的 UI 效果,而这只是它们中的其中一个,向强大的Photoshop致敬!)。
(3)增加一个我们自己定义的“关于对话框”。
在此前为了简单起见,在“关于”中我仅仅弹出了一个MessageBox。我们可以自定义一个关于对话框,同样这里我吸取了 PS 的关于对话框的建议和风格,即没有标题栏,没有任何按钮,对话框初始位置在其父窗口的中等偏上(上1/3)处。用户按Escape,回车键 或用鼠标点击任何位置即退出对话框。我的滤镜的关于对话框如下(在PS中点击菜单:帮助 -> 关于增效工具 -> FillRed Filter... ):
这是一个普通的对话框,但我主要想介绍是当鼠标移动到我的博客的网址上时,光标变成(IDC_HAND)手形,点击即可使用默认浏览器打开网址。它是用过使用PS的回调函数集中的相应函数来完成的。因此这里我将示范 PS callback suites 的一种标准用法:
在上面的代码中我们可以看到, PS CALLBACK Suites的用法 和 COM 组件的 QueryInterface 的使用方法是完全类似的:先声明想获取的回调函数集(callback Suite,一个含有一组PS内部的函数指针的struct)的一个指针,然后把该指针的地址传递给 BasicSuite 的 AcquireSuite 函数,成功以后我们就可以通过该指针去调用PS提供给插件的相应回调函数。
(4)总结。
到目前为止,我们已经完整的讲解了有关制作一个Photoshop滤镜的主要技术环节,从(1)创建项目,到(2)添加UI资源,再到(3)使Photoshop Scripting System知道我们的滤镜,并支持“动作”面板的对话框选项,以及本篇重点讲述的添加在对话框上的缩略图。涵盖了制作 Photoshop 滤镜插件的流程和重要知识,而Photoshop插件开发的技术细节以及插件种类仍然是非常繁复众多的,有待进一步的研究。
我们开发Photoshop插件的一个主要原因是,PS是图形处理领域的重要软件,为第三方开放了插件扩展的接口。作为第三方开发者我们可以根据自己的需求,遵照PS的约定去以插件形式扩展PS。在PS的重要用户基础上,扩展和研究将会更有实际意义。
制作滤镜的基本技术已经介绍完成,剩下的其他工作将主要是对图像处理算法的寻求和发掘。
本例是以使用基于Platform SDK的Windows程序开发为基础的,但重点在于讲解PS插件开发,因此没有详细讲解Windows程序开发中的一些技术细节。
(5)最后是源代码(增量更新)的下载链接:
https://files.cnblogs.com/hoodlum1980/FillRed.rar
(6)我的相关文章:
《怎样编写一个Photoshop滤镜(3) -- Scriping PlugIn》
【备注】有网友反应范例中的编译结果在PS CS2下无法加载,我发现编译好的滤镜文件仅有60KB左右(这样小感觉不太正常),而正常应该有至少有几百KB大小才对,可能是因为某些依赖未能静态链接。因此这就会导致这个DLL在我的机器环境上能够正常加载,但换个环境可能就会导致缺少依赖而无法加载。
我把项目中的ATL选项设置为“静态链接到ATL”后,编译结果就成了几百KB。使用我同事的机器环境(安装了PS CS2版本)作为测试,这时果然能够正常被PS CS2加载到了。