你有没有遇到过这种需求:给用户上传的头像打上马赛克,或者把证件照瞬间转成黑白灰度图。很多人第一反应是打开Photoshop,但当这个需求落到Java后端接口里,就必须自己写算法了。这篇文章我把图像处理里最经典的两个算法——灰度转换和马赛克——从零开始手写一遍,同时把背后绕不开的计算机字节与位运算彻底讲明白。读完全文,你能直接写出可用的图像工具类,也能在面试时把位运算相关的问题讲得头头是道。
我尽量用大白话拆解。不管你是刚入门的Java初学者,还是准备校招面试、背诵八股文的应届生,又或者是做后端想临时接一个图片处理需求的开发者,这篇文章都适合你。
1. 一张图片在Java眼里到底是什么
1.1 位图就是一张按顺序排好的颜色表格
在计算机内部,一张常见的jpg、png图片可以理解成一个巨大的二维网格:横向W个格子、纵向H个格子,每个格子就是一个像素(Pixel)。每个像素的颜色由红、绿、蓝三种光按比例混合而成,这就是常说的RGB三通道。每个通道的取值范围是0到255,正好可以用一个字节表示。
所以一个像素最少需要3个字节:
0, 0, 0是黑色255, 255, 255是白色255, 0, 0是纯红色0, 255, 0是纯绿色
假设一张图是1920x1080,总像素数大约207万,每个像素3个字节,纯像素数据就接近6MB。你可能会奇怪:为什么同一张图存成jpg只有几百KB,而内存里却这么大?因为jpg是有损压缩格式,内存里的位图(Bitmap)是不压缩的。这就是为什么做图像处理时要格外在意内存和性能。
在Java里,我们用BufferedImage这个类来表示位图。创建时可以指定类型:
java复制BufferedImage img = new BufferedImage(1920, 1080, BufferedImage.TYPE_INT_RGB);
TYPE_INT_RGB这个名字信息量很大:意思是"用一个int来承载一个像素的RGB"。
一个int是32位,低24位分别存放RGB三个通道。这意味着处理图像的时候,每个像素可以直接当成一个int来处理。而int是由字节组成的,字节又是由位组成的——位运算天然就是图像处理的底层语言。搞懂了这层关系,你就知道为什么网上所有图像处理教程都绕不开位运算了。
1.2 getRGB返回的神秘int,到底怎么拆开
先写一小段代码,把某个像素的RGB值读出来看看:
java复制BufferedImage img = ImageIO.read(new File("demo.jpg"));
int rgb = img.getRGB(100, 200);
System.out.printf("0x%08X%n", rgb);
输出结果类似:
code复制0xFF6A8C33
看到这个十六进制数,内存布局就清楚了。从高位到低位拆开:
- 第24到31位:Alpha通道(透明度),
0xFF表示不透明 - 第16到23位:R通道,
0x6A - 第8到15位:G通道,
0x8C - 第0到7位:B通道,
0x33
如果想把R取出来,自然就是右移16位,再掩掉高位的干扰数据:
java复制int r = (rgb >> 16) & 0xFF;
这里面的& 0xFF就是位运算里的"掩码"操作。很多初学者看到0xFF就发懵,我先不展开,等到第四章我会用一个非常阴间的Bug来证明:少了这一步,你会得到一堆莫名其妙的负数。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 灰度算法实战:三行代码背后藏了三层认知
2.1 并不是简单求平均:人眼对三种颜色的敏感度不同
灰度转换也叫去色,目标就是让图片只保留亮度信息,也就是让最终的R=G=B。很多人的第一反应是写:
java复制int gray = (r + g + b) / 3;
这个写法表面合理,实际效果却偏暗、偏"脏"。原因在于:人眼对绿色光最敏感,对蓝色光最不敏感。简单平均相当于把三个通道的权重看成完全一样,这和人类视觉系统的工作方式不符。
业内更常用的是加权平均公式。常见的两套标准:
- Rec.601(标清电视标准):
0.299R + 0.587G + 0.114B - Rec.709(高清电视标准):
0.2126R + 0.7152G + 0.0722B
可以明显看到,G的权重最高,B的权重最低。这就是"人眼对绿色最敏感"在数学上的落地。做图像处理如果完全不考虑视觉感知,出来的结果就会"看起来不太对"。
2.2 整数运算的灰度公式与完整代码
浮点公式直观但是稍慢。图像数据动辄上百万像素,每像素做三次浮点乘法,累计开销不小。所以更推荐把公式改写成整数运算:
java复制int gray = (r * 299 + g * 587 + b * 114) / 1000;
如果还想再极致一点,甚至可以避开除法,用位运算:
java复制int gray = (r * 77 + g * 151 + b * 28) >> 8;
这里的原理是:77 + 151 + 28 = 256,除以256就等价于右移8位。这种写法在早期嵌入式设备上很常见,现在写起来更多是为了装杯和面试讲解。
完整的灰度转换方法:
java复制public static BufferedImage toGray(BufferedImage src) {
int w = src.getWidth();
int h = src.getHeight();
BufferedImage dst = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
int rgb = src.getRGB(x, y);
int r = (rgb >> 16) & 0xFF;
int g = (rgb >> 8) & 0xFF;
int b = rgb & 0xFF;
int gray = (r * 299 + g * 587 + b * 114) / 1000;
int grayRgb = (gray << 16) | (gray << 8) | gray;
dst.setRGB(x, y, grayRgb);
}
}
return dst;
}
这段代码逻辑上完全正确,肉眼效果也不错。但它的性能其实一般,原因在第五章我会给出实测对比。学算法阶段,先保证逻辑正确,性能优化是后面的事。
2.3 面试常问:灰度算法的几种实现对比
如果面试官问"灰度转换有哪些方式",不要只回答加权平均。整理成表格会显得你吃得比较透:
| 方式 | 公式 | 特点 |
|---|---|---|
| 加权平均法 | 0.299R + 0.587G + 0.114B |
视觉最自然,通用首选 |
| 平均值法 | (R + G + B) / 3 |
代码简单,画面偏暗 |
| 最大值法 | max(R, G, B) |
图像偏亮,像开了高光 |
| 单一通道法 | 直接取R或G或B | 丢弃另外两个通道,带颜色倾向 |
很多老照片扫描件转灰度时,直接用单一绿色通道效果就还不错,就是因为绿色通道包含的亮度细节最多。这个知识点放在简历项目里很加分,因为它展示了你对原理的理解,而不仅仅是会用库函数。
3. 手写马赛克:区域降采样之外的三个隐蔽细节
3.1 马赛克的本质是"信息降采样"
马赛克不是"弄模糊",它的本质是把一片区域的像素合并成一个代表色。可以类比成:把一张高清大图用低分辨率重新保存,然后再强行放大回去,每个小方块变成一种颜色,画面就成了格子状。
这个代表色可以是区域的平均色、中心点色,甚至随机色。平均色最平滑,中心点色计算量最小,随机色噪感最强。实际项目里用平均色最多。
3.2 必须"先取样,后填充"
马赛克算法核心逻辑分两步:
- 对每个
size × size的区域,遍历区域内所有像素,把RGB分别求和取平均,算出一个代表色。 - 再次遍历这个区域,把所有像素的颜色设置为代表色。
这里有个非常隐蔽的坑:必须先算完整个区域的平均色,再回头填充。如果你边遍历边填充,后扫描到的像素会被前面已经填好的颜色污染,最终图片会出现从左上往右下蔓延的渐变感,像水彩晕开,完全不是马赛克的格子感。
这个坑我早期写的时候踩过,当时输出结果像极了做了个"油画滤镜",排查了好一会儿才发现是顺序问题。
下面是可以直接跑的完整实现:
java复制public static BufferedImage mosaic(BufferedImage src, int size) {
int w = src.getWidth();
int h = src.getHeight();
BufferedImage dst = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
int[] pixels = src.getRGB(0, 0, w, h, null, 0, w);
int[] out = new int[w * h];
for (int y = 0; y < h; y += size) {
for (int x = 0; x < w; x += size) {
int endX = Math.min(x + size, w);
int endY = Math.min(y + size, h);
long sumR = 0, sumG = 0, sumB = 0;
int count = 0;
for (int yy = y; yy < endY; yy++) {
for (int xx = x; xx < endX; xx++) {
int rgb = pixels[yy * w + xx];
sumR += (rgb >> 16) & 0xFF;
sumG += (rgb >> 8) & 0xFF;
sumB += rgb & 0xFF;
count++;
}
}
int avgR = (int) (sumR / count);
int avgG = (int) (sumG / count);
int avgB = (int) (sumB / count);
int avgRgb = (avgR << 16) | (avgG << 8) | avgB;
for (int yy = y; yy < endY; yy++) {
for (int xx = x; xx < endX; xx++) {
out[yy * w + xx] = avgRgb;
}
}
}
}
dst.setRGB(0, 0, w, h, out, 0, w);
return dst;
}
3.3 边界对齐、取色策略与马赛克强度
边界问题是初学者最容易忽略的:图片的宽高不一定是马赛克块大小的整数倍。比如1920x1080的图,用size=50,最后一行和最后一列会有一段不足50像素的区域。处理方式是用Math.min(x + size, w)把边界截断,确保不越界。
取色策略对最终效果影响也很大:
- 平均色:过渡平滑,噪点少,推荐
- 中心点色:只取区域中心那个像素的颜色,速度快,但碰到噪点区域会出现孤立色块
- 随机色:区域内随机抽一个像素的颜色,效果像加了噪点滤镜,不建议用于常规打码
马赛克强度用size控制。以我的经验:
size=8:轻微模糊,还能看出物体轮廓size=20:明显格子感,人脸五官基本丢失size=50:只剩色块,完全无法辨认原内容
实际生产环境里,人脸打码一般建议size不低于20,具体要看原图分辨率。像素越高,需要的块越大。
4. 从内存布局看位运算:为什么提取颜色必须 & 0xFF
4.1 一个int里到底存了哪几个字节
先看一张"内存布局图":一个int是32位,分成4个字节。对于TYPE_INT_RGB的像素,从高位到低位依次是:Alpha、R、G、B。
code复制0x12 34 56 78
A R G B
Java里的BufferedImage.getRGB()返回的int,其实就是打包好的4字节数据。你完全可以把它当成一个"颜色容器"来用。
这里有个Java特有的坑:Java是没有无符号byte的。byte的范围是-128到127,而不是0到255。当一个字节的十六进制值是0x80时,它在Java的byte里是负数-128。如果直接把这个byte赋值给int,Java会做符号扩展,得到0xFFFFFF80。原本只想要最低8位的数据,结果高位全被1填充了。
这就是为什么提取颜色必须写& 0xFF。0xFF是二进制8个1,与运算会保留低8位,砍掉高24位。
java复制int r = byteValue & 0xFF; // 正确
int r = byteValue; // 错误,可能是负数
4.2 &、|、<<、>>、>>>在图像处理中各司其职
图像处理里最常用的位运算就5个,完全可以一条条对上号:
&(按位与):做掩码,提取字节。想拿低8位就& 0xFF,想拿G通道就(rgb >> 8) & 0xFF。
|(按位或):做合并,把多个字节拼成一个int。它的好处是不会进位。比如0x12 | 0x34结果还是0x36,而不是加法那样可能产生进位。
<<(左移):把字节挪到指定的位置。R要放到第16到23位,就r << 16。
>>(右移):带符号右移,高位补符号位。在图像处理里,如果你只是想把高位挪到低位,后面还会紧跟& 0xFF,用>>没问题。
>>>(无符号右移):高位补0。如果你确定自己要处理的是无符号数据,用它最省心。
打包一个像素的完整操作:
java复制int repack(int a, int r, int g, int b) {
return (a << 24) | (r << 16) | (g << 8) | b;
}
这一段代码看起来简单,但初学者经常写出隐患:如果r是一个byte类型的负数,直接r << 16会先把r转成int并做符号扩展,导致高位出现一堆1,再或运算时就把其他通道污染了。正确做法是先转正:
java复制int repack(int a, int r, int g, int b) {
return ((a & 0xFF) << 24) | ((r & 0xFF) << 16) | ((g & 0xFF) << 8) | (b & 0xFF);
}
给r、g、b都加上& 0xFF,才能保证绝对干净。这个& 0xFF不能省,我见过很多次因为这个丢字节导致图片颜色完全错乱的案例。
4.3 位运算在图像处理中的高频考点
结合面试场景,和图像数据打包解包最相关的位运算知识还有这些:
- 判断一个数是奇数还是偶数:
(n & 1) == 0是偶数,反过来是奇数。 - 判断一个数是不是2的幂:
n > 0 && (n & (n - 1)) == 0。 - 交换两个数(不借助临时变量):
a ^= b; b ^= a; a ^= b;,原理是异或的自反性。 - 当除数是2的幂时,可以用
n & (m - 1)做取模替代。
这些技巧在图像处理里不是炫技,而是实打实的性能优化。前面灰度公式里>> 8替代除以256,就是典型用法。数据量一大,每个像素省一次除法,整体能省出可观的耗时。
5. 性能对比实测:逐像素API与批量数组处理差了多少
5.1 getRGB/setRGB慢在哪儿
前面给出的灰度转换代码里,逐像素调用了getRGB()和setRGB()。这段代码的缺点是:每个像素都触发两次方法调用,而BufferedImage在方法内部还要做边界检查,甚至可能触发图像数据从native缓冲到Java堆的拷贝。1920x1080的图有207万个像素,调用超过400万次方法,性能可想而知。
我自己在笔记本上跑过一次,结果大致是这样(不同机器差别明显,只给量级参考):
| 方案 | 灰度转换一张1920x1080图片的耗时 |
|---|---|
| 逐像素 getRGB/setRGB | 300 ~ 600ms |
| 一次getRGB拿到数组,处理完再setRGB写回 | 30 ~ 80ms |
| 直接操作DataBufferInt | 15 ~ 40ms |
| 多线程分块处理 | 5 ~ 15ms |
换算下来,逐像素方案比批量数组方案慢了一个数量级。这在图像处理里是完全不可接受的差距。
5.2 一次拿数组,处理完再写回
优化思路很简单:避免在循环里频繁调用API,把二维图像的读写变成一维数组的批量操作。
关键在于getRGB(int startX, int startY, int w, int h, int[] rgbArray, int offset, int scansize)这个方法。它可以把一整块区域的像素一次性拷到一个int数组里,处理完再setRGB()一次性写回。
灰度转换优化后的代码:
java复制public static BufferedImage toGrayFast(BufferedImage src) {
int w = src.getWidth();
int h = src.getHeight();
BufferedImage dst = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
int[] pixels = src.getRGB(0, 0, w, h, null, 0, w);
for (int i = 0; i < pixels.length; i++) {
int rgb = pixels[i];
int r = (rgb >> 16) & 0xFF;
int g = (rgb >> 8) & 0xFF;
int b = rgb & 0xFF;
int gray = (r * 299 + g * 587 + b * 114) / 1000;
pixels[i] = (gray << 16) | (gray << 8) | gray;
}
dst.setRGB(0, 0, w, h, pixels, 0, w);
return dst;
}
改动很小,但性能提升非常明显。这再次说明一个道理:代码写得再花哨,不如选对数据访问方式。
5.3 什么时候值得继续优化
如果你的图片分辨率特别高,比如服务器要处理几千万像素的航拍图,批量数组方案还是不够。这时可以再叠加两个优化:
- 多线程分块:把像素数组按行切成若干段,每段丢给一个线程处理,处理完再合并。注意切分行数据时要确保每个线程处理的像素不重叠,避免同步问题。
- 直接访问
DataBufferInt:把BufferedImage内部的数据缓冲区直接拿来当int数组用,省掉一次拷贝。但这种方式和BufferedImage的具体类型强绑定,代码侵入性高,建议只在明确知道图像类型时使用。
如果你真的到了需要OpenCV和GPU加速的规模,那已经不是手写算法能解决的场景。但在学习阶段,用纯Java数组处理几百万像素已经足够你深刻理解图像处理的原理了。
6. 把处理函数封装成工具类:从本地跑通到Web端接口
6.1 设计一个稳定的ImageProcessor
前面两段代码可以整合成一个工具类ImageProcessor,方法设计上我会坚持几个原则:
- 输入参数是
BufferedImage,不直接依赖文件路径,这样测试和集成都方便。 - 返回值是新的
BufferedImage,不修改原图,避免副作用。 - 默认参数(灰度用加权平均、马赛克size给一个合理值)让调用方不需要配置太多东西。
工具类骨架:
java复制public class ImageProcessor {
public static BufferedImage toGray(BufferedImage src) {
// 处理逻辑同上文
}
public static BufferedImage toGrayFast(BufferedImage src) {
// 批量数组版本
}
public static BufferedImage mosaic(BufferedImage src, int size) {
if (size <= 0) {
throw new IllegalArgumentException("马赛克块大小必须为正数");
}
// 处理逻辑同上文
}
}
注意mosaic方法里的参数校验。我之前就遇到过调用方传了size=0,导致死循环的情况。健壮性是一个工具类最基本的素质。
6.2 在Spring Boot里接收图片并返回处理结果
本地方法跑通后,接一个Web接口非常快。Spring Boot里接收上传图片、调用工具类处理、再以二进制流返回,逻辑非常清晰:
java复制@PostMapping("/image/process")
public ResponseEntity<byte[]> process(@RequestParam("file") MultipartFile file,
@RequestParam(value = "type", defaultValue = "gray") String type,
@RequestParam(value = "size", defaultValue = "20") int size) throws IOException {
BufferedImage image = ImageIO.read(file.getInputStream());
if (image == null) {
return ResponseEntity.badRequest().build();
}
BufferedImage result;
if ("gray".equalsIgnoreCase(type)) {
result = ImageProcessor.toGrayFast(image);
} else if ("mosaic".equalsIgnoreCase(type)) {
result = ImageProcessor.mosaic(image, size);
} else {
return ResponseEntity.badRequest().build();
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(result, "png", baos);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.IMAGE_PNG);
return new ResponseEntity<>(baos.toByteArray(), headers, HttpStatus.OK);
}
这里有一个非常值得注意的坑:ImageIO.write输出PNG可以保留透明通道,输出JPG则会把透明区域变成黑色。如果你的图片带有Alpha通道,输出JPG时一定要先处理成不透明背景,否则前端看到黑底会以为你处理出Bug了。
6.3 继续扩展的方向与我的一点体会
这几个基础算法跑通之后,往哪个方向扩展都很有价值:
- 加水印:用
Graphics2D在图片上绘制文字或Logo - 缩放图片:读取后直接用
Graphics2D.drawImage做缩放,注意选择插值方式 - 边缘检测:基于灰度图做Sobel算子卷积,可以提取轮廓
- 感知哈希:把图片缩小到8x8灰度,比较相邻像素亮暗,生成64位指纹,用于以图搜图
在我写这些基础算法之前,一度觉得图像处理必须上OpenCV,自己手写是重复造轮子。但等我真的把灰度、马赛克、位运算完整串了一遍之后,收益远超预期。后来我在另一个项目里写网络协议解析,又碰到了符号扩展问题,因为有了图像处理的积累,那次排错只花了几分钟。很多底层知识就是这样,你在某个领域彻底搞懂之后,换一个领域再遇到时,思路是完全通用的。
建议你也从最小实现开始,亲手把一张照片变灰,再亲手打上马赛克。你会发现,底层思维一旦打通,上层功能只是时间问题。
