沉寂了一段时间,继续 《500 lines or less》的学习。本文介绍一个Java语言实现的图片过滤器,依靠修改图片中的像素颜色来对图片进行简单的处理,其中使用了著名的图片编辑工具 Processing。
作者
Cate Huston。Cate 曾离开科技行业并在一年之后携带她的项目 Show&Hide 回归。她是 Ride 的移动工程总监,在国际上曾就移动开发和工程文化发表演讲,她还在技术演讲方面担任联合策展人,是 Glowforge 的顾问。Cate 主要居住在哥伦比亚,她曾在英国、澳大利亚、加拿大、中国和美国生活和工作,之前曾在谷歌担任过工程师,在 IBM 担任过实习生,还当过滑雪教练。Cate 的 Twitter 是 @catehstn。
一个绝妙的主意(也许没那么绝妙)
当我在中国旅行的时候,我经常看到一系列的四幅画,展示了同一个地方在不同的季节。冬天的白色,春天的浅色,夏天的绿色,秋天的红色和黄色是在视觉上区别季节的颜色。2011年前后,我有了一个我认为很绝妙的想法:我希望能够将一系列照片形象化为一系列颜色。我想它会显示旅行,以及四季的进展。
但我不知道如何从图像中求出主要颜色。我想把图像缩小到1x1的正方形,看看剩下什么,但这看起来像是作弊。我知道我想如何显示这些图像:在一个称为向日葵布局的布局中。这是布置圆圈最有效的方法。
我搁置了这个项目好几年,因为工作、生活、旅行、谈话而分心。最后,我找回了它,找出了如何计算主色,并完成了我的可视化。就在那时,我发现这个想法其实并不高明。这个过程并不像我希望的那样清晰,提取的主色通常不是最吸引人的色调,创建过程花费了太长时间(每张图像几秒钟),而且要用几百张图像来制作一些很酷的东西。
你可能会认为这会令人沮丧,但当我走到这一步的时候,我学到了很多以前没有的东西,关于色彩空间和像素操作,我已经开始制作那些很酷的局部彩色图像,你在伦敦明信片上看到的那种,有红色的公共汽车或电话亭,其他一切都是灰色的。
我使用了一个名为 Processing 的框架,因为我在开发编程课程时对它很熟悉,而且我知道它使创建可视化应用程序变得很容易。这是一个最初为艺术家设计的工具,所以它抽象了很多样板文件。它方便了我玩和实验。
大学和后来的工作,让我的时间充满了别人的想法和优先事项。完成这个项目的一个原因是学习如何腾出时间来实现自己的想法;我每周需要大约四个小时的良好的精神状态。因此,一个能让我更快行动的工具确实很有帮助,甚至是必要的,尽管它有自己的一系列问题,尤其是在编写测试时。
我觉得完整的测试对于验证项目是如何工作的特别重要,对于让一个经常会持续数周甚至数月的项目更容易地启动和恢复也非常重要。测试(和博客文章)形成了这个项目的文件。我可以让失败的测试记录下我还没有弄清楚的应该发生的事情,并且自信地做出改变,如果我改变了一些我已经忘记的关键的事情,测试会提醒我。
本文将介绍一些有关处理的细节,并通过颜色空间向你介绍,将图像分解为像素并对其进行操作,以及单元测试一些没有考虑到的东西。但我希望这也能促使你在最近没有花时间的想法上取得一些进展;即使你的想法和我的一样糟糕,你也可以在这个过程中做出一些很酷的东西,学到一些有趣的东西。
应用程序
本文将向你展示如何创建图像过滤器应用程序,你可以使用所创建的过滤器来操作数字图像。我们将使用 Processing,一种用 Java 构建的编程语言和开发环境。我们将介绍在 Processing 中设置应用程序、Processing 的一些特性、颜色表示的各个方面,以及如何创建颜色过滤器(模仿老式摄影中使用的内容)。我们还将创建一种特殊的过滤器,它只能通过数字方式完成:确定图像的主要色调,并显示或隐藏它,以创建怪异的部分彩色图像。
最后,我们将添加一个完整的测试组件,并介绍如何处理 Processing 在测试方面的一些限制。
背景
今天,我们可以拍摄一张照片,操作它,并在几秒钟内与我们所有的朋友分享。然而,在很久以前,这是一个花费数周时间的过程。
在过去,我们会拍照,然后当我们用了一整卷胶卷后,我们会把它拿进去冲洗(通常是在药店)。几天后,我们拿起冲洗过的照片,发现其中很多都有问题。手不够稳?我们当时没有注意到的随机出现的人或事?曝光过度?曝光不足?当然,到那时解决这个问题为时已晚。
大多数人不清除把胶卷变成照片的过程。光线是关键,你得小心胶卷。有一个过程,涉及黑暗的房间和化学品,有时在电影或电视上会出现这个过程。
但可能只有更少的人明白我们是如何从点击智能手机摄像头到出现在 Instagram (一个图片分享社交应用)上的图像的。其实有很多相似之处。
照片,古老方式
照片是由光在感光表面上的作用产生的。照相胶片上覆盖着卤化银晶体。(额外的图层用于创建彩色照片,为了简单起见,我们在这里只讲述黑白照片。)
对于使用胶卷的老式照片,光线命中到胶卷上你指向的东西,这些点上的晶体会根据光线的大小发生不同程度的变化。然后,显影过程将银盐转化为金属银,产生底片。底片将图像的明暗区域反转。一旦底片冲洗完毕,还有一系列的步骤来反转图像并打印出来。
照片,数码方式
使用智能手机或数码相机拍照时不再需要胶卷,而是一种叫做有源像素传感器的东西,它的工作原理类似。以前有银晶体的地方,现在有了像素——小正方形。(实际上,pixel 是“picture element”的缩写)数字图像是由像素组成的,分辨率越高,像素就越多。这就是为什么低分辨率图像被描述为“像素化”,你可以开始看到正方形。这些像素存储在一个数组中,每个数组“box”中的数字包含颜色。
在下面第一张图片中,我们看到一张高分辨率的照片,是在纽约的 MoMA 拍摄的一些放大的动物。第二张图片是同样的图像放大,但只有24 x 32像素。
看到它多么模糊了吗?我们称之为像素化,这意味着图像对于它所包含的像素来说太大了,所以正方形变得可见。在这里,我们可以使用它来更好地了解由彩色方块组成的图像。
这些像素看起来像什么?如果我们使用 Java 中的 Integer.toHexString
打印出中间部分像素的颜色(10,10 至 10,14),我们得到了十六进制颜色:
FFE8B1
FFFAC4
FFFCC3
FFFCC2
FFF5B7
十六进制颜色有六个字符长。前两个是红色值,中间两个是绿色值,后两个是蓝色值。有时有两个额外的字符是 alpha 值。在这种情况下,FFFAC4表示:
- 红色=FF(十六进制)=255(十进制)
- 绿色=FA(十六进制)=250(十进制)
- 蓝色=C4(十六进制)=196(十进制)
运行应用程序
在下面,我们有一张应用程序运行的图片。我知道,它一看就是开发人员设计的,但是我们只有 500 行 Java 代码可以使用,所以有些东西不得不忍受!你可以在右侧看到命令列表。我们可以做一些事情:
- 调整RGB过滤器。
- 调整“色调容差”。
- 设置主色调过滤器,以显示或隐藏主色调。
- 应用我们当前的设置(不可能每次按键都运行此设置)。
- 重置图像。
- 保存我们制作的图像。
Processing 使得创建一个小应用程序和进行图像处理变得简单;它有一个非常直观的焦点。我们将使用其 Java 版本,尽管 Processing 现在已经被移植到其他语言。
在本教程中,我通过在 eclipse 添加 core.jar 到我的构建路径来使用 Processing。如果需要,也可以使用Processing IDE,这样就不需要很多样板 Java 代码。如果你以后想把它移植到 Processing.js 并上传到网上,你需要用别的东西来替换文件选择器。
在项目的存储库中有详细的说明和屏幕截图。如果你已经熟悉 Eclipse 和 Java,那么你可能不需要它们。
Processing 基础
大小和颜色
我们不希望我们的应用程序是一个灰色的小窗口,所以我们首先要覆盖的两个基本方法是 setup()
和 draw()
。setup()
方法只在应用程序启动时调用,在这里我们可以设置应用程序窗口的大小。对于每个动画都会调用 draw()
方法,或者在某些操作之后可以通过调用 redraw()
触发。(如 Processing 文档中所述,不应显式调用 draw()
。)
Processing 的设计目的是很好地创建动画草图,但在我们的场景下,我们不需要动画[1],而是需要对按键做出响应。为了防止出现动画(这会影响性能),我们将在设置中调用 noLoop()
。这意味着无论何时调用 redraw()
, draw()
只会在 setup()
之后立即调用。
private static final int WIDTH = 360;
private static final int HEIGHT = 240;
public void setup() {
noLoop();
background(0);
}
public void settings() {
// Set up the view.
size(WIDTH, HEIGHT);
}
public void draw() {
background(0);
}
这些没有做很多事情,但是试着再次运行这个应用程序,调整宽度和高度的常量,可以看到不同的大小。
background(0)
指定黑色背景。尝试更改传递给 background()
的数字,看看会发生什么。它是一个 alpha 值,因此如果只传入一个数字,它总是灰色调的。或者,你可以调用 background(int r, int g, int b)
。
PImage
PImage 对象是 Processing 中表示图像的对象。我们会经常使用它,所以我们首先阅读一下它的文档。它有三个字段以及一些我们将会使用的方法。
字段 | 说明 |
---|---|
pixels[] |
包含图像中每个像素的颜色的数组 |
width |
图像宽度(像素) |
height |
图像高度(像素) |
方法 | 说明 |
---|---|
loadPixels |
将图像的像素数据加载到其pixels[] 数组中 |
updatePixels |
使用其pixels[] 数组中的数据更新图像 |
resize |
将图像的大小更改为新的宽度和高度 |
get |
读取任何像素的颜色或获取像素的矩形 |
set |
将颜色写入任何像素或将图像写入另一个图像 |
save |
将图像保存到 TIFF、TARGA、PNG 或 JPEG 文件 |
文件选择器
Processing 处理大多数文件选择过程;我们只需要调用selectInput()
,并实现回调(必须是公共方法)。
对于熟悉 Java 的人来说,这可能看起来很奇怪;监听器或 lambda 表达式可能更有意义。然而,随着 Processing 被发展成为艺术家的一种工具,这些东西在很大程度上被语言抽象掉了,以保持它的简易性。这是设计师们做出的一个选择:将简单性和普及性置于能力和灵活性之上。如果你使用精简的处理编辑器,而不是在 Eclipse 中作为库进行处理,那么你甚至不需要定义类名。
其他语言设计师有不同的目标受众,他们应该做出不同的选择。例如,在 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
超类中,并包含最后按下的键。)
编写测试
这个应用程序还有很多事情没做,但是我们已经可以看到很多地方出了问题;例如,用按键触发错误的动作。当我们增加复杂性时,我们会增发现更多的潜在问题,例如错误地更新图像状态,或者在应用过滤器后计算错误的像素颜色。我也喜欢(有些人觉得奇怪)编写单元测试。虽然有些人认为测试会延迟代码的签入,但我将测试视为我的首要调试工具,并将其视为深入了解代码中发生了什么的机会。
我喜欢处理,但它的设计是为了创建可视化应用程序,在这个领域,单元测试可能并不重要。很明显,它不是为可测试性而编写的;事实上,它的编写方式使得它不易测试。部分原因是它隐藏了复杂性,而其中一些隐藏的复杂性在编写单元测试时非常有用。静态方法和 final
方法的使用使得 mock
(记录交互并允许你伪造系统的一部分以验证另一部分是否正常工作的对象)的使用更加困难,mock
依赖于子类的能力。
我们可能怀着做测试驱动开发(TDD)和实现完美的测试覆盖率的良好意愿启动一个新项目,但实际上我们通常在查看由各种各样的人编写的各种代码,并试图找出它应该做什么,以及它如何出错以及为什么出错。也许我们写不出完美的测试,但是写测试可以帮助我们了解情况,记录正在发生的事情并向前迈进。
我们创造了“接缝(seam)”,使我们能够从杂乱无章的碎片中分离出一些东西,并对其进行部分验证。为此,我们有时会创建可以 mock 的包装器类。这些类所做的仅仅是保存一组相似的方法,或者转发对另一个不能被 mock 的对象的调用(由于 final
或 static
方法),因此它们编写起来非常枯燥,但却是创建接缝和使代码可测试的关键。
我使用 JUnit 进行测试,因为我使用 Java 作为库进行处理。我用 Mockito 来进行 mock。你可以下载 Mockito 并以添加 core.jar
的方式将JAR添加到构建路径中。我创建了两个辅助类,使模拟和测试应用程序成为可能(否则我们无法测试涉及 PImage
或 PApplet
方法的行为)。
IFAImage
是 PImage
的简单包装器。PixelColorHelper
是围绕 applet
像素颜色方法的包装器。这些包装器调用 final
和 static
方法,但是调用方法本身既不是 final
也不是 static
。这允许对它们进行 Mock。这些都有意做成了轻量级的,我们本可以更进一步,但这足以解决使用处理final
和 static
方法时的主要可测试性问题。毕竟,我们的目标是制作一个应用程序,而不是一个用于 Processing 的单元测试框架!
制作自己的过滤器
RGB 过滤器
在我们开始编写更复杂的像素处理之前,我们可以先做一个简短的练习,让我们能够轻松地进行像素处理。我们将创建标准(红、绿、蓝)滤色器,使我们能够创建与在相机镜头上放置彩色平板相同的效果,只允许符合条件的红(或者绿、蓝)光通过。
通过对下面的图像拍摄于春季法兰克福之旅)应用不同的过滤器,会产生季节不同的感觉。(还记得我们之前想象的四季画吗?)看看应用红色过滤器时,树会变绿多少。
[图片上传失败...(image-58112f-1635688473099)]
通过对一幅图像应用不同的 RGB 滤镜,我们可以使它看起来像是季节不同,这取决于滤除哪些颜色和强调哪些颜色。(还记得我们之前想象的四季画吗?)
我们需要怎么做?
- 设置过滤器。(你可以像前面的图片中那样组合红色、绿色和蓝色滤镜;我在这些示例中没有这样做,以便效果更清晰。)
- 对于图像中的每个像素,请检查其 RGB 值。
- 如果红色小于红色过滤器的值,则将红色设置为零。
- 如果绿色小于绿色过滤器的值,则将绿色设置为零。
- 如果蓝色小于蓝色过滤器的值,则将蓝色设置为零。
- 所有这些颜色不足的像素都将会是黑色。
虽然我们的图像是二维的,但像素是从左上到右、从上到下的一维数组。4x4图像的数组索引如下所示:
0 1 2 3
4 5 6 7
8 9 10 11
12 13 14 15
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 使得处理任何你想要的颜色空间都非常容易,但是你需要知道选择哪一个,所以理解它们是如何工作的是很重要的。
RGB 颜色
大多数程序员都熟悉的颜色空间是 RGBA:红、绿、蓝和 alpha;这就是我们在上面使用的颜色空间。在十六进制中,前两位是红色的值,中间两位是蓝色的,后两位是绿色的,最后两位(如果有的话)是 alph a值。数值范围从16进制的00(10进制的0)到FF(10进制的255)。alpha 表示不透明度,其中 0 表示透明,100% 表示不透明。
HSB 或 HSV颜色
这种颜色空间并不像 RGB 那样广为人知。第一个数字表示色调,第二个数字表示饱和度(颜色的强度),第三个数字表示亮度。HSB颜色空间可以用一个圆锥体来表示:色调是圆锥体周围的位置,饱和度是圆锥体到中心的距离,亮度是圆锥体的高度(0亮度是黑色)。
从图像中提取色调
现在我们已经习惯了像素操作,让我们做一些我们只能用数字方式做的事情。在数字上,我们可以用一种不太统一的方式来处理图像。
当我浏览我的图片时,我能看到正在出现的主题。日落时分,我在香港的一艘船上拍摄了一系列夜间照片,还有朝鲜的灰色,巴厘岛郁郁葱葱的绿色,冰岛冬天的冰白色和淡蓝色。我们能不能照张相,把主导整个场景的主要色彩去掉?
使用 HSB 颜色空间对后续工作帮助更大——当我们确定主颜色是什么时,我们对色调感兴趣。使用 RGB 值也可以做到这一点,但更困难(我们需要比较三个值),而且它对黑暗更敏感。我们可以使用 colorMode 切换到 HSB 颜色空间。
选定这个颜色空间后,就比使用 RGB 简单多了。我们需要找到每个像素的色调,找出哪一个是最“流行”的。我们不需要太精确,将非常相似的色调组合在一起即可,我们可以使用两种策略来处理这个问题。
首先,我们将小数四舍五入,返回整数,因为这使得确定我们将每个像素放入哪个“桶”变得很简单。其次,我们可以改变色调的范围。如果我们回想一下上面的圆锥体表示,我们可能会认为色调具有360度(就像一个圆)。默认情况下,处理使用255,这与典型的RGB相同(255在十六进制中是FF)。我们使用的范围越高,图片中的色彩就越明显。使用较小的范围可以让我们将相似的色调组合在一起。使用360度范围,我们不太可能分辨出色相224和225之间的区别,因为差别非常小。如果我们让范围是它的三分之一,120,四舍五入后,这两个颜色都变成75。
我们可以使用 colorMode 来改变色调的范围。如果我们调用 colorMode(HSB, 120)
,我们的色调检测只有我们使用 255 范围一半的精确度。我们还知道我们的色调将分为120个“桶”,所以我们可以简单地浏览我们的图像,获得像素的色调,并在数组中添加一个相应的计数。这将是 复杂度,其中 是像素的数量,因为它需要对每个像素进行操作。
for(int px in pixels) {
int hue = Math.round(hue(px));
hues[hue]++;
}
最后我们可以把这个颜色打印到屏幕上,或者显示在图片旁边。
最后我们可以把这个颜色打印到屏幕上,或者显示在图片旁边。一旦我们提取了“主色调”,我们可以选择在图像中显示或隐藏它。我们可以用不同的容差(我们可以接受的范围)来显示主色调。不属于此范围的像素可以通过设置基于亮度的值更改为灰度。下图显示使用 240 范围确定的主色调,并具有不同的容差。容差是指将出现最多的色调任意一边组合在一起的数量。
一旦我们提取了“主”色调,我们可以选择在图像中显示或隐藏它。我们可以用不同的容差(我们可以接受的范围)来显示主色调。不属于此范围的像素可以通过设置基于亮度的值更改为灰度。或者,我们可以通过将主色调像素的颜色设置为灰度来隐藏主色调,而让其他像素保持原样。或者,我们可以隐藏主色调。这些图像并排排列:原始图像在中间,左边的主色调(路径的棕色)显示出来,右边的主色调被隐藏(范围320,容差20)。
每个图像都需要一次双遍扫描(每个像素看两次),因此对于像素数量较大的图像,可能需要花费相当长的时间。
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。
为了达到锐化、模糊或深褐色的效果,我们应用了矩阵。对于图像的每个像素,取当前像素的颜色值或其相邻像素的颜色值与滤波器矩阵的对应值的乘积之和。有一些特定值的特殊矩阵可以锐化图像。有一些特定值的特殊矩阵可以锐化图像。
体系结构
这个应用程序有三个主要的组件。
应用程序
应用程序只包含一个文件:ImageFilterApp.java
文件.。这扩展了 PApplet
(处理应用程序超类)并处理布局、用户交互等。这个类是最难测试的,所以我们希望它尽可能小。
模型
模型由三个文件组成:HSBColor.java
文件是简单的 HSB颜色容器(由色调、饱和度和亮度组成)。IFAImage
是 PImage
的可测试性包装器。(PImage
包含许多无法模拟的 final
方法。)最后,ImageState.java
是一个对象,它描述图像的状态:应该应用什么级别的过滤器,以及哪些过滤器,并处理加载图像。(注意:每当调低滤色器时,以及每当重新计算主色调时,都需要重新加载图像。为了清晰起见,我们每次处理图像时重新加载。)
颜色
颜色由两个文件组成:ColorHelper.java
文件是进行所有图像处理和过滤的地方,以及 PixelColorHelper.java
文件抽象出像素颜色的 final PApplet
方法,以便于测试。
[图片上传失败...(image-304b01-1635688473099)]
包装类和测试
上面简单提到过,有两个包装类( IFAImage
和 PixelColorHelper
)为可测试性包装了库方法。这是因为在 Java 中,关键字 “final” 表示一个不能被子类覆盖或隐藏的方法,这意味着它们不能被 mock。
PixelColorHelper
在 applet 上包装方法。这意味着我们需要将 applet 传递给每个方法调用。(或者,我们可以将其作为一个字段,并在初始化时设置它。)
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 和相关测试
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。这允许我们验证行为是否如预期的那样。前面我们讨论了mock 对象的概念,这里我们看到了它们的用法。我们使用 Mockito 作为我们的模拟对象框架。
为了创建一个 mock,我们在一个实例变量上使用 @mock
注释,MockitoJUnitRunner
将在运行时对它进行模拟。
要设置方法的行为,我们使用:
when(mock.methodCall()).thenReturn(value)
为了验证一个方法被调用,我们使用 verify(mock.methodCall())
。
我们将在这里展示一些测试用例;如果您想了解其余内容,请访问 GitHub 存储库 500 Lines or Less 中这个项目的源文件夹。
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()
c中,我们创建像素数组,并让模拟图像始终返回它。 - 辅助方法可以更容易地设置周期性任务的期望值(例如,
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);
}
}
请注意:
- 我们公开了一个用于测试的受保护的初始化方法集,它可以帮助我们快速地将被测试的系统置于特定的状态。
- 我们模拟
PApplet
、ColorHelper
和IFAImage
(专门为此而创建)。 - 这次我们使用一个辅助方法(
assertState()
)来简化对图像状态的断言。
测量测试覆盖率
我使用 EclEmma 来度量 Eclipse 中的测试覆盖率。总的来说,我们的应用程序有81%的测试覆盖率,ImageFilterApp
没有覆盖,ImageState
覆盖率为94.8%,ColorHelper
覆盖率为100%。
ImageFilterApp
这是所有东西都联系在一起的类,但我们希望这里尽可能少。应用程序很难进行单元测试(大部分是布局),但因为我们已经将应用程序的很多功能推到我们自己测试过的类中,我们能够确保重要的部分按预期工作。
我们设置应用程序的大小,并进行布局。(这些都是通过运行应用程序来验证的,并确保它看起来没问题——无论测试覆盖率有多好,这一步都不应该被跳过!)
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()
的回调。 - 静态 final 常量定义在顶部。
原型的价值
在现实编程中,我们花了很多时间在产品化工作上。让事情看起来如此。保持 99.9% 的正常运行时间。我们花在角落案例上的时间比改进算法要多。
这些约束和需求对我们的用户很重要。然而,也有空间让我们从它们中解放出来,去玩和探索。
最终,我决定将其移植到一款原生移动应用上。Processing 有一个 Android 库,但和许多手机开发者一样,我选择了先去 iOS。我有多年的 iOS 经验,虽然我很少涉及 CoreGraphics,,但我认为即使我一开始就有了这个想法,我也不可能直接在 iOS 上创建它。平台迫使我在 RGB 颜色空间中操作,并且很难从图像中提取像素(你好,C)。内存和等待是一个主要的风险。
当它第一次成功时是一个令人兴奋的时刻。当它第一次在我的设备上运行并且没有崩溃时。当我优化了 66% 的内存使用并缩短了几秒的运行时间。其中有很长一段时间被锁在一间黑屋子里,而我只能断断续续咒骂。
因为我有了我的原型,我可以向我的商业伙伴和设计师解释我的想法和应用程序的功能。这意味着我深刻理解了它的工作原理,问题是如何让它在另一个平台上很好地工作。我知道自己的目标是什么,所以在漫长的一天结束后,我放弃了与之斗争,感觉自己没什么可表现的,我继续努力,并在第二天早上达到了一个令人振奋的时刻和里程碑式的时刻。
那么,如何在图像中找到主色调呢?有一个应用程序可以做到这一点:Show & Hide。
-
如果我们想创建一个动画草图,我们不会调用
noLoop()
(或者,如果我们想稍后开始动画,我们会调用loop()
)。动画的频率由frameRate()
决定。 ↩ -
从 JUnit4 开始,测试中的方法名不必以
test
开头,但习惯很难打破。 ↩