凯特离开了科技行业,花了一年的时间寻找自己的路,同时构建了她的激情项目 Show & Hide。她是 Ride 的移动工程总监,在国际上发表关于移动开发和工程文化的演讲,共同策划 Technically Speaking 并且是 Glowforge 的顾问。凯特不完全住在哥伦比亚,但她在那里花费了大量时间,并且曾在英国、澳大利亚、加拿大、中国和美国生活和工作,此前曾在 Google 担任工程师,在 IBM 担任 Extreme Blue 实习生,以及滑雪教练。凯特在Accidentally in Code 上写博客,并在 Twitter 上是@catehstn。
当我在中国旅行时,我经常看到一系列四幅画,展示同一地点在不同季节的景象。颜色——冬天的冷白色、春天的淡色调、夏天的翠绿色以及秋天的红色和黄色——是视觉上区分季节的因素。大约在 2011 年,我产生了一个我认为很绝妙的想法:我想能够将一系列照片可视化为一系列颜色。我认为它可以展示旅行,以及穿越季节的进程。
但我不知道如何计算图像的主导颜色。我考虑过将图像缩小到 1x1 的正方形并查看剩余的内容,但这似乎像是作弊。不过,我知道我想如何显示图像:使用名为向日葵布局的布局。这是排列圆圈最有效的方法。
我把这个项目搁置了数年,被工作、生活、旅行和演讲分散了注意力。最终我回到了它,弄清楚了如何计算主导颜色,并完成了我的可视化。那时我发现,这个想法实际上并不绝妙。进展并不像我期望的那样清晰,提取的主导颜色通常不是最吸引人的色调,创建过程花费了很长时间(每张图像几秒钟),并且需要数百张图像才能制作出很酷的东西(图 11.1)。
图 11.1 - 向日葵布局
您可能会认为这会令人沮丧,但当我到达这一点时,我已经学到了许多以前从未遇到过的事情——关于颜色空间和像素操作——并且我开始制作那些很酷的部分彩色图像,就像您在伦敦明信片上看到的带有红色巴士或电话亭,而其他所有内容都是灰度色的那种。
我使用了一个名为Processing 的框架,因为我熟悉它在开发编程课程中的应用,并且因为我知道它可以轻松创建可视化应用程序。它最初是为艺术家设计的工具,因此它抽象化了许多样板代码。它允许我进行游戏和实验。
大学,以及后来的工作,用其他人的想法和优先事项填满了我的时间。完成这个项目的一部分是学习如何抽出时间来推进自己的想法;我每周需要大约四个小时的良好心理时间。因此,一个能够让我更快前进的工具非常有帮助,甚至必要——尽管它也带来了一系列问题,尤其是在编写测试方面。
我觉得彻底的测试对于验证项目的运行方式以及使其更容易拾起并恢复一个经常搁置数周甚至数月之久的项目尤其重要。测试(和博客文章!)构成了这个项目的文档。我可以留下失败的测试来记录我尚未弄清楚应该发生的事情,并充满信心地进行更改,因为如果我更改了一些我忘记了至关重要的内容,测试会提醒我。
本章将介绍有关 Processing 的一些细节,并引导您了解颜色空间、将图像分解成像素并对其进行操作,以及对并非为测试而设计的项目进行单元测试。但我希望它也能促使您在您最近没有抽出时间的想法上取得一些进展;即使您的想法最终像我的想法一样糟糕,您也可能会在过程中创造出一些很酷的东西并学到一些迷人的知识。
本章将向您展示如何创建一个图像滤镜应用程序,您可以使用它通过自己创建的滤镜来操作您的数字图像。我们将使用 Processing,这是一种用 Java 构建的编程语言和开发环境。我们将介绍在 Processing 中设置应用程序、Processing 的一些功能、颜色表示的各个方面以及如何创建颜色滤镜(模仿旧式摄影中使用的滤镜)。我们还将创建一种只能通过数字方式实现的特殊滤镜:确定图像的主导色调并显示或隐藏它,以创建怪异的部分彩色图像。
最后,我们将添加一个完整的测试套件,并介绍如何在处理与可测试性相关的 Processing 的一些限制方面。
如今,我们可以在几秒钟内拍摄照片、对其进行操作并与所有朋友分享。然而,很久以前(从数字角度来看),这是一个需要数周才能完成的过程。
在过去,我们会拍摄照片,然后当我们用完一整卷胶卷时,我们会将其送去冲洗(通常在药店)。几天后我们会取回冲洗好的照片——并发现其中许多照片存在问题。手不够稳?当时我们没有注意到的随机的人或物?曝光过度?曝光不足?当然,那时已经太迟了,无法补救这个问题。
将胶卷变成图片的过程是大多数人都不了解的过程。光线是一个问题,因此您必须小心使用胶卷。有一个过程,涉及暗室和化学物质,他们有时会在电影或电视中展示。
但可能更少的人了解我们如何从智能手机相机上的点按到 Instagram 上的图像。实际上有很多相似之处。
照片是由光线对感光表面的影响产生的。照相胶卷上覆盖着卤化银晶体。(额外的层用于创建彩色照片——为简单起见,这里我们只关注黑白照片。)
当拍摄传统照片(使用胶卷)时,光线会根据您所指向的目标照射到胶卷上,并且这些点的晶体会根据光线的强度发生不同程度的变化。然后,冲洗过程将卤化银转化为金属银,从而创建底片。底片上图像的明暗区域是反转的。底片冲洗出来后,还有一系列步骤可以反转图像并打印出来。
使用智能手机或数码相机拍照时,没有胶卷。有一种叫做主动像素传感器的东西,其功能类似。过去我们使用卤化银晶体,现在我们使用像素——微小的正方形。(事实上,像素是“图像元素”的简称。)数字图像由像素组成,分辨率越高,像素就越多。这就是为什么低分辨率图像被称为“像素化”——您可以开始看到正方形。这些像素存储在一个数组中,每个数组“框”中的数字包含颜色。
在图 11.2中,我们看到一张在纽约现代艺术博物馆拍摄的一些充气动物的高分辨率照片。图 11.3是同一张图像放大后的版本,但只有 24 x 32 个像素。
图 11.2 - 纽约现代艺术博物馆的充气动物
图 11.3 - 放大的充气动物
看到它有多模糊了吗?我们称之为像素化,这意味着图像对于它包含的像素数量来说太大了,正方形变得可见。在这里,我们可以用它来更好地理解图像是由彩色正方形组成的。
这些像素是什么样子的?如果我们使用 Java 中方便的Integer.toHexString
打印出中间(10,10 到 10,14)的一些像素的颜色,我们会得到十六进制颜色
FFE8B1
FFFAC4
FFFCC3
FFFCC2
FFF5B7
十六进制颜色是六个字符长。前两个是红色值,接下来的两个是绿色值,最后的两个是蓝色值。有时还有额外的两个字符,表示 alpha 值。在这种情况下,FFFAC4
表示
在图 11.4中,我们看到我们的应用程序正在运行的图片。我知道它非常具有开发者设计风格,但我们只有 500 行 Java 代码可以使用,所以一些东西必须做出牺牲!您可以看到右侧的命令列表。我们可以做一些事情
图 11.4 - 应用程序
Processing 使创建小型应用程序和进行图像处理变得简单;它非常注重视觉效果。我们将使用基于 Java 的版本,尽管 Processing 现在已移植到其他语言。
在本教程中,我通过将core.jar
添加到我的构建路径中,在 Eclipse 中使用 Processing。如果您愿意,可以使用 Processing IDE,它可以省去许多样板 Java 代码。如果您以后想将其移植到 Processing.js 并将其上传到网上,则需要用其他东西替换文件选择器。
项目存储库中有带有屏幕截图的详细说明。如果您已经熟悉 Eclipse 和 Java,则可能不需要它们。
我们不希望我们的应用程序是一个很小的灰色窗口,因此我们将首先覆盖的两个基本方法是setup()
和draw()
。setup()
方法仅在应用程序启动时调用一次,用于执行设置应用程序窗口大小等操作。draw()
方法在每次动画时或某些操作后被调用,可以通过调用redraw()
触发。(如 Processing 文档中所述,不应显式调用draw()
。)
Processing 被设计成可以很好地创建动画草图,但在这种情况下,我们不需要动画1,我们希望响应按键。为了防止动画(这会拖慢性能),我们将在 setup 中调用noLoop()
。这意味着draw()
仅在setup()
之后立即调用一次,以及在我们调用redraw()
时。
private static final int WIDTH = 360;
private static final int HEIGHT = 240;
public void setup() {
noLoop();
// Set up the view.
size(WIDTH, HEIGHT);
background(0);
}
public void draw() {
background(0);
}
这些方法目前还没有做太多事情,但请尝试再次运行应用程序,调整WIDTH
和HEIGHT
中的常量,以查看不同的尺寸。
background(0)
指定黑色背景。尝试更改传递给background()
的数字,看看会发生什么——它是 alpha 值,因此如果您只传递一个数字,它始终是灰度。或者,您可以调用background(int r, int g, int b)
。
PImage 对象是表示图像的 Processing 对象。我们将大量使用它,因此值得通读文档。它有三个字段(表 11.1)以及我们将使用的一些方法(表 11.2)。
pixels[]
|
包含图像中每个像素颜色的数组 |
width
|
图像宽度(以像素为单位) |
height
|
图像高度(以像素为单位) |
: 表 11.1 - PImage 字段
loadPixels
|
将图像的像素数据加载到其pixels[] 数组中 |
updatePixels
|
使用其pixels[] 数组中的数据更新图像 |
resize
|
将图像的大小更改为新的宽度和高度 |
get
|
读取任何像素的颜色或获取像素矩形 |
set
|
将颜色写入任何像素或将图像写入另一个图像 |
save
|
将图像保存为TIFF、TARGA、PNG或JPEG文件 |
: 表 11.2 - PImage 方法
Processing 处理大部分文件选择过程;我们只需要调用selectInput()
,并实现一个回调(必须是公共的)。
对于熟悉 Java 的人来说,这可能看起来很奇怪;监听器或 lambda 表达式可能更有意义。但是,由于 Processing 作为一种面向艺术家的工具而开发,在大多数情况下,这些内容已被该语言抽象化,以保持其易用性。这是设计人员做出的选择:优先考虑简单性和易用性而不是强大性和灵活性。如果您使用精简版的 Processing 编辑器,而不是在 Eclipse 中将 Processing 作为库,则您甚至不需要定义类名。
其他具有不同目标受众的语言设计者会做出不同的选择,这应该是他们应该做的。例如,在 Haskell(一种纯函数式语言)中,函数式语言范式的纯净性优先于其他一切。这使得它成为解决数学问题的更好工具,而不是解决任何需要 IO 的问题。
// Called on key press.
private void chooseFile() {
// Choose the file.
selectInput("Select a file to process:", "fileSelected");
}
public void fileSelected(File file) {
if (file == null) {
println("User hit cancel.");
} else {
// save the image
redraw(); // update the display
}
}
通常在 Java 中,响应按键需要添加监听器并实现匿名函数。但是,与文件选择器一样,Processing 为我们处理了其中很多内容。我们只需要实现keyPressed()
。
public void keyPressed() {
print(“key pressed: ” + key);
}
如果您再次运行应用程序,则每次按下按键时,它都会将其输出到控制台。稍后,您将希望根据按下的键执行不同的操作,为此,您只需根据键值进行切换。(这存在于PApplet
超类中,并包含最后一个被按下的键。)
此应用程序目前功能不多,但我们已经可以看到许多可能出错的地方;例如,用按键触发错误的操作。随着我们添加复杂性,我们还会添加更多潜在的问题,例如错误地更新图像状态,或在应用滤镜后错误地计算像素颜色。我个人也很享受(有些人认为很奇怪)编写单元测试。虽然有些人似乎认为测试会延迟代码检查,但我认为测试是我首选的调试工具,也是深入了解代码中发生情况的机会。
我非常喜欢 Processing,但它旨在创建可视化应用程序,并且在这个领域单元测试可能不是一个很大的问题。很明显,它不是为可测试性而编写的;事实上,它以一种使其无法测试的方式编写,按原样。部分原因是它隐藏了复杂性,而其中一些隐藏的复杂性在编写单元测试时确实很有用。静态和 final 方法的使用使得使用模拟对象(记录交互并允许您伪造系统的一部分以验证另一部分的行为是否正确)变得更加困难,而模拟对象依赖于子类化的能力。
我们可能会怀着良好的意图开始一个全新项目,进行测试驱动开发 (TDD) 并实现完美的测试覆盖率,但实际上,我们通常会看到大量由各种各样的人编写的代码,并试图弄清楚它应该做什么,以及它是如何以及为什么出错的。然后,也许我们不会编写完美的测试,但编写任何测试都会帮助我们应对这种情况,记录正在发生的事情并继续前进。
我们创建“缝合点”,使我们能够将某些东西从其混乱的缠结部分中分解出来,并分部分进行验证。为此,我们有时会创建可以模拟的包装器类。这些类除了保存一系列类似的方法或将调用转发到另一个无法模拟的对象(由于 final 或静态方法)之外,什么也不做,因此编写起来非常枯燥,但对于创建缝合点和使代码可测试至关重要。
我使用 JUnit 进行测试,因为我使用 Processing 作为库在 Java 中工作。对于模拟,我使用了 Mockito。您可以下载Mockito 并将 JAR 添加到您的构建路径中,就像您添加core.jar
一样。我创建了两个辅助类,使模拟和测试应用程序成为可能(否则我们无法测试涉及PImage
或PApplet
方法的行为)。
IFAImage
是围绕 PImage 的一个薄包装器。PixelColorHelper
是围绕 applet 像素颜色方法的包装器。这些包装器调用 final 和静态方法,但调用者方法本身既不是 final 也不是静态的——这允许它们被模拟。这些是故意轻量级的,我们可以做得更多,但这足以解决使用 Processing 时可测试性的主要问题——静态和 final 方法。毕竟目标是制作一个应用程序——而不是为 Processing 创建一个单元测试框架!
名为ImageState
的类构成此应用程序的“模型”,尽可能地从扩展PApplet
的类中移除逻辑,以提高可测试性。它也有助于更简洁的设计和关注点分离:App
控制交互和 UI,而不是图像操作。
在我们开始编写更复杂的像素处理之前,我们可以从一个简短的练习开始,这将使我们能够舒适地进行像素操作。我们将创建标准(红色、绿色、蓝色)颜色滤镜,这些滤镜将允许我们创建与将彩色板放置在相机镜头上相同的效果,只允许透射具有足够红色(或绿色或蓝色)的光。
通过将不同的滤镜应用于此图像图 11.5(拍摄于春季法兰克福之旅),这几乎就像季节不同。(还记得我们之前想象的四季绘画吗?)看看当应用红色滤镜时,树木变得多么绿色。
图 11.5 - 法兰克福的四个(模拟)季节
我们该怎么做呢?
设置滤镜。(您可以像前面的图像一样组合红色、绿色和蓝色滤镜;在这些示例中我没有这样做,以便效果更清晰。)
对于图像中的每个像素,检查其 RGB 值。
任何缺少所有这些颜色的像素都将是黑色。
尽管我们的图像是二维的,但像素位于从左上角开始并向左到右、从上到下移动的一维数组中。4x4 图像的数组索引如下所示
0 | 1 | 2 | 3 |
4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 |
: 表 11.3 - 4x4 图像的像素索引
public void applyColorFilter(PApplet applet, IFAImage img, int minRed,
int minGreen, int minBlue, int colorRange) {
img.loadPixels();
int numberOfPixels = img.getPixels().length;
for (int i = 0; i < numberOfPixels; i++) {
int pixel = img.getPixel(i);
float alpha = pixelColorHelper.alpha(applet, pixel);
float red = pixelColorHelper.red(applet, pixel);
float green = pixelColorHelper.green(applet, pixel);
float blue = pixelColorHelper.blue(applet, pixel);
red = (red >= minRed) ? red : 0;
green = (green >= minGreen) ? green : 0;
blue = (blue >= minBlue) ? blue : 0;
image.setPixel(i, pixelColorHelper.color(applet, red, green, blue, alpha));
}
}
正如我们的第一个图像滤镜示例所示,程序中颜色的概念和表示对于理解我们的滤镜如何工作非常重要。为了为我们的下一个滤镜做好准备,让我们进一步探索颜色的概念。
我们在上一节中使用了名为“颜色空间”的概念,这是数字表示颜色的方法。孩子们混合颜料时会发现颜色可以由其他颜色构成;在数字世界中,事情的工作方式略有不同(减少沾染颜料的风险!),但类似。Processing 使得使用任何你想要的颜色空间变得非常容易,但你需要知道选择哪一个,所以理解它们的工作原理非常重要。
大多数程序员熟悉的颜色空间是 RGBA:红色、绿色、蓝色和 alpha;这就是我们上面使用的。在十六进制(16 进制)中,前两位数字是红色量,第二位是蓝色,第三位是绿色,最后两位(如果存在)是 alpha 值。这些值从 16 进制的 00(10 进制的 0)到 FF(10 进制的 255)不等。alpha 表示不透明度,其中 0 是透明的,100% 是不透明的。
这种颜色空间不如 RGB 广为人知。第一个数字表示色调,第二个数字表示饱和度(颜色强度),第三个数字表示亮度。HSB 颜色空间可以用圆锥体表示:色调是圆锥体周围的位置,饱和度是距中心的距离,亮度是高度(0 亮度为黑色)。
既然我们对像素操作很熟悉了,那么让我们做一些只能在数字世界中才能做到的事情。在数字世界中,我们可以以一种不那么统一的方式操作图像。
当我浏览我的照片流时,我可以看到一些主题正在出现。我在香港港口乘坐游船在日落时拍摄的夜景系列,朝鲜的灰色,巴厘岛的翠绿,冰岛冬天的冰冷白色和浅蓝色。我们可以拍摄一张照片并提取出主导场景的主要颜色吗?
使用 HSB 颜色空间是有意义的——我们在确定主要颜色时对色调感兴趣。可以使用 RGB 值来做到这一点,但更困难(我们必须比较所有三个值)并且对黑暗更敏感。我们可以使用colorMode更改为 HSB 颜色空间。
确定了这个颜色空间后,它比使用 RGB 更简单。我们需要找到每个像素的色调,并确定哪个色调最“流行”。我们可能不想太精确——我们希望将非常相似的色调组合在一起,我们可以使用两种策略来处理这个问题。
首先,我们将返回的十进制数四舍五入到整数,因为这使得确定我们将每个像素放入哪个“桶”变得很简单。其次,我们可以更改色调的范围。如果我们回想一下上面圆锥体的表示,我们可能会将色调视为具有 360 度(如圆形)。Processing 默认使用 255,这与 RGB 通常相同(255 在十六进制中是 FF)。我们使用的范围越高,图片中色调的差异就越大。使用较小的范围将允许我们将相似的色调组合在一起。使用 360 度范围,我们不太可能能够区分 224 和 225 的色调,因为差异非常小。如果我们将范围缩小到三分之一,即 120,则这两个色调在四舍五入后都变为 75。
我们可以使用colorMode
更改色相范围。如果我们调用colorMode(HSB, 120)
,那么我们的色相检测精度就会比使用255范围时降低不到一半。我们也知道我们的色相将落入120个“桶”中,因此我们可以简单地遍历我们的图像,获取像素的色相,并在数组中相应计数中加一。这将是\(O(n)\),其中\(n\)是像素的数量,因为它需要对每个像素进行操作。
for(int px in pixels) {
int hue = Math.round(hue(px));
hues[hue]++;
}
最后,我们可以将此色相打印到屏幕上,或将其显示在图片旁边(图11.6)。
图11.6 - 主色相与使用范围(桶的数量)的大小
提取“主导”色相后,我们可以选择在图像中显示或隐藏它。我们可以显示具有不同容差的主导色相(我们接受的周围范围)。不属于此范围的像素可以通过根据亮度设置值来更改为灰度。图11.7显示了使用240范围确定的主导色相,以及不同的容差。容差是与最流行色相两侧分组在一起的数量。
图11.7 - 显示主导色相
或者,我们可以隐藏主导色相。在图11.8中,图像并排转置:中间是原始图像,左侧显示主导色相(路径的棕色),右侧隐藏主导色相(范围320,容差20)。
图11.8 - 隐藏主导色相
每个图像都需要两次遍历(查看每个像素两次),因此在像素数量很大的图像上,它可能需要花费相当长的时间。
public HSBColor getDominantHue(PApplet applet, IFAImage image, int hueRange) {
image.loadPixels();
int numberOfPixels = image.getPixels().length;
int[] hues = new int[hueRange];
float[] saturations = new float[hueRange];
float[] brightnesses = new float[hueRange];
for (int i = 0; i < numberOfPixels; i++) {
int pixel = image.getPixel(i);
int hue = Math.round(pixelColorHelper.hue(applet, pixel));
float saturation = pixelColorHelper.saturation(applet, pixel);
float brightness = pixelColorHelper.brightness(applet, pixel);
hues[hue]++;
saturations[hue] += saturation;
brightnesses[hue] += brightness;
}
// Find the most common hue.
int hueCount = hues[0];
int hue = 0;
for (int i = 1; i < hues.length; i++) {
if (hues[i] > hueCount) {
hueCount = hues[i];
hue = i;
}
}
// Return the color to display.
float s = saturations[hue] / hueCount;
float b = brightnesses[hue] / hueCount;
return new HSBColor(hue, s, b);
}
public void processImageForHue(PApplet applet, IFAImage image, int hueRange,
int hueTolerance, boolean showHue) {
applet.colorMode(PApplet.HSB, (hueRange - 1));
image.loadPixels();
int numberOfPixels = image.getPixels().length;
HSBColor dominantHue = getDominantHue(applet, image, hueRange);
// Manipulate photo, grayscale any pixel that isn't close to that hue.
float lower = dominantHue.h - hueTolerance;
float upper = dominantHue.h + hueTolerance;
for (int i = 0; i < numberOfPixels; i++) {
int pixel = image.getPixel(i);
float hue = pixelColorHelper.hue(applet, pixel);
if (hueInRange(hue, hueRange, lower, upper) == showHue) {
float brightness = pixelColorHelper.brightness(applet, pixel);
image.setPixel(i, pixelColorHelper.color(applet, brightness));
}
}
image.updatePixels();
}
在当前的UI中,用户可以将红色、绿色和蓝色滤镜组合在一起。如果他们将主色相滤镜与红色、绿色和蓝色滤镜组合在一起,由于颜色空间的变化,结果有时可能会有点出乎意料。
Processing有一些内置方法支持图像操作;例如,invert
和blur
。
为了实现锐化、模糊或棕褐色等效果,我们应用矩阵。对于图像的每个像素,取乘积之和,其中每个乘积是当前像素或其邻居的颜色值,以及滤镜矩阵的相应值。有一些特定值的特殊矩阵可以锐化图像。
应用程序有三个主要组件(图11.9)。
应用程序包含一个文件:ImageFilterApp.java
。它扩展了PApplet
(Processing应用程序超类)并处理布局、用户交互等。此类是最难测试的,因此我们希望使其尽可能小。
模型包含三个文件:HSBColor.java
是HSB颜色的简单容器(包含色相、饱和度和亮度)。IFAImage
是PImage
的包装器,用于可测试性。(PImage
包含许多无法模拟的最终方法。)最后,ImageState.java
是描述图像状态的对象——应应用哪种级别的滤镜以及哪些滤镜——并处理图像加载。(注意:每当颜色滤镜向下调整以及每当重新计算主色相时,都需要重新加载图像。为了清晰起见,我们只在每次处理图像时重新加载。)
颜色包含两个文件:ColorHelper.java
是所有图像处理和滤镜发生的地方,PixelColorHelper.java
抽象出最终的PApplet
方法,用于像素颜色以实现可测试性。
图11.9 - 架构图
如上所述,有两个包装类(IFAImage
和PixelColorHelper
)包装库方法以实现可测试性。这是因为,在Java中,关键字“final”表示不能被子类覆盖或隐藏的方法,这意味着它们不能被模拟。
PixelColorHelper
包装小程序上的方法。这意味着我们需要将小程序传递到每个方法调用中。(或者,我们可以将其设为字段并在初始化时设置它。)
package com.catehuston.imagefilter.color;
import processing.core.PApplet;
public class PixelColorHelper {
public float alpha(PApplet applet, int pixel) {
return applet.alpha(pixel);
}
public float blue(PApplet applet, int pixel) {
return applet.blue(pixel);
}
public float brightness(PApplet applet, int pixel) {
return applet.brightness(pixel);
}
public int color(PApplet applet, float greyscale) {
return applet.color(greyscale);
}
public int color(PApplet applet, float red, float green, float blue,
float alpha) {
return applet.color(red, green, blue, alpha);
}
public float green(PApplet applet, int pixel) {
return applet.green(pixel);
}
public float hue(PApplet applet, int pixel) {
return applet.hue(pixel);
}
public float red(PApplet applet, int pixel) {
return applet.red(pixel);
}
public float saturation(PApplet applet, int pixel) {
return applet.saturation(pixel);
}
}
IFAImage
是PImage
的包装器,因此在我们的应用程序中,我们不会初始化PImage
,而是初始化IFAImage
——尽管我们确实必须公开PImage
以便可以渲染它。
package com.catehuston.imagefilter.model;
import processing.core.PApplet;
import processing.core.PImage;
public class IFAImage {
private PImage image;
public IFAImage() {
image = null;
}
public PImage image() {
return image;
}
public void update(PApplet applet, String filepath) {
image = null;
image = applet.loadImage(filepath);
}
// Wrapped methods from PImage.
public int getHeight() {
return image.height;
}
public int getPixel(int px) {
return image.pixels[px];
}
public int[] getPixels() {
return image.pixels;
}
public int getWidth() {
return image.width;
}
public void loadPixels() {
image.loadPixels();
}
public void resize(int width, int height) {
image.resize(width, height);
}
public void save(String filepath) {
image.save(filepath);
}
public void setPixel(int px, int color) {
image.pixels[px] = color;
}
public void updatePixels() {
image.updatePixels();
}
}
最后,我们有我们的简单容器类HSBColor
。请注意,它是不可变的(一旦创建,就无法更改)。不可变对象对于线程安全(我们这里不需要!)更好,而且也更容易理解和推理。一般来说,我倾向于使简单的模型类不可变,除非我发现有充分的理由不这样做。
你们中的一些人可能知道,在Processing和Java本身中已经存在表示颜色的类。不深入这些细节,它们都更侧重于RGB颜色,特别是Java类增加了比我们需要的更多的复杂性。如果我们确实想使用Java的awt.Color
,我们可能会没问题;但是awt GUI组件不能在Processing中使用,因此出于我们的目的,创建这个简单的容器类来保存我们需要的数据位是最简单的。
package com.catehuston.imagefilter.model;
public class HSBColor {
public final float h;
public final float s;
public final float b;
public HSBColor(float h, float s, float b) {
this.h = h;
this.s = s;
this.b = b;
}
}
ColorHelper
是所有图像操作发生的地方。如果不需要PixelColorHelper
,此类中的方法可以是静态的。(尽管我们不会在这里讨论静态方法的优缺点。)
package com.catehuston.imagefilter.color;
import processing.core.PApplet;
import com.catehuston.imagefilter.model.HSBColor;
import com.catehuston.imagefilter.model.IFAImage;
public class ColorHelper {
private final PixelColorHelper pixelColorHelper;
public ColorHelper(PixelColorHelper pixelColorHelper) {
this.pixelColorHelper = pixelColorHelper;
}
public boolean hueInRange(float hue, int hueRange, float lower, float upper) {
// Need to compensate for it being circular - can go around.
if (lower < 0) {
lower += hueRange;
}
if (upper > hueRange) {
upper -= hueRange;
}
if (lower < upper) {
return hue < upper && hue > lower;
} else {
return hue < upper || hue > lower;
}
}
public HSBColor getDominantHue(PApplet applet, IFAImage image, int hueRange) {
image.loadPixels();
int numberOfPixels = image.getPixels().length;
int[] hues = new int[hueRange];
float[] saturations = new float[hueRange];
float[] brightnesses = new float[hueRange];
for (int i = 0; i < numberOfPixels; i++) {
int pixel = image.getPixel(i);
int hue = Math.round(pixelColorHelper.hue(applet, pixel));
float saturation = pixelColorHelper.saturation(applet, pixel);
float brightness = pixelColorHelper.brightness(applet, pixel);
hues[hue]++;
saturations[hue] += saturation;
brightnesses[hue] += brightness;
}
// Find the most common hue.
int hueCount = hues[0];
int hue = 0;
for (int i = 1; i < hues.length; i++) {
if (hues[i] > hueCount) {
hueCount = hues[i];
hue = i;
}
}
// Return the color to display.
float s = saturations[hue] / hueCount;
float b = brightnesses[hue] / hueCount;
return new HSBColor(hue, s, b);
}
public void processImageForHue(PApplet applet, IFAImage image, int hueRange,
int hueTolerance, boolean showHue) {
applet.colorMode(PApplet.HSB, (hueRange - 1));
image.loadPixels();
int numberOfPixels = image.getPixels().length;
HSBColor dominantHue = getDominantHue(applet, image, hueRange);
// Manipulate photo, grayscale any pixel that isn't close to that hue.
float lower = dominantHue.h - hueTolerance;
float upper = dominantHue.h + hueTolerance;
for (int i = 0; i < numberOfPixels; i++) {
int pixel = image.getPixel(i);
float hue = pixelColorHelper.hue(applet, pixel);
if (hueInRange(hue, hueRange, lower, upper) == showHue) {
float brightness = pixelColorHelper.brightness(applet, pixel);
image.setPixel(i, pixelColorHelper.color(applet, brightness));
}
}
image.updatePixels();
}
public void applyColorFilter(PApplet applet, IFAImage image, int minRed,
int minGreen, int minBlue, int colorRange) {
applet.colorMode(PApplet.RGB, colorRange);
image.loadPixels();
int numberOfPixels = image.getPixels().length;
for (int i = 0; i < numberOfPixels; i++) {
int pixel = image.getPixel(i);
float alpha = pixelColorHelper.alpha(applet, pixel);
float red = pixelColorHelper.red(applet, pixel);
float green = pixelColorHelper.green(applet, pixel);
float blue = pixelColorHelper.blue(applet, pixel);
red = (red >= minRed) ? red : 0;
green = (green >= minGreen) ? green : 0;
blue = (blue >= minBlue) ? blue : 0;
image.setPixel(i, pixelColorHelper.color(applet, red, green, blue, alpha));
}
}
}
我们不想用整个图像来测试它,因为我们想要知道属性和推理的图像。我们通过模拟图像并使其返回像素数组来近似实现此目的——在本例中为5个。这使我们能够验证行为是否符合预期。前面我们介绍了模拟对象的概念,在这里我们看到了它的使用。我们使用Mockito作为我们的模拟对象框架。
要创建模拟,我们对实例变量使用@Mock
注释,它将在运行时由MockitoJUnitRunner
模拟。
要存根(设置方法的行为),我们使用
when(mock.methodCall()).thenReturn(value)
要验证方法是否被调用,我们使用verify(mock.methodCall())
。
我们将在此处显示一些示例测试用例;如果您想查看其余内容,请访问500 Lines or Less GitHub存储库中此项目的源文件夹。
package com.catehuston.imagefilter.color;
/* ... Imports omitted ... */
@RunWith(MockitoJUnitRunner.class)
public class ColorHelperTest {
@Mock PApplet applet;
@Mock IFAImage image;
@Mock PixelColorHelper pixelColorHelper;
ColorHelper colorHelper;
private static final int px1 = 1000;
private static final int px2 = 1010;
private static final int px3 = 1030;
private static final int px4 = 1040;
private static final int px5 = 1050;
private static final int[] pixels = { px1, px2, px3, px4, px5 };
@Before public void setUp() throws Exception {
colorHelper = new ColorHelper(pixelColorHelper);
when(image.getPixels()).thenReturn(pixels);
setHsbValuesForPixel(0, px1, 30F, 5F, 10F);
setHsbValuesForPixel(1, px2, 20F, 6F, 11F);
setHsbValuesForPixel(2, px3, 30F, 7F, 12F);
setHsbValuesForPixel(3, px4, 50F, 8F, 13F);
setHsbValuesForPixel(4, px5, 30F, 9F, 14F);
}
private void setHsbValuesForPixel(int px, int color, float h, float s, float b) {
when(image.getPixel(px)).thenReturn(color);
when(pixelColorHelper.hue(applet, color)).thenReturn(h);
when(pixelColorHelper.saturation(applet, color)).thenReturn(s);
when(pixelColorHelper.brightness(applet, color)).thenReturn(b);
}
private void setRgbValuesForPixel(int px, int color, float r, float g, float b,
float alpha) {
when(image.getPixel(px)).thenReturn(color);
when(pixelColorHelper.red(applet, color)).thenReturn(r);
when(pixelColorHelper.green(applet, color)).thenReturn(g);
when(pixelColorHelper.blue(applet, color)).thenReturn(b);
when(pixelColorHelper.alpha(applet, color)).thenReturn(alpha);
}
@Test public void testHsbColorFromImage() {
HSBColor color = colorHelper.getDominantHue(applet, image, 100);
verify(image).loadPixels();
assertEquals(30F, color.h, 0);
assertEquals(7F, color.s, 0);
assertEquals(12F, color.b, 0);
}
@Test public void testProcessImageNoHue() {
when(pixelColorHelper.color(applet, 11F)).thenReturn(11);
when(pixelColorHelper.color(applet, 13F)).thenReturn(13);
colorHelper.processImageForHue(applet, image, 60, 2, false);
verify(applet).colorMode(PApplet.HSB, 59);
verify(image, times(2)).loadPixels();
verify(image).setPixel(1, 11);
verify(image).setPixel(3, 13);
}
@Test public void testApplyColorFilter() {
setRgbValuesForPixel(0, px1, 10F, 12F, 14F, 60F);
setRgbValuesForPixel(1, px2, 20F, 22F, 24F, 70F);
setRgbValuesForPixel(2, px3, 30F, 32F, 34F, 80F);
setRgbValuesForPixel(3, px4, 40F, 42F, 44F, 90F);
setRgbValuesForPixel(4, px5, 50F, 52F, 54F, 100F);
when(pixelColorHelper.color(applet, 0F, 0F, 0F, 60F)).thenReturn(5);
when(pixelColorHelper.color(applet, 20F, 0F, 0F, 70F)).thenReturn(15);
when(pixelColorHelper.color(applet, 30F, 32F, 0F, 80F)).thenReturn(25);
when(pixelColorHelper.color(applet, 40F, 42F, 44F, 90F)).thenReturn(35);
when(pixelColorHelper.color(applet, 50F, 52F, 54F, 100F)).thenReturn(45);
colorHelper.applyColorFilter(applet, image, 15, 25, 35, 100);
verify(applet).colorMode(PApplet.RGB, 100);
verify(image).loadPixels();
verify(image).setPixel(0, 5);
verify(image).setPixel(1, 15);
verify(image).setPixel(2, 25);
verify(image).setPixel(3, 35);
verify(image).setPixel(4, 45);
}
}
请注意,
MockitoJUnit
运行器。PApplet
、IFAImage
(专门为此目的创建)和ImageColorHelper
。@Test
2注释。如果您想忽略测试(例如,在调试期间),您可以添加注释@Ignore
。setup()
中,我们创建像素数组并让模拟图像始终返回它。set*ForPixel()
)。ImageState
保存图像的当前“状态”——图像本身以及将应用的设置和滤镜。我们将在此处省略ImageState
的完整实现,但我们将展示如何对其进行测试。您可以访问此项目的源存储库以查看完整详细信息。
package com.catehuston.imagefilter.model;
import processing.core.PApplet;
import com.catehuston.imagefilter.color.ColorHelper;
public class ImageState {
enum ColorMode {
COLOR_FILTER,
SHOW_DOMINANT_HUE,
HIDE_DOMINANT_HUE
}
private final ColorHelper colorHelper;
private IFAImage image;
private String filepath;
public static final int INITIAL_HUE_TOLERANCE = 5;
ColorMode colorModeState = ColorMode.COLOR_FILTER;
int blueFilter = 0;
int greenFilter = 0;
int hueTolerance = 0;
int redFilter = 0;
public ImageState(ColorHelper colorHelper) {
this.colorHelper = colorHelper;
image = new IFAImage();
hueTolerance = INITIAL_HUE_TOLERANCE;
}
/* ... getters & setters */
public void updateImage(PApplet applet, int hueRange, int rgbColorRange,
int imageMax) { ... }
public void processKeyPress(char key, int inc, int rgbColorRange,
int hueIncrement, int hueRange) { ... }
public void setUpImage(PApplet applet, int imageMax) { ... }
public void resetImage(PApplet applet, int imageMax) { ... }
// For testing purposes only.
protected void set(IFAImage image, ColorMode colorModeState,
int redFilter, int greenFilter, int blueFilter, int hueTolerance) { ... }
}
在这里,我们可以测试对于给定状态是否会发生适当的操作;字段是否被相应地递增和递减。
package com.catehuston.imagefilter.model;
/* ... Imports omitted ... */
@RunWith(MockitoJUnitRunner.class)
public class ImageStateTest {
@Mock PApplet applet;
@Mock ColorHelper colorHelper;
@Mock IFAImage image;
private ImageState imageState;
@Before public void setUp() throws Exception {
imageState = new ImageState(colorHelper);
}
private void assertState(ColorMode colorMode, int redFilter,
int greenFilter, int blueFilter, int hueTolerance) {
assertEquals(colorMode, imageState.getColorMode());
assertEquals(redFilter, imageState.redFilter());
assertEquals(greenFilter, imageState.greenFilter());
assertEquals(blueFilter, imageState.blueFilter());
assertEquals(hueTolerance, imageState.hueTolerance());
}
@Test public void testUpdateImageDominantHueHidden() {
imageState.setFilepath("filepath");
imageState.set(image, ColorMode.HIDE_DOMINANT_HUE, 5, 10, 15, 10);
imageState.updateImage(applet, 100, 100, 500);
verify(image).update(applet, "filepath");
verify(colorHelper).processImageForHue(applet, image, 100, 10, false);
verify(colorHelper).applyColorFilter(applet, image, 5, 10, 15, 100);
verify(image).updatePixels();
}
@Test public void testUpdateDominantHueShowing() {
imageState.setFilepath("filepath");
imageState.set(image, ColorMode.SHOW_DOMINANT_HUE, 5, 10, 15, 10);
imageState.updateImage(applet, 100, 100, 500);
verify(image).update(applet, "filepath");
verify(colorHelper).processImageForHue(applet, image, 100, 10, true);
verify(colorHelper).applyColorFilter(applet, image, 5, 10, 15, 100);
verify(image).updatePixels();
}
@Test public void testUpdateRGBOnly() {
imageState.setFilepath("filepath");
imageState.set(image, ColorMode.COLOR_FILTER, 5, 10, 15, 10);
imageState.updateImage(applet, 100, 100, 500);
verify(image).update(applet, "filepath");
verify(colorHelper, never()).processImageForHue(any(PApplet.class),
any(IFAImage.class), anyInt(), anyInt(), anyBoolean());
verify(colorHelper).applyColorFilter(applet, image, 5, 10, 15, 100);
verify(image).updatePixels();
}
@Test public void testKeyPress() {
imageState.processKeyPress('r', 5, 100, 2, 200);
assertState(ColorMode.COLOR_FILTER, 5, 0, 0, 5);
imageState.processKeyPress('e', 5, 100, 2, 200);
assertState(ColorMode.COLOR_FILTER, 0, 0, 0, 5);
imageState.processKeyPress('g', 5, 100, 2, 200);
assertState(ColorMode.COLOR_FILTER, 0, 5, 0, 5);
imageState.processKeyPress('f', 5, 100, 2, 200);
assertState(ColorMode.COLOR_FILTER, 0, 0, 0, 5);
imageState.processKeyPress('b', 5, 100, 2, 200);
assertState(ColorMode.COLOR_FILTER, 0, 0, 5, 5);
imageState.processKeyPress('v', 5, 100, 2, 200);
assertState(ColorMode.COLOR_FILTER, 0, 0, 0, 5);
imageState.processKeyPress('h', 5, 100, 2, 200);
assertState(ColorMode.HIDE_DOMINANT_HUE, 0, 0, 0, 5);
imageState.processKeyPress('i', 5, 100, 2, 200);
assertState(ColorMode.HIDE_DOMINANT_HUE, 0, 0, 0, 7);
imageState.processKeyPress('u', 5, 100, 2, 200);
assertState(ColorMode.HIDE_DOMINANT_HUE, 0, 0, 0, 5);
imageState.processKeyPress('h', 5, 100, 2, 200);
assertState(ColorMode.COLOR_FILTER, 0, 0, 0, 5);
imageState.processKeyPress('s', 5, 100, 2, 200);
assertState(ColorMode.SHOW_DOMINANT_HUE, 0, 0, 0, 5);
imageState.processKeyPress('s', 5, 100, 2, 200);
assertState(ColorMode.COLOR_FILTER, 0, 0, 0, 5);
// Random key should do nothing.
imageState.processKeyPress('z', 5, 100, 2, 200);
assertState(ColorMode.COLOR_FILTER, 0, 0, 0, 5);
}
@Test public void testSave() {
imageState.set(image, ColorMode.SHOW_DOMINANT_HUE, 5, 10, 15, 10);
imageState.setFilepath("filepath");
imageState.processKeyPress('w', 5, 100, 2, 200);
verify(image).save("filepath-new.png");
}
@Test public void testSetupImageLandscape() {
imageState.set(image, ColorMode.SHOW_DOMINANT_HUE, 5, 10, 15, 10);
when(image.getWidth()).thenReturn(20);
when(image.getHeight()).thenReturn(8);
imageState.setUpImage(applet, 10);
verify(image).update(applet, null);
verify(image).resize(10, 4);
}
@Test public void testSetupImagePortrait() {
imageState.set(image, ColorMode.SHOW_DOMINANT_HUE, 5, 10, 15, 10);
when(image.getWidth()).thenReturn(8);
when(image.getHeight()).thenReturn(20);
imageState.setUpImage(applet, 10);
verify(image).update(applet, null);
verify(image).resize(4, 10);
}
@Test public void testResetImage() {
imageState.set(image, ColorMode.SHOW_DOMINANT_HUE, 5, 10, 15, 10);
imageState.resetImage(applet, 10);
assertState(ColorMode.COLOR_FILTER, 0, 0, 0, 5);
}
}
请注意,
set
以进行测试,这有助于我们快速将被测系统置于特定状态。PApplet
、ColorHelper
和IFAImage
(专门为此目的创建)。assertState()
)来简化断言图像的状态。我使用EclEmma来测量Eclipse中的测试覆盖率。总体而言,应用程序的测试覆盖率为81%,ImageFilterApp
未覆盖任何部分,ImageState
为94.8%,ColorHelper
为100%。
这是所有内容捆绑在一起的地方,但我们希望这里尽可能少。应用程序很难进行单元测试(其中大部分是布局),但由于我们将应用程序的大部分功能推入了我们自己的经过测试的类中,因此我们能够确保自己重要的部分按预期工作。
我们设置应用程序的大小并进行布局。(这些内容通过运行应用程序并确保其外观正常来验证——无论测试覆盖率多么好,都不应跳过此步骤!)
package com.catehuston.imagefilter.app;
import java.io.File;
import processing.core.PApplet;
import com.catehuston.imagefilter.color.ColorHelper;
import com.catehuston.imagefilter.color.PixelColorHelper;
import com.catehuston.imagefilter.model.ImageState;
@SuppressWarnings("serial")
public class ImageFilterApp extends PApplet {
static final String INSTRUCTIONS = "...";
static final int FILTER_HEIGHT = 2;
static final int FILTER_INCREMENT = 5;
static final int HUE_INCREMENT = 2;
static final int HUE_RANGE = 100;
static final int IMAGE_MAX = 640;
static final int RGB_COLOR_RANGE = 100;
static final int SIDE_BAR_PADDING = 10;
static final int SIDE_BAR_WIDTH = RGB_COLOR_RANGE + 2 * SIDE_BAR_PADDING + 50;
private ImageState imageState;
boolean redrawImage = true;
@Override
public void setup() {
noLoop();
imageState = new ImageState(new ColorHelper(new PixelColorHelper()));
// Set up the view.
size(IMAGE_MAX + SIDE_BAR_WIDTH, IMAGE_MAX);
background(0);
chooseFile();
}
@Override
public void draw() {
// Draw image.
if (imageState.image().image() != null && redrawImage) {
background(0);
drawImage();
}
colorMode(RGB, RGB_COLOR_RANGE);
fill(0);
rect(IMAGE_MAX, 0, SIDE_BAR_WIDTH, IMAGE_MAX);
stroke(RGB_COLOR_RANGE);
line(IMAGE_MAX, 0, IMAGE_MAX, IMAGE_MAX);
// Draw red line
int x = IMAGE_MAX + SIDE_BAR_PADDING;
int y = 2 * SIDE_BAR_PADDING;
stroke(RGB_COLOR_RANGE, 0, 0);
line(x, y, x + RGB_COLOR_RANGE, y);
line(x + imageState.redFilter(), y - FILTER_HEIGHT,
x + imageState.redFilter(), y + FILTER_HEIGHT);
// Draw green line
y += 2 * SIDE_BAR_PADDING;
stroke(0, RGB_COLOR_RANGE, 0);
line(x, y, x + RGB_COLOR_RANGE, y);
line(x + imageState.greenFilter(), y - FILTER_HEIGHT,
x + imageState.greenFilter(), y + FILTER_HEIGHT);
// Draw blue line
y += 2 * SIDE_BAR_PADDING;
stroke(0, 0, RGB_COLOR_RANGE);
line(x, y, x + RGB_COLOR_RANGE, y);
line(x + imageState.blueFilter(), y - FILTER_HEIGHT,
x + imageState.blueFilter(), y + FILTER_HEIGHT);
// Draw white line.
y += 2 * SIDE_BAR_PADDING;
stroke(HUE_RANGE);
line(x, y, x + 100, y);
line(x + imageState.hueTolerance(), y - FILTER_HEIGHT,
x + imageState.hueTolerance(), y + FILTER_HEIGHT);
y += 4 * SIDE_BAR_PADDING;
fill(RGB_COLOR_RANGE);
text(INSTRUCTIONS, x, y);
updatePixels();
}
// Callback for selectInput(), has to be public to be found.
public void fileSelected(File file) {
if (file == null) {
println("User hit cancel.");
} else {
imageState.setFilepath(file.getAbsolutePath());
imageState.setUpImage(this, IMAGE_MAX);
redrawImage = true;
redraw();
}
}
private void drawImage() {
imageMode(CENTER);
imageState.updateImage(this, HUE_RANGE, RGB_COLOR_RANGE, IMAGE_MAX);
image(imageState.image().image(), IMAGE_MAX/2, IMAGE_MAX/2,
imageState.image().getWidth(), imageState.image().getHeight());
redrawImage = false;
}
@Override
public void keyPressed() {
switch(key) {
case 'c':
chooseFile();
break;
case 'p':
redrawImage = true;
break;
case ' ':
imageState.resetImage(this, IMAGE_MAX);
redrawImage = true;
break;
}
imageState.processKeyPress(key, FILTER_INCREMENT, RGB_COLOR_RANGE,
HUE_INCREMENT, HUE_RANGE);
redraw();
}
private void chooseFile() {
// Choose the file.
selectInput("Select a file to process:", "fileSelected");
}
}
请注意,
PApplet
。ImageState
中完成。fileSelected()
是selectInput()
的回调。static final
常量在顶部定义。在现实世界的编程中,我们花费大量时间进行生产工作。使事物看起来恰到好处。保持99.9%的正常运行时间。我们花更多时间处理极端情况而不是优化算法。
这些约束和要求对我们的用户很重要。但是,也有空间让我们摆脱这些约束和要求,进行游戏和探索。
最终,我决定将其移植到本地移动应用程序。Processing有一个Android库,但与许多移动开发人员一样,我选择首先使用iOS。我有多年的iOS经验,尽管我对CoreGraphics了解不多,但我认为即使我最初有这个想法,我也不会能够立即在iOS上构建它。该平台强迫我在RGB颜色空间中操作,并且难以从图像中提取像素(你好,C)。内存和等待是主要风险。
有一些令人兴奋的时刻,当它第一次工作时。当它第一次在我的设备上运行时……没有崩溃。当我将内存使用量优化了66%并将运行时间缩短了几秒钟时。还有一些长时间被锁在黑暗的房间里,间歇性地咒骂。
因为我有我的原型,所以我可以向我的商业伙伴和我们的设计师解释我的想法以及应用程序的功能。这意味着我深入了解了它的工作原理,并且只是将其在其他平台上运行良好。我知道我的目标是什么,所以在漫长的一天结束时,我努力与它作斗争,感觉我几乎没有什么可以展示的,我继续坚持……并在第二天早上迎来了令人兴奋的时刻和里程碑。
那么,如何在图像中找到主导颜色?有一个应用程序可以做到这一点:Show & Hide。