这实际上是一个十分普遍的需求,在kotlin
中如何完成这一任务呢?其实这样简单的操作不需要任何三方库,只需要BufferedImage
的原生功能就能做到,约60行代码,首先我们准备一张封面这样的图,截选成正方形的(因为封面图必须是长条的所以我没传正方形的图),命名为zx.jpg
,放到resources
中,然后正常的读取:
读取图像
fun main() {
val url = System::javaClass.javaClass.getResource("/zx.jpg")!!
val jpg = ImageIO.read(url)
val width = jpg.width
val height = jpg.height
println("$width,$height")
}
能打印出宽高,则说明读取成功,代码很好理解,先找到resources
目录里zx,jpg
的url,这个是必须被找到的,不然后面全都无法进行,所以后面应该加上!!
,防止类型变成URL?
后面还要判空,然后用ImageIO
去读取图片。
处理图像
接下来就是核心的部分,根据宽高建立png
的空白BufferedImage
,然后把jpg
写到png
中:
val png = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB)
for (x in 0 until width) {
for (y in 0 until height) {
if (jpg.getRGB(x, y) == -1) {
png.setRGB(x, y, 0)
continue
}
png.setRGB(x, y, jpg.getRGB(x, y))
}
}
这里颜色模式要选TYPE_INT_ARGB
,因为要支持透明度。根据宽高循环所有像素自不必说,判断色彩如果是-1
,也就是纯白色,就可以直接设为Color(0, 0, 0, 0).rgb
,也就是0
,就是透明色啦。
接下来写出图像就可以了:
val file = File(File(url.toURI()).parent, "out.png")
file.createNewFile()
ImageIO.write(png, "PNG", file)
我为了方便对比图像直接写到同级目录了,要到build\resources\main
目录里去找这个out.png
,写出图像后可以看到如下效果:
可以看到人物的头发边和手边有很多白色区域,为什么没有被替换成透明呢?其实这就是所谓的“电子包浆”,是jpg的一大特色。因为jpg各种压缩导致图片会有很多浅色噪点,看着是纯白色,实际上是浅白,稍微带点深色的。
使用色彩范围
这时候只能试一试设置一个色彩范围了,这张图我将低于0x11000
的色彩区域的都设为透明,这个数值也可以称为tolerance(容差值),只需要修改下循环中的这个判断就好了:
for (x in 0 until width) {
for (y in 0 until height) {
if (jpg.getRGB(x, y) > -0x11000) {
png.setRGB(x, y, 0)
continue
}
png.setRGB(x, y, jpg.getRGB(x, y))
}
}
然后就能看到这样的效果:
但这样一看,更不行了,因为面部和头发中的一些亮点也很白,导致被一起替换透明了,而边缘的色彩还没完全笼盖进去,还有相当多的白边,这是两头不讨好了。
这时候不妨想一想,中间的部分和背景到底有哪些区别?人物的主体部分不会有白色?当然不能这样假设,其实唯一的区别就是背景与主体是阻隔开的。既然是从左往右,从上到下扫描图像,那么就这样想,如果左边上边都是透明了,那这块就也透明掉,不然就说明之间被人物阻隔开了,就不要去管。
为了方便阅读,我多加了一个分支来判断扫描区域是背景还是人物:
依照扫描区域替换
for (x in 0 until width) {
for (y in 0 until height) {
if (jpg.getRGB(x, y) > -0xc0000) {
if (x < 1 || y < 1
|| png.getRGB(x - 1, y) == 0
|| png.getRGB(x, y - 1) == 0
) {
png.setRGB(x, y, 0)
continue
}
}
png.setRGB(x, y, jpg.getRGB(x, y))
}
}
因为不用担心替换到人物区域了,我将容差值进一步提高到c0000
可以看到左上角的白边基本没有了,但左下角的还是有,这时候需要回溯扫描一下。
回溯扫描
这个过程并非一言两语就能说清楚,首先看看现在这样扫描的盲区在哪里,先画一个简单的像素图:
粉点是图像人物区域,蓝点是上述方法的扫描区域,灰点就是漏掉的两块灰色盲区,现在希望的是能将这些灰色盲区在回溯时能扫描进来。也就是原本是从左上角往右下角扫描的,但遇到一定的情况,需要再从右下辗转回到左上方,那么这个情况应该怎么判断呢?
首要的任务就是找到到需要回溯的点,如下图所示:
这4个绿色的像素点,就是需要检测出来的,它有什么特质呢?可以观察他们附近蓝点的扫描方向,一个是从左边扫到右边时,漏掉了上面的绿点。一个是从上面扫到下面时,漏掉了左边的绿点。所以只需要根据这个条件来找即可,也就是把循环中的条件改成这样:
if (jpg.getRGB(x, y) > -0xc0000) {
if (x < 1 || y < 1) {
png.setRGB(x, y, 0)
continue
} else if (png.getRGB(x - 1, y) == 0) {
png.setRGB(x, y, 0)
if (png.getRGB(x, y - 1) != 0) {
//向上边往回找
}
continue
} else if (png.getRGB(x, y - 1) == 0) {
png.setRGB(x, y, 0)
if (png.getRGB(x - 1, y) != 0) {
//向左边往回找
}
continue
}
}
png.setRGB(x, y, jpg.getRGB(x, y))
如代码所示,就是把以前的判断拆开,下一步就是继续往前找,如图所示:
到这一步已经判定出图上这个绿点是一个回溯口,以这个点切入往上一直往回找了,但如果接下来也一直都只是“向上边往回找”这一个方向,显然是不行的,因为棕色区域就没办法找到了,所以应当是除了反方向的另外三个方向都要找,也就是3个红点的位置,都要检查检查,这样才不会漏掉各种角落。
说到要按方向找,那就应当定义一个枚举了:
enum class Direction {
Left, Right, Up, Down
}
这个枚举就方便写逻辑时理清楚方向,不然直接用数值代表方向太难懂了,要用字符串表示也有点浪费空间。
然后就考虑定义一个回溯查找的方法,它应当是递归的,因为寻找没有可预期的尽头,要一直找到没有可以设置透明的蓝点为止,那么它最基本的需要一个坐标,有了坐标才知道当前找到哪了。此外还需要一个方向,不能再往回找已经回溯过的点位,不然就成瞎找了,四处乱找。
综上所述这个方法只需要避免重复去往回找来时的方向就行,这个方法要放到循环之前,这里有小提示,kotlin是可以方法里面套方法的,就像千层饼一层套一层。具体逻辑很易懂:
fun backtrack(ix: Int, iy: Int, direction: Direction) {
if (png.getRGB(ix, iy) == 0 || png.getRGB(ix, iy) <= -0xc0000) {
return
}
png.setRGB(ix, iy, 0)
if (direction != Direction.Left) {
backtrack(ix + 1, iy, Direction.Right)
}
if (direction != Direction.Right) {
backtrack(ix - 1, iy, Direction.Left)
}
if (direction != Direction.Up) {
backtrack(ix, iy + 1, Direction.Down)
}
if (direction != Direction.Down) {
backtrack(ix, iy - 1, Direction.Up)
}
}
可以说一目了然,第一个判断来看这个点位是不是命中的蓝点,也就是尚未被设置透明但应该被设为透明的点。过了这个判断就说明命中了,直接设为透明。然后看看是哪个方向找过来的,就不要找反方向的点位,其余3个点位全都要去找一找。
然后应用到上面的循环里就可以了:
if (x < 1 || y < 1) {
png.setRGB(x, y, 0)
continue
} else if (png.getRGB(x - 1, y) == 0) {
png.setRGB(x, y, 0)
if (png.getRGB(x, y - 1) != 0) {
backtrack(x, y - 1, Direction.Up)
}
continue
} else if (png.getRGB(x, y - 1) == 0) {
png.setRGB(x, y, 0)
if (png.getRGB(x - 1, y) != 0) {
backtrack(x - 1, y, Direction.Left)
}
continue
}
再运行一次,结果栈溢出了:
这个呀其实并非是因为使用了太多内存,而是因为递归的次数太多了,同时默认的单个线程栈空间又太小了,只有1MB
,这次个扫描又恰好超出了一点点,所以只需要加上VM参数:-Xss2m
,调为2M
就可以跑了,当然只要线程不多,现在的电脑内存条少说也是十几GB,给-Xss
调个128m
也不算过分,内存占用基本是看不出多大变化的。
在IDEA中如下图操作:
然后就能看到效果了,可以说是相当完美:
综合源代码
import java.awt.image.BufferedImage
import java.io.File
import javax.imageio.ImageIO
enum class Direction {
Left, Right, Up, Down
}
fun main() {
val url = System::javaClass.javaClass.getResource("/zx.jpg")!!
val jpg = ImageIO.read(url)
val width = jpg.width
val height = jpg.height
val png = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB)
val tolerance = -0xc0000
fun backtrack(ix: Int, iy: Int, direction: Direction) {
if (png.getRGB(ix, iy) == 0 || png.getRGB(ix, iy) <= tolerance) {
return
}
png.setRGB(ix, iy, 0)
if (direction != Direction.Left) {
backtrack(ix + 1, iy, Direction.Right)
}
if (direction != Direction.Right) {
backtrack(ix - 1, iy, Direction.Left)
}
if (direction != Direction.Up) {
backtrack(ix, iy + 1, Direction.Down)
}
if (direction != Direction.Down) {
backtrack(ix, iy - 1, Direction.Up)
}
}
for (x in 0 until width) {
for (y in 0 until height) {
if (jpg.getRGB(x, y) > tolerance) {
if (x < 1 || y < 1) {
png.setRGB(x, y, 0)
continue
} else if (png.getRGB(x - 1, y) == 0) {
png.setRGB(x, y, 0)
if (png.getRGB(x, y - 1) != 0) {
backtrack(x, y - 1, Direction.Up)
}
continue
} else if (png.getRGB(x, y - 1) == 0) {
png.setRGB(x, y, 0)
if (png.getRGB(x - 1, y) != 0) {
backtrack(x - 1, y, Direction.Left)
}
continue
}
}
png.setRGB(x, y, jpg.getRGB(x, y))
}
}
val file = File(File(url.toURI()).parent, "out.png")
file.createNewFile()
ImageIO.write(png, "PNG", file)
}
小结
这只是一个非常简单的小应用,因为想写的便于阅读和理解,所以有好多可以优化的地方。扫描过程其实非常粗糙,会扫到很多重复的点位,所以后面我会再写一篇文章谈一谈类似任务中更复杂的场景,会顺带再优化一下这个整体的过程。
本文写作于2023年5月31日并发布于lyrieek的掘金,于2023年7月17日进行修订发布于lyrieek的阿里云开发者社区。